From 34da791f268992edeac871d5fadc4e95585ad55a Mon Sep 17 00:00:00 2001 From: lili Date: Wed, 22 Jul 2026 22:57:16 -0700 Subject: [PATCH] fix(release): reconcile missing dogfood quota claims --- contracts/api-schemas/aigc.yaml | 18 +++ deploy/provision-dogfood-users.sh | 51 ++++++++ deploy/tests/test-provision-dogfood-users.sh | 118 ++++++++++++++---- .../quota/AdminNewapiQuotaController.java | 56 +++++++++ .../aigc/mq/NewapiQuotaClaimConsumer.java | 11 +- .../service/quota/NewapiQuotaService.java | 12 ++ .../service/quota/NewapiQuotaServiceImpl.java | 32 ++++- .../quota/AdminNewapiQuotaControllerTest.java | 64 ++++++++++ .../aigc/mq/NewapiQuotaClaimConsumerTest.java | 12 +- .../quota/NewapiQuotaServiceImplTest.java | 62 +++++++++ 10 files changed, 401 insertions(+), 35 deletions(-) create mode 100644 game-cloud/game-module-aigc/game-module-aigc-server/src/main/java/com/wanxiang/huijing/game/module/aigc/controller/admin/quota/AdminNewapiQuotaController.java create mode 100644 game-cloud/game-module-aigc/game-module-aigc-server/src/test/java/com/wanxiang/huijing/game/module/aigc/controller/admin/quota/AdminNewapiQuotaControllerTest.java diff --git a/contracts/api-schemas/aigc.yaml b/contracts/api-schemas/aigc.yaml index 57b7e123..c17e9191 100644 --- a/contracts/api-schemas/aigc.yaml +++ b/contracts/api-schemas/aigc.yaml @@ -134,6 +134,24 @@ paths: application/json: schema: { $ref: '#/components/schemas/CommonResultTaskPage' } + /admin-api/aigc/quota/claims/{userId}/reconcile: + post: + tags: [admin-aigc] + summary: 幂等补偿指定玩家的 new-api 额度 claim + description: >- + 仅供受信管理端在注册消息异常后补偿单个目标玩家,后端以 aigc:quota:reconcile 权限强制鉴权, + 并确认 userId 对应真实 game_player。已有 CLAIMED 记录时直接返回成功;未绑定时通过数据库 CAS + 从 FREE 池领取一条;并发或重复调用由 claimed_by 与 biz_no 唯一键收敛,不会多领。 + 返回 true 表示调用结束时该玩家已有 CLAIMED 记录;返回 false 表示功能未启用、玩家不存在或额度池已空。 + parameters: + - { name: userId, in: path, required: true, schema: { type: integer, format: int64, minimum: 1 }, description: 待补偿的 game_player.id } + responses: + '200': + description: 补偿结果 + content: + application/json: + schema: { $ref: '#/components/schemas/CommonResultBoolean' } + # ===================== Dify 回调对接点(仅契约,不实现)===================== /admin-api/aigc/dify/callback: post: diff --git a/deploy/provision-dogfood-users.sh b/deploy/provision-dogfood-users.sh index 79031925..03f3885c 100755 --- a/deploy/provision-dogfood-users.sh +++ b/deploy/provision-dogfood-users.sh @@ -189,6 +189,42 @@ read_counts() { || die "MySQL 计数结果格式非法" } +# 只查询本次 40 个目标账号中尚无 CLAIMED 记录的 game_player.id;补偿范围以此只读结果为起点。 +missing_claim_sql() { + cat <"$TMP_ACCOUNTS" +TARGET_USER_IDS=() for index in "${!TARGET_NAMES[@]}"; do username="${TARGET_NAMES[$index]}" if [ "$index" -lt 10 ]; then role='creator'; else role='player'; fi @@ -245,6 +282,8 @@ for index in "${!TARGET_NAMES[@]}"; do fi user_id="$(json_value data.userId)" [[ "$user_id" =~ ^[0-9]+$ ]] || die "账号 $username 登录响应缺合法 userId" + is_target_user_id "$user_id" && die "多个目标账号返回同一 userId=$user_id,拒绝继续" + TARGET_USER_IDS+=("$user_id") if [ "$role" = 'creator' ]; then request "$ADMIN_TOKEN" PUT /admin-api/passport/player/set-creator \ @@ -258,6 +297,18 @@ done mkdir -p "$(dirname "$DOGFOOD_ACCOUNTS_FILE")" install -m 600 "$TMP_ACCOUNTS" "$DOGFOOD_ACCOUNTS_FILE" +# 注册消息可能已被 ACK、进入 DLQ 或尚在重试;终验前只补偿这 40 人中数据库确认缺失的目标。 +read_missing_claim_user_ids +for user_id in "${MISSING_CLAIM_USER_IDS[@]}"; do + request "$ADMIN_TOKEN" POST "/admin-api/aigc/quota/claims/$user_id/reconcile" + code="$(json_value code)" + claimed="$(json_value data)" + if [ "$LAST_HTTP" != '200' ] || [ "$code" != '0' ] || { [ "$claimed" != 'True' ] && [ "$claimed" != 'true' ]; }; then + die "userId=$user_id 额度 claim 补偿失败(http=$LAST_HTTP code=${code:-none} claimed=${claimed:-none})" + fi + log "已幂等补偿额度 claim userId=$user_id" +done + deadline=$((SECONDS + VERIFY_TIMEOUT_SEC)) while :; do read_counts diff --git a/deploy/tests/test-provision-dogfood-users.sh b/deploy/tests/test-provision-dogfood-users.sh index 7dfc0789..469515f4 100755 --- a/deploy/tests/test-provision-dogfood-users.sh +++ b/deploy/tests/test-provision-dogfood-users.sh @@ -22,7 +22,7 @@ fail() { } # 服务状态同时供假 HTTP 服务与假 MySQL 查询读取;原子替换避免读到半写文件。 -printf '{"accounts":{},"creators":{}}\n' >"$TMP_DIR/state.json" +printf '{"accounts":{},"creators":{},"claims":[],"reconcileCalls":[]}\n' >"$TMP_DIR/state.json" cat >"$TMP_DIR/fake-server.py" <<'PY' import json @@ -79,6 +79,26 @@ class Handler(BaseHTTPRequestHandler): self.response({"code": 0, "data": {"accessToken": "fake-admin-token"}}) return state = load_state() + if self.path.startswith("/admin-api/aigc/quota/claims/") and self.path.endswith("/reconcile"): + if self.headers.get("Authorization") != "Bearer fake-admin-token": + self.response({"code": 401, "msg": "未授权"}, 401) + return + try: + user_id = int(self.path.split("/")[-2]) + except (ValueError, IndexError): + self.response({"code": 400, "msg": "userId 非法"}, 400) + return + known_ids = {account["userId"] for account in state["accounts"].values()} + if user_id not in known_ids: + self.response({"code": 0, "data": False}) + return + # 模拟后端唯一键与 CAS:重复补偿只记录调用,不增加第二条 claim。 + state["reconcileCalls"].append(user_id) + if user_id not in state["claims"]: + state["claims"].append(user_id) + save_state(state) + self.response({"code": 0, "data": True}) + return if self.path == "/app-api/passport/password-register": username = body.get("username", "") if username in state["accounts"]: @@ -131,10 +151,10 @@ PY cat >"$TMP_DIR/mysql" <<'SH' #!/usr/bin/env bash -# 忽略 SQL 文本,只根据假服务状态输出与生产终验相同的六列计数。 +# 根据 SQL 返回聚合计数或 40 个目标账号中缺失 claim 的 userId。 set -euo pipefail -cat >/dev/null -python3 - "$DOGFOOD_FAKE_STATE" <<'PY' +sql="$(cat)" +DOGFOOD_FAKE_SQL="$sql" python3 - "$DOGFOOD_FAKE_STATE" <<'PY' import json import os import sys @@ -143,27 +163,37 @@ with open(sys.argv[1], encoding="utf-8") as handle: state = json.load(handle) accounts = len(state["accounts"]) creators = len(state["creators"]) -free = 52 - accounts +claims = state["claims"] +free = 52 - len(claims) mode = os.environ.get("DOGFOOD_FAKE_COUNTS_MODE", "all") -claimed = creators if mode == "creator-only" else accounts duplicates = 1 if mode == "duplicate" else 0 -print(accounts, creators, 0, claimed, duplicates, free, sep="\t") +sql = os.environ["DOGFOOD_FAKE_SQL"] +if "SELECT p.id" in sql: + missing = sorted( + account["userId"] for account in state["accounts"].values() + if account["userId"] not in claims + ) + if os.environ.get("DOGFOOD_FAKE_MISSING_MODE") == "rogue": + missing.append(999999) + print(*missing, sep="\n") + raise SystemExit(0) +print(accounts, creators, 0, len(claims), duplicates, free, sep="\t") PY SH chmod +x "$TMP_DIR/mysql" cat >"$TMP_DIR/ssh" <<'SH' #!/usr/bin/env bash -# 模拟远端只读查询:验证最后一个参数确为完整 SQL 的 Base64,再返回聚合计数。 +# 模拟远端只读查询:验证最后一个参数确为完整 SQL 的 Base64,再返回计数或缺失列表。 set -euo pipefail sql_base64="${!#}" sql="$(printf '%s' "$sql_base64" | base64 -d)" case "$sql" in - *'SELECT COUNT(DISTINCT p.id)'*) ;; - *) printf '远端未收到完整聚合 SQL\n' >&2; exit 2 ;; + *'SELECT COUNT(DISTINCT p.id)'*|*'SELECT p.id'*) ;; + *) printf '远端未收到完整狗粮账号 SQL\n' >&2; exit 2 ;; esac cat >/dev/null -python3 - "$DOGFOOD_FAKE_STATE" <<'PY' +DOGFOOD_FAKE_SQL="$sql" python3 - "$DOGFOOD_FAKE_STATE" <<'PY' import json import os import sys @@ -172,10 +202,20 @@ with open(sys.argv[1], encoding="utf-8") as handle: state = json.load(handle) accounts = len(state["accounts"]) creators = len(state["creators"]) +claims = state["claims"] mode = os.environ.get("DOGFOOD_FAKE_COUNTS_MODE", "all") -claimed = creators if mode == "creator-only" else accounts duplicates = 1 if mode == "duplicate" else 0 -print(accounts, creators, 0, claimed, duplicates, 52 - accounts, sep="\t") +sql = os.environ["DOGFOOD_FAKE_SQL"] +if "SELECT p.id" in sql: + missing = sorted( + account["userId"] for account in state["accounts"].values() + if account["userId"] not in claims + ) + if os.environ.get("DOGFOOD_FAKE_MISSING_MODE") == "rogue": + missing.append(999999) + print(*missing, sep="\n") + raise SystemExit(0) +print(accounts, creators, 0, len(claims), duplicates, 52 - len(claims), sep="\t") PY SH chmod +x "$TMP_DIR/ssh" @@ -204,6 +244,7 @@ run_provision() { MYSQL_PASSWORD='fake-mysql-password' \ MYSQL_SSH_HOST="$mysql_ssh_host" \ DOGFOOD_FAKE_COUNTS_MODE="${DOGFOOD_FAKE_COUNTS_MODE:-all}" \ + DOGFOOD_FAKE_MISSING_MODE="${DOGFOOD_FAKE_MISSING_MODE:-}" \ DOGFOOD_FAKE_STATE="$TMP_DIR/state.json" \ DOGFOOD_ACCOUNTS_FILE="$TMP_DIR/dogfood-accounts.tsv" \ VERIFY_TIMEOUT_SEC=2 \ @@ -216,9 +257,43 @@ run_provision() { [ "$expected" = pass ] || fail "缺失 claim 的终验场景被错误放行:$DOGFOOD_FAKE_COUNTS_MODE" } -# 第一遍覆盖新建路径,第二遍覆盖“用户名已存在后使用同一派生密码登录”的幂等路径。 +# 第一遍模拟 40 条注册消息全部异常:账号已创建但 claim 全缺,脚本必须逐个主动补偿。 run_provision "$TMP_DIR/first.log" +python3 - "$TMP_DIR/state.json" <<'PY' +import json, sys +with open(sys.argv[1], encoding="utf-8") as handle: + state = json.load(handle) +assert len(state["claims"]) == 40, state +assert len(state["reconcileCalls"]) == 40, state +state["reconcileCalls"] = [] +with open(sys.argv[1], "w", encoding="utf-8") as handle: + json.dump(state, handle) +PY + +# 第二遍覆盖“账号已存在且 claim 已齐”的幂等路径:不得调用任何补偿接口。 run_provision "$TMP_DIR/second.log" +python3 - "$TMP_DIR/state.json" <<'PY' +import json, sys +with open(sys.argv[1], encoding="utf-8") as handle: + state = json.load(handle) +assert state["reconcileCalls"] == [], state +# 制造一个“账号已存在但 claim 缺失”的历史失败,再验证只修这一人。 +state["claims"].remove(1007) +with open(sys.argv[1], "w", encoding="utf-8") as handle: + json.dump(state, handle) +PY +run_provision "$TMP_DIR/recover-one.log" +python3 - "$TMP_DIR/state.json" <<'PY' +import json, sys +with open(sys.argv[1], encoding="utf-8") as handle: + state = json.load(handle) +assert state["reconcileCalls"] == [1007], state +assert len(state["claims"]) == 40, state +state["reconcileCalls"] = [] +with open(sys.argv[1], "w", encoding="utf-8") as handle: + json.dump(state, handle) +PY + # 第三遍覆盖从开发机调用时的远端 MySQL 只读查询传参路径。 run_provision "$TMP_DIR/remote.log" 'fake-remote' @@ -230,7 +305,7 @@ grep -q $'^dogfoodp30\tplayer\t' "$TMP_DIR/dogfood-accounts.tsv" || fail "缺少 mode="$(stat -f '%Lp' "$TMP_DIR/dogfood-accounts.tsv" 2>/dev/null || stat -c '%a' "$TMP_DIR/dogfood-accounts.tsv")" [ "$mode" = '600' ] || fail "凭据文件权限不是 600:$mode" -for log_file in "$TMP_DIR/first.log" "$TMP_DIR/second.log" "$TMP_DIR/remote.log"; do +for log_file in "$TMP_DIR/first.log" "$TMP_DIR/second.log" "$TMP_DIR/recover-one.log" "$TMP_DIR/remote.log"; do grep -q 'PROVISION_PASS accounts=40 creators=10 role_mismatches=0 claimed_accounts=40 duplicate_claims=0 FREE=12' "$log_file" \ || fail "缺少终验通过证据:$log_file" ! grep -q 'test-seed-must-not-appear' "$log_file" || fail "日志泄漏 seed" @@ -238,12 +313,11 @@ for log_file in "$TMP_DIR/first.log" "$TMP_DIR/second.log" "$TMP_DIR/remote.log" ! grep -q 'fake-admin-token\|fake-player-token\|fake-mysql-password' "$log_file" || fail "日志泄漏 token 或数据库密码" done -# 旧终验只核了 10 个 creator 的 claim;其余 30 人没额度也会假绿。现在必须明确拒绝。 -DOGFOOD_FAKE_COUNTS_MODE=creator-only run_provision "$TMP_DIR/creator-only-claims.log" '' fail -if ! rg -q 'claimed_accounts=10' "$TMP_DIR/creator-only-claims.log"; then - sed -n '1,80p' "$TMP_DIR/creator-only-claims.log" >&2 - fail "未报告只有 10 个账号 claim" -fi +# 数据库缺失查询若返回本次 40 人之外的编号,脚本必须在请求后端前拒绝,防止越权消耗额度。 +DOGFOOD_FAKE_MISSING_MODE=rogue run_provision "$TMP_DIR/rogue-missing-id.log" '' fail +rg -q '不属于本次 40 个目标账号' "$TMP_DIR/rogue-missing-id.log" || fail "未拒绝越界补偿 userId" + +# 重复 claim 属数据不变量破坏,不能通过补偿接口掩盖。 DOGFOOD_FAKE_COUNTS_MODE=duplicate run_provision "$TMP_DIR/duplicate-claims.log" '' fail rg -q 'duplicate_claims=1' "$TMP_DIR/duplicate-claims.log" || fail "未报告重复 claim" @@ -258,4 +332,4 @@ if PROVISION_ACK='PROVISION_DOGFOOD_40_USERS_WITH_TEMP_CONFIG' DOGFOOD_PASSWORD_ fail "非法 BASE host 未被拒绝" fi -printf 'TEST_PASS provision-dogfood-users 40/40 单 claim、角色、幂等、脱敏与权限校验通过\n' +printf 'TEST_PASS provision-dogfood-users 缺失 claim 补偿、40/40 单 claim、越权拒绝、幂等与脱敏校验通过\n' diff --git a/game-cloud/game-module-aigc/game-module-aigc-server/src/main/java/com/wanxiang/huijing/game/module/aigc/controller/admin/quota/AdminNewapiQuotaController.java b/game-cloud/game-module-aigc/game-module-aigc-server/src/main/java/com/wanxiang/huijing/game/module/aigc/controller/admin/quota/AdminNewapiQuotaController.java new file mode 100644 index 00000000..eb92a8ec --- /dev/null +++ b/game-cloud/game-module-aigc/game-module-aigc-server/src/main/java/com/wanxiang/huijing/game/module/aigc/controller/admin/quota/AdminNewapiQuotaController.java @@ -0,0 +1,56 @@ +package com.wanxiang.huijing.game.module.aigc.controller.admin.quota; + +import com.wanxiang.huijing.game.module.aigc.service.quota.NewapiQuotaService; +import com.wanxiang.huijing.framework.common.pojo.CommonResult; +import com.wanxiang.huijing.framework.security.core.util.SecurityFrameworkUtils; +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.tags.Tag; +import jakarta.annotation.Resource; +import jakarta.validation.constraints.Min; +import lombok.extern.slf4j.Slf4j; +import org.springframework.security.access.prepost.PreAuthorize; +import org.springframework.validation.annotation.Validated; +import org.springframework.web.bind.annotation.PathVariable; +import org.springframework.web.bind.annotation.PostMapping; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RestController; + +import static com.wanxiang.huijing.framework.common.pojo.CommonResult.success; + +/** + * 管理后台 - new-api 玩家额度 claim 补偿入口。 + * + *

该入口只处理注册消息异常后的单玩家补偿,不接受批量任意 SQL 或 token。权限由后端独立 RBAC 强制, + * 目标玩家还会在 Service 层二次确认;操作人从登录态读取并写日志,便于审计追踪。 + * + * @author 绘境AI + */ +@Slf4j +@Tag(name = "管理后台 - new-api 额度补偿") +@RestController +@RequestMapping("/aigc/quota") +@Validated +public class AdminNewapiQuotaController { + + /** new-api 玩家额度池服务。 */ + @Resource + private NewapiQuotaService quotaService; + + /** + * 幂等补偿单个玩家的额度 claim。 + * + * @param userId 待补偿的 game_player.id + * @return 调用结束时该玩家是否已有 CLAIMED 记录 + */ + @PostMapping("/claims/{userId}/reconcile") + @Operation(summary = "幂等补偿指定玩家的 new-api 额度 claim") + @PreAuthorize("@ss.hasPermission('aigc:quota:reconcile')") + public CommonResult reconcileClaim(@PathVariable("userId") @Min(1) Long userId) { + Long operatorUserId = SecurityFrameworkUtils.getLoginUserId(); + boolean claimed = quotaService.reconcileClaim(userId); + log.info("[newapi-quota-reconcile] 管理端补偿请求完成 operatorUserId={}, targetUserId={}, claimed={}", + operatorUserId, userId, claimed); + return success(claimed); + } + +} diff --git a/game-cloud/game-module-aigc/game-module-aigc-server/src/main/java/com/wanxiang/huijing/game/module/aigc/mq/NewapiQuotaClaimConsumer.java b/game-cloud/game-module-aigc/game-module-aigc-server/src/main/java/com/wanxiang/huijing/game/module/aigc/mq/NewapiQuotaClaimConsumer.java index af778e5a..5f3482c9 100644 --- a/game-cloud/game-module-aigc/game-module-aigc-server/src/main/java/com/wanxiang/huijing/game/module/aigc/mq/NewapiQuotaClaimConsumer.java +++ b/game-cloud/game-module-aigc/game-module-aigc-server/src/main/java/com/wanxiang/huijing/game/module/aigc/mq/NewapiQuotaClaimConsumer.java @@ -18,8 +18,8 @@ import org.springframework.stereotype.Component; *

装配开关:随 {@code aigc.newapi-quota.enabled=true} 装配——关闭(默认)时不起消费者、不连 NameServer, * WU2 整体未启用时零副作用(与 {@code NewapiQuotaServiceImpl} 主开关同源)。 * - *

幂等 + 不外抛:claim 幂等由 service 层 uk + 单 CAS 保证(重投/并发收敛成一条);池空/异常已在 service 内吞 - * (池空是离线补池才能恢复、非 MQ 重试窗口内可恢复,抛异常只灌 DLQ 空转)。此处防御性兜底 catch,正常返回=ack,不无限重投。 + *

幂等 + 可恢复:claim 幂等由 service 层 uk + 单 CAS 保证(重投/并发收敛成一条);池空由 service 按业务结果处理, + * 数据库或框架瞬时异常必须继续外抛给 RocketMQ 重试,不能正常返回后错误 ACK。超过消息重试窗口仍可由可信管理端补偿入口修复。 * 消费模型 = 默认 CLUSTERING(每条消息集群内单实例消费)。 * * @author 绘境AI @@ -55,10 +55,11 @@ public class NewapiQuotaClaimConsumer implements RocketMQListener安全边界由管理端 Controller 的独立 RBAC 权限承担;Service 仍会确认目标编号对应真实 + * {@code game_player},防止误把系统用户或不存在编号绑定到额度池。已有记录直接复用,缺失时使用与注册、 + * 派发相同的 CAS 与唯一键收敛,并发或重复调用不会多领。 + * + * @param gamePlayerId 待补偿的 game_player.id + * @return true=调用结束时玩家已有 CLAIMED 记录;false=功能关闭、玩家不存在或额度池为空 + */ + boolean reconcileClaim(Long gamePlayerId); + /** * 派发期解析 per-user token(§3.6)——per-user 门只对真实 member 在线创作生效。 * 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 d1d807d6..41d94001 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 @@ -75,6 +75,29 @@ public class NewapiQuotaServiceImpl implements NewapiQuotaService { }); } + // ================== 可信管理端补偿 ================== + + @Override + public boolean reconcileClaim(Long gamePlayerId) { + if (!enabled) { + log.warn("[newapi-quota-reconcile] 补偿被拒绝:额度池功能未启用 gamePlayerId={}", gamePlayerId); + return false; + } + if (gamePlayerId == null) { + log.warn("[newapi-quota-reconcile] 补偿被拒绝:gamePlayerId 为空"); + return false; + } + // 可信入口仍执行实体身份校验,避免管理员误传 system 用户编号后消耗一条额度。 + PlayerRespDTO player = playerApi.getPlayer(gamePlayerId).getCheckedData(); + if (player == null) { + log.warn("[newapi-quota-reconcile] 补偿被拒绝:目标不是有效 game_player gamePlayerId={}", gamePlayerId); + return false; + } + boolean claimed = TenantUtils.executeIgnore(() -> tryClaimCore(gamePlayerId, "admin-reconcile")); + log.info("[newapi-quota-reconcile] 补偿完成 gamePlayerId={}, claimed={}", gamePlayerId, claimed); + return claimed; + } + // ================== §3.6 派发期 per-user 门 ================== @Override @@ -148,9 +171,12 @@ public class NewapiQuotaServiceImpl implements NewapiQuotaService { return false; } catch (DuplicateKeyException e) { // 并发同玩家 claim:两路各抢一条不同 FREE,第二路写 claimed_by/biz_no 撞 uk_claimed_by/uk_biz_no → - // 幂等收敛为一条(该玩家已被并发路绑好),视为成功复用,不抛。 - log.info("[newapi-quota] 并发同玩家 claim 收敛(uk 拦截),幂等复用 scene={}, gamePlayerId={}", scene, gamePlayerId); - return true; + // 冲突后必须复查该玩家的真实 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; } } diff --git a/game-cloud/game-module-aigc/game-module-aigc-server/src/test/java/com/wanxiang/huijing/game/module/aigc/controller/admin/quota/AdminNewapiQuotaControllerTest.java b/game-cloud/game-module-aigc/game-module-aigc-server/src/test/java/com/wanxiang/huijing/game/module/aigc/controller/admin/quota/AdminNewapiQuotaControllerTest.java new file mode 100644 index 00000000..67c0f440 --- /dev/null +++ b/game-cloud/game-module-aigc/game-module-aigc-server/src/test/java/com/wanxiang/huijing/game/module/aigc/controller/admin/quota/AdminNewapiQuotaControllerTest.java @@ -0,0 +1,64 @@ +package com.wanxiang.huijing.game.module.aigc.controller.admin.quota; + +import com.wanxiang.huijing.game.module.aigc.service.quota.NewapiQuotaService; +import com.wanxiang.huijing.framework.security.core.util.SecurityFrameworkUtils; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.InjectMocks; +import org.mockito.Mock; +import org.mockito.MockedStatic; +import org.mockito.junit.jupiter.MockitoExtension; +import org.springframework.security.access.prepost.PreAuthorize; + +import java.lang.reflect.Method; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertNotNull; +import static org.junit.jupiter.api.Assertions.assertTrue; +import static org.mockito.Mockito.mockStatic; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +/** + * {@link AdminNewapiQuotaController} 可信补偿入口测试。 + * + *

权限测试直接检查后端方法上的 {@link PreAuthorize},防止只在前端隐藏按钮造成可绕过; + * 委托测试确认目标 userId 与当前管理员身份均来自可信边界并进入可追溯日志链路。 + * + * @author 绘境AI + */ +@ExtendWith(MockitoExtension.class) +class AdminNewapiQuotaControllerTest { + + @Mock + private NewapiQuotaService quotaService; + + @InjectMocks + private AdminNewapiQuotaController controller; + + /** 补偿端点必须具备独立写权限,不能复用普通查询权限。 */ + @Test + void testReconcileClaim_hasTrustedAdminPermission() throws Exception { + Method method = AdminNewapiQuotaController.class.getMethod("reconcileClaim", Long.class); + PreAuthorize preAuthorize = method.getAnnotation(PreAuthorize.class); + + assertNotNull(preAuthorize, "额度补偿端点必须在后端挂 PreAuthorize"); + assertTrue(preAuthorize.value().contains("aigc:quota:reconcile"), + "额度补偿端点必须使用独立的 aigc:quota:reconcile 权限"); + } + + /** 管理员调用只传目标 userId,操作人从安全上下文读取并留痕。 */ + @Test + void testReconcileClaim_delegatesTargetAndReturnsResult() { + when(quotaService.reconcileClaim(5L)).thenReturn(true); + try (MockedStatic security = mockStatic(SecurityFrameworkUtils.class)) { + security.when(SecurityFrameworkUtils::getLoginUserId).thenReturn(99L); + + assertEquals(Boolean.TRUE, controller.reconcileClaim(5L).getData()); + + security.verify(SecurityFrameworkUtils::getLoginUserId); + verify(quotaService).reconcileClaim(5L); + } + } + +} diff --git a/game-cloud/game-module-aigc/game-module-aigc-server/src/test/java/com/wanxiang/huijing/game/module/aigc/mq/NewapiQuotaClaimConsumerTest.java b/game-cloud/game-module-aigc/game-module-aigc-server/src/test/java/com/wanxiang/huijing/game/module/aigc/mq/NewapiQuotaClaimConsumerTest.java index 5decbfbd..bd23f79f 100644 --- a/game-cloud/game-module-aigc/game-module-aigc-server/src/test/java/com/wanxiang/huijing/game/module/aigc/mq/NewapiQuotaClaimConsumerTest.java +++ b/game-cloud/game-module-aigc/game-module-aigc-server/src/test/java/com/wanxiang/huijing/game/module/aigc/mq/NewapiQuotaClaimConsumerTest.java @@ -8,6 +8,7 @@ import org.mockito.InjectMocks; import org.mockito.Mock; import static org.junit.jupiter.api.Assertions.assertDoesNotThrow; +import static org.junit.jupiter.api.Assertions.assertThrows; import static org.mockito.ArgumentMatchers.any; import static org.mockito.ArgumentMatchers.anyString; import static org.mockito.ArgumentMatchers.eq; @@ -16,9 +17,10 @@ import static org.mockito.Mockito.never; import static org.mockito.Mockito.verify; /** - * {@link NewapiQuotaClaimConsumer} 单元测试(纯 Mockito)——把守「委托 service 做渠道过滤+claim」与「不外抛」契约。 + * {@link NewapiQuotaClaimConsumer} 单元测试(纯 Mockito)——把守「委托 service 做渠道过滤+claim」与消息重试契约。 * - *

覆盖:正常事件透传 userId+channel 给 service;毒消息(体空/无 userId)丢弃不委托不抛;service 异常被兜底吞(不无限重投)。 + *

覆盖:正常事件透传 userId+channel 给 service;毒消息(体空/无 userId)丢弃不委托不抛; + * service 瞬时异常必须向 RocketMQ 外抛,避免消息被错误 ACK。 * * @author 绘境AI */ @@ -59,11 +61,11 @@ class NewapiQuotaClaimConsumerTest extends BaseMockitoUnitTest { verify(quotaService, never()).claimForRegister(any(), anyString()); } - /** service 异常:兜底吞掉不外抛(正常返回=ack,不让 MQ 无限重投)。 */ + /** service 瞬时异常:必须外抛触发 RocketMQ 重试,不能正常返回后错误 ACK。 */ @Test - void testOnMessage_serviceThrows_swallowedNoThrow() { + void testOnMessage_serviceThrows_rethrowForMqRetry() { doThrow(new RuntimeException("db down")).when(quotaService).claimForRegister(eq(5L), eq("password")); - assertDoesNotThrow(() -> consumer.onMessage(event(5L, "password"))); + assertThrows(RuntimeException.class, () -> consumer.onMessage(event(5L, "password"))); } } 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 f06b7ebd..4355546c 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 @@ -17,8 +17,10 @@ import java.time.LocalDateTime; import static org.junit.jupiter.api.Assertions.assertDoesNotThrow; import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; import static org.junit.jupiter.api.Assertions.assertThrows; import static org.junit.jupiter.api.Assertions.assertNull; +import static org.junit.jupiter.api.Assertions.assertTrue; import static org.mockito.ArgumentMatchers.any; import static org.mockito.ArgumentMatchers.anyLong; import static org.mockito.ArgumentMatchers.anyString; @@ -134,6 +136,66 @@ class NewapiQuotaServiceImplTest extends BaseMockitoUnitTest { verifyNoInteractions(poolMapper, playerApi); } + // ===================== 可信管理端补偿 ===================== + + /** 已存在但 claim 缺失的真实玩家:补偿入口执行一次 CAS,并返回最终已绑定。 */ + @Test + void testReconcileClaim_missingClaim_claimsOnce() { + 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(1); + + assertTrue(service.reconcileClaim(5L)); + + verify(poolMapper).claimOneFree(eq(5L), eq("claim_5"), any(LocalDateTime.class)); + } + + /** 重复补偿:已有 CLAIMED 记录直接成功,不再占用第二条 FREE。 */ + @Test + void testReconcileClaim_existingClaim_idempotentNoCas() { + when(playerApi.getPlayer(5L)).thenReturn(CommonResult.success(member(5L))); + when(poolMapper.selectClaimedByPlayer(5L)).thenReturn(claimedEntry(5L, "sk-EXIST")); + + assertTrue(service.reconcileClaim(5L)); + + verify(poolMapper, never()).claimOneFree(anyLong(), anyString(), any(LocalDateTime.class)); + } + + /** 并发补偿:唯一键冲突后复查到同一玩家已绑定,视为幂等成功且不多领。 */ + @Test + void testReconcileClaim_concurrentDuplicate_rechecksAndReturnsSuccess() { + 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))) + .thenThrow(new DuplicateKeyException("uk_claimed_by 冲突")); + + assertTrue(service.reconcileClaim(5L)); + + verify(poolMapper).claimOneFree(eq(5L), eq("claim_5"), any(LocalDateTime.class)); + } + + /** 越权目标防线:不存在的玩家不能消耗额度池。 */ + @Test + void testReconcileClaim_unknownPlayer_rejectedWithoutPoolAccess() { + when(playerApi.getPlayer(404L)).thenReturn(CommonResult.success((PlayerRespDTO) null)); + + assertFalse(service.reconcileClaim(404L)); + + verifyNoInteractions(poolMapper); + } + + /** 主开关关闭时补偿入口也必须关闭,不能绕过额度池发布开关。 */ + @Test + void testReconcileClaim_disabled_rejectedWithoutDependencies() { + ReflectionTestUtils.setField(service, "enabled", false); + + assertFalse(service.reconcileClaim(5L)); + + verifyNoInteractions(playerApi, poolMapper); + } + // ===================== §3.6 派发 per-user 门 ===================== /** 主开关关:派发解析旁路返 null,不查身份、不查池。 */