交付出件箱

← 返回出件箱

资金链 7 步 · 微信回调移植清单(批 B 施工图)

2026-08-14 · dev2 云机 · 纯读现网代码整理,未改一行
依据 = settlement/backend 真实代码,每条带 文件:行号,可逐条核。
路线已定:路 B(快照挂支付订单,不写影子凭证) —— 长官 2026-08-14 拍板。


0. 一句话结论

方案 §五E 说「微信回调必须整段移植资金链 7 步」。读完代码,这句话不完整:

7 步照做只保证"钱算对了",不保证"钱看得见"。收益台账(PC / 渠道 / 平台手机端)
和结算单对账明细,走的是 收益快照 → 到账确认 → 付款凭证 的内连接。
微信支付没有"凭证",若不处理,这笔钱库里对、界面上查不到,而结算单总额
又由另一条不看凭证的查询算出 —— 总额有、明细无,结算单自己对不上账。

路 B 的解法:微信支付照常建「到账确认」记录(那本来就是"这笔钱确认到账"的语义),
但它挂的是支付订单而不是凭证;下游只需把「凭证」那一层从内连接改成外连接。


1. 现网入口与两条路径

环节 位置
API 入口 app/api/settlement/payment_vouchers.py:514 POST /{voucher_id}/confirm
主函数 app/services/settlement/payment_confirmation.py:322 confirm_payment_voucher
首签路径 同文件 349-453
增购/续费路径 同文件 190-319 _confirm_service_order_voucher(由 :338 分流)

分流判据(:338):voucher.service_order_id is not None → 走服务订单路径。


2. 七步逐条 · 现网代码在哪、干了什么

以首签路径为主轴,标出增购/续费路径的差异。

第 1 步 · 取数并加锁

payment_confirmation.py:330 get_payment_voucher_bundle(..., lock=True)
:331 _assert_pending() —— 非「待确认」直接拒。

增购/续费额外::205 锁订单行(with_for_update=True),且 order.status != "待确认到账" 就拒(:210)。

第 2 步 · 锁两级渠道(🔴 漏了会串比例)

payment_confirmation.py:134-157 _locked_revenue_channels
- 二级渠道 → 连同 parent_channel_id 一起 SELECT ... FOR UPDATE(:146)
- 一级渠道 → 来源渠道与结算渠道是同一个(:153)
- 二级但缺父级 / 父级非 primary → 409 拒绝,不静默放行(:141、:156)

⚠️ 锁是为了"读比例→写快照"之间别人改不了比例。微信回调并发更高,这把锁只能更严不能去掉。

第 3 步 · 取三种分成比例(🔴 三种业务取的不是同一列)

业务 取哪列 代码
首签 channel.rate_percent :351-352
增购 channel.expansion_rate_percent :184-187 _service_order_rate
续费 channel.renewal_rate_percent 同上

均 quantize(0.01, ROUND_HALF_UP)。拿错列 = 分成比例错 = 直接算错钱。

第 4 步 · 算两级分成金额

:353-354(首签)/ :219-220(增购续费):

source_channel_amount = 金额 × 来源渠道比例 / 100   (二级渠道拿的)
channel_amount        = 金额 × 结算渠道比例 / 100   (一级渠道结算的)

两者都 quantize(0.01, ROUND_HALF_UP);一级渠道场景两值相等。

第 5 步 · 建 PaymentConfirmation

:364-373(首签)/ :226-235(增购续费)
字段 voucher_id / org_id / confirmed_amount / confirmed_by / confirmed_at / result="confirmed",
建完立即 flush()(下一步快照要它的 id)。

第 6 步 · 建 RevenueSnapshot 并冻结比例

:375-393(首签)/ :237-256(增购续费)。冻结进快照的字段:

payment_confirmation_id / channel_id(结算渠道) / source_channel_id(来源渠道) / org_id
received_amount / rate_percent_snapshot / source_rate_percent_snapshot
source_channel_amount / channel_amount
period_month / period_key / period_start / period_end
settlement_status = "in_statement"
service_order_id   ← 仅增购/续费有(:253),首签为空

冻结 = 日后改渠道比例,这笔已生成的分成不变。

第 7 步 · 归账期 + 推进单据状态

归账期::375 period_for_datetime(confirmed_at) → periods.py:37-40
🔴 半月账期,不是月度:H1 = 1~15 日,H2 = 16~月末(periods.py:51-55)。

推进状态:
- 首签::395-396 凭证转已确认;不动 profile.status(开通仍人工点)
- 增购/续费::258-260 凭证转已确认 + order.status = "待开通"
→ 由 seat_provisioning_executor.py:89 执行开通,该函数要求状态恰为「待开通」(:99)

审计:每路径写 3 条 AuditLog(:398-450 / :262-316)。


3. 路 B 施工方案(已拍板)

3.1 为什么不走路 A(影子凭证)

路 A = 微信支付也往 payment_vouchers 写一行,好处是下游零改动。否决理由:

  1. 明着违反方案自己的原则 —— §五C line 215:
    「老表 payment_vouchers 转只读、数据一条不删;新表 payment_orders 只装微信订单」
  2. 会在「支付记录」列表里重复计数 —— 该列表是 历史线下凭证(只读) ∪ 微信新单 两类拼接
    (效果图 ⑥ 批注:「切换后只剩历史线下只读 + 微信新单两类,无中间态」)。
    影子凭证会让同一笔微信支付出现两次,除非每一处展示都记得排除它 —— 漏一处就是钱重复算。
  3. 状态填不出来:payment_vouchers.status 的 CHECK 只允许
    待确认 / 已确认到账 / 已驳回(models/settlement.py:330),填不了设计要的「已支付 / 已退款」。
  4. 凭证图字段必填:voucher_file_key NOT NULL(models/settlement.py:344),
    微信支付无凭证只能塞占位串,而效果图 ⑦ 明写「微信支付无凭证,付款人以微信昵称 + openid 记录」。

3.2 路 B 的核心思路

「到账确认」这条记录保留 —— 它的语义本来就是"这笔钱确认到账了",微信回调正是此事。
只把它挂到支付订单而不是凭证上。于是:

3.3 Schema 变更(3 处,都在 settlement_rw 属主表上,部署账号有权)

属主已转:grants.sql:65-66 payment_confirmations / revenue_snapshots OWNER = settlement_rw
⚠️ 新增列若指向核心 users/organizations 一律裸 Integer 不建 FK(避坑指南第 20 条,批 A 已实炸一次)

# 变更 现状 理由
1 payment_confirmations.voucher_id → 可空 nullable=False(:366) 微信侧没有凭证
2 payment_confirmations 新增 payment_order_id UUID + unique + FK payment_orders 无 挂支付订单;unique 就是回调幂等的兜底(微信会重复推送,重复回调撞唯一约束即止)
3 payment_confirmations.confirmed_by → 可空 nullable=False(FK users) 微信自动确认没有"操作人",不能伪造成某个管理员
4 加 CHECK:voucher_id 与 payment_order_id 恰有一个非空 无 防两边都空的孤儿确认记录

✅ voucher_id 改可空不会撞它的 unique 约束 —— PostgreSQL 唯一索引里多个 NULL 互不冲突。
✅ 核心主仓 app/models/settlement.py:264-276 有 payment_confirmations 的冻结副本,
但核心 alembic 已用 include_object 把结算表整体排除(migrations/env.py:41-57),
不会因两边模型不一致而生成回退迁移 —— 这条我一开始判成风险,实查后否掉,留档免得下次再虚惊。

3.4 下游查询改造(4 处 · 实际只改一行 join + 一处取值)

关键发现:下游从凭证上只读了 payment_no 一个字段。 逐处 grep 实证:

下游 join 位置 读的字段 改法
PC 收益台账 revenue_ledger.py:109-110 voucher.payment_no(:170) join(PaymentVoucher) → outerjoin;再 outerjoin(PaymentOrder);取值改 voucher.payment_no or order.out_trade_no
渠道侧收益台账 revenue_ledger.py:228-229 voucher.payment_no(:287) 同上
平台手机端台账 platform_mobile_paging.py:603-604 voucher.payment_no(:686 附近) 同上
结算单对账明细 settlement_statements.py:1081-1082 voucher.payment_no(:1131 附近) 同上
对账 PDF settlement_reconciliation_pdf.py:154-155、274-275 已是 外连接 ✅ 不动;但取 voucher 字段处同样要兜底
结算单总额生成 settlement_statements.py:783-795 不 join 凭证链 ✅ 不动

⇒ join(PaymentConfirmation) 那半截四处全部保持内连接,改动集中在「凭证」那一层。


4. 批 B 开工检查清单(逐条打勾)

资金链本体
- [ ] 按业务类型取对比例列(首签 rate_percent / 增购 expansion_rate_percent / 续费 renewal_rate_percent)
- [ ] 两级渠道 SELECT ... FOR UPDATE 加锁后再读比例
- [ ] 二级渠道缺父级 / 父级非 primary → 拒绝,不静默放行
- [ ] 金额一律 quantize(0.01, ROUND_HALF_UP)
- [ ] 建 confirmation 后 flush() 再建快照
- [ ] 快照 13 个字段一个不漏,settlement_status="in_statement"
- [ ] 增购/续费快照挂 service_order_id;首签不挂
- [ ] 账期用 period_for_datetime(),半月口径
- [ ] 增购/续费把 order.status 推到「待开通」,否则开通执行器不认(seat_provisioning_executor.py:99)
- [ ] 3 条 AuditLog 照写

签名与验签(root 2026-08-14 实机确认 · 走的是「微信支付公钥模式」不是平台证书模式)
- [ ] 🔴 请求头 Wechatpay-Serial 放的是 微信支付公钥 ID(WECHATPAY_PUBLIC_KEY_ID),
不是商户 API 证书序列号 —— 知识库原文亦印证:「请注意这里是平台证书序列号,
而不是商户API证书序列号」(报错:HTTP header缺少微信支付平台证书序列号-4012365346)
- [ ] 🔴 商户 API 证书序列号放在 Authorization 签名串的 serial_no 里
- [ ] 回调验签:微信把它用的公钥 ID 放在回调请求头 Wechatpay-Serial,按它取对应公钥验签
- [ ] 这两处搞反 = 直接被拒签,且报错信息不会明说是搞反了

🔴 上生产前的拦路虎(root 2026-08-14 提出 · 主会话已实查确认)
- [ ] 结算生产流水线还指向旧仓:sudo release-pipeline-status settle-prod 实测
最新 run#7 的仓库是 tongban-settlement.git,而 settle-test 是 doctor-ai-brain.git。
⇒ 批 B 代码进统一仓后,生产结算不会构建到它。上生产前必须先把生产流水线源切到统一仓
(或走 Phase72 发 main 那条路)。这件事不解决,批 B 只能停在 test。

路 B 专属
- [ ] 🔴 新表 payment_orders 必须同步加进核心仓 migrations/env.py 的 SETTLEMENT_TABLES
—— 那是写死的清单(结算侧是动态白名单、会自动含新表,核心侧不会)。
漏加则核心主链 alembic revision --autogenerate 会把它判成"多余表"生成 DROP TABLE。
批 A 就漏过一次(2026-08-14 自检抓出并补回),env.py 里那句「结算加新表时两边同步改」是认真的。
- [ ] confirmation 挂 payment_order_id、voucher_id 留空、confirmed_by 留空
- [ ] 回调幂等:靠 payment_order_id 唯一约束兜底,同一笔支付重复推送只产出一份确认 + 一份快照
- [ ] 4 处下游 join 改造 + payment_no 取值兜底
- [ ] 迁移里新增列不建指向核心 users 的 FK(避坑指南 20)

🔴 联调完成后必须删除的临时件(批 B 起,别留到生产)
- [ ] app/api/wx_openid_probe.py + 核心 main.py 里的两行注册 —— openid 取号探针页
(批 C 正式授权页上线后即可删)
- [ ] settlement/backend/app/api/settlement/wechatpay.py 的 /self-check 端点 +
settlement main.py 里的两行注册 —— 无鉴权,只回布尔不回配置值、结果缓存 60 秒,
但不该长期留在生产
- [ ] 删除后各跑一次 grep -rn "wx-probe\|self-check" 确认零残留

验收(钱面,必须真跑)
- [ ] test 上真走一遍:首开下单 → 支付 → 收益台账看得见这笔 → 生成结算单 → 明细有这一行且总额对得上
- [ ] 增购、续费各真跑一遍(三种比例列各覆盖一次)
- [ ] 重复推送同一笔回调,验证只产出一份快照(幂等)
- [ ] 二级渠道场景真跑一遍(两级分成都要有值)


5. 交底分层(哪些是硬事实,哪些还没做)