推进活动系统最小成品闭环与游客体验
This commit is contained in:
@@ -1,12 +1,67 @@
|
||||
# Backend
|
||||
> 文档版本:v1.22
|
||||
> 最后更新:2026-04-03 18:56:46
|
||||
> 文档版本:v1.41
|
||||
> 最后更新:2026-04-07 18:15:01
|
||||
|
||||
|
||||
这套后端现在已经能支撑一条完整主链:
|
||||
|
||||
`entry -> auth -> home/cards -> event play -> launch -> session -> result`
|
||||
|
||||
当前已实现接口总数:
|
||||
|
||||
- `116`
|
||||
|
||||
开发环境补充说明:
|
||||
|
||||
- `/admin/ops-workbench` 与 `/ops/admin/*` 在 `APP_ENV != production` 时默认免登录可用。
|
||||
- 即使浏览器里残留旧的玩家 token、过期 token 或非 ops token,运维链也会自动回退到 dev ops 上下文。
|
||||
- 生产环境不变,仍然必须使用正式 ops token。
|
||||
- 运维后台当前采用“左侧流程导航 + 中间单主视图 + 右侧状态/日志”结构,不再把地图、路线、活动、发布动作平铺在一个长页面里。
|
||||
- `资源总览` 先展示关键统计与当前运行时信息;地图页只保留关联活动数量和摘要,活动明细统一放到 `活动管理 / 活动编排`。
|
||||
- `地图 / 地点管理` 当前按“地图列表优先”组织:
|
||||
- 先看地图列表
|
||||
- 右上角只放 `添加地图 / 添加地点`
|
||||
- 地点编辑区改成省/市两级选择
|
||||
- 一张地图只属于一个地点,一个地点可挂多张地图
|
||||
|
||||
新增的游客模式公开接口:
|
||||
|
||||
- `GET /public/experience-maps`
|
||||
- `GET /public/experience-maps/{mapAssetPublicID}`
|
||||
- `GET /public/events/{eventPublicID}`
|
||||
- `GET /public/events/{eventPublicID}/play`
|
||||
- `POST /public/events/{eventPublicID}/launch`
|
||||
|
||||
这组接口用于支撑“未登录游客只体验默认活动”的最小链路。
|
||||
|
||||
新增的地图资源与默认体验活动接口:
|
||||
|
||||
- `GET /experience-maps`
|
||||
- `GET /experience-maps/{mapAssetPublicID}`
|
||||
|
||||
这组接口用于支撑“地图列表下的默认活动”最小产品化需求。
|
||||
|
||||
新增的运维地图管理接口:
|
||||
|
||||
- `GET /ops/admin/region-options`
|
||||
- `GET /admin/map-assets`
|
||||
- `PUT /admin/map-assets/{mapAssetPublicID}`
|
||||
- `GET /ops/admin/map-assets`
|
||||
- `PUT /ops/admin/map-assets/{mapAssetPublicID}`
|
||||
- `GET /ops/admin/course-sources`
|
||||
- `GET /ops/admin/course-sources/{sourcePublicID}`
|
||||
- `GET /ops/admin/course-sets/{courseSetPublicID}`
|
||||
- `POST /ops/admin/events`
|
||||
- `PUT /ops/admin/events/{eventPublicID}`
|
||||
|
||||
这组接口用于把运维后台收成统一主流程:
|
||||
|
||||
- 地图管理:列表 / 新增 / 编辑 / 详情 / 关联活动
|
||||
- KML / 赛道管理:围绕当前地图查看赛道集和默认路线
|
||||
- 活动管理:列表 / 新增 / 修改 / 详情 / 默认绑定 / 发布
|
||||
- 运维后台 UI 当前也已按“左侧导航 + 单主视图 + 右侧常驻状态栏”收口,不再把所有功能平铺在一个长页面里
|
||||
- 主区当前已改成宽屏自适应,不再固定窄宽度浪费屏幕空间
|
||||
|
||||
并且已经按“配置驱动游戏”收口:
|
||||
|
||||
- 业务对象是 `event`
|
||||
@@ -29,6 +84,16 @@
|
||||
- `presentation`
|
||||
- `content bundle`
|
||||
时,玩家才允许正式进入
|
||||
- 游客模式当前只允许进入:
|
||||
- `is_default_experience = true` 的活动
|
||||
- 且必须基于当前已发布 release
|
||||
- 游客模式当前不开放:
|
||||
- `/me/entry-home`
|
||||
- `/me/results`
|
||||
- 用户历史成绩与报名态
|
||||
- 游客模式 `launch` 会复用正式 session 模型,但返回:
|
||||
- `launch.source = public-default-experience`
|
||||
- `launch.business.isGuest = true`
|
||||
|
||||
当前 workbench 里新增的“当前 Launch 实际配置摘要”仅用于调试:
|
||||
|
||||
@@ -54,20 +119,84 @@
|
||||
当前 demo 真实输入第一刀也已经接入:
|
||||
|
||||
- workbench 的玩法切换会自动填入 backend 内置的:
|
||||
- `game manifest`
|
||||
- `presentation schema`
|
||||
- `content manifest`
|
||||
- 这些 demo 资源通过 backend 提供的 dev 路由读取:
|
||||
- `GET /dev/demo-assets/manifests/{demoKey}`
|
||||
- `GET /dev/demo-assets/presentations/{demoKey}`
|
||||
- `GET /dev/demo-assets/content-manifests/{demoKey}`
|
||||
|
||||
当前 workbench 的 `Bootstrap` 语义也已经拆开:
|
||||
|
||||
- `Bootstrap Demo(只准备数据)`
|
||||
- 只准备 demo 测试数据,不额外重新发布当前玩法
|
||||
- 只准备 demo 测试数据和基础已发布态,不额外重新发布当前玩法
|
||||
- `Bootstrap + 发布当前玩法`
|
||||
- 先准备 demo,再对当前选中的玩法执行一遍“发布活动配置(自动补 Runtime)”
|
||||
- 这两条路由只服务联调,不进入正式客户端发布链
|
||||
- 当前三条标准 demo 在 `Bootstrap Demo(只准备数据)` 后都会直接具备:
|
||||
- 当前 release
|
||||
- runtime
|
||||
- presentation
|
||||
- content bundle
|
||||
- 也就是说,frontend 当前从首页选顺序赛、积分赛、多赛道时,已经可以直接走“当前已发布 release”语义联调入口、详情和 `canLaunch`
|
||||
- 当前联调样例文案也已从 `Demo ...` 收口为中文活动样例,便于前端和总控直接对口排查
|
||||
- 当前 manual 多赛道 demo 也已切到 4 条正式 OSS KML:
|
||||
- `gotomars/kml/lxcb-001/2026-04-07/route01.kml`
|
||||
- `gotomars/kml/lxcb-001/2026-04-07/route02.kml`
|
||||
- `gotomars/kml/lxcb-001/2026-04-07/route03.kml`
|
||||
- `gotomars/kml/lxcb-001/2026-04-07/route04.kml`
|
||||
- 资源目录约束同步明确:
|
||||
- 正式资源目录只认 `OSS / CDN`
|
||||
- 本地 `tmp/` 仅作为临时收件箱,不参与正式发布源
|
||||
- 当前运维入口第一期已开始落地,先开放两条最小录入链:
|
||||
- `POST /admin/ops/tile-releases/import`
|
||||
- `POST /admin/ops/course-sets/import-kml-batch`
|
||||
- 当前目标是先把:
|
||||
- 地图瓦片版本录入
|
||||
- KML 批量录入并组装为 `course set / variants`
|
||||
收成可重复执行的运维入口,而不是继续依赖手工脚本或改代码上传 OSS
|
||||
- 当前运维入口第二期已起步,开始支持统一资源纳管:
|
||||
- `GET /admin/assets`
|
||||
- `POST /admin/assets/register-link`
|
||||
- `POST /admin/assets/upload`
|
||||
- `GET /admin/assets/{assetPublicID}`
|
||||
- 当前边界:
|
||||
- 允许“上传文件”与“登记外链”两种录入模式
|
||||
- backend 负责 OSS 存储和资源对象登记
|
||||
- 运维后台当前开始使用独立鉴权链:
|
||||
- `/ops/auth/*`
|
||||
- `/ops/admin/*`
|
||||
- 设计目标:
|
||||
- 运维账号与前端玩家账号完全分离
|
||||
- 生产环境走手机号验证码注册/登录
|
||||
- 后续可扩多租户与角色分级
|
||||
- 已新增独立运维入口:
|
||||
- `GET /admin/ops-workbench`
|
||||
- 当前开始把“调试后台”和“运维后台”拆开:
|
||||
- `/dev/workbench` 继续只做联调、回归、日志与摘要
|
||||
- `/admin/ops-workbench` 只做资源录入、OSS 纳管、地图/KML 导入
|
||||
- 当前开发环境为了录资源和调发布方便,运维后台默认免登录放行:
|
||||
- 可直接打开 `/admin/ops-workbench`
|
||||
- 可直接调用 `/ops/admin/*`
|
||||
- 只有主动验证运维账号链路时,才需要使用 `/ops/auth/*`
|
||||
- `Import Tile Release / Import KML Batch` 当前已从调试工作台主操作区迁到:
|
||||
- `/admin/ops-workbench`
|
||||
- `/dev/workbench` 当前只保留运维后台入口说明与跳转,不再承载正式资源录入操作
|
||||
- `/admin/ops-workbench` 当前已收成 5 块结构:
|
||||
- 资源总览
|
||||
- 地图资源管理
|
||||
- 资源录入
|
||||
- 赛道集管理
|
||||
- 活动绑定
|
||||
- 发布中心
|
||||
- 当前地图资源管理第一刀已接进运维后台:
|
||||
- 读取地点列表
|
||||
- 新建地点
|
||||
- 读取地点详情
|
||||
- 新建地图资源
|
||||
- 读取地图详情
|
||||
- 查看当前瓦片版本与默认活动摘要
|
||||
|
||||
当前活动卡片列表最小产品化第一刀也已经进入 backend:
|
||||
|
||||
@@ -96,6 +225,29 @@
|
||||
- `isDefaultExperience` 当前由卡片显式字段控制
|
||||
- `timeWindow / ctaText` 当前先按后端派生规则提供,允许后续继续演进
|
||||
|
||||
当前“准备页地图预览 V1”也已开始接入,但仍保持只读增强项边界:
|
||||
|
||||
- 当前只把预览字段挂在:
|
||||
- `GET /events/{eventPublicID}`
|
||||
- `GET /events/{eventPublicID}/play`
|
||||
- 当前新增字段为:
|
||||
- `preview.mode`
|
||||
- `preview.baseTiles.tileBaseUrl`
|
||||
- `preview.baseTiles.zoom`
|
||||
- `preview.baseTiles.tileSize`
|
||||
- `preview.viewport.width / height`
|
||||
- `preview.viewport.minLon / minLat / maxLon / maxLat`
|
||||
- `preview.variants[].controls`
|
||||
- `preview.variants[].legs`
|
||||
- `preview.selectedVariantId`
|
||||
- 当前只服务准备页只读地图预览:
|
||||
- 不进入正式 launch 主链
|
||||
- 不单独造新的地图资源体系
|
||||
- 非 demo / 非带预览元数据的活动允许返回空
|
||||
- workbench 当前也已新增固定摘要卡:
|
||||
- `准备页地图预览状态`
|
||||
- 用于直接查看当前活动回出的 preview 关键字段
|
||||
|
||||
## 文档导航
|
||||
|
||||
- [文档索引](D:/dev/cmr-mini/backend/docs/README.md)
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
# Backend Docs
|
||||
> 文档版本:v1.0
|
||||
> 最后更新:2026-04-02 08:28:05
|
||||
> 文档版本:v1.1
|
||||
> 最后更新:2026-04-07 18:47:09
|
||||
|
||||
|
||||
这套文档服务两个目的:
|
||||
@@ -20,6 +20,7 @@
|
||||
8. [前后端联调清单](D:/dev/cmr-mini/backend/docs/前后端联调清单.md)
|
||||
9. [TodoList](D:/dev/cmr-mini/backend/docs/todolist.md)
|
||||
10. [开发说明](D:/dev/cmr-mini/backend/docs/开发说明.md)
|
||||
11. [B2B 交接文档](D:/dev/cmr-mini/b2b.md)
|
||||
|
||||
## 当前系统范围
|
||||
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
# 后台管理最小方案
|
||||
> 文档版本:v1.1
|
||||
> 最后更新:2026-04-03 11:02:42
|
||||
> 文档版本:v1.8
|
||||
> 最后更新:2026-04-07 17:23:15
|
||||
|
||||
## 1. 目标
|
||||
|
||||
@@ -176,16 +176,16 @@
|
||||
|
||||
## 4. 后台第一版页面建议
|
||||
|
||||
按最小闭环,建议先做 6 个页面:
|
||||
按当前运维工作流,建议先按“地图主流程”组织,而不是把底层对象散着摆:
|
||||
|
||||
1. 地图列表页
|
||||
2. 赛场 / KML 列表页
|
||||
3. 资源包列表页
|
||||
4. Event 列表与编辑页
|
||||
5. Build / Release 列表页
|
||||
6. 发布详情页
|
||||
2. 地图详情页
|
||||
3. KML / 赛道管理页
|
||||
4. 活动管理页
|
||||
5. 发布中心
|
||||
6. 资源总览页
|
||||
|
||||
这 6 页够把“资源录入 -> Event 组装 -> 发布 -> launch”跑通。
|
||||
这 6 页的目标是把“资源录入 -> 地图管理 -> 赛道管理 -> 活动绑定 -> 发布”跑通。
|
||||
|
||||
补充:
|
||||
|
||||
@@ -216,8 +216,112 @@
|
||||
- `event` 只做引用和少量覆盖
|
||||
- `release` 固化具体版本引用
|
||||
|
||||
### 5.1 当前活动模型收口原则
|
||||
|
||||
为了让前台卡片、详情页、发布语义和运维操作都保持简单,当前活动模型先明确收成最小可玩单元:
|
||||
|
||||
- `单地图`
|
||||
- `单路线组`
|
||||
- `单玩法`
|
||||
|
||||
也就是第一阶段一个活动只表达一件事:
|
||||
|
||||
> 在这张地图上,使用这组路线,按这一个玩法运行。
|
||||
|
||||
当前不建议第一阶段直接支持:
|
||||
|
||||
- 一个活动绑定多张地图
|
||||
- 一个活动绑定多组路线
|
||||
- 一个活动同时支持多玩法
|
||||
|
||||
复杂需求先通过“活动实例化”解决,而不是在一个活动里做多对多编排。例如:
|
||||
|
||||
- 地图 A + 路线组 1 + 顺序赛 = 活动 A1
|
||||
- 地图 A + 路线组 2 + 顺序赛 = 活动 A2
|
||||
- 地图 A + 路线组 2 + 积分赛 = 活动 A3
|
||||
|
||||
这样做的目的:
|
||||
|
||||
- 前台卡片逻辑简单
|
||||
- 发布语义明确
|
||||
- 结果追溯简单
|
||||
- 运维后台第一期不需要一开始就做复杂配置器
|
||||
|
||||
后续如果确实需要复杂组合,优先考虑:
|
||||
|
||||
- 基于模板批量实例化多个活动
|
||||
|
||||
而不是直接把单个活动扩成多地图、多路线组、多玩法容器。
|
||||
|
||||
### 5.2 组合入口层原则
|
||||
|
||||
多地图、多路线组、多玩法这类需求后面仍然会存在,但当前建议放在“组合入口层”解决,不直接进入单个活动的运行模型。
|
||||
|
||||
也就是分成两层:
|
||||
|
||||
1. 运行实例层
|
||||
- 一个活动实例始终保持:
|
||||
- `单地图`
|
||||
- `单路线组`
|
||||
- `单玩法`
|
||||
|
||||
2. 组合入口层
|
||||
- 通过组合卡片或组合入口,把多个活动实例编排成一个前台入口
|
||||
- 例如:
|
||||
- 多地图合集
|
||||
- 多玩法合集
|
||||
- 不同难度合集
|
||||
- 同主题活动合集
|
||||
|
||||
这样做的目的:
|
||||
|
||||
- 前台入口可以灵活组合
|
||||
- backend 的发布、回溯、结果沉淀仍然保持简单
|
||||
- 运维后台第一期不需要一开始就做复杂多对多编排器
|
||||
- 后面若要扩能力,也是在“组合层”扩,而不是把底层活动模型搞乱
|
||||
|
||||
## 6. 一条完整后台工作流
|
||||
|
||||
## 7. 运维入口第一期
|
||||
|
||||
当前已经开始落地一条“先运维录入、再活动绑定发布”的最小入口,不再只靠手工脚本或改代码上传资源。
|
||||
|
||||
第一期先开放两条:
|
||||
|
||||
1. `POST /admin/ops/tile-releases/import`
|
||||
- 作用:一次录入 `place + map asset + tile release`
|
||||
- 适合把地图瓦片版本登记进 backend
|
||||
|
||||
2. `POST /admin/ops/course-sets/import-kml-batch`
|
||||
- 作用:一次录入一组 KML,生成 `course set + variants`
|
||||
- 适合多赛道活动的批量路线导入
|
||||
|
||||
第一期边界:
|
||||
|
||||
- 只做录入
|
||||
- 只做对象登记和最小 current 绑定
|
||||
- 已开始落地独立运维后台 `/admin/ops-workbench`
|
||||
- 不替代后续完整运维后台
|
||||
|
||||
当前第一版页面结构已经按主流程拆成:
|
||||
|
||||
1. `资源总览`
|
||||
2. `地图管理`
|
||||
3. `资源录入`
|
||||
4. `KML / 赛道管理`
|
||||
5. `活动管理`
|
||||
6. `发布中心`
|
||||
|
||||
当前设计原则:
|
||||
|
||||
- 运维首先看到“地图列表”
|
||||
- KML / 赛道围绕地图管理,不作为孤立对象平铺
|
||||
- 活动管理围绕地图关联活动、默认体验活动和发布状态展开
|
||||
- 活动编排当前先按“单地图 + 单路线组 + 单玩法”收口
|
||||
- 多地图 / 多玩法需求当前先通过“组合卡片 / 组合入口”承接
|
||||
- 地图、KML、活动尽量统一成“列表 / 新增 / 修改 / 详情 / 预览 / 发布关联”的相似使用习惯
|
||||
- 底层对象如 `Place / MapAsset / TileRelease / CourseSource / CourseSet` 继续保留,但收进主流程内部,不作为首页认知入口
|
||||
|
||||
```mermaid
|
||||
flowchart LR
|
||||
A["录入地图版本"] --> D["Event 选择地图版本"]
|
||||
@@ -327,7 +431,21 @@ flowchart LR
|
||||
2. `Event` 组装接口 `/admin/events`、`/admin/events/{id}/source` 已完成
|
||||
3. `pipeline/build/publish` 后台聚合接口已完成
|
||||
4. `rollback` 已完成
|
||||
5. 下一步是把这批接口接进 workbench 或正式后台页面
|
||||
5. 运维入口第一期已完成:
|
||||
- `POST /admin/ops/tile-releases/import`
|
||||
- `POST /admin/ops/course-sets/import-kml-batch`
|
||||
6. 运维入口第二期已开始:
|
||||
- `POST /admin/assets/upload`
|
||||
- `POST /admin/assets/register-link`
|
||||
- `GET /admin/assets`
|
||||
- `GET /admin/assets/{assetPublicID}`
|
||||
7. 运维后台第一版结构已开始落地到 `/admin/ops-workbench`:
|
||||
- `资源总览`
|
||||
- `资源录入`
|
||||
- `赛道集管理`
|
||||
- `活动绑定`
|
||||
- `发布中心`
|
||||
8. 下一步是继续把更多资源对象与发布细节收进这套运维台,而不是再塞回调试工作台
|
||||
|
||||
## 10. 一句话结论
|
||||
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
# 开发说明
|
||||
> 文档版本:v1.25
|
||||
> 最后更新:2026-04-03 18:56:46
|
||||
> 文档版本:v1.45
|
||||
> 最后更新:2026-04-07 18:15:01
|
||||
|
||||
|
||||
## 1. 环境变量
|
||||
@@ -18,6 +18,50 @@
|
||||
- `WECHAT_MINI_APP_ID`
|
||||
- `WECHAT_MINI_APP_SECRET`
|
||||
- `WECHAT_MINI_DEV_PREFIX`
|
||||
|
||||
## 2. 运维后台当前结构
|
||||
|
||||
- 运维后台入口:`/admin/ops-workbench`
|
||||
- 当前采用:
|
||||
- 左侧:流程导航
|
||||
- 中间:单主视图
|
||||
- 右侧:状态 / 日志 / 最近对象
|
||||
- 当前主流程导航:
|
||||
- `资源总览`
|
||||
- `地图 / 地点管理`
|
||||
- `路线资源管理`
|
||||
- `活动管理`
|
||||
- `活动编排`
|
||||
- `发布中心`
|
||||
- `资源录入` 作为辅助入口保留
|
||||
- `资源总览` 优先展示:
|
||||
- 地点、地图、瓦片版本、受管资源
|
||||
- 路线组、路线变体、运行绑定、配置源
|
||||
- 活动数、默认体验活动、已发布活动、发布版本、展示定义、内容包、运维账号
|
||||
- `地图 / 地点管理` 当前收成:
|
||||
- 先看地图列表
|
||||
- 右上角入口:`添加地图 / 添加地点`
|
||||
- 点击地图进入详情弹出层
|
||||
- 新增 / 编辑地图走独立弹出层
|
||||
- 新增地点走独立弹出层
|
||||
- 地点编辑区使用省/市两级选择,并回填到 `region`
|
||||
- 地图详情只保留:
|
||||
- 当前瓦片版本
|
||||
- 默认体验活动概况
|
||||
- 关联活动数量与摘要
|
||||
- 关联活动详情统一去 `活动管理`
|
||||
- 省市数据当前来自在线公开数据源:
|
||||
- `uiwjs/province-city-china`
|
||||
- backend 通过 `/ops/admin/region-options` 统一提供给运维台,页面本身不直连第三方源
|
||||
- 当前选中活动的 `release / runtime / presentation / content bundle`
|
||||
- 地图页当前只显示关联活动数量与摘要,不再平铺活动详情;活动详情和默认绑定统一放到 `活动管理 / 活动编排`。
|
||||
- `地图 / 地点管理` 当前已支持:
|
||||
- 地点列表
|
||||
- 地图列表
|
||||
- 地图关键字筛选
|
||||
- 点列表项直接读取详情
|
||||
- 选地点后自动带出该地点下地图
|
||||
- 选地图后自动带出当前瓦片版本、默认活动概况和地图预览摘要
|
||||
- `LOCAL_EVENT_DIR`
|
||||
- `ASSET_BASE_URL`
|
||||
- `ASSET_PUBLIC_BASE_URL`
|
||||
@@ -39,17 +83,86 @@ cd D:\dev\cmr-mini\backend
|
||||
.\scripts\start-dev.ps1
|
||||
```
|
||||
|
||||
开发环境补充:
|
||||
|
||||
- 运维后台入口:`/admin/ops-workbench`
|
||||
- 运维接口前缀:`/ops/admin/*`
|
||||
- 当 `APP_ENV != production` 时:
|
||||
- 缺少 token 会直接进入 dev ops 上下文
|
||||
- 残留旧 token、玩家 token、失效 token 也会自动回退到 dev ops 上下文
|
||||
- 目的是避免开发联调时每次都要重新登录
|
||||
|
||||
## 3. Workbench 当前重点
|
||||
|
||||
- 推荐联调入口:
|
||||
- `Bootstrap Demo`
|
||||
- `Bootstrap Demo(只准备数据)`
|
||||
- `Bootstrap + 发布当前玩法`
|
||||
- `Use Classic Demo / Use Score-O Demo / Use Manual Variant Demo`
|
||||
- `整条链一键验收`
|
||||
- `Bootstrap Demo(只准备数据)` 当前会直接准备三条标准 demo 的基础已发布态:
|
||||
- `evt_demo_001`
|
||||
- `evt_demo_score_o_001`
|
||||
- `evt_demo_variant_manual_001`
|
||||
- 这三条 demo 在 bootstrap 后都会直接带上:
|
||||
- 当前 release
|
||||
- runtime
|
||||
- presentation
|
||||
- content bundle
|
||||
- 也就是说,frontend 当前从首页选择三种玩法时,不需要再额外先点一次发布按钮,已经能直接按“当前已发布 release”语义联调入口、详情和 `canLaunch`
|
||||
- 当前玩法切换除了切 `event / release / source / build`,还会自动切换:
|
||||
- `presentation schema`
|
||||
|
||||
## 4. 运维后台当前主流程
|
||||
|
||||
运维后台入口:
|
||||
|
||||
- [http://127.0.0.1:18090/admin/ops-workbench](http://127.0.0.1:18090/admin/ops-workbench)
|
||||
|
||||
当前不再把运维动作混在调试后台里,统一分成 3 条管理线:
|
||||
|
||||
1. 地图管理
|
||||
- 先看地图列表
|
||||
- 再做新建 / 编辑
|
||||
- 然后看当前瓦片版本、默认活动和关联活动
|
||||
|
||||
2. KML / 赛道管理
|
||||
- 围绕当前地图导入一组 KML
|
||||
- 查看当前地图下赛道集
|
||||
- 查看默认路线和路线摘要
|
||||
|
||||
3. 活动管理
|
||||
- 先看活动列表
|
||||
- 再做新建 / 修改 / 读取详情
|
||||
- 然后管理默认 `runtime / presentation / content bundle`
|
||||
- 最后进入发布中心
|
||||
|
||||
当前 UI 组织方式也已收口:
|
||||
|
||||
- 左侧:流程导航
|
||||
- 中间:单主视图
|
||||
- 右侧:状态 / 日志 / 最近对象
|
||||
|
||||
也就是说:
|
||||
|
||||
- 不再把所有运维功能平铺在一个长页面里
|
||||
- 运维者一次只处理一个主任务块
|
||||
- 主区已改成宽屏自适应,尽量利用大屏空间
|
||||
|
||||
当前新增的地图管理接口:
|
||||
|
||||
- `GET /admin/map-assets`
|
||||
- `PUT /admin/map-assets/{mapAssetPublicID}`
|
||||
- `GET /ops/admin/map-assets`
|
||||
- `PUT /ops/admin/map-assets/{mapAssetPublicID}`
|
||||
- `GET /ops/admin/course-sources`
|
||||
- `GET /ops/admin/course-sources/{sourcePublicID}`
|
||||
- `GET /ops/admin/course-sets/{courseSetPublicID}`
|
||||
- `POST /ops/admin/events`
|
||||
- `PUT /ops/admin/events/{eventPublicID}`
|
||||
- `content manifest`
|
||||
- `asset manifest`
|
||||
- 这些 demo 资源现在由 backend 提供,避免继续在 workbench 里保留 `example.com` 占位地址:
|
||||
- `GET /dev/demo-assets/manifests/{demoKey}`
|
||||
- `GET /dev/demo-assets/presentations/{demoKey}`
|
||||
- `GET /dev/demo-assets/content-manifests/{demoKey}`
|
||||
- 如果 frontend 需要把页面侧调试日志直接打到 backend,优先使用:
|
||||
@@ -66,6 +179,14 @@ cd D:\dev\cmr-mini\backend
|
||||
- `game.mode`
|
||||
- 这组信息用于和前端地图页实际消费结果对口排查,避免只靠口头描述“像顺序赛/像积分赛”。
|
||||
- 注意:
|
||||
- 游客模式当前不走 `/dev/workbench` 一键链验证
|
||||
- frontend 若要联调游客模式,请直接使用:
|
||||
- `GET /public/experience-maps`
|
||||
- `GET /public/experience-maps/{mapAssetPublicID}`
|
||||
- `GET /public/events/{eventPublicID}`
|
||||
- `GET /public/events/{eventPublicID}/play`
|
||||
- `POST /public/events/{eventPublicID}/launch`
|
||||
- 游客模式当前只允许默认体验活动进入,且仍然必须基于已发布 release。
|
||||
- 这块摘要由 backend 代读 manifest,只用于 workbench 调试
|
||||
- 这样做是为了避免浏览器直接读取 OSS 时受跨域影响
|
||||
- 它不替代正式客户端加载逻辑
|
||||
@@ -78,6 +199,116 @@ cd D:\dev\cmr-mini\backend
|
||||
- `领秀城公园顺序赛`
|
||||
- `领秀城公园积分赛`
|
||||
- `领秀城公园多赛道挑战`
|
||||
- 当前“准备页地图预览 V1”已先接进只读查询接口:
|
||||
- `GET /events/{eventPublicID}`
|
||||
- `GET /events/{eventPublicID}/play`
|
||||
- 当前 preview 字段最小结构为:
|
||||
- `preview.mode`
|
||||
- `preview.baseTiles.tileBaseUrl`
|
||||
- `preview.baseTiles.zoom`
|
||||
- `preview.baseTiles.tileSize`
|
||||
- `preview.viewport.width / height`
|
||||
- `preview.viewport.minLon / minLat / maxLon / maxLat`
|
||||
- `preview.variants[].controls`
|
||||
- `preview.variants[].legs`
|
||||
- `preview.selectedVariantId`
|
||||
- 当前实现边界:
|
||||
- 只服务准备页只读预览
|
||||
- 不进入正式 launch 主链
|
||||
- demo 活动当前已自带预览元数据
|
||||
- 非带预览元数据的活动允许返回空
|
||||
- workbench 当前已增加固定卡片:
|
||||
- `准备页地图预览状态`
|
||||
- 当前会直接显示:
|
||||
- `Preview Mode`
|
||||
- `Tile Base URL`
|
||||
- `Zoom`
|
||||
- `Viewport`
|
||||
- `Selected Variant`
|
||||
- `Preview Variant Count`
|
||||
- `First Variant Controls`
|
||||
- `First Variant Legs`
|
||||
- 点击:
|
||||
- `Event Detail`
|
||||
- `Event Play`
|
||||
后都会刷新这张卡
|
||||
- 当前运维入口第一期已迁移到独立运维工作台:
|
||||
- `Import Tile Release`
|
||||
- `Import KML Batch`
|
||||
- 两条入口分别对应:
|
||||
- `POST /admin/ops/tile-releases/import`
|
||||
- `POST /admin/ops/course-sets/import-kml-batch`
|
||||
- 推荐使用顺序:
|
||||
1. 先录入瓦片版本
|
||||
2. 再批量录入 KML 路线
|
||||
3. 最后再继续组装 `runtime / event / release`
|
||||
- 这两条入口当前只服务运维录入第一期,不替代正式后台 UI。
|
||||
- 当前运维入口第二期已先落 backend 资源纳管接口:
|
||||
- `GET /admin/assets`
|
||||
- `POST /admin/assets/register-link`
|
||||
- `POST /admin/assets/upload`
|
||||
- `GET /admin/assets/{assetPublicID}`
|
||||
- 当前用途:
|
||||
- 运维不再必须自己管 OSS 目录细节
|
||||
- 允许直接上传文件,由 backend 负责:
|
||||
- 上传到 OSS
|
||||
- 生成正式 URL
|
||||
- 登记资源对象
|
||||
- 也允许直接登记已有正式外链
|
||||
- 当前已新增独立运维工作台:
|
||||
- [http://127.0.0.1:18090/admin/ops-workbench](http://127.0.0.1:18090/admin/ops-workbench)
|
||||
- 当前入口分工:
|
||||
- `/dev/workbench`
|
||||
- 调试工作台
|
||||
- 一键回归、配置摘要、前端日志、联调排查
|
||||
- 当前只保留运维入口说明与跳转,不再承载正式资源录入动作
|
||||
- `/admin/ops-workbench`
|
||||
- 运维工作台
|
||||
- 资源上传、外链登记、地图瓦片导入、KML 批量导入
|
||||
- 活动绑定
|
||||
- 发布中心
|
||||
- 当前运维后台鉴权也已经开始独立:
|
||||
- 运维账号接口:
|
||||
- `POST /ops/auth/sms/send`
|
||||
- `POST /ops/auth/register`
|
||||
- `POST /ops/auth/login/sms`
|
||||
- `POST /ops/auth/refresh`
|
||||
- `POST /ops/auth/logout`
|
||||
- `GET /ops/me`
|
||||
- 运维后台管理接口:
|
||||
- `/ops/admin/*`
|
||||
- 设计目标:
|
||||
- 运维账号与前端玩家账号完全分离
|
||||
- 生产环境走手机号验证码注册/登录
|
||||
- 后续可扩角色分级、多租户
|
||||
- 当前开发环境为了录资源和调发布方便,运维后台默认免登录:
|
||||
- `/admin/ops-workbench` 可直接进入
|
||||
- `/ops/admin/*` 在 non-production 下可直接调用
|
||||
- 只有主动验证运维账号链路时,才需要真的走手机号验证码登录
|
||||
- 当前运维工作台已收成 5 块:
|
||||
- `资源总览`
|
||||
- `地图资源管理`
|
||||
- `资源录入`
|
||||
- `赛道集管理`
|
||||
- `活动绑定`
|
||||
- `发布中心`
|
||||
- 当前“地图资源管理”第一刀的最小目标是:
|
||||
1. 读取地点列表
|
||||
2. 新建地点
|
||||
3. 读取地点详情
|
||||
4. 新建地图资源
|
||||
5. 读取地图详情
|
||||
6. 在同一页面查看:
|
||||
- 当前瓦片版本
|
||||
- 当前瓦片地址
|
||||
- 默认活动摘要
|
||||
- 当前运维台对应入口:
|
||||
- `GET /ops/admin/places`
|
||||
- `POST /ops/admin/places`
|
||||
- `GET /ops/admin/places/{placePublicID}`
|
||||
- `POST /ops/admin/places/{placePublicID}/map-assets`
|
||||
- `GET /ops/admin/map-assets/{mapAssetPublicID}`
|
||||
- `POST /ops/admin/map-assets/{mapAssetPublicID}/tile-releases`
|
||||
|
||||
## 4. 活动卡片列表最小摘要
|
||||
|
||||
@@ -649,6 +880,48 @@ Redis 后面只在需要性能优化、限流或短期票据缓存时再接。
|
||||
- `business`
|
||||
- `variant`
|
||||
|
||||
## 6.1 地图列表与默认活动
|
||||
|
||||
当前 backend 已补最小地图体验入口:
|
||||
|
||||
- `GET /experience-maps`
|
||||
- `GET /experience-maps/{mapAssetPublicID}`
|
||||
|
||||
语义约定:
|
||||
|
||||
- 地图列表按 `Place / MapAsset` 聚合
|
||||
- 默认活动关系来自:
|
||||
- `events.is_default_experience`
|
||||
- `events.show_in_event_list`
|
||||
- 当前已发布 `release` 绑定到的 `runtime.mapAsset`
|
||||
- `Bootstrap Demo` 后:
|
||||
- `evt_demo_001` 为默认体验活动
|
||||
- `evt_demo_score_o_001`
|
||||
- `evt_demo_variant_manual_001`
|
||||
为普通活动,但仍会出现在地图关联活动里
|
||||
|
||||
当前前端可直接消费的字段:
|
||||
|
||||
- 地图列表:
|
||||
- `placeId`
|
||||
- `placeName`
|
||||
- `mapId`
|
||||
- `mapName`
|
||||
- `coverUrl`
|
||||
- `summary`
|
||||
- `defaultExperienceCount`
|
||||
- `defaultExperienceEventIds`
|
||||
- 地图详情:
|
||||
- `placeId`
|
||||
- `placeName`
|
||||
- `mapId`
|
||||
- `mapName`
|
||||
- `coverUrl`
|
||||
- `summary`
|
||||
- `tileBaseUrl`
|
||||
- `tileMetaUrl`
|
||||
- `defaultExperiences[]`
|
||||
|
||||
## 7. 当前后续开发建议
|
||||
|
||||
文档整理完之后,后面建议按这个顺序继续:
|
||||
|
||||
@@ -1,10 +1,14 @@
|
||||
# API 清单
|
||||
> 文档版本:v1.12
|
||||
> 最后更新:2026-04-03 22:34:08
|
||||
> 文档版本:v1.23
|
||||
> 最后更新:2026-04-07 16:08:37
|
||||
|
||||
|
||||
本文档只记录当前 backend 已实现接口,不写未来规划接口。
|
||||
|
||||
当前已实现接口总数:
|
||||
|
||||
- `115`
|
||||
|
||||
## 1. Health
|
||||
|
||||
### `GET /healthz`
|
||||
@@ -13,6 +17,22 @@
|
||||
|
||||
- 健康检查
|
||||
|
||||
## 0. Workbench
|
||||
|
||||
### `GET /dev/workbench`
|
||||
|
||||
用途:
|
||||
|
||||
- 调试工作台
|
||||
- 一键回归、日志查看、摘要排查
|
||||
|
||||
### `GET /admin/ops-workbench`
|
||||
|
||||
用途:
|
||||
|
||||
- 运维后台第一期页面
|
||||
- 资源录入、OSS 纳管、地图瓦片导入、KML 批量导入
|
||||
|
||||
## 2. Auth
|
||||
|
||||
### `POST /auth/sms/send`
|
||||
@@ -75,6 +95,264 @@
|
||||
|
||||
- 撤销 refresh token
|
||||
|
||||
## 2.1 Ops Auth
|
||||
|
||||
说明:
|
||||
|
||||
- 运维账号与前端玩家账号完全分离
|
||||
- 生产环境走手机号验证码运维账号
|
||||
- 开发环境为了录资源和调发布方便,`/admin/ops-workbench` 与 `/ops/admin/*` 默认免登录可用
|
||||
|
||||
### `POST /ops/auth/sms/send`
|
||||
|
||||
用途:
|
||||
|
||||
- 发送运维账号登录 / 注册验证码
|
||||
|
||||
### `POST /ops/auth/register`
|
||||
|
||||
用途:
|
||||
|
||||
- 注册独立运维账号
|
||||
- 首个账号默认授予 `owner`
|
||||
|
||||
### `POST /ops/auth/login/sms`
|
||||
|
||||
用途:
|
||||
|
||||
- 运维账号手机号验证码登录
|
||||
|
||||
### `POST /ops/auth/refresh`
|
||||
|
||||
用途:
|
||||
|
||||
- 刷新运维 access token
|
||||
|
||||
### `POST /ops/auth/logout`
|
||||
|
||||
用途:
|
||||
|
||||
- 撤销运维 refresh token
|
||||
|
||||
### `GET /ops/me`
|
||||
|
||||
鉴权:
|
||||
|
||||
- Ops bearer token
|
||||
|
||||
用途:
|
||||
|
||||
- 获取当前运维账号信息和主角色
|
||||
|
||||
## 2.2 Ops Admin(地图资源管理第一刀)
|
||||
|
||||
### `GET /ops/admin/summary`
|
||||
|
||||
用途:
|
||||
|
||||
- 运维后台总览聚合接口
|
||||
- 返回资源、活动、运行绑定、发布版本统计
|
||||
|
||||
### `GET /ops/admin/assets`
|
||||
|
||||
用途:
|
||||
|
||||
- 读取受管资源列表
|
||||
- 给运维后台资源总览与录入校验使用
|
||||
|
||||
### `POST /ops/admin/assets/register-link`
|
||||
|
||||
用途:
|
||||
|
||||
- 登记已有正式外链资源
|
||||
- 统一纳管到 backend 资源对象
|
||||
|
||||
### `POST /ops/admin/assets/upload`
|
||||
|
||||
用途:
|
||||
|
||||
- 上传文件给 backend,再统一存入 OSS
|
||||
- 运维不需要自己处理底层存储细节
|
||||
|
||||
### `GET /ops/admin/assets/{assetPublicID}`
|
||||
|
||||
用途:
|
||||
|
||||
- 查看单个受管资源详情
|
||||
- 返回资源类型、版本、正式 URL 和元数据
|
||||
|
||||
### `GET /ops/admin/places`
|
||||
|
||||
用途:
|
||||
|
||||
- 读取地点列表
|
||||
- 给运维后台地图资源管理区做浏览入口
|
||||
|
||||
### `POST /ops/admin/places`
|
||||
|
||||
用途:
|
||||
|
||||
- 新建地点
|
||||
|
||||
### `GET /ops/admin/places/{placePublicID}`
|
||||
|
||||
用途:
|
||||
|
||||
- 读取地点详情
|
||||
- 返回当前地点下的地图资源列表
|
||||
|
||||
### `POST /ops/admin/places/{placePublicID}/map-assets`
|
||||
|
||||
用途:
|
||||
|
||||
- 在指定地点下新建地图资源
|
||||
|
||||
### `GET /ops/admin/map-assets/{mapAssetPublicID}`
|
||||
|
||||
用途:
|
||||
|
||||
- 读取地图资源详情
|
||||
- 返回当前瓦片版本、关联赛道集和关联活动摘要
|
||||
|
||||
### `GET /ops/admin/map-assets`
|
||||
|
||||
用途:
|
||||
|
||||
- 读取地图列表
|
||||
- 返回地点、当前瓦片版本和关联活动摘要
|
||||
|
||||
### `PUT /ops/admin/map-assets/{mapAssetPublicID}`
|
||||
|
||||
用途:
|
||||
|
||||
- 更新地图基础信息
|
||||
- 支持名称、封面、摘要、状态维护
|
||||
|
||||
### `POST /ops/admin/map-assets/{mapAssetPublicID}/tile-releases`
|
||||
|
||||
用途:
|
||||
|
||||
- 给地图资源追加新的瓦片版本
|
||||
|
||||
### `POST /ops/admin/ops/tile-releases/import`
|
||||
|
||||
用途:
|
||||
|
||||
- 一次录入 `place + map asset + tile release`
|
||||
- 运维录入地图瓦片版本的最小入口
|
||||
|
||||
### `GET /ops/admin/course-sources`
|
||||
|
||||
用途:
|
||||
|
||||
- 运维后台读取 KML / GeoJSON 输入源列表
|
||||
|
||||
### `GET /ops/admin/course-sources/{sourcePublicID}`
|
||||
|
||||
用途:
|
||||
|
||||
- 运维后台读取单个赛道输入源详情
|
||||
|
||||
### `GET /ops/admin/course-sets/{courseSetPublicID}`
|
||||
|
||||
用途:
|
||||
|
||||
- 运维后台读取赛道集合详情
|
||||
- 用于默认路线和路线预览
|
||||
|
||||
### `POST /ops/admin/ops/course-sets/import-kml-batch`
|
||||
|
||||
用途:
|
||||
|
||||
- 批量录入一组 KML
|
||||
- 自动生成 `course set + variants`
|
||||
|
||||
### `GET /ops/admin/events`
|
||||
|
||||
用途:
|
||||
|
||||
- 运维后台活动列表
|
||||
- 给地图关联活动区做浏览入口
|
||||
|
||||
### `POST /ops/admin/events`
|
||||
|
||||
用途:
|
||||
|
||||
- 运维后台新建活动
|
||||
- 先录活动基础信息,再进入默认绑定和发布链
|
||||
|
||||
### `GET /ops/admin/events/{eventPublicID}`
|
||||
|
||||
用途:
|
||||
|
||||
- 运维后台活动详情
|
||||
- 返回默认绑定和当前 source 摘要
|
||||
|
||||
### `PUT /ops/admin/events/{eventPublicID}`
|
||||
|
||||
用途:
|
||||
|
||||
- 运维后台更新活动基础信息
|
||||
- 支持名称、摘要、slug、状态维护
|
||||
|
||||
### `POST /ops/admin/events/{eventPublicID}/presentations/import`
|
||||
|
||||
用途:
|
||||
|
||||
- 导入活动展示定义
|
||||
- 用于当前活动默认绑定与发布
|
||||
|
||||
### `POST /ops/admin/events/{eventPublicID}/content-bundles/import`
|
||||
|
||||
用途:
|
||||
|
||||
- 导入活动内容包
|
||||
- 用于当前活动默认绑定与发布
|
||||
|
||||
### `POST /ops/admin/events/{eventPublicID}/defaults`
|
||||
|
||||
用途:
|
||||
|
||||
- 保存活动默认绑定
|
||||
- 固化 `runtime / presentation / content bundle` 三元组
|
||||
|
||||
### `GET /ops/admin/events/{eventPublicID}/pipeline`
|
||||
|
||||
用途:
|
||||
|
||||
- 读取活动发布链概览
|
||||
- 返回 source / build / release 摘要
|
||||
|
||||
### `POST /ops/admin/sources/{sourceID}/build`
|
||||
|
||||
用途:
|
||||
|
||||
- 基于 source 构建 build
|
||||
|
||||
### `GET /ops/admin/builds/{buildID}`
|
||||
|
||||
用途:
|
||||
|
||||
- 读取单个 build 明细
|
||||
|
||||
### `POST /ops/admin/builds/{buildID}/publish`
|
||||
|
||||
用途:
|
||||
|
||||
- 发布 build 为正式 release
|
||||
|
||||
### `GET /ops/admin/releases/{releasePublicID}`
|
||||
|
||||
用途:
|
||||
|
||||
- 读取单个 release 详情
|
||||
|
||||
### `POST /ops/admin/events/{eventPublicID}/rollback`
|
||||
|
||||
用途:
|
||||
|
||||
- 将活动当前 release 回滚到指定版本
|
||||
|
||||
## 3. Entry / Home
|
||||
|
||||
### `GET /entry/resolve`
|
||||
@@ -88,6 +366,50 @@
|
||||
- `channelCode`
|
||||
- `channelType`
|
||||
- `platformAppId`
|
||||
|
||||
## 3.1 Public Experience
|
||||
|
||||
### `GET /public/experience-maps`
|
||||
|
||||
用途:
|
||||
|
||||
- 游客模式地图列表
|
||||
- 返回可公开体验的地图与默认活动摘要
|
||||
|
||||
### `GET /public/experience-maps/{mapAssetPublicID}`
|
||||
|
||||
用途:
|
||||
|
||||
- 游客模式地图详情
|
||||
- 返回某张地图下挂载的默认体验活动
|
||||
|
||||
### `GET /public/events/{eventPublicID}`
|
||||
|
||||
用途:
|
||||
|
||||
- 游客模式活动详情
|
||||
- 只允许默认体验活动
|
||||
|
||||
### `GET /public/events/{eventPublicID}/play`
|
||||
|
||||
用途:
|
||||
|
||||
- 游客模式准备页聚合接口
|
||||
- 返回 `canLaunch`、`assignmentMode`、`courseVariants`、`preview`
|
||||
|
||||
### `POST /public/events/{eventPublicID}/launch`
|
||||
|
||||
用途:
|
||||
|
||||
- 游客模式启动一局
|
||||
- 返回与正式 launch 近似结构,并带 `business.isGuest = true`
|
||||
|
||||
核心参数:
|
||||
|
||||
- `releaseId`
|
||||
- `variantId`
|
||||
- `clientType`
|
||||
- `deviceKey`
|
||||
- `tenantCode`
|
||||
|
||||
### `GET /home`
|
||||
@@ -113,6 +435,55 @@
|
||||
- 只返回卡片列表
|
||||
- 当前与 `/home` 使用同一套卡片摘要语义
|
||||
|
||||
当前额外补充:
|
||||
|
||||
- `showInEventList`
|
||||
|
||||
### `GET /experience-maps`
|
||||
|
||||
用途:
|
||||
|
||||
- 地图资源列表
|
||||
- 返回每张地图下默认体验活动数量和默认活动 ID 列表
|
||||
|
||||
返回重点:
|
||||
|
||||
- `placeId`
|
||||
- `placeName`
|
||||
- `mapId`
|
||||
- `mapName`
|
||||
- `coverUrl`
|
||||
- `summary`
|
||||
- `defaultExperienceCount`
|
||||
- `defaultExperienceEventIds`
|
||||
|
||||
### `GET /experience-maps/{mapAssetPublicID}`
|
||||
|
||||
用途:
|
||||
|
||||
- 地图详情
|
||||
- 返回地图基础信息和默认体验活动摘要
|
||||
|
||||
返回重点:
|
||||
|
||||
- `tileBaseUrl`
|
||||
- `tileMetaUrl`
|
||||
- `defaultExperiences[]`
|
||||
|
||||
`defaultExperiences[]` 当前包含:
|
||||
|
||||
- `eventId`
|
||||
- `title`
|
||||
- `subtitle`
|
||||
- `eventType`
|
||||
- `status`
|
||||
- `statusCode`
|
||||
- `ctaText`
|
||||
- `isDefaultExperience`
|
||||
- `showInEventList`
|
||||
- `currentPresentation`
|
||||
- `currentContentBundle`
|
||||
|
||||
### `GET /me/entry-home`
|
||||
|
||||
鉴权:
|
||||
@@ -153,6 +524,7 @@
|
||||
- `release`
|
||||
- `resolvedRelease`
|
||||
- `runtime`
|
||||
- `preview`
|
||||
- `currentPresentation`
|
||||
- `currentContentBundle`
|
||||
|
||||
@@ -165,6 +537,18 @@
|
||||
- `currentContentBundle.bundleType`
|
||||
- `currentContentBundle.version`
|
||||
|
||||
当前 `preview` 为准备页地图预览 V1 只读增强字段,最小包括:
|
||||
|
||||
- `preview.mode`
|
||||
- `preview.baseTiles.tileBaseUrl`
|
||||
- `preview.baseTiles.zoom`
|
||||
- `preview.baseTiles.tileSize`
|
||||
- `preview.viewport.width / height`
|
||||
- `preview.viewport.minLon / minLat / maxLon / maxLat`
|
||||
- `preview.variants[].controls`
|
||||
- `preview.variants[].legs`
|
||||
- `preview.selectedVariantId`
|
||||
|
||||
### `GET /events/{eventPublicID}/play`
|
||||
|
||||
鉴权:
|
||||
@@ -181,6 +565,7 @@
|
||||
- `release`
|
||||
- `resolvedRelease`
|
||||
- `runtime`
|
||||
- `preview`
|
||||
- `currentPresentation`
|
||||
- `currentContentBundle`
|
||||
- `play.assignmentMode`
|
||||
@@ -209,6 +594,8 @@
|
||||
- `currentContentBundle.bundleType`
|
||||
- `currentContentBundle.version`
|
||||
|
||||
当前 `preview` 继续表示准备页地图预览 V1 只读增强字段,结构与 `GET /events/{eventPublicID}` 相同。
|
||||
|
||||
### `POST /events/{eventPublicID}/launch`
|
||||
|
||||
鉴权:
|
||||
@@ -665,6 +1052,9 @@
|
||||
- 当前是后台第一版的最小对象接口
|
||||
- 先只做 Bearer 鉴权
|
||||
- 暂未接正式 RBAC / 管理员权限模型
|
||||
- 运维后台当前已开始切到独立前缀:
|
||||
- `/ops/admin/*`
|
||||
- 这批接口在运维后台中与 `/admin/*` 使用同一套 handler,语义一致
|
||||
|
||||
### `GET /admin/maps`
|
||||
|
||||
@@ -1259,6 +1649,28 @@
|
||||
- 查看地图资产详情
|
||||
- 同时带出瓦片版本和赛道集合摘要
|
||||
|
||||
### `GET /admin/map-assets`
|
||||
|
||||
鉴权:
|
||||
|
||||
- Bearer token
|
||||
|
||||
用途:
|
||||
|
||||
- 查看地图资产列表
|
||||
- 返回地点、当前瓦片版本和关联活动摘要
|
||||
|
||||
### `PUT /admin/map-assets/{mapAssetPublicID}`
|
||||
|
||||
鉴权:
|
||||
|
||||
- Bearer token
|
||||
|
||||
用途:
|
||||
|
||||
- 更新地图资产基础信息
|
||||
- 支持名称、封面、摘要、状态维护
|
||||
|
||||
### `POST /admin/map-assets/{mapAssetPublicID}/tile-releases`
|
||||
|
||||
鉴权:
|
||||
@@ -1415,6 +1827,114 @@
|
||||
|
||||
- 查看单个运行绑定详情
|
||||
|
||||
### `POST /admin/ops/tile-releases/import`
|
||||
|
||||
鉴权:
|
||||
|
||||
- Bearer token
|
||||
|
||||
用途:
|
||||
|
||||
- 运维入口第一期:按 `place + map asset + tile release` 一次录入瓦片版本
|
||||
- 可在录入时直接指定 `setAsCurrent`
|
||||
|
||||
请求体重点:
|
||||
|
||||
- `placeCode`
|
||||
- `placeName`
|
||||
- `mapAssetCode`
|
||||
- `mapAssetName`
|
||||
- `mapType`
|
||||
- `versionCode`
|
||||
- `tileBaseUrl`
|
||||
- `metaUrl`
|
||||
- `publishedAssetRoot`
|
||||
- `setAsCurrent`
|
||||
|
||||
### `POST /admin/ops/course-sets/import-kml-batch`
|
||||
|
||||
鉴权:
|
||||
|
||||
- Bearer token
|
||||
|
||||
用途:
|
||||
|
||||
- 运维入口第一期:按一组 KML 路线批量录入赛道集合与 variants
|
||||
- 适合把同一场地的一批路线一次性整理成 `course set`
|
||||
|
||||
请求体重点:
|
||||
|
||||
- `placeCode`
|
||||
- `placeName`
|
||||
- `mapAssetCode`
|
||||
- `mapAssetName`
|
||||
- `mapType`
|
||||
- `courseSetCode`
|
||||
- `courseSetName`
|
||||
- `mode`
|
||||
- `defaultRouteCode`
|
||||
- `routes[]`
|
||||
|
||||
### `GET /admin/assets`
|
||||
|
||||
鉴权:
|
||||
|
||||
- Bearer token
|
||||
|
||||
用途:
|
||||
|
||||
- 查看当前 backend 已纳管的正式资源对象列表
|
||||
|
||||
### `POST /admin/assets/register-link`
|
||||
|
||||
鉴权:
|
||||
|
||||
- Bearer token
|
||||
|
||||
用途:
|
||||
|
||||
- 登记一个已有正式外链为受管资源
|
||||
|
||||
请求体重点:
|
||||
|
||||
- `assetType`
|
||||
- `assetCode`
|
||||
- `version`
|
||||
- `publicUrl`
|
||||
- `status`
|
||||
- `metadata`
|
||||
|
||||
### `POST /admin/assets/upload`
|
||||
|
||||
鉴权:
|
||||
|
||||
- Bearer token
|
||||
|
||||
用途:
|
||||
|
||||
- 通过 backend 上传一个正式资源文件到 OSS,并自动纳管
|
||||
|
||||
表单重点:
|
||||
|
||||
- `assetType`
|
||||
- `assetCode`
|
||||
- `version`
|
||||
- `title`
|
||||
- `objectDir` 可选
|
||||
- `status`
|
||||
- `metadataJson` 可选
|
||||
- `file`
|
||||
|
||||
### `GET /admin/assets/{assetPublicID}`
|
||||
|
||||
鉴权:
|
||||
|
||||
- Bearer token
|
||||
|
||||
用途:
|
||||
|
||||
- 查看单个已纳管资源对象详情
|
||||
|
||||
|
||||
### `GET /home`
|
||||
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
# 数据模型
|
||||
> 文档版本:v1.4
|
||||
> 最后更新:2026-04-03 22:34:08
|
||||
> 文档版本:v1.6
|
||||
> 最后更新:2026-04-07 16:29:08
|
||||
|
||||
当前 migration 共 11 版。
|
||||
当前 migration 共 15 版。
|
||||
|
||||
## 1. 迁移清单
|
||||
|
||||
@@ -17,6 +17,20 @@
|
||||
- [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)
|
||||
- [0011_card_summary.sql](D:/dev/cmr-mini/backend/migrations/0011_card_summary.sql)
|
||||
- [0012_managed_assets.sql](D:/dev/cmr-mini/backend/migrations/0012_managed_assets.sql)
|
||||
- [0013_ops_console.sql](D:/dev/cmr-mini/backend/migrations/0013_ops_console.sql)
|
||||
- [0014_map_experience.sql](D:/dev/cmr-mini/backend/migrations/0014_map_experience.sql)
|
||||
- [0015_guest_identity.sql](D:/dev/cmr-mini/backend/migrations/0015_guest_identity.sql)
|
||||
|
||||
## 2. 当前地图体验入口相关字段
|
||||
|
||||
- `events.is_default_experience`
|
||||
- `events.show_in_event_list`
|
||||
|
||||
当前用途:
|
||||
|
||||
- 支撑地图列表下的默认体验活动
|
||||
- 统一活动卡片、地图详情和默认体验入口语义
|
||||
|
||||
## 2. 表分组
|
||||
|
||||
@@ -50,6 +64,7 @@
|
||||
- `mobile`
|
||||
- `wechat_mini_openid`
|
||||
- `wechat_unionid`
|
||||
- `guest`
|
||||
|
||||
### 2.3 业务对象与配置发布
|
||||
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
# 资源对象与目录方案
|
||||
> 文档版本:v1.0
|
||||
> 最后更新:2026-04-02 08:28:05
|
||||
> 文档版本:v1.3
|
||||
> 最后更新:2026-04-07 13:13:19
|
||||
|
||||
|
||||
本文档用于把“地图复用、KML 复用、内容资源复用、配置发布”统一收成一套后端可执行方案。
|
||||
@@ -13,6 +13,20 @@
|
||||
- 让 `release` 能稳定追溯当时到底用了哪一份地图、哪一份 KML、哪一套资源包
|
||||
- 让同一套资源对象既能服务小程序,也能服务未来 APP
|
||||
|
||||
当前补充约束:
|
||||
|
||||
- 正式资源目录只认 `OSS / CDN`
|
||||
- 本地 `tmp/` 仅作为临时收件箱,不参与正式发布源
|
||||
- backend 当前已开始提供运维入口第一期:
|
||||
- `POST /admin/ops/tile-releases/import`
|
||||
- `POST /admin/ops/course-sets/import-kml-batch`
|
||||
- backend 当前也已开始提供运维入口第二期:
|
||||
- `POST /admin/assets/upload`
|
||||
- `POST /admin/assets/register-link`
|
||||
- `GET /admin/assets`
|
||||
- `GET /admin/assets/{assetPublicID}`
|
||||
- 当前目标是把“上传文件”和“登记外链”统一收口到同一套资源模型,不要求运维自己关心底层存储实现。
|
||||
|
||||
---
|
||||
|
||||
## 1. 设计结论
|
||||
@@ -379,6 +393,10 @@ build / publish 时,Go 中间层应做装配:
|
||||
|
||||
```text
|
||||
gotomars/maps/{mapCode}/{version}/...
|
||||
gotomars/kml/{placeCode}/{version}/route01.kml
|
||||
gotomars/kml/{placeCode}/{version}/route02.kml
|
||||
gotomars/kml/{placeCode}/{version}/route03.kml
|
||||
gotomars/kml/{placeCode}/{version}/route04.kml
|
||||
gotomars/playfields/{playfieldCode}/{version}/...
|
||||
gotomars/resource-packs/{packCode}/{version}/...
|
||||
gotomars/game-modes/{modeCode}/{version}/mode.json
|
||||
@@ -394,6 +412,16 @@ gotomars/event-releases/{eventPublicID}/{releasePublicID}/asset-index.json
|
||||
- 同一个 map / KML 修复时不会污染所有旧 release
|
||||
- APP 与小程序可共用相同资源版本,不必重复发两套发布目录
|
||||
|
||||
补充约束:
|
||||
|
||||
- 正式资源目录只认 `OSS / CDN`,不认仓库本地目录
|
||||
- `tmp/` 只作为临时收件箱,不作为任何正式发布源
|
||||
- 当前 manual 多赛道 demo 已切到:
|
||||
- `gotomars/kml/lxcb-001/2026-04-07/route01.kml`
|
||||
- `gotomars/kml/lxcb-001/2026-04-07/route02.kml`
|
||||
- `gotomars/kml/lxcb-001/2026-04-07/route03.kml`
|
||||
- `gotomars/kml/lxcb-001/2026-04-07/route04.kml`
|
||||
|
||||
---
|
||||
|
||||
## 8. 数据库建模建议
|
||||
|
||||
@@ -26,6 +26,7 @@ func New(ctx context.Context, cfg Config) (*App, error) {
|
||||
store := postgres.NewStore(pool)
|
||||
jwtManager := jwtx.NewManager(cfg.JWTIssuer, cfg.JWTAccessSecret, cfg.JWTAccessTTL)
|
||||
wechatMiniClient := wechatmini.NewClient(cfg.WechatMiniAppID, cfg.WechatMiniSecret, cfg.WechatMiniDevPrefix)
|
||||
assetPublisher := assets.NewOSSUtilPublisher(cfg.OSSUtilPath, cfg.OSSUtilConfigFile, cfg.AssetBucketRoot, cfg.AssetPublicBaseURL)
|
||||
authService := service.NewAuthService(service.AuthSettings{
|
||||
AppEnv: cfg.AppEnv,
|
||||
RefreshTTL: cfg.RefreshTTL,
|
||||
@@ -37,21 +38,32 @@ func New(ctx context.Context, cfg Config) (*App, error) {
|
||||
}, store, jwtManager)
|
||||
entryService := service.NewEntryService(store)
|
||||
entryHomeService := service.NewEntryHomeService(store)
|
||||
adminAssetService := service.NewAdminAssetService(store, cfg.AssetBaseURL, assetPublisher)
|
||||
adminResourceService := service.NewAdminResourceService(store)
|
||||
adminProductionService := service.NewAdminProductionService(store)
|
||||
adminEventService := service.NewAdminEventService(store)
|
||||
opsAuthService := service.NewOpsAuthService(service.OpsAuthSettings{
|
||||
AppEnv: cfg.AppEnv,
|
||||
RefreshTTL: cfg.RefreshTTL,
|
||||
SMSCodeTTL: cfg.SMSCodeTTL,
|
||||
SMSCodeCooldown: cfg.SMSCodeCooldown,
|
||||
SMSProvider: cfg.SMSProvider,
|
||||
DevSMSCode: cfg.DevSMSCode,
|
||||
}, store, jwtManager)
|
||||
opsSummaryService := service.NewOpsSummaryService(store)
|
||||
eventService := service.NewEventService(store)
|
||||
eventPlayService := service.NewEventPlayService(store)
|
||||
assetPublisher := assets.NewOSSUtilPublisher(cfg.OSSUtilPath, cfg.OSSUtilConfigFile, cfg.AssetBucketRoot, cfg.AssetPublicBaseURL)
|
||||
configService := service.NewConfigService(store, cfg.LocalEventDir, cfg.AssetBaseURL, assetPublisher)
|
||||
adminPipelineService := service.NewAdminPipelineService(store, configService)
|
||||
homeService := service.NewHomeService(store)
|
||||
mapExperienceService := service.NewMapExperienceService(store)
|
||||
publicExperienceService := service.NewPublicExperienceService(store, mapExperienceService, eventService)
|
||||
profileService := service.NewProfileService(store)
|
||||
resultService := service.NewResultService(store)
|
||||
sessionService := service.NewSessionService(store)
|
||||
devService := service.NewDevService(cfg.AppEnv, store)
|
||||
meService := service.NewMeService(store)
|
||||
router := httpapi.NewRouter(cfg.AppEnv, jwtManager, authService, entryService, entryHomeService, adminResourceService, adminProductionService, adminEventService, adminPipelineService, eventService, eventPlayService, configService, homeService, profileService, resultService, sessionService, devService, meService)
|
||||
router := httpapi.NewRouter(cfg.AppEnv, jwtManager, authService, opsAuthService, opsSummaryService, entryService, entryHomeService, adminAssetService, adminResourceService, adminProductionService, adminEventService, adminPipelineService, eventService, eventPlayService, publicExperienceService, configService, homeService, mapExperienceService, profileService, resultService, sessionService, devService, meService)
|
||||
|
||||
return &App{
|
||||
router: router,
|
||||
|
||||
132
backend/internal/httpapi/handlers/admin_asset_handler.go
Normal file
132
backend/internal/httpapi/handlers/admin_asset_handler.go
Normal file
@@ -0,0 +1,132 @@
|
||||
package handlers
|
||||
|
||||
import (
|
||||
"crypto/sha256"
|
||||
"encoding/hex"
|
||||
"encoding/json"
|
||||
"io"
|
||||
"net/http"
|
||||
"os"
|
||||
|
||||
"cmr-backend/internal/apperr"
|
||||
"cmr-backend/internal/httpx"
|
||||
"cmr-backend/internal/service"
|
||||
)
|
||||
|
||||
type AdminAssetHandler struct {
|
||||
service *service.AdminAssetService
|
||||
}
|
||||
|
||||
func NewAdminAssetHandler(service *service.AdminAssetService) *AdminAssetHandler {
|
||||
return &AdminAssetHandler{service: service}
|
||||
}
|
||||
|
||||
func (h *AdminAssetHandler) ListAssets(w http.ResponseWriter, r *http.Request) {
|
||||
result, err := h.service.ListManagedAssets(r.Context(), parseAdminLimit(r))
|
||||
if err != nil {
|
||||
httpx.WriteError(w, err)
|
||||
return
|
||||
}
|
||||
httpx.WriteJSON(w, http.StatusOK, map[string]any{"data": result})
|
||||
}
|
||||
|
||||
func (h *AdminAssetHandler) GetAsset(w http.ResponseWriter, r *http.Request) {
|
||||
result, err := h.service.GetManagedAsset(r.Context(), r.PathValue("assetPublicID"))
|
||||
if err != nil {
|
||||
httpx.WriteError(w, err)
|
||||
return
|
||||
}
|
||||
httpx.WriteJSON(w, http.StatusOK, map[string]any{"data": result})
|
||||
}
|
||||
|
||||
func (h *AdminAssetHandler) RegisterLink(w http.ResponseWriter, r *http.Request) {
|
||||
var req service.RegisterLinkAssetInput
|
||||
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.RegisterExternalLink(r.Context(), req)
|
||||
if err != nil {
|
||||
httpx.WriteError(w, err)
|
||||
return
|
||||
}
|
||||
httpx.WriteJSON(w, http.StatusCreated, map[string]any{"data": result})
|
||||
}
|
||||
|
||||
func (h *AdminAssetHandler) UploadFile(w http.ResponseWriter, r *http.Request) {
|
||||
if err := r.ParseMultipartForm(64 << 20); err != nil {
|
||||
httpx.WriteError(w, apperr.New(http.StatusBadRequest, "invalid_multipart", "invalid multipart form: "+err.Error()))
|
||||
return
|
||||
}
|
||||
|
||||
file, header, err := r.FormFile("file")
|
||||
if err != nil {
|
||||
httpx.WriteError(w, apperr.New(http.StatusBadRequest, "file_required", "multipart file field 'file' is required"))
|
||||
return
|
||||
}
|
||||
defer file.Close()
|
||||
|
||||
tmpFile, err := os.CreateTemp("", "cmr-upload-*"+header.Filename)
|
||||
if err != nil {
|
||||
httpx.WriteError(w, err)
|
||||
return
|
||||
}
|
||||
tmpPath := tmpFile.Name()
|
||||
defer os.Remove(tmpPath)
|
||||
|
||||
hash := sha256.New()
|
||||
written, err := io.Copy(io.MultiWriter(tmpFile, hash), file)
|
||||
if err != nil {
|
||||
tmpFile.Close()
|
||||
httpx.WriteError(w, err)
|
||||
return
|
||||
}
|
||||
if err := tmpFile.Close(); err != nil {
|
||||
httpx.WriteError(w, err)
|
||||
return
|
||||
}
|
||||
|
||||
input := service.UploadAssetFileInput{
|
||||
AssetType: r.FormValue("assetType"),
|
||||
AssetCode: r.FormValue("assetCode"),
|
||||
Version: r.FormValue("version"),
|
||||
Title: stringPtrOrNil(r.FormValue("title")),
|
||||
ObjectDir: stringPtrOrNil(r.FormValue("objectDir")),
|
||||
FileName: header.Filename,
|
||||
ContentType: header.Header.Get("Content-Type"),
|
||||
FileSize: written,
|
||||
Checksum: hex.EncodeToString(hash.Sum(nil)),
|
||||
TempPath: tmpPath,
|
||||
Status: r.FormValue("status"),
|
||||
Metadata: parseMetadataJSON(r.FormValue("metadataJson")),
|
||||
}
|
||||
|
||||
result, err := h.service.UploadAssetFile(r.Context(), input)
|
||||
if err != nil {
|
||||
httpx.WriteError(w, err)
|
||||
return
|
||||
}
|
||||
httpx.WriteJSON(w, http.StatusCreated, map[string]any{
|
||||
"data": result,
|
||||
"meta": map[string]any{
|
||||
"uploadedBytes": written,
|
||||
"checksumSha256": input.Checksum,
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
func stringPtrOrNil(value string) *string {
|
||||
if value == "" {
|
||||
return nil
|
||||
}
|
||||
return &value
|
||||
}
|
||||
|
||||
func parseMetadataJSON(raw string) map[string]any {
|
||||
if raw == "" {
|
||||
return nil
|
||||
}
|
||||
var payload map[string]any
|
||||
_ = json.Unmarshal([]byte(raw), &payload)
|
||||
return payload
|
||||
}
|
||||
@@ -25,6 +25,15 @@ func (h *AdminProductionHandler) ListPlaces(w http.ResponseWriter, r *http.Reque
|
||||
httpx.WriteJSON(w, http.StatusOK, map[string]any{"data": result})
|
||||
}
|
||||
|
||||
func (h *AdminProductionHandler) ListMapAssets(w http.ResponseWriter, r *http.Request) {
|
||||
result, err := h.service.ListMapAssets(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 {
|
||||
@@ -71,6 +80,20 @@ func (h *AdminProductionHandler) GetMapAsset(w http.ResponseWriter, r *http.Requ
|
||||
httpx.WriteJSON(w, http.StatusOK, map[string]any{"data": result})
|
||||
}
|
||||
|
||||
func (h *AdminProductionHandler) UpdateMapAsset(w http.ResponseWriter, r *http.Request) {
|
||||
var req service.UpdateAdminMapAssetInput
|
||||
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.UpdateMapAsset(r.Context(), r.PathValue("mapAssetPublicID"), req)
|
||||
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 {
|
||||
@@ -177,6 +200,34 @@ func (h *AdminProductionHandler) CreateRuntimeBinding(w http.ResponseWriter, r *
|
||||
httpx.WriteJSON(w, http.StatusCreated, map[string]any{"data": result})
|
||||
}
|
||||
|
||||
func (h *AdminProductionHandler) ImportTileRelease(w http.ResponseWriter, r *http.Request) {
|
||||
var req service.ImportAdminTileReleaseInput
|
||||
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.ImportTileRelease(r.Context(), req)
|
||||
if err != nil {
|
||||
httpx.WriteError(w, err)
|
||||
return
|
||||
}
|
||||
httpx.WriteJSON(w, http.StatusCreated, map[string]any{"data": result})
|
||||
}
|
||||
|
||||
func (h *AdminProductionHandler) ImportCourseSetKMLBatch(w http.ResponseWriter, r *http.Request) {
|
||||
var req service.ImportAdminCourseSetBatchInput
|
||||
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.ImportCourseSetKMLBatch(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 {
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
41
backend/internal/httpapi/handlers/map_experience_handler.go
Normal file
41
backend/internal/httpapi/handlers/map_experience_handler.go
Normal file
@@ -0,0 +1,41 @@
|
||||
package handlers
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"strconv"
|
||||
|
||||
"cmr-backend/internal/httpx"
|
||||
"cmr-backend/internal/service"
|
||||
)
|
||||
|
||||
type MapExperienceHandler struct {
|
||||
service *service.MapExperienceService
|
||||
}
|
||||
|
||||
func NewMapExperienceHandler(service *service.MapExperienceService) *MapExperienceHandler {
|
||||
return &MapExperienceHandler{service: service}
|
||||
}
|
||||
|
||||
func (h *MapExperienceHandler) ListMaps(w http.ResponseWriter, r *http.Request) {
|
||||
limit := 20
|
||||
if raw := r.URL.Query().Get("limit"); raw != "" {
|
||||
if parsed, err := strconv.Atoi(raw); err == nil {
|
||||
limit = parsed
|
||||
}
|
||||
}
|
||||
result, err := h.service.ListMaps(r.Context(), service.ListExperienceMapsInput{Limit: limit})
|
||||
if err != nil {
|
||||
httpx.WriteError(w, err)
|
||||
return
|
||||
}
|
||||
httpx.WriteJSON(w, http.StatusOK, map[string]any{"data": result})
|
||||
}
|
||||
|
||||
func (h *MapExperienceHandler) GetMapDetail(w http.ResponseWriter, r *http.Request) {
|
||||
result, err := h.service.GetMapDetail(r.Context(), r.PathValue("mapAssetPublicID"))
|
||||
if err != nil {
|
||||
httpx.WriteError(w, err)
|
||||
return
|
||||
}
|
||||
httpx.WriteJSON(w, http.StatusOK, map[string]any{"data": result})
|
||||
}
|
||||
101
backend/internal/httpapi/handlers/ops_auth_handler.go
Normal file
101
backend/internal/httpapi/handlers/ops_auth_handler.go
Normal file
@@ -0,0 +1,101 @@
|
||||
package handlers
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
|
||||
"cmr-backend/internal/apperr"
|
||||
"cmr-backend/internal/httpapi/middleware"
|
||||
"cmr-backend/internal/httpx"
|
||||
"cmr-backend/internal/service"
|
||||
)
|
||||
|
||||
type OpsAuthHandler struct {
|
||||
service *service.OpsAuthService
|
||||
}
|
||||
|
||||
func NewOpsAuthHandler(service *service.OpsAuthService) *OpsAuthHandler {
|
||||
return &OpsAuthHandler{service: service}
|
||||
}
|
||||
|
||||
func (h *OpsAuthHandler) SendSMSCode(w http.ResponseWriter, r *http.Request) {
|
||||
var req service.OpsSendSMSCodeInput
|
||||
if err := httpx.DecodeJSON(r, &req); err != nil {
|
||||
httpx.WriteError(w, apperr.New(http.StatusBadRequest, "invalid_json", "invalid request body"))
|
||||
return
|
||||
}
|
||||
result, err := h.service.SendSMSCode(r.Context(), req)
|
||||
if err != nil {
|
||||
httpx.WriteError(w, err)
|
||||
return
|
||||
}
|
||||
httpx.WriteJSON(w, http.StatusOK, map[string]any{"data": result})
|
||||
}
|
||||
|
||||
func (h *OpsAuthHandler) Register(w http.ResponseWriter, r *http.Request) {
|
||||
var req service.OpsRegisterInput
|
||||
if err := httpx.DecodeJSON(r, &req); err != nil {
|
||||
httpx.WriteError(w, apperr.New(http.StatusBadRequest, "invalid_json", "invalid request body"))
|
||||
return
|
||||
}
|
||||
result, err := h.service.Register(r.Context(), req)
|
||||
if err != nil {
|
||||
httpx.WriteError(w, err)
|
||||
return
|
||||
}
|
||||
httpx.WriteJSON(w, http.StatusOK, map[string]any{"data": result})
|
||||
}
|
||||
|
||||
func (h *OpsAuthHandler) LoginSMS(w http.ResponseWriter, r *http.Request) {
|
||||
var req service.OpsLoginSMSInput
|
||||
if err := httpx.DecodeJSON(r, &req); err != nil {
|
||||
httpx.WriteError(w, apperr.New(http.StatusBadRequest, "invalid_json", "invalid request body"))
|
||||
return
|
||||
}
|
||||
result, err := h.service.LoginSMS(r.Context(), req)
|
||||
if err != nil {
|
||||
httpx.WriteError(w, err)
|
||||
return
|
||||
}
|
||||
httpx.WriteJSON(w, http.StatusOK, map[string]any{"data": result})
|
||||
}
|
||||
|
||||
func (h *OpsAuthHandler) Refresh(w http.ResponseWriter, r *http.Request) {
|
||||
var req service.OpsRefreshTokenInput
|
||||
if err := httpx.DecodeJSON(r, &req); err != nil {
|
||||
httpx.WriteError(w, apperr.New(http.StatusBadRequest, "invalid_json", "invalid request body"))
|
||||
return
|
||||
}
|
||||
result, err := h.service.Refresh(r.Context(), req)
|
||||
if err != nil {
|
||||
httpx.WriteError(w, err)
|
||||
return
|
||||
}
|
||||
httpx.WriteJSON(w, http.StatusOK, map[string]any{"data": result})
|
||||
}
|
||||
|
||||
func (h *OpsAuthHandler) Logout(w http.ResponseWriter, r *http.Request) {
|
||||
var req service.OpsLogoutInput
|
||||
if err := httpx.DecodeJSON(r, &req); err != nil {
|
||||
httpx.WriteError(w, apperr.New(http.StatusBadRequest, "invalid_json", "invalid request body"))
|
||||
return
|
||||
}
|
||||
if err := h.service.Logout(r.Context(), req); err != nil {
|
||||
httpx.WriteError(w, err)
|
||||
return
|
||||
}
|
||||
httpx.WriteJSON(w, http.StatusOK, map[string]any{"data": map[string]any{"loggedOut": true}})
|
||||
}
|
||||
|
||||
func (h *OpsAuthHandler) Me(w http.ResponseWriter, r *http.Request) {
|
||||
auth := middleware.GetOpsAuthContext(r.Context())
|
||||
if auth == nil {
|
||||
httpx.WriteError(w, apperr.New(http.StatusUnauthorized, "unauthorized", "missing ops auth context"))
|
||||
return
|
||||
}
|
||||
result, err := h.service.GetMe(r.Context(), auth.OpsUserID)
|
||||
if err != nil {
|
||||
httpx.WriteError(w, err)
|
||||
return
|
||||
}
|
||||
httpx.WriteJSON(w, http.StatusOK, map[string]any{"data": result})
|
||||
}
|
||||
25
backend/internal/httpapi/handlers/ops_summary_handler.go
Normal file
25
backend/internal/httpapi/handlers/ops_summary_handler.go
Normal file
@@ -0,0 +1,25 @@
|
||||
package handlers
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
|
||||
"cmr-backend/internal/httpx"
|
||||
"cmr-backend/internal/service"
|
||||
)
|
||||
|
||||
type OpsSummaryHandler struct {
|
||||
service *service.OpsSummaryService
|
||||
}
|
||||
|
||||
func NewOpsSummaryHandler(service *service.OpsSummaryService) *OpsSummaryHandler {
|
||||
return &OpsSummaryHandler{service: service}
|
||||
}
|
||||
|
||||
func (h *OpsSummaryHandler) GetOverview(w http.ResponseWriter, r *http.Request) {
|
||||
result, err := h.service.GetOverview(r.Context())
|
||||
if err != nil {
|
||||
httpx.WriteError(w, err)
|
||||
return
|
||||
}
|
||||
httpx.WriteJSON(w, http.StatusOK, map[string]any{"data": result})
|
||||
}
|
||||
1681
backend/internal/httpapi/handlers/ops_workbench_handler.go
Normal file
1681
backend/internal/httpapi/handlers/ops_workbench_handler.go
Normal file
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,78 @@
|
||||
package handlers
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"strconv"
|
||||
|
||||
"cmr-backend/internal/apperr"
|
||||
"cmr-backend/internal/httpx"
|
||||
"cmr-backend/internal/service"
|
||||
)
|
||||
|
||||
type PublicExperienceHandler struct {
|
||||
service *service.PublicExperienceService
|
||||
}
|
||||
|
||||
func NewPublicExperienceHandler(service *service.PublicExperienceService) *PublicExperienceHandler {
|
||||
return &PublicExperienceHandler{service: service}
|
||||
}
|
||||
|
||||
func (h *PublicExperienceHandler) ListMaps(w http.ResponseWriter, r *http.Request) {
|
||||
limit := 20
|
||||
if raw := r.URL.Query().Get("limit"); raw != "" {
|
||||
if parsed, err := strconv.Atoi(raw); err == nil {
|
||||
limit = parsed
|
||||
}
|
||||
}
|
||||
result, err := h.service.ListMaps(r.Context(), service.ListExperienceMapsInput{Limit: limit})
|
||||
if err != nil {
|
||||
httpx.WriteError(w, err)
|
||||
return
|
||||
}
|
||||
httpx.WriteJSON(w, http.StatusOK, map[string]any{"data": result})
|
||||
}
|
||||
|
||||
func (h *PublicExperienceHandler) GetMapDetail(w http.ResponseWriter, r *http.Request) {
|
||||
result, err := h.service.GetMapDetail(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 *PublicExperienceHandler) GetEventDetail(w http.ResponseWriter, r *http.Request) {
|
||||
result, err := h.service.GetEventDetail(r.Context(), r.PathValue("eventPublicID"))
|
||||
if err != nil {
|
||||
httpx.WriteError(w, err)
|
||||
return
|
||||
}
|
||||
httpx.WriteJSON(w, http.StatusOK, map[string]any{"data": result})
|
||||
}
|
||||
|
||||
func (h *PublicExperienceHandler) GetEventPlay(w http.ResponseWriter, r *http.Request) {
|
||||
result, err := h.service.GetEventPlay(r.Context(), service.PublicEventPlayInput{
|
||||
EventPublicID: r.PathValue("eventPublicID"),
|
||||
})
|
||||
if err != nil {
|
||||
httpx.WriteError(w, err)
|
||||
return
|
||||
}
|
||||
httpx.WriteJSON(w, http.StatusOK, map[string]any{"data": result})
|
||||
}
|
||||
|
||||
func (h *PublicExperienceHandler) Launch(w http.ResponseWriter, r *http.Request) {
|
||||
var req service.PublicLaunchEventInput
|
||||
if err := httpx.DecodeJSON(r, &req); err != nil {
|
||||
httpx.WriteError(w, apperr.New(http.StatusBadRequest, "invalid_json", "invalid request body"))
|
||||
return
|
||||
}
|
||||
req.EventPublicID = r.PathValue("eventPublicID")
|
||||
|
||||
result, err := h.service.LaunchEvent(r.Context(), req)
|
||||
if err != nil {
|
||||
httpx.WriteError(w, err)
|
||||
return
|
||||
}
|
||||
httpx.WriteJSON(w, http.StatusOK, map[string]any{"data": result})
|
||||
}
|
||||
162
backend/internal/httpapi/handlers/region_options_handler.go
Normal file
162
backend/internal/httpapi/handlers/region_options_handler.go
Normal file
@@ -0,0 +1,162 @@
|
||||
package handlers
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"sort"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"cmr-backend/internal/apperr"
|
||||
"cmr-backend/internal/httpx"
|
||||
)
|
||||
|
||||
type RegionOptionsHandler struct {
|
||||
client *http.Client
|
||||
mu sync.Mutex
|
||||
cache []regionProvince
|
||||
}
|
||||
|
||||
type regionProvince struct {
|
||||
Code string `json:"code"`
|
||||
Name string `json:"name"`
|
||||
Cities []regionCity `json:"cities"`
|
||||
}
|
||||
|
||||
type regionCity struct {
|
||||
Code string `json:"code"`
|
||||
Name string `json:"name"`
|
||||
}
|
||||
|
||||
type remoteProvince struct {
|
||||
Code string `json:"code"`
|
||||
Name string `json:"name"`
|
||||
}
|
||||
|
||||
type remoteCity struct {
|
||||
Code string `json:"code"`
|
||||
Name string `json:"name"`
|
||||
Province string `json:"province"`
|
||||
}
|
||||
|
||||
func NewRegionOptionsHandler() *RegionOptionsHandler {
|
||||
return &RegionOptionsHandler{
|
||||
client: &http.Client{Timeout: 12 * time.Second},
|
||||
}
|
||||
}
|
||||
|
||||
func (h *RegionOptionsHandler) Get(w http.ResponseWriter, r *http.Request) {
|
||||
items, err := h.load(r.Context())
|
||||
if err != nil {
|
||||
httpx.WriteError(w, err)
|
||||
return
|
||||
}
|
||||
httpx.WriteJSON(w, http.StatusOK, map[string]any{"data": items})
|
||||
}
|
||||
|
||||
func (h *RegionOptionsHandler) load(ctx context.Context) ([]regionProvince, error) {
|
||||
h.mu.Lock()
|
||||
if len(h.cache) > 0 {
|
||||
cached := h.cache
|
||||
h.mu.Unlock()
|
||||
return cached, nil
|
||||
}
|
||||
h.mu.Unlock()
|
||||
|
||||
// Data source:
|
||||
// https://github.com/uiwjs/province-city-china
|
||||
// Using province + city JSON only, then reducing to the province/city structure
|
||||
// needed by ops workbench location management.
|
||||
provinces, err := h.fetchProvinces(ctx, "https://unpkg.com/province-city-china/dist/province.json")
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
cities, err := h.fetchCities(ctx, "https://unpkg.com/province-city-china/dist/city.json")
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
cityMap := make(map[string][]regionCity)
|
||||
for _, item := range cities {
|
||||
if item.Province == "" || item.Code == "" {
|
||||
continue
|
||||
}
|
||||
fullCode := item.Province + item.Code + "00"
|
||||
cityMap[item.Province] = append(cityMap[item.Province], regionCity{
|
||||
Code: fullCode,
|
||||
Name: item.Name,
|
||||
})
|
||||
}
|
||||
for key := range cityMap {
|
||||
sort.Slice(cityMap[key], func(i, j int) bool { return cityMap[key][i].Code < cityMap[key][j].Code })
|
||||
}
|
||||
|
||||
items := make([]regionProvince, 0, len(provinces))
|
||||
for _, item := range provinces {
|
||||
if len(item.Code) < 2 {
|
||||
continue
|
||||
}
|
||||
provinceCode := item.Code[:2]
|
||||
province := regionProvince{
|
||||
Code: item.Code,
|
||||
Name: item.Name,
|
||||
}
|
||||
if entries := cityMap[provinceCode]; len(entries) > 0 {
|
||||
province.Cities = entries
|
||||
} else {
|
||||
// 直辖市 / 特殊地区没有单独的地级市列表时,退化成自身即可。
|
||||
province.Cities = []regionCity{{
|
||||
Code: item.Code,
|
||||
Name: item.Name,
|
||||
}}
|
||||
}
|
||||
items = append(items, province)
|
||||
}
|
||||
|
||||
h.mu.Lock()
|
||||
h.cache = items
|
||||
h.mu.Unlock()
|
||||
return items, nil
|
||||
}
|
||||
|
||||
func (h *RegionOptionsHandler) fetchProvinces(ctx context.Context, url string) ([]remoteProvince, error) {
|
||||
req, err := http.NewRequestWithContext(ctx, http.MethodGet, url, nil)
|
||||
if err != nil {
|
||||
return nil, apperr.New(http.StatusBadGateway, "region_source_unavailable", "省市数据源不可用")
|
||||
}
|
||||
resp, err := h.client.Do(req)
|
||||
if err != nil {
|
||||
return nil, apperr.New(http.StatusBadGateway, "region_source_unavailable", "省市数据源不可用")
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
return nil, apperr.New(http.StatusBadGateway, "region_source_unavailable", fmt.Sprintf("省级数据拉取失败: %d", resp.StatusCode))
|
||||
}
|
||||
var items []remoteProvince
|
||||
if err := json.NewDecoder(resp.Body).Decode(&items); err != nil {
|
||||
return nil, apperr.New(http.StatusBadGateway, "region_source_invalid", "省级数据格式无效")
|
||||
}
|
||||
return items, nil
|
||||
}
|
||||
|
||||
func (h *RegionOptionsHandler) fetchCities(ctx context.Context, url string) ([]remoteCity, error) {
|
||||
req, err := http.NewRequestWithContext(ctx, http.MethodGet, url, nil)
|
||||
if err != nil {
|
||||
return nil, apperr.New(http.StatusBadGateway, "region_source_unavailable", "省市数据源不可用")
|
||||
}
|
||||
resp, err := h.client.Do(req)
|
||||
if err != nil {
|
||||
return nil, apperr.New(http.StatusBadGateway, "region_source_unavailable", "省市数据源不可用")
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
return nil, apperr.New(http.StatusBadGateway, "region_source_unavailable", fmt.Sprintf("市级数据拉取失败: %d", resp.StatusCode))
|
||||
}
|
||||
var items []remoteCity
|
||||
if err := json.NewDecoder(resp.Body).Decode(&items); err != nil {
|
||||
return nil, apperr.New(http.StatusBadGateway, "region_source_invalid", "市级数据格式无效")
|
||||
}
|
||||
return items, nil
|
||||
}
|
||||
@@ -17,6 +17,7 @@ const authKey authContextKey = "auth"
|
||||
type AuthContext struct {
|
||||
UserID string
|
||||
UserPublicID string
|
||||
RoleCode string
|
||||
}
|
||||
|
||||
func NewAuthMiddleware(jwtManager *jwtx.Manager) func(http.Handler) http.Handler {
|
||||
@@ -34,10 +35,15 @@ func NewAuthMiddleware(jwtManager *jwtx.Manager) func(http.Handler) http.Handler
|
||||
httpx.WriteError(w, apperr.New(http.StatusUnauthorized, "invalid_token", "invalid access token"))
|
||||
return
|
||||
}
|
||||
if claims.ActorType != "" && claims.ActorType != "user" {
|
||||
httpx.WriteError(w, apperr.New(http.StatusUnauthorized, "invalid_token", "invalid access token"))
|
||||
return
|
||||
}
|
||||
|
||||
ctx := context.WithValue(r.Context(), authKey, &AuthContext{
|
||||
UserID: claims.UserID,
|
||||
UserPublicID: claims.UserPublicID,
|
||||
RoleCode: claims.RoleCode,
|
||||
})
|
||||
next.ServeHTTP(w, r.WithContext(ctx))
|
||||
})
|
||||
|
||||
77
backend/internal/httpapi/middleware/ops_auth.go
Normal file
77
backend/internal/httpapi/middleware/ops_auth.go
Normal file
@@ -0,0 +1,77 @@
|
||||
package middleware
|
||||
|
||||
import (
|
||||
"context"
|
||||
"net/http"
|
||||
"strings"
|
||||
|
||||
"cmr-backend/internal/apperr"
|
||||
"cmr-backend/internal/httpx"
|
||||
"cmr-backend/internal/platform/jwtx"
|
||||
)
|
||||
|
||||
type opsAuthContextKey string
|
||||
|
||||
const opsAuthKey opsAuthContextKey = "ops-auth"
|
||||
|
||||
type OpsAuthContext struct {
|
||||
OpsUserID string
|
||||
OpsUserPublicID string
|
||||
RoleCode string
|
||||
}
|
||||
|
||||
func NewOpsAuthMiddleware(jwtManager *jwtx.Manager, appEnv string) func(http.Handler) http.Handler {
|
||||
devContext := func(r *http.Request) *http.Request {
|
||||
ctx := context.WithValue(r.Context(), opsAuthKey, &OpsAuthContext{
|
||||
OpsUserID: "dev-ops-user",
|
||||
OpsUserPublicID: "ops_dev_console",
|
||||
RoleCode: "owner",
|
||||
})
|
||||
return r.WithContext(ctx)
|
||||
}
|
||||
|
||||
return func(next http.Handler) http.Handler {
|
||||
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
authHeader := strings.TrimSpace(r.Header.Get("Authorization"))
|
||||
if authHeader == "" || !strings.HasPrefix(authHeader, "Bearer ") {
|
||||
if appEnv != "production" {
|
||||
next.ServeHTTP(w, devContext(r))
|
||||
return
|
||||
}
|
||||
httpx.WriteError(w, apperr.New(http.StatusUnauthorized, "unauthorized", "missing bearer token"))
|
||||
return
|
||||
}
|
||||
|
||||
token := strings.TrimSpace(strings.TrimPrefix(authHeader, "Bearer "))
|
||||
claims, err := jwtManager.ParseAccessToken(token)
|
||||
if err != nil {
|
||||
if appEnv != "production" {
|
||||
next.ServeHTTP(w, devContext(r))
|
||||
return
|
||||
}
|
||||
httpx.WriteError(w, apperr.New(http.StatusUnauthorized, "invalid_token", "invalid access token"))
|
||||
return
|
||||
}
|
||||
if claims.ActorType != "ops" {
|
||||
if appEnv != "production" {
|
||||
next.ServeHTTP(w, devContext(r))
|
||||
return
|
||||
}
|
||||
httpx.WriteError(w, apperr.New(http.StatusUnauthorized, "invalid_token", "invalid ops access token"))
|
||||
return
|
||||
}
|
||||
|
||||
ctx := context.WithValue(r.Context(), opsAuthKey, &OpsAuthContext{
|
||||
OpsUserID: claims.UserID,
|
||||
OpsUserPublicID: claims.UserPublicID,
|
||||
RoleCode: claims.RoleCode,
|
||||
})
|
||||
next.ServeHTTP(w, r.WithContext(ctx))
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func GetOpsAuthContext(ctx context.Context) *OpsAuthContext {
|
||||
auth, _ := ctx.Value(opsAuthKey).(*OpsAuthContext)
|
||||
return auth
|
||||
}
|
||||
@@ -13,16 +13,21 @@ func NewRouter(
|
||||
appEnv string,
|
||||
jwtManager *jwtx.Manager,
|
||||
authService *service.AuthService,
|
||||
opsAuthService *service.OpsAuthService,
|
||||
opsSummaryService *service.OpsSummaryService,
|
||||
entryService *service.EntryService,
|
||||
entryHomeService *service.EntryHomeService,
|
||||
adminAssetService *service.AdminAssetService,
|
||||
adminResourceService *service.AdminResourceService,
|
||||
adminProductionService *service.AdminProductionService,
|
||||
adminEventService *service.AdminEventService,
|
||||
adminPipelineService *service.AdminPipelineService,
|
||||
eventService *service.EventService,
|
||||
eventPlayService *service.EventPlayService,
|
||||
publicExperienceService *service.PublicExperienceService,
|
||||
configService *service.ConfigService,
|
||||
homeService *service.HomeService,
|
||||
mapExperienceService *service.MapExperienceService,
|
||||
profileService *service.ProfileService,
|
||||
resultService *service.ResultService,
|
||||
sessionService *service.SessionService,
|
||||
@@ -33,27 +38,43 @@ func NewRouter(
|
||||
|
||||
healthHandler := handlers.NewHealthHandler()
|
||||
authHandler := handlers.NewAuthHandler(authService)
|
||||
opsAuthHandler := handlers.NewOpsAuthHandler(opsAuthService)
|
||||
opsSummaryHandler := handlers.NewOpsSummaryHandler(opsSummaryService)
|
||||
regionOptionsHandler := handlers.NewRegionOptionsHandler()
|
||||
entryHandler := handlers.NewEntryHandler(entryService)
|
||||
entryHomeHandler := handlers.NewEntryHomeHandler(entryHomeService)
|
||||
adminAssetHandler := handlers.NewAdminAssetHandler(adminAssetService)
|
||||
adminResourceHandler := handlers.NewAdminResourceHandler(adminResourceService)
|
||||
adminProductionHandler := handlers.NewAdminProductionHandler(adminProductionService)
|
||||
adminEventHandler := handlers.NewAdminEventHandler(adminEventService)
|
||||
adminPipelineHandler := handlers.NewAdminPipelineHandler(adminPipelineService)
|
||||
eventHandler := handlers.NewEventHandler(eventService)
|
||||
eventPlayHandler := handlers.NewEventPlayHandler(eventPlayService)
|
||||
publicExperienceHandler := handlers.NewPublicExperienceHandler(publicExperienceService)
|
||||
configHandler := handlers.NewConfigHandler(configService)
|
||||
homeHandler := handlers.NewHomeHandler(homeService)
|
||||
mapExperienceHandler := handlers.NewMapExperienceHandler(mapExperienceService)
|
||||
profileHandler := handlers.NewProfileHandler(profileService)
|
||||
resultHandler := handlers.NewResultHandler(resultService)
|
||||
sessionHandler := handlers.NewSessionHandler(sessionService)
|
||||
devHandler := handlers.NewDevHandler(devService)
|
||||
opsWorkbenchHandler := handlers.NewOpsWorkbenchHandler()
|
||||
meHandler := handlers.NewMeHandler(meService)
|
||||
authMiddleware := middleware.NewAuthMiddleware(jwtManager)
|
||||
opsAuthMiddleware := middleware.NewOpsAuthMiddleware(jwtManager, appEnv)
|
||||
|
||||
mux.HandleFunc("GET /healthz", healthHandler.Get)
|
||||
mux.HandleFunc("GET /home", homeHandler.GetHome)
|
||||
mux.HandleFunc("GET /cards", homeHandler.GetCards)
|
||||
mux.HandleFunc("GET /experience-maps", mapExperienceHandler.ListMaps)
|
||||
mux.HandleFunc("GET /experience-maps/{mapAssetPublicID}", mapExperienceHandler.GetMapDetail)
|
||||
mux.HandleFunc("GET /public/experience-maps", publicExperienceHandler.ListMaps)
|
||||
mux.HandleFunc("GET /public/experience-maps/{mapAssetPublicID}", publicExperienceHandler.GetMapDetail)
|
||||
mux.HandleFunc("GET /entry/resolve", entryHandler.Resolve)
|
||||
mux.Handle("GET /admin/assets", authMiddleware(http.HandlerFunc(adminAssetHandler.ListAssets)))
|
||||
mux.Handle("POST /admin/assets/register-link", authMiddleware(http.HandlerFunc(adminAssetHandler.RegisterLink)))
|
||||
mux.Handle("POST /admin/assets/upload", authMiddleware(http.HandlerFunc(adminAssetHandler.UploadFile)))
|
||||
mux.Handle("GET /admin/assets/{assetPublicID}", authMiddleware(http.HandlerFunc(adminAssetHandler.GetAsset)))
|
||||
mux.Handle("GET /admin/maps", authMiddleware(http.HandlerFunc(adminResourceHandler.ListMaps)))
|
||||
mux.Handle("POST /admin/maps", authMiddleware(http.HandlerFunc(adminResourceHandler.CreateMap)))
|
||||
mux.Handle("GET /admin/maps/{mapPublicID}", authMiddleware(http.HandlerFunc(adminResourceHandler.GetMap)))
|
||||
@@ -61,8 +82,10 @@ func NewRouter(
|
||||
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("GET /admin/map-assets", authMiddleware(http.HandlerFunc(adminProductionHandler.ListMapAssets)))
|
||||
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("PUT /admin/map-assets/{mapAssetPublicID}", authMiddleware(http.HandlerFunc(adminProductionHandler.UpdateMapAsset)))
|
||||
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)))
|
||||
@@ -73,6 +96,8 @@ func NewRouter(
|
||||
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("POST /admin/ops/tile-releases/import", authMiddleware(http.HandlerFunc(adminProductionHandler.ImportTileRelease)))
|
||||
mux.Handle("POST /admin/ops/course-sets/import-kml-batch", authMiddleware(http.HandlerFunc(adminProductionHandler.ImportCourseSetKMLBatch)))
|
||||
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)))
|
||||
@@ -104,11 +129,13 @@ func NewRouter(
|
||||
mux.Handle("POST /admin/events/{eventPublicID}/rollback", authMiddleware(http.HandlerFunc(adminPipelineHandler.RollbackRelease)))
|
||||
if appEnv != "production" {
|
||||
mux.HandleFunc("GET /dev/workbench", devHandler.Workbench)
|
||||
mux.HandleFunc("GET /admin/ops-workbench", opsWorkbenchHandler.Get)
|
||||
mux.HandleFunc("POST /dev/bootstrap-demo", devHandler.BootstrapDemo)
|
||||
mux.HandleFunc("POST /dev/client-logs", devHandler.CreateClientLog)
|
||||
mux.HandleFunc("GET /dev/client-logs", devHandler.ListClientLogs)
|
||||
mux.HandleFunc("DELETE /dev/client-logs", devHandler.ClearClientLogs)
|
||||
mux.HandleFunc("GET /dev/manifest-summary", devHandler.ManifestSummary)
|
||||
mux.HandleFunc("GET /dev/demo-assets/manifests/{demoKey}", devHandler.DemoGameManifest)
|
||||
mux.HandleFunc("GET /dev/demo-assets/presentations/{demoKey}", devHandler.DemoPresentationSchema)
|
||||
mux.HandleFunc("GET /dev/demo-assets/content-manifests/{demoKey}", devHandler.DemoContentManifest)
|
||||
mux.HandleFunc("GET /dev/config/local-files", configHandler.ListLocalFiles)
|
||||
@@ -119,6 +146,9 @@ func NewRouter(
|
||||
mux.Handle("GET /me/entry-home", authMiddleware(http.HandlerFunc(entryHomeHandler.Get)))
|
||||
mux.Handle("GET /me/profile", authMiddleware(http.HandlerFunc(profileHandler.Get)))
|
||||
mux.HandleFunc("GET /events/{eventPublicID}", eventHandler.GetDetail)
|
||||
mux.HandleFunc("GET /public/events/{eventPublicID}", publicExperienceHandler.GetEventDetail)
|
||||
mux.HandleFunc("GET /public/events/{eventPublicID}/play", publicExperienceHandler.GetEventPlay)
|
||||
mux.HandleFunc("POST /public/events/{eventPublicID}/launch", publicExperienceHandler.Launch)
|
||||
mux.Handle("GET /events/{eventPublicID}/play", authMiddleware(http.HandlerFunc(eventPlayHandler.Get)))
|
||||
mux.Handle("GET /events/{eventPublicID}/config-sources", authMiddleware(http.HandlerFunc(configHandler.ListSources)))
|
||||
mux.Handle("POST /events/{eventPublicID}/launch", authMiddleware(http.HandlerFunc(eventHandler.Launch)))
|
||||
@@ -131,12 +161,50 @@ func NewRouter(
|
||||
mux.HandleFunc("POST /auth/sms/send", authHandler.SendSMSCode)
|
||||
mux.HandleFunc("POST /auth/login/sms", authHandler.LoginSMS)
|
||||
mux.HandleFunc("POST /auth/login/wechat-mini", authHandler.LoginWechatMini)
|
||||
mux.HandleFunc("POST /ops/auth/sms/send", opsAuthHandler.SendSMSCode)
|
||||
mux.HandleFunc("POST /ops/auth/register", opsAuthHandler.Register)
|
||||
mux.HandleFunc("POST /ops/auth/login/sms", opsAuthHandler.LoginSMS)
|
||||
mux.HandleFunc("POST /ops/auth/refresh", opsAuthHandler.Refresh)
|
||||
mux.HandleFunc("POST /ops/auth/logout", opsAuthHandler.Logout)
|
||||
mux.Handle("GET /ops/me", opsAuthMiddleware(http.HandlerFunc(opsAuthHandler.Me)))
|
||||
mux.Handle("POST /auth/bind/mobile", authMiddleware(http.HandlerFunc(authHandler.BindMobile)))
|
||||
mux.HandleFunc("POST /auth/refresh", authHandler.Refresh)
|
||||
mux.HandleFunc("POST /auth/logout", authHandler.Logout)
|
||||
mux.Handle("GET /me", authMiddleware(http.HandlerFunc(meHandler.Get)))
|
||||
mux.Handle("GET /me/sessions", authMiddleware(http.HandlerFunc(sessionHandler.ListMine)))
|
||||
mux.Handle("GET /me/results", authMiddleware(http.HandlerFunc(resultHandler.ListMine)))
|
||||
mux.Handle("GET /ops/admin/summary", opsAuthMiddleware(http.HandlerFunc(opsSummaryHandler.GetOverview)))
|
||||
mux.Handle("GET /ops/admin/region-options", opsAuthMiddleware(http.HandlerFunc(regionOptionsHandler.Get)))
|
||||
mux.Handle("GET /ops/admin/assets", opsAuthMiddleware(http.HandlerFunc(adminAssetHandler.ListAssets)))
|
||||
mux.Handle("POST /ops/admin/assets/register-link", opsAuthMiddleware(http.HandlerFunc(adminAssetHandler.RegisterLink)))
|
||||
mux.Handle("POST /ops/admin/assets/upload", opsAuthMiddleware(http.HandlerFunc(adminAssetHandler.UploadFile)))
|
||||
mux.Handle("GET /ops/admin/assets/{assetPublicID}", opsAuthMiddleware(http.HandlerFunc(adminAssetHandler.GetAsset)))
|
||||
mux.Handle("GET /ops/admin/places", opsAuthMiddleware(http.HandlerFunc(adminProductionHandler.ListPlaces)))
|
||||
mux.Handle("POST /ops/admin/places", opsAuthMiddleware(http.HandlerFunc(adminProductionHandler.CreatePlace)))
|
||||
mux.Handle("GET /ops/admin/places/{placePublicID}", opsAuthMiddleware(http.HandlerFunc(adminProductionHandler.GetPlace)))
|
||||
mux.Handle("GET /ops/admin/map-assets", opsAuthMiddleware(http.HandlerFunc(adminProductionHandler.ListMapAssets)))
|
||||
mux.Handle("POST /ops/admin/places/{placePublicID}/map-assets", opsAuthMiddleware(http.HandlerFunc(adminProductionHandler.CreateMapAsset)))
|
||||
mux.Handle("GET /ops/admin/map-assets/{mapAssetPublicID}", opsAuthMiddleware(http.HandlerFunc(adminProductionHandler.GetMapAsset)))
|
||||
mux.Handle("PUT /ops/admin/map-assets/{mapAssetPublicID}", opsAuthMiddleware(http.HandlerFunc(adminProductionHandler.UpdateMapAsset)))
|
||||
mux.Handle("POST /ops/admin/map-assets/{mapAssetPublicID}/tile-releases", opsAuthMiddleware(http.HandlerFunc(adminProductionHandler.CreateTileRelease)))
|
||||
mux.Handle("POST /ops/admin/ops/tile-releases/import", opsAuthMiddleware(http.HandlerFunc(adminProductionHandler.ImportTileRelease)))
|
||||
mux.Handle("GET /ops/admin/course-sources", opsAuthMiddleware(http.HandlerFunc(adminProductionHandler.ListCourseSources)))
|
||||
mux.Handle("GET /ops/admin/course-sources/{sourcePublicID}", opsAuthMiddleware(http.HandlerFunc(adminProductionHandler.GetCourseSource)))
|
||||
mux.Handle("GET /ops/admin/course-sets/{courseSetPublicID}", opsAuthMiddleware(http.HandlerFunc(adminProductionHandler.GetCourseSet)))
|
||||
mux.Handle("POST /ops/admin/ops/course-sets/import-kml-batch", opsAuthMiddleware(http.HandlerFunc(adminProductionHandler.ImportCourseSetKMLBatch)))
|
||||
mux.Handle("GET /ops/admin/events", opsAuthMiddleware(http.HandlerFunc(adminEventHandler.ListEvents)))
|
||||
mux.Handle("POST /ops/admin/events", opsAuthMiddleware(http.HandlerFunc(adminEventHandler.CreateEvent)))
|
||||
mux.Handle("GET /ops/admin/events/{eventPublicID}", opsAuthMiddleware(http.HandlerFunc(adminEventHandler.GetEvent)))
|
||||
mux.Handle("PUT /ops/admin/events/{eventPublicID}", opsAuthMiddleware(http.HandlerFunc(adminEventHandler.UpdateEvent)))
|
||||
mux.Handle("POST /ops/admin/events/{eventPublicID}/presentations/import", opsAuthMiddleware(http.HandlerFunc(adminEventHandler.ImportPresentation)))
|
||||
mux.Handle("POST /ops/admin/events/{eventPublicID}/content-bundles/import", opsAuthMiddleware(http.HandlerFunc(adminEventHandler.ImportContentBundle)))
|
||||
mux.Handle("POST /ops/admin/events/{eventPublicID}/defaults", opsAuthMiddleware(http.HandlerFunc(adminEventHandler.UpdateEventDefaults)))
|
||||
mux.Handle("GET /ops/admin/events/{eventPublicID}/pipeline", opsAuthMiddleware(http.HandlerFunc(adminPipelineHandler.GetEventPipeline)))
|
||||
mux.Handle("POST /ops/admin/sources/{sourceID}/build", opsAuthMiddleware(http.HandlerFunc(adminPipelineHandler.BuildSource)))
|
||||
mux.Handle("GET /ops/admin/builds/{buildID}", opsAuthMiddleware(http.HandlerFunc(adminPipelineHandler.GetBuild)))
|
||||
mux.Handle("POST /ops/admin/builds/{buildID}/publish", opsAuthMiddleware(http.HandlerFunc(adminPipelineHandler.PublishBuild)))
|
||||
mux.Handle("GET /ops/admin/releases/{releasePublicID}", opsAuthMiddleware(http.HandlerFunc(adminPipelineHandler.GetRelease)))
|
||||
mux.Handle("POST /ops/admin/events/{eventPublicID}/rollback", opsAuthMiddleware(http.HandlerFunc(adminPipelineHandler.RollbackRelease)))
|
||||
|
||||
return mux
|
||||
}
|
||||
|
||||
@@ -78,6 +78,38 @@ func (p *OSSUtilPublisher) UploadJSON(ctx context.Context, publicURL string, pay
|
||||
return nil
|
||||
}
|
||||
|
||||
func (p *OSSUtilPublisher) UploadFile(ctx context.Context, publicURL string, localPath string) error {
|
||||
if !p.Enabled() {
|
||||
return fmt.Errorf("asset publisher is not configured")
|
||||
}
|
||||
if strings.TrimSpace(localPath) == "" {
|
||||
return fmt.Errorf("local path is required")
|
||||
}
|
||||
|
||||
objectKey, err := p.objectKeyFromPublicURL(publicURL)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if _, err := os.Stat(p.ossutilPath); err != nil {
|
||||
return fmt.Errorf("ossutil not found: %w", err)
|
||||
}
|
||||
if _, err := os.Stat(p.configFile); err != nil {
|
||||
return fmt.Errorf("ossutil config not found: %w", err)
|
||||
}
|
||||
if _, err := os.Stat(localPath); err != nil {
|
||||
return fmt.Errorf("upload file not found: %w", err)
|
||||
}
|
||||
|
||||
target := p.bucketRoot + "/" + objectKey
|
||||
cmd := exec.CommandContext(ctx, p.ossutilPath, "cp", "-f", localPath, target, "--config-file", p.configFile)
|
||||
output, err := cmd.CombinedOutput()
|
||||
if err != nil {
|
||||
return fmt.Errorf("upload object %s failed: %w: %s", objectKey, err, strings.TrimSpace(string(output)))
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (p *OSSUtilPublisher) objectKeyFromPublicURL(publicURL string) (string, error) {
|
||||
publicURL = strings.TrimSpace(publicURL)
|
||||
if publicURL == "" {
|
||||
|
||||
@@ -16,6 +16,8 @@ type Manager struct {
|
||||
type AccessClaims struct {
|
||||
UserID string `json:"uid"`
|
||||
UserPublicID string `json:"upub"`
|
||||
ActorType string `json:"actorType,omitempty"`
|
||||
RoleCode string `json:"roleCode,omitempty"`
|
||||
jwt.RegisteredClaims
|
||||
}
|
||||
|
||||
@@ -28,10 +30,16 @@ func NewManager(issuer, secret string, ttl time.Duration) *Manager {
|
||||
}
|
||||
|
||||
func (m *Manager) IssueAccessToken(userID, userPublicID string) (string, time.Time, error) {
|
||||
return m.IssueActorAccessToken(userID, userPublicID, "user", "")
|
||||
}
|
||||
|
||||
func (m *Manager) IssueActorAccessToken(userID, userPublicID, actorType, roleCode string) (string, time.Time, error) {
|
||||
expiresAt := time.Now().UTC().Add(m.ttl)
|
||||
claims := AccessClaims{
|
||||
UserID: userID,
|
||||
UserPublicID: userPublicID,
|
||||
ActorType: actorType,
|
||||
RoleCode: roleCode,
|
||||
RegisteredClaims: jwt.RegisteredClaims{
|
||||
Issuer: m.issuer,
|
||||
Subject: userID,
|
||||
|
||||
303
backend/internal/service/admin_asset_service.go
Normal file
303
backend/internal/service/admin_asset_service.go
Normal file
@@ -0,0 +1,303 @@
|
||||
package service
|
||||
|
||||
import (
|
||||
"context"
|
||||
"mime"
|
||||
"net/http"
|
||||
"path"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
|
||||
"cmr-backend/internal/apperr"
|
||||
"cmr-backend/internal/platform/assets"
|
||||
"cmr-backend/internal/platform/security"
|
||||
"cmr-backend/internal/store/postgres"
|
||||
)
|
||||
|
||||
type AdminAssetService struct {
|
||||
store *postgres.Store
|
||||
assetBaseURL string
|
||||
assetPublisher *assets.OSSUtilPublisher
|
||||
}
|
||||
|
||||
type ManagedAssetSummary struct {
|
||||
ID string `json:"id"`
|
||||
AssetType string `json:"assetType"`
|
||||
AssetCode string `json:"assetCode"`
|
||||
Version string `json:"version"`
|
||||
Title *string `json:"title,omitempty"`
|
||||
SourceMode string `json:"sourceMode"`
|
||||
StorageProvider string `json:"storageProvider"`
|
||||
ObjectKey *string `json:"objectKey,omitempty"`
|
||||
PublicURL string `json:"publicUrl"`
|
||||
FileName *string `json:"fileName,omitempty"`
|
||||
ContentType *string `json:"contentType,omitempty"`
|
||||
FileSizeBytes *int64 `json:"fileSizeBytes,omitempty"`
|
||||
ChecksumSHA256 *string `json:"checksumSha256,omitempty"`
|
||||
Status string `json:"status"`
|
||||
Metadata map[string]any `json:"metadata,omitempty"`
|
||||
}
|
||||
|
||||
type RegisterLinkAssetInput struct {
|
||||
AssetType string `json:"assetType"`
|
||||
AssetCode string `json:"assetCode"`
|
||||
Version string `json:"version"`
|
||||
Title *string `json:"title,omitempty"`
|
||||
PublicURL string `json:"publicUrl"`
|
||||
FileName *string `json:"fileName,omitempty"`
|
||||
ContentType *string `json:"contentType,omitempty"`
|
||||
Status string `json:"status"`
|
||||
Metadata map[string]any `json:"metadata,omitempty"`
|
||||
}
|
||||
|
||||
type UploadAssetFileInput struct {
|
||||
AssetType string
|
||||
AssetCode string
|
||||
Version string
|
||||
Title *string
|
||||
ObjectDir *string
|
||||
FileName string
|
||||
ContentType string
|
||||
FileSize int64
|
||||
Checksum string
|
||||
TempPath string
|
||||
Status string
|
||||
Metadata map[string]any
|
||||
}
|
||||
|
||||
func NewAdminAssetService(store *postgres.Store, assetBaseURL string, assetPublisher *assets.OSSUtilPublisher) *AdminAssetService {
|
||||
return &AdminAssetService{
|
||||
store: store,
|
||||
assetBaseURL: strings.TrimRight(strings.TrimSpace(assetBaseURL), "/"),
|
||||
assetPublisher: assetPublisher,
|
||||
}
|
||||
}
|
||||
|
||||
func (s *AdminAssetService) ListManagedAssets(ctx context.Context, limit int) ([]ManagedAssetSummary, error) {
|
||||
items, err := s.store.ListManagedAssets(ctx, limit)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
result := make([]ManagedAssetSummary, 0, len(items))
|
||||
for _, item := range items {
|
||||
result = append(result, buildManagedAssetSummary(item))
|
||||
}
|
||||
return result, nil
|
||||
}
|
||||
|
||||
func (s *AdminAssetService) GetManagedAsset(ctx context.Context, assetPublicID string) (*ManagedAssetSummary, error) {
|
||||
record, err := s.store.GetManagedAssetByPublicID(ctx, strings.TrimSpace(assetPublicID))
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if record == nil {
|
||||
return nil, apperr.New(http.StatusNotFound, "asset_not_found", "asset not found")
|
||||
}
|
||||
summary := buildManagedAssetSummary(*record)
|
||||
return &summary, nil
|
||||
}
|
||||
|
||||
func (s *AdminAssetService) RegisterExternalLink(ctx context.Context, input RegisterLinkAssetInput) (*ManagedAssetSummary, error) {
|
||||
if err := validateManagedAssetInput(input.AssetType, input.AssetCode, input.Version); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
publicURL := strings.TrimSpace(input.PublicURL)
|
||||
if publicURL == "" {
|
||||
return nil, apperr.New(http.StatusBadRequest, "invalid_params", "publicUrl is required")
|
||||
}
|
||||
|
||||
publicID, err := security.GeneratePublicID("asset")
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
tx, err := s.store.Begin(ctx)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer tx.Rollback(ctx)
|
||||
|
||||
record, err := s.store.CreateManagedAsset(ctx, tx, postgres.CreateManagedAssetParams{
|
||||
PublicID: publicID,
|
||||
AssetType: normalizeCode(input.AssetType),
|
||||
AssetCode: normalizeCode(input.AssetCode),
|
||||
Version: strings.TrimSpace(input.Version),
|
||||
Title: assetTrimStringPtr(input.Title),
|
||||
SourceMode: "external_link",
|
||||
StorageProvider: "external",
|
||||
ObjectKey: nil,
|
||||
PublicURL: publicURL,
|
||||
FileName: assetTrimStringPtr(input.FileName),
|
||||
ContentType: assetTrimStringPtr(input.ContentType),
|
||||
FileSizeBytes: nil,
|
||||
ChecksumSHA256: nil,
|
||||
Status: normalizeManagedAssetStatus(input.Status),
|
||||
MetadataJSONB: normalizeJSONMap(input.Metadata),
|
||||
})
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if err := tx.Commit(ctx); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
summary := buildManagedAssetSummary(*record)
|
||||
return &summary, nil
|
||||
}
|
||||
|
||||
func (s *AdminAssetService) UploadAssetFile(ctx context.Context, input UploadAssetFileInput) (*ManagedAssetSummary, error) {
|
||||
if err := validateManagedAssetInput(input.AssetType, input.AssetCode, input.Version); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if !s.assetPublisher.Enabled() {
|
||||
return nil, apperr.New(http.StatusFailedDependency, "asset_publisher_not_configured", "asset publisher is not configured")
|
||||
}
|
||||
if strings.TrimSpace(input.TempPath) == "" || strings.TrimSpace(input.FileName) == "" {
|
||||
return nil, apperr.New(http.StatusBadRequest, "invalid_params", "upload file is required")
|
||||
}
|
||||
|
||||
objectDir := s.defaultObjectDir(input.AssetType, input.AssetCode, input.Version, input.ObjectDir)
|
||||
publicURL := s.assetBaseURL + "/" + strings.TrimLeft(path.Join(objectDir, sanitizeFileName(input.FileName)), "/")
|
||||
|
||||
if err := s.assetPublisher.UploadFile(ctx, publicURL, input.TempPath); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
publicID, err := security.GeneratePublicID("asset")
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
objectKey := strings.TrimPrefix(strings.TrimPrefix(publicURL, s.assetBaseURL), "/")
|
||||
fileName := sanitizeFileName(input.FileName)
|
||||
contentType := detectContentType(fileName, input.ContentType)
|
||||
checksum := strings.TrimSpace(input.Checksum)
|
||||
|
||||
tx, err := s.store.Begin(ctx)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer tx.Rollback(ctx)
|
||||
|
||||
record, err := s.store.CreateManagedAsset(ctx, tx, postgres.CreateManagedAssetParams{
|
||||
PublicID: publicID,
|
||||
AssetType: normalizeCode(input.AssetType),
|
||||
AssetCode: normalizeCode(input.AssetCode),
|
||||
Version: strings.TrimSpace(input.Version),
|
||||
Title: assetTrimStringPtr(input.Title),
|
||||
SourceMode: "uploaded",
|
||||
StorageProvider: "oss",
|
||||
ObjectKey: stringPtr(objectKey),
|
||||
PublicURL: publicURL,
|
||||
FileName: stringPtr(fileName),
|
||||
ContentType: stringPtr(contentType),
|
||||
FileSizeBytes: &input.FileSize,
|
||||
ChecksumSHA256: stringPtr(checksum),
|
||||
Status: normalizeManagedAssetStatus(input.Status),
|
||||
MetadataJSONB: normalizeJSONMap(input.Metadata),
|
||||
})
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if err := tx.Commit(ctx); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
summary := buildManagedAssetSummary(*record)
|
||||
return &summary, nil
|
||||
}
|
||||
|
||||
func (s *AdminAssetService) defaultObjectDir(assetType, assetCode, version string, preferred *string) string {
|
||||
if preferred != nil && strings.TrimSpace(*preferred) != "" {
|
||||
return strings.Trim(strings.ReplaceAll(strings.TrimSpace(*preferred), "\\", "/"), "/")
|
||||
}
|
||||
return path.Join("uploads", normalizeCode(assetType), normalizeCode(assetCode), strings.TrimSpace(version))
|
||||
}
|
||||
|
||||
func buildManagedAssetSummary(record postgres.ManagedAssetRecord) ManagedAssetSummary {
|
||||
return ManagedAssetSummary{
|
||||
ID: record.PublicID,
|
||||
AssetType: record.AssetType,
|
||||
AssetCode: record.AssetCode,
|
||||
Version: record.Version,
|
||||
Title: record.Title,
|
||||
SourceMode: record.SourceMode,
|
||||
StorageProvider: record.StorageProvider,
|
||||
ObjectKey: record.ObjectKey,
|
||||
PublicURL: record.PublicURL,
|
||||
FileName: record.FileName,
|
||||
ContentType: record.ContentType,
|
||||
FileSizeBytes: record.FileSizeBytes,
|
||||
ChecksumSHA256: record.ChecksumSHA256,
|
||||
Status: record.Status,
|
||||
Metadata: normalizeJSONMap(record.MetadataJSONB),
|
||||
}
|
||||
}
|
||||
|
||||
func validateManagedAssetInput(assetType, assetCode, version string) error {
|
||||
if normalizeCode(assetType) == "" || normalizeCode(assetCode) == "" || strings.TrimSpace(version) == "" {
|
||||
return apperr.New(http.StatusBadRequest, "invalid_params", "assetType, assetCode and version are required")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func normalizeManagedAssetStatus(value string) string {
|
||||
switch strings.ToLower(strings.TrimSpace(value)) {
|
||||
case "", "active":
|
||||
return "active"
|
||||
case "draft", "disabled", "archived":
|
||||
return strings.ToLower(strings.TrimSpace(value))
|
||||
default:
|
||||
return "active"
|
||||
}
|
||||
}
|
||||
|
||||
func normalizeCode(value string) string {
|
||||
value = strings.TrimSpace(strings.ToLower(value))
|
||||
value = strings.ReplaceAll(value, " ", "-")
|
||||
return value
|
||||
}
|
||||
|
||||
func sanitizeFileName(name string) string {
|
||||
name = filepath.Base(strings.TrimSpace(name))
|
||||
name = strings.ReplaceAll(name, " ", "-")
|
||||
return name
|
||||
}
|
||||
|
||||
func detectContentType(fileName, provided string) string {
|
||||
if strings.TrimSpace(provided) != "" {
|
||||
return strings.TrimSpace(provided)
|
||||
}
|
||||
if ext := filepath.Ext(fileName); ext != "" {
|
||||
if guessed := mime.TypeByExtension(ext); guessed != "" {
|
||||
return guessed
|
||||
}
|
||||
}
|
||||
return "application/octet-stream"
|
||||
}
|
||||
|
||||
func stringPtr(value string) *string {
|
||||
value = strings.TrimSpace(value)
|
||||
if value == "" {
|
||||
return nil
|
||||
}
|
||||
return &value
|
||||
}
|
||||
|
||||
func assetTrimStringPtr(value *string) *string {
|
||||
if value == nil {
|
||||
return nil
|
||||
}
|
||||
trimmed := strings.TrimSpace(*value)
|
||||
if trimmed == "" {
|
||||
return nil
|
||||
}
|
||||
return &trimmed
|
||||
}
|
||||
|
||||
func normalizeJSONMap(value map[string]any) map[string]any {
|
||||
if value == nil {
|
||||
return map[string]any{}
|
||||
}
|
||||
return value
|
||||
}
|
||||
@@ -44,6 +44,7 @@ type CreateAdminPlaceInput struct {
|
||||
type AdminMapAssetSummary struct {
|
||||
ID string `json:"id"`
|
||||
PlaceID string `json:"placeId"`
|
||||
PlaceName *string `json:"placeName,omitempty"`
|
||||
LegacyMapID *string `json:"legacyMapId,omitempty"`
|
||||
Code string `json:"code"`
|
||||
Name string `json:"name"`
|
||||
@@ -61,9 +62,10 @@ type AdminTileReleaseBrief struct {
|
||||
}
|
||||
|
||||
type AdminMapAssetDetail struct {
|
||||
MapAsset AdminMapAssetSummary `json:"mapAsset"`
|
||||
TileReleases []AdminTileReleaseView `json:"tileReleases"`
|
||||
CourseSets []AdminCourseSetBrief `json:"courseSets"`
|
||||
MapAsset AdminMapAssetSummary `json:"mapAsset"`
|
||||
TileReleases []AdminTileReleaseView `json:"tileReleases"`
|
||||
CourseSets []AdminCourseSetBrief `json:"courseSets"`
|
||||
LinkedEvents []AdminMapLinkedEventBrief `json:"linkedEvents"`
|
||||
}
|
||||
|
||||
type CreateAdminMapAssetInput struct {
|
||||
@@ -76,6 +78,31 @@ type CreateAdminMapAssetInput struct {
|
||||
Status string `json:"status"`
|
||||
}
|
||||
|
||||
type UpdateAdminMapAssetInput struct {
|
||||
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"`
|
||||
}
|
||||
|
||||
type AdminMapLinkedEventBrief struct {
|
||||
EventID string `json:"eventId"`
|
||||
Title string `json:"title"`
|
||||
Summary *string `json:"summary,omitempty"`
|
||||
Status string `json:"status"`
|
||||
IsDefaultExperience bool `json:"isDefaultExperience"`
|
||||
ShowInEventList bool `json:"showInEventList"`
|
||||
CurrentReleaseID *string `json:"currentReleaseId,omitempty"`
|
||||
ConfigLabel *string `json:"configLabel,omitempty"`
|
||||
RouteCode *string `json:"routeCode,omitempty"`
|
||||
CurrentPresentationID *string `json:"currentPresentationId,omitempty"`
|
||||
CurrentPresentation *string `json:"currentPresentation,omitempty"`
|
||||
CurrentContentBundleID *string `json:"currentContentBundleId,omitempty"`
|
||||
CurrentContentBundle *string `json:"currentContentBundle,omitempty"`
|
||||
}
|
||||
|
||||
type AdminTileReleaseView struct {
|
||||
ID string `json:"id"`
|
||||
LegacyVersionID *string `json:"legacyVersionId,omitempty"`
|
||||
@@ -202,6 +229,66 @@ type CreateAdminRuntimeBindingInput struct {
|
||||
Notes *string `json:"notes,omitempty"`
|
||||
}
|
||||
|
||||
type ImportAdminTileReleaseInput struct {
|
||||
PlaceCode string `json:"placeCode"`
|
||||
PlaceName string `json:"placeName"`
|
||||
PlaceRegion *string `json:"placeRegion,omitempty"`
|
||||
PlaceCoverURL *string `json:"placeCoverUrl,omitempty"`
|
||||
PlaceDescription *string `json:"placeDescription,omitempty"`
|
||||
PlaceCenterPoint map[string]any `json:"placeCenterPoint,omitempty"`
|
||||
MapAssetCode string `json:"mapAssetCode"`
|
||||
MapAssetName string `json:"mapAssetName"`
|
||||
MapType string `json:"mapType"`
|
||||
MapCoverURL *string `json:"mapCoverUrl,omitempty"`
|
||||
MapDescription *string `json:"mapDescription,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 ImportAdminTileReleaseResult struct {
|
||||
Place AdminPlaceSummary `json:"place"`
|
||||
MapAsset AdminMapAssetSummary `json:"mapAsset"`
|
||||
TileRelease AdminTileReleaseView `json:"tileRelease"`
|
||||
}
|
||||
|
||||
type ImportAdminCourseRouteInput struct {
|
||||
Name string `json:"name"`
|
||||
RouteCode string `json:"routeCode"`
|
||||
FileURL string `json:"fileUrl"`
|
||||
SourceType string `json:"sourceType"`
|
||||
ControlCount *int `json:"controlCount,omitempty"`
|
||||
Difficulty *string `json:"difficulty,omitempty"`
|
||||
Status string `json:"status"`
|
||||
Metadata map[string]any `json:"metadata,omitempty"`
|
||||
}
|
||||
|
||||
type ImportAdminCourseSetBatchInput struct {
|
||||
PlaceCode string `json:"placeCode"`
|
||||
PlaceName string `json:"placeName"`
|
||||
MapAssetCode string `json:"mapAssetCode"`
|
||||
MapAssetName string `json:"mapAssetName"`
|
||||
MapType string `json:"mapType"`
|
||||
CourseSetCode string `json:"courseSetCode"`
|
||||
CourseSetName string `json:"courseSetName"`
|
||||
Mode string `json:"mode"`
|
||||
Description *string `json:"description,omitempty"`
|
||||
Status string `json:"status"`
|
||||
DefaultRouteCode *string `json:"defaultRouteCode,omitempty"`
|
||||
Routes []ImportAdminCourseRouteInput `json:"routes"`
|
||||
}
|
||||
|
||||
type ImportAdminCourseSetBatchResult struct {
|
||||
Place AdminPlaceSummary `json:"place"`
|
||||
MapAsset AdminMapAssetSummary `json:"mapAsset"`
|
||||
CourseSet AdminCourseSetBrief `json:"courseSet"`
|
||||
Variants []AdminCourseVariantView `json:"variants"`
|
||||
}
|
||||
|
||||
func NewAdminProductionService(store *postgres.Store) *AdminProductionService {
|
||||
return &AdminProductionService{store: store}
|
||||
}
|
||||
@@ -218,6 +305,22 @@ func (s *AdminProductionService) ListPlaces(ctx context.Context, limit int) ([]A
|
||||
return result, nil
|
||||
}
|
||||
|
||||
func (s *AdminProductionService) ListMapAssets(ctx context.Context, limit int) ([]AdminMapAssetSummary, error) {
|
||||
items, err := s.store.ListMapAssets(ctx, limit)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
result := make([]AdminMapAssetSummary, 0, len(items))
|
||||
for _, item := range items {
|
||||
summary, err := s.buildAdminMapAssetSummary(ctx, item)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
result = append(result, summary)
|
||||
}
|
||||
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)
|
||||
@@ -362,10 +465,15 @@ func (s *AdminProductionService) GetMapAssetDetail(ctx context.Context, mapAsset
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
linkedEvents, err := s.store.ListMapAssetLinkedEvents(ctx, item.ID, 100)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
result := &AdminMapAssetDetail{
|
||||
MapAsset: summary,
|
||||
TileReleases: make([]AdminTileReleaseView, 0, len(tileReleases)),
|
||||
CourseSets: make([]AdminCourseSetBrief, 0, len(courseSets)),
|
||||
LinkedEvents: make([]AdminMapLinkedEventBrief, 0, len(linkedEvents)),
|
||||
}
|
||||
for _, release := range tileReleases {
|
||||
result.TileReleases = append(result.TileReleases, buildAdminTileReleaseView(release))
|
||||
@@ -377,9 +485,64 @@ func (s *AdminProductionService) GetMapAssetDetail(ctx context.Context, mapAsset
|
||||
}
|
||||
result.CourseSets = append(result.CourseSets, brief)
|
||||
}
|
||||
for _, linked := range linkedEvents {
|
||||
result.LinkedEvents = append(result.LinkedEvents, buildAdminMapLinkedEventBrief(linked))
|
||||
}
|
||||
return result, nil
|
||||
}
|
||||
|
||||
func (s *AdminProductionService) UpdateMapAsset(ctx context.Context, mapAssetPublicID string, input UpdateAdminMapAssetInput) (*AdminMapAssetSummary, 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")
|
||||
}
|
||||
input.Code = strings.TrimSpace(input.Code)
|
||||
input.Name = strings.TrimSpace(input.Name)
|
||||
input.MapType = strings.TrimSpace(input.MapType)
|
||||
if input.Code == "" || input.Name == "" {
|
||||
return nil, apperr.New(http.StatusBadRequest, "invalid_params", "code and name are required")
|
||||
}
|
||||
if input.MapType == "" {
|
||||
input.MapType = "standard"
|
||||
}
|
||||
|
||||
tx, err := s.store.Begin(ctx)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer tx.Rollback(ctx)
|
||||
updated, err := s.store.UpdateMapAsset(ctx, tx, postgres.UpdateMapAssetParams{
|
||||
MapAssetID: item.ID,
|
||||
Code: input.Code,
|
||||
Name: input.Name,
|
||||
MapType: input.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
|
||||
}
|
||||
refreshed, err := s.store.GetMapAssetByPublicID(ctx, updated.PublicID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if refreshed == nil {
|
||||
return nil, apperr.New(http.StatusNotFound, "map_asset_not_found", "map asset not found")
|
||||
}
|
||||
result, err := s.buildAdminMapAssetSummary(ctx, *refreshed)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
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 {
|
||||
@@ -748,6 +911,293 @@ func (s *AdminProductionService) CreateRuntimeBinding(ctx context.Context, input
|
||||
return &result, nil
|
||||
}
|
||||
|
||||
func (s *AdminProductionService) ImportTileRelease(ctx context.Context, input ImportAdminTileReleaseInput) (*ImportAdminTileReleaseResult, error) {
|
||||
input.PlaceCode = strings.TrimSpace(input.PlaceCode)
|
||||
input.PlaceName = strings.TrimSpace(input.PlaceName)
|
||||
input.MapAssetCode = strings.TrimSpace(input.MapAssetCode)
|
||||
input.MapAssetName = strings.TrimSpace(input.MapAssetName)
|
||||
input.VersionCode = strings.TrimSpace(input.VersionCode)
|
||||
input.TileBaseURL = strings.TrimSpace(input.TileBaseURL)
|
||||
input.MetaURL = strings.TrimSpace(input.MetaURL)
|
||||
if input.PlaceCode == "" || input.PlaceName == "" || input.MapAssetCode == "" || input.MapAssetName == "" || input.VersionCode == "" || input.TileBaseURL == "" || input.MetaURL == "" {
|
||||
return nil, apperr.New(http.StatusBadRequest, "invalid_params", "placeCode, placeName, mapAssetCode, mapAssetName, versionCode, tileBaseUrl and metaUrl are required")
|
||||
}
|
||||
|
||||
place, err := s.store.GetPlaceByCode(ctx, input.PlaceCode)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if place == nil {
|
||||
created, err := s.CreatePlace(ctx, CreateAdminPlaceInput{
|
||||
Code: input.PlaceCode,
|
||||
Name: input.PlaceName,
|
||||
Region: trimStringPtr(input.PlaceRegion),
|
||||
CoverURL: trimStringPtr(input.PlaceCoverURL),
|
||||
Description: trimStringPtr(input.PlaceDescription),
|
||||
CenterPoint: input.PlaceCenterPoint,
|
||||
Status: normalizeCatalogStatus(input.Status),
|
||||
})
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
place, err = s.store.GetPlaceByPublicID(ctx, created.ID)
|
||||
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.GetMapAssetByCode(ctx, input.MapAssetCode)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if mapAsset == nil {
|
||||
created, err := s.CreateMapAsset(ctx, place.PublicID, CreateAdminMapAssetInput{
|
||||
Code: input.MapAssetCode,
|
||||
Name: input.MapAssetName,
|
||||
MapType: strings.TrimSpace(input.MapType),
|
||||
CoverURL: trimStringPtr(input.MapCoverURL),
|
||||
Description: trimStringPtr(input.MapDescription),
|
||||
Status: normalizeCatalogStatus(input.Status),
|
||||
})
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
mapAsset, err = s.store.GetMapAssetByPublicID(ctx, created.ID)
|
||||
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")
|
||||
}
|
||||
|
||||
release, err := s.store.GetTileReleaseByMapAssetIDAndVersionCode(ctx, mapAsset.ID, input.VersionCode)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if release == nil {
|
||||
created, err := s.CreateTileRelease(ctx, mapAsset.PublicID, CreateAdminTileReleaseInput{
|
||||
VersionCode: input.VersionCode,
|
||||
Status: normalizeReleaseStatus(input.Status),
|
||||
TileBaseURL: input.TileBaseURL,
|
||||
MetaURL: input.MetaURL,
|
||||
PublishedAssetRoot: trimStringPtr(input.PublishedAssetRoot),
|
||||
Metadata: input.Metadata,
|
||||
SetAsCurrent: input.SetAsCurrent,
|
||||
})
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
release, err = s.store.GetTileReleaseByPublicID(ctx, created.ID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
} else if input.SetAsCurrent {
|
||||
tx, err := s.store.Begin(ctx)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer tx.Rollback(ctx)
|
||||
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
|
||||
}
|
||||
mapAsset, err = s.store.GetMapAssetByPublicID(ctx, mapAsset.PublicID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
}
|
||||
if release == nil {
|
||||
return nil, apperr.New(http.StatusNotFound, "tile_release_not_found", "tile release not found")
|
||||
}
|
||||
|
||||
placeSummary := buildAdminPlaceSummary(*place)
|
||||
mapSummary, err := s.buildAdminMapAssetSummary(ctx, *mapAsset)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &ImportAdminTileReleaseResult{
|
||||
Place: placeSummary,
|
||||
MapAsset: mapSummary,
|
||||
TileRelease: buildAdminTileReleaseView(*release),
|
||||
}, nil
|
||||
}
|
||||
|
||||
func (s *AdminProductionService) ImportCourseSetKMLBatch(ctx context.Context, input ImportAdminCourseSetBatchInput) (*ImportAdminCourseSetBatchResult, error) {
|
||||
input.PlaceCode = strings.TrimSpace(input.PlaceCode)
|
||||
input.PlaceName = strings.TrimSpace(input.PlaceName)
|
||||
input.MapAssetCode = strings.TrimSpace(input.MapAssetCode)
|
||||
input.MapAssetName = strings.TrimSpace(input.MapAssetName)
|
||||
input.CourseSetCode = strings.TrimSpace(input.CourseSetCode)
|
||||
input.CourseSetName = strings.TrimSpace(input.CourseSetName)
|
||||
input.Mode = strings.TrimSpace(input.Mode)
|
||||
if input.PlaceCode == "" || input.PlaceName == "" || input.MapAssetCode == "" || input.MapAssetName == "" || input.CourseSetCode == "" || input.CourseSetName == "" || input.Mode == "" || len(input.Routes) == 0 {
|
||||
return nil, apperr.New(http.StatusBadRequest, "invalid_params", "placeCode, placeName, mapAssetCode, mapAssetName, courseSetCode, courseSetName, mode and routes are required")
|
||||
}
|
||||
|
||||
place, err := s.store.GetPlaceByCode(ctx, input.PlaceCode)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if place == nil {
|
||||
created, err := s.CreatePlace(ctx, CreateAdminPlaceInput{
|
||||
Code: input.PlaceCode,
|
||||
Name: input.PlaceName,
|
||||
Status: normalizeCatalogStatus(input.Status),
|
||||
})
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
place, err = s.store.GetPlaceByPublicID(ctx, created.ID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
}
|
||||
mapAsset, err := s.store.GetMapAssetByCode(ctx, input.MapAssetCode)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if mapAsset == nil {
|
||||
created, err := s.CreateMapAsset(ctx, place.PublicID, CreateAdminMapAssetInput{
|
||||
Code: input.MapAssetCode,
|
||||
Name: input.MapAssetName,
|
||||
MapType: strings.TrimSpace(input.MapType),
|
||||
Status: normalizeCatalogStatus(input.Status),
|
||||
})
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
mapAsset, err = s.store.GetMapAssetByPublicID(ctx, created.ID)
|
||||
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")
|
||||
}
|
||||
|
||||
courseSet, err := s.store.GetCourseSetByCode(ctx, input.CourseSetCode)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if courseSet == nil {
|
||||
created, err := s.CreateCourseSet(ctx, mapAsset.PublicID, CreateAdminCourseSetInput{
|
||||
Code: input.CourseSetCode,
|
||||
Mode: input.Mode,
|
||||
Name: input.CourseSetName,
|
||||
Description: trimStringPtr(input.Description),
|
||||
Status: normalizeCatalogStatus(input.Status),
|
||||
})
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
courseSet, err = s.store.GetCourseSetByPublicID(ctx, created.ID)
|
||||
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")
|
||||
}
|
||||
|
||||
defaultRouteCode := ""
|
||||
if input.DefaultRouteCode != nil {
|
||||
defaultRouteCode = strings.TrimSpace(*input.DefaultRouteCode)
|
||||
}
|
||||
|
||||
for _, route := range input.Routes {
|
||||
route.Name = strings.TrimSpace(route.Name)
|
||||
route.RouteCode = strings.TrimSpace(route.RouteCode)
|
||||
route.FileURL = strings.TrimSpace(route.FileURL)
|
||||
sourceType := strings.TrimSpace(route.SourceType)
|
||||
if sourceType == "" {
|
||||
sourceType = "kml"
|
||||
}
|
||||
if route.Name == "" || route.RouteCode == "" || route.FileURL == "" {
|
||||
return nil, apperr.New(http.StatusBadRequest, "invalid_params", "route name, routeCode and fileUrl are required")
|
||||
}
|
||||
|
||||
existing, err := s.store.GetCourseVariantByCourseSetIDAndRouteCode(ctx, courseSet.ID, route.RouteCode)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if existing != nil {
|
||||
if defaultRouteCode != "" && route.RouteCode == defaultRouteCode {
|
||||
tx, err := s.store.Begin(ctx)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer tx.Rollback(ctx)
|
||||
if err := s.store.SetCourseSetCurrentVariant(ctx, tx, courseSet.ID, existing.ID); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if err := tx.Commit(ctx); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
}
|
||||
continue
|
||||
}
|
||||
|
||||
source, err := s.CreateCourseSource(ctx, CreateAdminCourseSourceInput{
|
||||
SourceType: sourceType,
|
||||
FileURL: route.FileURL,
|
||||
ImportStatus: "imported",
|
||||
Metadata: route.Metadata,
|
||||
})
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
isDefault := defaultRouteCode != "" && route.RouteCode == defaultRouteCode
|
||||
_, err = s.CreateCourseVariant(ctx, courseSet.PublicID, CreateAdminCourseVariantInput{
|
||||
SourceID: &source.ID,
|
||||
Name: route.Name,
|
||||
RouteCode: &route.RouteCode,
|
||||
Mode: input.Mode,
|
||||
ControlCount: route.ControlCount,
|
||||
Difficulty: trimStringPtr(route.Difficulty),
|
||||
Status: normalizeCatalogStatus(route.Status),
|
||||
IsDefault: isDefault,
|
||||
Metadata: route.Metadata,
|
||||
})
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
}
|
||||
|
||||
courseSet, err = s.store.GetCourseSetByPublicID(ctx, courseSet.PublicID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
variants, err := s.store.ListCourseVariantsByCourseSetID(ctx, courseSet.ID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
views := make([]AdminCourseVariantView, 0, len(variants))
|
||||
for _, variant := range variants {
|
||||
views = append(views, buildAdminCourseVariantView(variant))
|
||||
}
|
||||
placeSummary := buildAdminPlaceSummary(*place)
|
||||
mapSummary, err := s.buildAdminMapAssetSummary(ctx, *mapAsset)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
courseBrief, err := s.buildAdminCourseSetBrief(ctx, *courseSet)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &ImportAdminCourseSetBatchResult{
|
||||
Place: placeSummary,
|
||||
MapAsset: mapSummary,
|
||||
CourseSet: courseBrief,
|
||||
Variants: views,
|
||||
}, nil
|
||||
}
|
||||
|
||||
func (s *AdminProductionService) GetRuntimeBinding(ctx context.Context, runtimeBindingPublicID string) (*AdminRuntimeBindingSummary, error) {
|
||||
item, err := s.store.GetMapRuntimeBindingByPublicID(ctx, strings.TrimSpace(runtimeBindingPublicID))
|
||||
if err != nil {
|
||||
@@ -764,6 +1214,7 @@ func (s *AdminProductionService) buildAdminMapAssetSummary(ctx context.Context,
|
||||
result := AdminMapAssetSummary{
|
||||
ID: item.PublicID,
|
||||
PlaceID: item.PlaceID,
|
||||
PlaceName: item.PlaceName,
|
||||
LegacyMapID: item.LegacyMapPublicID,
|
||||
Code: item.Code,
|
||||
Name: item.Name,
|
||||
@@ -791,6 +1242,24 @@ func (s *AdminProductionService) buildAdminMapAssetSummary(ctx context.Context,
|
||||
return result, nil
|
||||
}
|
||||
|
||||
func buildAdminMapLinkedEventBrief(item postgres.MapAssetLinkedEvent) AdminMapLinkedEventBrief {
|
||||
return AdminMapLinkedEventBrief{
|
||||
EventID: item.EventPublicID,
|
||||
Title: item.DisplayName,
|
||||
Summary: item.Summary,
|
||||
Status: item.Status,
|
||||
IsDefaultExperience: item.IsDefaultExperience,
|
||||
ShowInEventList: item.ShowInEventList,
|
||||
CurrentReleaseID: item.CurrentReleasePublicID,
|
||||
ConfigLabel: item.ConfigLabel,
|
||||
RouteCode: item.RouteCode,
|
||||
CurrentPresentationID: item.CurrentPresentationID,
|
||||
CurrentPresentation: item.CurrentPresentationName,
|
||||
CurrentContentBundleID: item.CurrentContentBundleID,
|
||||
CurrentContentBundle: item.CurrentContentBundleName,
|
||||
}
|
||||
}
|
||||
|
||||
func (s *AdminProductionService) buildAdminCourseSetBrief(ctx context.Context, item postgres.CourseSet) (AdminCourseSetBrief, error) {
|
||||
result := AdminCourseSetBrief{
|
||||
ID: item.PublicID,
|
||||
|
||||
@@ -35,6 +35,7 @@ type EventPlayResult struct {
|
||||
} `json:"release,omitempty"`
|
||||
ResolvedRelease *ResolvedReleaseView `json:"resolvedRelease,omitempty"`
|
||||
Runtime *RuntimeSummaryView `json:"runtime,omitempty"`
|
||||
Preview *MapPreviewView `json:"preview,omitempty"`
|
||||
CurrentPresentation *PresentationSummaryView `json:"currentPresentation,omitempty"`
|
||||
CurrentContentBundle *ContentBundleSummaryView `json:"currentContentBundle,omitempty"`
|
||||
Play struct {
|
||||
@@ -104,6 +105,11 @@ func (s *EventPlayService) GetEventPlay(ctx context.Context, input EventPlayInpu
|
||||
}
|
||||
result.ResolvedRelease = buildResolvedReleaseFromEvent(event, LaunchSourceEventCurrentRelease)
|
||||
result.Runtime = buildRuntimeSummaryFromEvent(event)
|
||||
if preview, err := buildPreviewFromPayload(event.ReleasePayloadJSON); err != nil {
|
||||
return nil, err
|
||||
} else {
|
||||
result.Preview = preview
|
||||
}
|
||||
result.CurrentPresentation = buildPresentationSummaryFromEvent(event)
|
||||
if enrichedPresentation, err := loadPresentationSummaryByPublicID(ctx, s.store, event.PresentationID); err != nil {
|
||||
return nil, err
|
||||
|
||||
@@ -32,6 +32,7 @@ type EventDetailResult struct {
|
||||
} `json:"release,omitempty"`
|
||||
ResolvedRelease *ResolvedReleaseView `json:"resolvedRelease,omitempty"`
|
||||
Runtime *RuntimeSummaryView `json:"runtime,omitempty"`
|
||||
Preview *MapPreviewView `json:"preview,omitempty"`
|
||||
CurrentPresentation *PresentationSummaryView `json:"currentPresentation,omitempty"`
|
||||
CurrentContentBundle *ContentBundleSummaryView `json:"currentContentBundle,omitempty"`
|
||||
}
|
||||
@@ -71,6 +72,7 @@ type LaunchEventResult struct {
|
||||
SessionToken string `json:"sessionToken"`
|
||||
SessionTokenExpiresAt string `json:"sessionTokenExpiresAt"`
|
||||
RouteCode *string `json:"routeCode,omitempty"`
|
||||
IsGuest bool `json:"isGuest,omitempty"`
|
||||
} `json:"business"`
|
||||
} `json:"launch"`
|
||||
}
|
||||
@@ -117,6 +119,11 @@ func (s *EventService) GetEventDetail(ctx context.Context, eventPublicID string)
|
||||
}
|
||||
result.ResolvedRelease = buildResolvedReleaseFromEvent(event, LaunchSourceEventCurrentRelease)
|
||||
result.Runtime = buildRuntimeSummaryFromEvent(event)
|
||||
if preview, err := buildPreviewFromPayload(event.ReleasePayloadJSON); err != nil {
|
||||
return nil, err
|
||||
} else {
|
||||
result.Preview = preview
|
||||
}
|
||||
result.CurrentPresentation = buildPresentationSummaryFromEvent(event)
|
||||
if enrichedPresentation, err := loadPresentationSummaryByPublicID(ctx, s.store, event.PresentationID); err != nil {
|
||||
return nil, err
|
||||
@@ -245,5 +252,6 @@ func (s *EventService) LaunchEvent(ctx context.Context, input LaunchEventInput)
|
||||
result.Launch.Business.SessionToken = sessionToken
|
||||
result.Launch.Business.SessionTokenExpiresAt = session.SessionTokenExpiresAt.Format(time.RFC3339)
|
||||
result.Launch.Business.RouteCode = routeCode
|
||||
result.Launch.Business.IsGuest = false
|
||||
return result, nil
|
||||
}
|
||||
|
||||
@@ -37,6 +37,7 @@ type CardResult struct {
|
||||
TimeWindow string `json:"timeWindow"`
|
||||
CTAText string `json:"ctaText"`
|
||||
IsDefaultExperience bool `json:"isDefaultExperience"`
|
||||
ShowInEventList bool `json:"showInEventList"`
|
||||
EventType *string `json:"eventType,omitempty"`
|
||||
CurrentPresentation *PresentationSummaryView `json:"currentPresentation,omitempty"`
|
||||
CurrentContentBundle *ContentBundleSummaryView `json:"currentContentBundle,omitempty"`
|
||||
@@ -153,6 +154,7 @@ func mapCards(cards []postgres.Card) []CardResult {
|
||||
TimeWindow: deriveCardTimeWindow(card),
|
||||
CTAText: deriveCardCTAText(card, statusCode),
|
||||
IsDefaultExperience: card.IsDefaultExperience,
|
||||
ShowInEventList: card.ShowInEventList,
|
||||
EventType: deriveCardEventType(card),
|
||||
CurrentPresentation: buildCardPresentationSummary(card),
|
||||
CurrentContentBundle: buildCardContentBundleSummary(card),
|
||||
|
||||
300
backend/internal/service/map_experience_service.go
Normal file
300
backend/internal/service/map_experience_service.go
Normal file
@@ -0,0 +1,300 @@
|
||||
package service
|
||||
|
||||
import (
|
||||
"context"
|
||||
"net/http"
|
||||
"strings"
|
||||
|
||||
"cmr-backend/internal/apperr"
|
||||
"cmr-backend/internal/store/postgres"
|
||||
)
|
||||
|
||||
type MapExperienceService struct {
|
||||
store *postgres.Store
|
||||
}
|
||||
|
||||
type ListExperienceMapsInput struct {
|
||||
Limit int
|
||||
}
|
||||
|
||||
type ExperienceMapSummary struct {
|
||||
PlaceID string `json:"placeId"`
|
||||
PlaceName string `json:"placeName"`
|
||||
MapID string `json:"mapId"`
|
||||
MapName string `json:"mapName"`
|
||||
CoverURL *string `json:"coverUrl,omitempty"`
|
||||
Summary *string `json:"summary,omitempty"`
|
||||
DefaultExperienceCount int `json:"defaultExperienceCount"`
|
||||
DefaultExperienceEventIDs []string `json:"defaultExperienceEventIds"`
|
||||
}
|
||||
|
||||
type ExperienceMapDetail struct {
|
||||
PlaceID string `json:"placeId"`
|
||||
PlaceName string `json:"placeName"`
|
||||
MapID string `json:"mapId"`
|
||||
MapName string `json:"mapName"`
|
||||
CoverURL *string `json:"coverUrl,omitempty"`
|
||||
Summary *string `json:"summary,omitempty"`
|
||||
TileBaseURL *string `json:"tileBaseUrl,omitempty"`
|
||||
TileMetaURL *string `json:"tileMetaUrl,omitempty"`
|
||||
DefaultExperienceCount int `json:"defaultExperienceCount"`
|
||||
DefaultExperiences []ExperienceEventSummary `json:"defaultExperiences"`
|
||||
}
|
||||
|
||||
type ExperienceEventSummary struct {
|
||||
EventID string `json:"eventId"`
|
||||
Title string `json:"title"`
|
||||
Subtitle *string `json:"subtitle,omitempty"`
|
||||
EventType *string `json:"eventType,omitempty"`
|
||||
Status string `json:"status"`
|
||||
StatusCode string `json:"statusCode"`
|
||||
CTAText string `json:"ctaText"`
|
||||
IsDefaultExperience bool `json:"isDefaultExperience"`
|
||||
ShowInEventList bool `json:"showInEventList"`
|
||||
CurrentPresentation *PresentationSummaryView `json:"currentPresentation,omitempty"`
|
||||
CurrentContentBundle *ContentBundleSummaryView `json:"currentContentBundle,omitempty"`
|
||||
}
|
||||
|
||||
func NewMapExperienceService(store *postgres.Store) *MapExperienceService {
|
||||
return &MapExperienceService{store: store}
|
||||
}
|
||||
|
||||
func (s *MapExperienceService) ListMaps(ctx context.Context, input ListExperienceMapsInput) ([]ExperienceMapSummary, error) {
|
||||
rows, err := s.store.ListMapExperienceRows(ctx, input.Limit)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return mapExperienceSummaries(rows), nil
|
||||
}
|
||||
|
||||
func (s *MapExperienceService) GetMapDetail(ctx context.Context, mapPublicID string) (*ExperienceMapDetail, error) {
|
||||
mapPublicID = strings.TrimSpace(mapPublicID)
|
||||
if mapPublicID == "" {
|
||||
return nil, apperr.New(http.StatusBadRequest, "invalid_map_id", "map id is required")
|
||||
}
|
||||
rows, err := s.store.ListMapExperienceRowsByMapPublicID(ctx, mapPublicID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if len(rows) == 0 {
|
||||
return nil, apperr.New(http.StatusNotFound, "map_not_found", "map not found")
|
||||
}
|
||||
return buildMapExperienceDetail(rows), nil
|
||||
}
|
||||
|
||||
func mapExperienceSummaries(rows []postgres.MapExperienceRow) []ExperienceMapSummary {
|
||||
ordered := make([]string, 0, len(rows))
|
||||
index := make(map[string]*ExperienceMapSummary)
|
||||
for _, row := range rows {
|
||||
item, ok := index[row.MapAssetPublicID]
|
||||
if !ok {
|
||||
summary := &ExperienceMapSummary{
|
||||
PlaceID: row.PlacePublicID,
|
||||
PlaceName: row.PlaceName,
|
||||
MapID: row.MapAssetPublicID,
|
||||
MapName: row.MapAssetName,
|
||||
CoverURL: row.MapCoverURL,
|
||||
Summary: normalizeOptionalText(row.MapSummary),
|
||||
DefaultExperienceEventIDs: []string{},
|
||||
}
|
||||
index[row.MapAssetPublicID] = summary
|
||||
ordered = append(ordered, row.MapAssetPublicID)
|
||||
item = summary
|
||||
}
|
||||
if row.EventPublicID != nil && row.EventIsDefaultExperience {
|
||||
if !containsString(item.DefaultExperienceEventIDs, *row.EventPublicID) {
|
||||
item.DefaultExperienceEventIDs = append(item.DefaultExperienceEventIDs, *row.EventPublicID)
|
||||
item.DefaultExperienceCount++
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
result := make([]ExperienceMapSummary, 0, len(ordered))
|
||||
for _, id := range ordered {
|
||||
result = append(result, *index[id])
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
func buildMapExperienceDetail(rows []postgres.MapExperienceRow) *ExperienceMapDetail {
|
||||
first := rows[0]
|
||||
result := &ExperienceMapDetail{
|
||||
PlaceID: first.PlacePublicID,
|
||||
PlaceName: first.PlaceName,
|
||||
MapID: first.MapAssetPublicID,
|
||||
MapName: first.MapAssetName,
|
||||
CoverURL: first.MapCoverURL,
|
||||
Summary: normalizeOptionalText(first.MapSummary),
|
||||
TileBaseURL: first.TileBaseURL,
|
||||
TileMetaURL: first.TileMetaURL,
|
||||
DefaultExperiences: make([]ExperienceEventSummary, 0, 4),
|
||||
}
|
||||
seen := make(map[string]struct{})
|
||||
for _, row := range rows {
|
||||
if row.EventPublicID == nil || !row.EventIsDefaultExperience {
|
||||
continue
|
||||
}
|
||||
if _, ok := seen[*row.EventPublicID]; ok {
|
||||
continue
|
||||
}
|
||||
seen[*row.EventPublicID] = struct{}{}
|
||||
result.DefaultExperiences = append(result.DefaultExperiences, buildExperienceEventSummary(row))
|
||||
}
|
||||
result.DefaultExperienceCount = len(result.DefaultExperiences)
|
||||
return result
|
||||
}
|
||||
|
||||
func buildExperienceEventSummary(row postgres.MapExperienceRow) ExperienceEventSummary {
|
||||
statusCode, statusText := deriveExperienceEventStatus(row)
|
||||
return ExperienceEventSummary{
|
||||
EventID: valueOrEmpty(row.EventPublicID),
|
||||
Title: fallbackText(row.EventDisplayName, "未命名活动"),
|
||||
Subtitle: normalizeOptionalText(row.EventSummary),
|
||||
EventType: deriveExperienceEventType(row),
|
||||
Status: statusText,
|
||||
StatusCode: statusCode,
|
||||
CTAText: deriveExperienceEventCTA(statusCode, row.EventIsDefaultExperience),
|
||||
IsDefaultExperience: row.EventIsDefaultExperience,
|
||||
ShowInEventList: row.EventShowInEventList,
|
||||
CurrentPresentation: buildPresentationSummaryFromMapExperienceRow(row),
|
||||
CurrentContentBundle: buildContentBundleSummaryFromMapExperienceRow(row),
|
||||
}
|
||||
}
|
||||
|
||||
func deriveExperienceEventStatus(row postgres.MapExperienceRow) (string, string) {
|
||||
if row.EventStatus == nil {
|
||||
return "pending", "状态待确认"
|
||||
}
|
||||
switch strings.TrimSpace(*row.EventStatus) {
|
||||
case "active":
|
||||
if row.EventReleasePayloadJSON == nil || strings.TrimSpace(*row.EventReleasePayloadJSON) == "" {
|
||||
return "upcoming", "即将开始"
|
||||
}
|
||||
if row.EventPresentationID == nil || row.EventContentBundleID == nil {
|
||||
return "upcoming", "即将开始"
|
||||
}
|
||||
return "running", "进行中"
|
||||
case "archived", "disabled", "inactive":
|
||||
return "ended", "已结束"
|
||||
default:
|
||||
return "pending", "状态待确认"
|
||||
}
|
||||
}
|
||||
|
||||
func deriveExperienceEventCTA(statusCode string, isDefault bool) string {
|
||||
if isDefault {
|
||||
return "进入体验"
|
||||
}
|
||||
switch statusCode {
|
||||
case "running":
|
||||
return "进入活动"
|
||||
case "ended":
|
||||
return "查看回顾"
|
||||
default:
|
||||
return "查看详情"
|
||||
}
|
||||
}
|
||||
|
||||
func deriveExperienceEventType(row postgres.MapExperienceRow) *string {
|
||||
if row.EventReleasePayloadJSON != nil {
|
||||
payload, err := decodeJSONObject(*row.EventReleasePayloadJSON)
|
||||
if err == nil {
|
||||
if game, ok := payload["game"].(map[string]any); ok {
|
||||
if rawMode, ok := game["mode"].(string); ok {
|
||||
switch strings.TrimSpace(rawMode) {
|
||||
case "classic-sequential":
|
||||
text := "顺序赛"
|
||||
return &text
|
||||
case "score-o":
|
||||
text := "积分赛"
|
||||
return &text
|
||||
}
|
||||
}
|
||||
}
|
||||
if plan := resolveVariantPlan(row.EventReleasePayloadJSON); plan.AssignmentMode != nil && *plan.AssignmentMode == AssignmentModeManual {
|
||||
text := "多赛道"
|
||||
return &text
|
||||
}
|
||||
}
|
||||
}
|
||||
if row.EventIsDefaultExperience {
|
||||
text := "体验活动"
|
||||
return &text
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func buildPresentationSummaryFromMapExperienceRow(row postgres.MapExperienceRow) *PresentationSummaryView {
|
||||
if row.EventPresentationID == nil {
|
||||
return nil
|
||||
}
|
||||
summary := &PresentationSummaryView{
|
||||
PresentationID: *row.EventPresentationID,
|
||||
Name: row.EventPresentationName,
|
||||
PresentationType: row.EventPresentationType,
|
||||
}
|
||||
if row.EventPresentationSchema != nil && strings.TrimSpace(*row.EventPresentationSchema) != "" {
|
||||
if schema, err := decodeJSONObject(*row.EventPresentationSchema); err == nil {
|
||||
summary.TemplateKey = readStringField(schema, "templateKey")
|
||||
summary.Version = readStringField(schema, "version")
|
||||
}
|
||||
}
|
||||
return summary
|
||||
}
|
||||
|
||||
func buildContentBundleSummaryFromMapExperienceRow(row postgres.MapExperienceRow) *ContentBundleSummaryView {
|
||||
if row.EventContentBundleID == nil {
|
||||
return nil
|
||||
}
|
||||
summary := &ContentBundleSummaryView{
|
||||
ContentBundleID: *row.EventContentBundleID,
|
||||
Name: row.EventContentBundleName,
|
||||
EntryURL: row.EventContentEntryURL,
|
||||
AssetRootURL: row.EventContentAssetRootURL,
|
||||
}
|
||||
if row.EventContentMetadataJSON != nil && strings.TrimSpace(*row.EventContentMetadataJSON) != "" {
|
||||
if metadata, err := decodeJSONObject(*row.EventContentMetadataJSON); err == nil {
|
||||
summary.BundleType = readStringField(metadata, "bundleType")
|
||||
summary.Version = readStringField(metadata, "version")
|
||||
}
|
||||
}
|
||||
return summary
|
||||
}
|
||||
|
||||
func normalizeOptionalText(value *string) *string {
|
||||
if value == nil {
|
||||
return nil
|
||||
}
|
||||
trimmed := strings.TrimSpace(*value)
|
||||
if trimmed == "" {
|
||||
return nil
|
||||
}
|
||||
return &trimmed
|
||||
}
|
||||
|
||||
func fallbackText(value *string, fallback string) string {
|
||||
if value == nil {
|
||||
return fallback
|
||||
}
|
||||
trimmed := strings.TrimSpace(*value)
|
||||
if trimmed == "" {
|
||||
return fallback
|
||||
}
|
||||
return trimmed
|
||||
}
|
||||
|
||||
func valueOrEmpty(value *string) string {
|
||||
if value == nil {
|
||||
return ""
|
||||
}
|
||||
return *value
|
||||
}
|
||||
|
||||
func containsString(values []string, target string) bool {
|
||||
for _, item := range values {
|
||||
if item == target {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
395
backend/internal/service/ops_auth_service.go
Normal file
395
backend/internal/service/ops_auth_service.go
Normal file
@@ -0,0 +1,395 @@
|
||||
package service
|
||||
|
||||
import (
|
||||
"context"
|
||||
"net/http"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"cmr-backend/internal/apperr"
|
||||
"cmr-backend/internal/platform/jwtx"
|
||||
"cmr-backend/internal/platform/security"
|
||||
"cmr-backend/internal/store/postgres"
|
||||
)
|
||||
|
||||
type OpsAuthSettings struct {
|
||||
AppEnv string
|
||||
RefreshTTL time.Duration
|
||||
SMSCodeTTL time.Duration
|
||||
SMSCodeCooldown time.Duration
|
||||
SMSProvider string
|
||||
DevSMSCode string
|
||||
}
|
||||
|
||||
type OpsAuthService struct {
|
||||
cfg OpsAuthSettings
|
||||
store *postgres.Store
|
||||
jwtManager *jwtx.Manager
|
||||
}
|
||||
|
||||
type OpsSendSMSCodeInput struct {
|
||||
CountryCode string `json:"countryCode"`
|
||||
Mobile string `json:"mobile"`
|
||||
DeviceKey string `json:"deviceKey"`
|
||||
Scene string `json:"scene"`
|
||||
}
|
||||
|
||||
type OpsRegisterInput struct {
|
||||
CountryCode string `json:"countryCode"`
|
||||
Mobile string `json:"mobile"`
|
||||
Code string `json:"code"`
|
||||
DeviceKey string `json:"deviceKey"`
|
||||
DisplayName string `json:"displayName"`
|
||||
}
|
||||
|
||||
type OpsLoginSMSInput struct {
|
||||
CountryCode string `json:"countryCode"`
|
||||
Mobile string `json:"mobile"`
|
||||
Code string `json:"code"`
|
||||
DeviceKey string `json:"deviceKey"`
|
||||
}
|
||||
|
||||
type OpsRefreshTokenInput struct {
|
||||
RefreshToken string `json:"refreshToken"`
|
||||
DeviceKey string `json:"deviceKey"`
|
||||
}
|
||||
|
||||
type OpsLogoutInput struct {
|
||||
RefreshToken string `json:"refreshToken"`
|
||||
}
|
||||
|
||||
type OpsAuthUser struct {
|
||||
ID string `json:"id"`
|
||||
PublicID string `json:"publicId"`
|
||||
DisplayName string `json:"displayName"`
|
||||
Status string `json:"status"`
|
||||
RoleCode string `json:"roleCode"`
|
||||
}
|
||||
|
||||
type OpsAuthResult struct {
|
||||
User OpsAuthUser `json:"user"`
|
||||
Tokens AuthTokens `json:"tokens"`
|
||||
NewUser bool `json:"newUser"`
|
||||
DevLoginBypass bool `json:"devLoginBypass,omitempty"`
|
||||
}
|
||||
|
||||
func NewOpsAuthService(cfg OpsAuthSettings, store *postgres.Store, jwtManager *jwtx.Manager) *OpsAuthService {
|
||||
return &OpsAuthService{cfg: cfg, store: store, jwtManager: jwtManager}
|
||||
}
|
||||
|
||||
func (s *OpsAuthService) SendSMSCode(ctx context.Context, input OpsSendSMSCodeInput) (*SendSMSCodeResult, error) {
|
||||
input.CountryCode = normalizeCountryCode(input.CountryCode)
|
||||
input.Mobile = normalizeMobile(input.Mobile)
|
||||
input.Scene = normalizeOpsScene(input.Scene)
|
||||
if input.Mobile == "" || strings.TrimSpace(input.DeviceKey) == "" {
|
||||
return nil, apperr.New(http.StatusBadRequest, "invalid_params", "mobile and deviceKey are required")
|
||||
}
|
||||
|
||||
latest, err := s.store.GetLatestSMSCodeMeta(ctx, input.CountryCode, input.Mobile, "ops", input.Scene)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
now := time.Now().UTC()
|
||||
if latest != nil && latest.CooldownUntil.After(now) {
|
||||
return nil, apperr.New(http.StatusTooManyRequests, "sms_cooldown", "sms code sent too frequently")
|
||||
}
|
||||
|
||||
code := s.cfg.DevSMSCode
|
||||
if code == "" {
|
||||
code, err = security.GenerateNumericCode(6)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
}
|
||||
expiresAt := now.Add(s.cfg.SMSCodeTTL)
|
||||
cooldownUntil := now.Add(s.cfg.SMSCodeCooldown)
|
||||
if err := s.store.CreateSMSCode(ctx, postgres.CreateSMSCodeParams{
|
||||
Scene: input.Scene,
|
||||
CountryCode: input.CountryCode,
|
||||
Mobile: input.Mobile,
|
||||
ClientType: "ops",
|
||||
DeviceKey: input.DeviceKey,
|
||||
CodeHash: security.HashText(code),
|
||||
ProviderName: s.cfg.SMSProvider,
|
||||
ProviderDebug: map[string]any{"mode": s.cfg.SMSProvider, "channel": "ops_console"},
|
||||
ExpiresAt: expiresAt,
|
||||
CooldownUntil: cooldownUntil,
|
||||
}); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
result := &SendSMSCodeResult{
|
||||
TTLSeconds: int64(s.cfg.SMSCodeTTL.Seconds()),
|
||||
CooldownSeconds: int64(s.cfg.SMSCodeCooldown.Seconds()),
|
||||
}
|
||||
if strings.EqualFold(s.cfg.SMSProvider, "console") || strings.EqualFold(s.cfg.AppEnv, "development") {
|
||||
result.DevCode = &code
|
||||
}
|
||||
return result, nil
|
||||
}
|
||||
|
||||
func (s *OpsAuthService) Register(ctx context.Context, input OpsRegisterInput) (*OpsAuthResult, error) {
|
||||
input.CountryCode = normalizeCountryCode(input.CountryCode)
|
||||
input.Mobile = normalizeMobile(input.Mobile)
|
||||
input.Code = strings.TrimSpace(input.Code)
|
||||
input.DeviceKey = strings.TrimSpace(input.DeviceKey)
|
||||
input.DisplayName = strings.TrimSpace(input.DisplayName)
|
||||
if input.Mobile == "" || input.Code == "" || input.DeviceKey == "" || input.DisplayName == "" {
|
||||
return nil, apperr.New(http.StatusBadRequest, "invalid_params", "mobile, code, deviceKey and displayName are required")
|
||||
}
|
||||
|
||||
codeRecord, err := s.store.GetLatestValidSMSCode(ctx, input.CountryCode, input.Mobile, "ops", "ops_register")
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if codeRecord == nil || codeRecord.CodeHash != security.HashText(input.Code) {
|
||||
return nil, apperr.New(http.StatusUnauthorized, "invalid_sms_code", "invalid sms code")
|
||||
}
|
||||
|
||||
tx, err := s.store.Begin(ctx)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer tx.Rollback(ctx)
|
||||
|
||||
consumed, err := s.store.ConsumeSMSCode(ctx, tx, codeRecord.ID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if !consumed {
|
||||
return nil, apperr.New(http.StatusUnauthorized, "invalid_sms_code", "sms code already used")
|
||||
}
|
||||
|
||||
existing, err := s.store.GetOpsUserByMobile(ctx, tx, input.CountryCode, input.Mobile)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if existing != nil {
|
||||
return nil, apperr.New(http.StatusConflict, "ops_user_exists", "ops user already exists")
|
||||
}
|
||||
|
||||
publicID, err := security.GeneratePublicID("ops")
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
user, err := s.store.CreateOpsUser(ctx, tx, postgres.CreateOpsUserParams{
|
||||
PublicID: publicID,
|
||||
CountryCode: input.CountryCode,
|
||||
Mobile: input.Mobile,
|
||||
DisplayName: input.DisplayName,
|
||||
Status: "active",
|
||||
})
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
roleCode := "operator"
|
||||
count, err := s.store.CountOpsUsers(ctx)
|
||||
if err == nil && count == 0 {
|
||||
roleCode = "owner"
|
||||
}
|
||||
role, err := s.store.GetOpsRoleByCode(ctx, tx, roleCode)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if role == nil {
|
||||
return nil, apperr.New(http.StatusInternalServerError, "ops_role_missing", "default ops role is missing")
|
||||
}
|
||||
if err := s.store.AssignOpsRole(ctx, tx, user.ID, role.ID); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if err := s.store.TouchOpsUserLogin(ctx, tx, user.ID); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
result, _, err := s.issueAuthResult(ctx, tx, *user, input.DeviceKey, true)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if err := tx.Commit(ctx); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return result, nil
|
||||
}
|
||||
|
||||
func (s *OpsAuthService) LoginSMS(ctx context.Context, input OpsLoginSMSInput) (*OpsAuthResult, error) {
|
||||
input.CountryCode = normalizeCountryCode(input.CountryCode)
|
||||
input.Mobile = normalizeMobile(input.Mobile)
|
||||
input.Code = strings.TrimSpace(input.Code)
|
||||
input.DeviceKey = strings.TrimSpace(input.DeviceKey)
|
||||
if input.Mobile == "" || input.Code == "" || input.DeviceKey == "" {
|
||||
return nil, apperr.New(http.StatusBadRequest, "invalid_params", "mobile, code and deviceKey are required")
|
||||
}
|
||||
|
||||
codeRecord, err := s.store.GetLatestValidSMSCode(ctx, input.CountryCode, input.Mobile, "ops", "ops_login")
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if codeRecord == nil || codeRecord.CodeHash != security.HashText(input.Code) {
|
||||
return nil, apperr.New(http.StatusUnauthorized, "invalid_sms_code", "invalid sms code")
|
||||
}
|
||||
|
||||
tx, err := s.store.Begin(ctx)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer tx.Rollback(ctx)
|
||||
|
||||
consumed, err := s.store.ConsumeSMSCode(ctx, tx, codeRecord.ID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if !consumed {
|
||||
return nil, apperr.New(http.StatusUnauthorized, "invalid_sms_code", "sms code already used")
|
||||
}
|
||||
|
||||
user, err := s.store.GetOpsUserByMobile(ctx, tx, input.CountryCode, input.Mobile)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if user == nil {
|
||||
return nil, apperr.New(http.StatusNotFound, "ops_user_not_found", "ops user not found")
|
||||
}
|
||||
if user.Status != "active" {
|
||||
return nil, apperr.New(http.StatusForbidden, "ops_user_inactive", "ops user is not active")
|
||||
}
|
||||
if err := s.store.TouchOpsUserLogin(ctx, tx, user.ID); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
result, _, err := s.issueAuthResult(ctx, tx, *user, input.DeviceKey, false)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if err := tx.Commit(ctx); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return result, nil
|
||||
}
|
||||
|
||||
func (s *OpsAuthService) Refresh(ctx context.Context, input OpsRefreshTokenInput) (*OpsAuthResult, error) {
|
||||
input.RefreshToken = strings.TrimSpace(input.RefreshToken)
|
||||
if input.RefreshToken == "" {
|
||||
return nil, apperr.New(http.StatusBadRequest, "invalid_params", "refreshToken is required")
|
||||
}
|
||||
tx, err := s.store.Begin(ctx)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer tx.Rollback(ctx)
|
||||
|
||||
record, err := s.store.GetOpsRefreshTokenForUpdate(ctx, tx, security.HashText(input.RefreshToken))
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if record == nil || record.IsRevoked || record.ExpiresAt.Before(time.Now().UTC()) {
|
||||
return nil, apperr.New(http.StatusUnauthorized, "invalid_refresh_token", "refresh token is invalid or expired")
|
||||
}
|
||||
if input.DeviceKey != "" && record.DeviceKey != nil && input.DeviceKey != *record.DeviceKey {
|
||||
return nil, apperr.New(http.StatusUnauthorized, "invalid_refresh_token", "refresh token device mismatch")
|
||||
}
|
||||
user, err := s.store.GetOpsUserByID(ctx, tx, record.OpsUserID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if user == nil || user.Status != "active" {
|
||||
return nil, apperr.New(http.StatusUnauthorized, "invalid_refresh_token", "refresh token user not found")
|
||||
}
|
||||
result, newTokenID, err := s.issueAuthResult(ctx, tx, *user, nullableStringValue(record.DeviceKey), false)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if err := s.store.RotateOpsRefreshToken(ctx, tx, record.ID, newTokenID); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if err := tx.Commit(ctx); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return result, nil
|
||||
}
|
||||
|
||||
func (s *OpsAuthService) Logout(ctx context.Context, input OpsLogoutInput) error {
|
||||
if strings.TrimSpace(input.RefreshToken) == "" {
|
||||
return nil
|
||||
}
|
||||
return s.store.RevokeOpsRefreshToken(ctx, security.HashText(strings.TrimSpace(input.RefreshToken)))
|
||||
}
|
||||
|
||||
func (s *OpsAuthService) GetMe(ctx context.Context, opsUserID string) (*OpsAuthUser, error) {
|
||||
user, err := s.store.GetOpsUserByID(ctx, s.store.Pool(), opsUserID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if user == nil {
|
||||
return nil, apperr.New(http.StatusNotFound, "ops_user_not_found", "ops user not found")
|
||||
}
|
||||
role, err := s.store.GetPrimaryOpsRole(ctx, s.store.Pool(), user.ID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
result := buildOpsAuthUser(*user, role)
|
||||
return &result, nil
|
||||
}
|
||||
|
||||
func (s *OpsAuthService) issueAuthResult(ctx context.Context, tx postgres.Tx, user postgres.OpsUser, deviceKey string, newUser bool) (*OpsAuthResult, string, error) {
|
||||
role, err := s.store.GetPrimaryOpsRole(ctx, tx, user.ID)
|
||||
if err != nil {
|
||||
return nil, "", err
|
||||
}
|
||||
roleCode := ""
|
||||
if role != nil {
|
||||
roleCode = role.RoleCode
|
||||
}
|
||||
accessToken, accessExpiresAt, err := s.jwtManager.IssueActorAccessToken(user.ID, user.PublicID, "ops", roleCode)
|
||||
if err != nil {
|
||||
return nil, "", err
|
||||
}
|
||||
refreshToken, err := security.GenerateToken(32)
|
||||
if err != nil {
|
||||
return nil, "", err
|
||||
}
|
||||
refreshTokenHash := security.HashText(refreshToken)
|
||||
refreshExpiresAt := time.Now().UTC().Add(s.cfg.RefreshTTL)
|
||||
refreshID, err := s.store.CreateOpsRefreshToken(ctx, tx, postgres.CreateOpsRefreshTokenParams{
|
||||
OpsUserID: user.ID,
|
||||
DeviceKey: deviceKey,
|
||||
TokenHash: refreshTokenHash,
|
||||
ExpiresAt: refreshExpiresAt,
|
||||
})
|
||||
if err != nil {
|
||||
return nil, "", err
|
||||
}
|
||||
result := &OpsAuthResult{
|
||||
User: buildOpsAuthUser(user, role),
|
||||
Tokens: AuthTokens{
|
||||
AccessToken: accessToken,
|
||||
AccessTokenExpiresAt: accessExpiresAt.Format(time.RFC3339),
|
||||
RefreshToken: refreshToken,
|
||||
RefreshTokenExpiresAt: refreshExpiresAt.Format(time.RFC3339),
|
||||
},
|
||||
NewUser: newUser,
|
||||
}
|
||||
return result, refreshID, nil
|
||||
}
|
||||
|
||||
func buildOpsAuthUser(user postgres.OpsUser, role *postgres.OpsRole) OpsAuthUser {
|
||||
roleCode := ""
|
||||
if role != nil {
|
||||
roleCode = role.RoleCode
|
||||
}
|
||||
return OpsAuthUser{
|
||||
ID: user.ID,
|
||||
PublicID: user.PublicID,
|
||||
DisplayName: user.DisplayName,
|
||||
Status: user.Status,
|
||||
RoleCode: roleCode,
|
||||
}
|
||||
}
|
||||
|
||||
func normalizeOpsScene(value string) string {
|
||||
switch strings.TrimSpace(value) {
|
||||
case "ops_register":
|
||||
return "ops_register"
|
||||
default:
|
||||
return "ops_login"
|
||||
}
|
||||
}
|
||||
57
backend/internal/service/ops_summary_service.go
Normal file
57
backend/internal/service/ops_summary_service.go
Normal file
@@ -0,0 +1,57 @@
|
||||
package service
|
||||
|
||||
import (
|
||||
"context"
|
||||
|
||||
"cmr-backend/internal/store/postgres"
|
||||
)
|
||||
|
||||
type OpsOverviewSummary struct {
|
||||
ManagedAssets int `json:"managedAssets"`
|
||||
Places int `json:"places"`
|
||||
MapAssets int `json:"mapAssets"`
|
||||
TileReleases int `json:"tileReleases"`
|
||||
CourseSets int `json:"courseSets"`
|
||||
CourseVariants int `json:"courseVariants"`
|
||||
Events int `json:"events"`
|
||||
DefaultEvents int `json:"defaultEvents"`
|
||||
PublishedEvents int `json:"publishedEvents"`
|
||||
ConfigSources int `json:"configSources"`
|
||||
Releases int `json:"releases"`
|
||||
RuntimeBindings int `json:"runtimeBindings"`
|
||||
Presentations int `json:"presentations"`
|
||||
ContentBundles int `json:"contentBundles"`
|
||||
OpsUsers int `json:"opsUsers"`
|
||||
}
|
||||
|
||||
type OpsSummaryService struct {
|
||||
store *postgres.Store
|
||||
}
|
||||
|
||||
func NewOpsSummaryService(store *postgres.Store) *OpsSummaryService {
|
||||
return &OpsSummaryService{store: store}
|
||||
}
|
||||
|
||||
func (s *OpsSummaryService) GetOverview(ctx context.Context) (*OpsOverviewSummary, error) {
|
||||
counts, err := s.store.GetOpsOverviewCounts(ctx)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &OpsOverviewSummary{
|
||||
ManagedAssets: counts.ManagedAssets,
|
||||
Places: counts.Places,
|
||||
MapAssets: counts.MapAssets,
|
||||
TileReleases: counts.TileReleases,
|
||||
CourseSets: counts.CourseSets,
|
||||
CourseVariants: counts.CourseVariants,
|
||||
Events: counts.Events,
|
||||
DefaultEvents: counts.DefaultEvents,
|
||||
PublishedEvents: counts.PublishedEvents,
|
||||
ConfigSources: counts.ConfigSources,
|
||||
Releases: counts.Releases,
|
||||
RuntimeBindings: counts.RuntimeBindings,
|
||||
Presentations: counts.Presentations,
|
||||
ContentBundles: counts.ContentBundles,
|
||||
OpsUsers: counts.OpsUsers,
|
||||
}, nil
|
||||
}
|
||||
222
backend/internal/service/preview_contract.go
Normal file
222
backend/internal/service/preview_contract.go
Normal file
@@ -0,0 +1,222 @@
|
||||
package service
|
||||
|
||||
import "strings"
|
||||
|
||||
type MapPreviewView struct {
|
||||
Mode string `json:"mode"`
|
||||
BaseTiles *PreviewBaseTiles `json:"baseTiles,omitempty"`
|
||||
Viewport *PreviewViewport `json:"viewport,omitempty"`
|
||||
Variants []PreviewVariantView `json:"variants,omitempty"`
|
||||
SelectedVariantID *string `json:"selectedVariantId,omitempty"`
|
||||
}
|
||||
|
||||
type PreviewBaseTiles struct {
|
||||
TileBaseURL string `json:"tileBaseUrl"`
|
||||
Zoom *int `json:"zoom,omitempty"`
|
||||
TileSize *int `json:"tileSize,omitempty"`
|
||||
}
|
||||
|
||||
type PreviewViewport struct {
|
||||
Width *int `json:"width,omitempty"`
|
||||
Height *int `json:"height,omitempty"`
|
||||
MinLon *float64 `json:"minLon,omitempty"`
|
||||
MinLat *float64 `json:"minLat,omitempty"`
|
||||
MaxLon *float64 `json:"maxLon,omitempty"`
|
||||
MaxLat *float64 `json:"maxLat,omitempty"`
|
||||
}
|
||||
|
||||
type PreviewVariantView struct {
|
||||
VariantID string `json:"variantId"`
|
||||
Name *string `json:"name,omitempty"`
|
||||
RouteCode *string `json:"routeCode,omitempty"`
|
||||
Controls []PreviewControlView `json:"controls,omitempty"`
|
||||
Legs []PreviewLegView `json:"legs,omitempty"`
|
||||
}
|
||||
|
||||
type PreviewControlView struct {
|
||||
ID string `json:"id"`
|
||||
Kind *string `json:"kind,omitempty"`
|
||||
Lon *float64 `json:"lon,omitempty"`
|
||||
Lat *float64 `json:"lat,omitempty"`
|
||||
Label *string `json:"label,omitempty"`
|
||||
}
|
||||
|
||||
type PreviewLegView struct {
|
||||
From string `json:"from"`
|
||||
To string `json:"to"`
|
||||
}
|
||||
|
||||
func buildPreviewFromPayload(payloadJSON *string) (*MapPreviewView, error) {
|
||||
if payloadJSON == nil || strings.TrimSpace(*payloadJSON) == "" {
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
payload, err := decodeJSONObject(*payloadJSON)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
rawPreview, _ := payload["preview"].(map[string]any)
|
||||
if len(rawPreview) == 0 {
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
view := &MapPreviewView{}
|
||||
if mode := readStringField(rawPreview, "mode"); mode != nil {
|
||||
view.Mode = *mode
|
||||
}
|
||||
if view.Mode == "" {
|
||||
view.Mode = "readonly"
|
||||
}
|
||||
|
||||
if rawBaseTiles, ok := rawPreview["baseTiles"].(map[string]any); ok && len(rawBaseTiles) > 0 {
|
||||
baseTiles := &PreviewBaseTiles{}
|
||||
if tileBaseURL := readStringField(rawBaseTiles, "tileBaseUrl"); tileBaseURL != nil {
|
||||
baseTiles.TileBaseURL = *tileBaseURL
|
||||
}
|
||||
baseTiles.Zoom = readIntField(rawBaseTiles, "zoom")
|
||||
baseTiles.TileSize = readIntField(rawBaseTiles, "tileSize")
|
||||
if strings.TrimSpace(baseTiles.TileBaseURL) != "" {
|
||||
view.BaseTiles = baseTiles
|
||||
}
|
||||
}
|
||||
|
||||
if rawViewport, ok := rawPreview["viewport"].(map[string]any); ok && len(rawViewport) > 0 {
|
||||
viewport := &PreviewViewport{
|
||||
Width: readIntField(rawViewport, "width"),
|
||||
Height: readIntField(rawViewport, "height"),
|
||||
MinLon: readFloatField(rawViewport, "minLon"),
|
||||
MinLat: readFloatField(rawViewport, "minLat"),
|
||||
MaxLon: readFloatField(rawViewport, "maxLon"),
|
||||
MaxLat: readFloatField(rawViewport, "maxLat"),
|
||||
}
|
||||
view.Viewport = viewport
|
||||
}
|
||||
|
||||
if selectedVariantID := readStringField(rawPreview, "selectedVariantId"); selectedVariantID != nil {
|
||||
view.SelectedVariantID = selectedVariantID
|
||||
}
|
||||
|
||||
rawVariants, _ := rawPreview["variants"].([]any)
|
||||
if len(rawVariants) > 0 {
|
||||
view.Variants = make([]PreviewVariantView, 0, len(rawVariants))
|
||||
for _, raw := range rawVariants {
|
||||
item, ok := raw.(map[string]any)
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
variantID := readStringField(item, "variantId")
|
||||
if variantID == nil || strings.TrimSpace(*variantID) == "" {
|
||||
variantID = readStringField(item, "id")
|
||||
}
|
||||
if variantID == nil || strings.TrimSpace(*variantID) == "" {
|
||||
continue
|
||||
}
|
||||
variant := PreviewVariantView{
|
||||
VariantID: *variantID,
|
||||
Name: readStringField(item, "name"),
|
||||
RouteCode: readStringField(item, "routeCode"),
|
||||
}
|
||||
rawControls, _ := item["controls"].([]any)
|
||||
if len(rawControls) > 0 {
|
||||
variant.Controls = make([]PreviewControlView, 0, len(rawControls))
|
||||
for _, rawControl := range rawControls {
|
||||
controlMap, ok := rawControl.(map[string]any)
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
controlID := readStringField(controlMap, "id")
|
||||
if controlID == nil || strings.TrimSpace(*controlID) == "" {
|
||||
continue
|
||||
}
|
||||
variant.Controls = append(variant.Controls, PreviewControlView{
|
||||
ID: *controlID,
|
||||
Kind: readStringField(controlMap, "kind"),
|
||||
Lon: readFloatField(controlMap, "lon"),
|
||||
Lat: readFloatField(controlMap, "lat"),
|
||||
Label: readStringField(controlMap, "label"),
|
||||
})
|
||||
}
|
||||
}
|
||||
rawLegs, _ := item["legs"].([]any)
|
||||
if len(rawLegs) > 0 {
|
||||
variant.Legs = make([]PreviewLegView, 0, len(rawLegs))
|
||||
for _, rawLeg := range rawLegs {
|
||||
legMap, ok := rawLeg.(map[string]any)
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
from := readStringField(legMap, "from")
|
||||
to := readStringField(legMap, "to")
|
||||
if from == nil || to == nil || strings.TrimSpace(*from) == "" || strings.TrimSpace(*to) == "" {
|
||||
continue
|
||||
}
|
||||
variant.Legs = append(variant.Legs, PreviewLegView{
|
||||
From: *from,
|
||||
To: *to,
|
||||
})
|
||||
}
|
||||
}
|
||||
view.Variants = append(view.Variants, variant)
|
||||
}
|
||||
}
|
||||
|
||||
if view.BaseTiles == nil && view.Viewport == nil && len(view.Variants) == 0 {
|
||||
return nil, nil
|
||||
}
|
||||
return view, nil
|
||||
}
|
||||
|
||||
func readIntField(object map[string]any, key string) *int {
|
||||
if object == nil {
|
||||
return nil
|
||||
}
|
||||
value, ok := object[key]
|
||||
if !ok {
|
||||
return nil
|
||||
}
|
||||
switch v := value.(type) {
|
||||
case int:
|
||||
result := v
|
||||
return &result
|
||||
case int32:
|
||||
result := int(v)
|
||||
return &result
|
||||
case int64:
|
||||
result := int(v)
|
||||
return &result
|
||||
case float64:
|
||||
result := int(v)
|
||||
return &result
|
||||
default:
|
||||
return nil
|
||||
}
|
||||
}
|
||||
|
||||
func readFloatField(object map[string]any, key string) *float64 {
|
||||
if object == nil {
|
||||
return nil
|
||||
}
|
||||
value, ok := object[key]
|
||||
if !ok {
|
||||
return nil
|
||||
}
|
||||
switch v := value.(type) {
|
||||
case float64:
|
||||
result := v
|
||||
return &result
|
||||
case float32:
|
||||
result := float64(v)
|
||||
return &result
|
||||
case int:
|
||||
result := float64(v)
|
||||
return &result
|
||||
case int32:
|
||||
result := float64(v)
|
||||
return &result
|
||||
case int64:
|
||||
result := float64(v)
|
||||
return &result
|
||||
default:
|
||||
return nil
|
||||
}
|
||||
}
|
||||
303
backend/internal/service/public_experience_service.go
Normal file
303
backend/internal/service/public_experience_service.go
Normal file
@@ -0,0 +1,303 @@
|
||||
package service
|
||||
|
||||
import (
|
||||
"context"
|
||||
"net/http"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"cmr-backend/internal/apperr"
|
||||
"cmr-backend/internal/platform/security"
|
||||
"cmr-backend/internal/store/postgres"
|
||||
)
|
||||
|
||||
const (
|
||||
GuestLaunchSource = "public-default-experience"
|
||||
GuestIdentityProvider = "guest_device"
|
||||
GuestIdentityType = "guest"
|
||||
)
|
||||
|
||||
type PublicExperienceService struct {
|
||||
store *postgres.Store
|
||||
mapService *MapExperienceService
|
||||
eventService *EventService
|
||||
}
|
||||
|
||||
type PublicEventPlayInput struct {
|
||||
EventPublicID string
|
||||
}
|
||||
|
||||
type PublicLaunchEventInput struct {
|
||||
EventPublicID string `json:"-"`
|
||||
ReleaseID string `json:"releaseId,omitempty"`
|
||||
VariantID string `json:"variantId,omitempty"`
|
||||
ClientType string `json:"clientType"`
|
||||
DeviceKey string `json:"deviceKey"`
|
||||
}
|
||||
|
||||
func NewPublicExperienceService(store *postgres.Store, mapService *MapExperienceService, eventService *EventService) *PublicExperienceService {
|
||||
return &PublicExperienceService{
|
||||
store: store,
|
||||
mapService: mapService,
|
||||
eventService: eventService,
|
||||
}
|
||||
}
|
||||
|
||||
func (s *PublicExperienceService) ListMaps(ctx context.Context, input ListExperienceMapsInput) ([]ExperienceMapSummary, error) {
|
||||
return s.mapService.ListMaps(ctx, input)
|
||||
}
|
||||
|
||||
func (s *PublicExperienceService) GetMapDetail(ctx context.Context, mapPublicID string) (*ExperienceMapDetail, error) {
|
||||
return s.mapService.GetMapDetail(ctx, mapPublicID)
|
||||
}
|
||||
|
||||
func (s *PublicExperienceService) GetEventDetail(ctx context.Context, eventPublicID string) (*EventDetailResult, error) {
|
||||
event, err := s.store.GetEventByPublicID(ctx, strings.TrimSpace(eventPublicID))
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if err := ensurePublicExperienceEvent(event); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return s.eventService.GetEventDetail(ctx, eventPublicID)
|
||||
}
|
||||
|
||||
func (s *PublicExperienceService) GetEventPlay(ctx context.Context, input PublicEventPlayInput) (*EventPlayResult, error) {
|
||||
input.EventPublicID = strings.TrimSpace(input.EventPublicID)
|
||||
if input.EventPublicID == "" {
|
||||
return nil, apperr.New(http.StatusBadRequest, "invalid_params", "event id is required")
|
||||
}
|
||||
|
||||
event, err := s.store.GetEventByPublicID(ctx, input.EventPublicID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if err := ensurePublicExperienceEvent(event); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
result := &EventPlayResult{}
|
||||
result.Event.ID = event.PublicID
|
||||
result.Event.Slug = event.Slug
|
||||
result.Event.DisplayName = event.DisplayName
|
||||
result.Event.Summary = event.Summary
|
||||
result.Event.Status = event.Status
|
||||
variantPlan := resolveVariantPlan(event.ReleasePayloadJSON)
|
||||
result.Play.AssignmentMode = variantPlan.AssignmentMode
|
||||
if len(variantPlan.CourseVariants) > 0 {
|
||||
result.Play.CourseVariants = variantPlan.CourseVariants
|
||||
}
|
||||
if event.CurrentReleasePubID != nil && event.ConfigLabel != nil && event.ManifestURL != nil {
|
||||
result.Release = &struct {
|
||||
ID string `json:"id"`
|
||||
ConfigLabel string `json:"configLabel"`
|
||||
ManifestURL string `json:"manifestUrl"`
|
||||
ManifestChecksumSha256 *string `json:"manifestChecksumSha256,omitempty"`
|
||||
RouteCode *string `json:"routeCode,omitempty"`
|
||||
}{
|
||||
ID: *event.CurrentReleasePubID,
|
||||
ConfigLabel: *event.ConfigLabel,
|
||||
ManifestURL: *event.ManifestURL,
|
||||
ManifestChecksumSha256: event.ManifestChecksum,
|
||||
RouteCode: event.RouteCode,
|
||||
}
|
||||
}
|
||||
result.ResolvedRelease = buildResolvedReleaseFromEvent(event, GuestLaunchSource)
|
||||
result.Runtime = buildRuntimeSummaryFromEvent(event)
|
||||
if preview, err := buildPreviewFromPayload(event.ReleasePayloadJSON); err != nil {
|
||||
return nil, err
|
||||
} else {
|
||||
result.Preview = preview
|
||||
}
|
||||
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
|
||||
}
|
||||
|
||||
canLaunch, launchReason := evaluateEventLaunchReadiness(event)
|
||||
result.Play.CanLaunch = canLaunch
|
||||
if canLaunch {
|
||||
result.Play.LaunchSource = GuestLaunchSource
|
||||
result.Play.PrimaryAction = "start"
|
||||
result.Play.Reason = "guest can start default experience"
|
||||
return result, nil
|
||||
}
|
||||
result.Play.PrimaryAction = "unavailable"
|
||||
result.Play.Reason = launchReason
|
||||
return result, nil
|
||||
}
|
||||
|
||||
func (s *PublicExperienceService) LaunchEvent(ctx context.Context, input PublicLaunchEventInput) (*LaunchEventResult, error) {
|
||||
input.EventPublicID = strings.TrimSpace(input.EventPublicID)
|
||||
input.ReleaseID = strings.TrimSpace(input.ReleaseID)
|
||||
input.VariantID = strings.TrimSpace(input.VariantID)
|
||||
input.DeviceKey = strings.TrimSpace(input.DeviceKey)
|
||||
if input.EventPublicID == "" || input.DeviceKey == "" {
|
||||
return nil, apperr.New(http.StatusBadRequest, "invalid_params", "event id and deviceKey are required")
|
||||
}
|
||||
if err := validateClientType(input.ClientType); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
event, err := s.store.GetEventByPublicID(ctx, input.EventPublicID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if err := ensurePublicExperienceEvent(event); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if canLaunch, reason := evaluateEventLaunchReadiness(event); !canLaunch {
|
||||
return nil, launchReadinessError(reason)
|
||||
}
|
||||
if input.ReleaseID != "" && event.CurrentReleasePubID != nil && input.ReleaseID != *event.CurrentReleasePubID {
|
||||
return nil, apperr.New(http.StatusConflict, "release_not_launchable", "requested release is not the current published release")
|
||||
}
|
||||
|
||||
variantPlan := resolveVariantPlan(event.ReleasePayloadJSON)
|
||||
variant, err := resolveLaunchVariant(variantPlan, input.VariantID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
routeCode := event.RouteCode
|
||||
var assignmentMode *string
|
||||
var variantID *string
|
||||
var variantName *string
|
||||
if variant != nil {
|
||||
resultMode := variant.AssignmentMode
|
||||
assignmentMode = &resultMode
|
||||
variantID = &variant.ID
|
||||
variantName = &variant.Name
|
||||
if variant.RouteCode != nil {
|
||||
routeCode = variant.RouteCode
|
||||
}
|
||||
}
|
||||
|
||||
tx, err := s.store.Begin(ctx)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer tx.Rollback(ctx)
|
||||
|
||||
guestUser, err := s.findOrCreateGuestUser(ctx, tx, input.ClientType, input.DeviceKey)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if err := s.store.TouchUserLogin(ctx, tx, guestUser.ID); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
sessionPublicID, err := security.GeneratePublicID("sess")
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
sessionToken, err := security.GenerateToken(32)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
sessionTokenExpiresAt := time.Now().UTC().Add(2 * time.Hour)
|
||||
|
||||
session, err := s.store.CreateGameSession(ctx, tx, postgres.CreateGameSessionParams{
|
||||
SessionPublicID: sessionPublicID,
|
||||
UserID: guestUser.ID,
|
||||
EventID: event.ID,
|
||||
EventReleaseID: *event.CurrentReleaseID,
|
||||
DeviceKey: input.DeviceKey,
|
||||
ClientType: input.ClientType,
|
||||
AssignmentMode: assignmentMode,
|
||||
VariantID: variantID,
|
||||
VariantName: variantName,
|
||||
RouteCode: routeCode,
|
||||
SessionTokenHash: security.HashText(sessionToken),
|
||||
SessionTokenExpiresAt: sessionTokenExpiresAt,
|
||||
})
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
if err := tx.Commit(ctx); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
result := &LaunchEventResult{}
|
||||
result.Event.ID = event.PublicID
|
||||
result.Event.DisplayName = event.DisplayName
|
||||
result.Launch.Source = GuestLaunchSource
|
||||
result.Launch.ResolvedRelease = buildResolvedReleaseFromEvent(event, GuestLaunchSource)
|
||||
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
|
||||
result.Launch.Config.ReleaseID = *event.CurrentReleasePubID
|
||||
result.Launch.Config.RouteCode = routeCode
|
||||
result.Launch.Business.Source = GuestLaunchSource
|
||||
result.Launch.Business.EventID = event.PublicID
|
||||
result.Launch.Business.SessionID = session.SessionPublicID
|
||||
result.Launch.Business.SessionToken = sessionToken
|
||||
result.Launch.Business.SessionTokenExpiresAt = session.SessionTokenExpiresAt.Format(time.RFC3339)
|
||||
result.Launch.Business.RouteCode = routeCode
|
||||
result.Launch.Business.IsGuest = true
|
||||
return result, nil
|
||||
}
|
||||
|
||||
func (s *PublicExperienceService) findOrCreateGuestUser(ctx context.Context, tx postgres.Tx, clientType, deviceKey string) (*postgres.User, error) {
|
||||
providerSubject := clientType + ":" + deviceKey
|
||||
user, err := s.store.FindUserByProviderSubject(ctx, tx, GuestIdentityProvider, providerSubject)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if user != nil {
|
||||
return user, nil
|
||||
}
|
||||
userPublicID, err := security.GeneratePublicID("usr")
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
user, err = s.store.CreateUser(ctx, tx, postgres.CreateUserParams{
|
||||
PublicID: userPublicID,
|
||||
Status: "active",
|
||||
})
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if err := s.store.CreateIdentity(ctx, tx, postgres.CreateIdentityParams{
|
||||
UserID: user.ID,
|
||||
IdentityType: GuestIdentityType,
|
||||
Provider: GuestIdentityProvider,
|
||||
ProviderSubj: providerSubject,
|
||||
ProfileJSON: "{}",
|
||||
}); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return user, nil
|
||||
}
|
||||
|
||||
func ensurePublicExperienceEvent(event *postgres.Event) error {
|
||||
if event == nil {
|
||||
return apperr.New(http.StatusNotFound, "event_not_found", "event not found")
|
||||
}
|
||||
if !event.IsDefaultExperience {
|
||||
return apperr.New(http.StatusForbidden, "event_not_public", "event is not available in guest mode")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
135
backend/internal/store/postgres/asset_store.go
Normal file
135
backend/internal/store/postgres/asset_store.go
Normal file
@@ -0,0 +1,135 @@
|
||||
package postgres
|
||||
|
||||
import (
|
||||
"context"
|
||||
|
||||
"github.com/jackc/pgx/v5"
|
||||
)
|
||||
|
||||
type ManagedAssetRecord struct {
|
||||
ID string
|
||||
PublicID string
|
||||
AssetType string
|
||||
AssetCode string
|
||||
Version string
|
||||
Title *string
|
||||
SourceMode string
|
||||
StorageProvider string
|
||||
ObjectKey *string
|
||||
PublicURL string
|
||||
FileName *string
|
||||
ContentType *string
|
||||
FileSizeBytes *int64
|
||||
ChecksumSHA256 *string
|
||||
Status string
|
||||
MetadataJSONB map[string]any
|
||||
}
|
||||
|
||||
type CreateManagedAssetParams struct {
|
||||
PublicID string
|
||||
AssetType string
|
||||
AssetCode string
|
||||
Version string
|
||||
Title *string
|
||||
SourceMode string
|
||||
StorageProvider string
|
||||
ObjectKey *string
|
||||
PublicURL string
|
||||
FileName *string
|
||||
ContentType *string
|
||||
FileSizeBytes *int64
|
||||
ChecksumSHA256 *string
|
||||
Status string
|
||||
MetadataJSONB map[string]any
|
||||
}
|
||||
|
||||
func (s *Store) CreateManagedAsset(ctx context.Context, tx pgx.Tx, params CreateManagedAssetParams) (*ManagedAssetRecord, error) {
|
||||
row := tx.QueryRow(ctx, `
|
||||
INSERT INTO managed_assets (
|
||||
asset_public_id, asset_type, asset_code, version, title, source_mode, storage_provider,
|
||||
object_key, public_url, file_name, content_type, file_size_bytes, checksum_sha256, status, metadata_jsonb
|
||||
) VALUES (
|
||||
$1, $2, $3, $4, $5, $6, $7,
|
||||
$8, $9, $10, $11, $12, $13, $14, COALESCE($15, '{}'::jsonb)
|
||||
)
|
||||
RETURNING id, asset_public_id, asset_type, asset_code, version, title, source_mode, storage_provider,
|
||||
object_key, public_url, file_name, content_type, file_size_bytes, checksum_sha256, status, metadata_jsonb
|
||||
`,
|
||||
params.PublicID, params.AssetType, params.AssetCode, params.Version, params.Title, params.SourceMode, params.StorageProvider,
|
||||
params.ObjectKey, params.PublicURL, params.FileName, params.ContentType, params.FileSizeBytes, params.ChecksumSHA256, params.Status, params.MetadataJSONB,
|
||||
)
|
||||
return scanManagedAsset(row)
|
||||
}
|
||||
|
||||
func (s *Store) ListManagedAssets(ctx context.Context, limit int) ([]ManagedAssetRecord, error) {
|
||||
if limit <= 0 {
|
||||
limit = 20
|
||||
}
|
||||
rows, err := s.pool.Query(ctx, `
|
||||
SELECT id, asset_public_id, asset_type, asset_code, version, title, source_mode, storage_provider,
|
||||
object_key, public_url, file_name, content_type, file_size_bytes, checksum_sha256, status, metadata_jsonb
|
||||
FROM managed_assets
|
||||
ORDER BY created_at DESC
|
||||
LIMIT $1
|
||||
`, limit)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
var items []ManagedAssetRecord
|
||||
for rows.Next() {
|
||||
record, err := scanManagedAsset(rows)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
items = append(items, *record)
|
||||
}
|
||||
return items, rows.Err()
|
||||
}
|
||||
|
||||
func (s *Store) GetManagedAssetByPublicID(ctx context.Context, publicID string) (*ManagedAssetRecord, error) {
|
||||
row := s.pool.QueryRow(ctx, `
|
||||
SELECT id, asset_public_id, asset_type, asset_code, version, title, source_mode, storage_provider,
|
||||
object_key, public_url, file_name, content_type, file_size_bytes, checksum_sha256, status, metadata_jsonb
|
||||
FROM managed_assets
|
||||
WHERE asset_public_id = $1
|
||||
`, publicID)
|
||||
return scanManagedAsset(row)
|
||||
}
|
||||
|
||||
type managedAssetScanner interface {
|
||||
Scan(dest ...any) error
|
||||
}
|
||||
|
||||
func scanManagedAsset(scanner managedAssetScanner) (*ManagedAssetRecord, error) {
|
||||
var record ManagedAssetRecord
|
||||
err := scanner.Scan(
|
||||
&record.ID,
|
||||
&record.PublicID,
|
||||
&record.AssetType,
|
||||
&record.AssetCode,
|
||||
&record.Version,
|
||||
&record.Title,
|
||||
&record.SourceMode,
|
||||
&record.StorageProvider,
|
||||
&record.ObjectKey,
|
||||
&record.PublicURL,
|
||||
&record.FileName,
|
||||
&record.ContentType,
|
||||
&record.FileSizeBytes,
|
||||
&record.ChecksumSHA256,
|
||||
&record.Status,
|
||||
&record.MetadataJSONB,
|
||||
)
|
||||
if err != nil {
|
||||
if err == pgx.ErrNoRows {
|
||||
return nil, nil
|
||||
}
|
||||
return nil, err
|
||||
}
|
||||
if record.MetadataJSONB == nil {
|
||||
record.MetadataJSONB = map[string]any{}
|
||||
}
|
||||
return &record, nil
|
||||
}
|
||||
@@ -16,6 +16,7 @@ type Card struct {
|
||||
DisplaySlot string
|
||||
DisplayPriority int
|
||||
IsDefaultExperience bool
|
||||
ShowInEventList bool
|
||||
StartsAt *time.Time
|
||||
EndsAt *time.Time
|
||||
EntryChannelID *string
|
||||
@@ -59,6 +60,7 @@ func (s *Store) ListCardsForEntry(ctx context.Context, tenantID string, entryCha
|
||||
c.display_slot,
|
||||
c.display_priority,
|
||||
c.is_default_experience,
|
||||
COALESCE(e.show_in_event_list, true),
|
||||
c.starts_at,
|
||||
c.ends_at,
|
||||
c.entry_channel_id,
|
||||
@@ -117,6 +119,7 @@ func (s *Store) ListCardsForEntry(ctx context.Context, tenantID string, entryCha
|
||||
&card.DisplaySlot,
|
||||
&card.DisplayPriority,
|
||||
&card.IsDefaultExperience,
|
||||
&card.ShowInEventList,
|
||||
&card.StartsAt,
|
||||
&card.EndsAt,
|
||||
&card.EntryChannelID,
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -16,6 +16,8 @@ type Event struct {
|
||||
DisplayName string
|
||||
Summary *string
|
||||
Status string
|
||||
IsDefaultExperience bool
|
||||
ShowInEventList bool
|
||||
CurrentReleaseID *string
|
||||
CurrentReleasePubID *string
|
||||
ConfigLabel *string
|
||||
@@ -113,6 +115,8 @@ func (s *Store) GetEventByPublicID(ctx context.Context, eventPublicID string) (*
|
||||
e.display_name,
|
||||
e.summary,
|
||||
e.status,
|
||||
e.is_default_experience,
|
||||
e.show_in_event_list,
|
||||
e.current_release_id,
|
||||
er.release_public_id,
|
||||
er.config_label,
|
||||
@@ -159,6 +163,8 @@ func (s *Store) GetEventByPublicID(ctx context.Context, eventPublicID string) (*
|
||||
&event.DisplayName,
|
||||
&event.Summary,
|
||||
&event.Status,
|
||||
&event.IsDefaultExperience,
|
||||
&event.ShowInEventList,
|
||||
&event.CurrentReleaseID,
|
||||
&event.CurrentReleasePubID,
|
||||
&event.ConfigLabel,
|
||||
@@ -202,6 +208,8 @@ func (s *Store) GetEventByID(ctx context.Context, eventID string) (*Event, error
|
||||
e.display_name,
|
||||
e.summary,
|
||||
e.status,
|
||||
e.is_default_experience,
|
||||
e.show_in_event_list,
|
||||
e.current_release_id,
|
||||
er.release_public_id,
|
||||
er.config_label,
|
||||
@@ -248,6 +256,8 @@ func (s *Store) GetEventByID(ctx context.Context, eventID string) (*Event, error
|
||||
&event.DisplayName,
|
||||
&event.Summary,
|
||||
&event.Status,
|
||||
&event.IsDefaultExperience,
|
||||
&event.ShowInEventList,
|
||||
&event.CurrentReleaseID,
|
||||
&event.CurrentReleasePubID,
|
||||
&event.ConfigLabel,
|
||||
@@ -601,3 +611,16 @@ func (s *Store) SetEventReleaseRuntimeBinding(ctx context.Context, tx Tx, releas
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *Store) SetEventReleaseBindings(ctx context.Context, tx Tx, releaseID string, runtimeBindingID, presentationID, contentBundleID *string) error {
|
||||
if _, err := tx.Exec(ctx, `
|
||||
UPDATE event_releases
|
||||
SET runtime_binding_id = $2,
|
||||
presentation_id = $3,
|
||||
content_bundle_id = $4
|
||||
WHERE id = $1
|
||||
`, releaseID, runtimeBindingID, presentationID, contentBundleID); err != nil {
|
||||
return fmt.Errorf("set event release bindings: %w", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
216
backend/internal/store/postgres/map_experience_store.go
Normal file
216
backend/internal/store/postgres/map_experience_store.go
Normal file
@@ -0,0 +1,216 @@
|
||||
package postgres
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
)
|
||||
|
||||
type MapExperienceRow struct {
|
||||
PlacePublicID string
|
||||
PlaceName string
|
||||
MapAssetPublicID string
|
||||
MapAssetName string
|
||||
MapCoverURL *string
|
||||
MapSummary *string
|
||||
TileBaseURL *string
|
||||
TileMetaURL *string
|
||||
EventPublicID *string
|
||||
EventDisplayName *string
|
||||
EventSummary *string
|
||||
EventStatus *string
|
||||
EventIsDefaultExperience bool
|
||||
EventShowInEventList bool
|
||||
EventReleasePayloadJSON *string
|
||||
EventPresentationID *string
|
||||
EventPresentationName *string
|
||||
EventPresentationType *string
|
||||
EventPresentationSchema *string
|
||||
EventContentBundleID *string
|
||||
EventContentBundleName *string
|
||||
EventContentEntryURL *string
|
||||
EventContentAssetRootURL *string
|
||||
EventContentMetadataJSON *string
|
||||
}
|
||||
|
||||
func (s *Store) ListMapExperienceRows(ctx context.Context, limit int) ([]MapExperienceRow, error) {
|
||||
if limit <= 0 || limit > 200 {
|
||||
limit = 50
|
||||
}
|
||||
rows, err := s.pool.Query(ctx, `
|
||||
SELECT
|
||||
p.place_public_id,
|
||||
p.name,
|
||||
ma.map_asset_public_id,
|
||||
ma.name,
|
||||
COALESCE(ma.cover_url, p.cover_url) AS cover_url,
|
||||
COALESCE(ma.description, p.description) AS summary,
|
||||
tr.tile_base_url,
|
||||
tr.meta_url,
|
||||
e.event_public_id,
|
||||
e.display_name,
|
||||
e.summary,
|
||||
e.status,
|
||||
COALESCE(e.is_default_experience, false),
|
||||
COALESCE(e.show_in_event_list, true),
|
||||
er.payload_jsonb::text,
|
||||
ep.presentation_public_id,
|
||||
ep.name,
|
||||
ep.presentation_type,
|
||||
ep.schema_jsonb::text,
|
||||
cb.content_bundle_public_id,
|
||||
cb.name,
|
||||
cb.entry_url,
|
||||
cb.asset_root_url,
|
||||
cb.metadata_jsonb::text
|
||||
FROM map_assets ma
|
||||
JOIN places p ON p.id = ma.place_id
|
||||
LEFT JOIN tile_releases tr ON tr.id = ma.current_tile_release_id
|
||||
LEFT JOIN map_runtime_bindings mrb
|
||||
ON mrb.map_asset_id = ma.id
|
||||
AND mrb.status = 'active'
|
||||
LEFT JOIN event_releases er
|
||||
ON er.runtime_binding_id = mrb.id
|
||||
AND er.status = 'published'
|
||||
LEFT JOIN events e
|
||||
ON e.current_release_id = er.id
|
||||
AND e.status = 'active'
|
||||
LEFT JOIN event_presentations ep ON ep.id = er.presentation_id
|
||||
LEFT JOIN content_bundles cb ON cb.id = er.content_bundle_id
|
||||
WHERE ma.status = 'active'
|
||||
AND (e.id IS NULL OR e.show_in_event_list = true)
|
||||
ORDER BY p.name ASC, ma.name ASC, COALESCE(e.is_default_experience, false) DESC, e.display_name ASC
|
||||
LIMIT $1
|
||||
`, limit)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("list map experience rows: %w", err)
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
items := make([]MapExperienceRow, 0, limit)
|
||||
for rows.Next() {
|
||||
var item MapExperienceRow
|
||||
if err := rows.Scan(
|
||||
&item.PlacePublicID,
|
||||
&item.PlaceName,
|
||||
&item.MapAssetPublicID,
|
||||
&item.MapAssetName,
|
||||
&item.MapCoverURL,
|
||||
&item.MapSummary,
|
||||
&item.TileBaseURL,
|
||||
&item.TileMetaURL,
|
||||
&item.EventPublicID,
|
||||
&item.EventDisplayName,
|
||||
&item.EventSummary,
|
||||
&item.EventStatus,
|
||||
&item.EventIsDefaultExperience,
|
||||
&item.EventShowInEventList,
|
||||
&item.EventReleasePayloadJSON,
|
||||
&item.EventPresentationID,
|
||||
&item.EventPresentationName,
|
||||
&item.EventPresentationType,
|
||||
&item.EventPresentationSchema,
|
||||
&item.EventContentBundleID,
|
||||
&item.EventContentBundleName,
|
||||
&item.EventContentEntryURL,
|
||||
&item.EventContentAssetRootURL,
|
||||
&item.EventContentMetadataJSON,
|
||||
); err != nil {
|
||||
return nil, fmt.Errorf("scan map experience row: %w", err)
|
||||
}
|
||||
items = append(items, item)
|
||||
}
|
||||
if err := rows.Err(); err != nil {
|
||||
return nil, fmt.Errorf("iterate map experience rows: %w", err)
|
||||
}
|
||||
return items, nil
|
||||
}
|
||||
|
||||
func (s *Store) ListMapExperienceRowsByMapPublicID(ctx context.Context, mapAssetPublicID string) ([]MapExperienceRow, error) {
|
||||
rows, err := s.pool.Query(ctx, `
|
||||
SELECT
|
||||
p.place_public_id,
|
||||
p.name,
|
||||
ma.map_asset_public_id,
|
||||
ma.name,
|
||||
COALESCE(ma.cover_url, p.cover_url) AS cover_url,
|
||||
COALESCE(ma.description, p.description) AS summary,
|
||||
tr.tile_base_url,
|
||||
tr.meta_url,
|
||||
e.event_public_id,
|
||||
e.display_name,
|
||||
e.summary,
|
||||
e.status,
|
||||
COALESCE(e.is_default_experience, false),
|
||||
COALESCE(e.show_in_event_list, true),
|
||||
er.payload_jsonb::text,
|
||||
ep.presentation_public_id,
|
||||
ep.name,
|
||||
ep.presentation_type,
|
||||
ep.schema_jsonb::text,
|
||||
cb.content_bundle_public_id,
|
||||
cb.name,
|
||||
cb.entry_url,
|
||||
cb.asset_root_url,
|
||||
cb.metadata_jsonb::text
|
||||
FROM map_assets ma
|
||||
JOIN places p ON p.id = ma.place_id
|
||||
LEFT JOIN tile_releases tr ON tr.id = ma.current_tile_release_id
|
||||
LEFT JOIN map_runtime_bindings mrb
|
||||
ON mrb.map_asset_id = ma.id
|
||||
AND mrb.status = 'active'
|
||||
LEFT JOIN event_releases er
|
||||
ON er.runtime_binding_id = mrb.id
|
||||
AND er.status = 'published'
|
||||
LEFT JOIN events e
|
||||
ON e.current_release_id = er.id
|
||||
AND e.status = 'active'
|
||||
LEFT JOIN event_presentations ep ON ep.id = er.presentation_id
|
||||
LEFT JOIN content_bundles cb ON cb.id = er.content_bundle_id
|
||||
WHERE ma.map_asset_public_id = $1
|
||||
AND ma.status = 'active'
|
||||
AND (e.id IS NULL OR e.show_in_event_list = true)
|
||||
ORDER BY COALESCE(e.is_default_experience, false) DESC, e.display_name ASC
|
||||
`, mapAssetPublicID)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("list map experience rows by map public id: %w", err)
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
items := make([]MapExperienceRow, 0, 8)
|
||||
for rows.Next() {
|
||||
var item MapExperienceRow
|
||||
if err := rows.Scan(
|
||||
&item.PlacePublicID,
|
||||
&item.PlaceName,
|
||||
&item.MapAssetPublicID,
|
||||
&item.MapAssetName,
|
||||
&item.MapCoverURL,
|
||||
&item.MapSummary,
|
||||
&item.TileBaseURL,
|
||||
&item.TileMetaURL,
|
||||
&item.EventPublicID,
|
||||
&item.EventDisplayName,
|
||||
&item.EventSummary,
|
||||
&item.EventStatus,
|
||||
&item.EventIsDefaultExperience,
|
||||
&item.EventShowInEventList,
|
||||
&item.EventReleasePayloadJSON,
|
||||
&item.EventPresentationID,
|
||||
&item.EventPresentationName,
|
||||
&item.EventPresentationType,
|
||||
&item.EventPresentationSchema,
|
||||
&item.EventContentBundleID,
|
||||
&item.EventContentBundleName,
|
||||
&item.EventContentEntryURL,
|
||||
&item.EventContentAssetRootURL,
|
||||
&item.EventContentMetadataJSON,
|
||||
); err != nil {
|
||||
return nil, fmt.Errorf("scan map experience detail row: %w", err)
|
||||
}
|
||||
items = append(items, item)
|
||||
}
|
||||
if err := rows.Err(); err != nil {
|
||||
return nil, fmt.Errorf("iterate map experience detail rows: %w", err)
|
||||
}
|
||||
return items, nil
|
||||
}
|
||||
217
backend/internal/store/postgres/ops_auth_store.go
Normal file
217
backend/internal/store/postgres/ops_auth_store.go
Normal file
@@ -0,0 +1,217 @@
|
||||
package postgres
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"time"
|
||||
|
||||
"github.com/jackc/pgx/v5"
|
||||
)
|
||||
|
||||
type OpsUser struct {
|
||||
ID string
|
||||
PublicID string
|
||||
CountryCode string
|
||||
Mobile string
|
||||
DisplayName string
|
||||
Status string
|
||||
LastLoginAt *time.Time
|
||||
}
|
||||
|
||||
type OpsRole struct {
|
||||
ID string
|
||||
RoleCode string
|
||||
DisplayName string
|
||||
RoleRank int
|
||||
}
|
||||
|
||||
type CreateOpsUserParams struct {
|
||||
PublicID string
|
||||
CountryCode string
|
||||
Mobile string
|
||||
DisplayName string
|
||||
Status string
|
||||
}
|
||||
|
||||
type CreateOpsRefreshTokenParams struct {
|
||||
OpsUserID string
|
||||
DeviceKey string
|
||||
TokenHash string
|
||||
ExpiresAt time.Time
|
||||
}
|
||||
|
||||
type OpsRefreshTokenRecord struct {
|
||||
ID string
|
||||
OpsUserID string
|
||||
DeviceKey *string
|
||||
ExpiresAt time.Time
|
||||
IsRevoked bool
|
||||
}
|
||||
|
||||
func (s *Store) CountOpsUsers(ctx context.Context) (int, error) {
|
||||
row := s.pool.QueryRow(ctx, `SELECT COUNT(*) FROM ops_users WHERE status <> 'deleted'`)
|
||||
var count int
|
||||
if err := row.Scan(&count); err != nil {
|
||||
return 0, fmt.Errorf("count ops users: %w", err)
|
||||
}
|
||||
return count, nil
|
||||
}
|
||||
|
||||
func (s *Store) GetOpsUserByMobile(ctx context.Context, db queryRower, countryCode, mobile string) (*OpsUser, error) {
|
||||
row := db.QueryRow(ctx, `
|
||||
SELECT id, ops_user_public_id, country_code, mobile, display_name, status, last_login_at
|
||||
FROM ops_users
|
||||
WHERE country_code = $1 AND mobile = $2
|
||||
LIMIT 1
|
||||
`, countryCode, mobile)
|
||||
return scanOpsUser(row)
|
||||
}
|
||||
|
||||
func (s *Store) GetOpsUserByID(ctx context.Context, db queryRower, opsUserID string) (*OpsUser, error) {
|
||||
row := db.QueryRow(ctx, `
|
||||
SELECT id, ops_user_public_id, country_code, mobile, display_name, status, last_login_at
|
||||
FROM ops_users
|
||||
WHERE id = $1
|
||||
`, opsUserID)
|
||||
return scanOpsUser(row)
|
||||
}
|
||||
|
||||
func (s *Store) CreateOpsUser(ctx context.Context, tx Tx, params CreateOpsUserParams) (*OpsUser, error) {
|
||||
row := tx.QueryRow(ctx, `
|
||||
INSERT INTO ops_users (ops_user_public_id, country_code, mobile, display_name, status)
|
||||
VALUES ($1, $2, $3, $4, $5)
|
||||
RETURNING id, ops_user_public_id, country_code, mobile, display_name, status, last_login_at
|
||||
`, params.PublicID, params.CountryCode, params.Mobile, params.DisplayName, params.Status)
|
||||
return scanOpsUser(row)
|
||||
}
|
||||
|
||||
func (s *Store) TouchOpsUserLogin(ctx context.Context, tx Tx, opsUserID string) error {
|
||||
_, err := tx.Exec(ctx, `
|
||||
UPDATE ops_users
|
||||
SET last_login_at = NOW()
|
||||
WHERE id = $1
|
||||
`, opsUserID)
|
||||
if err != nil {
|
||||
return fmt.Errorf("touch ops user last login: %w", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *Store) GetOpsRoleByCode(ctx context.Context, tx Tx, roleCode string) (*OpsRole, error) {
|
||||
row := tx.QueryRow(ctx, `
|
||||
SELECT id, role_code, display_name, role_rank
|
||||
FROM ops_roles
|
||||
WHERE role_code = $1
|
||||
AND status = 'active'
|
||||
LIMIT 1
|
||||
`, roleCode)
|
||||
return scanOpsRole(row)
|
||||
}
|
||||
|
||||
func (s *Store) AssignOpsRole(ctx context.Context, tx Tx, opsUserID, roleID string) error {
|
||||
_, err := tx.Exec(ctx, `
|
||||
INSERT INTO ops_user_roles (ops_user_id, ops_role_id, status)
|
||||
VALUES ($1, $2, 'active')
|
||||
ON CONFLICT (ops_user_id, ops_role_id) DO NOTHING
|
||||
`, opsUserID, roleID)
|
||||
if err != nil {
|
||||
return fmt.Errorf("assign ops role: %w", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *Store) GetPrimaryOpsRole(ctx context.Context, db queryRower, opsUserID string) (*OpsRole, error) {
|
||||
row := db.QueryRow(ctx, `
|
||||
SELECT r.id, r.role_code, r.display_name, r.role_rank
|
||||
FROM ops_user_roles ur
|
||||
JOIN ops_roles r ON r.id = ur.ops_role_id
|
||||
WHERE ur.ops_user_id = $1
|
||||
AND ur.status = 'active'
|
||||
AND r.status = 'active'
|
||||
ORDER BY r.role_rank DESC, r.created_at ASC
|
||||
LIMIT 1
|
||||
`, opsUserID)
|
||||
return scanOpsRole(row)
|
||||
}
|
||||
|
||||
func (s *Store) CreateOpsRefreshToken(ctx context.Context, tx Tx, params CreateOpsRefreshTokenParams) (string, error) {
|
||||
row := tx.QueryRow(ctx, `
|
||||
INSERT INTO ops_refresh_tokens (ops_user_id, device_key, token_hash, expires_at)
|
||||
VALUES ($1, NULLIF($2, ''), $3, $4)
|
||||
RETURNING id
|
||||
`, params.OpsUserID, params.DeviceKey, params.TokenHash, params.ExpiresAt)
|
||||
|
||||
var id string
|
||||
if err := row.Scan(&id); err != nil {
|
||||
return "", fmt.Errorf("create ops refresh token: %w", err)
|
||||
}
|
||||
return id, nil
|
||||
}
|
||||
|
||||
func (s *Store) GetOpsRefreshTokenForUpdate(ctx context.Context, tx Tx, tokenHash string) (*OpsRefreshTokenRecord, error) {
|
||||
row := tx.QueryRow(ctx, `
|
||||
SELECT id, ops_user_id, device_key, expires_at, revoked_at IS NOT NULL
|
||||
FROM ops_refresh_tokens
|
||||
WHERE token_hash = $1
|
||||
FOR UPDATE
|
||||
`, tokenHash)
|
||||
|
||||
var record OpsRefreshTokenRecord
|
||||
err := row.Scan(&record.ID, &record.OpsUserID, &record.DeviceKey, &record.ExpiresAt, &record.IsRevoked)
|
||||
if errors.Is(err, pgx.ErrNoRows) {
|
||||
return nil, nil
|
||||
}
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("query ops refresh token for update: %w", err)
|
||||
}
|
||||
return &record, nil
|
||||
}
|
||||
|
||||
func (s *Store) RotateOpsRefreshToken(ctx context.Context, tx Tx, oldTokenID, newTokenID string) error {
|
||||
_, err := tx.Exec(ctx, `
|
||||
UPDATE ops_refresh_tokens
|
||||
SET revoked_at = NOW(), replaced_by_token_id = $2
|
||||
WHERE id = $1
|
||||
`, oldTokenID, newTokenID)
|
||||
if err != nil {
|
||||
return fmt.Errorf("rotate ops refresh token: %w", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *Store) RevokeOpsRefreshToken(ctx context.Context, tokenHash string) error {
|
||||
_, err := s.pool.Exec(ctx, `
|
||||
UPDATE ops_refresh_tokens
|
||||
SET revoked_at = COALESCE(revoked_at, NOW())
|
||||
WHERE token_hash = $1
|
||||
`, tokenHash)
|
||||
if err != nil {
|
||||
return fmt.Errorf("revoke ops refresh token: %w", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func scanOpsUser(row pgx.Row) (*OpsUser, error) {
|
||||
var item OpsUser
|
||||
err := row.Scan(&item.ID, &item.PublicID, &item.CountryCode, &item.Mobile, &item.DisplayName, &item.Status, &item.LastLoginAt)
|
||||
if errors.Is(err, pgx.ErrNoRows) {
|
||||
return nil, nil
|
||||
}
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("scan ops user: %w", err)
|
||||
}
|
||||
return &item, nil
|
||||
}
|
||||
|
||||
func scanOpsRole(row pgx.Row) (*OpsRole, error) {
|
||||
var item OpsRole
|
||||
err := row.Scan(&item.ID, &item.RoleCode, &item.DisplayName, &item.RoleRank)
|
||||
if errors.Is(err, pgx.ErrNoRows) {
|
||||
return nil, nil
|
||||
}
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("scan ops role: %w", err)
|
||||
}
|
||||
return &item, nil
|
||||
}
|
||||
66
backend/internal/store/postgres/ops_summary_store.go
Normal file
66
backend/internal/store/postgres/ops_summary_store.go
Normal file
@@ -0,0 +1,66 @@
|
||||
package postgres
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
)
|
||||
|
||||
type OpsOverviewCounts struct {
|
||||
ManagedAssets int
|
||||
Places int
|
||||
MapAssets int
|
||||
TileReleases int
|
||||
CourseSets int
|
||||
CourseVariants int
|
||||
Events int
|
||||
DefaultEvents int
|
||||
PublishedEvents int
|
||||
ConfigSources int
|
||||
Releases int
|
||||
RuntimeBindings int
|
||||
Presentations int
|
||||
ContentBundles int
|
||||
OpsUsers int
|
||||
}
|
||||
|
||||
func (s *Store) GetOpsOverviewCounts(ctx context.Context) (*OpsOverviewCounts, error) {
|
||||
row := s.pool.QueryRow(ctx, `
|
||||
SELECT
|
||||
(SELECT COUNT(*) FROM managed_assets WHERE status <> 'archived') AS managed_assets,
|
||||
(SELECT COUNT(*) FROM places WHERE status <> 'archived') AS places,
|
||||
(SELECT COUNT(*) FROM map_assets WHERE status <> 'archived') AS map_assets,
|
||||
(SELECT COUNT(*) FROM tile_releases WHERE status <> 'archived') AS tile_releases,
|
||||
(SELECT COUNT(*) FROM course_sets WHERE status <> 'archived') AS course_sets,
|
||||
(SELECT COUNT(*) FROM course_variants WHERE status <> 'archived') AS course_variants,
|
||||
(SELECT COUNT(*) FROM events WHERE status <> 'archived') AS events,
|
||||
(SELECT COUNT(*) FROM events WHERE status <> 'archived' AND COALESCE(is_default_experience, false)) AS default_events,
|
||||
(SELECT COUNT(*) FROM events WHERE status <> 'archived' AND current_release_id IS NOT NULL) AS published_events,
|
||||
(SELECT COUNT(*) FROM event_config_sources) AS config_sources,
|
||||
(SELECT COUNT(*) FROM event_releases) AS releases,
|
||||
(SELECT COUNT(*) FROM map_runtime_bindings WHERE status <> 'archived') AS runtime_bindings,
|
||||
(SELECT COUNT(*) FROM event_presentations WHERE status <> 'archived') AS presentations,
|
||||
(SELECT COUNT(*) FROM content_bundles WHERE status <> 'archived') AS content_bundles,
|
||||
(SELECT COUNT(*) FROM ops_users WHERE status <> 'deleted') AS ops_users
|
||||
`)
|
||||
var item OpsOverviewCounts
|
||||
if err := row.Scan(
|
||||
&item.ManagedAssets,
|
||||
&item.Places,
|
||||
&item.MapAssets,
|
||||
&item.TileReleases,
|
||||
&item.CourseSets,
|
||||
&item.CourseVariants,
|
||||
&item.Events,
|
||||
&item.DefaultEvents,
|
||||
&item.PublishedEvents,
|
||||
&item.ConfigSources,
|
||||
&item.Releases,
|
||||
&item.RuntimeBindings,
|
||||
&item.Presentations,
|
||||
&item.ContentBundles,
|
||||
&item.OpsUsers,
|
||||
); err != nil {
|
||||
return nil, fmt.Errorf("get ops overview counts: %w", err)
|
||||
}
|
||||
return &item, nil
|
||||
}
|
||||
@@ -28,6 +28,8 @@ type MapAsset struct {
|
||||
ID string
|
||||
PublicID string
|
||||
PlaceID string
|
||||
PlacePublicID *string
|
||||
PlaceName *string
|
||||
LegacyMapID *string
|
||||
LegacyMapPublicID *string
|
||||
Code string
|
||||
@@ -152,6 +154,16 @@ type CreateMapAssetParams struct {
|
||||
Status string
|
||||
}
|
||||
|
||||
type UpdateMapAssetParams struct {
|
||||
MapAssetID string
|
||||
Code string
|
||||
Name string
|
||||
MapType string
|
||||
CoverURL *string
|
||||
Description *string
|
||||
Status string
|
||||
}
|
||||
|
||||
type CreateTileReleaseParams struct {
|
||||
PublicID string
|
||||
MapAssetID string
|
||||
@@ -215,6 +227,53 @@ type CreateMapRuntimeBindingParams struct {
|
||||
Notes *string
|
||||
}
|
||||
|
||||
type MapAssetLinkedEvent struct {
|
||||
EventPublicID string
|
||||
DisplayName string
|
||||
Summary *string
|
||||
Status string
|
||||
IsDefaultExperience bool
|
||||
ShowInEventList bool
|
||||
CurrentReleasePublicID *string
|
||||
ConfigLabel *string
|
||||
RouteCode *string
|
||||
CurrentPresentationID *string
|
||||
CurrentPresentationName *string
|
||||
CurrentContentBundleID *string
|
||||
CurrentContentBundleName *string
|
||||
}
|
||||
|
||||
func (s *Store) ListMapAssets(ctx context.Context, limit int) ([]MapAsset, error) {
|
||||
if limit <= 0 || limit > 200 {
|
||||
limit = 50
|
||||
}
|
||||
rows, err := s.pool.Query(ctx, `
|
||||
SELECT ma.id, ma.map_asset_public_id, ma.place_id, p.place_public_id, p.name, 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
|
||||
JOIN places p ON p.id = ma.place_id
|
||||
LEFT JOIN maps lm ON lm.id = ma.legacy_map_id
|
||||
ORDER BY ma.created_at DESC
|
||||
LIMIT $1
|
||||
`, limit)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("list all 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 all map assets: %w", err)
|
||||
}
|
||||
return items, nil
|
||||
}
|
||||
|
||||
func (s *Store) ListPlaces(ctx context.Context, limit int) ([]Place, error) {
|
||||
if limit <= 0 || limit > 200 {
|
||||
limit = 50
|
||||
@@ -253,6 +312,16 @@ func (s *Store) GetPlaceByPublicID(ctx context.Context, publicID string) (*Place
|
||||
return scanPlace(row)
|
||||
}
|
||||
|
||||
func (s *Store) GetPlaceByCode(ctx context.Context, code 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 code = $1
|
||||
LIMIT 1
|
||||
`, code)
|
||||
return scanPlace(row)
|
||||
}
|
||||
|
||||
func (s *Store) CreatePlace(ctx context.Context, tx Tx, params CreatePlaceParams) (*Place, error) {
|
||||
centerPointJSON, err := marshalJSONMap(params.CenterPoint)
|
||||
if err != nil {
|
||||
@@ -268,9 +337,10 @@ func (s *Store) CreatePlace(ctx context.Context, tx Tx, params CreatePlaceParams
|
||||
|
||||
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,
|
||||
SELECT ma.id, ma.map_asset_public_id, ma.place_id, p.place_public_id, p.name, 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
|
||||
JOIN places p ON p.id = ma.place_id
|
||||
LEFT JOIN maps lm ON lm.id = ma.legacy_map_id
|
||||
WHERE ma.place_id = $1
|
||||
ORDER BY ma.created_at DESC
|
||||
@@ -295,9 +365,10 @@ func (s *Store) ListMapAssetsByPlaceID(ctx context.Context, placeID string) ([]M
|
||||
|
||||
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,
|
||||
SELECT ma.id, ma.map_asset_public_id, ma.place_id, p.place_public_id, p.name, 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
|
||||
JOIN places p ON p.id = ma.place_id
|
||||
LEFT JOIN maps lm ON lm.id = ma.legacy_map_id
|
||||
WHERE ma.map_asset_public_id = $1
|
||||
LIMIT 1
|
||||
@@ -305,15 +376,44 @@ func (s *Store) GetMapAssetByPublicID(ctx context.Context, publicID string) (*Ma
|
||||
return scanMapAsset(row)
|
||||
}
|
||||
|
||||
func (s *Store) GetMapAssetByCode(ctx context.Context, code string) (*MapAsset, error) {
|
||||
row := s.pool.QueryRow(ctx, `
|
||||
SELECT ma.id, ma.map_asset_public_id, ma.place_id, p.place_public_id, p.name, 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
|
||||
JOIN places p ON p.id = ma.place_id
|
||||
LEFT JOIN maps lm ON lm.id = ma.legacy_map_id
|
||||
WHERE ma.code = $1
|
||||
LIMIT 1
|
||||
`, code)
|
||||
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
|
||||
RETURNING id, map_asset_public_id, place_id, NULL::text, NULL::text, 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) UpdateMapAsset(ctx context.Context, tx Tx, params UpdateMapAssetParams) (*MapAsset, error) {
|
||||
row := tx.QueryRow(ctx, `
|
||||
UPDATE map_assets
|
||||
SET code = $2,
|
||||
name = $3,
|
||||
map_type = $4,
|
||||
cover_url = $5,
|
||||
description = $6,
|
||||
status = $7,
|
||||
updated_at = NOW()
|
||||
WHERE id = $1
|
||||
RETURNING id, map_asset_public_id, place_id, NULL::text, NULL::text, legacy_map_id, NULL::text, code, name, map_type, cover_url, description, status, current_tile_release_id, created_at, updated_at
|
||||
`, params.MapAssetID, 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,
|
||||
@@ -355,6 +455,19 @@ func (s *Store) GetTileReleaseByPublicID(ctx context.Context, publicID string) (
|
||||
return scanTileRelease(row)
|
||||
}
|
||||
|
||||
func (s *Store) GetTileReleaseByMapAssetIDAndVersionCode(ctx context.Context, mapAssetID, versionCode 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.map_asset_id = $1 AND tr.version_code = $2
|
||||
LIMIT 1
|
||||
`, mapAssetID, versionCode)
|
||||
return scanTileRelease(row)
|
||||
}
|
||||
|
||||
func (s *Store) CreateTileRelease(ctx context.Context, tx Tx, params CreateTileReleaseParams) (*TileRelease, error) {
|
||||
metadataJSON, err := marshalJSONMap(params.MetadataJSON)
|
||||
if err != nil {
|
||||
@@ -506,6 +619,16 @@ func (s *Store) GetCourseSetByPublicID(ctx context.Context, publicID string) (*C
|
||||
return scanCourseSet(row)
|
||||
}
|
||||
|
||||
func (s *Store) GetCourseSetByCode(ctx context.Context, code 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 code = $1
|
||||
LIMIT 1
|
||||
`, code)
|
||||
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)
|
||||
@@ -556,6 +679,19 @@ func (s *Store) GetCourseVariantByPublicID(ctx context.Context, publicID string)
|
||||
return scanCourseVariant(row)
|
||||
}
|
||||
|
||||
func (s *Store) GetCourseVariantByCourseSetIDAndRouteCode(ctx context.Context, courseSetID, routeCode 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_set_id = $1 AND cv.route_code = $2
|
||||
LIMIT 1
|
||||
`, courseSetID, routeCode)
|
||||
return scanCourseVariant(row)
|
||||
}
|
||||
|
||||
func (s *Store) CreateCourseVariant(ctx context.Context, tx Tx, params CreateCourseVariantParams) (*CourseVariant, error) {
|
||||
configPatchJSON, err := marshalJSONMap(params.ConfigPatch)
|
||||
if err != nil {
|
||||
@@ -641,6 +777,66 @@ func (s *Store) GetMapRuntimeBindingByPublicID(ctx context.Context, publicID str
|
||||
return scanMapRuntimeBinding(row)
|
||||
}
|
||||
|
||||
func (s *Store) ListMapAssetLinkedEvents(ctx context.Context, mapAssetID string, limit int) ([]MapAssetLinkedEvent, error) {
|
||||
if limit <= 0 || limit > 200 {
|
||||
limit = 50
|
||||
}
|
||||
rows, err := s.pool.Query(ctx, `
|
||||
SELECT
|
||||
e.event_public_id,
|
||||
e.display_name,
|
||||
e.summary,
|
||||
e.status,
|
||||
COALESCE(e.is_default_experience, false),
|
||||
COALESCE(e.show_in_event_list, true),
|
||||
er.release_public_id,
|
||||
er.config_label,
|
||||
er.route_code,
|
||||
ep.presentation_public_id,
|
||||
ep.name,
|
||||
cb.content_bundle_public_id,
|
||||
cb.name
|
||||
FROM events e
|
||||
JOIN map_runtime_bindings mrb ON mrb.id = e.current_runtime_binding_id
|
||||
LEFT JOIN event_releases er ON er.id = e.current_release_id
|
||||
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
|
||||
WHERE mrb.map_asset_id = $1
|
||||
ORDER BY COALESCE(e.is_default_experience, false) DESC, e.display_name ASC
|
||||
LIMIT $2
|
||||
`, mapAssetID, limit)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("list map asset linked events: %w", err)
|
||||
}
|
||||
defer rows.Close()
|
||||
items := []MapAssetLinkedEvent{}
|
||||
for rows.Next() {
|
||||
var item MapAssetLinkedEvent
|
||||
if err := rows.Scan(
|
||||
&item.EventPublicID,
|
||||
&item.DisplayName,
|
||||
&item.Summary,
|
||||
&item.Status,
|
||||
&item.IsDefaultExperience,
|
||||
&item.ShowInEventList,
|
||||
&item.CurrentReleasePublicID,
|
||||
&item.ConfigLabel,
|
||||
&item.RouteCode,
|
||||
&item.CurrentPresentationID,
|
||||
&item.CurrentPresentationName,
|
||||
&item.CurrentContentBundleID,
|
||||
&item.CurrentContentBundleName,
|
||||
); err != nil {
|
||||
return nil, fmt.Errorf("scan map asset linked event: %w", err)
|
||||
}
|
||||
items = append(items, item)
|
||||
}
|
||||
if err := rows.Err(); err != nil {
|
||||
return nil, fmt.Errorf("iterate map asset linked events: %w", err)
|
||||
}
|
||||
return items, nil
|
||||
}
|
||||
|
||||
func (s *Store) CreateMapRuntimeBinding(ctx context.Context, tx Tx, params CreateMapRuntimeBindingParams) (*MapRuntimeBinding, error) {
|
||||
row := tx.QueryRow(ctx, `
|
||||
INSERT INTO map_runtime_bindings (
|
||||
@@ -681,7 +877,7 @@ func scanPlaceFromRows(rows pgx.Rows) (*Place, error) {
|
||||
|
||||
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)
|
||||
err := row.Scan(&item.ID, &item.PublicID, &item.PlaceID, &item.PlacePublicID, &item.PlaceName, &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
|
||||
}
|
||||
@@ -693,7 +889,7 @@ func scanMapAsset(row pgx.Row) (*MapAsset, error) {
|
||||
|
||||
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)
|
||||
err := rows.Scan(&item.ID, &item.PublicID, &item.PlaceID, &item.PlacePublicID, &item.PlaceName, &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)
|
||||
}
|
||||
|
||||
33
backend/migrations/0012_managed_assets.sql
Normal file
33
backend/migrations/0012_managed_assets.sql
Normal file
@@ -0,0 +1,33 @@
|
||||
BEGIN;
|
||||
|
||||
CREATE TABLE managed_assets (
|
||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
asset_public_id TEXT NOT NULL UNIQUE,
|
||||
asset_type TEXT NOT NULL,
|
||||
asset_code TEXT NOT NULL,
|
||||
version TEXT NOT NULL,
|
||||
title TEXT,
|
||||
source_mode TEXT NOT NULL CHECK (source_mode IN ('uploaded', 'external_link')),
|
||||
storage_provider TEXT NOT NULL CHECK (storage_provider IN ('oss', 'external')),
|
||||
object_key TEXT,
|
||||
public_url TEXT NOT NULL,
|
||||
file_name TEXT,
|
||||
content_type TEXT,
|
||||
file_size_bytes BIGINT,
|
||||
checksum_sha256 TEXT,
|
||||
status TEXT NOT NULL DEFAULT 'active' CHECK (status IN ('draft', 'active', 'disabled', 'archived')),
|
||||
metadata_jsonb JSONB NOT NULL DEFAULT '{}'::jsonb,
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||
UNIQUE (asset_type, asset_code, version)
|
||||
);
|
||||
|
||||
CREATE INDEX managed_assets_asset_type_idx ON managed_assets(asset_type);
|
||||
CREATE INDEX managed_assets_asset_code_idx ON managed_assets(asset_code);
|
||||
CREATE INDEX managed_assets_status_idx ON managed_assets(status);
|
||||
|
||||
CREATE TRIGGER managed_assets_set_updated_at
|
||||
BEFORE UPDATE ON managed_assets
|
||||
FOR EACH ROW EXECUTE FUNCTION set_updated_at();
|
||||
|
||||
COMMIT;
|
||||
79
backend/migrations/0013_ops_console.sql
Normal file
79
backend/migrations/0013_ops_console.sql
Normal file
@@ -0,0 +1,79 @@
|
||||
BEGIN;
|
||||
|
||||
ALTER TABLE auth_sms_codes
|
||||
DROP CONSTRAINT IF EXISTS auth_sms_codes_client_type_check;
|
||||
|
||||
ALTER TABLE auth_sms_codes
|
||||
ADD CONSTRAINT auth_sms_codes_client_type_check
|
||||
CHECK (client_type IN ('app', 'wechat', 'ops'));
|
||||
|
||||
CREATE TABLE ops_roles (
|
||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
role_code TEXT NOT NULL UNIQUE,
|
||||
display_name TEXT NOT NULL,
|
||||
role_rank INT NOT NULL,
|
||||
permissions_jsonb JSONB NOT NULL DEFAULT '{}'::jsonb,
|
||||
status TEXT NOT NULL DEFAULT 'active' CHECK (status IN ('active', 'disabled', 'archived')),
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
|
||||
);
|
||||
|
||||
CREATE TRIGGER ops_roles_set_updated_at
|
||||
BEFORE UPDATE ON ops_roles
|
||||
FOR EACH ROW EXECUTE FUNCTION set_updated_at();
|
||||
|
||||
CREATE TABLE ops_users (
|
||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
ops_user_public_id TEXT NOT NULL UNIQUE,
|
||||
country_code TEXT NOT NULL,
|
||||
mobile TEXT NOT NULL,
|
||||
display_name TEXT NOT NULL,
|
||||
status TEXT NOT NULL DEFAULT 'active' CHECK (status IN ('active', 'disabled', 'deleted')),
|
||||
last_login_at TIMESTAMPTZ,
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||
UNIQUE (country_code, mobile)
|
||||
);
|
||||
|
||||
CREATE INDEX ops_users_mobile_idx ON ops_users(country_code, mobile);
|
||||
|
||||
CREATE TRIGGER ops_users_set_updated_at
|
||||
BEFORE UPDATE ON ops_users
|
||||
FOR EACH ROW EXECUTE FUNCTION set_updated_at();
|
||||
|
||||
CREATE TABLE ops_user_roles (
|
||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
ops_user_id UUID NOT NULL REFERENCES ops_users(id) ON DELETE CASCADE,
|
||||
ops_role_id UUID NOT NULL REFERENCES ops_roles(id) ON DELETE CASCADE,
|
||||
status TEXT NOT NULL DEFAULT 'active' CHECK (status IN ('active', 'disabled')),
|
||||
assigned_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||
assigned_by_ops_user_id UUID REFERENCES ops_users(id) ON DELETE SET NULL,
|
||||
UNIQUE (ops_user_id, ops_role_id)
|
||||
);
|
||||
|
||||
CREATE INDEX ops_user_roles_user_idx ON ops_user_roles(ops_user_id);
|
||||
CREATE INDEX ops_user_roles_role_idx ON ops_user_roles(ops_role_id);
|
||||
|
||||
CREATE TABLE ops_refresh_tokens (
|
||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
ops_user_id UUID NOT NULL REFERENCES ops_users(id) ON DELETE CASCADE,
|
||||
device_key TEXT,
|
||||
token_hash TEXT NOT NULL UNIQUE,
|
||||
issued_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||
expires_at TIMESTAMPTZ NOT NULL,
|
||||
revoked_at TIMESTAMPTZ,
|
||||
replaced_by_token_id UUID REFERENCES ops_refresh_tokens(id) ON DELETE SET NULL
|
||||
);
|
||||
|
||||
CREATE INDEX ops_refresh_tokens_user_idx ON ops_refresh_tokens(ops_user_id);
|
||||
CREATE INDEX ops_refresh_tokens_expires_idx ON ops_refresh_tokens(expires_at);
|
||||
|
||||
INSERT INTO ops_roles (role_code, display_name, role_rank, permissions_jsonb)
|
||||
VALUES
|
||||
('owner', '所有者', 100, '{"scope":"all"}'::jsonb),
|
||||
('admin', '管理员', 80, '{"scope":"ops_admin"}'::jsonb),
|
||||
('operator', '运维专员', 50, '{"scope":"ops_operator"}'::jsonb),
|
||||
('viewer', '只读观察员', 10, '{"scope":"ops_viewer"}'::jsonb)
|
||||
ON CONFLICT (role_code) DO NOTHING;
|
||||
|
||||
COMMIT;
|
||||
30
backend/migrations/0014_map_experience.sql
Normal file
30
backend/migrations/0014_map_experience.sql
Normal file
@@ -0,0 +1,30 @@
|
||||
BEGIN;
|
||||
|
||||
ALTER TABLE events
|
||||
ADD COLUMN IF NOT EXISTS is_default_experience BOOLEAN NOT NULL DEFAULT false;
|
||||
|
||||
ALTER TABLE events
|
||||
ADD COLUMN IF NOT EXISTS show_in_event_list BOOLEAN NOT NULL DEFAULT true;
|
||||
|
||||
CREATE INDEX IF NOT EXISTS events_is_default_experience_idx
|
||||
ON events(is_default_experience);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS events_show_in_event_list_idx
|
||||
ON events(show_in_event_list);
|
||||
|
||||
UPDATE events
|
||||
SET
|
||||
is_default_experience = true,
|
||||
show_in_event_list = true
|
||||
WHERE event_public_id = 'evt_demo_001';
|
||||
|
||||
UPDATE events
|
||||
SET
|
||||
is_default_experience = false,
|
||||
show_in_event_list = true
|
||||
WHERE event_public_id IN (
|
||||
'evt_demo_score_o_001',
|
||||
'evt_demo_variant_manual_001'
|
||||
);
|
||||
|
||||
COMMIT;
|
||||
14
backend/migrations/0015_guest_identity.sql
Normal file
14
backend/migrations/0015_guest_identity.sql
Normal file
@@ -0,0 +1,14 @@
|
||||
ALTER TABLE login_identities
|
||||
DROP CONSTRAINT IF EXISTS login_identities_identity_type_check;
|
||||
|
||||
ALTER TABLE login_identities
|
||||
ADD CONSTRAINT login_identities_identity_type_check
|
||||
CHECK (
|
||||
identity_type IN (
|
||||
'mobile',
|
||||
'wechat_mini_openid',
|
||||
'wechat_oa_openid',
|
||||
'wechat_unionid',
|
||||
'guest'
|
||||
)
|
||||
);
|
||||
Reference in New Issue
Block a user