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 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",
}

View File

@@ -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:

View File

@@ -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()

View File

@@ -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
View File

@@ -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
}

2
uv.lock generated
View File

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