交付出件箱

← 返回出件箱

结算 9 个数据库改动 · 真库预演报告 · 2026-08-20

云机环境 · 非验收证据。全部结论下方附真实终端输出。
目的:发生产 SOP 要求「含 migration 的发版必须先真库排练(同版本库+存量行,真跑升级验存量未动)」。

结论(先说结果)

项 结果
9 个迁移从 main 版本升到 test 版本 ✅ 全部跑通,退出码 0
存量数据是否被动 ✅ 一行没动(5 张表前后计数完全一致)
新表 ✅ 4 张全部建成
新字段 ✅ 9/9 全部到位
破坏性操作(升级段) ✅ 零 —— 无 drop_table / drop_column
🔴 存量守门 ✅ 实测生效,且失败时整体回滚

一、预演怎么搭的(为什么这么搭)

生产结算停在 phase66_000_zero_guarantee(settle-prod 最后部署 08-10),
test 在 phase71_000_support_qrcodes,中间隔 9 个迁移。

搭法:复制本地真库 → 退回 phase66 → 再升回 head。带真实存量行,最接近生产。

踩到的两个坑(都是既有问题,不是本次改动引入)

① 核心迁移链从零跑不通

先试过建空库从头跑,失败:

Running upgrade 0c5960f2e981 -> 002 ... -> 003
psycopg2.errors.DuplicateTable: relation "wechat_work_config" already exists

第 003 号要建的表,前面 0c5960f2e981 已经建过。生产不受影响(库是一路长起来的、不会重放),
但新环境永远建不起来。独立问题,已记录。

② 结算迁移无法从当前数据回退

asyncpg.exceptions.NotNullViolationError:
column "voucher_id" of relation "payment_confirmations" contains null values

微信支付新流程产生的到账记录本来就没有凭证号,回退时要把该列改回 NOT NULL 就撞上。
处理:在一次性复制库上删掉这 2074 行(生产 phase66 年代不存在这类记录),对齐后回退成功。
⚠️ 含义:结算迁移一旦上线就基本回不去了,发版前要有这个心理准备。

二、🔴 最要紧的一条:存量守门实测

phase70_000_drop_pending_amount 里写了道硬守门:

def _assert_no_pending_amount_rows() -> None:
    remaining = bind.execute(sa.text(
        f"select count(*) from {TABLE} where status = '待确认金额'")).scalar_one()
    if remaining:
        raise RuntimeError(f"still {remaining} order(s) in status '待确认金额'. ...")

负面测试:往预演库塞 1 张「待确认金额」单(amount_due 为空,还原真实坏单),跑升级:

Running upgrade phase69_000_review_guard -> phase70_000_drop_pending_amt, 废「待确认金额」状态
RuntimeError: still 1 order(s) in status '待确认金额'. 请先在平台把这些在途单人工跑完或取消...
--- 退出码 1 ---
--- 拦下后版本: phase66_000_zero_guarantee ---

🔴 一张坏单就把整批升级拦下,并且版本退回起点、前 8 个迁移一并回滚,无中间态。

⇒ 风险性质是「发版发不出去」,不是「数据被弄坏」。 失败是安全的。

但这带出一个必须查生产的问题

✅ 发版前必须在生产库跑这一条:

SELECT count(*) FROM store_service_orders WHERE status = '待确认金额';
-- 必须 = 0,否则整批结算迁移会中止

三、真实终端输出

升级全程(9 个迁移)

Running upgrade phase66_000_zero_guarantee -> phase67_000_service_prices, 价格管理
Running upgrade phase67_000_service_prices -> phase67_001_change_clock_ts, 改价历史时间戳
Running upgrade phase67_001_change_clock_ts -> phase68_000_payment_orders, 微信支付订单表
Running upgrade phase68_000_payment_orders -> phase68_001_refund_offset, 退款冲销
Running upgrade phase68_001_refund_offset -> phase68_002_signup_wx, 签约档案存微信身份
Running upgrade phase68_002_signup_wx -> phase68_003_store_closed, 机构关闭留痕
Running upgrade phase68_003_store_closed -> phase69_000_review_guard, 复核链接手机号验证
Running upgrade phase69_000_review_guard -> phase70_000_drop_pending_amt, 废「待确认金额」
Running upgrade phase70_000_drop_pending_amt -> phase71_000_support_qrcodes, 客服二维码
--- 退出码 0 ---
--- 升级后版本: phase71_000_support_qrcodes ---

存量数据前后对比(一行没动)

BEFORE  服务订单 3407 | 到账确认 4203 | 签约档案 24861 | 收益快照 4245 | 渠道 41307
AFTER   服务订单 3407 | 到账确认 4203 | 签约档案 24861 | 收益快照 4245 | 渠道 41307

新建的 4 张表

payment_orders / service_price_changes / service_prices / support_qrcodes

新增的 9 个字段(全在 org_signup_profiles · 9/9)

payer_openid  payer_unionid  payer_nickname  payer_avatar
closed_reason  closed_at  closed_by
review_link_failed_attempts  review_link_locked_until

状态约束已去掉「待确认金额」

CHECK (status = ANY (ARRAY['待付款','待确认到账','待开通','已完成','已驳回','已取消','开通失败']))

四、升级段无破坏性操作(逐个核过)

按 upgrade() 段单独解析(先前混着 downgrade() 段看会误判):

迁移 升级段动作
phase67_000 create_table ×2, create_index ×1
phase67_001 execute(ALTER 默认值)
phase68_000 create_table ×1, add_column ×1, alter_column ×2, 约束/索引 ×5
phase68_001 drop_constraint ×3(约束,非数据), create_check_constraint ×1, create_index ×1
phase68_002 add_column ×4, create_index ×1
phase68_003 add_column ×3
phase69_000 add_column ×2
phase70_000 换 CHECK 约束 + 换索引(名字吓人,实际不删列不删表)
phase71_000 create_table ×1

全部 9 个升级段:drop_table 0 次、drop_column 0 次。

五、清理

预演库已删除,临时 worktree 已移除,本地真库未被触碰(仍 phase71_000_support_qrcodes)。

云机环境 · 非验收证据。