支付回执与权益事务
java-db 的线程绑定连接让收款事实、权益批次和原操作结果可以在同一短事务中提交。结合数据库唯一约束和行锁,可以同时处理重复回执、并发通知和返回丢失。
区分三个事实
发起付款、核明实际收款、实际权益交付分别建模。提交付款请求后发生超时,保留原请求号并查询原请求;页面成功提示不能作为收款依据。回执先完成验签、商户作用域、币种、金额与订单匹配,再进入下列本地事务。
示例约定订单由服务端锁定金额,现金表对 (provider, merchant_scope, movement_no) 建立唯一约束,权益表对 order_id 建立唯一约束。这里只演示一单一批次;多现金行应使用订单行作为授予来源。
create table demo_order (
id bigint primary key,
expected_fen bigint not null check(expected_fen > 0),
beneficiary_id bigint not null,
quantity bigint not null check(quantity > 0),
state varchar(24) not null
);
create table demo_receipt (
id bigint primary key,
order_id bigint not null unique references demo_order(id),
provider varchar(32) not null,
merchant_scope varchar(64) not null,
movement_no varchar(128) not null,
amount_fen bigint not null check(amount_fen > 0),
unique(provider, merchant_scope, movement_no)
);
create table demo_card_batch (
id bigint primary key,
order_id bigint not null unique references demo_order(id),
beneficiary_id bigint not null,
quantity bigint not null check(quantity > 0)
);
import java.sql.Connection;
import nexus.io.db.activerecord.Db;
import nexus.io.db.activerecord.Row;
import nexus.io.tio.utils.snowflake.SnowflakeIdUtils;
public final class VerifiedReceiptService {
public void accept(long orderId, String provider, String merchantScope,
String movementNo, long verifiedAmountFen) {
boolean committed = Db.tx(Connection.TRANSACTION_READ_COMMITTED, () -> {
Row order = Db.findFirst("select * from demo_order where id=? for update", orderId);
if (order == null || order.getLong("expected_fen") != verifiedAmountFen) {
throw new IllegalArgumentException("Verified receipt does not match the order");
}
Row prior = Db.findFirst("""
select order_id, amount_fen from demo_receipt
where provider=? and merchant_scope=? and movement_no=?
""", provider, merchantScope, movementNo);
if (prior != null) {
if (prior.getLong("order_id") != orderId
|| prior.getLong("amount_fen") != verifiedAmountFen) {
throw new IllegalStateException("Receipt reconciliation is required");
}
return true;
}
Db.update("""
insert into demo_receipt(id, order_id, provider, merchant_scope, movement_no, amount_fen)
values(?,?,?,?,?,?)
""", SnowflakeIdUtils.id(), orderId, provider, merchantScope, movementNo, verifiedAmountFen);
Db.update("""
insert into demo_card_batch(id, order_id, beneficiary_id, quantity) values(?,?,?,?)
""", SnowflakeIdUtils.id(), orderId, order.getLong("beneficiary_id"), order.getLong("quantity"));
Db.update("update demo_order set state='GRANTED' where id=?", orderId);
return true;
});
if (!committed) {
throw new IllegalStateException("Receipt transaction was rolled back");
}
}
}
Db.tx 与其中的 Db 操作共享当前数据源的线程连接;事务统一使用同一隔离级别,避免内层调用尝试改变已经开始的事务。异常使相关事实一起回滚,调用方应保留原通知标识,重新查询后再以原标识重试。不要在数据库事务内部等待外部付款网络请求。
该示例的一单一收款约束适合演示正常订单。真实通道如果允许一单出现第二笔独立收款,应另存异常现金事实并对账,不能仅因已发卡而丢弃第二笔实际现金。
退款与本金覆盖
退款申请、局部保留、独立批准、指定执行、外部终局各有状态。执行发出后的取消请求只能记为待确认,收到原执行不可能成功的终局证据后再释放。部分成功只撤销成功范围,其余部分需分别有终局证据。
现金支出以实际移动号去重;本金覆盖以原现金行的范围并集去重。两笔独立支出覆盖相同本金时,两笔现金都保留,但本金只覆盖一次。因此净留款可能为负,差额单独核验,不自动成为客户债务。金额已核但覆盖未核时,显示分项待核,不自行猜测优先退已用还是未用。
对数量型服务,累计交付金额采用整数分计算。联系实际揭示、卡量扣减、原现金行交付范围及原操作结果放入同一事务;解密或授权校验失败则整个事务回滚。退款不删除原交付历史。
验证建议
- 并发发送同一回执,只有一笔收款事实和一次授予。
- 授予写入故障时收款事务回滚,原回执可重试。
- 退款与最后一次联系同时发生,只允许一个动作占用最后单位。
- 退款处理中重复取消,保留量保持不变。
- 独立拒付后迟到退款如实入账,本金范围只覆盖一次。
- 卡使用期结束后仍可查询原现金、退款和未结服务责任。
按实际服务区间交付的业务,可继续阅读商业库存与按量履约事务,将现金行累计金额与独立服务区间一起维护。
