完善活动运营域与联调标准化

This commit is contained in:
2026-04-03 13:11:41 +08:00
parent 0e28f70bad
commit 129ea935db
56 changed files with 11004 additions and 196 deletions

102
b2f.md
View File

@@ -1,6 +1,6 @@
# b2f # b2f
> 文档版本v1.3 > 文档版本v1.7
> 最后更新2026-04-02 15:25:40 > 最后更新2026-04-03 12:36:15
说明: 说明:
@@ -103,6 +103,53 @@
## 已确认 ## 已确认
### B2F-019
- 时间2026-04-03 12:36:15
- 谁提的backend
- 当前事实:
- backend 已完成活动运营域第二阶段第四刀的后台最小实现:
- `presentation import`
- `event 默认 active 绑定`
- `publish` 默认继承
- 本刀没有改前端当前稳定消费字段语义:
- `resolvedRelease`
- `business`
- `variant`
- `runtime`
- `presentation`
- `contentBundle`
- 这次新增能力主要影响后台运营链和发布默认行为,不要求 frontend 立即改接入
- 需要对方确认什么:
-
- 是否已解决:是
### B2F-018
- 时间2026-04-03 11:22:50
- 谁提的backend
- 当前事实:
- backend 已进入活动运营域第二阶段第二刀
- 当前客户端可消费新增摘要:
- `GET /events/{eventPublicID}` 返回:
- `currentPresentation`
- `currentContentBundle`
- `GET /events/{eventPublicID}/play` 返回:
- `currentPresentation`
- `currentContentBundle`
- `POST /events/{eventPublicID}/launch` 返回:
- `launch.presentation`
- `launch.contentBundle`
- 当前字段只做摘要透出,不下发复杂 schema
- 当前旧字段保持完全兼容:
- `resolvedRelease`
- `business`
- `variant`
- `runtime`
- 需要对方确认什么:
- frontend 后续如要消费活动运营域摘要,先以这些新增摘要字段为准
- 是否已解决:是
### B2F-003 ### B2F-003
- 时间2026-04-02 - 时间2026-04-02
@@ -360,6 +407,57 @@
- -
- 是否已解决:是 - 是否已解决:是
### B2F-020
- 时间2026-04-03 09:43:20
- 谁提的backend
- 当前事实:
- backend 已在保持旧字段不变的前提下,为 `launch` 新增兼容字段:
- `launch.runtime`
- 当前最小字段包括:
- `runtimeBindingId`
- `placeId`
- `mapId`
- `tileReleaseId`
- `courseSetId`
- `courseVariantId`
- 这是一组新增字段,不替代也不改变现有:
- `resolvedRelease`
- `business`
- `variant`
- frontend 当前可以忽略该字段,也可以开始做观测和日志透出
- 需要对方确认什么:
-
- 是否已解决:是
### B2F-021
- 时间2026-04-03 12:14:21
- 谁提的backend
- 当前事实:
- backend 已完成活动运营域第二阶段第三刀第一版
- 当前活动运营摘要已统一补齐最小字段:
- `currentPresentation.templateKey`
- `currentPresentation.version`
- `currentContentBundle.bundleType`
- `currentContentBundle.version`
- `launch.presentation.templateKey`
- `launch.presentation.version`
- `launch.contentBundle.bundleType`
- `launch.contentBundle.version`
- 上述字段当前已在以下接口可用:
- `GET /events/{eventPublicID}`
- `GET /events/{eventPublicID}/play`
- `POST /events/{eventPublicID}/launch`
- 旧字段继续完全兼容:
- `resolvedRelease`
- `business`
- `variant`
- `runtime`
- 需要对方确认什么:
- frontend 如果开始消费活动运营摘要细项,请优先读取新增的 `templateKey / version / bundleType`
- 是否已解决:是
--- ---
## 下一步 ## 下一步

443
b2t.md Normal file
View File

@@ -0,0 +1,443 @@
# B2T 协作清单
> 文档版本v1.11
> 最后更新2026-04-03 13:04:32
说明:
- 本文件由 backend 维护,写给总控线程
- 只写事实和请求
- 不写长讨论稿
- 每条固定包含:时间、谁提的、当前事实、需要对方确认什么、是否已解决
---
## 待确认
### B2T-009
- 时间2026-04-03 08:52:11
- 谁提的backend
- 当前事实:
- 总控线程在 [t2b.md](D:/dev/cmr-mini/t2b.md) v1.1 中,已确认 backend 可按增量演进推进第一阶段生产骨架
- backend 准备按已确认顺序开工:
- `Place`
- `MapAsset`
- `TileRelease`
- `CourseSource`
- `CourseSet`
- `CourseVariant`
- `MapRuntimeBinding`
- `EventPresentation``ContentBundle` 先保留对象语义,第二阶段补完整落库
- 需要对方确认什么:
- 无新的确认项;本条仅保留为“准备开工”记录
- 是否已解决:是
---
## 已确认
### B2T-001
- 时间2026-04-03 08:52:11
- 谁提的backend
- 当前事实:
- 总控线程已确认 backend 采用增量演进方式推进
- 第一阶段不要求一次性推翻当前已稳定联调的:
- `Event`
- `EventRelease`
- `Session`
主链
- 需要对方确认什么:
-
- 是否已解决:是
### B2T-002
- 时间2026-04-03 08:52:11
- 谁提的backend
- 当前事实:
- 总控线程已确认第一阶段优先落库对象顺序:
- `Place`
- `MapAsset`
- `TileRelease`
- `CourseSource`
- `CourseSet`
- `CourseVariant`
- `MapRuntimeBinding`
- backend 接受这条顺序,后续按此推进
- 需要对方确认什么:
-
- 是否已解决:是
### B2T-003
- 时间2026-04-03 08:52:11
- 谁提的backend
- 当前事实:
- 总控线程已确认 `launch` 采用两阶段兼容
- 第一阶段保留当前稳定联调字段:
- `resolvedRelease`
- `business`
- `variant`
- 第二阶段再补完整运行对象字段:
- `placeId`
- `mapId`
- `tileReleaseId`
- `courseVariantId`
- `eventReleaseId`
- 需要对方确认什么:
-
- 是否已解决:是
### B2T-004
- 时间2026-04-03 08:52:11
- 谁提的backend
- 当前事实:
- backend 认可总控线程的核心边界:
- 客户端最终只消费发布产物
- 不再直接消费原始 KML
- 不再直接消费地图原始资产
- 这与 backend 当前已稳定的:
- `EventRelease`
- `launch -> resolvedRelease`
- `session -> result`
主链一致
- 需要对方确认什么:
-
- 是否已解决:是
### B2T-005
- 时间2026-04-03 08:52:11
- 谁提的backend
- 当前事实:
- backend 认可总控线程提出的两域拆分:
- 地图运行域
- 活动运营域
- `CourseVariant` 作为最终运行事实,也与当前多赛道第一阶段契约一致
- 需要对方确认什么:
-
- 是否已解决:是
---
## 阻塞
- 当前无新增阻塞
---
## 已完成
### B2T-019
- 时间2026-04-03 13:04:32
- 谁提的backend
- 当前事实:
- backend 已把当前联调环境补成“一键测试环境”
- `/dev/bootstrap-demo` 不再只准备:
- `tenant`
- `channel`
- `event`
- `release`
- `source`
- `build`
- `card`
- 现在还会一并准备并回填:
- `place`
- `map asset`
- `tile release`
- `course source`
- `course set`
- `course variant`
- `runtime binding`
- workbench 的:
- `Bootstrap Demo`
- `一键补齐 Runtime 并发布`
已可从空白状态直接跑完整测试链
- workbench 日志现在会输出:
- 分步执行日志
- 真实错误消息
- stack
- 最后一次 curl
- 最终预期判定
- 需要对方确认什么:
-
- 是否已解决:是
### B2T-018
- 时间2026-04-03 12:36:15
- 谁提的backend
- 当前事实:
- backend 已完成“活动运营域第二阶段第四刀”最小实现
- 已新增:
- `POST /admin/events/{eventPublicID}/presentations/import`
- `POST /admin/events/{eventPublicID}/defaults`
- `events` 已新增默认 active 绑定列:
- `current_presentation_id`
- `current_content_bundle_id`
- `current_runtime_binding_id`
- `publish` 在未显式传入:
- `runtimeBindingId`
- `presentationId`
- `contentBundleId`
时,会优先继承 event 默认 active 绑定
- workbench 已补最小验证入口:
- `Import Presentation`
- `Save Event Defaults`
- `Publish Build` 空参继承验证
- 需要对方确认什么:
-
- 是否已解决:是
### B2T-007
- 时间2026-04-03 08:52:11
- 谁提的backend
- 当前事实:
- backend 已具备当前稳定主链:
- 统一登录
- entry/home
- event play
- launch
- session start / finish
- result / history
- backend 已具备:
- 配置 source / build / release
- 最小后台资源对象
- 多赛道第一阶段最小契约
- 需要对方确认什么:
-
- 是否已解决:是
### B2T-011
- 时间2026-04-03 09:09:07
- 谁提的backend
- 当前事实:
- backend 已开始第一阶段生产骨架实施
- `0008_production_skeleton.sql` 已落库到 `cmr20260401`
- 当前已新增并可用的后台生产接口覆盖:
- `Place`
- `MapAsset`
- `TileRelease`
- `CourseSource`
- `CourseSet`
- `CourseVariant`
- `MapRuntimeBinding`
- 需要对方确认什么:
-
- 是否已解决:是
### B2T-012
- 时间2026-04-03 09:23:03
- 谁提的backend
- 当前事实:
- backend 已按 [t2b.md](D:/dev/cmr-mini/t2b.md) 第 5 步要求,把第一阶段生产骨架对象接入 `/dev/workbench`
- 当前联调台已覆盖:
- `Place`
- `MapAsset`
- `TileRelease`
- `CourseSource`
- `CourseSet`
- `CourseVariant`
- `MapRuntimeBinding`
- 当前 workbench 只做:
- `list`
- `create`
- `detail`
- `binding`
- 需要对方确认什么:
-
- 是否已解决:是
### B2T-013
- 时间2026-04-03 09:27:18
- 谁提的backend
- 当前事实:
- backend 已整理第一阶段生产骨架最小操作顺序
- 当前推荐 workbench 联调路径为:
- `Place`
- `MapAsset`
- `TileRelease`
- `CourseSource`
- `CourseSet`
- `CourseVariant`
- `MapRuntimeBinding`
- 该顺序已写入 [开发说明.md](D:/dev/cmr-mini/backend/docs/开发说明.md)
- 需要对方确认什么:
-
- 是否已解决:是
### B2T-015
- 时间2026-04-03 09:43:20
- 谁提的backend
- 当前事实:
- backend 已完成“第三刀最小接线”第一版:
- `MapRuntimeBinding -> EventRelease`
- `launch.runtime` 兼容新增
- 当前新增能力:
- `GET /admin/releases/{releasePublicID}`
- `POST /admin/releases/{releasePublicID}/runtime-binding`
- 当前 `launch` 在保持旧字段不变的前提下,新增:
- `launch.runtime.runtimeBindingId`
- `launch.runtime.placeId`
- `launch.runtime.mapId`
- `launch.runtime.tileReleaseId`
- `launch.runtime.courseSetId`
- `launch.runtime.courseVariantId`
- `/dev/workbench` 已补最小验证入口:
- `Get Release`
- `Bind Runtime`
- 需要对方确认什么:
-
- 是否已解决:是
---
## 下一步
### B2T-010
- 时间2026-04-03 08:52:11
- 谁提的backend
- 当前事实:
- backend 已完成第一阶段生产骨架落库、最小模型接线和 workbench 联调台接入
- `EventPresentation``ContentBundle` 仍先在文档与接口边界保留语义
- 需要对方确认什么:
-
- 是否已解决:是
### B2T-014
- 时间2026-04-03 09:27:18
- 谁提的backend
- 当前事实:
- backend 下一步建议开始做“第一阶段对象与现有 Event/Release 的最小接线”
- 重点会围绕:
- `MapRuntimeBinding -> EventRelease`
- 运行对象如何逐步进入 `launch`
- 保持当前两阶段兼容不破坏前端稳定链
- 需要对方确认什么:
-
- 是否已解决:是
### B2T-016
- 时间2026-04-03 09:43:20
- 谁提的backend
- 当前事实:
- 当前 release 与 runtime binding 的挂接已可单独验证
- 第四刀目标是把 `runtimeBindingId` 直接接进 publish 流,减少一次手工挂接
- 需要对方确认什么:
-
- 是否已解决:是
### B2T-017
- 时间2026-04-03 10:46:00
- 谁提的backend
- 当前事实:
- backend 已完成第四刀第一版:
- `POST /admin/builds/{buildID}/publish` 支持可选 `runtimeBindingId`
- `POST /dev/config-builds/publish` 支持可选 `runtimeBindingId`
- 发布成功后返回 `runtime`
- `/dev/workbench` 发布区已支持直接填写 `Runtime Binding ID`
- 旧的“先 publish再 bind runtime”路径继续保留
- 需要对方确认什么:
-
- 是否已解决:是
### B2T-018
- 时间2026-04-03 11:02:42
- 谁提的backend
- 当前事实:
- backend 已完成活动运营域第二阶段第一版:
- `0009_event_ops_phase2.sql` 已落库到 `cmr20260401`
- 新增:
- `event_presentations`
- `content_bundles`
- `event_releases` 已明确支持绑定:
- `presentation_id`
- `content_bundle_id`
- `runtime_binding_id`
- 当前新增后台接口:
- `GET/POST /admin/events/{eventPublicID}/presentations`
- `GET /admin/presentations/{presentationPublicID}`
- `GET/POST /admin/events/{eventPublicID}/content-bundles`
- `GET /admin/content-bundles/{contentBundlePublicID}`
- `publish` 当前已支持可选直接挂接:
- `runtimeBindingId`
- `presentationId`
- `contentBundleId`
- `/dev/workbench` 已补最小验证入口:
- `Create Presentation`
- `Create Bundle`
- 发布区填写 `Presentation ID / Content Bundle ID / Runtime Binding ID`
- 需要对方确认什么:
-
- 是否已解决:是
### B2T-019
- 时间2026-04-03 11:22:50
- 谁提的backend
- 当前事实:
- backend 已完成“活动运营域第二阶段第二刀”第一版:
- `GET /events/{eventPublicID}` 透出:
- `currentPresentation`
- `currentContentBundle`
- `GET /events/{eventPublicID}/play` 透出:
- `currentPresentation`
- `currentContentBundle`
- `POST /events/{eventPublicID}/launch` 新增兼容摘要:
- `launch.presentation`
- `launch.contentBundle`
- `publish` 当前在未显式传入:
- `presentationId`
- `contentBundleId`
时,会优先按 event 当前默认的 active 配置自动补齐
- 旧字段和旧语义保持不变:
- `resolvedRelease`
- `business`
- `variant`
- `runtime`
- 需要对方确认什么:
-
- 是否已解决:是
### B2T-020
- 时间2026-04-03 12:14:21
- 谁提的backend
- 当前事实:
- backend 已完成“活动运营域第二阶段第三刀”第一版:
- `event detail / play / launch / release detail` 已统一补齐活动运营摘要
- `presentation` 摘要当前最少带:
- `presentationId`
- `templateKey`
- `version`
- `contentBundle` 摘要当前最少带:
- `contentBundleId`
- `bundleType`
- `version`
- backend 已新增最小导入入口:
- `POST /admin/events/{eventPublicID}/content-bundles/import`
- 当前导入入口先只记录:
- `bundleType`
- `sourceType`
- `manifestUrl`
- `version`
- `assetManifest`
- `/dev/workbench` 已补:
- `Import Bundle`
- API 目录中的导入接口说明
- 需要对方确认什么:
-
- 是否已解决:是

View File

@@ -1,6 +1,6 @@
# Backend # Backend
> 文档版本v1.1 > 文档版本v1.11
> 最后更新2026-04-02 09:35:44 > 最后更新2026-04-03 13:04:32
这套后端现在已经能支撑一条完整主链: 这套后端现在已经能支撑一条完整主链:
@@ -34,7 +34,7 @@
```powershell ```powershell
cd D:\dev\cmr-mini\backend cd D:\dev\cmr-mini\backend
go run .\cmd\api .\start-backend.ps1
``` ```
## 当前重点 ## 当前重点
@@ -45,9 +45,23 @@ go run .\cmd\api
- 配置驱动启动:`/events/{id}/play``/events/{id}/launch` - 配置驱动启动:`/events/{id}/play``/events/{id}/launch`
- 局生命周期:`start / finish / detail` - 局生命周期:`start / finish / detail`
- 局后结果:`/sessions/{id}/result``/me/results` - 局后结果:`/sessions/{id}/result``/me/results`
- 第一阶段生产骨架:`places / map-assets / tile-releases / course-sources / course-sets / course-variants / runtime-bindings`
- 第三刀最小接线:`runtimeBinding -> eventRelease -> launch.runtime`
- 第四刀发布闭环:`publish(runtimeBindingId) -> eventRelease -> launch.runtime`
- 活动运营域第二阶段:`event_presentations / content_bundles / event_release -> presentation,bundle,runtime`
- 活动运营域第二阶段第二刀:`event detail / event play / launch -> presentation,bundle 摘要`
- 活动运营域第二阶段第三刀:`release 摘要闭环 + content bundle import`
- 活动运营域第二阶段第四刀:`presentation import + event 默认 active 绑定 + publish 默认继承`
- 开发工作台:`/dev/workbench` - 开发工作台:`/dev/workbench`
- 用户主链调试 - 用户主链调试
- 资源对象与 Event 组装调试 - 资源对象与 Event 组装调试
- Build / Publish / Rollback 调试 - Build / Publish / Rollback 调试
- Release / RuntimeBinding 最小挂接验证
- Event Presentation / Content Bundle 最小挂接验证
- Content Bundle Import 最小导入验证
- Presentation Import / Event 默认绑定 / Publish 默认继承验证
- Runtime 自动补齐 + 默认绑定发布一键验证
- Bootstrap Demo 自动回填最小生产骨架 ID
- 一键测试环境:可从空白状态自动准备 demo event、source/build/release、presentation、content bundle、place、map asset、tile release、course source、course set、course variant、runtime binding并输出逐步日志与预期判定

View File

@@ -1,6 +1,6 @@
# 后台管理最小方案 # 后台管理最小方案
> 文档版本v1.0 > 文档版本v1.1
> 最后更新2026-04-02 09:01:17 > 最后更新2026-04-03 11:02:42
## 1. 目标 ## 1. 目标
@@ -132,6 +132,8 @@
- 当前选用资源包版本 - 当前选用资源包版本
- 当前玩法模式 - 当前玩法模式
- 少量覆盖项 - 少量覆盖项
- 展示定义(`EventPresentation`
- 内容包(`ContentBundle`
第一版只开放少量覆盖项: 第一版只开放少量覆盖项:
@@ -160,6 +162,7 @@
- build 状态 - build 状态
- release 列表 - release 列表
- 当前生效 release - 当前生效 release
- 当前绑定的 `presentation / bundle / runtime`
- 发布人 - 发布人
- 发布时间 - 发布时间
@@ -169,6 +172,7 @@
- 查看 build 产物 - 查看 build 产物
- 发布 build - 发布 build
- 回滚当前 release - 回滚当前 release
- 查看 release 当前绑定的 `presentation / bundle / runtime`
## 4. 后台第一版页面建议 ## 4. 后台第一版页面建议
@@ -183,6 +187,14 @@
这 6 页够把“资源录入 -> Event 组装 -> 发布 -> launch”跑通。 这 6 页够把“资源录入 -> Event 组装 -> 发布 -> launch”跑通。
补充:
- 当前第二阶段已经把 `EventPresentation``ContentBundle` 收成正式最小对象
- `EventRelease` 现在允许同时绑定:
- `presentation`
- `content bundle`
- `runtime binding`
## 5. 对象模型建议 ## 5. 对象模型建议
后台第一版建议围绕这些对象展开: 后台第一版建议围绕这些对象展开:

View File

@@ -1,6 +1,6 @@
# 开发说明 # 开发说明
> 文档版本v1.1 > 文档版本v1.12
> 最后更新2026-04-02 09:35:44 > 最后更新2026-04-03 13:04:32
## 1. 环境变量 ## 1. 环境变量
@@ -29,7 +29,7 @@
```powershell ```powershell
cd D:\dev\cmr-mini\backend cd D:\dev\cmr-mini\backend
go run .\cmd\api .\start-backend.ps1
``` ```
如果你想固定跑开发工作台常用端口 `18090`,直接执行: 如果你想固定跑开发工作台常用端口 `18090`,直接执行:
@@ -55,6 +55,55 @@ cd D:\dev\cmr-mini\backend
- 用户主链:`bootstrap -> auth -> entry/home -> event play/launch -> session -> result` - 用户主链:`bootstrap -> auth -> entry/home -> event play/launch -> session -> result`
- 后台运营链:`maps/playfields/resource-packs -> admin event source -> build -> publish -> rollback` - 后台运营链:`maps/playfields/resource-packs -> admin event source -> build -> publish -> rollback`
- 第一阶段生产骨架联调台:`places -> map-assets -> tile-releases -> course-sources -> course-sets -> course-variants -> runtime-bindings`
- 第三刀最小接线验证:`runtimeBinding -> release -> launch.runtime`
- 第四刀发布闭环验证:`runtimeBinding -> publish(runtimeBindingId) -> release -> launch.runtime`
- 活动运营域第二阶段验证:`presentation -> content bundle -> publish(presentationId, contentBundleId, runtimeBindingId) -> release`
- 活动运营域第二阶段第二刀验证:`event detail / play / launch -> presentation + content bundle 摘要`
- 活动运营域第二阶段第三刀验证:`release 摘要闭环 + content bundle import`
- 活动运营域第二阶段第四刀验证:`presentation import -> event 默认 active 绑定 -> publish 空参继承`
- workbench 一键验证增强:`一键默认绑定发布``一键补齐 Runtime 并发布`
- `/dev/bootstrap-demo` 现在也会回填最小生产骨架:`place / map asset / tile release / course source / course set / course variant / runtime binding`
### 2.1 当前推荐验证方式
如果目标是验证“从测试数据准备到 release 继承是否完整”,优先使用 workbench 的一键流,而不是手工逐个点按钮。
当前推荐顺序:
1. `Bootstrap Demo`
2. `一键补齐 Runtime 并发布`
当前这条一键链会自动完成:
- demo event / source / build / release 准备
- presentation 导入
- content bundle 导入
- event 默认 active 绑定保存
- 最小生产骨架准备:
- `place`
- `map asset`
- `tile release`
- `course source`
- `course set`
- `course variant`
- `runtime binding`
- publish
- release 回读校验
当前日志能力:
- 每一步都会写到“响应日志”
- 失败时会直接输出:
- 错误消息
- stack
- 最后一次 curl
- 成功时“预期结果”面板会直接给出:
- `Release ID`
- `Presentation`
- `Content Bundle`
- `Runtime Binding`
- `判定`
## 3. 当前开发约定 ## 3. 当前开发约定
@@ -134,6 +183,7 @@ Redis 后面只在需要性能优化、限流或短期票据缓存时再接。
- 入口解析 - 入口解析
- 首页聚合 - 首页聚合
- event play - event play
- 第一阶段生产骨架对象
- 配置导入、preview build、publish build - 配置导入、preview build、publish build
- launch - launch
- session start / finish - session start / finish
@@ -144,6 +194,11 @@ Redis 后面只在需要性能优化、限流或短期票据缓存时再接。
- `publish build` 现在会真实上传 `manifest.json``asset-index.json` 到 OSS - `publish build` 现在会真实上传 `manifest.json``asset-index.json` 到 OSS
- 如果上传失败,接口会直接报错,不再出现“数据库里已有 release但 OSS 上没有对象”的假成功 - 如果上传失败,接口会直接报错,不再出现“数据库里已有 release但 OSS 上没有对象”的假成功
- `Save Event Defaults` 会把当前 event 的默认 active 绑定写入:
- `currentPresentationId`
- `currentContentBundleId`
- `currentRuntimeBindingId`
- 之后 `Publish Build` 如果不显式填写这三项,会优先继承 event 默认 active 绑定
并且支持: 并且支持:
@@ -152,6 +207,29 @@ Redis 后面只在需要性能优化、限流或短期票据缓存时再接。
- curl 导出 - curl 导出
- request history - request history
当前第一阶段生产骨架联调台只做:
- `list`
- `create`
- `detail`
- `binding`
明确不做:
- 正式后台 UI
- `edit`
- `delete`
- `batch`
- 审核流
活动运营域第二阶段当前也只做最小动作:
- `list`
- `create`
- `detail`
- `publish 绑定`
- `import`
## 6. 当前推荐联调顺序 ## 6. 当前推荐联调顺序
### 场景一:小程序快速进入 ### 场景一:小程序快速进入
@@ -190,6 +268,164 @@ Redis 后面只在需要性能优化、限流或短期票据缓存时再接。
5. `events/{id}` 5. `events/{id}`
6. `events/{id}/launch` 6. `events/{id}/launch`
### 场景五:第一阶段生产骨架最小闭环
`/dev/workbench``后台运营` 模式中,按下面顺序操作:
1. `List Places``Create Place`
2. 在该 `Place``Create Map Asset`
3. 在该 `MapAsset``Create Tile Release`
4. `Create Course Source`
5. 在该 `MapAsset``Create Course Set`
6. 在该 `CourseSet``Create Variant`
7. `Create Runtime Binding`
成功后应能拿到这些 ID
- `placeId`
- `mapAssetId`
- `tileReleaseId`
- `courseSourceId`
- `courseSetId`
- `courseVariantId`
- `runtimeBindingId`
建议第一次联调时用这组最小规则:
- `Place` 先建 1 个
- 每个 `Place` 先只建 1 个 `MapAsset`
- 每个 `MapAsset` 先只建 1 个 `TileRelease`
- 每个 `CourseSet` 先只建 1 个默认 `CourseVariant`
- `RuntimeBinding` 先只绑定当前正在验证的 `Event`
这条链当前只验证对象关系闭环,不验证:
- 发布链切换
- `launch` 返回运行对象字段
- `EventPresentation`
- `ContentBundle`
### 场景六:第三刀最小接线验证
`/dev/workbench``后台运营` 模式中,先完成“场景五”,再按下面顺序操作:
1. `Get Pipeline`
2. 确认当前 `Release ID`
3. 填或复用 `Runtime Binding ID`
4. `Bind Runtime`
5. `Get Release`
6. 切回 `前台联调`
7. 对同一个 `event` 执行 `Launch`
### 场景七:活动运营域第二阶段最小闭环
`/dev/workbench``后台运营` 模式中,按下面顺序操作:
1. `Get Event`
2. `Create Presentation`
3. `Create Bundle`
4. `Assemble Source`
5. `Build Source`
6. 在发布区填:
- `Runtime Binding ID`
- `Presentation ID`
- `Content Bundle ID`
7. `Publish Build`
8. `Get Release`
成功后应能在 release 返回中看到:
- `runtime`
- `presentation`
- `contentBundle`
并且这 3 类绑定当前都已固化到 `event_release`
成功后应能看到:
- `GET /admin/releases/{releasePublicID}` 返回 `runtime`
- `POST /events/{eventPublicID}/launch` 返回 `launch.runtime`
当前阶段的约束是:
- 只新增 `runtime` 字段块
- 不改旧的:
- `resolvedRelease`
- `business`
- `variant`
- release 如果没挂 `runtimeBindingId`,则 `launch.runtime` 为空
### 场景八:活动运营域第二阶段第三刀验证
`/dev/workbench``后台运营` 模式中,先完成“场景七”,再按下面顺序操作:
1. `Create Presentation` 或直接复用现有 `Presentation ID`
2. `Import Bundle`
3. `Get Bundle`
4. `Get Pipeline`
5. `Publish Build`
6. `Get Release`
7. 切回 `前台联调`
8. `Event Detail`
9. `Event Play`
10. `Launch`
成功后应能同时看到这三组摘要:
- `release.presentation.templateKey / version`
- `release.contentBundle.bundleType / version`
- `release.runtime.placeId / mapId / tileReleaseId / courseVariantId`
同时客户端消费侧应保持一致:
- `GET /events/{eventPublicID}`
- `GET /events/{eventPublicID}/play`
- `POST /events/{eventPublicID}/launch`
当前 Content Bundle Import 只做统一导入入口,不做复杂资源平台:
- 输入:
- `title`
- `bundleType`
- `sourceType`
- `manifestUrl`
- `version`
- `assetManifest`
- 输出:
- `bundleId`
- `bundleType`
- `version`
- `assetManifest`
- `status`
### 场景七:第四刀发布闭环验证
`/dev/workbench``后台运营` 模式中,先完成“场景五”,再按下面顺序操作:
1. `Create Runtime Binding`
2. `Get Pipeline`
3. 确认 `Build ID`
4. 在发布区填 `Runtime Binding ID`
5. `Publish Build`
6. `Get Release`
7. 切回 `前台联调`
8. 对同一个 `event` 执行 `Launch`
成功后应能看到:
- `POST /admin/builds/{buildID}/publish` 返回带 `runtime`
- `GET /admin/releases/{releasePublicID}` 返回同一条 `runtime`
- `POST /events/{eventPublicID}/launch` 返回同一条 `launch.runtime`
当前第四刀的兼容要求是:
- 旧的“先 `publish`,再 `bind runtime`”路径继续可用
- 新的“`publish` 时直接传 `runtimeBindingId`”优先推荐
- 不修改旧的:
- `resolvedRelease`
- `business`
- `variant`
## 7. 当前后续开发建议 ## 7. 当前后续开发建议
文档整理完之后,后面建议按这个顺序继续: 文档整理完之后,后面建议按这个顺序继续:

View File

@@ -1,6 +1,6 @@
# API 清单 # API 清单
> 文档版本v1.1 > 文档版本v1.8
> 最后更新2026-04-02 11:05:32 > 最后更新2026-04-03 12:36:15
本文档只记录当前 backend 已实现接口,不写未来规划接口。 本文档只记录当前 backend 已实现接口,不写未来规划接口。
@@ -140,6 +140,18 @@
- `event` - `event`
- `release` - `release`
- `resolvedRelease` - `resolvedRelease`
- `runtime`
- `currentPresentation`
- `currentContentBundle`
当前摘要字段最少包括:
- `currentPresentation.presentationId`
- `currentPresentation.templateKey`
- `currentPresentation.version`
- `currentContentBundle.contentBundleId`
- `currentContentBundle.bundleType`
- `currentContentBundle.version`
### `GET /events/{eventPublicID}/play` ### `GET /events/{eventPublicID}/play`
@@ -156,6 +168,9 @@
- `event` - `event`
- `release` - `release`
- `resolvedRelease` - `resolvedRelease`
- `runtime`
- `currentPresentation`
- `currentContentBundle`
- `play.assignmentMode` - `play.assignmentMode`
- `play.courseVariants` - `play.courseVariants`
- `play.canLaunch` - `play.canLaunch`
@@ -164,6 +179,15 @@
- `play.ongoingSession` - `play.ongoingSession`
- `play.recentSession` - `play.recentSession`
当前摘要字段最少包括:
- `currentPresentation.presentationId`
- `currentPresentation.templateKey`
- `currentPresentation.version`
- `currentContentBundle.contentBundleId`
- `currentContentBundle.bundleType`
- `currentContentBundle.version`
### `POST /events/{eventPublicID}/launch` ### `POST /events/{eventPublicID}/launch`
鉴权: 鉴权:
@@ -192,10 +216,33 @@
- `launch.source` - `launch.source`
- `launch.resolvedRelease` - `launch.resolvedRelease`
- `launch.variant` - `launch.variant`
- `launch.runtime`
- `launch.presentation`
- `launch.contentBundle`
- `launch.config` - `launch.config`
- `launch.business.sessionId` - `launch.business.sessionId`
- `launch.business.sessionToken` - `launch.business.sessionToken`
当前活动运营摘要最少包括:
- `launch.presentation.presentationId`
- `launch.presentation.templateKey`
- `launch.presentation.version`
- `launch.contentBundle.contentBundleId`
- `launch.contentBundle.bundleType`
- `launch.contentBundle.version`
`launch.runtime` 当前为兼容新增字段,最少会带:
- `runtimeBindingId`
- `placeId`
- `mapId`
- `tileReleaseId`
- `courseSetId`
- `courseVariantId`
如当前 release 尚未挂接 runtime binding则该字段为空。
### `GET /events/{eventPublicID}/config-sources` ### `GET /events/{eventPublicID}/config-sources`
鉴权: 鉴权:
@@ -466,12 +513,16 @@
请求体重点: 请求体重点:
- `buildId` - `buildId`
- `runtimeBindingId` 可选
- `presentationId` 可选
- `contentBundleId` 可选
返回重点: 返回重点:
- `release.releaseId` - `release.releaseId`
- `release.manifestUrl` - `release.manifestUrl`
- `release.configLabel` - `release.configLabel`
- `runtime.runtimeBindingId` 可选
## 9. Admin 资源对象 ## 9. Admin 资源对象
@@ -738,6 +789,145 @@
- `overrides` - `overrides`
- `notes` - `notes`
### `GET /admin/events/{eventPublicID}/presentations`
鉴权:
- Bearer token
用途:
- 查看某个 event 下的展示定义列表
### `POST /admin/events/{eventPublicID}/presentations`
鉴权:
- Bearer token
用途:
- 为 event 创建一条最小 presentation 定义
请求体重点:
- `code`
- `name`
- `presentationType`
- `schema`
### `POST /admin/events/{eventPublicID}/presentations/import`
鉴权:
- Bearer token
用途:
- 通过统一导入入口创建展示定义
- 第一阶段只记录:
- `templateKey`
- `sourceType`
- `schemaUrl`
- `version`
- `title`
核心参数:
- `title`
- `templateKey`
- `sourceType`
- `schemaUrl`
- `version`
### `GET /admin/presentations/{presentationPublicID}`
鉴权:
- Bearer token
用途:
- 查看单条 presentation 明细
### `GET /admin/events/{eventPublicID}/content-bundles`
鉴权:
- Bearer token
用途:
- 查看某个 event 下的内容包列表
### `POST /admin/events/{eventPublicID}/content-bundles`
鉴权:
- Bearer token
用途:
- 为 event 创建一条最小 content bundle
请求体重点:
- `code`
- `name`
- `entryUrl`
- `assetRootUrl`
- `metadata`
### `POST /admin/events/{eventPublicID}/content-bundles/import`
鉴权:
- Bearer token
用途:
- 通过统一导入入口为 event 创建内容包
- 先记录 `bundleType / sourceType / manifestUrl / version / assetManifest`
请求体重点:
- `title`
- `bundleType`
- `sourceType`
- `manifestUrl`
- `version`
- `assetManifest`
### `GET /admin/content-bundles/{contentBundlePublicID}`
鉴权:
- Bearer token
用途:
- 查看单条 content bundle 明细
### `POST /admin/events/{eventPublicID}/defaults`
鉴权:
- Bearer token
用途:
- 固化 event 当前默认 active 绑定
- 后续 publish 在未显式传参时,优先继承:
- `presentationId`
- `contentBundleId`
- `runtimeBindingId`
核心参数:
- `presentationId`
- `contentBundleId`
- `runtimeBindingId`
### `GET /admin/events/{eventPublicID}/pipeline` ### `GET /admin/events/{eventPublicID}/pipeline`
鉴权: 鉴权:
@@ -782,6 +972,57 @@
- 将某次成功 build 发布成正式 release - 将某次成功 build 发布成正式 release
- 自动切换 event 当前 release - 自动切换 event 当前 release
- 可选在发布时直接挂接:
- `runtimeBindingId`
- `presentationId`
- `contentBundleId`
- 如果未显式传入 `runtimeBindingId / presentationId / contentBundleId`,会优先按 event 当前默认 active 绑定自动补齐
请求体重点:
- `runtimeBindingId` 可选
- `presentationId` 可选
- `contentBundleId` 可选
### `GET /admin/releases/{releasePublicID}`
鉴权:
- Bearer token
用途:
- 查看单个 release 明细
- 带出当前已挂接的最小 runtime / presentation / content bundle 摘要
当前 release 摘要最少包括:
- `presentation.presentationId`
- `presentation.templateKey`
- `presentation.version`
- `contentBundle.contentBundleId`
- `contentBundle.bundleType`
- `contentBundle.version`
- `runtime.runtimeBindingId`
- `runtime.placeId`
- `runtime.mapId`
- `runtime.tileReleaseId`
- `runtime.courseVariantId`
### `POST /admin/releases/{releasePublicID}/runtime-binding`
鉴权:
- Bearer token
用途:
- 将某个 `runtimeBindingId` 挂接到指定 release
- 为后续 `launch.runtime` 提供运行对象来源
请求体重点:
- `runtimeBindingId`
### `POST /admin/events/{eventPublicID}/rollback` ### `POST /admin/events/{eventPublicID}/rollback`
@@ -797,4 +1038,247 @@
- `releaseId` - `releaseId`
## 10. Admin 生产骨架
说明:
- 当前是总控确认后的第一阶段生产骨架接口
- 重点先覆盖:
- `Place`
- `MapAsset`
- `TileRelease`
- `CourseSource`
- `CourseSet`
- `CourseVariant`
- `MapRuntimeBinding`
- 这批接口不会替换现有 `events / event_releases / launch` 主链,而是增量补运行域对象
### `GET /admin/places`
鉴权:
- Bearer token
用途:
- 获取地点对象列表
### `POST /admin/places`
鉴权:
- Bearer token
用途:
- 新建地点对象
请求体重点:
- `code`
- `name`
- `region`
- `coverUrl`
- `description`
- `centerPoint`
- `status`
### `GET /admin/places/{placePublicID}`
鉴权:
- Bearer token
用途:
- 查看地点详情
- 同时带出该地点下的地图资产列表
### `POST /admin/places/{placePublicID}/map-assets`
鉴权:
- Bearer token
用途:
- 在某个地点下创建地图资产
请求体重点:
- `code`
- `name`
- `mapType`
- `legacyMapId`
- `coverUrl`
- `description`
- `status`
### `GET /admin/map-assets/{mapAssetPublicID}`
鉴权:
- Bearer token
用途:
- 查看地图资产详情
- 同时带出瓦片版本和赛道集合摘要
### `POST /admin/map-assets/{mapAssetPublicID}/tile-releases`
鉴权:
- Bearer token
用途:
- 为某个地图资产创建瓦片版本
请求体重点:
- `legacyVersionId`
- `versionCode`
- `tileBaseUrl`
- `metaUrl`
- `publishedAssetRoot`
- `metadata`
- `status`
- `setAsCurrent`
### `GET /admin/course-sources`
鉴权:
- Bearer token
用途:
- 获取赛道原始输入源列表
### `POST /admin/course-sources`
鉴权:
- Bearer token
用途:
- 新建赛道原始输入源
- 用于承接 KML / GeoJSON 等输入
请求体重点:
- `legacyPlayfieldId`
- `legacyVersionId`
- `sourceType`
- `fileUrl`
- `checksum`
- `parserVersion`
- `importStatus`
- `metadata`
### `GET /admin/course-sources/{sourcePublicID}`
鉴权:
- Bearer token
用途:
- 查看单个赛道输入源详情
### `POST /admin/map-assets/{mapAssetPublicID}/course-sets`
鉴权:
- Bearer token
用途:
- 在某个地图资产下创建赛道集合
请求体重点:
- `code`
- `mode`
- `name`
- `description`
- `status`
### `GET /admin/course-sets/{courseSetPublicID}`
鉴权:
- Bearer token
用途:
- 查看赛道集合详情
- 同时带出它的 variant 列表
### `POST /admin/course-sets/{courseSetPublicID}/variants`
鉴权:
- Bearer token
用途:
- 为某个赛道集合创建具体可运行赛道方案
请求体重点:
- `sourceId`
- `name`
- `routeCode`
- `mode`
- `controlCount`
- `difficulty`
- `configPatch`
- `metadata`
- `status`
- `isDefault`
### `GET /admin/runtime-bindings`
鉴权:
- Bearer token
用途:
- 获取活动运行绑定列表
### `POST /admin/runtime-bindings`
鉴权:
- Bearer token
用途:
- 把活动和地点、地图资产、瓦片版本、赛道方案正式绑定起来
请求体重点:
- `eventId`
- `placeId`
- `mapAssetId`
- `tileReleaseId`
- `courseSetId`
- `courseVariantId`
- `status`
- `notes`
### `GET /admin/runtime-bindings/{runtimeBindingPublicID}`
鉴权:
- Bearer token
用途:
- 查看单个运行绑定详情

View File

@@ -1,9 +1,8 @@
# 数据模型 # 数据模型
> 文档版本v1.0 > 文档版本v1.3
> 最后更新2026-04-02 08:28:05 > 最后更新2026-04-03 12:36:15
当前 migration 共 10 版。
当前 migration 共 6 版。
## 1. 迁移清单 ## 1. 迁移清单
@@ -13,6 +12,10 @@
- [0004_results.sql](D:/dev/cmr-mini/backend/migrations/0004_results.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) - [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) - [0006_resource_objects.sql](D:/dev/cmr-mini/backend/migrations/0006_resource_objects.sql)
- [0007_variant_minimal.sql](D:/dev/cmr-mini/backend/migrations/0007_variant_minimal.sql)
- [0008_production_skeleton.sql](D:/dev/cmr-mini/backend/migrations/0008_production_skeleton.sql)
- [0009_event_ops_phase2.sql](D:/dev/cmr-mini/backend/migrations/0009_event_ops_phase2.sql)
- [0010_event_default_bindings.sql](D:/dev/cmr-mini/backend/migrations/0010_event_default_bindings.sql)
## 2. 表分组 ## 2. 表分组
@@ -114,6 +117,51 @@
- 支撑后台第一版按“资源对象 + 版本”管理 - 支撑后台第一版按“资源对象 + 版本”管理
- 给后续 event 引用组装和发布流程提供稳定边界 - 给后续 event 引用组装和发布流程提供稳定边界
### 2.8 第一阶段生产骨架
- `places`
- `map_assets`
- `tile_releases`
- `course_sources`
- `course_sets`
- `course_variants`
- `map_runtime_bindings`
职责:
- 把地图运行域和活动运行绑定正式落库
- 把 KML 输入源和最终赛道方案拆开
- 在不推翻当前 `events / event_releases / game_sessions` 主链的前提下,增量补生产骨架
### 2.9 活动运营域第二阶段
- `event_presentations`
- `content_bundles`
职责:
- 把活动展示定义和内容包从临时 JSON 概念收成正式对象
-`event_releases` 明确绑定:
- `presentation_id`
- `content_bundle_id`
- `runtime_binding_id`
- 保持现有 `resolvedRelease / business / variant / runtime` 稳定返回不变
### 2.10 Event 默认 active 绑定
- `events.current_presentation_id`
- `events.current_content_bundle_id`
- `events.current_runtime_binding_id`
职责:
- 固化 event 当前默认 active
- `presentation`
- `content bundle`
- `runtime binding`
- 支撑 publish 在未显式传入时的默认继承
- 不改变前端当前稳定消费的 release / launch 字段语义
## 3. 当前最关键的关系 ## 3. 当前最关键的关系
### `tenant -> entry_channel` ### `tenant -> entry_channel`
@@ -160,6 +208,42 @@
一套内容/音频/主题资源可有多个版本。 一套内容/音频/主题资源可有多个版本。
### `place -> map_asset -> tile_release`
- `Place` 是地点上层对象
- `MapAsset` 是地点下的一张具体地图资产
- `TileRelease` 是某张地图的具体瓦片发布版本
### `course_source -> course_variant -> course_set`
- `CourseSource` 是原始输入源,例如 KML
- `CourseVariant` 是最终可运行赛道方案
- `CourseSet` 是一组方案集合
### `event_release -> map_runtime_binding`
- `event_releases.runtime_binding_id` 已预留给第一阶段生产骨架
- 当前客户端联调仍以 `resolvedRelease` 为主
- 第二阶段会继续把 `placeId / mapId / tileReleaseId / courseVariantId` 收到 `launch` 稳定返回中
### `event -> event_presentation`
- 一个 `event` 可有多条展示定义
- 当前最小用途是给 `event_release` 提供明确绑定目标
### `event -> content_bundle`
- 一个 `event` 可有多条内容包
- 当前最小用途是给 `event_release` 提供内容资源绑定目标
### `event_release -> presentation / content_bundle / runtime`
- 这是当前活动运营域第二阶段的最小闭环
- `release` 现在可以稳定固化:
- 展示定义
- 内容包
- 运行绑定
## 4. 当前已落库但仍应注意的边界 ## 4. 当前已落库但仍应注意的边界
### 4.1 不要把玩法细节塞回事件主表 ### 4.1 不要把玩法细节塞回事件主表

View File

@@ -1,6 +1,6 @@
# 核心流程 # 核心流程
> 文档版本v1.1 > 文档版本v1.2
> 最后更新2026-04-02 11:03:02 > 最后更新2026-04-03 11:22:50
## 1. 总流程 ## 1. 总流程
@@ -113,6 +113,8 @@ APP 当前主链是手机号验证码:
- `event` - `event`
- `release` - `release`
- `resolvedRelease` - `resolvedRelease`
- `currentPresentation`
- `currentContentBundle`
- `play.assignmentMode` - `play.assignmentMode`
- `play.courseVariants[]` - `play.courseVariants[]`
- `play.canLaunch` - `play.canLaunch`
@@ -160,6 +162,8 @@ APP 当前主链是手机号验证码:
- `launch.source` - `launch.source`
- `launch.resolvedRelease` - `launch.resolvedRelease`
- `launch.variant` - `launch.variant`
- `launch.presentation`
- `launch.contentBundle`
- `launch.config` - `launch.config`
- `launch.business.sessionId` - `launch.business.sessionId`
- `launch.business.sessionToken` - `launch.business.sessionToken`
@@ -179,6 +183,11 @@ APP 当前主链是手机号验证码:
- `launch.variant.id` - `launch.variant.id`
- `launch.variant.assignmentMode` - `launch.variant.assignmentMode`
活动运营域第二阶段第二刀新增建议消费摘要:
- `launch.presentation.presentationId`
- `launch.contentBundle.contentBundleId`
补充说明: 补充说明:
- 如果活动声明了多赛道 variant`launch` 会返回本局最终绑定的 `variant` - 如果活动声明了多赛道 variant`launch` 会返回本局最终绑定的 `variant`

View File

@@ -38,6 +38,7 @@ func New(ctx context.Context, cfg Config) (*App, error) {
entryService := service.NewEntryService(store) entryService := service.NewEntryService(store)
entryHomeService := service.NewEntryHomeService(store) entryHomeService := service.NewEntryHomeService(store)
adminResourceService := service.NewAdminResourceService(store) adminResourceService := service.NewAdminResourceService(store)
adminProductionService := service.NewAdminProductionService(store)
adminEventService := service.NewAdminEventService(store) adminEventService := service.NewAdminEventService(store)
eventService := service.NewEventService(store) eventService := service.NewEventService(store)
eventPlayService := service.NewEventPlayService(store) eventPlayService := service.NewEventPlayService(store)
@@ -50,7 +51,7 @@ func New(ctx context.Context, cfg Config) (*App, error) {
sessionService := service.NewSessionService(store) sessionService := service.NewSessionService(store)
devService := service.NewDevService(cfg.AppEnv, store) devService := service.NewDevService(cfg.AppEnv, store)
meService := service.NewMeService(store) meService := service.NewMeService(store)
router := httpapi.NewRouter(cfg.AppEnv, jwtManager, authService, entryService, entryHomeService, adminResourceService, adminEventService, adminPipelineService, eventService, eventPlayService, configService, homeService, profileService, resultService, sessionService, devService, meService) router := httpapi.NewRouter(cfg.AppEnv, jwtManager, authService, entryService, entryHomeService, adminResourceService, adminProductionService, adminEventService, adminPipelineService, eventService, eventPlayService, configService, homeService, profileService, resultService, sessionService, devService, meService)
return &App{ return &App{
router: router, router: router,

View File

@@ -82,3 +82,121 @@ func (h *AdminEventHandler) SaveSource(w http.ResponseWriter, r *http.Request) {
} }
httpx.WriteJSON(w, http.StatusCreated, map[string]any{"data": result}) httpx.WriteJSON(w, http.StatusCreated, map[string]any{"data": result})
} }
func (h *AdminEventHandler) ListPresentations(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.ListEventPresentations(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 *AdminEventHandler) CreatePresentation(w http.ResponseWriter, r *http.Request) {
var req service.CreateAdminEventPresentationInput
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.CreateEventPresentation(r.Context(), r.PathValue("eventPublicID"), req)
if err != nil {
httpx.WriteError(w, err)
return
}
httpx.WriteJSON(w, http.StatusCreated, map[string]any{"data": result})
}
func (h *AdminEventHandler) ImportPresentation(w http.ResponseWriter, r *http.Request) {
var req service.ImportAdminEventPresentationInput
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.ImportEventPresentation(r.Context(), r.PathValue("eventPublicID"), req)
if err != nil {
httpx.WriteError(w, err)
return
}
httpx.WriteJSON(w, http.StatusCreated, map[string]any{"data": result})
}
func (h *AdminEventHandler) GetPresentation(w http.ResponseWriter, r *http.Request) {
result, err := h.service.GetEventPresentation(r.Context(), r.PathValue("presentationPublicID"))
if err != nil {
httpx.WriteError(w, err)
return
}
httpx.WriteJSON(w, http.StatusOK, map[string]any{"data": result})
}
func (h *AdminEventHandler) ListContentBundles(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.ListContentBundles(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 *AdminEventHandler) CreateContentBundle(w http.ResponseWriter, r *http.Request) {
var req service.CreateAdminContentBundleInput
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.CreateContentBundle(r.Context(), r.PathValue("eventPublicID"), req)
if err != nil {
httpx.WriteError(w, err)
return
}
httpx.WriteJSON(w, http.StatusCreated, map[string]any{"data": result})
}
func (h *AdminEventHandler) ImportContentBundle(w http.ResponseWriter, r *http.Request) {
var req service.ImportAdminContentBundleInput
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.ImportContentBundle(r.Context(), r.PathValue("eventPublicID"), req)
if err != nil {
httpx.WriteError(w, err)
return
}
httpx.WriteJSON(w, http.StatusCreated, map[string]any{"data": result})
}
func (h *AdminEventHandler) GetContentBundle(w http.ResponseWriter, r *http.Request) {
result, err := h.service.GetContentBundle(r.Context(), r.PathValue("contentBundlePublicID"))
if err != nil {
httpx.WriteError(w, err)
return
}
httpx.WriteJSON(w, http.StatusOK, map[string]any{"data": result})
}
func (h *AdminEventHandler) UpdateEventDefaults(w http.ResponseWriter, r *http.Request) {
var req service.UpdateAdminEventDefaultsInput
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.UpdateEventDefaults(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

@@ -1,8 +1,10 @@
package handlers package handlers
import ( import (
"io"
"net/http" "net/http"
"strconv" "strconv"
"strings"
"cmr-backend/internal/apperr" "cmr-backend/internal/apperr"
"cmr-backend/internal/httpx" "cmr-backend/internal/httpx"
@@ -51,7 +53,46 @@ func (h *AdminPipelineHandler) GetBuild(w http.ResponseWriter, r *http.Request)
} }
func (h *AdminPipelineHandler) PublishBuild(w http.ResponseWriter, r *http.Request) { func (h *AdminPipelineHandler) PublishBuild(w http.ResponseWriter, r *http.Request) {
result, err := h.service.PublishBuild(r.Context(), r.PathValue("buildID")) var req service.AdminPublishBuildInput
if r.Body != nil {
raw, err := io.ReadAll(r.Body)
if err != nil {
httpx.WriteError(w, apperr.New(http.StatusBadRequest, "invalid_json", "failed to read request body: "+err.Error()))
return
}
if len(raw) > 0 {
r.Body = io.NopCloser(strings.NewReader(string(raw)))
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.PublishBuild(r.Context(), r.PathValue("buildID"), req)
if err != nil {
httpx.WriteError(w, err)
return
}
httpx.WriteJSON(w, http.StatusOK, map[string]any{"data": result})
}
func (h *AdminPipelineHandler) GetRelease(w http.ResponseWriter, r *http.Request) {
result, err := h.service.GetRelease(r.Context(), r.PathValue("releasePublicID"))
if err != nil {
httpx.WriteError(w, err)
return
}
httpx.WriteJSON(w, http.StatusOK, map[string]any{"data": result})
}
func (h *AdminPipelineHandler) BindReleaseRuntime(w http.ResponseWriter, r *http.Request) {
var req service.AdminBindReleaseRuntimeInput
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.BindReleaseRuntime(r.Context(), r.PathValue("releasePublicID"), req)
if err != nil { if err != nil {
httpx.WriteError(w, err) httpx.WriteError(w, err)
return return

View File

@@ -0,0 +1,187 @@
package handlers
import (
"net/http"
"cmr-backend/internal/apperr"
"cmr-backend/internal/httpx"
"cmr-backend/internal/service"
)
type AdminProductionHandler struct {
service *service.AdminProductionService
}
func NewAdminProductionHandler(service *service.AdminProductionService) *AdminProductionHandler {
return &AdminProductionHandler{service: service}
}
func (h *AdminProductionHandler) ListPlaces(w http.ResponseWriter, r *http.Request) {
result, err := h.service.ListPlaces(r.Context(), parseAdminLimit(r))
if err != nil {
httpx.WriteError(w, err)
return
}
httpx.WriteJSON(w, http.StatusOK, map[string]any{"data": result})
}
func (h *AdminProductionHandler) CreatePlace(w http.ResponseWriter, r *http.Request) {
var req service.CreateAdminPlaceInput
if err := httpx.DecodeJSON(r, &req); err != nil {
httpx.WriteError(w, apperr.New(http.StatusBadRequest, "invalid_json", "invalid request body: "+err.Error()))
return
}
result, err := h.service.CreatePlace(r.Context(), req)
if err != nil {
httpx.WriteError(w, err)
return
}
httpx.WriteJSON(w, http.StatusCreated, map[string]any{"data": result})
}
func (h *AdminProductionHandler) GetPlace(w http.ResponseWriter, r *http.Request) {
result, err := h.service.GetPlaceDetail(r.Context(), r.PathValue("placePublicID"))
if err != nil {
httpx.WriteError(w, err)
return
}
httpx.WriteJSON(w, http.StatusOK, map[string]any{"data": result})
}
func (h *AdminProductionHandler) CreateMapAsset(w http.ResponseWriter, r *http.Request) {
var req service.CreateAdminMapAssetInput
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.CreateMapAsset(r.Context(), r.PathValue("placePublicID"), req)
if err != nil {
httpx.WriteError(w, err)
return
}
httpx.WriteJSON(w, http.StatusCreated, map[string]any{"data": result})
}
func (h *AdminProductionHandler) GetMapAsset(w http.ResponseWriter, r *http.Request) {
result, err := h.service.GetMapAssetDetail(r.Context(), r.PathValue("mapAssetPublicID"))
if err != nil {
httpx.WriteError(w, err)
return
}
httpx.WriteJSON(w, http.StatusOK, map[string]any{"data": result})
}
func (h *AdminProductionHandler) CreateTileRelease(w http.ResponseWriter, r *http.Request) {
var req service.CreateAdminTileReleaseInput
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.CreateTileRelease(r.Context(), r.PathValue("mapAssetPublicID"), req)
if err != nil {
httpx.WriteError(w, err)
return
}
httpx.WriteJSON(w, http.StatusCreated, map[string]any{"data": result})
}
func (h *AdminProductionHandler) ListCourseSources(w http.ResponseWriter, r *http.Request) {
result, err := h.service.ListCourseSources(r.Context(), parseAdminLimit(r))
if err != nil {
httpx.WriteError(w, err)
return
}
httpx.WriteJSON(w, http.StatusOK, map[string]any{"data": result})
}
func (h *AdminProductionHandler) CreateCourseSource(w http.ResponseWriter, r *http.Request) {
var req service.CreateAdminCourseSourceInput
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.CreateCourseSource(r.Context(), req)
if err != nil {
httpx.WriteError(w, err)
return
}
httpx.WriteJSON(w, http.StatusCreated, map[string]any{"data": result})
}
func (h *AdminProductionHandler) GetCourseSource(w http.ResponseWriter, r *http.Request) {
result, err := h.service.GetCourseSource(r.Context(), r.PathValue("sourcePublicID"))
if err != nil {
httpx.WriteError(w, err)
return
}
httpx.WriteJSON(w, http.StatusOK, map[string]any{"data": result})
}
func (h *AdminProductionHandler) CreateCourseSet(w http.ResponseWriter, r *http.Request) {
var req service.CreateAdminCourseSetInput
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.CreateCourseSet(r.Context(), r.PathValue("mapAssetPublicID"), req)
if err != nil {
httpx.WriteError(w, err)
return
}
httpx.WriteJSON(w, http.StatusCreated, map[string]any{"data": result})
}
func (h *AdminProductionHandler) GetCourseSet(w http.ResponseWriter, r *http.Request) {
result, err := h.service.GetCourseSetDetail(r.Context(), r.PathValue("courseSetPublicID"))
if err != nil {
httpx.WriteError(w, err)
return
}
httpx.WriteJSON(w, http.StatusOK, map[string]any{"data": result})
}
func (h *AdminProductionHandler) CreateCourseVariant(w http.ResponseWriter, r *http.Request) {
var req service.CreateAdminCourseVariantInput
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.CreateCourseVariant(r.Context(), r.PathValue("courseSetPublicID"), req)
if err != nil {
httpx.WriteError(w, err)
return
}
httpx.WriteJSON(w, http.StatusCreated, map[string]any{"data": result})
}
func (h *AdminProductionHandler) ListRuntimeBindings(w http.ResponseWriter, r *http.Request) {
result, err := h.service.ListRuntimeBindings(r.Context(), parseAdminLimit(r))
if err != nil {
httpx.WriteError(w, err)
return
}
httpx.WriteJSON(w, http.StatusOK, map[string]any{"data": result})
}
func (h *AdminProductionHandler) CreateRuntimeBinding(w http.ResponseWriter, r *http.Request) {
var req service.CreateAdminRuntimeBindingInput
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.CreateRuntimeBinding(r.Context(), req)
if err != nil {
httpx.WriteError(w, err)
return
}
httpx.WriteJSON(w, http.StatusCreated, map[string]any{"data": result})
}
func (h *AdminProductionHandler) GetRuntimeBinding(w http.ResponseWriter, r *http.Request) {
result, err := h.service.GetRuntimeBinding(r.Context(), r.PathValue("runtimeBindingPublicID"))
if err != nil {
httpx.WriteError(w, err)
return
}
httpx.WriteJSON(w, http.StatusOK, map[string]any{"data": result})
}

File diff suppressed because it is too large Load Diff

View File

@@ -16,6 +16,7 @@ func NewRouter(
entryService *service.EntryService, entryService *service.EntryService,
entryHomeService *service.EntryHomeService, entryHomeService *service.EntryHomeService,
adminResourceService *service.AdminResourceService, adminResourceService *service.AdminResourceService,
adminProductionService *service.AdminProductionService,
adminEventService *service.AdminEventService, adminEventService *service.AdminEventService,
adminPipelineService *service.AdminPipelineService, adminPipelineService *service.AdminPipelineService,
eventService *service.EventService, eventService *service.EventService,
@@ -35,6 +36,7 @@ func NewRouter(
entryHandler := handlers.NewEntryHandler(entryService) entryHandler := handlers.NewEntryHandler(entryService)
entryHomeHandler := handlers.NewEntryHomeHandler(entryHomeService) entryHomeHandler := handlers.NewEntryHomeHandler(entryHomeService)
adminResourceHandler := handlers.NewAdminResourceHandler(adminResourceService) adminResourceHandler := handlers.NewAdminResourceHandler(adminResourceService)
adminProductionHandler := handlers.NewAdminProductionHandler(adminProductionService)
adminEventHandler := handlers.NewAdminEventHandler(adminEventService) adminEventHandler := handlers.NewAdminEventHandler(adminEventService)
adminPipelineHandler := handlers.NewAdminPipelineHandler(adminPipelineService) adminPipelineHandler := handlers.NewAdminPipelineHandler(adminPipelineService)
eventHandler := handlers.NewEventHandler(eventService) eventHandler := handlers.NewEventHandler(eventService)
@@ -56,6 +58,21 @@ func NewRouter(
mux.Handle("POST /admin/maps", authMiddleware(http.HandlerFunc(adminResourceHandler.CreateMap))) mux.Handle("POST /admin/maps", authMiddleware(http.HandlerFunc(adminResourceHandler.CreateMap)))
mux.Handle("GET /admin/maps/{mapPublicID}", authMiddleware(http.HandlerFunc(adminResourceHandler.GetMap))) mux.Handle("GET /admin/maps/{mapPublicID}", authMiddleware(http.HandlerFunc(adminResourceHandler.GetMap)))
mux.Handle("POST /admin/maps/{mapPublicID}/versions", authMiddleware(http.HandlerFunc(adminResourceHandler.CreateMapVersion))) mux.Handle("POST /admin/maps/{mapPublicID}/versions", authMiddleware(http.HandlerFunc(adminResourceHandler.CreateMapVersion)))
mux.Handle("GET /admin/places", authMiddleware(http.HandlerFunc(adminProductionHandler.ListPlaces)))
mux.Handle("POST /admin/places", authMiddleware(http.HandlerFunc(adminProductionHandler.CreatePlace)))
mux.Handle("GET /admin/places/{placePublicID}", authMiddleware(http.HandlerFunc(adminProductionHandler.GetPlace)))
mux.Handle("POST /admin/places/{placePublicID}/map-assets", authMiddleware(http.HandlerFunc(adminProductionHandler.CreateMapAsset)))
mux.Handle("GET /admin/map-assets/{mapAssetPublicID}", authMiddleware(http.HandlerFunc(adminProductionHandler.GetMapAsset)))
mux.Handle("POST /admin/map-assets/{mapAssetPublicID}/tile-releases", authMiddleware(http.HandlerFunc(adminProductionHandler.CreateTileRelease)))
mux.Handle("POST /admin/map-assets/{mapAssetPublicID}/course-sets", authMiddleware(http.HandlerFunc(adminProductionHandler.CreateCourseSet)))
mux.Handle("GET /admin/course-sources", authMiddleware(http.HandlerFunc(adminProductionHandler.ListCourseSources)))
mux.Handle("POST /admin/course-sources", authMiddleware(http.HandlerFunc(adminProductionHandler.CreateCourseSource)))
mux.Handle("GET /admin/course-sources/{sourcePublicID}", authMiddleware(http.HandlerFunc(adminProductionHandler.GetCourseSource)))
mux.Handle("GET /admin/course-sets/{courseSetPublicID}", authMiddleware(http.HandlerFunc(adminProductionHandler.GetCourseSet)))
mux.Handle("POST /admin/course-sets/{courseSetPublicID}/variants", authMiddleware(http.HandlerFunc(adminProductionHandler.CreateCourseVariant)))
mux.Handle("GET /admin/runtime-bindings", authMiddleware(http.HandlerFunc(adminProductionHandler.ListRuntimeBindings)))
mux.Handle("POST /admin/runtime-bindings", authMiddleware(http.HandlerFunc(adminProductionHandler.CreateRuntimeBinding)))
mux.Handle("GET /admin/runtime-bindings/{runtimeBindingPublicID}", authMiddleware(http.HandlerFunc(adminProductionHandler.GetRuntimeBinding)))
mux.Handle("GET /admin/playfields", authMiddleware(http.HandlerFunc(adminResourceHandler.ListPlayfields))) mux.Handle("GET /admin/playfields", authMiddleware(http.HandlerFunc(adminResourceHandler.ListPlayfields)))
mux.Handle("POST /admin/playfields", authMiddleware(http.HandlerFunc(adminResourceHandler.CreatePlayfield))) mux.Handle("POST /admin/playfields", authMiddleware(http.HandlerFunc(adminResourceHandler.CreatePlayfield)))
mux.Handle("GET /admin/playfields/{playfieldPublicID}", authMiddleware(http.HandlerFunc(adminResourceHandler.GetPlayfield))) mux.Handle("GET /admin/playfields/{playfieldPublicID}", authMiddleware(http.HandlerFunc(adminResourceHandler.GetPlayfield)))
@@ -69,10 +86,21 @@ func NewRouter(
mux.Handle("GET /admin/events/{eventPublicID}", authMiddleware(http.HandlerFunc(adminEventHandler.GetEvent))) mux.Handle("GET /admin/events/{eventPublicID}", authMiddleware(http.HandlerFunc(adminEventHandler.GetEvent)))
mux.Handle("PUT /admin/events/{eventPublicID}", authMiddleware(http.HandlerFunc(adminEventHandler.UpdateEvent))) 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("POST /admin/events/{eventPublicID}/source", authMiddleware(http.HandlerFunc(adminEventHandler.SaveSource)))
mux.Handle("GET /admin/events/{eventPublicID}/presentations", authMiddleware(http.HandlerFunc(adminEventHandler.ListPresentations)))
mux.Handle("POST /admin/events/{eventPublicID}/presentations", authMiddleware(http.HandlerFunc(adminEventHandler.CreatePresentation)))
mux.Handle("POST /admin/events/{eventPublicID}/presentations/import", authMiddleware(http.HandlerFunc(adminEventHandler.ImportPresentation)))
mux.Handle("GET /admin/presentations/{presentationPublicID}", authMiddleware(http.HandlerFunc(adminEventHandler.GetPresentation)))
mux.Handle("GET /admin/events/{eventPublicID}/content-bundles", authMiddleware(http.HandlerFunc(adminEventHandler.ListContentBundles)))
mux.Handle("POST /admin/events/{eventPublicID}/content-bundles", authMiddleware(http.HandlerFunc(adminEventHandler.CreateContentBundle)))
mux.Handle("POST /admin/events/{eventPublicID}/content-bundles/import", authMiddleware(http.HandlerFunc(adminEventHandler.ImportContentBundle)))
mux.Handle("GET /admin/content-bundles/{contentBundlePublicID}", authMiddleware(http.HandlerFunc(adminEventHandler.GetContentBundle)))
mux.Handle("POST /admin/events/{eventPublicID}/defaults", authMiddleware(http.HandlerFunc(adminEventHandler.UpdateEventDefaults)))
mux.Handle("GET /admin/events/{eventPublicID}/pipeline", authMiddleware(http.HandlerFunc(adminPipelineHandler.GetEventPipeline))) 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("POST /admin/sources/{sourceID}/build", authMiddleware(http.HandlerFunc(adminPipelineHandler.BuildSource)))
mux.Handle("GET /admin/builds/{buildID}", authMiddleware(http.HandlerFunc(adminPipelineHandler.GetBuild))) 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/builds/{buildID}/publish", authMiddleware(http.HandlerFunc(adminPipelineHandler.PublishBuild)))
mux.Handle("GET /admin/releases/{releasePublicID}", authMiddleware(http.HandlerFunc(adminPipelineHandler.GetRelease)))
mux.Handle("POST /admin/releases/{releasePublicID}/runtime-binding", authMiddleware(http.HandlerFunc(adminPipelineHandler.BindReleaseRuntime)))
mux.Handle("POST /admin/events/{eventPublicID}/rollback", authMiddleware(http.HandlerFunc(adminPipelineHandler.RollbackRelease))) mux.Handle("POST /admin/events/{eventPublicID}/rollback", authMiddleware(http.HandlerFunc(adminPipelineHandler.RollbackRelease)))
if appEnv != "production" { if appEnv != "production" {
mux.HandleFunc("GET /dev/workbench", devHandler.Workbench) mux.HandleFunc("GET /dev/workbench", devHandler.Workbench)

View File

@@ -2,6 +2,7 @@ package service
import ( import (
"context" "context"
"encoding/json"
"fmt" "fmt"
"net/http" "net/http"
"strings" "strings"
@@ -27,18 +28,25 @@ type AdminEventSummary struct {
} }
type AdminEventReleaseRef struct { type AdminEventReleaseRef struct {
ID string `json:"id"` ID string `json:"id"`
ConfigLabel *string `json:"configLabel,omitempty"` ConfigLabel *string `json:"configLabel,omitempty"`
ManifestURL *string `json:"manifestUrl,omitempty"` ManifestURL *string `json:"manifestUrl,omitempty"`
ManifestChecksumSha256 *string `json:"manifestChecksumSha256,omitempty"` ManifestChecksumSha256 *string `json:"manifestChecksumSha256,omitempty"`
RouteCode *string `json:"routeCode,omitempty"` RouteCode *string `json:"routeCode,omitempty"`
Presentation *PresentationSummaryView `json:"presentation,omitempty"`
ContentBundle *ContentBundleSummaryView `json:"contentBundle,omitempty"`
} }
type AdminEventDetail struct { type AdminEventDetail struct {
Event AdminEventSummary `json:"event"` Event AdminEventSummary `json:"event"`
LatestSource *EventConfigSourceView `json:"latestSource,omitempty"` LatestSource *EventConfigSourceView `json:"latestSource,omitempty"`
SourceCount int `json:"sourceCount"` SourceCount int `json:"sourceCount"`
CurrentSource *AdminAssembledSource `json:"currentSource,omitempty"` CurrentSource *AdminAssembledSource `json:"currentSource,omitempty"`
PresentationCount int `json:"presentationCount"`
ContentBundleCount int `json:"contentBundleCount"`
CurrentPresentation *PresentationSummaryView `json:"currentPresentation,omitempty"`
CurrentContentBundle *ContentBundleSummaryView `json:"currentContentBundle,omitempty"`
CurrentRuntime *RuntimeSummaryView `json:"currentRuntime,omitempty"`
} }
type CreateAdminEventInput struct { type CreateAdminEventInput struct {
@@ -76,6 +84,84 @@ type SaveAdminEventSourceInput struct {
Notes *string `json:"notes,omitempty"` Notes *string `json:"notes,omitempty"`
} }
type AdminEventPresentationView struct {
ID string `json:"id"`
EventID string `json:"eventId"`
Code string `json:"code"`
Name string `json:"name"`
PresentationType string `json:"presentationType"`
Status string `json:"status"`
IsDefault bool `json:"isDefault"`
TemplateKey *string `json:"templateKey,omitempty"`
Version *string `json:"version,omitempty"`
SourceType *string `json:"sourceType,omitempty"`
SchemaURL *string `json:"schemaUrl,omitempty"`
Schema map[string]any `json:"schema"`
}
type CreateAdminEventPresentationInput struct {
Code string `json:"code"`
Name string `json:"name"`
PresentationType string `json:"presentationType"`
Status string `json:"status"`
IsDefault bool `json:"isDefault"`
Schema map[string]any `json:"schema,omitempty"`
}
type ImportAdminEventPresentationInput struct {
Title string `json:"title"`
TemplateKey string `json:"templateKey"`
SourceType string `json:"sourceType"`
SchemaURL string `json:"schemaUrl"`
Version string `json:"version"`
Status string `json:"status"`
IsDefault bool `json:"isDefault"`
}
type AdminContentBundleView struct {
ID string `json:"id"`
EventID string `json:"eventId"`
Code string `json:"code"`
Name string `json:"name"`
Status string `json:"status"`
IsDefault bool `json:"isDefault"`
BundleType *string `json:"bundleType,omitempty"`
Version *string `json:"version,omitempty"`
SourceType *string `json:"sourceType,omitempty"`
ManifestURL *string `json:"manifestUrl,omitempty"`
AssetManifest any `json:"assetManifest,omitempty"`
EntryURL *string `json:"entryUrl,omitempty"`
AssetRootURL *string `json:"assetRootUrl,omitempty"`
Metadata map[string]any `json:"metadata"`
}
type CreateAdminContentBundleInput struct {
Code string `json:"code"`
Name string `json:"name"`
Status string `json:"status"`
IsDefault bool `json:"isDefault"`
EntryURL *string `json:"entryUrl,omitempty"`
AssetRootURL *string `json:"assetRootUrl,omitempty"`
Metadata map[string]any `json:"metadata,omitempty"`
}
type ImportAdminContentBundleInput struct {
Title string `json:"title"`
BundleType string `json:"bundleType"`
SourceType string `json:"sourceType"`
ManifestURL string `json:"manifestUrl"`
Version string `json:"version"`
Status string `json:"status"`
IsDefault bool `json:"isDefault"`
AssetManifest map[string]any `json:"assetManifest,omitempty"`
}
type UpdateAdminEventDefaultsInput struct {
PresentationID *string `json:"presentationId,omitempty"`
ContentBundleID *string `json:"contentBundleId,omitempty"`
RuntimeBindingID *string `json:"runtimeBindingId,omitempty"`
}
type AdminAssembledSource struct { type AdminAssembledSource struct {
Refs map[string]any `json:"refs"` Refs map[string]any `json:"refs"`
Runtime map[string]any `json:"runtime"` Runtime map[string]any `json:"runtime"`
@@ -240,10 +326,20 @@ func (s *AdminEventService) GetEventDetail(ctx context.Context, eventPublicID st
if err != nil { if err != nil {
return nil, err return nil, err
} }
presentations, err := s.store.ListEventPresentationsByEventID(ctx, record.ID, 200)
if err != nil {
return nil, err
}
contentBundles, err := s.store.ListContentBundlesByEventID(ctx, record.ID, 200)
if err != nil {
return nil, err
}
result := &AdminEventDetail{ result := &AdminEventDetail{
Event: buildAdminEventSummary(*record), Event: buildAdminEventSummary(*record),
SourceCount: len(allSources), SourceCount: len(allSources),
PresentationCount: len(presentations),
ContentBundleCount: len(contentBundles),
} }
if len(sources) > 0 { if len(sources) > 0 {
latest, err := buildEventConfigSourceView(&sources[0], record.PublicID) latest, err := buildEventConfigSourceView(&sources[0], record.PublicID)
@@ -253,9 +349,427 @@ func (s *AdminEventService) GetEventDetail(ctx context.Context, eventPublicID st
result.LatestSource = latest result.LatestSource = latest
result.CurrentSource = buildAdminAssembledSource(latest.Source) result.CurrentSource = buildAdminAssembledSource(latest.Source)
} }
result.CurrentPresentation = buildPresentationSummaryFromEventRecord(record)
result.CurrentContentBundle = buildContentBundleSummaryFromEventRecord(record)
result.CurrentRuntime = buildRuntimeSummaryFromAdminEventRecord(record)
if enrichedPresentation, err := loadPresentationSummaryByPublicID(ctx, s.store, record.CurrentPresentationID); err != nil {
return nil, err
} else if enrichedPresentation != nil {
result.CurrentPresentation = enrichedPresentation
}
if enrichedBundle, err := loadContentBundleSummaryByPublicID(ctx, s.store, record.CurrentContentBundleID); err != nil {
return nil, err
} else if enrichedBundle != nil {
result.CurrentContentBundle = enrichedBundle
}
return result, nil return result, nil
} }
func (s *AdminEventService) ListEventPresentations(ctx context.Context, eventPublicID string, limit int) ([]AdminEventPresentationView, 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")
}
items, err := s.store.ListEventPresentationsByEventID(ctx, eventRecord.ID, limit)
if err != nil {
return nil, err
}
result := make([]AdminEventPresentationView, 0, len(items))
for _, item := range items {
view, err := buildAdminEventPresentationView(item)
if err != nil {
return nil, err
}
result = append(result, view)
}
return result, nil
}
func (s *AdminEventService) CreateEventPresentation(ctx context.Context, eventPublicID string, input CreateAdminEventPresentationInput) (*AdminEventPresentationView, 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.Code = strings.TrimSpace(input.Code)
input.Name = strings.TrimSpace(input.Name)
input.PresentationType = normalizePresentationType(input.PresentationType)
if input.Code == "" || input.Name == "" {
return nil, apperr.New(http.StatusBadRequest, "invalid_params", "code and name are required")
}
publicID, err := security.GeneratePublicID("pres")
if err != nil {
return nil, err
}
schema := input.Schema
if schema == nil {
schema = map[string]any{}
}
tx, err := s.store.Begin(ctx)
if err != nil {
return nil, err
}
defer tx.Rollback(ctx)
record, err := s.store.CreateEventPresentation(ctx, tx, postgres.CreateEventPresentationParams{
PublicID: publicID,
EventID: eventRecord.ID,
Code: input.Code,
Name: input.Name,
PresentationType: input.PresentationType,
Status: normalizeEventCatalogStatus(input.Status),
IsDefault: input.IsDefault,
SchemaJSON: mustMarshalJSONObject(schema),
})
if err != nil {
return nil, err
}
if err := tx.Commit(ctx); err != nil {
return nil, err
}
view, err := buildAdminEventPresentationView(*record)
if err != nil {
return nil, err
}
return &view, nil
}
func (s *AdminEventService) ImportEventPresentation(ctx context.Context, eventPublicID string, input ImportAdminEventPresentationInput) (*AdminEventPresentationView, 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.Title = strings.TrimSpace(input.Title)
input.TemplateKey = strings.TrimSpace(input.TemplateKey)
input.SourceType = strings.TrimSpace(input.SourceType)
input.SchemaURL = strings.TrimSpace(input.SchemaURL)
input.Version = strings.TrimSpace(input.Version)
if input.Title == "" || input.TemplateKey == "" || input.SourceType == "" || input.SchemaURL == "" || input.Version == "" {
return nil, apperr.New(http.StatusBadRequest, "invalid_params", "title, templateKey, sourceType, schemaUrl and version are required")
}
publicID, err := security.GeneratePublicID("pres")
if err != nil {
return nil, err
}
code := generateImportedPresentationCode(input.Title, publicID)
status := normalizeEventCatalogStatus(input.Status)
if strings.TrimSpace(input.Status) == "" {
status = "active"
}
schema := map[string]any{
"templateKey": input.TemplateKey,
"sourceType": input.SourceType,
"schemaUrl": input.SchemaURL,
"version": input.Version,
}
tx, err := s.store.Begin(ctx)
if err != nil {
return nil, err
}
defer tx.Rollback(ctx)
record, err := s.store.CreateEventPresentation(ctx, tx, postgres.CreateEventPresentationParams{
PublicID: publicID,
EventID: eventRecord.ID,
Code: code,
Name: input.Title,
PresentationType: "generic",
Status: status,
IsDefault: input.IsDefault,
SchemaJSON: mustMarshalJSONObject(schema),
})
if err != nil {
return nil, err
}
if err := tx.Commit(ctx); err != nil {
return nil, err
}
view, err := buildAdminEventPresentationView(*record)
if err != nil {
return nil, err
}
return &view, nil
}
func (s *AdminEventService) GetEventPresentation(ctx context.Context, presentationPublicID string) (*AdminEventPresentationView, error) {
record, err := s.store.GetEventPresentationByPublicID(ctx, strings.TrimSpace(presentationPublicID))
if err != nil {
return nil, err
}
if record == nil {
return nil, apperr.New(http.StatusNotFound, "presentation_not_found", "presentation not found")
}
view, err := buildAdminEventPresentationView(*record)
if err != nil {
return nil, err
}
return &view, nil
}
func (s *AdminEventService) ListContentBundles(ctx context.Context, eventPublicID string, limit int) ([]AdminContentBundleView, 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")
}
items, err := s.store.ListContentBundlesByEventID(ctx, eventRecord.ID, limit)
if err != nil {
return nil, err
}
result := make([]AdminContentBundleView, 0, len(items))
for _, item := range items {
view, err := buildAdminContentBundleView(item)
if err != nil {
return nil, err
}
result = append(result, view)
}
return result, nil
}
func (s *AdminEventService) CreateContentBundle(ctx context.Context, eventPublicID string, input CreateAdminContentBundleInput) (*AdminContentBundleView, 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.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("bundle")
if err != nil {
return nil, err
}
metadata := input.Metadata
if metadata == nil {
metadata = map[string]any{}
}
tx, err := s.store.Begin(ctx)
if err != nil {
return nil, err
}
defer tx.Rollback(ctx)
record, err := s.store.CreateContentBundle(ctx, tx, postgres.CreateContentBundleParams{
PublicID: publicID,
EventID: eventRecord.ID,
Code: input.Code,
Name: input.Name,
Status: normalizeEventCatalogStatus(input.Status),
IsDefault: input.IsDefault,
EntryURL: trimStringPtr(input.EntryURL),
AssetRootURL: trimStringPtr(input.AssetRootURL),
MetadataJSON: mustMarshalJSONObject(metadata),
})
if err != nil {
return nil, err
}
if err := tx.Commit(ctx); err != nil {
return nil, err
}
view, err := buildAdminContentBundleView(*record)
if err != nil {
return nil, err
}
return &view, nil
}
func (s *AdminEventService) ImportContentBundle(ctx context.Context, eventPublicID string, input ImportAdminContentBundleInput) (*AdminContentBundleView, 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.Title = strings.TrimSpace(input.Title)
input.BundleType = strings.TrimSpace(input.BundleType)
input.SourceType = strings.TrimSpace(input.SourceType)
input.ManifestURL = strings.TrimSpace(input.ManifestURL)
input.Version = strings.TrimSpace(input.Version)
if input.Title == "" || input.BundleType == "" || input.SourceType == "" || input.ManifestURL == "" || input.Version == "" {
return nil, apperr.New(http.StatusBadRequest, "invalid_params", "title, bundleType, sourceType, manifestUrl and version are required")
}
publicID, err := security.GeneratePublicID("bundle")
if err != nil {
return nil, err
}
code := generateImportedBundleCode(input.Title, publicID)
assetManifest := input.AssetManifest
if assetManifest == nil {
assetManifest = map[string]any{
"manifestUrl": input.ManifestURL,
"sourceType": input.SourceType,
}
}
metadata := map[string]any{
"bundleType": input.BundleType,
"sourceType": input.SourceType,
"manifestUrl": input.ManifestURL,
"version": input.Version,
"assetManifest": assetManifest,
}
status := normalizeEventCatalogStatus(input.Status)
if strings.TrimSpace(input.Status) == "" {
status = "active"
}
tx, err := s.store.Begin(ctx)
if err != nil {
return nil, err
}
defer tx.Rollback(ctx)
record, err := s.store.CreateContentBundle(ctx, tx, postgres.CreateContentBundleParams{
PublicID: publicID,
EventID: eventRecord.ID,
Code: code,
Name: input.Title,
Status: status,
IsDefault: input.IsDefault,
EntryURL: nil,
AssetRootURL: nil,
MetadataJSON: mustMarshalJSONObject(metadata),
})
if err != nil {
return nil, err
}
if err := tx.Commit(ctx); err != nil {
return nil, err
}
view, err := buildAdminContentBundleView(*record)
if err != nil {
return nil, err
}
return &view, nil
}
func (s *AdminEventService) GetContentBundle(ctx context.Context, contentBundlePublicID string) (*AdminContentBundleView, error) {
record, err := s.store.GetContentBundleByPublicID(ctx, strings.TrimSpace(contentBundlePublicID))
if err != nil {
return nil, err
}
if record == nil {
return nil, apperr.New(http.StatusNotFound, "content_bundle_not_found", "content bundle not found")
}
view, err := buildAdminContentBundleView(*record)
if err != nil {
return nil, err
}
return &view, nil
}
func (s *AdminEventService) UpdateEventDefaults(ctx context.Context, eventPublicID string, input UpdateAdminEventDefaultsInput) (*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")
}
var presentationID *string
updatePresentation := false
if input.PresentationID != nil {
updatePresentation = true
trimmed := strings.TrimSpace(*input.PresentationID)
if trimmed != "" {
presentation, err := s.store.GetEventPresentationByPublicID(ctx, trimmed)
if err != nil {
return nil, err
}
if presentation == nil {
return nil, apperr.New(http.StatusNotFound, "presentation_not_found", "presentation not found")
}
if presentation.EventID != record.ID {
return nil, apperr.New(http.StatusConflict, "presentation_not_belong_to_event", "presentation does not belong to event")
}
presentationID = &presentation.ID
}
}
var contentBundleID *string
updateContent := false
if input.ContentBundleID != nil {
updateContent = true
trimmed := strings.TrimSpace(*input.ContentBundleID)
if trimmed != "" {
contentBundle, err := s.store.GetContentBundleByPublicID(ctx, trimmed)
if err != nil {
return nil, err
}
if contentBundle == nil {
return nil, apperr.New(http.StatusNotFound, "content_bundle_not_found", "content bundle not found")
}
if contentBundle.EventID != record.ID {
return nil, apperr.New(http.StatusConflict, "content_bundle_not_belong_to_event", "content bundle does not belong to event")
}
contentBundleID = &contentBundle.ID
}
}
var runtimeBindingID *string
updateRuntime := false
if input.RuntimeBindingID != nil {
updateRuntime = true
trimmed := strings.TrimSpace(*input.RuntimeBindingID)
if trimmed != "" {
runtimeBinding, err := s.store.GetMapRuntimeBindingByPublicID(ctx, trimmed)
if err != nil {
return nil, err
}
if runtimeBinding == nil {
return nil, apperr.New(http.StatusNotFound, "runtime_binding_not_found", "runtime binding not found")
}
if runtimeBinding.EventID != record.ID {
return nil, apperr.New(http.StatusConflict, "runtime_binding_not_belong_to_event", "runtime binding does not belong to event")
}
runtimeBindingID = &runtimeBinding.ID
}
}
tx, err := s.store.Begin(ctx)
if err != nil {
return nil, err
}
defer tx.Rollback(ctx)
if err := s.store.SetEventDefaultBindings(ctx, tx, postgres.SetEventDefaultBindingsParams{
EventID: record.ID,
PresentationID: presentationID,
ContentBundleID: contentBundleID,
RuntimeBindingID: runtimeBindingID,
UpdatePresentation: updatePresentation,
UpdateContent: updateContent,
UpdateRuntime: updateRuntime,
}); err != nil {
return nil, err
}
if err := tx.Commit(ctx); err != nil {
return nil, err
}
return s.GetEventDetail(ctx, eventPublicID)
}
func (s *AdminEventService) SaveEventSource(ctx context.Context, eventPublicID string, input SaveAdminEventSourceInput) (*EventConfigSourceView, error) { func (s *AdminEventService) SaveEventSource(ctx context.Context, eventPublicID string, input SaveAdminEventSourceInput) (*EventConfigSourceView, error) {
eventRecord, err := s.store.GetAdminEventByPublicID(ctx, strings.TrimSpace(eventPublicID)) eventRecord, err := s.store.GetAdminEventByPublicID(ctx, strings.TrimSpace(eventPublicID))
if err != nil { if err != nil {
@@ -441,11 +955,160 @@ func buildAdminEventSummary(item postgres.AdminEventRecord) AdminEventSummary {
ManifestURL: item.ManifestURL, ManifestURL: item.ManifestURL,
ManifestChecksumSha256: item.ManifestChecksum, ManifestChecksumSha256: item.ManifestChecksum,
RouteCode: item.RouteCode, RouteCode: item.RouteCode,
Presentation: buildPresentationSummaryFromEventRecord(&item),
ContentBundle: buildContentBundleSummaryFromEventRecord(&item),
} }
} }
return summary return summary
} }
func buildPresentationSummaryFromEventRecord(item *postgres.AdminEventRecord) *PresentationSummaryView {
if item == nil || item.CurrentPresentationID == nil {
return nil
}
return &PresentationSummaryView{
PresentationID: *item.CurrentPresentationID,
Name: item.CurrentPresentationName,
PresentationType: item.CurrentPresentationType,
}
}
func buildContentBundleSummaryFromEventRecord(item *postgres.AdminEventRecord) *ContentBundleSummaryView {
if item == nil || item.CurrentContentBundleID == nil {
return nil
}
return &ContentBundleSummaryView{
ContentBundleID: *item.CurrentContentBundleID,
Name: item.CurrentContentBundleName,
EntryURL: item.CurrentContentEntryURL,
AssetRootURL: item.CurrentContentAssetRootURL,
}
}
func buildRuntimeSummaryFromAdminEventRecord(item *postgres.AdminEventRecord) *RuntimeSummaryView {
if item == nil ||
item.CurrentRuntimeBindingID == nil ||
item.CurrentPlaceID == nil ||
item.CurrentMapAssetID == nil ||
item.CurrentTileReleaseID == nil ||
item.CurrentCourseSetID == nil ||
item.CurrentCourseVariantID == nil {
return nil
}
return &RuntimeSummaryView{
RuntimeBindingID: *item.CurrentRuntimeBindingID,
PlaceID: *item.CurrentPlaceID,
MapID: *item.CurrentMapAssetID,
TileReleaseID: *item.CurrentTileReleaseID,
CourseSetID: *item.CurrentCourseSetID,
CourseVariantID: *item.CurrentCourseVariantID,
CourseVariantName: item.CurrentCourseVariantName,
RouteCode: item.CurrentRuntimeRouteCode,
}
}
func buildAdminEventPresentationView(item postgres.EventPresentation) (AdminEventPresentationView, error) {
schema, err := decodeJSONObject(item.SchemaJSON)
if err != nil {
return AdminEventPresentationView{}, err
}
return AdminEventPresentationView{
ID: item.PublicID,
EventID: item.EventPublicID,
Code: item.Code,
Name: item.Name,
PresentationType: item.PresentationType,
Status: item.Status,
IsDefault: item.IsDefault,
TemplateKey: readStringField(schema, "templateKey"),
Version: readStringField(schema, "version"),
SourceType: readStringField(schema, "sourceType"),
SchemaURL: readStringField(schema, "schemaUrl"),
Schema: schema,
}, nil
}
func buildAdminContentBundleView(item postgres.ContentBundle) (AdminContentBundleView, error) {
metadata, err := decodeJSONObject(item.MetadataJSON)
if err != nil {
return AdminContentBundleView{}, err
}
return AdminContentBundleView{
ID: item.PublicID,
EventID: item.EventPublicID,
Code: item.Code,
Name: item.Name,
Status: item.Status,
IsDefault: item.IsDefault,
BundleType: readStringField(metadata, "bundleType"),
Version: readStringField(metadata, "version"),
SourceType: readStringField(metadata, "sourceType"),
ManifestURL: readStringField(metadata, "manifestUrl"),
AssetManifest: metadata["assetManifest"],
EntryURL: item.EntryURL,
AssetRootURL: item.AssetRootURL,
Metadata: metadata,
}, nil
}
func generateImportedBundleCode(title, publicID string) string {
var builder strings.Builder
for _, r := range strings.ToLower(title) {
switch {
case r >= 'a' && r <= 'z':
builder.WriteRune(r)
case r >= '0' && r <= '9':
builder.WriteRune(r)
case r == ' ' || r == '-' || r == '_':
if builder.Len() == 0 {
continue
}
last := builder.String()[builder.Len()-1]
if last != '-' {
builder.WriteByte('-')
}
}
}
code := strings.Trim(builder.String(), "-")
if code == "" {
code = "bundle"
}
suffix := publicID
if len(suffix) > 8 {
suffix = suffix[len(suffix)-8:]
}
return code + "-" + suffix
}
func generateImportedPresentationCode(title, publicID string) string {
var builder strings.Builder
for _, r := range strings.ToLower(title) {
switch {
case r >= 'a' && r <= 'z':
builder.WriteRune(r)
case r >= '0' && r <= '9':
builder.WriteRune(r)
case r == ' ' || r == '-' || r == '_':
if builder.Len() == 0 {
continue
}
last := builder.String()[builder.Len()-1]
if last != '-' {
builder.WriteByte('-')
}
}
}
code := strings.Trim(builder.String(), "-")
if code == "" {
code = "presentation"
}
suffix := publicID
if len(suffix) > 8 {
suffix = suffix[len(suffix)-8:]
}
return code + "-" + suffix
}
func buildAdminAssembledSource(source map[string]any) *AdminAssembledSource { func buildAdminAssembledSource(source map[string]any) *AdminAssembledSource {
result := &AdminAssembledSource{} result := &AdminAssembledSource{}
if refs, ok := source["refs"].(map[string]any); ok { if refs, ok := source["refs"].(map[string]any); ok {
@@ -474,6 +1137,26 @@ func normalizeEventCatalogStatus(value string) string {
} }
} }
func normalizePresentationType(value string) string {
switch strings.TrimSpace(value) {
case "card":
return "card"
case "detail":
return "detail"
case "h5":
return "h5"
case "result":
return "result"
default:
return "generic"
}
}
func mustMarshalJSONObject(value map[string]any) string {
raw, _ := json.Marshal(value)
return string(raw)
}
func mergeJSONObject(target map[string]any, overrides map[string]any) { func mergeJSONObject(target map[string]any, overrides map[string]any) {
for key, value := range overrides { for key, value := range overrides {
if valueMap, ok := value.(map[string]any); ok { if valueMap, ok := value.(map[string]any); ok {

View File

@@ -15,15 +15,18 @@ type AdminPipelineService struct {
} }
type AdminReleaseView struct { type AdminReleaseView struct {
ID string `json:"id"` ID string `json:"id"`
ReleaseNo int `json:"releaseNo"` ReleaseNo int `json:"releaseNo"`
ConfigLabel string `json:"configLabel"` ConfigLabel string `json:"configLabel"`
ManifestURL string `json:"manifestUrl"` ManifestURL string `json:"manifestUrl"`
ManifestChecksumSha256 *string `json:"manifestChecksumSha256,omitempty"` ManifestChecksumSha256 *string `json:"manifestChecksumSha256,omitempty"`
RouteCode *string `json:"routeCode,omitempty"` RouteCode *string `json:"routeCode,omitempty"`
BuildID *string `json:"buildId,omitempty"` BuildID *string `json:"buildId,omitempty"`
Status string `json:"status"` Status string `json:"status"`
PublishedAt string `json:"publishedAt"` PublishedAt string `json:"publishedAt"`
Runtime *RuntimeSummaryView `json:"runtime,omitempty"`
Presentation *PresentationSummaryView `json:"presentation,omitempty"`
ContentBundle *ContentBundleSummaryView `json:"contentBundle,omitempty"`
} }
type AdminEventPipelineView struct { type AdminEventPipelineView struct {
@@ -38,6 +41,16 @@ type AdminRollbackReleaseInput struct {
ReleaseID string `json:"releaseId"` ReleaseID string `json:"releaseId"`
} }
type AdminBindReleaseRuntimeInput struct {
RuntimeBindingID string `json:"runtimeBindingId"`
}
type AdminPublishBuildInput struct {
RuntimeBindingID string `json:"runtimeBindingId,omitempty"`
PresentationID string `json:"presentationId,omitempty"`
ContentBundleID string `json:"contentBundleId,omitempty"`
}
func NewAdminPipelineService(store *postgres.Store, configService *ConfigService) *AdminPipelineService { func NewAdminPipelineService(store *postgres.Store, configService *ConfigService) *AdminPipelineService {
return &AdminPipelineService{ return &AdminPipelineService{
store: store, store: store,
@@ -77,7 +90,18 @@ func (s *AdminPipelineService) GetEventPipeline(ctx context.Context, eventPublic
} }
releases := make([]AdminReleaseView, 0, len(releaseRecords)) releases := make([]AdminReleaseView, 0, len(releaseRecords))
for _, item := range releaseRecords { for _, item := range releaseRecords {
releases = append(releases, buildAdminReleaseView(item)) view := buildAdminReleaseView(item)
if enrichedPresentation, err := loadPresentationSummaryByPublicID(ctx, s.store, item.PresentationID); err != nil {
return nil, err
} else if enrichedPresentation != nil {
view.Presentation = enrichedPresentation
}
if enrichedBundle, err := loadContentBundleSummaryByPublicID(ctx, s.store, item.ContentBundleID); err != nil {
return nil, err
} else if enrichedBundle != nil {
view.ContentBundle = enrichedBundle
}
releases = append(releases, view)
} }
result := &AdminEventPipelineView{ result := &AdminEventPipelineView{
@@ -94,6 +118,19 @@ func (s *AdminPipelineService) GetEventPipeline(ctx context.Context, eventPublic
ManifestChecksumSha256: event.ManifestChecksum, ManifestChecksumSha256: event.ManifestChecksum,
RouteCode: event.RouteCode, RouteCode: event.RouteCode,
Status: "published", Status: "published",
Runtime: buildRuntimeSummaryFromEvent(event),
Presentation: buildPresentationSummaryFromEvent(event),
ContentBundle: buildContentBundleSummaryFromEvent(event),
}
if enrichedPresentation, err := loadPresentationSummaryByPublicID(ctx, s.store, event.PresentationID); err != nil {
return nil, err
} else if enrichedPresentation != nil {
result.CurrentRelease.Presentation = enrichedPresentation
}
if enrichedBundle, err := loadContentBundleSummaryByPublicID(ctx, s.store, event.ContentBundleID); err != nil {
return nil, err
} else if enrichedBundle != nil {
result.CurrentRelease.ContentBundle = enrichedBundle
} }
} }
return result, nil return result, nil
@@ -107,8 +144,84 @@ func (s *AdminPipelineService) GetBuild(ctx context.Context, buildID string) (*E
return s.configService.GetEventConfigBuild(ctx, buildID) return s.configService.GetEventConfigBuild(ctx, buildID)
} }
func (s *AdminPipelineService) PublishBuild(ctx context.Context, buildID string) (*PublishedReleaseView, error) { func (s *AdminPipelineService) PublishBuild(ctx context.Context, buildID string, input AdminPublishBuildInput) (*PublishedReleaseView, error) {
return s.configService.PublishBuild(ctx, PublishBuildInput{BuildID: buildID}) return s.configService.PublishBuild(ctx, PublishBuildInput{
BuildID: buildID,
RuntimeBindingID: input.RuntimeBindingID,
PresentationID: input.PresentationID,
ContentBundleID: input.ContentBundleID,
})
}
func (s *AdminPipelineService) GetRelease(ctx context.Context, releasePublicID string) (*AdminReleaseView, error) {
release, err := s.store.GetEventReleaseByPublicID(ctx, strings.TrimSpace(releasePublicID))
if err != nil {
return nil, err
}
if release == nil {
return nil, apperr.New(http.StatusNotFound, "release_not_found", "release not found")
}
view := buildAdminReleaseView(*release)
if enrichedPresentation, err := loadPresentationSummaryByPublicID(ctx, s.store, release.PresentationID); err != nil {
return nil, err
} else if enrichedPresentation != nil {
view.Presentation = enrichedPresentation
}
if enrichedBundle, err := loadContentBundleSummaryByPublicID(ctx, s.store, release.ContentBundleID); err != nil {
return nil, err
} else if enrichedBundle != nil {
view.ContentBundle = enrichedBundle
}
return &view, nil
}
func (s *AdminPipelineService) BindReleaseRuntime(ctx context.Context, releasePublicID string, input AdminBindReleaseRuntimeInput) (*AdminReleaseView, error) {
release, err := s.store.GetEventReleaseByPublicID(ctx, strings.TrimSpace(releasePublicID))
if err != nil {
return nil, err
}
if release == nil {
return nil, apperr.New(http.StatusNotFound, "release_not_found", "release not found")
}
input.RuntimeBindingID = strings.TrimSpace(input.RuntimeBindingID)
if input.RuntimeBindingID == "" {
return nil, apperr.New(http.StatusBadRequest, "invalid_params", "runtimeBindingId is required")
}
runtimeBinding, err := s.store.GetMapRuntimeBindingByPublicID(ctx, input.RuntimeBindingID)
if err != nil {
return nil, err
}
if runtimeBinding == nil {
return nil, apperr.New(http.StatusNotFound, "runtime_binding_not_found", "runtime binding not found")
}
if runtimeBinding.EventID != release.EventID {
return nil, apperr.New(http.StatusConflict, "runtime_binding_not_belong_to_event", "runtime binding does not belong to release event")
}
tx, err := s.store.Begin(ctx)
if err != nil {
return nil, err
}
defer tx.Rollback(ctx)
if err := s.store.SetEventReleaseRuntimeBinding(ctx, tx, release.ID, &runtimeBinding.ID); err != nil {
return nil, err
}
if err := tx.Commit(ctx); err != nil {
return nil, err
}
updated, err := s.store.GetEventReleaseByPublicID(ctx, release.PublicID)
if err != nil {
return nil, err
}
if updated == nil {
return nil, apperr.New(http.StatusNotFound, "release_not_found", "release not found")
}
view := buildAdminReleaseView(*updated)
return &view, nil
} }
func (s *AdminPipelineService) RollbackRelease(ctx context.Context, eventPublicID string, input AdminRollbackReleaseInput) (*AdminReleaseView, error) { func (s *AdminPipelineService) RollbackRelease(ctx context.Context, eventPublicID string, input AdminRollbackReleaseInput) (*AdminReleaseView, error) {
@@ -167,6 +280,9 @@ func buildAdminReleaseView(item postgres.EventRelease) AdminReleaseView {
BuildID: item.BuildID, BuildID: item.BuildID,
Status: item.Status, Status: item.Status,
PublishedAt: item.PublishedAt.Format(timeRFC3339), PublishedAt: item.PublishedAt.Format(timeRFC3339),
Runtime: buildRuntimeSummaryFromRelease(&item),
Presentation: buildPresentationSummaryFromRelease(&item),
ContentBundle: buildContentBundleSummaryFromRelease(&item),
} }
} }

View File

@@ -0,0 +1,935 @@
package service
import (
"context"
"net/http"
"strings"
"time"
"cmr-backend/internal/apperr"
"cmr-backend/internal/platform/security"
"cmr-backend/internal/store/postgres"
)
type AdminProductionService struct {
store *postgres.Store
}
type AdminPlaceSummary struct {
ID string `json:"id"`
Code string `json:"code"`
Name string `json:"name"`
Region *string `json:"region,omitempty"`
CoverURL *string `json:"coverUrl,omitempty"`
Description *string `json:"description,omitempty"`
CenterPoint map[string]any `json:"centerPoint,omitempty"`
Status string `json:"status"`
}
type AdminPlaceDetail struct {
Place AdminPlaceSummary `json:"place"`
MapAssets []AdminMapAssetSummary `json:"mapAssets"`
}
type CreateAdminPlaceInput struct {
Code string `json:"code"`
Name string `json:"name"`
Region *string `json:"region,omitempty"`
CoverURL *string `json:"coverUrl,omitempty"`
Description *string `json:"description,omitempty"`
CenterPoint map[string]any `json:"centerPoint,omitempty"`
Status string `json:"status"`
}
type AdminMapAssetSummary struct {
ID string `json:"id"`
PlaceID string `json:"placeId"`
LegacyMapID *string `json:"legacyMapId,omitempty"`
Code string `json:"code"`
Name string `json:"name"`
MapType string `json:"mapType"`
CoverURL *string `json:"coverUrl,omitempty"`
Description *string `json:"description,omitempty"`
Status string `json:"status"`
CurrentTileRelease *AdminTileReleaseBrief `json:"currentTileRelease,omitempty"`
}
type AdminTileReleaseBrief struct {
ID string `json:"id"`
VersionCode string `json:"versionCode"`
Status string `json:"status"`
}
type AdminMapAssetDetail struct {
MapAsset AdminMapAssetSummary `json:"mapAsset"`
TileReleases []AdminTileReleaseView `json:"tileReleases"`
CourseSets []AdminCourseSetBrief `json:"courseSets"`
}
type CreateAdminMapAssetInput struct {
Code string `json:"code"`
Name string `json:"name"`
MapType string `json:"mapType"`
LegacyMapID *string `json:"legacyMapId,omitempty"`
CoverURL *string `json:"coverUrl,omitempty"`
Description *string `json:"description,omitempty"`
Status string `json:"status"`
}
type AdminTileReleaseView struct {
ID string `json:"id"`
LegacyVersionID *string `json:"legacyVersionId,omitempty"`
VersionCode string `json:"versionCode"`
Status string `json:"status"`
TileBaseURL string `json:"tileBaseUrl"`
MetaURL string `json:"metaUrl"`
PublishedAssetRoot *string `json:"publishedAssetRoot,omitempty"`
Metadata map[string]any `json:"metadata,omitempty"`
PublishedAt *time.Time `json:"publishedAt,omitempty"`
}
type CreateAdminTileReleaseInput struct {
LegacyVersionID *string `json:"legacyVersionId,omitempty"`
VersionCode string `json:"versionCode"`
Status string `json:"status"`
TileBaseURL string `json:"tileBaseUrl"`
MetaURL string `json:"metaUrl"`
PublishedAssetRoot *string `json:"publishedAssetRoot,omitempty"`
Metadata map[string]any `json:"metadata,omitempty"`
SetAsCurrent bool `json:"setAsCurrent"`
}
type AdminCourseSourceSummary struct {
ID string `json:"id"`
LegacyVersionID *string `json:"legacyVersionId,omitempty"`
SourceType string `json:"sourceType"`
FileURL string `json:"fileUrl"`
Checksum *string `json:"checksum,omitempty"`
ParserVersion *string `json:"parserVersion,omitempty"`
ImportStatus string `json:"importStatus"`
Metadata map[string]any `json:"metadata,omitempty"`
ImportedAt time.Time `json:"importedAt"`
}
type CreateAdminCourseSourceInput struct {
LegacyPlayfieldID *string `json:"legacyPlayfieldId,omitempty"`
LegacyVersionID *string `json:"legacyVersionId,omitempty"`
SourceType string `json:"sourceType"`
FileURL string `json:"fileUrl"`
Checksum *string `json:"checksum,omitempty"`
ParserVersion *string `json:"parserVersion,omitempty"`
ImportStatus string `json:"importStatus"`
Metadata map[string]any `json:"metadata,omitempty"`
}
type AdminCourseSetBrief struct {
ID string `json:"id"`
Code string `json:"code"`
Mode string `json:"mode"`
Name string `json:"name"`
Description *string `json:"description,omitempty"`
Status string `json:"status"`
CurrentVariant *AdminCourseVariantBrief `json:"currentVariant,omitempty"`
}
type AdminCourseVariantBrief struct {
ID string `json:"id"`
Name string `json:"name"`
RouteCode *string `json:"routeCode,omitempty"`
Status string `json:"status"`
}
type AdminCourseSetDetail struct {
CourseSet AdminCourseSetBrief `json:"courseSet"`
Variants []AdminCourseVariantView `json:"variants"`
}
type CreateAdminCourseSetInput struct {
Code string `json:"code"`
Mode string `json:"mode"`
Name string `json:"name"`
Description *string `json:"description,omitempty"`
Status string `json:"status"`
}
type AdminCourseVariantView struct {
ID string `json:"id"`
SourceID *string `json:"sourceId,omitempty"`
Name string `json:"name"`
RouteCode *string `json:"routeCode,omitempty"`
Mode string `json:"mode"`
ControlCount *int `json:"controlCount,omitempty"`
Difficulty *string `json:"difficulty,omitempty"`
Status string `json:"status"`
IsDefault bool `json:"isDefault"`
ConfigPatch map[string]any `json:"configPatch,omitempty"`
Metadata map[string]any `json:"metadata,omitempty"`
}
type CreateAdminCourseVariantInput struct {
SourceID *string `json:"sourceId,omitempty"`
Name string `json:"name"`
RouteCode *string `json:"routeCode,omitempty"`
Mode string `json:"mode"`
ControlCount *int `json:"controlCount,omitempty"`
Difficulty *string `json:"difficulty,omitempty"`
Status string `json:"status"`
IsDefault bool `json:"isDefault"`
ConfigPatch map[string]any `json:"configPatch,omitempty"`
Metadata map[string]any `json:"metadata,omitempty"`
}
type AdminRuntimeBindingSummary struct {
ID string `json:"id"`
EventID string `json:"eventId"`
PlaceID string `json:"placeId"`
MapAssetID string `json:"mapAssetId"`
TileReleaseID string `json:"tileReleaseId"`
CourseSetID string `json:"courseSetId"`
CourseVariantID string `json:"courseVariantId"`
Status string `json:"status"`
Notes *string `json:"notes,omitempty"`
}
type CreateAdminRuntimeBindingInput struct {
EventID string `json:"eventId"`
PlaceID string `json:"placeId"`
MapAssetID string `json:"mapAssetId"`
TileReleaseID string `json:"tileReleaseId"`
CourseSetID string `json:"courseSetId"`
CourseVariantID string `json:"courseVariantId"`
Status string `json:"status"`
Notes *string `json:"notes,omitempty"`
}
func NewAdminProductionService(store *postgres.Store) *AdminProductionService {
return &AdminProductionService{store: store}
}
func (s *AdminProductionService) ListPlaces(ctx context.Context, limit int) ([]AdminPlaceSummary, error) {
items, err := s.store.ListPlaces(ctx, limit)
if err != nil {
return nil, err
}
result := make([]AdminPlaceSummary, 0, len(items))
for _, item := range items {
result = append(result, buildAdminPlaceSummary(item))
}
return result, nil
}
func (s *AdminProductionService) CreatePlace(ctx context.Context, input CreateAdminPlaceInput) (*AdminPlaceSummary, error) {
input.Code = strings.TrimSpace(input.Code)
input.Name = strings.TrimSpace(input.Name)
if input.Code == "" || input.Name == "" {
return nil, apperr.New(http.StatusBadRequest, "invalid_params", "code and name are required")
}
publicID, err := security.GeneratePublicID("place")
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.CreatePlace(ctx, tx, postgres.CreatePlaceParams{
PublicID: publicID,
Code: input.Code,
Name: input.Name,
Region: trimStringPtr(input.Region),
CoverURL: trimStringPtr(input.CoverURL),
Description: trimStringPtr(input.Description),
CenterPoint: input.CenterPoint,
Status: normalizeCatalogStatus(input.Status),
})
if err != nil {
return nil, err
}
if err := tx.Commit(ctx); err != nil {
return nil, err
}
result := buildAdminPlaceSummary(*item)
return &result, nil
}
func (s *AdminProductionService) GetPlaceDetail(ctx context.Context, placePublicID string) (*AdminPlaceDetail, error) {
place, err := s.store.GetPlaceByPublicID(ctx, strings.TrimSpace(placePublicID))
if err != nil {
return nil, err
}
if place == nil {
return nil, apperr.New(http.StatusNotFound, "place_not_found", "place not found")
}
mapAssets, err := s.store.ListMapAssetsByPlaceID(ctx, place.ID)
if err != nil {
return nil, err
}
result := &AdminPlaceDetail{
Place: buildAdminPlaceSummary(*place),
MapAssets: make([]AdminMapAssetSummary, 0, len(mapAssets)),
}
for _, item := range mapAssets {
summary, err := s.buildAdminMapAssetSummary(ctx, item)
if err != nil {
return nil, err
}
result.MapAssets = append(result.MapAssets, summary)
}
return result, nil
}
func (s *AdminProductionService) CreateMapAsset(ctx context.Context, placePublicID string, input CreateAdminMapAssetInput) (*AdminMapAssetSummary, error) {
place, err := s.store.GetPlaceByPublicID(ctx, strings.TrimSpace(placePublicID))
if err != nil {
return nil, err
}
if place == nil {
return nil, apperr.New(http.StatusNotFound, "place_not_found", "place not found")
}
input.Code = strings.TrimSpace(input.Code)
input.Name = strings.TrimSpace(input.Name)
mapType := strings.TrimSpace(input.MapType)
if mapType == "" {
mapType = "standard"
}
if input.Code == "" || input.Name == "" {
return nil, apperr.New(http.StatusBadRequest, "invalid_params", "code and name are required")
}
var legacyMapID *string
if input.LegacyMapID != nil && strings.TrimSpace(*input.LegacyMapID) != "" {
legacyMap, err := s.store.GetResourceMapByPublicID(ctx, strings.TrimSpace(*input.LegacyMapID))
if err != nil {
return nil, err
}
if legacyMap == nil {
return nil, apperr.New(http.StatusNotFound, "legacy_map_not_found", "legacy map not found")
}
legacyMapID = &legacyMap.ID
}
publicID, err := security.GeneratePublicID("mapasset")
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.CreateMapAsset(ctx, tx, postgres.CreateMapAssetParams{
PublicID: publicID,
PlaceID: place.ID,
LegacyMapID: legacyMapID,
Code: input.Code,
Name: input.Name,
MapType: mapType,
CoverURL: trimStringPtr(input.CoverURL),
Description: trimStringPtr(input.Description),
Status: normalizeCatalogStatus(input.Status),
})
if err != nil {
return nil, err
}
if err := tx.Commit(ctx); err != nil {
return nil, err
}
result, err := s.buildAdminMapAssetSummary(ctx, *item)
if err != nil {
return nil, err
}
return &result, nil
}
func (s *AdminProductionService) GetMapAssetDetail(ctx context.Context, mapAssetPublicID string) (*AdminMapAssetDetail, error) {
item, err := s.store.GetMapAssetByPublicID(ctx, strings.TrimSpace(mapAssetPublicID))
if err != nil {
return nil, err
}
if item == nil {
return nil, apperr.New(http.StatusNotFound, "map_asset_not_found", "map asset not found")
}
summary, err := s.buildAdminMapAssetSummary(ctx, *item)
if err != nil {
return nil, err
}
tileReleases, err := s.store.ListTileReleasesByMapAssetID(ctx, item.ID)
if err != nil {
return nil, err
}
courseSets, err := s.store.ListCourseSetsByMapAssetID(ctx, item.ID)
if err != nil {
return nil, err
}
result := &AdminMapAssetDetail{
MapAsset: summary,
TileReleases: make([]AdminTileReleaseView, 0, len(tileReleases)),
CourseSets: make([]AdminCourseSetBrief, 0, len(courseSets)),
}
for _, release := range tileReleases {
result.TileReleases = append(result.TileReleases, buildAdminTileReleaseView(release))
}
for _, courseSet := range courseSets {
brief, err := s.buildAdminCourseSetBrief(ctx, courseSet)
if err != nil {
return nil, err
}
result.CourseSets = append(result.CourseSets, brief)
}
return result, nil
}
func (s *AdminProductionService) CreateTileRelease(ctx context.Context, mapAssetPublicID string, input CreateAdminTileReleaseInput) (*AdminTileReleaseView, error) {
mapAsset, err := s.store.GetMapAssetByPublicID(ctx, strings.TrimSpace(mapAssetPublicID))
if err != nil {
return nil, err
}
if mapAsset == nil {
return nil, apperr.New(http.StatusNotFound, "map_asset_not_found", "map asset not found")
}
input.VersionCode = strings.TrimSpace(input.VersionCode)
input.TileBaseURL = strings.TrimSpace(input.TileBaseURL)
input.MetaURL = strings.TrimSpace(input.MetaURL)
if input.VersionCode == "" || input.TileBaseURL == "" || input.MetaURL == "" {
return nil, apperr.New(http.StatusBadRequest, "invalid_params", "versionCode, tileBaseUrl and metaUrl are required")
}
var legacyVersionID *string
if input.LegacyVersionID != nil && strings.TrimSpace(*input.LegacyVersionID) != "" {
if mapAsset.LegacyMapPublicID == nil || strings.TrimSpace(*mapAsset.LegacyMapPublicID) == "" {
return nil, apperr.New(http.StatusBadRequest, "legacy_map_missing", "map asset has no linked legacy map")
}
legacyVersion, err := s.store.GetResourceMapVersionByPublicID(ctx, *mapAsset.LegacyMapPublicID, strings.TrimSpace(*input.LegacyVersionID))
if err != nil {
return nil, err
}
if legacyVersion == nil {
return nil, apperr.New(http.StatusNotFound, "legacy_tile_version_not_found", "legacy map version not found")
}
legacyVersionID = &legacyVersion.ID
}
publicID, err := security.GeneratePublicID("tile")
if err != nil {
return nil, err
}
tx, err := s.store.Begin(ctx)
if err != nil {
return nil, err
}
defer tx.Rollback(ctx)
publishedAt := time.Now()
release, err := s.store.CreateTileRelease(ctx, tx, postgres.CreateTileReleaseParams{
PublicID: publicID,
MapAssetID: mapAsset.ID,
LegacyMapVersionID: legacyVersionID,
VersionCode: input.VersionCode,
Status: normalizeReleaseStatus(input.Status),
TileBaseURL: input.TileBaseURL,
MetaURL: input.MetaURL,
PublishedAssetRoot: trimStringPtr(input.PublishedAssetRoot),
MetadataJSON: input.Metadata,
PublishedAt: &publishedAt,
})
if err != nil {
return nil, err
}
if input.SetAsCurrent {
if err := s.store.SetMapAssetCurrentTileRelease(ctx, tx, mapAsset.ID, release.ID); err != nil {
return nil, err
}
}
if err := tx.Commit(ctx); err != nil {
return nil, err
}
view := buildAdminTileReleaseView(*release)
return &view, nil
}
func (s *AdminProductionService) ListCourseSources(ctx context.Context, limit int) ([]AdminCourseSourceSummary, error) {
items, err := s.store.ListCourseSources(ctx, limit)
if err != nil {
return nil, err
}
result := make([]AdminCourseSourceSummary, 0, len(items))
for _, item := range items {
result = append(result, buildAdminCourseSourceSummary(item))
}
return result, nil
}
func (s *AdminProductionService) CreateCourseSource(ctx context.Context, input CreateAdminCourseSourceInput) (*AdminCourseSourceSummary, error) {
sourceType := strings.TrimSpace(input.SourceType)
fileURL := strings.TrimSpace(input.FileURL)
if sourceType == "" || fileURL == "" {
return nil, apperr.New(http.StatusBadRequest, "invalid_params", "sourceType and fileUrl are required")
}
var legacyPlayfieldVersionID *string
if input.LegacyPlayfieldID != nil && input.LegacyVersionID != nil && strings.TrimSpace(*input.LegacyPlayfieldID) != "" && strings.TrimSpace(*input.LegacyVersionID) != "" {
version, err := s.store.GetResourcePlayfieldVersionByPublicID(ctx, strings.TrimSpace(*input.LegacyPlayfieldID), strings.TrimSpace(*input.LegacyVersionID))
if err != nil {
return nil, err
}
if version == nil {
return nil, apperr.New(http.StatusNotFound, "legacy_playfield_version_not_found", "legacy playfield version not found")
}
legacyPlayfieldVersionID = &version.ID
}
publicID, err := security.GeneratePublicID("csrc")
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.CreateCourseSource(ctx, tx, postgres.CreateCourseSourceParams{
PublicID: publicID,
LegacyPlayfieldVersionID: legacyPlayfieldVersionID,
SourceType: sourceType,
FileURL: fileURL,
Checksum: trimStringPtr(input.Checksum),
ParserVersion: trimStringPtr(input.ParserVersion),
ImportStatus: normalizeCourseSourceStatus(input.ImportStatus),
MetadataJSON: input.Metadata,
})
if err != nil {
return nil, err
}
if err := tx.Commit(ctx); err != nil {
return nil, err
}
result := buildAdminCourseSourceSummary(*item)
return &result, nil
}
func (s *AdminProductionService) GetCourseSource(ctx context.Context, sourcePublicID string) (*AdminCourseSourceSummary, error) {
item, err := s.store.GetCourseSourceByPublicID(ctx, strings.TrimSpace(sourcePublicID))
if err != nil {
return nil, err
}
if item == nil {
return nil, apperr.New(http.StatusNotFound, "course_source_not_found", "course source not found")
}
result := buildAdminCourseSourceSummary(*item)
return &result, nil
}
func (s *AdminProductionService) CreateCourseSet(ctx context.Context, mapAssetPublicID string, input CreateAdminCourseSetInput) (*AdminCourseSetBrief, error) {
mapAsset, err := s.store.GetMapAssetByPublicID(ctx, strings.TrimSpace(mapAssetPublicID))
if err != nil {
return nil, err
}
if mapAsset == nil {
return nil, apperr.New(http.StatusNotFound, "map_asset_not_found", "map asset not found")
}
input.Code = strings.TrimSpace(input.Code)
input.Mode = strings.TrimSpace(input.Mode)
input.Name = strings.TrimSpace(input.Name)
if input.Code == "" || input.Mode == "" || input.Name == "" {
return nil, apperr.New(http.StatusBadRequest, "invalid_params", "code, mode and name are required")
}
publicID, err := security.GeneratePublicID("cset")
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.CreateCourseSet(ctx, tx, postgres.CreateCourseSetParams{
PublicID: publicID,
PlaceID: mapAsset.PlaceID,
MapAssetID: mapAsset.ID,
Code: input.Code,
Mode: input.Mode,
Name: input.Name,
Description: trimStringPtr(input.Description),
Status: normalizeCatalogStatus(input.Status),
})
if err != nil {
return nil, err
}
if err := tx.Commit(ctx); err != nil {
return nil, err
}
brief, err := s.buildAdminCourseSetBrief(ctx, *item)
if err != nil {
return nil, err
}
return &brief, nil
}
func (s *AdminProductionService) GetCourseSetDetail(ctx context.Context, courseSetPublicID string) (*AdminCourseSetDetail, error) {
item, err := s.store.GetCourseSetByPublicID(ctx, strings.TrimSpace(courseSetPublicID))
if err != nil {
return nil, err
}
if item == nil {
return nil, apperr.New(http.StatusNotFound, "course_set_not_found", "course set not found")
}
brief, err := s.buildAdminCourseSetBrief(ctx, *item)
if err != nil {
return nil, err
}
variants, err := s.store.ListCourseVariantsByCourseSetID(ctx, item.ID)
if err != nil {
return nil, err
}
result := &AdminCourseSetDetail{
CourseSet: brief,
Variants: make([]AdminCourseVariantView, 0, len(variants)),
}
for _, variant := range variants {
result.Variants = append(result.Variants, buildAdminCourseVariantView(variant))
}
return result, nil
}
func (s *AdminProductionService) CreateCourseVariant(ctx context.Context, courseSetPublicID string, input CreateAdminCourseVariantInput) (*AdminCourseVariantView, error) {
courseSet, err := s.store.GetCourseSetByPublicID(ctx, strings.TrimSpace(courseSetPublicID))
if err != nil {
return nil, err
}
if courseSet == nil {
return nil, apperr.New(http.StatusNotFound, "course_set_not_found", "course set not found")
}
input.Name = strings.TrimSpace(input.Name)
input.Mode = strings.TrimSpace(input.Mode)
if input.Name == "" || input.Mode == "" {
return nil, apperr.New(http.StatusBadRequest, "invalid_params", "name and mode are required")
}
var sourceID *string
if input.SourceID != nil && strings.TrimSpace(*input.SourceID) != "" {
source, err := s.store.GetCourseSourceByPublicID(ctx, strings.TrimSpace(*input.SourceID))
if err != nil {
return nil, err
}
if source == nil {
return nil, apperr.New(http.StatusNotFound, "course_source_not_found", "course source not found")
}
sourceID = &source.ID
}
publicID, err := security.GeneratePublicID("cvar")
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.CreateCourseVariant(ctx, tx, postgres.CreateCourseVariantParams{
PublicID: publicID,
CourseSetID: courseSet.ID,
SourceID: sourceID,
Name: input.Name,
RouteCode: trimStringPtr(input.RouteCode),
Mode: input.Mode,
ControlCount: input.ControlCount,
Difficulty: trimStringPtr(input.Difficulty),
Status: normalizeCatalogStatus(input.Status),
IsDefault: input.IsDefault,
ConfigPatch: input.ConfigPatch,
MetadataJSON: input.Metadata,
})
if err != nil {
return nil, err
}
if input.IsDefault {
if err := s.store.SetCourseSetCurrentVariant(ctx, tx, courseSet.ID, item.ID); err != nil {
return nil, err
}
}
if err := tx.Commit(ctx); err != nil {
return nil, err
}
view := buildAdminCourseVariantView(*item)
return &view, nil
}
func (s *AdminProductionService) ListRuntimeBindings(ctx context.Context, limit int) ([]AdminRuntimeBindingSummary, error) {
items, err := s.store.ListMapRuntimeBindings(ctx, limit)
if err != nil {
return nil, err
}
result := make([]AdminRuntimeBindingSummary, 0, len(items))
for _, item := range items {
result = append(result, buildAdminRuntimeBindingSummary(item))
}
return result, nil
}
func (s *AdminProductionService) CreateRuntimeBinding(ctx context.Context, input CreateAdminRuntimeBindingInput) (*AdminRuntimeBindingSummary, error) {
eventRecord, err := s.store.GetAdminEventByPublicID(ctx, strings.TrimSpace(input.EventID))
if err != nil {
return nil, err
}
if eventRecord == nil {
return nil, apperr.New(http.StatusNotFound, "event_not_found", "event not found")
}
place, err := s.store.GetPlaceByPublicID(ctx, strings.TrimSpace(input.PlaceID))
if err != nil {
return nil, err
}
if place == nil {
return nil, apperr.New(http.StatusNotFound, "place_not_found", "place not found")
}
mapAsset, err := s.store.GetMapAssetByPublicID(ctx, strings.TrimSpace(input.MapAssetID))
if err != nil {
return nil, err
}
if mapAsset == nil || mapAsset.PlaceID != place.ID {
return nil, apperr.New(http.StatusBadRequest, "map_asset_mismatch", "map asset does not belong to place")
}
tileRelease, err := s.store.GetTileReleaseByPublicID(ctx, strings.TrimSpace(input.TileReleaseID))
if err != nil {
return nil, err
}
if tileRelease == nil || tileRelease.MapAssetID != mapAsset.ID {
return nil, apperr.New(http.StatusBadRequest, "tile_release_mismatch", "tile release does not belong to map asset")
}
courseSet, err := s.store.GetCourseSetByPublicID(ctx, strings.TrimSpace(input.CourseSetID))
if err != nil {
return nil, err
}
if courseSet == nil || courseSet.PlaceID != place.ID || courseSet.MapAssetID != mapAsset.ID {
return nil, apperr.New(http.StatusBadRequest, "course_set_mismatch", "course set does not match place/map asset")
}
courseVariant, err := s.store.GetCourseVariantByPublicID(ctx, strings.TrimSpace(input.CourseVariantID))
if err != nil {
return nil, err
}
if courseVariant == nil || courseVariant.CourseSetID != courseSet.ID {
return nil, apperr.New(http.StatusBadRequest, "course_variant_mismatch", "course variant does not belong to course set")
}
publicID, err := security.GeneratePublicID("rtbind")
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.CreateMapRuntimeBinding(ctx, tx, postgres.CreateMapRuntimeBindingParams{
PublicID: publicID,
EventID: eventRecord.ID,
PlaceID: place.ID,
MapAssetID: mapAsset.ID,
TileReleaseID: tileRelease.ID,
CourseSetID: courseSet.ID,
CourseVariantID: courseVariant.ID,
Status: normalizeRuntimeBindingStatus(input.Status),
Notes: trimStringPtr(input.Notes),
})
if err != nil {
return nil, err
}
if err := tx.Commit(ctx); err != nil {
return nil, err
}
created, err := s.store.GetMapRuntimeBindingByPublicID(ctx, item.PublicID)
if err != nil {
return nil, err
}
if created == nil {
return nil, apperr.New(http.StatusNotFound, "runtime_binding_not_found", "runtime binding not found")
}
result := buildAdminRuntimeBindingSummary(*created)
return &result, nil
}
func (s *AdminProductionService) GetRuntimeBinding(ctx context.Context, runtimeBindingPublicID string) (*AdminRuntimeBindingSummary, error) {
item, err := s.store.GetMapRuntimeBindingByPublicID(ctx, strings.TrimSpace(runtimeBindingPublicID))
if err != nil {
return nil, err
}
if item == nil {
return nil, apperr.New(http.StatusNotFound, "runtime_binding_not_found", "runtime binding not found")
}
result := buildAdminRuntimeBindingSummary(*item)
return &result, nil
}
func (s *AdminProductionService) buildAdminMapAssetSummary(ctx context.Context, item postgres.MapAsset) (AdminMapAssetSummary, error) {
result := AdminMapAssetSummary{
ID: item.PublicID,
PlaceID: item.PlaceID,
LegacyMapID: item.LegacyMapPublicID,
Code: item.Code,
Name: item.Name,
MapType: item.MapType,
CoverURL: item.CoverURL,
Description: item.Description,
Status: item.Status,
}
if item.CurrentTileReleaseID != nil {
releases, err := s.store.ListTileReleasesByMapAssetID(ctx, item.ID)
if err != nil {
return result, err
}
for _, release := range releases {
if release.ID == *item.CurrentTileReleaseID {
result.CurrentTileRelease = &AdminTileReleaseBrief{
ID: release.PublicID,
VersionCode: release.VersionCode,
Status: release.Status,
}
break
}
}
}
return result, nil
}
func (s *AdminProductionService) buildAdminCourseSetBrief(ctx context.Context, item postgres.CourseSet) (AdminCourseSetBrief, error) {
result := AdminCourseSetBrief{
ID: item.PublicID,
Code: item.Code,
Mode: item.Mode,
Name: item.Name,
Description: item.Description,
Status: item.Status,
}
if item.CurrentVariantID != nil {
variants, err := s.store.ListCourseVariantsByCourseSetID(ctx, item.ID)
if err != nil {
return result, err
}
for _, variant := range variants {
if variant.ID == *item.CurrentVariantID {
result.CurrentVariant = &AdminCourseVariantBrief{
ID: variant.PublicID,
Name: variant.Name,
RouteCode: variant.RouteCode,
Status: variant.Status,
}
break
}
}
}
return result, nil
}
func buildAdminPlaceSummary(item postgres.Place) AdminPlaceSummary {
return AdminPlaceSummary{
ID: item.PublicID,
Code: item.Code,
Name: item.Name,
Region: item.Region,
CoverURL: item.CoverURL,
Description: item.Description,
CenterPoint: decodeJSONMap(item.CenterPoint),
Status: item.Status,
}
}
func buildAdminTileReleaseView(item postgres.TileRelease) AdminTileReleaseView {
return AdminTileReleaseView{
ID: item.PublicID,
LegacyVersionID: item.LegacyMapVersionPub,
VersionCode: item.VersionCode,
Status: item.Status,
TileBaseURL: item.TileBaseURL,
MetaURL: item.MetaURL,
PublishedAssetRoot: item.PublishedAssetRoot,
Metadata: decodeJSONMap(item.MetadataJSON),
PublishedAt: item.PublishedAt,
}
}
func buildAdminCourseSourceSummary(item postgres.CourseSource) AdminCourseSourceSummary {
return AdminCourseSourceSummary{
ID: item.PublicID,
LegacyVersionID: item.LegacyPlayfieldVersionPub,
SourceType: item.SourceType,
FileURL: item.FileURL,
Checksum: item.Checksum,
ParserVersion: item.ParserVersion,
ImportStatus: item.ImportStatus,
Metadata: decodeJSONMap(item.MetadataJSON),
ImportedAt: item.ImportedAt,
}
}
func buildAdminCourseVariantView(item postgres.CourseVariant) AdminCourseVariantView {
return AdminCourseVariantView{
ID: item.PublicID,
SourceID: item.SourcePublicID,
Name: item.Name,
RouteCode: item.RouteCode,
Mode: item.Mode,
ControlCount: item.ControlCount,
Difficulty: item.Difficulty,
Status: item.Status,
IsDefault: item.IsDefault,
ConfigPatch: decodeJSONMap(item.ConfigPatch),
Metadata: decodeJSONMap(item.MetadataJSON),
}
}
func buildAdminRuntimeBindingSummary(item postgres.MapRuntimeBinding) AdminRuntimeBindingSummary {
return AdminRuntimeBindingSummary{
ID: item.PublicID,
EventID: item.EventPublicID,
PlaceID: item.PlacePublicID,
MapAssetID: item.MapAssetPublicID,
TileReleaseID: item.TileReleasePublicID,
CourseSetID: item.CourseSetPublicID,
CourseVariantID: item.CourseVariantPublicID,
Status: item.Status,
Notes: item.Notes,
}
}
func normalizeCourseSourceStatus(value string) string {
switch strings.TrimSpace(value) {
case "draft":
return "draft"
case "parsed":
return "parsed"
case "failed":
return "failed"
case "archived":
return "archived"
default:
return "imported"
}
}
func normalizeRuntimeBindingStatus(value string) string {
switch strings.TrimSpace(value) {
case "active":
return "active"
case "disabled":
return "disabled"
case "archived":
return "archived"
default:
return "draft"
}
}
func normalizeReleaseStatus(value string) string {
switch strings.TrimSpace(value) {
case "active":
return "active"
case "published":
return "published"
case "retired":
return "retired"
case "archived":
return "archived"
default:
return "draft"
}
}

View File

@@ -58,10 +58,13 @@ type EventConfigBuildView struct {
} }
type PublishedReleaseView struct { type PublishedReleaseView struct {
EventID string `json:"eventId"` EventID string `json:"eventId"`
Release ResolvedReleaseView `json:"release"` Release ResolvedReleaseView `json:"release"`
ReleaseNo int `json:"releaseNo"` ReleaseNo int `json:"releaseNo"`
PublishedAt string `json:"publishedAt"` PublishedAt string `json:"publishedAt"`
Runtime *RuntimeSummaryView `json:"runtime,omitempty"`
Presentation *PresentationSummaryView `json:"presentation,omitempty"`
ContentBundle *ContentBundleSummaryView `json:"contentBundle,omitempty"`
} }
type ImportLocalEventConfigInput struct { type ImportLocalEventConfigInput struct {
@@ -75,7 +78,10 @@ type BuildPreviewInput struct {
} }
type PublishBuildInput struct { type PublishBuildInput struct {
BuildID string `json:"buildId"` BuildID string `json:"buildId"`
RuntimeBindingID string `json:"runtimeBindingId,omitempty"`
PresentationID string `json:"presentationId,omitempty"`
ContentBundleID string `json:"contentBundleId,omitempty"`
} }
func NewConfigService(store *postgres.Store, localEventDir, assetBaseURL string, publisher *assets.OSSUtilPublisher) *ConfigService { func NewConfigService(store *postgres.Store, localEventDir, assetBaseURL string, publisher *assets.OSSUtilPublisher) *ConfigService {
@@ -306,6 +312,19 @@ func (s *ConfigService) PublishBuild(ctx context.Context, input PublishBuildInpu
return nil, apperr.New(http.StatusNotFound, "event_not_found", "event not found") return nil, apperr.New(http.StatusNotFound, "event_not_found", "event not found")
} }
runtimeBindingID, runtimeSummary, err := s.resolvePublishRuntimeBinding(ctx, event.ID, input.RuntimeBindingID)
if err != nil {
return nil, err
}
presentationID, presentationSummary, err := s.resolvePublishPresentation(ctx, event.ID, input.PresentationID)
if err != nil {
return nil, err
}
contentBundleID, contentBundleSummary, err := s.resolvePublishContentBundle(ctx, event.ID, input.ContentBundleID)
if err != nil {
return nil, err
}
manifest, err := decodeJSONObject(buildRecord.ManifestJSON) manifest, err := decodeJSONObject(buildRecord.ManifestJSON)
if err != nil { if err != nil {
return nil, apperr.New(http.StatusInternalServerError, "config_build_invalid", "stored build manifest is invalid") return nil, apperr.New(http.StatusInternalServerError, "config_build_invalid", "stored build manifest is invalid")
@@ -355,6 +374,9 @@ func (s *ConfigService) PublishBuild(ctx context.Context, input PublishBuildInpu
ManifestChecksum: &checksum, ManifestChecksum: &checksum,
RouteCode: routeCode, RouteCode: routeCode,
BuildID: &buildRecord.ID, BuildID: &buildRecord.ID,
RuntimeBindingID: runtimeBindingID,
PresentationID: presentationID,
ContentBundleID: contentBundleID,
Status: "published", Status: "published",
PayloadJSON: buildRecord.ManifestJSON, PayloadJSON: buildRecord.ManifestJSON,
}) })
@@ -386,11 +408,160 @@ func (s *ConfigService) PublishBuild(ctx context.Context, input PublishBuildInpu
ManifestChecksumSha256: releaseRecord.ManifestChecksum, ManifestChecksumSha256: releaseRecord.ManifestChecksum,
RouteCode: releaseRecord.RouteCode, RouteCode: releaseRecord.RouteCode,
}, },
ReleaseNo: releaseRecord.ReleaseNo, ReleaseNo: releaseRecord.ReleaseNo,
PublishedAt: releaseRecord.PublishedAt.Format(timeRFC3339), PublishedAt: releaseRecord.PublishedAt.Format(timeRFC3339),
Runtime: runtimeSummary,
Presentation: presentationSummary,
ContentBundle: contentBundleSummary,
}, nil }, nil
} }
func (s *ConfigService) resolvePublishRuntimeBinding(ctx context.Context, eventID string, runtimeBindingPublicID string) (*string, *RuntimeSummaryView, error) {
runtimeBindingPublicID = strings.TrimSpace(runtimeBindingPublicID)
if runtimeBindingPublicID == "" {
defaults, err := s.store.GetEventDefaultBindingsByEventID(ctx, eventID)
if err != nil {
return nil, nil, err
}
if defaults == nil || defaults.RuntimeBindingID == nil || defaults.RuntimeBindingPublicID == nil || defaults.PlacePublicID == nil || defaults.MapAssetPublicID == nil || defaults.TileReleasePublicID == nil || defaults.CourseSetPublicID == nil || defaults.CourseVariantPublicID == nil {
return nil, nil, nil
}
return defaults.RuntimeBindingID, &RuntimeSummaryView{
RuntimeBindingID: *defaults.RuntimeBindingPublicID,
PlaceID: *defaults.PlacePublicID,
PlaceName: defaults.PlaceName,
MapID: *defaults.MapAssetPublicID,
MapName: defaults.MapAssetName,
TileReleaseID: *defaults.TileReleasePublicID,
CourseSetID: *defaults.CourseSetPublicID,
CourseVariantID: *defaults.CourseVariantPublicID,
CourseVariantName: defaults.CourseVariantName,
RouteCode: defaults.RuntimeRouteCode,
}, nil
}
runtimeBinding, err := s.store.GetMapRuntimeBindingByPublicID(ctx, runtimeBindingPublicID)
if err != nil {
return nil, nil, err
}
if runtimeBinding == nil {
return nil, nil, apperr.New(http.StatusNotFound, "runtime_binding_not_found", "runtime binding not found")
}
if runtimeBinding.EventID != eventID {
return nil, nil, apperr.New(http.StatusConflict, "runtime_binding_not_belong_to_event", "runtime binding does not belong to build event")
}
return &runtimeBinding.ID, &RuntimeSummaryView{
RuntimeBindingID: runtimeBinding.PublicID,
PlaceID: runtimeBinding.PlacePublicID,
MapID: runtimeBinding.MapAssetPublicID,
TileReleaseID: runtimeBinding.TileReleasePublicID,
CourseSetID: runtimeBinding.CourseSetPublicID,
CourseVariantID: runtimeBinding.CourseVariantPublicID,
RouteCode: nil,
}, nil
}
func (s *ConfigService) resolvePublishPresentation(ctx context.Context, eventID string, presentationPublicID string) (*string, *PresentationSummaryView, error) {
presentationPublicID = strings.TrimSpace(presentationPublicID)
if presentationPublicID == "" {
defaults, err := s.store.GetEventDefaultBindingsByEventID(ctx, eventID)
if err != nil {
return nil, nil, err
}
if defaults != nil && defaults.PresentationID != nil && defaults.PresentationPublicID != nil {
record, err := s.store.GetEventPresentationByPublicID(ctx, *defaults.PresentationPublicID)
if err != nil {
return nil, nil, err
}
if record != nil {
summary, err := buildPresentationSummaryFromRecord(record)
if err != nil {
return nil, nil, err
}
return defaults.PresentationID, summary, nil
}
}
record, err := s.store.GetDefaultEventPresentationByEventID(ctx, eventID)
if err != nil {
return nil, nil, err
}
if record == nil {
return nil, nil, nil
}
summary, err := buildPresentationSummaryFromRecord(record)
if err != nil {
return nil, nil, err
}
return &record.ID, summary, nil
}
record, err := s.store.GetEventPresentationByPublicID(ctx, presentationPublicID)
if err != nil {
return nil, nil, err
}
if record == nil {
return nil, nil, apperr.New(http.StatusNotFound, "presentation_not_found", "presentation not found")
}
if record.EventID != eventID {
return nil, nil, apperr.New(http.StatusConflict, "presentation_not_belong_to_event", "presentation does not belong to build event")
}
summary, err := buildPresentationSummaryFromRecord(record)
if err != nil {
return nil, nil, err
}
return &record.ID, summary, nil
}
func (s *ConfigService) resolvePublishContentBundle(ctx context.Context, eventID string, contentBundlePublicID string) (*string, *ContentBundleSummaryView, error) {
contentBundlePublicID = strings.TrimSpace(contentBundlePublicID)
if contentBundlePublicID == "" {
defaults, err := s.store.GetEventDefaultBindingsByEventID(ctx, eventID)
if err != nil {
return nil, nil, err
}
if defaults != nil && defaults.ContentBundleID != nil && defaults.ContentBundlePublicID != nil {
record, err := s.store.GetContentBundleByPublicID(ctx, *defaults.ContentBundlePublicID)
if err != nil {
return nil, nil, err
}
if record != nil {
summary, err := buildContentBundleSummaryFromRecord(record)
if err != nil {
return nil, nil, err
}
return defaults.ContentBundleID, summary, nil
}
}
record, err := s.store.GetDefaultContentBundleByEventID(ctx, eventID)
if err != nil {
return nil, nil, err
}
if record == nil {
return nil, nil, nil
}
summary, err := buildContentBundleSummaryFromRecord(record)
if err != nil {
return nil, nil, err
}
return &record.ID, summary, nil
}
record, err := s.store.GetContentBundleByPublicID(ctx, contentBundlePublicID)
if err != nil {
return nil, nil, err
}
if record == nil {
return nil, nil, apperr.New(http.StatusNotFound, "content_bundle_not_found", "content bundle not found")
}
if record.EventID != eventID {
return nil, nil, apperr.New(http.StatusConflict, "content_bundle_not_belong_to_event", "content bundle does not belong to build event")
}
summary, err := buildContentBundleSummaryFromRecord(record)
if err != nil {
return nil, nil, err
}
return &record.ID, summary, nil
}
func (s *ConfigService) requireEvent(ctx context.Context, eventPublicID string) (*postgres.Event, error) { func (s *ConfigService) requireEvent(ctx context.Context, eventPublicID string) (*postgres.Event, error) {
eventPublicID = strings.TrimSpace(eventPublicID) eventPublicID = strings.TrimSpace(eventPublicID)
if eventPublicID == "" { if eventPublicID == "" {

View File

@@ -33,8 +33,11 @@ type EventPlayResult struct {
ManifestChecksumSha256 *string `json:"manifestChecksumSha256,omitempty"` ManifestChecksumSha256 *string `json:"manifestChecksumSha256,omitempty"`
RouteCode *string `json:"routeCode,omitempty"` RouteCode *string `json:"routeCode,omitempty"`
} `json:"release,omitempty"` } `json:"release,omitempty"`
ResolvedRelease *ResolvedReleaseView `json:"resolvedRelease,omitempty"` ResolvedRelease *ResolvedReleaseView `json:"resolvedRelease,omitempty"`
Play struct { Runtime *RuntimeSummaryView `json:"runtime,omitempty"`
CurrentPresentation *PresentationSummaryView `json:"currentPresentation,omitempty"`
CurrentContentBundle *ContentBundleSummaryView `json:"currentContentBundle,omitempty"`
Play struct {
AssignmentMode *string `json:"assignmentMode,omitempty"` AssignmentMode *string `json:"assignmentMode,omitempty"`
CourseVariants []CourseVariantView `json:"courseVariants,omitempty"` CourseVariants []CourseVariantView `json:"courseVariants,omitempty"`
CanLaunch bool `json:"canLaunch"` CanLaunch bool `json:"canLaunch"`
@@ -100,6 +103,19 @@ func (s *EventPlayService) GetEventPlay(ctx context.Context, input EventPlayInpu
} }
} }
result.ResolvedRelease = buildResolvedReleaseFromEvent(event, LaunchSourceEventCurrentRelease) result.ResolvedRelease = buildResolvedReleaseFromEvent(event, LaunchSourceEventCurrentRelease)
result.Runtime = buildRuntimeSummaryFromEvent(event)
result.CurrentPresentation = buildPresentationSummaryFromEvent(event)
if enrichedPresentation, err := loadPresentationSummaryByPublicID(ctx, s.store, event.PresentationID); err != nil {
return nil, err
} else if enrichedPresentation != nil {
result.CurrentPresentation = enrichedPresentation
}
result.CurrentContentBundle = buildContentBundleSummaryFromEvent(event)
if enrichedBundle, err := loadContentBundleSummaryByPublicID(ctx, s.store, event.ContentBundleID); err != nil {
return nil, err
} else if enrichedBundle != nil {
result.CurrentContentBundle = enrichedBundle
}
if len(sessions) > 0 { if len(sessions) > 0 {
recent := buildEntrySessionSummary(&sessions[0]) recent := buildEntrySessionSummary(&sessions[0])

View File

@@ -30,7 +30,10 @@ type EventDetailResult struct {
ManifestChecksumSha256 *string `json:"manifestChecksumSha256,omitempty"` ManifestChecksumSha256 *string `json:"manifestChecksumSha256,omitempty"`
RouteCode *string `json:"routeCode,omitempty"` RouteCode *string `json:"routeCode,omitempty"`
} `json:"release,omitempty"` } `json:"release,omitempty"`
ResolvedRelease *ResolvedReleaseView `json:"resolvedRelease,omitempty"` ResolvedRelease *ResolvedReleaseView `json:"resolvedRelease,omitempty"`
Runtime *RuntimeSummaryView `json:"runtime,omitempty"`
CurrentPresentation *PresentationSummaryView `json:"currentPresentation,omitempty"`
CurrentContentBundle *ContentBundleSummaryView `json:"currentContentBundle,omitempty"`
} }
type LaunchEventInput struct { type LaunchEventInput struct {
@@ -48,9 +51,12 @@ type LaunchEventResult struct {
DisplayName string `json:"displayName"` DisplayName string `json:"displayName"`
} `json:"event"` } `json:"event"`
Launch struct { Launch struct {
Source string `json:"source"` Source string `json:"source"`
ResolvedRelease *ResolvedReleaseView `json:"resolvedRelease,omitempty"` ResolvedRelease *ResolvedReleaseView `json:"resolvedRelease,omitempty"`
Variant *VariantBindingView `json:"variant,omitempty"` Variant *VariantBindingView `json:"variant,omitempty"`
Runtime *RuntimeSummaryView `json:"runtime,omitempty"`
Presentation *PresentationSummaryView `json:"presentation,omitempty"`
ContentBundle *ContentBundleSummaryView `json:"contentBundle,omitempty"`
Config struct { Config struct {
ConfigURL string `json:"configUrl"` ConfigURL string `json:"configUrl"`
ConfigLabel string `json:"configLabel"` ConfigLabel string `json:"configLabel"`
@@ -110,6 +116,19 @@ func (s *EventService) GetEventDetail(ctx context.Context, eventPublicID string)
} }
} }
result.ResolvedRelease = buildResolvedReleaseFromEvent(event, LaunchSourceEventCurrentRelease) result.ResolvedRelease = buildResolvedReleaseFromEvent(event, LaunchSourceEventCurrentRelease)
result.Runtime = buildRuntimeSummaryFromEvent(event)
result.CurrentPresentation = buildPresentationSummaryFromEvent(event)
if enrichedPresentation, err := loadPresentationSummaryByPublicID(ctx, s.store, event.PresentationID); err != nil {
return nil, err
} else if enrichedPresentation != nil {
result.CurrentPresentation = enrichedPresentation
}
result.CurrentContentBundle = buildContentBundleSummaryFromEvent(event)
if enrichedBundle, err := loadContentBundleSummaryByPublicID(ctx, s.store, event.ContentBundleID); err != nil {
return nil, err
} else if enrichedBundle != nil {
result.CurrentContentBundle = enrichedBundle
}
return result, nil return result, nil
} }
@@ -205,6 +224,19 @@ func (s *EventService) LaunchEvent(ctx context.Context, input LaunchEventInput)
result.Launch.Source = LaunchSourceEventCurrentRelease result.Launch.Source = LaunchSourceEventCurrentRelease
result.Launch.ResolvedRelease = buildResolvedReleaseFromEvent(event, LaunchSourceEventCurrentRelease) result.Launch.ResolvedRelease = buildResolvedReleaseFromEvent(event, LaunchSourceEventCurrentRelease)
result.Launch.Variant = variant result.Launch.Variant = variant
result.Launch.Runtime = buildRuntimeSummaryFromEvent(event)
result.Launch.Presentation = buildPresentationSummaryFromEvent(event)
if enrichedPresentation, err := loadPresentationSummaryByPublicID(ctx, s.store, event.PresentationID); err != nil {
return nil, err
} else if enrichedPresentation != nil {
result.Launch.Presentation = enrichedPresentation
}
result.Launch.ContentBundle = buildContentBundleSummaryFromEvent(event)
if enrichedBundle, err := loadContentBundleSummaryByPublicID(ctx, s.store, event.ContentBundleID); err != nil {
return nil, err
} else if enrichedBundle != nil {
result.Launch.ContentBundle = enrichedBundle
}
result.Launch.Config.ConfigURL = *event.ManifestURL result.Launch.Config.ConfigURL = *event.ManifestURL
result.Launch.Config.ConfigLabel = *event.ConfigLabel result.Launch.Config.ConfigLabel = *event.ConfigLabel
result.Launch.Config.ConfigChecksumSha256 = event.ManifestChecksum result.Launch.Config.ConfigChecksumSha256 = event.ManifestChecksum

View File

@@ -1,6 +1,11 @@
package service package service
import "cmr-backend/internal/store/postgres" import (
"context"
"strings"
"cmr-backend/internal/store/postgres"
)
const ( const (
LaunchSourceEventCurrentRelease = "event_current_release" LaunchSourceEventCurrentRelease = "event_current_release"
@@ -18,6 +23,36 @@ type ResolvedReleaseView struct {
RouteCode *string `json:"routeCode,omitempty"` RouteCode *string `json:"routeCode,omitempty"`
} }
type RuntimeSummaryView struct {
RuntimeBindingID string `json:"runtimeBindingId"`
PlaceID string `json:"placeId"`
PlaceName *string `json:"placeName,omitempty"`
MapID string `json:"mapId"`
MapName *string `json:"mapName,omitempty"`
TileReleaseID string `json:"tileReleaseId"`
CourseSetID string `json:"courseSetId"`
CourseVariantID string `json:"courseVariantId"`
CourseVariantName *string `json:"courseVariantName,omitempty"`
RouteCode *string `json:"routeCode,omitempty"`
}
type PresentationSummaryView struct {
PresentationID string `json:"presentationId"`
Name *string `json:"name,omitempty"`
PresentationType *string `json:"presentationType,omitempty"`
TemplateKey *string `json:"templateKey,omitempty"`
Version *string `json:"version,omitempty"`
}
type ContentBundleSummaryView struct {
ContentBundleID string `json:"contentBundleId"`
Name *string `json:"name,omitempty"`
BundleType *string `json:"bundleType,omitempty"`
Version *string `json:"version,omitempty"`
EntryURL *string `json:"entryUrl,omitempty"`
AssetRootURL *string `json:"assetRootUrl,omitempty"`
}
func buildResolvedReleaseFromEvent(event *postgres.Event, source string) *ResolvedReleaseView { func buildResolvedReleaseFromEvent(event *postgres.Event, source string) *ResolvedReleaseView {
if event == nil || event.CurrentReleasePubID == nil || event.ConfigLabel == nil || event.ManifestURL == nil { if event == nil || event.CurrentReleasePubID == nil || event.ConfigLabel == nil || event.ManifestURL == nil {
return nil return nil
@@ -35,6 +70,102 @@ func buildResolvedReleaseFromEvent(event *postgres.Event, source string) *Resolv
} }
} }
func buildRuntimeSummaryFromEvent(event *postgres.Event) *RuntimeSummaryView {
if event == nil ||
event.RuntimeBindingID == nil ||
event.PlacePublicID == nil ||
event.MapAssetPublicID == nil ||
event.TileReleasePublicID == nil ||
event.CourseSetPublicID == nil ||
event.CourseVariantID == nil {
return nil
}
return &RuntimeSummaryView{
RuntimeBindingID: *event.RuntimeBindingID,
PlaceID: *event.PlacePublicID,
PlaceName: event.PlaceName,
MapID: *event.MapAssetPublicID,
MapName: event.MapAssetName,
TileReleaseID: *event.TileReleasePublicID,
CourseSetID: *event.CourseSetPublicID,
CourseVariantID: *event.CourseVariantID,
CourseVariantName: event.CourseVariantName,
RouteCode: firstNonNilString(event.RuntimeRouteCode, event.RouteCode),
}
}
func buildRuntimeSummaryFromRelease(release *postgres.EventRelease) *RuntimeSummaryView {
if release == nil ||
release.RuntimeBindingID == nil ||
release.PlacePublicID == nil ||
release.MapAssetPublicID == nil ||
release.TileReleaseID == nil ||
release.CourseSetID == nil ||
release.CourseVariantID == nil {
return nil
}
return &RuntimeSummaryView{
RuntimeBindingID: *release.RuntimeBindingID,
PlaceID: *release.PlacePublicID,
PlaceName: release.PlaceName,
MapID: *release.MapAssetPublicID,
MapName: release.MapAssetName,
TileReleaseID: *release.TileReleaseID,
CourseSetID: *release.CourseSetID,
CourseVariantID: *release.CourseVariantID,
CourseVariantName: release.CourseVariantName,
RouteCode: firstNonNilString(release.RuntimeRouteCode, release.RouteCode),
}
}
func buildPresentationSummaryFromEvent(event *postgres.Event) *PresentationSummaryView {
if event == nil || event.PresentationID == nil {
return nil
}
return &PresentationSummaryView{
PresentationID: *event.PresentationID,
Name: event.PresentationName,
PresentationType: event.PresentationType,
}
}
func buildPresentationSummaryFromRelease(release *postgres.EventRelease) *PresentationSummaryView {
if release == nil || release.PresentationID == nil {
return nil
}
return &PresentationSummaryView{
PresentationID: *release.PresentationID,
Name: release.PresentationName,
PresentationType: release.PresentationType,
}
}
func buildContentBundleSummaryFromEvent(event *postgres.Event) *ContentBundleSummaryView {
if event == nil || event.ContentBundleID == nil {
return nil
}
return &ContentBundleSummaryView{
ContentBundleID: *event.ContentBundleID,
Name: event.ContentBundleName,
EntryURL: event.ContentEntryURL,
AssetRootURL: event.ContentAssetRootURL,
}
}
func buildContentBundleSummaryFromRelease(release *postgres.EventRelease) *ContentBundleSummaryView {
if release == nil || release.ContentBundleID == nil {
return nil
}
return &ContentBundleSummaryView{
ContentBundleID: *release.ContentBundleID,
Name: release.ContentBundleName,
EntryURL: release.ContentEntryURL,
AssetRootURL: release.ContentAssetURL,
}
}
func buildResolvedReleaseFromSession(session *postgres.Session, source string) *ResolvedReleaseView { func buildResolvedReleaseFromSession(session *postgres.Session, source string) *ResolvedReleaseView {
if session == nil || session.ReleasePublicID == nil || session.ConfigLabel == nil || session.ManifestURL == nil { if session == nil || session.ReleasePublicID == nil || session.ConfigLabel == nil || session.ManifestURL == nil {
return nil return nil
@@ -54,3 +185,96 @@ func buildResolvedReleaseFromSession(session *postgres.Session, source string) *
} }
return view return view
} }
func loadPresentationSummaryByPublicID(ctx context.Context, store *postgres.Store, publicID *string) (*PresentationSummaryView, error) {
if store == nil || publicID == nil || strings.TrimSpace(*publicID) == "" {
return nil, nil
}
record, err := store.GetEventPresentationByPublicID(ctx, strings.TrimSpace(*publicID))
if err != nil {
return nil, err
}
if record == nil {
return nil, nil
}
return buildPresentationSummaryFromRecord(record)
}
func loadContentBundleSummaryByPublicID(ctx context.Context, store *postgres.Store, publicID *string) (*ContentBundleSummaryView, error) {
if store == nil || publicID == nil || strings.TrimSpace(*publicID) == "" {
return nil, nil
}
record, err := store.GetContentBundleByPublicID(ctx, strings.TrimSpace(*publicID))
if err != nil {
return nil, err
}
if record == nil {
return nil, nil
}
return buildContentBundleSummaryFromRecord(record)
}
func buildPresentationSummaryFromRecord(record *postgres.EventPresentation) (*PresentationSummaryView, error) {
if record == nil {
return nil, nil
}
summary := &PresentationSummaryView{
PresentationID: record.PublicID,
Name: &record.Name,
PresentationType: &record.PresentationType,
}
schema, err := decodeJSONObject(record.SchemaJSON)
if err != nil {
return nil, err
}
summary.TemplateKey = readStringField(schema, "templateKey")
summary.Version = readStringField(schema, "version")
return summary, nil
}
func buildContentBundleSummaryFromRecord(record *postgres.ContentBundle) (*ContentBundleSummaryView, error) {
if record == nil {
return nil, nil
}
summary := &ContentBundleSummaryView{
ContentBundleID: record.PublicID,
Name: &record.Name,
EntryURL: record.EntryURL,
AssetRootURL: record.AssetRootURL,
}
metadata, err := decodeJSONObject(record.MetadataJSON)
if err != nil {
return nil, err
}
summary.BundleType = readStringField(metadata, "bundleType")
summary.Version = readStringField(metadata, "version")
return summary, nil
}
func readStringField(object map[string]any, key string) *string {
if object == nil {
return nil
}
value, ok := object[key]
if !ok {
return nil
}
text, ok := value.(string)
if !ok {
return nil
}
text = strings.TrimSpace(text)
if text == "" {
return nil
}
return &text
}
func firstNonNilString(values ...*string) *string {
for _, value := range values {
if value != nil {
return value
}
}
return nil
}

View File

@@ -16,21 +16,43 @@ type Tenant struct {
} }
type AdminEventRecord struct { type AdminEventRecord struct {
ID string ID string
PublicID string PublicID string
TenantID *string TenantID *string
TenantCode *string TenantCode *string
TenantName *string TenantName *string
Slug string Slug string
DisplayName string DisplayName string
Summary *string Summary *string
Status string Status string
CurrentReleaseID *string CurrentReleaseID *string
CurrentReleasePubID *string CurrentReleasePubID *string
ConfigLabel *string ConfigLabel *string
ManifestURL *string ManifestURL *string
ManifestChecksum *string ManifestChecksum *string
RouteCode *string RouteCode *string
PresentationID *string
PresentationName *string
PresentationType *string
ContentBundleID *string
ContentBundleName *string
ContentEntryURL *string
ContentAssetRootURL *string
CurrentPresentationID *string
CurrentPresentationName *string
CurrentPresentationType *string
CurrentContentBundleID *string
CurrentContentBundleName *string
CurrentContentEntryURL *string
CurrentContentAssetRootURL *string
CurrentRuntimeBindingID *string
CurrentPlaceID *string
CurrentMapAssetID *string
CurrentTileReleaseID *string
CurrentCourseSetID *string
CurrentCourseVariantID *string
CurrentCourseVariantName *string
CurrentRuntimeRouteCode *string
} }
type CreateAdminEventParams struct { type CreateAdminEventParams struct {
@@ -90,10 +112,42 @@ func (s *Store) ListAdminEvents(ctx context.Context, limit int) ([]AdminEventRec
er.config_label, er.config_label,
er.manifest_url, er.manifest_url,
er.manifest_checksum_sha256, er.manifest_checksum_sha256,
er.route_code er.route_code,
ep.presentation_public_id,
ep.name,
ep.presentation_type,
cb.content_bundle_public_id,
cb.name,
cb.entry_url,
cb.asset_root_url,
epc.presentation_public_id,
epc.name,
epc.presentation_type,
cbc.content_bundle_public_id,
cbc.name,
cbc.entry_url,
cbc.asset_root_url,
mrb.runtime_binding_public_id,
p.place_public_id,
ma.map_asset_public_id,
tr.tile_release_public_id,
cset.course_set_public_id,
cv.course_variant_public_id,
cv.name,
cv.route_code
FROM events e FROM events e
LEFT JOIN tenants t ON t.id = e.tenant_id LEFT JOIN tenants t ON t.id = e.tenant_id
LEFT JOIN event_releases er ON er.id = e.current_release_id LEFT JOIN event_releases er ON er.id = e.current_release_id
LEFT JOIN event_presentations ep ON ep.id = er.presentation_id
LEFT JOIN content_bundles cb ON cb.id = er.content_bundle_id
LEFT JOIN event_presentations epc ON epc.id = e.current_presentation_id
LEFT JOIN content_bundles cbc ON cbc.id = e.current_content_bundle_id
LEFT JOIN map_runtime_bindings mrb ON mrb.id = e.current_runtime_binding_id
LEFT JOIN places p ON p.id = mrb.place_id
LEFT JOIN map_assets ma ON ma.id = mrb.map_asset_id
LEFT JOIN tile_releases tr ON tr.id = mrb.tile_release_id
LEFT JOIN course_sets cset ON cset.id = mrb.course_set_id
LEFT JOIN course_variants cv ON cv.id = mrb.course_variant_id
ORDER BY e.created_at DESC ORDER BY e.created_at DESC
LIMIT $1 LIMIT $1
`, limit) `, limit)
@@ -133,10 +187,42 @@ func (s *Store) GetAdminEventByPublicID(ctx context.Context, eventPublicID strin
er.config_label, er.config_label,
er.manifest_url, er.manifest_url,
er.manifest_checksum_sha256, er.manifest_checksum_sha256,
er.route_code er.route_code,
ep.presentation_public_id,
ep.name,
ep.presentation_type,
cb.content_bundle_public_id,
cb.name,
cb.entry_url,
cb.asset_root_url,
epc.presentation_public_id,
epc.name,
epc.presentation_type,
cbc.content_bundle_public_id,
cbc.name,
cbc.entry_url,
cbc.asset_root_url,
mrb.runtime_binding_public_id,
p.place_public_id,
ma.map_asset_public_id,
tr.tile_release_public_id,
cset.course_set_public_id,
cv.course_variant_public_id,
cv.name,
cv.route_code
FROM events e FROM events e
LEFT JOIN tenants t ON t.id = e.tenant_id LEFT JOIN tenants t ON t.id = e.tenant_id
LEFT JOIN event_releases er ON er.id = e.current_release_id LEFT JOIN event_releases er ON er.id = e.current_release_id
LEFT JOIN event_presentations ep ON ep.id = er.presentation_id
LEFT JOIN content_bundles cb ON cb.id = er.content_bundle_id
LEFT JOIN event_presentations epc ON epc.id = e.current_presentation_id
LEFT JOIN content_bundles cbc ON cbc.id = e.current_content_bundle_id
LEFT JOIN map_runtime_bindings mrb ON mrb.id = e.current_runtime_binding_id
LEFT JOIN places p ON p.id = mrb.place_id
LEFT JOIN map_assets ma ON ma.id = mrb.map_asset_id
LEFT JOIN tile_releases tr ON tr.id = mrb.tile_release_id
LEFT JOIN course_sets cset ON cset.id = mrb.course_set_id
LEFT JOIN course_variants cv ON cv.id = mrb.course_variant_id
WHERE e.event_public_id = $1 WHERE e.event_public_id = $1
LIMIT 1 LIMIT 1
`, eventPublicID) `, eventPublicID)
@@ -212,6 +298,28 @@ func scanAdminEvent(row pgx.Row) (*AdminEventRecord, error) {
&item.ManifestURL, &item.ManifestURL,
&item.ManifestChecksum, &item.ManifestChecksum,
&item.RouteCode, &item.RouteCode,
&item.PresentationID,
&item.PresentationName,
&item.PresentationType,
&item.ContentBundleID,
&item.ContentBundleName,
&item.ContentEntryURL,
&item.ContentAssetRootURL,
&item.CurrentPresentationID,
&item.CurrentPresentationName,
&item.CurrentPresentationType,
&item.CurrentContentBundleID,
&item.CurrentContentBundleName,
&item.CurrentContentEntryURL,
&item.CurrentContentAssetRootURL,
&item.CurrentRuntimeBindingID,
&item.CurrentPlaceID,
&item.CurrentMapAssetID,
&item.CurrentTileReleaseID,
&item.CurrentCourseSetID,
&item.CurrentCourseVariantID,
&item.CurrentCourseVariantName,
&item.CurrentRuntimeRouteCode,
) )
if errors.Is(err, pgx.ErrNoRows) { if errors.Is(err, pgx.ErrNoRows) {
return nil, nil return nil, nil
@@ -240,6 +348,28 @@ func scanAdminEventFromRows(rows pgx.Rows) (*AdminEventRecord, error) {
&item.ManifestURL, &item.ManifestURL,
&item.ManifestChecksum, &item.ManifestChecksum,
&item.RouteCode, &item.RouteCode,
&item.PresentationID,
&item.PresentationName,
&item.PresentationType,
&item.ContentBundleID,
&item.ContentBundleName,
&item.ContentEntryURL,
&item.ContentAssetRootURL,
&item.CurrentPresentationID,
&item.CurrentPresentationName,
&item.CurrentPresentationType,
&item.CurrentContentBundleID,
&item.CurrentContentBundleName,
&item.CurrentContentEntryURL,
&item.CurrentContentAssetRootURL,
&item.CurrentRuntimeBindingID,
&item.CurrentPlaceID,
&item.CurrentMapAssetID,
&item.CurrentTileReleaseID,
&item.CurrentCourseSetID,
&item.CurrentCourseVariantID,
&item.CurrentCourseVariantName,
&item.CurrentRuntimeRouteCode,
) )
if err != nil { if err != nil {
return nil, fmt.Errorf("scan admin event row: %w", err) return nil, fmt.Errorf("scan admin event row: %w", err)

View File

@@ -13,6 +13,13 @@ type DemoBootstrapSummary struct {
SourceID string `json:"sourceId"` SourceID string `json:"sourceId"`
BuildID string `json:"buildId"` BuildID string `json:"buildId"`
CardID string `json:"cardId"` CardID string `json:"cardId"`
PlaceID string `json:"placeId"`
MapAssetID string `json:"mapAssetId"`
TileReleaseID string `json:"tileReleaseId"`
CourseSourceID string `json:"courseSourceId"`
CourseSetID string `json:"courseSetId"`
CourseVariantID string `json:"courseVariantId"`
RuntimeBindingID string `json:"runtimeBindingId"`
VariantManualEventID string `json:"variantManualEventId"` VariantManualEventID string `json:"variantManualEventId"`
VariantManualRelease string `json:"variantManualReleaseId"` VariantManualRelease string `json:"variantManualReleaseId"`
VariantManualCardID string `json:"variantManualCardId"` VariantManualCardID string `json:"variantManualCardId"`
@@ -311,6 +318,156 @@ func (s *Store) EnsureDemoData(ctx context.Context) (*DemoBootstrapSummary, erro
return nil, fmt.Errorf("ensure demo card: %w", err) return nil, fmt.Errorf("ensure demo card: %w", err)
} }
var placeID, placePublicID string
if err := tx.QueryRow(ctx, `
INSERT INTO places (
place_public_id, code, name, region, status
)
VALUES (
'place_demo_001', 'place-demo-001', 'Demo Park', 'Shanghai', 'active'
)
ON CONFLICT (code) DO UPDATE SET
name = EXCLUDED.name,
region = EXCLUDED.region,
status = EXCLUDED.status
RETURNING id, place_public_id
`).Scan(&placeID, &placePublicID); err != nil {
return nil, fmt.Errorf("ensure demo place: %w", err)
}
var mapAssetID, mapAssetPublicID string
if err := tx.QueryRow(ctx, `
INSERT INTO map_assets (
map_asset_public_id, place_id, code, name, map_type, status
)
VALUES (
'mapasset_demo_001', $1, 'mapasset-demo-001', 'Demo Asset Map', 'standard', 'active'
)
ON CONFLICT (code) DO UPDATE SET
place_id = EXCLUDED.place_id,
name = EXCLUDED.name,
map_type = EXCLUDED.map_type,
status = EXCLUDED.status
RETURNING id, map_asset_public_id
`, placeID).Scan(&mapAssetID, &mapAssetPublicID); err != nil {
return nil, fmt.Errorf("ensure demo map asset: %w", err)
}
var tileReleaseID, tileReleasePublicID string
if err := tx.QueryRow(ctx, `
INSERT INTO tile_releases (
tile_release_public_id, map_asset_id, version_code, status, tile_base_url, meta_url, published_at
)
VALUES (
'tile_demo_001', $1, 'v2026-04-03', 'published',
'https://example.com/tiles/demo/', 'https://example.com/tiles/demo/meta.json', NOW()
)
ON CONFLICT (map_asset_id, version_code) DO UPDATE SET
status = EXCLUDED.status,
tile_base_url = EXCLUDED.tile_base_url,
meta_url = EXCLUDED.meta_url,
published_at = EXCLUDED.published_at
RETURNING id, tile_release_public_id
`, mapAssetID).Scan(&tileReleaseID, &tileReleasePublicID); err != nil {
return nil, fmt.Errorf("ensure demo tile release: %w", err)
}
if _, err := tx.Exec(ctx, `
UPDATE map_assets
SET current_tile_release_id = $2
WHERE id = $1
`, mapAssetID, tileReleaseID); err != nil {
return nil, fmt.Errorf("attach demo tile release: %w", err)
}
var courseSourceID, courseSourcePublicID string
if err := tx.QueryRow(ctx, `
INSERT INTO course_sources (
course_source_public_id, source_type, file_url, import_status
)
VALUES (
'csource_demo_001', 'kml', 'https://example.com/course/demo.kml', 'imported'
)
ON CONFLICT (course_source_public_id) DO UPDATE SET
source_type = EXCLUDED.source_type,
file_url = EXCLUDED.file_url,
import_status = EXCLUDED.import_status
RETURNING id, course_source_public_id
`).Scan(&courseSourceID, &courseSourcePublicID); err != nil {
return nil, fmt.Errorf("ensure demo course source: %w", err)
}
var courseSetID, courseSetPublicID string
if err := tx.QueryRow(ctx, `
INSERT INTO course_sets (
course_set_public_id, place_id, map_asset_id, code, mode, name, status
)
VALUES (
'cset_demo_001', $1, $2, 'cset-demo-001', 'classic-sequential', 'Demo Course Set', 'active'
)
ON CONFLICT (code) DO UPDATE SET
place_id = EXCLUDED.place_id,
map_asset_id = EXCLUDED.map_asset_id,
mode = EXCLUDED.mode,
name = EXCLUDED.name,
status = EXCLUDED.status
RETURNING id, course_set_public_id
`, placeID, mapAssetID).Scan(&courseSetID, &courseSetPublicID); err != nil {
return nil, fmt.Errorf("ensure demo course set: %w", err)
}
var courseVariantID, courseVariantPublicID string
if err := tx.QueryRow(ctx, `
INSERT INTO course_variants (
course_variant_public_id, course_set_id, source_id, name, route_code, mode, control_count, status, is_default
)
VALUES (
'cvariant_demo_001', $1, $2, 'Demo Variant A', 'route-demo-a', 'classic-sequential', 8, 'active', true
)
ON CONFLICT (course_variant_public_id) DO UPDATE SET
course_set_id = EXCLUDED.course_set_id,
source_id = EXCLUDED.source_id,
name = EXCLUDED.name,
route_code = EXCLUDED.route_code,
mode = EXCLUDED.mode,
control_count = EXCLUDED.control_count,
status = EXCLUDED.status,
is_default = EXCLUDED.is_default
RETURNING id, course_variant_public_id
`, courseSetID, courseSourceID).Scan(&courseVariantID, &courseVariantPublicID); err != nil {
return nil, fmt.Errorf("ensure demo course variant: %w", err)
}
if _, err := tx.Exec(ctx, `
UPDATE course_sets
SET current_variant_id = $2
WHERE id = $1
`, courseSetID, courseVariantID); err != nil {
return nil, fmt.Errorf("attach demo course variant: %w", err)
}
var runtimeBindingID, runtimeBindingPublicID string
if err := tx.QueryRow(ctx, `
INSERT INTO map_runtime_bindings (
runtime_binding_public_id, event_id, place_id, map_asset_id, tile_release_id, course_set_id, course_variant_id, status, notes
)
VALUES (
'runtime_demo_001', $1, $2, $3, $4, $5, $6, 'active', 'demo runtime binding'
)
ON CONFLICT (runtime_binding_public_id) DO UPDATE SET
event_id = EXCLUDED.event_id,
place_id = EXCLUDED.place_id,
map_asset_id = EXCLUDED.map_asset_id,
tile_release_id = EXCLUDED.tile_release_id,
course_set_id = EXCLUDED.course_set_id,
course_variant_id = EXCLUDED.course_variant_id,
status = EXCLUDED.status,
notes = EXCLUDED.notes
RETURNING id, runtime_binding_public_id
`, eventID, placeID, mapAssetID, tileReleaseID, courseSetID, courseVariantID).Scan(&runtimeBindingID, &runtimeBindingPublicID); err != nil {
return nil, fmt.Errorf("ensure demo runtime binding: %w", err)
}
var manualEventID string var manualEventID string
if err := tx.QueryRow(ctx, ` if err := tx.QueryRow(ctx, `
INSERT INTO events ( INSERT INTO events (
@@ -452,6 +609,13 @@ func (s *Store) EnsureDemoData(ctx context.Context) (*DemoBootstrapSummary, erro
SourceID: source.ID, SourceID: source.ID,
BuildID: build.ID, BuildID: build.ID,
CardID: cardPublicID, CardID: cardPublicID,
PlaceID: placePublicID,
MapAssetID: mapAssetPublicID,
TileReleaseID: tileReleasePublicID,
CourseSourceID: courseSourcePublicID,
CourseSetID: courseSetPublicID,
CourseVariantID: courseVariantPublicID,
RuntimeBindingID: runtimeBindingPublicID,
VariantManualEventID: "evt_demo_variant_manual_001", VariantManualEventID: "evt_demo_variant_manual_001",
VariantManualRelease: manualReleaseRow.PublicID, VariantManualRelease: manualReleaseRow.PublicID,
VariantManualCardID: manualCardPublicID, VariantManualCardID: manualCardPublicID,

View File

@@ -0,0 +1,560 @@
package postgres
import (
"context"
"errors"
"fmt"
"github.com/jackc/pgx/v5"
)
type EventPresentation struct {
ID string
PublicID string
EventID string
EventPublicID string
Code string
Name string
PresentationType string
Status string
IsDefault bool
SchemaJSON string
CreatedAt string
UpdatedAt string
}
type ContentBundle struct {
ID string
PublicID string
EventID string
EventPublicID string
Code string
Name string
Status string
IsDefault bool
EntryURL *string
AssetRootURL *string
MetadataJSON string
CreatedAt string
UpdatedAt string
}
type CreateEventPresentationParams struct {
PublicID string
EventID string
Code string
Name string
PresentationType string
Status string
IsDefault bool
SchemaJSON string
}
type CreateContentBundleParams struct {
PublicID string
EventID string
Code string
Name string
Status string
IsDefault bool
EntryURL *string
AssetRootURL *string
MetadataJSON string
}
type EventDefaultBindings struct {
EventID string
EventPublicID string
PresentationID *string
PresentationPublicID *string
PresentationName *string
PresentationType *string
ContentBundleID *string
ContentBundlePublicID *string
ContentBundleName *string
ContentEntryURL *string
ContentAssetRootURL *string
RuntimeBindingID *string
RuntimeBindingPublicID *string
PlacePublicID *string
PlaceName *string
MapAssetPublicID *string
MapAssetName *string
TileReleasePublicID *string
CourseSetPublicID *string
CourseVariantPublicID *string
CourseVariantName *string
RuntimeRouteCode *string
}
type SetEventDefaultBindingsParams struct {
EventID string
PresentationID *string
ContentBundleID *string
RuntimeBindingID *string
UpdatePresentation bool
UpdateContent bool
UpdateRuntime bool
}
func (s *Store) ListEventPresentationsByEventID(ctx context.Context, eventID string, limit int) ([]EventPresentation, error) {
if limit <= 0 || limit > 200 {
limit = 50
}
rows, err := s.pool.Query(ctx, `
SELECT
ep.id,
ep.presentation_public_id,
ep.event_id,
e.event_public_id,
ep.code,
ep.name,
ep.presentation_type,
ep.status,
ep.is_default,
ep.schema_jsonb::text,
ep.created_at::text,
ep.updated_at::text
FROM event_presentations ep
JOIN events e ON e.id = ep.event_id
WHERE ep.event_id = $1
ORDER BY ep.is_default DESC, ep.created_at DESC
LIMIT $2
`, eventID, limit)
if err != nil {
return nil, fmt.Errorf("list event presentations: %w", err)
}
defer rows.Close()
items := []EventPresentation{}
for rows.Next() {
item, err := scanEventPresentationFromRows(rows)
if err != nil {
return nil, err
}
items = append(items, *item)
}
if err := rows.Err(); err != nil {
return nil, fmt.Errorf("iterate event presentations: %w", err)
}
return items, nil
}
func (s *Store) GetEventPresentationByPublicID(ctx context.Context, publicID string) (*EventPresentation, error) {
row := s.pool.QueryRow(ctx, `
SELECT
ep.id,
ep.presentation_public_id,
ep.event_id,
e.event_public_id,
ep.code,
ep.name,
ep.presentation_type,
ep.status,
ep.is_default,
ep.schema_jsonb::text,
ep.created_at::text,
ep.updated_at::text
FROM event_presentations ep
JOIN events e ON e.id = ep.event_id
WHERE ep.presentation_public_id = $1
LIMIT 1
`, publicID)
return scanEventPresentation(row)
}
func (s *Store) GetDefaultEventPresentationByEventID(ctx context.Context, eventID string) (*EventPresentation, error) {
row := s.pool.QueryRow(ctx, `
SELECT
ep.id,
ep.presentation_public_id,
ep.event_id,
e.event_public_id,
ep.code,
ep.name,
ep.presentation_type,
ep.status,
ep.is_default,
ep.schema_jsonb::text,
ep.created_at::text,
ep.updated_at::text
FROM event_presentations ep
JOIN events e ON e.id = ep.event_id
WHERE ep.event_id = $1
AND ep.status = 'active'
ORDER BY ep.is_default DESC, ep.updated_at DESC, ep.created_at DESC
LIMIT 1
`, eventID)
return scanEventPresentation(row)
}
func (s *Store) CreateEventPresentation(ctx context.Context, tx Tx, params CreateEventPresentationParams) (*EventPresentation, error) {
row := tx.QueryRow(ctx, `
INSERT INTO event_presentations (
presentation_public_id,
event_id,
code,
name,
presentation_type,
status,
is_default,
schema_jsonb
)
VALUES ($1, $2, $3, $4, $5, $6, $7, $8::jsonb)
RETURNING
id,
presentation_public_id,
event_id,
code,
name,
presentation_type,
status,
is_default,
schema_jsonb::text,
created_at::text,
updated_at::text
`, params.PublicID, params.EventID, params.Code, params.Name, params.PresentationType, params.Status, params.IsDefault, params.SchemaJSON)
var item EventPresentation
if err := row.Scan(
&item.ID,
&item.PublicID,
&item.EventID,
&item.Code,
&item.Name,
&item.PresentationType,
&item.Status,
&item.IsDefault,
&item.SchemaJSON,
&item.CreatedAt,
&item.UpdatedAt,
); err != nil {
return nil, fmt.Errorf("create event presentation: %w", err)
}
return &item, nil
}
func (s *Store) GetEventDefaultBindingsByEventID(ctx context.Context, eventID string) (*EventDefaultBindings, error) {
row := s.pool.QueryRow(ctx, `
SELECT
e.id,
e.event_public_id,
e.current_presentation_id,
ep.presentation_public_id,
ep.name,
ep.presentation_type,
e.current_content_bundle_id,
cb.content_bundle_public_id,
cb.name,
cb.entry_url,
cb.asset_root_url,
e.current_runtime_binding_id,
mrb.runtime_binding_public_id,
p.place_public_id,
p.name,
ma.map_asset_public_id,
ma.name,
tr.tile_release_public_id,
cset.course_set_public_id,
cv.course_variant_public_id,
cv.name,
cv.route_code
FROM events e
LEFT JOIN event_presentations ep ON ep.id = e.current_presentation_id
LEFT JOIN content_bundles cb ON cb.id = e.current_content_bundle_id
LEFT JOIN map_runtime_bindings mrb ON mrb.id = e.current_runtime_binding_id
LEFT JOIN places p ON p.id = mrb.place_id
LEFT JOIN map_assets ma ON ma.id = mrb.map_asset_id
LEFT JOIN tile_releases tr ON tr.id = mrb.tile_release_id
LEFT JOIN course_sets cset ON cset.id = mrb.course_set_id
LEFT JOIN course_variants cv ON cv.id = mrb.course_variant_id
WHERE e.id = $1
LIMIT 1
`, eventID)
return scanEventDefaultBindings(row)
}
func (s *Store) SetEventDefaultBindings(ctx context.Context, tx Tx, params SetEventDefaultBindingsParams) error {
if _, err := tx.Exec(ctx, `
UPDATE events
SET current_presentation_id = CASE WHEN $5 THEN $2 ELSE current_presentation_id END,
current_content_bundle_id = CASE WHEN $6 THEN $3 ELSE current_content_bundle_id END,
current_runtime_binding_id = CASE WHEN $7 THEN $4 ELSE current_runtime_binding_id END
WHERE id = $1
`, params.EventID, params.PresentationID, params.ContentBundleID, params.RuntimeBindingID, params.UpdatePresentation, params.UpdateContent, params.UpdateRuntime); err != nil {
return fmt.Errorf("set event default bindings: %w", err)
}
return nil
}
func (s *Store) ListContentBundlesByEventID(ctx context.Context, eventID string, limit int) ([]ContentBundle, error) {
if limit <= 0 || limit > 200 {
limit = 50
}
rows, err := s.pool.Query(ctx, `
SELECT
cb.id,
cb.content_bundle_public_id,
cb.event_id,
e.event_public_id,
cb.code,
cb.name,
cb.status,
cb.is_default,
cb.entry_url,
cb.asset_root_url,
cb.metadata_jsonb::text,
cb.created_at::text,
cb.updated_at::text
FROM content_bundles cb
JOIN events e ON e.id = cb.event_id
WHERE cb.event_id = $1
ORDER BY cb.is_default DESC, cb.created_at DESC
LIMIT $2
`, eventID, limit)
if err != nil {
return nil, fmt.Errorf("list content bundles: %w", err)
}
defer rows.Close()
items := []ContentBundle{}
for rows.Next() {
item, err := scanContentBundleFromRows(rows)
if err != nil {
return nil, err
}
items = append(items, *item)
}
if err := rows.Err(); err != nil {
return nil, fmt.Errorf("iterate content bundles: %w", err)
}
return items, nil
}
func (s *Store) GetContentBundleByPublicID(ctx context.Context, publicID string) (*ContentBundle, error) {
row := s.pool.QueryRow(ctx, `
SELECT
cb.id,
cb.content_bundle_public_id,
cb.event_id,
e.event_public_id,
cb.code,
cb.name,
cb.status,
cb.is_default,
cb.entry_url,
cb.asset_root_url,
cb.metadata_jsonb::text,
cb.created_at::text,
cb.updated_at::text
FROM content_bundles cb
JOIN events e ON e.id = cb.event_id
WHERE cb.content_bundle_public_id = $1
LIMIT 1
`, publicID)
return scanContentBundle(row)
}
func (s *Store) GetDefaultContentBundleByEventID(ctx context.Context, eventID string) (*ContentBundle, error) {
row := s.pool.QueryRow(ctx, `
SELECT
cb.id,
cb.content_bundle_public_id,
cb.event_id,
e.event_public_id,
cb.code,
cb.name,
cb.status,
cb.is_default,
cb.entry_url,
cb.asset_root_url,
cb.metadata_jsonb::text,
cb.created_at::text,
cb.updated_at::text
FROM content_bundles cb
JOIN events e ON e.id = cb.event_id
WHERE cb.event_id = $1
AND cb.status = 'active'
ORDER BY cb.is_default DESC, cb.updated_at DESC, cb.created_at DESC
LIMIT 1
`, eventID)
return scanContentBundle(row)
}
func (s *Store) CreateContentBundle(ctx context.Context, tx Tx, params CreateContentBundleParams) (*ContentBundle, error) {
row := tx.QueryRow(ctx, `
INSERT INTO content_bundles (
content_bundle_public_id,
event_id,
code,
name,
status,
is_default,
entry_url,
asset_root_url,
metadata_jsonb
)
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9::jsonb)
RETURNING
id,
content_bundle_public_id,
event_id,
code,
name,
status,
is_default,
entry_url,
asset_root_url,
metadata_jsonb::text,
created_at::text,
updated_at::text
`, params.PublicID, params.EventID, params.Code, params.Name, params.Status, params.IsDefault, params.EntryURL, params.AssetRootURL, params.MetadataJSON)
var item ContentBundle
if err := row.Scan(
&item.ID,
&item.PublicID,
&item.EventID,
&item.Code,
&item.Name,
&item.Status,
&item.IsDefault,
&item.EntryURL,
&item.AssetRootURL,
&item.MetadataJSON,
&item.CreatedAt,
&item.UpdatedAt,
); err != nil {
return nil, fmt.Errorf("create content bundle: %w", err)
}
return &item, nil
}
func scanEventPresentation(row pgx.Row) (*EventPresentation, error) {
var item EventPresentation
err := row.Scan(
&item.ID,
&item.PublicID,
&item.EventID,
&item.EventPublicID,
&item.Code,
&item.Name,
&item.PresentationType,
&item.Status,
&item.IsDefault,
&item.SchemaJSON,
&item.CreatedAt,
&item.UpdatedAt,
)
if errors.Is(err, pgx.ErrNoRows) {
return nil, nil
}
if err != nil {
return nil, fmt.Errorf("scan event presentation: %w", err)
}
return &item, nil
}
func scanEventPresentationFromRows(rows pgx.Rows) (*EventPresentation, error) {
var item EventPresentation
if err := rows.Scan(
&item.ID,
&item.PublicID,
&item.EventID,
&item.EventPublicID,
&item.Code,
&item.Name,
&item.PresentationType,
&item.Status,
&item.IsDefault,
&item.SchemaJSON,
&item.CreatedAt,
&item.UpdatedAt,
); err != nil {
return nil, fmt.Errorf("scan event presentation row: %w", err)
}
return &item, nil
}
func scanContentBundle(row pgx.Row) (*ContentBundle, error) {
var item ContentBundle
err := row.Scan(
&item.ID,
&item.PublicID,
&item.EventID,
&item.EventPublicID,
&item.Code,
&item.Name,
&item.Status,
&item.IsDefault,
&item.EntryURL,
&item.AssetRootURL,
&item.MetadataJSON,
&item.CreatedAt,
&item.UpdatedAt,
)
if errors.Is(err, pgx.ErrNoRows) {
return nil, nil
}
if err != nil {
return nil, fmt.Errorf("scan content bundle: %w", err)
}
return &item, nil
}
func scanContentBundleFromRows(rows pgx.Rows) (*ContentBundle, error) {
var item ContentBundle
if err := rows.Scan(
&item.ID,
&item.PublicID,
&item.EventID,
&item.EventPublicID,
&item.Code,
&item.Name,
&item.Status,
&item.IsDefault,
&item.EntryURL,
&item.AssetRootURL,
&item.MetadataJSON,
&item.CreatedAt,
&item.UpdatedAt,
); err != nil {
return nil, fmt.Errorf("scan content bundle row: %w", err)
}
return &item, nil
}
func scanEventDefaultBindings(row pgx.Row) (*EventDefaultBindings, error) {
var item EventDefaultBindings
err := row.Scan(
&item.EventID,
&item.EventPublicID,
&item.PresentationID,
&item.PresentationPublicID,
&item.PresentationName,
&item.PresentationType,
&item.ContentBundleID,
&item.ContentBundlePublicID,
&item.ContentBundleName,
&item.ContentEntryURL,
&item.ContentAssetRootURL,
&item.RuntimeBindingID,
&item.RuntimeBindingPublicID,
&item.PlacePublicID,
&item.PlaceName,
&item.MapAssetPublicID,
&item.MapAssetName,
&item.TileReleasePublicID,
&item.CourseSetPublicID,
&item.CourseVariantPublicID,
&item.CourseVariantName,
&item.RuntimeRouteCode,
)
if errors.Is(err, pgx.ErrNoRows) {
return nil, nil
}
if err != nil {
return nil, fmt.Errorf("scan event default bindings: %w", err)
}
return &item, nil
}

View File

@@ -23,20 +23,54 @@ type Event struct {
ManifestChecksum *string ManifestChecksum *string
RouteCode *string RouteCode *string
ReleasePayloadJSON *string ReleasePayloadJSON *string
RuntimeBindingID *string
PlacePublicID *string
PlaceName *string
MapAssetPublicID *string
MapAssetName *string
TileReleasePublicID *string
CourseSetPublicID *string
CourseVariantID *string
CourseVariantName *string
RuntimeRouteCode *string
PresentationID *string
PresentationName *string
PresentationType *string
ContentBundleID *string
ContentBundleName *string
ContentEntryURL *string
ContentAssetRootURL *string
} }
type EventRelease struct { type EventRelease struct {
ID string ID string
PublicID string PublicID string
EventID string EventID string
ReleaseNo int ReleaseNo int
ConfigLabel string ConfigLabel string
ManifestURL string ManifestURL string
ManifestChecksum *string ManifestChecksum *string
RouteCode *string RouteCode *string
BuildID *string BuildID *string
Status string Status string
PublishedAt time.Time PublishedAt time.Time
RuntimeBindingID *string
PlacePublicID *string
PlaceName *string
MapAssetPublicID *string
MapAssetName *string
TileReleaseID *string
CourseSetID *string
CourseVariantID *string
CourseVariantName *string
RuntimeRouteCode *string
PresentationID *string
PresentationName *string
PresentationType *string
ContentBundleID *string
ContentBundleName *string
ContentEntryURL *string
ContentAssetURL *string
} }
type CreateGameSessionParams struct { type CreateGameSessionParams struct {
@@ -85,9 +119,34 @@ func (s *Store) GetEventByPublicID(ctx context.Context, eventPublicID string) (*
er.manifest_url, er.manifest_url,
er.manifest_checksum_sha256, er.manifest_checksum_sha256,
er.route_code, er.route_code,
er.payload_jsonb::text er.payload_jsonb::text,
mrb.runtime_binding_public_id,
p.place_public_id,
p.name,
ma.map_asset_public_id,
ma.name,
tr.tile_release_public_id,
cset.course_set_public_id,
cv.course_variant_public_id,
cv.name,
cv.route_code,
ep.presentation_public_id,
ep.name,
ep.presentation_type,
cb.content_bundle_public_id,
cb.name,
cb.entry_url,
cb.asset_root_url
FROM events e FROM events e
LEFT JOIN event_releases er ON er.id = e.current_release_id LEFT JOIN event_releases er ON er.id = e.current_release_id
LEFT JOIN map_runtime_bindings mrb ON mrb.id = er.runtime_binding_id
LEFT JOIN places p ON p.id = mrb.place_id
LEFT JOIN map_assets ma ON ma.id = mrb.map_asset_id
LEFT JOIN tile_releases tr ON tr.id = mrb.tile_release_id
LEFT JOIN course_sets cset ON cset.id = mrb.course_set_id
LEFT JOIN course_variants cv ON cv.id = mrb.course_variant_id
LEFT JOIN event_presentations ep ON ep.id = er.presentation_id
LEFT JOIN content_bundles cb ON cb.id = er.content_bundle_id
WHERE e.event_public_id = $1 WHERE e.event_public_id = $1
LIMIT 1 LIMIT 1
`, eventPublicID) `, eventPublicID)
@@ -107,6 +166,23 @@ func (s *Store) GetEventByPublicID(ctx context.Context, eventPublicID string) (*
&event.ManifestChecksum, &event.ManifestChecksum,
&event.RouteCode, &event.RouteCode,
&event.ReleasePayloadJSON, &event.ReleasePayloadJSON,
&event.RuntimeBindingID,
&event.PlacePublicID,
&event.PlaceName,
&event.MapAssetPublicID,
&event.MapAssetName,
&event.TileReleasePublicID,
&event.CourseSetPublicID,
&event.CourseVariantID,
&event.CourseVariantName,
&event.RuntimeRouteCode,
&event.PresentationID,
&event.PresentationName,
&event.PresentationType,
&event.ContentBundleID,
&event.ContentBundleName,
&event.ContentEntryURL,
&event.ContentAssetRootURL,
) )
if errors.Is(err, pgx.ErrNoRows) { if errors.Is(err, pgx.ErrNoRows) {
return nil, nil return nil, nil
@@ -132,9 +208,34 @@ func (s *Store) GetEventByID(ctx context.Context, eventID string) (*Event, error
er.manifest_url, er.manifest_url,
er.manifest_checksum_sha256, er.manifest_checksum_sha256,
er.route_code, er.route_code,
er.payload_jsonb::text er.payload_jsonb::text,
mrb.runtime_binding_public_id,
p.place_public_id,
p.name,
ma.map_asset_public_id,
ma.name,
tr.tile_release_public_id,
cset.course_set_public_id,
cv.course_variant_public_id,
cv.name,
cv.route_code,
ep.presentation_public_id,
ep.name,
ep.presentation_type,
cb.content_bundle_public_id,
cb.name,
cb.entry_url,
cb.asset_root_url
FROM events e FROM events e
LEFT JOIN event_releases er ON er.id = e.current_release_id LEFT JOIN event_releases er ON er.id = e.current_release_id
LEFT JOIN map_runtime_bindings mrb ON mrb.id = er.runtime_binding_id
LEFT JOIN places p ON p.id = mrb.place_id
LEFT JOIN map_assets ma ON ma.id = mrb.map_asset_id
LEFT JOIN tile_releases tr ON tr.id = mrb.tile_release_id
LEFT JOIN course_sets cset ON cset.id = mrb.course_set_id
LEFT JOIN course_variants cv ON cv.id = mrb.course_variant_id
LEFT JOIN event_presentations ep ON ep.id = er.presentation_id
LEFT JOIN content_bundles cb ON cb.id = er.content_bundle_id
WHERE e.id = $1 WHERE e.id = $1
LIMIT 1 LIMIT 1
`, eventID) `, eventID)
@@ -154,6 +255,23 @@ func (s *Store) GetEventByID(ctx context.Context, eventID string) (*Event, error
&event.ManifestChecksum, &event.ManifestChecksum,
&event.RouteCode, &event.RouteCode,
&event.ReleasePayloadJSON, &event.ReleasePayloadJSON,
&event.RuntimeBindingID,
&event.PlacePublicID,
&event.PlaceName,
&event.MapAssetPublicID,
&event.MapAssetName,
&event.TileReleasePublicID,
&event.CourseSetPublicID,
&event.CourseVariantID,
&event.CourseVariantName,
&event.RuntimeRouteCode,
&event.PresentationID,
&event.PresentationName,
&event.PresentationType,
&event.ContentBundleID,
&event.ContentBundleName,
&event.ContentEntryURL,
&event.ContentAssetRootURL,
) )
if errors.Is(err, pgx.ErrNoRows) { if errors.Is(err, pgx.ErrNoRows) {
return nil, nil return nil, nil
@@ -168,7 +286,7 @@ func (s *Store) NextEventReleaseNo(ctx context.Context, eventID string) (int, er
var next int var next int
if err := s.pool.QueryRow(ctx, ` if err := s.pool.QueryRow(ctx, `
SELECT COALESCE(MAX(release_no), 0) + 1 SELECT COALESCE(MAX(release_no), 0) + 1
FROM event_releases FROM event_releases er
WHERE event_id = $1 WHERE event_id = $1
`, eventID).Scan(&next); err != nil { `, eventID).Scan(&next); err != nil {
return 0, fmt.Errorf("next event release no: %w", err) return 0, fmt.Errorf("next event release no: %w", err)
@@ -185,6 +303,9 @@ type CreateEventReleaseParams struct {
ManifestChecksum *string ManifestChecksum *string
RouteCode *string RouteCode *string
BuildID *string BuildID *string
RuntimeBindingID *string
PresentationID *string
ContentBundleID *string
Status string Status string
PayloadJSON string PayloadJSON string
} }
@@ -200,12 +321,15 @@ func (s *Store) CreateEventRelease(ctx context.Context, tx Tx, params CreateEven
manifest_checksum_sha256, manifest_checksum_sha256,
route_code, route_code,
build_id, build_id,
runtime_binding_id,
presentation_id,
content_bundle_id,
status, status,
payload_jsonb payload_jsonb
) )
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10::jsonb) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13::jsonb)
RETURNING id, release_public_id, event_id, release_no, config_label, manifest_url, manifest_checksum_sha256, route_code, build_id, status, published_at RETURNING id, release_public_id, event_id, release_no, config_label, manifest_url, manifest_checksum_sha256, route_code, build_id, status, published_at
`, params.PublicID, params.EventID, params.ReleaseNo, params.ConfigLabel, params.ManifestURL, params.ManifestChecksum, params.RouteCode, params.BuildID, params.Status, params.PayloadJSON) `, params.PublicID, params.EventID, params.ReleaseNo, params.ConfigLabel, params.ManifestURL, params.ManifestChecksum, params.RouteCode, params.BuildID, params.RuntimeBindingID, params.PresentationID, params.ContentBundleID, params.Status, params.PayloadJSON)
var item EventRelease var item EventRelease
if err := row.Scan( if err := row.Scan(
@@ -284,10 +408,46 @@ func (s *Store) ListEventReleasesByEventID(ctx context.Context, eventID string,
limit = 20 limit = 20
} }
rows, err := s.pool.Query(ctx, ` 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 SELECT
FROM event_releases er.id,
WHERE event_id = $1 er.release_public_id,
ORDER BY release_no DESC er.event_id,
er.release_no,
er.config_label,
er.manifest_url,
er.manifest_checksum_sha256,
er.route_code,
er.build_id,
er.status,
er.published_at,
mrb.runtime_binding_public_id,
p.place_public_id,
p.name,
ma.map_asset_public_id,
ma.name,
tr.tile_release_public_id,
cset.course_set_public_id,
cv.course_variant_public_id,
cv.name,
cv.route_code,
ep.presentation_public_id,
ep.name,
ep.presentation_type,
cb.content_bundle_public_id,
cb.name,
cb.entry_url,
cb.asset_root_url
FROM event_releases er
LEFT JOIN map_runtime_bindings mrb ON mrb.id = er.runtime_binding_id
LEFT JOIN places p ON p.id = mrb.place_id
LEFT JOIN map_assets ma ON ma.id = mrb.map_asset_id
LEFT JOIN tile_releases tr ON tr.id = mrb.tile_release_id
LEFT JOIN course_sets cset ON cset.id = mrb.course_set_id
LEFT JOIN course_variants cv ON cv.id = mrb.course_variant_id
LEFT JOIN event_presentations ep ON ep.id = er.presentation_id
LEFT JOIN content_bundles cb ON cb.id = er.content_bundle_id
WHERE er.event_id = $1
ORDER BY er.release_no DESC
LIMIT $2 LIMIT $2
`, eventID, limit) `, eventID, limit)
if err != nil { if err != nil {
@@ -311,9 +471,45 @@ func (s *Store) ListEventReleasesByEventID(ctx context.Context, eventID string,
func (s *Store) GetEventReleaseByPublicID(ctx context.Context, releasePublicID string) (*EventRelease, error) { func (s *Store) GetEventReleaseByPublicID(ctx context.Context, releasePublicID string) (*EventRelease, error) {
row := s.pool.QueryRow(ctx, ` 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 SELECT
FROM event_releases er.id,
WHERE release_public_id = $1 er.release_public_id,
er.event_id,
er.release_no,
er.config_label,
er.manifest_url,
er.manifest_checksum_sha256,
er.route_code,
er.build_id,
er.status,
er.published_at,
mrb.runtime_binding_public_id,
p.place_public_id,
p.name,
ma.map_asset_public_id,
ma.name,
tr.tile_release_public_id,
cset.course_set_public_id,
cv.course_variant_public_id,
cv.name,
cv.route_code,
ep.presentation_public_id,
ep.name,
ep.presentation_type,
cb.content_bundle_public_id,
cb.name,
cb.entry_url,
cb.asset_root_url
FROM event_releases er
LEFT JOIN map_runtime_bindings mrb ON mrb.id = er.runtime_binding_id
LEFT JOIN places p ON p.id = mrb.place_id
LEFT JOIN map_assets ma ON ma.id = mrb.map_asset_id
LEFT JOIN tile_releases tr ON tr.id = mrb.tile_release_id
LEFT JOIN course_sets cset ON cset.id = mrb.course_set_id
LEFT JOIN course_variants cv ON cv.id = mrb.course_variant_id
LEFT JOIN event_presentations ep ON ep.id = er.presentation_id
LEFT JOIN content_bundles cb ON cb.id = er.content_bundle_id
WHERE er.release_public_id = $1
LIMIT 1 LIMIT 1
`, releasePublicID) `, releasePublicID)
@@ -330,6 +526,23 @@ func (s *Store) GetEventReleaseByPublicID(ctx context.Context, releasePublicID s
&item.BuildID, &item.BuildID,
&item.Status, &item.Status,
&item.PublishedAt, &item.PublishedAt,
&item.RuntimeBindingID,
&item.PlacePublicID,
&item.PlaceName,
&item.MapAssetPublicID,
&item.MapAssetName,
&item.TileReleaseID,
&item.CourseSetID,
&item.CourseVariantID,
&item.CourseVariantName,
&item.RuntimeRouteCode,
&item.PresentationID,
&item.PresentationName,
&item.PresentationType,
&item.ContentBundleID,
&item.ContentBundleName,
&item.ContentEntryURL,
&item.ContentAssetURL,
) )
if errors.Is(err, pgx.ErrNoRows) { if errors.Is(err, pgx.ErrNoRows) {
return nil, nil return nil, nil
@@ -354,9 +567,37 @@ func scanEventReleaseFromRows(rows pgx.Rows) (*EventRelease, error) {
&item.BuildID, &item.BuildID,
&item.Status, &item.Status,
&item.PublishedAt, &item.PublishedAt,
&item.RuntimeBindingID,
&item.PlacePublicID,
&item.PlaceName,
&item.MapAssetPublicID,
&item.MapAssetName,
&item.TileReleaseID,
&item.CourseSetID,
&item.CourseVariantID,
&item.CourseVariantName,
&item.RuntimeRouteCode,
&item.PresentationID,
&item.PresentationName,
&item.PresentationType,
&item.ContentBundleID,
&item.ContentBundleName,
&item.ContentEntryURL,
&item.ContentAssetURL,
) )
if err != nil { if err != nil {
return nil, fmt.Errorf("scan event release row: %w", err) return nil, fmt.Errorf("scan event release row: %w", err)
} }
return &item, nil return &item, nil
} }
func (s *Store) SetEventReleaseRuntimeBinding(ctx context.Context, tx Tx, releaseID string, runtimeBindingID *string) error {
if _, err := tx.Exec(ctx, `
UPDATE event_releases
SET runtime_binding_id = $2
WHERE id = $1
`, releaseID, runtimeBindingID); err != nil {
return fmt.Errorf("set event release runtime binding: %w", err)
}
return nil
}

View File

@@ -0,0 +1,822 @@
package postgres
import (
"context"
"encoding/json"
"errors"
"fmt"
"time"
"github.com/jackc/pgx/v5"
)
type Place struct {
ID string
PublicID string
Code string
Name string
Region *string
CoverURL *string
Description *string
CenterPoint json.RawMessage
Status string
CreatedAt time.Time
UpdatedAt time.Time
}
type MapAsset struct {
ID string
PublicID string
PlaceID string
LegacyMapID *string
LegacyMapPublicID *string
Code string
Name string
MapType string
CoverURL *string
Description *string
Status string
CurrentTileReleaseID *string
CreatedAt time.Time
UpdatedAt time.Time
}
type TileRelease struct {
ID string
PublicID string
MapAssetID string
LegacyMapVersionID *string
LegacyMapVersionPub *string
VersionCode string
Status string
TileBaseURL string
MetaURL string
PublishedAssetRoot *string
MetadataJSON json.RawMessage
PublishedAt *time.Time
CreatedAt time.Time
UpdatedAt time.Time
}
type CourseSource struct {
ID string
PublicID string
LegacyPlayfieldVersionID *string
LegacyPlayfieldVersionPub *string
SourceType string
FileURL string
Checksum *string
ParserVersion *string
ImportStatus string
MetadataJSON json.RawMessage
ImportedAt time.Time
CreatedAt time.Time
UpdatedAt time.Time
}
type CourseSet struct {
ID string
PublicID string
PlaceID string
MapAssetID string
Code string
Mode string
Name string
Description *string
Status string
CurrentVariantID *string
CreatedAt time.Time
UpdatedAt time.Time
}
type CourseVariant struct {
ID string
PublicID string
CourseSetID string
SourceID *string
SourcePublicID *string
Name string
RouteCode *string
Mode string
ControlCount *int
Difficulty *string
Status string
IsDefault bool
ConfigPatch json.RawMessage
MetadataJSON json.RawMessage
CreatedAt time.Time
UpdatedAt time.Time
}
type MapRuntimeBinding struct {
ID string
PublicID string
EventID string
EventPublicID string
PlaceID string
PlacePublicID string
MapAssetID string
MapAssetPublicID string
TileReleaseID string
TileReleasePublicID string
CourseSetID string
CourseSetPublicID string
CourseVariantID string
CourseVariantPublicID string
Status string
Notes *string
CreatedAt time.Time
UpdatedAt time.Time
}
type CreatePlaceParams struct {
PublicID string
Code string
Name string
Region *string
CoverURL *string
Description *string
CenterPoint map[string]any
Status string
}
type CreateMapAssetParams struct {
PublicID string
PlaceID string
LegacyMapID *string
Code string
Name string
MapType string
CoverURL *string
Description *string
Status string
}
type CreateTileReleaseParams struct {
PublicID string
MapAssetID string
LegacyMapVersionID *string
VersionCode string
Status string
TileBaseURL string
MetaURL string
PublishedAssetRoot *string
MetadataJSON map[string]any
PublishedAt *time.Time
}
type CreateCourseSourceParams struct {
PublicID string
LegacyPlayfieldVersionID *string
SourceType string
FileURL string
Checksum *string
ParserVersion *string
ImportStatus string
MetadataJSON map[string]any
ImportedAt *time.Time
}
type CreateCourseSetParams struct {
PublicID string
PlaceID string
MapAssetID string
Code string
Mode string
Name string
Description *string
Status string
}
type CreateCourseVariantParams struct {
PublicID string
CourseSetID string
SourceID *string
Name string
RouteCode *string
Mode string
ControlCount *int
Difficulty *string
Status string
IsDefault bool
ConfigPatch map[string]any
MetadataJSON map[string]any
}
type CreateMapRuntimeBindingParams struct {
PublicID string
EventID string
PlaceID string
MapAssetID string
TileReleaseID string
CourseSetID string
CourseVariantID string
Status string
Notes *string
}
func (s *Store) ListPlaces(ctx context.Context, limit int) ([]Place, error) {
if limit <= 0 || limit > 200 {
limit = 50
}
rows, err := s.pool.Query(ctx, `
SELECT id, place_public_id, code, name, region, cover_url, description, center_point_jsonb::text, status, created_at, updated_at
FROM places
ORDER BY created_at DESC
LIMIT $1
`, limit)
if err != nil {
return nil, fmt.Errorf("list places: %w", err)
}
defer rows.Close()
items := []Place{}
for rows.Next() {
item, err := scanPlaceFromRows(rows)
if err != nil {
return nil, err
}
items = append(items, *item)
}
if err := rows.Err(); err != nil {
return nil, fmt.Errorf("iterate places: %w", err)
}
return items, nil
}
func (s *Store) GetPlaceByPublicID(ctx context.Context, publicID string) (*Place, error) {
row := s.pool.QueryRow(ctx, `
SELECT id, place_public_id, code, name, region, cover_url, description, center_point_jsonb::text, status, created_at, updated_at
FROM places
WHERE place_public_id = $1
LIMIT 1
`, publicID)
return scanPlace(row)
}
func (s *Store) CreatePlace(ctx context.Context, tx Tx, params CreatePlaceParams) (*Place, error) {
centerPointJSON, err := marshalJSONMap(params.CenterPoint)
if err != nil {
return nil, fmt.Errorf("marshal place center point: %w", err)
}
row := tx.QueryRow(ctx, `
INSERT INTO places (place_public_id, code, name, region, cover_url, description, center_point_jsonb, status)
VALUES ($1, $2, $3, $4, $5, $6, $7::jsonb, $8)
RETURNING id, place_public_id, code, name, region, cover_url, description, center_point_jsonb::text, status, created_at, updated_at
`, params.PublicID, params.Code, params.Name, params.Region, params.CoverURL, params.Description, centerPointJSON, params.Status)
return scanPlace(row)
}
func (s *Store) ListMapAssetsByPlaceID(ctx context.Context, placeID string) ([]MapAsset, error) {
rows, err := s.pool.Query(ctx, `
SELECT ma.id, ma.map_asset_public_id, ma.place_id, ma.legacy_map_id, lm.map_public_id, ma.code, ma.name, ma.map_type,
ma.cover_url, ma.description, ma.status, ma.current_tile_release_id, ma.created_at, ma.updated_at
FROM map_assets ma
LEFT JOIN maps lm ON lm.id = ma.legacy_map_id
WHERE ma.place_id = $1
ORDER BY ma.created_at DESC
`, placeID)
if err != nil {
return nil, fmt.Errorf("list map assets: %w", err)
}
defer rows.Close()
items := []MapAsset{}
for rows.Next() {
item, err := scanMapAssetFromRows(rows)
if err != nil {
return nil, err
}
items = append(items, *item)
}
if err := rows.Err(); err != nil {
return nil, fmt.Errorf("iterate map assets: %w", err)
}
return items, nil
}
func (s *Store) GetMapAssetByPublicID(ctx context.Context, publicID string) (*MapAsset, error) {
row := s.pool.QueryRow(ctx, `
SELECT ma.id, ma.map_asset_public_id, ma.place_id, ma.legacy_map_id, lm.map_public_id, ma.code, ma.name, ma.map_type,
ma.cover_url, ma.description, ma.status, ma.current_tile_release_id, ma.created_at, ma.updated_at
FROM map_assets ma
LEFT JOIN maps lm ON lm.id = ma.legacy_map_id
WHERE ma.map_asset_public_id = $1
LIMIT 1
`, publicID)
return scanMapAsset(row)
}
func (s *Store) CreateMapAsset(ctx context.Context, tx Tx, params CreateMapAssetParams) (*MapAsset, error) {
row := tx.QueryRow(ctx, `
INSERT INTO map_assets (map_asset_public_id, place_id, legacy_map_id, code, name, map_type, cover_url, description, status)
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9)
RETURNING id, map_asset_public_id, place_id, legacy_map_id, NULL::text, code, name, map_type, cover_url, description, status, current_tile_release_id, created_at, updated_at
`, params.PublicID, params.PlaceID, params.LegacyMapID, params.Code, params.Name, params.MapType, params.CoverURL, params.Description, params.Status)
return scanMapAsset(row)
}
func (s *Store) ListTileReleasesByMapAssetID(ctx context.Context, mapAssetID string) ([]TileRelease, error) {
rows, err := s.pool.Query(ctx, `
SELECT tr.id, tr.tile_release_public_id, tr.map_asset_id, tr.legacy_map_version_id, mv.version_public_id,
tr.version_code, tr.status, tr.tile_base_url, tr.meta_url, tr.published_asset_root,
tr.metadata_jsonb::text, tr.published_at, tr.created_at, tr.updated_at
FROM tile_releases tr
LEFT JOIN map_versions mv ON mv.id = tr.legacy_map_version_id
WHERE tr.map_asset_id = $1
ORDER BY tr.created_at DESC
`, mapAssetID)
if err != nil {
return nil, fmt.Errorf("list tile releases: %w", err)
}
defer rows.Close()
items := []TileRelease{}
for rows.Next() {
item, err := scanTileReleaseFromRows(rows)
if err != nil {
return nil, err
}
items = append(items, *item)
}
if err := rows.Err(); err != nil {
return nil, fmt.Errorf("iterate tile releases: %w", err)
}
return items, nil
}
func (s *Store) GetTileReleaseByPublicID(ctx context.Context, publicID string) (*TileRelease, error) {
row := s.pool.QueryRow(ctx, `
SELECT tr.id, tr.tile_release_public_id, tr.map_asset_id, tr.legacy_map_version_id, mv.version_public_id,
tr.version_code, tr.status, tr.tile_base_url, tr.meta_url, tr.published_asset_root,
tr.metadata_jsonb::text, tr.published_at, tr.created_at, tr.updated_at
FROM tile_releases tr
LEFT JOIN map_versions mv ON mv.id = tr.legacy_map_version_id
WHERE tr.tile_release_public_id = $1
LIMIT 1
`, publicID)
return scanTileRelease(row)
}
func (s *Store) CreateTileRelease(ctx context.Context, tx Tx, params CreateTileReleaseParams) (*TileRelease, error) {
metadataJSON, err := marshalJSONMap(params.MetadataJSON)
if err != nil {
return nil, fmt.Errorf("marshal tile release metadata: %w", err)
}
row := tx.QueryRow(ctx, `
INSERT INTO tile_releases (
tile_release_public_id, map_asset_id, legacy_map_version_id, version_code, status,
tile_base_url, meta_url, published_asset_root, metadata_jsonb, published_at
)
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9::jsonb, $10)
RETURNING id, tile_release_public_id, map_asset_id, legacy_map_version_id, NULL::text, version_code, status,
tile_base_url, meta_url, published_asset_root, metadata_jsonb::text, published_at, created_at, updated_at
`, params.PublicID, params.MapAssetID, params.LegacyMapVersionID, params.VersionCode, params.Status, params.TileBaseURL, params.MetaURL, params.PublishedAssetRoot, metadataJSON, params.PublishedAt)
return scanTileRelease(row)
}
func (s *Store) SetMapAssetCurrentTileRelease(ctx context.Context, tx Tx, mapAssetID, tileReleaseID string) error {
_, err := tx.Exec(ctx, `UPDATE map_assets SET current_tile_release_id = $2 WHERE id = $1`, mapAssetID, tileReleaseID)
if err != nil {
return fmt.Errorf("set map asset current tile release: %w", err)
}
return nil
}
func (s *Store) ListCourseSources(ctx context.Context, limit int) ([]CourseSource, error) {
if limit <= 0 || limit > 200 {
limit = 50
}
rows, err := s.pool.Query(ctx, `
SELECT cs.id, cs.course_source_public_id, cs.legacy_playfield_version_id, pv.version_public_id, cs.source_type,
cs.file_url, cs.checksum, cs.parser_version, cs.import_status, cs.metadata_jsonb::text, cs.imported_at, cs.created_at, cs.updated_at
FROM course_sources cs
LEFT JOIN playfield_versions pv ON pv.id = cs.legacy_playfield_version_id
ORDER BY cs.created_at DESC
LIMIT $1
`, limit)
if err != nil {
return nil, fmt.Errorf("list course sources: %w", err)
}
defer rows.Close()
items := []CourseSource{}
for rows.Next() {
item, err := scanCourseSourceFromRows(rows)
if err != nil {
return nil, err
}
items = append(items, *item)
}
if err := rows.Err(); err != nil {
return nil, fmt.Errorf("iterate course sources: %w", err)
}
return items, nil
}
func (s *Store) GetCourseSourceByPublicID(ctx context.Context, publicID string) (*CourseSource, error) {
row := s.pool.QueryRow(ctx, `
SELECT cs.id, cs.course_source_public_id, cs.legacy_playfield_version_id, pv.version_public_id, cs.source_type,
cs.file_url, cs.checksum, cs.parser_version, cs.import_status, cs.metadata_jsonb::text, cs.imported_at, cs.created_at, cs.updated_at
FROM course_sources cs
LEFT JOIN playfield_versions pv ON pv.id = cs.legacy_playfield_version_id
WHERE cs.course_source_public_id = $1
LIMIT 1
`, publicID)
return scanCourseSource(row)
}
func (s *Store) CreateCourseSource(ctx context.Context, tx Tx, params CreateCourseSourceParams) (*CourseSource, error) {
metadataJSON, err := marshalJSONMap(params.MetadataJSON)
if err != nil {
return nil, fmt.Errorf("marshal course source metadata: %w", err)
}
importedAt := time.Now()
if params.ImportedAt != nil {
importedAt = *params.ImportedAt
}
row := tx.QueryRow(ctx, `
INSERT INTO course_sources (
course_source_public_id, legacy_playfield_version_id, source_type, file_url, checksum,
parser_version, import_status, metadata_jsonb, imported_at
)
VALUES ($1, $2, $3, $4, $5, $6, $7, $8::jsonb, $9)
RETURNING id, course_source_public_id, legacy_playfield_version_id, NULL::text, source_type, file_url,
checksum, parser_version, import_status, metadata_jsonb::text, imported_at, created_at, updated_at
`, params.PublicID, params.LegacyPlayfieldVersionID, params.SourceType, params.FileURL, params.Checksum, params.ParserVersion, params.ImportStatus, metadataJSON, importedAt)
return scanCourseSource(row)
}
func (s *Store) ListCourseSets(ctx context.Context, limit int) ([]CourseSet, error) {
if limit <= 0 || limit > 200 {
limit = 50
}
rows, err := s.pool.Query(ctx, `
SELECT id, course_set_public_id, place_id, map_asset_id, code, mode, name, description, status, current_variant_id, created_at, updated_at
FROM course_sets
ORDER BY created_at DESC
LIMIT $1
`, limit)
if err != nil {
return nil, fmt.Errorf("list course sets: %w", err)
}
defer rows.Close()
items := []CourseSet{}
for rows.Next() {
item, err := scanCourseSetFromRows(rows)
if err != nil {
return nil, err
}
items = append(items, *item)
}
if err := rows.Err(); err != nil {
return nil, fmt.Errorf("iterate course sets: %w", err)
}
return items, nil
}
func (s *Store) ListCourseSetsByMapAssetID(ctx context.Context, mapAssetID string) ([]CourseSet, error) {
rows, err := s.pool.Query(ctx, `
SELECT id, course_set_public_id, place_id, map_asset_id, code, mode, name, description, status, current_variant_id, created_at, updated_at
FROM course_sets
WHERE map_asset_id = $1
ORDER BY created_at DESC
`, mapAssetID)
if err != nil {
return nil, fmt.Errorf("list course sets by map asset: %w", err)
}
defer rows.Close()
items := []CourseSet{}
for rows.Next() {
item, err := scanCourseSetFromRows(rows)
if err != nil {
return nil, err
}
items = append(items, *item)
}
if err := rows.Err(); err != nil {
return nil, fmt.Errorf("iterate course sets by map asset: %w", err)
}
return items, nil
}
func (s *Store) GetCourseSetByPublicID(ctx context.Context, publicID string) (*CourseSet, error) {
row := s.pool.QueryRow(ctx, `
SELECT id, course_set_public_id, place_id, map_asset_id, code, mode, name, description, status, current_variant_id, created_at, updated_at
FROM course_sets
WHERE course_set_public_id = $1
LIMIT 1
`, publicID)
return scanCourseSet(row)
}
func (s *Store) CreateCourseSet(ctx context.Context, tx Tx, params CreateCourseSetParams) (*CourseSet, error) {
row := tx.QueryRow(ctx, `
INSERT INTO course_sets (course_set_public_id, place_id, map_asset_id, code, mode, name, description, status)
VALUES ($1, $2, $3, $4, $5, $6, $7, $8)
RETURNING id, course_set_public_id, place_id, map_asset_id, code, mode, name, description, status, current_variant_id, created_at, updated_at
`, params.PublicID, params.PlaceID, params.MapAssetID, params.Code, params.Mode, params.Name, params.Description, params.Status)
return scanCourseSet(row)
}
func (s *Store) ListCourseVariantsByCourseSetID(ctx context.Context, courseSetID string) ([]CourseVariant, error) {
rows, err := s.pool.Query(ctx, `
SELECT cv.id, cv.course_variant_public_id, cv.course_set_id, cv.source_id, cs.course_source_public_id, cv.name, cv.route_code,
cv.mode, cv.control_count, cv.difficulty, cv.status, cv.is_default,
cv.config_patch_jsonb::text, cv.metadata_jsonb::text, cv.created_at, cv.updated_at
FROM course_variants cv
LEFT JOIN course_sources cs ON cs.id = cv.source_id
WHERE cv.course_set_id = $1
ORDER BY cv.created_at DESC
`, courseSetID)
if err != nil {
return nil, fmt.Errorf("list course variants: %w", err)
}
defer rows.Close()
items := []CourseVariant{}
for rows.Next() {
item, err := scanCourseVariantFromRows(rows)
if err != nil {
return nil, err
}
items = append(items, *item)
}
if err := rows.Err(); err != nil {
return nil, fmt.Errorf("iterate course variants: %w", err)
}
return items, nil
}
func (s *Store) GetCourseVariantByPublicID(ctx context.Context, publicID string) (*CourseVariant, error) {
row := s.pool.QueryRow(ctx, `
SELECT cv.id, cv.course_variant_public_id, cv.course_set_id, cv.source_id, cs.course_source_public_id, cv.name, cv.route_code,
cv.mode, cv.control_count, cv.difficulty, cv.status, cv.is_default,
cv.config_patch_jsonb::text, cv.metadata_jsonb::text, cv.created_at, cv.updated_at
FROM course_variants cv
LEFT JOIN course_sources cs ON cs.id = cv.source_id
WHERE cv.course_variant_public_id = $1
LIMIT 1
`, publicID)
return scanCourseVariant(row)
}
func (s *Store) CreateCourseVariant(ctx context.Context, tx Tx, params CreateCourseVariantParams) (*CourseVariant, error) {
configPatchJSON, err := marshalJSONMap(params.ConfigPatch)
if err != nil {
return nil, fmt.Errorf("marshal course variant config patch: %w", err)
}
metadataJSON, err := marshalJSONMap(params.MetadataJSON)
if err != nil {
return nil, fmt.Errorf("marshal course variant metadata: %w", err)
}
row := tx.QueryRow(ctx, `
INSERT INTO course_variants (
course_variant_public_id, course_set_id, source_id, name, route_code, mode, control_count,
difficulty, status, is_default, config_patch_jsonb, metadata_jsonb
)
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11::jsonb, $12::jsonb)
RETURNING id, course_variant_public_id, course_set_id, source_id, NULL::text, name, route_code, mode,
control_count, difficulty, status, is_default, config_patch_jsonb::text, metadata_jsonb::text, created_at, updated_at
`, params.PublicID, params.CourseSetID, params.SourceID, params.Name, params.RouteCode, params.Mode, params.ControlCount, params.Difficulty, params.Status, params.IsDefault, configPatchJSON, metadataJSON)
return scanCourseVariant(row)
}
func (s *Store) SetCourseSetCurrentVariant(ctx context.Context, tx Tx, courseSetID, variantID string) error {
_, err := tx.Exec(ctx, `UPDATE course_sets SET current_variant_id = $2 WHERE id = $1`, courseSetID, variantID)
if err != nil {
return fmt.Errorf("set course set current variant: %w", err)
}
return nil
}
func (s *Store) ListMapRuntimeBindings(ctx context.Context, limit int) ([]MapRuntimeBinding, error) {
if limit <= 0 || limit > 200 {
limit = 50
}
rows, err := s.pool.Query(ctx, `
SELECT mrb.id, mrb.runtime_binding_public_id, mrb.event_id, e.event_public_id, mrb.place_id, p.place_public_id,
mrb.map_asset_id, ma.map_asset_public_id, mrb.tile_release_id, tr.tile_release_public_id,
mrb.course_set_id, cset.course_set_public_id, mrb.course_variant_id, cv.course_variant_public_id,
mrb.status, mrb.notes, mrb.created_at, mrb.updated_at
FROM map_runtime_bindings mrb
JOIN events e ON e.id = mrb.event_id
JOIN places p ON p.id = mrb.place_id
JOIN map_assets ma ON ma.id = mrb.map_asset_id
JOIN tile_releases tr ON tr.id = mrb.tile_release_id
JOIN course_sets cset ON cset.id = mrb.course_set_id
JOIN course_variants cv ON cv.id = mrb.course_variant_id
ORDER BY mrb.created_at DESC
LIMIT $1
`, limit)
if err != nil {
return nil, fmt.Errorf("list runtime bindings: %w", err)
}
defer rows.Close()
items := []MapRuntimeBinding{}
for rows.Next() {
item, err := scanMapRuntimeBindingFromRows(rows)
if err != nil {
return nil, err
}
items = append(items, *item)
}
if err := rows.Err(); err != nil {
return nil, fmt.Errorf("iterate runtime bindings: %w", err)
}
return items, nil
}
func (s *Store) GetMapRuntimeBindingByPublicID(ctx context.Context, publicID string) (*MapRuntimeBinding, error) {
row := s.pool.QueryRow(ctx, `
SELECT mrb.id, mrb.runtime_binding_public_id, mrb.event_id, e.event_public_id, mrb.place_id, p.place_public_id,
mrb.map_asset_id, ma.map_asset_public_id, mrb.tile_release_id, tr.tile_release_public_id,
mrb.course_set_id, cset.course_set_public_id, mrb.course_variant_id, cv.course_variant_public_id,
mrb.status, mrb.notes, mrb.created_at, mrb.updated_at
FROM map_runtime_bindings mrb
JOIN events e ON e.id = mrb.event_id
JOIN places p ON p.id = mrb.place_id
JOIN map_assets ma ON ma.id = mrb.map_asset_id
JOIN tile_releases tr ON tr.id = mrb.tile_release_id
JOIN course_sets cset ON cset.id = mrb.course_set_id
JOIN course_variants cv ON cv.id = mrb.course_variant_id
WHERE mrb.runtime_binding_public_id = $1
LIMIT 1
`, publicID)
return scanMapRuntimeBinding(row)
}
func (s *Store) CreateMapRuntimeBinding(ctx context.Context, tx Tx, params CreateMapRuntimeBindingParams) (*MapRuntimeBinding, error) {
row := tx.QueryRow(ctx, `
INSERT INTO map_runtime_bindings (
runtime_binding_public_id, event_id, place_id, map_asset_id, tile_release_id, course_set_id, course_variant_id, status, notes
)
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9)
RETURNING id, runtime_binding_public_id, event_id, ''::text, place_id, ''::text, map_asset_id, ''::text,
tile_release_id, ''::text, course_set_id, ''::text, course_variant_id, ''::text,
status, notes, created_at, updated_at
`, params.PublicID, params.EventID, params.PlaceID, params.MapAssetID, params.TileReleaseID, params.CourseSetID, params.CourseVariantID, params.Status, params.Notes)
return scanMapRuntimeBinding(row)
}
func scanPlace(row pgx.Row) (*Place, error) {
var item Place
var centerPoint string
err := row.Scan(&item.ID, &item.PublicID, &item.Code, &item.Name, &item.Region, &item.CoverURL, &item.Description, &centerPoint, &item.Status, &item.CreatedAt, &item.UpdatedAt)
if errors.Is(err, pgx.ErrNoRows) {
return nil, nil
}
if err != nil {
return nil, fmt.Errorf("scan place: %w", err)
}
item.CenterPoint = json.RawMessage(centerPoint)
return &item, nil
}
func scanPlaceFromRows(rows pgx.Rows) (*Place, error) {
var item Place
var centerPoint string
err := rows.Scan(&item.ID, &item.PublicID, &item.Code, &item.Name, &item.Region, &item.CoverURL, &item.Description, &centerPoint, &item.Status, &item.CreatedAt, &item.UpdatedAt)
if err != nil {
return nil, fmt.Errorf("scan place row: %w", err)
}
item.CenterPoint = json.RawMessage(centerPoint)
return &item, nil
}
func scanMapAsset(row pgx.Row) (*MapAsset, error) {
var item MapAsset
err := row.Scan(&item.ID, &item.PublicID, &item.PlaceID, &item.LegacyMapID, &item.LegacyMapPublicID, &item.Code, &item.Name, &item.MapType, &item.CoverURL, &item.Description, &item.Status, &item.CurrentTileReleaseID, &item.CreatedAt, &item.UpdatedAt)
if errors.Is(err, pgx.ErrNoRows) {
return nil, nil
}
if err != nil {
return nil, fmt.Errorf("scan map asset: %w", err)
}
return &item, nil
}
func scanMapAssetFromRows(rows pgx.Rows) (*MapAsset, error) {
var item MapAsset
err := rows.Scan(&item.ID, &item.PublicID, &item.PlaceID, &item.LegacyMapID, &item.LegacyMapPublicID, &item.Code, &item.Name, &item.MapType, &item.CoverURL, &item.Description, &item.Status, &item.CurrentTileReleaseID, &item.CreatedAt, &item.UpdatedAt)
if err != nil {
return nil, fmt.Errorf("scan map asset row: %w", err)
}
return &item, nil
}
func scanTileRelease(row pgx.Row) (*TileRelease, error) {
var item TileRelease
var metadataJSON string
err := row.Scan(&item.ID, &item.PublicID, &item.MapAssetID, &item.LegacyMapVersionID, &item.LegacyMapVersionPub, &item.VersionCode, &item.Status, &item.TileBaseURL, &item.MetaURL, &item.PublishedAssetRoot, &metadataJSON, &item.PublishedAt, &item.CreatedAt, &item.UpdatedAt)
if errors.Is(err, pgx.ErrNoRows) {
return nil, nil
}
if err != nil {
return nil, fmt.Errorf("scan tile release: %w", err)
}
item.MetadataJSON = json.RawMessage(metadataJSON)
return &item, nil
}
func scanTileReleaseFromRows(rows pgx.Rows) (*TileRelease, error) {
var item TileRelease
var metadataJSON string
err := rows.Scan(&item.ID, &item.PublicID, &item.MapAssetID, &item.LegacyMapVersionID, &item.LegacyMapVersionPub, &item.VersionCode, &item.Status, &item.TileBaseURL, &item.MetaURL, &item.PublishedAssetRoot, &metadataJSON, &item.PublishedAt, &item.CreatedAt, &item.UpdatedAt)
if err != nil {
return nil, fmt.Errorf("scan tile release row: %w", err)
}
item.MetadataJSON = json.RawMessage(metadataJSON)
return &item, nil
}
func scanCourseSource(row pgx.Row) (*CourseSource, error) {
var item CourseSource
var metadataJSON string
err := row.Scan(&item.ID, &item.PublicID, &item.LegacyPlayfieldVersionID, &item.LegacyPlayfieldVersionPub, &item.SourceType, &item.FileURL, &item.Checksum, &item.ParserVersion, &item.ImportStatus, &metadataJSON, &item.ImportedAt, &item.CreatedAt, &item.UpdatedAt)
if errors.Is(err, pgx.ErrNoRows) {
return nil, nil
}
if err != nil {
return nil, fmt.Errorf("scan course source: %w", err)
}
item.MetadataJSON = json.RawMessage(metadataJSON)
return &item, nil
}
func scanCourseSourceFromRows(rows pgx.Rows) (*CourseSource, error) {
var item CourseSource
var metadataJSON string
err := rows.Scan(&item.ID, &item.PublicID, &item.LegacyPlayfieldVersionID, &item.LegacyPlayfieldVersionPub, &item.SourceType, &item.FileURL, &item.Checksum, &item.ParserVersion, &item.ImportStatus, &metadataJSON, &item.ImportedAt, &item.CreatedAt, &item.UpdatedAt)
if err != nil {
return nil, fmt.Errorf("scan course source row: %w", err)
}
item.MetadataJSON = json.RawMessage(metadataJSON)
return &item, nil
}
func scanCourseSet(row pgx.Row) (*CourseSet, error) {
var item CourseSet
err := row.Scan(&item.ID, &item.PublicID, &item.PlaceID, &item.MapAssetID, &item.Code, &item.Mode, &item.Name, &item.Description, &item.Status, &item.CurrentVariantID, &item.CreatedAt, &item.UpdatedAt)
if errors.Is(err, pgx.ErrNoRows) {
return nil, nil
}
if err != nil {
return nil, fmt.Errorf("scan course set: %w", err)
}
return &item, nil
}
func scanCourseSetFromRows(rows pgx.Rows) (*CourseSet, error) {
var item CourseSet
err := rows.Scan(&item.ID, &item.PublicID, &item.PlaceID, &item.MapAssetID, &item.Code, &item.Mode, &item.Name, &item.Description, &item.Status, &item.CurrentVariantID, &item.CreatedAt, &item.UpdatedAt)
if err != nil {
return nil, fmt.Errorf("scan course set row: %w", err)
}
return &item, nil
}
func scanCourseVariant(row pgx.Row) (*CourseVariant, error) {
var item CourseVariant
var configPatch string
var metadataJSON string
err := row.Scan(&item.ID, &item.PublicID, &item.CourseSetID, &item.SourceID, &item.SourcePublicID, &item.Name, &item.RouteCode, &item.Mode, &item.ControlCount, &item.Difficulty, &item.Status, &item.IsDefault, &configPatch, &metadataJSON, &item.CreatedAt, &item.UpdatedAt)
if errors.Is(err, pgx.ErrNoRows) {
return nil, nil
}
if err != nil {
return nil, fmt.Errorf("scan course variant: %w", err)
}
item.ConfigPatch = json.RawMessage(configPatch)
item.MetadataJSON = json.RawMessage(metadataJSON)
return &item, nil
}
func scanCourseVariantFromRows(rows pgx.Rows) (*CourseVariant, error) {
var item CourseVariant
var configPatch string
var metadataJSON string
err := rows.Scan(&item.ID, &item.PublicID, &item.CourseSetID, &item.SourceID, &item.SourcePublicID, &item.Name, &item.RouteCode, &item.Mode, &item.ControlCount, &item.Difficulty, &item.Status, &item.IsDefault, &configPatch, &metadataJSON, &item.CreatedAt, &item.UpdatedAt)
if err != nil {
return nil, fmt.Errorf("scan course variant row: %w", err)
}
item.ConfigPatch = json.RawMessage(configPatch)
item.MetadataJSON = json.RawMessage(metadataJSON)
return &item, nil
}
func scanMapRuntimeBinding(row pgx.Row) (*MapRuntimeBinding, error) {
var item MapRuntimeBinding
err := row.Scan(&item.ID, &item.PublicID, &item.EventID, &item.EventPublicID, &item.PlaceID, &item.PlacePublicID, &item.MapAssetID, &item.MapAssetPublicID, &item.TileReleaseID, &item.TileReleasePublicID, &item.CourseSetID, &item.CourseSetPublicID, &item.CourseVariantID, &item.CourseVariantPublicID, &item.Status, &item.Notes, &item.CreatedAt, &item.UpdatedAt)
if errors.Is(err, pgx.ErrNoRows) {
return nil, nil
}
if err != nil {
return nil, fmt.Errorf("scan runtime binding: %w", err)
}
return &item, nil
}
func scanMapRuntimeBindingFromRows(rows pgx.Rows) (*MapRuntimeBinding, error) {
var item MapRuntimeBinding
err := rows.Scan(&item.ID, &item.PublicID, &item.EventID, &item.EventPublicID, &item.PlaceID, &item.PlacePublicID, &item.MapAssetID, &item.MapAssetPublicID, &item.TileReleaseID, &item.TileReleasePublicID, &item.CourseSetID, &item.CourseSetPublicID, &item.CourseVariantID, &item.CourseVariantPublicID, &item.Status, &item.Notes, &item.CreatedAt, &item.UpdatedAt)
if err != nil {
return nil, fmt.Errorf("scan runtime binding row: %w", err)
}
return &item, nil
}

View File

@@ -0,0 +1,185 @@
BEGIN;
CREATE TABLE places (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
place_public_id TEXT NOT NULL UNIQUE,
code TEXT NOT NULL UNIQUE,
name TEXT NOT NULL,
region TEXT,
cover_url TEXT,
description TEXT,
center_point_jsonb JSONB NOT NULL DEFAULT '{}'::jsonb,
status TEXT NOT NULL DEFAULT 'draft' CHECK (status IN ('draft', 'active', 'disabled', 'archived')),
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);
CREATE INDEX places_status_idx ON places(status);
CREATE TRIGGER places_set_updated_at
BEFORE UPDATE ON places
FOR EACH ROW EXECUTE FUNCTION set_updated_at();
CREATE TABLE map_assets (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
map_asset_public_id TEXT NOT NULL UNIQUE,
place_id UUID NOT NULL REFERENCES places(id) ON DELETE CASCADE,
legacy_map_id UUID REFERENCES maps(id) ON DELETE SET NULL,
code TEXT NOT NULL UNIQUE,
name TEXT NOT NULL,
map_type TEXT NOT NULL DEFAULT 'standard',
cover_url TEXT,
description TEXT,
status TEXT NOT NULL DEFAULT 'draft' CHECK (status IN ('draft', 'active', 'disabled', 'archived')),
current_tile_release_id UUID,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);
CREATE INDEX map_assets_place_id_idx ON map_assets(place_id);
CREATE INDEX map_assets_legacy_map_id_idx ON map_assets(legacy_map_id);
CREATE INDEX map_assets_status_idx ON map_assets(status);
CREATE TRIGGER map_assets_set_updated_at
BEFORE UPDATE ON map_assets
FOR EACH ROW EXECUTE FUNCTION set_updated_at();
CREATE TABLE tile_releases (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
tile_release_public_id TEXT NOT NULL UNIQUE,
map_asset_id UUID NOT NULL REFERENCES map_assets(id) ON DELETE CASCADE,
legacy_map_version_id UUID REFERENCES map_versions(id) ON DELETE SET NULL,
version_code TEXT NOT NULL,
status TEXT NOT NULL DEFAULT 'draft' CHECK (status IN ('draft', 'active', 'published', 'retired', 'archived')),
tile_base_url TEXT NOT NULL,
meta_url TEXT NOT NULL,
published_asset_root TEXT,
metadata_jsonb JSONB NOT NULL DEFAULT '{}'::jsonb,
published_at TIMESTAMPTZ,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
UNIQUE (map_asset_id, version_code)
);
CREATE INDEX tile_releases_map_asset_id_idx ON tile_releases(map_asset_id);
CREATE INDEX tile_releases_legacy_map_version_id_idx ON tile_releases(legacy_map_version_id);
CREATE INDEX tile_releases_status_idx ON tile_releases(status);
CREATE TRIGGER tile_releases_set_updated_at
BEFORE UPDATE ON tile_releases
FOR EACH ROW EXECUTE FUNCTION set_updated_at();
ALTER TABLE map_assets
ADD CONSTRAINT map_assets_current_tile_release_fk
FOREIGN KEY (current_tile_release_id) REFERENCES tile_releases(id) ON DELETE SET NULL;
CREATE TABLE course_sources (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
course_source_public_id TEXT NOT NULL UNIQUE,
legacy_playfield_version_id UUID REFERENCES playfield_versions(id) ON DELETE SET NULL,
source_type TEXT NOT NULL CHECK (source_type IN ('kml', 'geojson', 'control_set', 'json')),
file_url TEXT NOT NULL,
checksum TEXT,
parser_version TEXT,
import_status TEXT NOT NULL DEFAULT 'imported' CHECK (import_status IN ('draft', 'imported', 'parsed', 'failed', 'archived')),
metadata_jsonb JSONB NOT NULL DEFAULT '{}'::jsonb,
imported_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);
CREATE INDEX course_sources_legacy_playfield_version_id_idx ON course_sources(legacy_playfield_version_id);
CREATE INDEX course_sources_source_type_idx ON course_sources(source_type);
CREATE INDEX course_sources_import_status_idx ON course_sources(import_status);
CREATE TRIGGER course_sources_set_updated_at
BEFORE UPDATE ON course_sources
FOR EACH ROW EXECUTE FUNCTION set_updated_at();
CREATE TABLE course_sets (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
course_set_public_id TEXT NOT NULL UNIQUE,
place_id UUID NOT NULL REFERENCES places(id) ON DELETE RESTRICT,
map_asset_id UUID NOT NULL REFERENCES map_assets(id) ON DELETE RESTRICT,
code TEXT NOT NULL UNIQUE,
mode TEXT NOT NULL,
name TEXT NOT NULL,
description TEXT,
status TEXT NOT NULL DEFAULT 'draft' CHECK (status IN ('draft', 'active', 'disabled', 'archived')),
current_variant_id UUID,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);
CREATE INDEX course_sets_place_id_idx ON course_sets(place_id);
CREATE INDEX course_sets_map_asset_id_idx ON course_sets(map_asset_id);
CREATE INDEX course_sets_status_idx ON course_sets(status);
CREATE TRIGGER course_sets_set_updated_at
BEFORE UPDATE ON course_sets
FOR EACH ROW EXECUTE FUNCTION set_updated_at();
CREATE TABLE course_variants (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
course_variant_public_id TEXT NOT NULL UNIQUE,
course_set_id UUID NOT NULL REFERENCES course_sets(id) ON DELETE CASCADE,
source_id UUID REFERENCES course_sources(id) ON DELETE SET NULL,
name TEXT NOT NULL,
route_code TEXT,
mode TEXT NOT NULL,
control_count INTEGER,
difficulty TEXT,
status TEXT NOT NULL DEFAULT 'draft' CHECK (status IN ('draft', 'active', 'disabled', 'archived')),
is_default BOOLEAN NOT NULL DEFAULT FALSE,
config_patch_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()
);
CREATE INDEX course_variants_course_set_id_idx ON course_variants(course_set_id);
CREATE INDEX course_variants_source_id_idx ON course_variants(source_id);
CREATE INDEX course_variants_status_idx ON course_variants(status);
CREATE INDEX course_variants_route_code_idx ON course_variants(route_code);
CREATE TRIGGER course_variants_set_updated_at
BEFORE UPDATE ON course_variants
FOR EACH ROW EXECUTE FUNCTION set_updated_at();
ALTER TABLE course_sets
ADD CONSTRAINT course_sets_current_variant_fk
FOREIGN KEY (current_variant_id) REFERENCES course_variants(id) ON DELETE SET NULL;
CREATE TABLE map_runtime_bindings (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
runtime_binding_public_id TEXT NOT NULL UNIQUE,
event_id UUID NOT NULL REFERENCES events(id) ON DELETE CASCADE,
place_id UUID NOT NULL REFERENCES places(id) ON DELETE RESTRICT,
map_asset_id UUID NOT NULL REFERENCES map_assets(id) ON DELETE RESTRICT,
tile_release_id UUID NOT NULL REFERENCES tile_releases(id) ON DELETE RESTRICT,
course_set_id UUID NOT NULL REFERENCES course_sets(id) ON DELETE RESTRICT,
course_variant_id UUID NOT NULL REFERENCES course_variants(id) ON DELETE RESTRICT,
status TEXT NOT NULL DEFAULT 'draft' CHECK (status IN ('draft', 'active', 'disabled', 'archived')),
notes TEXT,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);
CREATE INDEX map_runtime_bindings_event_id_idx ON map_runtime_bindings(event_id);
CREATE INDEX map_runtime_bindings_place_id_idx ON map_runtime_bindings(place_id);
CREATE INDEX map_runtime_bindings_map_asset_id_idx ON map_runtime_bindings(map_asset_id);
CREATE INDEX map_runtime_bindings_tile_release_id_idx ON map_runtime_bindings(tile_release_id);
CREATE INDEX map_runtime_bindings_course_set_id_idx ON map_runtime_bindings(course_set_id);
CREATE INDEX map_runtime_bindings_course_variant_id_idx ON map_runtime_bindings(course_variant_id);
CREATE INDEX map_runtime_bindings_status_idx ON map_runtime_bindings(status);
CREATE TRIGGER map_runtime_bindings_set_updated_at
BEFORE UPDATE ON map_runtime_bindings
FOR EACH ROW EXECUTE FUNCTION set_updated_at();
ALTER TABLE event_releases
ADD COLUMN runtime_binding_id UUID REFERENCES map_runtime_bindings(id) ON DELETE SET NULL;
CREATE INDEX event_releases_runtime_binding_id_idx ON event_releases(runtime_binding_id);
COMMIT;

View File

@@ -0,0 +1,55 @@
BEGIN;
CREATE TABLE event_presentations (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
presentation_public_id TEXT NOT NULL UNIQUE,
event_id UUID NOT NULL REFERENCES events(id) ON DELETE CASCADE,
code TEXT NOT NULL,
name TEXT NOT NULL,
presentation_type TEXT NOT NULL CHECK (presentation_type IN ('card', 'detail', 'h5', 'result', 'generic')),
status TEXT NOT NULL DEFAULT 'draft' CHECK (status IN ('draft', 'active', 'disabled', 'archived')),
is_default BOOLEAN NOT NULL DEFAULT FALSE,
schema_jsonb JSONB NOT NULL DEFAULT '{}'::jsonb,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
UNIQUE (event_id, code)
);
CREATE INDEX event_presentations_event_id_idx ON event_presentations(event_id);
CREATE INDEX event_presentations_status_idx ON event_presentations(status);
CREATE TRIGGER event_presentations_set_updated_at
BEFORE UPDATE ON event_presentations
FOR EACH ROW EXECUTE FUNCTION set_updated_at();
CREATE TABLE content_bundles (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
content_bundle_public_id TEXT NOT NULL UNIQUE,
event_id UUID NOT NULL REFERENCES events(id) ON DELETE CASCADE,
code TEXT NOT NULL,
name TEXT NOT NULL,
status TEXT NOT NULL DEFAULT 'draft' CHECK (status IN ('draft', 'active', 'disabled', 'archived')),
is_default BOOLEAN NOT NULL DEFAULT FALSE,
entry_url TEXT,
asset_root_url TEXT,
metadata_jsonb JSONB NOT NULL DEFAULT '{}'::jsonb,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
UNIQUE (event_id, code)
);
CREATE INDEX content_bundles_event_id_idx ON content_bundles(event_id);
CREATE INDEX content_bundles_status_idx ON content_bundles(status);
CREATE TRIGGER content_bundles_set_updated_at
BEFORE UPDATE ON content_bundles
FOR EACH ROW EXECUTE FUNCTION set_updated_at();
ALTER TABLE event_releases
ADD COLUMN presentation_id UUID REFERENCES event_presentations(id) ON DELETE SET NULL,
ADD COLUMN content_bundle_id UUID REFERENCES content_bundles(id) ON DELETE SET NULL;
CREATE INDEX event_releases_presentation_id_idx ON event_releases(presentation_id);
CREATE INDEX event_releases_content_bundle_id_idx ON event_releases(content_bundle_id);
COMMIT;

View File

@@ -0,0 +1,12 @@
BEGIN;
ALTER TABLE events
ADD COLUMN current_presentation_id UUID REFERENCES event_presentations(id) ON DELETE SET NULL,
ADD COLUMN current_content_bundle_id UUID REFERENCES content_bundles(id) ON DELETE SET NULL,
ADD COLUMN current_runtime_binding_id UUID REFERENCES map_runtime_bindings(id) ON DELETE SET NULL;
CREATE INDEX events_current_presentation_id_idx ON events(current_presentation_id);
CREATE INDEX events_current_content_bundle_id_idx ON events(current_content_bundle_id);
CREATE INDEX events_current_runtime_binding_id_idx ON events(current_runtime_binding_id);
COMMIT;

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,208 @@
# 生产发布与数据库上线方案
> 文档版本v1.0
> 最后更新2026-04-03 16:20:00
## 1. 目标
本文档用于约定:
- 开发测试数据库与正式生产数据库的关系
- 正式上线时数据库如何初始化与升级
- 后端服务如何与数据库发布配合
- 每次上线前后的最小检查项
当前原则只有一条:
- **测试库继续做测试,生产库独立建库,靠 migration 升级,不靠手工拷贝。**
---
## 2. 环境划分
建议至少分 2 套数据库环境:
- `dev`:开发 / 联调 / demo / workbench
- `prod`:正式生产
如果条件允许,推荐分 3 套:
- `dev`
- `staging`
- `prod`
其中:
- `dev` 用于日常开发和联调
- `staging` 用于上线前预演
- `prod` 只承载正式业务数据
---
## 3. 当前数据库结构来源
当前仓库已经采用 migration 驱动结构演进,目录位于:
- [D:\dev\cmr-mini\backend\migrations](D:/dev/cmr-mini/backend/migrations)
当前已存在的迁移文件包括:
- `0001_init.sql`
- `0002_launch.sql`
- `0003_home.sql`
- `0004_results.sql`
- `0005_config_pipeline.sql`
- `0006_resource_objects.sql`
- `0007_variant_minimal.sql`
- `0008_production_skeleton.sql`
- `0009_event_ops_phase2.sql`
因此正式上线时,数据库结构应以这些 migration 为准,而不是以当前测试库的现状为准。
---
## 4. 第一次生产上线
第一次正式上线建议按以下顺序执行:
1. 新建生产 PostgreSQL 数据库
2. 配置生产环境变量
3. 按顺序执行全部 migration
4. 导入必要的生产基础数据
5. 部署后端服务
6. 执行最小 smoke test
7. 再切前端 / 小程序到生产后端
### 4.1 生产环境变量
至少应与开发环境分离以下配置:
- `DATABASE_URL`
- JWT 相关密钥
- 微信小程序相关配置
- OSS / 资源发布相关配置
- 其它第三方服务配置
开发环境中的 `.env` 不能直接视为生产配置。
参考文件:
- [D:\dev\cmr-mini\backend\.env.example](D:/dev/cmr-mini/backend/.env.example)
### 4.2 生产初始数据
第一次上线时,建议只导入最小必要数据,例如:
- 租户
- 默认渠道
- 最小活动与地图运行数据
不建议把开发联调用的 demo 数据直接原样导入生产。
---
## 5. 后续版本上线
每次后续正式上线建议固定为以下顺序:
1. 备份生产数据库
2. 部署前确认本次新增 migration
3. 执行新增 migration
4. 发布后端服务
5. 验证关键接口
6. 再切客户端或前端流量
关键接口最少应验证:
- 登录
- 活动列表
- 活动详情
- `launch`
- `session start / finish`
- `result / history`
---
## 6. 上线原则
### 6.1 不直接复用测试库
禁止做法:
- 直接把开发测试数据库改名当生产库
- 通过手工拷表来“上线”
- 在生产库中临时手改结构但不补 migration
### 6.2 结构变更只走 migration
所有数据库结构变化都应:
- 新增 migration 文件
- 进入版本管理
- 随代码一起发布
不应依赖口头约定或临时 SQL。
### 6.3 生产数据与测试数据分离
开发 demo 数据、联调活动、手工实验数据,应停留在:
- `dev`
-`staging`
不要直接进入 `prod`
### 6.4 代码版本与数据库版本对应
每次上线都应能回答两件事:
- 当前代码对应到哪一个 migration 版本
- 当前生产库已经执行到哪一个 migration 版本
---
## 7. 推荐的发布节奏
建议发布节奏为:
### 阶段 1数据库先行
- 新 migration 先在 `dev` 验证
- 再在 `staging` 演练
- 最后进入 `prod`
### 阶段 2服务发布
- 后端服务发布到对应环境
- 验证启动、连接数据库、关键接口可用
### 阶段 3客户端切换
- 小程序 / App / H5 再切到新的后端能力
- 避免客户端先切到一个数据库未升级的后端版本
---
## 8. 当前项目建议的最小上线清单
对当前仓库,正式上线前建议至少确认:
- [D:\dev\cmr-mini\backend\migrations](D:/dev/cmr-mini/backend/migrations) 已完整纳入发布
- [D:\dev\cmr-mini\doc\backend\后台生产闭环架构草案.md](D:/dev/cmr-mini/doc/backend/后台生产闭环架构草案.md) 中当前主线对象已落库
- `DATABASE_URL` 已切到生产库
- JWT / 微信 / OSS 配置已替换为生产值
- workbench / 管理接口在生产环境默认不对外暴露或受限
- demo 事件、demo variant 不进入生产正式运营入口
---
## 9. 一句话结论
当前项目的正式上线方式应固定为:
- **新建独立生产库**
- **通过 migration 初始化和升级**
- **通过生产环境变量部署后端**
- **通过最小 smoke test 验证**
- **最后再切客户端入口**
不要把开发测试数据库直接视为生产数据库。

View File

@@ -1,6 +1,6 @@
# 多线程联调协作方式 # 多线程联调协作方式
> 文档版本v1.0 > 文档版本v1.1
> 最后更新2026-04-02 08:28:05 > 最后更新2026-04-03 11:15:00
## 目标 ## 目标
@@ -21,7 +21,7 @@
- 一个代码仓库 - 一个代码仓库
- 多条并行线程 - 多条并行线程
- 份根目录协作文档 - 份根目录协作文档
- 一名全局维护者负责总览和收口 - 一名全局维护者负责总览和收口
对应关系: 对应关系:
@@ -32,38 +32,57 @@
--- ---
## 2. 两份协作文档的职责 ## 2. 当前协作文档的职责
当前跨线程沟通只走两份文件: 当前跨线程沟通主线改为 4 份文件:
- [t2b.md](D:/dev/cmr-mini/t2b.md)
- [b2t.md](D:/dev/cmr-mini/b2t.md)
- [t2f.md](D:/dev/cmr-mini/t2f.md)
- [f2t.md](D:/dev/cmr-mini/f2t.md)
旧的:
- [f2b.md](D:/dev/cmr-mini/f2b.md) - [f2b.md](D:/dev/cmr-mini/f2b.md)
- [b2f.md](D:/dev/cmr-mini/b2f.md) - [b2f.md](D:/dev/cmr-mini/b2f.md)
### 2.1 `f2b.md` 默认不再作为主线协作文档继续扩写,只保留历史参考价值。
由前端线程维护,用于记录: ### 2.1 `t2b.md`
- 前端当前联调状态 由总控线程维护,写给后端线程,用于记录:
- 前端已经按什么契约实现
- 需要后端确认什么
- 当前前端阻塞点是什么
它不是后端说明文档,也不是讨论区。 - 当前阶段后端应推进什么
- 本刀范围是什么
- 哪些对象和接口先做
### 2.2 `b2f.md` ### 2.2 `b2t.md`
由后端线程维护,用于记录: 由后端线程维护,写给总控线程,用于记录:
- 后端已经具备什么能力 - 后端当前已完成什么
- 前端应如何接入 - 后端希望确认什么
- 哪些地方不允许前端自行假设 - 下一步建议是什么
- 哪些接口和字段已经定版
它不是前端反馈文档,也不是需求池。 ### 2.3 `t2f.md`
由总控线程维护,写给前端线程,用于记录:
- 当前阶段前端应推进什么
- 当前推荐接线顺序是什么
- 哪些字段和页面优先接入
### 2.4 `f2t.md`
由前端线程维护,写给总控线程,用于记录:
- 前端当前已完成什么
- 前端在哪些地方受阻
- 需要总控或后端确认什么
--- ---
## 2.3 当前固定模板 ## 2.5 当前固定模板
为了避免两份协作文档再次变成长讨论稿,当前约定两边都采用统一结构: 为了避免两份协作文档再次变成长讨论稿,当前约定两边都采用统一结构:
@@ -105,8 +124,10 @@
- [readme-develop.md](D:/dev/cmr-mini/readme-develop.md) - [readme-develop.md](D:/dev/cmr-mini/readme-develop.md)
- [文档索引.md](D:/dev/cmr-mini/doc/文档索引.md) - [文档索引.md](D:/dev/cmr-mini/doc/文档索引.md)
- [f2b.md](D:/dev/cmr-mini/f2b.md) - [t2b.md](D:/dev/cmr-mini/t2b.md)
- [b2f.md](D:/dev/cmr-mini/b2f.md) - [b2t.md](D:/dev/cmr-mini/b2t.md)
- [t2f.md](D:/dev/cmr-mini/t2f.md)
- [f2t.md](D:/dev/cmr-mini/f2t.md)
以及当前代码事实: 以及当前代码事实:
@@ -118,8 +139,8 @@
总控线程不应该: 总控线程不应该:
- 抢写前端线程的 `f2b.md` - 抢写前端线程的 [f2t.md](D:/dev/cmr-mini/f2t.md)
- 抢写后端线程的 `b2f.md` - 抢写后端线程的 [b2t.md](D:/dev/cmr-mini/b2t.md)
- 把临时讨论直接当作正式契约 - 把临时讨论直接当作正式契约
- 在两边尚未确认时擅自“替双方拍板” - 在两边尚未确认时擅自“替双方拍板”
@@ -133,7 +154,7 @@
总控线程需要持续做两件事: 总控线程需要持续做两件事:
- 读取并理解 [f2b.md](D:/dev/cmr-mini/f2b.md) 和 [b2f.md](D:/dev/cmr-mini/b2f.md) 的最新事实 - 读取并理解 [b2t.md](D:/dev/cmr-mini/b2t.md) 和 [f2t.md](D:/dev/cmr-mini/f2t.md) 的最新事实
- 把已经收敛的跨线程结论回写到 `doc/` 正式文档 - 把已经收敛的跨线程结论回写到 `doc/` 正式文档
也就是说,总控线程不是“第三份协作文档”,而是: 也就是说,总控线程不是“第三份协作文档”,而是:
@@ -152,8 +173,9 @@
```text ```text
前端/后端各自推进 前端/后端各自推进
-> 遇到跨边界事项时写入 f2b / b2f -> 总控通过 t2b / t2f 下发阶段性要求
-> 总控线程读取两份协作文档 -> 前后端通过 b2t / f2t 回写事实与阻塞
-> 总控线程读取四份协作文档
-> 判断是否需要: -> 判断是否需要:
- 调整主线优先级 - 调整主线优先级
- 更新正式方案文档 - 更新正式方案文档
@@ -163,7 +185,7 @@
也就是说: 也就是说:
- `f2b / b2f` 是协作事实层 - `t2b / b2t / t2f / f2t` 是协作事实层
- `doc/` 是正式知识层 - `doc/` 是正式知识层
- 代码是最终实现层 - 代码是最终实现层
@@ -177,8 +199,10 @@
位于仓库根目录: 位于仓库根目录:
- [f2b.md](D:/dev/cmr-mini/f2b.md) - [t2b.md](D:/dev/cmr-mini/t2b.md)
- [b2f.md](D:/dev/cmr-mini/b2f.md) - [b2t.md](D:/dev/cmr-mini/b2t.md)
- [t2f.md](D:/dev/cmr-mini/t2f.md)
- [f2t.md](D:/dev/cmr-mini/f2t.md)
特点: 特点:
@@ -265,7 +289,7 @@
- 模拟器接入 - 模拟器接入
- 交互和体验 - 交互和体验
并把需要后端确认事项写入 [f2b.md](D:/dev/cmr-mini/f2b.md)。 并把当前事实、阻塞和待确认事项回写到 [f2t.md](D:/dev/cmr-mini/f2t.md)。
### 7.2 后端线程 ### 7.2 后端线程
@@ -276,7 +300,7 @@
- release / manifest / config 发布链 - release / manifest / config 发布链
- workbench / dev tools - workbench / dev tools
并把前端需要知道的契约写入 [b2f.md](D:/dev/cmr-mini/b2f.md)。 并把当前事实、完成项和待确认事项回写到 [b2t.md](D:/dev/cmr-mini/b2t.md)。
### 7.3 总控线程 ### 7.3 总控线程
@@ -294,7 +318,7 @@
当前项目的协作方式正式定为: 当前项目的协作方式正式定为:
> 前后端线程分别维护自己的协作文档, 总控线程负责读取两份协作文档并维护全局主线、正式文档和阶段结论。 > 总控线程通过 [t2b.md](D:/dev/cmr-mini/t2b.md) / [t2f.md](D:/dev/cmr-mini/t2f.md) 下发阶段要求,前后端线程通过 [b2t.md](D:/dev/cmr-mini/b2t.md) / [f2t.md](D:/dev/cmr-mini/f2t.md) 回写事实,总控线程再维护全局主线、正式文档和阶段结论。
这样做的目标不是增加文书工作,而是: 这样做的目标不是增加文书工作,而是:
@@ -308,9 +332,13 @@
截至当前阶段,这套方式已经进入实际执行状态: 截至当前阶段,这套方式已经进入实际执行状态:
- 前端线程维护 [f2b.md](D:/dev/cmr-mini/f2b.md) - 总控线程维护
- 后端线程维护 [b2f.md](D:/dev/cmr-mini/b2f.md) - [t2b.md](D:/dev/cmr-mini/t2b.md)
- 两份文档都已经按统一结构整理 - [t2f.md](D:/dev/cmr-mini/t2f.md)
- 执行线程回写:
- [b2t.md](D:/dev/cmr-mini/b2t.md)
- [f2t.md](D:/dev/cmr-mini/f2t.md)
- 旧的 [f2b.md](D:/dev/cmr-mini/f2b.md) / [b2f.md](D:/dev/cmr-mini/b2f.md) 仅保留历史参考
- 总控线程负责维护 [文档索引.md](D:/dev/cmr-mini/doc/文档索引.md) 和 `doc/` 下的正式文档 - 总控线程负责维护 [文档索引.md](D:/dev/cmr-mini/doc/文档索引.md) 和 `doc/` 下的正式文档
后续如果线程数量增加,或者联调链变复杂,优先仍然是: 后续如果线程数量增加,或者联调链变复杂,优先仍然是:

View File

@@ -0,0 +1,98 @@
# 活动运营域摘要第一刀联调回归清单
> 文档版本v1.0
> 最后更新2026-04-03 19:38:00
## 目标
本清单只用于回归“活动运营域摘要第一刀”是否稳定,不扩新页面链,不验证复杂运营样式。
当前回归范围:
- 活动详情页
- 活动准备页
- `launch.presentation / launch.contentBundle` 会话快照
- 与 runtime 主链的相互不干扰
## 回归项
### 1. 活动详情页摘要
- 页面:`/pages/event/event`
- 检查项:
- 是否展示 `展示版本`
- 是否展示 `内容包版本`
- 当字段缺失时是否回退为 `当前未声明...`
- 通过标准:
- 页面可正常加载
- 摘要存在
- 不影响原有 `release / 主动作 / 赛道模式`
### 2. 准备页摘要
- 页面:`/pages/event-prepare/event-prepare`
- 检查项:
- 是否展示 `活动运营摘要`
- 是否仍保留:
- 多赛道选择
- runtime 预览摘要
- 设备准备区
- 通过标准:
- 新摘要可见
- 页面结构未被打乱
- 进入地图主链不受影响
### 3. launch 快照接线
- 页面链:`event -> event-prepare -> map`
- 检查项:
- `launch.presentation`
- `launch.contentBundle`
- 是否已进入 `GameLaunchEnvelope`
- 通过标准:
- launch 后不报错
- 不影响地图加载
- 会话快照链不断
### 4. 与 runtime 主链隔离
- 页面链:`event-prepare -> map -> result`
- 检查项:
- 新增活动运营摘要后,以下能力是否仍正常:
- `launch.runtime`
- 赛道摘要
- 恢复链
- 结果页跳转
- 通过标准:
- 没有因活动运营摘要接线导致 runtime 摘要缺失或错位
### 5. 缺字段降级
- 检查项:
- 若 backend 某活动未返回:
- `currentPresentation`
- `currentContentBundle`
- `launch.presentation`
- `launch.contentBundle`
- 前端是否仍能正常展示和进入地图
- 通过标准:
- 页面不崩
- 只显示 `当前未声明...`
- 主链继续可用
## 建议记录字段
如联调发现问题,建议一次性记录:
- `eventPublicID`
- 活动页展示的 `展示版本 / 内容包版本`
- 准备页展示的 `展示版本 / 内容包版本`
- `launch` 是否成功
- 地图是否正常进入
- 是否影响 `runtime` 摘要或多赛道链
- 实际报错文案 / 控制台错误
## 一句话结论
本轮只验证一句话:
**活动运营域摘要第一刀是否已经接稳,且没有影响 runtime 稳定主链。**

View File

@@ -0,0 +1,204 @@
# 第五刀联调回归清单
> 文档版本v1.0
> 最后更新2026-04-03 14:50:00
## 目标
本清单用于回归验证第五刀“前端接线阶段”的实际效果,重点确认:
- `launch.runtime` 是否已稳定进入前端运行链
- 多赛道与 runtime 摘要是否能同时正确回流
- 恢复链、结果链、首页摘要链是否保持一致
本清单优先验证“可见”和“一致”,不要求当前阶段完成复杂运营样式。
## 当前范围
本轮重点回归以下页面:
- 活动页
- 准备页
- 地图页
- 单局结果页
- 历史结果列表页
- 首页 `ongoing / recent`
## 建议测试数据
优先使用后端当前已提供的多赛道手动 demo
- `eventPublicID = evt_demo_variant_manual_001`
- `variant_a`
- `name = A 线`
- `routeCode = route-variant-a`
- `variant_b`
- `name = B 线`
- `routeCode = route-variant-b`
普通单赛道活动可继续使用:
- `eventPublicID = evt_demo_001`
## 回归项
### 1. 准备页预览态摘要
验证目标:
- 准备页能显示“运行对象摘要”
- 当前阶段允许是预览态,不要求已经拿到完整 `launch.runtime`
检查点:
- `地点` 当前允许显示 `待 launch.runtime 确认`
- `地图` 当前允许显示 `待 launch.runtime 确认`
- `赛道`
- `manual` 模式下,应跟随当前选择变化
- `RouteCode`
- `manual` 模式下,应跟随当前选择变化
### 2. Launch Runtime 映射
验证目标:
- 进入地图后,前端已正式消费后端 `launch.runtime`
检查点:
- 地图页“当前游戏”摘要中可看到:
- `运行绑定`
- `地点`
- `地图`
- `赛道集`
- `赛道版本`
- `RouteCode`
- `瓦片版本`
### 3. 多赛道手动选择
验证目标:
- `manual` 模式下,准备页选择的赛道和最终 `launch.variant / launch.runtime` 一致
建议步骤:
1. 打开 `evt_demo_variant_manual_001`
2. 在准备页选择 `A 线`
3. 进入地图,记录地图页 runtime 摘要
4. 结束一局,记录结果页摘要
5. 再重复一次,切换到 `B 线`
检查点:
- 地图页 `赛道版本`
- 单局结果页 `赛道版本`
- 历史结果列表页该条记录的 `赛道`
- 首页 `recent`
都应能区分 `A 线 / B 线`
### 4. 单局结果页 Runtime
验证目标:
- 结果页优先消费 `result.session.runtime`
- 如果后端某次未带该字段,前端能回退到 launch 快照,不出现空白
检查点:
- 结果页中可见:
- `运行绑定`
- `地点`
- `地图`
- `赛道集`
- `赛道版本`
- `RouteCode`
- `瓦片版本`
### 5. 历史结果列表页 Runtime
验证目标:
- 历史结果列表页保持摘要态,不改主结构,但能看到 runtime 对象
检查点:
- 每条结果卡片可显示:
- `地点`
- `地图`
- `赛道`
### 6. 首页 Ongoing / Recent Runtime
验证目标:
- 首页 `ongoing / recent` 已开始展示 runtime 摘要
检查点:
- `进行中运行对象`
- `最近一局运行对象`
内容至少包含:
- `地点`
- `地图`
- `赛道`
### 7. 恢复链 Runtime 一致性
验证目标:
- 非正常退出后恢复,赛道和 runtime 不发生漂移
建议步骤:
1. 使用多赛道活动选择 `B 线`
2. 进入地图并开始一局
3. 非正常退出
4. 重新进入程序并选择“继续恢复”
检查点:
- 恢复后的地图页 runtime 摘要仍然是原来的 `place / map / variant`
- 赛道版本不变
### 8. 放弃恢复语义
验证目标:
- 放弃恢复不会上错局
- 放弃后不会残留旧 runtime
建议步骤:
1. 打开一局并异常退出
2. 再进程序,选择“放弃”
3. 回首页
检查点:
- 不再提示旧局恢复
- 首页 `ongoing` 应消失
- 再开新局时 runtime 摘要以新局为准
## 问题记录建议
如果发现问题,尽量一次性记录:
- `eventPublicID`
- 选择的 `variantId / routeCode`
- `launch.variant`
- `launch.runtime`
- 地图页 runtime 摘要
- 结果页 runtime 摘要
- 首页 / 历史页摘要
- 是否属于恢复场景
## 当前阶段结论标准
本轮完成标准不是“页面全部重做”,而是:
- `launch.runtime` 已进入用户侧主页面链
- 多赛道与 runtime 摘要可同时回流
- 恢复链、结果链、首页摘要链不互相打架

View File

@@ -1,6 +1,6 @@
# 文档索引 # 文档索引
> 文档版本v1.0 > 文档版本v1.3
> 最后更新2026-04-02 18:10:04 > 最后更新2026-04-03 19:38:00
维护约定: 维护约定:
@@ -46,6 +46,8 @@
- [多线程联调协作方式](/D:/dev/cmr-mini/doc/gameplay/多线程联调协作方式.md) - [多线程联调协作方式](/D:/dev/cmr-mini/doc/gameplay/多线程联调协作方式.md)
- [APP全局产品架构草案](/D:/dev/cmr-mini/doc/gameplay/APP全局产品架构草案.md) - [APP全局产品架构草案](/D:/dev/cmr-mini/doc/gameplay/APP全局产品架构草案.md)
- [故障恢复机制](/D:/dev/cmr-mini/doc/gameplay/故障恢复机制.md) - [故障恢复机制](/D:/dev/cmr-mini/doc/gameplay/故障恢复机制.md)
- [活动运营域摘要第一刀联调回归清单](/D:/dev/cmr-mini/doc/gameplay/活动运营域摘要第一刀联调回归清单.md)
- [第五刀联调回归清单](/D:/dev/cmr-mini/doc/gameplay/第五刀联调回归清单.md)
- [运行时编译层总表](/D:/dev/cmr-mini/doc/gameplay/运行时编译层总表.md) - [运行时编译层总表](/D:/dev/cmr-mini/doc/gameplay/运行时编译层总表.md)
- [玩法设计文档模板](/D:/dev/cmr-mini/doc/gameplay/玩法设计文档模板.md) - [玩法设计文档模板](/D:/dev/cmr-mini/doc/gameplay/玩法设计文档模板.md)
@@ -75,6 +77,12 @@
- [网关文档索引](/D:/dev/cmr-mini/doc/gateway/网关文档索引.md) - [网关文档索引](/D:/dev/cmr-mini/doc/gateway/网关文档索引.md)
## 后端
- [业务后端数据库初版方案](/D:/dev/cmr-mini/doc/backend/业务后端数据库初版方案.md)
- [后台生产闭环架构草案](/D:/dev/cmr-mini/doc/backend/后台生产闭环架构草案.md)
- [生产发布与数据库上线方案](/D:/dev/cmr-mini/doc/backend/生产发布与数据库上线方案.md)
## 备注与归档 ## 备注与归档
- 长期保留的少量工作便签见 [notes](/D:/dev/cmr-mini/doc/notes)。 - 长期保留的少量工作便签见 [notes](/D:/dev/cmr-mini/doc/notes)。

87
f2b.md
View File

@@ -1,6 +1,6 @@
# F2B 协作清单 # F2B 协作清单
> 文档版本v1.3 > 文档版本v1.4
> 最后更新2026-04-02 15:19:37 > 最后更新2026-04-03 19:20:00
说明: 说明:
@@ -14,34 +14,7 @@
## 待确认 ## 待确认
### F2B-007 - 当前无
- 时间2026-04-02
- 提出方:前端
- 当前事实:
- 前端已完成多赛道第一阶段接入:
- 活动页、准备页可展示 `assignmentMode / courseVariants`
-`assignmentMode=manual` 时,准备页会让用户选择赛道
- 前端会把选中的 `variantId` 带入 `POST /events/{eventPublicID}/launch`
- 需要对方确认什么:
- 请 backend 提供一个可联调的 `manual` 多赛道活动或 demo 数据
- 该活动需确保 `play.courseVariants[]``launch.variant.*` 可稳定返回
- 状态:待确认
### F2B-008
- 时间2026-04-02
- 提出方:前端
- 当前事实:
- 前端已开始在首页 ongoing/recent、单局结果页、历史结果页展示 `variantName / routeCode`
- 当前需要确认从 `launch` 选定的 `variantId` 是否会稳定回流到:
- `/me/entry-home`
- `/sessions/{sessionPublicID}/result`
- `/me/results`
- 需要对方确认什么:
- 请 backend 确认以上摘要链是否已完成 variant 回写
- 如还未全部完成,请给出可联调时间点或先可用的接口范围
- 状态:待确认
--- ---
@@ -159,6 +132,36 @@
- -
- 状态:已确认 - 状态:已确认
### F2B-C009
- 时间2026-04-03
- 提出方:前端
- 当前事实:
- backend 已提供可联调的 `manual` 多赛道 demo 活动:
- `evt_demo_variant_manual_001`
- backend 已确认 `launch` 选定的 `variantId` 会稳定回流到:
- `/me/entry-home`
- `/sessions/{sessionPublicID}/result`
- `/me/results`
- 需要对方确认什么:
-
- 状态:已确认
### F2B-C010
- 时间2026-04-03
- 提出方:前端
- 当前事实:
- backend 已透出活动运营域第二阶段摘要字段:
- `currentPresentation`
- `currentContentBundle`
- `launch.presentation`
- `launch.contentBundle`
- 前端当前按总控口径,仅做类型 / adapter / 活动页与准备页轻摘要接线,不扩新页面链
- 需要对方确认什么:
-
- 状态:已确认
--- ---
## 阻塞 ## 阻塞
@@ -234,6 +237,20 @@
- -
- 状态:已完成 - 状态:已完成
### F2B-D005
- 时间2026-04-03
- 提出方:前端
- 当前事实:
- 前端已完成活动运营域摘要第一刀的轻接线:
- 活动页开始展示 `currentPresentation / currentContentBundle`
- 准备页开始展示活动运营摘要
- `launch.presentation / launch.contentBundle` 已进入 `GameLaunchEnvelope`
- 会话快照会随 `launchEnvelope` 一起保留这批摘要
- 需要对方确认什么:
-
- 状态:已完成
--- ---
## 下一步 ## 下一步
@@ -272,15 +289,13 @@
### F2B-N004 ### F2B-N004
- 时间2026-04-02 - 时间2026-04-03
- 提出方:前端 - 提出方:前端
- 当前事实: - 当前事实:
- 多赛道下一步最值钱的是专项联调,而不是继续扩页面 - 当前主链已进入“稳住 + 联调修复”阶段
- 当前优先链路为: - 活动运营域摘要第一刀已接通,但前端不会主动扩复杂运营样式
- `manual` 赛道选择 -> `launch.variant`
- `launch.variant` -> `ongoing / result / results`
- 需要对方确认什么: - 需要对方确认什么:
- -
- 状态:等待 backend 提供联调数据 - 状态:前端执行中

165
f2t.md Normal file
View File

@@ -0,0 +1,165 @@
# F2T 协作清单
> 文档版本v1.6
> 最后更新2026-04-03 19:38:00
说明:
- 本文件由前端线程维护,写给总控线程
- 只写事实和请求
- 不写长讨论稿
- 每条尽量包含:时间、谁提的、当前事实、需要确认什么、是否已解决
---
## 待确认
- 当前无
---
## 已确认
### F2T-001
- 时间2026-04-03 14:28:00
- 谁提的frontend
- 当前事实:
- 总控已确认:准备页当前阶段允许按“预览态运行对象摘要”展示
- 后端后续如补 `pre-launch runtime preview` 能力,再升级为正式预览态
- 需要确认什么:
-
- 是否已解决:是
### F2T-002
- 时间2026-04-03 14:28:00
- 谁提的frontend
- 当前事实:
- 地图页和单局结果页已开始消费 `launch.runtime`
- 当前做法为:
- 地图页:在“当前游戏”摘要里追加 runtime 对象行
- 单局结果页:优先读 `result.session.runtime`,没有时回退到 launch 快照
- 需要确认什么:
-
- 是否已解决:是
### F2T-003
- 时间2026-04-03 14:42:00
- 谁提的frontend
- 当前事实:
- 历史结果列表页已开始展示 runtime 摘要
- 当前展示内容:
- `place`
- `map`
- `course variant`
- 当前仍保持摘要态展示,不改列表主结构
- 需要确认什么:
-
- 是否已解决:是
### F2T-004
- 时间2026-04-03 14:42:00
- 谁提的frontend
- 当前事实:
- 首页 `ongoing / recent` 已开始展示 runtime 摘要
- 当前展示内容:
- `place`
- `map`
- `course variant`
- 当前仍保持摘要态展示,不改首页卡片结构
- 需要确认什么:
-
- 是否已解决:是
### F2T-005
- 时间2026-04-03 18:10:00
- 谁提的frontend
- 当前事实:
- 总控已确认前端当前阶段切换为“活动运营域摘要第一刀”
- 当前只允许:
- 活动详情页轻摘要
- 准备页轻摘要
- 会话快照接线
- 当前不做复杂运营样式,也不重构 runtime 主链
- 需要确认什么:
-
- 是否已解决:是
---
## 阻塞
- 当前无
---
## 已完成
### F2T-D001
- 时间2026-04-03 14:50:00
- 谁提的frontend
- 当前事实:
- 已完成 `launch.runtime -> GameLaunchEnvelope.runtime` 适配
- 已完成赛后跳结果页时的 runtime 快照兜底透传
- 已完成准备页、地图页、单局结果页、历史结果列表页、首页摘要第一阶段可视化接入
- 需要确认什么:
-
- 是否已解决:是
### F2T-D002
- 时间2026-04-03 14:50:00
- 谁提的frontend
- 当前事实:
- 已新增 [第五刀联调回归清单](D:/dev/cmr-mini/doc/gameplay/第五刀联调回归清单.md)
- 当前回归口径已固定覆盖:
- 准备页
- 地图页
- 单局结果页
- 历史结果列表页
- 首页 `ongoing / recent`
- 恢复链
- 需要确认什么:
-
- 是否已解决:是
### F2T-D003
- 时间2026-04-03 19:20:00
- 谁提的frontend
- 当前事实:
- 已完成活动运营域摘要第一刀轻接线:
- 活动详情页开始展示 `currentPresentation / currentContentBundle`
- 准备页开始展示活动运营摘要
- `launch.presentation / launch.contentBundle` 已适配进 `GameLaunchEnvelope`
- 会话快照会随 `launchEnvelope` 一起保留活动运营摘要
- 当前仍保持“摘要接线”边界,没有扩新页面主链
- 需要确认什么:
-
- 是否已解决:是
### F2T-D004
- 时间2026-04-03 19:38:00
- 谁提的frontend
- 当前事实:
- 已新增 [活动运营域摘要第一刀联调回归清单](D:/dev/cmr-mini/doc/gameplay/活动运营域摘要第一刀联调回归清单.md)
- 当前回归口径已固定覆盖:
- 活动详情页摘要
- 准备页摘要
- `launch.presentation / launch.contentBundle` 会话快照
- 与 runtime 主链隔离
- 缺字段降级
- 需要确认什么:
-
- 是否已解决:是
---
## 下一步
- 当前进入活动运营域摘要第一刀的联调回归与小范围修复阶段

View File

@@ -7,6 +7,7 @@ App<IAppOption>({
backendBaseUrl: null, backendBaseUrl: null,
backendAuthTokens: null, backendAuthTokens: null,
pendingResultSnapshot: null, pendingResultSnapshot: null,
pendingResultLaunchEnvelope: null,
pendingHeartRateAutoConnect: null, pendingHeartRateAutoConnect: null,
}, },
onLaunch() { onLaunch() {

View File

@@ -19,6 +19,12 @@ type EventPreparePageData = {
assignmentMode: string assignmentMode: string
variantModeText: string variantModeText: string
variantSummaryText: string variantSummaryText: string
presentationText: string
contentBundleText: string
runtimePlaceText: string
runtimeMapText: string
runtimeVariantText: string
runtimeRouteCodeText: string
selectedVariantId: string selectedVariantId: string
selectedVariantText: string selectedVariantText: string
selectableVariants: Array<{ selectableVariants: Array<{
@@ -85,6 +91,24 @@ function formatVariantSummary(result: BackendEventPlayResult): string {
return preview return preview
} }
function formatPresentationSummary(result: BackendEventPlayResult): string {
const currentPresentation = result.currentPresentation
if (!currentPresentation) {
return '当前未声明展示版本'
}
return `${currentPresentation.presentationId || '--'} / ${currentPresentation.templateKey || '--'} / ${currentPresentation.version || '--'}`
}
function formatContentBundleSummary(result: BackendEventPlayResult): string {
const currentContentBundle = result.currentContentBundle
if (!currentContentBundle) {
return '当前未声明内容包版本'
}
return `${currentContentBundle.bundleId || '--'} / ${currentContentBundle.bundleType || '--'} / ${currentContentBundle.version || '--'}`
}
function resolveSelectedVariantId( function resolveSelectedVariantId(
currentVariantId: string, currentVariantId: string,
assignmentMode?: string | null, assignmentMode?: string | null,
@@ -184,6 +208,12 @@ Page({
assignmentMode: '', assignmentMode: '',
variantModeText: '--', variantModeText: '--',
variantSummaryText: '--', variantSummaryText: '--',
presentationText: '--',
contentBundleText: '--',
runtimePlaceText: '待 launch 确认',
runtimeMapText: '待 launch 确认',
runtimeVariantText: '待 launch 确认',
runtimeRouteCodeText: '待 launch 确认',
selectedVariantId: '', selectedVariantId: '',
selectedVariantText: '当前无需手动指定赛道', selectedVariantText: '当前无需手动指定赛道',
selectableVariants: [], selectableVariants: [],
@@ -277,6 +307,20 @@ Page({
assignmentMode: result.play.assignmentMode || '', assignmentMode: result.play.assignmentMode || '',
variantModeText: formatAssignmentMode(result.play.assignmentMode), variantModeText: formatAssignmentMode(result.play.assignmentMode),
variantSummaryText: formatVariantSummary(result), variantSummaryText: formatVariantSummary(result),
presentationText: formatPresentationSummary(result),
contentBundleText: formatContentBundleSummary(result),
runtimePlaceText: '待 launch.runtime 确认',
runtimeMapText: '待 launch.runtime 确认',
runtimeVariantText: selectedVariant
? selectedVariant.name
: (result.play.courseVariants && result.play.courseVariants[0]
? result.play.courseVariants[0].name
: '待 launch 确认'),
runtimeRouteCodeText: selectedVariant
? selectedVariant.routeCodeText
: (result.play.courseVariants && result.play.courseVariants[0] && result.play.courseVariants[0].routeCode
? result.play.courseVariants[0].routeCode || '待 launch 确认'
: '待 launch 确认'),
selectedVariantId, selectedVariantId,
selectedVariantText: selectedVariant selectedVariantText: selectedVariant
? `${selectedVariant.name} / ${selectedVariant.routeCodeText}` ? `${selectedVariant.name} / ${selectedVariant.routeCodeText}`
@@ -512,6 +556,8 @@ Page({
selectedVariantText: selectedVariant selectedVariantText: selectedVariant
? `${selectedVariant.name} / ${selectedVariant.routeCodeText}` ? `${selectedVariant.name} / ${selectedVariant.routeCodeText}`
: '当前无需手动指定赛道', : '当前无需手动指定赛道',
runtimeVariantText: selectedVariant ? selectedVariant.name : '待 launch 确认',
runtimeRouteCodeText: selectedVariant ? selectedVariant.routeCodeText : '待 launch 确认',
selectableVariants, selectableVariants,
}) })
}, },

View File

@@ -16,6 +16,40 @@
<view class="summary">当前选择:{{selectedVariantText}}</view> <view class="summary">当前选择:{{selectedVariantText}}</view>
</view> </view>
<view class="panel">
<view class="panel__title">活动运营摘要</view>
<view class="summary">当前阶段先展示活动运营对象摘要,不展开复杂 schema。</view>
<view class="row">
<view class="row__label">展示版本</view>
<view class="row__value">{{presentationText}}</view>
</view>
<view class="row">
<view class="row__label">内容包版本</view>
<view class="row__value">{{contentBundleText}}</view>
</view>
</view>
<view class="panel">
<view class="panel__title">运行对象摘要</view>
<view class="summary">当前阶段以前端已知信息预览,最终绑定以后端 `launch.runtime` 为准。</view>
<view class="row">
<view class="row__label">地点</view>
<view class="row__value">{{runtimePlaceText}}</view>
</view>
<view class="row">
<view class="row__label">地图</view>
<view class="row__value">{{runtimeMapText}}</view>
</view>
<view class="row">
<view class="row__label">赛道</view>
<view class="row__value">{{runtimeVariantText}}</view>
</view>
<view class="row">
<view class="row__label">RouteCode</view>
<view class="row__value">{{runtimeRouteCodeText}}</view>
</view>
</view>
<view class="panel" wx:if="{{assignmentMode === 'manual' && selectableVariants.length}}"> <view class="panel" wx:if="{{assignmentMode === 'manual' && selectableVariants.length}}">
<view class="panel__title">赛道选择</view> <view class="panel__title">赛道选择</view>
<view class="summary">当前活动要求手动指定赛道。这里的选择会随 launch 一起带给后端,最终绑定以后端返回为准。</view> <view class="summary">当前活动要求手动指定赛道。这里的选择会随 launch 一起带给后端,最终绑定以后端返回为准。</view>

View File

@@ -11,6 +11,8 @@ type EventPageData = {
statusText: string statusText: string
variantModeText: string variantModeText: string
variantSummaryText: string variantSummaryText: string
presentationText: string
contentBundleText: string
} }
function formatAssignmentMode(mode?: string | null): string { function formatAssignmentMode(mode?: string | null): string {
@@ -38,6 +40,30 @@ function formatVariantSummary(result: BackendEventPlayResult): string {
return `${variants.length} 条赛道,可选 ${selectable.length} 条:${preview}${suffix}` return `${variants.length} 条赛道,可选 ${selectable.length} 条:${preview}${suffix}`
} }
function formatPresentationSummary(result: BackendEventPlayResult): string {
const currentPresentation = result.currentPresentation
if (!currentPresentation) {
return '当前未声明展示版本'
}
const presentationId = currentPresentation.presentationId || '--'
const templateKey = currentPresentation.templateKey || '--'
const version = currentPresentation.version || '--'
return `${presentationId} / ${templateKey} / ${version}`
}
function formatContentBundleSummary(result: BackendEventPlayResult): string {
const currentContentBundle = result.currentContentBundle
if (!currentContentBundle) {
return '当前未声明内容包版本'
}
const bundleId = currentContentBundle.bundleId || '--'
const bundleType = currentContentBundle.bundleType || '--'
const version = currentContentBundle.version || '--'
return `${bundleId} / ${bundleType} / ${version}`
}
function getAccessToken(): string | null { function getAccessToken(): string | null {
const app = getApp<IAppOption>() const app = getApp<IAppOption>()
const tokens = app.globalData && app.globalData.backendAuthTokens const tokens = app.globalData && app.globalData.backendAuthTokens
@@ -57,6 +83,8 @@ Page({
statusText: '待加载', statusText: '待加载',
variantModeText: '--', variantModeText: '--',
variantSummaryText: '--', variantSummaryText: '--',
presentationText: '--',
contentBundleText: '--',
} as EventPageData, } as EventPageData,
onLoad(query: { eventId?: string }) { onLoad(query: { eventId?: string }) {
@@ -112,6 +140,8 @@ Page({
statusText: result.play.canLaunch ? '可启动' : '当前不可启动', statusText: result.play.canLaunch ? '可启动' : '当前不可启动',
variantModeText: formatAssignmentMode(result.play.assignmentMode), variantModeText: formatAssignmentMode(result.play.assignmentMode),
variantSummaryText: formatVariantSummary(result), variantSummaryText: formatVariantSummary(result),
presentationText: formatPresentationSummary(result),
contentBundleText: formatContentBundleSummary(result),
}) })
}, },

View File

@@ -13,6 +13,8 @@
<view class="summary">状态:{{statusText}}</view> <view class="summary">状态:{{statusText}}</view>
<view class="summary">赛道模式:{{variantModeText}}</view> <view class="summary">赛道模式:{{variantModeText}}</view>
<view class="summary">赛道摘要:{{variantSummaryText}}</view> <view class="summary">赛道摘要:{{variantSummaryText}}</view>
<view class="summary">展示版本:{{presentationText}}</view>
<view class="summary">内容包版本:{{contentBundleText}}</view>
<view class="actions"> <view class="actions">
<button class="btn btn--secondary" bindtap="handleRefresh">刷新</button> <button class="btn btn--secondary" bindtap="handleRefresh">刷新</button>
<button class="btn btn--primary" bindtap="handleLaunch">前往准备页</button> <button class="btn btn--primary" bindtap="handleLaunch">前往准备页</button>

View File

@@ -12,6 +12,8 @@ type HomePageData = {
channelText: string channelText: string
ongoingSessionText: string ongoingSessionText: string
recentSessionText: string recentSessionText: string
ongoingRuntimeText: string
recentRuntimeText: string
cards: BackendCardResult[] cards: BackendCardResult[]
} }
@@ -26,6 +28,18 @@ function formatSessionSummary(session?: BackendEntryHomeResult['ongoingSession']
return `${title} / ${status} / ${route}` return `${title} / ${status} / ${route}`
} }
function formatRuntimeSummary(session?: BackendEntryHomeResult['ongoingSession'] | null): string {
if (!session || !session.runtime) {
return '运行对象 --'
}
const runtime = session.runtime
const placeText = runtime.placeName || runtime.placeId || '--'
const mapText = runtime.mapName || runtime.mapId || '--'
const variantText = runtime.courseVariantId || session.variantName || session.variantId || '--'
return `地点 ${placeText} / 地图 ${mapText} / 赛道 ${variantText}`
}
function requireAuthToken(): string | null { function requireAuthToken(): string | null {
const app = getApp<IAppOption>() const app = getApp<IAppOption>()
const tokens = app.globalData && app.globalData.backendAuthTokens const tokens = app.globalData && app.globalData.backendAuthTokens
@@ -43,6 +57,8 @@ Page({
channelText: '--', channelText: '--',
ongoingSessionText: '无', ongoingSessionText: '无',
recentSessionText: '无', recentSessionText: '无',
ongoingRuntimeText: '运行对象 --',
recentRuntimeText: '运行对象 --',
cards: [], cards: [],
} as HomePageData, } as HomePageData,
@@ -92,6 +108,8 @@ Page({
channelText: `${result.channel.displayName} / ${result.channel.code}`, channelText: `${result.channel.displayName} / ${result.channel.code}`,
ongoingSessionText: formatSessionSummary(result.ongoingSession), ongoingSessionText: formatSessionSummary(result.ongoingSession),
recentSessionText: formatSessionSummary(result.recentSession), recentSessionText: formatSessionSummary(result.recentSession),
ongoingRuntimeText: formatRuntimeSummary(result.ongoingSession),
recentRuntimeText: formatRuntimeSummary(result.recentSession),
cards: result.cards || [], cards: result.cards || [],
}) })
}, },

View File

@@ -11,7 +11,9 @@
<view class="panel__title">当前状态</view> <view class="panel__title">当前状态</view>
<view class="summary">{{statusText}}</view> <view class="summary">{{statusText}}</view>
<view class="summary">进行中:{{ongoingSessionText}}</view> <view class="summary">进行中:{{ongoingSessionText}}</view>
<view class="summary">进行中运行对象:{{ongoingRuntimeText}}</view>
<view class="summary">最近一局:{{recentSessionText}}</view> <view class="summary">最近一局:{{recentSessionText}}</view>
<view class="summary">最近一局运行对象:{{recentRuntimeText}}</view>
<view class="actions"> <view class="actions">
<button class="btn btn--secondary" bindtap="handleRefresh">刷新首页</button> <button class="btn btn--secondary" bindtap="handleRefresh">刷新首页</button>
<button class="btn btn--ghost" bindtap="handleOpenRecentResult">查看结果</button> <button class="btn btn--ghost" bindtap="handleOpenRecentResult">查看结果</button>

View File

@@ -809,6 +809,25 @@ function buildEmptyResultSceneSnapshot(): MapEngineResultSnapshot {
} }
} }
function buildRuntimeSummaryRows(envelope: GameLaunchEnvelope): MapEngineGameInfoRow[] {
const runtime = envelope.runtime
const variantName = envelope.variant ? (envelope.variant.variantName || envelope.variant.variantId || null) : null
const variantRouteCode = envelope.variant ? (envelope.variant.routeCode || null) : null
if (!runtime) {
return []
}
const rows: MapEngineGameInfoRow[] = []
rows.push({ label: '运行绑定', value: runtime.runtimeBindingId || '--' })
rows.push({ label: '地点', value: runtime.placeName || runtime.placeId || '--' })
rows.push({ label: '地图', value: runtime.mapName || runtime.mapId || '--' })
rows.push({ label: '赛道集', value: runtime.courseSetId || '--' })
rows.push({ label: '赛道版本', value: runtime.courseVariantId || variantName || '--' })
rows.push({ label: 'RouteCode', value: runtime.routeCode || variantRouteCode || '--' })
rows.push({ label: '瓦片版本', value: runtime.tileReleaseId || '--' })
return rows
}
Page({ Page({
data: { data: {
showDebugPanel: false, showDebugPanel: false,
@@ -1640,6 +1659,7 @@ Page({
const app = getApp<IAppOption>() const app = getApp<IAppOption>()
if (app.globalData) { if (app.globalData) {
app.globalData.pendingResultSnapshot = snapshot app.globalData.pendingResultSnapshot = snapshot
app.globalData.pendingResultLaunchEnvelope = currentGameLaunchEnvelope
} }
}, },
@@ -2422,6 +2442,7 @@ Page({
const snapshot = mapEngine.getGameInfoSnapshot() const snapshot = mapEngine.getGameInfoSnapshot()
const localRows = snapshot.localRows.concat([ const localRows = snapshot.localRows.concat([
...buildRuntimeSummaryRows(currentGameLaunchEnvelope),
{ label: '比例尺开关', value: this.data.showCenterScaleRuler ? '开启' : '关闭' }, { label: '比例尺开关', value: this.data.showCenterScaleRuler ? '开启' : '关闭' },
{ label: '比例尺锚点', value: this.data.centerScaleRulerAnchorMode === 'compass-center' ? '指北针圆心' : '屏幕中心' }, { label: '比例尺锚点', value: this.data.centerScaleRulerAnchorMode === 'compass-center' ? '指北针圆心' : '屏幕中心' },
{ label: '按钮习惯', value: this.data.sideButtonPlacement === 'right' ? '右手' : '左手' }, { label: '按钮习惯', value: this.data.sideButtonPlacement === 'right' ? '右手' : '左手' },
@@ -2450,7 +2471,7 @@ Page({
resultSceneSubtitle: snapshot.subtitle, resultSceneSubtitle: snapshot.subtitle,
resultSceneHeroLabel: snapshot.heroLabel, resultSceneHeroLabel: snapshot.heroLabel,
resultSceneHeroValue: snapshot.heroValue, resultSceneHeroValue: snapshot.heroValue,
resultSceneRows: snapshot.rows, resultSceneRows: snapshot.rows.concat(buildRuntimeSummaryRows(currentGameLaunchEnvelope)),
}) })
}, },

View File

@@ -1,6 +1,7 @@
import { loadBackendAuthTokens, loadBackendBaseUrl } from '../../utils/backendAuth' import { loadBackendAuthTokens, loadBackendBaseUrl } from '../../utils/backendAuth'
import { getSessionResult } from '../../utils/backendApi' import { getSessionResult } from '../../utils/backendApi'
import type { MapEngineResultSnapshot } from '../../engine/map/mapEngine' import type { MapEngineResultSnapshot } from '../../engine/map/mapEngine'
import type { GameLaunchEnvelope } from '../../utils/gameLaunch'
type ResultPageData = { type ResultPageData = {
sessionId: string sessionId: string
@@ -41,6 +42,56 @@ function formatRouteSummary(input: {
return '默认赛道' return '默认赛道'
} }
function formatRuntimeValue(...candidates: Array<string | null | undefined>): string {
for (let index = 0; index < candidates.length; index += 1) {
const value = candidates[index]
if (typeof value === 'string' && value.trim().length > 0) {
return value.trim()
}
}
return '--'
}
function appendRuntimeRows(
rows: Array<{ label: string; value: string }>,
options: {
runtime?: {
runtimeBindingId?: string | null
placeId?: string | null
placeName?: string | null
mapId?: string | null
mapName?: string | null
tileReleaseId?: string | null
courseSetId?: string | null
courseVariantId?: string | null
routeCode?: string | null
} | null
variantName?: string | null
routeCode?: string | null
},
) {
if (!options.runtime) {
return rows
}
return rows.concat([
{ label: '运行绑定', value: formatRuntimeValue(options.runtime.runtimeBindingId) },
{ label: '地点', value: formatRuntimeValue(options.runtime.placeName, options.runtime.placeId) },
{ label: '地图', value: formatRuntimeValue(options.runtime.mapName, options.runtime.mapId) },
{ label: '赛道集', value: formatRuntimeValue(options.runtime.courseSetId) },
{ label: '赛道版本', value: formatRuntimeValue(options.runtime.courseVariantId, options.variantName) },
{ label: 'RouteCode', value: formatRuntimeValue(options.runtime.routeCode, options.routeCode) },
{ label: '瓦片版本', value: formatRuntimeValue(options.runtime.tileReleaseId) },
])
}
function loadPendingResultLaunchEnvelope(): GameLaunchEnvelope | null {
const app = getApp<IAppOption>()
return app.globalData && app.globalData.pendingResultLaunchEnvelope
? app.globalData.pendingResultLaunchEnvelope
: null
}
Page({ Page({
data: { data: {
sessionId: '', sessionId: '',
@@ -75,17 +126,22 @@ Page({
return return
} }
const pendingLaunchEnvelope = loadPendingResultLaunchEnvelope()
this.setData({ this.setData({
statusText: '正在加载结果', statusText: '正在加载结果',
sessionTitleText: snapshot.title, sessionTitleText: snapshot.title,
sessionSubtitleText: snapshot.subtitle, sessionSubtitleText: snapshot.subtitle,
rows: [ rows: appendRuntimeRows([
{ label: snapshot.heroLabel, value: snapshot.heroValue }, { label: snapshot.heroLabel, value: snapshot.heroValue },
...snapshot.rows.map((row) => ({ ...snapshot.rows.map((row) => ({
label: row.label, label: row.label,
value: row.value, value: row.value,
})), })),
], ], {
runtime: pendingLaunchEnvelope && pendingLaunchEnvelope.runtime ? pendingLaunchEnvelope.runtime : null,
variantName: pendingLaunchEnvelope && pendingLaunchEnvelope.variant ? pendingLaunchEnvelope.variant.variantName : null,
routeCode: pendingLaunchEnvelope && pendingLaunchEnvelope.variant ? pendingLaunchEnvelope.variant.routeCode : null,
}),
}) })
if (app.globalData) { if (app.globalData) {
@@ -110,11 +166,12 @@ Page({
accessToken, accessToken,
sessionId, sessionId,
}) })
const pendingLaunchEnvelope = loadPendingResultLaunchEnvelope()
this.setData({ this.setData({
statusText: '单局结果加载完成', statusText: '单局结果加载完成',
sessionTitleText: result.session.eventName || result.session.eventDisplayName || result.session.eventId || result.session.id || result.session.sessionId, sessionTitleText: result.session.eventName || result.session.eventDisplayName || result.session.eventId || result.session.id || result.session.sessionId,
sessionSubtitleText: `${result.session.status || result.session.sessionStatus} / ${result.result.status} / ${formatRouteSummary(result.session)}`, sessionSubtitleText: `${result.session.status || result.session.sessionStatus} / ${result.result.status} / ${formatRouteSummary(result.session)}`,
rows: [ rows: appendRuntimeRows([
{ label: '赛道版本', value: formatRouteSummary(result.session) }, { label: '赛道版本', value: formatRouteSummary(result.session) },
{ label: '最终得分', value: formatValue(result.result.finalScore) }, { label: '最终得分', value: formatValue(result.result.finalScore) },
{ label: '最终用时(秒)', value: formatValue(result.result.finalDurationSec) }, { label: '最终用时(秒)', value: formatValue(result.result.finalDurationSec) },
@@ -123,8 +180,16 @@ Page({
{ label: '累计里程(m)', value: formatValue(result.result.distanceMeters) }, { label: '累计里程(m)', value: formatValue(result.result.distanceMeters) },
{ label: '平均速度(km/h)', value: formatValue(result.result.averageSpeedKmh) }, { label: '平均速度(km/h)', value: formatValue(result.result.averageSpeedKmh) },
{ label: '最大心率', value: formatValue(result.result.maxHeartRateBpm) }, { label: '最大心率', value: formatValue(result.result.maxHeartRateBpm) },
], ], {
runtime: result.session.runtime || (pendingLaunchEnvelope && pendingLaunchEnvelope.runtime ? pendingLaunchEnvelope.runtime : null),
variantName: result.session.variantName || (pendingLaunchEnvelope && pendingLaunchEnvelope.variant ? pendingLaunchEnvelope.variant.variantName : null),
routeCode: result.session.routeCode || (pendingLaunchEnvelope && pendingLaunchEnvelope.variant ? pendingLaunchEnvelope.variant.routeCode : null),
}),
}) })
const app = getApp<IAppOption>()
if (app.globalData) {
app.globalData.pendingResultLaunchEnvelope = null
}
} catch (error) { } catch (error) {
const message = error && (error as { message?: string }).message ? (error as { message: string }).message : '未知错误' const message = error && (error as { message?: string }).message ? (error as { message: string }).message : '未知错误'
this.setData({ this.setData({

View File

@@ -10,6 +10,7 @@ type ResultsPageData = {
statusText: string statusText: string
scoreText: string scoreText: string
routeText: string routeText: string
runtimeText: string
}> }>
} }
@@ -35,6 +36,18 @@ function formatRouteSummary(result: BackendSessionResultView): string {
return '默认赛道' return '默认赛道'
} }
function formatRuntimeSummary(result: BackendSessionResultView): string {
const runtime = result.session.runtime
if (!runtime) {
return '运行对象 --'
}
const placeText = runtime.placeName || runtime.placeId || '--'
const mapText = runtime.mapName || runtime.mapId || '--'
const variantText = runtime.courseVariantId || result.session.variantName || result.session.variantId || '--'
return `地点 ${placeText} / 地图 ${mapText} / 赛道 ${variantText}`
}
function buildResultCardView(result: BackendSessionResultView) { function buildResultCardView(result: BackendSessionResultView) {
return { return {
sessionId: result.session.id, sessionId: result.session.id,
@@ -42,6 +55,7 @@ function buildResultCardView(result: BackendSessionResultView) {
statusText: `${result.result.status} / ${result.session.status}`, statusText: `${result.result.status} / ${result.session.status}`,
scoreText: `得分 ${result.result.finalScore || '--'} / 用时 ${result.result.finalDurationSec || '--'}s`, scoreText: `得分 ${result.result.finalScore || '--'} / 用时 ${result.result.finalDurationSec || '--'}s`,
routeText: `赛道 ${formatRouteSummary(result)}`, routeText: `赛道 ${formatRouteSummary(result)}`,
runtimeText: formatRuntimeSummary(result),
} }
} }

View File

@@ -19,6 +19,7 @@
<view class="result-card__meta">{{item.statusText}}</view> <view class="result-card__meta">{{item.statusText}}</view>
<view class="result-card__meta">{{item.scoreText}}</view> <view class="result-card__meta">{{item.scoreText}}</view>
<view class="result-card__meta">{{item.routeText}}</view> <view class="result-card__meta">{{item.routeText}}</view>
<view class="result-card__meta">{{item.runtimeText}}</view>
</view> </view>
</view> </view>
</view> </view>

View File

@@ -45,6 +45,30 @@ export interface BackendLaunchVariantSummary {
assignmentMode?: string | null assignmentMode?: string | null
} }
export interface BackendRuntimeSummary {
runtimeBindingId?: string | null
placeId?: string | null
placeName?: string | null
mapId?: string | null
mapName?: string | null
tileReleaseId?: string | null
courseSetId?: string | null
courseVariantId?: string | null
routeCode?: string | null
}
export interface BackendPresentationSummary {
presentationId?: string | null
templateKey?: string | null
version?: string | null
}
export interface BackendContentBundleSummary {
bundleId?: string | null
bundleType?: string | null
version?: string | null
}
export interface BackendEntrySessionSummary { export interface BackendEntrySessionSummary {
id: string id: string
status: string status: string
@@ -55,6 +79,7 @@ export interface BackendEntrySessionSummary {
routeCode?: string | null routeCode?: string | null
variantId?: string | null variantId?: string | null
variantName?: string | null variantName?: string | null
runtime?: BackendRuntimeSummary | null
launchedAt?: string | null launchedAt?: string | null
startedAt?: string | null startedAt?: string | null
endedAt?: string | null endedAt?: string | null
@@ -115,6 +140,8 @@ export interface BackendEventPlayResult {
summary?: string | null summary?: string | null
status: string status: string
} }
currentPresentation?: BackendPresentationSummary | null
currentContentBundle?: BackendContentBundleSummary | null
release?: { release?: {
id: string id: string
configLabel: string configLabel: string
@@ -159,6 +186,9 @@ export interface BackendLaunchResult {
routeCode?: string | null routeCode?: string | null
} }
variant?: BackendLaunchVariantSummary | null variant?: BackendLaunchVariantSummary | null
runtime?: BackendRuntimeSummary | null
presentation?: BackendPresentationSummary | null
contentBundle?: BackendContentBundleSummary | null
} }
} }
@@ -179,6 +209,7 @@ export interface BackendSessionResult {
clientType: string clientType: string
deviceKey: string deviceKey: string
routeCode?: string | null routeCode?: string | null
runtime?: BackendRuntimeSummary | null
sessionTokenExpiresAt: string sessionTokenExpiresAt: string
launchedAt: string launchedAt: string
startedAt?: string | null startedAt?: string | null

View File

@@ -2,6 +2,10 @@ import { type GameLaunchEnvelope } from './gameLaunch'
import { type BackendLaunchResult } from './backendApi' import { type BackendLaunchResult } from './backendApi'
export function adaptBackendLaunchResultToEnvelope(result: BackendLaunchResult): GameLaunchEnvelope { export function adaptBackendLaunchResultToEnvelope(result: BackendLaunchResult): GameLaunchEnvelope {
const launchVariantRouteCode = result.launch.variant
? (result.launch.variant.routeCode || null)
: null
return { return {
config: { config: {
configUrl: result.launch.config.configUrl, configUrl: result.launch.config.configUrl,
@@ -29,5 +33,32 @@ export function adaptBackendLaunchResultToEnvelope(result: BackendLaunchResult):
routeCode: result.launch.config.routeCode || result.launch.business.routeCode || null, routeCode: result.launch.config.routeCode || result.launch.business.routeCode || null,
} }
: null, : null,
runtime: result.launch.runtime
? {
runtimeBindingId: result.launch.runtime.runtimeBindingId || null,
placeId: result.launch.runtime.placeId || null,
placeName: result.launch.runtime.placeName || null,
mapId: result.launch.runtime.mapId || null,
mapName: result.launch.runtime.mapName || null,
tileReleaseId: result.launch.runtime.tileReleaseId || null,
courseSetId: result.launch.runtime.courseSetId || null,
courseVariantId: result.launch.runtime.courseVariantId || null,
routeCode: result.launch.runtime.routeCode || launchVariantRouteCode || result.launch.config.routeCode || result.launch.business.routeCode || null,
}
: null,
presentation: result.launch.presentation
? {
presentationId: result.launch.presentation.presentationId || null,
templateKey: result.launch.presentation.templateKey || null,
version: result.launch.presentation.version || null,
}
: null,
contentBundle: result.launch.contentBundle
? {
bundleId: result.launch.contentBundle.bundleId || null,
bundleType: result.launch.contentBundle.bundleType || null,
version: result.launch.contentBundle.version || null,
}
: null,
} }
} }

View File

@@ -29,10 +29,37 @@ export interface GameVariantLaunchContext {
assignmentMode?: string | null assignmentMode?: string | null
} }
export interface GameRuntimeLaunchContext {
runtimeBindingId?: string | null
placeId?: string | null
placeName?: string | null
mapId?: string | null
mapName?: string | null
tileReleaseId?: string | null
courseSetId?: string | null
courseVariantId?: string | null
routeCode?: string | null
}
export interface GamePresentationLaunchContext {
presentationId?: string | null
templateKey?: string | null
version?: string | null
}
export interface GameContentBundleLaunchContext {
bundleId?: string | null
bundleType?: string | null
version?: string | null
}
export interface GameLaunchEnvelope { export interface GameLaunchEnvelope {
config: GameConfigLaunchRequest config: GameConfigLaunchRequest
business: BusinessLaunchContext | null business: BusinessLaunchContext | null
variant?: GameVariantLaunchContext | null variant?: GameVariantLaunchContext | null
runtime?: GameRuntimeLaunchContext | null
presentation?: GamePresentationLaunchContext | null
contentBundle?: GameContentBundleLaunchContext | null
} }
export interface MapPageLaunchOptions { export interface MapPageLaunchOptions {
@@ -57,6 +84,20 @@ export interface MapPageLaunchOptions {
variantId?: string variantId?: string
variantName?: string variantName?: string
assignmentMode?: string assignmentMode?: string
runtimeBindingId?: string
placeId?: string
placeName?: string
mapId?: string
mapName?: string
tileReleaseId?: string
courseSetId?: string
courseVariantId?: string
presentationId?: string
presentationTemplateKey?: string
presentationVersion?: string
contentBundleId?: string
contentBundleType?: string
contentBundleVersion?: string
} }
type PendingGameLaunchStore = Record<string, GameLaunchEnvelope> type PendingGameLaunchStore = Record<string, GameLaunchEnvelope>
@@ -154,6 +195,78 @@ function buildVariantLaunchContext(options?: MapPageLaunchOptions | null): GameV
} }
} }
function buildRuntimeLaunchContext(options?: MapPageLaunchOptions | null): GameRuntimeLaunchContext | null {
if (!options) {
return null
}
const runtimeBindingId = normalizeOptionalString(options.runtimeBindingId)
const placeId = normalizeOptionalString(options.placeId)
const placeName = normalizeOptionalString(options.placeName)
const mapId = normalizeOptionalString(options.mapId)
const mapName = normalizeOptionalString(options.mapName)
const tileReleaseId = normalizeOptionalString(options.tileReleaseId)
const courseSetId = normalizeOptionalString(options.courseSetId)
const courseVariantId = normalizeOptionalString(options.courseVariantId)
const routeCode = normalizeOptionalString(options.routeCode)
if (!runtimeBindingId && !placeId && !placeName && !mapId && !mapName && !tileReleaseId && !courseSetId && !courseVariantId && !routeCode) {
return null
}
return {
runtimeBindingId,
placeId,
placeName,
mapId,
mapName,
tileReleaseId,
courseSetId,
courseVariantId,
routeCode,
}
}
function buildPresentationLaunchContext(options?: MapPageLaunchOptions | null): GamePresentationLaunchContext | null {
if (!options) {
return null
}
const presentationId = normalizeOptionalString(options.presentationId)
const templateKey = normalizeOptionalString(options.presentationTemplateKey)
const version = normalizeOptionalString(options.presentationVersion)
if (!presentationId && !templateKey && !version) {
return null
}
return {
presentationId,
templateKey,
version,
}
}
function buildContentBundleLaunchContext(options?: MapPageLaunchOptions | null): GameContentBundleLaunchContext | null {
if (!options) {
return null
}
const bundleId = normalizeOptionalString(options.contentBundleId)
const bundleType = normalizeOptionalString(options.contentBundleType)
const version = normalizeOptionalString(options.contentBundleVersion)
if (!bundleId && !bundleType && !version) {
return null
}
return {
bundleId,
bundleType,
version,
}
}
function loadPendingGameLaunchStore(): PendingGameLaunchStore { function loadPendingGameLaunchStore(): PendingGameLaunchStore {
try { try {
const stored = wx.getStorageSync(PENDING_GAME_LAUNCH_STORAGE_KEY) const stored = wx.getStorageSync(PENDING_GAME_LAUNCH_STORAGE_KEY)
@@ -180,6 +293,9 @@ export function getDemoGameLaunchEnvelope(preset: DemoGamePreset = 'classic'): G
source: 'demo', source: 'demo',
}, },
variant: null, variant: null,
runtime: null,
presentation: null,
contentBundle: null,
} }
} }
@@ -252,6 +368,9 @@ export function resolveGameLaunchEnvelope(options?: MapPageLaunchOptions | null)
}, },
business: buildBusinessLaunchContext(options), business: buildBusinessLaunchContext(options),
variant: buildVariantLaunchContext(options), variant: buildVariantLaunchContext(options),
runtime: buildRuntimeLaunchContext(options),
presentation: buildPresentationLaunchContext(options),
contentBundle: buildContentBundleLaunchContext(options),
} }
} }

View File

@@ -1,6 +1,6 @@
# CMR Mini 开发架构阶段总结 # CMR Mini 开发架构阶段总结
> 文档版本v1.0 > 文档版本v1.13
> 最后更新2026-04-02 08:28:05 > 最后更新2026-04-03 13:08:15
文档维护约定: 文档维护约定:
@@ -10,6 +10,77 @@
本文档用于记录当前阶段小程序的整体架构、分层原则、事件驱动链路、模拟器体系,以及后续继续扩展时应遵守的边界。 本文档用于记录当前阶段小程序的整体架构、分层原则、事件驱动链路、模拟器体系,以及后续继续扩展时应遵守的边界。
当前补充约定:
- 多线程联调场景下,正式架构与长期结论优先沉淀到 `doc/`
- 面向后端线程的阶段性实施说明,优先写入根目录 [t2b.md](D:/dev/cmr-mini/t2b.md)。
- backend 新增写给总控线程的回写板:
- [b2t.md](D:/dev/cmr-mini/b2t.md)
- 面向前端线程的阶段性实施说明,优先写入根目录 [t2f.md](D:/dev/cmr-mini/t2f.md)。
- frontend 写给总控线程的回写板:
- [f2t.md](D:/dev/cmr-mini/f2t.md)
- 分层原则固定为:
- 玩家用前端
- 管理者用后端
- 中间层负责契约、架构、性能、健壮性与伸缩性
- 不把后台复杂性直接暴露给玩家界面
- 后台生产闭环的正式架构稿见:
- [后台生产闭环架构草案](D:/dev/cmr-mini/doc/backend/后台生产闭环架构草案.md)
- 正式上线时的数据库与服务发布流程见:
- [生产发布与数据库上线方案](D:/dev/cmr-mini/doc/backend/生产发布与数据库上线方案.md)
- backend 下一阶段建议:
- runtime 链已收口frontend 当前不再扩 runtime 页面链
- 活动运营域第二阶段第四刀已完成:
- `EventPresentation` 统一导入入口
- `Event` 默认 active 三元组固化
- publish 默认继承 active 三元组
- 当前主线已切到“联调标准化阶段”
- 当前已完成:
- `GET /events/{eventPublicID}` 透出 `currentPresentation / currentContentBundle`
- `GET /events/{eventPublicID}/play` 透出 `currentPresentation / currentContentBundle`
- `launch` 透出 `presentation / contentBundle`
- publish 可自动补齐 `presentationId / contentBundleId`
- `release detail` 已统一活动运营摘要
- `ContentBundle` 统一导入入口第一版已完成
- `Bootstrap Demo` 已可补齐:
- `place / map asset / tile release / course source / course set / course variant / runtime binding`
- `一键补齐 Runtime 并发布` 已可从空白状态跑完整测试链
- workbench 日志已具备:
- 分步日志
- 真实错误
- stack
- 最后一次 curl
- 预期判定
- 下一步建议:
- 固化“一键测试”链路为联调标准路径
- 固化稳定测试数据,不再依赖手工铺对象
- 逐步准备更接近生产的真实输入:
- 地图资源 URL
- KML / 赛道文件
- 内容 manifest
- presentation schema
- 活动文案样例
- 前端线程建议正式上场时机:
- 现在已完成活动运营域摘要接线第一刀
- 当前已完成:
- runtime 摘要链:
- 准备页预览态摘要
- 地图页
- 单局结果页
- 历史结果列表页
- 首页 ongoing
- 首页 recent
- 活动运营域摘要链:
- 活动详情页
- 活动准备页
- 会话快照
- 当前建议:
- frontend 进入联调标准化配合与小范围修复阶段
- 只做字段修正、摘要打磨、一致性修复
- 优先复用 backend 一键测试环境做回归
- 不继续扩新页面链
- 不做复杂运营样式
当前阶段的核心目标已经从“把地图画出来”升级为“建立一套可长期扩展的运动地图游戏底座”。 当前阶段的核心目标已经从“把地图画出来”升级为“建立一套可长期扩展的运动地图游戏底座”。
这套底座已经具备以下关键能力: 这套底座已经具备以下关键能力:

836
t2b.md Normal file
View File

@@ -0,0 +1,836 @@
# T2B 协作清单
> 文档版本v1.10
> 最后更新2026-04-03 13:08:15
说明:
- 本文件由总控维护,写给后端线程
- 目标是把“后台生产闭环”第一阶段需要落地的东西讲清楚
- 只写当前阶段实施说明,不写长讨论稿
- 正式架构文档以 [后台生产闭环架构草案](D:/dev/cmr-mini/doc/backend/后台生产闭环架构草案.md) 为准
---
## 0. 当前阶段状态与下一步
backend 当前已完成:
- 生产骨架对象落库与 `/dev/workbench` 最小联调台
- `MapRuntimeBinding -> EventRelease -> launch.runtime` 主链接通
- `EventPresentation / ContentBundle / EventRelease` 第一阶段接通
- `Event` 默认 active 三元组固化:
- `currentPresentationId`
- `currentContentBundleId`
- `currentRuntimeBindingId`
- `publish` 默认继承当前 active 三元组
- `Bootstrap Demo``一键补齐 Runtime 并发布` 已可从空白状态跑完整测试链
- workbench 日志已补齐:
- 分步日志
- 真实错误
- stack
- 最后一次 curl
- 预期判定
当前主线不再是继续补对象,而是进入:
**联调标准化阶段**
本阶段 backend 的核心任务只有 3 件事:
1. 固化“一键测试”链路,确保从空白环境可重复跑通
2. 固化详细日志口径,失败时明确定位在哪一步
3. 固化稳定测试数据,并逐步支持更接近生产的真实输入
当前不建议 backend 继续发散去做:
- 更多新对象
- 更多 workbench 管理按钮
- 更复杂后台 UI
- 过早扩大奖励、社交、审核流
---
## 1. 本次目标
本次不是让 backend 一次性做完整后台,而是先搭出**最小生产骨架**,让下面这条链能真正闭环:
```text
地图输入
-> 瓦片版本
-> KML 导入
-> 赛道 variant
-> 活动绑定
-> release
-> launch
-> 客户端消费
```
当前重点是:
- 先把对象模型定下来
- 先让地图、KML、活动三条输入链有正式落点
- 先让客户端只认发布产物
补充确认:
- 本次接受 backend 采用**增量演进**方式推进
- 不要求一次性推翻当前已稳定联调的:
- `Event`
- `EventRelease`
- `Session`
主链
当前补充确认:
- 生产骨架第一阶段与活动运营域第二阶段第四刀已经完成
- backend 当前下一步应切到“联调标准化”,而不是继续新增对象层级
---
## 2. 本次范围
### 2.1 本次必须做
- 定义并落库以下核心对象:
- `Place`
- `MapAsset`
- `TileRelease`
- `CourseSet`
- `CourseVariant`
- `CourseSource`
- `Event`
- `EventPresentation`
- `ContentBundle`
- `MapRuntimeBinding`
- `EventRelease`
- 先让 KML 不再只是文件,而能转成 `CourseVariant`
- 先让活动不再只是页面概念,而能正式绑定:
- 展示定义
- 内容包
- 运行绑定
- 发布版本
- 第一阶段优先落以下对象:
- `Place`
- `MapAsset`
- `TileRelease`
- `CourseSource`
- `CourseSet`
- `CourseVariant`
- `MapRuntimeBinding`
- 第一阶段允许暂缓完整落库:
- `EventPresentation`
- `ContentBundle`
但对象语义必须先在架构上定清楚
### 2.2 本次先不做
- 奖励系统
- 社交系统
- 复杂审核流
- 完整后台 UI
- 大而全的素材编排器
- 高级权限体系
---
## 3. 后端对象最小理解
### 3.1 地图运行域
#### `Place`
- 地点
- 上层业务对象
- 一个地点下可有多张地图
#### `MapAsset`
- 某个地点下的一张地图资源
- 一张地图可有多个瓦片版本
#### `TileRelease`
- 某张地图的具体瓦片发布版本
#### `CourseSet`
- 一组赛道集合
- 例如:校园顺序赛、校园积分赛
#### `CourseVariant`
- 一个具体可运行赛道方案
- 顺序赛 8 点 / 12 点
- 积分赛 A / B / C 方案
- 客户端最终只应认这个对象
#### `CourseSource`
- 原始输入源
- KML 只是来源,不是最终业务对象
### 3.2 活动运营域
#### `Event`
- 活动业务对象
- 默认体验活动和定制活动都属于它
#### `EventPresentation`
- 活动卡片、详情页、H5 schema
#### `ContentBundle`
- 图片、音频、动画、文创、结果页资源等内容包
#### `MapRuntimeBinding`
- 活动运行时绑定哪张地图、哪条赛道、哪套瓦片、哪套配置
#### `EventRelease`
- 客户端真正消费的活动发布版本
---
## 4. 最小关系建议
建议 backend 先按这个关系理解:
- `Place 1 -> N MapAsset`
- `MapAsset 1 -> N TileRelease`
- `MapAsset 1 -> N CourseSet`
- `CourseSet 1 -> N CourseVariant`
- `CourseVariant N -> 1 CourseSource`
- `Event 1 -> N EventPresentation`
- `Event 1 -> N ContentBundle`
- `Event 1 -> N MapRuntimeBinding`
- `Event 1 -> N EventRelease`
其中:
- `MapRuntimeBinding` 负责引用:
- `placeId`
- `mapId`
- `tileReleaseId`
- `courseSetId`
- `courseVariantId`
---
## 5. 后端第一阶段建议实施顺序
### 第一步:按增量方式落库最小对象
建议先把表和基础模型定下来。
第一阶段优先级建议:
1. `places`
2. `map_assets`
3. `tile_releases`
4. `course_sources`
5. `course_sets`
6. `course_variants`
7. `map_runtime_bindings`
第二阶段再补:
8. `event_presentations`
9. `content_bundles`
说明:
- 当前稳定的 `events / event_releases / sessions` 主链保留
- 本次是在现有骨架上增量补生产对象,不做一次性替换式重构
### 第二步:先打通 KML 导入链
目标:
- 上传 KML
- 保存 `CourseSource`
- 解析控制点与起终点
- 生成一个 `CourseVariant`
- 归入某个 `CourseSet`
### 第三步:先打通活动绑定链
目标:
- 一个 `Event` 可绑定:
- `EventPresentation`
- `ContentBundle`
- `MapRuntimeBinding`
### 第四步:先打通发布链
目标:
- 生成 `EventRelease`
- `launch` 先继续返回当前稳定字段:
- `resolvedRelease`
- `business`
- `variant`
- 第二阶段再补完整运行对象字段:
- `placeId`
- `mapId`
- `tileReleaseId`
- `courseVariantId`
- `eventReleaseId`
### 第五步:把第一阶段生产骨架接口接入 `/dev/workbench`
目标:
- 不让第一阶段对象只停留在 API 目录里
- 在 workbench 里形成最小可操作联调面板
- 用于验证对象关系和生产闭环,不用于替代正式后台
本步建议只做:
#### A. 地点与地图
- `Place` 列表
- 新建 `Place`
-`Place` 下新建 `MapAsset`
-`MapAsset` 下新建 `TileRelease`
- 查看详情
#### B. 赛道与 KML
- `CourseSource` 列表
- 新建 `CourseSource`
- 新建 `CourseSet`
-`CourseSet` 下新建 `CourseVariant`
- 查看详情
#### C. 运行绑定
- `MapRuntimeBinding` 列表
- 新建 `MapRuntimeBinding`
- 选择:
- `place`
- `map`
- `tile release`
- `course variant`
- 查看详情
本步明确不做:
- 完整后台 UI
- Event 全量编辑
- `EventPresentation` 可视化搭建
- `ContentBundle` 大资源管理台
- Build / Release 全流程可视化
- 删除、批量操作、审核流
一句话:
**workbench 当前只做“第一阶段生产骨架联调台”,不做“正式后台管理系统”。**
### 第六步:进入“最小接线”阶段
目标:
-`MapRuntimeBinding` 和当前 `EventRelease` 接起来
- 让运行对象开始逐步进入 `launch`
- 保持当前前端稳定链不被打断
本步建议优先做:
#### A. `EventRelease` 接 `MapRuntimeBinding`
-`EventRelease` 上补 `runtimeBindingId`
- 查询 `EventRelease` 时可带出最小 `runtime binding` 摘要
#### B. `launch` 新增 `runtime` 摘要块
- 保留当前稳定字段:
- `resolvedRelease`
- `business`
- `variant`
- 新增一个兼容性的 `runtime` 块,建议最少返回:
- `runtimeBindingId`
- `placeId`
- `mapId`
- `tileReleaseId`
- `courseSetId`
- `courseVariantId`
- 如字段成本不高,可附带:
- `placeName`
- `mapName`
- `routeCode`
#### C. `workbench` 最小接线验证
-`/dev/workbench` 上增加:
- `EventRelease` 选择或查看
- 绑定 `runtimeBinding`
- 查看 release 当前已接入的运行对象摘要
本步明确要求:
- 不修改旧字段语义
- 不移除旧字段
- 不让前端现有 `launch` 链断掉
- 先做到“后端可挂接、可透出、可验证”
---
## 6. 当前接口落地方向
当前阶段建议 backend 后续接口逐步收敛到:
### 6.1 生产侧接口
- 创建地点
- 创建地图
- 创建瓦片版本记录
- 上传 KML
- 生成赛道 variant
- 创建活动
- 保存活动展示定义
- 保存内容包引用
- 保存运行绑定
- 创建活动 release
### 6.2 客户端消费接口
- 活动列表
- 活动详情
- `launch`
- session start
- session finish
- result
- history
关键要求:
- 客户端只消费 release 产物
- 不再消费原始 KML
- 不再消费地图原始资产
- `launch` 采用两阶段兼容,不要求第一阶段打断当前前端稳定链
### 6.3 workbench 联调台
backend 下一步建议把以下接口先接到 `/dev/workbench`
- `Place`
- `MapAsset`
- `TileRelease`
- `CourseSource`
- `CourseSet`
- `CourseVariant`
- `MapRuntimeBinding`
接入目标:
- list
- create
- detail
- binding
不建议当前阶段接入:
- edit
- delete
- batch
- 审核流
### 6.4 最小接线阶段接口方向
backend 下一步建议新增或补齐以下能力:
- `EventRelease` 挂接 `runtimeBindingId`
- 查询 `EventRelease` 时返回最小运行绑定摘要
- `launch` 返回新增 `runtime` 摘要块
当前目标不是让前端强依赖新字段,而是先让:
- release 和 runtime binding 接上
- `launch` 能把运行对象透出来
- 前后端可以开始验证运行对象链是活的
### 6.5 第四刀:发布闭环阶段
在第三刀已经完成:
- `MapRuntimeBinding -> EventRelease`
- `launch.runtime` 兼容透出
之后,下一步建议进入真正的**发布闭环阶段**。
目标:
- 不再要求“先 publish再手工 bind runtime”
- 改为 publish 时就能直接产出带 `runtimeBindingId` 的完整 `EventRelease`
- 保持当前旧接口和旧字段完全兼容
本步建议优先做:
#### A. publish/build 接口支持 `runtimeBindingId`
- 在当前发布链中允许显式传入 `runtimeBindingId`
- 如果传入,则发布完成后直接把 release 绑好 runtime
- 如果不传入,则继续保持当前兼容行为
#### B. workbench publish 面板接入 runtime 选择
-`/dev/workbench` 的发布操作区增加 `Runtime Binding` 选择
- 支持一条完整联调链:
- 选 release source / build
-`runtimeBindingId`
- publish
- 直接 launch 验证
#### C. release 查询继续返回 runtime 摘要
- `Get Release`
- `launch`
都继续透出当前最小 `runtime` 摘要,供前端和总控验证。
本步关键要求:
- 只加能力,不改旧语义
- 新流程优先,但旧流程继续可用
- 发布结果尽量原子,避免漏掉 runtime 挂接
---
## 7. 当前需要 backend 重点注意的边界
1. KML 只是输入源,不是最终业务对象
2. 活动不是素材仓库,活动只引用 `ContentBundle`
3. 地图上层必须有 `Place`,不要让 `MapAsset` 直接当最上层
4. 客户端最终必须只认 `EventRelease`
5. launch 返回必须落到具体:
- `placeId`
- `mapId`
- `tileReleaseId`
- `courseVariantId`
- `eventReleaseId`
---
## 8. 当前待 backend 回写确认
请 backend 线程后续重点回写以下确认:
1. 第一阶段表结构是否接受这套对象拆分
2. `Place / MapAsset / TileRelease / CourseSource / CourseSet / CourseVariant / MapRuntimeBinding` 是否按当前顺序推进
3. KML 导入链是否准备按 `CourseSource -> CourseVariant`
4. 活动是否接受拆成:
- `Event`
- `EventPresentation`
- `ContentBundle`
- `MapRuntimeBinding`
- `EventRelease`
5. `launch` 两阶段兼容方案是否按当前确认推进
6. workbench 是否按“第一阶段生产骨架联调台”接入,且只做 list / create / detail / binding
本轮新增执行项:
7. 是否按“第三刀最小接线”推进:
- `MapRuntimeBinding -> EventRelease`
- `launch.runtime` 摘要透出
- 继续保持旧字段兼容
8. 是否按“第四刀发布闭环”推进:
- publish 直接支持 `runtimeBindingId`
- workbench publish 面板增加 runtime 选择
- 继续保留“先 publish再 bind runtime”的兼容路径
9. 是否进入“活动运营域第二阶段”:
- `EventPresentation` 最小落库
- `ContentBundle` 最小落库
- `EventRelease` 明确绑定 `presentation / bundle / runtime`
10. 是否进入“活动运营域第二阶段第二刀”:
- `event detail` 透出最小 `presentation / bundle` 摘要
- `release detail` 透出最小 `presentation / bundle / runtime` 摘要
- `launch` 增加兼容性的 `presentation / contentBundle` 摘要块
- publish 在未显式传入时允许按 event 当前默认配置自动补齐 `presentation / bundle`
---
## 9. 一句话结论
本次给 backend 的实施要求很简单:
**先别继续围绕散装页面和散装配置推进,先把地图运行域和活动运营域的最小骨架搭起来。**
当前下一步重点已经进一步明确为:
**活动运营域第二阶段第三刀第一版已完成backend 下一步切到“展示定义统一导入与默认绑定”阶段。**
### 6.6 第五刀:前端正式接线阶段
当前 backend 已完成第四刀第一版:
- publish 直接支持 `runtimeBindingId`
- workbench publish 区支持直接填写 `Runtime Binding ID`
- 发布成功返回 `runtime`
- 旧的“先 publish再 bind runtime”路径继续兼容
因此下一步建议正式进入**前端接线阶段**。
目标:
- 前端开始正式消费 `launch.runtime`
- 活动准备页、地图页、结果页、历史页开始逐步展示运行对象摘要
- 继续保持旧字段兼容,不要求一轮切掉老逻辑
前端第一阶段建议优先做:
#### A. `launch.runtime` 消费
- 读取并缓存:
- `runtimeBindingId`
- `placeId`
- `mapId`
- `tileReleaseId`
- `courseSetId`
- `courseVariantId`
- 如后端已返回名称摘要,也同步接入:
- `placeName`
- `mapName`
- `routeCode`
#### B. 准备页与地图页最小展示
- 在准备页展示当前地点 / 地图 / 赛道摘要
- 在地图页调试或摘要区透出当前 runtime 对象
#### C. 结果与历史摘要逐步接入
- 先不强改所有列表
- 优先让单局结果页和历史详情页能看见:
- `place`
- `map`
- `variant`
- `routeCode`
当前阶段原则:
- 前端正式上场,但只接新增摘要,不推翻现有稳定页面主链
- `resolvedRelease / business / variant` 旧字段仍继续保留和可用
- 如果后端某些名称摘要尚未补齐,前端先按 ID + 已有字段兜底
### 6.7 第六刀之后的下一步:活动运营域第二阶段第二刀
当前 backend 已完成:
- `0009_event_ops_phase2.sql`
- `EventPresentation` 最小落库
- `ContentBundle` 最小落库
- `EventRelease` 已可绑定:
- `presentationId`
- `bundleId`
- `runtimeBindingId`
- publish 已支持显式挂接:
- `presentationId`
- `contentBundleId`
- `runtimeBindingId`
因此下一步建议进入“活动运营域第二阶段第二刀”。目标是:
-`EventPresentation / ContentBundle` 不只存在于后台和 publish 输入里
- 而是正式进入可查询、可验证、可消费的发布摘要
建议顺序:
#### A. `event detail` 透出当前展示与内容包摘要
建议最少返回:
- `currentPresentation`
- `presentationId`
- `templateKey`
- `version`
- `currentContentBundle`
- `bundleId`
- `bundleType`
- `version`
#### B. `Get Release` 透出完整最小摘要
建议 `release detail` 同时包含:
- `presentation`
- `contentBundle`
- `runtime`
前两者先以摘要形式返回,不先做复杂 schema 下发。
#### C. `launch` 增加兼容性的活动运营摘要块
在保持旧字段与当前 `runtime` 不变的前提下,新增:
- `presentation`
- `contentBundle`
建议最少包括:
- `presentationId`
- `templateKey`
- `bundleId`
- `bundleType`
#### D. publish 增加默认补齐逻辑
如果 publish 未显式传入:
- `presentationId`
- `contentBundleId`
允许按 event 当前默认配置自动补齐。
本步明确不建议做:
- 前端立即全面消费 presentation / bundle
- 复杂活动页面 schema 下发
- 内容包全量资源编排
- 正式后台大 UI
### 6.8 活动运营域第二阶段第三刀:发布摘要闭环与内容包导入入口
当前已确认:
- frontend 已完成“活动运营域摘要第一刀”
- 当前前端进入联调回归与小范围修复阶段
- backend 下一步不应继续围绕前端页面做字段补丁,而应继续把活动运营域本身做完整
本刀建议拆成两步,但按一个阶段推进。
#### A. 先把 release 摘要闭环
目标:
-`EventRelease` 真正成为活动运营域统一发布产物
- 保证以下几处返回的摘要语义一致:
- `event detail`
- `event play`
- `launch`
- `release detail`
建议 `release detail` 最少透出:
- `presentation`
- `presentationId`
- `templateKey`
- `version`
- `contentBundle`
- `bundleId`
- `bundleType`
- `version`
- `runtime`
- `runtimeBindingId`
- `placeId`
- `mapId`
- `tileReleaseId`
- `courseVariantId`
同时建议 `/dev/workbench` 的 release 查看区,能直接验证这三类摘要。
#### B. 再打开 `ContentBundle` 统一导入入口
目标:
- 不再只手工创建 `ContentBundle`
- 让后续静态资源、音频、动画、文创等内容,先通过统一导入入口进入 bundle
当前阶段建议只做“入口”和“元信息”,不做完整资源平台。
建议最小能力:
- 新增 `ContentBundle Import` 最小接口
- 接收:
- `bundleType`
- `sourceType`
- `manifestUrl` 或等价资源清单入口
- `version`
- `title`
- 创建后生成:
- `bundleId`
- `bundleType`
- `version`
- `assetManifest`
- `status`
当前明确不做:
- 复杂资源上传工作流
- 大文件管理台
- 资源审核流
- 全量 H5 schema 组装
关键原则:
- `ContentBundle` 先做统一导入入口,不先做复杂资源管理系统
- frontend 当前不消费资源明细,只继续认摘要
- 先把“发布对象完整”和“内容资源有正式入口”两件事做起来
### 6.9 活动运营域第二阶段第四刀:展示定义统一导入与默认绑定
当前已确认:
- backend 已完成:
- `event detail / play / launch / release detail` 活动运营摘要闭环
- `ContentBundle` 统一导入入口第一版
- frontend 当前不再扩新页面链,继续联调回归
所以下一步 backend 不建议继续围绕玩家侧摘要补字段,而应继续把活动运营域生产链做完整。
#### A. 打开 `EventPresentation` 统一导入入口
目标:
- 后续外部活动卡片/H5 搭建系统,不再只靠手工创建 presentation
- 而是通过统一入口把展示定义正式导入 backend
建议最小能力:
- 新增 `EventPresentation Import` 最小接口
- 接收:
- `templateKey`
- `sourceType`
- `schemaUrl` 或等价 schema 入口
- `version`
- `title`
- 创建后生成:
- `presentationId`
- `templateKey`
- `version`
- `schema`
- `status`
#### B. 固化 `Event` 当前默认 active 绑定
目标:
-`Event` 当前默认使用的:
- `presentation`
- `contentBundle`
- `runtimeBinding`
三者关系稳定下来
建议至少明确:
- `currentPresentationId`
- `currentContentBundleId`
- `currentRuntimeBindingId`
以及 publish 在未显式传入时,默认如何继承这三者。
#### C. `/dev/workbench` 增加最小验证
建议补:
- `Import Presentation`
- 查看 event 当前 active
- presentation
- bundle
- runtime
- 在 publish 区验证默认继承是否正确
当前明确不做:
- 复杂展示编辑器
- 全量 H5 schema 编排平台
- 大型资源后台
关键原则:
- `EventPresentation``ContentBundle` 都要有统一导入入口
- `Event` 继续做业务壳和默认绑定,不吞大资源
- 玩家前端继续只认发布摘要,不认后台草稿对象

137
t2f.md Normal file
View File

@@ -0,0 +1,137 @@
# T2F 协作清单
> 文档版本v1.6
> 最后更新2026-04-03 13:08:15
说明:
- 本文件由总控维护,写给前端线程
- 只写当前阶段实施说明,不写长讨论稿
- 正式架构与长期结论以 `doc/` 下文档为准
---
## 1. 当前目标
当前前端线程已完成**活动运营域摘要接线第一刀**,进入联调标准化配合与小范围修复阶段。
本阶段目标:
- 验证活动运营域摘要接线是否稳定
- 修正联调中发现的小范围字段、展示、一致性问题
- 使用 backend 当前统一的“一键测试环境”和稳定 demo 数据做回归
- 继续保持 runtime 主链稳定,不扩新页面链
---
## 2. 当前后端已完成能力
后端当前已完成:
- `GET /events/{eventPublicID}` 透出:
- `currentPresentation`
- `currentContentBundle`
- `GET /events/{eventPublicID}/play` 透出:
- `currentPresentation`
- `currentContentBundle`
- `POST /events/{eventPublicID}/launch` 透出:
- `launch.presentation`
- `launch.contentBundle`
- publish 在未显式传入:
- `presentationId`
- `contentBundleId`
时,可按 event 当前 active 配置自动补齐
- runtime 主链继续保持稳定兼容:
- `resolvedRelease`
- `business`
- `variant`
- `runtime`
- backend 当前测试能力已升级:
- `Bootstrap Demo`
- `一键补齐 Runtime 并发布`
- 分步日志 / 真实错误 / stack / 最后一次 curl / 预期判定
---
## 3. 当前已完成
### 3.1 活动详情页
已开始展示:
- `currentPresentation`
- `presentationId`
- `templateKey`
- `version`
- `currentContentBundle`
- `bundleId`
- `bundleType`
- `version`
当前仍保持活动运营摘要展示,不做复杂运营样式。
### 3.2 活动准备页
已在当前 runtime 预览摘要旁边补活动运营摘要:
- 当前展示版本
- 当前内容包版本
仍然只做摘要,不重构准备页结构。
### 3.3 launch 会话快照
以下字段已收进当前会话快照:
- `launch.presentation`
- `launch.contentBundle`
这样后续结果页、历史页如果需要继续透出,就不需要重新拼接。
### 3.4 当前阶段仍不做
- 不下发复杂 `schema`
- 不消费完整 `EventPresentation` 结构
- 不把 `ContentBundle` 展开成资源明细
- 不重构首页、结果页、历史页已有结构
---
## 4. 当前阶段原则
- 玩家面对的是前端,前端页面必须保持干净、利落、人性化
- 先接新增摘要,不重构整条前端主链
- `resolvedRelease / business / variant` 旧字段继续保留
- runtime 主链已经稳定,不要为了活动运营摘要去动 runtime 主链
- 先做“看得见活动运营对象”,不先做复杂运营化样式
- 当前进入联调回归阶段,优先修问题,不主动扩新页面入口
- 当前联调应优先复用 backend 一键测试环境,不再各自手工铺多份 demo 对象
---
## 5. 当前待前端回写
请前端线程后续重点回写:
1. 联调过程中是否发现字段缺失或命名不稳
2. 当前展示是否有明显歧义或信息层级问题
3. 是否需要后端补更多名称摘要或默认字段
4. 有没有因为活动运营摘要接线影响到 runtime 稳定主链
---
## 6. 当前总控确认
1. 活动运营域摘要第一刀视为已完成
2. 前端当前进入联调回归与小范围修复阶段
3. 当前只接受字段修正、摘要打磨、一致性修复
4. 不继续扩新页面链,不做复杂运营样式
5. 如果前端发现缺字段,再由总控统一回写给 backend
6. 当前前端下一步重点是配合 backend 的一键测试环境做稳定回归,不再新增玩家侧功能入口
---
## 7. 一句话结论
当前前端最重要的事不是继续扩新链,而是:
**把活动运营域摘要第一刀先稳住,并统一切到 backend 一键测试环境下做联调回归和小范围修复。**

View File

@@ -7,7 +7,9 @@ import { getGameModeDefaults } from '../miniprogram/game/core/gameModeDefaults'
import { GameRuntime } from '../miniprogram/game/core/gameRuntime' import { GameRuntime } from '../miniprogram/game/core/gameRuntime'
import { ScoreORule } from '../miniprogram/game/rules/scoreORule' import { ScoreORule } from '../miniprogram/game/rules/scoreORule'
import { resolveSystemSettingsState } from '../miniprogram/game/core/systemSettingsState' import { resolveSystemSettingsState } from '../miniprogram/game/core/systemSettingsState'
import { adaptBackendLaunchResultToEnvelope } from '../miniprogram/utils/backendLaunchAdapter'
import { type GameDefinition } from '../miniprogram/game/core/gameDefinition' import { type GameDefinition } from '../miniprogram/game/core/gameDefinition'
import { type BackendLaunchResult } from '../miniprogram/utils/backendApi'
import { type OrienteeringCourseData } from '../miniprogram/utils/orienteeringCourse' import { type OrienteeringCourseData } from '../miniprogram/utils/orienteeringCourse'
type StorageMap = Record<string, unknown> type StorageMap = Record<string, unknown>
@@ -297,6 +299,70 @@ function testRuntimeRestoreDefinition(): void {
assert(restoreResult.presentation.hud.punchButtonText === runtime.presentation.hud.punchButtonText, '恢复后 HUD 关键按钮文案应可重建') assert(restoreResult.presentation.hud.punchButtonText === runtime.presentation.hud.punchButtonText, '恢复后 HUD 关键按钮文案应可重建')
} }
function testLaunchRuntimeAdapter(): void {
const launchResult: BackendLaunchResult = {
event: {
id: 'evt_demo_variant_manual_001',
displayName: 'Manual Variant Demo',
},
launch: {
source: 'event',
config: {
configUrl: 'https://example.com/runtime.json',
configLabel: 'runtime demo',
releaseId: 'rel_runtime_001',
routeCode: 'route-variant-b',
},
business: {
source: 'direct-event',
eventId: 'evt_demo_variant_manual_001',
sessionId: 'sess_001',
sessionToken: 'token_001',
sessionTokenExpiresAt: '2026-04-03T16:00:00+08:00',
routeCode: 'route-variant-b',
},
variant: {
id: 'variant_b',
name: 'B 线',
routeCode: 'route-variant-b',
assignmentMode: 'manual',
},
runtime: {
runtimeBindingId: 'rtb_001',
placeId: 'place_campus',
placeName: '示范校园',
mapId: 'map_main',
mapName: '主图',
tileReleaseId: 'tile_rel_001',
courseSetId: 'course_set_001',
courseVariantId: 'variant_b',
routeCode: 'route-variant-b',
},
presentation: {
presentationId: 'pres_001',
templateKey: 'campus-v1',
version: 'v3',
},
contentBundle: {
bundleId: 'bundle_001',
bundleType: 'quiz-pack',
version: 'v7',
},
},
}
const envelope = adaptBackendLaunchResultToEnvelope(launchResult)
assert(!!envelope.runtime, 'launch.runtime 应映射到 GameLaunchEnvelope.runtime')
assert(envelope.runtime!.runtimeBindingId === 'rtb_001', 'runtimeBindingId 应正确适配')
assert(envelope.runtime!.placeName === '示范校园', 'placeName 应正确适配')
assert(envelope.runtime!.mapName === '主图', 'mapName 应正确适配')
assert(envelope.runtime!.courseVariantId === 'variant_b', 'courseVariantId 应正确适配')
assert(envelope.runtime!.routeCode === 'route-variant-b', 'runtime routeCode 应优先保留后端透出值')
assert(!!envelope.variant && envelope.variant.variantName === 'B 线', 'variant 摘要应继续保持兼容')
assert(!!envelope.presentation && envelope.presentation.presentationId === 'pres_001', 'launch.presentation 应映射到 GameLaunchEnvelope.presentation')
assert(!!envelope.contentBundle && envelope.contentBundle.bundleId === 'bundle_001', 'launch.contentBundle 应映射到 GameLaunchEnvelope.contentBundle')
}
function run(): void { function run(): void {
createWxStorage({}) createWxStorage({})
testControlInheritance() testControlInheritance()
@@ -305,6 +371,7 @@ function run(): void {
testTimeoutEndReason() testTimeoutEndReason()
testClassicSequentialSkipConfirmDefault() testClassicSequentialSkipConfirmDefault()
testRuntimeRestoreDefinition() testRuntimeRestoreDefinition()
testLaunchRuntimeAdapter()
console.log('runtime smoke tests passed') console.log('runtime smoke tests passed')
} }

1
typings/index.d.ts vendored
View File

@@ -7,6 +7,7 @@ interface IAppOption {
backendBaseUrl?: string | null, backendBaseUrl?: string | null,
backendAuthTokens?: import('../miniprogram/utils/backendAuth').BackendAuthTokens | null, backendAuthTokens?: import('../miniprogram/utils/backendAuth').BackendAuthTokens | null,
pendingResultSnapshot?: import('../miniprogram/engine/map/mapEngine').MapEngineResultSnapshot | null, pendingResultSnapshot?: import('../miniprogram/engine/map/mapEngine').MapEngineResultSnapshot | null,
pendingResultLaunchEnvelope?: import('../miniprogram/utils/gameLaunch').GameLaunchEnvelope | null,
pendingHeartRateAutoConnect?: { pendingHeartRateAutoConnect?: {
enabled: boolean, enabled: boolean,
deviceName?: string | null, deviceName?: string | null,