Add force rerun recovery and polish CLI startup

This commit is contained in:
rayd1o
2026-04-08 02:49:29 +08:00
parent 981617ee80
commit f5308340af
6 changed files with 546 additions and 137 deletions

View File

@@ -3,7 +3,7 @@ from datetime import datetime, timedelta, timezone
from typing import Optional from typing import Optional
from fastapi import APIRouter, Depends, HTTPException, Query 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 sqlalchemy.ext.asyncio import AsyncSession
from app.core.time import to_iso8601_utc 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.core.data_sources import get_data_sources_config
from app.db.session import get_db from app.db.session import get_db
from app.models.collected_data import CollectedData from app.models.collected_data import CollectedData
from app.models.data_snapshot import DataSnapshot
from app.models.datasource import DataSource from app.models.datasource import DataSource
from app.models.task import CollectionTask from app.models.task import CollectionTask
from app.models.user import User 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() router = APIRouter()
STALE_RUNNING_TASK_TIMEOUT_MINUTES = 90 STALE_RUNNING_TASK_TIMEOUT_MINUTES = 90
@@ -100,6 +106,70 @@ async def get_running_task(db: AsyncSession, datasource_id: int) -> Optional[Col
return None 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("") @router.get("")
async def list_datasources( async def list_datasources(
module: Optional[str] = None, module: Optional[str] = None,
@@ -346,6 +416,7 @@ async def get_datasource_stats(
@router.post("/{source_id}/trigger") @router.post("/{source_id}/trigger")
async def trigger_datasource( async def trigger_datasource(
source_id: str, source_id: str,
force: bool = Query(False),
current_user: User = Depends(get_current_user), current_user: User = Depends(get_current_user),
db: AsyncSession = Depends(get_db), db: AsyncSession = Depends(get_db),
): ):
@@ -356,6 +427,26 @@ async def trigger_datasource(
if not datasource.is_active: if not datasource.is_active:
raise HTTPException(status_code=400, detail="Data source is disabled") 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) previous_task_id = await get_latest_task_id_for_datasource(datasource.id)
success = run_collector_now(datasource.source) success = run_collector_now(datasource.source)
if not success: if not success:
@@ -375,6 +466,7 @@ async def trigger_datasource(
"source_id": datasource.id, "source_id": datasource.id,
"task_id": task_id, "task_id": task_id,
"collector_name": datasource.source, "collector_name": datasource.source,
"force": force,
"message": f"Collector '{datasource.source}' has been triggered", "message": f"Collector '{datasource.source}' has been triggered",
} }

View File

@@ -1,5 +1,6 @@
"""Base collector class for all data sources""" """Base collector class for all data sources"""
import asyncio
from abc import ABC, abstractmethod from abc import ABC, abstractmethod
from typing import Dict, List, Any, Optional from typing import Dict, List, Any, Optional
from datetime import UTC, datetime from datetime import UTC, datetime
@@ -166,6 +167,59 @@ class BaseCollector(ABC):
await db.commit() await db.commit()
return snapshot.id 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]: async def run(self, db: AsyncSession) -> Dict[str, Any]:
"""Full pipeline: fetch -> transform -> save""" """Full pipeline: fetch -> transform -> save"""
from app.services.collectors.registry import collector_registry from app.services.collectors.registry import collector_registry
@@ -227,6 +281,21 @@ class BaseCollector(ABC):
"records_processed": records_count, "records_processed": records_count,
"execution_time_seconds": (datetime.now(UTC) - start_time).total_seconds(), "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: except Exception as e:
task.status = "failed" task.status = "failed"
task.phase = "failed" task.phase = "failed"
@@ -276,20 +345,34 @@ class BaseCollector(ABC):
updated_count = 0 updated_count = 0
unchanged_count = 0 unchanged_count = 0
seen_entity_keys: set[str] = set() seen_entity_keys: set[str] = set()
previous_current_keys: set[str] = set() progress_commit_interval = 1000
previous_current_result = await db.execute( previous_current_result = await db.execute(
select(CollectedData.entity_key).where( select(CollectedData)
.where(
CollectedData.source == self.name, CollectedData.source == self.name,
CollectedData.is_current == True, 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): 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", {}) raw_metadata = item.get("metadata", {})
extra_data = build_dynamic_metadata( extra_data = build_dynamic_metadata(
raw_metadata, raw_metadata,
@@ -318,20 +401,9 @@ class BaseCollector(ABC):
previous_record = None previous_record = None
if entity_key and entity_key not in seen_entity_keys: if entity_key and entity_key not in seen_entity_keys:
result = await db.execute( previous_record = previous_current_map.get(entity_key)
select(CollectedData) if previous_record is not None:
.where( previous_record.is_current = False
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
record = CollectedData( record = CollectedData(
snapshot_id=snapshot_id, snapshot_id=snapshot_id,
@@ -375,7 +447,7 @@ class BaseCollector(ABC):
seen_entity_keys.add(entity_key) seen_entity_keys.add(entity_key)
records_added += 1 records_added += 1
if i % 100 == 0: if (i + 1) % progress_commit_interval == 0:
await self.update_progress(i + 1, commit=True) await self.update_progress(i + 1, commit=True)
if snapshot_id is not None: if snapshot_id is not None:

View File

@@ -19,6 +19,30 @@ logger = logging.getLogger(__name__)
scheduler = AsyncIOScheduler() scheduler = AsyncIOScheduler()
RUNNING_TASK_GUARD_TIMEOUT_MINUTES = 90 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: 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") datasource.last_status = task_result.get("status")
await _update_next_run_at(datasource, db) await _update_next_run_at(datasource, db)
logger.info("Collector %s completed: %s", collector_name, task_result) 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: except Exception as exc:
datasource.last_run_at = datetime.now(UTC) datasource.last_run_at = datetime.now(UTC)
datasource.last_status = "failed" datasource.last_status = "failed"
@@ -245,9 +275,31 @@ def run_collector_now(collector_name: str) -> bool:
return False return False
try: 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) logger.info("Triggered collector: %s", collector_name)
return True return True
except Exception as exc: except Exception as exc:
logger.error("Failed to trigger collector %s: %s", collector_name, 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()

View File

@@ -1,6 +1,6 @@
import { useCallback, useEffect, useRef, useState } from 'react' import { useCallback, useEffect, useRef, useState } from 'react'
import { 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 Drawer, Tabs, Empty, Tooltip, Popconfirm, Collapse, InputNumber, Row, Col, Card
} from 'antd' } from 'antd'
import { import {
@@ -382,10 +382,12 @@ function DataSources() {
return () => clearInterval(interval) return () => clearInterval(interval)
}, [builtInSources, taskProgress, taskSocketConnected, fetchData]) }, [builtInSources, taskProgress, taskSocketConnected, fetchData])
const handleTrigger = async (id: number) => { const triggerDatasource = async (id: number, options?: { force?: boolean }) => {
try { const force = options?.force ?? false
const res = await axios.post(`/api/v1/datasources/${id}/trigger`) const res = await axios.post(`/api/v1/datasources/${id}/trigger`, null, {
messageApi.success('任务已触发') params: { force },
})
if (res.data.task_id) { if (res.data.task_id) {
setTaskProgress(prev => ({ setTaskProgress(prev => ({
...prev, ...prev,
@@ -402,10 +404,70 @@ function DataSources() {
fetchData() fetchData()
}, 800) }, 800)
} }
fetchData() 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) { } catch (error: unknown) {
const err = error as { response?: { data?: { detail?: string } } } const err = error as { response?: { data?: { detail?: string | { message?: string } } } }
messageApi.error(err.response?.data?.detail || '触发失败') const detail = err.response?.data?.detail
messageApi.error(typeof detail === 'string' ? detail : detail?.message || '强制重新采集失败')
}
},
})
}
const handleTrigger = async (id: number) => {
try {
await triggerDatasource(id)
messageApi.success('任务已触发')
} catch (error: unknown) {
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 () => { const handleUpdateSource = async () => {
if (!viewingSource) return if (!viewingSource) return
try { try {
await axios.post(`/api/v1/datasources/${viewingSource.id}/trigger`) await triggerDatasource(viewingSource.id)
messageApi.success('已触发更新') messageApi.success('已触发更新')
setViewDrawerVisible(false) setViewDrawerVisible(false)
} catch (error: unknown) { } catch (error: unknown) {
const err = error as { response?: { data?: { detail?: string } } } const err = error as { response?: { status?: number; data?: { detail?: string | {
messageApi.error(err.response?.data?.detail || '更新失败') 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
View File

@@ -5,11 +5,30 @@ set -e
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
cd "$SCRIPT_DIR" cd "$SCRIPT_DIR"
RED='\033[0;31m' RED='\033[38;5;203m'
GREEN='\033[0;32m' GREEN='\033[38;5;114m'
YELLOW='\033[1;33m' YELLOW='\033[38;5;221m'
BLUE='\033[0;34m' BLUE='\033[38;5;111m'
CYAN='\033[38;5;117m'
WHITE='\033[1;37m'
DIM='\033[2m'
NC='\033[0m' NC='\033[0m'
WAIT_SPINNER_FRAMES=(
'⠉⠁'
'⠈⠉'
'⠀⠙'
'⠀⠸'
'⠀⢰'
'⠀⣠'
'⢀⣀'
'⣀⡀'
'⣄⠀'
'⡆⠀'
'⠇⠀'
'⠋⠀'
)
WAIT_SPINNER_STEP=0
WAIT_SPINNER_TICKS_PER_SECOND=8
BACKEND_MAX_RETRIES="${BACKEND_MAX_RETRIES:-3}" BACKEND_MAX_RETRIES="${BACKEND_MAX_RETRIES:-3}"
BACKEND_HEALTH_CHECK_ATTEMPTS="${BACKEND_HEALTH_CHECK_ATTEMPTS:-10}" BACKEND_HEALTH_CHECK_ATTEMPTS="${BACKEND_HEALTH_CHECK_ATTEMPTS:-10}"
@@ -37,6 +56,100 @@ compose_up() {
fi 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() { run_with_retry() {
local max_retries="$1" local max_retries="$1"
local interval="$2" local interval="$2"
@@ -52,12 +165,12 @@ run_with_retry() {
fi fi
if [ "$attempt" -eq "$max_retries" ]; then if [ "$attempt" -eq "$max_retries" ]; then
echo -e "${RED}${failure_message}${NC}" clear_wait_spinner
log_error "${failure_message}"
return 1 return 1
fi fi
echo -e "${YELLOW}⚠️ ${retry_label}${attempt}/${max_retries} 次失败,${interval} 秒后重试...${NC}" animate_wait_spinner "${retry_label}${attempt}/${max_retries} 次失败,${interval} 秒后重试" "$interval"
sleep "$interval"
attempt=$((attempt + 1)) attempt=$((attempt + 1))
done done
@@ -65,17 +178,17 @@ run_with_retry() {
} }
ensure_uv_backend_deps() { ensure_uv_backend_deps() {
echo -e "${BLUE}📦 检查后端 uv 环境...${NC}" log_step "检查后端 uv 环境"
if ! command -v uv >/dev/null 2>&1; then if ! command -v uv >/dev/null 2>&1; then
echo -e "${RED}未找到 uv请先安装 uv 并加入 PATH${NC}" log_error "未找到 uv请先安装 uv 并加入 PATH"
exit 1 exit 1
fi fi
cd "$SCRIPT_DIR" cd "$SCRIPT_DIR"
if [ ! -x "$SCRIPT_DIR/.venv/bin/python" ]; then if [ ! -x "$SCRIPT_DIR/.venv/bin/python" ]; then
echo -e "${YELLOW}⚠️ 未检测到 .venv正在执行 uv sync...${NC}" log_warn "未检测到 .venv正在执行 uv sync"
if ! run_with_retry \ if ! run_with_retry \
"$DEPENDENCY_INSTALL_MAX_RETRIES" \ "$DEPENDENCY_INSTALL_MAX_RETRIES" \
"$DEPENDENCY_INSTALL_RETRY_INTERVAL" \ "$DEPENDENCY_INSTALL_RETRY_INTERVAL" \
@@ -87,23 +200,23 @@ ensure_uv_backend_deps() {
fi fi
if [ ! -x "$SCRIPT_DIR/.venv/bin/python" ]; then if [ ! -x "$SCRIPT_DIR/.venv/bin/python" ]; then
echo -e "${RED}uv 环境初始化失败,未找到 .venv/bin/python${NC}" log_error "uv 环境初始化失败,未找到 .venv/bin/python"
exit 1 exit 1
fi fi
} }
ensure_frontend_deps() { ensure_frontend_deps() {
echo -e "${BLUE}📦 检查前端依赖...${NC}" log_step "检查前端依赖"
if ! command -v bun >/dev/null 2>&1; then if ! command -v bun >/dev/null 2>&1; then
echo -e "${RED}未找到 bun请先安装或加载 bun 到 PATH${NC}" log_error "未找到 bun请先安装或加载 bun 到 PATH"
exit 1 exit 1
fi fi
cd "$SCRIPT_DIR/frontend" cd "$SCRIPT_DIR/frontend"
if [ ! -x "$SCRIPT_DIR/frontend/node_modules/.bin/vite" ]; then if [ ! -x "$SCRIPT_DIR/frontend/node_modules/.bin/vite" ]; then
echo -e "${YELLOW}⚠️ 前端依赖缺失,正在执行 bun install...${NC}" log_warn "前端依赖缺失,正在执行 bun install"
if ! run_with_retry \ if ! run_with_retry \
"$DEPENDENCY_INSTALL_MAX_RETRIES" \ "$DEPENDENCY_INSTALL_MAX_RETRIES" \
"$DEPENDENCY_INSTALL_RETRY_INTERVAL" \ "$DEPENDENCY_INSTALL_RETRY_INTERVAL" \
@@ -115,7 +228,7 @@ ensure_frontend_deps() {
fi fi
if [ ! -x "$SCRIPT_DIR/frontend/node_modules/.bin/vite" ]; then if [ ! -x "$SCRIPT_DIR/frontend/node_modules/.bin/vite" ]; then
echo -e "${RED}前端依赖安装失败,未找到 vite${NC}" log_error "前端依赖安装失败,未找到 vite"
exit 1 exit 1
fi fi
} }
@@ -129,14 +242,15 @@ wait_for_http() {
while [ "$attempt" -le "$attempts" ]; do while [ "$attempt" -le "$attempts" ]; do
if curl -s "$url" > /dev/null 2>&1; then if curl -s "$url" > /dev/null 2>&1; then
finish_wait_spinner "${service_name}已就绪"
return 0 return 0
fi fi
echo -e "${YELLOW}等待${service_name}就绪...${NC}" animate_wait_spinner "等待${service_name}就绪" "$interval"
sleep "$interval"
attempt=$((attempt + 1)) attempt=$((attempt + 1))
done done
clear_wait_spinner
return 1 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)" 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 if [ "$status" = "healthy" ] || [ "$status" = "running" ]; then
finish_wait_spinner "${service_name}容器健康检查已通过"
return 0 return 0
fi fi
echo -e "${YELLOW}等待${service_name}容器健康检查通过...${NC}" animate_wait_spinner "等待${service_name}容器健康检查通过" "$interval"
sleep "$interval"
attempt=$((attempt + 1)) attempt=$((attempt + 1))
done done
clear_wait_spinner
return 1 return 1
} }
@@ -173,15 +288,15 @@ wait_for_postgres_health() {
} }
start_database_services() { 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() { 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() { 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() { start_backend_with_retry() {
@@ -198,11 +313,12 @@ start_backend_with_retry() {
return 0 return 0
fi fi
echo -e "${YELLOW}⚠️ 后端第 ${retry}/${BACKEND_MAX_RETRIES} 次启动未就绪,准备重试...${NC}"
kill "$BACKEND_PID" 2>/dev/null || true kill "$BACKEND_PID" 2>/dev/null || true
animate_wait_spinner "后端第 ${retry}/${BACKEND_MAX_RETRIES} 次启动未就绪,准备重试" "$BACKEND_HEALTH_CHECK_INTERVAL"
retry=$((retry + 1)) retry=$((retry + 1))
done done
clear_wait_spinner
return 1 return 1
} }
@@ -210,10 +326,10 @@ start_ai_provider_service() {
local ai_provider_port="${1:-$DEFAULT_AI_PROVIDER_PORT}" local ai_provider_port="${1:-$DEFAULT_AI_PROVIDER_PORT}"
local retry=1 local retry=1
echo -e "${BLUE}🧠 启动 AI Provider...${NC}" log_step "启动 AI Provider"
while [ "$retry" -le "$AI_PROVIDER_START_MAX_RETRIES" ]; do 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" && 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 wait_for_http "http://localhost:${ai_provider_port}/health" "$AI_PROVIDER_HEALTH_CHECK_ATTEMPTS" "$AI_PROVIDER_HEALTH_CHECK_INTERVAL" "AI Provider"; then
return 0 return 0
@@ -221,18 +337,19 @@ start_ai_provider_service() {
fi fi
if [ "$retry" -eq "$AI_PROVIDER_START_MAX_RETRIES" ]; then 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 docker logs --tail 20 planet_aiprovider 2>/dev/null || true
exit 1 exit 1
fi fi
echo -e "${YELLOW}⚠️ AI Provider 第 ${retry}/${AI_PROVIDER_START_MAX_RETRIES} 次启动后仍未健康,正在重启容器...${NC}" docker restart planet_aiprovider >/dev/null 2>&1 || true
docker restart planet_aiprovider 2>/dev/null || true animate_wait_spinner "AI Provider 第 ${retry}/${AI_PROVIDER_START_MAX_RETRIES} 次启动后仍未健康,正在重启容器" "$AI_PROVIDER_RETRY_INTERVAL"
sleep "$AI_PROVIDER_RETRY_INTERVAL"
retry=$((retry + 1)) retry=$((retry + 1))
done done
echo -e "${RED}❌ AI Provider 启动失败${NC}" clear_wait_spinner
log_error "AI Provider 启动失败"
docker logs --tail 20 planet_aiprovider 2>/dev/null || true docker logs --tail 20 planet_aiprovider 2>/dev/null || true
exit 1 exit 1
} }
@@ -252,11 +369,12 @@ start_frontend_with_retry() {
return 0 return 0
fi fi
echo -e "${YELLOW}⚠️ 前端第 ${retry}/${FRONTEND_MAX_RETRIES} 次启动未就绪,准备重试...${NC}"
kill "$FRONTEND_PID" 2>/dev/null || true kill "$FRONTEND_PID" 2>/dev/null || true
animate_wait_spinner "前端第 ${retry}/${FRONTEND_MAX_RETRIES} 次启动未就绪,准备重试" "$FRONTEND_HEALTH_CHECK_INTERVAL"
retry=$((retry + 1)) retry=$((retry + 1))
done done
clear_wait_spinner
return 1 return 1
} }
@@ -269,15 +387,15 @@ ensure_database_services_healthy() {
fi fi
if [ "$retry" -eq "$DATABASE_START_MAX_RETRIES" ]; then 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_postgres 2>/dev/null || true
docker logs --tail 20 planet_redis 2>/dev/null || true docker logs --tail 20 planet_redis 2>/dev/null || true
exit 1 exit 1
fi fi
echo -e "${YELLOW}⚠️ 数据库第 ${retry}/${DATABASE_START_MAX_RETRIES} 次启动后仍未健康,正在重启容器...${NC}" docker restart planet_postgres planet_redis >/dev/null 2>&1 || true
docker restart planet_postgres planet_redis 2>/dev/null || true animate_wait_spinner "数据库第 ${retry}/${DATABASE_START_MAX_RETRIES} 次启动后仍未健康,正在重启容器" "$DATABASE_RETRY_INTERVAL"
sleep "$DATABASE_RETRY_INTERVAL"
retry=$((retry + 1)) retry=$((retry + 1))
done done
} }
@@ -291,14 +409,14 @@ ensure_postgres_service_healthy() {
fi fi
if [ "$retry" -eq "$DATABASE_START_MAX_RETRIES" ]; then 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 docker logs --tail 20 planet_postgres 2>/dev/null || true
exit 1 exit 1
fi fi
echo -e "${YELLOW}⚠️ PostgreSQL 第 ${retry}/${DATABASE_START_MAX_RETRIES} 次启动后仍未健康,正在重启容器...${NC}" docker restart planet_postgres >/dev/null 2>&1 || true
docker restart planet_postgres 2>/dev/null || true animate_wait_spinner "PostgreSQL 第 ${retry}/${DATABASE_START_MAX_RETRIES} 次启动后仍未健康,正在重启容器" "$DATABASE_RETRY_INTERVAL"
sleep "$DATABASE_RETRY_INTERVAL"
retry=$((retry + 1)) retry=$((retry + 1))
done done
} }
@@ -306,7 +424,7 @@ ensure_postgres_service_healthy() {
restart_database_service() { restart_database_service() {
local retry=1 local retry=1
echo -e "${BLUE}🗄️ 重启数据库...${NC}" log_step "重启数据库"
while [ "$retry" -le "$DATABASE_START_MAX_RETRIES" ]; do while [ "$retry" -le "$DATABASE_START_MAX_RETRIES" ]; do
if restart_database_services && wait_for_database_health; then if restart_database_services && wait_for_database_health; then
@@ -315,14 +433,14 @@ restart_database_service() {
fi fi
if [ "$retry" -eq "$DATABASE_START_MAX_RETRIES" ]; then 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_postgres 2>/dev/null || true
docker logs --tail 20 planet_redis 2>/dev/null || true docker logs --tail 20 planet_redis 2>/dev/null || true
exit 1 exit 1
fi fi
echo -e "${YELLOW}⚠️ 数据库第 ${retry}/${DATABASE_START_MAX_RETRIES} 次重启后仍未健康,继续重试...${NC}" animate_wait_spinner "数据库第 ${retry}/${DATABASE_START_MAX_RETRIES} 次重启后仍未健康,继续重试" "$DATABASE_RETRY_INTERVAL"
sleep "$DATABASE_RETRY_INTERVAL"
retry=$((retry + 1)) retry=$((retry + 1))
done done
} }
@@ -332,7 +450,7 @@ start_backend_service() {
local backend_port_requested="$2" local backend_port_requested="$2"
local ai_provider_port="${3:-$DEFAULT_AI_PROVIDER_PORT}" local ai_provider_port="${3:-$DEFAULT_AI_PROVIDER_PORT}"
echo -e "${BLUE}🗄️ 启动数据库...${NC}" log_step "启动数据库"
ensure_database_services_healthy ensure_database_services_healthy
sleep 3 sleep 3
@@ -342,10 +460,10 @@ start_backend_service() {
kill_port_if_requested "$backend_port" "后端" kill_port_if_requested "$backend_port" "后端"
fi fi
echo -e "${BLUE}🔧 启动后端...${NC}" log_step "启动后端"
ensure_uv_backend_deps ensure_uv_backend_deps
if ! start_backend_with_retry "$backend_port"; then 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 tail -10 /tmp/planet_backend.log
exit 1 exit 1
fi fi
@@ -359,10 +477,10 @@ start_frontend_service() {
kill_port_if_requested "$frontend_port" "前端" kill_port_if_requested "$frontend_port" "前端"
fi fi
echo -e "${BLUE}🌐 启动前端...${NC}" log_step "启动前端"
ensure_frontend_deps ensure_frontend_deps
if ! start_frontend_with_retry "$frontend_port"; then 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 tail -10 /tmp/planet_frontend.log
exit 1 exit 1
fi fi
@@ -372,7 +490,7 @@ validate_port() {
local port="$1" local port="$1"
if ! [[ "$port" =~ ^[0-9]+$ ]] || [ "$port" -lt 1 ] || [ "$port" -gt 65535 ]; then if ! [[ "$port" =~ ^[0-9]+$ ]] || [ "$port" -lt 1 ] || [ "$port" -gt 65535 ]; then
echo -e "${RED}非法端口: ${port}${NC}" log_error "非法端口: ${port}"
exit 1 exit 1
fi fi
} }
@@ -381,10 +499,10 @@ kill_port_if_requested() {
local port="$1" local port="$1"
local service_name="$2" 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 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 fuser -k "${port}/tcp" >/dev/null 2>&1 || true
sleep 1 sleep 1
return 0 return 0
@@ -394,14 +512,14 @@ kill_port_if_requested() {
local pids local pids
pids="$(lsof -ti tcp:"${port}" 2>/dev/null || true)" pids="$(lsof -ti tcp:"${port}" 2>/dev/null || true)"
if [ -n "$pids" ]; then if [ -n "$pids" ]; then
echo -e "${BLUE}🔌 发现端口 ${port} 占用,正在终止...${NC}" log_step "发现端口 ${port} 占用,正在终止"
kill $pids 2>/dev/null || true kill $pids 2>/dev/null || true
sleep 1 sleep 1
return 0 return 0
fi fi
fi fi
echo -e "${GREEN}端口 ${port} 未被占用${NC}" log_success "端口 ${port} 未被占用"
} }
parse_service_args() { parse_service_args() {
@@ -447,7 +565,7 @@ parse_service_args() {
shift 1 shift 1
;; ;;
*) *)
echo -e "${RED}❌ 未知参数: $1${NC}" log_error "未知参数: $1"
exit 1 exit 1
;; ;;
esac esac
@@ -462,9 +580,9 @@ cleanup_exit_containers() {
local exit_containers local exit_containers
exit_containers="$(docker ps -a --filter status=exited -q 2>/dev/null || true)" exit_containers="$(docker ps -a --filter status=exited -q 2>/dev/null || true)"
if [ -n "$exit_containers" ]; then 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 "$exit_containers" | xargs -r docker rm -f >/dev/null 2>&1 || true
echo -e "${GREEN}✅ 残留容器已清理${NC}" log_success "残留容器已清理"
fi fi
} }
@@ -492,15 +610,15 @@ create_user() {
ensure_uv_backend_deps ensure_uv_backend_deps
echo -e "${BLUE}🗄️ 启动数据库...${NC}" log_step "启动数据库"
ensure_postgres_service_healthy ensure_postgres_service_healthy
sleep 2 sleep 2
echo -e "${BLUE}👤 创建用户${NC}" log_step "创建用户"
read -r -p "用户名: " username read -r -p "用户名: " username
if [ -z "$username" ]; then if [ -z "$username" ]; then
echo -e "${RED}❌ 用户名不能为空${NC}" log_error "用户名不能为空"
exit 1 exit 1
fi fi
@@ -516,17 +634,17 @@ create_user() {
echo "" echo ""
if [ -z "$password" ]; then if [ -z "$password" ]; then
echo -e "${RED}❌ 密码不能为空${NC}" log_error "密码不能为空"
exit 1 exit 1
fi fi
if [ "${#password}" -lt 8 ]; then if [ "${#password}" -lt 8 ]; then
echo -e "${RED}❌ 密码长度不能少于 8 位${NC}" log_error "密码长度不能少于 8 位"
exit 1 exit 1
fi fi
if [ "$password" != "$password_confirm" ]; then if [ "$password" != "$password_confirm" ]; then
echo -e "${RED}❌ 两次输入的密码不一致${NC}" log_error "两次输入的密码不一致"
exit 1 exit 1
fi fi
@@ -580,35 +698,36 @@ async def main():
asyncio.run(main()) asyncio.run(main())
PY PY
echo -e "${GREEN}✅ 用户创建成功${NC}" log_success "用户创建成功"
echo " 用户名: ${username}" log_note "用户名: ${username}"
echo " 角色: ${role}" log_note "角色: ${role}"
echo " 邮箱: ${email}" log_note "邮箱: ${email}"
} }
start() { start() {
parse_service_args "$@" parse_service_args "$@"
cleanup_exit_containers cleanup_exit_containers
echo -e "${BLUE}🚀 启动智能星球计划...${NC}" print_splash
log_step "启动智能星球计划"
start_backend_service "$BACKEND_PORT" "$BACKEND_PORT_REQUESTED" "$AI_PROVIDER_PORT" start_backend_service "$BACKEND_PORT" "$BACKEND_PORT_REQUESTED" "$AI_PROVIDER_PORT"
start_frontend_service "$FRONTEND_PORT" "$FRONTEND_PORT_REQUESTED" start_frontend_service "$FRONTEND_PORT" "$FRONTEND_PORT_REQUESTED"
echo "" echo ""
echo -e "${GREEN}✅ 启动完成!${NC}" log_success "启动完成"
echo " 前端: http://localhost:${FRONTEND_PORT}" log_note "前端: http://localhost:${FRONTEND_PORT}"
echo " 后端: http://localhost:${BACKEND_PORT}" log_note "后端: http://localhost:${BACKEND_PORT}"
echo " AI Provider: http://localhost:${AI_PROVIDER_PORT}" log_note "AI Provider: http://localhost:${AI_PROVIDER_PORT}"
} }
stop() { stop() {
echo -e "${YELLOW}🛑 停止服务...${NC}" log_warn "停止服务"
stop_backend_service stop_backend_service
stop_ai_provider_service stop_ai_provider_service
stop_frontend_service stop_frontend_service
docker stop planet_postgres planet_redis 2>/dev/null || true docker stop planet_postgres planet_redis 2>/dev/null || true
echo -e "${GREEN}✅ 已停止${NC}" log_success "已停止"
} }
restart() { restart() {
@@ -622,7 +741,7 @@ restart() {
return 0 return 0
fi fi
echo -e "${YELLOW}🔄 按需重启服务...${NC}" log_warn "按需重启服务"
if [ "$DATABASE_REQUESTED" -eq 1 ]; then if [ "$DATABASE_REQUESTED" -eq 1 ]; then
restart_database_service restart_database_service
@@ -647,43 +766,43 @@ restart() {
fi fi
echo "" echo ""
echo -e "${GREEN}✅ 重启完成!${NC}" log_success "重启完成"
if [ "$DATABASE_REQUESTED" -eq 1 ]; then if [ "$DATABASE_REQUESTED" -eq 1 ]; then
echo " 数据库: planet_postgres, planet_redis" log_note "数据库: planet_postgres, planet_redis"
fi fi
if [ "$AI_PROVIDER_REQUESTED" -eq 1 ]; then 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 fi
if [ "$BACKEND_PORT_REQUESTED" -eq 1 ]; then if [ "$BACKEND_PORT_REQUESTED" -eq 1 ]; then
echo " 后端: http://localhost:${BACKEND_PORT}" log_note "后端: http://localhost:${BACKEND_PORT}"
fi fi
if [ "$FRONTEND_PORT_REQUESTED" -eq 1 ]; then if [ "$FRONTEND_PORT_REQUESTED" -eq 1 ]; then
echo " 前端: http://localhost:${FRONTEND_PORT}" log_note "前端: http://localhost:${FRONTEND_PORT}"
fi fi
} }
health() { health() {
echo "📊 容器状态:" echo -e "${DIM}·${NC} ${BLUE}view${NC} ${WHITE}容器状态${NC}"
docker ps --filter "name=planet_" --format "table {{.Names}}\t{{.Status}}\t{{.Ports}}" docker ps --filter "name=planet_" --format "table {{.Names}}\t{{.Status}}\t{{.Ports}}"
echo "" echo ""
echo "🔍 服务状态:" echo -e "${DIM}·${NC} ${BLUE}view${NC} ${WHITE}服务状态${NC}"
if curl -s http://localhost:8000/health > /dev/null 2>&1; then if curl -s http://localhost:8000/health > /dev/null 2>&1; then
echo -e " 后端: ${GREEN}✅ 运行中${NC}" echo -e "${DIM} 后端:${NC} ${GREEN}online${NC}"
else else
echo -e " 后端: ${RED}❌ 未运行${NC}" echo -e "${DIM} 后端:${NC} ${RED}offline${NC}"
fi fi
if curl -s "http://localhost:${DEFAULT_AI_PROVIDER_PORT}/health" > /dev/null 2>&1; then 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 else
echo -e " AI Provider: ${RED}❌ 未运行${NC}" echo -e "${DIM} AI Provider:${NC} ${RED}offline${NC}"
fi fi
if curl -s http://localhost:3000 > /dev/null 2>&1; then if curl -s http://localhost:3000 > /dev/null 2>&1; then
echo -e " 前端: ${GREEN}✅ 运行中${NC}" echo -e "${DIM} 前端:${NC} ${GREEN}online${NC}"
else else
echo -e " 前端: ${RED}❌ 未运行${NC}" echo -e "${DIM} 前端:${NC} ${RED}offline${NC}"
fi fi
} }

2
uv.lock generated
View File

@@ -475,7 +475,7 @@ wheels = [
[[package]] [[package]]
name = "planet" name = "planet"
version = "0.23.0" version = "0.23.1"
source = { virtual = "." } source = { virtual = "." }
dependencies = [ dependencies = [
{ name = "aiofiles" }, { name = "aiofiles" },