#!/bin/bash # 管理会计OS — 一键拉取、构建并部署到生产 set -e SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)" echo "===== 管理会计OS 生产部署 =====" echo "" # 0. 提交纪律检查:本地有未提交修改则中止,防止 git pull 覆盖丢失 echo "[0/5] 提交纪律检查..." cd "$SCRIPT_DIR" DIRTY=$(git status --porcelain 2>/dev/null | grep -vE "__pycache__|^\?\?" | head -20) if [ -n "$DIRTY" ]; then echo "❌ 检测到本地未提交的修改,中止部署(防止 git pull 覆盖丢失代码):" echo "$DIRTY" echo "" echo "请先提交代码: git add -A && git commit -m '...' && git push origin main" exit 1 fi echo " ✓ 工作区干净,无未提交修改" # 1. 拉取最新代码 echo "[1/5] 拉取最新代码..." git pull origin main echo " ✓ 代码已更新" # 1b. 鉴权面审计闸门(T1, 2026-09-15):B 类缺口(用户端点无鉴权)存在即中止部署 echo "[1b/5] 鉴权面审计闸门..." cd "$SCRIPT_DIR/backend" if ! ./venv/bin/python3 scripts/audit_api_auth_surface.py > /tmp/cma-auth-audit.log 2>&1; then echo "❌ 鉴权面审计发现 B 类缺口(用户端点缺鉴权),中止部署。详见 /tmp/cma-auth-audit.log" tail -20 /tmp/cma-auth-audit.log exit 1 fi cd "$SCRIPT_DIR" echo " ✓ 鉴权面审计通过(无 B 类缺口)" # 2. 构建前端 echo "[2/5] 构建前端..." cd "$SCRIPT_DIR/frontend" pnpm build echo " ✓ 构建完成" # 3. 部署前端到 Nginx echo "[3/5] 部署前端..." rm -rf /var/www/cma/assets/ cp -r dist/* /var/www/cma/ echo " ✓ 部署完成" # 3b. 前端层一致性断言(T2, 2026-09-18):部署链静默替换门 # 背景:[3] 只有 rm -rf + cp,缓存层吐旧 index、或 index 指向已被删的旧 chunk 时,现象是 # 「首页 200、点进页面 404/白屏」,而其后的冒烟 5 项全为后端项(127.0.0.1:8010)→ 整轮部署全绿。 # 三条断言各自独立输出,任一不一致即 exit 1。离线演练只覆盖下面三个变量,无需 build。 # >>> cma-frontend-assert(最小可抽取块:scripts/tests 按行首标记 awk 抽取后原样执行,改结构请沿用标记) FRONTEND_DIST_DIR="${FRONTEND_DIST_DIR:-$SCRIPT_DIR/frontend/dist}" CMA_WEBROOT="${CMA_WEBROOT:-/var/www/cma}" SITE_URL="${SITE_URL:-https://cma.sxbh.ltd}" echo "[3b/5] 前端层一致性断言(dist ↔ webroot ↔ 引用链)..." FRONTEND_ASSERT_FAIL=0 # ① 逐文件 md5 对账:dist/assets 与 webroot/assets 文件集合一致(无多、无少)且同名文件 md5 相同 DIST_ASSETS="$FRONTEND_DIST_DIR/assets" WEB_ASSETS="$CMA_WEBROOT/assets" DIST_FILES="$(ls -1 "$DIST_ASSETS" 2>/dev/null | sort)" WEB_FILES="$(ls -1 "$WEB_ASSETS" 2>/dev/null | sort)" MISSING_FILES="$(comm -23 <(printf '%s\n' "$DIST_FILES") <(printf '%s\n' "$WEB_FILES"))" EXTRA_FILES="$(comm -13 <(printf '%s\n' "$DIST_FILES") <(printf '%s\n' "$WEB_FILES"))" MD5_DIFF="" while IFS= read -r F; do [ -n "$F" ] || continue D_MD5="$(md5sum "$DIST_ASSETS/$F" 2>/dev/null | awk '{print $1}')" W_MD5="$(md5sum "$WEB_ASSETS/$F" 2>/dev/null | awk '{print $1}')" [ "$D_MD5" = "$W_MD5" ] || MD5_DIFF="${MD5_DIFF}${MD5_DIFF:+, }$F(dist=$D_MD5 web=$W_MD5)" done <<< "$DIST_FILES" if [ -n "$MISSING_FILES" ] || [ -n "$EXTRA_FILES" ] || [ -n "$MD5_DIFF" ]; then echo " ❌ 断言① 逐文件 md5 对账失败(dist=$DIST_ASSETS)" [ -z "$MISSING_FILES" ] || echo " webroot 缺失: $(printf '%s' "$MISSING_FILES" | tr '\n' ' ')" [ -z "$EXTRA_FILES" ] || echo " webroot 多出: $(printf '%s' "$EXTRA_FILES" | tr '\n' ' ')" [ -z "$MD5_DIFF" ] || echo " md5 不一致: $MD5_DIFF" FRONTEND_ASSERT_FAIL=1 else echo " ✅ 断言① 逐文件 md5 对账通过($(printf '%s\n' "$DIST_FILES" | awk 'NF{n++} END{print n+0}') 个文件全部一致)" fi # ② 主包一致:webroot/index.html 解析出的入口 chunk 必须在 dist 存在且 md5 相同 INDEX_HTML="$CMA_WEBROOT/index.html" MAIN_BUNDLE="$(grep -o '/assets/index-[A-Za-z0-9._-]*\.js' "$INDEX_HTML" 2>/dev/null | head -n 1)" if [ -z "$MAIN_BUNDLE" ]; then echo " ❌ 断言② 主包一致失败: 未能从 $INDEX_HTML 解析出入口 chunk(/assets/index-*.js)" FRONTEND_ASSERT_FAIL=1 elif [ ! -f "$FRONTEND_DIST_DIR$MAIN_BUNDLE" ]; then echo " ❌ 断言② 主包一致失败: index.html 引用的 $MAIN_BUNDLE 在 dist 不存在($FRONTEND_DIST_DIR)" FRONTEND_ASSERT_FAIL=1 else MAIN_D_MD5="$(md5sum "$FRONTEND_DIST_DIR$MAIN_BUNDLE" 2>/dev/null | awk '{print $1}')" MAIN_W_MD5="$(md5sum "$CMA_WEBROOT$MAIN_BUNDLE" 2>/dev/null | awk '{print $1}')" if [ -n "$MAIN_D_MD5" ] && [ "$MAIN_D_MD5" = "$MAIN_W_MD5" ]; then echo " ✅ 断言② 主包一致通过($MAIN_BUNDLE dist=web=$MAIN_D_MD5)" else echo " ❌ 断言② 主包一致失败: $MAIN_BUNDLE dist=$MAIN_D_MD5 web=$MAIN_W_MD5" FRONTEND_ASSERT_FAIL=1 fi fi # ③ 引用链可 200:主包内引用的懒加载 chunk 逐个请求 $SITE_URL,必须全部 HTTP 200(无静默跳过分支) # 字面量形态取并集:现网 Vite 产物实测为相对形态 from"./Login-CCqwmqG_.js",另有 "/assets/*.js" 形态; # 两者都归一到 /assets/ 再请求。解析不到任何引用 → 显式判失败(不得静默放行)。 MAIN_JS_FILE="$CMA_WEBROOT$MAIN_BUNDLE" CHUNKS="$(grep -o -E '(\./|/assets/)[A-Za-z0-9._-]+\.js' "$MAIN_JS_FILE" 2>/dev/null | sed -E 's#^\./#/assets/#' | sort -u)" CHUNK_TOTAL="$(printf '%s\n' "$CHUNKS" | awk 'NF{n++} END{print n+0}')" if [ ! -f "$MAIN_JS_FILE" ]; then echo " ❌ 断言③ 引用链可 200 失败: 主包在 webroot 不存在($MAIN_JS_FILE)" FRONTEND_ASSERT_FAIL=1 elif [ "$CHUNK_TOTAL" -eq 0 ]; then echo " ❌ 断言③ 引用链可 200 失败: 主包 $MAIN_BUNDLE 内未解析到任何 chunk 引用(./*.js 或 /assets/*.js)" FRONTEND_ASSERT_FAIL=1 else CHUNK_OK=0 CHUNK_BAD="" while IFS= read -r C; do [ -n "$C" ] || continue C_CODE="$(curl -s -o /dev/null -w '%{http_code}' --max-time 10 "$SITE_URL$C" 2>/dev/null || echo 000)" if [ "$C_CODE" = "200" ]; then CHUNK_OK=$((CHUNK_OK + 1)) else CHUNK_BAD="${CHUNK_BAD}${CHUNK_BAD:+, }$C(HTTP=$C_CODE)" fi done <<< "$CHUNKS" if [ -n "$CHUNK_BAD" ]; then echo " ❌ 断言③ 引用链可 200 失败($SITE_URL): $CHUNK_BAD" FRONTEND_ASSERT_FAIL=1 else echo " ✅ 断言③ 引用链可 200 通过($CHUNK_OK/$CHUNK_TOTAL chunk @ $SITE_URL)" fi fi # 三条均为硬门:任一不一致即中止部署(nginx 直接服务静态文件,无需 reload) if [ "$FRONTEND_ASSERT_FAIL" -ne 0 ]; then echo "" echo "🚨 前端层一致性断言未通过 → 中止部署(webroot 可能停留在旧包 / 半包)" echo " dist=$FRONTEND_DIST_DIR webroot=$CMA_WEBROOT site=$SITE_URL" exit 1 fi echo " ✓ 前端层一致性断言通过(① 逐文件 md5 ② 主包 ③ 引用链 200)" # <<< cma-frontend-assert # 4. 更新后端依赖(失败不阻断 — 依赖已在系统Python中,避免PEP668中断部署) echo "[4/5] 更新后端依赖..." cd "$SCRIPT_DIR/backend" if [ -f venv/bin/activate ]; then source venv/bin/activate pip install -r requirements.txt --quiet --no-cache-dir || echo " ⚠️ 依赖更新失败(不阻断)" deactivate else echo " ⚠️ venv为软链精简版,跳过依赖更新" fi echo " ✓ 依赖检查完成" # 4b. 数据库结构迁移(特权,重启前执行;幂等;可回滚) # cma-create-all-minpriv-rootfix-20260916:DDL 已从应用启动剥离(CMA_AUTO_CREATE 默认关), # 新模型必须由**有 DDL 权限的账号**在重启前建表;此处失败即中止部署(后端保持旧码运行), # 避免 2026-09-16 那种「启动 create_all 被 cma_api 拒 → 崩溃循环」。 echo "[4b/5] 数据库结构迁移(特权账号,重启前)..." cd "$SCRIPT_DIR/backend" if ! ./venv/bin/python3 scripts/db_migrate.py --apply; then echo "❌ 数据库结构迁移失败 → 中止部署(后端未重启,旧码继续服务)" echo " 排查: cd $SCRIPT_DIR/backend && ./venv/bin/python3 scripts/db_migrate.py --check" echo " 回滚: cd $SCRIPT_DIR/backend && ./venv/bin/python3 scripts/db_migrate.py --rollback latest" exit 1 fi cd "$SCRIPT_DIR" echo " ✓ 结构迁移完成(幂等;journal: backend/scripts/backups/db-migrate-journal/)" # 5. 重启后端服务 echo "[5/5] 重启后端服务..." systemctl daemon-reload systemctl restart cma-backend systemctl reload nginx 2>/dev/null || nginx -s reload echo " ✓ 服务重启完成" # ── 部署后冒烟测试 ── echo "" echo "===== 冒烟测试 =====" # 就绪轮询(2026-09-15 t2-cma-deploy-readiness-poll):journalctl 实测冷启动就绪间隔 2/2/3/3/5s, # 固定 sleep 3 会让贴边/超时的冷启动误判为冒烟 ❌(假告警,污染后续判断)。 # 判据 = HTTP 200 **且** 响应体含 {"status":"ok"};每 0.5s 探一次,上限 SMOKE_READY_MAX 秒(默认 30)。 # 超时 → 明确报错 + exit 非 0(既不静默放行,也不让后续 4 项冒烟产出次生噪音掩盖根因)。 # SMOKE_HEALTH_URL / SMOKE_READY_MAX 可覆盖,供离线演练(桩服务 / 空端口 / 短上限)。 # >>> cma-readiness-poll(最小可抽取块:scripts/tests 按行首标记 awk 抽取后原样执行,改结构请沿用标记) SMOKE_READY_MAX="${SMOKE_READY_MAX:-30}" HEALTH_URL="${SMOKE_HEALTH_URL:-http://127.0.0.1:8010/health}" READY_TRY=0 READY_T0=$(date +%s) READY_OK=0 READY_BODY_FILE="$(mktemp -t cma-ready-body.XXXXXX)" # 上限按**墙钟**判定(不是轮询次数):目标"接受连接但不响应"时单次探测要耗满 --max-time 2s, # 用次数当上限会让"上限 30s"实际跑到 150s。墙钟判定下最坏 = SMOKE_READY_MAX + 单次探测(≤2s) 的余量。 while [ $(( $(date +%s) - READY_T0 )) -lt "$SMOKE_READY_MAX" ]; do READY_CODE=$(curl -s -o "$READY_BODY_FILE" -w '%{http_code}' --max-time 2 "$HEALTH_URL" 2>/dev/null || echo 000) if [ "$READY_CODE" = "200" ] && grep -q '"status"[[:space:]]*:[[:space:]]*"ok"' "$READY_BODY_FILE" 2>/dev/null; then READY_OK=1 break fi sleep 0.5 READY_TRY=$((READY_TRY + 1)) done READY_ELAPSED=$(( $(date +%s) - READY_T0 )) if [ "$READY_OK" -ne 1 ]; then READY_LAST=$(head -c 200 "$READY_BODY_FILE" 2>/dev/null) rm -f "$READY_BODY_FILE" echo " ⏱ 就绪探测: 耗时 ${READY_ELAPSED}s / 轮询 ${READY_TRY} 次(上限 ${SMOKE_READY_MAX}s)→ ❌ 未就绪" echo "" echo "🚨 后端就绪超时(${SMOKE_READY_MAX}s):$HEALTH_URL 未返回 HTTP 200 + {\"status\":\"ok\"}" echo " 最近响应: ${READY_LAST:-<无响应>}" echo " 请检查后端日志: journalctl -u cma-backend -n 50" exit 1 fi rm -f "$READY_BODY_FILE" # <<< cma-readiness-poll echo " ⏱ 就绪探测: 耗时 ${READY_ELAPSED}s / 轮询 ${READY_TRY} 次(上限 ${SMOKE_READY_MAX}s,命中就绪即停)" SMOKE_FAIL=0 echo " ✅ 后端健康检查通过(${HEALTH_URL} → status=ok)" if curl -sf http://127.0.0.1:8010/api/cma/bot/ping > /dev/null 2>&1; then echo " ✅ BOT桥接API通过" else echo " ❌ BOT桥接API失败"; SMOKE_FAIL=1 fi # Schema 一致性检查(数据库与ORM结构对齐) if /root/cma-management/backend/venv/bin/python3 /root/cma-management/backend/scripts/schema_check.py > /dev/null 2>&1; then echo " ✅ Schema一致性通过" else echo " ❌ Schema不一致,请检查: cd /root/cma-management/backend && venv/bin/python3 scripts/schema_check.py" SMOKE_FAIL=1 fi # 凭据同源(2026-09-14 cma-defaultcred-code-fix):登录冒烟不再硬编码默认账号/口令 —— 轮换后必须自动跟随。 # 落点:$CMA_ADMIN_ENV 或 600 密钥文件(默认 wecom-ops 主副本);缺失/缺键 → 显式报错并中止,禁止静默退回默认口令。 CMA_ADMIN_ENV_FILE="${CMA_ADMIN_ENV:-/root/.hermes/profiles/wecom-ops/.secrets/cma-admin.env}" if [ ! -f "$CMA_ADMIN_ENV_FILE" ]; then echo " ❌ 管理员凭据文件缺失: $CMA_ADMIN_ENV_FILE" echo " 请准备 600 权限密钥文件(键名 CMA_ADMIN_USER / CMA_ADMIN_PASS),或用 CMA_ADMIN_ENV 指定路径" exit 1 fi set -a # shellcheck disable=SC1090 . "$CMA_ADMIN_ENV_FILE" set +a if [ -z "${CMA_ADMIN_USER:-}" ] || [ -z "${CMA_ADMIN_PASS:-}" ]; then echo " ❌ 凭据文件缺少 CMA_ADMIN_USER / CMA_ADMIN_PASS: $CMA_ADMIN_ENV_FILE" exit 1 fi echo " ℹ️ 登录冒烟凭据来源: $CMA_ADMIN_ENV_FILE(账号 ${CMA_ADMIN_USER},口令不回显)" TOKEN=$(curl -s -X POST http://127.0.0.1:8010/api/cma/auth/login \ -H "Content-Type: application/json" \ -d "{\"username\":\"${CMA_ADMIN_USER}\",\"password\":\"${CMA_ADMIN_PASS}\",\"entity_id\":1}" | python3 -c "import sys,json; print(json.load(sys.stdin).get('token',''))" 2>/dev/null) if [ -n "$TOKEN" ]; then echo " ✅ 登录API通过" if curl -sf -H "Authorization: Bearer $TOKEN" http://127.0.0.1:8010/api/cma/kpis > /dev/null 2>&1; then echo " ✅ KPI接口通过" else echo " ❌ KPI接口失败"; SMOKE_FAIL=1 fi else echo " ❌ 登录API失败"; SMOKE_FAIL=1 fi if [ "$SMOKE_FAIL" -eq 1 ]; then echo "" echo "🚨 冒烟测试未全部通过!请检查后端日志: journalctl -u cma-backend -n 50" exit 1 fi echo " ✅ 全部冒烟测试通过" echo "" echo "===== 部署完成 =====" echo " 前端: https://cma.sxbh.ltd" echo " 后端: http://127.0.0.1:8010/docs" echo " 后端状态: systemctl status cma-backend"