diff --git a/deploy/activate-staging-rc.sh b/deploy/activate-staging-rc.sh index 63e2c6f9..ab63f648 100755 --- a/deploy/activate-staging-rc.sh +++ b/deploy/activate-staging-rc.sh @@ -7,6 +7,9 @@ RC_COMMIT="${RC_COMMIT:-}" RC_HOST="${RC_HOST:-root@100.64.0.7}" RC_ROOT="${RC_ROOT:-/root/game-staging/releases}" GENERATION_ATTESTATION_ROOT="${GENERATION_ATTESTATION_ROOT:-/root/game-staging/attestations}" +GENERATION_GATE_RUN_ID="${GENERATION_GATE_RUN_ID:-}" +GENERATION_ATTESTATION_EXPECTED_ISSUER="${GENERATION_ATTESTATION_EXPECTED_ISSUER:-}" +GENERATION_ATTESTATION_TRUSTED_PUBLIC_KEY_FILE="${GENERATION_ATTESTATION_TRUSTED_PUBLIC_KEY_FILE:-}" BOOTSTRAP_ADMIN_TOKEN_FILE="${BOOTSTRAP_ADMIN_TOKEN_FILE:-}" ACTIVATION_TIMEOUT_SEC="${ACTIVATION_TIMEOUT_SEC:-180}" ACTIVATION_MODE="activate" @@ -60,6 +63,18 @@ if [ -n "$STORAGE_AUDIT_REF" ] \ printf 'STORAGE_AUDIT_REF 非法:只接受 sha256:<64hex>:evidence/storage-audit.tsv;留空则远端实时生成。\n' >&2 exit 2 fi +if [[ ! "$GENERATION_GATE_RUN_ID" =~ ^[A-Za-z0-9][A-Za-z0-9._-]{0,127}$ ]]; then + printf 'GENERATION_GATE_RUN_ID 必填,且只能包含安全的 run-id 字符。\n' >&2 + exit 2 +fi +if [[ ! "$GENERATION_ATTESTATION_EXPECTED_ISSUER" =~ ^[A-Za-z0-9][A-Za-z0-9._-]{0,127}$ ]]; then + printf 'GENERATION_ATTESTATION_EXPECTED_ISSUER 必填,且只能包含安全的签发者标识。\n' >&2 + exit 2 +fi +if [[ ! "$GENERATION_ATTESTATION_TRUSTED_PUBLIC_KEY_FILE" =~ ^/[A-Za-z0-9._/-]+$ ]]; then + printf 'GENERATION_ATTESTATION_TRUSTED_PUBLIC_KEY_FILE 必填,且必须是发布机上的安全绝对路径。\n' >&2 + exit 2 +fi if [ "$ACTIVATION_MODE" = activate ] && [[ ! "$BOOTSTRAP_ADMIN_TOKEN_FILE" =~ ^/[A-Za-z0-9._/-]+$ ]]; then printf 'BOOTSTRAP_ADMIN_TOKEN_FILE 必填,且必须是 mini-desktop 上的安全绝对路径。\n' >&2 exit 2 @@ -75,7 +90,9 @@ fi "$SSH_BIN" -o ProxyCommand=none "$RC_HOST" bash -s -- \ "$RC_COMMIT" "$RC_ROOT" "$ACTIVATION_TIMEOUT_SEC" "$STORAGE_AUDIT_REF" \ "$GENERATION_GATE_EVIDENCE_REF" "$GENERATION_ATTESTATION_ROOT" "$ACTIVATION_MODE" \ - "$BOOTSTRAP_ADMIN_TOKEN_FILE" <<'REMOTE_SCRIPT' + "$BOOTSTRAP_ADMIN_TOKEN_FILE" "$GENERATION_GATE_RUN_ID" \ + "$GENERATION_ATTESTATION_EXPECTED_ISSUER" \ + "$GENERATION_ATTESTATION_TRUSTED_PUBLIC_KEY_FILE" <<'REMOTE_SCRIPT' set -Eeuo pipefail commit="$1" @@ -86,6 +103,9 @@ expected_generation_gate_ref="$5" attestation_root="$6" activation_mode="$7" bootstrap_admin_token_file="$8" +expected_generation_gate_run_id="$9" +expected_attestation_issuer="${10}" +trusted_attestation_public_key_file="${11}" release="$root/$commit" active_pointer="$root/active" stable_prefix="gda-staging" @@ -107,6 +127,10 @@ bootstrap_token_validated=0 account_provision_run_id="${run_id}-dogfood" account_provision_cleanup_manifest="$evidence/account-provision/provision-cleanup.tsv" runtime_cleanup_rc=not-run +maintenance_chain="GDA_STAGING_MAINT" +maintenance_ports="48080,4173,4174,8300,9501" +maintenance_gate_active=0 +IPTABLES_BIN="${IPTABLES_BIN:-iptables}" umask 077 if [ "$activation_mode" = activate ]; then @@ -155,6 +179,58 @@ stop_stack() { done } +maintenance_rule_present() { + "$IPTABLES_BIN" -w 5 -C "$maintenance_chain" -p tcp -m multiport \ + --dports "$maintenance_ports" -j REJECT >/dev/null 2>&1 \ + && "$IPTABLES_BIN" -w 5 -C INPUT ! -i lo -j "$maintenance_chain" >/dev/null 2>&1 +} + +install_maintenance_gate() { + command -v "$IPTABLES_BIN" >/dev/null 2>&1 || { + log_step "缺 iptables,无法封锁正式入口" + return 1 + } + + # 固定链允许失败后的下一次激活接管遗留维护态;规则插在 INPUT 首位,覆盖已建立连接。 + if ! "$IPTABLES_BIN" -w 5 -nL "$maintenance_chain" >/dev/null 2>&1; then + "$IPTABLES_BIN" -w 5 -N "$maintenance_chain" || return 1 + fi + if ! "$IPTABLES_BIN" -w 5 -C "$maintenance_chain" -p tcp -m multiport \ + --dports "$maintenance_ports" -j REJECT >/dev/null 2>&1; then + "$IPTABLES_BIN" -w 5 -A "$maintenance_chain" -p tcp -m multiport \ + --dports "$maintenance_ports" -j REJECT || return 1 + fi + if ! "$IPTABLES_BIN" -w 5 -C INPUT ! -i lo -j "$maintenance_chain" >/dev/null 2>&1; then + "$IPTABLES_BIN" -w 5 -I INPUT 1 ! -i lo -j "$maintenance_chain" || return 1 + fi + # INPUT 跳转一旦存在,本次事务就必须负责解除;即使后续复核失败,回滚也不能遗留维护门。 + maintenance_gate_active=1 + maintenance_rule_present || { + log_step "正式入口维护门安装后复核失败" + return 1 + } + printf 'maintenance_gate=BLOCKED chain=%s ports=%s\n' "$maintenance_chain" "$maintenance_ports" \ + >"$evidence/maintenance-gate.status" + log_step "正式入口维护门 BLOCKED:仅允许 loopback 执行迁移、预置与 smoke" +} + +release_maintenance_gate() { + [ "$maintenance_gate_active" -eq 1 ] || return 0 + maintenance_rule_present || { + log_step "维护门状态漂移,拒绝按成功路径开放入口" + return 1 + } + while "$IPTABLES_BIN" -w 5 -C INPUT ! -i lo -j "$maintenance_chain" >/dev/null 2>&1; do + "$IPTABLES_BIN" -w 5 -D INPUT ! -i lo -j "$maintenance_chain" || return 1 + done + "$IPTABLES_BIN" -w 5 -F "$maintenance_chain" || return 1 + "$IPTABLES_BIN" -w 5 -X "$maintenance_chain" || return 1 + maintenance_gate_active=0 + printf 'maintenance_gate=OPENED chain=%s ports=%s\n' "$maintenance_chain" "$maintenance_ports" \ + >"$evidence/maintenance-gate.status" + log_step "正式入口维护门 OPENED" +} + capture_journals() { local prefix="$1" label="$2" service unit mkdir -p "$evidence/journal-$label" @@ -299,16 +375,33 @@ start_stack() { launch_dir="$(write_launchers "$target" "$label" "$backend_port" "$studio_port" "$admin_port" \ "$agent_port" "$worker_port" "$discovery_enabled")" - run_unit "$(unit_name "$prefix" cheap-agent)" "$launch_dir/cheap-agent.sh" - wait_port "$label cheap-agent" "$agent_port" - run_unit "$(unit_name "$prefix" cheap-worker)" "$launch_dir/cheap-worker.sh" - wait_json_health "$label cheap-worker" "http://127.0.0.1:$worker_port/health" ok true - run_unit "$(unit_name "$prefix" backend)" "$launch_dir/backend.sh" - wait_json_health "$label backend" "http://127.0.0.1:$backend_port/actuator/health" status UP - run_unit "$(unit_name "$prefix" studio)" "$launch_dir/studio.sh" - wait_url "$label studio" "http://127.0.0.1:$studio_port/" - run_unit "$(unit_name "$prefix" admin)" "$launch_dir/admin.sh" - wait_url "$label admin" "http://127.0.0.1:$admin_port/" + # failure_handler 会关闭 errexit;每一步显式返回,避免最后一个服务成功掩盖前序失败。 + run_unit "$(unit_name "$prefix" cheap-agent)" "$launch_dir/cheap-agent.sh" || return 1 + wait_port "$label cheap-agent" "$agent_port" || return 1 + run_unit "$(unit_name "$prefix" cheap-worker)" "$launch_dir/cheap-worker.sh" || return 1 + wait_json_health "$label cheap-worker" "http://127.0.0.1:$worker_port/health" ok true || return 1 + run_unit "$(unit_name "$prefix" backend)" "$launch_dir/backend.sh" || return 1 + wait_json_health "$label backend" "http://127.0.0.1:$backend_port/actuator/health" status UP || return 1 + run_unit "$(unit_name "$prefix" studio)" "$launch_dir/studio.sh" || return 1 + wait_url "$label studio" "http://127.0.0.1:$studio_port/" || return 1 + run_unit "$(unit_name "$prefix" admin)" "$launch_dir/admin.sh" || return 1 + wait_url "$label admin" "http://127.0.0.1:$admin_port/" || return 1 +} + +verify_stack() { + local label="$1" prefix="$2" backend_port="$3" studio_port="$4" admin_port="$5" + local agent_port="$6" worker_port="$7" + + systemctl is-active --quiet "$(unit_name "$prefix" cheap-agent)" || return 1 + wait_port "$label cheap-agent" "$agent_port" || return 1 + systemctl is-active --quiet "$(unit_name "$prefix" cheap-worker)" || return 1 + wait_json_health "$label cheap-worker" "http://127.0.0.1:$worker_port/health" ok true || return 1 + systemctl is-active --quiet "$(unit_name "$prefix" backend)" || return 1 + wait_json_health "$label backend" "http://127.0.0.1:$backend_port/actuator/health" status UP || return 1 + systemctl is-active --quiet "$(unit_name "$prefix" studio)" || return 1 + wait_url "$label studio" "http://127.0.0.1:$studio_port/" || return 1 + systemctl is-active --quiet "$(unit_name "$prefix" admin)" || return 1 + wait_url "$label admin" "http://127.0.0.1:$admin_port/" || return 1 } start_candidate_probe() { @@ -345,7 +438,8 @@ valid_prepared_release() { verify_generation_gate_evidence() { local ref="$1" expected_commit="$2" hash_and_path expected_hash relative_path actual_hash - local source_path canonical_root canonical_source component walk mode + local source_path signature_path canonical_root canonical_source canonical_signature component walk mode + local public_key_mode public_key_mode_decimal canonical_public_key hash_and_path="${ref#sha256:}" expected_hash="${hash_and_path%%:*}" relative_path="${hash_and_path#*:}" @@ -391,11 +485,49 @@ verify_generation_gate_evidence() { return 1 } - # 证据由 R1 执行方生成,激活器只验证,不创建或改写任何“通过”结论。 - python3 - "$source_path" "$expected_commit" <<'PY' + signature_path="$source_path.sig" + [ -f "$signature_path" ] && [ ! -L "$signature_path" ] || { + log_step "生成门 detached signature 缺失或不是普通文件 path=$relative_path.sig" + return 1 + } + canonical_signature="$(realpath "$signature_path")" || return 1 + [ "$canonical_signature" = "$signature_path" ] || return 1 + mode="$(stat -c '%a' "$signature_path" 2>/dev/null || stat -f '%Lp' "$signature_path")" + [ "$mode" = 600 ] || { + log_step "生成门 detached signature 权限必须为 0600 actual=$mode" + return 1 + } + + # 发布机只持有信任公钥;私钥由独立 R1 签发方保管,不能通过发布权限伪造 PASS。 + [ -f "$trusted_attestation_public_key_file" ] && [ ! -L "$trusted_attestation_public_key_file" ] || { + log_step "R1 信任公钥缺失或不是普通文件" + return 1 + } + canonical_public_key="$(realpath "$trusted_attestation_public_key_file")" || return 1 + [ "$canonical_public_key" = "$trusted_attestation_public_key_file" ] || { + log_step "R1 信任公钥必须使用 canonical 绝对路径" + return 1 + } + public_key_mode="$(stat -c '%a' "$trusted_attestation_public_key_file" 2>/dev/null \ + || stat -f '%Lp' "$trusted_attestation_public_key_file")" + public_key_mode_decimal=$((8#$public_key_mode)) + if (( (public_key_mode_decimal & 022) != 0 )); then + log_step "R1 信任公钥不得允许 group/other 写入 actual=$public_key_mode" + return 1 + fi + openssl pkeyutl -verify -pubin -inkey "$trusted_attestation_public_key_file" -rawin \ + -in "$source_path" -sigfile "$signature_path" >/dev/null 2>&1 || { + log_step "生成门 detached signature 验证失败" + return 1 + } + + # 证据由 R1 执行方生成,激活器只验证签名、身份、运行批次与有效期,不创建 PASS 结论。 + python3 - "$source_path" "$expected_commit" "$expected_generation_gate_run_id" \ + "$expected_attestation_issuer" <<'PY' +from datetime import datetime, timedelta, timezone import sys -path, expected_commit = sys.argv[1:] +path, expected_commit, expected_run_id, expected_issuer = sys.argv[1:] values = {} with open(path, encoding="utf-8") as handle: for line_number, raw in enumerate(handle, 1): @@ -411,8 +543,10 @@ with open(path, encoding="utf-8") as handle: values[key] = value required = { - "schema": "generation-r1-gate/1", + "schema": "generation-r1-gate/2", "rc_commit": expected_commit, + "run_id": expected_run_id, + "issuer": expected_issuer, "r1_status": "PASS", "actor_prompt_eval": "PASS", "judge_prompt_eval": "PASS", @@ -428,6 +562,24 @@ for key, expected in required.items(): actual = values.get(key) if actual != expected: raise SystemExit(f"生成门证据未通过 {key}: expected={expected} actual={actual!r}") + +def parse_utc(key): + value = values.get(key) + try: + parsed = datetime.strptime(value, "%Y-%m-%dT%H:%M:%SZ").replace(tzinfo=timezone.utc) + except (TypeError, ValueError) as error: + raise SystemExit(f"生成门证据时间非法 {key}: {value!r}") from error + return parsed + +issued_at = parse_utc("issued_at") +expires_at = parse_utc("expires_at") +now = datetime.now(timezone.utc) +if issued_at > now: + raise SystemExit("生成门证据尚未生效") +if expires_at <= now: + raise SystemExit("生成门证据已过期") +if expires_at <= issued_at or expires_at - issued_at > timedelta(hours=24): + raise SystemExit("生成门证据有效期必须大于 0 且不超过 24 小时") PY printf 'generation_gate_ref=%s\ngeneration_gate_path=%s\n' "$ref" "$relative_path" \ @@ -544,9 +696,20 @@ failure_handler() { stop_stack "$stable_prefix" if [ "$previous_was_active" -eq 1 ] && [ "$previous_valid" -eq 1 ]; then log_step "恢复旧版本服务 target=$previous" - start_stack "$previous" rollback "$stable_prefix" 48080 4173 4174 8300 9501 true - rollback_rc=$? + rollback_rc=0 + start_stack "$previous" rollback "$stable_prefix" 48080 4173 4174 8300 9501 true \ + || rollback_rc=$? + if [ "$rollback_rc" -eq 0 ]; then + verify_stack rollback "$stable_prefix" 48080 4173 4174 8300 9501 || rollback_rc=$? + fi + if [ "$rollback_rc" -eq 0 ]; then + release_maintenance_gate || rollback_rc=$? + fi printf 'rollback_service_rc=%s\n' "$rollback_rc" >"$evidence/rollback-result.txt" + if [ "$rollback_rc" -ne 0 ]; then + log_step "旧栈五服务恢复或复核失败,保留维护门" + rc=73 + fi else printf 'rollback_service_rc=not-available\nFIRST_RC_FULL_ROLLBACK=UNAVAILABLE\n' >"$evidence/rollback-result.txt" fi @@ -649,6 +812,9 @@ switch_started=1 capture_journals "$stable_prefix" pre-switch-old stop_stack "$stable_prefix" +# 从停写快照前到账号事务、审核轮换和 smoke 全部完成,正式端口只允许本机 loopback 访问。 +install_maintenance_gate + # 旧栈停写后再生成补偿快照,避免候选探针期间的新写入在失败恢复时丢失。 log_step "迁移前数据库快照" docker inspect game-staging-mysql >/dev/null @@ -808,6 +974,7 @@ stop_stack "$candidate_prefix" rm -f -- "$account_provision_cleanup_manifest" chmod -R go-rwx "$evidence" "$release/RC_STATUS" delete_bootstrap_token +release_maintenance_gate trap - ERR log_step "RC_ACTIVATION_PASS commit=$commit active=$release" log_step "ACTIVATION_EVIDENCE=$evidence" diff --git a/deploy/dogfood-ops/dogfood_ops.py b/deploy/dogfood-ops/dogfood_ops.py index 5ca7c1da..0cb7f386 100755 --- a/deploy/dogfood-ops/dogfood_ops.py +++ b/deploy/dogfood-ops/dogfood_ops.py @@ -40,7 +40,14 @@ JSON_TEMPLATES: dict[str, dict[str, Any]] = { "config-identity.json": {"schema": "dogfood-config-identity/1", "capturedAt": None, "configSha256": None, "configBundleRef": None, "rcCommit": None}, "snapshot.json": {"schema": "dogfood-snapshot/1", "mysql": {}, "isolatedRestore": {}}, "storage-audit.json": {"schema": "dogfood-storage-audit/1", "result": None, "capturedAt": None, "reportRef": None, "reportSha256": None}, - "accounts.json": {"schema": "dogfood-accounts/1", "batchId": None, "creatorCount": None, "playerCount": None, "freeQuotaCount": None}, + "accounts.json": { + "schema": "dogfood-accounts/2", + "activationRunId": None, + "databaseReportRef": None, + "databaseReportSha256": None, + "databaseReportCapturedAt": None, + "selectedAccounts": [], + }, "role-permissions.json": {"schema": "dogfood-role-permissions/1", "checks": []}, "main-chain.json": {"schema": MAIN_CHAIN_SCHEMA, "identity": {}, "stages": []}, "release-negatives.json": {"schema": "dogfood-release-negatives/1", "checks": []}, @@ -339,18 +346,159 @@ class EvidenceGate: self.check_hash_ref(storage.get("reportRef"), storage.get("reportSha256"), "storage-audit.report") def validate_accounts(self) -> None: - """校验波次账号规模、批次身份和额度余量。""" - accounts = self.load_json("accounts.json", "dogfood-accounts/1") + """以绑定激活批次的数据库报告校验 40 个账号、12 条预留额度和本波成员。""" + accounts = self.load_json("accounts.json", "dogfood-accounts/2") if accounts is None or self.manifest is None: return - for key in ("creatorCount", "playerCount"): - if accounts.get(key) != self.manifest[key]: - self.fail(f"accounts.{key}", f"{key} 不符合批准波次规模") - required_quota = math.ceil(self.manifest["creatorCount"] * 1.2) - if not isinstance(accounts.get("freeQuotaCount"), int) or accounts["freeQuotaCount"] < required_quota: - self.fail("accounts.free-quota", f"FREE 额度必须至少为 {required_quota}") - if not isinstance(accounts.get("batchId"), str) or not accounts["batchId"]: - self.fail("accounts.batch-id", "账号必须绑定非空 batchId") + activation_run_id = accounts.get("activationRunId") + if not isinstance(activation_run_id, str) or not re.fullmatch( + r"[A-Za-z0-9][A-Za-z0-9._-]{0,127}", activation_run_id): + self.fail("accounts.activation-run-id", "账号证据必须绑定合法 activation run-id") + + report_path = self.checked_hash_ref( + accounts.get("databaseReportRef"), + accounts.get("databaseReportSha256"), + "accounts.database-report", + ) + def checked_report_time(value: Any, context: str) -> datetime | None: + """激活快照可早于波次开始,但不得陈旧超过两小时或晚于当前可信时钟。""" + parsed = parse_timestamp(value) + if parsed is None: + self.fail("accounts.database-report.captured-at", f"{context} 必须是带时区 ISO 8601 时间") + return None + parsed = parsed.astimezone(timezone.utc) + earliest = (self.started_at or self.now) - timedelta(hours=2) + if parsed < earliest or parsed > self.now: + self.fail( + "accounts.database-report.captured-at", + f"{context} 必须满足 wave startedAt-2h <= capturedAt <= now:{value}", + ) + return None + return parsed + + declared_captured_at = checked_report_time( + accounts.get("databaseReportCapturedAt"), "accounts.databaseReportCapturedAt") + if report_path is None: + return + try: + report = json.loads(report_path.read_text(encoding="utf-8")) + except (OSError, json.JSONDecodeError) as exc: + self.fail("accounts.database-report.invalid-json", f"账号数据库报告不是合法 JSON:{exc}") + return + if not isinstance(report, dict) or report.get("schema") != "dogfood-account-database-report/1": + self.fail("accounts.database-report.schema", "账号数据库报告 schema 非法") + return + if report.get("source") != "staging-mysql-readonly" \ + or not isinstance(report.get("queryId"), str) or not report["queryId"]: + self.fail("accounts.database-report.source", "账号报告必须声明 staging MySQL 只读来源和查询 ID") + if report.get("activationRunId") != activation_run_id: + self.fail("accounts.activation-run-id", "accounts.json 与数据库报告的 activation run-id 不一致") + report_captured_at = checked_report_time( + report.get("capturedAt"), "accounts.databaseReport.capturedAt") + if declared_captured_at is not None and report_captured_at is not None \ + and declared_captured_at != report_captured_at: + self.fail("accounts.database-report.captured-at", "accounts.json 未绑定报告内同一采集时间") + + expected_roles = { + **{f"dogfoodc{index:02d}": "creator" for index in range(1, 11)}, + **{f"dogfoodp{index:02d}": "player" for index in range(1, 31)}, + } + report_accounts = report.get("accounts") + if not isinstance(report_accounts, list): + self.fail("accounts.database-report.accounts", "数据库报告 accounts 必须是数组") + return + by_identity: dict[tuple[int, str], dict[str, Any]] = {} + usernames: set[str] = set() + account_ids: set[int] = set() + pool_ids: set[int] = set() + for item in report_accounts: + if not isinstance(item, dict): + self.fail("accounts.database-report.accounts", "数据库报告存在非对象账号") + continue + account_id = item.get("id") + username = item.get("username") + pool_id = item.get("quotaPoolId") + if not isinstance(account_id, int) or isinstance(account_id, bool) or account_id <= 0 \ + or not isinstance(username, str) or username not in expected_roles \ + or item.get("role") != expected_roles.get(username) \ + or account_id in account_ids or username in usernames: + self.fail("accounts.database-report.accounts", "数据库报告账号 ID、用户名、角色或唯一性非法") + continue + account_ids.add(account_id) + usernames.add(username) + if not isinstance(pool_id, int) or isinstance(pool_id, bool) or pool_id <= 0 or pool_id in pool_ids: + self.fail("accounts.database-report.account-quota", f"账号 {username} 缺少唯一额度池 ID") + else: + pool_ids.add(pool_id) + if item.get("accountStatus") != "ACTIVE" \ + or item.get("quotaStatus") != "CLAIMED" \ + or item.get("tokenStatus") != "ENABLED" \ + or not isinstance(item.get("grantQuota"), int) or item["grantQuota"] <= 0 \ + or not isinstance(item.get("tokenRemainQuota"), int) or item["tokenRemainQuota"] <= 0: + self.fail("accounts.database-report.account-quota", f"账号 {username} 的账号或额度状态不可用") + by_identity[(account_id, username)] = item + if usernames != set(expected_roles) or len(report_accounts) != len(expected_roles): + self.fail("accounts.database-report.accounts", "数据库报告必须精确包含 dogfoodc01-10 与 dogfoodp01-30") + + reserves = report.get("reserves") + reserve_pool_ids: set[int] = set() + reserve_user_ids: set[int] = set() + reserve_token_ids: set[int] = set() + reserve_usernames: set[str] = set() + if not isinstance(reserves, list) or len(reserves) != 12: + self.fail("accounts.database-report.reserve-quota", "数据库报告必须精确包含 12 条 FREE 预留额度") + else: + for item in reserves: + if not isinstance(item, dict): + self.fail("accounts.database-report.reserve-quota", "预留额度存在非对象记录") + continue + pool_id = item.get("poolId") + user_id = item.get("newapiUserId") + token_id = item.get("newapiTokenId") + username = item.get("newapiUsername") + identifiers_valid = ( + isinstance(pool_id, int) and not isinstance(pool_id, bool) and pool_id > 0 + and isinstance(user_id, int) and not isinstance(user_id, bool) and user_id > 0 + and isinstance(token_id, int) and not isinstance(token_id, bool) and token_id > 0 + and isinstance(username, str) and re.fullmatch(r"neice_[0-9]{3}", username) + and pool_id not in pool_ids and pool_id not in reserve_pool_ids + and user_id not in reserve_user_ids and token_id not in reserve_token_ids + and username not in reserve_usernames + ) + quota_valid = ( + item.get("quotaStatus") == "FREE" + and item.get("tokenStatus") == "ENABLED" + and isinstance(item.get("grantQuota"), int) and item["grantQuota"] > 0 + and isinstance(item.get("tokenRemainQuota"), int) and item["tokenRemainQuota"] > 0 + ) + if not identifiers_valid or not quota_valid: + self.fail("accounts.database-report.reserve-quota", "FREE 预留额度的身份、状态或余额非法") + continue + reserve_pool_ids.add(pool_id) + reserve_user_ids.add(user_id) + reserve_token_ids.add(token_id) + reserve_usernames.add(username) + + selected = accounts.get("selectedAccounts") + if not isinstance(selected, list): + self.fail("accounts.selected.identity", "selectedAccounts 必须是明确账号数组") + return + selected_identities: set[tuple[int, str]] = set() + selected_roles: list[str] = [] + for item in selected: + if not isinstance(item, dict) or set(item) != {"id", "username"}: + self.fail("accounts.selected.identity", "波次账号只能声明 id 与 username") + continue + identity = (item.get("id"), item.get("username")) + if identity in selected_identities or identity not in by_identity: + self.fail("accounts.selected.identity", "波次账号未精确命中权威数据库报告或存在重复") + continue + selected_identities.add(identity) + selected_roles.append(by_identity[identity]["role"]) + if selected_roles.count("creator") != self.manifest["creatorCount"] \ + or selected_roles.count("player") != self.manifest["playerCount"] \ + or len(selected_identities) != self.manifest["creatorCount"] + self.manifest["playerCount"]: + self.fail("accounts.selected.scale", "权威报告中的波次账号角色与批准规模不匹配") def validate_permissions(self) -> None: """校验角色权限用例和截图清单。""" diff --git a/deploy/dogfood-ops/tests/test_dogfood_ops.py b/deploy/dogfood-ops/tests/test_dogfood_ops.py index 2c1f777d..1c06142f 100644 --- a/deploy/dogfood-ops/tests/test_dogfood_ops.py +++ b/deploy/dogfood-ops/tests/test_dogfood_ops.py @@ -283,6 +283,94 @@ class DogfoodOpsTest(unittest.TestCase): self.assertNotIn("main-chain.rc-artifact", result.stdout) self.assertNotIn("main-chain.game-artifact", result.stdout) + def test_accounts_rejects_self_reported_counts_without_database_report(self) -> None: + """自填人数和 FREE 数不能替代绑定激活批次的权威数据库报告。""" + wave_dir = self.init_wave("wave0") + self.populate_valid_evidence(wave_dir) + self.write_json(wave_dir / "accounts.json", { + "schema": "dogfood-accounts/1", + "batchId": "forged-batch", + "creatorCount": 3, + "playerCount": 5, + "freeQuotaCount": 12, + }) + + result = self.run_tool("check", "--wave-dir", str(wave_dir), "--phase", "readiness") + + self.assertNotEqual(result.returncode, 0) + self.assertIn("accounts.json schema 必须为 dogfood-accounts/2", result.stdout) + + def test_accounts_rejects_report_with_incomplete_canonical_account_set(self) -> None: + """权威报告必须明确覆盖 dogfoodc01-10 与 dogfoodp01-30,不能只报波次人数。""" + wave_dir = self.init_wave("wave0") + self.populate_valid_evidence(wave_dir) + accounts = json.loads((wave_dir / "accounts.json").read_text(encoding="utf-8")) + report_path = wave_dir / accounts["databaseReportRef"] + report = json.loads(report_path.read_text(encoding="utf-8")) + report["accounts"].pop() + self.write_json(report_path, report) + accounts["databaseReportSha256"] = hashlib.sha256(report_path.read_bytes()).hexdigest() + self.write_json(wave_dir / "accounts.json", accounts) + + result = self.run_tool("check", "--wave-dir", str(wave_dir), "--phase", "readiness") + + self.assertNotEqual(result.returncode, 0) + self.assertIn("accounts.database-report.accounts", result.stdout) + + def test_accounts_rejects_activation_or_quota_status_drift(self) -> None: + """激活 run-id、CLAIMED 账号额度和 12 条 FREE 预留任一漂移都必须失败关闭。""" + for mutation, expected_code in ( + ("activation", "accounts.activation-run-id"), + ("captured-at", "accounts.database-report.captured-at"), + ("claimed-quota", "accounts.database-report.account-quota"), + ("reserve-status", "accounts.database-report.reserve-quota"), + ): + with self.subTest(mutation=mutation): + with tempfile.TemporaryDirectory() as temp_dir: + root = Path(temp_dir) / "evidence" + self.assertEqual(DOGFOOD_OPS.initialize_wave(root, "wave0"), 0) + wave_dir = root / "wave0" + self.populate_valid_evidence(wave_dir) + accounts_path = wave_dir / "accounts.json" + accounts = json.loads(accounts_path.read_text(encoding="utf-8")) + report_path = wave_dir / accounts["databaseReportRef"] + report = json.loads(report_path.read_text(encoding="utf-8")) + if mutation == "activation": + report["activationRunId"] = "different-activation" + elif mutation == "captured-at": + started_at = datetime.fromisoformat( + json.loads((wave_dir / "manifest.json").read_text(encoding="utf-8"))["startedAt"] + .replace("Z", "+00:00") + ) + stale_time = DOGFOOD_OPS.format_timestamp(started_at - timedelta(hours=3)) + report["capturedAt"] = stale_time + accounts["databaseReportCapturedAt"] = stale_time + elif mutation == "claimed-quota": + report["accounts"][0]["tokenRemainQuota"] = 0 + else: + report["reserves"][0]["quotaStatus"] = "CLAIMED" + self.write_json(report_path, report) + accounts["databaseReportSha256"] = hashlib.sha256(report_path.read_bytes()).hexdigest() + self.write_json(accounts_path, accounts) + + gate = DOGFOOD_OPS.EvidenceGate(wave_dir, "readiness") + self.assertNotEqual(gate.run(), 0) + self.assertTrue(any(code == expected_code for code, _ in gate.errors), gate.errors) + + def test_accounts_rejects_selected_identity_not_present_in_report(self) -> None: + """波次成员必须按 ID+用户名精确引用权威报告中的同一账号。""" + wave_dir = self.init_wave("wave0") + self.populate_valid_evidence(wave_dir) + accounts_path = wave_dir / "accounts.json" + accounts = json.loads(accounts_path.read_text(encoding="utf-8")) + accounts["selectedAccounts"][0]["username"] = "dogfoodc10" + self.write_json(accounts_path, accounts) + + gate = DOGFOOD_OPS.EvidenceGate(wave_dir, "readiness") + + self.assertNotEqual(gate.run(), 0) + self.assertTrue(any(code == "accounts.selected.identity" for code, _ in gate.errors), gate.errors) + def populate_valid_evidence(self, wave_dir: Path) -> None: """构造不依赖 staging 的完整本地通过 fixture。""" manifest = json.loads((wave_dir / "manifest.json").read_text(encoding="utf-8")) @@ -303,6 +391,7 @@ class DogfoodOpsTest(unittest.TestCase): log_ref, _ = self.add_attachment(wave_dir, "application.log") review_ref, _ = self.add_attachment(wave_dir, "review-binding.json") database_ref, _ = self.add_attachment(wave_dir, "database.json") + account_report_ref = "attachments/account-database-report.json" game_identity = { "projectId": "p-1", @@ -341,10 +430,67 @@ class DogfoodOpsTest(unittest.TestCase): "schema": "dogfood-storage-audit/1", "result": "PASS", "capturedAt": captured_at, "reportRef": storage_ref, "reportSha256": storage_hash, }) + all_accounts = [] + for index in range(1, 11): + all_accounts.append({ + "id": 1000 + index, + "username": f"dogfoodc{index:02d}", + "role": "creator", + "accountStatus": "ACTIVE", + "quotaPoolId": 2000 + index, + "quotaStatus": "CLAIMED", + "grantQuota": 6849315, + "tokenStatus": "ENABLED", + "tokenRemainQuota": 6849315, + }) + for index in range(1, 31): + all_accounts.append({ + "id": 1100 + index, + "username": f"dogfoodp{index:02d}", + "role": "player", + "accountStatus": "ACTIVE", + "quotaPoolId": 2100 + index, + "quotaStatus": "CLAIMED", + "grantQuota": 6849315, + "tokenStatus": "ENABLED", + "tokenRemainQuota": 6849315, + }) + reserves = [{ + "poolId": 3000 + index, + "newapiUserId": 4000 + index, + "newapiUsername": f"neice_{index:03d}", + "newapiTokenId": 5000 + index, + "quotaStatus": "FREE", + "grantQuota": 6849315, + "tokenStatus": "ENABLED", + "tokenRemainQuota": 6849315, + } for index in range(1, 13)] + activation_run_id = "activation-20260723T010203Z" + self.write_json(wave_dir / account_report_ref, { + "schema": "dogfood-account-database-report/1", + "activationRunId": activation_run_id, + "capturedAt": captured_at, + "source": "staging-mysql-readonly", + "queryId": f"account-quota-audit-{wave}", + "accounts": all_accounts, + "reserves": reserves, + }) + account_report_hash = hashlib.sha256((wave_dir / account_report_ref).read_bytes()).hexdigest() + selected = ( + all_accounts[:3] + all_accounts[10:15] + if wave == "wave0" + else all_accounts + ) self.write_json(wave_dir / "accounts.json", { - "schema": "dogfood-accounts/1", "batchId": f"dogfood-{wave}", - "creatorCount": manifest["creatorCount"], "playerCount": manifest["playerCount"], - "freeQuotaCount": 4 if wave == "wave0" else 12, + "schema": "dogfood-accounts/2", + "activationRunId": activation_run_id, + "databaseReportRef": account_report_ref, + "databaseReportSha256": account_report_hash, + "databaseReportCapturedAt": captured_at, + "selectedAccounts": [ + {"id": account["id"], "username": account["username"]} + for account in selected + ], }) permission_cases = { diff --git a/deploy/prepare-staging-rc.sh b/deploy/prepare-staging-rc.sh index ed93aee0..17b4dae9 100755 --- a/deploy/prepare-staging-rc.sh +++ b/deploy/prepare-staging-rc.sh @@ -95,8 +95,11 @@ security_java_tests=( DogfoodRbacSeedTest TokenAuthenticationFilterTest AuthenticationEntryPointImplTest + DifyCallbackServiceImplTest + GameVersionServiceImplTest PassportServiceImplTest ProjectServiceImplTest + PublishOrchestrationServiceImplTest RuntimePackageServiceImplTest RuntimePackageApiImplDogfoodTest AdminRuntimeControllerDogfoodTest diff --git a/deploy/provision-dogfood-users.sh b/deploy/provision-dogfood-users.sh index 69cbd488..993e227b 100755 --- a/deploy/provision-dogfood-users.sh +++ b/deploy/provision-dogfood-users.sh @@ -52,8 +52,38 @@ require_env() { done } +# SQL 始终经 stdin 送入客户端;密码只放 MYSQL_PWD,不出现在命令行与日志。 +mysql_query() { + local sql="$1" + if [ -n "$MYSQL_SSH_HOST" ] && [ "$MYSQL_SSH_HOST" != '__AUTO__' ]; then + # SQL(可能包含短生命周期 bearer)只经 stdin 传输,绝不进入 ssh 进程参数。 + printf '%s' "$sql" | ssh -o ProxyCommand=none "$MYSQL_SSH_HOST" bash -c ' +set -euo pipefail +env_file="$1"; host="$2"; port="$3"; database="$4"; user="$5" +[ -f "$env_file" ] || { printf "远端 MySQL 环境文件不存在\n" >&2; exit 2; } +set -a; . "$env_file"; set +a +: "${MYSQL_ROOT_PASSWORD:?远端环境文件缺 MYSQL_ROOT_PASSWORD}" +MYSQL_PWD="$MYSQL_ROOT_PASSWORD" mysql --batch --skip-column-names \ + -h "$host" -P "$port" -u "$user" "$database" +' -- "$MYSQL_ENV_FILE" "$MYSQL_HOST" "$MYSQL_PORT" "$MYSQL_DATABASE" "$MYSQL_USER" + return + fi + if [ -z "${MYSQL_PASSWORD:-}" ]; then + [ -f "$MYSQL_ENV_FILE" ] || die "本地查询需 MYSQL_PASSWORD 或 MYSQL_ENV_FILE=$MYSQL_ENV_FILE" + set -a + # shellcheck disable=SC1090 + . "$MYSQL_ENV_FILE" + set +a + MYSQL_PASSWORD="${MYSQL_ROOT_PASSWORD:-}" + fi + [ -n "${MYSQL_PASSWORD:-}" ] || die "无法取得 MySQL 查询密码" + printf '%s' "$sql" | MYSQL_PWD="$MYSQL_PASSWORD" "$MYSQL_BIN" --batch --skip-column-names \ + -h "$MYSQL_HOST" -P "$MYSQL_PORT" -u "$MYSQL_USER" "$MYSQL_DATABASE" +} + cleanup_runtime_manifest() { - local manifest="$1" expected_run_id="$2" keys_file key cleanup_trap deleted=0 + local manifest="$1" expected_run_id="$2" refs_file sql_file db_result_file redis_scan_file keys_file + local key cleanup_trap deleted=0 oauth_count=0 revoked_count=0 [ -f "$manifest" ] && [ ! -L "$manifest" ] || die '运行态清理清单必须是非 symlink 普通文件' [ "$(stat -c '%a' "$manifest" 2>/dev/null || stat -f '%Lp' "$manifest")" = 600 ] || \ die '运行态清理清单权限必须为 0600' @@ -61,10 +91,15 @@ cleanup_runtime_manifest() { [[ "$REDIS_PORT" =~ ^[0-9]+$ && "$REDIS_DATABASE" =~ ^[0-9]+$ ]] || die 'Redis 端口或 database 非法' require_env REDIS_PASSWORD command -v "$REDIS_CLI_BIN" >/dev/null || die "缺少 Redis CLI:$REDIS_CLI_BIN" + refs_file="$(mktemp)" + sql_file="$(mktemp)" + db_result_file="$(mktemp)" + redis_scan_file="$(mktemp)" keys_file="$(mktemp)" - printf -v cleanup_trap 'rm -f -- %q' "$keys_file" + printf -v cleanup_trap 'rm -f -- %q %q %q %q %q' \ + "$refs_file" "$sql_file" "$db_result_file" "$redis_scan_file" "$keys_file" trap "$cleanup_trap" EXIT - python3 - "$manifest" "$expected_run_id" >"$keys_file" <<'PY' + python3 - "$manifest" "$expected_run_id" >"$refs_file" <<'PY' import os,re,stat,sys path,expected_run_id=sys.argv[1:] metadata=os.stat(path,follow_symlinks=False) @@ -73,6 +108,7 @@ if not stat.S_ISREG(metadata.st_mode) or stat.S_IMODE(metadata.st_mode) != 0o600 schema=None run_id=None keys=[] +oauth=[] with open(path,encoding="utf-8") as handle: for raw in handle: raw=raw.rstrip("\n") @@ -82,20 +118,140 @@ with open(path,encoding="utf-8") as handle: if kind == "schema": schema=value elif kind == "run_id": run_id=value elif kind == "key": keys.append(value) + elif kind == "oauth": + parts=value.split() + if len(parts) != 4: raise SystemExit("OAuth 清理引用格式非法") + access_id,refresh_id,access_hash,refresh_hash=parts + if not access_id.isdigit() or not refresh_id.isdigit() or int(access_id) <= 0 or int(refresh_id) <= 0: + raise SystemExit("OAuth 清理主键非法") + if not re.fullmatch(r"[0-9a-f]{64}",access_hash) or not re.fullmatch(r"[0-9a-f]{64}",refresh_hash): + raise SystemExit("OAuth 清理摘要非法") + oauth.append((access_id,refresh_id,access_hash,refresh_hash)) else: raise SystemExit("运行态清理清单字段非法") -if schema != "dogfood-provision-cleanup/1" or run_id != expected_run_id: +if schema != "dogfood-provision-cleanup/2" or run_id != expected_run_id: raise SystemExit("运行态清理清单 schema 或 run-id 不匹配") rate=re.compile( r"passport_ip:(?:register|login):(?:hour:[0-9A-Fa-f:.]+:[0-9]{10}|day:[0-9A-Fa-f:.]+:[0-9]{8})" ) -token=re.compile(r"oauth2_access_token:[A-Za-z0-9_-]{16,256}") unique=[] for key in keys: - if not (rate.fullmatch(key) or token.fullmatch(key)): + if not rate.fullmatch(key): raise SystemExit("运行态清理 key 越界") if key not in unique: unique.append(key) -print(*unique,sep="\n") +if len(set(oauth)) != len(oauth): raise SystemExit("OAuth 清理引用重复") +for key in unique: print("key",key,sep="\t") +for row in oauth: print("oauth",*row,sep="\t") PY + oauth_count="$(awk -F '\t' '$1=="oauth" {count++} END {print count+0}' "$refs_file")" + if [ "$oauth_count" -gt 0 ]; then + python3 - "$refs_file" >"$sql_file" <<'PY' +import sys +rows=[] +with open(sys.argv[1],encoding="utf-8") as handle: + for raw in handle: + parts=raw.rstrip("\n").split("\t") + if parts[0] == "oauth": rows.append(parts[1:]) +values=",".join( + f"({access_id},{refresh_id},'{access_hash}','{refresh_hash}')" + for access_id,refresh_id,access_hash,refresh_hash in rows +) +print("START TRANSACTION;") +print("CREATE TEMPORARY TABLE dogfood_oauth_revoke_refs (access_id BIGINT PRIMARY KEY, refresh_id BIGINT UNIQUE, access_hash CHAR(64), refresh_hash CHAR(64));") +print(f"INSERT INTO dogfood_oauth_revoke_refs VALUES {values};") +print(f"SET @dogfood_target_count={len(rows)};") +print("SELECT CONCAT('TARGET\\t',@dogfood_target_count);") +print("SELECT CONCAT('PAIR\\t',x.access_id,'\\t',x.refresh_id,'\\t',IF(a.id IS NULL,0,1),'\\t',IF(a.id IS NOT NULL AND x.access_hash=SHA2(a.access_token,256) AND x.refresh_hash=SHA2(a.refresh_token,256),1,0),'\\t',IF(r.id IS NULL,0,1),'\\t',IF(r.id IS NOT NULL AND x.refresh_hash=SHA2(r.refresh_token,256),1,0)) FROM dogfood_oauth_revoke_refs x LEFT JOIN system_oauth2_access_token a ON a.id=x.access_id LEFT JOIN system_oauth2_refresh_token r ON r.id=x.refresh_id ORDER BY x.access_id FOR UPDATE;") +print("SELECT COUNT(*) INTO @dogfood_valid_pairs FROM dogfood_oauth_revoke_refs x LEFT JOIN system_oauth2_access_token a ON a.id=x.access_id LEFT JOIN system_oauth2_refresh_token r ON r.id=x.refresh_id WHERE (a.id IS NULL AND r.id IS NULL) OR (a.id IS NOT NULL AND r.id IS NOT NULL AND x.access_hash=SHA2(a.access_token,256) AND x.refresh_hash=SHA2(a.refresh_token,256) AND x.refresh_hash=SHA2(r.refresh_token,256)) FOR UPDATE;") +print("SELECT CONCAT('VALID_PAIRS\\t',@dogfood_valid_pairs);") +print(f"DELETE a FROM system_oauth2_access_token a JOIN dogfood_oauth_revoke_refs x ON x.access_id=a.id AND x.access_hash=SHA2(a.access_token,256) AND x.refresh_hash=SHA2(a.refresh_token,256) WHERE @dogfood_valid_pairs={len(rows)};") +print("SET @dogfood_access_revoked=ROW_COUNT();") +print(f"DELETE r FROM system_oauth2_refresh_token r JOIN dogfood_oauth_revoke_refs x ON x.refresh_id=r.id AND x.refresh_hash=SHA2(r.refresh_token,256) WHERE @dogfood_valid_pairs={len(rows)};") +print("SET @dogfood_refresh_revoked=ROW_COUNT();") +print("SELECT CONCAT('REVOKED\\t',@dogfood_access_revoked,'\\t',@dogfood_refresh_revoked);") +print("SELECT CONCAT('REMAINING\\t',(SELECT COUNT(*) FROM system_oauth2_access_token a JOIN dogfood_oauth_revoke_refs x ON x.access_id=a.id),'\\t',(SELECT COUNT(*) FROM system_oauth2_refresh_token r JOIN dogfood_oauth_revoke_refs x ON x.refresh_id=r.id));") +print("COMMIT;") +PY + mysql_query "$(<"$sql_file")" >"$db_result_file" || die 'OAuth MySQL 撤销事务失败' + python3 - "$db_result_file" "$refs_file" "$oauth_count" >"$keys_file" <<'PY' +import sys +path,refs_path,expected=sys.argv[1],sys.argv[2],int(sys.argv[3]) +expected_pairs=set() +with open(refs_path,encoding="utf-8") as handle: + for raw in handle: + parts=raw.rstrip("\n").split("\t") + if parts[0] == "oauth": expected_pairs.add((int(parts[1]),int(parts[2]))) +target=[] +valid_pairs=[] +revoked=[] +remaining=[] +pairs={} +with open(path,encoding="utf-8") as handle: + for raw in handle: + parts=raw.rstrip("\n").split("\t") + if parts == [""]: continue + kind=parts[0] + if kind == "TARGET" and len(parts) == 2: target.append(parts[1:]) + elif kind == "VALID_PAIRS" and len(parts) == 2: valid_pairs.append(parts[1:]) + elif kind == "REVOKED" and len(parts) == 3: revoked.append(parts[1:]) + elif kind == "REMAINING" and len(parts) == 3: remaining.append(parts[1:]) + elif kind == "PAIR" and len(parts) == 7: + if not all(part.isdigit() for part in parts[1:]): + raise SystemExit("OAuth MySQL PAIR 结果格式非法") + identity=(int(parts[1]),int(parts[2])) + if identity in pairs: raise SystemExit("OAuth MySQL PAIR 结果重复") + pairs[identity]=tuple(map(int,parts[3:])) + else: raise SystemExit("OAuth MySQL 撤销结果包含未知字段") +scalar_groups=(target,valid_pairs,revoked,remaining) +if any(len(group) != 1 for group in scalar_groups) or any( + not all(value.isdigit() for value in group[0]) for group in scalar_groups): + raise SystemExit("OAuth MySQL 撤销结果格式非法") +target_count=int(target[0][0]) +valid_count=int(valid_pairs[0][0]) +revoked_access,revoked_refresh=map(int,revoked[0]) +remaining_access,remaining_refresh=map(int,remaining[0]) +if target_count != expected or expected_pairs != set(pairs) or len(expected_pairs) != expected: + raise SystemExit("OAuth MySQL 撤销目标不匹配") +active_count=0 +for identity,state in pairs.items(): + if state == (1,1,1,1): active_count += 1 + elif state != (0,0,0,0): + raise SystemExit(f"OAuth MySQL 撤销状态非法:access_id={identity[0]} refresh_id={identity[1]}") +if valid_count != expected: + raise SystemExit("OAuth MySQL 撤销状态非法:valid_pairs 与目标数不一致") +if (revoked_access,revoked_refresh) != (active_count,active_count): + raise SystemExit("OAuth MySQL 撤销状态非法:删除数与存在对数不一致") +if (remaining_access,remaining_refresh) != (0,0): + raise SystemExit("OAuth MySQL 撤销状态非法:目标主键仍有残留") +PY + revoked_count="$oauth_count" + fi + # OAuth access token 的 Redis key 可由不可逆摘要在受限命名空间内重新定位,支持 DB 已撤销后的幂等重试。 + REDISCLI_AUTH="$REDIS_PASSWORD" "$REDIS_CLI_BIN" -h "$REDIS_HOST" -p "$REDIS_PORT" \ + -n "$REDIS_DATABASE" --scan --pattern 'oauth2_access_token:*' >"$redis_scan_file" || \ + die 'OAuth Redis 缓存扫描失败' + python3 - "$refs_file" "$redis_scan_file" >"$keys_file" <<'PY' +import hashlib,re,sys +refs_path,scan_path=sys.argv[1:] +hashes=set() +with open(refs_path,encoding="utf-8") as handle: + for raw in handle: + parts=raw.rstrip("\n").split("\t") + if parts[0] == "oauth": hashes.update((parts[3],parts[4])) +matched={digest:set() for digest in hashes} +pattern=re.compile(r"oauth2_access_token:([A-Za-z0-9_-]{16,256})") +with open(scan_path,encoding="utf-8") as handle: + for raw in handle: + key=raw.rstrip("\n") + found=pattern.fullmatch(key) + if not found: raise SystemExit("Redis 扫描返回越界 key") + digest=hashlib.sha256(found.group(1).encode()).hexdigest() + if digest in hashes: matched[digest].add(key) +if any(len(keys) > 1 for keys in matched.values()): + raise SystemExit("OAuth 摘要命中多个 Redis key") +for keys in matched.values(): + if keys: print(next(iter(keys))) +PY + awk -F '\t' '$1=="key" {print $2}' "$refs_file" >>"$keys_file" while IFS= read -r key; do [ -n "$key" ] || continue # key 经 stdin 传入,避免 OAuth token 出现在进程参数或日志。 @@ -104,9 +260,10 @@ PY die '定向 Redis 清理失败' deleted=$((deleted + 1)) done <"$keys_file" - rm -f -- "$manifest" "$keys_file" + rm -f -- "$manifest" "$refs_file" "$sql_file" "$db_result_file" "$redis_scan_file" "$keys_file" trap - EXIT - printf 'DOGFOOD_RUNTIME_CLEANUP_PASS run_id=%s keys=%s\n' "$expected_run_id" "$deleted" + printf 'DOGFOOD_RUNTIME_CLEANUP_PASS run_id=%s oauth=%s keys=%s\n' \ + "$expected_run_id" "$revoked_count" "$deleted" } if [ "$MODE" = cleanup ]; then @@ -201,7 +358,7 @@ request() { mkdir -p "$(dirname "$DOGFOOD_CLEANUP_MANIFEST_FILE")" { - printf 'schema dogfood-provision-cleanup/1\n' + printf 'schema dogfood-provision-cleanup/2\n' printf 'run_id %s\n' "$DOGFOOD_PROVISION_RUN_ID" } >"$DOGFOOD_CLEANUP_MANIFEST_FILE" chmod 600 "$DOGFOOD_CLEANUP_MANIFEST_FILE" @@ -215,14 +372,24 @@ record_rate_keys() { } record_oauth_tokens() { - local access_token="$1" refresh_token="$2" token - for token in "$access_token" "$refresh_token"; do - [ -n "$token" ] || continue - [ "${#token}" -ge 16 ] && [ "${#token}" -le 256 ] \ - && [[ "$token" =~ ^[A-Za-z0-9_-]+$ ]] || \ - die '后端返回的 OAuth token 格式非法,拒绝写入清理清单' - printf 'key oauth2_access_token:%s\n' "$token" >>"$DOGFOOD_CLEANUP_MANIFEST_FILE" - done + local access_token="$1" refresh_token="$2" row + if [ -z "$access_token" ] && [ -z "$refresh_token" ]; then + return + fi + [ "${#access_token}" -ge 16 ] && [ "${#access_token}" -le 256 ] \ + && [[ "$access_token" =~ ^[A-Za-z0-9_-]+$ ]] \ + && [ "${#refresh_token}" -ge 16 ] && [ "${#refresh_token}" -le 256 ] \ + && [[ "$refresh_token" =~ ^[A-Za-z0-9_-]+$ ]] || \ + die '后端返回的 OAuth token 对格式非法,拒绝生成撤销引用' + row="$(mysql_query "SELECT a.id, r.id, SHA2(a.access_token,256), SHA2(r.refresh_token,256) +FROM system_oauth2_access_token a +JOIN system_oauth2_refresh_token r ON r.refresh_token=a.refresh_token +WHERE a.access_token='$access_token' AND r.refresh_token='$refresh_token';")" || \ + die '无法从 MySQL 解析 OAuth token 主键' + [[ "$row" =~ ^[0-9]+$'\t'[0-9]+$'\t'[0-9a-f]{64}$'\t'[0-9a-f]{64}$ ]] || \ + die 'OAuth token 主键或摘要查询结果非法' + grep -Fqx "oauth ${row//$'\t'/ }" "$DOGFOOD_CLEANUP_MANIFEST_FILE" 2>/dev/null || \ + printf 'oauth %s\n' "${row//$'\t'/ }" >>"$DOGFOOD_CLEANUP_MANIFEST_FILE" } json_value() { @@ -261,37 +428,6 @@ print(hmac.new(seed,username,hashlib.sha256).hexdigest()[:28]) PY } -# MySQL 查询只返回聚合计数。密码只经 MYSQL_PWD 传给客户端,不出现在命令行与日志。 -mysql_query() { - local sql="$1" - if [ -n "$MYSQL_SSH_HOST" ]; then - local sql_base64 - sql_base64="$(printf '%s' "$sql" | base64 | tr -d '\n')" - ssh -o ProxyCommand=none "$MYSQL_SSH_HOST" bash -s -- \ - "$MYSQL_ENV_FILE" "$MYSQL_HOST" "$MYSQL_PORT" "$MYSQL_DATABASE" "$MYSQL_USER" "$sql_base64" <<'REMOTE' -set -euo pipefail -env_file="$1"; host="$2"; port="$3"; database="$4"; user="$5"; sql_base64="$6" -[ -f "$env_file" ] || { printf '远端 MySQL 环境文件不存在\n' >&2; exit 2; } -set -a; . "$env_file"; set +a -: "${MYSQL_ROOT_PASSWORD:?远端环境文件缺 MYSQL_ROOT_PASSWORD}" -printf '%s' "$sql_base64" | base64 -d | MYSQL_PWD="$MYSQL_ROOT_PASSWORD" mysql --batch --skip-column-names \ - -h "$host" -P "$port" -u "$user" "$database" -REMOTE - return - fi - if [ -z "${MYSQL_PASSWORD:-}" ]; then - [ -f "$MYSQL_ENV_FILE" ] || die "本地查询需 MYSQL_PASSWORD 或 MYSQL_ENV_FILE=$MYSQL_ENV_FILE" - set -a - # shellcheck disable=SC1090 - . "$MYSQL_ENV_FILE" - set +a - MYSQL_PASSWORD="${MYSQL_ROOT_PASSWORD:-}" - fi - [ -n "${MYSQL_PASSWORD:-}" ] || die "无法取得 MySQL 只读查询密码" - printf '%s' "$sql" | MYSQL_PWD="$MYSQL_PASSWORD" "$MYSQL_BIN" --batch --skip-column-names \ - -h "$MYSQL_HOST" -P "$MYSQL_PORT" -u "$MYSQL_USER" "$MYSQL_DATABASE" -} - TARGET_NAMES=() for i in $(seq 1 10); do printf -v suffix '%02d' "$i" diff --git a/deploy/tests/test-provision-dogfood-users.sh b/deploy/tests/test-provision-dogfood-users.sh index 6f76cf19..aea86dcf 100755 --- a/deploy/tests/test-provision-dogfood-users.sh +++ b/deploy/tests/test-provision-dogfood-users.sh @@ -84,7 +84,7 @@ class Handler(BaseHTTPRequestHandler): "/app-api/trade/withdraw/page?" ): authorization = self.headers.get("Authorization") - if authorization == "Bearer fake-player-token": + if authorization and authorization.startswith("Bearer fakeAccessTokenLogin"): self.response({"code": 1106008000, "data": None}) else: # mock token 已关闭:test1 或无 token 都先被可信鉴权边界拒绝。 @@ -139,7 +139,14 @@ class Handler(BaseHTTPRequestHandler): "userId": user_id, } save_state(state) - self.response({"code": 0, "data": {"userId": user_id}}) + self.response({ + "code": 0, + "data": { + "userId": user_id, + "accessToken": f"fakeAccessTokenRegister{user_id}", + "refreshToken": f"fakeRefreshTokenRegister{user_id}", + }, + }) return if self.path == "/app-api/passport/password-login": account = state["accounts"].get(body.get("username", "")) @@ -148,7 +155,11 @@ class Handler(BaseHTTPRequestHandler): return self.response({ "code": 0, - "data": {"userId": account["userId"], "accessToken": "fake-player-token"}, + "data": { + "userId": account["userId"], + "accessToken": f"fakeAccessTokenLogin{account['userId']}", + "refreshToken": f"fakeRefreshTokenLogin{account['userId']}", + }, }) return self.response({"code": 404}, 404) @@ -192,7 +203,9 @@ set -euo pipefail sql="$(cat)" DOGFOOD_FAKE_SQL="$sql" python3 - "$DOGFOOD_FAKE_STATE" <<'PY' import json +import hashlib import os +import re import sys with open(sys.argv[1], encoding="utf-8") as handle: @@ -204,6 +217,21 @@ free = 52 - len(claims) mode = os.environ.get("DOGFOOD_FAKE_COUNTS_MODE", "all") duplicates = 1 if mode == "duplicate" else 0 sql = os.environ["DOGFOOD_FAKE_SQL"] +if "FROM system_oauth2_access_token a" in sql: + tokens = re.findall(r"a\.access_token='([A-Za-z0-9_-]+)'.*r\.refresh_token='([A-Za-z0-9_-]+)'", sql, re.S) + if len(tokens) != 1: + raise SystemExit("OAuth 主键查询没有携带唯一 token 对") + access_token, refresh_token = tokens[0] + numeric = int(re.search(r"([0-9]+)$", access_token).group(1)) + kind_offset = 0 if "Register" in access_token else 100000 + print( + numeric + kind_offset, + numeric + kind_offset + 500000, + hashlib.sha256(access_token.encode()).hexdigest(), + hashlib.sha256(refresh_token.encode()).hexdigest(), + sep="\t", + ) + raise SystemExit(0) if "SELECT p.id" in sql: missing = sorted( account["userId"] for account in state["accounts"].values() @@ -220,18 +248,22 @@ chmod +x "$TMP_DIR/mysql" cat >"$TMP_DIR/ssh" <<'SH' #!/usr/bin/env bash -# 模拟远端只读查询:验证最后一个参数确为完整 SQL 的 Base64,再返回计数或缺失列表。 +# 模拟远端查询:SQL 必须只经 stdin 传入,不能藏在可见进程参数中。 set -euo pipefail -sql_base64="${!#}" -sql="$(printf '%s' "$sql_base64" | base64 -d)" +sql="$(cat)" +[[ "$*" != *'fakeAccessToken'* && "$*" != *'fakeRefreshToken'* ]] || { + printf '远端命令参数泄漏 OAuth bearer\n' >&2 + exit 2 +} case "$sql" in - *'SELECT COUNT(DISTINCT p.id)'*|*'SELECT p.id'*) ;; - *) printf '远端未收到完整狗粮账号 SQL\n' >&2; exit 2 ;; + *'SELECT COUNT(DISTINCT p.id)'*|*'SELECT p.id'*|*'FROM system_oauth2_access_token a'*) ;; + *) printf '远端未从 stdin 收到完整狗粮账号 SQL\n' >&2; exit 2 ;; esac -cat >/dev/null DOGFOOD_FAKE_SQL="$sql" python3 - "$DOGFOOD_FAKE_STATE" <<'PY' import json +import hashlib import os +import re import sys with open(sys.argv[1], encoding="utf-8") as handle: @@ -242,6 +274,21 @@ claims = state["claims"] mode = os.environ.get("DOGFOOD_FAKE_COUNTS_MODE", "all") duplicates = 1 if mode == "duplicate" else 0 sql = os.environ["DOGFOOD_FAKE_SQL"] +if "FROM system_oauth2_access_token a" in sql: + tokens = re.findall(r"a\.access_token='([A-Za-z0-9_-]+)'.*r\.refresh_token='([A-Za-z0-9_-]+)'", sql, re.S) + if len(tokens) != 1: + raise SystemExit("远端 OAuth 主键查询没有携带唯一 token 对") + access_token, refresh_token = tokens[0] + numeric = int(re.search(r"([0-9]+)$", access_token).group(1)) + kind_offset = 0 if "Register" in access_token else 100000 + print( + numeric + kind_offset, + numeric + kind_offset + 500000, + hashlib.sha256(access_token.encode()).hexdigest(), + hashlib.sha256(refresh_token.encode()).hexdigest(), + sep="\t", + ) + raise SystemExit(0) if "SELECT p.id" in sql: missing = sorted( account["userId"] for account in state["accounts"].values() @@ -361,10 +408,18 @@ for log_file in "$TMP_DIR/first.log" "$TMP_DIR/second.log" "$TMP_DIR/recover-one || fail "缺少终验通过证据:$log_file" ! grep -q 'test-seed-must-not-appear' "$log_file" || fail "日志泄漏 seed" ! grep -q 'admin-password-must-not-appear' "$log_file" || fail "日志泄漏 admin 密码" - ! grep -q 'bootstrap-token-from-file\|fake-admin-token\|fake-player-token\|fake-mysql-password' "$log_file" || \ + ! grep -q 'bootstrap-token-from-file\|fake-admin-token\|fakeAccessToken\|fakeRefreshToken\|fake-mysql-password' "$log_file" || \ fail "日志泄漏 token 或数据库密码" done +# 持久清理清单只能保存数据库主键和不可逆摘要,不能保存 bearer 明文。 +cleanup_manifest="$TMP_DIR/dogfood-accounts.tsv.runtime-cleanup.tsv" +rg -q '^schema dogfood-provision-cleanup/2$' "$cleanup_manifest" || fail "清理清单未升级为主键撤销 schema" +rg -q '^oauth [0-9]+ [0-9]+ [0-9a-f]{64} [0-9a-f]{64}$' "$cleanup_manifest" || \ + fail "清理清单缺 OAuth 数据库主键与摘要" +! rg -q 'fakeAccessToken|fakeRefreshToken|oauth2_access_token:' "$cleanup_manifest" || \ + fail "持久清理清单仍保存 bearer 或带 bearer 的 Redis key" + # 手工场景仍可显式使用用户名密码;不能与 token 文件混用。 rm -f "$TMP_DIR/manual-requests.log" mv "$TMP_DIR/requests.log" "$TMP_DIR/token-requests.log" @@ -418,52 +473,167 @@ if PROVISION_ACK='PROVISION_DOGFOOD_40_USERS_WITH_TEMP_CONFIG' DOGFOOD_PASSWORD_ fail "非法 BASE host 未被拒绝" fi -# 失败补偿只按本轮 0600 清单删除四个精确限流桶和本轮 OAuth access/refresh 缓存 key。 +# 失败补偿先按主键+摘要事务删除 MySQL access/refresh 记录,再清四个限流桶与 access token 缓存。 cat >"$TMP_DIR/redis-cli" <<'SH' #!/usr/bin/env bash set -euo pipefail [ "${REDISCLI_AUTH:-}" = 'fake-redis-password' ] +if [ "$*" = '-h 127.0.0.1 -p 16379 -n 0 --scan --pattern oauth2_access_token:*' ]; then + printf 'oauth2_access_token:accessToken0123456789abcdef\n' + printf 'oauth2_access_token:refreshToken0123456789abcdef\n' + exit 0 +fi [ "$*" = '-h 127.0.0.1 -p 16379 -n 0 -x DEL' ] key="$(cat)" printf '%s\n' "$key" >>"$DOGFOOD_FAKE_REDIS_DELETES" printf '1\n' SH chmod +x "$TMP_DIR/redis-cli" -cat >"$TMP_DIR/runtime-cleanup.tsv" <<'EOF' -schema dogfood-provision-cleanup/1 +cat >"$TMP_DIR/mysql-cleanup" <<'SH' +#!/usr/bin/env bash +set -euo pipefail +sql="$(cat)" +printf '%s\n' "$sql" >"$DOGFOOD_FAKE_CLEANUP_SQL" +[[ "$sql" == *'DELETE a FROM system_oauth2_access_token'* ]] +[[ "$sql" == *'DELETE r FROM system_oauth2_refresh_token'* ]] +[[ "$sql" == *"CONCAT('PAIR\\t'"* ]] +[[ "$sql" == *"CONCAT('VALID_PAIRS\\t'"* ]] +[[ "$sql" == *"CONCAT('REMAINING\\t'"* ]] +case "${DOGFOOD_FAKE_DB_STATE:-present}" in + present) + printf 'TARGET\t1\nPAIR\t101\t601\t1\t1\t1\t1\nVALID_PAIRS\t1\nREVOKED\t1\t1\nREMAINING\t0\t0\n' + ;; + both-missing) + printf 'TARGET\t1\nPAIR\t101\t601\t0\t0\t0\t0\nVALID_PAIRS\t1\nREVOKED\t0\t0\nREMAINING\t0\t0\n' + ;; + access-only) + printf 'TARGET\t1\nPAIR\t101\t601\t1\t1\t0\t0\nVALID_PAIRS\t0\nREVOKED\t0\t0\nREMAINING\t1\t0\n' + ;; + refresh-only) + printf 'TARGET\t1\nPAIR\t101\t601\t0\t0\t1\t1\nVALID_PAIRS\t0\nREVOKED\t0\t0\nREMAINING\t0\t1\n' + ;; + access-hash-drift) + printf 'TARGET\t1\nPAIR\t101\t601\t1\t0\t1\t1\nVALID_PAIRS\t0\nREVOKED\t0\t0\nREMAINING\t1\t1\n' + ;; + refresh-hash-drift) + printf 'TARGET\t1\nPAIR\t101\t601\t1\t1\t1\t0\nVALID_PAIRS\t0\nREVOKED\t0\t0\nREMAINING\t1\t1\n' + ;; + valid-count-drift) + printf 'TARGET\t1\nPAIR\t101\t601\t1\t1\t1\t1\nVALID_PAIRS\t0\nREVOKED\t0\t0\nREMAINING\t1\t1\n' + ;; + residual-after-delete) + printf 'TARGET\t1\nPAIR\t101\t601\t1\t1\t1\t1\nVALID_PAIRS\t1\nREVOKED\t1\t1\nREMAINING\t1\t0\n' + ;; + target-count-drift) + printf 'TARGET\t2\nPAIR\t101\t601\t1\t1\t1\t1\nVALID_PAIRS\t1\nREVOKED\t1\t1\nREMAINING\t0\t0\n' + ;; + *) exit 2 ;; +esac +SH +chmod +x "$TMP_DIR/mysql-cleanup" +ACCESS_TOKEN_HASH="$(printf 'accessToken0123456789abcdef' | shasum -a 256 | awk '{print $1}')" +REFRESH_TOKEN_HASH="$(printf 'refreshToken0123456789abcdef' | shasum -a 256 | awk '{print $1}')" +cat >"$TMP_DIR/runtime-cleanup.tsv" <"$TMP_DIR/runtime-cleanup.log" [ "$(sort -u "$TMP_DIR/redis-deletes.log" | wc -l | tr -d ' ')" = 6 ] || \ fail "运行态补偿未精确删除 4 个限流桶与 2 个 token key" [ ! -e "$TMP_DIR/runtime-cleanup.tsv" ] || fail "补偿成功后未删除敏感清单" -rg -q '^DOGFOOD_RUNTIME_CLEANUP_PASS run_id=cleanup-run-001 keys=6$' \ +rg -q '^DOGFOOD_RUNTIME_CLEANUP_PASS run_id=cleanup-run-001 oauth=1 keys=6$' \ "$TMP_DIR/runtime-cleanup.log" || fail "运行态补偿缺可审计结果" ! rg -q 'accessToken0123456789abcdef|refreshToken0123456789abcdef' \ "$TMP_DIR/runtime-cleanup.log" || fail "运行态补偿日志泄漏 token" +# 单侧缺失和任一摘要漂移都必须停在 Redis 之前,并保留清单供人工复核。 +for db_state in access-only refresh-only access-hash-drift refresh-hash-drift valid-count-drift residual-after-delete; do + manifest="$TMP_DIR/${db_state}-runtime-cleanup.tsv" + redis_log="$TMP_DIR/${db_state}-redis-deletes.log" + cleanup_log="$TMP_DIR/${db_state}-runtime-cleanup.log" + cat >"$manifest" <"$redis_log" + if REDIS_PASSWORD='fake-redis-password' REDIS_CLI_BIN="$TMP_DIR/redis-cli" \ + MYSQL_BIN="$TMP_DIR/mysql-cleanup" MYSQL_PASSWORD='fake-mysql-password' MYSQL_SSH_HOST='' \ + DOGFOOD_FAKE_DB_STATE="$db_state" DOGFOOD_FAKE_CLEANUP_SQL="$TMP_DIR/mysql-cleanup-${db_state}.sql" \ + DOGFOOD_FAKE_REDIS_DELETES="$redis_log" \ + "$SCRIPT" --cleanup "$manifest" "cleanup-run-$db_state" >"$cleanup_log" 2>&1; then + fail "OAuth 非法数据库状态仍被判为成功:$db_state" + fi + [ -e "$manifest" ] || fail "OAuth 状态校验失败后错误删除清理清单:$db_state" + [ ! -s "$redis_log" ] || fail "OAuth 状态校验失败后仍清理了 Redis:$db_state" + rg -q 'OAuth MySQL 撤销状态非法' "$cleanup_log" || fail "OAuth 非法状态缺少可追踪拒因:$db_state" +done + +# SQL 自报的 TARGET 必须与清单目标数完全一致。 +target_manifest="$TMP_DIR/target-count-drift-runtime-cleanup.tsv" +cat >"$target_manifest" <"$TMP_DIR/target-count-drift.log" 2>&1; then + fail "OAuth TARGET 漂移仍被判为成功" +fi +rg -q 'OAuth MySQL 撤销目标不匹配' "$TMP_DIR/target-count-drift.log" || \ + fail "OAuth TARGET 漂移缺少可追踪拒因" + +# 数据库记录已成对撤销时,重试仍须用摘要定位并清掉遗留 Redis 缓存。 +cat >"$TMP_DIR/retry-runtime-cleanup.tsv" <"$TMP_DIR/retry-redis-deletes.log" +REDIS_PASSWORD='fake-redis-password' REDIS_CLI_BIN="$TMP_DIR/redis-cli" \ + MYSQL_BIN="$TMP_DIR/mysql-cleanup" MYSQL_PASSWORD='fake-mysql-password' MYSQL_SSH_HOST='' \ + DOGFOOD_FAKE_DB_STATE='both-missing' DOGFOOD_FAKE_CLEANUP_SQL="$TMP_DIR/mysql-cleanup-retry.sql" \ + DOGFOOD_FAKE_REDIS_DELETES="$TMP_DIR/retry-redis-deletes.log" \ + "$SCRIPT" --cleanup "$TMP_DIR/retry-runtime-cleanup.tsv" cleanup-run-retry \ + >"$TMP_DIR/retry-runtime-cleanup.log" +[ "$(sort -u "$TMP_DIR/retry-redis-deletes.log" | wc -l | tr -d ' ')" = 2 ] || \ + fail "MySQL 已撤销后的重试未按摘要清理 Redis" +rg -q '^DOGFOOD_RUNTIME_CLEANUP_PASS run_id=cleanup-run-retry oauth=1 keys=2$' \ + "$TMP_DIR/retry-runtime-cleanup.log" || fail "幂等 OAuth 清理缺少可审计结果" + # 预检阶段尚无 Redis 副作用时,只有 schema/run-id 的清单应幂等收口为 keys=0。 cat >"$TMP_DIR/empty-runtime-cleanup.tsv" <<'EOF' -schema dogfood-provision-cleanup/1 +schema dogfood-provision-cleanup/2 run_id cleanup-run-empty EOF chmod 600 "$TMP_DIR/empty-runtime-cleanup.tsv" REDIS_PASSWORD='fake-redis-password' REDIS_CLI_BIN="$TMP_DIR/redis-cli" \ + MYSQL_BIN="$TMP_DIR/mysql-cleanup" MYSQL_PASSWORD='fake-mysql-password' MYSQL_SSH_HOST='' \ + DOGFOOD_FAKE_CLEANUP_SQL="$TMP_DIR/mysql-cleanup-empty.sql" \ DOGFOOD_FAKE_REDIS_DELETES="$TMP_DIR/redis-deletes.log" \ "$SCRIPT" --cleanup "$TMP_DIR/empty-runtime-cleanup.tsv" cleanup-run-empty \ >"$TMP_DIR/empty-runtime-cleanup.log" [ ! -e "$TMP_DIR/empty-runtime-cleanup.tsv" ] || fail "空运行态清理清单未幂等删除" -rg -q '^DOGFOOD_RUNTIME_CLEANUP_PASS run_id=cleanup-run-empty keys=0$' \ +rg -q '^DOGFOOD_RUNTIME_CLEANUP_PASS run_id=cleanup-run-empty oauth=0 keys=0$' \ "$TMP_DIR/empty-runtime-cleanup.log" || fail "空运行态清理清单未幂等收口" printf 'TEST_PASS provision-dogfood-users 缺失 claim 补偿、运行态清理、40/40 单 claim、越权拒绝、幂等与脱敏校验通过\n' diff --git a/deploy/tests/test-staging-rc.sh b/deploy/tests/test-staging-rc.sh index 8369a7f7..86a7b325 100755 --- a/deploy/tests/test-staging-rc.sh +++ b/deploy/tests/test-staging-rc.sh @@ -9,9 +9,15 @@ SMOKE="$ROOT_DIR/deploy/smoke-test.sh" STORAGE_AUDIT="$ROOT_DIR/deploy/generate-staging-storage-audit.sh" RESTORE_VERIFY="$ROOT_DIR/deploy/verify-mysql-dump-restore.sh" GENERATION_GATE_REF="sha256:$(printf '0%.0s' $(seq 1 64)):r1/$(printf '0%.0s' $(seq 1 64)).attestation" +GENERATION_GATE_RUN_ID="r1-test-run-20260723" +GENERATION_ATTESTATION_EXPECTED_ISSUER="r1-independent-signer" +GENERATION_ATTESTATION_TRUSTED_PUBLIC_KEY_FILE="/root/game-staging/trust/r1-attestation-ed25519.pub" TMP_DIR="$(mktemp -d)" SERVER_PID="" +export GENERATION_GATE_RUN_ID GENERATION_ATTESTATION_EXPECTED_ISSUER +export GENERATION_ATTESTATION_TRUSTED_PUBLIC_KEY_FILE + cleanup() { if [ -n "$SERVER_PID" ]; then kill "$SERVER_PID" 2>/dev/null || true @@ -95,6 +101,13 @@ rg -q 'generate-staging-storage-audit\.sh' "$TMP_DIR/remote-script.sh" || fail " rg -q 'verify-mysql-dump-restore\.sh' "$TMP_DIR/remote-script.sh" || fail "缺隔离恢复验证" rg -q 'failure_handler' "$TMP_DIR/remote-script.sh" || fail "缺失败回滚处理" rg -q 'start_stack.*48080 4173 4174 8300 9501 true' "$TMP_DIR/remote-script.sh" || fail "缺正式五端口启动" +rg -q '^install_maintenance_gate()' "$TMP_DIR/remote-script.sh" || fail "缺正式端口维护门" +rg -q '^release_maintenance_gate()' "$TMP_DIR/remote-script.sh" || fail "缺维护门解除函数" +rg -q '^verify_stack()' "$TMP_DIR/remote-script.sh" || fail "缺五服务逐项复核函数" +rg -Fq 'INPUT 1 ! -i lo -j "$maintenance_chain"' "$TMP_DIR/remote-script.sh" || \ + fail "维护门没有在 INPUT 首位封锁全部非 loopback 入口" +rg -q 'maintenance_ports="48080,4173,4174,8300,9501"' "$TMP_DIR/remote-script.sh" || \ + fail "维护门未覆盖全部五个正式端口" rg -q 'mv -Tf.*active_pointer' "$TMP_DIR/remote-script.sh" || fail "active 指针不是原子替换" rg -Fq 'spring.cloud.nacos.discovery.enabled=${discovery_enabled}' "$TMP_DIR/remote-script.sh" || \ fail "backend launcher 没有显式控制 Nacos discovery" @@ -103,6 +116,10 @@ rg -q 'start_candidate_probe.*14173 14174 18300 19501' "$TMP_DIR/remote-script.s rg -q 'verify_generation_gate_evidence' "$TMP_DIR/remote-script.sh" || fail "缺生成 R1 证据校验" ! rg -q 'git .*cat-file.*expected_commit' "$TMP_DIR/remote-script.sh" || fail "R1 attestation 仍要求属于被证明 commit,存在自引用" rg -q 'attestation_root' "$TMP_DIR/remote-script.sh" || fail "缺独立 R1 attestation 根目录" +rg -q 'openssl pkeyutl -verify' "$TMP_DIR/remote-script.sh" || fail "R1 attestation 缺 detached signature 校验" +rg -q 'expected_generation_gate_run_id' "$TMP_DIR/remote-script.sh" || fail "R1 attestation 未绑定独立 run-id" +rg -q 'expected_attestation_issuer' "$TMP_DIR/remote-script.sh" || fail "R1 attestation 未绑定 issuer" +rg -q 'trusted_attestation_public_key_file' "$TMP_DIR/remote-script.sh" || fail "R1 attestation 未绑定信任公钥" rg -q 'flock -n' "$TMP_DIR/remote-script.sh" || fail "activation 缺单实例部署锁" rg -q 'candidate_backend=SKIPPED_NO_ISOLATED_DATA_PLANE' "$TMP_DIR/remote-script.sh" || \ fail "候选探活没有明确跳过共享数据面 backend" @@ -144,11 +161,17 @@ provision_pass_line="$(rg -n 'PROVISION_PASS accounts=40 creators=10 role_mismat runtime_verify_line="$(rg -n '狗粮审核账号轮换与运行态验证 PASS' "$TMP_DIR/remote-script.sh" | tail -1 | cut -d: -f1)" stable_smoke_line="$(rg -n 'stable-smoke\.log' "$TMP_DIR/remote-script.sh" | tail -1 | cut -d: -f1)" active_switch_line="$(rg -n '正式栈全绿,原子切换 active 指针' "$TMP_DIR/remote-script.sh" | tail -1 | cut -d: -f1)" +maintenance_install_line="$(rg -n '^install_maintenance_gate$' "$TMP_DIR/remote-script.sh" | tail -1 | cut -d: -f1)" +stable_start_line="$(rg -n '^start_stack .*48080 4173 4174 8300 9501 true$' "$TMP_DIR/remote-script.sh" | tail -1 | cut -d: -f1)" +maintenance_release_line="$(rg -n '^release_maintenance_gate$' "$TMP_DIR/remote-script.sh" | tail -1 | cut -d: -f1)" +[ "$maintenance_install_line" -lt "$stable_start_line" ] || fail "正式栈启动前未封锁外部入口" +[ "$maintenance_install_line" -lt "$snapshot_line" ] || fail "停写快照前未封锁外部入口" [ "$provision_line" -lt "$provision_pass_line" ] || fail "账号预置后未核对终验" [ "$provision_pass_line" -lt "$reviewer_line" ] || fail "admin 禁用发生在账号预置恢复前" [ "$reviewer_line" -lt "$runtime_verify_line" ] || fail "审核账号轮换后未做运行态验证" [ "$runtime_verify_line" -lt "$stable_smoke_line" ] || fail "审核账号运行态验证没有位于写 smoke 前" [ "$stable_smoke_line" -lt "$active_switch_line" ] || fail "active 指针在 smoke 前切换" +[ "$active_switch_line" -lt "$maintenance_release_line" ] || fail "事务提交前提前解除外部入口封锁" token_delete_line="$(rg -n '^delete_bootstrap_token$' "$TMP_DIR/remote-script.sh" | tail -1 | cut -d: -f1)" [ "$active_switch_line" -lt "$token_delete_line" ] || fail "bootstrap token 未在 smoke+active 后最后删除" failure_handler_body="$(sed -n '/^failure_handler()/,/^}/p' "$TMP_DIR/remote-script.sh")" @@ -156,6 +179,10 @@ failure_handler_body="$(sed -n '/^failure_handler()/,/^}/p' "$TMP_DIR/remote-scr fail "激活失败路径删除了补偿后重试所需的 bootstrap token" printf '%s\n' "$failure_handler_body" | rg -q 'cleanup_dogfood_provision_runtime' || \ fail "激活失败补偿未定向清理本次限流桶与 OAuth token 缓存" +printf '%s\n' "$failure_handler_body" | rg -q 'verify_stack.*rollback' || \ + fail "回滚后未逐项复核五服务" +printf '%s\n' "$failure_handler_body" | rg -q 'release_maintenance_gate' || \ + fail "旧栈完整恢复后未解除维护门" ! printf '%s\n' "$failure_handler_body" | rg -q 'exit 72' || \ fail "运行态 cleanup 失败仍直接 exit 72,跳过最终证据落盘" cleanup_rc_line="$(printf '%s\n' "$failure_handler_body" | rg -n 'rc=72' | cut -d: -f1)" @@ -170,6 +197,77 @@ cleanup_function_body="$(sed -n '/^cleanup_dogfood_provision_runtime()/,/^}/p' " printf '%s\n' "$cleanup_function_body" | rg -q -- '--cleanup' || fail "激活补偿未进入定向清理模式" printf '%s\n' "$cleanup_function_body" | rg -q 'account_provision_cleanup_manifest.*account_provision_run_id' || \ fail "激活补偿未用本次 run-id 约束清理清单" +start_stack_body="$(sed -n '/^start_stack()/,/^}/p' "$TMP_DIR/remote-script.sh")" +if printf '%s\n' "$start_stack_body" \ + | rg '^ (run_unit|wait_port|wait_json_health|wait_url) ' \ + | rg -v '\|\| return 1$' >/dev/null; then + fail "start_stack 存在依赖 errexit 的启动或探活步骤" +fi +verify_stack_body="$(sed -n '/^verify_stack()/,/^}/p' "$TMP_DIR/remote-script.sh")" +[ "$(printf '%s\n' "$verify_stack_body" | rg -c 'systemctl is-active --quiet')" -eq 5 ] || \ + fail "回滚复核没有逐项检查五个 systemd 服务" + +# 用有状态假 iptables 执行维护门真实函数,验证安装、复核和解除的完整生命周期。 +cat >"$TMP_DIR/fake-iptables" <<'SH' +#!/usr/bin/env bash +set -euo pipefail +state="${RC_TEST_TMP:?}/iptables-state" +mkdir -p "$state" +printf '%s\n' "$*" >>"$state/calls.log" +case "$*" in + '-w 5 -nL GDA_STAGING_MAINT') [ -f "$state/chain" ] ;; + '-w 5 -N GDA_STAGING_MAINT') touch "$state/chain" ;; + '-w 5 -C GDA_STAGING_MAINT -p tcp -m multiport --dports 48080,4173,4174,8300,9501 -j REJECT') + [ -f "$state/reject-rule" ] ;; + '-w 5 -A GDA_STAGING_MAINT -p tcp -m multiport --dports 48080,4173,4174,8300,9501 -j REJECT') + touch "$state/reject-rule" ;; + '-w 5 -C INPUT ! -i lo -j GDA_STAGING_MAINT') [ -f "$state/input-jump" ] ;; + '-w 5 -I INPUT 1 ! -i lo -j GDA_STAGING_MAINT') touch "$state/input-jump" ;; + '-w 5 -D INPUT ! -i lo -j GDA_STAGING_MAINT') rm -f "$state/input-jump" ;; + '-w 5 -F GDA_STAGING_MAINT') rm -f "$state/reject-rule" ;; + '-w 5 -X GDA_STAGING_MAINT') rm -f "$state/chain" ;; + *) printf '未知 fake iptables 调用:%s\n' "$*" >&2; exit 96 ;; +esac +SH +chmod +x "$TMP_DIR/fake-iptables" +{ + printf '%s\n' 'set -euo pipefail' + printf 'IPTABLES_BIN=%q\n' "$TMP_DIR/fake-iptables" + printf 'evidence=%q\n' "$TMP_DIR/maintenance-evidence" + printf '%s\n' 'maintenance_chain=GDA_STAGING_MAINT' \ + 'maintenance_ports=48080,4173,4174,8300,9501' 'maintenance_gate_active=0' + printf '%s\n' 'log_step() { :; }' + sed -n '/^maintenance_rule_present()/,/^}/p' "$TMP_DIR/remote-script.sh" + sed -n '/^install_maintenance_gate()/,/^}/p' "$TMP_DIR/remote-script.sh" + sed -n '/^release_maintenance_gate()/,/^}/p' "$TMP_DIR/remote-script.sh" + printf '%s\n' 'mkdir -p "$evidence"' 'install_maintenance_gate' \ + '[ "$maintenance_gate_active" -eq 1 ]' 'maintenance_rule_present' \ + 'release_maintenance_gate' '[ "$maintenance_gate_active" -eq 0 ]' +} >"$TMP_DIR/maintenance-harness.sh" +bash "$TMP_DIR/maintenance-harness.sh" +[ ! -e "$TMP_DIR/iptables-state/chain" ] \ + && [ ! -e "$TMP_DIR/iptables-state/reject-rule" ] \ + && [ ! -e "$TMP_DIR/iptables-state/input-jump" ] || fail "维护门解除后残留 iptables 状态" +rg -q '^maintenance_gate=OPENED ' "$TMP_DIR/maintenance-evidence/maintenance-gate.status" || \ + fail "维护门未留下 OPENED 终态证据" + +# 在关闭 errexit 的回滚环境中执行真实 start_stack,首个启动失败必须立即返回且不得继续探活。 +{ + printf '%s\n' 'set -uo pipefail' 'calls="${RC_TEST_TMP:?}/start-stack-calls.log"' + printf '%s\n' 'write_launchers() { printf "/tmp/launchers"; }' \ + 'unit_name() { printf "%s-%s" "$1" "$2"; }' \ + 'run_unit() { printf "run_unit %s\n" "$1" >>"$calls"; return 1; }' \ + 'wait_port() { printf "unexpected wait_port\n" >>"$calls"; return 0; }' \ + 'wait_json_health() { printf "unexpected wait_json_health\n" >>"$calls"; return 0; }' \ + 'wait_url() { printf "unexpected wait_url\n" >>"$calls"; return 0; }' + printf '%s\n' "$start_stack_body" + printf '%s\n' 'set +e' \ + 'start_stack /tmp/release rollback test-prefix 48080 4173 4174 8300 9501 true' \ + 'rc=$?' 'set -e' '[ "$rc" -eq 1 ]' \ + '[ "$(wc -l <"$calls" | tr -d " ")" -eq 1 ]' \ + '! rg -q "^unexpected " "$calls"' +} >"$TMP_DIR/start-stack-harness.sh" +bash "$TMP_DIR/start-stack-harness.sh" if rg -q '/root/games-development-ai' "$TMP_DIR/remote-script.sh"; then fail "激活脚本引用了远端脏旧仓" fi @@ -227,6 +325,9 @@ rg -q 'RuntimePackageApiImplDogfoodTest' "$PREPARE" || fail "prepare 未运行 r rg -q 'AdminNewapiQuotaControllerTest' "$PREPARE" || fail "prepare 未运行配额修复入口测试" rg -q 'NewapiQuotaClaimConsumerTest' "$PREPARE" || fail "prepare 未运行配额 MQ 消费测试" rg -q 'NewapiQuotaServiceImplTest' "$PREPARE" || fail "prepare 未运行配额服务测试" +rg -q 'GameVersionServiceImplTest' "$PREPARE" || fail "prepare 未运行版本产物绑定测试" +rg -q 'DifyCallbackServiceImplTest' "$PREPARE" || fail "prepare 未运行生成回调产物绑定测试" +rg -q 'PublishOrchestrationServiceImplTest' "$PREPARE" || fail "prepare 未运行发布编排测试" rg -q 'test-dogfood-reviewer-security\.py' "$PREPARE" || fail "prepare 未运行审核账号安全测试" rg -q 'test_dogfood_ops\.py' "$PREPARE" || fail "prepare 未运行波次证据账本测试" rg -q 'backend_security_tests=PASS' "$PREPARE" || fail "prepare 未把安全 Java 测试结果绑定 RC" @@ -262,7 +363,7 @@ tool_test_line="$(rg -n '运行发布工具回归测试' "$PREPARE" | cut -d: -f rg -q 'cheap-worker/\.venv/bin.*PATH' "$PREPARE" || fail "发布工具回归未显式使用 commit 内 Python" rg -q '原子切换 active' "$PREPARE" || fail "prepare 完成提示仍未使用 active 指针" -# 独立 attestation 不属于被证明 commit:内容寻址文件应通过,宽权限、symlink、commit 漂移必须拒绝。 +# 独立签发者用仓外私钥签名;发布机只持有信任公钥,签名、身份、run-id 和有效期任一漂移均拒绝。 cat >"$TMP_DIR/exec-ssh" <<'SH' #!/usr/bin/env bash set -euo pipefail @@ -273,10 +374,28 @@ SH chmod +x "$TMP_DIR/exec-ssh" attestation_root="$(realpath "$TMP_DIR")/attestations" mkdir -p "$attestation_root/r1" +openssl genpkey -algorithm ED25519 -out "$TMP_DIR/r1-private.pem" >/dev/null 2>&1 +openssl pkey -in "$TMP_DIR/r1-private.pem" -pubout -out "$TMP_DIR/r1-public.pem" >/dev/null 2>&1 +chmod 600 "$TMP_DIR/r1-private.pem" "$TMP_DIR/r1-public.pem" +trusted_public_key="$(realpath "$TMP_DIR/r1-public.pem")" +read -r issued_at expires_at expired_issued_at expired_expires_at < <(python3 - <<'PY' +from datetime import datetime, timedelta, timezone +now = datetime.now(timezone.utc).replace(microsecond=0) +fmt = "%Y-%m-%dT%H:%M:%SZ" +print((now - timedelta(minutes=1)).strftime(fmt), + (now + timedelta(minutes=30)).strftime(fmt), + (now - timedelta(hours=2)).strftime(fmt), + (now - timedelta(hours=1)).strftime(fmt)) +PY +) attestation_file="$attestation_root/r1/r1.attestation" cat >"$attestation_file" <"$TMP_DIR/attestation-pass.log" rg -q 'GENERATION_ATTESTATION_PASS' "$TMP_DIR/attestation-pass.log" || fail "独立 attestation 未通过" +if RC_COMMIT=0000000000000000000000000000000000000000 \ + GENERATION_GATE_EVIDENCE_REF="$attestation_ref" GENERATION_ATTESTATION_ROOT="$attestation_root" \ + GENERATION_ATTESTATION_TRUSTED_PUBLIC_KEY_FILE="$TMP_DIR/missing-public.pem" \ + SSH_BIN="$TMP_DIR/exec-ssh" bash "$ACTIVATE" --verify-r1-attestation >/dev/null 2>&1; then + fail "缺信任公钥仍允许 attestation" +fi + +mv "$attestation_file.sig" "$attestation_file.sig.missing" +if RC_COMMIT=0000000000000000000000000000000000000000 \ + GENERATION_GATE_EVIDENCE_REF="$attestation_ref" GENERATION_ATTESTATION_ROOT="$attestation_root" \ + GENERATION_ATTESTATION_TRUSTED_PUBLIC_KEY_FILE="$trusted_public_key" \ + SSH_BIN="$TMP_DIR/exec-ssh" bash "$ACTIVATE" --verify-r1-attestation >/dev/null 2>&1; then + fail "缺 detached signature 仍允许 attestation" +fi +mv "$attestation_file.sig.missing" "$attestation_file.sig" + +cp "$attestation_file.sig" "$TMP_DIR/valid-attestation.sig" +printf 'x' >>"$attestation_file.sig" +if RC_COMMIT=0000000000000000000000000000000000000000 \ + GENERATION_GATE_EVIDENCE_REF="$attestation_ref" GENERATION_ATTESTATION_ROOT="$attestation_root" \ + GENERATION_ATTESTATION_TRUSTED_PUBLIC_KEY_FILE="$trusted_public_key" \ + SSH_BIN="$TMP_DIR/exec-ssh" bash "$ACTIVATE" --verify-r1-attestation >/dev/null 2>&1; then + fail "篡改 detached signature 仍允许 attestation" +fi +mv "$TMP_DIR/valid-attestation.sig" "$attestation_file.sig" + +if RC_COMMIT=0000000000000000000000000000000000000000 \ + GENERATION_GATE_EVIDENCE_REF="$attestation_ref" GENERATION_ATTESTATION_ROOT="$attestation_root" \ + GENERATION_GATE_RUN_ID=other-run GENERATION_ATTESTATION_TRUSTED_PUBLIC_KEY_FILE="$trusted_public_key" \ + SSH_BIN="$TMP_DIR/exec-ssh" bash "$ACTIVATE" --verify-r1-attestation >/dev/null 2>&1; then + fail "run-id 漂移 attestation 未拒绝" +fi +if RC_COMMIT=0000000000000000000000000000000000000000 \ + GENERATION_GATE_EVIDENCE_REF="$attestation_ref" GENERATION_ATTESTATION_ROOT="$attestation_root" \ + GENERATION_ATTESTATION_EXPECTED_ISSUER=other-issuer \ + GENERATION_ATTESTATION_TRUSTED_PUBLIC_KEY_FILE="$trusted_public_key" \ + SSH_BIN="$TMP_DIR/exec-ssh" bash "$ACTIVATE" --verify-r1-attestation >/dev/null 2>&1; then + fail "issuer 漂移 attestation 未拒绝" +fi + chmod 644 "$attestation_file" if RC_COMMIT=0000000000000000000000000000000000000000 \ GENERATION_GATE_EVIDENCE_REF="$attestation_ref" GENERATION_ATTESTATION_ROOT="$attestation_root" \ + GENERATION_ATTESTATION_TRUSTED_PUBLIC_KEY_FILE="$trusted_public_key" \ SSH_BIN="$TMP_DIR/exec-ssh" bash "$ACTIVATE" --verify-r1-attestation >/dev/null 2>&1; then fail "权限过宽 attestation 未拒绝" fi @@ -308,16 +472,36 @@ chmod 600 "$attestation_file" ln -s "$attestation_file" "$attestation_root/r1/symlink.attestation" if RC_COMMIT=0000000000000000000000000000000000000000 \ GENERATION_GATE_EVIDENCE_REF="sha256:$attestation_hash:r1/symlink.attestation" \ - GENERATION_ATTESTATION_ROOT="$attestation_root" SSH_BIN="$TMP_DIR/exec-ssh" \ + GENERATION_ATTESTATION_ROOT="$attestation_root" \ + GENERATION_ATTESTATION_TRUSTED_PUBLIC_KEY_FILE="$trusted_public_key" SSH_BIN="$TMP_DIR/exec-ssh" \ bash "$ACTIVATE" --verify-r1-attestation >/dev/null 2>&1; then fail "symlink attestation 未拒绝" fi if RC_COMMIT=1111111111111111111111111111111111111111 \ GENERATION_GATE_EVIDENCE_REF="$attestation_ref" GENERATION_ATTESTATION_ROOT="$attestation_root" \ + GENERATION_ATTESTATION_TRUSTED_PUBLIC_KEY_FILE="$trusted_public_key" \ SSH_BIN="$TMP_DIR/exec-ssh" bash "$ACTIVATE" --verify-r1-attestation >/dev/null 2>&1; then fail "rc_commit 漂移 attestation 未拒绝" fi +expired_file="$attestation_root/r1/expired.attestation" +sed -e "s/^issued_at=.*/issued_at=$expired_issued_at/" \ + -e "s/^expires_at=.*/expires_at=$expired_expires_at/" "$attestation_file" >"$expired_file" +chmod 600 "$expired_file" +expired_hash="$(sha256sum "$expired_file" | awk '{print $1}')" +mv "$expired_file" "$attestation_root/r1/$expired_hash.attestation" +expired_file="$attestation_root/r1/$expired_hash.attestation" +openssl pkeyutl -sign -inkey "$TMP_DIR/r1-private.pem" -rawin \ + -in "$expired_file" -out "$expired_file.sig" +chmod 600 "$expired_file.sig" +if RC_COMMIT=0000000000000000000000000000000000000000 \ + GENERATION_GATE_EVIDENCE_REF="sha256:$expired_hash:r1/$expired_hash.attestation" \ + GENERATION_ATTESTATION_ROOT="$attestation_root" \ + GENERATION_ATTESTATION_TRUSTED_PUBLIC_KEY_FILE="$trusted_public_key" \ + SSH_BIN="$TMP_DIR/exec-ssh" bash "$ACTIVATE" --verify-r1-attestation >/dev/null 2>&1; then + fail "已过期 attestation 未拒绝" +fi + # 直接验证 DB-only 审计:生成、外部引用 fail-closed、旧 hash/路径校验。 FAKE_RC="$TMP_DIR/fake-rc" mkdir -p "$FAKE_RC/evidence" 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 17b7cbea..9bc7bd43 100644 --- a/game-cloud/game-module-aigc/game-module-aigc-server/src/main/java/com/wanxiang/huijing/game/module/aigc/service/quota/NewapiQuotaServiceImpl.java +++ b/game-cloud/game-module-aigc/game-module-aigc-server/src/main/java/com/wanxiang/huijing/game/module/aigc/service/quota/NewapiQuotaServiceImpl.java @@ -31,11 +31,13 @@ public class NewapiQuotaServiceImpl implements NewapiQuotaService { private static final String CHANNEL_PASSWORD = "password"; private static final String CHANNEL_INVITE = "invite"; + /** 单次 claim 允许的最长竞争时间;到期但仍有 FREE 时按瞬时竞争故障失败,不能伪装成池耗尽。 */ + private static final long CLAIM_CONTENTION_TIMEOUT_NANOS = 250_000_000L; + /** - * 单次 claim 的最大 CAS 次数。多个请求可能同时选中最早 FREE,输家需重选剩余条目; - * 固定小次数可消除常见竞争误判,同时避免异常竞争时无限占用注册或派发线程。 + * 极端情况下的 CPU 安全阀。是否耗尽始终以数据库 FREE 计数为准;命中安全阀只报告竞争超时。 */ - private static final int CLAIM_MAX_ATTEMPTS = 3; + private static final int CLAIM_CONTENTION_SAFETY_LIMIT = 64; /** * 主开关(默认 false):关时 claim/派发全旁路。同 {@code NewapiQuotaClaimConsumer} 的 @ConditionalOnProperty, @@ -164,7 +166,8 @@ public class NewapiQuotaServiceImpl implements NewapiQuotaService { return true; } String bizNo = "claim_" + gamePlayerId; - for (int attempt = 1; attempt <= CLAIM_MAX_ATTEMPTS; attempt++) { + long deadline = System.nanoTime() + CLAIM_CONTENTION_TIMEOUT_NANOS; + for (int attempt = 1; ; attempt++) { try { int affected = poolMapper.claimOneFree(gamePlayerId, bizNo, LocalDateTime.now()); if (affected == 1) { @@ -181,27 +184,58 @@ public class NewapiQuotaServiceImpl implements NewapiQuotaService { scene, gamePlayerId, concurrentClaim.getId(), attempt); return true; } - if (attempt < CLAIM_MAX_ATTEMPTS) { - // 下一轮 SQL 会重新选择当前最早 FREE,避开刚被并发抢走的条目;固定三轮,不做无界自旋。 - log.info("[newapi-quota] claim CAS 未命中且玩家仍未绑定,重选剩余 FREE scene={}, gamePlayerId={}, attempt={}", + long freeCount = currentFreeCount(scene); + if (freeCount == 0L) { + log.warn("[newapi-quota] 数据库确认额度池已无 FREE scene={}, gamePlayerId={}, attempts={}", scene, gamePlayerId, attempt); - continue; + triggerWaterLevelAlert(0L, scene); + return false; } + ensureContentionBudget(scene, gamePlayerId, attempt, freeCount, deadline); + // 每轮 SQL 都重新选择当前最早 FREE,避开刚被其它请求抢走的条目。 + log.info("[newapi-quota] claim CAS 未命中但仍有 FREE,继续重选 scene={}, gamePlayerId={}, attempt={}, free={}", + scene, gamePlayerId, attempt, freeCount); + Thread.onSpinWait(); } 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; + if (concurrentClaim != null) { + log.info("[newapi-quota] claim 唯一键冲突后复查到并发绑定 scene={}, gamePlayerId={}, poolId={}", + scene, gamePlayerId, concurrentClaim.getId()); + return true; + } + long freeCount = currentFreeCount(scene); + if (freeCount == 0L) { + triggerWaterLevelAlert(0L, scene); + return false; + } + ensureContentionBudget(scene, gamePlayerId, attempt, freeCount, deadline); + log.info("[newapi-quota] 唯一键冲突不属于当前玩家且仍有 FREE,继续重选 scene={}, gamePlayerId={}, attempt={}, free={}", + scene, gamePlayerId, attempt, freeCount); + Thread.onSpinWait(); } } + } - log.warn("[newapi-quota] 有界重试后仍无 FREE 且玩家未绑定 scene={}, gamePlayerId={}, attempts={}", - scene, gamePlayerId, CLAIM_MAX_ATTEMPTS); - triggerWaterLevelAlert(0L, scene); - return false; + /** 查询权威 FREE 候选数;查询失败必须向上抛出,不能降级成“池空”。 */ + private long currentFreeCount(String scene) { + Long freeCount = poolMapper.countFree(); + if (freeCount == null || freeCount < 0L) { + throw new IllegalStateException("额度池 FREE 计数非法,scene=" + scene); + } + return freeCount; + } + + /** 有 FREE 但持续竞争时按瞬时故障失败,避免调用方错误返回 quota_exhausted。 */ + private void ensureContentionBudget(String scene, Long gamePlayerId, int attempt, + long freeCount, long deadline) { + if (attempt < CLAIM_CONTENTION_SAFETY_LIMIT && System.nanoTime() < deadline) { + return; + } + log.error("[newapi-quota] claim 竞争超时但池仍有 FREE scene={}, gamePlayerId={}, attempts={}, free={}", + scene, gamePlayerId, attempt, freeCount); + throw new IllegalStateException("额度池仍有 FREE,但 claim 竞争超时,请重试"); } /** diff --git a/game-cloud/game-module-aigc/game-module-aigc-server/src/test/java/com/wanxiang/huijing/game/module/aigc/service/callback/DifyCallbackTxTraceTest.java b/game-cloud/game-module-aigc/game-module-aigc-server/src/test/java/com/wanxiang/huijing/game/module/aigc/service/callback/DifyCallbackTxTraceTest.java index 371f7acd..1c2cca22 100644 --- a/game-cloud/game-module-aigc/game-module-aigc-server/src/test/java/com/wanxiang/huijing/game/module/aigc/service/callback/DifyCallbackTxTraceTest.java +++ b/game-cloud/game-module-aigc/game-module-aigc-server/src/test/java/com/wanxiang/huijing/game/module/aigc/service/callback/DifyCallbackTxTraceTest.java @@ -77,6 +77,8 @@ class DifyCallbackTxTraceTest extends BaseMockitoUnitTest { @BeforeEach void setUp() { callbackService = new DifyCallbackServiceImpl(txService); + // 成功链新增产物摘要绑定;默认桩保持本组 trace 测试聚焦于事务与追踪行为。 + lenient().when(projectVersionApi.bindRuntimeArtifact(any())).thenReturn(CommonResult.success(Boolean.TRUE)); // @Value 在纯 Mockito 单测无 Spring 注入 → 反射显式开启 trace 落库段(范本 CallbackAdProviderTest) ReflectionTestUtils.setField(txService, "traceEnabled", true); } 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 6d9a95aa..09b07319 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 @@ -110,15 +110,16 @@ class NewapiQuotaServiceImplTest extends BaseMockitoUnitTest { verifyNoInteractions(poolMapper); } - /** 池空:CAS 返 0 → 不抛异常、不绑(记 WARN + 水位告警,注册照常成功)。 */ + /** 池空:CAS 返 0 且权威 FREE 计数为 0 → 不抛异常、不绑(记 WARN + 水位告警,注册照常成功)。 */ @Test void testClaimForRegister_poolEmpty_noThrow() { when(poolMapper.selectClaimedByPlayer(5L)).thenReturn(null); when(poolMapper.claimOneFree(eq(5L), eq("claim_5"), any(LocalDateTime.class))).thenReturn(0); + when(poolMapper.countFree()).thenReturn(0L); assertDoesNotThrow(() -> service.claimForRegister(5L, "password")); - verify(poolMapper, times(3)).claimOneFree(eq(5L), eq("claim_5"), any(LocalDateTime.class)); + verify(poolMapper).claimOneFree(eq(5L), eq("claim_5"), any(LocalDateTime.class)); } /** 并发同玩家 claim:CAS 撞 uk 抛 DuplicateKeyException → 幂等收敛,不外抛。 */ @@ -161,12 +162,29 @@ class NewapiQuotaServiceImplTest extends BaseMockitoUnitTest { when(poolMapper.claimOneFree(eq(5L), eq("claim_5"), any(LocalDateTime.class))) .thenReturn(0) .thenReturn(1); + when(poolMapper.countFree()).thenReturn(1L, 0L); assertTrue(service.reconcileClaim(5L)); verify(poolMapper, times(2)).claimOneFree(eq(5L), eq("claim_5"), any(LocalDateTime.class)); } + /** + * 高并发下前四轮都可能输给其它请求;只要数据库仍报告存在 FREE,不能因固定三次上限误报耗尽。 + */ + @Test + void testReconcileClaim_fourContentionsWhileFreeRemains_retriesUntilSuccess() { + 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, 0, 0, 0, 1); + when(poolMapper.countFree()).thenReturn(8L); + + assertTrue(service.reconcileClaim(5L)); + + verify(poolMapper, times(5)).claimOneFree(eq(5L), eq("claim_5"), any(LocalDateTime.class)); + } + /** 首轮 CAS 返 0 后若复查发现同一玩家已由并发请求绑定,应幂等成功且不再占第二条。 */ @Test void testReconcileClaim_affectedZero_rechecksConcurrentBindingWithoutRetry() { @@ -280,6 +298,7 @@ class NewapiQuotaServiceImplTest extends BaseMockitoUnitTest { 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); + when(poolMapper.countFree()).thenReturn(0L); assertThrows(QuotaPoolExhaustedException.class, () -> service.resolveUserTokenForDispatch(5L)); } diff --git a/game-cloud/game-module-feed/game-module-feed-server/src/main/java/com/wanxiang/huijing/game/module/feed/api/FeedApiImpl.java b/game-cloud/game-module-feed/game-module-feed-server/src/main/java/com/wanxiang/huijing/game/module/feed/api/FeedApiImpl.java index 8fb8f863..0e703c83 100644 --- a/game-cloud/game-module-feed/game-module-feed-server/src/main/java/com/wanxiang/huijing/game/module/feed/api/FeedApiImpl.java +++ b/game-cloud/game-module-feed/game-module-feed-server/src/main/java/com/wanxiang/huijing/game/module/feed/api/FeedApiImpl.java @@ -3,12 +3,17 @@ package com.wanxiang.huijing.game.module.feed.api; import com.wanxiang.huijing.game.module.feed.dto.FeedRankUpsertReqDTO; import com.wanxiang.huijing.game.module.feed.service.feed.FeedService; import com.wanxiang.huijing.framework.common.pojo.CommonResult; +import com.wanxiang.huijing.framework.common.util.servlet.ServletUtils; import jakarta.annotation.Resource; +import jakarta.servlet.http.HttpServletRequest; +import lombok.extern.slf4j.Slf4j; import org.springframework.context.annotation.Primary; import org.springframework.validation.annotation.Validated; import org.springframework.web.bind.annotation.RestController; import static com.wanxiang.huijing.framework.common.pojo.CommonResult.success; +import static com.wanxiang.huijing.framework.common.exception.util.ServiceExceptionUtil.exception; +import static com.wanxiang.huijing.game.module.feed.enums.ErrorCodeConstants.FEED_DOGFOOD_GATE_CLOSED; /** * 游戏流排序 API 实现(黄金闭环 §3.2/§3.5,提供 RESTful 接口给跨模块 Feign 调用:发布编排 + telemetry 算分回灌写排序行) @@ -22,6 +27,7 @@ import static com.wanxiang.huijing.framework.common.pojo.CommonResult.success; @RestController // 提供 RESTful API 接口,给 Feign 调用 @Validated @Primary // 与 @FeignClient 接口同名 Bean 冲突时优先用本地实现(同进程调用就地解析,上游事务内本地调用) +@Slf4j public class FeedApiImpl implements FeedApi { @Resource @@ -29,6 +35,7 @@ public class FeedApiImpl implements FeedApi { @Override public CommonResult upsertRank(FeedRankUpsertReqDTO req) { + rejectExternalRpcWrite("feed.rpc.upsert-rank"); // 按 mode 分流:PUBLISH_BASELINE(发布基线,写前 enforce 可见态三条件)/ QUALITY_REFRESH(仅刷分,保留 boost/pinned/status) feedService.upsertRank(req); return success(Boolean.TRUE); @@ -36,8 +43,28 @@ public class FeedApiImpl implements FeedApi { @Override public CommonResult offlineRank(Long gameId) { + rejectExternalRpcWrite("feed.rpc.offline-rank"); // 下架编排反向操作(数据回路小波·修3):按 gameId 全分区置 status=0 屏蔽,行数透传给调用方记审计日志(业务与日志在 Service) return success(feedService.offlineRank(gameId)); } + /** 当前单体只信任 project/telemetry 的本地 Java 调用;拆微服务前须先建立服务身份。 */ + private void rejectExternalRpcWrite(String operation) { + HttpServletRequest request = ServletUtils.getRequest(); + if (request == null) { + return; + } + String requestUri = request.getRequestURI(); + String contextPath = request.getContextPath(); + if (contextPath != null && !contextPath.isEmpty() && requestUri.startsWith(contextPath)) { + requestUri = requestUri.substring(contextPath.length()); + } + if (!requestUri.equals(FeedApi.PREFIX) && !requestUri.startsWith(FeedApi.PREFIX + "/")) { + return; + } + log.warn("[feedRpcGuard] 已拒绝外部 HTTP 写入口 operation={} code={}", + operation, FEED_DOGFOOD_GATE_CLOSED.getCode()); + throw exception(FEED_DOGFOOD_GATE_CLOSED); + } + } diff --git a/game-cloud/game-module-feed/game-module-feed-server/src/test/java/com/wanxiang/huijing/game/module/feed/api/FeedApiImplDogfoodTest.java b/game-cloud/game-module-feed/game-module-feed-server/src/test/java/com/wanxiang/huijing/game/module/feed/api/FeedApiImplDogfoodTest.java new file mode 100644 index 00000000..2e584100 --- /dev/null +++ b/game-cloud/game-module-feed/game-module-feed-server/src/test/java/com/wanxiang/huijing/game/module/feed/api/FeedApiImplDogfoodTest.java @@ -0,0 +1,77 @@ +package com.wanxiang.huijing.game.module.feed.api; + +import com.wanxiang.huijing.framework.common.exception.ServiceException; +import com.wanxiang.huijing.game.module.feed.dto.FeedRankUpsertReqDTO; +import com.wanxiang.huijing.game.module.feed.service.feed.FeedService; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.springframework.mock.web.MockHttpServletRequest; +import org.springframework.test.util.ReflectionTestUtils; +import org.springframework.web.context.request.RequestContextHolder; +import org.springframework.web.context.request.ServletRequestAttributes; + +import static org.junit.jupiter.api.Assertions.assertDoesNotThrow; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.verifyNoInteractions; + +/** feed 排序写 RPC 的狗粮可信调用边界测试。 */ +class FeedApiImplDogfoodTest { + + private FeedService feedService; + + @BeforeEach + void setUp() { + feedService = mock(FeedService.class); + } + + @AfterEach + void clearRequestContext() { + RequestContextHolder.resetRequestAttributes(); + } + + @Test + void upsertRank_rejectsExternalRpcBeforeService() { + FeedApiImpl api = createApi(); + FeedRankUpsertReqDTO req = new FeedRankUpsertReqDTO(); + bindRequest("/rpc-api/feed/upsert-rank"); + + assertThrows(ServiceException.class, () -> api.upsertRank(req)); + + verifyNoInteractions(feedService); + } + + @Test + void offlineRank_rejectsExternalRpcBeforeService() { + FeedApiImpl api = createApi(); + bindRequest("/rpc-api/feed/offline-rank"); + + assertThrows(ServiceException.class, () -> api.offlineRank(1024L)); + + verifyNoInteractions(feedService); + } + + @Test + void upsertRank_allowsTrustedLocalCall() { + FeedApiImpl api = createApi(); + FeedRankUpsertReqDTO req = new FeedRankUpsertReqDTO(); + bindRequest("/admin-api/project/review"); + + assertDoesNotThrow(() -> api.upsertRank(req)); + + verify(feedService).upsertRank(req); + } + + private FeedApiImpl createApi() { + FeedApiImpl api = new FeedApiImpl(); + ReflectionTestUtils.setField(api, "feedService", feedService); + return api; + } + + private static void bindRequest(String requestUri) { + MockHttpServletRequest request = new MockHttpServletRequest("POST", requestUri); + RequestContextHolder.setRequestAttributes(new ServletRequestAttributes(request)); + } +} diff --git a/game-cloud/game-module-project/game-module-project-server/src/main/java/com/wanxiang/huijing/game/module/project/api/ProjectVersionApiImpl.java b/game-cloud/game-module-project/game-module-project-server/src/main/java/com/wanxiang/huijing/game/module/project/api/ProjectVersionApiImpl.java index 72209f0b..1b0b3e09 100644 --- a/game-cloud/game-module-project/game-module-project-server/src/main/java/com/wanxiang/huijing/game/module/project/api/ProjectVersionApiImpl.java +++ b/game-cloud/game-module-project/game-module-project-server/src/main/java/com/wanxiang/huijing/game/module/project/api/ProjectVersionApiImpl.java @@ -4,12 +4,17 @@ import com.wanxiang.huijing.game.module.project.dto.ProjectVersionBindArtifactRe import com.wanxiang.huijing.game.module.project.dto.ProjectVersionCreateForPackageReqDTO; import com.wanxiang.huijing.game.module.project.service.version.GameVersionService; import com.wanxiang.huijing.framework.common.pojo.CommonResult; +import com.wanxiang.huijing.framework.common.util.servlet.ServletUtils; import jakarta.annotation.Resource; +import jakarta.servlet.http.HttpServletRequest; +import lombok.extern.slf4j.Slf4j; import org.springframework.context.annotation.Primary; import org.springframework.validation.annotation.Validated; import org.springframework.web.bind.annotation.RestController; import static com.wanxiang.huijing.framework.common.pojo.CommonResult.success; +import static com.wanxiang.huijing.framework.common.exception.util.ServiceExceptionUtil.exception; +import static com.wanxiang.huijing.game.module.project.enums.ErrorCodeConstants.PROJECT_VERSION_ARTIFACT_BIND_FAILED; /** * 游戏版本 API 实现(黄金闭环 §3.3 C3,提供 RESTful 接口给跨模块 Feign 调用:PackageFactory 落包建版本) @@ -22,6 +27,7 @@ import static com.wanxiang.huijing.framework.common.pojo.CommonResult.success; @RestController // 提供 RESTful API 接口,给 Feign 调用 @Validated @Primary // 与 @FeignClient 接口同名 Bean 冲突时优先用本地实现(同进程调用就地解析) +@Slf4j public class ProjectVersionApiImpl implements ProjectVersionApi { @Resource @@ -35,9 +41,31 @@ public class ProjectVersionApiImpl implements ProjectVersionApi { @Override public CommonResult bindRuntimeArtifact(ProjectVersionBindArtifactReqDTO req) { + // 仅允许 callback 等外层请求中的同 JVM 调用;直接命中 project RPC 的任何普通 Token 请求先于业务写入被拒绝。 + rejectExternalRpcWrite("project.rpc.bind-runtime-artifact"); // 同进程调用加入 callback 既有事务;服务异常直接向上冒泡,确保落包与版本摘要不会半成功。 gameVersionService.bindRuntimeArtifact(req); return success(Boolean.TRUE); } + /** 当前单体只信任本地 Java 调用;拆微服务前须先建立服务身份,不能直接放开此 HTTP 写入口。 */ + private void rejectExternalRpcWrite(String operation) { + HttpServletRequest request = ServletUtils.getRequest(); + if (request == null) { + return; + } + String requestUri = request.getRequestURI(); + String contextPath = request.getContextPath(); + if (contextPath != null && !contextPath.isEmpty() && requestUri.startsWith(contextPath)) { + requestUri = requestUri.substring(contextPath.length()); + } + if (!requestUri.equals(ProjectVersionApi.PREFIX) + && !requestUri.startsWith(ProjectVersionApi.PREFIX + "/")) { + return; + } + log.warn("[projectRpcGuard] 已拒绝外部 HTTP 写入口 operation={} code={}", + operation, PROJECT_VERSION_ARTIFACT_BIND_FAILED.getCode()); + throw exception(PROJECT_VERSION_ARTIFACT_BIND_FAILED); + } + } diff --git a/game-cloud/game-module-project/game-module-project-server/src/main/java/com/wanxiang/huijing/game/module/project/service/version/GameVersionServiceImpl.java b/game-cloud/game-module-project/game-module-project-server/src/main/java/com/wanxiang/huijing/game/module/project/service/version/GameVersionServiceImpl.java index 5817d707..76541f60 100644 --- a/game-cloud/game-module-project/game-module-project-server/src/main/java/com/wanxiang/huijing/game/module/project/service/version/GameVersionServiceImpl.java +++ b/game-cloud/game-module-project/game-module-project-server/src/main/java/com/wanxiang/huijing/game/module/project/service/version/GameVersionServiceImpl.java @@ -6,11 +6,15 @@ import com.wanxiang.huijing.game.module.project.dal.mysql.project.ProjectMapper; import com.wanxiang.huijing.game.module.project.dal.mysql.version.GameVersionMapper; import com.wanxiang.huijing.game.module.project.dto.ProjectVersionBindArtifactReqDTO; import com.wanxiang.huijing.game.module.project.dto.ProjectVersionCreateForPackageReqDTO; +import com.wanxiang.huijing.game.module.runtime.api.RuntimePackageApi; +import com.wanxiang.huijing.game.module.runtime.enums.PackageStatusEnum; import jakarta.annotation.Resource; import lombok.extern.slf4j.Slf4j; import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; import org.springframework.util.StringUtils; +import java.util.Objects; import java.util.regex.Pattern; import static com.wanxiang.huijing.game.module.project.enums.ErrorCodeConstants.PROJECT_VERSION_ARTIFACT_BIND_FAILED; @@ -47,6 +51,10 @@ public class GameVersionServiceImpl implements GameVersionService { @Resource private ProjectMapper projectMapper; + /** runtime 权威状态只读 seam;只依赖 -api,禁止跨模块直查运行包表。 */ + @Resource + private RuntimePackageApi runtimePackageApi; + @Override public Long createForPackage(ProjectVersionCreateForPackageReqDTO req) { // 幂等:同 genTaskId 已建版本则直接复用,不重复建(PackageFactory 重复落包安全) @@ -78,6 +86,7 @@ public class GameVersionServiceImpl implements GameVersionService { } @Override + @Transactional(rollbackFor = Exception.class) public void bindRuntimeArtifact(ProjectVersionBindArtifactReqDTO req) { // API 校验可能因同进程直接调用方式而被绕过,服务可信边界再次校验,禁止非法摘要进入审核门。 if (req == null || req.getVersionId() == null || req.getBundleSize() == null || req.getBundleSize() <= 0 @@ -87,7 +96,59 @@ public class GameVersionServiceImpl implements GameVersionService { req == null ? null : req.getBundleSize()); throw exception(PROJECT_VERSION_ARTIFACT_BIND_FAILED); } - // 仅更新审核所需的产物元数据;状态和其它版本字段继续由原有状态机负责。 + + // 锁定版本和项目,保证状态、归属、当前版本指针与摘要写入在同一事务快照内完成。 + GameVersionDO version = gameVersionMapper.selectByIdForUpdate(req.getVersionId()); + if (version == null || version.getGameId() == null) { + log.warn("[bindRuntimeArtifact] 目标版本不存在或缺少项目归属 versionId={}", req.getVersionId()); + throw exception(PROJECT_VERSION_ARTIFACT_BIND_FAILED); + } + + // 已发布版本只允许同一份产物幂等重放;任何改写都拒绝,避免审核后摘要被覆盖。 + boolean sameArtifact = Objects.equals(version.getChecksum(), req.getChecksum()) + && Objects.equals(version.getBundleSize(), req.getBundleSize()); + if (!Objects.equals(version.getStatus(), STATUS_PREVIEW_READY)) { + if (Objects.equals(version.getStatus(), STATUS_PUBLISHED) && sameArtifact) { + log.info("[bindRuntimeArtifact] 已发布版本同产物幂等返回 versionId={}", req.getVersionId()); + return; + } + log.warn("[bindRuntimeArtifact] 版本状态不可绑定 versionId={}, status={}", + req.getVersionId(), version.getStatus()); + throw exception(PROJECT_VERSION_ARTIFACT_BIND_FAILED); + } + + ProjectDO project = projectMapper.selectByIdForUpdate(version.getGameId()); + if (project == null || !Objects.equals(project.getCurrentVersionId(), version.getId())) { + log.warn("[bindRuntimeArtifact] 版本与项目当前指针不一致 versionId={}, gameId={}, currentVersionId={}", + version.getId(), version.getGameId(), project == null ? null : project.getCurrentVersionId()); + throw exception(PROJECT_VERSION_ARTIFACT_BIND_FAILED); + } + + Integer runtimeStatus; + try { + runtimeStatus = runtimePackageApi.getStatus(version.getId()).getCheckedData(); + } catch (Exception ex) { + // runtime 权威状态不可确认时必须拒绝绑定,原始异常仅留服务端日志用于追踪。 + log.warn("[bindRuntimeArtifact] runtime 包状态读取失败,拒绝绑定 versionId={}", version.getId(), ex); + throw exception(PROJECT_VERSION_ARTIFACT_BIND_FAILED); + } + if (!PackageStatusEnum.PREVIEW_READY.getStatus().equals(runtimeStatus)) { + log.warn("[bindRuntimeArtifact] 未找到同版本的预览就绪 runtime 包 versionId={}, runtimeStatus={}", + version.getId(), runtimeStatus); + throw exception(PROJECT_VERSION_ARTIFACT_BIND_FAILED); + } + + // 已有非空摘要不可被另一份产物覆盖;完全一致时幂等返回,避免重复写审计字段。 + if (StringUtils.hasText(version.getChecksum())) { + if (sameArtifact) { + log.info("[bindRuntimeArtifact] 预览版本同产物幂等返回 versionId={}", req.getVersionId()); + return; + } + log.warn("[bindRuntimeArtifact] 预览版本已有不同产物,拒绝覆盖 versionId={}", req.getVersionId()); + throw exception(PROJECT_VERSION_ARTIFACT_BIND_FAILED); + } + + // 仅更新审核所需的产物元数据;版本行和项目行仍由上方事务锁保护。 GameVersionDO update = new GameVersionDO(); update.setId(req.getVersionId()); update.setChecksum(req.getChecksum()); diff --git a/game-cloud/game-module-project/game-module-project-server/src/test/java/com/wanxiang/huijing/game/module/project/api/ProjectVersionApiImplDogfoodTest.java b/game-cloud/game-module-project/game-module-project-server/src/test/java/com/wanxiang/huijing/game/module/project/api/ProjectVersionApiImplDogfoodTest.java new file mode 100644 index 00000000..3465addb --- /dev/null +++ b/game-cloud/game-module-project/game-module-project-server/src/test/java/com/wanxiang/huijing/game/module/project/api/ProjectVersionApiImplDogfoodTest.java @@ -0,0 +1,76 @@ +package com.wanxiang.huijing.game.module.project.api; + +import com.wanxiang.huijing.framework.common.exception.ServiceException; +import com.wanxiang.huijing.game.module.project.dto.ProjectVersionBindArtifactReqDTO; +import com.wanxiang.huijing.game.module.project.service.version.GameVersionService; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.springframework.mock.web.MockHttpServletRequest; +import org.springframework.test.util.ReflectionTestUtils; +import org.springframework.web.context.request.RequestContextHolder; +import org.springframework.web.context.request.ServletRequestAttributes; + +import static org.junit.jupiter.api.Assertions.assertDoesNotThrow; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.verifyNoInteractions; + +/** project 版本写 RPC 的狗粮可信调用边界测试。 */ +class ProjectVersionApiImplDogfoodTest { + + private GameVersionService gameVersionService; + + @BeforeEach + void setUp() { + gameVersionService = mock(GameVersionService.class); + } + + @AfterEach + void clearRequestContext() { + RequestContextHolder.resetRequestAttributes(); + } + + @Test + void bindRuntimeArtifact_rejectsExternalRpcBeforeService() { + ProjectVersionApiImpl api = createApi(); + ProjectVersionBindArtifactReqDTO req = artifactReq(); + bindRequest("/rpc-api/project/version/bind-runtime-artifact"); + + assertThrows(ServiceException.class, () -> api.bindRuntimeArtifact(req)); + + verifyNoInteractions(gameVersionService); + } + + @Test + void bindRuntimeArtifact_allowsTrustedLocalCall() { + ProjectVersionApiImpl api = createApi(); + ProjectVersionBindArtifactReqDTO req = artifactReq(); + bindRequest("/rpc-api/aigc/callback"); + + assertDoesNotThrow(() -> api.bindRuntimeArtifact(req)); + + verify(gameVersionService).bindRuntimeArtifact(req); + } + + private ProjectVersionApiImpl createApi() { + ProjectVersionApiImpl api = new ProjectVersionApiImpl(); + ReflectionTestUtils.setField(api, "gameVersionService", gameVersionService); + return api; + } + + private static void bindRequest(String requestUri) { + MockHttpServletRequest request = new MockHttpServletRequest("POST", requestUri); + RequestContextHolder.setRequestAttributes(new ServletRequestAttributes(request)); + } + + /** 构造 callback 已完成 runtime 落包后的合法绑定入参。 */ + private static ProjectVersionBindArtifactReqDTO artifactReq() { + ProjectVersionBindArtifactReqDTO req = new ProjectVersionBindArtifactReqDTO(); + req.setVersionId(2048L); + req.setChecksum("a".repeat(64)); + req.setBundleSize(4096L); + return req; + } +} diff --git a/game-cloud/game-module-project/game-module-project-server/src/test/java/com/wanxiang/huijing/game/module/project/service/version/GameVersionServiceImplTest.java b/game-cloud/game-module-project/game-module-project-server/src/test/java/com/wanxiang/huijing/game/module/project/service/version/GameVersionServiceImplTest.java index 399fa832..f08c4301 100644 --- a/game-cloud/game-module-project/game-module-project-server/src/test/java/com/wanxiang/huijing/game/module/project/service/version/GameVersionServiceImplTest.java +++ b/game-cloud/game-module-project/game-module-project-server/src/test/java/com/wanxiang/huijing/game/module/project/service/version/GameVersionServiceImplTest.java @@ -2,10 +2,13 @@ package com.wanxiang.huijing.game.module.project.service.version; import com.wanxiang.huijing.framework.common.exception.ServiceException; import com.wanxiang.huijing.framework.test.core.ut.BaseMockitoUnitTest; +import com.wanxiang.huijing.game.module.project.dal.dataobject.project.ProjectDO; import com.wanxiang.huijing.game.module.project.dal.dataobject.version.GameVersionDO; import com.wanxiang.huijing.game.module.project.dal.mysql.project.ProjectMapper; import com.wanxiang.huijing.game.module.project.dal.mysql.version.GameVersionMapper; import com.wanxiang.huijing.game.module.project.dto.ProjectVersionBindArtifactReqDTO; +import com.wanxiang.huijing.game.module.runtime.api.RuntimePackageApi; +import com.wanxiang.huijing.game.module.runtime.enums.PackageStatusEnum; import org.junit.jupiter.api.Test; import org.mockito.ArgumentCaptor; import org.mockito.InjectMocks; @@ -16,8 +19,10 @@ import static org.junit.jupiter.api.Assertions.assertEquals; import static org.junit.jupiter.api.Assertions.assertThrows; import static org.mockito.ArgumentMatchers.any; import static org.mockito.Mockito.never; +import static org.mockito.Mockito.verifyNoInteractions; import static org.mockito.Mockito.verify; import static org.mockito.Mockito.when; +import static com.wanxiang.huijing.framework.common.pojo.CommonResult.success; /** * {@link GameVersionServiceImpl} 运行产物绑定测试。 @@ -36,9 +41,13 @@ class GameVersionServiceImplTest extends BaseMockitoUnitTest { @Mock private ProjectMapper projectMapper; + @Mock + private RuntimePackageApi runtimePackageApi; + @Test void bindRuntimeArtifact_updatesAuthoritativeChecksumForApproveGate() { ProjectVersionBindArtifactReqDTO req = artifactReq("a".repeat(64)); + prepareBindableVersion(); when(gameVersionMapper.updateById(any(GameVersionDO.class))).thenReturn(1); gameVersionService.bindRuntimeArtifact(req); @@ -64,12 +73,81 @@ class GameVersionServiceImplTest extends BaseMockitoUnitTest { @Test void bindRuntimeArtifact_rejectsMissingVersion() { ProjectVersionBindArtifactReqDTO req = artifactReq("b".repeat(64)); - when(gameVersionMapper.updateById(any(GameVersionDO.class))).thenReturn(0); + when(gameVersionMapper.selectByIdForUpdate(2048L)).thenReturn(null); ServiceException error = assertThrows(ServiceException.class, () -> gameVersionService.bindRuntimeArtifact(req)); assertEquals(PROJECT_VERSION_ARTIFACT_BIND_FAILED.getCode(), error.getCode()); + verify(gameVersionMapper, never()).updateById(any(GameVersionDO.class)); + verifyNoInteractions(runtimePackageApi); + } + + @Test + void bindRuntimeArtifact_rejectsPublishedVersionWithDifferentArtifact() { + GameVersionDO version = version(3, "c".repeat(64), 4096L); + when(gameVersionMapper.selectByIdForUpdate(2048L)).thenReturn(version); + + ServiceException error = assertThrows(ServiceException.class, + () -> gameVersionService.bindRuntimeArtifact(artifactReq("b".repeat(64)))); + + assertEquals(PROJECT_VERSION_ARTIFACT_BIND_FAILED.getCode(), error.getCode()); + verify(gameVersionMapper, never()).updateById(any(GameVersionDO.class)); + } + + @Test + void bindRuntimeArtifact_rejectsVersionWhoseProjectDoesNotPointToIt() { + when(gameVersionMapper.selectByIdForUpdate(2048L)).thenReturn(version(2, "", 0L)); + ProjectDO project = new ProjectDO(); + project.setId(1024L); + project.setCurrentVersionId(9999L); + when(projectMapper.selectByIdForUpdate(1024L)).thenReturn(project); + + ServiceException error = assertThrows(ServiceException.class, + () -> gameVersionService.bindRuntimeArtifact(artifactReq("b".repeat(64)))); + + assertEquals(PROJECT_VERSION_ARTIFACT_BIND_FAILED.getCode(), error.getCode()); + verify(gameVersionMapper, never()).updateById(any(GameVersionDO.class)); + verifyNoInteractions(runtimePackageApi); + } + + @Test + void bindRuntimeArtifact_rejectsWhenRuntimePackageIsNotPreviewReady() { + prepareBindableVersion(); + when(runtimePackageApi.getStatus(2048L)).thenReturn(success(PackageStatusEnum.EXPIRED.getStatus())); + + ServiceException error = assertThrows(ServiceException.class, + () -> gameVersionService.bindRuntimeArtifact(artifactReq("b".repeat(64)))); + + assertEquals(PROJECT_VERSION_ARTIFACT_BIND_FAILED.getCode(), error.getCode()); + verify(gameVersionMapper, never()).updateById(any(GameVersionDO.class)); + } + + @Test + void bindRuntimeArtifact_runtimeLookupFailureFailsClosed() { + prepareBindableVersion(); + when(runtimePackageApi.getStatus(2048L)).thenThrow(new IllegalStateException("runtime unavailable")); + + ServiceException error = assertThrows(ServiceException.class, + () -> gameVersionService.bindRuntimeArtifact(artifactReq("b".repeat(64)))); + + assertEquals(PROJECT_VERSION_ARTIFACT_BIND_FAILED.getCode(), error.getCode()); + verify(gameVersionMapper, never()).updateById(any(GameVersionDO.class)); + } + + @Test + void bindRuntimeArtifact_sameArtifactIsIdempotent() { + GameVersionDO version = version(2, "a".repeat(64), 4096L); + when(gameVersionMapper.selectByIdForUpdate(2048L)).thenReturn(version); + ProjectDO project = new ProjectDO(); + project.setId(1024L); + project.setCurrentVersionId(2048L); + when(projectMapper.selectByIdForUpdate(1024L)).thenReturn(project); + when(runtimePackageApi.getStatus(2048L)).thenReturn(success(PackageStatusEnum.PREVIEW_READY.getStatus())); + + gameVersionService.bindRuntimeArtifact(artifactReq("a".repeat(64))); + + verify(gameVersionMapper, never()).updateById(any(GameVersionDO.class)); } /** 构造 callback 已完成 runtime 落包后的产物绑定入参。 */ @@ -80,4 +158,25 @@ class GameVersionServiceImplTest extends BaseMockitoUnitTest { req.setBundleSize(4096L); return req; } + + /** 准备版本、项目、runtime 三方都指向同一待绑定版本的正常场景。 */ + private void prepareBindableVersion() { + when(gameVersionMapper.selectByIdForUpdate(2048L)).thenReturn(version(2, "", 0L)); + ProjectDO project = new ProjectDO(); + project.setId(1024L); + project.setCurrentVersionId(2048L); + when(projectMapper.selectByIdForUpdate(1024L)).thenReturn(project); + when(runtimePackageApi.getStatus(2048L)).thenReturn(success(PackageStatusEnum.PREVIEW_READY.getStatus())); + } + + /** 构造指定状态及产物元数据的版本。 */ + private static GameVersionDO version(int status, String checksum, long bundleSize) { + GameVersionDO version = new GameVersionDO(); + version.setId(2048L); + version.setGameId(1024L); + version.setStatus(status); + version.setChecksum(checksum); + version.setBundleSize(bundleSize); + return version; + } } diff --git a/game-cloud/game-module-runtime/game-module-runtime-server/pom.xml b/game-cloud/game-module-runtime/game-module-runtime-server/pom.xml index 952f4a0b..d234c00c 100644 --- a/game-cloud/game-module-runtime/game-module-runtime-server/pom.xml +++ b/game-cloud/game-module-runtime/game-module-runtime-server/pom.xml @@ -31,6 +31,13 @@ ${revision} + + + com.wanxiang + game-module-project-api + ${revision} + + com.wanxiang diff --git a/game-cloud/game-module-runtime/game-module-runtime-server/src/main/java/com/wanxiang/huijing/game/module/runtime/controller/app/runtime/AppRuntimeController.java b/game-cloud/game-module-runtime/game-module-runtime-server/src/main/java/com/wanxiang/huijing/game/module/runtime/controller/app/runtime/AppRuntimeController.java index 76c4bc54..e8617ac5 100644 --- a/game-cloud/game-module-runtime/game-module-runtime-server/src/main/java/com/wanxiang/huijing/game/module/runtime/controller/app/runtime/AppRuntimeController.java +++ b/game-cloud/game-module-runtime/game-module-runtime-server/src/main/java/com/wanxiang/huijing/game/module/runtime/controller/app/runtime/AppRuntimeController.java @@ -34,7 +34,7 @@ import static com.wanxiang.huijing.framework.common.pojo.CommonResult.success; * * 端前缀 /app-api 由框架按包名 controller.app.* 自动添加(见 WebProperties),@RequestMapping 只写 /runtime。 * 鉴权:2026-06-10 鉴权件放行,匿名合法——取包/manifest/会话开收四端点加 @PermitAll,匿名玩家可零门槛试玩。 - * 门禁仍在 Service 强制(不依赖前端):play 场景由 status 权威判定,preview 场景 userId=null 即拒(匿名不可越权预览); + * 门禁仍在 Service 强制(不依赖前端):play 场景由 status 权威判定,preview 场景仅项目 owner 或服务端已认证管理员可读; * 会话落库兼容 userId=null(匿名 player_user_id=NULL + anon_id 归属,对齐 V11 ALTER)。 * * @author 绘境AI @@ -58,7 +58,7 @@ public class AppRuntimeController { private PackageStore packageStore; @GetMapping("/package/{versionId}") - @PermitAll // 2026-06-10 鉴权件放行,匿名合法:play 场景门禁在 Service(status 权威判定),preview 场景 userId=null 即拒(§6.1 #5) + @PermitAll // play 可匿名;preview 在 Service 按项目 owner/管理员身份强校验,匿名与跨账号均拒绝 @Operation(summary = "取版本运行包清单", description = "预览/试玩宿主据此渲染 iframe、桥接 SDK ←#3、做完整性校验 T-RT-15") @Parameter(name = "versionId", description = "版本 ID", required = true, example = "2048") @Parameter(name = "scene", description = "preview 创作者预览 / play 玩家试玩", example = "play") @@ -73,7 +73,7 @@ public class AppRuntimeController { } @GetMapping(value = "/package/{versionId}/manifest", produces = MediaType.APPLICATION_JSON_VALUE) - @PermitAll // 2026-06-10 鉴权件放行,匿名合法:与 #5 同源门禁(复用 getPackageManifest),匿名宿主可取 manifest 试玩(§6.1 #6) + @PermitAll // play 可匿名;preview manifest 与清单复用同一 owner/管理员门禁 @Operation(summary = "取版本运行包 manifest 原始 JSON", description = "§3.4 C4:返回 manifest 原始 JSON 文本,不包 CommonResult、不 parse/re-serialize;宿主对响应文本算 sha256 与 checksum 严格比对后注入运行容器") @Parameter(name = "versionId", description = "版本 ID", required = true, example = "2048") diff --git a/game-cloud/game-module-runtime/game-module-runtime-server/src/main/java/com/wanxiang/huijing/game/module/runtime/service/pkg/RuntimePackageServiceImpl.java b/game-cloud/game-module-runtime/game-module-runtime-server/src/main/java/com/wanxiang/huijing/game/module/runtime/service/pkg/RuntimePackageServiceImpl.java index 0f3feff8..887b3e35 100644 --- a/game-cloud/game-module-runtime/game-module-runtime-server/src/main/java/com/wanxiang/huijing/game/module/runtime/service/pkg/RuntimePackageServiceImpl.java +++ b/game-cloud/game-module-runtime/game-module-runtime-server/src/main/java/com/wanxiang/huijing/game/module/runtime/service/pkg/RuntimePackageServiceImpl.java @@ -6,6 +6,10 @@ import com.wanxiang.huijing.game.module.runtime.dto.RuntimePackageStoreReqDTO; import com.wanxiang.huijing.game.module.runtime.enums.PackageStatusEnum; import com.wanxiang.huijing.game.module.runtime.enums.RuntimeSceneEnum; import com.wanxiang.huijing.game.module.runtime.service.pkg.store.PackageStore; +import com.wanxiang.huijing.game.module.project.api.ProjectApi; +import com.wanxiang.huijing.framework.common.enums.UserTypeEnum; +import com.wanxiang.huijing.framework.security.core.LoginUser; +import com.wanxiang.huijing.framework.security.core.util.SecurityFrameworkUtils; import jakarta.annotation.Resource; import lombok.extern.slf4j.Slf4j; import org.springframework.stereotype.Service; @@ -49,6 +53,10 @@ public class RuntimePackageServiceImpl implements RuntimePackageService { @Resource private PackageStore packageStore; + /** project 权威归属只读 seam;预览门禁不信任前端传入的 owner 信息。 */ + @Resource + private ProjectApi projectApi; + @Override public RuntimePackageDO getPackageManifest(Long versionId, String scene, Long userId) { // 取就绪运行包(uk_version 唯一) @@ -59,7 +67,7 @@ public class RuntimePackageServiceImpl implements RuntimePackageService { } // 取包门禁(决策1,以 game_runtime_package.status 为权威判定字段,不与 project.game_version.status 混用) if (RuntimeSceneEnum.isPreview(scene)) { - // 预览:放行 status∈{0 预览就绪,1 已发布},且校验调用者为版本 owner(创作者本人预览未发布版本) + // 预览:放行 status∈{0 预览就绪,1 已发布},且校验调用者为项目 owner 或服务端已认证管理员。 if (!PackageStatusEnum.isPreviewReady(pkg.getStatus()) && !PackageStatusEnum.isPublished(pkg.getStatus())) { throw exception(RUNTIME_PACKAGE_NOT_READY); } @@ -237,24 +245,36 @@ public class RuntimePackageServiceImpl implements RuntimePackageService { } /** - * 预览归属校验:仅版本 owner(创作者本人)可预览未发布运行包 - * - * 权威 owner 归属在 project 模块(game_project.creator_user_id),本模块不持有该字段, - * 故需跨模块判定——MVP 骨架阶段以 TODO 对接点占位,待 project -api 就绪后接入。 + * 预览归属校验:仅项目 owner 或服务端已认证管理员可预览未发布运行包。 * * @param pkg 运行包 DO * @param userId 当前登录用户 ID */ private void validatePreviewOwner(RuntimePackageDO pkg, Long userId) { - // TODO 对接点【project 归属校验】:调用 project 模块 -api(Feign:ProjectApi#isGameOwner(gameId, userId)) - // 判定 userId 是否为 pkg.gameId 对应游戏的创作者本人;非本人则抛 RUNTIME_PACKAGE_PREVIEW_NOT_OWNER。 - // 骨架阶段 project -api 未就绪,为不阻断闭环联调,此处暂放行并打点;接入后改为强校验。 - if (userId == null) { - // userId 兜底:未登录用户不可预览未发布运行包(基本边界,不依赖前端) + LoginUser loginUser = SecurityFrameworkUtils.getLoginUser(); + if (loginUser != null && UserTypeEnum.ADMIN.getValue().equals(loginUser.getUserType())) { + // 管理员身份来自服务端 Token 校验后的安全上下文,不接受请求参数或客户端身份头自述。 + log.info("[validatePreviewOwner] 管理员预览放行 gameId={}, adminUserId={}", + pkg.getGameId(), loginUser.getId()); + return; + } + if (userId == null || pkg.getGameId() == null) { throw exception(RUNTIME_PACKAGE_PREVIEW_NOT_OWNER); } - log.info("[validatePreviewOwner] 预览归属校验占位放行 gameId={}, userId={}(待 project -api 接入强校验)", - pkg.getGameId(), userId); + Long ownerUserId; + try { + ownerUserId = projectApi.getCreatorUserId(pkg.getGameId()).getCheckedData(); + } catch (Exception ex) { + // 权威归属读取失败时必须 fail-closed,且日志不记录 Token 或请求内容。 + log.warn("[validatePreviewOwner] 项目归属读取失败,拒绝预览 gameId={}, userId={}", + pkg.getGameId(), userId, ex); + throw exception(RUNTIME_PACKAGE_PREVIEW_NOT_OWNER); + } + if (!Objects.equals(ownerUserId, userId)) { + log.warn("[validatePreviewOwner] 非项目所有者预览被拒绝 gameId={}, userId={}", pkg.getGameId(), userId); + throw exception(RUNTIME_PACKAGE_PREVIEW_NOT_OWNER); + } + log.info("[validatePreviewOwner] 项目所有者预览放行 gameId={}, userId={}", pkg.getGameId(), userId); } } diff --git a/game-cloud/game-module-runtime/game-module-runtime-server/src/test/java/com/wanxiang/huijing/game/module/runtime/service/pkg/RuntimePackageServiceImplTest.java b/game-cloud/game-module-runtime/game-module-runtime-server/src/test/java/com/wanxiang/huijing/game/module/runtime/service/pkg/RuntimePackageServiceImplTest.java index 3e332520..ccd9e37a 100644 --- a/game-cloud/game-module-runtime/game-module-runtime-server/src/test/java/com/wanxiang/huijing/game/module/runtime/service/pkg/RuntimePackageServiceImplTest.java +++ b/game-cloud/game-module-runtime/game-module-runtime-server/src/test/java/com/wanxiang/huijing/game/module/runtime/service/pkg/RuntimePackageServiceImplTest.java @@ -6,15 +6,22 @@ import com.wanxiang.huijing.game.module.runtime.dto.RuntimePackageStoreReqDTO; import com.wanxiang.huijing.game.module.runtime.enums.PackageStatusEnum; import com.wanxiang.huijing.game.module.runtime.enums.RuntimeSceneEnum; import com.wanxiang.huijing.game.module.runtime.service.pkg.store.PackageStore; +import com.wanxiang.huijing.game.module.project.api.ProjectApi; +import com.wanxiang.huijing.framework.common.enums.UserTypeEnum; +import com.wanxiang.huijing.framework.security.core.LoginUser; +import com.wanxiang.huijing.framework.security.core.util.SecurityFrameworkUtils; import com.wanxiang.huijing.framework.common.exception.ServiceException; import com.wanxiang.huijing.framework.test.core.ut.BaseMockitoUnitTest; import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.AfterEach; +import org.springframework.mock.web.MockHttpServletRequest; import org.mockito.ArgumentCaptor; import org.mockito.InjectMocks; import org.mockito.Mock; import static com.wanxiang.huijing.game.module.runtime.enums.ErrorCodeConstants.*; import static org.junit.jupiter.api.Assertions.*; +import static com.wanxiang.huijing.framework.common.pojo.CommonResult.success; import static org.mockito.ArgumentMatchers.any; import static org.mockito.ArgumentMatchers.anyLong; import static org.mockito.ArgumentMatchers.anyString; @@ -39,6 +46,14 @@ class RuntimePackageServiceImplTest extends BaseMockitoUnitTest { @Mock private PackageStore packageStore; + @Mock + private ProjectApi projectApi; + + @AfterEach + void clearSecurityContext() { + org.springframework.security.core.context.SecurityContextHolder.clearContext(); + } + // ============================== getPackageManifest 取包门禁 ============================== @Test @@ -78,13 +93,38 @@ class RuntimePackageServiceImplTest extends BaseMockitoUnitTest { @Test void testGetPackage_previewReadyOwnerOk() { - // scene=preview + status=0 预览就绪 + 登录用户存在(骨架占位放行)→ 放行 + // scene=preview + status=0 预览就绪 + 当前用户是项目 owner → 放行 RuntimePackageDO p = pkg(PackageStatusEnum.PREVIEW_READY.getStatus()); when(runtimePackageMapper.selectByVersionId(2048L)).thenReturn(p); + when(projectApi.getCreatorUserId(1024L)).thenReturn(success(99L)); RuntimePackageDO got = runtimePackageService.getPackageManifest(2048L, RuntimeSceneEnum.PREVIEW.getScene(), 99L); assertSame(p, got); } + @Test + void testGetPackage_previewCrossAccountRejected() { + when(runtimePackageMapper.selectByVersionId(2048L)).thenReturn(pkg(PackageStatusEnum.PREVIEW_READY.getStatus())); + when(projectApi.getCreatorUserId(1024L)).thenReturn(success(99L)); + + ServiceException ex = assertThrows(ServiceException.class, + () -> runtimePackageService.getPackageManifest(2048L, RuntimeSceneEnum.PREVIEW.getScene(), 100L)); + + assertEquals(RUNTIME_PACKAGE_PREVIEW_NOT_OWNER.getCode(), ex.getCode()); + } + + @Test + void testGetPackage_previewTrustedAdminAllowed() { + when(runtimePackageMapper.selectByVersionId(2048L)).thenReturn(pkg(PackageStatusEnum.PREVIEW_READY.getStatus())); + MockHttpServletRequest request = new MockHttpServletRequest(); + SecurityFrameworkUtils.setLoginUser(new LoginUser().setId(7L) + .setUserType(UserTypeEnum.ADMIN.getValue()), request); + + RuntimePackageDO got = runtimePackageService.getPackageManifest( + 2048L, RuntimeSceneEnum.PREVIEW.getScene(), 7L); + + assertEquals(2048L, got.getVersionId()); + } + @Test void testGetPackage_previewNotLoginRejected() { // scene=preview 但无登录用户 → 归属门禁拒绝(基本边界) diff --git a/game-runtime/tools/newapi_pool_provision.py b/game-runtime/tools/newapi_pool_provision.py index bae5b954..ddbbca24 100644 --- a/game-runtime/tools/newapi_pool_provision.py +++ b/game-runtime/tools/newapi_pool_provision.py @@ -15,7 +15,7 @@ provision 全部离线做(S0 实测坐实:new-api admin 令牌不能替他 每个池条目四步建成(S0 坐实的唯一可行路径) ------------------------------------------------ 1. POST /api/user/(root 令牌)建用户,username = 确定性 `<前缀><序号>`;从 postgres 查 uid。 - 2. postgres 直写 UPDATE users SET access_token=<确定性 32 位串> WHERE id=uid + 2. postgres 直写 UPDATE users SET access_token=<高熵随机串> WHERE id=uid —— access_token 无法经 API 设,又是下一步「以该用户身份建 token」的前提。 3. POST /api/token/(以该用户 access_token + New-Api-User:uid 建 token), remain_quota=¥100 折算、unlimited_quota=false、expired_time=-1(不过期);从 postgres 查 token_id/key。 @@ -23,15 +23,20 @@ provision 全部离线做(S0 实测坐实:new-api admin 令牌不能替他 幂等(可重跑补池) ------------------ -按确定性 username 去重:重跑时若用户已存在则复用其 uid,逐步「补齐」缺失的 access_token / -token / quota(ensure 语义),不重复建号。补池 = 提高 --count 重跑。单条失败只记该条、不污染后续。 +按确定性 username 去重:重跑时若用户已存在则复用其 uid,按数据库中的同名 token 实现幂等, +逐步「补齐」缺失的 access_token / token / quota(ensure 语义),不重复建号。补池 = 提高 +--count 重跑。单条失败只记该条、不污染后续。 + +预算边界:既有同名 token 只读核验,任一额度或可用状态不一致都立即失败,不自动修复。 +只有本次执行确认 POST 创建成功且尚未 emit/import 的 token 才能修复落库漂移;脚本崩溃后 +遗留的 token 在重跑时也按既有 token 处理,默认禁止补余额。 外部交互红线 ------------ - HTTP:连接/读超时(urlopen timeout=读10s,内部连粒度由 socket 兜),非 2xx / success=false 归错, 5xx/超时重试 2 次(指数退避),4xx 不重试;全程绕系统代理(ProxyHandler({}),内网直连 100.64.x)。 - postgres:经 `docker exec infra-postgres psql` 执行(S0 验证路径),失败即抛、该条标记失败。 - - 日志:token / access_token 一律脱敏(前后各留 4 位);可追溯(时间戳 + 步骤 + 条目序号)。 + - 日志:token / access_token 不输出整串或片段;可追溯(时间戳 + 步骤 + 条目序号)。 凭据(不硬编码) ---------------- @@ -64,6 +69,7 @@ import urllib.error import urllib.request from datetime import datetime from pathlib import Path +from typing import NamedTuple # ─── 常量 ──────────────────────────────────────────────────────────────── NEWAPI_BASE = os.environ.get("NEWAPI_BASE", "http://localhost:3000") # 脚本宿主 mini-infra,本地直连 @@ -80,13 +86,20 @@ POOL_TABLE = "newapi_quota_pool" log = logging.getLogger("newapi_pool") -def _mask(token: str | None) -> str: - """脱敏敏感串:前后各留 4 位,中间打码。用于日志(红线:token/access_token 不整条打日志)。""" - if not token: - return "" - if len(token) <= 10: - return token[:2] + "***" - return f"{token[:4]}…{token[-4:]}(len={len(token)})" +class TokenState(NamedTuple): + """new-api token 的额度闸状态;key 只用于受控交接,不进入日志。""" + + token_id: int + token_key: str + remain_quota: int + status: int + unlimited_quota: bool + expired_time: int + + +def legacy_access_token(username: str) -> str: + """仅用于识别并轮换旧版可推导凭据,严禁用于生成新凭据。""" + return hashlib.sha256(f"neice-quota-pool::v1::{username}".encode()).hexdigest()[:32] # ─── postgres 直连(经 docker exec psql,S0 验证路径) ────────────────────── @@ -149,16 +162,42 @@ class Postgres: # access_token 无法经 API 设,必须 DB 直写(S0 坐实的关键步) self._run(f"UPDATE users SET access_token='{access_token}' WHERE id={uid};", "set-access-token") - def query_token(self, uid: int, name: str) -> tuple[int, str] | None: - """按 user_id + token name 查 token,返回 (token_id, token_key)。""" + def query_token(self, uid: int, name: str) -> TokenState | None: + """按 user_id + token name 查完整权威状态。 + + CASE 表达式把 PostgreSQL boolean 与 SQLite 的 0/1 存储统一为整数,避免脚本 + 依赖数据库驱动特有的布尔文本格式。 + """ out = self._run( - f"SELECT id, key FROM tokens WHERE user_id={uid} AND name='{name}' ORDER BY id LIMIT 1;" + "SELECT id, key, COALESCE(remain_quota, 0), COALESCE(status, 0), " + "CASE WHEN unlimited_quota THEN 1 ELSE 0 END, COALESCE(expired_time, 0) " + f"FROM tokens WHERE user_id={uid} AND name='{name}' ORDER BY id LIMIT 1;" ) line = out.splitlines()[0].strip() if out else "" - if not line or "|" not in line: + if not line: return None - tid, key = line.split("|", 1) - return int(tid), key.strip() + fields = line.split("|") + if len(fields) != 6: + raise RuntimeError(f"token 查询结果字段数异常 uid={uid}") + token_id, token_key, remain_quota, status, unlimited_quota, expired_time = fields + return TokenState( + token_id=int(token_id), + token_key=token_key.strip(), + remain_quota=int(remain_quota), + status=int(status), + unlimited_quota=bool(int(unlimited_quota)), + expired_time=int(expired_time), + ) + + def repair_token(self, token_id: int, grant: int, *, created_in_this_run: bool = False) -> None: + """仅修复本次刚创建的 token;默认拒绝,防止重跑给既有 token 充值。""" + if not created_in_this_run: + raise RuntimeError(f"只允许修复本次新建 token token_id={token_id}") + self._run( + f"UPDATE tokens SET remain_quota={grant}, status=1, " + f"unlimited_quota=FALSE, expired_time=-1 WHERE id={token_id};", + "repair-token", + ) def delete_user_cascade(self, username_like: str) -> tuple[int, int]: """清理:按 username LIKE 删 users + 其 tokens。返回 (删 tokens 数, 删 users 数)。 @@ -275,6 +314,39 @@ def ensure_user_quota(pg: Postgres, root_token: str, uid: int, username: str, gr return "database-repair" +def _token_state_matches(token: TokenState, grant: int) -> bool: + """权威 token 必须可用、有限额、不过期,并持有本批完整余额。""" + return ( + token.remain_quota == grant + and token.status == 1 + and not token.unlimited_quota + and token.expired_time == -1 + ) + + +def ensure_token_state(pg: Postgres, uid: int, name: str, grant: int, *, + created_in_this_run: bool = False) -> TokenState: + """核验 token;只有本次刚创建、尚未 emit/import 的 token 才允许修复。""" + token = pg.query_token(uid, name) + if token is None: + raise RuntimeError(f"未查到待核验 token uid={uid} name={name}") + if _token_state_matches(token, grant): + return token + + if not created_in_this_run: + raise RuntimeError( + f"既有 token 状态异常,可能已导入或消费,默认禁止修复或充值 " + f"uid={uid} token_id={token.token_id}" + ) + + pg.repair_token(token.token_id, grant, created_in_this_run=True) + repaired = pg.query_token(uid, name) + if repaired is None or repaired.token_id != token.token_id or not _token_state_matches(repaired, grant): + raise RuntimeError(f"token 权威状态修复后仍不一致 uid={uid} token_id={token.token_id}") + log.warning("[token] 已修复并核验权威额度状态 uid=%d token_id=%d", uid, token.token_id) + return repaired + + def validate_provision_request(start: int, count: int, prefix: str, hostname: str) -> list[str]: """校验离线补池边界,并返回确定性的目标用户名。 @@ -326,17 +398,24 @@ def provision_one(pg: Postgres, root_token: str, username: str, grant: int, else: log.info("[1/4] 用户 %s 已存在 uid=%d(复用)", username, uid) - # 步骤 2:DB 直写 access_token(确定性,可重复 UPDATE 幂等) - access_token = hashlib.sha256(f"neice-quota-pool::v1::{username}".encode()).hexdigest()[:32] + # 任何凭据或额度写入前先只读核验既有 token;异常即终止,默认重跑保持零副作用。 + tok = pg.query_token(uid, username) + token = None + if tok is not None: + token = ensure_token_state(pg, uid, username, grant) + + # 步骤 2:已有高熵 access_token 原值复用;缺失或旧版可推导值则安全轮换。 existing_at = pg.query_access_token(uid) - if existing_at != access_token: + if not existing_at or secrets.compare_digest(existing_at, legacy_access_token(username)): + access_token = secrets.token_urlsafe(24) pg.set_access_token(uid, access_token) - log.info("[2/4] DB 写 access_token uid=%d → %s", uid, _mask(access_token)) + log.info("[2/4] DB 写入随机 access_token uid=%d(值不记录)", uid) else: - log.info("[2/4] access_token uid=%d 已就绪 %s(复用)", uid, _mask(access_token)) + access_token = existing_at + log.info("[2/4] access_token uid=%d 已就绪(复用,值不记录)", uid) # 步骤 3:以该用户身份建 token(幂等——已存在同名 token 则复用) - tok = pg.query_token(uid, username) + token_created_in_this_run = False if tok is None: _http_json("POST", "/api/token/", access_token, uid, { "name": username, @@ -347,10 +426,17 @@ def provision_one(pg: Postgres, root_token: str, username: str, grant: int, tok = wait_for_db_visibility(lambda: pg.query_token(uid, username)) if tok is None: raise RuntimeError(f"建 token 后 postgres 未查到 user={uid} name={username}") - log.info("[3/4] 建 token uid=%d → token_id=%d key=%s", uid, tok[0], _mask(tok[1])) + # 仅该分支能证明 token 尚未进入 emit/import 流程,允许修复 API 落库漂移。 + token_created_in_this_run = True + log.info("[3/4] 建 token uid=%d → token_id=%d(key 不记录)", uid, tok.token_id) else: - log.info("[3/4] token uid=%d name=%s 已存在 token_id=%d(复用)", uid, username, tok[0]) - token_id, token_key = tok + log.info("[3/4] token uid=%d name=%s 已存在 token_id=%d(复用)", uid, username, tok.token_id) + + # 新建 token 在触碰 user.quota 前完成核验;既有 token 已在所有写入前只读核验。 + if token is None: + token = ensure_token_state( + pg, uid, username, grant, created_in_this_run=token_created_in_this_run + ) # 步骤 4:设 user.quota(root 令牌;账户自洽,权威余额闸仍是 token.remain_quota) quota_result = ensure_user_quota(pg, root_token, uid, username, grant) @@ -361,11 +447,14 @@ def provision_one(pg: Postgres, root_token: str, username: str, grant: int, else: log.warning("[4/4] 管理 API 未兑现 quota,数据库补偿并核验 uid=%d → %d", uid, grant) + # user.quota 只用于账户自洽;所有写入结束后再次只读核验,禁止借最终检查触发充值。 + token = ensure_token_state(pg, uid, username, grant) + # 组装池表条目(§3.5 契约字段) return { "newapi_user_id": uid, - "newapi_token_id": token_id, - "newapi_token_key": token_key, + "newapi_token_id": token.token_id, + "newapi_token_key": token.token_key, "grant_quota": grant, "quota_per_unit_snapshot": qpu, "usd_rate_snapshot": usd, @@ -424,7 +513,7 @@ def emit_outputs(entries: list[dict], out_dir: Path, prefix: str) -> tuple[Path, def cmd_provision(args) -> int: pg = Postgres(os.environ.get("NEWAPI_PG_PASSWORD", "")) root_token = os.environ.get("NEWAPI_ROOT_TOKEN") or pg.query_root_token() - log.info("root 管理令牌就绪 %s", _mask(root_token)) + log.info("root 管理令牌已就绪(值不记录)") qpu, usd = fetch_conversion() grant = compute_grant(args.yuan, qpu, usd) diff --git a/game-runtime/tools/test_newapi_pool_provision.py b/game-runtime/tools/test_newapi_pool_provision.py index 02501765..2bf634b1 100755 --- a/game-runtime/tools/test_newapi_pool_provision.py +++ b/game-runtime/tools/test_newapi_pool_provision.py @@ -5,6 +5,7 @@ from __future__ import annotations import importlib.util +import sqlite3 import stat import tempfile import unittest @@ -19,6 +20,20 @@ MODULE = importlib.util.module_from_spec(SPEC) SPEC.loader.exec_module(MODULE) +def token_state(*, token_key: str = "new-api-generated-secret-key", + remain_quota: int = 6849315, status: int = 1, + unlimited_quota: bool = False, expired_time: int = -1): + """构造 token 权威状态,测试只覆盖与额度池有关的字段。""" + return MODULE.TokenState( + token_id=151, + token_key=token_key, + remain_quota=remain_quota, + status=status, + unlimited_quota=unlimited_quota, + expired_time=expired_time, + ) + + class ProvisionRequestTest(unittest.TestCase): """验证补建区间、宿主边界与敏感产物权限。""" @@ -104,5 +119,191 @@ class ProvisionRequestTest(unittest.TestCase): pg.set_user_quota.assert_not_called() +class TokenStateTest(unittest.TestCase): + """验证 token 权威余额与可用状态的修复、复核和 schema 兼容性。""" + + def assert_token_repaired(self, broken_state) -> None: + pg = mock.Mock() + pg.query_token.side_effect = [broken_state, token_state()] + + actual = MODULE.ensure_token_state( + pg, 67, "neice_051", 6849315, created_in_this_run=True + ) + + self.assertEqual(actual, token_state()) + pg.repair_token.assert_called_once_with( + 151, 6849315, created_in_this_run=True + ) + + def test_repairs_newly_created_disabled_token(self) -> None: + self.assert_token_repaired(token_state(status=0)) + + def test_repairs_newly_created_zero_token_balance(self) -> None: + self.assert_token_repaired(token_state(remain_quota=0)) + + def test_repairs_newly_created_unlimited_token(self) -> None: + self.assert_token_repaired(token_state(unlimited_quota=True)) + + def test_repairs_newly_created_expired_token(self) -> None: + self.assert_token_repaired(token_state(expired_time=1_800_000_000)) + + def test_default_rerun_never_refills_consumed_existing_token(self) -> None: + pg = mock.Mock() + pg.query_token.return_value = token_state(remain_quota=0) + + with self.assertRaisesRegex(RuntimeError, "既有 token.*禁止修复或充值"): + MODULE.ensure_token_state(pg, 67, "neice_051", 6849315) + + pg.repair_token.assert_not_called() + + def test_default_rerun_never_reactivates_existing_disabled_token(self) -> None: + pg = mock.Mock() + pg.query_token.return_value = token_state(status=0) + + with self.assertRaisesRegex(RuntimeError, "既有 token.*禁止修复或充值"): + MODULE.ensure_token_state(pg, 67, "neice_051", 6849315) + + pg.repair_token.assert_not_called() + + def test_direct_repair_is_denied_without_new_token_proof(self) -> None: + pg = MODULE.Postgres.__new__(MODULE.Postgres) + pg._run = mock.Mock() + + with self.assertRaisesRegex(RuntimeError, "只允许修复本次新建 token"): + pg.repair_token(151, 6849315) + + pg._run.assert_not_called() + + def test_rejects_repair_when_authoritative_token_state_stays_invalid(self) -> None: + pg = mock.Mock() + pg.query_token.side_effect = [token_state(status=0), token_state(status=0)] + + with self.assertRaisesRegex(RuntimeError, "token 权威状态修复后仍不一致"): + MODULE.ensure_token_state( + pg, 67, "neice_051", 6849315, created_in_this_run=True + ) + + def test_token_sql_is_compatible_with_sqlite_schema(self) -> None: + connection = sqlite3.connect(":memory:") + connection.execute( + "CREATE TABLE tokens (" + "id INTEGER PRIMARY KEY, user_id INTEGER, name TEXT, key TEXT, " + "remain_quota INTEGER, status INTEGER, unlimited_quota BOOLEAN, expired_time INTEGER)" + ) + connection.execute( + "INSERT INTO tokens VALUES (151, 67, 'neice_051', 'secret-key', 0, 0, 1, 123)" + ) + pg = MODULE.Postgres.__new__(MODULE.Postgres) + + def run_sql(sql: str, operation: str = "query") -> str: + cursor = connection.execute(sql) + if cursor.description is None: + connection.commit() + return "" + return "\n".join("|".join(str(value) for value in row) for row in cursor.fetchall()) + + pg._run = run_sql + before = pg.query_token(67, "neice_051") + self.assertEqual(before, token_state( + token_key="secret-key", remain_quota=0, status=0, + unlimited_quota=True, expired_time=123 + )) + + pg.repair_token(151, 6849315, created_in_this_run=True) + + self.assertEqual(pg.query_token(67, "neice_051"), token_state(token_key="secret-key")) + connection.close() + + +class ProvisionSecurityTest(unittest.TestCase): + """验证随机管理凭据、同名 token 幂等与日志脱敏。""" + + def build_existing_user(self, access_token: str = "") -> mock.Mock: + pg = mock.Mock() + pg.query_user_id.return_value = 67 + pg.query_access_token.return_value = access_token + pg.query_user_quota.return_value = 6849315 + return pg + + def test_new_access_tokens_are_random_for_same_username(self) -> None: + generated = iter(("random-access-token-first-123456", "random-access-token-second-654321")) + written_tokens = [] + + with mock.patch.object(MODULE.secrets, "token_urlsafe", side_effect=lambda size: next(generated)), \ + mock.patch.object(MODULE, "_http_json"): + for token_key in ("gateway-key-first", "gateway-key-second"): + pg = self.build_existing_user() + pg.query_token.side_effect = [None, MODULE.TokenState( + 151, token_key, 6849315, 1, False, -1 + ), MODULE.TokenState(151, token_key, 6849315, 1, False, -1), + MODULE.TokenState(151, token_key, 6849315, 1, False, -1), + MODULE.TokenState(151, token_key, 6849315, 1, False, -1)] + MODULE.provision_one(pg, "root-token", "neice_051", 6849315, 500000, 7.3, False) + written_tokens.append(pg.set_access_token.call_args.args[1]) + + self.assertNotEqual(written_tokens[0], written_tokens[1]) + self.assertNotIn("neice_051", written_tokens) + + def test_rerun_reuses_existing_user_and_token(self) -> None: + pg = self.build_existing_user("existing-random-access-token-123") + pg.query_token.return_value = token_state() + + with mock.patch.object(MODULE, "_http_json") as request, \ + mock.patch.object(MODULE.secrets, "token_urlsafe") as random_token: + result = MODULE.provision_one( + pg, "root-token", "neice_051", 6849315, 500000, 7.3, False + ) + + self.assertEqual(result["newapi_token_id"], 151) + self.assertEqual(result["newapi_token_key"], "new-api-generated-secret-key") + request.assert_not_called() + random_token.assert_not_called() + pg.set_access_token.assert_not_called() + pg.repair_token.assert_not_called() + + def test_rotates_legacy_predictable_access_token(self) -> None: + legacy_token = MODULE.legacy_access_token("neice_051") + pg = self.build_existing_user(legacy_token) + pg.query_token.return_value = token_state() + + with mock.patch.object(MODULE.secrets, "token_urlsafe", return_value="rotated-random-token-123456789"): + MODULE.provision_one(pg, "root-token", "neice_051", 6849315, 500000, 7.3, False) + + pg.set_access_token.assert_called_once_with(67, "rotated-random-token-123456789") + + def test_logs_do_not_reveal_access_or_gateway_token_fragments(self) -> None: + access_token = "random-access-token-sensitive-123456" + gateway_key = "gateway-token-sensitive-654321" + pg = self.build_existing_user() + state = MODULE.TokenState(151, gateway_key, 6849315, 1, False, -1) + pg.query_token.side_effect = [None, state, state, state] + + with mock.patch.object(MODULE.secrets, "token_urlsafe", return_value=access_token), \ + mock.patch.object(MODULE, "_http_json"), \ + self.assertLogs("newapi_pool", level="INFO") as captured: + MODULE.provision_one(pg, "root-token", "neice_051", 6849315, 500000, 7.3, False) + + logs = "\n".join(captured.output) + for secret in (access_token, gateway_key): + self.assertNotIn(secret, logs) + self.assertNotIn(secret[:4], logs) + self.assertNotIn(secret[-4:], logs) + + def test_existing_consumed_token_fails_before_user_quota_write(self) -> None: + pg = self.build_existing_user(MODULE.legacy_access_token("neice_051")) + pg.query_token.return_value = token_state(remain_quota=0) + + with mock.patch.object(MODULE.secrets, "token_urlsafe") as random_token: + with self.assertRaisesRegex(RuntimeError, "禁止修复或充值"): + MODULE.provision_one( + pg, "root-token", "neice_051", 6849315, 500000, 7.3, False + ) + + pg.repair_token.assert_not_called() + pg.set_access_token.assert_not_called() + pg.set_user_quota.assert_not_called() + random_token.assert_not_called() + + if __name__ == "__main__": unittest.main()