fix(release): reconcile missing dogfood quota claims
This commit is contained in:
parent
ce1b38bfb3
commit
34da791f26
@ -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:
|
||||
|
||||
@ -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 player;BASE 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
|
||||
|
||||
@ -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'
|
||||
|
||||
@ -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);
|
||||
}
|
||||
|
||||
}
|
||||
@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@ -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.6)——per-user 门只对真实 member 在线创作生效。
|
||||
*
|
||||
|
||||
@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
@ -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")));
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
@ -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,不查身份、不查池。 */
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user