feat(release): wire dogfood staging product flow

This commit is contained in:
lili 2026-07-22 22:23:19 -07:00
parent af15ee1a32
commit 3bff0eefc3
12 changed files with 642 additions and 639 deletions

View File

@ -1,182 +1,487 @@
#!/usr/bin/env bash
# =============================================================================
# 造梦/绘境AI · staging 五链路冒烟门deploy smoke gate
# -----------------------------------------------------------------------------
# 用途:每次 staging 部署后跑一遍,**秒级**确认 5 条端到端闭环的关键 API 在岗、
# 鉴权/CORS/质量分回路结构正常。这是「部署后是否健康」的就绪检查liveness/
# readiness**不是**深度 e2e深度 e2e 走 agent-loop orchestrator 批跑)。
#
# 设计原则:
# - 默认 **以读为主**health/列表/feed/钱包/包体),可随时重复跑;唯一写=studio/draft 建一条
# 草稿status 0不发布、无下游副作用、可忽略用于验证创作入口+鉴权+gameId 分配在岗;
# - `--deep` 才走重写路径generate 入队触发真实生成 + 遥测事件 + 互动),会落库,按需开启;
# - 每条链路逐项 PASS/FAIL末尾总判 + 退出码0=全过1=有 FAIL——可挂 CI/部署脚本。
#
# 用法:
# bash deploy/smoke-test.sh # 默认 staging 只读冒烟
# BASE=http://100.64.0.7:48080 TOKEN=test1 bash deploy/smoke-test.sh
# bash deploy/smoke-test.sh --deep # 含写路径(生成入队+遥测事件)
#
# 约定:内网地址/测试 token 可入仓(内网非密);**真实密钥严禁写入本文件**。
# =============================================================================
# 绘境AI staging 冒烟门。
# 默认模式只做公开读 readiness--dogfood-e2e 才执行真实创作、审核、试玩会话和遥测写链。
set -uo pipefail
BASE="${BASE:-http://100.64.0.7:48080}" # staging 后端mini-desktop
TOKEN="${TOKEN:-test1}" # staging 批跑/冒烟测试态 token种子创作者
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}"
ORIGIN="${ORIGIN:-http://100.64.0.7:4173}" # 前端 origin验 CORS 同源放行)
DEEP=0
[ "${1:-}" = "--deep" ] && DEEP=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
PASS=0; FAIL=0; FAILED_ITEMS=()
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=""
# 颜色(非 TTY 自动降级为纯文本)
if [ -t 1 ]; then G='\033[0;32m'; R='\033[0;31m'; Y='\033[0;33m'; B='\033[0;34m'; N='\033[0m'; else G=''; R=''; Y=''; B=''; N=''; fi
cleanup() {
rm -f "$BODY_FILE"
}
trap cleanup EXIT
_log() { printf "%b\n" "$*"; }
_head() { printf "\n%b== %s ==%b\n" "$B" "$1" "$N"; }
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
# api METHOD PATH [DATA] —— 返回 "HTTP_CODE\nBODY";鉴权头正确引用(防 'Bearer test1' 空格被拆)
api() {
local method="$1" path="$2" data="${3:-}"
local url="$path"; [[ "$path" == http* ]] || url="$BASE$path"
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
curl -s -w $'\n%{http_code}' -X "$method" "$url" \
-H "Authorization: Bearer $TOKEN" -H "tenant-id: $TENANT" \
-H "Origin: $ORIGIN" -H "Content-Type: application/json" -d "$data" 2>/dev/null
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
curl -s -w $'\n%{http_code}' -X "$method" "$url" \
-H "Authorization: Bearer $TOKEN" -H "tenant-id: $TENANT" \
-H "Origin: $ORIGIN" 2>/dev/null
e2e_fail "$name 未返回预期业务码expected=$expected actual=${actual:-none} http=${LAST_HTTP}"
fi
}
# check NAME METHOD PATH MODE [EXTRA] [DATA]
# MODE=health → HTTP 200 即过actuator
# MODE=envelope → HTTP 200 ∧ huijing body code==0 即过
# MODE=envelope_nonempty → 再要求 data 非空list 长度≥1 或 data 非 null
# EXTRAenvelope_nonempty 下可传 jq 风格 python 取数表达式(默认探 data.list 或 data
check() {
local name="$1" method="$2" path="$3" mode="$4" data="${5:-}"
local out code body
out="$(api "$method" "$path" "$data")"
code="$(printf '%s' "$out" | tail -n1)"
body="$(printf '%s' "$out" | sed '$d')"
local ok=0 detail=""
case "$mode" in
health)
[ "$code" = "200" ] && ok=1; detail="http=$code" ;;
envelope|envelope_nonempty)
if [ "$code" = "200" ]; then
# 解析 huijing 信封 {code,data}envelope_nonempty 再判 data 是否非空
local pyout
pyout="$(printf '%s' "$body" | python3 -c "
import sys,json
try: d=json.load(sys.stdin)
except Exception as e: print('ERR|parse'); sys.exit()
c=d.get('code')
data=d.get('data')
if isinstance(data,dict): n=len(data.get('list')) if isinstance(data.get('list'),list) else (1 if data else 0)
elif isinstance(data,list): n=len(data)
elif data is None: n=0
else: n=1
print('%s|%s'%(c,n))
" 2>/dev/null)"
local ecode="${pyout%%|*}" ecount="${pyout##*|}"
if [ "$ecode" = "0" ]; then
if [ "$mode" = "envelope_nonempty" ]; then
[ "${ecount:-0}" -ge 1 ] 2>/dev/null && ok=1 || ok=0
detail="code=0 count=$ecount"
else ok=1; detail="code=0"; fi
else detail="http=$code code=$ecode"; fi
else detail="http=$code"; fi ;;
esac
if [ "$ok" = "1" ]; then PASS=$((PASS+1)); _log " ${G}${N} $name ($detail)"; else FAIL=$((FAIL+1)); FAILED_ITEMS+=("$name [$detail]"); _log " ${R}${N} $name ($detail)"; 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
}
_log "${B}绘境AI staging 五链路冒烟门${N} BASE=$BASE TOKEN=${TOKEN:0:2}*** deep=$DEEP"
require_env() {
local name
for name in "$@"; do
if [ -z "${!name:-}" ]; then
log "${R}缺少必填环境变量:$name${N}" >&2
exit 2
fi
done
}
# ---------------- 基础设施 + 鉴权 ----------------
_head "基础设施 / 鉴权"
check "infra: actuator/health" GET /actuator/health health
check "auth: passport/metoken 有效)" GET /app-api/passport/me envelope
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"
}
# ---------------- 链路① 创作 → 生成 → 预览试玩 ----------------
_head "链路① 创作→生成→预览"
# 四模板就绪(执行器 banner 同源getTemplateList 应返 clicker/merge/idle/tycoon
out="$(api GET /app-api/aigc/template/list)"
tcount="$(printf '%s' "$out" | sed '$d' | python3 -c "import sys,json
try: d=json.load(sys.stdin); data=d.get('data') or []; print(len(data) if isinstance(data,list) else 0)
except: print(-1)" 2>/dev/null)"
if [ "${tcount:-0}" -ge 4 ] 2>/dev/null; then PASS=$((PASS+1)); _log " ${G}${N} aigc/template/list四模板就绪 count=${tcount}"; else FAIL=$((FAIL+1)); FAILED_ITEMS+=("template/list count=$tcount"); _log " ${R}${N} aigc/template/list期望≥4实=${tcount}"; fi
check "studio/draft创建草稿拿 gameId" POST /app-api/studio/draft envelope_nonempty '{"title":"冒烟门-clicker","templateId":"clicker"}'
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
}
# ---------------- 链路② 发布 → 审核 → 游戏流 ----------------
_head "链路② 发布→审核→游戏流"
check "feed/stream内容池非空" GET "/app-api/feed/stream?size=5" envelope_nonempty
check "feed/zones分区结构" GET /app-api/feed/zones envelope
check "project/my创作者项目区" GET "/app-api/project/my?pageNo=1&pageSize=5" envelope
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
}
# ---------------- 链路③ 游戏流 → 试玩 → 互动 → 分享 ----------------
_head "链路③ 试玩→互动→分享"
# 真实取包走 GET /app-api/runtime/package/{versionId}descriptor 含 entry/manifest/sandbox
# 注意feed item 的 packageUrl 字段恒为 null**不是**取包入口runtime.ts:25 实证)。
VID="$(api GET "/app-api/feed/stream?size=1" | sed '$d' | python3 -c "
import sys,json
try:
d=json.load(sys.stdin); lst=(d.get('data') or {}).get('list') or []
print(lst[0].get('versionId','') if lst else '')
except: print('')" 2>/dev/null)"
if [ -n "$VID" ] && [ "$VID" != "None" ]; then
check "runtime/package/{versionId}(试玩取包 vid=${VID}" GET "/app-api/runtime/package/$VID" envelope
else FAIL=$((FAIL+1)); FAILED_ITEMS+=("feed 首条无 versionId"); _log " ${R}${N} feed 首条无 versionId无法验取包"; fi
# ---------------- 链路④ 广告 → 收益 → 钱包 ----------------
_head "链路④ 广告→收益→钱包"
check "ad/slot/list-enabled广告位" GET /app-api/ad/slot/list-enabled envelope
check "trade/account/mine钱包" GET /app-api/trade/account/mine envelope
check "trade/income/page收益流水" GET "/app-api/trade/income/page?pageNo=1&pageSize=5" envelope
# ---------------- 链路⑤ 数据回路遥测→质量分→feed 重排)----------------
_head "链路⑤ 数据回路(质量分回灌)"
# 结构断言feed 首条携带 qualityScorefeed 排序由质量分驱动 = 回路已回灌)
QSCORE="$(api GET "/app-api/feed/stream?size=1" | sed '$d' | python3 -c "
import sys,json
try:
d=json.load(sys.stdin); lst=(d.get('data') or {}).get('list') or []
print(lst[0].get('qualityScore') if lst and lst[0].get('qualityScore') is not None else 'NONE')
except: print('NONE')" 2>/dev/null)"
if [ "$QSCORE" != "NONE" ] && [ -n "$QSCORE" ]; then PASS=$((PASS+1)); _log " ${G}${N} feed 携带 qualityScore=${QSCORE}(质量分回路已回灌)"; else FAIL=$((FAIL+1)); FAILED_ITEMS+=("feed 无 qualityScore"); _log " ${R}${N} feed 首条无 qualityScore"; fi
# ---------------- --deep写路径生成入队 + 遥测事件)----------------
if [ "$DEEP" = "1" ]; then
_head "深度(--deep写路径创作→生成入队"
# 真实创作链写路径create_draft → studio_generate契约 backend_gw.py:123/130
# generate body = {sessionId: draft.id, prompt}sessionId=draft 的 id 非 gameId
# draft 不设 templateId便宜档真实流「一句话不选模板」→ 后端归一 generic → 分派闸templateId=='generic')投外置 worker :9501
# 旧四模板名idle/clicker 等会撞硬相等分派闸、fall-through 到进程内 LLM 旧路、到不了 worker见 staging-ops §8
# 入队触发 executor 派发 worker异步产包经回调落版本本冒烟**只验入队 200/code=0**,不等生成完成。
# 不验 telemetry/interact 写:① 遥测回路产出已由链路⑤(feed 携 qualityScore)结构性验证;
# ② 完整深度 e2eplay→judge→publish是 agent-loop orchestrator 批跑的职责,不与冒烟门重叠。
SID="$(api POST /app-api/studio/draft '{"title":"冒烟门-deep-generic"}' | sed '$d' | python3 -c "import sys,json
try: print((json.load(sys.stdin).get('data') or {}).get('id',''))
except: print('')" 2>/dev/null)"
if [ -n "$SID" ] && [ "$SID" != "None" ]; then
check "studio/generate入队 sessionId=${SID}" POST /app-api/studio/generate envelope \
"{\"sessionId\":$SID,\"prompt\":\"一个挂机种树攒果实的放置小游戏\"}"
else FAIL=$((FAIL+1)); FAILED_ITEMS+=("deep: draft 无 id"); _log " ${R}${N} deep: draft 未拿到 sessionId(id)"; 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
fi
# ---------------- 总判 ----------------
_head "总判"
TOTAL=$((PASS+FAIL))
if [ "$FAIL" = "0" ]; then
_log "${G}SMOKE_PASS${N} $PASS/$TOTAL 全过"
echo "SMOKE_VERDICT=PASS"
exit 0
head_line "公开只读 readiness"
request "" GET /actuator/health
if [ "$LAST_HTTP" = "200" ]; then mark_pass "backend health"; else mark_fail "backend healthhttp=${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 packageversionId=${FIRST_VERSION_ID}" "/app-api/runtime/package/${FIRST_VERSION_ID}?scene=play" 1
else
_log "${R}SMOKE_FAIL${N} $PASS/$TOTAL 过,$FAIL 项 FAIL"
for it in "${FAILED_ITEMS[@]}"; do _log " ${R}-${N} $it"; done
echo "SMOKE_VERDICT=FAIL"
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 successtaskChainId=${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)"
# 扫完整个主 feed0=找到并输出 versionId1=确认到底仍不存在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 审核写权限被拒绝且状态未变"
PRE_APPROVE_VERSION="$(scan_main_feed)"
PRE_APPROVE_SCAN_RC=$?
if [ "$PRE_APPROVE_SCAN_RC" -eq 0 ]; then
e2e_fail "审核通过前 gameId=$GAME_ID 已进入主 feedversionId=${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"
FEED_VERSION_ID="$(scan_main_feed)"
POST_APPROVE_SCAN_RC=$?
[ "$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 aggregatedplay=${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"

View File

@ -0,0 +1,113 @@
#!/usr/bin/env bash
# 内部狗粮 smoke 回归:用假 HTTP 服务锁住默认无副作用与写模式 fail-closed 前置门。
set -euo pipefail
ROOT_DIR="$(cd "$(dirname "$0")/../.." && pwd)"
SMOKE="$ROOT_DIR/deploy/smoke-test.sh"
TMP_DIR="$(mktemp -d)"
REQUEST_LOG="$TMP_DIR/requests.log"
trap 'rm -rf "$TMP_DIR"' EXIT
fail() {
printf 'FAIL: %s\n' "$1" >&2
exit 1
}
assert_contains() {
local file="$1" expected="$2"
grep -Fq "$expected" "$file" || fail "$file 缺少:$expected"
}
assert_exit_code() {
local expected="$1" actual="$2" message="$3"
[ "$actual" -eq "$expected" ] || fail "$messageexpected=$expected actual=$actual"
}
# 假 curl 只实现 readiness 所需响应,并记录方法与 URL测试不访问任何真实服务。
mkdir -p "$TMP_DIR/bin"
cat >"$TMP_DIR/bin/curl" <<'FAKE_CURL'
#!/usr/bin/env bash
set -euo pipefail
method="GET"
output=""
url=""
while [ "$#" -gt 0 ]; do
case "$1" in
-X) method="$2"; shift 2 ;;
-o) output="$2"; shift 2 ;;
-w|-H|--connect-timeout|--max-time|--data) shift 2 ;;
-sS) shift ;;
http://*|https://*) url="$1"; shift ;;
*) shift ;;
esac
done
printf '%s %s\n' "$method" "$url" >>"$REQUEST_LOG"
case "$url" in
*/actuator/health)
body='{"status":"UP"}' ;;
*/app-api/feed/zones)
body='{"code":0,"data":[{"id":1}]}' ;;
*/app-api/feed/stream*)
body='{"code":0,"data":{"list":[{"gameId":11,"versionId":22}],"hasMore":false}}' ;;
*/app-api/runtime/package/22*)
body='{"code":0,"data":{"gameId":11,"versionId":22,"checksum":"aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"}}' ;;
*)
body='{"code":404,"data":null}' ;;
esac
printf '%s' "$body" >"$output"
printf '200'
FAKE_CURL
chmod +x "$TMP_DIR/bin/curl"
export PATH="$TMP_DIR/bin:$PATH"
export REQUEST_LOG
bash -n "$SMOKE"
# 构建配置只能包含内网地址和模式开关,不能烘焙登录 token。
assert_contains "$ROOT_DIR/game-studio/.env.staging" 'VITE_STUDIO_TOKEN='
assert_contains "$ROOT_DIR/game-studio/.env.staging" 'VITE_DOGFOOD_MODE=true'
if grep -Eq '^VITE_STUDIO_TOKEN=.+$' "$ROOT_DIR/game-studio/.env.staging"; then
fail 'game-studio staging 构建烘焙了 token'
fi
assert_contains "$ROOT_DIR/game-admin/.env.stage" "VITE_BASE_URL='http://100.64.0.7:48080'"
if find "$ROOT_DIR/game-studio/public/mock-manifests" -type f -print -quit 2>/dev/null | grep -q .; then
fail 'staging public 目录仍包含退役 mock/CDP 走查资产'
fi
# 写模式必须显式确认副作用,且在任何 HTTP 请求前 fail-closed。
assert_contains "$SMOKE" 'DOGFOOD_E2E_ACK'
set +e
PATH="$TMP_DIR/bin:$PATH" "$SMOKE" --dogfood-e2e >"$TMP_DIR/missing-env.out" 2>&1
missing_env_rc=$?
set -e
assert_exit_code 2 "$missing_env_rc" '缺凭据时没有 fail-closed'
[ ! -s "$REQUEST_LOG" ] || fail '缺凭据时已发出网络请求'
set +e
DOGFOOD_CREATOR_USERNAME=creator DOGFOOD_CREATOR_PASSWORD=creator-pass \
DOGFOOD_REVIEWER_USERNAME=reviewer DOGFOOD_REVIEWER_PASSWORD=reviewer-pass \
DOGFOOD_READONLY_USERNAME=readonly DOGFOOD_READONLY_PASSWORD=readonly-pass \
PATH="$TMP_DIR/bin:$PATH" "$SMOKE" --dogfood-e2e >"$TMP_DIR/missing-ack.out" 2>&1
missing_ack_rc=$?
set -e
assert_exit_code 2 "$missing_ack_rc" '缺 ACK 时没有 fail-closed'
[ ! -s "$REQUEST_LOG" ] || fail '缺 ACK 时已发出网络请求'
# 默认 readiness 只能发送 GET且假服务完整返回时应通过。
PATH="$TMP_DIR/bin:$PATH" "$SMOKE" >"$TMP_DIR/readiness.out" 2>&1
assert_contains "$TMP_DIR/readiness.out" 'SMOKE_VERDICT=PASS'
if grep -Ev '^GET ' "$REQUEST_LOG" | grep -q .; then
fail '默认 readiness 发出了非 GET 请求'
fi
# 未知参数同样必须在发请求前拒绝。
: >"$REQUEST_LOG"
set +e
PATH="$TMP_DIR/bin:$PATH" "$SMOKE" --deep >"$TMP_DIR/unknown.out" 2>&1
unknown_rc=$?
set -e
assert_exit_code 2 "$unknown_rc" '未知参数未拒绝'
[ ! -s "$REQUEST_LOG" ] || fail '未知参数触发了网络请求'
printf 'PASS: dogfood smoke fail-closed regression\n'

View File

@ -3,8 +3,8 @@ NODE_ENV=production
VITE_DEV=false
# 请求路径
VITE_BASE_URL='http://api-dashboard.huijing.iocoder.cn'
# staging 后端固定走 mini-desktop 内网地址,禁止误连上游演示环境。
VITE_BASE_URL='http://100.64.0.7:48080'
# 文件上传类型server - 后端上传, client - 前端直连上传仅支持S3服务
VITE_UPLOAD_TYPE=server
@ -21,14 +21,16 @@ VITE_DROP_CONSOLE=true
# 是否sourcemap
VITE_SOURCEMAP=false
# 打包路径
VITE_BASE_PATH='http://static-vue3.huijing.iocoder.cn/'
# staging 由独立 4174 端口提供静态站点。
VITE_BASE_PATH='/'
# 输出路径
VITE_OUT_DIR=dist-stage
# 商城H5会员端域名
VITE_MALL_H5_DOMAIN='http://mall.huijing.iocoder.cn'
# staging 后端未启用租户、验证码与 API 加密,前端必须逐项对齐。
VITE_APP_TENANT_ENABLE=false
VITE_APP_CAPTCHA_ENABLE=false
VITE_APP_API_ENCRYPT_ENABLE=false
# GoView域名
VITE_GOVIEW_URL='http://127.0.0.1:3000'
# 绘境业务页面必须走真实 staging API不启用本地业务 mock。
VITE_APP_WANXIANG_MOCK=false

View File

@ -1,5 +1,6 @@
# game-studio 联调 envvite --mode staging。staging 后端在 mini-desktop。
# 非密钥:仅后端地址 + studio 登录态 token后端可识别的固定测试态故入库供 mini-desktop 克隆联调
# (根 .gitignore 已显式豁免 !game-studio/.env.staging
# staging 只烘焙公开地址与狗粮模式,不烘焙任何账号 token。
# 用户必须走真实用户名密码登录token 仅落浏览器 localStorage
VITE_API_BASE=http://100.64.0.7:48080
VITE_STUDIO_TOKEN=test1
VITE_STUDIO_TOKEN=
VITE_DOGFOOD_MODE=true

File diff suppressed because one or more lines are too long

View File

@ -1,54 +0,0 @@
#!/usr/bin/env bash
# U5+U2 真 UI 走查 runbookmini-desktop 本地执行)。
# chrome 生命周期:起 headless chrome(:9222) → 探活 → 跑 python 走查 → 杀 chrome 净场。
# 自身输出重定向到 /tmp/u5u2_out.txtCDP §2 坑5避免继承 ssh fd 被断连杀;本次一次性同步跑,输出兜底落文件)。
set -u
exec >/tmp/u5u2_out.txt 2>&1
echo "=== RUN_U5U2 START $(date -Is) ==="
SHOT=/tmp/u5u2
mkdir -p "$SHOT"
CHROME=/usr/bin/google-chrome
PROF=/tmp/u5u2_chrome_prof
rm -rf "$PROF"; mkdir -p "$PROF"
# 先清掉可能残留的 9222 chrome
pkill -f "remote-debugging-port=9222" 2>/dev/null || true
sleep 1
# 起 headless chromeCDP §2no-sandbox + no-zygote + remote-allow-origins=*
"$CHROME" \
--headless=new --no-sandbox --no-zygote \
--remote-debugging-port=9222 --remote-allow-origins=* \
--user-data-dir="$PROF" \
--window-size=420,900 \
--autoplay-policy=no-user-gesture-required \
about:blank >/tmp/u5u2_chrome.log 2>&1 &
CHROME_PID=$!
echo "chrome pid=$CHROME_PID"
# 探活:起后必 curl /json/version 验活再用(最多 ~10s
UP=0
for i in $(seq 1 20); do
if curl -s --max-time 2 http://localhost:9222/json/version >/dev/null 2>&1; then
UP=1; echo "chrome :9222 UP after ${i} tries"; break
fi
sleep 0.5
done
if [ "$UP" != "1" ]; then
echo "FATAL: chrome :9222 not up"; cat /tmp/u5u2_chrome.log; kill "$CHROME_PID" 2>/dev/null; exit 1
fi
curl -s --max-time 3 http://localhost:9222/json/version | head -c 200; echo
# 跑走查
echo "=== PYTHON WALK ==="
python3 /tmp/u5u2_walk.py
RC=$?
echo "=== PYTHON RC=$RC ==="
# 净场
kill "$CHROME_PID" 2>/dev/null || true
pkill -f "remote-debugging-port=9222" 2>/dev/null || true
echo "=== screenshots ==="
ls -la "$SHOT"
echo "=== RUN_U5U2 DONE $(date -Is) rc=$RC ==="

View File

@ -1,392 +0,0 @@
#!/usr/bin/env python3
# -*- coding: utf-8 -*-
"""
U5(8 页走查) + U2(审核发布) UI 走查 单文件 CDP 客户端(websocket-client 直驱)
mini-desktop 本地驱动 headless chrome(:9222) localhost 前端(staging-ops §1 认可的 e2e )
U5 studio(:4173) 6 feed流 / 试玩(play) / 创作(create,draft) / 创作者主页(creator) / 素材中心(material) / 玩法中心(template)
U5 admin (:4174) 2 经营看板(operation-dashboard) / 审核队列(review)
U2 admin 审核发布审核队列点通过approve 真实待审游戏( seed 9324 saa-e2e-breakout) 验转发布态
逐页断言(每页落 PAGE_VERDICT)
- 关键元素渲染(DOM 实查 selector)
- 控制台零未捕获异常(Runtime.exceptionThrown / console.error 累计)
- 5xx(Network.responseReceived 累计状态码 >=500)
- 真数据(非空壳列表/卡片/指标计数 > 0)
鉴权studio 受保护页用 localStorage 注入 test1 token(key=wanxiang_token store/user.ts) requiresAuth 守卫(只读走查口径)
tenant-id=1 由前端请求拦截器无条件注入无需手动设admin 走真实登录(芋道源码/admin/admin123)
不改后端/DB(seed 已在脚本外经真实 publish 路径完成) UI 走查取证
"""
import json, base64, time, sys, urllib.request
import websocket # websocket-client
CDP_HTTP = "http://localhost:9222"
STUDIO = "http://localhost:4173"
ADMIN = "http://localhost:4174"
SHOT_DIR = "/tmp/u5u2"
STUDIO_TOKEN = "test1"
# U2 seed: 经真实 publish 路径已置 9324(saa-e2e-breakout) 为 status=1 审核中
U2_TARGET_NAME = "saa-e2e-breakout"
U2_TARGET_ID = "9324"
import os
os.makedirs(SHOT_DIR, exist_ok=True)
def new_target(url="about:blank"):
"""Chrome 111+ 必须用 PUT /json/new 建 target。"""
req = urllib.request.Request(f"{CDP_HTTP}/json/new?{url}", method="PUT")
d = json.load(urllib.request.urlopen(req, timeout=10))
return d["webSocketDebuggerUrl"], d["id"]
class CDP:
def __init__(self, ws_url):
self.ws = websocket.create_connection(ws_url, max_size=None, timeout=45,
suppress_origin=True)
self._id = 0
self.console = [] # 累积 console error / exception
self.http5xx = [] # 累积 5xx 响应
self.http4xx = [] # 累积 4xx 响应(参考,不计入 pass)
def _collect(self, msg):
m = msg.get("method")
if m == "Log.entryAdded":
e = msg["params"]["entry"]
if e.get("level") == "error":
self.console.append({"src": "log", "level": "error", "text": (e.get("text") or "")[:240]})
elif m == "Runtime.consoleAPICalled":
t = msg["params"].get("type")
if t == "error":
args = msg["params"].get("args", [])
txt = " ".join(str(a.get("value", a.get("description", ""))) for a in args)[:240]
self.console.append({"src": "console", "level": "error", "text": txt})
elif m == "Runtime.exceptionThrown":
d = msg["params"].get("exceptionDetails", {})
self.console.append({"src": "exception", "level": "error",
"text": (d.get("text") or json.dumps(d.get("exception", {}), ensure_ascii=False))[:240]})
elif m == "Network.responseReceived":
r = msg["params"].get("response", {})
st = r.get("status", 0)
url = (r.get("url") or "")[:160]
if st >= 500:
self.http5xx.append({"status": st, "url": url})
elif 400 <= st < 500:
self.http4xx.append({"status": st, "url": url})
def send(self, method, params=None, timeout=45):
self._id += 1
mid = self._id
self.ws.send(json.dumps({"id": mid, "method": method, "params": params or {}}))
end = time.time() + timeout
while time.time() < end:
self.ws.settimeout(max(0.1, end - time.time()))
try:
msg = json.loads(self.ws.recv())
except Exception:
continue
self._collect(msg)
if msg.get("id") == mid:
if "error" in msg:
raise RuntimeError(f"{method}: {msg['error']}")
return msg.get("result", {})
raise TimeoutError(method)
def drain(self, secs=1.0):
end = time.time() + secs
while time.time() < end:
self.ws.settimeout(max(0.05, end - time.time()))
try:
msg = json.loads(self.ws.recv())
except Exception:
continue
self._collect(msg)
def evaljs(self, expr, timeout=45):
r = self.send("Runtime.evaluate",
{"expression": expr, "returnByValue": True, "awaitPromise": True}, timeout)
if "exceptionDetails" in r:
return {"__js_error__": json.dumps(r.get("exceptionDetails"), ensure_ascii=False)[:300]}
return r.get("result", {}).get("value")
def nav(self, url, settle=5):
self.send("Page.navigate", {"url": url})
time.sleep(settle)
self.drain(0.8)
def shot(self, name):
r = self.send("Page.captureScreenshot", {"format": "png"})
path = f"{SHOT_DIR}/{name}"
with open(path, "wb") as f:
f.write(base64.b64decode(r["data"]))
return path
def reset_net_log(self):
"""每页走查前清网络/console 累计,使断言只针对当前页。"""
self.console = []
self.http5xx = []
self.http4xx = []
def close(self):
try:
self.ws.close()
except Exception:
pass
# ---------- JS 片段 ----------
# 注入 studio 登录态(test1):必须在 :4173 同 origin 已开 target 内 setItem(localStorage 按 origin 隔离)
def js_inject_studio_token():
return ("(()=>{try{"
f"localStorage.setItem('wanxiang_token','{STUDIO_TOKEN}');"
f"localStorage.setItem('wanxiang_user_id','1');"
"localStorage.setItem('wanxiang_creator_flag','1');"
"localStorage.setItem('wanxiang_nickname','走查创作者');"
"return 'token-set:'+localStorage.getItem('wanxiang_token');"
"}catch(e){return 'err:'+e.message}})()")
# 通用页面信息 dump
JS_DUMP = r"""(()=>{
const btns=[...document.querySelectorAll('button')].map(e=>(e.innerText||'').replace(/\s+/g,'')).filter(Boolean).slice(0,30);
return {href:location.href, title:document.title, buttons:btns,
bodyText:(document.body.innerText||'').replace(/\s+/g,' ').slice(0,400)};
})()"""
# 计数若干 selector 命中数(真数据/渲染断言用);返回 {sel:count}
def js_count(selectors):
arr = json.dumps(selectors)
return (r"""(()=>{const sels=%s;const o={};
for(const s of sels){try{o[s]=document.querySelectorAll(s).length}catch(e){o[s]=-1}}
o.__href=location.href; o.__bodyLen=(document.body.innerText||'').replace(/\s+/g,'').length;
return o;})()""" % arr)
# admin el-table 行 dump(审核队列用)
JS_QUEUE = r"""(()=>{
const rows=[...document.querySelectorAll('.el-table__row')].map(r=>{
const cells=[...r.querySelectorAll('td')].map(td=>(td.innerText||'').trim().replace(/\s+/g,' ').slice(0,40));
const btns=[...r.querySelectorAll('button')].map(b=>(b.innerText||'').replace(/\s+/g,''));
return {cells, btns};
});
const empty=document.querySelector('.el-table__empty-text');
return {href:location.href, rowCount:rows.length, rows:rows.slice(0,8),
emptyText:empty?(empty.innerText||'').trim():'',
bodyText:(document.body.innerText||'').replace(/\s+/g,' ').slice(0,260)};
})()"""
JS_DUMP_LOGIN = r"""(()=>{
const inp=[...document.querySelectorAll('input')].map(e=>({type:e.type,ph:e.placeholder||''}));
const btn=[...document.querySelectorAll('button')].map(e=>(e.innerText||'').replace(/\s+/g,'')).filter(Boolean);
return {href:location.href, inputs:inp, buttons:btn};
})()"""
JS_CLICK_LOGIN = r"""(()=>{
const b=[...document.querySelectorAll('button')].find(e=>(e.innerText||'').replace(/\s+/g,'')==='登录');
if(b){b.click();return 'clicked-login';}
return 'no-login-btn';
})()"""
def js_click_pass(name):
return r"""(()=>{
const rows=[...document.querySelectorAll('.el-table__row')];
for(const r of rows){
if((r.innerText||'').includes('%s')){
const p=[...r.querySelectorAll('button')].find(b=>(b.innerText||'').replace(/\s+/g,'')==='通过');
if(p){p.click();return 'clicked-pass';}
return 'row-matched-no-pass-btn|btns:'+[...r.querySelectorAll('button')].map(b=>(b.innerText||'').replace(/\s+/g,'')).join(',');
}
}
return 'target-not-in-rows|count:'+rows.length;
})()""" % name
JS_CONFIRM = r"""(()=>{
const btns=[...document.querySelectorAll('.el-message-box button,.el-overlay button,.el-dialog button')];
const ok=btns.find(b=>{const t=(b.innerText||'').replace(/\s+/g,'');return t==='确定'||t==='确认';});
if(ok){ok.click();return 'confirmed';}
return 'no-confirm|btns:'+btns.map(b=>(b.innerText||'').replace(/\s+/g,'')).join(',');
})()"""
def out(tag, obj):
print(f"@@{tag}@@ " + json.dumps(obj, ensure_ascii=False), flush=True)
def page_verdict(c, page, counts, render_ok, data_ok, extra=None):
"""汇总单页断言:渲染 / console 零异常 / 零 5xx / 真数据。"""
v = {
"page": page,
"url": counts.get("__href", ""),
"render_ok": render_ok,
"console_errors": c.console[:8],
"console_error_count": len(c.console),
"http5xx": c.http5xx[:6],
"http5xx_count": len(c.http5xx),
"data_ok": data_ok,
"counts": {k: v2 for k, v2 in counts.items() if not k.startswith("__")},
"pass": bool(render_ok and data_ok and len(c.console) == 0 and len(c.http5xx) == 0),
}
if extra:
v["extra"] = extra
out("PAGE_VERDICT", v)
return v
def main():
ws_url, tid = new_target("about:blank")
c = CDP(ws_url)
c.send("Page.enable")
c.send("Runtime.enable")
c.send("Log.enable")
c.send("Network.enable")
verdicts = []
# ======================================================================
# STUDIO 段:先建 :4173 origin 注入 token再逐页走
# ======================================================================
c.nav(f"{STUDIO}/feed", settle=6) # 先到 feed(无需登录)建立 origin
print("INJECT_TOKEN:", c.evaljs(js_inject_studio_token()), flush=True)
# ---- U5-1 feed 流 ----
c.reset_net_log()
c.nav(f"{STUDIO}/feed", settle=7)
c.shot("u5_1_feed.png")
counts = c.evaljs(js_count([".feed", ".feed__zone", ".feed__page", ".game-card,[class*=card]"]))
out("FEED_DUMP", c.evaljs(JS_DUMP))
render = (counts.get(".feed", 0) > 0)
data = (counts.get(".feed__page", 0) > 0) # 至少一张卡片页
verdicts.append(page_verdict(c, "studio/feed", counts, render, data))
# ---- U5-2 试玩 play(已发布游戏 9306/93112) ----
c.reset_net_log()
c.nav(f"{STUDIO}/play/9306/93112", settle=9) # 试玩页含 iframe 宿主,留足加载时间
c.shot("u5_2_play.png")
counts = c.evaljs(js_count([".play,[class*=play]", "iframe", ".loading-bar,[class*=loading]", "[class*=error]"]))
out("PLAY_DUMP", c.evaljs(JS_DUMP))
# 渲染=有 iframe 宿主挂载;真数据=非错误态(无 error 容器主导) + iframe 在
render = (counts.get("iframe", 0) > 0)
data = (counts.get("iframe", 0) > 0)
verdicts.append(page_verdict(c, "studio/play", counts, render, data,
extra={"note": "试玩=GamePlayer iframe 宿主挂载;游戏内真玩另见 game-e2e harness"}))
# ---- U5-3 创作 create(draftrequiresAuth) ----
c.reset_net_log()
c.nav(f"{STUDIO}/create", settle=7)
c.shot("u5_3_create.png")
counts = c.evaljs(js_count([".create-page", ".create-head", "textarea", "button"]))
out("CREATE_DUMP", c.evaljs(JS_DUMP))
href = counts.get("__href", "")
render = (counts.get(".create-page", 0) > 0) and ("/login" not in href)
data = (counts.get("textarea", 0) > 0) # 创作输入框在=页面真渲染(非被守卫弹回登录)
verdicts.append(page_verdict(c, "studio/create", counts, render, data,
extra={"guard_bounced_to_login": "/login" in href}))
# ---- U5-4 创作者主页 creator/1(PUBLIC9 作品) ----
c.reset_net_log()
c.nav(f"{STUDIO}/creator/1", settle=7)
c.shot("u5_4_creator.png")
counts = c.evaljs(js_count([".creator", ".creator__stats", ".creator__stat",
".game-card,[class*=card],[class*=work]", "[class*=empty]"]))
out("CREATOR_DUMP", c.evaljs(JS_DUMP))
render = (counts.get(".creator", 0) > 0)
data = (counts.get(".creator__stat", 0) > 0) # 统计卡(发布数/播放/点赞)渲染=真数据
verdicts.append(page_verdict(c, "studio/creator", counts, render, data))
# ---- U5-5 素材中心 material(requiresAuth) ----
c.reset_net_log()
c.nav(f"{STUDIO}/studio/material", settle=7)
c.shot("u5_5_material.png")
counts = c.evaljs(js_count([".material", ".material__head", ".material__tabs",
".material__upload", "[class*=empty]", "[class*=asset],[class*=grid] img,[class*=item]"]))
out("MATERIAL_DUMP", c.evaljs(JS_DUMP))
href = counts.get("__href", "")
render = (counts.get(".material", 0) > 0) and ("/login" not in href)
# 素材库可能为空(空态=合法非空壳);真渲染=头部+上传入口在
data = (counts.get(".material__head", 0) > 0 and counts.get(".material__upload", 0) > 0)
verdicts.append(page_verdict(c, "studio/material", counts, render, data,
extra={"guard_bounced_to_login": "/login" in href,
"note": "素材库空态合法;断言取头部+上传入口渲染"}))
# ---- U5-6 玩法中心 template(requiresAuth5 品类) ----
c.reset_net_log()
c.nav(f"{STUDIO}/studio/template", settle=7)
c.shot("u5_6_template.png")
counts = c.evaljs(js_count([".tpl", ".tpl__head", ".tpl__list",
".tpl__card,[class*=card],[class*=item]", "[class*=empty]"]))
out("TEMPLATE_DUMP", c.evaljs(JS_DUMP))
href = counts.get("__href", "")
render = (counts.get(".tpl", 0) > 0) and ("/login" not in href)
data = (counts.get(".tpl__list", 0) > 0) # 模板列表容器在(5 品类)
verdicts.append(page_verdict(c, "studio/template", counts, render, data,
extra={"guard_bounced_to_login": "/login" in href}))
# ======================================================================
# ADMIN 段:真实登录 → 经营看板 + 审核队列(U5) → U2 审核通过
# ======================================================================
c.nav(f"{ADMIN}/", settle=8)
c.shot("u5_admin_login.png")
out("ADMIN_LOGIN_PAGE", c.evaljs(JS_DUMP_LOGIN))
print("ADMIN_CLICK_LOGIN:", c.evaljs(JS_CLICK_LOGIN), flush=True)
time.sleep(7)
c.drain(1.0)
print("ADMIN_AFTER_LOGIN_HREF:", c.evaljs("location.href"), flush=True)
# ---- U5-7 经营看板 operation-dashboard(跨模块 DAU/GMV/生成量/发布量) ----
c.reset_net_log()
c.nav(f"{ADMIN}/wanxiang/operation-dashboard", settle=8)
c.shot("u5_7_operation_dashboard.png")
counts = c.evaljs(js_count(["[class*=card]", "[class*=statistic],[class*=metric]",
".el-card", "canvas,[class*=chart],[class*=echarts]", "[class*=empty]"]))
out("OPDASH_DUMP", c.evaljs(JS_DUMP))
href = counts.get("__href", "")
render = ("/login" not in href) and (counts.get("__bodyLen", 0) > 20)
data = (counts.get(".el-card", 0) > 0 or counts.get("[class*=card]", 0) > 0
or counts.get("[class*=statistic],[class*=metric]", 0) > 0)
verdicts.append(page_verdict(c, "admin/operation-dashboard", counts, render, data,
extra={"guard_bounced_to_login": "/login" in href}))
# ---- U5-8 审核队列 review(含 U2 seed 待审游戏) ----
c.reset_net_log()
c.nav(f"{ADMIN}/wanxiang/review", settle=8)
c.shot("u5_8_review.png")
q = c.evaljs(JS_QUEUE)
counts = c.evaljs(js_count([".el-table", ".el-table__row", ".el-table__empty-text"]))
out("REVIEW_QUEUE", q)
href = counts.get("__href", "")
render = (counts.get(".el-table", 0) > 0) and ("/login" not in href)
data = (counts.get(".el-table__row", 0) > 0) # 队列有待审行(seed 9324)
rv = page_verdict(c, "admin/review", counts, render, data,
extra={"queue_rows": q.get("rows", []), "row_count": q.get("rowCount", 0),
"guard_bounced_to_login": "/login" in href})
verdicts.append(rv)
# ======================================================================
# U2 审核→发布:在审核队列点 9324(saa-e2e-breakout)「通过」
# ======================================================================
u2 = {"target_id": U2_TARGET_ID, "target_name": U2_TARGET_NAME}
c.reset_net_log()
c.shot("u2_before.png")
before_q = c.evaljs(JS_QUEUE)
u2["before_queue_rows"] = before_q.get("rowCount", 0)
print("U2_CLICK_PASS:", c.evaljs(js_click_pass(U2_TARGET_NAME)), flush=True)
time.sleep(2)
c.shot("u2_dialog.png")
print("U2_CONFIRM:", c.evaljs(JS_CONFIRM), flush=True)
time.sleep(5) # 等审核 API(同事务 publish) + 队列刷新
c.drain(1.5)
c.shot("u2_after.png")
after_q = c.evaljs(JS_QUEUE)
u2["after_queue_rows"] = after_q.get("rowCount", 0)
u2["queue_decremented"] = (after_q.get("rowCount", 0) < before_q.get("rowCount", 99))
u2["http5xx_during_review"] = c.http5xx[:6]
u2["console_errors_during_review"] = c.console[:6]
out("U2_REVIEW_RESULT", u2)
c.close()
out("WALK_SUMMARY", {"verdicts": verdicts, "u2": u2})
print("=== U5U2_WALK_DONE ===", flush=True)
if __name__ == "__main__":
main()

View File

@ -35,6 +35,9 @@ import { gateStorageKey, gateStorageWrite } from './storageGate';
import { buildIframeSrcdoc, buildDemoPackage, fetchAndVerifyManifest } from './inject';
import { getRuntimePackage } from './runtimeApi';
/** 狗粮阶段不展示任何假广告/假支付 UI请求仍须显式失败回包避免游戏悬挂等待。 */
const dogfoodMode = import.meta.env.VITE_DOGFOOD_MODE === 'true';
// propsspec §3.4
const props = withDefaults(defineProps<GamePlayerProps>(), {
traceId: '',
@ -403,6 +406,17 @@ function handleStorage(payload: unknown, direction: MessageDirection, requestId?
/** 广告请求 → 抛 adRequest + 弹桩层 */
function handleAdRequest(p: AdRequestPayload, requestId?: string): void {
emit('adRequest', { slotId: p.slotId, type: p.adType });
if (dogfoodMode) {
if (requestId) {
bridge?.post(
'ad',
p.adType === 'rewarded' ? { rewarded: false } : { shown: false },
traceId.value,
requestId,
);
}
return;
}
overlay.kind = 'ad';
overlay.adType = p.adType;
overlay.slotId = p.slotId;
@ -413,6 +427,10 @@ function handleAdRequest(p: AdRequestPayload, requestId?: string): void {
/** 支付请求 → 抛 payRequest + 弹桩层 */
function handlePayRequest(p: PayRequestPayload, requestId?: string): void {
emit('payRequest', { orderId: p.orderId, amount: p.amount });
if (dogfoodMode) {
if (requestId) bridge?.post('pay', { paid: false }, traceId.value, requestId);
return;
}
overlay.kind = 'pay';
overlay.orderId = p.orderId;
overlay.amount = p.amount;
@ -558,7 +576,7 @@ function onIframeLoad(): void {
</div>
<!-- ad/pay 桩弹层 -->
<div v-if="overlay.visible" class="gp-overlay" @click.self="cancelOverlay">
<div v-if="!dogfoodMode && overlay.visible" class="gp-overlay" @click.self="cancelOverlay">
<div class="gp-sheet">
<!-- 广告桩 -->
<template v-if="overlay.kind === 'ad'">

View File

@ -5,9 +5,9 @@
* spec §9.2
* - setLogin token/userId/nickname/creatorFlag localStorage
* - logout localStorage
* - token localStorage token > VITE_STUDIO_TOKEN(envstaging =test1) > 'mock-studio-token'
* - token localStorage token > VITE_STUDIO_TOKEN mock 退 mock token token
* - isRealLogin()localStorage token/isLogin() token true
* staging /agent env token API localStorage isRealLogin()=false UI
* Authorization
*/
import { defineStore } from 'pinia'
import { ref } from 'vue'
@ -65,13 +65,14 @@ function ensureAnonId(): string {
/**
* token localStorage token > env VITE_STUDIO_TOKEN > mock
* token env token staging /agent=test1mock
* token env token mock
*/
function resolveInitToken(): string {
const real = lsGet(LS_TOKEN)
if (real) return real
const envToken = import.meta.env.VITE_STUDIO_TOKEN as string | undefined
if (envToken) return envToken
if (import.meta.env.VITE_DOGFOOD_MODE === 'true') return ''
return 'mock-studio-token'
}
@ -81,7 +82,7 @@ function resolveInitUserId(): string {
}
export const useUserStore = defineStore('user', () => {
/** 登录 TokenlocalStorage 真 token > env VITE_STUDIO_TOKEN(=test1) > 'mock-studio-token' 占位§9.2 */
/** 登录 Token真实持久化 > 可选自动化 token狗粮未登录态为空本地 mock 才使用占位 token。 */
const token = ref<string>(resolveInitToken())
/** 用户 ID真实登录持久化优先否则 mock 固定 '1001' */
const userId = ref<string>(resolveInitUserId())
@ -105,7 +106,7 @@ export const useUserStore = defineStore('user', () => {
/**
* localStorage token §9.2 /
* staging /agent env token localStorage false UI
* localStorage true token
*/
function isRealLogin(): boolean {
return !!lsGet(LS_TOKEN)

View File

@ -50,6 +50,9 @@ const { t } = useI18n({
messages: { 'zh-CN': hubZh, 'en-US': hubEn },
})
/** 狗粮阶段不展示尚未接真实联盟账单的广告收益。 */
const dogfoodMode = import.meta.env.VITE_DOGFOOD_MODE === 'true'
/** 我的项目全量(聚合累计播放/点赞用;一次性拉取,分页上限内足够看板聚合) */
const projects = ref<Project[]>([])
/** 创作者等级(已发布作品数权威源;失败保持 null回退项目内 status=4 计数) */
@ -221,17 +224,19 @@ async function fetchAll() {
summaryLoaded.value = false
}),
// 广trade /my/income-summary
tradeApi
.getMyIncomeSummary()
.then((inc) => {
income.value = inc
incomeLoaded.value = true
})
.catch(() => {
// 广--
income.value = null
incomeLoaded.value = false
}),
dogfoodMode
? Promise.resolve()
: tradeApi
.getMyIncomeSummary()
.then((inc) => {
income.value = inc
incomeLoaded.value = true
})
.catch(() => {
// 广--
income.value = null
incomeLoaded.value = false
}),
// /telemetry /my/game-stat/trend?days=7
telemetryApi
.getMyTrend(7)
@ -319,7 +324,7 @@ onMounted(() => {
<span class="pending-row__value">{{ completionRateText }}</span>
</div>
<!-- 广告收益trade /my/income-summary totalNet已结算实得净额 -->
<div class="pending-row">
<div v-if="!dogfoodMode" class="pending-row">
<span class="pending-row__label">{{ t('dashboard.adIncome') }}</span>
<span class="pending-row__value">{{ incomeYuanText }}</span>
</div>

View File

@ -46,8 +46,11 @@ const route = useRoute()
const router = useRouter()
const userStore = useUserStore()
/** 内部狗粮只允许预置账号密码登录,不暴露短信、邀请注册或密码注册入口。 */
const dogfoodMode = import.meta.env.VITE_DOGFOOD_MODE === 'true'
/** 当前 Tab'sms'=验证码登录(默认主路径) / 'invite'=邀请码注册 / 'password'=用户名密码 */
const activeTab = ref<'sms' | 'invite' | 'password'>('sms')
const activeTab = ref<'sms' | 'invite' | 'password'>(dogfoodMode ? 'password' : 'sms')
/** 手机号sms/invite 两 Tab 共用password Tab 不用) */
const mobile = ref<string>('')
@ -217,7 +220,7 @@ async function onSubmit(): Promise<void> {
// username password trim/
const uname = username.value.trim()
resp =
passwordMode.value === 'register'
!dogfoodMode && passwordMode.value === 'register'
? await passwordRegister({ username: uname, password: password.value, anonId })
: await passwordLogin({ username: uname, password: password.value, anonId })
}
@ -239,7 +242,7 @@ async function onSubmit(): Promise<void> {
</header>
<!-- Tab 切换验证码登录 / 邀请码注册 / 用户名密码 -->
<div class="login-tabs">
<div v-if="!dogfoodMode" class="login-tabs">
<button
class="login-tab"
:class="{ 'is-active': activeTab === 'sms' }"
@ -316,7 +319,7 @@ async function onSubmit(): Promise<void> {
<!-- 用户名密码子模式登录/注册切换 + 用户名 + 密码内测种子用户WU1 §3.5 -->
<template v-else>
<!-- 登录/注册子模式切换注册走 password-register 建号即登录登录走 password-login -->
<div class="pwd-mode">
<div v-if="!dogfoodMode" class="pwd-mode">
<button
class="pwd-mode-btn"
:class="{ 'is-active': passwordMode === 'login' }"

View File

@ -7,8 +7,10 @@
interface ImportMetaEnv {
/** 后端 API base联调指向 staging如 http://100.64.0.7:48080空串=同源/mock */
readonly VITE_API_BASE?: string
/** studio 登录态 tokenstaging=test1后端可识别的固定测试态缺省回退 mock 占位 */
/** studio 登录态 token只供本地 mock/自动化staging 不得烘焙真实 token */
readonly VITE_STUDIO_TOKEN?: string
/** 内部狗粮模式:只开放密码登录,并隐藏未接真实渠道的广告、支付与收益入口 */
readonly VITE_DOGFOOD_MODE?: string
}
interface ImportMeta {