返回文档导航

核心资金事实:MySQL 事务 + Worker 轮询技术方案

v1.0 · 2026-07-20 · 依据 beex-service 当前订单、佣金、钱包、优惠券、提现与 Worker 代码核对
本方案回答的不是“要不要上 MQ”,而是 BeeX 的钱到底由谁说了算。结论是:订单、佣金、活动额度、钱包余额、账本、提现状态都只以 MySQL 提交后的记录为准;Worker 负责持续发现和推进任务;Redis 锁、MQ、飞书、Push 只负责效率和通知,不具备决定资金结果的权力。

1. 决策与边界

唯一事实源

每个国家数据中心自己的 MySQL。任何资金结果必须能从业务事实表与不可变账本解释出来。

MySQL
推进者

独立 Worker 定时拉平台订单、扫描待结算佣金、推进提现与执行对账。

Worker
并发保护

唯一键、CAS、行锁和短事务负责正确性;Redis 分布式锁只减少重复扫描。

DB Guard
异步边界

Outbox/MQ 可承接通知、Push、BI 等副作用,但不能直接决定余额或提现成功。

No Money Decision
明确禁止:不能因为“消息已经发到 MQ”“飞书已通知”“Xendit 请求返回 200”就直接认定资金完成;也不能把 TikTok、Shopee、Lazada、Xendit、飞书等网络调用放在持有钱包行锁的数据库事务里。

2. 什么是“核心资金事实”

“资金事实”是能够改变用户应得金额、可用余额、冻结余额或 BeeX 对外付款义务的数据。它和页面展示、缓存、通知不是同一个层级。

事实类别典型记录为什么属于资金事实不能由谁单独决定
平台订单事实affiliate_orders订单金额、平台佣金、状态、下单时间决定后续返现与分佣。H5、本地缓存、MQ 消息
收益权利commission_records记录谁因哪一单、按哪个规则、应得到多少钱及何时可结算。前端估算值、飞书订单通知
活动额度coupon_reservations、用户券订单创建时预占,完成时核销,退款时释放或顺延。活动 Banner、页面文案
钱包当前态user_wallets提供 pending、available、frozen、risk hold 的可锁定余额快照。Redis 缓存、客户端余额
资金变动凭证wallet_ledger每一笔变化都记录来源、业务键、变化前后余额,可审计、可对账。任意覆盖式 UPDATE
提现义务withdraw_requestspayout_transactions记录用户申请、冻结、第三方付款、成功/失败与解冻。单次 HTTP 返回、人工截图
账本与余额的关系:wallet_ledger 是不可变审计轨迹,user_wallets 是同一事务内维护的可锁定余额投影。两者必须一起成功或一起回滚;不能只改余额不写账,也不能先写账后异步改余额。

3. 不可破坏的资金不变量

  1. 一笔业务只入账一次:所有资金动作都有稳定业务幂等键,数据库唯一约束是最后一道防线。
  2. 余额守恒:pending + available + frozen + riskHold 的每次变化必须与账本 delta 和 before/after 对得上。
  3. 状态只能按允许方向推进:通过 CAS(WHERE status = expected)更新,禁止无条件覆盖。
  4. 金额统一用整数 minor unit:数据库和服务端计算使用 BIGINT,不使用 float/double。按 BeeX 当前约定,展示前再由统一金额组件换算和向下取整。
  5. 规则按下单时间命中:佣金比例、平台佣金基数上限、活动规则以 ordered_at 命中生效版本并形成快照,之后修改配置不改变历史订单。
  6. 外部调用不占用资金事务:事务内只落事实和任务,事务外调用第三方,返回后再开事务确认结果。
  7. 允许重复执行,不允许重复生效:Worker、Webhook 和 MQ 都按至少一次处理设计,不假设“恰好执行一次”。
  8. 通知不阻塞资金提交:飞书、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. 平台订单入库:一个订单一个事务

目标:同一平台订单的“订单事实、归因、基础返佣、活动额度预占、pending 钱包、Outbox”一起提交。现在各平台同步服务是依次调用多个 Service,并不全部处于同一个外层事务,这是第一优先级改造项。
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 事务内完成

  1. (platform, platform_order_id) upsert 订单;比较 platform_updated_at 和允许状态矩阵,旧消息不能覆盖新状态。
  2. 根据 tracking/click/referral 解析归属用户;归因补正必须保留变更审计。
  3. ordered_at 命中佣金规则版本,计算平台佣金基数 M = min(platformCommission, GMV × maxCommissionBaseBps),当前上限由配置版本控制。
  4. 按 User 直邀关系生成用户返现和邀请人奖励。
  5. 如有返佣基金等活动,按订单时序预占额度,防止多个未完成订单重复占用同一权益。
  6. commission_records=PENDING;若该平台状态已明确无效,则只保留审计记录,不增加 pending。
  7. 锁用户钱包,增加 pending,并写唯一账本。
  8. 写订单/收益变更 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 表示已处理或已失效。
为什么逐条事务:一条脏数据、死锁或余额异常只回滚这一条,不拖住整批;Worker 下一轮可以继续重试。批量查询可以大,但事务必须小。

8. 取消、退款与追偿

退款要根据佣金当前所在阶段采用不同动作,不能一律删除记录,也不能只改订单状态。详细状态与平台映射见《联盟订单退货、退款与收益撤销闭环》

当前资金阶段事务动作用户可见结果
佣金仍为 PENDINGCAS 为 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:申请与冻结

  1. 校验支付密码、KYC、收款账户、最低/最高金额、每日次数/金额和风控。
  2. 锁定 user_wallets,执行 available -= amountfrozen += amount
  3. WITHDRAW_FREEZE 账本。
  4. 创建 withdraw_requests 和一条 READY/PENDING 的持久化 payout task。
  5. 同事务提交;此时钱没有付给用户,但已不能被再次提现。

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 -= amountavailable += 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 平台订单同步

  1. 每个平台、国家和任务使用独立 job key;当前 TikTok、Shopee、Lazada、Traveloka 等默认约 15 分钟轮询。
  2. Redis 分布式锁避免多个 Worker 同时扫同一平台,但锁失效后即使重复执行,MySQL 幂等仍必须保证正确。
  3. 使用 watermark + 重叠时间窗。每次从“上次成功时间减去一段 overlap”开始拉,接住平台延迟回传和状态补发。
  4. 按页拉取;每个订单独立提交;一页处理完再保存 page checkpoint。进程中途退出最多重放这一页。
  5. 定期执行更大 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;
区分“扫描锁”和“资金锁”:分布式锁/lease 解决谁来做工作;唯一键、CAS、钱包行锁解决做两次会不会多加钱。前者失效只能多做一次,不能导致账错。

11. 幂等、并发与锁

动作稳定幂等键数据库保护
平台订单入库platform + platform_order_id订单唯一键 + 单调状态更新
一项订单收益order_id + benefit_type + beneficiary_user_id确定性 commission ID / 唯一键
pending 入账COMMISSION_RECORD + commission_id + COMMISSION_PENDINGuk_ledger_ref
活动预占campaign/coupon + order_id + user_idreservation 唯一键 + 券行锁
提现申请客户端 request ID 或 withdraw_id提现唯一键 + 钱包行锁
Xendit 付款withdraw_id本地 payout 唯一键 + Xendit idempotency key
Webhookprovider event ID / provider reference + statusWebhook event 唯一键 + 状态 CAS
Outbox 事件aggregate + version + event_typeevent_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 去重。必须在短事务内完成,并能通过业务事实和账本对账。
Outbox 的真实语义仍是至少一次:Relay 可能“已发布到 MQ,但还没来得及把 Outbox 标成 PUBLISHED 就崩溃”,下一轮会再发一次。因此每个消费者仍需按 event ID 或业务键去重。

13. 对账与自愈

事务和幂等是第一道保障,对账是发现代码缺陷、人工改库、平台延迟与第三方未知状态的第二道保障。对账不能只生成报表,还要定义可自动修复和必须人工处理的边界。

对账任务检查内容自动动作人工动作
钱包-账本钱包当前余额与最后一条账本 after 值、账本 delta 累计是否一致。只告警,不自动覆盖余额。根据完整账本和业务来源审批修复。
佣金-pending有效 PENDING 佣金与钱包 pending/对应 ledger 是否成对。幂等补写缺失账本或重新执行单记录事务。金额冲突进入资金异常单。
活动预占订单取消但 reservation 未释放;订单完成但未核销;过期 lease。按订单终态做幂等释放/核销。规则版本缺失时人工处理。
提现冻结PROCESSING/FUNDS_HELD 提现是否有等额 frozen;失败/拒绝是否已解冻。明确本地状态可幂等补账。第三方状态未知时禁止自动解冻。
Xendit按 external ID 比对本地 payout 与 provider 状态、金额、收款账户。确定终态后推进本地 CAS。金额/收款人不一致立即冻结并告警。
平台订单缺口按时间窗比对平台返回总数、最大更新时间、分页游标与本地入库数。触发扩大 lookback 的补偿同步。平台 API 缺失或权限异常联系平台。
禁止“自动修余额”:对账发现余额不一致时先冻结相关提现能力、产出差异明细和 trace,再由受审计的修复指令写一条 MANUAL_ADJUST 账本;不能直接把 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. 分阶段改造

Phase 0 · 固化不变量

补齐资金状态机、业务幂等键清单、金额单位断言、数据库约束和并发自动化测试。

先锁规则
Phase 1 · 单订单事务

建立 FinancialOrderIngestService,把订单、佣金、活动预占、pending 钱包和 Outbox 原子提交。

最高优先级
Phase 2 · 提现三段式

增强 payout_transactions/task,移除事务内 Xendit 调用,补 provider unknown 主动查询。

资金高风险
Phase 3 · 退款追偿

已完成自动撤销、冻结复核、活动额度顺延和提现阻断;后续补管理后台人工追偿审计。

核心已完成
Phase 4 · 对账与后台

落地五类对账、差异单、人工受审计修复和资金告警看板。

生产运营
Phase 5 · 可选 MQ

只迁移通知和非资金副作用;资金推进仍保留 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 三类对账能发现人工制造的差异,并阻断危险自动修复。
上线门槛:Phase 1 和 Phase 2 必须先在 id-test 通过并发、重复、乱序、崩溃恢复和第三方超时测试;生产发布必须使用同一个 commit,不允许把测试环境验证过的逻辑重新手改后再上线。