完善后端联调链路与模拟器多通道支持
This commit is contained in:
250
b2f.md
Normal file
250
b2f.md
Normal 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)
|
||||||
@@ -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
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|
||||||
## 快速启动
|
## 快速启动
|
||||||
|
|||||||
@@ -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 分层
|
||||||
- 配置构建器
|
- 配置构建器
|
||||||
- 发布资产清单
|
- 发布资产清单
|
||||||
|
|||||||
@@ -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 口径定稳,再继续扩后台配置系统。
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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`
|
||||||
|
|
||||||
|
业务接口必须保持统一,终端差异只进入上下文,不进入对象模型分叉。
|
||||||
|
|||||||
@@ -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 后续网关该怎么接
|
||||||
|
|
||||||
后面如果接实时网关,建议仍然走:
|
后面如果接实时网关,建议仍然走:
|
||||||
|
|||||||
589
backend/docs/资源对象与目录方案.md
Normal file
589
backend/docs/资源对象与目录方案.md
Normal 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,而不是做成“小程序跑通后再重构”的临时结构。
|
||||||
@@ -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)
|
||||||
|
|||||||
@@ -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
|
||||||
|
}
|
||||||
|
|||||||
96
backend/internal/platform/assets/publisher.go
Normal file
96
backend/internal/platform/assets/publisher.go
Normal 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
|
||||||
|
}
|
||||||
@@ -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 {
|
||||||
|
|||||||
@@ -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
13
backend/start-backend.ps1
Normal 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
|
||||||
696
doc/backend/业务后端数据库初版方案.md
Normal file
696
doc/backend/业务后端数据库初版方案.md
Normal 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 负责查询与发布编排,客户端继续消费发布后的运行态配置。
|
||||||
@@ -95,6 +95,8 @@
|
|||||||
|
|
||||||
也就是说,一个模拟器页面实例默认对应一个通道。
|
也就是说,一个模拟器页面实例默认对应一个通道。
|
||||||
|
|
||||||
|
当前这个输入已经提升到工作台顶部,作为全局调试参数,不再挂在“定位发送”分组下面。
|
||||||
|
|
||||||
## 小程序侧
|
## 小程序侧
|
||||||
|
|
||||||
调试面板提供一个统一输入:
|
调试面板提供一个统一输入:
|
||||||
|
|||||||
@@ -28,10 +28,10 @@
|
|||||||
|
|
||||||
新版面板采用工作台布局:
|
新版面板采用工作台布局:
|
||||||
|
|
||||||
- 顶部:连接状态条
|
- 顶部:连接状态条与全局模拟通道号
|
||||||
- 左侧:控制区
|
- 左侧:控制区
|
||||||
- 中间:地图与路径预览
|
- 中间:地图与路径预览
|
||||||
- 右侧:状态摘要与快捷观察
|
- 右侧:运行摘要、当前位置、最近事件
|
||||||
- 右下:调试日志浮层
|
- 右下:调试日志浮层
|
||||||
|
|
||||||
## 功能分区
|
## 功能分区
|
||||||
@@ -44,6 +44,7 @@
|
|||||||
- 心率模拟连接状态
|
- 心率模拟连接状态
|
||||||
- 调试日志连接状态
|
- 调试日志连接状态
|
||||||
- 一键连接开发调试源
|
- 一键连接开发调试源
|
||||||
|
- 全局模拟通道号
|
||||||
|
|
||||||
### 2. 左侧控制区
|
### 2. 左侧控制区
|
||||||
|
|
||||||
@@ -65,13 +66,9 @@
|
|||||||
|
|
||||||
包含:
|
包含:
|
||||||
|
|
||||||
- 当前经纬度
|
- 运行摘要
|
||||||
- 当前航向
|
- 当前位置
|
||||||
- 当前路径点数
|
- 最近事件
|
||||||
- 最近发送状态
|
|
||||||
- 最近心率发送状态
|
|
||||||
- 资源加载摘要
|
|
||||||
- 网关桥接摘要
|
|
||||||
|
|
||||||
### 5. 日志区
|
### 5. 日志区
|
||||||
|
|
||||||
@@ -80,6 +77,8 @@
|
|||||||
- 默认悬浮在地图右下
|
- 默认悬浮在地图右下
|
||||||
- 可清空
|
- 可清空
|
||||||
- 面积更大
|
- 面积更大
|
||||||
|
- 可缩到一角
|
||||||
|
- 支持按 scope 过滤
|
||||||
- 便于边看地图边看日志
|
- 便于边看地图边看日志
|
||||||
|
|
||||||
## 实施顺序
|
## 实施顺序
|
||||||
|
|||||||
@@ -103,8 +103,10 @@
|
|||||||
最小能力:
|
最小能力:
|
||||||
|
|
||||||
- websocket 接收 `debug-log`
|
- websocket 接收 `debug-log`
|
||||||
- UI 新增“调试日志”区域
|
- UI 使用右下角可缩放浮层承接“调试日志”
|
||||||
- 仅显示 `debug-log`
|
- 仅显示 `debug-log`
|
||||||
|
- 支持按 `scope` 过滤
|
||||||
|
- 按当前 `channelId` 过滤显示
|
||||||
- 保留最近若干条,避免无限增长
|
- 保留最近若干条,避免无限增长
|
||||||
|
|
||||||
## 后续扩展
|
## 后续扩展
|
||||||
|
|||||||
@@ -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
233
f2b.md
Normal 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 口径完全定稳。
|
||||||
@@ -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": {
|
||||||
|
|||||||
@@ -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())
|
||||||
|
|||||||
@@ -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 {
|
||||||
|
|||||||
3
miniprogram/pages/event/event.json
Normal file
3
miniprogram/pages/event/event.json
Normal file
@@ -0,0 +1,3 @@
|
|||||||
|
{
|
||||||
|
"navigationBarTitleText": "活动"
|
||||||
|
}
|
||||||
123
miniprogram/pages/event/event.ts
Normal file
123
miniprogram/pages/event/event.ts
Normal 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}`,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
},
|
||||||
|
})
|
||||||
20
miniprogram/pages/event/event.wxml
Normal file
20
miniprogram/pages/event/event.wxml
Normal 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>
|
||||||
91
miniprogram/pages/event/event.wxss
Normal file
91
miniprogram/pages/event/event.wxss
Normal 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;
|
||||||
|
}
|
||||||
3
miniprogram/pages/home/home.json
Normal file
3
miniprogram/pages/home/home.json
Normal file
@@ -0,0 +1,3 @@
|
|||||||
|
{
|
||||||
|
"navigationBarTitleText": "首页"
|
||||||
|
}
|
||||||
127
miniprogram/pages/home/home.ts
Normal file
127
miniprogram/pages/home/home.ts
Normal 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',
|
||||||
|
})
|
||||||
|
},
|
||||||
|
})
|
||||||
32
miniprogram/pages/home/home.wxml
Normal file
32
miniprogram/pages/home/home.wxml
Normal 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>
|
||||||
111
miniprogram/pages/home/home.wxss
Normal file
111
miniprogram/pages/home/home.wxss
Normal 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;
|
||||||
|
}
|
||||||
@@ -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
|
|
||||||
})
|
|
||||||
}
|
|
||||||
})
|
|
||||||
},
|
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -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>
|
|
||||||
|
|||||||
@@ -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;
|
|
||||||
}
|
}
|
||||||
|
|||||||
3
miniprogram/pages/login/login.json
Normal file
3
miniprogram/pages/login/login.json
Normal file
@@ -0,0 +1,3 @@
|
|||||||
|
{
|
||||||
|
"navigationBarTitleText": "登录"
|
||||||
|
}
|
||||||
127
miniprogram/pages/login/login.ts
Normal file
127
miniprogram/pages/login/login.ts
Normal 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: '已清空登录态',
|
||||||
|
})
|
||||||
|
},
|
||||||
|
})
|
||||||
35
miniprogram/pages/login/login.wxml
Normal file
35
miniprogram/pages/login/login.wxml
Normal 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>
|
||||||
116
miniprogram/pages/login/login.wxss
Normal file
116
miniprogram/pages/login/login.wxss
Normal 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;
|
||||||
|
}
|
||||||
@@ -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
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1391,6 +1558,10 @@ Page({
|
|||||||
showGameInfoPanel: false,
|
showGameInfoPanel: false,
|
||||||
showSystemSettingsPanel: false,
|
showSystemSettingsPanel: false,
|
||||||
})
|
})
|
||||||
|
const sessionContext = getCurrentBackendSessionContext()
|
||||||
|
if (sessionContext) {
|
||||||
|
syncedBackendSessionStartId = sessionContext.sessionId
|
||||||
|
}
|
||||||
this.syncSessionRecoveryLifecycle('running')
|
this.syncSessionRecoveryLifecycle('running')
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
@@ -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})`,
|
||||||
@@ -1946,6 +2120,7 @@ Page({
|
|||||||
cancelText: '取消',
|
cancelText: '取消',
|
||||||
success: (result) => {
|
success: (result) => {
|
||||||
if (result.confirm && mapEngine) {
|
if (result.confirm && mapEngine) {
|
||||||
|
this.syncBackendSessionFinish('cancelled')
|
||||||
systemSettingsLockLifetimeActive = false
|
systemSettingsLockLifetimeActive = false
|
||||||
mapEngine.handleForceExitGame()
|
mapEngine.handleForceExitGame()
|
||||||
}
|
}
|
||||||
|
|||||||
3
miniprogram/pages/result/result.json
Normal file
3
miniprogram/pages/result/result.json
Normal file
@@ -0,0 +1,3 @@
|
|||||||
|
{
|
||||||
|
"navigationBarTitleText": "结果"
|
||||||
|
}
|
||||||
134
miniprogram/pages/result/result.ts
Normal file
134
miniprogram/pages/result/result.ts
Normal 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()
|
||||||
|
},
|
||||||
|
})
|
||||||
33
miniprogram/pages/result/result.wxml
Normal file
33
miniprogram/pages/result/result.wxml
Normal 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>
|
||||||
114
miniprogram/pages/result/result.wxss
Normal file
114
miniprogram/pages/result/result.wxss
Normal 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;
|
||||||
|
}
|
||||||
375
miniprogram/utils/backendApi.ts
Normal file
375
miniprogram/utils/backendApi.ts
Normal 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,
|
||||||
|
})
|
||||||
|
}
|
||||||
86
miniprogram/utils/backendAuth.ts
Normal file
86
miniprogram/utils/backendAuth.ts
Normal 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 {}
|
||||||
|
}
|
||||||
21
miniprogram/utils/backendLaunchAdapter.ts
Normal file
21
miniprogram/utils/backendLaunchAdapter.ts
Normal 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,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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 当前推荐的联调顺序
|
||||||
|
|
||||||
|
|||||||
@@ -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`
|
||||||
|
|||||||
@@ -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
2
typings/index.d.ts
vendored
@@ -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,
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user