From 46959fd5fc423f57b9fa110c7018da73c1c2d472 Mon Sep 17 00:00:00 2001 From: Stev_Wang <304865932@qq.com> Date: Tue, 3 Nov 2026 07:31:50 +0800 Subject: [PATCH] =?UTF-8?q?chore(dev):=20=E5=A2=9E=E5=8A=A0=E5=BC=80?= =?UTF-8?q?=E5=8F=91=E6=9C=BA=E4=B8=80=E9=94=AE=E5=90=AF=E5=8A=A8=E8=84=9A?= =?UTF-8?q?=E6=9C=AC=20dev.sh?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 背景: - 开发机上前端/后端/worker 需要分别启动,端口被旧进程占用时排查成本高; PostgreSQL / Redis 已按约定提前以 Docker 启动,脚本不再负责。 改动: - 新增 ./dev.sh:后台启动 api(3000)/web(5173)/worker,启动前自动检测并结束 端口占用进程(--no-kill 可改为直接失败),等待健康检查后返回。 - 提供 start/stop/restart/status/logs/clean 子命令;stop 按进程组结束并兜底 释放端口;支持 --api-only/--web-only/--no-worker/--follow。 - 自动加载仓库根 .env.local/.env 并导出给子进程(worker 读取 REDIS_URL 需要)。 - .dev/ 纳入 gitignore;README 本地开发章节补充脚本用法。 影响面: - 仅新增开发工具与文档,不影响构建、运行时与 CI;`pnpm dev` 保持原行为。 回滚方式: - 回滚本提交即可,无数据与配置变更。 验证证据: - bash -n 通过; - ./dev.sh start / status / stop / restart / clean / --follow 实测; - 占用 3000/5173 的假服务被自动结束并成功启动; - --no-kill、--api-only、--no-worker 行为符合预期。 --- .gitignore | 2 + README.md | 12 ++ dev.sh | 582 +++++++++++++++++++++++++++++++++++++++++++++++++++++ 3 files changed, 596 insertions(+) create mode 100755 dev.sh diff --git a/.gitignore b/.gitignore index 8d6020f..544c285 100644 --- a/.gitignore +++ b/.gitignore @@ -75,3 +75,5 @@ test-results/ design/ # Vite 缓存 .vite/ +# dev.sh 运行状态与日志 +.dev/ diff --git a/README.md b/README.md index 8cb6742..57ad5aa 100644 --- a/README.md +++ b/README.md @@ -79,6 +79,18 @@ pnpm db:seed # 幂等写入 system_configs 基线 pnpm dev # 并行启动 api(3000) / web(5173) / worker ``` +也可以使用一键脚本(后台启动、自动检测并释放端口、带健康检查): + +```bash +./dev.sh # 启动 api + web + worker +./dev.sh status # 端口 / PID / 健康状态 +./dev.sh logs [api|web|worker] # 跟踪日志(默认全部) +./dev.sh stop # 停止并释放 3000 / 5173 +./dev.sh --help # 全部参数 +``` + +> `dev.sh` 只负责开发服务,不启动 PostgreSQL / Redis 或任何 Docker 容器;这两者按约定在开发前已就绪。 + - **本地数据库**:直接复用本机已有 Docker 服务 `docker-postgres-1`(PostgreSQL 18,`127.0.0.1:5432`),库名 `lingxi`,用户 `postgres`,密码 `postgres`;Redis 复用 `docker-redis-1`(`127.0.0.1:6379`)。请先确认这两个容器已启动,本仓库不会另起 PG/Redis。 - **对象存储**:使用公网 RustFS(`https://oss.xinsenidea.com`,桶 `lingxi-dev`);A0 的 `assets / renders / temp` 三类产物暂共用该桶,待 A1 按前缀拆分。真实 access key / secret 只写入本地 `.env.local`(已 gitignore),仓库内只保留占位。 - **API 文档**:启动 api 后访问 `http://127.0.0.1:3000/api/docs`,OpenAPI JSON 在 `openapi/lingxi.openapi.json`。 diff --git a/dev.sh b/dev.sh new file mode 100755 index 0000000..823e3c0 --- /dev/null +++ b/dev.sh @@ -0,0 +1,582 @@ +#!/usr/bin/env bash +# +# 灵犀 · 开发机服务启动脚本 +# +# 只负责启动本地开发服务(api / web / worker),不启动 PostgreSQL、Redis 或任何 +# Docker 容器——这些按约定在开发前已经就绪。 +# +# 特性: +# · 启动前自动检测端口占用,占用则结束对应进程(可用 --no-kill 改为直接失败) +# · 后台启动 + 健康检查,返回后可用 logs/status/stop 管理 +# · 停止时按进程组结束,并兜底清理端口,避免遗留孤儿进程 +# · api(3000) / web(5173) / worker(无端口) +# +# 用法: +# ./dev.sh 启动(默认 api + web + worker) +# ./dev.sh start 同上 +# ./dev.sh stop 停止全部服务并释放端口 +# ./dev.sh restart 重启 +# ./dev.sh status 查看端口、PID 与健康状态 +# ./dev.sh logs [服务名] 跟踪日志(api|web|worker,默认全部) +# ./dev.sh clean 停止并删除 .dev 状态与日志 +# +# 选项: +# --api-only 只启动 api +# --web-only 只启动 web +# --no-worker 不启动 worker +# --follow, -f 启动后前台跟踪日志,Ctrl+C 停止全部 +# --no-kill 端口占用时不自动结束,直接报错退出 +# -h, --help 显示帮助 +# +# 环境变量: +# API_PORT 覆盖 api 端口(默认取 .env.local/.env 的 PORT,否则 3000) +# WEB_PORT 覆盖 web 端口(默认 5173,需与 apps/web/vite.config.ts 一致) +# NO_COLOR 关闭彩色输出 +# +set -Eeuo pipefail + +ROOT="$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd)" +cd "$ROOT" + +STATE_DIR="$ROOT/.dev" +LOG_DIR="$STATE_DIR/logs" +PID_DIR="$STATE_DIR/pids" + +API_FILTER="@lingxi/api" +WEB_FILTER="@lingxi/web" +WORKER_FILTER="@lingxi/worker" + +# shellcheck disable=SC2034 +COMMAND="start" +SERVICES=("api" "web" "worker") +FOLLOW=0 +AUTO_KILL=1 + +# ---------- 输出 ---------- +if [[ -t 1 && -z "${NO_COLOR:-}" ]]; then + C_RESET=$'\033[0m'; C_DIM=$'\033[2m'; C_RED=$'\033[31m' + C_GREEN=$'\033[32m'; C_YELLOW=$'\033[33m'; C_BLUE=$'\033[36m' +else + C_RESET=""; C_DIM=""; C_RED=""; C_GREEN=""; C_YELLOW=""; C_BLUE="" +fi + +info() { printf '%s[dev]%s %s\n' "$C_BLUE" "$C_RESET" "$*"; } +ok() { printf '%s[dev]%s %s\n' "$C_GREEN" "$C_RESET" "$*"; } +warn() { printf '%s[dev]%s %s\n' "$C_YELLOW" "$C_RESET" "$*" >&2; } +die() { printf '%s[dev]%s %s\n' "$C_RED" "$C_RESET" "$*" >&2; exit 1; } +dim() { printf '%s%s%s\n' "$C_DIM" "$*" "$C_RESET"; } + +usage() { + cat <<'EOF_USAGE' +灵犀 · 开发机服务启动脚本(不启动 DB / Redis 容器) + +用法: + ./dev.sh 启动(默认 api + web + worker) + ./dev.sh start 同上 + ./dev.sh stop 停止全部服务并释放端口 + ./dev.sh restart 重启 + ./dev.sh status 查看端口、PID 与健康状态 + ./dev.sh logs [服务名] 跟踪日志(api|web|worker,默认全部) + ./dev.sh clean 停止并删除 .dev 状态与日志 + +选项: + --api-only 只启动 api + --web-only 只启动 web + --no-worker 不启动 worker + --follow, -f 启动后前台跟踪日志,Ctrl+C 停止全部 + --no-kill 端口占用时不自动结束,直接报错退出 + -h, --help 显示帮助 + +环境变量: + API_PORT 覆盖 api 端口(默认取 .env.local/.env 的 PORT,否则 3000) + WEB_PORT 覆盖 web 端口(默认 5173) + NO_COLOR 关闭彩色输出 +EOF_USAGE +} + +# ---------- 参数 ---------- +parse_args() { + while (($# > 0)); do + case "$1" in + start|stop|restart|status|clean) + COMMAND="$1" + shift + ;; + logs) + COMMAND="logs" + shift + if (($# > 0)) && [[ "$1" != -* ]]; then + LOG_TARGET="$1" + shift + fi + ;; + --api-only) + SERVICES=("api"); shift ;; + --web-only) + SERVICES=("web"); shift ;; + --no-worker) + local next=() + for service in "${SERVICES[@]}"; do + [[ "$service" == "worker" ]] || next+=("$service") + done + SERVICES=(${next[@]+"${next[@]}"}) + shift + ;; + --worker) + local has_worker=0 + for service in "${SERVICES[@]}"; do [[ "$service" == "worker" ]] && has_worker=1; done + ((has_worker == 1)) || SERVICES+=("worker") + shift + ;; + --follow|-f) + FOLLOW=1; shift ;; + --no-kill) + AUTO_KILL=0; shift ;; + -h|--help) + usage; exit 0 ;; + *) + die "未知参数:$1(用 --help 查看用法)" ;; + esac + done +} + +# ---------- 端口与进程 ---------- +listener_pids() { + local port="$1" + if command -v lsof >/dev/null 2>&1; then + lsof -nP -tiTCP:"$port" -sTCP:LISTEN 2>/dev/null || true + elif command -v ss >/dev/null 2>&1; then + ss -ltnpH "sport = :$port" 2>/dev/null | grep -oE 'pid=[0-9]+' | cut -d= -f2 | sort -u || true + elif command -v fuser >/dev/null 2>&1; then + fuser -n tcp "$port" 2>/dev/null | tr ' ' '\n' | grep -E '^[0-9]+$' || true + else + warn "缺少 lsof / ss / fuser,无法检测端口占用" + true + fi +} + +pid_alive() { + local pid="${1:-}" + [[ -n "$pid" ]] || return 1 + kill -0 "$pid" 2>/dev/null +} + +pid_command() { + local pid="$1" + ps -p "$pid" -o command= 2>/dev/null | tr -d '\n' || true +} + +process_brief() { + local pid="$1" + local name + name="$(pid_command "$pid")" + if [[ -z "$name" ]]; then + printf 'PID %s' "$pid" + else + printf 'PID %s (%s)' "$pid" "${name:0:64}" + fi +} + +kill_port() { + local port="$1" label="$2" pids pid + pids="$(listener_pids "$port")" + [[ -n "$pids" ]] || return 0 + + local brief="" + for pid in $pids; do + brief+="$(process_brief "$pid"); " + done + brief="${brief%; }" + + if ((AUTO_KILL == 0)); then + die "端口 ${port}(${label})已被占用:${brief}。已指定 --no-kill。" + fi + + warn "端口 ${port}(${label})被占用,结束进程:$brief" + for pid in $pids; do + kill -TERM "$pid" 2>/dev/null || true + done + for _ in $(seq 1 20); do + [[ -z "$(listener_pids "$port")" ]] && { ok "端口 $port 已释放"; return 0; } + sleep 0.25 + done + warn "端口 $port 优雅退出超时,强制结束" + for pid in $pids; do + kill -KILL "$pid" 2>/dev/null || true + done + sleep 0.3 + [[ -z "$(listener_pids "$port")" ]] || die "端口 $port 仍被占用,无法启动" + ok "端口 $port 已释放" +} + +stop_pid() { + local name="$1" + local pidfile="$PID_DIR/$name.pid" + local pid cmd + [[ -f "$pidfile" ]] || return 1 + pid="$(cat "$pidfile" 2>/dev/null || true)" + rm -f "$pidfile" + + pid_alive "$pid" || return 1 + cmd="$(pid_command "$pid")" + if [[ -n "$cmd" ]]; then + case "$cmd" in + *tsx*|*vite*|*pnpm*|*node*|*worker*) ;; + *) + warn "跳过 PID ${pid}(命令不匹配开发服务):${cmd:0:80}" + return 1 + ;; + esac + else + warn "无法读取 PID ${pid} 的命令(ps 不可用),仍尝试结束" + fi + + info "停止 ${name}(PID ${pid})" + kill -TERM -- "-$pid" 2>/dev/null || kill -TERM "$pid" 2>/dev/null || true + for _ in $(seq 1 20); do + pid_alive "$pid" || return 0 + sleep 0.25 + done + warn "$name 未在 5s 内退出,强制结束" + kill -KILL -- "-$pid" 2>/dev/null || kill -KILL "$pid" 2>/dev/null || true + return 0 +} + +# ---------- 服务元数据 ---------- +svc_port() { + case "$1" in + api) printf '%s' "$API_PORT" ;; + web) printf '%s' "$WEB_PORT" ;; + *) printf '' ;; + esac +} + +svc_filter() { + case "$1" in + api) printf '%s' "$API_FILTER" ;; + web) printf '%s' "$WEB_FILTER" ;; + worker) printf '%s' "$WORKER_FILTER" ;; + esac +} + +svc_log() { + printf '%s/%s.log' "$LOG_DIR" "$1" +} + +svc_health_url() { + case "$1" in + api) printf 'http://127.0.0.1:%s/health' "$API_PORT" ;; + web) printf 'http://127.0.0.1:%s/' "$WEB_PORT" ;; + *) printf '' ;; + esac +} + +# ---------- 环境 ---------- +read_env_port() { + local key="${1:-PORT}" file value + for file in "$ROOT/.env.local" "$ROOT/.env"; do + [[ -f "$file" ]] || continue + value="$(grep -E "^[[:space:]]*${key}[[:space:]]*=" "$file" 2>/dev/null | tail -n 1 | cut -d= -f2- | tr -d '"'"'"' ' || true)" + [[ -n "$value" ]] && { printf '%s' "$value"; return 0; } + done + return 0 +} + +load_env_files() { + local file line key value + for file in "$ROOT/.env.local" "$ROOT/.env"; do + [[ -f "$file" ]] || continue + while IFS= read -r line || [[ -n "$line" ]]; do + line="${line%$'\r'}" + [[ "$line" =~ ^[[:space:]]*# ]] && continue + [[ "$line" =~ ^[[:space:]]*$ ]] && continue + [[ "$line" == *=* ]] || continue + key="${line%%=*}" + key="${key#"${key%%[![:space:]]*}"}" + key="${key%"${key##*[![:space:]]}"}" + [[ "$key" =~ ^[A-Za-z_][A-Za-z0-9_]*$ ]] || continue + value="${line#*=}" + value="${value%"${value##*[![:space:]]}"}" + value="${value#\"}" + value="${value%\"}" + value="${value#\'}" + value="${value%\'}" + # shell 里已有的变量优先,不覆盖 + if [[ -z "${!key:-}" ]]; then + export "$key=$value" + fi + done < "$file" + done +} + +resolve_ports() { + if [[ -z "${API_PORT:-}" ]]; then + API_PORT="$(read_env_port PORT)" + [[ -n "$API_PORT" ]] || API_PORT=3000 + fi + if [[ -z "${WEB_PORT:-}" ]]; then + WEB_PORT=5173 + fi +} + +preflight() { + command -v node >/dev/null 2>&1 || die "未找到 node,请先安装 Node.js 24.15.0(见 .nvmrc)" + command -v pnpm >/dev/null 2>&1 || die "未找到 pnpm,请先安装 pnpm 10.x" + [[ -d "$ROOT/node_modules" ]] || die "未找到 node_modules,请先执行 pnpm install" + + local required current + required="$(cat "$ROOT/.nvmrc" 2>/dev/null | tr -d 'v[:space:]' || true)" + current="$(node -p 'process.versions.node' 2>/dev/null || true)" + if [[ -n "$required" && -n "$current" ]]; then + if [[ "${current%%.*}" != "${required%%.*}" ]]; then + warn "当前 Node.js ${current},.nvmrc 要求 ${required},建议先 nvm use" + fi + fi + + if [[ ! -f "$ROOT/packages/shared/dist/index.js" ]]; then + info "检测到 packages/shared 未构建,自动构建一次…" + pnpm --filter @lingxi/shared build >/dev/null + ok "packages/shared 构建完成" + fi + + if [[ ! -f "$ROOT/.env" && ! -f "$ROOT/.env.local" ]]; then + warn "未找到 .env / .env.local,api 可能因缺少 DATABASE_URL、JWT 密钥而启动失败" + fi +} + +# ---------- 启动 / 停止 ---------- +start_service() { + local name="$1" filter port log pidfile pid + filter="$(svc_filter "$name")" + port="$(svc_port "$name")" + log="$(svc_log "$name")" + pidfile="$PID_DIR/$name.pid" + + if [[ -f "$pidfile" ]] && pid_alive "$(cat "$pidfile" 2>/dev/null || true)"; then + if [[ -n "$port" ]] && curl -fsS --max-time 2 "$(svc_health_url "$name")" >/dev/null 2>&1; then + info "$name 已在运行(PID $(cat "$pidfile")),跳过" + return 0 + fi + warn "$name 的 PID 记录仍存在但服务未就绪,清理后重启" + stop_pid "$name" || true + fi + rm -f "$pidfile" + + if [[ -n "$port" ]]; then + kill_port "$port" "$name" + fi + + mkdir -p "$LOG_DIR" "$PID_DIR" + : > "$log" + + set -m + case "$name" in + api) + ( cd "$ROOT" && exec env PORT="$API_PORT" pnpm --filter "$filter" dev ) >>"$log" 2>&1 & + ;; + web) + ( cd "$ROOT" && exec pnpm --filter "$filter" dev -- --port "$WEB_PORT" --strictPort ) >>"$log" 2>&1 & + ;; + worker) + ( cd "$ROOT" && exec pnpm --filter "$filter" dev ) >>"$log" 2>&1 & + ;; + esac + pid=$! + set +m + echo "$pid" > "$pidfile" + disown "$pid" 2>/dev/null || true + + if [[ -n "$port" ]]; then + ok "已启动 ${name}(PID ${pid},端口 ${port})" + else + ok "已启动 ${name}(PID ${pid})" + fi +} + +wait_healthy() { + local name="$1" url pidfile pid timeout=90 elapsed=0 log + url="$(svc_health_url "$name")" + pidfile="$PID_DIR/$name.pid" + log="$(svc_log "$name")" + + if [[ -z "$url" ]]; then + # 无端口的服务(worker):等成功日志;tsx watch 崩溃后进程仍在,需看日志 + pid="$(cat "$pidfile" 2>/dev/null || true)" + local i + for i in $(seq 1 60); do + if grep -q "启动失败" "$log" 2>/dev/null; then + warn "$name 启动失败,最近日志:" + tail -n 25 "$log" >&2 || true + return 1 + fi + if grep -q "worker 已启动" "$log" 2>/dev/null; then + ok "$name 运行中" + return 0 + fi + pid_alive "$pid" || { + warn "$name 启动后进程已退出,最近日志:" + tail -n 20 "$log" >&2 || true + return 1 + } + sleep 0.25 + done + warn "$name 在 15s 内未输出启动成功日志,最近日志:" + tail -n 20 "$log" >&2 || true + return 1 + fi + + printf '%s[dev]%s 等待 %s 就绪 %s' "$C_BLUE" "$C_RESET" "$name" "$url" + while ((elapsed < timeout * 4)); do + if curl -fsS --max-time 2 "$url" >/dev/null 2>&1; then + printf '\n' + ok "$name 就绪:$url" + return 0 + fi + pid="$(cat "$pidfile" 2>/dev/null || true)" + if ! pid_alive "$pid"; then + printf '\n' + warn "$name 进程已退出,最近日志:" + tail -n 30 "$log" >&2 || true + return 1 + fi + printf '.' + sleep 0.25 + elapsed=$((elapsed + 1)) + done + printf '\n' + warn "$name 在 ${timeout}s 内未就绪,最近日志:" + tail -n 30 "$log" >&2 || true + return 1 +} + +TAIL_PID="" +cleanup_follow() { + printf '\n' + if [[ -n "${TAIL_PID:-}" ]] && kill -0 "$TAIL_PID" 2>/dev/null; then + kill "$TAIL_PID" 2>/dev/null || true + fi + stop_services + exit 0 +} + +start_services() { + preflight + mkdir -p "$LOG_DIR" "$PID_DIR" + + local service failed=0 + for service in "${SERVICES[@]}"; do + start_service "$service" || failed=1 + done + for service in "${SERVICES[@]}"; do + wait_healthy "$service" || failed=1 + done + + if ((failed != 0)); then + warn "有服务未正常启动,已停止本次启动的服务;日志保留在 ${LOG_DIR}" + stop_services >/dev/null 2>&1 || true + exit 1 + fi + + printf '\n' + ok "全部服务已启动" + printf ' %sAPI%s http://127.0.0.1:%s %s(健康检查 /health)%s\n' "$C_BLUE" "$C_RESET" "$API_PORT" "$C_DIM" "$C_RESET" + if [[ " ${SERVICES[*]} " == *" web "* ]]; then + printf ' %sWeb%s http://127.0.0.1:%s\n' "$C_BLUE" "$C_RESET" "$WEB_PORT" + fi + printf ' %s日志%s %s\n' "$C_BLUE" "$C_RESET" "$LOG_DIR" + dim " ./dev.sh status | ./dev.sh logs | ./dev.sh stop" + + if ((FOLLOW == 1)); then + local files=() + for service in "${SERVICES[@]}"; do files+=("$(svc_log "$service")"); done + sleep 0.3 + # 后台 tail + wait:前台 tail 会阻塞信号 trap,导致 Ctrl+C 不生效 + tail -n 20 -F "${files[@]}" & + TAIL_PID=$! + trap cleanup_follow INT TERM + wait "$TAIL_PID" || true + stop_services + fi +} + +stop_services() { + local service + for service in worker api web; do + stop_pid "$service" || true + done + # 兜底:即使 pid 文件丢失,也释放监听端口 + [[ -n "${API_PORT:-}" ]] && kill_port "$API_PORT" "api" || true + [[ -n "${WEB_PORT:-}" ]] && kill_port "$WEB_PORT" "web" || true + rm -f "$PID_DIR"/*.pid 2>/dev/null || true + ok "开发服务已全部停止" +} + +status_services() { + local service port pid pidfile state url code + printf '%-8s %-8s %-6s %-10s %s\n' "服务" "状态" "端口" "PID" "地址/健康" + for service in api web worker; do + port="$(svc_port "$service")" + pidfile="$PID_DIR/$service.pid" + pid="" + [[ -f "$pidfile" ]] && pid="$(cat "$pidfile" 2>/dev/null || true)" + url="$(svc_health_url "$service")" + state="stopped" + code="-" + if [[ -n "$port" ]] && [[ -n "$(listener_pids "$port")" ]]; then + state="running" + pid="$(listener_pids "$port" | head -n 1)" + if [[ -n "$url" ]]; then + # 启动瞬间 Vite 可能正在做依赖预构建,重试几次再判定 + for _ in 1 2 3 4 5; do + code="$(curl -s -o /dev/null -w '%{http_code}' --max-time 2 "$url" 2>/dev/null || true)" + [[ -n "$code" && "$code" != "000" ]] && break + sleep 0.2 + done + [[ -n "$code" ]] || code="-" + fi + elif pid_alive "$pid"; then + state="running" + else + pid="-" + fi + printf '%-8s %-8s %-6s %-10s %s\n' \ + "$service" "$state" "${port:--}" "${pid:--}" "${url:--} ${code}" + done +} + +logs_services() { + local files=() service + if [[ -n "${LOG_TARGET:-}" ]]; then + [[ "$LOG_TARGET" == "api" || "$LOG_TARGET" == "web" || "$LOG_TARGET" == "worker" ]] \ + || die "未知服务:${LOG_TARGET}(可选 api / web / worker)" + [[ -f "$(svc_log "$LOG_TARGET")" ]] || die "日志不存在:$(svc_log "$LOG_TARGET")" + files=("$(svc_log "$LOG_TARGET")") + else + for service in api web worker; do + [[ -f "$(svc_log "$service")" ]] && files+=("$(svc_log "$service")") + done + ((${#files[@]} > 0)) || die "暂无日志,先执行 ./dev.sh start" + fi + tail -n 50 -F "${files[@]}" +} + +clean_state() { + stop_services + rm -rf "$STATE_DIR" + ok "已删除 .dev 目录" +} + +# ---------- 主流程 ---------- +parse_args "$@" +load_env_files +resolve_ports + +case "$COMMAND" in + start) start_services ;; + stop) stop_services ;; + restart) stop_services; start_services ;; + status) status_services ;; + logs) logs_services ;; + clean) clean_state ;; + *) usage; die "未知命令:$COMMAND" ;; +esac