Files

485 lines
18 KiB
Bash
Executable File
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
#!/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 "$@"