From a70dc8d5d0b68c562aadb05d08dfdd27ae6c9d33 Mon Sep 17 00:00:00 2001 From: zhangyan Date: Wed, 1 Apr 2026 18:48:59 +0800 Subject: [PATCH] =?UTF-8?q?=E5=AE=8C=E5=96=84=E5=90=8E=E7=AB=AF=E8=81=94?= =?UTF-8?q?=E8=B0=83=E9=93=BE=E8=B7=AF=E4=B8=8E=E6=A8=A1=E6=8B=9F=E5=99=A8?= =?UTF-8?q?=E5=A4=9A=E9=80=9A=E9=81=93=E6=94=AF=E6=8C=81?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- b2f.md | 250 +++++++ backend/.env.example | 4 + backend/README.md | 1 + backend/docs/README.md | 8 +- backend/docs/todolist.md | 93 ++- backend/docs/开发说明.md | 12 + backend/docs/核心流程.md | 24 + backend/docs/系统架构.md | 33 + backend/docs/资源对象与目录方案.md | 589 +++++++++++++++ backend/internal/app/app.go | 4 +- backend/internal/app/config.go | 16 + backend/internal/platform/assets/publisher.go | 96 +++ backend/internal/service/config_service.go | 27 +- backend/scripts/start-dev.ps1 | 20 + backend/start-backend.ps1 | 13 + doc/backend/业务后端数据库初版方案.md | 696 ++++++++++++++++++ doc/debug/模拟器多通道联调最小方案.md | 2 + doc/debug/模拟器控制面板重构方案.md | 17 +- doc/debug/模拟器调试日志方案.md | 4 +- doc/debug/调试文档索引.md | 12 +- f2b.md | 233 ++++++ miniprogram/app.json | 6 +- miniprogram/app.ts | 7 + miniprogram/engine/map/mapEngine.ts | 76 ++ miniprogram/pages/event/event.json | 3 + miniprogram/pages/event/event.ts | 123 ++++ miniprogram/pages/event/event.wxml | 20 + miniprogram/pages/event/event.wxss | 91 +++ miniprogram/pages/home/home.json | 3 + miniprogram/pages/home/home.ts | 127 ++++ miniprogram/pages/home/home.wxml | 32 + miniprogram/pages/home/home.wxss | 111 +++ miniprogram/pages/index/index.ts | 57 +- miniprogram/pages/index/index.wxml | 30 +- miniprogram/pages/index/index.wxss | 65 +- miniprogram/pages/login/login.json | 3 + miniprogram/pages/login/login.ts | 127 ++++ miniprogram/pages/login/login.wxml | 35 + miniprogram/pages/login/login.wxss | 116 +++ miniprogram/pages/map/map.ts | 221 +++++- miniprogram/pages/result/result.json | 3 + miniprogram/pages/result/result.ts | 134 ++++ miniprogram/pages/result/result.wxml | 33 + miniprogram/pages/result/result.wxss | 114 +++ miniprogram/utils/backendApi.ts | 375 ++++++++++ miniprogram/utils/backendAuth.ts | 86 +++ miniprogram/utils/backendLaunchAdapter.ts | 21 + readme-develop.md | 42 +- tools/mock-gps-sim/README.md | 9 + tools/mock-gps-sim/server.js | 8 + typings/index.d.ts | 2 + 51 files changed, 4037 insertions(+), 197 deletions(-) create mode 100644 b2f.md create mode 100644 backend/docs/资源对象与目录方案.md create mode 100644 backend/internal/platform/assets/publisher.go create mode 100644 backend/start-backend.ps1 create mode 100644 doc/backend/业务后端数据库初版方案.md create mode 100644 f2b.md create mode 100644 miniprogram/pages/event/event.json create mode 100644 miniprogram/pages/event/event.ts create mode 100644 miniprogram/pages/event/event.wxml create mode 100644 miniprogram/pages/event/event.wxss create mode 100644 miniprogram/pages/home/home.json create mode 100644 miniprogram/pages/home/home.ts create mode 100644 miniprogram/pages/home/home.wxml create mode 100644 miniprogram/pages/home/home.wxss create mode 100644 miniprogram/pages/login/login.json create mode 100644 miniprogram/pages/login/login.ts create mode 100644 miniprogram/pages/login/login.wxml create mode 100644 miniprogram/pages/login/login.wxss create mode 100644 miniprogram/pages/result/result.json create mode 100644 miniprogram/pages/result/result.ts create mode 100644 miniprogram/pages/result/result.wxml create mode 100644 miniprogram/pages/result/result.wxss create mode 100644 miniprogram/utils/backendApi.ts create mode 100644 miniprogram/utils/backendAuth.ts create mode 100644 miniprogram/utils/backendLaunchAdapter.ts diff --git a/b2f.md b/b2f.md new file mode 100644 index 0000000..da19b4c --- /dev/null +++ b/b2f.md @@ -0,0 +1,250 @@ +# Backend To Frontend + +这份文件只用于记录 backend 当前对 frontend 的联调要求和协作约束。 + +约定: + +- 我只在这里写“后端已经具备什么、前端现在需要怎么接、哪些地方不能自行假设” +- 需要你拍板的事项,仍然先由你确认,不在这里直接定版 +- 前端给 backend 的反馈不要写这里,另走 `f2b.md` + +--- + +## 1. 当前联调基线 + +当前建议前端统一使用这组 demo 数据联调: + +- `eventPublicID = evt_demo_001` +- `channelCode = mini-demo` +- `channelType = wechat_mini` + +当前主链已经可联调: + +- 微信小程序登录 +- 首页聚合 +- `event play` +- `launch` +- `session start / finish` +- `session result` + +--- + +## 2. 当前已确认可用的后端能力 + +登录与用户: + +- `POST /auth/login/wechat-mini` +- `POST /auth/sms/send` +- `POST /auth/login/sms` +- `POST /auth/bind/mobile` +- `GET /me` +- `GET /me/profile` + +首页与入口: + +- `GET /entry/resolve` +- `GET /me/entry-home` + +活动与启动: + +- `GET /events/{eventPublicID}` +- `GET /events/{eventPublicID}/play` +- `POST /events/{eventPublicID}/launch` + +局内与结果: + +- `GET /sessions/{sessionPublicID}` +- `POST /sessions/{sessionPublicID}/start` +- `POST /sessions/{sessionPublicID}/finish` +- `GET /sessions/{sessionPublicID}/result` +- `GET /me/sessions` +- `GET /me/results` + +配置发布: + +- `GET /dev/config/local-files` +- `POST /dev/events/{eventPublicID}/config-sources/import-local` +- `POST /dev/config-builds/preview` +- `POST /dev/config-builds/publish` + +开发工具: + +- `POST /dev/bootstrap-demo` +- `GET /dev/workbench` + +--- + +## 3. 前端现在需要怎么接 + +## 3.1 登录 + +小程序当前主链: + +1. `POST /auth/login/wechat-mini` +2. 保存 `accessToken / refreshToken` +3. 后续业务接口统一带 Bearer token + +手机号绑定场景: + +1. `POST /auth/sms/send` with `scene=bind_mobile` +2. `POST /auth/bind/mobile` + +## 3.2 首页 + +首页直接接: + +- `GET /me/entry-home` + +不要自己拼: + +- `/me` +- `/home` +- `/cards` +- `/me/sessions` + +## 3.3 活动详情与开始前准备 + +活动详情 / 开始前准备页直接接: + +- `GET /events/{eventPublicID}/play` + +它的作用是: + +- 判断当前是否可启动 +- 判断主按钮应该是 `start` 还是 `continue` +- 返回当前会落到哪份 `release` + +## 3.4 进入游戏 + +进入游戏必须走: + +- `POST /events/{eventPublicID}/launch` + +启动后前端应以这些字段为准: + +- `launch.resolvedRelease.releaseId` +- `launch.resolvedRelease.manifestUrl` +- `launch.resolvedRelease.manifestChecksumSha256` +- `launch.config.configUrl` +- `launch.config.configLabel` +- `launch.config.releaseId` +- `launch.config.routeCode` +- `launch.business.sessionId` +- `launch.business.sessionToken` +- `launch.business.sessionTokenExpiresAt` + +## 3.5 结果页 + +结果页直接接: + +- `GET /sessions/{sessionPublicID}/result` + +列表页直接接: + +- `GET /me/results` + +--- + +## 4. 前端必须遵守的约束 + +## 4.1 正式流程只认 launch 返回的 manifest + +前端进入地图时: + +- 不要自己拼 release URL +- 不要回退到本地样例配置路径 +- 不要直接读取根目录 `event/*.json` + +必须以 launch 返回的为准: + +- `manifestUrl` +- `manifestChecksumSha256` +- `releaseId` + +## 4.2 launch 返回契约先不要自行扩展假设 + +前端当前只消费已约定字段。 + +如果出现下面任一情况,直接反馈阻塞,不要自行猜: + +- 字段缺失 +- 字段改名 +- 字段层级变化 +- `resolvedRelease` 与 `config` 含义不一致 + +## 4.3 release manifest 再报错时必须带完整上下文 + +如果再出现配置加载失败,请回传: + +- `eventPublicID` +- `releaseId` +- `manifestUrl` +- 页面报错文案 +- 控制台日志 +- 网络请求日志 + +--- + +## 5. 当前需要前端配合验证的事项 + +## F-001 回归最新 demo release + +当前建议回归使用: + +- `eventPublicID = evt_demo_001` +- `releaseId = rel_e7dd953743c5c0d2` +- `manifestUrl = https://oss-mbh5.colormaprun.com/gotomars/event/releases/evt_demo_001/rel_e7dd953743c5c0d2/manifest.json` + +需要前端验证: + +1. `play -> launch -> map load` 已能走通 +2. 地图页不再报 `release manifest 不存在或未发布` +3. 不再访问旧的失效 release + +## F-002 预埋“放弃恢复”调用位 + +这项先预埋,不要先自行定语义。 + +建议前端准备好: + +- 在“放弃恢复”按钮点击后,预留调用 `finish(cancelled)` 的位置 + +但是否正式启用: + +- 要等 backend 把 `cancelled` 语义确认完 + +## F-003 首页 / play / result ongoing 语义联调 + +后面 backend 收稳 `finished / failed / cancelled` 之后,前端需要配合回归: + +- `/me/entry-home` +- `/events/{eventPublicID}/play` +- `/sessions/{sessionPublicID}/result` + +重点看: + +- `cancelled` 后不再继续显示为 ongoing +- `failed` 后不再继续显示为 ongoing +- `finished` 后结果和首页摘要一致 + +--- + +## 6. 当前建议的前端接入顺序 + +1. 登录页 +2. 首页 +3. 活动详情页 / 开始前准备页 +4. launch +5. session start / finish +6. 结果页 +7. 我的页 + +--- + +## 7. 参考文档 + +- [后端总览 README](D:/dev/cmr-mini/backend/README.md) +- [接口清单](D:/dev/cmr-mini/backend/docs/接口清单.md) +- [开发说明](D:/dev/cmr-mini/backend/docs/开发说明.md) +- [系统架构](D:/dev/cmr-mini/backend/docs/系统架构.md) +- [核心流程](D:/dev/cmr-mini/backend/docs/核心流程.md) diff --git a/backend/.env.example b/backend/.env.example index 9871a00..2afaf09 100644 --- a/backend/.env.example +++ b/backend/.env.example @@ -18,3 +18,7 @@ WECHAT_MINI_DEV_PREFIX=dev- LOCAL_EVENT_DIR=..\event ASSET_BASE_URL=https://oss-mbh5.colormaprun.com/gotomars +ASSET_PUBLIC_BASE_URL=https://oss-mbh5.colormaprun.com +ASSET_BUCKET_ROOT=oss://color-map-html +OSSUTIL_PATH=..\tools\ossutil.exe +OSSUTIL_CONFIG_FILE=C:\Users\your-user\.ossutilconfig diff --git a/backend/README.md b/backend/README.md index 2212c2b..4f83963 100644 --- a/backend/README.md +++ b/backend/README.md @@ -19,6 +19,7 @@ - [API 清单](D:/dev/cmr-mini/backend/docs/接口清单.md) - [数据模型](D:/dev/cmr-mini/backend/docs/数据模型.md) - [配置管理方案](D:/dev/cmr-mini/backend/docs/配置管理方案.md) +- [资源对象与目录方案](D:/dev/cmr-mini/backend/docs/资源对象与目录方案.md) - [开发说明](D:/dev/cmr-mini/backend/docs/开发说明.md) ## 快速启动 diff --git a/backend/docs/README.md b/backend/docs/README.md index dd44801..250654a 100644 --- a/backend/docs/README.md +++ b/backend/docs/README.md @@ -12,9 +12,10 @@ 3. [API 清单](D:/dev/cmr-mini/backend/docs/接口清单.md) 4. [数据模型](D:/dev/cmr-mini/backend/docs/数据模型.md) 5. [配置管理方案](D:/dev/cmr-mini/backend/docs/配置管理方案.md) -6. [前后端联调清单](D:/dev/cmr-mini/backend/docs/前后端联调清单.md) -7. [TodoList](D:/dev/cmr-mini/backend/docs/todolist.md) -8. [开发说明](D:/dev/cmr-mini/backend/docs/开发说明.md) +6. [资源对象与目录方案](D:/dev/cmr-mini/backend/docs/资源对象与目录方案.md) +7. [前后端联调清单](D:/dev/cmr-mini/backend/docs/前后端联调清单.md) +8. [TodoList](D:/dev/cmr-mini/backend/docs/todolist.md) +9. [开发说明](D:/dev/cmr-mini/backend/docs/开发说明.md) ## 当前系统范围 @@ -34,6 +35,7 @@ 下一阶段建议重点: - 可伸缩配置管理 +- 共享资源对象化 - source/build/release 分层 - 配置构建器 - 发布资产清单 diff --git a/backend/docs/todolist.md b/backend/docs/todolist.md index 346aba2..96912c2 100644 --- a/backend/docs/todolist.md +++ b/backend/docs/todolist.md @@ -30,9 +30,29 @@ 所以 backend 现在最重要的不是再扩散接口,而是把当前契约和语义收稳。 +当前已确认不再阻塞主链的事项: + +- `evt_demo_001` 的 release manifest 现已可正常加载 +- 小程序已能进入地图 +- 模拟定位 / 调试日志问题已回到小程序与模拟器侧,不再属于 backend 当前阻塞 + +前端当前需要配合的事项: + +- 正式联调时始终以 `launch.resolvedRelease.manifestUrl` 为准,不再回退到本地样例配置路径 +- 如果再出现配置加载失败,反馈完整上下文: + - `eventPublicID` + - `releaseId` + - `manifestUrl` + - 页面报错文案 + - 控制台 / 网络日志 +- 当前 demo 联调建议统一使用: + - `eventPublicID = evt_demo_001` + - `channelCode = mini-demo` + - `channelType = wechat_mini` + ## 3. P0 必做 -## 3.1 固定 session 状态语义 +## 3.0 固定 session 状态语义 需要 backend 明确并固定: @@ -51,7 +71,7 @@ - 小程序现在已经按这个方向接 - 如果 backend 想改这 3 个状态语义,需要先讨论,不要单边改 -## 3.2 明确“放弃恢复”的后端处理 +## 3.1 明确“放弃恢复”的后端处理 这是当前最值得后端配合确认的一点。 @@ -87,7 +107,7 @@ backend 需要确认的目标语义是: - 如果 backend 认可这套语义,小程序侧下一步就可以把“点击放弃恢复”改成同步调用 `finish(cancelled)`。 -## 3.3 保证 start / finish 幂等与重复调用安全 +## 3.2 保证 start / finish 幂等与重复调用安全 联调和真实环境里,以下情况很常见: @@ -110,7 +130,7 @@ backend 需要确认: - 不把客户端补偿逻辑变成一堆冲突分支 -## 3.4 固定 `launch` 返回契约,不随意漂移 +## 3.3 固定 `launch` 返回契约,不随意漂移 当前客户端已经按下面这些字段接入: @@ -130,9 +150,38 @@ backend 现在需要做的是: - 先保持这些字段名稳定 - 如果要调整命名或层级,先沟通 +前端当前需要做的是: + +- 只消费当前已约定字段 +- 不额外推断 release URL +- 不把本地样例配置路径混进正式 launch 流程 +- 如果字段缺失或命名变化,直接在联调清单里标阻塞 + ## 4. P1 应尽快做 -## 4.1 增加用户身体资料读取接口 +## 4.1 给首页 / play / result 的 ongoing 语义再做一次回归确认 + +当前前端已经开始走: + +- 首页聚合 +- `event play` +- `launch` +- `session start / finish` +- 本地故障恢复 + +backend 建议再回归确认这几个接口对“进行中 session”的口径一致: + +- `/me/entry-home` +- `/events/{eventPublicID}/play` +- `/sessions/{sessionPublicID}/result` + +重点确认: + +1. `cancelled` 后不再继续出现在 ongoing 入口 +2. `failed` 后不再继续出现在 ongoing 入口 +3. `finished` 后结果页与首页摘要字段一致 + +## 4.2 增加用户身体资料读取接口 小程序侧已经有: @@ -152,7 +201,7 @@ backend 下一步建议提供: 这样后面心率页和消耗估算就能真实接业务数据。 -## 4.2 给 `session result` 补一点稳定摘要字段校验 +## 4.3 给 `session result` 补一点稳定摘要字段校验 客户端现在会上报: @@ -170,7 +219,7 @@ backend 建议补两件事: 不要因为某个可选字段缺失就整局 finish 失败。 -## 4.3 dev workbench 增加一组“恢复 / 取消恢复”场景按钮 +## 4.4 dev workbench 增加一组“恢复 / 取消恢复”场景按钮 当前 workbench 已经很好用了。 @@ -182,6 +231,17 @@ backend 建议补两件事: 这会很适合配合小程序故障恢复联调。 +## 4.5 前端预埋“放弃恢复”调用位 + +这项先预埋,不要先自行定语义。 + +前端建议准备好: + +- 在“放弃恢复”按钮点击后,预留调用 `finish(cancelled)` 的位置 +- 但是否正式启用,要等 backend 把 `cancelled` 语义确认完 + +这样一旦 backend 确认语义,小程序就能快速切过去,不需要再改一轮页面流程。 + ## 5. P2 下一阶段 ## 5.1 配置后台 source / build / release 真正开始做 @@ -205,6 +265,23 @@ backend 建议补两件事: 这部分不是当前联调阻塞项,但后面会成为业务壳的重要组成。 +## 5.3 兼顾未来 APP 的统一后端约束 + +backend 后续建设需要继续坚持: + +- 不做“小程序专用后端” +- 用户模型保持平台级 +- `event / release / session / result` 不按终端拆两套 +- 终端差异只通过上下文字段和运行时适配处理 + +建议优先保持: + +- 业务接口统一 +- 配置发布结构统一 +- 结果沉淀结构统一 + +这样后面 APP 接入时不会推翻现有 backend 结构。 + ## 6. 需要先讨论再动的边界 这些事项 backend 不建议自己先拍板: @@ -252,4 +329,4 @@ backend 现在最值得先做的,不是扩接口,而是先确认下面 3 条 当前 backend 最重要的任务不是“再加更多接口”,而是: -> 先把 session 运行态语义和故障恢复放弃语义定稳,再继续扩后台配置系统。 +> 先把 session 运行态语义、放弃恢复语义和 ongoing session 口径定稳,再继续扩后台配置系统。 diff --git a/backend/docs/开发说明.md b/backend/docs/开发说明.md index 8722c2d..aeaf2fa 100644 --- a/backend/docs/开发说明.md +++ b/backend/docs/开发说明.md @@ -17,6 +17,10 @@ - `WECHAT_MINI_DEV_PREFIX` - `LOCAL_EVENT_DIR` - `ASSET_BASE_URL` +- `ASSET_PUBLIC_BASE_URL` +- `ASSET_BUCKET_ROOT` +- `OSSUTIL_PATH` +- `OSSUTIL_CONFIG_FILE` ## 2. 本地启动 @@ -86,6 +90,9 @@ Redis 后面只在需要性能优化、限流或短期票据缓存时再接。 - `LOCAL_EVENT_DIR` 决定本地 source config 从哪里读 - `ASSET_BASE_URL` 决定 preview build 时如何把相对资源路径归一化成可运行 URL +- `ASSET_PUBLIC_BASE_URL` 决定 publish 时如何把公开 URL 映射到 OSS 对象 key +- `ASSET_BUCKET_ROOT` 决定发布对象上传到哪个 bucket 根路径 +- `OSSUTIL_PATH` 和 `OSSUTIL_CONFIG_FILE` 决定 backend 发布 manifest 时使用哪个 OSS 客户端 ## 4. Migration @@ -125,6 +132,11 @@ Redis 后面只在需要性能优化、限流或短期票据缓存时再接。 - result - profile +补充说明: + +- `publish build` 现在会真实上传 `manifest.json` 和 `asset-index.json` 到 OSS +- 如果上传失败,接口会直接报错,不再出现“数据库里已有 release,但 OSS 上没有对象”的假成功 + 并且支持: - quick flow diff --git a/backend/docs/核心流程.md b/backend/docs/核心流程.md index 2619be3..ee0418c 100644 --- a/backend/docs/核心流程.md +++ b/backend/docs/核心流程.md @@ -14,6 +14,11 @@ flowchart LR H --> I["Result / History"] ``` +补充说明: + +- 这条主流程既服务当前小程序,也要服务未来 APP +- 终端差异主要体现在登录方式、设备能力和运行时 UI,不应拆成两套业务流程 + ## 2. 入口解析 入口层先解决: @@ -39,6 +44,10 @@ APP 当前主链是手机号验证码: 2. `POST /auth/login/sms` 3. 返回 `access_token + refresh_token` +说明: + +- APP 是未来更强接入端,后端设计必须预留身体资料、设备绑定、遥测摘要等扩展空间 + ### 3.2 微信小程序 微信小程序当前主链是: @@ -134,6 +143,11 @@ APP 当前主链是手机号验证码: - `launch.business.sessionId` - `launch.business.sessionToken` +补充约束: + +- `launch` 是统一业务启动入口,不应因为 APP / 小程序差异复制两套接口 +- 终端差异通过 `clientType`、`deviceKey`、后续能力声明字段处理 + ### 6.3 客户端应如何使用 客户端进入游戏前,应以返回中的这几项为准: @@ -202,3 +216,13 @@ APP 当前主链是手机号验证码: 不要退回成: `event -> launch -> game` + +也不要走成: + +`mini event -> mini launch -> mini game` + +或: + +`app event -> app launch -> app game` + +业务接口必须保持统一,终端差异只进入上下文,不进入对象模型分叉。 diff --git a/backend/docs/系统架构.md b/backend/docs/系统架构.md index 2b78477..e67fa66 100644 --- a/backend/docs/系统架构.md +++ b/backend/docs/系统架构.md @@ -21,6 +21,11 @@ - 运行时解析复杂地图规则 - 直接下发数据库编辑态对象给客户端 +补充约束: + +- 这套 backend 必须服务未来 APP,不是“小程序专用后端” +- 登录方式可以按终端区分,但业务对象和业务接口不能按端分裂成两套 + ## 2. 分层 ### 2.1 平台层 @@ -35,6 +40,12 @@ 这层是整个平台共用能力。 +它必须同时支撑: + +- APP +- 微信小程序 +- 后续公众号 / H5 / 其他渠道 + ### 2.2 业务层 业务层统一处理: @@ -58,6 +69,12 @@ 这层是“客户端真正进入游戏时要消费的运行配置入口”。 +这里的发布结构应保持终端中立: + +- 不写死为小程序专用结构 +- 不直接依赖某个端的页面实现 +- 允许 APP 和小程序共用同一份 release / manifest + ### 2.4 运行层 运行层统一处理: @@ -126,6 +143,12 @@ - `GET /sessions/{id}` 会返回 `resolvedRelease` - `GET /sessions/{id}/result` 能追溯到当时的 release +补充约束: + +- release / manifest 只描述运行配置,不承载某个端的页面状态 +- 玩家设置、设备能力差异、运行时 UI 编译由客户端自行处理 +- 后端负责“发布可运行配置”,不是“替某个端生成最终运行时 profile” + ## 5. 代码分层 ### 5.1 入口层 @@ -189,6 +212,16 @@ - 驱动地图和玩法 - 产生过程数据和结束摘要 +适用范围: + +- 微信小程序客户端 +- 未来 APP 客户端 + +也就是说: + +- 后端按统一业务模型输出 +- 终端差异放在客户端运行时适配层,不放在后端业务接口层 + ### 6.3 后续网关该怎么接 后面如果接实时网关,建议仍然走: diff --git a/backend/docs/资源对象与目录方案.md b/backend/docs/资源对象与目录方案.md new file mode 100644 index 0000000..708aa6a --- /dev/null +++ b/backend/docs/资源对象与目录方案.md @@ -0,0 +1,589 @@ +# 资源对象与目录方案 + +本文档用于把“地图复用、KML 复用、内容资源复用、配置发布”统一收成一套后端可执行方案。 + +目标: + +- 不再把所有资源都塞进单个 `event/*.json` +- 让地图、KML、内容模板、主题资源都能独立复用 +- 让 `event` 只负责“组合与覆盖”,不拥有底层资源本体 +- 让 `release` 能稳定追溯当时到底用了哪一份地图、哪一份 KML、哪一套资源包 +- 让同一套资源对象既能服务小程序,也能服务未来 APP + +--- + +## 1. 设计结论 + +后端后续不要按“一个活动一个完整资源目录”来设计,而要按“资源对象库 + Event 组合 + Release 固化”来设计。 + +建议统一拆成这 5 类对象: + +1. `Map` +2. `Playfield` +3. `GameMode` +4. `ResourcePack` +5. `Event` + +它们的关系是: + +- `Map`:地图底座 +- `Playfield`:空间对象 / KML / 控制点集 +- `GameMode`:玩法默认规则 +- `ResourcePack`:内容、主题、音频等资源档 +- `Event`:业务活动对象,只做引用与少量覆盖 + +最终客户端吃的仍然不是这些编辑态对象,而是: + +- `Release` +- `manifest.json` + +补充约束: + +- manifest 必须保持终端中立 +- 不要在资源对象层把目录或字段设计成“小程序专用资源包” +- APP 与小程序应共享同一套资源对象和 release 记录 + +--- + +## 2. 为什么必须这么拆 + +你现在遇到的核心问题不是“目录怎么摆”,而是“哪些资源会复用”。 + +当前最典型的复用场景: + +- 同一张地图会被多个活动复用 +- 同一份 KML / 控制点集会被多个活动复用 +- 同一套 H5 内容模板会被多个活动复用 +- 同一套主题和音频资源会被多个活动复用 + +如果继续按 `event -> 自己拥有所有文件` 设计,后面会出现: + +- 地图重复拷贝 +- KML 重复上传 +- 同一资源多个活动版本不一致 +- 一次资源修复需要改很多 event +- 历史 session 无法明确追溯当时使用的是哪一版资源 + +所以正确做法不是“每个 event 一套全量文件”,而是: + +`共享资源对象 -> event 引用 -> release 固化版本` + +--- + +## 3. 五类核心对象 + +## 3.1 Map + +作用: + +- 表示一张可复用地图底座 + +最少应包含: + +- `code` +- `name` +- `status` +- `tiles root` +- `mapmeta` +- 可选边界、缩放、投影信息 + +典型场景: + +- 一个公园底图 +- 一张校园底图 +- 一张城区底图 + +注意: + +- `Map` 不等于某场活动 +- `Map` 是共享资产 + +## 3.2 Playfield + +作用: + +- 表示一份可复用场地对象数据 + +最常见的承载就是: + +- `KML` +- `GeoJSON` +- 控制点集 + +最少应包含: + +- `code` +- `name` +- `kind` +- `sourceType` +- `sourceFile` +- 可选提取元数据 + - 控制点数量 + - 边界范围 + - 是否包含起终点 + +注意: + +- `Playfield` 是共享对象,不属于某个 event 私有 +- 同一份 KML 可以被多个 event 复用 + +## 3.3 GameMode + +作用: + +- 表示一种玩法模式的默认规则对象 + +例如: + +- `classic-sequential` +- `score-o` + +最少应包含: + +- `code` +- `mode` +- `defaults json` + +注意: + +- 它不是最终 event 配置 +- 只是玩法默认值来源之一 + +## 3.4 ResourcePack + +作用: + +- 表示一套可复用资源档 + +当前最适合放进来的有: + +- `audioProfile` +- `contentProfile` +- `themeProfile` + +一个资源包内部可以包含: + +- 内容模板 +- H5 页面 +- 图片 +- 图标 +- 音效 +- 主题色与主题变量 + +## 3.5 Event + +作用: + +- 表示一个业务活动实例 + +它应该只负责: + +- 引用哪个 `Map` +- 引用哪个 `Playfield` +- 引用哪个 `GameMode` +- 引用哪个 `ResourcePack` +- 叠加少量 `Event Overrides` + +不要让 `Event` 负责: + +- 保存整份地图资源 +- 保存 KML 原件 +- 承担所有玩法默认规则 +- 拷贝整套资源包 + +--- + +## 4. 推荐目录结构 + +仓库内建议把“源资源”和“活动源配置”拆开。 + +推荐结构: + +```text +resources/ + maps/ + lxcb-001/ + v2026-03-30/ + mapmeta.json + tiles/ + playfields/ + c01/ + v2026-03-30/ + course.kml + meta.json + resource-packs/ + default-race/ + v2026-03-30/ + content/ + content.html + audio/ + theme/ + +game-modes/ + classic-sequential/ + v1/ + mode.json + score-o/ + v1/ + mode.json + +events/ + evt-demo-001/ + source.json +``` + +说明: + +- `resources/` 放共享对象的源资源 +- `game-modes/` 放玩法默认规则对象 +- `events/` 只放活动级 source config + +当前根目录 [event](D:/dev/cmr-mini/event) 可以继续保留作为过渡区,但后面建议逐步迁到: + +- `events/` +- `resources/` +- `game-modes/` + +--- + +## 5. Event Source 应该怎么写 + +后续 `event source` 不建议继续直接写死地图路径和 KML 路径,而应该引用对象版本。 + +推荐形态: + +```json +{ + "schemaVersion": "1", + "version": "2026.04.01", + "app": { + "id": "evt-demo-001", + "title": "积分赛示例" + }, + "refs": { + "map": { + "code": "lxcb-001", + "version": "v2026-03-30" + }, + "playfield": { + "code": "c01", + "version": "v2026-03-30" + }, + "gameMode": { + "code": "score-o", + "version": "v1" + }, + "resourcePack": { + "code": "default-race", + "version": "v2026-03-30" + } + }, + "overrides": { + "game": { + "session": { + "maxDurationSec": 5400 + }, + "punch": { + "radiusMeters": 5 + } + }, + "playfield": { + "metadata": { + "title": "示例路线", + "code": "demo-001" + } + } + } +} +``` + +这样 `Event` 管的是: + +- 引用 +- 覆盖 + +不是: + +- 全量资源路径 +- 全量运行时配置 + +--- + +## 6. Manifest 生成规则 + +build / publish 时,Go 中间层应做装配: + +`Map + Playfield + GameMode + ResourcePack + Event Overrides -> manifest.json` + +最终生成给客户端的 manifest 可以保持现在的运行结构,例如: + +```json +{ + "schemaVersion": "1", + "releaseId": "rel_xxx", + "version": "2026.04.01", + "app": { + "id": "evt-demo-001", + "title": "积分赛示例" + }, + "map": { + "tiles": "https://.../maps/lxcb-001/v2026-03-30/tiles/", + "mapmeta": "https://.../maps/lxcb-001/v2026-03-30/mapmeta.json" + }, + "playfield": { + "kind": "control-set", + "source": { + "type": "kml", + "url": "https://.../playfields/c01/v2026-03-30/course.kml" + } + }, + "game": { + "mode": "score-o" + }, + "resources": { + "audioProfile": "default", + "contentProfile": "default", + "themeProfile": "default-race" + } +} +``` + +这层才是客户端真正消费的配置。 + +重要边界: + +- 后端负责对象装配和发布 +- 前端继续负责运行时 profile 编译 +- 不把玩家设置和运行时状态写回发布配置 + +再补一条: + +- 不把 APP 专属页面状态或小程序专属页面状态写进 manifest +- 如需终端能力差异,后续通过能力声明或运行时适配层处理 + +--- + +## 7. OSS / CDN 目录建议 + +线上目录不要再继续以: + +- `gotomars/event/classic-sequential.json` +- `gotomars/event/score-o.json` + +这种“玩法文件名”方式长期演进。 + +建议改成版本化结构: + +```text +gotomars/maps/{mapCode}/{version}/... +gotomars/playfields/{playfieldCode}/{version}/... +gotomars/resource-packs/{packCode}/{version}/... +gotomars/game-modes/{modeCode}/{version}/mode.json +gotomars/event-releases/{eventPublicID}/{releasePublicID}/manifest.json +gotomars/event-releases/{eventPublicID}/{releasePublicID}/asset-index.json +``` + +好处: + +- 共享资源独立版本化 +- event release 只固化引用 +- 历史 session 可以回溯 +- 同一个 map / KML 修复时不会污染所有旧 release +- APP 与小程序可共用相同资源版本,不必重复发两套发布目录 + +--- + +## 8. 数据库建模建议 + +推荐按“主表 + version 表”建模。 + +建议对象: + +- `maps` +- `map_versions` +- `playfields` +- `playfield_versions` +- `game_modes` +- `game_mode_versions` +- `resource_packs` +- `resource_pack_versions` +- `events` +- `event_versions` +- `event_releases` + +其中: + +- 主表存稳定元信息 +- version 表存 `jsonb` 内容和具体资源引用 + +例如: + +### `maps` + +- `id` +- `code` +- `name` +- `status` +- `current_version_id` + +### `map_versions` + +- `id` +- `map_id` +- `version_code` +- `content_jsonb` +- `published_asset_root` +- `status` + +### `playfields` + +- `id` +- `code` +- `name` +- `kind` +- `status` +- `current_version_id` + +### `playfield_versions` + +- `id` +- `playfield_id` +- `version_code` +- `source_type` +- `content_jsonb` +- `asset_root` +- `status` + +### `resource_packs` + +- `id` +- `code` +- `name` +- `status` +- `current_version_id` + +### `resource_pack_versions` + +- `id` +- `resource_pack_id` +- `version_code` +- `content_jsonb` +- `asset_root` +- `status` + +### `game_modes` + +- `id` +- `code` +- `name` +- `status` +- `current_version_id` + +### `game_mode_versions` + +- `id` +- `game_mode_id` +- `version_code` +- `content_jsonb` +- `status` + +### `event_versions` + +- `id` +- `event_id` +- `version_code` +- `map_version_id` +- `playfield_version_id` +- `game_mode_version_id` +- `resource_pack_version_id` +- `overrides_jsonb` +- `status` + +核心点: + +- `Event` 不直接指向文件 URL +- `EventVersion` 指向对象版本 +- `Release` 固化当时装配结果 + +--- + +## 9. 后端职责边界 + +后端应强管理: + +- 对象关系 +- 版本关系 +- 引用有效性 +- 发布装配 +- 发布记录 + +后端不应强管理: + +- 每个玩法的所有细字段解释 +- 所有 HUD / 动画 / 实验细项的强结构化列 +- 玩家运行时设置 +- 玩家实时状态 + +同样不应做: + +- 为 APP 和小程序各维护一套资源目录规范 +- 为 APP 和小程序各发布一套不同语义的 event 配置 + +适合继续走 `jsonb` 的内容: + +- `game.sequence.*` +- `game.guidance.*` +- `game.presentation.*` +- `playfield.controlOverrides.*` +- 各类实验性字段 + +--- + +## 10. 推荐实施顺序 + +建议不要一次重构到底,按下面顺序推进: + +1. 先把概念定住 + - `Map` + - `Playfield` + - `GameMode` + - `ResourcePack` + - `Event` + +2. 先做文档和目录规范 + +3. 后端先补对象模型和 version 表草案 + +4. 配置构建器改成“按引用装配” + +5. 发布器改成“版本化共享资源 + event release manifest” + +6. 最后再做正式后台 UI + +--- + +## 11. 当前阶段的务实建议 + +在完全切换到对象化模型前,当前仓库可以先这样过渡: + +- 继续保留 [event](D:/dev/cmr-mini/event) 作为最小样例区 +- 继续保留现有 `import-local -> preview -> publish` +- 但新的 source 设计和目录设计先按本文档收口 + +也就是说: + +- 短期不推翻现有链路 +- 中期把资源引用模型补进来 +- 长期把单文件 `event/*.json` 迁到对象化配置系统 + +--- + +## 12. 一句话结论 + +后端资源管理的正确方向不是“每个活动一堆文件”,而是: + +`共享资源对象库 + Event 引用装配 + Release 固化发布` + +只有这样,地图复用、KML 复用、资源包复用、多活动发布才能长期稳定。 + +并且这套模型必须从一开始就兼顾未来 APP,而不是做成“小程序跑通后再重构”的临时结构。 diff --git a/backend/internal/app/app.go b/backend/internal/app/app.go index b2adba4..8fc1d41 100644 --- a/backend/internal/app/app.go +++ b/backend/internal/app/app.go @@ -5,6 +5,7 @@ import ( "net/http" "cmr-backend/internal/httpapi" + "cmr-backend/internal/platform/assets" "cmr-backend/internal/platform/jwtx" "cmr-backend/internal/platform/wechatmini" "cmr-backend/internal/service" @@ -38,7 +39,8 @@ func New(ctx context.Context, cfg Config) (*App, error) { entryHomeService := service.NewEntryHomeService(store) eventService := service.NewEventService(store) eventPlayService := service.NewEventPlayService(store) - configService := service.NewConfigService(store, cfg.LocalEventDir, cfg.AssetBaseURL) + assetPublisher := assets.NewOSSUtilPublisher(cfg.OSSUtilPath, cfg.OSSUtilConfigFile, cfg.AssetBucketRoot, cfg.AssetPublicBaseURL) + configService := service.NewConfigService(store, cfg.LocalEventDir, cfg.AssetBaseURL, assetPublisher) homeService := service.NewHomeService(store) profileService := service.NewProfileService(store) resultService := service.NewResultService(store) diff --git a/backend/internal/app/config.go b/backend/internal/app/config.go index 0ec5ce7..11581c6 100644 --- a/backend/internal/app/config.go +++ b/backend/internal/app/config.go @@ -24,6 +24,10 @@ type Config struct { WechatMiniDevPrefix string LocalEventDir string AssetBaseURL string + AssetPublicBaseURL string + AssetBucketRoot string + OSSUtilPath string + OSSUtilConfigFile string } func LoadConfigFromEnv() (Config, error) { @@ -44,6 +48,10 @@ func LoadConfigFromEnv() (Config, error) { WechatMiniDevPrefix: getEnv("WECHAT_MINI_DEV_PREFIX", "dev-"), LocalEventDir: getEnv("LOCAL_EVENT_DIR", filepath.Clean("..\\event")), AssetBaseURL: getEnv("ASSET_BASE_URL", "https://oss-mbh5.colormaprun.com/gotomars"), + AssetPublicBaseURL: getEnv("ASSET_PUBLIC_BASE_URL", "https://oss-mbh5.colormaprun.com"), + AssetBucketRoot: getEnv("ASSET_BUCKET_ROOT", "oss://color-map-html"), + OSSUtilPath: getEnv("OSSUTIL_PATH", filepath.Clean("..\\tools\\ossutil.exe")), + OSSUtilConfigFile: getEnv("OSSUTIL_CONFIG_FILE", filepath.Join(mustUserHomeDir(), ".ossutilconfig")), } if cfg.DatabaseURL == "" { @@ -71,3 +79,11 @@ func getDurationEnv(key string, fallback time.Duration) time.Duration { } return fallback } + +func mustUserHomeDir() string { + home, err := os.UserHomeDir() + if err != nil { + return "." + } + return home +} diff --git a/backend/internal/platform/assets/publisher.go b/backend/internal/platform/assets/publisher.go new file mode 100644 index 0000000..1c551ec --- /dev/null +++ b/backend/internal/platform/assets/publisher.go @@ -0,0 +1,96 @@ +package assets + +import ( + "context" + "fmt" + "os" + "os/exec" + "path/filepath" + "strings" +) + +type OSSUtilPublisher struct { + ossutilPath string + configFile string + bucketRoot string + publicBaseURL string +} + +func NewOSSUtilPublisher(ossutilPath, configFile, bucketRoot, publicBaseURL string) *OSSUtilPublisher { + return &OSSUtilPublisher{ + ossutilPath: strings.TrimSpace(ossutilPath), + configFile: strings.TrimSpace(configFile), + bucketRoot: strings.TrimRight(strings.TrimSpace(bucketRoot), "/"), + publicBaseURL: strings.TrimRight(strings.TrimSpace(publicBaseURL), "/"), + } +} + +func (p *OSSUtilPublisher) Enabled() bool { + return p != nil && + p.ossutilPath != "" && + p.configFile != "" && + p.bucketRoot != "" && + p.publicBaseURL != "" +} + +func (p *OSSUtilPublisher) UploadJSON(ctx context.Context, publicURL string, payload []byte) error { + if !p.Enabled() { + return fmt.Errorf("asset publisher is not configured") + } + if len(payload) == 0 { + return fmt.Errorf("payload is empty") + } + + objectKey, err := p.objectKeyFromPublicURL(publicURL) + if err != nil { + return err + } + + if _, err := os.Stat(p.ossutilPath); err != nil { + return fmt.Errorf("ossutil not found: %w", err) + } + if _, err := os.Stat(p.configFile); err != nil { + return fmt.Errorf("ossutil config not found: %w", err) + } + + tmpFile, err := os.CreateTemp("", "cmr-manifest-*.json") + if err != nil { + return fmt.Errorf("create temp file: %w", err) + } + tmpPath := tmpFile.Name() + defer os.Remove(tmpPath) + + if _, err := tmpFile.Write(payload); err != nil { + tmpFile.Close() + return fmt.Errorf("write temp file: %w", err) + } + if err := tmpFile.Close(); err != nil { + return fmt.Errorf("close temp file: %w", err) + } + + target := p.bucketRoot + "/" + objectKey + cmd := exec.CommandContext(ctx, p.ossutilPath, "cp", "-f", tmpPath, target, "--config-file", p.configFile) + output, err := cmd.CombinedOutput() + if err != nil { + return fmt.Errorf("upload object %s failed: %w: %s", objectKey, err, strings.TrimSpace(string(output))) + } + + return nil +} + +func (p *OSSUtilPublisher) objectKeyFromPublicURL(publicURL string) (string, error) { + publicURL = strings.TrimSpace(publicURL) + if publicURL == "" { + return "", fmt.Errorf("public url is required") + } + if !strings.HasPrefix(publicURL, p.publicBaseURL+"/") { + return "", fmt.Errorf("public url %s does not match public base %s", publicURL, p.publicBaseURL) + } + relative := strings.TrimPrefix(publicURL, p.publicBaseURL+"/") + relative = strings.ReplaceAll(relative, "\\", "/") + relative = strings.TrimLeft(relative, "/") + if relative == "" { + return "", fmt.Errorf("public url %s resolved to empty object key", publicURL) + } + return filepath.ToSlash(relative), nil +} diff --git a/backend/internal/service/config_service.go b/backend/internal/service/config_service.go index 248e2a5..36f0701 100644 --- a/backend/internal/service/config_service.go +++ b/backend/internal/service/config_service.go @@ -11,6 +11,7 @@ import ( "strings" "cmr-backend/internal/apperr" + "cmr-backend/internal/platform/assets" "cmr-backend/internal/platform/security" "cmr-backend/internal/store/postgres" ) @@ -19,6 +20,7 @@ type ConfigService struct { store *postgres.Store localEventDir string assetBaseURL string + publisher *assets.OSSUtilPublisher } type ConfigPipelineSummary struct { @@ -76,11 +78,12 @@ type PublishBuildInput struct { BuildID string `json:"buildId"` } -func NewConfigService(store *postgres.Store, localEventDir, assetBaseURL string) *ConfigService { +func NewConfigService(store *postgres.Store, localEventDir, assetBaseURL string, publisher *assets.OSSUtilPublisher) *ConfigService { return &ConfigService{ store: store, localEventDir: localEventDir, assetBaseURL: strings.TrimRight(assetBaseURL, "/"), + publisher: publisher, } } @@ -323,9 +326,20 @@ func (s *ConfigService) PublishBuild(ctx context.Context, input PublishBuildInpu configLabel := deriveConfigLabel(event, manifest, releaseNo) manifestURL := fmt.Sprintf("%s/event/releases/%s/%s/manifest.json", s.assetBaseURL, event.PublicID, releasePublicID) + assetIndexURL := fmt.Sprintf("%s/event/releases/%s/%s/asset-index.json", s.assetBaseURL, event.PublicID, releasePublicID) checksum := security.HashText(buildRecord.ManifestJSON) routeCode := deriveRouteCode(manifest) + if s.publisher == nil || !s.publisher.Enabled() { + return nil, apperr.New(http.StatusInternalServerError, "asset_publish_unavailable", "asset publisher is not configured") + } + if err := s.publisher.UploadJSON(ctx, manifestURL, []byte(buildRecord.ManifestJSON)); err != nil { + return nil, apperr.New(http.StatusInternalServerError, "asset_publish_failed", "failed to upload manifest: "+err.Error()) + } + if err := s.publisher.UploadJSON(ctx, assetIndexURL, []byte(buildRecord.AssetIndexJSON)); err != nil { + return nil, apperr.New(http.StatusInternalServerError, "asset_publish_failed", "failed to upload asset index: "+err.Error()) + } + tx, err := s.store.Begin(ctx) if err != nil { return nil, err @@ -348,7 +362,7 @@ func (s *ConfigService) PublishBuild(ctx context.Context, input PublishBuildInpu return nil, err } - if err := s.store.ReplaceEventReleaseAssets(ctx, tx, releaseRecord.ID, s.mapBuildAssetsToReleaseAssets(releaseRecord.ID, manifestURL, &checksum, assetIndex)); err != nil { + if err := s.store.ReplaceEventReleaseAssets(ctx, tx, releaseRecord.ID, s.mapBuildAssetsToReleaseAssets(releaseRecord.ID, manifestURL, assetIndexURL, &checksum, assetIndex)); err != nil { return nil, err } @@ -642,7 +656,7 @@ func deriveRouteCode(manifest map[string]any) *string { return nil } -func (s *ConfigService) mapBuildAssetsToReleaseAssets(eventReleaseID, manifestURL string, checksum *string, assetIndex []map[string]any) []postgres.UpsertEventReleaseAssetParams { +func (s *ConfigService) mapBuildAssetsToReleaseAssets(eventReleaseID, manifestURL, assetIndexURL string, checksum *string, assetIndex []map[string]any) []postgres.UpsertEventReleaseAssetParams { assets := []postgres.UpsertEventReleaseAssetParams{ { EventReleaseID: eventReleaseID, @@ -652,6 +666,13 @@ func (s *ConfigService) mapBuildAssetsToReleaseAssets(eventReleaseID, manifestUR Checksum: checksum, Meta: map[string]any{"source": "published-build"}, }, + { + EventReleaseID: eventReleaseID, + AssetType: "other", + AssetKey: "asset-index", + AssetURL: assetIndexURL, + Meta: map[string]any{"source": "published-build"}, + }, } for _, asset := range assetIndex { diff --git a/backend/scripts/start-dev.ps1 b/backend/scripts/start-dev.ps1 index b2c5a62..a2643ff 100644 --- a/backend/scripts/start-dev.ps1 +++ b/backend/scripts/start-dev.ps1 @@ -12,11 +12,31 @@ $env:DATABASE_URL = if ($env:DATABASE_URL) { $env:DATABASE_URL } else { "postgre $env:JWT_ACCESS_SECRET = if ($env:JWT_ACCESS_SECRET) { $env:JWT_ACCESS_SECRET } else { "change-me-in-production" } $env:AUTH_SMS_PROVIDER = if ($env:AUTH_SMS_PROVIDER) { $env:AUTH_SMS_PROVIDER } else { "console" } $env:WECHAT_MINI_DEV_PREFIX = if ($env:WECHAT_MINI_DEV_PREFIX) { $env:WECHAT_MINI_DEV_PREFIX } else { "dev-" } +$env:LOCAL_EVENT_DIR = if ($env:LOCAL_EVENT_DIR) { $env:LOCAL_EVENT_DIR } else { "D:\dev\cmr-mini\event" } +$env:ASSET_BASE_URL = if ($env:ASSET_BASE_URL) { $env:ASSET_BASE_URL } else { "https://oss-mbh5.colormaprun.com/gotomars" } +$env:ASSET_PUBLIC_BASE_URL = if ($env:ASSET_PUBLIC_BASE_URL) { $env:ASSET_PUBLIC_BASE_URL } else { "https://oss-mbh5.colormaprun.com" } +$env:ASSET_BUCKET_ROOT = if ($env:ASSET_BUCKET_ROOT) { $env:ASSET_BUCKET_ROOT } else { "oss://color-map-html" } +$env:OSSUTIL_PATH = if ($env:OSSUTIL_PATH) { $env:OSSUTIL_PATH } else { "D:\dev\cmr-mini\tools\ossutil.exe" } +$env:OSSUTIL_CONFIG_FILE = if ($env:OSSUTIL_CONFIG_FILE) { $env:OSSUTIL_CONFIG_FILE } else { (Join-Path $HOME ".ossutilconfig") } + +if (-not (Test-Path $env:LOCAL_EVENT_DIR)) { + throw ("LOCAL_EVENT_DIR not found: " + $env:LOCAL_EVENT_DIR) +} + +if (-not (Test-Path $env:OSSUTIL_PATH)) { + Write-Warning ("OSSUTIL_PATH not found: " + $env:OSSUTIL_PATH) +} + +if (-not (Test-Path $env:OSSUTIL_CONFIG_FILE)) { + Write-Warning ("OSSUTIL_CONFIG_FILE not found: " + $env:OSSUTIL_CONFIG_FILE) +} Write-Host "CMR backend dev server" -ForegroundColor Cyan Write-Host ("APP_ENV=" + $env:APP_ENV) Write-Host ("HTTP_ADDR=" + $env:HTTP_ADDR) Write-Host ("DATABASE_URL=" + $env:DATABASE_URL) +Write-Host ("LOCAL_EVENT_DIR=" + $env:LOCAL_EVENT_DIR) +Write-Host ("ASSET_BASE_URL=" + $env:ASSET_BASE_URL) Write-Host "" Write-Host "Workbench:" -ForegroundColor Yellow $workbenchAddr = $env:HTTP_ADDR diff --git a/backend/start-backend.ps1 b/backend/start-backend.ps1 new file mode 100644 index 0000000..4a2fd36 --- /dev/null +++ b/backend/start-backend.ps1 @@ -0,0 +1,13 @@ +Set-StrictMode -Version Latest +$ErrorActionPreference = "Stop" + +$backendDir = Split-Path -Parent $MyInvocation.MyCommand.Path +$scriptPath = Join-Path $backendDir "scripts\start-dev.ps1" + +if (-not (Test-Path $scriptPath)) { + throw ("Backend start script not found: " + $scriptPath) +} + +Set-Location $backendDir + +powershell -ExecutionPolicy Bypass -File $scriptPath diff --git a/doc/backend/业务后端数据库初版方案.md b/doc/backend/业务后端数据库初版方案.md new file mode 100644 index 0000000..dc33a70 --- /dev/null +++ b/doc/backend/业务后端数据库初版方案.md @@ -0,0 +1,696 @@ +# 业务后端数据库初版方案 + +## 1. 目标 + +本文档定义本项目业务后端第一版数据库方案。 + +第一版目标不是一次性覆盖整个平台所有能力,而是先稳定支撑以下范围: + +- 微信小程序登录与用户身份 +- 用户身体资料 +- 多租户与俱乐部基础隔离 +- 首页卡片、赛事、地图、Event 查询 +- 配置对象的版本化管理与发布 +- `launch` 启动与 `session_token` +- 小程序业务层与配置驱动游戏层对接 + +明确不纳入第一版数据库的能力: + +- 支付与分账 +- UGC 审核流 +- 订单退款 +- 成绩回放明细 +- GPS / 心率明细归档 +- 复杂运营报表 + +这些能力建议放到后续 migration。 + +## 2. 核心原则 + +### 2.1 业务数据与配置数据分层 + +数据库里同时存在两类对象: + +- 业务状态对象 +- 配置发布对象 + +两者要分层,不要揉成一个“超级事件表”。 + +### 2.2 运行态配置仍然走发布产物 + +数据库管理的是编辑态与发布关系,客户端运行时最终仍然消费静态发布结果,例如: + +- `manifest_url` +- `manifest_checksum_sha256` +- 地图资源路径 +- Event 发布配置 + +不要让客户端在运行时直接拼数据库对象。 + +### 2.3 游戏规则不进业务表细字段 + +业务后端负责: + +- 用户 +- 赛事 +- 报名 +- 发布 +- 启动 + +业务后端不应该成为所有玩法字段的解释器。玩法细节优先放在版本化 `jsonb` 内容中。 + +### 2.4 所有对外 ID 使用 public_id + +数据库内部主键统一使用 `uuid`。 + +对客户端暴露的对象使用稳定 `public_id`: + +- `user_public_id` +- `competition_public_id` +- `map_public_id` +- `event_public_id` +- `release_public_id` +- `session_public_id` + +原因: + +- 内外 ID 解耦 +- 便于迁移 +- 便于白标和多端统一 +- 避免顺序 ID 暴露内部增长信息 + +### 2.5 token 只存 hash,不存明文 + +以下内容不应明文入库: + +- 短信验证码 +- refresh token +- session token + +数据库只保存 hash。 + +## 3. 第一版建议模块 + +第一版数据库建议拆成 6 组: + +1. 租户与组织 +2. 用户与登录 +3. 配置对象与发布 +4. 赛事业务对象 +5. 页面与卡片 +6. 启动与 session + +## 4. 表清单 + +### 4.1 租户与组织 + +#### `tenants` + +平台租户。 + +建议字段: + +- `id` +- `tenant_code` +- `name` +- `status` +- `theme_jsonb` +- `settings_jsonb` +- `created_at` +- `updated_at` + +说明: + +- `theme_jsonb` 存白标主题 +- `settings_jsonb` 存租户级开关 + +#### `clubs` + +租户下的俱乐部或品牌实体。 + +建议字段: + +- `id` +- `tenant_id` +- `club_code` +- `name` +- `status` +- `profile_jsonb` +- `created_at` +- `updated_at` + +说明: + +- 第一版建议 club 从属于 tenant +- 不建议一开始做过深的组织树 + +### 4.2 用户与登录 + +#### `app_users` + +平台用户主表。 + +建议字段: + +- `id` +- `user_public_id` +- `default_tenant_id` +- `status` +- `nickname` +- `avatar_url` +- `last_login_at` +- `created_at` +- `updated_at` + +#### `login_identities` + +登录身份绑定表。 + +建议字段: + +- `id` +- `user_id` +- `identity_type` +- `provider` +- `provider_subject` +- `country_code` +- `mobile` +- `status` +- `profile_jsonb` +- `created_at` +- `updated_at` + +身份示例: + +- 手机号 +- 微信 `openid` +- 微信 `unionid` + +#### `client_devices` + +客户端设备标识记录。 + +建议字段: + +- `id` +- `device_key` +- `platform` +- `first_seen_at` +- `last_seen_at` +- `meta_jsonb` + +说明: + +- `device_key` 对应前端 `device_id` + +#### `auth_sms_codes` + +短信验证码发送与校验记录。 + +建议字段: + +- `id` +- `scene` +- `country_code` +- `mobile` +- `client_type` +- `device_key` +- `code_hash` +- `provider_payload_jsonb` +- `expires_at` +- `cooldown_until` +- `consumed_at` +- `created_at` + +#### `auth_refresh_tokens` + +刷新 token 持久化表。 + +建议字段: + +- `id` +- `user_id` +- `client_type` +- `device_key` +- `token_hash` +- `issued_at` +- `expires_at` +- `revoked_at` +- `replaced_by_token_id` + +#### `user_body_profiles` + +用户当前身体档案。 + +建议字段: + +- `id` +- `user_id` +- `status` +- `completed_at` +- `current_version_id` +- `created_at` +- `updated_at` + +#### `user_body_profile_versions` + +身体档案历史版本。 + +建议字段: + +- `id` +- `profile_id` +- `version_no` +- `gender` +- `birth_date` +- `height_cm` +- `weight_kg` +- `resting_heart_rate_bpm` +- `max_heart_rate_bpm` +- `created_at` + +### 4.3 配置对象与发布 + +这一组直接对应你现有的配置驱动架构。 + +#### `maps` / `map_versions` + +地图对象及版本。 + +主表管理: + +- `map_public_id` +- `slug` +- `name` +- `status` +- `tenant_id` +- `current_version_id` + +版本表管理: + +- `version_no` +- `content_jsonb` +- `created_at` + +#### `playfields` / `playfield_versions` + +路线、点位、场地定义。 + +#### `game_modes` / `game_mode_versions` + +玩法模式配置,例如: + +- `classic-sequential` +- `score-o` + +#### `resource_packs` / `resource_pack_versions` + +资源包,例如: + +- 音效 +- 素材包 +- UI 资源集 + +#### `events` / `event_versions` + +Event 本身与版本。 + +建议: + +- `events` 管对象身份 +- `event_versions` 管编辑态装配结果 + +`event_versions` 推荐显式引用: + +- `map_version_id` +- `playfield_version_id` +- `game_mode_version_id` +- `resource_pack_version_id` + +同时保留: + +- `content_jsonb` + +这样既有强关系,又保留灵活字段。 + +#### `event_releases` + +发布记录表。 + +建议字段: + +- `id` +- `release_public_id` +- `event_id` +- `event_version_id` +- `release_no` +- `manifest_url` +- `manifest_checksum_sha256` +- `status` +- `published_by_user_id` +- `published_at` +- `payload_jsonb` + +说明: + +- 客户端主要读这张表产出的 URL 与校验值 +- `events.current_release_id` 可指向当前对外生效版本 + +### 4.4 赛事业务对象 + +#### `competitions` + +赛事主表。 + +建议字段: + +- `id` +- `competition_public_id` +- `tenant_id` +- `club_id` +- `slug` +- `display_name` +- `status` +- `registration_enabled` +- `leaderboard_enabled` +- `realtime_board_enabled` +- `competition_start_at` +- `competition_end_at` +- `content_jsonb` +- `created_at` +- `updated_at` + +#### `competition_events` + +赛事与 Event 的关联表。 + +建议字段: + +- `id` +- `competition_id` +- `event_id` +- `event_release_id` +- `is_default` +- `sort_order` +- `relation_status` +- `created_at` + +说明: + +- 支持赛事绑定多个 Event +- 支持按赛事锁定某个 release + +#### `registrations` + +报名记录。 + +建议字段: + +- `id` +- `registration_public_id` +- `competition_id` +- `user_id` +- `group_id` +- `status` +- `form_payload_jsonb` +- `approved_at` +- `cancelled_at` +- `created_at` +- `updated_at` + +说明: + +- 第一版先不强行拆复杂参赛人结构 +- `form_payload_jsonb` 足够承接早期变化 + +### 4.5 页面与卡片 + +#### `page_configs` / `page_config_versions` + +H5 / 白标页面配置。 + +主表建议字段: + +- `id` +- `tenant_id` +- `club_id` +- `page_code` +- `name` +- `status` +- `current_version_id` +- `created_at` +- `updated_at` + +版本表建议字段: + +- `id` +- `page_config_id` +- `version_no` +- `dsl_jsonb` +- `theme_jsonb` +- `feature_flags_jsonb` +- `status` +- `created_at` + +#### `cards` + +首页卡片与运营入口。 + +建议字段: + +- `id` +- `card_public_id` +- `tenant_id` +- `club_id` +- `card_type` +- `display_name` +- `competition_id` +- `event_id` +- `map_id` +- `page_config_id` +- `html_url` +- `cover_url` +- `display_slot` +- `display_priority` +- `status` +- `starts_at` +- `ends_at` +- `created_at` +- `updated_at` + +说明: + +- 这张表直接支撑 `/cards` +- 允许卡片指向赛事、页面或其他目标 + +### 4.6 启动与 session + +#### `game_sessions` + +游戏启动记录。 + +建议字段: + +- `id` +- `session_public_id` +- `tenant_id` +- `user_id` +- `competition_id` +- `registration_id` +- `event_id` +- `event_release_id` +- `launch_request_id` +- `participant_public_id` +- `device_key` +- `client_type` +- `route_code` +- `status` +- `session_token_hash` +- `session_token_expires_at` +- `realtime_endpoint` +- `realtime_token_hash` +- `launched_at` +- `started_at` +- `ended_at` +- `created_at` +- `updated_at` + +说明: + +- 第一版闭环到 `launch` 即可 +- `session_token` 用于后续 session 相关接口开放后继续扩展 +- `launch_request_id` 需要唯一,支撑幂等 + +## 5. 当前 API 到表的映射 + +### `POST /auth/sms/send` + +写: + +- `auth_sms_codes` + +### `POST /auth/login/sms` + +读写: + +- `auth_sms_codes` +- `app_users` +- `login_identities` +- `user_body_profiles` +- `user_body_profile_versions` +- `auth_refresh_tokens` + +### `POST /auth/login/wechat` + +读写: + +- `app_users` +- `login_identities` +- `user_body_profiles` +- `user_body_profile_versions` +- `auth_refresh_tokens` + +### `POST /auth/refresh` + +读写: + +- `auth_refresh_tokens` + +### `PUT /me/body-profile` + +读写: + +- `user_body_profiles` +- `user_body_profile_versions` + +### `GET /cards` + +读: + +- `cards` +- 可选补充 `competitions` + +### `GET /competitions/{competition_id}` + +读: + +- `competitions` +- `competition_events` +- `events` +- `event_releases` +- `registrations` + +### `GET /events/{event_id}` / `GET /competitions/{competition_id}/events/{event_id}` + +读: + +- `events` +- `event_releases` +- `maps` +- `competitions` +- `registrations` + +### `POST /competitions/{competition_id}/registrations` + +写: + +- `registrations` + +### `POST /events/{event_id}/launch` + +读写: + +- `events` +- `event_releases` +- `registrations` 可选 +- `game_sessions` + +## 6. 第一版不建议做复杂拆分的地方 + +以下字段第一版优先用 `jsonb`,不要先做一堆子表: + +- 赛事详情扩展内容 +- 报名附加表单 +- 页面 DSL +- theme 配置 +- feature flags +- Event 覆盖项 +- 配置对象的实验字段 + +原因很简单: + +- 你现在业务和玩法都在快速变化 +- 先保留灵活性比过度范式化更重要 + +## 7. 第一版不建议进入数据库的内容 + +第一版不建议落库: + +- 实时网关运行态内存结构 +- GPS 点逐秒明细 +- 心率逐秒明细 +- WebGL 渲染状态 +- 设备桥接瞬时事件 + +这些应该仍然留在: + +- `realtime-gateway` +- 对象存储 +- 后续归档服务 + +不应直接压进业务主库。 + +## 8. 推荐 migration 顺序 + +建议按下面顺序建表: + +1. 租户与组织 +2. 用户与登录 +3. 配置对象与版本表 +4. 发布表 +5. 赛事与关联 +6. 页面与卡片 +7. session + +这样依赖关系最清晰。 + +## 9. 第二版可新增的模块 + +建议后续 migration 再补: + +- `orders` +- `payment_transactions` +- `refunds` +- `ugc_assets` +- `ugc_posts` +- `ugc_reviews` +- `session_uploads` +- `session_results` +- `gps_tracks` +- `heart_rate_streams` +- `channel_entries` +- `campaigns` + +## 10. 当前最适合你的起步方式 + +如果你现在准备开始做后端,我建议不要先写所有 API,而是按这个顺序开工: + +1. 先建数据库与 migration +2. 先写用户、赛事、Event、launch 这 4 个核心域 +3. 先让小程序跑通登录 -> 看赛事 -> launch -> 进入游戏 +4. 再补报名 +5. 再补页面配置、卡片、俱乐部首页 +6. 支付和 UGC 放到后续版本 + +## 11. 一句话总结 + +第一版数据库应该同时支撑两件事: + +- 业务闭环 +- 配置发布 + +但不能把它们混成一套随意增长的表结构。 + +正确方向是: + +> PostgreSQL 存业务状态 + 版本化配置对象,Go API 负责查询与发布编排,客户端继续消费发布后的运行态配置。 diff --git a/doc/debug/模拟器多通道联调最小方案.md b/doc/debug/模拟器多通道联调最小方案.md index 512ccaf..d476e4e 100644 --- a/doc/debug/模拟器多通道联调最小方案.md +++ b/doc/debug/模拟器多通道联调最小方案.md @@ -95,6 +95,8 @@ 也就是说,一个模拟器页面实例默认对应一个通道。 +当前这个输入已经提升到工作台顶部,作为全局调试参数,不再挂在“定位发送”分组下面。 + ## 小程序侧 调试面板提供一个统一输入: diff --git a/doc/debug/模拟器控制面板重构方案.md b/doc/debug/模拟器控制面板重构方案.md index cb7bc27..e5ff318 100644 --- a/doc/debug/模拟器控制面板重构方案.md +++ b/doc/debug/模拟器控制面板重构方案.md @@ -28,10 +28,10 @@ 新版面板采用工作台布局: -- 顶部:连接状态条 +- 顶部:连接状态条与全局模拟通道号 - 左侧:控制区 - 中间:地图与路径预览 -- 右侧:状态摘要与快捷观察 +- 右侧:运行摘要、当前位置、最近事件 - 右下:调试日志浮层 ## 功能分区 @@ -44,6 +44,7 @@ - 心率模拟连接状态 - 调试日志连接状态 - 一键连接开发调试源 +- 全局模拟通道号 ### 2. 左侧控制区 @@ -65,13 +66,9 @@ 包含: -- 当前经纬度 -- 当前航向 -- 当前路径点数 -- 最近发送状态 -- 最近心率发送状态 -- 资源加载摘要 -- 网关桥接摘要 +- 运行摘要 +- 当前位置 +- 最近事件 ### 5. 日志区 @@ -80,6 +77,8 @@ - 默认悬浮在地图右下 - 可清空 - 面积更大 +- 可缩到一角 +- 支持按 scope 过滤 - 便于边看地图边看日志 ## 实施顺序 diff --git a/doc/debug/模拟器调试日志方案.md b/doc/debug/模拟器调试日志方案.md index 5025faf..ea69bf3 100644 --- a/doc/debug/模拟器调试日志方案.md +++ b/doc/debug/模拟器调试日志方案.md @@ -103,8 +103,10 @@ 最小能力: - websocket 接收 `debug-log` -- UI 新增“调试日志”区域 +- UI 使用右下角可缩放浮层承接“调试日志” - 仅显示 `debug-log` +- 支持按 `scope` 过滤 +- 按当前 `channelId` 过滤显示 - 保留最近若干条,避免无限增长 ## 后续扩展 diff --git a/doc/debug/调试文档索引.md b/doc/debug/调试文档索引.md index 0fa0edb..1f5b561 100644 --- a/doc/debug/调试文档索引.md +++ b/doc/debug/调试文档索引.md @@ -24,12 +24,12 @@ ## 推荐阅读顺序 -1. [platform-capability-notes.md](/D:/dev/cmr-mini/doc/debug/平台能力说明.md) -2. [mock-simulator-control-panel-proposal.md](/D:/dev/cmr-mini/doc/debug/模拟器控制面板重构方案.md) -3. [sensor-current-summary.md](/D:/dev/cmr-mini/doc/debug/传感器现状总结.md) -4. [mock-simulator-debug-log-proposal.md](/D:/dev/cmr-mini/doc/debug/模拟器调试日志方案.md) -5. [multi-channel-simulator-minimal-plan.md](/D:/dev/cmr-mini/doc/debug/模拟器多通道联调最小方案.md) -6. [compass-debugging-notes.md](/D:/dev/cmr-mini/doc/debug/罗盘排障记录.md) +1. [平台能力说明](/D:/dev/cmr-mini/doc/debug/平台能力说明.md) +2. [模拟器控制面板重构方案](/D:/dev/cmr-mini/doc/debug/模拟器控制面板重构方案.md) +3. [传感器现状总结](/D:/dev/cmr-mini/doc/debug/传感器现状总结.md) +4. [模拟器调试日志方案](/D:/dev/cmr-mini/doc/debug/模拟器调试日志方案.md) +5. [模拟器多通道联调最小方案](/D:/dev/cmr-mini/doc/debug/模拟器多通道联调最小方案.md) +6. [罗盘排障记录](/D:/dev/cmr-mini/doc/debug/罗盘排障记录.md) ## 使用建议 diff --git a/f2b.md b/f2b.md new file mode 100644 index 0000000..b66bd28 --- /dev/null +++ b/f2b.md @@ -0,0 +1,233 @@ +# F2B 协作清单 + +本文档由前端维护,用于记录: + +- 前端当前联调状态 +- 需要后端确认或配合的事项 +- 已明确的接口契约与运行时语义 + +约定: + +- `f2b.md` 由前端维护 +- `b2f.md` 由后端维护 +- 双方只维护自己的文件 +- 边界不清的事项,先写入文档,再由你确认 + +--- + +## 1. 当前前端联调状态 + +当前小程序侧已经接通: + +- 微信小程序登录 +- 首页聚合 +- 活动页 `play` +- `launch -> 地图页` +- `session start` +- `session finish` +- `session result` +- 故障恢复提示与恢复继续 +- 故障恢复放弃 + +当前已确认不再是 backend 阻塞项: + +- `evt_demo_001` 的 release manifest 现在可正常加载 +- 地图页已经能正常拉起 +- 模拟定位 / 模拟日志的通道与连接问题,当前主要在小程序与模拟器侧处理 + +--- + +## 2. 前端已按当前契约实现 + +### 2.1 launch 契约 + +前端当前按以下字段消费: + +- `launch.resolvedRelease.releaseId` +- `launch.resolvedRelease.manifestUrl` +- `launch.resolvedRelease.manifestChecksumSha256` +- `launch.config.configUrl` +- `launch.config.configLabel` +- `launch.config.releaseId` +- `launch.config.routeCode` +- `launch.business.sessionId` +- `launch.business.sessionToken` +- `launch.business.sessionTokenExpiresAt` + +当前前端约束: + +- 正式联调只认后端 `launch` 下发的 release / manifest +- 不再回退到本地 `event/*.json` 样例路径 +- 如果 `manifestUrl` 无效,会直接在地图页报错 + +### 2.2 session 生命周期 + +前端当前已接: + +- 进入运行态后自动上报 `session start` +- 正常结束时上报 `finish(finished)` +- 超时结束时上报 `finish(failed)` +- 主动退出时上报 `finish(cancelled)` + +### 2.3 故障恢复 + +前端当前已接: + +- 检测到未正常结束对局时,弹出“继续恢复 / 放弃” +- 点击“继续恢复”时恢复本地运行时快照 +- 点击“放弃”时: + - 清理本地恢复快照 + - 并使用**恢复快照中的旧 sessionId/sessionToken** 向后端补报 `finish(cancelled)` + +当前实现口径: + +- 放弃恢复不会阻塞用户 +- 即使 backend 上报失败,前端也会继续放弃本地恢复 +- 失败时前端会明确提示“后端取消上报失败” + +--- + +## 3. 需要 backend 当前确认 / 配合 + +## 3.1 固定 session 三态语义 + +请 backend 明确并固定: + +- `finished` +- `failed` +- `cancelled` + +前端当前使用口径: + +- 正常打终点完成 -> `finished` +- 超时结束 -> `failed` +- 主动退出 / 放弃恢复 -> `cancelled` + +如果 backend 计划使用其他语义,请先在 `b2f.md` 明确,不要直接改单接口行为。 + +## 3.2 确认“放弃恢复 -> cancelled”是官方语义 + +前端现在已经启用: + +- 点击“放弃恢复”时,调用 `POST /sessions/{id}/finish` +- 参数:`status=cancelled` + +请 backend 确认: + +1. 这是否就是官方的“放弃恢复 / 放弃本局”语义 +2. 旧 `sessionToken` 是否允许在恢复放弃场景继续调用 `finish(cancelled)` +3. `cancelled` 后是否保证不再作为 `ongoingSession` 出现在: + - `/me/entry-home` + - `/events/{eventPublicID}/play` + +## 3.3 保证 start / finish 幂等 + +请 backend 明确: + +- `start` 重复调用是否安全 +- `finish` 重复调用是否安全 + +前端建议口径: + +- `start`:如果 session 已在运行态,返回成功和当前 session +- `finish`:如果 session 已进入终态,返回成功和当前 session/result + +原因: + +- 联调重试 +- 页面重进 +- 故障恢复补报 +- 用户重复点击 + +这些都很容易触发重复请求。 + +## 3.4 回归确认 ongoing session 口径一致 + +请 backend 回归确认以下接口对 ongoing session 的口径一致: + +- `/me/entry-home` +- `/events/{eventPublicID}/play` +- `/sessions/{sessionPublicID}/result` + +重点确认: + +1. `cancelled` 后不再继续出现在 ongoing 入口 +2. `failed` 后不再继续出现在 ongoing 入口 +3. `finished` 后结果摘要和首页摘要保持一致 + +## 3.5 保持 launch 返回契约稳定 + +当前前端已经按既定结构接好 `launch`。 + +请 backend: + +- 保持字段名稳定 +- 如需调整字段名或层级,先在 `b2f.md` 里给出变更说明 +- 尤其不要在未通知前端的情况下,改变: + - `resolvedRelease.manifestUrl` + - `business.sessionId` + - `business.sessionToken` + +--- + +## 4. P1 后续建议 + +## 4.1 用户身体数据接口 + +前端已经有 telemetry profile 合并能力。 + +backend 后续建议提供: + +- 当前用户 body profile 查询接口 + +建议至少包含: + +- `birthDate` 或 `heartRateAge` +- `weightKg` +- `restingHeartRateBpm` +- `maxHeartRateBpm`(可选) + +## 4.2 result 摘要字段容错 + +前端当前 finish 可能上报: + +- `finalDurationSec` +- `finalScore` +- `completedControls` +- `totalControls` +- `distanceMeters` +- `averageSpeedKmh` +- `maxHeartRateBpm` + +请 backend: + +- 对可选字段做空值容忍 +- 不要因某个非关键字段缺失导致整局 finish 失败 + +## 4.3 workbench 增加恢复相关调试项 + +建议 backend workbench 后续增加: + +- 将 session 标记为 `cancelled` +- 查询当前用户 ongoing session +- 查看最近一局状态流转 + +这样更利于故障恢复联调。 + +--- + +## 5. 当前前端需要 backend 反馈的最小集合 + +backend 现在只要先在 `b2f.md` 回 3 件事,前后端主链就能更稳: + +1. `finished / failed / cancelled` 三态最终语义 +2. 放弃恢复时 `finish(cancelled)` 是否是正式方案 +3. `start / finish` 是否按幂等处理 + +--- + +## 6. 一句话结论 + +当前前端最需要 backend 配合的,不是更多新接口,而是: + +> 先把 session 生命周期语义、放弃恢复语义和 ongoing session 口径完全定稳。 diff --git a/miniprogram/app.json b/miniprogram/app.json index c265205..98c33d3 100644 --- a/miniprogram/app.json +++ b/miniprogram/app.json @@ -1,9 +1,13 @@ { "pages": [ + "pages/index/index", + "pages/login/login", + "pages/home/home", + "pages/event/event", + "pages/result/result", "pages/map/map", "pages/experience-webview/experience-webview", "pages/webview-test/webview-test", - "pages/index/index", "pages/logs/logs" ], "window": { diff --git a/miniprogram/app.ts b/miniprogram/app.ts index e5d38e7..5e6974f 100644 --- a/miniprogram/app.ts +++ b/miniprogram/app.ts @@ -1,9 +1,16 @@ +import { loadBackendAuthTokens, loadBackendBaseUrl } from './utils/backendAuth' + // app.ts App({ globalData: { telemetryPlayerProfile: null, + backendBaseUrl: null, + backendAuthTokens: null, }, onLaunch() { + this.globalData.backendBaseUrl = loadBackendBaseUrl() + this.globalData.backendAuthTokens = loadBackendAuthTokens() + // 展示本地存储能力 const logs = wx.getStorageSync('logs') || [] logs.unshift(Date.now()) diff --git a/miniprogram/engine/map/mapEngine.ts b/miniprogram/engine/map/mapEngine.ts index ee4dbe6..d70c25c 100644 --- a/miniprogram/engine/map/mapEngine.ts +++ b/miniprogram/engine/map/mapEngine.ts @@ -387,6 +387,16 @@ export interface MapEngineGameInfoSnapshot { export type MapEngineResultSnapshot = ResultSummarySnapshot +export interface MapEngineSessionFinishSummary { + status: 'finished' | 'failed' | 'cancelled' + finalDurationSec?: number + finalScore?: number + completedControls?: number + totalControls?: number + distanceMeters?: number + averageSpeedKmh?: number +} + const VIEW_SYNC_KEYS: Array = [ 'animationLevel', 'buildVersion', @@ -1774,6 +1784,41 @@ export class MapEngine { ) } + getSessionFinishSummary(statusOverride?: 'finished' | 'failed' | 'cancelled'): MapEngineSessionFinishSummary | null { + const definition = this.gameRuntime.definition + const sessionState = this.gameRuntime.state + if (!definition || !sessionState) { + return null + } + + let status: 'finished' | 'failed' | 'cancelled' + if (statusOverride) { + status = statusOverride + } else if (sessionState.endReason === 'timed_out' || sessionState.status === 'failed') { + status = 'failed' + } else { + status = 'finished' + } + + const endAt = sessionState.endedAt !== null ? sessionState.endedAt : Date.now() + const finalDurationSec = sessionState.startedAt !== null + ? Math.max(0, Math.floor((endAt - sessionState.startedAt) / 1000)) + : undefined + const totalControls = definition.controls.filter((control) => control.kind === 'control').length + + return { + status, + finalDurationSec, + finalScore: this.getTotalSessionScore(), + completedControls: sessionState.completedControlIds.length, + totalControls, + distanceMeters: this.telemetryRuntime.state.distanceMeters, + averageSpeedKmh: this.telemetryRuntime.state.averageSpeedKmh === null + ? undefined + : this.telemetryRuntime.state.averageSpeedKmh, + } + } + buildSessionRecoveryRuntimeSnapshot(): RecoveryRuntimeSnapshot | null { const definition = this.gameRuntime.definition const state = this.gameRuntime.state @@ -3577,7 +3622,14 @@ export class MapEngine { } handleSetMockLocationMode(): void { + const wasListening = this.locationController.listening + if (!this.locationController.mockBridge.connected && !this.locationController.mockBridge.connecting) { + this.locationController.connectMockBridge() + } this.locationController.setSourceMode('mock') + if (!wasListening && !this.locationController.listening) { + this.locationController.start() + } } handleConnectMockLocationBridge(): void { @@ -3594,9 +3646,26 @@ export class MapEngine { handleSetMockChannelId(channelId: string): void { const normalized = String(channelId || '').trim() || 'default' + const shouldReconnectLocation = this.locationController.mockBridge.connected || this.locationController.mockBridge.connecting + const locationBridgeUrl = this.locationController.mockBridgeUrl + const shouldReconnectHeartRate = this.heartRateController.mockBridge.connected || this.heartRateController.mockBridge.connecting + const heartRateBridgeUrl = this.heartRateController.mockBridgeUrl + const shouldReconnectDebugLog = this.mockSimulatorDebugLogger.enabled this.locationController.setMockChannelId(normalized) this.heartRateController.setMockChannelId(normalized) this.mockSimulatorDebugLogger.setChannelId(normalized) + if (shouldReconnectLocation) { + this.locationController.disconnectMockBridge() + this.locationController.connectMockBridge(locationBridgeUrl) + } + if (shouldReconnectHeartRate) { + this.heartRateController.disconnectMockBridge() + this.heartRateController.connectMockBridge(heartRateBridgeUrl) + } + if (shouldReconnectDebugLog) { + this.mockSimulatorDebugLogger.disconnect() + this.mockSimulatorDebugLogger.connect() + } this.setState({ mockChannelIdText: normalized, }) @@ -3663,7 +3732,14 @@ export class MapEngine { } handleSetMockHeartRateMode(): void { + const wasConnected = this.heartRateController.connected + if (!this.heartRateController.mockBridge.connected && !this.heartRateController.mockBridge.connecting) { + this.heartRateController.connectMockBridge() + } this.heartRateController.setSourceMode('mock') + if (!wasConnected && !this.heartRateController.connected) { + this.heartRateController.startScanAndConnect() + } } handleConnectMockHeartRateBridge(): void { diff --git a/miniprogram/pages/event/event.json b/miniprogram/pages/event/event.json new file mode 100644 index 0000000..df1eef6 --- /dev/null +++ b/miniprogram/pages/event/event.json @@ -0,0 +1,3 @@ +{ + "navigationBarTitleText": "活动" +} diff --git a/miniprogram/pages/event/event.ts b/miniprogram/pages/event/event.ts new file mode 100644 index 0000000..6d42f02 --- /dev/null +++ b/miniprogram/pages/event/event.ts @@ -0,0 +1,123 @@ +import { loadBackendAuthTokens, loadBackendBaseUrl } from '../../utils/backendAuth' +import { getEventPlay, launchEvent, type BackendEventPlayResult } from '../../utils/backendApi' +import { adaptBackendLaunchResultToEnvelope } from '../../utils/backendLaunchAdapter' +import { prepareMapPageUrlForLaunch } from '../../utils/gameLaunch' + +type EventPageData = { + eventId: string + loading: boolean + titleText: string + summaryText: string + releaseText: string + actionText: string + statusText: string +} + +function getAccessToken(): string | null { + const app = getApp() + 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}`, + }) + } + }, +}) diff --git a/miniprogram/pages/event/event.wxml b/miniprogram/pages/event/event.wxml new file mode 100644 index 0000000..05eecd1 --- /dev/null +++ b/miniprogram/pages/event/event.wxml @@ -0,0 +1,20 @@ + + + + Event Play + {{titleText}} + {{summaryText}} + + + + 开始前准备 + Release:{{releaseText}} + 主动作:{{actionText}} + 状态:{{statusText}} + + + + + + + diff --git a/miniprogram/pages/event/event.wxss b/miniprogram/pages/event/event.wxss new file mode 100644 index 0000000..0da80e6 --- /dev/null +++ b/miniprogram/pages/event/event.wxss @@ -0,0 +1,91 @@ +page { + min-height: 100vh; + background: linear-gradient(180deg, #eff4fb 0%, #e8eff7 100%); +} + +.page { + min-height: 100vh; +} + +.shell { + display: grid; + gap: 24rpx; + padding: 28rpx 24rpx 40rpx; +} + +.hero, +.panel { + display: grid; + gap: 16rpx; + padding: 24rpx; + border-radius: 24rpx; +} + +.hero { + background: linear-gradient(135deg, #163a66 0%, #1f5da1 100%); + color: #ffffff; +} + +.hero__eyebrow { + font-size: 22rpx; + letter-spacing: 0.16em; + text-transform: uppercase; + color: rgba(255, 255, 255, 0.72); +} + +.hero__title { + font-size: 40rpx; + font-weight: 700; +} + +.hero__desc { + font-size: 24rpx; + color: rgba(255, 255, 255, 0.84); + line-height: 1.6; +} + +.panel { + background: rgba(255, 255, 255, 0.94); + box-shadow: 0 14rpx 32rpx rgba(40, 63, 95, 0.08); +} + +.panel__title { + font-size: 30rpx; + font-weight: 700; + color: #17345a; +} + +.summary { + font-size: 24rpx; + line-height: 1.6; + color: #30465f; +} + +.actions { + display: flex; + gap: 16rpx; + flex-wrap: wrap; +} + +.btn { + margin: 0; + min-height: 76rpx; + padding: 0 24rpx; + line-height: 76rpx; + border-radius: 18rpx; + font-size: 26rpx; +} + +.btn::after { + border: 0; +} + +.btn--primary { + background: #173d73; + color: #ffffff; +} + +.btn--secondary { + background: #dfeaf8; + color: #173d73; +} diff --git a/miniprogram/pages/home/home.json b/miniprogram/pages/home/home.json new file mode 100644 index 0000000..dac4751 --- /dev/null +++ b/miniprogram/pages/home/home.json @@ -0,0 +1,3 @@ +{ + "navigationBarTitleText": "首页" +} diff --git a/miniprogram/pages/home/home.ts b/miniprogram/pages/home/home.ts new file mode 100644 index 0000000..f2b1726 --- /dev/null +++ b/miniprogram/pages/home/home.ts @@ -0,0 +1,127 @@ +import { clearBackendAuthTokens, loadBackendAuthTokens, loadBackendBaseUrl } from '../../utils/backendAuth' +import { getEntryHome, type BackendCardResult, type BackendEntryHomeResult } from '../../utils/backendApi' + +const DEFAULT_CHANNEL_CODE = 'mini-demo' +const DEFAULT_CHANNEL_TYPE = 'wechat_mini' + +type HomePageData = { + loading: boolean + statusText: string + userNameText: string + tenantText: string + channelText: string + ongoingSessionText: string + recentSessionText: string + cards: BackendCardResult[] +} + +function requireAuthToken(): string | null { + const app = getApp() + 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() + if (app.globalData) { + app.globalData.backendAuthTokens = null + } + wx.redirectTo({ + url: '/pages/login/login', + }) + }, +}) diff --git a/miniprogram/pages/home/home.wxml b/miniprogram/pages/home/home.wxml new file mode 100644 index 0000000..9442853 --- /dev/null +++ b/miniprogram/pages/home/home.wxml @@ -0,0 +1,32 @@ + + + + Entry Home + {{userNameText}} + {{tenantText}} + {{channelText}} + + + + 当前状态 + {{statusText}} + 进行中:{{ongoingSessionText}} + 最近一局:{{recentSessionText}} + + + + + + + + + 活动入口 + 当前没有首页卡片 + + {{item.title}} + {{item.subtitle || (item.event && item.event.displayName ? item.event.displayName : '暂无副标题')}} + {{item.type}} / {{item.displaySlot}} + + + + diff --git a/miniprogram/pages/home/home.wxss b/miniprogram/pages/home/home.wxss new file mode 100644 index 0000000..a7ce70d --- /dev/null +++ b/miniprogram/pages/home/home.wxss @@ -0,0 +1,111 @@ +page { + min-height: 100vh; + background: linear-gradient(180deg, #eff4fb 0%, #e8eff7 100%); +} + +.page { + min-height: 100vh; +} + +.shell { + display: grid; + gap: 24rpx; + padding: 28rpx 24rpx 40rpx; +} + +.hero, +.panel { + display: grid; + gap: 16rpx; + padding: 24rpx; + border-radius: 24rpx; +} + +.hero { + background: linear-gradient(135deg, #163a66 0%, #1f5da1 100%); + color: #ffffff; +} + +.hero__eyebrow { + font-size: 22rpx; + letter-spacing: 0.16em; + text-transform: uppercase; + color: rgba(255, 255, 255, 0.72); +} + +.hero__title { + font-size: 40rpx; + font-weight: 700; +} + +.hero__desc { + font-size: 24rpx; + color: rgba(255, 255, 255, 0.84); +} + +.panel { + background: rgba(255, 255, 255, 0.94); + box-shadow: 0 14rpx 32rpx rgba(40, 63, 95, 0.08); +} + +.panel__title { + font-size: 30rpx; + font-weight: 700; + color: #17345a; +} + +.summary { + font-size: 24rpx; + line-height: 1.6; + color: #30465f; +} + +.actions { + display: flex; + flex-wrap: wrap; + gap: 16rpx; +} + +.btn { + margin: 0; + min-height: 76rpx; + padding: 0 24rpx; + line-height: 76rpx; + border-radius: 18rpx; + font-size: 26rpx; +} + +.btn::after { + border: 0; +} + +.btn--secondary { + background: #dfeaf8; + color: #173d73; +} + +.btn--ghost { + background: #ffffff; + color: #52657d; + border: 2rpx solid #d8e2ec; +} + +.card { + display: grid; + gap: 10rpx; + padding: 20rpx; + border-radius: 20rpx; + background: #f6f9fc; +} + +.card__title { + font-size: 28rpx; + font-weight: 700; + color: #17345a; +} + +.card__subtitle, +.card__meta { + font-size: 22rpx; + color: #64748b; +} diff --git a/miniprogram/pages/index/index.ts b/miniprogram/pages/index/index.ts index 332828a..683ed4c 100644 --- a/miniprogram/pages/index/index.ts +++ b/miniprogram/pages/index/index.ts @@ -1,52 +1,11 @@ -// index.ts -// 获取应用实例 -const defaultAvatarUrl = 'https://mmbiz.qpic.cn/mmbiz/icTdbqWNOwNRna42FI242Lcia07jQodd2FJGIYQfG0LAJGFxM4FbnQP6yfMxBgJ0F3YRqJCJ1aPAK2dQagdusBZg/0' +import { loadBackendAuthTokens } from '../../utils/backendAuth' -Component({ - data: { - motto: 'Hello World', - userInfo: { - avatarUrl: defaultAvatarUrl, - nickName: '', - }, - hasUserInfo: false, - canIUseGetUserProfile: wx.canIUse('getUserProfile'), - canIUseNicknameComp: wx.canIUse('input.type.nickname'), - }, - methods: { - // 事件处理函数 - bindViewTap() { - wx.navigateTo({ - url: '../logs/logs', - }) - }, - onChooseAvatar(e: any) { - const { avatarUrl } = e.detail - const { nickName } = this.data.userInfo - this.setData({ - "userInfo.avatarUrl": avatarUrl, - hasUserInfo: nickName && avatarUrl && avatarUrl !== defaultAvatarUrl, - }) - }, - onInputChange(e: any) { - const nickName = e.detail.value - const { avatarUrl } = this.data.userInfo - this.setData({ - "userInfo.nickName": nickName, - hasUserInfo: nickName && avatarUrl && avatarUrl !== defaultAvatarUrl, - }) - }, - getUserProfile() { - // 推荐使用wx.getUserProfile获取用户信息,开发者每次通过该接口获取用户个人信息均需用户确认,开发者妥善保管用户快速填写的头像昵称,避免重复弹窗 - wx.getUserProfile({ - desc: '展示用户信息', // 声明获取用户个人信息后的用途,后续会展示在弹窗中,请谨慎填写 - success: (res) => { - this.setData({ - userInfo: res.userInfo, - hasUserInfo: true - }) - } - }) - }, +Page({ + onLoad() { + const tokens = loadBackendAuthTokens() + const url = tokens && tokens.accessToken + ? '/pages/home/home' + : '/pages/login/login' + wx.redirectTo({ url }) }, }) diff --git a/miniprogram/pages/index/index.wxml b/miniprogram/pages/index/index.wxml index 0721ba0..ed07c7f 100644 --- a/miniprogram/pages/index/index.wxml +++ b/miniprogram/pages/index/index.wxml @@ -1,27 +1,3 @@ - - - - - - - - 昵称 - - - - - - 请使用2.10.4及以上版本基础库 - - - - {{userInfo.nickName}} - - - - {{motto}} - - - + + 正在进入业务页... + diff --git a/miniprogram/pages/index/index.wxss b/miniprogram/pages/index/index.wxss index 1ebed4b..d28eb37 100644 --- a/miniprogram/pages/index/index.wxss +++ b/miniprogram/pages/index/index.wxss @@ -1,62 +1,13 @@ -/**index.wxss**/ -page { - height: 100vh; +.boot-page { + min-height: 100vh; display: flex; - flex-direction: column; -} -.scrollarea { - flex: 1; - overflow-y: hidden; -} - -.userinfo { - display: flex; - flex-direction: column; align-items: center; - color: #aaa; - width: 80%; + justify-content: center; + background: linear-gradient(180deg, #0f2f5a 0%, #1d5ca8 100%); } -.userinfo-avatar { - overflow: hidden; - width: 128rpx; - height: 128rpx; - margin: 20rpx; - border-radius: 50%; -} - -.usermotto { - margin-top: 200px; -} - -.avatar-wrapper { - padding: 0; - width: 56px !important; - border-radius: 8px; - margin-top: 40px; - margin-bottom: 40px; -} - -.avatar { - display: block; - width: 56px; - height: 56px; -} - -.nickname-wrapper { - display: flex; - width: 100%; - padding: 16px; - box-sizing: border-box; - border-top: .5px solid rgba(0, 0, 0, 0.1); - border-bottom: .5px solid rgba(0, 0, 0, 0.1); - color: black; -} - -.nickname-label { - width: 105px; -} - -.nickname-input { - flex: 1; +.boot-page__text { + color: #ffffff; + font-size: 30rpx; + letter-spacing: 0.08em; } diff --git a/miniprogram/pages/login/login.json b/miniprogram/pages/login/login.json new file mode 100644 index 0000000..c5a131f --- /dev/null +++ b/miniprogram/pages/login/login.json @@ -0,0 +1,3 @@ +{ + "navigationBarTitleText": "登录" +} diff --git a/miniprogram/pages/login/login.ts b/miniprogram/pages/login/login.ts new file mode 100644 index 0000000..2d07fa0 --- /dev/null +++ b/miniprogram/pages/login/login.ts @@ -0,0 +1,127 @@ +import { clearBackendAuthTokens, saveBackendAuthTokens, saveBackendBaseUrl } from '../../utils/backendAuth' +import { loginWechatMini } from '../../utils/backendApi' + +const DEFAULT_BACKEND_BASE_URL = 'https://api.gotomars.xyz' +const DEFAULT_DEVICE_KEY = 'mini-dev-device-001' +const DEFAULT_DEV_CODE = 'dev-workbench-user' + +type LoginPageData = { + backendBaseUrl: string + deviceKey: string + loginCode: string + statusText: string +} + +function setAppBackendState(baseUrl: string, accessToken: string, refreshToken: string) { + const app = getApp() + 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() + 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() + 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() + if (app.globalData) { + app.globalData.backendAuthTokens = null + } + this.setData({ + statusText: '已清空登录态', + }) + }, +}) diff --git a/miniprogram/pages/login/login.wxml b/miniprogram/pages/login/login.wxml new file mode 100644 index 0000000..ffb275b --- /dev/null +++ b/miniprogram/pages/login/login.wxml @@ -0,0 +1,35 @@ + + + + CMR Backend + 登录 + 先把小程序登录态接到 backend,再进入首页和活动页。 + + + + 连接配置 + + Backend Base URL + + + + Device Key + + + + 开发登录 Code + + + + + + + + + + + 状态 + {{statusText}} + + + diff --git a/miniprogram/pages/login/login.wxss b/miniprogram/pages/login/login.wxss new file mode 100644 index 0000000..516a2bf --- /dev/null +++ b/miniprogram/pages/login/login.wxss @@ -0,0 +1,116 @@ +page { + min-height: 100vh; + background: linear-gradient(180deg, #eef4fb 0%, #e6eff9 100%); +} + +.page { + min-height: 100vh; +} + +.shell { + display: grid; + gap: 24rpx; + padding: 28rpx 24rpx 40rpx; +} + +.hero, +.panel { + display: grid; + gap: 18rpx; + padding: 24rpx; + border-radius: 24rpx; +} + +.hero { + background: linear-gradient(135deg, #123b72 0%, #1d5ca8 100%); + color: #ffffff; +} + +.hero__eyebrow { + font-size: 22rpx; + letter-spacing: 0.16em; + text-transform: uppercase; + color: rgba(255, 255, 255, 0.72); +} + +.hero__title { + font-size: 40rpx; + font-weight: 700; +} + +.hero__desc { + font-size: 24rpx; + line-height: 1.6; + color: rgba(255, 255, 255, 0.84); +} + +.panel { + background: rgba(255, 255, 255, 0.94); + box-shadow: 0 14rpx 32rpx rgba(40, 63, 95, 0.08); +} + +.panel__title { + font-size: 30rpx; + font-weight: 700; + color: #17345a; +} + +.field { + display: grid; + gap: 10rpx; +} + +.field__label { + font-size: 22rpx; + color: #64748b; +} + +.field__input { + min-height: 76rpx; + padding: 0 20rpx; + border-radius: 18rpx; + border: 2rpx solid #dce7f3; + background: #f8fbff; + font-size: 28rpx; +} + +.actions { + display: flex; + flex-wrap: wrap; + gap: 16rpx; +} + +.btn { + margin: 0; + min-height: 76rpx; + padding: 0 24rpx; + line-height: 76rpx; + border-radius: 18rpx; + font-size: 26rpx; +} + +.btn::after { + border: 0; +} + +.btn--primary { + background: #173d73; + color: #ffffff; +} + +.btn--secondary { + background: #dfeaf8; + color: #173d73; +} + +.btn--ghost { + background: #ffffff; + color: #52657d; + border: 2rpx solid #d8e2ec; +} + +.status { + font-size: 24rpx; + line-height: 1.6; + color: #30465f; +} diff --git a/miniprogram/pages/map/map.ts b/miniprogram/pages/map/map.ts index 319326d..6f39d88 100644 --- a/miniprogram/pages/map/map.ts +++ b/miniprogram/pages/map/map.ts @@ -12,6 +12,8 @@ import { type GameLaunchEnvelope, type MapPageLaunchOptions, } from '../../utils/gameLaunch' +import { finishSession, startSession, type BackendSessionFinishSummaryPayload } from '../../utils/backendApi' +import { loadBackendBaseUrl } from '../../utils/backendAuth' import { loadRemoteMapConfig, type RemoteMapConfig } from '../../utils/remoteMapConfig' import { type H5ExperienceFallbackPayload, type H5ExperienceRequest } from '../../game/experience/h5Experience' import { type TrackColorPreset } from '../../game/presentation/trackStyleConfig' @@ -173,6 +175,8 @@ let lastPunchHintHapticAt = 0 let currentSystemSettingsConfig: SystemSettingsConfig | undefined let currentRemoteMapConfig: RemoteMapConfig | undefined let systemSettingsLockLifetimeActive = false +let syncedBackendSessionStartId = '' +let syncedBackendSessionFinishId = '' let lastCenterScaleRulerStablePatch: Pick< MapPageData, | 'centerScaleRulerVisible' @@ -441,6 +445,37 @@ function hasExplicitLaunchOptions(options?: MapPageLaunchOptions | null): boolea ) } +function getCurrentBackendSessionContext(): { sessionId: string; sessionToken: string } | null { + const business = currentGameLaunchEnvelope.business + if (!business || !business.sessionId || !business.sessionToken) { + return null + } + + return { + sessionId: business.sessionId, + sessionToken: business.sessionToken, + } +} + +function getBackendSessionContextFromLaunchEnvelope(envelope: GameLaunchEnvelope | null | undefined): { sessionId: string; sessionToken: string } | null { + if (!envelope || !envelope.business || !envelope.business.sessionId || !envelope.business.sessionToken) { + return null + } + + return { + sessionId: envelope.business.sessionId, + sessionToken: envelope.business.sessionToken, + } +} + +function getCurrentBackendBaseUrl(): string { + const app = getApp() + if (app.globalData && app.globalData.backendBaseUrl) { + return app.globalData.backendBaseUrl + } + return loadBackendBaseUrl() +} + function buildSideButtonVisibility(mode: SideButtonMode) { return { sideButtonMode: mode, @@ -871,6 +906,8 @@ Page({ onLoad(options: MapPageLaunchOptions) { clearSessionRecoveryPersistTimer() + syncedBackendSessionStartId = '' + syncedBackendSessionFinishId = '' currentGameLaunchEnvelope = resolveGameLaunchEnvelope(options) if (!hasExplicitLaunchOptions(options)) { const recoverySnapshot = loadSessionRecoverySnapshot() @@ -991,6 +1028,8 @@ Page({ const nextAnimationLevel = typeof nextPatch.animationLevel === 'string' ? nextPatch.animationLevel : this.data.animationLevel + let shouldSyncBackendSessionStart = false + let backendSessionFinishStatus: 'finished' | 'failed' | null = null if (nextAnimationLevel === 'lite') { clearHudFxTimer('timer') @@ -1055,6 +1094,7 @@ Page({ nextData.showGameInfoPanel = false nextData.showSystemSettingsPanel = false clearGameInfoPanelSyncTimer() + backendSessionFinishStatus = nextPatch.gameSessionStatus === 'finished' ? 'finished' : 'failed' } else if ( nextPatch.gameSessionStatus !== this.data.gameSessionStatus && nextPatch.gameSessionStatus === 'idle' @@ -1064,6 +1104,11 @@ Page({ shouldSyncRuntimeSystemSettings = true clearSessionRecoverySnapshot() clearSessionRecoveryPersistTimer() + } else if ( + nextPatch.gameSessionStatus !== this.data.gameSessionStatus + && nextPatch.gameSessionStatus === 'running' + ) { + shouldSyncBackendSessionStart = true } else if (nextPatch.gameSessionStatus === 'running' || nextPatch.gameSessionStatus === 'idle') { nextData.showResultScene = false } @@ -1077,6 +1122,12 @@ Page({ if (typeof nextPatch.gameSessionStatus === 'string') { this.syncSessionRecoveryLifecycle(nextPatch.gameSessionStatus) } + if (shouldSyncBackendSessionStart) { + this.syncBackendSessionStart() + } + if (backendSessionFinishStatus) { + this.syncBackendSessionFinish(backendSessionFinishStatus) + } if (shouldSyncRuntimeSystemSettings) { this.applyRuntimeSystemSettings(nextLockLifetimeActive) } @@ -1088,6 +1139,12 @@ Page({ if (typeof nextPatch.gameSessionStatus === 'string') { this.syncSessionRecoveryLifecycle(nextPatch.gameSessionStatus) } + if (shouldSyncBackendSessionStart) { + this.syncBackendSessionStart() + } + if (backendSessionFinishStatus) { + this.syncBackendSessionFinish(backendSessionFinishStatus) + } if (shouldSyncRuntimeSystemSettings) { this.applyRuntimeSystemSettings(nextLockLifetimeActive) } @@ -1283,6 +1340,8 @@ Page({ onUnload() { this.persistSessionRecoverySnapshot() clearSessionRecoveryPersistTimer() + syncedBackendSessionStartId = '' + syncedBackendSessionFinishId = '' clearGameInfoPanelSyncTimer() clearCenterScaleRulerSyncTimer() clearCenterScaleRulerUpdateTimer() @@ -1332,6 +1391,114 @@ Page({ return true }, + syncBackendSessionStart() { + const sessionContext = getCurrentBackendSessionContext() + if (!sessionContext || syncedBackendSessionStartId === sessionContext.sessionId) { + return + } + + startSession({ + baseUrl: getCurrentBackendBaseUrl(), + sessionId: sessionContext.sessionId, + sessionToken: sessionContext.sessionToken, + }) + .then(() => { + syncedBackendSessionStartId = sessionContext.sessionId + }) + .catch((error) => { + const message = error && error.message ? error.message : '未知错误' + this.setData({ + statusText: `session start 上报失败: ${message}`, + }) + }) + }, + + syncBackendSessionFinish(statusOverride?: 'finished' | 'failed' | 'cancelled') { + const sessionContext = getCurrentBackendSessionContext() + if (!sessionContext || syncedBackendSessionFinishId === sessionContext.sessionId || !mapEngine) { + return + } + + const finishSummary = mapEngine.getSessionFinishSummary(statusOverride) + if (!finishSummary) { + return + } + + const summaryPayload: BackendSessionFinishSummaryPayload = {} + if (typeof finishSummary.finalDurationSec === 'number') { + summaryPayload.finalDurationSec = finishSummary.finalDurationSec + } + if (typeof finishSummary.finalScore === 'number') { + summaryPayload.finalScore = finishSummary.finalScore + } + if (typeof finishSummary.completedControls === 'number') { + summaryPayload.completedControls = finishSummary.completedControls + } + if (typeof finishSummary.totalControls === 'number') { + summaryPayload.totalControls = finishSummary.totalControls + } + if (typeof finishSummary.distanceMeters === 'number') { + summaryPayload.distanceMeters = finishSummary.distanceMeters + } + if (typeof finishSummary.averageSpeedKmh === 'number') { + summaryPayload.averageSpeedKmh = finishSummary.averageSpeedKmh + } + + finishSession({ + baseUrl: getCurrentBackendBaseUrl(), + sessionId: sessionContext.sessionId, + sessionToken: sessionContext.sessionToken, + status: finishSummary.status, + summary: summaryPayload, + }) + .then(() => { + syncedBackendSessionFinishId = sessionContext.sessionId + }) + .catch((error) => { + const message = error && error.message ? error.message : '未知错误' + this.setData({ + statusText: `session finish 上报失败: ${message}`, + }) + }) + }, + + reportAbandonedRecoverySnapshot(snapshot: SessionRecoverySnapshot) { + const sessionContext = getBackendSessionContextFromLaunchEnvelope(snapshot.launchEnvelope) + if (!sessionContext) { + clearSessionRecoverySnapshot() + return + } + + finishSession({ + baseUrl: getCurrentBackendBaseUrl(), + sessionId: sessionContext.sessionId, + sessionToken: sessionContext.sessionToken, + status: 'cancelled', + summary: {}, + }) + .then(() => { + syncedBackendSessionFinishId = sessionContext.sessionId + clearSessionRecoverySnapshot() + wx.showToast({ + title: '已放弃上次对局', + icon: 'none', + duration: 1400, + }) + }) + .catch((error) => { + clearSessionRecoverySnapshot() + const message = error && error.message ? error.message : '未知错误' + this.setData({ + statusText: `放弃恢复已生效,后端取消上报失败: ${message}`, + }) + wx.showToast({ + title: '已放弃上次对局', + icon: 'none', + duration: 1400, + }) + }) + }, + syncSessionRecoveryLifecycle(status: MapPageData['gameSessionStatus']) { if (status === 'running') { this.persistSessionRecoverySnapshot() @@ -1368,7 +1535,7 @@ Page({ cancelText: '放弃', success: (result) => { if (!result.confirm) { - clearSessionRecoverySnapshot() + this.reportAbandonedRecoverySnapshot(snapshot) return } @@ -1385,15 +1552,19 @@ Page({ return } - this.setData({ - showResultScene: false, - showDebugPanel: false, - showGameInfoPanel: false, - showSystemSettingsPanel: false, - }) - this.syncSessionRecoveryLifecycle('running') - }, - }) + this.setData({ + showResultScene: false, + showDebugPanel: false, + showGameInfoPanel: false, + showSystemSettingsPanel: false, + }) + const sessionContext = getCurrentBackendSessionContext() + if (sessionContext) { + syncedBackendSessionStartId = sessionContext.sessionId + } + this.syncSessionRecoveryLifecycle('running') + }, + }) }, compileCurrentRuntimeProfile(lockLifetimeActive = isSystemSettingsLockLifetimeActive()) { @@ -1537,7 +1708,10 @@ Page({ return } - const errorMessage = error && error.message ? error.message : '未知错误' + const rawErrorMessage = error && error.message ? error.message : '未知错误' + const errorMessage = rawErrorMessage.indexOf('404') >= 0 + ? `release manifest 不存在或未发布 (${configLabel})` + : rawErrorMessage this.setData({ configStatusText: `载入失败: ${errorMessage}`, statusText: `远程地图配置载入失败: ${errorMessage} (${INTERNAL_BUILD_VERSION})`, @@ -1939,18 +2113,19 @@ Page({ return } - wx.showModal({ - title: '确认退出', - content: '确认强制结束当前对局并返回开始前状态?', - confirmText: '确认退出', - cancelText: '取消', - success: (result) => { - if (result.confirm && mapEngine) { - systemSettingsLockLifetimeActive = false - mapEngine.handleForceExitGame() - } - }, - }) + wx.showModal({ + title: '确认退出', + content: '确认强制结束当前对局并返回开始前状态?', + confirmText: '确认退出', + cancelText: '取消', + success: (result) => { + if (result.confirm && mapEngine) { + this.syncBackendSessionFinish('cancelled') + systemSettingsLockLifetimeActive = false + mapEngine.handleForceExitGame() + } + }, + }) }, handleSkipAction() { diff --git a/miniprogram/pages/result/result.json b/miniprogram/pages/result/result.json new file mode 100644 index 0000000..3ad11d4 --- /dev/null +++ b/miniprogram/pages/result/result.json @@ -0,0 +1,3 @@ +{ + "navigationBarTitleText": "结果" +} diff --git a/miniprogram/pages/result/result.ts b/miniprogram/pages/result/result.ts new file mode 100644 index 0000000..79e491a --- /dev/null +++ b/miniprogram/pages/result/result.ts @@ -0,0 +1,134 @@ +import { loadBackendAuthTokens, loadBackendBaseUrl } from '../../utils/backendAuth' +import { getMyResults, getSessionResult, type BackendSessionResultView } from '../../utils/backendApi' + +type ResultPageData = { + sessionId: string + statusText: string + sessionTitleText: string + sessionSubtitleText: string + rows: Array<{ label: string; value: string }> + recentResults: BackendSessionResultView[] +} + +function getAccessToken(): string | null { + const app = getApp() + 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() + }, +}) diff --git a/miniprogram/pages/result/result.wxml b/miniprogram/pages/result/result.wxml new file mode 100644 index 0000000..e355a2f --- /dev/null +++ b/miniprogram/pages/result/result.wxml @@ -0,0 +1,33 @@ + + + + Result + {{sessionTitleText}} + {{sessionSubtitleText}} + + + + 当前状态 + {{statusText}} + + + + + 单局摘要 + + {{item.label}} + {{item.value}} + + + + + 最近结果 + 当前没有结果记录 + + {{item.session.eventName || item.session.id}} + {{item.result.status}} / {{item.session.status}} + 得分 {{item.result.finalScore || '--'}} / 用时 {{item.result.finalDurationSec || '--'}}s + + + + diff --git a/miniprogram/pages/result/result.wxss b/miniprogram/pages/result/result.wxss new file mode 100644 index 0000000..8477d83 --- /dev/null +++ b/miniprogram/pages/result/result.wxss @@ -0,0 +1,114 @@ +page { + min-height: 100vh; + background: linear-gradient(180deg, #eff4fb 0%, #e8eff7 100%); +} + +.page { + min-height: 100vh; +} + +.shell { + display: grid; + gap: 24rpx; + padding: 28rpx 24rpx 40rpx; +} + +.hero, +.panel { + display: grid; + gap: 16rpx; + padding: 24rpx; + border-radius: 24rpx; +} + +.hero { + background: linear-gradient(135deg, #163a66 0%, #1f5da1 100%); + color: #ffffff; +} + +.hero__eyebrow { + font-size: 22rpx; + letter-spacing: 0.16em; + text-transform: uppercase; + color: rgba(255, 255, 255, 0.72); +} + +.hero__title { + font-size: 40rpx; + font-weight: 700; +} + +.hero__desc { + font-size: 24rpx; + color: rgba(255, 255, 255, 0.84); +} + +.panel { + background: rgba(255, 255, 255, 0.94); + box-shadow: 0 14rpx 32rpx rgba(40, 63, 95, 0.08); +} + +.panel__title { + font-size: 30rpx; + font-weight: 700; + color: #17345a; +} + +.summary, +.row__label, +.row__value, +.result-card__meta { + font-size: 24rpx; + line-height: 1.6; + color: #30465f; +} + +.row { + display: flex; + justify-content: space-between; + gap: 16rpx; + padding: 10rpx 0; + border-bottom: 2rpx solid #edf2f7; +} + +.row:last-child { + border-bottom: 0; +} + +.row__value { + font-weight: 700; + color: #17345a; +} + +.result-card { + display: grid; + gap: 8rpx; + padding: 18rpx; + border-radius: 18rpx; + background: #f6f9fc; +} + +.result-card__title { + font-size: 28rpx; + font-weight: 700; + color: #17345a; +} + +.btn { + margin: 0; + min-height: 76rpx; + padding: 0 24rpx; + line-height: 76rpx; + border-radius: 18rpx; + font-size: 26rpx; +} + +.btn::after { + border: 0; +} + +.btn--ghost { + background: #ffffff; + color: #52657d; + border: 2rpx solid #d8e2ec; +} diff --git a/miniprogram/utils/backendApi.ts b/miniprogram/utils/backendApi.ts new file mode 100644 index 0000000..e1b7f0b --- /dev/null +++ b/miniprogram/utils/backendApi.ts @@ -0,0 +1,375 @@ +import { normalizeBackendBaseUrl } from './backendAuth' + +export interface BackendApiError { + statusCode: number + code: string + message: string + details?: unknown +} + +export interface BackendAuthLoginResult { + user?: { + id?: string + nickname?: string + avatarUrl?: string + } + tokens: { + accessToken: string + refreshToken: string + } +} + +export interface BackendResolvedRelease { + launchMode: string + source: string + eventId: string + releaseId: string + configLabel: string + manifestUrl: string + manifestChecksumSha256?: string | null + routeCode?: string | null +} + +export interface BackendEntrySessionSummary { + id: string + status: string + eventId?: string + eventName?: string + releaseId?: string | null + configLabel?: string | null + routeCode?: string | null + launchedAt?: string | null + startedAt?: string | null + endedAt?: string | null + // 兼容前端旧字段名,避免联调过渡期多处判断 + sessionId?: string + sessionStatus?: string + eventDisplayName?: string +} + +export interface BackendCardResult { + id: string + type: string + title: string + subtitle?: string | null + coverUrl?: string | null + displaySlot: string + displayPriority: number + event?: { + id: string + displayName: string + summary?: string | null + } | null + htmlUrl?: string | null +} + +export interface BackendEntryHomeResult { + user: { + id: string + publicId: string + status: string + nickname?: string | null + avatarUrl?: string | null + } + tenant: { + id: string + code: string + name: string + } + channel: { + id: string + code: string + type: string + platformAppId?: string | null + displayName: string + status: string + isDefault: boolean + } + cards: BackendCardResult[] + ongoingSession?: BackendEntrySessionSummary | null + recentSession?: BackendEntrySessionSummary | null +} + +export interface BackendEventPlayResult { + event: { + id: string + slug: string + displayName: string + summary?: string | null + status: string + } + release?: { + id: string + configLabel: string + manifestUrl: string + manifestChecksumSha256?: string | null + routeCode?: string | null + } | null + resolvedRelease?: BackendResolvedRelease | null + play: { + canLaunch: boolean + primaryAction: string + reason: string + launchSource?: string + ongoingSession?: BackendEntrySessionSummary | null + recentSession?: BackendEntrySessionSummary | null + } +} + +export interface BackendLaunchResult { + event: { + id: string + displayName: string + } + launch: { + source: string + resolvedRelease?: BackendResolvedRelease | null + config: { + configUrl: string + configLabel: string + configChecksumSha256?: string | null + releaseId: string + routeCode?: string | null + } + business: { + source: string + eventId: string + sessionId: string + sessionToken: string + sessionTokenExpiresAt: string + routeCode?: string | null + } + } +} + +export interface BackendSessionFinishSummaryPayload { + finalDurationSec?: number + finalScore?: number + completedControls?: number + totalControls?: number + distanceMeters?: number + averageSpeedKmh?: number + maxHeartRateBpm?: number +} + +export interface BackendSessionResult { + session: { + id: string + status: string + clientType: string + deviceKey: string + routeCode?: string | null + sessionTokenExpiresAt: string + launchedAt: string + startedAt?: string | null + endedAt?: string | null + } + event: { + id: string + displayName: string + } + resolvedRelease?: BackendResolvedRelease | null +} + +export interface BackendSessionResultView { + session: BackendEntrySessionSummary + result: { + status: string + finalDurationSec?: number + finalScore?: number + completedControls?: number + totalControls?: number + distanceMeters?: number + averageSpeedKmh?: number + maxHeartRateBpm?: number + summary?: Record + } +} + +type BackendEnvelope = { + data: T +} + +type RequestOptions = { + method: 'GET' | 'POST' + baseUrl: string + path: string + authToken?: string + body?: Record +} + +function requestBackend(options: RequestOptions): Promise { + const url = `${normalizeBackendBaseUrl(options.baseUrl)}${options.path}` + const header: Record = {} + if (options.body) { + header['Content-Type'] = 'application/json' + } + if (options.authToken) { + header.Authorization = `Bearer ${options.authToken}` + } + + return new Promise((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 | { error?: { code?: string; message?: string; details?: unknown } } + if (statusCode >= 200 && statusCode < 300 && data && typeof data === 'object' && 'data' in data) { + resolve((data as BackendEnvelope).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 { + return requestBackend({ + 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 { + return requestBackend({ + 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 { + const query = `channelCode=${encodeURIComponent(input.channelCode)}&channelType=${encodeURIComponent(input.channelType)}` + return requestBackend({ + 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 { + const body: Record = { + clientType: input.clientType, + deviceKey: input.deviceKey, + } + if (input.releaseId) { + body.releaseId = input.releaseId + } + return requestBackend({ + 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 { + return requestBackend({ + 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 { + return requestBackend({ + 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 { + return requestBackend({ + 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 { + const limit = typeof input.limit === 'number' ? input.limit : 20 + return requestBackend({ + method: 'GET', + baseUrl: input.baseUrl, + path: `/me/results?limit=${encodeURIComponent(String(limit))}`, + authToken: input.accessToken, + }) +} diff --git a/miniprogram/utils/backendAuth.ts b/miniprogram/utils/backendAuth.ts new file mode 100644 index 0000000..f4a1ea9 --- /dev/null +++ b/miniprogram/utils/backendAuth.ts @@ -0,0 +1,86 @@ +export interface BackendAuthTokens { + accessToken: string + refreshToken: string +} + +const BACKEND_BASE_URL_STORAGE_KEY = 'cmr.backend.baseUrl.v1' +const BACKEND_AUTH_TOKENS_STORAGE_KEY = 'cmr.backend.authTokens.v1' +const DEFAULT_BACKEND_BASE_URL = 'https://api.gotomars.xyz' +const LEGACY_LOCAL_BACKEND_BASE_URLS = [ + 'http://127.0.0.1:8080', + 'https://127.0.0.1:8080', + 'http://localhost:8080', + 'https://localhost:8080', +] + +function normalizeString(value: unknown): string { + return typeof value === 'string' ? value.trim() : '' +} + +export function normalizeBackendBaseUrl(value: unknown): string { + const normalized = normalizeString(value).replace(/\/+$/, '') + if (LEGACY_LOCAL_BACKEND_BASE_URLS.indexOf(normalized) >= 0) { + return DEFAULT_BACKEND_BASE_URL + } + return normalized || DEFAULT_BACKEND_BASE_URL +} + +export function loadBackendBaseUrl(): string { + try { + const stored = wx.getStorageSync(BACKEND_BASE_URL_STORAGE_KEY) + const normalized = normalizeBackendBaseUrl(stored) + if (normalized !== stored && normalized === DEFAULT_BACKEND_BASE_URL) { + wx.setStorageSync(BACKEND_BASE_URL_STORAGE_KEY, normalized) + } + return normalized + } catch { + return DEFAULT_BACKEND_BASE_URL + } +} + +export function saveBackendBaseUrl(baseUrl: string): string { + const normalized = normalizeBackendBaseUrl(baseUrl) + try { + wx.setStorageSync(BACKEND_BASE_URL_STORAGE_KEY, normalized) + } catch {} + return normalized +} + +export function loadBackendAuthTokens(): BackendAuthTokens | null { + try { + const stored = wx.getStorageSync(BACKEND_AUTH_TOKENS_STORAGE_KEY) + if (!stored || typeof stored !== 'object') { + return null + } + + const accessToken = normalizeString((stored as Record).accessToken) + const refreshToken = normalizeString((stored as Record).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 {} +} diff --git a/miniprogram/utils/backendLaunchAdapter.ts b/miniprogram/utils/backendLaunchAdapter.ts new file mode 100644 index 0000000..e09692a --- /dev/null +++ b/miniprogram/utils/backendLaunchAdapter.ts @@ -0,0 +1,21 @@ +import { type GameLaunchEnvelope } from './gameLaunch' +import { type BackendLaunchResult } from './backendApi' + +export function adaptBackendLaunchResultToEnvelope(result: BackendLaunchResult): GameLaunchEnvelope { + return { + config: { + configUrl: result.launch.config.configUrl, + configLabel: result.launch.config.configLabel, + configChecksumSha256: result.launch.config.configChecksumSha256 || null, + releaseId: result.launch.config.releaseId, + routeCode: result.launch.config.routeCode || null, + }, + business: { + source: result.launch.business.source === 'direct-event' ? 'direct-event' : 'custom', + eventId: result.launch.business.eventId, + sessionId: result.launch.business.sessionId, + sessionToken: result.launch.business.sessionToken, + sessionTokenExpiresAt: result.launch.business.sessionTokenExpiresAt, + }, + } +} diff --git a/readme-develop.md b/readme-develop.md index ad509a2..7a06959 100644 --- a/readme-develop.md +++ b/readme-develop.md @@ -76,9 +76,9 @@ 位于 [tools/mock-gps-sim](D:/dev/cmr-mini/tools/mock-gps-sim): - [server.js](D:/dev/cmr-mini/tools/mock-gps-sim/server.js):本地 HTTP + WebSocket 服务 -- [public/index.html](D:/dev/cmr-mini/tools/mock-gps-sim/public/index.html):模拟器 UI -- [public/simulator.js](D:/dev/cmr-mini/tools/mock-gps-sim/public/simulator.js):地图、路径、心率模拟逻辑 -- [public/style.css](D:/dev/cmr-mini/tools/mock-gps-sim/public/style.css):布局与样式 +- [public/index.html](D:/dev/cmr-mini/tools/mock-gps-sim/public/index.html):新版模拟器工作台 UI +- [public/simulator.js](D:/dev/cmr-mini/tools/mock-gps-sim/public/simulator.js):地图、路径、心率、日志、多通道模拟逻辑 +- [public/workbench.css](D:/dev/cmr-mini/tools/mock-gps-sim/public/workbench.css):新版工作台布局与样式 --- @@ -557,6 +557,7 @@ HUD 当前颜色由 telemetry 驱动。 { "type": "mock_gps", "timestamp": 1711267200000, + "channelId": "runner-a", "lat": 31.2304, "lon": 121.4737, "accuracyMeters": 6, @@ -597,10 +598,13 @@ HUD 当前颜色由 telemetry 驱动。 当前已经调整为: +- 顶部显示全局连接状态与全局模拟通道号 - 左侧控制面板独立滚动 -- 右侧地图固定不动 +- 中间地图固定作为主观察区 +- 右侧保留运行摘要、当前位置、最近事件 +- 右下使用可缩放的调试日志浮层 -这样更适合长面板配置和路径编辑,不会让地图区跟着滚动。 +这样更适合长面板配置、多人联调隔离和过程日志观察,不会让地图区跟着滚动。 --- @@ -899,7 +903,7 @@ flowchart TD - [server.js](D:/dev/cmr-mini/tools/mock-gps-sim/server.js) - [index.html](D:/dev/cmr-mini/tools/mock-gps-sim/public/index.html) - [simulator.js](D:/dev/cmr-mini/tools/mock-gps-sim/public/simulator.js) -- [style.css](D:/dev/cmr-mini/tools/mock-gps-sim/public/style.css) +- [workbench.css](D:/dev/cmr-mini/tools/mock-gps-sim/public/workbench.css) --- @@ -1020,15 +1024,35 @@ GPS: { "type": "mock_heart_rate", "timestamp": 1711267200000, + "channelId": "runner-a", "bpm": 148 } ``` -两者当前共用同一个 WebSocket 入口: +调试日志: -- `.../mock-gps` +```json +{ + "type": "debug-log", + "timestamp": 1711267200000, + "channelId": "runner-a", + "scope": "gps-logo", + "level": "info", + "message": "logo ready" +} +``` -这是当前阶段为了降低复杂度做的统一通道设计,后面如果模拟消息种类继续增加,再考虑独立通道或消息总线拆分。 +当前三条链已经拆开: + +- GPS:`.../mock-gps` +- 心率:`.../mock-hr` +- 日志:`.../debug-log` + +同时三条链统一使用同一个 `channelId` 做最小隔离: + +- 模拟器顶部设置一个全局“模拟通道号” +- 小程序调试面板也设置同一个“模拟通道号” +- 只有 `channelId` 精确匹配的数据才会被消费 ### 18.5 当前推荐的联调顺序 diff --git a/tools/mock-gps-sim/README.md b/tools/mock-gps-sim/README.md index 78b982d..6a6be95 100644 --- a/tools/mock-gps-sim/README.md +++ b/tools/mock-gps-sim/README.md @@ -41,6 +41,8 @@ group-01 然后在小程序调试面板里把“模拟通道号”也配成同一个值。 +当前“模拟通道号”位于工作台顶部,属于全局调试参数,不再归属某个单独分组。 + ## 当前能力 - 直接载入 `game.json` @@ -83,6 +85,13 @@ ws://127.0.0.1:17865/debug-log 当前 UI 会通过独立日志通道把这类消息显示到“调试日志”区域。 +日志区域当前是: + +- 地图右下角浮层 +- 可展开 / 缩小 +- 支持按 `scope` 过滤 +- 按当前 `channelId` 隔离显示 + 第一阶段主要用于承接: - `gps-logo` diff --git a/tools/mock-gps-sim/server.js b/tools/mock-gps-sim/server.js index e45c2ba..78b8fbb 100644 --- a/tools/mock-gps-sim/server.js +++ b/tools/mock-gps-sim/server.js @@ -106,6 +106,11 @@ function isDebugLogPayload(payload) { && typeof payload.message === 'string' } +function normalizeChannelId(value) { + const trimmed = String(value || '').trim() + return trimmed || 'default' +} + async function handleProxyRequest(request, response) { const requestUrl = new URL(request.url || '/', `http://127.0.0.1:${PORT}`) const targetUrl = requestUrl.searchParams.get('url') @@ -533,6 +538,7 @@ gpsWss.on('connection', (socket) => { const outgoing = JSON.stringify({ type: 'mock_gps', timestamp: Number.isFinite(parsed.timestamp) ? parsed.timestamp : Date.now(), + channelId: normalizeChannelId(parsed.channelId), lat: Number(parsed.lat), lon: Number(parsed.lon), accuracyMeters: Number.isFinite(parsed.accuracyMeters) ? Number(parsed.accuracyMeters) : 6, @@ -566,6 +572,7 @@ heartRateWss.on('connection', (socket) => { const outgoing = JSON.stringify({ type: 'mock_heart_rate', timestamp: Number.isFinite(parsed.timestamp) ? parsed.timestamp : Date.now(), + channelId: normalizeChannelId(parsed.channelId), bpm: Math.max(1, Math.round(Number(parsed.bpm))), }) gatewayBridge.publish(JSON.parse(outgoing)) @@ -595,6 +602,7 @@ debugLogWss.on('connection', (socket) => { const outgoing = JSON.stringify({ type: 'debug-log', timestamp: Number.isFinite(parsed.timestamp) ? parsed.timestamp : Date.now(), + channelId: normalizeChannelId(parsed.channelId), scope: String(parsed.scope || 'app').slice(0, 64), level: parsed.level === 'warn' || parsed.level === 'error' ? parsed.level : 'info', message: String(parsed.message || '').slice(0, 400), diff --git a/typings/index.d.ts b/typings/index.d.ts index 3632a74..2409417 100644 --- a/typings/index.d.ts +++ b/typings/index.d.ts @@ -4,6 +4,8 @@ interface IAppOption { globalData: { userInfo?: WechatMiniprogram.UserInfo, telemetryPlayerProfile?: import('../miniprogram/game/telemetry/playerTelemetryProfile').PlayerTelemetryProfile | null, + backendBaseUrl?: string | null, + backendAuthTokens?: import('../miniprogram/utils/backendAuth').BackendAuthTokens | null, } userInfoReadyCallback?: WechatMiniprogram.GetUserInfoSuccessCallback, }