feat(deploy): 财务上线数据阶段编排与运维加固

- 新增 deploy/post-deploy.sh:回填 → 业务配置 → 同步 → 验收四阶段编排,支持 --only/--dry-run/窗口参数
- 新增 tools 镜像与 scripts/verify-finance-launch.sql(15 项上线验收)
- 验收口径:成本闸门二选一、要求已过账凭证、备用金支出关联、成本覆盖 WARN
- 加固:参数校验、工具镜像每次刷新、docker run 兜底超时、迁移前缀探测、验收失败退出码 2
- 工具镜像预建 /app/data,消除日志 EACCES
- 回填后与 restore-db 恢复后自动 VACUUM (ANALYZE),修复订单列表分页 COUNT 全表扫描(41 万单实测 27s → 0.17s)
- 文档:41 万单同步用 7 天窗 + 180s 超时;回填/恢复后刷新统计
This commit is contained in:
yml2213
2026-10-28 11:52:42 +08:00
parent 2ddc26091c
commit 08318ef8d9
8 changed files with 977 additions and 8 deletions
+49
View File
@@ -0,0 +1,49 @@
# 财务运维工具镜像(一次性/按需容器)
#
# 为什么需要它:生产 backend 镜像为了体积与安全做了两件事——
# 1) `npm prune --omit=dev`(删掉 tsx 等 devDependencies)
# 2) 只 `COPY dist`(tsconfig.build.json 的 rootDir=src,scripts/ 不进 dist)
# 因此 `docker compose exec backend npm run revenue:backfill` 在生产**必然失败**。
# 本镜像保留源码 + devDependencies,专门用来跑 scripts/ 下的回填/同步脚本。
#
# 构建(仓库根目录):
# docker compose --profile tools build tools
# 或:
# docker build -f deploy/docker/tools.Dockerfile -t order-site/tools:latest .
#
# 直接执行单个脚本(不需要 compose):
# docker run --rm --network order_site_default --env-file .env \
# order-site/tools:latest npm run revenue:backfill -- --dryRun
#
# 说明:脚本只读写数据库,不写文件;镜像不挂载业务数据卷。
ARG BASE_REGISTRY=docker.m.daocloud.io
FROM ${BASE_REGISTRY}/library/node:22-bookworm
ARG DEBIAN_MIRROR=mirrors.aliyun.com
ARG NPM_REGISTRY=https://registry.npmmirror.com
ENV DEBIAN_FRONTEND=noninteractive \
NPM_CONFIG_REGISTRY=${NPM_REGISTRY} \
TZ=Asia/Shanghai \
NODE_ENV=development
WORKDIR /app
# 先装依赖再拷源码,命中 Docker 层缓存:只改脚本时不必重装 node_modules。
COPY apps/backend/package.json apps/backend/package-lock.json ./
RUN --mount=type=cache,target=/root/.npm \
npm ci --include=dev
COPY --chown=node:node apps/backend/ ./
# 脚本运行时会加载 backend 的 logger,它会把日志写到 $DATA_ROOT/logs。
# 镜像以 node 用户运行,必须提前建好该目录并授权,否则每条日志都会刷
# EACCES: permission denied, mkdir '/app/data'。
RUN mkdir -p /app/data && chown -R node:node /app/data
USER node
# 不带命令运行时列出可用脚本,避免误用。
CMD ["npm", "run"]
+484
View File
@@ -0,0 +1,484 @@
#!/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" | tr -d '[:space:]')"
if [[ "$migrations" != "3" ]]; then
fail "迁移 103–105 未全部应用(当前 ${migrations}/3):先确认 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–105 已应用"
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 "$@"
+22
View File
@@ -199,6 +199,26 @@ restore_configs() {
rm -rf -- "${staging}" rm -rf -- "${staging}"
} }
# pg_restore 不会带 pg_statistic,恢复后 planner 会按默认选择率选计划
# (41 万单实测会把订单列表 COUNT 走成 orders 全表扫描,冷缓存 20 秒以上)。
# 同时 VACUUM 重建可见性映射,让 index-only scan 真正只读索引。
refresh_stats() {
local database_url
database_url="$(read_env_value DATABASE_URL)"
log "刷新统计信息与可见性(VACUUM ANALYZE),恢复后首次查询才不会全表扫描"
if [[ -z "${database_url}" || "${database_url}" == *"@postgres:"* ]]; then
if ! docker compose exec -T postgres sh -c \
'psql -U "${POSTGRES_USER:-postgres}" -d "${POSTGRES_DB:-order_site}" -v ON_ERROR_STOP=1 -c "VACUUM (ANALYZE)"'; then
warn "VACUUM ANALYZE 失败。恢复已完成,但请手动执行后再开放后台,否则列表查询可能很慢。"
fi
else
if ! docker compose exec -T -e DB_RESTORE_URL="${database_url}" postgres sh -c \
'psql "$DB_RESTORE_URL" -v ON_ERROR_STOP=1 -c "VACUUM (ANALYZE)"'; then
warn "VACUUM ANALYZE 失败。恢复已完成,但请手动执行后再开放后台,否则列表查询可能很慢。"
fi
fi
}
restore_live_database() { restore_live_database() {
local key="$1" target="$2" config_target="${3:-}" local key="$1" target="$2" config_target="${3:-}"
@@ -235,6 +255,8 @@ restore_live_database() {
fail "迁移失败。请查看日志;数据已恢复到备份时点。" fail "迁移失败。请查看日志;数据已恢复到备份时点。"
fi fi
refresh_stats
if [[ -n "${config_target}" ]]; then if [[ -n "${config_target}" ]]; then
restore_configs "${config_target}" restore_configs "${config_target}"
fi fi
+34
View File
@@ -141,6 +141,40 @@ services:
volumes: volumes:
- ./apps/backend/data:/app/data - ./apps/backend/data:/app/data
# 财务运维工具容器:跑 scripts/ 下的回填/同步脚本。
#
# 生产 backend 镜像 `npm prune --omit=dev` 且只 COPY dist,没有 tsx 与 scripts/,
# 所以回填脚本必须用这个镜像执行。默认不随 `up` 启动(profiles: tools)。
#
# 用法:
# docker compose --profile tools run --rm --no-deps tools npm run revenue:backfill -- --dryRun
# 或者直接用编排脚本:
# bash deploy/post-deploy.sh --dry-run
tools:
profiles: ["tools"]
image: ${TOOLS_IMAGE:-order-site/tools:latest}
build:
context: .
dockerfile: deploy/docker/tools.Dockerfile
args:
BASE_REGISTRY: ${BASE_REGISTRY:-docker.m.daocloud.io}
DEBIAN_MIRROR: ${DEBIAN_MIRROR:-mirrors.aliyun.com}
NPM_REGISTRY: ${NPM_REGISTRY:-https://registry.npmmirror.com}
restart: "no"
env_file:
- .env
depends_on:
postgres:
condition: service_healthy
environment:
TZ: ${TZ:-Asia/Shanghai}
DATABASE_URL: ${DATABASE_URL:-postgres://postgres:postgres@postgres:5432/order_site}
DATABASE_SSL: ${DATABASE_SSL:-false}
# 回填按批提交,单条语句可能比 Web 请求慢;这里比后端默认值放宽。
DATABASE_STATEMENT_TIMEOUT_MS: ${DATABASE_STATEMENT_TIMEOUT_MS:-60000}
DATA_ROOT: /app/data
logging: *json-log-rotation
web: web:
build: build:
context: . context: .
+7 -1
View File
@@ -97,7 +97,13 @@ crontab -e
bash deploy/restore-db.sh bash deploy/restore-db.sh
``` ```
流程:下载最新备份并强制校验 sha256 → 停 backend/web 并确认已停止 → `pg_restore --clean --if-exists` → 执行迁移补差 → 重启并等待健康检查。任一步失败会尝试恢复原先运行的服务,并明确报告数据库可能处于部分恢复状态。执行前需交互输入 `RESTORE` 确认(非交互终端用 `RESTORE_CONFIRM=1` 或 `--yes`)。 流程:下载最新备份并强制校验 sha256 → 停 backend/web 并确认已停止 → `pg_restore --clean --if-exists` → 执行迁移补差 → `VACUUM (ANALYZE)` 刷新统计信息与可见性 → 重启并等待健康检查。任一步失败会尝试恢复原先运行的服务,并明确报告数据库可能处于部分恢复状态。执行前需交互输入 `RESTORE` 确认(非交互终端用 `RESTORE_CONFIRM=1` 或 `--yes`)。
> 恢复后必须 `VACUUM (ANALYZE)`:dump 不带 `pg_statistic`,没有统计信息时订单列表的 `COUNT` 会被规划成 `orders` 全表扫描,41 万单冷缓存实测 14–27 秒(默认语句超时 15 秒会直接 500)。`restore-db.sh` 已自动执行;手工 `pg_restore` 请补跑:
>
> ```bash
> docker compose exec -T postgres psql -U postgres -d order_site -c 'VACUUM (ANALYZE)'
> ```
如需同时恢复 `apps/backend/data` 配置: 如需同时恢复 `apps/backend/data` 配置:
@@ -167,6 +167,15 @@ npm run fulfillment:backfill # 只填 NULL(可选,报表用)
npm run finance:sync -- --mode=all --from=2026-07-10 --to=2026-10-22 npm run finance:sync -- --mode=all --from=2026-07-10 --to=2026-10-22
``` ```
> 回填完成后必须刷新统计信息,否则订单列表的分页总数会被规划成 `orders` 全表扫描:
>
> ```sql
> VACUUM (ANALYZE) order_items;
> VACUUM (ANALYZE) orders;
> ```
>
> `bash deploy/post-deploy.sh --only=backfill` 已自动执行这一步;手工回填请补跑。
验证: 验证:
```sql ```sql
@@ -174,6 +183,15 @@ SELECT COUNT(*) FROM order_items WHERE revenue_basis = 'unknown'; -- 退款上
``` ```
长区间按 30 天窗口分段执行(默认 `DATABASE_STATEMENT_TIMEOUT_MS=15000`)。 长区间按 30 天窗口分段执行(默认 `DATABASE_STATEMENT_TIMEOUT_MS=15000`)。
> 41 万单规模的实测口径:30 天窗口的单条语句会超过 60 秒,被 statement timeout 打断。
> 这种体量请用 **7 天窗口 + 180 秒超时**:
>
> ```bash
> DATABASE_STATEMENT_TIMEOUT_MS=180000 bash deploy/post-deploy.sh \
> --only=sync --from=2026-09-27 --to=2026-10-27 --window-days=7
> ```
注意:人工历史成本按既定口径**不补录**,历史人工毛利仍不可用。 注意:人工历史成本按既定口径**不补录**,历史人工毛利仍不可用。
--- ---
+52 -7
View File
@@ -219,19 +219,64 @@ docker compose ps
docker compose logs -f backend docker compose logs -f backend
``` ```
## 财务/成本上线后的初始化(迁移 102–111) ## 财务/成本上线后的初始化(迁移 103–105)
代码与迁移只负责建表;**备用金账户、积分余额、人工 SKU 配置不会自动创建**。 代码与迁移只负责建表;**存量订单回填、备用金账户、积分余额、商品目录、人工 SKU 不会自动创建**。
首次上线后按 [财务上线初始化与验收-2026-10-26.md](财务上线初始化与验收-2026-10-26.md) 执行: 部署成功后按「回填 → 商品初始化 → 财务同步 → 验收」四步走:
### 1. 数据回填
```bash
bash deploy/post-deploy.sh --dry-run # 先看计划与待回填量
bash deploy/post-deploy.sh --only=backfill # ① 收入回填 + ② 履约方式回填
```
> 不要在商品目录为空时直接跑 `--only=sync`:财务同步会为每个未归一订单项写一条
> `product_mapping_missing` 成本异常(生产规模 90 天实测约 36 万条),后台异常队列会被淹没。
脚本包含 `revenue:backfill`、`fulfillment:backfill`、`finance:sync` 与只读验收四段;任何一步都能重跑,
`--only=backfill|sync|verify` 可单独执行。
> 生产 backend 镜像没有 `tsx` 与 `scripts/`,脚本通过 `tools` 镜像执行
> ([deploy/docker/tools.Dockerfile](../deploy/docker/tools.Dockerfile),`docker compose --profile tools`)。
### 2. 业务配置:后台 UI
1. 钱包管理 → 补建客服备用金账户 → 按需拨付; 1. 钱包管理 → 补建客服备用金账户 → 按需拨付;
2. 商品与成本 → 积分钱包 → 给客服发放三平台积分; 2. 商品与成本 → 积分钱包 → 给客服发放三平台积分;
3. 商品与成本 → 人工 SKU 配置 → 扫描并逐项配置(保存后自动回填订单项); 3. 商品与成本 → 标准商品 → 从接单模板初始化 → 按工单回填商品 → 归一人工商品;
4. 打开「财务 → 总账与对账」,同步总账、检查试算平衡;按需关账/导入平台账单; 4. 商品与成本 → 人工 SKU 配置 → 扫描并逐项配置(保存后自动回填订单项)。
5. 用手册 §二 的验收 SQL 检查:配置覆盖、人工成本快照、`manual_cost_missing`、销售事实与总账对平。
> 第 3 步的商品目录/工单回填必须先于下一节 `--only=sync`。商品目录为空时同步会为每个
> 未归一订单项写一条 `product_mapping_missing` 异常(实测 90 天约 36 万条)。
### 3. 财务同步
商品归一完成后执行:
```bash
bash deploy/post-deploy.sh --only=sync # ③ 成本快照 / 销售事实
```
> 工具镜像每次运行都会重建(层缓存下只需几秒),确保 `git pull` 后执行的是当前脚本。
### 4. 总账与验收
5. 财务 → 总账与对账 → 同步销售事实到总账 → 检查试算平衡;按需关账/导入平台账单。
```bash
bash deploy/post-deploy.sh --only=verify # ④ 只读验收
```
退出码:`0` = 完成且验收无 FAIL、`1` = 执行失败、`2` = 验收未通过。
15 项检查全部 PASS 才算上线完成。若 15 号「近 7 天非人工销售事实成本覆盖」显示 WARN,
说明自动发货/接单平台仍有成本缺口,需要先归一商品并配置成本版本;WARN 不阻断验收,
但会直接影响毛利口径。
存量初始化完成后,日常重跑可直接 `bash deploy/post-deploy.sh`(回填 + 同步 + 验收一条龙)。
> 历史人工成本不补录;人工成本监控从第一次创建人工履约配置的时间开始。 > 历史人工成本不补录;人工成本监控从第一次创建人工履约配置的时间开始。
> 定时任务 `cost-finance-sync` 每 5 分钟运行,负责履约归集、人工 SKU 回填、成本快照与销售事实。 > 定时任务 `cost-finance-sync` 每 5 分钟运行,但**只覆盖最近 7 天**;更早历史靠 `post-deploy.sh` 的分窗同步。
--- ---
+311
View File
@@ -0,0 +1,311 @@
-- 财务上线验收(只读)
--
-- 用途:部署 + 数据初始化完成后,一次跑完所有闸门,输出 PASS / FAIL 清单。
-- 不写任何业务表;结果只落在会话级临时表 _launch_checks。
--
-- 用法:
-- # 1) 通过编排脚本(推荐,自动带上 launch_at)
-- bash deploy/post-deploy.sh --only=verify --launch-at='2026-10-27 18:00:00+08'
--
-- # 2) 手工执行(变量必须传,否则用脚本内的兜底时间)
-- docker compose exec -T postgres sh -lc \
-- 'psql -U "$POSTGRES_USER" -d "$POSTGRES_DB" \
-- -v ON_ERROR_STOP=1 -v launch_at="2026-10-27 18:00:00+08" -f -' \
-- < scripts/verify-finance-launch.sql
--
-- 退出码:0 = 无 FAIL(WARN 不阻断);3 = 存在 FAIL(末尾 RAISE EXCEPTION)。
-- 前置条件:迁移 103–105 已应用;否则先跑 deploy/ubuntu-deploy.sh。
\set ON_ERROR_STOP on
\if :{?launch_at}
\else
-- 与 post-deploy.sh 一致:默认取本次执行时间(容器 TZ=Asia/Shanghai)。
\set launch_at `date '+%Y-%m-%d %H:%M:%S%z'`
\endif
\echo ''
\echo '==================== 财务上线验收 ===================='
\echo '上线时间(launch_at): ' :launch_at
\echo ''
-- 建表与 15 条检查的 INSERT 不输出到终端(错误仍会进 stderr)。
\o /dev/null
SET client_min_messages = warning;
DROP TABLE IF EXISTS _launch_checks;
CREATE TEMP TABLE _launch_checks (
seq int,
id text,
title text,
actual text,
expected text,
status text,
hint text
);
-- 01 迁移基线 --------------------------------------------------------------
INSERT INTO _launch_checks
SELECT 1, '01', '迁移基线 103–105 已应用',
v::text, '3',
CASE WHEN v = 3 THEN 'PASS' ELSE 'FAIL' END,
'后端未完成迁移:docker compose logs backend | grep -i migration'
FROM (
-- 按版本前缀逐个判断,避免后续 105_xxx_hotfix 之类增量迁移把计数顶成 4。
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 AS v
) s;
-- 02 存量订单项收入回填 ----------------------------------------------------
INSERT INTO _launch_checks
SELECT 2, '02', '存量订单项收入已回填(revenue_basis)',
v::text, '0',
CASE WHEN v = 0 THEN 'PASS' ELSE 'FAIL' END,
'跑 npm run revenue:backfill(deploy/post-deploy.sh 会自动执行);未回填时退款按钮置灰'
FROM (
SELECT count(*) AS v FROM order_items WHERE revenue_basis = 'unknown'
) s;
-- 03 履约方式回填 ----------------------------------------------------------
INSERT INTO _launch_checks
SELECT 3, '03', '履约方式已回填(fulfillment_mode)',
v::text, '0',
CASE WHEN v = 0 THEN 'PASS' ELSE 'FAIL' END,
'跑 npm run fulfillment:backfill;NULL 会让订单/成本页口径缺失'
FROM (
SELECT count(*) AS v FROM order_items WHERE fulfillment_mode IS NULL
) s;
-- 04 履约方式取值合法 ------------------------------------------------------
INSERT INTO _launch_checks
SELECT 4, '04', '履约方式取值合法',
v::text, '0',
CASE WHEN v = 0 THEN 'PASS' ELSE 'FAIL' END,
'存在未知 fulfillment_mode,检查回填脚本与历史脏数据'
FROM (
SELECT count(*) AS v
FROM order_items
WHERE fulfillment_mode IS NOT NULL
AND fulfillment_mode NOT IN ('work_order', 'manual', 'legacy_platform', 'affiliate_dash')
) s;
-- 05 商品目录 --------------------------------------------------------------
INSERT INTO _launch_checks
SELECT 5, '05', '商品目录已初始化(product_catalog)',
v::text, '> 0',
CASE WHEN v > 0 THEN 'PASS' ELSE 'FAIL' END,
'商品与成本 → 标准商品 →「从接单模板初始化」/「按工单回填商品」/「归一人工商品」'
FROM (SELECT count(*) AS v FROM product_catalog) s;
-- 06 有效成本 --------------------------------------------------------------
INSERT INTO _launch_checks
SELECT 6, '06', '有效成本已生成(版本定价或正金额快照)',
'版本 ' || priced_versions::text || ' 个 / 正金额快照 ' || positive_snapshots::text || ' 条',
'至少一项 > 0',
CASE WHEN priced_versions > 0 OR positive_snapshots > 0 THEN 'PASS' ELSE 'FAIL' END,
'迁移只建 0 元占位版本;工单奖励/实际成本会写入快照。两项都为 0 时:先配置成本版本,或确认工单奖励已回写'
FROM (
SELECT
(SELECT count(*) FROM cost_versions
WHERE status = 'active' AND (amount_fen > 0 OR rate_bps > 0)) AS priced_versions,
(SELECT count(*) FROM cost_snapshots
WHERE superseded_at IS NULL AND total_amount_fen > 0) AS positive_snapshots
) s;
-- 07 客服备用金账户 --------------------------------------------------------
INSERT INTO _launch_checks
SELECT 7, '07', '客服备用金账户已补建',
v::text, '0',
CASE WHEN v = 0 THEN 'PASS' ELSE 'FAIL' END,
'钱包管理 → 客服账户 →「补建客服账户」(POST /admin/fund/accounts/ensure-agents)'
FROM (
SELECT count(*) AS v
FROM admin_users au
LEFT JOIN fund_accounts fa
ON fa.owner_admin_user_id = au.id
AND fa.account_type = 'agent'
WHERE au.role = 'support'
AND fa.id IS NULL
) s;
-- 08 上线后人工项必须有履约配置 -------------------------------------------
INSERT INTO _launch_checks
SELECT 8, '08', '上线后人工项已匹配履约配置',
v::text, '0',
CASE WHEN v = 0 THEN 'PASS' ELSE 'FAIL' END,
'商品与成本 → 人工 SKU 配置 →「扫描」并逐项配置;保存后会自动回填近 30 天'
FROM (
SELECT count(*) AS v
FROM order_items oi
WHERE oi.fulfillment_mode = 'manual'
AND oi.created_at >= :'launch_at'::timestamptz
AND oi.fulfillment_config_id IS NULL
) s;
-- 09 上线后人工项必须有成本快照(关账门槛) -------------------------------
INSERT INTO _launch_checks
SELECT 9, '09', '上线后人工项均已确认履约(关账门槛)',
v::text, '0',
CASE WHEN v = 0 THEN 'PASS' ELSE 'FAIL' END,
'待办量:有未确认人工单就会 FAIL,不宜当日常健康检查;订单列表/详情 →「确认履约」后写 manual_case 快照并扣备用金/积分'
FROM (
SELECT count(*) AS v
FROM order_items oi
WHERE oi.fulfillment_mode = 'manual'
AND oi.created_at >= :'launch_at'::timestamptz
AND NOT EXISTS (
SELECT 1 FROM cost_snapshots cs
WHERE cs.subject_type = 'manual_case'
AND cs.order_item_id = oi.id
AND cs.superseded_at IS NULL
)
) s;
-- 10 人工快照唯一性 --------------------------------------------------------
INSERT INTO _launch_checks
SELECT 10, '10', '一个订单项最多一条有效人工快照',
v::text, '0',
CASE WHEN v = 0 THEN 'PASS' ELSE 'FAIL' END,
'出现重复快照说明并发确认未被幂等拦截,需排查 manual_case 写入路径'
FROM (
SELECT count(*) AS v
FROM (
SELECT order_item_id
FROM cost_snapshots
WHERE subject_type = 'manual_case' AND superseded_at IS NULL
GROUP BY 1
HAVING count(*) > 1
) dup
) s;
-- 11 快照金额 = 备用金支出(现金路径) ------------------------------------
INSERT INTO _launch_checks
SELECT 11, '11', '人工快照金额与备用金支出一致',
v::text, '0',
CASE WHEN v = 0 THEN 'PASS' ELSE 'FAIL' END,
'金额不一致通常是确认事务部分失败;查 cost_snapshots / manual_expenses / fund_entries 三处'
FROM (
SELECT count(*) AS v
FROM cost_snapshots cs
-- 只核对现金路径:metadata.fundExpenseId 精确定位备用金支出,
-- 避免同一订单项有多条 manual_expenses(如另外登记的人工支出)时行放大误报。
JOIN manual_expenses me
ON me.id = NULLIF(cs.metadata_json->>'fundExpenseId', '')::bigint
WHERE cs.subject_type = 'manual_case'
AND cs.superseded_at IS NULL
AND cs.created_at >= :'launch_at'::timestamptz
AND cs.total_amount_fen <> me.amount_fen
) s;
-- 12 总账借贷平衡 ----------------------------------------------------------
INSERT INTO _launch_checks
SELECT 12, '12', '总账借贷平衡(全部已过账凭证)',
v::text || '(已过账凭证 ' || posted::text || ' 张)', '借贷为 0 且至少有 1 张凭证',
CASE WHEN v = 0 AND (posted > 0 OR facts = 0) THEN 'PASS' ELSE 'FAIL' END,
'财务 → 总账与对账 →「同步销售事实到总账」;有销售事实但 0 张凭证同样视为未完成(空表求和恒为 0,不能直接判 PASS)'
FROM (
SELECT
COALESCE(abs(sum(l.debit_fen) - sum(l.credit_fen)), 0)::bigint AS v,
count(DISTINCT e.id) AS posted,
(SELECT count(*) FROM sales_daily_facts) AS facts
FROM finance_journal_lines l
JOIN finance_journal_entries e ON e.id = l.entry_id AND e.status = 'posted'
) s;
-- 13 已关账期间不应再产生新凭证 -------------------------------------------
INSERT INTO _launch_checks
SELECT 13, '13', '已关账期间无新增凭证',
v::text, '0',
CASE WHEN v = 0 THEN 'PASS' ELSE 'FAIL' END,
'检查关账后是否有人重跑同步;以 closed_at 为基准,正常关账前生成的凭证不算违规'
FROM (
SELECT count(*) AS v
FROM finance_journal_entries e
JOIN finance_periods p
ON p.period_key = e.period_key
AND p.status = 'closed'
WHERE p.closed_at IS NOT NULL
AND e.created_at > p.closed_at
) s;
-- 14 销售事实已生成 --------------------------------------------------------
INSERT INTO _launch_checks
SELECT 14, '14', '销售事实已生成(sales_daily_facts)',
v::text, '> 0',
CASE WHEN v > 0 THEN 'PASS' ELSE 'FAIL' END,
'跑 finance:sync --mode=facts(post-deploy.sh 会按窗口执行);定时任务只覆盖最近 7 天'
FROM (SELECT count(*) AS v FROM sales_daily_facts) s;
-- 15 近 7 天成本覆盖(仅提示,不阻断上线) ---------------------------------
-- 说明:历史人工成本按既定口径不补录,这里只看非人工;自动发货/接单平台
-- 的成本缺口会直接影响毛利,必须在上线前处理,但不用它单独卡住验收。
INSERT INTO _launch_checks
SELECT 15, '15', '近 7 天非人工销售事实成本覆盖(仅提示)',
v::text, '0',
CASE WHEN v = 0 THEN 'PASS' ELSE 'WARN' END,
'存在成本缺失的非人工销售事实:先归一商品并配置成本版本;历史人工成本按既定口径不补录,故不计入'
FROM (
SELECT count(*) AS v
FROM sales_daily_facts
WHERE stat_date >= current_date - 6
AND fulfillment_mode <> 'manual'
AND cost_missing_count > 0
) s;
\o
\echo '【闸门清单】'
SELECT seq AS "#",
status,
id,
title AS "检查项",
actual AS "实际",
expected AS "期望"
FROM _launch_checks
ORDER BY seq;
\echo ''
\echo '【未通过 / 提示项的处理建议】'
SELECT id,
status AS "状态",
title AS "检查项",
hint AS "处理建议"
FROM _launch_checks
WHERE status <> 'PASS'
ORDER BY seq;
\echo ''
\echo '【汇总】'
SELECT count(*) FILTER (WHERE status = 'PASS') AS "通过",
count(*) FILTER (WHERE status = 'FAIL') AS "未通过",
count(*) FILTER (WHERE status = 'WARN') AS "提示",
count(*) AS "合计"
FROM _launch_checks;
\echo ''
\echo '【数据规模参考】'
SELECT (SELECT count(*) FROM orders) AS "订单",
(SELECT count(*) FROM order_items) AS "订单项",
(SELECT count(*) FROM product_catalog) AS "商品",
(SELECT count(*) FROM cost_snapshots WHERE superseded_at IS NULL) AS "有效成本快照",
(SELECT count(*) FROM sales_daily_facts) AS "销售事实行",
(SELECT count(*) FROM finance_journal_entries) AS "总账凭证",
(SELECT count(*) FROM cost_exceptions WHERE status = 'open') AS "未处理成本异常",
(SELECT count(*) FROM order_items WHERE revenue_basis = 'unknown') AS "待回填收入项";
DO $$
DECLARE
failed_count int;
failed_list text;
BEGIN
SELECT count(*), string_agg(id || ' ' || title, ';' ORDER BY seq)
INTO failed_count, failed_list
FROM _launch_checks
WHERE status = 'FAIL';
IF failed_count > 0 THEN
RAISE EXCEPTION '财务上线验收未通过(% 项):%', failed_count, failed_list;
END IF;
RAISE NOTICE '财务上线验收全部通过';
END $$;