fix(release): reconcile missing dogfood quota claims

This commit is contained in:
lili 2026-07-22 22:57:16 -07:00
parent ce1b38bfb3
commit 34da791f26
10 changed files with 401 additions and 35 deletions

View File

@ -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:

View File

@ -189,6 +189,42 @@ read_counts() {
|| die "MySQL 计数结果格式非法"
}
# 只查询本次 40 个目标账号中尚无 CLAIMED 记录的 game_player.id补偿范围以此只读结果为起点。
missing_claim_sql() {
cat <<SQL
SELECT p.id
FROM game_player p
LEFT JOIN newapi_quota_pool q
ON q.claimed_by_game_player_id=p.id AND q.status='CLAIMED' AND q.deleted=b'0'
WHERE p.username IN ($TARGET_SQL) AND p.deleted=b'0' AND q.id IS NULL
ORDER BY p.id;
SQL
}
# 登录返回的 40 个 userId 是本轮允许补偿的白名单;数据库返回值必须再次落在该集合内。
is_target_user_id() {
local expected="$1" candidate
for candidate in "${TARGET_USER_IDS[@]}"; do
[ "$candidate" = "$expected" ] && return 0
done
return 1
}
read_missing_claim_user_ids() {
local raw user_id candidate
raw="$(mysql_query "$(missing_claim_sql)")" || die "MySQL 缺失 claim 查询失败"
MISSING_CLAIM_USER_IDS=()
while IFS= read -r user_id; do
[ -n "$user_id" ] || continue
[[ "$user_id" =~ ^[0-9]+$ ]] || die "MySQL 缺失 claim 查询返回非法 userId$user_id"
is_target_user_id "$user_id" || die "缺失 claim 的 userId=$user_id 不属于本次 40 个目标账号,拒绝越界补偿"
for candidate in "${MISSING_CLAIM_USER_IDS[@]}"; do
[ "$candidate" != "$user_id" ] || die "MySQL 缺失 claim 查询返回重复 userId$user_id"
done
MISSING_CLAIM_USER_IDS+=("$user_id")
done <<<"$raw"
}
log "预置目标10 creator + 30 playerBASE host=$BASE_HOST"
log "运行前置:临时 dogfood=false、password-register/login=true、注册与登录 IP 时/日上限均为 60结束后恢复狗粮配置。"
@ -217,6 +253,7 @@ ADMIN_TOKEN="$(json_value data.accessToken)"
[ -n "$ADMIN_TOKEN" ] || die "admin 登录响应缺 accessToken"
printf 'username\trole\tuser_id\tpassword\n' >"$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

View File

@ -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'

View File

@ -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 补偿入口
*
* <p>该入口只处理注册消息异常后的单玩家补偿不接受批量任意 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<Boolean> 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);
}
}

View File

@ -18,8 +18,8 @@ import org.springframework.stereotype.Component;
* <p><b>装配开关</b> {@code aigc.newapi-quota.enabled=true} 装配关闭默认时不起消费者不连 NameServer
* WU2 整体未启用时零副作用 {@code NewapiQuotaServiceImpl} 主开关同源
*
* <p><b>幂等 + 不外抛</b>claim 幂等由 service uk + CAS 保证重投/并发收敛成一条池空/异常已在 service 内吞
* 池空是离线补池才能恢复 MQ 重试窗口内可恢复抛异常只灌 DLQ 空转此处防御性兜底 catch正常返回=ack不无限重投
* <p><b>幂等 + 可恢复</b>claim 幂等由 service uk + CAS 保证重投/并发收敛成一条池空 service 按业务结果处理
* 数据库或框架瞬时异常必须继续外抛给 RocketMQ 重试不能正常返回后错误 ACK超过消息重试窗口仍可由可信管理端补偿入口修复
* 消费模型 = 默认 CLUSTERING每条消息集群内单实例消费
*
* @author 绘境AI
@ -55,10 +55,11 @@ public class NewapiQuotaClaimConsumer implements RocketMQListener<PassportRegist
try {
quotaService.claimForRegister(event.getUserId(), event.getRegisterChannel());
log.info("[newapi-quota-mq] 消费注册成功事件已处置 userId={}, channel={}", event.getUserId(), event.getRegisterChannel());
} catch (Exception e) {
// 防御性兜底service 内已吞池空/常规失败此处只兜意外异常正常返回=ack不让 MQ 无限重投风暴
log.error("[newapi-quota-mq] 消费注册事件异常(吞掉不重投,待补池后懒 claim 兜)userId={}, channel={}",
} catch (RuntimeException e) {
// 瞬时数据库/框架异常必须交给 RocketMQ 重试正常返回会 ACK导致账号永久缺 claim
log.error("[newapi-quota-mq] 消费注册事件异常,交由 RocketMQ 重试 userId={}, channel={}",
event.getUserId(), event.getRegisterChannel(), e);
throw e;
}
}

View File

@ -30,6 +30,18 @@ public interface NewapiQuotaService {
*/
void claimForRegister(Long gamePlayerId, String registerChannel);
/**
* 可信管理端 claim 补偿注册消息异常或历史账号缺失 claim 按目标玩家编号幂等修复
*
* <p>安全边界由管理端 Controller 的独立 RBAC 权限承担Service 仍会确认目标编号对应真实
* {@code game_player}防止误把系统用户或不存在编号绑定到额度池已有记录直接复用缺失时使用与注册
* 派发相同的 CAS 与唯一键收敛并发或重复调用不会多领
*
* @param gamePlayerId 待补偿的 game_player.id
* @return true=调用结束时玩家已有 CLAIMED 记录false=功能关闭玩家不存在或额度池为空
*/
boolean reconcileClaim(Long gamePlayerId);
/**
* 派发期解析 per-user token§3.6per-user 门只对真实 member 在线创作生效
*

View File

@ -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;
}
}

View File

@ -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} 可信补偿入口测试
*
* <p>权限测试直接检查后端方法上的 {@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<SecurityFrameworkUtils> 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);
}
}
}

View File

@ -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消息重试契约
*
* <p>覆盖正常事件透传 userId+channel service毒消息体空/ userId丢弃不委托不抛service 异常被兜底吞不无限重投
* <p>覆盖正常事件透传 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")));
}
}

View File

@ -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不查身份、不查池。 */