Add force rerun recovery and polish CLI startup
This commit is contained in:
@@ -3,7 +3,7 @@ from datetime import datetime, timedelta, timezone
|
||||
from typing import Optional
|
||||
|
||||
from fastapi import APIRouter, Depends, HTTPException, Query
|
||||
from sqlalchemy import func, select
|
||||
from sqlalchemy import func, select, text
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.core.time import to_iso8601_utc
|
||||
@@ -11,10 +11,16 @@ from app.core.security import get_current_user
|
||||
from app.core.data_sources import get_data_sources_config
|
||||
from app.db.session import get_db
|
||||
from app.models.collected_data import CollectedData
|
||||
from app.models.data_snapshot import DataSnapshot
|
||||
from app.models.datasource import DataSource
|
||||
from app.models.task import CollectionTask
|
||||
from app.models.user import User
|
||||
from app.services.scheduler import get_latest_task_id_for_datasource, run_collector_now, sync_datasource_job
|
||||
from app.services.scheduler import (
|
||||
cancel_running_collector_now,
|
||||
get_latest_task_id_for_datasource,
|
||||
run_collector_now,
|
||||
sync_datasource_job,
|
||||
)
|
||||
|
||||
router = APIRouter()
|
||||
STALE_RUNNING_TASK_TIMEOUT_MINUTES = 90
|
||||
@@ -100,6 +106,70 @@ async def get_running_task(db: AsyncSession, datasource_id: int) -> Optional[Col
|
||||
return None
|
||||
|
||||
|
||||
async def rollback_orphaned_running_task(
|
||||
db: AsyncSession,
|
||||
datasource: DataSource,
|
||||
running_task: CollectionTask,
|
||||
) -> None:
|
||||
snapshot_result = await db.execute(
|
||||
select(DataSnapshot)
|
||||
.where(
|
||||
DataSnapshot.datasource_id == datasource.id,
|
||||
DataSnapshot.task_id == running_task.id,
|
||||
)
|
||||
.order_by(DataSnapshot.id.desc())
|
||||
.limit(1)
|
||||
)
|
||||
snapshot = snapshot_result.scalar_one_or_none()
|
||||
|
||||
await db.execute(CollectedData.__table__.delete().where(CollectedData.task_id == running_task.id))
|
||||
|
||||
await db.execute(
|
||||
text(
|
||||
"""
|
||||
UPDATE collected_data
|
||||
SET is_current = FALSE
|
||||
WHERE source = :source
|
||||
"""
|
||||
),
|
||||
{"source": datasource.source},
|
||||
)
|
||||
|
||||
if snapshot is not None:
|
||||
snapshot.status = "cancelled"
|
||||
snapshot.is_current = False
|
||||
snapshot.completed_at = datetime.now(timezone.utc)
|
||||
summary = dict(snapshot.summary or {})
|
||||
summary["rollback"] = True
|
||||
summary["rollback_reason"] = "orphaned_running_task_after_backend_restart"
|
||||
snapshot.summary = summary
|
||||
|
||||
if snapshot.parent_snapshot_id is not None:
|
||||
parent_snapshot = await db.get(DataSnapshot, snapshot.parent_snapshot_id)
|
||||
if parent_snapshot:
|
||||
parent_snapshot.is_current = True
|
||||
await db.execute(
|
||||
text(
|
||||
"""
|
||||
UPDATE collected_data
|
||||
SET is_current = TRUE
|
||||
WHERE snapshot_id = :snapshot_id
|
||||
"""
|
||||
),
|
||||
{"snapshot_id": snapshot.parent_snapshot_id},
|
||||
)
|
||||
|
||||
running_task.status = "cancelled"
|
||||
running_task.phase = "cancelled"
|
||||
running_task.completed_at = datetime.now(timezone.utc)
|
||||
existing_error = (running_task.error_message or "").strip()
|
||||
cancel_reason = "Cancelled after backend restart because the running task handle was lost; incomplete writes rolled back"
|
||||
running_task.error_message = f"{existing_error}\n{cancel_reason}".strip() if existing_error else cancel_reason
|
||||
datasource.last_status = "cancelled"
|
||||
datasource.last_run_at = datetime.now(timezone.utc)
|
||||
await db.commit()
|
||||
|
||||
|
||||
@router.get("")
|
||||
async def list_datasources(
|
||||
module: Optional[str] = None,
|
||||
@@ -346,6 +416,7 @@ async def get_datasource_stats(
|
||||
@router.post("/{source_id}/trigger")
|
||||
async def trigger_datasource(
|
||||
source_id: str,
|
||||
force: bool = Query(False),
|
||||
current_user: User = Depends(get_current_user),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
@@ -356,6 +427,26 @@ async def trigger_datasource(
|
||||
if not datasource.is_active:
|
||||
raise HTTPException(status_code=400, detail="Data source is disabled")
|
||||
|
||||
running_task = await get_running_task(db, datasource.id)
|
||||
if running_task is not None and not force:
|
||||
raise HTTPException(
|
||||
status_code=409,
|
||||
detail={
|
||||
"reason": "running_task_in_progress",
|
||||
"message": "当前采集任务尚未完成,重新触发会丢失本次未完成进度。是否强制重新采集?",
|
||||
"task_id": running_task.id,
|
||||
"phase": running_task.phase,
|
||||
"progress": running_task.progress,
|
||||
"records_processed": running_task.records_processed,
|
||||
"total_records": running_task.total_records,
|
||||
},
|
||||
)
|
||||
|
||||
if running_task is not None and force:
|
||||
cancelled = await cancel_running_collector_now(datasource.source)
|
||||
if not cancelled:
|
||||
await rollback_orphaned_running_task(db, datasource, running_task)
|
||||
|
||||
previous_task_id = await get_latest_task_id_for_datasource(datasource.id)
|
||||
success = run_collector_now(datasource.source)
|
||||
if not success:
|
||||
@@ -375,6 +466,7 @@ async def trigger_datasource(
|
||||
"source_id": datasource.id,
|
||||
"task_id": task_id,
|
||||
"collector_name": datasource.source,
|
||||
"force": force,
|
||||
"message": f"Collector '{datasource.source}' has been triggered",
|
||||
}
|
||||
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
"""Base collector class for all data sources"""
|
||||
|
||||
import asyncio
|
||||
from abc import ABC, abstractmethod
|
||||
from typing import Dict, List, Any, Optional
|
||||
from datetime import UTC, datetime
|
||||
@@ -166,6 +167,59 @@ class BaseCollector(ABC):
|
||||
await db.commit()
|
||||
return snapshot.id
|
||||
|
||||
async def _rollback_incomplete_run(
|
||||
self,
|
||||
db: AsyncSession,
|
||||
*,
|
||||
task_id: int,
|
||||
snapshot_id: Optional[int],
|
||||
reason: str,
|
||||
) -> None:
|
||||
from app.models.collected_data import CollectedData
|
||||
from app.models.data_snapshot import DataSnapshot
|
||||
|
||||
await db.execute(CollectedData.__table__.delete().where(CollectedData.task_id == task_id))
|
||||
|
||||
parent_snapshot_id: Optional[int] = None
|
||||
if snapshot_id is not None:
|
||||
snapshot = await db.get(DataSnapshot, snapshot_id)
|
||||
if snapshot:
|
||||
parent_snapshot_id = snapshot.parent_snapshot_id
|
||||
snapshot.status = "cancelled"
|
||||
snapshot.is_current = False
|
||||
snapshot.completed_at = datetime.now(UTC)
|
||||
summary = dict(snapshot.summary or {})
|
||||
summary["rollback"] = True
|
||||
summary["rollback_reason"] = reason
|
||||
snapshot.summary = summary
|
||||
|
||||
await db.execute(
|
||||
text(
|
||||
"""
|
||||
UPDATE collected_data
|
||||
SET is_current = FALSE
|
||||
WHERE source = :source
|
||||
"""
|
||||
),
|
||||
{"source": self.name},
|
||||
)
|
||||
|
||||
if parent_snapshot_id is not None:
|
||||
parent_snapshot = await db.get(DataSnapshot, parent_snapshot_id)
|
||||
if parent_snapshot:
|
||||
parent_snapshot.is_current = True
|
||||
|
||||
await db.execute(
|
||||
text(
|
||||
"""
|
||||
UPDATE collected_data
|
||||
SET is_current = TRUE
|
||||
WHERE snapshot_id = :snapshot_id
|
||||
"""
|
||||
),
|
||||
{"snapshot_id": parent_snapshot_id},
|
||||
)
|
||||
|
||||
async def run(self, db: AsyncSession) -> Dict[str, Any]:
|
||||
"""Full pipeline: fetch -> transform -> save"""
|
||||
from app.services.collectors.registry import collector_registry
|
||||
@@ -227,6 +281,21 @@ class BaseCollector(ABC):
|
||||
"records_processed": records_count,
|
||||
"execution_time_seconds": (datetime.now(UTC) - start_time).total_seconds(),
|
||||
}
|
||||
except asyncio.CancelledError:
|
||||
task.status = "cancelled"
|
||||
task.phase = "cancelled"
|
||||
task.error_message = "Collection cancelled by operator and rolled back"
|
||||
task.completed_at = datetime.now(UTC)
|
||||
if snapshot_id is not None:
|
||||
await self._rollback_incomplete_run(
|
||||
db,
|
||||
task_id=task_id,
|
||||
snapshot_id=snapshot_id,
|
||||
reason="cancelled_by_operator",
|
||||
)
|
||||
await db.commit()
|
||||
await self._publish_task_update(force=True)
|
||||
raise
|
||||
except Exception as e:
|
||||
task.status = "failed"
|
||||
task.phase = "failed"
|
||||
@@ -276,20 +345,34 @@ class BaseCollector(ABC):
|
||||
updated_count = 0
|
||||
unchanged_count = 0
|
||||
seen_entity_keys: set[str] = set()
|
||||
previous_current_keys: set[str] = set()
|
||||
progress_commit_interval = 1000
|
||||
|
||||
previous_current_result = await db.execute(
|
||||
select(CollectedData.entity_key).where(
|
||||
select(CollectedData)
|
||||
.where(
|
||||
CollectedData.source == self.name,
|
||||
CollectedData.is_current == True,
|
||||
)
|
||||
.order_by(CollectedData.entity_key.asc(), CollectedData.collected_at.desc().nullslast(), CollectedData.id.desc())
|
||||
)
|
||||
previous_current_keys = {row[0] for row in previous_current_result.fetchall() if row[0]}
|
||||
previous_current_records = previous_current_result.scalars().all()
|
||||
previous_current_keys = {record.entity_key for record in previous_current_records if record.entity_key}
|
||||
previous_current_map: dict[str, CollectedData] = {}
|
||||
stale_previous_records: list[CollectedData] = []
|
||||
|
||||
for existing_record in previous_current_records:
|
||||
entity_key = existing_record.entity_key
|
||||
if not entity_key:
|
||||
continue
|
||||
if entity_key not in previous_current_map:
|
||||
previous_current_map[entity_key] = existing_record
|
||||
continue
|
||||
stale_previous_records.append(existing_record)
|
||||
|
||||
for stale_record in stale_previous_records:
|
||||
stale_record.is_current = False
|
||||
|
||||
for i, item in enumerate(data):
|
||||
print(
|
||||
f"DEBUG: Saving item {i}: name={item.get('name')}, metadata={item.get('metadata', 'NOT FOUND')}"
|
||||
)
|
||||
raw_metadata = item.get("metadata", {})
|
||||
extra_data = build_dynamic_metadata(
|
||||
raw_metadata,
|
||||
@@ -318,20 +401,9 @@ class BaseCollector(ABC):
|
||||
previous_record = None
|
||||
|
||||
if entity_key and entity_key not in seen_entity_keys:
|
||||
result = await db.execute(
|
||||
select(CollectedData)
|
||||
.where(
|
||||
CollectedData.source == self.name,
|
||||
CollectedData.entity_key == entity_key,
|
||||
CollectedData.is_current == True,
|
||||
)
|
||||
.order_by(CollectedData.collected_at.desc().nullslast(), CollectedData.id.desc())
|
||||
)
|
||||
previous_records = result.scalars().all()
|
||||
if previous_records:
|
||||
previous_record = previous_records[0]
|
||||
for old_record in previous_records:
|
||||
old_record.is_current = False
|
||||
previous_record = previous_current_map.get(entity_key)
|
||||
if previous_record is not None:
|
||||
previous_record.is_current = False
|
||||
|
||||
record = CollectedData(
|
||||
snapshot_id=snapshot_id,
|
||||
@@ -375,7 +447,7 @@ class BaseCollector(ABC):
|
||||
seen_entity_keys.add(entity_key)
|
||||
records_added += 1
|
||||
|
||||
if i % 100 == 0:
|
||||
if (i + 1) % progress_commit_interval == 0:
|
||||
await self.update_progress(i + 1, commit=True)
|
||||
|
||||
if snapshot_id is not None:
|
||||
|
||||
@@ -19,6 +19,30 @@ logger = logging.getLogger(__name__)
|
||||
|
||||
scheduler = AsyncIOScheduler()
|
||||
RUNNING_TASK_GUARD_TIMEOUT_MINUTES = 90
|
||||
RUNNING_COLLECTOR_TASKS: dict[str, asyncio.Task[Any]] = {}
|
||||
|
||||
|
||||
def _collector_task_name(collector_name: str) -> str:
|
||||
return f"collector:{collector_name}"
|
||||
|
||||
|
||||
def get_running_collector_task(collector_name: str) -> asyncio.Task[Any] | None:
|
||||
task = RUNNING_COLLECTOR_TASKS.get(collector_name)
|
||||
if task is not None and not task.done():
|
||||
return task
|
||||
|
||||
if task is not None and task.done():
|
||||
RUNNING_COLLECTOR_TASKS.pop(collector_name, None)
|
||||
|
||||
target_name = _collector_task_name(collector_name)
|
||||
for candidate in asyncio.all_tasks():
|
||||
if candidate.done():
|
||||
continue
|
||||
if candidate.get_name() == target_name:
|
||||
RUNNING_COLLECTOR_TASKS[collector_name] = candidate
|
||||
return candidate
|
||||
|
||||
return None
|
||||
|
||||
|
||||
async def _update_next_run_at(datasource: DataSource, session) -> None:
|
||||
@@ -133,6 +157,12 @@ async def run_collector_task(collector_name: str):
|
||||
datasource.last_status = task_result.get("status")
|
||||
await _update_next_run_at(datasource, db)
|
||||
logger.info("Collector %s completed: %s", collector_name, task_result)
|
||||
except asyncio.CancelledError:
|
||||
datasource.last_run_at = datetime.now(UTC)
|
||||
datasource.last_status = "cancelled"
|
||||
await db.commit()
|
||||
logger.warning("Collector %s cancelled by operator", collector_name)
|
||||
raise
|
||||
except Exception as exc:
|
||||
datasource.last_run_at = datetime.now(UTC)
|
||||
datasource.last_status = "failed"
|
||||
@@ -245,9 +275,31 @@ def run_collector_now(collector_name: str) -> bool:
|
||||
return False
|
||||
|
||||
try:
|
||||
asyncio.create_task(run_collector_task(collector_name))
|
||||
task = asyncio.create_task(run_collector_task(collector_name), name=_collector_task_name(collector_name))
|
||||
RUNNING_COLLECTOR_TASKS[collector_name] = task
|
||||
|
||||
def _cleanup_task(done_task: asyncio.Task[Any]) -> None:
|
||||
current = RUNNING_COLLECTOR_TASKS.get(collector_name)
|
||||
if current is done_task:
|
||||
RUNNING_COLLECTOR_TASKS.pop(collector_name, None)
|
||||
|
||||
task.add_done_callback(_cleanup_task)
|
||||
logger.info("Triggered collector: %s", collector_name)
|
||||
return True
|
||||
except Exception as exc:
|
||||
logger.error("Failed to trigger collector %s: %s", collector_name, exc)
|
||||
return False
|
||||
return False
|
||||
|
||||
|
||||
async def cancel_running_collector_now(collector_name: str) -> bool:
|
||||
task = get_running_collector_task(collector_name)
|
||||
if task is None or task.done():
|
||||
RUNNING_COLLECTOR_TASKS.pop(collector_name, None)
|
||||
return False
|
||||
|
||||
task.cancel()
|
||||
try:
|
||||
await task
|
||||
except asyncio.CancelledError:
|
||||
return True
|
||||
return task.cancelled()
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { useCallback, useEffect, useRef, useState } from 'react'
|
||||
import {
|
||||
Table, Tag, Space, Button, Form, Input, Select, Progress, Checkbox, message,
|
||||
Table, Tag, Space, Button, Form, Input, Select, Progress, Checkbox, message, Modal,
|
||||
Drawer, Tabs, Empty, Tooltip, Popconfirm, Collapse, InputNumber, Row, Col, Card
|
||||
} from 'antd'
|
||||
import {
|
||||
@@ -382,30 +382,92 @@ function DataSources() {
|
||||
return () => clearInterval(interval)
|
||||
}, [builtInSources, taskProgress, taskSocketConnected, fetchData])
|
||||
|
||||
const triggerDatasource = async (id: number, options?: { force?: boolean }) => {
|
||||
const force = options?.force ?? false
|
||||
const res = await axios.post(`/api/v1/datasources/${id}/trigger`, null, {
|
||||
params: { force },
|
||||
})
|
||||
|
||||
if (res.data.task_id) {
|
||||
setTaskProgress(prev => ({
|
||||
...prev,
|
||||
[id]: {
|
||||
task_id: res.data.task_id,
|
||||
progress: 0,
|
||||
is_running: true,
|
||||
phase: 'queued',
|
||||
status: 'running',
|
||||
},
|
||||
}))
|
||||
} else {
|
||||
window.setTimeout(() => {
|
||||
fetchData()
|
||||
}, 800)
|
||||
}
|
||||
|
||||
fetchData()
|
||||
return res
|
||||
}
|
||||
|
||||
const confirmForceTrigger = (id: number, taskInfo?: {
|
||||
progress?: number | null
|
||||
phase?: string | null
|
||||
records_processed?: number | null
|
||||
total_records?: number | null
|
||||
message?: string
|
||||
}) => {
|
||||
const progressText = typeof taskInfo?.progress === 'number' ? `${Math.round(taskInfo.progress)}%` : '未知'
|
||||
const phaseText = taskInfo?.phase || 'running'
|
||||
const processedText = typeof taskInfo?.records_processed === 'number'
|
||||
? `${taskInfo.records_processed}${typeof taskInfo?.total_records === 'number' && taskInfo.total_records > 0 ? ` / ${taskInfo.total_records}` : ''}`
|
||||
: '未知'
|
||||
|
||||
Modal.confirm({
|
||||
title: '当前任务未完成',
|
||||
content: (
|
||||
<div>
|
||||
<p>{taskInfo?.message || '当前采集任务仍在运行,重新触发会丢失本次未完成进度。'}</p>
|
||||
<p>当前阶段: {phaseText}</p>
|
||||
<p>当前进度: {progressText}</p>
|
||||
<p>已处理记录: {processedText}</p>
|
||||
<p>确认后会强制取消当前采集,并回滚未完成写入,然后重新开始采集。</p>
|
||||
</div>
|
||||
),
|
||||
okText: '强制重新采集',
|
||||
cancelText: '取消',
|
||||
okButtonProps: { danger: true },
|
||||
onOk: async () => {
|
||||
try {
|
||||
await triggerDatasource(id, { force: true })
|
||||
messageApi.success('已强制重新触发,未完成采集将回滚')
|
||||
} catch (error: unknown) {
|
||||
const err = error as { response?: { data?: { detail?: string | { message?: string } } } }
|
||||
const detail = err.response?.data?.detail
|
||||
messageApi.error(typeof detail === 'string' ? detail : detail?.message || '强制重新采集失败')
|
||||
}
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
const handleTrigger = async (id: number) => {
|
||||
try {
|
||||
const res = await axios.post(`/api/v1/datasources/${id}/trigger`)
|
||||
await triggerDatasource(id)
|
||||
messageApi.success('任务已触发')
|
||||
if (res.data.task_id) {
|
||||
setTaskProgress(prev => ({
|
||||
...prev,
|
||||
[id]: {
|
||||
task_id: res.data.task_id,
|
||||
progress: 0,
|
||||
is_running: true,
|
||||
phase: 'queued',
|
||||
status: 'running',
|
||||
},
|
||||
}))
|
||||
} else {
|
||||
window.setTimeout(() => {
|
||||
fetchData()
|
||||
}, 800)
|
||||
}
|
||||
fetchData()
|
||||
} catch (error: unknown) {
|
||||
const err = error as { response?: { data?: { detail?: string } } }
|
||||
messageApi.error(err.response?.data?.detail || '触发失败')
|
||||
const err = error as { response?: { status?: number; data?: { detail?: string | {
|
||||
reason?: string
|
||||
message?: string
|
||||
progress?: number | null
|
||||
phase?: string | null
|
||||
records_processed?: number | null
|
||||
total_records?: number | null
|
||||
} } } }
|
||||
const detail = err.response?.data?.detail
|
||||
if (err.response?.status === 409 && typeof detail === 'object' && detail?.reason === 'running_task_in_progress') {
|
||||
confirmForceTrigger(id, detail)
|
||||
return
|
||||
}
|
||||
messageApi.error(typeof detail === 'string' ? detail : detail?.message || '触发失败')
|
||||
}
|
||||
}
|
||||
|
||||
@@ -527,12 +589,24 @@ function DataSources() {
|
||||
const handleUpdateSource = async () => {
|
||||
if (!viewingSource) return
|
||||
try {
|
||||
await axios.post(`/api/v1/datasources/${viewingSource.id}/trigger`)
|
||||
await triggerDatasource(viewingSource.id)
|
||||
messageApi.success('已触发更新')
|
||||
setViewDrawerVisible(false)
|
||||
} catch (error: unknown) {
|
||||
const err = error as { response?: { data?: { detail?: string } } }
|
||||
messageApi.error(err.response?.data?.detail || '更新失败')
|
||||
const err = error as { response?: { status?: number; data?: { detail?: string | {
|
||||
reason?: string
|
||||
message?: string
|
||||
progress?: number | null
|
||||
phase?: string | null
|
||||
records_processed?: number | null
|
||||
total_records?: number | null
|
||||
} } } }
|
||||
const detail = err.response?.data?.detail
|
||||
if (err.response?.status === 409 && typeof detail === 'object' && detail?.reason === 'running_task_in_progress') {
|
||||
confirmForceTrigger(viewingSource.id, detail)
|
||||
return
|
||||
}
|
||||
messageApi.error(typeof detail === 'string' ? detail : detail?.message || '更新失败')
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
293
planet.sh
293
planet.sh
@@ -5,11 +5,30 @@ set -e
|
||||
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||||
cd "$SCRIPT_DIR"
|
||||
|
||||
RED='\033[0;31m'
|
||||
GREEN='\033[0;32m'
|
||||
YELLOW='\033[1;33m'
|
||||
BLUE='\033[0;34m'
|
||||
RED='\033[38;5;203m'
|
||||
GREEN='\033[38;5;114m'
|
||||
YELLOW='\033[38;5;221m'
|
||||
BLUE='\033[38;5;111m'
|
||||
CYAN='\033[38;5;117m'
|
||||
WHITE='\033[1;37m'
|
||||
DIM='\033[2m'
|
||||
NC='\033[0m'
|
||||
WAIT_SPINNER_FRAMES=(
|
||||
'⠉⠁'
|
||||
'⠈⠉'
|
||||
'⠀⠙'
|
||||
'⠀⠸'
|
||||
'⠀⢰'
|
||||
'⠀⣠'
|
||||
'⢀⣀'
|
||||
'⣀⡀'
|
||||
'⣄⠀'
|
||||
'⡆⠀'
|
||||
'⠇⠀'
|
||||
'⠋⠀'
|
||||
)
|
||||
WAIT_SPINNER_STEP=0
|
||||
WAIT_SPINNER_TICKS_PER_SECOND=8
|
||||
|
||||
BACKEND_MAX_RETRIES="${BACKEND_MAX_RETRIES:-3}"
|
||||
BACKEND_HEALTH_CHECK_ATTEMPTS="${BACKEND_HEALTH_CHECK_ATTEMPTS:-10}"
|
||||
@@ -37,6 +56,100 @@ compose_up() {
|
||||
fi
|
||||
}
|
||||
|
||||
render_wait_spinner() {
|
||||
local message="$1"
|
||||
local step="${2:-1}"
|
||||
local frame_index=$(( (step - 1) % ${#WAIT_SPINNER_FRAMES[@]} ))
|
||||
local frame="${WAIT_SPINNER_FRAMES[$frame_index]}"
|
||||
printf "\r${DIM}·${NC} ${CYAN}%s${NC} ${WHITE}%s${NC}\033[K" "$frame" "$message"
|
||||
}
|
||||
|
||||
animate_wait_spinner() {
|
||||
local message="$1"
|
||||
local duration="${2:-1}"
|
||||
local ticks=$(( duration * WAIT_SPINNER_TICKS_PER_SECOND ))
|
||||
local tick=0
|
||||
|
||||
if [ "$ticks" -lt 1 ]; then
|
||||
ticks=1
|
||||
fi
|
||||
|
||||
while [ "$tick" -lt "$ticks" ]; do
|
||||
WAIT_SPINNER_STEP=$((WAIT_SPINNER_STEP + 1))
|
||||
render_wait_spinner "$message" "$WAIT_SPINNER_STEP"
|
||||
sleep 0.125
|
||||
tick=$((tick + 1))
|
||||
done
|
||||
}
|
||||
|
||||
clear_wait_spinner() {
|
||||
printf "\r\033[K"
|
||||
}
|
||||
|
||||
finish_wait_spinner() {
|
||||
log_line "done" "$GREEN" "$1"
|
||||
}
|
||||
|
||||
log_line() {
|
||||
local label="$1"
|
||||
local color="$2"
|
||||
local message="$3"
|
||||
clear_wait_spinner
|
||||
printf "${DIM}·${NC} ${color}%-4s${NC} ${WHITE}%s${NC}\n" "$label" "$message"
|
||||
}
|
||||
|
||||
log_step() {
|
||||
log_line "run" "$BLUE" "$1"
|
||||
}
|
||||
|
||||
log_warn() {
|
||||
log_line "warn" "$YELLOW" "$1"
|
||||
}
|
||||
|
||||
log_error() {
|
||||
log_line "fail" "$RED" "$1"
|
||||
}
|
||||
|
||||
log_note() {
|
||||
printf "${DIM} %s${NC}\n" "$1"
|
||||
}
|
||||
|
||||
log_success() {
|
||||
log_line "done" "$GREEN" "$1"
|
||||
}
|
||||
|
||||
print_splash() {
|
||||
clear_wait_spinner
|
||||
printf "%b" "$CYAN"
|
||||
cat <<'EOF'
|
||||
___ _ _ _ _ _
|
||||
|_ _|_ __ | |_ ___| | (_) __ _ ___ _ __ | |_
|
||||
| || '_ \| __/ _ \ | | |/ _` |/ _ \ '_ \| __|
|
||||
| || | | | || __/ | | | (_| | __/ | | | |_
|
||||
|___|_| |_|\__\___|_|_|_|\__, |\___|_| |_|\__|
|
||||
|___/
|
||||
EOF
|
||||
printf "%b\n" "$NC"
|
||||
printf "%b" "$BLUE"
|
||||
cat <<'EOF'
|
||||
____ _ _
|
||||
| _ \| | __ _ _ __ ___ | |_
|
||||
| |_) | |/ _` | '_ \ / _ \| __|
|
||||
| __/| | (_| | | | | __/| |_
|
||||
|_| |_|\__,_|_| |_|\___| \__|
|
||||
EOF
|
||||
printf "%b\n" "$NC"
|
||||
printf "%b" "$WHITE"
|
||||
cat <<'EOF'
|
||||
____ _
|
||||
| _ \| | __ _ _ __
|
||||
| |_) | |/ _` | '_ \
|
||||
| __/| | (_| | | | |
|
||||
|_| |_|\__,_|_| |_|
|
||||
EOF
|
||||
printf "%b\n\n" "$NC"
|
||||
}
|
||||
|
||||
run_with_retry() {
|
||||
local max_retries="$1"
|
||||
local interval="$2"
|
||||
@@ -52,12 +165,12 @@ run_with_retry() {
|
||||
fi
|
||||
|
||||
if [ "$attempt" -eq "$max_retries" ]; then
|
||||
echo -e "${RED}❌ ${failure_message}${NC}"
|
||||
clear_wait_spinner
|
||||
log_error "${failure_message}"
|
||||
return 1
|
||||
fi
|
||||
|
||||
echo -e "${YELLOW}⚠️ ${retry_label} 第 ${attempt}/${max_retries} 次失败,${interval} 秒后重试...${NC}"
|
||||
sleep "$interval"
|
||||
animate_wait_spinner "${retry_label} 第 ${attempt}/${max_retries} 次失败,${interval} 秒后重试" "$interval"
|
||||
attempt=$((attempt + 1))
|
||||
done
|
||||
|
||||
@@ -65,17 +178,17 @@ run_with_retry() {
|
||||
}
|
||||
|
||||
ensure_uv_backend_deps() {
|
||||
echo -e "${BLUE}📦 检查后端 uv 环境...${NC}"
|
||||
log_step "检查后端 uv 环境"
|
||||
|
||||
if ! command -v uv >/dev/null 2>&1; then
|
||||
echo -e "${RED}❌ 未找到 uv,请先安装 uv 并加入 PATH${NC}"
|
||||
log_error "未找到 uv,请先安装 uv 并加入 PATH"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
cd "$SCRIPT_DIR"
|
||||
|
||||
if [ ! -x "$SCRIPT_DIR/.venv/bin/python" ]; then
|
||||
echo -e "${YELLOW}⚠️ 未检测到 .venv,正在执行 uv sync...${NC}"
|
||||
log_warn "未检测到 .venv,正在执行 uv sync"
|
||||
if ! run_with_retry \
|
||||
"$DEPENDENCY_INSTALL_MAX_RETRIES" \
|
||||
"$DEPENDENCY_INSTALL_RETRY_INTERVAL" \
|
||||
@@ -87,23 +200,23 @@ ensure_uv_backend_deps() {
|
||||
fi
|
||||
|
||||
if [ ! -x "$SCRIPT_DIR/.venv/bin/python" ]; then
|
||||
echo -e "${RED}❌ uv 环境初始化失败,未找到 .venv/bin/python${NC}"
|
||||
log_error "uv 环境初始化失败,未找到 .venv/bin/python"
|
||||
exit 1
|
||||
fi
|
||||
}
|
||||
|
||||
ensure_frontend_deps() {
|
||||
echo -e "${BLUE}📦 检查前端依赖...${NC}"
|
||||
log_step "检查前端依赖"
|
||||
|
||||
if ! command -v bun >/dev/null 2>&1; then
|
||||
echo -e "${RED}❌ 未找到 bun,请先安装或加载 bun 到 PATH${NC}"
|
||||
log_error "未找到 bun,请先安装或加载 bun 到 PATH"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
cd "$SCRIPT_DIR/frontend"
|
||||
|
||||
if [ ! -x "$SCRIPT_DIR/frontend/node_modules/.bin/vite" ]; then
|
||||
echo -e "${YELLOW}⚠️ 前端依赖缺失,正在执行 bun install...${NC}"
|
||||
log_warn "前端依赖缺失,正在执行 bun install"
|
||||
if ! run_with_retry \
|
||||
"$DEPENDENCY_INSTALL_MAX_RETRIES" \
|
||||
"$DEPENDENCY_INSTALL_RETRY_INTERVAL" \
|
||||
@@ -115,7 +228,7 @@ ensure_frontend_deps() {
|
||||
fi
|
||||
|
||||
if [ ! -x "$SCRIPT_DIR/frontend/node_modules/.bin/vite" ]; then
|
||||
echo -e "${RED}❌ 前端依赖安装失败,未找到 vite${NC}"
|
||||
log_error "前端依赖安装失败,未找到 vite"
|
||||
exit 1
|
||||
fi
|
||||
}
|
||||
@@ -129,14 +242,15 @@ wait_for_http() {
|
||||
|
||||
while [ "$attempt" -le "$attempts" ]; do
|
||||
if curl -s "$url" > /dev/null 2>&1; then
|
||||
finish_wait_spinner "${service_name}已就绪"
|
||||
return 0
|
||||
fi
|
||||
|
||||
echo -e "${YELLOW}⏳ 等待${service_name}就绪...${NC}"
|
||||
sleep "$interval"
|
||||
animate_wait_spinner "等待${service_name}就绪" "$interval"
|
||||
attempt=$((attempt + 1))
|
||||
done
|
||||
|
||||
clear_wait_spinner
|
||||
return 1
|
||||
}
|
||||
|
||||
@@ -152,14 +266,15 @@ wait_for_container_health() {
|
||||
status="$(docker inspect --format '{{if .State.Health}}{{.State.Health.Status}}{{else}}{{.State.Status}}{{end}}' "$container_name" 2>/dev/null || true)"
|
||||
|
||||
if [ "$status" = "healthy" ] || [ "$status" = "running" ]; then
|
||||
finish_wait_spinner "${service_name}容器健康检查已通过"
|
||||
return 0
|
||||
fi
|
||||
|
||||
echo -e "${YELLOW}⏳ 等待${service_name}容器健康检查通过...${NC}"
|
||||
sleep "$interval"
|
||||
animate_wait_spinner "等待${service_name}容器健康检查通过" "$interval"
|
||||
attempt=$((attempt + 1))
|
||||
done
|
||||
|
||||
clear_wait_spinner
|
||||
return 1
|
||||
}
|
||||
|
||||
@@ -173,15 +288,15 @@ wait_for_postgres_health() {
|
||||
}
|
||||
|
||||
start_database_services() {
|
||||
docker start planet_postgres planet_redis 2>/dev/null || compose_up up -d postgres redis
|
||||
docker start planet_postgres planet_redis >/dev/null 2>&1 || compose_up up -d postgres redis >/dev/null 2>&1
|
||||
}
|
||||
|
||||
restart_database_services() {
|
||||
docker restart planet_postgres planet_redis 2>/dev/null || compose_up up -d postgres redis
|
||||
docker restart planet_postgres planet_redis >/dev/null 2>&1 || compose_up up -d postgres redis >/dev/null 2>&1
|
||||
}
|
||||
|
||||
start_postgres_service() {
|
||||
docker start planet_postgres 2>/dev/null || compose_up up -d postgres
|
||||
docker start planet_postgres >/dev/null 2>&1 || compose_up up -d postgres >/dev/null 2>&1
|
||||
}
|
||||
|
||||
start_backend_with_retry() {
|
||||
@@ -198,11 +313,12 @@ start_backend_with_retry() {
|
||||
return 0
|
||||
fi
|
||||
|
||||
echo -e "${YELLOW}⚠️ 后端第 ${retry}/${BACKEND_MAX_RETRIES} 次启动未就绪,准备重试...${NC}"
|
||||
kill "$BACKEND_PID" 2>/dev/null || true
|
||||
animate_wait_spinner "后端第 ${retry}/${BACKEND_MAX_RETRIES} 次启动未就绪,准备重试" "$BACKEND_HEALTH_CHECK_INTERVAL"
|
||||
retry=$((retry + 1))
|
||||
done
|
||||
|
||||
clear_wait_spinner
|
||||
return 1
|
||||
}
|
||||
|
||||
@@ -210,10 +326,10 @@ start_ai_provider_service() {
|
||||
local ai_provider_port="${1:-$DEFAULT_AI_PROVIDER_PORT}"
|
||||
local retry=1
|
||||
|
||||
echo -e "${BLUE}🧠 启动 AI Provider...${NC}"
|
||||
log_step "启动 AI Provider"
|
||||
|
||||
while [ "$retry" -le "$AI_PROVIDER_START_MAX_RETRIES" ]; do
|
||||
if docker start planet_aiprovider 2>/dev/null || compose_up up -d aiprovider; then
|
||||
if docker start planet_aiprovider >/dev/null 2>&1 || compose_up up -d aiprovider >/dev/null 2>&1; then
|
||||
if wait_for_container_health "planet_aiprovider" "$AI_PROVIDER_HEALTH_CHECK_ATTEMPTS" "$AI_PROVIDER_HEALTH_CHECK_INTERVAL" "AI Provider" &&
|
||||
wait_for_http "http://localhost:${ai_provider_port}/health" "$AI_PROVIDER_HEALTH_CHECK_ATTEMPTS" "$AI_PROVIDER_HEALTH_CHECK_INTERVAL" "AI Provider"; then
|
||||
return 0
|
||||
@@ -221,18 +337,19 @@ start_ai_provider_service() {
|
||||
fi
|
||||
|
||||
if [ "$retry" -eq "$AI_PROVIDER_START_MAX_RETRIES" ]; then
|
||||
echo -e "${RED}❌ AI Provider 启动失败,已重试 ${AI_PROVIDER_START_MAX_RETRIES} 次${NC}"
|
||||
clear_wait_spinner
|
||||
log_error "AI Provider 启动失败,已重试 ${AI_PROVIDER_START_MAX_RETRIES} 次"
|
||||
docker logs --tail 20 planet_aiprovider 2>/dev/null || true
|
||||
exit 1
|
||||
fi
|
||||
|
||||
echo -e "${YELLOW}⚠️ AI Provider 第 ${retry}/${AI_PROVIDER_START_MAX_RETRIES} 次启动后仍未健康,正在重启容器...${NC}"
|
||||
docker restart planet_aiprovider 2>/dev/null || true
|
||||
sleep "$AI_PROVIDER_RETRY_INTERVAL"
|
||||
docker restart planet_aiprovider >/dev/null 2>&1 || true
|
||||
animate_wait_spinner "AI Provider 第 ${retry}/${AI_PROVIDER_START_MAX_RETRIES} 次启动后仍未健康,正在重启容器" "$AI_PROVIDER_RETRY_INTERVAL"
|
||||
retry=$((retry + 1))
|
||||
done
|
||||
|
||||
echo -e "${RED}❌ AI Provider 启动失败${NC}"
|
||||
clear_wait_spinner
|
||||
log_error "AI Provider 启动失败"
|
||||
docker logs --tail 20 planet_aiprovider 2>/dev/null || true
|
||||
exit 1
|
||||
}
|
||||
@@ -252,11 +369,12 @@ start_frontend_with_retry() {
|
||||
return 0
|
||||
fi
|
||||
|
||||
echo -e "${YELLOW}⚠️ 前端第 ${retry}/${FRONTEND_MAX_RETRIES} 次启动未就绪,准备重试...${NC}"
|
||||
kill "$FRONTEND_PID" 2>/dev/null || true
|
||||
animate_wait_spinner "前端第 ${retry}/${FRONTEND_MAX_RETRIES} 次启动未就绪,准备重试" "$FRONTEND_HEALTH_CHECK_INTERVAL"
|
||||
retry=$((retry + 1))
|
||||
done
|
||||
|
||||
clear_wait_spinner
|
||||
return 1
|
||||
}
|
||||
|
||||
@@ -269,15 +387,15 @@ ensure_database_services_healthy() {
|
||||
fi
|
||||
|
||||
if [ "$retry" -eq "$DATABASE_START_MAX_RETRIES" ]; then
|
||||
echo -e "${RED}❌ 数据库启动失败,已重试 ${DATABASE_START_MAX_RETRIES} 次${NC}"
|
||||
clear_wait_spinner
|
||||
log_error "数据库启动失败,已重试 ${DATABASE_START_MAX_RETRIES} 次"
|
||||
docker logs --tail 20 planet_postgres 2>/dev/null || true
|
||||
docker logs --tail 20 planet_redis 2>/dev/null || true
|
||||
exit 1
|
||||
fi
|
||||
|
||||
echo -e "${YELLOW}⚠️ 数据库第 ${retry}/${DATABASE_START_MAX_RETRIES} 次启动后仍未健康,正在重启容器...${NC}"
|
||||
docker restart planet_postgres planet_redis 2>/dev/null || true
|
||||
sleep "$DATABASE_RETRY_INTERVAL"
|
||||
docker restart planet_postgres planet_redis >/dev/null 2>&1 || true
|
||||
animate_wait_spinner "数据库第 ${retry}/${DATABASE_START_MAX_RETRIES} 次启动后仍未健康,正在重启容器" "$DATABASE_RETRY_INTERVAL"
|
||||
retry=$((retry + 1))
|
||||
done
|
||||
}
|
||||
@@ -291,14 +409,14 @@ ensure_postgres_service_healthy() {
|
||||
fi
|
||||
|
||||
if [ "$retry" -eq "$DATABASE_START_MAX_RETRIES" ]; then
|
||||
echo -e "${RED}❌ PostgreSQL 启动失败,已重试 ${DATABASE_START_MAX_RETRIES} 次${NC}"
|
||||
clear_wait_spinner
|
||||
log_error "PostgreSQL 启动失败,已重试 ${DATABASE_START_MAX_RETRIES} 次"
|
||||
docker logs --tail 20 planet_postgres 2>/dev/null || true
|
||||
exit 1
|
||||
fi
|
||||
|
||||
echo -e "${YELLOW}⚠️ PostgreSQL 第 ${retry}/${DATABASE_START_MAX_RETRIES} 次启动后仍未健康,正在重启容器...${NC}"
|
||||
docker restart planet_postgres 2>/dev/null || true
|
||||
sleep "$DATABASE_RETRY_INTERVAL"
|
||||
docker restart planet_postgres >/dev/null 2>&1 || true
|
||||
animate_wait_spinner "PostgreSQL 第 ${retry}/${DATABASE_START_MAX_RETRIES} 次启动后仍未健康,正在重启容器" "$DATABASE_RETRY_INTERVAL"
|
||||
retry=$((retry + 1))
|
||||
done
|
||||
}
|
||||
@@ -306,7 +424,7 @@ ensure_postgres_service_healthy() {
|
||||
restart_database_service() {
|
||||
local retry=1
|
||||
|
||||
echo -e "${BLUE}🗄️ 重启数据库...${NC}"
|
||||
log_step "重启数据库"
|
||||
|
||||
while [ "$retry" -le "$DATABASE_START_MAX_RETRIES" ]; do
|
||||
if restart_database_services && wait_for_database_health; then
|
||||
@@ -315,14 +433,14 @@ restart_database_service() {
|
||||
fi
|
||||
|
||||
if [ "$retry" -eq "$DATABASE_START_MAX_RETRIES" ]; then
|
||||
echo -e "${RED}❌ 数据库重启失败,已重试 ${DATABASE_START_MAX_RETRIES} 次${NC}"
|
||||
clear_wait_spinner
|
||||
log_error "数据库重启失败,已重试 ${DATABASE_START_MAX_RETRIES} 次"
|
||||
docker logs --tail 20 planet_postgres 2>/dev/null || true
|
||||
docker logs --tail 20 planet_redis 2>/dev/null || true
|
||||
exit 1
|
||||
fi
|
||||
|
||||
echo -e "${YELLOW}⚠️ 数据库第 ${retry}/${DATABASE_START_MAX_RETRIES} 次重启后仍未健康,继续重试...${NC}"
|
||||
sleep "$DATABASE_RETRY_INTERVAL"
|
||||
animate_wait_spinner "数据库第 ${retry}/${DATABASE_START_MAX_RETRIES} 次重启后仍未健康,继续重试" "$DATABASE_RETRY_INTERVAL"
|
||||
retry=$((retry + 1))
|
||||
done
|
||||
}
|
||||
@@ -332,7 +450,7 @@ start_backend_service() {
|
||||
local backend_port_requested="$2"
|
||||
local ai_provider_port="${3:-$DEFAULT_AI_PROVIDER_PORT}"
|
||||
|
||||
echo -e "${BLUE}🗄️ 启动数据库...${NC}"
|
||||
log_step "启动数据库"
|
||||
ensure_database_services_healthy
|
||||
sleep 3
|
||||
|
||||
@@ -342,10 +460,10 @@ start_backend_service() {
|
||||
kill_port_if_requested "$backend_port" "后端"
|
||||
fi
|
||||
|
||||
echo -e "${BLUE}🔧 启动后端...${NC}"
|
||||
log_step "启动后端"
|
||||
ensure_uv_backend_deps
|
||||
if ! start_backend_with_retry "$backend_port"; then
|
||||
echo -e "${RED}❌ 后端启动失败,已重试 ${BACKEND_MAX_RETRIES} 次${NC}"
|
||||
log_error "后端启动失败,已重试 ${BACKEND_MAX_RETRIES} 次"
|
||||
tail -10 /tmp/planet_backend.log
|
||||
exit 1
|
||||
fi
|
||||
@@ -359,10 +477,10 @@ start_frontend_service() {
|
||||
kill_port_if_requested "$frontend_port" "前端"
|
||||
fi
|
||||
|
||||
echo -e "${BLUE}🌐 启动前端...${NC}"
|
||||
log_step "启动前端"
|
||||
ensure_frontend_deps
|
||||
if ! start_frontend_with_retry "$frontend_port"; then
|
||||
echo -e "${RED}❌ 前端启动失败,已重试 ${FRONTEND_MAX_RETRIES} 次${NC}"
|
||||
log_error "前端启动失败,已重试 ${FRONTEND_MAX_RETRIES} 次"
|
||||
tail -10 /tmp/planet_frontend.log
|
||||
exit 1
|
||||
fi
|
||||
@@ -372,7 +490,7 @@ validate_port() {
|
||||
local port="$1"
|
||||
|
||||
if ! [[ "$port" =~ ^[0-9]+$ ]] || [ "$port" -lt 1 ] || [ "$port" -gt 65535 ]; then
|
||||
echo -e "${RED}❌ 非法端口: ${port}${NC}"
|
||||
log_error "非法端口: ${port}"
|
||||
exit 1
|
||||
fi
|
||||
}
|
||||
@@ -381,10 +499,10 @@ kill_port_if_requested() {
|
||||
local port="$1"
|
||||
local service_name="$2"
|
||||
|
||||
echo -e "${YELLOW}🧹 检测 ${service_name} 端口 ${port} 占用...${NC}"
|
||||
log_warn "检测 ${service_name} 端口 ${port} 占用"
|
||||
|
||||
if command -v fuser >/dev/null 2>&1 && fuser "${port}/tcp" >/dev/null 2>&1; then
|
||||
echo -e "${BLUE}🔌 发现端口 ${port} 占用,正在终止...${NC}"
|
||||
log_step "发现端口 ${port} 占用,正在终止"
|
||||
fuser -k "${port}/tcp" >/dev/null 2>&1 || true
|
||||
sleep 1
|
||||
return 0
|
||||
@@ -394,14 +512,14 @@ kill_port_if_requested() {
|
||||
local pids
|
||||
pids="$(lsof -ti tcp:"${port}" 2>/dev/null || true)"
|
||||
if [ -n "$pids" ]; then
|
||||
echo -e "${BLUE}🔌 发现端口 ${port} 占用,正在终止...${NC}"
|
||||
log_step "发现端口 ${port} 占用,正在终止"
|
||||
kill $pids 2>/dev/null || true
|
||||
sleep 1
|
||||
return 0
|
||||
fi
|
||||
fi
|
||||
|
||||
echo -e "${GREEN}✅ 端口 ${port} 未被占用${NC}"
|
||||
log_success "端口 ${port} 未被占用"
|
||||
}
|
||||
|
||||
parse_service_args() {
|
||||
@@ -447,7 +565,7 @@ parse_service_args() {
|
||||
shift 1
|
||||
;;
|
||||
*)
|
||||
echo -e "${RED}❌ 未知参数: $1${NC}"
|
||||
log_error "未知参数: $1"
|
||||
exit 1
|
||||
;;
|
||||
esac
|
||||
@@ -462,9 +580,9 @@ cleanup_exit_containers() {
|
||||
local exit_containers
|
||||
exit_containers="$(docker ps -a --filter status=exited -q 2>/dev/null || true)"
|
||||
if [ -n "$exit_containers" ]; then
|
||||
echo -e "${BLUE}🗑️ 清理残留 Exit 容器...${NC}"
|
||||
log_step "清理残留 Exit 容器"
|
||||
echo "$exit_containers" | xargs -r docker rm -f >/dev/null 2>&1 || true
|
||||
echo -e "${GREEN}✅ 残留容器已清理${NC}"
|
||||
log_success "残留容器已清理"
|
||||
fi
|
||||
}
|
||||
|
||||
@@ -492,15 +610,15 @@ create_user() {
|
||||
|
||||
ensure_uv_backend_deps
|
||||
|
||||
echo -e "${BLUE}🗄️ 启动数据库...${NC}"
|
||||
log_step "启动数据库"
|
||||
ensure_postgres_service_healthy
|
||||
sleep 2
|
||||
|
||||
echo -e "${BLUE}👤 创建用户${NC}"
|
||||
log_step "创建用户"
|
||||
read -r -p "用户名: " username
|
||||
|
||||
if [ -z "$username" ]; then
|
||||
echo -e "${RED}❌ 用户名不能为空${NC}"
|
||||
log_error "用户名不能为空"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
@@ -516,17 +634,17 @@ create_user() {
|
||||
echo ""
|
||||
|
||||
if [ -z "$password" ]; then
|
||||
echo -e "${RED}❌ 密码不能为空${NC}"
|
||||
log_error "密码不能为空"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
if [ "${#password}" -lt 8 ]; then
|
||||
echo -e "${RED}❌ 密码长度不能少于 8 位${NC}"
|
||||
log_error "密码长度不能少于 8 位"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
if [ "$password" != "$password_confirm" ]; then
|
||||
echo -e "${RED}❌ 两次输入的密码不一致${NC}"
|
||||
log_error "两次输入的密码不一致"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
@@ -580,35 +698,36 @@ async def main():
|
||||
asyncio.run(main())
|
||||
PY
|
||||
|
||||
echo -e "${GREEN}✅ 用户创建成功${NC}"
|
||||
echo " 用户名: ${username}"
|
||||
echo " 角色: ${role}"
|
||||
echo " 邮箱: ${email}"
|
||||
log_success "用户创建成功"
|
||||
log_note "用户名: ${username}"
|
||||
log_note "角色: ${role}"
|
||||
log_note "邮箱: ${email}"
|
||||
}
|
||||
|
||||
start() {
|
||||
parse_service_args "$@"
|
||||
cleanup_exit_containers
|
||||
|
||||
echo -e "${BLUE}🚀 启动智能星球计划...${NC}"
|
||||
print_splash
|
||||
log_step "启动智能星球计划"
|
||||
|
||||
start_backend_service "$BACKEND_PORT" "$BACKEND_PORT_REQUESTED" "$AI_PROVIDER_PORT"
|
||||
start_frontend_service "$FRONTEND_PORT" "$FRONTEND_PORT_REQUESTED"
|
||||
|
||||
echo ""
|
||||
echo -e "${GREEN}✅ 启动完成!${NC}"
|
||||
echo " 前端: http://localhost:${FRONTEND_PORT}"
|
||||
echo " 后端: http://localhost:${BACKEND_PORT}"
|
||||
echo " AI Provider: http://localhost:${AI_PROVIDER_PORT}"
|
||||
log_success "启动完成"
|
||||
log_note "前端: http://localhost:${FRONTEND_PORT}"
|
||||
log_note "后端: http://localhost:${BACKEND_PORT}"
|
||||
log_note "AI Provider: http://localhost:${AI_PROVIDER_PORT}"
|
||||
}
|
||||
|
||||
stop() {
|
||||
echo -e "${YELLOW}🛑 停止服务...${NC}"
|
||||
log_warn "停止服务"
|
||||
stop_backend_service
|
||||
stop_ai_provider_service
|
||||
stop_frontend_service
|
||||
docker stop planet_postgres planet_redis 2>/dev/null || true
|
||||
echo -e "${GREEN}✅ 已停止${NC}"
|
||||
log_success "已停止"
|
||||
}
|
||||
|
||||
restart() {
|
||||
@@ -622,7 +741,7 @@ restart() {
|
||||
return 0
|
||||
fi
|
||||
|
||||
echo -e "${YELLOW}🔄 按需重启服务...${NC}"
|
||||
log_warn "按需重启服务"
|
||||
|
||||
if [ "$DATABASE_REQUESTED" -eq 1 ]; then
|
||||
restart_database_service
|
||||
@@ -647,43 +766,43 @@ restart() {
|
||||
fi
|
||||
|
||||
echo ""
|
||||
echo -e "${GREEN}✅ 重启完成!${NC}"
|
||||
log_success "重启完成"
|
||||
if [ "$DATABASE_REQUESTED" -eq 1 ]; then
|
||||
echo " 数据库: planet_postgres, planet_redis"
|
||||
log_note "数据库: planet_postgres, planet_redis"
|
||||
fi
|
||||
if [ "$AI_PROVIDER_REQUESTED" -eq 1 ]; then
|
||||
echo " AI Provider: http://localhost:${AI_PROVIDER_PORT}"
|
||||
log_note "AI Provider: http://localhost:${AI_PROVIDER_PORT}"
|
||||
fi
|
||||
if [ "$BACKEND_PORT_REQUESTED" -eq 1 ]; then
|
||||
echo " 后端: http://localhost:${BACKEND_PORT}"
|
||||
log_note "后端: http://localhost:${BACKEND_PORT}"
|
||||
fi
|
||||
if [ "$FRONTEND_PORT_REQUESTED" -eq 1 ]; then
|
||||
echo " 前端: http://localhost:${FRONTEND_PORT}"
|
||||
log_note "前端: http://localhost:${FRONTEND_PORT}"
|
||||
fi
|
||||
}
|
||||
|
||||
health() {
|
||||
echo "📊 容器状态:"
|
||||
echo -e "${DIM}·${NC} ${BLUE}view${NC} ${WHITE}容器状态${NC}"
|
||||
docker ps --filter "name=planet_" --format "table {{.Names}}\t{{.Status}}\t{{.Ports}}"
|
||||
|
||||
echo ""
|
||||
echo "🔍 服务状态:"
|
||||
echo -e "${DIM}·${NC} ${BLUE}view${NC} ${WHITE}服务状态${NC}"
|
||||
if curl -s http://localhost:8000/health > /dev/null 2>&1; then
|
||||
echo -e " 后端: ${GREEN}✅ 运行中${NC}"
|
||||
echo -e "${DIM} 后端:${NC} ${GREEN}online${NC}"
|
||||
else
|
||||
echo -e " 后端: ${RED}❌ 未运行${NC}"
|
||||
echo -e "${DIM} 后端:${NC} ${RED}offline${NC}"
|
||||
fi
|
||||
|
||||
if curl -s "http://localhost:${DEFAULT_AI_PROVIDER_PORT}/health" > /dev/null 2>&1; then
|
||||
echo -e " AI Provider: ${GREEN}✅ 运行中${NC}"
|
||||
echo -e "${DIM} AI Provider:${NC} ${GREEN}online${NC}"
|
||||
else
|
||||
echo -e " AI Provider: ${RED}❌ 未运行${NC}"
|
||||
echo -e "${DIM} AI Provider:${NC} ${RED}offline${NC}"
|
||||
fi
|
||||
|
||||
if curl -s http://localhost:3000 > /dev/null 2>&1; then
|
||||
echo -e " 前端: ${GREEN}✅ 运行中${NC}"
|
||||
echo -e "${DIM} 前端:${NC} ${GREEN}online${NC}"
|
||||
else
|
||||
echo -e " 前端: ${RED}❌ 未运行${NC}"
|
||||
echo -e "${DIM} 前端:${NC} ${RED}offline${NC}"
|
||||
fi
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user