diff --git a/b2b.md b/b2b.md
new file mode 100644
index 0000000..821c9bf
--- /dev/null
+++ b/b2b.md
@@ -0,0 +1,500 @@
+# B2B 交接文档
+> 文档版本:v1.0
+> 最后更新:2026-04-07 18:47:09
+
+## 1. 文档用途
+
+这份文档给“新线程 / 新接手人”直接使用,目标是:
+
+1. 快速理解当前 backend 主线已经做到哪里。
+2. 明确哪些架构约束已经定死,不能再随意回退。
+3. 明确当前运维后台、调试后台、前后端联调各自的边界。
+4. 给出下一步应该从哪里继续,而不是重新摸索一遍。
+
+---
+
+## 2. 先说结论
+
+当前 backend 已经形成三条比较稳定的主线:
+
+1. `玩家链`
+ - `entry -> auth -> home/cards -> event detail/play -> launch -> session -> result`
+
+2. `游客链`
+ - `public experience maps -> public event detail/play -> public launch`
+
+3. `运维链`
+ - `资源录入 -> 地图/地点管理 -> 路线资源管理 -> 活动管理/编排 -> 发布中心`
+
+同时,调试与运维已经明确分离:
+
+- 调试后台:`/dev/workbench`
+- 运维后台:`/admin/ops-workbench`
+
+当前接口总数:
+
+- `116`
+
+---
+
+## 3. 当前最重要的架构边界
+
+### 3.1 活动运行模型
+
+当前已经明确,第一阶段活动模型按最小运行单元收口:
+
+- `单地图`
+- `单路线组`
+- `单玩法`
+
+不要在第一阶段把单个活动扩成:
+
+- 多地图
+- 多路线组
+- 多玩法
+
+复杂需求后续通过两种方式承接:
+
+1. `活动实例化`
+ - 一个复杂主题拆成多个明确活动实例。
+
+2. `组合入口 / 组合卡片`
+ - 前台组合多个活动实例形成复杂入口。
+
+这条原则已经同步给总控,后续不要回退。
+
+### 3.2 玩家进入游戏的依据
+
+玩家进入游戏必须基于:
+
+- `已发布 release`
+
+而不是:
+
+- event 草稿
+- event 默认绑定
+- 未发布的 presentation / content bundle
+
+当前 `play.canLaunch` 已按这条规则收紧:
+
+必须同时具备:
+
+- event 是 `active`
+- 当前已发布 release 存在
+- release 有 `manifest`
+- release 绑定了 `runtime`
+- release 绑定了 `presentation`
+- release 绑定了 `content bundle`
+
+否则:
+
+- `play.canLaunch = false`
+
+### 3.3 调试后台与运维后台分离
+
+不要再把所有能力塞回一个页面。
+
+当前原则:
+
+- `/dev/workbench`
+ - 只做联调、一键回归、日志、摘要、问题排查
+
+- `/admin/ops-workbench`
+ - 只做资源录入、地图/路线/活动管理、发布中心
+
+---
+
+## 4. 用户明确表达过的产品/交互偏好
+
+这些是用户多次强调过的,后续要严格遵守。
+
+### 4.1 不要把所有功能平铺在一个长页面里
+
+用户明确反感“所有功能平铺一个页面”的方式。
+
+正确方向:
+
+- 列表就是列表
+- 点某一项进详情
+- 新增/编辑用:
+ - 新页面
+ - 弹出页
+ - 分步流程
+
+不要继续堆按钮。
+
+### 4.2 运维后台要符合人的使用习惯
+
+例如在地图管理里,用户明确要求的是:
+
+1. 先进入地图列表
+2. 右上角有:
+ - `添加地图`
+ - `添加地点`
+3. 点一张地图再进入详情
+4. 地图上传能力要放在地图管理里
+
+### 4.3 运维后台 UI 必须尽量利用宽屏
+
+用户明确要求:
+
+- 不要再固定窄宽度
+- 主区要做最大宽度适配
+- 不要浪费屏幕空间
+
+### 4.4 开发环境尽量免登录
+
+用户明确要求:
+
+- 开发环境不要每次都要求登录
+
+当前已实现:
+
+- `APP_ENV != production` 时,`/ops/admin/*` 默认可免登录使用
+- 即使浏览器残留旧玩家 token / 过期 token / 非 ops token,也会自动 fallback 到 dev ops 上下文
+
+---
+
+## 5. 当前已完成的关键能力
+
+## 5.1 调试后台
+
+入口:
+
+- [D:\dev\cmr-mini\backend\internal\httpapi\handlers\dev_handler.go](D:/dev/cmr-mini/backend/internal/httpapi/handlers/dev_handler.go)
+
+地址:
+
+- `/dev/workbench`
+
+已完成:
+
+- demo bootstrap
+- 一键发布链
+- 一键标准回归
+- 前端调试日志上报与查看
+- 当前玩法关键状态卡
+- Launch 实际配置摘要
+- 准备页地图预览状态
+- API 目录按业务链分类展示
+
+调试链已经能稳定支撑:
+
+- 选玩法
+- 回归
+- 看日志
+- 看 launch / manifest / preview 摘要
+
+## 5.2 游客模式
+
+公开接口:
+
+- `GET /public/experience-maps`
+- `GET /public/experience-maps/{mapAssetPublicID}`
+- `GET /public/events/{eventPublicID}`
+- `GET /public/events/{eventPublicID}/play`
+- `POST /public/events/{eventPublicID}/launch`
+
+语义:
+
+- 只允许默认体验活动
+- 只允许基于已发布 release
+- `launch.business.isGuest = true`
+- `launch.source = public-default-experience`
+
+游客 launch 曾经被 `guest` identity DB 约束卡死,已通过 migration 修复。
+
+## 5.3 地图列表下的默认体验活动
+
+接口:
+
+- `GET /experience-maps`
+- `GET /experience-maps/{mapAssetPublicID}`
+
+当前用于前端地图列表下的“默认体验活动”展示。
+
+## 5.4 运维后台
+
+入口:
+
+- [D:\dev\cmr-mini\backend\internal\httpapi\handlers\ops_workbench_handler.go](D:/dev/cmr-mini/backend/internal/httpapi/handlers/ops_workbench_handler.go)
+
+地址:
+
+- `/admin/ops-workbench`
+
+当前导航结构已收成:
+
+- `资源总览`
+- `地图 / 地点管理`
+- `路线资源管理`
+- `活动管理`
+- `活动编排`
+- `发布中心`
+- `资源录入`
+
+注意:
+
+- 这只是第一版结构
+- 还没有完全做成成熟的列表页/详情页后台
+- 但已经明确不再走“全平铺”老路
+
+## 5.5 资源录入第二期基础能力
+
+已新增统一资源模型接口:
+
+- `GET /ops/admin/assets`
+- `POST /ops/admin/assets/register-link`
+- `POST /ops/admin/assets/upload`
+- `GET /ops/admin/assets/{assetPublicID}`
+
+目的:
+
+- 运维无需操心资源到底来自上传文件还是外链
+- 最终都纳管成统一资源对象
+
+---
+
+## 6. 当前地图/地点管理做到哪里了
+
+这是接下来最可能继续做的主线。
+
+当前已经做了这些:
+
+1. 地图/地点管理已不再是平铺对象区
+2. 地图列表成为主入口
+3. 右上角只有:
+ - `添加地图`
+ - `添加地点`
+4. 地图详情改成弹层
+5. 地点编辑改成弹层
+6. 地点支持:
+ - 省份
+ - 城市
+ - 最终回填 `region`
+7. 新增地区接口:
+ - `GET /ops/admin/region-options`
+
+地区数据源:
+
+- [https://github.com/uiwjs/province-city-china](https://github.com/uiwjs/province-city-china)
+- [https://unpkg.com/province-city-china/dist/province.json](https://unpkg.com/province-city-china/dist/province.json)
+- [https://unpkg.com/province-city-china/dist/city.json](https://unpkg.com/province-city-china/dist/city.json)
+
+业务约束:
+
+- 一个地点可以有多张地图
+- 一张地图只能属于一个地点
+
+刚刚修复过一个关键前端脚本 bug:
+
+- `managed-place-id` 隐藏状态字段在重构时被删掉
+- 导致 `refresh-map-area` 前端脚本异常,只打印 `{ error: {} }`
+- 现已补回,同时错误日志已展开成:
+ - `name`
+ - `message`
+ - `stack`
+ - `status/body/url`
+
+---
+
+## 7. 数据库与 migration 重要说明
+
+开发库:
+
+- `cmr20260401`
+
+注意:
+
+- backend 启动脚本不会自动帮你补所有历史 migration
+- 之前已经遇到过“代码修了,库没跟上”的问题
+
+开发库里曾手工补过的重要 migration:
+
+1. [D:\dev\cmr-mini\backend\migrations\0012_managed_assets.sql](D:/dev/cmr-mini/backend/migrations/0012_managed_assets.sql)
+2. [D:\dev\cmr-mini\backend\migrations\0013_ops_console.sql](D:/dev/cmr-mini/backend/migrations/0013_ops_console.sql)
+3. [D:\dev\cmr-mini\backend\migrations\0015_guest_identity.sql](D:/dev/cmr-mini/backend/migrations/0015_guest_identity.sql)
+
+这些都已经在当前开发库应用过。
+
+如果新线程遇到“文档说修了,但接口还是异常”,先查两件事:
+
+1. 当前 API 实例是不是连着 `cmr20260401`
+2. 对应 migration 有没有真的进库
+
+---
+
+## 8. 关键文件入口
+
+### 8.1 路由与中间件
+
+- [D:\dev\cmr-mini\backend\internal\httpapi\router.go](D:/dev/cmr-mini/backend/internal/httpapi/router.go)
+- [D:\dev\cmr-mini\backend\internal\httpapi\middleware\ops_auth.go](D:/dev/cmr-mini/backend/internal/httpapi/middleware/ops_auth.go)
+
+### 8.2 调试后台
+
+- [D:\dev\cmr-mini\backend\internal\httpapi\handlers\dev_handler.go](D:/dev/cmr-mini/backend/internal/httpapi/handlers/dev_handler.go)
+
+### 8.3 运维后台
+
+- [D:\dev\cmr-mini\backend\internal\httpapi\handlers\ops_workbench_handler.go](D:/dev/cmr-mini/backend/internal/httpapi/handlers/ops_workbench_handler.go)
+- [D:\dev\cmr-mini\backend\internal\httpapi\handlers\region_options_handler.go](D:/dev/cmr-mini/backend/internal/httpapi/handlers/region_options_handler.go)
+
+### 8.4 地图默认体验活动 / 游客模式
+
+- [D:\dev\cmr-mini\backend\internal\service\map_experience_service.go](D:/dev/cmr-mini/backend/internal/service/map_experience_service.go)
+- [D:\dev\cmr-mini\backend\internal\httpapi\handlers\map_experience_handler.go](D:/dev/cmr-mini/backend/internal/httpapi/handlers/map_experience_handler.go)
+- [D:\dev\cmr-mini\backend\internal\service\public_experience_service.go](D:/dev/cmr-mini/backend/internal/service/public_experience_service.go)
+- [D:\dev\cmr-mini\backend\internal\httpapi\handlers\public_experience_handler.go](D:/dev/cmr-mini/backend/internal/httpapi/handlers/public_experience_handler.go)
+
+### 8.5 运维总览
+
+- [D:\dev\cmr-mini\backend\internal\store\postgres\ops_summary_store.go](D:/dev/cmr-mini/backend/internal/store/postgres/ops_summary_store.go)
+- [D:\dev\cmr-mini\backend\internal\service\ops_summary_service.go](D:/dev/cmr-mini/backend/internal/service/ops_summary_service.go)
+
+### 8.6 资源录入 / 受管资源
+
+- [D:\dev\cmr-mini\backend\internal\service\admin_asset_service.go](D:/dev/cmr-mini/backend/internal/service/admin_asset_service.go)
+- [D:\dev\cmr-mini\backend\internal\httpapi\handlers\admin_asset_handler.go](D:/dev/cmr-mini/backend/internal/httpapi/handlers/admin_asset_handler.go)
+- [D:\dev\cmr-mini\backend\internal\store\postgres\asset_store.go](D:/dev/cmr-mini/backend/internal/store/postgres/asset_store.go)
+
+---
+
+## 9. 必读文档
+
+新线程建议先看这些,不要一上来就只看代码。
+
+1. [D:\dev\cmr-mini\backend\README.md](D:/dev/cmr-mini/backend/README.md)
+2. [D:\dev\cmr-mini\backend\docs\开发说明.md](D:/dev/cmr-mini/backend/docs/开发说明.md)
+3. [D:\dev\cmr-mini\backend\docs\后台管理最小方案.md](D:/dev/cmr-mini/backend/docs/后台管理最小方案.md)
+4. [D:\dev\cmr-mini\backend\docs\资源对象与目录方案.md](D:/dev/cmr-mini/backend/docs/资源对象与目录方案.md)
+5. [D:\dev\cmr-mini\backend\docs\接口清单.md](D:/dev/cmr-mini/backend/docs/接口清单.md)
+6. [D:\dev\cmr-mini\b2t.md](D:/dev/cmr-mini/b2t.md)
+7. [D:\dev\cmr-mini\b2f.md](D:/dev/cmr-mini/b2f.md)
+
+其中:
+
+- `b2t.md` 看 backend 写给总控的当前结论
+- `b2f.md` 看 backend 写给前端的当前联调口径
+
+---
+
+## 10. 当前最值得继续做的事
+
+新线程接手后,建议优先级如下:
+
+### P0:把“地图/地点管理”真正做成列表 -> 详情流
+
+目标:
+
+1. 地图列表作为稳定主入口
+2. 地图详情弹层稳定
+3. 添加地图/添加地点弹层稳定
+4. 地图上传能力放进地图管理流
+
+这里的“上传地图”不是指调试式填 URL,而是最终要收成:
+
+- 上传文件
+- 或登记链接
+- backend 统一纳管
+
+### P1:把地图详情页做扎实
+
+建议继续补:
+
+- 当前瓦片版本
+- 历史瓦片版本
+- 简单预览
+- 默认体验活动数量
+- 关联活动数量
+- 跳转到活动管理
+
+注意:
+
+- 地图页只看数量和摘要
+- 活动详情不要重新塞回地图页
+
+### P2:后续再做路线资源管理同样的列表 -> 详情流
+
+KML/路线页也应遵守同一原则:
+
+- 列表就是列表
+- 点一项进详情
+- 上传就是上传
+- 不在首页平铺所有对象编辑器
+
+---
+
+## 11. 本线程最后一次真实修复
+
+如果新线程接手后发现:
+
+- 进入 `/admin/ops-workbench`
+- 切到“地图 / 地点管理”
+- 状态栏显示:
+ - `失败:refresh-map-area`
+- 响应日志里还是:
+ - `{ "error": {} }`
+
+先确认是否已经是本线程修后的代码版本。
+
+本线程最后一次修复的是:
+
+- 恢复 `managed-place-id` 隐藏状态字段
+- 展开运维页前端异常日志
+
+相关文件:
+
+- [D:\dev\cmr-mini\backend\internal\httpapi\handlers\ops_workbench_handler.go](D:/dev/cmr-mini/backend/internal/httpapi/handlers/ops_workbench_handler.go)
+
+---
+
+## 12. 本地验证方式
+
+不要让新线程重复踩坑,直接照这个流程:
+
+1. backend 编译
+
+```powershell
+cd D:\dev\cmr-mini\backend
+go build ./...
+```
+
+2. 启动 backend
+
+```powershell
+cd D:\dev\cmr-mini\backend
+.\start-backend.ps1
+```
+
+3. 浏览器强刷
+
+```text
+Ctrl + F5
+```
+
+4. 检查两个入口
+
+- 调试后台:
+ - [http://127.0.0.1:18090/dev/workbench](http://127.0.0.1:18090/dev/workbench)
+- 运维后台:
+ - [http://127.0.0.1:18090/admin/ops-workbench](http://127.0.0.1:18090/admin/ops-workbench)
+
+---
+
+## 13. 一句话交接
+
+当前 backend 已经把:
+
+- 调试后台
+- 游客模式
+- 默认体验地图接口
+- 运维后台第一版骨架
+
+都立起来了。
+
+接下来最该继续的不是扩新对象,而是把:
+
+**地图 / 地点管理 -> 路线资源管理 -> 活动管理 / 编排 -> 发布中心**
+
+这条运维主流程,按“列表 -> 详情 -> 弹层 / 分步”的方式做扎实。
diff --git a/b2f.md b/b2f.md
index 3e7532a..750ea37 100644
--- a/b2f.md
+++ b/b2f.md
@@ -1,27 +1,149 @@
# b2f
-> 文档版本:v1.26
-> 最后更新:2026-04-03 19:18:34
-
+> 文档版本:v1.40
+> 最后更新:2026-04-07 16:29:08
说明:
-- 只写事实和请求
-- 每条固定包含:时间、谁提的、当前事实、需要对方确认什么、是否已解决
+- 本文件由 backend 维护,写给 frontend
+- 只保留当前有效事项、联调基线和压缩归档
+- 已完成旧项不再逐条长留,只保留必要结论
---
## 待确认
+### B2F-045
+
+- 时间:2026-04-07 16:29:08
+- 谁提的:backend
+- 当前事实:
+ - backend 已补游客模式最小公开接口:
+ - `GET /public/experience-maps`
+ - `GET /public/experience-maps/{mapAssetPublicID}`
+ - `GET /public/events/{eventPublicID}`
+ - `GET /public/events/{eventPublicID}/play`
+ - `POST /public/events/{eventPublicID}/launch`
+ - 当前游客模式语义:
+ - 只允许默认体验活动
+ - 只允许基于已发布 release
+ - `public launch` 返回结构与正式 launch 基本同构
+ - 会额外返回:
+ - `launch.source = public-default-experience`
+ - `launch.business.isGuest = true`
+ - 当前不开放:
+ - `/me/entry-home`
+ - `/me/results`
+ - 历史成绩
+ - 报名态
+ - 本轮已定位并修复 `F2B-019` 的根因:
+ - `POST /public/events/{eventPublicID}/launch` 在创建游客身份时会写入 `login_identities`
+ - 旧库约束不允许 `identity_type = 'guest'`
+ - 已补 migration:
+ - [0015_guest_identity.sql](D:/dev/cmr-mini/backend/migrations/0015_guest_identity.sql)
+- 需要对方确认什么:
+ - frontend 重启 backend 并应用最新 migration 后,重新回归:
+ - `POST /public/events/evt_demo_001/launch`
+ - 若仍异常,只回传:
+ - `status`
+ - `error.code`
+ - `eventId`
+ - `deviceKey`
+- 是否已解决:是
+
+### B2F-044
+
+- 时间:2026-04-07 16:08:20
+- 谁提的:backend
+- 当前事实:
+ - backend 已补地图列表与默认活动最小接口:
+ - `GET /experience-maps`
+ - `GET /experience-maps/{mapAssetPublicID}`
+ - 当前地图列表字段:
+ - `placeId`
+ - `placeName`
+ - `mapId`
+ - `mapName`
+ - `coverUrl`
+ - `summary`
+ - `defaultExperienceCount`
+ - `defaultExperienceEventIds`
+ - 当前地图详情字段:
+ - `tileBaseUrl`
+ - `tileMetaUrl`
+ - `defaultExperiences[]`
+ - `defaultExperiences[]` 已带:
+ - `eventId`
+ - `title`
+ - `subtitle`
+ - `eventType`
+ - `status`
+ - `statusCode`
+ - `ctaText`
+ - `isDefaultExperience`
+ - `showInEventList`
+ - `currentPresentation`
+ - `currentContentBundle`
+- 需要对方确认什么:
+ - frontend 地图列表第一刀可直接按这组字段开始接线。
+ - 如仍缺字段,只回传“字段名 + 使用位置”。
+- 是否已解决:否
+
+### B2F-041
+
+- 时间:2026-04-07 13:12:00
+- 谁提的:backend
+- 当前事实:
+ - backend 已把准备页地图预览 V1 的只读字段挂到:
+ - `GET /events/{eventPublicID}`
+ - `GET /events/{eventPublicID}/play`
+ - 当前字段为:
+ - `preview.mode`
+ - `preview.baseTiles.tileBaseUrl`
+ - `preview.baseTiles.zoom`
+ - `preview.baseTiles.tileSize`
+ - `preview.viewport.width / height / minLon / minLat / maxLon / maxLat`
+ - `preview.variants[].controls`
+ - `preview.variants[].legs`
+ - `preview.selectedVariantId`
+ - 三条标准 demo 当前都已具备 preview 元数据。
+- 需要对方确认什么:
+ - frontend 可按这组字段开始准备页地图预览 V1 接线。
+ - 当前只做只读预览,不把 preview 当成正式 launch 前置条件。
+- 是否已解决:否
+
+### B2F-040
+
+- 时间:2026-04-07 10:58:18
+- 谁提的:backend
+- 当前事实:
+ - 首页 `ongoingSession` 语义已固定:
+ - 只认 `launched`
+ - 只认 `running`
+ - 以下状态都不算进行中:
+ - `finished`
+ - `failed`
+ - `cancelled`
+ - backend 已支持:
+ - `POST /sessions/{sessionPublicID}/finish`
+ - `status = cancelled`
+- 需要对方确认什么:
+ - frontend 首页“进行中”只在 `ongoingSession` 存在时显示。
+ - 建议按钮:
+ - `恢复`
+ - `放弃`
+ - `放弃` 必须调用 `finish(cancelled)`,然后清本地恢复快照,再刷新 `/me/entry-home`。
+- 是否已解决:否
+
### B2F-038
- 时间:2026-04-03 19:13:57
- 谁提的:backend
- 当前事实:
- - backend 已按“活动卡片列表最小产品化第一刀”补齐以下返回中的活动卡片最小摘要字段:
+ - backend 已给活动列表第一刀补齐最小摘要字段,返回位于:
- `GET /cards`
- `GET /home`
- `GET /me/entry-home`
- - 当前最小摘要字段为:
+ - 当前字段为:
- `summary`
- `status`
- `statusCode`
@@ -31,924 +153,131 @@
- `eventType`
- `currentPresentation`
- `currentContentBundle`
- - backend 当前希望 frontend 这轮优先做的是:
- - 活动列表页按这组字段完成最小接线
- - 详情页继续沿用:
- - `play.canLaunch`
- - `currentPresentation`
- - `currentContentBundle`
- 这组已发布 release 语义
- - 联调时继续通过 frontend 调试日志回传以下事实:
- - 列表页实际拿到的 `cardEventIds`
- - 点击卡片后的 `eventId`
- - 详情页实际显示的 `status / canLaunch / currentPresentation / currentContentBundle`
- 需要对方确认什么:
- - frontend 请按这组字段完成活动卡片列表最小实现,并回写:
- - 当前字段是否足够
- - 列表页是否还缺必需字段
- - 最新日志里是否已能稳定看到:
- - `cardEventIds`
- - `clickedEventId`
- - `detail.status`
- - `detail.canLaunch`
+ - frontend 按这组字段完成列表页最小接线。
+ - 如果仍缺字段,请只回传“缺什么字段、用于哪个页面块”。
- 是否已解决:否
-### B2F-037
-
-- 时间:2026-04-03 22:52:10
-- 谁提的:backend
-- 当前事实:
- - backend 已根据 frontend 在 `F2B-013` 的结构化日志,确认 manual 多赛道当前不显示赛道选择区的根因不在 frontend 展示层
- - 当前 frontend 日志事实为:
- - `event-play.pageEventId = evt_demo_variant_manual_001`
- - `event-play.variantCount = 0`
- - `event-prepare.variantCount = 0`
- - `event-prepare.selectableVariantCount = 0`
- - `event-prepare.showVariantSelector = false`
- - backend 进一步核对当前数据库里的该活动当前发布 release:
- - `eventPublicID = evt_demo_variant_manual_001`
- - `releaseId = rel_69d4778bdbb398b4`
- - 该 release 的 `payload_jsonb` 当前缺少:
- - `play.assignmentMode`
- - `play.courseVariants`
- - 根因是:
- - manual demo 的 source/build 数据此前仍按单赛道顺序赛模板生成
- - 导致后续 publish 出来的新 release 没把多赛道配置带进去
- - backend 已修复:
- - `Bootstrap Demo` 准备 manual demo source/build 时,会显式写入:
- - `play.assignmentMode = manual`
- - `play.courseVariants = [variant_a, variant_b]`
-- 需要对方确认什么:
- - 无,当前这条已通过本轮联调日志确认
-- 是否已解决:是
-
-### B2F-036
-
-- 时间:2026-04-03 22:34:08
-- 谁提的:backend
-- 当前事实:
- - backend 已按活动卡片列表最小产品化第一刀,统一补齐以下返回里的卡片摘要字段:
- - `GET /cards`
- - `GET /home`
- - `GET /me/entry-home`
- - 当前新增/补齐字段为:
- - `summary`
- - `status`
- - `statusCode`
- - `timeWindow`
- - `ctaText`
- - `isDefaultExperience`
- - `eventType`
- - `currentPresentation`
- - `currentContentBundle`
- - 当前口径固定如下:
- - `summary` 缺失时回退:`当前暂无活动摘要`
- - `timeWindow` 缺失时回退:`时间待公布`
- - `ctaText` 当前由 backend 派生:
- - 默认体验活动:`进入体验`
- - 进行中:`进入活动`
- - 已结束:`查看回顾`
- - 其余:`查看详情`
- - `currentPresentation / currentContentBundle` 继续表示当前已发布 release 摘要,不是 event 草稿默认值
- - backend 已给 `cards` 落显式字段:
- - `is_default_experience`
- - 当前 demo 数据已标记:
- - 顺序赛为默认体验活动
- - 积分赛、多赛道为普通活动
-- 需要对方确认什么:
- - frontend 可按以上字段和降级规则开始活动卡片列表最小产品化第一刀
- - frontend 请回写:
- - 当前字段是否足够启动列表页最小实现
- - 是否还缺列表页必需名称摘要
-- 是否已解决:否
-
-### B2F-035
-
-- 时间:2026-04-03 18:16:19
-- 谁提的:backend
-- 当前事实:
- - backend 已根据 frontend 在 `F2B-012` 的反馈,正式收紧 `play.canLaunch` 和 `POST /events/{eventPublicID}/launch` 的前置条件
- - 当前规则已改为:
- - 仅当当前 event 满足以下条件时,`play.canLaunch = true`
- - event `status = active`
- - 已存在当前发布 release
- - 当前发布 release 有 `manifest`
- - 当前发布 release 已绑定 `runtime`
- - 当前发布 release 已绑定 `presentation`
- - 当前发布 release 已绑定 `content bundle`
- - 当前若缺任一项,backend 会返回更明确原因,例如:
- - `current published release is missing runtime binding`
- - `current published release is missing presentation binding`
- - `current published release is missing content bundle binding`
- - `launch` 当前也已按同一套规则阻断,避免出现:
- - `play.canLaunch = false`
- - 但直接调用 `launch` 仍能进局
-- 需要对方确认什么:
- - frontend 请在 backend 重启后复验:
- - 当 `currentPresentation / currentContentBundle / runtime` 任意缺失时,`play.canLaunch` 是否已变为 `false`
- - `play.reason` 是否已返回更具体缺失原因
- - frontend 页面当前可继续沿用:
- - `canLaunch=false` 时禁用进入动作
- - 同时展示 backend 返回的 `reason`
-- 是否已解决:否
-
-### B2F-034
-
-- 时间:2026-04-03 18:05:19
-- 谁提的:backend
-- 当前事实:
- - backend 当前已确认一个需要 frontend 明确区分的语义:
- - `currentPresentation`
- - `currentContentBundle`
- 当前表示的是“当前已发布 release 上实际绑定的展示版本 / 内容包版本摘要”
- - 它们当前不是:
- - 活动草稿默认值
- - event 默认绑定草稿态
- - 这也解释了为什么:
- - 后台未完成导入 + 默认绑定 + publish 之前,这两项可能为空
- - 一旦跑过后台发布链,它们就会开始显示
- - backend 当前正式规则也已明确:
- - 玩家进入游戏必须基于“已发布 release”
- - 不能基于未发布默认配置直接放行
-- 需要对方确认什么:
- - frontend 请按以下口径调整页面语义:
- - 文案优先改成:
- - `当前发布展示版本`
- - `当前发布内容包版本`
- - 玩家能否继续进入,优先只看:
- - `play.canLaunch`
- - 当这两项为空时,优先解释为:
- - 当前发布 release 未绑定
- - 或当前尚未发布
- - 不要把它们展示成“活动默认配置已存在,只是未显示”
-- 是否已解决:否
-
-### B2F-032
-
-- 时间:2026-04-03 16:43:25
-- 谁提的:backend
-- 当前事实:
- - backend 已收到 frontend 最新一轮结构化调试日志,并确认积分赛主链已打通
- - 当前日志事实一致指向:
- - `entry-home.cardEventIds` 已包含 `evt_demo_score_o_001`
- - `event-play.pageEventId = evt_demo_score_o_001`
- - `event-prepare.pageEventId = evt_demo_score_o_001`
- - `launch.response.releaseId = rel_74bb47a0d0d3d252`
- - `runtime-compiler.details.game.mode = score-o`
- - `runtime-compiler.details.playfield.kind = control-set`
- - 当前 backend 没再看到“明明选积分赛却实际跑成顺序赛”的问题
- - 当前日志链还有 3 个口径优化项:
- - 非多赛道玩法时,`assignmentMode` 现在是空字符串 `\"\"`
- - `variantId` 为空时,`runtimeCourseVariantId` 仍可能有值,前端展示层不要把两者混为“用户选了赛道”
- - `occurredAt` 和 `receivedAt` 会有轻微时钟漂移,排查顺序时建议增加前端本地递增序号
-- 需要对方确认什么:
- - frontend 后续日志上报请优化为:
- - 非多赛道玩法时:
- - `assignmentMode = null` 或不传
- - `variantId = null` 或不传
- - 新增:
- - `details.seq`
- - 如需展示赛道来源,请区分:
- - `launchVariantId`
- - `runtimeCourseVariantId`
-- 是否已解决:否
-
-### B2F-031
-
-- 时间:2026-04-03 16:37:41
-- 谁提的:backend
-- 当前事实:
- - backend 已收到 frontend 新增的结构化调试日志
- - 这批日志已经明确说明:本轮前端实际进入的是经典顺序赛,不是积分赛
- - 关键事实如下:
- - `entry-home.cardEventIds = [evt_demo_001, evt_demo_variant_manual_001]`
- - `event-play.pageEventId = evt_demo_001`
- - `event-prepare.pageEventId = evt_demo_001`
- - `launch.response.releaseId = rel_demo_001`
- - `runtime-compiler.details.game.mode = classic-sequential`
- - backend 已确认根因不是积分赛 release 缺失,而是 demo 首页卡片入口配置错误:
- - 当前首页卡片查询只取 `home_primary`
- - score-o demo 卡此前被种到了 `home_secondary`
- - 所以前端首页根本拿不到积分赛入口
- - backend 已修复:
- - `card_demo_score_o_001` 改为 `home_primary`
- - 同时把优先级调高到 `98`
-- 需要对方确认什么:
- - frontend 请在 backend 重启后重新执行:
- - `Bootstrap Demo`
- - 进入首页再看 `cardEventIds`
- - 并确认首页卡片中是否已经出现:
- - `evt_demo_score_o_001`
-- 是否已解决:否
-
-### B2F-030
-
-- 时间:2026-04-03 16:16:38
-- 谁提的:backend
-- 当前事实:
- - backend 已新增 dev 调试接口:
- - `POST /dev/client-logs`
- - `GET /dev/client-logs`
- - `DELETE /dev/client-logs`
- - workbench 已新增:
- - `前端调试日志`
- - `拉取前端日志`
- - `清空前端日志`
- - 这套能力只用于联调,不参与正式生产日志链路
- - backend 当前建议 frontend 在关键阶段主动上报日志,至少覆盖:
- - launch 返回后
- - 地图页拿到最终 manifest 后
- - 运行时编译完成后
- - 发现缓存命中 / 恢复 session / 模式不符时
-- 需要对方确认什么:
- - frontend 请按最小字段约定接入并回传一轮:
- - `source`
- - `level`
- - `category`
- - `message`
- - `eventId`
- - `releaseId`
- - `sessionId`
- - `manifestUrl`
- - `route`
- - `details.schemaVersion`
- - `details.playfield.kind`
- - `details.game.mode`
- - `details.phase`
-- 是否已解决:否
-
-### B2F-029
-
-- 时间:2026-04-03 15:44:32
-- 谁提的:backend
-- 当前事实:
- - backend 已确认一个具体问题:
- - 所有“带 `Bootstrap Demo` 的一键按钮”,此前都会先把默认 demo 数据重新写回表单
- - 这会把前面已经选好的积分赛入口再次冲回顺序赛默认链
- - 受影响的不只是:
- - `整条链一键验收`
- - 还包括:
- - `看首页是否正常`
- - `快速进一局`
- - `发布活动配置(自动补 Runtime)`
- - backend 已修复为:
- - 这些一键流在调用 `Bootstrap Demo` 后,会按当前已选中的 `event` 回填对应的 `source / build / release / runtime`
- - 不再无条件回退到 `evt_demo_001`
-- 需要对方确认什么:
- - frontend 请重启 backend 后复验:
- - `Use Score-O Demo`
- - `整条链一键验收`
- - 并确认地图信息面板里是否已切到:
- - `eventPublicID = evt_demo_score_o_001`
- - `game.mode = score-o`
- - `playfield.kind = control-set`
-- 是否已解决:否
-
-### B2F-028
-
-- 时间:2026-04-03 15:29:07
-- 谁提的:backend
-- 当前事实:
- - backend 已核对积分赛 demo 当前发布链,以下 3 层结果一致:
- - 本地源配置 [score-o.json](D:/dev/cmr-mini/event/score-o.json)
- - OSS 配置 [score-o.json](https://oss-mbh5.colormaprun.com/gotomars/event/score-o.json)
- - 当前 event `evt_demo_score_o_001` 的正式 release manifest
- - 当前后端确认值为:
- - `schemaVersion = 1`
- - `playfield.kind = control-set`
- - `game.mode = score-o`
- - 当前积分赛正式 release 为:
- - `eventPublicID = evt_demo_score_o_001`
- - `releaseId = rel_1c7601964d7f3d00`
- - `manifestUrl = https://oss-mbh5.colormaprun.com/gotomars/event/releases/evt_demo_score_o_001/rel_1c7601964d7f3d00/manifest.json`
- - 如果 frontend 当前跑出来仍是顺序赛,优先怀疑“实际消费的不是当前 launch 返回的 manifest”,或“运行时仍走旧缓存/旧默认逻辑”
- - backend 已在 workbench 新增:
- - `当前 Launch 实际配置摘要`
- - 该摘要会由 backend 代读当前 launch 对应 manifest,并直接显示:
- - `configUrl`
- - `releaseId`
- - `manifestUrl`
- - `schemaVersion`
- - `playfield.kind`
- - `game.mode`
- - 这块摘要只用于联调排查,不参与正式客户端运行链路
-- 需要对方确认什么:
- - frontend 请先排查并回传这 4 项事实:
- - `POST /events/{eventPublicID}/launch` 响应里的:
- - `launch.config.configUrl`
- - `launch.resolvedRelease.manifestUrl`
- - `launch.config.releaseId`
- - 地图页/运行时真正读取的最终 manifest URL
- - 最终加载后的 manifest 摘要:
- - `schemaVersion`
- - `playfield.kind`
- - `game.mode`
- - 如仍表现为顺序赛,请同时给出:
- - 控制台日志
- - 网络请求日志
- - 是否存在本地缓存/上次 session 恢复痕迹
-- 是否已解决:否
-
-### B2F-001
-
-- 时间:2026-04-01
-- 谁提的:backend
-- 当前事实:
- - backend 当前主链已经可联调:
- - `POST /auth/login/wechat-mini`
- - `GET /me/entry-home`
- - `GET /events/{eventPublicID}/play`
- - `POST /events/{eventPublicID}/launch`
- - `POST /sessions/{sessionPublicID}/start`
- - `POST /sessions/{sessionPublicID}/finish`
- - `GET /sessions/{sessionPublicID}/result`
- - 当前建议统一使用 demo 入口:
- - `eventPublicID = evt_demo_001`
- - `channelCode = mini-demo`
- - `channelType = wechat_mini`
-- 需要对方确认什么:
- - frontend 是否按这组 demo 数据作为当前唯一联调入口
-- 是否已解决:否
-
-### B2F-002
-
-- 时间:2026-04-01
-- 谁提的:backend
-- 当前事实:
- - 进入游戏的正式流程必须以 `launch` 返回值为准
- - backend 当前约定字段:
- - `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`
-- 需要对方确认什么:
- - frontend 是否确认正式流程只消费上述字段,不再自行推断 release URL
-- 是否已解决:否
-
-### B2F-015
-
-- 时间:2026-04-02
-- 谁提的:backend
-- 当前事实:
- - backend 已阅读前端多赛道文档:
- - [多赛道 Variant 五层设计草案](D:/dev/cmr-mini/doc/gameplay/多赛道Variant五层设计草案.md)
- - [多赛道 Variant 前后端最小契约](D:/dev/cmr-mini/doc/gameplay/多赛道Variant前后端最小契约.md)
- - backend 认可第一阶段先做“最小契约”,不先做完整后台模型
- - backend 当前建议的第一阶段正式口径为:
- - `play.assignmentMode`
- - `play.courseVariants[]`
- - `id`
- - `name`
- - `description`
- - `routeCode`
- - `selectable`
- - `launch.variant.id`
- - `launch.variant.name`
- - `launch.variant.routeCode`
- - `launch.variant.assignmentMode`
- - `session / ongoing / recent / result` 摘要中补:
- - `variantId`
- - `variantName`
- - `routeCode`
- - backend 第一阶段实现目标仍然保持保守:
- - 一个 session 只绑定一个最终 `variantId`
- - `launch` 返回最终绑定结果
- - 恢复链不重新分配 variant
- - 当前兼容性约束:
- - 如果 `assignmentMode=manual` 且前端暂时未传 `variantId`
- - backend 当前会先回退到首个可选 variant,避免旧主链直接被打断
- - backend 当前已完成第一阶段最小实现:
- - `GET /events/{eventPublicID}/play`
- - `POST /events/{eventPublicID}/launch`
- - `GET /me/entry-home`
- - `GET /sessions/{sessionPublicID}`
- - `GET /sessions/{sessionPublicID}/result`
- - `GET /me/results`
- - `GET /me/sessions`
- - 上述链路已能携带第一阶段 variant 摘要字段
-- 需要对方确认什么:
- - frontend 可按这组字段开始第一阶段联调
-- 是否已解决:是
-
---
## 已确认
-### B2F-033
+### B2F-043
-- 时间:2026-04-03 17:25:35
+- 时间:2026-04-07 13:51:50
- 谁提的:backend
- 当前事实:
- - backend 已把玩法切换对应的联调资源补齐到 workbench:
- - `presentation schema`
- - `content manifest`
- - `asset manifest`
- - 玩法切换现在会自动填真实 dev 资源地址,不再继续保留 `example.com` 占位:
- - `GET /dev/demo-assets/presentations/{demoKey}`
- - `GET /dev/demo-assets/content-manifests/{demoKey}`
- - 当前联调样例文案也已统一成中文活动样例,便于 frontend 直接核对页面显示与日志事实
-- 需要对方确认什么:
- - frontend 如需核对当前玩法对应的展示/内容输入,可直接对照 workbench 当前表单值与上述两条 dev 资源地址
-- 是否已解决:是
-
-### B2F-027
-
-- 时间:2026-04-03 14:37:00
-- 谁提的:backend
-- 当前事实:
- - workbench 已提供 3 个显式玩法测试入口:
- - `Use Classic Demo`
- - `Use Score-O Demo`
- - `Use Manual Variant Demo`
- - 对应联调 event 为:
- - `evt_demo_001`
- - `evt_demo_score_o_001`
- - `evt_demo_variant_manual_001`
- - 积分赛入口已固定到:
- - `rel_demo_score_o_001`
- - `score-o.json`
-- 需要对方确认什么:
- - frontend 后续若要测顺序赛或积分赛,优先使用上述显式入口,而不是自行猜 event/release
-- 是否已解决:是
-
-### B2F-026
-
-- 时间:2026-04-03 14:29:42
-- 谁提的:backend
-- 当前事实:
- - backend 已把 manual 多赛道 demo 的赛道输入切到真实 KML
- - 当前 `Bootstrap Demo` 会准备两条真实赛道输入:
- - `variant_a -> c01.kml`
- - `variant_b -> c02.kml`
- - 当前地图仍继续共用同一组真实 tiles / mapmeta,这符合当前多赛道联调阶段的实际需要
-- 需要对方确认什么:
- - frontend 如需回归 manual 多赛道,请先重新执行一次 `Bootstrap Demo`
-- 是否已解决:是
-
-### B2F-025
-
-- 时间:2026-04-03 14:21:24
-- 谁提的:backend
-- 当前事实:
- - backend 已开始“真实输入替换第一刀”
- - 当前 `Bootstrap Demo` 不再给生产骨架使用 `example.com` 占位赛道/地图地址
- - 当前已改成真实可访问输入:
- - `CourseSource.fileUrl = https://oss-mbh5.colormaprun.com/gotomars/kml/lxcb-001/10/c01.kml`
- - `TileRelease.tileBaseUrl = https://oss-mbh5.colormaprun.com/gotomars/map/lxcb-001/tiles/`
- - `TileRelease.metaUrl = https://oss-mbh5.colormaprun.com/gotomars/map/lxcb-001/tiles/meta.json`
- - 这批真实输入仍走同一条一键回归链,不要求 frontend 改联调方式
-- 需要对方确认什么:
- - frontend 如需复验真实输入,请重新执行一次 `Bootstrap Demo`
-- 是否已解决:是
-
-### B2F-024
-
-- 时间:2026-04-03 20:10:25
-- 谁提的:backend
-- 当前事实:
- - backend 已确认 `evt_demo_variant_manual_001` 曾存在历史残留的 `launched` session,导致 `play.primaryAction=continue`
- - backend 已把清理逻辑并入 `POST /dev/bootstrap-demo`
- - 现在每次准备 demo 数据时,都会自动把 demo event 下残留的:
- - `launched`
- - `running`
- session 改成 `cancelled`
- - 这意味着前端后续再用标准测试链回归时,不需要手工清理旧 demo ongoing
-- 需要对方确认什么:
- - frontend 遇到这类“明明本地没有恢复快照,但后端仍返回 continue”的情况,优先先重新执行一次 `Bootstrap Demo`
-- 是否已解决:是
-
-### B2F-023
-
-- 时间:2026-04-03 13:24:38
-- 谁提的:backend
-- 当前事实:
- - backend 已把标准联调回归收成一键流
- - workbench 当前新增:
- - `一键标准回归`
- - `回归结果汇总`
- - 这条链会在标准发布链之后继续自动验证:
- - `GET /events/{eventPublicID}/play`
- - `POST /events/{eventPublicID}/launch`
- - `GET /sessions/{sessionPublicID}/result`
- - `GET /me/sessions`
- - `GET /me/results`
- - 回归结果会直接显示分项通过/未通过,不再要求 frontend 自己口头判断
-- 需要对方确认什么:
- - frontend 当前回归优先使用这条一键标准回归链
-- 是否已解决:是
-
-### B2F-022
-
-- 时间:2026-04-03 13:18:42
-- 谁提的:backend
-- 当前事实:
- - backend 当前已进入“联调标准化阶段”
- - 当前推荐 frontend 优先使用 workbench 的:
- - `Bootstrap Demo`
- - `一键补齐 Runtime 并发布`
- 作为联调回归入口
- - backend 现在提供的不是零散 demo 文本,而是一套可重复创建的真实测试对象:
- - `place`
- - `map asset`
- - `tile release`
- - `course source`
- - `course set`
- - `course variant`
- - `runtime binding`
- - `presentation`
- - `content bundle`
- - `release`
- - 如果联调失败,workbench 当前会直接给出:
- - 分步日志
- - 真实错误消息
- - stack
- - 最后一次 curl
- - 预期判定
-- 需要对方确认什么:
- - frontend 回归时优先基于这条一键测试链,不再先手工拼测试数据
-- 是否已解决:是
-
-### B2F-019
-
-- 时间:2026-04-03 12:36:15
-- 谁提的:backend
-- 当前事实:
- - backend 已完成活动运营域第二阶段第四刀的后台最小实现:
- - `presentation import`
- - `event 默认 active 绑定`
- - `publish` 默认继承
- - 本刀没有改前端当前稳定消费字段语义:
- - `resolvedRelease`
- - `business`
- - `variant`
- - `runtime`
- - `presentation`
- - `contentBundle`
- - 这次新增能力主要影响后台运营链和发布默认行为,不要求 frontend 立即改接入
+ - backend 已开始提供运维入口第一期:
+ - `POST /admin/ops/tile-releases/import`
+ - `POST /admin/ops/course-sets/import-kml-batch`
+ - backend 也已开始提供统一资源纳管入口:
+ - `GET /admin/assets`
+ - `POST /admin/assets/register-link`
+ - `POST /admin/assets/upload`
+ - `GET /admin/assets/{assetPublicID}`
+ - 当前已新增独立运维工作台:
+ - `GET /admin/ops-workbench`
+ - 这批接口与页面主要服务运维录入和发布准备,不要求 frontend 直接接入。
+ - 当前 API 总数同步更新为:
+ - `101`
- 需要对方确认什么:
- 无
- 是否已解决:是
-### B2F-018
+### B2F-042
-- 时间:2026-04-03 11:22:50
+- 时间:2026-04-07 12:38:13
- 谁提的:backend
- 当前事实:
- - backend 已进入活动运营域第二阶段第二刀
- - 当前客户端可消费新增摘要:
- - `GET /events/{eventPublicID}` 返回:
- - `currentPresentation`
- - `currentContentBundle`
- - `GET /events/{eventPublicID}/play` 返回:
- - `currentPresentation`
- - `currentContentBundle`
- - `POST /events/{eventPublicID}/launch` 返回:
- - `launch.presentation`
- - `launch.contentBundle`
- - 当前字段只做摘要透出,不下发复杂 schema
- - 当前旧字段保持完全兼容:
- - `resolvedRelease`
- - `business`
- - `variant`
- - `runtime`
-- 需要对方确认什么:
- - frontend 后续如要消费活动运营域摘要,先以这些新增摘要字段为准
-- 是否已解决:是
-
-### B2F-003
-
-- 时间:2026-04-02
-- 谁提的:backend
-- 当前事实:
- - backend 已确认 session 三态正式语义:
- - 正常完成 -> `finished`
- - 超时或规则失败 -> `failed`
- - 主动退出 / 放弃恢复 -> `cancelled`
-- 需要对方确认什么:
- - frontend 按这套语义继续联调
-- 是否已解决:是
-
-### B2F-004
-
-- 时间:2026-04-01
-- 谁提的:backend
-- 当前事实:
- - 正式联调时不应回退到本地样例配置路径
- - 不应直接读取根目录 `event/*.json`
- - 应只认 launch 返回的 `manifestUrl`
+ - manual 多赛道 demo 当前已切到 4 条正式 OSS KML:
+ - `gotomars/kml/lxcb-001/2026-04-07/route01.kml`
+ - `gotomars/kml/lxcb-001/2026-04-07/route02.kml`
+ - `gotomars/kml/lxcb-001/2026-04-07/route03.kml`
+ - `gotomars/kml/lxcb-001/2026-04-07/route04.kml`
+ - frontend 当前只需消费发布结果,无需读取本地临时目录。
- 需要对方确认什么:
- 无
- 是否已解决:是
-### B2F-005
+### B2F-034
-- 时间:2026-04-01
+- 时间:2026-04-07 09:46:00
- 谁提的:backend
- 当前事实:
- - 接口说明优先看 workbench 里的中文 API 列表
- - 深入字段说明再看 [接口清单](D:/dev/cmr-mini/backend/docs/接口清单.md)
+ - 玩家进入游戏只认“已发布 release”。
+ - `currentPresentation / currentContentBundle` 当前表示的是:
+ - 当前已发布 release 实际绑定的展示版本
+ - 当前已发布 release 实际绑定的内容包
+ - 它们不是 event 草稿默认值。
+ - `play.canLaunch` 当前已收紧,不是“有 release 就真”。
- 需要对方确认什么:
- - 无
+ - frontend 页面文案应按“当前发布展示版本 / 当前发布内容包版本”理解。
- 是否已解决:是
-### B2F-006
+### B2F-032
-- 时间:2026-04-02
+- 时间:2026-04-03 18:42:00
- 谁提的:backend
- 当前事实:
- - backend 已确认“放弃恢复”官方语义为 `POST /sessions/{sessionPublicID}/finish` 且 `status=cancelled`
- - 同一局的旧 `sessionToken` 在该场景允许继续用于 `finish(cancelled)`
- - `cancelled` 和 `failed` 后都不会再作为 `ongoingSession` 返回
-- 需要对方确认什么:
- - frontend 可正式把“放弃恢复”接到 `finish(cancelled)`
-- 是否已解决:是
-
-### B2F-007
-
-- 时间:2026-04-02
-- 谁提的:backend
-- 当前事实:
- - backend 已把 `start / finish` 收口成幂等处理
- - 重复 `start`:
- - `launched` -> 推进到 `running`
- - `running` / 终态 -> 直接返回当前 session
- - 重复 `finish`:
- - 已终态 -> 直接返回当前 session / result
-- 需要对方确认什么:
- - frontend 继续按当前补报 / 重试逻辑联调
-- 是否已解决:是
-
-### B2F-016
-
-- 时间:2026-04-02
-- 谁提的:backend
-- 当前事实:
- - backend 已确认 `launch` 当前关键字段为前端正式联调契约:
- - `resolvedRelease.manifestUrl`
- - `resolvedRelease.releaseId`
- - `business.sessionId`
- - `business.sessionToken`
- - `business.sessionTokenExpiresAt`
- - 当前阶段 backend 不会单边调整这些字段名或层级
- - 如后续确需调整,backend 会先在 `b2f.md` 明确通知,再安排联调变更
-- 需要对方确认什么:
- - frontend 继续按当前字段接入,不做额外推断
-- 是否已解决:是
-
-### B2F-017
-
-- 时间:2026-04-02
-- 谁提的:backend
-- 当前事实:
- - backend 已完成对 ongoing 口径的代码回归确认
- - 当前实现中:
- - 只有 `launched` 和 `running` 会被识别为 ongoing
- - `cancelled`、`failed`、`finished` 都不会再进入 ongoing
- - `/me/entry-home` 与 `/events/{eventPublicID}/play` 当前都复用同一 ongoing 判定逻辑
- - `/me/results` 当前只返回终态 session:
- - `finished`
- - `failed`
- - `cancelled`
- - 当前首页摘要、play 摘要、result 详情都会复用同一组 session 基础摘要字段:
- - `id`
- - `status`
+ - backend 已接收 frontend 调试日志,并以此作为联调事实依据。
+ - 当前建议前端日志至少带:
- `eventId`
- - `eventName`
- `releaseId`
- - `configLabel`
- - `routeCode`
+ - `manifestUrl`
+ - `game.mode`
+ - `playfield.kind`
+ - `details.seq`
- 需要对方确认什么:
- - frontend 可以按这套 ongoing / result 口径继续回归
+ - frontend 继续按结构化日志回传事实,不靠截图猜测。
- 是否已解决:是
---
## 阻塞
-### B2F-008
-
-- 时间:2026-04-01
-- 谁提的:backend
-- 当前事实:
- - 如果 frontend 再出现 manifest 加载失败,backend 仅靠一句“加载失败”无法定位
-- 需要对方确认什么:
- - 如再出现此类问题,请一次性提供:
- - `eventPublicID`
- - `releaseId`
- - `manifestUrl`
- - 页面报错文案
- - 控制台日志
- - 网络请求日志
-- 是否已解决:否
+- 当前无 backend 侧新增阻塞。
+- 若 frontend 发现问题,请直接回传:
+ - 当前 `eventId`
+ - 当前 `releaseId`
+ - 当前 `manifestUrl`
+ - 当前页面阶段
+ - 结构化日志片段
---
## 已完成
-### B2F-009
+### 归档摘要(保留必要结论)
-- 时间:2026-04-01
+- 时间:2026-04-07 12:18:00
- 谁提的:backend
- 当前事实:
- - backend 已修复 `publish build` 只写 DB、不上传 OSS 的问题
- - 新发布的 demo release manifest 已可正常访问
- - 当前可用 release:
- - `eventPublicID = evt_demo_001`
- - `releaseId = rel_e7dd953743c5c0d2`
- - `manifestUrl = https://oss-mbh5.colormaprun.com/gotomars/event/releases/evt_demo_001/rel_e7dd953743c5c0d2/manifest.json`
-- 需要对方确认什么:
- - 无
-- 是否已解决:是
-
-### B2F-010
-
-- 时间:2026-04-01
-- 谁提的:backend
-- 当前事实:
- - backend workbench 已支持中文 API 列表
- - 当前可用于日常联调:
- - `POST /dev/bootstrap-demo`
- - `GET /dev/workbench`
-- 需要对方确认什么:
- - 无
-- 是否已解决:是
-
-### B2F-011
-
-- 时间:2026-04-02
-- 谁提的:backend
-- 当前事实:
- - backend 已新增后台第一版资源对象接口:
- - `/admin/maps`
- - `/admin/playfields`
- - `/admin/resource-packs`
- - backend 已新增后台 `event` 组装接口:
- - `/admin/events`
- - `/admin/events/{eventPublicID}/source`
- - 这批接口主要服务后续后台配置运营,不影响当前小程序主链联调
-- 需要对方确认什么:
- - 无
-- 是否已解决:是
-
-### B2F-012
-
-- 时间:2026-04-02
-- 谁提的:backend
-- 当前事实:
- - backend 已补后台运营闭环接口:
- - `GET /admin/events/{eventPublicID}/pipeline`
- - `POST /admin/sources/{sourceID}/build`
- - `GET /admin/builds/{buildID}`
- - `POST /admin/builds/{buildID}/publish`
- - 当前后台侧已经可以完成:
- - 资源对象录入
- - event source 组装
- - preview build
- - publish release
-- 需要对方确认什么:
- - 无
-- 是否已解决:是
-
-### B2F-013
-
-- 时间:2026-04-02
-- 谁提的:backend
-- 当前事实:
- - backend 已补后台 `rollback` 接口:
- - `POST /admin/events/{eventPublicID}/rollback`
- - 当前后台侧已具备完整最小闭环:
- - 资源对象
- - event source 组装
- - build
- - publish
- - rollback
-- 需要对方确认什么:
- - 无
-- 是否已解决:是
-
-### B2F-018
-
-- 时间:2026-04-02
-- 谁提的:backend
-- 当前事实:
- - backend 已补一条可联调的 `manual` 多赛道 demo 活动:
- - `eventPublicID = evt_demo_variant_manual_001`
- - `releaseId = rel_demo_variant_manual_001`
- - `channelCode = mini-demo`
- - `channelType = wechat_mini`
- - 当前 demo 配置为:
+ - `Bootstrap Demo` 当前会准备三条标准 demo 的基础已发布态:
+ - `evt_demo_001`
+ - `evt_demo_score_o_001`
+ - `evt_demo_variant_manual_001`
+ - 三条 demo 当前都已清理历史残留 ongoing session。
+ - manual 多赛道当前已确认:
- `assignmentMode = manual`
- - `courseVariants = [variant_a, variant_b]`
- - 当前两条可选赛道:
- - `variant_a`
- - `name = A 线`
- - `routeCode = route-variant-a`
- - `variant_b`
- - `name = B 线`
- - `routeCode = route-variant-b`
- - 该活动已由 `POST /dev/bootstrap-demo` 自动准备
+ - `variantCount = 2`
+ - `detailCanLaunch = true`
+ - 积分赛当前已确认:
+ - `game.mode = score-o`
+ - `playfield.kind = control-set`
+ - 活动列表当前已能稳定返回 3 张标准 demo 卡片。
- 需要对方确认什么:
- 无
- 是否已解决:是
-### B2F-019
-
-- 时间:2026-04-02
-- 谁提的:backend
-- 当前事实:
- - backend 已完成 `variant_b` 的 service 层回归验证
- - 已确认从 `launch` 选定的 `variantId` 会稳定回流到:
- - `GET /me/entry-home`
- - `GET /sessions/{sessionPublicID}/result`
- - `GET /me/results`
- - 实测链路为:
- - `play.assignmentMode=manual`
- - `play.courseVariants=2`
- - `launch.variant.id=variant_b`
- - `entry-home recent.variantId=variant_b`
- - `result.session.variantId=variant_b`
- - `results[0].session.variantId=variant_b`
-- 需要对方确认什么:
- - 无
-- 是否已解决:是
-
-### B2F-020
-
-- 时间:2026-04-03 09:43:20
-- 谁提的:backend
-- 当前事实:
- - backend 已在保持旧字段不变的前提下,为 `launch` 新增兼容字段:
- - `launch.runtime`
- - 当前最小字段包括:
- - `runtimeBindingId`
- - `placeId`
- - `mapId`
- - `tileReleaseId`
- - `courseSetId`
- - `courseVariantId`
- - 这是一组新增字段,不替代也不改变现有:
- - `resolvedRelease`
- - `business`
- - `variant`
- - frontend 当前可以忽略该字段,也可以开始做观测和日志透出
-- 需要对方确认什么:
- - 无
-- 是否已解决:是
-
-### B2F-021
-
-- 时间:2026-04-03 12:14:21
-- 谁提的:backend
-- 当前事实:
- - backend 已完成活动运营域第二阶段第三刀第一版
- - 当前活动运营摘要已统一补齐最小字段:
- - `currentPresentation.templateKey`
- - `currentPresentation.version`
- - `currentContentBundle.bundleType`
- - `currentContentBundle.version`
- - `launch.presentation.templateKey`
- - `launch.presentation.version`
- - `launch.contentBundle.bundleType`
- - `launch.contentBundle.version`
- - 上述字段当前已在以下接口可用:
- - `GET /events/{eventPublicID}`
- - `GET /events/{eventPublicID}/play`
- - `POST /events/{eventPublicID}/launch`
- - 旧字段继续完全兼容:
- - `resolvedRelease`
- - `business`
- - `variant`
- - `runtime`
-- 需要对方确认什么:
- - frontend 如果开始消费活动运营摘要细项,请优先读取新增的 `templateKey / version / bundleType`
-- 是否已解决:是
-
---
## 下一步
-### B2F-014
-
-- 时间:2026-04-02
-- 谁提的:backend
-- 当前事实:
- - session P0 已完成一轮收口
- - 当前最值得继续联调确认的是:
- - 放弃恢复 -> `finish(cancelled)`
- - `failed / cancelled` 后 ongoing 消失
- - 重复 `start / finish` 不再打断主链
-- 需要对方确认什么:
- - frontend 当前优先配合:
- - 用当前 demo release 回归 `play -> launch -> map load`
- - 回归“继续恢复 / 放弃恢复”两条路径
- - 如确认进入多赛道第一阶段联调,请先回复 `B2F-015`
- - 如发现状态口径不一致,直接在 `f2b.md` 标具体接口和返回值
-- 是否已解决:否
-
-
+- frontend 继续按当前联调基线推进:
+ - 活动列表第一刀
+ - 详情页/准备页语义收口
+ - 准备页地图预览 V1
+- backend 继续保持:
+ - 一键测试链稳定
+ - 结构化日志可追踪
+ - demo 数据可重复复现
diff --git a/b2t.md b/b2t.md
index f575d05..04be024 100644
--- a/b2t.md
+++ b/b2t.md
@@ -1,64 +1,335 @@
# B2T 协作清单
-> 文档版本:v1.22
-> 最后更新:2026-04-03 19:21:23
+> 文档版本:v1.47
+> 最后更新:2026-04-07 18:15:01
说明:
-- 本文件由 backend 维护,写给总控线程
-- 只写事实和请求
-- 不写长讨论稿
-- 每条固定包含:时间、谁提的、当前事实、需要对方确认什么、是否已解决
+- 本文件由 backend 维护,写给总控
+- 只保留当前主线、有效结论和压缩归档
+- 已完成历史项不再逐条保留长记录
---
## 待确认
-### B2T-009
+### B2T-044
-- 时间:2026-04-03 08:52:11
+- 时间:2026-04-07 17:23:15
- 谁提的:backend
- 当前事实:
- - 总控线程在 [t2b.md](D:/dev/cmr-mini/t2b.md) v1.1 中,已确认 backend 可按增量演进推进第一阶段生产骨架
- - backend 准备按已确认顺序开工:
- - `Place`
- - `MapAsset`
- - `TileRelease`
- - `CourseSource`
- - `CourseSet`
- - `CourseVariant`
- - `MapRuntimeBinding`
- - `EventPresentation` 和 `ContentBundle` 先保留对象语义,第二阶段补完整落库
+ - backend 建议把活动模型先收成最小可玩单元:
+ - `单地图`
+ - `单路线组`
+ - `单玩法`
+ - 当前不建议第一阶段直接支持:
+ - 一个活动多地图
+ - 一个活动多路线组
+ - 一个活动多玩法
+ - 复杂需求优先通过“活动实例化”解决,而不是把单个活动扩成多对多容器。
+ - 多地图 / 多玩法的前台需求,建议通过“组合卡片 / 组合入口层”承接:
+ - 组合入口可以指向多个活动实例
+ - 单个活动实例仍保持最小可玩单元
+ - 这样可以同时简化:
+ - 前台活动卡片逻辑
+ - 发布语义
+ - 结果追溯
+ - 运维后台第一期流程
- 需要对方确认什么:
- - 无新的确认项;本条仅保留为“准备开工”记录
+ - 运维后台与活动编排后续按“单地图 + 单路线组 + 单玩法”继续推进。
+ - 多地图 / 多路线组 / 多玩法先作为后续模板化实例生成能力与组合入口能力,不在第一阶段直接进入活动主模型。
+- 是否已解决:否
+
+### B2T-045
+
+- 时间:2026-04-07 17:52:18
+- 谁提的:backend
+- 当前事实:
+ - 运维后台已继续从“对象平铺页”往“主流程后台”收。
+ - 当前左侧导航改成:
+ - `资源总览`
+ - `地图 / 地点管理`
+ - `路线资源管理`
+ - `活动管理`
+ - `活动编排`
+ - `发布中心`
+ - `资源录入` 作为辅助入口保留
+ - `资源总览` 已提升为运行时信息面板,优先展示关键统计与当前活动的 `release / runtime / presentation / content bundle`。
+ - 地图页已不再平铺关联活动详情,只保留:
+ - 关联活动数量
+ - 默认体验活动数量
+ - 关联活动摘要
+ - 跳转到活动管理
+- 需要对方确认什么:
+ - 运维后台继续按“总览优先 + 单主视图 + 地图页只看地图本身、活动详情去活动管理”这条交互结构推进。
+- 是否已解决:否
+
+### B2T-046
+
+- 时间:2026-04-07 18:03:42
+- 谁提的:backend
+- 当前事实:
+ - `地图 / 地点管理` 已继续从“按钮 + 手填 ID”收成“列表 -> 详情”流程。
+ - 当前已支持:
+ - 地点列表
+ - 地图列表
+ - 地点关键字筛选
+ - 地图关键字筛选
+ - 点地点自动读取地点详情与该地点下地图
+ - 点地图自动读取地图详情、当前瓦片版本、默认活动概况
+ - 地图页继续只保留关联活动数量与摘要,不在地图页平铺活动详情。
+- 需要对方确认什么:
+ - 地图 / 地点管理继续按“列表-详情主流程”推进,活动详情统一留在活动管理 / 活动编排。
+- 是否已解决:否
+
+### B2T-045
+
+- 时间:2026-04-07 17:30:06
+- 谁提的:backend
+- 当前事实:
+ - 运维后台当前已继续从“功能平铺页”收成“导航驱动的流程页”:
+ - 左侧导航
+ - 中间单主视图
+ - 右侧常驻状态栏
+ - 当前不再把地图管理、资源录入、KML 管理、活动管理、发布中心同时平铺在一个长页面里。
+ - 主区当前也已改成宽屏自适应,不再使用固定窄宽度。
+- 需要对方确认什么:
+ - 运维后台后续继续按“符合人使用习惯的流程导航页”推进,不再回退到功能平铺式工作台。
- 是否已解决:是
+### B2T-043
+
+- 时间:2026-04-07 16:45:40
+- 谁提的:backend
+- 当前事实:
+ - 运维后台当前在开发环境里默认免登录。
+ - 本轮已修复 dev-only 鉴权陷阱:
+ - 浏览器残留旧的玩家 token、失效 token 或非 ops token 时
+ - `/ops/admin/*` 之前会返回 `401 invalid_token`
+ - 现在开发环境会自动回退到 dev ops 上下文
+ - 生产环境不变,仍要求正式 ops token。
+- 需要对方确认什么:
+ - 无
+- 是否已解决:是
+
+### B2T-042
+
+- 时间:2026-04-07 16:29:08
+- 谁提的:backend
+- 当前事实:
+ - frontend 反馈游客模式第一刀里:
+ - `GET /public/experience-maps` 正常
+ - `GET /public/events/{eventPublicID}/play` 正常
+ - `POST /public/events/{eventPublicID}/launch` 返回 `500 internal_error`
+ - backend 已定位根因:
+ - 游客 launch 会创建 `guest_device` 身份
+ - 旧库约束不允许 `login_identities.identity_type = 'guest'`
+ - 已补 migration:
+ - [0015_guest_identity.sql](D:/dev/cmr-mini/backend/migrations/0015_guest_identity.sql)
+ - 本次修复不改游客接口契约,只修数据库约束。
+- 需要对方确认什么:
+ - 游客模式第一刀继续以:
+ - `evt_demo_001`
+ 作为基线回归。
+- 是否已解决:是
+
+### B2T-041
+
+- 时间:2026-04-07 16:08:37
+- 谁提的:backend
+- 当前事实:
+ - 运维后台当前已从“底层对象调试台”继续往“主流程管理台”收口:
+ - 地图管理
+ - KML / 赛道管理
+ - 活动管理
+ - 发布中心
+ - 当前已补运维地图主流程接口:
+ - `GET /admin/map-assets`
+ - `PUT /admin/map-assets/{mapAssetPublicID}`
+ - `GET /ops/admin/map-assets`
+ - `PUT /ops/admin/map-assets/{mapAssetPublicID}`
+ - `GET /ops/admin/course-sources`
+ - `GET /ops/admin/course-sources/{sourcePublicID}`
+ - `GET /ops/admin/course-sets/{courseSetPublicID}`
+ - 运维后台当前目标已明确:
+ - 运维先看地图列表
+ - KML / 赛道围绕地图管理
+ - 活动围绕地图查看关联活动、默认体验活动和发布状态
+ - 活动管理当前也已补成与地图同一套路:
+ - 列表
+ - 新建
+ - 修改
+ - 读取详情
+ - 当前新增:
+ - `POST /ops/admin/events`
+ - `PUT /ops/admin/events/{eventPublicID}`
+ - 当前 API 总数更新为:
+ - `115`
+- 需要对方确认什么:
+ - 运维后台第一期继续按“地图列表 -> 地图详情 -> KML / 赛道 -> 活动绑定 -> 发布中心”这条主流程推进。
+ - 不再把 `Place / MapAsset / TileRelease / CourseSource / CourseSet` 直接当首页认知入口。
+- 是否已解决:否
+
+### B2T-040
+
+- 时间:2026-04-07 20:12:00
+- 谁提的:backend
+- 当前事实:
+ - backend 已补游客模式第一刀最小公开接口:
+ - `GET /public/experience-maps`
+ - `GET /public/experience-maps/{mapAssetPublicID}`
+ - `GET /public/events/{eventPublicID}`
+ - `GET /public/events/{eventPublicID}/play`
+ - `POST /public/events/{eventPublicID}/launch`
+ - 当前规则:
+ - 只允许默认体验活动
+ - 只允许基于已发布 release
+ - guest launch 使用独立 `guest_device` 身份落到正常 session 模型
+ - 返回 `launch.business.isGuest = true`
+ - 当前 API 总数更新为:
+ - `106`
+- 需要对方确认什么:
+ - 游客模式第一刀按这组公开接口继续前后端联调。
+ - 若后续需要 guest 结果页或 guest 本地成绩迁移,再作为下一刀,不混入当前最小链。
+- 是否已解决:否
+
+### B2T-039
+
+- 时间:2026-04-07 16:42:15
+- 谁提的:backend
+- 当前事实:
+ - 运维后台已开始落地“地图资源管理第一刀”:
+ - `GET /ops/admin/places`
+ - `POST /ops/admin/places`
+ - `GET /ops/admin/places/{placePublicID}`
+ - `POST /ops/admin/places/{placePublicID}/map-assets`
+ - `GET /ops/admin/map-assets/{mapAssetPublicID}`
+ - `POST /ops/admin/map-assets/{mapAssetPublicID}/tile-releases`
+ - `/admin/ops-workbench` 当前已新增独立“地图资源管理”区,可直接:
+ - 建地点
+ - 建地图
+ - 看当前瓦片版本
+ - 看默认活动摘要
+- 需要对方确认什么:
+ - 运维后台第一期按“地图资源管理 -> 资源录入 -> 赛道集管理 -> 活动绑定 -> 发布中心”这条顺序继续推进。
+- 是否已解决:否
+
+### B2T-038
+
+- 时间:2026-04-07 16:08:20
+- 谁提的:backend
+- 当前事实:
+ - backend 已按“地图列表下的默认活动”补最小公开接口:
+ - `GET /experience-maps`
+ - `GET /experience-maps/{mapAssetPublicID}`
+ - 默认活动关系当前统一收口到:
+ - `events.is_default_experience`
+ - `events.show_in_event_list`
+ - 当前已发布 `release` 绑定到的 `runtime.mapAsset`
+ - `cards / home / me/entry-home` 当前也已补:
+ - `showInEventList`
+- 需要对方确认什么:
+ - 后续活动列表与地图入口第一刀以这组字段为准继续联调。
+ - 如需更复杂地图分组、排序、租户筛选,放到下一刀,不在这次最小实现里扩。
+- 是否已解决:否
+
+### B2T-034
+
+- 时间:2026-04-07 13:12:00
+- 谁提的:backend
+- 当前事实:
+ - backend 已按“准备页地图预览 V1”预留最小字段到:
+ - `GET /events/{eventPublicID}`
+ - `GET /events/{eventPublicID}/play`
+ - 当前 preview 仍保持:
+ - 只读增强项
+ - 不进入正式 launch 主链
+ - 不单独造新地图资源体系
+- 需要对方确认什么:
+ - backend 继续按“只读预览 V1”推进,不扩新对象层级。
+- 是否已解决:否
+
---
## 已确认
-### B2T-031
+### B2T-037
-- 时间:2026-04-03 19:21:23
+- 时间:2026-04-07 14:45:37
- 谁提的:backend
- 当前事实:
- - frontend 本轮已通过结构化调试日志确认以下链路正常:
- - 活动列表页当前能稳定拿到 3 张 demo 卡片
- - 多赛道入口点击后能进入正确活动:
- - `evt_demo_variant_manual_001`
- - 多赛道详情当前已拿到:
- - `assignmentMode = manual`
- - `variantCount = 2`
- - `detailCanLaunch = true`
- - 当前发布 `presentation / content bundle` 摘要正常
- - backend 当前可确认:
- - manual 多赛道 demo 的当前发布 release 已切到正确版本
- - 活动列表最小产品化第一刀与前端当前接线口径一致
- - frontend 最新 `f2b.md` 当前也已将:
- - `F2B-014`
- 标记为已确认
- - 当前仍留一条未完全收口的旧项:
- - `F2B-011`
- - 即 demo 活动历史 ongoing session 回收口径,需要后续单独收掉
+ - 运维后台当前已开始与玩家链路分离:
+ - 运维账号接口:`/ops/auth/*`
+ - 运维管理接口:`/ops/admin/*`
+ - `/admin/ops-workbench` 当前只服务运维录资源、活动绑定、发布中心。
+ - 开发环境当前默认免登录放行,方便录资源和调发布;生产环境再收口到手机号验证码运维账号。
+ - 运维总览统计当前已改为只读取服务端聚合摘要,不再用前端列表长度硬凑。
+- 需要对方确认什么:
+ - 无
+- 是否已解决:是
+
+### B2T-036
+
+- 时间:2026-04-07 13:51:50
+- 谁提的:backend
+- 当前事实:
+ - backend 已开始落地运维入口第一期,不再只靠手工脚本或改代码上传资源。
+ - 当前先开放两条最小录入链:
+ - `POST /admin/ops/tile-releases/import`
+ - `POST /admin/ops/course-sets/import-kml-batch`
+ - 运维入口第二期也已起步,开始支持统一资源纳管:
+ - `GET /admin/assets`
+ - `POST /admin/assets/register-link`
+ - `POST /admin/assets/upload`
+ - `GET /admin/assets/{assetPublicID}`
+ - 当前已新增独立运维工作台:
+ - `GET /admin/ops-workbench`
+ - 当前开始把:
+ - `/dev/workbench`
+ - `/admin/ops-workbench`
+ 两套入口按“调试后台 / 运维后台”正式拆开
+ - `Import Tile Release / Import KML Batch` 已从 `/dev/workbench` 主操作区迁到:
+ - `/admin/ops-workbench`
+ - `/dev/workbench` 当前只保留运维入口说明与跳转
+ - `/admin/ops-workbench` 当前已收成第一版运维台结构:
+ - 资源总览
+ - 资源录入
+ - 赛道集管理
+ - 活动绑定
+ - 发布中心
+ - 当前 API 总数同步更新为:
+ - `101`
+- 需要对方确认什么:
+ - 无
+- 是否已解决:是
+
+### B2T-035
+
+- 时间:2026-04-07 12:38:13
+- 谁提的:backend
+- 当前事实:
+ - 正式资源目录约束已收口:
+ - 正式资源只认 `OSS / CDN`
+ - 本地 `tmp/` 只作为临时收件箱,不作为正式发布源
+ - manual 多赛道 demo 当前已切到 4 条正式 OSS KML:
+ - `gotomars/kml/lxcb-001/2026-04-07/route01.kml`
+ - `gotomars/kml/lxcb-001/2026-04-07/route02.kml`
+ - `gotomars/kml/lxcb-001/2026-04-07/route03.kml`
+ - `gotomars/kml/lxcb-001/2026-04-07/route04.kml`
+- 需要对方确认什么:
+ - 无
+- 是否已解决:是
+
+### B2T-033
+
+- 时间:2026-04-07 10:55:40
+- 谁提的:backend
+- 当前事实:
+ - `Bootstrap Demo(只准备数据)` 当前会准备三条标准 demo 的基础已发布态:
+ - `runtime`
+ - `presentation`
+ - `content bundle`
+ - 当前 release
+ - frontend 从首页点三种玩法时,已可直接按“当前已发布 release”语义联调。
- 需要对方确认什么:
- 无
- 是否已解决:是
@@ -68,12 +339,11 @@
- 时间:2026-04-03 22:34:08
- 谁提的:backend
- 当前事实:
- - backend 已按 `活动卡片列表最小产品化配合阶段` 落完第一刀最小摘要字段
- - 当前以下返回已统一补齐活动卡片最小摘要:
+ - backend 已完成活动卡片列表最小产品化第一刀:
- `GET /cards`
- `GET /home`
- `GET /me/entry-home`
- - 当前摘要字段包括:
+ - 已补齐最小摘要字段:
- `summary`
- `status`
- `statusCode`
@@ -83,12 +353,6 @@
- `eventType`
- `currentPresentation`
- `currentContentBundle`
- - 当前实现继续保持:
- - 不新增对象层级
- - 不改活动详情页现有语义
- - `currentPresentation / currentContentBundle` 仍表示当前已发布 release 摘要
- - backend 同时已通过 `0011_card_summary.sql` 给 `cards` 落了显式字段:
- - `is_default_experience`
- 需要对方确认什么:
- 无
- 是否已解决:是
@@ -98,135 +362,11 @@
- 时间:2026-04-03 16:16:38
- 谁提的:backend
- 当前事实:
- - backend 已新增 dev 调试日志入口:
+ - backend 已提供联调结构化日志通道:
- `POST /dev/client-logs`
- `GET /dev/client-logs`
- `DELETE /dev/client-logs`
- - workbench 已新增 `前端调试日志` 面板,用于直接查看 frontend 主动上报的关键事实
- - 这套能力只服务联调排查,不替代正式生产日志体系
- - 当前目标是把“截图 + 口头描述”进一步收口成结构化事实,便于 backend 直接定位前端消费链问题
-- 需要对方确认什么:
- - 无
-- 是否已解决:是
-
-### B2T-027
-
-- 时间:2026-04-03 15:29:07
-- 谁提的:backend
-- 当前事实:
- - backend 已在 workbench 新增:
- - `当前 Launch 实际配置摘要`
- - 该摘要由 backend 代读当前 launch 对应 manifest,用于统一显示:
- - `configUrl`
- - `releaseId`
- - `manifestUrl`
- - `schemaVersion`
- - `playfield.kind`
- - `game.mode`
- - 该能力只用于联调排查,避免浏览器直接读取 OSS 时受跨域影响
- - 正式客户端链路不变,仍应直接消费:
- - `launch.config.configUrl`
- - `launch.resolvedRelease.manifestUrl`
-- 需要对方确认什么:
- - 无
-- 是否已解决:是
-
-### B2T-026
-
-- 时间:2026-04-03 14:37:00
-- 谁提的:backend
-- 当前事实:
- - backend 已把当前两种核心玩法和 manual 多赛道都挂成显式 demo 入口:
- - 顺序赛:`evt_demo_001`
- - 积分赛:`evt_demo_score_o_001`
- - 多赛道:`evt_demo_variant_manual_001`
- - 其中积分赛入口已固定到远端发布配置:
- - `https://oss-mbh5.colormaprun.com/gotomars/event/score-o.json`
- - workbench 当前可直接切换三种玩法做标准联调
-- 需要对方确认什么:
- - 无
-- 是否已解决:是
-
-### B2T-001
-
-- 时间:2026-04-03 08:52:11
-- 谁提的:backend
-- 当前事实:
- - 总控线程已确认 backend 采用增量演进方式推进
- - 第一阶段不要求一次性推翻当前已稳定联调的:
- - `Event`
- - `EventRelease`
- - `Session`
- 主链
-- 需要对方确认什么:
- - 无
-- 是否已解决:是
-
-### B2T-002
-
-- 时间:2026-04-03 08:52:11
-- 谁提的:backend
-- 当前事实:
- - 总控线程已确认第一阶段优先落库对象顺序:
- - `Place`
- - `MapAsset`
- - `TileRelease`
- - `CourseSource`
- - `CourseSet`
- - `CourseVariant`
- - `MapRuntimeBinding`
- - backend 接受这条顺序,后续按此推进
-- 需要对方确认什么:
- - 无
-- 是否已解决:是
-
-### B2T-003
-
-- 时间:2026-04-03 08:52:11
-- 谁提的:backend
-- 当前事实:
- - 总控线程已确认 `launch` 采用两阶段兼容
- - 第一阶段保留当前稳定联调字段:
- - `resolvedRelease`
- - `business`
- - `variant`
- - 第二阶段再补完整运行对象字段:
- - `placeId`
- - `mapId`
- - `tileReleaseId`
- - `courseVariantId`
- - `eventReleaseId`
-- 需要对方确认什么:
- - 无
-- 是否已解决:是
-
-### B2T-004
-
-- 时间:2026-04-03 08:52:11
-- 谁提的:backend
-- 当前事实:
- - backend 认可总控线程的核心边界:
- - 客户端最终只消费发布产物
- - 不再直接消费原始 KML
- - 不再直接消费地图原始资产
- - 这与 backend 当前已稳定的:
- - `EventRelease`
- - `launch -> resolvedRelease`
- - `session -> result`
- 主链一致
-- 需要对方确认什么:
- - 无
-- 是否已解决:是
-
-### B2T-005
-
-- 时间:2026-04-03 08:52:11
-- 谁提的:backend
-- 当前事实:
- - backend 认可总控线程提出的两域拆分:
- - 地图运行域
- - 活动运营域
- - `CourseVariant` 作为最终运行事实,也与当前多赛道第一阶段契约一致
+ - workbench 已有前端调试日志面板。
- 需要对方确认什么:
- 无
- 是否已解决:是
@@ -235,321 +375,36 @@
## 阻塞
-- 当前无新增阻塞
+- 当前无 backend 主线阻塞。
+- 当前不建议开启:
+ - 新对象扩张
+ - 正式后台 UI
+ - 与活动系统最小成品闭环无关的新玩家功能
---
## 已完成
-### B2T-030
+### 归档摘要(保留必要结论)
-- 时间:2026-04-03 19:08:55
+- 时间:2026-04-07 12:18:00
- 谁提的:backend
- 当前事实:
- - backend 当前已完成并稳定运行的主线可概括为三段:
- - 联调标准化阶段
- - 真实输入替换第一刀
- - 活动卡片列表最小产品化第一刀
- - 联调标准化阶段当前已具备:
+ - 联调标准化阶段已完成:
- 一键测试链
- 详细日志
- 稳定 demo 数据
- - workbench 回归结果汇总
- - frontend 调试日志通道
- - 真实输入替换第一刀当前已完成:
+ - workbench 回归汇总
+ - 真实输入替换第一刀已完成:
- 真实 KML
- 真实地图 URL
- - demo content manifest / presentation schema 通过 backend dev 资源入口提供
+ - dev content/presentation 入口
- 中文活动文案样例
- - 活动卡片列表最小产品化第一刀当前已完成:
- - `GET /cards`
- - `GET /home`
- - `GET /me/entry-home`
- 统一补齐活动卡片摘要字段
- - 当前卡片最小摘要字段包括:
- - `summary`
- - `status`
- - `statusCode`
- - `timeWindow`
- - `ctaText`
- - `isDefaultExperience`
- - `eventType`
- - `currentPresentation`
- - `currentContentBundle`
- - 当前阶段 backend 仍保持:
- - 不扩新对象层级
- - 不推翻现有 `Event / EventRelease / Session`
- - 继续以标准联调链为唯一基线
-- 需要对方确认什么:
- - 无
-- 是否已解决:是
-
-### B2T-029
-
-- 时间:2026-04-03 17:25:35
-- 谁提的:backend
-- 当前事实:
- - backend 已把“真实输入替换第一刀”继续推进到:
- - `content manifest`
- - `presentation schema`
- - 中文活动文案样例
- - 当前 workbench 的玩法切换会自动填充 backend 内置 demo 资源:
- - `GET /dev/demo-assets/presentations/{demoKey}`
- - `GET /dev/demo-assets/content-manifests/{demoKey}`
- - 这两条路由只服务联调,不进入正式客户端运行链路
- - `Bootstrap Demo` 当前准备的联调样例文案已统一为中文活动样例,不再继续暴露一批 `Demo ...` 名称
-- 需要对方确认什么:
- - 无
-- 是否已解决:是
-
-### B2T-024
-
-- 时间:2026-04-03 14:21:24
-- 谁提的:backend
-- 当前事实:
- - backend 已按 [t2b.md](D:/dev/cmr-mini/t2b.md) v1.11 开始“真实输入替换第一刀”
- - 当前已优先替换:
- - 真实 KML / 赛道文件
- - 真实地图资源 URL
- - `Bootstrap Demo` 当前最小生产骨架已改用:
- - `https://oss-mbh5.colormaprun.com/gotomars/kml/lxcb-001/10/c01.kml`
- - `https://oss-mbh5.colormaprun.com/gotomars/map/lxcb-001/tiles/`
- - `https://oss-mbh5.colormaprun.com/gotomars/map/lxcb-001/tiles/meta.json`
- - 当前仍保持同一条标准联调入口,不新增新流程
-- 需要对方确认什么:
- - 无
-- 是否已解决:是
-
-### B2T-025
-
-- 时间:2026-04-03 14:29:42
-- 谁提的:backend
-- 当前事实:
- - backend 已继续推进“真实输入替换第一刀”的 manual 多赛道部分
- - 当前 `Bootstrap Demo` 已为 demo course set 准备两条真实 KML 输入:
- - `c01.kml`
- - `c02.kml`
- - 当前 manual 多赛道 demo 的说明已同步为:
- - `variant_a -> c01.kml`
- - `variant_b -> c02.kml`
- - 当前仍沿用同一条标准联调入口,不新增新流程
-- 需要对方确认什么:
- - 无
-- 是否已解决:是
-
-### B2T-023
-
-- 时间:2026-04-03 13:24:38
-- 谁提的:backend
-- 当前事实:
- - backend 已把标准联调入口继续固化为一键回归流
- - workbench 当前新增:
- - `一键标准回归`
- - `回归结果汇总`
- - 这条链当前会在:
- - `Bootstrap Demo`
- - `一键补齐 Runtime 并发布`
- 之后,继续自动验证:
- - `play`
- - `launch`
- - `result`
- - `history`
- - 回归汇总当前会直接显示:
- - 分项通过/未通过
- - `Session ID`
- - 总判定
-- 需要对方确认什么:
- - 无
-- 是否已解决:是
-
-### B2T-021
-
-- 时间:2026-04-03 13:18:42
-- 谁提的:backend
-- 当前事实:
- - backend 已根据 [t2b.md](D:/dev/cmr-mini/t2b.md) v1.10 的最新口径,切入“联调标准化阶段”
- - 当前 backend 主线不再是继续扩对象或扩 workbench 管理能力
- - 当前统一收口为 3 个目标:
- - 固化“一键测试”链路
- - 固化详细日志口径
- - 固化稳定测试数据
- - 当前最推荐联调方式为:
- - `Bootstrap Demo`
- - `一键补齐 Runtime 并发布`
- - 这条链当前已可从空白环境直接跑通,并可输出:
- - 分步日志
- - 真实错误
- - stack
- - 最后一次 curl
- - 预期判定
-- 需要对方确认什么:
- - 无
-- 是否已解决:是
-
-### B2T-019
-
-- 时间:2026-04-03 13:04:32
-- 谁提的:backend
-- 当前事实:
- - backend 已把当前联调环境补成“一键测试环境”
- - `/dev/bootstrap-demo` 不再只准备:
- - `tenant`
- - `channel`
- - `event`
- - `release`
- - `source`
- - `build`
- - `card`
- - 现在还会一并准备并回填:
- - `place`
- - `map asset`
- - `tile release`
- - `course source`
- - `course set`
- - `course variant`
- - `runtime binding`
- - workbench 的:
- - `Bootstrap Demo`
- - `一键补齐 Runtime 并发布`
- 已可从空白状态直接跑完整测试链
- - workbench 日志现在会输出:
- - 分步执行日志
- - 真实错误消息
- - stack
- - 最后一次 curl
- - 最终预期判定
-- 需要对方确认什么:
- - 无
-- 是否已解决:是
-
-### B2T-018
-
-- 时间:2026-04-03 12:36:15
-- 谁提的:backend
-- 当前事实:
- - backend 已完成“活动运营域第二阶段第四刀”最小实现
- - 已新增:
- - `POST /admin/events/{eventPublicID}/presentations/import`
- - `POST /admin/events/{eventPublicID}/defaults`
- - `events` 已新增默认 active 绑定列:
- - `current_presentation_id`
- - `current_content_bundle_id`
- - `current_runtime_binding_id`
- - `publish` 在未显式传入:
- - `runtimeBindingId`
- - `presentationId`
- - `contentBundleId`
- 时,会优先继承 event 默认 active 绑定
- - workbench 已补最小验证入口:
- - `Import Presentation`
- - `Save Event Defaults`
- - `Publish Build` 空参继承验证
-- 需要对方确认什么:
- - 无
-- 是否已解决:是
-
-### B2T-007
-
-- 时间:2026-04-03 08:52:11
-- 谁提的:backend
-- 当前事实:
- - backend 已具备当前稳定主链:
- - 统一登录
- - entry/home
- - event play
- - launch
- - session start / finish
- - result / history
- - backend 已具备:
- - 配置 source / build / release
- - 最小后台资源对象
- - 多赛道第一阶段最小契约
-- 需要对方确认什么:
- - 无
-- 是否已解决:是
-
-### B2T-011
-
-- 时间:2026-04-03 09:09:07
-- 谁提的:backend
-- 当前事实:
- - backend 已开始第一阶段生产骨架实施
- - `0008_production_skeleton.sql` 已落库到 `cmr20260401`
- - 当前已新增并可用的后台生产接口覆盖:
- - `Place`
- - `MapAsset`
- - `TileRelease`
- - `CourseSource`
- - `CourseSet`
- - `CourseVariant`
- - `MapRuntimeBinding`
-- 需要对方确认什么:
- - 无
-- 是否已解决:是
-
-### B2T-012
-
-- 时间:2026-04-03 09:23:03
-- 谁提的:backend
-- 当前事实:
- - backend 已按 [t2b.md](D:/dev/cmr-mini/t2b.md) 第 5 步要求,把第一阶段生产骨架对象接入 `/dev/workbench`
- - 当前联调台已覆盖:
- - `Place`
- - `MapAsset`
- - `TileRelease`
- - `CourseSource`
- - `CourseSet`
- - `CourseVariant`
- - `MapRuntimeBinding`
- - 当前 workbench 只做:
- - `list`
- - `create`
- - `detail`
- - `binding`
-- 需要对方确认什么:
- - 无
-- 是否已解决:是
-
-### B2T-013
-
-- 时间:2026-04-03 09:27:18
-- 谁提的:backend
-- 当前事实:
- - backend 已整理第一阶段生产骨架最小操作顺序
- - 当前推荐 workbench 联调路径为:
- - `Place`
- - `MapAsset`
- - `TileRelease`
- - `CourseSource`
- - `CourseSet`
- - `CourseVariant`
- - `MapRuntimeBinding`
- - 该顺序已写入 [开发说明.md](D:/dev/cmr-mini/backend/docs/开发说明.md)
-- 需要对方确认什么:
- - 无
-- 是否已解决:是
-
-### B2T-015
-
-- 时间:2026-04-03 09:43:20
-- 谁提的:backend
-- 当前事实:
- - backend 已完成“第三刀最小接线”第一版:
- - `MapRuntimeBinding -> EventRelease`
- - `launch.runtime` 兼容新增
- - 当前新增能力:
- - `GET /admin/releases/{releasePublicID}`
- - `POST /admin/releases/{releasePublicID}/runtime-binding`
- - 当前 `launch` 在保持旧字段不变的前提下,新增:
- - `launch.runtime.runtimeBindingId`
- - `launch.runtime.placeId`
- - `launch.runtime.mapId`
- - `launch.runtime.tileReleaseId`
- - `launch.runtime.courseSetId`
- - `launch.runtime.courseVariantId`
- - `/dev/workbench` 已补最小验证入口:
- - `Get Release`
- - `Bind Runtime`
+ - 三条标准 demo 当前都可稳定联调:
+ - 顺序赛
+ - 积分赛
+ - manual 多赛道
+ - 历史 demo ongoing 残留已收口。
- 需要对方确认什么:
- 无
- 是否已解决:是
@@ -558,158 +413,13 @@
## 下一步
-### B2T-022
-
-- 时间:2026-04-03 13:18:42
-- 谁提的:backend
-- 当前事实:
- - backend 当前已具备“一键测试环境”与最小生产骨架测试数据
- - 后续联调阶段如要进一步贴近生产,只需要逐步替换以下 demo 输入:
- - 地图资源 URL
- - KML / 赛道文件
- - ContentBundle manifest
- - Presentation schema
- - 当前不需要继续新增对象层级即可支撑联调
-- 需要对方确认什么:
- - 总控后续如需要更接近生产的真实测试输入,请直接指定优先级最高的一类资源
-- 是否已解决:否
-
-### B2T-010
-
-- 时间:2026-04-03 08:52:11
-- 谁提的:backend
-- 当前事实:
- - backend 已完成第一阶段生产骨架落库、最小模型接线和 workbench 联调台接入
- - `EventPresentation` 和 `ContentBundle` 仍先在文档与接口边界保留语义
-- 需要对方确认什么:
- - 无
-- 是否已解决:是
-
-### B2T-014
-
-- 时间:2026-04-03 09:27:18
-- 谁提的:backend
-- 当前事实:
- - backend 下一步建议开始做“第一阶段对象与现有 Event/Release 的最小接线”
- - 重点会围绕:
- - `MapRuntimeBinding -> EventRelease`
- - 运行对象如何逐步进入 `launch`
- - 保持当前两阶段兼容不破坏前端稳定链
-- 需要对方确认什么:
- - 无
-- 是否已解决:是
-
-### B2T-016
-
-- 时间:2026-04-03 09:43:20
-- 谁提的:backend
-- 当前事实:
- - 当前 release 与 runtime binding 的挂接已可单独验证
- - 第四刀目标是把 `runtimeBindingId` 直接接进 publish 流,减少一次手工挂接
-- 需要对方确认什么:
- - 无
-- 是否已解决:是
-
-### B2T-017
-
-- 时间:2026-04-03 10:46:00
-- 谁提的:backend
-- 当前事实:
- - backend 已完成第四刀第一版:
- - `POST /admin/builds/{buildID}/publish` 支持可选 `runtimeBindingId`
- - `POST /dev/config-builds/publish` 支持可选 `runtimeBindingId`
- - 发布成功后返回 `runtime`
- - `/dev/workbench` 发布区已支持直接填写 `Runtime Binding ID`
- - 旧的“先 publish,再 bind runtime”路径继续保留
-- 需要对方确认什么:
- - 无
-- 是否已解决:是
-
-### B2T-018
-
-- 时间:2026-04-03 11:02:42
-- 谁提的:backend
-- 当前事实:
- - backend 已完成活动运营域第二阶段第一版:
- - `0009_event_ops_phase2.sql` 已落库到 `cmr20260401`
- - 新增:
- - `event_presentations`
- - `content_bundles`
- - `event_releases` 已明确支持绑定:
- - `presentation_id`
- - `content_bundle_id`
- - `runtime_binding_id`
- - 当前新增后台接口:
- - `GET/POST /admin/events/{eventPublicID}/presentations`
- - `GET /admin/presentations/{presentationPublicID}`
- - `GET/POST /admin/events/{eventPublicID}/content-bundles`
- - `GET /admin/content-bundles/{contentBundlePublicID}`
- - `publish` 当前已支持可选直接挂接:
- - `runtimeBindingId`
- - `presentationId`
- - `contentBundleId`
- - `/dev/workbench` 已补最小验证入口:
- - `Create Presentation`
- - `Create Bundle`
- - 发布区填写 `Presentation ID / Content Bundle ID / Runtime Binding ID`
-- 需要对方确认什么:
- - 无
-- 是否已解决:是
-
-### B2T-019
-
-- 时间:2026-04-03 11:22:50
-- 谁提的:backend
-- 当前事实:
- - backend 已完成“活动运营域第二阶段第二刀”第一版:
- - `GET /events/{eventPublicID}` 透出:
- - `currentPresentation`
- - `currentContentBundle`
- - `GET /events/{eventPublicID}/play` 透出:
- - `currentPresentation`
- - `currentContentBundle`
- - `POST /events/{eventPublicID}/launch` 新增兼容摘要:
- - `launch.presentation`
- - `launch.contentBundle`
- - `publish` 当前在未显式传入:
- - `presentationId`
- - `contentBundleId`
- 时,会优先按 event 当前默认的 active 配置自动补齐
- - 旧字段和旧语义保持不变:
- - `resolvedRelease`
- - `business`
- - `variant`
- - `runtime`
-- 需要对方确认什么:
- - 无
-- 是否已解决:是
-
-### B2T-020
-
-- 时间:2026-04-03 12:14:21
-- 谁提的:backend
-- 当前事实:
- - backend 已完成“活动运营域第二阶段第三刀”第一版:
- - `event detail / play / launch / release detail` 已统一补齐活动运营摘要
- - `presentation` 摘要当前最少带:
- - `presentationId`
- - `templateKey`
- - `version`
- - `contentBundle` 摘要当前最少带:
- - `contentBundleId`
- - `bundleType`
- - `version`
- - backend 已新增最小导入入口:
- - `POST /admin/events/{eventPublicID}/content-bundles/import`
- - 当前导入入口先只记录:
- - `bundleType`
- - `sourceType`
- - `manifestUrl`
- - `version`
- - `assetManifest`
- - `/dev/workbench` 已补:
- - `Import Bundle`
- - API 目录中的导入接口说明
-- 需要对方确认什么:
- - 无
-- 是否已解决:是
+- backend 当前继续围绕:
+ - 活动系统最小成品闭环
+ - 活动列表第一页联调小修
+ - 运维后台第一期里的地图 / 地点管理
+- 运维后台当前新增已落地:
+ - 地图 / 地点管理改成“地图列表优先”
+ - 右上角入口:`添加地图 / 添加地点`
+ - 地点编辑区接入省 / 市两级选择
+ - backend 新增:`GET /ops/admin/region-options`
+- 不开新战线,只做收口、稳定、验证。
diff --git a/backend/README.md b/backend/README.md
index f4c4f59..9a8d853 100644
--- a/backend/README.md
+++ b/backend/README.md
@@ -1,12 +1,67 @@
# Backend
-> 文档版本:v1.22
-> 最后更新:2026-04-03 18:56:46
+> 文档版本:v1.41
+> 最后更新:2026-04-07 18:15:01
这套后端现在已经能支撑一条完整主链:
`entry -> auth -> home/cards -> event play -> launch -> session -> result`
+当前已实现接口总数:
+
+- `116`
+
+开发环境补充说明:
+
+- `/admin/ops-workbench` 与 `/ops/admin/*` 在 `APP_ENV != production` 时默认免登录可用。
+- 即使浏览器里残留旧的玩家 token、过期 token 或非 ops token,运维链也会自动回退到 dev ops 上下文。
+- 生产环境不变,仍然必须使用正式 ops token。
+- 运维后台当前采用“左侧流程导航 + 中间单主视图 + 右侧状态/日志”结构,不再把地图、路线、活动、发布动作平铺在一个长页面里。
+- `资源总览` 先展示关键统计与当前运行时信息;地图页只保留关联活动数量和摘要,活动明细统一放到 `活动管理 / 活动编排`。
+- `地图 / 地点管理` 当前按“地图列表优先”组织:
+ - 先看地图列表
+ - 右上角只放 `添加地图 / 添加地点`
+ - 地点编辑区改成省/市两级选择
+ - 一张地图只属于一个地点,一个地点可挂多张地图
+
+新增的游客模式公开接口:
+
+- `GET /public/experience-maps`
+- `GET /public/experience-maps/{mapAssetPublicID}`
+- `GET /public/events/{eventPublicID}`
+- `GET /public/events/{eventPublicID}/play`
+- `POST /public/events/{eventPublicID}/launch`
+
+这组接口用于支撑“未登录游客只体验默认活动”的最小链路。
+
+新增的地图资源与默认体验活动接口:
+
+- `GET /experience-maps`
+- `GET /experience-maps/{mapAssetPublicID}`
+
+这组接口用于支撑“地图列表下的默认活动”最小产品化需求。
+
+新增的运维地图管理接口:
+
+- `GET /ops/admin/region-options`
+- `GET /admin/map-assets`
+- `PUT /admin/map-assets/{mapAssetPublicID}`
+- `GET /ops/admin/map-assets`
+- `PUT /ops/admin/map-assets/{mapAssetPublicID}`
+- `GET /ops/admin/course-sources`
+- `GET /ops/admin/course-sources/{sourcePublicID}`
+- `GET /ops/admin/course-sets/{courseSetPublicID}`
+- `POST /ops/admin/events`
+- `PUT /ops/admin/events/{eventPublicID}`
+
+这组接口用于把运维后台收成统一主流程:
+
+- 地图管理:列表 / 新增 / 编辑 / 详情 / 关联活动
+- KML / 赛道管理:围绕当前地图查看赛道集和默认路线
+- 活动管理:列表 / 新增 / 修改 / 详情 / 默认绑定 / 发布
+- 运维后台 UI 当前也已按“左侧导航 + 单主视图 + 右侧常驻状态栏”收口,不再把所有功能平铺在一个长页面里
+- 主区当前已改成宽屏自适应,不再固定窄宽度浪费屏幕空间
+
并且已经按“配置驱动游戏”收口:
- 业务对象是 `event`
@@ -29,6 +84,16 @@
- `presentation`
- `content bundle`
时,玩家才允许正式进入
+- 游客模式当前只允许进入:
+ - `is_default_experience = true` 的活动
+ - 且必须基于当前已发布 release
+- 游客模式当前不开放:
+ - `/me/entry-home`
+ - `/me/results`
+ - 用户历史成绩与报名态
+- 游客模式 `launch` 会复用正式 session 模型,但返回:
+ - `launch.source = public-default-experience`
+ - `launch.business.isGuest = true`
当前 workbench 里新增的“当前 Launch 实际配置摘要”仅用于调试:
@@ -54,20 +119,84 @@
当前 demo 真实输入第一刀也已经接入:
- workbench 的玩法切换会自动填入 backend 内置的:
+ - `game manifest`
- `presentation schema`
- `content manifest`
- 这些 demo 资源通过 backend 提供的 dev 路由读取:
+ - `GET /dev/demo-assets/manifests/{demoKey}`
- `GET /dev/demo-assets/presentations/{demoKey}`
- `GET /dev/demo-assets/content-manifests/{demoKey}`
当前 workbench 的 `Bootstrap` 语义也已经拆开:
- `Bootstrap Demo(只准备数据)`
- - 只准备 demo 测试数据,不额外重新发布当前玩法
+ - 只准备 demo 测试数据和基础已发布态,不额外重新发布当前玩法
- `Bootstrap + 发布当前玩法`
- 先准备 demo,再对当前选中的玩法执行一遍“发布活动配置(自动补 Runtime)”
- 这两条路由只服务联调,不进入正式客户端发布链
+- 当前三条标准 demo 在 `Bootstrap Demo(只准备数据)` 后都会直接具备:
+ - 当前 release
+ - runtime
+ - presentation
+ - content bundle
+- 也就是说,frontend 当前从首页选顺序赛、积分赛、多赛道时,已经可以直接走“当前已发布 release”语义联调入口、详情和 `canLaunch`
- 当前联调样例文案也已从 `Demo ...` 收口为中文活动样例,便于前端和总控直接对口排查
+- 当前 manual 多赛道 demo 也已切到 4 条正式 OSS KML:
+ - `gotomars/kml/lxcb-001/2026-04-07/route01.kml`
+ - `gotomars/kml/lxcb-001/2026-04-07/route02.kml`
+ - `gotomars/kml/lxcb-001/2026-04-07/route03.kml`
+ - `gotomars/kml/lxcb-001/2026-04-07/route04.kml`
+- 资源目录约束同步明确:
+ - 正式资源目录只认 `OSS / CDN`
+ - 本地 `tmp/` 仅作为临时收件箱,不参与正式发布源
+- 当前运维入口第一期已开始落地,先开放两条最小录入链:
+ - `POST /admin/ops/tile-releases/import`
+ - `POST /admin/ops/course-sets/import-kml-batch`
+- 当前目标是先把:
+ - 地图瓦片版本录入
+ - KML 批量录入并组装为 `course set / variants`
+ 收成可重复执行的运维入口,而不是继续依赖手工脚本或改代码上传 OSS
+- 当前运维入口第二期已起步,开始支持统一资源纳管:
+ - `GET /admin/assets`
+ - `POST /admin/assets/register-link`
+ - `POST /admin/assets/upload`
+ - `GET /admin/assets/{assetPublicID}`
+- 当前边界:
+ - 允许“上传文件”与“登记外链”两种录入模式
+ - backend 负责 OSS 存储和资源对象登记
+ - 运维后台当前开始使用独立鉴权链:
+ - `/ops/auth/*`
+ - `/ops/admin/*`
+ - 设计目标:
+ - 运维账号与前端玩家账号完全分离
+ - 生产环境走手机号验证码注册/登录
+ - 后续可扩多租户与角色分级
+ - 已新增独立运维入口:
+ - `GET /admin/ops-workbench`
+ - 当前开始把“调试后台”和“运维后台”拆开:
+ - `/dev/workbench` 继续只做联调、回归、日志与摘要
+ - `/admin/ops-workbench` 只做资源录入、OSS 纳管、地图/KML 导入
+ - 当前开发环境为了录资源和调发布方便,运维后台默认免登录放行:
+ - 可直接打开 `/admin/ops-workbench`
+ - 可直接调用 `/ops/admin/*`
+ - 只有主动验证运维账号链路时,才需要使用 `/ops/auth/*`
+ - `Import Tile Release / Import KML Batch` 当前已从调试工作台主操作区迁到:
+ - `/admin/ops-workbench`
+ - `/dev/workbench` 当前只保留运维后台入口说明与跳转,不再承载正式资源录入操作
+ - `/admin/ops-workbench` 当前已收成 5 块结构:
+ - 资源总览
+ - 地图资源管理
+ - 资源录入
+ - 赛道集管理
+ - 活动绑定
+ - 发布中心
+ - 当前地图资源管理第一刀已接进运维后台:
+ - 读取地点列表
+ - 新建地点
+ - 读取地点详情
+ - 新建地图资源
+ - 读取地图详情
+ - 查看当前瓦片版本与默认活动摘要
当前活动卡片列表最小产品化第一刀也已经进入 backend:
@@ -96,6 +225,29 @@
- `isDefaultExperience` 当前由卡片显式字段控制
- `timeWindow / ctaText` 当前先按后端派生规则提供,允许后续继续演进
+当前“准备页地图预览 V1”也已开始接入,但仍保持只读增强项边界:
+
+- 当前只把预览字段挂在:
+ - `GET /events/{eventPublicID}`
+ - `GET /events/{eventPublicID}/play`
+- 当前新增字段为:
+ - `preview.mode`
+ - `preview.baseTiles.tileBaseUrl`
+ - `preview.baseTiles.zoom`
+ - `preview.baseTiles.tileSize`
+ - `preview.viewport.width / height`
+ - `preview.viewport.minLon / minLat / maxLon / maxLat`
+ - `preview.variants[].controls`
+ - `preview.variants[].legs`
+ - `preview.selectedVariantId`
+- 当前只服务准备页只读地图预览:
+ - 不进入正式 launch 主链
+ - 不单独造新的地图资源体系
+- 非 demo / 非带预览元数据的活动允许返回空
+- workbench 当前也已新增固定摘要卡:
+ - `准备页地图预览状态`
+ - 用于直接查看当前活动回出的 preview 关键字段
+
## 文档导航
- [文档索引](D:/dev/cmr-mini/backend/docs/README.md)
diff --git a/backend/docs/README.md b/backend/docs/README.md
index 8181f8f..976aa74 100644
--- a/backend/docs/README.md
+++ b/backend/docs/README.md
@@ -1,6 +1,6 @@
# Backend Docs
-> 文档版本:v1.0
-> 最后更新:2026-04-02 08:28:05
+> 文档版本:v1.1
+> 最后更新:2026-04-07 18:47:09
这套文档服务两个目的:
@@ -20,6 +20,7 @@
8. [前后端联调清单](D:/dev/cmr-mini/backend/docs/前后端联调清单.md)
9. [TodoList](D:/dev/cmr-mini/backend/docs/todolist.md)
10. [开发说明](D:/dev/cmr-mini/backend/docs/开发说明.md)
+11. [B2B 交接文档](D:/dev/cmr-mini/b2b.md)
## 当前系统范围
diff --git a/backend/docs/后台管理最小方案.md b/backend/docs/后台管理最小方案.md
index 5c84ecc..9d0e1e5 100644
--- a/backend/docs/后台管理最小方案.md
+++ b/backend/docs/后台管理最小方案.md
@@ -1,6 +1,6 @@
# 后台管理最小方案
-> 文档版本:v1.1
-> 最后更新:2026-04-03 11:02:42
+> 文档版本:v1.8
+> 最后更新:2026-04-07 17:23:15
## 1. 目标
@@ -176,16 +176,16 @@
## 4. 后台第一版页面建议
-按最小闭环,建议先做 6 个页面:
+按当前运维工作流,建议先按“地图主流程”组织,而不是把底层对象散着摆:
1. 地图列表页
-2. 赛场 / KML 列表页
-3. 资源包列表页
-4. Event 列表与编辑页
-5. Build / Release 列表页
-6. 发布详情页
+2. 地图详情页
+3. KML / 赛道管理页
+4. 活动管理页
+5. 发布中心
+6. 资源总览页
-这 6 页够把“资源录入 -> Event 组装 -> 发布 -> launch”跑通。
+这 6 页的目标是把“资源录入 -> 地图管理 -> 赛道管理 -> 活动绑定 -> 发布”跑通。
补充:
@@ -216,8 +216,112 @@
- `event` 只做引用和少量覆盖
- `release` 固化具体版本引用
+### 5.1 当前活动模型收口原则
+
+为了让前台卡片、详情页、发布语义和运维操作都保持简单,当前活动模型先明确收成最小可玩单元:
+
+- `单地图`
+- `单路线组`
+- `单玩法`
+
+也就是第一阶段一个活动只表达一件事:
+
+> 在这张地图上,使用这组路线,按这一个玩法运行。
+
+当前不建议第一阶段直接支持:
+
+- 一个活动绑定多张地图
+- 一个活动绑定多组路线
+- 一个活动同时支持多玩法
+
+复杂需求先通过“活动实例化”解决,而不是在一个活动里做多对多编排。例如:
+
+- 地图 A + 路线组 1 + 顺序赛 = 活动 A1
+- 地图 A + 路线组 2 + 顺序赛 = 活动 A2
+- 地图 A + 路线组 2 + 积分赛 = 活动 A3
+
+这样做的目的:
+
+- 前台卡片逻辑简单
+- 发布语义明确
+- 结果追溯简单
+- 运维后台第一期不需要一开始就做复杂配置器
+
+后续如果确实需要复杂组合,优先考虑:
+
+- 基于模板批量实例化多个活动
+
+而不是直接把单个活动扩成多地图、多路线组、多玩法容器。
+
+### 5.2 组合入口层原则
+
+多地图、多路线组、多玩法这类需求后面仍然会存在,但当前建议放在“组合入口层”解决,不直接进入单个活动的运行模型。
+
+也就是分成两层:
+
+1. 运行实例层
+- 一个活动实例始终保持:
+ - `单地图`
+ - `单路线组`
+ - `单玩法`
+
+2. 组合入口层
+- 通过组合卡片或组合入口,把多个活动实例编排成一个前台入口
+- 例如:
+ - 多地图合集
+ - 多玩法合集
+ - 不同难度合集
+ - 同主题活动合集
+
+这样做的目的:
+
+- 前台入口可以灵活组合
+- backend 的发布、回溯、结果沉淀仍然保持简单
+- 运维后台第一期不需要一开始就做复杂多对多编排器
+- 后面若要扩能力,也是在“组合层”扩,而不是把底层活动模型搞乱
+
## 6. 一条完整后台工作流
+## 7. 运维入口第一期
+
+当前已经开始落地一条“先运维录入、再活动绑定发布”的最小入口,不再只靠手工脚本或改代码上传资源。
+
+第一期先开放两条:
+
+1. `POST /admin/ops/tile-releases/import`
+ - 作用:一次录入 `place + map asset + tile release`
+ - 适合把地图瓦片版本登记进 backend
+
+2. `POST /admin/ops/course-sets/import-kml-batch`
+ - 作用:一次录入一组 KML,生成 `course set + variants`
+ - 适合多赛道活动的批量路线导入
+
+第一期边界:
+
+- 只做录入
+- 只做对象登记和最小 current 绑定
+- 已开始落地独立运维后台 `/admin/ops-workbench`
+- 不替代后续完整运维后台
+
+当前第一版页面结构已经按主流程拆成:
+
+1. `资源总览`
+2. `地图管理`
+3. `资源录入`
+4. `KML / 赛道管理`
+5. `活动管理`
+6. `发布中心`
+
+当前设计原则:
+
+- 运维首先看到“地图列表”
+- KML / 赛道围绕地图管理,不作为孤立对象平铺
+- 活动管理围绕地图关联活动、默认体验活动和发布状态展开
+- 活动编排当前先按“单地图 + 单路线组 + 单玩法”收口
+- 多地图 / 多玩法需求当前先通过“组合卡片 / 组合入口”承接
+- 地图、KML、活动尽量统一成“列表 / 新增 / 修改 / 详情 / 预览 / 发布关联”的相似使用习惯
+- 底层对象如 `Place / MapAsset / TileRelease / CourseSource / CourseSet` 继续保留,但收进主流程内部,不作为首页认知入口
+
```mermaid
flowchart LR
A["录入地图版本"] --> D["Event 选择地图版本"]
@@ -327,7 +431,21 @@ flowchart LR
2. `Event` 组装接口 `/admin/events`、`/admin/events/{id}/source` 已完成
3. `pipeline/build/publish` 后台聚合接口已完成
4. `rollback` 已完成
-5. 下一步是把这批接口接进 workbench 或正式后台页面
+5. 运维入口第一期已完成:
+ - `POST /admin/ops/tile-releases/import`
+ - `POST /admin/ops/course-sets/import-kml-batch`
+6. 运维入口第二期已开始:
+ - `POST /admin/assets/upload`
+ - `POST /admin/assets/register-link`
+ - `GET /admin/assets`
+ - `GET /admin/assets/{assetPublicID}`
+7. 运维后台第一版结构已开始落地到 `/admin/ops-workbench`:
+ - `资源总览`
+ - `资源录入`
+ - `赛道集管理`
+ - `活动绑定`
+ - `发布中心`
+8. 下一步是继续把更多资源对象与发布细节收进这套运维台,而不是再塞回调试工作台
## 10. 一句话结论
diff --git a/backend/docs/开发说明.md b/backend/docs/开发说明.md
index 01d7514..a000366 100644
--- a/backend/docs/开发说明.md
+++ b/backend/docs/开发说明.md
@@ -1,6 +1,6 @@
# 开发说明
-> 文档版本:v1.25
-> 最后更新:2026-04-03 18:56:46
+> 文档版本:v1.45
+> 最后更新:2026-04-07 18:15:01
## 1. 环境变量
@@ -18,6 +18,50 @@
- `WECHAT_MINI_APP_ID`
- `WECHAT_MINI_APP_SECRET`
- `WECHAT_MINI_DEV_PREFIX`
+
+## 2. 运维后台当前结构
+
+- 运维后台入口:`/admin/ops-workbench`
+- 当前采用:
+ - 左侧:流程导航
+ - 中间:单主视图
+ - 右侧:状态 / 日志 / 最近对象
+- 当前主流程导航:
+ - `资源总览`
+ - `地图 / 地点管理`
+ - `路线资源管理`
+ - `活动管理`
+ - `活动编排`
+ - `发布中心`
+ - `资源录入` 作为辅助入口保留
+- `资源总览` 优先展示:
+ - 地点、地图、瓦片版本、受管资源
+ - 路线组、路线变体、运行绑定、配置源
+ - 活动数、默认体验活动、已发布活动、发布版本、展示定义、内容包、运维账号
+- `地图 / 地点管理` 当前收成:
+ - 先看地图列表
+ - 右上角入口:`添加地图 / 添加地点`
+ - 点击地图进入详情弹出层
+ - 新增 / 编辑地图走独立弹出层
+ - 新增地点走独立弹出层
+ - 地点编辑区使用省/市两级选择,并回填到 `region`
+ - 地图详情只保留:
+ - 当前瓦片版本
+ - 默认体验活动概况
+ - 关联活动数量与摘要
+ - 关联活动详情统一去 `活动管理`
+- 省市数据当前来自在线公开数据源:
+ - `uiwjs/province-city-china`
+ - backend 通过 `/ops/admin/region-options` 统一提供给运维台,页面本身不直连第三方源
+ - 当前选中活动的 `release / runtime / presentation / content bundle`
+- 地图页当前只显示关联活动数量与摘要,不再平铺活动详情;活动详情和默认绑定统一放到 `活动管理 / 活动编排`。
+- `地图 / 地点管理` 当前已支持:
+ - 地点列表
+ - 地图列表
+ - 地图关键字筛选
+ - 点列表项直接读取详情
+ - 选地点后自动带出该地点下地图
+ - 选地图后自动带出当前瓦片版本、默认活动概况和地图预览摘要
- `LOCAL_EVENT_DIR`
- `ASSET_BASE_URL`
- `ASSET_PUBLIC_BASE_URL`
@@ -39,17 +83,86 @@ cd D:\dev\cmr-mini\backend
.\scripts\start-dev.ps1
```
+开发环境补充:
+
+- 运维后台入口:`/admin/ops-workbench`
+- 运维接口前缀:`/ops/admin/*`
+- 当 `APP_ENV != production` 时:
+ - 缺少 token 会直接进入 dev ops 上下文
+ - 残留旧 token、玩家 token、失效 token 也会自动回退到 dev ops 上下文
+ - 目的是避免开发联调时每次都要重新登录
+
## 3. Workbench 当前重点
- 推荐联调入口:
- - `Bootstrap Demo`
+ - `Bootstrap Demo(只准备数据)`
+ - `Bootstrap + 发布当前玩法`
- `Use Classic Demo / Use Score-O Demo / Use Manual Variant Demo`
- `整条链一键验收`
+- `Bootstrap Demo(只准备数据)` 当前会直接准备三条标准 demo 的基础已发布态:
+ - `evt_demo_001`
+ - `evt_demo_score_o_001`
+ - `evt_demo_variant_manual_001`
+- 这三条 demo 在 bootstrap 后都会直接带上:
+ - 当前 release
+ - runtime
+ - presentation
+ - content bundle
+- 也就是说,frontend 当前从首页选择三种玩法时,不需要再额外先点一次发布按钮,已经能直接按“当前已发布 release”语义联调入口、详情和 `canLaunch`
- 当前玩法切换除了切 `event / release / source / build`,还会自动切换:
- `presentation schema`
+
+## 4. 运维后台当前主流程
+
+运维后台入口:
+
+- [http://127.0.0.1:18090/admin/ops-workbench](http://127.0.0.1:18090/admin/ops-workbench)
+
+当前不再把运维动作混在调试后台里,统一分成 3 条管理线:
+
+1. 地图管理
+- 先看地图列表
+- 再做新建 / 编辑
+- 然后看当前瓦片版本、默认活动和关联活动
+
+2. KML / 赛道管理
+- 围绕当前地图导入一组 KML
+- 查看当前地图下赛道集
+- 查看默认路线和路线摘要
+
+3. 活动管理
+- 先看活动列表
+- 再做新建 / 修改 / 读取详情
+- 然后管理默认 `runtime / presentation / content bundle`
+- 最后进入发布中心
+
+当前 UI 组织方式也已收口:
+
+- 左侧:流程导航
+- 中间:单主视图
+- 右侧:状态 / 日志 / 最近对象
+
+也就是说:
+
+- 不再把所有运维功能平铺在一个长页面里
+- 运维者一次只处理一个主任务块
+- 主区已改成宽屏自适应,尽量利用大屏空间
+
+当前新增的地图管理接口:
+
+- `GET /admin/map-assets`
+- `PUT /admin/map-assets/{mapAssetPublicID}`
+- `GET /ops/admin/map-assets`
+- `PUT /ops/admin/map-assets/{mapAssetPublicID}`
+- `GET /ops/admin/course-sources`
+- `GET /ops/admin/course-sources/{sourcePublicID}`
+- `GET /ops/admin/course-sets/{courseSetPublicID}`
+- `POST /ops/admin/events`
+- `PUT /ops/admin/events/{eventPublicID}`
- `content manifest`
- `asset manifest`
- 这些 demo 资源现在由 backend 提供,避免继续在 workbench 里保留 `example.com` 占位地址:
+ - `GET /dev/demo-assets/manifests/{demoKey}`
- `GET /dev/demo-assets/presentations/{demoKey}`
- `GET /dev/demo-assets/content-manifests/{demoKey}`
- 如果 frontend 需要把页面侧调试日志直接打到 backend,优先使用:
@@ -66,6 +179,14 @@ cd D:\dev\cmr-mini\backend
- `game.mode`
- 这组信息用于和前端地图页实际消费结果对口排查,避免只靠口头描述“像顺序赛/像积分赛”。
- 注意:
+ - 游客模式当前不走 `/dev/workbench` 一键链验证
+ - frontend 若要联调游客模式,请直接使用:
+ - `GET /public/experience-maps`
+ - `GET /public/experience-maps/{mapAssetPublicID}`
+ - `GET /public/events/{eventPublicID}`
+ - `GET /public/events/{eventPublicID}/play`
+ - `POST /public/events/{eventPublicID}/launch`
+ - 游客模式当前只允许默认体验活动进入,且仍然必须基于已发布 release。
- 这块摘要由 backend 代读 manifest,只用于 workbench 调试
- 这样做是为了避免浏览器直接读取 OSS 时受跨域影响
- 它不替代正式客户端加载逻辑
@@ -78,6 +199,116 @@ cd D:\dev\cmr-mini\backend
- `领秀城公园顺序赛`
- `领秀城公园积分赛`
- `领秀城公园多赛道挑战`
+- 当前“准备页地图预览 V1”已先接进只读查询接口:
+ - `GET /events/{eventPublicID}`
+ - `GET /events/{eventPublicID}/play`
+- 当前 preview 字段最小结构为:
+ - `preview.mode`
+ - `preview.baseTiles.tileBaseUrl`
+ - `preview.baseTiles.zoom`
+ - `preview.baseTiles.tileSize`
+ - `preview.viewport.width / height`
+ - `preview.viewport.minLon / minLat / maxLon / maxLat`
+ - `preview.variants[].controls`
+ - `preview.variants[].legs`
+ - `preview.selectedVariantId`
+- 当前实现边界:
+ - 只服务准备页只读预览
+ - 不进入正式 launch 主链
+ - demo 活动当前已自带预览元数据
+ - 非带预览元数据的活动允许返回空
+- workbench 当前已增加固定卡片:
+ - `准备页地图预览状态`
+ - 当前会直接显示:
+ - `Preview Mode`
+ - `Tile Base URL`
+ - `Zoom`
+ - `Viewport`
+ - `Selected Variant`
+ - `Preview Variant Count`
+ - `First Variant Controls`
+ - `First Variant Legs`
+ - 点击:
+ - `Event Detail`
+ - `Event Play`
+ 后都会刷新这张卡
+- 当前运维入口第一期已迁移到独立运维工作台:
+ - `Import Tile Release`
+ - `Import KML Batch`
+- 两条入口分别对应:
+ - `POST /admin/ops/tile-releases/import`
+ - `POST /admin/ops/course-sets/import-kml-batch`
+- 推荐使用顺序:
+ 1. 先录入瓦片版本
+ 2. 再批量录入 KML 路线
+ 3. 最后再继续组装 `runtime / event / release`
+- 这两条入口当前只服务运维录入第一期,不替代正式后台 UI。
+- 当前运维入口第二期已先落 backend 资源纳管接口:
+ - `GET /admin/assets`
+ - `POST /admin/assets/register-link`
+ - `POST /admin/assets/upload`
+ - `GET /admin/assets/{assetPublicID}`
+- 当前用途:
+ - 运维不再必须自己管 OSS 目录细节
+ - 允许直接上传文件,由 backend 负责:
+ - 上传到 OSS
+ - 生成正式 URL
+ - 登记资源对象
+ - 也允许直接登记已有正式外链
+- 当前已新增独立运维工作台:
+ - [http://127.0.0.1:18090/admin/ops-workbench](http://127.0.0.1:18090/admin/ops-workbench)
+- 当前入口分工:
+ - `/dev/workbench`
+ - 调试工作台
+ - 一键回归、配置摘要、前端日志、联调排查
+ - 当前只保留运维入口说明与跳转,不再承载正式资源录入动作
+ - `/admin/ops-workbench`
+ - 运维工作台
+ - 资源上传、外链登记、地图瓦片导入、KML 批量导入
+ - 活动绑定
+ - 发布中心
+- 当前运维后台鉴权也已经开始独立:
+ - 运维账号接口:
+ - `POST /ops/auth/sms/send`
+ - `POST /ops/auth/register`
+ - `POST /ops/auth/login/sms`
+ - `POST /ops/auth/refresh`
+ - `POST /ops/auth/logout`
+ - `GET /ops/me`
+ - 运维后台管理接口:
+ - `/ops/admin/*`
+ - 设计目标:
+ - 运维账号与前端玩家账号完全分离
+ - 生产环境走手机号验证码注册/登录
+ - 后续可扩角色分级、多租户
+- 当前开发环境为了录资源和调发布方便,运维后台默认免登录:
+ - `/admin/ops-workbench` 可直接进入
+ - `/ops/admin/*` 在 non-production 下可直接调用
+ - 只有主动验证运维账号链路时,才需要真的走手机号验证码登录
+- 当前运维工作台已收成 5 块:
+ - `资源总览`
+ - `地图资源管理`
+ - `资源录入`
+ - `赛道集管理`
+ - `活动绑定`
+ - `发布中心`
+- 当前“地图资源管理”第一刀的最小目标是:
+ 1. 读取地点列表
+ 2. 新建地点
+ 3. 读取地点详情
+ 4. 新建地图资源
+ 5. 读取地图详情
+ 6. 在同一页面查看:
+ - 当前瓦片版本
+ - 当前瓦片地址
+ - 默认活动摘要
+- 当前运维台对应入口:
+ - `GET /ops/admin/places`
+ - `POST /ops/admin/places`
+ - `GET /ops/admin/places/{placePublicID}`
+ - `POST /ops/admin/places/{placePublicID}/map-assets`
+ - `GET /ops/admin/map-assets/{mapAssetPublicID}`
+ - `POST /ops/admin/map-assets/{mapAssetPublicID}/tile-releases`
## 4. 活动卡片列表最小摘要
@@ -649,6 +880,48 @@ Redis 后面只在需要性能优化、限流或短期票据缓存时再接。
- `business`
- `variant`
+## 6.1 地图列表与默认活动
+
+当前 backend 已补最小地图体验入口:
+
+- `GET /experience-maps`
+- `GET /experience-maps/{mapAssetPublicID}`
+
+语义约定:
+
+- 地图列表按 `Place / MapAsset` 聚合
+- 默认活动关系来自:
+ - `events.is_default_experience`
+ - `events.show_in_event_list`
+ - 当前已发布 `release` 绑定到的 `runtime.mapAsset`
+- `Bootstrap Demo` 后:
+ - `evt_demo_001` 为默认体验活动
+ - `evt_demo_score_o_001`
+ - `evt_demo_variant_manual_001`
+ 为普通活动,但仍会出现在地图关联活动里
+
+当前前端可直接消费的字段:
+
+- 地图列表:
+ - `placeId`
+ - `placeName`
+ - `mapId`
+ - `mapName`
+ - `coverUrl`
+ - `summary`
+ - `defaultExperienceCount`
+ - `defaultExperienceEventIds`
+- 地图详情:
+ - `placeId`
+ - `placeName`
+ - `mapId`
+ - `mapName`
+ - `coverUrl`
+ - `summary`
+ - `tileBaseUrl`
+ - `tileMetaUrl`
+ - `defaultExperiences[]`
+
## 7. 当前后续开发建议
文档整理完之后,后面建议按这个顺序继续:
diff --git a/backend/docs/接口清单.md b/backend/docs/接口清单.md
index 4bfffde..d2d6af0 100644
--- a/backend/docs/接口清单.md
+++ b/backend/docs/接口清单.md
@@ -1,10 +1,14 @@
# API 清单
-> 文档版本:v1.12
-> 最后更新:2026-04-03 22:34:08
+> 文档版本:v1.23
+> 最后更新:2026-04-07 16:08:37
本文档只记录当前 backend 已实现接口,不写未来规划接口。
+当前已实现接口总数:
+
+- `115`
+
## 1. Health
### `GET /healthz`
@@ -13,6 +17,22 @@
- 健康检查
+## 0. Workbench
+
+### `GET /dev/workbench`
+
+用途:
+
+- 调试工作台
+- 一键回归、日志查看、摘要排查
+
+### `GET /admin/ops-workbench`
+
+用途:
+
+- 运维后台第一期页面
+- 资源录入、OSS 纳管、地图瓦片导入、KML 批量导入
+
## 2. Auth
### `POST /auth/sms/send`
@@ -75,6 +95,264 @@
- 撤销 refresh token
+## 2.1 Ops Auth
+
+说明:
+
+- 运维账号与前端玩家账号完全分离
+- 生产环境走手机号验证码运维账号
+- 开发环境为了录资源和调发布方便,`/admin/ops-workbench` 与 `/ops/admin/*` 默认免登录可用
+
+### `POST /ops/auth/sms/send`
+
+用途:
+
+- 发送运维账号登录 / 注册验证码
+
+### `POST /ops/auth/register`
+
+用途:
+
+- 注册独立运维账号
+- 首个账号默认授予 `owner`
+
+### `POST /ops/auth/login/sms`
+
+用途:
+
+- 运维账号手机号验证码登录
+
+### `POST /ops/auth/refresh`
+
+用途:
+
+- 刷新运维 access token
+
+### `POST /ops/auth/logout`
+
+用途:
+
+- 撤销运维 refresh token
+
+### `GET /ops/me`
+
+鉴权:
+
+- Ops bearer token
+
+用途:
+
+- 获取当前运维账号信息和主角色
+
+## 2.2 Ops Admin(地图资源管理第一刀)
+
+### `GET /ops/admin/summary`
+
+用途:
+
+- 运维后台总览聚合接口
+- 返回资源、活动、运行绑定、发布版本统计
+
+### `GET /ops/admin/assets`
+
+用途:
+
+- 读取受管资源列表
+- 给运维后台资源总览与录入校验使用
+
+### `POST /ops/admin/assets/register-link`
+
+用途:
+
+- 登记已有正式外链资源
+- 统一纳管到 backend 资源对象
+
+### `POST /ops/admin/assets/upload`
+
+用途:
+
+- 上传文件给 backend,再统一存入 OSS
+- 运维不需要自己处理底层存储细节
+
+### `GET /ops/admin/assets/{assetPublicID}`
+
+用途:
+
+- 查看单个受管资源详情
+- 返回资源类型、版本、正式 URL 和元数据
+
+### `GET /ops/admin/places`
+
+用途:
+
+- 读取地点列表
+- 给运维后台地图资源管理区做浏览入口
+
+### `POST /ops/admin/places`
+
+用途:
+
+- 新建地点
+
+### `GET /ops/admin/places/{placePublicID}`
+
+用途:
+
+- 读取地点详情
+- 返回当前地点下的地图资源列表
+
+### `POST /ops/admin/places/{placePublicID}/map-assets`
+
+用途:
+
+- 在指定地点下新建地图资源
+
+### `GET /ops/admin/map-assets/{mapAssetPublicID}`
+
+用途:
+
+- 读取地图资源详情
+- 返回当前瓦片版本、关联赛道集和关联活动摘要
+
+### `GET /ops/admin/map-assets`
+
+用途:
+
+- 读取地图列表
+- 返回地点、当前瓦片版本和关联活动摘要
+
+### `PUT /ops/admin/map-assets/{mapAssetPublicID}`
+
+用途:
+
+- 更新地图基础信息
+- 支持名称、封面、摘要、状态维护
+
+### `POST /ops/admin/map-assets/{mapAssetPublicID}/tile-releases`
+
+用途:
+
+- 给地图资源追加新的瓦片版本
+
+### `POST /ops/admin/ops/tile-releases/import`
+
+用途:
+
+- 一次录入 `place + map asset + tile release`
+- 运维录入地图瓦片版本的最小入口
+
+### `GET /ops/admin/course-sources`
+
+用途:
+
+- 运维后台读取 KML / GeoJSON 输入源列表
+
+### `GET /ops/admin/course-sources/{sourcePublicID}`
+
+用途:
+
+- 运维后台读取单个赛道输入源详情
+
+### `GET /ops/admin/course-sets/{courseSetPublicID}`
+
+用途:
+
+- 运维后台读取赛道集合详情
+- 用于默认路线和路线预览
+
+### `POST /ops/admin/ops/course-sets/import-kml-batch`
+
+用途:
+
+- 批量录入一组 KML
+- 自动生成 `course set + variants`
+
+### `GET /ops/admin/events`
+
+用途:
+
+- 运维后台活动列表
+- 给地图关联活动区做浏览入口
+
+### `POST /ops/admin/events`
+
+用途:
+
+- 运维后台新建活动
+- 先录活动基础信息,再进入默认绑定和发布链
+
+### `GET /ops/admin/events/{eventPublicID}`
+
+用途:
+
+- 运维后台活动详情
+- 返回默认绑定和当前 source 摘要
+
+### `PUT /ops/admin/events/{eventPublicID}`
+
+用途:
+
+- 运维后台更新活动基础信息
+- 支持名称、摘要、slug、状态维护
+
+### `POST /ops/admin/events/{eventPublicID}/presentations/import`
+
+用途:
+
+- 导入活动展示定义
+- 用于当前活动默认绑定与发布
+
+### `POST /ops/admin/events/{eventPublicID}/content-bundles/import`
+
+用途:
+
+- 导入活动内容包
+- 用于当前活动默认绑定与发布
+
+### `POST /ops/admin/events/{eventPublicID}/defaults`
+
+用途:
+
+- 保存活动默认绑定
+- 固化 `runtime / presentation / content bundle` 三元组
+
+### `GET /ops/admin/events/{eventPublicID}/pipeline`
+
+用途:
+
+- 读取活动发布链概览
+- 返回 source / build / release 摘要
+
+### `POST /ops/admin/sources/{sourceID}/build`
+
+用途:
+
+- 基于 source 构建 build
+
+### `GET /ops/admin/builds/{buildID}`
+
+用途:
+
+- 读取单个 build 明细
+
+### `POST /ops/admin/builds/{buildID}/publish`
+
+用途:
+
+- 发布 build 为正式 release
+
+### `GET /ops/admin/releases/{releasePublicID}`
+
+用途:
+
+- 读取单个 release 详情
+
+### `POST /ops/admin/events/{eventPublicID}/rollback`
+
+用途:
+
+- 将活动当前 release 回滚到指定版本
+
## 3. Entry / Home
### `GET /entry/resolve`
@@ -88,6 +366,50 @@
- `channelCode`
- `channelType`
- `platformAppId`
+
+## 3.1 Public Experience
+
+### `GET /public/experience-maps`
+
+用途:
+
+- 游客模式地图列表
+- 返回可公开体验的地图与默认活动摘要
+
+### `GET /public/experience-maps/{mapAssetPublicID}`
+
+用途:
+
+- 游客模式地图详情
+- 返回某张地图下挂载的默认体验活动
+
+### `GET /public/events/{eventPublicID}`
+
+用途:
+
+- 游客模式活动详情
+- 只允许默认体验活动
+
+### `GET /public/events/{eventPublicID}/play`
+
+用途:
+
+- 游客模式准备页聚合接口
+- 返回 `canLaunch`、`assignmentMode`、`courseVariants`、`preview`
+
+### `POST /public/events/{eventPublicID}/launch`
+
+用途:
+
+- 游客模式启动一局
+- 返回与正式 launch 近似结构,并带 `business.isGuest = true`
+
+核心参数:
+
+- `releaseId`
+- `variantId`
+- `clientType`
+- `deviceKey`
- `tenantCode`
### `GET /home`
@@ -113,6 +435,55 @@
- 只返回卡片列表
- 当前与 `/home` 使用同一套卡片摘要语义
+当前额外补充:
+
+- `showInEventList`
+
+### `GET /experience-maps`
+
+用途:
+
+- 地图资源列表
+- 返回每张地图下默认体验活动数量和默认活动 ID 列表
+
+返回重点:
+
+- `placeId`
+- `placeName`
+- `mapId`
+- `mapName`
+- `coverUrl`
+- `summary`
+- `defaultExperienceCount`
+- `defaultExperienceEventIds`
+
+### `GET /experience-maps/{mapAssetPublicID}`
+
+用途:
+
+- 地图详情
+- 返回地图基础信息和默认体验活动摘要
+
+返回重点:
+
+- `tileBaseUrl`
+- `tileMetaUrl`
+- `defaultExperiences[]`
+
+`defaultExperiences[]` 当前包含:
+
+- `eventId`
+- `title`
+- `subtitle`
+- `eventType`
+- `status`
+- `statusCode`
+- `ctaText`
+- `isDefaultExperience`
+- `showInEventList`
+- `currentPresentation`
+- `currentContentBundle`
+
### `GET /me/entry-home`
鉴权:
@@ -153,6 +524,7 @@
- `release`
- `resolvedRelease`
- `runtime`
+- `preview`
- `currentPresentation`
- `currentContentBundle`
@@ -165,6 +537,18 @@
- `currentContentBundle.bundleType`
- `currentContentBundle.version`
+当前 `preview` 为准备页地图预览 V1 只读增强字段,最小包括:
+
+- `preview.mode`
+- `preview.baseTiles.tileBaseUrl`
+- `preview.baseTiles.zoom`
+- `preview.baseTiles.tileSize`
+- `preview.viewport.width / height`
+- `preview.viewport.minLon / minLat / maxLon / maxLat`
+- `preview.variants[].controls`
+- `preview.variants[].legs`
+- `preview.selectedVariantId`
+
### `GET /events/{eventPublicID}/play`
鉴权:
@@ -181,6 +565,7 @@
- `release`
- `resolvedRelease`
- `runtime`
+- `preview`
- `currentPresentation`
- `currentContentBundle`
- `play.assignmentMode`
@@ -209,6 +594,8 @@
- `currentContentBundle.bundleType`
- `currentContentBundle.version`
+当前 `preview` 继续表示准备页地图预览 V1 只读增强字段,结构与 `GET /events/{eventPublicID}` 相同。
+
### `POST /events/{eventPublicID}/launch`
鉴权:
@@ -665,6 +1052,9 @@
- 当前是后台第一版的最小对象接口
- 先只做 Bearer 鉴权
- 暂未接正式 RBAC / 管理员权限模型
+- 运维后台当前已开始切到独立前缀:
+ - `/ops/admin/*`
+ - 这批接口在运维后台中与 `/admin/*` 使用同一套 handler,语义一致
### `GET /admin/maps`
@@ -1259,6 +1649,28 @@
- 查看地图资产详情
- 同时带出瓦片版本和赛道集合摘要
+### `GET /admin/map-assets`
+
+鉴权:
+
+- Bearer token
+
+用途:
+
+- 查看地图资产列表
+- 返回地点、当前瓦片版本和关联活动摘要
+
+### `PUT /admin/map-assets/{mapAssetPublicID}`
+
+鉴权:
+
+- Bearer token
+
+用途:
+
+- 更新地图资产基础信息
+- 支持名称、封面、摘要、状态维护
+
### `POST /admin/map-assets/{mapAssetPublicID}/tile-releases`
鉴权:
@@ -1415,6 +1827,114 @@
- 查看单个运行绑定详情
+### `POST /admin/ops/tile-releases/import`
+
+鉴权:
+
+- Bearer token
+
+用途:
+
+- 运维入口第一期:按 `place + map asset + tile release` 一次录入瓦片版本
+- 可在录入时直接指定 `setAsCurrent`
+
+请求体重点:
+
+- `placeCode`
+- `placeName`
+- `mapAssetCode`
+- `mapAssetName`
+- `mapType`
+- `versionCode`
+- `tileBaseUrl`
+- `metaUrl`
+- `publishedAssetRoot`
+- `setAsCurrent`
+
+### `POST /admin/ops/course-sets/import-kml-batch`
+
+鉴权:
+
+- Bearer token
+
+用途:
+
+- 运维入口第一期:按一组 KML 路线批量录入赛道集合与 variants
+- 适合把同一场地的一批路线一次性整理成 `course set`
+
+请求体重点:
+
+- `placeCode`
+- `placeName`
+- `mapAssetCode`
+- `mapAssetName`
+- `mapType`
+- `courseSetCode`
+- `courseSetName`
+- `mode`
+- `defaultRouteCode`
+- `routes[]`
+
+### `GET /admin/assets`
+
+鉴权:
+
+- Bearer token
+
+用途:
+
+- 查看当前 backend 已纳管的正式资源对象列表
+
+### `POST /admin/assets/register-link`
+
+鉴权:
+
+- Bearer token
+
+用途:
+
+- 登记一个已有正式外链为受管资源
+
+请求体重点:
+
+- `assetType`
+- `assetCode`
+- `version`
+- `publicUrl`
+- `status`
+- `metadata`
+
+### `POST /admin/assets/upload`
+
+鉴权:
+
+- Bearer token
+
+用途:
+
+- 通过 backend 上传一个正式资源文件到 OSS,并自动纳管
+
+表单重点:
+
+- `assetType`
+- `assetCode`
+- `version`
+- `title`
+- `objectDir` 可选
+- `status`
+- `metadataJson` 可选
+- `file`
+
+### `GET /admin/assets/{assetPublicID}`
+
+鉴权:
+
+- Bearer token
+
+用途:
+
+- 查看单个已纳管资源对象详情
+
### `GET /home`
diff --git a/backend/docs/数据模型.md b/backend/docs/数据模型.md
index 4992944..ec53ebd 100644
--- a/backend/docs/数据模型.md
+++ b/backend/docs/数据模型.md
@@ -1,8 +1,8 @@
# 数据模型
-> 文档版本:v1.4
-> 最后更新:2026-04-03 22:34:08
+> 文档版本:v1.6
+> 最后更新:2026-04-07 16:29:08
-当前 migration 共 11 版。
+当前 migration 共 15 版。
## 1. 迁移清单
@@ -17,6 +17,20 @@
- [0009_event_ops_phase2.sql](D:/dev/cmr-mini/backend/migrations/0009_event_ops_phase2.sql)
- [0010_event_default_bindings.sql](D:/dev/cmr-mini/backend/migrations/0010_event_default_bindings.sql)
- [0011_card_summary.sql](D:/dev/cmr-mini/backend/migrations/0011_card_summary.sql)
+- [0012_managed_assets.sql](D:/dev/cmr-mini/backend/migrations/0012_managed_assets.sql)
+- [0013_ops_console.sql](D:/dev/cmr-mini/backend/migrations/0013_ops_console.sql)
+- [0014_map_experience.sql](D:/dev/cmr-mini/backend/migrations/0014_map_experience.sql)
+- [0015_guest_identity.sql](D:/dev/cmr-mini/backend/migrations/0015_guest_identity.sql)
+
+## 2. 当前地图体验入口相关字段
+
+- `events.is_default_experience`
+- `events.show_in_event_list`
+
+当前用途:
+
+- 支撑地图列表下的默认体验活动
+- 统一活动卡片、地图详情和默认体验入口语义
## 2. 表分组
@@ -50,6 +64,7 @@
- `mobile`
- `wechat_mini_openid`
- `wechat_unionid`
+- `guest`
### 2.3 业务对象与配置发布
diff --git a/backend/docs/资源对象与目录方案.md b/backend/docs/资源对象与目录方案.md
index 711381e..5447af7 100644
--- a/backend/docs/资源对象与目录方案.md
+++ b/backend/docs/资源对象与目录方案.md
@@ -1,6 +1,6 @@
# 资源对象与目录方案
-> 文档版本:v1.0
-> 最后更新:2026-04-02 08:28:05
+> 文档版本:v1.3
+> 最后更新:2026-04-07 13:13:19
本文档用于把“地图复用、KML 复用、内容资源复用、配置发布”统一收成一套后端可执行方案。
@@ -13,6 +13,20 @@
- 让 `release` 能稳定追溯当时到底用了哪一份地图、哪一份 KML、哪一套资源包
- 让同一套资源对象既能服务小程序,也能服务未来 APP
+当前补充约束:
+
+- 正式资源目录只认 `OSS / CDN`
+- 本地 `tmp/` 仅作为临时收件箱,不参与正式发布源
+- backend 当前已开始提供运维入口第一期:
+ - `POST /admin/ops/tile-releases/import`
+ - `POST /admin/ops/course-sets/import-kml-batch`
+- backend 当前也已开始提供运维入口第二期:
+ - `POST /admin/assets/upload`
+ - `POST /admin/assets/register-link`
+ - `GET /admin/assets`
+ - `GET /admin/assets/{assetPublicID}`
+- 当前目标是把“上传文件”和“登记外链”统一收口到同一套资源模型,不要求运维自己关心底层存储实现。
+
---
## 1. 设计结论
@@ -379,6 +393,10 @@ build / publish 时,Go 中间层应做装配:
```text
gotomars/maps/{mapCode}/{version}/...
+gotomars/kml/{placeCode}/{version}/route01.kml
+gotomars/kml/{placeCode}/{version}/route02.kml
+gotomars/kml/{placeCode}/{version}/route03.kml
+gotomars/kml/{placeCode}/{version}/route04.kml
gotomars/playfields/{playfieldCode}/{version}/...
gotomars/resource-packs/{packCode}/{version}/...
gotomars/game-modes/{modeCode}/{version}/mode.json
@@ -394,6 +412,16 @@ gotomars/event-releases/{eventPublicID}/{releasePublicID}/asset-index.json
- 同一个 map / KML 修复时不会污染所有旧 release
- APP 与小程序可共用相同资源版本,不必重复发两套发布目录
+补充约束:
+
+- 正式资源目录只认 `OSS / CDN`,不认仓库本地目录
+- `tmp/` 只作为临时收件箱,不作为任何正式发布源
+- 当前 manual 多赛道 demo 已切到:
+ - `gotomars/kml/lxcb-001/2026-04-07/route01.kml`
+ - `gotomars/kml/lxcb-001/2026-04-07/route02.kml`
+ - `gotomars/kml/lxcb-001/2026-04-07/route03.kml`
+ - `gotomars/kml/lxcb-001/2026-04-07/route04.kml`
+
---
## 8. 数据库建模建议
diff --git a/backend/internal/app/app.go b/backend/internal/app/app.go
index cff1c6c..8feb004 100644
--- a/backend/internal/app/app.go
+++ b/backend/internal/app/app.go
@@ -26,6 +26,7 @@ func New(ctx context.Context, cfg Config) (*App, error) {
store := postgres.NewStore(pool)
jwtManager := jwtx.NewManager(cfg.JWTIssuer, cfg.JWTAccessSecret, cfg.JWTAccessTTL)
wechatMiniClient := wechatmini.NewClient(cfg.WechatMiniAppID, cfg.WechatMiniSecret, cfg.WechatMiniDevPrefix)
+ assetPublisher := assets.NewOSSUtilPublisher(cfg.OSSUtilPath, cfg.OSSUtilConfigFile, cfg.AssetBucketRoot, cfg.AssetPublicBaseURL)
authService := service.NewAuthService(service.AuthSettings{
AppEnv: cfg.AppEnv,
RefreshTTL: cfg.RefreshTTL,
@@ -37,21 +38,32 @@ func New(ctx context.Context, cfg Config) (*App, error) {
}, store, jwtManager)
entryService := service.NewEntryService(store)
entryHomeService := service.NewEntryHomeService(store)
+ adminAssetService := service.NewAdminAssetService(store, cfg.AssetBaseURL, assetPublisher)
adminResourceService := service.NewAdminResourceService(store)
adminProductionService := service.NewAdminProductionService(store)
adminEventService := service.NewAdminEventService(store)
+ opsAuthService := service.NewOpsAuthService(service.OpsAuthSettings{
+ AppEnv: cfg.AppEnv,
+ RefreshTTL: cfg.RefreshTTL,
+ SMSCodeTTL: cfg.SMSCodeTTL,
+ SMSCodeCooldown: cfg.SMSCodeCooldown,
+ SMSProvider: cfg.SMSProvider,
+ DevSMSCode: cfg.DevSMSCode,
+ }, store, jwtManager)
+ opsSummaryService := service.NewOpsSummaryService(store)
eventService := service.NewEventService(store)
eventPlayService := service.NewEventPlayService(store)
- assetPublisher := assets.NewOSSUtilPublisher(cfg.OSSUtilPath, cfg.OSSUtilConfigFile, cfg.AssetBucketRoot, cfg.AssetPublicBaseURL)
configService := service.NewConfigService(store, cfg.LocalEventDir, cfg.AssetBaseURL, assetPublisher)
adminPipelineService := service.NewAdminPipelineService(store, configService)
homeService := service.NewHomeService(store)
+ mapExperienceService := service.NewMapExperienceService(store)
+ publicExperienceService := service.NewPublicExperienceService(store, mapExperienceService, eventService)
profileService := service.NewProfileService(store)
resultService := service.NewResultService(store)
sessionService := service.NewSessionService(store)
devService := service.NewDevService(cfg.AppEnv, store)
meService := service.NewMeService(store)
- router := httpapi.NewRouter(cfg.AppEnv, jwtManager, authService, entryService, entryHomeService, adminResourceService, adminProductionService, adminEventService, adminPipelineService, eventService, eventPlayService, configService, homeService, profileService, resultService, sessionService, devService, meService)
+ router := httpapi.NewRouter(cfg.AppEnv, jwtManager, authService, opsAuthService, opsSummaryService, entryService, entryHomeService, adminAssetService, adminResourceService, adminProductionService, adminEventService, adminPipelineService, eventService, eventPlayService, publicExperienceService, configService, homeService, mapExperienceService, profileService, resultService, sessionService, devService, meService)
return &App{
router: router,
diff --git a/backend/internal/httpapi/handlers/admin_asset_handler.go b/backend/internal/httpapi/handlers/admin_asset_handler.go
new file mode 100644
index 0000000..9e92a80
--- /dev/null
+++ b/backend/internal/httpapi/handlers/admin_asset_handler.go
@@ -0,0 +1,132 @@
+package handlers
+
+import (
+ "crypto/sha256"
+ "encoding/hex"
+ "encoding/json"
+ "io"
+ "net/http"
+ "os"
+
+ "cmr-backend/internal/apperr"
+ "cmr-backend/internal/httpx"
+ "cmr-backend/internal/service"
+)
+
+type AdminAssetHandler struct {
+ service *service.AdminAssetService
+}
+
+func NewAdminAssetHandler(service *service.AdminAssetService) *AdminAssetHandler {
+ return &AdminAssetHandler{service: service}
+}
+
+func (h *AdminAssetHandler) ListAssets(w http.ResponseWriter, r *http.Request) {
+ result, err := h.service.ListManagedAssets(r.Context(), parseAdminLimit(r))
+ if err != nil {
+ httpx.WriteError(w, err)
+ return
+ }
+ httpx.WriteJSON(w, http.StatusOK, map[string]any{"data": result})
+}
+
+func (h *AdminAssetHandler) GetAsset(w http.ResponseWriter, r *http.Request) {
+ result, err := h.service.GetManagedAsset(r.Context(), r.PathValue("assetPublicID"))
+ if err != nil {
+ httpx.WriteError(w, err)
+ return
+ }
+ httpx.WriteJSON(w, http.StatusOK, map[string]any{"data": result})
+}
+
+func (h *AdminAssetHandler) RegisterLink(w http.ResponseWriter, r *http.Request) {
+ var req service.RegisterLinkAssetInput
+ if err := httpx.DecodeJSON(r, &req); err != nil {
+ httpx.WriteError(w, apperr.New(http.StatusBadRequest, "invalid_json", "invalid request body: "+err.Error()))
+ return
+ }
+ result, err := h.service.RegisterExternalLink(r.Context(), req)
+ if err != nil {
+ httpx.WriteError(w, err)
+ return
+ }
+ httpx.WriteJSON(w, http.StatusCreated, map[string]any{"data": result})
+}
+
+func (h *AdminAssetHandler) UploadFile(w http.ResponseWriter, r *http.Request) {
+ if err := r.ParseMultipartForm(64 << 20); err != nil {
+ httpx.WriteError(w, apperr.New(http.StatusBadRequest, "invalid_multipart", "invalid multipart form: "+err.Error()))
+ return
+ }
+
+ file, header, err := r.FormFile("file")
+ if err != nil {
+ httpx.WriteError(w, apperr.New(http.StatusBadRequest, "file_required", "multipart file field 'file' is required"))
+ return
+ }
+ defer file.Close()
+
+ tmpFile, err := os.CreateTemp("", "cmr-upload-*"+header.Filename)
+ if err != nil {
+ httpx.WriteError(w, err)
+ return
+ }
+ tmpPath := tmpFile.Name()
+ defer os.Remove(tmpPath)
+
+ hash := sha256.New()
+ written, err := io.Copy(io.MultiWriter(tmpFile, hash), file)
+ if err != nil {
+ tmpFile.Close()
+ httpx.WriteError(w, err)
+ return
+ }
+ if err := tmpFile.Close(); err != nil {
+ httpx.WriteError(w, err)
+ return
+ }
+
+ input := service.UploadAssetFileInput{
+ AssetType: r.FormValue("assetType"),
+ AssetCode: r.FormValue("assetCode"),
+ Version: r.FormValue("version"),
+ Title: stringPtrOrNil(r.FormValue("title")),
+ ObjectDir: stringPtrOrNil(r.FormValue("objectDir")),
+ FileName: header.Filename,
+ ContentType: header.Header.Get("Content-Type"),
+ FileSize: written,
+ Checksum: hex.EncodeToString(hash.Sum(nil)),
+ TempPath: tmpPath,
+ Status: r.FormValue("status"),
+ Metadata: parseMetadataJSON(r.FormValue("metadataJson")),
+ }
+
+ result, err := h.service.UploadAssetFile(r.Context(), input)
+ if err != nil {
+ httpx.WriteError(w, err)
+ return
+ }
+ httpx.WriteJSON(w, http.StatusCreated, map[string]any{
+ "data": result,
+ "meta": map[string]any{
+ "uploadedBytes": written,
+ "checksumSha256": input.Checksum,
+ },
+ })
+}
+
+func stringPtrOrNil(value string) *string {
+ if value == "" {
+ return nil
+ }
+ return &value
+}
+
+func parseMetadataJSON(raw string) map[string]any {
+ if raw == "" {
+ return nil
+ }
+ var payload map[string]any
+ _ = json.Unmarshal([]byte(raw), &payload)
+ return payload
+}
diff --git a/backend/internal/httpapi/handlers/admin_production_handler.go b/backend/internal/httpapi/handlers/admin_production_handler.go
index 17c4f94..bc6838e 100644
--- a/backend/internal/httpapi/handlers/admin_production_handler.go
+++ b/backend/internal/httpapi/handlers/admin_production_handler.go
@@ -25,6 +25,15 @@ func (h *AdminProductionHandler) ListPlaces(w http.ResponseWriter, r *http.Reque
httpx.WriteJSON(w, http.StatusOK, map[string]any{"data": result})
}
+func (h *AdminProductionHandler) ListMapAssets(w http.ResponseWriter, r *http.Request) {
+ result, err := h.service.ListMapAssets(r.Context(), parseAdminLimit(r))
+ if err != nil {
+ httpx.WriteError(w, err)
+ return
+ }
+ httpx.WriteJSON(w, http.StatusOK, map[string]any{"data": result})
+}
+
func (h *AdminProductionHandler) CreatePlace(w http.ResponseWriter, r *http.Request) {
var req service.CreateAdminPlaceInput
if err := httpx.DecodeJSON(r, &req); err != nil {
@@ -71,6 +80,20 @@ func (h *AdminProductionHandler) GetMapAsset(w http.ResponseWriter, r *http.Requ
httpx.WriteJSON(w, http.StatusOK, map[string]any{"data": result})
}
+func (h *AdminProductionHandler) UpdateMapAsset(w http.ResponseWriter, r *http.Request) {
+ var req service.UpdateAdminMapAssetInput
+ if err := httpx.DecodeJSON(r, &req); err != nil {
+ httpx.WriteError(w, apperr.New(http.StatusBadRequest, "invalid_json", "invalid request body: "+err.Error()))
+ return
+ }
+ result, err := h.service.UpdateMapAsset(r.Context(), r.PathValue("mapAssetPublicID"), req)
+ if err != nil {
+ httpx.WriteError(w, err)
+ return
+ }
+ httpx.WriteJSON(w, http.StatusOK, map[string]any{"data": result})
+}
+
func (h *AdminProductionHandler) CreateTileRelease(w http.ResponseWriter, r *http.Request) {
var req service.CreateAdminTileReleaseInput
if err := httpx.DecodeJSON(r, &req); err != nil {
@@ -177,6 +200,34 @@ func (h *AdminProductionHandler) CreateRuntimeBinding(w http.ResponseWriter, r *
httpx.WriteJSON(w, http.StatusCreated, map[string]any{"data": result})
}
+func (h *AdminProductionHandler) ImportTileRelease(w http.ResponseWriter, r *http.Request) {
+ var req service.ImportAdminTileReleaseInput
+ if err := httpx.DecodeJSON(r, &req); err != nil {
+ httpx.WriteError(w, apperr.New(http.StatusBadRequest, "invalid_json", "invalid request body: "+err.Error()))
+ return
+ }
+ result, err := h.service.ImportTileRelease(r.Context(), req)
+ if err != nil {
+ httpx.WriteError(w, err)
+ return
+ }
+ httpx.WriteJSON(w, http.StatusCreated, map[string]any{"data": result})
+}
+
+func (h *AdminProductionHandler) ImportCourseSetKMLBatch(w http.ResponseWriter, r *http.Request) {
+ var req service.ImportAdminCourseSetBatchInput
+ if err := httpx.DecodeJSON(r, &req); err != nil {
+ httpx.WriteError(w, apperr.New(http.StatusBadRequest, "invalid_json", "invalid request body: "+err.Error()))
+ return
+ }
+ result, err := h.service.ImportCourseSetKMLBatch(r.Context(), req)
+ if err != nil {
+ httpx.WriteError(w, err)
+ return
+ }
+ httpx.WriteJSON(w, http.StatusCreated, map[string]any{"data": result})
+}
+
func (h *AdminProductionHandler) GetRuntimeBinding(w http.ResponseWriter, r *http.Request) {
result, err := h.service.GetRuntimeBinding(r.Context(), r.PathValue("runtimeBindingPublicID"))
if err != nil {
diff --git a/backend/internal/httpapi/handlers/dev_handler.go b/backend/internal/httpapi/handlers/dev_handler.go
index fd6e842..4da9ef9 100644
--- a/backend/internal/httpapi/handlers/dev_handler.go
+++ b/backend/internal/httpapi/handlers/dev_handler.go
@@ -167,6 +167,21 @@ func (h *DevHandler) DemoContentManifest(w http.ResponseWriter, r *http.Request)
httpx.WriteJSON(w, http.StatusOK, payload)
}
+func (h *DevHandler) DemoGameManifest(w http.ResponseWriter, r *http.Request) {
+ if !h.devService.Enabled() {
+ http.NotFound(w, r)
+ return
+ }
+
+ key := r.PathValue("demoKey")
+ payload, ok := demoGameManifestAssets[key]
+ if !ok {
+ http.NotFound(w, r)
+ return
+ }
+ httpx.WriteJSON(w, http.StatusOK, payload)
+}
+
func pickString(v any) string {
switch t := v.(type) {
case string:
@@ -242,7 +257,7 @@ var demoPresentationAssets = map[string]map[string]any{
"title": "雪熊领秀城区多赛道挑战展示定义",
"event": map[string]any{
"title": "雪熊领秀城区多赛道挑战",
- "subtitle": "A / B 线手动选择联调活动",
+ "subtitle": "4 条路线手动选择联调活动",
},
"card": map[string]any{
"heroTitle": "多赛道选择",
@@ -251,9 +266,9 @@ var demoPresentationAssets = map[string]map[string]any{
},
"detail": map[string]any{
"sections": []map[string]any{
- {"type": "hero", "title": "先选赛道再开始", "subtitle": "A 线偏短,B 线偏长"},
- {"type": "variants", "items": []string{"A 线:短线体验版", "B 线:长线挑战版"}},
- {"type": "summary", "items": []string{"适合验证 variant 选择与回流链", "默认推荐 B 线做联调"}},
+ {"type": "hero", "title": "先选赛道再开始", "subtitle": "路线 01 ~ 04 四选一"},
+ {"type": "variants", "items": []string{"路线 01", "路线 02", "路线 03", "路线 04"}},
+ {"type": "summary", "items": []string{"适合验证 variant 选择与回流链", "默认推荐 路线 04 做联调"}},
},
},
},
@@ -316,15 +331,15 @@ var demoContentAssets = map[string]map[string]any{
"locale": "zh-CN",
"event": map[string]any{
"title": "雪熊领秀城区多赛道挑战",
- "subtitle": "A / B 线手动选择联调活动",
+ "subtitle": "4 条路线手动选择联调活动",
},
"hero": map[string]any{
"title": "同图多赛道",
"subtitle": "先选路线,再验证 launch / result / history 回流",
},
"sections": []map[string]any{
- {"type": "intro", "title": "玩法说明", "body": "A 线适合短线体验,B 线适合长线挑战与数据对比。"},
- {"type": "variants", "title": "赛道差异", "body": "两条赛道使用不同 KML,用于验证 variant 选择与恢复链。"},
+ {"type": "intro", "title": "玩法说明", "body": "路线 01 ~ 04 使用四条不同 KML,用于验证多赛道选择与回流。"},
+ {"type": "variants", "title": "赛道差异", "body": "四条赛道共享同一张底图,但各自使用独立 KML。"},
{"type": "result", "title": "结果页文案", "body": "结果页需展示所选赛道名、routeCode 与成绩摘要。"},
},
"assets": map[string]any{
@@ -334,6 +349,294 @@ var demoContentAssets = map[string]map[string]any{
},
}
+var demoGameManifestAssets = map[string]map[string]any{
+ "classic": {
+ "schemaVersion": "1",
+ "releaseId": "rel_demo_001",
+ "version": "2026.04.07",
+ "app": map[string]any{
+ "id": "sample-classic-001",
+ "title": "领秀城公园顺序赛",
+ },
+ "map": map[string]any{
+ "tiles": "https://oss-mbh5.colormaprun.com/gotomars/map/lxcb-001/tiles/",
+ "mapmeta": "https://oss-mbh5.colormaprun.com/gotomars/map/lxcb-001/tiles/meta.json",
+ },
+ "preview": demoGamePreview("classic"),
+ "playfield": map[string]any{
+ "kind": "course",
+ "source": map[string]any{
+ "type": "kml",
+ "url": "https://oss-mbh5.colormaprun.com/gotomars/kml/lxcb-001/10/c01.kml",
+ },
+ },
+ "game": map[string]any{
+ "mode": "classic-sequential",
+ },
+ "assets": map[string]any{
+ "contentHtml": "https://oss-mbh5.colormaprun.com/gotomars/event/content-h5-test-template.html",
+ },
+ },
+ "score-o": {
+ "schemaVersion": "1",
+ "releaseId": "rel_demo_score_o_001",
+ "version": "2026.04.07",
+ "app": map[string]any{
+ "id": "sample-score-o-001",
+ "title": "领秀城公园积分赛",
+ },
+ "map": map[string]any{
+ "tiles": "https://oss-mbh5.colormaprun.com/gotomars/map/lxcb-001/tiles/",
+ "mapmeta": "https://oss-mbh5.colormaprun.com/gotomars/map/lxcb-001/tiles/meta.json",
+ },
+ "preview": demoGamePreview("score-o"),
+ "playfield": map[string]any{
+ "kind": "control-set",
+ "source": map[string]any{
+ "type": "kml",
+ "url": "https://oss-mbh5.colormaprun.com/gotomars/kml/lxcb-001/10/c01.kml",
+ },
+ },
+ "game": map[string]any{
+ "mode": "score-o",
+ },
+ "assets": map[string]any{
+ "contentHtml": "https://oss-mbh5.colormaprun.com/gotomars/event/content-h5-test-template.html",
+ },
+ },
+ "manual-variant": {
+ "schemaVersion": "1",
+ "releaseId": "rel_demo_variant_manual_001",
+ "version": "2026.04.07",
+ "app": map[string]any{
+ "id": "sample-variant-manual-001",
+ "title": "领秀城公园多赛道挑战",
+ },
+ "map": map[string]any{
+ "tiles": "https://oss-mbh5.colormaprun.com/gotomars/map/lxcb-001/tiles/",
+ "mapmeta": "https://oss-mbh5.colormaprun.com/gotomars/map/lxcb-001/tiles/meta.json",
+ },
+ "preview": demoGamePreview("manual-variant"),
+ "playfield": map[string]any{
+ "kind": "course",
+ "source": map[string]any{
+ "type": "kml",
+ "url": "https://oss-mbh5.colormaprun.com/gotomars/kml/lxcb-001/2026-04-07/route04.kml",
+ },
+ },
+ "game": map[string]any{
+ "mode": "classic-sequential",
+ },
+ "play": map[string]any{
+ "assignmentMode": "manual",
+ "courseVariants": []map[string]any{
+ {
+ "id": "variant_a",
+ "name": "路线 01",
+ "description": "route01.kml",
+ "routeCode": "route-variant-a",
+ "selectable": true,
+ },
+ {
+ "id": "variant_b",
+ "name": "路线 02",
+ "description": "route02.kml",
+ "routeCode": "route-variant-b",
+ "selectable": true,
+ },
+ {
+ "id": "variant_c",
+ "name": "路线 03",
+ "description": "route03.kml",
+ "routeCode": "route-variant-c",
+ "selectable": true,
+ },
+ {
+ "id": "variant_d",
+ "name": "路线 04",
+ "description": "route04.kml",
+ "routeCode": "route-variant-d",
+ "selectable": true,
+ },
+ },
+ },
+ "assets": map[string]any{
+ "contentHtml": "https://oss-mbh5.colormaprun.com/gotomars/event/content-h5-test-template.html",
+ },
+ },
+}
+
+func demoGamePreview(kind string) map[string]any {
+ baseTiles := map[string]any{
+ "tileBaseUrl": "https://oss-mbh5.colormaprun.com/gotomars/map/lxcb-001/tiles/",
+ "zoom": 15,
+ "tileSize": 256,
+ }
+ viewport := map[string]any{
+ "width": 800,
+ "height": 450,
+ "minLon": 117.0000,
+ "minLat": 36.6000,
+ "maxLon": 117.0800,
+ "maxLat": 36.6600,
+ }
+ switch kind {
+ case "score-o":
+ return map[string]any{
+ "mode": "readonly",
+ "baseTiles": baseTiles,
+ "viewport": viewport,
+ "selectedVariantId": "variant_score_main",
+ "variants": []map[string]any{
+ {
+ "variantId": "variant_score_main",
+ "name": "积分赛主赛道",
+ "routeCode": "route-score-o-001",
+ "controls": []map[string]any{
+ {"id": "start", "kind": "start", "lon": 117.012, "lat": 36.612, "label": "起点"},
+ {"id": "s1", "kind": "control", "lon": 117.021, "lat": 36.618, "label": "10分点"},
+ {"id": "s2", "kind": "control", "lon": 117.034, "lat": 36.624, "label": "20分点"},
+ {"id": "s3", "kind": "control", "lon": 117.046, "lat": 36.616, "label": "15分点"},
+ {"id": "finish", "kind": "finish", "lon": 117.025, "lat": 36.606, "label": "终点"},
+ },
+ "legs": []map[string]any{
+ {"from": "start", "to": "s1"},
+ {"from": "s1", "to": "s2"},
+ {"from": "s2", "to": "s3"},
+ {"from": "s3", "to": "finish"},
+ },
+ },
+ },
+ }
+ case "manual-variant":
+ return map[string]any{
+ "mode": "readonly",
+ "baseTiles": baseTiles,
+ "viewport": viewport,
+ "selectedVariantId": "variant_d",
+ "variants": []map[string]any{
+ {
+ "variantId": "variant_a",
+ "name": "路线 01",
+ "routeCode": "route-variant-a",
+ "controls": []map[string]any{
+ {"id": "start", "kind": "start", "lon": 117.011, "lat": 36.611, "label": "A起点"},
+ {"id": "a1", "kind": "control", "lon": 117.020, "lat": 36.616, "label": "A1"},
+ {"id": "a2", "kind": "control", "lon": 117.028, "lat": 36.621, "label": "A2"},
+ {"id": "finish", "kind": "finish", "lon": 117.034, "lat": 36.626, "label": "A终点"},
+ },
+ "legs": []map[string]any{
+ {"from": "start", "to": "a1"},
+ {"from": "a1", "to": "a2"},
+ {"from": "a2", "to": "finish"},
+ },
+ },
+ {
+ "variantId": "variant_b",
+ "name": "路线 02",
+ "routeCode": "route-variant-b",
+ "controls": []map[string]any{
+ {"id": "start", "kind": "start", "lon": 117.014, "lat": 36.609, "label": "B起点"},
+ {"id": "b1", "kind": "control", "lon": 117.025, "lat": 36.615, "label": "B1"},
+ {"id": "b2", "kind": "control", "lon": 117.038, "lat": 36.622, "label": "B2"},
+ {"id": "b3", "kind": "control", "lon": 117.051, "lat": 36.629, "label": "B3"},
+ {"id": "finish", "kind": "finish", "lon": 117.060, "lat": 36.634, "label": "B终点"},
+ },
+ "legs": []map[string]any{
+ {"from": "start", "to": "b1"},
+ {"from": "b1", "to": "b2"},
+ {"from": "b2", "to": "b3"},
+ {"from": "b3", "to": "finish"},
+ },
+ },
+ {
+ "variantId": "variant_c",
+ "name": "路线 03",
+ "routeCode": "route-variant-c",
+ "controls": []map[string]any{
+ {"id": "1", "kind": "control", "lon": 117.000649296107, "lat": 36.5921631022497, "label": "1"},
+ {"id": "2", "kind": "control", "lon": 117.000665559813, "lat": 36.5919574366878, "label": "2"},
+ {"id": "3", "kind": "control", "lon": 117.001397426578, "lat": 36.5915983367736, "label": "3"},
+ {"id": "4", "kind": "control", "lon": 117.000441933857, "lat": 36.5915004001434, "label": "4"},
+ {"id": "5", "kind": "control", "lon": 117.000340285695, "lat": 36.5909356298175, "label": "5"},
+ {"id": "6", "kind": "control", "lon": 116.999860506371, "lat": 36.5912131186443, "label": "6"},
+ {"id": "7", "kind": "control", "lon": 116.999823913032, "lat": 36.591572220351, "label": "7"},
+ {"id": "8", "kind": "control", "lon": 116.999108309973, "lat": 36.5919019395375, "label": "8"},
+ {"id": "9", "kind": "control", "lon": 116.999689737459, "lat": 36.5922740961347, "label": "9"},
+ {"id": "10", "kind": "control", "lon": 117.000649296107, "lat": 36.5921631022497, "label": "10"},
+ },
+ "legs": []map[string]any{
+ {"from": "1", "to": "2"},
+ {"from": "2", "to": "3"},
+ {"from": "3", "to": "4"},
+ {"from": "4", "to": "5"},
+ {"from": "5", "to": "6"},
+ {"from": "6", "to": "7"},
+ {"from": "7", "to": "8"},
+ {"from": "8", "to": "9"},
+ {"from": "9", "to": "10"},
+ },
+ },
+ {
+ "variantId": "variant_d",
+ "name": "路线 04",
+ "routeCode": "route-variant-d",
+ "controls": []map[string]any{
+ {"id": "1", "kind": "control", "lon": 117.000649296107, "lat": 36.5921631022497, "label": "1"},
+ {"id": "2", "kind": "control", "lon": 117.000803801313, "lat": 36.5919411140006, "label": "2"},
+ {"id": "3", "kind": "control", "lon": 117.000937976887, "lat": 36.5916113949816, "label": "3"},
+ {"id": "4", "kind": "control", "lon": 117.000238637533, "lat": 36.5914742836876, "label": "4"},
+ {"id": "5", "kind": "control", "lon": 117.00058830721, "lat": 36.5905340853955, "label": "5"},
+ {"id": "6", "kind": "control", "lon": 116.998941606987, "lat": 36.5908278985923, "label": "6"},
+ {"id": "7", "kind": "control", "lon": 116.998774904002, "lat": 36.5913306430232, "label": "7"},
+ {"id": "8", "kind": "control", "lon": 116.999710067091, "lat": 36.5917615642144, "label": "8"},
+ {"id": "9", "kind": "control", "lon": 116.999766990062, "lat": 36.5921141343085, "label": "9"},
+ {"id": "10", "kind": "control", "lon": 117.000649296107, "lat": 36.5921631022497, "label": "10"},
+ },
+ "legs": []map[string]any{
+ {"from": "1", "to": "2"},
+ {"from": "2", "to": "3"},
+ {"from": "3", "to": "4"},
+ {"from": "4", "to": "5"},
+ {"from": "5", "to": "6"},
+ {"from": "6", "to": "7"},
+ {"from": "7", "to": "8"},
+ {"from": "8", "to": "9"},
+ {"from": "9", "to": "10"},
+ },
+ },
+ },
+ }
+ default:
+ return map[string]any{
+ "mode": "readonly",
+ "baseTiles": baseTiles,
+ "viewport": viewport,
+ "selectedVariantId": "variant_classic_main",
+ "variants": []map[string]any{
+ {
+ "variantId": "variant_classic_main",
+ "name": "顺序赛主赛道",
+ "routeCode": "route-demo-a",
+ "controls": []map[string]any{
+ {"id": "start", "kind": "start", "lon": 117.010, "lat": 36.610, "label": "起点"},
+ {"id": "c1", "kind": "control", "lon": 117.018, "lat": 36.615, "label": "1"},
+ {"id": "c2", "kind": "control", "lon": 117.027, "lat": 36.621, "label": "2"},
+ {"id": "c3", "kind": "control", "lon": 117.036, "lat": 36.627, "label": "3"},
+ {"id": "finish", "kind": "finish", "lon": 117.044, "lat": 36.632, "label": "终点"},
+ },
+ "legs": []map[string]any{
+ {"from": "start", "to": "c1"},
+ {"from": "c1", "to": "c2"},
+ {"from": "c2", "to": "c3"},
+ {"from": "c3", "to": "finish"},
+ },
+ },
+ },
+ }
+ }
+}
+
const devWorkbenchHTML = `
@@ -1252,6 +1555,19 @@ const devWorkbenchHTML = `
GET/admin/playfields
后台赛场对象列表接口。
@@ -2633,6 +3162,17 @@ const devWorkbenchHTML = `
playfieldKind: '-'
};
+ const currentPreviewStatus = {
+ mode: '-',
+ tileBaseUrl: '-',
+ zoom: '-',
+ viewport: '-',
+ selectedVariantId: '-',
+ variantCount: '-',
+ controlCount: '-',
+ legCount: '-'
+ };
+
const $ = (id) => document.getElementById(id);
const logEl = $('log');
const curlEl = $('curl');
@@ -2809,6 +3349,17 @@ const devWorkbenchHTML = `
$('current-flow-playfield-kind').textContent = currentFlowStatus.playfieldKind || '-';
}
+ function renderCurrentPreviewStatus() {
+ $('current-preview-mode').textContent = currentPreviewStatus.mode || '-';
+ $('current-preview-tile-url').textContent = currentPreviewStatus.tileBaseUrl || '-';
+ $('current-preview-zoom').textContent = currentPreviewStatus.zoom || '-';
+ $('current-preview-viewport').textContent = currentPreviewStatus.viewport || '-';
+ $('current-preview-selected-variant').textContent = currentPreviewStatus.selectedVariantId || '-';
+ $('current-preview-variant-count').textContent = currentPreviewStatus.variantCount || '-';
+ $('current-preview-control-count').textContent = currentPreviewStatus.controlCount || '-';
+ $('current-preview-leg-count').textContent = currentPreviewStatus.legCount || '-';
+ }
+
function syncCurrentFlowStatusFromForms() {
currentFlowStatus.eventId = trimmedOrUndefined($('event-id').value) || '-';
currentFlowStatus.releaseId = trimmedOrUndefined($('event-release-id').value) || state.releaseId || '-';
@@ -2824,6 +3375,19 @@ const devWorkbenchHTML = `
currentFlowStatus.gameMode = '-';
currentFlowStatus.playfieldKind = '-';
renderCurrentFlowStatus();
+ resetCurrentPreviewStatus();
+ }
+
+ function resetCurrentPreviewStatus() {
+ currentPreviewStatus.mode = '-';
+ currentPreviewStatus.tileBaseUrl = '-';
+ currentPreviewStatus.zoom = '-';
+ currentPreviewStatus.viewport = '-';
+ currentPreviewStatus.selectedVariantId = '-';
+ currentPreviewStatus.variantCount = '-';
+ currentPreviewStatus.controlCount = '-';
+ currentPreviewStatus.legCount = '-';
+ renderCurrentPreviewStatus();
}
function setCurrentFlowStatusFromPlayResponse(result) {
@@ -2837,6 +3401,7 @@ const devWorkbenchHTML = `
currentFlowStatus.assignmentMode = play.assignmentMode || '-';
currentFlowStatus.variantCount = variants.length ? String(variants.length) : '0';
renderCurrentFlowStatus();
+ setCurrentPreviewStatusFromPayload(payload);
}
function setCurrentFlowStatusFromLaunch(summary, launchData) {
@@ -2857,6 +3422,42 @@ const devWorkbenchHTML = `
renderCurrentFlowStatus();
}
+ function setCurrentPreviewStatusFromPayload(payload) {
+ const preview = payload && payload.preview ? payload.preview : {};
+ const baseTiles = preview.baseTiles || {};
+ const viewport = preview.viewport || {};
+ const variants = Array.isArray(preview.variants) ? preview.variants : [];
+ const firstVariant = variants[0] || {};
+ const controls = Array.isArray(firstVariant.controls) ? firstVariant.controls : [];
+ const legs = Array.isArray(firstVariant.legs) ? firstVariant.legs : [];
+ currentPreviewStatus.mode = preview.mode || '-';
+ currentPreviewStatus.tileBaseUrl = baseTiles.tileBaseUrl || '-';
+ currentPreviewStatus.zoom = typeof baseTiles.zoom === 'number' ? String(baseTiles.zoom) : '-';
+ currentPreviewStatus.viewport = formatPreviewViewport(viewport);
+ currentPreviewStatus.selectedVariantId = preview.selectedVariantId || '-';
+ currentPreviewStatus.variantCount = variants.length ? String(variants.length) : '0';
+ currentPreviewStatus.controlCount = controls.length ? String(controls.length) : '0';
+ currentPreviewStatus.legCount = legs.length ? String(legs.length) : '0';
+ renderCurrentPreviewStatus();
+ }
+
+ function formatPreviewViewport(viewport) {
+ if (!viewport || Object.keys(viewport).length === 0) {
+ return '-';
+ }
+ const size = (typeof viewport.width === 'number' && typeof viewport.height === 'number')
+ ? (String(viewport.width) + 'x' + String(viewport.height))
+ : null;
+ const bboxReady = ['minLon', 'minLat', 'maxLon', 'maxLat'].every((key) => typeof viewport[key] === 'number');
+ const bbox = bboxReady
+ ? [viewport.minLon, viewport.minLat, viewport.maxLon, viewport.maxLat].map((value) => Number(value).toFixed(4)).join(', ')
+ : null;
+ if (size && bbox) {
+ return size + ' | ' + bbox;
+ }
+ return size || bbox || '-';
+ }
+
function setDefaultPublishExpectation(result) {
const release = result || {};
const releaseId = release.id || '-';
@@ -3013,6 +3614,45 @@ const devWorkbenchHTML = `
}
}
+ async function resolveEventManifestSummary(payload) {
+ const data = payload || {};
+ const release = data.release || {};
+ const resolvedRelease = data.resolvedRelease || {};
+ const manifestUrl = release.manifestUrl || resolvedRelease.manifestUrl || '-';
+ const releaseId = release.id || resolvedRelease.releaseId || '-';
+ const summary = {
+ configUrl: manifestUrl,
+ releaseId: releaseId,
+ manifestUrl: manifestUrl,
+ schemaVersion: '-',
+ playfieldKind: '-',
+ gameMode: '-',
+ verdict: '未读取 manifest'
+ };
+ const targetUrl = release.manifestUrl || resolvedRelease.manifestUrl;
+ if (!targetUrl) {
+ summary.verdict = '未通过:event 未返回 manifestUrl';
+ return summary;
+ }
+ try {
+ const proxy = await request('GET', '/dev/manifest-summary?url=' + encodeURIComponent(targetUrl));
+ const result = proxy && proxy.data ? proxy.data : {};
+ summary.schemaVersion = result.schemaVersion || '-';
+ summary.playfieldKind = result.playfieldKind || '-';
+ summary.gameMode = result.gameMode || '-';
+ if (summary.schemaVersion !== '-' && summary.playfieldKind !== '-' && summary.gameMode !== '-') {
+ summary.verdict = '通过:已读取 event 当前 manifest 摘要';
+ } else {
+ summary.verdict = '未通过:manifest 已读取,但关键信息不完整';
+ }
+ return summary;
+ } catch (error) {
+ summary.verdict = '未通过:manifest 读取异常';
+ summary.error = error && error.message ? error.message : String(error);
+ return summary;
+ }
+ }
+
function setLaunchConfigSummary(summary) {
const data = summary || {};
$('launch-config-url').textContent = data.configUrl || '-';
@@ -3415,6 +4055,9 @@ const devWorkbenchHTML = `
});
const play = await request('GET', '/events/' + encodeURIComponent(eventId) + '/play', undefined, true);
setCurrentFlowStatusFromPlayResponse(play);
+ if (play && play.data) {
+ setCurrentPreviewStatusFromPayload(play.data);
+ }
const playPass = !!(play.data && play.data.play && play.data.resolvedRelease && play.data.resolvedRelease.manifestUrl);
markFlowStep(flowTitle, 'event-launch', {
@@ -3732,6 +4375,8 @@ const devWorkbenchHTML = `
prodCourseSetId: $('prod-course-set-id').value,
prodCourseMode: $('prod-course-mode').value,
prodCourseSetStatus: $('prod-course-set-status').value,
+ prodCourseDefaultRouteCode: $('prod-course-default-route-code').value,
+ prodCourseRoutesJSON: $('prod-course-routes-json').value,
prodCourseVariantId: $('prod-course-variant-id').value,
prodCourseVariantName: $('prod-course-variant-name').value,
prodCourseVariantRouteCode: $('prod-course-variant-route-code').value,
@@ -3889,6 +4534,8 @@ const devWorkbenchHTML = `
$('prod-course-set-id').value = fields.prodCourseSetId || $('prod-course-set-id').value;
$('prod-course-mode').value = fields.prodCourseMode || $('prod-course-mode').value;
$('prod-course-set-status').value = fields.prodCourseSetStatus || $('prod-course-set-status').value;
+ $('prod-course-default-route-code').value = fields.prodCourseDefaultRouteCode || $('prod-course-default-route-code').value;
+ $('prod-course-routes-json').value = fields.prodCourseRoutesJSON || $('prod-course-routes-json').value;
$('prod-course-variant-id').value = fields.prodCourseVariantId || $('prod-course-variant-id').value;
$('prod-course-variant-name').value = fields.prodCourseVariantName || $('prod-course-variant-name').value;
$('prod-course-variant-route-code').value = fields.prodCourseVariantRouteCode || $('prod-course-variant-route-code').value;
@@ -4003,6 +4650,25 @@ const devWorkbenchHTML = `
}
}
+ function parseJSONArray(value, label) {
+ const trimmed = trimmedOrUndefined(value);
+ if (trimmed === undefined) {
+ return [];
+ }
+ try {
+ const parsed = JSON.parse(trimmed);
+ if (!Array.isArray(parsed)) {
+ throw new Error(label + ' must be a JSON array');
+ }
+ return parsed;
+ } catch (error) {
+ if (error && error.message && error.message.indexOf(label + ' must be a JSON array') >= 0) {
+ throw error;
+ }
+ throw new Error(label + ' must be valid JSON array');
+ }
+ }
+
function buildFinishSummary() {
const summary = {
finalDurationSec: parseIntOrNull($('finish-duration').value),
@@ -4087,33 +4753,31 @@ const devWorkbenchHTML = `
function categorizeApiPath(path) {
if (path === '/healthz') {
- return 'Health';
+ return '基础';
}
- if (path.indexOf('/auth/') === 0) {
- return 'Auth';
+ if (path.indexOf('/public/') === 0 || path.indexOf('/experience-maps') === 0) {
+ return '游客链';
}
- if (path.indexOf('/entry/') === 0 || path === '/home' || path === '/cards' || path === '/me/entry-home') {
- return 'Entry/Home';
+ if (path.indexOf('/dev/') === 0 || path === '/dev/workbench') {
+ return '调试链';
}
- if (path.indexOf('/events/') === 0 || path.indexOf('/config-sources/') === 0 || path.indexOf('/config-builds/') === 0) {
- return 'Event';
+ if (path.indexOf('/ops/') === 0 || path.indexOf('/admin/') === 0) {
+ return '运维链';
}
- if (path.indexOf('/sessions/') === 0 || path === '/me/sessions') {
- return 'Session';
+ if (
+ path.indexOf('/auth/') === 0 ||
+ path.indexOf('/entry/') === 0 ||
+ path === '/home' ||
+ path === '/cards' ||
+ path.indexOf('/events/') === 0 ||
+ path.indexOf('/config-sources/') === 0 ||
+ path.indexOf('/config-builds/') === 0 ||
+ path.indexOf('/sessions/') === 0 ||
+ path.indexOf('/me') === 0
+ ) {
+ return '玩家链';
}
- if (path === '/me/results') {
- return 'Result';
- }
- if (path === '/me' || path === '/me/profile') {
- return 'Profile';
- }
- if (path.indexOf('/dev/') === 0) {
- return 'Dev';
- }
- if (path.indexOf('/admin/') === 0) {
- return 'Admin';
- }
- return 'Other';
+ return '其他';
}
function syncAPICounts() {
@@ -4126,7 +4790,7 @@ const devWorkbenchHTML = `
$('api-total-count').textContent = '(' + total + ')';
$('api-filter-meta').textContent = '当前 ' + visibleItems.length + ' / 总计 ' + total + ' 个接口,支持按关键词筛选。';
- const order = ['Health', 'Auth', 'Entry/Home', 'Event', 'Session', 'Result', 'Profile', 'Dev', 'Admin'];
+ const order = ['基础', '玩家链', '游客链', '调试链', '运维链', '其他'];
const counts = {};
order.forEach(function(key) { counts[key] = 0; });
items.forEach(function(node) {
@@ -4494,13 +5158,39 @@ const devWorkbenchHTML = `
request('GET', '/me/entry-home?channelCode=' + encodeURIComponent($('entry-channel-code').value) + '&channelType=' + encodeURIComponent($('entry-channel-type').value), undefined, true)
);
- $('btn-event-detail').onclick = () => run('event-detail', () =>
- request('GET', '/events/' + encodeURIComponent($('event-id').value))
- );
+ $('btn-event-detail').onclick = () => run('event-detail', async () => {
+ const result = await request('GET', '/events/' + encodeURIComponent($('event-id').value));
+ if (result && result.data) {
+ setCurrentPreviewStatusFromPayload(result.data);
+ const configSummary = await resolveEventManifestSummary(result.data);
+ setLaunchConfigSummary(configSummary);
+ if (configSummary.gameMode) {
+ currentFlowStatus.gameMode = configSummary.gameMode;
+ }
+ if (configSummary.playfieldKind) {
+ currentFlowStatus.playfieldKind = configSummary.playfieldKind;
+ }
+ renderCurrentFlowStatus();
+ }
+ return result;
+ });
$('btn-event-play').onclick = () => run('event-play', async () => {
const result = await request('GET', '/events/' + encodeURIComponent($('event-id').value) + '/play', undefined, true);
setCurrentFlowStatusFromPlayResponse(result);
+ if (result && result.data) {
+ setCurrentPreviewStatusFromPayload(result.data);
+ const configSummary = await resolveEventManifestSummary(result.data);
+ setLaunchConfigSummary(configSummary);
+ if (configSummary.gameMode) {
+ currentFlowStatus.gameMode = configSummary.gameMode;
+ }
+ if (configSummary.playfieldKind) {
+ currentFlowStatus.playfieldKind = configSummary.playfieldKind;
+ }
+ renderCurrentFlowStatus();
+ writeLog('event-play.summary', configSummary);
+ }
return result;
});
@@ -4638,6 +5328,33 @@ const devWorkbenchHTML = `
return result;
});
+ const btnProdTileImport = $('btn-prod-tile-import');
+ if (btnProdTileImport) {
+ btnProdTileImport.onclick = () => run('admin/ops/tile-releases/import', async () => {
+ const result = await request('POST', '/admin/ops/tile-releases/import', {
+ placeCode: $('prod-place-code').value,
+ placeName: $('prod-place-name').value,
+ placeRegion: trimmedOrUndefined($('prod-place-region').value),
+ placeCoverUrl: trimmedOrUndefined($('prod-place-cover-url').value),
+ mapAssetCode: $('prod-map-asset-code').value,
+ mapAssetName: $('prod-map-asset-name').value,
+ mapType: $('prod-map-asset-type').value,
+ versionCode: $('prod-tile-version-code').value,
+ status: $('prod-tile-status').value,
+ tileBaseUrl: $('prod-tile-base-url').value,
+ metaUrl: $('prod-tile-meta-url').value,
+ setAsCurrent: true
+ }, true);
+ if (result.data) {
+ $('prod-place-id').value = result.data.place && result.data.place.id ? result.data.place.id : $('prod-place-id').value;
+ $('prod-map-asset-id').value = result.data.mapAsset && result.data.mapAsset.id ? result.data.mapAsset.id : $('prod-map-asset-id').value;
+ $('prod-tile-release-id').value = result.data.tileRelease && result.data.tileRelease.id ? result.data.tileRelease.id : $('prod-tile-release-id').value;
+ }
+ persistState();
+ return result;
+ });
+ }
+
$('btn-prod-course-sources-list').onclick = () => run('admin/course-sources/list', async () => {
const result = await request('GET', '/admin/course-sources?limit=20', undefined, true);
const first = result.data && result.data[0];
@@ -4712,6 +5429,38 @@ const devWorkbenchHTML = `
return result;
});
+ const btnProdCourseBatchImport = $('btn-prod-course-batch-import');
+ if (btnProdCourseBatchImport) {
+ btnProdCourseBatchImport.onclick = () => run('admin/ops/course-sets/import-kml-batch', async () => {
+ const routes = parseJSONArray($('prod-course-routes-json').value, 'KML Batch JSON');
+ const result = await request('POST', '/admin/ops/course-sets/import-kml-batch', {
+ placeCode: $('prod-place-code').value,
+ placeName: $('prod-place-name').value,
+ mapAssetCode: $('prod-map-asset-code').value,
+ mapAssetName: $('prod-map-asset-name').value,
+ mapType: $('prod-map-asset-type').value,
+ courseSetCode: $('prod-course-set-code').value,
+ courseSetName: $('prod-course-set-name').value,
+ mode: $('prod-course-mode').value,
+ status: $('prod-course-set-status').value,
+ defaultRouteCode: trimmedOrUndefined($('prod-course-default-route-code').value),
+ routes: routes
+ }, true);
+ if (result.data) {
+ $('prod-place-id').value = result.data.place && result.data.place.id ? result.data.place.id : $('prod-place-id').value;
+ $('prod-map-asset-id').value = result.data.mapAsset && result.data.mapAsset.id ? result.data.mapAsset.id : $('prod-map-asset-id').value;
+ $('prod-course-set-id').value = result.data.courseSet && result.data.courseSet.id ? result.data.courseSet.id : $('prod-course-set-id').value;
+ if (result.data.courseSet && result.data.courseSet.currentVariant) {
+ $('prod-course-variant-id').value = result.data.courseSet.currentVariant.id || $('prod-course-variant-id').value;
+ } else if (result.data.variants && result.data.variants[0]) {
+ $('prod-course-variant-id').value = result.data.variants[0].id || $('prod-course-variant-id').value;
+ }
+ }
+ persistState();
+ return result;
+ });
+ }
+
$('btn-prod-runtime-bindings-list').onclick = () => run('admin/runtime-bindings/list', async () => {
const result = await request('GET', '/admin/runtime-bindings?limit=20', undefined, true);
const first = result.data && result.data[0];
@@ -5338,7 +6087,7 @@ const devWorkbenchHTML = `
applyFrontendDemoSelection({
eventId: result.data.variantManualEventId || 'evt_demo_variant_manual_001',
releaseId: result.data.variantManualReleaseId || 'rel_demo_variant_manual_001',
- variantId: 'variant_b',
+ variantId: 'variant_d',
localConfigFile: 'classic-sequential.json',
gameModeCode: 'classic-sequential',
demoKind: 'manual-variant',
@@ -5432,7 +6181,8 @@ const devWorkbenchHTML = `
'prod-course-source-legacy-playfield-id', 'prod-course-source-legacy-version-id',
'prod-course-source-type', 'prod-course-source-file-url', 'prod-course-source-status',
'prod-course-set-code', 'prod-course-set-name', 'prod-course-set-id', 'prod-course-mode',
- 'prod-course-set-status', 'prod-course-variant-id', 'prod-course-variant-name',
+ 'prod-course-set-status', 'prod-course-default-route-code', 'prod-course-routes-json',
+ 'prod-course-variant-id', 'prod-course-variant-name',
'prod-course-variant-route-code', 'prod-course-variant-status', 'prod-course-variant-control-count',
'prod-runtime-binding-id', 'prod-runtime-event-id', 'prod-runtime-binding-status', 'prod-runtime-notes',
'admin-map-code', 'admin-map-name', 'admin-map-id', 'admin-map-version-id',
diff --git a/backend/internal/httpapi/handlers/map_experience_handler.go b/backend/internal/httpapi/handlers/map_experience_handler.go
new file mode 100644
index 0000000..fc292d4
--- /dev/null
+++ b/backend/internal/httpapi/handlers/map_experience_handler.go
@@ -0,0 +1,41 @@
+package handlers
+
+import (
+ "net/http"
+ "strconv"
+
+ "cmr-backend/internal/httpx"
+ "cmr-backend/internal/service"
+)
+
+type MapExperienceHandler struct {
+ service *service.MapExperienceService
+}
+
+func NewMapExperienceHandler(service *service.MapExperienceService) *MapExperienceHandler {
+ return &MapExperienceHandler{service: service}
+}
+
+func (h *MapExperienceHandler) ListMaps(w http.ResponseWriter, r *http.Request) {
+ limit := 20
+ if raw := r.URL.Query().Get("limit"); raw != "" {
+ if parsed, err := strconv.Atoi(raw); err == nil {
+ limit = parsed
+ }
+ }
+ result, err := h.service.ListMaps(r.Context(), service.ListExperienceMapsInput{Limit: limit})
+ if err != nil {
+ httpx.WriteError(w, err)
+ return
+ }
+ httpx.WriteJSON(w, http.StatusOK, map[string]any{"data": result})
+}
+
+func (h *MapExperienceHandler) GetMapDetail(w http.ResponseWriter, r *http.Request) {
+ result, err := h.service.GetMapDetail(r.Context(), r.PathValue("mapAssetPublicID"))
+ if err != nil {
+ httpx.WriteError(w, err)
+ return
+ }
+ httpx.WriteJSON(w, http.StatusOK, map[string]any{"data": result})
+}
diff --git a/backend/internal/httpapi/handlers/ops_auth_handler.go b/backend/internal/httpapi/handlers/ops_auth_handler.go
new file mode 100644
index 0000000..a7c6f25
--- /dev/null
+++ b/backend/internal/httpapi/handlers/ops_auth_handler.go
@@ -0,0 +1,101 @@
+package handlers
+
+import (
+ "net/http"
+
+ "cmr-backend/internal/apperr"
+ "cmr-backend/internal/httpapi/middleware"
+ "cmr-backend/internal/httpx"
+ "cmr-backend/internal/service"
+)
+
+type OpsAuthHandler struct {
+ service *service.OpsAuthService
+}
+
+func NewOpsAuthHandler(service *service.OpsAuthService) *OpsAuthHandler {
+ return &OpsAuthHandler{service: service}
+}
+
+func (h *OpsAuthHandler) SendSMSCode(w http.ResponseWriter, r *http.Request) {
+ var req service.OpsSendSMSCodeInput
+ if err := httpx.DecodeJSON(r, &req); err != nil {
+ httpx.WriteError(w, apperr.New(http.StatusBadRequest, "invalid_json", "invalid request body"))
+ return
+ }
+ result, err := h.service.SendSMSCode(r.Context(), req)
+ if err != nil {
+ httpx.WriteError(w, err)
+ return
+ }
+ httpx.WriteJSON(w, http.StatusOK, map[string]any{"data": result})
+}
+
+func (h *OpsAuthHandler) Register(w http.ResponseWriter, r *http.Request) {
+ var req service.OpsRegisterInput
+ if err := httpx.DecodeJSON(r, &req); err != nil {
+ httpx.WriteError(w, apperr.New(http.StatusBadRequest, "invalid_json", "invalid request body"))
+ return
+ }
+ result, err := h.service.Register(r.Context(), req)
+ if err != nil {
+ httpx.WriteError(w, err)
+ return
+ }
+ httpx.WriteJSON(w, http.StatusOK, map[string]any{"data": result})
+}
+
+func (h *OpsAuthHandler) LoginSMS(w http.ResponseWriter, r *http.Request) {
+ var req service.OpsLoginSMSInput
+ if err := httpx.DecodeJSON(r, &req); err != nil {
+ httpx.WriteError(w, apperr.New(http.StatusBadRequest, "invalid_json", "invalid request body"))
+ return
+ }
+ result, err := h.service.LoginSMS(r.Context(), req)
+ if err != nil {
+ httpx.WriteError(w, err)
+ return
+ }
+ httpx.WriteJSON(w, http.StatusOK, map[string]any{"data": result})
+}
+
+func (h *OpsAuthHandler) Refresh(w http.ResponseWriter, r *http.Request) {
+ var req service.OpsRefreshTokenInput
+ if err := httpx.DecodeJSON(r, &req); err != nil {
+ httpx.WriteError(w, apperr.New(http.StatusBadRequest, "invalid_json", "invalid request body"))
+ return
+ }
+ result, err := h.service.Refresh(r.Context(), req)
+ if err != nil {
+ httpx.WriteError(w, err)
+ return
+ }
+ httpx.WriteJSON(w, http.StatusOK, map[string]any{"data": result})
+}
+
+func (h *OpsAuthHandler) Logout(w http.ResponseWriter, r *http.Request) {
+ var req service.OpsLogoutInput
+ if err := httpx.DecodeJSON(r, &req); err != nil {
+ httpx.WriteError(w, apperr.New(http.StatusBadRequest, "invalid_json", "invalid request body"))
+ return
+ }
+ if err := h.service.Logout(r.Context(), req); err != nil {
+ httpx.WriteError(w, err)
+ return
+ }
+ httpx.WriteJSON(w, http.StatusOK, map[string]any{"data": map[string]any{"loggedOut": true}})
+}
+
+func (h *OpsAuthHandler) Me(w http.ResponseWriter, r *http.Request) {
+ auth := middleware.GetOpsAuthContext(r.Context())
+ if auth == nil {
+ httpx.WriteError(w, apperr.New(http.StatusUnauthorized, "unauthorized", "missing ops auth context"))
+ return
+ }
+ result, err := h.service.GetMe(r.Context(), auth.OpsUserID)
+ if err != nil {
+ httpx.WriteError(w, err)
+ return
+ }
+ httpx.WriteJSON(w, http.StatusOK, map[string]any{"data": result})
+}
diff --git a/backend/internal/httpapi/handlers/ops_summary_handler.go b/backend/internal/httpapi/handlers/ops_summary_handler.go
new file mode 100644
index 0000000..51ff061
--- /dev/null
+++ b/backend/internal/httpapi/handlers/ops_summary_handler.go
@@ -0,0 +1,25 @@
+package handlers
+
+import (
+ "net/http"
+
+ "cmr-backend/internal/httpx"
+ "cmr-backend/internal/service"
+)
+
+type OpsSummaryHandler struct {
+ service *service.OpsSummaryService
+}
+
+func NewOpsSummaryHandler(service *service.OpsSummaryService) *OpsSummaryHandler {
+ return &OpsSummaryHandler{service: service}
+}
+
+func (h *OpsSummaryHandler) GetOverview(w http.ResponseWriter, r *http.Request) {
+ result, err := h.service.GetOverview(r.Context())
+ if err != nil {
+ httpx.WriteError(w, err)
+ return
+ }
+ httpx.WriteJSON(w, http.StatusOK, map[string]any{"data": result})
+}
diff --git a/backend/internal/httpapi/handlers/ops_workbench_handler.go b/backend/internal/httpapi/handlers/ops_workbench_handler.go
new file mode 100644
index 0000000..948a849
--- /dev/null
+++ b/backend/internal/httpapi/handlers/ops_workbench_handler.go
@@ -0,0 +1,1681 @@
+package handlers
+
+import "net/http"
+
+type OpsWorkbenchHandler struct{}
+
+func NewOpsWorkbenchHandler() *OpsWorkbenchHandler {
+ return &OpsWorkbenchHandler{}
+}
+
+func (h *OpsWorkbenchHandler) Get(w http.ResponseWriter, r *http.Request) {
+ w.Header().Set("Content-Type", "text/html; charset=utf-8")
+ _, _ = w.Write([]byte(opsWorkbenchHTML))
+}
+
+const opsWorkbenchHTML = `
+
+
+
+
+
CMR 运维后台
+
+
+
+
+
+
+
+
+ 先录资源,再绑活动,最后发布
+ 运维平台第一版的目标很单一:把正式资源稳定录入系统,沉淀成资源对象,再通过统一发布链形成可追溯的 release。这里不混调试按钮,也不直接暴露玩家链路。
+
+
资源录入文件上传和外链登记统一收口,不再依赖手工改代码或散脚本。
+
活动绑定活动侧只绑定默认 runtime、presentation、content bundle 三元组。
+
发布中心继续走统一 build / publish / rollback,不开第二套发布逻辑。
+
+
+
+
+ 资源总览
+ 先看关键统计,再看当前运行时信息
+ 资源总览先回答两个问题:系统里现在有多少正式对象;你当前选中的活动此刻绑定了哪套 release / runtime / 展示定义 / 内容包。
+
+
+
+
资源与路线统计
+
+
0地点
+
0地图
+
0瓦片版本
+
0受管资源
+
0路线组
+
0路线变体
+
0运行绑定
+
0配置源
+
+
+
+
活动与发布统计
+
+
0活动数
+
0默认体验活动
+
0已发布活动
+
0发布版本
+
0展示定义
+
0内容包
+
0运维账号
+
+
+
+
+
+
+
操作提示
+
1. 先看资源总览确认地点、地图、路线组、活动、已发布数量是否符合预期。
+
2. 再选主流程地图 / 地点管理 -> 路线资源管理 -> 活动管理 / 活动编排 -> 发布中心。
+
3. 关联活动只看摘要地图页只看数量和跳转,活动详情统一放到“活动管理”。
+
+
+
+
+
+
+
+
+
+
+ 开发环境默认免登录放行,生产环境请使用手机号验证码注册/登录的独立运维账号。运维账号和前端玩家账号完全分离。
+
+
+
+ 地图 / 地点管理
+ 先进入地图列表,再做新增、编辑和预览
+ 地点是地图的归属容器,不是主入口。一个地点可挂多张地图,一张地图只属于一个地点。关联活动在这里先只看数量和摘要,详情统一去“活动管理”。
+
+
+
地图列表
+
默认只看地图列表。点一张地图,再进入地图详情;新增地图和新增地点都走独立弹出层。
+
暂无地图打开页面后会自动拉取地图列表,也可以手动点“刷新地图区”。
+
+
+
+
+
+
+
地图详情
+
当前地图详情 / 预览
+
+
+
+
+
+
+
+
+
+
+
+
+
添加 / 编辑地图
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
添加 / 编辑地点
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ 资源录入
+ 统一纳管资源
+ 运维只需要关心“录一个资源”,不需要关心它最终是 OSS 上传还是已有外链。录完之后都落成统一资源对象。
+
+
+
上传文件
+
适合 KML、schema、manifest、本地静态包。backend 负责上传 OSS 并纳管。
+
+
+
+
+
+
+
登记外链
+
适合正式 OSS / CDN 上已经存在的资源。登记后,活动侧和发布链统一只认受管资源对象。
+
+
+
+
+
+
建议优先纳管:地图瓦片目录、KML、presentation schema、content bundle manifest。
+
+
+
+
+
+ KML / 赛道管理
+ 围绕当前地图管理 KML、赛道集和默认路线
+ KML 不是独立漂在外面的资源。运维上应该先选地图,再导入一批 KML,形成赛道集,然后查看默认路线和预览数据。
+
+
+
+
+
批量导入 KML
+
+
+
+
+
+
+
+
+
+
+
当前地图下赛道集
+
暂无赛道集读取地图详情或完成一轮 KML 导入后,这里会显示当前地图的赛道集。
+
+
+
+
+
+
+ 活动管理
+ 先看活动列表和基础信息
+ 活动管理先处理业务壳:名称、状态、是否默认体验、是否出现在活动列表,以及当前发布概况。资源绑定与发布准备统一去“活动编排”。
+
+
+
+
+
+
+
+
+
+
+
+
+ 活动编排
+ 绑定运行对象、展示定义和内容包
+ 这里才进入发布前准备:给当前活动绑定 runtime、presentation、content bundle,确认默认 active 三元组,然后交给发布中心 build / publish。
+
+
+
+
+
+
+
+
+
+
+ 发布中心
+ 统一 build / publish / rollback
+ 运维后台不造第二条发布链,仍然复用现在这套 source、build、release 流程。
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+`
diff --git a/backend/internal/httpapi/handlers/public_experience_handler.go b/backend/internal/httpapi/handlers/public_experience_handler.go
new file mode 100644
index 0000000..b7dc6df
--- /dev/null
+++ b/backend/internal/httpapi/handlers/public_experience_handler.go
@@ -0,0 +1,78 @@
+package handlers
+
+import (
+ "net/http"
+ "strconv"
+
+ "cmr-backend/internal/apperr"
+ "cmr-backend/internal/httpx"
+ "cmr-backend/internal/service"
+)
+
+type PublicExperienceHandler struct {
+ service *service.PublicExperienceService
+}
+
+func NewPublicExperienceHandler(service *service.PublicExperienceService) *PublicExperienceHandler {
+ return &PublicExperienceHandler{service: service}
+}
+
+func (h *PublicExperienceHandler) ListMaps(w http.ResponseWriter, r *http.Request) {
+ limit := 20
+ if raw := r.URL.Query().Get("limit"); raw != "" {
+ if parsed, err := strconv.Atoi(raw); err == nil {
+ limit = parsed
+ }
+ }
+ result, err := h.service.ListMaps(r.Context(), service.ListExperienceMapsInput{Limit: limit})
+ if err != nil {
+ httpx.WriteError(w, err)
+ return
+ }
+ httpx.WriteJSON(w, http.StatusOK, map[string]any{"data": result})
+}
+
+func (h *PublicExperienceHandler) GetMapDetail(w http.ResponseWriter, r *http.Request) {
+ result, err := h.service.GetMapDetail(r.Context(), r.PathValue("mapAssetPublicID"))
+ if err != nil {
+ httpx.WriteError(w, err)
+ return
+ }
+ httpx.WriteJSON(w, http.StatusOK, map[string]any{"data": result})
+}
+
+func (h *PublicExperienceHandler) GetEventDetail(w http.ResponseWriter, r *http.Request) {
+ result, err := h.service.GetEventDetail(r.Context(), r.PathValue("eventPublicID"))
+ if err != nil {
+ httpx.WriteError(w, err)
+ return
+ }
+ httpx.WriteJSON(w, http.StatusOK, map[string]any{"data": result})
+}
+
+func (h *PublicExperienceHandler) GetEventPlay(w http.ResponseWriter, r *http.Request) {
+ result, err := h.service.GetEventPlay(r.Context(), service.PublicEventPlayInput{
+ EventPublicID: r.PathValue("eventPublicID"),
+ })
+ if err != nil {
+ httpx.WriteError(w, err)
+ return
+ }
+ httpx.WriteJSON(w, http.StatusOK, map[string]any{"data": result})
+}
+
+func (h *PublicExperienceHandler) Launch(w http.ResponseWriter, r *http.Request) {
+ var req service.PublicLaunchEventInput
+ if err := httpx.DecodeJSON(r, &req); err != nil {
+ httpx.WriteError(w, apperr.New(http.StatusBadRequest, "invalid_json", "invalid request body"))
+ return
+ }
+ req.EventPublicID = r.PathValue("eventPublicID")
+
+ result, err := h.service.LaunchEvent(r.Context(), req)
+ if err != nil {
+ httpx.WriteError(w, err)
+ return
+ }
+ httpx.WriteJSON(w, http.StatusOK, map[string]any{"data": result})
+}
diff --git a/backend/internal/httpapi/handlers/region_options_handler.go b/backend/internal/httpapi/handlers/region_options_handler.go
new file mode 100644
index 0000000..906dd7a
--- /dev/null
+++ b/backend/internal/httpapi/handlers/region_options_handler.go
@@ -0,0 +1,162 @@
+package handlers
+
+import (
+ "context"
+ "encoding/json"
+ "fmt"
+ "net/http"
+ "sort"
+ "sync"
+ "time"
+
+ "cmr-backend/internal/apperr"
+ "cmr-backend/internal/httpx"
+)
+
+type RegionOptionsHandler struct {
+ client *http.Client
+ mu sync.Mutex
+ cache []regionProvince
+}
+
+type regionProvince struct {
+ Code string `json:"code"`
+ Name string `json:"name"`
+ Cities []regionCity `json:"cities"`
+}
+
+type regionCity struct {
+ Code string `json:"code"`
+ Name string `json:"name"`
+}
+
+type remoteProvince struct {
+ Code string `json:"code"`
+ Name string `json:"name"`
+}
+
+type remoteCity struct {
+ Code string `json:"code"`
+ Name string `json:"name"`
+ Province string `json:"province"`
+}
+
+func NewRegionOptionsHandler() *RegionOptionsHandler {
+ return &RegionOptionsHandler{
+ client: &http.Client{Timeout: 12 * time.Second},
+ }
+}
+
+func (h *RegionOptionsHandler) Get(w http.ResponseWriter, r *http.Request) {
+ items, err := h.load(r.Context())
+ if err != nil {
+ httpx.WriteError(w, err)
+ return
+ }
+ httpx.WriteJSON(w, http.StatusOK, map[string]any{"data": items})
+}
+
+func (h *RegionOptionsHandler) load(ctx context.Context) ([]regionProvince, error) {
+ h.mu.Lock()
+ if len(h.cache) > 0 {
+ cached := h.cache
+ h.mu.Unlock()
+ return cached, nil
+ }
+ h.mu.Unlock()
+
+ // Data source:
+ // https://github.com/uiwjs/province-city-china
+ // Using province + city JSON only, then reducing to the province/city structure
+ // needed by ops workbench location management.
+ provinces, err := h.fetchProvinces(ctx, "https://unpkg.com/province-city-china/dist/province.json")
+ if err != nil {
+ return nil, err
+ }
+ cities, err := h.fetchCities(ctx, "https://unpkg.com/province-city-china/dist/city.json")
+ if err != nil {
+ return nil, err
+ }
+
+ cityMap := make(map[string][]regionCity)
+ for _, item := range cities {
+ if item.Province == "" || item.Code == "" {
+ continue
+ }
+ fullCode := item.Province + item.Code + "00"
+ cityMap[item.Province] = append(cityMap[item.Province], regionCity{
+ Code: fullCode,
+ Name: item.Name,
+ })
+ }
+ for key := range cityMap {
+ sort.Slice(cityMap[key], func(i, j int) bool { return cityMap[key][i].Code < cityMap[key][j].Code })
+ }
+
+ items := make([]regionProvince, 0, len(provinces))
+ for _, item := range provinces {
+ if len(item.Code) < 2 {
+ continue
+ }
+ provinceCode := item.Code[:2]
+ province := regionProvince{
+ Code: item.Code,
+ Name: item.Name,
+ }
+ if entries := cityMap[provinceCode]; len(entries) > 0 {
+ province.Cities = entries
+ } else {
+ // 直辖市 / 特殊地区没有单独的地级市列表时,退化成自身即可。
+ province.Cities = []regionCity{{
+ Code: item.Code,
+ Name: item.Name,
+ }}
+ }
+ items = append(items, province)
+ }
+
+ h.mu.Lock()
+ h.cache = items
+ h.mu.Unlock()
+ return items, nil
+}
+
+func (h *RegionOptionsHandler) fetchProvinces(ctx context.Context, url string) ([]remoteProvince, error) {
+ req, err := http.NewRequestWithContext(ctx, http.MethodGet, url, nil)
+ if err != nil {
+ return nil, apperr.New(http.StatusBadGateway, "region_source_unavailable", "省市数据源不可用")
+ }
+ resp, err := h.client.Do(req)
+ if err != nil {
+ return nil, apperr.New(http.StatusBadGateway, "region_source_unavailable", "省市数据源不可用")
+ }
+ defer resp.Body.Close()
+ if resp.StatusCode != http.StatusOK {
+ return nil, apperr.New(http.StatusBadGateway, "region_source_unavailable", fmt.Sprintf("省级数据拉取失败: %d", resp.StatusCode))
+ }
+ var items []remoteProvince
+ if err := json.NewDecoder(resp.Body).Decode(&items); err != nil {
+ return nil, apperr.New(http.StatusBadGateway, "region_source_invalid", "省级数据格式无效")
+ }
+ return items, nil
+}
+
+func (h *RegionOptionsHandler) fetchCities(ctx context.Context, url string) ([]remoteCity, error) {
+ req, err := http.NewRequestWithContext(ctx, http.MethodGet, url, nil)
+ if err != nil {
+ return nil, apperr.New(http.StatusBadGateway, "region_source_unavailable", "省市数据源不可用")
+ }
+ resp, err := h.client.Do(req)
+ if err != nil {
+ return nil, apperr.New(http.StatusBadGateway, "region_source_unavailable", "省市数据源不可用")
+ }
+ defer resp.Body.Close()
+ if resp.StatusCode != http.StatusOK {
+ return nil, apperr.New(http.StatusBadGateway, "region_source_unavailable", fmt.Sprintf("市级数据拉取失败: %d", resp.StatusCode))
+ }
+ var items []remoteCity
+ if err := json.NewDecoder(resp.Body).Decode(&items); err != nil {
+ return nil, apperr.New(http.StatusBadGateway, "region_source_invalid", "市级数据格式无效")
+ }
+ return items, nil
+}
diff --git a/backend/internal/httpapi/middleware/auth.go b/backend/internal/httpapi/middleware/auth.go
index 279d1af..25f9e32 100644
--- a/backend/internal/httpapi/middleware/auth.go
+++ b/backend/internal/httpapi/middleware/auth.go
@@ -17,6 +17,7 @@ const authKey authContextKey = "auth"
type AuthContext struct {
UserID string
UserPublicID string
+ RoleCode string
}
func NewAuthMiddleware(jwtManager *jwtx.Manager) func(http.Handler) http.Handler {
@@ -34,10 +35,15 @@ func NewAuthMiddleware(jwtManager *jwtx.Manager) func(http.Handler) http.Handler
httpx.WriteError(w, apperr.New(http.StatusUnauthorized, "invalid_token", "invalid access token"))
return
}
+ if claims.ActorType != "" && claims.ActorType != "user" {
+ httpx.WriteError(w, apperr.New(http.StatusUnauthorized, "invalid_token", "invalid access token"))
+ return
+ }
ctx := context.WithValue(r.Context(), authKey, &AuthContext{
UserID: claims.UserID,
UserPublicID: claims.UserPublicID,
+ RoleCode: claims.RoleCode,
})
next.ServeHTTP(w, r.WithContext(ctx))
})
diff --git a/backend/internal/httpapi/middleware/ops_auth.go b/backend/internal/httpapi/middleware/ops_auth.go
new file mode 100644
index 0000000..4747ac1
--- /dev/null
+++ b/backend/internal/httpapi/middleware/ops_auth.go
@@ -0,0 +1,77 @@
+package middleware
+
+import (
+ "context"
+ "net/http"
+ "strings"
+
+ "cmr-backend/internal/apperr"
+ "cmr-backend/internal/httpx"
+ "cmr-backend/internal/platform/jwtx"
+)
+
+type opsAuthContextKey string
+
+const opsAuthKey opsAuthContextKey = "ops-auth"
+
+type OpsAuthContext struct {
+ OpsUserID string
+ OpsUserPublicID string
+ RoleCode string
+}
+
+func NewOpsAuthMiddleware(jwtManager *jwtx.Manager, appEnv string) func(http.Handler) http.Handler {
+ devContext := func(r *http.Request) *http.Request {
+ ctx := context.WithValue(r.Context(), opsAuthKey, &OpsAuthContext{
+ OpsUserID: "dev-ops-user",
+ OpsUserPublicID: "ops_dev_console",
+ RoleCode: "owner",
+ })
+ return r.WithContext(ctx)
+ }
+
+ return func(next http.Handler) http.Handler {
+ return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+ authHeader := strings.TrimSpace(r.Header.Get("Authorization"))
+ if authHeader == "" || !strings.HasPrefix(authHeader, "Bearer ") {
+ if appEnv != "production" {
+ next.ServeHTTP(w, devContext(r))
+ return
+ }
+ httpx.WriteError(w, apperr.New(http.StatusUnauthorized, "unauthorized", "missing bearer token"))
+ return
+ }
+
+ token := strings.TrimSpace(strings.TrimPrefix(authHeader, "Bearer "))
+ claims, err := jwtManager.ParseAccessToken(token)
+ if err != nil {
+ if appEnv != "production" {
+ next.ServeHTTP(w, devContext(r))
+ return
+ }
+ httpx.WriteError(w, apperr.New(http.StatusUnauthorized, "invalid_token", "invalid access token"))
+ return
+ }
+ if claims.ActorType != "ops" {
+ if appEnv != "production" {
+ next.ServeHTTP(w, devContext(r))
+ return
+ }
+ httpx.WriteError(w, apperr.New(http.StatusUnauthorized, "invalid_token", "invalid ops access token"))
+ return
+ }
+
+ ctx := context.WithValue(r.Context(), opsAuthKey, &OpsAuthContext{
+ OpsUserID: claims.UserID,
+ OpsUserPublicID: claims.UserPublicID,
+ RoleCode: claims.RoleCode,
+ })
+ next.ServeHTTP(w, r.WithContext(ctx))
+ })
+ }
+}
+
+func GetOpsAuthContext(ctx context.Context) *OpsAuthContext {
+ auth, _ := ctx.Value(opsAuthKey).(*OpsAuthContext)
+ return auth
+}
diff --git a/backend/internal/httpapi/router.go b/backend/internal/httpapi/router.go
index d083dbe..e7eb19d 100644
--- a/backend/internal/httpapi/router.go
+++ b/backend/internal/httpapi/router.go
@@ -13,16 +13,21 @@ func NewRouter(
appEnv string,
jwtManager *jwtx.Manager,
authService *service.AuthService,
+ opsAuthService *service.OpsAuthService,
+ opsSummaryService *service.OpsSummaryService,
entryService *service.EntryService,
entryHomeService *service.EntryHomeService,
+ adminAssetService *service.AdminAssetService,
adminResourceService *service.AdminResourceService,
adminProductionService *service.AdminProductionService,
adminEventService *service.AdminEventService,
adminPipelineService *service.AdminPipelineService,
eventService *service.EventService,
eventPlayService *service.EventPlayService,
+ publicExperienceService *service.PublicExperienceService,
configService *service.ConfigService,
homeService *service.HomeService,
+ mapExperienceService *service.MapExperienceService,
profileService *service.ProfileService,
resultService *service.ResultService,
sessionService *service.SessionService,
@@ -33,27 +38,43 @@ func NewRouter(
healthHandler := handlers.NewHealthHandler()
authHandler := handlers.NewAuthHandler(authService)
+ opsAuthHandler := handlers.NewOpsAuthHandler(opsAuthService)
+ opsSummaryHandler := handlers.NewOpsSummaryHandler(opsSummaryService)
+ regionOptionsHandler := handlers.NewRegionOptionsHandler()
entryHandler := handlers.NewEntryHandler(entryService)
entryHomeHandler := handlers.NewEntryHomeHandler(entryHomeService)
+ adminAssetHandler := handlers.NewAdminAssetHandler(adminAssetService)
adminResourceHandler := handlers.NewAdminResourceHandler(adminResourceService)
adminProductionHandler := handlers.NewAdminProductionHandler(adminProductionService)
adminEventHandler := handlers.NewAdminEventHandler(adminEventService)
adminPipelineHandler := handlers.NewAdminPipelineHandler(adminPipelineService)
eventHandler := handlers.NewEventHandler(eventService)
eventPlayHandler := handlers.NewEventPlayHandler(eventPlayService)
+ publicExperienceHandler := handlers.NewPublicExperienceHandler(publicExperienceService)
configHandler := handlers.NewConfigHandler(configService)
homeHandler := handlers.NewHomeHandler(homeService)
+ mapExperienceHandler := handlers.NewMapExperienceHandler(mapExperienceService)
profileHandler := handlers.NewProfileHandler(profileService)
resultHandler := handlers.NewResultHandler(resultService)
sessionHandler := handlers.NewSessionHandler(sessionService)
devHandler := handlers.NewDevHandler(devService)
+ opsWorkbenchHandler := handlers.NewOpsWorkbenchHandler()
meHandler := handlers.NewMeHandler(meService)
authMiddleware := middleware.NewAuthMiddleware(jwtManager)
+ opsAuthMiddleware := middleware.NewOpsAuthMiddleware(jwtManager, appEnv)
mux.HandleFunc("GET /healthz", healthHandler.Get)
mux.HandleFunc("GET /home", homeHandler.GetHome)
mux.HandleFunc("GET /cards", homeHandler.GetCards)
+ mux.HandleFunc("GET /experience-maps", mapExperienceHandler.ListMaps)
+ mux.HandleFunc("GET /experience-maps/{mapAssetPublicID}", mapExperienceHandler.GetMapDetail)
+ mux.HandleFunc("GET /public/experience-maps", publicExperienceHandler.ListMaps)
+ mux.HandleFunc("GET /public/experience-maps/{mapAssetPublicID}", publicExperienceHandler.GetMapDetail)
mux.HandleFunc("GET /entry/resolve", entryHandler.Resolve)
+ mux.Handle("GET /admin/assets", authMiddleware(http.HandlerFunc(adminAssetHandler.ListAssets)))
+ mux.Handle("POST /admin/assets/register-link", authMiddleware(http.HandlerFunc(adminAssetHandler.RegisterLink)))
+ mux.Handle("POST /admin/assets/upload", authMiddleware(http.HandlerFunc(adminAssetHandler.UploadFile)))
+ mux.Handle("GET /admin/assets/{assetPublicID}", authMiddleware(http.HandlerFunc(adminAssetHandler.GetAsset)))
mux.Handle("GET /admin/maps", authMiddleware(http.HandlerFunc(adminResourceHandler.ListMaps)))
mux.Handle("POST /admin/maps", authMiddleware(http.HandlerFunc(adminResourceHandler.CreateMap)))
mux.Handle("GET /admin/maps/{mapPublicID}", authMiddleware(http.HandlerFunc(adminResourceHandler.GetMap)))
@@ -61,8 +82,10 @@ func NewRouter(
mux.Handle("GET /admin/places", authMiddleware(http.HandlerFunc(adminProductionHandler.ListPlaces)))
mux.Handle("POST /admin/places", authMiddleware(http.HandlerFunc(adminProductionHandler.CreatePlace)))
mux.Handle("GET /admin/places/{placePublicID}", authMiddleware(http.HandlerFunc(adminProductionHandler.GetPlace)))
+ mux.Handle("GET /admin/map-assets", authMiddleware(http.HandlerFunc(adminProductionHandler.ListMapAssets)))
mux.Handle("POST /admin/places/{placePublicID}/map-assets", authMiddleware(http.HandlerFunc(adminProductionHandler.CreateMapAsset)))
mux.Handle("GET /admin/map-assets/{mapAssetPublicID}", authMiddleware(http.HandlerFunc(adminProductionHandler.GetMapAsset)))
+ mux.Handle("PUT /admin/map-assets/{mapAssetPublicID}", authMiddleware(http.HandlerFunc(adminProductionHandler.UpdateMapAsset)))
mux.Handle("POST /admin/map-assets/{mapAssetPublicID}/tile-releases", authMiddleware(http.HandlerFunc(adminProductionHandler.CreateTileRelease)))
mux.Handle("POST /admin/map-assets/{mapAssetPublicID}/course-sets", authMiddleware(http.HandlerFunc(adminProductionHandler.CreateCourseSet)))
mux.Handle("GET /admin/course-sources", authMiddleware(http.HandlerFunc(adminProductionHandler.ListCourseSources)))
@@ -73,6 +96,8 @@ func NewRouter(
mux.Handle("GET /admin/runtime-bindings", authMiddleware(http.HandlerFunc(adminProductionHandler.ListRuntimeBindings)))
mux.Handle("POST /admin/runtime-bindings", authMiddleware(http.HandlerFunc(adminProductionHandler.CreateRuntimeBinding)))
mux.Handle("GET /admin/runtime-bindings/{runtimeBindingPublicID}", authMiddleware(http.HandlerFunc(adminProductionHandler.GetRuntimeBinding)))
+ mux.Handle("POST /admin/ops/tile-releases/import", authMiddleware(http.HandlerFunc(adminProductionHandler.ImportTileRelease)))
+ mux.Handle("POST /admin/ops/course-sets/import-kml-batch", authMiddleware(http.HandlerFunc(adminProductionHandler.ImportCourseSetKMLBatch)))
mux.Handle("GET /admin/playfields", authMiddleware(http.HandlerFunc(adminResourceHandler.ListPlayfields)))
mux.Handle("POST /admin/playfields", authMiddleware(http.HandlerFunc(adminResourceHandler.CreatePlayfield)))
mux.Handle("GET /admin/playfields/{playfieldPublicID}", authMiddleware(http.HandlerFunc(adminResourceHandler.GetPlayfield)))
@@ -104,11 +129,13 @@ func NewRouter(
mux.Handle("POST /admin/events/{eventPublicID}/rollback", authMiddleware(http.HandlerFunc(adminPipelineHandler.RollbackRelease)))
if appEnv != "production" {
mux.HandleFunc("GET /dev/workbench", devHandler.Workbench)
+ mux.HandleFunc("GET /admin/ops-workbench", opsWorkbenchHandler.Get)
mux.HandleFunc("POST /dev/bootstrap-demo", devHandler.BootstrapDemo)
mux.HandleFunc("POST /dev/client-logs", devHandler.CreateClientLog)
mux.HandleFunc("GET /dev/client-logs", devHandler.ListClientLogs)
mux.HandleFunc("DELETE /dev/client-logs", devHandler.ClearClientLogs)
mux.HandleFunc("GET /dev/manifest-summary", devHandler.ManifestSummary)
+ mux.HandleFunc("GET /dev/demo-assets/manifests/{demoKey}", devHandler.DemoGameManifest)
mux.HandleFunc("GET /dev/demo-assets/presentations/{demoKey}", devHandler.DemoPresentationSchema)
mux.HandleFunc("GET /dev/demo-assets/content-manifests/{demoKey}", devHandler.DemoContentManifest)
mux.HandleFunc("GET /dev/config/local-files", configHandler.ListLocalFiles)
@@ -119,6 +146,9 @@ func NewRouter(
mux.Handle("GET /me/entry-home", authMiddleware(http.HandlerFunc(entryHomeHandler.Get)))
mux.Handle("GET /me/profile", authMiddleware(http.HandlerFunc(profileHandler.Get)))
mux.HandleFunc("GET /events/{eventPublicID}", eventHandler.GetDetail)
+ mux.HandleFunc("GET /public/events/{eventPublicID}", publicExperienceHandler.GetEventDetail)
+ mux.HandleFunc("GET /public/events/{eventPublicID}/play", publicExperienceHandler.GetEventPlay)
+ mux.HandleFunc("POST /public/events/{eventPublicID}/launch", publicExperienceHandler.Launch)
mux.Handle("GET /events/{eventPublicID}/play", authMiddleware(http.HandlerFunc(eventPlayHandler.Get)))
mux.Handle("GET /events/{eventPublicID}/config-sources", authMiddleware(http.HandlerFunc(configHandler.ListSources)))
mux.Handle("POST /events/{eventPublicID}/launch", authMiddleware(http.HandlerFunc(eventHandler.Launch)))
@@ -131,12 +161,50 @@ func NewRouter(
mux.HandleFunc("POST /auth/sms/send", authHandler.SendSMSCode)
mux.HandleFunc("POST /auth/login/sms", authHandler.LoginSMS)
mux.HandleFunc("POST /auth/login/wechat-mini", authHandler.LoginWechatMini)
+ mux.HandleFunc("POST /ops/auth/sms/send", opsAuthHandler.SendSMSCode)
+ mux.HandleFunc("POST /ops/auth/register", opsAuthHandler.Register)
+ mux.HandleFunc("POST /ops/auth/login/sms", opsAuthHandler.LoginSMS)
+ mux.HandleFunc("POST /ops/auth/refresh", opsAuthHandler.Refresh)
+ mux.HandleFunc("POST /ops/auth/logout", opsAuthHandler.Logout)
+ mux.Handle("GET /ops/me", opsAuthMiddleware(http.HandlerFunc(opsAuthHandler.Me)))
mux.Handle("POST /auth/bind/mobile", authMiddleware(http.HandlerFunc(authHandler.BindMobile)))
mux.HandleFunc("POST /auth/refresh", authHandler.Refresh)
mux.HandleFunc("POST /auth/logout", authHandler.Logout)
mux.Handle("GET /me", authMiddleware(http.HandlerFunc(meHandler.Get)))
mux.Handle("GET /me/sessions", authMiddleware(http.HandlerFunc(sessionHandler.ListMine)))
mux.Handle("GET /me/results", authMiddleware(http.HandlerFunc(resultHandler.ListMine)))
+ mux.Handle("GET /ops/admin/summary", opsAuthMiddleware(http.HandlerFunc(opsSummaryHandler.GetOverview)))
+ mux.Handle("GET /ops/admin/region-options", opsAuthMiddleware(http.HandlerFunc(regionOptionsHandler.Get)))
+ mux.Handle("GET /ops/admin/assets", opsAuthMiddleware(http.HandlerFunc(adminAssetHandler.ListAssets)))
+ mux.Handle("POST /ops/admin/assets/register-link", opsAuthMiddleware(http.HandlerFunc(adminAssetHandler.RegisterLink)))
+ mux.Handle("POST /ops/admin/assets/upload", opsAuthMiddleware(http.HandlerFunc(adminAssetHandler.UploadFile)))
+ mux.Handle("GET /ops/admin/assets/{assetPublicID}", opsAuthMiddleware(http.HandlerFunc(adminAssetHandler.GetAsset)))
+ mux.Handle("GET /ops/admin/places", opsAuthMiddleware(http.HandlerFunc(adminProductionHandler.ListPlaces)))
+ mux.Handle("POST /ops/admin/places", opsAuthMiddleware(http.HandlerFunc(adminProductionHandler.CreatePlace)))
+ mux.Handle("GET /ops/admin/places/{placePublicID}", opsAuthMiddleware(http.HandlerFunc(adminProductionHandler.GetPlace)))
+ mux.Handle("GET /ops/admin/map-assets", opsAuthMiddleware(http.HandlerFunc(adminProductionHandler.ListMapAssets)))
+ mux.Handle("POST /ops/admin/places/{placePublicID}/map-assets", opsAuthMiddleware(http.HandlerFunc(adminProductionHandler.CreateMapAsset)))
+ mux.Handle("GET /ops/admin/map-assets/{mapAssetPublicID}", opsAuthMiddleware(http.HandlerFunc(adminProductionHandler.GetMapAsset)))
+ mux.Handle("PUT /ops/admin/map-assets/{mapAssetPublicID}", opsAuthMiddleware(http.HandlerFunc(adminProductionHandler.UpdateMapAsset)))
+ mux.Handle("POST /ops/admin/map-assets/{mapAssetPublicID}/tile-releases", opsAuthMiddleware(http.HandlerFunc(adminProductionHandler.CreateTileRelease)))
+ mux.Handle("POST /ops/admin/ops/tile-releases/import", opsAuthMiddleware(http.HandlerFunc(adminProductionHandler.ImportTileRelease)))
+ mux.Handle("GET /ops/admin/course-sources", opsAuthMiddleware(http.HandlerFunc(adminProductionHandler.ListCourseSources)))
+ mux.Handle("GET /ops/admin/course-sources/{sourcePublicID}", opsAuthMiddleware(http.HandlerFunc(adminProductionHandler.GetCourseSource)))
+ mux.Handle("GET /ops/admin/course-sets/{courseSetPublicID}", opsAuthMiddleware(http.HandlerFunc(adminProductionHandler.GetCourseSet)))
+ mux.Handle("POST /ops/admin/ops/course-sets/import-kml-batch", opsAuthMiddleware(http.HandlerFunc(adminProductionHandler.ImportCourseSetKMLBatch)))
+ mux.Handle("GET /ops/admin/events", opsAuthMiddleware(http.HandlerFunc(adminEventHandler.ListEvents)))
+ mux.Handle("POST /ops/admin/events", opsAuthMiddleware(http.HandlerFunc(adminEventHandler.CreateEvent)))
+ mux.Handle("GET /ops/admin/events/{eventPublicID}", opsAuthMiddleware(http.HandlerFunc(adminEventHandler.GetEvent)))
+ mux.Handle("PUT /ops/admin/events/{eventPublicID}", opsAuthMiddleware(http.HandlerFunc(adminEventHandler.UpdateEvent)))
+ mux.Handle("POST /ops/admin/events/{eventPublicID}/presentations/import", opsAuthMiddleware(http.HandlerFunc(adminEventHandler.ImportPresentation)))
+ mux.Handle("POST /ops/admin/events/{eventPublicID}/content-bundles/import", opsAuthMiddleware(http.HandlerFunc(adminEventHandler.ImportContentBundle)))
+ mux.Handle("POST /ops/admin/events/{eventPublicID}/defaults", opsAuthMiddleware(http.HandlerFunc(adminEventHandler.UpdateEventDefaults)))
+ mux.Handle("GET /ops/admin/events/{eventPublicID}/pipeline", opsAuthMiddleware(http.HandlerFunc(adminPipelineHandler.GetEventPipeline)))
+ mux.Handle("POST /ops/admin/sources/{sourceID}/build", opsAuthMiddleware(http.HandlerFunc(adminPipelineHandler.BuildSource)))
+ mux.Handle("GET /ops/admin/builds/{buildID}", opsAuthMiddleware(http.HandlerFunc(adminPipelineHandler.GetBuild)))
+ mux.Handle("POST /ops/admin/builds/{buildID}/publish", opsAuthMiddleware(http.HandlerFunc(adminPipelineHandler.PublishBuild)))
+ mux.Handle("GET /ops/admin/releases/{releasePublicID}", opsAuthMiddleware(http.HandlerFunc(adminPipelineHandler.GetRelease)))
+ mux.Handle("POST /ops/admin/events/{eventPublicID}/rollback", opsAuthMiddleware(http.HandlerFunc(adminPipelineHandler.RollbackRelease)))
return mux
}
diff --git a/backend/internal/platform/assets/publisher.go b/backend/internal/platform/assets/publisher.go
index 1c551ec..288334d 100644
--- a/backend/internal/platform/assets/publisher.go
+++ b/backend/internal/platform/assets/publisher.go
@@ -78,6 +78,38 @@ func (p *OSSUtilPublisher) UploadJSON(ctx context.Context, publicURL string, pay
return nil
}
+func (p *OSSUtilPublisher) UploadFile(ctx context.Context, publicURL string, localPath string) error {
+ if !p.Enabled() {
+ return fmt.Errorf("asset publisher is not configured")
+ }
+ if strings.TrimSpace(localPath) == "" {
+ return fmt.Errorf("local path is required")
+ }
+
+ 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)
+ }
+ if _, err := os.Stat(localPath); err != nil {
+ return fmt.Errorf("upload file not found: %w", err)
+ }
+
+ target := p.bucketRoot + "/" + objectKey
+ cmd := exec.CommandContext(ctx, p.ossutilPath, "cp", "-f", localPath, 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 == "" {
diff --git a/backend/internal/platform/jwtx/jwt.go b/backend/internal/platform/jwtx/jwt.go
index 857a072..efd51d5 100644
--- a/backend/internal/platform/jwtx/jwt.go
+++ b/backend/internal/platform/jwtx/jwt.go
@@ -16,6 +16,8 @@ type Manager struct {
type AccessClaims struct {
UserID string `json:"uid"`
UserPublicID string `json:"upub"`
+ ActorType string `json:"actorType,omitempty"`
+ RoleCode string `json:"roleCode,omitempty"`
jwt.RegisteredClaims
}
@@ -28,10 +30,16 @@ func NewManager(issuer, secret string, ttl time.Duration) *Manager {
}
func (m *Manager) IssueAccessToken(userID, userPublicID string) (string, time.Time, error) {
+ return m.IssueActorAccessToken(userID, userPublicID, "user", "")
+}
+
+func (m *Manager) IssueActorAccessToken(userID, userPublicID, actorType, roleCode string) (string, time.Time, error) {
expiresAt := time.Now().UTC().Add(m.ttl)
claims := AccessClaims{
UserID: userID,
UserPublicID: userPublicID,
+ ActorType: actorType,
+ RoleCode: roleCode,
RegisteredClaims: jwt.RegisteredClaims{
Issuer: m.issuer,
Subject: userID,
diff --git a/backend/internal/service/admin_asset_service.go b/backend/internal/service/admin_asset_service.go
new file mode 100644
index 0000000..4c0b959
--- /dev/null
+++ b/backend/internal/service/admin_asset_service.go
@@ -0,0 +1,303 @@
+package service
+
+import (
+ "context"
+ "mime"
+ "net/http"
+ "path"
+ "path/filepath"
+ "strings"
+
+ "cmr-backend/internal/apperr"
+ "cmr-backend/internal/platform/assets"
+ "cmr-backend/internal/platform/security"
+ "cmr-backend/internal/store/postgres"
+)
+
+type AdminAssetService struct {
+ store *postgres.Store
+ assetBaseURL string
+ assetPublisher *assets.OSSUtilPublisher
+}
+
+type ManagedAssetSummary struct {
+ ID string `json:"id"`
+ AssetType string `json:"assetType"`
+ AssetCode string `json:"assetCode"`
+ Version string `json:"version"`
+ Title *string `json:"title,omitempty"`
+ SourceMode string `json:"sourceMode"`
+ StorageProvider string `json:"storageProvider"`
+ ObjectKey *string `json:"objectKey,omitempty"`
+ PublicURL string `json:"publicUrl"`
+ FileName *string `json:"fileName,omitempty"`
+ ContentType *string `json:"contentType,omitempty"`
+ FileSizeBytes *int64 `json:"fileSizeBytes,omitempty"`
+ ChecksumSHA256 *string `json:"checksumSha256,omitempty"`
+ Status string `json:"status"`
+ Metadata map[string]any `json:"metadata,omitempty"`
+}
+
+type RegisterLinkAssetInput struct {
+ AssetType string `json:"assetType"`
+ AssetCode string `json:"assetCode"`
+ Version string `json:"version"`
+ Title *string `json:"title,omitempty"`
+ PublicURL string `json:"publicUrl"`
+ FileName *string `json:"fileName,omitempty"`
+ ContentType *string `json:"contentType,omitempty"`
+ Status string `json:"status"`
+ Metadata map[string]any `json:"metadata,omitempty"`
+}
+
+type UploadAssetFileInput struct {
+ AssetType string
+ AssetCode string
+ Version string
+ Title *string
+ ObjectDir *string
+ FileName string
+ ContentType string
+ FileSize int64
+ Checksum string
+ TempPath string
+ Status string
+ Metadata map[string]any
+}
+
+func NewAdminAssetService(store *postgres.Store, assetBaseURL string, assetPublisher *assets.OSSUtilPublisher) *AdminAssetService {
+ return &AdminAssetService{
+ store: store,
+ assetBaseURL: strings.TrimRight(strings.TrimSpace(assetBaseURL), "/"),
+ assetPublisher: assetPublisher,
+ }
+}
+
+func (s *AdminAssetService) ListManagedAssets(ctx context.Context, limit int) ([]ManagedAssetSummary, error) {
+ items, err := s.store.ListManagedAssets(ctx, limit)
+ if err != nil {
+ return nil, err
+ }
+ result := make([]ManagedAssetSummary, 0, len(items))
+ for _, item := range items {
+ result = append(result, buildManagedAssetSummary(item))
+ }
+ return result, nil
+}
+
+func (s *AdminAssetService) GetManagedAsset(ctx context.Context, assetPublicID string) (*ManagedAssetSummary, error) {
+ record, err := s.store.GetManagedAssetByPublicID(ctx, strings.TrimSpace(assetPublicID))
+ if err != nil {
+ return nil, err
+ }
+ if record == nil {
+ return nil, apperr.New(http.StatusNotFound, "asset_not_found", "asset not found")
+ }
+ summary := buildManagedAssetSummary(*record)
+ return &summary, nil
+}
+
+func (s *AdminAssetService) RegisterExternalLink(ctx context.Context, input RegisterLinkAssetInput) (*ManagedAssetSummary, error) {
+ if err := validateManagedAssetInput(input.AssetType, input.AssetCode, input.Version); err != nil {
+ return nil, err
+ }
+ publicURL := strings.TrimSpace(input.PublicURL)
+ if publicURL == "" {
+ return nil, apperr.New(http.StatusBadRequest, "invalid_params", "publicUrl is required")
+ }
+
+ publicID, err := security.GeneratePublicID("asset")
+ if err != nil {
+ return nil, err
+ }
+
+ tx, err := s.store.Begin(ctx)
+ if err != nil {
+ return nil, err
+ }
+ defer tx.Rollback(ctx)
+
+ record, err := s.store.CreateManagedAsset(ctx, tx, postgres.CreateManagedAssetParams{
+ PublicID: publicID,
+ AssetType: normalizeCode(input.AssetType),
+ AssetCode: normalizeCode(input.AssetCode),
+ Version: strings.TrimSpace(input.Version),
+ Title: assetTrimStringPtr(input.Title),
+ SourceMode: "external_link",
+ StorageProvider: "external",
+ ObjectKey: nil,
+ PublicURL: publicURL,
+ FileName: assetTrimStringPtr(input.FileName),
+ ContentType: assetTrimStringPtr(input.ContentType),
+ FileSizeBytes: nil,
+ ChecksumSHA256: nil,
+ Status: normalizeManagedAssetStatus(input.Status),
+ MetadataJSONB: normalizeJSONMap(input.Metadata),
+ })
+ if err != nil {
+ return nil, err
+ }
+ if err := tx.Commit(ctx); err != nil {
+ return nil, err
+ }
+
+ summary := buildManagedAssetSummary(*record)
+ return &summary, nil
+}
+
+func (s *AdminAssetService) UploadAssetFile(ctx context.Context, input UploadAssetFileInput) (*ManagedAssetSummary, error) {
+ if err := validateManagedAssetInput(input.AssetType, input.AssetCode, input.Version); err != nil {
+ return nil, err
+ }
+ if !s.assetPublisher.Enabled() {
+ return nil, apperr.New(http.StatusFailedDependency, "asset_publisher_not_configured", "asset publisher is not configured")
+ }
+ if strings.TrimSpace(input.TempPath) == "" || strings.TrimSpace(input.FileName) == "" {
+ return nil, apperr.New(http.StatusBadRequest, "invalid_params", "upload file is required")
+ }
+
+ objectDir := s.defaultObjectDir(input.AssetType, input.AssetCode, input.Version, input.ObjectDir)
+ publicURL := s.assetBaseURL + "/" + strings.TrimLeft(path.Join(objectDir, sanitizeFileName(input.FileName)), "/")
+
+ if err := s.assetPublisher.UploadFile(ctx, publicURL, input.TempPath); err != nil {
+ return nil, err
+ }
+
+ publicID, err := security.GeneratePublicID("asset")
+ if err != nil {
+ return nil, err
+ }
+
+ objectKey := strings.TrimPrefix(strings.TrimPrefix(publicURL, s.assetBaseURL), "/")
+ fileName := sanitizeFileName(input.FileName)
+ contentType := detectContentType(fileName, input.ContentType)
+ checksum := strings.TrimSpace(input.Checksum)
+
+ tx, err := s.store.Begin(ctx)
+ if err != nil {
+ return nil, err
+ }
+ defer tx.Rollback(ctx)
+
+ record, err := s.store.CreateManagedAsset(ctx, tx, postgres.CreateManagedAssetParams{
+ PublicID: publicID,
+ AssetType: normalizeCode(input.AssetType),
+ AssetCode: normalizeCode(input.AssetCode),
+ Version: strings.TrimSpace(input.Version),
+ Title: assetTrimStringPtr(input.Title),
+ SourceMode: "uploaded",
+ StorageProvider: "oss",
+ ObjectKey: stringPtr(objectKey),
+ PublicURL: publicURL,
+ FileName: stringPtr(fileName),
+ ContentType: stringPtr(contentType),
+ FileSizeBytes: &input.FileSize,
+ ChecksumSHA256: stringPtr(checksum),
+ Status: normalizeManagedAssetStatus(input.Status),
+ MetadataJSONB: normalizeJSONMap(input.Metadata),
+ })
+ if err != nil {
+ return nil, err
+ }
+ if err := tx.Commit(ctx); err != nil {
+ return nil, err
+ }
+
+ summary := buildManagedAssetSummary(*record)
+ return &summary, nil
+}
+
+func (s *AdminAssetService) defaultObjectDir(assetType, assetCode, version string, preferred *string) string {
+ if preferred != nil && strings.TrimSpace(*preferred) != "" {
+ return strings.Trim(strings.ReplaceAll(strings.TrimSpace(*preferred), "\\", "/"), "/")
+ }
+ return path.Join("uploads", normalizeCode(assetType), normalizeCode(assetCode), strings.TrimSpace(version))
+}
+
+func buildManagedAssetSummary(record postgres.ManagedAssetRecord) ManagedAssetSummary {
+ return ManagedAssetSummary{
+ ID: record.PublicID,
+ AssetType: record.AssetType,
+ AssetCode: record.AssetCode,
+ Version: record.Version,
+ Title: record.Title,
+ SourceMode: record.SourceMode,
+ StorageProvider: record.StorageProvider,
+ ObjectKey: record.ObjectKey,
+ PublicURL: record.PublicURL,
+ FileName: record.FileName,
+ ContentType: record.ContentType,
+ FileSizeBytes: record.FileSizeBytes,
+ ChecksumSHA256: record.ChecksumSHA256,
+ Status: record.Status,
+ Metadata: normalizeJSONMap(record.MetadataJSONB),
+ }
+}
+
+func validateManagedAssetInput(assetType, assetCode, version string) error {
+ if normalizeCode(assetType) == "" || normalizeCode(assetCode) == "" || strings.TrimSpace(version) == "" {
+ return apperr.New(http.StatusBadRequest, "invalid_params", "assetType, assetCode and version are required")
+ }
+ return nil
+}
+
+func normalizeManagedAssetStatus(value string) string {
+ switch strings.ToLower(strings.TrimSpace(value)) {
+ case "", "active":
+ return "active"
+ case "draft", "disabled", "archived":
+ return strings.ToLower(strings.TrimSpace(value))
+ default:
+ return "active"
+ }
+}
+
+func normalizeCode(value string) string {
+ value = strings.TrimSpace(strings.ToLower(value))
+ value = strings.ReplaceAll(value, " ", "-")
+ return value
+}
+
+func sanitizeFileName(name string) string {
+ name = filepath.Base(strings.TrimSpace(name))
+ name = strings.ReplaceAll(name, " ", "-")
+ return name
+}
+
+func detectContentType(fileName, provided string) string {
+ if strings.TrimSpace(provided) != "" {
+ return strings.TrimSpace(provided)
+ }
+ if ext := filepath.Ext(fileName); ext != "" {
+ if guessed := mime.TypeByExtension(ext); guessed != "" {
+ return guessed
+ }
+ }
+ return "application/octet-stream"
+}
+
+func stringPtr(value string) *string {
+ value = strings.TrimSpace(value)
+ if value == "" {
+ return nil
+ }
+ return &value
+}
+
+func assetTrimStringPtr(value *string) *string {
+ if value == nil {
+ return nil
+ }
+ trimmed := strings.TrimSpace(*value)
+ if trimmed == "" {
+ return nil
+ }
+ return &trimmed
+}
+
+func normalizeJSONMap(value map[string]any) map[string]any {
+ if value == nil {
+ return map[string]any{}
+ }
+ return value
+}
diff --git a/backend/internal/service/admin_production_service.go b/backend/internal/service/admin_production_service.go
index d128584..8766c79 100644
--- a/backend/internal/service/admin_production_service.go
+++ b/backend/internal/service/admin_production_service.go
@@ -44,6 +44,7 @@ type CreateAdminPlaceInput struct {
type AdminMapAssetSummary struct {
ID string `json:"id"`
PlaceID string `json:"placeId"`
+ PlaceName *string `json:"placeName,omitempty"`
LegacyMapID *string `json:"legacyMapId,omitempty"`
Code string `json:"code"`
Name string `json:"name"`
@@ -61,9 +62,10 @@ type AdminTileReleaseBrief struct {
}
type AdminMapAssetDetail struct {
- MapAsset AdminMapAssetSummary `json:"mapAsset"`
- TileReleases []AdminTileReleaseView `json:"tileReleases"`
- CourseSets []AdminCourseSetBrief `json:"courseSets"`
+ MapAsset AdminMapAssetSummary `json:"mapAsset"`
+ TileReleases []AdminTileReleaseView `json:"tileReleases"`
+ CourseSets []AdminCourseSetBrief `json:"courseSets"`
+ LinkedEvents []AdminMapLinkedEventBrief `json:"linkedEvents"`
}
type CreateAdminMapAssetInput struct {
@@ -76,6 +78,31 @@ type CreateAdminMapAssetInput struct {
Status string `json:"status"`
}
+type UpdateAdminMapAssetInput struct {
+ Code string `json:"code"`
+ Name string `json:"name"`
+ MapType string `json:"mapType"`
+ CoverURL *string `json:"coverUrl,omitempty"`
+ Description *string `json:"description,omitempty"`
+ Status string `json:"status"`
+}
+
+type AdminMapLinkedEventBrief struct {
+ EventID string `json:"eventId"`
+ Title string `json:"title"`
+ Summary *string `json:"summary,omitempty"`
+ Status string `json:"status"`
+ IsDefaultExperience bool `json:"isDefaultExperience"`
+ ShowInEventList bool `json:"showInEventList"`
+ CurrentReleaseID *string `json:"currentReleaseId,omitempty"`
+ ConfigLabel *string `json:"configLabel,omitempty"`
+ RouteCode *string `json:"routeCode,omitempty"`
+ CurrentPresentationID *string `json:"currentPresentationId,omitempty"`
+ CurrentPresentation *string `json:"currentPresentation,omitempty"`
+ CurrentContentBundleID *string `json:"currentContentBundleId,omitempty"`
+ CurrentContentBundle *string `json:"currentContentBundle,omitempty"`
+}
+
type AdminTileReleaseView struct {
ID string `json:"id"`
LegacyVersionID *string `json:"legacyVersionId,omitempty"`
@@ -202,6 +229,66 @@ type CreateAdminRuntimeBindingInput struct {
Notes *string `json:"notes,omitempty"`
}
+type ImportAdminTileReleaseInput struct {
+ PlaceCode string `json:"placeCode"`
+ PlaceName string `json:"placeName"`
+ PlaceRegion *string `json:"placeRegion,omitempty"`
+ PlaceCoverURL *string `json:"placeCoverUrl,omitempty"`
+ PlaceDescription *string `json:"placeDescription,omitempty"`
+ PlaceCenterPoint map[string]any `json:"placeCenterPoint,omitempty"`
+ MapAssetCode string `json:"mapAssetCode"`
+ MapAssetName string `json:"mapAssetName"`
+ MapType string `json:"mapType"`
+ MapCoverURL *string `json:"mapCoverUrl,omitempty"`
+ MapDescription *string `json:"mapDescription,omitempty"`
+ VersionCode string `json:"versionCode"`
+ Status string `json:"status"`
+ TileBaseURL string `json:"tileBaseUrl"`
+ MetaURL string `json:"metaUrl"`
+ PublishedAssetRoot *string `json:"publishedAssetRoot,omitempty"`
+ Metadata map[string]any `json:"metadata,omitempty"`
+ SetAsCurrent bool `json:"setAsCurrent"`
+}
+
+type ImportAdminTileReleaseResult struct {
+ Place AdminPlaceSummary `json:"place"`
+ MapAsset AdminMapAssetSummary `json:"mapAsset"`
+ TileRelease AdminTileReleaseView `json:"tileRelease"`
+}
+
+type ImportAdminCourseRouteInput struct {
+ Name string `json:"name"`
+ RouteCode string `json:"routeCode"`
+ FileURL string `json:"fileUrl"`
+ SourceType string `json:"sourceType"`
+ ControlCount *int `json:"controlCount,omitempty"`
+ Difficulty *string `json:"difficulty,omitempty"`
+ Status string `json:"status"`
+ Metadata map[string]any `json:"metadata,omitempty"`
+}
+
+type ImportAdminCourseSetBatchInput struct {
+ PlaceCode string `json:"placeCode"`
+ PlaceName string `json:"placeName"`
+ MapAssetCode string `json:"mapAssetCode"`
+ MapAssetName string `json:"mapAssetName"`
+ MapType string `json:"mapType"`
+ CourseSetCode string `json:"courseSetCode"`
+ CourseSetName string `json:"courseSetName"`
+ Mode string `json:"mode"`
+ Description *string `json:"description,omitempty"`
+ Status string `json:"status"`
+ DefaultRouteCode *string `json:"defaultRouteCode,omitempty"`
+ Routes []ImportAdminCourseRouteInput `json:"routes"`
+}
+
+type ImportAdminCourseSetBatchResult struct {
+ Place AdminPlaceSummary `json:"place"`
+ MapAsset AdminMapAssetSummary `json:"mapAsset"`
+ CourseSet AdminCourseSetBrief `json:"courseSet"`
+ Variants []AdminCourseVariantView `json:"variants"`
+}
+
func NewAdminProductionService(store *postgres.Store) *AdminProductionService {
return &AdminProductionService{store: store}
}
@@ -218,6 +305,22 @@ func (s *AdminProductionService) ListPlaces(ctx context.Context, limit int) ([]A
return result, nil
}
+func (s *AdminProductionService) ListMapAssets(ctx context.Context, limit int) ([]AdminMapAssetSummary, error) {
+ items, err := s.store.ListMapAssets(ctx, limit)
+ if err != nil {
+ return nil, err
+ }
+ result := make([]AdminMapAssetSummary, 0, len(items))
+ for _, item := range items {
+ summary, err := s.buildAdminMapAssetSummary(ctx, item)
+ if err != nil {
+ return nil, err
+ }
+ result = append(result, summary)
+ }
+ return result, nil
+}
+
func (s *AdminProductionService) CreatePlace(ctx context.Context, input CreateAdminPlaceInput) (*AdminPlaceSummary, error) {
input.Code = strings.TrimSpace(input.Code)
input.Name = strings.TrimSpace(input.Name)
@@ -362,10 +465,15 @@ func (s *AdminProductionService) GetMapAssetDetail(ctx context.Context, mapAsset
if err != nil {
return nil, err
}
+ linkedEvents, err := s.store.ListMapAssetLinkedEvents(ctx, item.ID, 100)
+ if err != nil {
+ return nil, err
+ }
result := &AdminMapAssetDetail{
MapAsset: summary,
TileReleases: make([]AdminTileReleaseView, 0, len(tileReleases)),
CourseSets: make([]AdminCourseSetBrief, 0, len(courseSets)),
+ LinkedEvents: make([]AdminMapLinkedEventBrief, 0, len(linkedEvents)),
}
for _, release := range tileReleases {
result.TileReleases = append(result.TileReleases, buildAdminTileReleaseView(release))
@@ -377,9 +485,64 @@ func (s *AdminProductionService) GetMapAssetDetail(ctx context.Context, mapAsset
}
result.CourseSets = append(result.CourseSets, brief)
}
+ for _, linked := range linkedEvents {
+ result.LinkedEvents = append(result.LinkedEvents, buildAdminMapLinkedEventBrief(linked))
+ }
return result, nil
}
+func (s *AdminProductionService) UpdateMapAsset(ctx context.Context, mapAssetPublicID string, input UpdateAdminMapAssetInput) (*AdminMapAssetSummary, error) {
+ item, err := s.store.GetMapAssetByPublicID(ctx, strings.TrimSpace(mapAssetPublicID))
+ if err != nil {
+ return nil, err
+ }
+ if item == nil {
+ return nil, apperr.New(http.StatusNotFound, "map_asset_not_found", "map asset not found")
+ }
+ input.Code = strings.TrimSpace(input.Code)
+ input.Name = strings.TrimSpace(input.Name)
+ input.MapType = strings.TrimSpace(input.MapType)
+ if input.Code == "" || input.Name == "" {
+ return nil, apperr.New(http.StatusBadRequest, "invalid_params", "code and name are required")
+ }
+ if input.MapType == "" {
+ input.MapType = "standard"
+ }
+
+ tx, err := s.store.Begin(ctx)
+ if err != nil {
+ return nil, err
+ }
+ defer tx.Rollback(ctx)
+ updated, err := s.store.UpdateMapAsset(ctx, tx, postgres.UpdateMapAssetParams{
+ MapAssetID: item.ID,
+ Code: input.Code,
+ Name: input.Name,
+ MapType: input.MapType,
+ CoverURL: trimStringPtr(input.CoverURL),
+ Description: trimStringPtr(input.Description),
+ Status: normalizeCatalogStatus(input.Status),
+ })
+ if err != nil {
+ return nil, err
+ }
+ if err := tx.Commit(ctx); err != nil {
+ return nil, err
+ }
+ refreshed, err := s.store.GetMapAssetByPublicID(ctx, updated.PublicID)
+ if err != nil {
+ return nil, err
+ }
+ if refreshed == nil {
+ return nil, apperr.New(http.StatusNotFound, "map_asset_not_found", "map asset not found")
+ }
+ result, err := s.buildAdminMapAssetSummary(ctx, *refreshed)
+ if err != nil {
+ return nil, err
+ }
+ return &result, nil
+}
+
func (s *AdminProductionService) CreateTileRelease(ctx context.Context, mapAssetPublicID string, input CreateAdminTileReleaseInput) (*AdminTileReleaseView, error) {
mapAsset, err := s.store.GetMapAssetByPublicID(ctx, strings.TrimSpace(mapAssetPublicID))
if err != nil {
@@ -748,6 +911,293 @@ func (s *AdminProductionService) CreateRuntimeBinding(ctx context.Context, input
return &result, nil
}
+func (s *AdminProductionService) ImportTileRelease(ctx context.Context, input ImportAdminTileReleaseInput) (*ImportAdminTileReleaseResult, error) {
+ input.PlaceCode = strings.TrimSpace(input.PlaceCode)
+ input.PlaceName = strings.TrimSpace(input.PlaceName)
+ input.MapAssetCode = strings.TrimSpace(input.MapAssetCode)
+ input.MapAssetName = strings.TrimSpace(input.MapAssetName)
+ input.VersionCode = strings.TrimSpace(input.VersionCode)
+ input.TileBaseURL = strings.TrimSpace(input.TileBaseURL)
+ input.MetaURL = strings.TrimSpace(input.MetaURL)
+ if input.PlaceCode == "" || input.PlaceName == "" || input.MapAssetCode == "" || input.MapAssetName == "" || input.VersionCode == "" || input.TileBaseURL == "" || input.MetaURL == "" {
+ return nil, apperr.New(http.StatusBadRequest, "invalid_params", "placeCode, placeName, mapAssetCode, mapAssetName, versionCode, tileBaseUrl and metaUrl are required")
+ }
+
+ place, err := s.store.GetPlaceByCode(ctx, input.PlaceCode)
+ if err != nil {
+ return nil, err
+ }
+ if place == nil {
+ created, err := s.CreatePlace(ctx, CreateAdminPlaceInput{
+ Code: input.PlaceCode,
+ Name: input.PlaceName,
+ Region: trimStringPtr(input.PlaceRegion),
+ CoverURL: trimStringPtr(input.PlaceCoverURL),
+ Description: trimStringPtr(input.PlaceDescription),
+ CenterPoint: input.PlaceCenterPoint,
+ Status: normalizeCatalogStatus(input.Status),
+ })
+ if err != nil {
+ return nil, err
+ }
+ place, err = s.store.GetPlaceByPublicID(ctx, created.ID)
+ if err != nil {
+ return nil, err
+ }
+ }
+ if place == nil {
+ return nil, apperr.New(http.StatusNotFound, "place_not_found", "place not found")
+ }
+
+ mapAsset, err := s.store.GetMapAssetByCode(ctx, input.MapAssetCode)
+ if err != nil {
+ return nil, err
+ }
+ if mapAsset == nil {
+ created, err := s.CreateMapAsset(ctx, place.PublicID, CreateAdminMapAssetInput{
+ Code: input.MapAssetCode,
+ Name: input.MapAssetName,
+ MapType: strings.TrimSpace(input.MapType),
+ CoverURL: trimStringPtr(input.MapCoverURL),
+ Description: trimStringPtr(input.MapDescription),
+ Status: normalizeCatalogStatus(input.Status),
+ })
+ if err != nil {
+ return nil, err
+ }
+ mapAsset, err = s.store.GetMapAssetByPublicID(ctx, created.ID)
+ if err != nil {
+ return nil, err
+ }
+ }
+ if mapAsset == nil || mapAsset.PlaceID != place.ID {
+ return nil, apperr.New(http.StatusBadRequest, "map_asset_mismatch", "map asset does not belong to place")
+ }
+
+ release, err := s.store.GetTileReleaseByMapAssetIDAndVersionCode(ctx, mapAsset.ID, input.VersionCode)
+ if err != nil {
+ return nil, err
+ }
+ if release == nil {
+ created, err := s.CreateTileRelease(ctx, mapAsset.PublicID, CreateAdminTileReleaseInput{
+ VersionCode: input.VersionCode,
+ Status: normalizeReleaseStatus(input.Status),
+ TileBaseURL: input.TileBaseURL,
+ MetaURL: input.MetaURL,
+ PublishedAssetRoot: trimStringPtr(input.PublishedAssetRoot),
+ Metadata: input.Metadata,
+ SetAsCurrent: input.SetAsCurrent,
+ })
+ if err != nil {
+ return nil, err
+ }
+ release, err = s.store.GetTileReleaseByPublicID(ctx, created.ID)
+ if err != nil {
+ return nil, err
+ }
+ } else if input.SetAsCurrent {
+ tx, err := s.store.Begin(ctx)
+ if err != nil {
+ return nil, err
+ }
+ defer tx.Rollback(ctx)
+ if err := s.store.SetMapAssetCurrentTileRelease(ctx, tx, mapAsset.ID, release.ID); err != nil {
+ return nil, err
+ }
+ if err := tx.Commit(ctx); err != nil {
+ return nil, err
+ }
+ mapAsset, err = s.store.GetMapAssetByPublicID(ctx, mapAsset.PublicID)
+ if err != nil {
+ return nil, err
+ }
+ }
+ if release == nil {
+ return nil, apperr.New(http.StatusNotFound, "tile_release_not_found", "tile release not found")
+ }
+
+ placeSummary := buildAdminPlaceSummary(*place)
+ mapSummary, err := s.buildAdminMapAssetSummary(ctx, *mapAsset)
+ if err != nil {
+ return nil, err
+ }
+ return &ImportAdminTileReleaseResult{
+ Place: placeSummary,
+ MapAsset: mapSummary,
+ TileRelease: buildAdminTileReleaseView(*release),
+ }, nil
+}
+
+func (s *AdminProductionService) ImportCourseSetKMLBatch(ctx context.Context, input ImportAdminCourseSetBatchInput) (*ImportAdminCourseSetBatchResult, error) {
+ input.PlaceCode = strings.TrimSpace(input.PlaceCode)
+ input.PlaceName = strings.TrimSpace(input.PlaceName)
+ input.MapAssetCode = strings.TrimSpace(input.MapAssetCode)
+ input.MapAssetName = strings.TrimSpace(input.MapAssetName)
+ input.CourseSetCode = strings.TrimSpace(input.CourseSetCode)
+ input.CourseSetName = strings.TrimSpace(input.CourseSetName)
+ input.Mode = strings.TrimSpace(input.Mode)
+ if input.PlaceCode == "" || input.PlaceName == "" || input.MapAssetCode == "" || input.MapAssetName == "" || input.CourseSetCode == "" || input.CourseSetName == "" || input.Mode == "" || len(input.Routes) == 0 {
+ return nil, apperr.New(http.StatusBadRequest, "invalid_params", "placeCode, placeName, mapAssetCode, mapAssetName, courseSetCode, courseSetName, mode and routes are required")
+ }
+
+ place, err := s.store.GetPlaceByCode(ctx, input.PlaceCode)
+ if err != nil {
+ return nil, err
+ }
+ if place == nil {
+ created, err := s.CreatePlace(ctx, CreateAdminPlaceInput{
+ Code: input.PlaceCode,
+ Name: input.PlaceName,
+ Status: normalizeCatalogStatus(input.Status),
+ })
+ if err != nil {
+ return nil, err
+ }
+ place, err = s.store.GetPlaceByPublicID(ctx, created.ID)
+ if err != nil {
+ return nil, err
+ }
+ }
+ mapAsset, err := s.store.GetMapAssetByCode(ctx, input.MapAssetCode)
+ if err != nil {
+ return nil, err
+ }
+ if mapAsset == nil {
+ created, err := s.CreateMapAsset(ctx, place.PublicID, CreateAdminMapAssetInput{
+ Code: input.MapAssetCode,
+ Name: input.MapAssetName,
+ MapType: strings.TrimSpace(input.MapType),
+ Status: normalizeCatalogStatus(input.Status),
+ })
+ if err != nil {
+ return nil, err
+ }
+ mapAsset, err = s.store.GetMapAssetByPublicID(ctx, created.ID)
+ if err != nil {
+ return nil, err
+ }
+ }
+ if mapAsset == nil || mapAsset.PlaceID != place.ID {
+ return nil, apperr.New(http.StatusBadRequest, "map_asset_mismatch", "map asset does not belong to place")
+ }
+
+ courseSet, err := s.store.GetCourseSetByCode(ctx, input.CourseSetCode)
+ if err != nil {
+ return nil, err
+ }
+ if courseSet == nil {
+ created, err := s.CreateCourseSet(ctx, mapAsset.PublicID, CreateAdminCourseSetInput{
+ Code: input.CourseSetCode,
+ Mode: input.Mode,
+ Name: input.CourseSetName,
+ Description: trimStringPtr(input.Description),
+ Status: normalizeCatalogStatus(input.Status),
+ })
+ if err != nil {
+ return nil, err
+ }
+ courseSet, err = s.store.GetCourseSetByPublicID(ctx, created.ID)
+ if err != nil {
+ return nil, err
+ }
+ }
+ if courseSet == nil || courseSet.PlaceID != place.ID || courseSet.MapAssetID != mapAsset.ID {
+ return nil, apperr.New(http.StatusBadRequest, "course_set_mismatch", "course set does not match place/map asset")
+ }
+
+ defaultRouteCode := ""
+ if input.DefaultRouteCode != nil {
+ defaultRouteCode = strings.TrimSpace(*input.DefaultRouteCode)
+ }
+
+ for _, route := range input.Routes {
+ route.Name = strings.TrimSpace(route.Name)
+ route.RouteCode = strings.TrimSpace(route.RouteCode)
+ route.FileURL = strings.TrimSpace(route.FileURL)
+ sourceType := strings.TrimSpace(route.SourceType)
+ if sourceType == "" {
+ sourceType = "kml"
+ }
+ if route.Name == "" || route.RouteCode == "" || route.FileURL == "" {
+ return nil, apperr.New(http.StatusBadRequest, "invalid_params", "route name, routeCode and fileUrl are required")
+ }
+
+ existing, err := s.store.GetCourseVariantByCourseSetIDAndRouteCode(ctx, courseSet.ID, route.RouteCode)
+ if err != nil {
+ return nil, err
+ }
+ if existing != nil {
+ if defaultRouteCode != "" && route.RouteCode == defaultRouteCode {
+ tx, err := s.store.Begin(ctx)
+ if err != nil {
+ return nil, err
+ }
+ defer tx.Rollback(ctx)
+ if err := s.store.SetCourseSetCurrentVariant(ctx, tx, courseSet.ID, existing.ID); err != nil {
+ return nil, err
+ }
+ if err := tx.Commit(ctx); err != nil {
+ return nil, err
+ }
+ }
+ continue
+ }
+
+ source, err := s.CreateCourseSource(ctx, CreateAdminCourseSourceInput{
+ SourceType: sourceType,
+ FileURL: route.FileURL,
+ ImportStatus: "imported",
+ Metadata: route.Metadata,
+ })
+ if err != nil {
+ return nil, err
+ }
+ isDefault := defaultRouteCode != "" && route.RouteCode == defaultRouteCode
+ _, err = s.CreateCourseVariant(ctx, courseSet.PublicID, CreateAdminCourseVariantInput{
+ SourceID: &source.ID,
+ Name: route.Name,
+ RouteCode: &route.RouteCode,
+ Mode: input.Mode,
+ ControlCount: route.ControlCount,
+ Difficulty: trimStringPtr(route.Difficulty),
+ Status: normalizeCatalogStatus(route.Status),
+ IsDefault: isDefault,
+ Metadata: route.Metadata,
+ })
+ if err != nil {
+ return nil, err
+ }
+ }
+
+ courseSet, err = s.store.GetCourseSetByPublicID(ctx, courseSet.PublicID)
+ if err != nil {
+ return nil, err
+ }
+ variants, err := s.store.ListCourseVariantsByCourseSetID(ctx, courseSet.ID)
+ if err != nil {
+ return nil, err
+ }
+ views := make([]AdminCourseVariantView, 0, len(variants))
+ for _, variant := range variants {
+ views = append(views, buildAdminCourseVariantView(variant))
+ }
+ placeSummary := buildAdminPlaceSummary(*place)
+ mapSummary, err := s.buildAdminMapAssetSummary(ctx, *mapAsset)
+ if err != nil {
+ return nil, err
+ }
+ courseBrief, err := s.buildAdminCourseSetBrief(ctx, *courseSet)
+ if err != nil {
+ return nil, err
+ }
+ return &ImportAdminCourseSetBatchResult{
+ Place: placeSummary,
+ MapAsset: mapSummary,
+ CourseSet: courseBrief,
+ Variants: views,
+ }, nil
+}
+
func (s *AdminProductionService) GetRuntimeBinding(ctx context.Context, runtimeBindingPublicID string) (*AdminRuntimeBindingSummary, error) {
item, err := s.store.GetMapRuntimeBindingByPublicID(ctx, strings.TrimSpace(runtimeBindingPublicID))
if err != nil {
@@ -764,6 +1214,7 @@ func (s *AdminProductionService) buildAdminMapAssetSummary(ctx context.Context,
result := AdminMapAssetSummary{
ID: item.PublicID,
PlaceID: item.PlaceID,
+ PlaceName: item.PlaceName,
LegacyMapID: item.LegacyMapPublicID,
Code: item.Code,
Name: item.Name,
@@ -791,6 +1242,24 @@ func (s *AdminProductionService) buildAdminMapAssetSummary(ctx context.Context,
return result, nil
}
+func buildAdminMapLinkedEventBrief(item postgres.MapAssetLinkedEvent) AdminMapLinkedEventBrief {
+ return AdminMapLinkedEventBrief{
+ EventID: item.EventPublicID,
+ Title: item.DisplayName,
+ Summary: item.Summary,
+ Status: item.Status,
+ IsDefaultExperience: item.IsDefaultExperience,
+ ShowInEventList: item.ShowInEventList,
+ CurrentReleaseID: item.CurrentReleasePublicID,
+ ConfigLabel: item.ConfigLabel,
+ RouteCode: item.RouteCode,
+ CurrentPresentationID: item.CurrentPresentationID,
+ CurrentPresentation: item.CurrentPresentationName,
+ CurrentContentBundleID: item.CurrentContentBundleID,
+ CurrentContentBundle: item.CurrentContentBundleName,
+ }
+}
+
func (s *AdminProductionService) buildAdminCourseSetBrief(ctx context.Context, item postgres.CourseSet) (AdminCourseSetBrief, error) {
result := AdminCourseSetBrief{
ID: item.PublicID,
diff --git a/backend/internal/service/event_play_service.go b/backend/internal/service/event_play_service.go
index 70ce129..f16c2fb 100644
--- a/backend/internal/service/event_play_service.go
+++ b/backend/internal/service/event_play_service.go
@@ -35,6 +35,7 @@ type EventPlayResult struct {
} `json:"release,omitempty"`
ResolvedRelease *ResolvedReleaseView `json:"resolvedRelease,omitempty"`
Runtime *RuntimeSummaryView `json:"runtime,omitempty"`
+ Preview *MapPreviewView `json:"preview,omitempty"`
CurrentPresentation *PresentationSummaryView `json:"currentPresentation,omitempty"`
CurrentContentBundle *ContentBundleSummaryView `json:"currentContentBundle,omitempty"`
Play struct {
@@ -104,6 +105,11 @@ func (s *EventPlayService) GetEventPlay(ctx context.Context, input EventPlayInpu
}
result.ResolvedRelease = buildResolvedReleaseFromEvent(event, LaunchSourceEventCurrentRelease)
result.Runtime = buildRuntimeSummaryFromEvent(event)
+ if preview, err := buildPreviewFromPayload(event.ReleasePayloadJSON); err != nil {
+ return nil, err
+ } else {
+ result.Preview = preview
+ }
result.CurrentPresentation = buildPresentationSummaryFromEvent(event)
if enrichedPresentation, err := loadPresentationSummaryByPublicID(ctx, s.store, event.PresentationID); err != nil {
return nil, err
diff --git a/backend/internal/service/event_service.go b/backend/internal/service/event_service.go
index f0fbc9a..ebaf3cc 100644
--- a/backend/internal/service/event_service.go
+++ b/backend/internal/service/event_service.go
@@ -32,6 +32,7 @@ type EventDetailResult struct {
} `json:"release,omitempty"`
ResolvedRelease *ResolvedReleaseView `json:"resolvedRelease,omitempty"`
Runtime *RuntimeSummaryView `json:"runtime,omitempty"`
+ Preview *MapPreviewView `json:"preview,omitempty"`
CurrentPresentation *PresentationSummaryView `json:"currentPresentation,omitempty"`
CurrentContentBundle *ContentBundleSummaryView `json:"currentContentBundle,omitempty"`
}
@@ -71,6 +72,7 @@ type LaunchEventResult struct {
SessionToken string `json:"sessionToken"`
SessionTokenExpiresAt string `json:"sessionTokenExpiresAt"`
RouteCode *string `json:"routeCode,omitempty"`
+ IsGuest bool `json:"isGuest,omitempty"`
} `json:"business"`
} `json:"launch"`
}
@@ -117,6 +119,11 @@ func (s *EventService) GetEventDetail(ctx context.Context, eventPublicID string)
}
result.ResolvedRelease = buildResolvedReleaseFromEvent(event, LaunchSourceEventCurrentRelease)
result.Runtime = buildRuntimeSummaryFromEvent(event)
+ if preview, err := buildPreviewFromPayload(event.ReleasePayloadJSON); err != nil {
+ return nil, err
+ } else {
+ result.Preview = preview
+ }
result.CurrentPresentation = buildPresentationSummaryFromEvent(event)
if enrichedPresentation, err := loadPresentationSummaryByPublicID(ctx, s.store, event.PresentationID); err != nil {
return nil, err
@@ -245,5 +252,6 @@ func (s *EventService) LaunchEvent(ctx context.Context, input LaunchEventInput)
result.Launch.Business.SessionToken = sessionToken
result.Launch.Business.SessionTokenExpiresAt = session.SessionTokenExpiresAt.Format(time.RFC3339)
result.Launch.Business.RouteCode = routeCode
+ result.Launch.Business.IsGuest = false
return result, nil
}
diff --git a/backend/internal/service/home_service.go b/backend/internal/service/home_service.go
index 9b6a93e..d3d6018 100644
--- a/backend/internal/service/home_service.go
+++ b/backend/internal/service/home_service.go
@@ -37,6 +37,7 @@ type CardResult struct {
TimeWindow string `json:"timeWindow"`
CTAText string `json:"ctaText"`
IsDefaultExperience bool `json:"isDefaultExperience"`
+ ShowInEventList bool `json:"showInEventList"`
EventType *string `json:"eventType,omitempty"`
CurrentPresentation *PresentationSummaryView `json:"currentPresentation,omitempty"`
CurrentContentBundle *ContentBundleSummaryView `json:"currentContentBundle,omitempty"`
@@ -153,6 +154,7 @@ func mapCards(cards []postgres.Card) []CardResult {
TimeWindow: deriveCardTimeWindow(card),
CTAText: deriveCardCTAText(card, statusCode),
IsDefaultExperience: card.IsDefaultExperience,
+ ShowInEventList: card.ShowInEventList,
EventType: deriveCardEventType(card),
CurrentPresentation: buildCardPresentationSummary(card),
CurrentContentBundle: buildCardContentBundleSummary(card),
diff --git a/backend/internal/service/map_experience_service.go b/backend/internal/service/map_experience_service.go
new file mode 100644
index 0000000..a71eb13
--- /dev/null
+++ b/backend/internal/service/map_experience_service.go
@@ -0,0 +1,300 @@
+package service
+
+import (
+ "context"
+ "net/http"
+ "strings"
+
+ "cmr-backend/internal/apperr"
+ "cmr-backend/internal/store/postgres"
+)
+
+type MapExperienceService struct {
+ store *postgres.Store
+}
+
+type ListExperienceMapsInput struct {
+ Limit int
+}
+
+type ExperienceMapSummary struct {
+ PlaceID string `json:"placeId"`
+ PlaceName string `json:"placeName"`
+ MapID string `json:"mapId"`
+ MapName string `json:"mapName"`
+ CoverURL *string `json:"coverUrl,omitempty"`
+ Summary *string `json:"summary,omitempty"`
+ DefaultExperienceCount int `json:"defaultExperienceCount"`
+ DefaultExperienceEventIDs []string `json:"defaultExperienceEventIds"`
+}
+
+type ExperienceMapDetail struct {
+ PlaceID string `json:"placeId"`
+ PlaceName string `json:"placeName"`
+ MapID string `json:"mapId"`
+ MapName string `json:"mapName"`
+ CoverURL *string `json:"coverUrl,omitempty"`
+ Summary *string `json:"summary,omitempty"`
+ TileBaseURL *string `json:"tileBaseUrl,omitempty"`
+ TileMetaURL *string `json:"tileMetaUrl,omitempty"`
+ DefaultExperienceCount int `json:"defaultExperienceCount"`
+ DefaultExperiences []ExperienceEventSummary `json:"defaultExperiences"`
+}
+
+type ExperienceEventSummary struct {
+ EventID string `json:"eventId"`
+ Title string `json:"title"`
+ Subtitle *string `json:"subtitle,omitempty"`
+ EventType *string `json:"eventType,omitempty"`
+ Status string `json:"status"`
+ StatusCode string `json:"statusCode"`
+ CTAText string `json:"ctaText"`
+ IsDefaultExperience bool `json:"isDefaultExperience"`
+ ShowInEventList bool `json:"showInEventList"`
+ CurrentPresentation *PresentationSummaryView `json:"currentPresentation,omitempty"`
+ CurrentContentBundle *ContentBundleSummaryView `json:"currentContentBundle,omitempty"`
+}
+
+func NewMapExperienceService(store *postgres.Store) *MapExperienceService {
+ return &MapExperienceService{store: store}
+}
+
+func (s *MapExperienceService) ListMaps(ctx context.Context, input ListExperienceMapsInput) ([]ExperienceMapSummary, error) {
+ rows, err := s.store.ListMapExperienceRows(ctx, input.Limit)
+ if err != nil {
+ return nil, err
+ }
+ return mapExperienceSummaries(rows), nil
+}
+
+func (s *MapExperienceService) GetMapDetail(ctx context.Context, mapPublicID string) (*ExperienceMapDetail, error) {
+ mapPublicID = strings.TrimSpace(mapPublicID)
+ if mapPublicID == "" {
+ return nil, apperr.New(http.StatusBadRequest, "invalid_map_id", "map id is required")
+ }
+ rows, err := s.store.ListMapExperienceRowsByMapPublicID(ctx, mapPublicID)
+ if err != nil {
+ return nil, err
+ }
+ if len(rows) == 0 {
+ return nil, apperr.New(http.StatusNotFound, "map_not_found", "map not found")
+ }
+ return buildMapExperienceDetail(rows), nil
+}
+
+func mapExperienceSummaries(rows []postgres.MapExperienceRow) []ExperienceMapSummary {
+ ordered := make([]string, 0, len(rows))
+ index := make(map[string]*ExperienceMapSummary)
+ for _, row := range rows {
+ item, ok := index[row.MapAssetPublicID]
+ if !ok {
+ summary := &ExperienceMapSummary{
+ PlaceID: row.PlacePublicID,
+ PlaceName: row.PlaceName,
+ MapID: row.MapAssetPublicID,
+ MapName: row.MapAssetName,
+ CoverURL: row.MapCoverURL,
+ Summary: normalizeOptionalText(row.MapSummary),
+ DefaultExperienceEventIDs: []string{},
+ }
+ index[row.MapAssetPublicID] = summary
+ ordered = append(ordered, row.MapAssetPublicID)
+ item = summary
+ }
+ if row.EventPublicID != nil && row.EventIsDefaultExperience {
+ if !containsString(item.DefaultExperienceEventIDs, *row.EventPublicID) {
+ item.DefaultExperienceEventIDs = append(item.DefaultExperienceEventIDs, *row.EventPublicID)
+ item.DefaultExperienceCount++
+ }
+ }
+ }
+
+ result := make([]ExperienceMapSummary, 0, len(ordered))
+ for _, id := range ordered {
+ result = append(result, *index[id])
+ }
+ return result
+}
+
+func buildMapExperienceDetail(rows []postgres.MapExperienceRow) *ExperienceMapDetail {
+ first := rows[0]
+ result := &ExperienceMapDetail{
+ PlaceID: first.PlacePublicID,
+ PlaceName: first.PlaceName,
+ MapID: first.MapAssetPublicID,
+ MapName: first.MapAssetName,
+ CoverURL: first.MapCoverURL,
+ Summary: normalizeOptionalText(first.MapSummary),
+ TileBaseURL: first.TileBaseURL,
+ TileMetaURL: first.TileMetaURL,
+ DefaultExperiences: make([]ExperienceEventSummary, 0, 4),
+ }
+ seen := make(map[string]struct{})
+ for _, row := range rows {
+ if row.EventPublicID == nil || !row.EventIsDefaultExperience {
+ continue
+ }
+ if _, ok := seen[*row.EventPublicID]; ok {
+ continue
+ }
+ seen[*row.EventPublicID] = struct{}{}
+ result.DefaultExperiences = append(result.DefaultExperiences, buildExperienceEventSummary(row))
+ }
+ result.DefaultExperienceCount = len(result.DefaultExperiences)
+ return result
+}
+
+func buildExperienceEventSummary(row postgres.MapExperienceRow) ExperienceEventSummary {
+ statusCode, statusText := deriveExperienceEventStatus(row)
+ return ExperienceEventSummary{
+ EventID: valueOrEmpty(row.EventPublicID),
+ Title: fallbackText(row.EventDisplayName, "未命名活动"),
+ Subtitle: normalizeOptionalText(row.EventSummary),
+ EventType: deriveExperienceEventType(row),
+ Status: statusText,
+ StatusCode: statusCode,
+ CTAText: deriveExperienceEventCTA(statusCode, row.EventIsDefaultExperience),
+ IsDefaultExperience: row.EventIsDefaultExperience,
+ ShowInEventList: row.EventShowInEventList,
+ CurrentPresentation: buildPresentationSummaryFromMapExperienceRow(row),
+ CurrentContentBundle: buildContentBundleSummaryFromMapExperienceRow(row),
+ }
+}
+
+func deriveExperienceEventStatus(row postgres.MapExperienceRow) (string, string) {
+ if row.EventStatus == nil {
+ return "pending", "状态待确认"
+ }
+ switch strings.TrimSpace(*row.EventStatus) {
+ case "active":
+ if row.EventReleasePayloadJSON == nil || strings.TrimSpace(*row.EventReleasePayloadJSON) == "" {
+ return "upcoming", "即将开始"
+ }
+ if row.EventPresentationID == nil || row.EventContentBundleID == nil {
+ return "upcoming", "即将开始"
+ }
+ return "running", "进行中"
+ case "archived", "disabled", "inactive":
+ return "ended", "已结束"
+ default:
+ return "pending", "状态待确认"
+ }
+}
+
+func deriveExperienceEventCTA(statusCode string, isDefault bool) string {
+ if isDefault {
+ return "进入体验"
+ }
+ switch statusCode {
+ case "running":
+ return "进入活动"
+ case "ended":
+ return "查看回顾"
+ default:
+ return "查看详情"
+ }
+}
+
+func deriveExperienceEventType(row postgres.MapExperienceRow) *string {
+ if row.EventReleasePayloadJSON != nil {
+ payload, err := decodeJSONObject(*row.EventReleasePayloadJSON)
+ if err == nil {
+ if game, ok := payload["game"].(map[string]any); ok {
+ if rawMode, ok := game["mode"].(string); ok {
+ switch strings.TrimSpace(rawMode) {
+ case "classic-sequential":
+ text := "顺序赛"
+ return &text
+ case "score-o":
+ text := "积分赛"
+ return &text
+ }
+ }
+ }
+ if plan := resolveVariantPlan(row.EventReleasePayloadJSON); plan.AssignmentMode != nil && *plan.AssignmentMode == AssignmentModeManual {
+ text := "多赛道"
+ return &text
+ }
+ }
+ }
+ if row.EventIsDefaultExperience {
+ text := "体验活动"
+ return &text
+ }
+ return nil
+}
+
+func buildPresentationSummaryFromMapExperienceRow(row postgres.MapExperienceRow) *PresentationSummaryView {
+ if row.EventPresentationID == nil {
+ return nil
+ }
+ summary := &PresentationSummaryView{
+ PresentationID: *row.EventPresentationID,
+ Name: row.EventPresentationName,
+ PresentationType: row.EventPresentationType,
+ }
+ if row.EventPresentationSchema != nil && strings.TrimSpace(*row.EventPresentationSchema) != "" {
+ if schema, err := decodeJSONObject(*row.EventPresentationSchema); err == nil {
+ summary.TemplateKey = readStringField(schema, "templateKey")
+ summary.Version = readStringField(schema, "version")
+ }
+ }
+ return summary
+}
+
+func buildContentBundleSummaryFromMapExperienceRow(row postgres.MapExperienceRow) *ContentBundleSummaryView {
+ if row.EventContentBundleID == nil {
+ return nil
+ }
+ summary := &ContentBundleSummaryView{
+ ContentBundleID: *row.EventContentBundleID,
+ Name: row.EventContentBundleName,
+ EntryURL: row.EventContentEntryURL,
+ AssetRootURL: row.EventContentAssetRootURL,
+ }
+ if row.EventContentMetadataJSON != nil && strings.TrimSpace(*row.EventContentMetadataJSON) != "" {
+ if metadata, err := decodeJSONObject(*row.EventContentMetadataJSON); err == nil {
+ summary.BundleType = readStringField(metadata, "bundleType")
+ summary.Version = readStringField(metadata, "version")
+ }
+ }
+ return summary
+}
+
+func normalizeOptionalText(value *string) *string {
+ if value == nil {
+ return nil
+ }
+ trimmed := strings.TrimSpace(*value)
+ if trimmed == "" {
+ return nil
+ }
+ return &trimmed
+}
+
+func fallbackText(value *string, fallback string) string {
+ if value == nil {
+ return fallback
+ }
+ trimmed := strings.TrimSpace(*value)
+ if trimmed == "" {
+ return fallback
+ }
+ return trimmed
+}
+
+func valueOrEmpty(value *string) string {
+ if value == nil {
+ return ""
+ }
+ return *value
+}
+
+func containsString(values []string, target string) bool {
+ for _, item := range values {
+ if item == target {
+ return true
+ }
+ }
+ return false
+}
diff --git a/backend/internal/service/ops_auth_service.go b/backend/internal/service/ops_auth_service.go
new file mode 100644
index 0000000..f47f609
--- /dev/null
+++ b/backend/internal/service/ops_auth_service.go
@@ -0,0 +1,395 @@
+package service
+
+import (
+ "context"
+ "net/http"
+ "strings"
+ "time"
+
+ "cmr-backend/internal/apperr"
+ "cmr-backend/internal/platform/jwtx"
+ "cmr-backend/internal/platform/security"
+ "cmr-backend/internal/store/postgres"
+)
+
+type OpsAuthSettings struct {
+ AppEnv string
+ RefreshTTL time.Duration
+ SMSCodeTTL time.Duration
+ SMSCodeCooldown time.Duration
+ SMSProvider string
+ DevSMSCode string
+}
+
+type OpsAuthService struct {
+ cfg OpsAuthSettings
+ store *postgres.Store
+ jwtManager *jwtx.Manager
+}
+
+type OpsSendSMSCodeInput struct {
+ CountryCode string `json:"countryCode"`
+ Mobile string `json:"mobile"`
+ DeviceKey string `json:"deviceKey"`
+ Scene string `json:"scene"`
+}
+
+type OpsRegisterInput struct {
+ CountryCode string `json:"countryCode"`
+ Mobile string `json:"mobile"`
+ Code string `json:"code"`
+ DeviceKey string `json:"deviceKey"`
+ DisplayName string `json:"displayName"`
+}
+
+type OpsLoginSMSInput struct {
+ CountryCode string `json:"countryCode"`
+ Mobile string `json:"mobile"`
+ Code string `json:"code"`
+ DeviceKey string `json:"deviceKey"`
+}
+
+type OpsRefreshTokenInput struct {
+ RefreshToken string `json:"refreshToken"`
+ DeviceKey string `json:"deviceKey"`
+}
+
+type OpsLogoutInput struct {
+ RefreshToken string `json:"refreshToken"`
+}
+
+type OpsAuthUser struct {
+ ID string `json:"id"`
+ PublicID string `json:"publicId"`
+ DisplayName string `json:"displayName"`
+ Status string `json:"status"`
+ RoleCode string `json:"roleCode"`
+}
+
+type OpsAuthResult struct {
+ User OpsAuthUser `json:"user"`
+ Tokens AuthTokens `json:"tokens"`
+ NewUser bool `json:"newUser"`
+ DevLoginBypass bool `json:"devLoginBypass,omitempty"`
+}
+
+func NewOpsAuthService(cfg OpsAuthSettings, store *postgres.Store, jwtManager *jwtx.Manager) *OpsAuthService {
+ return &OpsAuthService{cfg: cfg, store: store, jwtManager: jwtManager}
+}
+
+func (s *OpsAuthService) SendSMSCode(ctx context.Context, input OpsSendSMSCodeInput) (*SendSMSCodeResult, error) {
+ input.CountryCode = normalizeCountryCode(input.CountryCode)
+ input.Mobile = normalizeMobile(input.Mobile)
+ input.Scene = normalizeOpsScene(input.Scene)
+ if input.Mobile == "" || strings.TrimSpace(input.DeviceKey) == "" {
+ return nil, apperr.New(http.StatusBadRequest, "invalid_params", "mobile and deviceKey are required")
+ }
+
+ latest, err := s.store.GetLatestSMSCodeMeta(ctx, input.CountryCode, input.Mobile, "ops", input.Scene)
+ if err != nil {
+ return nil, err
+ }
+ now := time.Now().UTC()
+ if latest != nil && latest.CooldownUntil.After(now) {
+ return nil, apperr.New(http.StatusTooManyRequests, "sms_cooldown", "sms code sent too frequently")
+ }
+
+ code := s.cfg.DevSMSCode
+ if code == "" {
+ code, err = security.GenerateNumericCode(6)
+ if err != nil {
+ return nil, err
+ }
+ }
+ expiresAt := now.Add(s.cfg.SMSCodeTTL)
+ cooldownUntil := now.Add(s.cfg.SMSCodeCooldown)
+ if err := s.store.CreateSMSCode(ctx, postgres.CreateSMSCodeParams{
+ Scene: input.Scene,
+ CountryCode: input.CountryCode,
+ Mobile: input.Mobile,
+ ClientType: "ops",
+ DeviceKey: input.DeviceKey,
+ CodeHash: security.HashText(code),
+ ProviderName: s.cfg.SMSProvider,
+ ProviderDebug: map[string]any{"mode": s.cfg.SMSProvider, "channel": "ops_console"},
+ ExpiresAt: expiresAt,
+ CooldownUntil: cooldownUntil,
+ }); err != nil {
+ return nil, err
+ }
+
+ result := &SendSMSCodeResult{
+ TTLSeconds: int64(s.cfg.SMSCodeTTL.Seconds()),
+ CooldownSeconds: int64(s.cfg.SMSCodeCooldown.Seconds()),
+ }
+ if strings.EqualFold(s.cfg.SMSProvider, "console") || strings.EqualFold(s.cfg.AppEnv, "development") {
+ result.DevCode = &code
+ }
+ return result, nil
+}
+
+func (s *OpsAuthService) Register(ctx context.Context, input OpsRegisterInput) (*OpsAuthResult, error) {
+ input.CountryCode = normalizeCountryCode(input.CountryCode)
+ input.Mobile = normalizeMobile(input.Mobile)
+ input.Code = strings.TrimSpace(input.Code)
+ input.DeviceKey = strings.TrimSpace(input.DeviceKey)
+ input.DisplayName = strings.TrimSpace(input.DisplayName)
+ if input.Mobile == "" || input.Code == "" || input.DeviceKey == "" || input.DisplayName == "" {
+ return nil, apperr.New(http.StatusBadRequest, "invalid_params", "mobile, code, deviceKey and displayName are required")
+ }
+
+ codeRecord, err := s.store.GetLatestValidSMSCode(ctx, input.CountryCode, input.Mobile, "ops", "ops_register")
+ if err != nil {
+ return nil, err
+ }
+ if codeRecord == nil || codeRecord.CodeHash != security.HashText(input.Code) {
+ return nil, apperr.New(http.StatusUnauthorized, "invalid_sms_code", "invalid sms code")
+ }
+
+ tx, err := s.store.Begin(ctx)
+ if err != nil {
+ return nil, err
+ }
+ defer tx.Rollback(ctx)
+
+ consumed, err := s.store.ConsumeSMSCode(ctx, tx, codeRecord.ID)
+ if err != nil {
+ return nil, err
+ }
+ if !consumed {
+ return nil, apperr.New(http.StatusUnauthorized, "invalid_sms_code", "sms code already used")
+ }
+
+ existing, err := s.store.GetOpsUserByMobile(ctx, tx, input.CountryCode, input.Mobile)
+ if err != nil {
+ return nil, err
+ }
+ if existing != nil {
+ return nil, apperr.New(http.StatusConflict, "ops_user_exists", "ops user already exists")
+ }
+
+ publicID, err := security.GeneratePublicID("ops")
+ if err != nil {
+ return nil, err
+ }
+ user, err := s.store.CreateOpsUser(ctx, tx, postgres.CreateOpsUserParams{
+ PublicID: publicID,
+ CountryCode: input.CountryCode,
+ Mobile: input.Mobile,
+ DisplayName: input.DisplayName,
+ Status: "active",
+ })
+ if err != nil {
+ return nil, err
+ }
+
+ roleCode := "operator"
+ count, err := s.store.CountOpsUsers(ctx)
+ if err == nil && count == 0 {
+ roleCode = "owner"
+ }
+ role, err := s.store.GetOpsRoleByCode(ctx, tx, roleCode)
+ if err != nil {
+ return nil, err
+ }
+ if role == nil {
+ return nil, apperr.New(http.StatusInternalServerError, "ops_role_missing", "default ops role is missing")
+ }
+ if err := s.store.AssignOpsRole(ctx, tx, user.ID, role.ID); err != nil {
+ return nil, err
+ }
+ if err := s.store.TouchOpsUserLogin(ctx, tx, user.ID); err != nil {
+ return nil, err
+ }
+
+ result, _, err := s.issueAuthResult(ctx, tx, *user, input.DeviceKey, true)
+ if err != nil {
+ return nil, err
+ }
+ if err := tx.Commit(ctx); err != nil {
+ return nil, err
+ }
+ return result, nil
+}
+
+func (s *OpsAuthService) LoginSMS(ctx context.Context, input OpsLoginSMSInput) (*OpsAuthResult, error) {
+ input.CountryCode = normalizeCountryCode(input.CountryCode)
+ input.Mobile = normalizeMobile(input.Mobile)
+ input.Code = strings.TrimSpace(input.Code)
+ input.DeviceKey = strings.TrimSpace(input.DeviceKey)
+ if input.Mobile == "" || input.Code == "" || input.DeviceKey == "" {
+ return nil, apperr.New(http.StatusBadRequest, "invalid_params", "mobile, code and deviceKey are required")
+ }
+
+ codeRecord, err := s.store.GetLatestValidSMSCode(ctx, input.CountryCode, input.Mobile, "ops", "ops_login")
+ if err != nil {
+ return nil, err
+ }
+ if codeRecord == nil || codeRecord.CodeHash != security.HashText(input.Code) {
+ return nil, apperr.New(http.StatusUnauthorized, "invalid_sms_code", "invalid sms code")
+ }
+
+ tx, err := s.store.Begin(ctx)
+ if err != nil {
+ return nil, err
+ }
+ defer tx.Rollback(ctx)
+
+ consumed, err := s.store.ConsumeSMSCode(ctx, tx, codeRecord.ID)
+ if err != nil {
+ return nil, err
+ }
+ if !consumed {
+ return nil, apperr.New(http.StatusUnauthorized, "invalid_sms_code", "sms code already used")
+ }
+
+ user, err := s.store.GetOpsUserByMobile(ctx, tx, input.CountryCode, input.Mobile)
+ if err != nil {
+ return nil, err
+ }
+ if user == nil {
+ return nil, apperr.New(http.StatusNotFound, "ops_user_not_found", "ops user not found")
+ }
+ if user.Status != "active" {
+ return nil, apperr.New(http.StatusForbidden, "ops_user_inactive", "ops user is not active")
+ }
+ if err := s.store.TouchOpsUserLogin(ctx, tx, user.ID); err != nil {
+ return nil, err
+ }
+ result, _, err := s.issueAuthResult(ctx, tx, *user, input.DeviceKey, false)
+ if err != nil {
+ return nil, err
+ }
+ if err := tx.Commit(ctx); err != nil {
+ return nil, err
+ }
+ return result, nil
+}
+
+func (s *OpsAuthService) Refresh(ctx context.Context, input OpsRefreshTokenInput) (*OpsAuthResult, error) {
+ input.RefreshToken = strings.TrimSpace(input.RefreshToken)
+ if input.RefreshToken == "" {
+ return nil, apperr.New(http.StatusBadRequest, "invalid_params", "refreshToken is required")
+ }
+ tx, err := s.store.Begin(ctx)
+ if err != nil {
+ return nil, err
+ }
+ defer tx.Rollback(ctx)
+
+ record, err := s.store.GetOpsRefreshTokenForUpdate(ctx, tx, security.HashText(input.RefreshToken))
+ if err != nil {
+ return nil, err
+ }
+ if record == nil || record.IsRevoked || record.ExpiresAt.Before(time.Now().UTC()) {
+ return nil, apperr.New(http.StatusUnauthorized, "invalid_refresh_token", "refresh token is invalid or expired")
+ }
+ if input.DeviceKey != "" && record.DeviceKey != nil && input.DeviceKey != *record.DeviceKey {
+ return nil, apperr.New(http.StatusUnauthorized, "invalid_refresh_token", "refresh token device mismatch")
+ }
+ user, err := s.store.GetOpsUserByID(ctx, tx, record.OpsUserID)
+ if err != nil {
+ return nil, err
+ }
+ if user == nil || user.Status != "active" {
+ return nil, apperr.New(http.StatusUnauthorized, "invalid_refresh_token", "refresh token user not found")
+ }
+ result, newTokenID, err := s.issueAuthResult(ctx, tx, *user, nullableStringValue(record.DeviceKey), false)
+ if err != nil {
+ return nil, err
+ }
+ if err := s.store.RotateOpsRefreshToken(ctx, tx, record.ID, newTokenID); err != nil {
+ return nil, err
+ }
+ if err := tx.Commit(ctx); err != nil {
+ return nil, err
+ }
+ return result, nil
+}
+
+func (s *OpsAuthService) Logout(ctx context.Context, input OpsLogoutInput) error {
+ if strings.TrimSpace(input.RefreshToken) == "" {
+ return nil
+ }
+ return s.store.RevokeOpsRefreshToken(ctx, security.HashText(strings.TrimSpace(input.RefreshToken)))
+}
+
+func (s *OpsAuthService) GetMe(ctx context.Context, opsUserID string) (*OpsAuthUser, error) {
+ user, err := s.store.GetOpsUserByID(ctx, s.store.Pool(), opsUserID)
+ if err != nil {
+ return nil, err
+ }
+ if user == nil {
+ return nil, apperr.New(http.StatusNotFound, "ops_user_not_found", "ops user not found")
+ }
+ role, err := s.store.GetPrimaryOpsRole(ctx, s.store.Pool(), user.ID)
+ if err != nil {
+ return nil, err
+ }
+ result := buildOpsAuthUser(*user, role)
+ return &result, nil
+}
+
+func (s *OpsAuthService) issueAuthResult(ctx context.Context, tx postgres.Tx, user postgres.OpsUser, deviceKey string, newUser bool) (*OpsAuthResult, string, error) {
+ role, err := s.store.GetPrimaryOpsRole(ctx, tx, user.ID)
+ if err != nil {
+ return nil, "", err
+ }
+ roleCode := ""
+ if role != nil {
+ roleCode = role.RoleCode
+ }
+ accessToken, accessExpiresAt, err := s.jwtManager.IssueActorAccessToken(user.ID, user.PublicID, "ops", roleCode)
+ if err != nil {
+ return nil, "", err
+ }
+ refreshToken, err := security.GenerateToken(32)
+ if err != nil {
+ return nil, "", err
+ }
+ refreshTokenHash := security.HashText(refreshToken)
+ refreshExpiresAt := time.Now().UTC().Add(s.cfg.RefreshTTL)
+ refreshID, err := s.store.CreateOpsRefreshToken(ctx, tx, postgres.CreateOpsRefreshTokenParams{
+ OpsUserID: user.ID,
+ DeviceKey: deviceKey,
+ TokenHash: refreshTokenHash,
+ ExpiresAt: refreshExpiresAt,
+ })
+ if err != nil {
+ return nil, "", err
+ }
+ result := &OpsAuthResult{
+ User: buildOpsAuthUser(user, role),
+ Tokens: AuthTokens{
+ AccessToken: accessToken,
+ AccessTokenExpiresAt: accessExpiresAt.Format(time.RFC3339),
+ RefreshToken: refreshToken,
+ RefreshTokenExpiresAt: refreshExpiresAt.Format(time.RFC3339),
+ },
+ NewUser: newUser,
+ }
+ return result, refreshID, nil
+}
+
+func buildOpsAuthUser(user postgres.OpsUser, role *postgres.OpsRole) OpsAuthUser {
+ roleCode := ""
+ if role != nil {
+ roleCode = role.RoleCode
+ }
+ return OpsAuthUser{
+ ID: user.ID,
+ PublicID: user.PublicID,
+ DisplayName: user.DisplayName,
+ Status: user.Status,
+ RoleCode: roleCode,
+ }
+}
+
+func normalizeOpsScene(value string) string {
+ switch strings.TrimSpace(value) {
+ case "ops_register":
+ return "ops_register"
+ default:
+ return "ops_login"
+ }
+}
diff --git a/backend/internal/service/ops_summary_service.go b/backend/internal/service/ops_summary_service.go
new file mode 100644
index 0000000..b5af87e
--- /dev/null
+++ b/backend/internal/service/ops_summary_service.go
@@ -0,0 +1,57 @@
+package service
+
+import (
+ "context"
+
+ "cmr-backend/internal/store/postgres"
+)
+
+type OpsOverviewSummary struct {
+ ManagedAssets int `json:"managedAssets"`
+ Places int `json:"places"`
+ MapAssets int `json:"mapAssets"`
+ TileReleases int `json:"tileReleases"`
+ CourseSets int `json:"courseSets"`
+ CourseVariants int `json:"courseVariants"`
+ Events int `json:"events"`
+ DefaultEvents int `json:"defaultEvents"`
+ PublishedEvents int `json:"publishedEvents"`
+ ConfigSources int `json:"configSources"`
+ Releases int `json:"releases"`
+ RuntimeBindings int `json:"runtimeBindings"`
+ Presentations int `json:"presentations"`
+ ContentBundles int `json:"contentBundles"`
+ OpsUsers int `json:"opsUsers"`
+}
+
+type OpsSummaryService struct {
+ store *postgres.Store
+}
+
+func NewOpsSummaryService(store *postgres.Store) *OpsSummaryService {
+ return &OpsSummaryService{store: store}
+}
+
+func (s *OpsSummaryService) GetOverview(ctx context.Context) (*OpsOverviewSummary, error) {
+ counts, err := s.store.GetOpsOverviewCounts(ctx)
+ if err != nil {
+ return nil, err
+ }
+ return &OpsOverviewSummary{
+ ManagedAssets: counts.ManagedAssets,
+ Places: counts.Places,
+ MapAssets: counts.MapAssets,
+ TileReleases: counts.TileReleases,
+ CourseSets: counts.CourseSets,
+ CourseVariants: counts.CourseVariants,
+ Events: counts.Events,
+ DefaultEvents: counts.DefaultEvents,
+ PublishedEvents: counts.PublishedEvents,
+ ConfigSources: counts.ConfigSources,
+ Releases: counts.Releases,
+ RuntimeBindings: counts.RuntimeBindings,
+ Presentations: counts.Presentations,
+ ContentBundles: counts.ContentBundles,
+ OpsUsers: counts.OpsUsers,
+ }, nil
+}
diff --git a/backend/internal/service/preview_contract.go b/backend/internal/service/preview_contract.go
new file mode 100644
index 0000000..4a327b1
--- /dev/null
+++ b/backend/internal/service/preview_contract.go
@@ -0,0 +1,222 @@
+package service
+
+import "strings"
+
+type MapPreviewView struct {
+ Mode string `json:"mode"`
+ BaseTiles *PreviewBaseTiles `json:"baseTiles,omitempty"`
+ Viewport *PreviewViewport `json:"viewport,omitempty"`
+ Variants []PreviewVariantView `json:"variants,omitempty"`
+ SelectedVariantID *string `json:"selectedVariantId,omitempty"`
+}
+
+type PreviewBaseTiles struct {
+ TileBaseURL string `json:"tileBaseUrl"`
+ Zoom *int `json:"zoom,omitempty"`
+ TileSize *int `json:"tileSize,omitempty"`
+}
+
+type PreviewViewport struct {
+ Width *int `json:"width,omitempty"`
+ Height *int `json:"height,omitempty"`
+ MinLon *float64 `json:"minLon,omitempty"`
+ MinLat *float64 `json:"minLat,omitempty"`
+ MaxLon *float64 `json:"maxLon,omitempty"`
+ MaxLat *float64 `json:"maxLat,omitempty"`
+}
+
+type PreviewVariantView struct {
+ VariantID string `json:"variantId"`
+ Name *string `json:"name,omitempty"`
+ RouteCode *string `json:"routeCode,omitempty"`
+ Controls []PreviewControlView `json:"controls,omitempty"`
+ Legs []PreviewLegView `json:"legs,omitempty"`
+}
+
+type PreviewControlView struct {
+ ID string `json:"id"`
+ Kind *string `json:"kind,omitempty"`
+ Lon *float64 `json:"lon,omitempty"`
+ Lat *float64 `json:"lat,omitempty"`
+ Label *string `json:"label,omitempty"`
+}
+
+type PreviewLegView struct {
+ From string `json:"from"`
+ To string `json:"to"`
+}
+
+func buildPreviewFromPayload(payloadJSON *string) (*MapPreviewView, error) {
+ if payloadJSON == nil || strings.TrimSpace(*payloadJSON) == "" {
+ return nil, nil
+ }
+
+ payload, err := decodeJSONObject(*payloadJSON)
+ if err != nil {
+ return nil, err
+ }
+ rawPreview, _ := payload["preview"].(map[string]any)
+ if len(rawPreview) == 0 {
+ return nil, nil
+ }
+
+ view := &MapPreviewView{}
+ if mode := readStringField(rawPreview, "mode"); mode != nil {
+ view.Mode = *mode
+ }
+ if view.Mode == "" {
+ view.Mode = "readonly"
+ }
+
+ if rawBaseTiles, ok := rawPreview["baseTiles"].(map[string]any); ok && len(rawBaseTiles) > 0 {
+ baseTiles := &PreviewBaseTiles{}
+ if tileBaseURL := readStringField(rawBaseTiles, "tileBaseUrl"); tileBaseURL != nil {
+ baseTiles.TileBaseURL = *tileBaseURL
+ }
+ baseTiles.Zoom = readIntField(rawBaseTiles, "zoom")
+ baseTiles.TileSize = readIntField(rawBaseTiles, "tileSize")
+ if strings.TrimSpace(baseTiles.TileBaseURL) != "" {
+ view.BaseTiles = baseTiles
+ }
+ }
+
+ if rawViewport, ok := rawPreview["viewport"].(map[string]any); ok && len(rawViewport) > 0 {
+ viewport := &PreviewViewport{
+ Width: readIntField(rawViewport, "width"),
+ Height: readIntField(rawViewport, "height"),
+ MinLon: readFloatField(rawViewport, "minLon"),
+ MinLat: readFloatField(rawViewport, "minLat"),
+ MaxLon: readFloatField(rawViewport, "maxLon"),
+ MaxLat: readFloatField(rawViewport, "maxLat"),
+ }
+ view.Viewport = viewport
+ }
+
+ if selectedVariantID := readStringField(rawPreview, "selectedVariantId"); selectedVariantID != nil {
+ view.SelectedVariantID = selectedVariantID
+ }
+
+ rawVariants, _ := rawPreview["variants"].([]any)
+ if len(rawVariants) > 0 {
+ view.Variants = make([]PreviewVariantView, 0, len(rawVariants))
+ for _, raw := range rawVariants {
+ item, ok := raw.(map[string]any)
+ if !ok {
+ continue
+ }
+ variantID := readStringField(item, "variantId")
+ if variantID == nil || strings.TrimSpace(*variantID) == "" {
+ variantID = readStringField(item, "id")
+ }
+ if variantID == nil || strings.TrimSpace(*variantID) == "" {
+ continue
+ }
+ variant := PreviewVariantView{
+ VariantID: *variantID,
+ Name: readStringField(item, "name"),
+ RouteCode: readStringField(item, "routeCode"),
+ }
+ rawControls, _ := item["controls"].([]any)
+ if len(rawControls) > 0 {
+ variant.Controls = make([]PreviewControlView, 0, len(rawControls))
+ for _, rawControl := range rawControls {
+ controlMap, ok := rawControl.(map[string]any)
+ if !ok {
+ continue
+ }
+ controlID := readStringField(controlMap, "id")
+ if controlID == nil || strings.TrimSpace(*controlID) == "" {
+ continue
+ }
+ variant.Controls = append(variant.Controls, PreviewControlView{
+ ID: *controlID,
+ Kind: readStringField(controlMap, "kind"),
+ Lon: readFloatField(controlMap, "lon"),
+ Lat: readFloatField(controlMap, "lat"),
+ Label: readStringField(controlMap, "label"),
+ })
+ }
+ }
+ rawLegs, _ := item["legs"].([]any)
+ if len(rawLegs) > 0 {
+ variant.Legs = make([]PreviewLegView, 0, len(rawLegs))
+ for _, rawLeg := range rawLegs {
+ legMap, ok := rawLeg.(map[string]any)
+ if !ok {
+ continue
+ }
+ from := readStringField(legMap, "from")
+ to := readStringField(legMap, "to")
+ if from == nil || to == nil || strings.TrimSpace(*from) == "" || strings.TrimSpace(*to) == "" {
+ continue
+ }
+ variant.Legs = append(variant.Legs, PreviewLegView{
+ From: *from,
+ To: *to,
+ })
+ }
+ }
+ view.Variants = append(view.Variants, variant)
+ }
+ }
+
+ if view.BaseTiles == nil && view.Viewport == nil && len(view.Variants) == 0 {
+ return nil, nil
+ }
+ return view, nil
+}
+
+func readIntField(object map[string]any, key string) *int {
+ if object == nil {
+ return nil
+ }
+ value, ok := object[key]
+ if !ok {
+ return nil
+ }
+ switch v := value.(type) {
+ case int:
+ result := v
+ return &result
+ case int32:
+ result := int(v)
+ return &result
+ case int64:
+ result := int(v)
+ return &result
+ case float64:
+ result := int(v)
+ return &result
+ default:
+ return nil
+ }
+}
+
+func readFloatField(object map[string]any, key string) *float64 {
+ if object == nil {
+ return nil
+ }
+ value, ok := object[key]
+ if !ok {
+ return nil
+ }
+ switch v := value.(type) {
+ case float64:
+ result := v
+ return &result
+ case float32:
+ result := float64(v)
+ return &result
+ case int:
+ result := float64(v)
+ return &result
+ case int32:
+ result := float64(v)
+ return &result
+ case int64:
+ result := float64(v)
+ return &result
+ default:
+ return nil
+ }
+}
diff --git a/backend/internal/service/public_experience_service.go b/backend/internal/service/public_experience_service.go
new file mode 100644
index 0000000..53ad846
--- /dev/null
+++ b/backend/internal/service/public_experience_service.go
@@ -0,0 +1,303 @@
+package service
+
+import (
+ "context"
+ "net/http"
+ "strings"
+ "time"
+
+ "cmr-backend/internal/apperr"
+ "cmr-backend/internal/platform/security"
+ "cmr-backend/internal/store/postgres"
+)
+
+const (
+ GuestLaunchSource = "public-default-experience"
+ GuestIdentityProvider = "guest_device"
+ GuestIdentityType = "guest"
+)
+
+type PublicExperienceService struct {
+ store *postgres.Store
+ mapService *MapExperienceService
+ eventService *EventService
+}
+
+type PublicEventPlayInput struct {
+ EventPublicID string
+}
+
+type PublicLaunchEventInput struct {
+ EventPublicID string `json:"-"`
+ ReleaseID string `json:"releaseId,omitempty"`
+ VariantID string `json:"variantId,omitempty"`
+ ClientType string `json:"clientType"`
+ DeviceKey string `json:"deviceKey"`
+}
+
+func NewPublicExperienceService(store *postgres.Store, mapService *MapExperienceService, eventService *EventService) *PublicExperienceService {
+ return &PublicExperienceService{
+ store: store,
+ mapService: mapService,
+ eventService: eventService,
+ }
+}
+
+func (s *PublicExperienceService) ListMaps(ctx context.Context, input ListExperienceMapsInput) ([]ExperienceMapSummary, error) {
+ return s.mapService.ListMaps(ctx, input)
+}
+
+func (s *PublicExperienceService) GetMapDetail(ctx context.Context, mapPublicID string) (*ExperienceMapDetail, error) {
+ return s.mapService.GetMapDetail(ctx, mapPublicID)
+}
+
+func (s *PublicExperienceService) GetEventDetail(ctx context.Context, eventPublicID string) (*EventDetailResult, error) {
+ event, err := s.store.GetEventByPublicID(ctx, strings.TrimSpace(eventPublicID))
+ if err != nil {
+ return nil, err
+ }
+ if err := ensurePublicExperienceEvent(event); err != nil {
+ return nil, err
+ }
+ return s.eventService.GetEventDetail(ctx, eventPublicID)
+}
+
+func (s *PublicExperienceService) GetEventPlay(ctx context.Context, input PublicEventPlayInput) (*EventPlayResult, error) {
+ input.EventPublicID = strings.TrimSpace(input.EventPublicID)
+ if input.EventPublicID == "" {
+ return nil, apperr.New(http.StatusBadRequest, "invalid_params", "event id is required")
+ }
+
+ event, err := s.store.GetEventByPublicID(ctx, input.EventPublicID)
+ if err != nil {
+ return nil, err
+ }
+ if err := ensurePublicExperienceEvent(event); err != nil {
+ return nil, err
+ }
+
+ result := &EventPlayResult{}
+ result.Event.ID = event.PublicID
+ result.Event.Slug = event.Slug
+ result.Event.DisplayName = event.DisplayName
+ result.Event.Summary = event.Summary
+ result.Event.Status = event.Status
+ variantPlan := resolveVariantPlan(event.ReleasePayloadJSON)
+ result.Play.AssignmentMode = variantPlan.AssignmentMode
+ if len(variantPlan.CourseVariants) > 0 {
+ result.Play.CourseVariants = variantPlan.CourseVariants
+ }
+ if event.CurrentReleasePubID != nil && event.ConfigLabel != nil && event.ManifestURL != nil {
+ result.Release = &struct {
+ ID string `json:"id"`
+ ConfigLabel string `json:"configLabel"`
+ ManifestURL string `json:"manifestUrl"`
+ ManifestChecksumSha256 *string `json:"manifestChecksumSha256,omitempty"`
+ RouteCode *string `json:"routeCode,omitempty"`
+ }{
+ ID: *event.CurrentReleasePubID,
+ ConfigLabel: *event.ConfigLabel,
+ ManifestURL: *event.ManifestURL,
+ ManifestChecksumSha256: event.ManifestChecksum,
+ RouteCode: event.RouteCode,
+ }
+ }
+ result.ResolvedRelease = buildResolvedReleaseFromEvent(event, GuestLaunchSource)
+ result.Runtime = buildRuntimeSummaryFromEvent(event)
+ if preview, err := buildPreviewFromPayload(event.ReleasePayloadJSON); err != nil {
+ return nil, err
+ } else {
+ result.Preview = preview
+ }
+ result.CurrentPresentation = buildPresentationSummaryFromEvent(event)
+ if enrichedPresentation, err := loadPresentationSummaryByPublicID(ctx, s.store, event.PresentationID); err != nil {
+ return nil, err
+ } else if enrichedPresentation != nil {
+ result.CurrentPresentation = enrichedPresentation
+ }
+ result.CurrentContentBundle = buildContentBundleSummaryFromEvent(event)
+ if enrichedBundle, err := loadContentBundleSummaryByPublicID(ctx, s.store, event.ContentBundleID); err != nil {
+ return nil, err
+ } else if enrichedBundle != nil {
+ result.CurrentContentBundle = enrichedBundle
+ }
+
+ canLaunch, launchReason := evaluateEventLaunchReadiness(event)
+ result.Play.CanLaunch = canLaunch
+ if canLaunch {
+ result.Play.LaunchSource = GuestLaunchSource
+ result.Play.PrimaryAction = "start"
+ result.Play.Reason = "guest can start default experience"
+ return result, nil
+ }
+ result.Play.PrimaryAction = "unavailable"
+ result.Play.Reason = launchReason
+ return result, nil
+}
+
+func (s *PublicExperienceService) LaunchEvent(ctx context.Context, input PublicLaunchEventInput) (*LaunchEventResult, error) {
+ input.EventPublicID = strings.TrimSpace(input.EventPublicID)
+ input.ReleaseID = strings.TrimSpace(input.ReleaseID)
+ input.VariantID = strings.TrimSpace(input.VariantID)
+ input.DeviceKey = strings.TrimSpace(input.DeviceKey)
+ if input.EventPublicID == "" || input.DeviceKey == "" {
+ return nil, apperr.New(http.StatusBadRequest, "invalid_params", "event id and deviceKey are required")
+ }
+ if err := validateClientType(input.ClientType); err != nil {
+ return nil, err
+ }
+
+ event, err := s.store.GetEventByPublicID(ctx, input.EventPublicID)
+ if err != nil {
+ return nil, err
+ }
+ if err := ensurePublicExperienceEvent(event); err != nil {
+ return nil, err
+ }
+ if canLaunch, reason := evaluateEventLaunchReadiness(event); !canLaunch {
+ return nil, launchReadinessError(reason)
+ }
+ if input.ReleaseID != "" && event.CurrentReleasePubID != nil && input.ReleaseID != *event.CurrentReleasePubID {
+ return nil, apperr.New(http.StatusConflict, "release_not_launchable", "requested release is not the current published release")
+ }
+
+ variantPlan := resolveVariantPlan(event.ReleasePayloadJSON)
+ variant, err := resolveLaunchVariant(variantPlan, input.VariantID)
+ if err != nil {
+ return nil, err
+ }
+ routeCode := event.RouteCode
+ var assignmentMode *string
+ var variantID *string
+ var variantName *string
+ if variant != nil {
+ resultMode := variant.AssignmentMode
+ assignmentMode = &resultMode
+ variantID = &variant.ID
+ variantName = &variant.Name
+ if variant.RouteCode != nil {
+ routeCode = variant.RouteCode
+ }
+ }
+
+ tx, err := s.store.Begin(ctx)
+ if err != nil {
+ return nil, err
+ }
+ defer tx.Rollback(ctx)
+
+ guestUser, err := s.findOrCreateGuestUser(ctx, tx, input.ClientType, input.DeviceKey)
+ if err != nil {
+ return nil, err
+ }
+ if err := s.store.TouchUserLogin(ctx, tx, guestUser.ID); err != nil {
+ return nil, err
+ }
+
+ sessionPublicID, err := security.GeneratePublicID("sess")
+ if err != nil {
+ return nil, err
+ }
+ sessionToken, err := security.GenerateToken(32)
+ if err != nil {
+ return nil, err
+ }
+ sessionTokenExpiresAt := time.Now().UTC().Add(2 * time.Hour)
+
+ session, err := s.store.CreateGameSession(ctx, tx, postgres.CreateGameSessionParams{
+ SessionPublicID: sessionPublicID,
+ UserID: guestUser.ID,
+ EventID: event.ID,
+ EventReleaseID: *event.CurrentReleaseID,
+ DeviceKey: input.DeviceKey,
+ ClientType: input.ClientType,
+ AssignmentMode: assignmentMode,
+ VariantID: variantID,
+ VariantName: variantName,
+ RouteCode: routeCode,
+ SessionTokenHash: security.HashText(sessionToken),
+ SessionTokenExpiresAt: sessionTokenExpiresAt,
+ })
+ if err != nil {
+ return nil, err
+ }
+
+ if err := tx.Commit(ctx); err != nil {
+ return nil, err
+ }
+
+ result := &LaunchEventResult{}
+ result.Event.ID = event.PublicID
+ result.Event.DisplayName = event.DisplayName
+ result.Launch.Source = GuestLaunchSource
+ result.Launch.ResolvedRelease = buildResolvedReleaseFromEvent(event, GuestLaunchSource)
+ result.Launch.Variant = variant
+ result.Launch.Runtime = buildRuntimeSummaryFromEvent(event)
+ result.Launch.Presentation = buildPresentationSummaryFromEvent(event)
+ if enrichedPresentation, err := loadPresentationSummaryByPublicID(ctx, s.store, event.PresentationID); err != nil {
+ return nil, err
+ } else if enrichedPresentation != nil {
+ result.Launch.Presentation = enrichedPresentation
+ }
+ result.Launch.ContentBundle = buildContentBundleSummaryFromEvent(event)
+ if enrichedBundle, err := loadContentBundleSummaryByPublicID(ctx, s.store, event.ContentBundleID); err != nil {
+ return nil, err
+ } else if enrichedBundle != nil {
+ result.Launch.ContentBundle = enrichedBundle
+ }
+ result.Launch.Config.ConfigURL = *event.ManifestURL
+ result.Launch.Config.ConfigLabel = *event.ConfigLabel
+ result.Launch.Config.ConfigChecksumSha256 = event.ManifestChecksum
+ result.Launch.Config.ReleaseID = *event.CurrentReleasePubID
+ result.Launch.Config.RouteCode = routeCode
+ result.Launch.Business.Source = GuestLaunchSource
+ result.Launch.Business.EventID = event.PublicID
+ result.Launch.Business.SessionID = session.SessionPublicID
+ result.Launch.Business.SessionToken = sessionToken
+ result.Launch.Business.SessionTokenExpiresAt = session.SessionTokenExpiresAt.Format(time.RFC3339)
+ result.Launch.Business.RouteCode = routeCode
+ result.Launch.Business.IsGuest = true
+ return result, nil
+}
+
+func (s *PublicExperienceService) findOrCreateGuestUser(ctx context.Context, tx postgres.Tx, clientType, deviceKey string) (*postgres.User, error) {
+ providerSubject := clientType + ":" + deviceKey
+ user, err := s.store.FindUserByProviderSubject(ctx, tx, GuestIdentityProvider, providerSubject)
+ if err != nil {
+ return nil, err
+ }
+ if user != nil {
+ return user, nil
+ }
+ userPublicID, err := security.GeneratePublicID("usr")
+ if err != nil {
+ return nil, err
+ }
+ user, err = s.store.CreateUser(ctx, tx, postgres.CreateUserParams{
+ PublicID: userPublicID,
+ Status: "active",
+ })
+ if err != nil {
+ return nil, err
+ }
+ if err := s.store.CreateIdentity(ctx, tx, postgres.CreateIdentityParams{
+ UserID: user.ID,
+ IdentityType: GuestIdentityType,
+ Provider: GuestIdentityProvider,
+ ProviderSubj: providerSubject,
+ ProfileJSON: "{}",
+ }); err != nil {
+ return nil, err
+ }
+ return user, nil
+}
+
+func ensurePublicExperienceEvent(event *postgres.Event) error {
+ if event == nil {
+ return apperr.New(http.StatusNotFound, "event_not_found", "event not found")
+ }
+ if !event.IsDefaultExperience {
+ return apperr.New(http.StatusForbidden, "event_not_public", "event is not available in guest mode")
+ }
+ return nil
+}
diff --git a/backend/internal/store/postgres/asset_store.go b/backend/internal/store/postgres/asset_store.go
new file mode 100644
index 0000000..9c7ac0c
--- /dev/null
+++ b/backend/internal/store/postgres/asset_store.go
@@ -0,0 +1,135 @@
+package postgres
+
+import (
+ "context"
+
+ "github.com/jackc/pgx/v5"
+)
+
+type ManagedAssetRecord struct {
+ ID string
+ PublicID string
+ AssetType string
+ AssetCode string
+ Version string
+ Title *string
+ SourceMode string
+ StorageProvider string
+ ObjectKey *string
+ PublicURL string
+ FileName *string
+ ContentType *string
+ FileSizeBytes *int64
+ ChecksumSHA256 *string
+ Status string
+ MetadataJSONB map[string]any
+}
+
+type CreateManagedAssetParams struct {
+ PublicID string
+ AssetType string
+ AssetCode string
+ Version string
+ Title *string
+ SourceMode string
+ StorageProvider string
+ ObjectKey *string
+ PublicURL string
+ FileName *string
+ ContentType *string
+ FileSizeBytes *int64
+ ChecksumSHA256 *string
+ Status string
+ MetadataJSONB map[string]any
+}
+
+func (s *Store) CreateManagedAsset(ctx context.Context, tx pgx.Tx, params CreateManagedAssetParams) (*ManagedAssetRecord, error) {
+ row := tx.QueryRow(ctx, `
+ INSERT INTO managed_assets (
+ asset_public_id, asset_type, asset_code, version, title, source_mode, storage_provider,
+ object_key, public_url, file_name, content_type, file_size_bytes, checksum_sha256, status, metadata_jsonb
+ ) VALUES (
+ $1, $2, $3, $4, $5, $6, $7,
+ $8, $9, $10, $11, $12, $13, $14, COALESCE($15, '{}'::jsonb)
+ )
+ RETURNING id, asset_public_id, asset_type, asset_code, version, title, source_mode, storage_provider,
+ object_key, public_url, file_name, content_type, file_size_bytes, checksum_sha256, status, metadata_jsonb
+ `,
+ params.PublicID, params.AssetType, params.AssetCode, params.Version, params.Title, params.SourceMode, params.StorageProvider,
+ params.ObjectKey, params.PublicURL, params.FileName, params.ContentType, params.FileSizeBytes, params.ChecksumSHA256, params.Status, params.MetadataJSONB,
+ )
+ return scanManagedAsset(row)
+}
+
+func (s *Store) ListManagedAssets(ctx context.Context, limit int) ([]ManagedAssetRecord, error) {
+ if limit <= 0 {
+ limit = 20
+ }
+ rows, err := s.pool.Query(ctx, `
+ SELECT id, asset_public_id, asset_type, asset_code, version, title, source_mode, storage_provider,
+ object_key, public_url, file_name, content_type, file_size_bytes, checksum_sha256, status, metadata_jsonb
+ FROM managed_assets
+ ORDER BY created_at DESC
+ LIMIT $1
+ `, limit)
+ if err != nil {
+ return nil, err
+ }
+ defer rows.Close()
+
+ var items []ManagedAssetRecord
+ for rows.Next() {
+ record, err := scanManagedAsset(rows)
+ if err != nil {
+ return nil, err
+ }
+ items = append(items, *record)
+ }
+ return items, rows.Err()
+}
+
+func (s *Store) GetManagedAssetByPublicID(ctx context.Context, publicID string) (*ManagedAssetRecord, error) {
+ row := s.pool.QueryRow(ctx, `
+ SELECT id, asset_public_id, asset_type, asset_code, version, title, source_mode, storage_provider,
+ object_key, public_url, file_name, content_type, file_size_bytes, checksum_sha256, status, metadata_jsonb
+ FROM managed_assets
+ WHERE asset_public_id = $1
+ `, publicID)
+ return scanManagedAsset(row)
+}
+
+type managedAssetScanner interface {
+ Scan(dest ...any) error
+}
+
+func scanManagedAsset(scanner managedAssetScanner) (*ManagedAssetRecord, error) {
+ var record ManagedAssetRecord
+ err := scanner.Scan(
+ &record.ID,
+ &record.PublicID,
+ &record.AssetType,
+ &record.AssetCode,
+ &record.Version,
+ &record.Title,
+ &record.SourceMode,
+ &record.StorageProvider,
+ &record.ObjectKey,
+ &record.PublicURL,
+ &record.FileName,
+ &record.ContentType,
+ &record.FileSizeBytes,
+ &record.ChecksumSHA256,
+ &record.Status,
+ &record.MetadataJSONB,
+ )
+ if err != nil {
+ if err == pgx.ErrNoRows {
+ return nil, nil
+ }
+ return nil, err
+ }
+ if record.MetadataJSONB == nil {
+ record.MetadataJSONB = map[string]any{}
+ }
+ return &record, nil
+}
diff --git a/backend/internal/store/postgres/card_store.go b/backend/internal/store/postgres/card_store.go
index 3da12f5..5afcce5 100644
--- a/backend/internal/store/postgres/card_store.go
+++ b/backend/internal/store/postgres/card_store.go
@@ -16,6 +16,7 @@ type Card struct {
DisplaySlot string
DisplayPriority int
IsDefaultExperience bool
+ ShowInEventList bool
StartsAt *time.Time
EndsAt *time.Time
EntryChannelID *string
@@ -59,6 +60,7 @@ func (s *Store) ListCardsForEntry(ctx context.Context, tenantID string, entryCha
c.display_slot,
c.display_priority,
c.is_default_experience,
+ COALESCE(e.show_in_event_list, true),
c.starts_at,
c.ends_at,
c.entry_channel_id,
@@ -117,6 +119,7 @@ func (s *Store) ListCardsForEntry(ctx context.Context, tenantID string, entryCha
&card.DisplaySlot,
&card.DisplayPriority,
&card.IsDefaultExperience,
+ &card.ShowInEventList,
&card.StartsAt,
&card.EndsAt,
&card.EntryChannelID,
diff --git a/backend/internal/store/postgres/dev_store.go b/backend/internal/store/postgres/dev_store.go
index 57c73c1..74a8a5b 100644
--- a/backend/internal/store/postgres/dev_store.go
+++ b/backend/internal/store/postgres/dev_store.go
@@ -78,15 +78,17 @@ func (s *Store) EnsureDemoData(ctx context.Context) (*DemoBootstrapSummary, erro
var eventID string
if err := tx.QueryRow(ctx, `
INSERT INTO events (
- tenant_id, event_public_id, slug, display_name, summary, status
+ tenant_id, event_public_id, slug, display_name, summary, status, is_default_experience, show_in_event_list
)
- VALUES ($1, 'evt_demo_001', 'city-park-classic', '领秀城公园顺序赛', '顺序赛联调样例活动', 'active')
+ VALUES ($1, 'evt_demo_001', 'city-park-classic', '领秀城公园顺序赛', '顺序赛联调样例活动', 'active', true, true)
ON CONFLICT (event_public_id) DO UPDATE SET
tenant_id = EXCLUDED.tenant_id,
slug = EXCLUDED.slug,
display_name = EXCLUDED.display_name,
summary = EXCLUDED.summary,
- status = EXCLUDED.status
+ status = EXCLUDED.status,
+ is_default_experience = EXCLUDED.is_default_experience,
+ show_in_event_list = EXCLUDED.show_in_event_list
RETURNING id
`, tenantID).Scan(&eventID); err != nil {
return nil, fmt.Errorf("ensure demo event: %w", err)
@@ -105,17 +107,19 @@ func (s *Store) EnsureDemoData(ctx context.Context) (*DemoBootstrapSummary, erro
manifest_url,
manifest_checksum_sha256,
route_code,
- status
+ status,
+ payload_jsonb
)
VALUES (
'rel_demo_001',
$1,
1,
'顺序赛联调配置 v1',
- 'https://oss-mbh5.colormaprun.com/gotomars/event/releases/evt_demo_001/rel_e7dd953743c5c0d2/manifest.json',
+ 'https://api.gotomars.xyz/dev/demo-assets/manifests/classic',
'demo-checksum-001',
'route-demo-001',
- 'published'
+ 'published',
+ $2::jsonb
)
ON CONFLICT (release_public_id) DO UPDATE SET
event_id = EXCLUDED.event_id,
@@ -123,9 +127,52 @@ func (s *Store) EnsureDemoData(ctx context.Context) (*DemoBootstrapSummary, erro
manifest_url = EXCLUDED.manifest_url,
manifest_checksum_sha256 = EXCLUDED.manifest_checksum_sha256,
route_code = EXCLUDED.route_code,
- status = EXCLUDED.status
+ status = EXCLUDED.status,
+ payload_jsonb = EXCLUDED.payload_jsonb
RETURNING id, release_public_id
- `, eventID).Scan(&releaseRow.ID, &releaseRow.PublicID); err != nil {
+ `, eventID, `{
+ "schemaVersion": "1",
+ "preview": {
+ "mode": "course-preview",
+ "baseTiles": {
+ "tileBaseUrl": "https://oss-mbh5.colormaprun.com/gotomars/map/lxcb-001/tiles/",
+ "zoom": 16,
+ "tileSize": 256
+ },
+ "viewport": {
+ "width": 375,
+ "height": 220,
+ "minLon": 117.1001,
+ "minLat": 36.6501,
+ "maxLon": 117.1089,
+ "maxLat": 36.6578
+ },
+ "selectedVariantId": "classic_main",
+ "variants": [
+ {
+ "id": "classic_main",
+ "name": "顺序赛主路线",
+ "controls": [
+ {"id": "31", "code": "31", "lon": 117.1012, "lat": 36.6512},
+ {"id": "32", "code": "32", "lon": 117.1034, "lat": 36.6529},
+ {"id": "33", "code": "33", "lon": 117.1052, "lat": 36.6541},
+ {"id": "34", "code": "34", "lon": 117.1072, "lat": 36.6561}
+ ],
+ "legs": [
+ {"from": "31", "to": "32"},
+ {"from": "32", "to": "33"},
+ {"from": "33", "to": "34"}
+ ]
+ }
+ ]
+ },
+ "playfield": {
+ "kind": "course"
+ },
+ "game": {
+ "mode": "classic-sequential"
+ }
+}`).Scan(&releaseRow.ID, &releaseRow.PublicID); err != nil {
return nil, fmt.Errorf("ensure demo release: %w", err)
}
@@ -159,11 +206,12 @@ func (s *Store) EnsureDemoData(ctx context.Context) (*DemoBootstrapSummary, erro
"tiles": "../map/lxcb-001/tiles/",
"mapmeta": "../map/lxcb-001/tiles/meta.json",
},
+ "preview": demoPreviewManifest("classic"),
"playfield": map[string]any{
"kind": "course",
"source": map[string]any{
"type": "kml",
- "url": "../kml/lxcb-001/10/c01.kml",
+ "url": "https://oss-mbh5.colormaprun.com/gotomars/kml/lxcb-001/2026-04-07/route04.kml",
},
},
"game": map[string]any{
@@ -197,6 +245,7 @@ func (s *Store) EnsureDemoData(ctx context.Context) (*DemoBootstrapSummary, erro
"tiles": "https://oss-mbh5.colormaprun.com/gotomars/map/lxcb-001/tiles/",
"mapmeta": "https://oss-mbh5.colormaprun.com/gotomars/map/lxcb-001/tiles/meta.json",
},
+ "preview": demoPreviewManifest("classic"),
"playfield": map[string]any{
"kind": "course",
"source": map[string]any{
@@ -248,7 +297,7 @@ func (s *Store) EnsureDemoData(ctx context.Context) (*DemoBootstrapSummary, erro
EventReleaseID: releaseRow.ID,
AssetType: "manifest",
AssetKey: "manifest",
- AssetURL: "https://oss-mbh5.colormaprun.com/gotomars/event/releases/evt_demo_001/rel_e7dd953743c5c0d2/manifest.json",
+ AssetURL: "https://api.gotomars.xyz/dev/demo-assets/manifests/classic",
Checksum: &manifestChecksum,
Meta: map[string]any{"source": "release-manifest"},
},
@@ -426,7 +475,7 @@ func (s *Store) EnsureDemoData(ctx context.Context) (*DemoBootstrapSummary, erro
course_source_public_id, source_type, file_url, import_status
)
VALUES (
- 'csource_demo_002', 'kml', 'https://oss-mbh5.colormaprun.com/gotomars/kml/lxcb-001/10/c02.kml', 'imported'
+ 'csource_demo_002', 'kml', 'https://oss-mbh5.colormaprun.com/gotomars/kml/lxcb-001/2026-04-07/route02.kml', 'imported'
)
ON CONFLICT (course_source_public_id) DO UPDATE SET
source_type = EXCLUDED.source_type,
@@ -437,6 +486,40 @@ func (s *Store) EnsureDemoData(ctx context.Context) (*DemoBootstrapSummary, erro
return nil, fmt.Errorf("ensure demo course source variant b: %w", err)
}
+ var courseSourceVariantCID string
+ if err := tx.QueryRow(ctx, `
+ INSERT INTO course_sources (
+ course_source_public_id, source_type, file_url, import_status
+ )
+ VALUES (
+ 'csource_demo_003', 'kml', 'https://oss-mbh5.colormaprun.com/gotomars/kml/lxcb-001/2026-04-07/route03.kml', 'imported'
+ )
+ ON CONFLICT (course_source_public_id) DO UPDATE SET
+ source_type = EXCLUDED.source_type,
+ file_url = EXCLUDED.file_url,
+ import_status = EXCLUDED.import_status
+ RETURNING id, course_source_public_id
+ `).Scan(&courseSourceVariantCID, new(string)); err != nil {
+ return nil, fmt.Errorf("ensure demo course source variant c: %w", err)
+ }
+
+ var courseSourceVariantDID string
+ if err := tx.QueryRow(ctx, `
+ INSERT INTO course_sources (
+ course_source_public_id, source_type, file_url, import_status
+ )
+ VALUES (
+ 'csource_demo_004', 'kml', 'https://oss-mbh5.colormaprun.com/gotomars/kml/lxcb-001/2026-04-07/route04.kml', 'imported'
+ )
+ ON CONFLICT (course_source_public_id) DO UPDATE SET
+ source_type = EXCLUDED.source_type,
+ file_url = EXCLUDED.file_url,
+ import_status = EXCLUDED.import_status
+ RETURNING id, course_source_public_id
+ `).Scan(&courseSourceVariantDID, new(string)); err != nil {
+ return nil, fmt.Errorf("ensure demo course source variant d: %w", err)
+ }
+
var courseSetID, courseSetPublicID string
if err := tx.QueryRow(ctx, `
INSERT INTO course_sets (
@@ -530,18 +613,59 @@ func (s *Store) EnsureDemoData(ctx context.Context) (*DemoBootstrapSummary, erro
return nil, fmt.Errorf("ensure demo runtime binding: %w", err)
}
+ classicPresentationID, _, err := s.upsertDemoEventPresentation(ctx, tx, eventID, upsertDemoEventPresentationParams{
+ PublicID: "pres_demo_001",
+ Code: "event-detail-classic",
+ Name: "顺序赛详情展示",
+ PresentationType: "detail",
+ TemplateKey: "event.detail.classic",
+ Version: "v2026-04-07-classic",
+ Title: "领秀城公园顺序赛展示定义",
+ })
+ if err != nil {
+ return nil, fmt.Errorf("ensure demo presentation: %w", err)
+ }
+ classicBundleID, _, err := s.upsertDemoContentBundle(ctx, tx, eventID, upsertDemoContentBundleParams{
+ PublicID: "bundle_demo_001",
+ Code: "result-media-classic",
+ Name: "顺序赛结果内容包",
+ BundleType: "result_media",
+ Version: "v2026-04-07-classic",
+ ManifestURL: "https://api.gotomars.xyz/dev/demo-assets/content-manifests/classic",
+ AssetRootURL: "https://oss-mbh5.colormaprun.com/gotomars/event/",
+ })
+ if err != nil {
+ return nil, fmt.Errorf("ensure demo content bundle: %w", err)
+ }
+ if err := s.SetEventDefaultBindings(ctx, tx, SetEventDefaultBindingsParams{
+ EventID: eventID,
+ PresentationID: &classicPresentationID,
+ ContentBundleID: &classicBundleID,
+ RuntimeBindingID: &runtimeBindingID,
+ UpdatePresentation: true,
+ UpdateContent: true,
+ UpdateRuntime: true,
+ }); err != nil {
+ return nil, fmt.Errorf("set demo default bindings: %w", err)
+ }
+ if err := s.SetEventReleaseBindings(ctx, tx, releaseRow.ID, &runtimeBindingID, &classicPresentationID, &classicBundleID); err != nil {
+ return nil, fmt.Errorf("bind demo release defaults: %w", err)
+ }
+
var manualEventID string
if err := tx.QueryRow(ctx, `
INSERT INTO events (
- tenant_id, event_public_id, slug, display_name, summary, status
+ tenant_id, event_public_id, slug, display_name, summary, status, is_default_experience, show_in_event_list
)
- VALUES ($1, 'evt_demo_variant_manual_001', 'city-park-manual-variant', '领秀城公园多赛道挑战', '手动多赛道联调样例活动', 'active')
+ VALUES ($1, 'evt_demo_variant_manual_001', 'city-park-manual-variant', '领秀城公园多赛道挑战', '手动多赛道联调样例活动', 'active', false, true)
ON CONFLICT (event_public_id) DO UPDATE SET
tenant_id = EXCLUDED.tenant_id,
slug = EXCLUDED.slug,
display_name = EXCLUDED.display_name,
summary = EXCLUDED.summary,
- status = EXCLUDED.status
+ status = EXCLUDED.status,
+ is_default_experience = EXCLUDED.is_default_experience,
+ show_in_event_list = EXCLUDED.show_in_event_list
RETURNING id
`, tenantID).Scan(&manualEventID); err != nil {
return nil, fmt.Errorf("ensure variant manual demo event: %w", err)
@@ -568,9 +692,9 @@ func (s *Store) EnsureDemoData(ctx context.Context) (*DemoBootstrapSummary, erro
$1,
1,
'多赛道联调配置 v1',
- 'https://oss-mbh5.colormaprun.com/gotomars/event/releases/evt_demo_001/rel_e7dd953743c5c0d2/manifest.json',
+ 'https://api.gotomars.xyz/dev/demo-assets/manifests/manual-variant',
'demo-variant-checksum-001',
- 'route-variant-a',
+ 'route-variant-d',
'published',
$2::jsonb
)
@@ -584,22 +708,174 @@ func (s *Store) EnsureDemoData(ctx context.Context) (*DemoBootstrapSummary, erro
payload_jsonb = EXCLUDED.payload_jsonb
RETURNING id, release_public_id
`, manualEventID, `{
+ "schemaVersion": "1",
+ "preview": {
+ "mode": "manual-variant-preview",
+ "baseTiles": {
+ "tileBaseUrl": "https://oss-mbh5.colormaprun.com/gotomars/map/lxcb-001/tiles/",
+ "zoom": 16,
+ "tileSize": 256
+ },
+ "viewport": {
+ "width": 375,
+ "height": 220,
+ "minLon": 117.1001,
+ "minLat": 36.6501,
+ "maxLon": 117.1112,
+ "maxLat": 36.6593
+ },
+ "selectedVariantId": "variant_d",
+ "variants": [
+ {
+ "id": "variant_a",
+ "name": "路线 01",
+ "routeCode": "route-variant-a",
+ "controls": [
+ {"id": "1", "code": "1", "lon": 117.000649296107, "lat": 36.5921631022497},
+ {"id": "2", "code": "2", "lon": 116.999689737459, "lat": 36.5922740961347},
+ {"id": "3", "code": "3", "lon": 116.999108309973, "lat": 36.5919019395375},
+ {"id": "4", "code": "4", "lon": 116.999823913032, "lat": 36.591572220351},
+ {"id": "5", "code": "5", "lon": 116.999860506371, "lat": 36.5912131186443},
+ {"id": "6", "code": "6", "lon": 117.000340285695, "lat": 36.5909356298175},
+ {"id": "7", "code": "7", "lon": 117.000441933857, "lat": 36.5915004001434},
+ {"id": "8", "code": "8", "lon": 117.001397426578, "lat": 36.5915983367736},
+ {"id": "9", "code": "9", "lon": 117.000665559813, "lat": 36.5919574366878},
+ {"id": "10", "code": "10", "lon": 117.000649296107, "lat": 36.5921631022497}
+ ],
+ "legs": [
+ {"from": "1", "to": "2"},
+ {"from": "2", "to": "3"},
+ {"from": "3", "to": "4"},
+ {"from": "4", "to": "5"},
+ {"from": "5", "to": "6"},
+ {"from": "6", "to": "7"},
+ {"from": "7", "to": "8"},
+ {"from": "8", "to": "9"},
+ {"from": "9", "to": "10"}
+ ]
+ },
+ {
+ "id": "variant_b",
+ "name": "路线 02",
+ "routeCode": "route-variant-b",
+ "controls": [
+ {"id": "1", "code": "1", "lon": 117.000649296107, "lat": 36.5921631022497},
+ {"id": "2", "code": "2", "lon": 116.999766990062, "lat": 36.5921141343085},
+ {"id": "3", "code": "3", "lon": 116.999710067091, "lat": 36.5917615642144},
+ {"id": "4", "code": "4", "lon": 116.998774904002, "lat": 36.5913306430232},
+ {"id": "5", "code": "5", "lon": 116.998941606987, "lat": 36.5908278985923},
+ {"id": "6", "code": "6", "lon": 117.00058830721, "lat": 36.5905340853955},
+ {"id": "7", "code": "7", "lon": 117.000238637533, "lat": 36.5914742836876},
+ {"id": "8", "code": "8", "lon": 117.000937976887, "lat": 36.5916113949816},
+ {"id": "9", "code": "9", "lon": 117.000803801313, "lat": 36.5919411140006},
+ {"id": "10", "code": "10", "lon": 117.000649296107, "lat": 36.5921631022497}
+ ],
+ "legs": [
+ {"from": "1", "to": "2"},
+ {"from": "2", "to": "3"},
+ {"from": "3", "to": "4"},
+ {"from": "4", "to": "5"},
+ {"from": "5", "to": "6"},
+ {"from": "6", "to": "7"},
+ {"from": "7", "to": "8"},
+ {"from": "8", "to": "9"},
+ {"from": "9", "to": "10"}
+ ]
+ },
+ {
+ "id": "variant_c",
+ "name": "路线 03",
+ "routeCode": "route-variant-c",
+ "controls": [
+ {"id": "1", "code": "1", "lon": 117.000649296107, "lat": 36.5921631022497},
+ {"id": "2", "code": "2", "lon": 117.000665559813, "lat": 36.5919574366878},
+ {"id": "3", "code": "3", "lon": 117.001397426578, "lat": 36.5915983367736},
+ {"id": "4", "code": "4", "lon": 117.000441933857, "lat": 36.5915004001434},
+ {"id": "5", "code": "5", "lon": 117.000340285695, "lat": 36.5909356298175},
+ {"id": "6", "code": "6", "lon": 116.999860506371, "lat": 36.5912131186443},
+ {"id": "7", "code": "7", "lon": 116.999823913032, "lat": 36.591572220351},
+ {"id": "8", "code": "8", "lon": 116.999108309973, "lat": 36.5919019395375},
+ {"id": "9", "code": "9", "lon": 116.999689737459, "lat": 36.5922740961347},
+ {"id": "10", "code": "10", "lon": 117.000649296107, "lat": 36.5921631022497}
+ ],
+ "legs": [
+ {"from": "1", "to": "2"},
+ {"from": "2", "to": "3"},
+ {"from": "3", "to": "4"},
+ {"from": "4", "to": "5"},
+ {"from": "5", "to": "6"},
+ {"from": "6", "to": "7"},
+ {"from": "7", "to": "8"},
+ {"from": "8", "to": "9"},
+ {"from": "9", "to": "10"}
+ ]
+ },
+ {
+ "id": "variant_d",
+ "name": "路线 04",
+ "routeCode": "route-variant-d",
+ "controls": [
+ {"id": "1", "code": "1", "lon": 117.000649296107, "lat": 36.5921631022497},
+ {"id": "2", "code": "2", "lon": 117.000803801313, "lat": 36.5919411140006},
+ {"id": "3", "code": "3", "lon": 117.000937976887, "lat": 36.5916113949816},
+ {"id": "4", "code": "4", "lon": 117.000238637533, "lat": 36.5914742836876},
+ {"id": "5", "code": "5", "lon": 117.00058830721, "lat": 36.5905340853955},
+ {"id": "6", "code": "6", "lon": 116.998941606987, "lat": 36.5908278985923},
+ {"id": "7", "code": "7", "lon": 116.998774904002, "lat": 36.5913306430232},
+ {"id": "8", "code": "8", "lon": 116.999710067091, "lat": 36.5917615642144},
+ {"id": "9", "code": "9", "lon": 116.999766990062, "lat": 36.5921141343085},
+ {"id": "10", "code": "10", "lon": 117.000649296107, "lat": 36.5921631022497}
+ ],
+ "legs": [
+ {"from": "1", "to": "2"},
+ {"from": "2", "to": "3"},
+ {"from": "3", "to": "4"},
+ {"from": "4", "to": "5"},
+ {"from": "5", "to": "6"},
+ {"from": "6", "to": "7"},
+ {"from": "7", "to": "8"},
+ {"from": "8", "to": "9"},
+ {"from": "9", "to": "10"}
+ ]
+ }
+ ]
+ },
+ "playfield": {
+ "kind": "course-set"
+ },
+ "game": {
+ "mode": "classic-sequential"
+ },
"play": {
"assignmentMode": "manual",
"courseVariants": [
{
"id": "variant_a",
- "name": "A 线",
- "description": "短线体验版(c01.kml)",
+ "name": "路线 01",
+ "description": "route01.kml",
"routeCode": "route-variant-a",
"selectable": true
},
{
"id": "variant_b",
- "name": "B 线",
- "description": "长线挑战版(c02.kml)",
+ "name": "路线 02",
+ "description": "route02.kml",
"routeCode": "route-variant-b",
"selectable": true
+ },
+ {
+ "id": "variant_c",
+ "name": "路线 03",
+ "description": "route03.kml",
+ "routeCode": "route-variant-c",
+ "selectable": true
+ },
+ {
+ "id": "variant_d",
+ "name": "路线 04",
+ "description": "route04.kml",
+ "routeCode": "route-variant-d",
+ "selectable": true
}
]
}
@@ -691,6 +967,7 @@ func (s *Store) EnsureDemoData(ctx context.Context) (*DemoBootstrapSummary, erro
"tiles": "../map/lxcb-001/tiles/",
"mapmeta": "../map/lxcb-001/tiles/meta.json",
},
+ "preview": demoPreviewManifest("manual-variant"),
"playfield": map[string]any{
"kind": "course",
"source": map[string]any{
@@ -704,20 +981,10 @@ func (s *Store) EnsureDemoData(ctx context.Context) (*DemoBootstrapSummary, erro
"play": map[string]any{
"assignmentMode": "manual",
"courseVariants": []map[string]any{
- {
- "id": "variant_a",
- "name": "A 线",
- "description": "短线体验版(c01.kml)",
- "routeCode": "route-variant-a",
- "selectable": true,
- },
- {
- "id": "variant_b",
- "name": "B 线",
- "description": "长线挑战版(c02.kml)",
- "routeCode": "route-variant-b",
- "selectable": true,
- },
+ {"id": "variant_a", "name": "路线 01", "description": "route01.kml", "routeCode": "route-variant-a", "selectable": true},
+ {"id": "variant_b", "name": "路线 02", "description": "route02.kml", "routeCode": "route-variant-b", "selectable": true},
+ {"id": "variant_c", "name": "路线 03", "description": "route03.kml", "routeCode": "route-variant-c", "selectable": true},
+ {"id": "variant_d", "name": "路线 04", "description": "route04.kml", "routeCode": "route-variant-d", "selectable": true},
},
},
"content": map[string]any{
@@ -748,11 +1015,12 @@ func (s *Store) EnsureDemoData(ctx context.Context) (*DemoBootstrapSummary, erro
"tiles": "https://oss-mbh5.colormaprun.com/gotomars/map/lxcb-001/tiles/",
"mapmeta": "https://oss-mbh5.colormaprun.com/gotomars/map/lxcb-001/tiles/meta.json",
},
+ "preview": demoPreviewManifest("manual-variant"),
"playfield": map[string]any{
"kind": "course",
"source": map[string]any{
"type": "kml",
- "url": "https://oss-mbh5.colormaprun.com/gotomars/kml/lxcb-001/10/c01.kml",
+ "url": "https://oss-mbh5.colormaprun.com/gotomars/kml/lxcb-001/2026-04-07/route04.kml",
},
},
"game": map[string]any{
@@ -761,20 +1029,10 @@ func (s *Store) EnsureDemoData(ctx context.Context) (*DemoBootstrapSummary, erro
"play": map[string]any{
"assignmentMode": "manual",
"courseVariants": []map[string]any{
- {
- "id": "variant_a",
- "name": "A 线",
- "description": "短线体验版(c01.kml)",
- "routeCode": "route-variant-a",
- "selectable": true,
- },
- {
- "id": "variant_b",
- "name": "B 线",
- "description": "长线挑战版(c02.kml)",
- "routeCode": "route-variant-b",
- "selectable": true,
- },
+ {"id": "variant_a", "name": "路线 01", "description": "route01.kml", "routeCode": "route-variant-a", "selectable": true},
+ {"id": "variant_b", "name": "路线 02", "description": "route02.kml", "routeCode": "route-variant-b", "selectable": true},
+ {"id": "variant_c", "name": "路线 03", "description": "route03.kml", "routeCode": "route-variant-c", "selectable": true},
+ {"id": "variant_d", "name": "路线 04", "description": "route04.kml", "routeCode": "route-variant-d", "selectable": true},
},
},
"assets": map[string]any{
@@ -815,13 +1073,81 @@ func (s *Store) EnsureDemoData(ctx context.Context) (*DemoBootstrapSummary, erro
return nil, fmt.Errorf("ensure variant manual demo course set: %w", err)
}
+ var manualCourseSourceAID string
+ if err := tx.QueryRow(ctx, `
+ INSERT INTO course_sources (
+ course_source_public_id, source_type, file_url, import_status
+ )
+ VALUES (
+ 'csource_demo_variant_manual_a', 'kml', 'https://oss-mbh5.colormaprun.com/gotomars/kml/lxcb-001/2026-04-07/route01.kml', 'imported'
+ )
+ ON CONFLICT (course_source_public_id) DO UPDATE SET
+ source_type = EXCLUDED.source_type,
+ file_url = EXCLUDED.file_url,
+ import_status = EXCLUDED.import_status
+ RETURNING id
+ `).Scan(&manualCourseSourceAID); err != nil {
+ return nil, fmt.Errorf("ensure variant manual demo source a: %w", err)
+ }
+
+ var manualCourseSourceBID string
+ if err := tx.QueryRow(ctx, `
+ INSERT INTO course_sources (
+ course_source_public_id, source_type, file_url, import_status
+ )
+ VALUES (
+ 'csource_demo_variant_manual_b', 'kml', 'https://oss-mbh5.colormaprun.com/gotomars/kml/lxcb-001/2026-04-07/route02.kml', 'imported'
+ )
+ ON CONFLICT (course_source_public_id) DO UPDATE SET
+ source_type = EXCLUDED.source_type,
+ file_url = EXCLUDED.file_url,
+ import_status = EXCLUDED.import_status
+ RETURNING id
+ `).Scan(&manualCourseSourceBID); err != nil {
+ return nil, fmt.Errorf("ensure variant manual demo source b: %w", err)
+ }
+
+ var manualCourseSourceCID string
+ if err := tx.QueryRow(ctx, `
+ INSERT INTO course_sources (
+ course_source_public_id, source_type, file_url, import_status
+ )
+ VALUES (
+ 'csource_demo_variant_manual_c', 'kml', 'https://oss-mbh5.colormaprun.com/gotomars/kml/lxcb-001/2026-04-07/route03.kml', 'imported'
+ )
+ ON CONFLICT (course_source_public_id) DO UPDATE SET
+ source_type = EXCLUDED.source_type,
+ file_url = EXCLUDED.file_url,
+ import_status = EXCLUDED.import_status
+ RETURNING id
+ `).Scan(&manualCourseSourceCID); err != nil {
+ return nil, fmt.Errorf("ensure variant manual demo source c: %w", err)
+ }
+
+ var manualCourseSourceDID string
+ if err := tx.QueryRow(ctx, `
+ INSERT INTO course_sources (
+ course_source_public_id, source_type, file_url, import_status
+ )
+ VALUES (
+ 'csource_demo_variant_manual_d', 'kml', 'https://oss-mbh5.colormaprun.com/gotomars/kml/lxcb-001/2026-04-07/route04.kml', 'imported'
+ )
+ ON CONFLICT (course_source_public_id) DO UPDATE SET
+ source_type = EXCLUDED.source_type,
+ file_url = EXCLUDED.file_url,
+ import_status = EXCLUDED.import_status
+ RETURNING id
+ `).Scan(&manualCourseSourceDID); err != nil {
+ return nil, fmt.Errorf("ensure variant manual demo source d: %w", err)
+ }
+
var manualVariantAID string
if err := tx.QueryRow(ctx, `
INSERT INTO course_variants (
course_variant_public_id, course_set_id, source_id, name, route_code, mode, control_count, status, is_default
)
VALUES (
- 'cvariant_demo_variant_manual_a', $1, $2, '多赛道 A 线', 'route-variant-a', 'classic-sequential', 8, 'active', false
+ 'cvariant_demo_variant_manual_a', $1, $2, '多赛道 路线 01', 'route-variant-a', 'classic-sequential', 10, 'active', false
)
ON CONFLICT (course_variant_public_id) DO UPDATE SET
course_set_id = EXCLUDED.course_set_id,
@@ -833,7 +1159,7 @@ func (s *Store) EnsureDemoData(ctx context.Context) (*DemoBootstrapSummary, erro
status = EXCLUDED.status,
is_default = EXCLUDED.is_default
RETURNING id
- `, manualCourseSetID, courseSourceID).Scan(&manualVariantAID); err != nil {
+ `, manualCourseSetID, manualCourseSourceAID).Scan(&manualVariantAID); err != nil {
return nil, fmt.Errorf("ensure variant manual demo variant a: %w", err)
}
@@ -843,7 +1169,7 @@ func (s *Store) EnsureDemoData(ctx context.Context) (*DemoBootstrapSummary, erro
course_variant_public_id, course_set_id, source_id, name, route_code, mode, control_count, status, is_default
)
VALUES (
- 'cvariant_demo_variant_manual_b', $1, $2, '多赛道 B 线', 'route-variant-b', 'classic-sequential', 10, 'active', true
+ 'cvariant_demo_variant_manual_b', $1, $2, '多赛道 路线 02', 'route-variant-b', 'classic-sequential', 10, 'active', false
)
ON CONFLICT (course_variant_public_id) DO UPDATE SET
course_set_id = EXCLUDED.course_set_id,
@@ -855,15 +1181,59 @@ func (s *Store) EnsureDemoData(ctx context.Context) (*DemoBootstrapSummary, erro
status = EXCLUDED.status,
is_default = EXCLUDED.is_default
RETURNING id, course_variant_public_id
- `, manualCourseSetID, courseSourceVariantBID).Scan(&manualVariantBID, &manualVariantBPublicID); err != nil {
+ `, manualCourseSetID, manualCourseSourceBID).Scan(&manualVariantBID, &manualVariantBPublicID); err != nil {
return nil, fmt.Errorf("ensure variant manual demo variant b: %w", err)
}
+ var manualVariantCID string
+ if err := tx.QueryRow(ctx, `
+ INSERT INTO course_variants (
+ course_variant_public_id, course_set_id, source_id, name, route_code, mode, control_count, status, is_default
+ )
+ VALUES (
+ 'cvariant_demo_variant_manual_c', $1, $2, '多赛道 路线 03', 'route-variant-c', 'classic-sequential', 10, 'active', false
+ )
+ ON CONFLICT (course_variant_public_id) DO UPDATE SET
+ course_set_id = EXCLUDED.course_set_id,
+ source_id = EXCLUDED.source_id,
+ name = EXCLUDED.name,
+ route_code = EXCLUDED.route_code,
+ mode = EXCLUDED.mode,
+ control_count = EXCLUDED.control_count,
+ status = EXCLUDED.status,
+ is_default = EXCLUDED.is_default
+ RETURNING id
+ `, manualCourseSetID, manualCourseSourceCID).Scan(&manualVariantCID); err != nil {
+ return nil, fmt.Errorf("ensure variant manual demo variant c: %w", err)
+ }
+
+ var manualVariantDID, manualVariantDPublicID string
+ if err := tx.QueryRow(ctx, `
+ INSERT INTO course_variants (
+ course_variant_public_id, course_set_id, source_id, name, route_code, mode, control_count, status, is_default
+ )
+ VALUES (
+ 'cvariant_demo_variant_manual_d', $1, $2, '多赛道 路线 04', 'route-variant-d', 'classic-sequential', 10, 'active', true
+ )
+ ON CONFLICT (course_variant_public_id) DO UPDATE SET
+ course_set_id = EXCLUDED.course_set_id,
+ source_id = EXCLUDED.source_id,
+ name = EXCLUDED.name,
+ route_code = EXCLUDED.route_code,
+ mode = EXCLUDED.mode,
+ control_count = EXCLUDED.control_count,
+ status = EXCLUDED.status,
+ is_default = EXCLUDED.is_default
+ RETURNING id, course_variant_public_id
+ `, manualCourseSetID, manualCourseSourceDID).Scan(&manualVariantDID, &manualVariantDPublicID); err != nil {
+ return nil, fmt.Errorf("ensure variant manual demo variant d: %w", err)
+ }
+
if _, err := tx.Exec(ctx, `
UPDATE course_sets
SET current_variant_id = $2
WHERE id = $1
- `, manualCourseSetID, manualVariantBID); err != nil {
+ `, manualCourseSetID, manualVariantDID); err != nil {
return nil, fmt.Errorf("attach variant manual demo course variant: %w", err)
}
@@ -885,22 +1255,63 @@ func (s *Store) EnsureDemoData(ctx context.Context) (*DemoBootstrapSummary, erro
status = EXCLUDED.status,
notes = EXCLUDED.notes
RETURNING id, runtime_binding_public_id
- `, manualEventID, placeID, mapAssetID, tileReleaseID, manualCourseSetID, manualVariantBID).Scan(&manualRuntimeBindingID, &manualRuntimeBindingPublicID); err != nil {
+ `, manualEventID, placeID, mapAssetID, tileReleaseID, manualCourseSetID, manualVariantDID).Scan(&manualRuntimeBindingID, &manualRuntimeBindingPublicID); err != nil {
return nil, fmt.Errorf("ensure variant manual demo runtime binding: %w", err)
}
+ manualPresentationID, _, err := s.upsertDemoEventPresentation(ctx, tx, manualEventID, upsertDemoEventPresentationParams{
+ PublicID: "pres_demo_variant_manual_001",
+ Code: "event-detail-manual-variant",
+ Name: "多赛道详情展示",
+ PresentationType: "detail",
+ TemplateKey: "event.detail.multi-variant",
+ Version: "v2026-04-07-manual",
+ Title: "领秀城公园多赛道挑战展示定义",
+ })
+ if err != nil {
+ return nil, fmt.Errorf("ensure variant manual presentation: %w", err)
+ }
+ manualBundleID, _, err := s.upsertDemoContentBundle(ctx, tx, manualEventID, upsertDemoContentBundleParams{
+ PublicID: "bundle_demo_variant_manual_001",
+ Code: "result-media-manual",
+ Name: "多赛道结果内容包",
+ BundleType: "result_media",
+ Version: "v2026-04-07-manual",
+ ManifestURL: "https://api.gotomars.xyz/dev/demo-assets/content-manifests/manual",
+ AssetRootURL: "https://oss-mbh5.colormaprun.com/gotomars/event/",
+ })
+ if err != nil {
+ return nil, fmt.Errorf("ensure variant manual content bundle: %w", err)
+ }
+ if err := s.SetEventDefaultBindings(ctx, tx, SetEventDefaultBindingsParams{
+ EventID: manualEventID,
+ PresentationID: &manualPresentationID,
+ ContentBundleID: &manualBundleID,
+ RuntimeBindingID: &manualRuntimeBindingID,
+ UpdatePresentation: true,
+ UpdateContent: true,
+ UpdateRuntime: true,
+ }); err != nil {
+ return nil, fmt.Errorf("set variant manual default bindings: %w", err)
+ }
+ if err := s.SetEventReleaseBindings(ctx, tx, manualReleaseRow.ID, &manualRuntimeBindingID, &manualPresentationID, &manualBundleID); err != nil {
+ return nil, fmt.Errorf("bind variant manual release defaults: %w", err)
+ }
+
var scoreOEventID string
if err := tx.QueryRow(ctx, `
INSERT INTO events (
- tenant_id, event_public_id, slug, display_name, summary, status
+ tenant_id, event_public_id, slug, display_name, summary, status, is_default_experience, show_in_event_list
)
- VALUES ($1, 'evt_demo_score_o_001', 'city-park-score-o', '领秀城公园积分赛', '积分赛联调样例活动', 'active')
+ VALUES ($1, 'evt_demo_score_o_001', 'city-park-score-o', '领秀城公园积分赛', '积分赛联调样例活动', 'active', false, true)
ON CONFLICT (event_public_id) DO UPDATE SET
tenant_id = EXCLUDED.tenant_id,
slug = EXCLUDED.slug,
display_name = EXCLUDED.display_name,
summary = EXCLUDED.summary,
- status = EXCLUDED.status
+ status = EXCLUDED.status,
+ is_default_experience = EXCLUDED.is_default_experience,
+ show_in_event_list = EXCLUDED.show_in_event_list
RETURNING id
`, tenantID).Scan(&scoreOEventID); err != nil {
return nil, fmt.Errorf("ensure score-o demo event: %w", err)
@@ -919,17 +1330,19 @@ func (s *Store) EnsureDemoData(ctx context.Context) (*DemoBootstrapSummary, erro
manifest_url,
manifest_checksum_sha256,
route_code,
- status
+ status,
+ payload_jsonb
)
VALUES (
'rel_demo_score_o_001',
$1,
1,
'积分赛联调配置 v1',
- 'https://oss-mbh5.colormaprun.com/gotomars/event/score-o.json',
+ 'https://api.gotomars.xyz/dev/demo-assets/manifests/score-o',
'demo-score-o-checksum-001',
'route-score-o-001',
- 'published'
+ 'published',
+ $2::jsonb
)
ON CONFLICT (release_public_id) DO UPDATE SET
event_id = EXCLUDED.event_id,
@@ -937,9 +1350,52 @@ func (s *Store) EnsureDemoData(ctx context.Context) (*DemoBootstrapSummary, erro
manifest_url = EXCLUDED.manifest_url,
manifest_checksum_sha256 = EXCLUDED.manifest_checksum_sha256,
route_code = EXCLUDED.route_code,
- status = EXCLUDED.status
+ status = EXCLUDED.status,
+ payload_jsonb = EXCLUDED.payload_jsonb
RETURNING id, release_public_id
- `, scoreOEventID).Scan(&scoreOReleaseRow.ID, &scoreOReleaseRow.PublicID); err != nil {
+ `, scoreOEventID, `{
+ "schemaVersion": "1",
+ "preview": {
+ "mode": "control-set-preview",
+ "baseTiles": {
+ "tileBaseUrl": "https://oss-mbh5.colormaprun.com/gotomars/map/lxcb-001/tiles/",
+ "zoom": 16,
+ "tileSize": 256
+ },
+ "viewport": {
+ "width": 375,
+ "height": 220,
+ "minLon": 117.1001,
+ "minLat": 36.6501,
+ "maxLon": 117.1098,
+ "maxLat": 36.6584
+ },
+ "selectedVariantId": "score_main",
+ "variants": [
+ {
+ "id": "score_main",
+ "name": "积分赛标准赛道",
+ "controls": [
+ {"id": "41", "code": "41", "lon": 117.1015, "lat": 36.6516},
+ {"id": "42", "code": "42", "lon": 117.1042, "lat": 36.6527},
+ {"id": "43", "code": "43", "lon": 117.1065, "lat": 36.6548},
+ {"id": "44", "code": "44", "lon": 117.1082, "lat": 36.6569}
+ ],
+ "legs": [
+ {"from": "41", "to": "42"},
+ {"from": "42", "to": "43"},
+ {"from": "43", "to": "44"}
+ ]
+ }
+ ]
+ },
+ "playfield": {
+ "kind": "control-set"
+ },
+ "game": {
+ "mode": "score-o"
+ }
+}`).Scan(&scoreOReleaseRow.ID, &scoreOReleaseRow.PublicID); err != nil {
return nil, fmt.Errorf("ensure score-o demo release: %w", err)
}
@@ -974,6 +1430,7 @@ func (s *Store) EnsureDemoData(ctx context.Context) (*DemoBootstrapSummary, erro
"tiles": "../map/lxcb-001/tiles/",
"mapmeta": "../map/lxcb-001/tiles/meta.json",
},
+ "preview": demoPreviewManifest("score-o"),
"playfield": map[string]any{
"kind": "control-set",
"source": map[string]any{
@@ -1012,6 +1469,7 @@ func (s *Store) EnsureDemoData(ctx context.Context) (*DemoBootstrapSummary, erro
"tiles": "https://oss-mbh5.colormaprun.com/gotomars/map/lxcb-001/tiles/",
"mapmeta": "https://oss-mbh5.colormaprun.com/gotomars/map/lxcb-001/tiles/meta.json",
},
+ "preview": demoPreviewManifest("score-o"),
"playfield": map[string]any{
"kind": "control-set",
"source": map[string]any{
@@ -1165,6 +1623,45 @@ func (s *Store) EnsureDemoData(ctx context.Context) (*DemoBootstrapSummary, erro
return nil, fmt.Errorf("ensure score-o demo runtime binding: %w", err)
}
+ scoreOPresentationID, _, err := s.upsertDemoEventPresentation(ctx, tx, scoreOEventID, upsertDemoEventPresentationParams{
+ PublicID: "pres_demo_score_o_001",
+ Code: "event-detail-score-o",
+ Name: "积分赛详情展示",
+ PresentationType: "detail",
+ TemplateKey: "event.detail.score-o",
+ Version: "v2026-04-07-score-o",
+ Title: "领秀城公园积分赛展示定义",
+ })
+ if err != nil {
+ return nil, fmt.Errorf("ensure score-o presentation: %w", err)
+ }
+ scoreOBundleID, _, err := s.upsertDemoContentBundle(ctx, tx, scoreOEventID, upsertDemoContentBundleParams{
+ PublicID: "bundle_demo_score_o_001",
+ Code: "result-media-score-o",
+ Name: "积分赛结果内容包",
+ BundleType: "result_media",
+ Version: "v2026-04-07-score-o",
+ ManifestURL: "https://api.gotomars.xyz/dev/demo-assets/content-manifests/score-o",
+ AssetRootURL: "https://oss-mbh5.colormaprun.com/gotomars/event/",
+ })
+ if err != nil {
+ return nil, fmt.Errorf("ensure score-o content bundle: %w", err)
+ }
+ if err := s.SetEventDefaultBindings(ctx, tx, SetEventDefaultBindingsParams{
+ EventID: scoreOEventID,
+ PresentationID: &scoreOPresentationID,
+ ContentBundleID: &scoreOBundleID,
+ RuntimeBindingID: &scoreORuntimeBindingID,
+ UpdatePresentation: true,
+ UpdateContent: true,
+ UpdateRuntime: true,
+ }); err != nil {
+ return nil, fmt.Errorf("set score-o default bindings: %w", err)
+ }
+ if err := s.SetEventReleaseBindings(ctx, tx, scoreOReleaseRow.ID, &scoreORuntimeBindingID, &scoreOPresentationID, &scoreOBundleID); err != nil {
+ return nil, fmt.Errorf("bind score-o release defaults: %w", err)
+ }
+
var cleanedSessionCount int64
if err := tx.QueryRow(ctx, `
WITH cleaned AS (
@@ -1215,8 +1712,282 @@ func (s *Store) EnsureDemoData(ctx context.Context) (*DemoBootstrapSummary, erro
VariantManualSourceID: manualSource.ID,
VariantManualBuildID: manualBuild.ID,
VariantManualCourseSet: manualCourseSetPublicID,
- VariantManualVariantID: manualVariantBPublicID,
+ VariantManualVariantID: manualVariantDPublicID,
VariantManualRuntimeID: manualRuntimeBindingPublicID,
CleanedSessionCount: cleanedSessionCount,
}, nil
}
+
+type upsertDemoEventPresentationParams struct {
+ PublicID string
+ Code string
+ Name string
+ PresentationType string
+ TemplateKey string
+ Version string
+ Title string
+}
+
+func (s *Store) upsertDemoEventPresentation(ctx context.Context, tx Tx, eventID string, params upsertDemoEventPresentationParams) (string, string, error) {
+ var id string
+ var publicID string
+ if err := tx.QueryRow(ctx, `
+ INSERT INTO event_presentations (
+ presentation_public_id,
+ event_id,
+ code,
+ name,
+ presentation_type,
+ status,
+ is_default,
+ schema_jsonb
+ )
+ VALUES ($1, $2, $3, $4, $5, 'active', true, $6::jsonb)
+ ON CONFLICT (event_id, code) DO UPDATE SET
+ presentation_public_id = EXCLUDED.presentation_public_id,
+ name = EXCLUDED.name,
+ presentation_type = EXCLUDED.presentation_type,
+ status = EXCLUDED.status,
+ is_default = EXCLUDED.is_default,
+ schema_jsonb = EXCLUDED.schema_jsonb
+ RETURNING id, presentation_public_id
+ `, params.PublicID, eventID, params.Code, params.Name, params.PresentationType, fmt.Sprintf(`{"templateKey":"%s","version":"%s","title":"%s"}`, params.TemplateKey, params.Version, params.Title)).Scan(&id, &publicID); err != nil {
+ return "", "", err
+ }
+ return id, publicID, nil
+}
+
+type upsertDemoContentBundleParams struct {
+ PublicID string
+ Code string
+ Name string
+ BundleType string
+ Version string
+ ManifestURL string
+ AssetRootURL string
+}
+
+func (s *Store) upsertDemoContentBundle(ctx context.Context, tx Tx, eventID string, params upsertDemoContentBundleParams) (string, string, error) {
+ var id string
+ var publicID string
+ if err := tx.QueryRow(ctx, `
+ INSERT INTO content_bundles (
+ content_bundle_public_id,
+ event_id,
+ code,
+ name,
+ status,
+ is_default,
+ entry_url,
+ asset_root_url,
+ metadata_jsonb
+ )
+ VALUES ($1, $2, $3, $4, 'active', true, $5, $6, $7::jsonb)
+ ON CONFLICT (event_id, code) DO UPDATE SET
+ content_bundle_public_id = EXCLUDED.content_bundle_public_id,
+ name = EXCLUDED.name,
+ status = EXCLUDED.status,
+ is_default = EXCLUDED.is_default,
+ entry_url = EXCLUDED.entry_url,
+ asset_root_url = EXCLUDED.asset_root_url,
+ metadata_jsonb = EXCLUDED.metadata_jsonb
+ RETURNING id, content_bundle_public_id
+ `, params.PublicID, eventID, params.Code, params.Name, params.ManifestURL, params.AssetRootURL, fmt.Sprintf(`{"bundleType":"%s","version":"%s"}`, params.BundleType, params.Version)).Scan(&id, &publicID); err != nil {
+ return "", "", err
+ }
+ return id, publicID, nil
+}
+
+func demoPreviewManifest(kind string) map[string]any {
+ baseTiles := map[string]any{
+ "tileBaseUrl": "https://oss-mbh5.colormaprun.com/gotomars/map/lxcb-001/tiles/",
+ "zoom": 15,
+ "tileSize": 256,
+ }
+ viewport := map[string]any{
+ "width": 800,
+ "height": 450,
+ "minLon": 117.0000,
+ "minLat": 36.6000,
+ "maxLon": 117.0800,
+ "maxLat": 36.6600,
+ }
+
+ switch kind {
+ case "score-o":
+ return map[string]any{
+ "mode": "readonly",
+ "baseTiles": baseTiles,
+ "viewport": viewport,
+ "selectedVariantId": "variant_score_main",
+ "variants": []map[string]any{
+ {
+ "variantId": "variant_score_main",
+ "name": "积分赛主赛道",
+ "routeCode": "route-score-o-001",
+ "controls": []map[string]any{
+ {"id": "start", "kind": "start", "lon": 117.012, "lat": 36.612, "label": "起点"},
+ {"id": "s1", "kind": "control", "lon": 117.021, "lat": 36.618, "label": "10分点"},
+ {"id": "s2", "kind": "control", "lon": 117.034, "lat": 36.624, "label": "20分点"},
+ {"id": "s3", "kind": "control", "lon": 117.046, "lat": 36.616, "label": "15分点"},
+ {"id": "finish", "kind": "finish", "lon": 117.025, "lat": 36.606, "label": "终点"},
+ },
+ "legs": []map[string]any{
+ {"from": "start", "to": "s1"},
+ {"from": "s1", "to": "s2"},
+ {"from": "s2", "to": "s3"},
+ {"from": "s3", "to": "finish"},
+ },
+ },
+ },
+ }
+ case "manual-variant":
+ return map[string]any{
+ "mode": "readonly",
+ "baseTiles": baseTiles,
+ "viewport": viewport,
+ "selectedVariantId": "variant_d",
+ "variants": []map[string]any{
+ {
+ "variantId": "variant_a",
+ "name": "路线 01",
+ "routeCode": "route-variant-a",
+ "controls": []map[string]any{
+ {"id": "1", "kind": "control", "lon": 117.000649296107, "lat": 36.5921631022497, "label": "1"},
+ {"id": "2", "kind": "control", "lon": 116.999689737459, "lat": 36.5922740961347, "label": "2"},
+ {"id": "3", "kind": "control", "lon": 116.999108309973, "lat": 36.5919019395375, "label": "3"},
+ {"id": "4", "kind": "control", "lon": 116.999823913032, "lat": 36.591572220351, "label": "4"},
+ {"id": "5", "kind": "control", "lon": 116.999860506371, "lat": 36.5912131186443, "label": "5"},
+ {"id": "6", "kind": "control", "lon": 117.000340285695, "lat": 36.5909356298175, "label": "6"},
+ {"id": "7", "kind": "control", "lon": 117.000441933857, "lat": 36.5915004001434, "label": "7"},
+ {"id": "8", "kind": "control", "lon": 117.001397426578, "lat": 36.5915983367736, "label": "8"},
+ {"id": "9", "kind": "control", "lon": 117.000665559813, "lat": 36.5919574366878, "label": "9"},
+ {"id": "10", "kind": "control", "lon": 117.000649296107, "lat": 36.5921631022497, "label": "10"},
+ },
+ "legs": []map[string]any{
+ {"from": "1", "to": "2"},
+ {"from": "2", "to": "3"},
+ {"from": "3", "to": "4"},
+ {"from": "4", "to": "5"},
+ {"from": "5", "to": "6"},
+ {"from": "6", "to": "7"},
+ {"from": "7", "to": "8"},
+ {"from": "8", "to": "9"},
+ {"from": "9", "to": "10"},
+ },
+ },
+ {
+ "variantId": "variant_b",
+ "name": "路线 02",
+ "routeCode": "route-variant-b",
+ "controls": []map[string]any{
+ {"id": "1", "kind": "control", "lon": 117.000649296107, "lat": 36.5921631022497, "label": "1"},
+ {"id": "2", "kind": "control", "lon": 116.999766990062, "lat": 36.5921141343085, "label": "2"},
+ {"id": "3", "kind": "control", "lon": 116.999710067091, "lat": 36.5917615642144, "label": "3"},
+ {"id": "4", "kind": "control", "lon": 116.998774904002, "lat": 36.5913306430232, "label": "4"},
+ {"id": "5", "kind": "control", "lon": 116.998941606987, "lat": 36.5908278985923, "label": "5"},
+ {"id": "6", "kind": "control", "lon": 117.00058830721, "lat": 36.5905340853955, "label": "6"},
+ {"id": "7", "kind": "control", "lon": 117.000238637533, "lat": 36.5914742836876, "label": "7"},
+ {"id": "8", "kind": "control", "lon": 117.000937976887, "lat": 36.5916113949816, "label": "8"},
+ {"id": "9", "kind": "control", "lon": 117.000803801313, "lat": 36.5919411140006, "label": "9"},
+ {"id": "10", "kind": "control", "lon": 117.000649296107, "lat": 36.5921631022497, "label": "10"},
+ },
+ "legs": []map[string]any{
+ {"from": "1", "to": "2"},
+ {"from": "2", "to": "3"},
+ {"from": "3", "to": "4"},
+ {"from": "4", "to": "5"},
+ {"from": "5", "to": "6"},
+ {"from": "6", "to": "7"},
+ {"from": "7", "to": "8"},
+ {"from": "8", "to": "9"},
+ {"from": "9", "to": "10"},
+ },
+ },
+ {
+ "variantId": "variant_c",
+ "name": "路线 03",
+ "routeCode": "route-variant-c",
+ "controls": []map[string]any{
+ {"id": "1", "kind": "control", "lon": 117.000649296107, "lat": 36.5921631022497, "label": "1"},
+ {"id": "2", "kind": "control", "lon": 117.000665559813, "lat": 36.5919574366878, "label": "2"},
+ {"id": "3", "kind": "control", "lon": 117.001397426578, "lat": 36.5915983367736, "label": "3"},
+ {"id": "4", "kind": "control", "lon": 117.000441933857, "lat": 36.5915004001434, "label": "4"},
+ {"id": "5", "kind": "control", "lon": 117.000340285695, "lat": 36.5909356298175, "label": "5"},
+ {"id": "6", "kind": "control", "lon": 116.999860506371, "lat": 36.5912131186443, "label": "6"},
+ {"id": "7", "kind": "control", "lon": 116.999823913032, "lat": 36.591572220351, "label": "7"},
+ {"id": "8", "kind": "control", "lon": 116.999108309973, "lat": 36.5919019395375, "label": "8"},
+ {"id": "9", "kind": "control", "lon": 116.999689737459, "lat": 36.5922740961347, "label": "9"},
+ {"id": "10", "kind": "control", "lon": 117.000649296107, "lat": 36.5921631022497, "label": "10"},
+ },
+ "legs": []map[string]any{
+ {"from": "1", "to": "2"},
+ {"from": "2", "to": "3"},
+ {"from": "3", "to": "4"},
+ {"from": "4", "to": "5"},
+ {"from": "5", "to": "6"},
+ {"from": "6", "to": "7"},
+ {"from": "7", "to": "8"},
+ {"from": "8", "to": "9"},
+ {"from": "9", "to": "10"},
+ },
+ },
+ {
+ "variantId": "variant_d",
+ "name": "路线 04",
+ "routeCode": "route-variant-d",
+ "controls": []map[string]any{
+ {"id": "1", "kind": "control", "lon": 117.000649296107, "lat": 36.5921631022497, "label": "1"},
+ {"id": "2", "kind": "control", "lon": 117.000803801313, "lat": 36.5919411140006, "label": "2"},
+ {"id": "3", "kind": "control", "lon": 117.000937976887, "lat": 36.5916113949816, "label": "3"},
+ {"id": "4", "kind": "control", "lon": 117.000238637533, "lat": 36.5914742836876, "label": "4"},
+ {"id": "5", "kind": "control", "lon": 117.00058830721, "lat": 36.5905340853955, "label": "5"},
+ {"id": "6", "kind": "control", "lon": 116.998941606987, "lat": 36.5908278985923, "label": "6"},
+ {"id": "7", "kind": "control", "lon": 116.998774904002, "lat": 36.5913306430232, "label": "7"},
+ {"id": "8", "kind": "control", "lon": 116.999710067091, "lat": 36.5917615642144, "label": "8"},
+ {"id": "9", "kind": "control", "lon": 116.999766990062, "lat": 36.5921141343085, "label": "9"},
+ {"id": "10", "kind": "control", "lon": 117.000649296107, "lat": 36.5921631022497, "label": "10"},
+ },
+ "legs": []map[string]any{
+ {"from": "1", "to": "2"},
+ {"from": "2", "to": "3"},
+ {"from": "3", "to": "4"},
+ {"from": "4", "to": "5"},
+ {"from": "5", "to": "6"},
+ {"from": "6", "to": "7"},
+ {"from": "7", "to": "8"},
+ {"from": "8", "to": "9"},
+ {"from": "9", "to": "10"},
+ },
+ },
+ },
+ }
+ default:
+ return map[string]any{
+ "mode": "readonly",
+ "baseTiles": baseTiles,
+ "viewport": viewport,
+ "selectedVariantId": "variant_classic_main",
+ "variants": []map[string]any{
+ {
+ "variantId": "variant_classic_main",
+ "name": "顺序赛主赛道",
+ "routeCode": "route-demo-a",
+ "controls": []map[string]any{
+ {"id": "start", "kind": "start", "lon": 117.010, "lat": 36.610, "label": "起点"},
+ {"id": "c1", "kind": "control", "lon": 117.018, "lat": 36.615, "label": "1"},
+ {"id": "c2", "kind": "control", "lon": 117.027, "lat": 36.621, "label": "2"},
+ {"id": "c3", "kind": "control", "lon": 117.036, "lat": 36.627, "label": "3"},
+ {"id": "finish", "kind": "finish", "lon": 117.044, "lat": 36.632, "label": "终点"},
+ },
+ "legs": []map[string]any{
+ {"from": "start", "to": "c1"},
+ {"from": "c1", "to": "c2"},
+ {"from": "c2", "to": "c3"},
+ {"from": "c3", "to": "finish"},
+ },
+ },
+ },
+ }
+ }
+}
diff --git a/backend/internal/store/postgres/event_store.go b/backend/internal/store/postgres/event_store.go
index a510cfa..d9a392b 100644
--- a/backend/internal/store/postgres/event_store.go
+++ b/backend/internal/store/postgres/event_store.go
@@ -16,6 +16,8 @@ type Event struct {
DisplayName string
Summary *string
Status string
+ IsDefaultExperience bool
+ ShowInEventList bool
CurrentReleaseID *string
CurrentReleasePubID *string
ConfigLabel *string
@@ -113,6 +115,8 @@ func (s *Store) GetEventByPublicID(ctx context.Context, eventPublicID string) (*
e.display_name,
e.summary,
e.status,
+ e.is_default_experience,
+ e.show_in_event_list,
e.current_release_id,
er.release_public_id,
er.config_label,
@@ -159,6 +163,8 @@ func (s *Store) GetEventByPublicID(ctx context.Context, eventPublicID string) (*
&event.DisplayName,
&event.Summary,
&event.Status,
+ &event.IsDefaultExperience,
+ &event.ShowInEventList,
&event.CurrentReleaseID,
&event.CurrentReleasePubID,
&event.ConfigLabel,
@@ -202,6 +208,8 @@ func (s *Store) GetEventByID(ctx context.Context, eventID string) (*Event, error
e.display_name,
e.summary,
e.status,
+ e.is_default_experience,
+ e.show_in_event_list,
e.current_release_id,
er.release_public_id,
er.config_label,
@@ -248,6 +256,8 @@ func (s *Store) GetEventByID(ctx context.Context, eventID string) (*Event, error
&event.DisplayName,
&event.Summary,
&event.Status,
+ &event.IsDefaultExperience,
+ &event.ShowInEventList,
&event.CurrentReleaseID,
&event.CurrentReleasePubID,
&event.ConfigLabel,
@@ -601,3 +611,16 @@ func (s *Store) SetEventReleaseRuntimeBinding(ctx context.Context, tx Tx, releas
}
return nil
}
+
+func (s *Store) SetEventReleaseBindings(ctx context.Context, tx Tx, releaseID string, runtimeBindingID, presentationID, contentBundleID *string) error {
+ if _, err := tx.Exec(ctx, `
+ UPDATE event_releases
+ SET runtime_binding_id = $2,
+ presentation_id = $3,
+ content_bundle_id = $4
+ WHERE id = $1
+ `, releaseID, runtimeBindingID, presentationID, contentBundleID); err != nil {
+ return fmt.Errorf("set event release bindings: %w", err)
+ }
+ return nil
+}
diff --git a/backend/internal/store/postgres/map_experience_store.go b/backend/internal/store/postgres/map_experience_store.go
new file mode 100644
index 0000000..bcb3238
--- /dev/null
+++ b/backend/internal/store/postgres/map_experience_store.go
@@ -0,0 +1,216 @@
+package postgres
+
+import (
+ "context"
+ "fmt"
+)
+
+type MapExperienceRow struct {
+ PlacePublicID string
+ PlaceName string
+ MapAssetPublicID string
+ MapAssetName string
+ MapCoverURL *string
+ MapSummary *string
+ TileBaseURL *string
+ TileMetaURL *string
+ EventPublicID *string
+ EventDisplayName *string
+ EventSummary *string
+ EventStatus *string
+ EventIsDefaultExperience bool
+ EventShowInEventList bool
+ EventReleasePayloadJSON *string
+ EventPresentationID *string
+ EventPresentationName *string
+ EventPresentationType *string
+ EventPresentationSchema *string
+ EventContentBundleID *string
+ EventContentBundleName *string
+ EventContentEntryURL *string
+ EventContentAssetRootURL *string
+ EventContentMetadataJSON *string
+}
+
+func (s *Store) ListMapExperienceRows(ctx context.Context, limit int) ([]MapExperienceRow, error) {
+ if limit <= 0 || limit > 200 {
+ limit = 50
+ }
+ rows, err := s.pool.Query(ctx, `
+ SELECT
+ p.place_public_id,
+ p.name,
+ ma.map_asset_public_id,
+ ma.name,
+ COALESCE(ma.cover_url, p.cover_url) AS cover_url,
+ COALESCE(ma.description, p.description) AS summary,
+ tr.tile_base_url,
+ tr.meta_url,
+ e.event_public_id,
+ e.display_name,
+ e.summary,
+ e.status,
+ COALESCE(e.is_default_experience, false),
+ COALESCE(e.show_in_event_list, true),
+ er.payload_jsonb::text,
+ ep.presentation_public_id,
+ ep.name,
+ ep.presentation_type,
+ ep.schema_jsonb::text,
+ cb.content_bundle_public_id,
+ cb.name,
+ cb.entry_url,
+ cb.asset_root_url,
+ cb.metadata_jsonb::text
+ FROM map_assets ma
+ JOIN places p ON p.id = ma.place_id
+ LEFT JOIN tile_releases tr ON tr.id = ma.current_tile_release_id
+ LEFT JOIN map_runtime_bindings mrb
+ ON mrb.map_asset_id = ma.id
+ AND mrb.status = 'active'
+ LEFT JOIN event_releases er
+ ON er.runtime_binding_id = mrb.id
+ AND er.status = 'published'
+ LEFT JOIN events e
+ ON e.current_release_id = er.id
+ AND e.status = 'active'
+ LEFT JOIN event_presentations ep ON ep.id = er.presentation_id
+ LEFT JOIN content_bundles cb ON cb.id = er.content_bundle_id
+ WHERE ma.status = 'active'
+ AND (e.id IS NULL OR e.show_in_event_list = true)
+ ORDER BY p.name ASC, ma.name ASC, COALESCE(e.is_default_experience, false) DESC, e.display_name ASC
+ LIMIT $1
+ `, limit)
+ if err != nil {
+ return nil, fmt.Errorf("list map experience rows: %w", err)
+ }
+ defer rows.Close()
+
+ items := make([]MapExperienceRow, 0, limit)
+ for rows.Next() {
+ var item MapExperienceRow
+ if err := rows.Scan(
+ &item.PlacePublicID,
+ &item.PlaceName,
+ &item.MapAssetPublicID,
+ &item.MapAssetName,
+ &item.MapCoverURL,
+ &item.MapSummary,
+ &item.TileBaseURL,
+ &item.TileMetaURL,
+ &item.EventPublicID,
+ &item.EventDisplayName,
+ &item.EventSummary,
+ &item.EventStatus,
+ &item.EventIsDefaultExperience,
+ &item.EventShowInEventList,
+ &item.EventReleasePayloadJSON,
+ &item.EventPresentationID,
+ &item.EventPresentationName,
+ &item.EventPresentationType,
+ &item.EventPresentationSchema,
+ &item.EventContentBundleID,
+ &item.EventContentBundleName,
+ &item.EventContentEntryURL,
+ &item.EventContentAssetRootURL,
+ &item.EventContentMetadataJSON,
+ ); err != nil {
+ return nil, fmt.Errorf("scan map experience row: %w", err)
+ }
+ items = append(items, item)
+ }
+ if err := rows.Err(); err != nil {
+ return nil, fmt.Errorf("iterate map experience rows: %w", err)
+ }
+ return items, nil
+}
+
+func (s *Store) ListMapExperienceRowsByMapPublicID(ctx context.Context, mapAssetPublicID string) ([]MapExperienceRow, error) {
+ rows, err := s.pool.Query(ctx, `
+ SELECT
+ p.place_public_id,
+ p.name,
+ ma.map_asset_public_id,
+ ma.name,
+ COALESCE(ma.cover_url, p.cover_url) AS cover_url,
+ COALESCE(ma.description, p.description) AS summary,
+ tr.tile_base_url,
+ tr.meta_url,
+ e.event_public_id,
+ e.display_name,
+ e.summary,
+ e.status,
+ COALESCE(e.is_default_experience, false),
+ COALESCE(e.show_in_event_list, true),
+ er.payload_jsonb::text,
+ ep.presentation_public_id,
+ ep.name,
+ ep.presentation_type,
+ ep.schema_jsonb::text,
+ cb.content_bundle_public_id,
+ cb.name,
+ cb.entry_url,
+ cb.asset_root_url,
+ cb.metadata_jsonb::text
+ FROM map_assets ma
+ JOIN places p ON p.id = ma.place_id
+ LEFT JOIN tile_releases tr ON tr.id = ma.current_tile_release_id
+ LEFT JOIN map_runtime_bindings mrb
+ ON mrb.map_asset_id = ma.id
+ AND mrb.status = 'active'
+ LEFT JOIN event_releases er
+ ON er.runtime_binding_id = mrb.id
+ AND er.status = 'published'
+ LEFT JOIN events e
+ ON e.current_release_id = er.id
+ AND e.status = 'active'
+ LEFT JOIN event_presentations ep ON ep.id = er.presentation_id
+ LEFT JOIN content_bundles cb ON cb.id = er.content_bundle_id
+ WHERE ma.map_asset_public_id = $1
+ AND ma.status = 'active'
+ AND (e.id IS NULL OR e.show_in_event_list = true)
+ ORDER BY COALESCE(e.is_default_experience, false) DESC, e.display_name ASC
+ `, mapAssetPublicID)
+ if err != nil {
+ return nil, fmt.Errorf("list map experience rows by map public id: %w", err)
+ }
+ defer rows.Close()
+
+ items := make([]MapExperienceRow, 0, 8)
+ for rows.Next() {
+ var item MapExperienceRow
+ if err := rows.Scan(
+ &item.PlacePublicID,
+ &item.PlaceName,
+ &item.MapAssetPublicID,
+ &item.MapAssetName,
+ &item.MapCoverURL,
+ &item.MapSummary,
+ &item.TileBaseURL,
+ &item.TileMetaURL,
+ &item.EventPublicID,
+ &item.EventDisplayName,
+ &item.EventSummary,
+ &item.EventStatus,
+ &item.EventIsDefaultExperience,
+ &item.EventShowInEventList,
+ &item.EventReleasePayloadJSON,
+ &item.EventPresentationID,
+ &item.EventPresentationName,
+ &item.EventPresentationType,
+ &item.EventPresentationSchema,
+ &item.EventContentBundleID,
+ &item.EventContentBundleName,
+ &item.EventContentEntryURL,
+ &item.EventContentAssetRootURL,
+ &item.EventContentMetadataJSON,
+ ); err != nil {
+ return nil, fmt.Errorf("scan map experience detail row: %w", err)
+ }
+ items = append(items, item)
+ }
+ if err := rows.Err(); err != nil {
+ return nil, fmt.Errorf("iterate map experience detail rows: %w", err)
+ }
+ return items, nil
+}
diff --git a/backend/internal/store/postgres/ops_auth_store.go b/backend/internal/store/postgres/ops_auth_store.go
new file mode 100644
index 0000000..4f0f74d
--- /dev/null
+++ b/backend/internal/store/postgres/ops_auth_store.go
@@ -0,0 +1,217 @@
+package postgres
+
+import (
+ "context"
+ "errors"
+ "fmt"
+ "time"
+
+ "github.com/jackc/pgx/v5"
+)
+
+type OpsUser struct {
+ ID string
+ PublicID string
+ CountryCode string
+ Mobile string
+ DisplayName string
+ Status string
+ LastLoginAt *time.Time
+}
+
+type OpsRole struct {
+ ID string
+ RoleCode string
+ DisplayName string
+ RoleRank int
+}
+
+type CreateOpsUserParams struct {
+ PublicID string
+ CountryCode string
+ Mobile string
+ DisplayName string
+ Status string
+}
+
+type CreateOpsRefreshTokenParams struct {
+ OpsUserID string
+ DeviceKey string
+ TokenHash string
+ ExpiresAt time.Time
+}
+
+type OpsRefreshTokenRecord struct {
+ ID string
+ OpsUserID string
+ DeviceKey *string
+ ExpiresAt time.Time
+ IsRevoked bool
+}
+
+func (s *Store) CountOpsUsers(ctx context.Context) (int, error) {
+ row := s.pool.QueryRow(ctx, `SELECT COUNT(*) FROM ops_users WHERE status <> 'deleted'`)
+ var count int
+ if err := row.Scan(&count); err != nil {
+ return 0, fmt.Errorf("count ops users: %w", err)
+ }
+ return count, nil
+}
+
+func (s *Store) GetOpsUserByMobile(ctx context.Context, db queryRower, countryCode, mobile string) (*OpsUser, error) {
+ row := db.QueryRow(ctx, `
+ SELECT id, ops_user_public_id, country_code, mobile, display_name, status, last_login_at
+ FROM ops_users
+ WHERE country_code = $1 AND mobile = $2
+ LIMIT 1
+ `, countryCode, mobile)
+ return scanOpsUser(row)
+}
+
+func (s *Store) GetOpsUserByID(ctx context.Context, db queryRower, opsUserID string) (*OpsUser, error) {
+ row := db.QueryRow(ctx, `
+ SELECT id, ops_user_public_id, country_code, mobile, display_name, status, last_login_at
+ FROM ops_users
+ WHERE id = $1
+ `, opsUserID)
+ return scanOpsUser(row)
+}
+
+func (s *Store) CreateOpsUser(ctx context.Context, tx Tx, params CreateOpsUserParams) (*OpsUser, error) {
+ row := tx.QueryRow(ctx, `
+ INSERT INTO ops_users (ops_user_public_id, country_code, mobile, display_name, status)
+ VALUES ($1, $2, $3, $4, $5)
+ RETURNING id, ops_user_public_id, country_code, mobile, display_name, status, last_login_at
+ `, params.PublicID, params.CountryCode, params.Mobile, params.DisplayName, params.Status)
+ return scanOpsUser(row)
+}
+
+func (s *Store) TouchOpsUserLogin(ctx context.Context, tx Tx, opsUserID string) error {
+ _, err := tx.Exec(ctx, `
+ UPDATE ops_users
+ SET last_login_at = NOW()
+ WHERE id = $1
+ `, opsUserID)
+ if err != nil {
+ return fmt.Errorf("touch ops user last login: %w", err)
+ }
+ return nil
+}
+
+func (s *Store) GetOpsRoleByCode(ctx context.Context, tx Tx, roleCode string) (*OpsRole, error) {
+ row := tx.QueryRow(ctx, `
+ SELECT id, role_code, display_name, role_rank
+ FROM ops_roles
+ WHERE role_code = $1
+ AND status = 'active'
+ LIMIT 1
+ `, roleCode)
+ return scanOpsRole(row)
+}
+
+func (s *Store) AssignOpsRole(ctx context.Context, tx Tx, opsUserID, roleID string) error {
+ _, err := tx.Exec(ctx, `
+ INSERT INTO ops_user_roles (ops_user_id, ops_role_id, status)
+ VALUES ($1, $2, 'active')
+ ON CONFLICT (ops_user_id, ops_role_id) DO NOTHING
+ `, opsUserID, roleID)
+ if err != nil {
+ return fmt.Errorf("assign ops role: %w", err)
+ }
+ return nil
+}
+
+func (s *Store) GetPrimaryOpsRole(ctx context.Context, db queryRower, opsUserID string) (*OpsRole, error) {
+ row := db.QueryRow(ctx, `
+ SELECT r.id, r.role_code, r.display_name, r.role_rank
+ FROM ops_user_roles ur
+ JOIN ops_roles r ON r.id = ur.ops_role_id
+ WHERE ur.ops_user_id = $1
+ AND ur.status = 'active'
+ AND r.status = 'active'
+ ORDER BY r.role_rank DESC, r.created_at ASC
+ LIMIT 1
+ `, opsUserID)
+ return scanOpsRole(row)
+}
+
+func (s *Store) CreateOpsRefreshToken(ctx context.Context, tx Tx, params CreateOpsRefreshTokenParams) (string, error) {
+ row := tx.QueryRow(ctx, `
+ INSERT INTO ops_refresh_tokens (ops_user_id, device_key, token_hash, expires_at)
+ VALUES ($1, NULLIF($2, ''), $3, $4)
+ RETURNING id
+ `, params.OpsUserID, params.DeviceKey, params.TokenHash, params.ExpiresAt)
+
+ var id string
+ if err := row.Scan(&id); err != nil {
+ return "", fmt.Errorf("create ops refresh token: %w", err)
+ }
+ return id, nil
+}
+
+func (s *Store) GetOpsRefreshTokenForUpdate(ctx context.Context, tx Tx, tokenHash string) (*OpsRefreshTokenRecord, error) {
+ row := tx.QueryRow(ctx, `
+ SELECT id, ops_user_id, device_key, expires_at, revoked_at IS NOT NULL
+ FROM ops_refresh_tokens
+ WHERE token_hash = $1
+ FOR UPDATE
+ `, tokenHash)
+
+ var record OpsRefreshTokenRecord
+ err := row.Scan(&record.ID, &record.OpsUserID, &record.DeviceKey, &record.ExpiresAt, &record.IsRevoked)
+ if errors.Is(err, pgx.ErrNoRows) {
+ return nil, nil
+ }
+ if err != nil {
+ return nil, fmt.Errorf("query ops refresh token for update: %w", err)
+ }
+ return &record, nil
+}
+
+func (s *Store) RotateOpsRefreshToken(ctx context.Context, tx Tx, oldTokenID, newTokenID string) error {
+ _, err := tx.Exec(ctx, `
+ UPDATE ops_refresh_tokens
+ SET revoked_at = NOW(), replaced_by_token_id = $2
+ WHERE id = $1
+ `, oldTokenID, newTokenID)
+ if err != nil {
+ return fmt.Errorf("rotate ops refresh token: %w", err)
+ }
+ return nil
+}
+
+func (s *Store) RevokeOpsRefreshToken(ctx context.Context, tokenHash string) error {
+ _, err := s.pool.Exec(ctx, `
+ UPDATE ops_refresh_tokens
+ SET revoked_at = COALESCE(revoked_at, NOW())
+ WHERE token_hash = $1
+ `, tokenHash)
+ if err != nil {
+ return fmt.Errorf("revoke ops refresh token: %w", err)
+ }
+ return nil
+}
+
+func scanOpsUser(row pgx.Row) (*OpsUser, error) {
+ var item OpsUser
+ err := row.Scan(&item.ID, &item.PublicID, &item.CountryCode, &item.Mobile, &item.DisplayName, &item.Status, &item.LastLoginAt)
+ if errors.Is(err, pgx.ErrNoRows) {
+ return nil, nil
+ }
+ if err != nil {
+ return nil, fmt.Errorf("scan ops user: %w", err)
+ }
+ return &item, nil
+}
+
+func scanOpsRole(row pgx.Row) (*OpsRole, error) {
+ var item OpsRole
+ err := row.Scan(&item.ID, &item.RoleCode, &item.DisplayName, &item.RoleRank)
+ if errors.Is(err, pgx.ErrNoRows) {
+ return nil, nil
+ }
+ if err != nil {
+ return nil, fmt.Errorf("scan ops role: %w", err)
+ }
+ return &item, nil
+}
diff --git a/backend/internal/store/postgres/ops_summary_store.go b/backend/internal/store/postgres/ops_summary_store.go
new file mode 100644
index 0000000..7ae911b
--- /dev/null
+++ b/backend/internal/store/postgres/ops_summary_store.go
@@ -0,0 +1,66 @@
+package postgres
+
+import (
+ "context"
+ "fmt"
+)
+
+type OpsOverviewCounts struct {
+ ManagedAssets int
+ Places int
+ MapAssets int
+ TileReleases int
+ CourseSets int
+ CourseVariants int
+ Events int
+ DefaultEvents int
+ PublishedEvents int
+ ConfigSources int
+ Releases int
+ RuntimeBindings int
+ Presentations int
+ ContentBundles int
+ OpsUsers int
+}
+
+func (s *Store) GetOpsOverviewCounts(ctx context.Context) (*OpsOverviewCounts, error) {
+ row := s.pool.QueryRow(ctx, `
+ SELECT
+ (SELECT COUNT(*) FROM managed_assets WHERE status <> 'archived') AS managed_assets,
+ (SELECT COUNT(*) FROM places WHERE status <> 'archived') AS places,
+ (SELECT COUNT(*) FROM map_assets WHERE status <> 'archived') AS map_assets,
+ (SELECT COUNT(*) FROM tile_releases WHERE status <> 'archived') AS tile_releases,
+ (SELECT COUNT(*) FROM course_sets WHERE status <> 'archived') AS course_sets,
+ (SELECT COUNT(*) FROM course_variants WHERE status <> 'archived') AS course_variants,
+ (SELECT COUNT(*) FROM events WHERE status <> 'archived') AS events,
+ (SELECT COUNT(*) FROM events WHERE status <> 'archived' AND COALESCE(is_default_experience, false)) AS default_events,
+ (SELECT COUNT(*) FROM events WHERE status <> 'archived' AND current_release_id IS NOT NULL) AS published_events,
+ (SELECT COUNT(*) FROM event_config_sources) AS config_sources,
+ (SELECT COUNT(*) FROM event_releases) AS releases,
+ (SELECT COUNT(*) FROM map_runtime_bindings WHERE status <> 'archived') AS runtime_bindings,
+ (SELECT COUNT(*) FROM event_presentations WHERE status <> 'archived') AS presentations,
+ (SELECT COUNT(*) FROM content_bundles WHERE status <> 'archived') AS content_bundles,
+ (SELECT COUNT(*) FROM ops_users WHERE status <> 'deleted') AS ops_users
+ `)
+ var item OpsOverviewCounts
+ if err := row.Scan(
+ &item.ManagedAssets,
+ &item.Places,
+ &item.MapAssets,
+ &item.TileReleases,
+ &item.CourseSets,
+ &item.CourseVariants,
+ &item.Events,
+ &item.DefaultEvents,
+ &item.PublishedEvents,
+ &item.ConfigSources,
+ &item.Releases,
+ &item.RuntimeBindings,
+ &item.Presentations,
+ &item.ContentBundles,
+ &item.OpsUsers,
+ ); err != nil {
+ return nil, fmt.Errorf("get ops overview counts: %w", err)
+ }
+ return &item, nil
+}
diff --git a/backend/internal/store/postgres/production_store.go b/backend/internal/store/postgres/production_store.go
index 1028538..90389a5 100644
--- a/backend/internal/store/postgres/production_store.go
+++ b/backend/internal/store/postgres/production_store.go
@@ -28,6 +28,8 @@ type MapAsset struct {
ID string
PublicID string
PlaceID string
+ PlacePublicID *string
+ PlaceName *string
LegacyMapID *string
LegacyMapPublicID *string
Code string
@@ -152,6 +154,16 @@ type CreateMapAssetParams struct {
Status string
}
+type UpdateMapAssetParams struct {
+ MapAssetID string
+ Code string
+ Name string
+ MapType string
+ CoverURL *string
+ Description *string
+ Status string
+}
+
type CreateTileReleaseParams struct {
PublicID string
MapAssetID string
@@ -215,6 +227,53 @@ type CreateMapRuntimeBindingParams struct {
Notes *string
}
+type MapAssetLinkedEvent struct {
+ EventPublicID string
+ DisplayName string
+ Summary *string
+ Status string
+ IsDefaultExperience bool
+ ShowInEventList bool
+ CurrentReleasePublicID *string
+ ConfigLabel *string
+ RouteCode *string
+ CurrentPresentationID *string
+ CurrentPresentationName *string
+ CurrentContentBundleID *string
+ CurrentContentBundleName *string
+}
+
+func (s *Store) ListMapAssets(ctx context.Context, limit int) ([]MapAsset, error) {
+ if limit <= 0 || limit > 200 {
+ limit = 50
+ }
+ rows, err := s.pool.Query(ctx, `
+ SELECT ma.id, ma.map_asset_public_id, ma.place_id, p.place_public_id, p.name, ma.legacy_map_id, lm.map_public_id, ma.code, ma.name, ma.map_type,
+ ma.cover_url, ma.description, ma.status, ma.current_tile_release_id, ma.created_at, ma.updated_at
+ FROM map_assets ma
+ JOIN places p ON p.id = ma.place_id
+ LEFT JOIN maps lm ON lm.id = ma.legacy_map_id
+ ORDER BY ma.created_at DESC
+ LIMIT $1
+ `, limit)
+ if err != nil {
+ return nil, fmt.Errorf("list all map assets: %w", err)
+ }
+ defer rows.Close()
+ items := []MapAsset{}
+ for rows.Next() {
+ item, err := scanMapAssetFromRows(rows)
+ if err != nil {
+ return nil, err
+ }
+ items = append(items, *item)
+ }
+ if err := rows.Err(); err != nil {
+ return nil, fmt.Errorf("iterate all map assets: %w", err)
+ }
+ return items, nil
+}
+
func (s *Store) ListPlaces(ctx context.Context, limit int) ([]Place, error) {
if limit <= 0 || limit > 200 {
limit = 50
@@ -253,6 +312,16 @@ func (s *Store) GetPlaceByPublicID(ctx context.Context, publicID string) (*Place
return scanPlace(row)
}
+func (s *Store) GetPlaceByCode(ctx context.Context, code string) (*Place, error) {
+ row := s.pool.QueryRow(ctx, `
+ SELECT id, place_public_id, code, name, region, cover_url, description, center_point_jsonb::text, status, created_at, updated_at
+ FROM places
+ WHERE code = $1
+ LIMIT 1
+ `, code)
+ return scanPlace(row)
+}
+
func (s *Store) CreatePlace(ctx context.Context, tx Tx, params CreatePlaceParams) (*Place, error) {
centerPointJSON, err := marshalJSONMap(params.CenterPoint)
if err != nil {
@@ -268,9 +337,10 @@ func (s *Store) CreatePlace(ctx context.Context, tx Tx, params CreatePlaceParams
func (s *Store) ListMapAssetsByPlaceID(ctx context.Context, placeID string) ([]MapAsset, error) {
rows, err := s.pool.Query(ctx, `
- SELECT ma.id, ma.map_asset_public_id, ma.place_id, ma.legacy_map_id, lm.map_public_id, ma.code, ma.name, ma.map_type,
+ SELECT ma.id, ma.map_asset_public_id, ma.place_id, p.place_public_id, p.name, ma.legacy_map_id, lm.map_public_id, ma.code, ma.name, ma.map_type,
ma.cover_url, ma.description, ma.status, ma.current_tile_release_id, ma.created_at, ma.updated_at
FROM map_assets ma
+ JOIN places p ON p.id = ma.place_id
LEFT JOIN maps lm ON lm.id = ma.legacy_map_id
WHERE ma.place_id = $1
ORDER BY ma.created_at DESC
@@ -295,9 +365,10 @@ func (s *Store) ListMapAssetsByPlaceID(ctx context.Context, placeID string) ([]M
func (s *Store) GetMapAssetByPublicID(ctx context.Context, publicID string) (*MapAsset, error) {
row := s.pool.QueryRow(ctx, `
- SELECT ma.id, ma.map_asset_public_id, ma.place_id, ma.legacy_map_id, lm.map_public_id, ma.code, ma.name, ma.map_type,
+ SELECT ma.id, ma.map_asset_public_id, ma.place_id, p.place_public_id, p.name, ma.legacy_map_id, lm.map_public_id, ma.code, ma.name, ma.map_type,
ma.cover_url, ma.description, ma.status, ma.current_tile_release_id, ma.created_at, ma.updated_at
FROM map_assets ma
+ JOIN places p ON p.id = ma.place_id
LEFT JOIN maps lm ON lm.id = ma.legacy_map_id
WHERE ma.map_asset_public_id = $1
LIMIT 1
@@ -305,15 +376,44 @@ func (s *Store) GetMapAssetByPublicID(ctx context.Context, publicID string) (*Ma
return scanMapAsset(row)
}
+func (s *Store) GetMapAssetByCode(ctx context.Context, code string) (*MapAsset, error) {
+ row := s.pool.QueryRow(ctx, `
+ SELECT ma.id, ma.map_asset_public_id, ma.place_id, p.place_public_id, p.name, ma.legacy_map_id, lm.map_public_id, ma.code, ma.name, ma.map_type,
+ ma.cover_url, ma.description, ma.status, ma.current_tile_release_id, ma.created_at, ma.updated_at
+ FROM map_assets ma
+ JOIN places p ON p.id = ma.place_id
+ LEFT JOIN maps lm ON lm.id = ma.legacy_map_id
+ WHERE ma.code = $1
+ LIMIT 1
+ `, code)
+ return scanMapAsset(row)
+}
+
func (s *Store) CreateMapAsset(ctx context.Context, tx Tx, params CreateMapAssetParams) (*MapAsset, error) {
row := tx.QueryRow(ctx, `
INSERT INTO map_assets (map_asset_public_id, place_id, legacy_map_id, code, name, map_type, cover_url, description, status)
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9)
- RETURNING id, map_asset_public_id, place_id, legacy_map_id, NULL::text, code, name, map_type, cover_url, description, status, current_tile_release_id, created_at, updated_at
+ RETURNING id, map_asset_public_id, place_id, NULL::text, NULL::text, legacy_map_id, NULL::text, code, name, map_type, cover_url, description, status, current_tile_release_id, created_at, updated_at
`, params.PublicID, params.PlaceID, params.LegacyMapID, params.Code, params.Name, params.MapType, params.CoverURL, params.Description, params.Status)
return scanMapAsset(row)
}
+func (s *Store) UpdateMapAsset(ctx context.Context, tx Tx, params UpdateMapAssetParams) (*MapAsset, error) {
+ row := tx.QueryRow(ctx, `
+ UPDATE map_assets
+ SET code = $2,
+ name = $3,
+ map_type = $4,
+ cover_url = $5,
+ description = $6,
+ status = $7,
+ updated_at = NOW()
+ WHERE id = $1
+ RETURNING id, map_asset_public_id, place_id, NULL::text, NULL::text, legacy_map_id, NULL::text, code, name, map_type, cover_url, description, status, current_tile_release_id, created_at, updated_at
+ `, params.MapAssetID, params.Code, params.Name, params.MapType, params.CoverURL, params.Description, params.Status)
+ return scanMapAsset(row)
+}
+
func (s *Store) ListTileReleasesByMapAssetID(ctx context.Context, mapAssetID string) ([]TileRelease, error) {
rows, err := s.pool.Query(ctx, `
SELECT tr.id, tr.tile_release_public_id, tr.map_asset_id, tr.legacy_map_version_id, mv.version_public_id,
@@ -355,6 +455,19 @@ func (s *Store) GetTileReleaseByPublicID(ctx context.Context, publicID string) (
return scanTileRelease(row)
}
+func (s *Store) GetTileReleaseByMapAssetIDAndVersionCode(ctx context.Context, mapAssetID, versionCode string) (*TileRelease, error) {
+ row := s.pool.QueryRow(ctx, `
+ SELECT tr.id, tr.tile_release_public_id, tr.map_asset_id, tr.legacy_map_version_id, mv.version_public_id,
+ tr.version_code, tr.status, tr.tile_base_url, tr.meta_url, tr.published_asset_root,
+ tr.metadata_jsonb::text, tr.published_at, tr.created_at, tr.updated_at
+ FROM tile_releases tr
+ LEFT JOIN map_versions mv ON mv.id = tr.legacy_map_version_id
+ WHERE tr.map_asset_id = $1 AND tr.version_code = $2
+ LIMIT 1
+ `, mapAssetID, versionCode)
+ return scanTileRelease(row)
+}
+
func (s *Store) CreateTileRelease(ctx context.Context, tx Tx, params CreateTileReleaseParams) (*TileRelease, error) {
metadataJSON, err := marshalJSONMap(params.MetadataJSON)
if err != nil {
@@ -506,6 +619,16 @@ func (s *Store) GetCourseSetByPublicID(ctx context.Context, publicID string) (*C
return scanCourseSet(row)
}
+func (s *Store) GetCourseSetByCode(ctx context.Context, code string) (*CourseSet, error) {
+ row := s.pool.QueryRow(ctx, `
+ SELECT id, course_set_public_id, place_id, map_asset_id, code, mode, name, description, status, current_variant_id, created_at, updated_at
+ FROM course_sets
+ WHERE code = $1
+ LIMIT 1
+ `, code)
+ return scanCourseSet(row)
+}
+
func (s *Store) CreateCourseSet(ctx context.Context, tx Tx, params CreateCourseSetParams) (*CourseSet, error) {
row := tx.QueryRow(ctx, `
INSERT INTO course_sets (course_set_public_id, place_id, map_asset_id, code, mode, name, description, status)
@@ -556,6 +679,19 @@ func (s *Store) GetCourseVariantByPublicID(ctx context.Context, publicID string)
return scanCourseVariant(row)
}
+func (s *Store) GetCourseVariantByCourseSetIDAndRouteCode(ctx context.Context, courseSetID, routeCode string) (*CourseVariant, error) {
+ row := s.pool.QueryRow(ctx, `
+ SELECT cv.id, cv.course_variant_public_id, cv.course_set_id, cv.source_id, cs.course_source_public_id, cv.name, cv.route_code,
+ cv.mode, cv.control_count, cv.difficulty, cv.status, cv.is_default,
+ cv.config_patch_jsonb::text, cv.metadata_jsonb::text, cv.created_at, cv.updated_at
+ FROM course_variants cv
+ LEFT JOIN course_sources cs ON cs.id = cv.source_id
+ WHERE cv.course_set_id = $1 AND cv.route_code = $2
+ LIMIT 1
+ `, courseSetID, routeCode)
+ return scanCourseVariant(row)
+}
+
func (s *Store) CreateCourseVariant(ctx context.Context, tx Tx, params CreateCourseVariantParams) (*CourseVariant, error) {
configPatchJSON, err := marshalJSONMap(params.ConfigPatch)
if err != nil {
@@ -641,6 +777,66 @@ func (s *Store) GetMapRuntimeBindingByPublicID(ctx context.Context, publicID str
return scanMapRuntimeBinding(row)
}
+func (s *Store) ListMapAssetLinkedEvents(ctx context.Context, mapAssetID string, limit int) ([]MapAssetLinkedEvent, error) {
+ if limit <= 0 || limit > 200 {
+ limit = 50
+ }
+ rows, err := s.pool.Query(ctx, `
+ SELECT
+ e.event_public_id,
+ e.display_name,
+ e.summary,
+ e.status,
+ COALESCE(e.is_default_experience, false),
+ COALESCE(e.show_in_event_list, true),
+ er.release_public_id,
+ er.config_label,
+ er.route_code,
+ ep.presentation_public_id,
+ ep.name,
+ cb.content_bundle_public_id,
+ cb.name
+ FROM events e
+ JOIN map_runtime_bindings mrb ON mrb.id = e.current_runtime_binding_id
+ LEFT JOIN event_releases er ON er.id = e.current_release_id
+ LEFT JOIN event_presentations ep ON ep.id = e.current_presentation_id
+ LEFT JOIN content_bundles cb ON cb.id = e.current_content_bundle_id
+ WHERE mrb.map_asset_id = $1
+ ORDER BY COALESCE(e.is_default_experience, false) DESC, e.display_name ASC
+ LIMIT $2
+ `, mapAssetID, limit)
+ if err != nil {
+ return nil, fmt.Errorf("list map asset linked events: %w", err)
+ }
+ defer rows.Close()
+ items := []MapAssetLinkedEvent{}
+ for rows.Next() {
+ var item MapAssetLinkedEvent
+ if err := rows.Scan(
+ &item.EventPublicID,
+ &item.DisplayName,
+ &item.Summary,
+ &item.Status,
+ &item.IsDefaultExperience,
+ &item.ShowInEventList,
+ &item.CurrentReleasePublicID,
+ &item.ConfigLabel,
+ &item.RouteCode,
+ &item.CurrentPresentationID,
+ &item.CurrentPresentationName,
+ &item.CurrentContentBundleID,
+ &item.CurrentContentBundleName,
+ ); err != nil {
+ return nil, fmt.Errorf("scan map asset linked event: %w", err)
+ }
+ items = append(items, item)
+ }
+ if err := rows.Err(); err != nil {
+ return nil, fmt.Errorf("iterate map asset linked events: %w", err)
+ }
+ return items, nil
+}
+
func (s *Store) CreateMapRuntimeBinding(ctx context.Context, tx Tx, params CreateMapRuntimeBindingParams) (*MapRuntimeBinding, error) {
row := tx.QueryRow(ctx, `
INSERT INTO map_runtime_bindings (
@@ -681,7 +877,7 @@ func scanPlaceFromRows(rows pgx.Rows) (*Place, error) {
func scanMapAsset(row pgx.Row) (*MapAsset, error) {
var item MapAsset
- err := row.Scan(&item.ID, &item.PublicID, &item.PlaceID, &item.LegacyMapID, &item.LegacyMapPublicID, &item.Code, &item.Name, &item.MapType, &item.CoverURL, &item.Description, &item.Status, &item.CurrentTileReleaseID, &item.CreatedAt, &item.UpdatedAt)
+ err := row.Scan(&item.ID, &item.PublicID, &item.PlaceID, &item.PlacePublicID, &item.PlaceName, &item.LegacyMapID, &item.LegacyMapPublicID, &item.Code, &item.Name, &item.MapType, &item.CoverURL, &item.Description, &item.Status, &item.CurrentTileReleaseID, &item.CreatedAt, &item.UpdatedAt)
if errors.Is(err, pgx.ErrNoRows) {
return nil, nil
}
@@ -693,7 +889,7 @@ func scanMapAsset(row pgx.Row) (*MapAsset, error) {
func scanMapAssetFromRows(rows pgx.Rows) (*MapAsset, error) {
var item MapAsset
- err := rows.Scan(&item.ID, &item.PublicID, &item.PlaceID, &item.LegacyMapID, &item.LegacyMapPublicID, &item.Code, &item.Name, &item.MapType, &item.CoverURL, &item.Description, &item.Status, &item.CurrentTileReleaseID, &item.CreatedAt, &item.UpdatedAt)
+ err := rows.Scan(&item.ID, &item.PublicID, &item.PlaceID, &item.PlacePublicID, &item.PlaceName, &item.LegacyMapID, &item.LegacyMapPublicID, &item.Code, &item.Name, &item.MapType, &item.CoverURL, &item.Description, &item.Status, &item.CurrentTileReleaseID, &item.CreatedAt, &item.UpdatedAt)
if err != nil {
return nil, fmt.Errorf("scan map asset row: %w", err)
}
diff --git a/backend/migrations/0012_managed_assets.sql b/backend/migrations/0012_managed_assets.sql
new file mode 100644
index 0000000..aa0e3d9
--- /dev/null
+++ b/backend/migrations/0012_managed_assets.sql
@@ -0,0 +1,33 @@
+BEGIN;
+
+CREATE TABLE managed_assets (
+ id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
+ asset_public_id TEXT NOT NULL UNIQUE,
+ asset_type TEXT NOT NULL,
+ asset_code TEXT NOT NULL,
+ version TEXT NOT NULL,
+ title TEXT,
+ source_mode TEXT NOT NULL CHECK (source_mode IN ('uploaded', 'external_link')),
+ storage_provider TEXT NOT NULL CHECK (storage_provider IN ('oss', 'external')),
+ object_key TEXT,
+ public_url TEXT NOT NULL,
+ file_name TEXT,
+ content_type TEXT,
+ file_size_bytes BIGINT,
+ checksum_sha256 TEXT,
+ status TEXT NOT NULL DEFAULT 'active' CHECK (status IN ('draft', 'active', 'disabled', 'archived')),
+ metadata_jsonb JSONB NOT NULL DEFAULT '{}'::jsonb,
+ created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
+ updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
+ UNIQUE (asset_type, asset_code, version)
+);
+
+CREATE INDEX managed_assets_asset_type_idx ON managed_assets(asset_type);
+CREATE INDEX managed_assets_asset_code_idx ON managed_assets(asset_code);
+CREATE INDEX managed_assets_status_idx ON managed_assets(status);
+
+CREATE TRIGGER managed_assets_set_updated_at
+BEFORE UPDATE ON managed_assets
+FOR EACH ROW EXECUTE FUNCTION set_updated_at();
+
+COMMIT;
diff --git a/backend/migrations/0013_ops_console.sql b/backend/migrations/0013_ops_console.sql
new file mode 100644
index 0000000..e70669e
--- /dev/null
+++ b/backend/migrations/0013_ops_console.sql
@@ -0,0 +1,79 @@
+BEGIN;
+
+ALTER TABLE auth_sms_codes
+ DROP CONSTRAINT IF EXISTS auth_sms_codes_client_type_check;
+
+ALTER TABLE auth_sms_codes
+ ADD CONSTRAINT auth_sms_codes_client_type_check
+ CHECK (client_type IN ('app', 'wechat', 'ops'));
+
+CREATE TABLE ops_roles (
+ id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
+ role_code TEXT NOT NULL UNIQUE,
+ display_name TEXT NOT NULL,
+ role_rank INT NOT NULL,
+ permissions_jsonb JSONB NOT NULL DEFAULT '{}'::jsonb,
+ status TEXT NOT NULL DEFAULT 'active' CHECK (status IN ('active', 'disabled', 'archived')),
+ created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
+ updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
+);
+
+CREATE TRIGGER ops_roles_set_updated_at
+BEFORE UPDATE ON ops_roles
+FOR EACH ROW EXECUTE FUNCTION set_updated_at();
+
+CREATE TABLE ops_users (
+ id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
+ ops_user_public_id TEXT NOT NULL UNIQUE,
+ country_code TEXT NOT NULL,
+ mobile TEXT NOT NULL,
+ display_name TEXT NOT NULL,
+ status TEXT NOT NULL DEFAULT 'active' CHECK (status IN ('active', 'disabled', 'deleted')),
+ last_login_at TIMESTAMPTZ,
+ created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
+ updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
+ UNIQUE (country_code, mobile)
+);
+
+CREATE INDEX ops_users_mobile_idx ON ops_users(country_code, mobile);
+
+CREATE TRIGGER ops_users_set_updated_at
+BEFORE UPDATE ON ops_users
+FOR EACH ROW EXECUTE FUNCTION set_updated_at();
+
+CREATE TABLE ops_user_roles (
+ id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
+ ops_user_id UUID NOT NULL REFERENCES ops_users(id) ON DELETE CASCADE,
+ ops_role_id UUID NOT NULL REFERENCES ops_roles(id) ON DELETE CASCADE,
+ status TEXT NOT NULL DEFAULT 'active' CHECK (status IN ('active', 'disabled')),
+ assigned_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
+ assigned_by_ops_user_id UUID REFERENCES ops_users(id) ON DELETE SET NULL,
+ UNIQUE (ops_user_id, ops_role_id)
+);
+
+CREATE INDEX ops_user_roles_user_idx ON ops_user_roles(ops_user_id);
+CREATE INDEX ops_user_roles_role_idx ON ops_user_roles(ops_role_id);
+
+CREATE TABLE ops_refresh_tokens (
+ id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
+ ops_user_id UUID NOT NULL REFERENCES ops_users(id) ON DELETE CASCADE,
+ device_key TEXT,
+ token_hash TEXT NOT NULL UNIQUE,
+ issued_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
+ expires_at TIMESTAMPTZ NOT NULL,
+ revoked_at TIMESTAMPTZ,
+ replaced_by_token_id UUID REFERENCES ops_refresh_tokens(id) ON DELETE SET NULL
+);
+
+CREATE INDEX ops_refresh_tokens_user_idx ON ops_refresh_tokens(ops_user_id);
+CREATE INDEX ops_refresh_tokens_expires_idx ON ops_refresh_tokens(expires_at);
+
+INSERT INTO ops_roles (role_code, display_name, role_rank, permissions_jsonb)
+VALUES
+ ('owner', '所有者', 100, '{"scope":"all"}'::jsonb),
+ ('admin', '管理员', 80, '{"scope":"ops_admin"}'::jsonb),
+ ('operator', '运维专员', 50, '{"scope":"ops_operator"}'::jsonb),
+ ('viewer', '只读观察员', 10, '{"scope":"ops_viewer"}'::jsonb)
+ON CONFLICT (role_code) DO NOTHING;
+
+COMMIT;
diff --git a/backend/migrations/0014_map_experience.sql b/backend/migrations/0014_map_experience.sql
new file mode 100644
index 0000000..638732d
--- /dev/null
+++ b/backend/migrations/0014_map_experience.sql
@@ -0,0 +1,30 @@
+BEGIN;
+
+ALTER TABLE events
+ADD COLUMN IF NOT EXISTS is_default_experience BOOLEAN NOT NULL DEFAULT false;
+
+ALTER TABLE events
+ADD COLUMN IF NOT EXISTS show_in_event_list BOOLEAN NOT NULL DEFAULT true;
+
+CREATE INDEX IF NOT EXISTS events_is_default_experience_idx
+ON events(is_default_experience);
+
+CREATE INDEX IF NOT EXISTS events_show_in_event_list_idx
+ON events(show_in_event_list);
+
+UPDATE events
+SET
+ is_default_experience = true,
+ show_in_event_list = true
+WHERE event_public_id = 'evt_demo_001';
+
+UPDATE events
+SET
+ is_default_experience = false,
+ show_in_event_list = true
+WHERE event_public_id IN (
+ 'evt_demo_score_o_001',
+ 'evt_demo_variant_manual_001'
+);
+
+COMMIT;
diff --git a/backend/migrations/0015_guest_identity.sql b/backend/migrations/0015_guest_identity.sql
new file mode 100644
index 0000000..6ca1894
--- /dev/null
+++ b/backend/migrations/0015_guest_identity.sql
@@ -0,0 +1,14 @@
+ALTER TABLE login_identities
+DROP CONSTRAINT IF EXISTS login_identities_identity_type_check;
+
+ALTER TABLE login_identities
+ADD CONSTRAINT login_identities_identity_type_check
+CHECK (
+ identity_type IN (
+ 'mobile',
+ 'wechat_mini_openid',
+ 'wechat_oa_openid',
+ 'wechat_unionid',
+ 'guest'
+ )
+);
diff --git a/doc/archive/协作/T2B阶段归档.md b/doc/archive/协作/T2B阶段归档.md
new file mode 100644
index 0000000..c2d13eb
--- /dev/null
+++ b/doc/archive/协作/T2B阶段归档.md
@@ -0,0 +1,26 @@
+# T2B 阶段归档
+> 文档版本:v1.0
+> 最后更新:2026-04-07 11:43:47
+
+本文档用于归档总控线程过去写给 backend 线程的阶段性说明,避免根目录 [t2b.md](D:/dev/cmr-mini/t2b.md) 持续膨胀。
+
+当前归档范围包括:
+
+- 第一阶段生产骨架对象定义与落库说明
+- `MapRuntimeBinding -> EventRelease -> launch.runtime` 接线阶段
+- 活动运营域第二阶段各刀说明
+- 联调标准化阶段说明
+- 真实输入替换第一刀/第二刀说明
+- 活动卡片列表最小产品化第一刀准备与接线说明
+
+后续原则:
+
+- 历史已完成阶段进入归档
+- 根目录 `t2b.md` 只保留当前阶段目标、当前任务、当前边界
+
+如需回看历史阶段细节,请以本归档文档和以下正式文档为准:
+
+- [后台生产闭环架构草案](D:/dev/cmr-mini/doc/backend/后台生产闭环架构草案.md)
+- [活动卡片列表最小产品方案](D:/dev/cmr-mini/doc/gameplay/活动卡片列表最小产品方案.md)
+- [运维后台第一期方案](D:/dev/cmr-mini/doc/gameplay/运维后台第一期方案.md)
+- [准备页地图预览方案](D:/dev/cmr-mini/doc/gameplay/准备页地图预览方案.md)
diff --git a/doc/archive/协作/T2F阶段归档.md b/doc/archive/协作/T2F阶段归档.md
new file mode 100644
index 0000000..f11f9c1
--- /dev/null
+++ b/doc/archive/协作/T2F阶段归档.md
@@ -0,0 +1,24 @@
+# T2F 阶段归档
+> 文档版本:v1.0
+> 最后更新:2026-04-07 11:43:47
+
+本文档用于归档总控线程过去写给 frontend 线程的阶段性说明,避免根目录 [t2f.md](D:/dev/cmr-mini/t2f.md) 持续膨胀。
+
+当前归档范围包括:
+
+- runtime 摘要链接线阶段说明
+- 活动运营域摘要第一刀说明
+- 活动卡片列表最小产品化第一刀准备与开发说明
+- 联调标准化阶段前端协作说明
+
+后续原则:
+
+- 历史已完成阶段进入归档
+- 根目录 `t2f.md` 只保留当前阶段目标、当前任务、当前边界
+
+如需回看历史阶段细节,请以本归档文档和以下正式文档为准:
+
+- [活动卡片列表最小产品方案](D:/dev/cmr-mini/doc/gameplay/活动卡片列表最小产品方案.md)
+- [活动运营域摘要第一刀联调回归清单](D:/dev/cmr-mini/doc/gameplay/活动运营域摘要第一刀联调回归清单.md)
+- [第五刀联调回归清单](D:/dev/cmr-mini/doc/gameplay/第五刀联调回归清单.md)
+- [准备页地图预览方案](D:/dev/cmr-mini/doc/gameplay/准备页地图预览方案.md)
diff --git a/doc/backend/后台游戏定制支持方案.md b/doc/backend/后台游戏定制支持方案.md
new file mode 100644
index 0000000..2aeb28e
--- /dev/null
+++ b/doc/backend/后台游戏定制支持方案.md
@@ -0,0 +1,474 @@
+# 后台游戏定制支持方案
+> 文档版本:v1.0
+> 最后更新:2026-04-07 13:05:00
+
+本文档用于定义后台运维平台后续如何正式支持“游戏定制”,重点回答以下问题:
+
+- 后台应支持哪些层级的游戏定制
+- 哪些能力适合做成后台对象,哪些应继续留在程序默认值层
+- 活动、地图、赛道、玩法、展示、内容、发布之间应如何分层
+- 后台第一阶段与后续阶段应按什么顺序推进
+
+本文档是对 [运维后台第一期方案](/D:/dev/cmr-mini/doc/gameplay/运维后台第一期方案.md) 的补充,不替代第一期范围文档。前者回答“第一期做哪些后台模块”,本文档回答“后台长期如何支撑游戏定制”。
+
+---
+
+## 1. 总体结论
+
+后台后续不应做成“全量 JSON 编辑器”,而应做成一套**活动生产与发布平台**。
+
+游戏定制建议按以下 5 层支持:
+
+1. 活动层
+2. 地图与赛道层
+3. 玩法层
+4. 运营内容层
+5. 发布层
+
+这 5 层的核心原则是:
+
+- 稳定能力优先留在程序默认值层
+- 只有真实存在活动差异、且运营会改的能力才进入后台
+- 客户端最终只消费发布后的稳定产物,不直接消费后台草稿对象
+
+---
+
+## 2. 后台支持游戏定制的 5 层模型
+
+### 2.1 活动层
+
+活动层回答“这是一场什么活动”。
+
+建议后台在这一层支持:
+
+- 活动基本信息
+- 活动状态
+- 活动时间窗口
+- 默认体验活动标记
+- 当前发布 release
+- 当前是否允许进入
+- 活动卡片摘要
+
+这一层不应暴露大量技术字段,不直接暴露地图、瓦片、KML 原始对象。
+
+### 2.2 地图与赛道层
+
+地图与赛道层回答“这场活动在哪张地图、哪条赛道上运行”。
+
+建议后台支持:
+
+- 地点管理
+- 地图管理
+- 瓦片版本管理
+- KML / 原始赛道源输入
+- `CourseSet`
+- `CourseVariant`
+- 多赛道 `assignmentMode`
+- 赛道预览
+
+这一层是后续游戏定制的核心层。
+
+关键原则:
+
+- 一个活动可绑定多个 `variant`
+- 一个 `session` 最终只绑定一个 `variantId`
+- 多赛道选择、随机指定、后端指定都应以 `session` 绑定为准
+
+### 2.3 玩法层
+
+玩法层回答“这场活动按什么规则玩”。
+
+建议后台支持:
+
+- 选择玩法模板
+- 少量玩法差异项
+- 玩法高级选项
+
+不建议后台一开始就暴露底层散装字段。应继续坚持:
+
+- 程序默认值
+- 玩法默认值
+- 活动覆盖值
+
+例如可以后台开放:
+
+- 顺序赛是否允许跳点
+- 跳点是否需要确认
+- 积分赛终点解锁条件
+- 关门时间
+- 多赛道选择模式
+
+但不建议一开始开放:
+
+- HUD 细碎映射
+- 所有音效参数
+- 全量点位显示细项
+
+### 2.4 运营内容层
+
+运营内容层回答“活动如何展示、用什么内容表达”。
+
+建议后台支持:
+
+- `EventPresentation`
+- `ContentBundle`
+- 当前 active presentation
+- 当前 active content bundle
+- 绑定状态
+- 发布前完整性检查
+
+这里已经和当前前后端第二阶段联调方向一致:
+
+- `currentPresentation`
+- `currentContentBundle`
+- `launch.presentation`
+- `launch.contentBundle`
+
+建议后台在这一层承担:
+
+- 选择当前展示版本
+- 选择当前内容包版本
+- 校验是否影响 `canLaunch`
+
+### 2.5 发布层
+
+发布层回答“客户端最终实际消费什么”。
+
+建议后台支持:
+
+- source/build/release
+- 当前发布版本
+- 历史版本
+- 发布记录
+- 回滚
+- 是否完整绑定:
+ - runtime
+ - presentation
+ - content bundle
+
+客户端最终应只认:
+
+- `EventRelease`
+- `launch`
+- `manifest/config/runtime/presentation/contentBundle` 摘要
+
+---
+
+## 3. 后台对象模型建议
+
+建议后续后台围绕以下对象正式建设:
+
+- `Event`
+- `EventRelease`
+- `MapRuntimeBinding`
+- `EventPresentation`
+- `ContentBundle`
+- `Place`
+- `MapAsset`
+- `TileRelease`
+- `CourseSource`
+- `CourseSet`
+- `CourseVariant`
+- `GameTemplate`
+
+对象分工建议如下。
+
+### 3.1 Event
+
+负责活动业务壳:
+
+- 标题
+- 副标题
+- 活动类型
+- 状态
+- 默认体验标记
+- 当前 active 三元组引用
+
+### 3.2 EventRelease
+
+负责客户端正式消费版本:
+
+- `presentationId`
+- `contentBundleId`
+- `runtimeBindingId`
+- `manifestUrl`
+- `configUrl`
+- 状态
+- 发布时间
+
+### 3.3 MapRuntimeBinding
+
+负责把活动运营域和地图运行域绑定起来:
+
+- `placeId`
+- `mapId`
+- `tileReleaseId`
+- `courseSetId`
+- `courseVariantId`
+- `configReleaseId`
+
+### 3.4 EventPresentation
+
+负责活动展示与卡片/详情结构。
+
+### 3.5 ContentBundle
+
+负责活动内容、资源、动画、音频、文创素材等静态资源引用。
+
+### 3.6 GameTemplate
+
+负责玩法模板和规则层默认差异。
+
+这层的作用不是替代程序默认值,而是让后台运营在不碰底层散装字段的前提下,选择“规则骨架”。
+
+---
+
+## 4. 后台定制边界建议
+
+后台后续不要做成“所有变量都可配”,建议按 3 级开放:
+
+### 4.1 核心必需项
+
+必须在后台显式可见:
+
+- 活动
+- 当前发布 release
+- runtime binding
+- presentation
+- content bundle
+- 地图
+- 赛道
+- 玩法模板
+
+### 4.2 常用活动项
+
+适合在后台标准表单中开放:
+
+- 多赛道模式
+- 积分赛 / 顺序赛少量规则项
+- 关门时间
+- 终点解锁条件
+- 赛道预览开关
+- 展示版本选择
+- 内容包选择
+
+### 4.3 高级配置项
+
+保留为高级区或后续阶段:
+
+- 点位覆盖
+- 腿线覆盖
+- HUD 高级细项
+- 音效/震动细项
+- 复杂调试/实验开关
+
+---
+
+## 5. 建议的后台页面/模块
+
+如果后台要开始支撑游戏定制,建议最小页面结构如下:
+
+1. 活动列表页
+2. 活动详情页
+3. 运行绑定页
+4. 地图与赛道管理页
+5. 玩法模板页
+6. 展示与内容绑定页
+7. 发布记录页
+
+每页建议承担的职责:
+
+### 5.1 活动列表页
+
+- 看活动
+- 看状态
+- 看当前发布
+- 看是否可进入
+
+### 5.2 活动详情页
+
+- 活动基本信息
+- 当前发布摘要
+- 当前 active 三元组摘要
+
+### 5.3 运行绑定页
+
+- 选择地点/地图/瓦片/赛道
+- 查看当前绑定的 `runtime`
+- 查看多赛道 variant
+
+### 5.4 地图与赛道管理页
+
+- 地图资源
+- 瓦片版本
+- KML 输入
+- 赛道解析
+- variant 管理
+
+### 5.5 玩法模板页
+
+- 顺序赛 / 积分赛等玩法模板
+- 活动级规则覆盖项
+
+### 5.6 展示与内容绑定页
+
+- 选择 `presentation`
+- 选择 `content bundle`
+- 查看绑定完整性
+
+### 5.7 发布记录页
+
+- build
+- publish
+- release
+- 历史版本
+- 回滚
+
+---
+
+## 6. 发布与生产流程建议
+
+后台应优先支持“生产闭环”,而不是单独做配置编辑页。
+
+建议最小生产流程为:
+
+1. 编辑活动基础信息
+2. 绑定地图与赛道
+3. 选择玩法模板
+4. 绑定展示版本
+5. 绑定内容包
+6. 生成 preview/build
+7. 校验完整性
+8. 发布 release
+9. 绑定当前发布
+10. 客户端通过 `launch` 消费
+
+建议发布前必须检查:
+
+- 当前 release 是否存在
+- runtime 是否绑定
+- manifest 是否存在
+- presentation 是否绑定
+- content bundle 是否绑定
+
+这也应与当前 backend 已收紧的 `play.canLaunch` 语义保持一致。
+
+---
+
+## 7. 后台如何支持多赛道游戏定制
+
+多赛道是后续后台必须重点支持的一条。
+
+建议后台支持:
+
+- `assignmentMode`
+ - `manual`
+ - `random`
+ - `server-assigned`
+- variant 列表
+- 每个 variant 的:
+ - 名称
+ - routeCode
+ - 难度
+ - 是否默认
+ - 是否允许预览
+
+关键原则:
+
+- 活动层可以看多个 variant
+- `session` 最终只绑定一个 variant
+- 前端可以选择,但最终以后端 session / launch 返回为准
+
+---
+
+## 8. 后台如何支持准备页地图预览
+
+建议后台后续支持的方向不是额外维护一套完全独立的预览地图资源,而是围绕:
+
+- 正式瓦片
+- 赛道预览元数据
+- 预览级别
+
+来支持准备页地图预览。
+
+当前推荐方案已经单独写在:
+
+- [准备页地图预览方案](/D:/dev/cmr-mini/doc/gameplay/准备页地图预览方案.md)
+
+后台需要支持的最小数据包括:
+
+- 低级别底图来源
+- 预览范围
+- 预览尺寸
+- 当前 variant 的点位几何
+- 预览级别:
+ - `none`
+ - `summary`
+ - `full`
+
+---
+
+## 9. 分阶段实施建议
+
+建议后台支持游戏定制按以下顺序推进:
+
+### 第一步:发布与绑定先闭环
+
+优先做:
+
+- runtime binding
+- presentation
+- content bundle
+- release
+
+先保证“活动能正式发布、能正式进入”。
+
+### 第二步:地图与赛道管理
+
+优先做:
+
+- 地点
+- 地图
+- 瓦片版本
+- KML 输入
+- variant 管理
+
+### 第三步:玩法模板化
+
+优先做:
+
+- `GameTemplate`
+- 少量规则项开放
+
+### 第四步:多赛道与预览
+
+优先做:
+
+- 多赛道 variant
+- assignment mode
+- 准备页预览支撑字段
+
+### 第五步:再扩高级配置
+
+例如:
+
+- 点位覆盖
+- 腿线覆盖
+- HUD 高级映射
+- 复杂体验开关
+
+---
+
+## 10. 一句话结论
+
+后台下一步不应做成“配置编辑器”,而应做成一套**活动生产与发布平台**。
+
+游戏定制建议以后台五层模型推进:
+
+- 活动层
+- 地图与赛道层
+- 玩法层
+- 运营内容层
+- 发布层
+
+只要这五层分清,后续游戏定制、活动运营、多赛道、准备页预览和发布闭环都能稳定扩展。
diff --git a/doc/backend/后端总体架构与当前执行清单.md b/doc/backend/后端总体架构与当前执行清单.md
new file mode 100644
index 0000000..75cfa4f
--- /dev/null
+++ b/doc/backend/后端总体架构与当前执行清单.md
@@ -0,0 +1,289 @@
+# 后端总体架构与当前执行清单
+> 文档版本:v1.0
+> 最后更新:2026-04-07 14:08:00
+
+本文档写给 backend 当前开发线程,目标是用一份短文档说明两件事:
+
+1. 当前系统总体架构已经收口到什么程度
+2. backend 现在最该做什么,不该做什么
+
+本文档不替代以下方案文档,而是它们的执行版摘要:
+
+- [后台生产闭环架构草案](/D:/dev/cmr-mini/doc/backend/后台生产闭环架构草案.md)
+- [后台游戏定制支持方案](/D:/dev/cmr-mini/doc/backend/后台游戏定制支持方案.md)
+- [运维后台第一期方案](/D:/dev/cmr-mini/doc/gameplay/运维后台第一期方案.md)
+
+---
+
+## 1. 当前总体架构
+
+当前系统建议继续按以下链路理解,不要回退到“散装配置 + 临时页面”的推进方式:
+
+```text
+地图运行域
+-> Place / MapAsset / TileRelease
+-> CourseSource / CourseSet / CourseVariant
+-> MapRuntimeBinding
+
+活动运营域
+-> Event / EventPresentation / ContentBundle
+-> EventRelease
+
+客户端消费域
+-> event detail / play / launch / result / entry-home
+-> frontend 只消费发布后的稳定摘要
+```
+
+### 当前已经稳定的主链
+
+- `Event`
+- `EventRelease`
+- `Session`
+- `launch.runtime`
+- `currentPresentation / currentContentBundle`
+- `play.canLaunch`
+
+### 当前已经明确的原则
+
+- 客户端只消费**已发布 release**,不消费草稿
+- 进入游戏是否允许,统一以 `play.canLaunch` 为准
+- `session` 才是最终绑定运行对象和多赛道 variant 的事实来源
+- frontend 不再继续扩散式读取散装配置
+
+---
+
+## 2. backend 当前最重要的五层职责
+
+backend 后续支持游戏定制,建议继续按这五层推进:
+
+### 2.1 活动层
+
+负责:
+
+- 活动列表
+- 活动详情
+- 活动状态
+- 当前发布版本
+- 当前是否允许进入
+
+### 2.2 地图与赛道层
+
+负责:
+
+- 地点
+- 地图
+- 瓦片版本
+- KML / 赛道源输入
+- 赛道集合
+- 多赛道 variant
+
+### 2.3 玩法层
+
+负责:
+
+- 玩法模板
+- 少量可调规则项
+- 不负责暴露全量碎字段
+
+### 2.4 运营内容层
+
+负责:
+
+- `EventPresentation`
+- `ContentBundle`
+- 当前 active 绑定
+- 发布前完整性检查
+
+### 2.5 发布层
+
+负责:
+
+- build / publish / release
+- 当前发布
+- 历史版本
+- 回滚
+- 完整性校验
+
+---
+
+## 3. 当前 backend 最该做什么
+
+当前 backend 不建议继续四处补小接口,而应优先把以下 4 条生产链做稳。
+
+### 3.1 生产对象链做稳
+
+优先保证以下对象关系稳定:
+
+- `Place`
+- `MapAsset`
+- `TileRelease`
+- `CourseSource`
+- `CourseSet`
+- `CourseVariant`
+- `MapRuntimeBinding`
+- `EventPresentation`
+- `ContentBundle`
+- `EventRelease`
+
+要求:
+
+- 可创建
+- 可查询
+- 可绑定
+- 可发布
+
+### 3.2 发布完整性做稳
+
+发布链当前应继续保持:
+
+- 缺 `runtime` 不可进入
+- 缺 `presentation` 不可进入
+- 缺 `content bundle` 不可进入
+- 缺 `manifest` 不可进入
+- 缺当前发布 release 不可进入
+
+也就是:
+
+- `play.canLaunch`
+- `launch`
+
+必须共享同一套发布完整性语义。
+
+### 3.3 多赛道生产链做稳
+
+多赛道当前不是前端显示问题,而是 backend 生产链问题最容易出错的部分。
+
+backend 当前应确保:
+
+- `assignmentMode` 真正进入发布物
+- `play.courseVariants[]` 真正进入发布物
+- `preview.variants[]` 与可选 variant 一一对应
+- `launch.variant` 与最终 session 绑定一致
+
+### 3.4 准备页地图预览支撑字段做稳
+
+准备页地图预览 V1 已进入前端实现,因此 backend 当前应稳定提供:
+
+- `preview.mode`
+- `preview.baseTiles`
+- `preview.viewport`
+- `preview.variants[].controls`
+- `preview.selectedVariantId`
+
+当前前端策略是:
+
+- 底图优先仍走 manifest 正式瓦片
+- variant 点位优先走 `play.preview`
+
+所以 backend 当前重点不是改前端展示,而是确保:
+
+- variant 预览点位与正式 variant 对齐
+- preview 数据能稳定进入 `event detail / play`
+
+---
+
+## 4. 当前 frontend 已经接住的东西
+
+backend 当前可以默认前端已经稳定消费以下摘要:
+
+### 4.1 活动链
+
+- 活动列表最小卡片字段
+- 活动详情 `status / canLaunch / currentPresentation / currentContentBundle`
+- 准备页摘要
+
+### 4.2 运行链
+
+- `launch.runtime`
+- `launch.variant`
+- `launch.presentation`
+- `launch.contentBundle`
+
+### 4.3 会话链
+
+- `ongoingSession`
+- `finish(cancelled)`
+- 恢复 / 放弃恢复
+
+### 4.4 结果链
+
+- 单局结果页
+- 历史结果页
+- 首页 ongoing / recent 摘要
+
+也就是说,backend 当前新增字段时,优先考虑是否会影响以上已稳定消费链路。
+
+---
+
+## 5. backend 当前不要做什么
+
+当前阶段不建议 backend 现在优先去做:
+
+- 全量 JSON 编辑器
+- 复杂可视化后台搭建器
+- 一次性铺开所有高级配置项
+- 把 `/dev/workbench` 继续膨胀成正式后台
+- 先做复杂审核流/权限流
+
+一句话:
+
+**现在要做的是生产闭环,不是万能后台。**
+
+---
+
+## 6. 当前最小执行清单
+
+backend 当前建议只盯这份最小执行清单:
+
+### A. 发布对象完整性
+
+- [ ] `play.canLaunch` 与 `launch` 阻断语义一致
+- [ ] 发布物缺任一核心绑定时不能进入游戏
+
+### B. 多赛道
+
+- [ ] `assignmentMode` 稳定进入发布物
+- [ ] `courseVariants[]` 稳定进入发布物
+- [ ] `launch.variant` 与最终 session 一致
+
+### C. 预览
+
+- [ ] `play.preview` 稳定进入 `event detail / play`
+- [ ] `preview.variants[]` 与 variant 对齐
+- [ ] 单赛道 / 多赛道都可稳定预览
+
+### D. 运维平台第一期
+
+- [ ] 活动列表/详情
+- [ ] 运行绑定
+- [ ] 展示/内容绑定
+- [ ] 发布记录
+- [ ] 与 workbench 分工清晰
+
+---
+
+## 7. 下一步建议顺序
+
+backend 现在建议按以下顺序推进:
+
+1. 先稳发布完整性和多赛道发布链
+2. 再稳准备页地图预览支撑字段
+3. 再做运维后台第一期页面与对象关系
+4. 最后再扩高级定制项
+
+这样可以避免:
+
+- 主链还没稳就去做复杂后台 UI
+- 生产对象还没定就提前开放全量配置
+
+---
+
+## 8. 一句话结论
+
+backend 当前应继续围绕“活动生产与发布平台”推进,而不是回到散装配置模式。
+
+现在最该做的不是加更多零碎接口,而是做稳这三件事:
+
+- 发布完整性
+- 多赛道生产链
+- 运维后台第一期的对象与页面闭环
diff --git a/doc/backend/后端第一阶段执行清单.md b/doc/backend/后端第一阶段执行清单.md
new file mode 100644
index 0000000..08eb98a
--- /dev/null
+++ b/doc/backend/后端第一阶段执行清单.md
@@ -0,0 +1,202 @@
+# 后端第一阶段执行清单
+> 文档版本:v1.0
+> 最后更新:2026-04-07 14:15:00
+
+本文档是 backend 当前阶段的执行清单版说明,配合以下文档一起使用:
+
+- [后端总体架构与当前执行清单](/D:/dev/cmr-mini/doc/backend/后端总体架构与当前执行清单.md)
+- [后台游戏定制支持方案](/D:/dev/cmr-mini/doc/backend/后台游戏定制支持方案.md)
+- [后台生产闭环架构草案](/D:/dev/cmr-mini/doc/backend/后台生产闭环架构草案.md)
+
+目标不是再讲架构,而是明确:
+
+- 第一阶段先做什么
+- 哪些必须做稳
+- 哪些暂时不要做
+
+---
+
+## 1. 第一阶段目标
+
+backend 第一阶段建议只完成这 4 件事:
+
+1. 发布完整性闭环
+2. 多赛道发布链稳定
+3. 准备页地图预览支撑字段稳定
+4. 运维后台第一期最小对象与页面闭环
+
+一句话:
+
+**先把活动能稳定发布、能稳定进入、能稳定多赛道、能稳定预览做稳。**
+
+---
+
+## 2. 第一阶段必须做稳的能力
+
+### 2.1 发布完整性
+
+必须保证以下条件一致:
+
+- `play.canLaunch`
+- `launch`
+- 当前发布 release 校验
+
+要求:
+
+- 缺 `runtime` 不可进入
+- 缺 `presentation` 不可进入
+- 缺 `content bundle` 不可进入
+- 缺 `manifest` 不可进入
+- 缺当前发布 release 不可进入
+
+### 2.2 多赛道发布链
+
+必须保证:
+
+- `assignmentMode` 进入发布物
+- `courseVariants[]` 进入发布物
+- `launch.variant` 与最终 session 一致
+- `preview.variants[]` 与 variant 对齐
+
+### 2.3 准备页地图预览支撑字段
+
+必须稳定提供:
+
+- `preview.mode`
+- `preview.baseTiles`
+- `preview.viewport`
+- `preview.variants[].controls`
+- `preview.selectedVariantId`
+
+### 2.4 运维后台第一期对象
+
+至少要能稳定管理:
+
+- `Event`
+- `EventRelease`
+- `MapRuntimeBinding`
+- `EventPresentation`
+- `ContentBundle`
+
+---
+
+## 3. 第一阶段建议顺序
+
+### 第一步:把发布完整性做稳
+
+先做:
+
+- `play.canLaunch` 规则统一
+- `launch` 阻断规则统一
+- release 完整性检查
+
+### 第二步:把多赛道做稳
+
+先做:
+
+- 发布物里透出 `assignmentMode`
+- 发布物里透出 `courseVariants[]`
+- `launch.variant`
+- session 最终绑定 variant
+
+### 第三步:把准备页预览支撑字段做稳
+
+先做:
+
+- `play.preview`
+- variant 预览点位
+- 单赛道 / 多赛道都可预览
+
+### 第四步:做运维后台第一期最小页
+
+先做:
+
+- 活动列表
+- 活动详情
+- 运行绑定
+- 展示/内容绑定
+- 发布记录
+
+---
+
+## 4. 第一阶段对象优先级
+
+### P0
+
+- `Event`
+- `EventRelease`
+- `MapRuntimeBinding`
+- `EventPresentation`
+- `ContentBundle`
+
+### P1
+
+- `Place`
+- `MapAsset`
+- `TileRelease`
+- `CourseSource`
+- `CourseSet`
+- `CourseVariant`
+
+### P2
+
+- `GameTemplate`
+- 高级玩法配置
+- 高级点位覆盖
+- 高级 HUD 配置
+
+---
+
+## 5. frontend 当前已经稳定消费的链路
+
+backend 当前可以默认 frontend 已经稳定消费:
+
+- 活动列表卡片最小字段
+- 活动详情 `status / canLaunch / currentPresentation / currentContentBundle`
+- 准备页摘要
+- `launch.runtime`
+- `launch.variant`
+- `launch.presentation`
+- `launch.contentBundle`
+- `ongoingSession`
+- 结果页 / 历史页活动链
+
+新增或调整接口时,优先不要打断这些链路。
+
+---
+
+## 6. 第一阶段暂时不要做什么
+
+当前阶段不建议优先做:
+
+- 全量 JSON 编辑器
+- 复杂可视化搭建器
+- 全量高级配置开放
+- 复杂审核流
+- 批量运维能力
+- 把 `/dev/workbench` 直接演化成正式后台
+
+---
+
+## 7. 第一阶段完成标准
+
+backend 第一阶段如果满足以下条件,就可以认为是“可进入第二阶段”:
+
+1. 活动发布对象完整性稳定
+2. 多赛道活动能稳定发布和进入
+3. 准备页地图预览字段稳定
+4. 运维后台第一期页面可用
+5. 前后端联调不再依赖临时 demo 修补
+
+---
+
+## 8. 一句话结论
+
+backend 第一阶段不是做“大而全后台”,而是做一套**稳定的活动生产与发布最小闭环**。
+
+当前最重要的是先做稳:
+
+- 发布完整性
+- 多赛道
+- 预览支撑字段
+- 运维后台第一期对象与页面
diff --git a/doc/config/最大配置模板后台落地裁剪表.md b/doc/config/最大配置模板后台落地裁剪表.md
new file mode 100644
index 0000000..140768c
--- /dev/null
+++ b/doc/config/最大配置模板后台落地裁剪表.md
@@ -0,0 +1,478 @@
+# 最大配置模板后台落地裁剪表
+> 文档版本:v1.0
+> 最后更新:2026-04-07 14:22:00
+
+本文档用于把当前“最大配置模板”转换成 backend 可执行的落地裁剪表。
+
+目标不是让 backend 1:1 支持所有字段,而是明确:
+
+- 哪些块属于第一阶段必做
+- 哪些块属于第二阶段可做
+- 哪些块当前不应进入后台,继续留在程序默认值层
+
+建议结合以下文档一起阅读:
+
+- [当前最全配置模板](/D:/dev/cmr-mini/doc/config/当前最全配置模板.md)
+- [配置选项字典](/D:/dev/cmr-mini/doc/config/配置选项字典.md)
+- [后台游戏定制支持方案](/D:/dev/cmr-mini/doc/backend/后台游戏定制支持方案.md)
+- [后端第一阶段执行清单](/D:/dev/cmr-mini/doc/backend/后端第一阶段执行清单.md)
+
+---
+
+## 1. 总体原则
+
+最大配置模板当前可视为“系统能力全集参考”,但 backend 不应照单全收。
+
+建议按以下三类裁剪:
+
+### A. 第一阶段必做
+
+必须进入 backend,且需要正式对象化、可绑定、可发布。
+
+### B. 第二阶段可做
+
+建议预留,但不需要第一阶段就全部落地。可以先保留为默认值或高级区。
+
+### C. 暂不进后台
+
+继续留在程序默认值层、前端默认值层或调试层,当前不建议后台开放。
+
+---
+
+## 2. 顶层结构裁剪
+
+### 2.1 `schemaVersion`
+
+- 分类:A
+- backend 建议:保留
+- 理由:发布物结构版本必须可追踪
+
+### 2.2 `version`
+
+- 分类:A
+- backend 建议:保留
+- 理由:配置内容版本必须可追踪
+
+### 2.3 `app`
+
+- 分类:A
+- backend 建议:进入活动层
+- 理由:属于活动基础信息
+
+### 2.4 `settings`
+
+- 分类:B
+- backend 建议:只开放少量活动级默认值和锁态
+- 理由:适合做活动级系统设置默认值,但不宜第一阶段全开
+
+### 2.5 `map`
+
+- 分类:A
+- backend 建议:进入地图运行域
+- 理由:属于运行底座
+
+### 2.6 `playfield`
+
+- 分类:A
+- backend 建议:进入地图与赛道层
+- 理由:属于赛道空间对象
+
+### 2.7 `game`
+
+- 分类:A / B 混合
+- backend 建议:玩法模板化后分层开放
+- 理由:游戏规则是后台支持游戏定制的核心,但应按模板化和差异化开放
+
+### 2.8 `resources`
+
+- 分类:A
+- backend 建议:进入运营内容层
+- 理由:与 `presentation / content bundle` 绑定关系紧密
+
+### 2.9 `debug`
+
+- 分类:C
+- backend 建议:不进正式后台
+- 理由:属于联调/开发域,不是正式活动生产对象
+
+---
+
+## 3. 各块裁剪建议
+
+## 3.1 `app`
+
+### 建议第一阶段进入后台
+
+- `app.id`
+- `app.title`
+- `app.locale`
+
+### 说明
+
+这些字段直接对应:
+
+- 活动基本信息
+- 活动标题
+- 活动语言环境
+
+属于活动层核心字段,必须进后台。
+
+---
+
+## 3.2 `settings`
+
+### 第一阶段建议只开放少量活动级默认值
+
+- `autoRotateEnabled`
+- `trackDisplayMode`
+- `gpsMarkerStyle`
+- `showCenterScaleRuler`
+- `useTrueNorth`
+
+### 第二阶段再考虑开放
+
+- 更多地图显示偏好
+- 更多设备偏好
+- 更细的锁态粒度
+
+### 当前不建议后台开放
+
+- 所有设置项的全量 `value/isLocked`
+
+### 说明
+
+`settings` 应继续遵守:
+
+- 值可持久化
+- `isLocked` 不持久化
+- 锁态只在当前游戏配置生命周期内生效
+
+所以后台第一阶段只应提供少量活动级默认值和锁态,不应做全量设置管理。
+
+---
+
+## 3.3 `map`
+
+### 第一阶段必做
+
+- `map.tiles`
+- `map.mapmeta`
+- `map.declination`
+- `map.initialView.zoom`
+
+### 说明
+
+这块应完全进入地图运行域:
+
+- `Place`
+- `MapAsset`
+- `TileRelease`
+
+这些字段不应继续作为散装 JSON 长期管理。
+
+---
+
+## 3.4 `playfield`
+
+### 第一阶段必做
+
+- `playfield.kind`
+- `playfield.source.type`
+- `playfield.source.url`
+- `playfield.CPRadius`
+- `playfield.metadata.title`
+- `playfield.metadata.code`
+
+### 第一阶段建议支持
+
+- `playfield.controlDefaults`
+- `playfield.controlOverrides`
+- `playfield.legDefaults`
+- `playfield.legOverrides`
+
+### 说明
+
+这里是“活动级默认 + 单点重载”的主战场。
+
+backend 不应只支持 `controlOverrides`,也应支持:
+
+- `controlDefaults`
+- `legDefaults`
+
+这样后台才不会被迫为每个点单独填字段。
+
+---
+
+## 3.5 `game.session`
+
+### 第一阶段必做
+
+- `startManually`
+- `requiresStartPunch`
+- `requiresFinishPunch`
+- `autoFinishOnLastControl`
+- `minCompletedControlsBeforeFinish`
+- `maxDurationSec`
+
+### 说明
+
+这块建议作为玩法模板和活动级差异项的一部分进入 backend。
+
+尤其:
+
+- 终点解锁条件
+- 关门时间
+
+都是活动定制常用项。
+
+---
+
+## 3.6 `game.punch`
+
+### 第一阶段必做
+
+- `policy`
+- `radiusMeters`
+- `requiresFocusSelection`
+
+### 说明
+
+这块直接影响:
+
+- 顺序赛 / 积分赛
+- 自动打点 / 确认打点
+- 是否需要先选目标点
+
+属于 backend 支持玩法定制的核心字段。
+
+---
+
+## 3.7 `game.sequence.skip`
+
+### 第一阶段建议开放
+
+- `enabled`
+- `radiusMeters`
+- `requiresConfirm`
+
+### 说明
+
+这块属于顺序赛核心差异项,适合进玩法模板页或活动高级规则页。
+
+---
+
+## 3.8 `game.scoring`
+
+### 第一阶段必做
+
+- `type`
+- `defaultControlScore`
+
+### 说明
+
+积分赛与顺序赛都要用到基础分/计分逻辑,这块应进入玩法模板层。
+
+---
+
+## 3.9 `game.guidance`
+
+### 第一阶段建议开放少量核心项
+
+- `showLegs`
+- `allowFocusSelection`
+
+### 第二阶段再做
+
+- `legAnimation`
+- 其他更细 guidance 展现项
+
+### 说明
+
+backend 第一阶段不需要开放全部 guidance 表现细项,只需支撑“路线是否显示 / 是否允许选目标”这类活动差异。
+
+---
+
+## 3.10 `game.visibility`
+
+### 第一阶段建议开放
+
+- `revealFullPlayfieldAfterStartPunch`
+
+### 说明
+
+这是一个清晰且常见的活动差异项,适合后台开放。
+
+---
+
+## 3.11 `game.finish`
+
+### 第一阶段建议开放
+
+- `finishControlAlwaysSelectable`
+
+### 说明
+
+终点是否始终可结束,是活动差异项,特别适合在积分赛里后台控制。
+
+---
+
+## 3.12 `game.telemetry`
+
+### 第一阶段建议只开放少量活动级默认项
+
+- `age`
+- `restingHeartRateBpm`
+- `userWeightKg`
+
+### 说明
+
+这块未来会更多依赖用户身体数据接口,当前 backend 不必重做一整套复杂管理。
+
+---
+
+## 3.13 `game.feedback`
+
+### 第一阶段不建议全开
+
+### 第一阶段最多开放
+
+- `audioProfile`
+- `hapticsProfile`
+- `uiEffectsProfile`
+
+### 第二阶段再考虑
+
+- 距离音三档参数
+- 各类 loopGap/volume 细项
+
+### 说明
+
+反馈层参数很多,但大多数不适合第一阶段后台直接编辑。
+
+---
+
+## 3.14 `game.presentation`
+
+### 第一阶段建议只开放这些方向
+
+- 顺序赛点位样式 profile
+- 积分赛点位样式 profile
+- 积分赛 scoreBands
+- `track.mode`
+- `gpsMarker.style`
+
+### 第二阶段再考虑
+
+- 每个表现子字段全量可编排
+- 更细的 glow / width / label 等参数
+
+### 说明
+
+这块当前最容易做过头。建议 backend 先支持“样式 profile / band / 模式级”配置,而不是让运营直接调几十个视觉参数。
+
+---
+
+## 3.15 `resources`
+
+### 第一阶段必做
+
+- `resources.audioProfile`
+- `resources.contentProfile`
+- `resources.themeProfile`
+
+### 说明
+
+这块应与:
+
+- `EventPresentation`
+- `ContentBundle`
+
+一起归入运营内容层。
+
+---
+
+## 3.16 `debug`
+
+### 当前不进后台
+
+- `allowModeSwitch`
+- `allowMockInput`
+- `allowSimulator`
+
+### 说明
+
+这些字段继续留在开发联调域,不应进入正式运维后台。
+
+---
+
+## 4. backend 第一阶段最小落地范围
+
+综合以上裁剪,建议 backend 第一阶段只做这些:
+
+### 活动层
+
+- `app.*`
+- 活动状态
+- 当前发布 release
+
+### 地图与赛道层
+
+- `map.*`
+- `playfield.kind`
+- `playfield.source`
+- `playfield.metadata`
+- `controlDefaults / controlOverrides`
+- 多赛道 variant
+
+### 玩法层
+
+- `game.mode`
+- `game.session`
+- `game.punch`
+- `game.sequence.skip`
+- `game.scoring`
+- 少量 `guidance / visibility / finish`
+
+### 运营内容层
+
+- `resources.*`
+- `presentation`
+- `content bundle`
+
+### 发布层
+
+- `schemaVersion`
+- `version`
+- 发布完整性校验
+
+---
+
+## 5. 当前明确不建议 backend 第一阶段落地的字段
+
+以下字段当前建议继续留在:
+
+- 程序默认值层
+- 玩法默认值层
+- 前端高级配置层
+
+而不要第一阶段就强行做成后台表单:
+
+- `game.feedback.audio.cues.*`
+- `game.presentation.track.*` 的所有细粒度宽度/颜色/光晕参数
+- `game.presentation.gpsMarker.*` 的所有细粒度品牌/动画参数
+- 所有高级 label / glow / accentRing 细项
+- `debug.*`
+
+---
+
+## 6. 一句话结论
+
+最大配置模板应作为 backend 的“能力全集参考”,但不应 1:1 全量落地。
+
+当前建议:
+
+- 第一阶段做核心活动生产与发布字段
+- 第二阶段做常用活动差异项
+- 高级视觉/反馈/调试细项继续留在程序默认值层或高级区
+
+这样 backend 才不会在第一阶段就跑偏成“大而全配置编辑器”。
diff --git a/doc/config/配置文档索引.md b/doc/config/配置文档索引.md
index 1480227..0d0dda4 100644
--- a/doc/config/配置文档索引.md
+++ b/doc/config/配置文档索引.md
@@ -1,6 +1,6 @@
# 配置文档索引
-> 文档版本:v1.0
-> 最后更新:2026-04-02 08:28:05
+> 文档版本:v1.1
+> 最后更新:2026-04-07 14:22:00
本文档用于汇总当前项目所有与配置设计、配置样例、配置管理相关的文档,并按“公共配置”和“按游戏分类”两层组织。
@@ -17,6 +17,8 @@
最小通用骨架
- [当前最全配置模板](D:/dev/cmr-mini/doc/config/当前最全配置模板.md)
当前共享全量模板
+- [最大配置模板后台落地裁剪表](D:/dev/cmr-mini/doc/config/最大配置模板后台落地裁剪表.md)
+ 把全量模板裁成 backend 第一阶段、第二阶段和暂不开放三类
- [后台配置管理方案V2](D:/dev/cmr-mini/doc/config/后台配置管理方案V2.md)
后台管理与发布方案
- [配置发布说明](D:/dev/cmr-mini/doc/config/配置发布说明.md)
diff --git a/doc/gameplay/colormaprun网站DEMO版方案.md b/doc/gameplay/colormaprun网站DEMO版方案.md
new file mode 100644
index 0000000..6aae615
--- /dev/null
+++ b/doc/gameplay/colormaprun网站DEMO版方案.md
@@ -0,0 +1,597 @@
+# colormaprun 网站 DEMO 版方案
+> 文档版本:v1.0
+> 最后更新:2026-04-07 11:40:54
+
+本文档用于说明 `colormaprun.com` 在正式重构过程中,是否需要先落一个可对外展示、可内部联调、可逐步演进的 **网站 DEMO 版**,以及这个 DEMO 应该如何与当前项目已有对象、接口和 H5 方案衔接。
+
+---
+
+## 1. 总体结论
+
+当前建议:
+
+**先做一个“网站 DEMO 版前台”,但不要把它做成浏览器里的完整游戏。**
+
+更准确地说,网站 DEMO 应该是:
+
+- 品牌官网的一期可运行版本
+- 活动门户的最小公开外壳
+- 地图体验入口的只读预览层
+- H5 内容增强页的公开承载层
+- 小程序 / APP 正式体验链的导流入口
+
+而不应该是:
+
+- 浏览器里复刻完整 GPS 游戏主流程
+- 浏览器里承担打点、状态机、计分、对局恢复
+- 绕开 `EventRelease / runtime / presentation / content bundle` 再做一套网站私有数据模型
+
+---
+
+## 2. 为什么值得先做 DEMO 版
+
+结合当前项目状态,网站 DEMO 版有 4 个现实价值:
+
+### 2.1 对外可展示
+
+当前线上站点还是静态宣传站,能介绍产品,但不能有效展示:
+
+- 当前有哪些活动
+- 默认体验活动是什么
+- 一场活动具体长什么样
+- 地图、赛道、内容、结果是如何组合的
+
+DEMO 版可以先把这些“真实能力”公开展示出来。
+
+### 2.2 对内可联调
+
+backend 当前已经有:
+
+- `/home`
+- `/cards`
+- `/events/{eventPublicID}`
+- `/events/{eventPublicID}/play`
+- `currentPresentation`
+- `currentContentBundle`
+- `runtime`
+
+网站 DEMO 版可以成为这些摘要接口的另一层消费端,不只服务小程序,也服务网站线程。
+
+### 2.3 对商务和合作更有说服力
+
+当前商务诉求里最难讲清的一件事不是“我们能做地图游戏”,而是:
+
+- 活动门户长什么样
+- 客户页面能定制到什么程度
+- 地图体验是如何被包装成活动的
+- H5 内容页和结果页如何承接品牌方需求
+
+网站 DEMO 版正好可以把这些能力做成可分享链接。
+
+### 2.4 能平滑演进到正式站点
+
+如果 DEMO 版一开始就沿当前正式对象和正式摘要搭建,那么它后面不需要推倒重来,可以直接逐步升级为:
+
+- 正式官网
+- 正式活动门户
+- 正式地图体验入口
+
+---
+
+## 3. 设计前提
+
+这个 DEMO 版必须服从当前项目已经定下来的几个核心边界。
+
+### 3.1 活动是对外核心,不是地图页
+
+参考:
+
+- [APP全局产品架构草案](D:/dev/cmr-mini/doc/gameplay/APP全局产品架构草案.md)
+
+当前正式产品口径已经明确:
+
+- 地图是资源底座
+- 活动是对外核心
+- Session 是游戏过程
+- 用户资产是长期沉淀
+
+因此网站 DEMO 应该优先承接 `Event`,而不是直接承接地图运行页。
+
+### 3.2 网站不拥有核心游戏状态
+
+参考:
+
+- [混合体验架构方案](D:/dev/cmr-mini/doc/experience/混合体验架构方案.md)
+- [H5 增强与内容扩展层方案](D:/dev/cmr-mini/doc/experience/H5增强与内容扩展层方案.md)
+
+当前已经定案:
+
+- 核心游戏过程归原生 / 小程序
+- H5 只负责增强体验
+- H5 必须可降级
+
+因此网站 DEMO 最适合承接:
+
+- 活动展示
+- 地图预览
+- H5 内容页
+- 结果页样例
+- 导流入口
+
+不适合承接:
+
+- GPS 实时主循环
+- 打点成功判定
+- 比赛开始 / 结束状态推进
+
+### 3.3 网站必须复用已发布态摘要
+
+参考:
+
+- [后台生产闭环架构草案](D:/dev/cmr-mini/doc/backend/后台生产闭环架构草案.md)
+- [Backend README](D:/dev/cmr-mini/backend/README.md)
+
+当前玩家正式进入规则已经很明确:
+
+- 必须基于当前已发布 `EventRelease`
+- 不能基于 event 草稿默认值
+- `currentPresentation / currentContentBundle` 表示的是已发布 release 实际绑定摘要
+
+网站 DEMO 也应遵守同一条规则。
+
+---
+
+## 4. 当前项目里已经可复用的能力
+
+当前做网站 DEMO,不需要再从零造一套后台。
+
+### 4.1 公开卡片摘要
+
+当前可直接复用:
+
+- `GET /home`
+- `GET /cards`
+
+这些接口已经统一补齐活动卡片最小摘要字段:
+
+- `title`
+- `subtitle`
+- `summary`
+- `status`
+- `statusCode`
+- `timeWindow`
+- `ctaText`
+- `coverUrl`
+- `isDefaultExperience`
+- `eventType`
+- `currentPresentation`
+- `currentContentBundle`
+
+这已经足够支撑:
+
+- 官网首页活动区
+- 活动列表页
+- DEMO 活动推荐区
+
+### 4.2 活动详情摘要
+
+当前可直接复用:
+
+- `GET /events/{eventPublicID}`
+
+当前最重要的公开摘要包括:
+
+- `event`
+- `release`
+- `resolvedRelease`
+- `runtime`
+- `currentPresentation`
+- `currentContentBundle`
+
+这已经足够支撑:
+
+- 活动详情页
+- DEMO 活动说明页
+- 地图预览区
+- 内容页入口说明
+
+### 4.3 标准 demo 活动
+
+当前 backend 已准备三条标准 demo:
+
+- `evt_demo_001`
+- `evt_demo_score_o_001`
+- `evt_demo_variant_manual_001`
+
+而且它们在 `Bootstrap Demo` 后都直接具备:
+
+- 当前 release
+- runtime
+- presentation
+- content bundle
+
+这意味着网站 DEMO 版完全可以先围绕这三条标准 demo 起步。
+
+### 4.4 地图预览思路
+
+参考:
+
+- [准备页地图预览方案](D:/dev/cmr-mini/doc/gameplay/准备页地图预览方案.md)
+
+当前已经有非常适合网站 DEMO 的预览策略:
+
+- 使用低级别正式瓦片作为底图
+- 使用当前赛道坐标做前端 overlay
+- 只做只读预览
+- 不做浏览器里的完整交互地图
+
+这套方案天然适合网站详情页和 demo 页。
+
+### 4.5 H5 内容增强页
+
+当前项目已经明确:
+
+- H5 是独立内容扩展层
+- 可以独立发布、独立回滚、独立管理
+- 原生永远保底
+
+这意味着网站 DEMO 版可以公开承接:
+
+- 活动包装页
+- 内容详情页
+- 品牌化结果页样例
+
+---
+
+## 5. 网站 DEMO 版的推荐定位
+
+当前建议把网站 DEMO 定位成:
+
+**“公开活动前台 + 只读体验样板间 + 正式体验导流入口”**
+
+可以理解为三层:
+
+### 5.1 官网层
+
+负责:
+
+- 品牌表达
+- 场景说明
+- 核心能力介绍
+- 商务与合作转化
+
+### 5.2 活动 DEMO 层
+
+负责:
+
+- 活动卡片列表
+- 活动详情
+- 活动版本摘要
+- 地图预览
+- 赛道差异展示
+- 内容页 / 结果页样例入口
+
+### 5.3 正式体验导流层
+
+负责:
+
+- 小程序二维码
+- APP 下载
+- 默认体验活动导流
+- 客户案例和咨询转化
+
+---
+
+## 6. 当前最推荐的 DEMO 页面结构
+
+### 6.1 首页 `/`
+
+首页不只做品牌宣传,建议同时挂出 3 条入口:
+
+1. 立即体验
+2. 查看活动 DEMO
+3. 办活动 / 区域合作
+
+首页模块建议:
+
+- 品牌首屏
+- 三类用户分流
+- 标准 demo 活动推荐
+- 场景模块
+- 核心能力摘要
+- 合作与商务 CTA
+
+### 6.2 活动列表页 `/events`
+
+建议直接消费 `/cards` 或 `/home` 的卡片摘要。
+
+最小结构:
+
+- 顶部说明
+- 筛选:
+ - 全部
+ - 体验活动
+ - 进行中
+ - 即将开始
+ - 已结束
+- 卡片列表
+- 空状态
+
+这和现有 [活动卡片列表最小产品方案](D:/dev/cmr-mini/doc/gameplay/活动卡片列表最小产品方案.md) 是一致的。
+
+### 6.3 活动详情页 `/events/:eventId`
+
+建议直接消费 `GET /events/{eventPublicID}`。
+
+最小区块:
+
+- 活动主信息
+- 当前发布摘要
+- 当前地图 / 地点 / 赛道摘要
+- 地图预览
+- 当前展示版本摘要
+- 当前内容包摘要
+- CTA:
+ - 立即体验
+ - 查看内容样例
+ - 办同款活动
+
+### 6.4 DEMO 专题页 `/demo`
+
+这个页面建议单独保留,不和通用活动列表混在一起。
+
+作用:
+
+- 固定展示三条标准 demo
+- 用来对外介绍产品能力边界
+- 用来给内部演示和商务沟通发链接
+
+建议固定三个 demo 卡:
+
+- 顺序赛 demo
+- 积分赛 demo
+- 多赛道 demo
+
+### 6.5 DEMO 详情页 `/demo/:eventId`
+
+建议重点展示“这套能力如何组成一场活动”,而不是只展示活动文案。
+
+推荐区块:
+
+- 活动介绍
+- 玩法类型
+- 地图预览
+- 赛道切换预览
+- 内容体验样例
+- 结果页样例
+- 真正体验入口
+
+---
+
+## 7. DEMO 版应该做什么,不该做什么
+
+### 7.1 建议做
+
+- 公开活动卡片列表
+- 活动详情页
+- 只读地图预览
+- 多赛道切换预览
+- 当前 `presentation / content bundle / runtime` 摘要说明
+- H5 内容页 / 结果页样例跳转
+- 导流到 APP / 小程序的正式体验入口
+
+### 7.2 当前不建议做
+
+- 浏览器里直接起一局正式 session
+- 游客在网站里直接体验完整 GPS 游戏
+- 网站里实现打点、计分、恢复、结果主链
+- 为网站额外定义一套“活动草稿对象”
+- 网站直接读取后台草稿对象而不是 release 摘要
+
+### 7.3 如果一定要做“浏览器可玩的 demo”
+
+当前最稳妥的口径不是“浏览器版正式游戏”,而是:
+
+**脚本化演示 demo**
+
+即:
+
+- 预录一条轨迹
+- 预设几个关键状态
+- 演示:
+ - 起点
+ - 控制点
+ - 内容卡
+ - 结果页
+- 只做播放,不做正式比赛主状态拥有者
+
+这样既能展示玩法,又不破坏当前“核心主流程归原生”的边界。
+
+---
+
+## 8. DEMO 版的推荐数据挂接方式
+
+### 8.1 V1
+
+直接使用现有后端摘要接口:
+
+- `GET /entry/resolve`
+- `GET /home`
+- `GET /cards`
+- `GET /events/{eventPublicID}`
+
+其中建议为网站新增一个固定公开入口 channel,例如:
+
+- `website-demo`
+ 或
+- `official-site`
+
+这样网站也走“入口上下文首页”逻辑,而不是写死一套站内假数据。
+
+### 8.2 V1 页面消费原则
+
+网站页面只消费:
+
+- 卡片摘要
+- 活动详情摘要
+- runtime 摘要
+- presentation 摘要
+- content bundle 摘要
+
+不消费:
+
+- admin 草稿对象
+- build 过程对象
+- 原始 KML
+- 原始 schema 大对象
+
+### 8.3 开发态与正式态
+
+开发态可以为了快速联调接:
+
+- `/dev/demo-assets/...`
+
+但正式 DEMO 页应优先基于:
+
+- 当前已发布 release 摘要
+- 当前已发布 `currentPresentation`
+- 当前已发布 `currentContentBundle`
+
+这样网站 DEMO 才不会和正式发布链分叉。
+
+---
+
+## 9. 网站 DEMO 的技术落地建议
+
+如果后续需要正式写网站代码,建议在仓库根目录新建:
+
+```text
+www/
+├─ src/
+│ ├─ pages/
+│ ├─ components/
+│ ├─ features/demo/
+│ ├─ features/events/
+│ ├─ features/map-preview/
+│ ├─ lib/api/
+│ ├─ lib/normalizers/
+│ └─ lib/mock/
+├─ public/
+└─ README.md
+```
+
+### 9.1 当前阶段推荐技术方向
+
+V1 更适合:
+
+- 轻量前台
+- 读接口
+- 做静态构建
+- 做少量交互
+
+因此更推荐:
+
+- `www/` 独立站点工程
+- 轻量组件化前端
+- 以静态页面 + API 拉取为主
+
+当前不建议一开始就做:
+
+- 重 CMS
+- 重后台
+- 复杂 SSR 体系
+
+### 9.2 前端层建议拆法
+
+建议把网站前台拆成三层:
+
+#### 页面层
+
+- 首页
+- 活动列表页
+- 活动详情页
+- demo 专题页
+
+#### 领域层
+
+- `events`
+- `demo`
+- `map-preview`
+- `lead-forms`
+
+#### 数据适配层
+
+负责把 backend 摘要结构适配成网站展示模型,例如:
+
+- `CardResult -> SiteEventCard`
+- `EventDetailResult -> SiteEventDetail`
+
+这样后端字段后续继续演进时,网站层不会被直接打穿。
+
+---
+
+## 10. 推荐分阶段实施顺序
+
+### 第一阶段:网站 DEMO 壳
+
+先做:
+
+- 新首页
+- demo 活动推荐区
+- 活动列表页
+- 活动详情页
+
+这一步先证明:
+
+- 网站已能消费正式活动摘要
+- 网站已从纯宣传页升级为活动前台
+
+### 第二阶段:地图预览与内容样例
+
+再补:
+
+- 地图预览
+- 多赛道切换预览
+- 内容页 / 结果页样例入口
+
+这一步用来证明:
+
+- 网站能真实展示 runtime / presentation / content bundle 的组合能力
+
+### 第三阶段:正式体验导流
+
+再补:
+
+- 默认体验入口
+- 小程序二维码
+- APP 下载和分平台导流
+- 商务与合作转化表单
+
+### 第四阶段:活动门户化
+
+最后再逐步进入:
+
+- 活动专题页
+- 地图体验入口页
+- 办活动页
+- 区域合作页
+
+---
+
+## 11. 当前最推荐的判断
+
+如果只问一句:
+
+> 网站上要不要做一个 DEMO 版?
+
+当前答案是:
+
+**要做,但这个 DEMO 不是浏览器复刻游戏,而是用正式活动对象、正式发布摘要、只读地图预览和 H5 增强页,做一个可展示、可分享、可联调、可逐步升级成正式门户的网站前台。**
+
+这条路线的好处是:
+
+- 和当前项目文档边界一致
+- 和当前 backend 已有能力一致
+- 和未来官网 / 活动门户重构方向一致
+- 不会再造一套和正式系统脱节的“演示站”
+
diff --git a/doc/gameplay/colormaprun网站重构方案.md b/doc/gameplay/colormaprun网站重构方案.md
new file mode 100644
index 0000000..d1afab9
--- /dev/null
+++ b/doc/gameplay/colormaprun网站重构方案.md
@@ -0,0 +1,223 @@
+# colormaprun 网站重构方案
+> 文档版本:v1.0
+> 最后更新:2026-04-07 10:32:36
+
+本文档用于整理 `colormaprun.com` 的重构方向,明确其从宣传官网升级为“品牌官网 + 活动门户 + 地图体验入口 + 商务转化站”的产品基线,并作为后续网站线程实施的正式依据。
+
+---
+
+## 1. 当前定位判断
+
+当前 `colormaprun.com` 已经不只是 APP 下载页,而是同时承担了三种角色:
+
+1. C 端玩家体验入口
+2. B 端活动/赛事服务展示入口
+3. 区域合作与商务转化入口
+
+因此,重构不应停留在“视觉改版”,而应升级为完整的网站产品重构。
+
+---
+
+## 2. 重构目标
+
+重构后的站点应同时承担:
+
+### 2.1 品牌官网
+
+负责:
+
+- 品牌表达
+- 核心能力展示
+- 产品可信度
+- 合作案例与合作伙伴
+
+### 2.2 活动门户
+
+负责:
+
+- 活动列表
+- 活动详情
+- 报名/签到/排行榜等活动入口
+- 默认体验活动入口
+
+### 2.3 地图体验入口
+
+负责:
+
+- 地区与地图入口
+- 默认体验活动
+- 地图预约
+- 体验链路承接
+
+### 2.4 商务转化站
+
+负责:
+
+- 校园活动
+- 企业团建
+- 亲子活动
+- 专业赛事
+- 区域合作
+
+---
+
+## 3. 三类核心用户
+
+网站重构必须按用户类型分流:
+
+### 3.1 玩家
+
+关注:
+
+- 我能玩什么
+- 附近或推荐地图
+- 默认体验活动
+- 下载与快速开始
+
+### 3.2 活动组织者 / 客户
+
+关注:
+
+- 能承接什么类型活动
+- 方案能力
+- 案例
+- 联系与咨询
+
+### 3.3 区域合作方
+
+关注:
+
+- 合作模式
+- 权益与边界
+- 区域机会
+- 商务联系
+
+---
+
+## 4. 一级信息架构建议
+
+建议重构后站点至少包含以下一级结构:
+
+1. 首页
+2. 活动
+3. 地图体验
+4. 办活动
+5. 区域合作
+6. 帮助与课堂
+
+---
+
+## 5. 首页改造方向
+
+首页不再做单一宣传页,而改成“分流首页”。
+
+首屏建议直接提供三类主 CTA:
+
+- 立即体验
+- 办活动 / 赛事
+- 区域合作
+
+同时保留:
+
+- APP 下载入口
+- 品牌差异化能力
+- 核心场景
+- 合作背书
+
+---
+
+## 6. 活动门户方向
+
+活动是后续网站最重要的一层。
+
+建议后续网站活动模块包括:
+
+- 活动列表页
+- 活动详情页
+- 活动专题页
+- 默认体验活动入口
+
+要求:
+
+- 活动展示来自后台生产与发布系统
+- 兼容标准活动与定制活动
+- 不把活动系统固化成几个死模板
+
+---
+
+## 7. 地图体验入口方向
+
+地图入口负责承接:
+
+- 地区浏览
+- 地图浏览
+- 默认活动体验
+- 地图预约
+
+地图预约页应被视为:
+
+- 需求捕获页
+- 地图供给扩张入口
+- 区域合作潜在线索入口
+
+---
+
+## 8. 办活动与合作页方向
+
+### 8.1 办活动
+
+建议聚焦:
+
+- 校园活动
+- 企业团建
+- 亲子研学
+- 专业赛事
+
+### 8.2 区域合作
+
+建议聚焦:
+
+- 合作模式
+- 区域权益
+- 共建模式
+- 商务联系
+
+---
+
+## 9. 与后台生产系统的关系
+
+网站后续不应长期停留在静态维护,而应逐步与后台生产系统打通:
+
+- 活动列表来自 `Event / EventRelease`
+- 活动详情来自 `EventPresentation / ContentBundle`
+- 地图体验入口来自默认活动和地图资源
+- 商务页与内容页仍可保留部分静态编排
+
+---
+
+## 10. 推荐实施顺序
+
+### 第一阶段
+
+- 首页分流重构
+- 活动列表/详情最小门户化
+- 办活动页
+- 合作页
+
+### 第二阶段
+
+- 地图体验页
+- 课堂 / FAQ / 手册与转化路径重组
+
+### 第三阶段
+
+- 与后台生产和发布系统进一步动态打通
+- 统计和转化数据化
+
+---
+
+## 11. 一句话结论
+
+`colormaprun.com` 的重构目标,不是简单换皮,而是升级成:
+
+**品牌官网 + 活动门户 + 地图体验入口 + 商务转化站。**
diff --git a/doc/gameplay/准备页地图预览方案.md b/doc/gameplay/准备页地图预览方案.md
new file mode 100644
index 0000000..ff6af48
--- /dev/null
+++ b/doc/gameplay/准备页地图预览方案.md
@@ -0,0 +1,388 @@
+# 准备页地图预览方案
+> 文档版本:v1.0
+> 最后更新:2026-04-07 12:18:00
+
+本文档用于说明活动准备页的地图预览能力如何设计与落地。
+
+当前目标不是直接进入实现,而是先把方案边界、分层、前后端职责和分阶段路径定清。
+
+---
+
+## 1. 目标
+
+准备页当前已经具备:
+
+- 活动状态摘要
+- 赛道选择
+- 设备准备
+- 进入地图
+
+但“本局对象预览”仍然主要是文字占位,对玩家帮助有限。
+
+准备页地图预览的目标是:
+
+1. 在进入地图前,让玩家建立空间预期
+2. 在多赛道活动中,让玩家能直观看到当前赛道差异
+3. 让准备页更像“出发前准备”,而不是工程参数页
+4. 不打断当前 runtime 主链,也不额外制造一套重资源地图体系
+
+---
+
+## 2. 推荐方案
+
+当前推荐采用:
+
+**低级别正式瓦片做底图,前端动态叠加赛道**
+
+也就是:
+
+- 底图来源:现有正式瓦片资源
+- 底图级别:选择一个较低 zoom 作为预览缩略底图
+- 叠加层:前端根据当前赛道数据在预览图上动态绘制起点、终点、控制点和腿线
+
+这是一个“混合预览方案”:
+
+- 不单独制作新的预览地图资源
+- 不在前端完整拼装高精度交互地图
+- 只在准备页做只读预览
+
+---
+
+## 3. 为什么选这个方案
+
+### 3.1 不新增独立地图资源
+
+如果单独为准备页制作预览地图资源,会带来:
+
+- 资源重复
+- 发布链复杂度上升
+- 底图与正式地图不一致风险
+
+使用低级别正式瓦片作为预览底图,可以保持:
+
+- 同源
+- 一致
+- 低成本
+
+### 3.2 多赛道切换更自然
+
+多赛道场景下:
+
+- 底图通常是同一张地图
+- 差异主要在赛道叠加层
+
+因此最合理的方式是:
+
+- 底图固定
+- 切换赛道时仅重绘 overlay
+
+这样:
+
+- 切换更快
+- 数据结构更清楚
+- 不需要为每个 variant 重新生成整张预览图
+
+### 3.3 前后端职责清楚
+
+后端负责:
+
+- 提供底图预览所需元数据
+- 提供赛道叠加所需坐标数据
+
+前端负责:
+
+- 展示底图
+- 动态叠加赛道
+- 在准备页进行只读预览
+
+这符合当前项目一直坚持的分层原则。
+
+---
+
+## 4. 整体分层
+
+准备页地图预览建议分成 4 层。
+
+### 4.1 地图底图层
+
+来源:
+
+- 当前正式瓦片资源
+
+形式:
+
+- 低级别缩略底图
+
+职责:
+
+- 提供地点区域的空间背景
+
+### 4.2 预览元数据层
+
+来源:
+
+- 后端预览元数据
+
+职责:
+
+- 告诉前端如何把经纬度投到预览图坐标系中
+
+### 4.3 赛道叠加层
+
+来源:
+
+- variant 控制点与腿线数据
+
+职责:
+
+- 在准备页上画出当前赛道
+
+### 4.4 准备页展示层
+
+职责:
+
+- 只读展示
+- 切换赛道联动预览
+- 不承担局内交互
+
+---
+
+## 5. 后端需要提供的最小字段
+
+V1 不要求后端提供完整预览图 URL,而是先提供“底图元数据 + 赛道 overlay 元数据”。
+
+建议后端提供以下最小结构:
+
+```json
+{
+ "preview": {
+ "mode": "full",
+ "baseTiles": {
+ "tileBaseUrl": "https://.../tiles/",
+ "zoom": 15,
+ "tileSize": 256
+ },
+ "viewport": {
+ "width": 800,
+ "height": 450,
+ "minLon": 117.0000,
+ "minLat": 36.6000,
+ "maxLon": 117.0800,
+ "maxLat": 36.6600
+ },
+ "variants": [
+ {
+ "variantId": "variant_a",
+ "name": "A线",
+ "routeCode": "route-variant-a",
+ "controls": [
+ { "id": "start", "kind": "start", "lon": 117.01, "lat": 36.61 },
+ { "id": "c1", "kind": "control", "lon": 117.02, "lat": 36.615 },
+ { "id": "finish", "kind": "finish", "lon": 117.03, "lat": 36.62 }
+ ],
+ "legs": [
+ { "from": "start", "to": "c1" },
+ { "from": "c1", "to": "finish" }
+ ]
+ }
+ ]
+ }
+}
+```
+
+其中关键字段是:
+
+- `baseTiles.tileBaseUrl`
+- `baseTiles.zoom`
+- `viewport.width / height`
+- `viewport.minLon / minLat / maxLon / maxLat`
+- `variants[].controls`
+- `variants[].legs`
+
+这些字段足够前端做只读预览。
+
+---
+
+## 6. 前端如何消费
+
+前端准备页的消费方式建议如下:
+
+### 6.1 底图渲染
+
+- 使用低级别瓦片作为底图来源
+- 按 `viewport` 与 `zoom` 计算需要的瓦片范围
+- 只在准备页内绘制一张静态缩略底图
+
+### 6.2 赛道叠加
+
+- 根据 `viewport` 把控制点经纬度投影到预览图坐标
+- 在 canvas 或同等绘制层上叠加:
+ - 起点
+ - 终点
+ - 控制点
+ - 腿线
+
+### 6.3 多赛道切换
+
+- 切换赛道时不重新换底图
+- 只重绘叠加层
+
+### 6.4 展示层级
+
+准备页只做:
+
+- 只读预览
+- 不拖拽
+- 不缩放
+- 不交互打点
+
+---
+
+## 7. 多赛道场景如何处理
+
+多赛道场景是这套方案的重点。
+
+当前建议规则:
+
+1. 同一活动下,所有 variant 共用一张底图
+2. 当前选中的 variant 决定叠加层内容
+3. 如果活动允许手动选赛道,切换赛道时预览同步切换
+4. 如果活动是随机分配或后台指定:
+ - 准备页在最终绑定前可只显示地点底图
+ - 一旦后端返回最终绑定赛道,再显示该赛道 overlay
+
+这套设计和当前多赛道 Variant 架构是一致的:
+
+- 底图属于地图对象
+- 叠加属于 variant 对象
+
+---
+
+## 8. 预览级别建议
+
+建议预览能力分成 3 档:
+
+### 8.1 `none`
+
+- 不显示地图预览
+- 只显示地点、地图、赛道文字信息
+
+适用于:
+
+- 不允许赛前预览的正式比赛
+
+### 8.2 `summary`
+
+- 显示底图
+- 只显示简化赛道范围、起终点或大致区域
+- 不暴露完整点位和腿线
+
+适用于:
+
+- 需要局前空间感,但不允许完整剧透路线
+
+### 8.3 `full`
+
+- 显示底图
+- 显示完整点位与腿线
+
+适用于:
+
+- 体验活动
+- 教学活动
+- 低门槛公开活动
+
+V1 建议先直接支持:
+
+- `none`
+- `full`
+
+后面再补 `summary`。
+
+---
+
+## 9. 分阶段实施建议
+
+### 9.1 V1
+
+只做:
+
+- 低级别瓦片底图
+- 前端动态赛道叠加
+- 支持多赛道切换联动
+- 只读展示
+
+不做:
+
+- 复杂预览交互
+- 预览模式细分
+- 缩放与拖拽
+
+### 9.2 V2
+
+补:
+
+- `none / summary / full`
+- 不同活动类型的预览策略
+- 更细的赛道保密规则
+
+### 9.3 V3
+
+考虑扩展到:
+
+- 活动详情页缩略预览
+- 活动列表卡片缩略图
+- 赛后结果页路线回看缩略图
+
+---
+
+## 10. 不建议的方案
+
+当前不建议:
+
+### 10.1 单独制作一套预览地图资源
+
+问题:
+
+- 成本高
+- 一致性差
+- 发布链复杂
+
+### 10.2 前端直接现场拼完整高精度瓦片地图
+
+问题:
+
+- 性能波动大
+- 首次加载慢
+- 多端一致性差
+
+### 10.3 后端为每个 variant 预生成完整大图
+
+问题:
+
+- 多赛道下资源重复
+- 每次改赛道都要重生图
+- 扩展性不如“底图固定 + 叠加切换”
+
+---
+
+## 11. 当前建议结论
+
+准备页地图预览最推荐的路线是:
+
+**使用低级别正式瓦片作为预览底图,由前端在准备页动态叠加当前赛道。**
+
+这条路线的优点是:
+
+- 不新增独立地图资源
+- 底图与正式地图同源
+- 多赛道切换成本低
+- 前后端职责清晰
+- 易于分阶段落地
+
+当前建议优先推进:
+
+1. 后端补预览元数据最小字段
+2. 前端在准备页实现只读底图 + overlay
+3. 先支持 `full`
+4. 后续再扩 `summary / none`
diff --git a/doc/gameplay/地图列表与默认体验活动方案.md b/doc/gameplay/地图列表与默认体验活动方案.md
new file mode 100644
index 0000000..7bd9082
--- /dev/null
+++ b/doc/gameplay/地图列表与默认体验活动方案.md
@@ -0,0 +1,292 @@
+# 地图列表与默认体验活动方案
+> 文档版本:v1.0
+> 最后更新:2026-04-07 14:35:00
+
+本文档用于定义“地图列表”这条产品线如何与现有活动系统协同工作,重点解决以下问题:
+
+- 是否需要独立的地图列表入口
+- 默认体验活动如何挂在地图/地点下
+- 默认体验活动是否必须出现在正式活动列表
+- 后台应如何支持这套关系
+
+本文档的核心原则是:
+
+**地图列表是体验入口层,不是第二套业务内核。**
+
+也就是说:
+
+- 正式业务主链仍然是 `Event -> Release -> Launch -> Session`
+- 地图列表只是帮助用户从“地点/地图”进入默认体验活动
+
+---
+
+## 1. 总体结论
+
+建议增加一条独立的:
+
+- `地图列表`
+
+它主要面向:
+
+- 未注册用户
+- 想快速试玩的用户
+- 先看地点/地图,再决定是否体验的用户
+
+但这条线不应重新发明一套“体验配置系统”,而应继续复用现有:
+
+- `Event`
+- `EventRelease`
+- `Launch`
+- `Session`
+
+默认体验活动只是:
+
+- 一类特殊的 `Event`
+- 可以挂在 `Place / MapAsset` 下
+- 可以选择是否出现在正式活动列表
+
+---
+
+## 2. 基本对象关系
+
+建议继续沿用当前对象模型:
+
+- `Place`
+- `MapAsset`
+- `Event`
+
+在此基础上,补一层关系:
+
+- `defaultExperienceEvents[]`
+
+可理解为:
+
+- 一个地点下可有多张地图
+- 一张地图下可挂 `0 ~ N` 个默认体验活动
+
+默认体验活动本身不需要新建特殊业务对象,仍然是 `Event`。
+
+---
+
+## 3. 默认体验活动的定义
+
+默认体验活动建议继续用 `Event` 承载,但补充两个活动级属性:
+
+### 3.1 `isDefaultExperience`
+
+- 类型:`boolean`
+- 含义:该活动是否属于默认体验活动
+
+### 3.2 `showInEventList`
+
+- 类型:`boolean`
+- 含义:该活动是否出现在正式活动列表中
+
+这样就能支持以下场景:
+
+1. 正式活动
+- `isDefaultExperience = false`
+- `showInEventList = true`
+
+2. 纯地图体验活动
+- `isDefaultExperience = true`
+- `showInEventList = false`
+
+3. 既是体验又允许出现在活动列表
+- `isDefaultExperience = true`
+- `showInEventList = true`
+
+4. 当前不开放的体验活动
+- 活动未 active
+- 或未挂到地图
+
+---
+
+## 4. 产品入口建议
+
+建议前台保留两条入口:
+
+### 4.1 活动列表
+
+面向正式活动。
+
+默认只展示:
+
+- `showInEventList = true`
+
+不要求把所有默认体验活动都混进去。
+
+### 4.2 地图列表
+
+面向“先选地图/地点,再试玩”活动。
+
+流程建议:
+
+1. 首页进入 `地图列表`
+2. 用户看到地点/地图卡片
+3. 点进地图详情
+4. 查看该地图下挂的默认体验活动
+5. 点某个体验活动进入现有:
+ - 活动详情页
+ - 准备页
+ - 地图页
+
+也就是说:
+
+**地图列表负责入口分发,活动主链仍复用现有链路。**
+
+---
+
+## 5. 地图列表页建议
+
+地图列表页第一阶段建议尽量简单。
+
+### 每张地图卡片最小字段
+
+- 地点名称
+- 地图名称
+- 地图预览图
+- 简短描述
+- 是否存在默认体验活动
+- 默认体验数量
+
+### 当前不必一开始就做
+
+- 复杂筛选
+- 复杂排序
+- 地图标签系统
+- 地图收藏
+
+---
+
+## 6. 地图详情页建议
+
+地图详情页建议承担:
+
+- 地图介绍
+- 地图预览
+- 默认体验活动列表
+
+### 默认体验活动列表显示建议
+
+每条体验活动可显示:
+
+- 标题
+- 副标题
+- 玩法类型
+- 当前状态
+- 进入体验 CTA
+
+如果该地图当前没有默认体验活动,应明确显示:
+
+- `当前暂无体验活动`
+
+不要整块隐藏。
+
+---
+
+## 7. 后台支持建议
+
+后台后续应支持:
+
+### 7.1 地图下挂体验活动
+
+最少应支持:
+
+- 一张地图可挂 `0 ~ N` 个默认体验活动
+- 一个默认体验活动可绑定到某张地图
+
+### 7.2 活动可见性控制
+
+后台应能控制:
+
+- 是否默认体验
+- 是否出现在正式活动列表
+
+也就是:
+
+- `isDefaultExperience`
+- `showInEventList`
+
+### 7.3 不要求每种玩法都挂默认体验
+
+后台不应把“默认体验活动”做成强制项。
+
+可以是:
+
+- 不挂
+- 只挂顺序赛
+- 只挂积分赛
+- 顺序赛 + 积分赛都挂
+
+这应由地图、运营目标和活动阶段决定,而不是系统硬约束。
+
+---
+
+## 8. 与现有活动系统的关系
+
+这套方案必须继续遵守:
+
+- 默认体验活动仍然是 `Event`
+- 仍然要有 `EventRelease`
+- 仍然通过 `play / launch / session` 进入
+- 仍然遵守 `canLaunch`
+
+不要为地图体验活动单独发明:
+
+- 新的 launch 入口
+- 新的 session 类型
+- 新的结果体系
+
+否则会把主链拆坏。
+
+---
+
+## 9. 推荐实施顺序
+
+建议按以下顺序推进:
+
+### 第一步
+
+先把后台和对象关系定好:
+
+- 地图可挂默认体验活动
+- 活动可配置是否在活动列表出现
+
+### 第二步
+
+前台做:
+
+- 地图列表页
+- 地图详情页
+
+### 第三步
+
+地图详情页里的默认体验活动继续复用现有:
+
+- 活动详情页
+- 准备页
+- 地图页
+
+### 第四步
+
+再决定是否补:
+
+- 地图筛选
+- 推荐体验
+- 地图封面优化
+
+---
+
+## 10. 一句话结论
+
+建议增加地图列表,但它应被定义为:
+
+**默认体验活动的入口层,而不是第二套活动系统。**
+
+后台后续只需支持:
+
+- 地图下挂默认体验活动
+- 默认体验活动是否出现在正式活动列表
+
+这样既能支持未注册/试玩用户体验,也不会把现有活动主链拆成两套。
diff --git a/doc/gameplay/多线程联调协作方式.md b/doc/gameplay/多线程联调协作方式.md
index 8758241..c638279 100644
--- a/doc/gameplay/多线程联调协作方式.md
+++ b/doc/gameplay/多线程联调协作方式.md
@@ -1,15 +1,16 @@
# 多线程联调协作方式
-> 文档版本:v1.1
-> 最后更新:2026-04-03 11:15:00
+> 文档版本:v1.2
+> 最后更新:2026-04-07 10:32:36
## 目标
-当前项目已经进入前后端联调阶段,并且存在多条并行工作线程:
+当前项目已经进入前后端联调和多产品线并行阶段,并且存在多条并行工作线程:
- 前端线程
- 后端线程
- 总控线程
+- 网站线程
这份文档用于明确三条线程如何协作、各自负责什么,以及如何通过共享文档同步事实,而不是靠口头记忆维持项目状态。
@@ -21,25 +22,28 @@
- 一个代码仓库
- 多条并行线程
-- 四份根目录协作文档
+- 六份根目录协作文档
- 一名全局维护者负责总览和收口
对应关系:
- 前端线程:推进小程序页面、状态链、模拟器接入、地图与体验层
- 后端线程:推进接口、配置发布、会话生命周期、业务数据模型
+- 网站线程:推进官网、活动门户、地图体验入口、商务转化站重构
- 总控线程:负责全局判断、主线推进、交叉影响评估、文档索引与阶段总结
---
## 2. 当前协作文档的职责
-当前跨线程沟通主线改为 4 份文件:
+当前跨线程沟通主线改为 6 份文件:
- [t2b.md](D:/dev/cmr-mini/t2b.md)
- [b2t.md](D:/dev/cmr-mini/b2t.md)
- [t2f.md](D:/dev/cmr-mini/t2f.md)
- [f2t.md](D:/dev/cmr-mini/f2t.md)
+- [t2w.md](D:/dev/cmr-mini/t2w.md)
+- [w2t.md](D:/dev/cmr-mini/w2t.md)
旧的:
@@ -80,6 +84,22 @@
- 前端在哪些地方受阻
- 需要总控或后端确认什么
+### 2.5 `t2w.md`
+
+由总控线程维护,写给网站线程,用于记录:
+
+- 当前阶段网站重构应推进什么
+- 当前优先级是什么
+- 哪些页面和转化链先做
+
+### 2.6 `w2t.md`
+
+由网站线程维护,写给总控线程,用于记录:
+
+- 网站线程当前已完成什么
+- 网站重构当前阻塞什么
+- 需要总控确认什么
+
---
## 2.5 当前固定模板
@@ -128,6 +148,8 @@
- [b2t.md](D:/dev/cmr-mini/b2t.md)
- [t2f.md](D:/dev/cmr-mini/t2f.md)
- [f2t.md](D:/dev/cmr-mini/f2t.md)
+- [t2w.md](D:/dev/cmr-mini/t2w.md)
+- [w2t.md](D:/dev/cmr-mini/w2t.md)
以及当前代码事实:
@@ -203,6 +225,8 @@
- [b2t.md](D:/dev/cmr-mini/b2t.md)
- [t2f.md](D:/dev/cmr-mini/t2f.md)
- [f2t.md](D:/dev/cmr-mini/f2t.md)
+- [t2w.md](D:/dev/cmr-mini/t2w.md)
+- [w2t.md](D:/dev/cmr-mini/w2t.md)
特点:
diff --git a/doc/gameplay/联调架构阶段总结.md b/doc/gameplay/联调架构阶段总结.md
index cd948e0..c61c20f 100644
--- a/doc/gameplay/联调架构阶段总结.md
+++ b/doc/gameplay/联调架构阶段总结.md
@@ -1,6 +1,6 @@
# 联调架构阶段总结
-> 文档版本:v1.0
-> 最后更新:2026-04-03 16:59:19
+> 文档版本:v1.1
+> 最后更新:2026-04-07 22:38:00
## 1. 当前结论
@@ -141,31 +141,53 @@ frontend 当前已配合提供:
### 6.2 正在推进
-- 真实输入替换
- 更接近生产的联调环境
+- 活动系统最小成品闭环回归
+- 地图体验链第一刀回归
+- 游客体验链第一刀回归
-### 6.3 暂不启动
+### 6.3 已进入当前最小成品闭环范围
-- 活动卡片(列表)产品化
-- 新玩家侧页面扩张
-- 更复杂后台运营功能
+- 活动卡片列表最小产品化第一刀
+- 地图体验第一刀
+- 游客模式第一刀
+- 准备页地图预览 V1
+
+### 6.4 当前后端收口原则
+
+- backend 第一阶段活动模型先按:
+ - 单地图
+ - 单路线组
+ - 单玩法
+ 收口推进
+- 复杂多地图 / 多路线组 / 多玩法活动,后续通过:
+ - 活动实例化
+ - 组合入口层
+ - 组合卡片层
+ 解决
---
## 7. 下一步建议
-当前下一步不再是继续搭骨架,而是继续把真实输入往活动层推进。
+当前下一步不再是继续搭骨架,而是把已经接通的玩家链真正收顺,并继续保持联调环境接近生产。
优先顺序建议:
-1. `content manifest`
-2. `presentation schema`
-3. 活动文案样例
+1. 活动列表第一刀联调回归与小修
+2. 活动详情页 / 准备页去工程味
+3. 地图体验链 / 游客体验链整链回归
+4. 继续使用:
+ - `Bootstrap Demo`
+ - `一键补齐 Runtime 并发布`
+ - `一键标准回归`
+ 做统一验证
同时继续保持:
-- 前端只做联调回归和小修
+- 前端不扩第二刀产品化
- 后端继续保证一键回归链稳定
+- 后端按单地图 / 单路线组 / 单玩法先收模型
- 排障优先看:
- `回归结果汇总`
- `当前 Launch 实际配置摘要`
diff --git a/doc/gameplay/运维后台第一期方案.md b/doc/gameplay/运维后台第一期方案.md
new file mode 100644
index 0000000..a375443
--- /dev/null
+++ b/doc/gameplay/运维后台第一期方案.md
@@ -0,0 +1,156 @@
+# 运维后台第一期方案
+> 文档版本:v1.0
+> 最后更新:2026-04-07 10:24:38
+
+本文档用于定义运维后台第一期的角色边界、模块范围和与当前 `/dev/workbench` 的分工,作为下周启动正式运维后台时的最小基线。
+
+---
+
+## 1. 目标
+
+运维后台第一期的目标不是替代开发联调台,而是为非开发角色提供一套**可管理活动、可绑定资源、可发布版本**的最小后台。
+
+第一期要解决的核心问题:
+
+1. 运维人员可以查看和管理活动
+2. 运维人员可以绑定展示定义、内容包和运行绑定
+3. 运维人员可以发布和查看当前生效版本
+4. 默认活动和自定义活动都能统一进入发布流
+
+---
+
+## 2. 与 workbench 的分工
+
+### 2.1 workbench
+
+继续保留为:
+
+- 开发联调台
+- 一键测试台
+- 诊断台
+- 回归台
+
+继续负责:
+
+- `Bootstrap Demo`
+- 一键补齐 Runtime 并发布
+- 一键标准回归
+- Launch 实际配置摘要
+- 前端调试日志
+
+### 2.2 运维后台
+
+第一期定位为:
+
+- 运营配置台
+- 发布管理台
+- 非开发人员日常使用后台
+
+不承担:
+
+- 一键测试
+- 分步诊断
+- 调试日志查看
+- 开发期 demo 数据准备
+
+---
+
+## 3. 第一期开哪些模块
+
+### 3.1 活动管理
+
+最小能力:
+
+- 活动列表
+- 活动详情
+- 活动状态查看
+- 默认体验活动标记查看
+
+### 3.2 展示管理
+
+最小能力:
+
+- 查看 `EventPresentation`
+- 绑定到活动
+- 查看当前 active presentation
+
+### 3.3 内容包管理
+
+最小能力:
+
+- 查看 `ContentBundle`
+- 导入内容包摘要
+- 绑定到活动
+- 查看当前 active bundle
+
+### 3.4 运行绑定管理
+
+最小能力:
+
+- 查看 `MapRuntimeBinding`
+- 选择活动当前使用的 runtime binding
+- 查看绑定摘要:
+ - `place`
+ - `map`
+ - `tile release`
+ - `course variant`
+
+### 3.5 发布管理
+
+最小能力:
+
+- 发布当前活动
+- 查看当前 release
+- 查看历史 release
+- 查看当前 release 绑定的:
+ - `presentation`
+ - `contentBundle`
+ - `runtime`
+
+---
+
+## 4. 第一阶段明确不做
+
+第一期不做:
+
+- 完整资源素材平台
+- 复杂审核流
+- 批量操作
+- 回滚自动化
+- 复杂权限模型
+- 活动搭建器可视化编辑器
+- 工作流编排
+
+---
+
+## 5. 页面建议
+
+建议第一期后台至少包含这几页:
+
+1. 活动列表页
+2. 活动详情页
+3. 展示定义选择页/弹层
+4. 内容包选择页/弹层
+5. 运行绑定选择页/弹层
+6. 发布详情页
+
+---
+
+## 6. 启动时机
+
+当前建议的时机是:
+
+- 本周先完成“活动系统最小成品闭环”
+- 下周开始做“运维后台第一期”
+
+原因:
+
+- 当前 workbench 仍承担联调标准化和回归职责
+- 活动配置、发布、进入、结果回看这一条产品链还在本周收口
+- 正式运维后台应在业务主链稳定后启动,避免两条后台线互相干扰
+
+---
+
+## 7. 一句话结论
+
+运维后台第一期应作为**运营配置与发布后台**启动,不替代 workbench,不承担开发诊断;建议在本周活动系统最小成品闭环完成后,于下周正式开工。
diff --git a/doc/文档索引.md b/doc/文档索引.md
index fd7227e..899ac35 100644
--- a/doc/文档索引.md
+++ b/doc/文档索引.md
@@ -1,6 +1,6 @@
# 文档索引
-> 文档版本:v1.8
-> 最后更新:2026-04-05 12:36:25
+> 文档版本:v1.17
+> 最后更新:2026-04-07 14:35:00
维护约定:
@@ -33,6 +33,7 @@
- [配置文档索引](/D:/dev/cmr-mini/doc/config/配置文档索引.md)
- [配置选项字典](/D:/dev/cmr-mini/doc/config/配置选项字典.md)
- [配置分级总表](/D:/dev/cmr-mini/doc/config/配置分级总表.md)
+- [最大配置模板后台落地裁剪表](/D:/dev/cmr-mini/doc/config/最大配置模板后台落地裁剪表.md)
- [全局规则与配置维度清单](/D:/dev/cmr-mini/doc/config/全局规则与配置维度清单.md)
- [当前最全配置模板](/D:/dev/cmr-mini/doc/config/当前最全配置模板.md)
@@ -41,11 +42,16 @@
- [玩法构想方案](/D:/dev/cmr-mini/doc/gameplay/玩法构想方案.md)
- [程序默认规则基线](/D:/dev/cmr-mini/doc/gameplay/程序默认规则基线.md)
- [游戏规则架构](/D:/dev/cmr-mini/doc/gameplay/游戏规则架构.md)
+- [地图列表与默认体验活动方案](/D:/dev/cmr-mini/doc/gameplay/地图列表与默认体验活动方案.md)
- [多赛道 Variant 五层设计草案](/D:/dev/cmr-mini/doc/gameplay/多赛道Variant五层设计草案.md)
- [多赛道 Variant 前后端最小契约](/D:/dev/cmr-mini/doc/gameplay/多赛道Variant前后端最小契约.md)
- [多线程联调协作方式](/D:/dev/cmr-mini/doc/gameplay/多线程联调协作方式.md)
- [联调架构阶段总结](/D:/dev/cmr-mini/doc/gameplay/联调架构阶段总结.md)
- [活动卡片列表最小产品方案](/D:/dev/cmr-mini/doc/gameplay/活动卡片列表最小产品方案.md)
+- [准备页地图预览方案](/D:/dev/cmr-mini/doc/gameplay/准备页地图预览方案.md)
+- [运维后台第一期方案](/D:/dev/cmr-mini/doc/gameplay/运维后台第一期方案.md)
+- [colormaprun网站重构方案](/D:/dev/cmr-mini/doc/gameplay/colormaprun网站重构方案.md)
+- [colormaprun网站DEMO版方案](/D:/dev/cmr-mini/doc/gameplay/colormaprun网站DEMO版方案.md)
- [APP全局产品架构草案](/D:/dev/cmr-mini/doc/gameplay/APP全局产品架构草案.md)
- [故障恢复机制](/D:/dev/cmr-mini/doc/gameplay/故障恢复机制.md)
- [活动运营域摘要第一刀联调回归清单](/D:/dev/cmr-mini/doc/gameplay/活动运营域摘要第一刀联调回归清单.md)
@@ -63,8 +69,8 @@
- [混合体验架构](/D:/dev/cmr-mini/doc/experience/混合体验架构方案.md)
- [原生与 H5 Bridge 规范](/D:/dev/cmr-mini/doc/experience/原生与H5桥接规范.md)
-- [H5 增强与内容扩展层方案](/D:/dev/mini-prog/doc/experience/H5增强与内容扩展层方案.md)
-- [H5 任务页埋点与结果回传规范](/D:/dev/mini-prog/doc/experience/H5任务页埋点与结果回传规范.md)
+- [H5 增强与内容扩展层方案](/D:/dev/cmr-mini/doc/experience/H5增强与内容扩展层方案.md)
+- [H5 任务页埋点与结果回传规范](/D:/dev/cmr-mini/doc/experience/H5任务页埋点与结果回传规范.md)
## 渲染
@@ -84,7 +90,10 @@
## 后端
- [业务后端数据库初版方案](/D:/dev/cmr-mini/doc/backend/业务后端数据库初版方案.md)
+- [后端第一阶段执行清单](/D:/dev/cmr-mini/doc/backend/后端第一阶段执行清单.md)
+- [后端总体架构与当前执行清单](/D:/dev/cmr-mini/doc/backend/后端总体架构与当前执行清单.md)
- [后台生产闭环架构草案](/D:/dev/cmr-mini/doc/backend/后台生产闭环架构草案.md)
+- [后台游戏定制支持方案](/D:/dev/cmr-mini/doc/backend/后台游戏定制支持方案.md)
- [生产发布与数据库上线方案](/D:/dev/cmr-mini/doc/backend/生产发布与数据库上线方案.md)
## 备注与归档
diff --git a/f2b.archive.md b/f2b.archive.md
new file mode 100644
index 0000000..cb52c9e
--- /dev/null
+++ b/f2b.archive.md
@@ -0,0 +1,51 @@
+# F2B 协作归档
+> 文档版本:v1.0
+> 最后更新:2026-04-07 13:10:00
+> 归档来源: [f2b.md](D:/dev/cmr-mini/f2b.md) v1.17 之前历史条目
+
+说明:
+
+- 本文件用于保存 `f2b.md` 主文件收缩前的历史事项
+- 只保留摘要,不再继续滚动追加日常新项
+
+---
+
+## 已归档事项摘要
+
+### 会话语义与恢复链
+
+- `F2B-C003`:确认 `finished / failed / cancelled` 三态语义
+- `F2B-C004`:确认放弃恢复使用 `finish(cancelled)`
+- `F2B-C005`:确认 `start / finish` 幂等
+- `F2B-D002`:前端故障恢复链第一版完成
+
+### 多赛道与 launch/runtime
+
+- `F2B-C006`:确认多赛道第一阶段最小契约
+- `F2B-C007`:确认 launch 关键字段正式契约
+- `F2B-C008`:确认 ongoing / recent / result 摘要口径
+- `F2B-C009`:确认 manual 多赛道 demo 与 variant 回流
+- `F2B-D004`:前端多赛道第一阶段接入完成
+- `F2B-D006`:前端补齐 launch/config/runtime 诊断链
+
+### 活动运营域摘要
+
+- `F2B-C010`:backend 透出 `currentPresentation / currentContentBundle / launch.presentation / launch.contentBundle`
+- `F2B-D005`:前端活动运营域摘要第一刀接线完成
+
+### 诊断与日志
+
+- `F2B-D007`:切换到 backend `client-logs`
+- `F2B-D009`:前端日志口径按 backend 建议收口
+
+### Demo 与入口问题
+
+- `F2B-D003`:`evt_demo_001` manifest 恢复可用
+- `F2B-D008`:积分赛误进顺序赛根因确认在 backend 首页卡片入口配置
+- `F2B-013 / F2B-014`:多赛道选择区问题已转由 backend 发布链修复并收口
+
+### 当前已收口但不再放主文件的确认项
+
+- `F2B-C001`:正式联调以前端消费 backend launch release/manifest 为准
+- `F2B-C002`:协作文档改为 `f2b.md / b2f.md` 双文件
+
diff --git a/f2b.md b/f2b.md
index ceb2250..7824f37 100644
--- a/f2b.md
+++ b/f2b.md
@@ -1,234 +1,170 @@
# F2B 协作清单
-> 文档版本:v1.16
-> 最后更新:2026-04-03 23:58:00
-
+> 文档版本:v2.5
+> 最后更新:2026-04-07 21:24:00
+> 历史归档: [f2b.archive.md](D:/dev/cmr-mini/f2b.archive.md)
说明:
- 本文件由前端维护,写给后端
-- 只写“事实”和“请求”
-- 不写长讨论稿
-- 每条尽量包含:时间、提出方、当前事实、需要对方确认什么、状态
+- 主文件只保留当前仍有意义的信息
+- 已完成的大段历史已转入归档
---
## 待确认
-### F2B-014
+### F2B-019
-- 时间:2026-04-03 23:18:00
+- 时间:2026-04-07 21:24:00
- 提出方:前端
- 当前事实:
- - backend 在 `B2F-037` 中已确认,本次“准备页没有赛道选择区”的直接原因不是前端显示条件,而是当前发布 release 的 `payload_jsonb` 缺少:
- - `play.assignmentMode`
- - `play.courseVariants`
- - backend 已说明修复方式为重新跑:
- - `Bootstrap Demo`
- - `Use Manual Variant Demo`
- - `发布活动配置(自动补 Runtime)` 或 `整条链一键验收`
- - 前端当前逻辑已经兼容:
- - 明确 `assignmentMode=manual` 时显示赛道选择区
- - 即使 `assignmentMode` 缺失,只要 `courseVariants` 中存在 2 条以上可选赛道,也会显示赛道选择区
- - 因此前端现在是否显示赛道选择区,取决于 backend 新发布的 release 是否真的回出了多赛道字段
+ - 游客模式第一刀前端已接到:
+ - 地图列表
+ - 地图详情
+ - 公共活动详情
+ - 公共准备页
+ - 公共 launch
+ - 前端直接实测 backend 公共接口结果如下:
+ - `GET /public/experience-maps` -> `200`
+ - `GET /public/events/evt_demo_001/play` -> `200`
+ - `POST /public/events/evt_demo_001/launch` -> `500 internal_error`
+ - 这说明游客模式“看地图/看活动/看准备页”已经通,但“真正进入地图”当前被 backend 公共 launch 卡住。
+ - 当前 guest mode 只有默认体验活动可进:
+ - `evt_demo_score_o_001` -> `403 event_not_public`
+ - `evt_demo_variant_manual_001` -> `403 event_not_public`
- 需要对方确认什么:
- - 该问题已由 backend 在 `B2F-037` 中确认修复完成,当前不再需要继续追问
- - 后续多赛道联调以修复后的 demo/publish 链为准
+ - 请 backend 优先检查 `POST /public/events/{eventPublicID}/launch` 的服务端错误原因。
+ - 建议先用 `evt_demo_001` 作为游客模式第一刀的联调基线,修通后再扩别的 demo。
+- 状态:待确认
+
+### F2B-018
+
+- 时间:2026-04-07 16:25:00
+- 提出方:前端
+- 当前事实:
+ - 地图体验第一刀前端已完成:
+ - 首页 `地图体验` 入口
+ - 地图列表页
+ - 地图详情页
+ - 默认体验活动卡片跳活动详情页
+ - 当前这条链仍依赖登录态,因为 backend 现有接口:
+ - `GET /experience-maps`
+ - `GET /experience-maps/{mapAssetPublicID}`
+ - `GET /events/{eventPublicID}`
+ - `GET /events/{eventPublicID}/play`
+ - `POST /events/{eventPublicID}/launch`
+ 都走登录态 access token
+ - backend 当前已经补齐这组接口。
+- 需要对方确认什么:
+ - 无
- 状态:已确认
-### F2B-013
+### F2B-017
-- 时间:2026-04-03 22:28:00
+- 时间:2026-04-07 14:40:00
- 提出方:前端
- 当前事实:
- - 手动多赛道活动当前已能进入准备页,但准备页仍未出现赛道选择区
- - 这次前端已排除“仅仅是 `assignmentMode` 没回 manual”这一种情况:
- - 当前前端兼容逻辑已放宽为:只要 `courseVariants` 中存在 2 条以上可选赛道,即使 `assignmentMode` 缺失,也会显示赛道选择区
- - 但当前实际页面仍显示:
- - `赛道模式:默认单赛道`
- - `赛道摘要:当前未声明额外赛道版本,启动时按默认赛道进入`
- - 这说明前端当前实际拿到的更像是:
- - `play.courseVariants = []` 或未返回
- - 前端已追加准备页诊断日志字段,后端可从 `event-prepare` 日志直接核对:
- - `details.variantCount`
- - `details.selectableVariantCount`
- - `details.showVariantSelector`
+ - 前端已新增产品方案文档:
+ - [地图列表与默认体验活动方案](D:/dev/cmr-mini/doc/gameplay/地图列表与默认体验活动方案.md)
+ - 当前建议方向是:
+ - 增加 `地图列表` 作为默认体验活动入口层
+ - 默认体验活动继续复用现有 `Event / Release / Launch / Session`
+ - 默认体验活动可挂可不挂
+ - 默认体验活动可以不出现在正式活动列表
+ - 当前前端并不需要 backend 先做完整地图后台,只需要最小关系和最小摘要支持。
- 需要对方确认什么:
- - 该问题根因已由 backend 在 `B2F-037` 中定位完成,当前不再需要继续从前端显示层排查
- - 后续请转看 `F2B-014`
-- 状态:已解决
+ - 请 backend 先评估并支持以下最小配合项:
+ 1. 地图/地点与默认体验活动的挂接关系
+ - 能回答:某张地图下挂了哪些默认体验活动
+ 2. 活动摘要补两个稳定字段:
+ - `isDefaultExperience`
+ - `showInEventList`
+ 3. 地图列表最小字段建议:
+ - `placeId`
+ - `placeName`
+ - `mapId`
+ - `mapName`
+ - `coverUrl`
+ - `summary`
+ - `defaultExperienceCount`
+ - `defaultExperienceEventIds[]`
+ 4. 地图详情最小字段建议:
+ - 地点名称
+ - 地图名称
+ - 地图预览图
+ - 默认体验活动列表(最少 `eventId / title / subtitle / eventType / status / ctaText`)
+ - 如 backend 对对象关系或字段命名有不同建议,请直接回:
+ - 字段名
+ - 所属接口
+ - 是否建议第一阶段落地
+- 状态:待确认
-### F2B-011
+### F2B-016
-- 时间:2026-04-03
+- 时间:2026-04-07 14:25:00
- 提出方:前端
- 当前事实:
- - 使用 backend 一键测试环境联调 `evt_demo_variant_manual_001` 时,活动页 / 准备页返回:
- - `primaryAction = continue`
- - `reason = user has an ongoing session for this event`
- - 但前端本地当前没有可恢复快照,且本轮联调主观确认“已经没有需要恢复的游戏”
- - 当前看起来像是 backend 仍认定该用户在该活动下存在 ongoing session
+ - 前端已新增一份用于 backend 对齐的配置裁剪文档:
+ - [最大配置模板后台落地裁剪表](D:/dev/cmr-mini/doc/config/最大配置模板后台落地裁剪表.md)
+ - 该文档的目的不是让 backend 1:1 支持最大配置模板,而是把当前配置能力裁成三类:
+ - 第一阶段必做
+ - 第二阶段可做
+ - 暂不进后台,继续留在程序默认值层
+ - 该文档建议配合以下文档一起看:
+ - [后端总体架构与当前执行清单](D:/dev/cmr-mini/doc/backend/后端总体架构与当前执行清单.md)
+ - [后端第一阶段执行清单](D:/dev/cmr-mini/doc/backend/后端第一阶段执行清单.md)
- 需要对方确认什么:
- - 请 backend 核对该用户在 `evt_demo_variant_manual_001` 下是否仍有 `launched / running` session 未清掉
- - 如这是预期行为,请说明推荐的标准清理路径;如不是预期,请修正 ongoing 判定或测试环境回收逻辑
-- 状态:待后续单独收口(当前不阻塞主线)
+ - 请 backend 以这三份文档为基线,对齐:
+ - 第一阶段后台对象范围
+ - 第一阶段应进入后台的配置字段
+ - 暂不进后台、继续保留在程序默认值层的字段
+ - 如 backend 对某块裁剪有异议,请直接指出:
+ - 字段名
+ - 希望调整到哪一阶段
+ - 原因
+- 状态:待确认
+
+### F2B-015
+
+- 时间:2026-04-07 13:46:00
+- 提出方:前端
+- 当前事实:
+ - 准备页地图预览当前已改成:
+ - 优先消费 `GET /events/{eventPublicID}/play` 返回的 `preview`
+ - 按当前所选 `variantId` 生成预览点位
+ - 底图优先仍使用 manifest 对应的正式瓦片源
+ - 当前小程序侧现象是:准备页预览仍为空白
+ - 前端已补结构化日志,当前会向 backend `client-logs` 上报:
+ - `category=event-prepare`
+ - `details.phase=prepare-preview`
+ - `source`
+ - `selectedVariantId`
+ - `backendPreviewVariantCount`
+ - `tileCount`
+ - `controlCount`
+ - `overlayAvailable`
+ - `previewMode`
+ - 失败时 `errorMessage`
+- 需要对方确认什么:
+ - 请 backend 拉取这批 `prepare-preview` 日志,并核对:
+ - 当前 `play.preview.variants` 是否真的返回了多条 variant 预览数据
+ - 当前所选 `selectedVariantId` 是否能在 `preview.variants[]` 中命中
+ - 当前 preview viewport / baseTiles 是否与正式发布对象一致
+ - 如果 backend 已确认日志中 `backendPreviewVariantCount > 0` 但前端仍空白,请回传对应日志片段与当前 demo 的 `eventId / releaseId`
+- 状态:待确认
---
## 已确认
-### F2B-C001
-
-- 时间:2026-04-01
-- 提出方:前端
-- 当前事实:
- - 正式联调时,前端以 backend `launch` 下发的 release/manifest 为准
- - 不再回退到本地 `event/*.json`
-- 需要对方确认什么:
- - 无
-- 状态:已确认
-
-### F2B-C002
-
-- 时间:2026-04-01
-- 提出方:前端
-- 当前事实:
- - 前后端协作文档改为双文件:
- - `f2b.md` 由前端维护
- - `b2f.md` 由后端维护
-- 需要对方确认什么:
- - 无
-- 状态:已确认
-
-### F2B-C003
-
-- 时间:2026-04-02
-- 提出方:前端
-- 当前事实:
- - backend 已确认 session 三态正式语义:
- - 正常完成 -> `finished`
- - 超时或规则失败 -> `failed`
- - 主动退出 / 放弃恢复 -> `cancelled`
- - 前端已按这套语义继续联调
-- 需要对方确认什么:
- - 无
-- 状态:已确认
-
-### F2B-C004
-
-- 时间:2026-04-02
-- 提出方:前端
-- 当前事实:
- - backend 已确认“放弃恢复”官方语义为 `finish(cancelled)`
- - 旧 `sessionToken` 在该场景下允许继续调用
- - 前端当前已正式启用该链路
-- 需要对方确认什么:
- - 无
-- 状态:已确认
-
-### F2B-C005
-
-- 时间:2026-04-02
-- 提出方:前端
-- 当前事实:
- - backend 已确认 `start / finish` 按幂等处理
- - 前端可继续按当前补报 / 重试逻辑联调
-- 需要对方确认什么:
- - 无
-- 状态:已确认
-
-### F2B-C006
-
-- 时间:2026-04-02
-- 提出方:前端
-- 当前事实:
- - backend 已确认多赛道第一阶段最小契约,且相关字段已可从以下接口返回:
- - `/events/{eventPublicID}/play`
- - `/events/{eventPublicID}/launch`
- - `/me/entry-home`
- - `/sessions/{sessionPublicID}`
- - `/sessions/{sessionPublicID}/result`
- - `/me/results`
- - `/me/sessions`
- - 正式口径为:
- - `play.assignmentMode`
- - `play.courseVariants[]`
- - `launch.variant.id/name/routeCode/assignmentMode`
- - `session / ongoing / recent / result` 摘要中带 `variantId/variantName/routeCode`
-- 需要对方确认什么:
- - 无
-- 状态:已确认
-
-### F2B-C007
-
-- 时间:2026-04-02
-- 提出方:前端
-- 当前事实:
- - backend 已确认 launch 关键字段为正式契约:
- - `resolvedRelease.manifestUrl`
- - `resolvedRelease.releaseId`
- - `business.sessionId`
- - `business.sessionToken`
- - `business.sessionTokenExpiresAt`
- - 如后续字段名或层级需调整,backend 将先在 `b2f.md` 通知
-- 需要对方确认什么:
- - 无
-- 状态:已确认
-
-### F2B-C008
-
-- 时间:2026-04-02
-- 提出方:前端
-- 当前事实:
- - backend 已确认 ongoing / recent / result 摘要口径:
- - `launched`、`running` 作为 ongoing
- - `finished`、`failed`、`cancelled` 不再作为 ongoing
- - `/me/results` 只返回终态对局
- - 前端后续按这套摘要口径做显示与回归
-- 需要对方确认什么:
- - 无
-- 状态:已确认
-
-### F2B-C009
-
-- 时间:2026-04-03
-- 提出方:前端
-- 当前事实:
- - backend 已提供可联调的 `manual` 多赛道 demo 活动:
- - `evt_demo_variant_manual_001`
- - backend 已确认 `launch` 选定的 `variantId` 会稳定回流到:
- - `/me/entry-home`
- - `/sessions/{sessionPublicID}/result`
- - `/me/results`
-- 需要对方确认什么:
- - 无
-- 状态:已确认
-
-### F2B-C010
-
-- 时间:2026-04-03
-- 提出方:前端
-- 当前事实:
- - backend 已透出活动运营域第二阶段摘要字段:
- - `currentPresentation`
- - `currentContentBundle`
- - `launch.presentation`
- - `launch.contentBundle`
- - 前端当前按总控口径,仅做类型 / adapter / 活动页与准备页轻摘要接线,不扩新页面链
-- 需要对方确认什么:
- - 无
-- 状态:已确认
-
### F2B-C011
- 时间:2026-04-03 22:20:00
- 提出方:前端
- 当前事实:
- - backend 已通过 `B2F-035` 正式收紧 `play.canLaunch` 与 `launch` 的前置条件
+ - backend 已通过 `B2F-035` 收紧 `play.canLaunch` 与 `launch`
- 当前规则为:缺 `runtime / presentation / content bundle / manifest / 当前发布 release` 任一项时,均不可进入游戏
- - 前端已按该契约复测,当前结果正常:
- - `canLaunch=false` 时页面会禁用进入动作
- - `play.reason` 会给出更具体的缺失原因
- - backend 也不会再允许直接 `launch` 绕过阻断
+ - 前端已复测通过,当前按 `play.canLaunch` 作为正式阻断口径
- 需要对方确认什么:
- 无
- 状态:已确认
@@ -238,11 +174,11 @@
- 时间:2026-04-03 23:52:00
- 提出方:前端
- 当前事实:
- - backend 已在 `B2F-037` 中确认:manual 多赛道准备页不显示选择区的根因是发布 release 缺少:
+ - manual 多赛道准备页不显示选择区的根因已确认是发布 release 缺少:
- `play.assignmentMode`
- `play.courseVariants`
- - backend 已修复 `Bootstrap Demo` 与发布链,当前问题已通过联调日志确认收口
- - frontend 当前已保留多赛道兜底展示逻辑,但该问题主因不在前端显示层
+ - backend 已修复 demo/build/publish 链
+ - 前端保留了多赛道空态兜底,但主因不在前端
- 需要对方确认什么:
- 无
- 状态:已确认
@@ -252,17 +188,8 @@
- 时间:2026-04-03 23:52:00
- 提出方:前端
- 当前事实:
- - backend 在 `B2F-038` 中要求的活动卡片列表第一刀字段,frontend 当前已按最小方案接入:
- - `summary`
- - `status`
- - `statusCode`
- - `timeWindow`
- - `ctaText`
- - `isDefaultExperience`
- - `eventType`
- - `currentPresentation`
- - `currentContentBundle`
- - frontend 当前列表页和详情页日志也已补齐:
+ - 活动卡片列表第一刀所需字段当前已足够
+ - 前端已补齐列表与详情页联调日志:
- `cardEventIds`
- `clickedEventId`
- `detailStatus`
@@ -270,254 +197,71 @@
- `detailCurrentPresentation`
- `detailCurrentContentBundle`
- 需要对方确认什么:
- - 当前字段已足够支撑活动卡片列表最小实现
- - 当前没有发现必须新增的列表页名称摘要字段
+ - 无
- 状态:已确认
---
## 阻塞
-### F2B-B001
-
-- 时间:2026-04-01
-- 提出方:前端
-- 当前事实:
- - 当前前端主链已基本可联调
- - 目前没有新的 backend 阻塞项
-- 需要对方确认什么:
- - 无
-- 状态:已解决
+- 当前无
---
## 已完成
-### F2B-D001
-
-- 时间:2026-04-01
-- 提出方:前端
-- 当前事实:
- - 小程序已接通:
- - 登录
- - 首页聚合
- - 活动页 `play`
- - `launch -> 地图页`
- - `session start`
- - `session finish`
- - `session result`
-- 需要对方确认什么:
- - 无
-- 状态:已完成
-
-### F2B-D002
-
-- 时间:2026-04-01
-- 提出方:前端
-- 当前事实:
- - 小程序已接入故障恢复:
- - 检测未正常结束对局
- - 弹“继续恢复 / 放弃”
- - 继续恢复时恢复本地运行时快照
- - 放弃时清本地恢复,并上报 `finish(cancelled)`
-- 需要对方确认什么:
- - 无
-- 状态:已完成
-
-### F2B-D003
-
-- 时间:2026-04-01
-- 提出方:前端
-- 当前事实:
- - `evt_demo_001` 当前 release manifest 已恢复可用
- - 前端已能正常进入地图
-- 需要对方确认什么:
- - 无
-- 状态:已完成
-
-### F2B-D004
-
-- 时间:2026-04-02
-- 提出方:前端
-- 当前事实:
- - 前端已完成多赛道第一阶段接入:
- - `backendApi / launchAdapter / GameLaunchEnvelope` 已接入 `variant` 字段
- - 故障恢复会随 `launchEnvelope` 保留 `variant` 信息
- - 活动页、准备页、首页、单局结果页、历史结果页开始展示赛道版本信息
- - `manual` 模式下准备页已支持选择赛道并把 `variantId` 带入 launch
-- 需要对方确认什么:
- - 无
-- 状态:已完成
-
-### F2B-D005
-
-- 时间:2026-04-03
-- 提出方:前端
-- 当前事实:
- - 前端已完成活动运营域摘要第一刀的轻接线:
- - 活动页开始展示 `currentPresentation / currentContentBundle`
- - 准备页开始展示活动运营摘要
- - `launch.presentation / launch.contentBundle` 已进入 `GameLaunchEnvelope`
- - 会话快照会随 `launchEnvelope` 一起保留这批摘要
-- 需要对方确认什么:
- - 无
-- 状态:已完成
-
-### F2B-D006
-
-- 时间:2026-04-03
-- 提出方:前端
-- 当前事实:
- - 已按 backend `B2F-028` 的排查口径补充前端诊断链,当前地图信息面板/赛后结果里可直接查看:
- - `launch.config.configUrl`
- - `launch.resolvedRelease.manifestUrl`
- - `launch.config.releaseId`
- - `launch.resolvedRelease.releaseId`
- - 最终加载后的:
- - `Schema版本`
- - `场地类型(playfield.kind)`
- - `模式编码(game.mode)`
- - 当前只补了诊断与观测,没有改动正式 launch 主链
-- 需要对方确认什么:
- - 无
-- 状态:已完成
-
-### F2B-D007
-
-- 时间:2026-04-03 16:26:37
-- 提出方:前端
-- 当前事实:
- - 已按 `B2F-030` 接入 backend `POST /dev/client-logs`
- - 当前关键阶段会主动上报最小调试日志:
- - `entry-home`
- - `event-play`
- - `event-prepare`
- - `launch-diagnostic`
- - `runtime-compiler`
- - `session-recovery`
- - 当前主日志字段已按 backend 建议最小口径回传:
- - `source`
- - `level`
- - `category`
- - `message`
- - `eventId`
- - `releaseId`
- - `sessionId`
- - `manifestUrl`
- - `route`
- - `details.phase`
- - `details.schemaVersion`
- - `details.playfield.kind`
- - `details.game.mode`
- - 模拟器日志不再作为当前联调主诊断口,保留地图内调试面板作为本地辅助能力
-- 需要对方确认什么:
- - 无
-- 状态:已完成
-
-### F2B-D008
-
-- 时间:2026-04-03 16:45:26
-- 提出方:前端
-- 当前事实:
- - backend 已通过 `B2F-031` 明确确认:积分赛误进顺序赛的根因不是前端解析,而是首页卡片入口配置错误
- - 具体根因为:
- - 首页卡片查询此前只取 `home_primary`
- - 积分赛 demo 卡此前被种到 `home_secondary`
- - 前端首页因此根本拿不到 `evt_demo_score_o_001`
- - backend 已修复积分赛卡片入口配置
- - 前端当前无需再为该问题修改玩法解析或 manifest 消费逻辑
-- 需要对方确认什么:
- - 无
-- 状态:已完成
-
-### F2B-D009
-
-- 时间:2026-04-03 16:45:26
-- 提出方:前端
-- 当前事实:
- - 已按 `B2F-032` 优化前端结构化调试日志口径:
- - 非多赛道玩法时,不再上报空字符串形式的 `assignmentMode`
- - 非手选赛道时,不再把空 `variantId` 伪装成已选赛道
- - 所有 client log 现在都会附带前端本地递增 `details.seq`
- - `launchVariantId` 与 `runtimeCourseVariantId` 已明确区分
-- 需要对方确认什么:
- - 无
-- 状态:已完成
-
### F2B-D010
- 时间:2026-04-03 22:12:00
- 提出方:前端
- 当前事实:
- - 已按 `B2F-034` 对活动页和准备页做语义收口:
- - `展示版本` 改成 `当前发布展示版本`
- - `内容包版本` 改成 `当前发布内容包版本`
- - 当 `currentPresentation / currentContentBundle` 为空时,前端当前统一解释为:
- - `当前发布 release 未绑定展示版本,或当前尚未发布`
- - `当前发布 release 未绑定内容包版本,或当前尚未发布`
- - 活动页与准备页当前进入动作都已优先受 `play.canLaunch` 控制:
- - `canLaunch=false` 时按钮禁用
- - 同时阻止继续进入准备页或地图
+ - 活动页与准备页已统一使用:
+ - `当前发布展示版本`
+ - `当前发布内容包版本`
+ - 当两项为空时,前端统一解释为:
+ - 当前发布 release 未绑定
+ - 或当前尚未发布
+- 需要对方确认什么:
+ - 无
+- 状态:已完成
+
+### F2B-D011
+
+- 时间:2026-04-07 12:06:00
+- 提出方:前端
+- 当前事实:
+ - 首页 `ongoingSession` 已收成正式交互
+ - 当前首页仅在 backend 返回 `ongoingSession` 时显示“进行中的游戏”
+ - 支持:
+ - `恢复`
+ - `放弃`
+ - `放弃` 会调用 `finish(cancelled)`,然后清理本地恢复快照并刷新首页
- 需要对方确认什么:
- 无
- 状态:已完成
---
+## 尾项
+
+### F2B-011
+
+- 时间:2026-04-03
+- 提出方:前端
+- 当前事实:
+ - demo 历史 `ongoing session` 的回收口径仍是独立尾项
+ - 当前不阻塞主线:多赛道、活动列表、运营摘要、runtime 主链均可继续联调
+- 需要对方确认什么:
+ - 后续请单独收口 demo 环境下 `launched / running` session 清理与 ongoing 判定规则
+- 状态:待后续单独处理
+
+---
+
## 下一步
-### F2B-N001
-
-- 时间:2026-04-02
-- 提出方:前端
-- 当前事实:
- - session 生命周期关键语义已由 backend 确认
- - 当前前端下一轮重点应转向主链回归与结果展示对齐
-- 需要对方确认什么:
- - 无
-- 状态:前端执行中
-
-### F2B-N002
-
-- 时间:2026-04-02
-- 提出方:前端
-- 当前事实:
- - 心率 / 卡路里个体化能力已在前端预留
-- 需要对方确认什么:
- - 后续是否提供用户身体数据接口
-- 状态:后续事项
-
-### F2B-N003
-
-- 时间:2026-04-02
-- 提出方:前端
-- 当前事实:
- - backend 已确认多赛道第一阶段最小契约
- - 前端已完成第一阶段基础接入,下一步将转入多赛道专项联调与展示补强
-- 需要对方确认什么:
- - 无
-- 状态:前端执行中
-
-### F2B-N004
-
-- 时间:2026-04-03
-- 提出方:前端
-- 当前事实:
- - 当前主链已进入“稳住 + 联调修复”阶段
- - 活动运营域摘要第一刀已接通,但前端不会主动扩复杂运营样式
-- 需要对方确认什么:
- - 无
-- 状态:前端执行中
-
-### F2B-N005
-
-- 时间:2026-04-03
-- 提出方:前端
-- 当前事实:
- - 当前已具备积分赛 demo 发布链诊断信息,下一步将按 backend 一键测试环境回归 `evt_demo_score_o_001`
- - 如仍表现为顺序赛,前端将回传 launch/config/runtime 三段事实,不再只报“现象”
-- 需要对方确认什么:
- - 无
-- 状态:前端执行中
-
-
+- 当前前后端继续按 backend 一键测试环境联调
+- 当前前端侧会优先关注:
+ - 活动列表第一刀回归
+ - 活动详情页/准备页用户化小修
+ - 准备页地图预览 V1 稳定性
+- 如后端语义或字段发生变化,再通过 `b2f.md` / `f2b.md` 做增量同步
diff --git a/f2f.md b/f2f.md
new file mode 100644
index 0000000..57e2e28
--- /dev/null
+++ b/f2f.md
@@ -0,0 +1,200 @@
+# F2F 工作交接
+
+- 文档版本:v1.0
+- 最后更新:2026-04-07 21:45:00
+- 维护方:前端线程 -> 新线程
+
+## 当前主线状态
+
+### 1. 活动主链
+- 活动列表第一刀已完成:
+ - 独立活动列表页已接通
+ - 支持 `全部 / 体验`
+ - 可跳活动详情页
+- 活动详情页用户化第一刀已完成:
+ - `当前状态`
+ - `赛道与版本`
+ - CTA 文案更像用户视角
+- 准备页用户化第一刀已完成:
+ - `当前准备状态`
+ - `活动版本摘要`
+ - `本局对象预览`
+ - 设备准备区
+- 结果页 / 历史页活动链衔接已完成一轮小修:
+ - 单局结果页支持 `返回活动`
+ - 历史结果页支持 `查看单局结果 / 返回活动`
+
+### 2. 地图体验主链
+- 地图体验第一刀已完成:
+ - 首页有 `地图体验` 入口
+ - 地图列表页已接通
+ - 地图详情页已接通
+ - 地图下默认体验活动可跳现有活动详情主链
+- 当前对象模型口径:
+ - `Place`
+ - `MapAsset`
+ - `Event`
+ - `defaultExperienceEvents[]`
+- 默认体验活动支持:
+ - 可挂可不挂
+ - 可以不显示在正式活动列表
+
+### 3. 游客模式主链
+- 游客模式第一刀已接上:
+ - 登录页支持 `游客体验`
+ - 游客可进地图列表、地图详情、默认体验活动详情、准备页、公共 `launch`
+ - 活动详情 / 准备页无登录态时自动走 `/public/...`
+ - 游客结果页优先展示本地结果摘要,不强制跳登录
+- 之前 `/public/events/{id}/launch` 有 backend 500 问题,已在协作文档里反馈过。
+- 用户最新口头反馈:backend 改后“能进了”,但未再做完整闭环确认。
+
+### 4. 准备页地图预览 V1
+- 已完成并可用:
+ - 低级别正式瓦片做底图
+ - 白膜压背景
+ - 只叠 KML 点位,不画腿线,不画数字
+ - 点位群尽量充满预览窗口
+- 多赛道:
+ - 已支持切换赛道后联动预览
+ - 当前实现是:
+ - 底图与缩放优先走 manifest 正式地图配置
+ - 多赛道点位联动优先消费 backend `play.preview`
+- 单赛道:
+ - 强制回到稳定的 manifest 预览链
+- 之前为预览排查加的 backend 临时日志已清理。
+
+### 5. 准备页进入地图链
+- 已补齐:
+ - 防连点
+ - 进度反馈
+ - 12 秒超时兜底
+ - 返回准备页后清理残留“正在进入地图”视觉状态
+- 相关文件:
+ - `miniprogram/pages/event-prepare/event-prepare.ts`
+ - `miniprogram/pages/event-prepare/event-prepare.wxml`
+ - `miniprogram/pages/event-prepare/event-prepare.wxss`
+
+### 6. 恢复与 ongoing
+- 冷启动恢复弹窗已取消
+- 统一走首页 `进行中的游戏` 区块:
+ - `恢复`
+ - `放弃`
+- `放弃` 会走 `finish(cancelled)`,然后清本地恢复快照并刷新首页
+
+## 最近重要修复
+
+### 1. 单独项目 www 类型检查干扰
+- 已在根 `tsconfig.json` 中排除 `www`
+- 现在根项目 `npm run typecheck` 可以通过
+
+### 2. 多赛道准备页选择区
+- 前面出现过“活动是多赛道,但准备页没有赛道区”的问题
+- 已确认根因主要在 backend 发布 release 缺少:
+ - `play.assignmentMode`
+ - `play.courseVariants`
+- 前端做了用户体验兜底:
+ - 只要能判断是多赛道活动,即使后端暂未返回可选赛道,也保留赛道区空态提示
+
+### 3. 积分赛误进顺序赛
+- 已确认根因在 backend 首页卡片入口配置,不是前端玩法解析
+- 当前主链已收口
+
+## 协作文档现状
+
+### 1. 前端 -> 后端
+- 文件:`f2b.md`
+- 当前版本:v2.5
+- 当前重点:
+ - `F2B-019`:游客模式公共 `launch` 曾返回 500,backend 文档称已修,但建议新线程视情况确认是否真正闭环
+
+### 2. 前端 -> 总控
+- 文件:`f2t.md`
+- 当前版本:v2.0
+- 说明:
+ - 内容已经偏旧,没有完整覆盖游客模式、地图体验、预览 V1、event-prepare 启动防抖等后续改动
+ - 如新线程继续与总控同步,建议先更新此文件
+
+## 当前建议优先级
+
+### P0:先做一轮游客模式真回归
+建议测试:
+1. 登录页点 `游客体验`
+2. 进入地图列表
+3. 进入地图详情
+4. 点默认体验活动
+5. 进入活动详情 / 准备页 / 地图
+6. 正常完成或主动退出
+7. 确认结果页、返回链、退出链是否正常
+
+如果游客主链仍有问题,优先看:
+- backend `/public/...` 返回
+- `f2b.md` 里的 `F2B-019`
+
+### P1:更新总控协作文档
+- 把以下内容同步进 `f2t.md`:
+ - 地图体验第一刀
+ - 游客模式第一刀
+ - 准备页预览 V1
+ - 进入地图防抖/进度/超时
+
+### P2:继续后台/运维平台协同
+当前已落文档,可继续给 backend / 总控参考:
+- `doc/backend/后台游戏定制支持方案.md`
+- `doc/backend/后端总体架构与当前执行清单.md`
+- `doc/config/最大配置模板后台落地裁剪表.md`
+- `doc/gameplay/地图列表与默认体验活动方案.md`
+- `doc/gameplay/准备页地图预览方案.md`
+
+## 建议优先阅读文档
+
+### 新线程先看这 6 份
+1. `f2f.md`
+ - 当前交接总览
+2. `f2b.md`
+ - 前端写给后端的当前待确认/已确认事项
+3. `f2t.md`
+ - 前端写给总控的当前状态
+4. `doc/gameplay/准备页地图预览方案.md`
+ - 准备页预览的设计与边界
+5. `doc/gameplay/地图列表与默认体验活动方案.md`
+ - 地图体验入口与默认体验活动模型
+6. `doc/backend/后端总体架构与当前执行清单.md`
+ - 后台/发布/游戏定制的大方向
+
+### 如果要继续做后台协同,再看这 3 份
+1. `doc/backend/后台游戏定制支持方案.md`
+2. `doc/config/最大配置模板后台落地裁剪表.md`
+3. `doc/backend/后端第一阶段执行清单.md`
+
+## 关键文件
+
+### 活动/地图体验
+- `miniprogram/pages/home/home.ts`
+- `miniprogram/pages/events/events.ts`
+- `miniprogram/pages/event/event.ts`
+- `miniprogram/pages/event-prepare/event-prepare.ts`
+- `miniprogram/pages/experience-maps/experience-maps.ts`
+- `miniprogram/pages/experience-map/experience-map.ts`
+- `miniprogram/pages/result/result.ts`
+- `miniprogram/pages/results/results.ts`
+
+### API / 启动 / 预览
+- `miniprogram/utils/backendApi.ts`
+- `miniprogram/utils/backendLaunchAdapter.ts`
+- `miniprogram/utils/gameLaunch.ts`
+- `miniprogram/utils/prepareMapPreview.ts`
+
+### 恢复 / 会话 / 首页 ongoing
+- `miniprogram/pages/index/index.ts`
+- `miniprogram/pages/home/home.ts`
+- `miniprogram/pages/map/map.ts`
+
+## 当前验证结果
+- `npm run test:runtime-smoke`:通过
+- `npm run typecheck`:通过
+
+## 交接结论
+- 当前不是架构失控阶段,主链总体稳定
+- 新线程优先不要扩新链
+- 先把游客模式做一次完整真回归
+- 再决定是继续收产品流,还是回到后台/总控协同线
diff --git a/f2t.archive.md b/f2t.archive.md
new file mode 100644
index 0000000..0b7746a
--- /dev/null
+++ b/f2t.archive.md
@@ -0,0 +1,44 @@
+# F2T 协作归档
+> 文档版本:v1.0
+> 最后更新:2026-04-07 13:10:00
+> 归档来源: [f2t.md](D:/dev/cmr-mini/f2t.md) v1.17 之前历史条目
+
+说明:
+
+- 本文件用于保存 `f2t.md` 主文件收缩前的历史事项
+- 只保留摘要,不再继续滚动追加日常新项
+
+---
+
+## 已归档事项摘要
+
+### runtime / launch 第五刀
+
+- `F2T-001 ~ F2T-005`
+- `F2T-D001 ~ F2T-D007`
+- 主要包括:
+ - `launch.runtime` 接线
+ - 准备页 / 地图页 / 结果页 / 历史页 / 首页摘要接入
+ - 第五刀联调回归清单
+ - 活动运营域摘要第一刀
+ - 统一 backend 一键测试环境
+ - backend `client-logs` 主诊断链
+
+### 活动卡片列表第一刀准备与落地
+
+- `F2T-D008`
+- `F2T-D010`
+- 主要包括:
+ - 活动卡片列表最小产品方案
+ - 独立活动列表页第一刀
+ - `全部 / 体验` 最小筛选
+ - 最小卡片字段与详情跳转
+
+### 页面用户化与活动链
+
+- 这些事项仍在主文件保留近期版本,但更早的背景讨论已归档
+- 包括:
+ - 活动详情页用户化第一刀
+ - 活动准备页用户化第一刀
+ - 结果页 / 历史页活动链衔接第一轮小修
+
diff --git a/f2t.md b/f2t.md
index e996038..8411c03 100644
--- a/f2t.md
+++ b/f2t.md
@@ -1,13 +1,13 @@
# F2T 协作清单
-> 文档版本:v1.12
-> 最后更新:2026-04-03 23:42:00
+> 文档版本:v2.1
+> 最后更新:2026-04-07 21:52:00
+> 历史归档: [f2t.archive.md](D:/dev/cmr-mini/f2t.archive.md)
说明:
- 本文件由前端线程维护,写给总控线程
-- 只写事实和请求
-- 不写长讨论稿
-- 每条尽量包含:时间、谁提的、当前事实、需要确认什么、是否已解决
+- 主文件只保留当前阶段真正需要看的信息
+- 长历史已转入归档
---
@@ -19,71 +19,20 @@
## 已确认
-### F2T-001
+### F2T-当前主线
-- 时间:2026-04-03 14:28:00
+- 时间:2026-04-07 21:52:00
- 谁提的:frontend
- 当前事实:
- - 总控已确认:准备页当前阶段允许按“预览态运行对象摘要”展示
- - 后端后续如补 `pre-launch runtime preview` 能力,再升级为正式预览态
-- 需要确认什么:
- - 无
-- 是否已解决:是
-
-### F2T-002
-
-- 时间:2026-04-03 14:28:00
-- 谁提的:frontend
-- 当前事实:
- - 地图页和单局结果页已开始消费 `launch.runtime`
- - 当前做法为:
- - 地图页:在“当前游戏”摘要里追加 runtime 对象行
- - 单局结果页:优先读 `result.session.runtime`,没有时回退到 launch 快照
-- 需要确认什么:
- - 无
-- 是否已解决:是
-
-### F2T-003
-
-- 时间:2026-04-03 14:42:00
-- 谁提的:frontend
-- 当前事实:
- - 历史结果列表页已开始展示 runtime 摘要
- - 当前展示内容:
- - `place`
- - `map`
- - `course variant`
- - 当前仍保持摘要态展示,不改列表主结构
-- 需要确认什么:
- - 无
-- 是否已解决:是
-
-### F2T-004
-
-- 时间:2026-04-03 14:42:00
-- 谁提的:frontend
-- 当前事实:
- - 首页 `ongoing / recent` 已开始展示 runtime 摘要
- - 当前展示内容:
- - `place`
- - `map`
- - `course variant`
- - 当前仍保持摘要态展示,不改首页卡片结构
-- 需要确认什么:
- - 无
-- 是否已解决:是
-
-### F2T-005
-
-- 时间:2026-04-03 18:10:00
-- 谁提的:frontend
-- 当前事实:
- - 总控已确认前端当前阶段切换为“活动运营域摘要第一刀”
- - 当前只允许:
- - 活动详情页轻摘要
- - 准备页轻摘要
- - 会话快照接线
- - 当前不做复杂运营样式,也不重构 runtime 主链
+ - 当前前端仍按总控主线推进:
+ - 活动列表第一刀回归与小修
+ - 活动详情页用户化
+ - 活动准备页用户化
+ - 结果页 / 历史页活动链衔接小修
+ - 同时已补齐两条可复用入口层能力:
+ - 地图体验第一刀
+ - 游客模式第一刀
+ - 当前不扩新主链,不重做首页主入口,不做复杂运营样式
- 需要确认什么:
- 无
- 是否已解决:是
@@ -98,173 +47,128 @@
## 已完成
-### F2T-D001
+### F2T-D011
-- 时间:2026-04-03 14:50:00
+- 时间:2026-04-07 10:42:00
- 谁提的:frontend
- 当前事实:
- - 已完成 `launch.runtime -> GameLaunchEnvelope.runtime` 适配
- - 已完成赛后跳结果页时的 runtime 快照兜底透传
- - 已完成准备页、地图页、单局结果页、历史结果列表页、首页摘要第一阶段可视化接入
+ - 活动详情页用户化第一刀已完成
+ - 当前详情页已收成:
+ - `当前状态`
+ - `赛道与版本`
+ - `canLaunch=false` 时主按钮文案已收成 `查看准备状态`
- 需要确认什么:
- 无
- 是否已解决:是
-### F2T-D002
+### F2T-D012
-- 时间:2026-04-03 14:50:00
+- 时间:2026-04-07 11:22:00
- 谁提的:frontend
- 当前事实:
- - 已新增 [第五刀联调回归清单](D:/dev/cmr-mini/doc/gameplay/第五刀联调回归清单.md)
- - 当前回归口径已固定覆盖:
- - 准备页
- - 地图页
- - 单局结果页
- - 历史结果列表页
- - 首页 `ongoing / recent`
- - 恢复链
+ - 活动准备页用户化第一刀已完成
+ - 当前准备页已收成:
+ - `当前准备状态`
+ - `活动版本摘要`
+ - `本局对象预览`
+ - `赛道选择`
+ - `设备准备`
- 需要确认什么:
- 无
- 是否已解决:是
-### F2T-D003
+### F2T-D013
-- 时间:2026-04-03 19:20:00
+- 时间:2026-04-07 11:41:00
- 谁提的:frontend
- 当前事实:
- - 已完成活动运营域摘要第一刀轻接线:
- - 活动详情页开始展示 `currentPresentation / currentContentBundle`
- - 准备页开始展示活动运营摘要
- - `launch.presentation / launch.contentBundle` 已适配进 `GameLaunchEnvelope`
- - 会话快照会随 `launchEnvelope` 一起保留活动运营摘要
- - 当前仍保持“摘要接线”边界,没有扩新页面主链
+ - 结果页 / 历史页活动链衔接第一轮小修已完成
+ - 当前已补:
+ - 单局结果页 `返回活动`
+ - 历史结果页 `查看单局结果 / 返回活动`
- 需要确认什么:
- 无
- 是否已解决:是
-### F2T-D004
+### F2T-D014
-- 时间:2026-04-03 19:38:00
+- 时间:2026-04-07 12:56:00
- 谁提的:frontend
- 当前事实:
- - 已新增 [活动运营域摘要第一刀联调回归清单](D:/dev/cmr-mini/doc/gameplay/活动运营域摘要第一刀联调回归清单.md)
- - 当前回归口径已固定覆盖:
- - 活动详情页摘要
- - 准备页摘要
- - `launch.presentation / launch.contentBundle` 会话快照
- - 与 runtime 主链隔离
- - 缺字段降级
+ - 准备页地图预览方案已落地并进入 V1
+ - 当前 V1 已完成:
+ - 低级别正式瓦片底图
+ - 准备页只读预览卡
+ - 前端叠加当前已知 KML 点位
+ - 多赛道数据不足时保留预览区并显示空态/说明提示
+ - 当前 V1 不做:
+ - 腿线
+ - 拖拽
+ - 缩放
+ - 复杂交互
- 需要确认什么:
- 无
- 是否已解决:是
-### F2T-D005
+### F2T-D015
-- 时间:2026-04-03 19:48:00
+- 时间:2026-04-07 18:40:00
- 谁提的:frontend
- 当前事实:
- - 已按总控最新口径把联调方式标准化
- - 当前活动运营域摘要第一刀回归默认统一使用 backend 的一键测试环境:
- - `Bootstrap Demo`
- - `一键补齐 Runtime 并发布`
- - 不再建议前后端各自手工铺多份 demo 对象
+ - 地图体验第一刀已完成
+ - 当前已具备:
+ - 首页 `地图体验` 入口
+ - 地图列表页
+ - 地图详情页
+ - 地图下默认体验活动入口
+ - 默认体验活动继续复用现有活动详情/准备页/地图主链
- 需要确认什么:
- 无
- 是否已解决:是
-### F2T-D006
+### F2T-D016
-- 时间:2026-04-03 16:26:37
+- 时间:2026-04-07 19:10:00
- 谁提的:frontend
- 当前事实:
- - 已按 backend 新增 dev 调试接口切换当前联调诊断主出口:
- - `POST /dev/client-logs`
- - 当前首页、活动页、准备页、地图关键链路会主动上报:
- - `entry-home`
- - `event-play`
- - `event-prepare`
- - `launch-diagnostic`
- - `runtime-compiler`
- - `session-recovery`
- - 登录后自动连接模拟器日志的链路已撤掉
- - 地图内调试面板继续保留,仅作为本地开发辅助,不再作为当前联调主诊断口
+ - 游客模式第一刀已完成
+ - 当前已具备:
+ - 登录页 `游客体验`
+ - 游客进入地图列表/地图详情/默认体验活动
+ - 无登录态时活动详情/准备页自动走 `/public/...`
+ - 游客结果页优先展示本地结果摘要
+- 需要确认什么:
+ - 当前更适合做一轮整链真回归
+- 是否已解决:是
+
+### F2T-D017
+
+- 时间:2026-04-07 20:05:00
+- 谁提的:frontend
+- 当前事实:
+ - 准备页进入地图链路已收口
+ - 当前已补:
+ - 防连点
+ - 状态反馈/进度条
+ - 12 秒超时保护
+ - 从地图主动退出后清理准备页残留载入态
- 需要确认什么:
- 无
- 是否已解决:是
-### F2T-D007
+### F2T-D018
-- 时间:2026-04-03 16:45:26
+- 时间:2026-04-07 21:40:00
- 谁提的:frontend
- 当前事实:
- - backend 已确认积分赛误进顺序赛的根因在 backend demo 首页卡片入口配置,不在前端玩法解析
- - 前端本轮未再修改 runtime / manifest 消费主链
- - 前端仅补了联调日志口径优化:
- - 非多赛道玩法不再上报空字符串 `assignmentMode`
- - 日志新增前端本地递增 `details.seq`
- - `launchVariantId` 与 `runtimeCourseVariantId` 明确区分
-- 需要确认什么:
- - 无
-- 是否已解决:是
-
-### F2T-D008
-
-- 时间:2026-04-03 22:05:00
-- 谁提的:frontend
-- 当前事实:
- - 已按总控当前口径更新 [活动卡片列表最小产品方案](D:/dev/cmr-mini/doc/gameplay/活动卡片列表最小产品方案.md)
- - 当前文档只收 3 类准备项:
- - 最小字段表
- - 缺字段降级策略
- - 最小页面结构建议
- - 当前未启动活动卡片列表页正式开发
- - 当前未扩新页面链,也未改首页现有活动入口实现
-- 需要确认什么:
- - 无
-- 是否已解决:是
-
-### F2T-D009
-
-- 时间:2026-04-03 22:12:00
-- 谁提的:frontend
-- 当前事实:
- - 已按 backend 对 `currentPresentation / currentContentBundle` 的语义要求完成前端小范围修正
- - 活动页与准备页当前统一使用:
- - `当前发布展示版本`
- - `当前发布内容包版本`
- - 当两项为空时,前端当前统一解释为:
- - 当前发布 release 未绑定
- - 或当前尚未发布
- - 活动页与准备页的继续进入动作,当前统一优先受 `play.canLaunch` 控制
-- 需要确认什么:
- - 无
-- 是否已解决:是
-
-### F2T-D010
-
-- 时间:2026-04-03 23:42:00
-- 谁提的:frontend
-- 当前事实:
- - 已按总控当前 `v1.9` 口径启动“活动卡片列表最小产品化第一刀”
- - 当前已落地:
- - 独立活动列表页:`/pages/events/events`
- - 最小筛选:`全部 / 体验`
- - 最小卡片展示:
- - `title`
- - `subtitle`
- - `summary`
- - `status`
- - `timeWindow`
- - `ctaText`
- - `isDefaultExperience`
- - `eventType`
- - `currentPresentation`
- - `currentContentBundle`
- - 从列表跳活动详情页
- - 当前第一刀仍保持边界:
- - 不重做首页现有入口区
- - 仅在首页补一个“活动列表”独立入口
- - 不扩更多玩家侧新链
+ - 后台/新线程协同文档已补齐
+ - 当前新增:
+ - [准备页地图预览方案](D:/dev/cmr-mini/doc/gameplay/准备页地图预览方案.md)
+ - [地图列表与默认体验活动方案](D:/dev/cmr-mini/doc/gameplay/地图列表与默认体验活动方案.md)
+ - [后台游戏定制支持方案](D:/dev/cmr-mini/doc/backend/后台游戏定制支持方案.md)
+ - [后端总体架构与当前执行清单](D:/dev/cmr-mini/doc/backend/后端总体架构与当前执行清单.md)
+ - [最大配置模板后台落地裁剪表](D:/dev/cmr-mini/doc/config/最大配置模板后台落地裁剪表.md)
+ - [f2f.md](D:/dev/cmr-mini/f2f.md)
- 需要确认什么:
- 无
- 是否已解决:是
@@ -273,8 +177,13 @@
## 下一步
-- 当前进入活动卡片列表最小产品化第一刀联调回归与小范围修复阶段
-- 当前重点验证:
+- 当前继续做:
+ - 活动列表第一刀回归与小修
+ - 活动详情页/准备页用户化打磨
+ - 准备页地图预览 V1 稳定性验证
+ - 游客模式整链真回归
+- 当前重点观察:
- 列表字段是否足够
- - `全部 / 体验` 分组是否符合预期
- - 卡片点击进入活动详情页是否稳定
+ - 列表到详情页跳转是否稳定
+ - 准备页地图预览底图、点位叠加和空态提示是否稳定
+ - 游客模式公共 `play / launch / result` 是否完全闭环
diff --git a/miniprogram/app.json b/miniprogram/app.json
index 3fc29dc..edaf422 100644
--- a/miniprogram/app.json
+++ b/miniprogram/app.json
@@ -4,6 +4,8 @@
"pages/login/login",
"pages/home/home",
"pages/events/events",
+ "pages/experience-maps/experience-maps",
+ "pages/experience-map/experience-map",
"pages/event/event",
"pages/event-prepare/event-prepare",
"pages/result/result",
diff --git a/miniprogram/pages/event-prepare/event-prepare.ts b/miniprogram/pages/event-prepare/event-prepare.ts
index 8bd40e3..0f64187 100644
--- a/miniprogram/pages/event-prepare/event-prepare.ts
+++ b/miniprogram/pages/event-prepare/event-prepare.ts
@@ -1,9 +1,11 @@
import { loadBackendAuthTokens, loadBackendBaseUrl } from '../../utils/backendAuth'
-import { getEventPlay, launchEvent, type BackendCourseVariantSummary, type BackendEventPlayResult } from '../../utils/backendApi'
+import { getEventPlay, getPublicEventPlay, launchEvent, launchPublicEvent, type BackendCourseVariantSummary, type BackendEventPlayResult } from '../../utils/backendApi'
import { adaptBackendLaunchResultToEnvelope } from '../../utils/backendLaunchAdapter'
import { formatBackendPlayActionText, formatBackendPlayStatusText } from '../../utils/backendPlayCopy'
import { prepareMapPageUrlForLaunch } from '../../utils/gameLaunch'
import { reportBackendClientLog } from '../../utils/backendClientLogs'
+import { loadRemoteMapConfig, type RemoteMapConfig } from '../../utils/remoteMapConfig'
+import { buildPreparePreviewScene, buildPreparePreviewSceneFromBackendPreview, buildPreparePreviewSceneFromVariantControls, type PreparePreviewControl, type PreparePreviewScene, type PreparePreviewTile } from '../../utils/prepareMapPreview'
import { HeartRateController } from '../../engine/sensor/heartRateController'
const PREFERRED_HEART_RATE_DEVICE_STORAGE_KEY = 'cmr.preferredHeartRateDevice'
@@ -14,6 +16,9 @@ type EventPreparePageData = {
eventId: string
loading: boolean
canLaunch: boolean
+ launchInFlight: boolean
+ launchProgressText: string
+ launchProgressPercent: number
titleText: string
summaryText: string
releaseText: string
@@ -28,6 +33,20 @@ type EventPreparePageData = {
runtimeMapText: string
runtimeVariantText: string
runtimeRouteCodeText: string
+ previewVisible: boolean
+ previewLoading: boolean
+ previewStatusText: string
+ previewHintText: string
+ previewVariantText: string
+ previewTiles: Array<{
+ url: string
+ styleText: string
+ }>
+ previewControls: Array<{
+ label: string
+ styleText: string
+ kindClass: string
+ }>
selectedVariantId: string
selectedVariantText: string
showVariantSelector: boolean
@@ -55,6 +74,104 @@ type EventPreparePageData = {
connected: boolean
}>
mockSourceStatusText: string
+ showMockSourceSummary: boolean
+}
+
+type EventPreparePageContext = WechatMiniprogram.Page.Instance
> & {
+ previewLoadSeq?: number
+ lastPlayResult?: BackendEventPlayResult | null
+ previewManifestUrl?: string | null
+ previewConfigCache?: RemoteMapConfig | null
+ previewSceneCache?: Record
+ launchAttemptSeq?: number
+ launchTimeoutTimer?: number
+}
+
+const PREVIEW_WIDTH = 640
+const PREVIEW_HEIGHT = 360
+const PREPARE_LAUNCH_TIMEOUT_MS = 12000
+
+function toPercent(value: number, total: number): string {
+ if (!total) {
+ return '0%'
+ }
+ return `${(value / total) * 100}%`
+}
+
+function buildPreviewTileView(scene: PreparePreviewScene, tile: PreparePreviewTile) {
+ const left = toPercent(tile.leftPx, scene.width)
+ const top = toPercent(tile.topPx, scene.height)
+ const width = toPercent(tile.sizePx, scene.width)
+ const height = toPercent(tile.sizePx, scene.height)
+ return {
+ url: tile.url,
+ styleText: `left:${left};top:${top};width:${width};height:${height};`,
+ }
+}
+
+function buildPreviewControlView(scene: PreparePreviewScene, control: PreparePreviewControl) {
+ let kindClass = 'preview-control--normal'
+ if (control.kind === 'start') {
+ kindClass = 'preview-control--start'
+ } else if (control.kind === 'finish') {
+ kindClass = 'preview-control--finish'
+ }
+
+ return {
+ label: control.label,
+ kindClass,
+ styleText: `left:${toPercent(control.x, scene.width)};top:${toPercent(control.y, scene.height)};`,
+ }
+}
+
+function resolvePreviewManifestUrl(result: BackendEventPlayResult): string {
+ if (result.resolvedRelease && result.resolvedRelease.manifestUrl) {
+ return result.resolvedRelease.manifestUrl
+ }
+ if (result.release && result.release.manifestUrl) {
+ return result.release.manifestUrl
+ }
+ return ''
+}
+
+function canUseBackendPreview(result: BackendEventPlayResult): boolean {
+ return !!(
+ result.preview
+ && result.preview.baseTiles
+ && result.preview.baseTiles.tileBaseUrl
+ && result.preview.viewport
+ && typeof result.preview.viewport.minLon === 'number'
+ && typeof result.preview.viewport.minLat === 'number'
+ && typeof result.preview.viewport.maxLon === 'number'
+ && typeof result.preview.viewport.maxLat === 'number'
+ )
+}
+
+function resolveSelectedPreviewVariant(result: BackendEventPlayResult, selectedVariantId: string) {
+ if (!result.preview || !result.preview.variants || !result.preview.variants.length) {
+ return null
+ }
+
+ const normalizedVariantId = selectedVariantId || (result.preview.selectedVariantId || '')
+ const exact = result.preview.variants.find((item) => {
+ const candidateId = item.variantId || item.id || ''
+ return candidateId === normalizedVariantId
+ })
+ if (exact) {
+ return exact
+ }
+ return result.preview.variants[0]
+}
+
+function resolvePreviewHintText(result: BackendEventPlayResult, scene: PreparePreviewScene): string {
+ if (detectMultiVariantContext(result)) {
+ return scene.overlayAvailable
+ ? '当前先展示低级别底图与已知赛道形态;多赛道最终以进入地图后的绑定结果为准。'
+ : '当前活动支持多赛道;当前先展示底图与所选赛道信息,赛道点位预览待后端补齐每条赛道的预览数据后联动。'
+ }
+ return scene.overlayAvailable
+ ? '当前先展示低级别底图与当前已知赛道,进入地图后按正式地图继续。'
+ : '当前先展示地图范围预览,进入地图后再查看正式赛道。'
}
function detectMultiVariantContext(result: BackendEventPlayResult): boolean {
@@ -212,6 +329,23 @@ function shouldShowVariantSelector(
let prepareHeartRateController: HeartRateController | null = null
+function clearPrepareLaunchTimeout(page: EventPreparePageContext) {
+ if (page.launchTimeoutTimer) {
+ clearTimeout(page.launchTimeoutTimer)
+ page.launchTimeoutTimer = 0
+ }
+}
+
+function resetPrepareLaunchVisualState(page: EventPreparePageContext) {
+ clearPrepareLaunchTimeout(page)
+ page.launchAttemptSeq = 0
+ page.setData({
+ launchInFlight: false,
+ launchProgressText: '待进入地图',
+ launchProgressPercent: 0,
+ })
+}
+
function getAccessToken(): string | null {
const app = getApp()
const tokens = app.globalData && app.globalData.backendAuthTokens
@@ -260,6 +394,9 @@ Page({
eventId: '',
loading: false,
canLaunch: false,
+ launchInFlight: false,
+ launchProgressText: '待进入地图',
+ launchProgressPercent: 0,
titleText: '开始前准备',
summaryText: '未加载',
releaseText: '--',
@@ -270,10 +407,17 @@ Page({
variantSummaryText: '--',
presentationText: '--',
contentBundleText: '--',
- runtimePlaceText: '待 launch 确认',
- runtimeMapText: '待 launch 确认',
- runtimeVariantText: '待 launch 确认',
- runtimeRouteCodeText: '待 launch 确认',
+ runtimePlaceText: '进入地图后确认',
+ runtimeMapText: '进入地图后确认',
+ runtimeVariantText: '进入地图后确认',
+ runtimeRouteCodeText: '进入地图后确认',
+ previewVisible: false,
+ previewLoading: false,
+ previewStatusText: '准备加载地图预览',
+ previewHintText: '进入地图前先看地图范围与当前已知赛道。',
+ previewVariantText: '预览将跟随当前赛道选择联动',
+ previewTiles: [],
+ previewControls: [],
selectedVariantId: '',
selectedVariantText: '当前无需手动指定赛道',
showVariantSelector: false,
@@ -289,6 +433,7 @@ Page({
locationBackgroundPermissionGranted: false,
heartRateDiscoveredDevices: [],
mockSourceStatusText: '模拟源调试仍在地图页调试面板中使用',
+ showMockSourceSummary: false,
} as EventPreparePageData,
onLoad(query: { eventId?: string }) {
@@ -306,10 +451,12 @@ Page({
},
onShow() {
+ resetPrepareLaunchVisualState(this as unknown as EventPreparePageContext)
this.refreshPreparationDeviceState()
},
onUnload() {
+ resetPrepareLaunchVisualState(this as unknown as EventPreparePageContext)
if (prepareHeartRateController) {
prepareHeartRateController.destroy()
prepareHeartRateController = null
@@ -319,10 +466,6 @@ Page({
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,
@@ -330,11 +473,17 @@ Page({
})
try {
- const result = await getEventPlay({
- baseUrl: loadBackendBaseUrl(),
- eventId: targetEventId,
- accessToken,
- })
+ const baseUrl = loadBackendBaseUrl()
+ const result = accessToken
+ ? await getEventPlay({
+ baseUrl,
+ eventId: targetEventId,
+ accessToken,
+ })
+ : await getPublicEventPlay({
+ baseUrl,
+ eventId: targetEventId,
+ })
this.applyEventPlay(result)
} catch (error) {
const message = error && (error as { message?: string }).message ? (error as { message: string }).message : '未知错误'
@@ -346,6 +495,14 @@ Page({
},
applyEventPlay(result: BackendEventPlayResult) {
+ ;(this as unknown as EventPreparePageContext).lastPlayResult = result
+ const page = this as unknown as EventPreparePageContext
+ const nextManifestUrl = resolvePreviewManifestUrl(result)
+ if (page.previewManifestUrl !== nextManifestUrl) {
+ page.previewManifestUrl = nextManifestUrl
+ page.previewConfigCache = null
+ page.previewSceneCache = {}
+ }
const multiVariantContext = detectMultiVariantContext(result)
const selectedVariantId = resolveSelectedVariantId(
this.data.selectedVariantId,
@@ -379,6 +536,7 @@ Page({
? result.resolvedRelease.manifestUrl
: '',
details: {
+ guestMode: !getAccessToken(),
pageEventId: this.data.eventId || '',
resultEventId: result.event.id || '',
selectedVariantId: logVariantId,
@@ -411,18 +569,27 @@ Page({
variantSummaryText: formatVariantSummary(result),
presentationText: formatPresentationSummary(result),
contentBundleText: formatContentBundleSummary(result),
- runtimePlaceText: '待 launch.runtime 确认',
- runtimeMapText: '待 launch.runtime 确认',
+ runtimePlaceText: '进入地图后确认',
+ runtimeMapText: '进入地图后确认',
runtimeVariantText: selectedVariant
? selectedVariant.name
: (result.play.courseVariants && result.play.courseVariants[0]
? result.play.courseVariants[0].name
- : '待 launch 确认'),
+ : '进入地图后确认'),
runtimeRouteCodeText: selectedVariant
? selectedVariant.routeCodeText
: (result.play.courseVariants && result.play.courseVariants[0] && result.play.courseVariants[0].routeCode
- ? result.play.courseVariants[0].routeCode || '待 launch 确认'
- : '待 launch 确认'),
+ ? result.play.courseVariants[0].routeCode || '进入地图后确认'
+ : '进入地图后确认'),
+ previewVisible: true,
+ previewLoading: true,
+ previewStatusText: '正在生成地图预览',
+ previewHintText: '进入地图前先看地图范围与当前已知赛道。',
+ previewVariantText: selectedVariant
+ ? `当前预览赛道:${selectedVariant.name} / ${selectedVariant.routeCodeText}`
+ : (multiVariantContext ? '当前预览赛道:待选择' : '当前预览赛道:默认赛道'),
+ previewTiles: [],
+ previewControls: [],
selectedVariantId,
selectedVariantText: selectedVariant
? `${selectedVariant.name} / ${selectedVariant.routeCodeText}`
@@ -431,6 +598,153 @@ Page({
variantSelectorEmptyText,
selectableVariants,
})
+ this.loadPrepareMapPreview(result)
+ },
+
+ async loadPrepareMapPreview(result: BackendEventPlayResult) {
+ const page = this as unknown as EventPreparePageContext
+ const seq = (page.previewLoadSeq || 0) + 1
+ page.previewLoadSeq = seq
+ const selectedVariantId = this.data.selectedVariantId || (result.preview && result.preview.selectedVariantId ? result.preview.selectedVariantId : '')
+ const manifestUrl = resolvePreviewManifestUrl(result)
+ let fallbackConfig: RemoteMapConfig | null = page.previewConfigCache || null
+ const multiVariantContext = detectMultiVariantContext(result)
+
+ if (multiVariantContext && canUseBackendPreview(result) && result.preview) {
+ const sceneCacheKey = selectedVariantId || '__default__'
+ const cachedScene = page.previewSceneCache && page.previewSceneCache[sceneCacheKey]
+ if (cachedScene) {
+ const previewTiles = cachedScene.tiles.map((item) => buildPreviewTileView(cachedScene, item))
+ const previewControls = cachedScene.controls.map((item) => buildPreviewControlView(cachedScene, item))
+ this.setData({
+ previewVisible: true,
+ previewLoading: false,
+ previewStatusText: cachedScene.overlayAvailable ? '已加载地图与赛道预览' : '已加载地图范围预览',
+ previewHintText: cachedScene.overlayAvailable
+ ? '当前预览已按所选赛道联动显示点位,最终绑定以后端 launch 返回结果为准。'
+ : '当前预览已切换到所选赛道的底图范围;该赛道暂未返回点位预览数据。',
+ previewVariantText: selectedVariantId
+ ? `当前预览赛道:${this.data.selectedVariantText}`
+ : '当前预览赛道:默认赛道',
+ previewTiles,
+ previewControls,
+ runtimePlaceText: result.event.displayName || this.data.runtimePlaceText,
+ })
+ return
+ }
+
+ if (manifestUrl) {
+ if (!fallbackConfig) {
+ try {
+ fallbackConfig = await loadRemoteMapConfig(manifestUrl)
+ page.previewConfigCache = fallbackConfig
+ } catch (_error) {
+ fallbackConfig = null
+ }
+ }
+ }
+
+ const selectedPreviewVariant = resolveSelectedPreviewVariant(result, selectedVariantId)
+ const scene = fallbackConfig && selectedPreviewVariant && selectedPreviewVariant.controls
+ ? buildPreparePreviewSceneFromVariantControls(
+ fallbackConfig,
+ PREVIEW_WIDTH,
+ PREVIEW_HEIGHT,
+ selectedPreviewVariant.controls,
+ )
+ : buildPreparePreviewSceneFromBackendPreview(
+ result.preview,
+ PREVIEW_WIDTH,
+ PREVIEW_HEIGHT,
+ selectedVariantId,
+ fallbackConfig ? fallbackConfig.tileSource : null,
+ )
+ if (page.previewLoadSeq !== seq) {
+ return
+ }
+
+ if (scene) {
+ if (!page.previewSceneCache) {
+ page.previewSceneCache = {}
+ }
+ page.previewSceneCache[sceneCacheKey] = scene
+ const previewTiles = scene.tiles.map((item) => buildPreviewTileView(scene, item))
+ const previewControls = scene.controls.map((item) => buildPreviewControlView(scene, item))
+ this.setData({
+ previewVisible: true,
+ previewLoading: false,
+ previewStatusText: scene.overlayAvailable ? '已加载地图与赛道预览' : '已加载地图范围预览',
+ previewHintText: scene.overlayAvailable
+ ? '当前预览已按所选赛道联动显示点位,最终绑定以后端 launch 返回结果为准。'
+ : '当前预览已切换到所选赛道的底图范围;该赛道暂未返回点位预览数据。',
+ previewVariantText: selectedVariantId
+ ? `当前预览赛道:${this.data.selectedVariantText}`
+ : '当前预览赛道:默认赛道',
+ previewTiles,
+ previewControls,
+ runtimePlaceText: result.event.displayName || this.data.runtimePlaceText,
+ })
+ return
+ }
+
+ }
+
+ if (!manifestUrl) {
+ this.setData({
+ previewVisible: true,
+ previewLoading: false,
+ previewStatusText: '当前发布未返回预览底图来源',
+ previewHintText: '当前活动暂无可用地图预览,请稍后刷新或联系后台。',
+ previewVariantText: '当前预览赛道:待进入地图后确认',
+ previewTiles: [],
+ previewControls: [],
+ })
+ return
+ }
+
+ try {
+ const config = fallbackConfig || await loadRemoteMapConfig(manifestUrl)
+ page.previewConfigCache = config
+ if (page.previewLoadSeq !== seq) {
+ return
+ }
+
+ const overlayEnabled = !multiVariantContext
+ const scene = buildPreparePreviewScene(config, PREVIEW_WIDTH, PREVIEW_HEIGHT, overlayEnabled)
+ const previewTiles = scene.tiles.map((item) => buildPreviewTileView(scene, item))
+ const previewControls = scene.controls.map((item) => buildPreviewControlView(scene, item))
+ const runtimeMapText = config.configTitle || '进入地图后确认'
+ const runtimePlaceText = result.event.displayName || '进入地图后确认'
+ this.setData({
+ previewVisible: true,
+ previewLoading: false,
+ previewStatusText: scene.overlayAvailable ? '已加载地图与赛道预览' : '已加载地图范围预览',
+ previewHintText: resolvePreviewHintText(result, scene),
+ previewVariantText: this.data.selectedVariantId
+ ? `当前预览赛道:${this.data.selectedVariantText}`
+ : (result.play.courseVariants && result.play.courseVariants[0]
+ ? `当前预览赛道:${result.play.courseVariants[0].name} / ${result.play.courseVariants[0].routeCode || '默认编码'}`
+ : '当前预览赛道:默认赛道'),
+ previewTiles,
+ previewControls,
+ runtimePlaceText,
+ runtimeMapText,
+ })
+ } catch (error) {
+ if (page.previewLoadSeq !== seq) {
+ return
+ }
+ const message = error && (error as { message?: string }).message ? (error as { message: string }).message : '未知错误'
+ this.setData({
+ previewVisible: true,
+ previewLoading: false,
+ previewStatusText: `地图预览加载失败:${message}`,
+ previewHintText: '当前先展示文字摘要;预览底图可在刷新后重试。',
+ previewVariantText: '当前预览赛道:待进入地图后确认',
+ previewTiles: [],
+ previewControls: [],
+ })
+ }
},
refreshPreparationDeviceState() {
@@ -576,10 +890,12 @@ Page({
refreshMockSourcePreparationStatus() {
const channelId = loadStoredMockChannelId()
const autoConnect = loadMockAutoConnectEnabled()
+ const showMockSourceSummary = autoConnect || channelId !== 'default'
this.setData({
mockSourceStatusText: autoConnect
- ? `自动连接已开启 / 通道 ${channelId}`
- : `自动连接未开启 / 通道 ${channelId}`,
+ ? `调试源自动连接已开启 / 通道 ${channelId}`
+ : `当前使用调试通道 ${channelId}`,
+ showMockSourceSummary,
})
},
@@ -660,19 +976,36 @@ Page({
selectedVariantText: selectedVariant
? `${selectedVariant.name} / ${selectedVariant.routeCodeText}`
: '当前无需手动指定赛道',
- runtimeVariantText: selectedVariant ? selectedVariant.name : '待 launch 确认',
- runtimeRouteCodeText: selectedVariant ? selectedVariant.routeCodeText : '待 launch 确认',
+ runtimeVariantText: selectedVariant ? selectedVariant.name : '进入地图后确认',
+ runtimeRouteCodeText: selectedVariant ? selectedVariant.routeCodeText : '进入地图后确认',
+ previewHintText: selectedVariant
+ ? (this.data.showVariantSelector
+ ? `当前已选 ${selectedVariant.name} / ${selectedVariant.routeCodeText};预览底图会保留不变,最终赛道以 launch 绑定结果为准。`
+ : `当前已选 ${selectedVariant.name} / ${selectedVariant.routeCodeText};最终地图以 launch 绑定结果为准。`)
+ : this.data.previewHintText,
+ previewStatusText: this.data.showVariantSelector ? '已加载地图范围预览' : this.data.previewStatusText,
+ previewVariantText: selectedVariant
+ ? `当前预览赛道:${selectedVariant.name} / ${selectedVariant.routeCodeText}`
+ : '当前预览赛道:待选择',
selectableVariants,
})
+
+ const page = this as unknown as EventPreparePageContext
+ if (page.lastPlayResult) {
+ this.loadPrepareMapPreview(page.lastPlayResult)
+ }
},
async handleLaunch() {
+ const page = this as unknown as EventPreparePageContext
const accessToken = getAccessToken()
- if (!accessToken) {
- wx.redirectTo({ url: '/pages/login/login' })
+ if (this.data.launchInFlight) {
+ wx.showToast({
+ title: '正在进入地图,请稍候',
+ icon: 'none',
+ })
return
}
-
if (!this.data.canLaunch) {
this.setData({
statusText: '当前发布状态不可进入地图',
@@ -696,8 +1029,29 @@ Page({
}
this.setData({
+ launchInFlight: true,
+ launchProgressText: '正在校验并创建本局',
+ launchProgressPercent: 24,
statusText: '正在创建 session 并进入地图',
})
+ const launchSeq = (page.launchAttemptSeq || 0) + 1
+ page.launchAttemptSeq = launchSeq
+ clearPrepareLaunchTimeout(page)
+ page.launchTimeoutTimer = setTimeout(() => {
+ if (page.launchAttemptSeq !== launchSeq) {
+ return
+ }
+ this.setData({
+ launchInFlight: false,
+ launchProgressText: '进入地图超时',
+ launchProgressPercent: 0,
+ statusText: '进入地图超时,请稍后重试',
+ })
+ wx.showToast({
+ title: '进入地图超时,请重试',
+ icon: 'none',
+ })
+ }, PREPARE_LAUNCH_TIMEOUT_MS) as unknown as number
try {
const assignmentMode = this.data.assignmentMode ? this.data.assignmentMode : null
@@ -716,6 +1070,10 @@ Page({
phase: 'launch-requested',
},
})
+ this.setData({
+ launchProgressText: '已发起启动请求,正在等待服务器响应',
+ launchProgressPercent: 52,
+ })
const app = getApp()
if (app.globalData) {
const pendingDeviceName = prepareHeartRateController && prepareHeartRateController.currentDeviceName
@@ -730,13 +1088,29 @@ Page({
prepareHeartRateController.destroy()
prepareHeartRateController = null
}
- const result = await launchEvent({
- baseUrl: loadBackendBaseUrl(),
- eventId: this.data.eventId,
- accessToken,
+ const result = accessToken
+ ? await launchEvent({
+ baseUrl: loadBackendBaseUrl(),
+ eventId: this.data.eventId,
+ accessToken,
variantId: this.data.showVariantSelector ? this.data.selectedVariantId : undefined,
- clientType: 'wechat',
- deviceKey: 'mini-dev-device-001',
+ clientType: 'wechat',
+ deviceKey: 'mini-dev-device-001',
+ })
+ : await launchPublicEvent({
+ baseUrl: loadBackendBaseUrl(),
+ eventId: this.data.eventId,
+ variantId: this.data.showVariantSelector ? this.data.selectedVariantId : undefined,
+ clientType: 'wechat',
+ deviceKey: 'mini-dev-device-001',
+ })
+ if (page.launchAttemptSeq !== launchSeq) {
+ return
+ }
+ clearPrepareLaunchTimeout(page)
+ this.setData({
+ launchProgressText: '启动成功,正在载入地图',
+ launchProgressPercent: 86,
})
reportBackendClientLog({
level: 'info',
@@ -749,6 +1123,7 @@ Page({
? result.launch.resolvedRelease.manifestUrl
: '',
details: {
+ guestMode: !accessToken,
pageEventId: this.data.eventId || '',
launchEventId: result.launch.business && result.launch.business.eventId ? result.launch.business.eventId : '',
launchSessionId: result.launch.business && result.launch.business.sessionId ? result.launch.business.sessionId : '',
@@ -769,8 +1144,15 @@ Page({
url: prepareMapPageUrlForLaunch(envelope),
})
} catch (error) {
+ if (page.launchAttemptSeq !== launchSeq) {
+ return
+ }
+ clearPrepareLaunchTimeout(page)
const message = error && (error as { message?: string }).message ? (error as { message: string }).message : '未知错误'
this.setData({
+ launchInFlight: false,
+ launchProgressText: '进入地图失败',
+ launchProgressPercent: 0,
statusText: `launch 失败:${message}`,
})
}
diff --git a/miniprogram/pages/event-prepare/event-prepare.wxml b/miniprogram/pages/event-prepare/event-prepare.wxml
index 39fbf2b..9e71149 100644
--- a/miniprogram/pages/event-prepare/event-prepare.wxml
+++ b/miniprogram/pages/event-prepare/event-prepare.wxml
@@ -7,18 +7,28 @@
- 活动与发布
- Release:{{releaseText}}
- 主动作:{{actionText}}
- 状态:{{statusText}}
+ 当前准备状态
+ 先确认赛道、设备和权限,再进入地图开始本局。
+
+ 当前发布
+ {{releaseText}}
+
+
+ 当前动作
+ {{actionText}}
+
+
+ 进入状态
+ {{statusText}}
+
赛道模式:{{variantModeText}}
赛道摘要:{{variantSummaryText}}
当前选择:{{selectedVariantText}}
- 活动运营摘要
- 当前阶段先展示当前发布 release 绑定的活动运营对象摘要,不展开复杂 schema。
+ 活动版本摘要
+ 这里展示本次进入地图将会使用的发布对象摘要,方便你确认当前活动版本。
当前发布展示版本
{{presentationText}}
@@ -30,8 +40,8 @@
- 运行对象摘要
- 当前阶段以前端已知信息预览,最终绑定以后端 `launch.runtime` 为准。
+ 本局对象预览
+ 进入地图前先用已知信息做预览,最终绑定以后端 launch 返回结果为准。
地点
{{runtimePlaceText}}
@@ -48,11 +58,39 @@
RouteCode
{{runtimeRouteCodeText}}
+
+
+ {{previewVariantText}}
+
+
+
+
+
+ 预览加载中...
+
+
+ {{previewHintText}}
+
赛道选择
- 当前活动要求手动指定赛道。这里的选择会随 launch 一起带给后端,最终绑定以后端返回为准。
+ 如果当前活动支持手动选赛道,请先在这里确认你的本局路线。
{{variantSelectorEmptyText}}
@@ -70,7 +108,7 @@
设备准备
- 这一页现在负责局前设备准备。定位权限先在这里确认,心率带支持先连后进图,地图内仍保留局中快速重连入口。
+ 定位权限建议先在这里确认;如果需要心率带,也建议先连接后再进入地图。
定位状态
{{locationStatusText}}
@@ -92,7 +130,7 @@
扫描状态
{{heartRateScanText}}
-
+
模拟源
{{mockSourceStatusText}}
@@ -105,12 +143,21 @@
- 开始比赛
- 这一页先承担局前准备壳子,后面会继续接定位权限、心率带局前连接和设备检查。
+ 进入地图
+ 进入地图后无需再次点开始;按玩法规则前往开始点,即可正式开始比赛。
+
+
+ 当前进度
+ {{launchProgressText}}
+
+
+
+
+
-
-
-
+
+
+
diff --git a/miniprogram/pages/event-prepare/event-prepare.wxss b/miniprogram/pages/event-prepare/event-prepare.wxss
index bad2149..c306649 100644
--- a/miniprogram/pages/event-prepare/event-prepare.wxss
+++ b/miniprogram/pages/event-prepare/event-prepare.wxss
@@ -88,6 +88,50 @@ page {
flex-wrap: wrap;
}
+.launch-progress {
+ display: grid;
+ gap: 12rpx;
+ padding: 18rpx 20rpx;
+ border-radius: 18rpx;
+ background: #f4f8fc;
+}
+
+.launch-progress__row {
+ display: flex;
+ align-items: center;
+ justify-content: space-between;
+ gap: 16rpx;
+}
+
+.launch-progress__label,
+.launch-progress__value {
+ font-size: 22rpx;
+ line-height: 1.6;
+ color: #35567d;
+}
+
+.launch-progress__value {
+ font-weight: 700;
+ text-align: right;
+}
+
+.launch-progress__track {
+ position: relative;
+ height: 12rpx;
+ overflow: hidden;
+ border-radius: 999rpx;
+ background: #d7e4f1;
+}
+
+.launch-progress__fill {
+ position: absolute;
+ left: 0;
+ top: 0;
+ bottom: 0;
+ border-radius: 999rpx;
+ background: linear-gradient(90deg, #1e5ca1 0%, #2d78cf 100%);
+}
+
.device-list {
display: grid;
gap: 14rpx;
@@ -98,6 +142,106 @@ page {
gap: 14rpx;
}
+.preview-card {
+ display: grid;
+ gap: 14rpx;
+ margin-top: 8rpx;
+}
+
+.preview-card__header {
+ display: flex;
+ align-items: center;
+ justify-content: space-between;
+ gap: 16rpx;
+}
+
+.preview-card__title {
+ font-size: 26rpx;
+ font-weight: 700;
+ color: #17345a;
+}
+
+.preview-card__status {
+ font-size: 22rpx;
+ color: #5c7288;
+ text-align: right;
+}
+
+.preview-card__variant {
+ justify-self: start;
+ padding: 8rpx 16rpx;
+ border-radius: 999rpx;
+ background: #eef4fb;
+ color: #24486f;
+ font-size: 22rpx;
+ line-height: 1.5;
+}
+
+.preview-frame {
+ position: relative;
+ width: 100%;
+ padding-top: 56.25%;
+ overflow: hidden;
+ border-radius: 22rpx;
+ background: #d9e4ef;
+ box-shadow: inset 0 0 0 2rpx rgba(255, 255, 255, 0.35);
+}
+
+.preview-stage {
+ position: absolute;
+ inset: 0;
+ overflow: hidden;
+ background: #d7e1ec;
+}
+
+.preview-tile {
+ position: absolute;
+}
+
+.preview-wash {
+ position: absolute;
+ inset: 0;
+ background: rgba(255, 255, 255, 0.34);
+ pointer-events: none;
+}
+
+.preview-control {
+ position: absolute;
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ width: 28rpx;
+ height: 28rpx;
+ margin-left: -14rpx;
+ margin-top: -14rpx;
+ border-radius: 999rpx;
+ border: 4rpx solid #e05f36;
+ background: rgba(255, 255, 255, 0.92);
+ box-shadow: 0 6rpx 14rpx rgba(23, 52, 90, 0.12);
+}
+
+.preview-control--start {
+ border-color: #1f6a45;
+ background: rgba(225, 245, 235, 0.96);
+}
+
+.preview-control--finish {
+ border-color: #8f1f4c;
+ background: rgba(255, 230, 239, 0.96);
+}
+
+
+.preview-loading {
+ position: absolute;
+ inset: 0;
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ background: rgba(244, 248, 252, 0.76);
+ font-size: 24rpx;
+ color: #35567d;
+}
+
.variant-card {
display: grid;
gap: 8rpx;
diff --git a/miniprogram/pages/event/event.ts b/miniprogram/pages/event/event.ts
index 9994496..1549f6e 100644
--- a/miniprogram/pages/event/event.ts
+++ b/miniprogram/pages/event/event.ts
@@ -1,16 +1,18 @@
import { loadBackendAuthTokens, loadBackendBaseUrl } from '../../utils/backendAuth'
-import { getEventPlay, type BackendEventPlayResult } from '../../utils/backendApi'
+import { getEventPlay, getPublicEventPlay, type BackendEventPlayResult } from '../../utils/backendApi'
import { formatBackendPlayActionText, formatBackendPlayStatusText } from '../../utils/backendPlayCopy'
import { reportBackendClientLog } from '../../utils/backendClientLogs'
type EventPageData = {
eventId: string
loading: boolean
+ canLaunch: boolean
titleText: string
summaryText: string
releaseText: string
actionText: string
statusText: string
+ primaryButtonText: string
variantModeText: string
variantSummaryText: string
presentationText: string
@@ -78,11 +80,13 @@ Page({
data: {
eventId: '',
loading: false,
+ canLaunch: false,
titleText: '活动详情',
summaryText: '未加载',
releaseText: '--',
actionText: '--',
statusText: '待加载',
+ primaryButtonText: '前往准备页',
variantModeText: '--',
variantSummaryText: '--',
presentationText: '--',
@@ -104,10 +108,6 @@ Page({
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,
@@ -115,11 +115,17 @@ Page({
})
try {
- const result = await getEventPlay({
- baseUrl: loadBackendBaseUrl(),
- eventId: targetEventId,
- accessToken,
- })
+ const baseUrl = loadBackendBaseUrl()
+ const result = accessToken
+ ? await getEventPlay({
+ baseUrl,
+ eventId: targetEventId,
+ accessToken,
+ })
+ : await getPublicEventPlay({
+ baseUrl,
+ eventId: targetEventId,
+ })
this.applyEventPlay(result)
} catch (error) {
const message = error && (error as { message?: string }).message ? (error as { message: string }).message : '未知错误'
@@ -144,6 +150,7 @@ Page({
? result.resolvedRelease.manifestUrl
: '',
details: {
+ guestMode: !getAccessToken(),
pageEventId: this.data.eventId || '',
resultEventId: result.event.id || '',
primaryAction: result.play.primaryAction || '',
@@ -161,6 +168,7 @@ Page({
})
this.setData({
loading: false,
+ canLaunch: result.play.canLaunch,
titleText: result.event.displayName,
summaryText: result.event.summary || '暂无活动简介',
releaseText: result.resolvedRelease
@@ -168,6 +176,7 @@ Page({
: '当前无可用 release',
actionText: formatBackendPlayActionText(result.play.primaryAction, result.play.reason),
statusText: formatBackendPlayStatusText(result.play.canLaunch, result.play.primaryAction, result.play.reason),
+ primaryButtonText: result.play.canLaunch ? '前往准备页' : '查看准备状态',
variantModeText: formatAssignmentMode(result.play.assignmentMode),
variantSummaryText: formatVariantSummary(result),
presentationText: formatPresentationSummary(result),
diff --git a/miniprogram/pages/event/event.wxml b/miniprogram/pages/event/event.wxml
index 2c5c25b..79be818 100644
--- a/miniprogram/pages/event/event.wxml
+++ b/miniprogram/pages/event/event.wxml
@@ -7,18 +7,23 @@
- 开始前准备
- Release:{{releaseText}}
- 主动作:{{actionText}}
- 状态:{{statusText}}
+ 当前状态
+ {{statusText}}
+ {{actionText}}
+ 你可以先进入准备页查看赛道、设备和局前状态,再决定是否进入地图。
+
+
+
+
+
+
+
+ 赛道与版本
+ 当前发布版本:{{releaseText}}
赛道模式:{{variantModeText}}
赛道摘要:{{variantSummaryText}}
当前发布展示版本:{{presentationText}}
当前发布内容包版本:{{contentBundleText}}
-
-
-
-
diff --git a/miniprogram/pages/event/event.wxss b/miniprogram/pages/event/event.wxss
index 0da80e6..767700f 100644
--- a/miniprogram/pages/event/event.wxss
+++ b/miniprogram/pages/event/event.wxss
@@ -61,6 +61,27 @@ page {
color: #30465f;
}
+.status-chip {
+ display: inline-flex;
+ align-items: center;
+ justify-content: center;
+ min-height: 52rpx;
+ padding: 0 18rpx;
+ border-radius: 999rpx;
+ font-size: 24rpx;
+ font-weight: 700;
+}
+
+.status-chip--ready {
+ background: #ddf1e4;
+ color: #1f6a3a;
+}
+
+.status-chip--blocked {
+ background: #f8e7e3;
+ color: #8a3d28;
+}
+
.actions {
display: flex;
gap: 16rpx;
diff --git a/miniprogram/pages/experience-map/experience-map.ts b/miniprogram/pages/experience-map/experience-map.ts
new file mode 100644
index 0000000..2c7ce74
--- /dev/null
+++ b/miniprogram/pages/experience-map/experience-map.ts
@@ -0,0 +1,195 @@
+import { loadBackendAuthTokens, loadBackendBaseUrl } from '../../utils/backendAuth'
+import {
+ getExperienceMapDetail,
+ getPublicExperienceMapDetail,
+ type BackendContentBundleSummary,
+ type BackendDefaultExperienceSummary,
+ type BackendExperienceMapDetail,
+ type BackendPresentationSummary,
+} from '../../utils/backendApi'
+import { reportBackendClientLog } from '../../utils/backendClientLogs'
+
+type DefaultExperienceCardView = {
+ eventId: string
+ titleText: string
+ subtitleText: string
+ statusText: string
+ ctaText: string
+ eventTypeText: string
+ presentationText: string
+ contentBundleText: string
+ disabled: boolean
+}
+
+type ExperienceMapPageData = {
+ mapId: string
+ loading: boolean
+ statusText: string
+ placeText: string
+ mapText: string
+ summaryText: string
+ tileInfoText: string
+ cards: DefaultExperienceCardView[]
+}
+
+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 formatPresentationSummary(summary?: BackendPresentationSummary | null): string {
+ if (!summary) {
+ return '当前未声明展示版本'
+ }
+ return summary.version || summary.templateKey || summary.presentationId || '当前未声明展示版本'
+}
+
+function formatContentBundleSummary(summary?: BackendContentBundleSummary | null): string {
+ if (!summary) {
+ return '当前未声明内容包版本'
+ }
+ return summary.version || summary.bundleType || summary.bundleId || '当前未声明内容包版本'
+}
+
+function buildDefaultExperienceCard(item: BackendDefaultExperienceSummary): DefaultExperienceCardView {
+ const eventId = item.eventId || ''
+ return {
+ eventId,
+ titleText: item.title || '未命名体验活动',
+ subtitleText: item.subtitle || '当前暂无副标题',
+ statusText: item.status || item.statusCode || '状态待确认',
+ ctaText: item.ctaText || '查看体验',
+ eventTypeText: item.eventType || '类型待确认',
+ presentationText: formatPresentationSummary(item.currentPresentation),
+ contentBundleText: formatContentBundleSummary(item.currentContentBundle),
+ disabled: !eventId,
+ }
+}
+
+Page({
+ data: {
+ mapId: '',
+ loading: false,
+ statusText: '准备加载地图详情',
+ placeText: '地点待确认',
+ mapText: '地图待确认',
+ summaryText: '当前暂无地图摘要',
+ tileInfoText: '瓦片信息待确认',
+ cards: [],
+ } as ExperienceMapPageData,
+
+ onLoad(query: { mapId?: string }) {
+ const mapId = query && query.mapId ? decodeURIComponent(query.mapId) : ''
+ if (!mapId) {
+ this.setData({
+ statusText: '缺少 mapId',
+ })
+ return
+ }
+ this.setData({ mapId })
+ this.loadMapDetail(mapId)
+ },
+
+ onShow() {
+ if (this.data.mapId) {
+ this.loadMapDetail(this.data.mapId)
+ }
+ },
+
+ async loadMapDetail(mapId?: string) {
+ const targetMapId = mapId || this.data.mapId
+ const accessToken = getAccessToken()
+
+ this.setData({
+ loading: true,
+ statusText: '正在加载地图详情',
+ })
+
+ try {
+ const baseUrl = loadBackendBaseUrl()
+ const result = accessToken
+ ? await getExperienceMapDetail({
+ baseUrl,
+ accessToken,
+ mapAssetId: targetMapId,
+ })
+ : await getPublicExperienceMapDetail({
+ baseUrl,
+ mapAssetId: targetMapId,
+ })
+ this.applyDetail(result)
+ } catch (error) {
+ const message = error && (error as { message?: string }).message ? (error as { message: string }).message : '未知错误'
+ this.setData({
+ loading: false,
+ statusText: `地图详情加载失败:${message}`,
+ cards: [],
+ })
+ }
+ },
+
+ applyDetail(result: BackendExperienceMapDetail) {
+ const cards = (result.defaultExperiences || []).map(buildDefaultExperienceCard)
+ reportBackendClientLog({
+ level: 'info',
+ category: 'experience-map-detail',
+ message: 'experience map detail loaded',
+ details: {
+ guestMode: !getAccessToken(),
+ mapId: result.mapId || this.data.mapId || '',
+ placeId: result.placeId || '',
+ defaultExperienceCount: cards.length,
+ defaultExperienceEventIds: (result.defaultExperiences || []).map((item) => item.eventId || ''),
+ },
+ })
+
+ const tileBase = result.tileBaseUrl || ''
+ const tileMeta = result.tileMetaUrl || ''
+ const tileInfoText = tileBase || tileMeta
+ ? `底图 ${tileBase || '--'} / Meta ${tileMeta || '--'}`
+ : '当前未声明瓦片信息'
+
+ this.setData({
+ loading: false,
+ statusText: cards.length ? '地图详情加载完成' : '当前地图暂无默认体验活动',
+ placeText: result.placeName || result.placeId || '地点待确认',
+ mapText: result.mapName || result.mapId || '地图待确认',
+ summaryText: result.summary || '当前暂无地图摘要',
+ tileInfoText,
+ cards,
+ })
+ },
+
+ handleRefresh() {
+ this.loadMapDetail()
+ },
+
+ handleOpenExperience(event: WechatMiniprogram.TouchEvent) {
+ const eventId = event.currentTarget.dataset.eventId as string | undefined
+ reportBackendClientLog({
+ level: 'info',
+ category: 'experience-map-detail',
+ message: 'default experience clicked',
+ eventId: eventId || '',
+ details: {
+ mapId: this.data.mapId || '',
+ clickedEventId: eventId || '',
+ },
+ })
+
+ if (!eventId) {
+ wx.showToast({
+ title: '该体验活动暂无入口',
+ icon: 'none',
+ })
+ return
+ }
+
+ wx.navigateTo({
+ url: `/pages/event/event?eventId=${encodeURIComponent(eventId)}`,
+ })
+ },
+})
diff --git a/miniprogram/pages/experience-map/experience-map.wxml b/miniprogram/pages/experience-map/experience-map.wxml
new file mode 100644
index 0000000..6a959d8
--- /dev/null
+++ b/miniprogram/pages/experience-map/experience-map.wxml
@@ -0,0 +1,42 @@
+
+
+
+ Map Detail
+ {{mapText}}
+ {{placeText}}
+
+
+
+ 地图信息
+ {{summaryText}}
+ {{tileInfoText}}
+ {{statusText}}
+
+
+
+
+
+
+ 默认体验活动
+ 当前暂无体验活动
+
+
+ 体验
+ {{item.eventTypeText}}
+
+ {{item.titleText}}
+ {{item.subtitleText}}
+
+ {{item.statusText}}
+
+
+ 展示:{{item.presentationText}}
+
+
+ 内容:{{item.contentBundleText}}
+
+ {{item.ctaText}}
+
+
+
+
diff --git a/miniprogram/pages/experience-map/experience-map.wxss b/miniprogram/pages/experience-map/experience-map.wxss
new file mode 100644
index 0000000..342c47d
--- /dev/null
+++ b/miniprogram/pages/experience-map/experience-map.wxss
@@ -0,0 +1,153 @@
+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;
+}
+
+.card {
+ display: grid;
+ gap: 12rpx;
+ padding: 22rpx;
+ border-radius: 22rpx;
+ background: #f6f9fc;
+}
+
+.card--disabled {
+ opacity: 0.7;
+}
+
+.card__top {
+ display: flex;
+ justify-content: space-between;
+ align-items: center;
+ gap: 16rpx;
+}
+
+.card__badge {
+ display: inline-flex;
+ align-items: center;
+ min-height: 40rpx;
+ padding: 0 14rpx;
+ border-radius: 999rpx;
+ background: #dce9fb;
+ color: #173d73;
+ font-size: 22rpx;
+ font-weight: 700;
+}
+
+.card__type {
+ font-size: 22rpx;
+ color: #64748b;
+}
+
+.card__title {
+ font-size: 30rpx;
+ font-weight: 700;
+ color: #17345a;
+}
+
+.card__subtitle,
+.card__meta,
+.card__cta {
+ font-size: 24rpx;
+ line-height: 1.6;
+}
+
+.card__subtitle {
+ color: #4f627a;
+}
+
+.card__meta-row {
+ display: flex;
+ flex-wrap: wrap;
+ gap: 16rpx;
+}
+
+.card__meta {
+ color: #64748b;
+}
+
+.card__cta {
+ color: #173d73;
+ font-weight: 700;
+}
diff --git a/miniprogram/pages/experience-maps/experience-maps.ts b/miniprogram/pages/experience-maps/experience-maps.ts
new file mode 100644
index 0000000..610817a
--- /dev/null
+++ b/miniprogram/pages/experience-maps/experience-maps.ts
@@ -0,0 +1,131 @@
+import { loadBackendAuthTokens, loadBackendBaseUrl } from '../../utils/backendAuth'
+import { getExperienceMaps, getPublicExperienceMaps, type BackendExperienceMapSummary } from '../../utils/backendApi'
+import { reportBackendClientLog } from '../../utils/backendClientLogs'
+
+type ExperienceMapCardView = {
+ mapId: string
+ placeText: string
+ mapText: string
+ summaryText: string
+ coverUrl: string
+ defaultExperienceText: string
+ disabled: boolean
+}
+
+type ExperienceMapsPageData = {
+ loading: boolean
+ statusText: string
+ cards: ExperienceMapCardView[]
+}
+
+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 buildCardView(item: BackendExperienceMapSummary): ExperienceMapCardView {
+ const mapId = item.mapId || ''
+ const defaultExperienceCount = typeof item.defaultExperienceCount === 'number' ? item.defaultExperienceCount : 0
+ return {
+ mapId,
+ placeText: item.placeName || item.placeId || '地点待确认',
+ mapText: item.mapName || item.mapId || '地图待确认',
+ summaryText: item.summary || '当前暂无地图摘要',
+ coverUrl: item.coverUrl || '',
+ defaultExperienceText: defaultExperienceCount > 0 ? `默认体验 ${defaultExperienceCount} 个` : '当前暂无默认体验活动',
+ disabled: !mapId,
+ }
+}
+
+Page({
+ data: {
+ loading: false,
+ statusText: '准备加载地图体验列表',
+ cards: [],
+ } as ExperienceMapsPageData,
+
+ onLoad() {
+ this.loadMaps()
+ },
+
+ onShow() {
+ this.loadMaps()
+ },
+
+ async loadMaps() {
+ const accessToken = getAccessToken()
+
+ this.setData({
+ loading: true,
+ statusText: '正在加载地图体验列表',
+ })
+
+ try {
+ const baseUrl = loadBackendBaseUrl()
+ const result = accessToken
+ ? await getExperienceMaps({
+ baseUrl,
+ accessToken,
+ })
+ : await getPublicExperienceMaps({
+ baseUrl,
+ })
+ reportBackendClientLog({
+ level: 'info',
+ category: 'experience-maps',
+ message: 'experience maps loaded',
+ details: {
+ guestMode: !accessToken,
+ mapCount: result.length,
+ mapIds: result.map((item) => item.mapId || ''),
+ mapsWithDefaultExperience: result.filter((item) => {
+ return typeof item.defaultExperienceCount === 'number' && item.defaultExperienceCount > 0
+ }).length,
+ },
+ })
+ const cards = result.map(buildCardView)
+ this.setData({
+ loading: false,
+ statusText: cards.length ? '地图体验列表加载完成' : '当前没有可体验地图',
+ cards,
+ })
+ } catch (error) {
+ const message = error && (error as { message?: string }).message ? (error as { message: string }).message : '未知错误'
+ this.setData({
+ loading: false,
+ statusText: `地图体验列表加载失败:${message}`,
+ cards: [],
+ })
+ }
+ },
+
+ handleRefresh() {
+ this.loadMaps()
+ },
+
+ handleOpenMap(event: WechatMiniprogram.TouchEvent) {
+ const mapId = event.currentTarget.dataset.mapId as string | undefined
+ reportBackendClientLog({
+ level: 'info',
+ category: 'experience-maps',
+ message: 'experience map clicked',
+ details: {
+ clickedMapId: mapId || '',
+ },
+ })
+ if (!mapId) {
+ wx.showToast({
+ title: '该地图暂无详情入口',
+ icon: 'none',
+ })
+ return
+ }
+
+ wx.navigateTo({
+ url: `/pages/experience-map/experience-map?mapId=${encodeURIComponent(mapId)}`,
+ })
+ },
+})
diff --git a/miniprogram/pages/experience-maps/experience-maps.wxml b/miniprogram/pages/experience-maps/experience-maps.wxml
new file mode 100644
index 0000000..400b4dd
--- /dev/null
+++ b/miniprogram/pages/experience-maps/experience-maps.wxml
@@ -0,0 +1,29 @@
+
+
+
+ Map Experience
+ 地图体验
+ 先选地点与地图,再进入默认体验活动。
+
+
+
+ 当前状态
+ {{statusText}}
+
+
+
+
+
+
+ 地图卡片
+ 当前没有可体验地图
+
+
+ {{item.mapText}}
+ {{item.placeText}}
+ {{item.summaryText}}
+ {{item.defaultExperienceText}}
+
+
+
+
diff --git a/miniprogram/pages/experience-maps/experience-maps.wxss b/miniprogram/pages/experience-maps/experience-maps.wxss
new file mode 100644
index 0000000..ec09a13
--- /dev/null
+++ b/miniprogram/pages/experience-maps/experience-maps.wxss
@@ -0,0 +1,129 @@
+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;
+}
+
+.card {
+ display: grid;
+ gap: 12rpx;
+ padding: 22rpx;
+ border-radius: 22rpx;
+ background: #f6f9fc;
+}
+
+.card--disabled {
+ opacity: 0.7;
+}
+
+.card__cover {
+ width: 100%;
+ height: 220rpx;
+ border-radius: 18rpx;
+ background: #d7e4f2;
+}
+
+.card__title {
+ font-size: 30rpx;
+ font-weight: 700;
+ color: #17345a;
+}
+
+.card__subtitle,
+.card__summary,
+.card__meta {
+ font-size: 24rpx;
+ line-height: 1.6;
+}
+
+.card__subtitle {
+ color: #4f627a;
+}
+
+.card__summary {
+ color: #30465f;
+}
+
+.card__meta {
+ color: #64748b;
+}
diff --git a/miniprogram/pages/home/home.ts b/miniprogram/pages/home/home.ts
index c002183..9c6c3b0 100644
--- a/miniprogram/pages/home/home.ts
+++ b/miniprogram/pages/home/home.ts
@@ -1,7 +1,9 @@
import { clearBackendAuthTokens, loadBackendAuthTokens, loadBackendBaseUrl } from '../../utils/backendAuth'
-import { getEntryHome, type BackendCardResult, type BackendEntryHomeResult } from '../../utils/backendApi'
+import { finishSession, getEntryHome, type BackendCardResult, type BackendEntryHomeResult } from '../../utils/backendApi'
import { reportBackendClientLog } from '../../utils/backendClientLogs'
import { setGlobalMockDebugBridgeEnabled } from '../../utils/globalMockDebugBridge'
+import { clearSessionRecoverySnapshot, loadSessionRecoverySnapshot } from '../../game/core/sessionRecovery'
+import { getBackendSessionContextFromLaunchEnvelope, prepareMapPageUrlForRecovery } from '../../utils/gameLaunch'
const DEFAULT_CHANNEL_CODE = 'mini-demo'
const DEFAULT_CHANNEL_TYPE = 'wechat_mini'
@@ -16,6 +18,10 @@ type HomePageData = {
recentSessionText: string
ongoingRuntimeText: string
recentRuntimeText: string
+ ongoingActionHintText: string
+ showOngoingPanel: boolean
+ canRecoverOngoing: boolean
+ canAbandonOngoing: boolean
cards: BackendCardResult[]
}
@@ -50,6 +56,15 @@ function requireAuthToken(): string | null {
return tokens && tokens.accessToken ? tokens.accessToken : null
}
+function getRecoverySnapshotSessionId(): string {
+ const snapshot = loadSessionRecoverySnapshot()
+ if (!snapshot) {
+ return ''
+ }
+ const context = getBackendSessionContextFromLaunchEnvelope(snapshot.launchEnvelope)
+ return context && context.sessionId ? context.sessionId : ''
+}
+
Page({
data: {
loading: false,
@@ -61,6 +76,10 @@ Page({
recentSessionText: '无',
ongoingRuntimeText: '运行对象 --',
recentRuntimeText: '运行对象 --',
+ ongoingActionHintText: '当前没有可恢复的进行中对局',
+ showOngoingPanel: false,
+ canRecoverOngoing: false,
+ canAbandonOngoing: false,
cards: [],
} as HomePageData,
@@ -102,6 +121,16 @@ Page({
},
applyEntryHomeResult(result: BackendEntryHomeResult) {
+ const ongoingSession = result.ongoingSession || null
+ const recoverySnapshotSessionId = getRecoverySnapshotSessionId()
+ const canRecoverOngoing = !!ongoingSession && !!recoverySnapshotSessionId
+ && ongoingSession.id === recoverySnapshotSessionId
+ const canAbandonOngoing = canRecoverOngoing
+ const ongoingActionHintText = !ongoingSession
+ ? '当前没有可恢复的进行中对局'
+ : canRecoverOngoing
+ ? '检测到本机仍保留这局的恢复记录,你可以继续恢复或主动放弃。'
+ : '检测到后端存在进行中对局,但本机当前没有匹配的恢复快照。'
reportBackendClientLog({
level: 'info',
category: 'entry-home',
@@ -112,6 +141,9 @@ Page({
recentSessionId: result.recentSession && result.recentSession.id ? result.recentSession.id : '',
recentEventId: result.recentSession && result.recentSession.eventId ? result.recentSession.eventId : '',
cardEventIds: (result.cards || []).map((item) => (item.event && item.event.id ? item.event.id : '')),
+ hasOngoingSession: !!ongoingSession,
+ recoverySnapshotSessionId,
+ canRecoverOngoing,
},
})
this.setData({
@@ -124,6 +156,10 @@ Page({
recentSessionText: formatSessionSummary(result.recentSession),
ongoingRuntimeText: formatRuntimeSummary(result.ongoingSession),
recentRuntimeText: formatRuntimeSummary(result.recentSession),
+ ongoingActionHintText,
+ showOngoingPanel: !!ongoingSession,
+ canRecoverOngoing,
+ canAbandonOngoing,
cards: result.cards || [],
})
},
@@ -159,6 +195,79 @@ Page({
})
},
+ handleOpenExperienceMaps() {
+ wx.navigateTo({
+ url: '/pages/experience-maps/experience-maps',
+ })
+ },
+
+ handleResumeOngoing() {
+ const snapshot = loadSessionRecoverySnapshot()
+ if (!snapshot) {
+ wx.showToast({
+ title: '本机未找到恢复快照',
+ icon: 'none',
+ })
+ this.loadEntryHome()
+ return
+ }
+
+ wx.navigateTo({
+ url: prepareMapPageUrlForRecovery(snapshot.launchEnvelope),
+ })
+ },
+
+ handleAbandonOngoing() {
+ const snapshot = loadSessionRecoverySnapshot()
+ if (!snapshot) {
+ wx.showToast({
+ title: '本机未找到恢复快照',
+ icon: 'none',
+ })
+ this.loadEntryHome()
+ return
+ }
+
+ const sessionContext = getBackendSessionContextFromLaunchEnvelope(snapshot.launchEnvelope)
+ if (!sessionContext) {
+ clearSessionRecoverySnapshot()
+ wx.showToast({
+ title: '已清理本机恢复记录',
+ icon: 'none',
+ })
+ this.loadEntryHome()
+ return
+ }
+
+ wx.showModal({
+ title: '放弃进行中的游戏',
+ content: '放弃后,这局游戏会记为已取消,且不会再出现在“进行中”。',
+ confirmText: '确认放弃',
+ cancelText: '先保留',
+ success: (result) => {
+ if (!result.confirm) {
+ return
+ }
+
+ finishSession({
+ baseUrl: loadBackendBaseUrl(),
+ sessionId: sessionContext.sessionId,
+ sessionToken: sessionContext.sessionToken,
+ status: 'cancelled',
+ summary: {},
+ }).catch(() => {
+ wx.showToast({
+ title: '取消上报失败,请稍后重试',
+ icon: 'none',
+ })
+ }).finally(() => {
+ clearSessionRecoverySnapshot()
+ this.loadEntryHome()
+ })
+ },
+ })
+ },
+
handleLogout() {
clearBackendAuthTokens()
setGlobalMockDebugBridgeEnabled(false)
diff --git a/miniprogram/pages/home/home.wxml b/miniprogram/pages/home/home.wxml
index d866406..afb5315 100644
--- a/miniprogram/pages/home/home.wxml
+++ b/miniprogram/pages/home/home.wxml
@@ -10,18 +10,31 @@
当前状态
{{statusText}}
- 进行中:{{ongoingSessionText}}
- 进行中运行对象:{{ongoingRuntimeText}}
+ 进行中:{{ongoingSessionText}}
+ 进行中运行对象:{{ongoingRuntimeText}}
+ {{ongoingActionHintText}}
最近一局:{{recentSessionText}}
最近一局运行对象:{{recentRuntimeText}}
+
+
+ 进行中的游戏
+ {{ongoingSessionText}}
+ {{ongoingRuntimeText}}
+ {{ongoingActionHintText}}
+
+
+
+
+
+
活动入口
当前没有首页卡片
diff --git a/miniprogram/pages/index/index.ts b/miniprogram/pages/index/index.ts
index 45c3336..3f0415d 100644
--- a/miniprogram/pages/index/index.ts
+++ b/miniprogram/pages/index/index.ts
@@ -1,16 +1,7 @@
-import { finishSession } from '../../utils/backendApi'
-import { loadBackendAuthTokens, loadBackendBaseUrl } from '../../utils/backendAuth'
-import { clearSessionRecoverySnapshot, loadSessionRecoverySnapshot } from '../../game/core/sessionRecovery'
-import { getBackendSessionContextFromLaunchEnvelope, prepareMapPageUrlForRecovery } from '../../utils/gameLaunch'
+import { loadBackendAuthTokens } from '../../utils/backendAuth'
Page({
onLoad() {
- const recoverySnapshot = loadSessionRecoverySnapshot()
- if (recoverySnapshot) {
- this.promptRecoveryAtEntry()
- return
- }
-
this.redirectToDefaultEntry()
},
@@ -21,59 +12,4 @@ Page({
: '/pages/login/login'
wx.redirectTo({ url })
},
-
- promptRecoveryAtEntry() {
- const recoverySnapshot = loadSessionRecoverySnapshot()
- if (!recoverySnapshot) {
- this.redirectToDefaultEntry()
- return
- }
-
- wx.showModal({
- title: '恢复对局',
- content: '检测到上次有未正常结束的对局,是否继续恢复?',
- confirmText: '继续恢复',
- cancelText: '放弃',
- success: (result) => {
- if (result.confirm) {
- wx.redirectTo({
- url: prepareMapPageUrlForRecovery(recoverySnapshot.launchEnvelope),
- })
- return
- }
-
- const sessionContext = getBackendSessionContextFromLaunchEnvelope(recoverySnapshot.launchEnvelope)
- if (!sessionContext) {
- clearSessionRecoverySnapshot()
- wx.showToast({
- title: '已放弃上次对局',
- icon: 'none',
- duration: 1400,
- })
- this.redirectToDefaultEntry()
- return
- }
-
- finishSession({
- baseUrl: loadBackendBaseUrl(),
- sessionId: sessionContext.sessionId,
- sessionToken: sessionContext.sessionToken,
- status: 'cancelled',
- summary: {},
- })
- .catch(() => {
- // 放弃恢复不阻塞进入业务页;失败只丢给后续状态页处理。
- })
- .finally(() => {
- clearSessionRecoverySnapshot()
- wx.showToast({
- title: '已放弃上次对局',
- icon: 'none',
- duration: 1400,
- })
- this.redirectToDefaultEntry()
- })
- },
- })
- },
})
diff --git a/miniprogram/pages/login/login.ts b/miniprogram/pages/login/login.ts
index 77b0e85..572807c 100644
--- a/miniprogram/pages/login/login.ts
+++ b/miniprogram/pages/login/login.ts
@@ -126,4 +126,21 @@ Page({
statusText: '已清空登录态',
})
},
+
+ handleContinueAsGuest() {
+ const baseUrl = this.persistBaseUrl()
+ clearBackendAuthTokens()
+ setGlobalMockDebugBridgeEnabled(false)
+ const app = getApp()
+ if (app.globalData) {
+ app.globalData.backendBaseUrl = baseUrl
+ app.globalData.backendAuthTokens = null
+ }
+ this.setData({
+ statusText: '已切换到游客模式,准备进入地图体验',
+ })
+ wx.redirectTo({
+ url: '/pages/experience-maps/experience-maps',
+ })
+ },
})
diff --git a/miniprogram/pages/login/login.wxml b/miniprogram/pages/login/login.wxml
index ffb275b..7ad0c01 100644
--- a/miniprogram/pages/login/login.wxml
+++ b/miniprogram/pages/login/login.wxml
@@ -23,6 +23,7 @@
+
diff --git a/miniprogram/pages/map/map.ts b/miniprogram/pages/map/map.ts
index f087573..31b8fc7 100644
--- a/miniprogram/pages/map/map.ts
+++ b/miniprogram/pages/map/map.ts
@@ -863,26 +863,6 @@ function buildLaunchConfigSummaryRows(envelope: GameLaunchEnvelope): MapEngineGa
return rows
}
-function emitSimulatorLaunchDiagnostic(
- stage: string,
- payload: Record,
-) {
- reportBackendClientLog({
- level: 'info',
- category: 'launch-diagnostic',
- message: stage,
- eventId: typeof payload.launchEventId === 'string' ? payload.launchEventId : '',
- releaseId: typeof payload.configReleaseId === 'string'
- ? payload.configReleaseId
- : (typeof payload.resolvedReleaseId === 'string' ? payload.resolvedReleaseId : ''),
- sessionId: typeof payload.launchSessionId === 'string' ? payload.launchSessionId : '',
- manifestUrl: typeof payload.resolvedManifestUrl === 'string'
- ? payload.resolvedManifestUrl
- : (typeof payload.configUrl === 'string' ? payload.configUrl : ''),
- details: payload,
- })
-}
-
Page({
data: {
showDebugPanel: false,
@@ -1584,21 +1564,6 @@ Page({
},
loadGameLaunchEnvelope(envelope: GameLaunchEnvelope) {
- emitSimulatorLaunchDiagnostic('loadGameLaunchEnvelope', {
- launchEventId: envelope.business && envelope.business.eventId ? envelope.business.eventId : '',
- launchSessionId: envelope.business && envelope.business.sessionId ? envelope.business.sessionId : '',
- configUrl: envelope.config.configUrl || '',
- configReleaseId: envelope.config.releaseId || '',
- resolvedManifestUrl: envelope.resolvedRelease && envelope.resolvedRelease.manifestUrl
- ? envelope.resolvedRelease.manifestUrl
- : '',
- resolvedReleaseId: envelope.resolvedRelease && envelope.resolvedRelease.releaseId
- ? envelope.resolvedRelease.releaseId
- : '',
- launchVariantId: envelope.variant && envelope.variant.variantId ? envelope.variant.variantId : null,
- launchVariantRouteCode: envelope.variant && envelope.variant.routeCode ? envelope.variant.routeCode : null,
- runtimeCourseVariantId: envelope.runtime && envelope.runtime.courseVariantId ? envelope.runtime.courseVariantId : null,
- })
this.loadMapConfigFromRemote(
envelope.config.configUrl,
envelope.config.configLabel,
@@ -2186,18 +2151,6 @@ Page({
return
}
- emitSimulatorLaunchDiagnostic('loadRemoteMapConfig:resolved', {
- launchEventId: currentGameLaunchEnvelope.business && currentGameLaunchEnvelope.business.eventId
- ? currentGameLaunchEnvelope.business.eventId
- : '',
- configUrl,
- configVersion: config.configVersion || '',
- schemaVersion: config.configSchemaVersion || '',
- playfieldKind: config.playfieldKind || '',
- gameMode: config.gameMode || '',
- configTitle: config.configTitle || '',
- })
-
currentEngine.applyRemoteMapConfig(config)
this.applyConfiguredSystemSettings(config)
const compiledProfile = this.applyCompiledRuntimeProfiles(true, {
@@ -2248,14 +2201,6 @@ Page({
return
}
- emitSimulatorLaunchDiagnostic('loadRemoteMapConfig:error', {
- launchEventId: currentGameLaunchEnvelope.business && currentGameLaunchEnvelope.business.eventId
- ? currentGameLaunchEnvelope.business.eventId
- : '',
- configUrl,
- message: error && error.message ? error.message : '未知错误',
- })
-
const rawErrorMessage = error && error.message ? error.message : '未知错误'
const errorMessage = rawErrorMessage.indexOf('404') >= 0
? `release manifest 不存在或未发布 (${configLabel})`
diff --git a/miniprogram/pages/result/result.ts b/miniprogram/pages/result/result.ts
index f689580..54f158c 100644
--- a/miniprogram/pages/result/result.ts
+++ b/miniprogram/pages/result/result.ts
@@ -5,9 +5,13 @@ import type { GameLaunchEnvelope } from '../../utils/gameLaunch'
type ResultPageData = {
sessionId: string
+ eventId: string
+ guestMode: boolean
statusText: string
sessionTitleText: string
sessionSubtitleText: string
+ activitySummaryText: string
+ listButtonText: string
rows: Array<{ label: string; value: string }>
}
@@ -95,9 +99,13 @@ function loadPendingResultLaunchEnvelope(): GameLaunchEnvelope | null {
Page({
data: {
sessionId: '',
+ eventId: '',
+ guestMode: false,
statusText: '准备加载结果',
sessionTitleText: '结果页',
sessionSubtitleText: '未加载',
+ activitySummaryText: '你可以查看本局结果,也可以回到活动继续查看详情。',
+ listButtonText: '查看历史结果',
rows: [],
} as ResultPageData,
@@ -131,6 +139,16 @@ Page({
statusText: '正在加载结果',
sessionTitleText: snapshot.title,
sessionSubtitleText: snapshot.subtitle,
+ guestMode: !getAccessToken(),
+ eventId: pendingLaunchEnvelope && pendingLaunchEnvelope.business && pendingLaunchEnvelope.business.eventId
+ ? pendingLaunchEnvelope.business.eventId
+ : '',
+ activitySummaryText: pendingLaunchEnvelope && pendingLaunchEnvelope.business && pendingLaunchEnvelope.business.eventId
+ ? (!getAccessToken()
+ ? '本局游客体验已结束,你可以回到活动继续查看,或返回地图体验。'
+ : '本局结果已生成,你可以继续查看详情,或回到活动页。')
+ : (!getAccessToken() ? '本局游客体验已结束,你可以返回地图体验。' : '本局结果已生成,你可以继续查看历史结果。'),
+ listButtonText: getAccessToken() ? '查看历史结果' : '返回地图体验',
rows: appendRuntimeRows([
{ label: snapshot.heroLabel, value: snapshot.heroValue },
...snapshot.rows.map((row) => ({
@@ -152,7 +170,11 @@ Page({
async loadSingleResult(sessionId: string) {
const accessToken = getAccessToken()
if (!accessToken) {
- wx.redirectTo({ url: '/pages/login/login' })
+ this.setData({
+ guestMode: true,
+ statusText: '游客模式当前不加载后端单局结果,先展示本地结果摘要',
+ listButtonText: '返回地图体验',
+ })
return
}
@@ -169,8 +191,12 @@ Page({
const pendingLaunchEnvelope = loadPendingResultLaunchEnvelope()
this.setData({
statusText: '单局结果加载完成',
+ eventId: result.session.eventId || '',
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} / ${formatRouteSummary(result.session)}`,
+ activitySummaryText: result.session.eventId
+ ? '你可以继续查看这场活动的详情,或回看历史结果。'
+ : '你可以继续回看历史结果。',
rows: appendRuntimeRows([
{ label: '赛道版本', value: formatRouteSummary(result.session) },
{ label: '最终得分', value: formatValue(result.result.finalScore) },
@@ -199,8 +225,27 @@ Page({
},
handleBackToList() {
+ if (this.data.guestMode) {
+ wx.redirectTo({
+ url: '/pages/experience-maps/experience-maps',
+ })
+ return
+ }
wx.redirectTo({
url: '/pages/results/results',
})
},
+
+ handleBackToEvent() {
+ if (!this.data.eventId) {
+ wx.showToast({
+ title: '当前结果未关联活动',
+ icon: 'none',
+ })
+ return
+ }
+ wx.redirectTo({
+ url: `/pages/event/event?eventId=${encodeURIComponent(this.data.eventId)}`,
+ })
+ },
})
diff --git a/miniprogram/pages/result/result.wxml b/miniprogram/pages/result/result.wxml
index f5e4805..552c8b9 100644
--- a/miniprogram/pages/result/result.wxml
+++ b/miniprogram/pages/result/result.wxml
@@ -9,7 +9,11 @@
当前状态
{{statusText}}
-
+ {{activitySummaryText}}
+
+
+
+
diff --git a/miniprogram/pages/results/results.ts b/miniprogram/pages/results/results.ts
index 1b51aec..8cfee8e 100644
--- a/miniprogram/pages/results/results.ts
+++ b/miniprogram/pages/results/results.ts
@@ -6,6 +6,7 @@ type ResultsPageData = {
statusText: string
results: Array<{
sessionId: string
+ eventId: string
titleText: string
statusText: string
scoreText: string
@@ -51,6 +52,7 @@ function formatRuntimeSummary(result: BackendSessionResultView): string {
function buildResultCardView(result: BackendSessionResultView) {
return {
sessionId: result.session.id,
+ eventId: result.session.eventId || '',
titleText: result.session.eventName || result.session.id,
statusText: `${result.result.status} / ${result.session.status}`,
scoreText: `得分 ${result.result.finalScore || '--'} / 用时 ${result.result.finalDurationSec || '--'}s`,
@@ -115,4 +117,18 @@ Page({
url: `/pages/result/result?sessionId=${encodeURIComponent(sessionId)}`,
})
},
+
+ handleOpenEvent(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)}`,
+ })
+ },
})
diff --git a/miniprogram/pages/results/results.wxml b/miniprogram/pages/results/results.wxml
index 6e5aa5a..d891425 100644
--- a/miniprogram/pages/results/results.wxml
+++ b/miniprogram/pages/results/results.wxml
@@ -9,6 +9,7 @@
当前状态
{{statusText}}
+ 你可以回看最近完成的对局,也可以回到对应活动继续查看详情。
@@ -20,6 +21,10 @@
{{item.scoreText}}
{{item.routeText}}
{{item.runtimeText}}
+
+
+
+
diff --git a/miniprogram/utils/backendApi.ts b/miniprogram/utils/backendApi.ts
index 3bed558..2a3e410 100644
--- a/miniprogram/utils/backendApi.ts
+++ b/miniprogram/utils/backendApi.ts
@@ -63,12 +63,92 @@ export interface BackendPresentationSummary {
version?: string | null
}
+export interface BackendPreviewControlSummary {
+ id?: string | null
+ label?: string | null
+ kind?: string | null
+ lon?: number | null
+ lat?: number | null
+}
+
+export interface BackendPreviewLegSummary {
+ fromLon?: number | null
+ fromLat?: number | null
+ toLon?: number | null
+ toLat?: number | null
+}
+
+export interface BackendPreviewVariantSummary {
+ variantId?: string | null
+ id?: string | null
+ name?: string | null
+ routeCode?: string | null
+ controls?: BackendPreviewControlSummary[] | null
+ legs?: BackendPreviewLegSummary[] | null
+}
+
+export interface BackendPreviewSummary {
+ mode?: string | null
+ baseTiles?: {
+ tileBaseUrl?: string | null
+ zoom?: number | null
+ tileSize?: number | null
+ } | null
+ viewport?: {
+ width?: number | null
+ height?: number | null
+ minLon?: number | null
+ minLat?: number | null
+ maxLon?: number | null
+ maxLat?: number | null
+ } | null
+ variants?: BackendPreviewVariantSummary[] | null
+ selectedVariantId?: string | null
+}
+
export interface BackendContentBundleSummary {
bundleId?: string | null
bundleType?: string | null
version?: string | null
}
+export interface BackendExperienceMapSummary {
+ placeId?: string | null
+ placeName?: string | null
+ mapId?: string | null
+ mapName?: string | null
+ coverUrl?: string | null
+ summary?: string | null
+ defaultExperienceCount?: number | null
+ defaultExperienceEventIds?: string[] | null
+}
+
+export interface BackendDefaultExperienceSummary {
+ eventId?: string | null
+ title?: string | null
+ subtitle?: string | null
+ eventType?: string | null
+ status?: string | null
+ statusCode?: string | null
+ ctaText?: string | null
+ isDefaultExperience?: boolean
+ showInEventList?: boolean
+ currentPresentation?: BackendPresentationSummary | null
+ currentContentBundle?: BackendContentBundleSummary | null
+}
+
+export interface BackendExperienceMapDetail {
+ placeId?: string | null
+ placeName?: string | null
+ mapId?: string | null
+ mapName?: string | null
+ coverUrl?: string | null
+ summary?: string | null
+ tileBaseUrl?: string | null
+ tileMetaUrl?: string | null
+ defaultExperiences?: BackendDefaultExperienceSummary[] | null
+}
+
export interface BackendEntrySessionSummary {
id: string
status: string
@@ -151,6 +231,7 @@ export interface BackendEventPlayResult {
}
currentPresentation?: BackendPresentationSummary | null
currentContentBundle?: BackendContentBundleSummary | null
+ preview?: BackendPreviewSummary | null
release?: {
id: string
configLabel: string
@@ -188,6 +269,7 @@ export interface BackendLaunchResult {
}
business: {
source: string
+ isGuest?: boolean
eventId: string
sessionId: string
sessionToken: string
@@ -348,6 +430,17 @@ export function getEventPlay(input: {
})
}
+export function getPublicEventPlay(input: {
+ baseUrl: string
+ eventId: string
+}): Promise {
+ return requestBackend({
+ method: 'GET',
+ baseUrl: input.baseUrl,
+ path: `/public/events/${encodeURIComponent(input.eventId)}/play`,
+ })
+}
+
export function getEntryHome(input: {
baseUrl: string
accessToken: string
@@ -391,6 +484,32 @@ export function launchEvent(input: {
})
}
+export function launchPublicEvent(input: {
+ baseUrl: string
+ eventId: string
+ releaseId?: string
+ variantId?: string
+ clientType: string
+ deviceKey: string
+}): Promise {
+ const body: Record = {
+ clientType: input.clientType,
+ deviceKey: input.deviceKey,
+ }
+ if (input.releaseId) {
+ body.releaseId = input.releaseId
+ }
+ if (input.variantId) {
+ body.variantId = input.variantId
+ }
+ return requestBackend({
+ method: 'POST',
+ baseUrl: input.baseUrl,
+ path: `/public/events/${encodeURIComponent(input.eventId)}/launch`,
+ body,
+ })
+}
+
export function startSession(input: {
baseUrl: string
sessionId: string
@@ -463,3 +582,49 @@ export function postClientLog(input: {
body: input.payload as unknown as Record,
})
}
+
+export function getExperienceMaps(input: {
+ baseUrl: string
+ accessToken: string
+}): Promise {
+ return requestBackend({
+ method: 'GET',
+ baseUrl: input.baseUrl,
+ path: '/experience-maps',
+ authToken: input.accessToken,
+ })
+}
+
+export function getPublicExperienceMaps(input: {
+ baseUrl: string
+}): Promise {
+ return requestBackend({
+ method: 'GET',
+ baseUrl: input.baseUrl,
+ path: '/public/experience-maps',
+ })
+}
+
+export function getExperienceMapDetail(input: {
+ baseUrl: string
+ accessToken: string
+ mapAssetId: string
+}): Promise {
+ return requestBackend({
+ method: 'GET',
+ baseUrl: input.baseUrl,
+ path: `/experience-maps/${encodeURIComponent(input.mapAssetId)}`,
+ authToken: input.accessToken,
+ })
+}
+
+export function getPublicExperienceMapDetail(input: {
+ baseUrl: string
+ mapAssetId: string
+}): Promise {
+ return requestBackend({
+ method: 'GET',
+ baseUrl: input.baseUrl,
+ path: `/public/experience-maps/${encodeURIComponent(input.mapAssetId)}`,
+ })
+}
diff --git a/miniprogram/utils/prepareMapPreview.ts b/miniprogram/utils/prepareMapPreview.ts
new file mode 100644
index 0000000..de2a53d
--- /dev/null
+++ b/miniprogram/utils/prepareMapPreview.ts
@@ -0,0 +1,482 @@
+import { type BackendPreviewSummary } from './backendApi'
+import { lonLatToWorldTile, type LonLatPoint } from './projection'
+import { isTileWithinBounds, type RemoteMapConfig } from './remoteMapConfig'
+import { buildTileUrl } from './tile'
+
+export interface PreparePreviewTile {
+ url: string
+ x: number
+ y: number
+ leftPx: number
+ topPx: number
+ sizePx: number
+}
+
+export interface PreparePreviewControl {
+ kind: 'start' | 'control' | 'finish'
+ label: string
+ x: number
+ y: number
+}
+
+export interface PreparePreviewLeg {
+ fromX: number
+ fromY: number
+ toX: number
+ toY: number
+}
+
+export interface PreparePreviewScene {
+ width: number
+ height: number
+ zoom: number
+ tiles: PreparePreviewTile[]
+ controls: PreparePreviewControl[]
+ legs: PreparePreviewLeg[]
+ overlayAvailable: boolean
+}
+
+interface PreviewPointSeed {
+ kind: 'start' | 'control' | 'finish'
+ label: string
+ point: LonLatPoint
+}
+
+function resolvePreviewTileTemplate(tileBaseUrl: string): string {
+ if (tileBaseUrl.indexOf('{z}') >= 0 && tileBaseUrl.indexOf('{x}') >= 0 && tileBaseUrl.indexOf('{y}') >= 0) {
+ return tileBaseUrl
+ }
+ const normalizedBase = tileBaseUrl.replace(/\/+$/, '')
+ return `${normalizedBase}/{z}/{x}/{y}.png`
+}
+
+function clamp(value: number, min: number, max: number): number {
+ return Math.max(min, Math.min(max, value))
+}
+
+function collectCoursePoints(config: RemoteMapConfig): LonLatPoint[] {
+ if (!config.course) {
+ return []
+ }
+
+ const points: LonLatPoint[] = []
+ config.course.layers.starts.forEach((item) => {
+ points.push(item.point)
+ })
+ config.course.layers.controls.forEach((item) => {
+ points.push(item.point)
+ })
+ config.course.layers.finishes.forEach((item) => {
+ points.push(item.point)
+ })
+ return points
+}
+
+function collectPreviewPointSeeds(items: Array<{
+ kind?: string | null
+ label?: string | null
+ lon?: number | null
+ lat?: number | null
+}>): PreviewPointSeed[] {
+ const seeds: PreviewPointSeed[] = []
+ items.forEach((item, index) => {
+ if (typeof item.lon !== 'number' || typeof item.lat !== 'number') {
+ return
+ }
+ const kind = item.kind === 'start' || item.kind === 'finish' ? item.kind : 'control'
+ seeds.push({
+ kind,
+ label: item.label || String(index + 1),
+ point: {
+ lon: item.lon,
+ lat: item.lat,
+ },
+ })
+ })
+ return seeds
+}
+
+function computePointBounds(points: LonLatPoint[]): { minLon: number; minLat: number; maxLon: number; maxLat: number } | null {
+ if (!points.length) {
+ return null
+ }
+
+ let minLon = points[0].lon
+ let maxLon = points[0].lon
+ let minLat = points[0].lat
+ let maxLat = points[0].lat
+ points.forEach((point) => {
+ minLon = Math.min(minLon, point.lon)
+ maxLon = Math.max(maxLon, point.lon)
+ minLat = Math.min(minLat, point.lat)
+ maxLat = Math.max(maxLat, point.lat)
+ })
+
+ return {
+ minLon,
+ minLat,
+ maxLon,
+ maxLat,
+ }
+}
+
+function resolvePreviewZoom(config: RemoteMapConfig, width: number, height: number, points: LonLatPoint[]): number {
+ const upperZoom = clamp(config.defaultZoom > 0 ? config.defaultZoom : config.maxZoom, config.minZoom, config.maxZoom)
+ if (!points.length) {
+ return clamp(upperZoom - 1, config.minZoom, config.maxZoom)
+ }
+
+ const bounds = computePointBounds(points)
+ if (!bounds) {
+ return clamp(upperZoom - 1, config.minZoom, config.maxZoom)
+ }
+
+ let fittedZoom = config.minZoom
+ for (let zoom = upperZoom; zoom >= config.minZoom; zoom -= 1) {
+ const northWest = lonLatToWorldTile({ lon: bounds.minLon, lat: bounds.maxLat }, zoom)
+ const southEast = lonLatToWorldTile({ lon: bounds.maxLon, lat: bounds.minLat }, zoom)
+ const widthPx = Math.abs(southEast.x - northWest.x) * config.tileSize
+ const heightPx = Math.abs(southEast.y - northWest.y) * config.tileSize
+ if (widthPx <= width * 0.9 && heightPx <= height * 0.9) {
+ fittedZoom = zoom
+ break
+ }
+ }
+
+ return clamp(fittedZoom, config.minZoom, config.maxZoom)
+}
+
+function resolvePreviewCenter(config: RemoteMapConfig, zoom: number, points: LonLatPoint[]): { x: number; y: number } {
+ const bounds = computePointBounds(points)
+ if (bounds) {
+ const center = lonLatToWorldTile(
+ {
+ lon: (bounds.minLon + bounds.maxLon) / 2,
+ lat: (bounds.minLat + bounds.maxLat) / 2,
+ },
+ zoom,
+ )
+ return {
+ x: center.x,
+ y: center.y,
+ }
+ }
+
+ return {
+ x: config.initialCenterTileX,
+ y: config.initialCenterTileY,
+ }
+}
+
+function buildPreviewTiles(
+ config: RemoteMapConfig,
+ zoom: number,
+ width: number,
+ height: number,
+ centerWorldX: number,
+ centerWorldY: number,
+): PreparePreviewTile[] {
+ const halfWidthInTiles = width / 2 / config.tileSize
+ const halfHeightInTiles = height / 2 / config.tileSize
+ const minTileX = Math.floor(centerWorldX - halfWidthInTiles) - 1
+ const maxTileX = Math.ceil(centerWorldX + halfWidthInTiles) + 1
+ const minTileY = Math.floor(centerWorldY - halfHeightInTiles) - 1
+ const maxTileY = Math.ceil(centerWorldY + halfHeightInTiles) + 1
+ const tiles: PreparePreviewTile[] = []
+
+ for (let tileY = minTileY; tileY <= maxTileY; tileY += 1) {
+ for (let tileX = minTileX; tileX <= maxTileX; tileX += 1) {
+ if (!isTileWithinBounds(config.tileBoundsByZoom, zoom, tileX, tileY)) {
+ continue
+ }
+
+ tiles.push({
+ url: buildTileUrl(config.tileSource, zoom, tileX, tileY),
+ x: tileX,
+ y: tileY,
+ leftPx: Math.round(width / 2 + (tileX - centerWorldX) * config.tileSize),
+ topPx: Math.round(height / 2 + (tileY - centerWorldY) * config.tileSize),
+ sizePx: config.tileSize,
+ })
+ }
+ }
+
+ return tiles
+}
+
+function applyFitTransform(
+ scene: PreparePreviewScene,
+ paddingRatio: number,
+): PreparePreviewScene {
+ if (!scene.controls.length) {
+ return scene
+ }
+
+ let minX = scene.controls[0].x
+ let maxX = scene.controls[0].x
+ let minY = scene.controls[0].y
+ let maxY = scene.controls[0].y
+
+ scene.controls.forEach((control) => {
+ minX = Math.min(minX, control.x)
+ maxX = Math.max(maxX, control.x)
+ minY = Math.min(minY, control.y)
+ maxY = Math.max(maxY, control.y)
+ })
+
+ const boundsWidth = Math.max(1, maxX - minX)
+ const boundsHeight = Math.max(1, maxY - minY)
+ const targetWidth = scene.width * paddingRatio
+ const targetHeight = scene.height * paddingRatio
+ const scale = Math.max(1, Math.min(targetWidth / boundsWidth, targetHeight / boundsHeight))
+ const centerX = (minX + maxX) / 2
+ const centerY = (minY + maxY) / 2
+
+ const transformX = (value: number) => ((value - centerX) * scale) + scene.width / 2
+ const transformY = (value: number) => ((value - centerY) * scale) + scene.height / 2
+
+ return {
+ ...scene,
+ tiles: scene.tiles.map((tile) => ({
+ ...tile,
+ leftPx: transformX(tile.leftPx),
+ topPx: transformY(tile.topPx),
+ sizePx: tile.sizePx * scale,
+ })),
+ controls: scene.controls.map((control) => ({
+ ...control,
+ x: transformX(control.x),
+ y: transformY(control.y),
+ })),
+ legs: scene.legs.map((leg) => ({
+ fromX: transformX(leg.fromX),
+ fromY: transformY(leg.fromY),
+ toX: transformX(leg.toX),
+ toY: transformY(leg.toY),
+ })),
+ }
+}
+
+export function buildPreparePreviewScene(
+ config: RemoteMapConfig,
+ width: number,
+ height: number,
+ overlayEnabled: boolean,
+): PreparePreviewScene {
+ const normalizedWidth = Math.max(240, Math.round(width))
+ const normalizedHeight = Math.max(140, Math.round(height))
+ const points = collectCoursePoints(config)
+ const zoom = resolvePreviewZoom(config, normalizedWidth, normalizedHeight, points)
+ const center = resolvePreviewCenter(config, zoom, points)
+ const tiles = buildPreviewTiles(config, zoom, normalizedWidth, normalizedHeight, center.x, center.y)
+
+ const controls: PreparePreviewControl[] = []
+ const legs: PreparePreviewLeg[] = []
+
+ if (overlayEnabled && config.course) {
+ const projectPoint = (point: LonLatPoint) => {
+ const world = lonLatToWorldTile(point, zoom)
+ return {
+ x: normalizedWidth / 2 + (world.x - center.x) * config.tileSize,
+ y: normalizedHeight / 2 + (world.y - center.y) * config.tileSize,
+ }
+ }
+
+ config.course.layers.legs.forEach((leg) => {
+ const from = projectPoint(leg.fromPoint)
+ const to = projectPoint(leg.toPoint)
+ legs.push({
+ fromX: from.x,
+ fromY: from.y,
+ toX: to.x,
+ toY: to.y,
+ })
+ })
+
+ config.course.layers.starts.forEach((item) => {
+ const point = projectPoint(item.point)
+ controls.push({
+ kind: 'start',
+ label: item.label,
+ x: point.x,
+ y: point.y,
+ })
+ })
+ config.course.layers.controls.forEach((item) => {
+ const point = projectPoint(item.point)
+ controls.push({
+ kind: 'control',
+ label: item.label,
+ x: point.x,
+ y: point.y,
+ })
+ })
+ config.course.layers.finishes.forEach((item) => {
+ const point = projectPoint(item.point)
+ controls.push({
+ kind: 'finish',
+ label: item.label,
+ x: point.x,
+ y: point.y,
+ })
+ })
+ }
+
+ const baseScene: PreparePreviewScene = {
+ width: normalizedWidth,
+ height: normalizedHeight,
+ zoom,
+ tiles,
+ controls,
+ legs,
+ overlayAvailable: overlayEnabled && !!config.course,
+ }
+
+ return applyFitTransform(baseScene, 0.88)
+}
+
+export function buildPreparePreviewSceneFromVariantControls(
+ config: RemoteMapConfig,
+ width: number,
+ height: number,
+ controlsInput: Array<{
+ kind?: string | null
+ label?: string | null
+ lon?: number | null
+ lat?: number | null
+ }>,
+): PreparePreviewScene | null {
+ const seeds = collectPreviewPointSeeds(controlsInput)
+ if (!seeds.length) {
+ return null
+ }
+
+ const normalizedWidth = Math.max(240, Math.round(width))
+ const normalizedHeight = Math.max(140, Math.round(height))
+ const points = seeds.map((item) => item.point)
+ const zoom = resolvePreviewZoom(config, normalizedWidth, normalizedHeight, points)
+ const center = resolvePreviewCenter(config, zoom, points)
+ const tiles = buildPreviewTiles(config, zoom, normalizedWidth, normalizedHeight, center.x, center.y)
+
+ const controls: PreparePreviewControl[] = seeds.map((item) => {
+ const world = lonLatToWorldTile(item.point, zoom)
+ return {
+ kind: item.kind,
+ label: item.label,
+ x: normalizedWidth / 2 + (world.x - center.x) * config.tileSize,
+ y: normalizedHeight / 2 + (world.y - center.y) * config.tileSize,
+ }
+ })
+
+ const scene: PreparePreviewScene = {
+ width: normalizedWidth,
+ height: normalizedHeight,
+ zoom,
+ tiles,
+ controls,
+ legs: [],
+ overlayAvailable: true,
+ }
+
+ return applyFitTransform(scene, 0.88)
+}
+
+export function buildPreparePreviewSceneFromBackendPreview(
+ preview: BackendPreviewSummary,
+ width: number,
+ height: number,
+ variantId?: string | null,
+ tileUrlTemplateOverride?: string | null,
+): PreparePreviewScene | null {
+ if (!preview.baseTiles || !preview.viewport || !preview.baseTiles.tileBaseUrl || typeof preview.baseTiles.zoom !== 'number') {
+ return null
+ }
+
+ const viewport = preview.viewport
+ if (
+ typeof viewport.minLon !== 'number'
+ || typeof viewport.minLat !== 'number'
+ || typeof viewport.maxLon !== 'number'
+ || typeof viewport.maxLat !== 'number'
+ ) {
+ return null
+ }
+
+ const normalizedWidth = Math.max(240, Math.round(width))
+ const normalizedHeight = Math.max(140, Math.round(height))
+ const zoom = Math.round(preview.baseTiles.zoom)
+ const tileSize = typeof preview.baseTiles.tileSize === 'number' && preview.baseTiles.tileSize > 0
+ ? preview.baseTiles.tileSize
+ : 256
+ const template = resolvePreviewTileTemplate(tileUrlTemplateOverride || preview.baseTiles.tileBaseUrl)
+
+ const center = lonLatToWorldTile(
+ {
+ lon: (viewport.minLon + viewport.maxLon) / 2,
+ lat: (viewport.minLat + viewport.maxLat) / 2,
+ },
+ zoom,
+ )
+
+ const northWest = lonLatToWorldTile({ lon: viewport.minLon, lat: viewport.maxLat }, zoom)
+ const southEast = lonLatToWorldTile({ lon: viewport.maxLon, lat: viewport.minLat }, zoom)
+ const boundsWidthPx = Math.max(1, Math.abs(southEast.x - northWest.x) * tileSize)
+ const boundsHeightPx = Math.max(1, Math.abs(southEast.y - northWest.y) * tileSize)
+ const scale = Math.min(normalizedWidth / boundsWidthPx, normalizedHeight / boundsHeightPx)
+
+ const minTileX = Math.floor(Math.min(northWest.x, southEast.x)) - 1
+ const maxTileX = Math.ceil(Math.max(northWest.x, southEast.x)) + 1
+ const minTileY = Math.floor(Math.min(northWest.y, southEast.y)) - 1
+ const maxTileY = Math.ceil(Math.max(northWest.y, southEast.y)) + 1
+
+ const tiles: PreparePreviewTile[] = []
+ for (let tileY = minTileY; tileY <= maxTileY; tileY += 1) {
+ for (let tileX = minTileX; tileX <= maxTileX; tileX += 1) {
+ const leftPx = ((tileX - center.x) * tileSize * scale) + normalizedWidth / 2
+ const topPx = ((tileY - center.y) * tileSize * scale) + normalizedHeight / 2
+ tiles.push({
+ url: buildTileUrl(template, zoom, tileX, tileY),
+ x: tileX,
+ y: tileY,
+ leftPx,
+ topPx,
+ sizePx: tileSize * scale,
+ })
+ }
+ }
+
+ const normalizedVariantId = variantId || preview.selectedVariantId || ''
+ const previewVariant = (preview.variants || []).find((item) => {
+ const candidateId = item.variantId || item.id || ''
+ return candidateId === normalizedVariantId
+ }) || (preview.variants && preview.variants[0] ? preview.variants[0] : null)
+
+ const controls: PreparePreviewControl[] = []
+ if (previewVariant && previewVariant.controls && previewVariant.controls.length) {
+ previewVariant.controls.forEach((item, index) => {
+ if (typeof item.lon !== 'number' || typeof item.lat !== 'number') {
+ return
+ }
+ const world = lonLatToWorldTile({ lon: item.lon, lat: item.lat }, zoom)
+ const x = ((world.x - center.x) * tileSize * scale) + normalizedWidth / 2
+ const y = ((world.y - center.y) * tileSize * scale) + normalizedHeight / 2
+ const normalizedKind = item.kind === 'start' || item.kind === 'finish' ? item.kind : 'control'
+ controls.push({
+ kind: normalizedKind,
+ label: item.label || String(index + 1),
+ x,
+ y,
+ })
+ })
+ }
+
+ return {
+ width: normalizedWidth,
+ height: normalizedHeight,
+ zoom,
+ tiles,
+ controls,
+ legs: [],
+ overlayAvailable: controls.length > 0,
+ }
+}
diff --git a/readme-develop.md b/readme-develop.md
index 37b9b47..56e951b 100644
--- a/readme-develop.md
+++ b/readme-develop.md
@@ -1,6 +1,6 @@
# CMR Mini 开发架构阶段总结
-> 文档版本:v1.20
-> 最后更新:2026-04-03 19:26:23
+> 文档版本:v1.24
+> 最后更新:2026-04-07 22:35:00
文档维护约定:
@@ -17,6 +17,8 @@
- [联调架构阶段总结](D:/dev/cmr-mini/doc/gameplay/联调架构阶段总结.md)
- 活动列表最小产品方案见:
- [活动卡片列表最小产品方案](D:/dev/cmr-mini/doc/gameplay/活动卡片列表最小产品方案.md)
+- 运维后台规划见:
+ - [运维后台第一期方案](D:/dev/cmr-mini/doc/gameplay/运维后台第一期方案.md)
- 面向后端线程的阶段性实施说明,优先写入根目录 [t2b.md](D:/dev/cmr-mini/t2b.md)。
- backend 新增写给总控线程的回写板:
- [b2t.md](D:/dev/cmr-mini/b2t.md)
@@ -69,7 +71,11 @@
- `presentation schema`
- `活动文案样例`
- 活动卡片列表最小产品化第一刀已完成
- - 当前主线进入“活动卡片列表第一刀联调回归与小范围修复阶段”
+ - 当前主线进入“活动系统最小成品闭环回归与小范围修复阶段”
+ - 本周目标切换为:
+ - 完成活动系统最小成品闭环
+ - 小程序主界面从工程态过渡到用户态
+ - 运维后台第一期只做规划,不正式开工
- backend 当前应优先保证:
- 从空白环境直接可跑
- workbench 日志能明确定位失败步骤
@@ -78,6 +84,16 @@
- 维护活动卡片列表第一刀所需最小摘要字段稳定
- 响应列表页联调中暴露的字段、默认值和语义问题
- 保持列表页与活动详情页摘要口径一致
+ - 收口活动配置、发布、默认活动与自定义活动统一流
+ - 第一阶段活动模型按:
+ - 单地图
+ - 单路线组
+ - 单玩法
+ 收口推进
+ - 当前不把复杂多地图 / 多路线组 / 多玩法语义硬塞进单个活动对象
+ - 为下周运维后台第一期准备对象与接口边界
+ - 继续保证三条标准 demo 活动无残留 `ongoing session`
+ - 继续保证一键回归链可从空白环境重复跑通
- 前端线程建议正式上场时机:
- 现在已完成活动运营域摘要接线第一刀
- 当前已完成:
@@ -94,7 +110,7 @@
- 会话快照
- 当前建议:
- frontend 已完成活动卡片列表最小产品化第一刀
- - frontend 当前进入联调回归与小范围修复阶段
+ - frontend 当前进入活动系统最小成品闭环回归与小范围修复阶段
- 优先复用 backend 一键测试环境做回归
- 优先复用:
- `回归结果汇总`
@@ -105,8 +121,21 @@
- 不做复杂运营样式
- frontend 当前分工:
- 活动列表页第一刀回归与小修
+ - 地图体验第一刀回归与小修
+ - 游客模式第一刀回归与小修
- 结构化日志补充
- 配合 backend 收口字段与默认值
+ - 活动详情页、准备页、结果页、历史页去工程味
+ - 在当前活动链、地图体验链、游客体验链内完成从工程态到用户态的过渡
+ - 不进入活动列表第二刀扩展
+ - 不进入地图体验第二刀
+ - 不进入游客模式第二刀
+ - 不做首页大重构
+ - 准备页地图预览按“用户化增强项”推进:
+ - 低级别正式瓦片底图
+ - 前端动态叠加当前赛道
+ - 只读展示
+ - 不做第二套交互地图
当前阶段的核心目标已经从“把地图画出来”升级为“建立一套可长期扩展的运动地图游戏底座”。
这套底座已经具备以下关键能力:
diff --git a/t2b.md b/t2b.md
index 4b0326a..a0b454b 100644
--- a/t2b.md
+++ b/t2b.md
@@ -1,949 +1,131 @@
# T2B 协作清单
-> 文档版本:v1.15
-> 最后更新:2026-04-03 19:26:23
+> 文档版本:v2.1
+> 最后更新:2026-04-07 22:35:00
说明:
-- 本文件由总控维护,写给后端线程
-- 目标是把“后台生产闭环”第一阶段需要落地的东西讲清楚
-- 只写当前阶段实施说明,不写长讨论稿
-- 正式架构文档以 [后台生产闭环架构草案](D:/dev/cmr-mini/doc/backend/后台生产闭环架构草案.md) 为准
+- 本文件由总控维护,写给 backend 线程
+- 只保留当前阶段信息
+- 历史说明已归档到 [T2B阶段归档](D:/dev/cmr-mini/doc/archive/协作/T2B阶段归档.md)
+- 正式架构以 [后台生产闭环架构草案](D:/dev/cmr-mini/doc/backend/后台生产闭环架构草案.md) 为准
---
-## 0. 当前阶段状态与下一步
+## 当前阶段
-backend 当前已完成:
+当前 backend 所处阶段:
-- 生产骨架对象落库与 `/dev/workbench` 最小联调台
+**活动系统最小成品闭环收口阶段**
+
+当前目标:
+
+1. 活动配置与发布链继续稳定
+2. 默认活动与自定义活动统一走发布流
+3. 一键测试与一键回归保持稳定
+4. 配合 frontend 完成活动列表第一刀回归与小修
+5. 接受第一阶段活动模型先按“单地图 + 单路线组 + 单玩法”收口
+
+---
+
+## 当前已完成基线
+
+backend 当前已稳定具备:
+
+- 生产骨架对象落库
- `MapRuntimeBinding -> EventRelease -> launch.runtime` 主链接通
-- `EventPresentation / ContentBundle / EventRelease` 第一阶段接通
-- `Event` 默认 active 三元组固化:
- - `currentPresentationId`
- - `currentContentBundleId`
- - `currentRuntimeBindingId`
-- `publish` 默认继承当前 active 三元组
-- `Bootstrap Demo` 与 `一键补齐 Runtime 并发布` 已可从空白状态跑完整测试链
-- `一键标准回归` 与 `回归结果汇总` 已接入标准联调入口
-- `前端调试日志` 与 `当前 Launch 实际配置摘要` 已接入 workbench
-- 三类标准 demo 入口已显式挂出:
- - `evt_demo_001`
- - `evt_demo_score_o_001`
- - `evt_demo_variant_manual_001`
-- workbench 日志已补齐:
- - 分步日志
- - 真实错误
- - stack
- - 最后一次 curl
- - 预期判定
+- `EventPresentation / ContentBundle / EventRelease` 接通
+- `Event` 默认 active 三元组固化
+- publish 默认继承 active 三元组
+- 第一阶段活动模型按:
+ - 单地图
+ - 单路线组
+ - 单玩法
+ 收口推进
+- `Bootstrap Demo`
+- `一键补齐 Runtime 并发布`
+- `一键标准回归`
+- `回归结果汇总`
+- `当前 Launch 实际配置摘要`
+- `前端调试日志`
+- 三条标准 demo 活动可用于联调
+- 标准 demo 环境下已清理残留 `ongoing session`
-当前主线不再是继续补对象,而是进入:
+---
-**联调标准化阶段**
+## 当前任务
-本阶段 backend 的核心任务只有 3 件事:
+### 1. 稳定 demo 与回归环境
-1. 固化“一键测试”链路,确保从空白环境可重复跑通
-2. 固化详细日志口径,失败时明确定位在哪一步
-3. 固化稳定测试数据,并逐步支持更接近生产的真实输入
+- 保持三条标准 demo 活动下无残留 `ongoing session`
+- 保持:
+ - `Bootstrap Demo`
+ - `一键补齐 Runtime 并发布`
+ - `一键标准回归`
+ 可从空白环境重复执行
-当前认为“真实输入替换第二刀”已经完成,backend 当前已完成:
+### 2. 配合活动列表第一刀回归与小修
-**活动卡片列表最小产品化配合第一刀**
+- 响应前端暴露的:
+ - 字段缺失
+ - 默认值不稳
+ - 状态语义不清
+ - 空值兜底不自然
+- 保持活动列表页与活动详情页摘要语义一致
-优先顺序建议:
+### 3. 为准备页地图预览 V1 预留最小字段
-1. 维持现有一键回归链稳定
-2. 为活动列表页补最小卡片摘要接口或字段
-3. 保持活动详情页与列表页使用同一套活动摘要口径
+后续最小字段方向:
+
+- `preview.mode`
+- `baseTiles.tileBaseUrl`
+- `baseTiles.zoom`
+- `viewport.width / height`
+- `viewport.minLon / minLat / maxLon / maxLat`
+- `variants[].controls`
+- `variants[].legs`
+- 可选:`selectedVariantId`
说明:
-- 真实 `content manifest`
-- 真实 `presentation schema`
-- 真实 `活动文案样例`
+- 这是准备页用户化增强项
+- 不是新主链
+- 不为此单独造新地图资源体系
-这三类输入已接入,当前不再作为本轮重点。
+### 4. 为下周运维后台第一期准备边界
-原则:
+- 只整理对象关系、接口边界、默认发布流
+- 不开正式后台 UI
-- 仍走同一条一键回归链
-- 不重新设计联调流程
-- 只是把 demo 输入逐步换成更接近生产的真实输入
+### 5. 保持活动模型第一阶段收口
-当前 backend 不建议切去做:
-
-- 新的玩家侧页面入口
-- 更多管理对象
-- 更复杂后台 UI
-
-### 0.5 当前分工
-
-backend 当前这一轮已完成:
-
-1. 活动卡片列表最小字段集对应的后端摘要
-2. 卡片摘要接口/返回字段收口
-3. 默认体验活动与普通活动的状态标记
-
-当前进入:
-
-**活动卡片列表最小产品化第一刀联调回归与小范围修复配合阶段**
-
-要求:
-
-- 继续挂在现有标准 demo 与一键回归链上
-- 不新开流程
-- 不新开对象层级
-- 优先响应前端在列表页第一刀联调中暴露的字段、默认值和语义问题
-
-当前进一步明确 backend 的执行口径如下:
-
-### 0.1 一键测试链路
-
-请继续以这条链作为唯一标准联调入口维护:
-
-```text
-Bootstrap Demo
--> 一键补齐 Runtime 并发布
--> launch / play / result / history 回归
-```
-
-要求:
-
-- 从空白环境直接可跑
-- 不依赖手工预铺 6~8 个对象
-- 同一条链可反复执行
-- 失败时能明确知道卡在哪一跳
-
-### 0.2 详细日志口径
-
-workbench 和相关 backend 调试输出,当前应至少统一包含:
-
-- 当前步骤名
-- 核心输入参数
-- 真实错误信息
-- stack
-- 最后一次 curl
-- 预期判定
-
-不要只输出“失败了”,要能回答:
-
-- 是哪一步失败
-- 为什么失败
-- 用什么请求复现
-
-### 0.3 稳定测试数据
-
-当前 demo 数据不要继续散落手工维护,统一以 backend 准备的一键测试数据为准。
-
-后续逐步支持以下更接近生产的真实输入:
-
-- 地图资源 URL
-- KML / 赛道文件
-- 内容 manifest
-- presentation schema
-- 活动文案样例
-
-### 0.4 当前不建议做
-
-联调标准化阶段不要继续发散去做:
-
-- 新对象扩张
-- 新管理面板
-- 更复杂 workbench UI
-- 复杂后台运营功能
-- 与当前联调闭环无关的页面能力
-
-当前不建议 backend 继续发散去做:
-
-- 更多新对象
-- 更多 workbench 管理按钮
-- 更复杂后台 UI
-- 过早扩大奖励、社交、审核流
+- 当前活动先按:
+ - 单地图
+ - 单路线组
+ - 单玩法
+ 推进
+- 复杂多地图 / 多路线组 / 多玩法活动,后续通过:
+ - 活动实例化
+ - 组合入口层
+ - 组合卡片层
+ 解决
+- 当前不把复杂组合能力硬塞进单个活动对象
---
-## 1. 本次目标
+## 当前不做
-本次不是让 backend 一次性做完整后台,而是先搭出**最小生产骨架**,让下面这条链能真正闭环:
-
-```text
-地图输入
--> 瓦片版本
--> KML 导入
--> 赛道 variant
--> 活动绑定
--> release
--> launch
--> 客户端消费
-```
-
-当前重点是:
-
-- 先把对象模型定下来
-- 先让地图、KML、活动三条输入链有正式落点
-- 先让客户端只认发布产物
-
-补充确认:
-
-- 本次接受 backend 采用**增量演进**方式推进
-- 不要求一次性推翻当前已稳定联调的:
- - `Event`
- - `EventRelease`
- - `Session`
- 主链
-
-当前补充确认:
-
-- 生产骨架第一阶段与活动运营域第二阶段第四刀已经完成
-- backend 当前下一步应切到“联调标准化”,而不是继续新增对象层级
+- 活动列表第二刀
+- 新玩家侧功能
+- 奖励 / 成就 / 社交扩展
+- workbench 膨胀成正式后台
+- 更多新对象扩张
+- 复杂后台管理 UI
+- 在单个活动对象里提前塞入复杂多地图 / 多路线组 / 多玩法组合语义
---
-## 2. 本次范围
+## 一句话
-### 2.1 本次必须做
+当前 backend 最重要的事是:
-- 定义并落库以下核心对象:
- - `Place`
- - `MapAsset`
- - `TileRelease`
- - `CourseSet`
- - `CourseVariant`
- - `CourseSource`
- - `Event`
- - `EventPresentation`
- - `ContentBundle`
- - `MapRuntimeBinding`
- - `EventRelease`
-
-- 先让 KML 不再只是文件,而能转成 `CourseVariant`
-- 先让活动不再只是页面概念,而能正式绑定:
- - 展示定义
- - 内容包
- - 运行绑定
- - 发布版本
-
-- 第一阶段优先落以下对象:
- - `Place`
- - `MapAsset`
- - `TileRelease`
- - `CourseSource`
- - `CourseSet`
- - `CourseVariant`
- - `MapRuntimeBinding`
-
-- 第一阶段允许暂缓完整落库:
- - `EventPresentation`
- - `ContentBundle`
- 但对象语义必须先在架构上定清楚
-
-### 2.2 本次先不做
-
-- 奖励系统
-- 社交系统
-- 复杂审核流
-- 完整后台 UI
-- 大而全的素材编排器
-- 高级权限体系
-
----
-
-## 3. 后端对象最小理解
-
-### 3.1 地图运行域
-
-#### `Place`
-- 地点
-- 上层业务对象
-- 一个地点下可有多张地图
-
-#### `MapAsset`
-- 某个地点下的一张地图资源
-- 一张地图可有多个瓦片版本
-
-#### `TileRelease`
-- 某张地图的具体瓦片发布版本
-
-#### `CourseSet`
-- 一组赛道集合
-- 例如:校园顺序赛、校园积分赛
-
-#### `CourseVariant`
-- 一个具体可运行赛道方案
-- 顺序赛 8 点 / 12 点
-- 积分赛 A / B / C 方案
-- 客户端最终只应认这个对象
-
-#### `CourseSource`
-- 原始输入源
-- KML 只是来源,不是最终业务对象
-
-### 3.2 活动运营域
-
-#### `Event`
-- 活动业务对象
-- 默认体验活动和定制活动都属于它
-
-#### `EventPresentation`
-- 活动卡片、详情页、H5 schema
-
-#### `ContentBundle`
-- 图片、音频、动画、文创、结果页资源等内容包
-
-#### `MapRuntimeBinding`
-- 活动运行时绑定哪张地图、哪条赛道、哪套瓦片、哪套配置
-
-#### `EventRelease`
-- 客户端真正消费的活动发布版本
-
----
-
-## 4. 最小关系建议
-
-建议 backend 先按这个关系理解:
-
-- `Place 1 -> N MapAsset`
-- `MapAsset 1 -> N TileRelease`
-- `MapAsset 1 -> N CourseSet`
-- `CourseSet 1 -> N CourseVariant`
-- `CourseVariant N -> 1 CourseSource`
-
-- `Event 1 -> N EventPresentation`
-- `Event 1 -> N ContentBundle`
-- `Event 1 -> N MapRuntimeBinding`
-- `Event 1 -> N EventRelease`
-
-其中:
-
-- `MapRuntimeBinding` 负责引用:
- - `placeId`
- - `mapId`
- - `tileReleaseId`
- - `courseSetId`
- - `courseVariantId`
-
----
-
-## 5. 后端第一阶段建议实施顺序
-
-### 第一步:按增量方式落库最小对象
-
-建议先把表和基础模型定下来。
-
-第一阶段优先级建议:
-
-1. `places`
-2. `map_assets`
-3. `tile_releases`
-4. `course_sources`
-5. `course_sets`
-6. `course_variants`
-7. `map_runtime_bindings`
-
-第二阶段再补:
-
-8. `event_presentations`
-9. `content_bundles`
-
-说明:
-
-- 当前稳定的 `events / event_releases / sessions` 主链保留
-- 本次是在现有骨架上增量补生产对象,不做一次性替换式重构
-
-### 第二步:先打通 KML 导入链
-
-目标:
-
-- 上传 KML
-- 保存 `CourseSource`
-- 解析控制点与起终点
-- 生成一个 `CourseVariant`
-- 归入某个 `CourseSet`
-
-### 第三步:先打通活动绑定链
-
-目标:
-
-- 一个 `Event` 可绑定:
- - `EventPresentation`
- - `ContentBundle`
- - `MapRuntimeBinding`
-
-### 第四步:先打通发布链
-
-目标:
-
-- 生成 `EventRelease`
-- `launch` 先继续返回当前稳定字段:
- - `resolvedRelease`
- - `business`
- - `variant`
-- 第二阶段再补完整运行对象字段:
- - `placeId`
- - `mapId`
- - `tileReleaseId`
- - `courseVariantId`
- - `eventReleaseId`
-
-### 第五步:把第一阶段生产骨架接口接入 `/dev/workbench`
-
-目标:
-
-- 不让第一阶段对象只停留在 API 目录里
-- 在 workbench 里形成最小可操作联调面板
-- 用于验证对象关系和生产闭环,不用于替代正式后台
-
-本步建议只做:
-
-#### A. 地点与地图
-- `Place` 列表
-- 新建 `Place`
-- 在 `Place` 下新建 `MapAsset`
-- 在 `MapAsset` 下新建 `TileRelease`
-- 查看详情
-
-#### B. 赛道与 KML
-- `CourseSource` 列表
-- 新建 `CourseSource`
-- 新建 `CourseSet`
-- 在 `CourseSet` 下新建 `CourseVariant`
-- 查看详情
-
-#### C. 运行绑定
-- `MapRuntimeBinding` 列表
-- 新建 `MapRuntimeBinding`
-- 选择:
- - `place`
- - `map`
- - `tile release`
- - `course variant`
-- 查看详情
-
-本步明确不做:
-
-- 完整后台 UI
-- Event 全量编辑
-- `EventPresentation` 可视化搭建
-- `ContentBundle` 大资源管理台
-- Build / Release 全流程可视化
-- 删除、批量操作、审核流
-
-一句话:
-
-**workbench 当前只做“第一阶段生产骨架联调台”,不做“正式后台管理系统”。**
-
-### 第六步:进入“最小接线”阶段
-
-目标:
-
-- 把 `MapRuntimeBinding` 和当前 `EventRelease` 接起来
-- 让运行对象开始逐步进入 `launch`
-- 保持当前前端稳定链不被打断
-
-本步建议优先做:
-
-#### A. `EventRelease` 接 `MapRuntimeBinding`
-- 在 `EventRelease` 上补 `runtimeBindingId`
-- 查询 `EventRelease` 时可带出最小 `runtime binding` 摘要
-
-#### B. `launch` 新增 `runtime` 摘要块
-- 保留当前稳定字段:
- - `resolvedRelease`
- - `business`
- - `variant`
-- 新增一个兼容性的 `runtime` 块,建议最少返回:
- - `runtimeBindingId`
- - `placeId`
- - `mapId`
- - `tileReleaseId`
- - `courseSetId`
- - `courseVariantId`
-- 如字段成本不高,可附带:
- - `placeName`
- - `mapName`
- - `routeCode`
-
-#### C. `workbench` 最小接线验证
-- 在 `/dev/workbench` 上增加:
- - `EventRelease` 选择或查看
- - 绑定 `runtimeBinding`
- - 查看 release 当前已接入的运行对象摘要
-
-本步明确要求:
-
-- 不修改旧字段语义
-- 不移除旧字段
-- 不让前端现有 `launch` 链断掉
-- 先做到“后端可挂接、可透出、可验证”
-
----
-
-## 6. 当前接口落地方向
-
-当前阶段建议 backend 后续接口逐步收敛到:
-
-### 6.1 生产侧接口
-
-- 创建地点
-- 创建地图
-- 创建瓦片版本记录
-- 上传 KML
-- 生成赛道 variant
-- 创建活动
-- 保存活动展示定义
-- 保存内容包引用
-- 保存运行绑定
-- 创建活动 release
-
-### 6.2 客户端消费接口
-
-- 活动列表
-- 活动详情
-- `launch`
-- session start
-- session finish
-- result
-- history
-
-关键要求:
-
-- 客户端只消费 release 产物
-- 不再消费原始 KML
-- 不再消费地图原始资产
-- `launch` 采用两阶段兼容,不要求第一阶段打断当前前端稳定链
-
-### 6.3 workbench 联调台
-
-backend 下一步建议把以下接口先接到 `/dev/workbench`:
-
-- `Place`
-- `MapAsset`
-- `TileRelease`
-- `CourseSource`
-- `CourseSet`
-- `CourseVariant`
-- `MapRuntimeBinding`
-
-接入目标:
-
-- list
-- create
-- detail
-- binding
-
-不建议当前阶段接入:
-
-- edit
-- delete
-- batch
-- 审核流
-
-### 6.4 最小接线阶段接口方向
-
-backend 下一步建议新增或补齐以下能力:
-
-- `EventRelease` 挂接 `runtimeBindingId`
-- 查询 `EventRelease` 时返回最小运行绑定摘要
-- `launch` 返回新增 `runtime` 摘要块
-
-当前目标不是让前端强依赖新字段,而是先让:
-
-- release 和 runtime binding 接上
-- `launch` 能把运行对象透出来
-- 前后端可以开始验证运行对象链是活的
-
-### 6.5 第四刀:发布闭环阶段
-
-在第三刀已经完成:
-
-- `MapRuntimeBinding -> EventRelease`
-- `launch.runtime` 兼容透出
-
-之后,下一步建议进入真正的**发布闭环阶段**。
-
-目标:
-
-- 不再要求“先 publish,再手工 bind runtime”
-- 改为 publish 时就能直接产出带 `runtimeBindingId` 的完整 `EventRelease`
-- 保持当前旧接口和旧字段完全兼容
-
-本步建议优先做:
-
-#### A. publish/build 接口支持 `runtimeBindingId`
-
-- 在当前发布链中允许显式传入 `runtimeBindingId`
-- 如果传入,则发布完成后直接把 release 绑好 runtime
-- 如果不传入,则继续保持当前兼容行为
-
-#### B. workbench publish 面板接入 runtime 选择
-
-- 在 `/dev/workbench` 的发布操作区增加 `Runtime Binding` 选择
-- 支持一条完整联调链:
- - 选 release source / build
- - 选 `runtimeBindingId`
- - publish
- - 直接 launch 验证
-
-#### C. release 查询继续返回 runtime 摘要
-
-- `Get Release`
-- `launch`
-
-都继续透出当前最小 `runtime` 摘要,供前端和总控验证。
-
-本步关键要求:
-
-- 只加能力,不改旧语义
-- 新流程优先,但旧流程继续可用
-- 发布结果尽量原子,避免漏掉 runtime 挂接
-
----
-
-## 7. 当前需要 backend 重点注意的边界
-
-1. KML 只是输入源,不是最终业务对象
-2. 活动不是素材仓库,活动只引用 `ContentBundle`
-3. 地图上层必须有 `Place`,不要让 `MapAsset` 直接当最上层
-4. 客户端最终必须只认 `EventRelease`
-5. launch 返回必须落到具体:
- - `placeId`
- - `mapId`
- - `tileReleaseId`
- - `courseVariantId`
- - `eventReleaseId`
-
----
-
-## 8. 当前待 backend 回写确认
-
-请 backend 线程后续重点回写以下确认:
-
-1. 第一阶段表结构是否接受这套对象拆分
-2. `Place / MapAsset / TileRelease / CourseSource / CourseSet / CourseVariant / MapRuntimeBinding` 是否按当前顺序推进
-3. KML 导入链是否准备按 `CourseSource -> CourseVariant` 落
-4. 活动是否接受拆成:
- - `Event`
- - `EventPresentation`
- - `ContentBundle`
- - `MapRuntimeBinding`
- - `EventRelease`
-5. `launch` 两阶段兼容方案是否按当前确认推进
-6. workbench 是否按“第一阶段生产骨架联调台”接入,且只做 list / create / detail / binding
-
-本轮新增执行项:
-
-7. 是否按“第三刀最小接线”推进:
- - `MapRuntimeBinding -> EventRelease`
- - `launch.runtime` 摘要透出
- - 继续保持旧字段兼容
-
-8. 是否按“第四刀发布闭环”推进:
- - publish 直接支持 `runtimeBindingId`
- - workbench publish 面板增加 runtime 选择
- - 继续保留“先 publish,再 bind runtime”的兼容路径
-
-9. 是否进入“活动运营域第二阶段”:
- - `EventPresentation` 最小落库
- - `ContentBundle` 最小落库
- - `EventRelease` 明确绑定 `presentation / bundle / runtime`
-
-10. 是否进入“活动运营域第二阶段第二刀”:
- - `event detail` 透出最小 `presentation / bundle` 摘要
- - `release detail` 透出最小 `presentation / bundle / runtime` 摘要
- - `launch` 增加兼容性的 `presentation / contentBundle` 摘要块
- - publish 在未显式传入时允许按 event 当前默认配置自动补齐 `presentation / bundle`
-
----
-
-## 9. 一句话结论
-
-本次给 backend 的实施要求很简单:
-
-**先别继续围绕散装页面和散装配置推进,先把地图运行域和活动运营域的最小骨架搭起来。**
-
-当前下一步重点已经进一步明确为:
-
-**活动运营域第二阶段第三刀第一版已完成,backend 下一步切到“展示定义统一导入与默认绑定”阶段。**
-
-### 6.6 第五刀:前端正式接线阶段
-
-当前 backend 已完成第四刀第一版:
-
-- publish 直接支持 `runtimeBindingId`
-- workbench publish 区支持直接填写 `Runtime Binding ID`
-- 发布成功返回 `runtime`
-- 旧的“先 publish,再 bind runtime”路径继续兼容
-
-因此下一步建议正式进入**前端接线阶段**。
-
-目标:
-
-- 前端开始正式消费 `launch.runtime`
-- 活动准备页、地图页、结果页、历史页开始逐步展示运行对象摘要
-- 继续保持旧字段兼容,不要求一轮切掉老逻辑
-
-前端第一阶段建议优先做:
-
-#### A. `launch.runtime` 消费
-
-- 读取并缓存:
- - `runtimeBindingId`
- - `placeId`
- - `mapId`
- - `tileReleaseId`
- - `courseSetId`
- - `courseVariantId`
-- 如后端已返回名称摘要,也同步接入:
- - `placeName`
- - `mapName`
- - `routeCode`
-
-#### B. 准备页与地图页最小展示
-
-- 在准备页展示当前地点 / 地图 / 赛道摘要
-- 在地图页调试或摘要区透出当前 runtime 对象
-
-#### C. 结果与历史摘要逐步接入
-
-- 先不强改所有列表
-- 优先让单局结果页和历史详情页能看见:
- - `place`
- - `map`
- - `variant`
- - `routeCode`
-
-当前阶段原则:
-
-- 前端正式上场,但只接新增摘要,不推翻现有稳定页面主链
-- `resolvedRelease / business / variant` 旧字段仍继续保留和可用
-- 如果后端某些名称摘要尚未补齐,前端先按 ID + 已有字段兜底
-
-### 6.7 第六刀之后的下一步:活动运营域第二阶段第二刀
-
-当前 backend 已完成:
-
-- `0009_event_ops_phase2.sql`
-- `EventPresentation` 最小落库
-- `ContentBundle` 最小落库
-- `EventRelease` 已可绑定:
- - `presentationId`
- - `bundleId`
- - `runtimeBindingId`
-- publish 已支持显式挂接:
- - `presentationId`
- - `contentBundleId`
- - `runtimeBindingId`
-
-因此下一步建议进入“活动运营域第二阶段第二刀”。目标是:
-
-- 让 `EventPresentation / ContentBundle` 不只存在于后台和 publish 输入里
-- 而是正式进入可查询、可验证、可消费的发布摘要
-
-建议顺序:
-
-#### A. `event detail` 透出当前展示与内容包摘要
-
-建议最少返回:
-
-- `currentPresentation`
- - `presentationId`
- - `templateKey`
- - `version`
-- `currentContentBundle`
- - `bundleId`
- - `bundleType`
- - `version`
-
-#### B. `Get Release` 透出完整最小摘要
-
-建议 `release detail` 同时包含:
-
-- `presentation`
-- `contentBundle`
-- `runtime`
-
-前两者先以摘要形式返回,不先做复杂 schema 下发。
-
-#### C. `launch` 增加兼容性的活动运营摘要块
-
-在保持旧字段与当前 `runtime` 不变的前提下,新增:
-
-- `presentation`
-- `contentBundle`
-
-建议最少包括:
-
-- `presentationId`
-- `templateKey`
-- `bundleId`
-- `bundleType`
-
-#### D. publish 增加默认补齐逻辑
-
-如果 publish 未显式传入:
-
-- `presentationId`
-- `contentBundleId`
-
-允许按 event 当前默认配置自动补齐。
-
-本步明确不建议做:
-
-- 前端立即全面消费 presentation / bundle
-- 复杂活动页面 schema 下发
-- 内容包全量资源编排
-- 正式后台大 UI
-
-### 6.8 活动运营域第二阶段第三刀:发布摘要闭环与内容包导入入口
-
-当前已确认:
-
-- frontend 已完成“活动运营域摘要第一刀”
-- 当前前端进入联调回归与小范围修复阶段
-- backend 下一步不应继续围绕前端页面做字段补丁,而应继续把活动运营域本身做完整
-
-本刀建议拆成两步,但按一个阶段推进。
-
-#### A. 先把 release 摘要闭环
-
-目标:
-
-- 让 `EventRelease` 真正成为活动运营域统一发布产物
-- 保证以下几处返回的摘要语义一致:
- - `event detail`
- - `event play`
- - `launch`
- - `release detail`
-
-建议 `release detail` 最少透出:
-
-- `presentation`
- - `presentationId`
- - `templateKey`
- - `version`
-- `contentBundle`
- - `bundleId`
- - `bundleType`
- - `version`
-- `runtime`
- - `runtimeBindingId`
- - `placeId`
- - `mapId`
- - `tileReleaseId`
- - `courseVariantId`
-
-同时建议 `/dev/workbench` 的 release 查看区,能直接验证这三类摘要。
-
-#### B. 再打开 `ContentBundle` 统一导入入口
-
-目标:
-
-- 不再只手工创建 `ContentBundle`
-- 让后续静态资源、音频、动画、文创等内容,先通过统一导入入口进入 bundle
-
-当前阶段建议只做“入口”和“元信息”,不做完整资源平台。
-
-建议最小能力:
-
-- 新增 `ContentBundle Import` 最小接口
-- 接收:
- - `bundleType`
- - `sourceType`
- - `manifestUrl` 或等价资源清单入口
- - `version`
- - `title`
-- 创建后生成:
- - `bundleId`
- - `bundleType`
- - `version`
- - `assetManifest`
- - `status`
-
-当前明确不做:
-
-- 复杂资源上传工作流
-- 大文件管理台
-- 资源审核流
-- 全量 H5 schema 组装
-
-关键原则:
-
-- `ContentBundle` 先做统一导入入口,不先做复杂资源管理系统
-- frontend 当前不消费资源明细,只继续认摘要
-- 先把“发布对象完整”和“内容资源有正式入口”两件事做起来
-
-### 6.9 活动运营域第二阶段第四刀:展示定义统一导入与默认绑定
-
-当前已确认:
-
-- backend 已完成:
- - `event detail / play / launch / release detail` 活动运营摘要闭环
- - `ContentBundle` 统一导入入口第一版
-- frontend 当前不再扩新页面链,继续联调回归
-
-所以下一步 backend 不建议继续围绕玩家侧摘要补字段,而应继续把活动运营域生产链做完整。
-
-#### A. 打开 `EventPresentation` 统一导入入口
-
-目标:
-
-- 后续外部活动卡片/H5 搭建系统,不再只靠手工创建 presentation
-- 而是通过统一入口把展示定义正式导入 backend
-
-建议最小能力:
-
-- 新增 `EventPresentation Import` 最小接口
-- 接收:
- - `templateKey`
- - `sourceType`
- - `schemaUrl` 或等价 schema 入口
- - `version`
- - `title`
-- 创建后生成:
- - `presentationId`
- - `templateKey`
- - `version`
- - `schema`
- - `status`
-
-#### B. 固化 `Event` 当前默认 active 绑定
-
-目标:
-
-- 把 `Event` 当前默认使用的:
- - `presentation`
- - `contentBundle`
- - `runtimeBinding`
- 三者关系稳定下来
-
-建议至少明确:
-
-- `currentPresentationId`
-- `currentContentBundleId`
-- `currentRuntimeBindingId`
-
-以及 publish 在未显式传入时,默认如何继承这三者。
-
-#### C. `/dev/workbench` 增加最小验证
-
-建议补:
-
-- `Import Presentation`
-- 查看 event 当前 active:
- - presentation
- - bundle
- - runtime
-- 在 publish 区验证默认继承是否正确
-
-当前明确不做:
-
-- 复杂展示编辑器
-- 全量 H5 schema 编排平台
-- 大型资源后台
-
-关键原则:
-
-- `EventPresentation` 和 `ContentBundle` 都要有统一导入入口
-- `Event` 继续做业务壳和默认绑定,不吞大资源
-- 玩家前端继续只认发布摘要,不认后台草稿对象
+**把活动系统最小成品闭环的后台链路稳住,按单地图/单路线组/单玩法先收模型,并配合 frontend 完成第一刀回归与小修。**
diff --git a/t2f.md b/t2f.md
index a47c643..cde01ce 100644
--- a/t2f.md
+++ b/t2f.md
@@ -1,181 +1,126 @@
# T2F 协作清单
-> 文档版本:v1.10
-> 最后更新:2026-04-03 19:26:23
+> 文档版本:v2.1
+> 最后更新:2026-04-07 22:35:00
说明:
-- 本文件由总控维护,写给前端线程
-- 只写当前阶段实施说明,不写长讨论稿
-- 正式架构与长期结论以 `doc/` 下文档为准
+- 本文件由总控维护,写给 frontend 线程
+- 只保留当前阶段信息
+- 历史说明已归档到 [T2F阶段归档](D:/dev/cmr-mini/doc/archive/协作/T2F阶段归档.md)
+- 正式方案以 `doc/` 下文档为准
---
-## 1. 当前目标
+## 当前阶段
-当前前端线程已完成:
+当前 frontend 所处阶段:
-- 活动运营域摘要接线第一刀
+**活动系统最小成品闭环回归与小范围修复阶段**
+
+当前目标:
+
+1. 活动列表可用且稳定
+2. 活动详情页更像用户页
+3. 活动准备页更像用户页
+4. 结果页 / 历史页和活动链自然衔接
+5. 地图体验链与游客体验链进入当前最小成品闭环
+6. 不破坏 runtime 稳定主链
+
+---
+
+## 当前已完成基线
+
+frontend 当前已稳定具备:
+
+- runtime 摘要链第一阶段接线
+- 活动运营域摘要第一刀接线
- 活动卡片列表最小产品化第一刀
-
-当前进入:
-
-**活动卡片列表最小产品化第一刀联调回归与小范围修复阶段**
-
-本阶段目标:
-
-- 在 backend 一键测试环境下回归活动列表页第一刀
-- 验证卡片字段、分组、跳转与详情页链路稳定
-- 只做小范围修复,不扩更多玩家侧新链
-- 继续保持 runtime 主链稳定
+- 独立活动列表页
+- `全部 / 体验` 最小筛选
+- 列表跳活动详情
+- 活动详情页与准备页摘要接线
+- 会话快照接线
+- 地图体验第一刀
+- 游客模式第一刀
+- 准备页地图预览 V1
---
-## 2. 当前后端已完成能力
+## 当前任务
-后端当前已完成:
+### 1. 活动列表第一刀回归与小修
-- `GET /events/{eventPublicID}` 透出:
- - `currentPresentation`
- - `currentContentBundle`
-- `GET /events/{eventPublicID}/play` 透出:
- - `currentPresentation`
- - `currentContentBundle`
-- `POST /events/{eventPublicID}/launch` 透出:
- - `launch.presentation`
- - `launch.contentBundle`
-- publish 在未显式传入:
- - `presentationId`
- - `contentBundleId`
- 时,可按 event 当前 active 配置自动补齐
-- runtime 主链继续保持稳定兼容:
- - `resolvedRelease`
- - `business`
- - `variant`
- - `runtime`
-- backend 当前测试能力已升级:
- - `Bootstrap Demo`
- - `一键补齐 Runtime 并发布`
- - `一键标准回归`
- - `回归结果汇总`
- - `当前 Launch 实际配置摘要`
- - 分步日志 / 真实错误 / stack / 最后一次 curl / 预期判定
- - `POST /dev/client-logs`
+- 校验字段是否够用
+- 校验 `全部 / 体验` 分组是否合理
+- 校验列表到详情页跳转是否稳定
+
+### 1.1 地图体验链回归
+
+- 校验首页 `地图体验` 入口可达
+- 校验地图列表 -> 地图详情 -> 默认体验活动入口链稳定
+- 校验默认体验活动与普通活动衔接自然
+
+### 1.2 游客模式第一刀回归
+
+- 校验登录页 `游客体验` 入口
+- 校验游客走 `/public/...` 链是否稳定
+- 校验游客结果页本地摘要是否自然
+
+### 2. 活动详情页用户化
+
+- 调整信息层级
+- 让活动状态、时间、CTA 更容易理解
+- 不暴露后台复杂性
+
+### 3. 活动准备页用户化
+
+- 保留必要摘要
+- 收工程感
+- 不把过多 runtime/debug 信息直接给玩家
+
+#### 准备页地图预览 V1
+
+作为当前用户化增强项推进,只做:
+
+- 低级别正式瓦片底图
+- 前端动态叠加当前赛道
+- 只读展示
+
+当前不做:
+
+- 拖拽
+- 缩放
+- 复杂交互
+- `summary` 级预览
+- 第二套交互地图
+
+### 4. 结果页 / 历史页活动链衔接
+
+- 让玩家能自然回看活动结果
+- 继续只做小范围修正,不做大重构
+
+### 5. 地图体验 / 游客体验与活动主链衔接
+
+- 保持游客走地图默认体验链
+- 保持登录用户走活动运营链
+- 前台感知上区分清楚,但不要做出两套割裂产品
---
-## 3. 当前已完成
+## 当前不做
-### 3.1 活动详情页
-
-已开始展示:
-
-- `currentPresentation`
- - `presentationId`
- - `templateKey`
- - `version`
-- `currentContentBundle`
- - `bundleId`
- - `bundleType`
- - `version`
-
-当前仍保持活动运营摘要展示,不做复杂运营样式。
-
-### 3.2 活动准备页
-
-已在当前 runtime 预览摘要旁边补活动运营摘要:
-
-- 当前展示版本
-- 当前内容包版本
-
-仍然只做摘要,不重构准备页结构。
-
-### 3.3 launch 会话快照
-
-以下字段已收进当前会话快照:
-
-- `launch.presentation`
-- `launch.contentBundle`
-
-这样后续结果页、历史页如果需要继续透出,就不需要重新拼接。
-
-### 3.4 当前阶段仍不做
-
-- 不下发复杂 `schema`
-- 不消费完整 `EventPresentation` 结构
-- 不把 `ContentBundle` 展开成资源明细
-- 不重构首页、结果页、历史页已有结构
-- 不做复杂运营化列表
-- 不重做首页现有入口区
-
-### 3.5 当前活动列表第一刀已完成
-
-当前已落地:
-
-1. 独立活动列表页:`/pages/events/events`
-2. 最小卡片样式
-3. 最小筛选:`全部 / 体验`
-4. 从列表跳活动详情页
-5. 首页补“活动列表”独立入口
-
-当前第一刀最小字段已覆盖:
-
-- `eventId`
-- `title`
-- `subtitle`
-- `summary`
-- `status`
-- `timeWindow`
-- `ctaText`
-- `coverUrl`
-- `isDefaultExperience`
-- `currentPresentation`
-- `currentContentBundle`
+- 活动列表第二刀扩展
+- 首页大改版
+- 新玩家入口扩张
+- 复杂运营样式
+- 新玩家功能扩张
+- 地图体验第二刀
+- 游客模式第二刀
---
-## 4. 当前阶段原则
+## 一句话
-- 玩家面对的是前端,前端页面必须保持干净、利落、人性化
-- 先接新增摘要,不重构整条前端主链
-- `resolvedRelease / business / variant` 旧字段继续保留
-- runtime 主链已经稳定,不要为了活动运营摘要去动 runtime 主链
-- 先做“看得见活动运营对象”,不先做复杂运营化样式
-- 当前活动列表第一刀允许扩一个独立列表页,但不扩更多玩家侧新链
-- 当前联调应优先复用 backend 一键测试环境,不再各自手工铺多份 demo 对象
-- 当前联调应优先复用 backend 提供的结构化诊断链,不再依赖截图 + 口头描述排查
+当前 frontend 最重要的事是:
----
-
-## 5. 当前待前端回写
-
-请前端线程后续重点回写:
-
-1. 列表字段是否足够支持当前最小卡片
-2. `全部 / 体验` 分组是否符合当前产品预期
-3. 卡片点击进入活动详情页是否稳定
-4. 是否需要 backend 再补名称摘要、状态字段或默认值
-5. 有没有因为活动列表接线影响到 runtime 稳定主链
-
----
-
-## 6. 当前总控确认
-
-1. 活动运营域摘要第一刀视为已完成
-2. 前端当前进入联调回归与小范围修复阶段
-3. 当前只接受字段修正、摘要打磨、一致性修复
-4. 当前不继续扩更多玩家侧新链,不做复杂运营样式
-5. 如果前端发现缺字段,再由总控统一回写给 backend
-6. 当前前端下一步重点是配合 backend 的一键测试环境做稳定回归
-7. 当前前端继续只做:
- - 联调回归
- - 小范围修复
- - 结构化日志补充
-8. 当前活动列表第一刀已完成,暂不进入第二刀产品扩展
-
----
-
-## 7. 一句话结论
-
-当前前端最重要的事不是继续扩新页面,而是:
-
-**把活动卡片列表最小产品化第一刀先稳住,并统一切到 backend 一键测试环境下做联调回归和小范围修复。**
+**把活动列表、地图体验、游客体验和相关活动链页面一起收顺,从工程态过渡到用户态。**
diff --git a/t2w.md b/t2w.md
new file mode 100644
index 0000000..098e3e5
--- /dev/null
+++ b/t2w.md
@@ -0,0 +1,82 @@
+# T2W 协作清单
+> 文档版本:v1.1
+> 最后更新:2026-04-07 11:43:47
+
+说明:
+
+- 本文件由总控维护,写给网站线程
+- 只写当前阶段实施说明,不写长讨论稿
+- 网站正式方案以 [colormaprun网站重构方案](D:/dev/cmr-mini/doc/gameplay/colormaprun网站重构方案.md) 为准
+
+---
+
+## 1. 当前目标
+
+网站线程当前目标不是直接全面重做,而是先完成:
+
+**网站重构第一阶段方案化与最小产品拆解。**
+
+要求:
+
+1. 明确首页分流结构
+2. 明确活动门户最小结构
+3. 明确“办活动 / 区域合作”两条转化路径
+4. 不与当前小程序活动系统主线打架
+
+---
+
+## 2. 当前阶段边界
+
+网站线程当前只做:
+
+- 信息架构
+- 页面结构方案
+- 产品层拆解
+- 必要时的最小字段/数据依赖清单
+
+当前不做:
+
+- 大规模正式开发
+- 全量 CMS/后台系统设计
+- 复杂 SEO 执行细节
+- 与小程序主产品线无关的发散功能
+
+---
+
+## 3. 当前优先顺序
+
+1. 首页分流方案
+2. 活动列表/详情门户方案
+3. 办活动页方案
+4. 区域合作页方案
+5. 地图体验入口方案
+
+---
+
+## 4. 当前阶段原则
+
+- 玩家用前端
+- 管理者用后端
+- 网站承担品牌、活动门户、地图体验入口和商务转化
+- 不把后台复杂性直接暴露给用户
+- 网站与小程序应在活动对象和默认活动逻辑上保持一致
+
+---
+
+## 5. 当前待网站线程回写
+
+请网站线程后续重点回写:
+
+1. 首页分流方案是否清晰
+2. 活动门户最小页面结构
+3. 办活动页和合作页的转化设计
+4. 网站后续和活动生产系统的最小接入点
+5. 哪些内容需要真实案例、真实文案或真实素材支持
+
+---
+
+## 6. 一句话结论
+
+当前网站线程最重要的事不是立刻开做全部页面,而是:
+
+**先把 `colormaprun.com` 重构成“品牌官网 + 活动门户 + 地图体验入口 + 商务转化站”的最小方案拆清楚。**
diff --git a/tmp/route01.kml b/tmp/route01.kml
new file mode 100644
index 0000000..db6b20f
--- /dev/null
+++ b/tmp/route01.kml
@@ -0,0 +1,69 @@
+
+
+
+
+
+
+路线01
+
+
+ 1
+
+ 117.000649296107,36.5921631022497
+
+
+
+ 2
+
+ 116.999689737459,36.5922740961347
+
+
+
+ 3
+
+ 116.999108309973,36.5919019395375
+
+
+
+ 4
+
+ 116.999823913032,36.591572220351
+
+
+
+ 5
+
+ 116.999860506371,36.5912131186443
+
+
+
+ 6
+
+ 117.000340285695,36.5909356298175
+
+
+
+ 7
+
+ 117.000441933857,36.5915004001434
+
+
+
+ 8
+
+ 117.001397426578,36.5915983367736
+
+
+
+ 9
+
+ 117.000665559813,36.5919574366878
+
+
+
+ 10
+
+ 117.000649296107,36.5921631022497
+
+
+
diff --git a/tmp/route02.kml b/tmp/route02.kml
new file mode 100644
index 0000000..2de5d3d
--- /dev/null
+++ b/tmp/route02.kml
@@ -0,0 +1,69 @@
+
+
+
+
+
+
+路线02
+
+
+ 1
+
+ 117.000649296107,36.5921631022497
+
+
+
+ 2
+
+ 116.999766990062,36.5921141343085
+
+
+
+ 3
+
+ 116.999710067091,36.5917615642144
+
+
+
+ 4
+
+ 116.998774904002,36.5913306430232
+
+
+
+ 5
+
+ 116.998941606987,36.5908278985923
+
+
+
+ 6
+
+ 117.00058830721,36.5905340853955
+
+
+
+ 7
+
+ 117.000238637533,36.5914742836876
+
+
+
+ 8
+
+ 117.000937976887,36.5916113949816
+
+
+
+ 9
+
+ 117.000803801313,36.5919411140006
+
+
+
+ 10
+
+ 117.000649296107,36.5921631022497
+
+
+
diff --git a/tmp/route03.kml b/tmp/route03.kml
new file mode 100644
index 0000000..3c90add
--- /dev/null
+++ b/tmp/route03.kml
@@ -0,0 +1,111 @@
+
+
+
+
+
+
+
+ 01
+
+
+
+ 1
+
+
+
+ 117.000649296107,36.5921631022497
+
+
+
+
+
+ 2
+
+
+
+ 117.000665559813,36.5919574366878
+
+
+
+
+
+ 3
+
+
+
+ 117.001397426578,36.5915983367736
+
+
+
+
+
+ 4
+
+
+
+ 117.000441933857,36.5915004001434
+
+
+
+
+
+ 5
+
+
+
+ 117.000340285695,36.5909356298175
+
+
+
+
+
+ 6
+
+
+
+ 116.999860506371,36.5912131186443
+
+
+
+
+
+ 7
+
+
+
+ 116.999823913032,36.591572220351
+
+
+
+
+
+ 8
+
+
+
+ 116.999108309973,36.5919019395375
+
+
+
+
+
+ 9
+
+
+
+ 116.999689737459,36.5922740961347
+
+
+
+
+
+ 10
+
+
+
+ 117.000649296107,36.5921631022497
+
+
+
+
+
\ No newline at end of file
diff --git a/tmp/route04.kml b/tmp/route04.kml
new file mode 100644
index 0000000..a56520f
--- /dev/null
+++ b/tmp/route04.kml
@@ -0,0 +1,111 @@
+
+
+
+
+
+
+
+ 01
+
+
+
+ 1
+
+
+
+ 117.000649296107,36.5921631022497
+
+
+
+
+
+ 2
+
+
+
+ 117.000803801313,36.5919411140006
+
+
+
+
+
+ 3
+
+
+
+ 117.000937976887,36.5916113949816
+
+
+
+
+
+ 4
+
+
+
+ 117.000238637533,36.5914742836876
+
+
+
+
+
+ 5
+
+
+
+ 117.00058830721,36.5905340853955
+
+
+
+
+
+ 6
+
+
+
+ 116.998941606987,36.5908278985923
+
+
+
+
+
+ 7
+
+
+
+ 116.998774904002,36.5913306430232
+
+
+
+
+
+ 8
+
+
+
+ 116.999710067091,36.5917615642144
+
+
+
+
+
+ 9
+
+
+
+ 116.999766990062,36.5921141343085
+
+
+
+
+
+ 10
+
+
+
+ 117.000649296107,36.5921631022497
+
+
+
+
+
\ No newline at end of file
diff --git a/tsconfig.json b/tsconfig.json
index 5a7a0f9..f9d7e2c 100644
--- a/tsconfig.json
+++ b/tsconfig.json
@@ -28,6 +28,7 @@
"./**/*.ts"
],
"exclude": [
- "node_modules"
+ "node_modules",
+ "www"
]
}
diff --git a/w2t.md b/w2t.md
new file mode 100644
index 0000000..84853d7
--- /dev/null
+++ b/w2t.md
@@ -0,0 +1,33 @@
+# W2T 协作清单
+> 文档版本:v1.0
+> 最后更新:2026-04-07 10:32:36
+
+说明:
+
+- 本文件由网站线程维护,写给总控线程
+- 用于记录网站重构线程当前已完成内容、阻塞和下一步建议
+- 采用与其他协作文档一致的固定结构,避免写成长讨论稿
+
+---
+
+## 待确认
+
+- 暂无
+
+## 已确认
+
+- 网站线程已建立
+- 当前总控到网站线程使用 [t2w.md](D:/dev/cmr-mini/t2w.md)
+- 网站线程到总控使用 [w2t.md](D:/dev/cmr-mini/w2t.md)
+
+## 阻塞
+
+- 暂无
+
+## 已完成
+
+- 暂无
+
+## 下一步
+
+- 按 [colormaprun网站重构方案](D:/dev/cmr-mini/doc/gameplay/colormaprun网站重构方案.md) 推进首页分流与活动门户最小方案