From 129ea935db9c0286069a7506b33c4d34890960f6 Mon Sep 17 00:00:00 2001 From: zhangyan Date: Fri, 3 Apr 2026 13:11:41 +0800 Subject: [PATCH] =?UTF-8?q?=E5=AE=8C=E5=96=84=E6=B4=BB=E5=8A=A8=E8=BF=90?= =?UTF-8?q?=E8=90=A5=E5=9F=9F=E4=B8=8E=E8=81=94=E8=B0=83=E6=A0=87=E5=87=86?= =?UTF-8?q?=E5=8C=96?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- b2f.md | 102 +- b2t.md | 443 +++++ backend/README.md | 20 +- backend/docs/后台管理最小方案.md | 16 +- backend/docs/开发说明.md | 242 ++- backend/docs/接口清单.md | 488 +++++- backend/docs/数据模型.md | 92 +- backend/docs/核心流程.md | 13 +- backend/internal/app/app.go | 3 +- .../httpapi/handlers/admin_event_handler.go | 118 ++ .../handlers/admin_pipeline_handler.go | 43 +- .../handlers/admin_production_handler.go | 187 +++ .../internal/httpapi/handlers/dev_handler.go | 1449 ++++++++++++++++- backend/internal/httpapi/router.go | 28 + .../internal/service/admin_event_service.go | 705 +++++++- .../service/admin_pipeline_service.go | 140 +- .../service/admin_production_service.go | 935 +++++++++++ backend/internal/service/config_service.go | 185 ++- .../internal/service/event_play_service.go | 20 +- backend/internal/service/event_service.go | 40 +- backend/internal/service/release_view.go | 226 ++- .../store/postgres/admin_event_store.go | 164 +- backend/internal/store/postgres/dev_store.go | 164 ++ .../store/postgres/event_ops_store.go | 560 +++++++ .../internal/store/postgres/event_store.go | 287 +++- .../store/postgres/production_store.go | 822 ++++++++++ .../migrations/0008_production_skeleton.sql | 185 +++ backend/migrations/0009_event_ops_phase2.sql | 55 + .../0010_event_default_bindings.sql | 12 + doc/backend/后台生产闭环架构草案.md | 1041 ++++++++++++ doc/backend/生产发布与数据库上线方案.md | 208 +++ doc/gameplay/多线程联调协作方式.md | 100 +- .../活动运营域摘要第一刀联调回归清单.md | 98 ++ doc/gameplay/第五刀联调回归清单.md | 204 +++ doc/文档索引.md | 12 +- f2b.md | 87 +- f2t.md | 165 ++ miniprogram/app.ts | 1 + .../pages/event-prepare/event-prepare.ts | 46 + .../pages/event-prepare/event-prepare.wxml | 34 + miniprogram/pages/event/event.ts | 30 + miniprogram/pages/event/event.wxml | 2 + miniprogram/pages/home/home.ts | 18 + miniprogram/pages/home/home.wxml | 2 + miniprogram/pages/map/map.ts | 23 +- miniprogram/pages/result/result.ts | 73 +- miniprogram/pages/results/results.ts | 14 + miniprogram/pages/results/results.wxml | 1 + miniprogram/utils/backendApi.ts | 31 + miniprogram/utils/backendLaunchAdapter.ts | 31 + miniprogram/utils/gameLaunch.ts | 119 ++ readme-develop.md | 75 +- t2b.md | 836 ++++++++++ t2f.md | 137 ++ tools/runtime-smoke-test.ts | 67 + typings/index.d.ts | 1 + 56 files changed, 11004 insertions(+), 196 deletions(-) create mode 100644 b2t.md create mode 100644 backend/internal/httpapi/handlers/admin_production_handler.go create mode 100644 backend/internal/service/admin_production_service.go create mode 100644 backend/internal/store/postgres/event_ops_store.go create mode 100644 backend/internal/store/postgres/production_store.go create mode 100644 backend/migrations/0008_production_skeleton.sql create mode 100644 backend/migrations/0009_event_ops_phase2.sql create mode 100644 backend/migrations/0010_event_default_bindings.sql create mode 100644 doc/backend/后台生产闭环架构草案.md create mode 100644 doc/backend/生产发布与数据库上线方案.md create mode 100644 doc/gameplay/活动运营域摘要第一刀联调回归清单.md create mode 100644 doc/gameplay/第五刀联调回归清单.md create mode 100644 f2t.md create mode 100644 t2b.md create mode 100644 t2f.md diff --git a/b2f.md b/b2f.md index 4bb3881..7dc55f2 100644 --- a/b2f.md +++ b/b2f.md @@ -1,6 +1,6 @@ # b2f -> 文档版本:v1.3 -> 最后更新:2026-04-02 15:25:40 +> 文档版本:v1.7 +> 最后更新: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 - 时间: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` +- 是否已解决:是 + --- ## 下一步 diff --git a/b2t.md b/b2t.md new file mode 100644 index 0000000..d0b104b --- /dev/null +++ b/b2t.md @@ -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 目录中的导入接口说明 +- 需要对方确认什么: + - 无 +- 是否已解决:是 diff --git a/backend/README.md b/backend/README.md index 5a2a133..7ca7a09 100644 --- a/backend/README.md +++ b/backend/README.md @@ -1,6 +1,6 @@ # Backend -> 文档版本:v1.1 -> 最后更新:2026-04-02 09:35:44 +> 文档版本:v1.11 +> 最后更新:2026-04-03 13:04:32 这套后端现在已经能支撑一条完整主链: @@ -34,7 +34,7 @@ ```powershell 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` - 局生命周期:`start / finish / detail` - 局后结果:`/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` - 用户主链调试 - 资源对象与 Event 组装调试 - 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,并输出逐步日志与预期判定 diff --git a/backend/docs/后台管理最小方案.md b/backend/docs/后台管理最小方案.md index 5f88c5e..5c84ecc 100644 --- a/backend/docs/后台管理最小方案.md +++ b/backend/docs/后台管理最小方案.md @@ -1,6 +1,6 @@ # 后台管理最小方案 -> 文档版本:v1.0 -> 最后更新:2026-04-02 09:01:17 +> 文档版本:v1.1 +> 最后更新:2026-04-03 11:02:42 ## 1. 目标 @@ -132,6 +132,8 @@ - 当前选用资源包版本 - 当前玩法模式 - 少量覆盖项 +- 展示定义(`EventPresentation`) +- 内容包(`ContentBundle`) 第一版只开放少量覆盖项: @@ -160,6 +162,7 @@ - build 状态 - release 列表 - 当前生效 release +- 当前绑定的 `presentation / bundle / runtime` - 发布人 - 发布时间 @@ -169,6 +172,7 @@ - 查看 build 产物 - 发布 build - 回滚当前 release +- 查看 release 当前绑定的 `presentation / bundle / runtime` ## 4. 后台第一版页面建议 @@ -183,6 +187,14 @@ 这 6 页够把“资源录入 -> Event 组装 -> 发布 -> launch”跑通。 +补充: + +- 当前第二阶段已经把 `EventPresentation` 和 `ContentBundle` 收成正式最小对象 +- `EventRelease` 现在允许同时绑定: + - `presentation` + - `content bundle` + - `runtime binding` + ## 5. 对象模型建议 后台第一版建议围绕这些对象展开: diff --git a/backend/docs/开发说明.md b/backend/docs/开发说明.md index 65f7352..8d566bb 100644 --- a/backend/docs/开发说明.md +++ b/backend/docs/开发说明.md @@ -1,6 +1,6 @@ # 开发说明 -> 文档版本:v1.1 -> 最后更新:2026-04-02 09:35:44 +> 文档版本:v1.12 +> 最后更新:2026-04-03 13:04:32 ## 1. 环境变量 @@ -29,7 +29,7 @@ ```powershell cd D:\dev\cmr-mini\backend -go run .\cmd\api +.\start-backend.ps1 ``` 如果你想固定跑开发工作台常用端口 `18090`,直接执行: @@ -55,6 +55,55 @@ cd D:\dev\cmr-mini\backend - 用户主链:`bootstrap -> auth -> entry/home -> event play/launch -> session -> result` - 后台运营链:`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. 当前开发约定 @@ -134,6 +183,7 @@ Redis 后面只在需要性能优化、限流或短期票据缓存时再接。 - 入口解析 - 首页聚合 - event play +- 第一阶段生产骨架对象 - 配置导入、preview build、publish build - launch - session start / finish @@ -144,6 +194,11 @@ Redis 后面只在需要性能优化、限流或短期票据缓存时再接。 - `publish build` 现在会真实上传 `manifest.json` 和 `asset-index.json` 到 OSS - 如果上传失败,接口会直接报错,不再出现“数据库里已有 release,但 OSS 上没有对象”的假成功 +- `Save Event Defaults` 会把当前 event 的默认 active 绑定写入: + - `currentPresentationId` + - `currentContentBundleId` + - `currentRuntimeBindingId` +- 之后 `Publish Build` 如果不显式填写这三项,会优先继承 event 默认 active 绑定 并且支持: @@ -152,6 +207,29 @@ Redis 后面只在需要性能优化、限流或短期票据缓存时再接。 - curl 导出 - request history +当前第一阶段生产骨架联调台只做: + +- `list` +- `create` +- `detail` +- `binding` + +明确不做: + +- 正式后台 UI +- `edit` +- `delete` +- `batch` +- 审核流 + +活动运营域第二阶段当前也只做最小动作: + +- `list` +- `create` +- `detail` +- `publish 绑定` +- `import` + ## 6. 当前推荐联调顺序 ### 场景一:小程序快速进入 @@ -190,6 +268,164 @@ Redis 后面只在需要性能优化、限流或短期票据缓存时再接。 5. `events/{id}` 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. 当前后续开发建议 文档整理完之后,后面建议按这个顺序继续: diff --git a/backend/docs/接口清单.md b/backend/docs/接口清单.md index 2af383f..270bd16 100644 --- a/backend/docs/接口清单.md +++ b/backend/docs/接口清单.md @@ -1,6 +1,6 @@ # API 清单 -> 文档版本:v1.1 -> 最后更新:2026-04-02 11:05:32 +> 文档版本:v1.8 +> 最后更新:2026-04-03 12:36:15 本文档只记录当前 backend 已实现接口,不写未来规划接口。 @@ -140,6 +140,18 @@ - `event` - `release` - `resolvedRelease` +- `runtime` +- `currentPresentation` +- `currentContentBundle` + +当前摘要字段最少包括: + +- `currentPresentation.presentationId` +- `currentPresentation.templateKey` +- `currentPresentation.version` +- `currentContentBundle.contentBundleId` +- `currentContentBundle.bundleType` +- `currentContentBundle.version` ### `GET /events/{eventPublicID}/play` @@ -156,6 +168,9 @@ - `event` - `release` - `resolvedRelease` +- `runtime` +- `currentPresentation` +- `currentContentBundle` - `play.assignmentMode` - `play.courseVariants` - `play.canLaunch` @@ -164,6 +179,15 @@ - `play.ongoingSession` - `play.recentSession` +当前摘要字段最少包括: + +- `currentPresentation.presentationId` +- `currentPresentation.templateKey` +- `currentPresentation.version` +- `currentContentBundle.contentBundleId` +- `currentContentBundle.bundleType` +- `currentContentBundle.version` + ### `POST /events/{eventPublicID}/launch` 鉴权: @@ -192,10 +216,33 @@ - `launch.source` - `launch.resolvedRelease` - `launch.variant` +- `launch.runtime` +- `launch.presentation` +- `launch.contentBundle` - `launch.config` - `launch.business.sessionId` - `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` 鉴权: @@ -466,12 +513,16 @@ 请求体重点: - `buildId` +- `runtimeBindingId` 可选 +- `presentationId` 可选 +- `contentBundleId` 可选 返回重点: - `release.releaseId` - `release.manifestUrl` - `release.configLabel` +- `runtime.runtimeBindingId` 可选 ## 9. Admin 资源对象 @@ -738,6 +789,145 @@ - `overrides` - `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` 鉴权: @@ -782,6 +972,57 @@ - 将某次成功 build 发布成正式 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` @@ -797,4 +1038,247 @@ - `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 + +用途: + +- 查看单个运行绑定详情 + diff --git a/backend/docs/数据模型.md b/backend/docs/数据模型.md index 989ced0..6d7b9af 100644 --- a/backend/docs/数据模型.md +++ b/backend/docs/数据模型.md @@ -1,9 +1,8 @@ # 数据模型 -> 文档版本:v1.0 -> 最后更新:2026-04-02 08:28:05 +> 文档版本:v1.3 +> 最后更新:2026-04-03 12:36:15 - -当前 migration 共 6 版。 +当前 migration 共 10 版。 ## 1. 迁移清单 @@ -13,6 +12,10 @@ - [0004_results.sql](D:/dev/cmr-mini/backend/migrations/0004_results.sql) - [0005_config_pipeline.sql](D:/dev/cmr-mini/backend/migrations/0005_config_pipeline.sql) - [0006_resource_objects.sql](D:/dev/cmr-mini/backend/migrations/0006_resource_objects.sql) +- [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. 表分组 @@ -114,6 +117,51 @@ - 支撑后台第一版按“资源对象 + 版本”管理 - 给后续 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. 当前最关键的关系 ### `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.1 不要把玩法细节塞回事件主表 diff --git a/backend/docs/核心流程.md b/backend/docs/核心流程.md index 77f5383..cacb2db 100644 --- a/backend/docs/核心流程.md +++ b/backend/docs/核心流程.md @@ -1,6 +1,6 @@ # 核心流程 -> 文档版本:v1.1 -> 最后更新:2026-04-02 11:03:02 +> 文档版本:v1.2 +> 最后更新:2026-04-03 11:22:50 ## 1. 总流程 @@ -113,6 +113,8 @@ APP 当前主链是手机号验证码: - `event` - `release` - `resolvedRelease` +- `currentPresentation` +- `currentContentBundle` - `play.assignmentMode` - `play.courseVariants[]` - `play.canLaunch` @@ -160,6 +162,8 @@ APP 当前主链是手机号验证码: - `launch.source` - `launch.resolvedRelease` - `launch.variant` +- `launch.presentation` +- `launch.contentBundle` - `launch.config` - `launch.business.sessionId` - `launch.business.sessionToken` @@ -179,6 +183,11 @@ APP 当前主链是手机号验证码: - `launch.variant.id` - `launch.variant.assignmentMode` +活动运营域第二阶段第二刀新增建议消费摘要: + +- `launch.presentation.presentationId` +- `launch.contentBundle.contentBundleId` + 补充说明: - 如果活动声明了多赛道 variant,`launch` 会返回本局最终绑定的 `variant` diff --git a/backend/internal/app/app.go b/backend/internal/app/app.go index a3a6802..cff1c6c 100644 --- a/backend/internal/app/app.go +++ b/backend/internal/app/app.go @@ -38,6 +38,7 @@ func New(ctx context.Context, cfg Config) (*App, error) { entryService := service.NewEntryService(store) entryHomeService := service.NewEntryHomeService(store) adminResourceService := service.NewAdminResourceService(store) + adminProductionService := service.NewAdminProductionService(store) adminEventService := service.NewAdminEventService(store) eventService := service.NewEventService(store) eventPlayService := service.NewEventPlayService(store) @@ -50,7 +51,7 @@ func New(ctx context.Context, cfg Config) (*App, error) { sessionService := service.NewSessionService(store) devService := service.NewDevService(cfg.AppEnv, 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{ router: router, diff --git a/backend/internal/httpapi/handlers/admin_event_handler.go b/backend/internal/httpapi/handlers/admin_event_handler.go index 4780334..1a216cf 100644 --- a/backend/internal/httpapi/handlers/admin_event_handler.go +++ b/backend/internal/httpapi/handlers/admin_event_handler.go @@ -82,3 +82,121 @@ func (h *AdminEventHandler) SaveSource(w http.ResponseWriter, r *http.Request) { } 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}) +} diff --git a/backend/internal/httpapi/handlers/admin_pipeline_handler.go b/backend/internal/httpapi/handlers/admin_pipeline_handler.go index 28a6ba4..c6192d8 100644 --- a/backend/internal/httpapi/handlers/admin_pipeline_handler.go +++ b/backend/internal/httpapi/handlers/admin_pipeline_handler.go @@ -1,8 +1,10 @@ package handlers import ( + "io" "net/http" "strconv" + "strings" "cmr-backend/internal/apperr" "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) { - 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 { httpx.WriteError(w, err) return diff --git a/backend/internal/httpapi/handlers/admin_production_handler.go b/backend/internal/httpapi/handlers/admin_production_handler.go new file mode 100644 index 0000000..17c4f94 --- /dev/null +++ b/backend/internal/httpapi/handlers/admin_production_handler.go @@ -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}) +} diff --git a/backend/internal/httpapi/handlers/dev_handler.go b/backend/internal/httpapi/handlers/dev_handler.go index d82666c..528e554 100644 --- a/backend/internal/httpapi/handlers/dev_handler.go +++ b/backend/internal/httpapi/handlers/dev_handler.go @@ -488,7 +488,7 @@ const devWorkbenchHTML = `

本地配置导入与发布

-

从本地 event 目录导入 source config,生成 preview build,并可直接发布成当前 release。

+

从本地 event 目录导入 source config,生成 preview build,并可在发布时直接挂接 runtime binding。

+
+ +
第四刀发布闭环:publish 时可直接带 runtimeBindingId,旧发布路径继续可用。
+
+
+ + +
@@ -704,13 +718,13 @@ const devWorkbenchHTML = `
- +
+ +
第四刀:发布时可直接带 runtime binding;旧的“先发布再绑定”路径继续保留。
+
+
+ + +
+
+ +
+
不填发布参数时,后端会先继承当前 Event 的默认 active:presentation / content bundle / runtime。
+ +
@@ -1256,10 +1646,10 @@ const devWorkbenchHTML = `
POST/dev/config-builds/publish
-
把成功的 build 发布成正式 release,并自动切换成当前 event 的可启动版本。
+
把成功的 build 发布成正式 release,并可选直接挂接 runtime binding。
鉴权:仅 non-production,无需鉴权
-
关键参数:buildId
+
关键参数:buildIdruntimeBindingId
@@ -1293,6 +1683,117 @@ const devWorkbenchHTML = ` +
+
GET/admin/places
+
第一阶段生产骨架的地点对象列表接口。
+
鉴权:Bearer token
+
+ +
+
POST/admin/places
+
创建地点对象,作为地图资产的上层归属。
+
+
鉴权:Bearer token
+
关键参数:codenameregionstatus
+
+
+ +
+
GET/admin/places/{placePublicID}
+
查看地点详情,并带出该地点下的地图资产列表。
+
鉴权:Bearer token
+
+ +
+
POST/admin/places/{placePublicID}/map-assets
+
在指定地点下创建地图资产,可选挂接已有 legacy map。
+
+
鉴权:Bearer token
+
关键参数:codenamemapTypelegacyMapId
+
+
+ +
+
GET/admin/map-assets/{mapAssetPublicID}
+
查看地图资产详情,带出瓦片版本和赛道集合摘要。
+
鉴权:Bearer token
+
+ +
+
POST/admin/map-assets/{mapAssetPublicID}/tile-releases
+
为地图资产创建瓦片版本,可选关联已有 legacy map version。
+
+
鉴权:Bearer token
+
关键参数:versionCodetileBaseUrlmetaUrlsetAsCurrent
+
+
+ +
+
GET/admin/course-sources
+
查看赛道原始输入源列表,承接 KML / GeoJSON 等输入。
+
鉴权:Bearer token
+
+ +
+
POST/admin/course-sources
+
创建赛道输入源,为后续解析成 CourseVariant 做准备。
+
+
鉴权:Bearer token
+
关键参数:sourceTypefileUrllegacyPlayfieldIdlegacyVersionId
+
+
+ +
+
GET/admin/course-sources/{sourcePublicID}
+
查看单个赛道输入源详情。
+
鉴权:Bearer token
+
+ +
+
POST/admin/map-assets/{mapAssetPublicID}/course-sets
+
在指定地图资产下创建赛道集合。
+
+
鉴权:Bearer token
+
关键参数:codemodenamestatus
+
+
+ +
+
GET/admin/course-sets/{courseSetPublicID}
+
查看单个赛道集合详情和 variant 列表。
+
鉴权:Bearer token
+
+ +
+
POST/admin/course-sets/{courseSetPublicID}/variants
+
为赛道集合创建具体可运行赛道方案。
+
+
鉴权:Bearer token
+
关键参数:sourceIdnamerouteCodemodeisDefault
+
+
+ +
+
GET/admin/runtime-bindings
+
查看活动运行绑定列表。
+
鉴权:Bearer token
+
+ +
+
POST/admin/runtime-bindings
+
把活动和地点、地图资产、瓦片、赛道集合、variant 绑定起来。
+
+
鉴权:Bearer token
+
关键参数:eventIdplaceIdmapAssetIdtileReleaseIdcourseSetIdcourseVariantId
+
+
+ +
+
GET/admin/runtime-bindings/{runtimeBindingPublicID}
+
查看单个运行绑定详情。
+
鉴权:Bearer token
+
+
GET/admin/playfields
后台赛场对象列表接口。
@@ -1392,6 +1893,75 @@ const devWorkbenchHTML = `
+
+
GET/admin/events/{eventPublicID}/presentations
+
查看某个 event 下的展示定义列表。
+
鉴权:Bearer token
+
+ +
+
POST/admin/events/{eventPublicID}/presentations
+
为 event 创建一条最小 presentation 定义,供 release 绑定使用。
+
+
鉴权:Bearer token
+
关键参数:codenamepresentationTypeschema
+
+
+ +
+
POST/admin/events/{eventPublicID}/presentations/import
+
通过统一导入入口为 event 创建展示定义,先记录 templateKey、sourceType、schemaUrl、version 和 title。
+
+
鉴权:Bearer token
+
关键参数:titletemplateKeysourceTypeschemaUrlversion
+
+
+ +
+
GET/admin/presentations/{presentationPublicID}
+
查看单条 presentation 明细。
+
鉴权:Bearer token
+
+ +
+
GET/admin/events/{eventPublicID}/content-bundles
+
查看某个 event 下的内容包列表。
+
鉴权:Bearer token
+
+ +
+
POST/admin/events/{eventPublicID}/content-bundles
+
为 event 创建一条最小 content bundle,供 release 绑定使用。
+
+
鉴权:Bearer token
+
关键参数:codenameentryUrlassetRootUrlmetadata
+
+
+ +
+
POST/admin/events/{eventPublicID}/content-bundles/import
+
通过统一导入入口为 event 创建内容包,先记录 bundleType、sourceType、manifestUrl、version 和 assetManifest。
+
+
鉴权:Bearer token
+
关键参数:titlebundleTypesourceTypemanifestUrlversion
+
+
+ +
+
GET/admin/content-bundles/{contentBundlePublicID}
+
查看单条内容包明细。
+
鉴权:Bearer token
+
+ +
+
POST/admin/events/{eventPublicID}/defaults
+
固化 event 当前默认 active 绑定,供后续 publish 在未显式传参时继承。
+
+
鉴权:Bearer token
+
关键参数:presentationIdcontentBundleIdruntimeBindingId
+
+
+
GET/admin/events/{eventPublicID}/pipeline
查看 event 下的 source、build、release 流水线概览。
@@ -1410,12 +1980,30 @@ const devWorkbenchHTML = `
鉴权:Bearer token
-
+
POST/admin/builds/{buildID}/publish
-
把后台 build 发布为正式 release,并切换为 event 当前发布版本。
+
把后台 build 发布为正式 release,可选直接挂接 runtime binding、presentation 和内容包,并切换为 event 当前发布版本。
+
+
鉴权:Bearer token
+
关键参数:runtimeBindingIdpresentationIdcontentBundleId
+
+
+ +
+
GET/admin/releases/{releasePublicID}
+
查看单个 release 明细,并带出当前已挂接的 runtime 摘要。
鉴权:Bearer token
+
+
POST/admin/releases/{releasePublicID}/runtime-binding
+
把某个 runtime binding 挂接到指定 release,上游 launch 会透出新的 runtime 摘要。
+
+
鉴权:Bearer token
+
关键参数:runtimeBindingId
+
+
+
POST/admin/events/{eventPublicID}/rollback
将 event 当前发布版本回滚到指定 releaseId。
@@ -1608,6 +2196,217 @@ const devWorkbenchHTML = ` persistState(); } + function setDefaultPublishExpectation(result) { + const release = result || {}; + const releaseId = release.id || '-'; + const presentationId = release.presentation && release.presentation.presentationId ? release.presentation.presentationId : '-'; + const contentBundleId = release.contentBundle && release.contentBundle.contentBundleId ? release.contentBundle.contentBundleId : '-'; + const runtimeBindingId = release.runtime && release.runtime.runtimeBindingId ? release.runtime.runtimeBindingId : '-'; + const expectedRuntime = trimmedOrUndefined($('admin-release-runtime-binding-id').value || $('prod-runtime-binding-id').value); + const hasPresentation = presentationId !== '-'; + const hasContentBundle = contentBundleId !== '-'; + const runtimeSatisfied = expectedRuntime ? runtimeBindingId !== '-' : true; + let verdict = '未通过'; + if (hasPresentation && hasContentBundle && runtimeSatisfied) { + verdict = expectedRuntime ? '通过:presentation / content bundle / runtime 已继承' : '通过:presentation / content bundle 已继承,runtime 未配置'; + } + $('flow-admin-release-result').textContent = releaseId; + $('flow-admin-presentation-result').textContent = presentationId; + $('flow-admin-content-bundle-result').textContent = contentBundleId; + $('flow-admin-runtime-result').textContent = runtimeBindingId; + $('flow-admin-verdict').textContent = verdict; + } + + async function runAdminDefaultPublishFlow(options) { + const ensureRuntime = options && options.ensureRuntime === true; + const flowTitle = ensureRuntime ? 'flow-admin-runtime-publish' : 'flow-admin-default-publish'; + const eventId = $('admin-event-ref-id').value || $('event-id').value; + if (!trimmedOrUndefined(eventId)) { + throw new Error('admin event id is required'); + } + + if (ensureRuntime) { + writeLog(flowTitle + '.step', { step: 'bootstrap-demo' }); + const bootstrap = await request('POST', '/dev/bootstrap-demo'); + if (bootstrap.data) { + state.sourceId = bootstrap.data.sourceId || state.sourceId; + state.buildId = bootstrap.data.buildId || state.buildId; + state.releaseId = bootstrap.data.releaseId || state.releaseId; + $('admin-pipeline-source-id').value = bootstrap.data.sourceId || $('admin-pipeline-source-id').value; + $('admin-pipeline-build-id').value = bootstrap.data.buildId || $('admin-pipeline-build-id').value; + $('admin-pipeline-release-id').value = bootstrap.data.releaseId || $('admin-pipeline-release-id').value; + $('prod-runtime-event-id').value = bootstrap.data.eventId || $('prod-runtime-event-id').value; + $('prod-place-id').value = bootstrap.data.placeId || $('prod-place-id').value; + $('prod-map-asset-id').value = bootstrap.data.mapAssetId || $('prod-map-asset-id').value; + $('prod-tile-release-id').value = bootstrap.data.tileReleaseId || $('prod-tile-release-id').value; + $('prod-course-source-id').value = bootstrap.data.courseSourceId || $('prod-course-source-id').value; + $('prod-course-set-id').value = bootstrap.data.courseSetId || $('prod-course-set-id').value; + $('prod-course-variant-id').value = bootstrap.data.courseVariantId || $('prod-course-variant-id').value; + $('prod-runtime-binding-id').value = bootstrap.data.runtimeBindingId || $('prod-runtime-binding-id').value; + } + } + + writeLog(flowTitle + '.step', { step: 'get-event', eventId: eventId }); + const eventDetail = await request('GET', '/admin/events/' + encodeURIComponent(eventId), undefined, true); + if (eventDetail.data && eventDetail.data.event) { + $('admin-event-ref-id').value = eventDetail.data.event.id || $('admin-event-ref-id').value; + $('event-id').value = eventDetail.data.event.id || $('event-id').value; + $('prod-runtime-event-id').value = eventDetail.data.event.id || $('prod-runtime-event-id').value; + if (eventDetail.data.latestSource && eventDetail.data.latestSource.id) { + state.sourceId = eventDetail.data.latestSource.id; + $('admin-pipeline-source-id').value = eventDetail.data.latestSource.id; + } + if (eventDetail.data.currentRuntime && eventDetail.data.currentRuntime.runtimeBindingId) { + $('admin-release-runtime-binding-id').value = eventDetail.data.currentRuntime.runtimeBindingId; + $('prod-runtime-binding-id').value = eventDetail.data.currentRuntime.runtimeBindingId; + } + } + + writeLog(flowTitle + '.step', { step: 'import-presentation', eventId: eventId }); + const importedPresentation = await request('POST', '/admin/events/' + encodeURIComponent(eventId) + '/presentations/import', { + title: $('admin-presentation-import-title').value, + templateKey: $('admin-presentation-import-template-key').value, + sourceType: $('admin-presentation-import-source-type').value, + schemaUrl: $('admin-presentation-import-schema-url').value, + version: $('admin-presentation-import-version').value, + status: 'active', + isDefault: true + }, true); + if (importedPresentation.data) { + $('admin-presentation-id').value = importedPresentation.data.id || $('admin-presentation-id').value; + $('admin-release-presentation-id').value = importedPresentation.data.id || $('admin-release-presentation-id').value; + $('config-presentation-id').value = importedPresentation.data.id || $('config-presentation-id').value; + } + + writeLog(flowTitle + '.step', { step: 'import-bundle', eventId: eventId }); + const importedBundle = await request('POST', '/admin/events/' + encodeURIComponent(eventId) + '/content-bundles/import', { + title: $('admin-content-import-title').value, + bundleType: $('admin-content-import-bundle-type').value, + sourceType: $('admin-content-import-source-type').value, + manifestUrl: $('admin-content-import-manifest-url').value, + version: $('admin-content-import-version').value, + status: 'active', + isDefault: true, + assetManifest: parseJSONObjectOrUndefined($('admin-content-import-asset-manifest-json').value, 'Content Asset Manifest JSON') + }, true); + if (importedBundle.data) { + $('admin-content-bundle-id').value = importedBundle.data.id || $('admin-content-bundle-id').value; + $('admin-release-content-bundle-id').value = importedBundle.data.id || $('admin-release-content-bundle-id').value; + $('config-content-bundle-id').value = importedBundle.data.id || $('config-content-bundle-id').value; + } + + if (ensureRuntime && !trimmedOrUndefined($('admin-release-runtime-binding-id').value || $('prod-runtime-binding-id').value)) { + const missing = []; + if (!trimmedOrUndefined($('prod-place-id').value)) { + missing.push('Place ID'); + } + if (!trimmedOrUndefined($('prod-map-asset-id').value)) { + missing.push('Map Asset ID'); + } + if (!trimmedOrUndefined($('prod-tile-release-id').value)) { + missing.push('Tile Release ID'); + } + if (!trimmedOrUndefined($('prod-course-set-id').value)) { + missing.push('Course Set ID'); + } + if (!trimmedOrUndefined($('prod-course-variant-id').value)) { + missing.push('Course Variant ID'); + } + if (missing.length > 0) { + throw new Error('创建 runtime binding 前缺少字段: ' + missing.join(', ')); + } + + writeLog(flowTitle + '.step', { + step: 'create-runtime-binding', + eventId: eventId, + placeId: $('prod-place-id').value, + mapAssetId: $('prod-map-asset-id').value, + tileReleaseId: $('prod-tile-release-id').value, + courseSetId: $('prod-course-set-id').value, + courseVariantId: $('prod-course-variant-id').value + }); + const createdRuntime = await request('POST', '/admin/runtime-bindings', { + eventId: $('prod-runtime-event-id').value || eventId, + placeId: $('prod-place-id').value, + mapAssetId: $('prod-map-asset-id').value, + tileReleaseId: $('prod-tile-release-id').value, + courseSetId: $('prod-course-set-id').value, + courseVariantId: $('prod-course-variant-id').value, + status: $('prod-runtime-binding-status').value, + notes: trimmedOrUndefined($('prod-runtime-notes').value) + }, true); + if (createdRuntime.data && createdRuntime.data.id) { + $('prod-runtime-binding-id').value = createdRuntime.data.id; + $('admin-release-runtime-binding-id').value = createdRuntime.data.id; + } + } + + writeLog(flowTitle + '.step', { step: 'save-defaults', eventId: eventId }); + const defaults = await request('POST', '/admin/events/' + encodeURIComponent(eventId) + '/defaults', { + presentationId: trimmedOrUndefined($('admin-release-presentation-id').value), + contentBundleId: trimmedOrUndefined($('admin-release-content-bundle-id').value), + runtimeBindingId: trimmedOrUndefined($('admin-release-runtime-binding-id').value || $('prod-runtime-binding-id').value) + }, true); + if (defaults.data && defaults.data.currentPresentation && defaults.data.currentPresentation.presentationId) { + $('admin-presentation-id').value = defaults.data.currentPresentation.presentationId; + $('admin-release-presentation-id').value = defaults.data.currentPresentation.presentationId; + $('config-presentation-id').value = defaults.data.currentPresentation.presentationId; + } + if (defaults.data && defaults.data.currentContentBundle && defaults.data.currentContentBundle.contentBundleId) { + $('admin-content-bundle-id').value = defaults.data.currentContentBundle.contentBundleId; + $('admin-release-content-bundle-id').value = defaults.data.currentContentBundle.contentBundleId; + $('config-content-bundle-id').value = defaults.data.currentContentBundle.contentBundleId; + } + if (defaults.data && defaults.data.currentRuntime && defaults.data.currentRuntime.runtimeBindingId) { + $('admin-release-runtime-binding-id').value = defaults.data.currentRuntime.runtimeBindingId; + $('prod-runtime-binding-id').value = defaults.data.currentRuntime.runtimeBindingId; + } + + const sourceId = $('admin-pipeline-source-id').value || state.sourceId; + if (!trimmedOrUndefined(sourceId)) { + throw new Error('no source id available for build'); + } + writeLog(flowTitle + '.step', { step: 'build-source', sourceId: sourceId }); + const build = await request('POST', '/admin/sources/' + encodeURIComponent(sourceId) + '/build', undefined, true); + state.sourceId = build.data.sourceId || state.sourceId; + state.buildId = build.data.id || state.buildId; + $('admin-pipeline-build-id').value = build.data.id || $('admin-pipeline-build-id').value; + $('admin-pipeline-source-id').value = build.data.sourceId || $('admin-pipeline-source-id').value; + + writeLog(flowTitle + '.step', { step: 'publish-build', buildId: $('admin-pipeline-build-id').value || state.buildId }); + const published = await request('POST', '/admin/builds/' + encodeURIComponent($('admin-pipeline-build-id').value || state.buildId) + '/publish', {}, true); + state.releaseId = published.data.release.releaseId || state.releaseId; + $('admin-pipeline-release-id').value = published.data.release.releaseId || $('admin-pipeline-release-id').value; + if (published.data.runtime && published.data.runtime.runtimeBindingId) { + $('admin-release-runtime-binding-id').value = published.data.runtime.runtimeBindingId; + $('prod-runtime-binding-id').value = published.data.runtime.runtimeBindingId; + } + if (published.data.presentation && published.data.presentation.presentationId) { + $('admin-release-presentation-id').value = published.data.presentation.presentationId; + $('admin-presentation-id').value = published.data.presentation.presentationId; + $('config-presentation-id').value = published.data.presentation.presentationId; + } + if (published.data.contentBundle && published.data.contentBundle.contentBundleId) { + $('admin-release-content-bundle-id').value = published.data.contentBundle.contentBundleId; + $('admin-content-bundle-id').value = published.data.contentBundle.contentBundleId; + $('config-content-bundle-id').value = published.data.contentBundle.contentBundleId; + } + + writeLog(flowTitle + '.step', { step: 'get-release', releaseId: $('admin-pipeline-release-id').value || state.releaseId }); + const releaseDetail = await request('GET', '/admin/releases/' + encodeURIComponent($('admin-pipeline-release-id').value || state.releaseId), undefined, true); + setDefaultPublishExpectation(releaseDetail.data); + writeLog(flowTitle + '.expected', { + releaseId: $('flow-admin-release-result').textContent, + presentationId: $('flow-admin-presentation-result').textContent, + contentBundleId: $('flow-admin-content-bundle-result').textContent, + runtimeBindingId: $('flow-admin-runtime-result').textContent, + verdict: $('flow-admin-verdict').textContent + }); + syncState(); + persistState(); + return releaseDetail; + } + function setStatus(text, isError = false) { statusEl.textContent = text; statusEl.className = isError ? 'status error' : 'status'; @@ -1629,8 +2428,27 @@ const devWorkbenchHTML = ` }); } + function normalizeLogPayload(payload) { + if (payload instanceof Error) { + return { + name: payload.name, + message: payload.message, + stack: payload.stack + }; + } + if (payload && typeof payload === 'object') { + if (payload.error instanceof Error) { + const next = Object.assign({}, payload); + next.error = normalizeLogPayload(payload.error); + return next; + } + return payload; + } + return payload; + } + function writeLog(title, payload) { - logEl.textContent = '[' + new Date().toLocaleString() + '] ' + title + '\n' + JSON.stringify(payload, null, 2); + logEl.textContent = '[' + new Date().toLocaleString() + '] ' + title + '\n' + JSON.stringify(normalizeLogPayload(payload), null, 2); } function persistState() { @@ -1653,6 +2471,9 @@ const devWorkbenchHTML = ` wechatDevice: $('wechat-device').value, localConfigFile: $('local-config-file').value, configEventId: $('config-event-id').value, + configRuntimeBindingId: $('config-runtime-binding-id').value, + configPresentationId: $('config-presentation-id').value, + configContentBundleId: $('config-content-bundle-id').value, entryChannelCode: $('entry-channel-code').value, entryChannelType: $('entry-channel-type').value, eventId: $('event-id').value, @@ -1667,6 +2488,44 @@ const devWorkbenchHTML = ` finishDistance: $('finish-distance').value, finishSpeed: $('finish-speed').value, finishHeartRate: $('finish-heart-rate').value, + prodPlaceCode: $('prod-place-code').value, + prodPlaceName: $('prod-place-name').value, + prodPlaceId: $('prod-place-id').value, + prodPlaceStatus: $('prod-place-status').value, + prodPlaceRegion: $('prod-place-region').value, + prodPlaceCoverUrl: $('prod-place-cover-url').value, + prodMapAssetCode: $('prod-map-asset-code').value, + prodMapAssetName: $('prod-map-asset-name').value, + prodMapAssetId: $('prod-map-asset-id').value, + prodMapAssetLegacyMapId: $('prod-map-asset-legacy-map-id').value, + prodMapAssetType: $('prod-map-asset-type').value, + prodMapAssetStatus: $('prod-map-asset-status').value, + prodTileReleaseId: $('prod-tile-release-id').value, + prodTileLegacyVersionId: $('prod-tile-legacy-version-id').value, + prodTileVersionCode: $('prod-tile-version-code').value, + prodTileStatus: $('prod-tile-status').value, + prodTileBaseUrl: $('prod-tile-base-url').value, + prodTileMetaUrl: $('prod-tile-meta-url').value, + prodCourseSourceId: $('prod-course-source-id').value, + prodCourseSourceLegacyPlayfieldId: $('prod-course-source-legacy-playfield-id').value, + prodCourseSourceLegacyVersionId: $('prod-course-source-legacy-version-id').value, + prodCourseSourceType: $('prod-course-source-type').value, + prodCourseSourceFileUrl: $('prod-course-source-file-url').value, + prodCourseSourceStatus: $('prod-course-source-status').value, + prodCourseSetCode: $('prod-course-set-code').value, + prodCourseSetName: $('prod-course-set-name').value, + prodCourseSetId: $('prod-course-set-id').value, + prodCourseMode: $('prod-course-mode').value, + prodCourseSetStatus: $('prod-course-set-status').value, + prodCourseVariantId: $('prod-course-variant-id').value, + prodCourseVariantName: $('prod-course-variant-name').value, + prodCourseVariantRouteCode: $('prod-course-variant-route-code').value, + prodCourseVariantStatus: $('prod-course-variant-status').value, + prodCourseVariantControlCount: $('prod-course-variant-control-count').value, + prodRuntimeBindingId: $('prod-runtime-binding-id').value, + prodRuntimeEventId: $('prod-runtime-event-id').value, + prodRuntimeBindingStatus: $('prod-runtime-binding-status').value, + prodRuntimeNotes: $('prod-runtime-notes').value, adminMapCode: $('admin-map-code').value, adminMapName: $('admin-map-name').value, adminMapId: $('admin-map-id').value, @@ -1705,9 +2564,34 @@ const devWorkbenchHTML = ` adminRouteCode: $('admin-route-code').value, adminSourceNotes: $('admin-source-notes').value, adminOverridesJSON: $('admin-overrides-json').value, + adminPresentationId: $('admin-presentation-id').value, + adminPresentationCode: $('admin-presentation-code').value, + adminPresentationName: $('admin-presentation-name').value, + adminPresentationType: $('admin-presentation-type').value, + adminPresentationSchemaJSON: $('admin-presentation-schema-json').value, + adminPresentationImportTitle: $('admin-presentation-import-title').value, + adminPresentationImportTemplateKey: $('admin-presentation-import-template-key').value, + adminPresentationImportSourceType: $('admin-presentation-import-source-type').value, + adminPresentationImportVersion: $('admin-presentation-import-version').value, + adminPresentationImportSchemaURL: $('admin-presentation-import-schema-url').value, + adminContentBundleId: $('admin-content-bundle-id').value, + adminContentBundleCode: $('admin-content-bundle-code').value, + adminContentBundleName: $('admin-content-bundle-name').value, + adminContentEntryURL: $('admin-content-entry-url').value, + adminContentAssetRootURL: $('admin-content-asset-root-url').value, + adminContentMetadataJSON: $('admin-content-metadata-json').value, + adminContentImportTitle: $('admin-content-import-title').value, + adminContentImportBundleType: $('admin-content-import-bundle-type').value, + adminContentImportSourceType: $('admin-content-import-source-type').value, + adminContentImportVersion: $('admin-content-import-version').value, + adminContentImportManifestURL: $('admin-content-import-manifest-url').value, + adminContentImportAssetManifestJSON: $('admin-content-import-asset-manifest-json').value, adminPipelineSourceId: $('admin-pipeline-source-id').value, adminPipelineBuildId: $('admin-pipeline-build-id').value, adminPipelineReleaseId: $('admin-pipeline-release-id').value, + adminReleaseRuntimeBindingId: $('admin-release-runtime-binding-id').value, + adminReleasePresentationId: $('admin-release-presentation-id').value, + adminReleaseContentBundleId: $('admin-release-content-bundle-id').value, adminRollbackReleaseId: $('admin-rollback-release-id').value }; } @@ -1744,6 +2628,9 @@ const devWorkbenchHTML = ` $('wechat-device').value = fields.wechatDevice || $('wechat-device').value; $('local-config-file').value = fields.localConfigFile || $('local-config-file').value; $('config-event-id').value = fields.configEventId || $('config-event-id').value; + $('config-runtime-binding-id').value = fields.configRuntimeBindingId || $('config-runtime-binding-id').value; + $('config-presentation-id').value = fields.configPresentationId || $('config-presentation-id').value; + $('config-content-bundle-id').value = fields.configContentBundleId || $('config-content-bundle-id').value; $('entry-channel-code').value = fields.entryChannelCode || $('entry-channel-code').value; $('entry-channel-type').value = fields.entryChannelType || $('entry-channel-type').value; $('event-id').value = fields.eventId || $('event-id').value; @@ -1758,6 +2645,44 @@ const devWorkbenchHTML = ` $('finish-distance').value = fields.finishDistance || $('finish-distance').value; $('finish-speed').value = fields.finishSpeed || $('finish-speed').value; $('finish-heart-rate').value = fields.finishHeartRate || $('finish-heart-rate').value; + $('prod-place-code').value = fields.prodPlaceCode || $('prod-place-code').value; + $('prod-place-name').value = fields.prodPlaceName || $('prod-place-name').value; + $('prod-place-id').value = fields.prodPlaceId || $('prod-place-id').value; + $('prod-place-status').value = fields.prodPlaceStatus || $('prod-place-status').value; + $('prod-place-region').value = fields.prodPlaceRegion || $('prod-place-region').value; + $('prod-place-cover-url').value = fields.prodPlaceCoverUrl || $('prod-place-cover-url').value; + $('prod-map-asset-code').value = fields.prodMapAssetCode || $('prod-map-asset-code').value; + $('prod-map-asset-name').value = fields.prodMapAssetName || $('prod-map-asset-name').value; + $('prod-map-asset-id').value = fields.prodMapAssetId || $('prod-map-asset-id').value; + $('prod-map-asset-legacy-map-id').value = fields.prodMapAssetLegacyMapId || $('prod-map-asset-legacy-map-id').value; + $('prod-map-asset-type').value = fields.prodMapAssetType || $('prod-map-asset-type').value; + $('prod-map-asset-status').value = fields.prodMapAssetStatus || $('prod-map-asset-status').value; + $('prod-tile-release-id').value = fields.prodTileReleaseId || $('prod-tile-release-id').value; + $('prod-tile-legacy-version-id').value = fields.prodTileLegacyVersionId || $('prod-tile-legacy-version-id').value; + $('prod-tile-version-code').value = fields.prodTileVersionCode || $('prod-tile-version-code').value; + $('prod-tile-status').value = fields.prodTileStatus || $('prod-tile-status').value; + $('prod-tile-base-url').value = fields.prodTileBaseUrl || $('prod-tile-base-url').value; + $('prod-tile-meta-url').value = fields.prodTileMetaUrl || $('prod-tile-meta-url').value; + $('prod-course-source-id').value = fields.prodCourseSourceId || $('prod-course-source-id').value; + $('prod-course-source-legacy-playfield-id').value = fields.prodCourseSourceLegacyPlayfieldId || $('prod-course-source-legacy-playfield-id').value; + $('prod-course-source-legacy-version-id').value = fields.prodCourseSourceLegacyVersionId || $('prod-course-source-legacy-version-id').value; + $('prod-course-source-type').value = fields.prodCourseSourceType || $('prod-course-source-type').value; + $('prod-course-source-file-url').value = fields.prodCourseSourceFileUrl || $('prod-course-source-file-url').value; + $('prod-course-source-status').value = fields.prodCourseSourceStatus || $('prod-course-source-status').value; + $('prod-course-set-code').value = fields.prodCourseSetCode || $('prod-course-set-code').value; + $('prod-course-set-name').value = fields.prodCourseSetName || $('prod-course-set-name').value; + $('prod-course-set-id').value = fields.prodCourseSetId || $('prod-course-set-id').value; + $('prod-course-mode').value = fields.prodCourseMode || $('prod-course-mode').value; + $('prod-course-set-status').value = fields.prodCourseSetStatus || $('prod-course-set-status').value; + $('prod-course-variant-id').value = fields.prodCourseVariantId || $('prod-course-variant-id').value; + $('prod-course-variant-name').value = fields.prodCourseVariantName || $('prod-course-variant-name').value; + $('prod-course-variant-route-code').value = fields.prodCourseVariantRouteCode || $('prod-course-variant-route-code').value; + $('prod-course-variant-status').value = fields.prodCourseVariantStatus || $('prod-course-variant-status').value; + $('prod-course-variant-control-count').value = fields.prodCourseVariantControlCount || $('prod-course-variant-control-count').value; + $('prod-runtime-binding-id').value = fields.prodRuntimeBindingId || $('prod-runtime-binding-id').value; + $('prod-runtime-event-id').value = fields.prodRuntimeEventId || $('prod-runtime-event-id').value; + $('prod-runtime-binding-status').value = fields.prodRuntimeBindingStatus || $('prod-runtime-binding-status').value; + $('prod-runtime-notes').value = fields.prodRuntimeNotes || $('prod-runtime-notes').value; $('admin-map-code').value = fields.adminMapCode || $('admin-map-code').value; $('admin-map-name').value = fields.adminMapName || $('admin-map-name').value; $('admin-map-id').value = fields.adminMapId || $('admin-map-id').value; @@ -1796,9 +2721,34 @@ const devWorkbenchHTML = ` $('admin-route-code').value = fields.adminRouteCode || $('admin-route-code').value; $('admin-source-notes').value = fields.adminSourceNotes || $('admin-source-notes').value; $('admin-overrides-json').value = fields.adminOverridesJSON || $('admin-overrides-json').value; + $('admin-presentation-id').value = fields.adminPresentationId || $('admin-presentation-id').value; + $('admin-presentation-code').value = fields.adminPresentationCode || $('admin-presentation-code').value; + $('admin-presentation-name').value = fields.adminPresentationName || $('admin-presentation-name').value; + $('admin-presentation-type').value = fields.adminPresentationType || $('admin-presentation-type').value; + $('admin-presentation-schema-json').value = fields.adminPresentationSchemaJSON || $('admin-presentation-schema-json').value; + $('admin-presentation-import-title').value = fields.adminPresentationImportTitle || $('admin-presentation-import-title').value; + $('admin-presentation-import-template-key').value = fields.adminPresentationImportTemplateKey || $('admin-presentation-import-template-key').value; + $('admin-presentation-import-source-type').value = fields.adminPresentationImportSourceType || $('admin-presentation-import-source-type').value; + $('admin-presentation-import-version').value = fields.adminPresentationImportVersion || $('admin-presentation-import-version').value; + $('admin-presentation-import-schema-url').value = fields.adminPresentationImportSchemaURL || $('admin-presentation-import-schema-url').value; + $('admin-content-bundle-id').value = fields.adminContentBundleId || $('admin-content-bundle-id').value; + $('admin-content-bundle-code').value = fields.adminContentBundleCode || $('admin-content-bundle-code').value; + $('admin-content-bundle-name').value = fields.adminContentBundleName || $('admin-content-bundle-name').value; + $('admin-content-entry-url').value = fields.adminContentEntryURL || $('admin-content-entry-url').value; + $('admin-content-asset-root-url').value = fields.adminContentAssetRootURL || $('admin-content-asset-root-url').value; + $('admin-content-metadata-json').value = fields.adminContentMetadataJSON || $('admin-content-metadata-json').value; + $('admin-content-import-title').value = fields.adminContentImportTitle || $('admin-content-import-title').value; + $('admin-content-import-bundle-type').value = fields.adminContentImportBundleType || $('admin-content-import-bundle-type').value; + $('admin-content-import-source-type').value = fields.adminContentImportSourceType || $('admin-content-import-source-type').value; + $('admin-content-import-version').value = fields.adminContentImportVersion || $('admin-content-import-version').value; + $('admin-content-import-manifest-url').value = fields.adminContentImportManifestURL || $('admin-content-import-manifest-url').value; + $('admin-content-import-asset-manifest-json').value = fields.adminContentImportAssetManifestJSON || $('admin-content-import-asset-manifest-json').value; $('admin-pipeline-source-id').value = fields.adminPipelineSourceId || $('admin-pipeline-source-id').value; $('admin-pipeline-build-id').value = fields.adminPipelineBuildId || $('admin-pipeline-build-id').value; $('admin-pipeline-release-id').value = fields.adminPipelineReleaseId || $('admin-pipeline-release-id').value; + $('admin-release-runtime-binding-id').value = fields.adminReleaseRuntimeBindingId || $('admin-release-runtime-binding-id').value; + $('admin-release-presentation-id').value = fields.adminReleasePresentationId || $('admin-release-presentation-id').value; + $('admin-release-content-bundle-id').value = fields.adminReleaseContentBundleId || $('admin-release-content-bundle-id').value; $('admin-rollback-release-id').value = fields.adminRollbackReleaseId || $('admin-rollback-release-id').value; } @@ -2138,8 +3088,11 @@ const devWorkbenchHTML = ` }); syncState(); } catch (err) { - setStatus('error: ' + title, true); - writeLog(title, err); + setStatus('error: ' + title + ' -> ' + (err && err.message ? err.message : 'unknown error'), true); + writeLog(title, { + error: err, + lastCurl: state.lastCurl || null + }); pushHistory({ title: title, time: new Date().toLocaleString(), @@ -2212,10 +3165,24 @@ const devWorkbenchHTML = ` $('btn-config-publish').onclick = () => run('config/publish-build', async () => { const result = await request('POST', '/dev/config-builds/publish', { - buildId: $('config-build-id').value + buildId: $('config-build-id').value, + runtimeBindingId: trimmedOrUndefined($('config-runtime-binding-id').value), + presentationId: trimmedOrUndefined($('config-presentation-id').value), + contentBundleId: trimmedOrUndefined($('config-content-bundle-id').value) }); state.releaseId = result.data.release.releaseId; $('event-release-id').value = result.data.release.releaseId; + if (result.data.runtime && result.data.runtime.runtimeBindingId) { + $('config-runtime-binding-id').value = result.data.runtime.runtimeBindingId; + } + if (result.data.presentation && result.data.presentation.presentationId) { + $('config-presentation-id').value = result.data.presentation.presentationId; + $('admin-release-presentation-id').value = result.data.presentation.presentationId; + } + if (result.data.contentBundle && result.data.contentBundle.contentBundleId) { + $('config-content-bundle-id').value = result.data.contentBundle.contentBundleId; + $('admin-release-content-bundle-id').value = result.data.contentBundle.contentBundleId; + } return result; }); @@ -2348,6 +3315,194 @@ const devWorkbenchHTML = ` request('GET', '/me/profile', undefined, true) ); + $('btn-prod-places-list').onclick = () => run('admin/places/list', async () => { + const result = await request('GET', '/admin/places?limit=20', undefined, true); + const first = result.data && result.data[0]; + if (first) { + $('prod-place-id').value = first.id || $('prod-place-id').value; + } + persistState(); + return result; + }); + + $('btn-prod-place-create').onclick = () => run('admin/places/create', async () => { + const result = await request('POST', '/admin/places', { + code: $('prod-place-code').value, + name: $('prod-place-name').value, + region: trimmedOrUndefined($('prod-place-region').value), + coverUrl: trimmedOrUndefined($('prod-place-cover-url').value), + status: $('prod-place-status').value + }, true); + $('prod-place-id').value = result.data.id || $('prod-place-id').value; + persistState(); + return result; + }); + + $('btn-prod-place-detail').onclick = () => run('admin/places/detail', async () => { + const result = await request('GET', '/admin/places/' + encodeURIComponent($('prod-place-id').value), undefined, true); + if (result.data && result.data.place) { + $('prod-place-id').value = result.data.place.id || $('prod-place-id').value; + } + if (result.data && result.data.mapAssets && result.data.mapAssets[0]) { + $('prod-map-asset-id').value = result.data.mapAssets[0].id || $('prod-map-asset-id').value; + } + persistState(); + return result; + }); + + $('btn-prod-map-asset-create').onclick = () => run('admin/map-assets/create', async () => { + const result = await request('POST', '/admin/places/' + encodeURIComponent($('prod-place-id').value) + '/map-assets', { + code: $('prod-map-asset-code').value, + name: $('prod-map-asset-name').value, + mapType: $('prod-map-asset-type').value, + legacyMapId: trimmedOrUndefined($('prod-map-asset-legacy-map-id').value), + status: $('prod-map-asset-status').value + }, true); + $('prod-map-asset-id').value = result.data.id || $('prod-map-asset-id').value; + persistState(); + return result; + }); + + $('btn-prod-map-asset-detail').onclick = () => run('admin/map-assets/detail', async () => { + const result = await request('GET', '/admin/map-assets/' + encodeURIComponent($('prod-map-asset-id').value), undefined, true); + if (result.data && result.data.mapAsset) { + $('prod-map-asset-id').value = result.data.mapAsset.id || $('prod-map-asset-id').value; + } + if (result.data && result.data.mapAsset && result.data.mapAsset.currentTileRelease) { + $('prod-tile-release-id').value = result.data.mapAsset.currentTileRelease.id || $('prod-tile-release-id').value; + } else if (result.data && result.data.tileReleases && result.data.tileReleases[0]) { + $('prod-tile-release-id').value = result.data.tileReleases[0].id || $('prod-tile-release-id').value; + } + if (result.data && result.data.courseSets && result.data.courseSets[0]) { + $('prod-course-set-id').value = result.data.courseSets[0].id || $('prod-course-set-id').value; + } + persistState(); + return result; + }); + + $('btn-prod-tile-create').onclick = () => run('admin/tile-releases/create', async () => { + const result = await request('POST', '/admin/map-assets/' + encodeURIComponent($('prod-map-asset-id').value) + '/tile-releases', { + legacyVersionId: trimmedOrUndefined($('prod-tile-legacy-version-id').value), + versionCode: $('prod-tile-version-code').value, + status: $('prod-tile-status').value, + tileBaseUrl: $('prod-tile-base-url').value, + metaUrl: $('prod-tile-meta-url').value, + setAsCurrent: true + }, true); + $('prod-tile-release-id').value = result.data.id || $('prod-tile-release-id').value; + persistState(); + return result; + }); + + $('btn-prod-course-sources-list').onclick = () => run('admin/course-sources/list', async () => { + const result = await request('GET', '/admin/course-sources?limit=20', undefined, true); + const first = result.data && result.data[0]; + if (first) { + $('prod-course-source-id').value = first.id || $('prod-course-source-id').value; + } + persistState(); + return result; + }); + + $('btn-prod-course-source-create').onclick = () => run('admin/course-sources/create', async () => { + const result = await request('POST', '/admin/course-sources', { + legacyPlayfieldId: trimmedOrUndefined($('prod-course-source-legacy-playfield-id').value), + legacyVersionId: trimmedOrUndefined($('prod-course-source-legacy-version-id').value), + sourceType: $('prod-course-source-type').value, + fileUrl: $('prod-course-source-file-url').value, + importStatus: $('prod-course-source-status').value + }, true); + $('prod-course-source-id').value = result.data.id || $('prod-course-source-id').value; + persistState(); + return result; + }); + + $('btn-prod-course-source-detail').onclick = () => run('admin/course-sources/detail', async () => { + const result = await request('GET', '/admin/course-sources/' + encodeURIComponent($('prod-course-source-id').value), undefined, true); + if (result.data) { + $('prod-course-source-id').value = result.data.id || $('prod-course-source-id').value; + } + persistState(); + return result; + }); + + $('btn-prod-course-set-create').onclick = () => run('admin/course-sets/create', async () => { + const result = await request('POST', '/admin/map-assets/' + encodeURIComponent($('prod-map-asset-id').value) + '/course-sets', { + code: $('prod-course-set-code').value, + mode: $('prod-course-mode').value, + name: $('prod-course-set-name').value, + status: $('prod-course-set-status').value + }, true); + $('prod-course-set-id').value = result.data.id || $('prod-course-set-id').value; + persistState(); + return result; + }); + + $('btn-prod-course-set-detail').onclick = () => run('admin/course-sets/detail', async () => { + const result = await request('GET', '/admin/course-sets/' + encodeURIComponent($('prod-course-set-id').value), undefined, true); + if (result.data && result.data.courseSet) { + $('prod-course-set-id').value = result.data.courseSet.id || $('prod-course-set-id').value; + if (result.data.courseSet.currentVariant) { + $('prod-course-variant-id').value = result.data.courseSet.currentVariant.id || $('prod-course-variant-id').value; + } + } + if (result.data && result.data.variants && result.data.variants[0]) { + $('prod-course-variant-id').value = result.data.variants[0].id || $('prod-course-variant-id').value; + } + persistState(); + return result; + }); + + $('btn-prod-course-variant-create').onclick = () => run('admin/course-variants/create', async () => { + const result = await request('POST', '/admin/course-sets/' + encodeURIComponent($('prod-course-set-id').value) + '/variants', { + sourceId: trimmedOrUndefined($('prod-course-source-id').value), + name: $('prod-course-variant-name').value, + routeCode: trimmedOrUndefined($('prod-course-variant-route-code').value), + mode: $('prod-course-mode').value, + controlCount: parseIntOrNull($('prod-course-variant-control-count').value), + status: $('prod-course-variant-status').value, + isDefault: true + }, true); + $('prod-course-variant-id').value = result.data.id || $('prod-course-variant-id').value; + persistState(); + return result; + }); + + $('btn-prod-runtime-bindings-list').onclick = () => run('admin/runtime-bindings/list', async () => { + const result = await request('GET', '/admin/runtime-bindings?limit=20', undefined, true); + const first = result.data && result.data[0]; + if (first) { + $('prod-runtime-binding-id').value = first.id || $('prod-runtime-binding-id').value; + } + persistState(); + return result; + }); + + $('btn-prod-runtime-binding-create').onclick = () => run('admin/runtime-bindings/create', async () => { + const result = await request('POST', '/admin/runtime-bindings', { + eventId: $('prod-runtime-event-id').value, + placeId: $('prod-place-id').value, + mapAssetId: $('prod-map-asset-id').value, + tileReleaseId: $('prod-tile-release-id').value, + courseSetId: $('prod-course-set-id').value, + courseVariantId: $('prod-course-variant-id').value, + status: $('prod-runtime-binding-status').value, + notes: trimmedOrUndefined($('prod-runtime-notes').value) + }, true); + $('prod-runtime-binding-id').value = result.data.id || $('prod-runtime-binding-id').value; + persistState(); + return result; + }); + + $('btn-prod-runtime-binding-detail').onclick = () => run('admin/runtime-bindings/detail', async () => { + const result = await request('GET', '/admin/runtime-bindings/' + encodeURIComponent($('prod-runtime-binding-id').value), undefined, true); + if (result.data) { + $('prod-runtime-binding-id').value = result.data.id || $('prod-runtime-binding-id').value; + } + persistState(); + return result; + }); + $('btn-admin-maps-list').onclick = () => run('admin/maps/list', async () => { const result = await request('GET', '/admin/maps?limit=20', undefined, true); const first = result.data && result.data[0]; @@ -2535,11 +3690,141 @@ const devWorkbenchHTML = ` if (result.data.latestSource && result.data.latestSource.id) { state.sourceId = result.data.latestSource.id; } + if (result.data.currentPresentation && result.data.currentPresentation.presentationId) { + $('admin-presentation-id').value = result.data.currentPresentation.presentationId; + $('admin-release-presentation-id').value = result.data.currentPresentation.presentationId; + $('config-presentation-id').value = result.data.currentPresentation.presentationId; + } + if (result.data.currentContentBundle && result.data.currentContentBundle.contentBundleId) { + $('admin-content-bundle-id').value = result.data.currentContentBundle.contentBundleId; + $('admin-release-content-bundle-id').value = result.data.currentContentBundle.contentBundleId; + $('config-content-bundle-id').value = result.data.currentContentBundle.contentBundleId; + } + if (result.data.currentRuntime && result.data.currentRuntime.runtimeBindingId) { + $('admin-release-runtime-binding-id').value = result.data.currentRuntime.runtimeBindingId; + $('prod-runtime-binding-id').value = result.data.currentRuntime.runtimeBindingId; + } } syncState(); return result; }); + $('btn-admin-presentations-list').onclick = () => run('admin/presentations/list', async () => { + const result = await request('GET', '/admin/events/' + encodeURIComponent($('admin-event-ref-id').value) + '/presentations?limit=20', undefined, true); + const first = result.data && result.data[0]; + if (first) { + $('admin-presentation-id').value = first.id || $('admin-presentation-id').value; + $('admin-release-presentation-id').value = first.id || $('admin-release-presentation-id').value; + $('config-presentation-id').value = first.id || $('config-presentation-id').value; + } + persistState(); + return result; + }); + + $('btn-admin-presentation-create').onclick = () => run('admin/presentations/create', async () => { + const result = await request('POST', '/admin/events/' + encodeURIComponent($('admin-event-ref-id').value) + '/presentations', { + code: $('admin-presentation-code').value, + name: $('admin-presentation-name').value, + presentationType: $('admin-presentation-type').value, + status: 'active', + isDefault: true, + schema: parseJSONObjectOrUndefined($('admin-presentation-schema-json').value, 'Presentation Schema JSON') || {} + }, true); + $('admin-presentation-id').value = result.data.id || $('admin-presentation-id').value; + $('admin-release-presentation-id').value = result.data.id || $('admin-release-presentation-id').value; + $('config-presentation-id').value = result.data.id || $('config-presentation-id').value; + persistState(); + return result; + }); + + $('btn-admin-presentation-import').onclick = () => run('admin/presentations/import', async () => { + const result = await request('POST', '/admin/events/' + encodeURIComponent($('admin-event-ref-id').value) + '/presentations/import', { + title: $('admin-presentation-import-title').value, + templateKey: $('admin-presentation-import-template-key').value, + sourceType: $('admin-presentation-import-source-type').value, + schemaUrl: $('admin-presentation-import-schema-url').value, + version: $('admin-presentation-import-version').value, + status: 'active', + isDefault: true + }, true); + $('admin-presentation-id').value = result.data.id || $('admin-presentation-id').value; + $('admin-presentation-name').value = result.data.name || $('admin-presentation-name').value; + $('admin-release-presentation-id').value = result.data.id || $('admin-release-presentation-id').value; + $('config-presentation-id').value = result.data.id || $('config-presentation-id').value; + persistState(); + return result; + }); + + $('btn-admin-presentation-detail').onclick = () => run('admin/presentations/detail', async () => { + const result = await request('GET', '/admin/presentations/' + encodeURIComponent($('admin-presentation-id').value), undefined, true); + if (result.data) { + $('admin-presentation-id').value = result.data.id || $('admin-presentation-id').value; + $('admin-release-presentation-id').value = result.data.id || $('admin-release-presentation-id').value; + $('config-presentation-id').value = result.data.id || $('config-presentation-id').value; + } + persistState(); + return result; + }); + + $('btn-admin-content-bundles-list').onclick = () => run('admin/content-bundles/list', async () => { + const result = await request('GET', '/admin/events/' + encodeURIComponent($('admin-event-ref-id').value) + '/content-bundles?limit=20', undefined, true); + const first = result.data && result.data[0]; + if (first) { + $('admin-content-bundle-id').value = first.id || $('admin-content-bundle-id').value; + $('admin-release-content-bundle-id').value = first.id || $('admin-release-content-bundle-id').value; + $('config-content-bundle-id').value = first.id || $('config-content-bundle-id').value; + } + persistState(); + return result; + }); + + $('btn-admin-content-bundle-create').onclick = () => run('admin/content-bundles/create', async () => { + const result = await request('POST', '/admin/events/' + encodeURIComponent($('admin-event-ref-id').value) + '/content-bundles', { + code: $('admin-content-bundle-code').value, + name: $('admin-content-bundle-name').value, + status: 'active', + isDefault: true, + entryUrl: trimmedOrUndefined($('admin-content-entry-url').value), + assetRootUrl: trimmedOrUndefined($('admin-content-asset-root-url').value), + metadata: parseJSONObjectOrUndefined($('admin-content-metadata-json').value, 'Content Metadata JSON') || {} + }, true); + $('admin-content-bundle-id').value = result.data.id || $('admin-content-bundle-id').value; + $('admin-release-content-bundle-id').value = result.data.id || $('admin-release-content-bundle-id').value; + $('config-content-bundle-id').value = result.data.id || $('config-content-bundle-id').value; + persistState(); + return result; + }); + + $('btn-admin-content-bundle-import').onclick = () => run('admin/content-bundles/import', async () => { + const result = await request('POST', '/admin/events/' + encodeURIComponent($('admin-event-ref-id').value) + '/content-bundles/import', { + title: $('admin-content-import-title').value, + bundleType: $('admin-content-import-bundle-type').value, + sourceType: $('admin-content-import-source-type').value, + manifestUrl: $('admin-content-import-manifest-url').value, + version: $('admin-content-import-version').value, + status: 'active', + isDefault: true, + assetManifest: parseJSONObjectOrUndefined($('admin-content-import-asset-manifest-json').value, 'Content Asset Manifest JSON') + }, true); + $('admin-content-bundle-id').value = result.data.id || $('admin-content-bundle-id').value; + $('admin-content-bundle-name').value = result.data.name || $('admin-content-bundle-name').value; + $('admin-release-content-bundle-id').value = result.data.id || $('admin-release-content-bundle-id').value; + $('config-content-bundle-id').value = result.data.id || $('config-content-bundle-id').value; + persistState(); + return result; + }); + + $('btn-admin-content-bundle-detail').onclick = () => run('admin/content-bundles/detail', async () => { + const result = await request('GET', '/admin/content-bundles/' + encodeURIComponent($('admin-content-bundle-id').value), undefined, true); + if (result.data) { + $('admin-content-bundle-id').value = result.data.id || $('admin-content-bundle-id').value; + $('admin-release-content-bundle-id').value = result.data.id || $('admin-release-content-bundle-id').value; + $('config-content-bundle-id').value = result.data.id || $('config-content-bundle-id').value; + } + persistState(); + return result; + }); + $('btn-admin-event-source').onclick = () => run('admin/events/source', async () => { const result = await request('POST', '/admin/events/' + encodeURIComponent($('admin-event-ref-id').value) + '/source', { map: { @@ -2569,6 +3854,20 @@ const devWorkbenchHTML = ` if (result.data) { if (result.data.currentRelease && result.data.currentRelease.id) { state.releaseId = result.data.currentRelease.id; + $('admin-pipeline-release-id').value = result.data.currentRelease.id; + } + if (result.data.currentRelease && result.data.currentRelease.runtime && result.data.currentRelease.runtime.runtimeBindingId) { + $('admin-release-runtime-binding-id').value = result.data.currentRelease.runtime.runtimeBindingId; + } + if (result.data.currentRelease && result.data.currentRelease.presentation && result.data.currentRelease.presentation.presentationId) { + $('admin-release-presentation-id').value = result.data.currentRelease.presentation.presentationId; + $('admin-presentation-id').value = result.data.currentRelease.presentation.presentationId; + $('config-presentation-id').value = result.data.currentRelease.presentation.presentationId; + } + if (result.data.currentRelease && result.data.currentRelease.contentBundle && result.data.currentRelease.contentBundle.contentBundleId) { + $('admin-release-content-bundle-id').value = result.data.currentRelease.contentBundle.contentBundleId; + $('admin-content-bundle-id').value = result.data.currentRelease.contentBundle.contentBundleId; + $('config-content-bundle-id').value = result.data.currentRelease.contentBundle.contentBundleId; } if (result.data.sources && result.data.sources[0] && result.data.sources[0].id) { state.sourceId = result.data.sources[0].id; @@ -2581,6 +3880,68 @@ const devWorkbenchHTML = ` return result; }); + $('btn-admin-release-detail').onclick = () => run('admin/releases/detail', async () => { + const result = await request('GET', '/admin/releases/' + encodeURIComponent($('admin-pipeline-release-id').value || state.releaseId), undefined, true); + if (result.data) { + state.releaseId = result.data.id || state.releaseId; + if (result.data.runtime && result.data.runtime.runtimeBindingId) { + $('admin-release-runtime-binding-id').value = result.data.runtime.runtimeBindingId; + } + if (result.data.presentation && result.data.presentation.presentationId) { + $('admin-release-presentation-id').value = result.data.presentation.presentationId; + $('admin-presentation-id').value = result.data.presentation.presentationId; + $('config-presentation-id').value = result.data.presentation.presentationId; + } + if (result.data.contentBundle && result.data.contentBundle.contentBundleId) { + $('admin-release-content-bundle-id').value = result.data.contentBundle.contentBundleId; + $('admin-content-bundle-id').value = result.data.contentBundle.contentBundleId; + $('config-content-bundle-id').value = result.data.contentBundle.contentBundleId; + } + setDefaultPublishExpectation(result.data); + } + syncState(); + return result; + }); + + $('btn-admin-bind-runtime').onclick = () => run('admin/releases/bind-runtime', async () => { + const runtimeBindingId = $('admin-release-runtime-binding-id').value || $('prod-runtime-binding-id').value; + const result = await request('POST', '/admin/releases/' + encodeURIComponent($('admin-pipeline-release-id').value || state.releaseId) + '/runtime-binding', { + runtimeBindingId: runtimeBindingId + }, true); + if (result.data) { + state.releaseId = result.data.id || state.releaseId; + if (result.data.runtime && result.data.runtime.runtimeBindingId) { + $('admin-release-runtime-binding-id').value = result.data.runtime.runtimeBindingId; + } + } + syncState(); + return result; + }); + + $('btn-admin-event-defaults').onclick = () => run('admin/events/defaults', async () => { + const result = await request('POST', '/admin/events/' + encodeURIComponent($('admin-event-ref-id').value) + '/defaults', { + presentationId: trimmedOrUndefined($('admin-release-presentation-id').value), + contentBundleId: trimmedOrUndefined($('admin-release-content-bundle-id').value), + runtimeBindingId: trimmedOrUndefined($('admin-release-runtime-binding-id').value || $('prod-runtime-binding-id').value) + }, true); + if (result.data && result.data.currentPresentation && result.data.currentPresentation.presentationId) { + $('admin-presentation-id').value = result.data.currentPresentation.presentationId; + $('admin-release-presentation-id').value = result.data.currentPresentation.presentationId; + $('config-presentation-id').value = result.data.currentPresentation.presentationId; + } + if (result.data && result.data.currentContentBundle && result.data.currentContentBundle.contentBundleId) { + $('admin-content-bundle-id').value = result.data.currentContentBundle.contentBundleId; + $('admin-release-content-bundle-id').value = result.data.currentContentBundle.contentBundleId; + $('config-content-bundle-id').value = result.data.currentContentBundle.contentBundleId; + } + if (result.data && result.data.currentRuntime && result.data.currentRuntime.runtimeBindingId) { + $('admin-release-runtime-binding-id').value = result.data.currentRuntime.runtimeBindingId; + $('prod-runtime-binding-id').value = result.data.currentRuntime.runtimeBindingId; + } + persistState(); + return result; + }); + $('btn-admin-build-source').onclick = () => run('admin/sources/build', async () => { const result = await request('POST', '/admin/sources/' + encodeURIComponent($('admin-pipeline-source-id').value || state.sourceId) + '/build', undefined, true); state.buildId = result.data.id || state.buildId; @@ -2598,8 +3959,25 @@ const devWorkbenchHTML = ` }); $('btn-admin-build-publish').onclick = () => run('admin/builds/publish', async () => { - const result = await request('POST', '/admin/builds/' + encodeURIComponent($('admin-pipeline-build-id').value || state.buildId) + '/publish', undefined, true); + const result = await request('POST', '/admin/builds/' + encodeURIComponent($('admin-pipeline-build-id').value || state.buildId) + '/publish', { + runtimeBindingId: trimmedOrUndefined($('admin-release-runtime-binding-id').value || $('prod-runtime-binding-id').value), + presentationId: trimmedOrUndefined($('admin-release-presentation-id').value), + contentBundleId: trimmedOrUndefined($('admin-release-content-bundle-id').value) + }, true); state.releaseId = result.data.release.releaseId || state.releaseId; + if (result.data.runtime && result.data.runtime.runtimeBindingId) { + $('admin-release-runtime-binding-id').value = result.data.runtime.runtimeBindingId; + } + if (result.data.presentation && result.data.presentation.presentationId) { + $('admin-release-presentation-id').value = result.data.presentation.presentationId; + $('admin-presentation-id').value = result.data.presentation.presentationId; + $('config-presentation-id').value = result.data.presentation.presentationId; + } + if (result.data.contentBundle && result.data.contentBundle.contentBundleId) { + $('admin-release-content-bundle-id').value = result.data.contentBundle.contentBundleId; + $('admin-content-bundle-id').value = result.data.contentBundle.contentBundleId; + $('config-content-bundle-id').value = result.data.contentBundle.contentBundleId; + } syncState(); return result; }); @@ -2658,6 +4036,14 @@ const devWorkbenchHTML = ` if (result.data.releaseId) { $('event-release-id').value = result.data.releaseId; } + $('prod-runtime-event-id').value = result.data.eventId || $('prod-runtime-event-id').value; + $('prod-place-id').value = result.data.placeId || $('prod-place-id').value; + $('prod-map-asset-id').value = result.data.mapAssetId || $('prod-map-asset-id').value; + $('prod-tile-release-id').value = result.data.tileReleaseId || $('prod-tile-release-id').value; + $('prod-course-source-id').value = result.data.courseSourceId || $('prod-course-source-id').value; + $('prod-course-set-id').value = result.data.courseSetId || $('prod-course-set-id').value; + $('prod-course-variant-id').value = result.data.courseVariantId || $('prod-course-variant-id').value; + $('prod-runtime-binding-id').value = result.data.runtimeBindingId || $('prod-runtime-binding-id').value; return result; }); @@ -2730,12 +4116,31 @@ const devWorkbenchHTML = ` return await request('GET', '/sessions/' + encodeURIComponent($('session-id').value) + '/result', undefined, true); }); + $('btn-flow-admin-default-publish').onclick = () => run('flow-admin-default-publish', async () => { + return await runAdminDefaultPublishFlow({ ensureRuntime: false }); + }); + + $('btn-flow-admin-runtime-publish').onclick = () => run('flow-admin-runtime-publish', async () => { + return await runAdminDefaultPublishFlow({ ensureRuntime: true }); + }); + [ 'sms-client-type', 'sms-scene', 'sms-mobile', 'sms-device', 'sms-country', 'sms-code', - 'wechat-code', 'wechat-device', 'local-config-file', 'config-event-id', 'entry-channel-code', 'entry-channel-type', + 'wechat-code', 'wechat-device', 'local-config-file', 'config-event-id', 'config-runtime-binding-id', 'config-presentation-id', 'config-content-bundle-id', 'entry-channel-code', 'entry-channel-type', 'event-id', 'event-release-id', 'event-device', 'finish-status', 'finish-duration', 'finish-score', 'finish-controls-done', 'finish-controls-total', 'finish-distance', 'finish-speed', - 'finish-heart-rate', 'admin-map-code', 'admin-map-name', 'admin-map-id', 'admin-map-version-id', + 'finish-heart-rate', 'prod-place-code', 'prod-place-name', 'prod-place-id', 'prod-place-status', + 'prod-place-region', 'prod-place-cover-url', 'prod-map-asset-code', 'prod-map-asset-name', + 'prod-map-asset-id', 'prod-map-asset-legacy-map-id', 'prod-map-asset-type', 'prod-map-asset-status', + 'prod-tile-release-id', 'prod-tile-legacy-version-id', 'prod-tile-version-code', 'prod-tile-status', + 'prod-tile-base-url', 'prod-tile-meta-url', 'prod-course-source-id', + 'prod-course-source-legacy-playfield-id', 'prod-course-source-legacy-version-id', + 'prod-course-source-type', 'prod-course-source-file-url', 'prod-course-source-status', + 'prod-course-set-code', 'prod-course-set-name', 'prod-course-set-id', 'prod-course-mode', + 'prod-course-set-status', 'prod-course-variant-id', 'prod-course-variant-name', + 'prod-course-variant-route-code', 'prod-course-variant-status', 'prod-course-variant-control-count', + 'prod-runtime-binding-id', 'prod-runtime-event-id', 'prod-runtime-binding-status', 'prod-runtime-notes', + 'admin-map-code', 'admin-map-name', 'admin-map-id', 'admin-map-version-id', 'admin-map-version-code', 'admin-map-status', 'admin-mapmeta-url', 'admin-tiles-root-url', 'admin-playfield-code', 'admin-playfield-name', 'admin-playfield-id', 'admin-playfield-version-id', 'admin-playfield-kind', 'admin-playfield-status', 'admin-playfield-version-code', 'admin-playfield-source-type', @@ -2744,8 +4149,16 @@ const devWorkbenchHTML = ` 'admin-pack-content-url', 'admin-pack-audio-url', 'admin-pack-theme-code', 'admin-published-asset-root', 'admin-tenant-code', 'admin-event-status', 'admin-event-ref-id', 'admin-event-slug', 'admin-event-name', 'admin-event-summary', 'admin-game-mode-code', 'admin-route-code', 'admin-source-notes', - 'admin-overrides-json', 'admin-pipeline-source-id', 'admin-pipeline-build-id', 'admin-pipeline-release-id', - 'admin-rollback-release-id' + 'admin-overrides-json', 'admin-presentation-id', 'admin-presentation-code', 'admin-presentation-name', + 'admin-presentation-type', 'admin-presentation-schema-json', 'admin-presentation-import-title', + 'admin-presentation-import-template-key', 'admin-presentation-import-source-type', + 'admin-presentation-import-version', 'admin-presentation-import-schema-url', 'admin-content-bundle-id', + 'admin-content-bundle-code', 'admin-content-bundle-name', 'admin-content-entry-url', + 'admin-content-asset-root-url', 'admin-content-metadata-json', 'admin-content-import-title', + 'admin-content-import-bundle-type', 'admin-content-import-source-type', 'admin-content-import-version', + 'admin-content-import-manifest-url', 'admin-content-import-asset-manifest-json', 'admin-pipeline-source-id', + 'admin-pipeline-build-id', 'admin-pipeline-release-id', 'admin-release-runtime-binding-id', + 'admin-release-presentation-id', 'admin-release-content-bundle-id', 'admin-rollback-release-id' ].forEach(function(id) { $(id).addEventListener('change', persistState); $(id).addEventListener('input', persistState); diff --git a/backend/internal/httpapi/router.go b/backend/internal/httpapi/router.go index 7819fcb..dc1948e 100644 --- a/backend/internal/httpapi/router.go +++ b/backend/internal/httpapi/router.go @@ -16,6 +16,7 @@ func NewRouter( entryService *service.EntryService, entryHomeService *service.EntryHomeService, adminResourceService *service.AdminResourceService, + adminProductionService *service.AdminProductionService, adminEventService *service.AdminEventService, adminPipelineService *service.AdminPipelineService, eventService *service.EventService, @@ -35,6 +36,7 @@ func NewRouter( entryHandler := handlers.NewEntryHandler(entryService) entryHomeHandler := handlers.NewEntryHomeHandler(entryHomeService) adminResourceHandler := handlers.NewAdminResourceHandler(adminResourceService) + adminProductionHandler := handlers.NewAdminProductionHandler(adminProductionService) adminEventHandler := handlers.NewAdminEventHandler(adminEventService) adminPipelineHandler := handlers.NewAdminPipelineHandler(adminPipelineService) eventHandler := handlers.NewEventHandler(eventService) @@ -56,6 +58,21 @@ func NewRouter( mux.Handle("POST /admin/maps", authMiddleware(http.HandlerFunc(adminResourceHandler.CreateMap))) mux.Handle("GET /admin/maps/{mapPublicID}", authMiddleware(http.HandlerFunc(adminResourceHandler.GetMap))) mux.Handle("POST /admin/maps/{mapPublicID}/versions", authMiddleware(http.HandlerFunc(adminResourceHandler.CreateMapVersion))) + mux.Handle("GET /admin/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("POST /admin/playfields", authMiddleware(http.HandlerFunc(adminResourceHandler.CreatePlayfield))) 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("PUT /admin/events/{eventPublicID}", authMiddleware(http.HandlerFunc(adminEventHandler.UpdateEvent))) mux.Handle("POST /admin/events/{eventPublicID}/source", authMiddleware(http.HandlerFunc(adminEventHandler.SaveSource))) + mux.Handle("GET /admin/events/{eventPublicID}/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("POST /admin/sources/{sourceID}/build", authMiddleware(http.HandlerFunc(adminPipelineHandler.BuildSource))) mux.Handle("GET /admin/builds/{buildID}", authMiddleware(http.HandlerFunc(adminPipelineHandler.GetBuild))) mux.Handle("POST /admin/builds/{buildID}/publish", authMiddleware(http.HandlerFunc(adminPipelineHandler.PublishBuild))) + mux.Handle("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))) if appEnv != "production" { mux.HandleFunc("GET /dev/workbench", devHandler.Workbench) diff --git a/backend/internal/service/admin_event_service.go b/backend/internal/service/admin_event_service.go index 25782fc..da67962 100644 --- a/backend/internal/service/admin_event_service.go +++ b/backend/internal/service/admin_event_service.go @@ -2,6 +2,7 @@ package service import ( "context" + "encoding/json" "fmt" "net/http" "strings" @@ -27,18 +28,25 @@ type AdminEventSummary struct { } type AdminEventReleaseRef struct { - ID string `json:"id"` - ConfigLabel *string `json:"configLabel,omitempty"` - ManifestURL *string `json:"manifestUrl,omitempty"` - ManifestChecksumSha256 *string `json:"manifestChecksumSha256,omitempty"` - RouteCode *string `json:"routeCode,omitempty"` + ID string `json:"id"` + ConfigLabel *string `json:"configLabel,omitempty"` + ManifestURL *string `json:"manifestUrl,omitempty"` + ManifestChecksumSha256 *string `json:"manifestChecksumSha256,omitempty"` + RouteCode *string `json:"routeCode,omitempty"` + Presentation *PresentationSummaryView `json:"presentation,omitempty"` + ContentBundle *ContentBundleSummaryView `json:"contentBundle,omitempty"` } type AdminEventDetail struct { - Event AdminEventSummary `json:"event"` - LatestSource *EventConfigSourceView `json:"latestSource,omitempty"` - SourceCount int `json:"sourceCount"` - CurrentSource *AdminAssembledSource `json:"currentSource,omitempty"` + Event AdminEventSummary `json:"event"` + LatestSource *EventConfigSourceView `json:"latestSource,omitempty"` + SourceCount int `json:"sourceCount"` + 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 { @@ -76,6 +84,84 @@ type SaveAdminEventSourceInput struct { 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 { Refs map[string]any `json:"refs"` Runtime map[string]any `json:"runtime"` @@ -240,10 +326,20 @@ func (s *AdminEventService) GetEventDetail(ctx context.Context, eventPublicID st if err != nil { 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{ - Event: buildAdminEventSummary(*record), - SourceCount: len(allSources), + Event: buildAdminEventSummary(*record), + SourceCount: len(allSources), + PresentationCount: len(presentations), + ContentBundleCount: len(contentBundles), } if len(sources) > 0 { latest, err := buildEventConfigSourceView(&sources[0], record.PublicID) @@ -253,9 +349,427 @@ func (s *AdminEventService) GetEventDetail(ctx context.Context, eventPublicID st result.LatestSource = latest 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 } +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) { eventRecord, err := s.store.GetAdminEventByPublicID(ctx, strings.TrimSpace(eventPublicID)) if err != nil { @@ -441,11 +955,160 @@ func buildAdminEventSummary(item postgres.AdminEventRecord) AdminEventSummary { ManifestURL: item.ManifestURL, ManifestChecksumSha256: item.ManifestChecksum, RouteCode: item.RouteCode, + Presentation: buildPresentationSummaryFromEventRecord(&item), + ContentBundle: buildContentBundleSummaryFromEventRecord(&item), } } 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 { result := &AdminAssembledSource{} 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) { for key, value := range overrides { if valueMap, ok := value.(map[string]any); ok { diff --git a/backend/internal/service/admin_pipeline_service.go b/backend/internal/service/admin_pipeline_service.go index 805d5bd..41a1739 100644 --- a/backend/internal/service/admin_pipeline_service.go +++ b/backend/internal/service/admin_pipeline_service.go @@ -15,15 +15,18 @@ type AdminPipelineService struct { } type AdminReleaseView struct { - ID string `json:"id"` - ReleaseNo int `json:"releaseNo"` - ConfigLabel string `json:"configLabel"` - ManifestURL string `json:"manifestUrl"` - ManifestChecksumSha256 *string `json:"manifestChecksumSha256,omitempty"` - RouteCode *string `json:"routeCode,omitempty"` - BuildID *string `json:"buildId,omitempty"` - Status string `json:"status"` - PublishedAt string `json:"publishedAt"` + ID string `json:"id"` + ReleaseNo int `json:"releaseNo"` + ConfigLabel string `json:"configLabel"` + ManifestURL string `json:"manifestUrl"` + ManifestChecksumSha256 *string `json:"manifestChecksumSha256,omitempty"` + RouteCode *string `json:"routeCode,omitempty"` + BuildID *string `json:"buildId,omitempty"` + Status string `json:"status"` + PublishedAt string `json:"publishedAt"` + Runtime *RuntimeSummaryView `json:"runtime,omitempty"` + Presentation *PresentationSummaryView `json:"presentation,omitempty"` + ContentBundle *ContentBundleSummaryView `json:"contentBundle,omitempty"` } type AdminEventPipelineView struct { @@ -38,6 +41,16 @@ type AdminRollbackReleaseInput struct { 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 { return &AdminPipelineService{ store: store, @@ -77,7 +90,18 @@ func (s *AdminPipelineService) GetEventPipeline(ctx context.Context, eventPublic } releases := make([]AdminReleaseView, 0, len(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{ @@ -94,6 +118,19 @@ func (s *AdminPipelineService) GetEventPipeline(ctx context.Context, eventPublic ManifestChecksumSha256: event.ManifestChecksum, RouteCode: event.RouteCode, 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 @@ -107,8 +144,84 @@ func (s *AdminPipelineService) GetBuild(ctx context.Context, buildID string) (*E return s.configService.GetEventConfigBuild(ctx, buildID) } -func (s *AdminPipelineService) PublishBuild(ctx context.Context, buildID string) (*PublishedReleaseView, error) { - return s.configService.PublishBuild(ctx, PublishBuildInput{BuildID: buildID}) +func (s *AdminPipelineService) PublishBuild(ctx context.Context, buildID string, input AdminPublishBuildInput) (*PublishedReleaseView, error) { + 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) { @@ -167,6 +280,9 @@ func buildAdminReleaseView(item postgres.EventRelease) AdminReleaseView { BuildID: item.BuildID, Status: item.Status, PublishedAt: item.PublishedAt.Format(timeRFC3339), + Runtime: buildRuntimeSummaryFromRelease(&item), + Presentation: buildPresentationSummaryFromRelease(&item), + ContentBundle: buildContentBundleSummaryFromRelease(&item), } } diff --git a/backend/internal/service/admin_production_service.go b/backend/internal/service/admin_production_service.go new file mode 100644 index 0000000..d128584 --- /dev/null +++ b/backend/internal/service/admin_production_service.go @@ -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" + } +} diff --git a/backend/internal/service/config_service.go b/backend/internal/service/config_service.go index 36f0701..3b8d0c7 100644 --- a/backend/internal/service/config_service.go +++ b/backend/internal/service/config_service.go @@ -58,10 +58,13 @@ type EventConfigBuildView struct { } type PublishedReleaseView struct { - EventID string `json:"eventId"` - Release ResolvedReleaseView `json:"release"` - ReleaseNo int `json:"releaseNo"` - PublishedAt string `json:"publishedAt"` + EventID string `json:"eventId"` + Release ResolvedReleaseView `json:"release"` + ReleaseNo int `json:"releaseNo"` + PublishedAt string `json:"publishedAt"` + Runtime *RuntimeSummaryView `json:"runtime,omitempty"` + Presentation *PresentationSummaryView `json:"presentation,omitempty"` + ContentBundle *ContentBundleSummaryView `json:"contentBundle,omitempty"` } type ImportLocalEventConfigInput struct { @@ -75,7 +78,10 @@ type BuildPreviewInput 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 { @@ -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") } + 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) if err != nil { 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, RouteCode: routeCode, BuildID: &buildRecord.ID, + RuntimeBindingID: runtimeBindingID, + PresentationID: presentationID, + ContentBundleID: contentBundleID, Status: "published", PayloadJSON: buildRecord.ManifestJSON, }) @@ -386,11 +408,160 @@ func (s *ConfigService) PublishBuild(ctx context.Context, input PublishBuildInpu ManifestChecksumSha256: releaseRecord.ManifestChecksum, RouteCode: releaseRecord.RouteCode, }, - ReleaseNo: releaseRecord.ReleaseNo, - PublishedAt: releaseRecord.PublishedAt.Format(timeRFC3339), + ReleaseNo: releaseRecord.ReleaseNo, + PublishedAt: releaseRecord.PublishedAt.Format(timeRFC3339), + Runtime: runtimeSummary, + Presentation: presentationSummary, + ContentBundle: contentBundleSummary, }, 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) { eventPublicID = strings.TrimSpace(eventPublicID) if eventPublicID == "" { diff --git a/backend/internal/service/event_play_service.go b/backend/internal/service/event_play_service.go index 3bd5229..82e951c 100644 --- a/backend/internal/service/event_play_service.go +++ b/backend/internal/service/event_play_service.go @@ -33,8 +33,11 @@ type EventPlayResult struct { ManifestChecksumSha256 *string `json:"manifestChecksumSha256,omitempty"` RouteCode *string `json:"routeCode,omitempty"` } `json:"release,omitempty"` - ResolvedRelease *ResolvedReleaseView `json:"resolvedRelease,omitempty"` - Play struct { + ResolvedRelease *ResolvedReleaseView `json:"resolvedRelease,omitempty"` + Runtime *RuntimeSummaryView `json:"runtime,omitempty"` + CurrentPresentation *PresentationSummaryView `json:"currentPresentation,omitempty"` + CurrentContentBundle *ContentBundleSummaryView `json:"currentContentBundle,omitempty"` + Play struct { AssignmentMode *string `json:"assignmentMode,omitempty"` CourseVariants []CourseVariantView `json:"courseVariants,omitempty"` CanLaunch bool `json:"canLaunch"` @@ -100,6 +103,19 @@ func (s *EventPlayService) GetEventPlay(ctx context.Context, input EventPlayInpu } } 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 { recent := buildEntrySessionSummary(&sessions[0]) diff --git a/backend/internal/service/event_service.go b/backend/internal/service/event_service.go index 5ff477c..520b3e1 100644 --- a/backend/internal/service/event_service.go +++ b/backend/internal/service/event_service.go @@ -30,7 +30,10 @@ type EventDetailResult struct { ManifestChecksumSha256 *string `json:"manifestChecksumSha256,omitempty"` RouteCode *string `json:"routeCode,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 { @@ -48,9 +51,12 @@ type LaunchEventResult struct { DisplayName string `json:"displayName"` } `json:"event"` Launch struct { - Source string `json:"source"` - ResolvedRelease *ResolvedReleaseView `json:"resolvedRelease,omitempty"` - Variant *VariantBindingView `json:"variant,omitempty"` + Source string `json:"source"` + ResolvedRelease *ResolvedReleaseView `json:"resolvedRelease,omitempty"` + Variant *VariantBindingView `json:"variant,omitempty"` + Runtime *RuntimeSummaryView `json:"runtime,omitempty"` + Presentation *PresentationSummaryView `json:"presentation,omitempty"` + ContentBundle *ContentBundleSummaryView `json:"contentBundle,omitempty"` Config struct { ConfigURL string `json:"configUrl"` ConfigLabel string `json:"configLabel"` @@ -110,6 +116,19 @@ func (s *EventService) GetEventDetail(ctx context.Context, eventPublicID string) } } 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 } @@ -205,6 +224,19 @@ func (s *EventService) LaunchEvent(ctx context.Context, input LaunchEventInput) result.Launch.Source = LaunchSourceEventCurrentRelease result.Launch.ResolvedRelease = buildResolvedReleaseFromEvent(event, LaunchSourceEventCurrentRelease) 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.ConfigLabel = *event.ConfigLabel result.Launch.Config.ConfigChecksumSha256 = event.ManifestChecksum diff --git a/backend/internal/service/release_view.go b/backend/internal/service/release_view.go index a605fab..73a4173 100644 --- a/backend/internal/service/release_view.go +++ b/backend/internal/service/release_view.go @@ -1,6 +1,11 @@ package service -import "cmr-backend/internal/store/postgres" +import ( + "context" + "strings" + + "cmr-backend/internal/store/postgres" +) const ( LaunchSourceEventCurrentRelease = "event_current_release" @@ -18,6 +23,36 @@ type ResolvedReleaseView struct { 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 { if event == nil || event.CurrentReleasePubID == nil || event.ConfigLabel == nil || event.ManifestURL == 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 { if session == nil || session.ReleasePublicID == nil || session.ConfigLabel == nil || session.ManifestURL == nil { return nil @@ -54,3 +185,96 @@ func buildResolvedReleaseFromSession(session *postgres.Session, source string) * } 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 +} diff --git a/backend/internal/store/postgres/admin_event_store.go b/backend/internal/store/postgres/admin_event_store.go index 6ed8af3..de0ba2d 100644 --- a/backend/internal/store/postgres/admin_event_store.go +++ b/backend/internal/store/postgres/admin_event_store.go @@ -16,21 +16,43 @@ type Tenant struct { } type AdminEventRecord struct { - ID string - PublicID string - TenantID *string - TenantCode *string - TenantName *string - Slug string - DisplayName string - Summary *string - Status string - CurrentReleaseID *string - CurrentReleasePubID *string - ConfigLabel *string - ManifestURL *string - ManifestChecksum *string - RouteCode *string + ID string + PublicID string + TenantID *string + TenantCode *string + TenantName *string + Slug string + DisplayName string + Summary *string + Status string + CurrentReleaseID *string + CurrentReleasePubID *string + ConfigLabel *string + ManifestURL *string + ManifestChecksum *string + RouteCode *string + 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 { @@ -90,10 +112,42 @@ func (s *Store) ListAdminEvents(ctx context.Context, limit int) ([]AdminEventRec er.config_label, er.manifest_url, 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 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_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 LIMIT $1 `, limit) @@ -133,10 +187,42 @@ func (s *Store) GetAdminEventByPublicID(ctx context.Context, eventPublicID strin er.config_label, er.manifest_url, 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 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_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 LIMIT 1 `, eventPublicID) @@ -212,6 +298,28 @@ func scanAdminEvent(row pgx.Row) (*AdminEventRecord, error) { &item.ManifestURL, &item.ManifestChecksum, &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) { return nil, nil @@ -240,6 +348,28 @@ func scanAdminEventFromRows(rows pgx.Rows) (*AdminEventRecord, error) { &item.ManifestURL, &item.ManifestChecksum, &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 { return nil, fmt.Errorf("scan admin event row: %w", err) diff --git a/backend/internal/store/postgres/dev_store.go b/backend/internal/store/postgres/dev_store.go index 8518207..44022ca 100644 --- a/backend/internal/store/postgres/dev_store.go +++ b/backend/internal/store/postgres/dev_store.go @@ -13,6 +13,13 @@ type DemoBootstrapSummary struct { SourceID string `json:"sourceId"` BuildID string `json:"buildId"` 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"` VariantManualRelease string `json:"variantManualReleaseId"` 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) } + 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 if err := tx.QueryRow(ctx, ` INSERT INTO events ( @@ -452,6 +609,13 @@ func (s *Store) EnsureDemoData(ctx context.Context) (*DemoBootstrapSummary, erro SourceID: source.ID, BuildID: build.ID, CardID: cardPublicID, + PlaceID: placePublicID, + MapAssetID: mapAssetPublicID, + TileReleaseID: tileReleasePublicID, + CourseSourceID: courseSourcePublicID, + CourseSetID: courseSetPublicID, + CourseVariantID: courseVariantPublicID, + RuntimeBindingID: runtimeBindingPublicID, VariantManualEventID: "evt_demo_variant_manual_001", VariantManualRelease: manualReleaseRow.PublicID, VariantManualCardID: manualCardPublicID, diff --git a/backend/internal/store/postgres/event_ops_store.go b/backend/internal/store/postgres/event_ops_store.go new file mode 100644 index 0000000..903f6e5 --- /dev/null +++ b/backend/internal/store/postgres/event_ops_store.go @@ -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 +} diff --git a/backend/internal/store/postgres/event_store.go b/backend/internal/store/postgres/event_store.go index 16a46a3..a510cfa 100644 --- a/backend/internal/store/postgres/event_store.go +++ b/backend/internal/store/postgres/event_store.go @@ -23,20 +23,54 @@ type Event struct { ManifestChecksum *string RouteCode *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 { - ID string - PublicID string - EventID string - ReleaseNo int - ConfigLabel string - ManifestURL string - ManifestChecksum *string - RouteCode *string - BuildID *string - Status string - PublishedAt time.Time + ID string + PublicID string + EventID string + ReleaseNo int + ConfigLabel string + ManifestURL string + ManifestChecksum *string + RouteCode *string + BuildID *string + Status string + 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 { @@ -85,9 +119,34 @@ func (s *Store) GetEventByPublicID(ctx context.Context, eventPublicID string) (* er.manifest_url, er.manifest_checksum_sha256, 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 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 LIMIT 1 `, eventPublicID) @@ -107,6 +166,23 @@ func (s *Store) GetEventByPublicID(ctx context.Context, eventPublicID string) (* &event.ManifestChecksum, &event.RouteCode, &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) { return nil, nil @@ -132,9 +208,34 @@ func (s *Store) GetEventByID(ctx context.Context, eventID string) (*Event, error er.manifest_url, er.manifest_checksum_sha256, 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 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 LIMIT 1 `, eventID) @@ -154,6 +255,23 @@ func (s *Store) GetEventByID(ctx context.Context, eventID string) (*Event, error &event.ManifestChecksum, &event.RouteCode, &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) { return nil, nil @@ -168,7 +286,7 @@ func (s *Store) NextEventReleaseNo(ctx context.Context, eventID string) (int, er var next int if err := s.pool.QueryRow(ctx, ` SELECT COALESCE(MAX(release_no), 0) + 1 - FROM event_releases + FROM event_releases er WHERE event_id = $1 `, eventID).Scan(&next); err != nil { return 0, fmt.Errorf("next event release no: %w", err) @@ -185,6 +303,9 @@ type CreateEventReleaseParams struct { ManifestChecksum *string RouteCode *string BuildID *string + RuntimeBindingID *string + PresentationID *string + ContentBundleID *string Status string PayloadJSON string } @@ -200,12 +321,15 @@ func (s *Store) CreateEventRelease(ctx context.Context, tx Tx, params CreateEven manifest_checksum_sha256, route_code, build_id, + runtime_binding_id, + presentation_id, + content_bundle_id, status, 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 - `, 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 if err := row.Scan( @@ -284,10 +408,46 @@ func (s *Store) ListEventReleasesByEventID(ctx context.Context, eventID string, limit = 20 } rows, err := s.pool.Query(ctx, ` - SELECT id, release_public_id, event_id, release_no, config_label, manifest_url, manifest_checksum_sha256, route_code, build_id, status, published_at - FROM event_releases - WHERE event_id = $1 - ORDER BY release_no DESC + SELECT + er.id, + 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.event_id = $1 + ORDER BY er.release_no DESC LIMIT $2 `, eventID, limit) 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) { row := s.pool.QueryRow(ctx, ` - SELECT id, release_public_id, event_id, release_no, config_label, manifest_url, manifest_checksum_sha256, route_code, build_id, status, published_at - FROM event_releases - WHERE release_public_id = $1 + SELECT + er.id, + 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 `, releasePublicID) @@ -330,6 +526,23 @@ func (s *Store) GetEventReleaseByPublicID(ctx context.Context, releasePublicID s &item.BuildID, &item.Status, &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) { return nil, nil @@ -354,9 +567,37 @@ func scanEventReleaseFromRows(rows pgx.Rows) (*EventRelease, error) { &item.BuildID, &item.Status, &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 { return nil, fmt.Errorf("scan event release row: %w", err) } 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 +} diff --git a/backend/internal/store/postgres/production_store.go b/backend/internal/store/postgres/production_store.go new file mode 100644 index 0000000..1028538 --- /dev/null +++ b/backend/internal/store/postgres/production_store.go @@ -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 +} diff --git a/backend/migrations/0008_production_skeleton.sql b/backend/migrations/0008_production_skeleton.sql new file mode 100644 index 0000000..1b01094 --- /dev/null +++ b/backend/migrations/0008_production_skeleton.sql @@ -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; diff --git a/backend/migrations/0009_event_ops_phase2.sql b/backend/migrations/0009_event_ops_phase2.sql new file mode 100644 index 0000000..e862e19 --- /dev/null +++ b/backend/migrations/0009_event_ops_phase2.sql @@ -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; diff --git a/backend/migrations/0010_event_default_bindings.sql b/backend/migrations/0010_event_default_bindings.sql new file mode 100644 index 0000000..e2d4a94 --- /dev/null +++ b/backend/migrations/0010_event_default_bindings.sql @@ -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; diff --git a/doc/backend/后台生产闭环架构草案.md b/doc/backend/后台生产闭环架构草案.md new file mode 100644 index 0000000..743c8b5 --- /dev/null +++ b/doc/backend/后台生产闭环架构草案.md @@ -0,0 +1,1041 @@ +# 后台生产闭环架构草案 +> 文档版本:v1.10 +> 最后更新:2026-04-03 20:05:00 + +本文档用于明确当前系统后续必须建立的后台生产闭环,重点回答以下问题: + +- 地图如何输入与管理 +- KML 如何输入与管理 +- 活动卡片如何输入与管理 +- 客户端最终到底消费什么 +- 一次活动从生产到发布的最小闭环是什么 + +本文档当前已经从初版的“地图 / 赛道 / 活动 / 发布”四对象方案,升级为更完整的**双域骨架**: + +- 地图运行域 +- 活动运营域 + +目标是让后端线程后续可以按统一对象模型落地,而不是继续围绕散装文件、散装页面地址和临时配置推进。 + +--- + +## 1. 总体结论 + +当前系统后续不应继续按“前端页面 + 若干散装配置”的方式推进,而应正式收口成一套后台生产与发布闭环。 + +当前补充确认: + +- 第一阶段采用**增量演进**,不一次性推翻现有稳定主链 +- 当前已稳定的: + - `Event` + - `EventRelease` + - `Session` + 继续保留 +- 新对象先以生产骨架方式增量引入 + +建议长期按以下链路理解: + +```text +地图资源输入 +-> 地点 / 地图资产管理 +-> 瓦片生成 +-> 瓦片版本发布 + +KML 输入 +-> 赛道解析 +-> 赛道集合 / 赛道方案管理 +-> 赛道版本发布 + +活动卡片输入 +-> 活动展示配置 +-> 绑定地图 / 赛道 / 规则 / 内容包 +-> 活动发布版本 + +最终: +Event Release +-> launch +-> manifest / config / tile release / variant +-> 客户端消费 +``` + +核心判断: + +- 地图是资源资产 +- KML 是赛道原始输入源,不是最终业务对象 +- 活动卡片是活动展示层,不是素材仓库 +- 发布版本才是客户端真正消费的正式产物 + +--- + +## 2. 双域骨架 + +当前建议把后台对象正式拆成两大域。 + +### 2.1 地图运行域 + +负责: + +- 地点 +- 地图资源 +- 瓦片版本 +- 赛道集合 +- 赛道方案 +- KML 原始输入源 + +### 2.2 活动运营域 + +负责: + +- 活动业务对象 +- 活动展示定义 +- 活动内容资源 +- 活动运行绑定 +- 活动发布版本 + +这两个域必须分开管理,但要通过运行绑定和发布版本连接起来。 + +--- + +## 3. 地图运行域对象 + +### 3.1 地点对象 `Place` + +作用: + +- 作为最上层业务地点对象 +- 组织一个地点下的多张地图 +- 作为地图与活动的上层归属 + +建议最小字段: + +- `placeId` +- `name` +- `region` +- `coverUrl` +- `description` +- `centerPoint` +- `status` + +关键原则: + +- 后台上层以地点为中心管理,不以地图为最高层 +- 用户认的是地点,不是某个底层 mapId + +### 3.2 地图资源对象 `MapAsset` + +作用: + +- 管理某个地点下的一张具体地图资源 +- 可表示竞赛图、训练图、导览图等不同图种 + +建议最小字段: + +- `mapId` +- `placeId` +- `name` +- `mapType` +- `coverUrl` +- `description` +- `sourceFiles` +- `status` + +关键原则: + +- 一个地点可有多张不同地图 +- 地图原始资产与瓦片产物必须分开 + +### 3.3 瓦片版本对象 `TileRelease` + +作用: + +- 管理某张地图的具体瓦片发布版本 + +建议最小字段: + +- `tileReleaseId` +- `mapId` +- `version` +- `tileBaseUrl` +- `metaUrl` +- `status` +- `publishedAt` + +关键原则: + +- 一张地图可以对应多个瓦片版本 +- 客户端运行时必须落到具体 `TileRelease` + +### 3.4 赛道集合对象 `CourseSet` + +作用: + +- 管理一组同主题赛道集合 +- 例如:校园顺序赛、校园积分赛、亲子体验赛 + +建议最小字段: + +- `courseSetId` +- `placeId` +- `mapId` +- `mode` +- `name` +- `description` +- `status` + +关键原则: + +- 顺序赛和积分赛都统一落在 `CourseSet` +- `CourseSet` 是赛道方案集合,不是具体方案本身 + +### 3.5 赛道方案对象 `CourseVariant` + +作用: + +- 表示一个具体可运行的赛道方案 +- 是客户端真正应该认的赛道对象 + +建议最小字段: + +- `variantId` +- `courseSetId` +- `name` +- `routeCode` +- `mode` +- `controlCount` +- `difficulty` +- `status` +- `sourceId` +- `configPatch` +- `isDefault` + +关键原则: + +- 顺序赛下的 8 点、12 点、16 点都应是 `CourseVariant` +- 积分赛下不同布点方案也应是 `CourseVariant` +- 客户端只认 `CourseVariant`,不直接认 KML + +### 3.6 原始输入对象 `CourseSource` + +作用: + +- 管理原始输入源 +- KML 只是输入来源,不是最终业务对象 + +建议最小字段: + +- `sourceId` +- `type` +- `fileUrl` +- `checksum` +- `importedAt` +- `parserVersion` + +关键原则: + +- KML 不直接下发客户端 +- KML 先进入后台解析,再转成 `CourseVariant` + +--- + +## 4. 活动运营域对象 + +### 4.1 活动对象 `Event` + +作用: + +- 管理活动卡片、活动详情、报名、签到、排行榜等展示与运营能力 +- 作为对外活动业务对象 + +建议最小字段: + +- `eventId` +- `title` +- `subtitle` +- `eventType` +- `status` +- `timeWindow` +- `defaultExperience` +- `playMode` +- `currentPresentationId` +- `currentBundleId` + +关键原则: + +- 地图默认活动和定制活动都统一属于 `Event` +- 只是 `eventType` 不同,例如: + - `default_experience` + - `standard_event` + - `custom_campaign` + +### 4.2 展示对象 `EventPresentation` + +作用: + +- 管理活动卡片、详情页、H5 结构和区块 schema + +建议最小字段: + +- `presentationId` +- `eventId` +- `cardSchema` +- `detailSchema` +- `templateKey` +- `status` +- `version` + +关键原则: + +- 活动页继续允许 H5 + API 快搭 +- 但展示页必须有正式对象和版本,不应只是散装页面地址 + +### 4.3 内容资源对象 `ContentBundle` + +作用: + +- 管理活动使用的静态资源和内容资源包 + +建议最小字段: + +- `bundleId` +- `name` +- `bundleType` +- `assetManifest` +- `version` +- `status` + +资源类型可包括: + +- 图片 +- 音频 +- 动画 +- 文创内容 +- H5 详情资源 +- 结果页资源 +- 品牌素材 + +关键原则: + +- 活动对象不直接吞大量资源 +- 活动只引用 `ContentBundle` + +### 4.4 运行绑定对象 `MapRuntimeBinding` + +作用: + +- 把活动运营域和地图运行域连接起来 +- 明确一个活动实际运行时使用哪张地图、哪条赛道、哪套瓦片和配置 + +建议最小字段: + +- `runtimeBindingId` +- `eventId` +- `placeId` +- `mapId` +- `tileReleaseId` +- `courseSetId` +- `courseVariantId` +- `configReleaseId` + +关键原则: + +- 活动不直接管理 KML、地图原始文件 +- 活动只引用已经整理好的运行资源 + +### 4.5 活动发布对象 `EventRelease` + +作用: + +- 管理活动对外发布的正式产物 +- 形成客户端唯一消费入口 + +建议最小字段: + +- `eventReleaseId` +- `eventId` +- `presentationId` +- `bundleId` +- `runtimeBindingId` +- `manifestUrl` +- `configUrl` +- `status` +- `publishedAt` +- `rollbackFrom` + +关键原则: + +- 客户端只认 `EventRelease` +- `EventRelease` 必须版本化、可回滚、可追踪 + +--- + +## 5. 三条输入链如何定 + +### 5.1 地图输入链 + +建议支持两类入口: + +1. 上传新的地图原始资源 +2. 基于已有地图重新生成新瓦片版本 + +最小流程: + +1. 创建 `Place` +2. 在地点下创建 `MapAsset` +3. 上传原始资源 +4. 后台校验与处理 +5. 生成瓦片 +6. 生成 `TileRelease` + +建议后台状态: + +- 草稿 +- 处理中 +- 可发布 +- 已发布 +- 已归档 + +### 5.2 KML 输入链 + +建议支持: + +1. 上传 KML 文件 +2. 后台自动解析 +3. 预览起点、终点、控制点 +4. 校验字段与几何合法性 +5. 保存为 `CourseSource` +6. 生成 `CourseVariant` +7. 归入某个 `CourseSet` + +关键要求: + +- KML 解析过程必须可预览 +- 起终点、控制点、点位顺序必须可人工复核 +- 发布后形成稳定 `CourseVariant`,不再直接依赖原始 KML + +### 5.3 活动卡片输入链 + +建议继续保持当前 H5 + API 快搭方向,但后台对象要正式化。 + +最小流程: + +1. 创建 `Event` +2. 配置活动基础信息 +3. 配置 `EventPresentation` +4. 导入或绑定 `ContentBundle` +5. 绑定 `MapRuntimeBinding` +6. 生成 `EventRelease` + +关键要求: + +- 保持 H5 灵活性 +- 但活动页必须有正式对象、正式版本、正式发布入口 + +--- + +## 6. 后台模块建议 + +第一阶段建议只做 5 个后台模块,不要一开始铺太大。 + +### 6.1 地点与地图管理 + +负责: + +- 地点列表 +- 地图列表 +- 地图详情 +- 原始资源上传 +- 瓦片版本管理 + +### 6.2 赛道管理 + +负责: + +- KML 上传 +- 赛道解析预览 +- 控制点校验 +- `CourseSet` +- `CourseVariant` + +### 6.3 活动管理 + +负责: + +- 活动列表 +- 活动基础信息 +- 默认体验活动标记 +- 活动时间与状态 + +### 6.4 展示与内容管理 + +负责: + +- 活动卡片与详情配置 +- H5 页面 schema +- 内容资源包管理 + +### 6.5 发布管理 + +负责: + +- 运行绑定 +- 当前发布版本 +- 历史版本 +- 回滚 +- 发布记录 + +补充说明: + +- 第一阶段为了提高联调效率,可以把新增生产骨架对象接入 `/dev/workbench` +- 但 `/dev/workbench` 当前定位应当是: + - 第一阶段生产骨架联调台 + - 对象关系验证台 + - 输入链路验证台 +- 不应当在这一阶段把它扩张成完整后台系统 + +--- + +## 7. 客户端最终消费什么 + +后续客户端不应直接消费后台散装对象,而应以发布产物为准。 + +客户端正式消费边界建议为: + +- 活动摘要列表 +- 活动详情数据 +- `launch` +- `manifestUrl` +- `configUrl` +- `eventReleaseId` +- `place/map/tile/course/variant` 摘要 + +也就是说: + +- 客户端不直接碰地图原始文件 +- 客户端不直接碰原始 KML +- 客户端不直接碰活动草稿 +- 客户端只消费发布后的稳定产物 + +--- + +## 8. 第一阶段 workbench 接入建议 + +建议 backend 在第一阶段把新增生产骨架接口接入 `/dev/workbench`,但范围严格控制。 + +### 8.1 建议接入的对象 + +- `Place` +- `MapAsset` +- `TileRelease` +- `CourseSource` +- `CourseSet` +- `CourseVariant` +- `MapRuntimeBinding` + +### 8.2 建议提供的最小能力 + +- list +- create +- detail +- binding + +### 8.3 当前不建议接入的能力 + +- 完整 Event 管理台 +- `EventPresentation` 可视化搭建 +- `ContentBundle` 资源管理台 +- Build / Release 完整后台 +- 编辑、删除、批量操作、审核流 + +设计原则: + +**当前 `/dev/workbench` 只承担“第一阶段生产骨架联调台”的角色,不承担正式后台系统角色。** + +--- + +## 9. 最小接线阶段建议 + +在第一阶段生产骨架对象已经落库、接口已接入 workbench 之后,下一步建议进入真正的“最小接线”阶段。 + +### 9.1 接线目标 + +- 把 `MapRuntimeBinding` 和现有 `EventRelease` 接起来 +- 让运行对象开始逐步进入 `launch` +- 继续保持当前稳定前端链兼容 + +### 9.2 接线顺序 + +#### 第一步:`EventRelease` 挂接 `runtimeBindingId` + +目标: + +- 当前发布版本不仅指向展示和配置,还能指向实际运行对象 + +#### 第二步:查询 release 时带出最小 runtime 摘要 + +建议最少包括: + +- `runtimeBindingId` +- `placeId` +- `mapId` +- `tileReleaseId` +- `courseSetId` +- `courseVariantId` + +#### 第三步:`launch` 增加兼容性的 `runtime` 块 + +要求: + +- 继续保留当前稳定字段: + - `resolvedRelease` + - `business` + - `variant` +- 新增 `runtime` 摘要块 +- 先透出,不要求客户端第一阶段立即强依赖 + +### 9.3 当前阶段原则 + +- 只加字段,不改旧字段语义 +- 只补连接,不推翻主链 +- 先做到“可挂接、可透出、可验证” +- 前端现有稳定联调链不能被打断 + +### 9.4 第四刀:发布闭环阶段 + +在第三刀已经完成: + +- `MapRuntimeBinding -> EventRelease` +- `launch.runtime` 兼容透出 + +之后,下一步建议进入**发布闭环阶段**。 + +目标: + +- 让 `EventRelease` 在 publish 时就直接带上 `runtimeBindingId` +- 避免继续依赖“先 publish,再手工 bind runtime”的两段式操作 +- 保持当前旧链路完全兼容,避免打断现有前端稳定联调 + +建议顺序: + +#### 第一步:publish/build 接口支持传入 `runtimeBindingId` + +要求: + +- 如果 publish 时传入 `runtimeBindingId` +- 则发布完成后直接生成一个已接 runtime 的完整 `EventRelease` +- 如果不传入,则继续允许后续通过现有 bind runtime 接口补挂 + +#### 第二步:workbench publish 区接入 runtime 选择 + +要求: + +- 在 `/dev/workbench` 的发布操作里增加 `Runtime Binding` 选择项 +- 支持最小联调链: + - 选 release source/build + - 选 `runtimeBindingId` + - publish + - launch 验证 + +#### 第三步:保持 release / launch 查询摘要一致 + +要求: + +- `Get Release` +- `launch` + +继续稳定返回最小 `runtime` 摘要,便于前端和总控验证发布结果是否完整。 + +关键原则: + +- 只加能力,不改旧语义 +- 新流程优先,旧流程兼容 +- 发布结果尽量原子,减少漏挂 runtime 的人工步骤 + +### 9.5 第五刀:前端接线阶段 + +在第四刀已经完成: + +- publish 直接支持 `runtimeBindingId` +- workbench publish 区可选择或填写 runtime +- 发布成功返回 `runtime` + +之后,下一步建议进入**前端正式接线阶段**。 + +目标: + +- 前端开始正式消费 `launch.runtime` +- 运行对象摘要逐步进入准备页、地图页、结果页、历史页 +- 保持旧字段兼容,不打断当前稳定主链 + +建议顺序: + +#### 第一步:准备页接 `launch.runtime` + +最先展示: + +- `place` +- `map` +- `course variant` +- `routeCode` + +#### 第二步:地图页接 runtime 摘要 + +要求: + +- 地图页至少能在调试区或摘要区确认当前实际运行的是哪套地图和赛道 + +#### 第三步:结果与历史逐步接入 + +要求: + +- 单局结果页优先透出当前运行对象摘要 +- 历史详情页随后补齐 +- 列表页可最后再做 + +关键原则: + +- 前端从新增 `runtime` 摘要开始接,不要求一次性重构所有页面 +- `resolvedRelease / business / variant` 旧字段继续保留 +- 这一步的重点是“让运行对象真正被前端看见”,不是再扩更多后端对象 + +### 9.6 第六刀:活动运营域第二阶段 + +在第五刀前端摘要接线已经完成第一轮之后,当前主线建议切换为: + +- `EventPresentation` +- `ContentBundle` +- `EventRelease` + +这一组对象的正式化落地。 + +当前建议优先做: + +1. `EventPresentation` 最小落库 +2. `ContentBundle` 最小落库 +3. `EventRelease` 明确绑定: + - `presentationId` + - `bundleId` + - `runtimeBindingId` + +当前不建议继续优先扩: + +- Place / Map / Tile / Course 一侧对象 +- runtime 摘要继续扩更多前端页面 +- 复杂活动后台 UI + +### 9.7 第七刀:活动运营域第二阶段第二刀 + +在第六刀已经完成: + +- `EventPresentation` 最小落库 +- `ContentBundle` 最小落库 +- `EventRelease` 已绑定: + - `presentationId` + - `bundleId` + - `runtimeBindingId` +- publish 已支持显式挂接: + - `presentationId` + - `contentBundleId` + - `runtimeBindingId` + +之后,下一步建议进入活动运营域第二阶段第二刀。 + +目标: + +- 让 `EventPresentation / ContentBundle` 从“后台对象”进入“可查询、可验证、可消费的发布摘要” +- 继续保持现有前端稳定链兼容 + +建议顺序: + +#### 第一步:`event detail` 透出当前展示与内容包摘要 + +建议最少透出: + +- `currentPresentation` + - `presentationId` + - `templateKey` + - `version` +- `currentContentBundle` + - `bundleId` + - `bundleType` + - `version` + +#### 第二步:`Get Release` 透出完整最小摘要 + +建议 `release detail` 同时包含: + +- `presentation` +- `contentBundle` +- `runtime` + +其中前两者以摘要形式返回即可,避免一开始就下发复杂 schema。 + +#### 第三步:`launch` 增加兼容性的活动运营摘要块 + +在保持旧字段与当前 `runtime` 不变的前提下,新增: + +- `presentation` +- `contentBundle` + +建议最少包括: + +- `presentationId` +- `templateKey` +- `bundleId` +- `bundleType` + +#### 第四步:publish 增加默认补齐逻辑 + +如果 publish 未显式传入: + +- `presentationId` +- `contentBundleId` + +允许按 event 当前默认配置自动补齐。 + +关键原则: + +- 只加摘要,不先做全量 schema 下发 +- 只加能力,不改旧语义 +- 先让前后端能验证活动运营域对象已经进入发布链 + +### 9.8 第八刀:活动运营域发布摘要闭环与内容包统一导入入口 + +在第七刀已经完成之后,当前下一步建议切到: + +- 让 `EventRelease` 成为统一可验证的活动运营发布对象 +- 让 `ContentBundle` 不再只靠手工创建,而有统一导入入口 + +#### 第一部分:发布摘要闭环 + +目标: + +- 保证 `event detail / event play / launch / release detail` 对活动运营摘要的返回语义一致 + +建议 `release detail` 最少固定透出: + +- `presentation` + - `presentationId` + - `templateKey` + - `version` +- `contentBundle` + - `bundleId` + - `bundleType` + - `version` +- `runtime` + - `runtimeBindingId` + - `placeId` + - `mapId` + - `tileReleaseId` + - `courseVariantId` + +这一步的意义是: + +- 让 `EventRelease` 真正成为客户端、总控、workbench 都能验证的统一发布对象 + +#### 第二部分:`ContentBundle` 统一导入入口 + +目标: + +- 后续静态资源、动画、音频、文创等内容,不直接散落在活动对象里 +- 统一先进入 `ContentBundle` + +当前阶段建议只做最小导入能力: + +- 导入入口 +- 资源清单/manifest 记录 +- bundle 元信息 + +而不在这一刀里做: + +- 复杂资源平台 +- 大而全素材管理 +- 审核流 +- 全量页面 schema 组装 + +关键原则: + +- 活动对象继续只引用 `ContentBundle` +- 客户端继续只消费摘要,不消费资源明细 +- 统一导入入口优先于复杂管理后台 + +### 9.9 第九刀:展示定义统一导入与默认绑定 + +在第八刀已经完成之后,当前下一步建议切到: + +- `EventPresentation` 统一导入入口 +- `Event` 当前默认 active 绑定稳定化 + +#### 第一部分:`EventPresentation` 统一导入入口 + +目标: + +- 外部活动卡片/H5 搭建系统后续可以正式接入 backend +- 展示定义不再只靠手工创建 + +当前阶段建议只做最小导入能力: + +- 导入入口 +- schema 入口记录 +- presentation 元信息 + +建议最小字段: + +- `templateKey` +- `sourceType` +- `schemaUrl` +- `version` +- `title` + +不在这一刀里做: + +- 可视化编辑器 +- 全量 schema 验证平台 +- 复杂运营后台 + +#### 第二部分:`Event` 默认 active 绑定 + +目标: + +- 固定 `Event` 当前默认使用的: + - `presentation` + - `contentBundle` + - `runtimeBinding` + +这样 publish 在未显式传入时,才有稳定默认继承基础。 + +建议至少明确: + +- `currentPresentationId` +- `currentContentBundleId` +- `currentRuntimeBindingId` + +#### 第三部分:workbench 最小验证 + +建议 workbench 增加: + +- `Import Presentation` +- 查看 event 当前 active 三元组 +- publish 默认继承验证 + +关键原则: + +- `Event` 继续是业务编排壳 +- `EventPresentation` 和 `ContentBundle` 都走统一导入入口 +- 前端继续只消费发布摘要,不直接消费后台草稿对象 + +--- + +## 10. 一次标准活动上线的最小闭环 + +建议先把一次活动上线收成最小闭环: + +1. 创建地点 +2. 创建地图并生成瓦片版本 +3. 上传并解析 KML,生成赛道 variant +4. 搭建活动卡片与活动详情 +5. 绑定地图、赛道、玩法与内容包 +6. 生成 `EventRelease` +7. 客户端通过 `launch` 消费该 `EventRelease` + +这就是后续联调和上线的最小标准流程。 + +--- + +## 11. 第一阶段实施建议 + +结合 backend 当前已有骨架,建议第一阶段按以下方式推进: + +### 9.1 先增量引入地图运行域对象 + +第一阶段建议优先落库: + +- `Place` +- `MapAsset` +- `TileRelease` +- `CourseSource` +- `CourseSet` +- `CourseVariant` +- `MapRuntimeBinding` + +原因: + +- 这批对象最直接承接地图、瓦片、KML、赛道输入链 +- 与当前多赛道 variant 联调方向一致 +- 对现有 `Event / EventRelease / Session` 主链侵入最小 + +### 9.2 活动运营域先保持主链稳定 + +第一阶段建议: + +- 保留当前 `Event` +- 保留当前 `EventRelease` +- `EventPresentation` 与 `ContentBundle` 先作为架构对象明确下来 +- 后续第二阶段再补完整落库 + +### 9.3 launch 采用两阶段兼容 + +第一阶段: + +- 保留当前稳定字段: + - `resolvedRelease` + - `business` + - `variant` + +第二阶段: + +- 再补完整运行对象字段: + - `placeId` + - `mapId` + - `tileReleaseId` + - `courseVariantId` + - `eventReleaseId` + +这样可以避免打断当前已稳定的前后端联调主链。 + +--- + +## 12. 当前阶段最该先定的内容 + +当前建议优先定以下内容,不要先陷入后台 UI 细节: + +1. `Place` 的最小字段与状态机 +2. `MapAsset / TileRelease` 的最小字段与版本关系 +3. `CourseSet / CourseVariant / CourseSource` 的最小字段与 KML 解析结果结构 +4. `Event / EventPresentation / ContentBundle` 的最小字段与引用关系 +5. `MapRuntimeBinding / EventRelease` 的发布与回滚规则 + +这些一旦定下来: + +- 地图怎么进来 +- KML 怎么进来 +- 活动卡片怎么进来 +- 客户端怎么消费 + +就都能真正闭环。 + +--- + +## 13. 一句话结论 + +当前最重要的不是继续加页面,而是先把后台生产闭环正式化。 + +后续整个系统建议围绕以下双域骨架继续收口: + +### 地图运行域 + +- `Place` +- `MapAsset` +- `TileRelease` +- `CourseSet` +- `CourseVariant` +- `CourseSource` + +### 活动运营域 + +- `Event` +- `EventPresentation` +- `ContentBundle` +- `MapRuntimeBinding` +- `EventRelease` + +只有这套对象模型和三条输入链定下来,地图、KML、活动卡片三类资产才能真正形成可维护、可发布、可回滚的完整生产体系。 diff --git a/doc/backend/生产发布与数据库上线方案.md b/doc/backend/生产发布与数据库上线方案.md new file mode 100644 index 0000000..907e79e --- /dev/null +++ b/doc/backend/生产发布与数据库上线方案.md @@ -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 验证** +- **最后再切客户端入口** + +不要把开发测试数据库直接视为生产数据库。 diff --git a/doc/gameplay/多线程联调协作方式.md b/doc/gameplay/多线程联调协作方式.md index 961ef4d..8758241 100644 --- a/doc/gameplay/多线程联调协作方式.md +++ b/doc/gameplay/多线程联调协作方式.md @@ -1,6 +1,6 @@ # 多线程联调协作方式 -> 文档版本:v1.0 -> 最后更新:2026-04-02 08:28:05 +> 文档版本:v1.1 +> 最后更新: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) - [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) - [文档索引.md](D:/dev/cmr-mini/doc/文档索引.md) -- [f2b.md](D:/dev/cmr-mini/f2b.md) -- [b2f.md](D:/dev/cmr-mini/b2f.md) +- [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) 以及当前代码事实: @@ -118,8 +139,8 @@ 总控线程不应该: -- 抢写前端线程的 `f2b.md` -- 抢写后端线程的 `b2f.md` +- 抢写前端线程的 [f2t.md](D:/dev/cmr-mini/f2t.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/` 正式文档 也就是说,总控线程不是“第三份协作文档”,而是: @@ -152,8 +173,9 @@ ```text 前端/后端各自推进 --> 遇到跨边界事项时写入 f2b / b2f --> 总控线程读取两份协作文档 +-> 总控通过 t2b / t2f 下发阶段性要求 +-> 前后端通过 b2t / f2t 回写事实与阻塞 +-> 总控线程读取四份协作文档 -> 判断是否需要: - 调整主线优先级 - 更新正式方案文档 @@ -163,7 +185,7 @@ 也就是说: -- `f2b / b2f` 是协作事实层 +- `t2b / b2t / t2f / f2t` 是协作事实层 - `doc/` 是正式知识层 - 代码是最终实现层 @@ -177,8 +199,10 @@ 位于仓库根目录: -- [f2b.md](D:/dev/cmr-mini/f2b.md) -- [b2f.md](D:/dev/cmr-mini/b2f.md) +- [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) 特点: @@ -265,7 +289,7 @@ - 模拟器接入 - 交互和体验 -并把需要后端确认的事项写入 [f2b.md](D:/dev/cmr-mini/f2b.md)。 +并把当前事实、阻塞和待确认事项回写到 [f2t.md](D:/dev/cmr-mini/f2t.md)。 ### 7.2 后端线程 @@ -276,7 +300,7 @@ - release / manifest / config 发布链 - workbench / dev tools -并把前端需要知道的契约写入 [b2f.md](D:/dev/cmr-mini/b2f.md)。 +并把当前事实、完成项和待确认事项回写到 [b2t.md](D:/dev/cmr-mini/b2t.md)。 ### 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/` 下的正式文档 后续如果线程数量增加,或者联调链变复杂,优先仍然是: diff --git a/doc/gameplay/活动运营域摘要第一刀联调回归清单.md b/doc/gameplay/活动运营域摘要第一刀联调回归清单.md new file mode 100644 index 0000000..c1b1be3 --- /dev/null +++ b/doc/gameplay/活动运营域摘要第一刀联调回归清单.md @@ -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 稳定主链。** diff --git a/doc/gameplay/第五刀联调回归清单.md b/doc/gameplay/第五刀联调回归清单.md new file mode 100644 index 0000000..fe1a1e6 --- /dev/null +++ b/doc/gameplay/第五刀联调回归清单.md @@ -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 摘要可同时回流 +- 恢复链、结果链、首页摘要链不互相打架 diff --git a/doc/文档索引.md b/doc/文档索引.md index 554067b..6eb28d4 100644 --- a/doc/文档索引.md +++ b/doc/文档索引.md @@ -1,6 +1,6 @@ # 文档索引 -> 文档版本:v1.0 -> 最后更新:2026-04-02 18:10:04 +> 文档版本:v1.3 +> 最后更新:2026-04-03 19:38:00 维护约定: @@ -46,6 +46,8 @@ - [多线程联调协作方式](/D:/dev/cmr-mini/doc/gameplay/多线程联调协作方式.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) @@ -75,6 +77,12 @@ - [网关文档索引](/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)。 diff --git a/f2b.md b/f2b.md index 6f15645..81922c9 100644 --- a/f2b.md +++ b/f2b.md @@ -1,6 +1,6 @@ # F2B 协作清单 -> 文档版本:v1.3 -> 最后更新:2026-04-02 15:19:37 +> 文档版本:v1.4 +> 最后更新: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 -- 时间:2026-04-02 +- 时间:2026-04-03 - 提出方:前端 - 当前事实: - - 多赛道下一步最值钱的是专项联调,而不是继续扩页面 - - 当前优先链路为: - - `manual` 赛道选择 -> `launch.variant` - - `launch.variant` -> `ongoing / result / results` + - 当前主链已进入“稳住 + 联调修复”阶段 + - 活动运营域摘要第一刀已接通,但前端不会主动扩复杂运营样式 - 需要对方确认什么: - 无 -- 状态:等待 backend 提供联调数据 +- 状态:前端执行中 diff --git a/f2t.md b/f2t.md new file mode 100644 index 0000000..742cbf4 --- /dev/null +++ b/f2t.md @@ -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 主链隔离 + - 缺字段降级 +- 需要确认什么: + - 无 +- 是否已解决:是 + +--- + +## 下一步 + +- 当前进入活动运营域摘要第一刀的联调回归与小范围修复阶段 diff --git a/miniprogram/app.ts b/miniprogram/app.ts index 49ab83a..7116ed3 100644 --- a/miniprogram/app.ts +++ b/miniprogram/app.ts @@ -7,6 +7,7 @@ App({ backendBaseUrl: null, backendAuthTokens: null, pendingResultSnapshot: null, + pendingResultLaunchEnvelope: null, pendingHeartRateAutoConnect: null, }, onLaunch() { diff --git a/miniprogram/pages/event-prepare/event-prepare.ts b/miniprogram/pages/event-prepare/event-prepare.ts index d591136..84cd07e 100644 --- a/miniprogram/pages/event-prepare/event-prepare.ts +++ b/miniprogram/pages/event-prepare/event-prepare.ts @@ -19,6 +19,12 @@ type EventPreparePageData = { assignmentMode: string variantModeText: string variantSummaryText: string + presentationText: string + contentBundleText: string + runtimePlaceText: string + runtimeMapText: string + runtimeVariantText: string + runtimeRouteCodeText: string selectedVariantId: string selectedVariantText: string selectableVariants: Array<{ @@ -85,6 +91,24 @@ function formatVariantSummary(result: BackendEventPlayResult): string { 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( currentVariantId: string, assignmentMode?: string | null, @@ -184,6 +208,12 @@ Page({ assignmentMode: '', variantModeText: '--', variantSummaryText: '--', + presentationText: '--', + contentBundleText: '--', + runtimePlaceText: '待 launch 确认', + runtimeMapText: '待 launch 确认', + runtimeVariantText: '待 launch 确认', + runtimeRouteCodeText: '待 launch 确认', selectedVariantId: '', selectedVariantText: '当前无需手动指定赛道', selectableVariants: [], @@ -277,6 +307,20 @@ Page({ assignmentMode: result.play.assignmentMode || '', variantModeText: formatAssignmentMode(result.play.assignmentMode), 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, selectedVariantText: selectedVariant ? `${selectedVariant.name} / ${selectedVariant.routeCodeText}` @@ -512,6 +556,8 @@ Page({ selectedVariantText: selectedVariant ? `${selectedVariant.name} / ${selectedVariant.routeCodeText}` : '当前无需手动指定赛道', + runtimeVariantText: selectedVariant ? selectedVariant.name : '待 launch 确认', + runtimeRouteCodeText: selectedVariant ? selectedVariant.routeCodeText : '待 launch 确认', selectableVariants, }) }, diff --git a/miniprogram/pages/event-prepare/event-prepare.wxml b/miniprogram/pages/event-prepare/event-prepare.wxml index 167e77d..3025e3d 100644 --- a/miniprogram/pages/event-prepare/event-prepare.wxml +++ b/miniprogram/pages/event-prepare/event-prepare.wxml @@ -16,6 +16,40 @@ 当前选择:{{selectedVariantText}} + + 活动运营摘要 + 当前阶段先展示活动运营对象摘要,不展开复杂 schema。 + + 展示版本 + {{presentationText}} + + + 内容包版本 + {{contentBundleText}} + + + + + 运行对象摘要 + 当前阶段以前端已知信息预览,最终绑定以后端 `launch.runtime` 为准。 + + 地点 + {{runtimePlaceText}} + + + 地图 + {{runtimeMapText}} + + + 赛道 + {{runtimeVariantText}} + + + RouteCode + {{runtimeRouteCodeText}} + + + 赛道选择 当前活动要求手动指定赛道。这里的选择会随 launch 一起带给后端,最终绑定以后端返回为准。 diff --git a/miniprogram/pages/event/event.ts b/miniprogram/pages/event/event.ts index 31ec24b..a220604 100644 --- a/miniprogram/pages/event/event.ts +++ b/miniprogram/pages/event/event.ts @@ -11,6 +11,8 @@ type EventPageData = { statusText: string variantModeText: string variantSummaryText: string + presentationText: string + contentBundleText: string } function formatAssignmentMode(mode?: string | null): string { @@ -38,6 +40,30 @@ function formatVariantSummary(result: BackendEventPlayResult): string { 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 { const app = getApp() const tokens = app.globalData && app.globalData.backendAuthTokens @@ -57,6 +83,8 @@ Page({ statusText: '待加载', variantModeText: '--', variantSummaryText: '--', + presentationText: '--', + contentBundleText: '--', } as EventPageData, onLoad(query: { eventId?: string }) { @@ -112,6 +140,8 @@ Page({ statusText: result.play.canLaunch ? '可启动' : '当前不可启动', variantModeText: formatAssignmentMode(result.play.assignmentMode), variantSummaryText: formatVariantSummary(result), + presentationText: formatPresentationSummary(result), + contentBundleText: formatContentBundleSummary(result), }) }, diff --git a/miniprogram/pages/event/event.wxml b/miniprogram/pages/event/event.wxml index a08f3d6..b39050f 100644 --- a/miniprogram/pages/event/event.wxml +++ b/miniprogram/pages/event/event.wxml @@ -13,6 +13,8 @@ 状态:{{statusText}} 赛道模式:{{variantModeText}} 赛道摘要:{{variantSummaryText}} + 展示版本:{{presentationText}} + 内容包版本:{{contentBundleText}} diff --git a/miniprogram/pages/home/home.ts b/miniprogram/pages/home/home.ts index 15a0bce..5607d4e 100644 --- a/miniprogram/pages/home/home.ts +++ b/miniprogram/pages/home/home.ts @@ -12,6 +12,8 @@ type HomePageData = { channelText: string ongoingSessionText: string recentSessionText: string + ongoingRuntimeText: string + recentRuntimeText: string cards: BackendCardResult[] } @@ -26,6 +28,18 @@ function formatSessionSummary(session?: BackendEntryHomeResult['ongoingSession'] 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 { const app = getApp() const tokens = app.globalData && app.globalData.backendAuthTokens @@ -43,6 +57,8 @@ Page({ channelText: '--', ongoingSessionText: '无', recentSessionText: '无', + ongoingRuntimeText: '运行对象 --', + recentRuntimeText: '运行对象 --', cards: [], } as HomePageData, @@ -92,6 +108,8 @@ Page({ channelText: `${result.channel.displayName} / ${result.channel.code}`, ongoingSessionText: formatSessionSummary(result.ongoingSession), recentSessionText: formatSessionSummary(result.recentSession), + ongoingRuntimeText: formatRuntimeSummary(result.ongoingSession), + recentRuntimeText: formatRuntimeSummary(result.recentSession), cards: result.cards || [], }) }, diff --git a/miniprogram/pages/home/home.wxml b/miniprogram/pages/home/home.wxml index 9442853..4a2b747 100644 --- a/miniprogram/pages/home/home.wxml +++ b/miniprogram/pages/home/home.wxml @@ -11,7 +11,9 @@ 当前状态 {{statusText}} 进行中:{{ongoingSessionText}} + 进行中运行对象:{{ongoingRuntimeText}} 最近一局:{{recentSessionText}} + 最近一局运行对象:{{recentRuntimeText}} diff --git a/miniprogram/pages/map/map.ts b/miniprogram/pages/map/map.ts index 457e3e1..958039f 100644 --- a/miniprogram/pages/map/map.ts +++ b/miniprogram/pages/map/map.ts @@ -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({ data: { showDebugPanel: false, @@ -1640,6 +1659,7 @@ Page({ const app = getApp() if (app.globalData) { app.globalData.pendingResultSnapshot = snapshot + app.globalData.pendingResultLaunchEnvelope = currentGameLaunchEnvelope } }, @@ -2422,6 +2442,7 @@ Page({ const snapshot = mapEngine.getGameInfoSnapshot() const localRows = snapshot.localRows.concat([ + ...buildRuntimeSummaryRows(currentGameLaunchEnvelope), { label: '比例尺开关', value: this.data.showCenterScaleRuler ? '开启' : '关闭' }, { label: '比例尺锚点', value: this.data.centerScaleRulerAnchorMode === 'compass-center' ? '指北针圆心' : '屏幕中心' }, { label: '按钮习惯', value: this.data.sideButtonPlacement === 'right' ? '右手' : '左手' }, @@ -2450,7 +2471,7 @@ Page({ resultSceneSubtitle: snapshot.subtitle, resultSceneHeroLabel: snapshot.heroLabel, resultSceneHeroValue: snapshot.heroValue, - resultSceneRows: snapshot.rows, + resultSceneRows: snapshot.rows.concat(buildRuntimeSummaryRows(currentGameLaunchEnvelope)), }) }, diff --git a/miniprogram/pages/result/result.ts b/miniprogram/pages/result/result.ts index cff5978..f689580 100644 --- a/miniprogram/pages/result/result.ts +++ b/miniprogram/pages/result/result.ts @@ -1,6 +1,7 @@ import { loadBackendAuthTokens, loadBackendBaseUrl } from '../../utils/backendAuth' import { getSessionResult } from '../../utils/backendApi' import type { MapEngineResultSnapshot } from '../../engine/map/mapEngine' +import type { GameLaunchEnvelope } from '../../utils/gameLaunch' type ResultPageData = { sessionId: string @@ -41,6 +42,56 @@ function formatRouteSummary(input: { return '默认赛道' } +function formatRuntimeValue(...candidates: Array): 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() + return app.globalData && app.globalData.pendingResultLaunchEnvelope + ? app.globalData.pendingResultLaunchEnvelope + : null +} + Page({ data: { sessionId: '', @@ -75,17 +126,22 @@ Page({ return } + const pendingLaunchEnvelope = loadPendingResultLaunchEnvelope() this.setData({ statusText: '正在加载结果', sessionTitleText: snapshot.title, sessionSubtitleText: snapshot.subtitle, - rows: [ + rows: appendRuntimeRows([ { label: snapshot.heroLabel, value: snapshot.heroValue }, ...snapshot.rows.map((row) => ({ label: row.label, 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) { @@ -110,11 +166,12 @@ Page({ accessToken, sessionId, }) + const pendingLaunchEnvelope = loadPendingResultLaunchEnvelope() this.setData({ statusText: '单局结果加载完成', 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)}`, - rows: [ + rows: appendRuntimeRows([ { label: '赛道版本', value: formatRouteSummary(result.session) }, { label: '最终得分', value: formatValue(result.result.finalScore) }, { label: '最终用时(秒)', value: formatValue(result.result.finalDurationSec) }, @@ -123,8 +180,16 @@ Page({ { label: '累计里程(m)', value: formatValue(result.result.distanceMeters) }, { label: '平均速度(km/h)', value: formatValue(result.result.averageSpeedKmh) }, { 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() + if (app.globalData) { + app.globalData.pendingResultLaunchEnvelope = null + } } catch (error) { const message = error && (error as { message?: string }).message ? (error as { message: string }).message : '未知错误' this.setData({ diff --git a/miniprogram/pages/results/results.ts b/miniprogram/pages/results/results.ts index 1d3a834..1b51aec 100644 --- a/miniprogram/pages/results/results.ts +++ b/miniprogram/pages/results/results.ts @@ -10,6 +10,7 @@ type ResultsPageData = { statusText: string scoreText: string routeText: string + runtimeText: string }> } @@ -35,6 +36,18 @@ function formatRouteSummary(result: BackendSessionResultView): string { 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) { return { sessionId: result.session.id, @@ -42,6 +55,7 @@ function buildResultCardView(result: BackendSessionResultView) { statusText: `${result.result.status} / ${result.session.status}`, scoreText: `得分 ${result.result.finalScore || '--'} / 用时 ${result.result.finalDurationSec || '--'}s`, routeText: `赛道 ${formatRouteSummary(result)}`, + runtimeText: formatRuntimeSummary(result), } } diff --git a/miniprogram/pages/results/results.wxml b/miniprogram/pages/results/results.wxml index 8861088..6e5aa5a 100644 --- a/miniprogram/pages/results/results.wxml +++ b/miniprogram/pages/results/results.wxml @@ -19,6 +19,7 @@ {{item.statusText}} {{item.scoreText}} {{item.routeText}} + {{item.runtimeText}} diff --git a/miniprogram/utils/backendApi.ts b/miniprogram/utils/backendApi.ts index 01f6deb..4c1980a 100644 --- a/miniprogram/utils/backendApi.ts +++ b/miniprogram/utils/backendApi.ts @@ -45,6 +45,30 @@ export interface BackendLaunchVariantSummary { 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 { id: string status: string @@ -55,6 +79,7 @@ export interface BackendEntrySessionSummary { routeCode?: string | null variantId?: string | null variantName?: string | null + runtime?: BackendRuntimeSummary | null launchedAt?: string | null startedAt?: string | null endedAt?: string | null @@ -115,6 +140,8 @@ export interface BackendEventPlayResult { summary?: string | null status: string } + currentPresentation?: BackendPresentationSummary | null + currentContentBundle?: BackendContentBundleSummary | null release?: { id: string configLabel: string @@ -159,6 +186,9 @@ export interface BackendLaunchResult { routeCode?: string | null } variant?: BackendLaunchVariantSummary | null + runtime?: BackendRuntimeSummary | null + presentation?: BackendPresentationSummary | null + contentBundle?: BackendContentBundleSummary | null } } @@ -179,6 +209,7 @@ export interface BackendSessionResult { clientType: string deviceKey: string routeCode?: string | null + runtime?: BackendRuntimeSummary | null sessionTokenExpiresAt: string launchedAt: string startedAt?: string | null diff --git a/miniprogram/utils/backendLaunchAdapter.ts b/miniprogram/utils/backendLaunchAdapter.ts index 0b96ed3..5bc9c60 100644 --- a/miniprogram/utils/backendLaunchAdapter.ts +++ b/miniprogram/utils/backendLaunchAdapter.ts @@ -2,6 +2,10 @@ import { type GameLaunchEnvelope } from './gameLaunch' import { type BackendLaunchResult } from './backendApi' export function adaptBackendLaunchResultToEnvelope(result: BackendLaunchResult): GameLaunchEnvelope { + const launchVariantRouteCode = result.launch.variant + ? (result.launch.variant.routeCode || null) + : null + return { config: { configUrl: result.launch.config.configUrl, @@ -29,5 +33,32 @@ export function adaptBackendLaunchResultToEnvelope(result: BackendLaunchResult): routeCode: result.launch.config.routeCode || result.launch.business.routeCode || 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, } } diff --git a/miniprogram/utils/gameLaunch.ts b/miniprogram/utils/gameLaunch.ts index b2f7b3e..ed8d723 100644 --- a/miniprogram/utils/gameLaunch.ts +++ b/miniprogram/utils/gameLaunch.ts @@ -29,10 +29,37 @@ export interface GameVariantLaunchContext { 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 { config: GameConfigLaunchRequest business: BusinessLaunchContext | null variant?: GameVariantLaunchContext | null + runtime?: GameRuntimeLaunchContext | null + presentation?: GamePresentationLaunchContext | null + contentBundle?: GameContentBundleLaunchContext | null } export interface MapPageLaunchOptions { @@ -57,6 +84,20 @@ export interface MapPageLaunchOptions { variantId?: string variantName?: 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 @@ -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 { try { const stored = wx.getStorageSync(PENDING_GAME_LAUNCH_STORAGE_KEY) @@ -180,6 +293,9 @@ export function getDemoGameLaunchEnvelope(preset: DemoGamePreset = 'classic'): G source: 'demo', }, variant: null, + runtime: null, + presentation: null, + contentBundle: null, } } @@ -252,6 +368,9 @@ export function resolveGameLaunchEnvelope(options?: MapPageLaunchOptions | null) }, business: buildBusinessLaunchContext(options), variant: buildVariantLaunchContext(options), + runtime: buildRuntimeLaunchContext(options), + presentation: buildPresentationLaunchContext(options), + contentBundle: buildContentBundleLaunchContext(options), } } diff --git a/readme-develop.md b/readme-develop.md index f9417fd..ea320db 100644 --- a/readme-develop.md +++ b/readme-develop.md @@ -1,6 +1,6 @@ # CMR Mini 开发架构阶段总结 -> 文档版本:v1.0 -> 最后更新:2026-04-02 08:28:05 +> 文档版本:v1.13 +> 最后更新: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 一键测试环境做回归 + - 不继续扩新页面链 + - 不做复杂运营样式 + 当前阶段的核心目标已经从“把地图画出来”升级为“建立一套可长期扩展的运动地图游戏底座”。 这套底座已经具备以下关键能力: diff --git a/t2b.md b/t2b.md new file mode 100644 index 0000000..c3934fe --- /dev/null +++ b/t2b.md @@ -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` 继续做业务壳和默认绑定,不吞大资源 +- 玩家前端继续只认发布摘要,不认后台草稿对象 diff --git a/t2f.md b/t2f.md new file mode 100644 index 0000000..bb2bb02 --- /dev/null +++ b/t2f.md @@ -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 一键测试环境下做联调回归和小范围修复。** diff --git a/tools/runtime-smoke-test.ts b/tools/runtime-smoke-test.ts index 38e1c8a..29b149b 100644 --- a/tools/runtime-smoke-test.ts +++ b/tools/runtime-smoke-test.ts @@ -7,7 +7,9 @@ import { getGameModeDefaults } from '../miniprogram/game/core/gameModeDefaults' import { GameRuntime } from '../miniprogram/game/core/gameRuntime' import { ScoreORule } from '../miniprogram/game/rules/scoreORule' import { resolveSystemSettingsState } from '../miniprogram/game/core/systemSettingsState' +import { adaptBackendLaunchResultToEnvelope } from '../miniprogram/utils/backendLaunchAdapter' import { type GameDefinition } from '../miniprogram/game/core/gameDefinition' +import { type BackendLaunchResult } from '../miniprogram/utils/backendApi' import { type OrienteeringCourseData } from '../miniprogram/utils/orienteeringCourse' type StorageMap = Record @@ -297,6 +299,70 @@ function testRuntimeRestoreDefinition(): void { 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 { createWxStorage({}) testControlInheritance() @@ -305,6 +371,7 @@ function run(): void { testTimeoutEndReason() testClassicSequentialSkipConfirmDefault() testRuntimeRestoreDefinition() + testLaunchRuntimeAdapter() console.log('runtime smoke tests passed') } diff --git a/typings/index.d.ts b/typings/index.d.ts index d44b37b..af433a5 100644 --- a/typings/index.d.ts +++ b/typings/index.d.ts @@ -7,6 +7,7 @@ interface IAppOption { backendBaseUrl?: string | null, backendAuthTokens?: import('../miniprogram/utils/backendAuth').BackendAuthTokens | null, pendingResultSnapshot?: import('../miniprogram/engine/map/mapEngine').MapEngineResultSnapshot | null, + pendingResultLaunchEnvelope?: import('../miniprogram/utils/gameLaunch').GameLaunchEnvelope | null, pendingHeartRateAutoConnect?: { enabled: boolean, deviceName?: string | null,