fix(release): close final dogfood safety gaps

This commit is contained in:
lili 2026-07-23 01:43:41 -07:00
parent ebd74a87ee
commit 0551b3552f
22 changed files with 1901 additions and 167 deletions

View File

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

View File

@ -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:
"""校验角色权限用例和截图清单。"""

View File

@ -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 = {

View File

@ -95,8 +95,11 @@ security_java_tests=(
DogfoodRbacSeedTest
TokenAuthenticationFilterTest
AuthenticationEntryPointImplTest
DifyCallbackServiceImplTest
GameVersionServiceImplTest
PassportServiceImplTest
ProjectServiceImplTest
PublishOrchestrationServiceImplTest
RuntimePackageServiceImplTest
RuntimePackageApiImplDogfoodTest
AdminRuntimeControllerDogfoodTest

View File

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

View File

@ -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" <<EOF
schema dogfood-provision-cleanup/2
run_id cleanup-run-001
key passport_ip:register:hour:127.0.0.1:2026072312
key passport_ip:register:day:127.0.0.1:20260723
key passport_ip:login:hour:127.0.0.1:2026072312
key passport_ip:login:day:127.0.0.1:20260723
key oauth2_access_token:accessToken0123456789abcdef
key oauth2_access_token:refreshToken0123456789abcdef
oauth 101 601 $ACCESS_TOKEN_HASH $REFRESH_TOKEN_HASH
EOF
chmod 600 "$TMP_DIR/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.sql" \
DOGFOOD_FAKE_REDIS_DELETES="$TMP_DIR/redis-deletes.log" \
"$SCRIPT" --cleanup "$TMP_DIR/runtime-cleanup.tsv" cleanup-run-001 \
>"$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" <<EOF
schema dogfood-provision-cleanup/2
run_id cleanup-run-$db_state
key passport_ip:login:day:127.0.0.1:20260723
oauth 101 601 $ACCESS_TOKEN_HASH $REFRESH_TOKEN_HASH
EOF
chmod 600 "$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" <<EOF
schema dogfood-provision-cleanup/2
run_id cleanup-run-target-count-drift
oauth 101 601 $ACCESS_TOKEN_HASH $REFRESH_TOKEN_HASH
EOF
chmod 600 "$target_manifest"
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='target-count-drift' DOGFOOD_FAKE_CLEANUP_SQL="$TMP_DIR/mysql-cleanup-target-count-drift.sql" \
DOGFOOD_FAKE_REDIS_DELETES="$TMP_DIR/target-count-drift-redis.log" \
"$SCRIPT" --cleanup "$target_manifest" cleanup-run-target-count-drift \
>"$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" <<EOF
schema dogfood-provision-cleanup/2
run_id cleanup-run-retry
oauth 101 601 $ACCESS_TOKEN_HASH $REFRESH_TOKEN_HASH
EOF
chmod 600 "$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'

View File

@ -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" <<EOF
schema=generation-r1-gate/1
schema=generation-r1-gate/2
rc_commit=0000000000000000000000000000000000000000
run_id=$GENERATION_GATE_RUN_ID
issuer=$GENERATION_ATTESTATION_EXPECTED_ISSUER
issued_at=$issued_at
expires_at=$expires_at
r1_status=PASS
actor_prompt_eval=PASS
judge_prompt_eval=PASS
@ -292,15 +411,60 @@ chmod 600 "$attestation_file"
attestation_hash="$(sha256sum "$attestation_file" | awk '{print $1}')"
mv "$attestation_file" "$attestation_root/r1/$attestation_hash.attestation"
attestation_file="$attestation_root/r1/$attestation_hash.attestation"
openssl pkeyutl -sign -inkey "$TMP_DIR/r1-private.pem" -rawin \
-in "$attestation_file" -out "$attestation_file.sig"
chmod 600 "$attestation_file.sig"
attestation_ref="sha256:$attestation_hash:r1/$attestation_hash.attestation"
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 >"$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"

View File

@ -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 竞争超时,请重试");
}
/**

View File

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

View File

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

View File

@ -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<Boolean> 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<Integer> 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);
}
}

View File

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

View File

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

View File

@ -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());

View File

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

View File

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

View File

@ -31,6 +31,13 @@
<version>${revision}</version>
</dependency>
<!-- 依赖 project 的 -api:预览门禁按 gameId 查询权威创作者归属;禁止依赖 project-server,避免实现层耦合。 -->
<dependency>
<groupId>com.wanxiang</groupId>
<artifactId>game-module-project-api</artifactId>
<version>${revision}</version>
</dependency>
<!-- 业务组件:数据权限(玩家/创作者只见自己数据)+ 多租户(DO 继承 TenantBaseDO) -->
<dependency>
<groupId>com.wanxiang</groupId>

View File

@ -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")

View File

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

View File

@ -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 但无登录用户 → 归属门禁拒绝(基本边界)

View File

@ -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 "<empty>"
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)

View File

@ -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()