瀏覽代碼

完善后端联调链路与模拟器多通道支持

zhangyan 1 周之前
父節點
當前提交
a70dc8d5d0
共有 51 個文件被更改,包括 4037 次插入197 次删除
  1. 250 0
      b2f.md
  2. 4 0
      backend/.env.example
  3. 1 0
      backend/README.md
  4. 5 3
      backend/docs/README.md
  5. 85 8
      backend/docs/todolist.md
  6. 12 0
      backend/docs/开发说明.md
  7. 24 0
      backend/docs/核心流程.md
  8. 33 0
      backend/docs/系统架构.md
  9. 589 0
      backend/docs/资源对象与目录方案.md
  10. 3 1
      backend/internal/app/app.go
  11. 16 0
      backend/internal/app/config.go
  12. 96 0
      backend/internal/platform/assets/publisher.go
  13. 24 3
      backend/internal/service/config_service.go
  14. 20 0
      backend/scripts/start-dev.ps1
  15. 13 0
      backend/start-backend.ps1
  16. 696 0
      doc/backend/业务后端数据库初版方案.md
  17. 2 0
      doc/debug/模拟器多通道联调最小方案.md
  18. 8 9
      doc/debug/模拟器控制面板重构方案.md
  19. 3 1
      doc/debug/模拟器调试日志方案.md
  20. 6 6
      doc/debug/调试文档索引.md
  21. 233 0
      f2b.md
  22. 5 1
      miniprogram/app.json
  23. 7 0
      miniprogram/app.ts
  24. 76 0
      miniprogram/engine/map/mapEngine.ts
  25. 3 0
      miniprogram/pages/event/event.json
  26. 123 0
      miniprogram/pages/event/event.ts
  27. 20 0
      miniprogram/pages/event/event.wxml
  28. 91 0
      miniprogram/pages/event/event.wxss
  29. 3 0
      miniprogram/pages/home/home.json
  30. 127 0
      miniprogram/pages/home/home.ts
  31. 32 0
      miniprogram/pages/home/home.wxml
  32. 111 0
      miniprogram/pages/home/home.wxss
  33. 8 49
      miniprogram/pages/index/index.ts
  34. 3 27
      miniprogram/pages/index/index.wxml
  35. 8 57
      miniprogram/pages/index/index.wxss
  36. 3 0
      miniprogram/pages/login/login.json
  37. 127 0
      miniprogram/pages/login/login.ts
  38. 35 0
      miniprogram/pages/login/login.wxml
  39. 116 0
      miniprogram/pages/login/login.wxss
  40. 198 23
      miniprogram/pages/map/map.ts
  41. 3 0
      miniprogram/pages/result/result.json
  42. 134 0
      miniprogram/pages/result/result.ts
  43. 33 0
      miniprogram/pages/result/result.wxml
  44. 114 0
      miniprogram/pages/result/result.wxss
  45. 375 0
      miniprogram/utils/backendApi.ts
  46. 86 0
      miniprogram/utils/backendAuth.ts
  47. 21 0
      miniprogram/utils/backendLaunchAdapter.ts
  48. 33 9
      readme-develop.md
  49. 9 0
      tools/mock-gps-sim/README.md
  50. 8 0
      tools/mock-gps-sim/server.js
  51. 2 0
      typings/index.d.ts

+ 250 - 0
b2f.md

@@ -0,0 +1,250 @@
+# Backend To Frontend
+
+这份文件只用于记录 backend 当前对 frontend 的联调要求和协作约束。
+
+约定:
+
+- 我只在这里写“后端已经具备什么、前端现在需要怎么接、哪些地方不能自行假设”
+- 需要你拍板的事项,仍然先由你确认,不在这里直接定版
+- 前端给 backend 的反馈不要写这里,另走 `f2b.md`
+
+---
+
+## 1. 当前联调基线
+
+当前建议前端统一使用这组 demo 数据联调:
+
+- `eventPublicID = evt_demo_001`
+- `channelCode = mini-demo`
+- `channelType = wechat_mini`
+
+当前主链已经可联调:
+
+- 微信小程序登录
+- 首页聚合
+- `event play`
+- `launch`
+- `session start / finish`
+- `session result`
+
+---
+
+## 2. 当前已确认可用的后端能力
+
+登录与用户:
+
+- `POST /auth/login/wechat-mini`
+- `POST /auth/sms/send`
+- `POST /auth/login/sms`
+- `POST /auth/bind/mobile`
+- `GET /me`
+- `GET /me/profile`
+
+首页与入口:
+
+- `GET /entry/resolve`
+- `GET /me/entry-home`
+
+活动与启动:
+
+- `GET /events/{eventPublicID}`
+- `GET /events/{eventPublicID}/play`
+- `POST /events/{eventPublicID}/launch`
+
+局内与结果:
+
+- `GET /sessions/{sessionPublicID}`
+- `POST /sessions/{sessionPublicID}/start`
+- `POST /sessions/{sessionPublicID}/finish`
+- `GET /sessions/{sessionPublicID}/result`
+- `GET /me/sessions`
+- `GET /me/results`
+
+配置发布:
+
+- `GET /dev/config/local-files`
+- `POST /dev/events/{eventPublicID}/config-sources/import-local`
+- `POST /dev/config-builds/preview`
+- `POST /dev/config-builds/publish`
+
+开发工具:
+
+- `POST /dev/bootstrap-demo`
+- `GET /dev/workbench`
+
+---
+
+## 3. 前端现在需要怎么接
+
+## 3.1 登录
+
+小程序当前主链:
+
+1. `POST /auth/login/wechat-mini`
+2. 保存 `accessToken / refreshToken`
+3. 后续业务接口统一带 Bearer token
+
+手机号绑定场景:
+
+1. `POST /auth/sms/send` with `scene=bind_mobile`
+2. `POST /auth/bind/mobile`
+
+## 3.2 首页
+
+首页直接接:
+
+- `GET /me/entry-home`
+
+不要自己拼:
+
+- `/me`
+- `/home`
+- `/cards`
+- `/me/sessions`
+
+## 3.3 活动详情与开始前准备
+
+活动详情 / 开始前准备页直接接:
+
+- `GET /events/{eventPublicID}/play`
+
+它的作用是:
+
+- 判断当前是否可启动
+- 判断主按钮应该是 `start` 还是 `continue`
+- 返回当前会落到哪份 `release`
+
+## 3.4 进入游戏
+
+进入游戏必须走:
+
+- `POST /events/{eventPublicID}/launch`
+
+启动后前端应以这些字段为准:
+
+- `launch.resolvedRelease.releaseId`
+- `launch.resolvedRelease.manifestUrl`
+- `launch.resolvedRelease.manifestChecksumSha256`
+- `launch.config.configUrl`
+- `launch.config.configLabel`
+- `launch.config.releaseId`
+- `launch.config.routeCode`
+- `launch.business.sessionId`
+- `launch.business.sessionToken`
+- `launch.business.sessionTokenExpiresAt`
+
+## 3.5 结果页
+
+结果页直接接:
+
+- `GET /sessions/{sessionPublicID}/result`
+
+列表页直接接:
+
+- `GET /me/results`
+
+---
+
+## 4. 前端必须遵守的约束
+
+## 4.1 正式流程只认 launch 返回的 manifest
+
+前端进入地图时:
+
+- 不要自己拼 release URL
+- 不要回退到本地样例配置路径
+- 不要直接读取根目录 `event/*.json`
+
+必须以 launch 返回的为准:
+
+- `manifestUrl`
+- `manifestChecksumSha256`
+- `releaseId`
+
+## 4.2 launch 返回契约先不要自行扩展假设
+
+前端当前只消费已约定字段。
+
+如果出现下面任一情况,直接反馈阻塞,不要自行猜:
+
+- 字段缺失
+- 字段改名
+- 字段层级变化
+- `resolvedRelease` 与 `config` 含义不一致
+
+## 4.3 release manifest 再报错时必须带完整上下文
+
+如果再出现配置加载失败,请回传:
+
+- `eventPublicID`
+- `releaseId`
+- `manifestUrl`
+- 页面报错文案
+- 控制台日志
+- 网络请求日志
+
+---
+
+## 5. 当前需要前端配合验证的事项
+
+## F-001 回归最新 demo release
+
+当前建议回归使用:
+
+- `eventPublicID = evt_demo_001`
+- `releaseId = rel_e7dd953743c5c0d2`
+- `manifestUrl = https://oss-mbh5.colormaprun.com/gotomars/event/releases/evt_demo_001/rel_e7dd953743c5c0d2/manifest.json`
+
+需要前端验证:
+
+1. `play -> launch -> map load` 已能走通
+2. 地图页不再报 `release manifest 不存在或未发布`
+3. 不再访问旧的失效 release
+
+## F-002 预埋“放弃恢复”调用位
+
+这项先预埋,不要先自行定语义。
+
+建议前端准备好:
+
+- 在“放弃恢复”按钮点击后,预留调用 `finish(cancelled)` 的位置
+
+但是否正式启用:
+
+- 要等 backend 把 `cancelled` 语义确认完
+
+## F-003 首页 / play / result ongoing 语义联调
+
+后面 backend 收稳 `finished / failed / cancelled` 之后,前端需要配合回归:
+
+- `/me/entry-home`
+- `/events/{eventPublicID}/play`
+- `/sessions/{sessionPublicID}/result`
+
+重点看:
+
+- `cancelled` 后不再继续显示为 ongoing
+- `failed` 后不再继续显示为 ongoing
+- `finished` 后结果和首页摘要一致
+
+---
+
+## 6. 当前建议的前端接入顺序
+
+1. 登录页
+2. 首页
+3. 活动详情页 / 开始前准备页
+4. launch
+5. session start / finish
+6. 结果页
+7. 我的页
+
+---
+
+## 7. 参考文档
+
+- [后端总览 README](D:/dev/cmr-mini/backend/README.md)
+- [接口清单](D:/dev/cmr-mini/backend/docs/接口清单.md)
+- [开发说明](D:/dev/cmr-mini/backend/docs/开发说明.md)
+- [系统架构](D:/dev/cmr-mini/backend/docs/系统架构.md)
+- [核心流程](D:/dev/cmr-mini/backend/docs/核心流程.md)

+ 4 - 0
backend/.env.example

@@ -18,3 +18,7 @@ WECHAT_MINI_DEV_PREFIX=dev-
 
 LOCAL_EVENT_DIR=..\event
 ASSET_BASE_URL=https://oss-mbh5.colormaprun.com/gotomars
+ASSET_PUBLIC_BASE_URL=https://oss-mbh5.colormaprun.com
+ASSET_BUCKET_ROOT=oss://color-map-html
+OSSUTIL_PATH=..\tools\ossutil.exe
+OSSUTIL_CONFIG_FILE=C:\Users\your-user\.ossutilconfig

+ 1 - 0
backend/README.md

@@ -19,6 +19,7 @@
 - [API 清单](D:/dev/cmr-mini/backend/docs/接口清单.md)
 - [数据模型](D:/dev/cmr-mini/backend/docs/数据模型.md)
 - [配置管理方案](D:/dev/cmr-mini/backend/docs/配置管理方案.md)
+- [资源对象与目录方案](D:/dev/cmr-mini/backend/docs/资源对象与目录方案.md)
 - [开发说明](D:/dev/cmr-mini/backend/docs/开发说明.md)
 
 ## 快速启动

+ 5 - 3
backend/docs/README.md

@@ -12,9 +12,10 @@
 3. [API 清单](D:/dev/cmr-mini/backend/docs/接口清单.md)
 4. [数据模型](D:/dev/cmr-mini/backend/docs/数据模型.md)
 5. [配置管理方案](D:/dev/cmr-mini/backend/docs/配置管理方案.md)
-6. [前后端联调清单](D:/dev/cmr-mini/backend/docs/前后端联调清单.md)
-7. [TodoList](D:/dev/cmr-mini/backend/docs/todolist.md)
-8. [开发说明](D:/dev/cmr-mini/backend/docs/开发说明.md)
+6. [资源对象与目录方案](D:/dev/cmr-mini/backend/docs/资源对象与目录方案.md)
+7. [前后端联调清单](D:/dev/cmr-mini/backend/docs/前后端联调清单.md)
+8. [TodoList](D:/dev/cmr-mini/backend/docs/todolist.md)
+9. [开发说明](D:/dev/cmr-mini/backend/docs/开发说明.md)
 
 ## 当前系统范围
 
@@ -34,6 +35,7 @@
 下一阶段建议重点:
 
 - 可伸缩配置管理
+- 共享资源对象化
 - source/build/release 分层
 - 配置构建器
 - 发布资产清单

+ 85 - 8
backend/docs/todolist.md

@@ -30,9 +30,29 @@
 
 所以 backend 现在最重要的不是再扩散接口,而是把当前契约和语义收稳。
 
+当前已确认不再阻塞主链的事项:
+
+- `evt_demo_001` 的 release manifest 现已可正常加载
+- 小程序已能进入地图
+- 模拟定位 / 调试日志问题已回到小程序与模拟器侧,不再属于 backend 当前阻塞
+
+前端当前需要配合的事项:
+
+- 正式联调时始终以 `launch.resolvedRelease.manifestUrl` 为准,不再回退到本地样例配置路径
+- 如果再出现配置加载失败,反馈完整上下文:
+  - `eventPublicID`
+  - `releaseId`
+  - `manifestUrl`
+  - 页面报错文案
+  - 控制台 / 网络日志
+- 当前 demo 联调建议统一使用:
+  - `eventPublicID = evt_demo_001`
+  - `channelCode = mini-demo`
+  - `channelType = wechat_mini`
+
 ## 3. P0 必做
 
-## 3.1 固定 session 状态语义
+## 3.0 固定 session 状态语义
 
 需要 backend 明确并固定:
 
@@ -51,7 +71,7 @@
 - 小程序现在已经按这个方向接
 - 如果 backend 想改这 3 个状态语义,需要先讨论,不要单边改
 
-## 3.2 明确“放弃恢复”的后端处理
+## 3.1 明确“放弃恢复”的后端处理
 
 这是当前最值得后端配合确认的一点。
 
@@ -87,7 +107,7 @@ backend 需要确认的目标语义是:
 
 - 如果 backend 认可这套语义,小程序侧下一步就可以把“点击放弃恢复”改成同步调用 `finish(cancelled)`。
 
-## 3.3 保证 start / finish 幂等与重复调用安全
+## 3.2 保证 start / finish 幂等与重复调用安全
 
 联调和真实环境里,以下情况很常见:
 
@@ -110,7 +130,7 @@ backend 需要确认:
 
 - 不把客户端补偿逻辑变成一堆冲突分支
 
-## 3.4 固定 `launch` 返回契约,不随意漂移
+## 3.3 固定 `launch` 返回契约,不随意漂移
 
 当前客户端已经按下面这些字段接入:
 
@@ -130,9 +150,38 @@ backend 现在需要做的是:
 - 先保持这些字段名稳定
 - 如果要调整命名或层级,先沟通
 
+前端当前需要做的是:
+
+- 只消费当前已约定字段
+- 不额外推断 release URL
+- 不把本地样例配置路径混进正式 launch 流程
+- 如果字段缺失或命名变化,直接在联调清单里标阻塞
+
 ## 4. P1 应尽快做
 
-## 4.1 增加用户身体资料读取接口
+## 4.1 给首页 / play / result 的 ongoing 语义再做一次回归确认
+
+当前前端已经开始走:
+
+- 首页聚合
+- `event play`
+- `launch`
+- `session start / finish`
+- 本地故障恢复
+
+backend 建议再回归确认这几个接口对“进行中 session”的口径一致:
+
+- `/me/entry-home`
+- `/events/{eventPublicID}/play`
+- `/sessions/{sessionPublicID}/result`
+
+重点确认:
+
+1. `cancelled` 后不再继续出现在 ongoing 入口
+2. `failed` 后不再继续出现在 ongoing 入口
+3. `finished` 后结果页与首页摘要字段一致
+
+## 4.2 增加用户身体资料读取接口
 
 小程序侧已经有:
 
@@ -152,7 +201,7 @@ backend 下一步建议提供:
 
 这样后面心率页和消耗估算就能真实接业务数据。
 
-## 4.2 给 `session result` 补一点稳定摘要字段校验
+## 4.3 给 `session result` 补一点稳定摘要字段校验
 
 客户端现在会上报:
 
@@ -170,7 +219,7 @@ backend 建议补两件事:
 
 不要因为某个可选字段缺失就整局 finish 失败。
 
-## 4.3 dev workbench 增加一组“恢复 / 取消恢复”场景按钮
+## 4.4 dev workbench 增加一组“恢复 / 取消恢复”场景按钮
 
 当前 workbench 已经很好用了。
 
@@ -182,6 +231,17 @@ backend 建议补两件事:
 
 这会很适合配合小程序故障恢复联调。
 
+## 4.5 前端预埋“放弃恢复”调用位
+
+这项先预埋,不要先自行定语义。
+
+前端建议准备好:
+
+- 在“放弃恢复”按钮点击后,预留调用 `finish(cancelled)` 的位置
+- 但是否正式启用,要等 backend 把 `cancelled` 语义确认完
+
+这样一旦 backend 确认语义,小程序就能快速切过去,不需要再改一轮页面流程。
+
 ## 5. P2 下一阶段
 
 ## 5.1 配置后台 source / build / release 真正开始做
@@ -205,6 +265,23 @@ backend 建议补两件事:
 
 这部分不是当前联调阻塞项,但后面会成为业务壳的重要组成。
 
+## 5.3 兼顾未来 APP 的统一后端约束
+
+backend 后续建设需要继续坚持:
+
+- 不做“小程序专用后端”
+- 用户模型保持平台级
+- `event / release / session / result` 不按终端拆两套
+- 终端差异只通过上下文字段和运行时适配处理
+
+建议优先保持:
+
+- 业务接口统一
+- 配置发布结构统一
+- 结果沉淀结构统一
+
+这样后面 APP 接入时不会推翻现有 backend 结构。
+
 ## 6. 需要先讨论再动的边界
 
 这些事项 backend 不建议自己先拍板:
@@ -252,4 +329,4 @@ backend 现在最值得先做的,不是扩接口,而是先确认下面 3 条
 
 当前 backend 最重要的任务不是“再加更多接口”,而是:
 
-> 先把 session 运行态语义和故障恢复放弃语义定稳,再继续扩后台配置系统。
+> 先把 session 运行态语义、放弃恢复语义和 ongoing session 口径定稳,再继续扩后台配置系统。

+ 12 - 0
backend/docs/开发说明.md

@@ -17,6 +17,10 @@
 - `WECHAT_MINI_DEV_PREFIX`
 - `LOCAL_EVENT_DIR`
 - `ASSET_BASE_URL`
+- `ASSET_PUBLIC_BASE_URL`
+- `ASSET_BUCKET_ROOT`
+- `OSSUTIL_PATH`
+- `OSSUTIL_CONFIG_FILE`
 
 ## 2. 本地启动
 
@@ -86,6 +90,9 @@ Redis 后面只在需要性能优化、限流或短期票据缓存时再接。
 
 - `LOCAL_EVENT_DIR` 决定本地 source config 从哪里读
 - `ASSET_BASE_URL` 决定 preview build 时如何把相对资源路径归一化成可运行 URL
+- `ASSET_PUBLIC_BASE_URL` 决定 publish 时如何把公开 URL 映射到 OSS 对象 key
+- `ASSET_BUCKET_ROOT` 决定发布对象上传到哪个 bucket 根路径
+- `OSSUTIL_PATH` 和 `OSSUTIL_CONFIG_FILE` 决定 backend 发布 manifest 时使用哪个 OSS 客户端
 
 ## 4. Migration
 
@@ -125,6 +132,11 @@ Redis 后面只在需要性能优化、限流或短期票据缓存时再接。
 - result
 - profile
 
+补充说明:
+
+- `publish build` 现在会真实上传 `manifest.json` 和 `asset-index.json` 到 OSS
+- 如果上传失败,接口会直接报错,不再出现“数据库里已有 release,但 OSS 上没有对象”的假成功
+
 并且支持:
 
 - quick flow

+ 24 - 0
backend/docs/核心流程.md

@@ -14,6 +14,11 @@ flowchart LR
   H --> I["Result / History"]
 ```
 
+补充说明:
+
+- 这条主流程既服务当前小程序,也要服务未来 APP
+- 终端差异主要体现在登录方式、设备能力和运行时 UI,不应拆成两套业务流程
+
 ## 2. 入口解析
 
 入口层先解决:
@@ -39,6 +44,10 @@ APP 当前主链是手机号验证码:
 2. `POST /auth/login/sms`
 3. 返回 `access_token + refresh_token`
 
+说明:
+
+- APP 是未来更强接入端,后端设计必须预留身体资料、设备绑定、遥测摘要等扩展空间
+
 ### 3.2 微信小程序
 
 微信小程序当前主链是:
@@ -134,6 +143,11 @@ APP 当前主链是手机号验证码:
 - `launch.business.sessionId`
 - `launch.business.sessionToken`
 
+补充约束:
+
+- `launch` 是统一业务启动入口,不应因为 APP / 小程序差异复制两套接口
+- 终端差异通过 `clientType`、`deviceKey`、后续能力声明字段处理
+
 ### 6.3 客户端应如何使用
 
 客户端进入游戏前,应以返回中的这几项为准:
@@ -202,3 +216,13 @@ APP 当前主链是手机号验证码:
 不要退回成:
 
 `event -> launch -> game`
+
+也不要走成:
+
+`mini event -> mini launch -> mini game`
+
+或:
+
+`app event -> app launch -> app game`
+
+业务接口必须保持统一,终端差异只进入上下文,不进入对象模型分叉。

+ 33 - 0
backend/docs/系统架构.md

@@ -21,6 +21,11 @@
 - 运行时解析复杂地图规则
 - 直接下发数据库编辑态对象给客户端
 
+补充约束:
+
+- 这套 backend 必须服务未来 APP,不是“小程序专用后端”
+- 登录方式可以按终端区分,但业务对象和业务接口不能按端分裂成两套
+
 ## 2. 分层
 
 ### 2.1 平台层
@@ -35,6 +40,12 @@
 
 这层是整个平台共用能力。
 
+它必须同时支撑:
+
+- APP
+- 微信小程序
+- 后续公众号 / H5 / 其他渠道
+
 ### 2.2 业务层
 
 业务层统一处理:
@@ -58,6 +69,12 @@
 
 这层是“客户端真正进入游戏时要消费的运行配置入口”。
 
+这里的发布结构应保持终端中立:
+
+- 不写死为小程序专用结构
+- 不直接依赖某个端的页面实现
+- 允许 APP 和小程序共用同一份 release / manifest
+
 ### 2.4 运行层
 
 运行层统一处理:
@@ -126,6 +143,12 @@
 - `GET /sessions/{id}` 会返回 `resolvedRelease`
 - `GET /sessions/{id}/result` 能追溯到当时的 release
 
+补充约束:
+
+- release / manifest 只描述运行配置,不承载某个端的页面状态
+- 玩家设置、设备能力差异、运行时 UI 编译由客户端自行处理
+- 后端负责“发布可运行配置”,不是“替某个端生成最终运行时 profile”
+
 ## 5. 代码分层
 
 ### 5.1 入口层
@@ -189,6 +212,16 @@
 - 驱动地图和玩法
 - 产生过程数据和结束摘要
 
+适用范围:
+
+- 微信小程序客户端
+- 未来 APP 客户端
+
+也就是说:
+
+- 后端按统一业务模型输出
+- 终端差异放在客户端运行时适配层,不放在后端业务接口层
+
 ### 6.3 后续网关该怎么接
 
 后面如果接实时网关,建议仍然走:

+ 589 - 0
backend/docs/资源对象与目录方案.md

@@ -0,0 +1,589 @@
+# 资源对象与目录方案
+
+本文档用于把“地图复用、KML 复用、内容资源复用、配置发布”统一收成一套后端可执行方案。
+
+目标:
+
+- 不再把所有资源都塞进单个 `event/*.json`
+- 让地图、KML、内容模板、主题资源都能独立复用
+- 让 `event` 只负责“组合与覆盖”,不拥有底层资源本体
+- 让 `release` 能稳定追溯当时到底用了哪一份地图、哪一份 KML、哪一套资源包
+- 让同一套资源对象既能服务小程序,也能服务未来 APP
+
+---
+
+## 1. 设计结论
+
+后端后续不要按“一个活动一个完整资源目录”来设计,而要按“资源对象库 + Event 组合 + Release 固化”来设计。
+
+建议统一拆成这 5 类对象:
+
+1. `Map`
+2. `Playfield`
+3. `GameMode`
+4. `ResourcePack`
+5. `Event`
+
+它们的关系是:
+
+- `Map`:地图底座
+- `Playfield`:空间对象 / KML / 控制点集
+- `GameMode`:玩法默认规则
+- `ResourcePack`:内容、主题、音频等资源档
+- `Event`:业务活动对象,只做引用与少量覆盖
+
+最终客户端吃的仍然不是这些编辑态对象,而是:
+
+- `Release`
+- `manifest.json`
+
+补充约束:
+
+- manifest 必须保持终端中立
+- 不要在资源对象层把目录或字段设计成“小程序专用资源包”
+- APP 与小程序应共享同一套资源对象和 release 记录
+
+---
+
+## 2. 为什么必须这么拆
+
+你现在遇到的核心问题不是“目录怎么摆”,而是“哪些资源会复用”。
+
+当前最典型的复用场景:
+
+- 同一张地图会被多个活动复用
+- 同一份 KML / 控制点集会被多个活动复用
+- 同一套 H5 内容模板会被多个活动复用
+- 同一套主题和音频资源会被多个活动复用
+
+如果继续按 `event -> 自己拥有所有文件` 设计,后面会出现:
+
+- 地图重复拷贝
+- KML 重复上传
+- 同一资源多个活动版本不一致
+- 一次资源修复需要改很多 event
+- 历史 session 无法明确追溯当时使用的是哪一版资源
+
+所以正确做法不是“每个 event 一套全量文件”,而是:
+
+`共享资源对象 -> event 引用 -> release 固化版本`
+
+---
+
+## 3. 五类核心对象
+
+## 3.1 Map
+
+作用:
+
+- 表示一张可复用地图底座
+
+最少应包含:
+
+- `code`
+- `name`
+- `status`
+- `tiles root`
+- `mapmeta`
+- 可选边界、缩放、投影信息
+
+典型场景:
+
+- 一个公园底图
+- 一张校园底图
+- 一张城区底图
+
+注意:
+
+- `Map` 不等于某场活动
+- `Map` 是共享资产
+
+## 3.2 Playfield
+
+作用:
+
+- 表示一份可复用场地对象数据
+
+最常见的承载就是:
+
+- `KML`
+- `GeoJSON`
+- 控制点集
+
+最少应包含:
+
+- `code`
+- `name`
+- `kind`
+- `sourceType`
+- `sourceFile`
+- 可选提取元数据
+  - 控制点数量
+  - 边界范围
+  - 是否包含起终点
+
+注意:
+
+- `Playfield` 是共享对象,不属于某个 event 私有
+- 同一份 KML 可以被多个 event 复用
+
+## 3.3 GameMode
+
+作用:
+
+- 表示一种玩法模式的默认规则对象
+
+例如:
+
+- `classic-sequential`
+- `score-o`
+
+最少应包含:
+
+- `code`
+- `mode`
+- `defaults json`
+
+注意:
+
+- 它不是最终 event 配置
+- 只是玩法默认值来源之一
+
+## 3.4 ResourcePack
+
+作用:
+
+- 表示一套可复用资源档
+
+当前最适合放进来的有:
+
+- `audioProfile`
+- `contentProfile`
+- `themeProfile`
+
+一个资源包内部可以包含:
+
+- 内容模板
+- H5 页面
+- 图片
+- 图标
+- 音效
+- 主题色与主题变量
+
+## 3.5 Event
+
+作用:
+
+- 表示一个业务活动实例
+
+它应该只负责:
+
+- 引用哪个 `Map`
+- 引用哪个 `Playfield`
+- 引用哪个 `GameMode`
+- 引用哪个 `ResourcePack`
+- 叠加少量 `Event Overrides`
+
+不要让 `Event` 负责:
+
+- 保存整份地图资源
+- 保存 KML 原件
+- 承担所有玩法默认规则
+- 拷贝整套资源包
+
+---
+
+## 4. 推荐目录结构
+
+仓库内建议把“源资源”和“活动源配置”拆开。
+
+推荐结构:
+
+```text
+resources/
+  maps/
+    lxcb-001/
+      v2026-03-30/
+        mapmeta.json
+        tiles/
+  playfields/
+    c01/
+      v2026-03-30/
+        course.kml
+        meta.json
+  resource-packs/
+    default-race/
+      v2026-03-30/
+        content/
+          content.html
+        audio/
+        theme/
+
+game-modes/
+  classic-sequential/
+    v1/
+      mode.json
+  score-o/
+    v1/
+      mode.json
+
+events/
+  evt-demo-001/
+    source.json
+```
+
+说明:
+
+- `resources/` 放共享对象的源资源
+- `game-modes/` 放玩法默认规则对象
+- `events/` 只放活动级 source config
+
+当前根目录 [event](D:/dev/cmr-mini/event) 可以继续保留作为过渡区,但后面建议逐步迁到:
+
+- `events/`
+- `resources/`
+- `game-modes/`
+
+---
+
+## 5. Event Source 应该怎么写
+
+后续 `event source` 不建议继续直接写死地图路径和 KML 路径,而应该引用对象版本。
+
+推荐形态:
+
+```json
+{
+  "schemaVersion": "1",
+  "version": "2026.04.01",
+  "app": {
+    "id": "evt-demo-001",
+    "title": "积分赛示例"
+  },
+  "refs": {
+    "map": {
+      "code": "lxcb-001",
+      "version": "v2026-03-30"
+    },
+    "playfield": {
+      "code": "c01",
+      "version": "v2026-03-30"
+    },
+    "gameMode": {
+      "code": "score-o",
+      "version": "v1"
+    },
+    "resourcePack": {
+      "code": "default-race",
+      "version": "v2026-03-30"
+    }
+  },
+  "overrides": {
+    "game": {
+      "session": {
+        "maxDurationSec": 5400
+      },
+      "punch": {
+        "radiusMeters": 5
+      }
+    },
+    "playfield": {
+      "metadata": {
+        "title": "示例路线",
+        "code": "demo-001"
+      }
+    }
+  }
+}
+```
+
+这样 `Event` 管的是:
+
+- 引用
+- 覆盖
+
+不是:
+
+- 全量资源路径
+- 全量运行时配置
+
+---
+
+## 6. Manifest 生成规则
+
+build / publish 时,Go 中间层应做装配:
+
+`Map + Playfield + GameMode + ResourcePack + Event Overrides -> manifest.json`
+
+最终生成给客户端的 manifest 可以保持现在的运行结构,例如:
+
+```json
+{
+  "schemaVersion": "1",
+  "releaseId": "rel_xxx",
+  "version": "2026.04.01",
+  "app": {
+    "id": "evt-demo-001",
+    "title": "积分赛示例"
+  },
+  "map": {
+    "tiles": "https://.../maps/lxcb-001/v2026-03-30/tiles/",
+    "mapmeta": "https://.../maps/lxcb-001/v2026-03-30/mapmeta.json"
+  },
+  "playfield": {
+    "kind": "control-set",
+    "source": {
+      "type": "kml",
+      "url": "https://.../playfields/c01/v2026-03-30/course.kml"
+    }
+  },
+  "game": {
+    "mode": "score-o"
+  },
+  "resources": {
+    "audioProfile": "default",
+    "contentProfile": "default",
+    "themeProfile": "default-race"
+  }
+}
+```
+
+这层才是客户端真正消费的配置。
+
+重要边界:
+
+- 后端负责对象装配和发布
+- 前端继续负责运行时 profile 编译
+- 不把玩家设置和运行时状态写回发布配置
+
+再补一条:
+
+- 不把 APP 专属页面状态或小程序专属页面状态写进 manifest
+- 如需终端能力差异,后续通过能力声明或运行时适配层处理
+
+---
+
+## 7. OSS / CDN 目录建议
+
+线上目录不要再继续以:
+
+- `gotomars/event/classic-sequential.json`
+- `gotomars/event/score-o.json`
+
+这种“玩法文件名”方式长期演进。
+
+建议改成版本化结构:
+
+```text
+gotomars/maps/{mapCode}/{version}/...
+gotomars/playfields/{playfieldCode}/{version}/...
+gotomars/resource-packs/{packCode}/{version}/...
+gotomars/game-modes/{modeCode}/{version}/mode.json
+gotomars/event-releases/{eventPublicID}/{releasePublicID}/manifest.json
+gotomars/event-releases/{eventPublicID}/{releasePublicID}/asset-index.json
+```
+
+好处:
+
+- 共享资源独立版本化
+- event release 只固化引用
+- 历史 session 可以回溯
+- 同一个 map / KML 修复时不会污染所有旧 release
+- APP 与小程序可共用相同资源版本,不必重复发两套发布目录
+
+---
+
+## 8. 数据库建模建议
+
+推荐按“主表 + version 表”建模。
+
+建议对象:
+
+- `maps`
+- `map_versions`
+- `playfields`
+- `playfield_versions`
+- `game_modes`
+- `game_mode_versions`
+- `resource_packs`
+- `resource_pack_versions`
+- `events`
+- `event_versions`
+- `event_releases`
+
+其中:
+
+- 主表存稳定元信息
+- version 表存 `jsonb` 内容和具体资源引用
+
+例如:
+
+### `maps`
+
+- `id`
+- `code`
+- `name`
+- `status`
+- `current_version_id`
+
+### `map_versions`
+
+- `id`
+- `map_id`
+- `version_code`
+- `content_jsonb`
+- `published_asset_root`
+- `status`
+
+### `playfields`
+
+- `id`
+- `code`
+- `name`
+- `kind`
+- `status`
+- `current_version_id`
+
+### `playfield_versions`
+
+- `id`
+- `playfield_id`
+- `version_code`
+- `source_type`
+- `content_jsonb`
+- `asset_root`
+- `status`
+
+### `resource_packs`
+
+- `id`
+- `code`
+- `name`
+- `status`
+- `current_version_id`
+
+### `resource_pack_versions`
+
+- `id`
+- `resource_pack_id`
+- `version_code`
+- `content_jsonb`
+- `asset_root`
+- `status`
+
+### `game_modes`
+
+- `id`
+- `code`
+- `name`
+- `status`
+- `current_version_id`
+
+### `game_mode_versions`
+
+- `id`
+- `game_mode_id`
+- `version_code`
+- `content_jsonb`
+- `status`
+
+### `event_versions`
+
+- `id`
+- `event_id`
+- `version_code`
+- `map_version_id`
+- `playfield_version_id`
+- `game_mode_version_id`
+- `resource_pack_version_id`
+- `overrides_jsonb`
+- `status`
+
+核心点:
+
+- `Event` 不直接指向文件 URL
+- `EventVersion` 指向对象版本
+- `Release` 固化当时装配结果
+
+---
+
+## 9. 后端职责边界
+
+后端应强管理:
+
+- 对象关系
+- 版本关系
+- 引用有效性
+- 发布装配
+- 发布记录
+
+后端不应强管理:
+
+- 每个玩法的所有细字段解释
+- 所有 HUD / 动画 / 实验细项的强结构化列
+- 玩家运行时设置
+- 玩家实时状态
+
+同样不应做:
+
+- 为 APP 和小程序各维护一套资源目录规范
+- 为 APP 和小程序各发布一套不同语义的 event 配置
+
+适合继续走 `jsonb` 的内容:
+
+- `game.sequence.*`
+- `game.guidance.*`
+- `game.presentation.*`
+- `playfield.controlOverrides.*`
+- 各类实验性字段
+
+---
+
+## 10. 推荐实施顺序
+
+建议不要一次重构到底,按下面顺序推进:
+
+1. 先把概念定住
+   - `Map`
+   - `Playfield`
+   - `GameMode`
+   - `ResourcePack`
+   - `Event`
+
+2. 先做文档和目录规范
+
+3. 后端先补对象模型和 version 表草案
+
+4. 配置构建器改成“按引用装配”
+
+5. 发布器改成“版本化共享资源 + event release manifest”
+
+6. 最后再做正式后台 UI
+
+---
+
+## 11. 当前阶段的务实建议
+
+在完全切换到对象化模型前,当前仓库可以先这样过渡:
+
+- 继续保留 [event](D:/dev/cmr-mini/event) 作为最小样例区
+- 继续保留现有 `import-local -> preview -> publish`
+- 但新的 source 设计和目录设计先按本文档收口
+
+也就是说:
+
+- 短期不推翻现有链路
+- 中期把资源引用模型补进来
+- 长期把单文件 `event/*.json` 迁到对象化配置系统
+
+---
+
+## 12. 一句话结论
+
+后端资源管理的正确方向不是“每个活动一堆文件”,而是:
+
+`共享资源对象库 + Event 引用装配 + Release 固化发布`
+
+只有这样,地图复用、KML 复用、资源包复用、多活动发布才能长期稳定。
+
+并且这套模型必须从一开始就兼顾未来 APP,而不是做成“小程序跑通后再重构”的临时结构。

+ 3 - 1
backend/internal/app/app.go

@@ -5,6 +5,7 @@ import (
 	"net/http"
 
 	"cmr-backend/internal/httpapi"
+	"cmr-backend/internal/platform/assets"
 	"cmr-backend/internal/platform/jwtx"
 	"cmr-backend/internal/platform/wechatmini"
 	"cmr-backend/internal/service"
@@ -38,7 +39,8 @@ func New(ctx context.Context, cfg Config) (*App, error) {
 	entryHomeService := service.NewEntryHomeService(store)
 	eventService := service.NewEventService(store)
 	eventPlayService := service.NewEventPlayService(store)
-	configService := service.NewConfigService(store, cfg.LocalEventDir, cfg.AssetBaseURL)
+	assetPublisher := assets.NewOSSUtilPublisher(cfg.OSSUtilPath, cfg.OSSUtilConfigFile, cfg.AssetBucketRoot, cfg.AssetPublicBaseURL)
+	configService := service.NewConfigService(store, cfg.LocalEventDir, cfg.AssetBaseURL, assetPublisher)
 	homeService := service.NewHomeService(store)
 	profileService := service.NewProfileService(store)
 	resultService := service.NewResultService(store)

+ 16 - 0
backend/internal/app/config.go

@@ -24,6 +24,10 @@ type Config struct {
 	WechatMiniDevPrefix string
 	LocalEventDir       string
 	AssetBaseURL        string
+	AssetPublicBaseURL  string
+	AssetBucketRoot     string
+	OSSUtilPath         string
+	OSSUtilConfigFile   string
 }
 
 func LoadConfigFromEnv() (Config, error) {
@@ -44,6 +48,10 @@ func LoadConfigFromEnv() (Config, error) {
 		WechatMiniDevPrefix: getEnv("WECHAT_MINI_DEV_PREFIX", "dev-"),
 		LocalEventDir:       getEnv("LOCAL_EVENT_DIR", filepath.Clean("..\\event")),
 		AssetBaseURL:        getEnv("ASSET_BASE_URL", "https://oss-mbh5.colormaprun.com/gotomars"),
+		AssetPublicBaseURL:  getEnv("ASSET_PUBLIC_BASE_URL", "https://oss-mbh5.colormaprun.com"),
+		AssetBucketRoot:     getEnv("ASSET_BUCKET_ROOT", "oss://color-map-html"),
+		OSSUtilPath:         getEnv("OSSUTIL_PATH", filepath.Clean("..\\tools\\ossutil.exe")),
+		OSSUtilConfigFile:   getEnv("OSSUTIL_CONFIG_FILE", filepath.Join(mustUserHomeDir(), ".ossutilconfig")),
 	}
 
 	if cfg.DatabaseURL == "" {
@@ -71,3 +79,11 @@ func getDurationEnv(key string, fallback time.Duration) time.Duration {
 	}
 	return fallback
 }
+
+func mustUserHomeDir() string {
+	home, err := os.UserHomeDir()
+	if err != nil {
+		return "."
+	}
+	return home
+}

+ 96 - 0
backend/internal/platform/assets/publisher.go

@@ -0,0 +1,96 @@
+package assets
+
+import (
+	"context"
+	"fmt"
+	"os"
+	"os/exec"
+	"path/filepath"
+	"strings"
+)
+
+type OSSUtilPublisher struct {
+	ossutilPath   string
+	configFile    string
+	bucketRoot    string
+	publicBaseURL string
+}
+
+func NewOSSUtilPublisher(ossutilPath, configFile, bucketRoot, publicBaseURL string) *OSSUtilPublisher {
+	return &OSSUtilPublisher{
+		ossutilPath:   strings.TrimSpace(ossutilPath),
+		configFile:    strings.TrimSpace(configFile),
+		bucketRoot:    strings.TrimRight(strings.TrimSpace(bucketRoot), "/"),
+		publicBaseURL: strings.TrimRight(strings.TrimSpace(publicBaseURL), "/"),
+	}
+}
+
+func (p *OSSUtilPublisher) Enabled() bool {
+	return p != nil &&
+		p.ossutilPath != "" &&
+		p.configFile != "" &&
+		p.bucketRoot != "" &&
+		p.publicBaseURL != ""
+}
+
+func (p *OSSUtilPublisher) UploadJSON(ctx context.Context, publicURL string, payload []byte) error {
+	if !p.Enabled() {
+		return fmt.Errorf("asset publisher is not configured")
+	}
+	if len(payload) == 0 {
+		return fmt.Errorf("payload is empty")
+	}
+
+	objectKey, err := p.objectKeyFromPublicURL(publicURL)
+	if err != nil {
+		return err
+	}
+
+	if _, err := os.Stat(p.ossutilPath); err != nil {
+		return fmt.Errorf("ossutil not found: %w", err)
+	}
+	if _, err := os.Stat(p.configFile); err != nil {
+		return fmt.Errorf("ossutil config not found: %w", err)
+	}
+
+	tmpFile, err := os.CreateTemp("", "cmr-manifest-*.json")
+	if err != nil {
+		return fmt.Errorf("create temp file: %w", err)
+	}
+	tmpPath := tmpFile.Name()
+	defer os.Remove(tmpPath)
+
+	if _, err := tmpFile.Write(payload); err != nil {
+		tmpFile.Close()
+		return fmt.Errorf("write temp file: %w", err)
+	}
+	if err := tmpFile.Close(); err != nil {
+		return fmt.Errorf("close temp file: %w", err)
+	}
+
+	target := p.bucketRoot + "/" + objectKey
+	cmd := exec.CommandContext(ctx, p.ossutilPath, "cp", "-f", tmpPath, target, "--config-file", p.configFile)
+	output, err := cmd.CombinedOutput()
+	if err != nil {
+		return fmt.Errorf("upload object %s failed: %w: %s", objectKey, err, strings.TrimSpace(string(output)))
+	}
+
+	return nil
+}
+
+func (p *OSSUtilPublisher) objectKeyFromPublicURL(publicURL string) (string, error) {
+	publicURL = strings.TrimSpace(publicURL)
+	if publicURL == "" {
+		return "", fmt.Errorf("public url is required")
+	}
+	if !strings.HasPrefix(publicURL, p.publicBaseURL+"/") {
+		return "", fmt.Errorf("public url %s does not match public base %s", publicURL, p.publicBaseURL)
+	}
+	relative := strings.TrimPrefix(publicURL, p.publicBaseURL+"/")
+	relative = strings.ReplaceAll(relative, "\\", "/")
+	relative = strings.TrimLeft(relative, "/")
+	if relative == "" {
+		return "", fmt.Errorf("public url %s resolved to empty object key", publicURL)
+	}
+	return filepath.ToSlash(relative), nil
+}

+ 24 - 3
backend/internal/service/config_service.go

@@ -11,6 +11,7 @@ import (
 	"strings"
 
 	"cmr-backend/internal/apperr"
+	"cmr-backend/internal/platform/assets"
 	"cmr-backend/internal/platform/security"
 	"cmr-backend/internal/store/postgres"
 )
@@ -19,6 +20,7 @@ type ConfigService struct {
 	store         *postgres.Store
 	localEventDir string
 	assetBaseURL  string
+	publisher     *assets.OSSUtilPublisher
 }
 
 type ConfigPipelineSummary struct {
@@ -76,11 +78,12 @@ type PublishBuildInput struct {
 	BuildID string `json:"buildId"`
 }
 
-func NewConfigService(store *postgres.Store, localEventDir, assetBaseURL string) *ConfigService {
+func NewConfigService(store *postgres.Store, localEventDir, assetBaseURL string, publisher *assets.OSSUtilPublisher) *ConfigService {
 	return &ConfigService{
 		store:         store,
 		localEventDir: localEventDir,
 		assetBaseURL:  strings.TrimRight(assetBaseURL, "/"),
+		publisher:     publisher,
 	}
 }
 
@@ -323,9 +326,20 @@ func (s *ConfigService) PublishBuild(ctx context.Context, input PublishBuildInpu
 
 	configLabel := deriveConfigLabel(event, manifest, releaseNo)
 	manifestURL := fmt.Sprintf("%s/event/releases/%s/%s/manifest.json", s.assetBaseURL, event.PublicID, releasePublicID)
+	assetIndexURL := fmt.Sprintf("%s/event/releases/%s/%s/asset-index.json", s.assetBaseURL, event.PublicID, releasePublicID)
 	checksum := security.HashText(buildRecord.ManifestJSON)
 	routeCode := deriveRouteCode(manifest)
 
+	if s.publisher == nil || !s.publisher.Enabled() {
+		return nil, apperr.New(http.StatusInternalServerError, "asset_publish_unavailable", "asset publisher is not configured")
+	}
+	if err := s.publisher.UploadJSON(ctx, manifestURL, []byte(buildRecord.ManifestJSON)); err != nil {
+		return nil, apperr.New(http.StatusInternalServerError, "asset_publish_failed", "failed to upload manifest: "+err.Error())
+	}
+	if err := s.publisher.UploadJSON(ctx, assetIndexURL, []byte(buildRecord.AssetIndexJSON)); err != nil {
+		return nil, apperr.New(http.StatusInternalServerError, "asset_publish_failed", "failed to upload asset index: "+err.Error())
+	}
+
 	tx, err := s.store.Begin(ctx)
 	if err != nil {
 		return nil, err
@@ -348,7 +362,7 @@ func (s *ConfigService) PublishBuild(ctx context.Context, input PublishBuildInpu
 		return nil, err
 	}
 
-	if err := s.store.ReplaceEventReleaseAssets(ctx, tx, releaseRecord.ID, s.mapBuildAssetsToReleaseAssets(releaseRecord.ID, manifestURL, &checksum, assetIndex)); err != nil {
+	if err := s.store.ReplaceEventReleaseAssets(ctx, tx, releaseRecord.ID, s.mapBuildAssetsToReleaseAssets(releaseRecord.ID, manifestURL, assetIndexURL, &checksum, assetIndex)); err != nil {
 		return nil, err
 	}
 
@@ -642,7 +656,7 @@ func deriveRouteCode(manifest map[string]any) *string {
 	return nil
 }
 
-func (s *ConfigService) mapBuildAssetsToReleaseAssets(eventReleaseID, manifestURL string, checksum *string, assetIndex []map[string]any) []postgres.UpsertEventReleaseAssetParams {
+func (s *ConfigService) mapBuildAssetsToReleaseAssets(eventReleaseID, manifestURL, assetIndexURL string, checksum *string, assetIndex []map[string]any) []postgres.UpsertEventReleaseAssetParams {
 	assets := []postgres.UpsertEventReleaseAssetParams{
 		{
 			EventReleaseID: eventReleaseID,
@@ -652,6 +666,13 @@ func (s *ConfigService) mapBuildAssetsToReleaseAssets(eventReleaseID, manifestUR
 			Checksum:       checksum,
 			Meta:           map[string]any{"source": "published-build"},
 		},
+		{
+			EventReleaseID: eventReleaseID,
+			AssetType:      "other",
+			AssetKey:       "asset-index",
+			AssetURL:       assetIndexURL,
+			Meta:           map[string]any{"source": "published-build"},
+		},
 	}
 
 	for _, asset := range assetIndex {

+ 20 - 0
backend/scripts/start-dev.ps1

@@ -12,11 +12,31 @@ $env:DATABASE_URL = if ($env:DATABASE_URL) { $env:DATABASE_URL } else { "postgre
 $env:JWT_ACCESS_SECRET = if ($env:JWT_ACCESS_SECRET) { $env:JWT_ACCESS_SECRET } else { "change-me-in-production" }
 $env:AUTH_SMS_PROVIDER = if ($env:AUTH_SMS_PROVIDER) { $env:AUTH_SMS_PROVIDER } else { "console" }
 $env:WECHAT_MINI_DEV_PREFIX = if ($env:WECHAT_MINI_DEV_PREFIX) { $env:WECHAT_MINI_DEV_PREFIX } else { "dev-" }
+$env:LOCAL_EVENT_DIR = if ($env:LOCAL_EVENT_DIR) { $env:LOCAL_EVENT_DIR } else { "D:\dev\cmr-mini\event" }
+$env:ASSET_BASE_URL = if ($env:ASSET_BASE_URL) { $env:ASSET_BASE_URL } else { "https://oss-mbh5.colormaprun.com/gotomars" }
+$env:ASSET_PUBLIC_BASE_URL = if ($env:ASSET_PUBLIC_BASE_URL) { $env:ASSET_PUBLIC_BASE_URL } else { "https://oss-mbh5.colormaprun.com" }
+$env:ASSET_BUCKET_ROOT = if ($env:ASSET_BUCKET_ROOT) { $env:ASSET_BUCKET_ROOT } else { "oss://color-map-html" }
+$env:OSSUTIL_PATH = if ($env:OSSUTIL_PATH) { $env:OSSUTIL_PATH } else { "D:\dev\cmr-mini\tools\ossutil.exe" }
+$env:OSSUTIL_CONFIG_FILE = if ($env:OSSUTIL_CONFIG_FILE) { $env:OSSUTIL_CONFIG_FILE } else { (Join-Path $HOME ".ossutilconfig") }
+
+if (-not (Test-Path $env:LOCAL_EVENT_DIR)) {
+  throw ("LOCAL_EVENT_DIR not found: " + $env:LOCAL_EVENT_DIR)
+}
+
+if (-not (Test-Path $env:OSSUTIL_PATH)) {
+  Write-Warning ("OSSUTIL_PATH not found: " + $env:OSSUTIL_PATH)
+}
+
+if (-not (Test-Path $env:OSSUTIL_CONFIG_FILE)) {
+  Write-Warning ("OSSUTIL_CONFIG_FILE not found: " + $env:OSSUTIL_CONFIG_FILE)
+}
 
 Write-Host "CMR backend dev server" -ForegroundColor Cyan
 Write-Host ("APP_ENV=" + $env:APP_ENV)
 Write-Host ("HTTP_ADDR=" + $env:HTTP_ADDR)
 Write-Host ("DATABASE_URL=" + $env:DATABASE_URL)
+Write-Host ("LOCAL_EVENT_DIR=" + $env:LOCAL_EVENT_DIR)
+Write-Host ("ASSET_BASE_URL=" + $env:ASSET_BASE_URL)
 Write-Host ""
 Write-Host "Workbench:" -ForegroundColor Yellow
 $workbenchAddr = $env:HTTP_ADDR

+ 13 - 0
backend/start-backend.ps1

@@ -0,0 +1,13 @@
+Set-StrictMode -Version Latest
+$ErrorActionPreference = "Stop"
+
+$backendDir = Split-Path -Parent $MyInvocation.MyCommand.Path
+$scriptPath = Join-Path $backendDir "scripts\start-dev.ps1"
+
+if (-not (Test-Path $scriptPath)) {
+  throw ("Backend start script not found: " + $scriptPath)
+}
+
+Set-Location $backendDir
+
+powershell -ExecutionPolicy Bypass -File $scriptPath

+ 696 - 0
doc/backend/业务后端数据库初版方案.md

@@ -0,0 +1,696 @@
+# 业务后端数据库初版方案
+
+## 1. 目标
+
+本文档定义本项目业务后端第一版数据库方案。
+
+第一版目标不是一次性覆盖整个平台所有能力,而是先稳定支撑以下范围:
+
+- 微信小程序登录与用户身份
+- 用户身体资料
+- 多租户与俱乐部基础隔离
+- 首页卡片、赛事、地图、Event 查询
+- 配置对象的版本化管理与发布
+- `launch` 启动与 `session_token`
+- 小程序业务层与配置驱动游戏层对接
+
+明确不纳入第一版数据库的能力:
+
+- 支付与分账
+- UGC 审核流
+- 订单退款
+- 成绩回放明细
+- GPS / 心率明细归档
+- 复杂运营报表
+
+这些能力建议放到后续 migration。
+
+## 2. 核心原则
+
+### 2.1 业务数据与配置数据分层
+
+数据库里同时存在两类对象:
+
+- 业务状态对象
+- 配置发布对象
+
+两者要分层,不要揉成一个“超级事件表”。
+
+### 2.2 运行态配置仍然走发布产物
+
+数据库管理的是编辑态与发布关系,客户端运行时最终仍然消费静态发布结果,例如:
+
+- `manifest_url`
+- `manifest_checksum_sha256`
+- 地图资源路径
+- Event 发布配置
+
+不要让客户端在运行时直接拼数据库对象。
+
+### 2.3 游戏规则不进业务表细字段
+
+业务后端负责:
+
+- 用户
+- 赛事
+- 报名
+- 发布
+- 启动
+
+业务后端不应该成为所有玩法字段的解释器。玩法细节优先放在版本化 `jsonb` 内容中。
+
+### 2.4 所有对外 ID 使用 public_id
+
+数据库内部主键统一使用 `uuid`。
+
+对客户端暴露的对象使用稳定 `public_id`:
+
+- `user_public_id`
+- `competition_public_id`
+- `map_public_id`
+- `event_public_id`
+- `release_public_id`
+- `session_public_id`
+
+原因:
+
+- 内外 ID 解耦
+- 便于迁移
+- 便于白标和多端统一
+- 避免顺序 ID 暴露内部增长信息
+
+### 2.5 token 只存 hash,不存明文
+
+以下内容不应明文入库:
+
+- 短信验证码
+- refresh token
+- session token
+
+数据库只保存 hash。
+
+## 3. 第一版建议模块
+
+第一版数据库建议拆成 6 组:
+
+1. 租户与组织
+2. 用户与登录
+3. 配置对象与发布
+4. 赛事业务对象
+5. 页面与卡片
+6. 启动与 session
+
+## 4. 表清单
+
+### 4.1 租户与组织
+
+#### `tenants`
+
+平台租户。
+
+建议字段:
+
+- `id`
+- `tenant_code`
+- `name`
+- `status`
+- `theme_jsonb`
+- `settings_jsonb`
+- `created_at`
+- `updated_at`
+
+说明:
+
+- `theme_jsonb` 存白标主题
+- `settings_jsonb` 存租户级开关
+
+#### `clubs`
+
+租户下的俱乐部或品牌实体。
+
+建议字段:
+
+- `id`
+- `tenant_id`
+- `club_code`
+- `name`
+- `status`
+- `profile_jsonb`
+- `created_at`
+- `updated_at`
+
+说明:
+
+- 第一版建议 club 从属于 tenant
+- 不建议一开始做过深的组织树
+
+### 4.2 用户与登录
+
+#### `app_users`
+
+平台用户主表。
+
+建议字段:
+
+- `id`
+- `user_public_id`
+- `default_tenant_id`
+- `status`
+- `nickname`
+- `avatar_url`
+- `last_login_at`
+- `created_at`
+- `updated_at`
+
+#### `login_identities`
+
+登录身份绑定表。
+
+建议字段:
+
+- `id`
+- `user_id`
+- `identity_type`
+- `provider`
+- `provider_subject`
+- `country_code`
+- `mobile`
+- `status`
+- `profile_jsonb`
+- `created_at`
+- `updated_at`
+
+身份示例:
+
+- 手机号
+- 微信 `openid`
+- 微信 `unionid`
+
+#### `client_devices`
+
+客户端设备标识记录。
+
+建议字段:
+
+- `id`
+- `device_key`
+- `platform`
+- `first_seen_at`
+- `last_seen_at`
+- `meta_jsonb`
+
+说明:
+
+- `device_key` 对应前端 `device_id`
+
+#### `auth_sms_codes`
+
+短信验证码发送与校验记录。
+
+建议字段:
+
+- `id`
+- `scene`
+- `country_code`
+- `mobile`
+- `client_type`
+- `device_key`
+- `code_hash`
+- `provider_payload_jsonb`
+- `expires_at`
+- `cooldown_until`
+- `consumed_at`
+- `created_at`
+
+#### `auth_refresh_tokens`
+
+刷新 token 持久化表。
+
+建议字段:
+
+- `id`
+- `user_id`
+- `client_type`
+- `device_key`
+- `token_hash`
+- `issued_at`
+- `expires_at`
+- `revoked_at`
+- `replaced_by_token_id`
+
+#### `user_body_profiles`
+
+用户当前身体档案。
+
+建议字段:
+
+- `id`
+- `user_id`
+- `status`
+- `completed_at`
+- `current_version_id`
+- `created_at`
+- `updated_at`
+
+#### `user_body_profile_versions`
+
+身体档案历史版本。
+
+建议字段:
+
+- `id`
+- `profile_id`
+- `version_no`
+- `gender`
+- `birth_date`
+- `height_cm`
+- `weight_kg`
+- `resting_heart_rate_bpm`
+- `max_heart_rate_bpm`
+- `created_at`
+
+### 4.3 配置对象与发布
+
+这一组直接对应你现有的配置驱动架构。
+
+#### `maps` / `map_versions`
+
+地图对象及版本。
+
+主表管理:
+
+- `map_public_id`
+- `slug`
+- `name`
+- `status`
+- `tenant_id`
+- `current_version_id`
+
+版本表管理:
+
+- `version_no`
+- `content_jsonb`
+- `created_at`
+
+#### `playfields` / `playfield_versions`
+
+路线、点位、场地定义。
+
+#### `game_modes` / `game_mode_versions`
+
+玩法模式配置,例如:
+
+- `classic-sequential`
+- `score-o`
+
+#### `resource_packs` / `resource_pack_versions`
+
+资源包,例如:
+
+- 音效
+- 素材包
+- UI 资源集
+
+#### `events` / `event_versions`
+
+Event 本身与版本。
+
+建议:
+
+- `events` 管对象身份
+- `event_versions` 管编辑态装配结果
+
+`event_versions` 推荐显式引用:
+
+- `map_version_id`
+- `playfield_version_id`
+- `game_mode_version_id`
+- `resource_pack_version_id`
+
+同时保留:
+
+- `content_jsonb`
+
+这样既有强关系,又保留灵活字段。
+
+#### `event_releases`
+
+发布记录表。
+
+建议字段:
+
+- `id`
+- `release_public_id`
+- `event_id`
+- `event_version_id`
+- `release_no`
+- `manifest_url`
+- `manifest_checksum_sha256`
+- `status`
+- `published_by_user_id`
+- `published_at`
+- `payload_jsonb`
+
+说明:
+
+- 客户端主要读这张表产出的 URL 与校验值
+- `events.current_release_id` 可指向当前对外生效版本
+
+### 4.4 赛事业务对象
+
+#### `competitions`
+
+赛事主表。
+
+建议字段:
+
+- `id`
+- `competition_public_id`
+- `tenant_id`
+- `club_id`
+- `slug`
+- `display_name`
+- `status`
+- `registration_enabled`
+- `leaderboard_enabled`
+- `realtime_board_enabled`
+- `competition_start_at`
+- `competition_end_at`
+- `content_jsonb`
+- `created_at`
+- `updated_at`
+
+#### `competition_events`
+
+赛事与 Event 的关联表。
+
+建议字段:
+
+- `id`
+- `competition_id`
+- `event_id`
+- `event_release_id`
+- `is_default`
+- `sort_order`
+- `relation_status`
+- `created_at`
+
+说明:
+
+- 支持赛事绑定多个 Event
+- 支持按赛事锁定某个 release
+
+#### `registrations`
+
+报名记录。
+
+建议字段:
+
+- `id`
+- `registration_public_id`
+- `competition_id`
+- `user_id`
+- `group_id`
+- `status`
+- `form_payload_jsonb`
+- `approved_at`
+- `cancelled_at`
+- `created_at`
+- `updated_at`
+
+说明:
+
+- 第一版先不强行拆复杂参赛人结构
+- `form_payload_jsonb` 足够承接早期变化
+
+### 4.5 页面与卡片
+
+#### `page_configs` / `page_config_versions`
+
+H5 / 白标页面配置。
+
+主表建议字段:
+
+- `id`
+- `tenant_id`
+- `club_id`
+- `page_code`
+- `name`
+- `status`
+- `current_version_id`
+- `created_at`
+- `updated_at`
+
+版本表建议字段:
+
+- `id`
+- `page_config_id`
+- `version_no`
+- `dsl_jsonb`
+- `theme_jsonb`
+- `feature_flags_jsonb`
+- `status`
+- `created_at`
+
+#### `cards`
+
+首页卡片与运营入口。
+
+建议字段:
+
+- `id`
+- `card_public_id`
+- `tenant_id`
+- `club_id`
+- `card_type`
+- `display_name`
+- `competition_id`
+- `event_id`
+- `map_id`
+- `page_config_id`
+- `html_url`
+- `cover_url`
+- `display_slot`
+- `display_priority`
+- `status`
+- `starts_at`
+- `ends_at`
+- `created_at`
+- `updated_at`
+
+说明:
+
+- 这张表直接支撑 `/cards`
+- 允许卡片指向赛事、页面或其他目标
+
+### 4.6 启动与 session
+
+#### `game_sessions`
+
+游戏启动记录。
+
+建议字段:
+
+- `id`
+- `session_public_id`
+- `tenant_id`
+- `user_id`
+- `competition_id`
+- `registration_id`
+- `event_id`
+- `event_release_id`
+- `launch_request_id`
+- `participant_public_id`
+- `device_key`
+- `client_type`
+- `route_code`
+- `status`
+- `session_token_hash`
+- `session_token_expires_at`
+- `realtime_endpoint`
+- `realtime_token_hash`
+- `launched_at`
+- `started_at`
+- `ended_at`
+- `created_at`
+- `updated_at`
+
+说明:
+
+- 第一版闭环到 `launch` 即可
+- `session_token` 用于后续 session 相关接口开放后继续扩展
+- `launch_request_id` 需要唯一,支撑幂等
+
+## 5. 当前 API 到表的映射
+
+### `POST /auth/sms/send`
+
+写:
+
+- `auth_sms_codes`
+
+### `POST /auth/login/sms`
+
+读写:
+
+- `auth_sms_codes`
+- `app_users`
+- `login_identities`
+- `user_body_profiles`
+- `user_body_profile_versions`
+- `auth_refresh_tokens`
+
+### `POST /auth/login/wechat`
+
+读写:
+
+- `app_users`
+- `login_identities`
+- `user_body_profiles`
+- `user_body_profile_versions`
+- `auth_refresh_tokens`
+
+### `POST /auth/refresh`
+
+读写:
+
+- `auth_refresh_tokens`
+
+### `PUT /me/body-profile`
+
+读写:
+
+- `user_body_profiles`
+- `user_body_profile_versions`
+
+### `GET /cards`
+
+读:
+
+- `cards`
+- 可选补充 `competitions`
+
+### `GET /competitions/{competition_id}`
+
+读:
+
+- `competitions`
+- `competition_events`
+- `events`
+- `event_releases`
+- `registrations`
+
+### `GET /events/{event_id}` / `GET /competitions/{competition_id}/events/{event_id}`
+
+读:
+
+- `events`
+- `event_releases`
+- `maps`
+- `competitions`
+- `registrations`
+
+### `POST /competitions/{competition_id}/registrations`
+
+写:
+
+- `registrations`
+
+### `POST /events/{event_id}/launch`
+
+读写:
+
+- `events`
+- `event_releases`
+- `registrations` 可选
+- `game_sessions`
+
+## 6. 第一版不建议做复杂拆分的地方
+
+以下字段第一版优先用 `jsonb`,不要先做一堆子表:
+
+- 赛事详情扩展内容
+- 报名附加表单
+- 页面 DSL
+- theme 配置
+- feature flags
+- Event 覆盖项
+- 配置对象的实验字段
+
+原因很简单:
+
+- 你现在业务和玩法都在快速变化
+- 先保留灵活性比过度范式化更重要
+
+## 7. 第一版不建议进入数据库的内容
+
+第一版不建议落库:
+
+- 实时网关运行态内存结构
+- GPS 点逐秒明细
+- 心率逐秒明细
+- WebGL 渲染状态
+- 设备桥接瞬时事件
+
+这些应该仍然留在:
+
+- `realtime-gateway`
+- 对象存储
+- 后续归档服务
+
+不应直接压进业务主库。
+
+## 8. 推荐 migration 顺序
+
+建议按下面顺序建表:
+
+1. 租户与组织
+2. 用户与登录
+3. 配置对象与版本表
+4. 发布表
+5. 赛事与关联
+6. 页面与卡片
+7. session
+
+这样依赖关系最清晰。
+
+## 9. 第二版可新增的模块
+
+建议后续 migration 再补:
+
+- `orders`
+- `payment_transactions`
+- `refunds`
+- `ugc_assets`
+- `ugc_posts`
+- `ugc_reviews`
+- `session_uploads`
+- `session_results`
+- `gps_tracks`
+- `heart_rate_streams`
+- `channel_entries`
+- `campaigns`
+
+## 10. 当前最适合你的起步方式
+
+如果你现在准备开始做后端,我建议不要先写所有 API,而是按这个顺序开工:
+
+1. 先建数据库与 migration
+2. 先写用户、赛事、Event、launch 这 4 个核心域
+3. 先让小程序跑通登录 -> 看赛事 -> launch -> 进入游戏
+4. 再补报名
+5. 再补页面配置、卡片、俱乐部首页
+6. 支付和 UGC 放到后续版本
+
+## 11. 一句话总结
+
+第一版数据库应该同时支撑两件事:
+
+- 业务闭环
+- 配置发布
+
+但不能把它们混成一套随意增长的表结构。
+
+正确方向是:
+
+> PostgreSQL 存业务状态 + 版本化配置对象,Go API 负责查询与发布编排,客户端继续消费发布后的运行态配置。

+ 2 - 0
doc/debug/模拟器多通道联调最小方案.md

@@ -95,6 +95,8 @@
 
 也就是说,一个模拟器页面实例默认对应一个通道。
 
+当前这个输入已经提升到工作台顶部,作为全局调试参数,不再挂在“定位发送”分组下面。
+
 ## 小程序侧
 
 调试面板提供一个统一输入:

+ 8 - 9
doc/debug/模拟器控制面板重构方案.md

@@ -28,10 +28,10 @@
 
 新版面板采用工作台布局:
 
-- 顶部:连接状态条
+- 顶部:连接状态条与全局模拟通道号
 - 左侧:控制区
 - 中间:地图与路径预览
-- 右侧:状态摘要与快捷观察
+- 右侧:运行摘要、当前位置、最近事件
 - 右下:调试日志浮层
 
 ## 功能分区
@@ -44,6 +44,7 @@
 - 心率模拟连接状态
 - 调试日志连接状态
 - 一键连接开发调试源
+- 全局模拟通道号
 
 ### 2. 左侧控制区
 
@@ -65,13 +66,9 @@
 
 包含:
 
-- 当前经纬度
-- 当前航向
-- 当前路径点数
-- 最近发送状态
-- 最近心率发送状态
-- 资源加载摘要
-- 网关桥接摘要
+- 运行摘要
+- 当前位置
+- 最近事件
 
 ### 5. 日志区
 
@@ -80,6 +77,8 @@
 - 默认悬浮在地图右下
 - 可清空
 - 面积更大
+- 可缩到一角
+- 支持按 scope 过滤
 - 便于边看地图边看日志
 
 ## 实施顺序

+ 3 - 1
doc/debug/模拟器调试日志方案.md

@@ -103,8 +103,10 @@
 最小能力:
 
 - websocket 接收 `debug-log`
-- UI 新增“调试日志”区域
+- UI 使用右下角可缩放浮层承接“调试日志”
 - 仅显示 `debug-log`
+- 支持按 `scope` 过滤
+- 按当前 `channelId` 过滤显示
 - 保留最近若干条,避免无限增长
 
 ## 后续扩展

+ 6 - 6
doc/debug/调试文档索引.md

@@ -24,12 +24,12 @@
 
 ## 推荐阅读顺序
 
-1. [platform-capability-notes.md](/D:/dev/cmr-mini/doc/debug/平台能力说明.md)
-2. [mock-simulator-control-panel-proposal.md](/D:/dev/cmr-mini/doc/debug/模拟器控制面板重构方案.md)
-3. [sensor-current-summary.md](/D:/dev/cmr-mini/doc/debug/传感器现状总结.md)
-4. [mock-simulator-debug-log-proposal.md](/D:/dev/cmr-mini/doc/debug/模拟器调试日志方案.md)
-5. [multi-channel-simulator-minimal-plan.md](/D:/dev/cmr-mini/doc/debug/模拟器多通道联调最小方案.md)
-6. [compass-debugging-notes.md](/D:/dev/cmr-mini/doc/debug/罗盘排障记录.md)
+1. [平台能力说明](/D:/dev/cmr-mini/doc/debug/平台能力说明.md)
+2. [模拟器控制面板重构方案](/D:/dev/cmr-mini/doc/debug/模拟器控制面板重构方案.md)
+3. [传感器现状总结](/D:/dev/cmr-mini/doc/debug/传感器现状总结.md)
+4. [模拟器调试日志方案](/D:/dev/cmr-mini/doc/debug/模拟器调试日志方案.md)
+5. [模拟器多通道联调最小方案](/D:/dev/cmr-mini/doc/debug/模拟器多通道联调最小方案.md)
+6. [罗盘排障记录](/D:/dev/cmr-mini/doc/debug/罗盘排障记录.md)
 
 ## 使用建议
 

+ 233 - 0
f2b.md

@@ -0,0 +1,233 @@
+# F2B 协作清单
+
+本文档由前端维护,用于记录:
+
+- 前端当前联调状态
+- 需要后端确认或配合的事项
+- 已明确的接口契约与运行时语义
+
+约定:
+
+- `f2b.md` 由前端维护
+- `b2f.md` 由后端维护
+- 双方只维护自己的文件
+- 边界不清的事项,先写入文档,再由你确认
+
+---
+
+## 1. 当前前端联调状态
+
+当前小程序侧已经接通:
+
+- 微信小程序登录
+- 首页聚合
+- 活动页 `play`
+- `launch -> 地图页`
+- `session start`
+- `session finish`
+- `session result`
+- 故障恢复提示与恢复继续
+- 故障恢复放弃
+
+当前已确认不再是 backend 阻塞项:
+
+- `evt_demo_001` 的 release manifest 现在可正常加载
+- 地图页已经能正常拉起
+- 模拟定位 / 模拟日志的通道与连接问题,当前主要在小程序与模拟器侧处理
+
+---
+
+## 2. 前端已按当前契约实现
+
+### 2.1 launch 契约
+
+前端当前按以下字段消费:
+
+- `launch.resolvedRelease.releaseId`
+- `launch.resolvedRelease.manifestUrl`
+- `launch.resolvedRelease.manifestChecksumSha256`
+- `launch.config.configUrl`
+- `launch.config.configLabel`
+- `launch.config.releaseId`
+- `launch.config.routeCode`
+- `launch.business.sessionId`
+- `launch.business.sessionToken`
+- `launch.business.sessionTokenExpiresAt`
+
+当前前端约束:
+
+- 正式联调只认后端 `launch` 下发的 release / manifest
+- 不再回退到本地 `event/*.json` 样例路径
+- 如果 `manifestUrl` 无效,会直接在地图页报错
+
+### 2.2 session 生命周期
+
+前端当前已接:
+
+- 进入运行态后自动上报 `session start`
+- 正常结束时上报 `finish(finished)`
+- 超时结束时上报 `finish(failed)`
+- 主动退出时上报 `finish(cancelled)`
+
+### 2.3 故障恢复
+
+前端当前已接:
+
+- 检测到未正常结束对局时,弹出“继续恢复 / 放弃”
+- 点击“继续恢复”时恢复本地运行时快照
+- 点击“放弃”时:
+  - 清理本地恢复快照
+  - 并使用**恢复快照中的旧 sessionId/sessionToken** 向后端补报 `finish(cancelled)`
+
+当前实现口径:
+
+- 放弃恢复不会阻塞用户
+- 即使 backend 上报失败,前端也会继续放弃本地恢复
+- 失败时前端会明确提示“后端取消上报失败”
+
+---
+
+## 3. 需要 backend 当前确认 / 配合
+
+## 3.1 固定 session 三态语义
+
+请 backend 明确并固定:
+
+- `finished`
+- `failed`
+- `cancelled`
+
+前端当前使用口径:
+
+- 正常打终点完成 -> `finished`
+- 超时结束 -> `failed`
+- 主动退出 / 放弃恢复 -> `cancelled`
+
+如果 backend 计划使用其他语义,请先在 `b2f.md` 明确,不要直接改单接口行为。
+
+## 3.2 确认“放弃恢复 -> cancelled”是官方语义
+
+前端现在已经启用:
+
+- 点击“放弃恢复”时,调用 `POST /sessions/{id}/finish`
+- 参数:`status=cancelled`
+
+请 backend 确认:
+
+1. 这是否就是官方的“放弃恢复 / 放弃本局”语义
+2. 旧 `sessionToken` 是否允许在恢复放弃场景继续调用 `finish(cancelled)`
+3. `cancelled` 后是否保证不再作为 `ongoingSession` 出现在:
+   - `/me/entry-home`
+   - `/events/{eventPublicID}/play`
+
+## 3.3 保证 start / finish 幂等
+
+请 backend 明确:
+
+- `start` 重复调用是否安全
+- `finish` 重复调用是否安全
+
+前端建议口径:
+
+- `start`:如果 session 已在运行态,返回成功和当前 session
+- `finish`:如果 session 已进入终态,返回成功和当前 session/result
+
+原因:
+
+- 联调重试
+- 页面重进
+- 故障恢复补报
+- 用户重复点击
+
+这些都很容易触发重复请求。
+
+## 3.4 回归确认 ongoing session 口径一致
+
+请 backend 回归确认以下接口对 ongoing session 的口径一致:
+
+- `/me/entry-home`
+- `/events/{eventPublicID}/play`
+- `/sessions/{sessionPublicID}/result`
+
+重点确认:
+
+1. `cancelled` 后不再继续出现在 ongoing 入口
+2. `failed` 后不再继续出现在 ongoing 入口
+3. `finished` 后结果摘要和首页摘要保持一致
+
+## 3.5 保持 launch 返回契约稳定
+
+当前前端已经按既定结构接好 `launch`。
+
+请 backend:
+
+- 保持字段名稳定
+- 如需调整字段名或层级,先在 `b2f.md` 里给出变更说明
+- 尤其不要在未通知前端的情况下,改变:
+  - `resolvedRelease.manifestUrl`
+  - `business.sessionId`
+  - `business.sessionToken`
+
+---
+
+## 4. P1 后续建议
+
+## 4.1 用户身体数据接口
+
+前端已经有 telemetry profile 合并能力。
+
+backend 后续建议提供:
+
+- 当前用户 body profile 查询接口
+
+建议至少包含:
+
+- `birthDate` 或 `heartRateAge`
+- `weightKg`
+- `restingHeartRateBpm`
+- `maxHeartRateBpm`(可选)
+
+## 4.2 result 摘要字段容错
+
+前端当前 finish 可能上报:
+
+- `finalDurationSec`
+- `finalScore`
+- `completedControls`
+- `totalControls`
+- `distanceMeters`
+- `averageSpeedKmh`
+- `maxHeartRateBpm`
+
+请 backend:
+
+- 对可选字段做空值容忍
+- 不要因某个非关键字段缺失导致整局 finish 失败
+
+## 4.3 workbench 增加恢复相关调试项
+
+建议 backend workbench 后续增加:
+
+- 将 session 标记为 `cancelled`
+- 查询当前用户 ongoing session
+- 查看最近一局状态流转
+
+这样更利于故障恢复联调。
+
+---
+
+## 5. 当前前端需要 backend 反馈的最小集合
+
+backend 现在只要先在 `b2f.md` 回 3 件事,前后端主链就能更稳:
+
+1. `finished / failed / cancelled` 三态最终语义
+2. 放弃恢复时 `finish(cancelled)` 是否是正式方案
+3. `start / finish` 是否按幂等处理
+
+---
+
+## 6. 一句话结论
+
+当前前端最需要 backend 配合的,不是更多新接口,而是:
+
+> 先把 session 生命周期语义、放弃恢复语义和 ongoing session 口径完全定稳。

+ 5 - 1
miniprogram/app.json

@@ -1,9 +1,13 @@
 {
   "pages": [
+    "pages/index/index",
+    "pages/login/login",
+    "pages/home/home",
+    "pages/event/event",
+    "pages/result/result",
     "pages/map/map",
     "pages/experience-webview/experience-webview",
     "pages/webview-test/webview-test",
-    "pages/index/index",
     "pages/logs/logs"
   ],
   "window": {

+ 7 - 0
miniprogram/app.ts

@@ -1,9 +1,16 @@
+import { loadBackendAuthTokens, loadBackendBaseUrl } from './utils/backendAuth'
+
 // app.ts
 App<IAppOption>({
   globalData: {
     telemetryPlayerProfile: null,
+    backendBaseUrl: null,
+    backendAuthTokens: null,
   },
   onLaunch() {
+    this.globalData.backendBaseUrl = loadBackendBaseUrl()
+    this.globalData.backendAuthTokens = loadBackendAuthTokens()
+
     // 展示本地存储能力
     const logs = wx.getStorageSync('logs') || []
     logs.unshift(Date.now())

+ 76 - 0
miniprogram/engine/map/mapEngine.ts

@@ -387,6 +387,16 @@ export interface MapEngineGameInfoSnapshot {
 
 export type MapEngineResultSnapshot = ResultSummarySnapshot
 
+export interface MapEngineSessionFinishSummary {
+  status: 'finished' | 'failed' | 'cancelled'
+  finalDurationSec?: number
+  finalScore?: number
+  completedControls?: number
+  totalControls?: number
+  distanceMeters?: number
+  averageSpeedKmh?: number
+}
+
 const VIEW_SYNC_KEYS: Array<keyof MapEngineViewState> = [
   'animationLevel',
   'buildVersion',
@@ -1774,6 +1784,41 @@ export class MapEngine {
     )
   }
 
+  getSessionFinishSummary(statusOverride?: 'finished' | 'failed' | 'cancelled'): MapEngineSessionFinishSummary | null {
+    const definition = this.gameRuntime.definition
+    const sessionState = this.gameRuntime.state
+    if (!definition || !sessionState) {
+      return null
+    }
+
+    let status: 'finished' | 'failed' | 'cancelled'
+    if (statusOverride) {
+      status = statusOverride
+    } else if (sessionState.endReason === 'timed_out' || sessionState.status === 'failed') {
+      status = 'failed'
+    } else {
+      status = 'finished'
+    }
+
+    const endAt = sessionState.endedAt !== null ? sessionState.endedAt : Date.now()
+    const finalDurationSec = sessionState.startedAt !== null
+      ? Math.max(0, Math.floor((endAt - sessionState.startedAt) / 1000))
+      : undefined
+    const totalControls = definition.controls.filter((control) => control.kind === 'control').length
+
+    return {
+      status,
+      finalDurationSec,
+      finalScore: this.getTotalSessionScore(),
+      completedControls: sessionState.completedControlIds.length,
+      totalControls,
+      distanceMeters: this.telemetryRuntime.state.distanceMeters,
+      averageSpeedKmh: this.telemetryRuntime.state.averageSpeedKmh === null
+        ? undefined
+        : this.telemetryRuntime.state.averageSpeedKmh,
+    }
+  }
+
   buildSessionRecoveryRuntimeSnapshot(): RecoveryRuntimeSnapshot | null {
     const definition = this.gameRuntime.definition
     const state = this.gameRuntime.state
@@ -3577,7 +3622,14 @@ export class MapEngine {
   }
 
   handleSetMockLocationMode(): void {
+    const wasListening = this.locationController.listening
+    if (!this.locationController.mockBridge.connected && !this.locationController.mockBridge.connecting) {
+      this.locationController.connectMockBridge()
+    }
     this.locationController.setSourceMode('mock')
+    if (!wasListening && !this.locationController.listening) {
+      this.locationController.start()
+    }
   }
 
   handleConnectMockLocationBridge(): void {
@@ -3594,9 +3646,26 @@ export class MapEngine {
 
   handleSetMockChannelId(channelId: string): void {
     const normalized = String(channelId || '').trim() || 'default'
+    const shouldReconnectLocation = this.locationController.mockBridge.connected || this.locationController.mockBridge.connecting
+    const locationBridgeUrl = this.locationController.mockBridgeUrl
+    const shouldReconnectHeartRate = this.heartRateController.mockBridge.connected || this.heartRateController.mockBridge.connecting
+    const heartRateBridgeUrl = this.heartRateController.mockBridgeUrl
+    const shouldReconnectDebugLog = this.mockSimulatorDebugLogger.enabled
     this.locationController.setMockChannelId(normalized)
     this.heartRateController.setMockChannelId(normalized)
     this.mockSimulatorDebugLogger.setChannelId(normalized)
+    if (shouldReconnectLocation) {
+      this.locationController.disconnectMockBridge()
+      this.locationController.connectMockBridge(locationBridgeUrl)
+    }
+    if (shouldReconnectHeartRate) {
+      this.heartRateController.disconnectMockBridge()
+      this.heartRateController.connectMockBridge(heartRateBridgeUrl)
+    }
+    if (shouldReconnectDebugLog) {
+      this.mockSimulatorDebugLogger.disconnect()
+      this.mockSimulatorDebugLogger.connect()
+    }
     this.setState({
       mockChannelIdText: normalized,
     })
@@ -3663,7 +3732,14 @@ export class MapEngine {
   }
 
   handleSetMockHeartRateMode(): void {
+    const wasConnected = this.heartRateController.connected
+    if (!this.heartRateController.mockBridge.connected && !this.heartRateController.mockBridge.connecting) {
+      this.heartRateController.connectMockBridge()
+    }
     this.heartRateController.setSourceMode('mock')
+    if (!wasConnected && !this.heartRateController.connected) {
+      this.heartRateController.startScanAndConnect()
+    }
   }
 
   handleConnectMockHeartRateBridge(): void {

+ 3 - 0
miniprogram/pages/event/event.json

@@ -0,0 +1,3 @@
+{
+  "navigationBarTitleText": "活动"
+}

+ 123 - 0
miniprogram/pages/event/event.ts

@@ -0,0 +1,123 @@
+import { loadBackendAuthTokens, loadBackendBaseUrl } from '../../utils/backendAuth'
+import { getEventPlay, launchEvent, type BackendEventPlayResult } from '../../utils/backendApi'
+import { adaptBackendLaunchResultToEnvelope } from '../../utils/backendLaunchAdapter'
+import { prepareMapPageUrlForLaunch } from '../../utils/gameLaunch'
+
+type EventPageData = {
+  eventId: string
+  loading: boolean
+  titleText: string
+  summaryText: string
+  releaseText: string
+  actionText: string
+  statusText: string
+}
+
+function getAccessToken(): string | null {
+  const app = getApp<IAppOption>()
+  const tokens = app.globalData && app.globalData.backendAuthTokens
+    ? app.globalData.backendAuthTokens
+    : loadBackendAuthTokens()
+  return tokens && tokens.accessToken ? tokens.accessToken : null
+}
+
+Page({
+  data: {
+    eventId: '',
+    loading: false,
+    titleText: '活动详情',
+    summaryText: '未加载',
+    releaseText: '--',
+    actionText: '--',
+    statusText: '待加载',
+  } as EventPageData,
+
+  onLoad(query: { eventId?: string }) {
+    const eventId = query && query.eventId ? decodeURIComponent(query.eventId) : ''
+    if (!eventId) {
+      this.setData({
+        statusText: '缺少 eventId',
+      })
+      return
+    }
+    this.setData({ eventId })
+    this.loadEventPlay(eventId)
+  },
+
+  async loadEventPlay(eventId?: string) {
+    const targetEventId = eventId || this.data.eventId
+    const accessToken = getAccessToken()
+    if (!accessToken) {
+      wx.redirectTo({ url: '/pages/login/login' })
+      return
+    }
+
+    this.setData({
+      loading: true,
+      statusText: '正在加载活动上下文',
+    })
+
+    try {
+      const result = await getEventPlay({
+        baseUrl: loadBackendBaseUrl(),
+        eventId: targetEventId,
+        accessToken,
+      })
+      this.applyEventPlay(result)
+    } catch (error) {
+      const message = error && (error as { message?: string }).message ? (error as { message: string }).message : '未知错误'
+      this.setData({
+        loading: false,
+        statusText: `活动加载失败:${message}`,
+      })
+    }
+  },
+
+  applyEventPlay(result: BackendEventPlayResult) {
+    this.setData({
+      loading: false,
+      titleText: result.event.displayName,
+      summaryText: result.event.summary || '暂无活动简介',
+      releaseText: result.resolvedRelease
+        ? `${result.resolvedRelease.configLabel} / ${result.resolvedRelease.releaseId}`
+        : '当前无可用 release',
+      actionText: `${result.play.primaryAction} / ${result.play.reason}`,
+      statusText: result.play.canLaunch ? '可启动' : '当前不可启动',
+    })
+  },
+
+  handleRefresh() {
+    this.loadEventPlay()
+  },
+
+  async handleLaunch() {
+    const accessToken = getAccessToken()
+    if (!accessToken) {
+      wx.redirectTo({ url: '/pages/login/login' })
+      return
+    }
+
+    this.setData({
+      statusText: '正在创建 session 并进入地图',
+    })
+
+    try {
+      const result = await launchEvent({
+        baseUrl: loadBackendBaseUrl(),
+        eventId: this.data.eventId,
+        accessToken,
+        clientType: 'wechat',
+        deviceKey: 'mini-dev-device-001',
+      })
+      const envelope = adaptBackendLaunchResultToEnvelope(result)
+      wx.navigateTo({
+        url: prepareMapPageUrlForLaunch(envelope),
+      })
+    } catch (error) {
+      const message = error && (error as { message?: string }).message ? (error as { message: string }).message : '未知错误'
+      this.setData({
+        statusText: `launch 失败:${message}`,
+      })
+    }
+  },
+})

+ 20 - 0
miniprogram/pages/event/event.wxml

@@ -0,0 +1,20 @@
+<scroll-view class="page" scroll-y>
+  <view class="shell">
+    <view class="hero">
+      <view class="hero__eyebrow">Event Play</view>
+      <view class="hero__title">{{titleText}}</view>
+      <view class="hero__desc">{{summaryText}}</view>
+    </view>
+
+    <view class="panel">
+      <view class="panel__title">开始前准备</view>
+      <view class="summary">Release:{{releaseText}}</view>
+      <view class="summary">主动作:{{actionText}}</view>
+      <view class="summary">状态:{{statusText}}</view>
+      <view class="actions">
+        <button class="btn btn--secondary" bindtap="handleRefresh">刷新</button>
+        <button class="btn btn--primary" bindtap="handleLaunch">开始比赛</button>
+      </view>
+    </view>
+  </view>
+</scroll-view>

+ 91 - 0
miniprogram/pages/event/event.wxss

@@ -0,0 +1,91 @@
+page {
+  min-height: 100vh;
+  background: linear-gradient(180deg, #eff4fb 0%, #e8eff7 100%);
+}
+
+.page {
+  min-height: 100vh;
+}
+
+.shell {
+  display: grid;
+  gap: 24rpx;
+  padding: 28rpx 24rpx 40rpx;
+}
+
+.hero,
+.panel {
+  display: grid;
+  gap: 16rpx;
+  padding: 24rpx;
+  border-radius: 24rpx;
+}
+
+.hero {
+  background: linear-gradient(135deg, #163a66 0%, #1f5da1 100%);
+  color: #ffffff;
+}
+
+.hero__eyebrow {
+  font-size: 22rpx;
+  letter-spacing: 0.16em;
+  text-transform: uppercase;
+  color: rgba(255, 255, 255, 0.72);
+}
+
+.hero__title {
+  font-size: 40rpx;
+  font-weight: 700;
+}
+
+.hero__desc {
+  font-size: 24rpx;
+  color: rgba(255, 255, 255, 0.84);
+  line-height: 1.6;
+}
+
+.panel {
+  background: rgba(255, 255, 255, 0.94);
+  box-shadow: 0 14rpx 32rpx rgba(40, 63, 95, 0.08);
+}
+
+.panel__title {
+  font-size: 30rpx;
+  font-weight: 700;
+  color: #17345a;
+}
+
+.summary {
+  font-size: 24rpx;
+  line-height: 1.6;
+  color: #30465f;
+}
+
+.actions {
+  display: flex;
+  gap: 16rpx;
+  flex-wrap: wrap;
+}
+
+.btn {
+  margin: 0;
+  min-height: 76rpx;
+  padding: 0 24rpx;
+  line-height: 76rpx;
+  border-radius: 18rpx;
+  font-size: 26rpx;
+}
+
+.btn::after {
+  border: 0;
+}
+
+.btn--primary {
+  background: #173d73;
+  color: #ffffff;
+}
+
+.btn--secondary {
+  background: #dfeaf8;
+  color: #173d73;
+}

+ 3 - 0
miniprogram/pages/home/home.json

@@ -0,0 +1,3 @@
+{
+  "navigationBarTitleText": "首页"
+}

+ 127 - 0
miniprogram/pages/home/home.ts

@@ -0,0 +1,127 @@
+import { clearBackendAuthTokens, loadBackendAuthTokens, loadBackendBaseUrl } from '../../utils/backendAuth'
+import { getEntryHome, type BackendCardResult, type BackendEntryHomeResult } from '../../utils/backendApi'
+
+const DEFAULT_CHANNEL_CODE = 'mini-demo'
+const DEFAULT_CHANNEL_TYPE = 'wechat_mini'
+
+type HomePageData = {
+  loading: boolean
+  statusText: string
+  userNameText: string
+  tenantText: string
+  channelText: string
+  ongoingSessionText: string
+  recentSessionText: string
+  cards: BackendCardResult[]
+}
+
+function requireAuthToken(): string | null {
+  const app = getApp<IAppOption>()
+  const tokens = app.globalData && app.globalData.backendAuthTokens
+    ? app.globalData.backendAuthTokens
+    : loadBackendAuthTokens()
+  return tokens && tokens.accessToken ? tokens.accessToken : null
+}
+
+Page({
+  data: {
+    loading: false,
+    statusText: '准备加载首页',
+    userNameText: '--',
+    tenantText: '--',
+    channelText: '--',
+    ongoingSessionText: '无',
+    recentSessionText: '无',
+    cards: [],
+  } as HomePageData,
+
+  onLoad() {
+    this.loadEntryHome()
+  },
+
+  onShow() {
+    this.loadEntryHome()
+  },
+
+  async loadEntryHome() {
+    const accessToken = requireAuthToken()
+    if (!accessToken) {
+      wx.redirectTo({ url: '/pages/login/login' })
+      return
+    }
+
+    this.setData({
+      loading: true,
+      statusText: '正在加载首页聚合',
+    })
+
+    try {
+      const result = await getEntryHome({
+        baseUrl: loadBackendBaseUrl(),
+        accessToken,
+        channelCode: DEFAULT_CHANNEL_CODE,
+        channelType: DEFAULT_CHANNEL_TYPE,
+      })
+      this.applyEntryHomeResult(result)
+    } catch (error) {
+      const message = error && (error as { message?: string }).message ? (error as { message: string }).message : '未知错误'
+      this.setData({
+        loading: false,
+        statusText: `首页加载失败:${message}`,
+      })
+    }
+  },
+
+  applyEntryHomeResult(result: BackendEntryHomeResult) {
+    this.setData({
+      loading: false,
+      statusText: '首页加载完成',
+      userNameText: result.user.nickname || result.user.publicId || result.user.id,
+      tenantText: `${result.tenant.name} (${result.tenant.code})`,
+      channelText: `${result.channel.displayName} / ${result.channel.code}`,
+      ongoingSessionText: result.ongoingSession
+        ? `${result.ongoingSession.eventName || result.ongoingSession.eventDisplayName || result.ongoingSession.eventId || result.ongoingSession.id || result.ongoingSession.sessionId} / ${result.ongoingSession.status || result.ongoingSession.sessionStatus}`
+        : '无',
+      recentSessionText: result.recentSession
+        ? `${result.recentSession.eventName || result.recentSession.eventDisplayName || result.recentSession.eventId || result.recentSession.id || result.recentSession.sessionId} / ${result.recentSession.status || result.recentSession.sessionStatus}`
+        : '无',
+      cards: result.cards || [],
+    })
+  },
+
+  handleRefresh() {
+    this.loadEntryHome()
+  },
+
+  handleOpenCard(event: WechatMiniprogram.TouchEvent) {
+    const eventId = event.currentTarget.dataset.eventId as string | undefined
+    if (!eventId) {
+      wx.showToast({
+        title: '该卡片暂无活动入口',
+        icon: 'none',
+      })
+      return
+    }
+
+    wx.navigateTo({
+      url: `/pages/event/event?eventId=${encodeURIComponent(eventId)}`,
+    })
+  },
+
+  handleOpenRecentResult() {
+    wx.navigateTo({
+      url: '/pages/result/result',
+    })
+  },
+
+  handleLogout() {
+    clearBackendAuthTokens()
+    const app = getApp<IAppOption>()
+    if (app.globalData) {
+      app.globalData.backendAuthTokens = null
+    }
+    wx.redirectTo({
+      url: '/pages/login/login',
+    })
+  },
+})

+ 32 - 0
miniprogram/pages/home/home.wxml

@@ -0,0 +1,32 @@
+<scroll-view class="page" scroll-y>
+  <view class="shell">
+    <view class="hero">
+      <view class="hero__eyebrow">Entry Home</view>
+      <view class="hero__title">{{userNameText}}</view>
+      <view class="hero__desc">{{tenantText}}</view>
+      <view class="hero__desc">{{channelText}}</view>
+    </view>
+
+    <view class="panel">
+      <view class="panel__title">当前状态</view>
+      <view class="summary">{{statusText}}</view>
+      <view class="summary">进行中:{{ongoingSessionText}}</view>
+      <view class="summary">最近一局:{{recentSessionText}}</view>
+      <view class="actions">
+        <button class="btn btn--secondary" bindtap="handleRefresh">刷新首页</button>
+        <button class="btn btn--ghost" bindtap="handleOpenRecentResult">查看结果</button>
+        <button class="btn btn--ghost" bindtap="handleLogout">退出登录</button>
+      </view>
+    </view>
+
+    <view class="panel">
+      <view class="panel__title">活动入口</view>
+      <view wx:if="{{!cards.length}}" class="summary">当前没有首页卡片</view>
+      <view wx:for="{{cards}}" wx:key="id" class="card" bindtap="handleOpenCard" data-event-id="{{item.event && item.event.id ? item.event.id : ''}}">
+        <view class="card__title">{{item.title}}</view>
+        <view class="card__subtitle">{{item.subtitle || (item.event && item.event.displayName ? item.event.displayName : '暂无副标题')}}</view>
+        <view class="card__meta">{{item.type}} / {{item.displaySlot}}</view>
+      </view>
+    </view>
+  </view>
+</scroll-view>

+ 111 - 0
miniprogram/pages/home/home.wxss

@@ -0,0 +1,111 @@
+page {
+  min-height: 100vh;
+  background: linear-gradient(180deg, #eff4fb 0%, #e8eff7 100%);
+}
+
+.page {
+  min-height: 100vh;
+}
+
+.shell {
+  display: grid;
+  gap: 24rpx;
+  padding: 28rpx 24rpx 40rpx;
+}
+
+.hero,
+.panel {
+  display: grid;
+  gap: 16rpx;
+  padding: 24rpx;
+  border-radius: 24rpx;
+}
+
+.hero {
+  background: linear-gradient(135deg, #163a66 0%, #1f5da1 100%);
+  color: #ffffff;
+}
+
+.hero__eyebrow {
+  font-size: 22rpx;
+  letter-spacing: 0.16em;
+  text-transform: uppercase;
+  color: rgba(255, 255, 255, 0.72);
+}
+
+.hero__title {
+  font-size: 40rpx;
+  font-weight: 700;
+}
+
+.hero__desc {
+  font-size: 24rpx;
+  color: rgba(255, 255, 255, 0.84);
+}
+
+.panel {
+  background: rgba(255, 255, 255, 0.94);
+  box-shadow: 0 14rpx 32rpx rgba(40, 63, 95, 0.08);
+}
+
+.panel__title {
+  font-size: 30rpx;
+  font-weight: 700;
+  color: #17345a;
+}
+
+.summary {
+  font-size: 24rpx;
+  line-height: 1.6;
+  color: #30465f;
+}
+
+.actions {
+  display: flex;
+  flex-wrap: wrap;
+  gap: 16rpx;
+}
+
+.btn {
+  margin: 0;
+  min-height: 76rpx;
+  padding: 0 24rpx;
+  line-height: 76rpx;
+  border-radius: 18rpx;
+  font-size: 26rpx;
+}
+
+.btn::after {
+  border: 0;
+}
+
+.btn--secondary {
+  background: #dfeaf8;
+  color: #173d73;
+}
+
+.btn--ghost {
+  background: #ffffff;
+  color: #52657d;
+  border: 2rpx solid #d8e2ec;
+}
+
+.card {
+  display: grid;
+  gap: 10rpx;
+  padding: 20rpx;
+  border-radius: 20rpx;
+  background: #f6f9fc;
+}
+
+.card__title {
+  font-size: 28rpx;
+  font-weight: 700;
+  color: #17345a;
+}
+
+.card__subtitle,
+.card__meta {
+  font-size: 22rpx;
+  color: #64748b;
+}

+ 8 - 49
miniprogram/pages/index/index.ts

@@ -1,52 +1,11 @@
-// index.ts
-// 获取应用实例
-const defaultAvatarUrl = 'https://mmbiz.qpic.cn/mmbiz/icTdbqWNOwNRna42FI242Lcia07jQodd2FJGIYQfG0LAJGFxM4FbnQP6yfMxBgJ0F3YRqJCJ1aPAK2dQagdusBZg/0'
+import { loadBackendAuthTokens } from '../../utils/backendAuth'
 
-Component({
-  data: {
-    motto: 'Hello World',
-    userInfo: {
-      avatarUrl: defaultAvatarUrl,
-      nickName: '',
-    },
-    hasUserInfo: false,
-    canIUseGetUserProfile: wx.canIUse('getUserProfile'),
-    canIUseNicknameComp: wx.canIUse('input.type.nickname'),
-  },
-  methods: {
-    // 事件处理函数
-    bindViewTap() {
-      wx.navigateTo({
-        url: '../logs/logs',
-      })
-    },
-    onChooseAvatar(e: any) {
-      const { avatarUrl } = e.detail
-      const { nickName } = this.data.userInfo
-      this.setData({
-        "userInfo.avatarUrl": avatarUrl,
-        hasUserInfo: nickName && avatarUrl && avatarUrl !== defaultAvatarUrl,
-      })
-    },
-    onInputChange(e: any) {
-      const nickName = e.detail.value
-      const { avatarUrl } = this.data.userInfo
-      this.setData({
-        "userInfo.nickName": nickName,
-        hasUserInfo: nickName && avatarUrl && avatarUrl !== defaultAvatarUrl,
-      })
-    },
-    getUserProfile() {
-      // 推荐使用wx.getUserProfile获取用户信息,开发者每次通过该接口获取用户个人信息均需用户确认,开发者妥善保管用户快速填写的头像昵称,避免重复弹窗
-      wx.getUserProfile({
-        desc: '展示用户信息', // 声明获取用户个人信息后的用途,后续会展示在弹窗中,请谨慎填写
-        success: (res) => {
-          this.setData({
-            userInfo: res.userInfo,
-            hasUserInfo: true
-          })
-        }
-      })
-    },
+Page({
+  onLoad() {
+    const tokens = loadBackendAuthTokens()
+    const url = tokens && tokens.accessToken
+      ? '/pages/home/home'
+      : '/pages/login/login'
+    wx.redirectTo({ url })
   },
 })

+ 3 - 27
miniprogram/pages/index/index.wxml

@@ -1,27 +1,3 @@
-<!--index.wxml-->
-<scroll-view class="scrollarea" scroll-y type="list">
-  <view class="container">
-    <view class="userinfo">
-      <block wx:if="{{canIUseNicknameComp && !hasUserInfo}}">
-        <button class="avatar-wrapper" open-type="chooseAvatar" bind:chooseavatar="onChooseAvatar">
-          <image class="avatar" src="{{userInfo.avatarUrl}}"></image>
-        </button>
-        <view class="nickname-wrapper">
-          <text class="nickname-label">昵称</text>
-          <input type="nickname" class="nickname-input" placeholder="请输入昵称" bind:change="onInputChange" />
-        </view>
-      </block>
-      <block wx:elif="{{!hasUserInfo}}">
-        <button wx:if="{{canIUseGetUserProfile}}" bindtap="getUserProfile"> 获取头像昵称 </button>
-        <view wx:else> 请使用2.10.4及以上版本基础库 </view>
-      </block>
-      <block wx:else>
-        <image bindtap="bindViewTap" class="userinfo-avatar" src="{{userInfo.avatarUrl}}" mode="cover"></image>
-        <text class="userinfo-nickname">{{userInfo.nickName}}</text>
-      </block>
-    </view>
-    <view class="usermotto">
-      <text class="user-motto">{{motto}}</text>
-    </view>
-  </view>
-</scroll-view>
+<view class="boot-page">
+  <view class="boot-page__text">正在进入业务页...</view>
+</view>

+ 8 - 57
miniprogram/pages/index/index.wxss

@@ -1,62 +1,13 @@
-/**index.wxss**/
-page {
-  height: 100vh;
+.boot-page {
+  min-height: 100vh;
   display: flex;
-  flex-direction: column;
-}
-.scrollarea {
-  flex: 1;
-  overflow-y: hidden;
-}
-
-.userinfo {
-  display: flex;
-  flex-direction: column;
   align-items: center;
-  color: #aaa;
-  width: 80%;
-}
-
-.userinfo-avatar {
-  overflow: hidden;
-  width: 128rpx;
-  height: 128rpx;
-  margin: 20rpx;
-  border-radius: 50%;
-}
-
-.usermotto {
-  margin-top: 200px;
-}
-
-.avatar-wrapper {
-  padding: 0;
-  width: 56px !important;
-  border-radius: 8px;
-  margin-top: 40px;
-  margin-bottom: 40px;
-}
-
-.avatar {
-  display: block;
-  width: 56px;
-  height: 56px;
-}
-
-.nickname-wrapper {
-  display: flex;
-  width: 100%;
-  padding: 16px;
-  box-sizing: border-box;
-  border-top: .5px solid rgba(0, 0, 0, 0.1);
-  border-bottom: .5px solid rgba(0, 0, 0, 0.1);
-  color: black;
-}
-
-.nickname-label {
-  width: 105px;
+  justify-content: center;
+  background: linear-gradient(180deg, #0f2f5a 0%, #1d5ca8 100%);
 }
 
-.nickname-input {
-  flex: 1;
+.boot-page__text {
+  color: #ffffff;
+  font-size: 30rpx;
+  letter-spacing: 0.08em;
 }

+ 3 - 0
miniprogram/pages/login/login.json

@@ -0,0 +1,3 @@
+{
+  "navigationBarTitleText": "登录"
+}

+ 127 - 0
miniprogram/pages/login/login.ts

@@ -0,0 +1,127 @@
+import { clearBackendAuthTokens, saveBackendAuthTokens, saveBackendBaseUrl } from '../../utils/backendAuth'
+import { loginWechatMini } from '../../utils/backendApi'
+
+const DEFAULT_BACKEND_BASE_URL = 'https://api.gotomars.xyz'
+const DEFAULT_DEVICE_KEY = 'mini-dev-device-001'
+const DEFAULT_DEV_CODE = 'dev-workbench-user'
+
+type LoginPageData = {
+  backendBaseUrl: string
+  deviceKey: string
+  loginCode: string
+  statusText: string
+}
+
+function setAppBackendState(baseUrl: string, accessToken: string, refreshToken: string) {
+  const app = getApp<IAppOption>()
+  if (app.globalData) {
+    app.globalData.backendBaseUrl = baseUrl
+    app.globalData.backendAuthTokens = { accessToken, refreshToken }
+  }
+}
+
+Page({
+  data: {
+    backendBaseUrl: DEFAULT_BACKEND_BASE_URL,
+    deviceKey: DEFAULT_DEVICE_KEY,
+    loginCode: DEFAULT_DEV_CODE,
+    statusText: '请先登录后端',
+  } as LoginPageData,
+
+  onLoad() {
+    const app = getApp<IAppOption>()
+    this.setData({
+      backendBaseUrl: app.globalData && app.globalData.backendBaseUrl
+        ? app.globalData.backendBaseUrl
+        : DEFAULT_BACKEND_BASE_URL,
+    })
+  },
+  handleBaseUrlInput(event: WechatMiniprogram.Input) {
+    this.setData({ backendBaseUrl: event.detail.value })
+  },
+
+  handleDeviceKeyInput(event: WechatMiniprogram.Input) {
+    this.setData({ deviceKey: event.detail.value })
+  },
+
+  handleLoginCodeInput(event: WechatMiniprogram.Input) {
+    this.setData({ loginCode: event.detail.value })
+  },
+
+  persistBaseUrl(): string {
+    const normalized = saveBackendBaseUrl(this.data.backendBaseUrl)
+    const app = getApp<IAppOption>()
+    if (app.globalData) {
+      app.globalData.backendBaseUrl = normalized
+    }
+    if (normalized !== this.data.backendBaseUrl) {
+      this.setData({ backendBaseUrl: normalized })
+    }
+    return normalized
+  },
+
+  async loginWithCode(code: string, sourceLabel: string) {
+    const baseUrl = this.persistBaseUrl()
+    this.setData({
+      statusText: `正在用 ${sourceLabel} 登录后端`,
+    })
+    try {
+      const result = await loginWechatMini({
+        baseUrl,
+        code,
+        deviceKey: this.data.deviceKey || DEFAULT_DEVICE_KEY,
+        clientType: 'wechat',
+      })
+      const tokens = saveBackendAuthTokens(result.tokens)
+      setAppBackendState(baseUrl, tokens.accessToken, tokens.refreshToken)
+      this.setData({
+        statusText: '登录成功,准备进入首页',
+      })
+      wx.redirectTo({
+        url: '/pages/home/home',
+      })
+    } catch (error) {
+      const message = error && (error as { message?: string }).message ? (error as { message: string }).message : '未知错误'
+      this.setData({
+        statusText: `登录失败:${message}`,
+      })
+    }
+  },
+
+  handleLoginWithDevCode() {
+    this.loginWithCode((this.data.loginCode || DEFAULT_DEV_CODE).trim(), '开发码')
+  },
+
+  handleLoginWithWechat() {
+    this.setData({
+      statusText: '正在调用 wx.login',
+    })
+    wx.login({
+      success: (result) => {
+        const code = result && result.code ? result.code : ''
+        if (!code) {
+          this.setData({ statusText: 'wx.login 未返回 code' })
+          return
+        }
+        this.setData({ loginCode: code })
+        this.loginWithCode(code, 'wx.login code')
+      },
+      fail: (error) => {
+        this.setData({
+          statusText: `wx.login 失败:${error && error.errMsg ? error.errMsg : '未知错误'}`,
+        })
+      },
+    })
+  },
+
+  handleClearLoginState() {
+    clearBackendAuthTokens()
+    const app = getApp<IAppOption>()
+    if (app.globalData) {
+      app.globalData.backendAuthTokens = null
+    }
+    this.setData({
+      statusText: '已清空登录态',
+    })
+  },
+})

+ 35 - 0
miniprogram/pages/login/login.wxml

@@ -0,0 +1,35 @@
+<scroll-view class="page" scroll-y>
+  <view class="shell">
+    <view class="hero">
+      <view class="hero__eyebrow">CMR Backend</view>
+      <view class="hero__title">登录</view>
+      <view class="hero__desc">先把小程序登录态接到 backend,再进入首页和活动页。</view>
+    </view>
+
+    <view class="panel">
+      <view class="panel__title">连接配置</view>
+      <view class="field">
+        <view class="field__label">Backend Base URL</view>
+        <input class="field__input" value="{{backendBaseUrl}}" bindinput="handleBaseUrlInput" />
+      </view>
+      <view class="field">
+        <view class="field__label">Device Key</view>
+        <input class="field__input" value="{{deviceKey}}" bindinput="handleDeviceKeyInput" />
+      </view>
+      <view class="field">
+        <view class="field__label">开发登录 Code</view>
+        <input class="field__input" value="{{loginCode}}" bindinput="handleLoginCodeInput" />
+      </view>
+      <view class="actions">
+        <button class="btn btn--primary" bindtap="handleLoginWithDevCode">开发码登录</button>
+        <button class="btn btn--secondary" bindtap="handleLoginWithWechat">wx.login 登录</button>
+        <button class="btn btn--ghost" bindtap="handleClearLoginState">清空登录态</button>
+      </view>
+    </view>
+
+    <view class="panel">
+      <view class="panel__title">状态</view>
+      <view class="status">{{statusText}}</view>
+    </view>
+  </view>
+</scroll-view>

+ 116 - 0
miniprogram/pages/login/login.wxss

@@ -0,0 +1,116 @@
+page {
+  min-height: 100vh;
+  background: linear-gradient(180deg, #eef4fb 0%, #e6eff9 100%);
+}
+
+.page {
+  min-height: 100vh;
+}
+
+.shell {
+  display: grid;
+  gap: 24rpx;
+  padding: 28rpx 24rpx 40rpx;
+}
+
+.hero,
+.panel {
+  display: grid;
+  gap: 18rpx;
+  padding: 24rpx;
+  border-radius: 24rpx;
+}
+
+.hero {
+  background: linear-gradient(135deg, #123b72 0%, #1d5ca8 100%);
+  color: #ffffff;
+}
+
+.hero__eyebrow {
+  font-size: 22rpx;
+  letter-spacing: 0.16em;
+  text-transform: uppercase;
+  color: rgba(255, 255, 255, 0.72);
+}
+
+.hero__title {
+  font-size: 40rpx;
+  font-weight: 700;
+}
+
+.hero__desc {
+  font-size: 24rpx;
+  line-height: 1.6;
+  color: rgba(255, 255, 255, 0.84);
+}
+
+.panel {
+  background: rgba(255, 255, 255, 0.94);
+  box-shadow: 0 14rpx 32rpx rgba(40, 63, 95, 0.08);
+}
+
+.panel__title {
+  font-size: 30rpx;
+  font-weight: 700;
+  color: #17345a;
+}
+
+.field {
+  display: grid;
+  gap: 10rpx;
+}
+
+.field__label {
+  font-size: 22rpx;
+  color: #64748b;
+}
+
+.field__input {
+  min-height: 76rpx;
+  padding: 0 20rpx;
+  border-radius: 18rpx;
+  border: 2rpx solid #dce7f3;
+  background: #f8fbff;
+  font-size: 28rpx;
+}
+
+.actions {
+  display: flex;
+  flex-wrap: wrap;
+  gap: 16rpx;
+}
+
+.btn {
+  margin: 0;
+  min-height: 76rpx;
+  padding: 0 24rpx;
+  line-height: 76rpx;
+  border-radius: 18rpx;
+  font-size: 26rpx;
+}
+
+.btn::after {
+  border: 0;
+}
+
+.btn--primary {
+  background: #173d73;
+  color: #ffffff;
+}
+
+.btn--secondary {
+  background: #dfeaf8;
+  color: #173d73;
+}
+
+.btn--ghost {
+  background: #ffffff;
+  color: #52657d;
+  border: 2rpx solid #d8e2ec;
+}
+
+.status {
+  font-size: 24rpx;
+  line-height: 1.6;
+  color: #30465f;
+}

+ 198 - 23
miniprogram/pages/map/map.ts

@@ -12,6 +12,8 @@ import {
   type GameLaunchEnvelope,
   type MapPageLaunchOptions,
 } from '../../utils/gameLaunch'
+import { finishSession, startSession, type BackendSessionFinishSummaryPayload } from '../../utils/backendApi'
+import { loadBackendBaseUrl } from '../../utils/backendAuth'
 import { loadRemoteMapConfig, type RemoteMapConfig } from '../../utils/remoteMapConfig'
 import { type H5ExperienceFallbackPayload, type H5ExperienceRequest } from '../../game/experience/h5Experience'
 import { type TrackColorPreset } from '../../game/presentation/trackStyleConfig'
@@ -173,6 +175,8 @@ let lastPunchHintHapticAt = 0
 let currentSystemSettingsConfig: SystemSettingsConfig | undefined
 let currentRemoteMapConfig: RemoteMapConfig | undefined
 let systemSettingsLockLifetimeActive = false
+let syncedBackendSessionStartId = ''
+let syncedBackendSessionFinishId = ''
 let lastCenterScaleRulerStablePatch: Pick<
   MapPageData,
   | 'centerScaleRulerVisible'
@@ -441,6 +445,37 @@ function hasExplicitLaunchOptions(options?: MapPageLaunchOptions | null): boolea
   )
 }
 
+function getCurrentBackendSessionContext(): { sessionId: string; sessionToken: string } | null {
+  const business = currentGameLaunchEnvelope.business
+  if (!business || !business.sessionId || !business.sessionToken) {
+    return null
+  }
+
+  return {
+    sessionId: business.sessionId,
+    sessionToken: business.sessionToken,
+  }
+}
+
+function getBackendSessionContextFromLaunchEnvelope(envelope: GameLaunchEnvelope | null | undefined): { sessionId: string; sessionToken: string } | null {
+  if (!envelope || !envelope.business || !envelope.business.sessionId || !envelope.business.sessionToken) {
+    return null
+  }
+
+  return {
+    sessionId: envelope.business.sessionId,
+    sessionToken: envelope.business.sessionToken,
+  }
+}
+
+function getCurrentBackendBaseUrl(): string {
+  const app = getApp<IAppOption>()
+  if (app.globalData && app.globalData.backendBaseUrl) {
+    return app.globalData.backendBaseUrl
+  }
+  return loadBackendBaseUrl()
+}
+
 function buildSideButtonVisibility(mode: SideButtonMode) {
   return {
     sideButtonMode: mode,
@@ -871,6 +906,8 @@ Page({
 
   onLoad(options: MapPageLaunchOptions) {
     clearSessionRecoveryPersistTimer()
+    syncedBackendSessionStartId = ''
+    syncedBackendSessionFinishId = ''
     currentGameLaunchEnvelope = resolveGameLaunchEnvelope(options)
     if (!hasExplicitLaunchOptions(options)) {
       const recoverySnapshot = loadSessionRecoverySnapshot()
@@ -991,6 +1028,8 @@ Page({
         const nextAnimationLevel = typeof nextPatch.animationLevel === 'string'
           ? nextPatch.animationLevel
           : this.data.animationLevel
+        let shouldSyncBackendSessionStart = false
+        let backendSessionFinishStatus: 'finished' | 'failed' | null = null
 
         if (nextAnimationLevel === 'lite') {
           clearHudFxTimer('timer')
@@ -1055,6 +1094,7 @@ Page({
             nextData.showGameInfoPanel = false
             nextData.showSystemSettingsPanel = false
             clearGameInfoPanelSyncTimer()
+            backendSessionFinishStatus = nextPatch.gameSessionStatus === 'finished' ? 'finished' : 'failed'
           } else if (
             nextPatch.gameSessionStatus !== this.data.gameSessionStatus
             && nextPatch.gameSessionStatus === 'idle'
@@ -1064,6 +1104,11 @@ Page({
             shouldSyncRuntimeSystemSettings = true
             clearSessionRecoverySnapshot()
             clearSessionRecoveryPersistTimer()
+          } else if (
+            nextPatch.gameSessionStatus !== this.data.gameSessionStatus
+            && nextPatch.gameSessionStatus === 'running'
+          ) {
+            shouldSyncBackendSessionStart = true
           } else if (nextPatch.gameSessionStatus === 'running' || nextPatch.gameSessionStatus === 'idle') {
             nextData.showResultScene = false
           }
@@ -1077,6 +1122,12 @@ Page({
             if (typeof nextPatch.gameSessionStatus === 'string') {
               this.syncSessionRecoveryLifecycle(nextPatch.gameSessionStatus)
             }
+            if (shouldSyncBackendSessionStart) {
+              this.syncBackendSessionStart()
+            }
+            if (backendSessionFinishStatus) {
+              this.syncBackendSessionFinish(backendSessionFinishStatus)
+            }
             if (shouldSyncRuntimeSystemSettings) {
               this.applyRuntimeSystemSettings(nextLockLifetimeActive)
             }
@@ -1088,6 +1139,12 @@ Page({
           if (typeof nextPatch.gameSessionStatus === 'string') {
             this.syncSessionRecoveryLifecycle(nextPatch.gameSessionStatus)
           }
+          if (shouldSyncBackendSessionStart) {
+            this.syncBackendSessionStart()
+          }
+          if (backendSessionFinishStatus) {
+            this.syncBackendSessionFinish(backendSessionFinishStatus)
+          }
           if (shouldSyncRuntimeSystemSettings) {
             this.applyRuntimeSystemSettings(nextLockLifetimeActive)
           }
@@ -1283,6 +1340,8 @@ Page({
   onUnload() {
     this.persistSessionRecoverySnapshot()
     clearSessionRecoveryPersistTimer()
+    syncedBackendSessionStartId = ''
+    syncedBackendSessionFinishId = ''
     clearGameInfoPanelSyncTimer()
     clearCenterScaleRulerSyncTimer()
     clearCenterScaleRulerUpdateTimer()
@@ -1332,6 +1391,114 @@ Page({
     return true
   },
 
+  syncBackendSessionStart() {
+    const sessionContext = getCurrentBackendSessionContext()
+    if (!sessionContext || syncedBackendSessionStartId === sessionContext.sessionId) {
+      return
+    }
+
+    startSession({
+      baseUrl: getCurrentBackendBaseUrl(),
+      sessionId: sessionContext.sessionId,
+      sessionToken: sessionContext.sessionToken,
+    })
+      .then(() => {
+        syncedBackendSessionStartId = sessionContext.sessionId
+      })
+      .catch((error) => {
+        const message = error && error.message ? error.message : '未知错误'
+        this.setData({
+          statusText: `session start 上报失败: ${message}`,
+        })
+      })
+  },
+
+  syncBackendSessionFinish(statusOverride?: 'finished' | 'failed' | 'cancelled') {
+    const sessionContext = getCurrentBackendSessionContext()
+    if (!sessionContext || syncedBackendSessionFinishId === sessionContext.sessionId || !mapEngine) {
+      return
+    }
+
+    const finishSummary = mapEngine.getSessionFinishSummary(statusOverride)
+    if (!finishSummary) {
+      return
+    }
+
+    const summaryPayload: BackendSessionFinishSummaryPayload = {}
+    if (typeof finishSummary.finalDurationSec === 'number') {
+      summaryPayload.finalDurationSec = finishSummary.finalDurationSec
+    }
+    if (typeof finishSummary.finalScore === 'number') {
+      summaryPayload.finalScore = finishSummary.finalScore
+    }
+    if (typeof finishSummary.completedControls === 'number') {
+      summaryPayload.completedControls = finishSummary.completedControls
+    }
+    if (typeof finishSummary.totalControls === 'number') {
+      summaryPayload.totalControls = finishSummary.totalControls
+    }
+    if (typeof finishSummary.distanceMeters === 'number') {
+      summaryPayload.distanceMeters = finishSummary.distanceMeters
+    }
+    if (typeof finishSummary.averageSpeedKmh === 'number') {
+      summaryPayload.averageSpeedKmh = finishSummary.averageSpeedKmh
+    }
+
+    finishSession({
+      baseUrl: getCurrentBackendBaseUrl(),
+      sessionId: sessionContext.sessionId,
+      sessionToken: sessionContext.sessionToken,
+      status: finishSummary.status,
+      summary: summaryPayload,
+    })
+      .then(() => {
+        syncedBackendSessionFinishId = sessionContext.sessionId
+      })
+      .catch((error) => {
+        const message = error && error.message ? error.message : '未知错误'
+        this.setData({
+          statusText: `session finish 上报失败: ${message}`,
+        })
+      })
+  },
+
+  reportAbandonedRecoverySnapshot(snapshot: SessionRecoverySnapshot) {
+    const sessionContext = getBackendSessionContextFromLaunchEnvelope(snapshot.launchEnvelope)
+    if (!sessionContext) {
+      clearSessionRecoverySnapshot()
+      return
+    }
+
+    finishSession({
+      baseUrl: getCurrentBackendBaseUrl(),
+      sessionId: sessionContext.sessionId,
+      sessionToken: sessionContext.sessionToken,
+      status: 'cancelled',
+      summary: {},
+    })
+      .then(() => {
+        syncedBackendSessionFinishId = sessionContext.sessionId
+        clearSessionRecoverySnapshot()
+        wx.showToast({
+          title: '已放弃上次对局',
+          icon: 'none',
+          duration: 1400,
+        })
+      })
+      .catch((error) => {
+        clearSessionRecoverySnapshot()
+        const message = error && error.message ? error.message : '未知错误'
+        this.setData({
+          statusText: `放弃恢复已生效,后端取消上报失败: ${message}`,
+        })
+        wx.showToast({
+          title: '已放弃上次对局',
+          icon: 'none',
+          duration: 1400,
+        })
+      })
+  },
+
   syncSessionRecoveryLifecycle(status: MapPageData['gameSessionStatus']) {
     if (status === 'running') {
       this.persistSessionRecoverySnapshot()
@@ -1368,7 +1535,7 @@ Page({
       cancelText: '放弃',
       success: (result) => {
         if (!result.confirm) {
-          clearSessionRecoverySnapshot()
+          this.reportAbandonedRecoverySnapshot(snapshot)
           return
         }
 
@@ -1385,15 +1552,19 @@ Page({
           return
         }
 
-        this.setData({
-          showResultScene: false,
-          showDebugPanel: false,
-          showGameInfoPanel: false,
-          showSystemSettingsPanel: false,
-        })
-        this.syncSessionRecoveryLifecycle('running')
-      },
-    })
+          this.setData({
+            showResultScene: false,
+            showDebugPanel: false,
+            showGameInfoPanel: false,
+            showSystemSettingsPanel: false,
+          })
+          const sessionContext = getCurrentBackendSessionContext()
+          if (sessionContext) {
+            syncedBackendSessionStartId = sessionContext.sessionId
+          }
+          this.syncSessionRecoveryLifecycle('running')
+        },
+      })
   },
 
   compileCurrentRuntimeProfile(lockLifetimeActive = isSystemSettingsLockLifetimeActive()) {
@@ -1537,7 +1708,10 @@ Page({
           return
         }
 
-        const errorMessage = error && error.message ? error.message : '未知错误'
+        const rawErrorMessage = error && error.message ? error.message : '未知错误'
+        const errorMessage = rawErrorMessage.indexOf('404') >= 0
+          ? `release manifest 不存在或未发布 (${configLabel})`
+          : rawErrorMessage
         this.setData({
           configStatusText: `载入失败: ${errorMessage}`,
           statusText: `远程地图配置载入失败: ${errorMessage} (${INTERNAL_BUILD_VERSION})`,
@@ -1939,18 +2113,19 @@ Page({
       return
     }
 
-    wx.showModal({
-      title: '确认退出',
-      content: '确认强制结束当前对局并返回开始前状态?',
-      confirmText: '确认退出',
-      cancelText: '取消',
-      success: (result) => {
-        if (result.confirm && mapEngine) {
-          systemSettingsLockLifetimeActive = false
-          mapEngine.handleForceExitGame()
-        }
-      },
-    })
+      wx.showModal({
+        title: '确认退出',
+        content: '确认强制结束当前对局并返回开始前状态?',
+        confirmText: '确认退出',
+        cancelText: '取消',
+        success: (result) => {
+          if (result.confirm && mapEngine) {
+            this.syncBackendSessionFinish('cancelled')
+            systemSettingsLockLifetimeActive = false
+            mapEngine.handleForceExitGame()
+          }
+        },
+      })
   },
 
   handleSkipAction() {

+ 3 - 0
miniprogram/pages/result/result.json

@@ -0,0 +1,3 @@
+{
+  "navigationBarTitleText": "结果"
+}

+ 134 - 0
miniprogram/pages/result/result.ts

@@ -0,0 +1,134 @@
+import { loadBackendAuthTokens, loadBackendBaseUrl } from '../../utils/backendAuth'
+import { getMyResults, getSessionResult, type BackendSessionResultView } from '../../utils/backendApi'
+
+type ResultPageData = {
+  sessionId: string
+  statusText: string
+  sessionTitleText: string
+  sessionSubtitleText: string
+  rows: Array<{ label: string; value: string }>
+  recentResults: BackendSessionResultView[]
+}
+
+function getAccessToken(): string | null {
+  const app = getApp<IAppOption>()
+  const tokens = app.globalData && app.globalData.backendAuthTokens
+    ? app.globalData.backendAuthTokens
+    : loadBackendAuthTokens()
+  return tokens && tokens.accessToken ? tokens.accessToken : null
+}
+
+function formatValue(value: unknown): string {
+  if (value === null || value === undefined || value === '') {
+    return '--'
+  }
+  return String(value)
+}
+
+Page({
+  data: {
+    sessionId: '',
+    statusText: '准备加载结果',
+    sessionTitleText: '结果页',
+    sessionSubtitleText: '未加载',
+    rows: [],
+    recentResults: [],
+  } as ResultPageData,
+
+  onLoad(query: { sessionId?: string }) {
+    const sessionId = query && query.sessionId ? decodeURIComponent(query.sessionId) : ''
+    this.setData({ sessionId })
+    if (sessionId) {
+      this.loadSingleResult(sessionId)
+      return
+    }
+    this.loadRecentResults()
+  },
+
+  async loadSingleResult(sessionId: string) {
+    const accessToken = getAccessToken()
+    if (!accessToken) {
+      wx.redirectTo({ url: '/pages/login/login' })
+      return
+    }
+
+    this.setData({
+      statusText: '正在加载单局结果',
+    })
+
+    try {
+      const result = await getSessionResult({
+        baseUrl: loadBackendBaseUrl(),
+        accessToken,
+        sessionId,
+      })
+      this.setData({
+        statusText: '单局结果加载完成',
+        sessionTitleText: result.session.eventName || result.session.eventDisplayName || result.session.eventId || result.session.id || result.session.sessionId,
+        sessionSubtitleText: `${result.session.status || result.session.sessionStatus} / ${result.result.status}`,
+        rows: [
+          { label: '最终得分', value: formatValue(result.result.finalScore) },
+          { label: '最终用时(秒)', value: formatValue(result.result.finalDurationSec) },
+          { label: '完成点数', value: formatValue(result.result.completedControls) },
+          { label: '总点数', value: formatValue(result.result.totalControls) },
+          { label: '累计里程(m)', value: formatValue(result.result.distanceMeters) },
+          { label: '平均速度(km/h)', value: formatValue(result.result.averageSpeedKmh) },
+          { label: '最大心率', value: formatValue(result.result.maxHeartRateBpm) },
+        ],
+      })
+    } catch (error) {
+      const message = error && (error as { message?: string }).message ? (error as { message: string }).message : '未知错误'
+      this.setData({
+        statusText: `结果加载失败:${message}`,
+      })
+    }
+  },
+
+  async loadRecentResults() {
+    const accessToken = getAccessToken()
+    if (!accessToken) {
+      wx.redirectTo({ url: '/pages/login/login' })
+      return
+    }
+
+    this.setData({
+      statusText: '正在加载最近结果',
+    })
+
+    try {
+      const results = await getMyResults({
+        baseUrl: loadBackendBaseUrl(),
+        accessToken,
+        limit: 20,
+      })
+      this.setData({
+        statusText: '最近结果加载完成',
+        sessionSubtitleText: '最近结果列表',
+        recentResults: results,
+      })
+    } catch (error) {
+      const message = error && (error as { message?: string }).message ? (error as { message: string }).message : '未知错误'
+      this.setData({
+        statusText: `结果加载失败:${message}`,
+      })
+    }
+  },
+
+  handleOpenResult(event: WechatMiniprogram.TouchEvent) {
+    const sessionId = event.currentTarget.dataset.sessionId as string | undefined
+    if (!sessionId) {
+      return
+    }
+    wx.redirectTo({
+      url: `/pages/result/result?sessionId=${encodeURIComponent(sessionId)}`,
+    })
+  },
+
+  handleBackToList() {
+    this.setData({
+      sessionId: '',
+      rows: [],
+    })
+    this.loadRecentResults()
+  },
+})

+ 33 - 0
miniprogram/pages/result/result.wxml

@@ -0,0 +1,33 @@
+<scroll-view class="page" scroll-y>
+  <view class="shell">
+    <view class="hero">
+      <view class="hero__eyebrow">Result</view>
+      <view class="hero__title">{{sessionTitleText}}</view>
+      <view class="hero__desc">{{sessionSubtitleText}}</view>
+    </view>
+
+    <view class="panel">
+      <view class="panel__title">当前状态</view>
+      <view class="summary">{{statusText}}</view>
+      <button wx:if="{{sessionId}}" class="btn btn--ghost" bindtap="handleBackToList">返回最近结果</button>
+    </view>
+
+    <view wx:if="{{rows.length}}" class="panel">
+      <view class="panel__title">单局摘要</view>
+      <view wx:for="{{rows}}" wx:key="label" class="row">
+        <view class="row__label">{{item.label}}</view>
+        <view class="row__value">{{item.value}}</view>
+      </view>
+    </view>
+
+    <view wx:if="{{!sessionId}}" class="panel">
+      <view class="panel__title">最近结果</view>
+      <view wx:if="{{!recentResults.length}}" class="summary">当前没有结果记录</view>
+      <view wx:for="{{recentResults}}" wx:key="session.id" class="result-card" bindtap="handleOpenResult" data-session-id="{{item.session.id}}">
+        <view class="result-card__title">{{item.session.eventName || item.session.id}}</view>
+        <view class="result-card__meta">{{item.result.status}} / {{item.session.status}}</view>
+        <view class="result-card__meta">得分 {{item.result.finalScore || '--'}} / 用时 {{item.result.finalDurationSec || '--'}}s</view>
+      </view>
+    </view>
+  </view>
+</scroll-view>

+ 114 - 0
miniprogram/pages/result/result.wxss

@@ -0,0 +1,114 @@
+page {
+  min-height: 100vh;
+  background: linear-gradient(180deg, #eff4fb 0%, #e8eff7 100%);
+}
+
+.page {
+  min-height: 100vh;
+}
+
+.shell {
+  display: grid;
+  gap: 24rpx;
+  padding: 28rpx 24rpx 40rpx;
+}
+
+.hero,
+.panel {
+  display: grid;
+  gap: 16rpx;
+  padding: 24rpx;
+  border-radius: 24rpx;
+}
+
+.hero {
+  background: linear-gradient(135deg, #163a66 0%, #1f5da1 100%);
+  color: #ffffff;
+}
+
+.hero__eyebrow {
+  font-size: 22rpx;
+  letter-spacing: 0.16em;
+  text-transform: uppercase;
+  color: rgba(255, 255, 255, 0.72);
+}
+
+.hero__title {
+  font-size: 40rpx;
+  font-weight: 700;
+}
+
+.hero__desc {
+  font-size: 24rpx;
+  color: rgba(255, 255, 255, 0.84);
+}
+
+.panel {
+  background: rgba(255, 255, 255, 0.94);
+  box-shadow: 0 14rpx 32rpx rgba(40, 63, 95, 0.08);
+}
+
+.panel__title {
+  font-size: 30rpx;
+  font-weight: 700;
+  color: #17345a;
+}
+
+.summary,
+.row__label,
+.row__value,
+.result-card__meta {
+  font-size: 24rpx;
+  line-height: 1.6;
+  color: #30465f;
+}
+
+.row {
+  display: flex;
+  justify-content: space-between;
+  gap: 16rpx;
+  padding: 10rpx 0;
+  border-bottom: 2rpx solid #edf2f7;
+}
+
+.row:last-child {
+  border-bottom: 0;
+}
+
+.row__value {
+  font-weight: 700;
+  color: #17345a;
+}
+
+.result-card {
+  display: grid;
+  gap: 8rpx;
+  padding: 18rpx;
+  border-radius: 18rpx;
+  background: #f6f9fc;
+}
+
+.result-card__title {
+  font-size: 28rpx;
+  font-weight: 700;
+  color: #17345a;
+}
+
+.btn {
+  margin: 0;
+  min-height: 76rpx;
+  padding: 0 24rpx;
+  line-height: 76rpx;
+  border-radius: 18rpx;
+  font-size: 26rpx;
+}
+
+.btn::after {
+  border: 0;
+}
+
+.btn--ghost {
+  background: #ffffff;
+  color: #52657d;
+  border: 2rpx solid #d8e2ec;
+}

+ 375 - 0
miniprogram/utils/backendApi.ts

@@ -0,0 +1,375 @@
+import { normalizeBackendBaseUrl } from './backendAuth'
+
+export interface BackendApiError {
+  statusCode: number
+  code: string
+  message: string
+  details?: unknown
+}
+
+export interface BackendAuthLoginResult {
+  user?: {
+    id?: string
+    nickname?: string
+    avatarUrl?: string
+  }
+  tokens: {
+    accessToken: string
+    refreshToken: string
+  }
+}
+
+export interface BackendResolvedRelease {
+  launchMode: string
+  source: string
+  eventId: string
+  releaseId: string
+  configLabel: string
+  manifestUrl: string
+  manifestChecksumSha256?: string | null
+  routeCode?: string | null
+}
+
+export interface BackendEntrySessionSummary {
+  id: string
+  status: string
+  eventId?: string
+  eventName?: string
+  releaseId?: string | null
+  configLabel?: string | null
+  routeCode?: string | null
+  launchedAt?: string | null
+  startedAt?: string | null
+  endedAt?: string | null
+  // 兼容前端旧字段名,避免联调过渡期多处判断
+  sessionId?: string
+  sessionStatus?: string
+  eventDisplayName?: string
+}
+
+export interface BackendCardResult {
+  id: string
+  type: string
+  title: string
+  subtitle?: string | null
+  coverUrl?: string | null
+  displaySlot: string
+  displayPriority: number
+  event?: {
+    id: string
+    displayName: string
+    summary?: string | null
+  } | null
+  htmlUrl?: string | null
+}
+
+export interface BackendEntryHomeResult {
+  user: {
+    id: string
+    publicId: string
+    status: string
+    nickname?: string | null
+    avatarUrl?: string | null
+  }
+  tenant: {
+    id: string
+    code: string
+    name: string
+  }
+  channel: {
+    id: string
+    code: string
+    type: string
+    platformAppId?: string | null
+    displayName: string
+    status: string
+    isDefault: boolean
+  }
+  cards: BackendCardResult[]
+  ongoingSession?: BackendEntrySessionSummary | null
+  recentSession?: BackendEntrySessionSummary | null
+}
+
+export interface BackendEventPlayResult {
+  event: {
+    id: string
+    slug: string
+    displayName: string
+    summary?: string | null
+    status: string
+  }
+  release?: {
+    id: string
+    configLabel: string
+    manifestUrl: string
+    manifestChecksumSha256?: string | null
+    routeCode?: string | null
+  } | null
+  resolvedRelease?: BackendResolvedRelease | null
+  play: {
+    canLaunch: boolean
+    primaryAction: string
+    reason: string
+    launchSource?: string
+    ongoingSession?: BackendEntrySessionSummary | null
+    recentSession?: BackendEntrySessionSummary | null
+  }
+}
+
+export interface BackendLaunchResult {
+  event: {
+    id: string
+    displayName: string
+  }
+  launch: {
+    source: string
+    resolvedRelease?: BackendResolvedRelease | null
+    config: {
+      configUrl: string
+      configLabel: string
+      configChecksumSha256?: string | null
+      releaseId: string
+      routeCode?: string | null
+    }
+    business: {
+      source: string
+      eventId: string
+      sessionId: string
+      sessionToken: string
+      sessionTokenExpiresAt: string
+      routeCode?: string | null
+    }
+  }
+}
+
+export interface BackendSessionFinishSummaryPayload {
+  finalDurationSec?: number
+  finalScore?: number
+  completedControls?: number
+  totalControls?: number
+  distanceMeters?: number
+  averageSpeedKmh?: number
+  maxHeartRateBpm?: number
+}
+
+export interface BackendSessionResult {
+  session: {
+    id: string
+    status: string
+    clientType: string
+    deviceKey: string
+    routeCode?: string | null
+    sessionTokenExpiresAt: string
+    launchedAt: string
+    startedAt?: string | null
+    endedAt?: string | null
+  }
+  event: {
+    id: string
+    displayName: string
+  }
+  resolvedRelease?: BackendResolvedRelease | null
+}
+
+export interface BackendSessionResultView {
+  session: BackendEntrySessionSummary
+  result: {
+    status: string
+    finalDurationSec?: number
+    finalScore?: number
+    completedControls?: number
+    totalControls?: number
+    distanceMeters?: number
+    averageSpeedKmh?: number
+    maxHeartRateBpm?: number
+    summary?: Record<string, unknown>
+  }
+}
+
+type BackendEnvelope<T> = {
+  data: T
+}
+
+type RequestOptions = {
+  method: 'GET' | 'POST'
+  baseUrl: string
+  path: string
+  authToken?: string
+  body?: Record<string, unknown>
+}
+
+function requestBackend<T>(options: RequestOptions): Promise<T> {
+  const url = `${normalizeBackendBaseUrl(options.baseUrl)}${options.path}`
+  const header: Record<string, string> = {}
+  if (options.body) {
+    header['Content-Type'] = 'application/json'
+  }
+  if (options.authToken) {
+    header.Authorization = `Bearer ${options.authToken}`
+  }
+
+  return new Promise<T>((resolve, reject) => {
+    wx.request({
+      url,
+      method: options.method,
+      header,
+      data: options.body,
+      success: (response) => {
+        const statusCode = typeof response.statusCode === 'number' ? response.statusCode : 0
+        const data = response.data as BackendEnvelope<T> | { error?: { code?: string; message?: string; details?: unknown } }
+        if (statusCode >= 200 && statusCode < 300 && data && typeof data === 'object' && 'data' in data) {
+          resolve((data as BackendEnvelope<T>).data)
+          return
+        }
+
+        const errorPayload = data && typeof data === 'object' && 'error' in data
+          ? (data as { error?: { code?: string; message?: string; details?: unknown } }).error
+          : undefined
+        reject({
+          statusCode,
+          code: errorPayload && errorPayload.code ? errorPayload.code : 'backend_error',
+          message: errorPayload && errorPayload.message ? errorPayload.message : `request failed: ${statusCode}`,
+          details: errorPayload && errorPayload.details ? errorPayload.details : response.data,
+        } as BackendApiError)
+      },
+      fail: (error) => {
+        reject({
+          statusCode: 0,
+          code: 'network_error',
+          message: error && error.errMsg ? error.errMsg : 'network request failed',
+        } as BackendApiError)
+      },
+    })
+  })
+}
+
+export function loginWechatMini(input: {
+  baseUrl: string
+  code: string
+  deviceKey: string
+  clientType?: string
+}): Promise<BackendAuthLoginResult> {
+  return requestBackend<BackendAuthLoginResult>({
+    method: 'POST',
+    baseUrl: input.baseUrl,
+    path: '/auth/login/wechat-mini',
+    body: {
+      code: input.code,
+      clientType: input.clientType || 'wechat',
+      deviceKey: input.deviceKey,
+    },
+  })
+}
+
+export function getEventPlay(input: {
+  baseUrl: string
+  eventId: string
+  accessToken: string
+}): Promise<BackendEventPlayResult> {
+  return requestBackend<BackendEventPlayResult>({
+    method: 'GET',
+    baseUrl: input.baseUrl,
+    path: `/events/${encodeURIComponent(input.eventId)}/play`,
+    authToken: input.accessToken,
+  })
+}
+
+export function getEntryHome(input: {
+  baseUrl: string
+  accessToken: string
+  channelCode: string
+  channelType: string
+}): Promise<BackendEntryHomeResult> {
+  const query = `channelCode=${encodeURIComponent(input.channelCode)}&channelType=${encodeURIComponent(input.channelType)}`
+  return requestBackend<BackendEntryHomeResult>({
+    method: 'GET',
+    baseUrl: input.baseUrl,
+    path: `/me/entry-home?${query}`,
+    authToken: input.accessToken,
+  })
+}
+
+export function launchEvent(input: {
+  baseUrl: string
+  eventId: string
+  accessToken: string
+  releaseId?: string
+  clientType: string
+  deviceKey: string
+}): Promise<BackendLaunchResult> {
+  const body: Record<string, unknown> = {
+    clientType: input.clientType,
+    deviceKey: input.deviceKey,
+  }
+  if (input.releaseId) {
+    body.releaseId = input.releaseId
+  }
+  return requestBackend<BackendLaunchResult>({
+    method: 'POST',
+    baseUrl: input.baseUrl,
+    path: `/events/${encodeURIComponent(input.eventId)}/launch`,
+    authToken: input.accessToken,
+    body,
+  })
+}
+
+export function startSession(input: {
+  baseUrl: string
+  sessionId: string
+  sessionToken: string
+}): Promise<BackendSessionResult> {
+  return requestBackend<BackendSessionResult>({
+    method: 'POST',
+    baseUrl: input.baseUrl,
+    path: `/sessions/${encodeURIComponent(input.sessionId)}/start`,
+    body: {
+      sessionToken: input.sessionToken,
+    },
+  })
+}
+
+export function finishSession(input: {
+  baseUrl: string
+  sessionId: string
+  sessionToken: string
+  status: 'finished' | 'failed' | 'cancelled'
+  summary: BackendSessionFinishSummaryPayload
+}): Promise<BackendSessionResult> {
+  return requestBackend<BackendSessionResult>({
+    method: 'POST',
+    baseUrl: input.baseUrl,
+    path: `/sessions/${encodeURIComponent(input.sessionId)}/finish`,
+    body: {
+      sessionToken: input.sessionToken,
+      status: input.status,
+      summary: input.summary,
+    },
+  })
+}
+
+export function getSessionResult(input: {
+  baseUrl: string
+  accessToken: string
+  sessionId: string
+}): Promise<BackendSessionResultView> {
+  return requestBackend<BackendSessionResultView>({
+    method: 'GET',
+    baseUrl: input.baseUrl,
+    path: `/sessions/${encodeURIComponent(input.sessionId)}/result`,
+    authToken: input.accessToken,
+  })
+}
+
+export function getMyResults(input: {
+  baseUrl: string
+  accessToken: string
+  limit?: number
+}): Promise<BackendSessionResultView[]> {
+  const limit = typeof input.limit === 'number' ? input.limit : 20
+  return requestBackend<BackendSessionResultView[]>({
+    method: 'GET',
+    baseUrl: input.baseUrl,
+    path: `/me/results?limit=${encodeURIComponent(String(limit))}`,
+    authToken: input.accessToken,
+  })
+}

+ 86 - 0
miniprogram/utils/backendAuth.ts

@@ -0,0 +1,86 @@
+export interface BackendAuthTokens {
+  accessToken: string
+  refreshToken: string
+}
+
+const BACKEND_BASE_URL_STORAGE_KEY = 'cmr.backend.baseUrl.v1'
+const BACKEND_AUTH_TOKENS_STORAGE_KEY = 'cmr.backend.authTokens.v1'
+const DEFAULT_BACKEND_BASE_URL = 'https://api.gotomars.xyz'
+const LEGACY_LOCAL_BACKEND_BASE_URLS = [
+  'http://127.0.0.1:8080',
+  'https://127.0.0.1:8080',
+  'http://localhost:8080',
+  'https://localhost:8080',
+]
+
+function normalizeString(value: unknown): string {
+  return typeof value === 'string' ? value.trim() : ''
+}
+
+export function normalizeBackendBaseUrl(value: unknown): string {
+  const normalized = normalizeString(value).replace(/\/+$/, '')
+  if (LEGACY_LOCAL_BACKEND_BASE_URLS.indexOf(normalized) >= 0) {
+    return DEFAULT_BACKEND_BASE_URL
+  }
+  return normalized || DEFAULT_BACKEND_BASE_URL
+}
+
+export function loadBackendBaseUrl(): string {
+  try {
+    const stored = wx.getStorageSync(BACKEND_BASE_URL_STORAGE_KEY)
+    const normalized = normalizeBackendBaseUrl(stored)
+    if (normalized !== stored && normalized === DEFAULT_BACKEND_BASE_URL) {
+      wx.setStorageSync(BACKEND_BASE_URL_STORAGE_KEY, normalized)
+    }
+    return normalized
+  } catch {
+    return DEFAULT_BACKEND_BASE_URL
+  }
+}
+
+export function saveBackendBaseUrl(baseUrl: string): string {
+  const normalized = normalizeBackendBaseUrl(baseUrl)
+  try {
+    wx.setStorageSync(BACKEND_BASE_URL_STORAGE_KEY, normalized)
+  } catch {}
+  return normalized
+}
+
+export function loadBackendAuthTokens(): BackendAuthTokens | null {
+  try {
+    const stored = wx.getStorageSync(BACKEND_AUTH_TOKENS_STORAGE_KEY)
+    if (!stored || typeof stored !== 'object') {
+      return null
+    }
+
+    const accessToken = normalizeString((stored as Record<string, unknown>).accessToken)
+    const refreshToken = normalizeString((stored as Record<string, unknown>).refreshToken)
+    if (!accessToken || !refreshToken) {
+      return null
+    }
+
+    return {
+      accessToken,
+      refreshToken,
+    }
+  } catch {
+    return null
+  }
+}
+
+export function saveBackendAuthTokens(tokens: BackendAuthTokens): BackendAuthTokens {
+  const normalized = {
+    accessToken: normalizeString(tokens.accessToken),
+    refreshToken: normalizeString(tokens.refreshToken),
+  }
+  try {
+    wx.setStorageSync(BACKEND_AUTH_TOKENS_STORAGE_KEY, normalized)
+  } catch {}
+  return normalized
+}
+
+export function clearBackendAuthTokens() {
+  try {
+    wx.removeStorageSync(BACKEND_AUTH_TOKENS_STORAGE_KEY)
+  } catch {}
+}

+ 21 - 0
miniprogram/utils/backendLaunchAdapter.ts

@@ -0,0 +1,21 @@
+import { type GameLaunchEnvelope } from './gameLaunch'
+import { type BackendLaunchResult } from './backendApi'
+
+export function adaptBackendLaunchResultToEnvelope(result: BackendLaunchResult): GameLaunchEnvelope {
+  return {
+    config: {
+      configUrl: result.launch.config.configUrl,
+      configLabel: result.launch.config.configLabel,
+      configChecksumSha256: result.launch.config.configChecksumSha256 || null,
+      releaseId: result.launch.config.releaseId,
+      routeCode: result.launch.config.routeCode || null,
+    },
+    business: {
+      source: result.launch.business.source === 'direct-event' ? 'direct-event' : 'custom',
+      eventId: result.launch.business.eventId,
+      sessionId: result.launch.business.sessionId,
+      sessionToken: result.launch.business.sessionToken,
+      sessionTokenExpiresAt: result.launch.business.sessionTokenExpiresAt,
+    },
+  }
+}

+ 33 - 9
readme-develop.md

@@ -76,9 +76,9 @@
 位于 [tools/mock-gps-sim](D:/dev/cmr-mini/tools/mock-gps-sim):
 
 - [server.js](D:/dev/cmr-mini/tools/mock-gps-sim/server.js):本地 HTTP + WebSocket 服务
-- [public/index.html](D:/dev/cmr-mini/tools/mock-gps-sim/public/index.html):模拟器 UI
-- [public/simulator.js](D:/dev/cmr-mini/tools/mock-gps-sim/public/simulator.js):地图、路径、心率模拟逻辑
-- [public/style.css](D:/dev/cmr-mini/tools/mock-gps-sim/public/style.css):布局与样式
+- [public/index.html](D:/dev/cmr-mini/tools/mock-gps-sim/public/index.html):新版模拟器工作台 UI
+- [public/simulator.js](D:/dev/cmr-mini/tools/mock-gps-sim/public/simulator.js):地图、路径、心率、日志、多通道模拟逻辑
+- [public/workbench.css](D:/dev/cmr-mini/tools/mock-gps-sim/public/workbench.css):新版工作台布局与样式
 
 ---
 
@@ -557,6 +557,7 @@ HUD 当前颜色由 telemetry 驱动。
 {
   "type": "mock_gps",
   "timestamp": 1711267200000,
+  "channelId": "runner-a",
   "lat": 31.2304,
   "lon": 121.4737,
   "accuracyMeters": 6,
@@ -597,10 +598,13 @@ HUD 当前颜色由 telemetry 驱动。
 
 当前已经调整为:
 
+- 顶部显示全局连接状态与全局模拟通道号
 - 左侧控制面板独立滚动
-- 右侧地图固定不动
+- 中间地图固定作为主观察区
+- 右侧保留运行摘要、当前位置、最近事件
+- 右下使用可缩放的调试日志浮层
 
-这样更适合长面板配置和路径编辑,不会让地图区跟着滚动。
+这样更适合长面板配置、多人联调隔离和过程日志观察,不会让地图区跟着滚动。
 
 ---
 
@@ -899,7 +903,7 @@ flowchart TD
 - [server.js](D:/dev/cmr-mini/tools/mock-gps-sim/server.js)
 - [index.html](D:/dev/cmr-mini/tools/mock-gps-sim/public/index.html)
 - [simulator.js](D:/dev/cmr-mini/tools/mock-gps-sim/public/simulator.js)
-- [style.css](D:/dev/cmr-mini/tools/mock-gps-sim/public/style.css)
+- [workbench.css](D:/dev/cmr-mini/tools/mock-gps-sim/public/workbench.css)
 
 ---
 
@@ -1020,15 +1024,35 @@ GPS:
 {
   "type": "mock_heart_rate",
   "timestamp": 1711267200000,
+  "channelId": "runner-a",
   "bpm": 148
 }
 ```
 
-两者当前共用同一个 WebSocket 入口
+调试日志
 
-- `.../mock-gps`
+```json
+{
+  "type": "debug-log",
+  "timestamp": 1711267200000,
+  "channelId": "runner-a",
+  "scope": "gps-logo",
+  "level": "info",
+  "message": "logo ready"
+}
+```
+
+当前三条链已经拆开:
+
+- GPS:`.../mock-gps`
+- 心率:`.../mock-hr`
+- 日志:`.../debug-log`
+
+同时三条链统一使用同一个 `channelId` 做最小隔离:
 
-这是当前阶段为了降低复杂度做的统一通道设计,后面如果模拟消息种类继续增加,再考虑独立通道或消息总线拆分。
+- 模拟器顶部设置一个全局“模拟通道号”
+- 小程序调试面板也设置同一个“模拟通道号”
+- 只有 `channelId` 精确匹配的数据才会被消费
 
 ### 18.5 当前推荐的联调顺序
 

+ 9 - 0
tools/mock-gps-sim/README.md

@@ -41,6 +41,8 @@ group-01
 
 然后在小程序调试面板里把“模拟通道号”也配成同一个值。
 
+当前“模拟通道号”位于工作台顶部,属于全局调试参数,不再归属某个单独分组。
+
 ## 当前能力
 
 - 直接载入 `game.json`
@@ -83,6 +85,13 @@ ws://127.0.0.1:17865/debug-log
 
 当前 UI 会通过独立日志通道把这类消息显示到“调试日志”区域。
 
+日志区域当前是:
+
+- 地图右下角浮层
+- 可展开 / 缩小
+- 支持按 `scope` 过滤
+- 按当前 `channelId` 隔离显示
+
 第一阶段主要用于承接:
 
 - `gps-logo`

+ 8 - 0
tools/mock-gps-sim/server.js

@@ -106,6 +106,11 @@ function isDebugLogPayload(payload) {
     && typeof payload.message === 'string'
 }
 
+function normalizeChannelId(value) {
+  const trimmed = String(value || '').trim()
+  return trimmed || 'default'
+}
+
 async function handleProxyRequest(request, response) {
   const requestUrl = new URL(request.url || '/', `http://127.0.0.1:${PORT}`)
   const targetUrl = requestUrl.searchParams.get('url')
@@ -533,6 +538,7 @@ gpsWss.on('connection', (socket) => {
     const outgoing = JSON.stringify({
       type: 'mock_gps',
       timestamp: Number.isFinite(parsed.timestamp) ? parsed.timestamp : Date.now(),
+      channelId: normalizeChannelId(parsed.channelId),
       lat: Number(parsed.lat),
       lon: Number(parsed.lon),
       accuracyMeters: Number.isFinite(parsed.accuracyMeters) ? Number(parsed.accuracyMeters) : 6,
@@ -566,6 +572,7 @@ heartRateWss.on('connection', (socket) => {
     const outgoing = JSON.stringify({
       type: 'mock_heart_rate',
       timestamp: Number.isFinite(parsed.timestamp) ? parsed.timestamp : Date.now(),
+      channelId: normalizeChannelId(parsed.channelId),
       bpm: Math.max(1, Math.round(Number(parsed.bpm))),
     })
     gatewayBridge.publish(JSON.parse(outgoing))
@@ -595,6 +602,7 @@ debugLogWss.on('connection', (socket) => {
     const outgoing = JSON.stringify({
       type: 'debug-log',
       timestamp: Number.isFinite(parsed.timestamp) ? parsed.timestamp : Date.now(),
+      channelId: normalizeChannelId(parsed.channelId),
       scope: String(parsed.scope || 'app').slice(0, 64),
       level: parsed.level === 'warn' || parsed.level === 'error' ? parsed.level : 'info',
       message: String(parsed.message || '').slice(0, 400),

+ 2 - 0
typings/index.d.ts

@@ -4,6 +4,8 @@ interface IAppOption {
   globalData: {
     userInfo?: WechatMiniprogram.UserInfo,
     telemetryPlayerProfile?: import('../miniprogram/game/telemetry/playerTelemetryProfile').PlayerTelemetryProfile | null,
+    backendBaseUrl?: string | null,
+    backendAuthTokens?: import('../miniprogram/utils/backendAuth').BackendAuthTokens | null,
   }
   userInfoReadyCallback?: WechatMiniprogram.GetUserInfoSuccessCallback,
 }