diff --git a/b2f.md b/b2f.md index 5181580..985b7fc 100644 --- a/b2f.md +++ b/b2f.md @@ -1,6 +1,6 @@ # b2f -> 文档版本:v1.10 -> 最后更新:2026-04-03 20:10:25 +> 文档版本:v1.19 +> 最后更新:2026-04-03 16:43:25 说明: @@ -12,6 +12,172 @@ ## 待确认 +### B2F-032 + +- 时间:2026-04-03 16:43:25 +- 谁提的:backend +- 当前事实: + - backend 已收到 frontend 最新一轮结构化调试日志,并确认积分赛主链已打通 + - 当前日志事实一致指向: + - `entry-home.cardEventIds` 已包含 `evt_demo_score_o_001` + - `event-play.pageEventId = evt_demo_score_o_001` + - `event-prepare.pageEventId = evt_demo_score_o_001` + - `launch.response.releaseId = rel_74bb47a0d0d3d252` + - `runtime-compiler.details.game.mode = score-o` + - `runtime-compiler.details.playfield.kind = control-set` + - 当前 backend 没再看到“明明选积分赛却实际跑成顺序赛”的问题 + - 当前日志链还有 3 个口径优化项: + - 非多赛道玩法时,`assignmentMode` 现在是空字符串 `\"\"` + - `variantId` 为空时,`runtimeCourseVariantId` 仍可能有值,前端展示层不要把两者混为“用户选了赛道” + - `occurredAt` 和 `receivedAt` 会有轻微时钟漂移,排查顺序时建议增加前端本地递增序号 +- 需要对方确认什么: + - frontend 后续日志上报请优化为: + - 非多赛道玩法时: + - `assignmentMode = null` 或不传 + - `variantId = null` 或不传 + - 新增: + - `details.seq` + - 如需展示赛道来源,请区分: + - `launchVariantId` + - `runtimeCourseVariantId` +- 是否已解决:否 + +### B2F-031 + +- 时间:2026-04-03 16:37:41 +- 谁提的:backend +- 当前事实: + - backend 已收到 frontend 新增的结构化调试日志 + - 这批日志已经明确说明:本轮前端实际进入的是经典顺序赛,不是积分赛 + - 关键事实如下: + - `entry-home.cardEventIds = [evt_demo_001, evt_demo_variant_manual_001]` + - `event-play.pageEventId = evt_demo_001` + - `event-prepare.pageEventId = evt_demo_001` + - `launch.response.releaseId = rel_demo_001` + - `runtime-compiler.details.game.mode = classic-sequential` + - backend 已确认根因不是积分赛 release 缺失,而是 demo 首页卡片入口配置错误: + - 当前首页卡片查询只取 `home_primary` + - score-o demo 卡此前被种到了 `home_secondary` + - 所以前端首页根本拿不到积分赛入口 + - backend 已修复: + - `card_demo_score_o_001` 改为 `home_primary` + - 同时把优先级调高到 `98` +- 需要对方确认什么: + - frontend 请在 backend 重启后重新执行: + - `Bootstrap Demo` + - 进入首页再看 `cardEventIds` + - 并确认首页卡片中是否已经出现: + - `evt_demo_score_o_001` +- 是否已解决:否 + +### B2F-030 + +- 时间:2026-04-03 16:16:38 +- 谁提的:backend +- 当前事实: + - backend 已新增 dev 调试接口: + - `POST /dev/client-logs` + - `GET /dev/client-logs` + - `DELETE /dev/client-logs` + - workbench 已新增: + - `前端调试日志` + - `拉取前端日志` + - `清空前端日志` + - 这套能力只用于联调,不参与正式生产日志链路 + - backend 当前建议 frontend 在关键阶段主动上报日志,至少覆盖: + - launch 返回后 + - 地图页拿到最终 manifest 后 + - 运行时编译完成后 + - 发现缓存命中 / 恢复 session / 模式不符时 +- 需要对方确认什么: + - frontend 请按最小字段约定接入并回传一轮: + - `source` + - `level` + - `category` + - `message` + - `eventId` + - `releaseId` + - `sessionId` + - `manifestUrl` + - `route` + - `details.schemaVersion` + - `details.playfield.kind` + - `details.game.mode` + - `details.phase` +- 是否已解决:否 + +### B2F-029 + +- 时间:2026-04-03 15:44:32 +- 谁提的:backend +- 当前事实: + - backend 已确认一个具体问题: + - 所有“带 `Bootstrap Demo` 的一键按钮”,此前都会先把默认 demo 数据重新写回表单 + - 这会把前面已经选好的积分赛入口再次冲回顺序赛默认链 + - 受影响的不只是: + - `整条链一键验收` + - 还包括: + - `看首页是否正常` + - `快速进一局` + - `发布活动配置(自动补 Runtime)` + - backend 已修复为: + - 这些一键流在调用 `Bootstrap Demo` 后,会按当前已选中的 `event` 回填对应的 `source / build / release / runtime` + - 不再无条件回退到 `evt_demo_001` +- 需要对方确认什么: + - frontend 请重启 backend 后复验: + - `Use Score-O Demo` + - `整条链一键验收` + - 并确认地图信息面板里是否已切到: + - `eventPublicID = evt_demo_score_o_001` + - `game.mode = score-o` + - `playfield.kind = control-set` +- 是否已解决:否 + +### B2F-028 + +- 时间:2026-04-03 15:29:07 +- 谁提的:backend +- 当前事实: + - backend 已核对积分赛 demo 当前发布链,以下 3 层结果一致: + - 本地源配置 [score-o.json](D:/dev/cmr-mini/event/score-o.json) + - OSS 配置 [score-o.json](https://oss-mbh5.colormaprun.com/gotomars/event/score-o.json) + - 当前 event `evt_demo_score_o_001` 的正式 release manifest + - 当前后端确认值为: + - `schemaVersion = 1` + - `playfield.kind = control-set` + - `game.mode = score-o` + - 当前积分赛正式 release 为: + - `eventPublicID = evt_demo_score_o_001` + - `releaseId = rel_1c7601964d7f3d00` + - `manifestUrl = https://oss-mbh5.colormaprun.com/gotomars/event/releases/evt_demo_score_o_001/rel_1c7601964d7f3d00/manifest.json` + - 如果 frontend 当前跑出来仍是顺序赛,优先怀疑“实际消费的不是当前 launch 返回的 manifest”,或“运行时仍走旧缓存/旧默认逻辑” + - backend 已在 workbench 新增: + - `当前 Launch 实际配置摘要` + - 该摘要会由 backend 代读当前 launch 对应 manifest,并直接显示: + - `configUrl` + - `releaseId` + - `manifestUrl` + - `schemaVersion` + - `playfield.kind` + - `game.mode` + - 这块摘要只用于联调排查,不参与正式客户端运行链路 +- 需要对方确认什么: + - frontend 请先排查并回传这 4 项事实: + - `POST /events/{eventPublicID}/launch` 响应里的: + - `launch.config.configUrl` + - `launch.resolvedRelease.manifestUrl` + - `launch.config.releaseId` + - 地图页/运行时真正读取的最终 manifest URL + - 最终加载后的 manifest 摘要: + - `schemaVersion` + - `playfield.kind` + - `game.mode` + - 如仍表现为顺序赛,请同时给出: + - 控制台日志 + - 网络请求日志 + - 是否存在本地缓存/上次 session 恢复痕迹 +- 是否已解决:否 + ### B2F-001 - 时间:2026-04-01 @@ -103,6 +269,56 @@ ## 已确认 +### B2F-027 + +- 时间:2026-04-03 14:37:00 +- 谁提的:backend +- 当前事实: + - workbench 已提供 3 个显式玩法测试入口: + - `Use Classic Demo` + - `Use Score-O Demo` + - `Use Manual Variant Demo` + - 对应联调 event 为: + - `evt_demo_001` + - `evt_demo_score_o_001` + - `evt_demo_variant_manual_001` + - 积分赛入口已固定到: + - `rel_demo_score_o_001` + - `score-o.json` +- 需要对方确认什么: + - frontend 后续若要测顺序赛或积分赛,优先使用上述显式入口,而不是自行猜 event/release +- 是否已解决:是 + +### B2F-026 + +- 时间:2026-04-03 14:29:42 +- 谁提的:backend +- 当前事实: + - backend 已把 manual 多赛道 demo 的赛道输入切到真实 KML + - 当前 `Bootstrap Demo` 会准备两条真实赛道输入: + - `variant_a -> c01.kml` + - `variant_b -> c02.kml` + - 当前地图仍继续共用同一组真实 tiles / mapmeta,这符合当前多赛道联调阶段的实际需要 +- 需要对方确认什么: + - frontend 如需回归 manual 多赛道,请先重新执行一次 `Bootstrap Demo` +- 是否已解决:是 + +### B2F-025 + +- 时间:2026-04-03 14:21:24 +- 谁提的:backend +- 当前事实: + - backend 已开始“真实输入替换第一刀” + - 当前 `Bootstrap Demo` 不再给生产骨架使用 `example.com` 占位赛道/地图地址 + - 当前已改成真实可访问输入: + - `CourseSource.fileUrl = https://oss-mbh5.colormaprun.com/gotomars/kml/lxcb-001/10/c01.kml` + - `TileRelease.tileBaseUrl = https://oss-mbh5.colormaprun.com/gotomars/map/lxcb-001/tiles/` + - `TileRelease.metaUrl = https://oss-mbh5.colormaprun.com/gotomars/map/lxcb-001/tiles/meta.json` + - 这批真实输入仍走同一条一键回归链,不要求 frontend 改联调方式 +- 需要对方确认什么: + - frontend 如需复验真实输入,请重新执行一次 `Bootstrap Demo` +- 是否已解决:是 + ### B2F-024 - 时间:2026-04-03 20:10:25 diff --git a/b2t.md b/b2t.md index 98823ce..57b412e 100644 --- a/b2t.md +++ b/b2t.md @@ -1,6 +1,6 @@ # B2T 协作清单 -> 文档版本:v1.13 -> 最后更新:2026-04-03 13:24:38 +> 文档版本:v1.18 +> 最后更新:2026-04-03 16:16:38 说明: @@ -36,6 +36,60 @@ ## 已确认 +### B2T-028 + +- 时间:2026-04-03 16:16:38 +- 谁提的:backend +- 当前事实: + - backend 已新增 dev 调试日志入口: + - `POST /dev/client-logs` + - `GET /dev/client-logs` + - `DELETE /dev/client-logs` + - workbench 已新增 `前端调试日志` 面板,用于直接查看 frontend 主动上报的关键事实 + - 这套能力只服务联调排查,不替代正式生产日志体系 + - 当前目标是把“截图 + 口头描述”进一步收口成结构化事实,便于 backend 直接定位前端消费链问题 +- 需要对方确认什么: + - 无 +- 是否已解决:是 + +### B2T-027 + +- 时间:2026-04-03 15:29:07 +- 谁提的:backend +- 当前事实: + - backend 已在 workbench 新增: + - `当前 Launch 实际配置摘要` + - 该摘要由 backend 代读当前 launch 对应 manifest,用于统一显示: + - `configUrl` + - `releaseId` + - `manifestUrl` + - `schemaVersion` + - `playfield.kind` + - `game.mode` + - 该能力只用于联调排查,避免浏览器直接读取 OSS 时受跨域影响 + - 正式客户端链路不变,仍应直接消费: + - `launch.config.configUrl` + - `launch.resolvedRelease.manifestUrl` +- 需要对方确认什么: + - 无 +- 是否已解决:是 + +### B2T-026 + +- 时间:2026-04-03 14:37:00 +- 谁提的:backend +- 当前事实: + - backend 已把当前两种核心玩法和 manual 多赛道都挂成显式 demo 入口: + - 顺序赛:`evt_demo_001` + - 积分赛:`evt_demo_score_o_001` + - 多赛道:`evt_demo_variant_manual_001` + - 其中积分赛入口已固定到远端发布配置: + - `https://oss-mbh5.colormaprun.com/gotomars/event/score-o.json` + - workbench 当前可直接切换三种玩法做标准联调 +- 需要对方确认什么: + - 无 +- 是否已解决:是 + ### B2T-001 - 时间:2026-04-03 08:52:11 @@ -130,6 +184,41 @@ ## 已完成 +### B2T-024 + +- 时间:2026-04-03 14:21:24 +- 谁提的:backend +- 当前事实: + - backend 已按 [t2b.md](D:/dev/cmr-mini/t2b.md) v1.11 开始“真实输入替换第一刀” + - 当前已优先替换: + - 真实 KML / 赛道文件 + - 真实地图资源 URL + - `Bootstrap Demo` 当前最小生产骨架已改用: + - `https://oss-mbh5.colormaprun.com/gotomars/kml/lxcb-001/10/c01.kml` + - `https://oss-mbh5.colormaprun.com/gotomars/map/lxcb-001/tiles/` + - `https://oss-mbh5.colormaprun.com/gotomars/map/lxcb-001/tiles/meta.json` + - 当前仍保持同一条标准联调入口,不新增新流程 +- 需要对方确认什么: + - 无 +- 是否已解决:是 + +### B2T-025 + +- 时间:2026-04-03 14:29:42 +- 谁提的:backend +- 当前事实: + - backend 已继续推进“真实输入替换第一刀”的 manual 多赛道部分 + - 当前 `Bootstrap Demo` 已为 demo course set 准备两条真实 KML 输入: + - `c01.kml` + - `c02.kml` + - 当前 manual 多赛道 demo 的说明已同步为: + - `variant_a -> c01.kml` + - `variant_b -> c02.kml` + - 当前仍沿用同一条标准联调入口,不新增新流程 +- 需要对方确认什么: + - 无 +- 是否已解决:是 + ### B2T-023 - 时间:2026-04-03 13:24:38 diff --git a/backend/README.md b/backend/README.md index 42d4a49..76d51bd 100644 --- a/backend/README.md +++ b/backend/README.md @@ -1,6 +1,6 @@ # Backend -> 文档版本:v1.12 -> 最后更新:2026-04-03 13:24:38 +> 文档版本:v1.17 +> 最后更新:2026-04-03 16:16:38 这套后端现在已经能支撑一条完整主链: @@ -14,6 +14,27 @@ - 真正进入游戏时客户端消费的是 `manifest_url` - `session` 会固化当时实际绑定的 `release` +当前 workbench 里新增的“当前 Launch 实际配置摘要”仅用于调试: + +- 它会由 backend 代读当前 launch 对应的 manifest +- 用来显示: + - `configUrl` + - `releaseId` + - `manifestUrl` + - `schemaVersion` + - `playfield.kind` + - `game.mode` +- 这块只服务联调排查,不参与正式客户端运行链路 +- 正式客户端仍应直接消费 `launch` 返回的: + - `launch.config.configUrl` + - `launch.resolvedRelease.manifestUrl` + +当前 workbench 里新增的“前端调试日志”也仅用于联调: + +- frontend 可将页面侧调试日志 `POST` 到 `/dev/client-logs` +- backend 会临时保留最近 200 条日志,供 workbench 查看与清空 +- 这块只用于联调排查,不替代正式生产日志体系 + ## 文档导航 - [文档索引](D:/dev/cmr-mini/backend/docs/README.md) @@ -64,5 +85,15 @@ cd D:\dev\cmr-mini\backend - Bootstrap Demo 自动回填最小生产骨架 ID - 一键测试环境:可从空白状态自动准备 demo event、source/build/release、presentation、content bundle、place、map asset、tile release、course source、course set、course variant、runtime binding,并输出逐步日志与预期判定 - 一键标准回归:在标准发布链跑通后,继续自动验证 `play / launch / result / history` + - 真实输入替换第一刀:`Bootstrap Demo` 已改用真实可访问的 KML 与地图资源 URL + - manual 多赛道 demo:已切到真实 `c01.kml / c02.kml` 输入 + - 前端调试日志: + - `POST /dev/client-logs` + - `GET /dev/client-logs` + - `DELETE /dev/client-logs` + - 显式玩法入口: + - 顺序赛:`evt_demo_001` + - 积分赛:`evt_demo_score_o_001` + - 多赛道:`evt_demo_variant_manual_001` diff --git a/backend/docs/开发说明.md b/backend/docs/开发说明.md index dfe5f3f..78e942d 100644 --- a/backend/docs/开发说明.md +++ b/backend/docs/开发说明.md @@ -1,6 +1,6 @@ # 开发说明 -> 文档版本:v1.14 -> 最后更新:2026-04-03 20:10:25 +> 文档版本:v1.20 +> 最后更新:2026-04-03 16:16:38 ## 1. 环境变量 @@ -39,6 +39,35 @@ cd D:\dev\cmr-mini\backend .\scripts\start-dev.ps1 ``` +## 3. Workbench 当前重点 + +- 推荐联调入口: + - `Bootstrap Demo` + - `Use Classic Demo / Use Score-O Demo / Use Manual Variant Demo` + - `整条链一键验收` +- 如果 frontend 需要把页面侧调试日志直接打到 backend,优先使用: + - `POST /dev/client-logs` + - 然后在 workbench 的 `前端调试日志` 面板里查看 +- 如果需要判断前端到底拿到了哪份配置,优先看 workbench 的: + - `当前 Launch 实际配置摘要` +- 这块会直接显示: + - `configUrl` + - `releaseId` + - `manifestUrl` + - `schemaVersion` + - `playfield.kind` + - `game.mode` +- 这组信息用于和前端地图页实际消费结果对口排查,避免只靠口头描述“像顺序赛/像积分赛”。 +- 注意: + - 这块摘要由 backend 代读 manifest,只用于 workbench 调试 + - 这样做是为了避免浏览器直接读取 OSS 时受跨域影响 + - 它不替代正式客户端加载逻辑 + - 正式客户端仍必须直接消费 `launch.config.configUrl` 或 `launch.resolvedRelease.manifestUrl` +- `前端调试日志` 也是调试专用能力: + - backend 当前只在内存里保留最近 200 条 + - 适合前端把关键事实直接打进来,避免只靠截图和口头描述 + - 不替代正式生产日志体系 + 默认会设置: - `APP_ENV=development` @@ -72,8 +101,12 @@ cd D:\dev\cmr-mini\backend 当前推荐顺序: 1. `Bootstrap Demo` -2. `一键补齐 Runtime 并发布` -3. `一键标准回归` +2. 选择一种玩法入口: + - `Use Classic Demo` + - `Use Score-O Demo` + - `Use Manual Variant Demo` +3. `一键补齐 Runtime 并发布` +4. `一键标准回归` 当前这条一键链会自动完成: @@ -94,6 +127,20 @@ cd D:\dev\cmr-mini\backend - `play / launch / result / history` 回归汇总 - demo 活动残留 ongoing session 清理: - 会把 demo event 下历史遗留的 `launched / running` session 自动改成 `cancelled` +- 真实输入替换第一刀: + - `CourseSource.fileUrl` 当前已切到真实 KML: + - `https://oss-mbh5.colormaprun.com/gotomars/kml/lxcb-001/10/c01.kml` + - `https://oss-mbh5.colormaprun.com/gotomars/kml/lxcb-001/10/c02.kml` + - `TileRelease.tileBaseUrl / metaUrl` 当前已切到真实地图资源: + - `https://oss-mbh5.colormaprun.com/gotomars/map/lxcb-001/tiles/` + - `https://oss-mbh5.colormaprun.com/gotomars/map/lxcb-001/tiles/meta.json` + - manual 多赛道 demo 当前已使用两条真实赛道输入: + - `variant_a -> c01.kml` + - `variant_b -> c02.kml` + - 显式玩法测试入口: + - 顺序赛:`evt_demo_001 -> rel_demo_001 -> classic-sequential.json` + - 积分赛:`evt_demo_score_o_001 -> rel_demo_score_o_001 -> score-o.json` + - 多赛道:`evt_demo_variant_manual_001 -> rel_demo_variant_manual_001` 当前日志能力: @@ -116,6 +163,60 @@ cd D:\dev\cmr-mini\backend - `History` - `Session ID` - `总判定` +- workbench 现在还支持查看 frontend 主动上报的调试日志: + - `拉取前端日志` + - `清空前端日志` + - 前端建议最少带: + - `eventId` + - `releaseId` + - `sessionId` + - `manifestUrl` + - `route` + - `game.mode` + - `playfield.kind` + - 当前页面阶段或动作名 + +### 2.2 前端调试日志最小约定 + +dev 环境下,frontend 可直接把关键调试事实发到 backend: + +- `POST /dev/client-logs` + +建议请求体最少包含: + +```json +{ + "source": "miniprogram", + "level": "info", + "category": "runtime", + "message": "map page loaded manifest", + "eventId": "evt_demo_score_o_001", + "releaseId": "rel_xxx", + "sessionId": "sess_xxx", + "manifestUrl": "https://oss-mbh5.colormaprun.com/...", + "route": "pages/map/map", + "occurredAt": "2026-04-03T16:16:38+08:00", + "details": { + "schemaVersion": "1", + "playfield.kind": "control-set", + "game.mode": "score-o", + "phase": "map-init" + } +} +``` + +当前说明: + +- `source`:建议填终端来源,例如 `miniprogram` +- `level`:建议填 `info / warn / error` +- `category`:建议填 `launch / runtime / cache / network` +- `message`:一句话说明当前发生了什么 +- `details`:放结构化调试细节,backend 原样收下 + +辅助接口: + +- `GET /dev/client-logs?limit=50` +- `DELETE /dev/client-logs` ## 3. 当前开发约定 diff --git a/backend/docs/接口清单.md b/backend/docs/接口清单.md index 270bd16..8db180b 100644 --- a/backend/docs/接口清单.md +++ b/backend/docs/接口清单.md @@ -1,6 +1,6 @@ # API 清单 -> 文档版本:v1.8 -> 最后更新:2026-04-03 12:36:15 +> 文档版本:v1.9 +> 最后更新:2026-04-03 16:16:38 本文档只记录当前 backend 已实现接口,不写未来规划接口。 @@ -436,6 +436,61 @@ - 自动准备 demo tenant / channel / event / release / card +### `POST /dev/client-logs` + +环境: + +- 仅 non-production + +用途: + +- 接收 frontend 主动上报的调试日志 +- 供 backend 通过 workbench 统一查看和排查 + +请求体重点: + +- `source` +- `level` +- `category` +- `message` +- `eventId` +- `releaseId` +- `sessionId` +- `manifestUrl` +- `route` +- `occurredAt` +- `details` + +补充说明: + +- 当前只保存在内存中 +- 默认最多保留最近 200 条 +- 仅用于联调调试 + +### `GET /dev/client-logs` + +环境: + +- 仅 non-production + +用途: + +- 获取 frontend 最近上报的调试日志 + +查询参数: + +- `limit` + +### `DELETE /dev/client-logs` + +环境: + +- 仅 non-production + +用途: + +- 清空当前内存中的 frontend 调试日志 + ### `GET /dev/workbench` 环境: @@ -459,6 +514,26 @@ - scenarios - request history - curl 导出 +- frontend 调试日志查看/清空 + +### `GET /dev/manifest-summary` + +环境: + +- 仅 non-production + +用途: + +- 由 backend 代读指定 manifest +- 返回最小调试摘要: + - `schemaVersion` + - `playfield.kind` + - `game.mode` + +补充说明: + +- 只用于 workbench 联调排查 +- 不参与正式客户端运行链路 ### `GET /dev/config/local-files` diff --git a/backend/internal/httpapi/handlers/dev_handler.go b/backend/internal/httpapi/handlers/dev_handler.go index f6b1f04..eeedb3b 100644 --- a/backend/internal/httpapi/handlers/dev_handler.go +++ b/backend/internal/httpapi/handlers/dev_handler.go @@ -1,7 +1,11 @@ package handlers import ( + "encoding/json" + "fmt" "net/http" + neturl "net/url" + "time" "cmr-backend/internal/httpx" "cmr-backend/internal/service" @@ -24,6 +28,59 @@ func (h *DevHandler) BootstrapDemo(w http.ResponseWriter, r *http.Request) { httpx.WriteJSON(w, http.StatusOK, map[string]any{"data": result}) } +func (h *DevHandler) CreateClientLog(w http.ResponseWriter, r *http.Request) { + if !h.devService.Enabled() { + http.NotFound(w, r) + return + } + + var input service.CreateClientDebugLogInput + if err := httpx.DecodeJSON(r, &input); err != nil { + httpx.WriteError(w, fmt.Errorf("decode client log: %w", err)) + return + } + + entry, err := h.devService.AddClientDebugLog(r.Context(), input) + if err != nil { + httpx.WriteError(w, err) + return + } + httpx.WriteJSON(w, http.StatusOK, map[string]any{"data": entry}) +} + +func (h *DevHandler) ListClientLogs(w http.ResponseWriter, r *http.Request) { + if !h.devService.Enabled() { + http.NotFound(w, r) + return + } + + limit := 50 + if raw := r.URL.Query().Get("limit"); raw != "" { + var parsed int + if _, err := fmt.Sscanf(raw, "%d", &parsed); err == nil { + limit = parsed + } + } + items, err := h.devService.ListClientDebugLogs(r.Context(), limit) + if err != nil { + httpx.WriteError(w, err) + return + } + httpx.WriteJSON(w, http.StatusOK, map[string]any{"data": items}) +} + +func (h *DevHandler) ClearClientLogs(w http.ResponseWriter, r *http.Request) { + if !h.devService.Enabled() { + http.NotFound(w, r) + return + } + if err := h.devService.ClearClientDebugLogs(r.Context()); err != nil { + httpx.WriteError(w, err) + return + } + httpx.WriteJSON(w, http.StatusOK, map[string]any{"data": map[string]any{"cleared": true}}) +} + func (h *DevHandler) Workbench(w http.ResponseWriter, r *http.Request) { if !h.devService.Enabled() { http.NotFound(w, r) @@ -34,6 +91,75 @@ func (h *DevHandler) Workbench(w http.ResponseWriter, r *http.Request) { _, _ = w.Write([]byte(devWorkbenchHTML)) } +func (h *DevHandler) ManifestSummary(w http.ResponseWriter, r *http.Request) { + if !h.devService.Enabled() { + http.NotFound(w, r) + return + } + + rawURL := r.URL.Query().Get("url") + if rawURL == "" { + httpx.WriteError(w, fmt.Errorf("manifest summary url is required")) + return + } + + parsed, err := neturl.Parse(rawURL) + if err != nil || parsed.Scheme == "" || parsed.Host == "" { + httpx.WriteError(w, fmt.Errorf("invalid manifest url")) + return + } + + client := &http.Client{Timeout: 15 * time.Second} + resp, err := client.Get(parsed.String()) + if err != nil { + httpx.WriteError(w, fmt.Errorf("fetch manifest: %w", err)) + return + } + defer resp.Body.Close() + + if resp.StatusCode < 200 || resp.StatusCode >= 300 { + httpx.WriteError(w, fmt.Errorf("fetch manifest: http %d", resp.StatusCode)) + return + } + + var manifest map[string]any + if err := json.NewDecoder(resp.Body).Decode(&manifest); err != nil { + httpx.WriteError(w, fmt.Errorf("decode manifest: %w", err)) + return + } + + summary := map[string]any{ + "url": parsed.String(), + "schemaVersion": pickString(manifest["schemaVersion"]), + "playfieldKind": pickNestedString(manifest, "playfield", "kind"), + "gameMode": pickNestedString(manifest, "game", "mode"), + } + httpx.WriteJSON(w, http.StatusOK, map[string]any{"data": summary}) +} + +func pickString(v any) string { + switch t := v.(type) { + case string: + return t + case float64: + return fmt.Sprintf("%.0f", t) + default: + return "" + } +} + +func pickNestedString(m map[string]any, parent, child string) string { + value, ok := m[parent] + if !ok { + return "" + } + nested, ok := value.(map[string]any) + if !ok { + return "" + } + return pickString(nested[child]) +} + const devWorkbenchHTML = ` @@ -191,6 +317,7 @@ const devWorkbenchHTML = ` display: grid; gap: 16px; grid-template-columns: repeat(auto-fit, minmax(280px, 1fr)); + align-items: start; } .stack { display: grid; @@ -203,6 +330,7 @@ const devWorkbenchHTML = ` padding: 16px; display: grid; gap: 12px; + align-content: start; box-shadow: 0 8px 30px rgba(0, 0, 0, 0.18); } .panel h2 { @@ -261,6 +389,46 @@ const devWorkbenchHTML = ` color: var(--text); border: 1px solid var(--line); } + .btn-stack { + display: inline-flex; + align-items: center; + gap: 8px; + flex-wrap: wrap; + } + .btn-badge { + display: inline-flex; + align-items: center; + justify-content: center; + min-width: 38px; + min-height: 20px; + padding: 0 8px; + border-radius: 999px; + font-size: 11px; + font-weight: 800; + letter-spacing: 0.02em; + background: rgba(255,255,255,0.16); + color: #08231a; + } + .btn-badge.home { + background: rgba(79, 209, 165, 0.22); + color: #083226; + } + .btn-badge.game { + background: rgba(255, 209, 102, 0.3); + color: #3c2a00; + } + .btn-badge.publish { + background: rgba(125, 211, 252, 0.28); + color: #082a43; + } + .btn-badge.verify { + background: rgba(251, 146, 60, 0.3); + color: #482100; + } + .btn-badge.recommend { + background: rgba(248, 113, 113, 0.28); + color: #4a1111; + } .actions { display: flex; flex-wrap: wrap; @@ -469,19 +637,22 @@ const devWorkbenchHTML = `
-

准备 Demo 数据

-

初始化 demo tenant / channel / event / card。

+

第一步:选玩法

+

先在这里选玩法入口。顺序赛、积分赛、多赛道各有一套独立 demo 数据,后面一键流程都会复用这里选中的 event。

- - + + + +
默认入口 tenant_demo / mini-demo / evt_demo_001
+
积分赛入口 tenant_demo / mini-demo / evt_demo_score_o_001
多赛道入口 tenant_demo / mini-demo / evt_demo_variant_manual_001
@@ -720,23 +891,30 @@ const devWorkbenchHTML = ` -
+
-

一键流程

-

把常用接口串成一键工作流,减少重复点击。

+

第二步:点测试目标

+

先选玩法入口,再按“你现在想测什么”点对应按钮。大多数情况下,你只需要点最后一个“一键标准回归”。

- - - - - - - + + + + + + +
-
这些流程会复用当前表单里的手机号、设备、event、channel 等输入。“一键默认绑定发布” 会自动执行:Get Event -> Import Presentation -> Import Bundle -> Save Event Defaults -> Build Source -> Publish Build -> Get Release。“一键补齐 Runtime 并发布” 会在缺少默认 runtime 时自动创建 Runtime Binding,再继续发布链。“一键标准回归” 会继续执行:play -> launch -> start -> finish -> result -> history。
+
+ 推荐顺序: +
1. 先点上面的玩法入口:Use Classic Demo / Use Score-O Demo / Use Manual Variant Demo +
2. 想直接验收,就点 整条链一键验收 +
3. 想只测发布链,就点 发布活动配置(自动补 Runtime) +
4. 想只测局内流程,就点 快速进一局、结束并看结果 +
+
这些流程会复用当前表单里的手机号、设备、event、channel 等输入。发布活动配置(默认绑定)会自动执行:Get Event -> Import Presentation -> Import Bundle -> Save Event Defaults -> Build Source -> Publish Build -> Get Release。发布活动配置(自动补 Runtime)会在缺少默认 runtime 时自动创建 Runtime Binding,再继续发布链。整条链一键验收会继续执行:play -> launch -> start -> finish -> result -> history。
预期结果
@@ -759,20 +937,45 @@ const devWorkbenchHTML = `
总判定 待执行
+
+
当前 Launch 实际配置摘要
+
+
Config URL -
+
Release ID -
+
Manifest URL -
+
Schema Version -
+
Playfield Kind -
+
Game Mode -
+
判定 待执行
+
+
-
-

请求导出

-

最后一次请求会生成一条可复制的 curl,后面做问题复现会方便很多。

-
- - -
-
-
Last Curl
-
-
-
+
+
+

请求导出

+

最后一次请求会生成一条可复制的 curl,后面做问题复现会方便很多。

+
+ + +
+
+
Last Curl
+
+
+
+ +
+

前端调试日志

+

前端可把 launch、manifest、地图页、结果页等调试信息直接打到 backend。这里显示最近日志,便于和 workbench 当前配置对口排查。

+
+ + +
+
建议前端至少上报:eventId / releaseId / manifestUrl / game.mode / playfield.kind / 页面阶段。
+
+
+
+
+
GET/dev/workbench
+
开发态工作台页面,集中提供一键流、日志、配置摘要、API 目录和后台运营联调入口。
+
鉴权:仅 non-production,无需鉴权
+
+ +
+
POST/dev/client-logs
+
接收 frontend 主动上报的调试日志,供 backend 在 workbench 中统一查看。
+
+
鉴权:仅 non-production,无需鉴权
+
关键参数:sourcelevelcategorymessageeventIdreleaseIdsessionIdmanifestUrlroutedetails
+
+
+ +
+
GET/dev/client-logs
+
获取 frontend 最近上报的调试日志,便于 backend 直接对照排查。
+
+
鉴权:仅 non-production,无需鉴权
+
查询参数:limit
+
+
+ +
+
DELETE/dev/client-logs
+
清空当前内存中的 frontend 调试日志,方便开始新一轮联调。
+
鉴权:仅 non-production,无需鉴权
+
+ +
+
GET/dev/manifest-summary
+
由 backend 代读指定 manifest,并返回 schemaVersionplayfield.kindgame.mode 调试摘要。
+
+
鉴权:仅 non-production,无需鉴权
+
查询参数:url
+
+
+
GET/dev/config/local-files
列出本地配置目录中的 JSON 文件,作为 source config 导入入口。
@@ -2257,6 +2499,159 @@ const devWorkbenchHTML = ` $('flow-regression-overall').textContent = overall; } + function resetLaunchConfigSummary() { + $('launch-config-url').textContent = '-'; + $('launch-config-release-id').textContent = '-'; + $('launch-config-manifest-url').textContent = '-'; + $('launch-config-schema-version').textContent = '-'; + $('launch-config-playfield-kind').textContent = '-'; + $('launch-config-game-mode').textContent = '-'; + $('launch-config-verdict').textContent = '待执行'; + } + + async function resolveLaunchConfigSummary(launchPayload) { + const launchData = launchPayload && launchPayload.launch ? launchPayload.launch : {}; + const config = launchData.config || {}; + const resolvedRelease = launchData.resolvedRelease || {}; + const configUrl = config.configUrl || '-'; + const releaseId = config.releaseId || resolvedRelease.releaseId || '-'; + const manifestUrl = resolvedRelease.manifestUrl || config.configUrl || '-'; + const summary = { + configUrl: configUrl, + releaseId: releaseId, + manifestUrl: manifestUrl, + schemaVersion: '-', + playfieldKind: '-', + gameMode: '-', + verdict: '未读取 manifest' + }; + const targetUrl = config.configUrl || resolvedRelease.manifestUrl; + if (!targetUrl) { + summary.verdict = '未通过:launch 未返回 configUrl / manifestUrl'; + return summary; + } + try { + const proxy = await request('GET', '/dev/manifest-summary?url=' + encodeURIComponent(targetUrl)); + const data = proxy && proxy.data ? proxy.data : {}; + summary.schemaVersion = data.schemaVersion || '-'; + summary.playfieldKind = data.playfieldKind || '-'; + summary.gameMode = data.gameMode || '-'; + if (summary.schemaVersion !== '-' && summary.playfieldKind !== '-' && summary.gameMode !== '-') { + summary.verdict = '通过:已读取 launch 实际 manifest 摘要'; + } else { + summary.verdict = '未通过:manifest 已读取,但关键信息不完整'; + } + return summary; + } catch (error) { + summary.verdict = '未通过:manifest 读取异常'; + summary.error = error && error.message ? error.message : String(error); + return summary; + } + } + + function setLaunchConfigSummary(summary) { + const data = summary || {}; + $('launch-config-url').textContent = data.configUrl || '-'; + $('launch-config-release-id').textContent = data.releaseId || '-'; + $('launch-config-manifest-url').textContent = data.manifestUrl || '-'; + $('launch-config-schema-version').textContent = data.schemaVersion || '-'; + $('launch-config-playfield-kind').textContent = data.playfieldKind || '-'; + $('launch-config-game-mode').textContent = data.gameMode || '-'; + $('launch-config-verdict').textContent = data.verdict || '待执行'; + } + + function renderClientLogs(items) { + const logs = Array.isArray(items) ? items : []; + if (!logs.length) { + $('client-logs').textContent = '暂无前端调试日志'; + return; + } + $('client-logs').textContent = logs.map(function(item) { + const lines = []; + lines.push('[' + (item.receivedAt || '-') + '] #' + (item.id || '-') + ' ' + (item.level || 'info').toUpperCase() + ' ' + (item.source || 'unknown')); + if (item.category) { + lines.push('category: ' + item.category); + } + lines.push('message: ' + (item.message || '-')); + if (item.eventId || item.releaseId || item.sessionId) { + lines.push('event/release/session: ' + (item.eventId || '-') + ' / ' + (item.releaseId || '-') + ' / ' + (item.sessionId || '-')); + } + if (item.manifestUrl) { + lines.push('manifest: ' + item.manifestUrl); + } + if (item.route) { + lines.push('route: ' + item.route); + } + if (item.details && Object.keys(item.details).length) { + lines.push('details: ' + JSON.stringify(item.details, null, 2)); + } + return lines.join('\n'); + }).join('\n\n---\n\n'); + } + + async function refreshClientLogs() { + const result = await request('GET', '/dev/client-logs?limit=50'); + renderClientLogs(result.data); + return result; + } + + function selectBootstrapContextForEvent(bootstrap, eventId) { + const data = bootstrap || {}; + if (eventId && data.scoreOEventId && eventId === data.scoreOEventId) { + return { + eventId: data.scoreOEventId, + releaseId: data.scoreOReleaseId || '', + sourceId: data.scoreOSourceId || '', + buildId: data.scoreOBuildId || '', + courseSetId: data.scoreOCourseSetId || '', + courseVariantId: data.scoreOCourseVariantId || '', + runtimeBindingId: data.scoreORuntimeBindingId || '' + }; + } + if (eventId && data.variantManualEventId && eventId === data.variantManualEventId) { + return { + eventId: data.variantManualEventId, + releaseId: data.variantManualReleaseId || '', + sourceId: data.sourceId || '', + buildId: data.buildId || '', + courseSetId: data.courseSetId || '', + courseVariantId: data.courseVariantId || '', + runtimeBindingId: data.runtimeBindingId || '' + }; + } + return { + eventId: data.eventId || '', + releaseId: data.releaseId || '', + sourceId: data.sourceId || '', + buildId: data.buildId || '', + courseSetId: data.courseSetId || '', + courseVariantId: data.courseVariantId || '', + runtimeBindingId: data.runtimeBindingId || '' + }; + } + + function applyBootstrapContext(bootstrap, explicitEventId) { + const eventId = explicitEventId || $('event-id').value || $('admin-event-ref-id').value || ''; + const selected = selectBootstrapContextForEvent(bootstrap, eventId); + state.sourceId = selected.sourceId || state.sourceId; + state.buildId = selected.buildId || state.buildId; + state.releaseId = selected.releaseId || state.releaseId; + $('admin-pipeline-source-id').value = selected.sourceId || $('admin-pipeline-source-id').value; + $('admin-pipeline-build-id').value = selected.buildId || $('admin-pipeline-build-id').value; + $('admin-pipeline-release-id').value = selected.releaseId || $('admin-pipeline-release-id').value; + $('event-release-id').value = selected.releaseId || $('event-release-id').value; + $('prod-runtime-event-id').value = selected.eventId || $('prod-runtime-event-id').value; + $('prod-place-id').value = bootstrap.placeId || $('prod-place-id').value; + $('prod-map-asset-id').value = bootstrap.mapAssetId || $('prod-map-asset-id').value; + $('prod-tile-release-id').value = bootstrap.tileReleaseId || $('prod-tile-release-id').value; + $('prod-course-source-id').value = bootstrap.courseSourceId || $('prod-course-source-id').value; + $('prod-course-set-id').value = selected.courseSetId || $('prod-course-set-id').value; + $('prod-course-variant-id').value = selected.courseVariantId || $('prod-course-variant-id').value; + $('prod-runtime-binding-id').value = selected.runtimeBindingId || $('prod-runtime-binding-id').value; + syncState(); + return selected; + } + function extractList(payload) { if (Array.isArray(payload)) { return payload; @@ -2306,20 +2701,7 @@ const devWorkbenchHTML = ` writeLog(flowTitle + '.step', { step: 'bootstrap-demo' }); const bootstrap = await request('POST', '/dev/bootstrap-demo'); if (bootstrap.data) { - state.sourceId = bootstrap.data.sourceId || state.sourceId; - state.buildId = bootstrap.data.buildId || state.buildId; - state.releaseId = bootstrap.data.releaseId || state.releaseId; - $('admin-pipeline-source-id').value = bootstrap.data.sourceId || $('admin-pipeline-source-id').value; - $('admin-pipeline-build-id').value = bootstrap.data.buildId || $('admin-pipeline-build-id').value; - $('admin-pipeline-release-id').value = bootstrap.data.releaseId || $('admin-pipeline-release-id').value; - $('prod-runtime-event-id').value = bootstrap.data.eventId || $('prod-runtime-event-id').value; - $('prod-place-id').value = bootstrap.data.placeId || $('prod-place-id').value; - $('prod-map-asset-id').value = bootstrap.data.mapAssetId || $('prod-map-asset-id').value; - $('prod-tile-release-id').value = bootstrap.data.tileReleaseId || $('prod-tile-release-id').value; - $('prod-course-source-id').value = bootstrap.data.courseSourceId || $('prod-course-source-id').value; - $('prod-course-set-id').value = bootstrap.data.courseSetId || $('prod-course-set-id').value; - $('prod-course-variant-id').value = bootstrap.data.courseVariantId || $('prod-course-variant-id').value; - $('prod-runtime-binding-id').value = bootstrap.data.runtimeBindingId || $('prod-runtime-binding-id').value; + applyBootstrapContext(bootstrap.data, eventId); } } @@ -2336,6 +2718,9 @@ const devWorkbenchHTML = ` if (eventDetail.data.currentRuntime && eventDetail.data.currentRuntime.runtimeBindingId) { $('admin-release-runtime-binding-id').value = eventDetail.data.currentRuntime.runtimeBindingId; $('prod-runtime-binding-id').value = eventDetail.data.currentRuntime.runtimeBindingId; + } else { + $('admin-release-runtime-binding-id').value = ''; + $('prod-runtime-binding-id').value = ''; } } @@ -2539,6 +2924,9 @@ const devWorkbenchHTML = ` launch.data.launch.resolvedRelease && launch.data.launch.resolvedRelease.manifestUrl ); + const launchConfigSummary = await resolveLaunchConfigSummary(launch.data); + setLaunchConfigSummary(launchConfigSummary); + writeLog(flowTitle + '.launch-summary', launchConfigSummary); writeLog(flowTitle + '.step', { step: 'session-start', @@ -2618,6 +3006,41 @@ const devWorkbenchHTML = ` }; } + function applyFrontendDemoSelection(options) { + resetLaunchConfigSummary(); + $('entry-channel-code').value = 'mini-demo'; + $('entry-channel-type').value = 'wechat_mini'; + $('event-id').value = options.eventId; + $('event-release-id').value = options.releaseId; + $('event-variant-id').value = options.variantId || ''; + $('config-event-id').value = options.eventId; + $('admin-event-ref-id').value = options.eventId; + $('local-config-file').value = options.localConfigFile || $('local-config-file').value; + if (options.gameModeCode) { + $('admin-game-mode-code').value = options.gameModeCode; + $('prod-course-mode').value = options.gameModeCode; + } + $('prod-runtime-event-id').value = options.eventId; + $('prod-course-set-id').value = options.courseSetId || $('prod-course-set-id').value; + $('prod-course-variant-id').value = options.courseVariantId || $('prod-course-variant-id').value; + $('prod-runtime-binding-id').value = options.runtimeBindingId || ''; + $('admin-release-runtime-binding-id').value = options.runtimeBindingId || ''; + $('admin-pipeline-source-id').value = options.sourceId || ''; + $('admin-pipeline-build-id').value = options.buildId || ''; + state.sourceId = options.sourceId || ''; + state.buildId = options.buildId || ''; + state.releaseId = options.releaseId || state.releaseId; + localStorage.setItem(MODE_KEY, 'frontend'); + syncWorkbenchMode(); + writeLog(options.logTitle, { + eventId: $('event-id').value, + releaseId: $('event-release-id').value, + variantId: $('event-variant-id').value || null, + localConfigFile: $('local-config-file').value + }); + setStatus(options.statusText); + } + function setStatus(text, isError = false) { statusEl.textContent = text; statusEl.className = isError ? 'status error' : 'status'; @@ -3485,6 +3908,10 @@ const devWorkbenchHTML = ` }, true); state.sessionId = result.data.launch.business.sessionId; state.sessionToken = result.data.launch.business.sessionToken; + syncState(); + const configSummary = await resolveLaunchConfigSummary(result.data); + setLaunchConfigSummary(configSummary); + writeLog('event-launch.summary', configSummary); return result; }); @@ -4221,6 +4648,16 @@ const devWorkbenchHTML = ` setStatus('ok: history cleared'); }; + $('btn-client-logs-refresh').onclick = () => run('dev/client-logs', async () => { + return await refreshClientLogs(); + }); + + $('btn-client-logs-clear').onclick = () => run('dev/client-logs/clear', async () => { + const result = await request('DELETE', '/dev/client-logs'); + renderClientLogs([]); + return result; + }); + $('btn-scenario-save').onclick = saveCurrentScenario; $('btn-scenario-load').onclick = loadSelectedScenario; $('btn-scenario-delete').onclick = deleteSelectedScenario; @@ -4228,7 +4665,10 @@ const devWorkbenchHTML = ` $('btn-scenario-import').onclick = importScenarioFromJSON; $('btn-flow-home').onclick = () => run('flow-home', async () => { - await request('POST', '/dev/bootstrap-demo'); + const bootstrap = await request('POST', '/dev/bootstrap-demo'); + if (bootstrap.data) { + applyBootstrapContext(bootstrap.data); + } const login = await request('POST', '/auth/login/wechat-mini', { code: $('wechat-code').value, clientType: 'wechat', @@ -4241,43 +4681,70 @@ const devWorkbenchHTML = ` $('btn-bootstrap').onclick = () => run('bootstrap-demo', async () => { const result = await request('POST', '/dev/bootstrap-demo'); - state.sourceId = result.data.sourceId || ''; - state.buildId = result.data.buildId || ''; - state.releaseId = result.data.releaseId || state.releaseId || ''; - if (result.data.releaseId) { - $('event-release-id').value = result.data.releaseId; - } - $('prod-runtime-event-id').value = result.data.eventId || $('prod-runtime-event-id').value; - $('prod-place-id').value = result.data.placeId || $('prod-place-id').value; - $('prod-map-asset-id').value = result.data.mapAssetId || $('prod-map-asset-id').value; - $('prod-tile-release-id').value = result.data.tileReleaseId || $('prod-tile-release-id').value; - $('prod-course-source-id').value = result.data.courseSourceId || $('prod-course-source-id').value; - $('prod-course-set-id').value = result.data.courseSetId || $('prod-course-set-id').value; - $('prod-course-variant-id').value = result.data.courseVariantId || $('prod-course-variant-id').value; - $('prod-runtime-binding-id').value = result.data.runtimeBindingId || $('prod-runtime-binding-id').value; + applyBootstrapContext(result.data); + return result; + }); + + $('btn-use-classic-demo').onclick = () => run('use-classic-demo', async () => { + const result = await request('POST', '/dev/bootstrap-demo'); + applyFrontendDemoSelection({ + eventId: result.data.eventId || 'evt_demo_001', + releaseId: result.data.releaseId || 'rel_demo_001', + localConfigFile: 'classic-sequential.json', + gameModeCode: 'classic-sequential', + sourceId: result.data.sourceId || '', + buildId: result.data.buildId || '', + courseSetId: result.data.courseSetId || '', + courseVariantId: result.data.courseVariantId || '', + runtimeBindingId: result.data.runtimeBindingId || '', + logTitle: 'classic-demo-ready', + statusText: 'ok: classic demo loaded' + }); + return result; + }); + + $('btn-use-score-o-demo').onclick = () => run('use-score-o-demo', async () => { + const result = await request('POST', '/dev/bootstrap-demo'); + applyFrontendDemoSelection({ + eventId: result.data.scoreOEventId || 'evt_demo_score_o_001', + releaseId: result.data.scoreOReleaseId || 'rel_demo_score_o_001', + localConfigFile: 'score-o.json', + gameModeCode: 'score-o', + sourceId: result.data.scoreOSourceId || '', + buildId: result.data.scoreOBuildId || '', + courseSetId: result.data.scoreOCourseSetId || '', + courseVariantId: result.data.scoreOCourseVariantId || '', + runtimeBindingId: result.data.scoreORuntimeBindingId || '', + logTitle: 'score-o-demo-ready', + statusText: 'ok: score-o demo loaded' + }); return result; }); $('btn-use-variant-manual-demo').onclick = () => run('use-variant-manual-demo', async () => { const result = await request('POST', '/dev/bootstrap-demo'); - $('entry-channel-code').value = 'mini-demo'; - $('entry-channel-type').value = 'wechat_mini'; - $('event-id').value = result.data.variantManualEventId || 'evt_demo_variant_manual_001'; - $('event-release-id').value = result.data.variantManualReleaseId || 'rel_demo_variant_manual_001'; - $('event-variant-id').value = 'variant_b'; - localStorage.setItem(MODE_KEY, 'frontend'); - syncWorkbenchMode(); - writeLog('variant-manual-demo-ready', { - eventId: $('event-id').value, - releaseId: $('event-release-id').value, - variantId: $('event-variant-id').value + applyFrontendDemoSelection({ + eventId: result.data.variantManualEventId || 'evt_demo_variant_manual_001', + releaseId: result.data.variantManualReleaseId || 'rel_demo_variant_manual_001', + variantId: 'variant_b', + localConfigFile: 'classic-sequential.json', + gameModeCode: 'classic-sequential', + sourceId: result.data.sourceId || '', + buildId: result.data.buildId || '', + courseSetId: result.data.courseSetId || '', + courseVariantId: result.data.courseVariantId || '', + runtimeBindingId: '', + logTitle: 'variant-manual-demo-ready', + statusText: 'ok: manual variant demo loaded' }); - setStatus('ok: manual variant demo loaded'); return result; }); $('btn-flow-launch').onclick = () => run('flow-launch', async () => { - await request('POST', '/dev/bootstrap-demo'); + const bootstrap = await request('POST', '/dev/bootstrap-demo'); + if (bootstrap.data) { + applyBootstrapContext(bootstrap.data); + } const smsSend = await request('POST', '/auth/sms/send', { countryCode: $('sms-country').value, mobile: $('sms-mobile').value, @@ -4388,6 +4855,7 @@ const devWorkbenchHTML = ` renderScenarioOptions(); applyAPIFilter(); syncAPICounts(); + renderClientLogs([]); writeLog('workbench-ready', { ok: true, hint: 'Use Bootstrap Demo first on a fresh database.' }); diff --git a/backend/internal/httpapi/router.go b/backend/internal/httpapi/router.go index dc1948e..d642b41 100644 --- a/backend/internal/httpapi/router.go +++ b/backend/internal/httpapi/router.go @@ -105,6 +105,10 @@ func NewRouter( if appEnv != "production" { mux.HandleFunc("GET /dev/workbench", devHandler.Workbench) mux.HandleFunc("POST /dev/bootstrap-demo", devHandler.BootstrapDemo) + mux.HandleFunc("POST /dev/client-logs", devHandler.CreateClientLog) + mux.HandleFunc("GET /dev/client-logs", devHandler.ListClientLogs) + mux.HandleFunc("DELETE /dev/client-logs", devHandler.ClearClientLogs) + mux.HandleFunc("GET /dev/manifest-summary", devHandler.ManifestSummary) mux.HandleFunc("GET /dev/config/local-files", configHandler.ListLocalFiles) mux.HandleFunc("POST /dev/events/{eventPublicID}/config-sources/import-local", configHandler.ImportLocal) mux.HandleFunc("POST /dev/config-builds/preview", configHandler.BuildPreview) diff --git a/backend/internal/service/dev_service.go b/backend/internal/service/dev_service.go index 92050d7..d9043b8 100644 --- a/backend/internal/service/dev_service.go +++ b/backend/internal/service/dev_service.go @@ -3,6 +3,9 @@ package service import ( "context" "net/http" + "sort" + "sync" + "time" "cmr-backend/internal/apperr" "cmr-backend/internal/store/postgres" @@ -11,6 +14,39 @@ import ( type DevService struct { appEnv string store *postgres.Store + mu sync.Mutex + logSeq int64 + logs []ClientDebugLogEntry +} + +type ClientDebugLogEntry struct { + ID int64 `json:"id"` + Source string `json:"source"` + Level string `json:"level"` + Category string `json:"category,omitempty"` + Message string `json:"message"` + EventID string `json:"eventId,omitempty"` + ReleaseID string `json:"releaseId,omitempty"` + SessionID string `json:"sessionId,omitempty"` + ManifestURL string `json:"manifestUrl,omitempty"` + Route string `json:"route,omitempty"` + OccurredAt time.Time `json:"occurredAt"` + ReceivedAt time.Time `json:"receivedAt"` + Details map[string]any `json:"details,omitempty"` +} + +type CreateClientDebugLogInput struct { + Source string `json:"source"` + Level string `json:"level"` + Category string `json:"category"` + Message string `json:"message"` + EventID string `json:"eventId"` + ReleaseID string `json:"releaseId"` + SessionID string `json:"sessionId"` + ManifestURL string `json:"manifestUrl"` + Route string `json:"route"` + OccurredAt string `json:"occurredAt"` + Details map[string]any `json:"details"` } func NewDevService(appEnv string, store *postgres.Store) *DevService { @@ -30,3 +66,83 @@ func (s *DevService) BootstrapDemo(ctx context.Context) (*postgres.DemoBootstrap } return s.store.EnsureDemoData(ctx) } + +func (s *DevService) AddClientDebugLog(_ context.Context, input CreateClientDebugLogInput) (*ClientDebugLogEntry, error) { + if !s.Enabled() { + return nil, apperr.New(http.StatusNotFound, "not_found", "dev client logs are disabled") + } + if input.Message == "" { + return nil, apperr.New(http.StatusBadRequest, "invalid_request", "message is required") + } + if input.Source == "" { + input.Source = "unknown" + } + if input.Level == "" { + input.Level = "info" + } + + occurredAt := time.Now().UTC() + if input.OccurredAt != "" { + parsed, err := time.Parse(time.RFC3339, input.OccurredAt) + if err != nil { + return nil, apperr.New(http.StatusBadRequest, "invalid_request", "occurredAt must be RFC3339") + } + occurredAt = parsed.UTC() + } + + entry := ClientDebugLogEntry{ + Source: input.Source, + Level: input.Level, + Category: input.Category, + Message: input.Message, + EventID: input.EventID, + ReleaseID: input.ReleaseID, + SessionID: input.SessionID, + ManifestURL: input.ManifestURL, + Route: input.Route, + OccurredAt: occurredAt, + ReceivedAt: time.Now().UTC(), + Details: input.Details, + } + + s.mu.Lock() + defer s.mu.Unlock() + s.logSeq++ + entry.ID = s.logSeq + s.logs = append(s.logs, entry) + if len(s.logs) > 200 { + s.logs = append([]ClientDebugLogEntry(nil), s.logs[len(s.logs)-200:]...) + } + copyEntry := entry + return ©Entry, nil +} + +func (s *DevService) ListClientDebugLogs(_ context.Context, limit int) ([]ClientDebugLogEntry, error) { + if !s.Enabled() { + return nil, apperr.New(http.StatusNotFound, "not_found", "dev client logs are disabled") + } + if limit <= 0 || limit > 200 { + limit = 50 + } + + s.mu.Lock() + defer s.mu.Unlock() + items := append([]ClientDebugLogEntry(nil), s.logs...) + sort.Slice(items, func(i, j int) bool { + return items[i].ID > items[j].ID + }) + if len(items) > limit { + items = items[:limit] + } + return items, nil +} + +func (s *DevService) ClearClientDebugLogs(_ context.Context) error { + if !s.Enabled() { + return apperr.New(http.StatusNotFound, "not_found", "dev client logs are disabled") + } + s.mu.Lock() + defer s.mu.Unlock() + s.logs = nil + return nil +} diff --git a/backend/internal/store/postgres/dev_store.go b/backend/internal/store/postgres/dev_store.go index 3ed2fd5..235d2ea 100644 --- a/backend/internal/store/postgres/dev_store.go +++ b/backend/internal/store/postgres/dev_store.go @@ -6,24 +6,32 @@ import ( ) type DemoBootstrapSummary struct { - TenantCode string `json:"tenantCode"` - ChannelCode string `json:"channelCode"` - EventID string `json:"eventId"` - ReleaseID string `json:"releaseId"` - SourceID string `json:"sourceId"` - BuildID string `json:"buildId"` - CardID string `json:"cardId"` - PlaceID string `json:"placeId"` - MapAssetID string `json:"mapAssetId"` - TileReleaseID string `json:"tileReleaseId"` - CourseSourceID string `json:"courseSourceId"` - CourseSetID string `json:"courseSetId"` - CourseVariantID string `json:"courseVariantId"` - RuntimeBindingID string `json:"runtimeBindingId"` - VariantManualEventID string `json:"variantManualEventId"` - VariantManualRelease string `json:"variantManualReleaseId"` - VariantManualCardID string `json:"variantManualCardId"` - CleanedSessionCount int64 `json:"cleanedSessionCount"` + TenantCode string `json:"tenantCode"` + ChannelCode string `json:"channelCode"` + EventID string `json:"eventId"` + ReleaseID string `json:"releaseId"` + SourceID string `json:"sourceId"` + BuildID string `json:"buildId"` + CardID string `json:"cardId"` + PlaceID string `json:"placeId"` + MapAssetID string `json:"mapAssetId"` + TileReleaseID string `json:"tileReleaseId"` + CourseSourceID string `json:"courseSourceId"` + CourseSetID string `json:"courseSetId"` + CourseVariantID string `json:"courseVariantId"` + RuntimeBindingID string `json:"runtimeBindingId"` + ScoreOEventID string `json:"scoreOEventId"` + ScoreOReleaseID string `json:"scoreOReleaseId"` + ScoreOCardID string `json:"scoreOCardId"` + ScoreOSourceID string `json:"scoreOSourceId"` + ScoreOBuildID string `json:"scoreOBuildId"` + ScoreOCourseSetID string `json:"scoreOCourseSetId"` + ScoreOCourseVariantID string `json:"scoreOCourseVariantId"` + ScoreORuntimeBindingID string `json:"scoreORuntimeBindingId"` + VariantManualEventID string `json:"variantManualEventId"` + VariantManualRelease string `json:"variantManualReleaseId"` + VariantManualCardID string `json:"variantManualCardId"` + CleanedSessionCount int64 `json:"cleanedSessionCount"` } func (s *Store) EnsureDemoData(ctx context.Context) (*DemoBootstrapSummary, error) { @@ -361,7 +369,7 @@ func (s *Store) EnsureDemoData(ctx context.Context) (*DemoBootstrapSummary, erro ) VALUES ( 'tile_demo_001', $1, 'v2026-04-03', 'published', - 'https://example.com/tiles/demo/', 'https://example.com/tiles/demo/meta.json', NOW() + 'https://oss-mbh5.colormaprun.com/gotomars/map/lxcb-001/tiles/', 'https://oss-mbh5.colormaprun.com/gotomars/map/lxcb-001/tiles/meta.json', NOW() ) ON CONFLICT (map_asset_id, version_code) DO UPDATE SET status = EXCLUDED.status, @@ -387,7 +395,7 @@ func (s *Store) EnsureDemoData(ctx context.Context) (*DemoBootstrapSummary, erro course_source_public_id, source_type, file_url, import_status ) VALUES ( - 'csource_demo_001', 'kml', 'https://example.com/course/demo.kml', 'imported' + 'csource_demo_001', 'kml', 'https://oss-mbh5.colormaprun.com/gotomars/kml/lxcb-001/10/c01.kml', 'imported' ) ON CONFLICT (course_source_public_id) DO UPDATE SET source_type = EXCLUDED.source_type, @@ -398,6 +406,23 @@ func (s *Store) EnsureDemoData(ctx context.Context) (*DemoBootstrapSummary, erro return nil, fmt.Errorf("ensure demo course source: %w", err) } + var courseSourceVariantBID string + if err := tx.QueryRow(ctx, ` + INSERT INTO course_sources ( + course_source_public_id, source_type, file_url, import_status + ) + VALUES ( + 'csource_demo_002', 'kml', 'https://oss-mbh5.colormaprun.com/gotomars/kml/lxcb-001/10/c02.kml', 'imported' + ) + ON CONFLICT (course_source_public_id) DO UPDATE SET + source_type = EXCLUDED.source_type, + file_url = EXCLUDED.file_url, + import_status = EXCLUDED.import_status + RETURNING id, course_source_public_id + `).Scan(&courseSourceVariantBID, new(string)); err != nil { + return nil, fmt.Errorf("ensure demo course source variant b: %w", err) + } + var courseSetID, courseSetPublicID string if err := tx.QueryRow(ctx, ` INSERT INTO course_sets ( @@ -439,6 +464,28 @@ func (s *Store) EnsureDemoData(ctx context.Context) (*DemoBootstrapSummary, erro return nil, fmt.Errorf("ensure demo course variant: %w", err) } + var courseVariantBID string + if err := tx.QueryRow(ctx, ` + INSERT INTO course_variants ( + course_variant_public_id, course_set_id, source_id, name, route_code, mode, control_count, status, is_default + ) + VALUES ( + 'cvariant_demo_002', $1, $2, 'Demo Variant B', 'route-demo-b', 'classic-sequential', 10, 'active', false + ) + ON CONFLICT (course_variant_public_id) DO UPDATE SET + course_set_id = EXCLUDED.course_set_id, + source_id = EXCLUDED.source_id, + name = EXCLUDED.name, + route_code = EXCLUDED.route_code, + mode = EXCLUDED.mode, + control_count = EXCLUDED.control_count, + status = EXCLUDED.status, + is_default = EXCLUDED.is_default + RETURNING id, course_variant_public_id + `, courseSetID, courseSourceVariantBID).Scan(&courseVariantBID, new(string)); err != nil { + return nil, fmt.Errorf("ensure demo course variant b: %w", err) + } + if _, err := tx.Exec(ctx, ` UPDATE course_sets SET current_variant_id = $2 @@ -529,14 +576,14 @@ func (s *Store) EnsureDemoData(ctx context.Context) (*DemoBootstrapSummary, erro { "id": "variant_a", "name": "A 线", - "description": "短线体验版", + "description": "短线体验版(c01.kml)", "routeCode": "route-variant-a", "selectable": true }, { "id": "variant_b", "name": "B 线", - "description": "长线挑战版", + "description": "长线挑战版(c02.kml)", "routeCode": "route-variant-b", "selectable": true } @@ -598,6 +645,273 @@ func (s *Store) EnsureDemoData(ctx context.Context) (*DemoBootstrapSummary, erro return nil, fmt.Errorf("ensure variant manual demo card: %w", err) } + var scoreOEventID string + if err := tx.QueryRow(ctx, ` + INSERT INTO events ( + tenant_id, event_public_id, slug, display_name, summary, status + ) + VALUES ($1, 'evt_demo_score_o_001', 'demo-score-o-run', 'Demo Score-O Run', '积分赛联调活动', 'active') + ON CONFLICT (event_public_id) DO UPDATE SET + tenant_id = EXCLUDED.tenant_id, + slug = EXCLUDED.slug, + display_name = EXCLUDED.display_name, + summary = EXCLUDED.summary, + status = EXCLUDED.status + RETURNING id + `, tenantID).Scan(&scoreOEventID); err != nil { + return nil, fmt.Errorf("ensure score-o demo event: %w", err) + } + + var scoreOReleaseRow struct { + ID string + PublicID string + } + if err := tx.QueryRow(ctx, ` + INSERT INTO event_releases ( + release_public_id, + event_id, + release_no, + config_label, + manifest_url, + manifest_checksum_sha256, + route_code, + status + ) + VALUES ( + 'rel_demo_score_o_001', + $1, + 1, + 'Demo Score-O Config v1', + 'https://oss-mbh5.colormaprun.com/gotomars/event/score-o.json', + 'demo-score-o-checksum-001', + 'route-score-o-001', + 'published' + ) + ON CONFLICT (release_public_id) DO UPDATE SET + event_id = EXCLUDED.event_id, + config_label = EXCLUDED.config_label, + manifest_url = EXCLUDED.manifest_url, + manifest_checksum_sha256 = EXCLUDED.manifest_checksum_sha256, + route_code = EXCLUDED.route_code, + status = EXCLUDED.status + RETURNING id, release_public_id + `, scoreOEventID).Scan(&scoreOReleaseRow.ID, &scoreOReleaseRow.PublicID); err != nil { + return nil, fmt.Errorf("ensure score-o demo release: %w", err) + } + + if _, err := tx.Exec(ctx, ` + UPDATE events + SET current_release_id = $2 + WHERE id = $1 + `, scoreOEventID, scoreOReleaseRow.ID); err != nil { + return nil, fmt.Errorf("attach score-o demo release: %w", err) + } + + scoreOSourceNotes := "demo source config imported from local event sample score-o" + scoreOSource, err := s.UpsertEventConfigSource(ctx, tx, UpsertEventConfigSourceParams{ + EventID: scoreOEventID, + SourceVersionNo: 1, + SourceKind: "event_bundle", + SchemaID: "event-source", + SchemaVersion: "1", + Status: "active", + Notes: &scoreOSourceNotes, + Source: map[string]any{ + "schemaVersion": "1", + "app": map[string]any{ + "id": "sample-score-o-001", + "title": "积分赛示例", + }, + "branding": map[string]any{ + "tenantCode": "tenant_demo", + "entryChannel": "mini-demo", + }, + "map": map[string]any{ + "tiles": "../map/lxcb-001/tiles/", + "mapmeta": "../map/lxcb-001/tiles/meta.json", + }, + "playfield": map[string]any{ + "kind": "control-set", + "source": map[string]any{ + "type": "kml", + "url": "../kml/lxcb-001/10/c01.kml", + }, + }, + "game": map[string]any{ + "mode": "score-o", + }, + "content": map[string]any{ + "h5Template": "content-h5-test-template.html", + }, + }, + }) + if err != nil { + return nil, fmt.Errorf("ensure score-o demo event config source: %w", err) + } + + scoreOBuildLog := "demo build generated from sample score-o.json" + scoreOBuild, err := s.UpsertEventConfigBuild(ctx, tx, UpsertEventConfigBuildParams{ + EventID: scoreOEventID, + SourceID: scoreOSource.ID, + BuildNo: 1, + BuildStatus: "success", + BuildLog: &scoreOBuildLog, + Manifest: map[string]any{ + "schemaVersion": "1", + "releaseId": "rel_demo_score_o_001", + "version": "2026.04.01", + "app": map[string]any{ + "id": "sample-score-o-001", + "title": "积分赛示例", + }, + "map": map[string]any{ + "tiles": "https://oss-mbh5.colormaprun.com/gotomars/map/lxcb-001/tiles/", + "mapmeta": "https://oss-mbh5.colormaprun.com/gotomars/map/lxcb-001/tiles/meta.json", + }, + "playfield": map[string]any{ + "kind": "control-set", + "source": map[string]any{ + "type": "kml", + "url": "https://oss-mbh5.colormaprun.com/gotomars/kml/lxcb-001/10/c01.kml", + }, + }, + "game": map[string]any{ + "mode": "score-o", + }, + "assets": map[string]any{ + "contentHtml": "https://oss-mbh5.colormaprun.com/gotomars/event/content-h5-test-template.html", + }, + }, + AssetIndex: []map[string]any{ + {"assetType": "manifest", "assetKey": "manifest"}, + {"assetType": "mapmeta", "assetKey": "mapmeta"}, + {"assetType": "playfield", "assetKey": "playfield-kml"}, + {"assetType": "content_html", "assetKey": "content-html"}, + }, + }) + if err != nil { + return nil, fmt.Errorf("ensure score-o demo event config build: %w", err) + } + + if err := s.AttachBuildToRelease(ctx, tx, scoreOReleaseRow.ID, scoreOBuild.ID); err != nil { + return nil, fmt.Errorf("attach score-o demo build to release: %w", err) + } + + var scoreOCardPublicID string + if err := tx.QueryRow(ctx, ` + INSERT INTO cards ( + card_public_id, + tenant_id, + entry_channel_id, + card_type, + title, + subtitle, + cover_url, + event_id, + display_slot, + display_priority, + status + ) + VALUES ( + 'card_demo_score_o_001', + $1, + $2, + 'event', + 'Demo Score-O Run', + '积分赛联调入口', + 'https://oss-mbh5.colormaprun.com/gotomars/assets/demo-cover.jpg', + $3, + 'home_primary', + 98, + 'active' + ) + ON CONFLICT (card_public_id) DO UPDATE SET + tenant_id = EXCLUDED.tenant_id, + entry_channel_id = EXCLUDED.entry_channel_id, + card_type = EXCLUDED.card_type, + title = EXCLUDED.title, + subtitle = EXCLUDED.subtitle, + cover_url = EXCLUDED.cover_url, + event_id = EXCLUDED.event_id, + display_slot = EXCLUDED.display_slot, + display_priority = EXCLUDED.display_priority, + status = EXCLUDED.status + RETURNING card_public_id + `, tenantID, channelID, scoreOEventID).Scan(&scoreOCardPublicID); err != nil { + return nil, fmt.Errorf("ensure score-o demo card: %w", err) + } + + var scoreOCourseSetID, scoreOCourseSetPublicID string + if err := tx.QueryRow(ctx, ` + INSERT INTO course_sets ( + course_set_public_id, place_id, map_asset_id, code, mode, name, status + ) + VALUES ( + 'cset_demo_score_o_001', $1, $2, 'cset-demo-score-o-001', 'score-o', 'Demo Score-O Course Set', 'active' + ) + ON CONFLICT (code) DO UPDATE SET + place_id = EXCLUDED.place_id, + map_asset_id = EXCLUDED.map_asset_id, + mode = EXCLUDED.mode, + name = EXCLUDED.name, + status = EXCLUDED.status + RETURNING id, course_set_public_id + `, placeID, mapAssetID).Scan(&scoreOCourseSetID, &scoreOCourseSetPublicID); err != nil { + return nil, fmt.Errorf("ensure score-o demo course set: %w", err) + } + + var scoreOCourseVariantID, scoreOCourseVariantPublicID string + if err := tx.QueryRow(ctx, ` + INSERT INTO course_variants ( + course_variant_public_id, course_set_id, source_id, name, route_code, mode, control_count, status, is_default + ) + VALUES ( + 'cvariant_demo_score_o_001', $1, $2, 'Demo Score-O Variant', 'route-score-o-001', 'score-o', 10, 'active', true + ) + ON CONFLICT (course_variant_public_id) DO UPDATE SET + course_set_id = EXCLUDED.course_set_id, + source_id = EXCLUDED.source_id, + name = EXCLUDED.name, + route_code = EXCLUDED.route_code, + mode = EXCLUDED.mode, + control_count = EXCLUDED.control_count, + status = EXCLUDED.status, + is_default = EXCLUDED.is_default + RETURNING id, course_variant_public_id + `, scoreOCourseSetID, courseSourceID).Scan(&scoreOCourseVariantID, &scoreOCourseVariantPublicID); err != nil { + return nil, fmt.Errorf("ensure score-o demo course variant: %w", err) + } + + if _, err := tx.Exec(ctx, ` + UPDATE course_sets + SET current_variant_id = $2 + WHERE id = $1 + `, scoreOCourseSetID, scoreOCourseVariantID); err != nil { + return nil, fmt.Errorf("attach score-o demo course variant: %w", err) + } + + var scoreORuntimeBindingID, scoreORuntimeBindingPublicID string + if err := tx.QueryRow(ctx, ` + INSERT INTO map_runtime_bindings ( + runtime_binding_public_id, event_id, place_id, map_asset_id, tile_release_id, course_set_id, course_variant_id, status, notes + ) + VALUES ( + 'runtime_demo_score_o_001', $1, $2, $3, $4, $5, $6, 'active', 'demo score-o runtime binding' + ) + ON CONFLICT (runtime_binding_public_id) DO UPDATE SET + event_id = EXCLUDED.event_id, + place_id = EXCLUDED.place_id, + map_asset_id = EXCLUDED.map_asset_id, + tile_release_id = EXCLUDED.tile_release_id, + course_set_id = EXCLUDED.course_set_id, + course_variant_id = EXCLUDED.course_variant_id, + status = EXCLUDED.status, + notes = EXCLUDED.notes + RETURNING id, runtime_binding_public_id + `, scoreOEventID, placeID, mapAssetID, tileReleaseID, scoreOCourseSetID, scoreOCourseVariantID).Scan(&scoreORuntimeBindingID, &scoreORuntimeBindingPublicID); err != nil { + return nil, fmt.Errorf("ensure score-o demo runtime binding: %w", err) + } + var cleanedSessionCount int64 if err := tx.QueryRow(ctx, ` WITH cleaned AS ( @@ -611,7 +925,7 @@ func (s *Store) EnsureDemoData(ctx context.Context) (*DemoBootstrapSummary, erro RETURNING 1 ) SELECT COUNT(*) FROM cleaned - `, []string{eventID, manualEventID}).Scan(&cleanedSessionCount); err != nil { + `, []string{eventID, scoreOEventID, manualEventID}).Scan(&cleanedSessionCount); err != nil { return nil, fmt.Errorf("cleanup demo ongoing sessions: %w", err) } @@ -620,23 +934,31 @@ func (s *Store) EnsureDemoData(ctx context.Context) (*DemoBootstrapSummary, erro } return &DemoBootstrapSummary{ - TenantCode: "tenant_demo", - ChannelCode: "mini-demo", - EventID: "evt_demo_001", - ReleaseID: releaseRow.PublicID, - SourceID: source.ID, - BuildID: build.ID, - CardID: cardPublicID, - PlaceID: placePublicID, - MapAssetID: mapAssetPublicID, - TileReleaseID: tileReleasePublicID, - CourseSourceID: courseSourcePublicID, - CourseSetID: courseSetPublicID, - CourseVariantID: courseVariantPublicID, - RuntimeBindingID: runtimeBindingPublicID, - VariantManualEventID: "evt_demo_variant_manual_001", - VariantManualRelease: manualReleaseRow.PublicID, - VariantManualCardID: manualCardPublicID, - CleanedSessionCount: cleanedSessionCount, + TenantCode: "tenant_demo", + ChannelCode: "mini-demo", + EventID: "evt_demo_001", + ReleaseID: releaseRow.PublicID, + SourceID: source.ID, + BuildID: build.ID, + CardID: cardPublicID, + PlaceID: placePublicID, + MapAssetID: mapAssetPublicID, + TileReleaseID: tileReleasePublicID, + CourseSourceID: courseSourcePublicID, + CourseSetID: courseSetPublicID, + CourseVariantID: courseVariantPublicID, + RuntimeBindingID: runtimeBindingPublicID, + ScoreOEventID: "evt_demo_score_o_001", + ScoreOReleaseID: scoreOReleaseRow.PublicID, + ScoreOCardID: scoreOCardPublicID, + ScoreOSourceID: scoreOSource.ID, + ScoreOBuildID: scoreOBuild.ID, + ScoreOCourseSetID: scoreOCourseSetPublicID, + ScoreOCourseVariantID: scoreOCourseVariantPublicID, + ScoreORuntimeBindingID: scoreORuntimeBindingPublicID, + VariantManualEventID: "evt_demo_variant_manual_001", + VariantManualRelease: manualReleaseRow.PublicID, + VariantManualCardID: manualCardPublicID, + CleanedSessionCount: cleanedSessionCount, }, nil } diff --git a/doc/gameplay/程序默认规则基线.md b/doc/gameplay/程序默认规则基线.md index dd2664f..e7512dc 100644 --- a/doc/gameplay/程序默认规则基线.md +++ b/doc/gameplay/程序默认规则基线.md @@ -1,6 +1,6 @@ # 程序默认规则基线 -> 文档版本:v1.0 -> 最后更新:2026-04-02 08:28:05 +> 文档版本:v1.1 +> 最后更新:2026-04-03 20:40:00 本文档用于定义当前客户端在**不依赖活动配置细项**时,程序层应该内建的默认规则。 @@ -129,6 +129,7 @@ - 成功打开始点后开始计时 - 起点完成后只给短反馈,并更新引导和 HUD - 默认不弹白色开始卡 +- 从准备页进入地图即视为进入对局,不再额外要求点击开始按钮 - 默认不弹答题卡 ### 3.2 普通点 diff --git a/doc/gameplay/联调架构阶段总结.md b/doc/gameplay/联调架构阶段总结.md new file mode 100644 index 0000000..cd948e0 --- /dev/null +++ b/doc/gameplay/联调架构阶段总结.md @@ -0,0 +1,182 @@ +# 联调架构阶段总结 +> 文档版本:v1.0 +> 最后更新:2026-04-03 16:59:19 + +## 1. 当前结论 + +当前联调架构已经从“能联”升级为“可诊断、可回归、可收口”。 + +这次阶段性进步的核心不是多了几个接口,而是三条链一起立住了: + +1. 标准测试链 +2. 结构化诊断链 +3. 多线程协作链 + +也就是说,当前联调已经不再主要依赖: + +- 截图 +- 口头描述 +- 各自猜测 + +而是可以依赖统一入口、统一日志和统一回写口径来定位问题。 + +--- + +## 2. 标准测试链 + +backend 当前已经把联调入口收敛成标准路径: + +```text +Bootstrap Demo +-> 一键补齐 Runtime 并发布 +-> 一键标准回归 +-> play / launch / result / history 验证 +``` + +当前这条链的价值是: + +- 从空白环境直接起链 +- 不再手工预铺多份 demo 对象 +- 同一条测试链可以反复执行 +- 回归结果有统一出口 + +当前 workbench 已具备: + +- `Bootstrap Demo` +- `一键补齐 Runtime 并发布` +- `一键标准回归` +- `回归结果汇总` + +--- + +## 3. 稳定测试数据链 + +当前联调环境已经不再只靠临时假数据,而是开始切入更接近生产的真实输入。 + +当前已接入: + +- 真实 KML / 赛道文件 +- 真实地图资源 URL +- manual 多赛道双 KML 输入 +- 三类显式 demo 入口: + - `evt_demo_001` + - `evt_demo_score_o_001` + - `evt_demo_variant_manual_001` + +当前阶段这条链的意义是: + +- 前后端终于在测同一套对象 +- demo 数据不再漂 +- 联调结果更接近生产环境 + +--- + +## 4. 结构化诊断链 + +这次联调真正发生质变的关键,是结构化诊断口径已经建立。 + +backend 当前已提供: + +- 分步执行日志 +- 真实错误 +- stack +- 最后一次 curl +- 预期判定 +- `当前 Launch 实际配置摘要` + +frontend 当前已配合提供: + +- `POST /dev/client-logs` +- 首页、活动页、准备页、地图关键链路的主动上报 +- 更明确的本地诊断字段: + - `details.seq` + - `launchVariantId` + - `runtimeCourseVariantId` + +当前这条结构化诊断链意味着: + +- 不再只知道“失败了” +- 可以知道: + - 卡在哪一步 + - 当前 launch 实际拿到了什么 + - 前端消费到了什么 + - 是后端发布问题、前端消费问题,还是规则理解问题 + +这也是为什么最近某些问题反复修改多轮仍未命中,而补上结构化日志后能一次定位成功。 + +--- + +## 5. 多线程协作链 + +当前多线程联调已经形成稳定协作方式: + +- 总控 -> 后端: + - [t2b.md](D:/dev/cmr-mini/t2b.md) +- 后端 -> 总控: + - [b2t.md](D:/dev/cmr-mini/b2t.md) +- 总控 -> 前端: + - [t2f.md](D:/dev/cmr-mini/t2f.md) +- 前端 -> 总控: + - [f2t.md](D:/dev/cmr-mini/f2t.md) + +这条协作链的作用是: + +- 前后端不再互相口头转述 +- 总控能统一收口 +- 阶段性结论能及时沉淀回: + - [文档索引](D:/dev/cmr-mini/doc/文档索引.md) + - [readme-develop.md](D:/dev/cmr-mini/readme-develop.md) + +--- + +## 6. 当前阶段结论 + +可以把当前状态明确成: + +### 6.1 已完成 + +- 基础骨架 +- 活动运营域摘要第一刀 +- 联调标准化第一版 + +### 6.2 正在推进 + +- 真实输入替换 +- 更接近生产的联调环境 + +### 6.3 暂不启动 + +- 活动卡片(列表)产品化 +- 新玩家侧页面扩张 +- 更复杂后台运营功能 + +--- + +## 7. 下一步建议 + +当前下一步不再是继续搭骨架,而是继续把真实输入往活动层推进。 + +优先顺序建议: + +1. `content manifest` +2. `presentation schema` +3. 活动文案样例 + +同时继续保持: + +- 前端只做联调回归和小修 +- 后端继续保证一键回归链稳定 +- 排障优先看: + - `回归结果汇总` + - `当前 Launch 实际配置摘要` + - `前端调试日志` + +--- + +## 8. 一句话总结 + +当前联调架构已经从“人肉协作”升级成: + +**标准测试链 + 结构化诊断链 + 多线程协作链** + +这代表系统已经从“能跑”进入“可持续联调、可持续收口、可逐步逼近生产”的阶段。 diff --git a/doc/games/积分赛/规则说明文档.md b/doc/games/积分赛/规则说明文档.md index f2b0fb9..01d93de 100644 --- a/doc/games/积分赛/规则说明文档.md +++ b/doc/games/积分赛/规则说明文档.md @@ -1,6 +1,6 @@ # 积分赛规则说明文档 -> 文档版本:v1.0 -> 最后更新:2026-04-02 08:28:05 +> 文档版本:v1.1 +> 最后更新:2026-04-03 20:40:00 本文档用于定义 `score-o` 在**最小模板**下的系统默认规则,作为后续实现、联调和配置扩展的共同基线。 @@ -46,6 +46,7 @@ - 基础 HUD - 所有积分点和结束点默认不显示 - 页面提示玩家:需要先打开始点,比赛才会正式开始并开始计时 +- 从准备页进入地图即视为进入本局,不再额外要求点击开始按钮 ### 3.2 打开始点 diff --git a/doc/games/顺序打点/规则说明文档.md b/doc/games/顺序打点/规则说明文档.md index 5f240d6..16fb326 100644 --- a/doc/games/顺序打点/规则说明文档.md +++ b/doc/games/顺序打点/规则说明文档.md @@ -1,6 +1,6 @@ # 顺序打点规则说明文档 -> 文档版本:v1.0 -> 最后更新:2026-04-02 08:28:05 +> 文档版本:v1.1 +> 最后更新:2026-04-03 20:40:00 本文档用于定义 `classic-sequential` 在**最小模板**下的系统默认规则,作为后续实现、联调和配置扩展的共同基线。 @@ -46,6 +46,7 @@ - 基础 HUD - 普通控制点、终点、路线和腿线默认不显示 - 页面提示玩家:需要先打开始点,比赛才会正式开始并开始计时 +- 从准备页进入地图即视为进入本局,不再额外要求点击开始按钮 - 最小模板下,点击检查点默认不弹详情卡 ### 3.2 打开始点 diff --git a/doc/文档索引.md b/doc/文档索引.md index 6eb28d4..3c2be5d 100644 --- a/doc/文档索引.md +++ b/doc/文档索引.md @@ -1,6 +1,6 @@ # 文档索引 -> 文档版本:v1.3 -> 最后更新:2026-04-03 19:38:00 +> 文档版本:v1.4 +> 最后更新:2026-04-03 16:59:19 维护约定: @@ -44,6 +44,7 @@ - [多赛道 Variant 五层设计草案](/D:/dev/cmr-mini/doc/gameplay/多赛道Variant五层设计草案.md) - [多赛道 Variant 前后端最小契约](/D:/dev/cmr-mini/doc/gameplay/多赛道Variant前后端最小契约.md) - [多线程联调协作方式](/D:/dev/cmr-mini/doc/gameplay/多线程联调协作方式.md) +- [联调架构阶段总结](/D:/dev/cmr-mini/doc/gameplay/联调架构阶段总结.md) - [APP全局产品架构草案](/D:/dev/cmr-mini/doc/gameplay/APP全局产品架构草案.md) - [故障恢复机制](/D:/dev/cmr-mini/doc/gameplay/故障恢复机制.md) - [活动运营域摘要第一刀联调回归清单](/D:/dev/cmr-mini/doc/gameplay/活动运营域摘要第一刀联调回归清单.md) diff --git a/f2b.md b/f2b.md index 32941f5..2efc39f 100644 --- a/f2b.md +++ b/f2b.md @@ -1,6 +1,6 @@ # F2B 协作清单 -> 文档版本:v1.5 -> 最后更新:2026-04-03 20:02:00 +> 文档版本:v1.9 +> 最后更新:2026-04-03 16:45:26 说明: @@ -264,6 +264,87 @@ - 无 - 状态:已完成 +### F2B-D006 + +- 时间:2026-04-03 +- 提出方:前端 +- 当前事实: + - 已按 backend `B2F-028` 的排查口径补充前端诊断链,当前地图信息面板/赛后结果里可直接查看: + - `launch.config.configUrl` + - `launch.resolvedRelease.manifestUrl` + - `launch.config.releaseId` + - `launch.resolvedRelease.releaseId` + - 最终加载后的: + - `Schema版本` + - `场地类型(playfield.kind)` + - `模式编码(game.mode)` + - 当前只补了诊断与观测,没有改动正式 launch 主链 +- 需要对方确认什么: + - 无 +- 状态:已完成 + +### F2B-D007 + +- 时间:2026-04-03 16:26:37 +- 提出方:前端 +- 当前事实: + - 已按 `B2F-030` 接入 backend `POST /dev/client-logs` + - 当前关键阶段会主动上报最小调试日志: + - `entry-home` + - `event-play` + - `event-prepare` + - `launch-diagnostic` + - `runtime-compiler` + - `session-recovery` + - 当前主日志字段已按 backend 建议最小口径回传: + - `source` + - `level` + - `category` + - `message` + - `eventId` + - `releaseId` + - `sessionId` + - `manifestUrl` + - `route` + - `details.phase` + - `details.schemaVersion` + - `details.playfield.kind` + - `details.game.mode` + - 模拟器日志不再作为当前联调主诊断口,保留地图内调试面板作为本地辅助能力 +- 需要对方确认什么: + - 无 +- 状态:已完成 + +### F2B-D008 + +- 时间:2026-04-03 16:45:26 +- 提出方:前端 +- 当前事实: + - backend 已通过 `B2F-031` 明确确认:积分赛误进顺序赛的根因不是前端解析,而是首页卡片入口配置错误 + - 具体根因为: + - 首页卡片查询此前只取 `home_primary` + - 积分赛 demo 卡此前被种到 `home_secondary` + - 前端首页因此根本拿不到 `evt_demo_score_o_001` + - backend 已修复积分赛卡片入口配置 + - 前端当前无需再为该问题修改玩法解析或 manifest 消费逻辑 +- 需要对方确认什么: + - 无 +- 状态:已完成 + +### F2B-D009 + +- 时间:2026-04-03 16:45:26 +- 提出方:前端 +- 当前事实: + - 已按 `B2F-032` 优化前端结构化调试日志口径: + - 非多赛道玩法时,不再上报空字符串形式的 `assignmentMode` + - 非手选赛道时,不再把空 `variantId` 伪装成已选赛道 + - 所有 client log 现在都会附带前端本地递增 `details.seq` + - `launchVariantId` 与 `runtimeCourseVariantId` 已明确区分 +- 需要对方确认什么: + - 无 +- 状态:已完成 + --- ## 下一步 @@ -311,4 +392,15 @@ - 无 - 状态:前端执行中 +### F2B-N005 + +- 时间:2026-04-03 +- 提出方:前端 +- 当前事实: + - 当前已具备积分赛 demo 发布链诊断信息,下一步将按 backend 一键测试环境回归 `evt_demo_score_o_001` + - 如仍表现为顺序赛,前端将回传 launch/config/runtime 三段事实,不再只报“现象” +- 需要对方确认什么: + - 无 +- 状态:前端执行中 + diff --git a/f2t.md b/f2t.md index 1e69306..35795e8 100644 --- a/f2t.md +++ b/f2t.md @@ -1,6 +1,6 @@ # F2T 协作清单 -> 文档版本:v1.7 -> 最后更新:2026-04-03 19:48:00 +> 文档版本:v1.9 +> 最后更新:2026-04-03 16:45:26 说明: @@ -172,6 +172,41 @@ - 无 - 是否已解决:是 +### F2T-D006 + +- 时间:2026-04-03 16:26:37 +- 谁提的:frontend +- 当前事实: + - 已按 backend 新增 dev 调试接口切换当前联调诊断主出口: + - `POST /dev/client-logs` + - 当前首页、活动页、准备页、地图关键链路会主动上报: + - `entry-home` + - `event-play` + - `event-prepare` + - `launch-diagnostic` + - `runtime-compiler` + - `session-recovery` + - 登录后自动连接模拟器日志的链路已撤掉 + - 地图内调试面板继续保留,仅作为本地开发辅助,不再作为当前联调主诊断口 +- 需要确认什么: + - 无 +- 是否已解决:是 + +### F2T-D007 + +- 时间:2026-04-03 16:45:26 +- 谁提的:frontend +- 当前事实: + - backend 已确认积分赛误进顺序赛的根因在 backend demo 首页卡片入口配置,不在前端玩法解析 + - 前端本轮未再修改 runtime / manifest 消费主链 + - 前端仅补了联调日志口径优化: + - 非多赛道玩法不再上报空字符串 `assignmentMode` + - 日志新增前端本地递增 `details.seq` + - `launchVariantId` 与 `runtimeCourseVariantId` 明确区分 +- 需要确认什么: + - 无 +- 是否已解决:是 + --- ## 下一步 diff --git a/miniprogram/engine/map/mapEngine.ts b/miniprogram/engine/map/mapEngine.ts index d70c25c..537676c 100644 --- a/miniprogram/engine/map/mapEngine.ts +++ b/miniprogram/engine/map/mapEngine.ts @@ -1108,6 +1108,7 @@ export class MapEngine { configAppId: string configSchemaVersion: string configVersion: string + playfieldKind: string controlScoreOverrides: Record controlContentOverrides: Record defaultControlContentOverride: GameControlDisplayContentOverride | null @@ -1417,6 +1418,7 @@ export class MapEngine { this.configAppId = '' this.configSchemaVersion = '1' this.configVersion = '' + this.playfieldKind = '' this.controlScoreOverrides = {} this.controlContentOverrides = {} this.defaultControlContentOverride = null @@ -1721,6 +1723,8 @@ export class MapEngine { { label: '比赛名称', value: title || '--' }, { label: '配置版本', value: this.configVersion || '--' }, { label: 'Schema版本', value: this.configSchemaVersion || '--' }, + { label: '场地类型', value: this.playfieldKind || '--' }, + { label: '模式编码', value: this.gameMode || '--' }, { label: '活动ID', value: this.configAppId || '--' }, { label: '动画等级', value: formatAnimationLevelText(this.state.animationLevel) }, { label: '地图', value: this.state.mapName || '--' }, @@ -3423,8 +3427,8 @@ export class MapEngine { this.courseOverlayVisible = true const gameModeText = this.gameMode === 'score-o' ? '积分赛' : '顺序打点' const defaultStatusText = this.currentGpsPoint - ? `${gameModeText}已开始 (${this.buildVersion})` - : `${gameModeText}已开始,GPS定位启动中 (${this.buildVersion})` + ? `已进入${gameModeText},请先打开始点 (${this.buildVersion})` + : `已进入${gameModeText},GPS定位启动中,请先打开始点 (${this.buildVersion})` this.commitGameResult(gameResult, defaultStatusText) } @@ -3683,6 +3687,15 @@ export class MapEngine { this.mockSimulatorDebugLogger.disconnect() } + handleEmitMockDebugLog( + scope: string, + level: 'info' | 'warn' | 'error', + message: string, + payload?: Record, + ): void { + this.mockSimulatorDebugLogger.log(scope, level, message, payload) + } + handleSetGameMode(nextMode: 'classic-sequential' | 'score-o'): void { if (this.gameMode === nextMode) { return @@ -3882,6 +3895,7 @@ export class MapEngine { this.configAppId = config.configAppId this.configSchemaVersion = config.configSchemaVersion this.configVersion = config.configVersion + this.playfieldKind = config.playfieldKind this.controlScoreOverrides = config.controlScoreOverrides this.controlContentOverrides = config.controlContentOverrides this.defaultControlContentOverride = config.defaultControlContentOverride diff --git a/miniprogram/game/rules/classicSequentialRule.ts b/miniprogram/game/rules/classicSequentialRule.ts index 14ca6b5..84e1698 100644 --- a/miniprogram/game/rules/classicSequentialRule.ts +++ b/miniprogram/game/rules/classicSequentialRule.ts @@ -114,7 +114,7 @@ function getGuidanceEffects( function buildPunchHintText(definition: GameDefinition, state: GameSessionState, currentTarget: GameControl | null): string { if (state.status === 'idle') { - return '点击开始后先打开始点' + return '先打开始点即可正式开始比赛' } if (state.status === 'finished') { diff --git a/miniprogram/game/rules/scoreORule.ts b/miniprogram/game/rules/scoreORule.ts index c824923..7766ffe 100644 --- a/miniprogram/game/rules/scoreORule.ts +++ b/miniprogram/game/rules/scoreORule.ts @@ -271,7 +271,7 @@ function buildPunchHintText( focusedTarget: GameControl | null, ): string { if (state.status === 'idle') { - return '点击开始后先打开始点' + return '先打开始点即可正式开始比赛' } if (state.status === 'finished') { diff --git a/miniprogram/pages/event-prepare/event-prepare.ts b/miniprogram/pages/event-prepare/event-prepare.ts index cd13710..1cb75d4 100644 --- a/miniprogram/pages/event-prepare/event-prepare.ts +++ b/miniprogram/pages/event-prepare/event-prepare.ts @@ -3,6 +3,7 @@ import { getEventPlay, launchEvent, type BackendCourseVariantSummary, type Backe import { adaptBackendLaunchResultToEnvelope } from '../../utils/backendLaunchAdapter' import { formatBackendPlayActionText, formatBackendPlayStatusText } from '../../utils/backendPlayCopy' import { prepareMapPageUrlForLaunch } from '../../utils/gameLaunch' +import { reportBackendClientLog } from '../../utils/backendClientLogs' import { HeartRateController } from '../../engine/sensor/heartRateController' const PREFERRED_HEART_RATE_DEVICE_STORAGE_KEY = 'cmr.preferredHeartRateDevice' @@ -290,12 +291,32 @@ Page({ result.play.assignmentMode, result.play.courseVariants, ) + const assignmentMode = result.play.assignmentMode ? result.play.assignmentMode : null + const logVariantId = assignmentMode === 'manual' && selectedVariantId ? selectedVariantId : null const selectableVariants = buildSelectableVariants( selectedVariantId, result.play.assignmentMode, result.play.courseVariants, ) const selectedVariant = selectableVariants.find((item) => item.id === selectedVariantId) || null + reportBackendClientLog({ + level: 'info', + category: 'event-prepare', + message: 'prepare play loaded', + eventId: result.event.id || this.data.eventId || '', + releaseId: result.resolvedRelease && result.resolvedRelease.releaseId + ? result.resolvedRelease.releaseId + : '', + manifestUrl: result.resolvedRelease && result.resolvedRelease.manifestUrl + ? result.resolvedRelease.manifestUrl + : '', + details: { + pageEventId: this.data.eventId || '', + resultEventId: result.event.id || '', + selectedVariantId: logVariantId, + assignmentMode, + }, + }) this.setData({ loading: false, titleText: `${result.event.displayName} / 开始前准备`, @@ -586,6 +607,22 @@ Page({ }) try { + const assignmentMode = this.data.assignmentMode ? this.data.assignmentMode : null + const selectedVariantId = assignmentMode === 'manual' && this.data.selectedVariantId + ? this.data.selectedVariantId + : null + reportBackendClientLog({ + level: 'info', + category: 'event-prepare', + message: 'launch requested', + eventId: this.data.eventId || '', + details: { + pageEventId: this.data.eventId || '', + selectedVariantId, + assignmentMode, + phase: 'launch-requested', + }, + }) const app = getApp() if (app.globalData) { const pendingDeviceName = prepareHeartRateController && prepareHeartRateController.currentDeviceName @@ -608,6 +645,32 @@ Page({ clientType: 'wechat', deviceKey: 'mini-dev-device-001', }) + reportBackendClientLog({ + level: 'info', + category: 'event-prepare', + message: 'launch response received', + eventId: result.launch.business && result.launch.business.eventId ? result.launch.business.eventId : this.data.eventId || '', + releaseId: result.launch.config && result.launch.config.releaseId ? result.launch.config.releaseId : '', + sessionId: result.launch.business && result.launch.business.sessionId ? result.launch.business.sessionId : '', + manifestUrl: result.launch.resolvedRelease && result.launch.resolvedRelease.manifestUrl + ? result.launch.resolvedRelease.manifestUrl + : '', + details: { + pageEventId: this.data.eventId || '', + launchEventId: result.launch.business && result.launch.business.eventId ? result.launch.business.eventId : '', + launchSessionId: result.launch.business && result.launch.business.sessionId ? result.launch.business.sessionId : '', + configUrl: result.launch.config && result.launch.config.configUrl ? result.launch.config.configUrl : '', + releaseId: result.launch.config && result.launch.config.releaseId ? result.launch.config.releaseId : '', + resolvedReleaseId: result.launch.resolvedRelease && result.launch.resolvedRelease.releaseId + ? result.launch.resolvedRelease.releaseId + : '', + resolvedManifestUrl: result.launch.resolvedRelease && result.launch.resolvedRelease.manifestUrl + ? result.launch.resolvedRelease.manifestUrl + : '', + launchVariantId: result.launch.variant && result.launch.variant.id ? result.launch.variant.id : null, + phase: 'launch-response', + }, + }) const envelope = adaptBackendLaunchResultToEnvelope(result) wx.navigateTo({ url: prepareMapPageUrlForLaunch(envelope), diff --git a/miniprogram/pages/event/event.ts b/miniprogram/pages/event/event.ts index 0b1ec8a..8df677b 100644 --- a/miniprogram/pages/event/event.ts +++ b/miniprogram/pages/event/event.ts @@ -1,6 +1,7 @@ import { loadBackendAuthTokens, loadBackendBaseUrl } from '../../utils/backendAuth' import { getEventPlay, type BackendEventPlayResult } from '../../utils/backendApi' import { formatBackendPlayActionText, formatBackendPlayStatusText } from '../../utils/backendPlayCopy' +import { reportBackendClientLog } from '../../utils/backendClientLogs' type EventPageData = { eventId: string @@ -130,6 +131,26 @@ Page({ }, applyEventPlay(result: BackendEventPlayResult) { + const assignmentMode = result.play.assignmentMode ? result.play.assignmentMode : null + reportBackendClientLog({ + level: 'info', + category: 'event-play', + message: 'event play loaded', + eventId: result.event.id || this.data.eventId || '', + releaseId: result.resolvedRelease && result.resolvedRelease.releaseId + ? result.resolvedRelease.releaseId + : '', + manifestUrl: result.resolvedRelease && result.resolvedRelease.manifestUrl + ? result.resolvedRelease.manifestUrl + : '', + details: { + pageEventId: this.data.eventId || '', + resultEventId: result.event.id || '', + primaryAction: result.play.primaryAction || '', + assignmentMode, + variantCount: result.play.courseVariants ? result.play.courseVariants.length : 0, + }, + }) this.setData({ loading: false, titleText: result.event.displayName, diff --git a/miniprogram/pages/home/home.ts b/miniprogram/pages/home/home.ts index 5607d4e..93d6b4c 100644 --- a/miniprogram/pages/home/home.ts +++ b/miniprogram/pages/home/home.ts @@ -1,5 +1,7 @@ import { clearBackendAuthTokens, loadBackendAuthTokens, loadBackendBaseUrl } from '../../utils/backendAuth' import { getEntryHome, type BackendCardResult, type BackendEntryHomeResult } from '../../utils/backendApi' +import { reportBackendClientLog } from '../../utils/backendClientLogs' +import { setGlobalMockDebugBridgeEnabled } from '../../utils/globalMockDebugBridge' const DEFAULT_CHANNEL_CODE = 'mini-demo' const DEFAULT_CHANNEL_TYPE = 'wechat_mini' @@ -100,6 +102,18 @@ Page({ }, applyEntryHomeResult(result: BackendEntryHomeResult) { + reportBackendClientLog({ + level: 'info', + category: 'entry-home', + message: 'entry home loaded', + details: { + ongoingSessionId: result.ongoingSession && result.ongoingSession.id ? result.ongoingSession.id : '', + ongoingEventId: result.ongoingSession && result.ongoingSession.eventId ? result.ongoingSession.eventId : '', + recentSessionId: result.recentSession && result.recentSession.id ? result.recentSession.id : '', + recentEventId: result.recentSession && result.recentSession.eventId ? result.recentSession.eventId : '', + cardEventIds: (result.cards || []).map((item) => (item.event && item.event.id ? item.event.id : '')), + }, + }) this.setData({ loading: false, statusText: '首页加载完成', @@ -141,6 +155,7 @@ Page({ handleLogout() { clearBackendAuthTokens() + setGlobalMockDebugBridgeEnabled(false) const app = getApp() if (app.globalData) { app.globalData.backendAuthTokens = null diff --git a/miniprogram/pages/login/login.ts b/miniprogram/pages/login/login.ts index 2d07fa0..77b0e85 100644 --- a/miniprogram/pages/login/login.ts +++ b/miniprogram/pages/login/login.ts @@ -1,5 +1,6 @@ import { clearBackendAuthTokens, saveBackendAuthTokens, saveBackendBaseUrl } from '../../utils/backendAuth' import { loginWechatMini } from '../../utils/backendApi' +import { setGlobalMockDebugBridgeEnabled } from '../../utils/globalMockDebugBridge' const DEFAULT_BACKEND_BASE_URL = 'https://api.gotomars.xyz' const DEFAULT_DEVICE_KEY = 'mini-dev-device-001' @@ -116,6 +117,7 @@ Page({ handleClearLoginState() { clearBackendAuthTokens() + setGlobalMockDebugBridgeEnabled(false) const app = getApp() if (app.globalData) { app.globalData.backendAuthTokens = null diff --git a/miniprogram/pages/map/map.ts b/miniprogram/pages/map/map.ts index 958039f..f087573 100644 --- a/miniprogram/pages/map/map.ts +++ b/miniprogram/pages/map/map.ts @@ -16,6 +16,13 @@ import { import { finishSession, startSession, type BackendSessionFinishSummaryPayload } from '../../utils/backendApi' import { loadBackendBaseUrl } from '../../utils/backendAuth' import { loadRemoteMapConfig, type RemoteMapConfig } from '../../utils/remoteMapConfig' +import { + persistStoredMockDebugLogBridgeUrl, + setGlobalMockDebugBridgeChannelId, + setGlobalMockDebugBridgeEnabled, + setGlobalMockDebugBridgeUrl, +} from '../../utils/globalMockDebugBridge' +import { reportBackendClientLog } from '../../utils/backendClientLogs' import { type H5ExperienceFallbackPayload, type H5ExperienceRequest } from '../../game/experience/h5Experience' import { type TrackColorPreset } from '../../game/presentation/trackStyleConfig' import { type GpsMarkerColorPreset } from '../../game/presentation/gpsMarkerStyleConfig' @@ -146,6 +153,7 @@ type MapPageData = MapEngineViewState & { showLeftButtonGroup: boolean showRightButtonGroups: boolean showBottomDebugButton: boolean + showStartEntryButton: boolean } function getGlobalTelemetryProfile(): PlayerTelemetryProfile | null { @@ -184,6 +192,7 @@ let systemSettingsLockLifetimeActive = false let syncedBackendSessionStartId = '' let syncedBackendSessionFinishId = '' let shouldAutoRestoreRecoverySnapshot = false +let shouldAutoStartSessionOnEnter = false let redirectedToResultPage = false let pendingHeartRateSwitchDeviceName: string | null = null const DEBUG_MOCK_CHANNEL_ID_STORAGE_KEY = 'cmr.debug.mockChannelId.v1' @@ -828,6 +837,52 @@ function buildRuntimeSummaryRows(envelope: GameLaunchEnvelope): MapEngineGameInf return rows } +function buildLaunchConfigSummaryRows(envelope: GameLaunchEnvelope): MapEngineGameInfoRow[] { + const rows: MapEngineGameInfoRow[] = [] + rows.push({ label: '配置标签', value: envelope.config.configLabel || '--' }) + rows.push({ label: '配置URL', value: envelope.config.configUrl || '--' }) + rows.push({ label: '配置Release', value: envelope.config.releaseId || '--' }) + rows.push({ + label: 'Launch Event', + value: envelope.business && envelope.business.eventId + ? envelope.business.eventId + : '--', + }) + rows.push({ + label: 'Resolved Manifest', + value: envelope.resolvedRelease && envelope.resolvedRelease.manifestUrl + ? envelope.resolvedRelease.manifestUrl + : '--', + }) + rows.push({ + label: 'Resolved Release', + value: envelope.resolvedRelease && envelope.resolvedRelease.releaseId + ? envelope.resolvedRelease.releaseId + : '--', + }) + return rows +} + +function emitSimulatorLaunchDiagnostic( + stage: string, + payload: Record, +) { + reportBackendClientLog({ + level: 'info', + category: 'launch-diagnostic', + message: stage, + eventId: typeof payload.launchEventId === 'string' ? payload.launchEventId : '', + releaseId: typeof payload.configReleaseId === 'string' + ? payload.configReleaseId + : (typeof payload.resolvedReleaseId === 'string' ? payload.resolvedReleaseId : ''), + sessionId: typeof payload.launchSessionId === 'string' ? payload.launchSessionId : '', + manifestUrl: typeof payload.resolvedManifestUrl === 'string' + ? payload.resolvedManifestUrl + : (typeof payload.configUrl === 'string' ? payload.configUrl : ''), + details: payload, + }) +} + Page({ data: { showDebugPanel: false, @@ -967,6 +1022,7 @@ Page({ centerScaleRulerMajorMarks: [], compassTicks: buildCompassTicks(), compassLabels: buildCompassLabels(), + showStartEntryButton: true, ...buildSideButtonVisibility('shown'), ...buildSideButtonState({ sideButtonMode: 'shown', @@ -989,10 +1045,15 @@ Page({ syncedBackendSessionFinishId = '' redirectedToResultPage = false shouldAutoRestoreRecoverySnapshot = options && options.recoverSession === '1' - currentGameLaunchEnvelope = resolveGameLaunchEnvelope(options) - if (!hasExplicitLaunchOptions(options)) { - const recoverySnapshot = loadSessionRecoverySnapshot() - if (recoverySnapshot) { + shouldAutoStartSessionOnEnter = !!(options && options.autoStartOnEnter === '1') + const recoverySnapshot = loadSessionRecoverySnapshot() + if (shouldAutoRestoreRecoverySnapshot && recoverySnapshot) { + // Recovery should trust the persisted session envelope first so it can + // survive launchId stash misses and still reconstruct the original round. + currentGameLaunchEnvelope = recoverySnapshot.launchEnvelope + } else { + currentGameLaunchEnvelope = resolveGameLaunchEnvelope(options) + if (!hasExplicitLaunchOptions(options) && recoverySnapshot) { currentGameLaunchEnvelope = recoverySnapshot.launchEnvelope } } @@ -1005,6 +1066,9 @@ Page({ const statusBarHeight = systemInfo.statusBarHeight || 0 const menuButtonRect = wx.getMenuButtonBoundingClientRect() const menuButtonBottom = menuButtonRect && typeof menuButtonRect.bottom === 'number' ? menuButtonRect.bottom : statusBarHeight + this.setData({ + showStartEntryButton: !shouldAutoStartSessionOnEnter, + }) if (mapEngine) { mapEngine.destroy() @@ -1514,11 +1578,27 @@ Page({ systemSettingsLockLifetimeActive = false currentGameLaunchEnvelope = getDemoGameLaunchEnvelope() shouldAutoRestoreRecoverySnapshot = false + shouldAutoStartSessionOnEnter = false redirectedToResultPage = false stageCanvasAttached = false }, loadGameLaunchEnvelope(envelope: GameLaunchEnvelope) { + emitSimulatorLaunchDiagnostic('loadGameLaunchEnvelope', { + launchEventId: envelope.business && envelope.business.eventId ? envelope.business.eventId : '', + launchSessionId: envelope.business && envelope.business.sessionId ? envelope.business.sessionId : '', + configUrl: envelope.config.configUrl || '', + configReleaseId: envelope.config.releaseId || '', + resolvedManifestUrl: envelope.resolvedRelease && envelope.resolvedRelease.manifestUrl + ? envelope.resolvedRelease.manifestUrl + : '', + resolvedReleaseId: envelope.resolvedRelease && envelope.resolvedRelease.releaseId + ? envelope.resolvedRelease.releaseId + : '', + launchVariantId: envelope.variant && envelope.variant.variantId ? envelope.variant.variantId : null, + launchVariantRouteCode: envelope.variant && envelope.variant.routeCode ? envelope.variant.routeCode : null, + runtimeCourseVariantId: envelope.runtime && envelope.runtime.courseVariantId ? envelope.runtime.courseVariantId : null, + }) this.loadMapConfigFromRemote( envelope.config.configUrl, envelope.config.configLabel, @@ -1621,10 +1701,49 @@ Page({ reportAbandonedRecoverySnapshot(snapshot: SessionRecoverySnapshot) { const sessionContext = getBackendSessionContextFromLaunchEnvelope(snapshot.launchEnvelope) if (!sessionContext) { + reportBackendClientLog({ + level: 'warn', + category: 'session-recovery', + message: 'abandon recovery without valid session context', + eventId: snapshot.launchEnvelope.business && snapshot.launchEnvelope.business.eventId + ? snapshot.launchEnvelope.business.eventId + : '', + releaseId: snapshot.launchEnvelope.config && snapshot.launchEnvelope.config.releaseId + ? snapshot.launchEnvelope.config.releaseId + : '', + manifestUrl: snapshot.launchEnvelope.resolvedRelease && snapshot.launchEnvelope.resolvedRelease.manifestUrl + ? snapshot.launchEnvelope.resolvedRelease.manifestUrl + : snapshot.launchEnvelope.config && snapshot.launchEnvelope.config.configUrl + ? snapshot.launchEnvelope.config.configUrl + : '', + details: { + phase: 'abandon-no-session', + }, + }) clearSessionRecoverySnapshot() return } + reportBackendClientLog({ + level: 'info', + category: 'session-recovery', + message: 'abandon recovery requested', + eventId: snapshot.launchEnvelope.business && snapshot.launchEnvelope.business.eventId + ? snapshot.launchEnvelope.business.eventId + : '', + releaseId: snapshot.launchEnvelope.config && snapshot.launchEnvelope.config.releaseId + ? snapshot.launchEnvelope.config.releaseId + : '', + sessionId: sessionContext.sessionId, + manifestUrl: snapshot.launchEnvelope.resolvedRelease && snapshot.launchEnvelope.resolvedRelease.manifestUrl + ? snapshot.launchEnvelope.resolvedRelease.manifestUrl + : snapshot.launchEnvelope.config && snapshot.launchEnvelope.config.configUrl + ? snapshot.launchEnvelope.config.configUrl + : '', + details: { + phase: 'abandon-requested', + }, + }) finishSession({ baseUrl: getCurrentBackendBaseUrl(), sessionId: sessionContext.sessionId, @@ -1634,6 +1753,26 @@ Page({ }) .then(() => { syncedBackendSessionFinishId = sessionContext.sessionId + reportBackendClientLog({ + level: 'info', + category: 'session-recovery', + message: 'abandon recovery synced as cancelled', + eventId: snapshot.launchEnvelope.business && snapshot.launchEnvelope.business.eventId + ? snapshot.launchEnvelope.business.eventId + : '', + releaseId: snapshot.launchEnvelope.config && snapshot.launchEnvelope.config.releaseId + ? snapshot.launchEnvelope.config.releaseId + : '', + sessionId: sessionContext.sessionId, + manifestUrl: snapshot.launchEnvelope.resolvedRelease && snapshot.launchEnvelope.resolvedRelease.manifestUrl + ? snapshot.launchEnvelope.resolvedRelease.manifestUrl + : snapshot.launchEnvelope.config && snapshot.launchEnvelope.config.configUrl + ? snapshot.launchEnvelope.config.configUrl + : '', + details: { + phase: 'abandon-finished', + }, + }) clearSessionRecoverySnapshot() wx.showToast({ title: '已放弃上次对局', @@ -1642,6 +1781,27 @@ Page({ }) }) .catch((error) => { + reportBackendClientLog({ + level: 'warn', + category: 'session-recovery', + message: 'abandon recovery finish(cancelled) failed', + eventId: snapshot.launchEnvelope.business && snapshot.launchEnvelope.business.eventId + ? snapshot.launchEnvelope.business.eventId + : '', + releaseId: snapshot.launchEnvelope.config && snapshot.launchEnvelope.config.releaseId + ? snapshot.launchEnvelope.config.releaseId + : '', + sessionId: sessionContext.sessionId, + manifestUrl: snapshot.launchEnvelope.resolvedRelease && snapshot.launchEnvelope.resolvedRelease.manifestUrl + ? snapshot.launchEnvelope.resolvedRelease.manifestUrl + : snapshot.launchEnvelope.config && snapshot.launchEnvelope.config.configUrl + ? snapshot.launchEnvelope.config.configUrl + : '', + details: { + phase: 'abandon-failed', + message: error && error.message ? error.message : '未知错误', + }, + }) clearSessionRecoverySnapshot() const message = error && error.message ? error.message : '未知错误' this.setData({ @@ -1712,6 +1872,28 @@ Page({ this.applyRuntimeSystemSettings(true) const restored = mapEngine ? mapEngine.restoreSessionRecoveryRuntimeSnapshot(snapshot.runtime) : false if (!restored) { + reportBackendClientLog({ + level: 'warn', + category: 'session-recovery', + message: 'recovery restore failed', + eventId: snapshot.launchEnvelope.business && snapshot.launchEnvelope.business.eventId + ? snapshot.launchEnvelope.business.eventId + : '', + releaseId: snapshot.launchEnvelope.config && snapshot.launchEnvelope.config.releaseId + ? snapshot.launchEnvelope.config.releaseId + : '', + sessionId: snapshot.launchEnvelope.business && snapshot.launchEnvelope.business.sessionId + ? snapshot.launchEnvelope.business.sessionId + : '', + manifestUrl: snapshot.launchEnvelope.resolvedRelease && snapshot.launchEnvelope.resolvedRelease.manifestUrl + ? snapshot.launchEnvelope.resolvedRelease.manifestUrl + : snapshot.launchEnvelope.config && snapshot.launchEnvelope.config.configUrl + ? snapshot.launchEnvelope.config.configUrl + : '', + details: { + phase: 'restore-failed', + }, + }) clearSessionRecoverySnapshot() wx.showToast({ title: '恢复失败,已回到初始状态', @@ -1726,11 +1908,34 @@ Page({ showDebugPanel: false, showGameInfoPanel: false, showSystemSettingsPanel: false, + showStartEntryButton: false, }) const sessionContext = getCurrentBackendSessionContext() if (sessionContext) { syncedBackendSessionStartId = sessionContext.sessionId } + reportBackendClientLog({ + level: 'info', + category: 'session-recovery', + message: 'recovery restored', + eventId: snapshot.launchEnvelope.business && snapshot.launchEnvelope.business.eventId + ? snapshot.launchEnvelope.business.eventId + : '', + releaseId: snapshot.launchEnvelope.config && snapshot.launchEnvelope.config.releaseId + ? snapshot.launchEnvelope.config.releaseId + : '', + sessionId: snapshot.launchEnvelope.business && snapshot.launchEnvelope.business.sessionId + ? snapshot.launchEnvelope.business.sessionId + : '', + manifestUrl: snapshot.launchEnvelope.resolvedRelease && snapshot.launchEnvelope.resolvedRelease.manifestUrl + ? snapshot.launchEnvelope.resolvedRelease.manifestUrl + : snapshot.launchEnvelope.config && snapshot.launchEnvelope.config.configUrl + ? snapshot.launchEnvelope.config.configUrl + : '', + details: { + phase: 'restored', + }, + }) this.syncSessionRecoveryLifecycle('running') return true }, @@ -1752,24 +1957,77 @@ Page({ maybePromptSessionRecoveryRestore(config: RemoteMapConfig) { const snapshot = loadSessionRecoverySnapshot() if (!snapshot || !mapEngine) { - return + return false } if ( snapshot.launchEnvelope.config.configUrl !== currentGameLaunchEnvelope.config.configUrl || snapshot.configAppId !== config.configAppId - || snapshot.configVersion !== config.configVersion ) { + reportBackendClientLog({ + level: 'warn', + category: 'session-recovery', + message: 'recovery snapshot dropped due to config mismatch', + eventId: snapshot.launchEnvelope.business && snapshot.launchEnvelope.business.eventId + ? snapshot.launchEnvelope.business.eventId + : '', + releaseId: snapshot.launchEnvelope.config && snapshot.launchEnvelope.config.releaseId + ? snapshot.launchEnvelope.config.releaseId + : '', + sessionId: snapshot.launchEnvelope.business && snapshot.launchEnvelope.business.sessionId + ? snapshot.launchEnvelope.business.sessionId + : '', + manifestUrl: snapshot.launchEnvelope.resolvedRelease && snapshot.launchEnvelope.resolvedRelease.manifestUrl + ? snapshot.launchEnvelope.resolvedRelease.manifestUrl + : snapshot.launchEnvelope.config && snapshot.launchEnvelope.config.configUrl + ? snapshot.launchEnvelope.config.configUrl + : '', + details: { + phase: 'config-mismatch', + currentConfigUrl: currentGameLaunchEnvelope.config.configUrl, + snapshotConfigUrl: snapshot.launchEnvelope.config.configUrl, + currentConfigAppId: config.configAppId, + snapshotConfigAppId: snapshot.configAppId, + }, + }) clearSessionRecoverySnapshot() - return + this.setData({ + statusText: '检测到旧局恢复记录,但当前配置源已变化,已回到初始状态', + }) + return false } if (shouldAutoRestoreRecoverySnapshot) { shouldAutoRestoreRecoverySnapshot = false + reportBackendClientLog({ + level: 'info', + category: 'session-recovery', + message: 'auto recovery requested', + eventId: snapshot.launchEnvelope.business && snapshot.launchEnvelope.business.eventId + ? snapshot.launchEnvelope.business.eventId + : '', + releaseId: snapshot.launchEnvelope.config && snapshot.launchEnvelope.config.releaseId + ? snapshot.launchEnvelope.config.releaseId + : '', + sessionId: snapshot.launchEnvelope.business && snapshot.launchEnvelope.business.sessionId + ? snapshot.launchEnvelope.business.sessionId + : '', + manifestUrl: snapshot.launchEnvelope.resolvedRelease && snapshot.launchEnvelope.resolvedRelease.manifestUrl + ? snapshot.launchEnvelope.resolvedRelease.manifestUrl + : snapshot.launchEnvelope.config && snapshot.launchEnvelope.config.configUrl + ? snapshot.launchEnvelope.config.configUrl + : '', + details: { + phase: 'auto-restore', + }, + }) this.restoreRecoverySnapshot(snapshot) - return + return true } + this.setData({ + showStartEntryButton: true, + }) wx.showModal({ title: '恢复对局', content: '检测到上次有未正常结束的对局,是否继续恢复?', @@ -1784,6 +2042,21 @@ Page({ this.restoreRecoverySnapshot(snapshot) }, }) + return true + }, + + maybeAutoStartSessionOnEnter() { + if (!shouldAutoStartSessionOnEnter || !mapEngine) { + return + } + + shouldAutoStartSessionOnEnter = false + systemSettingsLockLifetimeActive = true + this.applyRuntimeSystemSettings(true) + this.setData({ + showStartEntryButton: false, + }) + mapEngine.handleStartGame() }, compileCurrentRuntimeProfile(lockLifetimeActive = isSystemSettingsLockLifetimeActive()) { @@ -1913,20 +2186,76 @@ Page({ return } + emitSimulatorLaunchDiagnostic('loadRemoteMapConfig:resolved', { + launchEventId: currentGameLaunchEnvelope.business && currentGameLaunchEnvelope.business.eventId + ? currentGameLaunchEnvelope.business.eventId + : '', + configUrl, + configVersion: config.configVersion || '', + schemaVersion: config.configSchemaVersion || '', + playfieldKind: config.playfieldKind || '', + gameMode: config.gameMode || '', + configTitle: config.configTitle || '', + }) + currentEngine.applyRemoteMapConfig(config) this.applyConfiguredSystemSettings(config) - this.applyCompiledRuntimeProfiles(true, { + const compiledProfile = this.applyCompiledRuntimeProfiles(true, { includeMap: true, includeGame: true, includePresentation: true, }) - this.maybePromptSessionRecoveryRestore(config) + if (compiledProfile) { + reportBackendClientLog({ + level: 'info', + category: 'runtime-compiler', + message: 'compiled runtime profile applied', + eventId: currentGameLaunchEnvelope.business && currentGameLaunchEnvelope.business.eventId + ? currentGameLaunchEnvelope.business.eventId + : '', + releaseId: currentGameLaunchEnvelope.config && currentGameLaunchEnvelope.config.releaseId + ? currentGameLaunchEnvelope.config.releaseId + : '', + sessionId: currentGameLaunchEnvelope.business && currentGameLaunchEnvelope.business.sessionId + ? currentGameLaunchEnvelope.business.sessionId + : '', + manifestUrl: currentGameLaunchEnvelope.resolvedRelease && currentGameLaunchEnvelope.resolvedRelease.manifestUrl + ? currentGameLaunchEnvelope.resolvedRelease.manifestUrl + : currentGameLaunchEnvelope.config && currentGameLaunchEnvelope.config.configUrl + ? currentGameLaunchEnvelope.config.configUrl + : '', + details: { + phase: 'compiled-runtime-applied', + schemaVersion: config.configSchemaVersion || '', + playfield: { + kind: config.playfieldKind || '', + }, + game: { + mode: config.gameMode || '', + }, + }, + }) + } + const recoveryHandled = this.maybePromptSessionRecoveryRestore(config) + if (!recoveryHandled) { + this.maybeAutoStartSessionOnEnter() + } else { + shouldAutoStartSessionOnEnter = false + } }) .catch((error) => { if (mapEngine !== currentEngine) { return } + emitSimulatorLaunchDiagnostic('loadRemoteMapConfig:error', { + launchEventId: currentGameLaunchEnvelope.business && currentGameLaunchEnvelope.business.eventId + ? currentGameLaunchEnvelope.business.eventId + : '', + configUrl, + message: error && error.message ? error.message : '未知错误', + }) + const rawErrorMessage = error && error.message ? error.message : '未知错误' const errorMessage = rawErrorMessage.indexOf('404') >= 0 ? `release manifest 不存在或未发布 (${configLabel})` @@ -2115,6 +2444,10 @@ Page({ }) persistMockChannelId(channelId) persistMockAutoConnectEnabled(true) + setGlobalMockDebugBridgeChannelId(channelId) + setGlobalMockDebugBridgeUrl(this.data.mockDebugLogBridgeUrlDraft) + persistStoredMockDebugLogBridgeUrl(this.data.mockDebugLogBridgeUrlDraft) + setGlobalMockDebugBridgeEnabled(true) mapEngine.handleSetMockChannelId(channelId) mapEngine.handleSetMockLocationBridgeUrl(this.data.mockBridgeUrlDraft) mapEngine.handleSetMockHeartRateBridgeUrl(this.data.mockHeartRateBridgeUrlDraft) @@ -2144,6 +2477,7 @@ Page({ mockChannelIdDraft: channelId, }) persistMockChannelId(channelId) + setGlobalMockDebugBridgeChannelId(channelId) if (mapEngine) { mapEngine.handleSetMockChannelId(channelId) } @@ -2199,12 +2533,17 @@ Page({ }, handleSaveMockDebugLogBridgeUrl() { + persistStoredMockDebugLogBridgeUrl(this.data.mockDebugLogBridgeUrlDraft) + setGlobalMockDebugBridgeUrl(this.data.mockDebugLogBridgeUrlDraft) if (mapEngine) { mapEngine.handleSetMockDebugLogBridgeUrl(this.data.mockDebugLogBridgeUrlDraft) } }, handleConnectMockDebugLogBridge() { + setGlobalMockDebugBridgeChannelId((this.data.mockChannelIdDraft || '').trim() || 'default') + setGlobalMockDebugBridgeUrl(this.data.mockDebugLogBridgeUrlDraft) + setGlobalMockDebugBridgeEnabled(true) if (mapEngine) { mapEngine.handleConnectMockDebugLogBridge() } @@ -2212,6 +2551,7 @@ Page({ handleDisconnectMockDebugLogBridge() { persistMockAutoConnectEnabled(false) + setGlobalMockDebugBridgeEnabled(false) if (mapEngine) { mapEngine.handleDisconnectMockDebugLogBridge() } @@ -2358,8 +2698,12 @@ Page({ handleStartGame() { if (mapEngine) { + shouldAutoStartSessionOnEnter = false systemSettingsLockLifetimeActive = true this.applyRuntimeSystemSettings(true) + this.setData({ + showStartEntryButton: false, + }) mapEngine.handleStartGame() } }, @@ -2443,6 +2787,7 @@ Page({ const snapshot = mapEngine.getGameInfoSnapshot() const localRows = snapshot.localRows.concat([ ...buildRuntimeSummaryRows(currentGameLaunchEnvelope), + ...buildLaunchConfigSummaryRows(currentGameLaunchEnvelope), { label: '比例尺开关', value: this.data.showCenterScaleRuler ? '开启' : '关闭' }, { label: '比例尺锚点', value: this.data.centerScaleRulerAnchorMode === 'compass-center' ? '指北针圆心' : '屏幕中心' }, { label: '按钮习惯', value: this.data.sideButtonPlacement === 'right' ? '右手' : '左手' }, @@ -2471,7 +2816,9 @@ Page({ resultSceneSubtitle: snapshot.subtitle, resultSceneHeroLabel: snapshot.heroLabel, resultSceneHeroValue: snapshot.heroValue, - resultSceneRows: snapshot.rows.concat(buildRuntimeSummaryRows(currentGameLaunchEnvelope)), + resultSceneRows: snapshot.rows + .concat(buildRuntimeSummaryRows(currentGameLaunchEnvelope)) + .concat(buildLaunchConfigSummaryRows(currentGameLaunchEnvelope)), }) }, diff --git a/miniprogram/pages/map/map.wxml b/miniprogram/pages/map/map.wxml index 98916f2..e979a26 100644 --- a/miniprogram/pages/map/map.wxml +++ b/miniprogram/pages/map/map.wxml @@ -158,7 +158,7 @@ {{pendingContentEntryText}} - + 开始 diff --git a/miniprogram/utils/backendApi.ts b/miniprogram/utils/backendApi.ts index 4c1980a..5095a11 100644 --- a/miniprogram/utils/backendApi.ts +++ b/miniprogram/utils/backendApi.ts @@ -237,6 +237,20 @@ export interface BackendSessionResultView { } } +export interface BackendClientLogInput { + source: string + level: 'debug' | 'info' | 'warn' | 'error' + category: string + message: string + eventId?: string + releaseId?: string + sessionId?: string + manifestUrl?: string + route?: string + occurredAt?: string + details?: Record +} + type BackendEnvelope = { data: T } @@ -428,3 +442,15 @@ export function getMyResults(input: { authToken: input.accessToken, }) } + +export function postClientLog(input: { + baseUrl: string + payload: BackendClientLogInput +}): Promise { + return requestBackend({ + method: 'POST', + baseUrl: input.baseUrl, + path: '/dev/client-logs', + body: input.payload as unknown as Record, + }) +} diff --git a/miniprogram/utils/backendClientLogs.ts b/miniprogram/utils/backendClientLogs.ts new file mode 100644 index 0000000..bc00a13 --- /dev/null +++ b/miniprogram/utils/backendClientLogs.ts @@ -0,0 +1,90 @@ +import { loadBackendBaseUrl } from './backendAuth' +import { postClientLog, type BackendClientLogInput } from './backendApi' + +type ClientLogLevel = BackendClientLogInput['level'] + +type ClientLogEntry = { + level: ClientLogLevel + category: string + message: string + eventId?: string + releaseId?: string + sessionId?: string + manifestUrl?: string + route?: string + details?: Record +} + +const CLIENT_LOG_SOURCE = 'wechat-mini' +const MAX_PENDING_CLIENT_LOGS = 100 + +const pendingClientLogs: BackendClientLogInput[] = [] +let clientLogFlushInProgress = false +let clientLogSequence = 0 + +function getCurrentRoute(): string { + const pages = getCurrentPages() + if (!pages.length) { + return '' + } + const current = pages[pages.length - 1] + return current && current.route ? current.route : '' +} + +function enqueueClientLog(payload: BackendClientLogInput) { + pendingClientLogs.push(payload) + if (pendingClientLogs.length > MAX_PENDING_CLIENT_LOGS) { + pendingClientLogs.shift() + } +} + +function flushNextClientLog() { + if (clientLogFlushInProgress || !pendingClientLogs.length) { + return + } + + const baseUrl = loadBackendBaseUrl() + if (!baseUrl) { + pendingClientLogs.length = 0 + return + } + + const payload = pendingClientLogs.shift() + if (!payload) { + return + } + + clientLogFlushInProgress = true + postClientLog({ + baseUrl, + payload, + }).catch(() => { + // 联调日志不打断主流程,失败时静默丢弃。 + }).finally(() => { + clientLogFlushInProgress = false + if (pendingClientLogs.length) { + flushNextClientLog() + } + }) +} + +export function reportBackendClientLog(entry: ClientLogEntry) { + clientLogSequence += 1 + const details = entry.details ? { ...entry.details } : {} + details.seq = clientLogSequence + const payload: BackendClientLogInput = { + source: CLIENT_LOG_SOURCE, + level: entry.level, + category: entry.category, + message: entry.message, + eventId: entry.eventId || '', + releaseId: entry.releaseId || '', + sessionId: entry.sessionId || '', + manifestUrl: entry.manifestUrl || '', + route: entry.route || getCurrentRoute(), + occurredAt: new Date().toISOString(), + details, + } + enqueueClientLog(payload) + flushNextClientLog() +} diff --git a/miniprogram/utils/backendLaunchAdapter.ts b/miniprogram/utils/backendLaunchAdapter.ts index 5bc9c60..c194a4f 100644 --- a/miniprogram/utils/backendLaunchAdapter.ts +++ b/miniprogram/utils/backendLaunchAdapter.ts @@ -21,6 +21,18 @@ export function adaptBackendLaunchResultToEnvelope(result: BackendLaunchResult): sessionToken: result.launch.business.sessionToken, sessionTokenExpiresAt: result.launch.business.sessionTokenExpiresAt, }, + resolvedRelease: result.launch.resolvedRelease + ? { + launchMode: result.launch.resolvedRelease.launchMode || null, + source: result.launch.resolvedRelease.source || null, + eventId: result.launch.resolvedRelease.eventId || null, + releaseId: result.launch.resolvedRelease.releaseId || null, + configLabel: result.launch.resolvedRelease.configLabel || null, + manifestUrl: result.launch.resolvedRelease.manifestUrl || null, + manifestChecksumSha256: result.launch.resolvedRelease.manifestChecksumSha256 || null, + routeCode: result.launch.resolvedRelease.routeCode || null, + } + : null, variant: result.launch.variant ? { variantId: result.launch.variant.id, diff --git a/miniprogram/utils/gameLaunch.ts b/miniprogram/utils/gameLaunch.ts index ed8d723..dd95f8b 100644 --- a/miniprogram/utils/gameLaunch.ts +++ b/miniprogram/utils/gameLaunch.ts @@ -9,6 +9,17 @@ export interface GameConfigLaunchRequest { routeCode?: string | null } +export interface GameResolvedReleaseLaunchContext { + launchMode?: string | null + source?: string | null + eventId?: string | null + releaseId?: string | null + configLabel?: string | null + manifestUrl?: string | null + manifestChecksumSha256?: string | null + routeCode?: string | null +} + export interface BusinessLaunchContext { source: BusinessLaunchSource competitionId?: string | null @@ -56,6 +67,7 @@ export interface GameContentBundleLaunchContext { export interface GameLaunchEnvelope { config: GameConfigLaunchRequest business: BusinessLaunchContext | null + resolvedRelease?: GameResolvedReleaseLaunchContext | null variant?: GameVariantLaunchContext | null runtime?: GameRuntimeLaunchContext | null presentation?: GamePresentationLaunchContext | null @@ -65,6 +77,7 @@ export interface GameLaunchEnvelope { export interface MapPageLaunchOptions { launchId?: string recoverSession?: string + autoStartOnEnter?: string preset?: string configUrl?: string configLabel?: string @@ -292,6 +305,7 @@ export function getDemoGameLaunchEnvelope(preset: DemoGamePreset = 'classic'): G business: { source: 'demo', }, + resolvedRelease: null, variant: null, runtime: null, presentation: null, @@ -324,12 +338,24 @@ export function consumePendingGameLaunchEnvelope(launchId: string): GameLaunchEn return envelope } -export function buildMapPageUrlWithLaunchId(launchId: string): string { - return `/pages/map/map?launchId=${encodeURIComponent(launchId)}` +export function buildMapPageUrlWithLaunchId(launchId: string, extraQuery?: Record): string { + const queryParts = [`launchId=${encodeURIComponent(launchId)}`] + if (extraQuery) { + Object.keys(extraQuery).forEach((key) => { + const value = extraQuery[key] + if (typeof value === 'string' && value) { + queryParts.push(`${encodeURIComponent(key)}=${encodeURIComponent(value)}`) + } + }) + } + return `/pages/map/map?${queryParts.join('&')}` } export function prepareMapPageUrlForLaunch(envelope: GameLaunchEnvelope): string { - return buildMapPageUrlWithLaunchId(stashPendingGameLaunchEnvelope(envelope)) + return buildMapPageUrlWithLaunchId( + stashPendingGameLaunchEnvelope(envelope), + { autoStartOnEnter: '1' }, + ) } export function prepareMapPageUrlForRecovery(envelope: GameLaunchEnvelope): string { @@ -367,6 +393,7 @@ export function resolveGameLaunchEnvelope(options?: MapPageLaunchOptions | null) routeCode: normalizeOptionalString(options ? options.routeCode : undefined), }, business: buildBusinessLaunchContext(options), + resolvedRelease: null, variant: buildVariantLaunchContext(options), runtime: buildRuntimeLaunchContext(options), presentation: buildPresentationLaunchContext(options), diff --git a/miniprogram/utils/globalMockDebugBridge.ts b/miniprogram/utils/globalMockDebugBridge.ts new file mode 100644 index 0000000..668ae48 --- /dev/null +++ b/miniprogram/utils/globalMockDebugBridge.ts @@ -0,0 +1,88 @@ +import { MockSimulatorDebugLogger, type MockSimulatorDebugLogLevel } from '../engine/debug/mockSimulatorDebugLogger' + +const DEBUG_MOCK_CHANNEL_ID_STORAGE_KEY = 'cmr.debug.mockChannelId.v1' +const DEBUG_MOCK_AUTO_CONNECT_STORAGE_KEY = 'cmr.debug.autoConnectMockSources.v1' +const DEBUG_MOCK_LOG_URL_STORAGE_KEY = 'cmr.debug.logBridgeUrl.v1' +const DEFAULT_DEBUG_LOG_URL = 'wss://gs.gotomars.xyz/debug-log' + +let globalMockDebugLogger: MockSimulatorDebugLogger | null = null + +function ensureLogger(): MockSimulatorDebugLogger { + if (!globalMockDebugLogger) { + globalMockDebugLogger = new MockSimulatorDebugLogger() + } + return globalMockDebugLogger +} + +export function loadStoredMockChannelIdForGlobalDebug(): string { + try { + const value = wx.getStorageSync(DEBUG_MOCK_CHANNEL_ID_STORAGE_KEY) + if (typeof value === 'string' && value.trim().length > 0) { + return value.trim() + } + } catch (_error) { + // Ignore storage read failures and fall back to default. + } + return 'default' +} + +export function loadMockAutoConnectEnabledForGlobalDebug(): boolean { + try { + return wx.getStorageSync(DEBUG_MOCK_AUTO_CONNECT_STORAGE_KEY) === true + } catch (_error) { + return false + } +} + +export function loadStoredMockDebugLogBridgeUrl(): string { + try { + const value = wx.getStorageSync(DEBUG_MOCK_LOG_URL_STORAGE_KEY) + if (typeof value === 'string' && value.trim().length > 0) { + return value.trim() + } + } catch (_error) { + // Ignore storage read failures and fall back to default. + } + return DEFAULT_DEBUG_LOG_URL +} + +export function persistStoredMockDebugLogBridgeUrl(url: string) { + try { + wx.setStorageSync(DEBUG_MOCK_LOG_URL_STORAGE_KEY, url) + } catch (_error) { + // Ignore storage write failures. + } +} + +export function syncGlobalMockDebugBridgeFromStorage(): void { + const logger = ensureLogger() + logger.setChannelId(loadStoredMockChannelIdForGlobalDebug()) + logger.setUrl(loadStoredMockDebugLogBridgeUrl()) + logger.setEnabled(loadMockAutoConnectEnabledForGlobalDebug()) +} + +export function setGlobalMockDebugBridgeChannelId(channelId: string): void { + const logger = ensureLogger() + logger.setChannelId(channelId) +} + +export function setGlobalMockDebugBridgeEnabled(enabled: boolean): void { + const logger = ensureLogger() + logger.setEnabled(enabled) +} + +export function setGlobalMockDebugBridgeUrl(url: string): void { + const logger = ensureLogger() + logger.setUrl(url) +} + +export function emitGlobalMockDebugLog( + scope: string, + level: MockSimulatorDebugLogLevel, + message: string, + payload?: Record, +): void { + const logger = ensureLogger() + logger.log(scope, level, message, payload) +} + diff --git a/miniprogram/utils/remoteMapConfig.ts b/miniprogram/utils/remoteMapConfig.ts index b658a11..c8e3c93 100644 --- a/miniprogram/utils/remoteMapConfig.ts +++ b/miniprogram/utils/remoteMapConfig.ts @@ -68,6 +68,7 @@ export interface RemoteMapConfig { configAppId: string configSchemaVersion: string configVersion: string + playfieldKind: string tileSource: string minZoom: number maxZoom: number @@ -122,6 +123,7 @@ interface ParsedGameConfig { appId: string schemaVersion: string version: string + playfieldKind: string mapRoot: string mapMeta: string course: string | null @@ -1754,6 +1756,7 @@ function parseGameConfigFromJson(text: string, gameConfigUrl: string): ParsedGam appId: rawApp && typeof rawApp.id === 'string' ? rawApp.id : '', schemaVersion: typeof parsed.schemaVersion === 'string' ? parsed.schemaVersion : '1', version: typeof parsed.version === 'string' ? parsed.version : '', + playfieldKind: rawPlayfield && typeof rawPlayfield.kind === 'string' ? rawPlayfield.kind : '', mapRoot, mapMeta, course: rawPlayfieldSource && typeof rawPlayfieldSource.url === 'string' @@ -1855,6 +1858,7 @@ function parseGameConfigFromYaml(text: string, gameConfigUrl: string): ParsedGam appId: '', schemaVersion: '1', version: '', + playfieldKind: typeof config.playfieldkind === 'string' ? config.playfieldkind : '', mapRoot, mapMeta, course: typeof config.course === 'string' ? config.course : null, @@ -2157,6 +2161,7 @@ export async function loadRemoteMapConfig(gameConfigUrl: string): Promise 文档版本:v1.14 -> 最后更新:2026-04-03 14:16:17 +> 文档版本:v1.16 +> 最后更新:2026-04-03 16:59:19 文档维护约定: @@ -13,6 +13,8 @@ 当前补充约定: - 多线程联调场景下,正式架构与长期结论优先沉淀到 `doc/`。 +- 当前联调架构的阶段总结见: + - [联调架构阶段总结](D:/dev/cmr-mini/doc/gameplay/联调架构阶段总结.md) - 面向后端线程的阶段性实施说明,优先写入根目录 [t2b.md](D:/dev/cmr-mini/t2b.md)。 - backend 新增写给总控线程的回写板: - [b2t.md](D:/dev/cmr-mini/b2t.md) @@ -46,6 +48,12 @@ - `place / map asset / tile release / course source / course set / course variant / runtime binding` - `一键补齐 Runtime 并发布` 已可从空白状态跑完整测试链 - `一键标准回归` 与 `回归结果汇总` 已接入 workbench + - `当前 Launch 实际配置摘要` 已接入 workbench + - `前端调试日志` 已接入 workbench + - 三类标准 demo 入口已显式挂出: + - `evt_demo_001` + - `evt_demo_score_o_001` + - `evt_demo_variant_manual_001` - workbench 日志已具备: - 分步日志 - 真实错误 @@ -54,13 +62,12 @@ - 预期判定 - 下一步建议: - 联调标准化第一版视为已完成 - - 下一步进入“真实输入替换第一刀” - - 逐步把 demo 输入替换成更接近生产的真实输入: - - KML / 赛道文件 - - 地图资源 URL - - 内容 manifest - - presentation schema - - 活动文案样例 + - 当前主线进入“真实输入替换第二刀” + - 当前优先替换: + - `content manifest` + - `presentation schema` + - `活动文案样例` + - `KML / 赛道文件` 与 `地图资源 URL` 已接入,不再作为本轮重点 - backend 在联调标准化阶段应优先保证: - 从空白环境直接可跑 - workbench 日志能明确定位失败步骤 @@ -83,8 +90,13 @@ - frontend 进入联调标准化配合与小范围修复阶段 - 只做字段修正、摘要打磨、一致性修复 - 优先复用 backend 一键测试环境做回归 + - 优先复用: + - `回归结果汇总` + - `当前 Launch 实际配置摘要` + - `前端调试日志` - 不继续扩新页面链 - 不做复杂运营样式 + - 不启动活动卡片(列表)产品化开发 当前阶段的核心目标已经从“把地图画出来”升级为“建立一套可长期扩展的运动地图游戏底座”。 这套底座已经具备以下关键能力: diff --git a/t2b.md b/t2b.md index cedae95..95b9a1d 100644 --- a/t2b.md +++ b/t2b.md @@ -1,6 +1,6 @@ # T2B 协作清单 -> 文档版本:v1.11 -> 最后更新:2026-04-03 14:16:17 +> 文档版本:v1.12 +> 最后更新:2026-04-03 16:55:07 说明: @@ -25,6 +25,11 @@ backend 当前已完成: - `publish` 默认继承当前 active 三元组 - `Bootstrap Demo` 与 `一键补齐 Runtime 并发布` 已可从空白状态跑完整测试链 - `一键标准回归` 与 `回归结果汇总` 已接入标准联调入口 +- `前端调试日志` 与 `当前 Launch 实际配置摘要` 已接入 workbench +- 三类标准 demo 入口已显式挂出: + - `evt_demo_001` + - `evt_demo_score_o_001` + - `evt_demo_variant_manual_001` - workbench 日志已补齐: - 分步日志 - 真实错误 @@ -42,16 +47,22 @@ backend 当前已完成: 2. 固化详细日志口径,失败时明确定位在哪一步 3. 固化稳定测试数据,并逐步支持更接近生产的真实输入 -当前认为“联调标准化第一版”已经基本到位,backend 下一步应进入: +当前认为“联调标准化第一版”已经完成,backend 下一步应进入: **真实输入替换第一刀** 优先顺序建议: -1. 先替换真实 KML / 赛道文件 -2. 再替换真实地图资源 URL -3. 再替换真实内容 manifest / presentation schema -4. 最后再补真实活动文案样例 +1. 继续推进真实 `content manifest` +2. 再推进真实 `presentation schema` +3. 最后补真实 `活动文案样例` + +说明: + +- 真实 `KML / 赛道文件` +- 真实 `地图资源 URL` + +这两类输入已接入,当前不再作为本轮重点。 原则: @@ -59,6 +70,13 @@ backend 当前已完成: - 不重新设计联调流程 - 只是把 demo 输入逐步换成更接近生产的真实输入 +当前 backend 不建议切去做: + +- 活动卡片列表产品化 +- 新的玩家侧页面入口 +- 更多管理对象 +- 更复杂后台 UI + 当前进一步明确 backend 的执行口径如下: ### 0.1 一键测试链路 diff --git a/t2f.md b/t2f.md index bb2bb02..86a0113 100644 --- a/t2f.md +++ b/t2f.md @@ -1,6 +1,6 @@ # T2F 协作清单 -> 文档版本:v1.6 -> 最后更新:2026-04-03 13:08:15 +> 文档版本:v1.7 +> 最后更新:2026-04-03 16:55:07 说明: @@ -19,6 +19,10 @@ - 验证活动运营域摘要接线是否稳定 - 修正联调中发现的小范围字段、展示、一致性问题 - 使用 backend 当前统一的“一键测试环境”和稳定 demo 数据做回归 +- 使用 backend 当前统一的结构化诊断入口做回归: + - `回归结果汇总` + - `当前 Launch 实际配置摘要` + - `前端调试日志` - 继续保持 runtime 主链稳定,不扩新页面链 --- @@ -48,7 +52,11 @@ - backend 当前测试能力已升级: - `Bootstrap Demo` - `一键补齐 Runtime 并发布` + - `一键标准回归` + - `回归结果汇总` + - `当前 Launch 实际配置摘要` - 分步日志 / 真实错误 / stack / 最后一次 curl / 预期判定 + - `POST /dev/client-logs` --- @@ -93,6 +101,7 @@ - 不消费完整 `EventPresentation` 结构 - 不把 `ContentBundle` 展开成资源明细 - 不重构首页、结果页、历史页已有结构 +- 不启动活动卡片(列表)产品化开发 --- @@ -105,6 +114,7 @@ - 先做“看得见活动运营对象”,不先做复杂运营化样式 - 当前进入联调回归阶段,优先修问题,不主动扩新页面入口 - 当前联调应优先复用 backend 一键测试环境,不再各自手工铺多份 demo 对象 +- 当前联调应优先复用 backend 提供的结构化诊断链,不再依赖截图 + 口头描述排查 --- @@ -127,6 +137,10 @@ 4. 不继续扩新页面链,不做复杂运营样式 5. 如果前端发现缺字段,再由总控统一回写给 backend 6. 当前前端下一步重点是配合 backend 的一键测试环境做稳定回归,不再新增玩家侧功能入口 +7. 当前前端继续只做: + - 联调回归 + - 小范围修复 + - 结构化日志补充 --- diff --git a/tools/runtime-smoke-test.ts b/tools/runtime-smoke-test.ts index 29b149b..368daf6 100644 --- a/tools/runtime-smoke-test.ts +++ b/tools/runtime-smoke-test.ts @@ -307,6 +307,16 @@ function testLaunchRuntimeAdapter(): void { }, launch: { source: 'event', + resolvedRelease: { + launchMode: 'formal-release', + source: 'current-release', + eventId: 'evt_demo_variant_manual_001', + releaseId: 'rel_runtime_001', + configLabel: 'runtime demo', + manifestUrl: 'https://example.com/releases/rel_runtime_001/manifest.json', + manifestChecksumSha256: 'manifest-sha-001', + routeCode: 'route-variant-b', + }, config: { configUrl: 'https://example.com/runtime.json', configLabel: 'runtime demo', @@ -352,6 +362,9 @@ function testLaunchRuntimeAdapter(): void { } const envelope = adaptBackendLaunchResultToEnvelope(launchResult) + assert(!!envelope.resolvedRelease, 'resolvedRelease 应映射到 GameLaunchEnvelope.resolvedRelease') + assert(envelope.resolvedRelease!.manifestUrl === 'https://example.com/releases/rel_runtime_001/manifest.json', 'resolvedRelease.manifestUrl 应正确适配') + assert(envelope.resolvedRelease!.releaseId === 'rel_runtime_001', 'resolvedRelease.releaseId 应正确适配') assert(!!envelope.runtime, 'launch.runtime 应映射到 GameLaunchEnvelope.runtime') assert(envelope.runtime!.runtimeBindingId === 'rtb_001', 'runtimeBindingId 应正确适配') assert(envelope.runtime!.placeName === '示范校园', 'placeName 应正确适配')