同步前后端联调与文档更新

This commit is contained in:
2026-04-02 09:25:05 +08:00
parent af43beadb0
commit 6964e26ec9
113 changed files with 4317 additions and 293 deletions

132
b2f.md
View File

@@ -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` 标具体接口和返回值
- 是否已解决:否

View File

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

View File

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

View File

@@ -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 口径定稳,再继续扩后台配置系统。

View File

@@ -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 两个适配器。

View File

@@ -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 发布 的最小运营后台。

View File

@@ -1,6 +1,6 @@
# 开发说明
> 文档版本v1.0
> 最后更新2026-04-02
> 最后更新2026-04-02 08:28:05
## 1. 环境变量
@@ -196,3 +196,4 @@ Redis 后面只在需要性能优化、限流或短期票据缓存时再接。
不要跳回去把玩法规则塞进 backend。

View File

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

View File

@@ -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不要先拍脑袋建大而全表。

View File

@@ -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 当前主链是手机号验证码:
业务接口必须保持统一,终端差异只进入上下文,不进入对象模型分叉。

View File

@@ -1,6 +1,6 @@
# 系统架构
> 文档版本v1.0
> 最后更新2026-04-02
> 最后更新2026-04-02 08:28:05
## 1. 目标
@@ -235,3 +235,4 @@
不要把微信身份或业务 token 直接暴露给实时网关。

View File

@@ -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而不是做成“小程序跑通后再重构”的临时结构。

View File

@@ -1,6 +1,6 @@
# 配置管理方案
> 文档版本v1.0
> 最后更新2026-04-02
> 最后更新2026-04-02 08:28:05
## 1. 目标
@@ -414,3 +414,4 @@
这样以后无论你配置项怎么继续长,主架构都还能撑住。

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -1,5 +1,5 @@
> 文档版本v1.0
> 最后更新2026-04-02
> 最后更新2026-04-02 08:28:05
结果页会根据客户的要求不停的变换,用什么方案能实现这个需求,其实其他的弹出内容也都存在这个问题,样式,内容都时根据客户需求变化的,怎样一种方案设计比较好呢?
@@ -334,3 +334,4 @@ CTA 就是卡片上引导用户下一步操作的按钮。
再深一点自定GPS点能不能做成动画的停止一个动画跑起来又是一个动画甚至可以做些额外的动作。
开个小差我想临时加个功能在咱的GPS模拟器加个日志输出功能把调试期间不方便打在调试面板里的信息输出到模拟器上你觉得如何这样更方便后期调试如果可以先给个方案

View File

@@ -1,6 +1,6 @@
# 动画字典 v1
> 文档版本v1.0
> 最后更新2026-04-02
> 最后更新2026-04-02 08:28:05
## 1. 目的
@@ -360,3 +360,4 @@

View File

@@ -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),当前以本文件为统一入口。

View File

@@ -1,6 +1,6 @@
# 动画体系阶段性小结
> 文档版本v1.0
> 最后更新2026-04-02
> 最后更新2026-04-02 08:28:05
## 1. 当前定位
@@ -195,3 +195,4 @@

View File

@@ -1,6 +1,6 @@
# 动画接入规格模板
> 文档版本v1.0
> 最后更新2026-04-02
> 最后更新2026-04-02 08:28:05
## 1. 用途
@@ -166,3 +166,4 @@ lite 表现:透明度降低 50%,时长缩短到 220ms

View File

@@ -1,6 +1,6 @@
# 动画接入评审清单
> 文档版本v1.0
> 最后更新2026-04-02
> 最后更新2026-04-02 08:28:05
## 1. 用途
@@ -165,3 +165,4 @@

View File

@@ -1,6 +1,6 @@
# 动效系统设计方案
> 文档版本v1.0
> 最后更新2026-04-02
> 最后更新2026-04-02 08:28:05
本文档用于整理当前项目后续的动画 / 动效建设方案,目标不是单纯“让界面更花”,而是把动画正式纳入现有架构,成为:
@@ -453,3 +453,4 @@
**后续动画建设应以“打点成功”和“目标状态”两条高频体验为起点,把动画正式纳入现有架构,而不是继续做零散样式补丁。**

View File

@@ -1,6 +1,6 @@
# 配置驱动应用的后台管理方案建议
> 文档版本v1.0
> 最后更新2026-04-02
> 最后更新2026-04-02 08:28:05
本文用于整理当前这类“配置驱动型地图游戏应用”的后台管理建议,面向:
@@ -419,3 +419,4 @@ Go 中间层实现“装配成最终 JSON”。
- 可稳定运行

View File

@@ -1,6 +1,6 @@
# 积分赛配置文档(基础版)
> 文档版本v1.0
> 最后更新2026-04-02
> 最后更新2026-04-02 08:28:05
本文档用于给服务端和后台配置设计提供一份可直接落地的积分赛基础模板。
@@ -358,3 +358,4 @@
- 先把静态积分赛入口结构定稳,后续再扩动态积分与更复杂玩法

View File

@@ -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 组合。**

View File

@@ -1,6 +1,6 @@
# 顺序赛配置文档(基础版)
> 文档版本v1.0
> 最后更新2026-04-02
> 最后更新2026-04-02 08:28:05
本文档用于给服务端和后台配置设计提供一份可直接落地的顺序赛基础模板。
@@ -316,3 +316,4 @@
- 先把基础入口结构定稳,后续再细化跳点、惩罚、特殊引导等高级规则

View File

@@ -1,6 +1,6 @@
# 默认配置模板文档(当前实现版)
> 文档版本v1.0
> 最后更新2026-04-02
> 最后更新2026-04-02 08:28:05
本文档提供一份 **当前客户端可直接使用的默认配置模板**
@@ -418,3 +418,4 @@

View File

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

View File

@@ -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` 三种体验形态。**

View File

@@ -1,6 +1,6 @@
# 游戏中文创体验层方案
> 文档版本v1.0
> 最后更新2026-04-02
> 最后更新2026-04-02 08:28:05
## 1. 目标
@@ -331,3 +331,4 @@ interface ExperienceRuntimeState {
第一阶段先用“控制点完成触发内容卡”跑通最小闭环,后面再逐步扩成完整体验系统。

View File

@@ -1,6 +1,6 @@
# 游戏结算层方案
> 文档版本v1.0
> 最后更新2026-04-02
> 最后更新2026-04-02 08:28:05
## 1. 目标
@@ -296,3 +296,4 @@ interface ResultSceneState {
第一阶段先做基础 summary后续再逐步接入文创奖励、奖章、排名和过场动画。

View File

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

View File

@@ -1,6 +1,6 @@
# 临时玩法讨论记录
> 文档版本v1.0
> 最后更新2026-04-02
> 最后更新2026-04-02 08:28:05
本文档用于临时记录以下讨论内容:
@@ -212,3 +212,4 @@
像贪吃蛇式玩法和区域拾金币玩法,都更像是“新增玩法插件”,而不是“推翻现有底座”。

View File

@@ -1,6 +1,6 @@
# 传感器接入待开发方案
> 文档版本v1.0
> 最后更新2026-04-02
> 最后更新2026-04-02 08:28:05
本文档用于整理当前项目后续可利用的传感器能力,分为:
@@ -573,3 +573,4 @@
**原始传感器进 `engine/sensor`,高级状态进 `telemetry`,上层只消费统一状态。**

View File

@@ -1,6 +1,6 @@
# 多人模拟器改造待开发文档
> 文档版本v1.0
> 最后更新2026-04-02
> 最后更新2026-04-02 08:28:05
本文档用于记录“公网模拟器支持多人开发/多人联调”的待开发方案。
@@ -333,3 +333,4 @@ type ClientSession = {
当前阶段不急着实现,但应作为后续多人开发与多人玩法联调的重要底座能力。

View File

@@ -1,5 +1,5 @@
> 文档版本v1.0
> 最后更新2026-04-02
> 最后更新2026-04-02 08:28:05
结果页会根据客户的要求不停的变换,用什么方案能实现这个需求,其实其他的弹出内容也都存在这个问题,样式,内容都时根据客户需求变化的,怎样一种方案设计比较好呢?
@@ -7,3 +7,4 @@

View File

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

View File

@@ -1,6 +1,6 @@
# 业务后端数据库初版方案
> 文档版本v1.0
> 最后更新2026-04-02
> 最后更新2026-04-02 08:28:05
## 1. 目标
@@ -698,3 +698,4 @@ H5 / 白标页面配置。
> PostgreSQL 存业务状态 + 版本化配置对象Go API 负责查询与发布编排,客户端继续消费发布后的运行态配置。

View File

@@ -1,6 +1,6 @@
# 全局规则与配置维度清单
> 文档版本v1.0
> 最后更新2026-04-02
> 最后更新2026-04-02 08:28:05
本文档用于定义当前系统中**跨玩法共用**的全局规则块和配置维度,作为后续所有玩法设计文档、配置文件设计、后台录入和联调的统一骨架。
@@ -408,3 +408,4 @@
- 后续扩展不会只长代码、不长文档

View File

@@ -1,6 +1,6 @@
# 配置频繁变更场景下的后台管理方案
> 文档版本v1.0
> 最后更新2026-04-02
> 最后更新2026-04-02 08:28:05
本文用于整理一套更适合“配置项变化很频繁”的后台方案。
@@ -409,3 +409,4 @@ Go 中间层先做最小装配功能。
**PostgreSQL 存“版本化对象 + jsonb 内容”Go 中间层做“装配 + 校验 + 发布”,客户端只读静态发布结果。**

View File

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

View File

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

View File

@@ -1,6 +1,6 @@
# 线上业务接入边界方案
> 文档版本v1.0
> 最后更新2026-04-02
> 最后更新2026-04-02 08:28:05
## 1. 目的
@@ -340,3 +340,4 @@ miniprogram/
线上系统负责“把用户送进正确的一局游戏”,配置系统负责“定义这局游戏是什么”。

View File

@@ -1,6 +1,6 @@
# 配置分级总表
> 文档版本v1.0
> 最后更新2026-04-02
> 最后更新2026-04-02 08:28:05
本文档用于把当前配置体系按“核心必需项 / 常用活动项 / 高级实验项”三层整理,作为后续后台配置设计、活动装配和字段治理的统一依据。
@@ -228,3 +228,4 @@
如果无法明确归类,默认先归入高级实验项,不急着开放到后台常规表单。

View File

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

View File

@@ -1,6 +1,6 @@
# 配置文档索引
> 文档版本v1.0
> 最后更新2026-04-02
> 最后更新2026-04-02 08:28:05
本文档用于汇总当前项目所有与配置设计、配置样例、配置管理相关的文档,并按“公共配置”和“按游戏分类”两层组织。
@@ -71,3 +71,4 @@
7. 对应玩法目录下的游戏配置项
8. 对应玩法的 `event/*.json` 样例

View File

@@ -1,6 +1,6 @@
# 配置选项字典(当前实现版)
> 文档版本v1.0
> 最后更新2026-04-02
> 最后更新2026-04-02 08:28:05
本文档用于整理 **当前客户端已经消费或已经预留承载的配置项**,作为事件配置、后台配置和联调时的统一参考。
@@ -1295,3 +1295,4 @@
- 后台可录入
- 客户端联调时有统一参考

View File

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

View File

@@ -1,6 +1,6 @@
# 平台能力与主体限制说明
> 文档版本v1.0
> 最后更新2026-04-02
> 最后更新2026-04-02 08:28:05
本文档用于记录当前项目在 **微信小程序平台能力** 上已经确认的边界,避免后续把环境或主体限制误判成代码问题。
@@ -147,3 +147,4 @@
- 待企业主体生效后,再统一回归验证

View File

@@ -1,6 +1,6 @@
# 模拟器多通道联调最小方案
> 文档版本v1.0
> 最后更新2026-04-02
> 最后更新2026-04-02 08:28:05
## 目标
@@ -145,3 +145,4 @@
如果后面真的需要这些,再升级到房间模型。

View File

@@ -1,6 +1,6 @@
# 模拟器控制面板重构方案
> 文档版本v1.0
> 最后更新2026-04-02
> 最后更新2026-04-02 08:28:05
## 目标
@@ -98,3 +98,4 @@
- 模拟器只保留一个工作台入口
- websocket 协议和调试逻辑继续复用

View File

@@ -1,6 +1,6 @@
# 模拟器调试日志方案
> 文档版本v1.0
> 最后更新2026-04-02
> 最后更新2026-04-02 08:28:05
## 目标
@@ -132,3 +132,4 @@
先把 `gps-logo` 调试链打通,再回头用模拟器日志查 logo 为什么不显示,比继续把临时字段堆在调试面板里更稳。

View File

@@ -1,6 +1,6 @@
# 罗盘问题排查记录
> 文档版本v1.0
> 最后更新2026-04-02
> 最后更新2026-04-02 08:28:05
## 背景
@@ -215,3 +215,4 @@
**在微信小程序里Android 罗盘监听的稳定性比 iOS 更脆;某些看似冗余的 `start()` 调用,实际是平台兼容补丁,不应该在没有真机回归的情况下清理。**

View File

@@ -1,6 +1,6 @@
# 调试文档索引
> 文档版本v1.0
> 最后更新2026-04-02
> 最后更新2026-04-02 08:28:05
这一组文档用于记录:
@@ -42,3 +42,4 @@
- 看“多人联调怎么隔离”,优先看模拟器多通道联调最小方案。
- 看“为什么罗盘以前坏过”,再去看罗盘问题记录。

View File

@@ -1,6 +1,6 @@
# 原生与 H5 Bridge 协议草案
> 文档版本v1.0
> 最后更新2026-04-02
> 最后更新2026-04-02 08:28:05
本文档定义当前项目中 **原生小程序****H5 定制内容页** 之间的基础通信协议。
@@ -384,3 +384,4 @@ Bridge 的第一阶段目标,不是做成万能总线,而是:
这 5 条做稳,就足够支撑第一波客户定制需求。

View File

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

View File

@@ -1,6 +1,6 @@
# 多线程联调协作方式
> 文档版本v1.0
> 最后更新2026-04-02
> 最后更新2026-04-02 08:28:05
## 目标
@@ -320,3 +320,4 @@
而不是先把协作体系做复杂。

View File

@@ -1,6 +1,6 @@
# 故障恢复机制
> 文档版本v1.0
> 最后更新2026-04-02
> 最后更新2026-04-02 08:28:05
本文档用于说明当前客户端在“游戏进行中非正常退出”场景下的恢复机制。
@@ -241,3 +241,4 @@
**保证玩家在异常退出后可以继续当前对局,但不承担恢复所有临时界面状态。**

View File

@@ -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` 管可运行样例,客户端解析配置后交给规则引擎执行,并由轻量恢复层处理异常退出后的续局。**

View File

@@ -1,6 +1,6 @@
# 新玩法建议方案
> 文档版本v1.0
> 最后更新2026-04-02
> 最后更新2026-04-02 08:28:05
本文档用于整理当前阶段值得考虑的新游戏玩法方向,重点回答以下问题:
@@ -444,3 +444,4 @@
如果只优先选一个最值得推进的新玩法,建议先做:`幽灵追逐赛`

View File

@@ -1,6 +1,6 @@
# 玩法设计文档模板
> 文档版本v1.0
> 最后更新2026-04-02
> 最后更新2026-04-02 08:28:05
本文档用于定义后续所有玩法设计文档的**统一写法**,保证玩法规则、全局规则块、配置落点和最小样例能够一起沉淀,为后续 JSON 配置管理和后台装配提供稳定输入。
@@ -413,3 +413,4 @@

View File

@@ -1,6 +1,6 @@
# 程序默认规则基线
> 文档版本v1.0
> 最后更新2026-04-02
> 最后更新2026-04-02 08:28:05
本文档用于定义当前客户端在**不依赖活动配置细项**时,程序层应该内建的默认规则。
@@ -441,3 +441,4 @@ HUD 属于公共程序能力,不属于某个玩法专属实现。
当前阶段应以这份文档作为**程序默认能力基线**先把最小流程、弹层职责、HUD 结构和距离反馈定死,再决定哪些内容值得进入配置层。

View File

@@ -1,6 +1,6 @@
# 运行时编译层总表
> 文档版本v1.0
> 最后更新2026-04-02
> 最后更新2026-04-02 08:28:05
本文档用于定义当前项目推荐的“运行时编译层”结构,也就是把系统默认值、玩法默认值、活动配置、玩家设置编译成统一运行时 profile 的中间层。
@@ -247,3 +247,4 @@
这样配置越多,系统越不容易乱;后续后台做复杂了,也还是有一层中间结构兜住。

View File

@@ -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 只提示“请选择目标点”

View File

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

View File

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

View File

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

View File

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

View File

@@ -1,6 +1,6 @@
# 积分赛规则说明文档
> 文档版本v1.0
> 最后更新2026-04-02
> 最后更新2026-04-02 08:28:05
本文档用于定义 `score-o` 在**最小模板**下的系统默认规则,作为后续实现、联调和配置扩展的共同基线。
@@ -320,3 +320,4 @@
- 积分赛样例配置和实现验收时的基准口径

View File

@@ -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 秒题卡
- 答题时比赛继续计时

View File

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

View File

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

View File

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

View File

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

View File

@@ -1,6 +1,6 @@
# 顺序打点规则说明文档
> 文档版本v1.0
> 最后更新2026-04-02
> 最后更新2026-04-02 08:28:05
本文档用于定义 `classic-sequential` 在**最小模板**下的系统默认规则,作为后续实现、联调和配置扩展的共同基线。
@@ -304,3 +304,4 @@
- 顺序赛样例配置和实现验收时的基准口径

View File

@@ -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
这条路径最轻、最稳,也最符合你现在“先不正式上线”的目标。

View File

@@ -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 <consumer-token> -topics t
这也是当前最省风险的组合。

View File

@@ -1,6 +1,6 @@
# 实时设备数据网关最终方案
> 文档版本v1.0
> 最后更新2026-04-02
> 最后更新2026-04-02 08:28:05
本文档用于收敛当前关于 GPS 模拟、中转、监控、规则判定、回放、通知分发等讨论,给出一版可直接进入实现设计的最终方案。
@@ -880,3 +880,4 @@ Business Server
同时又能把实时性能放在系统设计的首位。

View File

@@ -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 插件

View File

@@ -1,6 +1,6 @@
# 实时网关协议草案
> 文档版本v1.0
> 最后更新2026-04-02
> 最后更新2026-04-02 08:28:05
本文档描述 `realtime-gateway` 第一版协议草案,范围只覆盖 MVP。
@@ -347,3 +347,4 @@
- `auth_refresh`

View File

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

View File

@@ -1,6 +1,6 @@
# 沟通协作建议
> 文档版本v1.0
> 最后更新2026-04-02
> 最后更新2026-04-02 08:28:05
这份文档用于约定后续在 UI 微调、交互细改、规则补充时,怎样沟通最有效,减少来回修改。
@@ -99,3 +99,4 @@
**需求把边界说死,修改一次只动一层。**

View File

@@ -1,6 +1,6 @@
# GPS 点动画系统方案
> 文档版本v1.0
> 最后更新2026-04-02
> 最后更新2026-04-02 08:28:05
## 目标
@@ -213,3 +213,4 @@ GPS 点动画不应该做成单一固定动画,而应该做成:
这 4 种状态的程序化动画跑通,再决定后续是否继续开放更细粒度配置。

View File

@@ -1,6 +1,6 @@
# GPS 点样式系统方案
> 文档版本v1.0
> 最后更新2026-04-02
> 最后更新2026-04-02 08:28:05
## 目标
@@ -116,3 +116,4 @@ GPS 点应被视为独立样式系统,而不是固定蓝点。
做稳定,再逐步承接商业品牌化定制。

View File

@@ -1,6 +1,6 @@
# 轨迹可视化方案
> 文档版本v1.0
> 最后更新2026-04-02
> 最后更新2026-04-02 08:28:05
本文档定义用户轨迹的显示模式、默认策略与配置结构。
@@ -88,3 +88,4 @@
- `standard / lite` 下自动降级 glow

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