修复:防止提现创建中的竞态条件和重复收益
Some checks failed
Build and Push Backend / docker (push) Failing after 6s

- 在 earnings_line 表添加唯一约束 (tenant_id, order_id, clerk_id, earning_type, deleted)
- 重排提现创建流程:先预留收益行,成功后才创建提现请求
- 在收益行预留时添加状态验证,检测并发修改
- 使用临时提现ID进行预留,创建请求后替换为真实ID
- 添加唯一约束前先清理重复的收益记录(V10 迁移)

此修复解决了关键的竞态条件问题:并发提现可能创建没有资金支持的孤儿请求记录。
修复后确保快速失败行为 - 如果收益行已被占用,提现请求永远不会被创建。
This commit is contained in:
irving
2025-10-13 22:45:53 -04:00
parent 5438a8e1f0
commit 0725c94bbe
2 changed files with 41 additions and 9 deletions

View File

@@ -65,6 +65,23 @@ public class WithdrawalServiceImpl extends ServiceImpl<WithdrawalRequestMapper,
List<EarningsLineEntity> lines = earningsService.findWithdrawable(clerkId, amount, now);
if (lines.isEmpty()) throw new CustomException("可提现余额不足");
// Reserve lines FIRST with temp ID (fail fast before creating request)
String tempWithdrawalId = "TEMP_" + IdUtils.getUuid();
int reservedCount = 0;
for (EarningsLineEntity line : lines) {
boolean updated = earningsService.update(Wrappers.lambdaUpdate(EarningsLineEntity.class)
.eq(EarningsLineEntity::getId, line.getId())
.eq(EarningsLineEntity::getStatus, line.getStatus()) // Verify status unchanged
.set(EarningsLineEntity::getStatus, "withdrawing")
.set(EarningsLineEntity::getWithdrawalId, tempWithdrawalId));
if (!updated) {
// Another request already took this line
throw new CustomException("部分收益已被其他提现请求锁定,请重试");
}
reservedCount++;
}
// Only NOW create the request (after successful reservation)
LocalDateTime confirmedAt = payeeProfile.getLastConfirmedAt();
String snapshotJson = buildPayeeSnapshot(payeeProfile, confirmedAt);
@@ -80,20 +97,17 @@ public class WithdrawalServiceImpl extends ServiceImpl<WithdrawalRequestMapper,
req.setStatus("pending");
req.setOutBizNo(req.getId());
this.save(req);
// Update temp ID to real ID
earningsService.update(Wrappers.lambdaUpdate(EarningsLineEntity.class)
.eq(EarningsLineEntity::getWithdrawalId, tempWithdrawalId)
.set(EarningsLineEntity::getWithdrawalId, req.getId()));
// log created
withdrawalLogService.log(req.getTenantId(), clerkId, req.getId(),
"CREATED", null, req.getStatus(), "提现申请创建", null);
withdrawalLogService.log(req.getTenantId(), clerkId, req.getId(),
"PAYEE_CONFIRMED", req.getStatus(), req.getStatus(), "店员确认收款码", snapshotJson);
int reservedCount = 0;
for (EarningsLineEntity line : lines) {
earningsService.update(Wrappers.lambdaUpdate(EarningsLineEntity.class)
.eq(EarningsLineEntity::getId, line.getId())
.set(EarningsLineEntity::getStatus, "withdrawing")
.set(EarningsLineEntity::getWithdrawalId, req.getId()));
reservedCount++;
}
withdrawalLogService.log(req.getTenantId(), clerkId, req.getId(),
"RESERVED", req.getStatus(), req.getStatus(),
"冻结收益已预留,条数=" + reservedCount + ", 金额=" + amount, null);