feat(B1): 黄金闭环前端联调(step③) — 真连 staging + 有效遥测信封

step③ game-studio 联调(编译验证待 mini-desktop):
- request.ts 注入 tenant-id:1;user.ts token 取 VITE_STUDIO_TOKEN(=test1)
- vite.config.ts: VITE_API_BASE 非空则关 mock + 启动打印 baseURL/mock 状态
- runtimeApi.ts: 取包改委托 src/api/runtime.ts(带 base+token+tenant,修 Codex C3 裸 axios 同源)
- inject.ts: manifestUrl resolve 到 VITE_API_BASE + 带鉴权头(修 Codex C4)
- telemetry: 信封补 eventId(randomUUID)+ traceId 兜底非空;sendBeacon→fetch keepalive 带头(sendBeacon 不能带 Authorization/tenant)
- Play.vue onTelemetry 白名单原样转发 like/share/play_end(修 Codex H8 吞事件)
- 新增 game-studio/.env.staging + vite-env.d.ts;.gitignore 豁免 .env.staging(防 gitignore-swallows-frontend-source 同类坑)
- 注:Feed.vue 互动经 buildEnvelope traceId 兜底覆盖,免改

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
zizi 2026-06-09 14:40:53 +00:00
parent 1740d8c226
commit a7076871e4
11 changed files with 162 additions and 85 deletions

5
.gitignore vendored
View File

@ -35,3 +35,8 @@ coverage/
!game-admin/.env.prod !game-admin/.env.prod
!game-admin/.env.stage !game-admin/.env.stage
!game-admin/.env.test !game-admin/.env.test
# ── 例外 3game-studio 联调 envstaging 后端地址 + studio token非密钥须入库供 mini-desktop 克隆联调)──
# 同 gitignore-swallows-frontend-source 记忆:根 .env.* 笼统忽略会吞掉 game-studio/.env.staging
# 致 mini-desktop 匿名克隆后拿不到联调配置、vite --mode staging 起不来。故显式豁免。
!game-studio/.env.staging

5
game-studio/.env.staging Normal file
View File

@ -0,0 +1,5 @@
# game-studio 联调 envvite --mode staging。staging 后端在 mini-desktop。
# 非密钥:仅后端地址 + studio 登录态 token后端可识别的固定测试态故入库供 mini-desktop 克隆联调
# (根 .gitignore 已显式豁免 !game-studio/.env.staging
VITE_API_BASE=http://100.64.0.7:48080
VITE_STUDIO_TOKEN=test1

View File

@ -48,6 +48,9 @@ const service: AxiosInstance = axios.create({
*/ */
service.interceptors.request.use( service.interceptors.request.use(
async (config: InternalAxiosRequestConfig) => { async (config: InternalAxiosRequestConfig) => {
// 多租户头staging 单租户固定 tenant-id=1无条件注入与登录态解耦
// 缺它 yudao 租户拦截器会按默认租户过滤,导致 feed/stat 等查空§2.3 / 路线 plan §2.B
config.headers.set('tenant-id', '1')
try { try {
// 延迟取 userStore确保 Pinia 已挂载 // 延迟取 userStore确保 Pinia 已挂载
const { useUserStore } = await import('../store/user') const { useUserStore } = await import('../store/user')

View File

@ -362,6 +362,8 @@ export interface TelemetryEnvelope {
ts: number ts: number
/** 全链路 traceId */ /** 全链路 traceId */
traceId: string traceId: string
/** 事件实例 UUID必填幂等真身前端 crypto.randomUUID() 生成,落 game_telemetry_event.event_iduk_event_id重放同 eventId 不重复计数§3.5 C5 */
eventId: string
/** 会话 IDruntime sessionId 转 string */ /** 会话 IDruntime sessionId 转 string */
sessionId?: string sessionId?: string
/** 用户标识:匿名用 anonId登录带 userId */ /** 用户标识:匿名用 anonId登录带 userId */

View File

@ -151,6 +151,26 @@ async function sha256Hex(text: string): Promise<string> {
.join(''); .join('');
} }
/** 相对 API 路径 resolve 到 VITE_API_BASE联调指向 staging绝对 URL 原样mock 模式空 base=同源。§3.4 C4 */
function resolveApiUrl(url: string): string {
if (/^https?:\/\//i.test(url)) return url;
const base = (import.meta.env.VITE_API_BASE as string | undefined) ?? '';
return base + url;
}
/** manifest fetch 鉴权头tenant-id + Authorizationstaging app-api 取包需登录态/租户,缺则 401/查空。动态取 userStore 避免顶层耦合。 */
async function buildManifestHeaders(): Promise<Record<string, string>> {
const headers: Record<string, string> = { 'tenant-id': '1' };
try {
const { useUserStore } = await import('../store/user');
const token = useUserStore().token;
if (token) headers['Authorization'] = `Bearer ${token}`;
} catch {
/* Pinia 未就绪时仅缺鉴权头,不阻断 */
}
return headers;
}
/** /**
* GAP-2 manifestUrl manifest.json GamePackage #4 * GAP-2 manifestUrl manifest.json GamePackage #4
* *
@ -170,8 +190,10 @@ export async function fetchAndVerifyManifest(
manifestUrl: string, manifestUrl: string,
expectedChecksum?: string, expectedChecksum?: string,
): Promise<GamePackage> { ): Promise<GamePackage> {
// 1) 拉取原始文本(保留原始字节序,确保 checksum 可复现) // 1) 拉取原始文本(保留原始字节序,确保 checksum 可复现)。
const resp = await fetch(manifestUrl, { method: 'GET', credentials: 'omit' }); // §3.4 C4manifest 端点为相对路径 /app-api/runtime/package/{versionId}/manifest
// 联调须 resolve 到 VITE_API_BASE 并带鉴权头(否则打到前端 origin 或被 staging 401
const resp = await fetch(resolveApiUrl(manifestUrl), { method: 'GET', headers: await buildManifestHeaders(), credentials: 'omit' });
if (!resp.ok) { if (!resp.ok) {
throw new Error(`取 manifest 失败HTTP ${resp.status}`); throw new Error(`取 manifest 失败HTTP ${resp.status}`);
} }

View File

@ -1,47 +1,26 @@
/** /**
* A2 宿 | * A2 宿 |
* ---------------------------------------------------------------------------- * ----------------------------------------------------------------------------
* #1 runtime.yaml * src/api/runtime.ts request.ts VITE_API_BASE + Authorization + tenant-id
* GET /app-api/runtime/package/{versionId}?scene=play|preview * yudao CommonResult Codex C3B1 axios base/token/tenant
* CommonResult<RuntimePackageRespVO>{ code, data, msg }code=0 * origin staging (401)A1 src/api/runtime.ts
* * GamePlayer fetchPackage prop /
*
* - A1 src/api/runtime.ts axios import A1
* axios GamePlayer
* A1 runtime.getPackage GamePlayer fetchPackage prop
* - /app-api yudao runtime.yaml mock/
* -
*/ */
import { getPackage } from '../api/runtime';
import axios from 'axios';
import type { RuntimePackageRespVO } from './contract'; import type { RuntimePackageRespVO } from './contract';
/** Yudao CommonResult 信封runtime.yaml CommonResultRuntimePackage */
interface CommonResult<T> {
code: number;
data: T;
msg: string;
}
/** /**
* * API
* @param versionId IDint64 path * @param versionId IDint64 path
* @param scene preview= / play= * @param scene preview= / play=
* @throws code0 GamePlayer emits error * @throws code0 1-102-001-001 GamePlayer /
*/ */
export async function getRuntimePackage( export async function getRuntimePackage(
versionId: number, versionId: number,
scene: 'play' | 'preview', scene: 'play' | 'preview',
): Promise<RuntimePackageRespVO> { ): Promise<RuntimePackageRespVO> {
// 严格按契约路径与查询参数拼装 // src/api/runtime.ts.getPackage 返回 RuntimePackage与 RuntimePackageRespVO 同形,均镜像 runtime.yaml RuntimePackageRespVO
const resp = await axios.get<CommonResult<RuntimePackageRespVO>>( // request.ts 已注入 base/token/tenant 并解包 CommonResultcode≠0 时已 reject。结构透传。
`/app-api/runtime/package/${versionId}`, const pkg = await getPackage(versionId, scene);
{ params: { scene }, timeout: 8000 }, return pkg as unknown as RuntimePackageRespVO;
);
const body = resp.data;
// 统一信封校验code=0 才成功;否则抛出错误码(如 1-102-001-001 无就绪运行包)
if (!body || body.code !== 0 || !body.data) {
throw new Error(`runtime.getPackage 失败code=${body?.code} msg=${body?.msg ?? ''}`);
}
return body.data;
} }

View File

@ -24,8 +24,8 @@ function ensureAnonId(): string {
} }
export const useUserStore = defineStore('user', () => { export const useUserStore = defineStore('user', () => {
/** 登录 TokenMVP 固定 mock空串表示匿名 */ /** 登录 Tokenstaging 联调取 env VITE_STUDIO_TOKEN=test1后端可识别的登录态缺省回退 mock 占位§2.4)。 */
const token = ref<string>('mock-studio-token') const token = ref<string>((import.meta.env.VITE_STUDIO_TOKEN as string | undefined) ?? 'mock-studio-token')
/** 用户 IDmock 固定) */ /** 用户 IDmock 固定) */
const userId = ref<string>('1001') const userId = ref<string>('1001')
/** 昵称mock 固定) */ /** 昵称mock 固定) */

View File

@ -20,9 +20,11 @@ const SCHEMA_VERSION = 'v1'
const FLUSH_SIZE = 10 const FLUSH_SIZE = 10
/** 定时 flush 间隔5s */ /** 定时 flush 间隔5s */
const FLUSH_INTERVAL = 5_000 const FLUSH_INTERVAL = 5_000
/** sendBeacon 上报地址(与 telemetry.yaml /events/batch 一致;同源命中 mock 中间件) */ /** API base联调取 VITE_API_BASE 指向 stagingmock 模式为空串=同源命中中间件) */
const BATCH_URL = '/app-api/telemetry/events/batch' const API_BASE: string = (import.meta.env.VITE_API_BASE as string | undefined) ?? ''
const BEACON_URL = '/app-api/telemetry/perf/beacon' /** 卸载/beacon 上报地址(拼 API_BASE联调命中 stagingmock 同源命中中间件) */
const BATCH_URL = API_BASE + '/app-api/telemetry/events/batch'
const BEACON_URL = API_BASE + '/app-api/telemetry/perf/beacon'
/** /**
* v1 events.schema.json eventRegistry * v1 events.schema.json eventRegistry
@ -77,6 +79,55 @@ function toStr(v: string | number | undefined): string | undefined {
return v == null ? undefined : String(v) return v == null ? undefined : String(v)
} }
/** 生成事件实例 UUID幂等真身 §3.5 C5优先 crypto.randomUUID老环境退化随机串 */
function genEventId(): string {
try {
if (typeof crypto !== 'undefined' && crypto.randomUUID) return crypto.randomUUID()
} catch {
/* 退化 */
}
return 'ev_' + Date.now().toString(36) + Math.random().toString(36).slice(2, 12)
}
/**
* traceId traceId §3.5 A/B traceId
* startTrace /
*/
let sessionTraceId: string | null = null
export function startTrace(traceId?: string): string {
sessionTraceId = traceId && traceId.trim() ? traceId : 'tr_' + genEventId()
return sessionTraceId
}
function currentTraceId(): string {
if (!sessionTraceId) sessionTraceId = 'tr_' + genEventId()
return sessionTraceId
}
/** 取登录态 token卸载/beacon 上下文同步读 userStore失败返回空 */
function readToken(): string {
try {
return useUserStore().token || ''
} catch {
return ''
}
}
/**
* fetch(keepalive)
* sendBeacon Authorization/tenant-id staging /
* fetch keepalive §2.7 / 线 plan §2.B
*/
function beaconPost(url: string, body: string): void {
const headers: Record<string, string> = { 'Content-Type': 'application/json', 'tenant-id': '1' }
const token = readToken()
if (token) headers['Authorization'] = `Bearer ${token}`
try {
void fetch(url, { method: 'POST', headers, body, keepalive: true, credentials: 'omit' }).catch(() => {})
} catch {
/* fire-and-forget卸载兜底失败不影响业务 */
}
}
/** /**
* user/context/ * user/context/
* @param event * @param event
@ -102,7 +153,9 @@ function buildEnvelope(
event, event,
schemaVersion: SCHEMA_VERSION, schemaVersion: SCHEMA_VERSION,
ts: Date.now(), ts: Date.now(),
traceId: ctx?.traceId ?? '', // eventId每事件实例唯一 UUID幂等真身 §3.5 C5traceId有则用、无则回退会话级 traceId永不为空
eventId: genEventId(),
traceId: ctx?.traceId && ctx.traceId.trim() ? ctx.traceId : currentTraceId(),
sessionId: toStr(ctx?.sessionId), sessionId: toStr(ctx?.sessionId),
user: { userId, anonId }, user: { userId, anonId },
context: { context: {
@ -134,19 +187,8 @@ function flush(): void {
function flushOnUnload(): void { function flushOnUnload(): void {
if (buffer.length === 0) return if (buffer.length === 0) return
const batch = buffer.splice(0, buffer.length) const batch = buffer.splice(0, buffer.length)
const body = JSON.stringify({ batch }) // fetch(keepalive) 带鉴权头上报(替代 sendBeacon后者不能带 Authorization/tenant-id联调会被 staging 拒)
try { beaconPost(BATCH_URL, JSON.stringify({ batch }))
if (navigator.sendBeacon) {
// sendBeacon 用 application/json便于服务端按 JSON 解析
const blob = new Blob([body], { type: 'application/json' })
const ok = navigator.sendBeacon(BATCH_URL, blob)
if (ok) return
}
} catch {
// 忽略 sendBeacon 异常,走下面 axios 兜底
}
// 退化:重新入队由常规 flush 处理(尽力而为)
void apiEventsBatch(batch).catch(() => {})
} }
/** 确保定时器与卸载监听已启动(懒启动,首次 track 时挂载) */ /** 确保定时器与卸载监听已启动(懒启动,首次 track 时挂载) */
@ -196,20 +238,8 @@ export function perfBeacon(
ctx?: TrackContext, ctx?: TrackContext,
): void { ): void {
const envelope = buildEnvelope(event, props, ctx) const envelope = buildEnvelope(event, props, ctx)
const body = JSON.stringify(envelope) // fetch(keepalive) 带鉴权头单条上报(/perf/beacon 契约:永远受理成功;替代 sendBeacon 以带头打 staging
try { beaconPost(BEACON_URL, JSON.stringify(envelope))
if (navigator.sendBeacon) {
const blob = new Blob([body], { type: 'application/json' })
// /perf/beacon 契约:单条信封,永远受理成功
if (navigator.sendBeacon(BEACON_URL, blob)) return
}
} catch {
// 忽略,走 axios 兜底
}
// 退化:非卸载场景用常规 axios 通道
void import('../api/telemetry').then(({ perfBeacon: apiPerf }) => {
void apiPerf(envelope).catch(() => {})
})
} }
/** /**

View File

@ -20,7 +20,7 @@ import { useRouter } from 'vue-router'
import GamePlayer from '../../host/GamePlayer.vue' import GamePlayer from '../../host/GamePlayer.vue'
import { LoadingBar, EmptyState } from '../../components' import { LoadingBar, EmptyState } from '../../components'
import { useSessionStore } from '../../store/session' import { useSessionStore } from '../../store/session'
import { track, perfBeacon } from '../../telemetry' import { track, perfBeacon, type TelemetryEventName } from '../../telemetry'
/** 路由参数stringversionId 可缺省,缺省时无法即玩,给错误兜底) */ /** 路由参数stringversionId 可缺省,缺省时无法即玩,给错误兜底) */
const props = defineProps<{ gameId: string; versionId?: string }>() const props = defineProps<{ gameId: string; versionId?: string }>()
@ -109,16 +109,21 @@ function onError(err: { message: string; stack?: string }): void {
errorMsg.value = err.message === 'load_timeout' ? '游戏加载超时' : '游戏加载失败' errorMsg.value = err.message === 'load_timeout' ? '游戏加载超时' : '游戏加载失败'
} }
/** runtime 转发事件白名单(契约登记的转化事件原样透传;其余 game_impression 兜底)。修复 Codex H8。 */
const FORWARD_EVENTS = new Set<string>([
'game_play_start', 'game_play_end', 'like', 'favorite', 'share', 'report', 'game_load_failed', 'game_impression',
])
/** 遥测转发GamePlayer 内游戏发的 telemetry → 走 track 落地(契约 #5 */ /** 遥测转发GamePlayer 内游戏发的 telemetry → 走 track 落地(契约 #5 */
function onTelemetry(data: { event: string; props?: Record<string, unknown> }): void { function onTelemetry(data: { event: string; props?: Record<string, unknown> }): void {
// GamePlayer // Codex H8 game_impression like/share/play_end
// rejected game_impression // A/B trackgame_impression rejected
// demo telemetry便 const ctx = { gameId: numGameId.value, versionId: numVersionId.value, traceId: traceId.value }
track('game_impression', { forwarded: data.event, ...(data.props ?? {}) }, { if (FORWARD_EVENTS.has(data.event)) {
gameId: numGameId.value, track(data.event as TelemetryEventName, data.props ?? {}, ctx)
versionId: numVersionId.value, } else {
traceId: traceId.value, track('game_impression', { forwarded: data.event, ...(data.props ?? {}) }, ctx)
}) }
} }
/* ============================================================================ /* ============================================================================

16
game-studio/src/vite-env.d.ts vendored Normal file
View File

@ -0,0 +1,16 @@
/// <reference types="vite/client" />
/**
* Vite env vite/client ImportMetaEnv
* vite/client ImportMetaEnv import.meta.env.VITE_* 访 vue-tsc
*/
interface ImportMetaEnv {
/** 后端 API base联调指向 staging如 http://100.64.0.7:48080空串=同源/mock */
readonly VITE_API_BASE?: string
/** studio 登录态 tokenstaging=test1后端可识别的固定测试态缺省回退 mock 占位) */
readonly VITE_STUDIO_TOKEN?: string
}
interface ImportMeta {
readonly env: ImportMetaEnv
}

View File

@ -1,6 +1,6 @@
import { fileURLToPath, URL } from 'node:url' import { fileURLToPath, URL } from 'node:url'
import type { IncomingMessage } from 'node:http' import type { IncomingMessage } from 'node:http'
import { defineConfig, type Connect, type PluginOption } from 'vite' import { defineConfig, loadEnv, type Connect, type PluginOption } from 'vite'
import vue from '@vitejs/plugin-vue' import vue from '@vitejs/plugin-vue'
// 自写 mock 调度器DOM-freedev/preview 两端复用)。严格按 contracts/api-schemas 返回数据。 // 自写 mock 调度器DOM-freedev/preview 两端复用)。严格按 contracts/api-schemas 返回数据。
import { dispatch, MOCK_PREFIXES } from './src/mock/index.ts' import { dispatch, MOCK_PREFIXES } from './src/mock/index.ts'
@ -89,12 +89,22 @@ function mockPlugin(): PluginOption {
} }
// https://vite.dev/config/ // https://vite.dev/config/
export default defineConfig({ export default defineConfig(({ mode }) => {
plugins: [vue(), mockPlugin()], // 读 env第三参 '' = 不限前缀,确保拿到 VITE_API_BASE
resolve: { // VITE_API_BASE 非空 = 联调真连后端(如 staging此时关本地 mock空 = 同源 mock§2.3)。
alias: { const env = loadEnv(mode, process.cwd(), '')
// '@' → src供 B 工位用 @/ 绝对导入A1 自身用相对导入,避免依赖 tsconfig paths const apiBase = env.VITE_API_BASE ?? ''
'@': fileURLToPath(new URL('./src', import.meta.url)), const mockEnabled = !apiBase
// 启动打印:一眼确认"真连后端"还是"mock"
console.log(`[wanxiang] mode=${mode} VITE_API_BASE=${apiBase || '(空=同源/mock)'} mock=${mockEnabled ? 'ON' : 'OFF(真连后端)'}`)
return {
// mockEnabled=false 时不注册 mock 插件,请求直达 VITE_API_BASE
plugins: [vue(), ...(mockEnabled ? [mockPlugin()] : [])],
resolve: {
alias: {
// '@' → src供 B 工位用 @/ 绝对导入A1 自身用相对导入,避免依赖 tsconfig paths
'@': fileURLToPath(new URL('./src', import.meta.url)),
},
}, },
}, }
}) })