diff --git a/doc/MyToDo.md b/doc/MyToDo.md new file mode 100644 index 0000000..f08dc4f --- /dev/null +++ b/doc/MyToDo.md @@ -0,0 +1,333 @@ + +结果页会根据客户的要求不停的变换,用什么方案能实现这个需求,其实其他的弹出内容也都存在这个问题,样式,内容都时根据客户需求变化的,怎样一种方案设计比较好呢? + + +我想改造下GPS模拟器,做成一个数据中转程序,这样后面的开发模拟,家长端监控,场控,数据回放就都能支持了,类似于路由器,只中转不保存数据,你觉得可行否?或者还有没有更好的方案? +技术栈要轻量,健壮,性能第一,类似软路由这类应用 + + +目前已经把卡片架构的底座搭出来了,进度可以概括成: + +已经完成的 +1. 原生内容卡主链已经成立 +现在控制点内容不再是单一硬编码弹层,而是已经支持: + +title / body +clickTitle / clickBody +autoPopup +once +priority +template +而且: + +start-1 / control-N / finish-1 都可配置 +起终点同位置时,点击内容优先级也已经处理了 +2. 原生内容卡模板化已经落地 +目前已经有 3 种原生模板: + +minimal +story +focus +默认策略也定了: + +起点/终点:focus +普通点:story +3. 内容展示机制已经不是“弹了就完” +现在已经有: + +自动弹 +手动查看入口 +队列 +优先级覆盖 +once 本局只展示一次 +也就是说,已经开始像正式的“内容体验层”了。 + +4. H5 分工已经定案并接进去了 +这条边界已经试清楚了: + +即时内容弹窗:原生 +H5:详情页 / 互动任务页 / 结果页 +H5 不再直接顶替原生弹窗 +当前实际链路是: + +原生内容卡 -> 查看详情 CTA -> H5 详情页 + +5. H5 容器页已经能用了 +企业主体切过来后: + +web-view 已经能打开 +最小测试链路已经通了 +但也验证了一个结论: +小程序 web-view 不适合做局部弹窗内容区 +适合整页详情/任务/结果页 +现在还没完全做完的 +1. 原生内容卡还不够“组件化” +现在模板化已经有了,但还没完全形成更正式的: + +卡片 ViewModel +卡片模板注册表 +模板渲染规范 +现在还是“已可用,但还不够体系化”。 + +2. CTA 还比较初级 +现在 查看详情 能用了,但还缺: + +CTA 文案配置化 +CTA 图标 / 样式分级 +多动作支持 +查看详情 +开始互动 +拍照任务 +语音留言 +3. 结果页卡片体系还没完全接完 +结果页最小闭环做过,但还没真正进入: + +原生模板化 +H5 增强版 +数据模型与展示模板分层 +4. 原生有限 DSL 还没开始正式做 +我们已经把方向定了: + +原生模板 +原生有限 DSL +H5 扩展 +但目前真正落代码的主要还是原生模板层,DSL 还没开始。 + +所以当前阶段判断 +如果按“卡片架构”来讲,我会定义成: + +已经完成了第一阶段 60%~70%: + +主链和边界已经对了 +配置和模板能力已经起了 +还差把它做成真正可长期扩展的“卡片系统” +这周最该做什么 +我建议这周把这 3 件事做完,卡片架构就算基本立住了: + +1. 把原生内容卡正式收成组件化结构 +目标: + +ContentCardModel +模板映射 +渲染入口统一 +2. 把 CTA 体系做出来 +目标: + +CTA 类型 +CTA 文案 +CTA 动作模型 +原生 CTA -> H5 详情/任务 +3. 把结果页也纳入同一套卡片/模板思路 +目标: + +原生最小结果页模板化 +和内容卡共用部分卡片能力 +为 H5 结果页增强留接口 +一句话结论 +现在卡片架构已经从“零散弹层”进化到“有配置、有模板、有 H5 分工的内容系统雏形”了。 +这周如果继续往下做,最值得的是把: + +原生内容卡组件化 +CTA 动作体系 +结果页模板化 +这三件事补齐。 + +如果你愿意,我下一步可以直接给你列一版: +本周卡片架构实施清单,按文件和优先级拆开。 + + +好的,列个清单看看 + + +可以,按这周目标,我建议清单拆成 3 层。 + +P0 本周必须完成 + +原生内容卡模型收口 +目标: +把当前散在地图页状态里的内容卡字段收成统一 ContentCardModel +明确字段: +template +title +body +motionClass +priority +once +cta +建议落点: + +D:\dev\cmr-mini\miniprogram\game\experience +D:\dev\cmr-mini\miniprogram\engine\map\mapEngine.ts +CTA 动作体系 +目标: +不再只有一个硬编码 查看详情 +统一成动作模型,例如: +open-detail +open-task +close +CTA 支持: +label +type +target +建议先只做一主一辅两个按钮位。 + +原生内容卡模板注册表 +目标: +把 minimal / story / focus 从“字符串分支”变成正式模板映射 +先明确每个模板: +标题样式 +正文字数策略 +CTA 区布局 +动画等级差异 +结果页最小模板化 +目标: +把当前结果页按内容卡思路收成一个稳定模板 +至少区分: +result-minimal +result-rich 预留 +P1 本周最好完成 + +配置字典补齐卡片字段 +目标: +文档里明确: +template +contentExperience +clickExperience +cta +默认逻辑 +样例配置同步更新 +文件: + +D:\dev\cmr-mini\doc\config-option-dictionary.md +D:\dev\cmr-mini\event\classic-sequential.json +D:\dev\cmr-mini\event\score-o.json +内容卡与结果页的 ViewModel 分层 +目标: +内容卡和结果页都先走 ViewModel +页面不直接拼业务字段 +建议模型: + +ContentCardModel +ResultCardModel +动画字典补卡片节点 +目标: +把以下节点补进动画字典: +内容卡进入 +内容卡关闭 +CTA 点击 +结果页进入 +结果主数字进入 +P2 可以放下周 + +原生有限 DSL 第一版 +目标: +不直接做万能布局 +先支持有限区块组合 +H5 详情/任务页 CTA 扩展 +目标: +不只 查看详情 +后面支持: +拍照打卡 +语音留言 +开始互动 +结果页 H5 增强版入口 +目标: +原生结果页保底 +H5 作为增强页入口 +推荐执行顺序 + +ContentCardModel +CTA 动作体系 +模板注册表 +结果页模板化 +文档和样例同步 +动画字典补节点 +一句话 +这周先把“内容卡是一个系统”这件事做实,再把结果页纳入同一思路。 +如果你要,我下一步可以直接按这个清单开始做 P0-1:先收 ContentCardModel + CTA 动作体系。 + + +CTA是啥来 + + + +CTA 就是: + +Call To Action +中文可以理解成: + +行动按钮 +引导操作 +下一步动作 +在你这个项目里,最直观的例子就是内容卡上的: + +查看详情 +关闭 +以后还可以是: + +开始互动 +拍照打卡 +语音留言 +查看结果 +继续前进 +也就是说,CTA 不是内容本身,而是用户看到卡片后,可以立刻执行的动作入口。 + +在你现在这个项目里 +例如这张卡: + +标题:比赛开始 +正文:从这里出发,先熟悉地图方向... +CTA: +查看详情 +关闭 +所以“CTA 动作体系”说白了就是: + +把这些按钮从临时写死,收成统一的动作模型。 + +比如以后配置里可以变成: + +{ + "cta": { + "type": "open-detail", + "label": "查看详情" + } +} +一句话: +CTA 就是卡片上引导用户下一步操作的按钮。 + + + +1. 先帮我指定一份游戏的最小配置模板,也就是说,去掉所有选配项,最小的游戏可跑配置,每个选项要带说明 +2. 再给我一份当前开发状态下最全的配置模板,每个选项,参数要带说明 +3. 将这2个文档落成文件 + + +先把原生内容卡收口,这是系统默认配置,所有配置不起作用得时候,他们兜底, +在原生卡片上,CTA可以有拍照,语音,开启H5深度内容等能力。 +其中有一个自动出题目的能力,就是2-3位数得加减运算,并提供3个备选项让客户选择,同时有个倒计时,倒计时结束或选择答案后弹出对错提示。答对题目有积分,答错或没答没有积分,正确打点后也收割改点积分,顺序赛默认是1积分,积分赛根据实际点位积分来。 +先实现以上功能 + + +接着实现几个功能,细节的问题稍后说 + +打卡点的样式我需要几套样式,现在是单一标准空心圆圈,太枯燥。 +我有几个想法: +1. 顺序赛,可以定制打卡点样式,可以定制路线腿样式 +2. 积分赛,可以定制打卡点样式,不同积分可以不同颜色。 + +基于上面的想法,你有好的实施方案吗?先讨论 + + +好的,测试可以,接着讨论下一个问题 +轨迹,我的想法是,用户轨迹有三种形式,无轨迹,全轨迹,拖尾轨迹,如果不走,轨迹最终消失,就是轨迹指着GPS点跑,你有什么方案?先讨论 + +轨迹选项:无,彗尾,全轨迹 +轨迹样式: +尾巴:短,中,长 +颜色:可以放8-16种基本色,亮色 + +再说GPS点,用户位置的GPS点样式也是定制的,先说默认样式,可以定制显示与不显示。现在的样式有点呆和粗糙,我想给GPS点上加一个方向指示的小三角,跟着朝向转,你能理解吗?另外GPS点也有3种大小,用户自己可设置,默认中等大小即可,颜色也可设置。最重要的是,根据我们的经验,很多客户希望可以定制这个定位点,具有商业属性,例如换成商家的LOGO,这个有方案吗?先讨论。 + +再深一点,自定GPS点能不能做成动画的,停止一个动画,跑起来又是一个动画,甚至可以做些额外的动作。 + +开个小差,我想临时加个功能,在咱的GPS模拟器加个日志输出功能,把调试期间不方便打在调试面板里的信息输出到模拟器上,你觉得如何?这样更方便后期调试?如果可以先给个方案 \ No newline at end of file diff --git a/doc/config/线上业务接入边界方案.md b/doc/config/线上业务接入边界方案.md new file mode 100644 index 0000000..77cfc9b --- /dev/null +++ b/doc/config/线上业务接入边界方案.md @@ -0,0 +1,338 @@ +# 线上业务接入边界方案 + +## 1. 目的 + +本文档定义小程序接入线上业务 API 时的架构边界,确保以下原则始终成立: + +- 游戏玩法仍然完全由配置驱动 +- 线上 API 只负责业务编排,不负责定义或污染玩法 +- 地图引擎和规则运行时可以继续独立于业务系统运行 +- 本地 demo、离线配置、线上赛事三种入口可以共存 + +本文档适用于以下接入范围: + +- 用户管理 +- 登录与鉴权 +- 首页卡片 +- 赛事详情 +- 地图详情 +- Event 详情 +- 报名 +- launch 启动 +- 后续 session 上报与查询 + +## 2. 核心结论 + +线上接入后,系统仍保持两层结构: + +- 业务层:决定“用户是谁、能进什么、当前启动什么” +- 游戏层:决定“地图怎么画、规则怎么跑、控制点怎么判定、体验怎么表现” + +两层之间只允许通过一个明确的启动模型通信,不允许业务 API 对游戏规则对象做直接写入。 + +一句话定义: + +> API 负责发放启动上下文,配置负责定义游戏本身。 + +## 3. 分层原则 + +### 3.1 业务层职责 + +业务层负责: + +- 登录与 token 管理 +- 用户资料与身体数据 +- 卡片、赛事、地图、Event 列表与详情 +- 报名资格校验 +- launch 启动资格与 session 凭证发放 +- 后续成绩、轨迹、历史记录上传与查询 + +业务层可以决定: + +- 是否允许用户启动 +- 当前应启动哪个 Event +- 当前应加载哪份配置或 manifest +- 当前启动绑定的 `session_id`、`session_token` + +业务层不可以决定: + +- 控制点布局 +- 游戏规则 +- 打卡判定 +- 跳点规则 +- 引导、音效、表现策略 +- 游戏内内容卡的结构和行为定义 + +### 3.2 游戏层职责 + +游戏层负责: + +- 地图资源加载 +- KML / course / 配置解析 +- `GameDefinition` 构建 +- 规则插件运行 +- 传感器接入 +- HUD、反馈、结果页、本地统计 +- 游戏内 session 生命周期 + +游戏层只认配置和本地运行态,不认业务 API 对象。 + +游戏层不应该直接出现以下业务字段: + +- `competition_id` +- `registration_status` +- `access_token` +- `refresh_token` +- `Authorization` +- `user_id` + +业务字段可以存在于页面壳层或业务服务层,但不进入规则层。 + +## 4. 唯一允许的层间模型 + +业务层和游戏层之间,统一通过 `GameLaunchEnvelope` 通信。 + +建议结构如下: + +```ts +interface GameLaunchEnvelope { + config: { + configUrl: string + configLabel: string + configChecksumSha256?: string | null + releaseId?: string | null + routeCode?: string | null + } + business: { + source: 'demo' | 'competition' | 'direct-event' | 'custom' + competitionId?: string | null + eventId?: string | null + launchRequestId?: string | null + participantId?: string | null + sessionId?: string | null + sessionToken?: string | null + sessionTokenExpiresAt?: string | null + realtimeEndpoint?: string | null + realtimeToken?: string | null + } | null +} +``` + +解释: + +- `config` 是游戏层真正消费的输入 +- `business` 是业务壳保留的上下文 +- 地图页可以同时拿到两者,但地图引擎只读取 `config` + +## 5. 推荐启动链路 + +### 5.1 Demo 启动 + +适用于本地调试、离线测试、玩法验证。 + +流程: + +1. 页面构建 demo `GameLaunchEnvelope` +2. `config.configUrl` 指向 demo 配置 +3. `business.source = 'demo'` +4. 跳转地图页 +5. 地图页加载配置并启动引擎 + +特点: + +- 不依赖业务 API +- 不依赖登录 +- 不依赖 session + +### 5.2 线上赛事启动 + +适用于正式业务入口。 + +流程: + +1. 业务页请求赛事 / Event 详情 +2. 用户在业务页完成登录、资格校验、报名确认 +3. 用户点击开始,业务页调用 `launch` +4. 后端返回 `session_id`、`session_token`、`release_id`、`manifest_url`、`route_code` +5. 业务层把上述信息转换为 `GameLaunchEnvelope` +6. 地图页只根据 `config` 载入配置 +7. 业务壳层保存 `business` 上下文供后续上报使用 + +注意: + +- `launch` 是业务启动,不等于规则层 `startSession` +- 规则层本地开始游戏,仍由引擎按配置驱动 + +### 5.3 线上直入地图启动 + +适用于地图详情或 Event 直入。 + +流程与赛事入口基本一致,区别仅在于: + +- 入口页不同 +- 资格校验更轻 +- `business.source = 'direct-event'` + +## 6. manifest 的角色 + +后端提供的 `manifest_url` 不应直接变成规则层对象。 + +推荐做法: + +- 业务层或适配层下载 manifest +- 将 manifest 解析并映射到当前配置体系 +- 输出为当前引擎已支持的配置入口 + +manifest 是“线上发布描述”,不是“规则运行对象”。 + +建议把 manifest 适配理解为一个编译过程: + +- 输入:后端发布描述 +- 输出:当前配置驱动引擎可识别的配置资源 + +## 7. 目录建议 + +建议按三层组织代码: + +```text +miniprogram/ + services/ + http.ts + client-api.ts + auth.ts + business/ + launch/ + launchBuilder.ts + launchStore.ts + manifestAdapter.ts + utils/ + gameLaunch.ts + pages/ + login/ + home/ + competition-detail/ + event-detail/ + map/ +``` + +说明: + +- `services` 只处理 API 通信 +- `business/launch` 只做业务到配置的适配 +- `utils/gameLaunch.ts` 定义启动模型和页面跳转协议 +- `pages/map` 只做配置加载和游戏承载 + +## 8. 代码边界约束 + +### 8.1 允许进入地图页的内容 + +允许进入地图页: + +- `GameLaunchEnvelope` +- `configUrl` +- `configLabel` +- `releaseId` +- `routeCode` +- `sessionToken` + +但地图页内部还要继续区分: + +- 引擎可读:`config` +- 业务壳可读:`business` + +### 8.2 不允许进入引擎的内容 + +以下内容禁止进入 `MapEngine`、`GameRuntime`、`GameDefinition`: + +- 用户信息 +- 登录态 token +- 报名状态 +- 业务接口返回原始对象 +- 赛事详情原始 JSON +- Event 详情原始 JSON + +### 8.3 上报也走旁路 + +后续若接 `punches`、`finish`、`session-uploads`,建议流程如下: + +1. 游戏层产生本地事件 +2. 页面壳层或业务 service 订阅这些事件 +3. 由业务层决定是否调用 API + +不要在规则层里直接 `wx.request`。 + +## 9. 当前项目的落地点 + +当前项目已具备以下基础: + +- 地图页是配置驱动入口 +- `remoteMapConfig.ts` 负责远程配置加载 +- `MapEngine` 负责本地规则与表现运行 +- 已新增 `utils/gameLaunch.ts` 作为启动边界模型 + +当前建议继续保持: + +- `MapEngine` 只接 `RemoteMapConfig` +- 地图页只从 `GameLaunchEnvelope.config` 获取配置入口 +- 业务上下文保留在地图页外层或页面壳层 + +## 10. 分阶段落地建议 + +### 阶段一:边界固化 + +目标: + +- 地图页彻底改为只接 `GameLaunchEnvelope` +- demo 启动与线上启动走同一套入口协议 + +验收标准: + +- 不再依赖页面内硬编码 URL 作为唯一启动方式 +- 业务字段不进入引擎 + +### 阶段二:业务壳接入 + +目标: + +- 接入登录、首页卡片、赛事详情、Event 详情、报名、launch + +验收标准: + +- 能从业务页成功进入地图页 +- 地图仍由配置驱动启动 + +### 阶段三:manifest 适配 + +目标: + +- 将后端 `manifest_url` 适配为当前配置体系可消费的输入 + +验收标准: + +- 同一个 Event 的线上发布内容可稳定映射为游戏配置入口 + +### 阶段四:session 下游联通 + +目标: + +- 补充上报、完成、结果、历史查询 + +验收标准: + +- 业务链路打通 +- 规则层仍不直接依赖业务 API + +## 11. 必须长期坚持的规则 + +- 业务 API 不定义玩法 +- 配置文件不承载用户态 +- 引擎不依赖登录状态 +- 引擎不依赖报名状态 +- 业务页不直接修改 `GameDefinition` +- 规则层不直接请求业务 API + +如果后续出现需求需要绕过这几条规则,应视为架构变更,不应当作普通功能迭代处理。 + +## 12. 一句话总结 + +线上系统负责“把用户送进正确的一局游戏”,配置系统负责“定义这局游戏是什么”。 diff --git a/doc/debug/模拟器多通道联调最小方案.md b/doc/debug/模拟器多通道联调最小方案.md new file mode 100644 index 0000000..512ccaf --- /dev/null +++ b/doc/debug/模拟器多通道联调最小方案.md @@ -0,0 +1,141 @@ +# 模拟器多通道联调最小方案 + +## 目标 + +在不引入房间系统、不增加复杂编排的前提下,让同一台本地模拟器服务能够同时承接多路联调数据,并保证不同联调对象之间的数据不串线。 + +## 方案 + +统一增加一个字段: + +- `channelId` + +三条链都带这个字段: + +- `mock-gps` +- `mock-hr` +- `debug-log` + +## 设计原则 + +- 不做 room / participant 管理 +- 不做多人控制台编排 +- 只解决“数据隔离” +- GPS、心率、日志三条链统一使用同一个模拟通道号 + +## 默认值 + +默认通道号: + +```json +"default" +``` + +空值、缺失值都归一化成: + +```json +"default" +``` + +## 消息格式 + +### GPS + +```json +{ + "type": "mock_gps", + "timestamp": 1712345678901, + "channelId": "runner-a", + "lat": 31.2304, + "lon": 121.4737, + "accuracyMeters": 6, + "speedMps": 2.4, + "headingDeg": 92 +} +``` + +### 心率 + +```json +{ + "type": "mock_heart_rate", + "timestamp": 1712345678901, + "channelId": "runner-a", + "bpm": 148 +} +``` + +### 调试日志 + +```json +{ + "type": "debug-log", + "timestamp": 1712345678901, + "channelId": "runner-a", + "scope": "gps-logo", + "level": "info", + "message": "logo ready", + "payload": { + "src": "https://example.com/logo.png" + } +} +``` + +## 模拟器侧 + +新版工作台提供一个统一输入: + +- `模拟通道号` + +它会同时作用于: + +- GPS 发送 +- 心率发送 +- 日志过滤 + +也就是说,一个模拟器页面实例默认对应一个通道。 + +## 小程序侧 + +调试面板提供一个统一输入: + +- `模拟通道号` + +保存后会同步给: + +- 定位模拟接收过滤 +- 心率模拟接收过滤 +- logger 发送通道 + +“一键连接开发调试源”会带上当前通道号一起生效。 + +## 接收规则 + +接收端统一按归一化后的 `channelId` 精确匹配: + +- 收到的消息 `channelId` 与当前模拟通道号一致才消费 +- 不一致直接忽略 + +缺失 `channelId` 的旧消息,按 `default` 处理。 + +## 适用场景 + +- 两台手机同时接同一台本地模拟器服务 +- 一个调试人员同时开多台模拟器页面 +- 同时联调多个儿童设备 + +## 当前边界 + +这套最小方案只解决: + +- 多路数据隔离 + +不解决: + +- 房间管理 +- 成员列表 +- 批量启动/停止 +- 同步起跑 +- 多控制台协作 + +如果后面真的需要这些,再升级到房间模型。 diff --git a/doc/debug/模拟器控制面板重构方案.md b/doc/debug/模拟器控制面板重构方案.md index 9e650da..cb7bc27 100644 --- a/doc/debug/模拟器控制面板重构方案.md +++ b/doc/debug/模拟器控制面板重构方案.md @@ -2,22 +2,22 @@ ## 目标 -在不破坏现有老版面板的前提下,新增一套新版控制面板,用于承接更复杂的开发调试工作流。 +在不增加第二套历史 UI 负担的前提下,整理出一套新版控制面板,用于承接更复杂的开发调试工作流。 重构目标: -- 保留老版入口,确保已有使用习惯不受影响 - 新增工作台式面板,提升连接、控制、观察、排障效率 - 继续复用现有模拟器脚本和 websocket 协议,避免维护两套逻辑 +- 最终只保留新版入口,避免长期双份维护 ## 设计原则 -1. 新旧并行 +1. 单入口维护 - 新版入口使用 `/` - - 旧版入口保留在 `/v1/` + - 模拟器只保留一个工作台入口 2. 逻辑复用 - - 两个页面共用 `simulator.js` - - 只通过不同 HTML 布局和 CSS 风格区分 + - 继续复用 `simulator.js` + - 只维护一套 HTML 布局和 CSS 风格 3. 面向调试流程 - 连接优先 - 控制第二 @@ -44,7 +44,6 @@ - 心率模拟连接状态 - 调试日志连接状态 - 一键连接开发调试源 -- 新旧面板切换入口 ### 2. 左侧控制区 @@ -83,26 +82,16 @@ - 面积更大 - 便于边看地图边看日志 -## 与旧版的关系 - -旧版和新版应同时可用: - -- 新版作为默认工作台 -- 旧版继续作为稳定基线 -- 问题排查时可快速回退旧版 - ## 实施顺序 1. 根路径切换到新版工作台 2. 新增新版样式 `workbench.css` 3. 复用现有 `simulator.js` -4. 旧版页面迁移到 `/v1/` -5. 在旧版和新版之间互相添加跳转入口 -6. 更新 README 和调试文档索引 +4. 清理历史入口和旧文案 +5. 更新 README 和调试文档索引 ## 验收标准 -- 老版页面继续正常工作 - 新版页面可完整使用现有 GPS、心率、日志、路径、网关能力 -- 两个页面共用同一套 websocket 协议和数据逻辑 -- 用户可以在两个版本之间切换 +- 模拟器只保留一个工作台入口 +- websocket 协议和调试逻辑继续复用 diff --git a/doc/debug/模拟器调试日志方案.md b/doc/debug/模拟器调试日志方案.md index 57e657b..5025faf 100644 --- a/doc/debug/模拟器调试日志方案.md +++ b/doc/debug/模拟器调试日志方案.md @@ -2,11 +2,11 @@ ## 目标 -复用现有 GPS 模拟器 websocket,在不污染地图调试面板的前提下,把高频、临时、开发期日志输出到外部模拟器。 +复用现有模拟器服务,在不污染地图调试面板的前提下,把高频、临时、开发期日志输出到外部模拟器。 第一阶段只做最小闭环: -- 复用 `tools/mock-gps-sim` 现有 websocket +- 复用 `tools/mock-gps-sim` 现有服务 - 增加 `debug-log` 消息类型 - 小程序侧增加最小 logger - 第一批只发送 `gps-logo` 范围日志 @@ -27,6 +27,7 @@ { "type": "debug-log", "timestamp": 1712345678901, + "channelId": "runner-a", "scope": "gps-logo", "level": "info", "message": "wx.getImageInfo success", @@ -45,6 +46,8 @@ 毫秒时间戳 - `scope` 日志分类,例如 `gps-logo`、`h5`、`compass` +- `channelId` + 日志所属模拟通道,用于多人联调时隔离不同设备的过程日志 - `level` `info / warn / error` - `message` @@ -123,4 +126,3 @@ ## 当前结论 先把 `gps-logo` 调试链打通,再回头用模拟器日志查 logo 为什么不显示,比继续把临时字段堆在调试面板里更稳。 - diff --git a/doc/debug/调试文档索引.md b/doc/debug/调试文档索引.md index f77af8d..0fa0edb 100644 --- a/doc/debug/调试文档索引.md +++ b/doc/debug/调试文档索引.md @@ -10,11 +10,13 @@ ## 当前主文档 - [模拟器控制面板重构方案](/D:/dev/cmr-mini/doc/debug/模拟器控制面板重构方案.md) - 用于说明新版模拟器工作台布局、新旧并行策略和重构目标。 + 用于说明新版模拟器工作台布局和重构目标。 - [平台能力说明](/D:/dev/cmr-mini/doc/debug/平台能力说明.md) 用于记录主体能力、`web-view`、传感器等平台边界。 - [模拟器调试日志方案](/D:/dev/cmr-mini/doc/debug/模拟器调试日志方案.md) 用于说明 mock simulator 的日志旁路与 `debug-log` 协议。 +- [模拟器多通道联调最小方案](/D:/dev/cmr-mini/doc/debug/模拟器多通道联调最小方案.md) + 用于说明 GPS / 心率 / 日志三条链统一按 `channelId` 隔离的最小实现。 - [传感器当前状态总结](/D:/dev/cmr-mini/doc/debug/传感器现状总结.md) 用于看当前已确认的传感器状态与调试结论。 - [罗盘问题记录](/D:/dev/cmr-mini/doc/debug/罗盘排障记录.md) @@ -26,11 +28,13 @@ 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. [compass-debugging-notes.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) ## 使用建议 - 看“当前限制”和“为什么会这样”,优先看平台能力说明。 - 看“现在系统是什么状态”,优先看传感器现状总结。 - 看“以后日志怎么打”,优先看模拟器日志方案。 +- 看“多人联调怎么隔离”,优先看模拟器多通道联调最小方案。 - 看“为什么罗盘以前坏过”,再去看罗盘问题记录。 diff --git a/miniprogram/engine/debug/mockSimulatorDebugLogger.ts b/miniprogram/engine/debug/mockSimulatorDebugLogger.ts index 93cf368..ceb5511 100644 --- a/miniprogram/engine/debug/mockSimulatorDebugLogger.ts +++ b/miniprogram/engine/debug/mockSimulatorDebugLogger.ts @@ -14,12 +14,18 @@ export interface MockSimulatorDebugLoggerState { export interface MockSimulatorDebugLogEntry { type: 'debug-log' timestamp: number + channelId?: string scope: string level: MockSimulatorDebugLogLevel message: string payload?: Record } +function normalizeMockSimulatorChannelId(rawChannelId: string | null | undefined): string { + const trimmed = String(rawChannelId || '').trim() + return trimmed || 'default' +} + function normalizeMockSimulatorLogUrl(rawUrl: string): string { const trimmed = String(rawUrl || '').trim() if (!trimmed) { @@ -45,6 +51,7 @@ export class MockSimulatorDebugLogger { connected: boolean connecting: boolean url: string + channelId: string queue: MockSimulatorDebugLogEntry[] onStateChange?: (state: MockSimulatorDebugLoggerState) => void @@ -54,6 +61,7 @@ export class MockSimulatorDebugLogger { this.connected = false this.connecting = false this.url = DEFAULT_DEBUG_LOG_URL + this.channelId = 'default' this.queue = [] this.onStateChange = onStateChange } @@ -114,6 +122,10 @@ export class MockSimulatorDebugLogger { this.connect() } + setChannelId(channelId: string): void { + this.channelId = normalizeMockSimulatorChannelId(channelId) + } + log( scope: string, level: MockSimulatorDebugLogLevel, @@ -127,6 +139,7 @@ export class MockSimulatorDebugLogger { const entry: MockSimulatorDebugLogEntry = { type: 'debug-log', timestamp: Date.now(), + channelId: this.channelId, scope, level, message, @@ -185,11 +198,13 @@ export class MockSimulatorDebugLogger { this.send({ type: 'debug-log', timestamp: Date.now(), + channelId: this.channelId, scope: 'logger', level: 'info', message: 'logger channel connected', payload: { url: this.url, + channelId: this.channelId, }, }) this.flush() diff --git a/project.config.json b/project.config.json index 9ead79b..0be1f95 100644 --- a/project.config.json +++ b/project.config.json @@ -46,5 +46,5 @@ "ignore": [], "include": [] }, - "appid": "wx9cca5c5a219a4f9c" + "appid": "wx0c8b079993bb9d7a" } \ No newline at end of file diff --git a/tmp/Client-API.md b/tmp/Client-API.md new file mode 100644 index 0000000..32e7828 --- /dev/null +++ b/tmp/Client-API.md @@ -0,0 +1,694 @@ +# Client API 前端联调文档 + +文档版本:v1.0.0 +最后更新:2026-03-31 +状态:联调中 + +## 1. 文档说明 + +本文档面向前端联调,描述当前 `client-api` 在代码中真实可用的接口。 + +约定: + +- 本文档优先级高于产品阶段的总草案;前端联调以本文档为准 +- 本文档只覆盖 `client-api` +- 每个接口会标记当前状态: + - `已实现,已联调` + - `已实现,未联调` + - `预留未就绪` + +## 2. 通用约定 + +### 2.1 Base Path + +- `client-api`:`/client/v1` + +### 2.2 成功响应 + +```json +{ + "request_id": "req_xxx", + "data": {} +} +``` + +### 2.3 失败响应 + +```json +{ + "request_id": "req_xxx", + "error": { + "code": "invalid_request", + "message": "xxx", + "details": {} + } +} +``` + +### 2.4 鉴权说明 + +- 登录后接口使用:`Authorization: Bearer {access_token}` +- `launch` 成功后会返回 `session_token` +- 但当前版本下游 `session` 相关接口尚未开放,因此前端暂时只需要保存 `session_token` + +### 2.5 枚举说明 + +`client_type` + +- `app` +- `wechat` + +`body_profile_status` + +- `pending` +- `completed` + +## 3. 已实现接口 + +### 3.1 `POST /client/v1/auth/sms/send` + +状态:`已实现,已联调` + +接口介绍: + +- 发送短信验证码 +- 当前已接阿里云短信 + +请求参数: + +| 字段 | 类型 | 必填 | 说明 | +| --- | --- | --- | --- | +| `scene` | `string` | 是 | 当前已使用 `client_login` | +| `country_code` | `string` | 是 | 国家码,如 `86` | +| `mobile` | `string` | 是 | 手机号 | +| `client_type` | `string` | 是 | `app` / `wechat` | +| `device_id` | `string` | 是 | 设备唯一标识 | + +请求示例: + +```json +{ + "scene": "client_login", + "country_code": "86", + "mobile": "15168870729", + "client_type": "app", + "device_id": "dev_iphone_001" +} +``` + +返回参数: + +| 字段 | 类型 | 说明 | +| --- | --- | --- | +| `cooldown_sec` | `int` | 重发冷却时间 | +| `expires_in_sec` | `int` | 验证码有效期 | + +### 3.2 `POST /client/v1/auth/login/sms` + +状态:`已实现,已联调` + +接口介绍: + +- 短信登录 +- 如果手机号首次登录,后端会自动创建: + - `users` + - `login_identities` + - 默认 `user_body_profiles` + - 首条 `user_body_profile_versions` + +请求参数: + +| 字段 | 类型 | 必填 | 说明 | +| --- | --- | --- | --- | +| `login_request_id` | `string` | 是 | 登录幂等号 | +| `country_code` | `string` | 是 | 国家码 | +| `mobile` | `string` | 是 | 手机号 | +| `sms_code` | `string` | 是 | 短信验证码 | +| `client_type` | `string` | 是 | `app` / `wechat` | +| `device_id` | `string` | 是 | 设备唯一标识 | + +返回参数: + +| 字段 | 类型 | 说明 | +| --- | --- | --- | +| `user_id` | `string` | 用户公开 ID | +| `access_token` | `string` | 登录态 token | +| `refresh_token` | `string` | refresh token | +| `login_method` | `string` | 当前为 `sms` | +| `body_profile_status` | `string` | `pending` / `completed` | +| `expires_in_sec` | `int` | `access_token` 有效期 | + +### 3.3 `POST /client/v1/auth/login/wechat` + +状态:`已实现,未联调` + +接口介绍: + +- 微信登录入口已预留 +- 当前仓库里 provider 仍以 mock 为主,尚未做真实联调 + +请求参数: + +| 字段 | 类型 | 必填 | 说明 | +| --- | --- | --- | --- | +| `login_request_id` | `string` | 是 | 登录幂等号 | +| `wechat_login_code` | `string` | 是 | 微信登录 code | +| `client_type` | `string` | 是 | 建议传 `wechat` | +| `device_id` | `string` | 是 | 设备唯一标识 | + +返回参数: + +| 字段 | 类型 | 说明 | +| --- | --- | --- | +| `user_id` | `string` | 用户公开 ID | +| `access_token` | `string` | 登录态 token | +| `refresh_token` | `string` | refresh token | +| `login_method` | `string` | 当前为 `wechat` | +| `body_profile_status` | `string` | `pending` / `completed` | +| `expires_in_sec` | `int` | `access_token` 有效期 | + +### 3.4 `POST /client/v1/auth/refresh` + +状态:`已实现,已联调` + +接口介绍: + +- 使用 refresh token 刷新 `access_token` + +请求参数: + +| 字段 | 类型 | 必填 | 说明 | +| --- | --- | --- | --- | +| `refresh_token` | `string` | 是 | refresh token | +| `client_type` | `string` | 是 | `app` / `wechat` | + +返回参数: + +| 字段 | 类型 | 说明 | +| --- | --- | --- | +| `access_token` | `string` | 新的登录态 token | +| `expires_in_sec` | `int` | 有效期 | + +### 3.5 `PUT /client/v1/me/body-profile` + +状态:`已实现,未联调` + +接口介绍: + +- 更新身体数据 +- 成功后会更新当前档案,并追加历史版本 + +认证: + +- 需要 `access_token` + +请求参数: + +| 字段 | 类型 | 必填 | 说明 | +| --- | --- | --- | --- | +| `gender` | `string` | 否 | `male` / `female` / `other` / `unknown` | +| `birth_date` | `string` | 否 | `YYYY-MM-DD` | +| `height_cm` | `number` | 否 | 身高,厘米 | +| `weight_kg` | `number` | 否 | 体重,千克 | +| `resting_heart_rate_bpm` | `int` | 否 | 静息心率 | +| `max_heart_rate_bpm` | `int` | 否 | 最大心率 | + +返回参数: + +| 字段 | 类型 | 说明 | +| --- | --- | --- | +| `user_id` | `string` | 用户公开 ID | +| `body_profile_status` | `string` | `pending` / `completed` | +| `completed_at` | `string` | 首次补全时间,未补全时可能为空 | + +### 3.6 `GET /client/v1/cards` + +状态:`已实现,已联调` + +接口介绍: + +- 首页卡片列表 + +查询参数: + +| 字段 | 类型 | 必填 | 说明 | +| --- | --- | --- | --- | +| `region_code` | `string` | 否 | 地区编码,当前可空 | + +返回参数: + +`data.items[]` + +| 字段 | 类型 | 说明 | +| --- | --- | --- | +| `card_id` | `string` | 卡片公开 ID | +| `card_type` | `string` | 例如 `competition_card` | +| `display_name` | `string` | 卡片展示名称 | +| `competition_id` | `string` | 关联赛事 ID,非赛事卡可能为空 | +| `html_url` | `string` | H5 地址,可空 | +| `cover_url` | `string` | 封面地址,可空 | +| `display_slot` | `string` | 展示槽位 | +| `display_priority` | `int` | 展示优先级 | + +### 3.7 `GET /client/v1/competitions/{competition_id}` + +状态:`已实现,已联调` + +接口介绍: + +- 赛事详情页 +- 如果带 `access_token`,会返回当前用户的 `registration_status` + +认证: + +- 可匿名访问 +- 建议前端在登录后带上 `access_token` + +Path 参数: + +| 字段 | 类型 | 说明 | +| --- | --- | --- | +| `competition_id` | `string` | 赛事公开 ID | + +返回参数: + +| 字段 | 类型 | 说明 | +| --- | --- | --- | +| `competition_id` | `string` | 赛事公开 ID | +| `display_name` | `string` | 赛事名称 | +| `competition_status` | `string` | 当前状态 | +| `registration_enabled` | `bool` | 是否允许报名 | +| `registration_status` | `string` | 当前用户报名状态,匿名访问时可能为空 | +| `competition_start_at` | `string` | 赛事开始时间 | +| `competition_end_at` | `string` | 赛事结束时间 | +| `leaderboard_enabled` | `bool` | 是否展示排行榜 | +| `realtime_board_enabled` | `bool` | 是否启用实时榜 | + +`data.events[]` + +| 字段 | 类型 | 说明 | +| --- | --- | --- | +| `event_id` | `string` | Event 公开 ID | +| `display_name` | `string` | Event 名称 | +| `is_default` | `bool` | 是否默认 Event | +| `current_release_id` | `string` | 当前发布版 ID | +| `manifest_url` | `string` | manifest 下载地址 | +| `manifest_checksum_sha256` | `string` | manifest 校验值 | +| `relation_status` | `string` | 关联状态 | + +### 3.8 `GET /client/v1/competitions/{competition_id}/events/{event_id}` + +状态:`已实现,已联调` + +接口介绍: + +- 赛事上下文下的 Event 详情 +- 前端应在这个页面预加载 manifest,并完成路线预览 + +认证: + +- 可匿名访问 +- 建议前端在登录后带上 `access_token` + +Path 参数: + +| 字段 | 类型 | 说明 | +| --- | --- | --- | +| `competition_id` | `string` | 赛事公开 ID | +| `event_id` | `string` | Event 公开 ID | + +返回参数: + +| 字段 | 类型 | 说明 | +| --- | --- | --- | +| `competition_id` | `string` | 赛事公开 ID | +| `event_id` | `string` | Event 公开 ID | +| `display_name` | `string` | Event 名称 | +| `current_release_id` | `string` | 当前发布版 ID | +| `manifest_url` | `string` | manifest 下载地址 | +| `manifest_checksum_sha256` | `string` | manifest 校验值 | +| `direct_entry_enabled` | `bool` | 是否支持地图直入 | +| `playfield_version_id` | `string` | 场地版本 ID | +| `playfield_kind` | `string` | 如 `course` | + +`data.competition_context` + +| 字段 | 类型 | 说明 | +| --- | --- | --- | +| `competition_id` | `string` | 赛事公开 ID | +| `display_name` | `string` | 赛事名称 | +| `competition_status` | `string` | 赛事状态 | +| `registration_status` | `string` | 当前用户报名状态 | +| `leaderboard_enabled` | `bool` | 是否显示排行榜 | +| `realtime_board_enabled` | `bool` | 是否启用实时榜 | + +`data.map_summary` + +| 字段 | 类型 | 说明 | +| --- | --- | --- | +| `map_id` | `string` | 地图公开 ID | +| `display_name` | `string` | 地图名称 | +| `cover_url` | `string` | 封面图,可空 | +| `scale_text` | `string` | 比例尺,可空 | + +`data.preview_summary` + +| 字段 | 类型 | 说明 | +| --- | --- | --- | +| `control_count` | `int` | 控制点数量 | +| `route_count` | `int` | 路线数量 | +| `playfield_kind` | `string` | 场地类型 | + +### 3.9 `POST /client/v1/competitions/{competition_id}/registrations` + +状态:`已实现,未联调` + +接口介绍: + +- 赛事报名 + +认证: + +- 需要 `access_token` + +Path 参数: + +| 字段 | 类型 | 说明 | +| --- | --- | --- | +| `competition_id` | `string` | 赛事公开 ID | + +请求参数: + +| 字段 | 类型 | 必填 | 说明 | +| --- | --- | --- | --- | +| `group_id` | `string` | 否 | 队伍或分组 ID | +| `form_payload` | `object` | 否 | 附加报名表单 | + +返回参数: + +| 字段 | 类型 | 说明 | +| --- | --- | --- | +| `registration_id` | `string` | 报名记录 ID | +| `status` | `string` | 当前报名状态 | + +### 3.10 `GET /client/v1/maps` + +状态:`已实现,已联调` + +接口介绍: + +- 地图列表页 + +查询参数: + +| 字段 | 类型 | 必填 | 说明 | +| --- | --- | --- | --- | +| `region_code` | `string` | 否 | 地区编码 | +| `page` | `int` | 否 | 默认 `1` | +| `page_size` | `int` | 否 | 默认 `20`,最大 `50` | + +返回参数: + +`data.items[]` + +| 字段 | 类型 | 说明 | +| --- | --- | --- | +| `map_id` | `string` | 地图公开 ID | +| `display_name` | `string` | 地图名称 | +| `cover_url` | `string` | 封面图,可空 | +| `scale_text` | `string` | 比例尺,可空 | +| `distance_from_user_km` | `number` | 距离,可空 | + +说明: + +- 当前响应只返回 `items`,不回显 `page/page_size/total` + +### 3.11 `GET /client/v1/maps/{map_id}` + +状态:`已实现,已联调` + +接口介绍: + +- 地图详情页 +- 同时返回当前地图下允许直入的 Event 列表 + +Path 参数: + +| 字段 | 类型 | 说明 | +| --- | --- | --- | +| `map_id` | `string` | 地图公开 ID | + +返回参数: + +| 字段 | 类型 | 说明 | +| --- | --- | --- | +| `map_id` | `string` | 地图公开 ID | +| `display_name` | `string` | 地图名称 | + +`data.map_summary` + +| 字段 | 类型 | 说明 | +| --- | --- | --- | +| `cover_url` | `string` | 封面图,可空 | +| `scale_text` | `string` | 比例尺,可空 | +| `updated_date` | `string` | 更新时间,可空 | + +`data.events[]` + +| 字段 | 类型 | 说明 | +| --- | --- | --- | +| `event_id` | `string` | Event 公开 ID | +| `display_name` | `string` | Event 名称 | +| `preview_image_url` | `string` | 预览图,可空 | +| `control_count` | `int` | 控制点数量 | +| `route_count` | `int` | 路线数量 | +| `direct_entry_enabled` | `bool` | 是否允许地图直入 | +| `current_release_id` | `string` | 当前发布版 ID | +| `manifest_url` | `string` | manifest 下载地址 | +| `manifest_checksum_sha256` | `string` | manifest 校验值 | +| `playfield_kind` | `string` | 如 `course` | + +### 3.12 `GET /client/v1/events` + +状态:`已实现,已联调` + +接口介绍: + +- 地图直入链路下的 Event 列表 + +查询参数: + +| 字段 | 类型 | 必填 | 说明 | +| --- | --- | --- | --- | +| `map_id` | `string` | 否 | 按地图筛选 | +| `page` | `int` | 否 | 默认 `1` | +| `page_size` | `int` | 否 | 默认 `20`,最大 `50` | + +返回参数: + +`data.items[]` + +| 字段 | 类型 | 说明 | +| --- | --- | --- | +| `event_id` | `string` | Event 公开 ID | +| `display_name` | `string` | Event 名称 | +| `map_id` | `string` | 地图公开 ID | +| `map_display_name` | `string` | 地图名称 | +| `preview_image_url` | `string` | 预览图,可空 | +| `control_count` | `int` | 控制点数量 | +| `route_count` | `int` | 路线数量 | +| `direct_entry_enabled` | `bool` | 是否允许地图直入 | +| `current_release_id` | `string` | 当前发布版 ID | +| `manifest_url` | `string` | manifest 下载地址 | +| `manifest_checksum_sha256` | `string` | manifest 校验值 | +| `playfield_kind` | `string` | 如 `course` | + +说明: + +- 当前响应只返回 `items`,不回显 `page/page_size/total` + +### 3.13 `GET /client/v1/events/{event_id}` + +状态:`已实现,已联调` + +接口介绍: + +- 地图直入上下文下的 Event 详情 +- 与赛事入口页共用同一套 Event 详情视图 + +Path 参数: + +| 字段 | 类型 | 说明 | +| --- | --- | --- | +| `event_id` | `string` | Event 公开 ID | + +返回参数: + +| 字段 | 类型 | 说明 | +| --- | --- | --- | +| `event_id` | `string` | Event 公开 ID | +| `display_name` | `string` | Event 名称 | +| `direct_entry_enabled` | `bool` | 是否允许地图直入 | +| `current_release_id` | `string` | 当前发布版 ID | +| `manifest_url` | `string` | manifest 下载地址 | +| `manifest_checksum_sha256` | `string` | manifest 校验值 | +| `playfield_version_id` | `string` | 场地版本 ID | +| `playfield_kind` | `string` | 如 `course` | + +`data.map_summary` + +| 字段 | 类型 | 说明 | +| --- | --- | --- | +| `map_id` | `string` | 地图公开 ID | +| `display_name` | `string` | 地图名称 | +| `cover_url` | `string` | 封面图,可空 | +| `scale_text` | `string` | 比例尺,可空 | + +`data.event_summary` + +| 字段 | 类型 | 说明 | +| --- | --- | --- | +| `control_count` | `int` | 控制点数量 | +| `route_count` | `int` | 路线数量 | +| `playfield_kind` | `string` | 场地类型 | + +### 3.14 `POST /client/v1/competitions/{competition_id}/events/{event_id}/launch` + +状态:`已实现,已联调` + +接口介绍: + +- 赛事入口 `launch` +- 会校验赛事时间窗、报名状态、`release_id` +- 成功后创建 session,并返回 `session_token` + +认证: + +- 需要 `access_token` + +Path 参数: + +| 字段 | 类型 | 说明 | +| --- | --- | --- | +| `competition_id` | `string` | 赛事公开 ID | +| `event_id` | `string` | Event 公开 ID | + +请求参数: + +| 字段 | 类型 | 必填 | 说明 | +| --- | --- | --- | --- | +| `launch_request_id` | `string` | 是 | 启动幂等号 | +| `device_id` | `string` | 是 | 设备唯一标识 | +| `client_type` | `string` | 是 | 必须与 `access_token` 内声明一致 | +| `release_id` | `string` | 是 | 前端当前持有的发布版 ID | +| `route_code` | `string` | 否 | 当前选中的路线编码 | + +返回参数: + +| 字段 | 类型 | 说明 | +| --- | --- | --- | +| `session_id` | `string` | 会话 ID | +| `session_token` | `string` | 会话 token | +| `session_token_expires_at` | `string` | 会话 token 过期时间 | +| `participant_id` | `string` | 参赛身份 ID | +| `competition_id` | `string` | 赛事公开 ID | +| `event_id` | `string` | Event 公开 ID | +| `event_release_id` | `string` | 实际冻结的发布版 ID | +| `playfield_version_id` | `string` | 场地版本 ID | +| `route_code` | `string` | 当前冻结的路线编码 | +| `realtime_endpoint` | `string` | realtime-center 地址 | +| `realtime_token` | `string` | 当前版本通常为空 | + +当前注意: + +- 若前端传入的 `release_id` 已过期,会返回 `EVENT_RELEASE_STALE` +- 当前后端会冻结并回传 `route_code`,但还没有对 manifest 内路线做严格校验 + +### 3.15 `POST /client/v1/events/{event_id}/launch` + +状态:`已实现,已联调` + +接口介绍: + +- 地图直入 `launch` +- 不校验赛事报名资格 +- 成功后创建 session,并返回 `session_token` + +认证: + +- 需要 `access_token` + +Path 参数: + +| 字段 | 类型 | 说明 | +| --- | --- | --- | +| `event_id` | `string` | Event 公开 ID | + +请求参数: + +| 字段 | 类型 | 必填 | 说明 | +| --- | --- | --- | --- | +| `launch_request_id` | `string` | 是 | 启动幂等号 | +| `device_id` | `string` | 是 | 设备唯一标识 | +| `client_type` | `string` | 是 | 必须与 `access_token` 内声明一致 | +| `release_id` | `string` | 是 | 前端当前持有的发布版 ID | +| `route_code` | `string` | 否 | 当前选中的路线编码 | + +返回参数: + +| 字段 | 类型 | 说明 | +| --- | --- | --- | +| `session_id` | `string` | 会话 ID | +| `session_token` | `string` | 会话 token | +| `session_token_expires_at` | `string` | 会话 token 过期时间 | +| `participant_id` | `string` | 参赛身份 ID | +| `event_id` | `string` | Event 公开 ID | +| `event_release_id` | `string` | 实际冻结的发布版 ID | +| `playfield_version_id` | `string` | 场地版本 ID | +| `route_code` | `string` | 当前冻结的路线编码 | +| `realtime_endpoint` | `string` | realtime-center 地址 | +| `realtime_token` | `string` | 当前版本通常为空 | + +当前注意: + +- 若前端传入的 `release_id` 已过期,会返回 `EVENT_RELEASE_STALE` +- 当前后端会冻结并回传 `route_code`,但还没有对 manifest 内路线做严格校验 + +## 4. 预留未就绪接口 + +以下接口当前在路由中已占位,但实际会直接返回 `not implemented`: + +| 接口 | 当前错误码 | +| --- | --- | +| `POST /client/v1/session-uploads` | `session_upload_not_ready` | +| `POST /client/v1/punches` | `session_punch_not_ready` | +| `POST /client/v1/sessions/{session_id}/finish` | `session_finish_not_ready` | +| `GET /client/v1/sessions/{session_id}/result` | `session_result_not_ready` | +| `GET /client/v1/sessions/{session_id}/replay-summary` | `session_replay_summary_not_ready` | +| `GET /client/v1/sessions/{session_id}/gps-track` | `session_gps_track_not_ready` | +| `GET /client/v1/sessions/{session_id}/heart-rate` | `session_heart_rate_not_ready` | +| `GET /client/v1/me/sessions` | `session_history_not_ready` | + +说明: + +- 所以当前不是“只有 GPS / 心率上报没测” +- 而是整条 `session` 下游链路都还没开放 +- 当前已实测闭环停在 `launch` 成功返回 `session_token` + +## 5. 当前测试数据 + +以下数据可直接用于本地联调: + +| 类型 | ID | +| --- | --- | +| `card_id` | `crd_classic_demo_001` | +| `competition_id` | `cmp_classic_demo_001` | +| `map_id` | `lxcb-001` | +| `event_id` | `sample-classic-001` | +| `release_id` | `sample-classic-001-rel-1` | +| `route_code` | `classic-001` | + +说明: + +- 已存在测试赛事卡片、地图、Event、manifest 绑定关系 +- 已存在一个已批准报名的测试用户,可直接验证赛事入口 `launch` diff --git a/tools/mock-gps-sim/README.md b/tools/mock-gps-sim/README.md index 156f851..78b982d 100644 --- a/tools/mock-gps-sim/README.md +++ b/tools/mock-gps-sim/README.md @@ -11,12 +11,36 @@ npm run mock-gps-sim 启动后: - 新版工作台: `http://127.0.0.1:17865/` -- 旧版面板: `http://127.0.0.1:17865/v1/` - 小程序定位模拟地址: `ws://127.0.0.1:17865/mock-gps` - 小程序心率模拟地址: `ws://127.0.0.1:17865/mock-hr` - 小程序调试日志地址: `ws://127.0.0.1:17865/debug-log` - 资源代理: `http://127.0.0.1:17865/proxy?url=` +## 多通道联调 + +模拟器现在支持一个最小的多通道隔离方案: + +- GPS 模拟消息带 `channelId` +- 心率模拟消息带 `channelId` +- 调试日志消息带 `channelId` +- 小程序端按同一个模拟通道号过滤三条链 + +默认通道号: + +```text +default +``` + +如果需要多人并行联调,可以在模拟器工作台里把“模拟通道号”改成例如: + +```text +runner-a +runner-b +group-01 +``` + +然后在小程序调试面板里把“模拟通道号”也配成同一个值。 + ## 当前能力 - 直接载入 `game.json` @@ -47,6 +71,7 @@ ws://127.0.0.1:17865/debug-log { "type": "debug-log", "timestamp": 1712345678901, + "channelId": "runner-a", "scope": "gps-logo", "level": "info", "message": "wx.getImageInfo success", @@ -89,12 +114,6 @@ ws://127.0.0.1:17865/debug-log http://127.0.0.1:17865/ ``` -如果需要旧版稳定界面,打开: - -```text -http://127.0.0.1:17865/v1/ -``` - 在“新网关桥接”区域可以直接配置: - 是否启用桥接 diff --git a/tools/mock-gps-sim/public/index.html b/tools/mock-gps-sim/public/index.html index 452a1b5..ebe4210 100644 --- a/tools/mock-gps-sim/public/index.html +++ b/tools/mock-gps-sim/public/index.html @@ -14,10 +14,16 @@
MOCK GPS SIM

模拟器工作台

+
+ +
定位模拟 @@ -36,29 +42,11 @@ 未启用
-
未连接
diff --git a/tools/mock-gps-sim/public/simulator.js b/tools/mock-gps-sim/public/simulator.js index c991272..fa1391b 100644 --- a/tools/mock-gps-sim/public/simulator.js +++ b/tools/mock-gps-sim/public/simulator.js @@ -13,8 +13,14 @@ ]) const BRIDGE_CONFIG_STORAGE_KEY = 'mock-gps-sim.bridge-config' const BRIDGE_PRESETS_STORAGE_KEY = 'mock-gps-sim.bridge-presets' + const SIM_CHANNEL_STORAGE_KEY = 'mock-gps-sim.channel-id' const MAX_DEBUG_LOG_LINES = 400 + function normalizeSimChannelId(rawValue) { + const trimmed = String(rawValue || '').trim() + return trimmed || 'default' + } + const map = L.map('map').setView(DEFAULT_CENTER, 16) let tileLayer = createTileLayer(DEFAULT_TILE_URL, { maxZoom: 20, @@ -60,6 +66,7 @@ lastHeartRateSentText: '--', lastResourceDetailText: '尚未载入资源', lastTrackSourceText: '路径待命', + simChannelId: 'default', currentLatLng: L.latLng(DEFAULT_CENTER[0], DEFAULT_CENTER[1]), headingDeg: 0, currentSegmentIndex: 0, @@ -128,6 +135,7 @@ trackFileInput: document.getElementById('trackFileInput'), importTrackBtn: document.getElementById('importTrackBtn'), connectBtn: document.getElementById('connectBtn'), + simChannelIdInput: document.getElementById('simChannelIdInput'), sendOnceBtn: document.getElementById('sendOnceBtn'), streamBtn: document.getElementById('streamBtn'), stopStreamBtn: document.getElementById('stopStreamBtn'), @@ -164,6 +172,7 @@ } elements.configUrlInput.value = DEFAULT_CONFIG_URL + applySimChannelId(loadSimChannelId(), false) function createTileLayer(urlTemplate, extraOptions) { return L.tileLayer(urlTemplate, Object.assign({ @@ -177,6 +186,33 @@ elements.log.textContent = `[${time}] ${message}\n` + elements.log.textContent } + function loadSimChannelId() { + try { + return normalizeSimChannelId(window.localStorage.getItem(SIM_CHANNEL_STORAGE_KEY)) + } catch (_error) { + return 'default' + } + } + + function saveSimChannelId(channelId) { + try { + window.localStorage.setItem(SIM_CHANNEL_STORAGE_KEY, normalizeSimChannelId(channelId)) + } catch (_error) { + // noop + } + } + + function applySimChannelId(channelId, persist) { + state.simChannelId = normalizeSimChannelId(channelId) + if (elements.simChannelIdInput) { + elements.simChannelIdInput.value = state.simChannelId + } + if (persist) { + saveSimChannelId(state.simChannelId) + } + renderDebugLog() + } + function logDebug(entry) { if (!elements.debugLog) { return @@ -184,6 +220,7 @@ const normalized = { timestamp: entry.timestamp || Date.now(), + channelId: normalizeSimChannelId(entry.channelId), scope: String(entry.scope || 'app'), level: String(entry.level || 'info'), message: String(entry.message || ''), @@ -231,12 +268,15 @@ } const filteredEntries = state.debugLogEntries.filter((entry) => { + if (normalizeSimChannelId(entry.channelId) !== state.simChannelId) { + return false + } return state.debugLogScopeFilter === 'all' || entry.scope === state.debugLogScopeFilter }) if (elements.debugLogMeta) { const scopeLabel = state.debugLogScopeFilter === 'all' ? '全部' : state.debugLogScopeFilter - elements.debugLogMeta.textContent = `${scopeLabel} · ${filteredEntries.length} 条` + elements.debugLogMeta.textContent = `通道 ${state.simChannelId} · ${scopeLabel} · ${filteredEntries.length} 条` } elements.debugLog.textContent = filteredEntries @@ -244,7 +284,7 @@ const time = new Date(entry.timestamp || Date.now()).toLocaleTimeString() const level = String(entry.level || 'info').toUpperCase() const payloadText = entry.payload ? ` ${JSON.stringify(entry.payload)}` : '' - return `[${time}] [${entry.scope}] [${level}] ${entry.message}${payloadText}` + return `[${time}] [${entry.channelId}] [${entry.scope}] [${level}] ${entry.message}${payloadText}` }) .join('\n') } @@ -1389,6 +1429,7 @@ const payload = { type: 'mock_gps', timestamp: Date.now(), + channelId: state.simChannelId, lat: Number(state.currentLatLng.lat.toFixed(6)), lon: Number(state.currentLatLng.lng.toFixed(6)), accuracyMeters: getAccuracy(), @@ -1409,6 +1450,7 @@ const payload = { type: 'mock_heart_rate', timestamp: Date.now(), + channelId: state.simChannelId, bpm: state.heartRateSampleMode ? getSampleHeartRateBpm() : getHeartRateBpm(), } state.heartRateSocket.send(JSON.stringify(payload)) @@ -1840,6 +1882,12 @@ }) elements.connectBtn.addEventListener('click', connectSocket) + if (elements.simChannelIdInput) { + elements.simChannelIdInput.addEventListener('change', () => { + applySimChannelId(elements.simChannelIdInput.value, true) + log(`已切换模拟通道 ${state.simChannelId}`) + }) + } elements.applyGatewayBridgePresetBtn.addEventListener('click', applyBridgePresetToForm) elements.saveGatewayBridgePresetBtn.addEventListener('click', saveCurrentBridgePreset) elements.deleteGatewayBridgePresetBtn.addEventListener('click', deleteSelectedBridgePreset) diff --git a/tools/mock-gps-sim/public/v1/index.html b/tools/mock-gps-sim/public/v1/index.html deleted file mode 100644 index 1f91132..0000000 --- a/tools/mock-gps-sim/public/v1/index.html +++ /dev/null @@ -1,231 +0,0 @@ - - - - - - Mock GPS Simulator v1 - - - - -
- - -
-
-
-
-
调试日志
- -
-
-
-
-
- - - - - diff --git a/tools/mock-gps-sim/public/workbench.css b/tools/mock-gps-sim/public/workbench.css index 3c827d9..83f4f72 100644 --- a/tools/mock-gps-sim/public/workbench.css +++ b/tools/mock-gps-sim/public/workbench.css @@ -48,6 +48,7 @@ body { display: flex; flex-wrap: wrap; gap: 14px; + align-items: center; } .wb-topbar__links a { @@ -63,6 +64,26 @@ body { gap: 16px; } +.wb-topbar__global { + min-width: 220px; +} + +.wb-topbar__field { + margin-bottom: 0; +} + +.wb-topbar__field span { + font-size: 12px; + font-weight: 700; + letter-spacing: 0.06em; + color: #5d786c; +} + +.wb-topbar__field input { + min-width: 220px; + background: rgba(255, 255, 255, 0.92); +} + .wb-connection-bar { display: flex; flex-wrap: wrap; @@ -104,7 +125,7 @@ body { .wb-layout { min-height: 0; display: grid; - grid-template-columns: 380px 1fr; + grid-template-columns: 380px 1fr 280px; gap: 18px; padding: 18px; } @@ -115,6 +136,12 @@ body { padding-right: 4px; } +.wb-rail { + min-height: 0; + overflow-y: auto; + padding-left: 4px; +} + .wb-stage { position: relative; min-height: 0; @@ -123,14 +150,6 @@ body { box-shadow: 0 28px 60px rgba(20, 41, 31, 0.18); } -.wb-bottom-strip { - padding: 0 18px 18px; -} - -.wb-card--bottom .log { - max-height: 180px; -} - #map { width: 100%; height: 100%; @@ -515,7 +534,7 @@ body { @media (max-width: 1380px) { .wb-layout { - grid-template-columns: 340px 1fr; + grid-template-columns: 340px 1fr 250px; } .floating-debug-log { @@ -526,10 +545,11 @@ body { @media (max-width: 1120px) { .wb-layout { grid-template-columns: 1fr; - grid-template-rows: auto minmax(420px, 1fr); + grid-template-rows: auto minmax(420px, 1fr) auto; } - .wb-sidebar { + .wb-sidebar, + .wb-rail { max-height: 32vh; } @@ -549,7 +569,9 @@ body { align-items: flex-start; } - .wb-bottom-strip { - padding-top: 18px; + .wb-topbar__global, + .wb-topbar__field input { + width: 100%; + min-width: 0; } }