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

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

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;