推进活动系统最小成品闭环与游客体验

This commit is contained in:
2026-04-07 19:05:18 +08:00
parent 1a6008449e
commit 6cd16f08dd
102 changed files with 16087 additions and 3556 deletions

500
b2b.md Normal file
View File

@@ -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 已经把:
- 调试后台
- 游客模式
- 默认体验地图接口
- 运维后台第一版骨架
都立起来了。
接下来最该继续的不是扩新对象,而是把:
**地图 / 地点管理 -> 路线资源管理 -> 活动管理 / 编排 -> 发布中心**
这条运维主流程,按“列表 -> 详情 -> 弹层 / 分步”的方式做扎实。

1073
b2f.md

File diff suppressed because it is too large Load Diff

970
b2t.md

File diff suppressed because it is too large Load Diff

View File

@@ -1,12 +1,67 @@
# Backend # Backend
> 文档版本v1.22 > 文档版本v1.41
> 最后更新2026-04-03 18:56:46 > 最后更新2026-04-07 18:15:01
这套后端现在已经能支撑一条完整主链: 这套后端现在已经能支撑一条完整主链:
`entry -> auth -> home/cards -> event play -> launch -> session -> result` `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` - 业务对象是 `event`
@@ -29,6 +84,16 @@
- `presentation` - `presentation`
- `content bundle` - `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 实际配置摘要”仅用于调试: 当前 workbench 里新增的“当前 Launch 实际配置摘要”仅用于调试:
@@ -54,20 +119,84 @@
当前 demo 真实输入第一刀也已经接入: 当前 demo 真实输入第一刀也已经接入:
- workbench 的玩法切换会自动填入 backend 内置的: - workbench 的玩法切换会自动填入 backend 内置的:
- `game manifest`
- `presentation schema` - `presentation schema`
- `content manifest` - `content manifest`
- 这些 demo 资源通过 backend 提供的 dev 路由读取: - 这些 demo 资源通过 backend 提供的 dev 路由读取:
- `GET /dev/demo-assets/manifests/{demoKey}`
- `GET /dev/demo-assets/presentations/{demoKey}` - `GET /dev/demo-assets/presentations/{demoKey}`
- `GET /dev/demo-assets/content-manifests/{demoKey}` - `GET /dev/demo-assets/content-manifests/{demoKey}`
当前 workbench 的 `Bootstrap` 语义也已经拆开: 当前 workbench 的 `Bootstrap` 语义也已经拆开:
- `Bootstrap Demo只准备数据` - `Bootstrap Demo只准备数据`
- 只准备 demo 测试数据,不额外重新发布当前玩法 - 只准备 demo 测试数据和基础已发布态,不额外重新发布当前玩法
- `Bootstrap + 发布当前玩法` - `Bootstrap + 发布当前玩法`
- 先准备 demo再对当前选中的玩法执行一遍“发布活动配置自动补 Runtime - 先准备 demo再对当前选中的玩法执行一遍“发布活动配置自动补 Runtime
- 这两条路由只服务联调,不进入正式客户端发布链 - 这两条路由只服务联调,不进入正式客户端发布链
- 当前三条标准 demo 在 `Bootstrap Demo只准备数据` 后都会直接具备:
- 当前 release
- runtime
- presentation
- content bundle
- 也就是说frontend 当前从首页选顺序赛、积分赛、多赛道时,已经可以直接走“当前已发布 release”语义联调入口、详情和 `canLaunch`
- 当前联调样例文案也已从 `Demo ...` 收口为中文活动样例,便于前端和总控直接对口排查 - 当前联调样例文案也已从 `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 当前活动卡片列表最小产品化第一刀也已经进入 backend
@@ -96,6 +225,29 @@
- `isDefaultExperience` 当前由卡片显式字段控制 - `isDefaultExperience` 当前由卡片显式字段控制
- `timeWindow / ctaText` 当前先按后端派生规则提供,允许后续继续演进 - `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) - [文档索引](D:/dev/cmr-mini/backend/docs/README.md)

View File

@@ -1,6 +1,6 @@
# Backend Docs # Backend Docs
> 文档版本v1.0 > 文档版本v1.1
> 最后更新2026-04-02 08:28:05 > 最后更新2026-04-07 18:47:09
这套文档服务两个目的: 这套文档服务两个目的:
@@ -20,6 +20,7 @@
8. [前后端联调清单](D:/dev/cmr-mini/backend/docs/前后端联调清单.md) 8. [前后端联调清单](D:/dev/cmr-mini/backend/docs/前后端联调清单.md)
9. [TodoList](D:/dev/cmr-mini/backend/docs/todolist.md) 9. [TodoList](D:/dev/cmr-mini/backend/docs/todolist.md)
10. [开发说明](D:/dev/cmr-mini/backend/docs/开发说明.md) 10. [开发说明](D:/dev/cmr-mini/backend/docs/开发说明.md)
11. [B2B 交接文档](D:/dev/cmr-mini/b2b.md)
## 当前系统范围 ## 当前系统范围

View File

@@ -1,6 +1,6 @@
# 后台管理最小方案 # 后台管理最小方案
> 文档版本v1.1 > 文档版本v1.8
> 最后更新2026-04-03 11:02:42 > 最后更新2026-04-07 17:23:15
## 1. 目标 ## 1. 目标
@@ -176,16 +176,16 @@
## 4. 后台第一版页面建议 ## 4. 后台第一版页面建议
最小闭环,建议先做 6 个页面 当前运维工作流,建议先按“地图主流程”组织,而不是把底层对象散着摆
1. 地图列表页 1. 地图列表页
2. 赛场 / KML 列表 2. 地图详情
3. 资源包列表 3. KML / 赛道管理
4. Event 列表与编辑 4. 活动管理
5. Build / Release 列表页 5. 发布中心
6. 发布详情 6. 资源总览
这 6 页把“资源录入 -> Event 组装 -> 发布 -> launch”跑通。 这 6 页的目标是把“资源录入 -> 地图管理 -> 赛道管理 -> 活动绑定 -> 发布”跑通。
补充: 补充:
@@ -216,8 +216,112 @@
- `event` 只做引用和少量覆盖 - `event` 只做引用和少量覆盖
- `release` 固化具体版本引用 - `release` 固化具体版本引用
### 5.1 当前活动模型收口原则
为了让前台卡片、详情页、发布语义和运维操作都保持简单,当前活动模型先明确收成最小可玩单元:
- `单地图`
- `单路线组`
- `单玩法`
也就是第一阶段一个活动只表达一件事:
> 在这张地图上,使用这组路线,按这一个玩法运行。
当前不建议第一阶段直接支持:
- 一个活动绑定多张地图
- 一个活动绑定多组路线
- 一个活动同时支持多玩法
复杂需求先通过“活动实例化”解决,而不是在一个活动里做多对多编排。例如:
- 地图 A + 路线组 1 + 顺序赛 = 活动 A1
- 地图 A + 路线组 2 + 顺序赛 = 活动 A2
- 地图 A + 路线组 2 + 积分赛 = 活动 A3
这样做的目的:
- 前台卡片逻辑简单
- 发布语义明确
- 结果追溯简单
- 运维后台第一期不需要一开始就做复杂配置器
后续如果确实需要复杂组合,优先考虑:
- 基于模板批量实例化多个活动
而不是直接把单个活动扩成多地图、多路线组、多玩法容器。
### 5.2 组合入口层原则
多地图、多路线组、多玩法这类需求后面仍然会存在,但当前建议放在“组合入口层”解决,不直接进入单个活动的运行模型。
也就是分成两层:
1. 运行实例层
- 一个活动实例始终保持:
- `单地图`
- `单路线组`
- `单玩法`
2. 组合入口层
- 通过组合卡片或组合入口,把多个活动实例编排成一个前台入口
- 例如:
- 多地图合集
- 多玩法合集
- 不同难度合集
- 同主题活动合集
这样做的目的:
- 前台入口可以灵活组合
- backend 的发布、回溯、结果沉淀仍然保持简单
- 运维后台第一期不需要一开始就做复杂多对多编排器
- 后面若要扩能力,也是在“组合层”扩,而不是把底层活动模型搞乱
## 6. 一条完整后台工作流 ## 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 ```mermaid
flowchart LR flowchart LR
A["录入地图版本"] --> D["Event 选择地图版本"] A["录入地图版本"] --> D["Event 选择地图版本"]
@@ -327,7 +431,21 @@ flowchart LR
2. `Event` 组装接口 `/admin/events``/admin/events/{id}/source` 已完成 2. `Event` 组装接口 `/admin/events``/admin/events/{id}/source` 已完成
3. `pipeline/build/publish` 后台聚合接口已完成 3. `pipeline/build/publish` 后台聚合接口已完成
4. `rollback` 已完成 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. 一句话结论 ## 10. 一句话结论

View File

@@ -1,6 +1,6 @@
# 开发说明 # 开发说明
> 文档版本v1.25 > 文档版本v1.45
> 最后更新2026-04-03 18:56:46 > 最后更新2026-04-07 18:15:01
## 1. 环境变量 ## 1. 环境变量
@@ -18,6 +18,50 @@
- `WECHAT_MINI_APP_ID` - `WECHAT_MINI_APP_ID`
- `WECHAT_MINI_APP_SECRET` - `WECHAT_MINI_APP_SECRET`
- `WECHAT_MINI_DEV_PREFIX` - `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` - `LOCAL_EVENT_DIR`
- `ASSET_BASE_URL` - `ASSET_BASE_URL`
- `ASSET_PUBLIC_BASE_URL` - `ASSET_PUBLIC_BASE_URL`
@@ -39,17 +83,86 @@ cd D:\dev\cmr-mini\backend
.\scripts\start-dev.ps1 .\scripts\start-dev.ps1
``` ```
开发环境补充:
- 运维后台入口:`/admin/ops-workbench`
- 运维接口前缀:`/ops/admin/*`
-`APP_ENV != production` 时:
- 缺少 token 会直接进入 dev ops 上下文
- 残留旧 token、玩家 token、失效 token 也会自动回退到 dev ops 上下文
- 目的是避免开发联调时每次都要重新登录
## 3. Workbench 当前重点 ## 3. Workbench 当前重点
- 推荐联调入口: - 推荐联调入口:
- `Bootstrap Demo` - `Bootstrap Demo(只准备数据)`
- `Bootstrap + 发布当前玩法`
- `Use Classic Demo / Use Score-O Demo / Use Manual Variant Demo` - `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`,还会自动切换: - 当前玩法切换除了切 `event / release / source / build`,还会自动切换:
- `presentation schema` - `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` - `content manifest`
- `asset manifest` - `asset manifest`
- 这些 demo 资源现在由 backend 提供,避免继续在 workbench 里保留 `example.com` 占位地址: - 这些 demo 资源现在由 backend 提供,避免继续在 workbench 里保留 `example.com` 占位地址:
- `GET /dev/demo-assets/manifests/{demoKey}`
- `GET /dev/demo-assets/presentations/{demoKey}` - `GET /dev/demo-assets/presentations/{demoKey}`
- `GET /dev/demo-assets/content-manifests/{demoKey}` - `GET /dev/demo-assets/content-manifests/{demoKey}`
- 如果 frontend 需要把页面侧调试日志直接打到 backend优先使用 - 如果 frontend 需要把页面侧调试日志直接打到 backend优先使用
@@ -66,6 +179,14 @@ cd D:\dev\cmr-mini\backend
- `game.mode` - `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 调试 - 这块摘要由 backend 代读 manifest只用于 workbench 调试
- 这样做是为了避免浏览器直接读取 OSS 时受跨域影响 - 这样做是为了避免浏览器直接读取 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. 活动卡片列表最小摘要 ## 4. 活动卡片列表最小摘要
@@ -649,6 +880,48 @@ Redis 后面只在需要性能优化、限流或短期票据缓存时再接。
- `business` - `business`
- `variant` - `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. 当前后续开发建议 ## 7. 当前后续开发建议
文档整理完之后,后面建议按这个顺序继续: 文档整理完之后,后面建议按这个顺序继续:

View File

@@ -1,10 +1,14 @@
# API 清单 # API 清单
> 文档版本v1.12 > 文档版本v1.23
> 最后更新2026-04-03 22:34:08 > 最后更新2026-04-07 16:08:37
本文档只记录当前 backend 已实现接口,不写未来规划接口。 本文档只记录当前 backend 已实现接口,不写未来规划接口。
当前已实现接口总数:
- `115`
## 1. Health ## 1. Health
### `GET /healthz` ### `GET /healthz`
@@ -13,6 +17,22 @@
- 健康检查 - 健康检查
## 0. Workbench
### `GET /dev/workbench`
用途:
- 调试工作台
- 一键回归、日志查看、摘要排查
### `GET /admin/ops-workbench`
用途:
- 运维后台第一期页面
- 资源录入、OSS 纳管、地图瓦片导入、KML 批量导入
## 2. Auth ## 2. Auth
### `POST /auth/sms/send` ### `POST /auth/sms/send`
@@ -75,6 +95,264 @@
- 撤销 refresh token - 撤销 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 ## 3. Entry / Home
### `GET /entry/resolve` ### `GET /entry/resolve`
@@ -88,6 +366,50 @@
- `channelCode` - `channelCode`
- `channelType` - `channelType`
- `platformAppId` - `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` - `tenantCode`
### `GET /home` ### `GET /home`
@@ -113,6 +435,55 @@
- 只返回卡片列表 - 只返回卡片列表
- 当前与 `/home` 使用同一套卡片摘要语义 - 当前与 `/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` ### `GET /me/entry-home`
鉴权: 鉴权:
@@ -153,6 +524,7 @@
- `release` - `release`
- `resolvedRelease` - `resolvedRelease`
- `runtime` - `runtime`
- `preview`
- `currentPresentation` - `currentPresentation`
- `currentContentBundle` - `currentContentBundle`
@@ -165,6 +537,18 @@
- `currentContentBundle.bundleType` - `currentContentBundle.bundleType`
- `currentContentBundle.version` - `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` ### `GET /events/{eventPublicID}/play`
鉴权: 鉴权:
@@ -181,6 +565,7 @@
- `release` - `release`
- `resolvedRelease` - `resolvedRelease`
- `runtime` - `runtime`
- `preview`
- `currentPresentation` - `currentPresentation`
- `currentContentBundle` - `currentContentBundle`
- `play.assignmentMode` - `play.assignmentMode`
@@ -209,6 +594,8 @@
- `currentContentBundle.bundleType` - `currentContentBundle.bundleType`
- `currentContentBundle.version` - `currentContentBundle.version`
当前 `preview` 继续表示准备页地图预览 V1 只读增强字段,结构与 `GET /events/{eventPublicID}` 相同。
### `POST /events/{eventPublicID}/launch` ### `POST /events/{eventPublicID}/launch`
鉴权: 鉴权:
@@ -665,6 +1052,9 @@
- 当前是后台第一版的最小对象接口 - 当前是后台第一版的最小对象接口
- 先只做 Bearer 鉴权 - 先只做 Bearer 鉴权
- 暂未接正式 RBAC / 管理员权限模型 - 暂未接正式 RBAC / 管理员权限模型
- 运维后台当前已开始切到独立前缀:
- `/ops/admin/*`
- 这批接口在运维后台中与 `/admin/*` 使用同一套 handler语义一致
### `GET /admin/maps` ### `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` ### `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` ### `GET /home`

View File

@@ -1,8 +1,8 @@
# 数据模型 # 数据模型
> 文档版本v1.4 > 文档版本v1.6
> 最后更新2026-04-03 22:34:08 > 最后更新2026-04-07 16:29:08
当前 migration 共 11 版。 当前 migration 共 15 版。
## 1. 迁移清单 ## 1. 迁移清单
@@ -17,6 +17,20 @@
- [0009_event_ops_phase2.sql](D:/dev/cmr-mini/backend/migrations/0009_event_ops_phase2.sql) - [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) - [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) - [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. 表分组 ## 2. 表分组
@@ -50,6 +64,7 @@
- `mobile` - `mobile`
- `wechat_mini_openid` - `wechat_mini_openid`
- `wechat_unionid` - `wechat_unionid`
- `guest`
### 2.3 业务对象与配置发布 ### 2.3 业务对象与配置发布

View File

@@ -1,6 +1,6 @@
# 资源对象与目录方案 # 资源对象与目录方案
> 文档版本v1.0 > 文档版本v1.3
> 最后更新2026-04-02 08:28:05 > 最后更新2026-04-07 13:13:19
本文档用于把“地图复用、KML 复用、内容资源复用、配置发布”统一收成一套后端可执行方案。 本文档用于把“地图复用、KML 复用、内容资源复用、配置发布”统一收成一套后端可执行方案。
@@ -13,6 +13,20 @@
-`release` 能稳定追溯当时到底用了哪一份地图、哪一份 KML、哪一套资源包 -`release` 能稳定追溯当时到底用了哪一份地图、哪一份 KML、哪一套资源包
- 让同一套资源对象既能服务小程序,也能服务未来 APP - 让同一套资源对象既能服务小程序,也能服务未来 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. 设计结论 ## 1. 设计结论
@@ -379,6 +393,10 @@ build / publish 时Go 中间层应做装配:
```text ```text
gotomars/maps/{mapCode}/{version}/... 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/playfields/{playfieldCode}/{version}/...
gotomars/resource-packs/{packCode}/{version}/... gotomars/resource-packs/{packCode}/{version}/...
gotomars/game-modes/{modeCode}/{version}/mode.json gotomars/game-modes/{modeCode}/{version}/mode.json
@@ -394,6 +412,16 @@ gotomars/event-releases/{eventPublicID}/{releasePublicID}/asset-index.json
- 同一个 map / KML 修复时不会污染所有旧 release - 同一个 map / KML 修复时不会污染所有旧 release
- APP 与小程序可共用相同资源版本,不必重复发两套发布目录 - 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. 数据库建模建议 ## 8. 数据库建模建议

View File

@@ -26,6 +26,7 @@ func New(ctx context.Context, cfg Config) (*App, error) {
store := postgres.NewStore(pool) store := postgres.NewStore(pool)
jwtManager := jwtx.NewManager(cfg.JWTIssuer, cfg.JWTAccessSecret, cfg.JWTAccessTTL) jwtManager := jwtx.NewManager(cfg.JWTIssuer, cfg.JWTAccessSecret, cfg.JWTAccessTTL)
wechatMiniClient := wechatmini.NewClient(cfg.WechatMiniAppID, cfg.WechatMiniSecret, cfg.WechatMiniDevPrefix) 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{ authService := service.NewAuthService(service.AuthSettings{
AppEnv: cfg.AppEnv, AppEnv: cfg.AppEnv,
RefreshTTL: cfg.RefreshTTL, RefreshTTL: cfg.RefreshTTL,
@@ -37,21 +38,32 @@ func New(ctx context.Context, cfg Config) (*App, error) {
}, store, jwtManager) }, store, jwtManager)
entryService := service.NewEntryService(store) entryService := service.NewEntryService(store)
entryHomeService := service.NewEntryHomeService(store) entryHomeService := service.NewEntryHomeService(store)
adminAssetService := service.NewAdminAssetService(store, cfg.AssetBaseURL, assetPublisher)
adminResourceService := service.NewAdminResourceService(store) adminResourceService := service.NewAdminResourceService(store)
adminProductionService := service.NewAdminProductionService(store) adminProductionService := service.NewAdminProductionService(store)
adminEventService := service.NewAdminEventService(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) eventService := service.NewEventService(store)
eventPlayService := service.NewEventPlayService(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) configService := service.NewConfigService(store, cfg.LocalEventDir, cfg.AssetBaseURL, assetPublisher)
adminPipelineService := service.NewAdminPipelineService(store, configService) adminPipelineService := service.NewAdminPipelineService(store, configService)
homeService := service.NewHomeService(store) homeService := service.NewHomeService(store)
mapExperienceService := service.NewMapExperienceService(store)
publicExperienceService := service.NewPublicExperienceService(store, mapExperienceService, eventService)
profileService := service.NewProfileService(store) profileService := service.NewProfileService(store)
resultService := service.NewResultService(store) resultService := service.NewResultService(store)
sessionService := service.NewSessionService(store) sessionService := service.NewSessionService(store)
devService := service.NewDevService(cfg.AppEnv, store) devService := service.NewDevService(cfg.AppEnv, store)
meService := service.NewMeService(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{ return &App{
router: router, router: router,

View File

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

View File

@@ -25,6 +25,15 @@ func (h *AdminProductionHandler) ListPlaces(w http.ResponseWriter, r *http.Reque
httpx.WriteJSON(w, http.StatusOK, map[string]any{"data": result}) 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) { func (h *AdminProductionHandler) CreatePlace(w http.ResponseWriter, r *http.Request) {
var req service.CreateAdminPlaceInput var req service.CreateAdminPlaceInput
if err := httpx.DecodeJSON(r, &req); err != nil { 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}) 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) { func (h *AdminProductionHandler) CreateTileRelease(w http.ResponseWriter, r *http.Request) {
var req service.CreateAdminTileReleaseInput var req service.CreateAdminTileReleaseInput
if err := httpx.DecodeJSON(r, &req); err != nil { 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}) 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) { func (h *AdminProductionHandler) GetRuntimeBinding(w http.ResponseWriter, r *http.Request) {
result, err := h.service.GetRuntimeBinding(r.Context(), r.PathValue("runtimeBindingPublicID")) result, err := h.service.GetRuntimeBinding(r.Context(), r.PathValue("runtimeBindingPublicID"))
if err != nil { if err != nil {

File diff suppressed because it is too large Load Diff

View File

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

View File

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

View File

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

File diff suppressed because it is too large Load Diff

View File

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

View File

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

View File

@@ -17,6 +17,7 @@ const authKey authContextKey = "auth"
type AuthContext struct { type AuthContext struct {
UserID string UserID string
UserPublicID string UserPublicID string
RoleCode string
} }
func NewAuthMiddleware(jwtManager *jwtx.Manager) func(http.Handler) http.Handler { 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")) httpx.WriteError(w, apperr.New(http.StatusUnauthorized, "invalid_token", "invalid access token"))
return 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{ ctx := context.WithValue(r.Context(), authKey, &AuthContext{
UserID: claims.UserID, UserID: claims.UserID,
UserPublicID: claims.UserPublicID, UserPublicID: claims.UserPublicID,
RoleCode: claims.RoleCode,
}) })
next.ServeHTTP(w, r.WithContext(ctx)) next.ServeHTTP(w, r.WithContext(ctx))
}) })

View File

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

View File

@@ -13,16 +13,21 @@ func NewRouter(
appEnv string, appEnv string,
jwtManager *jwtx.Manager, jwtManager *jwtx.Manager,
authService *service.AuthService, authService *service.AuthService,
opsAuthService *service.OpsAuthService,
opsSummaryService *service.OpsSummaryService,
entryService *service.EntryService, entryService *service.EntryService,
entryHomeService *service.EntryHomeService, entryHomeService *service.EntryHomeService,
adminAssetService *service.AdminAssetService,
adminResourceService *service.AdminResourceService, adminResourceService *service.AdminResourceService,
adminProductionService *service.AdminProductionService, adminProductionService *service.AdminProductionService,
adminEventService *service.AdminEventService, adminEventService *service.AdminEventService,
adminPipelineService *service.AdminPipelineService, adminPipelineService *service.AdminPipelineService,
eventService *service.EventService, eventService *service.EventService,
eventPlayService *service.EventPlayService, eventPlayService *service.EventPlayService,
publicExperienceService *service.PublicExperienceService,
configService *service.ConfigService, configService *service.ConfigService,
homeService *service.HomeService, homeService *service.HomeService,
mapExperienceService *service.MapExperienceService,
profileService *service.ProfileService, profileService *service.ProfileService,
resultService *service.ResultService, resultService *service.ResultService,
sessionService *service.SessionService, sessionService *service.SessionService,
@@ -33,27 +38,43 @@ func NewRouter(
healthHandler := handlers.NewHealthHandler() healthHandler := handlers.NewHealthHandler()
authHandler := handlers.NewAuthHandler(authService) authHandler := handlers.NewAuthHandler(authService)
opsAuthHandler := handlers.NewOpsAuthHandler(opsAuthService)
opsSummaryHandler := handlers.NewOpsSummaryHandler(opsSummaryService)
regionOptionsHandler := handlers.NewRegionOptionsHandler()
entryHandler := handlers.NewEntryHandler(entryService) entryHandler := handlers.NewEntryHandler(entryService)
entryHomeHandler := handlers.NewEntryHomeHandler(entryHomeService) entryHomeHandler := handlers.NewEntryHomeHandler(entryHomeService)
adminAssetHandler := handlers.NewAdminAssetHandler(adminAssetService)
adminResourceHandler := handlers.NewAdminResourceHandler(adminResourceService) adminResourceHandler := handlers.NewAdminResourceHandler(adminResourceService)
adminProductionHandler := handlers.NewAdminProductionHandler(adminProductionService) adminProductionHandler := handlers.NewAdminProductionHandler(adminProductionService)
adminEventHandler := handlers.NewAdminEventHandler(adminEventService) adminEventHandler := handlers.NewAdminEventHandler(adminEventService)
adminPipelineHandler := handlers.NewAdminPipelineHandler(adminPipelineService) adminPipelineHandler := handlers.NewAdminPipelineHandler(adminPipelineService)
eventHandler := handlers.NewEventHandler(eventService) eventHandler := handlers.NewEventHandler(eventService)
eventPlayHandler := handlers.NewEventPlayHandler(eventPlayService) eventPlayHandler := handlers.NewEventPlayHandler(eventPlayService)
publicExperienceHandler := handlers.NewPublicExperienceHandler(publicExperienceService)
configHandler := handlers.NewConfigHandler(configService) configHandler := handlers.NewConfigHandler(configService)
homeHandler := handlers.NewHomeHandler(homeService) homeHandler := handlers.NewHomeHandler(homeService)
mapExperienceHandler := handlers.NewMapExperienceHandler(mapExperienceService)
profileHandler := handlers.NewProfileHandler(profileService) profileHandler := handlers.NewProfileHandler(profileService)
resultHandler := handlers.NewResultHandler(resultService) resultHandler := handlers.NewResultHandler(resultService)
sessionHandler := handlers.NewSessionHandler(sessionService) sessionHandler := handlers.NewSessionHandler(sessionService)
devHandler := handlers.NewDevHandler(devService) devHandler := handlers.NewDevHandler(devService)
opsWorkbenchHandler := handlers.NewOpsWorkbenchHandler()
meHandler := handlers.NewMeHandler(meService) meHandler := handlers.NewMeHandler(meService)
authMiddleware := middleware.NewAuthMiddleware(jwtManager) authMiddleware := middleware.NewAuthMiddleware(jwtManager)
opsAuthMiddleware := middleware.NewOpsAuthMiddleware(jwtManager, appEnv)
mux.HandleFunc("GET /healthz", healthHandler.Get) mux.HandleFunc("GET /healthz", healthHandler.Get)
mux.HandleFunc("GET /home", homeHandler.GetHome) mux.HandleFunc("GET /home", homeHandler.GetHome)
mux.HandleFunc("GET /cards", homeHandler.GetCards) 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.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("GET /admin/maps", authMiddleware(http.HandlerFunc(adminResourceHandler.ListMaps)))
mux.Handle("POST /admin/maps", authMiddleware(http.HandlerFunc(adminResourceHandler.CreateMap))) mux.Handle("POST /admin/maps", authMiddleware(http.HandlerFunc(adminResourceHandler.CreateMap)))
mux.Handle("GET /admin/maps/{mapPublicID}", authMiddleware(http.HandlerFunc(adminResourceHandler.GetMap))) 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("GET /admin/places", authMiddleware(http.HandlerFunc(adminProductionHandler.ListPlaces)))
mux.Handle("POST /admin/places", authMiddleware(http.HandlerFunc(adminProductionHandler.CreatePlace))) 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/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("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("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}/tile-releases", authMiddleware(http.HandlerFunc(adminProductionHandler.CreateTileRelease)))
mux.Handle("POST /admin/map-assets/{mapAssetPublicID}/course-sets", authMiddleware(http.HandlerFunc(adminProductionHandler.CreateCourseSet))) 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))) 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("GET /admin/runtime-bindings", authMiddleware(http.HandlerFunc(adminProductionHandler.ListRuntimeBindings)))
mux.Handle("POST /admin/runtime-bindings", authMiddleware(http.HandlerFunc(adminProductionHandler.CreateRuntimeBinding))) 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("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("GET /admin/playfields", authMiddleware(http.HandlerFunc(adminResourceHandler.ListPlayfields)))
mux.Handle("POST /admin/playfields", authMiddleware(http.HandlerFunc(adminResourceHandler.CreatePlayfield))) mux.Handle("POST /admin/playfields", authMiddleware(http.HandlerFunc(adminResourceHandler.CreatePlayfield)))
mux.Handle("GET /admin/playfields/{playfieldPublicID}", authMiddleware(http.HandlerFunc(adminResourceHandler.GetPlayfield))) 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))) mux.Handle("POST /admin/events/{eventPublicID}/rollback", authMiddleware(http.HandlerFunc(adminPipelineHandler.RollbackRelease)))
if appEnv != "production" { if appEnv != "production" {
mux.HandleFunc("GET /dev/workbench", devHandler.Workbench) 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/bootstrap-demo", devHandler.BootstrapDemo)
mux.HandleFunc("POST /dev/client-logs", devHandler.CreateClientLog) mux.HandleFunc("POST /dev/client-logs", devHandler.CreateClientLog)
mux.HandleFunc("GET /dev/client-logs", devHandler.ListClientLogs) mux.HandleFunc("GET /dev/client-logs", devHandler.ListClientLogs)
mux.HandleFunc("DELETE /dev/client-logs", devHandler.ClearClientLogs) mux.HandleFunc("DELETE /dev/client-logs", devHandler.ClearClientLogs)
mux.HandleFunc("GET /dev/manifest-summary", devHandler.ManifestSummary) 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/presentations/{demoKey}", devHandler.DemoPresentationSchema)
mux.HandleFunc("GET /dev/demo-assets/content-manifests/{demoKey}", devHandler.DemoContentManifest) mux.HandleFunc("GET /dev/demo-assets/content-manifests/{demoKey}", devHandler.DemoContentManifest)
mux.HandleFunc("GET /dev/config/local-files", configHandler.ListLocalFiles) 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/entry-home", authMiddleware(http.HandlerFunc(entryHomeHandler.Get)))
mux.Handle("GET /me/profile", authMiddleware(http.HandlerFunc(profileHandler.Get))) mux.Handle("GET /me/profile", authMiddleware(http.HandlerFunc(profileHandler.Get)))
mux.HandleFunc("GET /events/{eventPublicID}", eventHandler.GetDetail) 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}/play", authMiddleware(http.HandlerFunc(eventPlayHandler.Get)))
mux.Handle("GET /events/{eventPublicID}/config-sources", authMiddleware(http.HandlerFunc(configHandler.ListSources))) mux.Handle("GET /events/{eventPublicID}/config-sources", authMiddleware(http.HandlerFunc(configHandler.ListSources)))
mux.Handle("POST /events/{eventPublicID}/launch", authMiddleware(http.HandlerFunc(eventHandler.Launch))) 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/sms/send", authHandler.SendSMSCode)
mux.HandleFunc("POST /auth/login/sms", authHandler.LoginSMS) mux.HandleFunc("POST /auth/login/sms", authHandler.LoginSMS)
mux.HandleFunc("POST /auth/login/wechat-mini", authHandler.LoginWechatMini) 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.Handle("POST /auth/bind/mobile", authMiddleware(http.HandlerFunc(authHandler.BindMobile)))
mux.HandleFunc("POST /auth/refresh", authHandler.Refresh) mux.HandleFunc("POST /auth/refresh", authHandler.Refresh)
mux.HandleFunc("POST /auth/logout", authHandler.Logout) mux.HandleFunc("POST /auth/logout", authHandler.Logout)
mux.Handle("GET /me", authMiddleware(http.HandlerFunc(meHandler.Get))) mux.Handle("GET /me", authMiddleware(http.HandlerFunc(meHandler.Get)))
mux.Handle("GET /me/sessions", authMiddleware(http.HandlerFunc(sessionHandler.ListMine))) mux.Handle("GET /me/sessions", authMiddleware(http.HandlerFunc(sessionHandler.ListMine)))
mux.Handle("GET /me/results", authMiddleware(http.HandlerFunc(resultHandler.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 return mux
} }

View File

@@ -78,6 +78,38 @@ func (p *OSSUtilPublisher) UploadJSON(ctx context.Context, publicURL string, pay
return nil 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) { func (p *OSSUtilPublisher) objectKeyFromPublicURL(publicURL string) (string, error) {
publicURL = strings.TrimSpace(publicURL) publicURL = strings.TrimSpace(publicURL)
if publicURL == "" { if publicURL == "" {

View File

@@ -16,6 +16,8 @@ type Manager struct {
type AccessClaims struct { type AccessClaims struct {
UserID string `json:"uid"` UserID string `json:"uid"`
UserPublicID string `json:"upub"` UserPublicID string `json:"upub"`
ActorType string `json:"actorType,omitempty"`
RoleCode string `json:"roleCode,omitempty"`
jwt.RegisteredClaims 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) { 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) expiresAt := time.Now().UTC().Add(m.ttl)
claims := AccessClaims{ claims := AccessClaims{
UserID: userID, UserID: userID,
UserPublicID: userPublicID, UserPublicID: userPublicID,
ActorType: actorType,
RoleCode: roleCode,
RegisteredClaims: jwt.RegisteredClaims{ RegisteredClaims: jwt.RegisteredClaims{
Issuer: m.issuer, Issuer: m.issuer,
Subject: userID, Subject: userID,

View File

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

View File

@@ -44,6 +44,7 @@ type CreateAdminPlaceInput struct {
type AdminMapAssetSummary struct { type AdminMapAssetSummary struct {
ID string `json:"id"` ID string `json:"id"`
PlaceID string `json:"placeId"` PlaceID string `json:"placeId"`
PlaceName *string `json:"placeName,omitempty"`
LegacyMapID *string `json:"legacyMapId,omitempty"` LegacyMapID *string `json:"legacyMapId,omitempty"`
Code string `json:"code"` Code string `json:"code"`
Name string `json:"name"` Name string `json:"name"`
@@ -61,9 +62,10 @@ type AdminTileReleaseBrief struct {
} }
type AdminMapAssetDetail struct { type AdminMapAssetDetail struct {
MapAsset AdminMapAssetSummary `json:"mapAsset"` MapAsset AdminMapAssetSummary `json:"mapAsset"`
TileReleases []AdminTileReleaseView `json:"tileReleases"` TileReleases []AdminTileReleaseView `json:"tileReleases"`
CourseSets []AdminCourseSetBrief `json:"courseSets"` CourseSets []AdminCourseSetBrief `json:"courseSets"`
LinkedEvents []AdminMapLinkedEventBrief `json:"linkedEvents"`
} }
type CreateAdminMapAssetInput struct { type CreateAdminMapAssetInput struct {
@@ -76,6 +78,31 @@ type CreateAdminMapAssetInput struct {
Status string `json:"status"` 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 { type AdminTileReleaseView struct {
ID string `json:"id"` ID string `json:"id"`
LegacyVersionID *string `json:"legacyVersionId,omitempty"` LegacyVersionID *string `json:"legacyVersionId,omitempty"`
@@ -202,6 +229,66 @@ type CreateAdminRuntimeBindingInput struct {
Notes *string `json:"notes,omitempty"` 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 { func NewAdminProductionService(store *postgres.Store) *AdminProductionService {
return &AdminProductionService{store: store} return &AdminProductionService{store: store}
} }
@@ -218,6 +305,22 @@ func (s *AdminProductionService) ListPlaces(ctx context.Context, limit int) ([]A
return result, nil 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) { func (s *AdminProductionService) CreatePlace(ctx context.Context, input CreateAdminPlaceInput) (*AdminPlaceSummary, error) {
input.Code = strings.TrimSpace(input.Code) input.Code = strings.TrimSpace(input.Code)
input.Name = strings.TrimSpace(input.Name) input.Name = strings.TrimSpace(input.Name)
@@ -362,10 +465,15 @@ func (s *AdminProductionService) GetMapAssetDetail(ctx context.Context, mapAsset
if err != nil { if err != nil {
return nil, err return nil, err
} }
linkedEvents, err := s.store.ListMapAssetLinkedEvents(ctx, item.ID, 100)
if err != nil {
return nil, err
}
result := &AdminMapAssetDetail{ result := &AdminMapAssetDetail{
MapAsset: summary, MapAsset: summary,
TileReleases: make([]AdminTileReleaseView, 0, len(tileReleases)), TileReleases: make([]AdminTileReleaseView, 0, len(tileReleases)),
CourseSets: make([]AdminCourseSetBrief, 0, len(courseSets)), CourseSets: make([]AdminCourseSetBrief, 0, len(courseSets)),
LinkedEvents: make([]AdminMapLinkedEventBrief, 0, len(linkedEvents)),
} }
for _, release := range tileReleases { for _, release := range tileReleases {
result.TileReleases = append(result.TileReleases, buildAdminTileReleaseView(release)) 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) result.CourseSets = append(result.CourseSets, brief)
} }
for _, linked := range linkedEvents {
result.LinkedEvents = append(result.LinkedEvents, buildAdminMapLinkedEventBrief(linked))
}
return result, nil 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) { func (s *AdminProductionService) CreateTileRelease(ctx context.Context, mapAssetPublicID string, input CreateAdminTileReleaseInput) (*AdminTileReleaseView, error) {
mapAsset, err := s.store.GetMapAssetByPublicID(ctx, strings.TrimSpace(mapAssetPublicID)) mapAsset, err := s.store.GetMapAssetByPublicID(ctx, strings.TrimSpace(mapAssetPublicID))
if err != nil { if err != nil {
@@ -748,6 +911,293 @@ func (s *AdminProductionService) CreateRuntimeBinding(ctx context.Context, input
return &result, nil 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) { func (s *AdminProductionService) GetRuntimeBinding(ctx context.Context, runtimeBindingPublicID string) (*AdminRuntimeBindingSummary, error) {
item, err := s.store.GetMapRuntimeBindingByPublicID(ctx, strings.TrimSpace(runtimeBindingPublicID)) item, err := s.store.GetMapRuntimeBindingByPublicID(ctx, strings.TrimSpace(runtimeBindingPublicID))
if err != nil { if err != nil {
@@ -764,6 +1214,7 @@ func (s *AdminProductionService) buildAdminMapAssetSummary(ctx context.Context,
result := AdminMapAssetSummary{ result := AdminMapAssetSummary{
ID: item.PublicID, ID: item.PublicID,
PlaceID: item.PlaceID, PlaceID: item.PlaceID,
PlaceName: item.PlaceName,
LegacyMapID: item.LegacyMapPublicID, LegacyMapID: item.LegacyMapPublicID,
Code: item.Code, Code: item.Code,
Name: item.Name, Name: item.Name,
@@ -791,6 +1242,24 @@ func (s *AdminProductionService) buildAdminMapAssetSummary(ctx context.Context,
return result, nil 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) { func (s *AdminProductionService) buildAdminCourseSetBrief(ctx context.Context, item postgres.CourseSet) (AdminCourseSetBrief, error) {
result := AdminCourseSetBrief{ result := AdminCourseSetBrief{
ID: item.PublicID, ID: item.PublicID,

View File

@@ -35,6 +35,7 @@ type EventPlayResult struct {
} `json:"release,omitempty"` } `json:"release,omitempty"`
ResolvedRelease *ResolvedReleaseView `json:"resolvedRelease,omitempty"` ResolvedRelease *ResolvedReleaseView `json:"resolvedRelease,omitempty"`
Runtime *RuntimeSummaryView `json:"runtime,omitempty"` Runtime *RuntimeSummaryView `json:"runtime,omitempty"`
Preview *MapPreviewView `json:"preview,omitempty"`
CurrentPresentation *PresentationSummaryView `json:"currentPresentation,omitempty"` CurrentPresentation *PresentationSummaryView `json:"currentPresentation,omitempty"`
CurrentContentBundle *ContentBundleSummaryView `json:"currentContentBundle,omitempty"` CurrentContentBundle *ContentBundleSummaryView `json:"currentContentBundle,omitempty"`
Play struct { Play struct {
@@ -104,6 +105,11 @@ func (s *EventPlayService) GetEventPlay(ctx context.Context, input EventPlayInpu
} }
result.ResolvedRelease = buildResolvedReleaseFromEvent(event, LaunchSourceEventCurrentRelease) result.ResolvedRelease = buildResolvedReleaseFromEvent(event, LaunchSourceEventCurrentRelease)
result.Runtime = buildRuntimeSummaryFromEvent(event) result.Runtime = buildRuntimeSummaryFromEvent(event)
if preview, err := buildPreviewFromPayload(event.ReleasePayloadJSON); err != nil {
return nil, err
} else {
result.Preview = preview
}
result.CurrentPresentation = buildPresentationSummaryFromEvent(event) result.CurrentPresentation = buildPresentationSummaryFromEvent(event)
if enrichedPresentation, err := loadPresentationSummaryByPublicID(ctx, s.store, event.PresentationID); err != nil { if enrichedPresentation, err := loadPresentationSummaryByPublicID(ctx, s.store, event.PresentationID); err != nil {
return nil, err return nil, err

View File

@@ -32,6 +32,7 @@ type EventDetailResult struct {
} `json:"release,omitempty"` } `json:"release,omitempty"`
ResolvedRelease *ResolvedReleaseView `json:"resolvedRelease,omitempty"` ResolvedRelease *ResolvedReleaseView `json:"resolvedRelease,omitempty"`
Runtime *RuntimeSummaryView `json:"runtime,omitempty"` Runtime *RuntimeSummaryView `json:"runtime,omitempty"`
Preview *MapPreviewView `json:"preview,omitempty"`
CurrentPresentation *PresentationSummaryView `json:"currentPresentation,omitempty"` CurrentPresentation *PresentationSummaryView `json:"currentPresentation,omitempty"`
CurrentContentBundle *ContentBundleSummaryView `json:"currentContentBundle,omitempty"` CurrentContentBundle *ContentBundleSummaryView `json:"currentContentBundle,omitempty"`
} }
@@ -71,6 +72,7 @@ type LaunchEventResult struct {
SessionToken string `json:"sessionToken"` SessionToken string `json:"sessionToken"`
SessionTokenExpiresAt string `json:"sessionTokenExpiresAt"` SessionTokenExpiresAt string `json:"sessionTokenExpiresAt"`
RouteCode *string `json:"routeCode,omitempty"` RouteCode *string `json:"routeCode,omitempty"`
IsGuest bool `json:"isGuest,omitempty"`
} `json:"business"` } `json:"business"`
} `json:"launch"` } `json:"launch"`
} }
@@ -117,6 +119,11 @@ func (s *EventService) GetEventDetail(ctx context.Context, eventPublicID string)
} }
result.ResolvedRelease = buildResolvedReleaseFromEvent(event, LaunchSourceEventCurrentRelease) result.ResolvedRelease = buildResolvedReleaseFromEvent(event, LaunchSourceEventCurrentRelease)
result.Runtime = buildRuntimeSummaryFromEvent(event) result.Runtime = buildRuntimeSummaryFromEvent(event)
if preview, err := buildPreviewFromPayload(event.ReleasePayloadJSON); err != nil {
return nil, err
} else {
result.Preview = preview
}
result.CurrentPresentation = buildPresentationSummaryFromEvent(event) result.CurrentPresentation = buildPresentationSummaryFromEvent(event)
if enrichedPresentation, err := loadPresentationSummaryByPublicID(ctx, s.store, event.PresentationID); err != nil { if enrichedPresentation, err := loadPresentationSummaryByPublicID(ctx, s.store, event.PresentationID); err != nil {
return nil, err return nil, err
@@ -245,5 +252,6 @@ func (s *EventService) LaunchEvent(ctx context.Context, input LaunchEventInput)
result.Launch.Business.SessionToken = sessionToken result.Launch.Business.SessionToken = sessionToken
result.Launch.Business.SessionTokenExpiresAt = session.SessionTokenExpiresAt.Format(time.RFC3339) result.Launch.Business.SessionTokenExpiresAt = session.SessionTokenExpiresAt.Format(time.RFC3339)
result.Launch.Business.RouteCode = routeCode result.Launch.Business.RouteCode = routeCode
result.Launch.Business.IsGuest = false
return result, nil return result, nil
} }

View File

@@ -37,6 +37,7 @@ type CardResult struct {
TimeWindow string `json:"timeWindow"` TimeWindow string `json:"timeWindow"`
CTAText string `json:"ctaText"` CTAText string `json:"ctaText"`
IsDefaultExperience bool `json:"isDefaultExperience"` IsDefaultExperience bool `json:"isDefaultExperience"`
ShowInEventList bool `json:"showInEventList"`
EventType *string `json:"eventType,omitempty"` EventType *string `json:"eventType,omitempty"`
CurrentPresentation *PresentationSummaryView `json:"currentPresentation,omitempty"` CurrentPresentation *PresentationSummaryView `json:"currentPresentation,omitempty"`
CurrentContentBundle *ContentBundleSummaryView `json:"currentContentBundle,omitempty"` CurrentContentBundle *ContentBundleSummaryView `json:"currentContentBundle,omitempty"`
@@ -153,6 +154,7 @@ func mapCards(cards []postgres.Card) []CardResult {
TimeWindow: deriveCardTimeWindow(card), TimeWindow: deriveCardTimeWindow(card),
CTAText: deriveCardCTAText(card, statusCode), CTAText: deriveCardCTAText(card, statusCode),
IsDefaultExperience: card.IsDefaultExperience, IsDefaultExperience: card.IsDefaultExperience,
ShowInEventList: card.ShowInEventList,
EventType: deriveCardEventType(card), EventType: deriveCardEventType(card),
CurrentPresentation: buildCardPresentationSummary(card), CurrentPresentation: buildCardPresentationSummary(card),
CurrentContentBundle: buildCardContentBundleSummary(card), CurrentContentBundle: buildCardContentBundleSummary(card),

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -16,6 +16,7 @@ type Card struct {
DisplaySlot string DisplaySlot string
DisplayPriority int DisplayPriority int
IsDefaultExperience bool IsDefaultExperience bool
ShowInEventList bool
StartsAt *time.Time StartsAt *time.Time
EndsAt *time.Time EndsAt *time.Time
EntryChannelID *string EntryChannelID *string
@@ -59,6 +60,7 @@ func (s *Store) ListCardsForEntry(ctx context.Context, tenantID string, entryCha
c.display_slot, c.display_slot,
c.display_priority, c.display_priority,
c.is_default_experience, c.is_default_experience,
COALESCE(e.show_in_event_list, true),
c.starts_at, c.starts_at,
c.ends_at, c.ends_at,
c.entry_channel_id, c.entry_channel_id,
@@ -117,6 +119,7 @@ func (s *Store) ListCardsForEntry(ctx context.Context, tenantID string, entryCha
&card.DisplaySlot, &card.DisplaySlot,
&card.DisplayPriority, &card.DisplayPriority,
&card.IsDefaultExperience, &card.IsDefaultExperience,
&card.ShowInEventList,
&card.StartsAt, &card.StartsAt,
&card.EndsAt, &card.EndsAt,
&card.EntryChannelID, &card.EntryChannelID,

File diff suppressed because it is too large Load Diff

View File

@@ -16,6 +16,8 @@ type Event struct {
DisplayName string DisplayName string
Summary *string Summary *string
Status string Status string
IsDefaultExperience bool
ShowInEventList bool
CurrentReleaseID *string CurrentReleaseID *string
CurrentReleasePubID *string CurrentReleasePubID *string
ConfigLabel *string ConfigLabel *string
@@ -113,6 +115,8 @@ func (s *Store) GetEventByPublicID(ctx context.Context, eventPublicID string) (*
e.display_name, e.display_name,
e.summary, e.summary,
e.status, e.status,
e.is_default_experience,
e.show_in_event_list,
e.current_release_id, e.current_release_id,
er.release_public_id, er.release_public_id,
er.config_label, er.config_label,
@@ -159,6 +163,8 @@ func (s *Store) GetEventByPublicID(ctx context.Context, eventPublicID string) (*
&event.DisplayName, &event.DisplayName,
&event.Summary, &event.Summary,
&event.Status, &event.Status,
&event.IsDefaultExperience,
&event.ShowInEventList,
&event.CurrentReleaseID, &event.CurrentReleaseID,
&event.CurrentReleasePubID, &event.CurrentReleasePubID,
&event.ConfigLabel, &event.ConfigLabel,
@@ -202,6 +208,8 @@ func (s *Store) GetEventByID(ctx context.Context, eventID string) (*Event, error
e.display_name, e.display_name,
e.summary, e.summary,
e.status, e.status,
e.is_default_experience,
e.show_in_event_list,
e.current_release_id, e.current_release_id,
er.release_public_id, er.release_public_id,
er.config_label, er.config_label,
@@ -248,6 +256,8 @@ func (s *Store) GetEventByID(ctx context.Context, eventID string) (*Event, error
&event.DisplayName, &event.DisplayName,
&event.Summary, &event.Summary,
&event.Status, &event.Status,
&event.IsDefaultExperience,
&event.ShowInEventList,
&event.CurrentReleaseID, &event.CurrentReleaseID,
&event.CurrentReleasePubID, &event.CurrentReleasePubID,
&event.ConfigLabel, &event.ConfigLabel,
@@ -601,3 +611,16 @@ func (s *Store) SetEventReleaseRuntimeBinding(ctx context.Context, tx Tx, releas
} }
return nil 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
}

View File

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

View File

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

View File

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

View File

@@ -28,6 +28,8 @@ type MapAsset struct {
ID string ID string
PublicID string PublicID string
PlaceID string PlaceID string
PlacePublicID *string
PlaceName *string
LegacyMapID *string LegacyMapID *string
LegacyMapPublicID *string LegacyMapPublicID *string
Code string Code string
@@ -152,6 +154,16 @@ type CreateMapAssetParams struct {
Status string Status string
} }
type UpdateMapAssetParams struct {
MapAssetID string
Code string
Name string
MapType string
CoverURL *string
Description *string
Status string
}
type CreateTileReleaseParams struct { type CreateTileReleaseParams struct {
PublicID string PublicID string
MapAssetID string MapAssetID string
@@ -215,6 +227,53 @@ type CreateMapRuntimeBindingParams struct {
Notes *string 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) { func (s *Store) ListPlaces(ctx context.Context, limit int) ([]Place, error) {
if limit <= 0 || limit > 200 { if limit <= 0 || limit > 200 {
limit = 50 limit = 50
@@ -253,6 +312,16 @@ func (s *Store) GetPlaceByPublicID(ctx context.Context, publicID string) (*Place
return scanPlace(row) 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) { func (s *Store) CreatePlace(ctx context.Context, tx Tx, params CreatePlaceParams) (*Place, error) {
centerPointJSON, err := marshalJSONMap(params.CenterPoint) centerPointJSON, err := marshalJSONMap(params.CenterPoint)
if err != nil { 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) { func (s *Store) ListMapAssetsByPlaceID(ctx context.Context, placeID string) ([]MapAsset, error) {
rows, err := s.pool.Query(ctx, ` 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 ma.cover_url, ma.description, ma.status, ma.current_tile_release_id, ma.created_at, ma.updated_at
FROM map_assets ma FROM map_assets ma
JOIN places p ON p.id = ma.place_id
LEFT JOIN maps lm ON lm.id = ma.legacy_map_id LEFT JOIN maps lm ON lm.id = ma.legacy_map_id
WHERE ma.place_id = $1 WHERE ma.place_id = $1
ORDER BY ma.created_at DESC 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) { func (s *Store) GetMapAssetByPublicID(ctx context.Context, publicID string) (*MapAsset, error) {
row := s.pool.QueryRow(ctx, ` 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 ma.cover_url, ma.description, ma.status, ma.current_tile_release_id, ma.created_at, ma.updated_at
FROM map_assets ma FROM map_assets ma
JOIN places p ON p.id = ma.place_id
LEFT JOIN maps lm ON lm.id = ma.legacy_map_id LEFT JOIN maps lm ON lm.id = ma.legacy_map_id
WHERE ma.map_asset_public_id = $1 WHERE ma.map_asset_public_id = $1
LIMIT 1 LIMIT 1
@@ -305,15 +376,44 @@ func (s *Store) GetMapAssetByPublicID(ctx context.Context, publicID string) (*Ma
return scanMapAsset(row) 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) { func (s *Store) CreateMapAsset(ctx context.Context, tx Tx, params CreateMapAssetParams) (*MapAsset, error) {
row := tx.QueryRow(ctx, ` 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) 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) 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) `, params.PublicID, params.PlaceID, params.LegacyMapID, params.Code, params.Name, params.MapType, params.CoverURL, params.Description, params.Status)
return scanMapAsset(row) 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) { func (s *Store) ListTileReleasesByMapAssetID(ctx context.Context, mapAssetID string) ([]TileRelease, error) {
rows, err := s.pool.Query(ctx, ` 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, 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) 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) { func (s *Store) CreateTileRelease(ctx context.Context, tx Tx, params CreateTileReleaseParams) (*TileRelease, error) {
metadataJSON, err := marshalJSONMap(params.MetadataJSON) metadataJSON, err := marshalJSONMap(params.MetadataJSON)
if err != nil { if err != nil {
@@ -506,6 +619,16 @@ func (s *Store) GetCourseSetByPublicID(ctx context.Context, publicID string) (*C
return scanCourseSet(row) 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) { func (s *Store) CreateCourseSet(ctx context.Context, tx Tx, params CreateCourseSetParams) (*CourseSet, error) {
row := tx.QueryRow(ctx, ` row := tx.QueryRow(ctx, `
INSERT INTO course_sets (course_set_public_id, place_id, map_asset_id, code, mode, name, description, status) 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) 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) { func (s *Store) CreateCourseVariant(ctx context.Context, tx Tx, params CreateCourseVariantParams) (*CourseVariant, error) {
configPatchJSON, err := marshalJSONMap(params.ConfigPatch) configPatchJSON, err := marshalJSONMap(params.ConfigPatch)
if err != nil { if err != nil {
@@ -641,6 +777,66 @@ func (s *Store) GetMapRuntimeBindingByPublicID(ctx context.Context, publicID str
return scanMapRuntimeBinding(row) 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) { func (s *Store) CreateMapRuntimeBinding(ctx context.Context, tx Tx, params CreateMapRuntimeBindingParams) (*MapRuntimeBinding, error) {
row := tx.QueryRow(ctx, ` row := tx.QueryRow(ctx, `
INSERT INTO map_runtime_bindings ( INSERT INTO map_runtime_bindings (
@@ -681,7 +877,7 @@ func scanPlaceFromRows(rows pgx.Rows) (*Place, error) {
func scanMapAsset(row pgx.Row) (*MapAsset, error) { func scanMapAsset(row pgx.Row) (*MapAsset, error) {
var item MapAsset 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) { if errors.Is(err, pgx.ErrNoRows) {
return nil, nil return nil, nil
} }
@@ -693,7 +889,7 @@ func scanMapAsset(row pgx.Row) (*MapAsset, error) {
func scanMapAssetFromRows(rows pgx.Rows) (*MapAsset, error) { func scanMapAssetFromRows(rows pgx.Rows) (*MapAsset, error) {
var item MapAsset 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 { if err != nil {
return nil, fmt.Errorf("scan map asset row: %w", err) return nil, fmt.Errorf("scan map asset row: %w", err)
} }

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -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. 一句话结论
后台下一步不应做成“配置编辑器”,而应做成一套**活动生产与发布平台**。
游戏定制建议以后台五层模型推进:
- 活动层
- 地图与赛道层
- 玩法层
- 运营内容层
- 发布层
只要这五层分清,后续游戏定制、活动运营、多赛道、准备页预览和发布闭环都能稳定扩展。

View File

@@ -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 当前应继续围绕“活动生产与发布平台”推进,而不是回到散装配置模式。
现在最该做的不是加更多零碎接口,而是做稳这三件事:
- 发布完整性
- 多赛道生产链
- 运维后台第一期的对象与页面闭环

View File

@@ -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 第一阶段不是做“大而全后台”,而是做一套**稳定的活动生产与发布最小闭环**。
当前最重要的是先做稳:
- 发布完整性
- 多赛道
- 预览支撑字段
- 运维后台第一期对象与页面

View File

@@ -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 才不会在第一阶段就跑偏成“大而全配置编辑器”。

View File

@@ -1,6 +1,6 @@
# 配置文档索引 # 配置文档索引
> 文档版本v1.0 > 文档版本v1.1
> 最后更新2026-04-02 08:28:05 > 最后更新2026-04-07 14:22:00
本文档用于汇总当前项目所有与配置设计、配置样例、配置管理相关的文档,并按“公共配置”和“按游戏分类”两层组织。 本文档用于汇总当前项目所有与配置设计、配置样例、配置管理相关的文档,并按“公共配置”和“按游戏分类”两层组织。
@@ -17,6 +17,8 @@
最小通用骨架 最小通用骨架
- [当前最全配置模板](D:/dev/cmr-mini/doc/config/当前最全配置模板.md) - [当前最全配置模板](D:/dev/cmr-mini/doc/config/当前最全配置模板.md)
当前共享全量模板 当前共享全量模板
- [最大配置模板后台落地裁剪表](D:/dev/cmr-mini/doc/config/最大配置模板后台落地裁剪表.md)
把全量模板裁成 backend 第一阶段、第二阶段和暂不开放三类
- [后台配置管理方案V2](D:/dev/cmr-mini/doc/config/后台配置管理方案V2.md) - [后台配置管理方案V2](D:/dev/cmr-mini/doc/config/后台配置管理方案V2.md)
后台管理与发布方案 后台管理与发布方案
- [配置发布说明](D:/dev/cmr-mini/doc/config/配置发布说明.md) - [配置发布说明](D:/dev/cmr-mini/doc/config/配置发布说明.md)

View File

@@ -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 已有能力一致
- 和未来官网 / 活动门户重构方向一致
- 不会再造一套和正式系统脱节的“演示站”

View File

@@ -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` 的重构目标,不是简单换皮,而是升级成:
**品牌官网 + 活动门户 + 地图体验入口 + 商务转化站。**

View File

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

View File

@@ -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. 一句话结论
建议增加地图列表,但它应被定义为:
**默认体验活动的入口层,而不是第二套活动系统。**
后台后续只需支持:
- 地图下挂默认体验活动
- 默认体验活动是否出现在正式活动列表
这样既能支持未注册/试玩用户体验,也不会把现有活动主链拆成两套。

View File

@@ -1,15 +1,16 @@
# 多线程联调协作方式 # 多线程联调协作方式
> 文档版本v1.1 > 文档版本v1.2
> 最后更新2026-04-03 11:15:00 > 最后更新2026-04-07 10:32:36
## 目标 ## 目标
当前项目已经进入前后端联调阶段,并且存在多条并行工作线程: 当前项目已经进入前后端联调和多产品线并行阶段,并且存在多条并行工作线程:
- 前端线程 - 前端线程
- 后端线程 - 后端线程
- 总控线程 - 总控线程
- 网站线程
这份文档用于明确三条线程如何协作、各自负责什么,以及如何通过共享文档同步事实,而不是靠口头记忆维持项目状态。 这份文档用于明确三条线程如何协作、各自负责什么,以及如何通过共享文档同步事实,而不是靠口头记忆维持项目状态。
@@ -21,25 +22,28 @@
- 一个代码仓库 - 一个代码仓库
- 多条并行线程 - 多条并行线程
- 份根目录协作文档 - 份根目录协作文档
- 一名全局维护者负责总览和收口 - 一名全局维护者负责总览和收口
对应关系: 对应关系:
- 前端线程:推进小程序页面、状态链、模拟器接入、地图与体验层 - 前端线程:推进小程序页面、状态链、模拟器接入、地图与体验层
- 后端线程:推进接口、配置发布、会话生命周期、业务数据模型 - 后端线程:推进接口、配置发布、会话生命周期、业务数据模型
- 网站线程:推进官网、活动门户、地图体验入口、商务转化站重构
- 总控线程:负责全局判断、主线推进、交叉影响评估、文档索引与阶段总结 - 总控线程:负责全局判断、主线推进、交叉影响评估、文档索引与阶段总结
--- ---
## 2. 当前协作文档的职责 ## 2. 当前协作文档的职责
当前跨线程沟通主线改为 4 份文件: 当前跨线程沟通主线改为 6 份文件:
- [t2b.md](D:/dev/cmr-mini/t2b.md) - [t2b.md](D:/dev/cmr-mini/t2b.md)
- [b2t.md](D:/dev/cmr-mini/b2t.md) - [b2t.md](D:/dev/cmr-mini/b2t.md)
- [t2f.md](D:/dev/cmr-mini/t2f.md) - [t2f.md](D:/dev/cmr-mini/t2f.md)
- [f2t.md](D:/dev/cmr-mini/f2t.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 当前固定模板 ## 2.5 当前固定模板
@@ -128,6 +148,8 @@
- [b2t.md](D:/dev/cmr-mini/b2t.md) - [b2t.md](D:/dev/cmr-mini/b2t.md)
- [t2f.md](D:/dev/cmr-mini/t2f.md) - [t2f.md](D:/dev/cmr-mini/t2f.md)
- [f2t.md](D:/dev/cmr-mini/f2t.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) - [b2t.md](D:/dev/cmr-mini/b2t.md)
- [t2f.md](D:/dev/cmr-mini/t2f.md) - [t2f.md](D:/dev/cmr-mini/t2f.md)
- [f2t.md](D:/dev/cmr-mini/f2t.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)
特点: 特点:

View File

@@ -1,6 +1,6 @@
# 联调架构阶段总结 # 联调架构阶段总结
> 文档版本v1.0 > 文档版本v1.1
> 最后更新2026-04-03 16:59:19 > 最后更新2026-04-07 22:38:00
## 1. 当前结论 ## 1. 当前结论
@@ -141,31 +141,53 @@ frontend 当前已配合提供:
### 6.2 正在推进 ### 6.2 正在推进
- 真实输入替换
- 更接近生产的联调环境 - 更接近生产的联调环境
- 活动系统最小成品闭环回归
- 地图体验链第一刀回归
- 游客体验链第一刀回归
### 6.3 暂不启动 ### 6.3 已进入当前最小成品闭环范围
- 活动卡片列表产品化 - 活动卡片列表最小产品化第一刀
- 新玩家侧页面扩张 - 地图体验第一刀
- 更复杂后台运营功能 - 游客模式第一刀
- 准备页地图预览 V1
### 6.4 当前后端收口原则
- backend 第一阶段活动模型先按:
- 单地图
- 单路线组
- 单玩法
收口推进
- 复杂多地图 / 多路线组 / 多玩法活动,后续通过:
- 活动实例化
- 组合入口层
- 组合卡片层
解决
--- ---
## 7. 下一步建议 ## 7. 下一步建议
当前下一步不再是继续搭骨架,而是继续把真实输入往活动层推进 当前下一步不再是继续搭骨架,而是把已经接通的玩家链真正收顺,并继续保持联调环境接近生产
优先顺序建议: 优先顺序建议:
1. `content manifest` 1. 活动列表第一刀联调回归与小修
2. `presentation schema` 2. 活动详情页 / 准备页去工程味
3. 活动文案样例 3. 地图体验链 / 游客体验链整链回归
4. 继续使用:
- `Bootstrap Demo`
- `一键补齐 Runtime 并发布`
- `一键标准回归`
做统一验证
同时继续保持: 同时继续保持:
- 前端只做联调回归和小修 - 前端不扩第二刀产品化
- 后端继续保证一键回归链稳定 - 后端继续保证一键回归链稳定
- 后端按单地图 / 单路线组 / 单玩法先收模型
- 排障优先看: - 排障优先看:
- `回归结果汇总` - `回归结果汇总`
- `当前 Launch 实际配置摘要` - `当前 Launch 实际配置摘要`

View File

@@ -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不承担开发诊断建议在本周活动系统最小成品闭环完成后于下周正式开工。

View File

@@ -1,6 +1,6 @@
# 文档索引 # 文档索引
> 文档版本v1.8 > 文档版本v1.17
> 最后更新2026-04-05 12:36:25 > 最后更新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)
- [最大配置模板后台落地裁剪表](/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)
- [游戏规则架构](/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)
- [多赛道 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) - [活动卡片列表最小产品方案](/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) - [APP全局产品架构草案](/D:/dev/cmr-mini/doc/gameplay/APP全局产品架构草案.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)
@@ -63,8 +69,8 @@
- [混合体验架构](/D:/dev/cmr-mini/doc/experience/混合体验架构方案.md) - [混合体验架构](/D:/dev/cmr-mini/doc/experience/混合体验架构方案.md)
- [原生与 H5 Bridge 规范](/D:/dev/cmr-mini/doc/experience/原生与H5桥接规范.md) - [原生与 H5 Bridge 规范](/D:/dev/cmr-mini/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/mini-prog/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)
- [后台游戏定制支持方案](/D:/dev/cmr-mini/doc/backend/后台游戏定制支持方案.md)
- [生产发布与数据库上线方案](/D:/dev/cmr-mini/doc/backend/生产发布与数据库上线方案.md) - [生产发布与数据库上线方案](/D:/dev/cmr-mini/doc/backend/生产发布与数据库上线方案.md)
## 备注与归档 ## 备注与归档

51
f2b.archive.md Normal file
View File

@@ -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` 双文件

616
f2b.md
View File

@@ -1,234 +1,170 @@
# F2B 协作清单 # F2B 协作清单
> 文档版本v1.16 > 文档版本v2.5
> 最后更新2026-04-03 23:58:00 > 最后更新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` - 公共 launch
- `发布活动配置(自动补 Runtime``整条链一键验收` - 前端直接实测 backend 公共接口结果如下:
- 前端当前逻辑已经兼容: - `GET /public/experience-maps` -> `200`
- 明确 `assignmentMode=manual` 时显示赛道选择区 - `GET /public/events/evt_demo_001/play` -> `200`
- 即使 `assignmentMode` 缺失,只要 `courseVariants` 中存在 2 条以上可选赛道,也会显示赛道选择区 - `POST /public/events/evt_demo_001/launch` -> `500 internal_error`
- 因此前端现在是否显示赛道选择区,取决于 backend 新发布的 release 是否真的回出了多赛道字段 - 这说明游客模式“看地图/看活动/看准备页”已经通,但“真正进入地图”当前被 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` 中确认修复完成,当前不再需要继续追问 - backend 优先检查 `POST /public/events/{eventPublicID}/launch` 的服务端错误原因。
- 后续多赛道联调以修复后的 demo/publish 链为准 - 建议先用 `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”这一种情况 - [地图列表与默认体验活动方案](D:/dev/cmr-mini/doc/gameplay/地图列表与默认体验活动方案.md)
- 当前前端兼容逻辑已放宽为:只要 `courseVariants` 中存在 2 条以上可选赛道,即使 `assignmentMode` 缺失,也会显示赛道选择区 - 当前建议方向是:
- 但当前实际页面仍显示: - 增加 `地图列表` 作为默认体验活动入口层
- `赛道模式:默认单赛道` - 默认体验活动继续复用现有 `Event / Release / Launch / Session`
- `赛道摘要:当前未声明额外赛道版本,启动时按默认赛道进入` - 默认体验活动可挂可不挂
- 这说明前端当前实际拿到的更像是: - 默认体验活动可以不出现在正式活动列表
- `play.courseVariants = []` 或未返回 - 当前前端并不需要 backend 先做完整地图后台,只需要最小关系和最小摘要支持。
- 前端已追加准备页诊断日志字段,后端可从 `event-prepare` 日志直接核对:
- `details.variantCount`
- `details.selectableVariantCount`
- `details.showVariantSelector`
- 需要对方确认什么: - 需要对方确认什么:
- 该问题根因已由 backend `B2F-037` 中定位完成,当前不再需要继续从前端显示层排查 - backend 先评估并支持以下最小配合项:
- 后续请转看 `F2B-014` 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` 时,活动页 / 准备页返回 - 前端已新增一份用于 backend 对齐的配置裁剪文档
- `primaryAction = continue` - [最大配置模板后台落地裁剪表](D:/dev/cmr-mini/doc/config/最大配置模板后台落地裁剪表.md)
- `reason = user has an ongoing session for this event` - 该文档的目的不是让 backend 1:1 支持最大配置模板,而是把当前配置能力裁成三类:
- 但前端本地当前没有可恢复快照,且本轮联调主观确认“已经没有需要恢复的游戏” - 第一阶段必做
- 当前看起来像是 backend 仍认定该用户在该活动下存在 ongoing session - 第二阶段可做
- 暂不进后台,继续留在程序默认值层
- 该文档建议配合以下文档一起看:
- [后端总体架构与当前执行清单](D:/dev/cmr-mini/doc/backend/后端总体架构与当前执行清单.md)
- [后端第一阶段执行清单](D:/dev/cmr-mini/doc/backend/后端第一阶段执行清单.md)
- 需要对方确认什么: - 需要对方确认什么:
- 请 backend 核对该用户在 `evt_demo_variant_manual_001` 下是否仍有 `launched / running` session 未清掉 - 请 backend 以这三份文档为基线,对齐:
- 如这是预期行为,请说明推荐的标准清理路径;如不是预期,请修正 ongoing 判定或测试环境回收逻辑 - 第一阶段后台对象范围
- 状态:待后续单独收口(当前不阻塞主线) - 第一阶段应进入后台的配置字段
- 暂不进后台、继续保留在程序默认值层的字段
- 如 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 ### F2B-C011
- 时间2026-04-03 22:20:00 - 时间2026-04-03 22:20:00
- 提出方:前端 - 提出方:前端
- 当前事实: - 当前事实:
- backend 已通过 `B2F-035` 正式收紧 `play.canLaunch``launch` 的前置条件 - backend 已通过 `B2F-035` 收紧 `play.canLaunch``launch`
- 当前规则为:缺 `runtime / presentation / content bundle / manifest / 当前发布 release` 任一项时,均不可进入游戏 - 当前规则为:缺 `runtime / presentation / content bundle / manifest / 当前发布 release` 任一项时,均不可进入游戏
- 前端已按该契约复测,当前结果正常: - 前端已复测通过,当前按 `play.canLaunch` 作为正式阻断口径
- `canLaunch=false` 时页面会禁用进入动作
- `play.reason` 会给出更具体的缺失原因
- backend 也不会再允许直接 `launch` 绕过阻断
- 需要对方确认什么: - 需要对方确认什么:
- -
- 状态:已确认 - 状态:已确认
@@ -238,11 +174,11 @@
- 时间2026-04-03 23:52:00 - 时间2026-04-03 23:52:00
- 提出方:前端 - 提出方:前端
- 当前事实: - 当前事实:
- backend 已在 `B2F-037` 中确认:manual 多赛道准备页不显示选择区的根因是发布 release 缺少: - manual 多赛道准备页不显示选择区的根因已确认是发布 release 缺少:
- `play.assignmentMode` - `play.assignmentMode`
- `play.courseVariants` - `play.courseVariants`
- backend 已修复 `Bootstrap Demo` 与发布链,当前问题已通过联调日志确认收口 - backend 已修复 demo/build/publish 链
- frontend 当前已保留多赛道兜底展示逻辑,但该问题主因不在前端显示层 - 前端保留多赛道空态兜底,但主因不在前端
- 需要对方确认什么: - 需要对方确认什么:
- -
- 状态:已确认 - 状态:已确认
@@ -252,17 +188,8 @@
- 时间2026-04-03 23:52:00 - 时间2026-04-03 23:52:00
- 提出方:前端 - 提出方:前端
- 当前事实: - 当前事实:
- backend 在 `B2F-038` 中要求的活动卡片列表第一刀字段frontend 当前已按最小方案接入: - 活动卡片列表第一刀所需字段当前已足够
- `summary` - 前端已补齐列表与详情页联调日志:
- `status`
- `statusCode`
- `timeWindow`
- `ctaText`
- `isDefaultExperience`
- `eventType`
- `currentPresentation`
- `currentContentBundle`
- frontend 当前列表页和详情页日志也已补齐:
- `cardEventIds` - `cardEventIds`
- `clickedEventId` - `clickedEventId`
- `detailStatus` - `detailStatus`
@@ -270,254 +197,71 @@
- `detailCurrentPresentation` - `detailCurrentPresentation`
- `detailCurrentContentBundle` - `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 ### F2B-D010
- 时间2026-04-03 22:12:00 - 时间2026-04-03 22:12:00
- 提出方:前端 - 提出方:前端
- 当前事实: - 当前事实:
- 已按 `B2F-034`活动页准备页做语义收口 - 活动页准备页已统一使用
- `展示版本` 改成 `当前发布展示版本` - `当前发布展示版本`
- `内容包版本` 改成 `当前发布内容包版本` - `当前发布内容包版本`
- `currentPresentation / currentContentBundle` 为空时,前端当前统一解释为: -两项为空时,前端统一解释为:
- `当前发布 release 未绑定展示版本,或当前尚未发布` - 当前发布 release 未绑定
- `当前发布 release 未绑定内容包版本,或当前尚未发布` - 或当前尚未发布
- 活动页与准备页当前进入动作都已优先受 `play.canLaunch` 控制 - 需要对方确认什么
- `canLaunch=false` 时按钮禁用 -
- 同时阻止继续进入准备页或地图 - 状态:已完成
### 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 - 当前前后端继续按 backend 一键测试环境联调
- 当前前端侧会优先关注:
- 时间2026-04-02 - 活动列表第一刀回归
- 提出方:前端 - 活动详情页/准备页用户化小修
- 当前事实: - 准备页地图预览 V1 稳定性
- session 生命周期关键语义已由 backend 确认 - 如后端语义或字段发生变化,再通过 `b2f.md` / `f2b.md` 做增量同步
- 当前前端下一轮重点应转向主链回归与结果展示对齐
- 需要对方确认什么:
-
- 状态:前端执行中
### 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 三段事实,不再只报“现象”
- 需要对方确认什么:
-
- 状态:前端执行中

200
f2f.md Normal file
View File

@@ -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` 曾返回 500backend 文档称已修,但建议新线程视情况确认是否真正闭环
### 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`:通过
## 交接结论
- 当前不是架构失控阶段,主链总体稳定
- 新线程优先不要扩新链
- 先把游客模式做一次完整真回归
- 再决定是继续收产品流,还是回到后台/总控协同线

44
f2t.archive.md Normal file
View File

@@ -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`
- 主要包括:
- 活动卡片列表最小产品方案
- 独立活动列表页第一刀
- `全部 / 体验` 最小筛选
- 最小卡片字段与详情跳转
### 页面用户化与活动链
- 这些事项仍在主文件保留近期版本,但更早的背景讨论已归档
- 包括:
- 活动详情页用户化第一刀
- 活动准备页用户化第一刀
- 结果页 / 历史页活动链衔接第一轮小修

295
f2t.md
View File

@@ -1,13 +1,13 @@
# F2T 协作清单 # F2T 协作清单
> 文档版本v1.12 > 文档版本v2.1
> 最后更新2026-04-03 23:42:00 > 最后更新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 - 谁提的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 - 谁提的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 - 谁提的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 - 谁提的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 - 谁提的frontend
- 当前事实: - 当前事实:
- 已新增 [活动运营域摘要第一刀联调回归清单](D:/dev/cmr-mini/doc/gameplay/活动运营域摘要第一刀联调回归清单.md) - 准备页地图预览方案已落地并进入 V1
- 当前回归口径已固定覆盖 - 当前 V1 已完成
- 活动详情页摘要 - 低级别正式瓦片底图
- 准备页摘要 - 准备页只读预览卡
- `launch.presentation / launch.contentBundle` 会话快照 - 前端叠加当前已知 KML 点位
- 与 runtime 主链隔离 - 多赛道数据不足时保留预览区并显示空态/说明提示
- 缺字段降级 - 当前 V1 不做:
- 腿线
- 拖拽
- 缩放
- 复杂交互
- 需要确认什么: - 需要确认什么:
- -
- 是否已解决:是 - 是否已解决:是
### F2T-D005 ### F2T-D015
- 时间2026-04-03 19:48:00 - 时间2026-04-07 18:40:00
- 谁提的frontend - 谁提的frontend
- 当前事实: - 当前事实:
- 已按总控最新口径把联调方式标准化 - 地图体验第一刀已完成
- 当前活动运营域摘要第一刀回归默认统一使用 backend 的一键测试环境 - 当前已具备
- `Bootstrap Demo` - 首页 `地图体验` 入口
- `一键补齐 Runtime 并发布` - 地图列表页
- 不再建议前后端各自手工铺多份 demo 对象 - 地图详情页
- 地图下默认体验活动入口
- 默认体验活动继续复用现有活动详情/准备页/地图主链
- 需要确认什么: - 需要确认什么:
- -
- 是否已解决:是 - 是否已解决:是
### F2T-D006 ### F2T-D016
- 时间2026-04-03 16:26:37 - 时间2026-04-07 19:10:00
- 谁提的frontend - 谁提的frontend
- 当前事实: - 当前事实:
- 已按 backend 新增 dev 调试接口切换当前联调诊断主出口: - 游客模式第一刀已完成
- `POST /dev/client-logs` - 当前已具备:
- 当前首页、活动页、准备页、地图关键链路会主动上报: - 登录页 `游客体验`
- `entry-home` - 游客进入地图列表/地图详情/默认体验活动
- `event-play` - 无登录态时活动详情/准备页自动走 `/public/...`
- `event-prepare` - 游客结果页优先展示本地结果摘要
- `launch-diagnostic` - 需要确认什么:
- `runtime-compiler` - 当前更适合做一轮整链真回归
- `session-recovery` - 是否已解决:是
- 登录后自动连接模拟器日志的链路已撤掉
- 地图内调试面板继续保留,仅作为本地开发辅助,不再作为当前联调主诊断口 ### 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 - 谁提的frontend
- 当前事实: - 当前事实:
- backend 已确认积分赛误进顺序赛的根因在 backend demo 首页卡片入口配置,不在前端玩法解析 - 后台/新线程协同文档已补齐
- 前端本轮未再修改 runtime / manifest 消费主链 - 当前新增:
- 前端仅补了联调日志口径优化: - [准备页地图预览方案](D:/dev/cmr-mini/doc/gameplay/准备页地图预览方案.md)
- 非多赛道玩法不再上报空字符串 `assignmentMode` - [地图列表与默认体验活动方案](D:/dev/cmr-mini/doc/gameplay/地图列表与默认体验活动方案.md)
- 日志新增前端本地递增 `details.seq` - [后台游戏定制支持方案](D:/dev/cmr-mini/doc/backend/后台游戏定制支持方案.md)
- `launchVariantId``runtimeCourseVariantId` 明确区分 - [后端总体架构与当前执行清单](D:/dev/cmr-mini/doc/backend/后端总体架构与当前执行清单.md)
- 需要确认什么: - [最大配置模板后台落地裁剪表](D:/dev/cmr-mini/doc/config/最大配置模板后台落地裁剪表.md)
- - [f2f.md](D:/dev/cmr-mini/f2f.md)
- 是否已解决:是
### 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`
- 从列表跳活动详情页
- 当前第一刀仍保持边界:
- 不重做首页现有入口区
- 仅在首页补一个“活动列表”独立入口
- 不扩更多玩家侧新链
- 需要确认什么: - 需要确认什么:
- -
- 是否已解决:是 - 是否已解决:是
@@ -273,8 +177,13 @@
## 下一步 ## 下一步
- 当前进入活动卡片列表最小产品化第一刀联调回归与小范围修复阶段 - 当前继续做:
- 当前重点验证: - 活动列表第一刀回归与小修
- 活动详情页/准备页用户化打磨
- 准备页地图预览 V1 稳定性验证
- 游客模式整链真回归
- 当前重点观察:
- 列表字段是否足够 - 列表字段是否足够
- `全部 / 体验` 分组是否符合预期 - 列表到详情页跳转是否稳定
- 卡片点击进入活动详情页是否稳定 - 准备页地图预览底图、点位叠加和空态提示是否稳定
- 游客模式公共 `play / launch / result` 是否完全闭环

View File

@@ -4,6 +4,8 @@
"pages/login/login", "pages/login/login",
"pages/home/home", "pages/home/home",
"pages/events/events", "pages/events/events",
"pages/experience-maps/experience-maps",
"pages/experience-map/experience-map",
"pages/event/event", "pages/event/event",
"pages/event-prepare/event-prepare", "pages/event-prepare/event-prepare",
"pages/result/result", "pages/result/result",

View File

@@ -1,9 +1,11 @@
import { loadBackendAuthTokens, loadBackendBaseUrl } from '../../utils/backendAuth' 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 { adaptBackendLaunchResultToEnvelope } from '../../utils/backendLaunchAdapter'
import { formatBackendPlayActionText, formatBackendPlayStatusText } from '../../utils/backendPlayCopy' import { formatBackendPlayActionText, formatBackendPlayStatusText } from '../../utils/backendPlayCopy'
import { prepareMapPageUrlForLaunch } from '../../utils/gameLaunch' import { prepareMapPageUrlForLaunch } from '../../utils/gameLaunch'
import { reportBackendClientLog } from '../../utils/backendClientLogs' 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' import { HeartRateController } from '../../engine/sensor/heartRateController'
const PREFERRED_HEART_RATE_DEVICE_STORAGE_KEY = 'cmr.preferredHeartRateDevice' const PREFERRED_HEART_RATE_DEVICE_STORAGE_KEY = 'cmr.preferredHeartRateDevice'
@@ -14,6 +16,9 @@ type EventPreparePageData = {
eventId: string eventId: string
loading: boolean loading: boolean
canLaunch: boolean canLaunch: boolean
launchInFlight: boolean
launchProgressText: string
launchProgressPercent: number
titleText: string titleText: string
summaryText: string summaryText: string
releaseText: string releaseText: string
@@ -28,6 +33,20 @@ type EventPreparePageData = {
runtimeMapText: string runtimeMapText: string
runtimeVariantText: string runtimeVariantText: string
runtimeRouteCodeText: 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 selectedVariantId: string
selectedVariantText: string selectedVariantText: string
showVariantSelector: boolean showVariantSelector: boolean
@@ -55,6 +74,104 @@ type EventPreparePageData = {
connected: boolean connected: boolean
}> }>
mockSourceStatusText: string mockSourceStatusText: string
showMockSourceSummary: boolean
}
type EventPreparePageContext = WechatMiniprogram.Page.Instance<EventPreparePageData, Record<string, never>> & {
previewLoadSeq?: number
lastPlayResult?: BackendEventPlayResult | null
previewManifestUrl?: string | null
previewConfigCache?: RemoteMapConfig | null
previewSceneCache?: Record<string, PreparePreviewScene>
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 { function detectMultiVariantContext(result: BackendEventPlayResult): boolean {
@@ -212,6 +329,23 @@ function shouldShowVariantSelector(
let prepareHeartRateController: HeartRateController | null = null 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 { function getAccessToken(): string | null {
const app = getApp<IAppOption>() const app = getApp<IAppOption>()
const tokens = app.globalData && app.globalData.backendAuthTokens const tokens = app.globalData && app.globalData.backendAuthTokens
@@ -260,6 +394,9 @@ Page({
eventId: '', eventId: '',
loading: false, loading: false,
canLaunch: false, canLaunch: false,
launchInFlight: false,
launchProgressText: '待进入地图',
launchProgressPercent: 0,
titleText: '开始前准备', titleText: '开始前准备',
summaryText: '未加载', summaryText: '未加载',
releaseText: '--', releaseText: '--',
@@ -270,10 +407,17 @@ Page({
variantSummaryText: '--', variantSummaryText: '--',
presentationText: '--', presentationText: '--',
contentBundleText: '--', contentBundleText: '--',
runtimePlaceText: '待 launch 确认', runtimePlaceText: '进入地图后确认',
runtimeMapText: '待 launch 确认', runtimeMapText: '进入地图后确认',
runtimeVariantText: '待 launch 确认', runtimeVariantText: '进入地图后确认',
runtimeRouteCodeText: '待 launch 确认', runtimeRouteCodeText: '进入地图后确认',
previewVisible: false,
previewLoading: false,
previewStatusText: '准备加载地图预览',
previewHintText: '进入地图前先看地图范围与当前已知赛道。',
previewVariantText: '预览将跟随当前赛道选择联动',
previewTiles: [],
previewControls: [],
selectedVariantId: '', selectedVariantId: '',
selectedVariantText: '当前无需手动指定赛道', selectedVariantText: '当前无需手动指定赛道',
showVariantSelector: false, showVariantSelector: false,
@@ -289,6 +433,7 @@ Page({
locationBackgroundPermissionGranted: false, locationBackgroundPermissionGranted: false,
heartRateDiscoveredDevices: [], heartRateDiscoveredDevices: [],
mockSourceStatusText: '模拟源调试仍在地图页调试面板中使用', mockSourceStatusText: '模拟源调试仍在地图页调试面板中使用',
showMockSourceSummary: false,
} as EventPreparePageData, } as EventPreparePageData,
onLoad(query: { eventId?: string }) { onLoad(query: { eventId?: string }) {
@@ -306,10 +451,12 @@ Page({
}, },
onShow() { onShow() {
resetPrepareLaunchVisualState(this as unknown as EventPreparePageContext)
this.refreshPreparationDeviceState() this.refreshPreparationDeviceState()
}, },
onUnload() { onUnload() {
resetPrepareLaunchVisualState(this as unknown as EventPreparePageContext)
if (prepareHeartRateController) { if (prepareHeartRateController) {
prepareHeartRateController.destroy() prepareHeartRateController.destroy()
prepareHeartRateController = null prepareHeartRateController = null
@@ -319,10 +466,6 @@ Page({
async loadEventPlay(eventId?: string) { async loadEventPlay(eventId?: string) {
const targetEventId = eventId || this.data.eventId const targetEventId = eventId || this.data.eventId
const accessToken = getAccessToken() const accessToken = getAccessToken()
if (!accessToken) {
wx.redirectTo({ url: '/pages/login/login' })
return
}
this.setData({ this.setData({
loading: true, loading: true,
@@ -330,11 +473,17 @@ Page({
}) })
try { try {
const result = await getEventPlay({ const baseUrl = loadBackendBaseUrl()
baseUrl: loadBackendBaseUrl(), const result = accessToken
eventId: targetEventId, ? await getEventPlay({
accessToken, baseUrl,
}) eventId: targetEventId,
accessToken,
})
: await getPublicEventPlay({
baseUrl,
eventId: targetEventId,
})
this.applyEventPlay(result) this.applyEventPlay(result)
} catch (error) { } catch (error) {
const message = error && (error as { message?: string }).message ? (error as { message: string }).message : '未知错误' const message = error && (error as { message?: string }).message ? (error as { message: string }).message : '未知错误'
@@ -346,6 +495,14 @@ Page({
}, },
applyEventPlay(result: BackendEventPlayResult) { 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 multiVariantContext = detectMultiVariantContext(result)
const selectedVariantId = resolveSelectedVariantId( const selectedVariantId = resolveSelectedVariantId(
this.data.selectedVariantId, this.data.selectedVariantId,
@@ -379,6 +536,7 @@ Page({
? result.resolvedRelease.manifestUrl ? result.resolvedRelease.manifestUrl
: '', : '',
details: { details: {
guestMode: !getAccessToken(),
pageEventId: this.data.eventId || '', pageEventId: this.data.eventId || '',
resultEventId: result.event.id || '', resultEventId: result.event.id || '',
selectedVariantId: logVariantId, selectedVariantId: logVariantId,
@@ -411,18 +569,27 @@ Page({
variantSummaryText: formatVariantSummary(result), variantSummaryText: formatVariantSummary(result),
presentationText: formatPresentationSummary(result), presentationText: formatPresentationSummary(result),
contentBundleText: formatContentBundleSummary(result), contentBundleText: formatContentBundleSummary(result),
runtimePlaceText: '待 launch.runtime 确认', runtimePlaceText: '进入地图后确认',
runtimeMapText: '待 launch.runtime 确认', runtimeMapText: '进入地图后确认',
runtimeVariantText: selectedVariant runtimeVariantText: selectedVariant
? selectedVariant.name ? selectedVariant.name
: (result.play.courseVariants && result.play.courseVariants[0] : (result.play.courseVariants && result.play.courseVariants[0]
? result.play.courseVariants[0].name ? result.play.courseVariants[0].name
: '待 launch 确认'), : '进入地图后确认'),
runtimeRouteCodeText: selectedVariant runtimeRouteCodeText: selectedVariant
? selectedVariant.routeCodeText ? selectedVariant.routeCodeText
: (result.play.courseVariants && result.play.courseVariants[0] && result.play.courseVariants[0].routeCode : (result.play.courseVariants && result.play.courseVariants[0] && result.play.courseVariants[0].routeCode
? result.play.courseVariants[0].routeCode || '待 launch 确认' ? result.play.courseVariants[0].routeCode || '进入地图后确认'
: '待 launch 确认'), : '进入地图后确认'),
previewVisible: true,
previewLoading: true,
previewStatusText: '正在生成地图预览',
previewHintText: '进入地图前先看地图范围与当前已知赛道。',
previewVariantText: selectedVariant
? `当前预览赛道:${selectedVariant.name} / ${selectedVariant.routeCodeText}`
: (multiVariantContext ? '当前预览赛道:待选择' : '当前预览赛道:默认赛道'),
previewTiles: [],
previewControls: [],
selectedVariantId, selectedVariantId,
selectedVariantText: selectedVariant selectedVariantText: selectedVariant
? `${selectedVariant.name} / ${selectedVariant.routeCodeText}` ? `${selectedVariant.name} / ${selectedVariant.routeCodeText}`
@@ -431,6 +598,153 @@ Page({
variantSelectorEmptyText, variantSelectorEmptyText,
selectableVariants, 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() { refreshPreparationDeviceState() {
@@ -576,10 +890,12 @@ Page({
refreshMockSourcePreparationStatus() { refreshMockSourcePreparationStatus() {
const channelId = loadStoredMockChannelId() const channelId = loadStoredMockChannelId()
const autoConnect = loadMockAutoConnectEnabled() const autoConnect = loadMockAutoConnectEnabled()
const showMockSourceSummary = autoConnect || channelId !== 'default'
this.setData({ this.setData({
mockSourceStatusText: autoConnect mockSourceStatusText: autoConnect
? `自动连接已开启 / 通道 ${channelId}` ? `调试源自动连接已开启 / 通道 ${channelId}`
: `自动连接未开启 / 通道 ${channelId}`, : `当前使用调试通道 ${channelId}`,
showMockSourceSummary,
}) })
}, },
@@ -660,19 +976,36 @@ Page({
selectedVariantText: selectedVariant selectedVariantText: selectedVariant
? `${selectedVariant.name} / ${selectedVariant.routeCodeText}` ? `${selectedVariant.name} / ${selectedVariant.routeCodeText}`
: '当前无需手动指定赛道', : '当前无需手动指定赛道',
runtimeVariantText: selectedVariant ? selectedVariant.name : '待 launch 确认', runtimeVariantText: selectedVariant ? selectedVariant.name : '进入地图后确认',
runtimeRouteCodeText: selectedVariant ? selectedVariant.routeCodeText : '待 launch 确认', 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, selectableVariants,
}) })
const page = this as unknown as EventPreparePageContext
if (page.lastPlayResult) {
this.loadPrepareMapPreview(page.lastPlayResult)
}
}, },
async handleLaunch() { async handleLaunch() {
const page = this as unknown as EventPreparePageContext
const accessToken = getAccessToken() const accessToken = getAccessToken()
if (!accessToken) { if (this.data.launchInFlight) {
wx.redirectTo({ url: '/pages/login/login' }) wx.showToast({
title: '正在进入地图,请稍候',
icon: 'none',
})
return return
} }
if (!this.data.canLaunch) { if (!this.data.canLaunch) {
this.setData({ this.setData({
statusText: '当前发布状态不可进入地图', statusText: '当前发布状态不可进入地图',
@@ -696,8 +1029,29 @@ Page({
} }
this.setData({ this.setData({
launchInFlight: true,
launchProgressText: '正在校验并创建本局',
launchProgressPercent: 24,
statusText: '正在创建 session 并进入地图', 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 { try {
const assignmentMode = this.data.assignmentMode ? this.data.assignmentMode : null const assignmentMode = this.data.assignmentMode ? this.data.assignmentMode : null
@@ -716,6 +1070,10 @@ Page({
phase: 'launch-requested', phase: 'launch-requested',
}, },
}) })
this.setData({
launchProgressText: '已发起启动请求,正在等待服务器响应',
launchProgressPercent: 52,
})
const app = getApp<IAppOption>() const app = getApp<IAppOption>()
if (app.globalData) { if (app.globalData) {
const pendingDeviceName = prepareHeartRateController && prepareHeartRateController.currentDeviceName const pendingDeviceName = prepareHeartRateController && prepareHeartRateController.currentDeviceName
@@ -730,13 +1088,29 @@ Page({
prepareHeartRateController.destroy() prepareHeartRateController.destroy()
prepareHeartRateController = null prepareHeartRateController = null
} }
const result = await launchEvent({ const result = accessToken
baseUrl: loadBackendBaseUrl(), ? await launchEvent({
eventId: this.data.eventId, baseUrl: loadBackendBaseUrl(),
accessToken, eventId: this.data.eventId,
accessToken,
variantId: this.data.showVariantSelector ? this.data.selectedVariantId : undefined, variantId: this.data.showVariantSelector ? this.data.selectedVariantId : undefined,
clientType: 'wechat', clientType: 'wechat',
deviceKey: 'mini-dev-device-001', 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({ reportBackendClientLog({
level: 'info', level: 'info',
@@ -749,6 +1123,7 @@ Page({
? result.launch.resolvedRelease.manifestUrl ? result.launch.resolvedRelease.manifestUrl
: '', : '',
details: { details: {
guestMode: !accessToken,
pageEventId: this.data.eventId || '', pageEventId: this.data.eventId || '',
launchEventId: result.launch.business && result.launch.business.eventId ? result.launch.business.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 : '', launchSessionId: result.launch.business && result.launch.business.sessionId ? result.launch.business.sessionId : '',
@@ -769,8 +1144,15 @@ Page({
url: prepareMapPageUrlForLaunch(envelope), url: prepareMapPageUrlForLaunch(envelope),
}) })
} catch (error) { } catch (error) {
if (page.launchAttemptSeq !== launchSeq) {
return
}
clearPrepareLaunchTimeout(page)
const message = error && (error as { message?: string }).message ? (error as { message: string }).message : '未知错误' const message = error && (error as { message?: string }).message ? (error as { message: string }).message : '未知错误'
this.setData({ this.setData({
launchInFlight: false,
launchProgressText: '进入地图失败',
launchProgressPercent: 0,
statusText: `launch 失败:${message}`, statusText: `launch 失败:${message}`,
}) })
} }

View File

@@ -7,18 +7,28 @@
</view> </view>
<view class="panel"> <view class="panel">
<view class="panel__title">活动与发布</view> <view class="panel__title">当前准备状态</view>
<view class="summary">Release{{releaseText}}</view> <view class="summary">先确认赛道、设备和权限,再进入地图开始本局。</view>
<view class="summary">主动作:{{actionText}}</view> <view class="row">
<view class="summary">状态:{{statusText}}</view> <view class="row__label">当前发布</view>
<view class="row__value">{{releaseText}}</view>
</view>
<view class="row">
<view class="row__label">当前动作</view>
<view class="row__value">{{actionText}}</view>
</view>
<view class="row">
<view class="row__label">进入状态</view>
<view class="row__value">{{statusText}}</view>
</view>
<view class="summary">赛道模式:{{variantModeText}}</view> <view class="summary">赛道模式:{{variantModeText}}</view>
<view class="summary">赛道摘要:{{variantSummaryText}}</view> <view class="summary">赛道摘要:{{variantSummaryText}}</view>
<view class="summary">当前选择:{{selectedVariantText}}</view> <view class="summary">当前选择:{{selectedVariantText}}</view>
</view> </view>
<view class="panel"> <view class="panel">
<view class="panel__title">活动运营摘要</view> <view class="panel__title">活动版本摘要</view>
<view class="summary">当前阶段先展示当前发布 release 绑定的活动运营对象摘要,不展开复杂 schema。</view> <view class="summary">这里展示本次进入地图将会使用的发布对象摘要,方便你确认当前活动版本。</view>
<view class="row"> <view class="row">
<view class="row__label">当前发布展示版本</view> <view class="row__label">当前发布展示版本</view>
<view class="row__value">{{presentationText}}</view> <view class="row__value">{{presentationText}}</view>
@@ -30,8 +40,8 @@
</view> </view>
<view class="panel"> <view class="panel">
<view class="panel__title">运行对象摘要</view> <view class="panel__title">本局对象预览</view>
<view class="summary">当前阶段以前端已知信息预览,最终绑定以后端 `launch.runtime` 为准。</view> <view class="summary">进入地图前先用已知信息预览,最终绑定以后端 launch 返回结果为准。</view>
<view class="row"> <view class="row">
<view class="row__label">地点</view> <view class="row__label">地点</view>
<view class="row__value">{{runtimePlaceText}}</view> <view class="row__value">{{runtimePlaceText}}</view>
@@ -48,11 +58,39 @@
<view class="row__label">RouteCode</view> <view class="row__label">RouteCode</view>
<view class="row__value">{{runtimeRouteCodeText}}</view> <view class="row__value">{{runtimeRouteCodeText}}</view>
</view> </view>
<view class="preview-card" wx:if="{{previewVisible}}">
<view class="preview-card__header">
<view class="preview-card__title">地图预览</view>
<view class="preview-card__status">{{previewStatusText}}</view>
</view>
<view class="preview-card__variant">{{previewVariantText}}</view>
<view class="preview-frame">
<view class="preview-stage">
<image
wx:for="{{previewTiles}}"
wx:key="url"
class="preview-tile"
src="{{item.url}}"
mode="scaleToFill"
style="{{item.styleText}}"
/>
<view class="preview-wash"></view>
<view
wx:for="{{previewControls}}"
wx:key="styleText"
class="preview-control {{item.kindClass}}"
style="{{item.styleText}}"
></view>
<view wx:if="{{previewLoading}}" class="preview-loading">预览加载中...</view>
</view>
</view>
<view class="summary">{{previewHintText}}</view>
</view>
</view> </view>
<view class="panel" wx:if="{{showVariantSelector}}"> <view class="panel" wx:if="{{showVariantSelector}}">
<view class="panel__title">赛道选择</view> <view class="panel__title">赛道选择</view>
<view class="summary">当前活动要求手动指定赛道。这里的选择会随 launch 一起带给后端,最终绑定以后端返回为准。</view> <view class="summary">如果当前活动支持手动选赛道,请先在这里确认你的本局路线。</view>
<view wx:if="{{!selectableVariants.length}}" class="summary">{{variantSelectorEmptyText}}</view> <view wx:if="{{!selectableVariants.length}}" class="summary">{{variantSelectorEmptyText}}</view>
<view wx:if="{{selectableVariants.length}}" class="variant-list"> <view wx:if="{{selectableVariants.length}}" class="variant-list">
<view wx:for="{{selectableVariants}}" wx:key="id" class="variant-card {{item.selected ? 'variant-card--active' : ''}}" data-variant-id="{{item.id}}" bindtap="handleSelectVariant"> <view wx:for="{{selectableVariants}}" wx:key="id" class="variant-card {{item.selected ? 'variant-card--active' : ''}}" data-variant-id="{{item.id}}" bindtap="handleSelectVariant">
@@ -70,7 +108,7 @@
<view class="panel"> <view class="panel">
<view class="panel__title">设备准备</view> <view class="panel__title">设备准备</view>
<view class="summary">这一页现在负责局前设备准备。定位权限先在这里确认,心率带支持先连后进图,地图内仍保留局中快速重连入口。</view> <view class="summary">定位权限建议先在这里确认;如果需要心率带,也建议先连接后再进入地图。</view>
<view class="row"> <view class="row">
<view class="row__label">定位状态</view> <view class="row__label">定位状态</view>
<view class="row__value">{{locationStatusText}}</view> <view class="row__value">{{locationStatusText}}</view>
@@ -92,7 +130,7 @@
<view class="row__label">扫描状态</view> <view class="row__label">扫描状态</view>
<view class="row__value">{{heartRateScanText}}</view> <view class="row__value">{{heartRateScanText}}</view>
</view> </view>
<view class="row"> <view class="row" wx:if="{{showMockSourceSummary}}">
<view class="row__label">模拟源</view> <view class="row__label">模拟源</view>
<view class="row__value">{{mockSourceStatusText}}</view> <view class="row__value">{{mockSourceStatusText}}</view>
</view> </view>
@@ -105,12 +143,21 @@
</view> </view>
<view class="panel"> <view class="panel">
<view class="panel__title">开始比赛</view> <view class="panel__title">进入地图</view>
<view class="summary">这一页先承担局前准备壳子,后面会继续接定位权限、心率带局前连接和设备检查。</view> <view class="summary">进入地图后无需再次点开始;按玩法规则前往开始点,即可正式开始比赛。</view>
<view wx:if="{{launchInFlight}}" class="launch-progress">
<view class="launch-progress__row">
<text class="launch-progress__label">当前进度</text>
<text class="launch-progress__value">{{launchProgressText}}</text>
</view>
<view class="launch-progress__track">
<view class="launch-progress__fill" style="width: {{launchProgressPercent}}%;"></view>
</view>
</view>
<view class="actions"> <view class="actions">
<button class="btn btn--secondary" bindtap="handleBack">返回活动页</button> <button class="btn btn--secondary" bindtap="handleBack" disabled="{{launchInFlight}}">返回活动页</button>
<button class="btn btn--ghost" bindtap="handleRefresh">刷新</button> <button class="btn btn--ghost" bindtap="handleRefresh" disabled="{{launchInFlight}}">刷新</button>
<button class="btn btn--primary" bindtap="handleLaunch" disabled="{{!canLaunch}}">进入地图</button> <button class="btn btn--primary" bindtap="handleLaunch" disabled="{{!canLaunch || launchInFlight}}">{{launchInFlight ? '正在进入地图...' : '进入地图'}}</button>
</view> </view>
</view> </view>
</view> </view>

View File

@@ -88,6 +88,50 @@ page {
flex-wrap: wrap; 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 { .device-list {
display: grid; display: grid;
gap: 14rpx; gap: 14rpx;
@@ -98,6 +142,106 @@ page {
gap: 14rpx; 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 { .variant-card {
display: grid; display: grid;
gap: 8rpx; gap: 8rpx;

View File

@@ -1,16 +1,18 @@
import { loadBackendAuthTokens, loadBackendBaseUrl } from '../../utils/backendAuth' 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 { formatBackendPlayActionText, formatBackendPlayStatusText } from '../../utils/backendPlayCopy'
import { reportBackendClientLog } from '../../utils/backendClientLogs' import { reportBackendClientLog } from '../../utils/backendClientLogs'
type EventPageData = { type EventPageData = {
eventId: string eventId: string
loading: boolean loading: boolean
canLaunch: boolean
titleText: string titleText: string
summaryText: string summaryText: string
releaseText: string releaseText: string
actionText: string actionText: string
statusText: string statusText: string
primaryButtonText: string
variantModeText: string variantModeText: string
variantSummaryText: string variantSummaryText: string
presentationText: string presentationText: string
@@ -78,11 +80,13 @@ Page({
data: { data: {
eventId: '', eventId: '',
loading: false, loading: false,
canLaunch: false,
titleText: '活动详情', titleText: '活动详情',
summaryText: '未加载', summaryText: '未加载',
releaseText: '--', releaseText: '--',
actionText: '--', actionText: '--',
statusText: '待加载', statusText: '待加载',
primaryButtonText: '前往准备页',
variantModeText: '--', variantModeText: '--',
variantSummaryText: '--', variantSummaryText: '--',
presentationText: '--', presentationText: '--',
@@ -104,10 +108,6 @@ Page({
async loadEventPlay(eventId?: string) { async loadEventPlay(eventId?: string) {
const targetEventId = eventId || this.data.eventId const targetEventId = eventId || this.data.eventId
const accessToken = getAccessToken() const accessToken = getAccessToken()
if (!accessToken) {
wx.redirectTo({ url: '/pages/login/login' })
return
}
this.setData({ this.setData({
loading: true, loading: true,
@@ -115,11 +115,17 @@ Page({
}) })
try { try {
const result = await getEventPlay({ const baseUrl = loadBackendBaseUrl()
baseUrl: loadBackendBaseUrl(), const result = accessToken
eventId: targetEventId, ? await getEventPlay({
accessToken, baseUrl,
}) eventId: targetEventId,
accessToken,
})
: await getPublicEventPlay({
baseUrl,
eventId: targetEventId,
})
this.applyEventPlay(result) this.applyEventPlay(result)
} catch (error) { } catch (error) {
const message = error && (error as { message?: string }).message ? (error as { message: string }).message : '未知错误' const message = error && (error as { message?: string }).message ? (error as { message: string }).message : '未知错误'
@@ -144,6 +150,7 @@ Page({
? result.resolvedRelease.manifestUrl ? result.resolvedRelease.manifestUrl
: '', : '',
details: { details: {
guestMode: !getAccessToken(),
pageEventId: this.data.eventId || '', pageEventId: this.data.eventId || '',
resultEventId: result.event.id || '', resultEventId: result.event.id || '',
primaryAction: result.play.primaryAction || '', primaryAction: result.play.primaryAction || '',
@@ -161,6 +168,7 @@ Page({
}) })
this.setData({ this.setData({
loading: false, loading: false,
canLaunch: result.play.canLaunch,
titleText: result.event.displayName, titleText: result.event.displayName,
summaryText: result.event.summary || '暂无活动简介', summaryText: result.event.summary || '暂无活动简介',
releaseText: result.resolvedRelease releaseText: result.resolvedRelease
@@ -168,6 +176,7 @@ Page({
: '当前无可用 release', : '当前无可用 release',
actionText: formatBackendPlayActionText(result.play.primaryAction, result.play.reason), actionText: formatBackendPlayActionText(result.play.primaryAction, result.play.reason),
statusText: formatBackendPlayStatusText(result.play.canLaunch, 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), variantModeText: formatAssignmentMode(result.play.assignmentMode),
variantSummaryText: formatVariantSummary(result), variantSummaryText: formatVariantSummary(result),
presentationText: formatPresentationSummary(result), presentationText: formatPresentationSummary(result),

View File

@@ -7,18 +7,23 @@
</view> </view>
<view class="panel"> <view class="panel">
<view class="panel__title">开始前准备</view> <view class="panel__title">当前状态</view>
<view class="summary">Release{{releaseText}}</view> <view class="status-chip {{canLaunch ? 'status-chip--ready' : 'status-chip--blocked'}}">{{statusText}}</view>
<view class="summary">主动作:{{actionText}}</view> <view class="summary">{{actionText}}</view>
<view class="summary">状态:{{statusText}}</view> <view class="summary">你可以先进入准备页查看赛道、设备和局前状态,再决定是否进入地图。</view>
<view class="actions">
<button class="btn btn--secondary" bindtap="handleRefresh">刷新</button>
<button class="btn btn--primary" bindtap="handleLaunch">{{primaryButtonText}}</button>
</view>
</view>
<view class="panel">
<view class="panel__title">赛道与版本</view>
<view class="summary">当前发布版本:{{releaseText}}</view>
<view class="summary">赛道模式:{{variantModeText}}</view> <view class="summary">赛道模式:{{variantModeText}}</view>
<view class="summary">赛道摘要:{{variantSummaryText}}</view> <view class="summary">赛道摘要:{{variantSummaryText}}</view>
<view class="summary">当前发布展示版本:{{presentationText}}</view> <view class="summary">当前发布展示版本:{{presentationText}}</view>
<view class="summary">当前发布内容包版本:{{contentBundleText}}</view> <view class="summary">当前发布内容包版本:{{contentBundleText}}</view>
<view class="actions">
<button class="btn btn--secondary" bindtap="handleRefresh">刷新</button>
<button class="btn btn--primary" bindtap="handleLaunch">前往准备页</button>
</view>
</view> </view>
</view> </view>
</scroll-view> </scroll-view>

View File

@@ -61,6 +61,27 @@ page {
color: #30465f; 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 { .actions {
display: flex; display: flex;
gap: 16rpx; gap: 16rpx;

View File

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

View File

@@ -0,0 +1,42 @@
<scroll-view class="page" scroll-y>
<view class="shell">
<view class="hero">
<view class="hero__eyebrow">Map Detail</view>
<view class="hero__title">{{mapText}}</view>
<view class="hero__desc">{{placeText}}</view>
</view>
<view class="panel">
<view class="panel__title">地图信息</view>
<view class="summary">{{summaryText}}</view>
<view class="summary">{{tileInfoText}}</view>
<view class="summary">{{statusText}}</view>
<view class="actions">
<button class="btn btn--secondary" bindtap="handleRefresh">刷新详情</button>
</view>
</view>
<view class="panel">
<view class="panel__title">默认体验活动</view>
<view wx:if="{{!cards.length}}" class="summary">当前暂无体验活动</view>
<view wx:for="{{cards}}" wx:key="eventId" class="card {{item.disabled ? 'card--disabled' : ''}}" bindtap="handleOpenExperience" data-event-id="{{item.eventId}}">
<view class="card__top">
<text class="card__badge">体验</text>
<text class="card__type">{{item.eventTypeText}}</text>
</view>
<view class="card__title">{{item.titleText}}</view>
<view class="card__subtitle">{{item.subtitleText}}</view>
<view class="card__meta-row">
<text class="card__meta">{{item.statusText}}</text>
</view>
<view class="card__meta-row">
<text class="card__meta">展示:{{item.presentationText}}</text>
</view>
<view class="card__meta-row">
<text class="card__meta">内容:{{item.contentBundleText}}</text>
</view>
<view class="card__cta">{{item.ctaText}}</view>
</view>
</view>
</view>
</scroll-view>

View File

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

View File

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

View File

@@ -0,0 +1,29 @@
<scroll-view class="page" scroll-y>
<view class="shell">
<view class="hero">
<view class="hero__eyebrow">Map Experience</view>
<view class="hero__title">地图体验</view>
<view class="hero__desc">先选地点与地图,再进入默认体验活动。</view>
</view>
<view class="panel">
<view class="panel__title">当前状态</view>
<view class="summary">{{statusText}}</view>
<view class="actions">
<button class="btn btn--secondary" bindtap="handleRefresh">刷新列表</button>
</view>
</view>
<view class="panel">
<view class="panel__title">地图卡片</view>
<view wx:if="{{!cards.length}}" class="summary">当前没有可体验地图</view>
<view wx:for="{{cards}}" wx:key="mapId" class="card {{item.disabled ? 'card--disabled' : ''}}" bindtap="handleOpenMap" data-map-id="{{item.mapId}}">
<image wx:if="{{item.coverUrl}}" class="card__cover" src="{{item.coverUrl}}" mode="aspectFill"></image>
<view class="card__title">{{item.mapText}}</view>
<view class="card__subtitle">{{item.placeText}}</view>
<view class="card__summary">{{item.summaryText}}</view>
<view class="card__meta">{{item.defaultExperienceText}}</view>
</view>
</view>
</view>
</scroll-view>

View File

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

View File

@@ -1,7 +1,9 @@
import { clearBackendAuthTokens, loadBackendAuthTokens, loadBackendBaseUrl } from '../../utils/backendAuth' 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 { reportBackendClientLog } from '../../utils/backendClientLogs'
import { setGlobalMockDebugBridgeEnabled } from '../../utils/globalMockDebugBridge' 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_CODE = 'mini-demo'
const DEFAULT_CHANNEL_TYPE = 'wechat_mini' const DEFAULT_CHANNEL_TYPE = 'wechat_mini'
@@ -16,6 +18,10 @@ type HomePageData = {
recentSessionText: string recentSessionText: string
ongoingRuntimeText: string ongoingRuntimeText: string
recentRuntimeText: string recentRuntimeText: string
ongoingActionHintText: string
showOngoingPanel: boolean
canRecoverOngoing: boolean
canAbandonOngoing: boolean
cards: BackendCardResult[] cards: BackendCardResult[]
} }
@@ -50,6 +56,15 @@ function requireAuthToken(): string | null {
return tokens && tokens.accessToken ? tokens.accessToken : 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({ Page({
data: { data: {
loading: false, loading: false,
@@ -61,6 +76,10 @@ Page({
recentSessionText: '无', recentSessionText: '无',
ongoingRuntimeText: '运行对象 --', ongoingRuntimeText: '运行对象 --',
recentRuntimeText: '运行对象 --', recentRuntimeText: '运行对象 --',
ongoingActionHintText: '当前没有可恢复的进行中对局',
showOngoingPanel: false,
canRecoverOngoing: false,
canAbandonOngoing: false,
cards: [], cards: [],
} as HomePageData, } as HomePageData,
@@ -102,6 +121,16 @@ Page({
}, },
applyEntryHomeResult(result: BackendEntryHomeResult) { 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({ reportBackendClientLog({
level: 'info', level: 'info',
category: 'entry-home', category: 'entry-home',
@@ -112,6 +141,9 @@ Page({
recentSessionId: result.recentSession && result.recentSession.id ? result.recentSession.id : '', recentSessionId: result.recentSession && result.recentSession.id ? result.recentSession.id : '',
recentEventId: result.recentSession && result.recentSession.eventId ? result.recentSession.eventId : '', recentEventId: result.recentSession && result.recentSession.eventId ? result.recentSession.eventId : '',
cardEventIds: (result.cards || []).map((item) => (item.event && item.event.id ? item.event.id : '')), cardEventIds: (result.cards || []).map((item) => (item.event && item.event.id ? item.event.id : '')),
hasOngoingSession: !!ongoingSession,
recoverySnapshotSessionId,
canRecoverOngoing,
}, },
}) })
this.setData({ this.setData({
@@ -124,6 +156,10 @@ Page({
recentSessionText: formatSessionSummary(result.recentSession), recentSessionText: formatSessionSummary(result.recentSession),
ongoingRuntimeText: formatRuntimeSummary(result.ongoingSession), ongoingRuntimeText: formatRuntimeSummary(result.ongoingSession),
recentRuntimeText: formatRuntimeSummary(result.recentSession), recentRuntimeText: formatRuntimeSummary(result.recentSession),
ongoingActionHintText,
showOngoingPanel: !!ongoingSession,
canRecoverOngoing,
canAbandonOngoing,
cards: result.cards || [], 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() { handleLogout() {
clearBackendAuthTokens() clearBackendAuthTokens()
setGlobalMockDebugBridgeEnabled(false) setGlobalMockDebugBridgeEnabled(false)

View File

@@ -10,18 +10,31 @@
<view class="panel"> <view class="panel">
<view class="panel__title">当前状态</view> <view class="panel__title">当前状态</view>
<view class="summary">{{statusText}}</view> <view class="summary">{{statusText}}</view>
<view class="summary">进行中:{{ongoingSessionText}}</view> <view wx:if="{{showOngoingPanel}}" class="summary">进行中:{{ongoingSessionText}}</view>
<view class="summary">进行中运行对象:{{ongoingRuntimeText}}</view> <view wx:if="{{showOngoingPanel}}" class="summary">进行中运行对象:{{ongoingRuntimeText}}</view>
<view wx:if="{{showOngoingPanel}}" class="summary">{{ongoingActionHintText}}</view>
<view class="summary">最近一局:{{recentSessionText}}</view> <view class="summary">最近一局:{{recentSessionText}}</view>
<view class="summary">最近一局运行对象:{{recentRuntimeText}}</view> <view class="summary">最近一局运行对象:{{recentRuntimeText}}</view>
<view class="actions"> <view class="actions">
<button class="btn btn--secondary" bindtap="handleRefresh">刷新首页</button> <button class="btn btn--secondary" bindtap="handleRefresh">刷新首页</button>
<button class="btn btn--ghost" bindtap="handleOpenEventList">活动列表</button> <button class="btn btn--ghost" bindtap="handleOpenEventList">活动列表</button>
<button class="btn btn--ghost" bindtap="handleOpenExperienceMaps">地图体验</button>
<button class="btn btn--ghost" bindtap="handleOpenRecentResult">查看结果</button> <button class="btn btn--ghost" bindtap="handleOpenRecentResult">查看结果</button>
<button class="btn btn--ghost" bindtap="handleLogout">退出登录</button> <button class="btn btn--ghost" bindtap="handleLogout">退出登录</button>
</view> </view>
</view> </view>
<view wx:if="{{showOngoingPanel}}" class="panel">
<view class="panel__title">进行中的游戏</view>
<view class="summary">{{ongoingSessionText}}</view>
<view class="summary">{{ongoingRuntimeText}}</view>
<view class="summary">{{ongoingActionHintText}}</view>
<view class="actions">
<button class="btn btn--secondary" bindtap="handleResumeOngoing" disabled="{{!canRecoverOngoing}}">恢复</button>
<button class="btn btn--ghost" bindtap="handleAbandonOngoing" disabled="{{!canAbandonOngoing}}">放弃</button>
</view>
</view>
<view class="panel"> <view class="panel">
<view class="panel__title">活动入口</view> <view class="panel__title">活动入口</view>
<view wx:if="{{!cards.length}}" class="summary">当前没有首页卡片</view> <view wx:if="{{!cards.length}}" class="summary">当前没有首页卡片</view>

View File

@@ -1,16 +1,7 @@
import { finishSession } from '../../utils/backendApi' import { loadBackendAuthTokens } from '../../utils/backendAuth'
import { loadBackendAuthTokens, loadBackendBaseUrl } from '../../utils/backendAuth'
import { clearSessionRecoverySnapshot, loadSessionRecoverySnapshot } from '../../game/core/sessionRecovery'
import { getBackendSessionContextFromLaunchEnvelope, prepareMapPageUrlForRecovery } from '../../utils/gameLaunch'
Page({ Page({
onLoad() { onLoad() {
const recoverySnapshot = loadSessionRecoverySnapshot()
if (recoverySnapshot) {
this.promptRecoveryAtEntry()
return
}
this.redirectToDefaultEntry() this.redirectToDefaultEntry()
}, },
@@ -21,59 +12,4 @@ Page({
: '/pages/login/login' : '/pages/login/login'
wx.redirectTo({ url }) 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()
})
},
})
},
}) })

View File

@@ -126,4 +126,21 @@ Page({
statusText: '已清空登录态', statusText: '已清空登录态',
}) })
}, },
handleContinueAsGuest() {
const baseUrl = this.persistBaseUrl()
clearBackendAuthTokens()
setGlobalMockDebugBridgeEnabled(false)
const app = getApp<IAppOption>()
if (app.globalData) {
app.globalData.backendBaseUrl = baseUrl
app.globalData.backendAuthTokens = null
}
this.setData({
statusText: '已切换到游客模式,准备进入地图体验',
})
wx.redirectTo({
url: '/pages/experience-maps/experience-maps',
})
},
}) })

View File

@@ -23,6 +23,7 @@
<view class="actions"> <view class="actions">
<button class="btn btn--primary" bindtap="handleLoginWithDevCode">开发码登录</button> <button class="btn btn--primary" bindtap="handleLoginWithDevCode">开发码登录</button>
<button class="btn btn--secondary" bindtap="handleLoginWithWechat">wx.login 登录</button> <button class="btn btn--secondary" bindtap="handleLoginWithWechat">wx.login 登录</button>
<button class="btn btn--secondary" bindtap="handleContinueAsGuest">游客体验</button>
<button class="btn btn--ghost" bindtap="handleClearLoginState">清空登录态</button> <button class="btn btn--ghost" bindtap="handleClearLoginState">清空登录态</button>
</view> </view>
</view> </view>

View File

@@ -863,26 +863,6 @@ function buildLaunchConfigSummaryRows(envelope: GameLaunchEnvelope): MapEngineGa
return rows return rows
} }
function emitSimulatorLaunchDiagnostic(
stage: string,
payload: Record<string, unknown>,
) {
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({ Page({
data: { data: {
showDebugPanel: false, showDebugPanel: false,
@@ -1584,21 +1564,6 @@ Page({
}, },
loadGameLaunchEnvelope(envelope: GameLaunchEnvelope) { 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( this.loadMapConfigFromRemote(
envelope.config.configUrl, envelope.config.configUrl,
envelope.config.configLabel, envelope.config.configLabel,
@@ -2186,18 +2151,6 @@ Page({
return 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) currentEngine.applyRemoteMapConfig(config)
this.applyConfiguredSystemSettings(config) this.applyConfiguredSystemSettings(config)
const compiledProfile = this.applyCompiledRuntimeProfiles(true, { const compiledProfile = this.applyCompiledRuntimeProfiles(true, {
@@ -2248,14 +2201,6 @@ Page({
return 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 rawErrorMessage = error && error.message ? error.message : '未知错误'
const errorMessage = rawErrorMessage.indexOf('404') >= 0 const errorMessage = rawErrorMessage.indexOf('404') >= 0
? `release manifest 不存在或未发布 (${configLabel})` ? `release manifest 不存在或未发布 (${configLabel})`

View File

@@ -5,9 +5,13 @@ import type { GameLaunchEnvelope } from '../../utils/gameLaunch'
type ResultPageData = { type ResultPageData = {
sessionId: string sessionId: string
eventId: string
guestMode: boolean
statusText: string statusText: string
sessionTitleText: string sessionTitleText: string
sessionSubtitleText: string sessionSubtitleText: string
activitySummaryText: string
listButtonText: string
rows: Array<{ label: string; value: string }> rows: Array<{ label: string; value: string }>
} }
@@ -95,9 +99,13 @@ function loadPendingResultLaunchEnvelope(): GameLaunchEnvelope | null {
Page({ Page({
data: { data: {
sessionId: '', sessionId: '',
eventId: '',
guestMode: false,
statusText: '准备加载结果', statusText: '准备加载结果',
sessionTitleText: '结果页', sessionTitleText: '结果页',
sessionSubtitleText: '未加载', sessionSubtitleText: '未加载',
activitySummaryText: '你可以查看本局结果,也可以回到活动继续查看详情。',
listButtonText: '查看历史结果',
rows: [], rows: [],
} as ResultPageData, } as ResultPageData,
@@ -131,6 +139,16 @@ Page({
statusText: '正在加载结果', statusText: '正在加载结果',
sessionTitleText: snapshot.title, sessionTitleText: snapshot.title,
sessionSubtitleText: snapshot.subtitle, 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([ rows: appendRuntimeRows([
{ label: snapshot.heroLabel, value: snapshot.heroValue }, { label: snapshot.heroLabel, value: snapshot.heroValue },
...snapshot.rows.map((row) => ({ ...snapshot.rows.map((row) => ({
@@ -152,7 +170,11 @@ Page({
async loadSingleResult(sessionId: string) { async loadSingleResult(sessionId: string) {
const accessToken = getAccessToken() const accessToken = getAccessToken()
if (!accessToken) { if (!accessToken) {
wx.redirectTo({ url: '/pages/login/login' }) this.setData({
guestMode: true,
statusText: '游客模式当前不加载后端单局结果,先展示本地结果摘要',
listButtonText: '返回地图体验',
})
return return
} }
@@ -169,8 +191,12 @@ Page({
const pendingLaunchEnvelope = loadPendingResultLaunchEnvelope() const pendingLaunchEnvelope = loadPendingResultLaunchEnvelope()
this.setData({ this.setData({
statusText: '单局结果加载完成', statusText: '单局结果加载完成',
eventId: result.session.eventId || '',
sessionTitleText: result.session.eventName || result.session.eventDisplayName || result.session.eventId || result.session.id || result.session.sessionId, 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)}`, sessionSubtitleText: `${result.session.status || result.session.sessionStatus} / ${result.result.status} / ${formatRouteSummary(result.session)}`,
activitySummaryText: result.session.eventId
? '你可以继续查看这场活动的详情,或回看历史结果。'
: '你可以继续回看历史结果。',
rows: appendRuntimeRows([ rows: appendRuntimeRows([
{ label: '赛道版本', value: formatRouteSummary(result.session) }, { label: '赛道版本', value: formatRouteSummary(result.session) },
{ label: '最终得分', value: formatValue(result.result.finalScore) }, { label: '最终得分', value: formatValue(result.result.finalScore) },
@@ -199,8 +225,27 @@ Page({
}, },
handleBackToList() { handleBackToList() {
if (this.data.guestMode) {
wx.redirectTo({
url: '/pages/experience-maps/experience-maps',
})
return
}
wx.redirectTo({ wx.redirectTo({
url: '/pages/results/results', 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)}`,
})
},
}) })

View File

@@ -9,7 +9,11 @@
<view class="panel"> <view class="panel">
<view class="panel__title">当前状态</view> <view class="panel__title">当前状态</view>
<view class="summary">{{statusText}}</view> <view class="summary">{{statusText}}</view>
<button class="btn btn--ghost" bindtap="handleBackToList">查看历史结果</button> <view class="summary">{{activitySummaryText}}</view>
<view class="actions">
<button class="btn btn--secondary" wx:if="{{eventId}}" bindtap="handleBackToEvent">返回活动</button>
<button class="btn btn--ghost" bindtap="handleBackToList">{{listButtonText}}</button>
</view>
</view> </view>
<view wx:if="{{rows.length}}" class="panel"> <view wx:if="{{rows.length}}" class="panel">

View File

@@ -6,6 +6,7 @@ type ResultsPageData = {
statusText: string statusText: string
results: Array<{ results: Array<{
sessionId: string sessionId: string
eventId: string
titleText: string titleText: string
statusText: string statusText: string
scoreText: string scoreText: string
@@ -51,6 +52,7 @@ function formatRuntimeSummary(result: BackendSessionResultView): string {
function buildResultCardView(result: BackendSessionResultView) { function buildResultCardView(result: BackendSessionResultView) {
return { return {
sessionId: result.session.id, sessionId: result.session.id,
eventId: result.session.eventId || '',
titleText: result.session.eventName || result.session.id, titleText: result.session.eventName || result.session.id,
statusText: `${result.result.status} / ${result.session.status}`, statusText: `${result.result.status} / ${result.session.status}`,
scoreText: `得分 ${result.result.finalScore || '--'} / 用时 ${result.result.finalDurationSec || '--'}s`, scoreText: `得分 ${result.result.finalScore || '--'} / 用时 ${result.result.finalDurationSec || '--'}s`,
@@ -115,4 +117,18 @@ Page({
url: `/pages/result/result?sessionId=${encodeURIComponent(sessionId)}`, 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)}`,
})
},
}) })

View File

@@ -9,6 +9,7 @@
<view class="panel"> <view class="panel">
<view class="panel__title">当前状态</view> <view class="panel__title">当前状态</view>
<view class="summary">{{statusText}}</view> <view class="summary">{{statusText}}</view>
<view class="summary">你可以回看最近完成的对局,也可以回到对应活动继续查看详情。</view>
</view> </view>
<view class="panel"> <view class="panel">
@@ -20,6 +21,10 @@
<view class="result-card__meta">{{item.scoreText}}</view> <view class="result-card__meta">{{item.scoreText}}</view>
<view class="result-card__meta">{{item.routeText}}</view> <view class="result-card__meta">{{item.routeText}}</view>
<view class="result-card__meta">{{item.runtimeText}}</view> <view class="result-card__meta">{{item.runtimeText}}</view>
<view class="actions">
<button class="btn btn--secondary" data-session-id="{{item.sessionId}}" catchtap="handleOpenResult">查看单局结果</button>
<button class="btn btn--ghost" wx:if="{{item.eventId}}" data-event-id="{{item.eventId}}" catchtap="handleOpenEvent">返回活动</button>
</view>
</view> </view>
</view> </view>
</view> </view>

View File

@@ -63,12 +63,92 @@ export interface BackendPresentationSummary {
version?: string | null 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 { export interface BackendContentBundleSummary {
bundleId?: string | null bundleId?: string | null
bundleType?: string | null bundleType?: string | null
version?: 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 { export interface BackendEntrySessionSummary {
id: string id: string
status: string status: string
@@ -151,6 +231,7 @@ export interface BackendEventPlayResult {
} }
currentPresentation?: BackendPresentationSummary | null currentPresentation?: BackendPresentationSummary | null
currentContentBundle?: BackendContentBundleSummary | null currentContentBundle?: BackendContentBundleSummary | null
preview?: BackendPreviewSummary | null
release?: { release?: {
id: string id: string
configLabel: string configLabel: string
@@ -188,6 +269,7 @@ export interface BackendLaunchResult {
} }
business: { business: {
source: string source: string
isGuest?: boolean
eventId: string eventId: string
sessionId: string sessionId: string
sessionToken: string sessionToken: string
@@ -348,6 +430,17 @@ export function getEventPlay(input: {
}) })
} }
export function getPublicEventPlay(input: {
baseUrl: string
eventId: string
}): Promise<BackendEventPlayResult> {
return requestBackend<BackendEventPlayResult>({
method: 'GET',
baseUrl: input.baseUrl,
path: `/public/events/${encodeURIComponent(input.eventId)}/play`,
})
}
export function getEntryHome(input: { export function getEntryHome(input: {
baseUrl: string baseUrl: string
accessToken: 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<BackendLaunchResult> {
const body: Record<string, unknown> = {
clientType: input.clientType,
deviceKey: input.deviceKey,
}
if (input.releaseId) {
body.releaseId = input.releaseId
}
if (input.variantId) {
body.variantId = input.variantId
}
return requestBackend<BackendLaunchResult>({
method: 'POST',
baseUrl: input.baseUrl,
path: `/public/events/${encodeURIComponent(input.eventId)}/launch`,
body,
})
}
export function startSession(input: { export function startSession(input: {
baseUrl: string baseUrl: string
sessionId: string sessionId: string
@@ -463,3 +582,49 @@ export function postClientLog(input: {
body: input.payload as unknown as Record<string, unknown>, body: input.payload as unknown as Record<string, unknown>,
}) })
} }
export function getExperienceMaps(input: {
baseUrl: string
accessToken: string
}): Promise<BackendExperienceMapSummary[]> {
return requestBackend<BackendExperienceMapSummary[]>({
method: 'GET',
baseUrl: input.baseUrl,
path: '/experience-maps',
authToken: input.accessToken,
})
}
export function getPublicExperienceMaps(input: {
baseUrl: string
}): Promise<BackendExperienceMapSummary[]> {
return requestBackend<BackendExperienceMapSummary[]>({
method: 'GET',
baseUrl: input.baseUrl,
path: '/public/experience-maps',
})
}
export function getExperienceMapDetail(input: {
baseUrl: string
accessToken: string
mapAssetId: string
}): Promise<BackendExperienceMapDetail> {
return requestBackend<BackendExperienceMapDetail>({
method: 'GET',
baseUrl: input.baseUrl,
path: `/experience-maps/${encodeURIComponent(input.mapAssetId)}`,
authToken: input.accessToken,
})
}
export function getPublicExperienceMapDetail(input: {
baseUrl: string
mapAssetId: string
}): Promise<BackendExperienceMapDetail> {
return requestBackend<BackendExperienceMapDetail>({
method: 'GET',
baseUrl: input.baseUrl,
path: `/public/experience-maps/${encodeURIComponent(input.mapAssetId)}`,
})
}

View File

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

View File

@@ -1,6 +1,6 @@
# CMR Mini 开发架构阶段总结 # CMR Mini 开发架构阶段总结
> 文档版本v1.20 > 文档版本v1.24
> 最后更新2026-04-03 19:26:23 > 最后更新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) - [活动卡片列表最小产品方案](D:/dev/cmr-mini/doc/gameplay/活动卡片列表最小产品方案.md)
- 运维后台规划见:
- [运维后台第一期方案](D:/dev/cmr-mini/doc/gameplay/运维后台第一期方案.md)
- 面向后端线程的阶段性实施说明,优先写入根目录 [t2b.md](D:/dev/cmr-mini/t2b.md)。 - 面向后端线程的阶段性实施说明,优先写入根目录 [t2b.md](D:/dev/cmr-mini/t2b.md)。
- backend 新增写给总控线程的回写板: - backend 新增写给总控线程的回写板:
- [b2t.md](D:/dev/cmr-mini/b2t.md) - [b2t.md](D:/dev/cmr-mini/b2t.md)
@@ -69,7 +71,11 @@
- `presentation schema` - `presentation schema`
- `活动文案样例` - `活动文案样例`
- 活动卡片列表最小产品化第一刀已完成 - 活动卡片列表最小产品化第一刀已完成
- 当前主线进入“活动卡片列表第一刀联调回归与小范围修复阶段” - 当前主线进入“活动系统最小成品闭环回归与小范围修复阶段”
- 本周目标切换为:
- 完成活动系统最小成品闭环
- 小程序主界面从工程态过渡到用户态
- 运维后台第一期只做规划,不正式开工
- backend 当前应优先保证: - backend 当前应优先保证:
- 从空白环境直接可跑 - 从空白环境直接可跑
- workbench 日志能明确定位失败步骤 - workbench 日志能明确定位失败步骤
@@ -78,6 +84,16 @@
- 维护活动卡片列表第一刀所需最小摘要字段稳定 - 维护活动卡片列表第一刀所需最小摘要字段稳定
- 响应列表页联调中暴露的字段、默认值和语义问题 - 响应列表页联调中暴露的字段、默认值和语义问题
- 保持列表页与活动详情页摘要口径一致 - 保持列表页与活动详情页摘要口径一致
- 收口活动配置、发布、默认活动与自定义活动统一流
- 第一阶段活动模型按:
- 单地图
- 单路线组
- 单玩法
收口推进
- 当前不把复杂多地图 / 多路线组 / 多玩法语义硬塞进单个活动对象
- 为下周运维后台第一期准备对象与接口边界
- 继续保证三条标准 demo 活动无残留 `ongoing session`
- 继续保证一键回归链可从空白环境重复跑通
- 前端线程建议正式上场时机: - 前端线程建议正式上场时机:
- 现在已完成活动运营域摘要接线第一刀 - 现在已完成活动运营域摘要接线第一刀
- 当前已完成: - 当前已完成:
@@ -94,7 +110,7 @@
- 会话快照 - 会话快照
- 当前建议: - 当前建议:
- frontend 已完成活动卡片列表最小产品化第一刀 - frontend 已完成活动卡片列表最小产品化第一刀
- frontend 当前进入联调回归与小范围修复阶段 - frontend 当前进入活动系统最小成品闭环回归与小范围修复阶段
- 优先复用 backend 一键测试环境做回归 - 优先复用 backend 一键测试环境做回归
- 优先复用: - 优先复用:
- `回归结果汇总` - `回归结果汇总`
@@ -105,8 +121,21 @@
- 不做复杂运营样式 - 不做复杂运营样式
- frontend 当前分工: - frontend 当前分工:
- 活动列表页第一刀回归与小修 - 活动列表页第一刀回归与小修
- 地图体验第一刀回归与小修
- 游客模式第一刀回归与小修
- 结构化日志补充 - 结构化日志补充
- 配合 backend 收口字段与默认值 - 配合 backend 收口字段与默认值
- 活动详情页、准备页、结果页、历史页去工程味
- 在当前活动链、地图体验链、游客体验链内完成从工程态到用户态的过渡
- 不进入活动列表第二刀扩展
- 不进入地图体验第二刀
- 不进入游客模式第二刀
- 不做首页大重构
- 准备页地图预览按“用户化增强项”推进:
- 低级别正式瓦片底图
- 前端动态叠加当前赛道
- 只读展示
- 不做第二套交互地图
当前阶段的核心目标已经从“把地图画出来”升级为“建立一套可长期扩展的运动地图游戏底座”。 当前阶段的核心目标已经从“把地图画出来”升级为“建立一套可长期扩展的运动地图游戏底座”。
这套底座已经具备以下关键能力: 这套底座已经具备以下关键能力:

1012
t2b.md

File diff suppressed because it is too large Load Diff

259
t2f.md
View File

@@ -1,181 +1,126 @@
# T2F 协作清单 # T2F 协作清单
> 文档版本v1.10 > 文档版本v2.1
> 最后更新2026-04-03 19:26:23 > 最后更新2026-04-07 22:35:00
说明: 说明:
- 本文件由总控维护,写给前端线程 - 本文件由总控维护,写给 frontend 线程
-当前阶段实施说明,不写长讨论稿 -保留当前阶段信息
- 正式架构与长期结论以 `doc/` 下文档为准 - 历史说明已归档到 [T2F阶段归档](D:/dev/cmr-mini/doc/archive/协作/T2F阶段归档.md)
- 正式方案以 `doc/` 下文档为准
--- ---
## 1. 当前目标 ## 当前阶段
当前前端线程已完成 当前 frontend 所处阶段
- 活动运营域摘要接线第一刀 **活动系统最小成品闭环回归与小范围修复阶段**
当前目标:
1. 活动列表可用且稳定
2. 活动详情页更像用户页
3. 活动准备页更像用户页
4. 结果页 / 历史页和活动链自然衔接
5. 地图体验链与游客体验链进入当前最小成品闭环
6. 不破坏 runtime 稳定主链
---
## 当前已完成基线
frontend 当前已稳定具备:
- runtime 摘要链第一阶段接线
- 活动运营域摘要第一刀接线
- 活动卡片列表最小产品化第一刀 - 活动卡片列表最小产品化第一刀
- 独立活动列表页
当前进入: - `全部 / 体验` 最小筛选
- 列表跳活动详情
**活动卡片列表最小产品化第一刀联调回归与小范围修复阶段** - 活动详情页与准备页摘要接线
- 会话快照接线
本阶段目标: - 地图体验第一刀
- 游客模式第一刀
- 在 backend 一键测试环境下回归活动列表页第一刀 - 准备页地图预览 V1
- 验证卡片字段、分组、跳转与详情页链路稳定
- 只做小范围修复,不扩更多玩家侧新链
- 继续保持 runtime 主链稳定
--- ---
## 2. 当前后端已完成能力 ## 当前任务
后端当前已完成: ### 1. 活动列表第一刀回归与小修
- `GET /events/{eventPublicID}` 透出: - 校验字段是否够用
- `currentPresentation` - 校验 `全部 / 体验` 分组是否合理
- `currentContentBundle` - 校验列表到详情页跳转是否稳定
- `GET /events/{eventPublicID}/play` 透出:
- `currentPresentation` ### 1.1 地图体验链回归
- `currentContentBundle`
- `POST /events/{eventPublicID}/launch` 透出: - 校验首页 `地图体验` 入口可达
- `launch.presentation` - 校验地图列表 -> 地图详情 -> 默认体验活动入口链稳定
- `launch.contentBundle` - 校验默认体验活动与普通活动衔接自然
- publish 在未显式传入:
- `presentationId` ### 1.2 游客模式第一刀回归
- `contentBundleId`
时,可按 event 当前 active 配置自动补齐 - 校验登录页 `游客体验` 入口
- runtime 主链继续保持稳定兼容: - 校验游客走 `/public/...` 链是否稳定
- `resolvedRelease` - 校验游客结果页本地摘要是否自然
- `business`
- `variant` ### 2. 活动详情页用户化
- `runtime`
- backend 当前测试能力已升级: - 调整信息层级
- `Bootstrap Demo` - 让活动状态、时间、CTA 更容易理解
- `一键补齐 Runtime 并发布` - 不暴露后台复杂性
- `一键标准回归`
- `回归结果汇总` ### 3. 活动准备页用户化
- `当前 Launch 实际配置摘要`
- 分步日志 / 真实错误 / stack / 最后一次 curl / 预期判定 - 保留必要摘要
- `POST /dev/client-logs` - 收工程感
- 不把过多 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. 当前阶段原则 ## 一句话
- 玩家面对的是前端,前端页面必须保持干净、利落、人性化 当前 frontend 最重要的事是:
- 先接新增摘要,不重构整条前端主链
- `resolvedRelease / business / variant` 旧字段继续保留
- runtime 主链已经稳定,不要为了活动运营摘要去动 runtime 主链
- 先做“看得见活动运营对象”,不先做复杂运营化样式
- 当前活动列表第一刀允许扩一个独立列表页,但不扩更多玩家侧新链
- 当前联调应优先复用 backend 一键测试环境,不再各自手工铺多份 demo 对象
- 当前联调应优先复用 backend 提供的结构化诊断链,不再依赖截图 + 口头描述排查
--- **把活动列表、地图体验、游客体验和相关活动链页面一起收顺,从工程态过渡到用户态。**
## 5. 当前待前端回写
请前端线程后续重点回写:
1. 列表字段是否足够支持当前最小卡片
2. `全部 / 体验` 分组是否符合当前产品预期
3. 卡片点击进入活动详情页是否稳定
4. 是否需要 backend 再补名称摘要、状态字段或默认值
5. 有没有因为活动列表接线影响到 runtime 稳定主链
---
## 6. 当前总控确认
1. 活动运营域摘要第一刀视为已完成
2. 前端当前进入联调回归与小范围修复阶段
3. 当前只接受字段修正、摘要打磨、一致性修复
4. 当前不继续扩更多玩家侧新链,不做复杂运营样式
5. 如果前端发现缺字段,再由总控统一回写给 backend
6. 当前前端下一步重点是配合 backend 的一键测试环境做稳定回归
7. 当前前端继续只做:
- 联调回归
- 小范围修复
- 结构化日志补充
8. 当前活动列表第一刀已完成,暂不进入第二刀产品扩展
---
## 7. 一句话结论
当前前端最重要的事不是继续扩新页面,而是:
**把活动卡片列表最小产品化第一刀先稳住,并统一切到 backend 一键测试环境下做联调回归和小范围修复。**

82
t2w.md Normal file
View File

@@ -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` 重构成“品牌官网 + 活动门户 + 地图体验入口 + 商务转化站”的最小方案拆清楚。**

69
tmp/route01.kml Normal file
View File

@@ -0,0 +1,69 @@
<?xml version="1.0" encoding="utf-8" ?>
<kml xmlns="http://www.opengis.net/kml/2.2">
<Document id="root_doc">
<Schema name="路线01" id="路线01">
<SimpleField name="id" type="float"></SimpleField>
</Schema>
<Folder><name>路线01</name>
<Placemark id="路线01.1">
<ExtendedData><SchemaData schemaUrl="#路线01">
<SimpleData name="id">1</SimpleData>
</SchemaData></ExtendedData>
<Point><coordinates>117.000649296107,36.5921631022497</coordinates></Point>
</Placemark>
<Placemark id="路线01.2">
<ExtendedData><SchemaData schemaUrl="#路线01">
<SimpleData name="id">2</SimpleData>
</SchemaData></ExtendedData>
<Point><coordinates>116.999689737459,36.5922740961347</coordinates></Point>
</Placemark>
<Placemark id="路线01.3">
<ExtendedData><SchemaData schemaUrl="#路线01">
<SimpleData name="id">3</SimpleData>
</SchemaData></ExtendedData>
<Point><coordinates>116.999108309973,36.5919019395375</coordinates></Point>
</Placemark>
<Placemark id="路线01.4">
<ExtendedData><SchemaData schemaUrl="#路线01">
<SimpleData name="id">4</SimpleData>
</SchemaData></ExtendedData>
<Point><coordinates>116.999823913032,36.591572220351</coordinates></Point>
</Placemark>
<Placemark id="路线01.5">
<ExtendedData><SchemaData schemaUrl="#路线01">
<SimpleData name="id">5</SimpleData>
</SchemaData></ExtendedData>
<Point><coordinates>116.999860506371,36.5912131186443</coordinates></Point>
</Placemark>
<Placemark id="路线01.6">
<ExtendedData><SchemaData schemaUrl="#路线01">
<SimpleData name="id">6</SimpleData>
</SchemaData></ExtendedData>
<Point><coordinates>117.000340285695,36.5909356298175</coordinates></Point>
</Placemark>
<Placemark id="路线01.7">
<ExtendedData><SchemaData schemaUrl="#路线01">
<SimpleData name="id">7</SimpleData>
</SchemaData></ExtendedData>
<Point><coordinates>117.000441933857,36.5915004001434</coordinates></Point>
</Placemark>
<Placemark id="路线01.8">
<ExtendedData><SchemaData schemaUrl="#路线01">
<SimpleData name="id">8</SimpleData>
</SchemaData></ExtendedData>
<Point><coordinates>117.001397426578,36.5915983367736</coordinates></Point>
</Placemark>
<Placemark id="路线01.9">
<ExtendedData><SchemaData schemaUrl="#路线01">
<SimpleData name="id">9</SimpleData>
</SchemaData></ExtendedData>
<Point><coordinates>117.000665559813,36.5919574366878</coordinates></Point>
</Placemark>
<Placemark id="路线01.10">
<ExtendedData><SchemaData schemaUrl="#路线01">
<SimpleData name="id">10</SimpleData>
</SchemaData></ExtendedData>
<Point><coordinates>117.000649296107,36.5921631022497</coordinates></Point>
</Placemark>
</Folder>
</Document></kml>

69
tmp/route02.kml Normal file
View File

@@ -0,0 +1,69 @@
<?xml version="1.0" encoding="utf-8" ?>
<kml xmlns="http://www.opengis.net/kml/2.2">
<Document id="root_doc">
<Schema name="路线02" id="路线02">
<SimpleField name="id" type="float"></SimpleField>
</Schema>
<Folder><name>路线02</name>
<Placemark id="路线02.1">
<ExtendedData><SchemaData schemaUrl="#路线02">
<SimpleData name="id">1</SimpleData>
</SchemaData></ExtendedData>
<Point><coordinates>117.000649296107,36.5921631022497</coordinates></Point>
</Placemark>
<Placemark id="路线02.2">
<ExtendedData><SchemaData schemaUrl="#路线02">
<SimpleData name="id">2</SimpleData>
</SchemaData></ExtendedData>
<Point><coordinates>116.999766990062,36.5921141343085</coordinates></Point>
</Placemark>
<Placemark id="路线02.3">
<ExtendedData><SchemaData schemaUrl="#路线02">
<SimpleData name="id">3</SimpleData>
</SchemaData></ExtendedData>
<Point><coordinates>116.999710067091,36.5917615642144</coordinates></Point>
</Placemark>
<Placemark id="路线02.4">
<ExtendedData><SchemaData schemaUrl="#路线02">
<SimpleData name="id">4</SimpleData>
</SchemaData></ExtendedData>
<Point><coordinates>116.998774904002,36.5913306430232</coordinates></Point>
</Placemark>
<Placemark id="路线02.5">
<ExtendedData><SchemaData schemaUrl="#路线02">
<SimpleData name="id">5</SimpleData>
</SchemaData></ExtendedData>
<Point><coordinates>116.998941606987,36.5908278985923</coordinates></Point>
</Placemark>
<Placemark id="路线02.6">
<ExtendedData><SchemaData schemaUrl="#路线02">
<SimpleData name="id">6</SimpleData>
</SchemaData></ExtendedData>
<Point><coordinates>117.00058830721,36.5905340853955</coordinates></Point>
</Placemark>
<Placemark id="路线02.7">
<ExtendedData><SchemaData schemaUrl="#路线02">
<SimpleData name="id">7</SimpleData>
</SchemaData></ExtendedData>
<Point><coordinates>117.000238637533,36.5914742836876</coordinates></Point>
</Placemark>
<Placemark id="路线02.8">
<ExtendedData><SchemaData schemaUrl="#路线02">
<SimpleData name="id">8</SimpleData>
</SchemaData></ExtendedData>
<Point><coordinates>117.000937976887,36.5916113949816</coordinates></Point>
</Placemark>
<Placemark id="路线02.9">
<ExtendedData><SchemaData schemaUrl="#路线02">
<SimpleData name="id">9</SimpleData>
</SchemaData></ExtendedData>
<Point><coordinates>117.000803801313,36.5919411140006</coordinates></Point>
</Placemark>
<Placemark id="路线02.10">
<ExtendedData><SchemaData schemaUrl="#路线02">
<SimpleData name="id">10</SimpleData>
</SchemaData></ExtendedData>
<Point><coordinates>117.000649296107,36.5921631022497</coordinates></Point>
</Placemark>
</Folder>
</Document></kml>

111
tmp/route03.kml Normal file
View File

@@ -0,0 +1,111 @@
<?xml version="1.0" ?>
<kml xmlns="http://www.opengis.net/kml/2.2">
<Document id="root_doc">
<Schema name="01" id="01">
<SimpleField name="id" type="float"/>
</Schema>
<Folder>
<name>01</name>
<Placemark id="路线01.10">
<ExtendedData>
<SchemaData schemaUrl="#路线01">
<SimpleData name="id">1</SimpleData>
</SchemaData>
</ExtendedData>
<Point>
<coordinates>117.000649296107,36.5921631022497</coordinates>
</Point>
</Placemark>
<Placemark id="路线01.9">
<ExtendedData>
<SchemaData schemaUrl="#路线01">
<SimpleData name="id">2</SimpleData>
</SchemaData>
</ExtendedData>
<Point>
<coordinates>117.000665559813,36.5919574366878</coordinates>
</Point>
</Placemark>
<Placemark id="路线01.8">
<ExtendedData>
<SchemaData schemaUrl="#路线01">
<SimpleData name="id">3</SimpleData>
</SchemaData>
</ExtendedData>
<Point>
<coordinates>117.001397426578,36.5915983367736</coordinates>
</Point>
</Placemark>
<Placemark id="路线01.7">
<ExtendedData>
<SchemaData schemaUrl="#路线01">
<SimpleData name="id">4</SimpleData>
</SchemaData>
</ExtendedData>
<Point>
<coordinates>117.000441933857,36.5915004001434</coordinates>
</Point>
</Placemark>
<Placemark id="路线01.6">
<ExtendedData>
<SchemaData schemaUrl="#路线01">
<SimpleData name="id">5</SimpleData>
</SchemaData>
</ExtendedData>
<Point>
<coordinates>117.000340285695,36.5909356298175</coordinates>
</Point>
</Placemark>
<Placemark id="路线01.5">
<ExtendedData>
<SchemaData schemaUrl="#路线01">
<SimpleData name="id">6</SimpleData>
</SchemaData>
</ExtendedData>
<Point>
<coordinates>116.999860506371,36.5912131186443</coordinates>
</Point>
</Placemark>
<Placemark id="路线01.4">
<ExtendedData>
<SchemaData schemaUrl="#路线01">
<SimpleData name="id">7</SimpleData>
</SchemaData>
</ExtendedData>
<Point>
<coordinates>116.999823913032,36.591572220351</coordinates>
</Point>
</Placemark>
<Placemark id="路线01.3">
<ExtendedData>
<SchemaData schemaUrl="#路线01">
<SimpleData name="id">8</SimpleData>
</SchemaData>
</ExtendedData>
<Point>
<coordinates>116.999108309973,36.5919019395375</coordinates>
</Point>
</Placemark>
<Placemark id="路线01.2">
<ExtendedData>
<SchemaData schemaUrl="#路线01">
<SimpleData name="id">9</SimpleData>
</SchemaData>
</ExtendedData>
<Point>
<coordinates>116.999689737459,36.5922740961347</coordinates>
</Point>
</Placemark>
<Placemark id="路线01.1">
<ExtendedData>
<SchemaData schemaUrl="#路线01">
<SimpleData name="id">10</SimpleData>
</SchemaData>
</ExtendedData>
<Point>
<coordinates>117.000649296107,36.5921631022497</coordinates>
</Point>
</Placemark>
</Folder>
</Document>
</kml>

111
tmp/route04.kml Normal file
View File

@@ -0,0 +1,111 @@
<?xml version="1.0" ?>
<kml xmlns="http://www.opengis.net/kml/2.2">
<Document id="root_doc">
<Schema name="01" id="01">
<SimpleField name="id" type="float"/>
</Schema>
<Folder>
<name>01</name>
<Placemark id="路线02.10">
<ExtendedData>
<SchemaData schemaUrl="#路线02">
<SimpleData name="id">1</SimpleData>
</SchemaData>
</ExtendedData>
<Point>
<coordinates>117.000649296107,36.5921631022497</coordinates>
</Point>
</Placemark>
<Placemark id="路线02.9">
<ExtendedData>
<SchemaData schemaUrl="#路线02">
<SimpleData name="id">2</SimpleData>
</SchemaData>
</ExtendedData>
<Point>
<coordinates>117.000803801313,36.5919411140006</coordinates>
</Point>
</Placemark>
<Placemark id="路线02.8">
<ExtendedData>
<SchemaData schemaUrl="#路线02">
<SimpleData name="id">3</SimpleData>
</SchemaData>
</ExtendedData>
<Point>
<coordinates>117.000937976887,36.5916113949816</coordinates>
</Point>
</Placemark>
<Placemark id="路线02.7">
<ExtendedData>
<SchemaData schemaUrl="#路线02">
<SimpleData name="id">4</SimpleData>
</SchemaData>
</ExtendedData>
<Point>
<coordinates>117.000238637533,36.5914742836876</coordinates>
</Point>
</Placemark>
<Placemark id="路线02.6">
<ExtendedData>
<SchemaData schemaUrl="#路线02">
<SimpleData name="id">5</SimpleData>
</SchemaData>
</ExtendedData>
<Point>
<coordinates>117.00058830721,36.5905340853955</coordinates>
</Point>
</Placemark>
<Placemark id="路线02.5">
<ExtendedData>
<SchemaData schemaUrl="#路线02">
<SimpleData name="id">6</SimpleData>
</SchemaData>
</ExtendedData>
<Point>
<coordinates>116.998941606987,36.5908278985923</coordinates>
</Point>
</Placemark>
<Placemark id="路线02.4">
<ExtendedData>
<SchemaData schemaUrl="#路线02">
<SimpleData name="id">7</SimpleData>
</SchemaData>
</ExtendedData>
<Point>
<coordinates>116.998774904002,36.5913306430232</coordinates>
</Point>
</Placemark>
<Placemark id="路线02.3">
<ExtendedData>
<SchemaData schemaUrl="#路线02">
<SimpleData name="id">8</SimpleData>
</SchemaData>
</ExtendedData>
<Point>
<coordinates>116.999710067091,36.5917615642144</coordinates>
</Point>
</Placemark>
<Placemark id="路线02.2">
<ExtendedData>
<SchemaData schemaUrl="#路线02">
<SimpleData name="id">9</SimpleData>
</SchemaData>
</ExtendedData>
<Point>
<coordinates>116.999766990062,36.5921141343085</coordinates>
</Point>
</Placemark>
<Placemark id="路线02.1">
<ExtendedData>
<SchemaData schemaUrl="#路线02">
<SimpleData name="id">10</SimpleData>
</SchemaData>
</ExtendedData>
<Point>
<coordinates>117.000649296107,36.5921631022497</coordinates>
</Point>
</Placemark>
</Folder>
</Document>
</kml>

Some files were not shown because too many files have changed in this diff Show More