diff --git a/game-cloud/game-module-aigc/game-module-aigc-server/src/main/java/com/wanxiang/huijing/game/module/aigc/service/quota/NewapiQuotaServiceImpl.java b/game-cloud/game-module-aigc/game-module-aigc-server/src/main/java/com/wanxiang/huijing/game/module/aigc/service/quota/NewapiQuotaServiceImpl.java index 41d94001..17b7cbea 100644 --- a/game-cloud/game-module-aigc/game-module-aigc-server/src/main/java/com/wanxiang/huijing/game/module/aigc/service/quota/NewapiQuotaServiceImpl.java +++ b/game-cloud/game-module-aigc/game-module-aigc-server/src/main/java/com/wanxiang/huijing/game/module/aigc/service/quota/NewapiQuotaServiceImpl.java @@ -31,6 +31,12 @@ public class NewapiQuotaServiceImpl implements NewapiQuotaService { private static final String CHANNEL_PASSWORD = "password"; private static final String CHANNEL_INVITE = "invite"; + /** + * 单次 claim 的最大 CAS 次数。多个请求可能同时选中最早 FREE,输家需重选剩余条目; + * 固定小次数可消除常见竞争误判,同时避免异常竞争时无限占用注册或派发线程。 + */ + private static final int CLAIM_MAX_ATTEMPTS = 3; + /** * 主开关(默认 false):关时 claim/派发全旁路。同 {@code NewapiQuotaClaimConsumer} 的 @ConditionalOnProperty, * 开则消费者装配 + per-user 门生效。 @@ -158,26 +164,44 @@ public class NewapiQuotaServiceImpl implements NewapiQuotaService { return true; } String bizNo = "claim_" + gamePlayerId; - try { - int affected = poolMapper.claimOneFree(gamePlayerId, bizNo, LocalDateTime.now()); - if (affected == 1) { - log.info("[newapi-quota] claim 成功 FREE→CLAIMED scene={}, gamePlayerId={}, bizNo={}", scene, gamePlayerId, bizNo); - alertIfLowWater(scene); - return true; + for (int attempt = 1; attempt <= CLAIM_MAX_ATTEMPTS; attempt++) { + try { + int affected = poolMapper.claimOneFree(gamePlayerId, bizNo, LocalDateTime.now()); + if (affected == 1) { + log.info("[newapi-quota] claim 成功 FREE→CLAIMED scene={}, gamePlayerId={}, bizNo={}, attempt={}", + 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; } /** diff --git a/game-cloud/game-module-aigc/game-module-aigc-server/src/test/java/com/wanxiang/huijing/game/module/aigc/service/quota/NewapiQuotaServiceImplTest.java b/game-cloud/game-module-aigc/game-module-aigc-server/src/test/java/com/wanxiang/huijing/game/module/aigc/service/quota/NewapiQuotaServiceImplTest.java index 4355546c..6d9a95aa 100644 --- a/game-cloud/game-module-aigc/game-module-aigc-server/src/test/java/com/wanxiang/huijing/game/module/aigc/service/quota/NewapiQuotaServiceImplTest.java +++ b/game-cloud/game-module-aigc/game-module-aigc-server/src/test/java/com/wanxiang/huijing/game/module/aigc/service/quota/NewapiQuotaServiceImplTest.java @@ -26,6 +26,7 @@ import static org.mockito.ArgumentMatchers.anyLong; import static org.mockito.ArgumentMatchers.anyString; import static org.mockito.ArgumentMatchers.eq; import static org.mockito.Mockito.never; +import static org.mockito.Mockito.times; import static org.mockito.Mockito.verify; import static org.mockito.Mockito.verifyNoInteractions; 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); assertDoesNotThrow(() -> service.claimForRegister(5L, "password")); + + verify(poolMapper, times(3)).claimOneFree(eq(5L), eq("claim_5"), any(LocalDateTime.class)); } /** 并发同玩家 claim:CAS 撞 uk 抛 DuplicateKeyException → 幂等收敛,不外抛。 */ @@ -150,6 +153,34 @@ class NewapiQuotaServiceImplTest extends BaseMockitoUnitTest { 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。 */ @Test void testReconcileClaim_existingClaim_idempotentNoCas() { diff --git a/game-cloud/game-module-trade/game-module-trade-server/src/main/java/com/wanxiang/huijing/game/module/trade/controller/app/TradeController.java b/game-cloud/game-module-trade/game-module-trade-server/src/main/java/com/wanxiang/huijing/game/module/trade/controller/app/TradeController.java index 05712b0a..6133942b 100644 --- a/game-cloud/game-module-trade/game-module-trade-server/src/main/java/com/wanxiang/huijing/game/module/trade/controller/app/TradeController.java +++ b/game-cloud/game-module-trade/game-module-trade-server/src/main/java/com/wanxiang/huijing/game/module/trade/controller/app/TradeController.java @@ -68,6 +68,8 @@ public class TradeController { @GetMapping("/account/mine") @Operation(summary = "我的收益账户", description = "余额/累计收益/累计提现/冻结中(单位分);账户不存在时按需初始化为零值账户") public CommonResult getMyAccount() { + // 查询可能创建零值账户,必须先在可信后端关闭 dogfood 交易子系统,不能依赖前端隐藏入口。 + dogfoodAccessGuard.rejectInDogfood("trade.account.mine", TRADE_DOGFOOD_MONETIZATION_DISABLED); Long userId = SecurityFrameworkUtils.getLoginUserId(); // 账户不存在时按需初始化为零值账户(首次查询/首次有收益时) AccountDO account = accountService.getOrInitAccount(userId); @@ -77,6 +79,7 @@ public class TradeController { @GetMapping("/income/page") @Operation(summary = "我的收益流水分页", description = "source=ad/tip、gross/net/share_rate、结算日;创作者只见自己") public CommonResult> getMyIncomePage(@Valid AppIncomePageReqVO reqVO) { + dogfoodAccessGuard.rejectInDogfood("trade.income.page", TRADE_DOGFOOD_MONETIZATION_DISABLED); Long userId = SecurityFrameworkUtils.getLoginUserId(); PageResult pageResult = incomeService.getMyIncomePage(reqVO, userId); return success(TradeConvert.toIncomePage(pageResult)); @@ -85,6 +88,7 @@ public class TradeController { @GetMapping("/my/income-summary") @Operation(summary = "我的收益汇总", description = "看板「收益」转真:累计实得/原始收入/入账笔数(聚合已结算收益);归属隔离限本人") public CommonResult getMyIncomeSummary() { + dogfoodAccessGuard.rejectInDogfood("trade.income-summary", TRADE_DOGFOOD_MONETIZATION_DISABLED); // 归属隔离硬边界:user_id 取自 token 解析的 getLoginUserId(),聚合谓词在 Service/Mapper 强制本人 Long userId = SecurityFrameworkUtils.getLoginUserId(); return success(incomeService.getMyIncomeSummary(userId)); @@ -110,6 +114,7 @@ public class TradeController { @GetMapping("/subscription/mine") @Operation(summary = "我的会员订阅态(U2)", description = "C 端查订阅态:是否有效(active)/套餐/到期时间;有效性按 expire_time>now 实时判定(无 cron 也准);从未订阅返回空态(subscribed=false)") public CommonResult getMySubscription() { + dogfoodAccessGuard.rejectInDogfood("trade.subscription.mine", TRADE_DOGFOOD_MONETIZATION_DISABLED); // 归属隔离硬边界:userId 取自 token 解析的 getLoginUserId(),命中 uk_user 限本人 Long userId = SecurityFrameworkUtils.getLoginUserId(); SubscriptionDO sub = subscriptionService.getMySubscription(userId); diff --git a/game-cloud/game-module-trade/game-module-trade-server/src/test/java/com/wanxiang/huijing/game/module/trade/controller/app/TradeControllerDogfoodTest.java b/game-cloud/game-module-trade/game-module-trade-server/src/test/java/com/wanxiang/huijing/game/module/trade/controller/app/TradeControllerDogfoodTest.java index d7b87c9b..7b4d83e8 100644 --- a/game-cloud/game-module-trade/game-module-trade-server/src/test/java/com/wanxiang/huijing/game/module/trade/controller/app/TradeControllerDogfoodTest.java +++ b/game-cloud/game-module-trade/game-module-trade-server/src/test/java/com/wanxiang/huijing/game/module/trade/controller/app/TradeControllerDogfoodTest.java @@ -18,9 +18,10 @@ import static org.junit.jupiter.api.Assertions.assertThrows; import static org.mockito.ArgumentMatchers.anyString; import static org.mockito.ArgumentMatchers.eq; import static org.mockito.Mockito.doThrow; +import static org.mockito.Mockito.verify; import static org.mockito.Mockito.verifyNoInteractions; -/** 充值与提现 app 入口在狗粮态的直接调用负例。 */ +/** 交易 app 入口在狗粮态的可信后端拒绝负例。 */ class TradeControllerDogfoodTest extends BaseMockitoUnitTest { @InjectMocks @@ -52,7 +53,48 @@ class TradeControllerDogfoodTest extends BaseMockitoUnitTest { assertThrows(ServiceException.class, () -> chargeController.getPointsBalance()); assertThrows(ServiceException.class, () -> tradeController.applyWithdraw(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); } + /** 查询账户会按需创建零值账户,狗粮态必须在触碰账户服务前拒绝。 */ + @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); + } + }