#!/bin/zsh # 双击本文件,或在终端执行:./start.command cd "$(dirname "$0")" || exit 1 PROJECT_DIR="$PWD" BACKEND_HOST="127.0.0.1" BACKEND_PORT="8800" BACKEND_URL="http://${BACKEND_HOST}:${BACKEND_PORT}" STUDIO_URL="http://localhost:8900" RUNTIME_DIR="$PROJECT_DIR/data/runtime" INSTANCE_PID_FILE="$RUNTIME_DIR/start-command.pid" # 两个前端项目均锁定 pnpm 10.32.1。系统的 `pnpm` 是 Corepack shim, # 其默认版本可能随 Node 更新而变化,因此显式指定项目所需的版本。 PNPM_VERSION="10.32.1" project_pnpm() { corepack "pnpm@${PNPM_VERSION}" "$@" } # 由当前 start.command 管理的子进程。它们都在清理函数里按 PID 树结束, # 不扫描或误杀其他项目的 Python / Node 进程。 BACKEND_PID="" STUDIO_PID="" WORKER_SUPERVISOR_PID="" WORKER_SOURCE_WATCH_PID="" COLLECTOR_WATCH_PID="" COLLECTOR_STAMP="" WORKER_SOURCE_STAMP="" CLEANUP_DONE=false stop_process_tree() { local pid="$1" local child [[ "$pid" == <-> ]] || return 0 kill -0 "$pid" 2>/dev/null || return 0 # 先停止子进程,避免 Worker 守护循环在父进程退出前重新拉起 Worker。 for child in $(pgrep -P "$pid" 2>/dev/null); do stop_process_tree "$child" done kill -TERM "$pid" 2>/dev/null || true } cleanup() { [[ "$CLEANUP_DONE" == true ]] && return CLEANUP_DONE=true stop_process_tree "$BACKEND_PID" stop_process_tree "$STUDIO_PID" stop_process_tree "$WORKER_SUPERVISOR_PID" stop_process_tree "$WORKER_SOURCE_WATCH_PID" stop_process_tree "$COLLECTOR_WATCH_PID" [[ -n "$COLLECTOR_STAMP" ]] && rm -f "$COLLECTOR_STAMP" 2>/dev/null [[ -n "$WORKER_SOURCE_STAMP" ]] && rm -f "$WORKER_SOURCE_STAMP" 2>/dev/null if [[ -f "$INSTANCE_PID_FILE" ]] && [[ "$(<"$INSTANCE_PID_FILE")" == "$$" ]]; then rm -f "$INSTANCE_PID_FILE" fi } on_signal() { exit 0 } stop_previous_instance() { [[ -f "$INSTANCE_PID_FILE" ]] || return 0 local previous_pid command cwd previous_pid="$(<"$INSTANCE_PID_FILE")" if [[ "$previous_pid" != <-> ]] || ! kill -0 "$previous_pid" 2>/dev/null; then rm -f "$INSTANCE_PID_FILE" return 0 fi command="$(ps -p "$previous_pid" -o command= 2>/dev/null)" cwd="$(lsof -a -p "$previous_pid" -d cwd -Fn 2>/dev/null | sed -n 's/^n//p' | head -n 1)" if [[ "$command" != *"start.command"* || "$cwd" != "$PROJECT_DIR" ]]; then echo "⚠️ 发现过期的启动记录,未匹配到本项目进程,已忽略。" rm -f "$INSTANCE_PID_FILE" return 0 fi echo "检测到已有工具台实例(PID: $previous_pid),正在停止后重新启动…" stop_process_tree "$previous_pid" for _ in {1..50}; do kill -0 "$previous_pid" 2>/dev/null || break sleep 0.1 done if kill -0 "$previous_pid" 2>/dev/null; then echo "⚠️ 旧实例未能停止;为避免启动重复 Worker,本次不启动新实例。" exit 1 fi rm -f "$INSTANCE_PID_FILE" } stop_unmanaged_project_workers() { # PID 文件只能管理此前由 start.command 拉起的实例;这里额外收敛曾被手动 # 启动、但 cwd 明确属于当前项目的 Worker。绝不按 Python 名称做全局清理。 local pid parent_pid command cwd parent_command parent_cwd for pid in $(pgrep -f 'server/worker\.py' 2>/dev/null); do command="$(ps -p "$pid" -o command= 2>/dev/null)" cwd="$(lsof -a -p "$pid" -d cwd -Fn 2>/dev/null | sed -n 's/^n//p' | head -n 1)" [[ "$command" == *"server/worker.py"* && "$cwd" == "$PROJECT_DIR" ]] || continue parent_pid="$(ps -p "$pid" -o ppid= 2>/dev/null | tr -d ' ')" parent_command="$(ps -p "$parent_pid" -o command= 2>/dev/null)" parent_cwd="$(lsof -a -p "$parent_pid" -d cwd -Fn 2>/dev/null | sed -n 's/^n//p' | head -n 1)" echo "检测到遗留 Worker(PID: $pid),正在停止…" if [[ "$parent_command" == *"start.command"* && "$parent_cwd" == "$PROJECT_DIR" ]]; then # 这是脱离 PID 文件的旧守护循环;连同父进程停掉,避免它 2 秒后重拉 Worker。 stop_process_tree "$parent_pid" else # 手动启动的 Worker 只停止自身,不触碰其可能属于其他工具的父终端。 kill -TERM "$pid" 2>/dev/null || true fi done } mkdir -p "$RUNTIME_DIR" || exit 1 stop_previous_instance stop_unmanaged_project_workers print -r -- "$$" > "$INSTANCE_PID_FILE" trap cleanup EXIT trap on_signal INT TERM HUP if [[ ! -d .venv ]]; then echo "未找到 .venv,正在创建…" python3 -m venv .venv || exit 1 fi source .venv/bin/activate # 每次启动都同步依赖,requirements.txt 有更新时自动补装(已装满时耗时极短) pip install -q -r server/requirements.txt || exit 1 if [[ ! -f .env ]]; then echo "未找到 .env,已从 .env.example 复制,请填入 DEEPSEEK_API_KEY 后再启动。" cp .env.example .env echo "按回车关闭…" read -r exit 1 fi # 数据结构变更由 Alembic 管理;必须先迁移,再启动会读取新字段的服务端。 if ! alembic upgrade head; then echo "⚠️ 数据库迁移失败,停止启动以避免新旧代码与表结构不一致" exit 1 fi # ---- 采集插件(extensions/collector):启动时构建一次,之后监听代码改动自动重建 ---- # 产物 .output/chrome-mv3 由 Chrome 以解压方式加载;重建后仍需去 chrome://extensions 点刷新 COLLECTOR_STAMP="$(mktemp -u /tmp/osk-collector-stamp.XXXXXX)" watch_collector() { touch "$COLLECTOR_STAMP" while true; do sleep 3 if [[ -n "$(find extensions/collector/src extensions/collector/wxt.config.ts -newer "$COLLECTOR_STAMP" -print 2>/dev/null)" ]]; then touch "$COLLECTOR_STAMP" echo "🔧 检测到插件代码改动,重新构建…" if project_pnpm -C extensions/collector build >/dev/null 2>&1; then echo "✅ 采集插件已重建($(date +%H:%M:%S)),去 chrome://extensions 点刷新生效" else echo "⚠️ 采集插件重建失败($(date +%H:%M:%S)):可手动跑 pnpm -C extensions/collector build 看报错" fi fi done } if [[ ! -d extensions/collector ]]; then echo "⚠️ 未找到 extensions/collector,跳过采集插件构建" elif ! command -v corepack >/dev/null 2>&1; then echo "⚠️ 未找到 Corepack,本次跳过采集插件构建(可手动:corepack pnpm@${PNPM_VERSION} -C extensions/collector build)" elif ! CI=true project_pnpm -C extensions/collector install --silent; then echo "⚠️ 采集插件依赖安装失败,本次跳过构建" else echo "正在构建采集插件…" if project_pnpm -C extensions/collector build; then echo "采集插件已构建:extensions/collector/.output/chrome-mv3" else echo "⚠️ 采集插件构建失败,继续启动服务(可手动重试:pnpm -C extensions/collector build)" fi watch_collector & COLLECTOR_WATCH_PID=$! fi echo # ---- 持久化后台任务 Worker(与 uvicorn 独立,服务重启不会中断正在轮询的模型任务)---- # Worker 因配置/网络等未捕获错误退出时,2 秒后自动拉起;否则任务会长期停在「排队中」。 supervise_worker() { while true; do python server/worker.py WORKER_EXIT_CODE=$? echo "⚠️ 后台任务 Worker 已退出(code: $WORKER_EXIT_CODE),2 秒后自动重启…" sleep 2 done } supervise_worker & WORKER_SUPERVISOR_PID=$! echo "后台任务 Worker 守护进程已启动(PID: $WORKER_SUPERVISOR_PID)" # Uvicorn 的热更新只管理 Web 进程;Worker 独立运行,代码更新后必须主动重启, # 否则它会继续用内存中已导入的旧 generator.py 提交模型任务。 WORKER_SOURCE_STAMP="$(mktemp /tmp/osk-worker-source.XXXXXX)" watch_worker_source() { while true; do sleep 2 if [[ -n "$(find server -type f \( -name '*.py' -o -name '*.yaml' \) -newer "$WORKER_SOURCE_STAMP" -print -quit 2>/dev/null)" ]]; then touch "$WORKER_SOURCE_STAMP" echo "🔧 检测到 Worker 源码改动,正在重启 Worker 以应用新版本…" for worker_pid in $(pgrep -P "$WORKER_SUPERVISOR_PID" 2>/dev/null); do kill -TERM "$worker_pid" 2>/dev/null || true done fi done } watch_worker_source & WORKER_SOURCE_WATCH_PID=$! if ! command -v corepack >/dev/null 2>&1; then echo "⚠️ 未找到 Corepack,本次跳过 studio 前端(可手动:corepack pnpm@${PNPM_VERSION} -C studio dev)" # CI=true:node_modules 与 lockfile 不一致时自动重建,不弹确认 elif CI=true project_pnpm -C studio install --silent; then project_pnpm -C studio dev & # vite:8900,启动后会自动打开浏览器 STUDIO_PID=$! else echo "⚠️ studio 依赖安装失败,本次只启动后端" fi echo echo "前端访问地址:" echo " 工具台 v1(web/) $BACKEND_URL/ozonSeller.html" echo " 发布工作台(studio/) $STUDIO_URL" echo " 后端 API 文档 $BACKEND_URL/docs" echo echo "按 Ctrl+C 可停止全部服务" echo # --app-dir server 让 uvicorn 在 server/ 下找 main:app,工作目录仍是仓库根(.env 在此)。 # 后端改为后台子进程 + wait:start.command 本身始终存活,PID 文件和 EXIT 清理才能可靠生效。 uvicorn main:app --app-dir server --reload --reload-include '*.yaml' --host "$BACKEND_HOST" --port "$BACKEND_PORT" & BACKEND_PID=$! wait "$BACKEND_PID" echo echo "服务已停止。按回车关闭窗口…" read -r