#!/usr/bin/env bash # 绘境AI staging 冒烟门。 # 默认模式只做公开读 readiness;--dogfood-e2e 才执行真实创作、审核、试玩会话和遥测写链。 set -uo pipefail BASE="${BASE:-http://100.64.0.7:48080}" STUDIO_BASE="${STUDIO_BASE:-http://100.64.0.7:4173}" ORIGIN="${ORIGIN:-$STUDIO_BASE}" TENANT="${TENANT:-1}" MODE="readiness" if [ "${1:-}" = "--dogfood-e2e" ]; then MODE="dogfood-e2e" elif [ -n "${1:-}" ]; then printf '未知参数:%s\n用法:%s [--dogfood-e2e]\n' "$1" "$0" >&2 exit 2 fi SYSTEMCTL_BIN="${SYSTEMCTL_BIN:-systemctl}" PROC_ROOT="${PROC_ROOT:-/proc}" PASS=0 FAIL=0 FAILED_ITEMS=() LAST_HTTP="" LAST_BODY="" BODY_FILE="$(mktemp)" RUN_ID="${RUN_ID:-$(date -u +%Y%m%dT%H%M%SZ)-$$}" EVIDENCE_OUT="${EVIDENCE_OUT:-/tmp/dogfood-e2e-${RUN_ID}.json}" TASK_CHAIN_ID="" AIGC_TASK_ID="" GAME_ID="" VERSION_ID="" SESSION_ID="" TELEMETRY_TRACE_ID="" ARTIFACT_HASH="" E2E_STATUS="not-started" FAILURE_ITEM="" cleanup() { rm -f "$BODY_FILE" } trap cleanup EXIT if [ -t 1 ]; then G='\033[0;32m'; R='\033[0;31m'; B='\033[0;34m'; N='\033[0m' else G=''; R=''; B=''; N='' fi log() { printf "%b\n" "$*"; } head_line() { printf "\n%b== %s ==%b\n" "$B" "$1" "$N"; } # 请求响应分别放在全局 LAST_HTTP/LAST_BODY,避免 token 或响应体进入日志。 request() { local token="$1" method="$2" path="$3" data="${4:-}" local url="$path" local -a args [[ "$path" == http://* || "$path" == https://* ]] || url="$BASE$path" args=(-sS --connect-timeout 5 --max-time 30 -o "$BODY_FILE" -w '%{http_code}' -X "$method" -H "tenant-id: $TENANT" -H "Origin: $ORIGIN") [ -n "$token" ] && args+=(-H "Authorization: Bearer $token") if [ -n "$data" ]; then args+=(-H 'Content-Type: application/json' --data "$data") fi LAST_HTTP="$(curl "${args[@]}" "$url" 2>/dev/null || printf '000')" LAST_BODY="$(<"$BODY_FILE")" } # 从 stdin JSON 按点路径读取标量;缺字段或非 JSON 返回空串。 json_path() { local path="$1" python3 -c ' import json,sys path=sys.argv[1].split(".") if sys.argv[1] else [] try: value=json.load(sys.stdin) for part in path: value=value[int(part)] if isinstance(value,list) else value.get(part) if value is None: break if value is None: print("") elif isinstance(value,bool): print("true" if value else "false") elif isinstance(value,(dict,list)): print(json.dumps(value,separators=(",",":"),ensure_ascii=False)) else: print(value) except Exception: print("") ' "$path" } body_value() { printf '%s' "$LAST_BODY" | json_path "$1" } envelope_ok() { [ "$LAST_HTTP" = "200" ] && [ "$(body_value code)" = "0" ] } mark_pass() { PASS=$((PASS + 1)) log " ${G}PASS${N} $1" } mark_fail() { FAIL=$((FAIL + 1)) FAILED_ITEMS+=("$1") log " ${R}FAIL${N} $1" } # 狗粮安全边界业务码:逐字对账后端各模块 ErrorCodeConstants,禁止用“任意失败”冒充安全门生效。 CODE_PASSWORD_REGISTER_DISABLED=1002090007 # system PASSWORD_REGISTER_DISABLED = 1_002_090_007 CODE_DOGFOOD_SMS_DISABLED=1002090008 # system DOGFOOD_SMS_DISABLED = 1_002_090_008 CODE_AD_DOGFOOD_DISABLED=1111002006 # ad AD_DOGFOOD_DISABLED = 1_111_002_006 CODE_TRADE_DOGFOOD_DISABLED=1106008000 # trade TRADE_DOGFOOD_MONETIZATION_DISABLED = 1_106_008_000 expect_business_code() { local name="$1" expected="$2" token="$3" method="$4" path="$5" data="${6:-}" request "$token" "$method" "$path" "$data" local actual actual="$(body_value code)" if [ "$LAST_HTTP" = "200" ] && [ "$actual" = "$expected" ]; then mark_pass "${name}(业务码=${expected})" else e2e_fail "$name 未返回预期业务码(expected=$expected actual=${actual:-none} http=${LAST_HTTP})" fi } read_check() { local name="$1" path="$2" require_data="${3:-0}" request "" GET "$path" if envelope_ok; then if [ "$require_data" = "1" ] && ! printf '%s' "$LAST_BODY" | python3 -c ' import json,sys value=json.load(sys.stdin).get("data") assert value is not None and value != [] and value != {} and value != "" ' >/dev/null 2>&1; then mark_fail "${name}(data 为空)" else mark_pass "$name" fi else mark_fail "${name}(http=${LAST_HTTP} code=$(body_value code))" fi } require_env() { local name for name in "$@"; do if [ -z "${!name:-}" ]; then log "${R}缺少必填环境变量:$name${N}" >&2 exit 2 fi done } write_evidence() { local status="$1" mkdir -p "$(dirname "$EVIDENCE_OUT")" E2E_STATUS="$status" RUN_ID="$RUN_ID" TASK_CHAIN_ID="$TASK_CHAIN_ID" AIGC_TASK_ID="$AIGC_TASK_ID" \ GAME_ID="$GAME_ID" VERSION_ID="$VERSION_ID" SESSION_ID="$SESSION_ID" \ TELEMETRY_TRACE_ID="$TELEMETRY_TRACE_ID" ARTIFACT_HASH="$ARTIFACT_HASH" FAILURE_ITEM="$FAILURE_ITEM" \ python3 - "$EVIDENCE_OUT" <<'PY' import json,os,sys def number(name): raw=os.environ.get(name,"") return int(raw) if raw.isdigit() else None doc={ "runId":os.environ["RUN_ID"], "status":os.environ["E2E_STATUS"], "taskChainId":number("TASK_CHAIN_ID"), "aigcTaskId":number("AIGC_TASK_ID"), "gameId":number("GAME_ID"), "versionId":number("VERSION_ID"), "sessionId":number("SESSION_ID"), "telemetryTraceId":os.environ.get("TELEMETRY_TRACE_ID") or None, "artifactHash":os.environ.get("ARTIFACT_HASH") or None, "result":{"verdict":os.environ["E2E_STATUS"],"failedItem":os.environ.get("FAILURE_ITEM") or None}, "browserQaResult":"pending", } with open(sys.argv[1],"w",encoding="utf-8") as f: json.dump(doc,f,ensure_ascii=False,indent=2) PY chmod 600 "$EVIDENCE_OUT" } e2e_fail() { local message="$1" E2E_STATUS="failed" FAILURE_ITEM="$message" write_evidence "$E2E_STATUS" log "${R}DOGFOOD_E2E_FAIL${N} $message" log "证据:$EVIDENCE_OUT" exit 1 } e2e_require_ok() { local name="$1" if envelope_ok; then mark_pass "$name" else e2e_fail "${name}(http=${LAST_HTTP} code=$(body_value code))" fi } # 写模式具有明确副作用,账号凭据与人工确认必须在任何网络请求前齐备。 if [ "$MODE" = "dogfood-e2e" ]; then require_env DOGFOOD_CREATOR_USERNAME DOGFOOD_CREATOR_PASSWORD \ DOGFOOD_REVIEWER_USERNAME DOGFOOD_REVIEWER_PASSWORD \ DOGFOOD_READONLY_USERNAME DOGFOOD_READONLY_PASSWORD \ DOGFOOD_E2E_ACK if [ "$DOGFOOD_E2E_ACK" != "I_ACKNOWLEDGE_DOGFOOD_WRITES" ]; then log "${R}DOGFOOD_E2E_ACK 无效;必须显式设置为 I_ACKNOWLEDGE_DOGFOOD_WRITES${N}" >&2 exit 2 fi require_env EXPECTED_RC_COMMIT if [[ ! "$EXPECTED_RC_COMMIT" =~ ^[0-9a-f]{40}$ ]]; then log "${R}EXPECTED_RC_COMMIT 必须是完整 40 位小写提交哈希${N}" >&2 exit 2 fi # 写模式只允许在 mini-desktop 本机对固定 staging 端口执行,禁止 ACK 配合 BASE 重定向到其它环境。 if [ "$BASE" != "http://127.0.0.1:48080" ] || [ "$STUDIO_BASE" != "http://127.0.0.1:4173" ]; then log "${R}写 smoke 仅允许 BASE=http://127.0.0.1:48080、STUDIO_BASE=http://127.0.0.1:4173${N}" >&2 exit 2 fi if [ "$(hostname -s)" != "mini-desktop" ]; then log "${R}写 smoke 只能在 mini-desktop 本机执行${N}" >&2 exit 2 fi STAGING_ACTIVE_POINTER="${STAGING_ACTIVE_POINTER:-/root/game-staging/releases/active}" if [ ! -L "$STAGING_ACTIVE_POINTER" ]; then log "${R}缺少 staging active 指针:$STAGING_ACTIVE_POINTER${N}" >&2 exit 2 fi ACTIVE_RELEASE="$(readlink -f "$STAGING_ACTIVE_POINTER" 2>/dev/null || true)" if [ -z "$ACTIVE_RELEASE" ] || [ "${ACTIVE_RELEASE##*/}" != "$EXPECTED_RC_COMMIT" ]; then log "${R}active 指针与 EXPECTED_RC_COMMIT 不一致${N}" >&2 exit 2 fi if [ ! -f "$ACTIVE_RELEASE/RC_STATUS" ] || \ ! grep -q "^active commit=$EXPECTED_RC_COMMIT " "$ACTIVE_RELEASE/RC_STATUS"; then log "${R}active RC_STATUS 未证明目标 commit 已激活${N}" >&2 exit 2 fi # active 指针只能证明部署意图;运行中 backend 的真实命令行也必须来自同一 RC。 if ! "$SYSTEMCTL_BIN" is-active --quiet gda-staging-backend; then log "${R}staging backend systemd unit 未运行${N}" >&2 exit 2 fi BACKEND_PID="$("$SYSTEMCTL_BIN" show --property=MainPID --value gda-staging-backend 2>/dev/null || true)" if ! [[ "$BACKEND_PID" =~ ^[1-9][0-9]*$ ]] || [ ! -r "$PROC_ROOT/$BACKEND_PID/cmdline" ]; then log "${R}无法读取 staging backend 运行进程身份${N}" >&2 exit 2 fi EXPECTED_BACKEND_JAR="$ACTIVE_RELEASE/game-cloud/huijing-server/target/huijing-server.jar" if ! tr '\0' '\n' <"$PROC_ROOT/$BACKEND_PID/cmdline" | grep -Fqx -- "$EXPECTED_BACKEND_JAR"; then log "${R}运行中的 backend 不属于 active RC${N}" >&2 exit 2 fi fi head_line "公开只读 readiness" request "" GET /actuator/health if [ "$LAST_HTTP" = "200" ]; then mark_pass "backend health"; else mark_fail "backend health(http=${LAST_HTTP})"; fi read_check "feed zones" /app-api/feed/zones 1 read_check "feed stream" "/app-api/feed/stream?size=1" 1 FIRST_VERSION_ID="" request "" GET "/app-api/feed/stream?size=1" if envelope_ok; then FIRST_VERSION_ID="$(body_value data.list.0.versionId)" fi if [ -n "$FIRST_VERSION_ID" ]; then read_check "published runtime package(versionId=${FIRST_VERSION_ID})" "/app-api/runtime/package/${FIRST_VERSION_ID}?scene=play" 1 else mark_fail "feed 首条缺 versionId,无法校验公开运行包" fi if [ "$MODE" = "readiness" ]; then head_line "总判" if [ "$FAIL" -eq 0 ]; then log "${G}SMOKE_PASS${N} $PASS/$((PASS + FAIL)) 全过(默认模式无写请求)" log "SMOKE_VERDICT=PASS" exit 0 fi log "${R}SMOKE_FAIL${N} $PASS/$((PASS + FAIL)) 通过" printf ' - %s\n' "${FAILED_ITEMS[@]}" log "SMOKE_VERDICT=FAIL" exit 1 fi # 以下模式会创建并发布一款带 RUN_ID 的游戏、写试玩会话和遥测事件,不做隐式清理。 E2E_TIMEOUT_SEC="${E2E_TIMEOUT_SEC:-1200}" POLL_INTERVAL_SEC="${POLL_INTERVAL_SEC:-5}" E2E_PROMPT="${E2E_PROMPT:-做一个躲避陨石、可以计分并有明确结束状态的小游戏}" E2E_TITLE="${E2E_TITLE:-dogfood-${RUN_ID}}" export DOGFOOD_CREATOR_USERNAME DOGFOOD_CREATOR_PASSWORD DOGFOOD_REVIEWER_USERNAME \ DOGFOOD_REVIEWER_PASSWORD DOGFOOD_READONLY_USERNAME DOGFOOD_READONLY_PASSWORD \ E2E_PROMPT E2E_TITLE RUN_ID head_line "真实账号登录(凭据不落日志)" CREATOR_LOGIN_BODY="$(python3 - <<'PY' import json,os print(json.dumps({"username":os.environ["DOGFOOD_CREATOR_USERNAME"],"password":os.environ["DOGFOOD_CREATOR_PASSWORD"]})) PY )" request "" POST /app-api/passport/password-login "$CREATOR_LOGIN_BODY" e2e_require_ok "creator password login" CREATOR_TOKEN="$(body_value data.accessToken)" CREATOR_USER_ID="$(body_value data.userId)" CREATOR_FLAG="$(body_value data.creatorFlag)" [ -n "$CREATOR_TOKEN" ] || e2e_fail "creator 登录响应缺 accessToken" [ "$CREATOR_FLAG" = "1" ] || e2e_fail "creatorFlag=${CREATOR_FLAG},不是预置创作者" admin_login() { local role="$1" user_var="$2" pass_var="$3" local payload export DOGFOOD_LOGIN_USERNAME="${!user_var}" DOGFOOD_LOGIN_PASSWORD="${!pass_var}" payload="$(python3 - <<'PY' import json,os print(json.dumps({"username":os.environ["DOGFOOD_LOGIN_USERNAME"],"password":os.environ["DOGFOOD_LOGIN_PASSWORD"]})) PY )" request "" POST /admin-api/system/auth/login "$payload" envelope_ok || e2e_fail "$role 登录失败(http=$LAST_HTTP code=$(body_value code))" ADMIN_LOGIN_TOKEN="$(body_value data.accessToken)" [ -n "$ADMIN_LOGIN_TOKEN" ] || e2e_fail "$role 登录响应缺 accessToken" } ADMIN_LOGIN_TOKEN="" admin_login reviewer DOGFOOD_REVIEWER_USERNAME DOGFOOD_REVIEWER_PASSWORD REVIEWER_TOKEN="$ADMIN_LOGIN_TOKEN" admin_login readonly DOGFOOD_READONLY_USERNAME DOGFOOD_READONLY_PASSWORD READONLY_TOKEN="$ADMIN_LOGIN_TOKEN" mark_pass "reviewer / readonly password login" head_line "狗粮安全边界精确负例" # 用户名最长 30 位,保留 dogreg + RUN_ID 数字前 24 位。 REGISTER_NEGATIVE_BODY="$(REGISTER_NAME="dogreg${RUN_ID//[^0-9]/}" python3 - <<'PY' import json,os print(json.dumps({"username":os.environ["REGISTER_NAME"][:30],"password":"DogfoodNoRegister1"})) PY )" expect_business_code "password register disabled" "$CODE_PASSWORD_REGISTER_DISABLED" "" POST \ /app-api/passport/password-register "$REGISTER_NEGATIVE_BODY" expect_business_code "SMS send disabled" "$CODE_DOGFOOD_SMS_DISABLED" "" POST \ /app-api/passport/send-sms-code '{"mobile":"13800000000"}' expect_business_code "ad slot disabled" "$CODE_AD_DOGFOOD_DISABLED" "$CREATOR_TOKEN" GET \ /app-api/ad/slot/list-enabled expect_business_code "charge balance disabled" "$CODE_TRADE_DOGFOOD_DISABLED" "$CREATOR_TOKEN" GET \ /app-api/trade/charge/points-balance expect_business_code "withdraw page disabled" "$CODE_TRADE_DOGFOOD_DISABLED" "$CREATOR_TOKEN" GET \ '/app-api/trade/withdraw/page?pageNo=1&pageSize=1' head_line "创建并等待生成终态" CREATE_BODY="$(python3 - <<'PY' import json,os print(json.dumps({"title":os.environ["E2E_TITLE"],"prompt":os.environ["E2E_PROMPT"], "templateId":"generic","idempotencyKey":"dogfood-"+os.environ["RUN_ID"]},ensure_ascii=False)) PY )" request "$CREATOR_TOKEN" POST /app-api/studio/create "$CREATE_BODY" e2e_require_ok "studio one-step create" TASK_CHAIN_ID="$(body_value data.id)" AIGC_TASK_ID="$(body_value data.aigcTaskId)" GAME_ID="$(body_value data.gameId)" [ -n "$TASK_CHAIN_ID" ] && [ -n "$GAME_ID" ] || e2e_fail "create 响应缺 taskChainId/gameId" deadline=$((SECONDS + E2E_TIMEOUT_SEC)) while [ "$SECONDS" -lt "$deadline" ]; do request "$CREATOR_TOKEN" GET "/app-api/studio/task/$TASK_CHAIN_ID" envelope_ok || e2e_fail "轮询任务链失败(http=$LAST_HTTP code=$(body_value code))" task_status="$(body_value data.status)" AIGC_TASK_ID="$(body_value data.aigcTaskId)" GAME_ID="$(body_value data.gameId)" VERSION_ID="$(body_value data.versionId)" if [ "$task_status" = "2" ]; then break; fi [ "$task_status" != "3" ] || e2e_fail "生成进入失败终态(taskChainId=${TASK_CHAIN_ID} aigcTaskId=${AIGC_TASK_ID})" sleep "$POLL_INTERVAL_SEC" done [ "${task_status:-}" = "2" ] || e2e_fail "等待生成终态超时 ${E2E_TIMEOUT_SEC}s" [ -n "$VERSION_ID" ] || e2e_fail "生成完成但 versionId 为空" mark_pass "generation terminal success(taskChainId=${TASK_CHAIN_ID} versionId=${VERSION_ID})" head_line "预览包与发布门禁" request "$CREATOR_TOKEN" GET "/app-api/runtime/package/$VERSION_ID?scene=preview" e2e_require_ok "creator preview package descriptor" ARTIFACT_HASH="$(body_value data.checksum)" if ! [[ "$ARTIFACT_HASH" =~ ^[0-9a-f]{64}$ ]]; then e2e_fail "预览运行包缺少合法 SHA-256 checksum" fi request "$CREATOR_TOKEN" GET "/app-api/runtime/package/$VERSION_ID/manifest?scene=preview" if [ "$LAST_HTTP" = "200" ] && printf '%s' "$LAST_BODY" | python3 -m json.tool >/dev/null 2>&1; then mark_pass "creator preview manifest" else e2e_fail "预览 manifest 不可读(http=${LAST_HTTP})" fi LAUNCH_ZONE_ID="${DOGFOOD_ZONE_ID:-}" if [ -z "$LAUNCH_ZONE_ID" ]; then request "" GET /app-api/feed/zones envelope_ok || e2e_fail "无法读取发布专区" LAUNCH_ZONE_ID="$(body_value data.0.id)" fi [ -n "$LAUNCH_ZONE_ID" ] || e2e_fail "没有可用发布专区,请设置 DOGFOOD_ZONE_ID" PUBLISH_BODY="{\"versionId\":$VERSION_ID,\"launchZoneId\":$LAUNCH_ZONE_ID}" request "$CREATOR_TOKEN" POST "/app-api/project/$GAME_ID/publish" "$PUBLISH_BODY" e2e_require_ok "submit publish" [ "$(body_value data.admitted)" = "true" ] || e2e_fail "发布门禁未全通过:$(body_value data.gates)" # 扫完整个主 feed:0=找到并输出 versionId,1=确认到底仍不存在,2=请求/游标异常导致无法下结论。 scan_main_feed() { local cursor="" page encoded found for page in $(seq 1 20); do if [ -n "$cursor" ]; then encoded="$(python3 -c 'import sys,urllib.parse; print(urllib.parse.quote(sys.argv[1],safe=""))' "$cursor")" request "" GET "/app-api/feed/stream?size=30&cursor=$encoded" else request "" GET "/app-api/feed/stream?size=30" fi envelope_ok || return 2 found="$(printf '%s' "$LAST_BODY" | python3 -c 'import json,sys; gid=int(sys.argv[1]); d=json.load(sys.stdin); xs=(d.get("data") or {}).get("list",[]); print(next((x.get("versionId","") for x in xs if int(x.get("gameId",-1))==gid),""))' "$GAME_ID")" if [ -n "$found" ]; then printf '%s' "$found"; return 0; fi [ "$(body_value data.hasMore)" = "true" ] || return 1 cursor="$(body_value data.nextCursor)" [ -n "$cursor" ] || return 2 done return 2 } head_line "审核队列与 RBAC 负验" request "$REVIEWER_TOKEN" GET "/admin-api/project/review/page?pageNo=1&pageSize=100&status=1" e2e_require_ok "reviewer review queue" if ! printf '%s' "$LAST_BODY" | python3 -c 'import json,sys; gid=int(sys.argv[1]); d=json.load(sys.stdin); assert any(int(x.get("id",-1))==gid for x in (d.get("data") or {}).get("list",[]))' "$GAME_ID"; then e2e_fail "新游戏未进入审核队列(gameId=${GAME_ID})" fi REVIEW_BODY="{\"gameId\":$GAME_ID,\"versionId\":$VERSION_ID,\"decision\":1,\"reason\":\"dogfood $RUN_ID\"}" request "$READONLY_TOKEN" POST /admin-api/project/review "$REVIEW_BODY" if envelope_ok; then e2e_fail "readonly 账号竟可审核,RBAC 负验失败" fi request "$REVIEWER_TOKEN" GET "/admin-api/project/review/page?pageNo=1&pageSize=100&status=1" if ! envelope_ok || ! printf '%s' "$LAST_BODY" | python3 -c 'import json,sys; gid=int(sys.argv[1]); d=json.load(sys.stdin); assert any(int(x.get("id",-1))==gid for x in (d.get("data") or {}).get("list",[]))' "$GAME_ID"; then e2e_fail "readonly 拒绝后审核对象状态发生变化" fi mark_pass "readonly 审核写权限被拒绝且状态未变" if PRE_APPROVE_VERSION="$(scan_main_feed)"; then PRE_APPROVE_SCAN_RC=0 else PRE_APPROVE_SCAN_RC=$? fi if [ "$PRE_APPROVE_SCAN_RC" -eq 0 ]; then e2e_fail "审核通过前 gameId=$GAME_ID 已进入主 feed(versionId=${PRE_APPROVE_VERSION})" elif [ "$PRE_APPROVE_SCAN_RC" -eq 1 ]; then mark_pass "pre-approve game absent from main feed" else e2e_fail "审核通过前无法完成主 feed 全量缺席证明" fi request "$REVIEWER_TOKEN" POST /admin-api/project/review "$REVIEW_BODY" e2e_require_ok "reviewer approve" head_line "发布态、feed 与公开运行包" deadline=$((SECONDS + 90)) project_status="" while [ "$SECONDS" -lt "$deadline" ]; do request "$CREATOR_TOKEN" GET "/app-api/project/$GAME_ID" if envelope_ok; then project_status="$(body_value data.status)"; fi [ "$project_status" = "4" ] && break sleep 2 done [ "$project_status" = "4" ] || e2e_fail "审核通过后项目未进入 published(4)" mark_pass "project published status=4" if FEED_VERSION_ID="$(scan_main_feed)"; then POST_APPROVE_SCAN_RC=0 else POST_APPROVE_SCAN_RC=$? fi [ "$POST_APPROVE_SCAN_RC" -eq 0 ] || e2e_fail "审核通过后未能在主 feed 找到 gameId=${GAME_ID}(scanRc=${POST_APPROVE_SCAN_RC})" [ "$FEED_VERSION_ID" = "$VERSION_ID" ] || e2e_fail "feed versionId=$FEED_VERSION_ID 与生成版本 $VERSION_ID 不一致" mark_pass "exact game appears in main feed" request "" GET "/app-api/runtime/package/$VERSION_ID?scene=play" e2e_require_ok "public play package descriptor" request "" GET "/app-api/runtime/package/$VERSION_ID/manifest?scene=play" if [ "$LAST_HTTP" = "200" ] && printf '%s' "$LAST_BODY" | python3 -m json.tool >/dev/null 2>&1; then mark_pass "public play manifest" else e2e_fail "公开试玩 manifest 不可读(http=${LAST_HTTP})" fi head_line "试玩会话与遥测回流" request "$READONLY_TOKEN" GET "/admin-api/telemetry/game-stat/$GAME_ID" e2e_require_ok "readonly telemetry query" BASE_PLAY_COUNT="$(body_value data.playCount)"; BASE_PLAY_COUNT="${BASE_PLAY_COUNT:-0}" BASE_END_COUNT="$(body_value data.playEndCount)"; BASE_END_COUNT="${BASE_END_COUNT:-0}" TELEMETRY_TRACE_ID="$(python3 -c 'import uuid; print(uuid.uuid4())')" CLIENT_PLAY_TOKEN="ptk-${RUN_ID:0:36}" SESSION_BODY="{\"gameId\":$GAME_ID,\"versionId\":$VERSION_ID,\"clientPlayToken\":\"$CLIENT_PLAY_TOKEN\",\"scene\":\"play\",\"traceId\":\"$TELEMETRY_TRACE_ID\"}" request "$CREATOR_TOKEN" POST /app-api/runtime/session/start "$SESSION_BODY" e2e_require_ok "runtime session start" SESSION_ID="$(body_value data.sessionId)" [ -n "$SESSION_ID" ] || e2e_fail "session start 未返回 sessionId" request "$CREATOR_TOKEN" POST /app-api/runtime/session/end "{\"sessionId\":$SESSION_ID,\"durationMs\":1200,\"endReason\":\"game_end\"}" e2e_require_ok "runtime session end" export GAME_ID VERSION_ID SESSION_ID TELEMETRY_TRACE_ID CREATOR_USER_ID TELEMETRY_BODY="$(python3 - <<'PY' import json,os,time,uuid now=int(time.time()*1000) base={"schemaVersion":"v1","traceId":os.environ["TELEMETRY_TRACE_ID"], "sessionId":os.environ["SESSION_ID"],"user":{"userId":os.environ["CREATOR_USER_ID"]}, "context":{"gameId":os.environ["GAME_ID"],"versionId":os.environ["VERSION_ID"],"deviceType":"desktop"}} start={**base,"eventId":str(uuid.uuid4()),"event":"game_play_start","ts":now,"props":{}} end={**base,"eventId":str(uuid.uuid4()),"event":"game_play_end","ts":now+1200, "props":{"duration_ms":1200,"completed":True}} print(json.dumps({"batch":[start,end]},separators=(",",":"))) PY )" request "$CREATOR_TOKEN" POST /app-api/telemetry/events/batch "$TELEMETRY_BODY" e2e_require_ok "telemetry batch admission" [ "$(body_value data.accepted)" = "2" ] && [ "$(body_value data.rejected)" = "0" ] || \ e2e_fail "遥测批量未全部受理(accepted=$(body_value data.accepted) rejected=$(body_value data.rejected))" deadline=$((SECONDS + 120)) PLAY_COUNT="$BASE_PLAY_COUNT"; END_COUNT="$BASE_END_COUNT" while [ "$SECONDS" -lt "$deadline" ]; do request "$READONLY_TOKEN" GET "/admin-api/telemetry/game-stat/$GAME_ID" if envelope_ok; then PLAY_COUNT="$(body_value data.playCount)"; PLAY_COUNT="${PLAY_COUNT:-0}" END_COUNT="$(body_value data.playEndCount)"; END_COUNT="${END_COUNT:-0}" if [ "$PLAY_COUNT" -gt "$BASE_PLAY_COUNT" ] 2>/dev/null && [ "$END_COUNT" -gt "$BASE_END_COUNT" ] 2>/dev/null; then break; fi fi sleep 3 done if [ "$PLAY_COUNT" -le "$BASE_PLAY_COUNT" ] 2>/dev/null || [ "$END_COUNT" -le "$BASE_END_COUNT" ] 2>/dev/null; then e2e_fail "遥测 MQ 聚合在 120s 内未回流(play ${BASE_PLAY_COUNT}->${PLAY_COUNT}, end ${BASE_END_COUNT}->${END_COUNT})" fi mark_pass "telemetry persisted and aggregated(play=${PLAY_COUNT} end=${END_COUNT})" E2E_STATUS="passed" write_evidence "$E2E_STATUS" head_line "总判" log "${G}DOGFOOD_E2E_PASS${N} gameId=$GAME_ID versionId=$VERSION_ID sessionId=$SESSION_ID" log "浏览器真玩仍是独立硬门:$STUDIO_BASE/play/$GAME_ID/$VERSION_ID" log "证据:$EVIDENCE_OUT" log "DOGFOOD_E2E_VERDICT=PASS"