后端开发

会员多资产预扣支付技术设计方案

面向会员钱包、充值余额、赠送余额、优惠券和代币的多资产组合支付设计,覆盖三段式预扣、原路退款、并发控制、状态机、对账与落地实现。

会员多资产预扣支付技术设计方案

版本:v2.0
状态:设计评审稿
更新时间:2026-07-10
适用范围:会员钱包、充值余额、赠送余额、优惠券、代币等内部资产组合支付。


0. 设计结论

本方案采用“多资产库存化管理 + 三段式预扣支付”:

  1. 校验试算:根据订单金额、资产优先级、资产规则、有效期和 FIFO 批次规则,计算本次可用资产组合。
  2. 冻结预扣:把本次资产占用写入 order / order_detail,同时把钱包、批次、券、代币从可用态转入冻结态。
  3. 确认扣减或释放:用户确认支付后冻结转实扣;取消、超时、失败时按冻结明细释放。

核心取舍:

  • 使用 order_detail 承载冻结明细,而不是额外引入冻结单主子表。订单支付链路更短,冻结生命周期和订单生命周期天然绑定。
  • 使用 payment_txn_detail 承载最终扣减明细,作为退款“原路返回”的唯一依据。
  • 使用 wallet_ledger 做不可变流水审计,所有余额变化必须留痕,便于对账、追溯和异常修复。
  • 对同一会员资产扣减做会员级串行化锁,并辅以数据库条件更新和幂等键,避免超扣、重复扣减和重复退款。

IP配图:预扣边界


1. 背景与需求

1.1 业务背景

会员支付不是单一现金支付,而是多个内部资产共同承担一笔订单:

  • 充值余额:用户实际充值形成的可退资产,需要支持按充值批次、有效期、FIFO 消耗。
  • 赠送余额:营销赠送形成的权益资产,通常有有效期、消耗优先级和退款限制。
  • 优惠券:满减券、代金券或权益券,存在门槛、有效期、整券锁定、不返还等规则。
  • 代币:按一定汇率折算订单金额,可能存在小数精度和单独账户。
  • 现金或第三方支付:内部资产不足时可作为补差支付方式,本文只定义内部资产侧和交易明细衔接。

订单支付链路中,用户提交订单后可能还要输入支付密码、等待风控校验、等待收银终端确认或等待外部支付补差。如果在最终确认前直接扣减资产,一旦支付中断就需要复杂回滚;如果不提前占用资产,并发订单又可能同时看到同一份余额,造成超卖式超扣。因此需要一个清晰的预扣层。

1.2 业务目标

目标说明
多资产组合支付同一订单可由优惠券、赠送金、充值金、代币、现金等组合完成支付。
批次库存化扣减充值/赠送按批次入库,扣减时遵守“有效期优先 + 同有效期先充先消”。
三段式预扣支持“校验 -> 冻结 -> 确认扣减/释放”,避免支付确认前资产被重复使用。
原路退款退款按原支付明细返还到对应资产,优惠券是否返还由配置决定。
金额安全防超扣、防重复支付、防重复退款、防多退少退。
可审计可对账所有资产变动有明细、有流水、有状态,可按日对账和追溯。

1.3 非目标

本设计不覆盖以下内容,但会预留衔接点:

  1. 第三方支付网关的具体接入,如微信、支付宝、银行卡收单。
  2. 复杂营销规则引擎的规则编辑后台。
  3. 会计总账系统的复式记账凭证生成。
  4. 跨店、跨租户、跨法人资金清结算。
  5. 监管资金存管和备付金合规流程。

2. 行业难点与常见解决方案

2.1 行业难点

难点一:支付不是一次数据库扣款,而是一个生命周期。

真实支付会经历授权、确认、撤销、退款、部分退款、失败重试、超时等状态。内部会员资产支付如果只做“余额减法”,后续很难解释订单卡在中间态时资产到底归谁。

难点二:资产规则不一致。

充值余额强调可退和批次有效期;赠送金强调营销成本和过期;优惠券强调门槛、整券锁定和是否返还;代币强调汇率与精度。多个资产直接混在一个余额字段里会导致规则无法落地。

难点三:并发下的“看见可用”不等于“能扣到”。

同一会员可能同时发起多个订单、补单、退款或客服调整。只在接口层校验余额,不在数据库层做条件更新,会出现两个请求同时通过校验并超扣。

难点四:退款必须回答“退到哪里”。

如果支付成功后没有保存每一分钱来自哪个资产、哪个批次、哪张券,退款时只能按比例猜,极易产生财务争议。

难点五:重试和消息补偿会天然制造重复请求。

客户端超时、网关回调重试、任务补偿、人工重试都会触发相同业务动作。没有幂等模型时,系统越“自愈”,越容易重复扣款或重复退款。

2.2 行业方案映射

主流支付平台普遍采用授权与捕获模型。Stripe PaymentIntent 支持先授权、后捕获,手动捕获可用于临时保留资金;Adyen 支持 Authorised 后 Capture 或 Cancel;PayPal Orders/Payments API 也区分 authorize、capture、void、refund。内部会员资产系统可以借鉴这一生命周期,但资产对象从银行卡额度变成了钱包批次、券和代币账户。

行业概念外部支付含义本方案内部映射
Authorize / Hold先占用支付能力,不立即入账给商户冻结会员资产,写入 order_detail
Capture确认收款,资金正式结算冻结转实扣,生成 payment_txn_detail
Void / Cancel未捕获前撤销授权释放冻结,订单取消或超时
Refund已捕获后退回资金根据 payment_txn_detail 原路返还
Idempotency Key重试时避免重复创建或重复扣款下单、冻结、确认、退款接口都保存幂等键
Ledger记录每次资金变化wallet_ledger 记录所有资产变动

本方案不是照搬外部支付网关,而是把成熟的支付生命周期抽象下沉到内部资产系统:订单支付前先占用,确认后再实扣,失败则释放,成功后退款按原交易明细反向处理


3. 领域术语

术语定义
资产会员可用于抵扣订单金额的权益,如充值余额、赠送余额、优惠券、代币。
资产批次一次充值、赠送、退款或调整形成的库存批次,包含金额、剩余、冻结、有效期。
试算不修改资产,只根据当前快照计算本单应使用哪些资产。
冻结将资产从可用转为冻结,表示该资产已被订单占用但尚未确认扣减。
确认扣减用户支付确认后,将冻结资产转为已消费。
释放订单取消、支付失败或超时后,将冻结资产退回可用。
原路返回退款按原支付明细返回到同一资产引用,如同一批次、同一优惠券、同一代币账户。
幂等键表示一次业务动作的唯一请求键,同一键重复请求只能产生一次业务效果。

4. 总体架构

4.1 分层职责

@startuml
title 会员多资产预扣支付总体架构
skinparam componentStyle rectangle
skinparam shadowing false

actor "会员/收银端" as User

rectangle "订单域" as OrderDomain {
  component "订单服务\nOrder Service" as OrderSvc
  component "订单状态机\nOrder FSM" as OrderFSM
}

rectangle "支付域" as PaymentDomain {
  component "支付编排服务\nPayment Orchestrator" as PaySvc
  component "交易服务\nPayment Txn" as TxnSvc
  component "退款服务\nRefund Service" as RefundSvc
}

rectangle "资产域" as AssetDomain {
  component "资产路由器\nAsset Router" as Router
  component "钱包服务\nWallet Service" as WalletSvc
  component "券服务\nCoupon Service" as CouponSvc
  component "代币服务\nToken Service" as TokenSvc
  component "流水服务\nLedger Service" as LedgerSvc
}

database "MySQL\n订单/交易/资产/流水" as DB
queue "MQ\n超时/补偿/对账事件" as MQ
cloud "Redis\n锁/幂等/冻结索引" as Redis

User --> OrderSvc : 提交订单/确认支付/取消/退款
OrderSvc --> PaySvc : 发起支付动作
PaySvc --> Router : 试算资产组合
PaySvc --> WalletSvc : 冻结/扣减/释放余额批次
PaySvc --> CouponSvc : 锁券/核销/返还
PaySvc --> TokenSvc : 冻结/扣减/释放代币
PaySvc --> TxnSvc : 生成交易与交易明细
RefundSvc --> TxnSvc : 查询原支付明细
WalletSvc --> LedgerSvc
CouponSvc --> LedgerSvc
TokenSvc --> LedgerSvc

OrderSvc --> DB
PaySvc --> Redis
PaySvc --> DB
RefundSvc --> DB
LedgerSvc --> DB
MQ --> PaySvc : 超时释放/补偿重试
MQ --> RefundSvc : 退款补偿
@enduml

4.2 关键设计原则

  1. 先路由,后冻结:试算阶段只计算,不修改资产;冻结阶段必须在事务内重新校验,避免快照过期。
  2. 冻结明细即订单明细:本单占用了哪些资产,直接沉淀在 order_detail,订单取消或确认都按这份明细执行。
  3. 交易明细即退款依据:只有确认扣减后才生成 payment_txn_detail,退款不再重新跑资产路由。
  4. 流水不可变:资产变更只追加流水,不覆盖历史。余额表是快照,流水表是审计事实。
  5. 状态单向流转:冻结、支付、退款都有明确状态机,不允许随意回退。
  6. 业务幂等优先于技术重试:客户端、MQ、定时任务、人工补偿都可能重试,接口必须天然幂等。

5. 核心业务流程

5.1 主流程:校验、冻结、确认、释放

@startuml
title 会员多资产预扣支付主流程
start
:提交订单;
:生成业务幂等键;
:资产试算\n优惠券/赠送金/充值金/代币;

if (资产是否足够?) then (否)
  :返回余额或权益不足;
  stop
else (是)
  :获取会员资产锁;
  :事务内重新读取资产;
  :按路由明细冻结资产;
  :写入 order_detail 冻结项;
  :order.freeze_status = FROZEN;
  :等待用户确认;
endif

if (用户确认支付?) then (是)
  :校验支付密码/风控;
  :冻结转实扣;
  :生成 payment_txn;
  :生成 payment_txn_detail;
  :写 wallet_ledger;
  :order.pay_status = SUCCESS;
  stop
else (取消/超时/失败)
  :按 order_detail 释放冻结;
  :写释放流水;
  :order.freeze_status = RELEASED/EXPIRED;
  stop
endif
@enduml

5.2 支付三段式时序图

@startuml
title 三段式预扣支付时序图
actor User as "会员"
participant OrderSvc as "订单服务"
participant PaySvc as "支付编排服务"
participant AssetRouter as "资产路由器"
participant AssetSvc as "资产服务"
participant Redis as "Redis"
database DB as "MySQL"
participant Scheduler as "超时任务"

User -> OrderSvc : 提交订单(order_no, amount)
OrderSvc -> PaySvc : prepay(order_no, customer_id, amount)
PaySvc -> AssetRouter : 试算资产组合
AssetRouter -> DB : 查询资产快照/规则快照
DB --> AssetRouter : 可用资产候选
AssetRouter --> PaySvc : allocation_plan

alt 资产不足
  PaySvc --> OrderSvc : PREPAY_INSUFFICIENT
  OrderSvc --> User : 余额或权益不足
else 资产足够
  PaySvc -> Redis : lock asset:{customer_id}
  PaySvc -> DB : begin transaction
  PaySvc -> DB : 条件更新钱包/批次/券/代币为冻结
  PaySvc -> DB : 写 order_detail 冻结明细
  PaySvc -> DB : 更新 order.freeze_status=FROZEN
  PaySvc -> DB : commit
  PaySvc -> Redis : 写 freeze zset(order_no, expire_at)
  PaySvc --> OrderSvc : 冻结成功
  OrderSvc --> User : 等待支付确认

  User -> OrderSvc : 输入支付密码确认
  OrderSvc -> PaySvc : capture(order_no)
  PaySvc -> Redis : lock asset:{customer_id}
  PaySvc -> DB : begin transaction
  PaySvc -> DB : 冻结转扣减
  PaySvc -> DB : 生成 payment_txn/payment_txn_detail
  PaySvc -> DB : 写 wallet_ledger
  PaySvc -> DB : 更新订单支付成功
  PaySvc -> DB : commit
  PaySvc -> Redis : 删除 freeze zset
  PaySvc --> OrderSvc : 支付成功
  OrderSvc --> User : 支付成功
end

Scheduler -> Redis : 扫描到期冻结
Scheduler -> PaySvc : release(order_no, reason=TIMEOUT)
PaySvc -> DB : 幂等释放冻结
@enduml

5.3 多资产路由流程

IP配图:多资产路由

@startuml
title 多资产扣减路由
start
:输入订单应付金额;
:加载支付策略\n资产优先级/是否可混付/券规则;

partition "优惠券" {
  :筛选可用券\n状态、有效期、门槛、适用范围;
  :按 priority_no / valid_to 排序;
  :整券锁定或面额分摊;
}

partition "余额批次" {
  :查询 ACTIVE 批次;
  :排序 expire_at ASC, created_at ASC, id ASC;
  :逐批分配扣减金额;
}

partition "代币" {
  :按汇率折算可抵扣金额;
  :处理小数精度和最小扣减单位;
}

:汇总 allocation_plan;
if (已覆盖订单金额?) then (是)
  :返回可冻结明细;
else (否)
  :计算现金补差或返回不足;
endif
stop
@enduml

6. 状态机设计

6.1 订单支付状态机

@startuml
title 订单冻结与支付状态机
[*] --> INIT : 创建订单
INIT --> PRECHECKED : 资产试算通过
INIT --> CLOSED : 资产不足/创建失败

PRECHECKED --> FROZEN : 冻结成功
PRECHECKED --> CLOSED : 冻结失败

FROZEN --> PAYING : 用户确认/密码校验中
FROZEN --> RELEASED : 用户取消
FROZEN --> EXPIRED : 冻结超时

PAYING --> SUCCESS : 冻结转实扣成功
PAYING --> RELEASED : 支付确认失败且可释放
PAYING --> MANUAL_REVIEW : 事务结果未知

SUCCESS --> PART_REFUNDED : 部分退款成功
SUCCESS --> REFUNDED : 全额退款成功
PART_REFUNDED --> REFUNDED : 剩余金额退完

RELEASED --> [*]
EXPIRED --> [*]
CLOSED --> [*]
REFUNDED --> [*]
MANUAL_REVIEW --> SUCCESS : 补偿确认成功
MANUAL_REVIEW --> RELEASED : 补偿确认未扣减
@enduml

6.2 单条资产明细状态机

@startuml
title order_detail 资产明细状态机
[*] --> CALCULATED : 试算生成
CALCULATED --> FROZEN : 写入订单明细并冻结资产
FROZEN --> CONFIRMED : 确认扣减
FROZEN --> RELEASED : 取消/超时/失败释放
CONFIRMED --> REFUNDING : 发起退款
REFUNDING --> REFUNDED : 退款完成
REFUNDING --> REFUND_FAILED : 退款失败待补偿
REFUND_FAILED --> REFUNDING : 补偿重试
RELEASED --> [*]
REFUNDED --> [*]
@enduml

6.3 退款状态机

@startuml
title 退款交易状态机
[*] --> INIT : 创建退款单
INIT --> PROCESSING : 校验原交易/计算可退
PROCESSING --> SUCCESS : 资产原路回补成功
PROCESSING --> PART_SUCCESS : 部分资产回补成功
PROCESSING --> FAILED : 全部失败
PART_SUCCESS --> PROCESSING : 补偿剩余明细
FAILED --> PROCESSING : 幂等重试
SUCCESS --> [*]
@enduml

7. 领域模型与数据模型

7.1 PlantUML 领域模型图

@startuml
title 会员多资产预扣支付领域模型
hide circle
skinparam linetype ortho
skinparam classAttributeIconSize 0

class Customer {
  +id
  +memberNo
  +status
}

class Wallet {
  +customerId
  +assetType
  +availableAmt
  +frozenAmt
  +version
}

class WalletBatch {
  +assetType
  +sourceType
  +remainAmt
  +frozenAmt
  +expireAt
  +status
}

class Coupon {
  +couponCode
  +remainValue
  +canRefundReturn
  +validTo
  +status
}

class TokenAccount {
  +availableToken
  +frozenToken
  +version
}

class Order {
  +orderNo
  +orderAmt
  +freezeStatus
  +payStatus
  +freezeExpireAt
}

class OrderDetail {
  +lineType
  +assetType
  +assetRefId
  +frozenAmt
  +consumedAmt
  +releasedAmt
  +detailStatus
}

class PaymentTxn {
  +txnNo
  +bizType
  +totalAmt
  +status
  +idempotencyKey
}

class PaymentTxnDetail {
  +assetType
  +assetRefId
  +amount
  +orderDetailId
}

class RefundTxn {
  +refundNo
  +refundAmt
  +status
  +idempotencyKey
}

class RefundTxnDetail {
  +payTxnDetailId
  +refundAmt
  +returnPath
}

class WalletLedger {
  +assetType
  +changeType
  +changeAmt
  +refType
  +refId
}

Customer "1" --> "many" Wallet
Customer "1" --> "many" WalletBatch
Customer "1" --> "many" Coupon
Customer "1" --> "1" TokenAccount
Customer "1" --> "many" Order

Order "1" --> "many" OrderDetail
Order "1" --> "0..1" PaymentTxn
PaymentTxn "1" --> "many" PaymentTxnDetail
OrderDetail "1" --> "0..1" PaymentTxnDetail
PaymentTxn "1" --> "many" RefundTxn
RefundTxn "1" --> "many" RefundTxnDetail
PaymentTxnDetail "1" --> "many" RefundTxnDetail
WalletLedger --> Order
WalletLedger --> PaymentTxn
WalletLedger --> RefundTxn
@enduml

7.2 核心表职责

职责设计要点
customer会员主体只承载会员身份与状态,不放资产金额。
wallet按会员、资产类型聚合余额查询快照使用;通过 version 和条件更新防并发超扣。
wallet_batch余额类资产批次库存支持有效期优先、FIFO、退款回原批次。
coupon优惠券权益库存支持整券锁定、核销、返还、不返还。
token_account代币账户聚合代币精度独立于金额精度,支付明细保存折算金额。
order业务订单和冻结状态承载冻结总额、冻结状态、支付状态、超时时间。
order_detail商品行与资产冻结行line_type=ASSET_ALLOC 时表示冻结明细。
payment_txn支付交易主单确认扣减后生成,记录交易状态和幂等键。
payment_txn_detail支付交易明细记录每种资产的实扣来源,是退款依据。
wallet_ledger资产流水所有冻结、释放、扣减、退款、调整都必须写入。
refund_txn退款主单记录退款请求、金额、状态和幂等键。
refund_txn_detail退款明细对应原支付明细,记录是否原路返回或不返还。
idempotency_record幂等记录建议独立建表,保存请求键、请求摘要、响应摘要和处理状态。

7.3 ER 模型图

@startuml
title 核心 ER 模型
hide circle
skinparam linetype ortho

entity "customer\n会员" as customer {
  *id : bigint <<PK>>
  --
  member_no : varchar
  status : varchar
}

entity "wallet\n钱包汇总" as wallet {
  *id : bigint <<PK>>
  --
  customer_id : bigint <<FK>>
  asset_type : varchar
  available_amt : decimal(18,2)
  frozen_amt : decimal(18,2)
  version : int
}

entity "wallet_batch\n余额批次" as batch {
  *id : bigint <<PK>>
  --
  customer_id : bigint <<FK>>
  asset_type : varchar
  source_type : varchar
  source_id : bigint
  total_amt : decimal(18,2)
  remain_amt : decimal(18,2)
  frozen_amt : decimal(18,2)
  expire_at : datetime
  status : varchar
}

entity "coupon\n优惠券" as coupon {
  *id : bigint <<PK>>
  --
  customer_id : bigint <<FK>>
  coupon_code : varchar
  remain_value : decimal(18,2)
  can_refund_return : tinyint
  valid_to : datetime
  status : varchar
}

entity "token_account\n代币账户" as token {
  *id : bigint <<PK>>
  --
  customer_id : bigint <<FK>>
  available_token : decimal(18,4)
  frozen_token : decimal(18,4)
  version : int
}

entity "order\n订单" as ord {
  *id : bigint <<PK>>
  --
  order_no : varchar
  customer_id : bigint <<FK>>
  order_amt : decimal(18,2)
  freeze_amt : decimal(18,2)
  freeze_status : varchar
  pay_status : varchar
  freeze_expire_at : datetime
}

entity "order_detail\n订单明细/冻结明细" as od {
  *id : bigint <<PK>>
  --
  order_id : bigint <<FK>>
  line_type : varchar
  asset_type : varchar
  asset_ref_id : bigint
  frozen_amt : decimal(18,2)
  consumed_amt : decimal(18,2)
  released_amt : decimal(18,2)
  detail_status : varchar
}

entity "payment_txn\n支付交易" as pay {
  *id : bigint <<PK>>
  --
  txn_no : varchar
  biz_order_no : varchar
  customer_id : bigint <<FK>>
  total_amt : decimal(18,2)
  status : varchar
  idempotency_key : varchar
}

entity "payment_txn_detail\n支付明细" as payd {
  *id : bigint <<PK>>
  --
  pay_txn_id : bigint <<FK>>
  order_detail_id : bigint <<FK>>
  asset_type : varchar
  asset_ref_id : bigint
  amount : decimal(18,2)
}

entity "refund_txn\n退款交易" as refund {
  *id : bigint <<PK>>
  --
  refund_no : varchar
  pay_txn_id : bigint <<FK>>
  refund_amt : decimal(18,2)
  status : varchar
  idempotency_key : varchar
}

entity "refund_txn_detail\n退款明细" as refundd {
  *id : bigint <<PK>>
  --
  refund_txn_id : bigint <<FK>>
  pay_txn_detail_id : bigint <<FK>>
  asset_type : varchar
  asset_ref_id : bigint
  refund_amt : decimal(18,2)
  return_path : varchar
}

entity "wallet_ledger\n资产流水" as ledger {
  *id : bigint <<PK>>
  --
  customer_id : bigint <<FK>>
  asset_type : varchar
  change_type : varchar
  change_amt : decimal(18,2)
  ref_type : varchar
  ref_id : bigint
}

customer ||--o{ wallet
customer ||--o{ batch
customer ||--o{ coupon
customer ||--|| token
customer ||--o{ ord
ord ||--o{ od
ord ||--o{ pay
pay ||--o{ payd
od ||--o{ payd
pay ||--o{ refund
refund ||--o{ refundd
payd ||--o{ refundd
customer ||--o{ ledger
@enduml

7.4 关键字段建议

order

字段类型说明
order_novarchar(64)全局唯一订单号。
customer_idbigint会员 ID。
order_amtdecimal(18,2)订单应付金额。
freeze_amtdecimal(18,2)当前冻结金额。
payable_amtdecimal(18,2)内部资产抵扣后仍需现金补差的金额。
freeze_statusvarchar(32)NONE/FROZEN/CONFIRMED/RELEASED/EXPIRED
pay_statusvarchar(32)INIT/PAYING/SUCCESS/CANCELLED/PART_REFUNDED/REFUNDED
freeze_expire_atdatetime冻结失效时间。
versionint订单状态机乐观锁。

order_detail

字段类型说明
line_typevarchar(32)ITEM 商品行;ASSET_ALLOC 资产冻结行。
asset_typevarchar(32)COUPON/RECHARGE/GIFT/TOKEN/CASH
asset_ref_idbigint批次 ID、券 ID、代币账户 ID。
rule_snapshotjson/text使用规则快照,避免后续规则变更影响历史订单。
frozen_amtdecimal(18,2)冻结金额。优惠券整券锁定时仍记录分摊金额。
consumed_amtdecimal(18,2)确认实扣金额。
released_amtdecimal(18,2)已释放金额。
sequence_noint扣减顺序。
detail_statusvarchar(32)FROZEN/CONFIRMED/RELEASED/REFUNDED

payment_txn_detail

字段类型说明
pay_txn_idbigint支付交易主单。
order_detail_idbigint对应资产冻结明细。
asset_typevarchar(32)支付资产类型。
asset_ref_idbigint原资产引用。
amountdecimal(18,2)本资产承担的订单金额。
asset_quantitydecimal(18,4)代币等非金额资产的原始数量,可选。
refund_available_amtdecimal(18,2)剩余可退金额,或通过退款明细聚合计算。

8. 为什么把冻结信息并入 order / order_detail

8.1 备选方案对比

方案说明优点缺点结论
直接扣减,失败回滚下单时直接扣余额,失败再补回实现最简单中间态难解释,失败补偿复杂,用户体验差不建议
独立冻结单主子表asset_freeze_order + freeze_detail 单独管理冻结适合跨业务、长周期、多订单合并占用表更多,状态同步复杂,订单链路查询成本高仅在冻结跨订单时使用
冻结并入订单明细order_detail 记录冻结资产行订单支付语义集中,确认/释放都按订单明细处理订单明细承载职责更重,需要清晰 line_type本方案采用

8.2 采用该方案的原因

  1. 冻结天然依附订单:本场景下冻结不会脱离订单独立存在,没有必要抽成独立聚合根。
  2. 释放和确认都以订单为入口:取消订单、支付超时、确认扣减都可以直接按 order_detail 执行。
  3. 减少双写状态:独立冻结单会产生订单状态、冻结单状态、交易状态三处一致性问题。
  4. 审计链条更短:订单明细 -> 支付明细 -> 退款明细是一条清晰链路。

8.3 必须增加的约束

  • order_detail.line_type 必须区分商品行和资产行,避免后续统计混淆。
  • 资产冻结行必须有 asset_type + asset_ref_id + sequence_no
  • 冻结行一旦确认扣减,不允许物理删除。
  • 取消或超时释放时,只能处理 detail_status=FROZEN 的明细,保证接口幂等。
  • 如果未来出现“一个冻结跨多个订单”“先冻结后选订单”“长周期订金占用”等场景,应重新评估独立冻结单。

9. 资产路由与扣减规则

9.1 推荐默认优先级

默认扣减顺序建议为:

优惠券 > 赠送余额 > 充值余额 > 代币 > 现金补差

原因:

  1. 优惠券通常有最短有效期和营销核销诉求,应优先使用。
  2. 赠送余额通常不可提现或退款限制更多,优先消耗能降低后续营销负债。
  3. 充值余额是用户实付资金,规则更敏感,应在赠送余额之后使用。
  4. 代币可能存在汇率波动或精度问题,建议单独配置是否参与自动抵扣。
  5. 现金补差只处理内部资产不足部分。

具体优先级必须配置化,订单侧只读取策略快照,不硬编码业务规则。

9.2 余额批次扣减规则

余额批次排序:

ORDER BY expire_at ASC, created_at ASC, id ASC

扣减口径:

  • 优先使用有效期更早的批次,降低过期损失。
  • 同一有效期内先入先出,符合会员“先充先用”的直觉。
  • 只允许扣减 status=ACTIVEremain_amt > 0 的批次。
  • 试算和冻结都使用同一排序,但冻结阶段必须重新读取并条件更新。

9.3 优惠券规则

优惠券需要保存规则快照:

  • 使用门槛:订单金额、商品范围、门店范围、会员等级。
  • 抵扣方式:满减、固定金额、比例折扣。
  • 是否整券锁定:大多数券建议整券锁定,防止同一张券被多个订单并发使用。
  • 是否退款返还:通过 can_refund_return 控制。
  • 退款后状态:可配置为返还原券、生成补偿券、不返还并记营销损益。

9.4 代币规则

代币建议和金额分开表达:

  • asset_quantity 保存代币数量,精度建议 decimal(18,4) 或更高。
  • amount 保存本次折算后的订单金额,精度统一 decimal(18,2)
  • 汇率必须写入 rule_snapshot,避免后续汇率变化影响退款。
  • 代币退款应按原始数量返还,而不是按退款时汇率重新折算。

9.5 路由伪代码

函数 allocateAssets(orderAmount, customerAssets, strategy):
  # 为什么先生成计划而不是直接扣减:
  # 试算可在无锁或短锁场景执行,降低主交易占锁时间。
  remain = orderAmount
  allocation = []

  for assetType in strategy.priorityList:
    candidates = loadCandidates(assetType)

    # 为什么要稳定排序:
    # 同一批数据在重试时必须得到相同分配结果,便于幂等和排查。
    sortedCandidates = sortByRule(candidates)

    for candidate in sortedCandidates:
      usable = min(candidate.availableValue, remain)
      if usable <= 0:
        continue

      allocation.add(candidate, usable)
      remain = remain - usable

      if remain == 0:
        return allocation

  if strategy.allowCashSupplement:
    allocation.add(CASH, remain)
    return allocation

  throw INSUFFICIENT_ASSET

10. 事务与并发控制

IP配图:并发风险

10.1 并发控制分层

层级措施解决问题
API 层idempotency_key防止客户端、网关、任务重复提交。
Redis 层lock:asset:{customerId}串行化同一会员资产扣减。
DB 层条件更新 + 乐观锁即使锁失效,也不能超扣。
状态机层单向流转防止已释放再扣、已成功再释放。
流水层变更留痕出问题后可以追溯和修复。

10.2 冻结事务边界

冻结阶段必须在一个本地事务中完成:

  1. 校验订单状态为可冻结。
  2. 重新读取资产候选,验证路由计划仍可执行。
  3. 条件更新余额批次、券、代币账户。
  4. 写入 order_detail 资产冻结行。
  5. 更新 order.freeze_status=FROZENfreeze_expire_at
  6. 写入冻结流水 wallet_ledger(change_type=FREEZE)

示例条件更新:

UPDATE wallet_batch
SET remain_amt = remain_amt - :freeze_amt,
    frozen_amt = frozen_amt + :freeze_amt,
    version = version + 1
WHERE id = :batch_id
  AND status = 'ACTIVE'
  AND remain_amt >= :freeze_amt
  AND version = :version;

如果影响行数为 0,说明资产已变化,必须回滚并重新试算。

10.3 确认扣减事务边界

确认阶段必须在一个本地事务中完成:

  1. 校验订单为 FROZEN/PAYING,且冻结未释放。
  2. 查询所有 order_detail.detail_status=FROZEN 的资产行。
  3. 将冻结金额转为已消费金额。
  4. 更新资产表冻结余额,如 frozen_amt = frozen_amt - consumed_amt
  5. 生成 payment_txnpayment_txn_detail
  6. 写入扣减流水 wallet_ledger(change_type=DEBIT)
  7. 更新订单 freeze_status=CONFIRMEDpay_status=SUCCESS

10.4 释放事务边界

释放阶段只处理仍处于冻结状态的明细:

SELECT *
FROM order_detail
WHERE order_id = :order_id
  AND line_type = 'ASSET_ALLOC'
  AND detail_status = 'FROZEN'
FOR UPDATE;

这样重复释放请求不会重复加回可用余额。释放成功后:

  • wallet_batch.remain_amt += released_amt
  • wallet_batch.frozen_amt -= released_amt
  • coupon.statusLOCKED 回到 UNUSED 或原状态
  • token_account.available_token += released_token
  • token_account.frozen_token -= released_token
  • order_detail.detail_status = RELEASED
  • wallet_ledger.change_type = UNFREEZE

10.5 幂等键设计

建议幂等键格式:

业务动作幂等键
下单预扣PREPAY:{order_no}:{client_request_id}
确认扣减CAPTURE:{order_no}
释放冻结RELEASE:{order_no}:{reason}
退款REFUND:{pay_txn_no}:{refund_request_no}
充值审核入账RECHARGE_POST:{recharge_no}

idempotency_record 建议字段:

字段说明
idempotency_key全局唯一。
biz_type业务动作。
request_hash请求参数摘要,同 key 不同参数应拒绝。
statusPROCESSING/SUCCESS/FAILED
response_snapshot成功响应快照,重复请求直接返回。
expire_at过期时间,支付和退款建议至少保留 30-90 天。

11. 退款设计

11.1 退款原则

  1. 已确认扣减的订单才能退款。
  2. 退款不重新跑资产路由,只依据 payment_txn_detail
  3. 每条支付明细累计退款金额不得超过原扣减金额。
  4. 优惠券是否返还由原券配置和业务规则决定。
  5. 退款要支持部分退款、多次退款、失败补偿。

IP配图:退款原路返回

11.2 退款流程图

@startuml
title 原路退款流程
start
:接收退款请求;
:校验幂等键;
:查询原 payment_txn;

if (原交易是否成功?) then (否)
  :拒绝退款;
  stop
endif

:查询 payment_txn_detail;
:计算每条明细可退金额;

if (退款金额是否超限?) then (是)
  :返回 REFUND_AMOUNT_EXCEEDED;
  stop
endif

:创建 refund_txn/refund_txn_detail;

while (还有退款明细?) is (是)
  :取一条退款明细;
  if (asset_type=COUPON 且不返还?) then (是)
    :记录 return_path=NO_RETURN;
    :计入营销损益或现金补偿待处理;
  else (否)
    :按 asset_ref_id 原路回补;
    :写 REFUND 流水;
  endif
endwhile (否)

:更新 refund_txn=SUCCESS;
:更新订单退款状态;
stop
@enduml

11.3 退款分摊策略

推荐两种模式,默认使用“按原明细顺序退款”:

模式说明适用场景
按原明细顺序退款先退优惠券、赠送金、充值金等,直到覆盖退款金额规则清晰,便于解释,适合内部资产。
按支付明细比例退款每种资产按原支付占比分摊退款适合商品级退款且资产分摊需要更均匀。

不建议退款时重新选择资产,因为这会破坏“原路返回”的可解释性。

11.4 优惠券不返还处理

优惠券 can_refund_return=0 时,退款明细仍要记录:

  • return_path=NO_RETURN
  • refund_amt 为该券承担的退款金额
  • asset_ref_id 指向原券
  • remark 标注规则快照或营销损益口径

产品和财务需要明确不返券后的用户补偿方式:

  1. 不补偿,退款金额只退其他实付资产。
  2. 转为现金/余额补偿。
  3. 生成新的补偿券。
  4. 计入营销损益,但用户侧展示“优惠券不退回”。

12. 超时、补偿与异常处理

12.1 冻结超时释放

冻结成功后写入 Redis ZSet:

key: asset:freeze:timeout
score: freeze_expire_at timestamp
value: order_no

定时任务每次拉取到期订单,调用 release(order_no, reason=TIMEOUT)。Redis 只做扫描加速,真正是否释放以数据库订单状态和明细状态为准。

12.2 结果未知处理

确认扣减时如果服务超时或数据库连接中断,不能直接重试扣减,应先查:

  1. payment_txn 是否已存在成功交易。
  2. order.pay_status 是否已成功。
  3. order_detail 是否已从 FROZEN 转为 CONFIRMED
  4. wallet_ledger 是否已有对应 DEBIT 流水。

如果四者一致,返回成功;如果部分成功,进入 MANUAL_REVIEW 或补偿任务。

12.3 常见异常与处理

异常可能原因处理策略
试算成功但冻结失败并发订单先冻结了资产重新试算,仍不足则提示资产不足。
冻结成功但用户未确认用户取消、页面关闭、网络中断到期释放,释放接口幂等。
确认支付重复提交用户连点、客户端超时重试CAPTURE:{order_no} 幂等返回首次结果。
扣减事务部分未知DB 超时、服务重启查询状态和流水,补偿一致性。
退款重复提交客服重复操作、任务重试REFUND:{pay_txn_no}:{refund_request_no} 幂等。
券规则变更运营修改券配置使用 rule_snapshot,历史订单不受影响。

13. 对账与审计

13.1 三层对账

对账层对账公式频率
钱包汇总 vs 批次wallet.available_amt = sum(wallet_batch.remain_amt)wallet.frozen_amt = sum(wallet_batch.frozen_amt)每日,重要会员可小时级。
资产表 vs 流水期初余额 + sum(ledger.change_amt) = 期末余额每日。
订单交易 vs 支付明细payment_txn.total_amt = sum(payment_txn_detail.amount)实时校验 + 每日。
退款 vs 原支付明细sum(refund_txn_detail.refund_amt) <= payment_txn_detail.amount每次退款 + 每日。

13.2 对账异常处理流程

@startuml
title 资产对账异常处理流程
start
:每日生成对账批次;
:聚合钱包、批次、流水、交易明细;
if (是否平账?) then (是)
  :生成对账通过报告;
  stop
else (否)
  :生成差异明细;
  :按会员/资产/订单定位;
  if (可自动修复?) then (是)
    :生成调整流水;
    :执行补偿任务;
    :复核差异;
  else (否)
    :进入人工审核;
    :人工确认后执行调整;
  endif
endif
:归档处理记录;
stop
@enduml

13.3 流水设计要求

wallet_ledger 不是可有可无的日志,而是资金审计事实:

  • 所有资产变化都必须写流水。
  • 流水不可物理删除。
  • 流水必须包含变更前后快照,便于定位问题。
  • 流水必须包含 ref_type/ref_id,能反查订单、交易、退款。
  • 人工调整也必须走流水,不能直接改余额。

14. API 设计建议

14.1 核心接口

接口方法说明幂等键
/api/payments/prepayPOST订单资产试算并冻结PREPAY:{order_no}:{client_request_id}
/api/payments/capturePOST用户确认后冻结转实扣CAPTURE:{order_no}
/api/payments/releasePOST用户取消或超时释放冻结RELEASE:{order_no}:{reason}
/api/refundsPOST基于原交易发起退款REFUND:{pay_txn_no}:{refund_request_no}
/api/assets/quotePOST只试算不冻结,用于前端展示不强制,建议带请求号便于追踪
/api/recharges/{id}/approvePOST充值审核通过并入账RECHARGE_POST:{recharge_no}

14.2 预扣请求示例

{
  "orderNo": "O202607100001",
  "customerId": 10001,
  "orderAmount": "128.00",
  "clientRequestId": "c5e3f4a2",
  "allowCashSupplement": true,
  "selectedCouponIds": [88001],
  "strategyCode": "DEFAULT_MEMBER_PAY"
}

14.3 预扣响应示例

{
  "orderNo": "O202607100001",
  "freezeStatus": "FROZEN",
  "freezeExpireAt": "2026-07-10T20:30:00+08:00",
  "assetAllocations": [
    {
      "assetType": "COUPON",
      "assetRefId": 88001,
      "amount": "20.00",
      "sequenceNo": 1
    },
    {
      "assetType": "GIFT",
      "assetRefId": 51001,
      "amount": "30.00",
      "sequenceNo": 2
    },
    {
      "assetType": "RECHARGE",
      "assetRefId": 51002,
      "amount": "78.00",
      "sequenceNo": 3
    }
  ],
  "cashSupplementAmount": "0.00"
}

14.4 错误码建议

错误码含义
ASSET_INSUFFICIENT资产不足。
ASSET_CHANGED_RETRY资产快照变化,需要重新试算。
ORDER_STATE_INVALID当前订单状态不允许该操作。
FREEZE_EXPIRED冻结已过期。
CAPTURE_ALREADY_SUCCESS确认扣减已成功,幂等返回。
REFUND_AMOUNT_EXCEEDED退款金额超过可退金额。
IDEMPOTENCY_CONFLICT同幂等键请求参数不一致。
ASSET_RULE_NOT_MATCH优惠券或资产规则不满足。

15. 索引与约束建议

15.1 唯一约束

唯一键
orderuk_order_no(order_no)
payment_txnuk_txn_no(txn_no)uk_biz_order(biz_type, biz_order_no)
refund_txnuk_refund_no(refund_no)uk_idempotency(idempotency_key)
idempotency_recorduk_idempotency_key(idempotency_key)
couponuk_coupon_code(coupon_code)

15.2 查询索引

索引用途
wallet_batch(customer_id, asset_type, status, expire_at, created_at, id)批次扣减候选查询。
order_detail(order_id, line_type, detail_status)确认、释放、退款查询冻结行。
payment_txn_detail(pay_txn_id, asset_type, asset_ref_id)退款按原支付明细查询。
wallet_ledger(customer_id, asset_type, created_at)会员资产流水查询和对账。
refund_txn_detail(pay_txn_detail_id)聚合已退金额。

15.3 金额约束

建议在应用层和数据库层同时保证:

  • available_amt >= 0
  • frozen_amt >= 0
  • remain_amt >= 0
  • refund_amt >= 0
  • sum(refund_detail.refund_amt) <= payment_txn_detail.amount
  • 金额字段使用 decimal,禁止 float/double

MySQL 8 可以使用 CHECK 约束;如果线上版本或团队规范不依赖 CHECK,必须在条件更新和单元测试中覆盖。


16. 充值入账设计

充值不是支付订单,但会形成资产库存,应遵守同样的流水和幂等要求。

16.1 充值审核流程

@startuml
title 充值审核入账流程
start
:提交充值申请;
:创建 recharge_record(PENDING);
:支付或线下收款确认;
:运营/财务审核;
if (审核通过?) then (是)
  :生成充值批次 RECHARGE;
  if (有赠送金额?) then (是)
    :生成赠送批次 GIFT;
  endif
  :更新 wallet 汇总;
  :写 CREDIT 流水;
  :recharge_record=APPROVED;
else (否)
  :recharge_record=REJECTED;
  :记录审核原因;
endif
stop
@enduml

16.2 入账规则

  • 充值本金批次和赠送批次必须分开,避免退款和过期规则混淆。
  • 充值审核通过后才入账,审核日志不可删除。
  • 充值入账幂等键建议为 RECHARGE_POST:{recharge_no}
  • 退款回原批次时,source_type=REFUND 可选择新建批次或回补原批次;本方案推荐回补原批次并写明退款流水。

17. 实现细节建议

17.1 服务拆分

模块核心职责
AssetQuoteService只负责资产试算,不做写操作。
AssetFreezeService负责冻结事务。
AssetCaptureService负责确认扣减事务。
AssetReleaseService负责取消和超时释放。
RefundService负责原路退款和退款补偿。
LedgerService统一生成流水,禁止业务服务绕过流水直接改余额。
IdempotencyService幂等键检查、请求摘要校验、响应快照返回。

17.2 事务内不要做的事情

  1. 不调用外部支付网关。
  2. 不发送 MQ 后等待结果。
  3. 不执行复杂营销规则搜索。
  4. 不生成大报表或复杂对账。
  5. 不做用户通知。

事务中只做必须原子提交的数据变更。外部通知、消息投递、搜索索引更新应使用事务后事件或 Outbox 模式。

17.3 Outbox 事件建议

事件触发时机用途
AssetFrozen冻结成功通知订单可进入待确认。
AssetCaptured扣减成功通知履约、积分、通知系统。
AssetReleased释放成功通知订单关闭或恢复权益展示。
RefundSucceeded退款成功通知订单、客服、用户消息。
RechargePosted充值入账成功通知会员资产刷新。

17.4 可观测性

关键日志字段:

  • trace_id
  • customer_id
  • order_no
  • txn_no
  • refund_no
  • idempotency_key
  • asset_type
  • asset_ref_id
  • change_type
  • amount
  • before_available / after_available
  • before_frozen / after_frozen

关键指标:

  • 冻结成功率
  • 冻结失败原因分布
  • 确认扣减耗时
  • 超时释放数量
  • 幂等命中次数
  • 退款失败补偿数量
  • 对账差异金额

18. 测试场景清单

18.1 正常场景

  1. 单一充值批次支付成功。
  2. 多个充值批次按有效期和 FIFO 扣减。
  3. 优惠券 + 赠送金 + 充值金组合支付成功。
  4. 代币按汇率折算支付成功。
  5. 内部资产不足时现金补差。
  6. 用户取消后冻结释放。
  7. 冻结超时后定时释放。
  8. 支付成功后全额退款。
  9. 支付成功后多次部分退款。
  10. 优惠券可返还和不可返还两种退款。

18.2 并发与异常场景

  1. 同一会员两个订单同时冻结同一批次余额,只允许一个成功或按余额正确拆分。
  2. 同一订单重复确认支付,只生成一笔交易。
  3. 同一退款请求重复提交,只生成一笔退款。
  4. 试算后资产被其他订单冻结,冻结阶段失败并重新试算。
  5. 释放任务重复执行,不重复加余额。
  6. 确认扣减时服务超时,重试能返回首次结果。
  7. 退款中优惠券不返还,退款明细仍可审计。
  8. 人工调整余额后,对账能识别流水和余额一致性。

19. 风险与待确认问题

19.1 需要产品确认

  1. 优惠券不返还时,是否补偿现金、余额或新券。
  2. 赠送余额退款是否返还,是否存在过期后不返还。
  3. 代币汇率是下单时固定,还是支付确认时固定。本方案建议下单冻结时固定。
  4. 现金补差失败后,内部资产冻结是继续保留还是立即释放。
  5. 冻结有效期默认多长,是否按订单类型配置。

19.2 需要技术确认

  1. Redis 锁是否已有统一组件,是否支持自动续期和 fencing token。
  2. MySQL 版本是否支持 CHECK 约束。
  3. 是否已有 Outbox 或可靠消息方案。
  4. 是否要求所有资产流水走复式记账模型。
  5. 是否需要支持跨租户或跨门店资产隔离。

20. 分阶段落地计划

阶段一:核心闭环

  • 钱包、批次、券、代币基础表。
  • 订单冻结明细。
  • 预扣、确认、释放三个接口。
  • 会员级锁、条件更新、幂等键。
  • 基础流水。

阶段二:退款与补偿

  • 支付交易和交易明细。
  • 原路退款。
  • 超时释放任务。
  • 结果未知补偿。
  • 幂等记录持久化。

阶段三:对账与运营能力

  • 每日资产对账。
  • 对账差异报告。
  • 人工调整入口。
  • 充值审核入账。
  • 资产流水查询后台。

阶段四:策略化与扩展

  • 资产优先级配置。
  • 优惠券规则快照。
  • 代币汇率策略。
  • 现金补差和第三方支付衔接。
  • Outbox 事件和下游通知。

21. 参考资料

  1. Stripe PaymentIntents 文档:PaymentIntent 建议与订单/客户会话一一对应,并使用幂等键避免重复创建。
  2. Stripe 手动捕获/资金保留文档:使用 manual capture 临时保留资金,之后再捕获。
  3. Adyen 支付生命周期文档:支付状态覆盖授权、捕获、退款等生命周期。
  4. Adyen Capture 文档:支持手动捕获和部分捕获等能力。
  5. PayPal Orders API v2:订单 API 支持创建、授权、捕获订单。
  6. PayPal Payments API v2:支持授权、捕获已授权支付、退款等动作。
  7. AWS Builders Library: Making retries safe with idempotent APIs:通过幂等 API 降低重试带来的副作用。

22. 附录:IP 配图提示词

当前文档已内置 4 张本地 SVG 概念图。如果后续需要替换成更统一的 GoCode 正文插画,可以使用以下图位和提示词生成 PNG:

  1. 预扣边界:GoCode 程序员在白色抽象空间中为订单画出“冻结/确认扣减/超时释放”的事务边界,短标签为“预扣边界、冻结、确认扣减、超时释放、资产不出账”。
  2. 多资产路由:GoCode 把优惠券、赠送金、充值金、代币卡片分拣到扣减队列,短标签为“优惠券、赠送金、充值金、代币、规则校验、FIFO”。
  3. 并发风险:GoCode 用放大镜检查两个并发请求争抢同一会员资产,短标签为“锁竞争、重复提交、会员资产锁、条件更新、不超扣”。
  4. 退款原路返回:GoCode 把退款包沿支付明细的路径放回原券、原批次、原账户,短标签为“支付明细、原路返回、原券、原批次、原账户、不返券配置”。