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:
parent
1740d8c226
commit
a7076871e4
5
.gitignore
vendored
5
.gitignore
vendored
@ -35,3 +35,8 @@ coverage/
|
||||
!game-admin/.env.prod
|
||||
!game-admin/.env.stage
|
||||
!game-admin/.env.test
|
||||
|
||||
# ── 例外 3:game-studio 联调 env(staging 后端地址 + 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
5
game-studio/.env.staging
Normal file
@ -0,0 +1,5 @@
|
||||
# game-studio 联调 env(vite --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
|
||||
@ -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')
|
||||
|
||||
@ -362,6 +362,8 @@ export interface TelemetryEnvelope {
|
||||
ts: number
|
||||
/** 全链路 traceId */
|
||||
traceId: string
|
||||
/** 事件实例 UUID(必填,幂等真身):前端 crypto.randomUUID() 生成,落 game_telemetry_event.event_id(uk_event_id),重放同 eventId 不重复计数(§3.5 C5) */
|
||||
eventId: string
|
||||
/** 会话 ID(runtime sessionId 转 string) */
|
||||
sessionId?: string
|
||||
/** 用户标识:匿名用 anonId,登录带 userId */
|
||||
|
||||
@ -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 + Authorization):staging 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 C4:manifest 端点为相对路径 /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}`);
|
||||
}
|
||||
|
||||
@ -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 C3:B1 联调前本文件用裸 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 版本 ID(int64;契约端点 path 参数为整型)
|
||||
* @param scene preview=创作者预览未发布 / play=玩家试玩已发布
|
||||
* @throws 当 code≠0 或网络失败时抛出,由 GamePlayer 捕获并走错误态(emits error)
|
||||
* @throws 当 code≠0(如 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 并解包 CommonResult(code≠0 时已 reject)。结构透传。
|
||||
const pkg = await getPackage(versionId, scene);
|
||||
return pkg as unknown as RuntimePackageRespVO;
|
||||
}
|
||||
|
||||
@ -24,8 +24,8 @@ function ensureAnonId(): string {
|
||||
}
|
||||
|
||||
export const useUserStore = defineStore('user', () => {
|
||||
/** 登录 Token(MVP 固定 mock;空串表示匿名) */
|
||||
const token = ref<string>('mock-studio-token')
|
||||
/** 登录 Token:staging 联调取 env VITE_STUDIO_TOKEN(=test1,后端可识别的登录态);缺省回退 mock 占位(§2.4)。 */
|
||||
const token = ref<string>((import.meta.env.VITE_STUDIO_TOKEN as string | undefined) ?? 'mock-studio-token')
|
||||
/** 用户 ID(mock 固定) */
|
||||
const userId = ref<string>('1001')
|
||||
/** 昵称(mock 固定) */
|
||||
|
||||
@ -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 指向 staging;mock 模式为空串=同源命中中间件) */
|
||||
const API_BASE: string = (import.meta.env.VITE_API_BASE as string | undefined) ?? ''
|
||||
/** 卸载/beacon 上报地址(拼 API_BASE:联调命中 staging,mock 同源命中中间件) */
|
||||
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 C5);traceId:有则用、无则回退会话级 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))
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@ -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'
|
||||
|
||||
/** 路由参数(string;versionId 可缺省,缺省时无法即玩,给错误兜底) */
|
||||
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 的互动事件采不到)。命中契约事件名→原样 track;未命中→game_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
16
game-studio/src/vite-env.d.ts
vendored
Normal 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 登录态 token(staging=test1,后端可识别的固定测试态;缺省回退 mock 占位) */
|
||||
readonly VITE_STUDIO_TOKEN?: string
|
||||
}
|
||||
|
||||
interface ImportMeta {
|
||||
readonly env: ImportMetaEnv
|
||||
}
|
||||
@ -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-free,dev/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)),
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
})
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user