diff --git a/b2f.md b/b2f.md index 4ed4b53..f71dcf9 100644 --- a/b2f.md +++ b/b2f.md @@ -1,6 +1,6 @@ # b2f > 文档版本:v1.0 -> 最后更新:2026-04-02 +> 最后更新:2026-04-02 09:01:17 说明: @@ -54,21 +54,23 @@ - frontend 是否确认正式流程只消费上述字段,不再自行推断 release URL - 是否已解决:否 -### B2F-003 - -- 时间:2026-04-01 -- 谁提的:backend -- 当前事实: - - backend 准备把“放弃恢复”收口为 `finish(cancelled)` 语义 - - 当前语义尚未最终拍板 -- 需要对方确认什么: - - frontend 是否可以先预埋“放弃恢复”调用位,但在语义确认前不默认启用 -- 是否已解决:否 - --- ## 已确认 +### B2F-003 + +- 时间:2026-04-02 +- 谁提的:backend +- 当前事实: + - backend 已确认 session 三态正式语义: + - 正常完成 -> `finished` + - 超时或规则失败 -> `failed` + - 主动退出 / 放弃恢复 -> `cancelled` +- 需要对方确认什么: + - frontend 按这套语义继续联调 +- 是否已解决:是 + ### B2F-004 - 时间:2026-04-01 @@ -92,11 +94,38 @@ - 无 - 是否已解决:是 +### B2F-006 + +- 时间:2026-04-02 +- 谁提的:backend +- 当前事实: + - backend 已确认“放弃恢复”官方语义为 `POST /sessions/{sessionPublicID}/finish` 且 `status=cancelled` + - 同一局的旧 `sessionToken` 在该场景允许继续用于 `finish(cancelled)` + - `cancelled` 和 `failed` 后都不会再作为 `ongoingSession` 返回 +- 需要对方确认什么: + - frontend 可正式把“放弃恢复”接到 `finish(cancelled)` +- 是否已解决:是 + +### B2F-007 + +- 时间:2026-04-02 +- 谁提的:backend +- 当前事实: + - backend 已把 `start / finish` 收口成幂等处理 + - 重复 `start`: + - `launched` -> 推进到 `running` + - `running` / 终态 -> 直接返回当前 session + - 重复 `finish`: + - 已终态 -> 直接返回当前 session / result +- 需要对方确认什么: + - frontend 继续按当前补报 / 重试逻辑联调 +- 是否已解决:是 + --- ## 阻塞 -### B2F-006 +### B2F-008 - 时间:2026-04-01 - 谁提的:backend @@ -116,7 +145,7 @@ ## 已完成 -### B2F-007 +### B2F-009 - 时间:2026-04-01 - 谁提的:backend @@ -131,7 +160,7 @@ - 无 - 是否已解决:是 -### B2F-008 +### B2F-010 - 时间:2026-04-01 - 谁提的:backend @@ -144,23 +173,78 @@ - 无 - 是否已解决:是 +### B2F-011 + +- 时间:2026-04-02 +- 谁提的:backend +- 当前事实: + - backend 已新增后台第一版资源对象接口: + - `/admin/maps` + - `/admin/playfields` + - `/admin/resource-packs` + - backend 已新增后台 `event` 组装接口: + - `/admin/events` + - `/admin/events/{eventPublicID}/source` + - 这批接口主要服务后续后台配置运营,不影响当前小程序主链联调 +- 需要对方确认什么: + - 无 +- 是否已解决:是 + +### B2F-012 + +- 时间:2026-04-02 +- 谁提的:backend +- 当前事实: + - backend 已补后台运营闭环接口: + - `GET /admin/events/{eventPublicID}/pipeline` + - `POST /admin/sources/{sourceID}/build` + - `GET /admin/builds/{buildID}` + - `POST /admin/builds/{buildID}/publish` + - 当前后台侧已经可以完成: + - 资源对象录入 + - event source 组装 + - preview build + - publish release +- 需要对方确认什么: + - 无 +- 是否已解决:是 + +### B2F-013 + +- 时间:2026-04-02 +- 谁提的:backend +- 当前事实: + - backend 已补后台 `rollback` 接口: + - `POST /admin/events/{eventPublicID}/rollback` + - 当前后台侧已具备完整最小闭环: + - 资源对象 + - event source 组装 + - build + - publish + - rollback +- 需要对方确认什么: + - 无 +- 是否已解决:是 + --- ## 下一步 -### B2F-009 +### B2F-014 -- 时间:2026-04-01 +- 时间:2026-04-02 - 谁提的:backend - 当前事实: - - backend 下一步会优先处理 P0: - - 固定 `finished / failed / cancelled` - - 明确“放弃恢复”是否落 `cancelled` - - 收稳 `start / finish` 幂等 + - session P0 已完成一轮收口 + - 当前最值得继续联调确认的是: + - 放弃恢复 -> `finish(cancelled)` + - `failed / cancelled` 后 ongoing 消失 + - 重复 `start / finish` 不再打断主链 - 需要对方确认什么: - frontend 当前优先配合: - - 用最新 demo release 回归 `play -> launch -> map load` - - 确认正式流程只认 launch 返回的 `manifestUrl` - - 预埋“放弃恢复”调用位 + - 用当前 demo release 回归 `play -> launch -> map load` + - 回归“继续恢复 / 放弃恢复”两条路径 + - 如发现状态口径不一致,直接在 `f2b.md` 标具体接口和返回值 - 是否已解决:否 + diff --git a/backend/README.md b/backend/README.md index f873419..e4d4900 100644 --- a/backend/README.md +++ b/backend/README.md @@ -1,6 +1,6 @@ # Backend > 文档版本:v1.0 -> 最后更新:2026-04-02 +> 最后更新:2026-04-02 08:28:05 这套后端现在已经能支撑一条完整主链: @@ -23,6 +23,7 @@ - [数据模型](D:/dev/cmr-mini/backend/docs/数据模型.md) - [配置管理方案](D:/dev/cmr-mini/backend/docs/配置管理方案.md) - [资源对象与目录方案](D:/dev/cmr-mini/backend/docs/资源对象与目录方案.md) +- [后台管理最小方案](D:/dev/cmr-mini/backend/docs/后台管理最小方案.md) - [开发说明](D:/dev/cmr-mini/backend/docs/开发说明.md) ## 快速启动 @@ -46,3 +47,4 @@ go run .\cmd\api - 局后结果:`/sessions/{id}/result`、`/me/results` - 开发工作台:`/dev/workbench` + diff --git a/backend/docs/README.md b/backend/docs/README.md index 235b847..8181f8f 100644 --- a/backend/docs/README.md +++ b/backend/docs/README.md @@ -1,6 +1,6 @@ # Backend Docs > 文档版本:v1.0 -> 最后更新:2026-04-02 +> 最后更新:2026-04-02 08:28:05 这套文档服务两个目的: @@ -16,9 +16,10 @@ 4. [数据模型](D:/dev/cmr-mini/backend/docs/数据模型.md) 5. [配置管理方案](D:/dev/cmr-mini/backend/docs/配置管理方案.md) 6. [资源对象与目录方案](D:/dev/cmr-mini/backend/docs/资源对象与目录方案.md) -7. [前后端联调清单](D:/dev/cmr-mini/backend/docs/前后端联调清单.md) -8. [TodoList](D:/dev/cmr-mini/backend/docs/todolist.md) -9. [开发说明](D:/dev/cmr-mini/backend/docs/开发说明.md) +7. [后台管理最小方案](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) +10. [开发说明](D:/dev/cmr-mini/backend/docs/开发说明.md) ## 当前系统范围 @@ -58,3 +59,4 @@ - 路由注册:[router.go](D:/dev/cmr-mini/backend/internal/httpapi/router.go) - migration:[migrations](D:/dev/cmr-mini/backend/migrations) + diff --git a/backend/docs/todolist.md b/backend/docs/todolist.md index ae793ac..4703462 100644 --- a/backend/docs/todolist.md +++ b/backend/docs/todolist.md @@ -1,6 +1,6 @@ # Backend TodoList > 文档版本:v1.0 -> 最后更新:2026-04-02 +> 最后更新:2026-04-02 08:28:05 ## 1. 目标 @@ -53,11 +53,11 @@ - `channelCode = mini-demo` - `channelType = wechat_mini` -## 3. P0 必做 +## 3. P0 已完成 ## 3.0 固定 session 状态语义 -需要 backend 明确并固定: +当前 backend 已明确并固定: - `finished` - `failed` @@ -76,8 +76,6 @@ ## 3.1 明确“放弃恢复”的后端处理 -这是当前最值得后端配合确认的一点。 - 当前小程序本地恢复逻辑已经是: - 进入程序检测到未正常结束对局 @@ -86,11 +84,11 @@ 现在本地“放弃”只会清除本地恢复快照。 -backend 需要确认的目标语义是: +backend 已确认的目标语义是: > 玩家点击“放弃恢复”后,这一局是否应同时在业务后端标记为 `cancelled`。 -我建议 backend 采用: +当前结论: - **是,应标记为 `cancelled`** @@ -100,7 +98,7 @@ backend 需要确认的目标语义是: - `/events/{id}/play` 和 `/me/entry-home` 可能一直把它当成可继续的局 - 会和小程序本地“已放弃”产生语义分叉 -建议 backend 配合确认: +当前 backend 已收口: 1. `POST /sessions/{id}/finish` 使用 `status=cancelled` 是否就是官方放弃语义 2. 如果客户端持有旧 `sessionToken`,恢复放弃时是否允许直接调用 `finish(cancelled)` @@ -108,7 +106,7 @@ backend 需要确认的目标语义是: 备注: -- 如果 backend 认可这套语义,小程序侧下一步就可以把“点击放弃恢复”改成同步调用 `finish(cancelled)`。 +- 小程序侧现在可以把“点击放弃恢复”正式接成同步调用 `finish(cancelled)`。 ## 3.2 保证 start / finish 幂等与重复调用安全 @@ -119,12 +117,12 @@ backend 需要确认的目标语义是: - 故障恢复后二次补报 - 用户重复点击 -backend 需要确认: +当前 backend 已确认: - `start` 重复调用的幂等语义 - `finish` 重复调用的幂等语义 -建议: +当前实现: - `start`:如果已 `running`,返回当前 session,视为成功 - `finish`:如果已进入终态,返回当前 session/result,视为成功 @@ -334,3 +332,4 @@ backend 现在最值得先做的,不是扩接口,而是先确认下面 3 条 > 先把 session 运行态语义、放弃恢复语义和 ongoing session 口径定稳,再继续扩后台配置系统。 + diff --git a/backend/docs/前后端联调清单.md b/backend/docs/前后端联调清单.md index 8c9043e..7f482e8 100644 --- a/backend/docs/前后端联调清单.md +++ b/backend/docs/前后端联调清单.md @@ -1,6 +1,6 @@ # 前后端联调清单 > 文档版本:v1.0 -> 最后更新:2026-04-02 +> 最后更新:2026-04-02 08:28:05 ## 1. 目的 @@ -371,3 +371,4 @@ backend 文档里也规划了: > backend 业务后端主链已经进入可联调阶段;小程序地图运行内核也已经具备承接能力;下一步最值钱的是补小程序业务 API 层和 launch/finish 两个适配器。 + diff --git a/backend/docs/后台管理最小方案.md b/backend/docs/后台管理最小方案.md new file mode 100644 index 0000000..5f88c5e --- /dev/null +++ b/backend/docs/后台管理最小方案.md @@ -0,0 +1,327 @@ +# 后台管理最小方案 +> 文档版本:v1.0 +> 最后更新:2026-04-02 09:01:17 + +## 1. 目标 + +后台第一版不是做一个“大而全配置表单”,而是先做一套最小可运营壳,解决这 3 个问题: + +1. 共享资源能被对象化管理 +2. `event` 能引用这些资源并组装配置 +3. 能从 source -> build -> release 完成发布 + +一句话: + +> 后台第一版管理的是“资源对象 + 引用关系 + 发布流程”,不是“直接编辑一个越来越大的 JSON 文件”。 + +## 2. 设计边界 + +### 2.1 后台应该管理什么 + +- 地图对象 +- 赛场 / KML 对象 +- 资源包对象 +- Event 基础信息 +- Event 对资源对象的引用 +- Build / Release 发布记录 + +### 2.2 后台不应该一开始就做什么 + +- 把所有配置项做成一个超大表单 +- 把玩家运行时设置也塞进发布配置 +- 把前端运行时编译逻辑搬到后端后台 +- 直接按 `event` 管理所有资源文件 + +### 2.3 与未来 APP 的关系 + +后台配置不是“小程序后台”,而是统一配置运营后台。 + +所以第一版开始就要坚持: + +- 资源对象平台级复用 +- `release / manifest` 终端中立 +- 同一份发布结果同时服务小程序和未来 APP + +## 3. 第一版建议模块 + +### 3.1 地图管理 + +作用: + +- 管理可复用地图对象和版本 + +建议字段: + +- 地图名称 +- 地图编码 +- 版本号 +- `mapmeta` 文件 +- 瓦片根路径 +- 覆盖范围 +- 状态 +- 备注 + +第一版动作: + +- 新建地图 +- 新建地图版本 +- 查看地图版本详情 +- 停用某个版本 + +### 3.2 赛场 / KML 管理 + +作用: + +- 把 KML / GeoJSON / 控制点数据做成独立可复用对象 + +建议字段: + +- 赛场名称 +- 赛场编码 +- 版本号 +- 原始文件 +- 控制点数量 +- 边界摘要 +- 状态 +- 备注 + +第一版动作: + +- 上传 KML +- 新建赛场版本 +- 查看赛场版本详情 +- 停用某个版本 + +### 3.3 资源包管理 + +作用: + +- 管理内容页、音频、主题等共享资源 + +建议字段: + +- 资源包名称 +- 资源包编码 +- 版本号 +- 内容页入口 +- 音频包入口 +- 主题配置入口 +- 状态 +- 备注 + +第一版动作: + +- 新建资源包 +- 新建资源包版本 +- 查看资源包版本详情 + +### 3.4 Event 组装 + +作用: + +- 管理业务活动本身,并引用共享资源对象 + +建议字段: + +- Event 名称 +- Event 编码 / public id +- 简介 +- 状态 +- 当前选用地图版本 +- 当前选用赛场版本 +- 当前选用资源包版本 +- 当前玩法模式 +- 少量覆盖项 + +第一版只开放少量覆盖项: + +- 标题 +- 摘要 +- 路线编码 +- 玩法模式 +- 少量规则开关 + +不要第一版就开放: + +- 全量 `presentation.*` +- 全量 `telemetry.*` +- 全量 `debug.*` + +### 3.5 Build / Release 管理 + +作用: + +- 把 source config 变成 preview build,再发布成正式 release + +页面需要看到: + +- source 列表 +- build 列表 +- build 状态 +- release 列表 +- 当前生效 release +- 发布人 +- 发布时间 + +第一版动作: + +- 从 source 生成 build +- 查看 build 产物 +- 发布 build +- 回滚当前 release + +## 4. 后台第一版页面建议 + +按最小闭环,建议先做 6 个页面: + +1. 地图列表页 +2. 赛场 / KML 列表页 +3. 资源包列表页 +4. Event 列表与编辑页 +5. Build / Release 列表页 +6. 发布详情页 + +这 6 页够把“资源录入 -> Event 组装 -> 发布 -> launch”跑通。 + +## 5. 对象模型建议 + +后台第一版建议围绕这些对象展开: + +- `Map` +- `MapVersion` +- `Playfield` +- `PlayfieldVersion` +- `ResourcePack` +- `ResourcePackVersion` +- `Event` +- `EventConfigSource` +- `EventConfigBuild` +- `EventRelease` + +关键原则: + +- 共享资源按对象库管理 +- `event` 只做引用和少量覆盖 +- `release` 固化具体版本引用 + +## 6. 一条完整后台工作流 + +```mermaid +flowchart LR + A["录入地图版本"] --> D["Event 选择地图版本"] + B["录入赛场版本"] --> D + C["录入资源包版本"] --> D + D --> E["保存 Source Config"] + E --> F["生成 Preview Build"] + F --> G["检查 Manifest / Asset Index"] + G --> H["发布 Release"] + H --> I["前台 Launch 使用新 Release"] +``` + +## 7. 第一版后端接口需求 + +后台真正开做前,后端最好先补齐下面这批接口: + +### 7.1 地图对象 + +- `GET /admin/maps` +- `POST /admin/maps` +- `GET /admin/maps/{id}` +- `POST /admin/maps/{id}/versions` + +### 7.2 赛场对象 + +- `GET /admin/playfields` +- `POST /admin/playfields` +- `GET /admin/playfields/{id}` +- `POST /admin/playfields/{id}/versions` + +### 7.3 资源包对象 + +- `GET /admin/resource-packs` +- `POST /admin/resource-packs` +- `GET /admin/resource-packs/{id}` +- `POST /admin/resource-packs/{id}/versions` + +### 7.4 Event 组装 + +- `GET /admin/events` +- `POST /admin/events` +- `GET /admin/events/{id}` +- `PUT /admin/events/{id}` +- `POST /admin/events/{id}/source` + +当前状态: + +- 已实现 +- 当前 `source` 组装方式是: + - 选择 `map version` + - 选择 `playfield version` + - 可选选择 `resource pack version` + - 传 `gameModeCode` + - 可传少量 `overrides` + - backend 直接生成一版可进入现有 build 流程的 source config + +### 7.5 Build / Release + +- `GET /admin/events/{id}/sources` +- `POST /admin/sources/{id}/build` +- `GET /admin/builds/{id}` +- `POST /admin/builds/{id}/publish` +- `POST /admin/events/{id}/rollback` + +当前状态: + +- 已实现 +- 当前已可用: + - `GET /admin/events/{eventPublicID}/pipeline` + - `POST /admin/sources/{sourceID}/build` + - `GET /admin/builds/{buildID}` + - `POST /admin/builds/{buildID}/publish` + - `POST /admin/events/{eventPublicID}/rollback` +- 当前 rollback 方式: + - 显式传 `releaseId` + - 只允许切回同一 event 下的已发布 release + +## 8. 第一版不要做的事 + +为了避免项目被后台表单拖死,第一版明确不做: + +- 全量 schema 可视化编辑器 +- 拖拽式配置搭建器 +- 复杂权限系统 +- 资源批量编排器 +- 多层审批流 + +这些都应该等“最小配置运营闭环”跑通后再说。 + +## 9. 建议开发顺序 + +建议按下面顺序推进: + +1. 先补资源对象模型和接口 +2. 再补 Event 引用组装接口 +3. 再补 Build / Release 运营接口 +4. 最后再做后台页面 + +原因: + +- 没有稳定后端对象模型,后台页面会反复推翻 +- 先把对象和发布链条定住,前后端才不会互相拖拽 + +当前进度: + +1. 资源对象模型和 `/admin/maps`、`/admin/playfields`、`/admin/resource-packs` 已完成 +2. `Event` 组装接口 `/admin/events`、`/admin/events/{id}/source` 已完成 +3. `pipeline/build/publish` 后台聚合接口已完成 +4. `rollback` 已完成 +5. 下一步是把这批接口接进 workbench 或正式后台页面 + +## 10. 一句话结论 + +是的,后面需要一版后台管理界面。 + +但第一版不应该是“配置大全编辑器”,而应该是: + +> 共享资源管理 + Event 组装 + Build / Release 发布 的最小运营后台。 + diff --git a/backend/docs/开发说明.md b/backend/docs/开发说明.md index 5b86d00..571d590 100644 --- a/backend/docs/开发说明.md +++ b/backend/docs/开发说明.md @@ -1,6 +1,6 @@ # 开发说明 > 文档版本:v1.0 -> 最后更新:2026-04-02 +> 最后更新:2026-04-02 08:28:05 ## 1. 环境变量 @@ -196,3 +196,4 @@ Redis 后面只在需要性能优化、限流或短期票据缓存时再接。 不要跳回去把玩法规则塞进 backend。 + diff --git a/backend/docs/接口清单.md b/backend/docs/接口清单.md index f2aadb2..5615cf3 100644 --- a/backend/docs/接口清单.md +++ b/backend/docs/接口清单.md @@ -1,6 +1,6 @@ # API 清单 > 文档版本:v1.0 -> 最后更新:2026-04-02 +> 最后更新:2026-04-02 09:01:17 本文档只记录当前 backend 已实现接口,不写未来规划接口。 @@ -238,6 +238,13 @@ - 将 session 从 `launched` 推进到 `running` +补充约束: + +- 幂等 +- 重复调用时: + - `launched` 会推进到 `running` + - `running` 或已终态直接返回当前 session + ### `POST /sessions/{sessionPublicID}/finish` 鉴权: @@ -249,6 +256,19 @@ - 结束一局 - 同时沉淀结果摘要 +当前结束语义: + +- `finished`:正常完成 +- `failed`:超时或规则失败 +- `cancelled`:主动退出或放弃恢复 + +补充约束: + +- 幂等 +- 已终态重复调用直接返回当前 session / result +- `finish(cancelled)` 是当前“放弃恢复”的官方后端语义 +- 同一局旧 `sessionToken` 在 `finish(cancelled)` 场景允许继续使用 + 请求体重点: - `sessionToken` @@ -427,3 +447,328 @@ - `release.manifestUrl` - `release.configLabel` +## 9. Admin 资源对象 + +说明: + +- 当前是后台第一版的最小对象接口 +- 先只做 Bearer 鉴权 +- 暂未接正式 RBAC / 管理员权限模型 + +### `GET /admin/maps` + +鉴权: + +- Bearer token + +用途: + +- 获取地图对象列表 + +### `POST /admin/maps` + +鉴权: + +- Bearer token + +用途: + +- 新建地图对象 + +请求体重点: + +- `code` +- `name` +- `status` +- `description` + +### `GET /admin/maps/{mapPublicID}` + +鉴权: + +- Bearer token + +用途: + +- 获取地图对象详情和版本列表 + +### `POST /admin/maps/{mapPublicID}/versions` + +鉴权: + +- Bearer token + +用途: + +- 新建地图版本 + +请求体重点: + +- `versionCode` +- `mapmetaUrl` +- `tilesRootUrl` +- `status` +- `publishedAssetRoot` +- `bounds` +- `metadata` +- `setAsCurrent` + +### `GET /admin/playfields` + +鉴权: + +- Bearer token + +用途: + +- 获取赛场 / KML 对象列表 + +### `POST /admin/playfields` + +鉴权: + +- Bearer token + +用途: + +- 新建赛场对象 + +请求体重点: + +- `code` +- `name` +- `kind` +- `status` +- `description` + +### `GET /admin/playfields/{playfieldPublicID}` + +鉴权: + +- Bearer token + +用途: + +- 获取赛场对象详情和版本列表 + +### `POST /admin/playfields/{playfieldPublicID}/versions` + +鉴权: + +- Bearer token + +用途: + +- 新建赛场版本 + +请求体重点: + +- `versionCode` +- `sourceType` +- `sourceUrl` +- `controlCount` +- `status` +- `publishedAssetRoot` +- `bounds` +- `metadata` +- `setAsCurrent` + +### `GET /admin/resource-packs` + +鉴权: + +- Bearer token + +用途: + +- 获取资源包对象列表 + +### `POST /admin/resource-packs` + +鉴权: + +- Bearer token + +用途: + +- 新建资源包对象 + +请求体重点: + +- `code` +- `name` +- `status` +- `description` + +### `GET /admin/resource-packs/{resourcePackPublicID}` + +鉴权: + +- Bearer token + +用途: + +- 获取资源包对象详情和版本列表 + +### `POST /admin/resource-packs/{resourcePackPublicID}/versions` + +鉴权: + +- Bearer token + +用途: + +- 新建资源包版本 + +请求体重点: + +- `versionCode` +- `contentEntryUrl` +- `audioRootUrl` +- `themeProfileCode` +- `status` +- `publishedAssetRoot` +- `metadata` +- `setAsCurrent` + +### `GET /admin/events` + +鉴权: + +- Bearer token + +用途: + +- 获取后台 Event 列表 + +### `POST /admin/events` + +鉴权: + +- Bearer token + +用途: + +- 新建后台 Event + +请求体重点: + +- `tenantCode` +- `slug` +- `displayName` +- `summary` +- `status` + +### `GET /admin/events/{eventPublicID}` + +鉴权: + +- Bearer token + +用途: + +- 获取后台 Event 详情 +- 返回最新 source config 摘要 + +### `PUT /admin/events/{eventPublicID}` + +鉴权: + +- Bearer token + +用途: + +- 更新 Event 基础信息 + +请求体重点: + +- `tenantCode` +- `slug` +- `displayName` +- `summary` +- `status` + +### `POST /admin/events/{eventPublicID}/source` + +鉴权: + +- Bearer token + +用途: + +- 用地图版本、赛场版本、资源包版本组装一版 source config +- 直接落到现有 `event_config_sources` + +请求体重点: + +- `map.mapId` +- `map.versionId` +- `playfield.playfieldId` +- `playfield.versionId` +- `resourcePack.resourcePackId` +- `resourcePack.versionId` +- `gameModeCode` +- `routeCode` +- `overrides` +- `notes` + +### `GET /admin/events/{eventPublicID}/pipeline` + +鉴权: + +- Bearer token + +用途: + +- 从后台视角聚合查看某个 event 的: + - sources + - builds + - releases + - current release + +### `POST /admin/sources/{sourceID}/build` + +鉴权: + +- Bearer token + +用途: + +- 基于某个 source 生成 preview build + +### `GET /admin/builds/{buildID}` + +鉴权: + +- Bearer token + +用途: + +- 查看某次 build 详情 + +### `POST /admin/builds/{buildID}/publish` + +鉴权: + +- Bearer token + +用途: + +- 将某次成功 build 发布成正式 release +- 自动切换 event 当前 release + +### `POST /admin/events/{eventPublicID}/rollback` + +鉴权: + +- Bearer token + +用途: + +- 将 event 当前 release 显式切回某个已发布 release + +请求体重点: + +- `releaseId` + + diff --git a/backend/docs/数据模型.md b/backend/docs/数据模型.md index 3d8afd8..989ced0 100644 --- a/backend/docs/数据模型.md +++ b/backend/docs/数据模型.md @@ -1,9 +1,9 @@ # 数据模型 > 文档版本:v1.0 -> 最后更新:2026-04-02 +> 最后更新:2026-04-02 08:28:05 -当前 migration 共 5 版。 +当前 migration 共 6 版。 ## 1. 迁移清单 @@ -12,6 +12,7 @@ - [0003_home.sql](D:/dev/cmr-mini/backend/migrations/0003_home.sql) - [0004_results.sql](D:/dev/cmr-mini/backend/migrations/0004_results.sql) - [0005_config_pipeline.sql](D:/dev/cmr-mini/backend/migrations/0005_config_pipeline.sql) +- [0006_resource_objects.sql](D:/dev/cmr-mini/backend/migrations/0006_resource_objects.sql) ## 2. 表分组 @@ -98,6 +99,21 @@ - 保存构建后的 manifest 和 asset index - 保存正式 release 关联的资产清单 +### 2.7 共享资源对象 + +- `maps` +- `map_versions` +- `playfields` +- `playfield_versions` +- `resource_packs` +- `resource_pack_versions` + +职责: + +- 把地图、KML/赛场、内容资源包做成可复用对象 +- 支撑后台第一版按“资源对象 + 版本”管理 +- 给后续 event 引用组装和发布流程提供稳定边界 + ## 3. 当前最关键的关系 ### `tenant -> entry_channel` @@ -132,6 +148,18 @@ - build 是构建态 - release 是发布态 +### `map -> map_version` + +一张地图可有多个版本。 + +### `playfield -> playfield_version` + +一份赛场/KML 可有多个版本。 + +### `resource_pack -> resource_pack_version` + +一套内容/音频/主题资源可有多个版本。 + ## 4. 当前已落库但仍应注意的边界 ### 4.1 不要把玩法细节塞回事件主表 @@ -173,3 +201,4 @@ 这些后面要按真正业务需要补 migration,不要先拍脑袋建大而全表。 + diff --git a/backend/docs/核心流程.md b/backend/docs/核心流程.md index e0c7d95..c90fdfc 100644 --- a/backend/docs/核心流程.md +++ b/backend/docs/核心流程.md @@ -1,6 +1,6 @@ # 核心流程 > 文档版本:v1.0 -> 最后更新:2026-04-02 +> 最后更新:2026-04-02 08:28:05 ## 1. 总流程 @@ -182,6 +182,29 @@ APP 当前主链是手机号验证码: 这保证了业务登录态和一局游戏运行态是分开的。 +### 7.3 当前状态语义 + +- `launched`:已创建一局,客户端尚未正式开始 +- `running`:客户端已开始本局 +- `finished`:正常完成 +- `failed`:超时或规则失败 +- `cancelled`:主动退出或放弃恢复 + +补充约束: + +- `cancelled` 和 `failed` 都不再作为 ongoing session 返回 +- “放弃恢复”当前正式收口为 `finish(cancelled)` +- 同一局旧 `sessionToken` 在 `finish(cancelled)` 场景允许继续使用 + +### 7.4 幂等要求 + +- `start` 幂等: + - `launched` -> `running` + - 重复 `start` 不应报错 +- `finish` 幂等: + - 第一次进入终态后,重复 `finish` 直接返回当前结果 +- 这个约束同时服务小程序故障恢复和未来 APP 重试补报 + ## 8. 结果流程 ### 8.1 当前接口 @@ -230,3 +253,4 @@ APP 当前主链是手机号验证码: 业务接口必须保持统一,终端差异只进入上下文,不进入对象模型分叉。 + diff --git a/backend/docs/系统架构.md b/backend/docs/系统架构.md index 6a6c888..d3dfc99 100644 --- a/backend/docs/系统架构.md +++ b/backend/docs/系统架构.md @@ -1,6 +1,6 @@ # 系统架构 > 文档版本:v1.0 -> 最后更新:2026-04-02 +> 最后更新:2026-04-02 08:28:05 ## 1. 目标 @@ -235,3 +235,4 @@ 不要把微信身份或业务 token 直接暴露给实时网关。 + diff --git a/backend/docs/资源对象与目录方案.md b/backend/docs/资源对象与目录方案.md index 6c69a9d..711381e 100644 --- a/backend/docs/资源对象与目录方案.md +++ b/backend/docs/资源对象与目录方案.md @@ -1,6 +1,6 @@ # 资源对象与目录方案 > 文档版本:v1.0 -> 最后更新:2026-04-02 +> 最后更新:2026-04-02 08:28:05 本文档用于把“地图复用、KML 复用、内容资源复用、配置发布”统一收成一套后端可执行方案。 @@ -591,3 +591,4 @@ gotomars/event-releases/{eventPublicID}/{releasePublicID}/asset-index.json 并且这套模型必须从一开始就兼顾未来 APP,而不是做成“小程序跑通后再重构”的临时结构。 + diff --git a/backend/docs/配置管理方案.md b/backend/docs/配置管理方案.md index ad5a711..d2129eb 100644 --- a/backend/docs/配置管理方案.md +++ b/backend/docs/配置管理方案.md @@ -1,6 +1,6 @@ # 配置管理方案 > 文档版本:v1.0 -> 最后更新:2026-04-02 +> 最后更新:2026-04-02 08:28:05 ## 1. 目标 @@ -414,3 +414,4 @@ 这样以后无论你配置项怎么继续长,主架构都还能撑住。 + diff --git a/backend/internal/app/app.go b/backend/internal/app/app.go index 8fc1d41..a3a6802 100644 --- a/backend/internal/app/app.go +++ b/backend/internal/app/app.go @@ -37,17 +37,20 @@ func New(ctx context.Context, cfg Config) (*App, error) { }, store, jwtManager) entryService := service.NewEntryService(store) entryHomeService := service.NewEntryHomeService(store) + adminResourceService := service.NewAdminResourceService(store) + adminEventService := service.NewAdminEventService(store) eventService := service.NewEventService(store) eventPlayService := service.NewEventPlayService(store) assetPublisher := assets.NewOSSUtilPublisher(cfg.OSSUtilPath, cfg.OSSUtilConfigFile, cfg.AssetBucketRoot, cfg.AssetPublicBaseURL) configService := service.NewConfigService(store, cfg.LocalEventDir, cfg.AssetBaseURL, assetPublisher) + adminPipelineService := service.NewAdminPipelineService(store, configService) homeService := service.NewHomeService(store) profileService := service.NewProfileService(store) resultService := service.NewResultService(store) sessionService := service.NewSessionService(store) devService := service.NewDevService(cfg.AppEnv, store) meService := service.NewMeService(store) - router := httpapi.NewRouter(cfg.AppEnv, jwtManager, authService, entryService, entryHomeService, eventService, eventPlayService, configService, homeService, profileService, resultService, sessionService, devService, meService) + router := httpapi.NewRouter(cfg.AppEnv, jwtManager, authService, entryService, entryHomeService, adminResourceService, adminEventService, adminPipelineService, eventService, eventPlayService, configService, homeService, profileService, resultService, sessionService, devService, meService) return &App{ router: router, diff --git a/backend/internal/httpapi/handlers/admin_event_handler.go b/backend/internal/httpapi/handlers/admin_event_handler.go new file mode 100644 index 0000000..4780334 --- /dev/null +++ b/backend/internal/httpapi/handlers/admin_event_handler.go @@ -0,0 +1,84 @@ +package handlers + +import ( + "net/http" + "strconv" + + "cmr-backend/internal/apperr" + "cmr-backend/internal/httpx" + "cmr-backend/internal/service" +) + +type AdminEventHandler struct { + service *service.AdminEventService +} + +func NewAdminEventHandler(service *service.AdminEventService) *AdminEventHandler { + return &AdminEventHandler{service: service} +} + +func (h *AdminEventHandler) ListEvents(w http.ResponseWriter, r *http.Request) { + limit := 50 + if raw := r.URL.Query().Get("limit"); raw != "" { + if parsed, err := strconv.Atoi(raw); err == nil { + limit = parsed + } + } + result, err := h.service.ListEvents(r.Context(), limit) + if err != nil { + httpx.WriteError(w, err) + return + } + httpx.WriteJSON(w, http.StatusOK, map[string]any{"data": result}) +} + +func (h *AdminEventHandler) CreateEvent(w http.ResponseWriter, r *http.Request) { + var req service.CreateAdminEventInput + 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.CreateEvent(r.Context(), req) + if err != nil { + httpx.WriteError(w, err) + return + } + httpx.WriteJSON(w, http.StatusCreated, map[string]any{"data": result}) +} + +func (h *AdminEventHandler) GetEvent(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 *AdminEventHandler) UpdateEvent(w http.ResponseWriter, r *http.Request) { + var req service.UpdateAdminEventInput + 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.UpdateEvent(r.Context(), r.PathValue("eventPublicID"), req) + if err != nil { + httpx.WriteError(w, err) + return + } + httpx.WriteJSON(w, http.StatusOK, map[string]any{"data": result}) +} + +func (h *AdminEventHandler) SaveSource(w http.ResponseWriter, r *http.Request) { + var req service.SaveAdminEventSourceInput + 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.SaveEventSource(r.Context(), r.PathValue("eventPublicID"), req) + if err != nil { + httpx.WriteError(w, err) + return + } + httpx.WriteJSON(w, http.StatusCreated, map[string]any{"data": result}) +} diff --git a/backend/internal/httpapi/handlers/admin_pipeline_handler.go b/backend/internal/httpapi/handlers/admin_pipeline_handler.go new file mode 100644 index 0000000..28a6ba4 --- /dev/null +++ b/backend/internal/httpapi/handlers/admin_pipeline_handler.go @@ -0,0 +1,74 @@ +package handlers + +import ( + "net/http" + "strconv" + + "cmr-backend/internal/apperr" + "cmr-backend/internal/httpx" + "cmr-backend/internal/service" +) + +type AdminPipelineHandler struct { + service *service.AdminPipelineService +} + +func NewAdminPipelineHandler(service *service.AdminPipelineService) *AdminPipelineHandler { + return &AdminPipelineHandler{service: service} +} + +func (h *AdminPipelineHandler) GetEventPipeline(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.GetEventPipeline(r.Context(), r.PathValue("eventPublicID"), limit) + if err != nil { + httpx.WriteError(w, err) + return + } + httpx.WriteJSON(w, http.StatusOK, map[string]any{"data": result}) +} + +func (h *AdminPipelineHandler) BuildSource(w http.ResponseWriter, r *http.Request) { + result, err := h.service.BuildSource(r.Context(), r.PathValue("sourceID")) + if err != nil { + httpx.WriteError(w, err) + return + } + httpx.WriteJSON(w, http.StatusOK, map[string]any{"data": result}) +} + +func (h *AdminPipelineHandler) GetBuild(w http.ResponseWriter, r *http.Request) { + result, err := h.service.GetBuild(r.Context(), r.PathValue("buildID")) + if err != nil { + httpx.WriteError(w, err) + return + } + httpx.WriteJSON(w, http.StatusOK, map[string]any{"data": result}) +} + +func (h *AdminPipelineHandler) PublishBuild(w http.ResponseWriter, r *http.Request) { + result, err := h.service.PublishBuild(r.Context(), r.PathValue("buildID")) + if err != nil { + httpx.WriteError(w, err) + return + } + httpx.WriteJSON(w, http.StatusOK, map[string]any{"data": result}) +} + +func (h *AdminPipelineHandler) RollbackRelease(w http.ResponseWriter, r *http.Request) { + var req service.AdminRollbackReleaseInput + 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.RollbackRelease(r.Context(), r.PathValue("eventPublicID"), req) + if err != nil { + httpx.WriteError(w, err) + return + } + httpx.WriteJSON(w, http.StatusOK, map[string]any{"data": result}) +} diff --git a/backend/internal/httpapi/handlers/admin_resource_handler.go b/backend/internal/httpapi/handlers/admin_resource_handler.go new file mode 100644 index 0000000..44f7cb5 --- /dev/null +++ b/backend/internal/httpapi/handlers/admin_resource_handler.go @@ -0,0 +1,169 @@ +package handlers + +import ( + "net/http" + "strconv" + + "cmr-backend/internal/apperr" + "cmr-backend/internal/httpx" + "cmr-backend/internal/service" +) + +type AdminResourceHandler struct { + service *service.AdminResourceService +} + +func NewAdminResourceHandler(service *service.AdminResourceService) *AdminResourceHandler { + return &AdminResourceHandler{service: service} +} + +func (h *AdminResourceHandler) ListMaps(w http.ResponseWriter, r *http.Request) { + limit := parseAdminLimit(r) + result, err := h.service.ListMaps(r.Context(), limit) + if err != nil { + httpx.WriteError(w, err) + return + } + httpx.WriteJSON(w, http.StatusOK, map[string]any{"data": result}) +} + +func (h *AdminResourceHandler) CreateMap(w http.ResponseWriter, r *http.Request) { + var req service.CreateAdminMapInput + 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.CreateMap(r.Context(), req) + if err != nil { + httpx.WriteError(w, err) + return + } + httpx.WriteJSON(w, http.StatusCreated, map[string]any{"data": result}) +} + +func (h *AdminResourceHandler) GetMap(w http.ResponseWriter, r *http.Request) { + result, err := h.service.GetMapDetail(r.Context(), r.PathValue("mapPublicID")) + if err != nil { + httpx.WriteError(w, err) + return + } + httpx.WriteJSON(w, http.StatusOK, map[string]any{"data": result}) +} + +func (h *AdminResourceHandler) CreateMapVersion(w http.ResponseWriter, r *http.Request) { + var req service.CreateAdminMapVersionInput + 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.CreateMapVersion(r.Context(), r.PathValue("mapPublicID"), req) + if err != nil { + httpx.WriteError(w, err) + return + } + httpx.WriteJSON(w, http.StatusCreated, map[string]any{"data": result}) +} + +func (h *AdminResourceHandler) ListPlayfields(w http.ResponseWriter, r *http.Request) { + limit := parseAdminLimit(r) + result, err := h.service.ListPlayfields(r.Context(), limit) + if err != nil { + httpx.WriteError(w, err) + return + } + httpx.WriteJSON(w, http.StatusOK, map[string]any{"data": result}) +} + +func (h *AdminResourceHandler) CreatePlayfield(w http.ResponseWriter, r *http.Request) { + var req service.CreateAdminPlayfieldInput + 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.CreatePlayfield(r.Context(), req) + if err != nil { + httpx.WriteError(w, err) + return + } + httpx.WriteJSON(w, http.StatusCreated, map[string]any{"data": result}) +} + +func (h *AdminResourceHandler) GetPlayfield(w http.ResponseWriter, r *http.Request) { + result, err := h.service.GetPlayfieldDetail(r.Context(), r.PathValue("playfieldPublicID")) + if err != nil { + httpx.WriteError(w, err) + return + } + httpx.WriteJSON(w, http.StatusOK, map[string]any{"data": result}) +} + +func (h *AdminResourceHandler) CreatePlayfieldVersion(w http.ResponseWriter, r *http.Request) { + var req service.CreateAdminPlayfieldVersionInput + 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.CreatePlayfieldVersion(r.Context(), r.PathValue("playfieldPublicID"), req) + if err != nil { + httpx.WriteError(w, err) + return + } + httpx.WriteJSON(w, http.StatusCreated, map[string]any{"data": result}) +} + +func (h *AdminResourceHandler) ListResourcePacks(w http.ResponseWriter, r *http.Request) { + limit := parseAdminLimit(r) + result, err := h.service.ListResourcePacks(r.Context(), limit) + if err != nil { + httpx.WriteError(w, err) + return + } + httpx.WriteJSON(w, http.StatusOK, map[string]any{"data": result}) +} + +func (h *AdminResourceHandler) CreateResourcePack(w http.ResponseWriter, r *http.Request) { + var req service.CreateAdminResourcePackInput + 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.CreateResourcePack(r.Context(), req) + if err != nil { + httpx.WriteError(w, err) + return + } + httpx.WriteJSON(w, http.StatusCreated, map[string]any{"data": result}) +} + +func (h *AdminResourceHandler) GetResourcePack(w http.ResponseWriter, r *http.Request) { + result, err := h.service.GetResourcePackDetail(r.Context(), r.PathValue("resourcePackPublicID")) + if err != nil { + httpx.WriteError(w, err) + return + } + httpx.WriteJSON(w, http.StatusOK, map[string]any{"data": result}) +} + +func (h *AdminResourceHandler) CreateResourcePackVersion(w http.ResponseWriter, r *http.Request) { + var req service.CreateAdminResourcePackVersionInput + 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.CreateResourcePackVersion(r.Context(), r.PathValue("resourcePackPublicID"), req) + if err != nil { + httpx.WriteError(w, err) + return + } + httpx.WriteJSON(w, http.StatusCreated, map[string]any{"data": result}) +} + +func parseAdminLimit(r *http.Request) int { + limit := 50 + if raw := r.URL.Query().Get("limit"); raw != "" { + if parsed, err := strconv.Atoi(raw); err == nil { + limit = parsed + } + } + return limit +} diff --git a/backend/internal/httpapi/router.go b/backend/internal/httpapi/router.go index e6bb18f..7819fcb 100644 --- a/backend/internal/httpapi/router.go +++ b/backend/internal/httpapi/router.go @@ -15,6 +15,9 @@ func NewRouter( authService *service.AuthService, entryService *service.EntryService, entryHomeService *service.EntryHomeService, + adminResourceService *service.AdminResourceService, + adminEventService *service.AdminEventService, + adminPipelineService *service.AdminPipelineService, eventService *service.EventService, eventPlayService *service.EventPlayService, configService *service.ConfigService, @@ -31,6 +34,9 @@ func NewRouter( authHandler := handlers.NewAuthHandler(authService) entryHandler := handlers.NewEntryHandler(entryService) entryHomeHandler := handlers.NewEntryHomeHandler(entryHomeService) + adminResourceHandler := handlers.NewAdminResourceHandler(adminResourceService) + adminEventHandler := handlers.NewAdminEventHandler(adminEventService) + adminPipelineHandler := handlers.NewAdminPipelineHandler(adminPipelineService) eventHandler := handlers.NewEventHandler(eventService) eventPlayHandler := handlers.NewEventPlayHandler(eventPlayService) configHandler := handlers.NewConfigHandler(configService) @@ -46,6 +52,28 @@ func NewRouter( mux.HandleFunc("GET /home", homeHandler.GetHome) mux.HandleFunc("GET /cards", homeHandler.GetCards) mux.HandleFunc("GET /entry/resolve", entryHandler.Resolve) + mux.Handle("GET /admin/maps", authMiddleware(http.HandlerFunc(adminResourceHandler.ListMaps))) + mux.Handle("POST /admin/maps", authMiddleware(http.HandlerFunc(adminResourceHandler.CreateMap))) + mux.Handle("GET /admin/maps/{mapPublicID}", authMiddleware(http.HandlerFunc(adminResourceHandler.GetMap))) + mux.Handle("POST /admin/maps/{mapPublicID}/versions", authMiddleware(http.HandlerFunc(adminResourceHandler.CreateMapVersion))) + mux.Handle("GET /admin/playfields", authMiddleware(http.HandlerFunc(adminResourceHandler.ListPlayfields))) + mux.Handle("POST /admin/playfields", authMiddleware(http.HandlerFunc(adminResourceHandler.CreatePlayfield))) + mux.Handle("GET /admin/playfields/{playfieldPublicID}", authMiddleware(http.HandlerFunc(adminResourceHandler.GetPlayfield))) + mux.Handle("POST /admin/playfields/{playfieldPublicID}/versions", authMiddleware(http.HandlerFunc(adminResourceHandler.CreatePlayfieldVersion))) + mux.Handle("GET /admin/resource-packs", authMiddleware(http.HandlerFunc(adminResourceHandler.ListResourcePacks))) + mux.Handle("POST /admin/resource-packs", authMiddleware(http.HandlerFunc(adminResourceHandler.CreateResourcePack))) + mux.Handle("GET /admin/resource-packs/{resourcePackPublicID}", authMiddleware(http.HandlerFunc(adminResourceHandler.GetResourcePack))) + mux.Handle("POST /admin/resource-packs/{resourcePackPublicID}/versions", authMiddleware(http.HandlerFunc(adminResourceHandler.CreateResourcePackVersion))) + mux.Handle("GET /admin/events", authMiddleware(http.HandlerFunc(adminEventHandler.ListEvents))) + mux.Handle("POST /admin/events", authMiddleware(http.HandlerFunc(adminEventHandler.CreateEvent))) + mux.Handle("GET /admin/events/{eventPublicID}", authMiddleware(http.HandlerFunc(adminEventHandler.GetEvent))) + mux.Handle("PUT /admin/events/{eventPublicID}", authMiddleware(http.HandlerFunc(adminEventHandler.UpdateEvent))) + mux.Handle("POST /admin/events/{eventPublicID}/source", authMiddleware(http.HandlerFunc(adminEventHandler.SaveSource))) + mux.Handle("GET /admin/events/{eventPublicID}/pipeline", authMiddleware(http.HandlerFunc(adminPipelineHandler.GetEventPipeline))) + mux.Handle("POST /admin/sources/{sourceID}/build", authMiddleware(http.HandlerFunc(adminPipelineHandler.BuildSource))) + mux.Handle("GET /admin/builds/{buildID}", authMiddleware(http.HandlerFunc(adminPipelineHandler.GetBuild))) + mux.Handle("POST /admin/builds/{buildID}/publish", authMiddleware(http.HandlerFunc(adminPipelineHandler.PublishBuild))) + mux.Handle("POST /admin/events/{eventPublicID}/rollback", authMiddleware(http.HandlerFunc(adminPipelineHandler.RollbackRelease))) if appEnv != "production" { mux.HandleFunc("GET /dev/workbench", devHandler.Workbench) mux.HandleFunc("POST /dev/bootstrap-demo", devHandler.BootstrapDemo) diff --git a/backend/internal/service/admin_event_service.go b/backend/internal/service/admin_event_service.go new file mode 100644 index 0000000..25782fc --- /dev/null +++ b/backend/internal/service/admin_event_service.go @@ -0,0 +1,490 @@ +package service + +import ( + "context" + "fmt" + "net/http" + "strings" + + "cmr-backend/internal/apperr" + "cmr-backend/internal/platform/security" + "cmr-backend/internal/store/postgres" +) + +type AdminEventService struct { + store *postgres.Store +} + +type AdminEventSummary struct { + ID string `json:"id"` + TenantCode *string `json:"tenantCode,omitempty"` + TenantName *string `json:"tenantName,omitempty"` + Slug string `json:"slug"` + DisplayName string `json:"displayName"` + Summary *string `json:"summary,omitempty"` + Status string `json:"status"` + CurrentRelease *AdminEventReleaseRef `json:"currentRelease,omitempty"` +} + +type AdminEventReleaseRef struct { + ID string `json:"id"` + ConfigLabel *string `json:"configLabel,omitempty"` + ManifestURL *string `json:"manifestUrl,omitempty"` + ManifestChecksumSha256 *string `json:"manifestChecksumSha256,omitempty"` + RouteCode *string `json:"routeCode,omitempty"` +} + +type AdminEventDetail struct { + Event AdminEventSummary `json:"event"` + LatestSource *EventConfigSourceView `json:"latestSource,omitempty"` + SourceCount int `json:"sourceCount"` + CurrentSource *AdminAssembledSource `json:"currentSource,omitempty"` +} + +type CreateAdminEventInput struct { + TenantCode *string `json:"tenantCode,omitempty"` + Slug string `json:"slug"` + DisplayName string `json:"displayName"` + Summary *string `json:"summary,omitempty"` + Status string `json:"status"` +} + +type UpdateAdminEventInput struct { + TenantCode *string `json:"tenantCode,omitempty"` + Slug string `json:"slug"` + DisplayName string `json:"displayName"` + Summary *string `json:"summary,omitempty"` + Status string `json:"status"` +} + +type SaveAdminEventSourceInput struct { + Map struct { + MapID string `json:"mapId"` + VersionID string `json:"versionId"` + } `json:"map"` + Playfield struct { + PlayfieldID string `json:"playfieldId"` + VersionID string `json:"versionId"` + } `json:"playfield"` + ResourcePack *struct { + ResourcePackID string `json:"resourcePackId"` + VersionID string `json:"versionId"` + } `json:"resourcePack,omitempty"` + GameModeCode string `json:"gameModeCode"` + RouteCode *string `json:"routeCode,omitempty"` + Overrides map[string]any `json:"overrides,omitempty"` + Notes *string `json:"notes,omitempty"` +} + +type AdminAssembledSource struct { + Refs map[string]any `json:"refs"` + Runtime map[string]any `json:"runtime"` + Overrides map[string]any `json:"overrides,omitempty"` +} + +func NewAdminEventService(store *postgres.Store) *AdminEventService { + return &AdminEventService{store: store} +} + +func (s *AdminEventService) ListEvents(ctx context.Context, limit int) ([]AdminEventSummary, error) { + items, err := s.store.ListAdminEvents(ctx, limit) + if err != nil { + return nil, err + } + results := make([]AdminEventSummary, 0, len(items)) + for _, item := range items { + results = append(results, buildAdminEventSummary(item)) + } + return results, nil +} + +func (s *AdminEventService) CreateEvent(ctx context.Context, input CreateAdminEventInput) (*AdminEventSummary, error) { + input.Slug = strings.TrimSpace(input.Slug) + input.DisplayName = strings.TrimSpace(input.DisplayName) + if input.Slug == "" || input.DisplayName == "" { + return nil, apperr.New(http.StatusBadRequest, "invalid_params", "slug and displayName are required") + } + + var tenantID *string + var tenantCode *string + var tenantName *string + if input.TenantCode != nil && strings.TrimSpace(*input.TenantCode) != "" { + tenant, err := s.store.GetTenantByCode(ctx, strings.TrimSpace(*input.TenantCode)) + if err != nil { + return nil, err + } + if tenant == nil { + return nil, apperr.New(http.StatusNotFound, "tenant_not_found", "tenant not found") + } + tenantID = &tenant.ID + tenantCode = &tenant.TenantCode + tenantName = &tenant.Name + } + + publicID, err := security.GeneratePublicID("evt") + if err != nil { + return nil, err + } + + tx, err := s.store.Begin(ctx) + if err != nil { + return nil, err + } + defer tx.Rollback(ctx) + + item, err := s.store.CreateAdminEvent(ctx, tx, postgres.CreateAdminEventParams{ + PublicID: publicID, + TenantID: tenantID, + Slug: input.Slug, + DisplayName: input.DisplayName, + Summary: trimStringPtr(input.Summary), + Status: normalizeEventCatalogStatus(input.Status), + }) + if err != nil { + return nil, err + } + if err := tx.Commit(ctx); err != nil { + return nil, err + } + + return &AdminEventSummary{ + ID: item.PublicID, + TenantCode: tenantCode, + TenantName: tenantName, + Slug: item.Slug, + DisplayName: item.DisplayName, + Summary: item.Summary, + Status: item.Status, + }, nil +} + +func (s *AdminEventService) UpdateEvent(ctx context.Context, eventPublicID string, input UpdateAdminEventInput) (*AdminEventSummary, error) { + record, err := s.store.GetAdminEventByPublicID(ctx, strings.TrimSpace(eventPublicID)) + if err != nil { + return nil, err + } + if record == nil { + return nil, apperr.New(http.StatusNotFound, "event_not_found", "event not found") + } + + input.Slug = strings.TrimSpace(input.Slug) + input.DisplayName = strings.TrimSpace(input.DisplayName) + if input.Slug == "" || input.DisplayName == "" { + return nil, apperr.New(http.StatusBadRequest, "invalid_params", "slug and displayName are required") + } + + var tenantID *string + clearTenant := false + if input.TenantCode != nil { + if trimmed := strings.TrimSpace(*input.TenantCode); trimmed != "" { + tenant, err := s.store.GetTenantByCode(ctx, trimmed) + if err != nil { + return nil, err + } + if tenant == nil { + return nil, apperr.New(http.StatusNotFound, "tenant_not_found", "tenant not found") + } + tenantID = &tenant.ID + } else { + clearTenant = true + } + } + + tx, err := s.store.Begin(ctx) + if err != nil { + return nil, err + } + defer tx.Rollback(ctx) + + updated, err := s.store.UpdateAdminEvent(ctx, tx, postgres.UpdateAdminEventParams{ + EventID: record.ID, + TenantID: tenantID, + Slug: input.Slug, + DisplayName: input.DisplayName, + Summary: trimStringPtr(input.Summary), + Status: normalizeEventCatalogStatus(input.Status), + ClearTenant: clearTenant, + }) + if err != nil { + return nil, err + } + if err := tx.Commit(ctx); err != nil { + return nil, err + } + + refreshed, err := s.store.GetAdminEventByPublicID(ctx, updated.PublicID) + if err != nil { + return nil, err + } + if refreshed == nil { + return nil, apperr.New(http.StatusNotFound, "event_not_found", "event not found") + } + summary := buildAdminEventSummary(*refreshed) + return &summary, nil +} + +func (s *AdminEventService) GetEventDetail(ctx context.Context, eventPublicID string) (*AdminEventDetail, error) { + record, err := s.store.GetAdminEventByPublicID(ctx, strings.TrimSpace(eventPublicID)) + if err != nil { + return nil, err + } + if record == nil { + return nil, apperr.New(http.StatusNotFound, "event_not_found", "event not found") + } + + sources, err := s.store.ListEventConfigSourcesByEventID(ctx, record.ID, 1) + if err != nil { + return nil, err + } + allSources, err := s.store.ListEventConfigSourcesByEventID(ctx, record.ID, 200) + if err != nil { + return nil, err + } + + result := &AdminEventDetail{ + Event: buildAdminEventSummary(*record), + SourceCount: len(allSources), + } + if len(sources) > 0 { + latest, err := buildEventConfigSourceView(&sources[0], record.PublicID) + if err != nil { + return nil, err + } + result.LatestSource = latest + result.CurrentSource = buildAdminAssembledSource(latest.Source) + } + return result, nil +} + +func (s *AdminEventService) SaveEventSource(ctx context.Context, eventPublicID string, input SaveAdminEventSourceInput) (*EventConfigSourceView, error) { + eventRecord, err := s.store.GetAdminEventByPublicID(ctx, strings.TrimSpace(eventPublicID)) + if err != nil { + return nil, err + } + if eventRecord == nil { + return nil, apperr.New(http.StatusNotFound, "event_not_found", "event not found") + } + + input.GameModeCode = strings.TrimSpace(input.GameModeCode) + input.Map.MapID = strings.TrimSpace(input.Map.MapID) + input.Map.VersionID = strings.TrimSpace(input.Map.VersionID) + input.Playfield.PlayfieldID = strings.TrimSpace(input.Playfield.PlayfieldID) + input.Playfield.VersionID = strings.TrimSpace(input.Playfield.VersionID) + if input.Map.MapID == "" || input.Map.VersionID == "" || input.Playfield.PlayfieldID == "" || input.Playfield.VersionID == "" || input.GameModeCode == "" { + return nil, apperr.New(http.StatusBadRequest, "invalid_params", "map, playfield and gameModeCode are required") + } + + mapVersion, err := s.store.GetResourceMapVersionByPublicID(ctx, input.Map.MapID, input.Map.VersionID) + if err != nil { + return nil, err + } + if mapVersion == nil { + return nil, apperr.New(http.StatusNotFound, "map_version_not_found", "map version not found") + } + playfieldVersion, err := s.store.GetResourcePlayfieldVersionByPublicID(ctx, input.Playfield.PlayfieldID, input.Playfield.VersionID) + if err != nil { + return nil, err + } + if playfieldVersion == nil { + return nil, apperr.New(http.StatusNotFound, "playfield_version_not_found", "playfield version not found") + } + + var resourcePackVersion *postgres.ResourcePackVersion + if input.ResourcePack != nil { + input.ResourcePack.ResourcePackID = strings.TrimSpace(input.ResourcePack.ResourcePackID) + input.ResourcePack.VersionID = strings.TrimSpace(input.ResourcePack.VersionID) + if input.ResourcePack.ResourcePackID == "" || input.ResourcePack.VersionID == "" { + return nil, apperr.New(http.StatusBadRequest, "invalid_params", "resourcePackId and versionId are required when resourcePack is provided") + } + resourcePackVersion, err = s.store.GetResourcePackVersionByPublicID(ctx, input.ResourcePack.ResourcePackID, input.ResourcePack.VersionID) + if err != nil { + return nil, err + } + if resourcePackVersion == nil { + return nil, apperr.New(http.StatusNotFound, "resource_pack_version_not_found", "resource pack version not found") + } + } + + source := s.buildEventSource(eventRecord, mapVersion, playfieldVersion, resourcePackVersion, input) + if err := validateSourceConfig(source); err != nil { + return nil, err + } + + nextVersion, err := s.store.NextEventConfigSourceVersion(ctx, eventRecord.ID) + if err != nil { + return nil, err + } + + note := trimStringPtr(input.Notes) + if note == nil { + defaultNote := fmt.Sprintf("assembled from admin refs: map=%s/%s playfield=%s/%s", input.Map.MapID, input.Map.VersionID, input.Playfield.PlayfieldID, input.Playfield.VersionID) + note = &defaultNote + } + + tx, err := s.store.Begin(ctx) + if err != nil { + return nil, err + } + defer tx.Rollback(ctx) + + record, err := s.store.UpsertEventConfigSource(ctx, tx, postgres.UpsertEventConfigSourceParams{ + EventID: eventRecord.ID, + SourceVersionNo: nextVersion, + SourceKind: "admin_assembled_bundle", + SchemaID: "event-source", + SchemaVersion: resolveSchemaVersion(source), + Status: "active", + Source: source, + Notes: note, + }) + if err != nil { + return nil, err + } + if err := tx.Commit(ctx); err != nil { + return nil, err + } + return buildEventConfigSourceView(record, eventRecord.PublicID) +} + +func (s *AdminEventService) buildEventSource(event *postgres.AdminEventRecord, mapVersion *postgres.ResourceMapVersion, playfieldVersion *postgres.ResourcePlayfieldVersion, resourcePackVersion *postgres.ResourcePackVersion, input SaveAdminEventSourceInput) map[string]any { + source := map[string]any{ + "schemaVersion": "1", + "app": map[string]any{ + "id": event.PublicID, + "title": event.DisplayName, + }, + "refs": map[string]any{ + "map": map[string]any{ + "id": input.Map.MapID, + "versionId": input.Map.VersionID, + }, + "playfield": map[string]any{ + "id": input.Playfield.PlayfieldID, + "versionId": input.Playfield.VersionID, + }, + "gameMode": map[string]any{ + "code": input.GameModeCode, + }, + }, + "map": map[string]any{ + "tiles": mapVersion.TilesRootURL, + "mapmeta": mapVersion.MapmetaURL, + }, + "playfield": map[string]any{ + "kind": "course", + "source": map[string]any{ + "type": playfieldVersion.SourceType, + "url": playfieldVersion.SourceURL, + }, + }, + "game": map[string]any{ + "mode": input.GameModeCode, + }, + } + if event.Summary != nil && strings.TrimSpace(*event.Summary) != "" { + source["summary"] = *event.Summary + } + if event.TenantCode != nil && strings.TrimSpace(*event.TenantCode) != "" { + source["branding"] = map[string]any{ + "tenantCode": *event.TenantCode, + } + } + if input.RouteCode != nil && strings.TrimSpace(*input.RouteCode) != "" { + source["playfield"].(map[string]any)["metadata"] = map[string]any{ + "routeCode": strings.TrimSpace(*input.RouteCode), + } + } + if resourcePackVersion != nil { + source["refs"].(map[string]any)["resourcePack"] = map[string]any{ + "id": input.ResourcePack.ResourcePackID, + "versionId": input.ResourcePack.VersionID, + } + resources := map[string]any{} + assets := map[string]any{} + if resourcePackVersion.ThemeProfileCode != nil && strings.TrimSpace(*resourcePackVersion.ThemeProfileCode) != "" { + resources["themeProfile"] = *resourcePackVersion.ThemeProfileCode + } + if resourcePackVersion.ContentEntryURL != nil && strings.TrimSpace(*resourcePackVersion.ContentEntryURL) != "" { + assets["contentHtml"] = *resourcePackVersion.ContentEntryURL + } + if resourcePackVersion.AudioRootURL != nil && strings.TrimSpace(*resourcePackVersion.AudioRootURL) != "" { + resources["audioRoot"] = *resourcePackVersion.AudioRootURL + } + if len(resources) > 0 { + source["resources"] = resources + } + if len(assets) > 0 { + source["assets"] = assets + } + } + if len(input.Overrides) > 0 { + source["overrides"] = input.Overrides + mergeJSONObject(source, input.Overrides) + } + return source +} + +func buildAdminEventSummary(item postgres.AdminEventRecord) AdminEventSummary { + summary := AdminEventSummary{ + ID: item.PublicID, + TenantCode: item.TenantCode, + TenantName: item.TenantName, + Slug: item.Slug, + DisplayName: item.DisplayName, + Summary: item.Summary, + Status: item.Status, + } + if item.CurrentReleasePubID != nil { + summary.CurrentRelease = &AdminEventReleaseRef{ + ID: *item.CurrentReleasePubID, + ConfigLabel: item.ConfigLabel, + ManifestURL: item.ManifestURL, + ManifestChecksumSha256: item.ManifestChecksum, + RouteCode: item.RouteCode, + } + } + return summary +} + +func buildAdminAssembledSource(source map[string]any) *AdminAssembledSource { + result := &AdminAssembledSource{} + if refs, ok := source["refs"].(map[string]any); ok { + result.Refs = refs + } + runtime := cloneJSONObject(source) + delete(runtime, "refs") + delete(runtime, "overrides") + if overrides, ok := source["overrides"].(map[string]any); ok && len(overrides) > 0 { + result.Overrides = overrides + } + result.Runtime = runtime + return result +} + +func normalizeEventCatalogStatus(value string) string { + switch strings.TrimSpace(value) { + case "active": + return "active" + case "disabled": + return "disabled" + case "archived": + return "archived" + default: + return "draft" + } +} + +func mergeJSONObject(target map[string]any, overrides map[string]any) { + for key, value := range overrides { + if valueMap, ok := value.(map[string]any); ok { + existing, ok := target[key].(map[string]any) + if !ok { + existing = map[string]any{} + target[key] = existing + } + mergeJSONObject(existing, valueMap) + continue + } + target[key] = value + } +} diff --git a/backend/internal/service/admin_pipeline_service.go b/backend/internal/service/admin_pipeline_service.go new file mode 100644 index 0000000..805d5bd --- /dev/null +++ b/backend/internal/service/admin_pipeline_service.go @@ -0,0 +1,178 @@ +package service + +import ( + "context" + "net/http" + "strings" + + "cmr-backend/internal/apperr" + "cmr-backend/internal/store/postgres" +) + +type AdminPipelineService struct { + store *postgres.Store + configService *ConfigService +} + +type AdminReleaseView struct { + ID string `json:"id"` + ReleaseNo int `json:"releaseNo"` + ConfigLabel string `json:"configLabel"` + ManifestURL string `json:"manifestUrl"` + ManifestChecksumSha256 *string `json:"manifestChecksumSha256,omitempty"` + RouteCode *string `json:"routeCode,omitempty"` + BuildID *string `json:"buildId,omitempty"` + Status string `json:"status"` + PublishedAt string `json:"publishedAt"` +} + +type AdminEventPipelineView struct { + EventID string `json:"eventId"` + CurrentRelease *AdminReleaseView `json:"currentRelease,omitempty"` + Sources []EventConfigSourceView `json:"sources"` + Builds []EventConfigBuildView `json:"builds"` + Releases []AdminReleaseView `json:"releases"` +} + +type AdminRollbackReleaseInput struct { + ReleaseID string `json:"releaseId"` +} + +func NewAdminPipelineService(store *postgres.Store, configService *ConfigService) *AdminPipelineService { + return &AdminPipelineService{ + store: store, + configService: configService, + } +} + +func (s *AdminPipelineService) GetEventPipeline(ctx context.Context, eventPublicID string, limit int) (*AdminEventPipelineView, error) { + event, err := s.store.GetEventByPublicID(ctx, strings.TrimSpace(eventPublicID)) + if err != nil { + return nil, err + } + if event == nil { + return nil, apperr.New(http.StatusNotFound, "event_not_found", "event not found") + } + + sources, err := s.configService.ListEventConfigSources(ctx, event.PublicID, limit) + if err != nil { + return nil, err + } + buildRecords, err := s.store.ListEventConfigBuildsByEventID(ctx, event.ID, limit) + if err != nil { + return nil, err + } + releaseRecords, err := s.store.ListEventReleasesByEventID(ctx, event.ID, limit) + if err != nil { + return nil, err + } + + builds := make([]EventConfigBuildView, 0, len(buildRecords)) + for i := range buildRecords { + item, err := buildEventConfigBuildView(&buildRecords[i]) + if err != nil { + return nil, err + } + builds = append(builds, *item) + } + releases := make([]AdminReleaseView, 0, len(releaseRecords)) + for _, item := range releaseRecords { + releases = append(releases, buildAdminReleaseView(item)) + } + + result := &AdminEventPipelineView{ + EventID: event.PublicID, + Sources: sources, + Builds: builds, + Releases: releases, + } + if event.CurrentReleasePubID != nil { + result.CurrentRelease = &AdminReleaseView{ + ID: *event.CurrentReleasePubID, + ConfigLabel: derefStringOrEmpty(event.ConfigLabel), + ManifestURL: derefStringOrEmpty(event.ManifestURL), + ManifestChecksumSha256: event.ManifestChecksum, + RouteCode: event.RouteCode, + Status: "published", + } + } + return result, nil +} + +func (s *AdminPipelineService) BuildSource(ctx context.Context, sourceID string) (*EventConfigBuildView, error) { + return s.configService.BuildPreview(ctx, BuildPreviewInput{SourceID: sourceID}) +} + +func (s *AdminPipelineService) GetBuild(ctx context.Context, buildID string) (*EventConfigBuildView, error) { + return s.configService.GetEventConfigBuild(ctx, buildID) +} + +func (s *AdminPipelineService) PublishBuild(ctx context.Context, buildID string) (*PublishedReleaseView, error) { + return s.configService.PublishBuild(ctx, PublishBuildInput{BuildID: buildID}) +} + +func (s *AdminPipelineService) RollbackRelease(ctx context.Context, eventPublicID string, input AdminRollbackReleaseInput) (*AdminReleaseView, error) { + event, err := s.store.GetEventByPublicID(ctx, strings.TrimSpace(eventPublicID)) + if err != nil { + return nil, err + } + if event == nil { + return nil, apperr.New(http.StatusNotFound, "event_not_found", "event not found") + } + + input.ReleaseID = strings.TrimSpace(input.ReleaseID) + if input.ReleaseID == "" { + return nil, apperr.New(http.StatusBadRequest, "invalid_params", "releaseId is required") + } + + release, err := s.store.GetEventReleaseByPublicID(ctx, input.ReleaseID) + if err != nil { + return nil, err + } + if release == nil { + return nil, apperr.New(http.StatusNotFound, "release_not_found", "release not found") + } + if release.EventID != event.ID { + return nil, apperr.New(http.StatusConflict, "release_not_belong_to_event", "release does not belong to event") + } + if release.Status != "published" { + return nil, apperr.New(http.StatusConflict, "release_not_publishable", "release is not published") + } + + tx, err := s.store.Begin(ctx) + if err != nil { + return nil, err + } + defer tx.Rollback(ctx) + + if err := s.store.SetCurrentEventRelease(ctx, tx, event.ID, release.ID); err != nil { + return nil, err + } + if err := tx.Commit(ctx); err != nil { + return nil, err + } + + view := buildAdminReleaseView(*release) + return &view, nil +} + +func buildAdminReleaseView(item postgres.EventRelease) AdminReleaseView { + return AdminReleaseView{ + ID: item.PublicID, + ReleaseNo: item.ReleaseNo, + ConfigLabel: item.ConfigLabel, + ManifestURL: item.ManifestURL, + ManifestChecksumSha256: item.ManifestChecksum, + RouteCode: item.RouteCode, + BuildID: item.BuildID, + Status: item.Status, + PublishedAt: item.PublishedAt.Format(timeRFC3339), + } +} + +func derefStringOrEmpty(value *string) string { + if value == nil { + return "" + } + return *value +} diff --git a/backend/internal/service/admin_resource_service.go b/backend/internal/service/admin_resource_service.go new file mode 100644 index 0000000..a10459a --- /dev/null +++ b/backend/internal/service/admin_resource_service.go @@ -0,0 +1,719 @@ +package service + +import ( + "context" + "encoding/json" + "net/http" + "strings" + + "cmr-backend/internal/apperr" + "cmr-backend/internal/platform/security" + "cmr-backend/internal/store/postgres" +) + +type AdminResourceService struct { + store *postgres.Store +} + +type AdminMapSummary struct { + ID string `json:"id"` + Code string `json:"code"` + Name string `json:"name"` + Status string `json:"status"` + Description *string `json:"description,omitempty"` + CurrentVersionID *string `json:"currentVersionId,omitempty"` + CurrentVersion *AdminMapVersionBrief `json:"currentVersion,omitempty"` +} + +type AdminMapVersionBrief struct { + ID string `json:"id"` + VersionCode string `json:"versionCode"` + Status string `json:"status"` +} + +type AdminMapVersion struct { + ID string `json:"id"` + VersionCode string `json:"versionCode"` + Status string `json:"status"` + MapmetaURL string `json:"mapmetaUrl"` + TilesRootURL string `json:"tilesRootUrl"` + PublishedAssetRoot *string `json:"publishedAssetRoot,omitempty"` + Bounds map[string]any `json:"bounds,omitempty"` + Metadata map[string]any `json:"metadata,omitempty"` +} + +type AdminMapDetail struct { + Map AdminMapSummary `json:"map"` + Versions []AdminMapVersion `json:"versions"` +} + +type CreateAdminMapInput struct { + Code string `json:"code"` + Name string `json:"name"` + Status string `json:"status"` + Description *string `json:"description,omitempty"` +} + +type CreateAdminMapVersionInput struct { + VersionCode string `json:"versionCode"` + Status string `json:"status"` + MapmetaURL string `json:"mapmetaUrl"` + TilesRootURL string `json:"tilesRootUrl"` + PublishedAssetRoot *string `json:"publishedAssetRoot,omitempty"` + Bounds map[string]any `json:"bounds,omitempty"` + Metadata map[string]any `json:"metadata,omitempty"` + SetAsCurrent bool `json:"setAsCurrent"` +} + +type AdminPlayfieldSummary struct { + ID string `json:"id"` + Code string `json:"code"` + Name string `json:"name"` + Kind string `json:"kind"` + Status string `json:"status"` + Description *string `json:"description,omitempty"` + CurrentVersionID *string `json:"currentVersionId,omitempty"` + CurrentVersion *AdminPlayfieldVersionBrief `json:"currentVersion,omitempty"` +} + +type AdminPlayfieldVersionBrief struct { + ID string `json:"id"` + VersionCode string `json:"versionCode"` + Status string `json:"status"` + SourceType string `json:"sourceType"` +} + +type AdminPlayfieldVersion struct { + ID string `json:"id"` + VersionCode string `json:"versionCode"` + Status string `json:"status"` + SourceType string `json:"sourceType"` + SourceURL string `json:"sourceUrl"` + PublishedAssetRoot *string `json:"publishedAssetRoot,omitempty"` + ControlCount *int `json:"controlCount,omitempty"` + Bounds map[string]any `json:"bounds,omitempty"` + Metadata map[string]any `json:"metadata,omitempty"` +} + +type AdminPlayfieldDetail struct { + Playfield AdminPlayfieldSummary `json:"playfield"` + Versions []AdminPlayfieldVersion `json:"versions"` +} + +type CreateAdminPlayfieldInput struct { + Code string `json:"code"` + Name string `json:"name"` + Kind string `json:"kind"` + Status string `json:"status"` + Description *string `json:"description,omitempty"` +} + +type CreateAdminPlayfieldVersionInput struct { + VersionCode string `json:"versionCode"` + Status string `json:"status"` + SourceType string `json:"sourceType"` + SourceURL string `json:"sourceUrl"` + PublishedAssetRoot *string `json:"publishedAssetRoot,omitempty"` + ControlCount *int `json:"controlCount,omitempty"` + Bounds map[string]any `json:"bounds,omitempty"` + Metadata map[string]any `json:"metadata,omitempty"` + SetAsCurrent bool `json:"setAsCurrent"` +} + +type AdminResourcePackSummary struct { + ID string `json:"id"` + Code string `json:"code"` + Name string `json:"name"` + Status string `json:"status"` + Description *string `json:"description,omitempty"` + CurrentVersionID *string `json:"currentVersionId,omitempty"` + CurrentVersion *AdminResourcePackVersionBrief `json:"currentVersion,omitempty"` +} + +type AdminResourcePackVersionBrief struct { + ID string `json:"id"` + VersionCode string `json:"versionCode"` + Status string `json:"status"` +} + +type AdminResourcePackVersion struct { + ID string `json:"id"` + VersionCode string `json:"versionCode"` + Status string `json:"status"` + ContentEntryURL *string `json:"contentEntryUrl,omitempty"` + AudioRootURL *string `json:"audioRootUrl,omitempty"` + ThemeProfileCode *string `json:"themeProfileCode,omitempty"` + PublishedAssetRoot *string `json:"publishedAssetRoot,omitempty"` + Metadata map[string]any `json:"metadata,omitempty"` +} + +type AdminResourcePackDetail struct { + ResourcePack AdminResourcePackSummary `json:"resourcePack"` + Versions []AdminResourcePackVersion `json:"versions"` +} + +type CreateAdminResourcePackInput struct { + Code string `json:"code"` + Name string `json:"name"` + Status string `json:"status"` + Description *string `json:"description,omitempty"` +} + +type CreateAdminResourcePackVersionInput struct { + VersionCode string `json:"versionCode"` + Status string `json:"status"` + ContentEntryURL *string `json:"contentEntryUrl,omitempty"` + AudioRootURL *string `json:"audioRootUrl,omitempty"` + ThemeProfileCode *string `json:"themeProfileCode,omitempty"` + PublishedAssetRoot *string `json:"publishedAssetRoot,omitempty"` + Metadata map[string]any `json:"metadata,omitempty"` + SetAsCurrent bool `json:"setAsCurrent"` +} + +func NewAdminResourceService(store *postgres.Store) *AdminResourceService { + return &AdminResourceService{store: store} +} + +func (s *AdminResourceService) ListMaps(ctx context.Context, limit int) ([]AdminMapSummary, error) { + items, err := s.store.ListResourceMaps(ctx, limit) + if err != nil { + return nil, err + } + results := make([]AdminMapSummary, 0, len(items)) + for _, item := range items { + results = append(results, AdminMapSummary{ + ID: item.PublicID, + Code: item.Code, + Name: item.Name, + Status: item.Status, + Description: item.Description, + CurrentVersionID: item.CurrentVersionID, + }) + } + return results, nil +} + +func (s *AdminResourceService) CreateMap(ctx context.Context, input CreateAdminMapInput) (*AdminMapSummary, error) { + input.Code = strings.TrimSpace(input.Code) + input.Name = strings.TrimSpace(input.Name) + status := normalizeCatalogStatus(input.Status) + if input.Code == "" || input.Name == "" { + return nil, apperr.New(http.StatusBadRequest, "invalid_params", "code and name are required") + } + + publicID, err := security.GeneratePublicID("map") + if err != nil { + return nil, err + } + tx, err := s.store.Begin(ctx) + if err != nil { + return nil, err + } + defer tx.Rollback(ctx) + + item, err := s.store.CreateResourceMap(ctx, tx, postgres.CreateResourceMapParams{ + PublicID: publicID, + Code: input.Code, + Name: input.Name, + Status: status, + Description: trimStringPtr(input.Description), + }) + if err != nil { + return nil, err + } + if err := tx.Commit(ctx); err != nil { + return nil, err + } + return &AdminMapSummary{ + ID: item.PublicID, + Code: item.Code, + Name: item.Name, + Status: item.Status, + Description: item.Description, + CurrentVersionID: item.CurrentVersionID, + }, nil +} + +func (s *AdminResourceService) GetMapDetail(ctx context.Context, mapPublicID string) (*AdminMapDetail, error) { + item, err := s.store.GetResourceMapByPublicID(ctx, strings.TrimSpace(mapPublicID)) + if err != nil { + return nil, err + } + if item == nil { + return nil, apperr.New(http.StatusNotFound, "map_not_found", "map not found") + } + versions, err := s.store.ListResourceMapVersions(ctx, item.ID) + if err != nil { + return nil, err + } + result := &AdminMapDetail{ + Map: AdminMapSummary{ + ID: item.PublicID, + Code: item.Code, + Name: item.Name, + Status: item.Status, + Description: item.Description, + CurrentVersionID: item.CurrentVersionID, + }, + Versions: make([]AdminMapVersion, 0, len(versions)), + } + for _, version := range versions { + view := AdminMapVersion{ + ID: version.PublicID, + VersionCode: version.VersionCode, + Status: version.Status, + MapmetaURL: version.MapmetaURL, + TilesRootURL: version.TilesRootURL, + PublishedAssetRoot: version.PublishedAssetRoot, + Bounds: decodeJSONMap(version.BoundsJSON), + Metadata: decodeJSONMap(version.MetadataJSON), + } + result.Versions = append(result.Versions, view) + if item.CurrentVersionID != nil && *item.CurrentVersionID == version.ID { + result.Map.CurrentVersion = &AdminMapVersionBrief{ + ID: version.PublicID, + VersionCode: version.VersionCode, + Status: version.Status, + } + result.Map.CurrentVersionID = &view.ID + } + } + return result, nil +} + +func (s *AdminResourceService) CreateMapVersion(ctx context.Context, mapPublicID string, input CreateAdminMapVersionInput) (*AdminMapVersion, error) { + mapItem, err := s.store.GetResourceMapByPublicID(ctx, strings.TrimSpace(mapPublicID)) + if err != nil { + return nil, err + } + if mapItem == nil { + return nil, apperr.New(http.StatusNotFound, "map_not_found", "map not found") + } + input.VersionCode = strings.TrimSpace(input.VersionCode) + input.MapmetaURL = strings.TrimSpace(input.MapmetaURL) + input.TilesRootURL = strings.TrimSpace(input.TilesRootURL) + if input.VersionCode == "" || input.MapmetaURL == "" || input.TilesRootURL == "" { + return nil, apperr.New(http.StatusBadRequest, "invalid_params", "versionCode, mapmetaUrl and tilesRootUrl are required") + } + + publicID, err := security.GeneratePublicID("mapv") + if err != nil { + return nil, err + } + tx, err := s.store.Begin(ctx) + if err != nil { + return nil, err + } + defer tx.Rollback(ctx) + + version, err := s.store.CreateResourceMapVersion(ctx, tx, postgres.CreateResourceMapVersionParams{ + PublicID: publicID, + MapID: mapItem.ID, + VersionCode: input.VersionCode, + Status: normalizeVersionStatus(input.Status), + MapmetaURL: input.MapmetaURL, + TilesRootURL: input.TilesRootURL, + PublishedAssetRoot: trimStringPtr(input.PublishedAssetRoot), + BoundsJSON: input.Bounds, + MetadataJSON: input.Metadata, + }) + if err != nil { + return nil, err + } + if input.SetAsCurrent { + if err := s.store.SetResourceMapCurrentVersion(ctx, tx, mapItem.ID, version.ID); err != nil { + return nil, err + } + } + if err := tx.Commit(ctx); err != nil { + return nil, err + } + return &AdminMapVersion{ + ID: version.PublicID, + VersionCode: version.VersionCode, + Status: version.Status, + MapmetaURL: version.MapmetaURL, + TilesRootURL: version.TilesRootURL, + PublishedAssetRoot: version.PublishedAssetRoot, + Bounds: decodeJSONMap(version.BoundsJSON), + Metadata: decodeJSONMap(version.MetadataJSON), + }, nil +} + +func (s *AdminResourceService) ListPlayfields(ctx context.Context, limit int) ([]AdminPlayfieldSummary, error) { + items, err := s.store.ListResourcePlayfields(ctx, limit) + if err != nil { + return nil, err + } + results := make([]AdminPlayfieldSummary, 0, len(items)) + for _, item := range items { + results = append(results, AdminPlayfieldSummary{ + ID: item.PublicID, + Code: item.Code, + Name: item.Name, + Kind: item.Kind, + Status: item.Status, + Description: item.Description, + CurrentVersionID: item.CurrentVersionID, + }) + } + return results, nil +} + +func (s *AdminResourceService) CreatePlayfield(ctx context.Context, input CreateAdminPlayfieldInput) (*AdminPlayfieldSummary, error) { + input.Code = strings.TrimSpace(input.Code) + input.Name = strings.TrimSpace(input.Name) + kind := strings.TrimSpace(input.Kind) + if kind == "" { + kind = "course" + } + if input.Code == "" || input.Name == "" { + return nil, apperr.New(http.StatusBadRequest, "invalid_params", "code and name are required") + } + publicID, err := security.GeneratePublicID("pf") + if err != nil { + return nil, err + } + tx, err := s.store.Begin(ctx) + if err != nil { + return nil, err + } + defer tx.Rollback(ctx) + item, err := s.store.CreateResourcePlayfield(ctx, tx, postgres.CreateResourcePlayfieldParams{ + PublicID: publicID, + Code: input.Code, + Name: input.Name, + Kind: kind, + Status: normalizeCatalogStatus(input.Status), + Description: trimStringPtr(input.Description), + }) + if err != nil { + return nil, err + } + if err := tx.Commit(ctx); err != nil { + return nil, err + } + return &AdminPlayfieldSummary{ + ID: item.PublicID, + Code: item.Code, + Name: item.Name, + Kind: item.Kind, + Status: item.Status, + Description: item.Description, + CurrentVersionID: item.CurrentVersionID, + }, nil +} + +func (s *AdminResourceService) GetPlayfieldDetail(ctx context.Context, publicID string) (*AdminPlayfieldDetail, error) { + item, err := s.store.GetResourcePlayfieldByPublicID(ctx, strings.TrimSpace(publicID)) + if err != nil { + return nil, err + } + if item == nil { + return nil, apperr.New(http.StatusNotFound, "playfield_not_found", "playfield not found") + } + versions, err := s.store.ListResourcePlayfieldVersions(ctx, item.ID) + if err != nil { + return nil, err + } + result := &AdminPlayfieldDetail{ + Playfield: AdminPlayfieldSummary{ + ID: item.PublicID, + Code: item.Code, + Name: item.Name, + Kind: item.Kind, + Status: item.Status, + Description: item.Description, + CurrentVersionID: item.CurrentVersionID, + }, + Versions: make([]AdminPlayfieldVersion, 0, len(versions)), + } + for _, version := range versions { + view := AdminPlayfieldVersion{ + ID: version.PublicID, + VersionCode: version.VersionCode, + Status: version.Status, + SourceType: version.SourceType, + SourceURL: version.SourceURL, + PublishedAssetRoot: version.PublishedAssetRoot, + ControlCount: version.ControlCount, + Bounds: decodeJSONMap(version.BoundsJSON), + Metadata: decodeJSONMap(version.MetadataJSON), + } + result.Versions = append(result.Versions, view) + if item.CurrentVersionID != nil && *item.CurrentVersionID == version.ID { + result.Playfield.CurrentVersion = &AdminPlayfieldVersionBrief{ + ID: version.PublicID, + VersionCode: version.VersionCode, + Status: version.Status, + SourceType: version.SourceType, + } + result.Playfield.CurrentVersionID = &view.ID + } + } + return result, nil +} + +func (s *AdminResourceService) CreatePlayfieldVersion(ctx context.Context, publicID string, input CreateAdminPlayfieldVersionInput) (*AdminPlayfieldVersion, error) { + item, err := s.store.GetResourcePlayfieldByPublicID(ctx, strings.TrimSpace(publicID)) + if err != nil { + return nil, err + } + if item == nil { + return nil, apperr.New(http.StatusNotFound, "playfield_not_found", "playfield not found") + } + input.VersionCode = strings.TrimSpace(input.VersionCode) + input.SourceType = strings.TrimSpace(input.SourceType) + input.SourceURL = strings.TrimSpace(input.SourceURL) + if input.VersionCode == "" || input.SourceType == "" || input.SourceURL == "" { + return nil, apperr.New(http.StatusBadRequest, "invalid_params", "versionCode, sourceType and sourceUrl are required") + } + publicVersionID, err := security.GeneratePublicID("pfv") + if err != nil { + return nil, err + } + tx, err := s.store.Begin(ctx) + if err != nil { + return nil, err + } + defer tx.Rollback(ctx) + version, err := s.store.CreateResourcePlayfieldVersion(ctx, tx, postgres.CreateResourcePlayfieldVersionParams{ + PublicID: publicVersionID, + PlayfieldID: item.ID, + VersionCode: input.VersionCode, + Status: normalizeVersionStatus(input.Status), + SourceType: input.SourceType, + SourceURL: input.SourceURL, + PublishedAssetRoot: trimStringPtr(input.PublishedAssetRoot), + ControlCount: input.ControlCount, + BoundsJSON: input.Bounds, + MetadataJSON: input.Metadata, + }) + if err != nil { + return nil, err + } + if input.SetAsCurrent { + if err := s.store.SetResourcePlayfieldCurrentVersion(ctx, tx, item.ID, version.ID); err != nil { + return nil, err + } + } + if err := tx.Commit(ctx); err != nil { + return nil, err + } + return &AdminPlayfieldVersion{ + ID: version.PublicID, + VersionCode: version.VersionCode, + Status: version.Status, + SourceType: version.SourceType, + SourceURL: version.SourceURL, + PublishedAssetRoot: version.PublishedAssetRoot, + ControlCount: version.ControlCount, + Bounds: decodeJSONMap(version.BoundsJSON), + Metadata: decodeJSONMap(version.MetadataJSON), + }, nil +} + +func (s *AdminResourceService) ListResourcePacks(ctx context.Context, limit int) ([]AdminResourcePackSummary, error) { + items, err := s.store.ListResourcePacks(ctx, limit) + if err != nil { + return nil, err + } + results := make([]AdminResourcePackSummary, 0, len(items)) + for _, item := range items { + results = append(results, AdminResourcePackSummary{ + ID: item.PublicID, + Code: item.Code, + Name: item.Name, + Status: item.Status, + Description: item.Description, + CurrentVersionID: item.CurrentVersionID, + }) + } + return results, nil +} + +func (s *AdminResourceService) CreateResourcePack(ctx context.Context, input CreateAdminResourcePackInput) (*AdminResourcePackSummary, error) { + input.Code = strings.TrimSpace(input.Code) + input.Name = strings.TrimSpace(input.Name) + if input.Code == "" || input.Name == "" { + return nil, apperr.New(http.StatusBadRequest, "invalid_params", "code and name are required") + } + publicID, err := security.GeneratePublicID("rp") + if err != nil { + return nil, err + } + tx, err := s.store.Begin(ctx) + if err != nil { + return nil, err + } + defer tx.Rollback(ctx) + item, err := s.store.CreateResourcePack(ctx, tx, postgres.CreateResourcePackParams{ + PublicID: publicID, + Code: input.Code, + Name: input.Name, + Status: normalizeCatalogStatus(input.Status), + Description: trimStringPtr(input.Description), + }) + if err != nil { + return nil, err + } + if err := tx.Commit(ctx); err != nil { + return nil, err + } + return &AdminResourcePackSummary{ + ID: item.PublicID, + Code: item.Code, + Name: item.Name, + Status: item.Status, + Description: item.Description, + CurrentVersionID: item.CurrentVersionID, + }, nil +} + +func (s *AdminResourceService) GetResourcePackDetail(ctx context.Context, publicID string) (*AdminResourcePackDetail, error) { + item, err := s.store.GetResourcePackByPublicID(ctx, strings.TrimSpace(publicID)) + if err != nil { + return nil, err + } + if item == nil { + return nil, apperr.New(http.StatusNotFound, "resource_pack_not_found", "resource pack not found") + } + versions, err := s.store.ListResourcePackVersions(ctx, item.ID) + if err != nil { + return nil, err + } + result := &AdminResourcePackDetail{ + ResourcePack: AdminResourcePackSummary{ + ID: item.PublicID, + Code: item.Code, + Name: item.Name, + Status: item.Status, + Description: item.Description, + CurrentVersionID: item.CurrentVersionID, + }, + Versions: make([]AdminResourcePackVersion, 0, len(versions)), + } + for _, version := range versions { + view := AdminResourcePackVersion{ + ID: version.PublicID, + VersionCode: version.VersionCode, + Status: version.Status, + ContentEntryURL: version.ContentEntryURL, + AudioRootURL: version.AudioRootURL, + ThemeProfileCode: version.ThemeProfileCode, + PublishedAssetRoot: version.PublishedAssetRoot, + Metadata: decodeJSONMap(version.MetadataJSON), + } + result.Versions = append(result.Versions, view) + if item.CurrentVersionID != nil && *item.CurrentVersionID == version.ID { + result.ResourcePack.CurrentVersion = &AdminResourcePackVersionBrief{ + ID: version.PublicID, + VersionCode: version.VersionCode, + Status: version.Status, + } + result.ResourcePack.CurrentVersionID = &view.ID + } + } + return result, nil +} + +func (s *AdminResourceService) CreateResourcePackVersion(ctx context.Context, publicID string, input CreateAdminResourcePackVersionInput) (*AdminResourcePackVersion, error) { + item, err := s.store.GetResourcePackByPublicID(ctx, strings.TrimSpace(publicID)) + if err != nil { + return nil, err + } + if item == nil { + return nil, apperr.New(http.StatusNotFound, "resource_pack_not_found", "resource pack not found") + } + input.VersionCode = strings.TrimSpace(input.VersionCode) + if input.VersionCode == "" { + return nil, apperr.New(http.StatusBadRequest, "invalid_params", "versionCode is required") + } + publicVersionID, err := security.GeneratePublicID("rpv") + if err != nil { + return nil, err + } + tx, err := s.store.Begin(ctx) + if err != nil { + return nil, err + } + defer tx.Rollback(ctx) + version, err := s.store.CreateResourcePackVersion(ctx, tx, postgres.CreateResourcePackVersionParams{ + PublicID: publicVersionID, + ResourcePackID: item.ID, + VersionCode: input.VersionCode, + Status: normalizeVersionStatus(input.Status), + ContentEntryURL: trimStringPtr(input.ContentEntryURL), + AudioRootURL: trimStringPtr(input.AudioRootURL), + ThemeProfileCode: trimStringPtr(input.ThemeProfileCode), + PublishedAssetRoot: trimStringPtr(input.PublishedAssetRoot), + MetadataJSON: input.Metadata, + }) + if err != nil { + return nil, err + } + if input.SetAsCurrent { + if err := s.store.SetResourcePackCurrentVersion(ctx, tx, item.ID, version.ID); err != nil { + return nil, err + } + } + if err := tx.Commit(ctx); err != nil { + return nil, err + } + return &AdminResourcePackVersion{ + ID: version.PublicID, + VersionCode: version.VersionCode, + Status: version.Status, + ContentEntryURL: version.ContentEntryURL, + AudioRootURL: version.AudioRootURL, + ThemeProfileCode: version.ThemeProfileCode, + PublishedAssetRoot: version.PublishedAssetRoot, + Metadata: decodeJSONMap(version.MetadataJSON), + }, nil +} + +func normalizeCatalogStatus(value string) string { + switch strings.TrimSpace(value) { + case "active": + return "active" + case "disabled": + return "disabled" + case "archived": + return "archived" + default: + return "draft" + } +} + +func normalizeVersionStatus(value string) string { + switch strings.TrimSpace(value) { + case "active": + return "active" + case "archived": + return "archived" + default: + return "draft" + } +} + +func trimStringPtr(value *string) *string { + if value == nil { + return nil + } + trimmed := strings.TrimSpace(*value) + if trimmed == "" { + return nil + } + return &trimmed +} + +func decodeJSONMap(raw json.RawMessage) map[string]any { + if len(raw) == 0 { + return nil + } + result := map[string]any{} + if err := json.Unmarshal(raw, &result); err != nil || len(result) == 0 { + return nil + } + return result +} diff --git a/backend/internal/service/entry_home_service.go b/backend/internal/service/entry_home_service.go index 9e6ed6f..2acad6b 100644 --- a/backend/internal/service/entry_home_service.go +++ b/backend/internal/service/entry_home_service.go @@ -127,7 +127,7 @@ func (s *EntryHomeService) GetEntryHome(ctx context.Context, input EntryHomeInpu } for i := range sessions { - if sessions[i].Status == "launched" || sessions[i].Status == "running" { + if isSessionOngoingStatus(sessions[i].Status) { ongoing := buildEntrySessionSummary(&sessions[i]) result.OngoingSession = &ongoing break diff --git a/backend/internal/service/event_play_service.go b/backend/internal/service/event_play_service.go index e3fc5ef..eda1b52 100644 --- a/backend/internal/service/event_play_service.go +++ b/backend/internal/service/event_play_service.go @@ -99,7 +99,7 @@ func (s *EventPlayService) GetEventPlay(ctx context.Context, input EventPlayInpu result.Play.RecentSession = &recent } for i := range sessions { - if sessions[i].Status == "launched" || sessions[i].Status == "running" { + if isSessionOngoingStatus(sessions[i].Status) { ongoing := buildEntrySessionSummary(&sessions[i]) result.Play.OngoingSession = &ongoing break diff --git a/backend/internal/service/session_service.go b/backend/internal/service/session_service.go index cdda049..c809629 100644 --- a/backend/internal/service/session_service.go +++ b/backend/internal/service/session_service.go @@ -16,6 +16,10 @@ type SessionService struct { store *postgres.Store } +type sessionTokenPolicy struct { + AllowExpired bool +} + type SessionResult struct { Session struct { ID string `json:"id"` @@ -99,57 +103,11 @@ func (s *SessionService) ListMySessions(ctx context.Context, userID string, limi } func (s *SessionService) StartSession(ctx context.Context, input SessionActionInput) (*SessionResult, error) { - session, err := s.validateSessionAction(ctx, input.SessionPublicID, input.SessionToken) + session, err := s.validateSessionAction(ctx, input.SessionPublicID, input.SessionToken, sessionTokenPolicy{}) if err != nil { return nil, err } - - if session.Status == "finished" || session.Status == "cancelled" || session.Status == "failed" { - return nil, apperr.New(http.StatusConflict, "session_not_startable", "session cannot be started") - } - - tx, err := s.store.Begin(ctx) - if err != nil { - return nil, err - } - defer tx.Rollback(ctx) - - locked, err := s.store.GetSessionByPublicIDForUpdate(ctx, tx, input.SessionPublicID) - if err != nil { - return nil, err - } - if locked == nil { - return nil, apperr.New(http.StatusNotFound, "session_not_found", "session not found") - } - if err := s.verifySessionToken(locked, input.SessionToken); err != nil { - return nil, err - } - if locked.Status == "finished" || locked.Status == "cancelled" || locked.Status == "failed" { - return nil, apperr.New(http.StatusConflict, "session_not_startable", "session cannot be started") - } - - if err := s.store.StartSession(ctx, tx, locked.ID); err != nil { - return nil, err - } - - updated, err := s.store.GetSessionByPublicIDForUpdate(ctx, tx, input.SessionPublicID) - if err != nil { - return nil, err - } - if err := tx.Commit(ctx); err != nil { - return nil, err - } - return buildSessionResult(updated), nil -} - -func (s *SessionService) FinishSession(ctx context.Context, input FinishSessionInput) (*SessionResult, error) { - input.Status = normalizeFinishStatus(input.Status) - session, err := s.validateSessionAction(ctx, input.SessionPublicID, input.SessionToken) - if err != nil { - return nil, err - } - - if session.Status == "finished" || session.Status == "cancelled" || session.Status == "failed" { + if session.Status == SessionStatusRunning || isSessionTerminalStatus(session.Status) { return buildSessionResult(session), nil } @@ -166,11 +124,73 @@ func (s *SessionService) FinishSession(ctx context.Context, input FinishSessionI if locked == nil { return nil, apperr.New(http.StatusNotFound, "session_not_found", "session not found") } - if err := s.verifySessionToken(locked, input.SessionToken); err != nil { + if err := s.verifySessionToken(locked, input.SessionToken, sessionTokenPolicy{}); err != nil { + return nil, err + } + if locked.Status == SessionStatusRunning || isSessionTerminalStatus(locked.Status) { + if err := tx.Commit(ctx); err != nil { + return nil, err + } + return buildSessionResult(locked), nil + } + + if locked.Status == SessionStatusLaunched { + if err := s.store.StartSession(ctx, tx, locked.ID); err != nil { + return nil, err + } + } + + updated, err := s.store.GetSessionByPublicIDForUpdate(ctx, tx, input.SessionPublicID) + if err != nil { + return nil, err + } + if updated == nil { + return nil, apperr.New(http.StatusNotFound, "session_not_found", "session not found") + } + if err := tx.Commit(ctx); err != nil { + return nil, err + } + return buildSessionResult(updated), nil +} + +func (s *SessionService) FinishSession(ctx context.Context, input FinishSessionInput) (*SessionResult, error) { + status, err := normalizeFinishStatus(input.Status) + if err != nil { + return nil, err + } + input.Status = status + + session, err := s.validateSessionAction(ctx, input.SessionPublicID, input.SessionToken, sessionTokenPolicy{ + AllowExpired: input.Status == SessionStatusCancelled, + }) + if err != nil { return nil, err } - if locked.Status == "finished" || locked.Status == "cancelled" || locked.Status == "failed" { + if isSessionTerminalStatus(session.Status) { + return buildSessionResult(session), nil + } + + tx, err := s.store.Begin(ctx) + if err != nil { + return nil, err + } + defer tx.Rollback(ctx) + + locked, err := s.store.GetSessionByPublicIDForUpdate(ctx, tx, input.SessionPublicID) + if err != nil { + return nil, err + } + if locked == nil { + return nil, apperr.New(http.StatusNotFound, "session_not_found", "session not found") + } + if err := s.verifySessionToken(locked, input.SessionToken, sessionTokenPolicy{ + AllowExpired: input.Status == SessionStatusCancelled || isSessionTerminalStatus(locked.Status), + }); err != nil { + return nil, err + } + + if isSessionTerminalStatus(locked.Status) { if err := tx.Commit(ctx); err != nil { return nil, err } @@ -208,7 +228,7 @@ func (s *SessionService) FinishSession(ctx context.Context, input FinishSessionI return buildSessionResult(updated), nil } -func (s *SessionService) validateSessionAction(ctx context.Context, sessionPublicID, sessionToken string) (*postgres.Session, error) { +func (s *SessionService) validateSessionAction(ctx context.Context, sessionPublicID, sessionToken string, policy sessionTokenPolicy) (*postgres.Session, error) { sessionPublicID = strings.TrimSpace(sessionPublicID) sessionToken = strings.TrimSpace(sessionToken) if sessionPublicID == "" || sessionToken == "" { @@ -222,19 +242,19 @@ func (s *SessionService) validateSessionAction(ctx context.Context, sessionPubli if session == nil { return nil, apperr.New(http.StatusNotFound, "session_not_found", "session not found") } - if err := s.verifySessionToken(session, sessionToken); err != nil { + if err := s.verifySessionToken(session, sessionToken, policy); err != nil { return nil, err } return session, nil } -func (s *SessionService) verifySessionToken(session *postgres.Session, sessionToken string) error { - if session.SessionTokenExpiresAt.Before(time.Now().UTC()) { - return apperr.New(http.StatusUnauthorized, "session_token_expired", "session token expired") - } +func (s *SessionService) verifySessionToken(session *postgres.Session, sessionToken string, policy sessionTokenPolicy) error { if session.SessionTokenHash != security.HashText(sessionToken) { return apperr.New(http.StatusUnauthorized, "invalid_session_token", "invalid session token") } + if !policy.AllowExpired && session.SessionTokenExpiresAt.Before(time.Now().UTC()) { + return apperr.New(http.StatusUnauthorized, "session_token_expired", "session token expired") + } return nil } @@ -265,14 +285,16 @@ func buildSessionResult(session *postgres.Session) *SessionResult { return result } -func normalizeFinishStatus(value string) string { +func normalizeFinishStatus(value string) (string, error) { switch strings.TrimSpace(value) { - case "failed": - return "failed" - case "cancelled": - return "cancelled" + case "", SessionStatusFinished: + return SessionStatusFinished, nil + case SessionStatusFailed: + return SessionStatusFailed, nil + case SessionStatusCancelled: + return SessionStatusCancelled, nil default: - return "finished" + return "", apperr.New(http.StatusBadRequest, "invalid_finish_status", "status must be finished, failed or cancelled") } } diff --git a/backend/internal/service/session_status.go b/backend/internal/service/session_status.go new file mode 100644 index 0000000..89268ec --- /dev/null +++ b/backend/internal/service/session_status.go @@ -0,0 +1,27 @@ +package service + +const ( + SessionStatusLaunched = "launched" + SessionStatusRunning = "running" + SessionStatusFinished = "finished" + SessionStatusFailed = "failed" + SessionStatusCancelled = "cancelled" +) + +func isSessionTerminalStatus(status string) bool { + switch status { + case SessionStatusFinished, SessionStatusFailed, SessionStatusCancelled: + return true + default: + return false + } +} + +func isSessionOngoingStatus(status string) bool { + switch status { + case SessionStatusLaunched, SessionStatusRunning: + return true + default: + return false + } +} diff --git a/backend/internal/store/postgres/admin_event_store.go b/backend/internal/store/postgres/admin_event_store.go new file mode 100644 index 0000000..6ed8af3 --- /dev/null +++ b/backend/internal/store/postgres/admin_event_store.go @@ -0,0 +1,248 @@ +package postgres + +import ( + "context" + "errors" + "fmt" + + "github.com/jackc/pgx/v5" +) + +type Tenant struct { + ID string + TenantCode string + Name string + Status string +} + +type AdminEventRecord struct { + ID string + PublicID string + TenantID *string + TenantCode *string + TenantName *string + Slug string + DisplayName string + Summary *string + Status string + CurrentReleaseID *string + CurrentReleasePubID *string + ConfigLabel *string + ManifestURL *string + ManifestChecksum *string + RouteCode *string +} + +type CreateAdminEventParams struct { + PublicID string + TenantID *string + Slug string + DisplayName string + Summary *string + Status string +} + +type UpdateAdminEventParams struct { + EventID string + TenantID *string + Slug string + DisplayName string + Summary *string + Status string + ClearTenant bool +} + +func (s *Store) GetTenantByCode(ctx context.Context, tenantCode string) (*Tenant, error) { + row := s.pool.QueryRow(ctx, ` + SELECT id, tenant_code, name, status + FROM tenants + WHERE tenant_code = $1 + LIMIT 1 + `, tenantCode) + var item Tenant + err := row.Scan(&item.ID, &item.TenantCode, &item.Name, &item.Status) + if errors.Is(err, pgx.ErrNoRows) { + return nil, nil + } + if err != nil { + return nil, fmt.Errorf("get tenant by code: %w", err) + } + return &item, nil +} + +func (s *Store) ListAdminEvents(ctx context.Context, limit int) ([]AdminEventRecord, error) { + if limit <= 0 || limit > 200 { + limit = 50 + } + rows, err := s.pool.Query(ctx, ` + SELECT + e.id, + e.event_public_id, + e.tenant_id, + t.tenant_code, + t.name, + e.slug, + e.display_name, + e.summary, + e.status, + e.current_release_id, + er.release_public_id, + er.config_label, + er.manifest_url, + er.manifest_checksum_sha256, + er.route_code + FROM events e + LEFT JOIN tenants t ON t.id = e.tenant_id + LEFT JOIN event_releases er ON er.id = e.current_release_id + ORDER BY e.created_at DESC + LIMIT $1 + `, limit) + if err != nil { + return nil, fmt.Errorf("list admin events: %w", err) + } + defer rows.Close() + + items := []AdminEventRecord{} + for rows.Next() { + item, err := scanAdminEventFromRows(rows) + if err != nil { + return nil, err + } + items = append(items, *item) + } + if err := rows.Err(); err != nil { + return nil, fmt.Errorf("iterate admin events: %w", err) + } + return items, nil +} + +func (s *Store) GetAdminEventByPublicID(ctx context.Context, eventPublicID string) (*AdminEventRecord, error) { + row := s.pool.QueryRow(ctx, ` + SELECT + e.id, + e.event_public_id, + e.tenant_id, + t.tenant_code, + t.name, + e.slug, + e.display_name, + e.summary, + e.status, + e.current_release_id, + er.release_public_id, + er.config_label, + er.manifest_url, + er.manifest_checksum_sha256, + er.route_code + FROM events e + LEFT JOIN tenants t ON t.id = e.tenant_id + LEFT JOIN event_releases er ON er.id = e.current_release_id + WHERE e.event_public_id = $1 + LIMIT 1 + `, eventPublicID) + return scanAdminEvent(row) +} + +func (s *Store) CreateAdminEvent(ctx context.Context, tx Tx, params CreateAdminEventParams) (*AdminEventRecord, error) { + row := tx.QueryRow(ctx, ` + INSERT INTO events (tenant_id, event_public_id, slug, display_name, summary, status) + VALUES ($1, $2, $3, $4, $5, $6) + RETURNING id, event_public_id, tenant_id, slug, display_name, summary, status, current_release_id + `, params.TenantID, params.PublicID, params.Slug, params.DisplayName, params.Summary, params.Status) + + var item AdminEventRecord + if err := row.Scan( + &item.ID, + &item.PublicID, + &item.TenantID, + &item.Slug, + &item.DisplayName, + &item.Summary, + &item.Status, + &item.CurrentReleaseID, + ); err != nil { + return nil, fmt.Errorf("create admin event: %w", err) + } + return &item, nil +} + +func (s *Store) UpdateAdminEvent(ctx context.Context, tx Tx, params UpdateAdminEventParams) (*AdminEventRecord, error) { + row := tx.QueryRow(ctx, ` + UPDATE events + SET tenant_id = CASE WHEN $7 THEN NULL ELSE $2 END, + slug = $3, + display_name = $4, + summary = $5, + status = $6 + WHERE id = $1 + RETURNING id, event_public_id, tenant_id, slug, display_name, summary, status, current_release_id + `, params.EventID, params.TenantID, params.Slug, params.DisplayName, params.Summary, params.Status, params.ClearTenant) + + var item AdminEventRecord + if err := row.Scan( + &item.ID, + &item.PublicID, + &item.TenantID, + &item.Slug, + &item.DisplayName, + &item.Summary, + &item.Status, + &item.CurrentReleaseID, + ); err != nil { + return nil, fmt.Errorf("update admin event: %w", err) + } + return &item, nil +} + +func scanAdminEvent(row pgx.Row) (*AdminEventRecord, error) { + var item AdminEventRecord + err := row.Scan( + &item.ID, + &item.PublicID, + &item.TenantID, + &item.TenantCode, + &item.TenantName, + &item.Slug, + &item.DisplayName, + &item.Summary, + &item.Status, + &item.CurrentReleaseID, + &item.CurrentReleasePubID, + &item.ConfigLabel, + &item.ManifestURL, + &item.ManifestChecksum, + &item.RouteCode, + ) + if errors.Is(err, pgx.ErrNoRows) { + return nil, nil + } + if err != nil { + return nil, fmt.Errorf("scan admin event: %w", err) + } + return &item, nil +} + +func scanAdminEventFromRows(rows pgx.Rows) (*AdminEventRecord, error) { + var item AdminEventRecord + err := rows.Scan( + &item.ID, + &item.PublicID, + &item.TenantID, + &item.TenantCode, + &item.TenantName, + &item.Slug, + &item.DisplayName, + &item.Summary, + &item.Status, + &item.CurrentReleaseID, + &item.CurrentReleasePubID, + &item.ConfigLabel, + &item.ManifestURL, + &item.ManifestChecksum, + &item.RouteCode, + ) + if err != nil { + return nil, fmt.Errorf("scan admin event row: %w", err) + } + return &item, nil +} diff --git a/backend/internal/store/postgres/config_store.go b/backend/internal/store/postgres/config_store.go index 5f03303..4d224fc 100644 --- a/backend/internal/store/postgres/config_store.go +++ b/backend/internal/store/postgres/config_store.go @@ -261,6 +261,36 @@ func (s *Store) GetEventConfigBuildByID(ctx context.Context, buildID string) (*E return scanEventConfigBuild(row) } +func (s *Store) ListEventConfigBuildsByEventID(ctx context.Context, eventID string, limit int) ([]EventConfigBuild, error) { + if limit <= 0 || limit > 100 { + limit = 20 + } + rows, err := s.pool.Query(ctx, ` + SELECT id, event_id, source_id, build_no, build_status, build_log, manifest_jsonb::text, asset_index_jsonb::text + FROM event_config_builds + WHERE event_id = $1 + ORDER BY build_no DESC + LIMIT $2 + `, eventID, limit) + if err != nil { + return nil, fmt.Errorf("list event config builds: %w", err) + } + defer rows.Close() + + items := []EventConfigBuild{} + for rows.Next() { + item, err := scanEventConfigBuildFromRows(rows) + if err != nil { + return nil, err + } + items = append(items, *item) + } + if err := rows.Err(); err != nil { + return nil, fmt.Errorf("iterate event config builds: %w", err) + } + return items, nil +} + func scanEventConfigSource(row pgx.Row) (*EventConfigSource, error) { var item EventConfigSource err := row.Scan( @@ -321,3 +351,21 @@ func scanEventConfigBuild(row pgx.Row) (*EventConfigBuild, error) { } return &item, nil } + +func scanEventConfigBuildFromRows(rows pgx.Rows) (*EventConfigBuild, error) { + var item EventConfigBuild + err := rows.Scan( + &item.ID, + &item.EventID, + &item.SourceID, + &item.BuildNo, + &item.BuildStatus, + &item.BuildLog, + &item.ManifestJSON, + &item.AssetIndexJSON, + ) + if err != nil { + return nil, fmt.Errorf("scan event config build row: %w", err) + } + return &item, nil +} diff --git a/backend/internal/store/postgres/event_store.go b/backend/internal/store/postgres/event_store.go index f9c3daf..e1aac57 100644 --- a/backend/internal/store/postgres/event_store.go +++ b/backend/internal/store/postgres/event_store.go @@ -261,3 +261,85 @@ func (s *Store) CreateGameSession(ctx context.Context, tx Tx, params CreateGameS } return &session, nil } + +func (s *Store) ListEventReleasesByEventID(ctx context.Context, eventID string, limit int) ([]EventRelease, error) { + if limit <= 0 || limit > 100 { + limit = 20 + } + rows, err := s.pool.Query(ctx, ` + SELECT id, release_public_id, event_id, release_no, config_label, manifest_url, manifest_checksum_sha256, route_code, build_id, status, published_at + FROM event_releases + WHERE event_id = $1 + ORDER BY release_no DESC + LIMIT $2 + `, eventID, limit) + if err != nil { + return nil, fmt.Errorf("list event releases by event id: %w", err) + } + defer rows.Close() + + items := []EventRelease{} + for rows.Next() { + item, err := scanEventReleaseFromRows(rows) + if err != nil { + return nil, err + } + items = append(items, *item) + } + if err := rows.Err(); err != nil { + return nil, fmt.Errorf("iterate event releases by event id: %w", err) + } + return items, nil +} + +func (s *Store) GetEventReleaseByPublicID(ctx context.Context, releasePublicID string) (*EventRelease, error) { + row := s.pool.QueryRow(ctx, ` + SELECT id, release_public_id, event_id, release_no, config_label, manifest_url, manifest_checksum_sha256, route_code, build_id, status, published_at + FROM event_releases + WHERE release_public_id = $1 + LIMIT 1 + `, releasePublicID) + + var item EventRelease + err := row.Scan( + &item.ID, + &item.PublicID, + &item.EventID, + &item.ReleaseNo, + &item.ConfigLabel, + &item.ManifestURL, + &item.ManifestChecksum, + &item.RouteCode, + &item.BuildID, + &item.Status, + &item.PublishedAt, + ) + if errors.Is(err, pgx.ErrNoRows) { + return nil, nil + } + if err != nil { + return nil, fmt.Errorf("get event release by public id: %w", err) + } + return &item, nil +} + +func scanEventReleaseFromRows(rows pgx.Rows) (*EventRelease, error) { + var item EventRelease + err := rows.Scan( + &item.ID, + &item.PublicID, + &item.EventID, + &item.ReleaseNo, + &item.ConfigLabel, + &item.ManifestURL, + &item.ManifestChecksum, + &item.RouteCode, + &item.BuildID, + &item.Status, + &item.PublishedAt, + ) + if err != nil { + return nil, fmt.Errorf("scan event release row: %w", err) + } + return &item, nil +} diff --git a/backend/internal/store/postgres/resource_store.go b/backend/internal/store/postgres/resource_store.go new file mode 100644 index 0000000..db4ba68 --- /dev/null +++ b/backend/internal/store/postgres/resource_store.go @@ -0,0 +1,660 @@ +package postgres + +import ( + "context" + "encoding/json" + "errors" + "fmt" + "time" + + "github.com/jackc/pgx/v5" +) + +type ResourceMap struct { + ID string + PublicID string + Code string + Name string + Status string + Description *string + CurrentVersionID *string + CreatedAt time.Time + UpdatedAt time.Time +} + +type ResourceMapVersion struct { + ID string + PublicID string + MapID string + VersionCode string + Status string + MapmetaURL string + TilesRootURL string + PublishedAssetRoot *string + BoundsJSON json.RawMessage + MetadataJSON json.RawMessage + CreatedAt time.Time + UpdatedAt time.Time +} + +type ResourcePlayfield struct { + ID string + PublicID string + Code string + Name string + Kind string + Status string + Description *string + CurrentVersionID *string + CreatedAt time.Time + UpdatedAt time.Time +} + +type ResourcePlayfieldVersion struct { + ID string + PublicID string + PlayfieldID string + VersionCode string + Status string + SourceType string + SourceURL string + PublishedAssetRoot *string + ControlCount *int + BoundsJSON json.RawMessage + MetadataJSON json.RawMessage + CreatedAt time.Time + UpdatedAt time.Time +} + +type ResourcePack struct { + ID string + PublicID string + Code string + Name string + Status string + Description *string + CurrentVersionID *string + CreatedAt time.Time + UpdatedAt time.Time +} + +type ResourcePackVersion struct { + ID string + PublicID string + ResourcePackID string + VersionCode string + Status string + ContentEntryURL *string + AudioRootURL *string + ThemeProfileCode *string + PublishedAssetRoot *string + MetadataJSON json.RawMessage + CreatedAt time.Time + UpdatedAt time.Time +} + +type CreateResourceMapParams struct { + PublicID string + Code string + Name string + Status string + Description *string +} + +type CreateResourceMapVersionParams struct { + PublicID string + MapID string + VersionCode string + Status string + MapmetaURL string + TilesRootURL string + PublishedAssetRoot *string + BoundsJSON map[string]any + MetadataJSON map[string]any +} + +type CreateResourcePlayfieldParams struct { + PublicID string + Code string + Name string + Kind string + Status string + Description *string +} + +type CreateResourcePlayfieldVersionParams struct { + PublicID string + PlayfieldID string + VersionCode string + Status string + SourceType string + SourceURL string + PublishedAssetRoot *string + ControlCount *int + BoundsJSON map[string]any + MetadataJSON map[string]any +} + +type CreateResourcePackParams struct { + PublicID string + Code string + Name string + Status string + Description *string +} + +type CreateResourcePackVersionParams struct { + PublicID string + ResourcePackID string + VersionCode string + Status string + ContentEntryURL *string + AudioRootURL *string + ThemeProfileCode *string + PublishedAssetRoot *string + MetadataJSON map[string]any +} + +func (s *Store) ListResourceMaps(ctx context.Context, limit int) ([]ResourceMap, error) { + if limit <= 0 || limit > 200 { + limit = 50 + } + rows, err := s.pool.Query(ctx, ` + SELECT id, map_public_id, code, name, status, description, current_version_id, created_at, updated_at + FROM maps + ORDER BY created_at DESC + LIMIT $1 + `, limit) + if err != nil { + return nil, fmt.Errorf("list resource maps: %w", err) + } + defer rows.Close() + + items := []ResourceMap{} + for rows.Next() { + item, err := scanResourceMapFromRows(rows) + if err != nil { + return nil, err + } + items = append(items, *item) + } + if err := rows.Err(); err != nil { + return nil, fmt.Errorf("iterate resource maps: %w", err) + } + return items, nil +} + +func (s *Store) GetResourceMapByPublicID(ctx context.Context, publicID string) (*ResourceMap, error) { + row := s.pool.QueryRow(ctx, ` + SELECT id, map_public_id, code, name, status, description, current_version_id, created_at, updated_at + FROM maps + WHERE map_public_id = $1 + LIMIT 1 + `, publicID) + return scanResourceMap(row) +} + +func (s *Store) CreateResourceMap(ctx context.Context, tx Tx, params CreateResourceMapParams) (*ResourceMap, error) { + row := tx.QueryRow(ctx, ` + INSERT INTO maps (map_public_id, code, name, status, description) + VALUES ($1, $2, $3, $4, $5) + RETURNING id, map_public_id, code, name, status, description, current_version_id, created_at, updated_at + `, params.PublicID, params.Code, params.Name, params.Status, params.Description) + return scanResourceMap(row) +} + +func (s *Store) ListResourceMapVersions(ctx context.Context, mapID string) ([]ResourceMapVersion, error) { + rows, err := s.pool.Query(ctx, ` + SELECT id, version_public_id, map_id, version_code, status, mapmeta_url, tiles_root_url, published_asset_root, + bounds_jsonb::text, metadata_jsonb::text, created_at, updated_at + FROM map_versions + WHERE map_id = $1 + ORDER BY created_at DESC + `, mapID) + if err != nil { + return nil, fmt.Errorf("list resource map versions: %w", err) + } + defer rows.Close() + + items := []ResourceMapVersion{} + for rows.Next() { + item, err := scanResourceMapVersionFromRows(rows) + if err != nil { + return nil, err + } + items = append(items, *item) + } + if err := rows.Err(); err != nil { + return nil, fmt.Errorf("iterate resource map versions: %w", err) + } + return items, nil +} + +func (s *Store) GetResourceMapVersionByPublicID(ctx context.Context, mapPublicID, versionPublicID string) (*ResourceMapVersion, error) { + row := s.pool.QueryRow(ctx, ` + SELECT mv.id, mv.version_public_id, mv.map_id, mv.version_code, mv.status, mv.mapmeta_url, mv.tiles_root_url, mv.published_asset_root, + mv.bounds_jsonb::text, mv.metadata_jsonb::text, mv.created_at, mv.updated_at + FROM map_versions mv + JOIN maps m ON m.id = mv.map_id + WHERE m.map_public_id = $1 + AND mv.version_public_id = $2 + LIMIT 1 + `, mapPublicID, versionPublicID) + return scanResourceMapVersion(row) +} + +func (s *Store) CreateResourceMapVersion(ctx context.Context, tx Tx, params CreateResourceMapVersionParams) (*ResourceMapVersion, error) { + boundsJSON, err := marshalJSONMap(params.BoundsJSON) + if err != nil { + return nil, fmt.Errorf("marshal map bounds: %w", err) + } + metadataJSON, err := marshalJSONMap(params.MetadataJSON) + if err != nil { + return nil, fmt.Errorf("marshal map metadata: %w", err) + } + row := tx.QueryRow(ctx, ` + INSERT INTO map_versions ( + version_public_id, map_id, version_code, status, mapmeta_url, tiles_root_url, + published_asset_root, bounds_jsonb, metadata_jsonb + ) + VALUES ($1, $2, $3, $4, $5, $6, $7, $8::jsonb, $9::jsonb) + RETURNING id, version_public_id, map_id, version_code, status, mapmeta_url, tiles_root_url, published_asset_root, + bounds_jsonb::text, metadata_jsonb::text, created_at, updated_at + `, params.PublicID, params.MapID, params.VersionCode, params.Status, params.MapmetaURL, params.TilesRootURL, params.PublishedAssetRoot, boundsJSON, metadataJSON) + return scanResourceMapVersion(row) +} + +func (s *Store) SetResourceMapCurrentVersion(ctx context.Context, tx Tx, mapID, versionID string) error { + _, err := tx.Exec(ctx, `UPDATE maps SET current_version_id = $2 WHERE id = $1`, mapID, versionID) + if err != nil { + return fmt.Errorf("set resource map current version: %w", err) + } + return nil +} + +func (s *Store) ListResourcePlayfields(ctx context.Context, limit int) ([]ResourcePlayfield, error) { + if limit <= 0 || limit > 200 { + limit = 50 + } + rows, err := s.pool.Query(ctx, ` + SELECT id, playfield_public_id, code, name, kind, status, description, current_version_id, created_at, updated_at + FROM playfields + ORDER BY created_at DESC + LIMIT $1 + `, limit) + if err != nil { + return nil, fmt.Errorf("list resource playfields: %w", err) + } + defer rows.Close() + + items := []ResourcePlayfield{} + for rows.Next() { + item, err := scanResourcePlayfieldFromRows(rows) + if err != nil { + return nil, err + } + items = append(items, *item) + } + if err := rows.Err(); err != nil { + return nil, fmt.Errorf("iterate resource playfields: %w", err) + } + return items, nil +} + +func (s *Store) GetResourcePlayfieldByPublicID(ctx context.Context, publicID string) (*ResourcePlayfield, error) { + row := s.pool.QueryRow(ctx, ` + SELECT id, playfield_public_id, code, name, kind, status, description, current_version_id, created_at, updated_at + FROM playfields + WHERE playfield_public_id = $1 + LIMIT 1 + `, publicID) + return scanResourcePlayfield(row) +} + +func (s *Store) CreateResourcePlayfield(ctx context.Context, tx Tx, params CreateResourcePlayfieldParams) (*ResourcePlayfield, error) { + row := tx.QueryRow(ctx, ` + INSERT INTO playfields (playfield_public_id, code, name, kind, status, description) + VALUES ($1, $2, $3, $4, $5, $6) + RETURNING id, playfield_public_id, code, name, kind, status, description, current_version_id, created_at, updated_at + `, params.PublicID, params.Code, params.Name, params.Kind, params.Status, params.Description) + return scanResourcePlayfield(row) +} + +func (s *Store) ListResourcePlayfieldVersions(ctx context.Context, playfieldID string) ([]ResourcePlayfieldVersion, error) { + rows, err := s.pool.Query(ctx, ` + SELECT id, version_public_id, playfield_id, version_code, status, source_type, source_url, published_asset_root, + control_count, bounds_jsonb::text, metadata_jsonb::text, created_at, updated_at + FROM playfield_versions + WHERE playfield_id = $1 + ORDER BY created_at DESC + `, playfieldID) + if err != nil { + return nil, fmt.Errorf("list resource playfield versions: %w", err) + } + defer rows.Close() + + items := []ResourcePlayfieldVersion{} + for rows.Next() { + item, err := scanResourcePlayfieldVersionFromRows(rows) + if err != nil { + return nil, err + } + items = append(items, *item) + } + if err := rows.Err(); err != nil { + return nil, fmt.Errorf("iterate resource playfield versions: %w", err) + } + return items, nil +} + +func (s *Store) GetResourcePlayfieldVersionByPublicID(ctx context.Context, playfieldPublicID, versionPublicID string) (*ResourcePlayfieldVersion, error) { + row := s.pool.QueryRow(ctx, ` + SELECT pv.id, pv.version_public_id, pv.playfield_id, pv.version_code, pv.status, pv.source_type, pv.source_url, pv.published_asset_root, + pv.control_count, pv.bounds_jsonb::text, pv.metadata_jsonb::text, pv.created_at, pv.updated_at + FROM playfield_versions pv + JOIN playfields p ON p.id = pv.playfield_id + WHERE p.playfield_public_id = $1 + AND pv.version_public_id = $2 + LIMIT 1 + `, playfieldPublicID, versionPublicID) + return scanResourcePlayfieldVersion(row) +} + +func (s *Store) CreateResourcePlayfieldVersion(ctx context.Context, tx Tx, params CreateResourcePlayfieldVersionParams) (*ResourcePlayfieldVersion, error) { + boundsJSON, err := marshalJSONMap(params.BoundsJSON) + if err != nil { + return nil, fmt.Errorf("marshal playfield bounds: %w", err) + } + metadataJSON, err := marshalJSONMap(params.MetadataJSON) + if err != nil { + return nil, fmt.Errorf("marshal playfield metadata: %w", err) + } + row := tx.QueryRow(ctx, ` + INSERT INTO playfield_versions ( + version_public_id, playfield_id, version_code, status, source_type, source_url, + published_asset_root, control_count, bounds_jsonb, metadata_jsonb + ) + VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9::jsonb, $10::jsonb) + RETURNING id, version_public_id, playfield_id, version_code, status, source_type, source_url, published_asset_root, + control_count, bounds_jsonb::text, metadata_jsonb::text, created_at, updated_at + `, params.PublicID, params.PlayfieldID, params.VersionCode, params.Status, params.SourceType, params.SourceURL, params.PublishedAssetRoot, params.ControlCount, boundsJSON, metadataJSON) + return scanResourcePlayfieldVersion(row) +} + +func (s *Store) SetResourcePlayfieldCurrentVersion(ctx context.Context, tx Tx, playfieldID, versionID string) error { + _, err := tx.Exec(ctx, `UPDATE playfields SET current_version_id = $2 WHERE id = $1`, playfieldID, versionID) + if err != nil { + return fmt.Errorf("set resource playfield current version: %w", err) + } + return nil +} + +func (s *Store) ListResourcePacks(ctx context.Context, limit int) ([]ResourcePack, error) { + if limit <= 0 || limit > 200 { + limit = 50 + } + rows, err := s.pool.Query(ctx, ` + SELECT id, resource_pack_public_id, code, name, status, description, current_version_id, created_at, updated_at + FROM resource_packs + ORDER BY created_at DESC + LIMIT $1 + `, limit) + if err != nil { + return nil, fmt.Errorf("list resource packs: %w", err) + } + defer rows.Close() + + items := []ResourcePack{} + for rows.Next() { + item, err := scanResourcePackFromRows(rows) + if err != nil { + return nil, err + } + items = append(items, *item) + } + if err := rows.Err(); err != nil { + return nil, fmt.Errorf("iterate resource packs: %w", err) + } + return items, nil +} + +func (s *Store) GetResourcePackByPublicID(ctx context.Context, publicID string) (*ResourcePack, error) { + row := s.pool.QueryRow(ctx, ` + SELECT id, resource_pack_public_id, code, name, status, description, current_version_id, created_at, updated_at + FROM resource_packs + WHERE resource_pack_public_id = $1 + LIMIT 1 + `, publicID) + return scanResourcePack(row) +} + +func (s *Store) CreateResourcePack(ctx context.Context, tx Tx, params CreateResourcePackParams) (*ResourcePack, error) { + row := tx.QueryRow(ctx, ` + INSERT INTO resource_packs (resource_pack_public_id, code, name, status, description) + VALUES ($1, $2, $3, $4, $5) + RETURNING id, resource_pack_public_id, code, name, status, description, current_version_id, created_at, updated_at + `, params.PublicID, params.Code, params.Name, params.Status, params.Description) + return scanResourcePack(row) +} + +func (s *Store) ListResourcePackVersions(ctx context.Context, resourcePackID string) ([]ResourcePackVersion, error) { + rows, err := s.pool.Query(ctx, ` + SELECT id, version_public_id, resource_pack_id, version_code, status, content_entry_url, audio_root_url, + theme_profile_code, published_asset_root, metadata_jsonb::text, created_at, updated_at + FROM resource_pack_versions + WHERE resource_pack_id = $1 + ORDER BY created_at DESC + `, resourcePackID) + if err != nil { + return nil, fmt.Errorf("list resource pack versions: %w", err) + } + defer rows.Close() + + items := []ResourcePackVersion{} + for rows.Next() { + item, err := scanResourcePackVersionFromRows(rows) + if err != nil { + return nil, err + } + items = append(items, *item) + } + if err := rows.Err(); err != nil { + return nil, fmt.Errorf("iterate resource pack versions: %w", err) + } + return items, nil +} + +func (s *Store) GetResourcePackVersionByPublicID(ctx context.Context, resourcePackPublicID, versionPublicID string) (*ResourcePackVersion, error) { + row := s.pool.QueryRow(ctx, ` + SELECT pv.id, pv.version_public_id, pv.resource_pack_id, pv.version_code, pv.status, pv.content_entry_url, pv.audio_root_url, + pv.theme_profile_code, pv.published_asset_root, pv.metadata_jsonb::text, pv.created_at, pv.updated_at + FROM resource_pack_versions pv + JOIN resource_packs rp ON rp.id = pv.resource_pack_id + WHERE rp.resource_pack_public_id = $1 + AND pv.version_public_id = $2 + LIMIT 1 + `, resourcePackPublicID, versionPublicID) + return scanResourcePackVersion(row) +} + +func (s *Store) CreateResourcePackVersion(ctx context.Context, tx Tx, params CreateResourcePackVersionParams) (*ResourcePackVersion, error) { + metadataJSON, err := marshalJSONMap(params.MetadataJSON) + if err != nil { + return nil, fmt.Errorf("marshal resource pack metadata: %w", err) + } + row := tx.QueryRow(ctx, ` + INSERT INTO resource_pack_versions ( + version_public_id, resource_pack_id, version_code, status, content_entry_url, + audio_root_url, theme_profile_code, published_asset_root, metadata_jsonb + ) + VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9::jsonb) + RETURNING id, version_public_id, resource_pack_id, version_code, status, content_entry_url, audio_root_url, + theme_profile_code, published_asset_root, metadata_jsonb::text, created_at, updated_at + `, params.PublicID, params.ResourcePackID, params.VersionCode, params.Status, params.ContentEntryURL, params.AudioRootURL, params.ThemeProfileCode, params.PublishedAssetRoot, metadataJSON) + return scanResourcePackVersion(row) +} + +func (s *Store) SetResourcePackCurrentVersion(ctx context.Context, tx Tx, resourcePackID, versionID string) error { + _, err := tx.Exec(ctx, `UPDATE resource_packs SET current_version_id = $2 WHERE id = $1`, resourcePackID, versionID) + if err != nil { + return fmt.Errorf("set resource pack current version: %w", err) + } + return nil +} + +func scanResourceMap(row pgx.Row) (*ResourceMap, error) { + var item ResourceMap + err := row.Scan(&item.ID, &item.PublicID, &item.Code, &item.Name, &item.Status, &item.Description, &item.CurrentVersionID, &item.CreatedAt, &item.UpdatedAt) + if errors.Is(err, pgx.ErrNoRows) { + return nil, nil + } + if err != nil { + return nil, fmt.Errorf("scan resource map: %w", err) + } + return &item, nil +} + +func scanResourceMapFromRows(rows pgx.Rows) (*ResourceMap, error) { + var item ResourceMap + err := rows.Scan(&item.ID, &item.PublicID, &item.Code, &item.Name, &item.Status, &item.Description, &item.CurrentVersionID, &item.CreatedAt, &item.UpdatedAt) + if err != nil { + return nil, fmt.Errorf("scan resource map row: %w", err) + } + return &item, nil +} + +func scanResourceMapVersion(row pgx.Row) (*ResourceMapVersion, error) { + var item ResourceMapVersion + var boundsJSON string + var metadataJSON string + err := row.Scan(&item.ID, &item.PublicID, &item.MapID, &item.VersionCode, &item.Status, &item.MapmetaURL, &item.TilesRootURL, &item.PublishedAssetRoot, &boundsJSON, &metadataJSON, &item.CreatedAt, &item.UpdatedAt) + if errors.Is(err, pgx.ErrNoRows) { + return nil, nil + } + if err != nil { + return nil, fmt.Errorf("scan resource map version: %w", err) + } + item.BoundsJSON = json.RawMessage(boundsJSON) + item.MetadataJSON = json.RawMessage(metadataJSON) + return &item, nil +} + +func scanResourceMapVersionFromRows(rows pgx.Rows) (*ResourceMapVersion, error) { + var item ResourceMapVersion + var boundsJSON string + var metadataJSON string + err := rows.Scan(&item.ID, &item.PublicID, &item.MapID, &item.VersionCode, &item.Status, &item.MapmetaURL, &item.TilesRootURL, &item.PublishedAssetRoot, &boundsJSON, &metadataJSON, &item.CreatedAt, &item.UpdatedAt) + if err != nil { + return nil, fmt.Errorf("scan resource map version row: %w", err) + } + item.BoundsJSON = json.RawMessage(boundsJSON) + item.MetadataJSON = json.RawMessage(metadataJSON) + return &item, nil +} + +func scanResourcePlayfield(row pgx.Row) (*ResourcePlayfield, error) { + var item ResourcePlayfield + err := row.Scan(&item.ID, &item.PublicID, &item.Code, &item.Name, &item.Kind, &item.Status, &item.Description, &item.CurrentVersionID, &item.CreatedAt, &item.UpdatedAt) + if errors.Is(err, pgx.ErrNoRows) { + return nil, nil + } + if err != nil { + return nil, fmt.Errorf("scan resource playfield: %w", err) + } + return &item, nil +} + +func scanResourcePlayfieldFromRows(rows pgx.Rows) (*ResourcePlayfield, error) { + var item ResourcePlayfield + err := rows.Scan(&item.ID, &item.PublicID, &item.Code, &item.Name, &item.Kind, &item.Status, &item.Description, &item.CurrentVersionID, &item.CreatedAt, &item.UpdatedAt) + if err != nil { + return nil, fmt.Errorf("scan resource playfield row: %w", err) + } + return &item, nil +} + +func scanResourcePlayfieldVersion(row pgx.Row) (*ResourcePlayfieldVersion, error) { + var item ResourcePlayfieldVersion + var boundsJSON string + var metadataJSON string + err := row.Scan(&item.ID, &item.PublicID, &item.PlayfieldID, &item.VersionCode, &item.Status, &item.SourceType, &item.SourceURL, &item.PublishedAssetRoot, &item.ControlCount, &boundsJSON, &metadataJSON, &item.CreatedAt, &item.UpdatedAt) + if errors.Is(err, pgx.ErrNoRows) { + return nil, nil + } + if err != nil { + return nil, fmt.Errorf("scan resource playfield version: %w", err) + } + item.BoundsJSON = json.RawMessage(boundsJSON) + item.MetadataJSON = json.RawMessage(metadataJSON) + return &item, nil +} + +func scanResourcePlayfieldVersionFromRows(rows pgx.Rows) (*ResourcePlayfieldVersion, error) { + var item ResourcePlayfieldVersion + var boundsJSON string + var metadataJSON string + err := rows.Scan(&item.ID, &item.PublicID, &item.PlayfieldID, &item.VersionCode, &item.Status, &item.SourceType, &item.SourceURL, &item.PublishedAssetRoot, &item.ControlCount, &boundsJSON, &metadataJSON, &item.CreatedAt, &item.UpdatedAt) + if err != nil { + return nil, fmt.Errorf("scan resource playfield version row: %w", err) + } + item.BoundsJSON = json.RawMessage(boundsJSON) + item.MetadataJSON = json.RawMessage(metadataJSON) + return &item, nil +} + +func scanResourcePack(row pgx.Row) (*ResourcePack, error) { + var item ResourcePack + err := row.Scan(&item.ID, &item.PublicID, &item.Code, &item.Name, &item.Status, &item.Description, &item.CurrentVersionID, &item.CreatedAt, &item.UpdatedAt) + if errors.Is(err, pgx.ErrNoRows) { + return nil, nil + } + if err != nil { + return nil, fmt.Errorf("scan resource pack: %w", err) + } + return &item, nil +} + +func scanResourcePackFromRows(rows pgx.Rows) (*ResourcePack, error) { + var item ResourcePack + err := rows.Scan(&item.ID, &item.PublicID, &item.Code, &item.Name, &item.Status, &item.Description, &item.CurrentVersionID, &item.CreatedAt, &item.UpdatedAt) + if err != nil { + return nil, fmt.Errorf("scan resource pack row: %w", err) + } + return &item, nil +} + +func scanResourcePackVersion(row pgx.Row) (*ResourcePackVersion, error) { + var item ResourcePackVersion + var metadataJSON string + err := row.Scan(&item.ID, &item.PublicID, &item.ResourcePackID, &item.VersionCode, &item.Status, &item.ContentEntryURL, &item.AudioRootURL, &item.ThemeProfileCode, &item.PublishedAssetRoot, &metadataJSON, &item.CreatedAt, &item.UpdatedAt) + if errors.Is(err, pgx.ErrNoRows) { + return nil, nil + } + if err != nil { + return nil, fmt.Errorf("scan resource pack version: %w", err) + } + item.MetadataJSON = json.RawMessage(metadataJSON) + return &item, nil +} + +func scanResourcePackVersionFromRows(rows pgx.Rows) (*ResourcePackVersion, error) { + var item ResourcePackVersion + var metadataJSON string + err := rows.Scan(&item.ID, &item.PublicID, &item.ResourcePackID, &item.VersionCode, &item.Status, &item.ContentEntryURL, &item.AudioRootURL, &item.ThemeProfileCode, &item.PublishedAssetRoot, &metadataJSON, &item.CreatedAt, &item.UpdatedAt) + if err != nil { + return nil, fmt.Errorf("scan resource pack version row: %w", err) + } + item.MetadataJSON = json.RawMessage(metadataJSON) + return &item, nil +} + +func marshalJSONMap(value map[string]any) (string, error) { + if value == nil { + value = map[string]any{} + } + raw, err := json.Marshal(value) + if err != nil { + return "", err + } + return string(raw), nil +} diff --git a/backend/migrations/0006_resource_objects.sql b/backend/migrations/0006_resource_objects.sql new file mode 100644 index 0000000..2d65a43 --- /dev/null +++ b/backend/migrations/0006_resource_objects.sql @@ -0,0 +1,140 @@ +BEGIN; + +CREATE TABLE maps ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + map_public_id TEXT NOT NULL UNIQUE, + code TEXT NOT NULL UNIQUE, + name TEXT NOT NULL, + status TEXT NOT NULL DEFAULT 'draft' CHECK (status IN ('draft', 'active', 'disabled', 'archived')), + description TEXT, + current_version_id UUID, + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW() +); + +CREATE INDEX maps_status_idx ON maps(status); + +CREATE TRIGGER maps_set_updated_at +BEFORE UPDATE ON maps +FOR EACH ROW EXECUTE FUNCTION set_updated_at(); + +CREATE TABLE map_versions ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + version_public_id TEXT NOT NULL UNIQUE, + map_id UUID NOT NULL REFERENCES maps(id) ON DELETE CASCADE, + version_code TEXT NOT NULL, + status TEXT NOT NULL DEFAULT 'draft' CHECK (status IN ('draft', 'active', 'archived')), + mapmeta_url TEXT NOT NULL, + tiles_root_url TEXT NOT NULL, + published_asset_root TEXT, + bounds_jsonb JSONB NOT NULL DEFAULT '{}'::jsonb, + metadata_jsonb JSONB NOT NULL DEFAULT '{}'::jsonb, + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + UNIQUE (map_id, version_code) +); + +CREATE INDEX map_versions_map_id_idx ON map_versions(map_id); +CREATE INDEX map_versions_status_idx ON map_versions(status); + +CREATE TRIGGER map_versions_set_updated_at +BEFORE UPDATE ON map_versions +FOR EACH ROW EXECUTE FUNCTION set_updated_at(); + +ALTER TABLE maps +ADD CONSTRAINT maps_current_version_fk +FOREIGN KEY (current_version_id) REFERENCES map_versions(id) ON DELETE SET NULL; + +CREATE TABLE playfields ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + playfield_public_id TEXT NOT NULL UNIQUE, + code TEXT NOT NULL UNIQUE, + name TEXT NOT NULL, + kind TEXT NOT NULL DEFAULT 'course', + status TEXT NOT NULL DEFAULT 'draft' CHECK (status IN ('draft', 'active', 'disabled', 'archived')), + description TEXT, + current_version_id UUID, + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW() +); + +CREATE INDEX playfields_status_idx ON playfields(status); + +CREATE TRIGGER playfields_set_updated_at +BEFORE UPDATE ON playfields +FOR EACH ROW EXECUTE FUNCTION set_updated_at(); + +CREATE TABLE playfield_versions ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + version_public_id TEXT NOT NULL UNIQUE, + playfield_id UUID NOT NULL REFERENCES playfields(id) ON DELETE CASCADE, + version_code TEXT NOT NULL, + status TEXT NOT NULL DEFAULT 'draft' CHECK (status IN ('draft', 'active', 'archived')), + source_type TEXT NOT NULL CHECK (source_type IN ('kml', 'geojson', 'control_set', 'json')), + source_url TEXT NOT NULL, + published_asset_root TEXT, + control_count INTEGER, + bounds_jsonb JSONB NOT NULL DEFAULT '{}'::jsonb, + metadata_jsonb JSONB NOT NULL DEFAULT '{}'::jsonb, + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + UNIQUE (playfield_id, version_code) +); + +CREATE INDEX playfield_versions_playfield_id_idx ON playfield_versions(playfield_id); +CREATE INDEX playfield_versions_status_idx ON playfield_versions(status); + +CREATE TRIGGER playfield_versions_set_updated_at +BEFORE UPDATE ON playfield_versions +FOR EACH ROW EXECUTE FUNCTION set_updated_at(); + +ALTER TABLE playfields +ADD CONSTRAINT playfields_current_version_fk +FOREIGN KEY (current_version_id) REFERENCES playfield_versions(id) ON DELETE SET NULL; + +CREATE TABLE resource_packs ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + resource_pack_public_id TEXT NOT NULL UNIQUE, + code TEXT NOT NULL UNIQUE, + name TEXT NOT NULL, + status TEXT NOT NULL DEFAULT 'draft' CHECK (status IN ('draft', 'active', 'disabled', 'archived')), + description TEXT, + current_version_id UUID, + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW() +); + +CREATE INDEX resource_packs_status_idx ON resource_packs(status); + +CREATE TRIGGER resource_packs_set_updated_at +BEFORE UPDATE ON resource_packs +FOR EACH ROW EXECUTE FUNCTION set_updated_at(); + +CREATE TABLE resource_pack_versions ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + version_public_id TEXT NOT NULL UNIQUE, + resource_pack_id UUID NOT NULL REFERENCES resource_packs(id) ON DELETE CASCADE, + version_code TEXT NOT NULL, + status TEXT NOT NULL DEFAULT 'draft' CHECK (status IN ('draft', 'active', 'archived')), + content_entry_url TEXT, + audio_root_url TEXT, + theme_profile_code TEXT, + published_asset_root TEXT, + metadata_jsonb JSONB NOT NULL DEFAULT '{}'::jsonb, + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + UNIQUE (resource_pack_id, version_code) +); + +CREATE INDEX resource_pack_versions_pack_id_idx ON resource_pack_versions(resource_pack_id); +CREATE INDEX resource_pack_versions_status_idx ON resource_pack_versions(status); + +CREATE TRIGGER resource_pack_versions_set_updated_at +BEFORE UPDATE ON resource_pack_versions +FOR EACH ROW EXECUTE FUNCTION set_updated_at(); + +ALTER TABLE resource_packs +ADD CONSTRAINT resource_packs_current_version_fk +FOREIGN KEY (current_version_id) REFERENCES resource_pack_versions(id) ON DELETE SET NULL; + +COMMIT; diff --git a/doc/MyToDo.md b/doc/MyToDo.md index c2f2802..66f5730 100644 --- a/doc/MyToDo.md +++ b/doc/MyToDo.md @@ -1,5 +1,5 @@ > 文档版本:v1.0 -> 最后更新:2026-04-02 +> 最后更新:2026-04-02 08:28:05 结果页会根据客户的要求不停的变换,用什么方案能实现这个需求,其实其他的弹出内容也都存在这个问题,样式,内容都时根据客户需求变化的,怎样一种方案设计比较好呢? @@ -334,3 +334,4 @@ CTA 就是卡片上引导用户下一步操作的按钮。 再深一点,自定GPS点能不能做成动画的,停止一个动画,跑起来又是一个动画,甚至可以做些额外的动作。 开个小差,我想临时加个功能,在咱的GPS模拟器加个日志输出功能,把调试期间不方便打在调试面板里的信息输出到模拟器上,你觉得如何?这样更方便后期调试?如果可以先给个方案 + diff --git a/doc/animation/动画字典.md b/doc/animation/动画字典.md index ca5def1..2864086 100644 --- a/doc/animation/动画字典.md +++ b/doc/animation/动画字典.md @@ -1,6 +1,6 @@ # 动画字典 v1 > 文档版本:v1.0 -> 最后更新:2026-04-02 +> 最后更新:2026-04-02 08:28:05 ## 1. 目的 @@ -360,3 +360,4 @@ + diff --git a/doc/animation/动画接入工作流.md b/doc/animation/动画接入工作流.md index aae674e..5a777fe 100644 --- a/doc/animation/动画接入工作流.md +++ b/doc/animation/动画接入工作流.md @@ -1,6 +1,6 @@ # 动画接入工作流 > 文档版本:v1.0 -> 最后更新:2026-04-02 +> 最后更新:2026-04-02 08:28:05 ## 1. 目的 @@ -366,3 +366,4 @@ lite 表现: 已经归档到 [archive/animation](/D:/dev/cmr-mini/doc/archive/animation),当前以本文件为统一入口。 + diff --git a/doc/animation/动画管线总结.md b/doc/animation/动画管线总结.md index 0191d46..1258759 100644 --- a/doc/animation/动画管线总结.md +++ b/doc/animation/动画管线总结.md @@ -1,6 +1,6 @@ # 动画体系阶段性小结 > 文档版本:v1.0 -> 最后更新:2026-04-02 +> 最后更新:2026-04-02 08:28:05 ## 1. 当前定位 @@ -195,3 +195,4 @@ + diff --git a/doc/archive/animation/动画接入规格模板.md b/doc/archive/animation/动画接入规格模板.md index b31c8d5..0632eb6 100644 --- a/doc/archive/animation/动画接入规格模板.md +++ b/doc/archive/animation/动画接入规格模板.md @@ -1,6 +1,6 @@ # 动画接入规格模板 > 文档版本:v1.0 -> 最后更新:2026-04-02 +> 最后更新:2026-04-02 08:28:05 ## 1. 用途 @@ -166,3 +166,4 @@ lite 表现:透明度降低 50%,时长缩短到 220ms + diff --git a/doc/archive/animation/动画接入评审清单.md b/doc/archive/animation/动画接入评审清单.md index 28e1b90..439f5b1 100644 --- a/doc/archive/animation/动画接入评审清单.md +++ b/doc/archive/animation/动画接入评审清单.md @@ -1,6 +1,6 @@ # 动画接入评审清单 > 文档版本:v1.0 -> 最后更新:2026-04-02 +> 最后更新:2026-04-02 08:28:05 ## 1. 用途 @@ -165,3 +165,4 @@ + diff --git a/doc/archive/animation/动画设计方案.md b/doc/archive/animation/动画设计方案.md index 12c5074..7c9a66a 100644 --- a/doc/archive/animation/动画设计方案.md +++ b/doc/archive/animation/动画设计方案.md @@ -1,6 +1,6 @@ # 动效系统设计方案 > 文档版本:v1.0 -> 最后更新:2026-04-02 +> 最后更新:2026-04-02 08:28:05 本文档用于整理当前项目后续的动画 / 动效建设方案,目标不是单纯“让界面更花”,而是把动画正式纳入现有架构,成为: @@ -453,3 +453,4 @@ **后续动画建设应以“打点成功”和“目标状态”两条高频体验为起点,把动画正式纳入现有架构,而不是继续做零散样式补丁。** + diff --git a/doc/archive/config/后台配置管理方案.md b/doc/archive/config/后台配置管理方案.md index d1c568b..effed20 100644 --- a/doc/archive/config/后台配置管理方案.md +++ b/doc/archive/config/后台配置管理方案.md @@ -1,6 +1,6 @@ # 配置驱动应用的后台管理方案建议 > 文档版本:v1.0 -> 最后更新:2026-04-02 +> 最后更新:2026-04-02 08:28:05 本文用于整理当前这类“配置驱动型地图游戏应用”的后台管理建议,面向: @@ -419,3 +419,4 @@ Go 中间层实现“装配成最终 JSON”。 - 可稳定运行 + diff --git a/doc/archive/config/积分赛配置模板.md b/doc/archive/config/积分赛配置模板.md index 9965326..cd2c836 100644 --- a/doc/archive/config/积分赛配置模板.md +++ b/doc/archive/config/积分赛配置模板.md @@ -1,6 +1,6 @@ # 积分赛配置文档(基础版) > 文档版本:v1.0 -> 最后更新:2026-04-02 +> 最后更新:2026-04-02 08:28:05 本文档用于给服务端和后台配置设计提供一份可直接落地的积分赛基础模板。 @@ -358,3 +358,4 @@ - 先把静态积分赛入口结构定稳,后续再扩动态积分与更复杂玩法 + diff --git a/doc/archive/config/配置设计方案.md b/doc/archive/config/配置设计方案.md index 0610af1..009a171 100644 --- a/doc/archive/config/配置设计方案.md +++ b/doc/archive/config/配置设计方案.md @@ -1,6 +1,6 @@ # 游戏配置文件设计方案(阶段讨论稿) > 文档版本:v1.0 -> 最后更新:2026-04-02 +> 最后更新:2026-04-02 08:28:05 本文档用于整理当前阶段推荐的配置文件设计方案,供后端、客户端和后台管理设计参考。 @@ -590,3 +590,4 @@ KML 适合描述: **KML 描述空间事实,配置描述玩法解释;主配置按 `map / playfield / game / resources / debug` 分层,后续再升级成 manifest 组合。** + diff --git a/doc/archive/config/顺序赛配置模板.md b/doc/archive/config/顺序赛配置模板.md index c2fab11..8cf181b 100644 --- a/doc/archive/config/顺序赛配置模板.md +++ b/doc/archive/config/顺序赛配置模板.md @@ -1,6 +1,6 @@ # 顺序赛配置文档(基础版) > 文档版本:v1.0 -> 最后更新:2026-04-02 +> 最后更新:2026-04-02 08:28:05 本文档用于给服务端和后台配置设计提供一份可直接落地的顺序赛基础模板。 @@ -316,3 +316,4 @@ - 先把基础入口结构定稳,后续再细化跳点、惩罚、特殊引导等高级规则 + diff --git a/doc/archive/config/默认配置模板.md b/doc/archive/config/默认配置模板.md index 5acb8cc..55d7512 100644 --- a/doc/archive/config/默认配置模板.md +++ b/doc/archive/config/默认配置模板.md @@ -1,6 +1,6 @@ # 默认配置模板文档(当前实现版) > 文档版本:v1.0 -> 最后更新:2026-04-02 +> 最后更新:2026-04-02 08:28:05 本文档提供一份 **当前客户端可直接使用的默认配置模板**。 @@ -418,3 +418,4 @@ + diff --git a/doc/archive/experience/H5体验接入方案.md b/doc/archive/experience/H5体验接入方案.md index 6b5fb8e..783544e 100644 --- a/doc/archive/experience/H5体验接入方案.md +++ b/doc/archive/experience/H5体验接入方案.md @@ -1,6 +1,6 @@ # H5 体验接入方案 > 文档版本:v1.0 -> 最后更新:2026-04-02 +> 最后更新:2026-04-02 08:28:05 本文档用于定义当前项目中 **原生小程序 + H5 定制内容** 的混合接入方案。 @@ -416,3 +416,4 @@ H5 接入时必须注意: - [platform-capability-notes.md](D:/dev/cmr-mini/doc/debug/平台能力说明.md) + diff --git a/doc/archive/experience/体验壳子方案.md b/doc/archive/experience/体验壳子方案.md index 068fea5..4ed2983 100644 --- a/doc/archive/experience/体验壳子方案.md +++ b/doc/archive/experience/体验壳子方案.md @@ -1,6 +1,6 @@ # Experience Shell 方案 > 文档版本:v1.0 -> 最后更新:2026-04-02 +> 最后更新:2026-04-02 08:28:05 本文档用于定义小程序中 H5 定制内容的承载方式。目标不是把 H5 做成真正的同页弹窗,而是做成: @@ -235,3 +235,4 @@ H5 可以通过 bridge 发: **独立页面承载,但由原生壳子把它做成 `sheet / dialog / fullscreen` 三种体验形态。** + diff --git a/doc/archive/experience/内容体验层方案.md b/doc/archive/experience/内容体验层方案.md index 24e3ad5..986c1b5 100644 --- a/doc/archive/experience/内容体验层方案.md +++ b/doc/archive/experience/内容体验层方案.md @@ -1,6 +1,6 @@ # 游戏中文创体验层方案 > 文档版本:v1.0 -> 最后更新:2026-04-02 +> 最后更新:2026-04-02 08:28:05 ## 1. 目标 @@ -331,3 +331,4 @@ interface ExperienceRuntimeState { 第一阶段先用“控制点完成触发内容卡”跑通最小闭环,后面再逐步扩成完整体验系统。 + diff --git a/doc/archive/experience/结果页方案.md b/doc/archive/experience/结果页方案.md index 34ee67a..c47d8ec 100644 --- a/doc/archive/experience/结果页方案.md +++ b/doc/archive/experience/结果页方案.md @@ -1,6 +1,6 @@ # 游戏结算层方案 > 文档版本:v1.0 -> 最后更新:2026-04-02 +> 最后更新:2026-04-02 08:28:05 ## 1. 目标 @@ -296,3 +296,4 @@ interface ResultSceneState { 第一阶段先做基础 summary,后续再逐步接入文创奖励、奖章、排名和过场动画。 + diff --git a/doc/archive/notes/Gemini分析.md b/doc/archive/notes/Gemini分析.md index a7885b8..8560f7a 100644 --- a/doc/archive/notes/Gemini分析.md +++ b/doc/archive/notes/Gemini分析.md @@ -1,6 +1,6 @@ # CMR-Mini 项目深度分析报告 (GeminiAnalysis.md) > 文档版本:v1.0 -> 最后更新:2026-04-02 +> 最后更新:2026-04-02 08:28:05 ## 1. 项目定位与核心愿景 @@ -53,3 +53,4 @@ CMR-Mini 已经建立了一个非常坚实的专业定向越野引擎基础。 *Generated by Gemini CLI Analysis Tool* + diff --git a/doc/archive/notes/临时玩法讨论.md b/doc/archive/notes/临时玩法讨论.md index 81d019f..0a3c0e0 100644 --- a/doc/archive/notes/临时玩法讨论.md +++ b/doc/archive/notes/临时玩法讨论.md @@ -1,6 +1,6 @@ # 临时玩法讨论记录 > 文档版本:v1.0 -> 最后更新:2026-04-02 +> 最后更新:2026-04-02 08:28:05 本文档用于临时记录以下讨论内容: @@ -212,3 +212,4 @@ 像贪吃蛇式玩法和区域拾金币玩法,都更像是“新增玩法插件”,而不是“推翻现有底座”。 + diff --git a/doc/archive/notes/传感器接入待办.md b/doc/archive/notes/传感器接入待办.md index ef29475..4a0d897 100644 --- a/doc/archive/notes/传感器接入待办.md +++ b/doc/archive/notes/传感器接入待办.md @@ -1,6 +1,6 @@ # 传感器接入待开发方案 > 文档版本:v1.0 -> 最后更新:2026-04-02 +> 最后更新:2026-04-02 08:28:05 本文档用于整理当前项目后续可利用的传感器能力,分为: @@ -573,3 +573,4 @@ **原始传感器进 `engine/sensor`,高级状态进 `telemetry`,上层只消费统一状态。** + diff --git a/doc/archive/notes/多人模拟器待办.md b/doc/archive/notes/多人模拟器待办.md index 3f4c093..369a700 100644 --- a/doc/archive/notes/多人模拟器待办.md +++ b/doc/archive/notes/多人模拟器待办.md @@ -1,6 +1,6 @@ # 多人模拟器改造待开发文档 > 文档版本:v1.0 -> 最后更新:2026-04-02 +> 最后更新:2026-04-02 08:28:05 本文档用于记录“公网模拟器支持多人开发/多人联调”的待开发方案。 @@ -333,3 +333,4 @@ type ClientSession = { 当前阶段不急着实现,但应作为后续多人开发与多人玩法联调的重要底座能力。 + diff --git a/doc/archive/notes/我的待办.md b/doc/archive/notes/我的待办.md index 4161d31..81bfc47 100644 --- a/doc/archive/notes/我的待办.md +++ b/doc/archive/notes/我的待办.md @@ -1,5 +1,5 @@ > 文档版本:v1.0 -> 最后更新:2026-04-02 +> 最后更新:2026-04-02 08:28:05 结果页会根据客户的要求不停的变换,用什么方案能实现这个需求,其实其他的弹出内容也都存在这个问题,样式,内容都时根据客户需求变化的,怎样一种方案设计比较好呢? @@ -7,3 +7,4 @@ + diff --git a/doc/archive/归档索引.md b/doc/archive/归档索引.md index b546802..6868455 100644 --- a/doc/archive/归档索引.md +++ b/doc/archive/归档索引.md @@ -1,6 +1,6 @@ # 文档归档索引 > 文档版本:v1.0 -> 最后更新:2026-04-02 +> 最后更新:2026-04-02 08:28:05 这里存放的是已经完成历史使命的阶段性方案稿、重复模板和临时记录。 @@ -28,3 +28,4 @@ - 混合体验架构:[hybrid-experience-architecture.md](/D:/dev/cmr-mini/doc/experience/混合体验架构方案.md) + diff --git a/doc/backend/业务后端数据库初版方案.md b/doc/backend/业务后端数据库初版方案.md index d41f101..1464932 100644 --- a/doc/backend/业务后端数据库初版方案.md +++ b/doc/backend/业务后端数据库初版方案.md @@ -1,6 +1,6 @@ # 业务后端数据库初版方案 > 文档版本:v1.0 -> 最后更新:2026-04-02 +> 最后更新:2026-04-02 08:28:05 ## 1. 目标 @@ -698,3 +698,4 @@ H5 / 白标页面配置。 > PostgreSQL 存业务状态 + 版本化配置对象,Go API 负责查询与发布编排,客户端继续消费发布后的运行态配置。 + diff --git a/doc/config/全局规则与配置维度清单.md b/doc/config/全局规则与配置维度清单.md index 7093e6f..a44e2d6 100644 --- a/doc/config/全局规则与配置维度清单.md +++ b/doc/config/全局规则与配置维度清单.md @@ -1,6 +1,6 @@ # 全局规则与配置维度清单 > 文档版本:v1.0 -> 最后更新:2026-04-02 +> 最后更新:2026-04-02 08:28:05 本文档用于定义当前系统中**跨玩法共用**的全局规则块和配置维度,作为后续所有玩法设计文档、配置文件设计、后台录入和联调的统一骨架。 @@ -408,3 +408,4 @@ - 后续扩展不会只长代码、不长文档 + diff --git a/doc/config/后台配置管理方案V2.md b/doc/config/后台配置管理方案V2.md index 4c495b2..9662361 100644 --- a/doc/config/后台配置管理方案V2.md +++ b/doc/config/后台配置管理方案V2.md @@ -1,6 +1,6 @@ # 配置频繁变更场景下的后台管理方案 > 文档版本:v1.0 -> 最后更新:2026-04-02 +> 最后更新:2026-04-02 08:28:05 本文用于整理一套更适合“配置项变化很频繁”的后台方案。 @@ -409,3 +409,4 @@ Go 中间层先做最小装配功能。 **PostgreSQL 存“版本化对象 + jsonb 内容”,Go 中间层做“装配 + 校验 + 发布”,客户端只读静态发布结果。** + diff --git a/doc/config/当前最全配置模板.md b/doc/config/当前最全配置模板.md index 002a2b6..fa931ad 100644 --- a/doc/config/当前最全配置模板.md +++ b/doc/config/当前最全配置模板.md @@ -1,6 +1,6 @@ # 游戏配置全量模板(当前开发实现版) > 文档版本:v1.0 -> 最后更新:2026-04-02 +> 最后更新:2026-04-02 08:28:05 本文档提供一份 **截至当前开发状态,客户端已实现或已正式消费的较完整配置模板**。 @@ -763,3 +763,4 @@ - [D:\dev\cmr-mini\doc\config-option-dictionary.md](D:/dev/cmr-mini/doc/config/配置选项字典.md) - [D:\dev\cmr-mini\doc\config-docs-index.md](D:/dev/cmr-mini/doc/config/配置文档索引.md) + diff --git a/doc/config/最小游戏配置模板.md b/doc/config/最小游戏配置模板.md index 7066c40..153af2f 100644 --- a/doc/config/最小游戏配置模板.md +++ b/doc/config/最小游戏配置模板.md @@ -1,6 +1,6 @@ # 游戏最小可跑配置模板 > 文档版本:v1.0 -> 最后更新:2026-04-02 +> 最后更新:2026-04-02 08:28:05 本文档提供一份 **去掉大部分选配项之后,当前客户端可以直接跑起来的最小配置模板**。 @@ -204,3 +204,4 @@ - [D:\dev\cmr-mini\doc\config-option-dictionary.md](D:/dev/cmr-mini/doc/config/配置选项字典.md) + diff --git a/doc/config/线上业务接入边界方案.md b/doc/config/线上业务接入边界方案.md index 6a497f1..237513a 100644 --- a/doc/config/线上业务接入边界方案.md +++ b/doc/config/线上业务接入边界方案.md @@ -1,6 +1,6 @@ # 线上业务接入边界方案 > 文档版本:v1.0 -> 最后更新:2026-04-02 +> 最后更新:2026-04-02 08:28:05 ## 1. 目的 @@ -340,3 +340,4 @@ miniprogram/ 线上系统负责“把用户送进正确的一局游戏”,配置系统负责“定义这局游戏是什么”。 + diff --git a/doc/config/配置分级总表.md b/doc/config/配置分级总表.md index 4f8ad17..fa9bf3b 100644 --- a/doc/config/配置分级总表.md +++ b/doc/config/配置分级总表.md @@ -1,6 +1,6 @@ # 配置分级总表 > 文档版本:v1.0 -> 最后更新:2026-04-02 +> 最后更新:2026-04-02 08:28:05 本文档用于把当前配置体系按“核心必需项 / 常用活动项 / 高级实验项”三层整理,作为后续后台配置设计、活动装配和字段治理的统一依据。 @@ -228,3 +228,4 @@ 如果无法明确归类,默认先归入高级实验项,不急着开放到后台常规表单。 + diff --git a/doc/config/配置发布说明.md b/doc/config/配置发布说明.md index 3d48c7d..df23132 100644 --- a/doc/config/配置发布说明.md +++ b/doc/config/配置发布说明.md @@ -1,6 +1,6 @@ # 配置发布说明 > 文档版本:v1.0 -> 最后更新:2026-04-02 +> 最后更新:2026-04-02 08:28:05 本文档说明当前项目如何把 `event/*.json` 样例配置同步到服务器。 @@ -117,3 +117,4 @@ npm run publish:config:dry-run 3. 后台上传 OSS/CDN 4. 客户端仍只读取静态 JSON + diff --git a/doc/config/配置文档索引.md b/doc/config/配置文档索引.md index 036c9af..1480227 100644 --- a/doc/config/配置文档索引.md +++ b/doc/config/配置文档索引.md @@ -1,6 +1,6 @@ # 配置文档索引 > 文档版本:v1.0 -> 最后更新:2026-04-02 +> 最后更新:2026-04-02 08:28:05 本文档用于汇总当前项目所有与配置设计、配置样例、配置管理相关的文档,并按“公共配置”和“按游戏分类”两层组织。 @@ -71,3 +71,4 @@ 7. 对应玩法目录下的游戏配置项 8. 对应玩法的 `event/*.json` 样例 + diff --git a/doc/config/配置选项字典.md b/doc/config/配置选项字典.md index eacf157..7b9b2fc 100644 --- a/doc/config/配置选项字典.md +++ b/doc/config/配置选项字典.md @@ -1,6 +1,6 @@ # 配置选项字典(当前实现版) > 文档版本:v1.0 -> 最后更新:2026-04-02 +> 最后更新:2026-04-02 08:28:05 本文档用于整理 **当前客户端已经消费或已经预留承载的配置项**,作为事件配置、后台配置和联调时的统一参考。 @@ -1295,3 +1295,4 @@ - 后台可录入 - 客户端联调时有统一参考 + diff --git a/doc/debug/传感器现状总结.md b/doc/debug/传感器现状总结.md index 57b6585..ea55525 100644 --- a/doc/debug/传感器现状总结.md +++ b/doc/debug/传感器现状总结.md @@ -1,6 +1,6 @@ # 传感器现状总结 > 文档版本:v1.0 -> 最后更新:2026-04-02 +> 最后更新:2026-04-02 08:28:05 本文档用于说明当前小程序版本已经接入并实际使用的传感器/输入源、它们在系统中的作用,以及当前阶段的稳定边界。 @@ -237,3 +237,4 @@ - [platform-capability-notes.md](D:/dev/cmr-mini/doc/debug/平台能力说明.md) + diff --git a/doc/debug/平台能力说明.md b/doc/debug/平台能力说明.md index cbcbcc1..558d0bc 100644 --- a/doc/debug/平台能力说明.md +++ b/doc/debug/平台能力说明.md @@ -1,6 +1,6 @@ # 平台能力与主体限制说明 > 文档版本:v1.0 -> 最后更新:2026-04-02 +> 最后更新:2026-04-02 08:28:05 本文档用于记录当前项目在 **微信小程序平台能力** 上已经确认的边界,避免后续把环境或主体限制误判成代码问题。 @@ -147,3 +147,4 @@ - 待企业主体生效后,再统一回归验证 + diff --git a/doc/debug/模拟器多通道联调最小方案.md b/doc/debug/模拟器多通道联调最小方案.md index c5cfb53..293be6c 100644 --- a/doc/debug/模拟器多通道联调最小方案.md +++ b/doc/debug/模拟器多通道联调最小方案.md @@ -1,6 +1,6 @@ # 模拟器多通道联调最小方案 > 文档版本:v1.0 -> 最后更新:2026-04-02 +> 最后更新:2026-04-02 08:28:05 ## 目标 @@ -145,3 +145,4 @@ 如果后面真的需要这些,再升级到房间模型。 + diff --git a/doc/debug/模拟器控制面板重构方案.md b/doc/debug/模拟器控制面板重构方案.md index ee5477a..1829627 100644 --- a/doc/debug/模拟器控制面板重构方案.md +++ b/doc/debug/模拟器控制面板重构方案.md @@ -1,6 +1,6 @@ # 模拟器控制面板重构方案 > 文档版本:v1.0 -> 最后更新:2026-04-02 +> 最后更新:2026-04-02 08:28:05 ## 目标 @@ -98,3 +98,4 @@ - 模拟器只保留一个工作台入口 - websocket 协议和调试逻辑继续复用 + diff --git a/doc/debug/模拟器调试日志方案.md b/doc/debug/模拟器调试日志方案.md index 8006b85..74a32d9 100644 --- a/doc/debug/模拟器调试日志方案.md +++ b/doc/debug/模拟器调试日志方案.md @@ -1,6 +1,6 @@ # 模拟器调试日志方案 > 文档版本:v1.0 -> 最后更新:2026-04-02 +> 最后更新:2026-04-02 08:28:05 ## 目标 @@ -132,3 +132,4 @@ 先把 `gps-logo` 调试链打通,再回头用模拟器日志查 logo 为什么不显示,比继续把临时字段堆在调试面板里更稳。 + diff --git a/doc/debug/罗盘排障记录.md b/doc/debug/罗盘排障记录.md index 370e52e..0b16f7a 100644 --- a/doc/debug/罗盘排障记录.md +++ b/doc/debug/罗盘排障记录.md @@ -1,6 +1,6 @@ # 罗盘问题排查记录 > 文档版本:v1.0 -> 最后更新:2026-04-02 +> 最后更新:2026-04-02 08:28:05 ## 背景 @@ -215,3 +215,4 @@ **在微信小程序里,Android 罗盘监听的稳定性比 iOS 更脆;某些看似冗余的 `start()` 调用,实际是平台兼容补丁,不应该在没有真机回归的情况下清理。** + diff --git a/doc/debug/调试文档索引.md b/doc/debug/调试文档索引.md index ef95357..829c3ea 100644 --- a/doc/debug/调试文档索引.md +++ b/doc/debug/调试文档索引.md @@ -1,6 +1,6 @@ # 调试文档索引 > 文档版本:v1.0 -> 最后更新:2026-04-02 +> 最后更新:2026-04-02 08:28:05 这一组文档用于记录: @@ -42,3 +42,4 @@ - 看“多人联调怎么隔离”,优先看模拟器多通道联调最小方案。 - 看“为什么罗盘以前坏过”,再去看罗盘问题记录。 + diff --git a/doc/experience/原生与H5桥接规范.md b/doc/experience/原生与H5桥接规范.md index 849d393..46f69d6 100644 --- a/doc/experience/原生与H5桥接规范.md +++ b/doc/experience/原生与H5桥接规范.md @@ -1,6 +1,6 @@ # 原生与 H5 Bridge 协议草案 > 文档版本:v1.0 -> 最后更新:2026-04-02 +> 最后更新:2026-04-02 08:28:05 本文档定义当前项目中 **原生小程序** 与 **H5 定制内容页** 之间的基础通信协议。 @@ -384,3 +384,4 @@ Bridge 的第一阶段目标,不是做成万能总线,而是: 这 5 条做稳,就足够支撑第一波客户定制需求。 + diff --git a/doc/experience/混合体验架构方案.md b/doc/experience/混合体验架构方案.md index 1745b2e..a4b7563 100644 --- a/doc/experience/混合体验架构方案.md +++ b/doc/experience/混合体验架构方案.md @@ -1,6 +1,6 @@ # 混合体验架构方案 > 文档版本:v1.0 -> 最后更新:2026-04-02 +> 最后更新:2026-04-02 08:28:05 本文档用于说明当前项目在 **结果页、文创内容页、客户定制体验页** 上的长期承载方案。 @@ -514,3 +514,4 @@ H5 详情页或任务页 - [h5-experience-integration-proposal.md](/D:/dev/cmr-mini/doc/archive/experience/H5体验接入方案.md) + diff --git a/doc/gameplay/多线程联调协作方式.md b/doc/gameplay/多线程联调协作方式.md index f7344f9..961ef4d 100644 --- a/doc/gameplay/多线程联调协作方式.md +++ b/doc/gameplay/多线程联调协作方式.md @@ -1,6 +1,6 @@ # 多线程联调协作方式 > 文档版本:v1.0 -> 最后更新:2026-04-02 +> 最后更新:2026-04-02 08:28:05 ## 目标 @@ -320,3 +320,4 @@ 而不是先把协作体系做复杂。 + diff --git a/doc/gameplay/故障恢复机制.md b/doc/gameplay/故障恢复机制.md index 7fb1f4d..386e26b 100644 --- a/doc/gameplay/故障恢复机制.md +++ b/doc/gameplay/故障恢复机制.md @@ -1,6 +1,6 @@ # 故障恢复机制 > 文档版本:v1.0 -> 最后更新:2026-04-02 +> 最后更新:2026-04-02 08:28:05 本文档用于说明当前客户端在“游戏进行中非正常退出”场景下的恢复机制。 @@ -241,3 +241,4 @@ **保证玩家在异常退出后可以继续当前对局,但不承担恢复所有临时界面状态。** + diff --git a/doc/gameplay/游戏规则架构.md b/doc/gameplay/游戏规则架构.md index 620499a..9f28dbf 100644 --- a/doc/gameplay/游戏规则架构.md +++ b/doc/gameplay/游戏规则架构.md @@ -1,6 +1,6 @@ # 游戏规则架构 > 文档版本:v1.0 -> 最后更新:2026-04-02 +> 最后更新:2026-04-02 08:28:05 本文档用于说明当前项目中“游戏规则”在文档、配置文件、样例 JSON、解析代码和运行时规则引擎之间的实际组织方式。 @@ -382,3 +382,4 @@ **`doc/config` 管公共规则全集,`doc/games/<游戏名称>` 管玩法规则与配置子集,`event/*.json` 管可运行样例,客户端解析配置后交给规则引擎执行,并由轻量恢复层处理异常退出后的续局。** + diff --git a/doc/gameplay/玩法构想方案.md b/doc/gameplay/玩法构想方案.md index 25ff11a..4abc990 100644 --- a/doc/gameplay/玩法构想方案.md +++ b/doc/gameplay/玩法构想方案.md @@ -1,6 +1,6 @@ # 新玩法建议方案 > 文档版本:v1.0 -> 最后更新:2026-04-02 +> 最后更新:2026-04-02 08:28:05 本文档用于整理当前阶段值得考虑的新游戏玩法方向,重点回答以下问题: @@ -444,3 +444,4 @@ 如果只优先选一个最值得推进的新玩法,建议先做:`幽灵追逐赛`。 + diff --git a/doc/gameplay/玩法设计文档模板.md b/doc/gameplay/玩法设计文档模板.md index 7fa3016..915fc43 100644 --- a/doc/gameplay/玩法设计文档模板.md +++ b/doc/gameplay/玩法设计文档模板.md @@ -1,6 +1,6 @@ # 玩法设计文档模板 > 文档版本:v1.0 -> 最后更新:2026-04-02 +> 最后更新:2026-04-02 08:28:05 本文档用于定义后续所有玩法设计文档的**统一写法**,保证玩法规则、全局规则块、配置落点和最小样例能够一起沉淀,为后续 JSON 配置管理和后台装配提供稳定输入。 @@ -413,3 +413,4 @@ + diff --git a/doc/gameplay/程序默认规则基线.md b/doc/gameplay/程序默认规则基线.md index 5b4fc09..dd2664f 100644 --- a/doc/gameplay/程序默认规则基线.md +++ b/doc/gameplay/程序默认规则基线.md @@ -1,6 +1,6 @@ # 程序默认规则基线 > 文档版本:v1.0 -> 最后更新:2026-04-02 +> 最后更新:2026-04-02 08:28:05 本文档用于定义当前客户端在**不依赖活动配置细项**时,程序层应该内建的默认规则。 @@ -441,3 +441,4 @@ HUD 属于公共程序能力,不属于某个玩法专属实现。 当前阶段应以这份文档作为**程序默认能力基线**:先把最小流程、弹层职责、HUD 结构和距离反馈定死,再决定哪些内容值得进入配置层。 + diff --git a/doc/gameplay/运行时编译层总表.md b/doc/gameplay/运行时编译层总表.md index 7f07857..cee2d5a 100644 --- a/doc/gameplay/运行时编译层总表.md +++ b/doc/gameplay/运行时编译层总表.md @@ -1,6 +1,6 @@ # 运行时编译层总表 > 文档版本:v1.0 -> 最后更新:2026-04-02 +> 最后更新:2026-04-02 08:28:05 本文档用于定义当前项目推荐的“运行时编译层”结构,也就是把系统默认值、玩法默认值、活动配置、玩家设置编译成统一运行时 profile 的中间层。 @@ -247,3 +247,4 @@ 这样配置越多,系统越不容易乱;后续后台做复杂了,也还是有一层中间结构兜住。 + diff --git a/doc/games/积分赛/全局配置项.md b/doc/games/积分赛/全局配置项.md index 818c4b1..1d47698 100644 --- a/doc/games/积分赛/全局配置项.md +++ b/doc/games/积分赛/全局配置项.md @@ -1,6 +1,6 @@ # 积分赛全局配置项 > 文档版本:v1.0 -> 最后更新:2026-04-02 +> 最后更新:2026-04-02 08:28:05 本文档只列积分赛对公共配置块的默认落点。完整字段定义仍以 [全局规则与配置维度清单](D:/dev/cmr-mini/doc/config/全局规则与配置维度清单.md) 和 [配置选项字典](D:/dev/cmr-mini/doc/config/配置选项字典.md) 为准。 @@ -23,3 +23,4 @@ - 答题时比赛继续计时 - 未选择目标点时,HUD 只提示“请选择目标点” + diff --git a/doc/games/积分赛/最大配置模板.md b/doc/games/积分赛/最大配置模板.md index c81b55f..ec257f2 100644 --- a/doc/games/积分赛/最大配置模板.md +++ b/doc/games/积分赛/最大配置模板.md @@ -1,6 +1,6 @@ # 积分赛最大配置模板 > 文档版本:v1.0 -> 最后更新:2026-04-02 +> 最后更新:2026-04-02 08:28:05 本文档作为积分赛的完整模板入口。当前项目仍维护一份共享全量模板: @@ -55,3 +55,4 @@ - [游戏配置项](D:/dev/cmr-mini/doc/games/积分赛/游戏配置项.md) - [score-o.json](D:/dev/cmr-mini/event/score-o.json) + diff --git a/doc/games/积分赛/最小配置模板.md b/doc/games/积分赛/最小配置模板.md index 80bc058..2a51672 100644 --- a/doc/games/积分赛/最小配置模板.md +++ b/doc/games/积分赛/最小配置模板.md @@ -1,6 +1,6 @@ # 积分赛最小配置模板 > 文档版本:v1.0 -> 最后更新:2026-04-02 +> 最后更新:2026-04-02 08:28:05 本文档提供一份 **积分赛(`score-o`)最小可跑配置模板**。 @@ -248,3 +248,4 @@ - [当前最全配置模板](D:/dev/cmr-mini/doc/config/当前最全配置模板.md) + diff --git a/doc/games/积分赛/游戏说明文档.md b/doc/games/积分赛/游戏说明文档.md index 5369ddc..dd8cf24 100644 --- a/doc/games/积分赛/游戏说明文档.md +++ b/doc/games/积分赛/游戏说明文档.md @@ -1,6 +1,6 @@ # 积分赛游戏说明文档 > 文档版本:v1.0 -> 最后更新:2026-04-02 +> 最后更新:2026-04-02 08:28:05 本文档作为 `score-o` 的目录入口,用来统一说明本玩法文档放在哪里、分别看什么。 @@ -35,3 +35,4 @@ - [全局规则与配置维度清单](D:/dev/cmr-mini/doc/config/全局规则与配置维度清单.md) - [当前最全配置模板](D:/dev/cmr-mini/doc/config/当前最全配置模板.md) + diff --git a/doc/games/积分赛/游戏配置项.md b/doc/games/积分赛/游戏配置项.md index df3342d..520f591 100644 --- a/doc/games/积分赛/游戏配置项.md +++ b/doc/games/积分赛/游戏配置项.md @@ -1,6 +1,6 @@ # 积分赛游戏配置项 > 文档版本:v1.0 -> 最后更新:2026-04-02 +> 最后更新:2026-04-02 08:28:05 本文档用于汇总当前系统对 `score-o` 的已支持可配置项,重点只看和积分赛玩法直接相关的字段。 @@ -56,3 +56,4 @@ - [规则说明文档](D:/dev/cmr-mini/doc/games/积分赛/规则说明文档.md) + diff --git a/doc/games/积分赛/规则说明文档.md b/doc/games/积分赛/规则说明文档.md index 35ba4d7..f2b0fb9 100644 --- a/doc/games/积分赛/规则说明文档.md +++ b/doc/games/积分赛/规则说明文档.md @@ -1,6 +1,6 @@ # 积分赛规则说明文档 > 文档版本:v1.0 -> 最后更新:2026-04-02 +> 最后更新:2026-04-02 08:28:05 本文档用于定义 `score-o` 在**最小模板**下的系统默认规则,作为后续实现、联调和配置扩展的共同基线。 @@ -320,3 +320,4 @@ - 积分赛样例配置和实现验收时的基准口径 + diff --git a/doc/games/顺序打点/全局配置项.md b/doc/games/顺序打点/全局配置项.md index 752633c..5cf987a 100644 --- a/doc/games/顺序打点/全局配置项.md +++ b/doc/games/顺序打点/全局配置项.md @@ -1,6 +1,6 @@ # 顺序打点全局配置项 > 文档版本:v1.0 -> 最后更新:2026-04-02 +> 最后更新:2026-04-02 08:28:05 本文档只列顺序打点对公共配置块的默认落点。完整字段定义仍以 [全局规则与配置维度清单](D:/dev/cmr-mini/doc/config/全局规则与配置维度清单.md) 和 [配置选项字典](D:/dev/cmr-mini/doc/config/配置选项字典.md) 为准。 @@ -22,3 +22,4 @@ - 普通点默认自动进入 10 秒题卡 - 答题时比赛继续计时 + diff --git a/doc/games/顺序打点/最大配置模板.md b/doc/games/顺序打点/最大配置模板.md index 0fd1191..fc5c51e 100644 --- a/doc/games/顺序打点/最大配置模板.md +++ b/doc/games/顺序打点/最大配置模板.md @@ -1,6 +1,6 @@ # 顺序打点最大配置模板 > 文档版本:v1.0 -> 最后更新:2026-04-02 +> 最后更新:2026-04-02 08:28:05 本文档作为顺序打点的完整模板入口。当前项目仍维护一份共享全量模板: @@ -59,3 +59,4 @@ - [游戏配置项](D:/dev/cmr-mini/doc/games/顺序打点/游戏配置项.md) - [classic-sequential.json](D:/dev/cmr-mini/event/classic-sequential.json) + diff --git a/doc/games/顺序打点/最小配置模板.md b/doc/games/顺序打点/最小配置模板.md index fcd8a5e..55c46d8 100644 --- a/doc/games/顺序打点/最小配置模板.md +++ b/doc/games/顺序打点/最小配置模板.md @@ -1,6 +1,6 @@ # 顺序打点最小配置模板 > 文档版本:v1.0 -> 最后更新:2026-04-02 +> 最后更新:2026-04-02 08:28:05 本文档提供一份 **顺序赛(`classic-sequential`)最小可跑配置模板**。 @@ -234,3 +234,4 @@ - [当前最全配置模板](D:/dev/cmr-mini/doc/config/当前最全配置模板.md) + diff --git a/doc/games/顺序打点/游戏说明文档.md b/doc/games/顺序打点/游戏说明文档.md index 5d9a08a..d7e732e 100644 --- a/doc/games/顺序打点/游戏说明文档.md +++ b/doc/games/顺序打点/游戏说明文档.md @@ -1,6 +1,6 @@ # 顺序打点游戏说明文档 > 文档版本:v1.0 -> 最后更新:2026-04-02 +> 最后更新:2026-04-02 08:28:05 本文档作为 `classic-sequential` 的目录入口,用来统一说明本玩法文档放在哪里、分别看什么。 @@ -35,3 +35,4 @@ - [全局规则与配置维度清单](D:/dev/cmr-mini/doc/config/全局规则与配置维度清单.md) - [当前最全配置模板](D:/dev/cmr-mini/doc/config/当前最全配置模板.md) + diff --git a/doc/games/顺序打点/游戏配置项.md b/doc/games/顺序打点/游戏配置项.md index 7b7aac4..8f9054d 100644 --- a/doc/games/顺序打点/游戏配置项.md +++ b/doc/games/顺序打点/游戏配置项.md @@ -1,6 +1,6 @@ # 顺序打点游戏配置项 > 文档版本:v1.0 -> 最后更新:2026-04-02 +> 最后更新:2026-04-02 08:28:05 本文档用于汇总当前系统对 `classic-sequential` 的**已支持可配置项**,便于产品、客户端、服务端、后台录入和联调统一对照。 @@ -360,3 +360,4 @@ 5. [顺序赛样例配置](D:/dev/cmr-mini/event/classic-sequential.json) + diff --git a/doc/games/顺序打点/规则说明文档.md b/doc/games/顺序打点/规则说明文档.md index 645cc21..5f240d6 100644 --- a/doc/games/顺序打点/规则说明文档.md +++ b/doc/games/顺序打点/规则说明文档.md @@ -1,6 +1,6 @@ # 顺序打点规则说明文档 > 文档版本:v1.0 -> 最后更新:2026-04-02 +> 最后更新:2026-04-02 08:28:05 本文档用于定义 `classic-sequential` 在**最小模板**下的系统默认规则,作为后续实现、联调和配置扩展的共同基线。 @@ -304,3 +304,4 @@ - 顺序赛样例配置和实现验收时的基准口径 + diff --git a/doc/gateway/Cloudflare隧道开发说明.md b/doc/gateway/Cloudflare隧道开发说明.md index 72f7eaa..d68963a 100644 --- a/doc/gateway/Cloudflare隧道开发说明.md +++ b/doc/gateway/Cloudflare隧道开发说明.md @@ -1,6 +1,6 @@ # Realtime Gateway + Cloudflare Tunnel 本机联调说明 > 文档版本:v1.0 -> 最后更新:2026-04-02 +> 最后更新:2026-04-02 08:28:05 本文档说明如何在**不正式部署到线上服务器**的前提下,把本机的 `realtime-gateway` 暴露给外部设备或远程联调方。 @@ -290,3 +290,4 @@ npm run mock-gps-sim 这条路径最轻、最稳,也最符合你现在“先不正式上线”的目标。 + diff --git a/doc/gateway/实时网关运行手册.md b/doc/gateway/实时网关运行手册.md index c21040e..fcac5d9 100644 --- a/doc/gateway/实时网关运行手册.md +++ b/doc/gateway/实时网关运行手册.md @@ -1,6 +1,6 @@ # Realtime Gateway 运行手册 > 文档版本:v1.0 -> 最后更新:2026-04-02 +> 最后更新:2026-04-02 08:28:05 本文档用于整理当前 `realtime-gateway` 的构建、运行、联调和排障方式,覆盖今天已经落地的能力。 @@ -446,3 +446,4 @@ go run .\cmd\mock-consumer -channel-id ch-xxxx -token -topics t 这也是当前最省风险的组合。 + diff --git a/doc/gateway/实时设备网关架构.md b/doc/gateway/实时设备网关架构.md index d4fd2fa..278874c 100644 --- a/doc/gateway/实时设备网关架构.md +++ b/doc/gateway/实时设备网关架构.md @@ -1,6 +1,6 @@ # 实时设备数据网关最终方案 > 文档版本:v1.0 -> 最后更新:2026-04-02 +> 最后更新:2026-04-02 08:28:05 本文档用于收敛当前关于 GPS 模拟、中转、监控、规则判定、回放、通知分发等讨论,给出一版可直接进入实现设计的最终方案。 @@ -880,3 +880,4 @@ Business Server 同时又能把实时性能放在系统设计的首位。 + diff --git a/doc/gateway/网关MVP任务拆分.md b/doc/gateway/网关MVP任务拆分.md index f5f9c1a..f9b9ea0 100644 --- a/doc/gateway/网关MVP任务拆分.md +++ b/doc/gateway/网关MVP任务拆分.md @@ -1,6 +1,6 @@ # 实时网关 MVP 拆分 > 文档版本:v1.0 -> 最后更新:2026-04-02 +> 最后更新:2026-04-02 08:28:05 本文档用于把 `realtime-gateway` 第一阶段工作拆成可执行任务。 @@ -126,3 +126,4 @@ MVP 跑通后优先做: 5. Dispatcher 插件 + diff --git a/doc/gateway/网关协议规范.md b/doc/gateway/网关协议规范.md index 53910e3..bcf312e 100644 --- a/doc/gateway/网关协议规范.md +++ b/doc/gateway/网关协议规范.md @@ -1,6 +1,6 @@ # 实时网关协议草案 > 文档版本:v1.0 -> 最后更新:2026-04-02 +> 最后更新:2026-04-02 08:28:05 本文档描述 `realtime-gateway` 第一版协议草案,范围只覆盖 MVP。 @@ -347,3 +347,4 @@ - `auth_refresh` + diff --git a/doc/gateway/网关文档索引.md b/doc/gateway/网关文档索引.md index 8c1233d..eeb5f31 100644 --- a/doc/gateway/网关文档索引.md +++ b/doc/gateway/网关文档索引.md @@ -1,6 +1,6 @@ # 网关文档索引 > 文档版本:v1.0 -> 最后更新:2026-04-02 +> 最后更新:2026-04-02 08:28:05 这一组文档用于承接: @@ -33,3 +33,4 @@ 4. [gateway-mvp-task-breakdown.md](/D:/dev/cmr-mini/doc/gateway/网关MVP任务拆分.md) + diff --git a/doc/notes/沟通协作建议.md b/doc/notes/沟通协作建议.md index 19421dc..9450564 100644 --- a/doc/notes/沟通协作建议.md +++ b/doc/notes/沟通协作建议.md @@ -1,6 +1,6 @@ # 沟通协作建议 > 文档版本:v1.0 -> 最后更新:2026-04-02 +> 最后更新:2026-04-02 08:28:05 这份文档用于约定后续在 UI 微调、交互细改、规则补充时,怎样沟通最有效,减少来回修改。 @@ -99,3 +99,4 @@ **需求把边界说死,修改一次只动一层。** + diff --git a/doc/rendering/GPS点动画系统方案.md b/doc/rendering/GPS点动画系统方案.md index 991b86f..3f0e46b 100644 --- a/doc/rendering/GPS点动画系统方案.md +++ b/doc/rendering/GPS点动画系统方案.md @@ -1,6 +1,6 @@ # GPS 点动画系统方案 > 文档版本:v1.0 -> 最后更新:2026-04-02 +> 最后更新:2026-04-02 08:28:05 ## 目标 @@ -213,3 +213,4 @@ GPS 点动画不应该做成单一固定动画,而应该做成: 这 4 种状态的程序化动画跑通,再决定后续是否继续开放更细粒度配置。 + diff --git a/doc/rendering/GPS点样式系统方案.md b/doc/rendering/GPS点样式系统方案.md index 70f0771..8d0839b 100644 --- a/doc/rendering/GPS点样式系统方案.md +++ b/doc/rendering/GPS点样式系统方案.md @@ -1,6 +1,6 @@ # GPS 点样式系统方案 > 文档版本:v1.0 -> 最后更新:2026-04-02 +> 最后更新:2026-04-02 08:28:05 ## 目标 @@ -116,3 +116,4 @@ GPS 点应被视为独立样式系统,而不是固定蓝点。 做稳定,再逐步承接商业品牌化定制。 + diff --git a/doc/rendering/轨迹可视化方案.md b/doc/rendering/轨迹可视化方案.md index 4603fe1..04b78e8 100644 --- a/doc/rendering/轨迹可视化方案.md +++ b/doc/rendering/轨迹可视化方案.md @@ -1,6 +1,6 @@ # 轨迹可视化方案 > 文档版本:v1.0 -> 最后更新:2026-04-02 +> 最后更新:2026-04-02 08:28:05 本文档定义用户轨迹的显示模式、默认策略与配置结构。 @@ -88,3 +88,4 @@ - `standard / lite` 下自动降级 glow + diff --git a/doc/文档索引.md b/doc/文档索引.md index c469b79..5551454 100644 --- a/doc/文档索引.md +++ b/doc/文档索引.md @@ -1,10 +1,11 @@ # 文档索引 > 文档版本:v1.0 -> 最后更新:2026-04-02 +> 最后更新:2026-04-02 08:28:05 维护约定: - 所有 Markdown 文档统一在标题下方标注 `文档版本` 和 `最后更新`。 +- `最后更新` 必须写到日期时间,例如 `2026-04-02 08:28:05`。 - 后续新建或更新文档时,必须同步维护这两项元信息。 ## 按游戏分类 @@ -76,3 +77,4 @@ - 长期保留的少量工作便签见 [notes](/D:/dev/cmr-mini/doc/notes)。 - 历史方案稿和阶段性讨论稿已移到 [archive](/D:/dev/cmr-mini/doc/archive/归档索引.md)。 + diff --git a/f2b.md b/f2b.md index ef4fc75..ce96a9f 100644 --- a/f2b.md +++ b/f2b.md @@ -1,6 +1,6 @@ # F2B 协作清单 > 文档版本:v1.0 -> 最后更新:2026-04-02 +> 最后更新:2026-04-02 08:28:05 说明: @@ -14,44 +14,6 @@ ## 待确认 -### F2B-001 - -- 时间:2026-04-01 -- 提出方:前端 -- 当前事实: - - 小程序当前按以下语义上报 session 结束状态: - - 正常打终点完成 -> `finished` - - 超时结束 -> `failed` - - 主动退出 / 放弃恢复 -> `cancelled` -- 需要对方确认什么: - - backend 是否确认以上三态为正式语义 -- 状态:待确认 - -### F2B-002 - -- 时间:2026-04-01 -- 提出方:前端 -- 当前事实: - - 小程序已启用“放弃恢复 -> `finish(cancelled)`” - - 调用时使用的是恢复快照里的旧 `sessionId/sessionToken` - - 若上报失败,前端仍会放弃本地恢复,不阻塞用户 -- 需要对方确认什么: - - backend 是否确认“放弃恢复”应记为 `cancelled` - - 旧 `sessionToken` 在该场景下是否允许调用 `finish(cancelled)` -- 状态:待确认 - -### F2B-003 - -- 时间:2026-04-01 -- 提出方:前端 -- 当前事实: - - 联调和故障恢复场景下,`start` / `finish` 存在重复调用可能 - - 当前前端已经尽量去重,但无法完全避免网络重试和页面重进 -- 需要对方确认什么: - - backend 是否按幂等方式处理 `start` - - backend 是否按幂等方式处理 `finish` -- 状态:待确认 - ### F2B-004 - 时间:2026-04-01 @@ -109,6 +71,43 @@ - 无 - 状态:已确认 +### 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` 按幂等处理 + - 前端可继续按当前补报 / 重试逻辑联调 +- 需要对方确认什么: + - 无 +- 状态:已确认 + --- ## 阻塞 @@ -176,20 +175,18 @@ ### F2B-N001 -- 时间:2026-04-01 +- 时间:2026-04-02 - 提出方:前端 - 当前事实: - - 当前最需要 backend 反馈的,是 session 生命周期相关语义 + - session 生命周期关键语义已由 backend 确认 + - 当前前端下一轮重点应转向主链回归与结果展示对齐 - 需要对方确认什么: - - 优先回复: - - F2B-001 - - F2B-002 - - F2B-003 -- 状态:等待后端回复 + - 无 +- 状态:前端执行中 ### F2B-N002 -- 时间:2026-04-01 +- 时间:2026-04-02 - 提出方:前端 - 当前事实: - 心率 / 卡路里个体化能力已在前端预留 @@ -197,3 +194,4 @@ - 后续是否提供用户身体数据接口 - 状态:后续事项 + diff --git a/miniprogram/pages/index/index.ts b/miniprogram/pages/index/index.ts index 683ed4c..45c3336 100644 --- a/miniprogram/pages/index/index.ts +++ b/miniprogram/pages/index/index.ts @@ -1,11 +1,79 @@ -import { loadBackendAuthTokens } from '../../utils/backendAuth' +import { finishSession } from '../../utils/backendApi' +import { loadBackendAuthTokens, loadBackendBaseUrl } from '../../utils/backendAuth' +import { clearSessionRecoverySnapshot, loadSessionRecoverySnapshot } from '../../game/core/sessionRecovery' +import { getBackendSessionContextFromLaunchEnvelope, prepareMapPageUrlForRecovery } from '../../utils/gameLaunch' Page({ onLoad() { + const recoverySnapshot = loadSessionRecoverySnapshot() + if (recoverySnapshot) { + this.promptRecoveryAtEntry() + return + } + + this.redirectToDefaultEntry() + }, + + redirectToDefaultEntry() { const tokens = loadBackendAuthTokens() const url = tokens && tokens.accessToken ? '/pages/home/home' : '/pages/login/login' wx.redirectTo({ url }) }, + + promptRecoveryAtEntry() { + const recoverySnapshot = loadSessionRecoverySnapshot() + if (!recoverySnapshot) { + this.redirectToDefaultEntry() + return + } + + wx.showModal({ + title: '恢复对局', + content: '检测到上次有未正常结束的对局,是否继续恢复?', + confirmText: '继续恢复', + cancelText: '放弃', + success: (result) => { + if (result.confirm) { + wx.redirectTo({ + url: prepareMapPageUrlForRecovery(recoverySnapshot.launchEnvelope), + }) + return + } + + const sessionContext = getBackendSessionContextFromLaunchEnvelope(recoverySnapshot.launchEnvelope) + if (!sessionContext) { + clearSessionRecoverySnapshot() + wx.showToast({ + title: '已放弃上次对局', + icon: 'none', + duration: 1400, + }) + this.redirectToDefaultEntry() + return + } + + finishSession({ + baseUrl: loadBackendBaseUrl(), + sessionId: sessionContext.sessionId, + sessionToken: sessionContext.sessionToken, + status: 'cancelled', + summary: {}, + }) + .catch(() => { + // 放弃恢复不阻塞进入业务页;失败只丢给后续状态页处理。 + }) + .finally(() => { + clearSessionRecoverySnapshot() + wx.showToast({ + title: '已放弃上次对局', + icon: 'none', + duration: 1400, + }) + this.redirectToDefaultEntry() + }) + }, + }) + }, }) diff --git a/miniprogram/pages/map/map.ts b/miniprogram/pages/map/map.ts index 6f39d88..de5fef4 100644 --- a/miniprogram/pages/map/map.ts +++ b/miniprogram/pages/map/map.ts @@ -7,6 +7,7 @@ import { type MapEngineViewState, } from '../../engine/map/mapEngine' import { + getBackendSessionContextFromLaunchEnvelope, getDemoGameLaunchEnvelope, resolveGameLaunchEnvelope, type GameLaunchEnvelope, @@ -177,6 +178,9 @@ let currentRemoteMapConfig: RemoteMapConfig | undefined let systemSettingsLockLifetimeActive = false let syncedBackendSessionStartId = '' let syncedBackendSessionFinishId = '' +let shouldAutoRestoreRecoverySnapshot = false +const DEBUG_MOCK_CHANNEL_ID_STORAGE_KEY = 'cmr.debug.mockChannelId.v1' +const DEBUG_MOCK_AUTO_CONNECT_STORAGE_KEY = 'cmr.debug.autoConnectMockSources.v1' let lastCenterScaleRulerStablePatch: Pick< MapPageData, | 'centerScaleRulerVisible' @@ -405,6 +409,42 @@ function updateStoredUserSettings(patch: Partial) { ) } +function loadStoredMockChannelId(): string { + try { + const value = wx.getStorageSync(DEBUG_MOCK_CHANNEL_ID_STORAGE_KEY) + if (typeof value === 'string' && value.trim().length > 0) { + return value.trim() + } + } catch (_error) { + // Ignore storage read failures and fall back to default. + } + return 'default' +} + +function persistMockChannelId(channelId: string) { + try { + wx.setStorageSync(DEBUG_MOCK_CHANNEL_ID_STORAGE_KEY, channelId) + } catch (_error) { + // Ignore storage write failures in debug preference persistence. + } +} + +function loadMockAutoConnectEnabled(): boolean { + try { + return wx.getStorageSync(DEBUG_MOCK_AUTO_CONNECT_STORAGE_KEY) === true + } catch (_error) { + return false + } +} + +function persistMockAutoConnectEnabled(enabled: boolean) { + try { + wx.setStorageSync(DEBUG_MOCK_AUTO_CONNECT_STORAGE_KEY, enabled) + } catch (_error) { + // Ignore storage write failures in debug preference persistence. + } +} + function buildResolvedSystemSettingsPatch( resolvedSettings: ResolvedSystemSettingsState, ): Partial { @@ -446,26 +486,7 @@ function hasExplicitLaunchOptions(options?: MapPageLaunchOptions | null): boolea } function getCurrentBackendSessionContext(): { sessionId: string; sessionToken: string } | null { - const business = currentGameLaunchEnvelope.business - if (!business || !business.sessionId || !business.sessionToken) { - return null - } - - return { - sessionId: business.sessionId, - sessionToken: business.sessionToken, - } -} - -function getBackendSessionContextFromLaunchEnvelope(envelope: GameLaunchEnvelope | null | undefined): { sessionId: string; sessionToken: string } | null { - if (!envelope || !envelope.business || !envelope.business.sessionId || !envelope.business.sessionToken) { - return null - } - - return { - sessionId: envelope.business.sessionId, - sessionToken: envelope.business.sessionToken, - } + return getBackendSessionContextFromLaunchEnvelope(currentGameLaunchEnvelope) } function getCurrentBackendBaseUrl(): string { @@ -908,6 +929,7 @@ Page({ clearSessionRecoveryPersistTimer() syncedBackendSessionStartId = '' syncedBackendSessionFinishId = '' + shouldAutoRestoreRecoverySnapshot = options && options.recoverSession === '1' currentGameLaunchEnvelope = resolveGameLaunchEnvelope(options) if (!hasExplicitLaunchOptions(options)) { const recoverySnapshot = loadSessionRecoverySnapshot() @@ -918,6 +940,8 @@ Page({ currentSystemSettingsConfig = undefined currentRemoteMapConfig = undefined systemSettingsLockLifetimeActive = false + const storedMockChannelId = loadStoredMockChannelId() + const shouldAutoConnectMockSources = loadMockAutoConnectEnabled() const systemInfo = wx.getSystemInfoSync() const statusBarHeight = systemInfo.statusBarHeight || 0 const menuButtonRect = wx.getMenuButtonBoundingClientRect() @@ -1215,9 +1239,9 @@ Page({ mockBridgeConnected: false, mockBridgeStatusText: '未连接', mockBridgeUrlText: 'wss://gs.gotomars.xyz/mock-gps', - mockChannelIdText: 'default', + mockChannelIdText: storedMockChannelId, mockBridgeUrlDraft: 'wss://gs.gotomars.xyz/mock-gps', - mockChannelIdDraft: 'default', + mockChannelIdDraft: storedMockChannelId, mockCoordText: '--', mockSpeedText: '--', heartRateSourceMode: 'real', @@ -1314,6 +1338,10 @@ Page({ centerTileY: 0, tileSizePx: 0, }), + }, () => { + if (shouldAutoConnectMockSources) { + this.handleConnectAllMockSources() + } }) }, @@ -1359,6 +1387,7 @@ Page({ currentRemoteMapConfig = undefined systemSettingsLockLifetimeActive = false currentGameLaunchEnvelope = getDemoGameLaunchEnvelope() + shouldAutoRestoreRecoverySnapshot = false stageCanvasAttached = false }, @@ -1499,6 +1528,34 @@ Page({ }) }, + restoreRecoverySnapshot(snapshot: SessionRecoverySnapshot) { + systemSettingsLockLifetimeActive = true + this.applyRuntimeSystemSettings(true) + const restored = mapEngine ? mapEngine.restoreSessionRecoveryRuntimeSnapshot(snapshot.runtime) : false + if (!restored) { + clearSessionRecoverySnapshot() + wx.showToast({ + title: '恢复失败,已回到初始状态', + icon: 'none', + duration: 1600, + }) + return false + } + + this.setData({ + showResultScene: false, + showDebugPanel: false, + showGameInfoPanel: false, + showSystemSettingsPanel: false, + }) + const sessionContext = getCurrentBackendSessionContext() + if (sessionContext) { + syncedBackendSessionStartId = sessionContext.sessionId + } + this.syncSessionRecoveryLifecycle('running') + return true + }, + syncSessionRecoveryLifecycle(status: MapPageData['gameSessionStatus']) { if (status === 'running') { this.persistSessionRecoverySnapshot() @@ -1528,6 +1585,12 @@ Page({ return } + if (shouldAutoRestoreRecoverySnapshot) { + shouldAutoRestoreRecoverySnapshot = false + this.restoreRecoverySnapshot(snapshot) + return + } + wx.showModal({ title: '恢复对局', content: '检测到上次有未正常结束的对局,是否继续恢复?', @@ -1539,32 +1602,9 @@ Page({ return } - systemSettingsLockLifetimeActive = true - this.applyRuntimeSystemSettings(true) - const restored = mapEngine ? mapEngine.restoreSessionRecoveryRuntimeSnapshot(snapshot.runtime) : false - if (!restored) { - clearSessionRecoverySnapshot() - wx.showToast({ - title: '恢复失败,已回到初始状态', - icon: 'none', - duration: 1600, - }) - return - } - - this.setData({ - showResultScene: false, - showDebugPanel: false, - showGameInfoPanel: false, - showSystemSettingsPanel: false, - }) - const sessionContext = getCurrentBackendSessionContext() - if (sessionContext) { - syncedBackendSessionStartId = sessionContext.sessionId - } - this.syncSessionRecoveryLifecycle('running') - }, - }) + this.restoreRecoverySnapshot(snapshot) + }, + }) }, compileCurrentRuntimeProfile(lockLifetimeActive = isSystemSettingsLockLifetimeActive()) { @@ -1890,7 +1930,13 @@ Page({ if (!mapEngine) { return } - mapEngine.handleSetMockChannelId(this.data.mockChannelIdDraft) + const channelId = (this.data.mockChannelIdDraft || '').trim() || 'default' + this.setData({ + mockChannelIdDraft: channelId, + }) + persistMockChannelId(channelId) + persistMockAutoConnectEnabled(true) + mapEngine.handleSetMockChannelId(channelId) mapEngine.handleSetMockLocationBridgeUrl(this.data.mockBridgeUrlDraft) mapEngine.handleSetMockHeartRateBridgeUrl(this.data.mockHeartRateBridgeUrlDraft) mapEngine.handleSetMockDebugLogBridgeUrl(this.data.mockDebugLogBridgeUrlDraft) @@ -1914,8 +1960,13 @@ Page({ }, handleSaveMockChannelId() { + const channelId = (this.data.mockChannelIdDraft || '').trim() || 'default' + this.setData({ + mockChannelIdDraft: channelId, + }) + persistMockChannelId(channelId) if (mapEngine) { - mapEngine.handleSetMockChannelId(this.data.mockChannelIdDraft) + mapEngine.handleSetMockChannelId(channelId) } }, @@ -1932,6 +1983,7 @@ Page({ }, handleDisconnectMockLocationBridge() { + persistMockAutoConnectEnabled(false) if (mapEngine) { mapEngine.handleDisconnectMockLocationBridge() } @@ -1980,6 +2032,7 @@ Page({ }, handleDisconnectMockDebugLogBridge() { + persistMockAutoConnectEnabled(false) if (mapEngine) { mapEngine.handleDisconnectMockDebugLogBridge() } @@ -1992,6 +2045,7 @@ Page({ }, handleDisconnectMockHeartRateBridge() { + persistMockAutoConnectEnabled(false) if (mapEngine) { mapEngine.handleDisconnectMockHeartRateBridge() } diff --git a/miniprogram/pages/map/map.wxml b/miniprogram/pages/map/map.wxml index 8e4f353..4d96bce 100644 --- a/miniprogram/pages/map/map.wxml +++ b/miniprogram/pages/map/map.wxml @@ -802,7 +802,7 @@ 定位模拟、心率模拟、调试日志与方向状态 - 一键连接开发调试源 + 一键连接开发调试源 测试 H5 diff --git a/miniprogram/utils/gameLaunch.ts b/miniprogram/utils/gameLaunch.ts index 9bd2de5..ab84c7a 100644 --- a/miniprogram/utils/gameLaunch.ts +++ b/miniprogram/utils/gameLaunch.ts @@ -29,6 +29,7 @@ export interface GameLaunchEnvelope { export interface MapPageLaunchOptions { launchId?: string + recoverSession?: string preset?: string configUrl?: string configLabel?: string @@ -181,6 +182,21 @@ export function prepareMapPageUrlForLaunch(envelope: GameLaunchEnvelope): string return buildMapPageUrlWithLaunchId(stashPendingGameLaunchEnvelope(envelope)) } +export function prepareMapPageUrlForRecovery(envelope: GameLaunchEnvelope): string { + return `${buildMapPageUrlWithLaunchId(stashPendingGameLaunchEnvelope(envelope))}&recoverSession=1` +} + +export function getBackendSessionContextFromLaunchEnvelope(envelope: GameLaunchEnvelope | null | undefined): { sessionId: string; sessionToken: string } | null { + if (!envelope || !envelope.business || !envelope.business.sessionId || !envelope.business.sessionToken) { + return null + } + + return { + sessionId: envelope.business.sessionId, + sessionToken: envelope.business.sessionToken, + } +} + export function resolveGameLaunchEnvelope(options?: MapPageLaunchOptions | null): GameLaunchEnvelope { const launchId = normalizeOptionalString(options ? options.launchId : undefined) if (launchId) { diff --git a/readme-develop.md b/readme-develop.md index 7a0cc32..f9417fd 100644 --- a/readme-develop.md +++ b/readme-develop.md @@ -1,10 +1,11 @@ # CMR Mini 开发架构阶段总结 > 文档版本:v1.0 -> 最后更新:2026-04-02 +> 最后更新:2026-04-02 08:28:05 文档维护约定: - 仓库内 Markdown 文档统一在标题下方标注 `文档版本` 和 `最后更新`。 +- `最后更新` 必须写到日期时间,例如 `2026-04-02 08:28:05`。 - 后续新建文档或更新文档内容时,必须同步更新这两项元信息。 本文档用于记录当前阶段小程序的整体架构、分层原则、事件驱动链路、模拟器体系,以及后续继续扩展时应遵守的边界。 @@ -1571,3 +1572,4 @@ GPS: 都会沿这条边界继续推进,而不是重新混在一个弹层里。 + diff --git a/readme.md b/readme.md index 9d635ea..7acd747 100644 --- a/readme.md +++ b/readme.md @@ -1,6 +1,6 @@ # CMR Mini Program(彩图奔跑小程序) > 文档版本:v1.0 -> 最后更新:2026-04-02 +> 最后更新:2026-04-02 08:28:05 ## 📌 项目简介 @@ -208,3 +208,4 @@ NPC / 问答扩展 🧠 一句话总结 这是一个“自研地图引擎 + 定向运动系统”的小程序项目,而不是普通地图应用。 + diff --git a/realtime-gateway/README.md b/realtime-gateway/README.md index 3ca39ff..3791314 100644 --- a/realtime-gateway/README.md +++ b/realtime-gateway/README.md @@ -1,6 +1,6 @@ # Realtime Gateway > 文档版本:v1.0 -> 最后更新:2026-04-02 +> 最后更新:2026-04-02 08:28:05 `realtime-gateway` 是一个独立于现有模拟器的 Go 实时设备数据网关工程。 @@ -151,3 +151,4 @@ go run .\cmd\mock-consumer -channel-id ch-xxxx -token -topics t - [实时网关运行手册.md](D:/dev/cmr-mini/doc/gateway/实时网关运行手册.md) + diff --git a/tmp/Client-API.md b/tmp/Client-API.md index 245c96f..6032e30 100644 --- a/tmp/Client-API.md +++ b/tmp/Client-API.md @@ -1,6 +1,6 @@ # Client API 前端联调文档 > 文档版本:v1.0 -> 最后更新:2026-04-02 +> 最后更新:2026-04-02 08:28:05 文档版本:v1.0.0 @@ -696,3 +696,4 @@ Path 参数: - 已存在测试赛事卡片、地图、Event、manifest 绑定关系 - 已存在一个已批准报名的测试用户,可直接验证赛事入口 `launch` + diff --git a/todolist.md b/todolist.md index 58afa61..3e68f35 100644 --- a/todolist.md +++ b/todolist.md @@ -1,6 +1,6 @@ # CMR 联调协作清单 > 文档版本:v1.0 -> 最后更新:2026-04-02 +> 最后更新:2026-04-02 08:28:05 本文档用于后端、前端和你之间的联调协作。 @@ -294,3 +294,4 @@ - 已支持场景保存 - 已支持配置发布链调试 + diff --git a/tools/mock-gps-sim/README.md b/tools/mock-gps-sim/README.md index dd84305..b57b700 100644 --- a/tools/mock-gps-sim/README.md +++ b/tools/mock-gps-sim/README.md @@ -1,6 +1,6 @@ # Mock GPS Simulator > 文档版本:v1.0 -> 最后更新:2026-04-02 +> 最后更新:2026-04-02 08:28:05 ## 启动 @@ -19,6 +19,19 @@ npm run mock-gps-sim - 小程序调试日志地址: `ws://127.0.0.1:17865/debug-log` - 资源代理: `http://127.0.0.1:17865/proxy?url=` +补充说明: + +- 模拟器工作台内部现在会按当前页面地址自动推导 websocket 地址。 +- 本地访问 `http://127.0.0.1:17865/` 时,会自动连接: + - `ws://127.0.0.1:17865/mock-gps` + - `ws://127.0.0.1:17865/mock-hr` + - `ws://127.0.0.1:17865/debug-log` +- 外网访问例如 `https://gs.gotomars.xyz/` 时,会自动连接同源: + - `wss://gs.gotomars.xyz/mock-gps` + - `wss://gs.gotomars.xyz/mock-hr` + - `wss://gs.gotomars.xyz/debug-log` +- 因此外网页面不能再把 `https://gs.gotomars.xyz/` 当作 websocket 地址手工填写;应直接使用对应的 `wss://...` 路径,或让工作台按同源自动连接。 + ## 多通道联调 模拟器现在支持一个最小的多通道隔离方案: @@ -281,3 +294,4 @@ http://192.168.1.23:17865/ ws://192.168.1.23:17865/debug-log ``` + diff --git a/tools/mock-gps-sim/public/simulator.js b/tools/mock-gps-sim/public/simulator.js index fa1391b..d04f55c 100644 --- a/tools/mock-gps-sim/public/simulator.js +++ b/tools/mock-gps-sim/public/simulator.js @@ -3,9 +3,16 @@ const DEFAULT_CONFIG_URL = 'https://oss-mbh5.colormaprun.com/wxmini/test/game.json' const DEFAULT_TILE_URL = 'https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png' const PROXY_BASE_URL = `${location.origin}/proxy?url=` - const GPS_WS_URL = `ws://${location.hostname}:17865/mock-gps` - const HEART_RATE_WS_URL = `ws://${location.hostname}:17865/mock-hr` - const DEBUG_LOG_WS_URL = `ws://${location.hostname}:17865/debug-log` + + function createSameOriginWsUrl(pathname) { + const wsProtocol = location.protocol === 'https:' ? 'wss:' : 'ws:' + const normalizedPath = String(pathname || '').startsWith('/') ? pathname : `/${pathname}` + return `${wsProtocol}//${location.host}${normalizedPath}` + } + + const GPS_WS_URL = createSameOriginWsUrl('/mock-gps') + const HEART_RATE_WS_URL = createSameOriginWsUrl('/mock-hr') + const DEBUG_LOG_WS_URL = createSameOriginWsUrl('/debug-log') const DEFAULT_GATEWAY_BRIDGE_URL = 'ws://127.0.0.1:18080/ws' const LEGACY_GATEWAY_BRIDGE_URLS = new Set([ 'ws://127.0.0.1:8080/ws',