#!/usr/bin/env bash set -Eeuo pipefail # 财务上线「数据阶段」编排脚本 # # 位置:在 deploy/ubuntu-deploy.sh 成功之后执行(代码与迁移已生效、backend 已 ready)。 # # 它把以前散落在文档里的脚本步骤串成一条可重复执行的流水线: # ① 存量订单项收入回填 revenue:backfill # ② 履约方式回填 fulfillment:backfill # ③ 财务历史同步(分窗口) finance:sync --mode=all # ④ 上线验收(只读 SQL) scripts/verify-finance-launch.sql # # 设计原则: # * 幂等:每一步都能重跑,中断后直接再跑一次即可; # * 串行:回填与同步都会争 order_items 行锁,**不要并发**; # * 只读验收:验收脚本不写业务表,随时可跑; # * 不碰人工步骤:备用金/积分/商品目录/人工 SKU/总账同步仍需在后台完成,脚本末尾会打印清单。 # # 常用: # bash deploy/post-deploy.sh --dry-run # 只打印计划与待回填量 # bash deploy/post-deploy.sh --no-verify # 回填 + 同步,跳过验收(日常重跑) # bash deploy/post-deploy.sh # 全流程(默认最近 90 天) # bash deploy/post-deploy.sh --from=2026-07-01 --to=2026-10-27 # bash deploy/post-deploy.sh --only=backfill # 只跑 ①② # bash deploy/post-deploy.sh --only=sync # 只跑 ③ # bash deploy/post-deploy.sh --only=verify # 只跑 ④ # bash deploy/post-deploy.sh --no-build --no-verify # # 依赖:docker compose(或 docker-compose)已在项目根目录可用,且 .env 存在。 ROOT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" cd "$ROOT_DIR" TOOLS_IMAGE="${TOOLS_IMAGE:-order-site/tools:latest}" TOOLS_DOCKERFILE="deploy/docker/tools.Dockerfile" VERIFY_SQL="scripts/verify-finance-launch.sql" WINDOW_DAYS="${WINDOW_DAYS:-30}" BATCH_SIZE="${BATCH_SIZE:-5000}" MAX_BATCHES="${MAX_BATCHES:-1000}" MAX_BACKFILL_ROUNDS="${MAX_BACKFILL_ROUNDS:-5}" DRY_RUN=false SKIP_BACKFILL=false SKIP_SYNC=false NO_VERIFY=false NO_BUILD=false ONLY="" FROM_DATE="" TO_DATE="" LAUNCH_AT="" log() { printf '\033[1;34m[post-deploy]\033[0m %s\n' "$*"; } warn() { printf '\033[1;33m[post-deploy]\033[0m %s\n' "$*"; } fail() { printf '\033[1;31m[post-deploy]\033[0m %s\n' "$*" >&2 exit 1 } usage() { cat <<'EOF' 用法:bash deploy/post-deploy.sh [选项] 选项: --dry-run 只打印计划 + 只读统计,不写库、不构建镜像 --from=YYYY-MM-DD 财务同步起始日(默认:今天往前 90 天) --to=YYYY-MM-DD 财务同步结束日(默认:今天) --window-days=N 同步窗口天数(默认 30;41 万单规模建议 7) --batch-size=N 回填批大小(默认 5000) --max-batches=N 单次回填最大批数(默认 1000) --launch-at=TIMESTAMP 验收基准时间(默认:本次执行时间),用于「上线后」类检查 --only=backfill|sync|verify 只跑指定阶段 --skip-backfill 跳过 ①②(回填) --skip-sync 跳过 ③(财务历史同步) --no-verify 跳过 ④(验收) --no-build 不构建/刷新工具镜像(镜像不存在时直接失败) -h, --help 显示本帮助 环境变量: POST_DEPLOY_COMPOSE_FILE 指定 compose 文件(默认 docker-compose.yml) TOOLS_IMAGE 工具镜像名(默认 order-site/tools:latest) WINDOW_DAYS / BATCH_SIZE / MAX_BATCHES 同上对应选项的默认值 首次上线顺序(避免商品未归一先同步产生大量异常): ① bash deploy/post-deploy.sh --only=backfill (结束后自动 VACUUM ANALYZE order_items/orders,约 30–60 秒,不阻塞读写) ② 后台完成业务配置 1)–4)(商品目录必须先于财务同步) ③ bash deploy/post-deploy.sh --only=sync ④ 后台完成业务配置 5)(总账同步) ⑤ bash deploy/post-deploy.sh --only=verify 业务配置(后台 UI): 1) 钱包管理 → 补建客服账户 → 拨付备用金 2) 商品与成本 → 积分钱包 → 发放三平台积分 3) 商品与成本 → 标准商品 → 模板初始化 / 按工单回填 / 归一人工商品 4) 商品与成本 → 人工 SKU 配置 → 扫描并逐项配置 5) 财务 → 总账与对账 → 同步销售事实到总账 → 试算平衡 → 按需关账 详见 docs/财务上线初始化与验收-2026-10-26.md 退出码:0 = 完成且验收无 FAIL;1 = 执行失败;2 = 验收未通过。 存量初始化完成后,日常重跑可直接 `bash deploy/post-deploy.sh`(回填 + 同步 + 验收一条龙)。 EOF } # ---------- 参数解析 ---------- while [[ $# -gt 0 ]]; do case "$1" in --dry-run) DRY_RUN=true ;; --skip-backfill) SKIP_BACKFILL=true ;; --skip-sync) SKIP_SYNC=true ;; --no-verify) NO_VERIFY=true ;; --no-build) NO_BUILD=true ;; --from=*) FROM_DATE="${1#*=}" ;; --to=*) TO_DATE="${1#*=}" ;; --launch-at=*) LAUNCH_AT="${1#*=}" ;; --window-days=*) WINDOW_DAYS="${1#*=}" ;; --batch-size=*) BATCH_SIZE="${1#*=}" ;; --max-batches=*) MAX_BATCHES="${1#*=}" ;; --only=*) ONLY="${1#*=}" ;; -h | --help) usage exit 0 ;; *) fail "未知参数:$1(--help 查看用法)" ;; esac shift done case "$ONLY" in '' | backfill | sync | verify) ;; *) fail "--only 只能是 backfill / sync / verify(收到:${ONLY})" ;; esac # 数值参数必须在进入循环前校验:WINDOW_DAYS<=0 会让分窗循环原地打转。 validate_integer() { local name="$1" value="$2" min="$3" max="$4" [[ "$value" =~ ^[0-9]+$ ]] || fail "${name} 需要整数(收到:${value})" local number=$((10#$value)) if ((number < min || number > max)); then fail "${name} 需要在 ${min}–${max} 之间(收到:${value})" fi } validate_integer "--window-days" "$WINDOW_DAYS" 1 400 validate_integer "--batch-size" "$BATCH_SIZE" 1 50000 validate_integer "--max-batches" "$MAX_BATCHES" 1 10000 validate_integer "MAX_BACKFILL_ROUNDS" "$MAX_BACKFILL_ROUNDS" 1 100 should_run() { [[ -z "$ONLY" || "$ONLY" == "$1" ]] } # ---------- 日期工具(兼容 GNU date 与 macOS BSD date) ---------- if date -u -d '2026-01-01 + 1 day' +%F >/dev/null 2>&1; then DATE_STYLE=gnu else DATE_STYLE=bsd fi date_today() { date +%F; } date_add_days() { local base="$1" days="$2" signed="$2" if [[ "$days" == "0" ]]; then printf '%s\n' "$base" return 0 fi # BSD date 必须显式带符号(-v+30d / -v-90d),且 -v 必须写在 -f 之前。 [[ "$days" != -* ]] && signed="+$days" if [[ "$DATE_STYLE" == gnu ]]; then date -d "$base $days days" +%F else date -j -v"${signed}d" -f '%F' "$base" +%F fi } date_days_ago() { date_add_days "$(date_today)" "-$1"; } is_iso_date() { [[ "$1" =~ ^[0-9]{4}-[0-9]{2}-[0-9]{2}$ ]]; } # ---------- compose / docker 封装 ---------- COMPOSE_FILE_OVERRIDE="${POST_DEPLOY_COMPOSE_FILE:-}" if [[ -n "$COMPOSE_FILE_OVERRIDE" ]]; then COMPOSE_CMD=(docker compose -f "$COMPOSE_FILE_OVERRIDE") elif docker compose version >/dev/null 2>&1; then COMPOSE_CMD=(docker compose) elif command -v docker-compose >/dev/null 2>&1; then COMPOSE_CMD=(docker-compose) else fail "找不到 docker compose(或 docker-compose)" fi compose() { "${COMPOSE_CMD[@]}" "$@"; } HAS_TOOLS_SERVICE=false PROFILE_SUPPORTED=false TOOLS_IMAGE_READY=false NETWORK="" detect_capabilities() { if compose --profile tools config --services 2>/dev/null | grep -qx 'tools'; then HAS_TOOLS_SERVICE=true fi if compose --profile tools config >/dev/null 2>&1; then PROFILE_SUPPORTED=true fi } detect_network() { local cid cid="$(compose ps -q postgres 2>/dev/null | head -1 || true)" if [[ -n "$cid" ]]; then NETWORK="$(docker inspect "$cid" --format '{{range $k,$v := .NetworkSettings.Networks}}{{$k}}{{"\n"}}{{end}}' 2>/dev/null | head -1 || true)" fi if [[ -z "$NETWORK" ]]; then NETWORK="$(basename "$ROOT_DIR" | tr '[:upper:]' '[:lower:]')_default" fi } env_database_url() { local value="" if [[ -f .env ]]; then value="$(grep -E '^DATABASE_URL=' .env | head -1 | cut -d= -f2- || true)" fi if [[ -z "$value" ]]; then value="postgres://postgres:postgres@postgres:5432/order_site" fi printf '%s' "$value" } # ---------- psql 封装(全部在 postgres 容器内执行) ---------- psql_scalar() { local sql="$1" compose exec -T -e SQL_QUERY="$sql" postgres sh -lc \ 'psql -U "$POSTGRES_USER" -d "$POSTGRES_DB" -tAX -v ON_ERROR_STOP=1 -c "$SQL_QUERY"' } # ---------- 工具镜像 ---------- ensure_tools_image() { # 全流程里 backfill 和 sync 各调用一次,同一次运行只构建一遍。 [[ "$TOOLS_IMAGE_READY" == true ]] && return 0 local image_exists=false if docker image inspect "$TOOLS_IMAGE" >/dev/null 2>&1; then image_exists=true fi if [[ "$DRY_RUN" == true ]]; then [[ "$image_exists" == true ]] || warn "工具镜像 $TOOLS_IMAGE 不存在(dry-run 不构建);正式执行时会自动构建" TOOLS_IMAGE_READY=true return 0 fi if [[ "$NO_BUILD" == true ]]; then [[ "$image_exists" == true ]] || fail "工具镜像 $TOOLS_IMAGE 不存在,且指定了 --no-build" log "沿用现有工具镜像 ${TOOLS_IMAGE}(--no-build)" TOOLS_IMAGE_READY=true return 0 fi # 每次都重建:有层缓存时通常只要几秒,但能保证 git pull 后跑的是当前脚本, # 而不是上次部署留在服务器上的旧 tools:latest。 log "刷新工具镜像 ${TOOLS_IMAGE}(首次约 2–4 分钟,之后走层缓存)" if [[ "$HAS_TOOLS_SERVICE" == true && "$PROFILE_SUPPORTED" == true ]]; then compose --profile tools build tools else docker build -f "$TOOLS_DOCKERFILE" -t "$TOOLS_IMAGE" . fi TOOLS_IMAGE_READY=true } run_tool() { local script="$1" shift if [[ "$HAS_TOOLS_SERVICE" == true && "$PROFILE_SUPPORTED" == true ]]; then compose --profile tools run --rm --no-deps -T tools npm run "$script" -- "$@" else docker run --rm \ --network "$NETWORK" \ --env-file .env \ -e DATABASE_URL="$(env_database_url)" \ -e DATABASE_STATEMENT_TIMEOUT_MS="${DATABASE_STATEMENT_TIMEOUT_MS:-60000}" \ -e TZ="${TZ:-Asia/Shanghai}" \ "$TOOLS_IMAGE" \ npm run "$script" -- "$@" fi } # 回填脚本在「还有剩余」时返回 2,语义是“继续跑下一轮”,不是失败。 run_backfill() { local script="$1" shift local round=1 code=0 while :; do log " 第 ${round} 轮:npm run ${script} -- $*" set +e run_tool "$script" "$@" code=$? set -e if [[ $code -eq 0 ]]; then return 0 fi if [[ $code -eq 2 && $round -lt $MAX_BACKFILL_ROUNDS ]]; then warn " 仍有剩余(退出码 2),继续下一轮" round=$((round + 1)) continue fi if [[ $code -eq 2 ]]; then fail "${script} 跑满 ${MAX_BACKFILL_ROUNDS} 轮仍有剩余;调大 --max-batches 后重跑" fi fail "${script} 失败(退出码 ${code})" done } # ---------- 各阶段 ---------- PENDING_REVENUE="?" PENDING_FULFILLMENT="?" preflight() { log "预检" [[ -f .env ]] || fail "项目根目录缺少 .env(生产由 ubuntu-deploy.sh 检查)" [[ -f "$VERIFY_SQL" ]] || fail "缺少验收脚本 $VERIFY_SQL" local running running="$(compose ps -q postgres 2>/dev/null | head -1 || true)" [[ -n "$running" ]] || fail "postgres 容器未运行:先执行 bash deploy/ubuntu-deploy.sh" detect_capabilities detect_network local migrations migrations="$(psql_scalar "SELECT (EXISTS (SELECT 1 FROM schema_migrations WHERE COALESCE(filename, name) ~ '^103_'))::int + (EXISTS (SELECT 1 FROM schema_migrations WHERE COALESCE(filename, name) ~ '^104_'))::int + (EXISTS (SELECT 1 FROM schema_migrations WHERE COALESCE(filename, name) ~ '^105_'))::int + (EXISTS (SELECT 1 FROM schema_migrations WHERE COALESCE(filename, name) ~ '^106_'))::int" | tr -d '[:space:]')" if [[ "$migrations" != "4" ]]; then fail "迁移 103–106 未全部应用(当前 ${migrations}/4):先确认 deploy/ubuntu-deploy.sh 成功、backend 日志无迁移错误" fi PENDING_REVENUE="$(psql_scalar "SELECT count(*) FROM order_items WHERE revenue_basis = 'unknown'" | tr -d '[:space:]')" PENDING_FULFILLMENT="$(psql_scalar "SELECT count(*) FROM order_items WHERE fulfillment_mode IS NULL" | tr -d '[:space:]')" log " postgres 运行中 / 迁移 103–106 已应用" log " 待回填收入项:${PENDING_REVENUE} 行;待回填履约方式:${PENDING_FULFILLMENT} 行" log " 工具镜像:${TOOLS_IMAGE}(services=${HAS_TOOLS_SERVICE}, network=${NETWORK})" } step_backfill() { log "① 回填存量订单项收入(revenue:backfill)" if [[ "$DRY_RUN" == true ]]; then if docker image inspect "$TOOLS_IMAGE" >/dev/null 2>&1; then run_tool revenue:backfill --dryRun || warn "dry-run 预览失败(不影响后续)" else log " [dry-run] 跳过执行(工具镜像未构建)" fi else run_backfill revenue:backfill --batchSize="$BATCH_SIZE" --maxBatches="$MAX_BATCHES" fi log "② 回填履约方式(fulfillment:backfill,只填 NULL)" if [[ "$DRY_RUN" == true ]]; then log " [dry-run] 跳过执行" else run_backfill fulfillment:backfill --batchSize="$BATCH_SIZE" --maxBatches="$MAX_BATCHES" fi if [[ "$DRY_RUN" != true ]]; then refresh_stats fi } # 回填会 UPDATE 40 万行 order_items:既产生大量死元组,又给新列 fulfillment_mode # 留下空统计。autovacuum 追不上时,订单列表的 COUNT 会被估算成 orders 全表扫描, # 冷缓存实测 20 秒级(测试库曾因此 500)。这里在回填后立刻补一次,VACUUM 只拿 # SHARE UPDATE EXCLUSIVE,不阻塞读写。 refresh_stats() { log "②' 刷新统计信息与可见性(VACUUM ANALYZE order_items/orders)" if ! compose exec -T postgres sh -lc \ 'psql -U "$POSTGRES_USER" -d "$POSTGRES_DB" -v ON_ERROR_STOP=1 -c "VACUUM (ANALYZE) order_items" -c "VACUUM (ANALYZE) orders"'; then warn "VACUUM ANALYZE 失败:统计信息未刷新,订单列表 COUNT 可能变慢;请手动补跑后再放开后台" fi } step_sync() { local from="$FROM_DATE" to="$TO_DATE" win_to local products products="$(psql_scalar 'SELECT count(*) FROM product_catalog' | tr -d '[:space:]')" if [[ "$products" == "0" ]]; then warn "商品目录为空:finance:sync 会为未归一订单项生成大量 product_mapping_missing 异常;建议先完成商品初始化再跑 --only=sync" fi log "③ 财务历史同步(finance:sync --mode=all),区间 ${from} → ${to},每窗 ${WINDOW_DAYS} 天" while :; do win_to="$(date_add_days "$from" $((WINDOW_DAYS - 1)))" if [[ "$win_to" > "$to" ]]; then win_to="$to" fi if [[ "$DRY_RUN" == true ]]; then log " [dry-run] npm run finance:sync -- --mode=all --from=${from} --to=${win_to}" else log " 窗口 ${from} → ${win_to}" run_tool finance:sync --mode=all --from="$from" --to="$win_to" fi [[ "$win_to" == "$to" ]] && break from="$(date_add_days "$win_to" 1)" done } step_verify() { log "④ 上线验收(只读)" local code=0 set +e compose exec -T -e LAUNCH_AT="$LAUNCH_AT" postgres sh -lc \ 'psql -U "$POSTGRES_USER" -d "$POSTGRES_DB" -v ON_ERROR_STOP=1 -v launch_at="$LAUNCH_AT" -f -' \ <"$VERIFY_SQL" code=$? set -e if [[ $code -ne 0 ]]; then warn "验收未通过(psql 退出码 ${code}):按上面 FAIL 项的「处理建议」逐条处理后,重跑 --only=verify" return 1 fi log "验收全部通过" } manual_checklist() { cat <<'EOF' ──────────────────────────────────────────────────────────── 剩余人工步骤(后台 UI,脚本无法代替): 1) 钱包管理 → 补建客服账户 → 拨付备用金 2) 商品与成本 → 积分钱包 → 发放三平台积分 3) 商品与成本 → 标准商品 →「从接单模板初始化」→「按工单回填商品」→「归一人工商品」 4) 商品与成本 → 人工 SKU 配置 →「扫描」并逐项配置 5) 财务 → 总账与对账 →「同步销售事实到总账」→ 试算平衡 → 按需关账 首次上线顺序:--only=backfill → 完成 1)–4) → --only=sync → 完成 5) → --only=verify 手册:docs/财务上线初始化与验收-2026-10-26.md ──────────────────────────────────────────────────────────── EOF } # ---------- 主流程 ---------- main() { if [[ -z "$FROM_DATE" ]]; then FROM_DATE="$(date_days_ago 90)"; fi if [[ -z "$TO_DATE" ]]; then TO_DATE="$(date_today)"; fi if [[ -z "$LAUNCH_AT" ]]; then LAUNCH_AT="$(date '+%Y-%m-%d %H:%M:%S%z')"; fi is_iso_date "$FROM_DATE" || fail "--from 需要 YYYY-MM-DD(收到:${FROM_DATE})" is_iso_date "$TO_DATE" || fail "--to 需要 YYYY-MM-DD(收到:${TO_DATE})" [[ "$FROM_DATE" < "$TO_DATE" || "$FROM_DATE" == "$TO_DATE" ]] || fail "--from 不能晚于 --to" log "计划:dry-run=${DRY_RUN} only=${ONLY:-all} from=${FROM_DATE} to=${TO_DATE} window=${WINDOW_DAYS}d launch-at=${LAUNCH_AT}" preflight if should_run backfill && [[ "$SKIP_BACKFILL" != true ]]; then ensure_tools_image step_backfill fi if should_run sync && [[ "$SKIP_SYNC" != true ]]; then ensure_tools_image step_sync fi local verify_code=0 if should_run verify && [[ "$NO_VERIFY" != true ]]; then step_verify || verify_code=1 fi manual_checklist if [[ $verify_code -ne 0 && "$DRY_RUN" != true ]]; then warn "数据阶段已完成,但验收未通过:按上面的 FAIL 项处理后重跑 bash deploy/post-deploy.sh --only=verify" exit 2 fi log "完成" } main "$@"