完善活动运营域与联调标准化
This commit is contained in:
102
b2f.md
102
b2f.md
@@ -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
443
b2t.md
Normal 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 目录中的导入接口说明
|
||||||
|
- 需要对方确认什么:
|
||||||
|
- 无
|
||||||
|
- 是否已解决:是
|
||||||
@@ -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,并输出逐步日志与预期判定
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -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. 对象模型建议
|
||||||
|
|
||||||
后台第一版建议围绕这些对象展开:
|
后台第一版建议围绕这些对象展开:
|
||||||
|
|||||||
@@ -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. 当前后续开发建议
|
||||||
|
|
||||||
文档整理完之后,后面建议按这个顺序继续:
|
文档整理完之后,后面建议按这个顺序继续:
|
||||||
|
|||||||
@@ -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
|
||||||
|
|
||||||
|
用途:
|
||||||
|
|
||||||
|
- 查看单个运行绑定详情
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -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 不要把玩法细节塞回事件主表
|
||||||
|
|||||||
@@ -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`
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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})
|
||||||
|
}
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
187
backend/internal/httpapi/handlers/admin_production_handler.go
Normal file
187
backend/internal/httpapi/handlers/admin_production_handler.go
Normal 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
@@ -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)
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ package service
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
|
"encoding/json"
|
||||||
"fmt"
|
"fmt"
|
||||||
"net/http"
|
"net/http"
|
||||||
"strings"
|
"strings"
|
||||||
@@ -32,6 +33,8 @@ type AdminEventReleaseRef struct {
|
|||||||
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 {
|
||||||
@@ -39,6 +42,11 @@ type AdminEventDetail struct {
|
|||||||
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 {
|
||||||
|
|||||||
@@ -24,6 +24,9 @@ type AdminReleaseView struct {
|
|||||||
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),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
935
backend/internal/service/admin_production_service.go
Normal file
935
backend/internal/service/admin_production_service.go
Normal 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"
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -62,6 +62,9 @@ type PublishedReleaseView struct {
|
|||||||
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 {
|
||||||
@@ -76,6 +79,9 @@ 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,
|
||||||
})
|
})
|
||||||
@@ -388,9 +410,158 @@ func (s *ConfigService) PublishBuild(ctx context.Context, input PublishBuildInpu
|
|||||||
},
|
},
|
||||||
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 == "" {
|
||||||
|
|||||||
@@ -34,6 +34,9 @@ type EventPlayResult struct {
|
|||||||
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"`
|
||||||
Play struct {
|
Play struct {
|
||||||
AssignmentMode *string `json:"assignmentMode,omitempty"`
|
AssignmentMode *string `json:"assignmentMode,omitempty"`
|
||||||
CourseVariants []CourseVariantView `json:"courseVariants,omitempty"`
|
CourseVariants []CourseVariantView `json:"courseVariants,omitempty"`
|
||||||
@@ -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])
|
||||||
|
|||||||
@@ -31,6 +31,9 @@ type EventDetailResult struct {
|
|||||||
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 {
|
||||||
@@ -51,6 +54,9 @@ type LaunchEventResult 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
|
||||||
|
|||||||
@@ -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
|
||||||
|
}
|
||||||
|
|||||||
@@ -31,6 +31,28 @@ type AdminEventRecord struct {
|
|||||||
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)
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
560
backend/internal/store/postgres/event_ops_store.go
Normal file
560
backend/internal/store/postgres/event_ops_store.go
Normal 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
|
||||||
|
}
|
||||||
@@ -23,6 +23,23 @@ 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 {
|
||||||
@@ -37,6 +54,23 @@ type EventRelease struct {
|
|||||||
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
|
||||||
|
}
|
||||||
|
|||||||
822
backend/internal/store/postgres/production_store.go
Normal file
822
backend/internal/store/postgres/production_store.go
Normal 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, ¢erPoint, &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, ¢erPoint, &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
|
||||||
|
}
|
||||||
185
backend/migrations/0008_production_skeleton.sql
Normal file
185
backend/migrations/0008_production_skeleton.sql
Normal 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;
|
||||||
55
backend/migrations/0009_event_ops_phase2.sql
Normal file
55
backend/migrations/0009_event_ops_phase2.sql
Normal 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;
|
||||||
12
backend/migrations/0010_event_default_bindings.sql
Normal file
12
backend/migrations/0010_event_default_bindings.sql
Normal 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;
|
||||||
1041
doc/backend/后台生产闭环架构草案.md
Normal file
1041
doc/backend/后台生产闭环架构草案.md
Normal file
File diff suppressed because it is too large
Load Diff
208
doc/backend/生产发布与数据库上线方案.md
Normal file
208
doc/backend/生产发布与数据库上线方案.md
Normal 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 验证**
|
||||||
|
- **最后再切客户端入口**
|
||||||
|
|
||||||
|
不要把开发测试数据库直接视为生产数据库。
|
||||||
@@ -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/` 下的正式文档
|
||||||
|
|
||||||
后续如果线程数量增加,或者联调链变复杂,优先仍然是:
|
后续如果线程数量增加,或者联调链变复杂,优先仍然是:
|
||||||
|
|||||||
98
doc/gameplay/活动运营域摘要第一刀联调回归清单.md
Normal file
98
doc/gameplay/活动运营域摘要第一刀联调回归清单.md
Normal 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 稳定主链。**
|
||||||
204
doc/gameplay/第五刀联调回归清单.md
Normal file
204
doc/gameplay/第五刀联调回归清单.md
Normal 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 摘要可同时回流
|
||||||
|
- 恢复链、结果链、首页摘要链不互相打架
|
||||||
12
doc/文档索引.md
12
doc/文档索引.md
@@ -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
87
f2b.md
@@ -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
165
f2t.md
Normal 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 主链隔离
|
||||||
|
- 缺字段降级
|
||||||
|
- 需要确认什么:
|
||||||
|
- 无
|
||||||
|
- 是否已解决:是
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 下一步
|
||||||
|
|
||||||
|
- 当前进入活动运营域摘要第一刀的联调回归与小范围修复阶段
|
||||||
@@ -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() {
|
||||||
|
|||||||
@@ -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,
|
||||||
})
|
})
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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),
|
||||||
})
|
})
|
||||||
},
|
},
|
||||||
|
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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 || [],
|
||||||
})
|
})
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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)),
|
||||||
})
|
})
|
||||||
},
|
},
|
||||||
|
|
||||||
|
|||||||
@@ -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({
|
||||||
|
|||||||
@@ -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),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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
836
t2b.md
Normal 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
137
t2f.md
Normal 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 一键测试环境下做联调回归和小范围修复。**
|
||||||
@@ -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
1
typings/index.d.ts
vendored
@@ -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,
|
||||||
|
|||||||
Reference in New Issue
Block a user