fix(dogfood): close quota and trade race gaps
This commit is contained in:
parent
c454610183
commit
20dd705fac
@ -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;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
@ -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));
|
||||||
}
|
}
|
||||||
|
|
||||||
/** 并发同玩家 claim:CAS 撞 uk 抛 DuplicateKeyException → 幂等收敛,不外抛。 */
|
/** 并发同玩家 claim:CAS 撞 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() {
|
||||||
|
|||||||
@ -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);
|
||||||
|
|||||||
@ -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);
|
||||||
|
}
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user