核心资金事实:MySQL 事务 + Worker 轮询技术方案
1. 决策与边界
每个国家数据中心自己的 MySQL。任何资金结果必须能从业务事实表与不可变账本解释出来。
MySQL独立 Worker 定时拉平台订单、扫描待结算佣金、推进提现与执行对账。
Worker唯一键、CAS、行锁和短事务负责正确性;Redis 分布式锁只减少重复扫描。
DB GuardOutbox/MQ 可承接通知、Push、BI 等副作用,但不能直接决定余额或提现成功。
No Money Decision2. 什么是“核心资金事实”
“资金事实”是能够改变用户应得金额、可用余额、冻结余额或 BeeX 对外付款义务的数据。它和页面展示、缓存、通知不是同一个层级。
| 事实类别 | 典型记录 | 为什么属于资金事实 | 不能由谁单独决定 |
|---|---|---|---|
| 平台订单事实 | affiliate_orders | 订单金额、平台佣金、状态、下单时间决定后续返现与分佣。 | H5、本地缓存、MQ 消息 |
| 收益权利 | commission_records | 记录谁因哪一单、按哪个规则、应得到多少钱及何时可结算。 | 前端估算值、飞书订单通知 |
| 活动额度 | coupon_reservations、用户券 | 订单创建时预占,完成时核销,退款时释放或顺延。 | 活动 Banner、页面文案 |
| 钱包当前态 | user_wallets | 提供 pending、available、frozen、risk hold 的可锁定余额快照。 | Redis 缓存、客户端余额 |
| 资金变动凭证 | wallet_ledger | 每一笔变化都记录来源、业务键、变化前后余额,可审计、可对账。 | 任意覆盖式 UPDATE |
| 提现义务 | withdraw_requests、payout_transactions | 记录用户申请、冻结、第三方付款、成功/失败与解冻。 | 单次 HTTP 返回、人工截图 |
wallet_ledger 是不可变审计轨迹,user_wallets 是同一事务内维护的可锁定余额投影。两者必须一起成功或一起回滚;不能只改余额不写账,也不能先写账后异步改余额。3. 不可破坏的资金不变量
- 一笔业务只入账一次:所有资金动作都有稳定业务幂等键,数据库唯一约束是最后一道防线。
- 余额守恒:
pending + available + frozen + riskHold的每次变化必须与账本 delta 和 before/after 对得上。 - 状态只能按允许方向推进:通过 CAS(
WHERE status = expected)更新,禁止无条件覆盖。 - 金额统一用整数 minor unit:数据库和服务端计算使用
BIGINT,不使用 float/double。按 BeeX 当前约定,展示前再由统一金额组件换算和向下取整。 - 规则按下单时间命中:佣金比例、平台佣金基数上限、活动规则以
ordered_at命中生效版本并形成快照,之后修改配置不改变历史订单。 - 外部调用不占用资金事务:事务内只落事实和任务,事务外调用第三方,返回后再开事务确认结果。
- 允许重复执行,不允许重复生效:Worker、Webhook 和 MQ 都按至少一次处理设计,不假设“恰好执行一次”。
- 通知不阻塞资金提交:飞书、Push、BI、SLS 全部在事务提交后处理,失败可重试但不得回滚已确认的资金事实。
余额变动通用断言 newBalance = oldBalance + delta newBalance >= 0 -- 除非明确采用负债模型 UNIQUE(ref_type, ref_id, ledger_type) -- 同一业务动作只写一次账 wallet update + ledger insert -- 必须处于同一 MySQL 事务
4. 总体架构
flowchart LR
PLATFORM["TikTok / Shopee / Lazada / Traveloka"] -->|"轮询原始订单"| WORKER["Worker :7020"]
WORKER --> NORMALIZE["平台适配与标准化\n事务外完成"]
NORMALIZE --> TX["单订单短事务"]
TX --> ORDERS[("affiliate_orders")]
TX --> COMM[("commission_records")]
TX --> COUPON[("coupon_reservations")]
TX --> WALLET[("user_wallets + wallet_ledger")]
TX --> OUTBOX[("outbox_event")]
SETTLE["结算 Worker"] -->|"扫描到期记录"| COMM
SETTLE --> WALLET
PAYOUT["提现 Worker"] --> PAYTASK[("payout task / transaction")]
PAYOUT -->|"事务外调用"| XENDIT["Xendit"]
XENDIT -->|"Webhook / Query"| WEBHOOK["Webhook :7030"]
WEBHOOK --> PAYTASK
WEBHOOK --> WALLET
OUTBOX --> RELAY["Outbox Relay"]
RELAY --> SIDE["飞书 / Push / BI / 可选 MQ"]
平台订单同步不是一个跨网络的大事务。Worker 先在事务外调用平台 API、解析数据,再把每个订单交给独立短事务处理。一个订单失败只影响自己,不能让整个分页批次回滚;页面、通知和 MQ 也不进入这段资金事务。
5. 数据模型与职责
| 表 | 当前/目标 | 业务主键或唯一键 | 职责 |
|---|---|---|---|
affiliate_orders | 已有 | (platform, platform_order_id) | 平台订单标准化事实和原始 payload;不能用通知记录代替。 |
platform_order_index | 已有 | (platform, platform_order_id) | 平台订单到国家/用户/本地订单的反查索引。 |
commission_records | 已有 | order + benefit type + beneficiary 的确定性 ID | 返现/邀请奖励的应收权利、状态、到期时间和规则快照。 |
user_wallets | 已有 | user_id | 当前余额投影;所有更新先 SELECT ... FOR UPDATE。 |
wallet_ledger | 已有 | (ref_type, ref_id, type) | 不可变资金流水,记录变化前后余额。 |
coupon_reservations | 已有 | 订单 + 券/权益 | 订单先到先占活动额度,完成核销,退款释放或顺延。 |
withdraw_requests | 已有 | withdraw_id | 提现业务状态、金额、手续费、收款资料快照。 |
payout_transactions | 已有,需增强 | withdraw_id / provider reference | 作为持久化付款命令和第三方尝试记录,补充 lease、next_attempt、request/response。 |
outbox_event | 已有 | event_id | 事务提交后的通知副作用;允许重复投递,消费者必须幂等。 |
worker_sync_cursor | 建议新增 | (country, job_name) | 保存平台拉取 watermark、page token、最后成功时间和版本。 |
risk_events / risk_blacklist | 已有 | 退款订单 + 受益人 + 风险类型 | 已释放或已提现收益发生退款时记录人工追偿证据并阻断提现;不自动制造负钱包。 |
6. 平台订单入库:一个订单一个事务
sequenceDiagram
participant P as 平台 API
participant W as Order Sync Worker
participant S as FinancialOrderIngestService
participant DB as MySQL
participant O as Outbox Relay
W->>P: 拉取一页订单(事务外)
P-->>W: 原始订单列表
loop 每个订单独立处理
W->>S: processOne(normalizedOrder)
S->>DB: BEGIN
S->>DB: upsert affiliate_orders(平台订单唯一键)
S->>DB: 解析并锁定归因/规则快照
S->>DB: upsert commission_records
S->>DB: 预占 coupon_reservations
S->>DB: 锁 user_wallets,写 pending + wallet_ledger
S->>DB: 写 outbox_event
S->>DB: COMMIT
end
O->>DB: 事务后轮询 Outbox
6.1 事务外完成
- 调用 TikTok/Shopee/Lazada/Traveloka API、翻页、签名和 JSON 解析。
- 平台字段标准化:平台订单号、商品、金额、平台佣金、下单时间、平台更新时间、状态。
- 生成稳定本地 ID 和幂等业务键;不能使用本次运行随机数决定资金记录 ID。
6.2 事务内完成
- 按
(platform, platform_order_id)upsert 订单;比较platform_updated_at和允许状态矩阵,旧消息不能覆盖新状态。 - 根据 tracking/click/referral 解析归属用户;归因补正必须保留变更审计。
- 以
ordered_at命中佣金规则版本,计算平台佣金基数M = min(platformCommission, GMV × maxCommissionBaseBps),当前上限由配置版本控制。 - 按 User 直邀关系生成用户返现和邀请人奖励。
- 如有返佣基金等活动,按订单时序预占额度,防止多个未完成订单重复占用同一权益。
- 写
commission_records=PENDING;若该平台状态已明确无效,则只保留审计记录,不增加 pending。 - 锁用户钱包,增加 pending,并写唯一账本。
- 写订单/收益变更 Outbox;提交后再发送飞书和 Push。
-- 数据库兜底:平台重复回传 100 次也只存在一个订单 UNIQUE KEY uk_platform_order (platform, platform_order_id) -- 资金账本兜底:同一佣金记录的 PENDING 入账只成功一次 UNIQUE KEY uk_ledger_ref (ref_type, ref_id, type)
7. 佣金结算:扫描到期记录,逐条短事务释放
现有 CommissionSettlementService 的方向是正确的:默认每 5 分钟扫描一次、批量取 200 条、每条佣金通过自代理进入独立事务,先 CAS 再把钱包 pending 转 available。保留这个模式,不需要为了“实时”改成由 MQ 直接加钱。
sequenceDiagram
participant W as Settlement Worker
participant DB as MySQL
W->>DB: 查询 PENDING 且 release_at <= now(limit 200)
loop 每条 commission
W->>DB: BEGIN
W->>DB: UPDATE commission_records SET status=AVAILABLE WHERE id=? AND status=PENDING
alt CAS 成功
W->>DB: SELECT user_wallets FOR UPDATE
W->>DB: pending -= amount, available += amount
W->>DB: INSERT wallet_ledger(唯一业务键)
W->>DB: INSERT outbox_event
W->>DB: COMMIT
else 已被其它实例处理
W->>DB: ROLLBACK / no-op
end
end
UPDATE commission_records SET status = 'AVAILABLE', settled_at = NOW() WHERE id = :commissionId AND status = 'PENDING' AND release_at <= NOW(); -- affectedRows = 1 才有资格继续改钱包;0 表示已处理或已失效。
8. 取消、退款与追偿
退款要根据佣金当前所在阶段采用不同动作,不能一律删除记录,也不能只改订单状态。详细状态与平台映射见《联盟订单退货、退款与收益撤销闭环》。
| 当前资金阶段 | 事务动作 | 用户可见结果 |
|---|---|---|
佣金仍为 PENDING | CAS 为 INVALID/REVERSED;钱包 pending 扣回;释放活动预占;写 REFUND_DEDUCT 账本。 | 待结算减少,活动额度顺延给后续符合订单。 |
| 退款处理中或部分退款 | 订单进入 FROZEN;冻结仍为 PENDING 的佣金;已释放收益创建复核事件。 | 显示退款审核中,不继续展示或结算旧金额。 |
已转为 AVAILABLE | 不自动扣减 available;创建追偿/风控事件并阻断提现,等待财务或运营受审计处理。 | 钱包不出现无法解释的负数;提现暂时不可用。 |
| 已提现 | 不静默抵扣未来收益;记录追偿证据、平台原始 payload 和风险事件,并阻断后续提现。 | 资金历史可解释,人工处理结果可审计。 |
| 退款驳回或撤回 | 订单恢复 COMPLETED;此前 FROZEN 的 PENDING 佣金恢复;解除对应复核。 | 恢复正常返现流程。 |
reversePendingCommission() 只自动撤销未释放佣金。AVAILABLE/PAID 后退款不自动扣可用余额、不建立自动债务,也不从未来收益静默抵扣;系统创建追偿风险记录并阻断提现,由财务或运营处理。
stateDiagram-v2
[*] --> PENDING
PENDING --> AVAILABLE: 到结算时间且订单有效
PENDING --> INVALID: 取消/退款
PENDING --> FROZEN: 退款处理中/部分退款
FROZEN --> PENDING: 退款驳回或撤回
FROZEN --> INVALID: 确认退款
AVAILABLE --> RECOVERY_REVIEW: 退款发生在收益释放后
RECOVERY_REVIEW --> MANUAL_RESOLVED: 财务/运营受审计处理
9. 提现与 Xendit:三段式执行
@Transactional 方法中直接调用 Xendit。网络超时会让数据库事务长时间持锁,并产生“Xendit 可能成功、MySQL 却回滚”的不确定状态。目标方案必须拆成三段。
sequenceDiagram
participant A as API / Admin
participant DB as MySQL
participant W as Payout Worker
participant X as Xendit
participant H as Xendit Webhook
A->>DB: Tx1 验证规则和支付密码,冻结余额,创建提现单与付款任务
DB-->>A: COMMIT(PAYOUT_PENDING)
W->>DB: 短事务领取任务,写 lease/attempt,COMMIT
W->>X: 事务外 POST payout,Idempotency-Key=withdrawId
X-->>W: 接受 / 明确失败 / 超时未知
W->>DB: Tx2 保存 providerRef 与本次结果
X->>H: Webhook 最终状态
H->>DB: Tx3 webhook 去重 + CAS 状态 + 扣 frozen 或解冻 + 账本
9.1 Tx1:申请与冻结
- 校验支付密码、KYC、收款账户、最低/最高金额、每日次数/金额和风控。
- 锁定
user_wallets,执行available -= amount、frozen += amount。 - 写
WITHDRAW_FREEZE账本。 - 创建
withdraw_requests和一条 READY/PENDING 的持久化 payout task。 - 同事务提交;此时钱没有付给用户,但已不能被再次提现。
9.2 事务外:调用 Xendit
- Worker 用
withdraw_id作为 Xendit external/idempotency key。 - 调用前先持久化 attempt,调用时不持有钱包锁和 MySQL 长事务。
- 明确失败才进入可解冻判断;超时、断网、502 等“结果未知”不能直接重试,也不能直接解冻。
- 结果未知时先按 external ID 查询 Xendit;确认没有创建付款后,才允许用同一幂等键重试。
9.3 Tx2/Tx3:确认结果
| Xendit 结果 | MySQL 事务动作 |
|---|---|
| 接受处理 | 保存 provider reference,CAS 到 PROCESSING;继续保持 frozen。 |
| Webhook 成功 | Webhook event 去重;CAS PROCESSING→PAID;frozen -= amount;写 WITHDRAW_SUCCESS。 |
| 明确失败且确定未扣款 | CAS PROCESSING/PENDING→FAILED;frozen -= amount、available += amount;写 WITHDRAW_RELEASE。 |
| 余额不足等可恢复错误 | 保持 PROCESSING/FUNDS_HELD,不解冻用户资金;Worker 10 分钟后查询或安全重试。 |
| 状态未知 | 标记 PROVIDER_UNKNOWN,禁止盲目重试和解冻,进入主动查询与告警。 |
stateDiagram-v2
[*] --> REQUESTED
REQUESTED --> REJECTED: 人工拒绝并解冻
REQUESTED --> PAYOUT_PENDING: 审核通过/免审核
PAYOUT_PENDING --> PROCESSING: Xendit 已接受
PAYOUT_PENDING --> PROVIDER_UNKNOWN: 超时或结果未知
PROVIDER_UNKNOWN --> PROCESSING: 查询到已创建
PROVIDER_UNKNOWN --> PAYOUT_PENDING: 确认未创建,可安全重试
PROCESSING --> PAID: 成功 Webhook
PROCESSING --> FUNDS_HELD: Xendit 余额不足等可恢复错误
FUNDS_HELD --> PROCESSING: 安全重试
PAYOUT_PENDING --> FAILED: 明确未扣款失败并解冻
PROCESSING --> FAILED: 明确终态失败并解冻
10. Worker 轮询机制
10.1 平台订单同步
- 每个平台、国家和任务使用独立 job key;当前 TikTok、Shopee、Lazada、Traveloka 等默认约 15 分钟轮询。
- Redis 分布式锁避免多个 Worker 同时扫同一平台,但锁失效后即使重复执行,MySQL 幂等仍必须保证正确。
- 使用 watermark + 重叠时间窗。每次从“上次成功时间减去一段 overlap”开始拉,接住平台延迟回传和状态补发。
- 按页拉取;每个订单独立提交;一页处理完再保存 page checkpoint。进程中途退出最多重放这一页。
- 定期执行更大 lookback 的补偿扫描,不能只靠一个永远向前的 cursor,否则晚到订单会永久漏掉。
10.2 本地到期任务
佣金释放、活动过期、提现重试直接扫描 MySQL 当前状态,不需要把“未来某个时间执行”的命令长期押在 MQ 中。
SELECT id FROM commission_records WHERE status = 'PENDING' AND release_at <= NOW() ORDER BY release_at, id LIMIT 200; -- 每条记录再进入独立事务做 CAS,查询结果本身不代表拥有处理权。
10.3 多 Worker 水平扩容
早期可继续用分布式锁控制单任务单实例。任务量增长后,对 payout 等执行任务增加数据库 lease:
BEGIN;
SELECT id FROM payout_tasks
WHERE status IN ('READY','RETRY')
AND next_attempt_at <= NOW()
AND (lease_until IS NULL OR lease_until < NOW())
ORDER BY next_attempt_at, id
FOR UPDATE SKIP LOCKED
LIMIT 50;
UPDATE payout_tasks
SET status='PROCESSING', lease_owner=:workerId,
lease_until=DATE_ADD(NOW(), INTERVAL 2 MINUTE), attempt_count=attempt_count+1
WHERE id IN (...);
COMMIT;
11. 幂等、并发与锁
| 动作 | 稳定幂等键 | 数据库保护 |
|---|---|---|
| 平台订单入库 | platform + platform_order_id | 订单唯一键 + 单调状态更新 |
| 一项订单收益 | order_id + benefit_type + beneficiary_user_id | 确定性 commission ID / 唯一键 |
| pending 入账 | COMMISSION_RECORD + commission_id + COMMISSION_PENDING | uk_ledger_ref |
| 活动预占 | campaign/coupon + order_id + user_id | reservation 唯一键 + 券行锁 |
| 提现申请 | 客户端 request ID 或 withdraw_id | 提现唯一键 + 钱包行锁 |
| Xendit 付款 | withdraw_id | 本地 payout 唯一键 + Xendit idempotency key |
| Webhook | provider event ID / provider reference + status | Webhook event 唯一键 + 状态 CAS |
| Outbox 事件 | aggregate + version + event_type | event_id 唯一键;消费者仍需幂等 |
11.1 锁顺序
所有资金事务统一锁顺序,减少死锁:先锁/更新业务根记录(订单、佣金、提现),再锁权益记录,最后按 user_id 排序锁钱包,随后只做 ledger/outbox insert。禁止某条路径先锁钱包、再回头锁同一订单的佣金记录。
11.2 隔离级别
不把正确性寄托在默认隔离级别上。首阶段保留当前 MySQL/Spring 隔离配置,依靠显式 FOR UPDATE、唯一键和 CAS;是否从 REPEATABLE READ 调整为 READ COMMITTED,要经过死锁、幻读和回归测试后单独决策。
12. Outbox 与 MQ 的边界
资金事务可以在提交前写一条 Outbox,但 Outbox 表示“资金事实已提交后,需要通知外部”,不是资金本身。
| 适合 Outbox/MQ | 继续由 MySQL + Worker 决定 |
|---|---|
| 新订单飞书通知、用户 Push、BI 埋点、客服提醒、SLS 审计副本。 | 订单是否有效、佣金金额、活动额度预占、pending→available、冻结/解冻、提现终态。 |
| 消费者失败可重试,重复通知可通过 event ID 去重。 | 必须在短事务内完成,并能通过业务事实和账本对账。 |
13. 对账与自愈
事务和幂等是第一道保障,对账是发现代码缺陷、人工改库、平台延迟与第三方未知状态的第二道保障。对账不能只生成报表,还要定义可自动修复和必须人工处理的边界。
| 对账任务 | 检查内容 | 自动动作 | 人工动作 |
|---|---|---|---|
| 钱包-账本 | 钱包当前余额与最后一条账本 after 值、账本 delta 累计是否一致。 | 只告警,不自动覆盖余额。 | 根据完整账本和业务来源审批修复。 |
| 佣金-pending | 有效 PENDING 佣金与钱包 pending/对应 ledger 是否成对。 | 幂等补写缺失账本或重新执行单记录事务。 | 金额冲突进入资金异常单。 |
| 活动预占 | 订单取消但 reservation 未释放;订单完成但未核销;过期 lease。 | 按订单终态做幂等释放/核销。 | 规则版本缺失时人工处理。 |
| 提现冻结 | PROCESSING/FUNDS_HELD 提现是否有等额 frozen;失败/拒绝是否已解冻。 | 明确本地状态可幂等补账。 | 第三方状态未知时禁止自动解冻。 |
| Xendit | 按 external ID 比对本地 payout 与 provider 状态、金额、收款账户。 | 确定终态后推进本地 CAS。 | 金额/收款人不一致立即冻结并告警。 |
| 平台订单缺口 | 按时间窗比对平台返回总数、最大更新时间、分页游标与本地入库数。 | 触发扩大 lookback 的补偿同步。 | 平台 API 缺失或权限异常联系平台。 |
user_wallets 改成某个计算值。14. 监控与告警
| 指标 | 建议观察方式 | 告警含义 |
|---|---|---|
platform_sync_lag_seconds | 平台最后成功同步时间与当前时间差,按平台/国家。 | 可能漏单或平台 API 故障。 |
oldest_pending_commission_age | 最老已到 release_at 但仍 PENDING 的年龄。 | 结算 Worker 卡住。 |
wallet_reconcile_mismatch | 钱包与账本不一致用户数/差额。 | 最高级资金告警,出现即阻断相关操作。 |
withdraw_frozen_age | 冻结超过 SLA 的提现数与金额。 | Xendit 状态未闭环或 Webhook 丢失。 |
payout_provider_unknown | 结果未知任务数量与最老年龄。 | 禁止盲目重试,需要主动查询。 |
worker_retry/dead_count | 按任务类型、错误码、平台统计。 | 系统性参数或权限问题。 |
outbox_oldest_pending_age | 最老未发布 Outbox 年龄。 | 资金已正确,但用户通知/BI 可能延迟。 |
飞书告警必须带:环境、国家数据中心、任务、平台、业务 ID、用户联盟码、request/trace ID、首次失败时间、重试次数和可执行的排查链接。不要只发“任务失败”。
15. 国家数据中心隔离
flowchart TB
subgraph ID["Indonesia Data Center"]
IDAPI["API / WA / Webhook"]
IDW["Worker"]
IDDB[("ID MySQL")]
IDX["ID 平台与 Xendit 账号"]
IDAPI --> IDDB
IDW --> IDDB
IDW --> IDX
end
subgraph MY["Malaysia Data Center"]
MYAPI["API / WA / Webhook"]
MYW["Worker"]
MYDB[("MY MySQL")]
MYX["MY 平台与支付账号"]
MYAPI --> MYDB
MYW --> MYDB
MYW --> MYX
end
- 印尼、马来等国家是完全不同的数据中心、数据库、Worker、平台凭证和支付账户,不存在跨国家数据库事务。
country_code只用于数据自描述、审计、备份与迁移检查,不意味着把多个国家业务混在一套数据库里运行。- 资金对账、游标、告警和幂等键均按本数据中心独立管理;跨国 BI 只能读取脱敏汇总,不参与资金写入。
16. 当前实现与目标差距
| 能力 | 当前真实状态 | 目标与动作 |
|---|---|---|
| 钱包并发 | 已具备:user_wallets FOR UPDATE,账本业务唯一键。 | 保留并补充统一锁顺序、死锁指标与并发测试。 |
| 佣金释放 | 已具备:每 5 分钟扫描、单记录事务、CAS 后 pending→available。 | 保留现状,补充 reconciliation 与 stale pending 告警。 |
| 订单事务边界 | 有缺口:订单 upsert、基础佣金、活动券处理由同步 Service 顺序调用,不是统一外层事务。 | 新增 FinancialOrderIngestService.processOne(),把单订单资金事实收口到一个事务。 |
| 活动额度预占 | 部分具备:已有 reservation、券行锁和退款释放逻辑。 | 纳入单订单事务,并对所有活动收益使用统一业务键与状态机。 |
| 退款与部分退款 | 已具备:PENDING 自动撤销;部分退款冻结;退款驳回恢复;AVAILABLE/PAID 创建追偿风险并阻断提现;120 天退款历史在 24 小时内复核一轮。 | 增加管理后台退款证据聚合和人工处理审计界面;不自动制造负钱包。 |
| 提现冻结/成功/失败 | 已具备:冻结、Webhook CAS、成功扣 frozen、失败解冻和资金不足重试。 | 保留业务判断。 |
| Xendit 调用边界 | 高风险缺口:自动提现/审批会在事务方法中直接调用 Xendit。 | 拆成冻结事务→事务外调用→结果事务,增加 provider unknown 查询状态。 |
| 通知副作用 | 部分具备:已有 Outbox,主要用于 Push 等路径。 | 订单通知、资金通知、BI 全部改为同事务写 Outbox,提交后投递。 |
| 对账 | 需完善:有监控和部分状态检查,但缺统一资金差异单与自愈边界。 | 增加钱包、佣金、活动、提现、Provider 五类对账任务和管理后台视图。 |
17. 分阶段改造
补齐资金状态机、业务幂等键清单、金额单位断言、数据库约束和并发自动化测试。
先锁规则建立 FinancialOrderIngestService,把订单、佣金、活动预占、pending 钱包和 Outbox 原子提交。
最高优先级增强 payout_transactions/task,移除事务内 Xendit 调用,补 provider unknown 主动查询。
资金高风险已完成自动撤销、冻结复核、活动额度顺延和提现阻断;后续补管理后台人工追偿审计。
核心已完成落地五类对账、差异单、人工受审计修复和资金告警看板。
生产运营只迁移通知和非资金副作用;资金推进仍保留 MySQL + Worker 轮询与对账兜底。
最后考虑18. 验收用例
- 同一平台订单重复回放 100 次,订单、佣金、活动预占和每种钱包 ledger 都只有一份。
- 两个 Worker 同时结算同一 commission,只有一个 CAS 成功,pending/available 只变化一次。
- 单订单事务在写 commission 后人为抛异常,订单资金事实、钱包和 Outbox 全部回滚,不留下半成品。
- 平台订单乱序回放 PENDING→COMPLETED→旧 PENDING,最终状态不能倒退。
- 活动额度只够一单时,两单并发下单只有先锁到权益的一单预占成功;退款后额度可顺延。
- 结算后退款:不自动扣 available、不形成 debt;只创建唯一追偿风险记录并阻断提现,重复回放不重复创建。
- 部分退款或正金额快照下降:订单进入 FROZEN,佣金停止释放;退款驳回后恢复。
- Shopee 稀疏回放金额为 0:保留原金额,不误判为部分退款。
- 历史退款追扫在 24 小时内覆盖最近 120 天,不因退款发生较晚而漏单。
- 提现申请接口超时重试,不会重复冻结。
- Xendit 已成功但 Worker 在保存响应前崩溃,重启后通过 external ID 查询恢复为 PROCESSING/PAID,不会再次付款。
- 重复、乱序 Webhook 不会重复扣 frozen 或重复解冻。
- 飞书/MQ 全部关闭时,订单、佣金、钱包、提现仍然正确;恢复后 Outbox 可以补发通知。
- Redis 锁主动过期、两个 Worker 重复扫描时,资金结果仍然正确。
- 钱包-账本、佣金-pending、提现-frozen 三类对账能发现人工制造的差异,并阻断危险自动修复。