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

This commit is contained in:
2026-04-01 18:48:59 +08:00
parent 94a1f0ba78
commit a70dc8d5d0
51 changed files with 4037 additions and 197 deletions

250
b2f.md Normal file
View File

@@ -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)

View File

@@ -18,3 +18,7 @@ WECHAT_MINI_DEV_PREFIX=dev-
LOCAL_EVENT_DIR=..\event LOCAL_EVENT_DIR=..\event
ASSET_BASE_URL=https://oss-mbh5.colormaprun.com/gotomars 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

View File

@@ -19,6 +19,7 @@
- [API 清单](D:/dev/cmr-mini/backend/docs/接口清单.md) - [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)
- [资源对象与目录方案](D:/dev/cmr-mini/backend/docs/资源对象与目录方案.md)
- [开发说明](D:/dev/cmr-mini/backend/docs/开发说明.md) - [开发说明](D:/dev/cmr-mini/backend/docs/开发说明.md)
## 快速启动 ## 快速启动

View File

@@ -12,9 +12,10 @@
3. [API 清单](D:/dev/cmr-mini/backend/docs/接口清单.md) 3. [API 清单](D:/dev/cmr-mini/backend/docs/接口清单.md)
4. [数据模型](D:/dev/cmr-mini/backend/docs/数据模型.md) 4. [数据模型](D:/dev/cmr-mini/backend/docs/数据模型.md)
5. [配置管理方案](D:/dev/cmr-mini/backend/docs/配置管理方案.md) 5. [配置管理方案](D:/dev/cmr-mini/backend/docs/配置管理方案.md)
6. [前后端联调清单](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) 7. [前后端联调清单](D:/dev/cmr-mini/backend/docs/前后端联调清单.md)
8. [开发说明](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 分层 - source/build/release 分层
- 配置构建器 - 配置构建器
- 发布资产清单 - 发布资产清单

View File

@@ -30,9 +30,29 @@
所以 backend 现在最重要的不是再扩散接口,而是把当前契约和语义收稳。 所以 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. P0 必做
## 3.1 固定 session 状态语义 ## 3.0 固定 session 状态语义
需要 backend 明确并固定: 需要 backend 明确并固定:
@@ -51,7 +71,7 @@
- 小程序现在已经按这个方向接 - 小程序现在已经按这个方向接
- 如果 backend 想改这 3 个状态语义,需要先讨论,不要单边改 - 如果 backend 想改这 3 个状态语义,需要先讨论,不要单边改
## 3.2 明确“放弃恢复”的后端处理 ## 3.1 明确“放弃恢复”的后端处理
这是当前最值得后端配合确认的一点。 这是当前最值得后端配合确认的一点。
@@ -87,7 +107,7 @@ backend 需要确认的目标语义是:
- 如果 backend 认可这套语义,小程序侧下一步就可以把“点击放弃恢复”改成同步调用 `finish(cancelled)` - 如果 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. 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 失败。 不要因为某个可选字段缺失就整局 finish 失败。
## 4.3 dev workbench 增加一组“恢复 / 取消恢复”场景按钮 ## 4.4 dev workbench 增加一组“恢复 / 取消恢复”场景按钮
当前 workbench 已经很好用了。 当前 workbench 已经很好用了。
@@ -182,6 +231,17 @@ backend 建议补两件事:
这会很适合配合小程序故障恢复联调。 这会很适合配合小程序故障恢复联调。
## 4.5 前端预埋“放弃恢复”调用位
这项先预埋,不要先自行定语义。
前端建议准备好:
- 在“放弃恢复”按钮点击后,预留调用 `finish(cancelled)` 的位置
- 但是否正式启用,要等 backend 把 `cancelled` 语义确认完
这样一旦 backend 确认语义,小程序就能快速切过去,不需要再改一轮页面流程。
## 5. P2 下一阶段 ## 5. P2 下一阶段
## 5.1 配置后台 source / build / release 真正开始做 ## 5.1 配置后台 source / build / release 真正开始做
@@ -205,6 +265,23 @@ backend 建议补两件事:
这部分不是当前联调阻塞项,但后面会成为业务壳的重要组成。 这部分不是当前联调阻塞项,但后面会成为业务壳的重要组成。
## 5.3 兼顾未来 APP 的统一后端约束
backend 后续建设需要继续坚持:
- 不做“小程序专用后端”
- 用户模型保持平台级
- `event / release / session / result` 不按终端拆两套
- 终端差异只通过上下文字段和运行时适配处理
建议优先保持:
- 业务接口统一
- 配置发布结构统一
- 结果沉淀结构统一
这样后面 APP 接入时不会推翻现有 backend 结构。
## 6. 需要先讨论再动的边界 ## 6. 需要先讨论再动的边界
这些事项 backend 不建议自己先拍板: 这些事项 backend 不建议自己先拍板:
@@ -252,4 +329,4 @@ backend 现在最值得先做的,不是扩接口,而是先确认下面 3 条
当前 backend 最重要的任务不是“再加更多接口”,而是: 当前 backend 最重要的任务不是“再加更多接口”,而是:
> 先把 session 运行态语义和故障恢复放弃语义定稳,再继续扩后台配置系统。 > 先把 session 运行态语义、放弃恢复语义和 ongoing session 口径定稳,再继续扩后台配置系统。

View File

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

View File

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

View File

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

View File

@@ -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而不是做成“小程序跑通后再重构”的临时结构。

View File

@@ -5,6 +5,7 @@ import (
"net/http" "net/http"
"cmr-backend/internal/httpapi" "cmr-backend/internal/httpapi"
"cmr-backend/internal/platform/assets"
"cmr-backend/internal/platform/jwtx" "cmr-backend/internal/platform/jwtx"
"cmr-backend/internal/platform/wechatmini" "cmr-backend/internal/platform/wechatmini"
"cmr-backend/internal/service" "cmr-backend/internal/service"
@@ -38,7 +39,8 @@ func New(ctx context.Context, cfg Config) (*App, error) {
entryHomeService := service.NewEntryHomeService(store) entryHomeService := service.NewEntryHomeService(store)
eventService := service.NewEventService(store) eventService := service.NewEventService(store)
eventPlayService := service.NewEventPlayService(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) homeService := service.NewHomeService(store)
profileService := service.NewProfileService(store) profileService := service.NewProfileService(store)
resultService := service.NewResultService(store) resultService := service.NewResultService(store)

View File

@@ -24,6 +24,10 @@ type Config struct {
WechatMiniDevPrefix string WechatMiniDevPrefix string
LocalEventDir string LocalEventDir string
AssetBaseURL string AssetBaseURL string
AssetPublicBaseURL string
AssetBucketRoot string
OSSUtilPath string
OSSUtilConfigFile string
} }
func LoadConfigFromEnv() (Config, error) { func LoadConfigFromEnv() (Config, error) {
@@ -44,6 +48,10 @@ func LoadConfigFromEnv() (Config, error) {
WechatMiniDevPrefix: getEnv("WECHAT_MINI_DEV_PREFIX", "dev-"), WechatMiniDevPrefix: getEnv("WECHAT_MINI_DEV_PREFIX", "dev-"),
LocalEventDir: getEnv("LOCAL_EVENT_DIR", filepath.Clean("..\\event")), LocalEventDir: getEnv("LOCAL_EVENT_DIR", filepath.Clean("..\\event")),
AssetBaseURL: getEnv("ASSET_BASE_URL", "https://oss-mbh5.colormaprun.com/gotomars"), 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 == "" { if cfg.DatabaseURL == "" {
@@ -71,3 +79,11 @@ func getDurationEnv(key string, fallback time.Duration) time.Duration {
} }
return fallback return fallback
} }
func mustUserHomeDir() string {
home, err := os.UserHomeDir()
if err != nil {
return "."
}
return home
}

View File

@@ -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
}

View File

@@ -11,6 +11,7 @@ import (
"strings" "strings"
"cmr-backend/internal/apperr" "cmr-backend/internal/apperr"
"cmr-backend/internal/platform/assets"
"cmr-backend/internal/platform/security" "cmr-backend/internal/platform/security"
"cmr-backend/internal/store/postgres" "cmr-backend/internal/store/postgres"
) )
@@ -19,6 +20,7 @@ type ConfigService struct {
store *postgres.Store store *postgres.Store
localEventDir string localEventDir string
assetBaseURL string assetBaseURL string
publisher *assets.OSSUtilPublisher
} }
type ConfigPipelineSummary struct { type ConfigPipelineSummary struct {
@@ -76,11 +78,12 @@ type PublishBuildInput struct {
BuildID string `json:"buildId"` 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{ return &ConfigService{
store: store, store: store,
localEventDir: localEventDir, localEventDir: localEventDir,
assetBaseURL: strings.TrimRight(assetBaseURL, "/"), assetBaseURL: strings.TrimRight(assetBaseURL, "/"),
publisher: publisher,
} }
} }
@@ -323,9 +326,20 @@ func (s *ConfigService) PublishBuild(ctx context.Context, input PublishBuildInpu
configLabel := deriveConfigLabel(event, manifest, releaseNo) configLabel := deriveConfigLabel(event, manifest, releaseNo)
manifestURL := fmt.Sprintf("%s/event/releases/%s/%s/manifest.json", s.assetBaseURL, event.PublicID, releasePublicID) 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) checksum := security.HashText(buildRecord.ManifestJSON)
routeCode := deriveRouteCode(manifest) 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) tx, err := s.store.Begin(ctx)
if err != nil { if err != nil {
return nil, err return nil, err
@@ -348,7 +362,7 @@ func (s *ConfigService) PublishBuild(ctx context.Context, input PublishBuildInpu
return nil, err 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 return nil, err
} }
@@ -642,7 +656,7 @@ func deriveRouteCode(manifest map[string]any) *string {
return nil 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{ assets := []postgres.UpsertEventReleaseAssetParams{
{ {
EventReleaseID: eventReleaseID, EventReleaseID: eventReleaseID,
@@ -652,6 +666,13 @@ func (s *ConfigService) mapBuildAssetsToReleaseAssets(eventReleaseID, manifestUR
Checksum: checksum, Checksum: checksum,
Meta: map[string]any{"source": "published-build"}, 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 { for _, asset := range assetIndex {

View File

@@ -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: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: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: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 "CMR backend dev server" -ForegroundColor Cyan
Write-Host ("APP_ENV=" + $env:APP_ENV) Write-Host ("APP_ENV=" + $env:APP_ENV)
Write-Host ("HTTP_ADDR=" + $env:HTTP_ADDR) Write-Host ("HTTP_ADDR=" + $env:HTTP_ADDR)
Write-Host ("DATABASE_URL=" + $env:DATABASE_URL) 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 ""
Write-Host "Workbench:" -ForegroundColor Yellow Write-Host "Workbench:" -ForegroundColor Yellow
$workbenchAddr = $env:HTTP_ADDR $workbenchAddr = $env:HTTP_ADDR

13
backend/start-backend.ps1 Normal file
View File

@@ -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

View File

@@ -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 负责查询与发布编排,客户端继续消费发布后的运行态配置。

View File

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

View File

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

View File

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

View File

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

233
f2b.md Normal file
View File

@@ -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 口径完全定稳。

View File

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

View File

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

View File

@@ -387,6 +387,16 @@ export interface MapEngineGameInfoSnapshot {
export type MapEngineResultSnapshot = ResultSummarySnapshot 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> = [ const VIEW_SYNC_KEYS: Array<keyof MapEngineViewState> = [
'animationLevel', 'animationLevel',
'buildVersion', '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 { buildSessionRecoveryRuntimeSnapshot(): RecoveryRuntimeSnapshot | null {
const definition = this.gameRuntime.definition const definition = this.gameRuntime.definition
const state = this.gameRuntime.state const state = this.gameRuntime.state
@@ -3577,7 +3622,14 @@ export class MapEngine {
} }
handleSetMockLocationMode(): void { handleSetMockLocationMode(): void {
const wasListening = this.locationController.listening
if (!this.locationController.mockBridge.connected && !this.locationController.mockBridge.connecting) {
this.locationController.connectMockBridge()
}
this.locationController.setSourceMode('mock') this.locationController.setSourceMode('mock')
if (!wasListening && !this.locationController.listening) {
this.locationController.start()
}
} }
handleConnectMockLocationBridge(): void { handleConnectMockLocationBridge(): void {
@@ -3594,9 +3646,26 @@ export class MapEngine {
handleSetMockChannelId(channelId: string): void { handleSetMockChannelId(channelId: string): void {
const normalized = String(channelId || '').trim() || 'default' 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.locationController.setMockChannelId(normalized)
this.heartRateController.setMockChannelId(normalized) this.heartRateController.setMockChannelId(normalized)
this.mockSimulatorDebugLogger.setChannelId(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({ this.setState({
mockChannelIdText: normalized, mockChannelIdText: normalized,
}) })
@@ -3663,7 +3732,14 @@ export class MapEngine {
} }
handleSetMockHeartRateMode(): void { handleSetMockHeartRateMode(): void {
const wasConnected = this.heartRateController.connected
if (!this.heartRateController.mockBridge.connected && !this.heartRateController.mockBridge.connecting) {
this.heartRateController.connectMockBridge()
}
this.heartRateController.setSourceMode('mock') this.heartRateController.setSourceMode('mock')
if (!wasConnected && !this.heartRateController.connected) {
this.heartRateController.startScanAndConnect()
}
} }
handleConnectMockHeartRateBridge(): void { handleConnectMockHeartRateBridge(): void {

View File

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

View File

@@ -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}`,
})
}
},
})

View File

@@ -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>

View File

@@ -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;
}

View File

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

View File

@@ -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',
})
},
})

View File

@@ -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>

View File

@@ -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;
}

View File

@@ -1,52 +1,11 @@
// index.ts import { loadBackendAuthTokens } from '../../utils/backendAuth'
// 获取应用实例
const defaultAvatarUrl = 'https://mmbiz.qpic.cn/mmbiz/icTdbqWNOwNRna42FI242Lcia07jQodd2FJGIYQfG0LAJGFxM4FbnQP6yfMxBgJ0F3YRqJCJ1aPAK2dQagdusBZg/0'
Component({ Page({
data: { onLoad() {
motto: 'Hello World', const tokens = loadBackendAuthTokens()
userInfo: { const url = tokens && tokens.accessToken
avatarUrl: defaultAvatarUrl, ? '/pages/home/home'
nickName: '', : '/pages/login/login'
}, wx.redirectTo({ url })
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
})
}
})
},
}, },
}) })

View File

@@ -1,27 +1,3 @@
<!--index.wxml--> <view class="boot-page">
<scroll-view class="scrollarea" scroll-y type="list"> <view class="boot-page__text">正在进入业务页...</view>
<view class="container"> </view>
<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 File

@@ -1,62 +1,13 @@
/**index.wxss**/ .boot-page {
page { min-height: 100vh;
height: 100vh;
display: flex; display: flex;
flex-direction: column;
}
.scrollarea {
flex: 1;
overflow-y: hidden;
}
.userinfo {
display: flex;
flex-direction: column;
align-items: center; align-items: center;
color: #aaa; justify-content: center;
width: 80%; background: linear-gradient(180deg, #0f2f5a 0%, #1d5ca8 100%);
} }
.userinfo-avatar { .boot-page__text {
overflow: hidden; color: #ffffff;
width: 128rpx; font-size: 30rpx;
height: 128rpx; letter-spacing: 0.08em;
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;
}
.nickname-input {
flex: 1;
} }

View File

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

View File

@@ -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: '已清空登录态',
})
},
})

View File

@@ -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>

View File

@@ -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;
}

View File

@@ -12,6 +12,8 @@ import {
type GameLaunchEnvelope, type GameLaunchEnvelope,
type MapPageLaunchOptions, type MapPageLaunchOptions,
} from '../../utils/gameLaunch' } from '../../utils/gameLaunch'
import { finishSession, startSession, type BackendSessionFinishSummaryPayload } from '../../utils/backendApi'
import { loadBackendBaseUrl } from '../../utils/backendAuth'
import { loadRemoteMapConfig, type RemoteMapConfig } from '../../utils/remoteMapConfig' import { loadRemoteMapConfig, type RemoteMapConfig } from '../../utils/remoteMapConfig'
import { type H5ExperienceFallbackPayload, type H5ExperienceRequest } from '../../game/experience/h5Experience' import { type H5ExperienceFallbackPayload, type H5ExperienceRequest } from '../../game/experience/h5Experience'
import { type TrackColorPreset } from '../../game/presentation/trackStyleConfig' import { type TrackColorPreset } from '../../game/presentation/trackStyleConfig'
@@ -173,6 +175,8 @@ let lastPunchHintHapticAt = 0
let currentSystemSettingsConfig: SystemSettingsConfig | undefined let currentSystemSettingsConfig: SystemSettingsConfig | undefined
let currentRemoteMapConfig: RemoteMapConfig | undefined let currentRemoteMapConfig: RemoteMapConfig | undefined
let systemSettingsLockLifetimeActive = false let systemSettingsLockLifetimeActive = false
let syncedBackendSessionStartId = ''
let syncedBackendSessionFinishId = ''
let lastCenterScaleRulerStablePatch: Pick< let lastCenterScaleRulerStablePatch: Pick<
MapPageData, MapPageData,
| 'centerScaleRulerVisible' | '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) { function buildSideButtonVisibility(mode: SideButtonMode) {
return { return {
sideButtonMode: mode, sideButtonMode: mode,
@@ -871,6 +906,8 @@ Page({
onLoad(options: MapPageLaunchOptions) { onLoad(options: MapPageLaunchOptions) {
clearSessionRecoveryPersistTimer() clearSessionRecoveryPersistTimer()
syncedBackendSessionStartId = ''
syncedBackendSessionFinishId = ''
currentGameLaunchEnvelope = resolveGameLaunchEnvelope(options) currentGameLaunchEnvelope = resolveGameLaunchEnvelope(options)
if (!hasExplicitLaunchOptions(options)) { if (!hasExplicitLaunchOptions(options)) {
const recoverySnapshot = loadSessionRecoverySnapshot() const recoverySnapshot = loadSessionRecoverySnapshot()
@@ -991,6 +1028,8 @@ Page({
const nextAnimationLevel = typeof nextPatch.animationLevel === 'string' const nextAnimationLevel = typeof nextPatch.animationLevel === 'string'
? nextPatch.animationLevel ? nextPatch.animationLevel
: this.data.animationLevel : this.data.animationLevel
let shouldSyncBackendSessionStart = false
let backendSessionFinishStatus: 'finished' | 'failed' | null = null
if (nextAnimationLevel === 'lite') { if (nextAnimationLevel === 'lite') {
clearHudFxTimer('timer') clearHudFxTimer('timer')
@@ -1055,6 +1094,7 @@ Page({
nextData.showGameInfoPanel = false nextData.showGameInfoPanel = false
nextData.showSystemSettingsPanel = false nextData.showSystemSettingsPanel = false
clearGameInfoPanelSyncTimer() clearGameInfoPanelSyncTimer()
backendSessionFinishStatus = nextPatch.gameSessionStatus === 'finished' ? 'finished' : 'failed'
} else if ( } else if (
nextPatch.gameSessionStatus !== this.data.gameSessionStatus nextPatch.gameSessionStatus !== this.data.gameSessionStatus
&& nextPatch.gameSessionStatus === 'idle' && nextPatch.gameSessionStatus === 'idle'
@@ -1064,6 +1104,11 @@ Page({
shouldSyncRuntimeSystemSettings = true shouldSyncRuntimeSystemSettings = true
clearSessionRecoverySnapshot() clearSessionRecoverySnapshot()
clearSessionRecoveryPersistTimer() clearSessionRecoveryPersistTimer()
} else if (
nextPatch.gameSessionStatus !== this.data.gameSessionStatus
&& nextPatch.gameSessionStatus === 'running'
) {
shouldSyncBackendSessionStart = true
} else if (nextPatch.gameSessionStatus === 'running' || nextPatch.gameSessionStatus === 'idle') { } else if (nextPatch.gameSessionStatus === 'running' || nextPatch.gameSessionStatus === 'idle') {
nextData.showResultScene = false nextData.showResultScene = false
} }
@@ -1077,6 +1122,12 @@ Page({
if (typeof nextPatch.gameSessionStatus === 'string') { if (typeof nextPatch.gameSessionStatus === 'string') {
this.syncSessionRecoveryLifecycle(nextPatch.gameSessionStatus) this.syncSessionRecoveryLifecycle(nextPatch.gameSessionStatus)
} }
if (shouldSyncBackendSessionStart) {
this.syncBackendSessionStart()
}
if (backendSessionFinishStatus) {
this.syncBackendSessionFinish(backendSessionFinishStatus)
}
if (shouldSyncRuntimeSystemSettings) { if (shouldSyncRuntimeSystemSettings) {
this.applyRuntimeSystemSettings(nextLockLifetimeActive) this.applyRuntimeSystemSettings(nextLockLifetimeActive)
} }
@@ -1088,6 +1139,12 @@ Page({
if (typeof nextPatch.gameSessionStatus === 'string') { if (typeof nextPatch.gameSessionStatus === 'string') {
this.syncSessionRecoveryLifecycle(nextPatch.gameSessionStatus) this.syncSessionRecoveryLifecycle(nextPatch.gameSessionStatus)
} }
if (shouldSyncBackendSessionStart) {
this.syncBackendSessionStart()
}
if (backendSessionFinishStatus) {
this.syncBackendSessionFinish(backendSessionFinishStatus)
}
if (shouldSyncRuntimeSystemSettings) { if (shouldSyncRuntimeSystemSettings) {
this.applyRuntimeSystemSettings(nextLockLifetimeActive) this.applyRuntimeSystemSettings(nextLockLifetimeActive)
} }
@@ -1283,6 +1340,8 @@ Page({
onUnload() { onUnload() {
this.persistSessionRecoverySnapshot() this.persistSessionRecoverySnapshot()
clearSessionRecoveryPersistTimer() clearSessionRecoveryPersistTimer()
syncedBackendSessionStartId = ''
syncedBackendSessionFinishId = ''
clearGameInfoPanelSyncTimer() clearGameInfoPanelSyncTimer()
clearCenterScaleRulerSyncTimer() clearCenterScaleRulerSyncTimer()
clearCenterScaleRulerUpdateTimer() clearCenterScaleRulerUpdateTimer()
@@ -1332,6 +1391,114 @@ Page({
return true 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']) { syncSessionRecoveryLifecycle(status: MapPageData['gameSessionStatus']) {
if (status === 'running') { if (status === 'running') {
this.persistSessionRecoverySnapshot() this.persistSessionRecoverySnapshot()
@@ -1368,7 +1535,7 @@ Page({
cancelText: '放弃', cancelText: '放弃',
success: (result) => { success: (result) => {
if (!result.confirm) { if (!result.confirm) {
clearSessionRecoverySnapshot() this.reportAbandonedRecoverySnapshot(snapshot)
return return
} }
@@ -1385,15 +1552,19 @@ Page({
return return
} }
this.setData({ this.setData({
showResultScene: false, showResultScene: false,
showDebugPanel: false, showDebugPanel: false,
showGameInfoPanel: false, showGameInfoPanel: false,
showSystemSettingsPanel: false, showSystemSettingsPanel: false,
}) })
this.syncSessionRecoveryLifecycle('running') const sessionContext = getCurrentBackendSessionContext()
}, if (sessionContext) {
}) syncedBackendSessionStartId = sessionContext.sessionId
}
this.syncSessionRecoveryLifecycle('running')
},
})
}, },
compileCurrentRuntimeProfile(lockLifetimeActive = isSystemSettingsLockLifetimeActive()) { compileCurrentRuntimeProfile(lockLifetimeActive = isSystemSettingsLockLifetimeActive()) {
@@ -1537,7 +1708,10 @@ Page({
return 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({ this.setData({
configStatusText: `载入失败: ${errorMessage}`, configStatusText: `载入失败: ${errorMessage}`,
statusText: `远程地图配置载入失败: ${errorMessage} (${INTERNAL_BUILD_VERSION})`, statusText: `远程地图配置载入失败: ${errorMessage} (${INTERNAL_BUILD_VERSION})`,
@@ -1939,18 +2113,19 @@ Page({
return return
} }
wx.showModal({ wx.showModal({
title: '确认退出', title: '确认退出',
content: '确认强制结束当前对局并返回开始前状态?', content: '确认强制结束当前对局并返回开始前状态?',
confirmText: '确认退出', confirmText: '确认退出',
cancelText: '取消', cancelText: '取消',
success: (result) => { success: (result) => {
if (result.confirm && mapEngine) { if (result.confirm && mapEngine) {
systemSettingsLockLifetimeActive = false this.syncBackendSessionFinish('cancelled')
mapEngine.handleForceExitGame() systemSettingsLockLifetimeActive = false
} mapEngine.handleForceExitGame()
}, }
}) },
})
}, },
handleSkipAction() { handleSkipAction() {

View File

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

View File

@@ -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()
},
})

View File

@@ -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>

View File

@@ -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;
}

View File

@@ -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,
})
}

View File

@@ -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 {}
}

View File

@@ -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,
},
}
}

View File

@@ -76,9 +76,9 @@
位于 [tools/mock-gps-sim](D:/dev/cmr-mini/tools/mock-gps-sim) 位于 [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 服务 - [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/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/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/workbench.css](D:/dev/cmr-mini/tools/mock-gps-sim/public/workbench.css)新版工作台布局与样式
--- ---
@@ -557,6 +557,7 @@ HUD 当前颜色由 telemetry 驱动。
{ {
"type": "mock_gps", "type": "mock_gps",
"timestamp": 1711267200000, "timestamp": 1711267200000,
"channelId": "runner-a",
"lat": 31.2304, "lat": 31.2304,
"lon": 121.4737, "lon": 121.4737,
"accuracyMeters": 6, "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) - [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) - [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) - [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", "type": "mock_heart_rate",
"timestamp": 1711267200000, "timestamp": 1711267200000,
"channelId": "runner-a",
"bpm": 148 "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 当前推荐的联调顺序 ### 18.5 当前推荐的联调顺序

View File

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

View File

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

2
typings/index.d.ts vendored
View File

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