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.stage
!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(
async (config: InternalAxiosRequestConfig) => {
// 多租户头staging 单租户固定 tenant-id=1无条件注入与登录态解耦
// 缺它 yudao 租户拦截器会按默认租户过滤,导致 feed/stat 等查空§2.3 / 路线 plan §2.B
config.headers.set('tenant-id', '1')
try {
// 延迟取 userStore确保 Pinia 已挂载
const { useUserStore } = await import('../store/user')

View File

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

View File

@ -151,6 +151,26 @@ async function sha256Hex(text: string): Promise<string> {
.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
*
@ -170,8 +190,10 @@ export async function fetchAndVerifyManifest(
manifestUrl: string,
expectedChecksum?: string,
): Promise<GamePackage> {
// 1) 拉取原始文本(保留原始字节序,确保 checksum 可复现)
const resp = await fetch(manifestUrl, { method: 'GET', credentials: 'omit' });
// 1) 拉取原始文本(保留原始字节序,确保 checksum 可复现)。
// §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) {
throw new Error(`取 manifest 失败HTTP ${resp.status}`);
}

View File

@ -1,47 +1,26 @@
/**
* A2 宿 |
* A2 宿 |
* ----------------------------------------------------------------------------
* #1 runtime.yaml
* GET /app-api/runtime/package/{versionId}?scene=play|preview
* CommonResult<RuntimePackageRespVO>{ code, data, msg }code=0
*
*
* - A1 src/api/runtime.ts axios import A1
* axios GamePlayer
* A1 runtime.getPackage GamePlayer fetchPackage prop
* - /app-api yudao runtime.yaml mock/
* -
* src/api/runtime.ts request.ts VITE_API_BASE + Authorization + tenant-id
* yudao CommonResult Codex C3B1 axios base/token/tenant
* origin staging (401)A1 src/api/runtime.ts
* GamePlayer fetchPackage prop /
*/
import axios from 'axios';
import { getPackage } from '../api/runtime';
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 scene preview= / play=
* @throws code0 GamePlayer emits error
* @throws code0 1-102-001-001 GamePlayer /
*/
export async function getRuntimePackage(
versionId: number,
scene: 'play' | 'preview',
): Promise<RuntimePackageRespVO> {
// 严格按契约路径与查询参数拼装
const resp = await axios.get<CommonResult<RuntimePackageRespVO>>(
`/app-api/runtime/package/${versionId}`,
{ params: { scene }, timeout: 8000 },
);
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;
// src/api/runtime.ts.getPackage 返回 RuntimePackage与 RuntimePackageRespVO 同形,均镜像 runtime.yaml RuntimePackageRespVO
// request.ts 已注入 base/token/tenant 并解包 CommonResultcode≠0 时已 reject。结构透传。
const pkg = await getPackage(versionId, scene);
return pkg as unknown as RuntimePackageRespVO;
}

View File

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

View File

@ -20,9 +20,11 @@ const SCHEMA_VERSION = 'v1'
const FLUSH_SIZE = 10
/** 定时 flush 间隔5s */
const FLUSH_INTERVAL = 5_000
/** sendBeacon 上报地址(与 telemetry.yaml /events/batch 一致;同源命中 mock 中间件) */
const BATCH_URL = '/app-api/telemetry/events/batch'
const BEACON_URL = '/app-api/telemetry/perf/beacon'
/** API base联调取 VITE_API_BASE 指向 stagingmock 模式为空串=同源命中中间件) */
const API_BASE: string = (import.meta.env.VITE_API_BASE as string | undefined) ?? ''
/** 卸载/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
@ -77,6 +79,55 @@ function toStr(v: string | number | undefined): string | undefined {
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/
* @param event
@ -102,7 +153,9 @@ function buildEnvelope(
event,
schemaVersion: SCHEMA_VERSION,
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),
user: { userId, anonId },
context: {
@ -134,19 +187,8 @@ function flush(): void {
function flushOnUnload(): void {
if (buffer.length === 0) return
const batch = buffer.splice(0, buffer.length)
const body = JSON.stringify({ batch })
try {
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(() => {})
// fetch(keepalive) 带鉴权头上报(替代 sendBeacon后者不能带 Authorization/tenant-id联调会被 staging 拒)
beaconPost(BATCH_URL, JSON.stringify({ batch }))
}
/** 确保定时器与卸载监听已启动(懒启动,首次 track 时挂载) */
@ -196,20 +238,8 @@ export function perfBeacon(
ctx?: TrackContext,
): void {
const envelope = buildEnvelope(event, props, ctx)
const body = JSON.stringify(envelope)
try {
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(() => {})
})
// fetch(keepalive) 带鉴权头单条上报(/perf/beacon 契约:永远受理成功;替代 sendBeacon 以带头打 staging
beaconPost(BEACON_URL, JSON.stringify(envelope))
}
/**

View File

@ -20,7 +20,7 @@ import { useRouter } from 'vue-router'
import GamePlayer from '../../host/GamePlayer.vue'
import { LoadingBar, EmptyState } from '../../components'
import { useSessionStore } from '../../store/session'
import { track, perfBeacon } from '../../telemetry'
import { track, perfBeacon, type TelemetryEventName } from '../../telemetry'
/** 路由参数stringversionId 可缺省,缺省时无法即玩,给错误兜底) */
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' ? '游戏加载超时' : '游戏加载失败'
}
/** 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 */
function onTelemetry(data: { event: string; props?: Record<string, unknown> }): void {
// GamePlayer
// rejected game_impression
// demo telemetry便
track('game_impression', { forwarded: data.event, ...(data.props ?? {}) }, {
gameId: numGameId.value,
versionId: numVersionId.value,
traceId: traceId.value,
})
// Codex H8 game_impression like/share/play_end
// A/B trackgame_impression rejected
const ctx = { gameId: numGameId.value, versionId: numVersionId.value, traceId: traceId.value }
if (FORWARD_EVENTS.has(data.event)) {
track(data.event as TelemetryEventName, data.props ?? {}, ctx)
} else {
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 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'
// 自写 mock 调度器DOM-freedev/preview 两端复用)。严格按 contracts/api-schemas 返回数据。
import { dispatch, MOCK_PREFIXES } from './src/mock/index.ts'
@ -89,12 +89,22 @@ function mockPlugin(): PluginOption {
}
// https://vite.dev/config/
export default defineConfig({
plugins: [vue(), mockPlugin()],
resolve: {
alias: {
// '@' → src供 B 工位用 @/ 绝对导入A1 自身用相对导入,避免依赖 tsconfig paths
'@': fileURLToPath(new URL('./src', import.meta.url)),
export default defineConfig(({ mode }) => {
// 读 env第三参 '' = 不限前缀,确保拿到 VITE_API_BASE
// VITE_API_BASE 非空 = 联调真连后端(如 staging此时关本地 mock空 = 同源 mock§2.3)。
const env = loadEnv(mode, process.cwd(), '')
const apiBase = env.VITE_API_BASE ?? ''
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)),
},
},
},
}
})