fix(release): bind dogfood gates to runtime state
Some checks failed
contract-gates / contract-gates (push) Has been cancelled
docs-gate / docs-gate (push) Has been cancelled

This commit is contained in:
lili 2026-07-23 02:49:08 -07:00
parent 495bd30a0c
commit bcb7fec906
9 changed files with 553 additions and 47 deletions

View File

@ -736,8 +736,43 @@ restore_active_pointer() {
fi
}
verify_active_pointer() {
local target="$1" expected_realpath actual_realpath expected_commit actual_commit
if [ "$target" = "none" ]; then
[ ! -e "$active_pointer" ] && [ ! -L "$active_pointer" ] || {
log_step "active 指针应为空但仍存在 path=$active_pointer"
return 1
}
return 0
fi
[ -L "$active_pointer" ] || {
log_step "active 指针不是符号链接 path=$active_pointer"
return 1
}
expected_realpath="$(realpath "$target")" || return 1
actual_realpath="$(readlink -f "$active_pointer")" || return 1
[ "$actual_realpath" = "$expected_realpath" ] || {
log_step "active 指针目标不一致 expected=$expected_realpath actual=$actual_realpath"
return 1
}
expected_commit="$(git -C "$expected_realpath" rev-parse HEAD)" || return 1
[[ "$expected_commit" =~ ^[0-9a-f]{40}$ ]] || return 1
[ "$(basename "$expected_realpath")" = "$expected_commit" ] || {
log_step "active RC 目录名与提交不一致 path=$expected_realpath commit=$expected_commit"
return 1
}
actual_commit="$(git -C "$actual_realpath" rev-parse HEAD)" || return 1
[ "$actual_commit" = "$expected_commit" ] || {
log_step "active 指针提交不一致 expected=$expected_commit actual=$actual_commit"
return 1
}
}
failure_handler() {
local rc=$?
local rc=$? rollback_rc=0 rollback_service_rc=not-run rollback_pointer_rc=not-run
local maintenance_release_rc=not-run
trap - ERR
set +e
log_step "ACTIVATION FAIL rc=${rc},开始保留证据并恢复旧栈"
@ -759,32 +794,61 @@ failure_handler() {
fi
fi
if [ "$switch_started" -eq 1 ]; then
stop_stack "$stable_prefix" 48080 4173 4174 8300 9501
rollback_service_rc=0
stop_stack "$stable_prefix" 48080 4173 4174 8300 9501 || rollback_service_rc=$?
if [ "$previous_was_active" -eq 1 ] && [ "$previous_valid" -eq 1 ]; then
log_step "恢复旧版本服务 target=$previous"
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=$?
if [ "$rollback_service_rc" -eq 0 ]; then
start_stack "$previous" rollback "$stable_prefix" 48080 4173 4174 8300 9501 true \
|| rollback_service_rc=$?
fi
if [ "$rollback_rc" -eq 0 ]; then
release_maintenance_gate || rollback_rc=$?
if [ "$rollback_service_rc" -eq 0 ]; then
verify_stack rollback "$stable_prefix" 48080 4173 4174 8300 9501 \
|| rollback_service_rc=$?
fi
printf 'rollback_service_rc=%s\n' "$rollback_rc" >"$evidence/rollback-result.txt"
if [ "$rollback_rc" -ne 0 ]; then
log_step "旧栈五服务恢复或复核失败,保留维护门"
rc=73
if [ "$rollback_service_rc" -ne 0 ]; then
rollback_rc="$rollback_service_rc"
fi
else
printf 'rollback_service_rc=not-available\nFIRST_RC_FULL_ROLLBACK=UNAVAILABLE\n' >"$evidence/rollback-result.txt"
rollback_service_rc=not-available
rollback_rc=73
fi
# 切换事务一旦开始,就重建 canonical active不能依赖 pointer_changed 标记覆盖所有失败窗口。
if [ "$previous_was_active" -eq 1 ] && [ "$previous_valid" -eq 1 ]; then
rollback_pointer_rc=0
restore_active_pointer "$previous" || rollback_pointer_rc=$?
if [ "$rollback_pointer_rc" -eq 0 ]; then
verify_active_pointer "$previous" || rollback_pointer_rc=$?
fi
if [ "$rollback_pointer_rc" -ne 0 ] && [ "$rollback_rc" -eq 0 ]; then
rollback_rc="$rollback_pointer_rc"
fi
fi
fi
if [ "$pointer_changed" -eq 1 ]; then
restore_active_pointer "$previous"
# 数据库、旧栈和 active 指针全部恢复后,解除维护门才是失败补偿的最后一步。
if [ "$maintenance_gate_active" -eq 1 ] && [ "$rollback_rc" -eq 0 ]; then
maintenance_release_rc=0
release_maintenance_gate || maintenance_release_rc=$?
if [ "$maintenance_release_rc" -ne 0 ]; then
rollback_rc="$maintenance_release_rc"
fi
fi
if [ "$switch_started" -eq 0 ] && [ "$maintenance_gate_active" -eq 1 ]; then
release_maintenance_gate || rc=74
if [ "$switch_started" -eq 1 ]; then
{
printf 'rollback_service_rc=%s\n' "$rollback_service_rc"
printf 'rollback_pointer_rc=%s\n' "$rollback_pointer_rc"
printf 'maintenance_release_rc=%s\n' "$maintenance_release_rc"
printf 'rollback_rc=%s\n' "$rollback_rc"
if [ "$previous_was_active" -ne 1 ] || [ "$previous_valid" -ne 1 ]; then
printf 'FIRST_RC_FULL_ROLLBACK=UNAVAILABLE\n'
fi
} >"$evidence/rollback-result.txt"
fi
if [ "$rollback_rc" -ne 0 ]; then
log_step "失败补偿未完整恢复,保留维护门 rollback_rc=$rollback_rc"
rc=73
fi
if [ "$bootstrap_token_validated" -eq 1 ]; then
log_step "激活失败,保留 0600 bootstrap admin token 供补偿后重试或人工吊销"

View File

@ -7,6 +7,7 @@ readonly EXPECTED_SIGNER_HOST='minione-ubuntu-infra'
readonly SSH_TARGET='root@100.64.0.7'
readonly SIGNER_ISSUER='r1-mini-infra'
readonly SIGNER_PRIVATE_KEY='/root/dogfood-r1-signer/r1-ed25519-private.pem'
readonly STAGING_RELEASES_ROOT='/root/game-staging/releases'
readonly REPORT_SCHEMA='dogfood-account-database-report/2'
readonly QUERY_ID='dogfood-account-readiness-v2'
readonly QUERY_SCHEMA='dogfood-account-readiness-row/2'
@ -46,11 +47,12 @@ output="$output_dir/$(basename "$output")"
test_mode="${DOGFOOD_COLLECTOR_TEST_MODE:-0}"
if [ "$test_mode" = 1 ]; then
# 测试钩子只替换进程和私钥SSH 目标、查询、issuer 与报告 schema 仍由生产常量固定。
# 测试钩子只替换进程、私钥和本地 fixture 根;生产 SSH 目标、issuer 与报告 schema 仍固定。
ssh_bin="${DOGFOOD_COLLECTOR_TEST_SSH_BIN:?}"
signer_key="${DOGFOOD_COLLECTOR_TEST_SIGNER_KEY:?}"
openssl_bin="${DOGFOOD_COLLECTOR_TEST_OPENSSL_BIN:?}"
docker_bin="${DOGFOOD_COLLECTOR_TEST_DOCKER_BIN:?}"
runtime_releases_root="${DOGFOOD_COLLECTOR_TEST_RELEASES_ROOT:?}"
else
[ "$(id -u)" = 0 ] || die '采集器必须以 mini-infra root signer 身份运行'
[ "$(hostname -s)" = "$EXPECTED_SIGNER_HOST" ] || die '采集器只能在固定 mini-infra signer 主机运行'
@ -58,6 +60,7 @@ else
signer_key="$SIGNER_PRIVATE_KEY"
openssl_bin='/usr/bin/openssl'
docker_bin='/usr/bin/docker'
runtime_releases_root="$STAGING_RELEASES_ROOT"
fi
[ -x "$ssh_bin" ] || die '固定 SSH 客户端不可执行'
[ -x "$openssl_bin" ] || die 'OpenSSL 客户端不可执行'
@ -119,9 +122,83 @@ SQL
tmp_rows="$output_dir/.account-report-rows.$$"
tmp_newapi_rows="$output_dir/.account-report-newapi-rows.$$"
tmp_runtime_state_before="$output_dir/.account-report-runtime-before.$$"
tmp_runtime_state_after="$output_dir/.account-report-runtime-after.$$"
tmp_report="$output_dir/.account-report.$$"
tmp_signature="$output_dir/.account-report-signature.$$"
trap 'rm -f "$tmp_rows" "$tmp_newapi_rows" "$tmp_report" "$tmp_signature"' EXIT
trap 'rm -f "$tmp_rows" "$tmp_newapi_rows" "$tmp_runtime_state_before" "$tmp_runtime_state_after" "$tmp_report" "$tmp_signature"' EXIT
collect_runtime_state() {
local destination="$1" quoted_root remote_command
printf -v quoted_root '%q' "$runtime_releases_root"
remote_command="$(cat <<EOF
set -euo pipefail
releases_root=$quoted_root
active_pointer="\$releases_root/active"
# active 必须是指向固定 releases 根下 40 位 commit 目录的符号链接。
[ -L "\$active_pointer" ]
canonical_root="\$(readlink -f -- "\$releases_root")"
[ "\$canonical_root" = "\$releases_root" ]
release="\$(readlink -f -- "\$active_pointer")"
[ -d "\$release" ]
commit="\${release##*/}"
printf '%s' "\$commit" | grep -Eq '^[0-9a-f]{40}\$'
[ "\$release" = "\$releases_root/\$commit" ]
# RC_STATUS 只能是一条 active 终态,并且 commit、evidence 都必须回指当前 canonical release。
status_file="\$release/RC_STATUS"
[ -f "\$status_file" ] && [ ! -L "\$status_file" ]
[ "\$(awk 'NF { count++ } END { print count + 0 }' "\$status_file")" -eq 1 ]
IFS= read -r status_line <"\$status_file"
set -- \$status_line
[ "\$#" -eq 4 ] && [ "\$1" = active ]
status_commit="\${2#commit=}"
completed_at="\${3#at=}"
evidence="\${4#evidence=}"
[ "\$2" = "commit=\$status_commit" ] && [ "\$status_commit" = "\$commit" ]
[ "\$3" = "at=\$completed_at" ]
printf '%s' "\$completed_at" | grep -Eq '^[0-9]{4}-[0-9]{2}-[0-9]{2}T[0-9]{2}:[0-9]{2}:[0-9]{2}Z\$'
[ "\$4" = "evidence=\$evidence" ]
evidence_name="\${evidence##*/}"
run_id="\${evidence_name#activation-}"
[ "\$evidence_name" = "activation-\$run_id" ]
printf '%s' "\$run_id" | grep -Eq '^[0-9]{8}T[0-9]{6}Z\$'
[ "\${evidence%/*}" = "\$release/evidence" ]
[ -d "\$evidence" ] && [ ! -L "\$evidence" ]
[ "\$(readlink -f -- "\$evidence")" = "\$evidence" ]
# activation.log 最后两个非空记录必须是该 release 的 PASS 与同一 evidence 回执。
activation_log="\$evidence/activation.log"
[ -f "\$activation_log" ] && [ ! -L "\$activation_log" ]
terminal_lines="\$(awk 'NF { previous=last; last=\$0 } END { print previous; print last }' "\$activation_log")"
pass_line="\$(printf '%s\n' "\$terminal_lines" | sed -n '1p')"
evidence_line="\$(printf '%s\n' "\$terminal_lines" | sed -n '2p')"
printf '%s\n' "\$pass_line" | grep -Eq '^\[[0-9]{4}-[0-9]{2}-[0-9]{2}T[0-9]{2}:[0-9]{2}:[0-9]{2}Z\] '
printf '%s\n' "\$evidence_line" | grep -Eq '^\[[0-9]{4}-[0-9]{2}-[0-9]{2}T[0-9]{2}:[0-9]{2}:[0-9]{2}Z\] '
[ "\${pass_line#*] }" = "RC_ACTIVATION_PASS commit=\$commit active=\$release" ]
[ "\${evidence_line#*] }" = "ACTIVATION_EVIDENCE=\$evidence" ]
printf 'DOGFOOD_RUNTIME_STATE_V1\t%s\t%s\t%s\t%s\t%s\n' \
"\$commit" "\$run_id" "\$completed_at" "\$release" "\$evidence"
EOF
)"
"$ssh_bin" \
-o BatchMode=yes -o ConnectTimeout=10 -o ConnectionAttempts=1 -o StrictHostKeyChecking=yes \
"$SSH_TARGET" "$remote_command" >"$destination" || die '固定 staging active/RC_STATUS/activation evidence 校验失败'
chmod 600 "$destination"
}
collect_runtime_state "$tmp_runtime_state_before"
if [ "$(awk 'END { print NR + 0 }' "$tmp_runtime_state_before")" -ne 1 ]; then
die 'staging 运行态观测输出行数非法'
fi
IFS=$'\t' read -r state_schema observed_rc_commit observed_activation_run_id \
observed_completed_at observed_release observed_evidence <"$tmp_runtime_state_before"
[ "$state_schema" = DOGFOOD_RUNTIME_STATE_V1 ] || die 'staging 运行态观测 schema 非法'
[ "$observed_rc_commit" = "$rc_commit" ] || die 'expected rcCommit 与 staging active commit 漂移'
[ "$observed_activation_run_id" = "$activation_run_id" ] || die 'expected activation run-id 与 staging 事实漂移'
remote_mysql_command='set -euo pipefail; . /root/huijing-dev.env; : "${MYSQL_ROOT_PASSWORD:?}"; docker exec -i game-staging-mysql sh -lc '\''MYSQL_PWD="$MYSQL_ROOT_PASSWORD" exec mysql -uroot -N -B ruoyi-vue-pro'\'''
printf '%s\n' "$FIXED_QUERY" | "$ssh_bin" \
@ -132,17 +209,24 @@ printf '%s\n' "$FIXED_NEWAPI_QUERY" | "$docker_bin" exec -i "$NEWAPI_PG_CONTAINE
'PGPASSWORD="$POSTGRES_PASSWORD" exec psql -U root -d new-api -q -t -A -F "|" -v ON_ERROR_STOP=1' \
>"$tmp_newapi_rows" || die '固定 new-api PostgreSQL 只读查询失败'
chmod 600 "$tmp_newapi_rows"
collect_runtime_state "$tmp_runtime_state_after"
cmp -s "$tmp_runtime_state_before" "$tmp_runtime_state_after" || \
die '账号采集期间 staging active/activation evidence 发生漂移'
captured_at="$(date -u '+%Y-%m-%dT%H:%M:%SZ')"
python3 - "$tmp_rows" "$tmp_newapi_rows" "$tmp_report" "$activation_run_id" "$rc_commit" "$captured_at" \
"$REPORT_SCHEMA" "$SIGNER_ISSUER" "$QUERY_ID" "$QUERY_SCHEMA" <<'PY'
python3 - "$tmp_rows" "$tmp_newapi_rows" "$tmp_report" "$observed_activation_run_id" \
"$observed_rc_commit" "$captured_at" "$REPORT_SCHEMA" "$SIGNER_ISSUER" "$QUERY_ID" "$QUERY_SCHEMA" \
"$observed_completed_at" "$observed_release" "$observed_evidence" <<'PY'
import json
import re
import sys
from pathlib import Path
rows_path, newapi_rows_path, report_path = map(Path, sys.argv[1:4])
activation_run_id, rc_commit, captured_at, schema, issuer, query_id, query_schema = sys.argv[4:]
(
activation_run_id, rc_commit, captured_at, schema, issuer, query_id, query_schema,
activation_completed_at, active_release, activation_evidence,
) = sys.argv[4:]
expected_roles = {
**{f"dogfoodc{index:02d}": "creator" for index in range(1, 11)},
**{f"dogfoodp{index:02d}": "player" for index in range(1, 31)},
@ -254,6 +338,10 @@ report = {
"schema": schema,
"issuer": issuer,
"activationRunId": activation_run_id,
"activationStatus": "PASS",
"activationCompletedAt": activation_completed_at,
"activeReleaseRef": active_release,
"activationEvidenceRef": activation_evidence,
"rcCommit": rc_commit,
"capturedAt": captured_at,
"queryId": query_id,

View File

@ -82,14 +82,16 @@ deploy/dogfood-ops/init-wave.sh \
### 3.1 账号报告 `/2`
账号报告不能手写。采集器必须在 mini-infra 以 root signer 身份运行。它先固定通过 `root@100.64.0.7` 查询 `game-staging-mysql/ruoyi-vue-pro`,在 MySQL `READ ONLY` 事务内取得 40 条 `CLAIMED` 和 12 条 `FREE` 池记录;再固定查询 mini-infra 本机 `infra-postgres/new-api`,从容器已有的 `POSTGRES_PASSWORD` 设置 `PGPASSWORD`,不把密码放入参数、日志或报告。每条池记录必须按 `newapiUserId + newapiTokenId` 唯一命中 canonical `neice_NNN` 用户/token且 token 同时满足 `status=1``unlimited_quota=false``expired_time=-1``remain_quota>0`。任一禁用、耗尽、ID 漂移、重复行或多 token 匹配都会在签名前失败。
账号报告不能手写。采集器必须在 mini-infra 以 root signer 身份运行。它先固定通过 `root@100.64.0.7` 读取 `/root/game-staging/releases/active` 的 canonical commit、该 release 的单行 `RC_STATUS`,以及 `RC_STATUS` 指向的 activation evidence只有 `activation.log` 最后两个非空记录分别证明同一 commit 的 `RC_ACTIVATION_PASS` 和同一路径的 `ACTIVATION_EVIDENCE` 才继续。命令行 `--rc-commit``--activation-run-id` 只是 expected 值不能成为报告事实来源任一参数与运行态不一致均拒签。采集器在数据库查询后再次读取相同状态active 指针、RC_STATUS 或 evidence 发生漂移也拒签。
运行态身份稳定后,采集器在 MySQL `READ ONLY` 事务内取得 40 条 `CLAIMED` 和 12 条 `FREE` 池记录;再固定查询 mini-infra 本机 `infra-postgres/new-api`,从容器已有的 `POSTGRES_PASSWORD` 设置 `PGPASSWORD`,不把密码放入参数、日志或报告。每条池记录必须按 `newapiUserId + newapiTokenId` 唯一命中 canonical `neice_NNN` 用户/token且 token 同时满足 `status=1``unlimited_quota=false``expired_time=-1``remain_quota>0`。任一禁用、耗尽、ID 漂移、重复行或多 token 匹配都会在签名前失败。
```bash
# 在可信工作站从指定 RC commit 导出脚本,不使用当前未提交工作树。
set -euo pipefail
umask 077
: "${RC_COMMIT:?请先设置 commit-pinned RC 的 40 位哈希}"
: "${ACTIVATION_RUN_ID:?请先设置激活 run-id}"
: "${ACTIVATION_RUN_ID:?请先设置 active release 的 activation run-id例如 20260723T010203Z}"
[[ "$RC_COMMIT" =~ ^[0-9a-f]{40}$ ]]
[[ "$ACTIVATION_RUN_ID" =~ ^[A-Za-z0-9][A-Za-z0-9._-]{0,127}$ ]]
PINNED_SCRIPT="$(mktemp)"
@ -133,12 +135,12 @@ install -m 600 "$TRANSFER_DIR/wave0-account-report.json.sig" /受控证据根/wa
rm -rf "$TRANSFER_DIR"
```
随后填写:
报告中的 `rcCommit``activationRunId``activationCompletedAt``activeReleaseRef``activationEvidenceRef``activationStatus=PASS` 全部来自上述两次一致的 mini-desktop 观测,不复制命令参数。随后填写:
```json
{
"schema": "dogfood-accounts/2",
"activationRunId": "activation-20260723T010203Z",
"activationRunId": "20260723T010203Z",
"databaseReportRef": "attachments/wave0-account-report.json",
"databaseReportSignatureRef": "attachments/wave0-account-report.json.sig",
"databaseReportSha256": "<报告文件SHA-256>",

View File

@ -48,9 +48,23 @@ openssl pkey -in "$TMP_DIR/private.pem" -pubout -out "$TMP_DIR/public.pem" >/dev
cat >"$TMP_DIR/fake-ssh" <<'SH'
#!/usr/bin/env bash
set -euo pipefail
printf '%s\n' "$*" >"$DOGFOOD_TEST_SSH_ARGS"
cat >"$DOGFOOD_TEST_SQL_CAPTURE"
cat "$DOGFOOD_TEST_MYSQL_ROWS"
printf '%s\n' "$*" >>"$DOGFOOD_TEST_SSH_ARGS"
remote_command="${!#}"
if [[ "$remote_command" == *DOGFOOD_RUNTIME_STATE_V1* ]]; then
state_call_count=0
if [ -f "$DOGFOOD_TEST_STATE_CALL_COUNT" ]; then
state_call_count="$(cat "$DOGFOOD_TEST_STATE_CALL_COUNT")"
fi
state_call_count="$((state_call_count + 1))"
printf '%s\n' "$state_call_count" >"$DOGFOOD_TEST_STATE_CALL_COUNT"
if [ "$state_call_count" -eq 2 ] && [ -n "${DOGFOOD_TEST_DRIFT_TARGET:-}" ]; then
ln -sfn "$DOGFOOD_TEST_DRIFT_TARGET" "$DOGFOOD_COLLECTOR_TEST_RELEASES_ROOT/active"
fi
bash -c "$remote_command"
else
cat >"$DOGFOOD_TEST_SQL_CAPTURE"
cat "$DOGFOOD_TEST_MYSQL_ROWS"
fi
SH
chmod +x "$TMP_DIR/fake-ssh"
@ -74,11 +88,46 @@ export DOGFOOD_TEST_MYSQL_ROWS="$TMP_DIR/mysql.tsv"
export DOGFOOD_TEST_DOCKER_ARGS="$TMP_DIR/docker-args"
export DOGFOOD_TEST_PG_SQL_CAPTURE="$TMP_DIR/newapi.sql"
export DOGFOOD_TEST_NEWAPI_ROWS="$TMP_DIR/newapi.tsv"
export DOGFOOD_TEST_STATE_CALL_COUNT="$TMP_DIR/state-call-count"
export DOGFOOD_COLLECTOR_TEST_RELEASES_ROOT="$TMP_DIR/releases"
mkdir -p "$DOGFOOD_COLLECTOR_TEST_RELEASES_ROOT"
export DOGFOOD_COLLECTOR_TEST_RELEASES_ROOT="$(cd "$DOGFOOD_COLLECTOR_TEST_RELEASES_ROOT" && pwd -P)"
active_commit=1111111111111111111111111111111111111111
active_run_id=20260723T010203Z
active_release="$DOGFOOD_COLLECTOR_TEST_RELEASES_ROOT/$active_commit"
active_evidence="$active_release/evidence/activation-$active_run_id"
mkdir -p "$active_evidence"
ln -s "$active_release" "$DOGFOOD_COLLECTOR_TEST_RELEASES_ROOT/active"
printf 'active commit=%s at=2026-07-23T01:02:03Z evidence=%s\n' \
"$active_commit" "$active_evidence" >"$active_release/RC_STATUS"
cat >"$active_evidence/activation.log" <<EOF
[2026-07-23T01:02:03Z] RC_ACTIVATION_PASS commit=$active_commit active=$active_release
[2026-07-23T01:02:03Z] ACTIVATION_EVIDENCE=$active_evidence
EOF
chmod 600 "$active_release/RC_STATUS" "$active_evidence/activation.log"
reset_state_probe() {
rm -f "$DOGFOOD_TEST_STATE_CALL_COUNT"
unset DOGFOOD_TEST_DRIFT_TARGET
}
assert_refused_without_output() {
local name="$1"
shift
reset_state_probe
if "$SCRIPT" "$@" --output "$TMP_DIR/$name.json" >/dev/null 2>&1; then
fail "$name 未被拒绝"
fi
[ ! -e "$TMP_DIR/$name.json" ] && [ ! -e "$TMP_DIR/$name.json.sig" ] || \
fail "$name 失败后遗留半成品"
}
report="$TMP_DIR/account-report.json"
reset_state_probe
"$SCRIPT" \
--activation-run-id activation-20260723T010203Z \
--rc-commit 1111111111111111111111111111111111111111 \
--activation-run-id "$active_run_id" \
--rc-commit "$active_commit" \
--output "$report"
[ -f "$report" ] && [ -f "$report.sig" ] || fail '没有生成报告及 detached signature'
@ -103,6 +152,8 @@ if grep -Eqi 'SELECT[^;]*(key|access_token|password)' "$TMP_DIR/newapi.sql"; the
fi
grep -q "SIGNER_PRIVATE_KEY='/root/dogfood-r1-signer/r1-ed25519-private.pem'" "$SCRIPT" || \
fail '生产 signer 私钥路径不是固定值'
grep -q "STAGING_RELEASES_ROOT='/root/game-staging/releases'" "$SCRIPT" || \
fail '生产 staging releases 根不是固定值'
grep -q 'realpath.*signer_key' "$SCRIPT" || fail '生产 signer 私钥未校验 canonical 路径'
grep -q "stat -c '%u'.*signer_key" "$SCRIPT" || fail '生产 signer 私钥未校验 root 所有权'
@ -116,6 +167,14 @@ assert report["issuer"] == "r1-mini-infra"
assert report["queryId"] == "dogfood-account-readiness-v2"
assert report["querySchema"] == "dogfood-account-readiness-row/2"
assert report["rcCommit"] == "1" * 40
assert report["activationRunId"] == "20260723T010203Z"
assert report["activationStatus"] == "PASS"
assert report["activationCompletedAt"] == "2026-07-23T01:02:03Z"
assert report["activeReleaseRef"].endswith("/" + "1" * 40)
assert report["activationEvidenceRef"].endswith(
"/1111111111111111111111111111111111111111/evidence/activation-20260723T010203Z"
)
assert report["activationEvidenceRef"].startswith(report["activeReleaseRef"] + "/evidence/")
assert len(report["accounts"]) == 40
assert len(report["reserves"]) == 12
for item in report["accounts"] + report["reserves"]:
@ -129,11 +188,92 @@ for secret_name in ("tokenKey", "password", "accessToken", "refreshToken"):
assert secret_name not in serialized
PY
# 参数只是 expected与 mini-desktop 当前 active 事实不一致时,签名器必须拒绝。
assert_refused_without_output forged-commit \
--activation-run-id "$active_run_id" \
--rc-commit 2222222222222222222222222222222222222222
assert_refused_without_output forged-run-id \
--activation-run-id 20260723T111111Z \
--rc-commit "$active_commit"
# active、RC_STATUS、activation evidence 任一缺失都不能退回使用命令参数。
rm "$DOGFOOD_COLLECTOR_TEST_RELEASES_ROOT/active"
assert_refused_without_output missing-active \
--activation-run-id "$active_run_id" --rc-commit "$active_commit"
ln -s "$active_release" "$DOGFOOD_COLLECTOR_TEST_RELEASES_ROOT/active"
mv "$active_release/RC_STATUS" "$active_release/RC_STATUS.missing"
assert_refused_without_output missing-rc-status \
--activation-run-id "$active_run_id" --rc-commit "$active_commit"
mv "$active_release/RC_STATUS.missing" "$active_release/RC_STATUS"
mv "$active_evidence" "$active_evidence.missing"
assert_refused_without_output missing-activation-evidence \
--activation-run-id "$active_run_id" --rc-commit "$active_commit"
mv "$active_evidence.missing" "$active_evidence"
# RC_STATUS 与 evidence 自身必须绑定 active commit、规范 run-id 和成功终态。
cp "$active_release/RC_STATUS" "$TMP_DIR/RC_STATUS.valid"
sed "s/commit=$active_commit/commit=2222222222222222222222222222222222222222/" \
"$TMP_DIR/RC_STATUS.valid" >"$active_release/RC_STATUS"
assert_refused_without_output rc-status-commit-drift \
--activation-run-id "$active_run_id" --rc-commit "$active_commit"
cp "$TMP_DIR/RC_STATUS.valid" "$active_release/RC_STATUS"
malformed_evidence="$active_release/evidence/activation-manual"
mv "$active_evidence" "$malformed_evidence"
sed "s#evidence=$active_evidence#evidence=$malformed_evidence#" \
"$TMP_DIR/RC_STATUS.valid" >"$active_release/RC_STATUS"
sed "s#ACTIVATION_EVIDENCE=$active_evidence#ACTIVATION_EVIDENCE=$malformed_evidence#" \
"$malformed_evidence/activation.log" >"$TMP_DIR/activation.log.malformed"
mv "$TMP_DIR/activation.log.malformed" "$malformed_evidence/activation.log"
chmod 600 "$malformed_evidence/activation.log"
assert_refused_without_output malformed-evidence-run-id \
--activation-run-id "$active_run_id" --rc-commit "$active_commit"
mv "$malformed_evidence" "$active_evidence"
cp "$TMP_DIR/RC_STATUS.valid" "$active_release/RC_STATUS"
cat >"$active_evidence/activation.log" <<EOF
[2026-07-23T01:02:03Z] ACTIVATION FAIL rc=1
[2026-07-23T01:02:03Z] ACTIVATION_EVIDENCE=$active_evidence
EOF
chmod 600 "$active_evidence/activation.log"
assert_refused_without_output failed-activation-terminal \
--activation-run-id "$active_run_id" --rc-commit "$active_commit"
cat >"$active_evidence/activation.log" <<EOF
[2026-07-23T01:02:03Z] RC_ACTIVATION_PASS commit=$active_commit active=$active_release
[2026-07-23T01:02:03Z] ACTIVATION_EVIDENCE=$active_evidence
EOF
chmod 600 "$active_evidence/activation.log"
# 数据库采集前后必须观测同一 active release防止切版窗口产生错绑报告。
drift_commit=2222222222222222222222222222222222222222
drift_run_id=20260723T020304Z
drift_release="$DOGFOOD_COLLECTOR_TEST_RELEASES_ROOT/$drift_commit"
drift_evidence="$drift_release/evidence/activation-$drift_run_id"
mkdir -p "$drift_evidence"
printf 'active commit=%s at=2026-07-23T02:03:04Z evidence=%s\n' \
"$drift_commit" "$drift_evidence" >"$drift_release/RC_STATUS"
cat >"$drift_evidence/activation.log" <<EOF
[2026-07-23T02:03:04Z] RC_ACTIVATION_PASS commit=$drift_commit active=$drift_release
[2026-07-23T02:03:04Z] ACTIVATION_EVIDENCE=$drift_evidence
EOF
chmod 600 "$drift_release/RC_STATUS" "$drift_evidence/activation.log"
reset_state_probe
export DOGFOOD_TEST_DRIFT_TARGET="$drift_release"
if "$SCRIPT" --activation-run-id "$active_run_id" --rc-commit "$active_commit" \
--output "$TMP_DIR/pointer-drift.json" >/dev/null 2>&1; then
fail '采集期间 active 指针漂移仍被签发'
fi
[ ! -e "$TMP_DIR/pointer-drift.json" ] && [ ! -e "$TMP_DIR/pointer-drift.json.sig" ] || \
fail '指针漂移失败后遗留半成品'
ln -sfn "$active_release" "$DOGFOOD_COLLECTOR_TEST_RELEASES_ROOT/active"
unset DOGFOOD_TEST_DRIFT_TARGET
# 精确规模是签发前置条件39 个 CLAIMED 账号必须失败且不得留下半成品。
head -n 51 "$TMP_DIR/mysql.tsv" >"$TMP_DIR/incomplete.tsv"
export DOGFOOD_TEST_MYSQL_ROWS="$TMP_DIR/incomplete.tsv"
if "$SCRIPT" --activation-run-id activation-20260723T010203Z \
--rc-commit 1111111111111111111111111111111111111111 \
if "$SCRIPT" --activation-run-id "$active_run_id" \
--rc-commit "$active_commit" \
--output "$TMP_DIR/incomplete.json" >/dev/null 2>&1; then
fail '不完整 40+12 数据仍被签发'
fi
@ -157,8 +297,8 @@ for mutation in disabled zero id-drift multi-row; do
;;
esac
export DOGFOOD_TEST_NEWAPI_ROWS="$TMP_DIR/newapi-$mutation.tsv"
if "$SCRIPT" --activation-run-id activation-20260723T010203Z \
--rc-commit 1111111111111111111111111111111111111111 \
if "$SCRIPT" --activation-run-id "$active_run_id" \
--rc-commit "$active_commit" \
--output "$TMP_DIR/$mutation.json" >/dev/null 2>&1; then
fail "new-api 异常仍被签发 mutation=$mutation"
fi

View File

@ -193,6 +193,17 @@ printf '%s\n' "$failure_handler_body" | rg -q 'verify_stack.*rollback' || \
fail "回滚后未逐项复核五服务"
printf '%s\n' "$failure_handler_body" | rg -q 'release_maintenance_gate' || \
fail "旧栈完整恢复后未解除维护门"
rg -q '^verify_active_pointer()' "$TMP_DIR/remote-script.sh" || \
fail "active 指针恢复后缺 realpath/commit 复核函数"
pointer_restore_line="$(printf '%s\n' "$failure_handler_body" | rg -n 'restore_active_pointer.*previous' | cut -d: -f1)"
pointer_verify_line="$(printf '%s\n' "$failure_handler_body" | rg -n 'verify_active_pointer.*previous' | cut -d: -f1)"
rollback_release_line="$(printf '%s\n' "$failure_handler_body" | rg -n 'release_maintenance_gate' | cut -d: -f1)"
[ -n "$pointer_restore_line" ] && [ -n "$pointer_verify_line" ] && [ -n "$rollback_release_line" ] \
&& [ "$pointer_restore_line" -lt "$pointer_verify_line" ] \
&& [ "$pointer_verify_line" -lt "$rollback_release_line" ] || \
fail "失败回滚未按 active 恢复→realpath/commit 复核→最后解除维护门排序"
printf '%s\n' "$failure_handler_body" | rg -q 'rollback_rc=' || \
fail "失败回滚证据未记录总 rollback_rc"
! 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)"
@ -365,6 +376,126 @@ bash "$TMP_DIR/stop-stack-harness.sh" || fail "stop_stack 未严格传播服务
'! rg -q "^unexpected " "$calls"'
} >"$TMP_DIR/start-stack-harness.sh"
bash "$TMP_DIR/start-stack-harness.sh"
# 执行失败回滚状态机:即使新 active 尚未完成标记,也必须恢复并复核旧指针后才开放入口。
{
cat <<'SH'
#!/usr/bin/env bash
set -uo pipefail
case_name="${1:?}"
state="${RC_TEST_TMP:?}/rollback-state-$case_name"
calls="$state/calls.log"
evidence="$state/evidence"
release="$state/release"
mkdir -p "$evidence" "$release"
commit=0000000000000000000000000000000000000000
candidate_prefix=gda-candidate
stable_prefix=gda-staging
switch_started=1
pointer_changed=0
previous_was_active=1
previous_valid=1
previous=/root/game-staging/releases/1111111111111111111111111111111111111111
database_restore_required=1
database_restore_rc=not-run
runtime_cleanup_rc=not-run
bootstrap_token_validated=0
maintenance_gate_active=1
run_id=test-run
log_step() { printf 'log:%s\n' "$*" >>"$calls"; }
capture_journals() { :; }
stop_stack() { printf 'stop:%s\n' "$1" >>"$calls"; }
cleanup_dogfood_provision_runtime() { return 0; }
restore_database_snapshot() { printf 'database\n' >>"$calls"; return 0; }
start_stack() { printf 'start-old-stack\n' >>"$calls"; return 0; }
verify_stack() { printf 'verify-old-stack\n' >>"$calls"; return 0; }
restore_active_pointer() {
printf 'restore-active\n' >>"$calls"
[ "$case_name" != pointer-fail ] || return 88
}
verify_active_pointer() { printf 'verify-active\n' >>"$calls"; return 0; }
release_maintenance_gate() { printf 'release-maintenance\n' >>"$calls"; maintenance_gate_active=0; }
SH
printf '%s\n' "$failure_handler_body"
printf '%s\n' 'false' 'failure_handler'
} >"$TMP_DIR/rollback-state-harness.sh"
chmod +x "$TMP_DIR/rollback-state-harness.sh"
set +e
bash "$TMP_DIR/rollback-state-harness.sh" success
rollback_success_rc=$?
set -e
[ "$rollback_success_rc" -eq 1 ] || fail "完整回滚没有保留原始激活失败码"
success_calls="$TMP_DIR/rollback-state-success/calls.log"
database_line="$(rg -n '^database$' "$success_calls" | cut -d: -f1 || true)"
service_line="$(rg -n '^verify-old-stack$' "$success_calls" | cut -d: -f1 || true)"
restore_pointer_line="$(rg -n '^restore-active$' "$success_calls" | cut -d: -f1 || true)"
verify_pointer_line="$(rg -n '^verify-active$' "$success_calls" | cut -d: -f1 || true)"
release_line="$(rg -n '^release-maintenance$' "$success_calls" | cut -d: -f1 || true)"
[ -n "$database_line" ] && [ -n "$service_line" ] && [ -n "$restore_pointer_line" ] \
&& [ -n "$verify_pointer_line" ] && [ -n "$release_line" ] \
&& [ "$database_line" -lt "$service_line" ] \
&& [ "$service_line" -lt "$restore_pointer_line" ] \
&& [ "$restore_pointer_line" -lt "$verify_pointer_line" ] \
&& [ "$verify_pointer_line" -lt "$release_line" ] || \
fail "完整回滚未按数据库→旧栈→active 恢复复核→维护门释放执行"
rg -q '^rollback_rc=0$' "$TMP_DIR/rollback-state-success/evidence/rollback-result.txt" || \
fail "完整回滚证据未记录 rollback_rc=0"
set +e
bash "$TMP_DIR/rollback-state-harness.sh" pointer-fail
rollback_pointer_fail_rc=$?
set -e
[ "$rollback_pointer_fail_rc" -eq 73 ] || fail "active 指针恢复失败未返回统一回滚失败码 73"
pointer_fail_calls="$TMP_DIR/rollback-state-pointer-fail/calls.log"
! rg -q '^release-maintenance$' "$pointer_fail_calls" || fail "active 指针失败后仍开放正式入口"
rg -q '^rollback_pointer_rc=88$' "$TMP_DIR/rollback-state-pointer-fail/evidence/rollback-result.txt" || \
fail "active 指针失败证据未记录原始非零返回码"
rg -q '^rollback_rc=88$' "$TMP_DIR/rollback-state-pointer-fail/evidence/rollback-result.txt" || \
fail "active 指针失败未计入总 rollback_rc"
# 执行真实 active 指针函数,验证目标 realpath 与 RC 目录名、Git HEAD 三者必须一致。
pointer_functions="$TMP_DIR/active-pointer-functions.sh"
{
printf '%s\n' 'set -euo pipefail'
sed -n '/^restore_active_pointer()/,/^}/p' "$TMP_DIR/remote-script.sh"
sed -n '/^verify_active_pointer()/,/^}/p' "$TMP_DIR/remote-script.sh"
} >"$pointer_functions"
pointer_root="$TMP_DIR/pointer-releases"
pointer_seed="$TMP_DIR/pointer-seed"
mkdir -p "$pointer_root" "$pointer_seed"
git -C "$pointer_seed" init -q
git -C "$pointer_seed" config user.name staging-rc-test
git -C "$pointer_seed" config user.email staging-rc-test@example.invalid
printf 'v1\n' >"$pointer_seed/artifact.txt"
git -C "$pointer_seed" add artifact.txt
git -C "$pointer_seed" commit -q -m v1
pointer_commit="$(git -C "$pointer_seed" rev-parse HEAD)"
pointer_release="$pointer_root/$pointer_commit"
mv "$pointer_seed" "$pointer_release"
{
printf 'root=%q\n' "$pointer_root"
printf 'active_pointer=%q\n' "$pointer_root/active"
printf '%s\n' 'run_id=pointer-test' 'log_step() { :; }' \
'mv() { if [ "${1:-}" = -Tf ]; then command mv -f "$2" "$3"; else command mv "$@"; fi; }'
printf 'source %q\n' "$pointer_functions"
printf 'restore_active_pointer %q\n' "$pointer_release"
printf 'verify_active_pointer %q\n' "$pointer_release"
} >"$TMP_DIR/active-pointer-pass.sh"
bash "$TMP_DIR/active-pointer-pass.sh" || fail "active 指针 realpath/commit 正例失败"
[ "$(readlink -f "$pointer_root/active")" = "$(realpath "$pointer_release")" ] || \
fail "active 指针未恢复到旧 RC 的 canonical 路径"
printf 'v2\n' >>"$pointer_release/artifact.txt"
git -C "$pointer_release" add artifact.txt
git -C "$pointer_release" commit -q -m v2
if bash -c "root=$(printf '%q' "$pointer_root"); active_pointer=$(printf '%q' "$pointer_root/active"); \
run_id=pointer-test; log_step() { :; }; source $(printf '%q' "$pointer_functions"); \
verify_active_pointer $(printf '%q' "$pointer_release")"; then
fail "active RC 的 Git HEAD 漂移后仍通过 commit 复核"
fi
if rg -q '/root/games-development-ai' "$TMP_DIR/remote-script.sh"; then
fail "激活脚本引用了远端脏旧仓"
fi

View File

@ -10,6 +10,7 @@ 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 org.springframework.web.servlet.HandlerMapping;
import static com.wanxiang.huijing.framework.common.pojo.CommonResult.success;
import static com.wanxiang.huijing.framework.common.exception.util.ServiceExceptionUtil.exception;
@ -54,12 +55,13 @@ public class FeedApiImpl implements FeedApi {
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());
// 使用 Spring 已规范化并选中的 handler pattern矩阵参数不能再制造原始 URI 与映射路径差异
Object patternAttribute = request.getAttribute(HandlerMapping.BEST_MATCHING_PATTERN_ATTRIBUTE);
if (patternAttribute == null) {
return;
}
if (!requestUri.equals(FeedApi.PREFIX) && !requestUri.startsWith(FeedApi.PREFIX + "/")) {
String pattern = patternAttribute.toString();
if (!pattern.equals(FeedApi.PREFIX) && !pattern.startsWith(FeedApi.PREFIX + "/")) {
return;
}
log.warn("[feedRpcGuard] 已拒绝外部 HTTP 写入口 operation={} code={}",

View File

@ -8,14 +8,22 @@ 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.test.web.servlet.MockMvc;
import org.springframework.test.web.servlet.setup.MockMvcBuilders;
import org.springframework.web.context.request.RequestContextHolder;
import org.springframework.web.context.request.ServletRequestAttributes;
import org.springframework.web.servlet.HandlerMapping;
import jakarta.servlet.ServletException;
import static org.junit.jupiter.api.Assertions.assertDoesNotThrow;
import static org.junit.jupiter.api.Assertions.assertInstanceOf;
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;
import static org.springframework.http.MediaType.APPLICATION_JSON;
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post;
/** feed 排序写 RPC 的狗粮可信调用边界测试。 */
class FeedApiImplDogfoodTest {
@ -64,6 +72,34 @@ class FeedApiImplDogfoodTest {
verify(feedService).upsertRank(req);
}
@Test
void upsertRank_rejectsMatrixParameterRpcThroughSpringMapping() {
FeedApiImpl api = createApi();
MockMvc mockMvc = MockMvcBuilders.standaloneSetup(api).build();
// 覆盖 Spring 已解析路径与原始 URI 不一致的场景防止矩阵参数绕过写 RPC 守卫
ServletException exception = assertThrows(ServletException.class, () -> mockMvc.perform(post(
"/rpc-api/feed;x=1/upsert-rank")
.contentType(APPLICATION_JSON)
.content("{}")));
assertInstanceOf(ServiceException.class, exception.getCause());
verifyNoInteractions(feedService);
}
@Test
void offlineRank_rejectsMatrixParameterRpcThroughSpringMapping() {
FeedApiImpl api = createApi();
MockMvc mockMvc = MockMvcBuilders.standaloneSetup(api).build();
ServletException exception = assertThrows(ServletException.class, () -> mockMvc.perform(post(
"/rpc-api/feed;x=1/offline-rank")
.param("gameId", "1024")));
assertInstanceOf(ServiceException.class, exception.getCause());
verifyNoInteractions(feedService);
}
private FeedApiImpl createApi() {
FeedApiImpl api = new FeedApiImpl();
ReflectionTestUtils.setField(api, "feedService", feedService);
@ -72,6 +108,7 @@ class FeedApiImplDogfoodTest {
private static void bindRequest(String requestUri) {
MockHttpServletRequest request = new MockHttpServletRequest("POST", requestUri);
request.setAttribute(HandlerMapping.BEST_MATCHING_PATTERN_ATTRIBUTE, requestUri);
RequestContextHolder.setRequestAttributes(new ServletRequestAttributes(request));
}
}

View File

@ -11,6 +11,7 @@ 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 org.springframework.web.servlet.HandlerMapping;
import static com.wanxiang.huijing.framework.common.pojo.CommonResult.success;
import static com.wanxiang.huijing.framework.common.exception.util.ServiceExceptionUtil.exception;
@ -56,13 +57,14 @@ public class ProjectVersionApiImpl implements ProjectVersionApi {
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());
// 只信任 Spring 已解析的 handler pattern原始 URI 可携带矩阵参数不能用于安全边界判断
Object patternAttribute = request.getAttribute(HandlerMapping.BEST_MATCHING_PATTERN_ATTRIBUTE);
if (patternAttribute == null) {
return;
}
if (!requestUri.equals(ProjectVersionApi.PREFIX)
&& !requestUri.startsWith(ProjectVersionApi.PREFIX + "/")) {
String pattern = patternAttribute.toString();
if (!pattern.equals(ProjectVersionApi.PREFIX)
&& !pattern.startsWith(ProjectVersionApi.PREFIX + "/")) {
return;
}
log.warn("[projectRpcGuard] 已拒绝外部 HTTP 写入口 operation={} code={}",

View File

@ -9,14 +9,22 @@ 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.test.web.servlet.MockMvc;
import org.springframework.test.web.servlet.setup.MockMvcBuilders;
import org.springframework.web.context.request.RequestContextHolder;
import org.springframework.web.context.request.ServletRequestAttributes;
import org.springframework.web.servlet.HandlerMapping;
import jakarta.servlet.ServletException;
import static org.junit.jupiter.api.Assertions.assertDoesNotThrow;
import static org.junit.jupiter.api.Assertions.assertInstanceOf;
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;
import static org.springframework.http.MediaType.APPLICATION_JSON;
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post;
/** project 版本写 RPC 的狗粮可信调用边界测试。 */
class ProjectVersionApiImplDogfoodTest {
@ -67,6 +75,36 @@ class ProjectVersionApiImplDogfoodTest {
verify(gameVersionService).bindRuntimeArtifact(req);
}
@Test
void createForPackage_rejectsMatrixParameterRpcThroughSpringMapping() {
ProjectVersionApiImpl api = createApi();
MockMvc mockMvc = MockMvcBuilders.standaloneSetup(api).build();
// 必须经过 Spring 的真实 handler 映射证明矩阵参数规范化后仍命中内部 RPC 且被业务守卫拒绝
ServletException exception = assertThrows(ServletException.class, () -> mockMvc.perform(post(
"/rpc-api/project;x=1/version/create-for-package")
.contentType(APPLICATION_JSON)
.content("{\"gameId\":1024}")));
assertInstanceOf(ServiceException.class, exception.getCause());
verifyNoInteractions(gameVersionService);
}
@Test
void bindRuntimeArtifact_rejectsMatrixParameterRpcThroughSpringMapping() {
ProjectVersionApiImpl api = createApi();
MockMvc mockMvc = MockMvcBuilders.standaloneSetup(api).build();
ServletException exception = assertThrows(ServletException.class, () -> mockMvc.perform(post(
"/rpc-api/project;x=1/version/bind-runtime-artifact")
.contentType(APPLICATION_JSON)
.content("{\"versionId\":2048,\"checksum\":\"" + "a".repeat(64)
+ "\",\"bundleSize\":4096}")));
assertInstanceOf(ServiceException.class, exception.getCause());
verifyNoInteractions(gameVersionService);
}
private ProjectVersionApiImpl createApi() {
ProjectVersionApiImpl api = new ProjectVersionApiImpl();
ReflectionTestUtils.setField(api, "gameVersionService", gameVersionService);
@ -75,12 +113,14 @@ class ProjectVersionApiImplDogfoodTest {
private static void bindRequest(String requestUri) {
MockHttpServletRequest request = new MockHttpServletRequest("POST", requestUri);
request.setAttribute(HandlerMapping.BEST_MATCHING_PATTERN_ATTRIBUTE, requestUri);
RequestContextHolder.setRequestAttributes(new ServletRequestAttributes(request));
}
/** 模拟普通玩家携带自己的访问令牌直接命中内部 RPC HTTP 路径。 */
private static void bindOrdinaryPlayerRequest(String requestUri) {
MockHttpServletRequest request = new MockHttpServletRequest("POST", requestUri);
request.setAttribute(HandlerMapping.BEST_MATCHING_PATTERN_ATTRIBUTE, requestUri);
request.addHeader("Authorization", "Bearer ordinary-player-token");
RequestContextHolder.setRequestAttributes(new ServletRequestAttributes(request));
}