diff --git a/.gitignore b/.gitignore index c018bce2..9d11b061 100644 --- a/.gitignore +++ b/.gitignore @@ -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 diff --git a/game-studio/.env.staging b/game-studio/.env.staging new file mode 100644 index 00000000..d9ebc699 --- /dev/null +++ b/game-studio/.env.staging @@ -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 diff --git a/game-studio/src/api/request.ts b/game-studio/src/api/request.ts index e9fb5159..09ec07b6 100644 --- a/game-studio/src/api/request.ts +++ b/game-studio/src/api/request.ts @@ -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') diff --git a/game-studio/src/api/types.ts b/game-studio/src/api/types.ts index b005e933..de6e97b5 100644 --- a/game-studio/src/api/types.ts +++ b/game-studio/src/api/types.ts @@ -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 */ diff --git a/game-studio/src/host/inject.ts b/game-studio/src/host/inject.ts index 9381dbab..cde04756 100644 --- a/game-studio/src/host/inject.ts +++ b/game-studio/src/host/inject.ts @@ -151,6 +151,26 @@ async function sha256Hex(text: string): Promise { .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> { + const headers: Record = { '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 { - // 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}`); } diff --git a/game-studio/src/host/runtimeApi.ts b/game-studio/src/host/runtimeApi.ts index 0b122adf..7ec37173 100644 --- a/game-studio/src/host/runtimeApi.ts +++ b/game-studio/src/host/runtimeApi.ts @@ -1,47 +1,26 @@ /** - * A2 宿主引擎 | 运行包清单取数(内置默认实现) + * A2 宿主引擎 | 运行包清单取数 * ---------------------------------------------------------------------------- - * 严格消费契约 #1 runtime.yaml 的端点: - * GET /app-api/runtime/package/{versionId}?scene=play|preview - * → CommonResult({ 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 { - 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 { - // 严格按契约路径与查询参数拼装 - const resp = await axios.get>( - `/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; } diff --git a/game-studio/src/store/user.ts b/game-studio/src/store/user.ts index f52383ac..a3047232 100644 --- a/game-studio/src/store/user.ts +++ b/game-studio/src/store/user.ts @@ -24,8 +24,8 @@ function ensureAnonId(): string { } export const useUserStore = defineStore('user', () => { - /** 登录 Token(MVP 固定 mock;空串表示匿名) */ - const token = ref('mock-studio-token') + /** 登录 Token:staging 联调取 env VITE_STUDIO_TOKEN(=test1,后端可识别的登录态);缺省回退 mock 占位(§2.4)。 */ + const token = ref((import.meta.env.VITE_STUDIO_TOKEN as string | undefined) ?? 'mock-studio-token') /** 用户 ID(mock 固定) */ const userId = ref('1001') /** 昵称(mock 固定) */ diff --git a/game-studio/src/telemetry/index.ts b/game-studio/src/telemetry/index.ts index abd98533..b0f3a10e 100644 --- a/game-studio/src/telemetry/index.ts +++ b/game-studio/src/telemetry/index.ts @@ -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 = { '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)) } /** diff --git a/game-studio/src/views/play/Play.vue b/game-studio/src/views/play/Play.vue index d14f2300..9d5f90ce 100644 --- a/game-studio/src/views/play/Play.vue +++ b/game-studio/src/views/play/Play.vue @@ -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([ + '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 }): 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) + } } /* ============================================================================ diff --git a/game-studio/src/vite-env.d.ts b/game-studio/src/vite-env.d.ts new file mode 100644 index 00000000..906aec9e --- /dev/null +++ b/game-studio/src/vite-env.d.ts @@ -0,0 +1,16 @@ +/// + +/** + * 本项目用到的 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 +} diff --git a/game-studio/vite.config.ts b/game-studio/vite.config.ts index 578c5256..9fb356fb 100644 --- a/game-studio/vite.config.ts +++ b/game-studio/vite.config.ts @@ -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)), + }, }, - }, + } })