fix(dogfood): close quota and trade race gaps

This commit is contained in:
lili 2026-07-23 00:26:11 -07:00
parent c454610183
commit 20dd705fac
4 changed files with 121 additions and 19 deletions

View File

@ -31,6 +31,12 @@ public class NewapiQuotaServiceImpl implements NewapiQuotaService {
private static final String CHANNEL_PASSWORD = "password"; private static final String CHANNEL_PASSWORD = "password";
private static final String CHANNEL_INVITE = "invite"; private static final String CHANNEL_INVITE = "invite";
/**
* 单次 claim 的最大 CAS 次数多个请求可能同时选中最早 FREE输家需重选剩余条目
* 固定小次数可消除常见竞争误判同时避免异常竞争时无限占用注册或派发线程
*/
private static final int CLAIM_MAX_ATTEMPTS = 3;
/** /**
* 主开关默认 false关时 claim/派发全旁路 {@code NewapiQuotaClaimConsumer} @ConditionalOnProperty * 主开关默认 false关时 claim/派发全旁路 {@code NewapiQuotaClaimConsumer} @ConditionalOnProperty
* 开则消费者装配 + per-user 门生效 * 开则消费者装配 + per-user 门生效
@ -158,26 +164,44 @@ public class NewapiQuotaServiceImpl implements NewapiQuotaService {
return true; return true;
} }
String bizNo = "claim_" + gamePlayerId; String bizNo = "claim_" + gamePlayerId;
try { for (int attempt = 1; attempt <= CLAIM_MAX_ATTEMPTS; attempt++) {
int affected = poolMapper.claimOneFree(gamePlayerId, bizNo, LocalDateTime.now()); try {
if (affected == 1) { int affected = poolMapper.claimOneFree(gamePlayerId, bizNo, LocalDateTime.now());
log.info("[newapi-quota] claim 成功 FREE→CLAIMED scene={}, gamePlayerId={}, bizNo={}", scene, gamePlayerId, bizNo); if (affected == 1) {
alertIfLowWater(scene); log.info("[newapi-quota] claim 成功 FREE→CLAIMED scene={}, gamePlayerId={}, bizNo={}, attempt={}",
return true; scene, gamePlayerId, bizNo, attempt);
alertIfLowWater(scene);
return true;
}
// affected=0 既可能是真池空也可能是并发请求先更新了同一条最早 FREE先复查本人绑定以保持幂等
NewapiQuotaPoolDO concurrentClaim = poolMapper.selectClaimedByPlayer(gamePlayerId);
if (concurrentClaim != null) {
log.info("[newapi-quota] claim CAS 未命中后复查到并发绑定,幂等复用 scene={}, gamePlayerId={}, poolId={}, attempt={}",
scene, gamePlayerId, concurrentClaim.getId(), attempt);
return true;
}
if (attempt < CLAIM_MAX_ATTEMPTS) {
// 下一轮 SQL 会重新选择当前最早 FREE避开刚被并发抢走的条目固定三轮不做无界自旋
log.info("[newapi-quota] claim CAS 未命中且玩家仍未绑定,重选剩余 FREE scene={}, gamePlayerId={}, attempt={}",
scene, gamePlayerId, attempt);
continue;
}
} catch (DuplicateKeyException e) {
// 并发同玩家 claim两路各抢一条不同 FREE第二路写 claimed_by/biz_no uk_claimed_by/uk_biz_no
// 冲突后必须复查真实 CLAIMED不能把其它历史脏 biz_no 冲突误判为成功
NewapiQuotaPoolDO concurrentClaim = poolMapper.selectClaimedByPlayer(gamePlayerId);
boolean claimedByConcurrentRequest = concurrentClaim != null;
log.info("[newapi-quota] claim 唯一键冲突后复查完成 scene={}, gamePlayerId={}, claimed={}",
scene, gamePlayerId, claimedByConcurrentRequest);
return claimedByConcurrentRequest;
} }
// affected=0子查询无 FREE 命中 = 池空非并发并发同玩家走下方 DuplicateKeyException 分支
log.warn("[newapi-quota] 池空(无 FREE 条目),本次未绑 scene={}, gamePlayerId={}", scene, gamePlayerId);
triggerWaterLevelAlert(0L, scene);
return false;
} catch (DuplicateKeyException e) {
// 并发同玩家 claim两路各抢一条不同 FREE第二路写 claimed_by/biz_no uk_claimed_by/uk_biz_no
// 冲突后必须复查该玩家的真实 CLAIMED 记录不能把其它历史脏 biz_no 冲突误判为成功
NewapiQuotaPoolDO concurrentClaim = poolMapper.selectClaimedByPlayer(gamePlayerId);
boolean claimedByConcurrentRequest = concurrentClaim != null;
log.info("[newapi-quota] claim 唯一键冲突后复查完成 scene={}, gamePlayerId={}, claimed={}",
scene, gamePlayerId, claimedByConcurrentRequest);
return claimedByConcurrentRequest;
} }
log.warn("[newapi-quota] 有界重试后仍无 FREE 且玩家未绑定 scene={}, gamePlayerId={}, attempts={}",
scene, gamePlayerId, CLAIM_MAX_ATTEMPTS);
triggerWaterLevelAlert(0L, scene);
return false;
} }
/** /**

View File

@ -26,6 +26,7 @@ import static org.mockito.ArgumentMatchers.anyLong;
import static org.mockito.ArgumentMatchers.anyString; import static org.mockito.ArgumentMatchers.anyString;
import static org.mockito.ArgumentMatchers.eq; import static org.mockito.ArgumentMatchers.eq;
import static org.mockito.Mockito.never; import static org.mockito.Mockito.never;
import static org.mockito.Mockito.times;
import static org.mockito.Mockito.verify; import static org.mockito.Mockito.verify;
import static org.mockito.Mockito.verifyNoInteractions; import static org.mockito.Mockito.verifyNoInteractions;
import static org.mockito.Mockito.when; import static org.mockito.Mockito.when;
@ -116,6 +117,8 @@ class NewapiQuotaServiceImplTest extends BaseMockitoUnitTest {
when(poolMapper.claimOneFree(eq(5L), eq("claim_5"), any(LocalDateTime.class))).thenReturn(0); when(poolMapper.claimOneFree(eq(5L), eq("claim_5"), any(LocalDateTime.class))).thenReturn(0);
assertDoesNotThrow(() -> service.claimForRegister(5L, "password")); assertDoesNotThrow(() -> service.claimForRegister(5L, "password"));
verify(poolMapper, times(3)).claimOneFree(eq(5L), eq("claim_5"), any(LocalDateTime.class));
} }
/** 并发同玩家 claimCAS 撞 uk 抛 DuplicateKeyException → 幂等收敛,不外抛。 */ /** 并发同玩家 claimCAS 撞 uk 抛 DuplicateKeyException → 幂等收敛,不外抛。 */
@ -150,6 +153,34 @@ class NewapiQuotaServiceImplTest extends BaseMockitoUnitTest {
verify(poolMapper).claimOneFree(eq(5L), eq("claim_5"), any(LocalDateTime.class)); verify(poolMapper).claimOneFree(eq(5L), eq("claim_5"), any(LocalDateTime.class));
} }
/** 并发抢同一条 FREE 时首轮 CAS 可返 0复查仍未绑定后应重选剩余 FREE并在小次数内成功。 */
@Test
void testReconcileClaim_affectedZero_retriesRemainingFreeAndSucceeds() {
when(playerApi.getPlayer(5L)).thenReturn(CommonResult.success(member(5L)));
when(poolMapper.selectClaimedByPlayer(5L)).thenReturn(null);
when(poolMapper.claimOneFree(eq(5L), eq("claim_5"), any(LocalDateTime.class)))
.thenReturn(0)
.thenReturn(1);
assertTrue(service.reconcileClaim(5L));
verify(poolMapper, times(2)).claimOneFree(eq(5L), eq("claim_5"), any(LocalDateTime.class));
}
/** 首轮 CAS 返 0 后若复查发现同一玩家已由并发请求绑定,应幂等成功且不再占第二条。 */
@Test
void testReconcileClaim_affectedZero_rechecksConcurrentBindingWithoutRetry() {
when(playerApi.getPlayer(5L)).thenReturn(CommonResult.success(member(5L)));
when(poolMapper.selectClaimedByPlayer(5L))
.thenReturn(null)
.thenReturn(claimedEntry(5L, "sk-CONCURRENT"));
when(poolMapper.claimOneFree(eq(5L), eq("claim_5"), any(LocalDateTime.class))).thenReturn(0);
assertTrue(service.reconcileClaim(5L));
verify(poolMapper).claimOneFree(eq(5L), eq("claim_5"), any(LocalDateTime.class));
}
/** 重复补偿:已有 CLAIMED 记录直接成功,不再占用第二条 FREE。 */ /** 重复补偿:已有 CLAIMED 记录直接成功,不再占用第二条 FREE。 */
@Test @Test
void testReconcileClaim_existingClaim_idempotentNoCas() { void testReconcileClaim_existingClaim_idempotentNoCas() {

View File

@ -68,6 +68,8 @@ public class TradeController {
@GetMapping("/account/mine") @GetMapping("/account/mine")
@Operation(summary = "我的收益账户", description = "余额/累计收益/累计提现/冻结中(单位分);账户不存在时按需初始化为零值账户") @Operation(summary = "我的收益账户", description = "余额/累计收益/累计提现/冻结中(单位分);账户不存在时按需初始化为零值账户")
public CommonResult<TradeAccountRespVO> getMyAccount() { public CommonResult<TradeAccountRespVO> getMyAccount() {
// 查询可能创建零值账户必须先在可信后端关闭 dogfood 交易子系统不能依赖前端隐藏入口
dogfoodAccessGuard.rejectInDogfood("trade.account.mine", TRADE_DOGFOOD_MONETIZATION_DISABLED);
Long userId = SecurityFrameworkUtils.getLoginUserId(); Long userId = SecurityFrameworkUtils.getLoginUserId();
// 账户不存在时按需初始化为零值账户首次查询/首次有收益时 // 账户不存在时按需初始化为零值账户首次查询/首次有收益时
AccountDO account = accountService.getOrInitAccount(userId); AccountDO account = accountService.getOrInitAccount(userId);
@ -77,6 +79,7 @@ public class TradeController {
@GetMapping("/income/page") @GetMapping("/income/page")
@Operation(summary = "我的收益流水分页", description = "source=ad/tip、gross/net/share_rate、结算日创作者只见自己") @Operation(summary = "我的收益流水分页", description = "source=ad/tip、gross/net/share_rate、结算日创作者只见自己")
public CommonResult<PageResult<IncomeRespVO>> getMyIncomePage(@Valid AppIncomePageReqVO reqVO) { public CommonResult<PageResult<IncomeRespVO>> getMyIncomePage(@Valid AppIncomePageReqVO reqVO) {
dogfoodAccessGuard.rejectInDogfood("trade.income.page", TRADE_DOGFOOD_MONETIZATION_DISABLED);
Long userId = SecurityFrameworkUtils.getLoginUserId(); Long userId = SecurityFrameworkUtils.getLoginUserId();
PageResult<IncomeDO> pageResult = incomeService.getMyIncomePage(reqVO, userId); PageResult<IncomeDO> pageResult = incomeService.getMyIncomePage(reqVO, userId);
return success(TradeConvert.toIncomePage(pageResult)); return success(TradeConvert.toIncomePage(pageResult));
@ -85,6 +88,7 @@ public class TradeController {
@GetMapping("/my/income-summary") @GetMapping("/my/income-summary")
@Operation(summary = "我的收益汇总", description = "看板「收益」转真:累计实得/原始收入/入账笔数(聚合已结算收益);归属隔离限本人") @Operation(summary = "我的收益汇总", description = "看板「收益」转真:累计实得/原始收入/入账笔数(聚合已结算收益);归属隔离限本人")
public CommonResult<IncomeSummaryVO> getMyIncomeSummary() { public CommonResult<IncomeSummaryVO> getMyIncomeSummary() {
dogfoodAccessGuard.rejectInDogfood("trade.income-summary", TRADE_DOGFOOD_MONETIZATION_DISABLED);
// 归属隔离硬边界user_id 取自 token 解析的 getLoginUserId()聚合谓词在 Service/Mapper 强制本人 // 归属隔离硬边界user_id 取自 token 解析的 getLoginUserId()聚合谓词在 Service/Mapper 强制本人
Long userId = SecurityFrameworkUtils.getLoginUserId(); Long userId = SecurityFrameworkUtils.getLoginUserId();
return success(incomeService.getMyIncomeSummary(userId)); return success(incomeService.getMyIncomeSummary(userId));
@ -110,6 +114,7 @@ public class TradeController {
@GetMapping("/subscription/mine") @GetMapping("/subscription/mine")
@Operation(summary = "我的会员订阅态U2", description = "C 端查订阅态:是否有效(active)/套餐/到期时间;有效性按 expire_time>now 实时判定(无 cron 也准);从未订阅返回空态(subscribed=false)") @Operation(summary = "我的会员订阅态U2", description = "C 端查订阅态:是否有效(active)/套餐/到期时间;有效性按 expire_time>now 实时判定(无 cron 也准);从未订阅返回空态(subscribed=false)")
public CommonResult<SubscriptionRespVO> getMySubscription() { public CommonResult<SubscriptionRespVO> getMySubscription() {
dogfoodAccessGuard.rejectInDogfood("trade.subscription.mine", TRADE_DOGFOOD_MONETIZATION_DISABLED);
// 归属隔离硬边界userId 取自 token 解析的 getLoginUserId()命中 uk_user 限本人 // 归属隔离硬边界userId 取自 token 解析的 getLoginUserId()命中 uk_user 限本人
Long userId = SecurityFrameworkUtils.getLoginUserId(); Long userId = SecurityFrameworkUtils.getLoginUserId();
SubscriptionDO sub = subscriptionService.getMySubscription(userId); SubscriptionDO sub = subscriptionService.getMySubscription(userId);

View File

@ -18,9 +18,10 @@ import static org.junit.jupiter.api.Assertions.assertThrows;
import static org.mockito.ArgumentMatchers.anyString; import static org.mockito.ArgumentMatchers.anyString;
import static org.mockito.ArgumentMatchers.eq; import static org.mockito.ArgumentMatchers.eq;
import static org.mockito.Mockito.doThrow; import static org.mockito.Mockito.doThrow;
import static org.mockito.Mockito.verify;
import static org.mockito.Mockito.verifyNoInteractions; import static org.mockito.Mockito.verifyNoInteractions;
/** 充值与提现 app 入口在狗粮态的直接调用负例。 */ /** 交易 app 入口在狗粮态的可信后端拒绝负例。 */
class TradeControllerDogfoodTest extends BaseMockitoUnitTest { class TradeControllerDogfoodTest extends BaseMockitoUnitTest {
@InjectMocks @InjectMocks
@ -52,7 +53,48 @@ class TradeControllerDogfoodTest extends BaseMockitoUnitTest {
assertThrows(ServiceException.class, () -> chargeController.getPointsBalance()); assertThrows(ServiceException.class, () -> chargeController.getPointsBalance());
assertThrows(ServiceException.class, () -> tradeController.applyWithdraw(null)); assertThrows(ServiceException.class, () -> tradeController.applyWithdraw(null));
assertThrows(ServiceException.class, () -> tradeController.getMyWithdrawPage(null)); assertThrows(ServiceException.class, () -> tradeController.getMyWithdrawPage(null));
verify(dogfoodAccessGuard).rejectInDogfood(
"trade.charge.create", TRADE_DOGFOOD_MONETIZATION_DISABLED);
verify(dogfoodAccessGuard).rejectInDogfood(
"trade.charge.points-balance", TRADE_DOGFOOD_MONETIZATION_DISABLED);
verify(dogfoodAccessGuard).rejectInDogfood(
"trade.withdraw.apply", TRADE_DOGFOOD_MONETIZATION_DISABLED);
verify(dogfoodAccessGuard).rejectInDogfood(
"trade.withdraw.page", TRADE_DOGFOOD_MONETIZATION_DISABLED);
verifyNoInteractions(chargeService, withdrawService); verifyNoInteractions(chargeService, withdrawService);
} }
/** 查询账户会按需创建零值账户,狗粮态必须在触碰账户服务前拒绝。 */
@Test
void shouldRejectAccountReadOrCreateBeforeBusinessService() {
assertThrows(ServiceException.class, () -> tradeController.getMyAccount());
verify(dogfoodAccessGuard).rejectInDogfood(
"trade.account.mine", TRADE_DOGFOOD_MONETIZATION_DISABLED);
verifyNoInteractions(accountService);
}
/** 收益分页与汇总虽为读取,也属于未启用交易子系统,狗粮态不得泄漏或触发业务链。 */
@Test
void shouldRejectIncomeReadsBeforeBusinessService() {
assertThrows(ServiceException.class, () -> tradeController.getMyIncomePage(null));
assertThrows(ServiceException.class, () -> tradeController.getMyIncomeSummary());
verify(dogfoodAccessGuard).rejectInDogfood(
"trade.income.page", TRADE_DOGFOOD_MONETIZATION_DISABLED);
verify(dogfoodAccessGuard).rejectInDogfood(
"trade.income-summary", TRADE_DOGFOOD_MONETIZATION_DISABLED);
verifyNoInteractions(incomeService);
}
/** 订阅态读取属于交易子系统,狗粮态必须由可信后端统一拒绝。 */
@Test
void shouldRejectSubscriptionReadBeforeBusinessService() {
assertThrows(ServiceException.class, () -> tradeController.getMySubscription());
verify(dogfoodAccessGuard).rejectInDogfood(
"trade.subscription.mine", TRADE_DOGFOOD_MONETIZATION_DISABLED);
verifyNoInteractions(subscriptionService);
}
} }