1 Commits

Author SHA1 Message Date
71e1866e99 merge: integrate map engine north reference work 2026-03-20 11:41:36 +08:00
415 changed files with 666 additions and 109349 deletions

13
.gitignore vendored
View File

@@ -1,14 +1,10 @@
node_modules/
.tmp-ts/
.tmp-runtime-smoke/
miniprogram_npm/
dist/
build/
coverage/
project.private.config.json
project.config.json
private.wx0c8b079993bb9d7a.key
wX5FOd926R.txt
*.log
npm-debug.log*
yarn-debug.log*
@@ -22,12 +18,3 @@ pnpm-debug.log*
*.swp
.DS_Store
Thumbs.db
realtime-gateway/bin/
realtime-gateway/.tmp-gateway.*
oss-html.ps1
tools/ossutil.exe
tools/accesskey.txt
/flutter-app/
/harmony_app/
/Gemini*.md
/ossutil.exe

View File

@@ -1,84 +0,0 @@
{
"schemaVersion": "1",
"version": "2026.03.25",
"app": {
"id": "sample-classic-001",
"title": "顺序赛示例",
"locale": "zh-CN"
},
"map": {
"tiles": "sample-map/tiles/",
"mapmeta": "sample-map/tiles/meta.json",
"declination": 6.91,
"initialView": {
"zoom": 17
}
},
"playfield": {
"kind": "course",
"source": {
"type": "kml",
"url": "sample-course/course.kml"
},
"CPRadius": 6,
"metadata": {
"title": "顺序赛路线示例",
"code": "classic-001"
}
},
"game": {
"mode": "classic-sequential",
"rulesVersion": "1",
"session": {
"startManually": true,
"requiresStartPunch": true,
"requiresFinishPunch": true,
"autoFinishOnLastControl": false,
"maxDurationSec": 5400
},
"punch": {
"policy": "enter-confirm",
"radiusMeters": 10
},
"sequence": {
"skip": {
"enabled": false,
"radiusMeters": 30,
"requiresConfirm": true
}
},
"guidance": {
"showLegs": true,
"legAnimation": true,
"allowFocusSelection": false
},
"visibility": {
"revealFullPlayfieldAfterStartPunch": true
},
"finish": {
"finishControlAlwaysSelectable": false
},
"telemetry": {
"heartRate": {
"age": 30,
"restingHeartRateBpm": 62,
"userWeightKg": 65
}
},
"feedback": {
"audioProfile": "default",
"hapticsProfile": "default",
"uiEffectsProfile": "default"
}
},
"resources": {
"audioProfile": "default",
"contentProfile": "default",
"themeProfile": "default-race"
},
"debug": {
"allowModeSwitch": false,
"allowMockInput": false,
"allowSimulator": false
}
}

View File

@@ -1,108 +0,0 @@
{
"schemaVersion": "1",
"version": "2026.03.25",
"app": {
"id": "sample-score-o-001",
"title": "积分赛示例",
"locale": "zh-CN"
},
"map": {
"tiles": "sample-map/tiles/",
"mapmeta": "sample-map/tiles/meta.json",
"declination": 6.91,
"initialView": {
"zoom": 17
}
},
"playfield": {
"kind": "control-set",
"source": {
"type": "kml",
"url": "sample-course/course.kml"
},
"CPRadius": 6,
"controlOverrides": {
"control-1": {
"score": 10
},
"control-2": {
"score": 20
},
"control-3": {
"score": 30
},
"control-4": {
"score": 40
},
"control-5": {
"score": 50
},
"control-6": {
"score": 60
},
"control-7": {
"score": 70
},
"control-8": {
"score": 80
}
},
"metadata": {
"title": "积分赛控制点示例2 起终点 + 8 积分点)",
"code": "score-o-001"
}
},
"game": {
"mode": "score-o",
"rulesVersion": "1",
"session": {
"startManually": true,
"requiresStartPunch": true,
"requiresFinishPunch": false,
"autoFinishOnLastControl": false,
"maxDurationSec": 5400
},
"punch": {
"policy": "enter-confirm",
"radiusMeters": 10,
"requiresFocusSelection": true
},
"scoring": {
"type": "score",
"defaultControlScore": 10
},
"guidance": {
"showLegs": false,
"legAnimation": false,
"allowFocusSelection": true
},
"visibility": {
"revealFullPlayfieldAfterStartPunch": true
},
"finish": {
"finishControlAlwaysSelectable": true
},
"telemetry": {
"heartRate": {
"age": 30,
"restingHeartRateBpm": 62,
"userWeightKg": 65
}
},
"feedback": {
"audioProfile": "default",
"hapticsProfile": "default",
"uiEffectsProfile": "default"
}
},
"resources": {
"audioProfile": "default",
"contentProfile": "default",
"themeProfile": "default-race"
},
"debug": {
"allowModeSwitch": false,
"allowMockInput": false,
"allowSimulator": false
}
}

500
b2b.md
View File

@@ -1,500 +0,0 @@
# B2B 交接文档
> 文档版本v1.0
> 最后更新2026-04-07 18:47:09
## 1. 文档用途
这份文档给“新线程 / 新接手人”直接使用,目标是:
1. 快速理解当前 backend 主线已经做到哪里。
2. 明确哪些架构约束已经定死,不能再随意回退。
3. 明确当前运维后台、调试后台、前后端联调各自的边界。
4. 给出下一步应该从哪里继续,而不是重新摸索一遍。
---
## 2. 先说结论
当前 backend 已经形成三条比较稳定的主线:
1. `玩家链`
- `entry -> auth -> home/cards -> event detail/play -> launch -> session -> result`
2. `游客链`
- `public experience maps -> public event detail/play -> public launch`
3. `运维链`
- `资源录入 -> 地图/地点管理 -> 路线资源管理 -> 活动管理/编排 -> 发布中心`
同时,调试与运维已经明确分离:
- 调试后台:`/dev/workbench`
- 运维后台:`/admin/ops-workbench`
当前接口总数:
- `116`
---
## 3. 当前最重要的架构边界
### 3.1 活动运行模型
当前已经明确,第一阶段活动模型按最小运行单元收口:
- `单地图`
- `单路线组`
- `单玩法`
不要在第一阶段把单个活动扩成:
- 多地图
- 多路线组
- 多玩法
复杂需求后续通过两种方式承接:
1. `活动实例化`
- 一个复杂主题拆成多个明确活动实例。
2. `组合入口 / 组合卡片`
- 前台组合多个活动实例形成复杂入口。
这条原则已经同步给总控,后续不要回退。
### 3.2 玩家进入游戏的依据
玩家进入游戏必须基于:
- `已发布 release`
而不是:
- event 草稿
- event 默认绑定
- 未发布的 presentation / content bundle
当前 `play.canLaunch` 已按这条规则收紧:
必须同时具备:
- event 是 `active`
- 当前已发布 release 存在
- release 有 `manifest`
- release 绑定了 `runtime`
- release 绑定了 `presentation`
- release 绑定了 `content bundle`
否则:
- `play.canLaunch = false`
### 3.3 调试后台与运维后台分离
不要再把所有能力塞回一个页面。
当前原则:
- `/dev/workbench`
- 只做联调、一键回归、日志、摘要、问题排查
- `/admin/ops-workbench`
- 只做资源录入、地图/路线/活动管理、发布中心
---
## 4. 用户明确表达过的产品/交互偏好
这些是用户多次强调过的,后续要严格遵守。
### 4.1 不要把所有功能平铺在一个长页面里
用户明确反感“所有功能平铺一个页面”的方式。
正确方向:
- 列表就是列表
- 点某一项进详情
- 新增/编辑用:
- 新页面
- 弹出页
- 分步流程
不要继续堆按钮。
### 4.2 运维后台要符合人的使用习惯
例如在地图管理里,用户明确要求的是:
1. 先进入地图列表
2. 右上角有:
- `添加地图`
- `添加地点`
3. 点一张地图再进入详情
4. 地图上传能力要放在地图管理里
### 4.3 运维后台 UI 必须尽量利用宽屏
用户明确要求:
- 不要再固定窄宽度
- 主区要做最大宽度适配
- 不要浪费屏幕空间
### 4.4 开发环境尽量免登录
用户明确要求:
- 开发环境不要每次都要求登录
当前已实现:
- `APP_ENV != production` 时,`/ops/admin/*` 默认可免登录使用
- 即使浏览器残留旧玩家 token / 过期 token / 非 ops token也会自动 fallback 到 dev ops 上下文
---
## 5. 当前已完成的关键能力
## 5.1 调试后台
入口:
- [D:\dev\cmr-mini\backend\internal\httpapi\handlers\dev_handler.go](D:/dev/cmr-mini/backend/internal/httpapi/handlers/dev_handler.go)
地址:
- `/dev/workbench`
已完成:
- demo bootstrap
- 一键发布链
- 一键标准回归
- 前端调试日志上报与查看
- 当前玩法关键状态卡
- Launch 实际配置摘要
- 准备页地图预览状态
- API 目录按业务链分类展示
调试链已经能稳定支撑:
- 选玩法
- 回归
- 看日志
- 看 launch / manifest / preview 摘要
## 5.2 游客模式
公开接口:
- `GET /public/experience-maps`
- `GET /public/experience-maps/{mapAssetPublicID}`
- `GET /public/events/{eventPublicID}`
- `GET /public/events/{eventPublicID}/play`
- `POST /public/events/{eventPublicID}/launch`
语义:
- 只允许默认体验活动
- 只允许基于已发布 release
- `launch.business.isGuest = true`
- `launch.source = public-default-experience`
游客 launch 曾经被 `guest` identity DB 约束卡死,已通过 migration 修复。
## 5.3 地图列表下的默认体验活动
接口:
- `GET /experience-maps`
- `GET /experience-maps/{mapAssetPublicID}`
当前用于前端地图列表下的“默认体验活动”展示。
## 5.4 运维后台
入口:
- [D:\dev\cmr-mini\backend\internal\httpapi\handlers\ops_workbench_handler.go](D:/dev/cmr-mini/backend/internal/httpapi/handlers/ops_workbench_handler.go)
地址:
- `/admin/ops-workbench`
当前导航结构已收成:
- `资源总览`
- `地图 / 地点管理`
- `路线资源管理`
- `活动管理`
- `活动编排`
- `发布中心`
- `资源录入`
注意:
- 这只是第一版结构
- 还没有完全做成成熟的列表页/详情页后台
- 但已经明确不再走“全平铺”老路
## 5.5 资源录入第二期基础能力
已新增统一资源模型接口:
- `GET /ops/admin/assets`
- `POST /ops/admin/assets/register-link`
- `POST /ops/admin/assets/upload`
- `GET /ops/admin/assets/{assetPublicID}`
目的:
- 运维无需操心资源到底来自上传文件还是外链
- 最终都纳管成统一资源对象
---
## 6. 当前地图/地点管理做到哪里了
这是接下来最可能继续做的主线。
当前已经做了这些:
1. 地图/地点管理已不再是平铺对象区
2. 地图列表成为主入口
3. 右上角只有:
- `添加地图`
- `添加地点`
4. 地图详情改成弹层
5. 地点编辑改成弹层
6. 地点支持:
- 省份
- 城市
- 最终回填 `region`
7. 新增地区接口:
- `GET /ops/admin/region-options`
地区数据源:
- [https://github.com/uiwjs/province-city-china](https://github.com/uiwjs/province-city-china)
- [https://unpkg.com/province-city-china/dist/province.json](https://unpkg.com/province-city-china/dist/province.json)
- [https://unpkg.com/province-city-china/dist/city.json](https://unpkg.com/province-city-china/dist/city.json)
业务约束:
- 一个地点可以有多张地图
- 一张地图只能属于一个地点
刚刚修复过一个关键前端脚本 bug
- `managed-place-id` 隐藏状态字段在重构时被删掉
- 导致 `refresh-map-area` 前端脚本异常,只打印 `{ error: {} }`
- 现已补回,同时错误日志已展开成:
- `name`
- `message`
- `stack`
- `status/body/url`
---
## 7. 数据库与 migration 重要说明
开发库:
- `cmr20260401`
注意:
- backend 启动脚本不会自动帮你补所有历史 migration
- 之前已经遇到过“代码修了,库没跟上”的问题
开发库里曾手工补过的重要 migration
1. [D:\dev\cmr-mini\backend\migrations\0012_managed_assets.sql](D:/dev/cmr-mini/backend/migrations/0012_managed_assets.sql)
2. [D:\dev\cmr-mini\backend\migrations\0013_ops_console.sql](D:/dev/cmr-mini/backend/migrations/0013_ops_console.sql)
3. [D:\dev\cmr-mini\backend\migrations\0015_guest_identity.sql](D:/dev/cmr-mini/backend/migrations/0015_guest_identity.sql)
这些都已经在当前开发库应用过。
如果新线程遇到“文档说修了,但接口还是异常”,先查两件事:
1. 当前 API 实例是不是连着 `cmr20260401`
2. 对应 migration 有没有真的进库
---
## 8. 关键文件入口
### 8.1 路由与中间件
- [D:\dev\cmr-mini\backend\internal\httpapi\router.go](D:/dev/cmr-mini/backend/internal/httpapi/router.go)
- [D:\dev\cmr-mini\backend\internal\httpapi\middleware\ops_auth.go](D:/dev/cmr-mini/backend/internal/httpapi/middleware/ops_auth.go)
### 8.2 调试后台
- [D:\dev\cmr-mini\backend\internal\httpapi\handlers\dev_handler.go](D:/dev/cmr-mini/backend/internal/httpapi/handlers/dev_handler.go)
### 8.3 运维后台
- [D:\dev\cmr-mini\backend\internal\httpapi\handlers\ops_workbench_handler.go](D:/dev/cmr-mini/backend/internal/httpapi/handlers/ops_workbench_handler.go)
- [D:\dev\cmr-mini\backend\internal\httpapi\handlers\region_options_handler.go](D:/dev/cmr-mini/backend/internal/httpapi/handlers/region_options_handler.go)
### 8.4 地图默认体验活动 / 游客模式
- [D:\dev\cmr-mini\backend\internal\service\map_experience_service.go](D:/dev/cmr-mini/backend/internal/service/map_experience_service.go)
- [D:\dev\cmr-mini\backend\internal\httpapi\handlers\map_experience_handler.go](D:/dev/cmr-mini/backend/internal/httpapi/handlers/map_experience_handler.go)
- [D:\dev\cmr-mini\backend\internal\service\public_experience_service.go](D:/dev/cmr-mini/backend/internal/service/public_experience_service.go)
- [D:\dev\cmr-mini\backend\internal\httpapi\handlers\public_experience_handler.go](D:/dev/cmr-mini/backend/internal/httpapi/handlers/public_experience_handler.go)
### 8.5 运维总览
- [D:\dev\cmr-mini\backend\internal\store\postgres\ops_summary_store.go](D:/dev/cmr-mini/backend/internal/store/postgres/ops_summary_store.go)
- [D:\dev\cmr-mini\backend\internal\service\ops_summary_service.go](D:/dev/cmr-mini/backend/internal/service/ops_summary_service.go)
### 8.6 资源录入 / 受管资源
- [D:\dev\cmr-mini\backend\internal\service\admin_asset_service.go](D:/dev/cmr-mini/backend/internal/service/admin_asset_service.go)
- [D:\dev\cmr-mini\backend\internal\httpapi\handlers\admin_asset_handler.go](D:/dev/cmr-mini/backend/internal/httpapi/handlers/admin_asset_handler.go)
- [D:\dev\cmr-mini\backend\internal\store\postgres\asset_store.go](D:/dev/cmr-mini/backend/internal/store/postgres/asset_store.go)
---
## 9. 必读文档
新线程建议先看这些,不要一上来就只看代码。
1. [D:\dev\cmr-mini\backend\README.md](D:/dev/cmr-mini/backend/README.md)
2. [D:\dev\cmr-mini\backend\docs\开发说明.md](D:/dev/cmr-mini/backend/docs/开发说明.md)
3. [D:\dev\cmr-mini\backend\docs\后台管理最小方案.md](D:/dev/cmr-mini/backend/docs/后台管理最小方案.md)
4. [D:\dev\cmr-mini\backend\docs\资源对象与目录方案.md](D:/dev/cmr-mini/backend/docs/资源对象与目录方案.md)
5. [D:\dev\cmr-mini\backend\docs\接口清单.md](D:/dev/cmr-mini/backend/docs/接口清单.md)
6. [D:\dev\cmr-mini\b2t.md](D:/dev/cmr-mini/b2t.md)
7. [D:\dev\cmr-mini\b2f.md](D:/dev/cmr-mini/b2f.md)
其中:
- `b2t.md` 看 backend 写给总控的当前结论
- `b2f.md` 看 backend 写给前端的当前联调口径
---
## 10. 当前最值得继续做的事
新线程接手后,建议优先级如下:
### P0把“地图/地点管理”真正做成列表 -> 详情流
目标:
1. 地图列表作为稳定主入口
2. 地图详情弹层稳定
3. 添加地图/添加地点弹层稳定
4. 地图上传能力放进地图管理流
这里的“上传地图”不是指调试式填 URL而是最终要收成
- 上传文件
- 或登记链接
- backend 统一纳管
### P1把地图详情页做扎实
建议继续补:
- 当前瓦片版本
- 历史瓦片版本
- 简单预览
- 默认体验活动数量
- 关联活动数量
- 跳转到活动管理
注意:
- 地图页只看数量和摘要
- 活动详情不要重新塞回地图页
### P2后续再做路线资源管理同样的列表 -> 详情流
KML/路线页也应遵守同一原则:
- 列表就是列表
- 点一项进详情
- 上传就是上传
- 不在首页平铺所有对象编辑器
---
## 11. 本线程最后一次真实修复
如果新线程接手后发现:
- 进入 `/admin/ops-workbench`
- 切到“地图 / 地点管理”
- 状态栏显示:
- `失败refresh-map-area`
- 响应日志里还是:
- `{ "error": {} }`
先确认是否已经是本线程修后的代码版本。
本线程最后一次修复的是:
- 恢复 `managed-place-id` 隐藏状态字段
- 展开运维页前端异常日志
相关文件:
- [D:\dev\cmr-mini\backend\internal\httpapi\handlers\ops_workbench_handler.go](D:/dev/cmr-mini/backend/internal/httpapi/handlers/ops_workbench_handler.go)
---
## 12. 本地验证方式
不要让新线程重复踩坑,直接照这个流程:
1. backend 编译
```powershell
cd D:\dev\cmr-mini\backend
go build ./...
```
2. 启动 backend
```powershell
cd D:\dev\cmr-mini\backend
.\start-backend.ps1
```
3. 浏览器强刷
```text
Ctrl + F5
```
4. 检查两个入口
- 调试后台:
- [http://127.0.0.1:18090/dev/workbench](http://127.0.0.1:18090/dev/workbench)
- 运维后台:
- [http://127.0.0.1:18090/admin/ops-workbench](http://127.0.0.1:18090/admin/ops-workbench)
---
## 13. 一句话交接
当前 backend 已经把:
- 调试后台
- 游客模式
- 默认体验地图接口
- 运维后台第一版骨架
都立起来了。
接下来最该继续的不是扩新对象,而是把:
**地图 / 地点管理 -> 路线资源管理 -> 活动管理 / 编排 -> 发布中心**
这条运维主流程,按“列表 -> 详情 -> 弹层 / 分步”的方式做扎实。

283
b2f.md
View File

@@ -1,283 +0,0 @@
# b2f
> 文档版本v1.40
> 最后更新2026-04-07 16:29:08
说明:
- 本文件由 backend 维护,写给 frontend
- 只保留当前有效事项、联调基线和压缩归档
- 已完成旧项不再逐条长留,只保留必要结论
---
## 待确认
### B2F-045
- 时间2026-04-07 16:29:08
- 谁提的backend
- 当前事实:
- backend 已补游客模式最小公开接口:
- `GET /public/experience-maps`
- `GET /public/experience-maps/{mapAssetPublicID}`
- `GET /public/events/{eventPublicID}`
- `GET /public/events/{eventPublicID}/play`
- `POST /public/events/{eventPublicID}/launch`
- 当前游客模式语义:
- 只允许默认体验活动
- 只允许基于已发布 release
- `public launch` 返回结构与正式 launch 基本同构
- 会额外返回:
- `launch.source = public-default-experience`
- `launch.business.isGuest = true`
- 当前不开放:
- `/me/entry-home`
- `/me/results`
- 历史成绩
- 报名态
- 本轮已定位并修复 `F2B-019` 的根因:
- `POST /public/events/{eventPublicID}/launch` 在创建游客身份时会写入 `login_identities`
- 旧库约束不允许 `identity_type = 'guest'`
- 已补 migration
- [0015_guest_identity.sql](D:/dev/cmr-mini/backend/migrations/0015_guest_identity.sql)
- 需要对方确认什么:
- frontend 重启 backend 并应用最新 migration 后,重新回归:
- `POST /public/events/evt_demo_001/launch`
- 若仍异常,只回传:
- `status`
- `error.code`
- `eventId`
- `deviceKey`
- 是否已解决:是
### B2F-044
- 时间2026-04-07 16:08:20
- 谁提的backend
- 当前事实:
- backend 已补地图列表与默认活动最小接口:
- `GET /experience-maps`
- `GET /experience-maps/{mapAssetPublicID}`
- 当前地图列表字段:
- `placeId`
- `placeName`
- `mapId`
- `mapName`
- `coverUrl`
- `summary`
- `defaultExperienceCount`
- `defaultExperienceEventIds`
- 当前地图详情字段:
- `tileBaseUrl`
- `tileMetaUrl`
- `defaultExperiences[]`
- `defaultExperiences[]` 已带:
- `eventId`
- `title`
- `subtitle`
- `eventType`
- `status`
- `statusCode`
- `ctaText`
- `isDefaultExperience`
- `showInEventList`
- `currentPresentation`
- `currentContentBundle`
- 需要对方确认什么:
- frontend 地图列表第一刀可直接按这组字段开始接线。
- 如仍缺字段,只回传“字段名 + 使用位置”。
- 是否已解决:否
### B2F-041
- 时间2026-04-07 13:12:00
- 谁提的backend
- 当前事实:
- backend 已把准备页地图预览 V1 的只读字段挂到:
- `GET /events/{eventPublicID}`
- `GET /events/{eventPublicID}/play`
- 当前字段为:
- `preview.mode`
- `preview.baseTiles.tileBaseUrl`
- `preview.baseTiles.zoom`
- `preview.baseTiles.tileSize`
- `preview.viewport.width / height / minLon / minLat / maxLon / maxLat`
- `preview.variants[].controls`
- `preview.variants[].legs`
- `preview.selectedVariantId`
- 三条标准 demo 当前都已具备 preview 元数据。
- 需要对方确认什么:
- frontend 可按这组字段开始准备页地图预览 V1 接线。
- 当前只做只读预览,不把 preview 当成正式 launch 前置条件。
- 是否已解决:否
### B2F-040
- 时间2026-04-07 10:58:18
- 谁提的backend
- 当前事实:
- 首页 `ongoingSession` 语义已固定:
- 只认 `launched`
- 只认 `running`
- 以下状态都不算进行中:
- `finished`
- `failed`
- `cancelled`
- backend 已支持:
- `POST /sessions/{sessionPublicID}/finish`
- `status = cancelled`
- 需要对方确认什么:
- frontend 首页“进行中”只在 `ongoingSession` 存在时显示。
- 建议按钮:
- `恢复`
- `放弃`
- `放弃` 必须调用 `finish(cancelled)`,然后清本地恢复快照,再刷新 `/me/entry-home`
- 是否已解决:否
### B2F-038
- 时间2026-04-03 19:13:57
- 谁提的backend
- 当前事实:
- backend 已给活动列表第一刀补齐最小摘要字段,返回位于:
- `GET /cards`
- `GET /home`
- `GET /me/entry-home`
- 当前字段为:
- `summary`
- `status`
- `statusCode`
- `timeWindow`
- `ctaText`
- `isDefaultExperience`
- `eventType`
- `currentPresentation`
- `currentContentBundle`
- 需要对方确认什么:
- frontend 按这组字段完成列表页最小接线。
- 如果仍缺字段,请只回传“缺什么字段、用于哪个页面块”。
- 是否已解决:否
---
## 已确认
### B2F-043
- 时间2026-04-07 13:51:50
- 谁提的backend
- 当前事实:
- backend 已开始提供运维入口第一期:
- `POST /admin/ops/tile-releases/import`
- `POST /admin/ops/course-sets/import-kml-batch`
- backend 也已开始提供统一资源纳管入口:
- `GET /admin/assets`
- `POST /admin/assets/register-link`
- `POST /admin/assets/upload`
- `GET /admin/assets/{assetPublicID}`
- 当前已新增独立运维工作台:
- `GET /admin/ops-workbench`
- 这批接口与页面主要服务运维录入和发布准备,不要求 frontend 直接接入。
- 当前 API 总数同步更新为:
- `101`
- 需要对方确认什么:
-
- 是否已解决:是
### B2F-042
- 时间2026-04-07 12:38:13
- 谁提的backend
- 当前事实:
- 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`
- frontend 当前只需消费发布结果,无需读取本地临时目录。
- 需要对方确认什么:
-
- 是否已解决:是
### B2F-034
- 时间2026-04-07 09:46:00
- 谁提的backend
- 当前事实:
- 玩家进入游戏只认“已发布 release”。
- `currentPresentation / currentContentBundle` 当前表示的是:
- 当前已发布 release 实际绑定的展示版本
- 当前已发布 release 实际绑定的内容包
- 它们不是 event 草稿默认值。
- `play.canLaunch` 当前已收紧,不是“有 release 就真”。
- 需要对方确认什么:
- frontend 页面文案应按“当前发布展示版本 / 当前发布内容包版本”理解。
- 是否已解决:是
### B2F-032
- 时间2026-04-03 18:42:00
- 谁提的backend
- 当前事实:
- backend 已接收 frontend 调试日志,并以此作为联调事实依据。
- 当前建议前端日志至少带:
- `eventId`
- `releaseId`
- `manifestUrl`
- `game.mode`
- `playfield.kind`
- `details.seq`
- 需要对方确认什么:
- frontend 继续按结构化日志回传事实,不靠截图猜测。
- 是否已解决:是
---
## 阻塞
- 当前无 backend 侧新增阻塞。
- 若 frontend 发现问题,请直接回传:
- 当前 `eventId`
- 当前 `releaseId`
- 当前 `manifestUrl`
- 当前页面阶段
- 结构化日志片段
---
## 已完成
### 归档摘要(保留必要结论)
- 时间2026-04-07 12:18:00
- 谁提的backend
- 当前事实:
- `Bootstrap Demo` 当前会准备三条标准 demo 的基础已发布态:
- `evt_demo_001`
- `evt_demo_score_o_001`
- `evt_demo_variant_manual_001`
- 三条 demo 当前都已清理历史残留 ongoing session。
- manual 多赛道当前已确认:
- `assignmentMode = manual`
- `variantCount = 2`
- `detailCanLaunch = true`
- 积分赛当前已确认:
- `game.mode = score-o`
- `playfield.kind = control-set`
- 活动列表当前已能稳定返回 3 张标准 demo 卡片。
- 需要对方确认什么:
-
- 是否已解决:是
---
## 下一步
- frontend 继续按当前联调基线推进:
- 活动列表第一刀
- 详情页/准备页语义收口
- 准备页地图预览 V1
- backend 继续保持:
- 一键测试链稳定
- 结构化日志可追踪
- demo 数据可重复复现

425
b2t.md
View File

@@ -1,425 +0,0 @@
# B2T 协作清单
> 文档版本v1.47
> 最后更新2026-04-07 18:15:01
说明:
- 本文件由 backend 维护,写给总控
- 只保留当前主线、有效结论和压缩归档
- 已完成历史项不再逐条保留长记录
---
## 待确认
### B2T-044
- 时间2026-04-07 17:23:15
- 谁提的backend
- 当前事实:
- backend 建议把活动模型先收成最小可玩单元:
- `单地图`
- `单路线组`
- `单玩法`
- 当前不建议第一阶段直接支持:
- 一个活动多地图
- 一个活动多路线组
- 一个活动多玩法
- 复杂需求优先通过“活动实例化”解决,而不是把单个活动扩成多对多容器。
- 多地图 / 多玩法的前台需求,建议通过“组合卡片 / 组合入口层”承接:
- 组合入口可以指向多个活动实例
- 单个活动实例仍保持最小可玩单元
- 这样可以同时简化:
- 前台活动卡片逻辑
- 发布语义
- 结果追溯
- 运维后台第一期流程
- 需要对方确认什么:
- 运维后台与活动编排后续按“单地图 + 单路线组 + 单玩法”继续推进。
- 多地图 / 多路线组 / 多玩法先作为后续模板化实例生成能力与组合入口能力,不在第一阶段直接进入活动主模型。
- 是否已解决:否
### B2T-045
- 时间2026-04-07 17:52:18
- 谁提的backend
- 当前事实:
- 运维后台已继续从“对象平铺页”往“主流程后台”收。
- 当前左侧导航改成:
- `资源总览`
- `地图 / 地点管理`
- `路线资源管理`
- `活动管理`
- `活动编排`
- `发布中心`
- `资源录入` 作为辅助入口保留
- `资源总览` 已提升为运行时信息面板,优先展示关键统计与当前活动的 `release / runtime / presentation / content bundle`
- 地图页已不再平铺关联活动详情,只保留:
- 关联活动数量
- 默认体验活动数量
- 关联活动摘要
- 跳转到活动管理
- 需要对方确认什么:
- 运维后台继续按“总览优先 + 单主视图 + 地图页只看地图本身、活动详情去活动管理”这条交互结构推进。
- 是否已解决:否
### B2T-046
- 时间2026-04-07 18:03:42
- 谁提的backend
- 当前事实:
- `地图 / 地点管理` 已继续从“按钮 + 手填 ID”收成“列表 -> 详情”流程。
- 当前已支持:
- 地点列表
- 地图列表
- 地点关键字筛选
- 地图关键字筛选
- 点地点自动读取地点详情与该地点下地图
- 点地图自动读取地图详情、当前瓦片版本、默认活动概况
- 地图页继续只保留关联活动数量与摘要,不在地图页平铺活动详情。
- 需要对方确认什么:
- 地图 / 地点管理继续按“列表-详情主流程”推进,活动详情统一留在活动管理 / 活动编排。
- 是否已解决:否
### B2T-045
- 时间2026-04-07 17:30:06
- 谁提的backend
- 当前事实:
- 运维后台当前已继续从“功能平铺页”收成“导航驱动的流程页”:
- 左侧导航
- 中间单主视图
- 右侧常驻状态栏
- 当前不再把地图管理、资源录入、KML 管理、活动管理、发布中心同时平铺在一个长页面里。
- 主区当前也已改成宽屏自适应,不再使用固定窄宽度。
- 需要对方确认什么:
- 运维后台后续继续按“符合人使用习惯的流程导航页”推进,不再回退到功能平铺式工作台。
- 是否已解决:是
### B2T-043
- 时间2026-04-07 16:45:40
- 谁提的backend
- 当前事实:
- 运维后台当前在开发环境里默认免登录。
- 本轮已修复 dev-only 鉴权陷阱:
- 浏览器残留旧的玩家 token、失效 token 或非 ops token 时
- `/ops/admin/*` 之前会返回 `401 invalid_token`
- 现在开发环境会自动回退到 dev ops 上下文
- 生产环境不变,仍要求正式 ops token。
- 需要对方确认什么:
-
- 是否已解决:是
### B2T-042
- 时间2026-04-07 16:29:08
- 谁提的backend
- 当前事实:
- frontend 反馈游客模式第一刀里:
- `GET /public/experience-maps` 正常
- `GET /public/events/{eventPublicID}/play` 正常
- `POST /public/events/{eventPublicID}/launch` 返回 `500 internal_error`
- backend 已定位根因:
- 游客 launch 会创建 `guest_device` 身份
- 旧库约束不允许 `login_identities.identity_type = 'guest'`
- 已补 migration
- [0015_guest_identity.sql](D:/dev/cmr-mini/backend/migrations/0015_guest_identity.sql)
- 本次修复不改游客接口契约,只修数据库约束。
- 需要对方确认什么:
- 游客模式第一刀继续以:
- `evt_demo_001`
作为基线回归。
- 是否已解决:是
### B2T-041
- 时间2026-04-07 16:08:37
- 谁提的backend
- 当前事实:
- 运维后台当前已从“底层对象调试台”继续往“主流程管理台”收口:
- 地图管理
- KML / 赛道管理
- 活动管理
- 发布中心
- 当前已补运维地图主流程接口:
- `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}`
- 运维后台当前目标已明确:
- 运维先看地图列表
- KML / 赛道围绕地图管理
- 活动围绕地图查看关联活动、默认体验活动和发布状态
- 活动管理当前也已补成与地图同一套路:
- 列表
- 新建
- 修改
- 读取详情
- 当前新增:
- `POST /ops/admin/events`
- `PUT /ops/admin/events/{eventPublicID}`
- 当前 API 总数更新为:
- `115`
- 需要对方确认什么:
- 运维后台第一期继续按“地图列表 -> 地图详情 -> KML / 赛道 -> 活动绑定 -> 发布中心”这条主流程推进。
- 不再把 `Place / MapAsset / TileRelease / CourseSource / CourseSet` 直接当首页认知入口。
- 是否已解决:否
### B2T-040
- 时间2026-04-07 20:12:00
- 谁提的backend
- 当前事实:
- backend 已补游客模式第一刀最小公开接口:
- `GET /public/experience-maps`
- `GET /public/experience-maps/{mapAssetPublicID}`
- `GET /public/events/{eventPublicID}`
- `GET /public/events/{eventPublicID}/play`
- `POST /public/events/{eventPublicID}/launch`
- 当前规则:
- 只允许默认体验活动
- 只允许基于已发布 release
- guest launch 使用独立 `guest_device` 身份落到正常 session 模型
- 返回 `launch.business.isGuest = true`
- 当前 API 总数更新为:
- `106`
- 需要对方确认什么:
- 游客模式第一刀按这组公开接口继续前后端联调。
- 若后续需要 guest 结果页或 guest 本地成绩迁移,再作为下一刀,不混入当前最小链。
- 是否已解决:否
### B2T-039
- 时间2026-04-07 16:42:15
- 谁提的backend
- 当前事实:
- 运维后台已开始落地“地图资源管理第一刀”:
- `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`
- `/admin/ops-workbench` 当前已新增独立“地图资源管理”区,可直接:
- 建地点
- 建地图
- 看当前瓦片版本
- 看默认活动摘要
- 需要对方确认什么:
- 运维后台第一期按“地图资源管理 -> 资源录入 -> 赛道集管理 -> 活动绑定 -> 发布中心”这条顺序继续推进。
- 是否已解决:否
### B2T-038
- 时间2026-04-07 16:08:20
- 谁提的backend
- 当前事实:
- backend 已按“地图列表下的默认活动”补最小公开接口:
- `GET /experience-maps`
- `GET /experience-maps/{mapAssetPublicID}`
- 默认活动关系当前统一收口到:
- `events.is_default_experience`
- `events.show_in_event_list`
- 当前已发布 `release` 绑定到的 `runtime.mapAsset`
- `cards / home / me/entry-home` 当前也已补:
- `showInEventList`
- 需要对方确认什么:
- 后续活动列表与地图入口第一刀以这组字段为准继续联调。
- 如需更复杂地图分组、排序、租户筛选,放到下一刀,不在这次最小实现里扩。
- 是否已解决:否
### B2T-034
- 时间2026-04-07 13:12:00
- 谁提的backend
- 当前事实:
- backend 已按“准备页地图预览 V1”预留最小字段到
- `GET /events/{eventPublicID}`
- `GET /events/{eventPublicID}/play`
- 当前 preview 仍保持:
- 只读增强项
- 不进入正式 launch 主链
- 不单独造新地图资源体系
- 需要对方确认什么:
- backend 继续按“只读预览 V1”推进不扩新对象层级。
- 是否已解决:否
---
## 已确认
### B2T-037
- 时间2026-04-07 14:45:37
- 谁提的backend
- 当前事实:
- 运维后台当前已开始与玩家链路分离:
- 运维账号接口:`/ops/auth/*`
- 运维管理接口:`/ops/admin/*`
- `/admin/ops-workbench` 当前只服务运维录资源、活动绑定、发布中心。
- 开发环境当前默认免登录放行,方便录资源和调发布;生产环境再收口到手机号验证码运维账号。
- 运维总览统计当前已改为只读取服务端聚合摘要,不再用前端列表长度硬凑。
- 需要对方确认什么:
-
- 是否已解决:是
### B2T-036
- 时间2026-04-07 13:51:50
- 谁提的backend
- 当前事实:
- backend 已开始落地运维入口第一期,不再只靠手工脚本或改代码上传资源。
- 当前先开放两条最小录入链:
- `POST /admin/ops/tile-releases/import`
- `POST /admin/ops/course-sets/import-kml-batch`
- 运维入口第二期也已起步,开始支持统一资源纳管:
- `GET /admin/assets`
- `POST /admin/assets/register-link`
- `POST /admin/assets/upload`
- `GET /admin/assets/{assetPublicID}`
- 当前已新增独立运维工作台:
- `GET /admin/ops-workbench`
- 当前开始把:
- `/dev/workbench`
- `/admin/ops-workbench`
两套入口按“调试后台 / 运维后台”正式拆开
- `Import Tile Release / Import KML Batch` 已从 `/dev/workbench` 主操作区迁到:
- `/admin/ops-workbench`
- `/dev/workbench` 当前只保留运维入口说明与跳转
- `/admin/ops-workbench` 当前已收成第一版运维台结构:
- 资源总览
- 资源录入
- 赛道集管理
- 活动绑定
- 发布中心
- 当前 API 总数同步更新为:
- `101`
- 需要对方确认什么:
-
- 是否已解决:是
### B2T-035
- 时间2026-04-07 12:38:13
- 谁提的backend
- 当前事实:
- 正式资源目录约束已收口:
- 正式资源只认 `OSS / CDN`
- 本地 `tmp/` 只作为临时收件箱,不作为正式发布源
- 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`
- 需要对方确认什么:
-
- 是否已解决:是
### B2T-033
- 时间2026-04-07 10:55:40
- 谁提的backend
- 当前事实:
- `Bootstrap Demo只准备数据` 当前会准备三条标准 demo 的基础已发布态:
- `runtime`
- `presentation`
- `content bundle`
- 当前 release
- frontend 从首页点三种玩法时,已可直接按“当前已发布 release”语义联调。
- 需要对方确认什么:
-
- 是否已解决:是
### B2T-029
- 时间2026-04-03 22:34:08
- 谁提的backend
- 当前事实:
- backend 已完成活动卡片列表最小产品化第一刀:
- `GET /cards`
- `GET /home`
- `GET /me/entry-home`
- 已补齐最小摘要字段:
- `summary`
- `status`
- `statusCode`
- `timeWindow`
- `ctaText`
- `isDefaultExperience`
- `eventType`
- `currentPresentation`
- `currentContentBundle`
- 需要对方确认什么:
-
- 是否已解决:是
### B2T-028
- 时间2026-04-03 16:16:38
- 谁提的backend
- 当前事实:
- backend 已提供联调结构化日志通道:
- `POST /dev/client-logs`
- `GET /dev/client-logs`
- `DELETE /dev/client-logs`
- workbench 已有前端调试日志面板。
- 需要对方确认什么:
-
- 是否已解决:是
---
## 阻塞
- 当前无 backend 主线阻塞。
- 当前不建议开启:
- 新对象扩张
- 正式后台 UI
- 与活动系统最小成品闭环无关的新玩家功能
---
## 已完成
### 归档摘要(保留必要结论)
- 时间2026-04-07 12:18:00
- 谁提的backend
- 当前事实:
- 联调标准化阶段已完成:
- 一键测试链
- 详细日志
- 稳定 demo 数据
- workbench 回归汇总
- 真实输入替换第一刀已完成:
- 真实 KML
- 真实地图 URL
- dev content/presentation 入口
- 中文活动文案样例
- 三条标准 demo 当前都可稳定联调:
- 顺序赛
- 积分赛
- manual 多赛道
- 历史 demo ongoing 残留已收口。
- 需要对方确认什么:
-
- 是否已解决:是
---
## 下一步
- backend 当前继续围绕:
- 活动系统最小成品闭环
- 活动列表第一页联调小修
- 运维后台第一期里的地图 / 地点管理
- 运维后台当前新增已落地:
- 地图 / 地点管理改成“地图列表优先”
- 右上角入口:`添加地图 / 添加地点`
- 地点编辑区接入省 / 市两级选择
- backend 新增:`GET /ops/admin/region-options`
- 不开新战线,只做收口、稳定、验证。

View File

@@ -1,24 +0,0 @@
APP_ENV=development
HTTP_ADDR=:8080
DATABASE_URL=postgres://postgres:asdf*123@192.168.100.77:5432/cmr20260401?sslmode=disable
JWT_ISSUER=cmr-backend
JWT_ACCESS_SECRET=change-me-in-production
JWT_ACCESS_TTL=2h
AUTH_REFRESH_TTL=720h
AUTH_SMS_CODE_TTL=10m
AUTH_SMS_COOLDOWN=60s
AUTH_SMS_PROVIDER=console
AUTH_DEV_SMS_CODE=
WECHAT_MINI_APP_ID=
WECHAT_MINI_APP_SECRET=
WECHAT_MINI_DEV_PREFIX=dev-
LOCAL_EVENT_DIR=..\event
ASSET_BASE_URL=https://oss-mbh5.colormaprun.com/gotomars
ASSET_PUBLIC_BASE_URL=https://oss-mbh5.colormaprun.com
ASSET_BUCKET_ROOT=oss://color-map-html
OSSUTIL_PATH=..\tools\ossutil.exe
OSSUTIL_CONFIG_FILE=C:\Users\your-user\.ossutilconfig

View File

@@ -1,312 +0,0 @@
# Backend
> 文档版本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`
- 运行配置对象是 `event_release`
- 真正进入游戏时客户端消费的是 `manifest_url`
- `session` 会固化当时实际绑定的 `release`
当前还要明确一条业务规则:
- 玩家进入游戏,必须基于“已发布 release”
- `event` 默认绑定、活动草稿配置、未发布 presentation / content bundle 都不能直接作为玩家正式进入依据
- 当前 `currentPresentation` / `currentContentBundle` 在玩家链路里表示的是:
- 当前已发布 release 实际绑定的展示版本摘要
- 当前已发布 release 实际绑定的内容包摘要
- 它们不是 event 草稿默认值摘要
- 当前 `play.canLaunch``launch` 也已按同一套规则收口:
- 只有当当前发布 release 同时具备:
- `manifest`
- `runtime`
- `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 实际配置摘要”仅用于调试:
- 它会由 backend 代读当前 launch 对应的 manifest
- 用来显示:
- `configUrl`
- `releaseId`
- `manifestUrl`
- `schemaVersion`
- `playfield.kind`
- `game.mode`
- 这块只服务联调排查,不参与正式客户端运行链路
- 正式客户端仍应直接消费 `launch` 返回的:
- `launch.config.configUrl`
- `launch.resolvedRelease.manifestUrl`
当前 workbench 里新增的“前端调试日志”也仅用于联调:
- frontend 可将页面侧调试日志 `POST``/dev/client-logs`
- backend 会临时保留最近 200 条日志,供 workbench 查看与清空
- 这块只用于联调排查,不替代正式生产日志体系
当前 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 测试数据和基础已发布态,不额外重新发布当前玩法
- `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
- `/cards`
- `/home`
- `/me/entry-home`
这三处当前已统一补齐最小活动卡片摘要字段:
- `summary`
- `status`
- `statusCode`
- `timeWindow`
- `ctaText`
- `isDefaultExperience`
- `eventType`
- `currentPresentation`
- `currentContentBundle`
当前口径:
- 卡片摘要与详情页继续共用同一套“当前发布 release 摘要”语义
- `currentPresentation / currentContentBundle` 仍表示:
- 当前已发布 release 实际绑定的展示版本摘要
- 当前已发布 release 实际绑定的内容包摘要
- `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)
- [系统架构](D:/dev/cmr-mini/backend/docs/系统架构.md)
- [核心流程](D:/dev/cmr-mini/backend/docs/核心流程.md)
- [API 清单](D:/dev/cmr-mini/backend/docs/接口清单.md)
- [数据模型](D:/dev/cmr-mini/backend/docs/数据模型.md)
- [配置管理方案](D:/dev/cmr-mini/backend/docs/配置管理方案.md)
- [资源对象与目录方案](D:/dev/cmr-mini/backend/docs/资源对象与目录方案.md)
- [后台管理最小方案](D:/dev/cmr-mini/backend/docs/后台管理最小方案.md)
- [开发说明](D:/dev/cmr-mini/backend/docs/开发说明.md)
## 快速启动
1. 配置环境变量,参考 [`.env.example`](D:/dev/cmr-mini/backend/.env.example)
2. 按顺序执行 [migrations](D:/dev/cmr-mini/backend/migrations)
3. 启动服务
```powershell
cd D:\dev\cmr-mini\backend
.\start-backend.ps1
```
## 当前重点
- 统一登录:短信 + 微信小程序
- 多入口:`tenant + entry_channel`
- 首页聚合:`/home``/cards``/me/entry-home`
- 配置驱动启动:`/events/{id}/play``/events/{id}/launch`
- 局生命周期:`start / finish / detail`
- 局后结果:`/sessions/{id}/result``/me/results`
- 第一阶段生产骨架:`places / map-assets / tile-releases / course-sources / course-sets / course-variants / runtime-bindings`
- 第三刀最小接线:`runtimeBinding -> eventRelease -> launch.runtime`
- 第四刀发布闭环:`publish(runtimeBindingId) -> eventRelease -> launch.runtime`
- 活动运营域第二阶段:`event_presentations / content_bundles / event_release -> presentation,bundle,runtime`
- 活动运营域第二阶段第二刀:`event detail / event play / launch -> presentation,bundle 摘要`
- 活动运营域第二阶段第三刀:`release 摘要闭环 + content bundle import`
- 活动运营域第二阶段第四刀:`presentation import + event 默认 active 绑定 + publish 默认继承`
- 开发工作台:`/dev/workbench`
- 用户主链调试
- 资源对象与 Event 组装调试
- Build / Publish / Rollback 调试
- Release / RuntimeBinding 最小挂接验证
- Event Presentation / Content Bundle 最小挂接验证
- Content Bundle Import 最小导入验证
- Presentation Import / Event 默认绑定 / Publish 默认继承验证
- Runtime 自动补齐 + 默认绑定发布一键验证
- Bootstrap Demo 自动回填最小生产骨架 ID
- 一键测试环境:可从空白状态自动准备 demo event、source/build/release、presentation、content bundle、place、map asset、tile release、course source、course set、course variant、runtime binding并输出逐步日志与预期判定
- 一键标准回归:在标准发布链跑通后,继续自动验证 `play / launch / result / history`
- 真实输入替换第一刀:`Bootstrap Demo` 已改用真实可访问的 KML 与地图资源 URL
- manual 多赛道 demo已切到真实 `c01.kml / c02.kml` 输入
- 前端调试日志:
- `POST /dev/client-logs`
- `GET /dev/client-logs`
- `DELETE /dev/client-logs`
- 显式玩法入口:
- 顺序赛:`evt_demo_001`
- 积分赛:`evt_demo_score_o_001`
- 多赛道:`evt_demo_variant_manual_001`

View File

@@ -1,55 +0,0 @@
package main
import (
"context"
"log/slog"
"net/http"
"os"
"os/signal"
"syscall"
"time"
"cmr-backend/internal/app"
)
func main() {
ctx := context.Background()
cfg, err := app.LoadConfigFromEnv()
if err != nil {
slog.Error("load config failed", "error", err)
os.Exit(1)
}
application, err := app.New(ctx, cfg)
if err != nil {
slog.Error("create app failed", "error", err)
os.Exit(1)
}
defer application.Close()
server := &http.Server{
Addr: cfg.HTTPAddr,
Handler: application.Router(),
ReadHeaderTimeout: 5 * time.Second,
}
go func() {
slog.Info("api server started", "addr", cfg.HTTPAddr)
if err := server.ListenAndServe(); err != nil && err != http.ErrServerClosed {
slog.Error("server stopped unexpectedly", "error", err)
os.Exit(1)
}
}()
stop := make(chan os.Signal, 1)
signal.Notify(stop, syscall.SIGINT, syscall.SIGTERM)
<-stop
shutdownCtx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
defer cancel()
if err := server.Shutdown(shutdownCtx); err != nil {
slog.Error("graceful shutdown failed", "error", err)
os.Exit(1)
}
}

View File

@@ -1,63 +0,0 @@
# Backend Docs
> 文档版本v1.1
> 最后更新2026-04-07 18:47:09
这套文档服务两个目的:
1. 让后面开发时能快速查到当前后端边界
2. 把“配置驱动游戏”的核心约束写清楚,避免业务层和游戏层重新耦合
## 建议阅读顺序
1. [系统架构](D:/dev/cmr-mini/backend/docs/系统架构.md)
2. [核心流程](D:/dev/cmr-mini/backend/docs/核心流程.md)
3. [API 清单](D:/dev/cmr-mini/backend/docs/接口清单.md)
4. [数据模型](D:/dev/cmr-mini/backend/docs/数据模型.md)
5. [配置管理方案](D:/dev/cmr-mini/backend/docs/配置管理方案.md)
6. [资源对象与目录方案](D:/dev/cmr-mini/backend/docs/资源对象与目录方案.md)
7. [后台管理最小方案](D:/dev/cmr-mini/backend/docs/后台管理最小方案.md)
8. [前后端联调清单](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)
## 当前系统范围
当前 backend 已覆盖:
- 多租户入口识别
- APP 短信登录
- 微信小程序登录
- 手机号绑定与账号合并
- 首页卡片与入口聚合
- Event 详情与 play 上下文
-`event_release` 为核心的 launch
- session 生命周期
- session 结果沉淀
- 开发 workbench
下一阶段建议重点:
- 可伸缩配置管理
- 共享资源对象化
- source/build/release 分层
- 配置构建器
- 发布资产清单
## 当前最重要的设计约束
- 用户是平台级,不是俱乐部级
- 渠道是入口,不是用户体系
- `event` 是业务对象,不是运行配置本体
- `event_release` 才是进入游戏时真正绑定的配置发布对象
- `game_session` 必须固化当时实际使用的 release
## 代码入口
- 程序入口:[main.go](D:/dev/cmr-mini/backend/cmd/api/main.go)
- 应用装配:[app.go](D:/dev/cmr-mini/backend/internal/app/app.go)
- 路由注册:[router.go](D:/dev/cmr-mini/backend/internal/httpapi/router.go)
- migration[migrations](D:/dev/cmr-mini/backend/migrations)

View File

@@ -1,346 +0,0 @@
# Backend TodoList
> 文档版本v1.2
> 最后更新2026-04-02 11:03:02
## 1. 目标
这份 TodoList 只列当前需要 backend 配合联调和近期应推进的事项。
原则:
- 不重复写已经稳定可用的能力
- 优先写会影响前后端联调闭环的点
- 边界不清的事项单独标记“需确认”
## 2. 当前联调现状
当前已经可联调的主链:
- 微信小程序登录
- `GET /events/{eventPublicID}/play`
- `POST /events/{eventPublicID}/launch`
- `POST /sessions/{sessionPublicID}/start`
- `POST /sessions/{sessionPublicID}/finish`
- `GET /sessions/{sessionPublicID}/result`
小程序侧已经具备:
- backend 地址和 token 持久化
- `launch -> GameLaunchEnvelope` 适配
- 进入地图后自动上报 `session start`
- 对局结束后自动上报 `session finish`
所以 backend 现在最重要的不是再扩散接口,而是把当前契约和语义收稳。
当前已确认不再阻塞主链的事项:
- `evt_demo_001` 的 release manifest 现已可正常加载
- 小程序已能进入地图
- `launch` 关键字段在当前阶段不再单边漂移
- `cancelled / failed / finished` 已从 ongoing 口径里收稳
- 模拟定位 / 调试日志问题已回到小程序与模拟器侧,不再属于 backend 当前阻塞
前端当前需要配合的事项:
- 正式联调时始终以 `launch.resolvedRelease.manifestUrl` 为准,不再回退到本地样例配置路径
- 如果再出现配置加载失败,反馈完整上下文:
- `eventPublicID`
- `releaseId`
- `manifestUrl`
- 页面报错文案
- 控制台 / 网络日志
- 当前 demo 联调建议统一使用:
- `eventPublicID = evt_demo_001`
- `channelCode = mini-demo`
- `channelType = wechat_mini`
## 3. P0 已完成
## 3.0 固定 session 状态语义
当前 backend 已明确并固定:
- `finished`
- `failed`
- `cancelled`
建议当前口径:
- 正常打终点完成:`finished`
- 超时结束:`failed`
- 主动退出 / 放弃恢复:`cancelled`
说明:
- 小程序现在已经按这个方向接
- 如果 backend 想改这 3 个状态语义,需要先讨论,不要单边改
## 3.1 明确“放弃恢复”的后端处理
当前小程序本地恢复逻辑已经是:
- 进入程序检测到未正常结束对局
- 弹确认框
- 玩家可“继续恢复”或“放弃”
现在本地“放弃”只会清除本地恢复快照。
backend 已确认的目标语义是:
> 玩家点击“放弃恢复”后,这一局是否应同时在业务后端标记为 `cancelled`。
当前结论:
- **是,应标记为 `cancelled`**
原因:
- 否则 `ongoingSession` 会继续存在
- `/events/{id}/play``/me/entry-home` 可能一直把它当成可继续的局
- 会和小程序本地“已放弃”产生语义分叉
当前 backend 已收口:
1. `POST /sessions/{id}/finish` 使用 `status=cancelled` 是否就是官方放弃语义
2. 如果客户端持有旧 `sessionToken`,恢复放弃时是否允许直接调用 `finish(cancelled)`
3. `cancelled` 后,`event play``entry-home` 中不再返回为 `ongoingSession`
备注:
- 小程序侧现在可以把“点击放弃恢复”正式接成同步调用 `finish(cancelled)`
## 3.2 保证 start / finish 幂等与重复调用安全
联调和真实环境里,以下情况很常见:
- 网络重试
- 页面重进
- 故障恢复后二次补报
- 用户重复点击
当前 backend 已确认:
- `start` 重复调用的幂等语义
- `finish` 重复调用的幂等语义
当前实现:
- `start`:如果已 `running`,返回当前 session视为成功
- `finish`:如果已进入终态,返回当前 session/result视为成功
目的:
- 不把客户端补偿逻辑变成一堆冲突分支
## 3.3 固定 `launch` 返回契约,不随意漂移
当前客户端已经按下面这些字段接入:
- `launch.resolvedRelease.releaseId`
- `launch.resolvedRelease.manifestUrl`
- `launch.resolvedRelease.manifestChecksumSha256`
- `launch.config.configUrl`
- `launch.config.configLabel`
- `launch.config.releaseId`
- `launch.config.routeCode`
- `launch.business.sessionId`
- `launch.business.sessionToken`
- `launch.business.sessionTokenExpiresAt`
backend 现在需要做的是:
- 先保持这些字段名稳定
- 如果要调整命名或层级,先沟通
前端当前需要做的是:
- 只消费当前已约定字段
- 不额外推断 release URL
- 不把本地样例配置路径混进正式 launch 流程
- 如果字段缺失或命名变化,直接在联调清单里标阻塞
## 4. P1 应尽快做
## 4.1 多赛道 Variant 第一阶段最小契约
当前前端已给出:
- [多赛道 Variant 五层设计草案](D:/dev/cmr-mini/doc/gameplay/多赛道Variant五层设计草案.md)
- [多赛道 Variant 前后端最小契约](D:/dev/cmr-mini/doc/gameplay/多赛道Variant前后端最小契约.md)
backend 当前建议第一阶段只做最小闭环:
- `play.assignmentMode`
- `play.courseVariants[]`
- `launch.variant.*`
- `session / result / ongoing / recent``variantId / variantName / routeCode`
当前目标:
1. 一个 session 最终只绑定一个 `variantId`
2. `launch` 返回最终绑定结果
3. 恢复链不重新分配 variant
4. 结果页、ongoing、历史结果都能追溯 variant
备注:
- 当前只先定最小契约,不先做完整后台 variant 编排模型
- 当前第一阶段最小后端链路已补入:
- `play.assignmentMode`
- `play.courseVariants[]`
- `launch.variant.*`
- `session / result / ongoing / recent``variantId / variantName / routeCode`
- 下一步应由前端按该契约联调,不再继续扩后台 variant 模型
## 4.2 增加用户身体资料读取接口
小程序侧已经有:
- telemetry profile 合并入口
- 心率/卡路里计算逻辑
backend 下一步建议提供:
- 当前用户 body profile 查询接口
建议返回至少包含:
- `birthDate``heartRateAge`
- `weightKg`
- `restingHeartRateBpm`
- `maxHeartRateBpm`(可选)
这样后面心率页和消耗估算就能真实接业务数据。
## 4.3 给 `session result` 补一点稳定摘要字段校验
客户端现在会上报:
- `finalDurationSec`
- `finalScore`
- `completedControls`
- `totalControls`
- `distanceMeters`
- `averageSpeedKmh`
backend 建议补两件事:
- 合理性校验
- 空值容忍
不要因为某个可选字段缺失就整局 finish 失败。
## 4.4 dev workbench 增加一组“恢复 / 取消恢复”场景按钮
当前 workbench 已经很好用了。
建议后续再补:
- 标记 session 为 `cancelled`
- 查询 ongoing session
- 快速查看某个用户最新 session 状态
这会很适合配合小程序故障恢复联调。
## 4.5 前端预埋“放弃恢复”调用位
这项先预埋,不要先自行定语义。
前端建议准备好:
- 在“放弃恢复”按钮点击后,预留调用 `finish(cancelled)` 的位置
- 但是否正式启用,要等 backend 把 `cancelled` 语义确认完
这样一旦 backend 确认语义,小程序就能快速切过去,不需要再改一轮页面流程。
## 5. P2 下一阶段
## 5.1 配置后台 source / build / release 真正开始做
当前已经有:
- 表结构
- 架构文档
还缺:
- source CRUD
- build 触发
- manifest 产物生成
- release 发布
- asset index 查询
这个建议在当前主链联稳之后再推进。
## 5.2 page / cards / competition 等业务对象继续长出来
这部分不是当前联调阻塞项,但后面会成为业务壳的重要组成。
## 5.3 兼顾未来 APP 的统一后端约束
backend 后续建设需要继续坚持:
- 不做“小程序专用后端”
- 用户模型保持平台级
- `event / release / session / result` 不按终端拆两套
- 终端差异只通过上下文字段和运行时适配处理
建议优先保持:
- 业务接口统一
- 配置发布结构统一
- 结果沉淀结构统一
这样后面 APP 接入时不会推翻现有 backend 结构。
## 6. 需要先讨论再动的边界
这些事项 backend 不建议自己先拍板:
### 6.1 `failed` 是否专指超时
当前建议是:
- 超时 -> `failed`
- 主动退出 / 放弃恢复 -> `cancelled`
如果 backend 有别的语义方案,需要先统一。
### 6.2 放弃恢复是否一定写后端
我个人建议写后端,并落成 `cancelled`
但如果 backend 团队认为:
- 放弃恢复只影响本地
- 业务上仍允许以后继续从服务端 ongoing session 恢复
那就必须明确告知客户端,不然两边会冲突。
### 6.3 result 页是以后继续本地展示,还是跳业务结果页
当前客户端是本地结果页。
backend 后面如果要接业务结果页,最好提前定:
- finish 成功后是否仍停留地图内结果页
- 还是跳业务壳结果页
## 7. 我建议的最近动作
backend 现在最值得先做的,不是继续铺更多页面接口,而是先推进下面 3 条:
1. 与前端确认多赛道第一阶段最小契约
2. 已按最小契约扩完 `play -> launch -> session/result`
3. 再补用户身体资料接口和 workbench 恢复场景按钮
这样不会打断当前主链,同时能把下一阶段多赛道联调接上。
## 8. 一句话结论
当前 backend 最重要的任务不是“再加更多接口”,而是:
> 在不破坏当前稳定主链的前提下,先把多赛道 Variant 第一阶段最小契约定稳,再继续向配置与后台模型延伸。

View File

@@ -1,374 +0,0 @@
# 前后端联调清单
> 文档版本v1.0
> 最后更新2026-04-02 08:28:05
## 1. 目的
这份清单只回答三件事:
1. 小程序当前已经具备哪些接后端的前置能力
2. backend 当前已经提供了哪些可联调接口
3. 哪些链路已经能接,哪些链路还缺适配
本文不讨论未来大而全后台方案,只服务当前联调落地。
## 2. 当前结论
当前状态可以概括成一句话:
> backend 业务主链已经可联调;小程序地图运行内核也已经成型;两边之间还缺一层业务接入和会话上报适配。
也就是说:
- 登录、活动详情、launch、session、result 这一条后端链已经可用
- 小程序地图页已经支持携带 `configUrl / releaseId / sessionId / sessionToken`
- 但小程序当前仍主要走本地 demo / 直连 OSS manifest
- 真正的“后端 launch -> 地图页 -> session start/finish/result”还没有正式接上
## 3. 小程序当前已具备的联调基础
## 3.1 启动信封已经成型
地图页不是只吃一个 `configUrl`,而是吃一份启动信封:
- [gameLaunch.ts](D:/dev/cmr-mini/miniprogram/utils/gameLaunch.ts)
当前结构:
- `config.configUrl`
- `config.configLabel`
- `config.configChecksumSha256`
- `config.releaseId`
- `config.routeCode`
- `business.source`
- `business.competitionId`
- `business.eventId`
- `business.launchRequestId`
- `business.participantId`
- `business.sessionId`
- `business.sessionToken`
- `business.sessionTokenExpiresAt`
- `business.realtimeEndpoint`
- `business.realtimeToken`
这意味着:
- backend `launch` 返回的数据结构已经能自然装进小程序地图启动链
- 地图页并不需要重构启动模型,只需要把业务页接到 `GameLaunchEnvelope`
## 3.2 地图页已经支持远端 manifest 启动
- [map.ts](D:/dev/cmr-mini/miniprogram/pages/map/map.ts)
当前地图页会:
1. 解析 `GameLaunchEnvelope`
2.`loadRemoteMapConfig(configUrl)`
3. 编译 runtime profile
4. 启动 `MapEngine`
所以只要后端能给出:
- `manifestUrl`
- `releaseId`
- `configChecksumSha256`
地图页就可以直接跑。
## 3.3 会话态字段已经进入地图页
地图页当前已经能接收并持有:
- `sessionId`
- `sessionToken`
- `sessionTokenExpiresAt`
这说明后面接:
- `POST /sessions/{id}/start`
- `POST /sessions/{id}/finish`
不需要再改地图启动协议。
## 3.4 故障恢复也已经具备会话上下文承载
故障恢复快照当前会保留:
- `launchEnvelope`
- 运行态快照
这意味着一旦接入后端 session 后,恢复链也可以继续沿用同一份 `launchEnvelope`
## 4. backend 当前已具备的联调基础
## 4.1 路由主链已落地
- [router.go](D:/dev/cmr-mini/backend/internal/httpapi/router.go)
当前已实现:
- `POST /auth/login/wechat-mini`
- `GET /me/entry-home`
- `GET /events/{eventPublicID}/play`
- `POST /events/{eventPublicID}/launch`
- `POST /sessions/{sessionPublicID}/start`
- `POST /sessions/{sessionPublicID}/finish`
- `GET /sessions/{sessionPublicID}/result`
- `GET /me/results`
## 4.2 launch 返回结构已贴近客户端
- [核心流程.md](D:/dev/cmr-mini/backend/docs/核心流程.md)
- [接口清单.md](D:/dev/cmr-mini/backend/docs/接口清单.md)
当前 `launch` 返回重点:
- `launch.resolvedRelease.releaseId`
- `launch.resolvedRelease.manifestUrl`
- `launch.resolvedRelease.manifestChecksumSha256`
- `launch.business.sessionId`
- `launch.business.sessionToken`
这和小程序 `GameLaunchEnvelope` 基本是同一语义。
## 4.3 session 运行态和结果态已分离
- [session_service.go](D:/dev/cmr-mini/backend/internal/service/session_service.go)
当前已经区分:
- 业务登录态:`access_token`
- 局内运行态:`sessionToken`
这对地图页是对的,因为地图页真正需要的是:
- 进入前有业务 token
- 进入后局内动作用 sessionToken
## 4.4 开发 workbench 已可用于联调
- [dev_handler.go](D:/dev/cmr-mini/backend/internal/httpapi/handlers/dev_handler.go)
当前 workbench 已能串:
- bootstrap
- auth
- entry/home
- event play / launch
- session start / finish / detail
- result 查询
这对前后端联调非常有价值,说明后端已经不是“只看文档”阶段。
## 5. 当前已经能接的链路
## 5.1 P0登录与业务页前置链
可接:
1. 小程序 `wx.login`
2. `POST /auth/login/wechat-mini`
3. 拿到 `accessToken`
4.`GET /me/entry-home`
5.`GET /events/{eventPublicID}/play`
当前缺口:
- 小程序还没有正式业务页 API 适配层
- 还没有统一 token 持久化与请求封装
## 5.2 P0launch 进入地图
可接:
1. 前置业务页拿到 event play
2.`POST /events/{eventPublicID}/launch`
3. 把返回结果映射成 `GameLaunchEnvelope`
4. `navigateTo('/pages/map/map?...')`
当前缺口:
- 还没有一层 `backend launch -> GameLaunchEnvelope` 的适配函数
- 当前 `gameLaunch.ts` 仍偏 demo/static config 驱动
## 5.3 P0finish 回传结果
可接:
1. 地图页结束一局
2. 提取结果摘要
3.`sessionId + sessionToken``POST /sessions/{id}/finish`
4. 业务页或结果页再查 `GET /sessions/{id}/result`
当前缺口:
- 小程序本地结果页已经有摘要,但还没有正式调用 backend finish
- finish payload 和本地 `resultSummary` 之间还需要一层映射
## 6. 当前还不能说已经接通的链路
## 6.1 配置后台 source/build/release
backend 当前已经有:
- 表结构
- 文档模型
但还没有真正开放:
- `config source`
- `build`
- `release assets`
- `preview launch`
也就是说:
**配置后台链还不能联调,只能联业务主链。**
## 6.2 body profile / 遥测个体化
小程序已经有:
- 身体数据入口
- 遥测 runtime profile
backend 文档里也规划了:
- 用户身体资料
但当前接口清单里还没有明确的 body profile 读接口落到小程序链上,所以这条还不能算当前联调主线。
## 7. 当前最大的接口适配缺口
我认为目前最大缺口只有 4 个:
### 7.1 业务 API 客户端缺失
小程序当前缺:
- 统一 `request` 封装
- token 持久化
- access token 刷新
- backend DTO -> 小程序 view model 适配
### 7.2 launch 适配层缺失
需要一层明确的转换:
`LaunchResponse -> GameLaunchEnvelope`
这里最适合单独做成一个小模块,而不是散落在页面里。
### 7.3 session finish 映射缺失
地图页当前本地已经有:
- 用时
- 分数
- 完成点数
- 里程
- 速度
- 最大心率
但还没有一个稳定函数把它映射为 backend finish payload。
### 7.4 业务结果页与地图结果页还未打通
现在地图页已经有自己的结果页。
后面要决定:
- 地图页结果页先本地展示,再异步回传
- 还是 finish 成功后跳业务结果页
这件事需要前后端统一策略。
## 8. 推荐联调顺序
建议按下面顺序推进,不要跳步:
### 第一步:接微信小程序登录
目标:
- 小程序拿到 `accessToken`
- 能请求鉴权接口
### 第二步:接 event play
目标:
- 小程序业务页能拿到:
- `event`
- `resolvedRelease`
- `play.canLaunch`
- `play.ongoingSession`
### 第三步:接 launch -> map
目标:
- 从后端 launch 返回直接进入地图
- 不再靠 demo preset 手工切配置
### 第四步:接 start / finish / result
目标:
- 开赛后能回传 start
- 结束后能回传 finish
- 结果页能查 backend result
### 第五步:再考虑 ongoing session 恢复
目标:
- backend ongoing session
- 本地故障恢复
两条链统一口径
## 9. 当前已落地的小程序联调适配
小程序侧当前已经补了第一批适配层:
- [backendAuth.ts](D:/dev/cmr-mini/miniprogram/utils/backendAuth.ts)
- [backendApi.ts](D:/dev/cmr-mini/miniprogram/utils/backendApi.ts)
- [backendLaunchAdapter.ts](D:/dev/cmr-mini/miniprogram/utils/backendLaunchAdapter.ts)
- [index.ts](D:/dev/cmr-mini/miniprogram/pages/index/index.ts)
当前已具备:
- 后端 base URL 本地持久化
- access / refresh token 本地持久化
- 微信小程序登录请求封装
- `event play` 请求封装
- `launch -> GameLaunchEnvelope` 适配
- 从首页直接 `launch` 进入地图
- 地图页 `session start / finish` 上报接入
因此当前主链已从“可分析”进入“可实测”。
## 10. 我建议的最近行动项
如果开始联调,我建议先做这 3 件事:
1. 新增小程序 `backendApi` 请求层
先只包 auth / event play / launch / session finish
2. 新增 `launchAdapter`
把 backend launch 响应稳定转成 `GameLaunchEnvelope`
3. 新增 `finishAdapter`
把地图页结果摘要稳定转成 backend finish payload
这三件做完,前后端主链就能真正接起来。
## 11. 一句话结论
当前最真实的进度判断是:
> backend 业务后端主链已经进入可联调阶段;小程序地图运行内核也已经具备承接能力;下一步最值钱的是补小程序业务 API 层和 launch/finish 两个适配器。

View File

@@ -1,457 +0,0 @@
# 后台管理最小方案
> 文档版本v1.8
> 最后更新2026-04-07 17:23:15
## 1. 目标
后台第一版不是做一个“大而全配置表单”,而是先做一套最小可运营壳,解决这 3 个问题:
1. 共享资源能被对象化管理
2. `event` 能引用这些资源并组装配置
3. 能从 source -> build -> release 完成发布
一句话:
> 后台第一版管理的是“资源对象 + 引用关系 + 发布流程”,不是“直接编辑一个越来越大的 JSON 文件”。
## 2. 设计边界
### 2.1 后台应该管理什么
- 地图对象
- 赛场 / KML 对象
- 资源包对象
- Event 基础信息
- Event 对资源对象的引用
- Build / Release 发布记录
### 2.2 后台不应该一开始就做什么
- 把所有配置项做成一个超大表单
- 把玩家运行时设置也塞进发布配置
- 把前端运行时编译逻辑搬到后端后台
- 直接按 `event` 管理所有资源文件
### 2.3 与未来 APP 的关系
后台配置不是“小程序后台”,而是统一配置运营后台。
所以第一版开始就要坚持:
- 资源对象平台级复用
- `release / manifest` 终端中立
- 同一份发布结果同时服务小程序和未来 APP
## 3. 第一版建议模块
### 3.1 地图管理
作用:
- 管理可复用地图对象和版本
建议字段:
- 地图名称
- 地图编码
- 版本号
- `mapmeta` 文件
- 瓦片根路径
- 覆盖范围
- 状态
- 备注
第一版动作:
- 新建地图
- 新建地图版本
- 查看地图版本详情
- 停用某个版本
### 3.2 赛场 / KML 管理
作用:
- 把 KML / GeoJSON / 控制点数据做成独立可复用对象
建议字段:
- 赛场名称
- 赛场编码
- 版本号
- 原始文件
- 控制点数量
- 边界摘要
- 状态
- 备注
第一版动作:
- 上传 KML
- 新建赛场版本
- 查看赛场版本详情
- 停用某个版本
### 3.3 资源包管理
作用:
- 管理内容页、音频、主题等共享资源
建议字段:
- 资源包名称
- 资源包编码
- 版本号
- 内容页入口
- 音频包入口
- 主题配置入口
- 状态
- 备注
第一版动作:
- 新建资源包
- 新建资源包版本
- 查看资源包版本详情
### 3.4 Event 组装
作用:
- 管理业务活动本身,并引用共享资源对象
建议字段:
- Event 名称
- Event 编码 / public id
- 简介
- 状态
- 当前选用地图版本
- 当前选用赛场版本
- 当前选用资源包版本
- 当前玩法模式
- 少量覆盖项
- 展示定义(`EventPresentation`
- 内容包(`ContentBundle`
第一版只开放少量覆盖项:
- 标题
- 摘要
- 路线编码
- 玩法模式
- 少量规则开关
不要第一版就开放:
- 全量 `presentation.*`
- 全量 `telemetry.*`
- 全量 `debug.*`
### 3.5 Build / Release 管理
作用:
- 把 source config 变成 preview build再发布成正式 release
页面需要看到:
- source 列表
- build 列表
- build 状态
- release 列表
- 当前生效 release
- 当前绑定的 `presentation / bundle / runtime`
- 发布人
- 发布时间
第一版动作:
- 从 source 生成 build
- 查看 build 产物
- 发布 build
- 回滚当前 release
- 查看 release 当前绑定的 `presentation / bundle / runtime`
## 4. 后台第一版页面建议
按当前运维工作流,建议先按“地图主流程”组织,而不是把底层对象散着摆:
1. 地图列表页
2. 地图详情页
3. KML / 赛道管理页
4. 活动管理页
5. 发布中心
6. 资源总览页
这 6 页的目标是把“资源录入 -> 地图管理 -> 赛道管理 -> 活动绑定 -> 发布”跑通。
补充:
- 当前第二阶段已经把 `EventPresentation``ContentBundle` 收成正式最小对象
- `EventRelease` 现在允许同时绑定:
- `presentation`
- `content bundle`
- `runtime binding`
## 5. 对象模型建议
后台第一版建议围绕这些对象展开:
- `Map`
- `MapVersion`
- `Playfield`
- `PlayfieldVersion`
- `ResourcePack`
- `ResourcePackVersion`
- `Event`
- `EventConfigSource`
- `EventConfigBuild`
- `EventRelease`
关键原则:
- 共享资源按对象库管理
- `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 选择地图版本"]
B["录入赛场版本"] --> D
C["录入资源包版本"] --> D
D --> E["保存 Source Config"]
E --> F["生成 Preview Build"]
F --> G["检查 Manifest / Asset Index"]
G --> H["发布 Release"]
H --> I["前台 Launch 使用新 Release"]
```
## 7. 第一版后端接口需求
后台真正开做前,后端最好先补齐下面这批接口:
### 7.1 地图对象
- `GET /admin/maps`
- `POST /admin/maps`
- `GET /admin/maps/{id}`
- `POST /admin/maps/{id}/versions`
### 7.2 赛场对象
- `GET /admin/playfields`
- `POST /admin/playfields`
- `GET /admin/playfields/{id}`
- `POST /admin/playfields/{id}/versions`
### 7.3 资源包对象
- `GET /admin/resource-packs`
- `POST /admin/resource-packs`
- `GET /admin/resource-packs/{id}`
- `POST /admin/resource-packs/{id}/versions`
### 7.4 Event 组装
- `GET /admin/events`
- `POST /admin/events`
- `GET /admin/events/{id}`
- `PUT /admin/events/{id}`
- `POST /admin/events/{id}/source`
当前状态:
- 已实现
- 当前 `source` 组装方式是:
- 选择 `map version`
- 选择 `playfield version`
- 可选选择 `resource pack version`
-`gameModeCode`
- 可传少量 `overrides`
- backend 直接生成一版可进入现有 build 流程的 source config
### 7.5 Build / Release
- `GET /admin/events/{id}/sources`
- `POST /admin/sources/{id}/build`
- `GET /admin/builds/{id}`
- `POST /admin/builds/{id}/publish`
- `POST /admin/events/{id}/rollback`
当前状态:
- 已实现
- 当前已可用:
- `GET /admin/events/{eventPublicID}/pipeline`
- `POST /admin/sources/{sourceID}/build`
- `GET /admin/builds/{buildID}`
- `POST /admin/builds/{buildID}/publish`
- `POST /admin/events/{eventPublicID}/rollback`
- 当前 rollback 方式:
- 显式传 `releaseId`
- 只允许切回同一 event 下的已发布 release
## 8. 第一版不要做的事
为了避免项目被后台表单拖死,第一版明确不做:
- 全量 schema 可视化编辑器
- 拖拽式配置搭建器
- 复杂权限系统
- 资源批量编排器
- 多层审批流
这些都应该等“最小配置运营闭环”跑通后再说。
## 9. 建议开发顺序
建议按下面顺序推进:
1. 先补资源对象模型和接口
2. 再补 Event 引用组装接口
3. 再补 Build / Release 运营接口
4. 最后再做后台页面
原因:
- 没有稳定后端对象模型,后台页面会反复推翻
- 先把对象和发布链条定住,前后端才不会互相拖拽
当前进度:
1. 资源对象模型和 `/admin/maps``/admin/playfields``/admin/resource-packs` 已完成
2. `Event` 组装接口 `/admin/events``/admin/events/{id}/source` 已完成
3. `pipeline/build/publish` 后台聚合接口已完成
4. `rollback` 已完成
5. 运维入口第一期已完成:
- `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. 一句话结论
是的,后面需要一版后台管理界面。
但第一版不应该是“配置大全编辑器”,而应该是:
> 共享资源管理 + Event 组装 + Build / Release 发布 的最小运营后台。

View File

@@ -1,936 +0,0 @@
# 开发说明
> 文档版本v1.45
> 最后更新2026-04-07 18:15:01
## 1. 环境变量
参考 [`.env.example`](D:/dev/cmr-mini/backend/.env.example)。
当前最关键的变量:
- `APP_ENV`
- `HTTP_ADDR`
- `DATABASE_URL`
- `JWT_ACCESS_SECRET`
- `AUTH_SMS_PROVIDER`
- `AUTH_DEV_SMS_CODE`
- `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`
- `ASSET_BUCKET_ROOT`
- `OSSUTIL_PATH`
- `OSSUTIL_CONFIG_FILE`
## 2. 本地启动
```powershell
cd D:\dev\cmr-mini\backend
.\start-backend.ps1
```
如果你想固定跑开发工作台常用端口 `18090`,直接执行:
```powershell
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 + 发布当前玩法`
- `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优先使用
- `POST /dev/client-logs`
- 然后在 workbench 的 `前端调试日志` 面板里查看
- 如果需要判断前端到底拿到了哪份配置,优先看 workbench 的:
- `当前 Launch 实际配置摘要`
- 这块会直接显示:
- `configUrl`
- `releaseId`
- `manifestUrl`
- `schemaVersion`
- `playfield.kind`
- `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 时受跨域影响
- 它不替代正式客户端加载逻辑
- 正式客户端仍必须直接消费 `launch.config.configUrl``launch.resolvedRelease.manifestUrl`
- `前端调试日志` 也是调试专用能力:
- backend 当前只在内存里保留最近 200 条
- 适合前端把关键事实直接打进来,避免只靠截图和口头描述
- 不替代正式生产日志体系
- `Bootstrap Demo` 准备出的联调文案也已换成中文样例:
- `领秀城公园顺序赛`
- `领秀城公园积分赛`
- `领秀城公园多赛道挑战`
- 当前“准备页地图预览 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. 活动卡片列表最小摘要
当前 backend 已为以下入口统一补齐活动卡片最小摘要字段:
- `/cards`
- `/home`
- `/me/entry-home`
当前字段集:
- `title`
- `subtitle`
- `summary`
- `status`
- `statusCode`
- `timeWindow`
- `ctaText`
- `coverUrl`
- `isDefaultExperience`
- `eventType`
- `currentPresentation`
- `currentContentBundle`
当前派生规则:
- `summary`
- 无值时回退为:`当前暂无活动摘要`
- `status`
- `running` -> `进行中`
- `upcoming` -> `即将开始`
- `ended` -> `已结束`
- 其余 -> `状态待确认`
- `timeWindow`
-`cards.starts_at / ends_at` 派生
- 缺失时回退为:`时间待公布`
- `ctaText`
- 默认体验活动:`进入体验`
- 进行中:`进入活动`
- 已结束:`查看回顾`
- 其余:`查看详情`
- `currentPresentation / currentContentBundle`
- 当前继续表示已发布 release 实际绑定摘要
- 不是 event 草稿默认值
默认会设置:
- `APP_ENV=development`
- `HTTP_ADDR=:18090`
- `DATABASE_URL=postgres://postgres:asdf*123@192.168.100.77:5432/cmr20260401?sslmode=disable`
- `AUTH_SMS_PROVIDER=console`
- `WECHAT_MINI_DEV_PREFIX=dev-`
启动后可直接打开:
- [http://127.0.0.1:18090/dev/workbench](http://127.0.0.1:18090/dev/workbench)
当前 workbench 已覆盖两类调试链:
- 用户主链:`bootstrap -> auth -> entry/home -> event play/launch -> session -> result`
- 后台运营链:`maps/playfields/resource-packs -> admin event source -> build -> publish -> rollback`
- 第一阶段生产骨架联调台:`places -> map-assets -> tile-releases -> course-sources -> course-sets -> course-variants -> runtime-bindings`
- 第三刀最小接线验证:`runtimeBinding -> release -> launch.runtime`
- 第四刀发布闭环验证:`runtimeBinding -> publish(runtimeBindingId) -> release -> launch.runtime`
- 活动运营域第二阶段验证:`presentation -> content bundle -> publish(presentationId, contentBundleId, runtimeBindingId) -> release`
- 活动运营域第二阶段第二刀验证:`event detail / play / launch -> presentation + content bundle 摘要`
- 活动运营域第二阶段第三刀验证:`release 摘要闭环 + content bundle import`
- 活动运营域第二阶段第四刀验证:`presentation import -> event 默认 active 绑定 -> publish 空参继承`
- workbench 一键验证增强:`一键默认绑定发布``一键补齐 Runtime 并发布`
- `/dev/bootstrap-demo` 现在也会回填最小生产骨架:`place / map asset / tile release / course source / course set / course variant / runtime binding`
### 2.1 当前推荐验证方式
如果目标是验证“从测试数据准备到 release 继承是否完整”,优先使用 workbench 的一键流,而不是手工逐个点按钮。
当前推荐顺序:
1. `Bootstrap Demo只准备数据`
2. 选择一种玩法入口:
- `Use Classic Demo`
- `Use Score-O Demo`
- `Use Manual Variant Demo`
3. 如果只是想看发布过程,点 `Bootstrap + 发布当前玩法`
4. 如果想只测发布链,点 `一键补齐 Runtime 并发布`
5. 如果想直接验整条链,点 `一键标准回归`
当前这几个按钮的职责已经拆开:
- `Bootstrap Demo只准备数据`
- 只负责准备 demo event / source / build / release / runtime 等测试数据
- 不会基于当前玩法再额外重新发布一版
- `Bootstrap + 发布当前玩法`
- 会先执行一遍 `Bootstrap Demo`
- 然后对当前选中的玩法执行“发布活动配置(自动补 Runtime
- `一键补齐 Runtime 并发布`
- 不再隐式 bootstrap
- 只基于当前已选玩法和当前表单上下文执行发布链
当前这条一键链会自动完成:
- demo event / source / build / release 准备
- presentation 导入
- content bundle 导入
- event 默认 active 绑定保存
- 最小生产骨架准备:
- `place`
- `map asset`
- `tile release`
- `course source`
- `course set`
- `course variant`
- `runtime binding`
- publish
- release 回读校验
- `play / launch / result / history` 回归汇总
- demo 活动残留 ongoing session 清理:
- 会把 demo event 下历史遗留的 `launched / running` session 自动改成 `cancelled`
- 真实输入替换第一刀:
- `CourseSource.fileUrl` 当前已切到真实 KML
- `https://oss-mbh5.colormaprun.com/gotomars/kml/lxcb-001/10/c01.kml`
- `https://oss-mbh5.colormaprun.com/gotomars/kml/lxcb-001/10/c02.kml`
- `TileRelease.tileBaseUrl / metaUrl` 当前已切到真实地图资源:
- `https://oss-mbh5.colormaprun.com/gotomars/map/lxcb-001/tiles/`
- `https://oss-mbh5.colormaprun.com/gotomars/map/lxcb-001/tiles/meta.json`
- manual 多赛道 demo 当前已使用两条真实赛道输入:
- `variant_a -> c01.kml`
- `variant_b -> c02.kml`
- 显式玩法测试入口:
- 顺序赛:`evt_demo_001 -> rel_demo_001 -> classic-sequential.json`
- 积分赛:`evt_demo_score_o_001 -> rel_demo_score_o_001 -> score-o.json`
- 多赛道:`evt_demo_variant_manual_001 -> rel_demo_variant_manual_001`
当前日志能力:
- 每一步都会写到“响应日志”
- 失败时会直接输出:
- 错误消息
- stack
- 最后一次 curl
- 成功时“预期结果”面板会直接给出:
- `Release ID`
- `Presentation`
- `Content Bundle`
- `Runtime Binding`
- `判定`
- 成功跑完标准回归后,“回归结果汇总”会直接给出:
- `发布链`
- `Play`
- `Launch`
- `Result`
- `History`
- `Session ID`
- `总判定`
- workbench 现在还支持查看 frontend 主动上报的调试日志:
- `拉取前端日志`
- `清空前端日志`
- 前端建议最少带:
- `eventId`
- `releaseId`
- `sessionId`
- `manifestUrl`
- `route`
- `game.mode`
- `playfield.kind`
- 当前页面阶段或动作名
### 2.2 前端调试日志最小约定
dev 环境下frontend 可直接把关键调试事实发到 backend
- `POST /dev/client-logs`
建议请求体最少包含:
```json
{
"source": "miniprogram",
"level": "info",
"category": "runtime",
"message": "map page loaded manifest",
"eventId": "evt_demo_score_o_001",
"releaseId": "rel_xxx",
"sessionId": "sess_xxx",
"manifestUrl": "https://oss-mbh5.colormaprun.com/...",
"route": "pages/map/map",
"occurredAt": "2026-04-03T16:16:38+08:00",
"details": {
"schemaVersion": "1",
"playfield.kind": "control-set",
"game.mode": "score-o",
"phase": "map-init"
}
}
```
当前说明:
- `source`:建议填终端来源,例如 `miniprogram`
- `level`:建议填 `info / warn / error`
- `category`:建议填 `launch / runtime / cache / network`
- `message`:一句话说明当前发生了什么
- `details`放结构化调试细节backend 原样收下
辅助接口:
- `GET /dev/client-logs?limit=50`
- `DELETE /dev/client-logs`
## 3. 当前开发约定
### 3.0 玩家进入规则
当前要明确一条玩家链路规则:
- 玩家进入游戏,必须基于“已发布 release”
- 不能基于:
- event 草稿默认绑定
- 未发布 presentation
- 未发布 content bundle
- 未发布 runtime
当前接口中的:
- `currentPresentation`
- `currentContentBundle`
在玩家链路里表示的是:
- 当前已发布 release 上实际绑定的展示版本摘要
- 当前已发布 release 上实际绑定的内容包摘要
不是:
- event 草稿默认值摘要
所以如果当前 release 还没绑定这些对象,玩家页看到空值是正常行为。前端页面应优先:
-`play.canLaunch` 判定是否允许进入
- 把空值解释成“当前未发布或当前发布未绑定”
当前 `canLaunch` 已按正式进入规则收紧:
- 只有当当前 event 满足以下条件时,`play.canLaunch = true`
- event `status = active`
- 已存在当前发布 release
- 当前发布 release 有 `manifest`
- 当前发布 release 已绑定 `runtime`
- 当前发布 release 已绑定 `presentation`
- 当前发布 release 已绑定 `content bundle`
当前 `POST /events/{eventPublicID}/launch` 也已与 `canLaunch` 保持同一套前置条件。
### 3.1 开发阶段先不用 Redis
当前第一版全部依赖:
- PostgreSQL
- JWT
- refresh token 持久化
Redis 后面只在需要性能优化、限流或短期票据缓存时再接。
### 3.2 开发环境短信
当前默认可走 `console` provider。
用途:
- 本地联调无需接真实短信供应商
### 3.3 微信小程序开发态
当前支持 `dev-` 前缀 code。
适合:
- 后端联调
- workbench 快速验证
### 3.4 本地配置目录
当前支持从根目录 [event](D:/dev/cmr-mini/event) 导入本地配置文件。
相关环境变量:
- `LOCAL_EVENT_DIR`
- `ASSET_BASE_URL`
作用:
- `LOCAL_EVENT_DIR` 决定本地 source config 从哪里读
- `ASSET_BASE_URL` 决定 preview build 时如何把相对资源路径归一化成可运行 URL
- `ASSET_PUBLIC_BASE_URL` 决定 publish 时如何把公开 URL 映射到 OSS 对象 key
- `ASSET_BUCKET_ROOT` 决定发布对象上传到哪个 bucket 根路径
- `OSSUTIL_PATH``OSSUTIL_CONFIG_FILE` 决定 backend 发布 manifest 时使用哪个 OSS 客户端
## 4. Migration
当前 migration 文件在 [migrations](D:/dev/cmr-mini/backend/migrations)。
执行原则:
1. 按编号顺序执行
2. schema 变更只通过新增 migration 完成
3. 不直接改线上已执行 migration
## 5. 开发工作台
### `POST /dev/bootstrap-demo`
它会保证 demo 数据存在:
- `tenant_demo`
- `mini-demo`
- `evt_demo_001`
- `rel_demo_001`
- `card_demo_001`
### `GET /dev/workbench`
这是当前最重要的联调工具。
可以直接测试:
- 登录
- 入口解析
- 首页聚合
- event play
- 第一阶段生产骨架对象
- 配置导入、preview build、publish build
- launch
- session start / finish
- result
- profile
补充说明:
- `publish build` 现在会真实上传 `manifest.json``asset-index.json` 到 OSS
- 如果上传失败,接口会直接报错,不再出现“数据库里已有 release但 OSS 上没有对象”的假成功
- `Save Event Defaults` 会把当前 event 的默认 active 绑定写入:
- `currentPresentationId`
- `currentContentBundleId`
- `currentRuntimeBindingId`
- 之后 `Publish Build` 如果不显式填写这三项,会优先继承 event 默认 active 绑定
并且支持:
- quick flow
- scenario 保存/导入/导出
- curl 导出
- request history
当前第一阶段生产骨架联调台只做:
- `list`
- `create`
- `detail`
- `binding`
明确不做:
- 正式后台 UI
- `edit`
- `delete`
- `batch`
- 审核流
活动运营域第二阶段当前也只做最小动作:
- `list`
- `create`
- `detail`
- `publish 绑定`
- `import`
## 6. 当前推荐联调顺序
### 场景一:小程序快速进入
1. `bootstrap-demo`
2. `login/wechat-mini`
3. `me/entry-home`
4. `events/{id}/play`
5. `events/{id}/launch`
6. `sessions/{id}/start`
7. `sessions/{id}/finish`
8. `sessions/{id}/result`
### 场景二APP 主身份
1. `auth/sms/send`
2. `auth/login/sms`
3. `me/entry-home`
4. `launch`
5. `session`
6. `result`
### 场景三:微信轻账号绑定手机号
1. `login/wechat-mini`
2. `auth/sms/send` with `scene=bind_mobile`
3. `auth/bind/mobile`
4. `me/profile`
### 场景四:配置发布到可启动 release
1. `bootstrap-demo`
2. `dev/events/{eventPublicID}/config-sources/import-local`
3. `dev/config-builds/preview`
4. `dev/config-builds/publish`
5. `events/{id}`
6. `events/{id}/launch`
### 场景五:第一阶段生产骨架最小闭环
`/dev/workbench``后台运营` 模式中,按下面顺序操作:
1. `List Places``Create Place`
2. 在该 `Place``Create Map Asset`
3. 在该 `MapAsset``Create Tile Release`
4. `Create Course Source`
5. 在该 `MapAsset``Create Course Set`
6. 在该 `CourseSet``Create Variant`
7. `Create Runtime Binding`
成功后应能拿到这些 ID
- `placeId`
- `mapAssetId`
- `tileReleaseId`
- `courseSourceId`
- `courseSetId`
- `courseVariantId`
- `runtimeBindingId`
建议第一次联调时用这组最小规则:
- `Place` 先建 1 个
- 每个 `Place` 先只建 1 个 `MapAsset`
- 每个 `MapAsset` 先只建 1 个 `TileRelease`
- 每个 `CourseSet` 先只建 1 个默认 `CourseVariant`
- `RuntimeBinding` 先只绑定当前正在验证的 `Event`
这条链当前只验证对象关系闭环,不验证:
- 发布链切换
- `launch` 返回运行对象字段
- `EventPresentation`
- `ContentBundle`
### 场景六:第三刀最小接线验证
`/dev/workbench``后台运营` 模式中,先完成“场景五”,再按下面顺序操作:
1. `Get Pipeline`
2. 确认当前 `Release ID`
3. 填或复用 `Runtime Binding ID`
4. `Bind Runtime`
5. `Get Release`
6. 切回 `前台联调`
7. 对同一个 `event` 执行 `Launch`
### 场景七:活动运营域第二阶段最小闭环
`/dev/workbench``后台运营` 模式中,按下面顺序操作:
1. `Get Event`
2. `Create Presentation`
3. `Create Bundle`
4. `Assemble Source`
5. `Build Source`
6. 在发布区填:
- `Runtime Binding ID`
- `Presentation ID`
- `Content Bundle ID`
7. `Publish Build`
8. `Get Release`
成功后应能在 release 返回中看到:
- `runtime`
- `presentation`
- `contentBundle`
并且这 3 类绑定当前都已固化到 `event_release`
成功后应能看到:
- `GET /admin/releases/{releasePublicID}` 返回 `runtime`
- `POST /events/{eventPublicID}/launch` 返回 `launch.runtime`
当前阶段的约束是:
- 只新增 `runtime` 字段块
- 不改旧的:
- `resolvedRelease`
- `business`
- `variant`
- release 如果没挂 `runtimeBindingId`,则 `launch.runtime` 为空
### 场景八:活动运营域第二阶段第三刀验证
`/dev/workbench``后台运营` 模式中,先完成“场景七”,再按下面顺序操作:
1. `Create Presentation` 或直接复用现有 `Presentation ID`
2. `Import Bundle`
3. `Get Bundle`
4. `Get Pipeline`
5. `Publish Build`
6. `Get Release`
7. 切回 `前台联调`
8. `Event Detail`
9. `Event Play`
10. `Launch`
成功后应能同时看到这三组摘要:
- `release.presentation.templateKey / version`
- `release.contentBundle.bundleType / version`
- `release.runtime.placeId / mapId / tileReleaseId / courseVariantId`
同时客户端消费侧应保持一致:
- `GET /events/{eventPublicID}`
- `GET /events/{eventPublicID}/play`
- `POST /events/{eventPublicID}/launch`
当前 Content Bundle Import 只做统一导入入口,不做复杂资源平台:
- 输入:
- `title`
- `bundleType`
- `sourceType`
- `manifestUrl`
- `version`
- `assetManifest`
- 输出:
- `bundleId`
- `bundleType`
- `version`
- `assetManifest`
- `status`
### 场景七:第四刀发布闭环验证
`/dev/workbench``后台运营` 模式中,先完成“场景五”,再按下面顺序操作:
1. `Create Runtime Binding`
2. `Get Pipeline`
3. 确认 `Build ID`
4. 在发布区填 `Runtime Binding ID`
5. `Publish Build`
6. `Get Release`
7. 切回 `前台联调`
8. 对同一个 `event` 执行 `Launch`
成功后应能看到:
- `POST /admin/builds/{buildID}/publish` 返回带 `runtime`
- `GET /admin/releases/{releasePublicID}` 返回同一条 `runtime`
- `POST /events/{eventPublicID}/launch` 返回同一条 `launch.runtime`
当前第四刀的兼容要求是:
- 旧的“先 `publish`,再 `bind runtime`”路径继续可用
- 新的“`publish` 时直接传 `runtimeBindingId`”优先推荐
- 不修改旧的:
- `resolvedRelease`
- `business`
- `variant`
## 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. 抽出更通用的 `play context -> launch` 模型
2. 补赛事与报名层
3. 补页面配置和白标首页
4. 再考虑实时网关票据
不要跳回去把玩法规则塞进 backend。

File diff suppressed because it is too large Load Diff

View File

@@ -1,314 +0,0 @@
# 数据模型
> 文档版本v1.6
> 最后更新2026-04-07 16:29:08
当前 migration 共 15 版。
## 1. 迁移清单
- [0001_init.sql](D:/dev/cmr-mini/backend/migrations/0001_init.sql)
- [0002_launch.sql](D:/dev/cmr-mini/backend/migrations/0002_launch.sql)
- [0003_home.sql](D:/dev/cmr-mini/backend/migrations/0003_home.sql)
- [0004_results.sql](D:/dev/cmr-mini/backend/migrations/0004_results.sql)
- [0005_config_pipeline.sql](D:/dev/cmr-mini/backend/migrations/0005_config_pipeline.sql)
- [0006_resource_objects.sql](D:/dev/cmr-mini/backend/migrations/0006_resource_objects.sql)
- [0007_variant_minimal.sql](D:/dev/cmr-mini/backend/migrations/0007_variant_minimal.sql)
- [0008_production_skeleton.sql](D:/dev/cmr-mini/backend/migrations/0008_production_skeleton.sql)
- [0009_event_ops_phase2.sql](D:/dev/cmr-mini/backend/migrations/0009_event_ops_phase2.sql)
- [0010_event_default_bindings.sql](D:/dev/cmr-mini/backend/migrations/0010_event_default_bindings.sql)
- [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. 表分组
### 2.1 多租户与入口
- `tenants`
- `entry_channels`
职责:
- 识别品牌壳
- 识别渠道入口
- 承接后续俱乐部 / 政府公众号 / H5 / 二维码入口
### 2.2 用户与登录
- `users`
- `login_identities`
- `auth_sms_codes`
- `auth_refresh_tokens`
职责:
- 平台级用户
- 多身份登录
- 验证码记录
- refresh token 持久化
当前身份示例:
- `mobile`
- `wechat_mini_openid`
- `wechat_unionid`
- `guest`
### 2.3 业务对象与配置发布
- `events`
- `event_releases`
职责分工:
- `events` 管业务对象身份和展示
- `event_releases` 管发布后的运行配置入口
关键字段:
- `events.current_release_id`
- `event_releases.release_public_id`
- `event_releases.config_label`
- `event_releases.manifest_url`
- `event_releases.manifest_checksum_sha256`
- `event_releases.route_code`
### 2.4 首页与入口卡片
- `cards`
职责:
- 支撑首页卡片
- 运营入口聚合
- tenant/channel 维度展示控制
- 默认体验活动标记
当前补充字段:
- `cards.is_default_experience`
当前说明:
- 活动卡片列表第一刀先通过卡片显式字段承接“默认体验活动 / 普通活动”区分
- `timeWindow / ctaText / status` 当前先由 backend 摘要层派生,不再额外新增对象层级
### 2.5 运行态
- `game_sessions`
- `session_results`
职责:
- 固化一局游戏
- 固化该局绑定的 release
- 固化局后结果摘要
### 2.6 配置构建与发布资产
- `event_config_sources`
- `event_config_builds`
- `event_release_assets`
职责:
- 保存编辑态 source config
- 保存构建后的 manifest 和 asset index
- 保存正式 release 关联的资产清单
### 2.7 共享资源对象
- `maps`
- `map_versions`
- `playfields`
- `playfield_versions`
- `resource_packs`
- `resource_pack_versions`
职责:
- 把地图、KML/赛场、内容资源包做成可复用对象
- 支撑后台第一版按“资源对象 + 版本”管理
- 给后续 event 引用组装和发布流程提供稳定边界
### 2.8 第一阶段生产骨架
- `places`
- `map_assets`
- `tile_releases`
- `course_sources`
- `course_sets`
- `course_variants`
- `map_runtime_bindings`
职责:
- 把地图运行域和活动运行绑定正式落库
- 把 KML 输入源和最终赛道方案拆开
- 在不推翻当前 `events / event_releases / game_sessions` 主链的前提下,增量补生产骨架
### 2.9 活动运营域第二阶段
- `event_presentations`
- `content_bundles`
职责:
- 把活动展示定义和内容包从临时 JSON 概念收成正式对象
-`event_releases` 明确绑定:
- `presentation_id`
- `content_bundle_id`
- `runtime_binding_id`
- 保持现有 `resolvedRelease / business / variant / runtime` 稳定返回不变
### 2.10 Event 默认 active 绑定
- `events.current_presentation_id`
- `events.current_content_bundle_id`
- `events.current_runtime_binding_id`
职责:
- 固化 event 当前默认 active
- `presentation`
- `content bundle`
- `runtime binding`
- 支撑 publish 在未显式传入时的默认继承
- 不改变前端当前稳定消费的 release / launch 字段语义
## 3. 当前最关键的关系
### `tenant -> entry_channel`
一个 tenant 下可有多个渠道入口。
### `user -> login_identity`
一个平台用户可绑定多个登录身份。
### `event -> event_release`
一个 event 可有多个 release。
客户端真正进入游戏时,最终会消费其中一份 release 的 manifest。
### `event_release -> game_session`
一局 session 必须绑定一份明确的 release。
这是当前系统最关键的配置驱动约束。
### `game_session -> session_result`
一局结束后可有一条结果摘要。
### `event_config_source -> event_config_build -> event_release`
这是后续配置生命周期主链:
- source 是编辑态
- build 是构建态
- release 是发布态
### `map -> map_version`
一张地图可有多个版本。
### `playfield -> playfield_version`
一份赛场/KML 可有多个版本。
### `resource_pack -> resource_pack_version`
一套内容/音频/主题资源可有多个版本。
### `place -> map_asset -> tile_release`
- `Place` 是地点上层对象
- `MapAsset` 是地点下的一张具体地图资产
- `TileRelease` 是某张地图的具体瓦片发布版本
### `course_source -> course_variant -> course_set`
- `CourseSource` 是原始输入源,例如 KML
- `CourseVariant` 是最终可运行赛道方案
- `CourseSet` 是一组方案集合
### `event_release -> map_runtime_binding`
- `event_releases.runtime_binding_id` 已预留给第一阶段生产骨架
- 当前客户端联调仍以 `resolvedRelease` 为主
- 第二阶段会继续把 `placeId / mapId / tileReleaseId / courseVariantId` 收到 `launch` 稳定返回中
### `event -> event_presentation`
- 一个 `event` 可有多条展示定义
- 当前最小用途是给 `event_release` 提供明确绑定目标
### `event -> content_bundle`
- 一个 `event` 可有多条内容包
- 当前最小用途是给 `event_release` 提供内容资源绑定目标
### `event_release -> presentation / content_bundle / runtime`
- 这是当前活动运营域第二阶段的最小闭环
- `release` 现在可以稳定固化:
- 展示定义
- 内容包
- 运行绑定
## 4. 当前已落库但仍应注意的边界
### 4.1 不要把玩法细节塞回事件主表
当前数据库只记录:
- 发布关系
- manifest 入口
- 结果摘要
玩法解释器仍应留在游戏客户端。
### 4.2 不要让历史局跟随当前 release 漂移
即使 event 后面发布新版本:
- 旧 session 仍然指向旧 `event_release_id`
- 旧 result 仍然对应旧 release
### 4.3 不要把登录态和运行态混在一起
当前已有两种 token
- `access_token`
- `sessionToken`
后面如果加实时网关,也应继续区分。
## 5. 当前缺口
当前 schema 还没有这些模块:
- `competitions`
- `registrations`
- `page_configs`
- `clubs`
- `client_devices`
- 实时票据 / 网关票据
这些后面要按真正业务需要补 migration不要先拍脑袋建大而全表。

View File

@@ -1,308 +0,0 @@
# 核心流程
> 文档版本v1.3
> 最后更新2026-04-03 18:16:19
## 1. 总流程
```mermaid
flowchart LR
A["Entry Resolve"] --> B["Auth"]
B --> C["Home / Cards"]
C --> D["Event Play"]
D --> E["Resolve Release"]
E --> F["Launch Session"]
F --> G["Client Load Manifest"]
G --> H["Session Start / Finish"]
H --> I["Result / History"]
```
补充说明:
- 这条主流程既服务当前小程序,也要服务未来 APP
- 终端差异主要体现在登录方式、设备能力和运行时 UI不应拆成两套业务流程
## 2. 入口解析
入口层先解决:
- 用户从哪个渠道进来
- 当前归属哪个 `tenant`
- 当前品牌壳和首页卡片应该加载什么
当前对应接口:
- `GET /entry/resolve`
- `GET /home`
- `GET /cards`
- `GET /me/entry-home`
## 3. 登录流程
### 3.1 APP
APP 当前主链是手机号验证码:
1. `POST /auth/sms/send`
2. `POST /auth/login/sms`
3. 返回 `access_token + refresh_token`
说明:
- APP 是未来更强接入端,后端设计必须预留身体资料、设备绑定、遥测摘要等扩展空间
### 3.2 微信小程序
微信小程序当前主链是:
1. 客户端 `wx.login`
2. `POST /auth/login/wechat-mini`
3. 后端换取 `openid`
4. 返回 `access_token + refresh_token`
开发环境也支持 `dev-` 前缀 code。
### 3.3 绑定与合并
当小程序用户后续绑定手机号时:
1. 先发 `bind_mobile` 场景验证码
2. `POST /auth/bind/mobile`
3. 如果手机号已属于别的用户,则合并到手机号主账号
当前策略是:
- 手机号账号优先
- 微信轻账号并入手机号账号
## 4. 首页流程
首页不是固定首页,而是“入口上下文首页”。
当前聚合接口:
- `GET /me/entry-home`
它会返回:
- 当前用户
- 当前 tenant
- 当前 channel
- 当前 cards
- 继续中的 session
- 最近一局 session
## 5. Event Play 流程
活动详情页或开始前准备页不应该只拿 `event`
它还必须拿到:
- 当前是否可启动
- 当前会落到哪份 `release`
- 当前是否存在多赛道 `variant` 编排
- 是否有 ongoing session
- 当前推荐动作是什么
补充规则:
- `play.canLaunch` 不是“有 event 就能进”
- 它当前表示“当前发布 release 已完整可启动”
- 最小要求为:
- 已发布 release 存在
- manifest 存在
- runtime 已绑定
- presentation 已绑定
- content bundle 已绑定
当前聚合接口:
- `GET /events/{eventPublicID}/play`
它会返回:
- `event`
- `release`
- `resolvedRelease`
- `currentPresentation`
- `currentContentBundle`
- `play.assignmentMode`
- `play.courseVariants[]`
- `play.canLaunch`
- `play.primaryAction`
- `play.launchSource`
- `play.ongoingSession`
- `play.recentSession`
当前多赛道第一阶段约束:
- `play.assignmentMode` 只先支持最小口径:
- `manual`
- `random`
- `server-assigned`
- `play.courseVariants[]` 只先返回准备页必需字段:
- `id`
- `name`
- `description`
- `routeCode`
- `selectable`
## 6. Launch 流程
### 6.1 当前原则
启动一局游戏时,不是“启动一个 event”。
而是:
> 基于 event 当前可启动的 release创建一条固化 release 的 session。
### 6.2 当前接口
- `POST /events/{eventPublicID}/launch`
当前请求体支持:
- `releaseId`
- `variantId`
- `clientType`
- `deviceKey`
当前返回会带:
- `launch.source`
- `launch.resolvedRelease`
- `launch.variant`
- `launch.presentation`
- `launch.contentBundle`
- `launch.config`
- `launch.business.sessionId`
- `launch.business.sessionToken`
补充约束:
- `launch` 是统一业务启动入口,不应因为 APP / 小程序差异复制两套接口
- 终端差异通过 `clientType``deviceKey`、后续能力声明字段处理
### 6.3 客户端应如何使用
客户端进入游戏前,应以返回中的这几项为准:
- `launch.resolvedRelease.releaseId`
- `launch.resolvedRelease.manifestUrl`
- `launch.resolvedRelease.manifestChecksumSha256`
- `launch.variant.id`
- `launch.variant.assignmentMode`
活动运营域第二阶段第二刀新增建议消费摘要:
- `launch.presentation.presentationId`
- `launch.contentBundle.contentBundleId`
补充说明:
- 如果活动声明了多赛道 variant`launch` 会返回本局最终绑定的 `variant`
- 前端可以发起选择,但最终绑定以后端 `launch` 返回为准
- 故障恢复不重新分配 variant
而不是再拿 `event` 自己去猜。
## 7. Session 流程
### 7.1 当前接口
- `GET /sessions/{sessionPublicID}`
- `POST /sessions/{sessionPublicID}/start`
- `POST /sessions/{sessionPublicID}/finish`
- `GET /me/sessions`
### 7.2 鉴权模型
查询接口:
-`access_token`
局内动作接口:
-`sessionToken`
这保证了业务登录态和一局游戏运行态是分开的。
### 7.3 当前状态语义
- `launched`:已创建一局,客户端尚未正式开始
- `running`:客户端已开始本局
- `finished`:正常完成
- `failed`:超时或规则失败
- `cancelled`:主动退出或放弃恢复
补充约束:
- `cancelled``failed` 都不再作为 ongoing session 返回
- “放弃恢复”当前正式收口为 `finish(cancelled)`
- 同一局旧 `sessionToken``finish(cancelled)` 场景允许继续使用
- 第一阶段若活动声明了多赛道session 会固化:
- `assignmentMode`
- `variantId`
- `variantName`
- `routeCode`
### 7.4 幂等要求
- `start` 幂等:
- `launched` -> `running`
- 重复 `start` 不应报错
- `finish` 幂等:
- 第一次进入终态后,重复 `finish` 直接返回当前结果
- 这个约束同时服务小程序故障恢复和未来 APP 重试补报
## 8. 结果流程
### 8.1 当前接口
- `GET /sessions/{sessionPublicID}/result`
- `GET /me/results`
### 8.2 当前 finish payload
`finish` 当前支持上传结果摘要:
- `finalDurationSec`
- `finalScore`
- `completedControls`
- `totalControls`
- `distanceMeters`
- `averageSpeedKmh`
- `maxHeartRateBpm`
### 8.3 结果页约束
结果页应该基于 session 结果查看,不应该回头去查当前 event 当前 release。
因为:
- 一个 event 未来可能发布新版本
- 历史结果必须追溯到当时真实跑过的那份 release
- 如果一场活动存在多个 variant结果与历史摘要也必须能追溯本局 `variantId`
## 9. 当前最应该坚持的流程约束
业务主线应始终保持为:
`entry -> auth -> event play -> resolve release -> launch -> session -> result`
不要退回成:
`event -> launch -> game`
也不要走成:
`mini event -> mini launch -> mini game`
或:
`app event -> app launch -> app game`
业务接口必须保持统一,终端差异只进入上下文,不进入对象模型分叉。

View File

@@ -1,238 +0,0 @@
# 系统架构
> 文档版本v1.0
> 最后更新2026-04-02 08:28:05
## 1. 目标
当前 backend 不是一个“给地图页喂数据的简单服务”,而是一个业务壳后端。
它负责:
- 用户与登录
- 多租户与多入口
- 首页与业务入口聚合
- Event 业务对象
- 配置发布解析
- 启动一局游戏
- session 生命周期
- 结果沉淀
它不负责:
- 解释游戏玩法细节
- 运行时解析复杂地图规则
- 直接下发数据库编辑态对象给客户端
补充约束:
- 这套 backend 必须服务未来 APP不是“小程序专用后端”
- 登录方式可以按终端区分,但业务对象和业务接口不能按端分裂成两套
## 2. 分层
### 2.1 平台层
平台层统一处理:
- `tenant`
- `entry_channel`
- `user`
- `login_identity`
- `auth_refresh_token`
这层是整个平台共用能力。
它必须同时支撑:
- APP
- 微信小程序
- 后续公众号 / H5 / 其他渠道
### 2.2 业务层
业务层统一处理:
- `card`
- `event`
- `event_play`
- `entry_home`
- `profile`
它面向页面和运营入口,但不直接承载游戏规则。
### 2.3 配置发布层
配置发布层统一处理:
- `event_release`
- `manifest_url`
- `manifest_checksum_sha256`
- `route_code`
这层是“客户端真正进入游戏时要消费的运行配置入口”。
这里的发布结构应保持终端中立:
- 不写死为小程序专用结构
- 不直接依赖某个端的页面实现
- 允许 APP 和小程序共用同一份 release / manifest
### 2.4 运行层
运行层统一处理:
- `game_session`
- `session_token`
- `session_results`
这层不关心编辑态,只关心“一局游戏”。
## 3. 最重要的对象关系
### 3.1 `event`
`event` 是业务对象。
它负责:
- 活动身份
- 展示名称
- 业务状态
- 当前指向的发布版本
它不是客户端实际运行的配置文件本体。
### 3.2 `event_release`
`event_release` 是配置发布对象。
它负责:
- 这次发布的 `manifest_url`
- 配置标签 `config_label`
- 可选校验值
- 可选 `route_code`
进入游戏时,客户端真正需要的是这里。
### 3.3 `game_session`
`game_session` 是运行对象。
它必须固化:
- 当前用户
- 当前 event
- 当前实际使用的 `event_release`
- 当前 `session_token`
这样后续哪怕 event 切到新 release旧 session 也不会漂移。
## 4. 配置驱动原则
这套系统必须坚持下面这条原则:
> 业务层先解析出一份可启动的 release客户端再基于这份 release 的 manifest 进入游戏。
不能走成:
> 客户端拿到 event 后自己再去推断该加载哪份配置
所以当前接口都在往这个方向收口:
- `GET /events/{id}/play` 会返回 `resolvedRelease`
- `POST /events/{id}/launch` 会返回 `resolvedRelease`
- `GET /sessions/{id}` 会返回 `resolvedRelease`
- `GET /sessions/{id}/result` 能追溯到当时的 release
补充约束:
- release / manifest 只描述运行配置,不承载某个端的页面状态
- 玩家设置、设备能力差异、运行时 UI 编译由客户端自行处理
- 后端负责“发布可运行配置”,不是“替某个端生成最终运行时 profile”
## 5. 代码分层
### 5.1 入口层
- [main.go](D:/dev/cmr-mini/backend/cmd/api/main.go)
- [app.go](D:/dev/cmr-mini/backend/internal/app/app.go)
- [config.go](D:/dev/cmr-mini/backend/internal/app/config.go)
### 5.2 HTTP 层
- [router.go](D:/dev/cmr-mini/backend/internal/httpapi/router.go)
- [handlers](D:/dev/cmr-mini/backend/internal/httpapi/handlers)
- [middleware](D:/dev/cmr-mini/backend/internal/httpapi/middleware)
### 5.3 用例层
- [service](D:/dev/cmr-mini/backend/internal/service)
当前主要服务:
- `AuthService`
- `EntryService`
- `HomeService`
- `EntryHomeService`
- `EventService`
- `EventPlayService`
- `SessionService`
- `ResultService`
- `ProfileService`
- `DevService`
### 5.4 数据层
- [store/postgres](D:/dev/cmr-mini/backend/internal/store/postgres)
特点:
- 手写 SQL
- `pgx` 连接池
- 不依赖 ORM
### 5.5 平台适配层
- [jwtx](D:/dev/cmr-mini/backend/internal/platform/jwtx)
- [security](D:/dev/cmr-mini/backend/internal/platform/security)
- [wechatmini](D:/dev/cmr-mini/backend/internal/platform/wechatmini)
## 6. 当前边界
### 6.1 backend 管什么
- 业务身份
- 配置发布解析
- 启动编排
- 一局的生命周期和结果
### 6.2 游戏客户端管什么
- 下载 `manifest_url`
- 解析运行配置
- 驱动地图和玩法
- 产生过程数据和结束摘要
适用范围:
- 微信小程序客户端
- 未来 APP 客户端
也就是说:
- 后端按统一业务模型输出
- 终端差异放在客户端运行时适配层,不放在后端业务接口层
### 6.3 后续网关该怎么接
后面如果接实时网关,建议仍然走:
- backend 负责登录与 launch
- launch 或 session 负责产出短期实时票据
- 网关只认 backend 签发的运行态票据
不要把微信身份或业务 token 直接暴露给实时网关。

View File

@@ -1,622 +0,0 @@
# 资源对象与目录方案
> 文档版本v1.3
> 最后更新2026-04-07 13:13:19
本文档用于把“地图复用、KML 复用、内容资源复用、配置发布”统一收成一套后端可执行方案。
目标:
- 不再把所有资源都塞进单个 `event/*.json`
- 让地图、KML、内容模板、主题资源都能独立复用
-`event` 只负责“组合与覆盖”,不拥有底层资源本体
-`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. 设计结论
后端后续不要按“一个活动一个完整资源目录”来设计,而要按“资源对象库 + Event 组合 + Release 固化”来设计。
建议统一拆成这 5 类对象:
1. `Map`
2. `Playfield`
3. `GameMode`
4. `ResourcePack`
5. `Event`
它们的关系是:
- `Map`:地图底座
- `Playfield`:空间对象 / KML / 控制点集
- `GameMode`:玩法默认规则
- `ResourcePack`:内容、主题、音频等资源档
- `Event`:业务活动对象,只做引用与少量覆盖
最终客户端吃的仍然不是这些编辑态对象,而是:
- `Release`
- `manifest.json`
补充约束:
- manifest 必须保持终端中立
- 不要在资源对象层把目录或字段设计成“小程序专用资源包”
- APP 与小程序应共享同一套资源对象和 release 记录
---
## 2. 为什么必须这么拆
你现在遇到的核心问题不是“目录怎么摆”,而是“哪些资源会复用”。
当前最典型的复用场景:
- 同一张地图会被多个活动复用
- 同一份 KML / 控制点集会被多个活动复用
- 同一套 H5 内容模板会被多个活动复用
- 同一套主题和音频资源会被多个活动复用
如果继续按 `event -> 自己拥有所有文件` 设计,后面会出现:
- 地图重复拷贝
- KML 重复上传
- 同一资源多个活动版本不一致
- 一次资源修复需要改很多 event
- 历史 session 无法明确追溯当时使用的是哪一版资源
所以正确做法不是“每个 event 一套全量文件”,而是:
`共享资源对象 -> event 引用 -> release 固化版本`
---
## 3. 五类核心对象
## 3.1 Map
作用:
- 表示一张可复用地图底座
最少应包含:
- `code`
- `name`
- `status`
- `tiles root`
- `mapmeta`
- 可选边界、缩放、投影信息
典型场景:
- 一个公园底图
- 一张校园底图
- 一张城区底图
注意:
- `Map` 不等于某场活动
- `Map` 是共享资产
## 3.2 Playfield
作用:
- 表示一份可复用场地对象数据
最常见的承载就是:
- `KML`
- `GeoJSON`
- 控制点集
最少应包含:
- `code`
- `name`
- `kind`
- `sourceType`
- `sourceFile`
- 可选提取元数据
- 控制点数量
- 边界范围
- 是否包含起终点
注意:
- `Playfield` 是共享对象,不属于某个 event 私有
- 同一份 KML 可以被多个 event 复用
## 3.3 GameMode
作用:
- 表示一种玩法模式的默认规则对象
例如:
- `classic-sequential`
- `score-o`
最少应包含:
- `code`
- `mode`
- `defaults json`
注意:
- 它不是最终 event 配置
- 只是玩法默认值来源之一
## 3.4 ResourcePack
作用:
- 表示一套可复用资源档
当前最适合放进来的有:
- `audioProfile`
- `contentProfile`
- `themeProfile`
一个资源包内部可以包含:
- 内容模板
- H5 页面
- 图片
- 图标
- 音效
- 主题色与主题变量
## 3.5 Event
作用:
- 表示一个业务活动实例
它应该只负责:
- 引用哪个 `Map`
- 引用哪个 `Playfield`
- 引用哪个 `GameMode`
- 引用哪个 `ResourcePack`
- 叠加少量 `Event Overrides`
不要让 `Event` 负责:
- 保存整份地图资源
- 保存 KML 原件
- 承担所有玩法默认规则
- 拷贝整套资源包
---
## 4. 推荐目录结构
仓库内建议把“源资源”和“活动源配置”拆开。
推荐结构:
```text
resources/
maps/
lxcb-001/
v2026-03-30/
mapmeta.json
tiles/
playfields/
c01/
v2026-03-30/
course.kml
meta.json
resource-packs/
default-race/
v2026-03-30/
content/
content.html
audio/
theme/
game-modes/
classic-sequential/
v1/
mode.json
score-o/
v1/
mode.json
events/
evt-demo-001/
source.json
```
说明:
- `resources/` 放共享对象的源资源
- `game-modes/` 放玩法默认规则对象
- `events/` 只放活动级 source config
当前根目录 [event](D:/dev/cmr-mini/event) 可以继续保留作为过渡区,但后面建议逐步迁到:
- `events/`
- `resources/`
- `game-modes/`
---
## 5. Event Source 应该怎么写
后续 `event source` 不建议继续直接写死地图路径和 KML 路径,而应该引用对象版本。
推荐形态:
```json
{
"schemaVersion": "1",
"version": "2026.04.01",
"app": {
"id": "evt-demo-001",
"title": "积分赛示例"
},
"refs": {
"map": {
"code": "lxcb-001",
"version": "v2026-03-30"
},
"playfield": {
"code": "c01",
"version": "v2026-03-30"
},
"gameMode": {
"code": "score-o",
"version": "v1"
},
"resourcePack": {
"code": "default-race",
"version": "v2026-03-30"
}
},
"overrides": {
"game": {
"session": {
"maxDurationSec": 5400
},
"punch": {
"radiusMeters": 5
}
},
"playfield": {
"metadata": {
"title": "示例路线",
"code": "demo-001"
}
}
}
}
```
这样 `Event` 管的是:
- 引用
- 覆盖
不是:
- 全量资源路径
- 全量运行时配置
---
## 6. Manifest 生成规则
build / publish 时Go 中间层应做装配:
`Map + Playfield + GameMode + ResourcePack + Event Overrides -> manifest.json`
最终生成给客户端的 manifest 可以保持现在的运行结构,例如:
```json
{
"schemaVersion": "1",
"releaseId": "rel_xxx",
"version": "2026.04.01",
"app": {
"id": "evt-demo-001",
"title": "积分赛示例"
},
"map": {
"tiles": "https://.../maps/lxcb-001/v2026-03-30/tiles/",
"mapmeta": "https://.../maps/lxcb-001/v2026-03-30/mapmeta.json"
},
"playfield": {
"kind": "control-set",
"source": {
"type": "kml",
"url": "https://.../playfields/c01/v2026-03-30/course.kml"
}
},
"game": {
"mode": "score-o"
},
"resources": {
"audioProfile": "default",
"contentProfile": "default",
"themeProfile": "default-race"
}
}
```
这层才是客户端真正消费的配置。
重要边界:
- 后端负责对象装配和发布
- 前端继续负责运行时 profile 编译
- 不把玩家设置和运行时状态写回发布配置
再补一条:
- 不把 APP 专属页面状态或小程序专属页面状态写进 manifest
- 如需终端能力差异,后续通过能力声明或运行时适配层处理
---
## 7. OSS / CDN 目录建议
线上目录不要再继续以:
- `gotomars/event/classic-sequential.json`
- `gotomars/event/score-o.json`
这种“玩法文件名”方式长期演进。
建议改成版本化结构:
```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
gotomars/event-releases/{eventPublicID}/{releasePublicID}/manifest.json
gotomars/event-releases/{eventPublicID}/{releasePublicID}/asset-index.json
```
好处:
- 共享资源独立版本化
- event release 只固化引用
- 历史 session 可以回溯
- 同一个 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. 数据库建模建议
推荐按“主表 + version 表”建模。
建议对象:
- `maps`
- `map_versions`
- `playfields`
- `playfield_versions`
- `game_modes`
- `game_mode_versions`
- `resource_packs`
- `resource_pack_versions`
- `events`
- `event_versions`
- `event_releases`
其中:
- 主表存稳定元信息
- version 表存 `jsonb` 内容和具体资源引用
例如:
### `maps`
- `id`
- `code`
- `name`
- `status`
- `current_version_id`
### `map_versions`
- `id`
- `map_id`
- `version_code`
- `content_jsonb`
- `published_asset_root`
- `status`
### `playfields`
- `id`
- `code`
- `name`
- `kind`
- `status`
- `current_version_id`
### `playfield_versions`
- `id`
- `playfield_id`
- `version_code`
- `source_type`
- `content_jsonb`
- `asset_root`
- `status`
### `resource_packs`
- `id`
- `code`
- `name`
- `status`
- `current_version_id`
### `resource_pack_versions`
- `id`
- `resource_pack_id`
- `version_code`
- `content_jsonb`
- `asset_root`
- `status`
### `game_modes`
- `id`
- `code`
- `name`
- `status`
- `current_version_id`
### `game_mode_versions`
- `id`
- `game_mode_id`
- `version_code`
- `content_jsonb`
- `status`
### `event_versions`
- `id`
- `event_id`
- `version_code`
- `map_version_id`
- `playfield_version_id`
- `game_mode_version_id`
- `resource_pack_version_id`
- `overrides_jsonb`
- `status`
核心点:
- `Event` 不直接指向文件 URL
- `EventVersion` 指向对象版本
- `Release` 固化当时装配结果
---
## 9. 后端职责边界
后端应强管理:
- 对象关系
- 版本关系
- 引用有效性
- 发布装配
- 发布记录
后端不应强管理:
- 每个玩法的所有细字段解释
- 所有 HUD / 动画 / 实验细项的强结构化列
- 玩家运行时设置
- 玩家实时状态
同样不应做:
- 为 APP 和小程序各维护一套资源目录规范
- 为 APP 和小程序各发布一套不同语义的 event 配置
适合继续走 `jsonb` 的内容:
- `game.sequence.*`
- `game.guidance.*`
- `game.presentation.*`
- `playfield.controlOverrides.*`
- 各类实验性字段
---
## 10. 推荐实施顺序
建议不要一次重构到底,按下面顺序推进:
1. 先把概念定住
- `Map`
- `Playfield`
- `GameMode`
- `ResourcePack`
- `Event`
2. 先做文档和目录规范
3. 后端先补对象模型和 version 表草案
4. 配置构建器改成“按引用装配”
5. 发布器改成“版本化共享资源 + event release manifest”
6. 最后再做正式后台 UI
---
## 11. 当前阶段的务实建议
在完全切换到对象化模型前,当前仓库可以先这样过渡:
- 继续保留 [event](D:/dev/cmr-mini/event) 作为最小样例区
- 继续保留现有 `import-local -> preview -> publish`
- 但新的 source 设计和目录设计先按本文档收口
也就是说:
- 短期不推翻现有链路
- 中期把资源引用模型补进来
- 长期把单文件 `event/*.json` 迁到对象化配置系统
---
## 12. 一句话结论
后端资源管理的正确方向不是“每个活动一堆文件”,而是:
`共享资源对象库 + Event 引用装配 + Release 固化发布`
只有这样地图复用、KML 复用、资源包复用、多活动发布才能长期稳定。
并且这套模型必须从一开始就兼顾未来 APP而不是做成“小程序跑通后再重构”的临时结构。

View File

@@ -1,417 +0,0 @@
# 配置管理方案
> 文档版本v1.0
> 最后更新2026-04-02 08:28:05
## 1. 目标
后续 backend 不应该只“管理一个 event JSON 文件”,而应该管理一整套可伸缩的配置生命周期。
这套生命周期至少要覆盖:
1. 编辑态源配置
2. 构建态中间产物
3. 对外发布版本
4. 启动时绑定的 release
5. 运行完成后的 session 追溯
核心目标不是支持当前字段,而是支持以后继续加字段时,主架构不需要推翻。
## 2. 当前现状
当前根目录下的 [event](D:/dev/cmr-mini/event) 已经保存了最小启动配置样例:
- [classic-sequential.json](D:/dev/cmr-mini/event/classic-sequential.json)
- [score-o.json](D:/dev/cmr-mini/event/score-o.json)
从这两个样例看,当前“最小启动配置”已经有了很好的雏形:
- `app`
- `map`
- `playfield`
- `game.mode`
这类文件很适合作为运行时 manifest 的基础形态。
但如果后续继续往里面堆:
- 赛事规则
- 计分规则
- 内容页
- 安全策略
- 品牌配置
- 多媒体资源
- telemetry 开关
- 实验字段
就不能再只靠单个最终 JSON 手工维护了。
## 3. 核心原则
### 3.1 稳定的是层,不是字段
后端要稳定的是这些层:
- `source config`
- `build`
- `release`
- `launch`
- `session`
而不是把所有具体配置字段都设计成强结构数据库列。
### 3.2 编辑态和运行态必须分离
编辑态:
- 配置项可以很多
- 允许草稿
- 允许试验字段
- 允许中间状态
运行态:
- 必须稳定
- 必须可校验
- 必须有版本
- 必须能被客户端直接消费
### 3.3 客户端只消费发布产物
客户端进入游戏时,不应直接读取编辑态对象。
客户端应该只消费:
- `manifest_url`
- `manifest_checksum_sha256`
- 与 manifest 配套的发布资源
### 3.4 session 必须固化 release
只要一局启动了:
- 必须固化 `event_release_id`
- 后续 event 切新发布,不影响老 session
- 结果页和历史页都必须能回看当时那份配置
## 4. 三层配置模型
## 4.1 第一层:源配置
这是编辑态配置。
建议特点:
- 允许字段增长
- 允许草稿
- 允许频繁修改
- 主要存 `jsonb`
它对应“最大启动配置”或“完整编辑配置集合”。
### 可能包含的块
- `app`
- `branding`
- `map`
- `playfield`
- `game`
- `rules`
- `scoring`
- `timeControl`
- `content`
- `assets`
- `safety`
- `telemetry`
- `featureFlags`
## 4.2 第二层:构建产物
这是后端根据源配置构建出来的中间结果。
建议职责:
- schema 校验
- 引用资源补全
- 相对路径转绝对路径
- 生成最终 manifest
- 生成资产清单
- 记录构建日志
这一层是后续做“预览构建”“草稿预览”“发布前检查”的关键。
## 4.3 第三层:发布版本
这是正式对外运行时版本。
建议职责:
- 绑定 build 结果
- 绑定 manifest URL
- 绑定 checksum
- 绑定资源清单
- 进入 launch 链路
当前已有的 `event_releases` 就是这层的起点,但后面还需要更完整的 build / assets 支撑。
## 5. 最小启动配置和最大配置怎么定义
建议不要把“最小配置 / 最大配置”当成数据库对象名,而要作为两种形态理解。
### 5.1 最小启动配置
就是客户端能开局所必需的最小 manifest。
建议包含:
- `schemaVersion`
- `releaseId`
- `app`
- `map`
- `playfield`
- `game`
- 必要资源引用
特点:
- 结构稳定
- 字段尽量少
- 客户端可直接消费
### 5.2 最大配置
就是完整编辑态 source config。
特点:
- 字段可以很多
- 块可以不断扩展
- 不要求直接给客户端消费
- 构建后才会变成运行时 manifest
## 6. 当前 event 目录该扮演什么角色
当前根目录 [event](D:/dev/cmr-mini/event) 建议继续保留,但角色要明确:
它应该是:
- 本地源配置样例目录
- 构建输入参考目录
- 调试和原型验证输入
它不应该直接承担:
- 线上唯一配置源
- 发布版本存储
- 客户端直接运行入口
线上真正的运行入口应当是:
- 数据库里的 release 元数据
- 对象存储/CDN 里的 manifest 和资源
## 7. 数据模型建议
在当前 [数据模型.md](D:/dev/cmr-mini/backend/docs/数据模型.md) 基础上,建议新增 3 张核心表。
这 3 张表的第一版 migration 已经落在:
- [0005_config_pipeline.sql](D:/dev/cmr-mini/backend/migrations/0005_config_pipeline.sql)
## 7.1 `event_config_sources`
用途:
- 存编辑态源配置版本
建议字段:
- `id`
- `event_id`
- `source_version_no`
- `source_kind`
- `schema_id`
- `schema_version`
- `status`
- `source_jsonb`
- `notes`
- `created_by_user_id`
- `created_at`
说明:
- `source_jsonb` 存完整编辑态配置
- `schema_id + schema_version` 用来做校验
## 7.2 `event_config_builds`
用途:
- 存一次构建的结果
建议字段:
- `id`
- `event_id`
- `source_id`
- `build_no`
- `build_status`
- `build_log`
- `manifest_jsonb`
- `asset_index_jsonb`
- `created_by_user_id`
- `created_at`
说明:
- `manifest_jsonb` 是构建后得到的运行 manifest
- `asset_index_jsonb` 是构建时收集到的资源清单
## 7.3 `event_release_assets`
用途:
- 存 release 的资源清单
建议字段:
- `id`
- `event_release_id`
- `asset_type`
- `asset_key`
- `asset_path`
- `asset_url`
- `checksum`
- `size_bytes`
- `meta_jsonb`
说明:
- 这张表非常适合后面做资源核对、回滚、调试和发布检查
## 8. 强结构和弱结构怎么分
## 8.1 强结构字段
这些字段后端应强约束:
- `event_id`
- `release_id`
- `manifest_url`
- `manifest_checksum_sha256`
- `status`
- `published_at`
- `session_public_id`
- `event_release_id`
这些是运行链路基础,不适合做成松散字段。
## 8.2 弱结构字段
这些字段建议主要放 `jsonb`
- 玩法规则
- 计分策略
- 文案内容
- H5 内容块
- 品牌视觉配置
- 资源扩展配置
- feature flags
- 实验字段
这样后面新增字段时,主链路不会被迫重构。
## 9. 后端后续能力建议
## 9.1 源配置管理
建议支持:
- 保存草稿 source
- 查看 source 历史版本
- source diff
- 从文件导入 source
## 9.2 构建能力
建议支持:
- 校验 source schema
- 校验资源引用存在
- 生成 manifest
- 生成 asset index
- 输出 build log
## 9.3 发布能力
建议支持:
- 从某个 build 发布 release
- 生成 `manifest_url`
- 上传 release 资产
- 标记当前生效 release
- 回滚旧 release
## 9.4 调试能力
建议支持:
- 预览构建结果
- 查看某个 release 资产清单
- 查看某个 session 实际绑定的 release 和 manifest
## 10. 推荐 API 路线
建议后面按这个顺序补接口:
### 第一批source
- `POST /events/{id}/config-sources`
- `GET /events/{id}/config-sources`
- `GET /config-sources/{id}`
### 第二批build
- `POST /config-sources/{id}/build`
- `GET /builds/{id}`
- `GET /builds/{id}/manifest`
### 第三批release
- `POST /builds/{id}/release`
- `GET /releases/{id}`
- `GET /releases/{id}/assets`
### 第四批preview
- `GET /events/{id}/preview-play`
- `POST /builds/{id}/preview-launch`
## 11. 推荐开发顺序
当前最值得先做的不是配置后台 UI而是配置构建器。
建议顺序:
1. 先定义 source config 和 manifest 的字段边界
2. 先建 `event_config_sources`
3. 先做 schema 校验器
4. 先做 build 产物生成
5. 再建 `event_config_builds`
6. 再做正式 release 发布
7. 最后才做后台编辑器
原因很简单:
- 没有 build/release 核心能力,后台只是个大表单
- 先把构建链打通,后面各种管理壳层才有基础
## 12. 一句话结论
后续 backend 不该做成“管理一个越来越大的 event JSON 文件”,而应该做成:
> 源配置管理 + 构建产物管理 + release 发布管理 + session 绑定 release
这样以后无论你配置项怎么继续长,主架构都还能撑住。

View File

@@ -1,17 +0,0 @@
module cmr-backend
go 1.25.1
require (
github.com/golang-jwt/jwt/v5 v5.2.2
github.com/jackc/pgx/v5 v5.7.6
)
require (
github.com/jackc/pgpassfile v1.0.0 // indirect
github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 // indirect
github.com/jackc/puddle/v2 v2.2.2 // indirect
golang.org/x/crypto v0.37.0 // indirect
golang.org/x/sync v0.13.0 // indirect
golang.org/x/text v0.24.0 // indirect
)

View File

@@ -1,30 +0,0 @@
github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/golang-jwt/jwt/v5 v5.2.2 h1:Rl4B7itRWVtYIHFrSNd7vhTiz9UpLdi6gZhZ3wEeDy8=
github.com/golang-jwt/jwt/v5 v5.2.2/go.mod h1:pqrtFR0X4osieyHYxtmOUWsAWrfe1Q5UVIyoH402zdk=
github.com/jackc/pgpassfile v1.0.0 h1:/6Hmqy13Ss2zCq62VdNG8tM1wchn8zjSGOBJ6icpsIM=
github.com/jackc/pgpassfile v1.0.0/go.mod h1:CEx0iS5ambNFdcRtxPj5JhEz+xB6uRky5eyVu/W2HEg=
github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 h1:iCEnooe7UlwOQYpKFhBabPMi4aNAfoODPEFNiAnClxo=
github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761/go.mod h1:5TJZWKEWniPve33vlWYSoGYefn3gLQRzjfDlhSJ9ZKM=
github.com/jackc/pgx/v5 v5.7.6 h1:rWQc5FwZSPX58r1OQmkuaNicxdmExaEz5A2DO2hUuTk=
github.com/jackc/pgx/v5 v5.7.6/go.mod h1:aruU7o91Tc2q2cFp5h4uP3f6ztExVpyVv88Xl/8Vl8M=
github.com/jackc/puddle/v2 v2.2.2 h1:PR8nw+E/1w0GLuRFSmiioY6UooMp6KJv0/61nB7icHo=
github.com/jackc/puddle/v2 v2.2.2/go.mod h1:vriiEXHvEE654aYKXXjOvZM39qJ0q+azkZFrfEOc3H4=
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI=
github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
github.com/stretchr/testify v1.8.1 h1:w7B6lhMri9wdJUVmEZPGGhZzrYTPvgJArz7wNPgYKsk=
github.com/stretchr/testify v1.8.1/go.mod h1:w2LPCIKwWwSfY2zedu0+kehJoqGctiVI29o6fzry7u4=
golang.org/x/crypto v0.37.0 h1:kJNSjF/Xp7kU0iB2Z+9viTPMW4EqqsrywMXLJOOsXSE=
golang.org/x/crypto v0.37.0/go.mod h1:vg+k43peMZ0pUMhYmVAWysMK35e6ioLh3wB8ZCAfbVc=
golang.org/x/sync v0.13.0 h1:AauUjRAJ9OSnvULf/ARrrVywoJDy0YS2AwQ98I37610=
golang.org/x/sync v0.13.0/go.mod h1:1dzgHSNfp02xaA81J2MS99Qcpr2w7fw1gpm99rleRqA=
golang.org/x/text v0.24.0 h1:dd5Bzh4yt5KYA8f9CJHCP4FB4D51c2c6JvN37xJJkJ0=
golang.org/x/text v0.24.0/go.mod h1:L8rBsPeo2pSS+xqN0d5u2ikmjtmoJbDBT1b7nHvFCdU=
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=

View File

@@ -1,82 +0,0 @@
package app
import (
"context"
"net/http"
"cmr-backend/internal/httpapi"
"cmr-backend/internal/platform/assets"
"cmr-backend/internal/platform/jwtx"
"cmr-backend/internal/platform/wechatmini"
"cmr-backend/internal/service"
"cmr-backend/internal/store/postgres"
)
type App struct {
router http.Handler
store *postgres.Store
}
func New(ctx context.Context, cfg Config) (*App, error) {
pool, err := postgres.Open(ctx, cfg.DatabaseURL)
if err != nil {
return nil, err
}
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,
SMSCodeTTL: cfg.SMSCodeTTL,
SMSCodeCooldown: cfg.SMSCodeCooldown,
SMSProvider: cfg.SMSProvider,
DevSMSCode: cfg.DevSMSCode,
WechatMini: wechatMiniClient,
}, 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)
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, opsAuthService, opsSummaryService, entryService, entryHomeService, adminAssetService, adminResourceService, adminProductionService, adminEventService, adminPipelineService, eventService, eventPlayService, publicExperienceService, configService, homeService, mapExperienceService, profileService, resultService, sessionService, devService, meService)
return &App{
router: router,
store: store,
}, nil
}
func (a *App) Router() http.Handler {
return a.router
}
func (a *App) Close() {
if a.store != nil {
a.store.Close()
}
}

View File

@@ -1,89 +0,0 @@
package app
import (
"fmt"
"os"
"path/filepath"
"time"
)
type Config struct {
AppEnv string
HTTPAddr string
DatabaseURL string
JWTIssuer string
JWTAccessSecret string
JWTAccessTTL time.Duration
RefreshTTL time.Duration
SMSCodeTTL time.Duration
SMSCodeCooldown time.Duration
SMSProvider string
DevSMSCode string
WechatMiniAppID string
WechatMiniSecret string
WechatMiniDevPrefix string
LocalEventDir string
AssetBaseURL string
AssetPublicBaseURL string
AssetBucketRoot string
OSSUtilPath string
OSSUtilConfigFile string
}
func LoadConfigFromEnv() (Config, error) {
cfg := Config{
AppEnv: getEnv("APP_ENV", "development"),
HTTPAddr: getEnv("HTTP_ADDR", ":8080"),
DatabaseURL: os.Getenv("DATABASE_URL"),
JWTIssuer: getEnv("JWT_ISSUER", "cmr-backend"),
JWTAccessSecret: getEnv("JWT_ACCESS_SECRET", "change-me-in-production"),
JWTAccessTTL: getDurationEnv("JWT_ACCESS_TTL", 2*time.Hour),
RefreshTTL: getDurationEnv("AUTH_REFRESH_TTL", 30*24*time.Hour),
SMSCodeTTL: getDurationEnv("AUTH_SMS_CODE_TTL", 10*time.Minute),
SMSCodeCooldown: getDurationEnv("AUTH_SMS_COOLDOWN", 60*time.Second),
SMSProvider: getEnv("AUTH_SMS_PROVIDER", "console"),
DevSMSCode: os.Getenv("AUTH_DEV_SMS_CODE"),
WechatMiniAppID: getEnv("WECHAT_MINI_APP_ID", ""),
WechatMiniSecret: getEnv("WECHAT_MINI_APP_SECRET", ""),
WechatMiniDevPrefix: getEnv("WECHAT_MINI_DEV_PREFIX", "dev-"),
LocalEventDir: getEnv("LOCAL_EVENT_DIR", filepath.Clean("..\\event")),
AssetBaseURL: getEnv("ASSET_BASE_URL", "https://oss-mbh5.colormaprun.com/gotomars"),
AssetPublicBaseURL: getEnv("ASSET_PUBLIC_BASE_URL", "https://oss-mbh5.colormaprun.com"),
AssetBucketRoot: getEnv("ASSET_BUCKET_ROOT", "oss://color-map-html"),
OSSUtilPath: getEnv("OSSUTIL_PATH", filepath.Clean("..\\tools\\ossutil.exe")),
OSSUtilConfigFile: getEnv("OSSUTIL_CONFIG_FILE", filepath.Join(mustUserHomeDir(), ".ossutilconfig")),
}
if cfg.DatabaseURL == "" {
return Config{}, fmt.Errorf("DATABASE_URL is required")
}
if cfg.JWTAccessSecret == "" {
return Config{}, fmt.Errorf("JWT_ACCESS_SECRET is required")
}
return cfg, nil
}
func getEnv(key, fallback string) string {
if value := os.Getenv(key); value != "" {
return value
}
return fallback
}
func getDurationEnv(key string, fallback time.Duration) time.Duration {
if value := os.Getenv(key); value != "" {
if parsed, err := time.ParseDuration(value); err == nil {
return parsed
}
}
return fallback
}
func mustUserHomeDir() string {
home, err := os.UserHomeDir()
if err != nil {
return "."
}
return home
}

View File

@@ -1,29 +0,0 @@
package apperr
import "errors"
type Error struct {
Status int `json:"-"`
Code string `json:"code"`
Message string `json:"message"`
}
func (e *Error) Error() string {
return e.Message
}
func New(status int, code, message string) *Error {
return &Error{
Status: status,
Code: code,
Message: message,
}
}
func From(err error) *Error {
var appErr *Error
if errors.As(err, &appErr) {
return appErr
}
return nil
}

View File

@@ -1,132 +0,0 @@
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
}

View File

@@ -1,202 +0,0 @@
package handlers
import (
"net/http"
"strconv"
"cmr-backend/internal/apperr"
"cmr-backend/internal/httpx"
"cmr-backend/internal/service"
)
type AdminEventHandler struct {
service *service.AdminEventService
}
func NewAdminEventHandler(service *service.AdminEventService) *AdminEventHandler {
return &AdminEventHandler{service: service}
}
func (h *AdminEventHandler) ListEvents(w http.ResponseWriter, r *http.Request) {
limit := 50
if raw := r.URL.Query().Get("limit"); raw != "" {
if parsed, err := strconv.Atoi(raw); err == nil {
limit = parsed
}
}
result, err := h.service.ListEvents(r.Context(), limit)
if err != nil {
httpx.WriteError(w, err)
return
}
httpx.WriteJSON(w, http.StatusOK, map[string]any{"data": result})
}
func (h *AdminEventHandler) CreateEvent(w http.ResponseWriter, r *http.Request) {
var req service.CreateAdminEventInput
if err := httpx.DecodeJSON(r, &req); err != nil {
httpx.WriteError(w, apperr.New(http.StatusBadRequest, "invalid_json", "invalid request body: "+err.Error()))
return
}
result, err := h.service.CreateEvent(r.Context(), req)
if err != nil {
httpx.WriteError(w, err)
return
}
httpx.WriteJSON(w, http.StatusCreated, map[string]any{"data": result})
}
func (h *AdminEventHandler) GetEvent(w http.ResponseWriter, r *http.Request) {
result, err := h.service.GetEventDetail(r.Context(), r.PathValue("eventPublicID"))
if err != nil {
httpx.WriteError(w, err)
return
}
httpx.WriteJSON(w, http.StatusOK, map[string]any{"data": result})
}
func (h *AdminEventHandler) UpdateEvent(w http.ResponseWriter, r *http.Request) {
var req service.UpdateAdminEventInput
if err := httpx.DecodeJSON(r, &req); err != nil {
httpx.WriteError(w, apperr.New(http.StatusBadRequest, "invalid_json", "invalid request body: "+err.Error()))
return
}
result, err := h.service.UpdateEvent(r.Context(), r.PathValue("eventPublicID"), req)
if err != nil {
httpx.WriteError(w, err)
return
}
httpx.WriteJSON(w, http.StatusOK, map[string]any{"data": result})
}
func (h *AdminEventHandler) SaveSource(w http.ResponseWriter, r *http.Request) {
var req service.SaveAdminEventSourceInput
if err := httpx.DecodeJSON(r, &req); err != nil {
httpx.WriteError(w, apperr.New(http.StatusBadRequest, "invalid_json", "invalid request body: "+err.Error()))
return
}
result, err := h.service.SaveEventSource(r.Context(), r.PathValue("eventPublicID"), req)
if err != nil {
httpx.WriteError(w, err)
return
}
httpx.WriteJSON(w, http.StatusCreated, map[string]any{"data": result})
}
func (h *AdminEventHandler) ListPresentations(w http.ResponseWriter, r *http.Request) {
limit := 50
if raw := r.URL.Query().Get("limit"); raw != "" {
if parsed, err := strconv.Atoi(raw); err == nil {
limit = parsed
}
}
result, err := h.service.ListEventPresentations(r.Context(), r.PathValue("eventPublicID"), limit)
if err != nil {
httpx.WriteError(w, err)
return
}
httpx.WriteJSON(w, http.StatusOK, map[string]any{"data": result})
}
func (h *AdminEventHandler) CreatePresentation(w http.ResponseWriter, r *http.Request) {
var req service.CreateAdminEventPresentationInput
if err := httpx.DecodeJSON(r, &req); err != nil {
httpx.WriteError(w, apperr.New(http.StatusBadRequest, "invalid_json", "invalid request body: "+err.Error()))
return
}
result, err := h.service.CreateEventPresentation(r.Context(), r.PathValue("eventPublicID"), req)
if err != nil {
httpx.WriteError(w, err)
return
}
httpx.WriteJSON(w, http.StatusCreated, map[string]any{"data": result})
}
func (h *AdminEventHandler) ImportPresentation(w http.ResponseWriter, r *http.Request) {
var req service.ImportAdminEventPresentationInput
if err := httpx.DecodeJSON(r, &req); err != nil {
httpx.WriteError(w, apperr.New(http.StatusBadRequest, "invalid_json", "invalid request body: "+err.Error()))
return
}
result, err := h.service.ImportEventPresentation(r.Context(), r.PathValue("eventPublicID"), req)
if err != nil {
httpx.WriteError(w, err)
return
}
httpx.WriteJSON(w, http.StatusCreated, map[string]any{"data": result})
}
func (h *AdminEventHandler) GetPresentation(w http.ResponseWriter, r *http.Request) {
result, err := h.service.GetEventPresentation(r.Context(), r.PathValue("presentationPublicID"))
if err != nil {
httpx.WriteError(w, err)
return
}
httpx.WriteJSON(w, http.StatusOK, map[string]any{"data": result})
}
func (h *AdminEventHandler) ListContentBundles(w http.ResponseWriter, r *http.Request) {
limit := 50
if raw := r.URL.Query().Get("limit"); raw != "" {
if parsed, err := strconv.Atoi(raw); err == nil {
limit = parsed
}
}
result, err := h.service.ListContentBundles(r.Context(), r.PathValue("eventPublicID"), limit)
if err != nil {
httpx.WriteError(w, err)
return
}
httpx.WriteJSON(w, http.StatusOK, map[string]any{"data": result})
}
func (h *AdminEventHandler) CreateContentBundle(w http.ResponseWriter, r *http.Request) {
var req service.CreateAdminContentBundleInput
if err := httpx.DecodeJSON(r, &req); err != nil {
httpx.WriteError(w, apperr.New(http.StatusBadRequest, "invalid_json", "invalid request body: "+err.Error()))
return
}
result, err := h.service.CreateContentBundle(r.Context(), r.PathValue("eventPublicID"), req)
if err != nil {
httpx.WriteError(w, err)
return
}
httpx.WriteJSON(w, http.StatusCreated, map[string]any{"data": result})
}
func (h *AdminEventHandler) ImportContentBundle(w http.ResponseWriter, r *http.Request) {
var req service.ImportAdminContentBundleInput
if err := httpx.DecodeJSON(r, &req); err != nil {
httpx.WriteError(w, apperr.New(http.StatusBadRequest, "invalid_json", "invalid request body: "+err.Error()))
return
}
result, err := h.service.ImportContentBundle(r.Context(), r.PathValue("eventPublicID"), req)
if err != nil {
httpx.WriteError(w, err)
return
}
httpx.WriteJSON(w, http.StatusCreated, map[string]any{"data": result})
}
func (h *AdminEventHandler) GetContentBundle(w http.ResponseWriter, r *http.Request) {
result, err := h.service.GetContentBundle(r.Context(), r.PathValue("contentBundlePublicID"))
if err != nil {
httpx.WriteError(w, err)
return
}
httpx.WriteJSON(w, http.StatusOK, map[string]any{"data": result})
}
func (h *AdminEventHandler) UpdateEventDefaults(w http.ResponseWriter, r *http.Request) {
var req service.UpdateAdminEventDefaultsInput
if err := httpx.DecodeJSON(r, &req); err != nil {
httpx.WriteError(w, apperr.New(http.StatusBadRequest, "invalid_json", "invalid request body: "+err.Error()))
return
}
result, err := h.service.UpdateEventDefaults(r.Context(), r.PathValue("eventPublicID"), req)
if err != nil {
httpx.WriteError(w, err)
return
}
httpx.WriteJSON(w, http.StatusOK, map[string]any{"data": result})
}

View File

@@ -1,115 +0,0 @@
package handlers
import (
"io"
"net/http"
"strconv"
"strings"
"cmr-backend/internal/apperr"
"cmr-backend/internal/httpx"
"cmr-backend/internal/service"
)
type AdminPipelineHandler struct {
service *service.AdminPipelineService
}
func NewAdminPipelineHandler(service *service.AdminPipelineService) *AdminPipelineHandler {
return &AdminPipelineHandler{service: service}
}
func (h *AdminPipelineHandler) GetEventPipeline(w http.ResponseWriter, r *http.Request) {
limit := 20
if raw := r.URL.Query().Get("limit"); raw != "" {
if parsed, err := strconv.Atoi(raw); err == nil {
limit = parsed
}
}
result, err := h.service.GetEventPipeline(r.Context(), r.PathValue("eventPublicID"), limit)
if err != nil {
httpx.WriteError(w, err)
return
}
httpx.WriteJSON(w, http.StatusOK, map[string]any{"data": result})
}
func (h *AdminPipelineHandler) BuildSource(w http.ResponseWriter, r *http.Request) {
result, err := h.service.BuildSource(r.Context(), r.PathValue("sourceID"))
if err != nil {
httpx.WriteError(w, err)
return
}
httpx.WriteJSON(w, http.StatusOK, map[string]any{"data": result})
}
func (h *AdminPipelineHandler) GetBuild(w http.ResponseWriter, r *http.Request) {
result, err := h.service.GetBuild(r.Context(), r.PathValue("buildID"))
if err != nil {
httpx.WriteError(w, err)
return
}
httpx.WriteJSON(w, http.StatusOK, map[string]any{"data": result})
}
func (h *AdminPipelineHandler) PublishBuild(w http.ResponseWriter, r *http.Request) {
var req service.AdminPublishBuildInput
if r.Body != nil {
raw, err := io.ReadAll(r.Body)
if err != nil {
httpx.WriteError(w, apperr.New(http.StatusBadRequest, "invalid_json", "failed to read request body: "+err.Error()))
return
}
if len(raw) > 0 {
r.Body = io.NopCloser(strings.NewReader(string(raw)))
if err := httpx.DecodeJSON(r, &req); err != nil {
httpx.WriteError(w, apperr.New(http.StatusBadRequest, "invalid_json", "invalid request body: "+err.Error()))
return
}
}
}
result, err := h.service.PublishBuild(r.Context(), r.PathValue("buildID"), req)
if err != nil {
httpx.WriteError(w, err)
return
}
httpx.WriteJSON(w, http.StatusOK, map[string]any{"data": result})
}
func (h *AdminPipelineHandler) GetRelease(w http.ResponseWriter, r *http.Request) {
result, err := h.service.GetRelease(r.Context(), r.PathValue("releasePublicID"))
if err != nil {
httpx.WriteError(w, err)
return
}
httpx.WriteJSON(w, http.StatusOK, map[string]any{"data": result})
}
func (h *AdminPipelineHandler) BindReleaseRuntime(w http.ResponseWriter, r *http.Request) {
var req service.AdminBindReleaseRuntimeInput
if err := httpx.DecodeJSON(r, &req); err != nil {
httpx.WriteError(w, apperr.New(http.StatusBadRequest, "invalid_json", "invalid request body: "+err.Error()))
return
}
result, err := h.service.BindReleaseRuntime(r.Context(), r.PathValue("releasePublicID"), req)
if err != nil {
httpx.WriteError(w, err)
return
}
httpx.WriteJSON(w, http.StatusOK, map[string]any{"data": result})
}
func (h *AdminPipelineHandler) RollbackRelease(w http.ResponseWriter, r *http.Request) {
var req service.AdminRollbackReleaseInput
if err := httpx.DecodeJSON(r, &req); err != nil {
httpx.WriteError(w, apperr.New(http.StatusBadRequest, "invalid_json", "invalid request body: "+err.Error()))
return
}
result, err := h.service.RollbackRelease(r.Context(), r.PathValue("eventPublicID"), req)
if err != nil {
httpx.WriteError(w, err)
return
}
httpx.WriteJSON(w, http.StatusOK, map[string]any{"data": result})
}

View File

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

View File

@@ -1,169 +0,0 @@
package handlers
import (
"net/http"
"strconv"
"cmr-backend/internal/apperr"
"cmr-backend/internal/httpx"
"cmr-backend/internal/service"
)
type AdminResourceHandler struct {
service *service.AdminResourceService
}
func NewAdminResourceHandler(service *service.AdminResourceService) *AdminResourceHandler {
return &AdminResourceHandler{service: service}
}
func (h *AdminResourceHandler) ListMaps(w http.ResponseWriter, r *http.Request) {
limit := parseAdminLimit(r)
result, err := h.service.ListMaps(r.Context(), limit)
if err != nil {
httpx.WriteError(w, err)
return
}
httpx.WriteJSON(w, http.StatusOK, map[string]any{"data": result})
}
func (h *AdminResourceHandler) CreateMap(w http.ResponseWriter, r *http.Request) {
var req service.CreateAdminMapInput
if err := httpx.DecodeJSON(r, &req); err != nil {
httpx.WriteError(w, apperr.New(http.StatusBadRequest, "invalid_json", "invalid request body: "+err.Error()))
return
}
result, err := h.service.CreateMap(r.Context(), req)
if err != nil {
httpx.WriteError(w, err)
return
}
httpx.WriteJSON(w, http.StatusCreated, map[string]any{"data": result})
}
func (h *AdminResourceHandler) GetMap(w http.ResponseWriter, r *http.Request) {
result, err := h.service.GetMapDetail(r.Context(), r.PathValue("mapPublicID"))
if err != nil {
httpx.WriteError(w, err)
return
}
httpx.WriteJSON(w, http.StatusOK, map[string]any{"data": result})
}
func (h *AdminResourceHandler) CreateMapVersion(w http.ResponseWriter, r *http.Request) {
var req service.CreateAdminMapVersionInput
if err := httpx.DecodeJSON(r, &req); err != nil {
httpx.WriteError(w, apperr.New(http.StatusBadRequest, "invalid_json", "invalid request body: "+err.Error()))
return
}
result, err := h.service.CreateMapVersion(r.Context(), r.PathValue("mapPublicID"), req)
if err != nil {
httpx.WriteError(w, err)
return
}
httpx.WriteJSON(w, http.StatusCreated, map[string]any{"data": result})
}
func (h *AdminResourceHandler) ListPlayfields(w http.ResponseWriter, r *http.Request) {
limit := parseAdminLimit(r)
result, err := h.service.ListPlayfields(r.Context(), limit)
if err != nil {
httpx.WriteError(w, err)
return
}
httpx.WriteJSON(w, http.StatusOK, map[string]any{"data": result})
}
func (h *AdminResourceHandler) CreatePlayfield(w http.ResponseWriter, r *http.Request) {
var req service.CreateAdminPlayfieldInput
if err := httpx.DecodeJSON(r, &req); err != nil {
httpx.WriteError(w, apperr.New(http.StatusBadRequest, "invalid_json", "invalid request body: "+err.Error()))
return
}
result, err := h.service.CreatePlayfield(r.Context(), req)
if err != nil {
httpx.WriteError(w, err)
return
}
httpx.WriteJSON(w, http.StatusCreated, map[string]any{"data": result})
}
func (h *AdminResourceHandler) GetPlayfield(w http.ResponseWriter, r *http.Request) {
result, err := h.service.GetPlayfieldDetail(r.Context(), r.PathValue("playfieldPublicID"))
if err != nil {
httpx.WriteError(w, err)
return
}
httpx.WriteJSON(w, http.StatusOK, map[string]any{"data": result})
}
func (h *AdminResourceHandler) CreatePlayfieldVersion(w http.ResponseWriter, r *http.Request) {
var req service.CreateAdminPlayfieldVersionInput
if err := httpx.DecodeJSON(r, &req); err != nil {
httpx.WriteError(w, apperr.New(http.StatusBadRequest, "invalid_json", "invalid request body: "+err.Error()))
return
}
result, err := h.service.CreatePlayfieldVersion(r.Context(), r.PathValue("playfieldPublicID"), req)
if err != nil {
httpx.WriteError(w, err)
return
}
httpx.WriteJSON(w, http.StatusCreated, map[string]any{"data": result})
}
func (h *AdminResourceHandler) ListResourcePacks(w http.ResponseWriter, r *http.Request) {
limit := parseAdminLimit(r)
result, err := h.service.ListResourcePacks(r.Context(), limit)
if err != nil {
httpx.WriteError(w, err)
return
}
httpx.WriteJSON(w, http.StatusOK, map[string]any{"data": result})
}
func (h *AdminResourceHandler) CreateResourcePack(w http.ResponseWriter, r *http.Request) {
var req service.CreateAdminResourcePackInput
if err := httpx.DecodeJSON(r, &req); err != nil {
httpx.WriteError(w, apperr.New(http.StatusBadRequest, "invalid_json", "invalid request body: "+err.Error()))
return
}
result, err := h.service.CreateResourcePack(r.Context(), req)
if err != nil {
httpx.WriteError(w, err)
return
}
httpx.WriteJSON(w, http.StatusCreated, map[string]any{"data": result})
}
func (h *AdminResourceHandler) GetResourcePack(w http.ResponseWriter, r *http.Request) {
result, err := h.service.GetResourcePackDetail(r.Context(), r.PathValue("resourcePackPublicID"))
if err != nil {
httpx.WriteError(w, err)
return
}
httpx.WriteJSON(w, http.StatusOK, map[string]any{"data": result})
}
func (h *AdminResourceHandler) CreateResourcePackVersion(w http.ResponseWriter, r *http.Request) {
var req service.CreateAdminResourcePackVersionInput
if err := httpx.DecodeJSON(r, &req); err != nil {
httpx.WriteError(w, apperr.New(http.StatusBadRequest, "invalid_json", "invalid request body: "+err.Error()))
return
}
result, err := h.service.CreateResourcePackVersion(r.Context(), r.PathValue("resourcePackPublicID"), req)
if err != nil {
httpx.WriteError(w, err)
return
}
httpx.WriteJSON(w, http.StatusCreated, map[string]any{"data": result})
}
func parseAdminLimit(r *http.Request) int {
limit := 50
if raw := r.URL.Query().Get("limit"); raw != "" {
if parsed, err := strconv.Atoi(raw); err == nil {
limit = parsed
}
}
return limit
}

View File

@@ -1,129 +0,0 @@
package handlers
import (
"net/http"
"cmr-backend/internal/apperr"
"cmr-backend/internal/httpapi/middleware"
"cmr-backend/internal/httpx"
"cmr-backend/internal/service"
)
type AuthHandler struct {
authService *service.AuthService
}
func NewAuthHandler(authService *service.AuthService) *AuthHandler {
return &AuthHandler{authService: authService}
}
func (h *AuthHandler) SendSMSCode(w http.ResponseWriter, r *http.Request) {
var req service.SendSMSCodeInput
if err := httpx.DecodeJSON(r, &req); err != nil {
httpx.WriteError(w, apperr.New(http.StatusBadRequest, "invalid_json", "invalid request body"))
return
}
result, err := h.authService.SendSMSCode(r.Context(), req)
if err != nil {
httpx.WriteError(w, err)
return
}
httpx.WriteJSON(w, http.StatusOK, map[string]any{"data": result})
}
func (h *AuthHandler) LoginSMS(w http.ResponseWriter, r *http.Request) {
var req service.LoginSMSInput
if err := httpx.DecodeJSON(r, &req); err != nil {
httpx.WriteError(w, apperr.New(http.StatusBadRequest, "invalid_json", "invalid request body"))
return
}
result, err := h.authService.LoginSMS(r.Context(), req)
if err != nil {
httpx.WriteError(w, err)
return
}
httpx.WriteJSON(w, http.StatusOK, map[string]any{"data": result})
}
func (h *AuthHandler) LoginWechatMini(w http.ResponseWriter, r *http.Request) {
var req service.LoginWechatMiniInput
if err := httpx.DecodeJSON(r, &req); err != nil {
httpx.WriteError(w, apperr.New(http.StatusBadRequest, "invalid_json", "invalid request body"))
return
}
result, err := h.authService.LoginWechatMini(r.Context(), req)
if err != nil {
httpx.WriteError(w, err)
return
}
httpx.WriteJSON(w, http.StatusOK, map[string]any{"data": result})
}
func (h *AuthHandler) BindMobile(w http.ResponseWriter, r *http.Request) {
var req service.BindMobileInput
if err := httpx.DecodeJSON(r, &req); err != nil {
httpx.WriteError(w, apperr.New(http.StatusBadRequest, "invalid_json", "invalid request body"))
return
}
auth := middleware.GetAuthContext(r.Context())
if auth == nil {
httpx.WriteError(w, apperr.New(http.StatusUnauthorized, "unauthorized", "missing auth context"))
return
}
req.UserID = auth.UserID
result, err := h.authService.BindMobile(r.Context(), req)
if err != nil {
httpx.WriteError(w, err)
return
}
httpx.WriteJSON(w, http.StatusOK, map[string]any{"data": result})
}
func (h *AuthHandler) Refresh(w http.ResponseWriter, r *http.Request) {
var req service.RefreshTokenInput
if err := httpx.DecodeJSON(r, &req); err != nil {
httpx.WriteError(w, apperr.New(http.StatusBadRequest, "invalid_json", "invalid request body"))
return
}
result, err := h.authService.Refresh(r.Context(), req)
if err != nil {
httpx.WriteError(w, err)
return
}
httpx.WriteJSON(w, http.StatusOK, map[string]any{"data": result})
}
func (h *AuthHandler) Logout(w http.ResponseWriter, r *http.Request) {
var req service.LogoutInput
if err := httpx.DecodeJSON(r, &req); err != nil {
httpx.WriteError(w, apperr.New(http.StatusBadRequest, "invalid_json", "invalid request body"))
return
}
auth := middleware.GetAuthContext(r.Context())
if auth != nil && req.UserID == "" {
req.UserID = auth.UserID
}
if err := h.authService.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,
},
})
}

View File

@@ -1,107 +0,0 @@
package handlers
import (
"net/http"
"strconv"
"cmr-backend/internal/apperr"
"cmr-backend/internal/httpx"
"cmr-backend/internal/service"
)
type ConfigHandler struct {
configService *service.ConfigService
}
func NewConfigHandler(configService *service.ConfigService) *ConfigHandler {
return &ConfigHandler{configService: configService}
}
func (h *ConfigHandler) ListLocalFiles(w http.ResponseWriter, r *http.Request) {
result, err := h.configService.ListLocalEventFiles()
if err != nil {
httpx.WriteError(w, err)
return
}
httpx.WriteJSON(w, http.StatusOK, map[string]any{"data": result})
}
func (h *ConfigHandler) ImportLocal(w http.ResponseWriter, r *http.Request) {
var req service.ImportLocalEventConfigInput
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.configService.ImportLocalEventConfig(r.Context(), req)
if err != nil {
httpx.WriteError(w, err)
return
}
httpx.WriteJSON(w, http.StatusOK, map[string]any{"data": result})
}
func (h *ConfigHandler) BuildPreview(w http.ResponseWriter, r *http.Request) {
var req service.BuildPreviewInput
if err := httpx.DecodeJSON(r, &req); err != nil {
httpx.WriteError(w, apperr.New(http.StatusBadRequest, "invalid_json", "invalid request body"))
return
}
result, err := h.configService.BuildPreview(r.Context(), req)
if err != nil {
httpx.WriteError(w, err)
return
}
httpx.WriteJSON(w, http.StatusOK, map[string]any{"data": result})
}
func (h *ConfigHandler) PublishBuild(w http.ResponseWriter, r *http.Request) {
var req service.PublishBuildInput
if err := httpx.DecodeJSON(r, &req); err != nil {
httpx.WriteError(w, apperr.New(http.StatusBadRequest, "invalid_json", "invalid request body"))
return
}
result, err := h.configService.PublishBuild(r.Context(), req)
if err != nil {
httpx.WriteError(w, err)
return
}
httpx.WriteJSON(w, http.StatusOK, map[string]any{"data": result})
}
func (h *ConfigHandler) ListSources(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.configService.ListEventConfigSources(r.Context(), r.PathValue("eventPublicID"), limit)
if err != nil {
httpx.WriteError(w, err)
return
}
httpx.WriteJSON(w, http.StatusOK, map[string]any{"data": result})
}
func (h *ConfigHandler) GetSource(w http.ResponseWriter, r *http.Request) {
result, err := h.configService.GetEventConfigSource(r.Context(), r.PathValue("sourceID"))
if err != nil {
httpx.WriteError(w, err)
return
}
httpx.WriteJSON(w, http.StatusOK, map[string]any{"data": result})
}
func (h *ConfigHandler) GetBuild(w http.ResponseWriter, r *http.Request) {
result, err := h.configService.GetEventConfigBuild(r.Context(), r.PathValue("buildID"))
if err != nil {
httpx.WriteError(w, err)
return
}
httpx.WriteJSON(w, http.StatusOK, map[string]any{"data": result})
}

File diff suppressed because it is too large Load Diff

View File

@@ -1,31 +0,0 @@
package handlers
import (
"net/http"
"cmr-backend/internal/httpx"
"cmr-backend/internal/service"
)
type EntryHandler struct {
entryService *service.EntryService
}
func NewEntryHandler(entryService *service.EntryService) *EntryHandler {
return &EntryHandler{entryService: entryService}
}
func (h *EntryHandler) Resolve(w http.ResponseWriter, r *http.Request) {
result, err := h.entryService.Resolve(r.Context(), service.ResolveEntryInput{
ChannelCode: r.URL.Query().Get("channelCode"),
ChannelType: r.URL.Query().Get("channelType"),
PlatformAppID: r.URL.Query().Get("platformAppId"),
TenantCode: r.URL.Query().Get("tenantCode"),
})
if err != nil {
httpx.WriteError(w, err)
return
}
httpx.WriteJSON(w, http.StatusOK, map[string]any{"data": result})
}

View File

@@ -1,40 +0,0 @@
package handlers
import (
"net/http"
"cmr-backend/internal/apperr"
"cmr-backend/internal/httpapi/middleware"
"cmr-backend/internal/httpx"
"cmr-backend/internal/service"
)
type EntryHomeHandler struct {
entryHomeService *service.EntryHomeService
}
func NewEntryHomeHandler(entryHomeService *service.EntryHomeService) *EntryHomeHandler {
return &EntryHomeHandler{entryHomeService: entryHomeService}
}
func (h *EntryHomeHandler) Get(w http.ResponseWriter, r *http.Request) {
auth := middleware.GetAuthContext(r.Context())
if auth == nil {
httpx.WriteError(w, apperr.New(http.StatusUnauthorized, "unauthorized", "missing auth context"))
return
}
result, err := h.entryHomeService.GetEntryHome(r.Context(), service.EntryHomeInput{
UserID: auth.UserID,
ChannelCode: r.URL.Query().Get("channelCode"),
ChannelType: r.URL.Query().Get("channelType"),
PlatformAppID: r.URL.Query().Get("platformAppId"),
TenantCode: r.URL.Query().Get("tenantCode"),
})
if err != nil {
httpx.WriteError(w, err)
return
}
httpx.WriteJSON(w, http.StatusOK, map[string]any{"data": result})
}

View File

@@ -1,51 +0,0 @@
package handlers
import (
"net/http"
"cmr-backend/internal/apperr"
"cmr-backend/internal/httpapi/middleware"
"cmr-backend/internal/httpx"
"cmr-backend/internal/service"
)
type EventHandler struct {
eventService *service.EventService
}
func NewEventHandler(eventService *service.EventService) *EventHandler {
return &EventHandler{eventService: eventService}
}
func (h *EventHandler) GetDetail(w http.ResponseWriter, r *http.Request) {
eventPublicID := r.PathValue("eventPublicID")
result, err := h.eventService.GetEventDetail(r.Context(), eventPublicID)
if err != nil {
httpx.WriteError(w, err)
return
}
httpx.WriteJSON(w, http.StatusOK, map[string]any{"data": result})
}
func (h *EventHandler) Launch(w http.ResponseWriter, r *http.Request) {
auth := middleware.GetAuthContext(r.Context())
if auth == nil {
httpx.WriteError(w, apperr.New(http.StatusUnauthorized, "unauthorized", "missing auth context"))
return
}
var req service.LaunchEventInput
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")
req.UserID = auth.UserID
result, err := h.eventService.LaunchEvent(r.Context(), req)
if err != nil {
httpx.WriteError(w, err)
return
}
httpx.WriteJSON(w, http.StatusOK, map[string]any{"data": result})
}

View File

@@ -1,37 +0,0 @@
package handlers
import (
"net/http"
"cmr-backend/internal/apperr"
"cmr-backend/internal/httpapi/middleware"
"cmr-backend/internal/httpx"
"cmr-backend/internal/service"
)
type EventPlayHandler struct {
eventPlayService *service.EventPlayService
}
func NewEventPlayHandler(eventPlayService *service.EventPlayService) *EventPlayHandler {
return &EventPlayHandler{eventPlayService: eventPlayService}
}
func (h *EventPlayHandler) Get(w http.ResponseWriter, r *http.Request) {
auth := middleware.GetAuthContext(r.Context())
if auth == nil {
httpx.WriteError(w, apperr.New(http.StatusUnauthorized, "unauthorized", "missing auth context"))
return
}
result, err := h.eventPlayService.GetEventPlay(r.Context(), service.EventPlayInput{
EventPublicID: r.PathValue("eventPublicID"),
UserID: auth.UserID,
})
if err != nil {
httpx.WriteError(w, err)
return
}
httpx.WriteJSON(w, http.StatusOK, map[string]any{"data": result})
}

View File

@@ -1,21 +0,0 @@
package handlers
import (
"net/http"
"cmr-backend/internal/httpx"
)
type HealthHandler struct{}
func NewHealthHandler() *HealthHandler {
return &HealthHandler{}
}
func (h *HealthHandler) Get(w http.ResponseWriter, r *http.Request) {
httpx.WriteJSON(w, http.StatusOK, map[string]any{
"data": map[string]any{
"status": "ok",
},
})
}

View File

@@ -1,53 +0,0 @@
package handlers
import (
"net/http"
"strconv"
"cmr-backend/internal/httpx"
"cmr-backend/internal/service"
)
type HomeHandler struct {
homeService *service.HomeService
}
func NewHomeHandler(homeService *service.HomeService) *HomeHandler {
return &HomeHandler{homeService: homeService}
}
func (h *HomeHandler) GetHome(w http.ResponseWriter, r *http.Request) {
result, err := h.homeService.GetHome(r.Context(), buildListCardsInput(r))
if err != nil {
httpx.WriteError(w, err)
return
}
httpx.WriteJSON(w, http.StatusOK, map[string]any{"data": result})
}
func (h *HomeHandler) GetCards(w http.ResponseWriter, r *http.Request) {
result, err := h.homeService.ListCards(r.Context(), buildListCardsInput(r))
if err != nil {
httpx.WriteError(w, err)
return
}
httpx.WriteJSON(w, http.StatusOK, map[string]any{"data": result})
}
func buildListCardsInput(r *http.Request) service.ListCardsInput {
limit := 20
if raw := r.URL.Query().Get("limit"); raw != "" {
if parsed, err := strconv.Atoi(raw); err == nil {
limit = parsed
}
}
return service.ListCardsInput{
ChannelCode: r.URL.Query().Get("channelCode"),
ChannelType: r.URL.Query().Get("channelType"),
PlatformAppID: r.URL.Query().Get("platformAppId"),
TenantCode: r.URL.Query().Get("tenantCode"),
Slot: r.URL.Query().Get("slot"),
Limit: limit,
}
}

View File

@@ -1,41 +0,0 @@
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})
}

View File

@@ -1,34 +0,0 @@
package handlers
import (
"net/http"
"cmr-backend/internal/apperr"
"cmr-backend/internal/httpapi/middleware"
"cmr-backend/internal/httpx"
"cmr-backend/internal/service"
)
type MeHandler struct {
meService *service.MeService
}
func NewMeHandler(meService *service.MeService) *MeHandler {
return &MeHandler{meService: meService}
}
func (h *MeHandler) Get(w http.ResponseWriter, r *http.Request) {
auth := middleware.GetAuthContext(r.Context())
if auth == nil {
httpx.WriteError(w, apperr.New(http.StatusUnauthorized, "unauthorized", "missing auth context"))
return
}
user, err := h.meService.GetMe(r.Context(), auth.UserID)
if err != nil {
httpx.WriteError(w, err)
return
}
httpx.WriteJSON(w, http.StatusOK, map[string]any{"data": user})
}

View File

@@ -1,101 +0,0 @@
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})
}

View File

@@ -1,25 +0,0 @@
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})
}

File diff suppressed because it is too large Load Diff

View File

@@ -1,34 +0,0 @@
package handlers
import (
"net/http"
"cmr-backend/internal/apperr"
"cmr-backend/internal/httpapi/middleware"
"cmr-backend/internal/httpx"
"cmr-backend/internal/service"
)
type ProfileHandler struct {
profileService *service.ProfileService
}
func NewProfileHandler(profileService *service.ProfileService) *ProfileHandler {
return &ProfileHandler{profileService: profileService}
}
func (h *ProfileHandler) Get(w http.ResponseWriter, r *http.Request) {
auth := middleware.GetAuthContext(r.Context())
if auth == nil {
httpx.WriteError(w, apperr.New(http.StatusUnauthorized, "unauthorized", "missing auth context"))
return
}
result, err := h.profileService.GetProfile(r.Context(), auth.UserID)
if err != nil {
httpx.WriteError(w, err)
return
}
httpx.WriteJSON(w, http.StatusOK, map[string]any{"data": result})
}

View File

@@ -1,78 +0,0 @@
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})
}

View File

@@ -1,162 +0,0 @@
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
}

View File

@@ -1,58 +0,0 @@
package handlers
import (
"net/http"
"strconv"
"cmr-backend/internal/apperr"
"cmr-backend/internal/httpapi/middleware"
"cmr-backend/internal/httpx"
"cmr-backend/internal/service"
)
type ResultHandler struct {
resultService *service.ResultService
}
func NewResultHandler(resultService *service.ResultService) *ResultHandler {
return &ResultHandler{resultService: resultService}
}
func (h *ResultHandler) GetSessionResult(w http.ResponseWriter, r *http.Request) {
auth := middleware.GetAuthContext(r.Context())
if auth == nil {
httpx.WriteError(w, apperr.New(http.StatusUnauthorized, "unauthorized", "missing auth context"))
return
}
result, err := h.resultService.GetSessionResult(r.Context(), r.PathValue("sessionPublicID"), auth.UserID)
if err != nil {
httpx.WriteError(w, err)
return
}
httpx.WriteJSON(w, http.StatusOK, map[string]any{"data": result})
}
func (h *ResultHandler) ListMine(w http.ResponseWriter, r *http.Request) {
auth := middleware.GetAuthContext(r.Context())
if auth == nil {
httpx.WriteError(w, apperr.New(http.StatusUnauthorized, "unauthorized", "missing auth context"))
return
}
limit := 20
if raw := r.URL.Query().Get("limit"); raw != "" {
if parsed, err := strconv.Atoi(raw); err == nil {
limit = parsed
}
}
result, err := h.resultService.ListMyResults(r.Context(), auth.UserID, limit)
if err != nil {
httpx.WriteError(w, err)
return
}
httpx.WriteJSON(w, http.StatusOK, map[string]any{"data": result})
}

View File

@@ -1,88 +0,0 @@
package handlers
import (
"net/http"
"strconv"
"cmr-backend/internal/apperr"
"cmr-backend/internal/httpapi/middleware"
"cmr-backend/internal/httpx"
"cmr-backend/internal/service"
)
type SessionHandler struct {
sessionService *service.SessionService
}
func NewSessionHandler(sessionService *service.SessionService) *SessionHandler {
return &SessionHandler{sessionService: sessionService}
}
func (h *SessionHandler) GetDetail(w http.ResponseWriter, r *http.Request) {
auth := middleware.GetAuthContext(r.Context())
if auth == nil {
httpx.WriteError(w, apperr.New(http.StatusUnauthorized, "unauthorized", "missing auth context"))
return
}
result, err := h.sessionService.GetSession(r.Context(), r.PathValue("sessionPublicID"), auth.UserID)
if err != nil {
httpx.WriteError(w, err)
return
}
httpx.WriteJSON(w, http.StatusOK, map[string]any{"data": result})
}
func (h *SessionHandler) ListMine(w http.ResponseWriter, r *http.Request) {
auth := middleware.GetAuthContext(r.Context())
if auth == nil {
httpx.WriteError(w, apperr.New(http.StatusUnauthorized, "unauthorized", "missing auth context"))
return
}
limit := 20
if raw := r.URL.Query().Get("limit"); raw != "" {
if parsed, err := strconv.Atoi(raw); err == nil {
limit = parsed
}
}
result, err := h.sessionService.ListMySessions(r.Context(), auth.UserID, limit)
if err != nil {
httpx.WriteError(w, err)
return
}
httpx.WriteJSON(w, http.StatusOK, map[string]any{"data": result})
}
func (h *SessionHandler) Start(w http.ResponseWriter, r *http.Request) {
var req service.SessionActionInput
if err := httpx.DecodeJSON(r, &req); err != nil {
httpx.WriteError(w, apperr.New(http.StatusBadRequest, "invalid_json", "invalid request body: "+err.Error()))
return
}
req.SessionPublicID = r.PathValue("sessionPublicID")
result, err := h.sessionService.StartSession(r.Context(), req)
if err != nil {
httpx.WriteError(w, err)
return
}
httpx.WriteJSON(w, http.StatusOK, map[string]any{"data": result})
}
func (h *SessionHandler) Finish(w http.ResponseWriter, r *http.Request) {
var req service.FinishSessionInput
if err := httpx.DecodeJSON(r, &req); err != nil {
httpx.WriteError(w, apperr.New(http.StatusBadRequest, "invalid_json", "invalid request body: "+err.Error()))
return
}
req.SessionPublicID = r.PathValue("sessionPublicID")
result, err := h.sessionService.FinishSession(r.Context(), req)
if err != nil {
httpx.WriteError(w, err)
return
}
httpx.WriteJSON(w, http.StatusOK, map[string]any{"data": result})
}

View File

@@ -1,56 +0,0 @@
package middleware
import (
"context"
"net/http"
"strings"
"cmr-backend/internal/apperr"
"cmr-backend/internal/httpx"
"cmr-backend/internal/platform/jwtx"
)
type authContextKey string
const authKey authContextKey = "auth"
type AuthContext struct {
UserID string
UserPublicID string
RoleCode string
}
func NewAuthMiddleware(jwtManager *jwtx.Manager) func(http.Handler) http.Handler {
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 ") {
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 {
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))
})
}
}
func GetAuthContext(ctx context.Context) *AuthContext {
auth, _ := ctx.Value(authKey).(*AuthContext)
return auth
}

View File

@@ -1,77 +0,0 @@
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
}

View File

@@ -1,210 +0,0 @@
package httpapi
import (
"net/http"
"cmr-backend/internal/httpapi/handlers"
"cmr-backend/internal/httpapi/middleware"
"cmr-backend/internal/platform/jwtx"
"cmr-backend/internal/service"
)
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,
devService *service.DevService,
meService *service.MeService,
) http.Handler {
mux := http.NewServeMux()
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)))
mux.Handle("POST /admin/maps/{mapPublicID}/versions", authMiddleware(http.HandlerFunc(adminResourceHandler.CreateMapVersion)))
mux.Handle("GET /admin/places", authMiddleware(http.HandlerFunc(adminProductionHandler.ListPlaces)))
mux.Handle("POST /admin/places", authMiddleware(http.HandlerFunc(adminProductionHandler.CreatePlace)))
mux.Handle("GET /admin/places/{placePublicID}", authMiddleware(http.HandlerFunc(adminProductionHandler.GetPlace)))
mux.Handle("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)))
mux.Handle("POST /admin/course-sources", authMiddleware(http.HandlerFunc(adminProductionHandler.CreateCourseSource)))
mux.Handle("GET /admin/course-sources/{sourcePublicID}", authMiddleware(http.HandlerFunc(adminProductionHandler.GetCourseSource)))
mux.Handle("GET /admin/course-sets/{courseSetPublicID}", authMiddleware(http.HandlerFunc(adminProductionHandler.GetCourseSet)))
mux.Handle("POST /admin/course-sets/{courseSetPublicID}/variants", authMiddleware(http.HandlerFunc(adminProductionHandler.CreateCourseVariant)))
mux.Handle("GET /admin/runtime-bindings", authMiddleware(http.HandlerFunc(adminProductionHandler.ListRuntimeBindings)))
mux.Handle("POST /admin/runtime-bindings", authMiddleware(http.HandlerFunc(adminProductionHandler.CreateRuntimeBinding)))
mux.Handle("GET /admin/runtime-bindings/{runtimeBindingPublicID}", authMiddleware(http.HandlerFunc(adminProductionHandler.GetRuntimeBinding)))
mux.Handle("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)))
mux.Handle("POST /admin/playfields/{playfieldPublicID}/versions", authMiddleware(http.HandlerFunc(adminResourceHandler.CreatePlayfieldVersion)))
mux.Handle("GET /admin/resource-packs", authMiddleware(http.HandlerFunc(adminResourceHandler.ListResourcePacks)))
mux.Handle("POST /admin/resource-packs", authMiddleware(http.HandlerFunc(adminResourceHandler.CreateResourcePack)))
mux.Handle("GET /admin/resource-packs/{resourcePackPublicID}", authMiddleware(http.HandlerFunc(adminResourceHandler.GetResourcePack)))
mux.Handle("POST /admin/resource-packs/{resourcePackPublicID}/versions", authMiddleware(http.HandlerFunc(adminResourceHandler.CreateResourcePackVersion)))
mux.Handle("GET /admin/events", authMiddleware(http.HandlerFunc(adminEventHandler.ListEvents)))
mux.Handle("POST /admin/events", authMiddleware(http.HandlerFunc(adminEventHandler.CreateEvent)))
mux.Handle("GET /admin/events/{eventPublicID}", authMiddleware(http.HandlerFunc(adminEventHandler.GetEvent)))
mux.Handle("PUT /admin/events/{eventPublicID}", authMiddleware(http.HandlerFunc(adminEventHandler.UpdateEvent)))
mux.Handle("POST /admin/events/{eventPublicID}/source", authMiddleware(http.HandlerFunc(adminEventHandler.SaveSource)))
mux.Handle("GET /admin/events/{eventPublicID}/presentations", authMiddleware(http.HandlerFunc(adminEventHandler.ListPresentations)))
mux.Handle("POST /admin/events/{eventPublicID}/presentations", authMiddleware(http.HandlerFunc(adminEventHandler.CreatePresentation)))
mux.Handle("POST /admin/events/{eventPublicID}/presentations/import", authMiddleware(http.HandlerFunc(adminEventHandler.ImportPresentation)))
mux.Handle("GET /admin/presentations/{presentationPublicID}", authMiddleware(http.HandlerFunc(adminEventHandler.GetPresentation)))
mux.Handle("GET /admin/events/{eventPublicID}/content-bundles", authMiddleware(http.HandlerFunc(adminEventHandler.ListContentBundles)))
mux.Handle("POST /admin/events/{eventPublicID}/content-bundles", authMiddleware(http.HandlerFunc(adminEventHandler.CreateContentBundle)))
mux.Handle("POST /admin/events/{eventPublicID}/content-bundles/import", authMiddleware(http.HandlerFunc(adminEventHandler.ImportContentBundle)))
mux.Handle("GET /admin/content-bundles/{contentBundlePublicID}", authMiddleware(http.HandlerFunc(adminEventHandler.GetContentBundle)))
mux.Handle("POST /admin/events/{eventPublicID}/defaults", authMiddleware(http.HandlerFunc(adminEventHandler.UpdateEventDefaults)))
mux.Handle("GET /admin/events/{eventPublicID}/pipeline", authMiddleware(http.HandlerFunc(adminPipelineHandler.GetEventPipeline)))
mux.Handle("POST /admin/sources/{sourceID}/build", authMiddleware(http.HandlerFunc(adminPipelineHandler.BuildSource)))
mux.Handle("GET /admin/builds/{buildID}", authMiddleware(http.HandlerFunc(adminPipelineHandler.GetBuild)))
mux.Handle("POST /admin/builds/{buildID}/publish", authMiddleware(http.HandlerFunc(adminPipelineHandler.PublishBuild)))
mux.Handle("GET /admin/releases/{releasePublicID}", authMiddleware(http.HandlerFunc(adminPipelineHandler.GetRelease)))
mux.Handle("POST /admin/releases/{releasePublicID}/runtime-binding", authMiddleware(http.HandlerFunc(adminPipelineHandler.BindReleaseRuntime)))
mux.Handle("POST /admin/events/{eventPublicID}/rollback", authMiddleware(http.HandlerFunc(adminPipelineHandler.RollbackRelease)))
if appEnv != "production" {
mux.HandleFunc("GET /dev/workbench", devHandler.Workbench)
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)
mux.HandleFunc("POST /dev/events/{eventPublicID}/config-sources/import-local", configHandler.ImportLocal)
mux.HandleFunc("POST /dev/config-builds/preview", configHandler.BuildPreview)
mux.HandleFunc("POST /dev/config-builds/publish", configHandler.PublishBuild)
}
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)))
mux.Handle("GET /config-sources/{sourceID}", authMiddleware(http.HandlerFunc(configHandler.GetSource)))
mux.Handle("GET /config-builds/{buildID}", authMiddleware(http.HandlerFunc(configHandler.GetBuild)))
mux.Handle("GET /sessions/{sessionPublicID}", authMiddleware(http.HandlerFunc(sessionHandler.GetDetail)))
mux.Handle("GET /sessions/{sessionPublicID}/result", authMiddleware(http.HandlerFunc(resultHandler.GetSessionResult)))
mux.HandleFunc("POST /sessions/{sessionPublicID}/start", sessionHandler.Start)
mux.HandleFunc("POST /sessions/{sessionPublicID}/finish", sessionHandler.Finish)
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
}

View File

@@ -1,39 +0,0 @@
package httpx
import (
"encoding/json"
"net/http"
"cmr-backend/internal/apperr"
)
func WriteJSON(w http.ResponseWriter, status int, payload any) {
w.Header().Set("Content-Type", "application/json; charset=utf-8")
w.WriteHeader(status)
_ = json.NewEncoder(w).Encode(payload)
}
func WriteError(w http.ResponseWriter, err error) {
if appErr := apperr.From(err); appErr != nil {
WriteJSON(w, appErr.Status, map[string]any{
"error": map[string]any{
"code": appErr.Code,
"message": appErr.Message,
},
})
return
}
WriteJSON(w, http.StatusInternalServerError, map[string]any{
"error": map[string]any{
"code": "internal_error",
"message": "internal server error",
},
})
}
func DecodeJSON(r *http.Request, dst any) error {
decoder := json.NewDecoder(r.Body)
decoder.DisallowUnknownFields()
return decoder.Decode(dst)
}

View File

@@ -1,128 +0,0 @@
package assets
import (
"context"
"fmt"
"os"
"os/exec"
"path/filepath"
"strings"
)
type OSSUtilPublisher struct {
ossutilPath string
configFile string
bucketRoot string
publicBaseURL string
}
func NewOSSUtilPublisher(ossutilPath, configFile, bucketRoot, publicBaseURL string) *OSSUtilPublisher {
return &OSSUtilPublisher{
ossutilPath: strings.TrimSpace(ossutilPath),
configFile: strings.TrimSpace(configFile),
bucketRoot: strings.TrimRight(strings.TrimSpace(bucketRoot), "/"),
publicBaseURL: strings.TrimRight(strings.TrimSpace(publicBaseURL), "/"),
}
}
func (p *OSSUtilPublisher) Enabled() bool {
return p != nil &&
p.ossutilPath != "" &&
p.configFile != "" &&
p.bucketRoot != "" &&
p.publicBaseURL != ""
}
func (p *OSSUtilPublisher) UploadJSON(ctx context.Context, publicURL string, payload []byte) error {
if !p.Enabled() {
return fmt.Errorf("asset publisher is not configured")
}
if len(payload) == 0 {
return fmt.Errorf("payload is empty")
}
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)
}
tmpFile, err := os.CreateTemp("", "cmr-manifest-*.json")
if err != nil {
return fmt.Errorf("create temp file: %w", err)
}
tmpPath := tmpFile.Name()
defer os.Remove(tmpPath)
if _, err := tmpFile.Write(payload); err != nil {
tmpFile.Close()
return fmt.Errorf("write temp file: %w", err)
}
if err := tmpFile.Close(); err != nil {
return fmt.Errorf("close temp file: %w", err)
}
target := p.bucketRoot + "/" + objectKey
cmd := exec.CommandContext(ctx, p.ossutilPath, "cp", "-f", tmpPath, 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) 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 == "" {
return "", fmt.Errorf("public url is required")
}
if !strings.HasPrefix(publicURL, p.publicBaseURL+"/") {
return "", fmt.Errorf("public url %s does not match public base %s", publicURL, p.publicBaseURL)
}
relative := strings.TrimPrefix(publicURL, p.publicBaseURL+"/")
relative = strings.ReplaceAll(relative, "\\", "/")
relative = strings.TrimLeft(relative, "/")
if relative == "" {
return "", fmt.Errorf("public url %s resolved to empty object key", publicURL)
}
return filepath.ToSlash(relative), nil
}

View File

@@ -1,75 +0,0 @@
package jwtx
import (
"fmt"
"time"
"github.com/golang-jwt/jwt/v5"
)
type Manager struct {
issuer string
secret []byte
ttl time.Duration
}
type AccessClaims struct {
UserID string `json:"uid"`
UserPublicID string `json:"upub"`
ActorType string `json:"actorType,omitempty"`
RoleCode string `json:"roleCode,omitempty"`
jwt.RegisteredClaims
}
func NewManager(issuer, secret string, ttl time.Duration) *Manager {
return &Manager{
issuer: issuer,
secret: []byte(secret),
ttl: ttl,
}
}
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,
ExpiresAt: jwt.NewNumericDate(expiresAt),
IssuedAt: jwt.NewNumericDate(time.Now().UTC()),
},
}
token := jwt.NewWithClaims(jwt.SigningMethodHS256, claims)
signed, err := token.SignedString(m.secret)
if err != nil {
return "", time.Time{}, err
}
return signed, expiresAt, nil
}
func (m *Manager) ParseAccessToken(tokenString string) (*AccessClaims, error) {
token, err := jwt.ParseWithClaims(tokenString, &AccessClaims{}, func(token *jwt.Token) (any, error) {
if token.Method != jwt.SigningMethodHS256 {
return nil, fmt.Errorf("unexpected signing method")
}
return m.secret, nil
})
if err != nil {
return nil, err
}
claims, ok := token.Claims.(*AccessClaims)
if !ok || !token.Valid {
return nil, fmt.Errorf("invalid token claims")
}
return claims, nil
}

View File

@@ -1,47 +0,0 @@
package security
import (
"crypto/rand"
"crypto/sha256"
"encoding/base64"
"encoding/hex"
)
func GenerateToken(byteLength int) (string, error) {
raw := make([]byte, byteLength)
if _, err := rand.Read(raw); err != nil {
return "", err
}
return base64.RawURLEncoding.EncodeToString(raw), nil
}
func GenerateNumericCode(length int) (string, error) {
if length <= 0 {
length = 6
}
const digits = "0123456789"
raw := make([]byte, length)
if _, err := rand.Read(raw); err != nil {
return "", err
}
code := make([]byte, length)
for i := range raw {
code[i] = digits[int(raw[i])%len(digits)]
}
return string(code), nil
}
func HashText(value string) string {
sum := sha256.Sum256([]byte(value))
return hex.EncodeToString(sum[:])
}
func GeneratePublicID(prefix string) (string, error) {
raw := make([]byte, 8)
if _, err := rand.Read(raw); err != nil {
return "", err
}
return prefix + "_" + hex.EncodeToString(raw), nil
}

View File

@@ -1,120 +0,0 @@
package wechatmini
import (
"context"
"crypto/sha1"
"encoding/hex"
"encoding/json"
"fmt"
"io"
"net/http"
"net/url"
"strings"
"time"
)
type Client struct {
appID string
appSecret string
devPrefix string
httpClient *http.Client
}
type Session struct {
AppID string
OpenID string
UnionID string
SessionKey string
}
type code2SessionResponse struct {
OpenID string `json:"openid"`
SessionKey string `json:"session_key"`
UnionID string `json:"unionid"`
ErrCode int `json:"errcode"`
ErrMsg string `json:"errmsg"`
}
func NewClient(appID, appSecret, devPrefix string) *Client {
return &Client{
appID: appID,
appSecret: appSecret,
devPrefix: devPrefix,
httpClient: &http.Client{Timeout: 8 * time.Second},
}
}
func (c *Client) ExchangeCode(ctx context.Context, code string) (*Session, error) {
code = strings.TrimSpace(code)
if code == "" {
return nil, fmt.Errorf("wechat code is required")
}
if c.devPrefix != "" && strings.HasPrefix(code, c.devPrefix) {
suffix := strings.TrimPrefix(code, c.devPrefix)
if suffix == "" {
suffix = "default"
}
return &Session{
AppID: fallbackString(c.appID, "dev-mini-app"),
OpenID: "dev_openid_" + normalizeDevID(suffix),
UnionID: "",
}, nil
}
if c.appID == "" || c.appSecret == "" {
return nil, fmt.Errorf("wechat mini app credentials are not configured")
}
values := url.Values{}
values.Set("appid", c.appID)
values.Set("secret", c.appSecret)
values.Set("js_code", code)
values.Set("grant_type", "authorization_code")
req, err := http.NewRequestWithContext(ctx, http.MethodGet, "https://api.weixin.qq.com/sns/jscode2session?"+values.Encode(), nil)
if err != nil {
return nil, err
}
resp, err := c.httpClient.Do(req)
if err != nil {
return nil, err
}
defer resp.Body.Close()
body, err := io.ReadAll(resp.Body)
if err != nil {
return nil, err
}
var parsed code2SessionResponse
if err := json.Unmarshal(body, &parsed); err != nil {
return nil, err
}
if parsed.ErrCode != 0 {
return nil, fmt.Errorf("wechat code2session failed: %d %s", parsed.ErrCode, parsed.ErrMsg)
}
if parsed.OpenID == "" {
return nil, fmt.Errorf("wechat code2session returned empty openid")
}
return &Session{
AppID: c.appID,
OpenID: parsed.OpenID,
UnionID: parsed.UnionID,
SessionKey: parsed.SessionKey,
}, nil
}
func normalizeDevID(value string) string {
sum := sha1.Sum([]byte(value))
return hex.EncodeToString(sum[:])[:16]
}
func fallbackString(value, fallback string) string {
if value != "" {
return value
}
return fallback
}

View File

@@ -1,303 +0,0 @@
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
}

File diff suppressed because it is too large Load Diff

View File

@@ -1,294 +0,0 @@
package service
import (
"context"
"net/http"
"strings"
"cmr-backend/internal/apperr"
"cmr-backend/internal/store/postgres"
)
type AdminPipelineService struct {
store *postgres.Store
configService *ConfigService
}
type AdminReleaseView struct {
ID string `json:"id"`
ReleaseNo int `json:"releaseNo"`
ConfigLabel string `json:"configLabel"`
ManifestURL string `json:"manifestUrl"`
ManifestChecksumSha256 *string `json:"manifestChecksumSha256,omitempty"`
RouteCode *string `json:"routeCode,omitempty"`
BuildID *string `json:"buildId,omitempty"`
Status string `json:"status"`
PublishedAt string `json:"publishedAt"`
Runtime *RuntimeSummaryView `json:"runtime,omitempty"`
Presentation *PresentationSummaryView `json:"presentation,omitempty"`
ContentBundle *ContentBundleSummaryView `json:"contentBundle,omitempty"`
}
type AdminEventPipelineView struct {
EventID string `json:"eventId"`
CurrentRelease *AdminReleaseView `json:"currentRelease,omitempty"`
Sources []EventConfigSourceView `json:"sources"`
Builds []EventConfigBuildView `json:"builds"`
Releases []AdminReleaseView `json:"releases"`
}
type AdminRollbackReleaseInput struct {
ReleaseID string `json:"releaseId"`
}
type AdminBindReleaseRuntimeInput struct {
RuntimeBindingID string `json:"runtimeBindingId"`
}
type AdminPublishBuildInput struct {
RuntimeBindingID string `json:"runtimeBindingId,omitempty"`
PresentationID string `json:"presentationId,omitempty"`
ContentBundleID string `json:"contentBundleId,omitempty"`
}
func NewAdminPipelineService(store *postgres.Store, configService *ConfigService) *AdminPipelineService {
return &AdminPipelineService{
store: store,
configService: configService,
}
}
func (s *AdminPipelineService) GetEventPipeline(ctx context.Context, eventPublicID string, limit int) (*AdminEventPipelineView, error) {
event, err := s.store.GetEventByPublicID(ctx, strings.TrimSpace(eventPublicID))
if err != nil {
return nil, err
}
if event == nil {
return nil, apperr.New(http.StatusNotFound, "event_not_found", "event not found")
}
sources, err := s.configService.ListEventConfigSources(ctx, event.PublicID, limit)
if err != nil {
return nil, err
}
buildRecords, err := s.store.ListEventConfigBuildsByEventID(ctx, event.ID, limit)
if err != nil {
return nil, err
}
releaseRecords, err := s.store.ListEventReleasesByEventID(ctx, event.ID, limit)
if err != nil {
return nil, err
}
builds := make([]EventConfigBuildView, 0, len(buildRecords))
for i := range buildRecords {
item, err := buildEventConfigBuildView(&buildRecords[i])
if err != nil {
return nil, err
}
builds = append(builds, *item)
}
releases := make([]AdminReleaseView, 0, len(releaseRecords))
for _, item := range releaseRecords {
view := buildAdminReleaseView(item)
if enrichedPresentation, err := loadPresentationSummaryByPublicID(ctx, s.store, item.PresentationID); err != nil {
return nil, err
} else if enrichedPresentation != nil {
view.Presentation = enrichedPresentation
}
if enrichedBundle, err := loadContentBundleSummaryByPublicID(ctx, s.store, item.ContentBundleID); err != nil {
return nil, err
} else if enrichedBundle != nil {
view.ContentBundle = enrichedBundle
}
releases = append(releases, view)
}
result := &AdminEventPipelineView{
EventID: event.PublicID,
Sources: sources,
Builds: builds,
Releases: releases,
}
if event.CurrentReleasePubID != nil {
result.CurrentRelease = &AdminReleaseView{
ID: *event.CurrentReleasePubID,
ConfigLabel: derefStringOrEmpty(event.ConfigLabel),
ManifestURL: derefStringOrEmpty(event.ManifestURL),
ManifestChecksumSha256: event.ManifestChecksum,
RouteCode: event.RouteCode,
Status: "published",
Runtime: buildRuntimeSummaryFromEvent(event),
Presentation: buildPresentationSummaryFromEvent(event),
ContentBundle: buildContentBundleSummaryFromEvent(event),
}
if enrichedPresentation, err := loadPresentationSummaryByPublicID(ctx, s.store, event.PresentationID); err != nil {
return nil, err
} else if enrichedPresentation != nil {
result.CurrentRelease.Presentation = enrichedPresentation
}
if enrichedBundle, err := loadContentBundleSummaryByPublicID(ctx, s.store, event.ContentBundleID); err != nil {
return nil, err
} else if enrichedBundle != nil {
result.CurrentRelease.ContentBundle = enrichedBundle
}
}
return result, nil
}
func (s *AdminPipelineService) BuildSource(ctx context.Context, sourceID string) (*EventConfigBuildView, error) {
return s.configService.BuildPreview(ctx, BuildPreviewInput{SourceID: sourceID})
}
func (s *AdminPipelineService) GetBuild(ctx context.Context, buildID string) (*EventConfigBuildView, error) {
return s.configService.GetEventConfigBuild(ctx, buildID)
}
func (s *AdminPipelineService) PublishBuild(ctx context.Context, buildID string, input AdminPublishBuildInput) (*PublishedReleaseView, error) {
return s.configService.PublishBuild(ctx, PublishBuildInput{
BuildID: buildID,
RuntimeBindingID: input.RuntimeBindingID,
PresentationID: input.PresentationID,
ContentBundleID: input.ContentBundleID,
})
}
func (s *AdminPipelineService) GetRelease(ctx context.Context, releasePublicID string) (*AdminReleaseView, error) {
release, err := s.store.GetEventReleaseByPublicID(ctx, strings.TrimSpace(releasePublicID))
if err != nil {
return nil, err
}
if release == nil {
return nil, apperr.New(http.StatusNotFound, "release_not_found", "release not found")
}
view := buildAdminReleaseView(*release)
if enrichedPresentation, err := loadPresentationSummaryByPublicID(ctx, s.store, release.PresentationID); err != nil {
return nil, err
} else if enrichedPresentation != nil {
view.Presentation = enrichedPresentation
}
if enrichedBundle, err := loadContentBundleSummaryByPublicID(ctx, s.store, release.ContentBundleID); err != nil {
return nil, err
} else if enrichedBundle != nil {
view.ContentBundle = enrichedBundle
}
return &view, nil
}
func (s *AdminPipelineService) BindReleaseRuntime(ctx context.Context, releasePublicID string, input AdminBindReleaseRuntimeInput) (*AdminReleaseView, error) {
release, err := s.store.GetEventReleaseByPublicID(ctx, strings.TrimSpace(releasePublicID))
if err != nil {
return nil, err
}
if release == nil {
return nil, apperr.New(http.StatusNotFound, "release_not_found", "release not found")
}
input.RuntimeBindingID = strings.TrimSpace(input.RuntimeBindingID)
if input.RuntimeBindingID == "" {
return nil, apperr.New(http.StatusBadRequest, "invalid_params", "runtimeBindingId is required")
}
runtimeBinding, err := s.store.GetMapRuntimeBindingByPublicID(ctx, input.RuntimeBindingID)
if err != nil {
return nil, err
}
if runtimeBinding == nil {
return nil, apperr.New(http.StatusNotFound, "runtime_binding_not_found", "runtime binding not found")
}
if runtimeBinding.EventID != release.EventID {
return nil, apperr.New(http.StatusConflict, "runtime_binding_not_belong_to_event", "runtime binding does not belong to release event")
}
tx, err := s.store.Begin(ctx)
if err != nil {
return nil, err
}
defer tx.Rollback(ctx)
if err := s.store.SetEventReleaseRuntimeBinding(ctx, tx, release.ID, &runtimeBinding.ID); err != nil {
return nil, err
}
if err := tx.Commit(ctx); err != nil {
return nil, err
}
updated, err := s.store.GetEventReleaseByPublicID(ctx, release.PublicID)
if err != nil {
return nil, err
}
if updated == nil {
return nil, apperr.New(http.StatusNotFound, "release_not_found", "release not found")
}
view := buildAdminReleaseView(*updated)
return &view, nil
}
func (s *AdminPipelineService) RollbackRelease(ctx context.Context, eventPublicID string, input AdminRollbackReleaseInput) (*AdminReleaseView, error) {
event, err := s.store.GetEventByPublicID(ctx, strings.TrimSpace(eventPublicID))
if err != nil {
return nil, err
}
if event == nil {
return nil, apperr.New(http.StatusNotFound, "event_not_found", "event not found")
}
input.ReleaseID = strings.TrimSpace(input.ReleaseID)
if input.ReleaseID == "" {
return nil, apperr.New(http.StatusBadRequest, "invalid_params", "releaseId is required")
}
release, err := s.store.GetEventReleaseByPublicID(ctx, input.ReleaseID)
if err != nil {
return nil, err
}
if release == nil {
return nil, apperr.New(http.StatusNotFound, "release_not_found", "release not found")
}
if release.EventID != event.ID {
return nil, apperr.New(http.StatusConflict, "release_not_belong_to_event", "release does not belong to event")
}
if release.Status != "published" {
return nil, apperr.New(http.StatusConflict, "release_not_publishable", "release is not published")
}
tx, err := s.store.Begin(ctx)
if err != nil {
return nil, err
}
defer tx.Rollback(ctx)
if err := s.store.SetCurrentEventRelease(ctx, tx, event.ID, release.ID); err != nil {
return nil, err
}
if err := tx.Commit(ctx); err != nil {
return nil, err
}
view := buildAdminReleaseView(*release)
return &view, nil
}
func buildAdminReleaseView(item postgres.EventRelease) AdminReleaseView {
return AdminReleaseView{
ID: item.PublicID,
ReleaseNo: item.ReleaseNo,
ConfigLabel: item.ConfigLabel,
ManifestURL: item.ManifestURL,
ManifestChecksumSha256: item.ManifestChecksum,
RouteCode: item.RouteCode,
BuildID: item.BuildID,
Status: item.Status,
PublishedAt: item.PublishedAt.Format(timeRFC3339),
Runtime: buildRuntimeSummaryFromRelease(&item),
Presentation: buildPresentationSummaryFromRelease(&item),
ContentBundle: buildContentBundleSummaryFromRelease(&item),
}
}
func derefStringOrEmpty(value *string) string {
if value == nil {
return ""
}
return *value
}

File diff suppressed because it is too large Load Diff

View File

@@ -1,719 +0,0 @@
package service
import (
"context"
"encoding/json"
"net/http"
"strings"
"cmr-backend/internal/apperr"
"cmr-backend/internal/platform/security"
"cmr-backend/internal/store/postgres"
)
type AdminResourceService struct {
store *postgres.Store
}
type AdminMapSummary struct {
ID string `json:"id"`
Code string `json:"code"`
Name string `json:"name"`
Status string `json:"status"`
Description *string `json:"description,omitempty"`
CurrentVersionID *string `json:"currentVersionId,omitempty"`
CurrentVersion *AdminMapVersionBrief `json:"currentVersion,omitempty"`
}
type AdminMapVersionBrief struct {
ID string `json:"id"`
VersionCode string `json:"versionCode"`
Status string `json:"status"`
}
type AdminMapVersion struct {
ID string `json:"id"`
VersionCode string `json:"versionCode"`
Status string `json:"status"`
MapmetaURL string `json:"mapmetaUrl"`
TilesRootURL string `json:"tilesRootUrl"`
PublishedAssetRoot *string `json:"publishedAssetRoot,omitempty"`
Bounds map[string]any `json:"bounds,omitempty"`
Metadata map[string]any `json:"metadata,omitempty"`
}
type AdminMapDetail struct {
Map AdminMapSummary `json:"map"`
Versions []AdminMapVersion `json:"versions"`
}
type CreateAdminMapInput struct {
Code string `json:"code"`
Name string `json:"name"`
Status string `json:"status"`
Description *string `json:"description,omitempty"`
}
type CreateAdminMapVersionInput struct {
VersionCode string `json:"versionCode"`
Status string `json:"status"`
MapmetaURL string `json:"mapmetaUrl"`
TilesRootURL string `json:"tilesRootUrl"`
PublishedAssetRoot *string `json:"publishedAssetRoot,omitempty"`
Bounds map[string]any `json:"bounds,omitempty"`
Metadata map[string]any `json:"metadata,omitempty"`
SetAsCurrent bool `json:"setAsCurrent"`
}
type AdminPlayfieldSummary struct {
ID string `json:"id"`
Code string `json:"code"`
Name string `json:"name"`
Kind string `json:"kind"`
Status string `json:"status"`
Description *string `json:"description,omitempty"`
CurrentVersionID *string `json:"currentVersionId,omitempty"`
CurrentVersion *AdminPlayfieldVersionBrief `json:"currentVersion,omitempty"`
}
type AdminPlayfieldVersionBrief struct {
ID string `json:"id"`
VersionCode string `json:"versionCode"`
Status string `json:"status"`
SourceType string `json:"sourceType"`
}
type AdminPlayfieldVersion struct {
ID string `json:"id"`
VersionCode string `json:"versionCode"`
Status string `json:"status"`
SourceType string `json:"sourceType"`
SourceURL string `json:"sourceUrl"`
PublishedAssetRoot *string `json:"publishedAssetRoot,omitempty"`
ControlCount *int `json:"controlCount,omitempty"`
Bounds map[string]any `json:"bounds,omitempty"`
Metadata map[string]any `json:"metadata,omitempty"`
}
type AdminPlayfieldDetail struct {
Playfield AdminPlayfieldSummary `json:"playfield"`
Versions []AdminPlayfieldVersion `json:"versions"`
}
type CreateAdminPlayfieldInput struct {
Code string `json:"code"`
Name string `json:"name"`
Kind string `json:"kind"`
Status string `json:"status"`
Description *string `json:"description,omitempty"`
}
type CreateAdminPlayfieldVersionInput struct {
VersionCode string `json:"versionCode"`
Status string `json:"status"`
SourceType string `json:"sourceType"`
SourceURL string `json:"sourceUrl"`
PublishedAssetRoot *string `json:"publishedAssetRoot,omitempty"`
ControlCount *int `json:"controlCount,omitempty"`
Bounds map[string]any `json:"bounds,omitempty"`
Metadata map[string]any `json:"metadata,omitempty"`
SetAsCurrent bool `json:"setAsCurrent"`
}
type AdminResourcePackSummary struct {
ID string `json:"id"`
Code string `json:"code"`
Name string `json:"name"`
Status string `json:"status"`
Description *string `json:"description,omitempty"`
CurrentVersionID *string `json:"currentVersionId,omitempty"`
CurrentVersion *AdminResourcePackVersionBrief `json:"currentVersion,omitempty"`
}
type AdminResourcePackVersionBrief struct {
ID string `json:"id"`
VersionCode string `json:"versionCode"`
Status string `json:"status"`
}
type AdminResourcePackVersion struct {
ID string `json:"id"`
VersionCode string `json:"versionCode"`
Status string `json:"status"`
ContentEntryURL *string `json:"contentEntryUrl,omitempty"`
AudioRootURL *string `json:"audioRootUrl,omitempty"`
ThemeProfileCode *string `json:"themeProfileCode,omitempty"`
PublishedAssetRoot *string `json:"publishedAssetRoot,omitempty"`
Metadata map[string]any `json:"metadata,omitempty"`
}
type AdminResourcePackDetail struct {
ResourcePack AdminResourcePackSummary `json:"resourcePack"`
Versions []AdminResourcePackVersion `json:"versions"`
}
type CreateAdminResourcePackInput struct {
Code string `json:"code"`
Name string `json:"name"`
Status string `json:"status"`
Description *string `json:"description,omitempty"`
}
type CreateAdminResourcePackVersionInput struct {
VersionCode string `json:"versionCode"`
Status string `json:"status"`
ContentEntryURL *string `json:"contentEntryUrl,omitempty"`
AudioRootURL *string `json:"audioRootUrl,omitempty"`
ThemeProfileCode *string `json:"themeProfileCode,omitempty"`
PublishedAssetRoot *string `json:"publishedAssetRoot,omitempty"`
Metadata map[string]any `json:"metadata,omitempty"`
SetAsCurrent bool `json:"setAsCurrent"`
}
func NewAdminResourceService(store *postgres.Store) *AdminResourceService {
return &AdminResourceService{store: store}
}
func (s *AdminResourceService) ListMaps(ctx context.Context, limit int) ([]AdminMapSummary, error) {
items, err := s.store.ListResourceMaps(ctx, limit)
if err != nil {
return nil, err
}
results := make([]AdminMapSummary, 0, len(items))
for _, item := range items {
results = append(results, AdminMapSummary{
ID: item.PublicID,
Code: item.Code,
Name: item.Name,
Status: item.Status,
Description: item.Description,
CurrentVersionID: item.CurrentVersionID,
})
}
return results, nil
}
func (s *AdminResourceService) CreateMap(ctx context.Context, input CreateAdminMapInput) (*AdminMapSummary, error) {
input.Code = strings.TrimSpace(input.Code)
input.Name = strings.TrimSpace(input.Name)
status := normalizeCatalogStatus(input.Status)
if input.Code == "" || input.Name == "" {
return nil, apperr.New(http.StatusBadRequest, "invalid_params", "code and name are required")
}
publicID, err := security.GeneratePublicID("map")
if err != nil {
return nil, err
}
tx, err := s.store.Begin(ctx)
if err != nil {
return nil, err
}
defer tx.Rollback(ctx)
item, err := s.store.CreateResourceMap(ctx, tx, postgres.CreateResourceMapParams{
PublicID: publicID,
Code: input.Code,
Name: input.Name,
Status: status,
Description: trimStringPtr(input.Description),
})
if err != nil {
return nil, err
}
if err := tx.Commit(ctx); err != nil {
return nil, err
}
return &AdminMapSummary{
ID: item.PublicID,
Code: item.Code,
Name: item.Name,
Status: item.Status,
Description: item.Description,
CurrentVersionID: item.CurrentVersionID,
}, nil
}
func (s *AdminResourceService) GetMapDetail(ctx context.Context, mapPublicID string) (*AdminMapDetail, error) {
item, err := s.store.GetResourceMapByPublicID(ctx, strings.TrimSpace(mapPublicID))
if err != nil {
return nil, err
}
if item == nil {
return nil, apperr.New(http.StatusNotFound, "map_not_found", "map not found")
}
versions, err := s.store.ListResourceMapVersions(ctx, item.ID)
if err != nil {
return nil, err
}
result := &AdminMapDetail{
Map: AdminMapSummary{
ID: item.PublicID,
Code: item.Code,
Name: item.Name,
Status: item.Status,
Description: item.Description,
CurrentVersionID: item.CurrentVersionID,
},
Versions: make([]AdminMapVersion, 0, len(versions)),
}
for _, version := range versions {
view := AdminMapVersion{
ID: version.PublicID,
VersionCode: version.VersionCode,
Status: version.Status,
MapmetaURL: version.MapmetaURL,
TilesRootURL: version.TilesRootURL,
PublishedAssetRoot: version.PublishedAssetRoot,
Bounds: decodeJSONMap(version.BoundsJSON),
Metadata: decodeJSONMap(version.MetadataJSON),
}
result.Versions = append(result.Versions, view)
if item.CurrentVersionID != nil && *item.CurrentVersionID == version.ID {
result.Map.CurrentVersion = &AdminMapVersionBrief{
ID: version.PublicID,
VersionCode: version.VersionCode,
Status: version.Status,
}
result.Map.CurrentVersionID = &view.ID
}
}
return result, nil
}
func (s *AdminResourceService) CreateMapVersion(ctx context.Context, mapPublicID string, input CreateAdminMapVersionInput) (*AdminMapVersion, error) {
mapItem, err := s.store.GetResourceMapByPublicID(ctx, strings.TrimSpace(mapPublicID))
if err != nil {
return nil, err
}
if mapItem == nil {
return nil, apperr.New(http.StatusNotFound, "map_not_found", "map not found")
}
input.VersionCode = strings.TrimSpace(input.VersionCode)
input.MapmetaURL = strings.TrimSpace(input.MapmetaURL)
input.TilesRootURL = strings.TrimSpace(input.TilesRootURL)
if input.VersionCode == "" || input.MapmetaURL == "" || input.TilesRootURL == "" {
return nil, apperr.New(http.StatusBadRequest, "invalid_params", "versionCode, mapmetaUrl and tilesRootUrl are required")
}
publicID, err := security.GeneratePublicID("mapv")
if err != nil {
return nil, err
}
tx, err := s.store.Begin(ctx)
if err != nil {
return nil, err
}
defer tx.Rollback(ctx)
version, err := s.store.CreateResourceMapVersion(ctx, tx, postgres.CreateResourceMapVersionParams{
PublicID: publicID,
MapID: mapItem.ID,
VersionCode: input.VersionCode,
Status: normalizeVersionStatus(input.Status),
MapmetaURL: input.MapmetaURL,
TilesRootURL: input.TilesRootURL,
PublishedAssetRoot: trimStringPtr(input.PublishedAssetRoot),
BoundsJSON: input.Bounds,
MetadataJSON: input.Metadata,
})
if err != nil {
return nil, err
}
if input.SetAsCurrent {
if err := s.store.SetResourceMapCurrentVersion(ctx, tx, mapItem.ID, version.ID); err != nil {
return nil, err
}
}
if err := tx.Commit(ctx); err != nil {
return nil, err
}
return &AdminMapVersion{
ID: version.PublicID,
VersionCode: version.VersionCode,
Status: version.Status,
MapmetaURL: version.MapmetaURL,
TilesRootURL: version.TilesRootURL,
PublishedAssetRoot: version.PublishedAssetRoot,
Bounds: decodeJSONMap(version.BoundsJSON),
Metadata: decodeJSONMap(version.MetadataJSON),
}, nil
}
func (s *AdminResourceService) ListPlayfields(ctx context.Context, limit int) ([]AdminPlayfieldSummary, error) {
items, err := s.store.ListResourcePlayfields(ctx, limit)
if err != nil {
return nil, err
}
results := make([]AdminPlayfieldSummary, 0, len(items))
for _, item := range items {
results = append(results, AdminPlayfieldSummary{
ID: item.PublicID,
Code: item.Code,
Name: item.Name,
Kind: item.Kind,
Status: item.Status,
Description: item.Description,
CurrentVersionID: item.CurrentVersionID,
})
}
return results, nil
}
func (s *AdminResourceService) CreatePlayfield(ctx context.Context, input CreateAdminPlayfieldInput) (*AdminPlayfieldSummary, error) {
input.Code = strings.TrimSpace(input.Code)
input.Name = strings.TrimSpace(input.Name)
kind := strings.TrimSpace(input.Kind)
if kind == "" {
kind = "course"
}
if input.Code == "" || input.Name == "" {
return nil, apperr.New(http.StatusBadRequest, "invalid_params", "code and name are required")
}
publicID, err := security.GeneratePublicID("pf")
if err != nil {
return nil, err
}
tx, err := s.store.Begin(ctx)
if err != nil {
return nil, err
}
defer tx.Rollback(ctx)
item, err := s.store.CreateResourcePlayfield(ctx, tx, postgres.CreateResourcePlayfieldParams{
PublicID: publicID,
Code: input.Code,
Name: input.Name,
Kind: kind,
Status: normalizeCatalogStatus(input.Status),
Description: trimStringPtr(input.Description),
})
if err != nil {
return nil, err
}
if err := tx.Commit(ctx); err != nil {
return nil, err
}
return &AdminPlayfieldSummary{
ID: item.PublicID,
Code: item.Code,
Name: item.Name,
Kind: item.Kind,
Status: item.Status,
Description: item.Description,
CurrentVersionID: item.CurrentVersionID,
}, nil
}
func (s *AdminResourceService) GetPlayfieldDetail(ctx context.Context, publicID string) (*AdminPlayfieldDetail, error) {
item, err := s.store.GetResourcePlayfieldByPublicID(ctx, strings.TrimSpace(publicID))
if err != nil {
return nil, err
}
if item == nil {
return nil, apperr.New(http.StatusNotFound, "playfield_not_found", "playfield not found")
}
versions, err := s.store.ListResourcePlayfieldVersions(ctx, item.ID)
if err != nil {
return nil, err
}
result := &AdminPlayfieldDetail{
Playfield: AdminPlayfieldSummary{
ID: item.PublicID,
Code: item.Code,
Name: item.Name,
Kind: item.Kind,
Status: item.Status,
Description: item.Description,
CurrentVersionID: item.CurrentVersionID,
},
Versions: make([]AdminPlayfieldVersion, 0, len(versions)),
}
for _, version := range versions {
view := AdminPlayfieldVersion{
ID: version.PublicID,
VersionCode: version.VersionCode,
Status: version.Status,
SourceType: version.SourceType,
SourceURL: version.SourceURL,
PublishedAssetRoot: version.PublishedAssetRoot,
ControlCount: version.ControlCount,
Bounds: decodeJSONMap(version.BoundsJSON),
Metadata: decodeJSONMap(version.MetadataJSON),
}
result.Versions = append(result.Versions, view)
if item.CurrentVersionID != nil && *item.CurrentVersionID == version.ID {
result.Playfield.CurrentVersion = &AdminPlayfieldVersionBrief{
ID: version.PublicID,
VersionCode: version.VersionCode,
Status: version.Status,
SourceType: version.SourceType,
}
result.Playfield.CurrentVersionID = &view.ID
}
}
return result, nil
}
func (s *AdminResourceService) CreatePlayfieldVersion(ctx context.Context, publicID string, input CreateAdminPlayfieldVersionInput) (*AdminPlayfieldVersion, error) {
item, err := s.store.GetResourcePlayfieldByPublicID(ctx, strings.TrimSpace(publicID))
if err != nil {
return nil, err
}
if item == nil {
return nil, apperr.New(http.StatusNotFound, "playfield_not_found", "playfield not found")
}
input.VersionCode = strings.TrimSpace(input.VersionCode)
input.SourceType = strings.TrimSpace(input.SourceType)
input.SourceURL = strings.TrimSpace(input.SourceURL)
if input.VersionCode == "" || input.SourceType == "" || input.SourceURL == "" {
return nil, apperr.New(http.StatusBadRequest, "invalid_params", "versionCode, sourceType and sourceUrl are required")
}
publicVersionID, err := security.GeneratePublicID("pfv")
if err != nil {
return nil, err
}
tx, err := s.store.Begin(ctx)
if err != nil {
return nil, err
}
defer tx.Rollback(ctx)
version, err := s.store.CreateResourcePlayfieldVersion(ctx, tx, postgres.CreateResourcePlayfieldVersionParams{
PublicID: publicVersionID,
PlayfieldID: item.ID,
VersionCode: input.VersionCode,
Status: normalizeVersionStatus(input.Status),
SourceType: input.SourceType,
SourceURL: input.SourceURL,
PublishedAssetRoot: trimStringPtr(input.PublishedAssetRoot),
ControlCount: input.ControlCount,
BoundsJSON: input.Bounds,
MetadataJSON: input.Metadata,
})
if err != nil {
return nil, err
}
if input.SetAsCurrent {
if err := s.store.SetResourcePlayfieldCurrentVersion(ctx, tx, item.ID, version.ID); err != nil {
return nil, err
}
}
if err := tx.Commit(ctx); err != nil {
return nil, err
}
return &AdminPlayfieldVersion{
ID: version.PublicID,
VersionCode: version.VersionCode,
Status: version.Status,
SourceType: version.SourceType,
SourceURL: version.SourceURL,
PublishedAssetRoot: version.PublishedAssetRoot,
ControlCount: version.ControlCount,
Bounds: decodeJSONMap(version.BoundsJSON),
Metadata: decodeJSONMap(version.MetadataJSON),
}, nil
}
func (s *AdminResourceService) ListResourcePacks(ctx context.Context, limit int) ([]AdminResourcePackSummary, error) {
items, err := s.store.ListResourcePacks(ctx, limit)
if err != nil {
return nil, err
}
results := make([]AdminResourcePackSummary, 0, len(items))
for _, item := range items {
results = append(results, AdminResourcePackSummary{
ID: item.PublicID,
Code: item.Code,
Name: item.Name,
Status: item.Status,
Description: item.Description,
CurrentVersionID: item.CurrentVersionID,
})
}
return results, nil
}
func (s *AdminResourceService) CreateResourcePack(ctx context.Context, input CreateAdminResourcePackInput) (*AdminResourcePackSummary, error) {
input.Code = strings.TrimSpace(input.Code)
input.Name = strings.TrimSpace(input.Name)
if input.Code == "" || input.Name == "" {
return nil, apperr.New(http.StatusBadRequest, "invalid_params", "code and name are required")
}
publicID, err := security.GeneratePublicID("rp")
if err != nil {
return nil, err
}
tx, err := s.store.Begin(ctx)
if err != nil {
return nil, err
}
defer tx.Rollback(ctx)
item, err := s.store.CreateResourcePack(ctx, tx, postgres.CreateResourcePackParams{
PublicID: publicID,
Code: input.Code,
Name: input.Name,
Status: normalizeCatalogStatus(input.Status),
Description: trimStringPtr(input.Description),
})
if err != nil {
return nil, err
}
if err := tx.Commit(ctx); err != nil {
return nil, err
}
return &AdminResourcePackSummary{
ID: item.PublicID,
Code: item.Code,
Name: item.Name,
Status: item.Status,
Description: item.Description,
CurrentVersionID: item.CurrentVersionID,
}, nil
}
func (s *AdminResourceService) GetResourcePackDetail(ctx context.Context, publicID string) (*AdminResourcePackDetail, error) {
item, err := s.store.GetResourcePackByPublicID(ctx, strings.TrimSpace(publicID))
if err != nil {
return nil, err
}
if item == nil {
return nil, apperr.New(http.StatusNotFound, "resource_pack_not_found", "resource pack not found")
}
versions, err := s.store.ListResourcePackVersions(ctx, item.ID)
if err != nil {
return nil, err
}
result := &AdminResourcePackDetail{
ResourcePack: AdminResourcePackSummary{
ID: item.PublicID,
Code: item.Code,
Name: item.Name,
Status: item.Status,
Description: item.Description,
CurrentVersionID: item.CurrentVersionID,
},
Versions: make([]AdminResourcePackVersion, 0, len(versions)),
}
for _, version := range versions {
view := AdminResourcePackVersion{
ID: version.PublicID,
VersionCode: version.VersionCode,
Status: version.Status,
ContentEntryURL: version.ContentEntryURL,
AudioRootURL: version.AudioRootURL,
ThemeProfileCode: version.ThemeProfileCode,
PublishedAssetRoot: version.PublishedAssetRoot,
Metadata: decodeJSONMap(version.MetadataJSON),
}
result.Versions = append(result.Versions, view)
if item.CurrentVersionID != nil && *item.CurrentVersionID == version.ID {
result.ResourcePack.CurrentVersion = &AdminResourcePackVersionBrief{
ID: version.PublicID,
VersionCode: version.VersionCode,
Status: version.Status,
}
result.ResourcePack.CurrentVersionID = &view.ID
}
}
return result, nil
}
func (s *AdminResourceService) CreateResourcePackVersion(ctx context.Context, publicID string, input CreateAdminResourcePackVersionInput) (*AdminResourcePackVersion, error) {
item, err := s.store.GetResourcePackByPublicID(ctx, strings.TrimSpace(publicID))
if err != nil {
return nil, err
}
if item == nil {
return nil, apperr.New(http.StatusNotFound, "resource_pack_not_found", "resource pack not found")
}
input.VersionCode = strings.TrimSpace(input.VersionCode)
if input.VersionCode == "" {
return nil, apperr.New(http.StatusBadRequest, "invalid_params", "versionCode is required")
}
publicVersionID, err := security.GeneratePublicID("rpv")
if err != nil {
return nil, err
}
tx, err := s.store.Begin(ctx)
if err != nil {
return nil, err
}
defer tx.Rollback(ctx)
version, err := s.store.CreateResourcePackVersion(ctx, tx, postgres.CreateResourcePackVersionParams{
PublicID: publicVersionID,
ResourcePackID: item.ID,
VersionCode: input.VersionCode,
Status: normalizeVersionStatus(input.Status),
ContentEntryURL: trimStringPtr(input.ContentEntryURL),
AudioRootURL: trimStringPtr(input.AudioRootURL),
ThemeProfileCode: trimStringPtr(input.ThemeProfileCode),
PublishedAssetRoot: trimStringPtr(input.PublishedAssetRoot),
MetadataJSON: input.Metadata,
})
if err != nil {
return nil, err
}
if input.SetAsCurrent {
if err := s.store.SetResourcePackCurrentVersion(ctx, tx, item.ID, version.ID); err != nil {
return nil, err
}
}
if err := tx.Commit(ctx); err != nil {
return nil, err
}
return &AdminResourcePackVersion{
ID: version.PublicID,
VersionCode: version.VersionCode,
Status: version.Status,
ContentEntryURL: version.ContentEntryURL,
AudioRootURL: version.AudioRootURL,
ThemeProfileCode: version.ThemeProfileCode,
PublishedAssetRoot: version.PublishedAssetRoot,
Metadata: decodeJSONMap(version.MetadataJSON),
}, nil
}
func normalizeCatalogStatus(value string) string {
switch strings.TrimSpace(value) {
case "active":
return "active"
case "disabled":
return "disabled"
case "archived":
return "archived"
default:
return "draft"
}
}
func normalizeVersionStatus(value string) string {
switch strings.TrimSpace(value) {
case "active":
return "active"
case "archived":
return "archived"
default:
return "draft"
}
}
func trimStringPtr(value *string) *string {
if value == nil {
return nil
}
trimmed := strings.TrimSpace(*value)
if trimmed == "" {
return nil
}
return &trimmed
}
func decodeJSONMap(raw json.RawMessage) map[string]any {
if len(raw) == 0 {
return nil
}
result := map[string]any{}
if err := json.Unmarshal(raw, &result); err != nil || len(result) == 0 {
return nil
}
return result
}

View File

@@ -1,595 +0,0 @@
package service
import (
"context"
"encoding/json"
"net/http"
"strings"
"time"
"cmr-backend/internal/apperr"
"cmr-backend/internal/platform/jwtx"
"cmr-backend/internal/platform/security"
"cmr-backend/internal/platform/wechatmini"
"cmr-backend/internal/store/postgres"
)
type AuthSettings struct {
AppEnv string
RefreshTTL time.Duration
SMSCodeTTL time.Duration
SMSCodeCooldown time.Duration
SMSProvider string
DevSMSCode string
WechatMini *wechatmini.Client
}
type AuthService struct {
cfg AuthSettings
store *postgres.Store
jwtManager *jwtx.Manager
}
type SendSMSCodeInput struct {
CountryCode string `json:"countryCode"`
Mobile string `json:"mobile"`
ClientType string `json:"clientType"`
DeviceKey string `json:"deviceKey"`
Scene string `json:"scene"`
}
type SendSMSCodeResult struct {
TTLSeconds int64 `json:"ttlSeconds"`
CooldownSeconds int64 `json:"cooldownSeconds"`
DevCode *string `json:"devCode,omitempty"`
}
type LoginSMSInput struct {
CountryCode string `json:"countryCode"`
Mobile string `json:"mobile"`
Code string `json:"code"`
ClientType string `json:"clientType"`
DeviceKey string `json:"deviceKey"`
}
type LoginWechatMiniInput struct {
Code string `json:"code"`
ClientType string `json:"clientType"`
DeviceKey string `json:"deviceKey"`
}
type BindMobileInput struct {
UserID string `json:"-"`
CountryCode string `json:"countryCode"`
Mobile string `json:"mobile"`
Code string `json:"code"`
ClientType string `json:"clientType"`
DeviceKey string `json:"deviceKey"`
}
type RefreshTokenInput struct {
RefreshToken string `json:"refreshToken"`
ClientType string `json:"clientType"`
DeviceKey string `json:"deviceKey"`
}
type LogoutInput struct {
RefreshToken string `json:"refreshToken"`
UserID string `json:"-"`
}
type AuthUser struct {
ID string `json:"id"`
PublicID string `json:"publicId"`
Status string `json:"status"`
Nickname *string `json:"nickname,omitempty"`
AvatarURL *string `json:"avatarUrl,omitempty"`
}
type AuthTokens struct {
AccessToken string `json:"accessToken"`
AccessTokenExpiresAt string `json:"accessTokenExpiresAt"`
RefreshToken string `json:"refreshToken"`
RefreshTokenExpiresAt string `json:"refreshTokenExpiresAt"`
}
type AuthResult struct {
User AuthUser `json:"user"`
Tokens AuthTokens `json:"tokens"`
NewUser bool `json:"newUser"`
}
func NewAuthService(cfg AuthSettings, store *postgres.Store, jwtManager *jwtx.Manager) *AuthService {
return &AuthService{
cfg: cfg,
store: store,
jwtManager: jwtManager,
}
}
func (s *AuthService) SendSMSCode(ctx context.Context, input SendSMSCodeInput) (*SendSMSCodeResult, error) {
input.CountryCode = normalizeCountryCode(input.CountryCode)
input.Mobile = normalizeMobile(input.Mobile)
input.Scene = normalizeScene(input.Scene)
if err := validateClientType(input.ClientType); err != nil {
return nil, err
}
if input.Mobile == "" || 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, input.ClientType, 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: input.ClientType,
DeviceKey: input.DeviceKey,
CodeHash: security.HashText(code),
ProviderName: s.cfg.SMSProvider,
ProviderDebug: map[string]any{"mode": s.cfg.SMSProvider},
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 *AuthService) LoginSMS(ctx context.Context, input LoginSMSInput) (*AuthResult, error) {
input.CountryCode = normalizeCountryCode(input.CountryCode)
input.Mobile = normalizeMobile(input.Mobile)
input.Code = strings.TrimSpace(input.Code)
if err := validateClientType(input.ClientType); err != nil {
return nil, err
}
if input.Mobile == "" || input.DeviceKey == "" || input.Code == "" {
return nil, apperr.New(http.StatusBadRequest, "invalid_params", "mobile, code and deviceKey are required")
}
codeRecord, err := s.store.GetLatestValidSMSCode(ctx, input.CountryCode, input.Mobile, input.ClientType, "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.FindUserByMobile(ctx, tx, input.CountryCode, input.Mobile)
if err != nil {
return nil, err
}
newUser := false
if 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.CreateMobileIdentity(ctx, tx, postgres.CreateMobileIdentityParams{
UserID: user.ID,
CountryCode: input.CountryCode,
Mobile: input.Mobile,
Provider: "mobile",
ProviderSubj: input.CountryCode + ":" + input.Mobile,
IdentityType: "mobile",
}); err != nil {
return nil, err
}
newUser = true
}
if err := s.store.TouchUserLogin(ctx, tx, user.ID); err != nil {
return nil, err
}
result, err := s.issueAuthResult(ctx, tx, *user, input.ClientType, input.DeviceKey, newUser)
if err != nil {
return nil, err
}
if err := tx.Commit(ctx); err != nil {
return nil, err
}
return result, nil
}
func (s *AuthService) Refresh(ctx context.Context, input RefreshTokenInput) (*AuthResult, error) {
input.RefreshToken = strings.TrimSpace(input.RefreshToken)
if err := validateClientType(input.ClientType); err != nil {
return nil, err
}
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.GetRefreshTokenForUpdate(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.ClientType != "" && input.ClientType != record.ClientType {
return nil, apperr.New(http.StatusUnauthorized, "invalid_refresh_token", "refresh token client mismatch")
}
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.GetUserByID(ctx, tx, record.UserID)
if err != nil {
return nil, err
}
if user == nil {
return nil, apperr.New(http.StatusUnauthorized, "invalid_refresh_token", "refresh token user not found")
}
result, refreshTokenID, err := s.issueAuthResultWithRefreshID(ctx, tx, *user, record.ClientType, nullableStringValue(record.DeviceKey), false)
if err != nil {
return nil, err
}
if err := s.store.RotateRefreshToken(ctx, tx, record.ID, refreshTokenID); err != nil {
return nil, err
}
if err := tx.Commit(ctx); err != nil {
return nil, err
}
return result, nil
}
func (s *AuthService) LoginWechatMini(ctx context.Context, input LoginWechatMiniInput) (*AuthResult, error) {
input.Code = strings.TrimSpace(input.Code)
if err := validateClientType(input.ClientType); err != nil {
return nil, err
}
if input.ClientType != "wechat" {
return nil, apperr.New(http.StatusBadRequest, "invalid_client_type", "wechat mini login requires clientType=wechat")
}
if input.Code == "" || strings.TrimSpace(input.DeviceKey) == "" {
return nil, apperr.New(http.StatusBadRequest, "invalid_params", "code and deviceKey are required")
}
if s.cfg.WechatMini == nil {
return nil, apperr.New(http.StatusNotImplemented, "wechat_not_configured", "wechat mini provider is not configured")
}
session, err := s.cfg.WechatMini.ExchangeCode(ctx, input.Code)
if err != nil {
return nil, apperr.New(http.StatusUnauthorized, "wechat_login_failed", err.Error())
}
openIDSubject := session.AppID + ":" + session.OpenID
unionIDSubject := strings.TrimSpace(session.UnionID)
tx, err := s.store.Begin(ctx)
if err != nil {
return nil, err
}
defer tx.Rollback(ctx)
user, err := s.store.FindUserByProviderSubject(ctx, tx, "wechat_mini", openIDSubject)
if err != nil {
return nil, err
}
if user == nil && unionIDSubject != "" {
user, err = s.store.FindUserByProviderSubject(ctx, tx, "wechat_unionid", unionIDSubject)
if err != nil {
return nil, err
}
}
newUser := false
if 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
}
newUser = true
}
profileJSON, err := json.Marshal(map[string]any{
"appId": session.AppID,
})
if err != nil {
return nil, err
}
if err := s.store.CreateIdentity(ctx, tx, postgres.CreateIdentityParams{
UserID: user.ID,
IdentityType: "wechat_mini_openid",
Provider: "wechat_mini",
ProviderSubj: openIDSubject,
ProfileJSON: string(profileJSON),
}); err != nil {
return nil, err
}
if unionIDSubject != "" {
if err := s.store.CreateIdentity(ctx, tx, postgres.CreateIdentityParams{
UserID: user.ID,
IdentityType: "wechat_unionid",
Provider: "wechat_unionid",
ProviderSubj: unionIDSubject,
ProfileJSON: "{}",
}); err != nil {
return nil, err
}
}
if err := s.store.TouchUserLogin(ctx, tx, user.ID); err != nil {
return nil, err
}
result, err := s.issueAuthResult(ctx, tx, *user, input.ClientType, input.DeviceKey, newUser)
if err != nil {
return nil, err
}
if err := tx.Commit(ctx); err != nil {
return nil, err
}
return result, nil
}
func (s *AuthService) BindMobile(ctx context.Context, input BindMobileInput) (*AuthResult, error) {
input.CountryCode = normalizeCountryCode(input.CountryCode)
input.Mobile = normalizeMobile(input.Mobile)
input.Code = strings.TrimSpace(input.Code)
if err := validateClientType(input.ClientType); err != nil {
return nil, err
}
if input.UserID == "" || input.Mobile == "" || input.Code == "" || strings.TrimSpace(input.DeviceKey) == "" {
return nil, apperr.New(http.StatusBadRequest, "invalid_params", "user, mobile, code and deviceKey are required")
}
codeRecord, err := s.store.GetLatestValidSMSCode(ctx, input.CountryCode, input.Mobile, input.ClientType, "bind_mobile")
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")
}
currentUser, err := s.store.GetUserByID(ctx, tx, input.UserID)
if err != nil {
return nil, err
}
if currentUser == nil {
return nil, apperr.New(http.StatusNotFound, "user_not_found", "current user not found")
}
mobileUser, err := s.store.FindUserByMobile(ctx, tx, input.CountryCode, input.Mobile)
if err != nil {
return nil, err
}
finalUser := currentUser
newlyBound := false
if mobileUser == nil {
if err := s.store.CreateMobileIdentity(ctx, tx, postgres.CreateMobileIdentityParams{
UserID: currentUser.ID,
CountryCode: input.CountryCode,
Mobile: input.Mobile,
Provider: "mobile",
ProviderSubj: input.CountryCode + ":" + input.Mobile,
IdentityType: "mobile",
}); err != nil {
return nil, err
}
newlyBound = true
} else if mobileUser.ID != currentUser.ID {
if err := s.store.TransferNonMobileIdentities(ctx, tx, currentUser.ID, mobileUser.ID); err != nil {
return nil, err
}
if err := s.store.RevokeRefreshTokensByUserID(ctx, tx, currentUser.ID); err != nil {
return nil, err
}
if err := s.store.DeactivateUser(ctx, tx, currentUser.ID); err != nil {
return nil, err
}
finalUser = mobileUser
}
if err := s.store.TouchUserLogin(ctx, tx, finalUser.ID); err != nil {
return nil, err
}
result, err := s.issueAuthResult(ctx, tx, *finalUser, input.ClientType, input.DeviceKey, newlyBound)
if err != nil {
return nil, err
}
if err := tx.Commit(ctx); err != nil {
return nil, err
}
return result, nil
}
func (s *AuthService) Logout(ctx context.Context, input LogoutInput) error {
if strings.TrimSpace(input.RefreshToken) == "" {
return nil
}
return s.store.RevokeRefreshToken(ctx, security.HashText(strings.TrimSpace(input.RefreshToken)))
}
func (s *AuthService) issueAuthResult(
ctx context.Context,
tx postgres.Tx,
user postgres.User,
clientType string,
deviceKey string,
newUser bool,
) (*AuthResult, error) {
result, _, err := s.issueAuthResultWithRefreshID(ctx, tx, user, clientType, deviceKey, newUser)
return result, err
}
func (s *AuthService) issueAuthResultWithRefreshID(
ctx context.Context,
tx postgres.Tx,
user postgres.User,
clientType string,
deviceKey string,
newUser bool,
) (*AuthResult, string, error) {
accessToken, accessExpiresAt, err := s.jwtManager.IssueAccessToken(user.ID, user.PublicID)
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.CreateRefreshToken(ctx, tx, postgres.CreateRefreshTokenParams{
UserID: user.ID,
ClientType: clientType,
DeviceKey: deviceKey,
TokenHash: refreshTokenHash,
ExpiresAt: refreshExpiresAt,
})
if err != nil {
return nil, "", err
}
return &AuthResult{
User: AuthUser{
ID: user.ID,
PublicID: user.PublicID,
Status: user.Status,
Nickname: user.Nickname,
AvatarURL: user.AvatarURL,
},
Tokens: AuthTokens{
AccessToken: accessToken,
AccessTokenExpiresAt: accessExpiresAt.Format(time.RFC3339),
RefreshToken: refreshToken,
RefreshTokenExpiresAt: refreshExpiresAt.Format(time.RFC3339),
},
NewUser: newUser,
}, refreshID, nil
}
func validateClientType(clientType string) error {
switch clientType {
case "app", "wechat":
return nil
default:
return apperr.New(http.StatusBadRequest, "invalid_client_type", "clientType must be app or wechat")
}
}
func normalizeCountryCode(value string) string {
value = strings.TrimSpace(value)
if value == "" {
return "86"
}
return strings.TrimPrefix(value, "+")
}
func normalizeMobile(value string) string {
value = strings.TrimSpace(value)
value = strings.ReplaceAll(value, " ", "")
value = strings.ReplaceAll(value, "-", "")
return value
}
func normalizeScene(value string) string {
value = strings.TrimSpace(value)
if value == "" {
return "login"
}
return value
}
func nullableStringValue(value *string) string {
if value == nil {
return ""
}
return *value
}

View File

@@ -1,870 +0,0 @@
package service
import (
"context"
"encoding/json"
"fmt"
"net/http"
"os"
"path/filepath"
"sort"
"strings"
"cmr-backend/internal/apperr"
"cmr-backend/internal/platform/assets"
"cmr-backend/internal/platform/security"
"cmr-backend/internal/store/postgres"
)
type ConfigService struct {
store *postgres.Store
localEventDir string
assetBaseURL string
publisher *assets.OSSUtilPublisher
}
type ConfigPipelineSummary struct {
SourceTable string `json:"sourceTable"`
BuildTable string `json:"buildTable"`
ReleaseAssetsTable string `json:"releaseAssetsTable"`
}
type LocalEventFile struct {
FileName string `json:"fileName"`
FullPath string `json:"fullPath"`
}
type EventConfigSourceView struct {
ID string `json:"id"`
EventID string `json:"eventId"`
SourceVersionNo int `json:"sourceVersionNo"`
SourceKind string `json:"sourceKind"`
SchemaID string `json:"schemaId"`
SchemaVersion string `json:"schemaVersion"`
Status string `json:"status"`
Notes *string `json:"notes,omitempty"`
Source map[string]any `json:"source"`
}
type EventConfigBuildView struct {
ID string `json:"id"`
EventID string `json:"eventId"`
SourceID string `json:"sourceId"`
BuildNo int `json:"buildNo"`
BuildStatus string `json:"buildStatus"`
BuildLog *string `json:"buildLog,omitempty"`
Manifest map[string]any `json:"manifest"`
AssetIndex []map[string]any `json:"assetIndex"`
}
type PublishedReleaseView struct {
EventID string `json:"eventId"`
Release ResolvedReleaseView `json:"release"`
ReleaseNo int `json:"releaseNo"`
PublishedAt string `json:"publishedAt"`
Runtime *RuntimeSummaryView `json:"runtime,omitempty"`
Presentation *PresentationSummaryView `json:"presentation,omitempty"`
ContentBundle *ContentBundleSummaryView `json:"contentBundle,omitempty"`
}
type ImportLocalEventConfigInput struct {
EventPublicID string
FileName string `json:"fileName"`
Notes *string `json:"notes,omitempty"`
}
type BuildPreviewInput struct {
SourceID string `json:"sourceId"`
}
type PublishBuildInput struct {
BuildID string `json:"buildId"`
RuntimeBindingID string `json:"runtimeBindingId,omitempty"`
PresentationID string `json:"presentationId,omitempty"`
ContentBundleID string `json:"contentBundleId,omitempty"`
}
func NewConfigService(store *postgres.Store, localEventDir, assetBaseURL string, publisher *assets.OSSUtilPublisher) *ConfigService {
return &ConfigService{
store: store,
localEventDir: localEventDir,
assetBaseURL: strings.TrimRight(assetBaseURL, "/"),
publisher: publisher,
}
}
func (s *ConfigService) PipelineSummary() ConfigPipelineSummary {
return ConfigPipelineSummary{
SourceTable: "event_config_sources",
BuildTable: "event_config_builds",
ReleaseAssetsTable: "event_release_assets",
}
}
func (s *ConfigService) ListLocalEventFiles() ([]LocalEventFile, error) {
dir, err := filepath.Abs(s.localEventDir)
if err != nil {
return nil, apperr.New(http.StatusInternalServerError, "config_dir_invalid", "failed to resolve local event directory")
}
entries, err := os.ReadDir(dir)
if err != nil {
return nil, apperr.New(http.StatusInternalServerError, "config_dir_unavailable", "failed to read local event directory")
}
files := make([]LocalEventFile, 0)
for _, entry := range entries {
if entry.IsDir() {
continue
}
if strings.ToLower(filepath.Ext(entry.Name())) != ".json" {
continue
}
files = append(files, LocalEventFile{
FileName: entry.Name(),
FullPath: filepath.Join(dir, entry.Name()),
})
}
sort.Slice(files, func(i, j int) bool {
return files[i].FileName < files[j].FileName
})
return files, nil
}
func (s *ConfigService) ListEventConfigSources(ctx context.Context, eventPublicID string, limit int) ([]EventConfigSourceView, error) {
event, err := s.requireEvent(ctx, eventPublicID)
if err != nil {
return nil, err
}
items, err := s.store.ListEventConfigSourcesByEventID(ctx, event.ID, limit)
if err != nil {
return nil, err
}
results := make([]EventConfigSourceView, 0, len(items))
for i := range items {
view, err := buildEventConfigSourceView(&items[i], event.PublicID)
if err != nil {
return nil, err
}
results = append(results, *view)
}
return results, nil
}
func (s *ConfigService) GetEventConfigSource(ctx context.Context, sourceID string) (*EventConfigSourceView, error) {
record, err := s.store.GetEventConfigSourceByID(ctx, strings.TrimSpace(sourceID))
if err != nil {
return nil, err
}
if record == nil {
return nil, apperr.New(http.StatusNotFound, "config_source_not_found", "config source not found")
}
return buildEventConfigSourceView(record, "")
}
func (s *ConfigService) GetEventConfigBuild(ctx context.Context, buildID string) (*EventConfigBuildView, error) {
record, err := s.store.GetEventConfigBuildByID(ctx, strings.TrimSpace(buildID))
if err != nil {
return nil, err
}
if record == nil {
return nil, apperr.New(http.StatusNotFound, "config_build_not_found", "config build not found")
}
return buildEventConfigBuildView(record)
}
func (s *ConfigService) ImportLocalEventConfig(ctx context.Context, input ImportLocalEventConfigInput) (*EventConfigSourceView, error) {
event, err := s.requireEvent(ctx, input.EventPublicID)
if err != nil {
return nil, err
}
fileName := strings.TrimSpace(filepath.Base(input.FileName))
if fileName == "" || strings.Contains(fileName, "..") || strings.ToLower(filepath.Ext(fileName)) != ".json" {
return nil, apperr.New(http.StatusBadRequest, "invalid_params", "valid json fileName is required")
}
dir, err := filepath.Abs(s.localEventDir)
if err != nil {
return nil, apperr.New(http.StatusInternalServerError, "config_dir_invalid", "failed to resolve local event directory")
}
path := filepath.Join(dir, fileName)
raw, err := os.ReadFile(path)
if err != nil {
return nil, apperr.New(http.StatusNotFound, "config_file_not_found", "local config file not found")
}
source := map[string]any{}
if err := json.Unmarshal(raw, &source); err != nil {
return nil, apperr.New(http.StatusBadRequest, "config_json_invalid", "local config file is not valid json")
}
if err := validateSourceConfig(source); err != nil {
return nil, err
}
nextVersion, err := s.store.NextEventConfigSourceVersion(ctx, event.ID)
if err != nil {
return nil, err
}
note := input.Notes
if note == nil || strings.TrimSpace(*note) == "" {
defaultNote := "imported from local event file: " + fileName
note = &defaultNote
}
tx, err := s.store.Begin(ctx)
if err != nil {
return nil, err
}
defer tx.Rollback(ctx)
record, err := s.store.UpsertEventConfigSource(ctx, tx, postgres.UpsertEventConfigSourceParams{
EventID: event.ID,
SourceVersionNo: nextVersion,
SourceKind: "event_bundle",
SchemaID: "event-source",
SchemaVersion: resolveSchemaVersion(source),
Status: "active",
Source: source,
Notes: note,
})
if err != nil {
return nil, err
}
if err := tx.Commit(ctx); err != nil {
return nil, err
}
return buildEventConfigSourceView(record, event.PublicID)
}
func (s *ConfigService) BuildPreview(ctx context.Context, input BuildPreviewInput) (*EventConfigBuildView, error) {
sourceRecord, err := s.store.GetEventConfigSourceByID(ctx, strings.TrimSpace(input.SourceID))
if err != nil {
return nil, err
}
if sourceRecord == nil {
return nil, apperr.New(http.StatusNotFound, "config_source_not_found", "config source not found")
}
source, err := decodeJSONObject(sourceRecord.SourceJSON)
if err != nil {
return nil, apperr.New(http.StatusInternalServerError, "config_source_invalid", "stored source config is invalid")
}
if err := validateSourceConfig(source); err != nil {
return nil, err
}
buildNo, err := s.store.NextEventConfigBuildNo(ctx, sourceRecord.EventID)
if err != nil {
return nil, err
}
previewReleaseID := fmt.Sprintf("preview_%d", buildNo)
manifest := s.buildPreviewManifest(source, previewReleaseID)
assetIndex := s.buildAssetIndex(manifest)
buildLog := "preview build generated from source " + sourceRecord.ID
tx, err := s.store.Begin(ctx)
if err != nil {
return nil, err
}
defer tx.Rollback(ctx)
record, err := s.store.UpsertEventConfigBuild(ctx, tx, postgres.UpsertEventConfigBuildParams{
EventID: sourceRecord.EventID,
SourceID: sourceRecord.ID,
BuildNo: buildNo,
BuildStatus: "success",
BuildLog: &buildLog,
Manifest: manifest,
AssetIndex: assetIndex,
})
if err != nil {
return nil, err
}
if err := tx.Commit(ctx); err != nil {
return nil, err
}
return buildEventConfigBuildView(record)
}
func (s *ConfigService) PublishBuild(ctx context.Context, input PublishBuildInput) (*PublishedReleaseView, error) {
buildRecord, err := s.store.GetEventConfigBuildByID(ctx, strings.TrimSpace(input.BuildID))
if err != nil {
return nil, err
}
if buildRecord == nil {
return nil, apperr.New(http.StatusNotFound, "config_build_not_found", "config build not found")
}
if buildRecord.BuildStatus != "success" {
return nil, apperr.New(http.StatusConflict, "config_build_not_publishable", "config build is not publishable")
}
event, err := s.store.GetEventByID(ctx, buildRecord.EventID)
if err != nil {
return nil, err
}
if event == nil {
return nil, apperr.New(http.StatusNotFound, "event_not_found", "event not found")
}
runtimeBindingID, runtimeSummary, err := s.resolvePublishRuntimeBinding(ctx, event.ID, input.RuntimeBindingID)
if err != nil {
return nil, err
}
presentationID, presentationSummary, err := s.resolvePublishPresentation(ctx, event.ID, input.PresentationID)
if err != nil {
return nil, err
}
contentBundleID, contentBundleSummary, err := s.resolvePublishContentBundle(ctx, event.ID, input.ContentBundleID)
if err != nil {
return nil, err
}
manifest, err := decodeJSONObject(buildRecord.ManifestJSON)
if err != nil {
return nil, apperr.New(http.StatusInternalServerError, "config_build_invalid", "stored build manifest is invalid")
}
assetIndex, err := decodeJSONArray(buildRecord.AssetIndexJSON)
if err != nil {
return nil, apperr.New(http.StatusInternalServerError, "config_build_invalid", "stored build asset index is invalid")
}
releaseNo, err := s.store.NextEventReleaseNo(ctx, event.ID)
if err != nil {
return nil, err
}
releasePublicID, err := security.GeneratePublicID("rel")
if err != nil {
return nil, err
}
configLabel := deriveConfigLabel(event, manifest, releaseNo)
manifestURL := fmt.Sprintf("%s/event/releases/%s/%s/manifest.json", s.assetBaseURL, event.PublicID, releasePublicID)
assetIndexURL := fmt.Sprintf("%s/event/releases/%s/%s/asset-index.json", s.assetBaseURL, event.PublicID, releasePublicID)
checksum := security.HashText(buildRecord.ManifestJSON)
routeCode := deriveRouteCode(manifest)
if s.publisher == nil || !s.publisher.Enabled() {
return nil, apperr.New(http.StatusInternalServerError, "asset_publish_unavailable", "asset publisher is not configured")
}
if err := s.publisher.UploadJSON(ctx, manifestURL, []byte(buildRecord.ManifestJSON)); err != nil {
return nil, apperr.New(http.StatusInternalServerError, "asset_publish_failed", "failed to upload manifest: "+err.Error())
}
if err := s.publisher.UploadJSON(ctx, assetIndexURL, []byte(buildRecord.AssetIndexJSON)); err != nil {
return nil, apperr.New(http.StatusInternalServerError, "asset_publish_failed", "failed to upload asset index: "+err.Error())
}
tx, err := s.store.Begin(ctx)
if err != nil {
return nil, err
}
defer tx.Rollback(ctx)
releaseRecord, err := s.store.CreateEventRelease(ctx, tx, postgres.CreateEventReleaseParams{
PublicID: releasePublicID,
EventID: event.ID,
ReleaseNo: releaseNo,
ConfigLabel: configLabel,
ManifestURL: manifestURL,
ManifestChecksum: &checksum,
RouteCode: routeCode,
BuildID: &buildRecord.ID,
RuntimeBindingID: runtimeBindingID,
PresentationID: presentationID,
ContentBundleID: contentBundleID,
Status: "published",
PayloadJSON: buildRecord.ManifestJSON,
})
if err != nil {
return nil, err
}
if err := s.store.ReplaceEventReleaseAssets(ctx, tx, releaseRecord.ID, s.mapBuildAssetsToReleaseAssets(releaseRecord.ID, manifestURL, assetIndexURL, &checksum, assetIndex)); err != nil {
return nil, err
}
if err := s.store.SetCurrentEventRelease(ctx, tx, event.ID, releaseRecord.ID); err != nil {
return nil, err
}
if err := tx.Commit(ctx); err != nil {
return nil, err
}
return &PublishedReleaseView{
EventID: event.PublicID,
Release: ResolvedReleaseView{
LaunchMode: LaunchModeManifestRelease,
Source: LaunchSourceEventCurrentRelease,
EventID: event.PublicID,
ReleaseID: releaseRecord.PublicID,
ConfigLabel: releaseRecord.ConfigLabel,
ManifestURL: releaseRecord.ManifestURL,
ManifestChecksumSha256: releaseRecord.ManifestChecksum,
RouteCode: releaseRecord.RouteCode,
},
ReleaseNo: releaseRecord.ReleaseNo,
PublishedAt: releaseRecord.PublishedAt.Format(timeRFC3339),
Runtime: runtimeSummary,
Presentation: presentationSummary,
ContentBundle: contentBundleSummary,
}, nil
}
func (s *ConfigService) resolvePublishRuntimeBinding(ctx context.Context, eventID string, runtimeBindingPublicID string) (*string, *RuntimeSummaryView, error) {
runtimeBindingPublicID = strings.TrimSpace(runtimeBindingPublicID)
if runtimeBindingPublicID == "" {
defaults, err := s.store.GetEventDefaultBindingsByEventID(ctx, eventID)
if err != nil {
return nil, nil, err
}
if defaults == nil || defaults.RuntimeBindingID == nil || defaults.RuntimeBindingPublicID == nil || defaults.PlacePublicID == nil || defaults.MapAssetPublicID == nil || defaults.TileReleasePublicID == nil || defaults.CourseSetPublicID == nil || defaults.CourseVariantPublicID == nil {
return nil, nil, nil
}
return defaults.RuntimeBindingID, &RuntimeSummaryView{
RuntimeBindingID: *defaults.RuntimeBindingPublicID,
PlaceID: *defaults.PlacePublicID,
PlaceName: defaults.PlaceName,
MapID: *defaults.MapAssetPublicID,
MapName: defaults.MapAssetName,
TileReleaseID: *defaults.TileReleasePublicID,
CourseSetID: *defaults.CourseSetPublicID,
CourseVariantID: *defaults.CourseVariantPublicID,
CourseVariantName: defaults.CourseVariantName,
RouteCode: defaults.RuntimeRouteCode,
}, nil
}
runtimeBinding, err := s.store.GetMapRuntimeBindingByPublicID(ctx, runtimeBindingPublicID)
if err != nil {
return nil, nil, err
}
if runtimeBinding == nil {
return nil, nil, apperr.New(http.StatusNotFound, "runtime_binding_not_found", "runtime binding not found")
}
if runtimeBinding.EventID != eventID {
return nil, nil, apperr.New(http.StatusConflict, "runtime_binding_not_belong_to_event", "runtime binding does not belong to build event")
}
return &runtimeBinding.ID, &RuntimeSummaryView{
RuntimeBindingID: runtimeBinding.PublicID,
PlaceID: runtimeBinding.PlacePublicID,
MapID: runtimeBinding.MapAssetPublicID,
TileReleaseID: runtimeBinding.TileReleasePublicID,
CourseSetID: runtimeBinding.CourseSetPublicID,
CourseVariantID: runtimeBinding.CourseVariantPublicID,
RouteCode: nil,
}, nil
}
func (s *ConfigService) resolvePublishPresentation(ctx context.Context, eventID string, presentationPublicID string) (*string, *PresentationSummaryView, error) {
presentationPublicID = strings.TrimSpace(presentationPublicID)
if presentationPublicID == "" {
defaults, err := s.store.GetEventDefaultBindingsByEventID(ctx, eventID)
if err != nil {
return nil, nil, err
}
if defaults != nil && defaults.PresentationID != nil && defaults.PresentationPublicID != nil {
record, err := s.store.GetEventPresentationByPublicID(ctx, *defaults.PresentationPublicID)
if err != nil {
return nil, nil, err
}
if record != nil {
summary, err := buildPresentationSummaryFromRecord(record)
if err != nil {
return nil, nil, err
}
return defaults.PresentationID, summary, nil
}
}
record, err := s.store.GetDefaultEventPresentationByEventID(ctx, eventID)
if err != nil {
return nil, nil, err
}
if record == nil {
return nil, nil, nil
}
summary, err := buildPresentationSummaryFromRecord(record)
if err != nil {
return nil, nil, err
}
return &record.ID, summary, nil
}
record, err := s.store.GetEventPresentationByPublicID(ctx, presentationPublicID)
if err != nil {
return nil, nil, err
}
if record == nil {
return nil, nil, apperr.New(http.StatusNotFound, "presentation_not_found", "presentation not found")
}
if record.EventID != eventID {
return nil, nil, apperr.New(http.StatusConflict, "presentation_not_belong_to_event", "presentation does not belong to build event")
}
summary, err := buildPresentationSummaryFromRecord(record)
if err != nil {
return nil, nil, err
}
return &record.ID, summary, nil
}
func (s *ConfigService) resolvePublishContentBundle(ctx context.Context, eventID string, contentBundlePublicID string) (*string, *ContentBundleSummaryView, error) {
contentBundlePublicID = strings.TrimSpace(contentBundlePublicID)
if contentBundlePublicID == "" {
defaults, err := s.store.GetEventDefaultBindingsByEventID(ctx, eventID)
if err != nil {
return nil, nil, err
}
if defaults != nil && defaults.ContentBundleID != nil && defaults.ContentBundlePublicID != nil {
record, err := s.store.GetContentBundleByPublicID(ctx, *defaults.ContentBundlePublicID)
if err != nil {
return nil, nil, err
}
if record != nil {
summary, err := buildContentBundleSummaryFromRecord(record)
if err != nil {
return nil, nil, err
}
return defaults.ContentBundleID, summary, nil
}
}
record, err := s.store.GetDefaultContentBundleByEventID(ctx, eventID)
if err != nil {
return nil, nil, err
}
if record == nil {
return nil, nil, nil
}
summary, err := buildContentBundleSummaryFromRecord(record)
if err != nil {
return nil, nil, err
}
return &record.ID, summary, nil
}
record, err := s.store.GetContentBundleByPublicID(ctx, contentBundlePublicID)
if err != nil {
return nil, nil, err
}
if record == nil {
return nil, nil, apperr.New(http.StatusNotFound, "content_bundle_not_found", "content bundle not found")
}
if record.EventID != eventID {
return nil, nil, apperr.New(http.StatusConflict, "content_bundle_not_belong_to_event", "content bundle does not belong to build event")
}
summary, err := buildContentBundleSummaryFromRecord(record)
if err != nil {
return nil, nil, err
}
return &record.ID, summary, nil
}
func (s *ConfigService) requireEvent(ctx context.Context, eventPublicID string) (*postgres.Event, error) {
eventPublicID = strings.TrimSpace(eventPublicID)
if eventPublicID == "" {
return nil, apperr.New(http.StatusBadRequest, "invalid_params", "event id is required")
}
event, err := s.store.GetEventByPublicID(ctx, eventPublicID)
if err != nil {
return nil, err
}
if event == nil {
return nil, apperr.New(http.StatusNotFound, "event_not_found", "event not found")
}
return event, nil
}
func buildEventConfigSourceView(record *postgres.EventConfigSource, eventPublicID string) (*EventConfigSourceView, error) {
source, err := decodeJSONObject(record.SourceJSON)
if err != nil {
return nil, err
}
view := &EventConfigSourceView{
ID: record.ID,
EventID: eventPublicID,
SourceVersionNo: record.SourceVersionNo,
SourceKind: record.SourceKind,
SchemaID: record.SchemaID,
SchemaVersion: record.SchemaVersion,
Status: record.Status,
Notes: record.Notes,
Source: source,
}
return view, nil
}
func buildEventConfigBuildView(record *postgres.EventConfigBuild) (*EventConfigBuildView, error) {
manifest, err := decodeJSONObject(record.ManifestJSON)
if err != nil {
return nil, err
}
assetIndex, err := decodeJSONArray(record.AssetIndexJSON)
if err != nil {
return nil, err
}
return &EventConfigBuildView{
ID: record.ID,
EventID: record.EventID,
SourceID: record.SourceID,
BuildNo: record.BuildNo,
BuildStatus: record.BuildStatus,
BuildLog: record.BuildLog,
Manifest: manifest,
AssetIndex: assetIndex,
}, nil
}
func validateSourceConfig(source map[string]any) error {
requiredMap := func(parent map[string]any, key string) (map[string]any, error) {
value, ok := parent[key]
if !ok {
return nil, apperr.New(http.StatusBadRequest, "config_missing_field", "missing required field: "+key)
}
asMap, ok := value.(map[string]any)
if !ok {
return nil, apperr.New(http.StatusBadRequest, "config_invalid_field", "invalid object field: "+key)
}
return asMap, nil
}
requiredString := func(parent map[string]any, key string) error {
value, ok := parent[key]
if !ok {
return apperr.New(http.StatusBadRequest, "config_missing_field", "missing required field: "+key)
}
text, ok := value.(string)
if !ok || strings.TrimSpace(text) == "" {
return apperr.New(http.StatusBadRequest, "config_invalid_field", "invalid string field: "+key)
}
return nil
}
if err := requiredString(source, "schemaVersion"); err != nil {
return err
}
app, err := requiredMap(source, "app")
if err != nil {
return err
}
if err := requiredString(app, "id"); err != nil {
return err
}
if err := requiredString(app, "title"); err != nil {
return err
}
m, err := requiredMap(source, "map")
if err != nil {
return err
}
if err := requiredString(m, "tiles"); err != nil {
return err
}
if err := requiredString(m, "mapmeta"); err != nil {
return err
}
playfield, err := requiredMap(source, "playfield")
if err != nil {
return err
}
if err := requiredString(playfield, "kind"); err != nil {
return err
}
playfieldSource, err := requiredMap(playfield, "source")
if err != nil {
return err
}
if err := requiredString(playfieldSource, "type"); err != nil {
return err
}
if err := requiredString(playfieldSource, "url"); err != nil {
return err
}
game, err := requiredMap(source, "game")
if err != nil {
return err
}
if err := requiredString(game, "mode"); err != nil {
return err
}
return nil
}
func resolveSchemaVersion(source map[string]any) string {
if value, ok := source["schemaVersion"].(string); ok && strings.TrimSpace(value) != "" {
return value
}
return "1"
}
func (s *ConfigService) buildPreviewManifest(source map[string]any, previewReleaseID string) map[string]any {
manifest := cloneJSONObject(source)
manifest["releaseId"] = previewReleaseID
manifest["preview"] = true
manifest["assetBaseUrl"] = s.assetBaseURL
if version, ok := manifest["version"]; !ok || version == "" {
manifest["version"] = "preview"
}
if m, ok := manifest["map"].(map[string]any); ok {
if tiles, ok := m["tiles"].(string); ok {
m["tiles"] = s.normalizeAssetURL(tiles)
}
if meta, ok := m["mapmeta"].(string); ok {
m["mapmeta"] = s.normalizeAssetURL(meta)
}
}
if playfield, ok := manifest["playfield"].(map[string]any); ok {
if src, ok := playfield["source"].(map[string]any); ok {
if url, ok := src["url"].(string); ok {
src["url"] = s.normalizeAssetURL(url)
}
}
}
if assets, ok := manifest["assets"].(map[string]any); ok {
for key, value := range assets {
if text, ok := value.(string); ok {
assets[key] = s.normalizeAssetURL(text)
}
}
}
return manifest
}
func (s *ConfigService) buildAssetIndex(manifest map[string]any) []map[string]any {
var assets []map[string]any
if m, ok := manifest["map"].(map[string]any); ok {
if tiles, ok := m["tiles"].(string); ok {
assets = append(assets, map[string]any{"assetType": "tiles", "assetKey": "tiles-root", "assetUrl": tiles})
}
if meta, ok := m["mapmeta"].(string); ok {
assets = append(assets, map[string]any{"assetType": "mapmeta", "assetKey": "mapmeta", "assetUrl": meta})
}
}
if playfield, ok := manifest["playfield"].(map[string]any); ok {
if src, ok := playfield["source"].(map[string]any); ok {
if url, ok := src["url"].(string); ok {
assets = append(assets, map[string]any{"assetType": "playfield", "assetKey": "playfield-source", "assetUrl": url})
}
}
}
if rawAssets, ok := manifest["assets"].(map[string]any); ok {
keys := make([]string, 0, len(rawAssets))
for key := range rawAssets {
keys = append(keys, key)
}
sort.Strings(keys)
for _, key := range keys {
if url, ok := rawAssets[key].(string); ok {
assets = append(assets, map[string]any{"assetType": "other", "assetKey": key, "assetUrl": url})
}
}
}
return assets
}
func (s *ConfigService) normalizeAssetURL(value string) string {
value = strings.TrimSpace(value)
if value == "" {
return value
}
if strings.HasPrefix(value, "http://") || strings.HasPrefix(value, "https://") {
return value
}
trimmed := strings.TrimPrefix(value, "../")
trimmed = strings.TrimPrefix(trimmed, "./")
trimmed = strings.TrimLeft(trimmed, "/")
return s.assetBaseURL + "/" + trimmed
}
func cloneJSONObject(source map[string]any) map[string]any {
raw, _ := json.Marshal(source)
cloned := map[string]any{}
_ = json.Unmarshal(raw, &cloned)
return cloned
}
func decodeJSONObject(raw string) (map[string]any, error) {
result := map[string]any{}
if err := json.Unmarshal([]byte(raw), &result); err != nil {
return nil, err
}
return result, nil
}
func decodeJSONArray(raw string) ([]map[string]any, error) {
if strings.TrimSpace(raw) == "" {
return []map[string]any{}, nil
}
var result []map[string]any
if err := json.Unmarshal([]byte(raw), &result); err != nil {
return nil, err
}
return result, nil
}
func deriveConfigLabel(event *postgres.Event, manifest map[string]any, releaseNo int) string {
if app, ok := manifest["app"].(map[string]any); ok {
if title, ok := app["title"].(string); ok && strings.TrimSpace(title) != "" {
return fmt.Sprintf("%s Release %d", strings.TrimSpace(title), releaseNo)
}
}
if event != nil && strings.TrimSpace(event.DisplayName) != "" {
return fmt.Sprintf("%s Release %d", event.DisplayName, releaseNo)
}
return fmt.Sprintf("Release %d", releaseNo)
}
func deriveRouteCode(manifest map[string]any) *string {
if playfield, ok := manifest["playfield"].(map[string]any); ok {
if value, ok := playfield["kind"].(string); ok && strings.TrimSpace(value) != "" {
route := strings.TrimSpace(value)
return &route
}
}
return nil
}
func (s *ConfigService) mapBuildAssetsToReleaseAssets(eventReleaseID, manifestURL, assetIndexURL string, checksum *string, assetIndex []map[string]any) []postgres.UpsertEventReleaseAssetParams {
assets := []postgres.UpsertEventReleaseAssetParams{
{
EventReleaseID: eventReleaseID,
AssetType: "manifest",
AssetKey: "manifest",
AssetURL: manifestURL,
Checksum: checksum,
Meta: map[string]any{"source": "published-build"},
},
{
EventReleaseID: eventReleaseID,
AssetType: "other",
AssetKey: "asset-index",
AssetURL: assetIndexURL,
Meta: map[string]any{"source": "published-build"},
},
}
for _, asset := range assetIndex {
assetType, _ := asset["assetType"].(string)
assetKey, _ := asset["assetKey"].(string)
assetURL, _ := asset["assetUrl"].(string)
if strings.TrimSpace(assetType) == "" || strings.TrimSpace(assetKey) == "" || strings.TrimSpace(assetURL) == "" {
continue
}
mappedType := assetType
if mappedType != "manifest" && mappedType != "mapmeta" && mappedType != "tiles" && mappedType != "playfield" && mappedType != "content_html" && mappedType != "media" {
mappedType = "other"
}
assets = append(assets, postgres.UpsertEventReleaseAssetParams{
EventReleaseID: eventReleaseID,
AssetType: mappedType,
AssetKey: assetKey,
AssetURL: assetURL,
Meta: asset,
})
}
return assets
}

View File

@@ -1,148 +0,0 @@
package service
import (
"context"
"net/http"
"sort"
"sync"
"time"
"cmr-backend/internal/apperr"
"cmr-backend/internal/store/postgres"
)
type DevService struct {
appEnv string
store *postgres.Store
mu sync.Mutex
logSeq int64
logs []ClientDebugLogEntry
}
type ClientDebugLogEntry struct {
ID int64 `json:"id"`
Source string `json:"source"`
Level string `json:"level"`
Category string `json:"category,omitempty"`
Message string `json:"message"`
EventID string `json:"eventId,omitempty"`
ReleaseID string `json:"releaseId,omitempty"`
SessionID string `json:"sessionId,omitempty"`
ManifestURL string `json:"manifestUrl,omitempty"`
Route string `json:"route,omitempty"`
OccurredAt time.Time `json:"occurredAt"`
ReceivedAt time.Time `json:"receivedAt"`
Details map[string]any `json:"details,omitempty"`
}
type CreateClientDebugLogInput struct {
Source string `json:"source"`
Level string `json:"level"`
Category string `json:"category"`
Message string `json:"message"`
EventID string `json:"eventId"`
ReleaseID string `json:"releaseId"`
SessionID string `json:"sessionId"`
ManifestURL string `json:"manifestUrl"`
Route string `json:"route"`
OccurredAt string `json:"occurredAt"`
Details map[string]any `json:"details"`
}
func NewDevService(appEnv string, store *postgres.Store) *DevService {
return &DevService{
appEnv: appEnv,
store: store,
}
}
func (s *DevService) Enabled() bool {
return s.appEnv != "production"
}
func (s *DevService) BootstrapDemo(ctx context.Context) (*postgres.DemoBootstrapSummary, error) {
if !s.Enabled() {
return nil, apperr.New(http.StatusNotFound, "not_found", "dev bootstrap is disabled")
}
return s.store.EnsureDemoData(ctx)
}
func (s *DevService) AddClientDebugLog(_ context.Context, input CreateClientDebugLogInput) (*ClientDebugLogEntry, error) {
if !s.Enabled() {
return nil, apperr.New(http.StatusNotFound, "not_found", "dev client logs are disabled")
}
if input.Message == "" {
return nil, apperr.New(http.StatusBadRequest, "invalid_request", "message is required")
}
if input.Source == "" {
input.Source = "unknown"
}
if input.Level == "" {
input.Level = "info"
}
occurredAt := time.Now().UTC()
if input.OccurredAt != "" {
parsed, err := time.Parse(time.RFC3339, input.OccurredAt)
if err != nil {
return nil, apperr.New(http.StatusBadRequest, "invalid_request", "occurredAt must be RFC3339")
}
occurredAt = parsed.UTC()
}
entry := ClientDebugLogEntry{
Source: input.Source,
Level: input.Level,
Category: input.Category,
Message: input.Message,
EventID: input.EventID,
ReleaseID: input.ReleaseID,
SessionID: input.SessionID,
ManifestURL: input.ManifestURL,
Route: input.Route,
OccurredAt: occurredAt,
ReceivedAt: time.Now().UTC(),
Details: input.Details,
}
s.mu.Lock()
defer s.mu.Unlock()
s.logSeq++
entry.ID = s.logSeq
s.logs = append(s.logs, entry)
if len(s.logs) > 200 {
s.logs = append([]ClientDebugLogEntry(nil), s.logs[len(s.logs)-200:]...)
}
copyEntry := entry
return &copyEntry, nil
}
func (s *DevService) ListClientDebugLogs(_ context.Context, limit int) ([]ClientDebugLogEntry, error) {
if !s.Enabled() {
return nil, apperr.New(http.StatusNotFound, "not_found", "dev client logs are disabled")
}
if limit <= 0 || limit > 200 {
limit = 50
}
s.mu.Lock()
defer s.mu.Unlock()
items := append([]ClientDebugLogEntry(nil), s.logs...)
sort.Slice(items, func(i, j int) bool {
return items[i].ID > items[j].ID
})
if len(items) > limit {
items = items[:limit]
}
return items, nil
}
func (s *DevService) ClearClientDebugLogs(_ context.Context) error {
if !s.Enabled() {
return apperr.New(http.StatusNotFound, "not_found", "dev client logs are disabled")
}
s.mu.Lock()
defer s.mu.Unlock()
s.logs = nil
return nil
}

View File

@@ -1,168 +0,0 @@
package service
import (
"context"
"net/http"
"strings"
"cmr-backend/internal/apperr"
"cmr-backend/internal/store/postgres"
)
type EntryHomeService struct {
store *postgres.Store
}
type EntryHomeInput struct {
UserID string
ChannelCode string
ChannelType string
PlatformAppID string
TenantCode string
}
type EntryHomeResult struct {
User struct {
ID string `json:"id"`
PublicID string `json:"publicId"`
Status string `json:"status"`
Nickname *string `json:"nickname,omitempty"`
AvatarURL *string `json:"avatarUrl,omitempty"`
} `json:"user"`
Tenant struct {
ID string `json:"id"`
Code string `json:"code"`
Name string `json:"name"`
} `json:"tenant"`
Channel struct {
ID string `json:"id"`
Code string `json:"code"`
Type string `json:"type"`
PlatformAppID *string `json:"platformAppId,omitempty"`
DisplayName string `json:"displayName"`
Status string `json:"status"`
IsDefault bool `json:"isDefault"`
} `json:"channel"`
Cards []CardResult `json:"cards"`
OngoingSession *EntrySessionSummary `json:"ongoingSession,omitempty"`
RecentSession *EntrySessionSummary `json:"recentSession,omitempty"`
}
type EntrySessionSummary struct {
ID string `json:"id"`
Status string `json:"status"`
EventID string `json:"eventId"`
EventName string `json:"eventName"`
ReleaseID *string `json:"releaseId,omitempty"`
ConfigLabel *string `json:"configLabel,omitempty"`
VariantID *string `json:"variantId,omitempty"`
VariantName *string `json:"variantName,omitempty"`
RouteCode *string `json:"routeCode,omitempty"`
LaunchedAt string `json:"launchedAt"`
StartedAt *string `json:"startedAt,omitempty"`
EndedAt *string `json:"endedAt,omitempty"`
}
func NewEntryHomeService(store *postgres.Store) *EntryHomeService {
return &EntryHomeService{store: store}
}
func (s *EntryHomeService) GetEntryHome(ctx context.Context, input EntryHomeInput) (*EntryHomeResult, error) {
input.UserID = strings.TrimSpace(input.UserID)
if input.UserID == "" {
return nil, apperr.New(http.StatusUnauthorized, "unauthorized", "user is required")
}
user, err := s.store.GetUserByID(ctx, s.store.Pool(), input.UserID)
if err != nil {
return nil, err
}
if user == nil {
return nil, apperr.New(http.StatusNotFound, "user_not_found", "user not found")
}
entry, err := s.store.FindEntryChannel(ctx, postgres.FindEntryChannelParams{
ChannelCode: strings.TrimSpace(input.ChannelCode),
ChannelType: strings.TrimSpace(input.ChannelType),
PlatformAppID: strings.TrimSpace(input.PlatformAppID),
TenantCode: strings.TrimSpace(input.TenantCode),
})
if err != nil {
return nil, err
}
if entry == nil {
return nil, apperr.New(http.StatusNotFound, "entry_channel_not_found", "entry channel not found")
}
cards, err := s.store.ListCardsForEntry(ctx, entry.TenantID, &entry.ID, "home_primary", nowUTC(), 20)
if err != nil {
return nil, err
}
sessions, err := s.store.ListSessionsByUserID(ctx, user.ID, 10)
if err != nil {
return nil, err
}
result := &EntryHomeResult{
Cards: mapCards(cards),
}
result.User.ID = user.ID
result.User.PublicID = user.PublicID
result.User.Status = user.Status
result.User.Nickname = user.Nickname
result.User.AvatarURL = user.AvatarURL
result.Tenant.ID = entry.TenantID
result.Tenant.Code = entry.TenantCode
result.Tenant.Name = entry.TenantName
result.Channel.ID = entry.ID
result.Channel.Code = entry.ChannelCode
result.Channel.Type = entry.ChannelType
result.Channel.PlatformAppID = entry.PlatformAppID
result.Channel.DisplayName = entry.DisplayName
result.Channel.Status = entry.Status
result.Channel.IsDefault = entry.IsDefault
if len(sessions) > 0 {
recent := buildEntrySessionSummary(&sessions[0])
result.RecentSession = &recent
}
for i := range sessions {
if isSessionOngoingStatus(sessions[i].Status) {
ongoing := buildEntrySessionSummary(&sessions[i])
result.OngoingSession = &ongoing
break
}
}
return result, nil
}
func buildEntrySessionSummary(session *postgres.Session) EntrySessionSummary {
summary := EntrySessionSummary{
ID: session.SessionPublicID,
Status: session.Status,
VariantID: session.VariantID,
VariantName: session.VariantName,
RouteCode: session.RouteCode,
LaunchedAt: session.LaunchedAt.Format(timeRFC3339),
}
if session.EventPublicID != nil {
summary.EventID = *session.EventPublicID
}
if session.EventDisplayName != nil {
summary.EventName = *session.EventDisplayName
}
summary.ReleaseID = session.ReleasePublicID
summary.ConfigLabel = session.ConfigLabel
if session.StartedAt != nil {
value := session.StartedAt.Format(timeRFC3339)
summary.StartedAt = &value
}
if session.EndedAt != nil {
value := session.EndedAt.Format(timeRFC3339)
summary.EndedAt = &value
}
return summary
}

View File

@@ -1,79 +0,0 @@
package service
import (
"context"
"net/http"
"strings"
"cmr-backend/internal/apperr"
"cmr-backend/internal/store/postgres"
)
type EntryService struct {
store *postgres.Store
}
type ResolveEntryInput struct {
ChannelCode string
ChannelType string
PlatformAppID string
TenantCode string
}
type ResolveEntryResult struct {
Tenant struct {
ID string `json:"id"`
Code string `json:"code"`
Name string `json:"name"`
} `json:"tenant"`
Channel struct {
ID string `json:"id"`
Code string `json:"code"`
Type string `json:"type"`
PlatformAppID *string `json:"platformAppId,omitempty"`
DisplayName string `json:"displayName"`
Status string `json:"status"`
IsDefault bool `json:"isDefault"`
} `json:"channel"`
}
func NewEntryService(store *postgres.Store) *EntryService {
return &EntryService{store: store}
}
func (s *EntryService) Resolve(ctx context.Context, input ResolveEntryInput) (*ResolveEntryResult, error) {
input.ChannelCode = strings.TrimSpace(input.ChannelCode)
input.ChannelType = strings.TrimSpace(input.ChannelType)
input.PlatformAppID = strings.TrimSpace(input.PlatformAppID)
input.TenantCode = strings.TrimSpace(input.TenantCode)
if input.ChannelCode == "" && input.PlatformAppID == "" {
return nil, apperr.New(http.StatusBadRequest, "invalid_params", "channelCode or platformAppId is required")
}
entry, err := s.store.FindEntryChannel(ctx, postgres.FindEntryChannelParams{
ChannelCode: input.ChannelCode,
ChannelType: input.ChannelType,
PlatformAppID: input.PlatformAppID,
TenantCode: input.TenantCode,
})
if err != nil {
return nil, err
}
if entry == nil {
return nil, apperr.New(http.StatusNotFound, "entry_channel_not_found", "entry channel not found")
}
result := &ResolveEntryResult{}
result.Tenant.ID = entry.TenantID
result.Tenant.Code = entry.TenantCode
result.Tenant.Name = entry.TenantName
result.Channel.ID = entry.ID
result.Channel.Code = entry.ChannelCode
result.Channel.Type = entry.ChannelType
result.Channel.PlatformAppID = entry.PlatformAppID
result.Channel.DisplayName = entry.DisplayName
result.Channel.Status = entry.Status
result.Channel.IsDefault = entry.IsDefault
return result, nil
}

View File

@@ -1,160 +0,0 @@
package service
import (
"context"
"net/http"
"strings"
"cmr-backend/internal/apperr"
"cmr-backend/internal/store/postgres"
)
type EventPlayService struct {
store *postgres.Store
}
type EventPlayInput struct {
EventPublicID string
UserID string
}
type EventPlayResult struct {
Event struct {
ID string `json:"id"`
Slug string `json:"slug"`
DisplayName string `json:"displayName"`
Summary *string `json:"summary,omitempty"`
Status string `json:"status"`
} `json:"event"`
Release *struct {
ID string `json:"id"`
ConfigLabel string `json:"configLabel"`
ManifestURL string `json:"manifestUrl"`
ManifestChecksumSha256 *string `json:"manifestChecksumSha256,omitempty"`
RouteCode *string `json:"routeCode,omitempty"`
} `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 {
AssignmentMode *string `json:"assignmentMode,omitempty"`
CourseVariants []CourseVariantView `json:"courseVariants,omitempty"`
CanLaunch bool `json:"canLaunch"`
PrimaryAction string `json:"primaryAction"`
Reason string `json:"reason"`
LaunchSource string `json:"launchSource,omitempty"`
OngoingSession *EntrySessionSummary `json:"ongoingSession,omitempty"`
RecentSession *EntrySessionSummary `json:"recentSession,omitempty"`
} `json:"play"`
}
func NewEventPlayService(store *postgres.Store) *EventPlayService {
return &EventPlayService{store: store}
}
func (s *EventPlayService) GetEventPlay(ctx context.Context, input EventPlayInput) (*EventPlayResult, error) {
input.EventPublicID = strings.TrimSpace(input.EventPublicID)
input.UserID = strings.TrimSpace(input.UserID)
if input.EventPublicID == "" {
return nil, apperr.New(http.StatusBadRequest, "invalid_params", "event id is required")
}
if input.UserID == "" {
return nil, apperr.New(http.StatusUnauthorized, "unauthorized", "user is required")
}
event, err := s.store.GetEventByPublicID(ctx, input.EventPublicID)
if err != nil {
return nil, err
}
if event == nil {
return nil, apperr.New(http.StatusNotFound, "event_not_found", "event not found")
}
sessions, err := s.store.ListSessionsByUserAndEvent(ctx, input.UserID, event.ID, 10)
if 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, 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
} else if enrichedPresentation != nil {
result.CurrentPresentation = enrichedPresentation
}
result.CurrentContentBundle = buildContentBundleSummaryFromEvent(event)
if enrichedBundle, err := loadContentBundleSummaryByPublicID(ctx, s.store, event.ContentBundleID); err != nil {
return nil, err
} else if enrichedBundle != nil {
result.CurrentContentBundle = enrichedBundle
}
if len(sessions) > 0 {
recent := buildEntrySessionSummary(&sessions[0])
result.Play.RecentSession = &recent
}
for i := range sessions {
if isSessionOngoingStatus(sessions[i].Status) {
ongoing := buildEntrySessionSummary(&sessions[i])
result.Play.OngoingSession = &ongoing
break
}
}
canLaunch, launchReason := evaluateEventLaunchReadiness(event)
result.Play.CanLaunch = canLaunch
if canLaunch {
result.Play.LaunchSource = LaunchSourceEventCurrentRelease
}
switch {
case result.Play.OngoingSession != nil:
result.Play.PrimaryAction = "continue"
result.Play.Reason = "user has an ongoing session for this event"
case canLaunch:
result.Play.PrimaryAction = "start"
result.Play.Reason = launchReason
case result.Play.RecentSession != nil:
result.Play.PrimaryAction = "review_last_result"
result.Play.Reason = launchReason + ", but user has previous session history"
default:
result.Play.PrimaryAction = "unavailable"
result.Play.Reason = launchReason
}
return result, nil
}

View File

@@ -1,257 +0,0 @@
package service
import (
"context"
"net/http"
"strings"
"time"
"cmr-backend/internal/apperr"
"cmr-backend/internal/platform/security"
"cmr-backend/internal/store/postgres"
)
type EventService struct {
store *postgres.Store
}
type EventDetailResult struct {
Event struct {
ID string `json:"id"`
Slug string `json:"slug"`
DisplayName string `json:"displayName"`
Summary *string `json:"summary,omitempty"`
Status string `json:"status"`
} `json:"event"`
Release *struct {
ID string `json:"id"`
ConfigLabel string `json:"configLabel"`
ManifestURL string `json:"manifestUrl"`
ManifestChecksumSha256 *string `json:"manifestChecksumSha256,omitempty"`
RouteCode *string `json:"routeCode,omitempty"`
} `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"`
}
type LaunchEventInput struct {
EventPublicID string
UserID string
ReleaseID string `json:"releaseId,omitempty"`
VariantID string `json:"variantId,omitempty"`
ClientType string `json:"clientType"`
DeviceKey string `json:"deviceKey"`
}
type LaunchEventResult struct {
Event struct {
ID string `json:"id"`
DisplayName string `json:"displayName"`
} `json:"event"`
Launch struct {
Source string `json:"source"`
ResolvedRelease *ResolvedReleaseView `json:"resolvedRelease,omitempty"`
Variant *VariantBindingView `json:"variant,omitempty"`
Runtime *RuntimeSummaryView `json:"runtime,omitempty"`
Presentation *PresentationSummaryView `json:"presentation,omitempty"`
ContentBundle *ContentBundleSummaryView `json:"contentBundle,omitempty"`
Config struct {
ConfigURL string `json:"configUrl"`
ConfigLabel string `json:"configLabel"`
ConfigChecksumSha256 *string `json:"configChecksumSha256,omitempty"`
ReleaseID string `json:"releaseId"`
RouteCode *string `json:"routeCode,omitempty"`
} `json:"config"`
Business struct {
Source string `json:"source"`
EventID string `json:"eventId"`
SessionID string `json:"sessionId"`
SessionToken string `json:"sessionToken"`
SessionTokenExpiresAt string `json:"sessionTokenExpiresAt"`
RouteCode *string `json:"routeCode,omitempty"`
IsGuest bool `json:"isGuest,omitempty"`
} `json:"business"`
} `json:"launch"`
}
func NewEventService(store *postgres.Store) *EventService {
return &EventService{store: store}
}
func (s *EventService) GetEventDetail(ctx context.Context, eventPublicID string) (*EventDetailResult, error) {
eventPublicID = strings.TrimSpace(eventPublicID)
if eventPublicID == "" {
return nil, apperr.New(http.StatusBadRequest, "invalid_params", "event id is required")
}
event, err := s.store.GetEventByPublicID(ctx, eventPublicID)
if err != nil {
return nil, err
}
if event == nil {
return nil, apperr.New(http.StatusNotFound, "event_not_found", "event not found")
}
result := &EventDetailResult{}
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
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, 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
} else if enrichedPresentation != nil {
result.CurrentPresentation = enrichedPresentation
}
result.CurrentContentBundle = buildContentBundleSummaryFromEvent(event)
if enrichedBundle, err := loadContentBundleSummaryByPublicID(ctx, s.store, event.ContentBundleID); err != nil {
return nil, err
} else if enrichedBundle != nil {
result.CurrentContentBundle = enrichedBundle
}
return result, nil
}
func (s *EventService) LaunchEvent(ctx context.Context, input LaunchEventInput) (*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 err := validateClientType(input.ClientType); err != nil {
return nil, err
}
if input.EventPublicID == "" || input.UserID == "" || input.DeviceKey == "" {
return nil, apperr.New(http.StatusBadRequest, "invalid_params", "event id, user and deviceKey are required")
}
event, err := s.store.GetEventByPublicID(ctx, input.EventPublicID)
if err != nil {
return nil, err
}
if event == nil {
return nil, apperr.New(http.StatusNotFound, "event_not_found", "event not found")
}
if canLaunch, reason := evaluateEventLaunchReadiness(event); !canLaunch {
return nil, launchReadinessError(reason)
}
if input.ReleaseID != "" && 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)
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: input.UserID,
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 = LaunchSourceEventCurrentRelease
result.Launch.ResolvedRelease = buildResolvedReleaseFromEvent(event, LaunchSourceEventCurrentRelease)
result.Launch.Variant = variant
result.Launch.Runtime = buildRuntimeSummaryFromEvent(event)
result.Launch.Presentation = buildPresentationSummaryFromEvent(event)
if enrichedPresentation, err := loadPresentationSummaryByPublicID(ctx, s.store, event.PresentationID); err != nil {
return nil, err
} else if enrichedPresentation != nil {
result.Launch.Presentation = enrichedPresentation
}
result.Launch.ContentBundle = buildContentBundleSummaryFromEvent(event)
if enrichedBundle, err := loadContentBundleSummaryByPublicID(ctx, s.store, event.ContentBundleID); err != nil {
return nil, err
} else if enrichedBundle != nil {
result.Launch.ContentBundle = enrichedBundle
}
result.Launch.Config.ConfigURL = *event.ManifestURL
result.Launch.Config.ConfigLabel = *event.ConfigLabel
result.Launch.Config.ConfigChecksumSha256 = event.ManifestChecksum
result.Launch.Config.ReleaseID = *event.CurrentReleasePubID
result.Launch.Config.RouteCode = routeCode
result.Launch.Business.Source = "direct-event"
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 = false
return result, nil
}

View File

@@ -1,314 +0,0 @@
package service
import (
"context"
"net/http"
"strings"
"time"
"cmr-backend/internal/apperr"
"cmr-backend/internal/store/postgres"
)
type HomeService struct {
store *postgres.Store
}
type ListCardsInput struct {
ChannelCode string
ChannelType string
PlatformAppID string
TenantCode string
Slot string
Limit int
}
type CardResult struct {
ID string `json:"id"`
Type string `json:"type"`
Title string `json:"title"`
Subtitle *string `json:"subtitle,omitempty"`
Summary *string `json:"summary,omitempty"`
CoverURL *string `json:"coverUrl,omitempty"`
DisplaySlot string `json:"displaySlot"`
DisplayPriority int `json:"displayPriority"`
Status string `json:"status"`
StatusCode string `json:"statusCode"`
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"`
Event *struct {
ID string `json:"id"`
DisplayName string `json:"displayName"`
Summary *string `json:"summary,omitempty"`
Status *string `json:"status,omitempty"`
} `json:"event,omitempty"`
HTMLURL *string `json:"htmlUrl,omitempty"`
}
type HomeResult struct {
Tenant struct {
ID string `json:"id"`
Code string `json:"code"`
Name string `json:"name"`
} `json:"tenant"`
Channel struct {
ID string `json:"id"`
Code string `json:"code"`
Type string `json:"type"`
PlatformAppID *string `json:"platformAppId,omitempty"`
DisplayName string `json:"displayName"`
Status string `json:"status"`
IsDefault bool `json:"isDefault"`
} `json:"channel"`
Cards []CardResult `json:"cards"`
}
func NewHomeService(store *postgres.Store) *HomeService {
return &HomeService{store: store}
}
func (s *HomeService) ListCards(ctx context.Context, input ListCardsInput) ([]CardResult, error) {
entry, err := s.resolveEntry(ctx, input)
if err != nil {
return nil, err
}
cards, err := s.store.ListCardsForEntry(ctx, entry.TenantID, &entry.ID, normalizeSlot(input.Slot), time.Now().UTC(), input.Limit)
if err != nil {
return nil, err
}
return mapCards(cards), nil
}
func (s *HomeService) GetHome(ctx context.Context, input ListCardsInput) (*HomeResult, error) {
entry, err := s.resolveEntry(ctx, input)
if err != nil {
return nil, err
}
cards, err := s.store.ListCardsForEntry(ctx, entry.TenantID, &entry.ID, normalizeSlot(input.Slot), time.Now().UTC(), input.Limit)
if err != nil {
return nil, err
}
result := &HomeResult{
Cards: mapCards(cards),
}
result.Tenant.ID = entry.TenantID
result.Tenant.Code = entry.TenantCode
result.Tenant.Name = entry.TenantName
result.Channel.ID = entry.ID
result.Channel.Code = entry.ChannelCode
result.Channel.Type = entry.ChannelType
result.Channel.PlatformAppID = entry.PlatformAppID
result.Channel.DisplayName = entry.DisplayName
result.Channel.Status = entry.Status
result.Channel.IsDefault = entry.IsDefault
return result, nil
}
func (s *HomeService) resolveEntry(ctx context.Context, input ListCardsInput) (*postgres.EntryChannel, error) {
entry, err := s.store.FindEntryChannel(ctx, postgres.FindEntryChannelParams{
ChannelCode: strings.TrimSpace(input.ChannelCode),
ChannelType: strings.TrimSpace(input.ChannelType),
PlatformAppID: strings.TrimSpace(input.PlatformAppID),
TenantCode: strings.TrimSpace(input.TenantCode),
})
if err != nil {
return nil, err
}
if entry == nil {
return nil, apperr.New(http.StatusNotFound, "entry_channel_not_found", "entry channel not found")
}
return entry, nil
}
func normalizeSlot(slot string) string {
slot = strings.TrimSpace(slot)
if slot == "" {
return "home_primary"
}
return slot
}
func mapCards(cards []postgres.Card) []CardResult {
results := make([]CardResult, 0, len(cards))
for _, card := range cards {
statusCode, statusText := deriveCardStatus(card)
item := CardResult{
ID: card.PublicID,
Type: card.CardType,
Title: fallbackCardTitle(card.Title),
Subtitle: card.Subtitle,
Summary: fallbackCardSummary(card.EventSummary),
CoverURL: card.CoverURL,
DisplaySlot: card.DisplaySlot,
DisplayPriority: card.DisplayPriority,
Status: statusText,
StatusCode: statusCode,
TimeWindow: deriveCardTimeWindow(card),
CTAText: deriveCardCTAText(card, statusCode),
IsDefaultExperience: card.IsDefaultExperience,
ShowInEventList: card.ShowInEventList,
EventType: deriveCardEventType(card),
CurrentPresentation: buildCardPresentationSummary(card),
CurrentContentBundle: buildCardContentBundleSummary(card),
HTMLURL: card.HTMLURL,
}
if card.EventPublicID != nil || card.EventDisplayName != nil {
item.Event = &struct {
ID string `json:"id"`
DisplayName string `json:"displayName"`
Summary *string `json:"summary,omitempty"`
Status *string `json:"status,omitempty"`
}{
Summary: card.EventSummary,
Status: card.EventStatus,
}
if card.EventPublicID != nil {
item.Event.ID = *card.EventPublicID
}
if card.EventDisplayName != nil {
item.Event.DisplayName = *card.EventDisplayName
}
}
results = append(results, item)
}
return results
}
func fallbackCardTitle(title string) string {
title = strings.TrimSpace(title)
if title == "" {
return "未命名活动"
}
return title
}
func fallbackCardSummary(summary *string) *string {
if summary != nil && strings.TrimSpace(*summary) != "" {
return summary
}
text := "当前暂无活动摘要"
return &text
}
func deriveCardStatus(card postgres.Card) (string, string) {
if card.EventStatus == nil {
return "pending", "状态待确认"
}
switch strings.TrimSpace(*card.EventStatus) {
case "active":
if card.EventCurrentReleasePubID == nil {
return "upcoming", "即将开始"
}
if card.EventRuntimeBindingID == nil || card.EventPresentationID == nil || card.EventContentBundleID == nil {
return "upcoming", "即将开始"
}
return "running", "进行中"
case "archived", "disabled", "inactive":
return "ended", "已结束"
default:
return "pending", "状态待确认"
}
}
func deriveCardTimeWindow(card postgres.Card) string {
if card.StartsAt == nil && card.EndsAt == nil {
return "时间待公布"
}
const layout = "01-02 15:04"
switch {
case card.StartsAt != nil && card.EndsAt != nil:
return card.StartsAt.Local().Format(layout) + " - " + card.EndsAt.Local().Format(layout)
case card.StartsAt != nil:
return "开始于 " + card.StartsAt.Local().Format(layout)
default:
return "截止至 " + card.EndsAt.Local().Format(layout)
}
}
func deriveCardCTAText(card postgres.Card, statusCode string) string {
if card.IsDefaultExperience {
return "进入体验"
}
switch statusCode {
case "running":
return "进入活动"
case "ended":
return "查看回顾"
default:
return "查看详情"
}
}
func deriveCardEventType(card postgres.Card) *string {
if card.EventReleasePayloadJSON != nil {
payload, err := decodeJSONObject(*card.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(card.EventReleasePayloadJSON); plan.AssignmentMode != nil && *plan.AssignmentMode == AssignmentModeManual {
text := "多赛道"
return &text
}
}
}
if card.IsDefaultExperience {
text := "体验活动"
return &text
}
return nil
}
func buildCardPresentationSummary(card postgres.Card) *PresentationSummaryView {
if card.EventPresentationID == nil {
return nil
}
summary := &PresentationSummaryView{
PresentationID: *card.EventPresentationID,
Name: card.EventPresentationName,
PresentationType: card.EventPresentationType,
}
if card.EventPresentationSchemaJSON != nil && strings.TrimSpace(*card.EventPresentationSchemaJSON) != "" {
if schema, err := decodeJSONObject(*card.EventPresentationSchemaJSON); err == nil {
summary.TemplateKey = readStringField(schema, "templateKey")
summary.Version = readStringField(schema, "version")
}
}
return summary
}
func buildCardContentBundleSummary(card postgres.Card) *ContentBundleSummaryView {
if card.EventContentBundleID == nil {
return nil
}
summary := &ContentBundleSummaryView{
ContentBundleID: *card.EventContentBundleID,
Name: card.EventContentBundleName,
EntryURL: card.EventContentEntryURL,
AssetRootURL: card.EventContentAssetRootURL,
}
if card.EventContentMetadataJSON != nil && strings.TrimSpace(*card.EventContentMetadataJSON) != "" {
if metadata, err := decodeJSONObject(*card.EventContentMetadataJSON); err == nil {
summary.BundleType = readStringField(metadata, "bundleType")
summary.Version = readStringField(metadata, "version")
}
}
return summary
}

View File

@@ -1,56 +0,0 @@
package service
import (
"net/http"
"cmr-backend/internal/apperr"
"cmr-backend/internal/store/postgres"
)
const (
launchReadyReasonOK = "event is active and launchable"
launchReadyReasonNotActive = "event is not active"
launchReadyReasonReleaseMissing = "event does not have a published release"
launchReadyReasonRuntimeMissing = "current published release is missing runtime binding"
launchReadyReasonPresentationMissing = "current published release is missing presentation binding"
launchReadyReasonContentMissing = "current published release is missing content bundle binding"
)
func evaluateEventLaunchReadiness(event *postgres.Event) (bool, string) {
if event == nil {
return false, launchReadyReasonReleaseMissing
}
if event.Status != "active" {
return false, launchReadyReasonNotActive
}
if event.CurrentReleaseID == nil || event.CurrentReleasePubID == nil || event.ConfigLabel == nil || event.ManifestURL == nil {
return false, launchReadyReasonReleaseMissing
}
if buildRuntimeSummaryFromEvent(event) == nil {
return false, launchReadyReasonRuntimeMissing
}
if buildPresentationSummaryFromEvent(event) == nil {
return false, launchReadyReasonPresentationMissing
}
if buildContentBundleSummaryFromEvent(event) == nil {
return false, launchReadyReasonContentMissing
}
return true, launchReadyReasonOK
}
func launchReadinessError(reason string) error {
switch reason {
case launchReadyReasonNotActive:
return apperr.New(http.StatusConflict, "event_not_launchable", reason)
case launchReadyReasonReleaseMissing:
return apperr.New(http.StatusConflict, "event_release_missing", reason)
case launchReadyReasonRuntimeMissing:
return apperr.New(http.StatusConflict, "event_release_runtime_missing", reason)
case launchReadyReasonPresentationMissing:
return apperr.New(http.StatusConflict, "event_release_presentation_missing", reason)
case launchReadyReasonContentMissing:
return apperr.New(http.StatusConflict, "event_release_content_bundle_missing", reason)
default:
return apperr.New(http.StatusConflict, "event_not_launchable", reason)
}
}

View File

@@ -1,300 +0,0 @@
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
}

View File

@@ -1,43 +0,0 @@
package service
import (
"context"
"net/http"
"cmr-backend/internal/apperr"
"cmr-backend/internal/store/postgres"
)
type MeService struct {
store *postgres.Store
}
type MeResult struct {
ID string `json:"id"`
PublicID string `json:"publicId"`
Status string `json:"status"`
Nickname *string `json:"nickname,omitempty"`
AvatarURL *string `json:"avatarUrl,omitempty"`
}
func NewMeService(store *postgres.Store) *MeService {
return &MeService{store: store}
}
func (s *MeService) GetMe(ctx context.Context, userID string) (*MeResult, error) {
user, err := s.store.GetUserByID(ctx, s.store.Pool(), userID)
if err != nil {
return nil, err
}
if user == nil {
return nil, apperr.New(http.StatusNotFound, "user_not_found", "user not found")
}
return &MeResult{
ID: user.ID,
PublicID: user.PublicID,
Status: user.Status,
Nickname: user.Nickname,
AvatarURL: user.AvatarURL,
}, nil
}

View File

@@ -1,395 +0,0 @@
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"
}
}

View File

@@ -1,57 +0,0 @@
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
}

View File

@@ -1,222 +0,0 @@
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
}
}

View File

@@ -1,119 +0,0 @@
package service
import (
"context"
"net/http"
"cmr-backend/internal/apperr"
"cmr-backend/internal/store/postgres"
)
type ProfileService struct {
store *postgres.Store
}
type ProfileResult struct {
User struct {
ID string `json:"id"`
PublicID string `json:"publicId"`
Status string `json:"status"`
Nickname *string `json:"nickname,omitempty"`
AvatarURL *string `json:"avatarUrl,omitempty"`
} `json:"user"`
Bindings struct {
HasMobile bool `json:"hasMobile"`
HasWechatMini bool `json:"hasWechatMini"`
HasWechatUnion bool `json:"hasWechatUnion"`
Items []ProfileBindingItem `json:"items"`
} `json:"bindings"`
RecentSessions []EntrySessionSummary `json:"recentSessions"`
}
type ProfileBindingItem struct {
IdentityType string `json:"identityType"`
Provider string `json:"provider"`
Status string `json:"status"`
CountryCode *string `json:"countryCode,omitempty"`
Mobile *string `json:"mobile,omitempty"`
MaskedLabel string `json:"maskedLabel"`
}
func NewProfileService(store *postgres.Store) *ProfileService {
return &ProfileService{store: store}
}
func (s *ProfileService) GetProfile(ctx context.Context, userID string) (*ProfileResult, error) {
if userID == "" {
return nil, apperr.New(http.StatusUnauthorized, "unauthorized", "user is required")
}
user, err := s.store.GetUserByID(ctx, s.store.Pool(), userID)
if err != nil {
return nil, err
}
if user == nil {
return nil, apperr.New(http.StatusNotFound, "user_not_found", "user not found")
}
identities, err := s.store.ListIdentitiesByUserID(ctx, userID)
if err != nil {
return nil, err
}
sessions, err := s.store.ListSessionsByUserID(ctx, userID, 5)
if err != nil {
return nil, err
}
result := &ProfileResult{}
result.User.ID = user.ID
result.User.PublicID = user.PublicID
result.User.Status = user.Status
result.User.Nickname = user.Nickname
result.User.AvatarURL = user.AvatarURL
for _, identity := range identities {
item := ProfileBindingItem{
IdentityType: identity.IdentityType,
Provider: identity.Provider,
Status: identity.Status,
CountryCode: identity.CountryCode,
Mobile: identity.Mobile,
MaskedLabel: maskIdentity(identity),
}
result.Bindings.Items = append(result.Bindings.Items, item)
switch identity.Provider {
case "mobile":
result.Bindings.HasMobile = true
case "wechat_mini":
result.Bindings.HasWechatMini = true
case "wechat_unionid":
result.Bindings.HasWechatUnion = true
}
}
for i := range sessions {
result.RecentSessions = append(result.RecentSessions, buildEntrySessionSummary(&sessions[i]))
}
return result, nil
}
func maskIdentity(identity postgres.LoginIdentity) string {
if identity.Provider == "mobile" && identity.Mobile != nil {
value := *identity.Mobile
if len(value) >= 7 {
return value[:3] + "****" + value[len(value)-4:]
}
return value
}
if identity.Provider == "wechat_mini" {
return "WeChat Mini bound"
}
if identity.Provider == "wechat_unionid" {
return "WeChat Union bound"
}
return identity.Provider
}

View File

@@ -1,303 +0,0 @@
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
}

View File

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

View File

@@ -1,94 +0,0 @@
package service
import (
"context"
"encoding/json"
"net/http"
"cmr-backend/internal/apperr"
"cmr-backend/internal/store/postgres"
)
type ResultService struct {
store *postgres.Store
}
type SessionResultView struct {
Session EntrySessionSummary `json:"session"`
Result ResultSummaryPayload `json:"result"`
}
type ResultSummaryPayload struct {
Status string `json:"status"`
FinalDurationSec *int `json:"finalDurationSec,omitempty"`
FinalScore *int `json:"finalScore,omitempty"`
CompletedControls *int `json:"completedControls,omitempty"`
TotalControls *int `json:"totalControls,omitempty"`
DistanceMeters *float64 `json:"distanceMeters,omitempty"`
AverageSpeedKmh *float64 `json:"averageSpeedKmh,omitempty"`
MaxHeartRateBpm *int `json:"maxHeartRateBpm,omitempty"`
Summary map[string]any `json:"summary,omitempty"`
}
func NewResultService(store *postgres.Store) *ResultService {
return &ResultService{store: store}
}
func (s *ResultService) GetSessionResult(ctx context.Context, sessionPublicID, userID string) (*SessionResultView, error) {
record, err := s.store.GetSessionResultByPublicID(ctx, sessionPublicID)
if err != nil {
return nil, err
}
if record == nil {
return nil, apperr.New(http.StatusNotFound, "session_not_found", "session not found")
}
if userID != "" && record.UserID != userID {
return nil, apperr.New(http.StatusForbidden, "session_forbidden", "session does not belong to current user")
}
return buildSessionResultView(record), nil
}
func (s *ResultService) ListMyResults(ctx context.Context, userID string, limit int) ([]SessionResultView, error) {
if userID == "" {
return nil, apperr.New(http.StatusUnauthorized, "unauthorized", "user is required")
}
records, err := s.store.ListSessionResultsByUserID(ctx, userID, limit)
if err != nil {
return nil, err
}
results := make([]SessionResultView, 0, len(records))
for i := range records {
results = append(results, *buildSessionResultView(&records[i]))
}
return results, nil
}
func buildSessionResultView(record *postgres.SessionResultRecord) *SessionResultView {
view := &SessionResultView{
Session: buildEntrySessionSummary(&record.Session),
Result: ResultSummaryPayload{
Status: record.Status,
},
}
if record.Result != nil {
view.Result.Status = record.Result.ResultStatus
view.Result.FinalDurationSec = record.Result.FinalDurationSec
view.Result.FinalScore = record.Result.FinalScore
view.Result.CompletedControls = record.Result.CompletedControls
view.Result.TotalControls = record.Result.TotalControls
view.Result.DistanceMeters = record.Result.DistanceMeters
view.Result.AverageSpeedKmh = record.Result.AverageSpeedKmh
view.Result.MaxHeartRateBpm = record.Result.MaxHeartRateBpm
if record.Result.SummaryJSON != "" {
summary := map[string]any{}
if err := json.Unmarshal([]byte(record.Result.SummaryJSON), &summary); err == nil && len(summary) > 0 {
view.Result.Summary = summary
}
}
}
return view
}

View File

@@ -1,352 +0,0 @@
package service
import (
"context"
"encoding/json"
"net/http"
"strings"
"time"
"cmr-backend/internal/apperr"
"cmr-backend/internal/platform/security"
"cmr-backend/internal/store/postgres"
)
type SessionService struct {
store *postgres.Store
}
type sessionTokenPolicy struct {
AllowExpired bool
}
type SessionResult struct {
Session struct {
ID string `json:"id"`
Status string `json:"status"`
ClientType string `json:"clientType"`
DeviceKey string `json:"deviceKey"`
AssignmentMode *string `json:"assignmentMode,omitempty"`
VariantID *string `json:"variantId,omitempty"`
VariantName *string `json:"variantName,omitempty"`
RouteCode *string `json:"routeCode,omitempty"`
SessionTokenExpiresAt string `json:"sessionTokenExpiresAt"`
LaunchedAt string `json:"launchedAt"`
StartedAt *string `json:"startedAt,omitempty"`
EndedAt *string `json:"endedAt,omitempty"`
} `json:"session"`
Event struct {
ID string `json:"id"`
DisplayName string `json:"displayName"`
} `json:"event"`
ResolvedRelease *ResolvedReleaseView `json:"resolvedRelease,omitempty"`
}
type SessionActionInput struct {
SessionPublicID string
SessionToken string `json:"sessionToken"`
}
type FinishSessionInput struct {
SessionPublicID string
SessionToken string `json:"sessionToken"`
Status string `json:"status"`
Summary *SessionSummaryInput `json:"summary,omitempty"`
}
type SessionSummaryInput struct {
FinalDurationSec *int `json:"finalDurationSec,omitempty"`
FinalScore *int `json:"finalScore,omitempty"`
CompletedControls *int `json:"completedControls,omitempty"`
TotalControls *int `json:"totalControls,omitempty"`
DistanceMeters *float64 `json:"distanceMeters,omitempty"`
AverageSpeedKmh *float64 `json:"averageSpeedKmh,omitempty"`
MaxHeartRateBpm *int `json:"maxHeartRateBpm,omitempty"`
}
func NewSessionService(store *postgres.Store) *SessionService {
return &SessionService{store: store}
}
func (s *SessionService) GetSession(ctx context.Context, sessionPublicID, userID string) (*SessionResult, error) {
sessionPublicID = strings.TrimSpace(sessionPublicID)
if sessionPublicID == "" {
return nil, apperr.New(http.StatusBadRequest, "invalid_params", "session id is required")
}
session, err := s.store.GetSessionByPublicID(ctx, sessionPublicID)
if err != nil {
return nil, err
}
if session == nil {
return nil, apperr.New(http.StatusNotFound, "session_not_found", "session not found")
}
if userID != "" && session.UserID != userID {
return nil, apperr.New(http.StatusForbidden, "session_forbidden", "session does not belong to current user")
}
return buildSessionResult(session), nil
}
func (s *SessionService) ListMySessions(ctx context.Context, userID string, limit int) ([]SessionResult, error) {
if userID == "" {
return nil, apperr.New(http.StatusUnauthorized, "unauthorized", "user is required")
}
sessions, err := s.store.ListSessionsByUserID(ctx, userID, limit)
if err != nil {
return nil, err
}
results := make([]SessionResult, 0, len(sessions))
for i := range sessions {
results = append(results, *buildSessionResult(&sessions[i]))
}
return results, nil
}
func (s *SessionService) StartSession(ctx context.Context, input SessionActionInput) (*SessionResult, error) {
session, err := s.validateSessionAction(ctx, input.SessionPublicID, input.SessionToken, sessionTokenPolicy{})
if err != nil {
return nil, err
}
if session.Status == SessionStatusRunning || isSessionTerminalStatus(session.Status) {
return buildSessionResult(session), nil
}
tx, err := s.store.Begin(ctx)
if err != nil {
return nil, err
}
defer tx.Rollback(ctx)
locked, err := s.store.GetSessionByPublicIDForUpdate(ctx, tx, input.SessionPublicID)
if err != nil {
return nil, err
}
if locked == nil {
return nil, apperr.New(http.StatusNotFound, "session_not_found", "session not found")
}
if err := s.verifySessionToken(locked, input.SessionToken, sessionTokenPolicy{}); err != nil {
return nil, err
}
if locked.Status == SessionStatusRunning || isSessionTerminalStatus(locked.Status) {
if err := tx.Commit(ctx); err != nil {
return nil, err
}
return buildSessionResult(locked), nil
}
if locked.Status == SessionStatusLaunched {
if err := s.store.StartSession(ctx, tx, locked.ID); err != nil {
return nil, err
}
}
updated, err := s.store.GetSessionByPublicIDForUpdate(ctx, tx, input.SessionPublicID)
if err != nil {
return nil, err
}
if updated == nil {
return nil, apperr.New(http.StatusNotFound, "session_not_found", "session not found")
}
if err := tx.Commit(ctx); err != nil {
return nil, err
}
return buildSessionResult(updated), nil
}
func (s *SessionService) FinishSession(ctx context.Context, input FinishSessionInput) (*SessionResult, error) {
status, err := normalizeFinishStatus(input.Status)
if err != nil {
return nil, err
}
input.Status = status
session, err := s.validateSessionAction(ctx, input.SessionPublicID, input.SessionToken, sessionTokenPolicy{
AllowExpired: input.Status == SessionStatusCancelled,
})
if err != nil {
return nil, err
}
if isSessionTerminalStatus(session.Status) {
return buildSessionResult(session), nil
}
tx, err := s.store.Begin(ctx)
if err != nil {
return nil, err
}
defer tx.Rollback(ctx)
locked, err := s.store.GetSessionByPublicIDForUpdate(ctx, tx, input.SessionPublicID)
if err != nil {
return nil, err
}
if locked == nil {
return nil, apperr.New(http.StatusNotFound, "session_not_found", "session not found")
}
if err := s.verifySessionToken(locked, input.SessionToken, sessionTokenPolicy{
AllowExpired: input.Status == SessionStatusCancelled || isSessionTerminalStatus(locked.Status),
}); err != nil {
return nil, err
}
if isSessionTerminalStatus(locked.Status) {
if err := tx.Commit(ctx); err != nil {
return nil, err
}
return buildSessionResult(locked), nil
}
if err := s.store.FinishSession(ctx, tx, postgres.FinishSessionParams{
SessionID: locked.ID,
Status: input.Status,
}); err != nil {
return nil, err
}
updated, err := s.store.GetSessionByPublicIDForUpdate(ctx, tx, input.SessionPublicID)
if err != nil {
return nil, err
}
if _, err := s.store.UpsertSessionResult(ctx, tx, postgres.UpsertSessionResultParams{
SessionID: updated.ID,
ResultStatus: input.Status,
Summary: buildSummaryMap(input.Summary),
FinalDurationSec: resolveDurationSeconds(updated, input.Summary),
FinalScore: summaryInt(input.Summary, func(v *SessionSummaryInput) *int { return v.FinalScore }),
CompletedControls: summaryInt(input.Summary, func(v *SessionSummaryInput) *int { return v.CompletedControls }),
TotalControls: summaryInt(input.Summary, func(v *SessionSummaryInput) *int { return v.TotalControls }),
DistanceMeters: summaryFloat(input.Summary, func(v *SessionSummaryInput) *float64 { return v.DistanceMeters }),
AverageSpeedKmh: summaryFloat(input.Summary, func(v *SessionSummaryInput) *float64 { return v.AverageSpeedKmh }),
MaxHeartRateBpm: summaryInt(input.Summary, func(v *SessionSummaryInput) *int { return v.MaxHeartRateBpm }),
}); err != nil {
return nil, err
}
if err := tx.Commit(ctx); err != nil {
return nil, err
}
return buildSessionResult(updated), nil
}
func (s *SessionService) validateSessionAction(ctx context.Context, sessionPublicID, sessionToken string, policy sessionTokenPolicy) (*postgres.Session, error) {
sessionPublicID = strings.TrimSpace(sessionPublicID)
sessionToken = strings.TrimSpace(sessionToken)
if sessionPublicID == "" || sessionToken == "" {
return nil, apperr.New(http.StatusBadRequest, "invalid_params", "session id and sessionToken are required")
}
session, err := s.store.GetSessionByPublicID(ctx, sessionPublicID)
if err != nil {
return nil, err
}
if session == nil {
return nil, apperr.New(http.StatusNotFound, "session_not_found", "session not found")
}
if err := s.verifySessionToken(session, sessionToken, policy); err != nil {
return nil, err
}
return session, nil
}
func (s *SessionService) verifySessionToken(session *postgres.Session, sessionToken string, policy sessionTokenPolicy) error {
if session.SessionTokenHash != security.HashText(sessionToken) {
return apperr.New(http.StatusUnauthorized, "invalid_session_token", "invalid session token")
}
if !policy.AllowExpired && session.SessionTokenExpiresAt.Before(time.Now().UTC()) {
return apperr.New(http.StatusUnauthorized, "session_token_expired", "session token expired")
}
return nil
}
func buildSessionResult(session *postgres.Session) *SessionResult {
result := &SessionResult{}
result.Session.ID = session.SessionPublicID
result.Session.Status = session.Status
result.Session.ClientType = session.ClientType
result.Session.DeviceKey = session.DeviceKey
result.Session.AssignmentMode = session.AssignmentMode
result.Session.VariantID = session.VariantID
result.Session.VariantName = session.VariantName
result.Session.RouteCode = session.RouteCode
result.Session.SessionTokenExpiresAt = session.SessionTokenExpiresAt.Format(time.RFC3339)
result.Session.LaunchedAt = session.LaunchedAt.Format(time.RFC3339)
if session.StartedAt != nil {
value := session.StartedAt.Format(time.RFC3339)
result.Session.StartedAt = &value
}
if session.EndedAt != nil {
value := session.EndedAt.Format(time.RFC3339)
result.Session.EndedAt = &value
}
if session.EventPublicID != nil {
result.Event.ID = *session.EventPublicID
}
if session.EventDisplayName != nil {
result.Event.DisplayName = *session.EventDisplayName
}
result.ResolvedRelease = buildResolvedReleaseFromSession(session, LaunchSourceEventCurrentRelease)
return result
}
func normalizeFinishStatus(value string) (string, error) {
switch strings.TrimSpace(value) {
case "", SessionStatusFinished:
return SessionStatusFinished, nil
case SessionStatusFailed:
return SessionStatusFailed, nil
case SessionStatusCancelled:
return SessionStatusCancelled, nil
default:
return "", apperr.New(http.StatusBadRequest, "invalid_finish_status", "status must be finished, failed or cancelled")
}
}
func buildSummaryMap(summary *SessionSummaryInput) map[string]any {
if summary == nil {
return map[string]any{}
}
raw, err := json.Marshal(summary)
if err != nil {
return map[string]any{}
}
result := map[string]any{}
if err := json.Unmarshal(raw, &result); err != nil {
return map[string]any{}
}
return result
}
func resolveDurationSeconds(session *postgres.Session, summary *SessionSummaryInput) *int {
if summary != nil && summary.FinalDurationSec != nil {
return summary.FinalDurationSec
}
if session.StartedAt != nil {
endAt := time.Now().UTC()
if session.EndedAt != nil {
endAt = *session.EndedAt
}
seconds := int(endAt.Sub(*session.StartedAt).Seconds())
if seconds < 0 {
seconds = 0
}
return &seconds
}
return nil
}
func summaryInt(summary *SessionSummaryInput, getter func(*SessionSummaryInput) *int) *int {
if summary == nil {
return nil
}
return getter(summary)
}
func summaryFloat(summary *SessionSummaryInput, getter func(*SessionSummaryInput) *float64) *float64 {
if summary == nil {
return nil
}
return getter(summary)
}

View File

@@ -1,27 +0,0 @@
package service
const (
SessionStatusLaunched = "launched"
SessionStatusRunning = "running"
SessionStatusFinished = "finished"
SessionStatusFailed = "failed"
SessionStatusCancelled = "cancelled"
)
func isSessionTerminalStatus(status string) bool {
switch status {
case SessionStatusFinished, SessionStatusFailed, SessionStatusCancelled:
return true
default:
return false
}
}
func isSessionOngoingStatus(status string) bool {
switch status {
case SessionStatusLaunched, SessionStatusRunning:
return true
default:
return false
}
}

View File

@@ -1,9 +0,0 @@
package service
import "time"
const timeRFC3339 = time.RFC3339
func nowUTC() time.Time {
return time.Now().UTC()
}

View File

@@ -1,189 +0,0 @@
package service
import (
"crypto/rand"
"encoding/json"
"fmt"
"math/big"
"net/http"
"strings"
"cmr-backend/internal/apperr"
)
const (
AssignmentModeManual = "manual"
AssignmentModeRandom = "random"
AssignmentModeServerAssigned = "server-assigned"
)
type CourseVariantView struct {
ID string `json:"id"`
Name string `json:"name"`
Description *string `json:"description,omitempty"`
RouteCode *string `json:"routeCode,omitempty"`
Selectable bool `json:"selectable"`
}
type VariantBindingView struct {
ID string `json:"id"`
Name string `json:"name"`
RouteCode *string `json:"routeCode,omitempty"`
AssignmentMode string `json:"assignmentMode"`
}
type VariantPlan struct {
AssignmentMode *string
CourseVariants []CourseVariantView
}
func resolveVariantPlan(payloadJSON *string) VariantPlan {
if payloadJSON == nil || strings.TrimSpace(*payloadJSON) == "" {
return VariantPlan{}
}
var payload map[string]any
if err := json.Unmarshal([]byte(*payloadJSON), &payload); err != nil {
return VariantPlan{}
}
play, _ := payload["play"].(map[string]any)
if len(play) == 0 {
return VariantPlan{}
}
result := VariantPlan{}
if rawMode, ok := play["assignmentMode"].(string); ok {
if normalized := normalizeAssignmentMode(rawMode); normalized != nil {
result.AssignmentMode = normalized
}
}
rawVariants, _ := play["courseVariants"].([]any)
if len(rawVariants) == 0 {
return result
}
for _, raw := range rawVariants {
item, ok := raw.(map[string]any)
if !ok {
continue
}
id, _ := item["id"].(string)
name, _ := item["name"].(string)
id = strings.TrimSpace(id)
name = strings.TrimSpace(name)
if id == "" || name == "" {
continue
}
var description *string
if value, ok := item["description"].(string); ok && strings.TrimSpace(value) != "" {
trimmed := strings.TrimSpace(value)
description = &trimmed
}
var routeCode *string
if value, ok := item["routeCode"].(string); ok && strings.TrimSpace(value) != "" {
trimmed := strings.TrimSpace(value)
routeCode = &trimmed
}
selectable := true
if value, ok := item["selectable"].(bool); ok {
selectable = value
}
result.CourseVariants = append(result.CourseVariants, CourseVariantView{
ID: id,
Name: name,
Description: description,
RouteCode: routeCode,
Selectable: selectable,
})
}
return result
}
func resolveLaunchVariant(plan VariantPlan, requestedVariantID string) (*VariantBindingView, error) {
requestedVariantID = strings.TrimSpace(requestedVariantID)
if len(plan.CourseVariants) == 0 {
return nil, nil
}
mode := AssignmentModeManual
if plan.AssignmentMode != nil {
mode = *plan.AssignmentMode
}
if requestedVariantID != "" {
for _, item := range plan.CourseVariants {
if item.ID == requestedVariantID {
if !item.Selectable && mode == AssignmentModeManual {
return nil, apperr.New(http.StatusBadRequest, "variant_not_selectable", "requested variant is not selectable")
}
return &VariantBindingView{
ID: item.ID,
Name: item.Name,
RouteCode: item.RouteCode,
AssignmentMode: mode,
}, nil
}
}
return nil, apperr.New(http.StatusBadRequest, "variant_not_found", "requested variant does not exist")
}
selected, err := selectDefaultVariant(plan.CourseVariants, mode)
if err != nil {
return nil, err
}
return &VariantBindingView{
ID: selected.ID,
Name: selected.Name,
RouteCode: selected.RouteCode,
AssignmentMode: mode,
}, nil
}
func normalizeAssignmentMode(value string) *string {
switch strings.TrimSpace(value) {
case AssignmentModeManual:
mode := AssignmentModeManual
return &mode
case AssignmentModeRandom:
mode := AssignmentModeRandom
return &mode
case AssignmentModeServerAssigned:
mode := AssignmentModeServerAssigned
return &mode
default:
return nil
}
}
func selectDefaultVariant(items []CourseVariantView, mode string) (*CourseVariantView, error) {
candidates := make([]CourseVariantView, 0, len(items))
for _, item := range items {
if item.Selectable {
candidates = append(candidates, item)
}
}
if len(candidates) == 0 {
candidates = append(candidates, items...)
}
if len(candidates) == 0 {
return nil, apperr.New(http.StatusBadRequest, "variant_not_found", "course variants are empty")
}
switch mode {
case AssignmentModeRandom:
index, err := rand.Int(rand.Reader, big.NewInt(int64(len(candidates))))
if err != nil {
return nil, apperr.New(http.StatusInternalServerError, "variant_select_failed", fmt.Sprintf("failed to select random variant: %v", err))
}
selected := candidates[int(index.Int64())]
return &selected, nil
case AssignmentModeServerAssigned, AssignmentModeManual:
fallthrough
default:
selected := candidates[0]
return &selected, nil
}
}

View File

@@ -1,378 +0,0 @@
package postgres
import (
"context"
"errors"
"fmt"
"github.com/jackc/pgx/v5"
)
type Tenant struct {
ID string
TenantCode string
Name string
Status string
}
type AdminEventRecord struct {
ID string
PublicID string
TenantID *string
TenantCode *string
TenantName *string
Slug string
DisplayName string
Summary *string
Status string
CurrentReleaseID *string
CurrentReleasePubID *string
ConfigLabel *string
ManifestURL *string
ManifestChecksum *string
RouteCode *string
PresentationID *string
PresentationName *string
PresentationType *string
ContentBundleID *string
ContentBundleName *string
ContentEntryURL *string
ContentAssetRootURL *string
CurrentPresentationID *string
CurrentPresentationName *string
CurrentPresentationType *string
CurrentContentBundleID *string
CurrentContentBundleName *string
CurrentContentEntryURL *string
CurrentContentAssetRootURL *string
CurrentRuntimeBindingID *string
CurrentPlaceID *string
CurrentMapAssetID *string
CurrentTileReleaseID *string
CurrentCourseSetID *string
CurrentCourseVariantID *string
CurrentCourseVariantName *string
CurrentRuntimeRouteCode *string
}
type CreateAdminEventParams struct {
PublicID string
TenantID *string
Slug string
DisplayName string
Summary *string
Status string
}
type UpdateAdminEventParams struct {
EventID string
TenantID *string
Slug string
DisplayName string
Summary *string
Status string
ClearTenant bool
}
func (s *Store) GetTenantByCode(ctx context.Context, tenantCode string) (*Tenant, error) {
row := s.pool.QueryRow(ctx, `
SELECT id, tenant_code, name, status
FROM tenants
WHERE tenant_code = $1
LIMIT 1
`, tenantCode)
var item Tenant
err := row.Scan(&item.ID, &item.TenantCode, &item.Name, &item.Status)
if errors.Is(err, pgx.ErrNoRows) {
return nil, nil
}
if err != nil {
return nil, fmt.Errorf("get tenant by code: %w", err)
}
return &item, nil
}
func (s *Store) ListAdminEvents(ctx context.Context, limit int) ([]AdminEventRecord, error) {
if limit <= 0 || limit > 200 {
limit = 50
}
rows, err := s.pool.Query(ctx, `
SELECT
e.id,
e.event_public_id,
e.tenant_id,
t.tenant_code,
t.name,
e.slug,
e.display_name,
e.summary,
e.status,
e.current_release_id,
er.release_public_id,
er.config_label,
er.manifest_url,
er.manifest_checksum_sha256,
er.route_code,
ep.presentation_public_id,
ep.name,
ep.presentation_type,
cb.content_bundle_public_id,
cb.name,
cb.entry_url,
cb.asset_root_url,
epc.presentation_public_id,
epc.name,
epc.presentation_type,
cbc.content_bundle_public_id,
cbc.name,
cbc.entry_url,
cbc.asset_root_url,
mrb.runtime_binding_public_id,
p.place_public_id,
ma.map_asset_public_id,
tr.tile_release_public_id,
cset.course_set_public_id,
cv.course_variant_public_id,
cv.name,
cv.route_code
FROM events e
LEFT JOIN tenants t ON t.id = e.tenant_id
LEFT JOIN event_releases er ON er.id = e.current_release_id
LEFT JOIN event_presentations ep ON ep.id = er.presentation_id
LEFT JOIN content_bundles cb ON cb.id = er.content_bundle_id
LEFT JOIN event_presentations epc ON epc.id = e.current_presentation_id
LEFT JOIN content_bundles cbc ON cbc.id = e.current_content_bundle_id
LEFT JOIN map_runtime_bindings mrb ON mrb.id = e.current_runtime_binding_id
LEFT JOIN places p ON p.id = mrb.place_id
LEFT JOIN map_assets ma ON ma.id = mrb.map_asset_id
LEFT JOIN tile_releases tr ON tr.id = mrb.tile_release_id
LEFT JOIN course_sets cset ON cset.id = mrb.course_set_id
LEFT JOIN course_variants cv ON cv.id = mrb.course_variant_id
ORDER BY e.created_at DESC
LIMIT $1
`, limit)
if err != nil {
return nil, fmt.Errorf("list admin events: %w", err)
}
defer rows.Close()
items := []AdminEventRecord{}
for rows.Next() {
item, err := scanAdminEventFromRows(rows)
if err != nil {
return nil, err
}
items = append(items, *item)
}
if err := rows.Err(); err != nil {
return nil, fmt.Errorf("iterate admin events: %w", err)
}
return items, nil
}
func (s *Store) GetAdminEventByPublicID(ctx context.Context, eventPublicID string) (*AdminEventRecord, error) {
row := s.pool.QueryRow(ctx, `
SELECT
e.id,
e.event_public_id,
e.tenant_id,
t.tenant_code,
t.name,
e.slug,
e.display_name,
e.summary,
e.status,
e.current_release_id,
er.release_public_id,
er.config_label,
er.manifest_url,
er.manifest_checksum_sha256,
er.route_code,
ep.presentation_public_id,
ep.name,
ep.presentation_type,
cb.content_bundle_public_id,
cb.name,
cb.entry_url,
cb.asset_root_url,
epc.presentation_public_id,
epc.name,
epc.presentation_type,
cbc.content_bundle_public_id,
cbc.name,
cbc.entry_url,
cbc.asset_root_url,
mrb.runtime_binding_public_id,
p.place_public_id,
ma.map_asset_public_id,
tr.tile_release_public_id,
cset.course_set_public_id,
cv.course_variant_public_id,
cv.name,
cv.route_code
FROM events e
LEFT JOIN tenants t ON t.id = e.tenant_id
LEFT JOIN event_releases er ON er.id = e.current_release_id
LEFT JOIN event_presentations ep ON ep.id = er.presentation_id
LEFT JOIN content_bundles cb ON cb.id = er.content_bundle_id
LEFT JOIN event_presentations epc ON epc.id = e.current_presentation_id
LEFT JOIN content_bundles cbc ON cbc.id = e.current_content_bundle_id
LEFT JOIN map_runtime_bindings mrb ON mrb.id = e.current_runtime_binding_id
LEFT JOIN places p ON p.id = mrb.place_id
LEFT JOIN map_assets ma ON ma.id = mrb.map_asset_id
LEFT JOIN tile_releases tr ON tr.id = mrb.tile_release_id
LEFT JOIN course_sets cset ON cset.id = mrb.course_set_id
LEFT JOIN course_variants cv ON cv.id = mrb.course_variant_id
WHERE e.event_public_id = $1
LIMIT 1
`, eventPublicID)
return scanAdminEvent(row)
}
func (s *Store) CreateAdminEvent(ctx context.Context, tx Tx, params CreateAdminEventParams) (*AdminEventRecord, error) {
row := tx.QueryRow(ctx, `
INSERT INTO events (tenant_id, event_public_id, slug, display_name, summary, status)
VALUES ($1, $2, $3, $4, $5, $6)
RETURNING id, event_public_id, tenant_id, slug, display_name, summary, status, current_release_id
`, params.TenantID, params.PublicID, params.Slug, params.DisplayName, params.Summary, params.Status)
var item AdminEventRecord
if err := row.Scan(
&item.ID,
&item.PublicID,
&item.TenantID,
&item.Slug,
&item.DisplayName,
&item.Summary,
&item.Status,
&item.CurrentReleaseID,
); err != nil {
return nil, fmt.Errorf("create admin event: %w", err)
}
return &item, nil
}
func (s *Store) UpdateAdminEvent(ctx context.Context, tx Tx, params UpdateAdminEventParams) (*AdminEventRecord, error) {
row := tx.QueryRow(ctx, `
UPDATE events
SET tenant_id = CASE WHEN $7 THEN NULL ELSE $2 END,
slug = $3,
display_name = $4,
summary = $5,
status = $6
WHERE id = $1
RETURNING id, event_public_id, tenant_id, slug, display_name, summary, status, current_release_id
`, params.EventID, params.TenantID, params.Slug, params.DisplayName, params.Summary, params.Status, params.ClearTenant)
var item AdminEventRecord
if err := row.Scan(
&item.ID,
&item.PublicID,
&item.TenantID,
&item.Slug,
&item.DisplayName,
&item.Summary,
&item.Status,
&item.CurrentReleaseID,
); err != nil {
return nil, fmt.Errorf("update admin event: %w", err)
}
return &item, nil
}
func scanAdminEvent(row pgx.Row) (*AdminEventRecord, error) {
var item AdminEventRecord
err := row.Scan(
&item.ID,
&item.PublicID,
&item.TenantID,
&item.TenantCode,
&item.TenantName,
&item.Slug,
&item.DisplayName,
&item.Summary,
&item.Status,
&item.CurrentReleaseID,
&item.CurrentReleasePubID,
&item.ConfigLabel,
&item.ManifestURL,
&item.ManifestChecksum,
&item.RouteCode,
&item.PresentationID,
&item.PresentationName,
&item.PresentationType,
&item.ContentBundleID,
&item.ContentBundleName,
&item.ContentEntryURL,
&item.ContentAssetRootURL,
&item.CurrentPresentationID,
&item.CurrentPresentationName,
&item.CurrentPresentationType,
&item.CurrentContentBundleID,
&item.CurrentContentBundleName,
&item.CurrentContentEntryURL,
&item.CurrentContentAssetRootURL,
&item.CurrentRuntimeBindingID,
&item.CurrentPlaceID,
&item.CurrentMapAssetID,
&item.CurrentTileReleaseID,
&item.CurrentCourseSetID,
&item.CurrentCourseVariantID,
&item.CurrentCourseVariantName,
&item.CurrentRuntimeRouteCode,
)
if errors.Is(err, pgx.ErrNoRows) {
return nil, nil
}
if err != nil {
return nil, fmt.Errorf("scan admin event: %w", err)
}
return &item, nil
}
func scanAdminEventFromRows(rows pgx.Rows) (*AdminEventRecord, error) {
var item AdminEventRecord
err := rows.Scan(
&item.ID,
&item.PublicID,
&item.TenantID,
&item.TenantCode,
&item.TenantName,
&item.Slug,
&item.DisplayName,
&item.Summary,
&item.Status,
&item.CurrentReleaseID,
&item.CurrentReleasePubID,
&item.ConfigLabel,
&item.ManifestURL,
&item.ManifestChecksum,
&item.RouteCode,
&item.PresentationID,
&item.PresentationName,
&item.PresentationType,
&item.ContentBundleID,
&item.ContentBundleName,
&item.ContentEntryURL,
&item.ContentAssetRootURL,
&item.CurrentPresentationID,
&item.CurrentPresentationName,
&item.CurrentPresentationType,
&item.CurrentContentBundleID,
&item.CurrentContentBundleName,
&item.CurrentContentEntryURL,
&item.CurrentContentAssetRootURL,
&item.CurrentRuntimeBindingID,
&item.CurrentPlaceID,
&item.CurrentMapAssetID,
&item.CurrentTileReleaseID,
&item.CurrentCourseSetID,
&item.CurrentCourseVariantID,
&item.CurrentCourseVariantName,
&item.CurrentRuntimeRouteCode,
)
if err != nil {
return nil, fmt.Errorf("scan admin event row: %w", err)
}
return &item, nil
}

View File

@@ -1,135 +0,0 @@
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
}

View File

@@ -1,310 +0,0 @@
package postgres
import (
"context"
"encoding/json"
"errors"
"fmt"
"net/http"
"time"
"cmr-backend/internal/apperr"
"github.com/jackc/pgx/v5"
)
type SMSCodeMeta struct {
ID string
CodeHash string
ExpiresAt time.Time
CooldownUntil time.Time
}
type CreateSMSCodeParams struct {
Scene string
CountryCode string
Mobile string
ClientType string
DeviceKey string
CodeHash string
ProviderName string
ProviderDebug map[string]any
ExpiresAt time.Time
CooldownUntil time.Time
}
type CreateMobileIdentityParams struct {
UserID string
IdentityType string
Provider string
ProviderSubj string
CountryCode string
Mobile string
}
type CreateIdentityParams struct {
UserID string
IdentityType string
Provider string
ProviderSubj string
CountryCode *string
Mobile *string
ProfileJSON string
}
type CreateRefreshTokenParams struct {
UserID string
ClientType string
DeviceKey string
TokenHash string
ExpiresAt time.Time
}
type RefreshTokenRecord struct {
ID string
UserID string
ClientType string
DeviceKey *string
ExpiresAt time.Time
IsRevoked bool
}
func (s *Store) GetLatestSMSCodeMeta(ctx context.Context, countryCode, mobile, clientType, scene string) (*SMSCodeMeta, error) {
row := s.pool.QueryRow(ctx, `
SELECT id, code_hash, expires_at, cooldown_until
FROM auth_sms_codes
WHERE country_code = $1 AND mobile = $2 AND client_type = $3 AND scene = $4
ORDER BY created_at DESC
LIMIT 1
`, countryCode, mobile, clientType, scene)
var record SMSCodeMeta
err := row.Scan(&record.ID, &record.CodeHash, &record.ExpiresAt, &record.CooldownUntil)
if errors.Is(err, pgx.ErrNoRows) {
return nil, nil
}
if err != nil {
return nil, fmt.Errorf("query latest sms code meta: %w", err)
}
return &record, nil
}
func (s *Store) CreateSMSCode(ctx context.Context, params CreateSMSCodeParams) error {
payload, err := json.Marshal(map[string]any{
"provider": params.ProviderName,
"debug": params.ProviderDebug,
})
if err != nil {
return err
}
_, err = s.pool.Exec(ctx, `
INSERT INTO auth_sms_codes (
scene, country_code, mobile, client_type, device_key, code_hash,
provider_payload_jsonb, expires_at, cooldown_until
)
VALUES ($1, $2, $3, $4, $5, $6, $7::jsonb, $8, $9)
`, params.Scene, params.CountryCode, params.Mobile, params.ClientType, params.DeviceKey, params.CodeHash, string(payload), params.ExpiresAt, params.CooldownUntil)
if err != nil {
return fmt.Errorf("insert sms code: %w", err)
}
return nil
}
func (s *Store) GetLatestValidSMSCode(ctx context.Context, countryCode, mobile, clientType, scene string) (*SMSCodeMeta, error) {
row := s.pool.QueryRow(ctx, `
SELECT id, code_hash, expires_at, cooldown_until
FROM auth_sms_codes
WHERE country_code = $1
AND mobile = $2
AND client_type = $3
AND scene = $4
AND consumed_at IS NULL
AND expires_at > NOW()
ORDER BY created_at DESC
LIMIT 1
`, countryCode, mobile, clientType, scene)
var record SMSCodeMeta
err := row.Scan(&record.ID, &record.CodeHash, &record.ExpiresAt, &record.CooldownUntil)
if errors.Is(err, pgx.ErrNoRows) {
return nil, nil
}
if err != nil {
return nil, fmt.Errorf("query latest valid sms code: %w", err)
}
return &record, nil
}
func (s *Store) ConsumeSMSCode(ctx context.Context, tx Tx, id string) (bool, error) {
commandTag, err := tx.Exec(ctx, `
UPDATE auth_sms_codes
SET consumed_at = NOW()
WHERE id = $1 AND consumed_at IS NULL
`, id)
if err != nil {
return false, fmt.Errorf("consume sms code: %w", err)
}
return commandTag.RowsAffected() == 1, nil
}
func (s *Store) CreateMobileIdentity(ctx context.Context, tx Tx, params CreateMobileIdentityParams) error {
countryCode := params.CountryCode
mobile := params.Mobile
return s.CreateIdentity(ctx, tx, CreateIdentityParams{
UserID: params.UserID,
IdentityType: params.IdentityType,
Provider: params.Provider,
ProviderSubj: params.ProviderSubj,
CountryCode: &countryCode,
Mobile: &mobile,
ProfileJSON: "{}",
})
}
func (s *Store) CreateIdentity(ctx context.Context, tx Tx, params CreateIdentityParams) error {
_, err := tx.Exec(ctx, `
INSERT INTO login_identities (
user_id, identity_type, provider, provider_subject, country_code, mobile, status, profile_jsonb
)
VALUES ($1, $2, $3, $4, $5, $6, 'active', $7::jsonb)
ON CONFLICT (provider, provider_subject) DO NOTHING
`, params.UserID, params.IdentityType, params.Provider, params.ProviderSubj, params.CountryCode, params.Mobile, zeroJSON(params.ProfileJSON))
if err != nil {
return fmt.Errorf("create identity: %w", err)
}
return nil
}
func (s *Store) FindUserByProviderSubject(ctx context.Context, tx Tx, provider, providerSubject string) (*User, error) {
row := tx.QueryRow(ctx, `
SELECT u.id, u.user_public_id, u.status, u.nickname, u.avatar_url
FROM users u
JOIN login_identities li ON li.user_id = u.id
WHERE li.provider = $1
AND li.provider_subject = $2
AND li.status = 'active'
LIMIT 1
`, provider, providerSubject)
return scanUser(row)
}
func (s *Store) CreateRefreshToken(ctx context.Context, tx Tx, params CreateRefreshTokenParams) (string, error) {
row := tx.QueryRow(ctx, `
INSERT INTO auth_refresh_tokens (user_id, client_type, device_key, token_hash, expires_at)
VALUES ($1, $2, NULLIF($3, ''), $4, $5)
RETURNING id
`, params.UserID, params.ClientType, params.DeviceKey, params.TokenHash, params.ExpiresAt)
var id string
if err := row.Scan(&id); err != nil {
return "", fmt.Errorf("create refresh token: %w", err)
}
return id, nil
}
func (s *Store) GetRefreshTokenForUpdate(ctx context.Context, tx Tx, tokenHash string) (*RefreshTokenRecord, error) {
row := tx.QueryRow(ctx, `
SELECT id, user_id, client_type, device_key, expires_at, revoked_at IS NOT NULL
FROM auth_refresh_tokens
WHERE token_hash = $1
FOR UPDATE
`, tokenHash)
var record RefreshTokenRecord
err := row.Scan(&record.ID, &record.UserID, &record.ClientType, &record.DeviceKey, &record.ExpiresAt, &record.IsRevoked)
if errors.Is(err, pgx.ErrNoRows) {
return nil, nil
}
if err != nil {
return nil, fmt.Errorf("query refresh token for update: %w", err)
}
return &record, nil
}
func (s *Store) RotateRefreshToken(ctx context.Context, tx Tx, oldTokenID, newTokenID string) error {
_, err := tx.Exec(ctx, `
UPDATE auth_refresh_tokens
SET revoked_at = NOW(), replaced_by_token_id = $2
WHERE id = $1
`, oldTokenID, newTokenID)
if err != nil {
return fmt.Errorf("rotate refresh token: %w", err)
}
return nil
}
func (s *Store) RevokeRefreshToken(ctx context.Context, tokenHash string) error {
commandTag, err := s.pool.Exec(ctx, `
UPDATE auth_refresh_tokens
SET revoked_at = COALESCE(revoked_at, NOW())
WHERE token_hash = $1
`, tokenHash)
if err != nil {
return fmt.Errorf("revoke refresh token: %w", err)
}
if commandTag.RowsAffected() == 0 {
return apperr.New(http.StatusNotFound, "refresh_token_not_found", "refresh token not found")
}
return nil
}
func (s *Store) RevokeRefreshTokensByUserID(ctx context.Context, tx Tx, userID string) error {
_, err := tx.Exec(ctx, `
UPDATE auth_refresh_tokens
SET revoked_at = COALESCE(revoked_at, NOW())
WHERE user_id = $1
`, userID)
if err != nil {
return fmt.Errorf("revoke refresh tokens by user id: %w", err)
}
return nil
}
func (s *Store) TransferNonMobileIdentities(ctx context.Context, tx Tx, sourceUserID, targetUserID string) error {
if sourceUserID == targetUserID {
return nil
}
_, err := tx.Exec(ctx, `
INSERT INTO login_identities (
user_id, identity_type, provider, provider_subject, country_code, mobile, status, profile_jsonb, created_at, updated_at
)
SELECT
$2,
li.identity_type,
li.provider,
li.provider_subject,
li.country_code,
li.mobile,
li.status,
li.profile_jsonb,
li.created_at,
li.updated_at
FROM login_identities li
WHERE li.user_id = $1
AND li.provider <> 'mobile'
ON CONFLICT (provider, provider_subject) DO NOTHING
`, sourceUserID, targetUserID)
if err != nil {
return fmt.Errorf("copy non-mobile identities: %w", err)
}
_, err = tx.Exec(ctx, `
DELETE FROM login_identities
WHERE user_id = $1
AND provider <> 'mobile'
`, sourceUserID)
if err != nil {
return fmt.Errorf("delete source non-mobile identities: %w", err)
}
return nil
}
func zeroJSON(value string) string {
if value == "" {
return "{}"
}
return value
}

View File

@@ -1,154 +0,0 @@
package postgres
import (
"context"
"fmt"
"time"
)
type Card struct {
ID string
PublicID string
CardType string
Title string
Subtitle *string
CoverURL *string
DisplaySlot string
DisplayPriority int
IsDefaultExperience bool
ShowInEventList bool
StartsAt *time.Time
EndsAt *time.Time
EntryChannelID *string
EventPublicID *string
EventDisplayName *string
EventSummary *string
EventStatus *string
EventCurrentReleasePubID *string
EventConfigLabel *string
EventRouteCode *string
EventReleasePayloadJSON *string
EventRuntimeBindingID *string
EventPresentationID *string
EventPresentationName *string
EventPresentationType *string
EventPresentationSchemaJSON *string
EventContentBundleID *string
EventContentBundleName *string
EventContentEntryURL *string
EventContentAssetRootURL *string
EventContentMetadataJSON *string
HTMLURL *string
}
func (s *Store) ListCardsForEntry(ctx context.Context, tenantID string, entryChannelID *string, slot string, now time.Time, limit int) ([]Card, error) {
if limit <= 0 || limit > 100 {
limit = 20
}
if slot == "" {
slot = "home_primary"
}
rows, err := s.pool.Query(ctx, `
SELECT
c.id,
c.card_public_id,
c.card_type,
c.title,
c.subtitle,
c.cover_url,
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,
e.event_public_id,
e.display_name,
e.summary,
e.status,
er.release_public_id,
er.config_label,
er.route_code,
er.payload_jsonb::text,
mrb.runtime_binding_public_id,
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,
c.html_url
FROM cards c
LEFT JOIN events e ON e.id = c.event_id
LEFT JOIN event_releases er ON er.id = e.current_release_id
LEFT JOIN map_runtime_bindings mrb ON mrb.id = er.runtime_binding_id
LEFT JOIN event_presentations ep ON ep.id = er.presentation_id
LEFT JOIN content_bundles cb ON cb.id = er.content_bundle_id
WHERE c.tenant_id = $1
AND ($2::uuid IS NULL OR c.entry_channel_id = $2 OR c.entry_channel_id IS NULL)
AND c.display_slot = $3
AND c.status = 'active'
AND (c.starts_at IS NULL OR c.starts_at <= $4)
AND (c.ends_at IS NULL OR c.ends_at >= $4)
ORDER BY
CASE WHEN $2::uuid IS NOT NULL AND c.entry_channel_id = $2 THEN 0 ELSE 1 END,
c.display_priority DESC,
c.created_at ASC
LIMIT $5
`, tenantID, entryChannelID, slot, now, limit)
if err != nil {
return nil, fmt.Errorf("list cards for entry: %w", err)
}
defer rows.Close()
var cards []Card
for rows.Next() {
var card Card
if err := rows.Scan(
&card.ID,
&card.PublicID,
&card.CardType,
&card.Title,
&card.Subtitle,
&card.CoverURL,
&card.DisplaySlot,
&card.DisplayPriority,
&card.IsDefaultExperience,
&card.ShowInEventList,
&card.StartsAt,
&card.EndsAt,
&card.EntryChannelID,
&card.EventPublicID,
&card.EventDisplayName,
&card.EventSummary,
&card.EventStatus,
&card.EventCurrentReleasePubID,
&card.EventConfigLabel,
&card.EventRouteCode,
&card.EventReleasePayloadJSON,
&card.EventRuntimeBindingID,
&card.EventPresentationID,
&card.EventPresentationName,
&card.EventPresentationType,
&card.EventPresentationSchemaJSON,
&card.EventContentBundleID,
&card.EventContentBundleName,
&card.EventContentEntryURL,
&card.EventContentAssetRootURL,
&card.EventContentMetadataJSON,
&card.HTMLURL,
); err != nil {
return nil, fmt.Errorf("scan card: %w", err)
}
cards = append(cards, card)
}
if err := rows.Err(); err != nil {
return nil, fmt.Errorf("iterate cards: %w", err)
}
return cards, nil
}

View File

@@ -1,371 +0,0 @@
package postgres
import (
"context"
"encoding/json"
"errors"
"fmt"
"github.com/jackc/pgx/v5"
)
type EventConfigSource struct {
ID string
EventID string
SourceVersionNo int
SourceKind string
SchemaID string
SchemaVersion string
Status string
SourceJSON string
Notes *string
}
type EventConfigBuild struct {
ID string
EventID string
SourceID string
BuildNo int
BuildStatus string
BuildLog *string
ManifestJSON string
AssetIndexJSON string
}
type EventReleaseAsset struct {
ID string
EventReleaseID string
AssetType string
AssetKey string
AssetPath *string
AssetURL string
Checksum *string
SizeBytes *int64
MetaJSON string
}
type UpsertEventConfigSourceParams struct {
EventID string
SourceVersionNo int
SourceKind string
SchemaID string
SchemaVersion string
Status string
Source map[string]any
Notes *string
}
type UpsertEventConfigBuildParams struct {
EventID string
SourceID string
BuildNo int
BuildStatus string
BuildLog *string
Manifest map[string]any
AssetIndex []map[string]any
}
type UpsertEventReleaseAssetParams struct {
EventReleaseID string
AssetType string
AssetKey string
AssetPath *string
AssetURL string
Checksum *string
SizeBytes *int64
Meta map[string]any
}
func (s *Store) UpsertEventConfigSource(ctx context.Context, tx Tx, params UpsertEventConfigSourceParams) (*EventConfigSource, error) {
sourceJSON, err := json.Marshal(params.Source)
if err != nil {
return nil, fmt.Errorf("marshal event config source: %w", err)
}
row := tx.QueryRow(ctx, `
INSERT INTO event_config_sources (
event_id, source_version_no, source_kind, schema_id, schema_version, status, source_jsonb, notes
)
VALUES ($1, $2, $3, $4, $5, $6, $7::jsonb, $8)
ON CONFLICT (event_id, source_version_no) DO UPDATE SET
source_kind = EXCLUDED.source_kind,
schema_id = EXCLUDED.schema_id,
schema_version = EXCLUDED.schema_version,
status = EXCLUDED.status,
source_jsonb = EXCLUDED.source_jsonb,
notes = EXCLUDED.notes
RETURNING id, event_id, source_version_no, source_kind, schema_id, schema_version, status, source_jsonb::text, notes
`, params.EventID, params.SourceVersionNo, params.SourceKind, params.SchemaID, params.SchemaVersion, params.Status, string(sourceJSON), params.Notes)
var item EventConfigSource
if err := row.Scan(
&item.ID,
&item.EventID,
&item.SourceVersionNo,
&item.SourceKind,
&item.SchemaID,
&item.SchemaVersion,
&item.Status,
&item.SourceJSON,
&item.Notes,
); err != nil {
return nil, fmt.Errorf("upsert event config source: %w", err)
}
return &item, nil
}
func (s *Store) UpsertEventConfigBuild(ctx context.Context, tx Tx, params UpsertEventConfigBuildParams) (*EventConfigBuild, error) {
manifestJSON, err := json.Marshal(params.Manifest)
if err != nil {
return nil, fmt.Errorf("marshal event config manifest: %w", err)
}
assetIndexJSON, err := json.Marshal(params.AssetIndex)
if err != nil {
return nil, fmt.Errorf("marshal event config asset index: %w", err)
}
row := tx.QueryRow(ctx, `
INSERT INTO event_config_builds (
event_id, source_id, build_no, build_status, build_log, manifest_jsonb, asset_index_jsonb
)
VALUES ($1, $2, $3, $4, $5, $6::jsonb, $7::jsonb)
ON CONFLICT (event_id, build_no) DO UPDATE SET
source_id = EXCLUDED.source_id,
build_status = EXCLUDED.build_status,
build_log = EXCLUDED.build_log,
manifest_jsonb = EXCLUDED.manifest_jsonb,
asset_index_jsonb = EXCLUDED.asset_index_jsonb
RETURNING id, event_id, source_id, build_no, build_status, build_log, manifest_jsonb::text, asset_index_jsonb::text
`, params.EventID, params.SourceID, params.BuildNo, params.BuildStatus, params.BuildLog, string(manifestJSON), string(assetIndexJSON))
var item EventConfigBuild
if err := row.Scan(
&item.ID,
&item.EventID,
&item.SourceID,
&item.BuildNo,
&item.BuildStatus,
&item.BuildLog,
&item.ManifestJSON,
&item.AssetIndexJSON,
); err != nil {
return nil, fmt.Errorf("upsert event config build: %w", err)
}
return &item, nil
}
func (s *Store) AttachBuildToRelease(ctx context.Context, tx Tx, releaseID, buildID string) error {
if _, err := tx.Exec(ctx, `
UPDATE event_releases
SET build_id = $2
WHERE id = $1
`, releaseID, buildID); err != nil {
return fmt.Errorf("attach build to release: %w", err)
}
return nil
}
func (s *Store) ReplaceEventReleaseAssets(ctx context.Context, tx Tx, eventReleaseID string, assets []UpsertEventReleaseAssetParams) error {
if _, err := tx.Exec(ctx, `DELETE FROM event_release_assets WHERE event_release_id = $1`, eventReleaseID); err != nil {
return fmt.Errorf("clear event release assets: %w", err)
}
for _, asset := range assets {
metaJSON, err := json.Marshal(asset.Meta)
if err != nil {
return fmt.Errorf("marshal event release asset meta: %w", err)
}
if _, err := tx.Exec(ctx, `
INSERT INTO event_release_assets (
event_release_id, asset_type, asset_key, asset_path, asset_url, checksum, size_bytes, meta_jsonb
)
VALUES ($1, $2, $3, $4, $5, $6, $7, $8::jsonb)
`, eventReleaseID, asset.AssetType, asset.AssetKey, asset.AssetPath, asset.AssetURL, asset.Checksum, asset.SizeBytes, string(metaJSON)); err != nil {
return fmt.Errorf("insert event release asset: %w", err)
}
}
return nil
}
func (s *Store) NextEventConfigSourceVersion(ctx context.Context, eventID string) (int, error) {
var next int
if err := s.pool.QueryRow(ctx, `
SELECT COALESCE(MAX(source_version_no), 0) + 1
FROM event_config_sources
WHERE event_id = $1
`, eventID).Scan(&next); err != nil {
return 0, fmt.Errorf("next event config source version: %w", err)
}
return next, nil
}
func (s *Store) NextEventConfigBuildNo(ctx context.Context, eventID string) (int, error) {
var next int
if err := s.pool.QueryRow(ctx, `
SELECT COALESCE(MAX(build_no), 0) + 1
FROM event_config_builds
WHERE event_id = $1
`, eventID).Scan(&next); err != nil {
return 0, fmt.Errorf("next event config build no: %w", err)
}
return next, nil
}
func (s *Store) ListEventConfigSourcesByEventID(ctx context.Context, eventID string, limit int) ([]EventConfigSource, error) {
if limit <= 0 || limit > 100 {
limit = 20
}
rows, err := s.pool.Query(ctx, `
SELECT id, event_id, source_version_no, source_kind, schema_id, schema_version, status, source_jsonb::text, notes
FROM event_config_sources
WHERE event_id = $1
ORDER BY source_version_no DESC
LIMIT $2
`, eventID, limit)
if err != nil {
return nil, fmt.Errorf("list event config sources: %w", err)
}
defer rows.Close()
var items []EventConfigSource
for rows.Next() {
item, err := scanEventConfigSourceFromRows(rows)
if err != nil {
return nil, err
}
items = append(items, *item)
}
if err := rows.Err(); err != nil {
return nil, fmt.Errorf("iterate event config sources: %w", err)
}
return items, nil
}
func (s *Store) GetEventConfigSourceByID(ctx context.Context, sourceID string) (*EventConfigSource, error) {
row := s.pool.QueryRow(ctx, `
SELECT id, event_id, source_version_no, source_kind, schema_id, schema_version, status, source_jsonb::text, notes
FROM event_config_sources
WHERE id = $1
LIMIT 1
`, sourceID)
return scanEventConfigSource(row)
}
func (s *Store) GetEventConfigBuildByID(ctx context.Context, buildID string) (*EventConfigBuild, error) {
row := s.pool.QueryRow(ctx, `
SELECT id, event_id, source_id, build_no, build_status, build_log, manifest_jsonb::text, asset_index_jsonb::text
FROM event_config_builds
WHERE id = $1
LIMIT 1
`, buildID)
return scanEventConfigBuild(row)
}
func (s *Store) ListEventConfigBuildsByEventID(ctx context.Context, eventID string, limit int) ([]EventConfigBuild, error) {
if limit <= 0 || limit > 100 {
limit = 20
}
rows, err := s.pool.Query(ctx, `
SELECT id, event_id, source_id, build_no, build_status, build_log, manifest_jsonb::text, asset_index_jsonb::text
FROM event_config_builds
WHERE event_id = $1
ORDER BY build_no DESC
LIMIT $2
`, eventID, limit)
if err != nil {
return nil, fmt.Errorf("list event config builds: %w", err)
}
defer rows.Close()
items := []EventConfigBuild{}
for rows.Next() {
item, err := scanEventConfigBuildFromRows(rows)
if err != nil {
return nil, err
}
items = append(items, *item)
}
if err := rows.Err(); err != nil {
return nil, fmt.Errorf("iterate event config builds: %w", err)
}
return items, nil
}
func scanEventConfigSource(row pgx.Row) (*EventConfigSource, error) {
var item EventConfigSource
err := row.Scan(
&item.ID,
&item.EventID,
&item.SourceVersionNo,
&item.SourceKind,
&item.SchemaID,
&item.SchemaVersion,
&item.Status,
&item.SourceJSON,
&item.Notes,
)
if errors.Is(err, pgx.ErrNoRows) {
return nil, nil
}
if err != nil {
return nil, fmt.Errorf("scan event config source: %w", err)
}
return &item, nil
}
func scanEventConfigSourceFromRows(rows pgx.Rows) (*EventConfigSource, error) {
var item EventConfigSource
if err := rows.Scan(
&item.ID,
&item.EventID,
&item.SourceVersionNo,
&item.SourceKind,
&item.SchemaID,
&item.SchemaVersion,
&item.Status,
&item.SourceJSON,
&item.Notes,
); err != nil {
return nil, fmt.Errorf("scan event config source row: %w", err)
}
return &item, nil
}
func scanEventConfigBuild(row pgx.Row) (*EventConfigBuild, error) {
var item EventConfigBuild
err := row.Scan(
&item.ID,
&item.EventID,
&item.SourceID,
&item.BuildNo,
&item.BuildStatus,
&item.BuildLog,
&item.ManifestJSON,
&item.AssetIndexJSON,
)
if errors.Is(err, pgx.ErrNoRows) {
return nil, nil
}
if err != nil {
return nil, fmt.Errorf("scan event config build: %w", err)
}
return &item, nil
}
func scanEventConfigBuildFromRows(rows pgx.Rows) (*EventConfigBuild, error) {
var item EventConfigBuild
err := rows.Scan(
&item.ID,
&item.EventID,
&item.SourceID,
&item.BuildNo,
&item.BuildStatus,
&item.BuildLog,
&item.ManifestJSON,
&item.AssetIndexJSON,
)
if err != nil {
return nil, fmt.Errorf("scan event config build row: %w", err)
}
return &item, nil
}

View File

@@ -1,46 +0,0 @@
package postgres
import (
"context"
"fmt"
"github.com/jackc/pgx/v5"
"github.com/jackc/pgx/v5/pgxpool"
)
type Store struct {
pool *pgxpool.Pool
}
type Tx = pgx.Tx
func Open(ctx context.Context, databaseURL string) (*pgxpool.Pool, error) {
pool, err := pgxpool.New(ctx, databaseURL)
if err != nil {
return nil, fmt.Errorf("open postgres pool: %w", err)
}
if err := pool.Ping(ctx); err != nil {
pool.Close()
return nil, fmt.Errorf("ping postgres: %w", err)
}
return pool, nil
}
func NewStore(pool *pgxpool.Pool) *Store {
return &Store{pool: pool}
}
func (s *Store) Pool() *pgxpool.Pool {
return s.pool
}
func (s *Store) Close() {
if s.pool != nil {
s.pool.Close()
}
}
func (s *Store) Begin(ctx context.Context) (pgx.Tx, error) {
return s.pool.Begin(ctx)
}

File diff suppressed because it is too large Load Diff

View File

@@ -1,74 +0,0 @@
package postgres
import (
"context"
"errors"
"fmt"
"github.com/jackc/pgx/v5"
)
type EntryChannel struct {
ID string
ChannelCode string
ChannelType string
PlatformAppID *string
DisplayName string
Status string
IsDefault bool
TenantID string
TenantCode string
TenantName string
}
type FindEntryChannelParams struct {
ChannelCode string
ChannelType string
PlatformAppID string
TenantCode string
}
func (s *Store) FindEntryChannel(ctx context.Context, params FindEntryChannelParams) (*EntryChannel, error) {
row := s.pool.QueryRow(ctx, `
SELECT
ec.id,
ec.channel_code,
ec.channel_type,
ec.platform_app_id,
ec.display_name,
ec.status,
ec.is_default,
t.id,
t.tenant_code,
t.name
FROM entry_channels ec
JOIN tenants t ON t.id = ec.tenant_id
WHERE ($1 = '' OR ec.channel_code = $1)
AND ($2 = '' OR ec.channel_type = $2)
AND ($3 = '' OR COALESCE(ec.platform_app_id, '') = $3)
AND ($4 = '' OR t.tenant_code = $4)
ORDER BY ec.is_default DESC, ec.created_at ASC
LIMIT 1
`, params.ChannelCode, params.ChannelType, params.PlatformAppID, params.TenantCode)
var entry EntryChannel
err := row.Scan(
&entry.ID,
&entry.ChannelCode,
&entry.ChannelType,
&entry.PlatformAppID,
&entry.DisplayName,
&entry.Status,
&entry.IsDefault,
&entry.TenantID,
&entry.TenantCode,
&entry.TenantName,
)
if errors.Is(err, pgx.ErrNoRows) {
return nil, nil
}
if err != nil {
return nil, fmt.Errorf("find entry channel: %w", err)
}
return &entry, nil
}

View File

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

View File

@@ -1,626 +0,0 @@
package postgres
import (
"context"
"errors"
"fmt"
"time"
"github.com/jackc/pgx/v5"
)
type Event struct {
ID string
PublicID string
Slug string
DisplayName string
Summary *string
Status string
IsDefaultExperience bool
ShowInEventList bool
CurrentReleaseID *string
CurrentReleasePubID *string
ConfigLabel *string
ManifestURL *string
ManifestChecksum *string
RouteCode *string
ReleasePayloadJSON *string
RuntimeBindingID *string
PlacePublicID *string
PlaceName *string
MapAssetPublicID *string
MapAssetName *string
TileReleasePublicID *string
CourseSetPublicID *string
CourseVariantID *string
CourseVariantName *string
RuntimeRouteCode *string
PresentationID *string
PresentationName *string
PresentationType *string
ContentBundleID *string
ContentBundleName *string
ContentEntryURL *string
ContentAssetRootURL *string
}
type EventRelease struct {
ID string
PublicID string
EventID string
ReleaseNo int
ConfigLabel string
ManifestURL string
ManifestChecksum *string
RouteCode *string
BuildID *string
Status string
PublishedAt time.Time
RuntimeBindingID *string
PlacePublicID *string
PlaceName *string
MapAssetPublicID *string
MapAssetName *string
TileReleaseID *string
CourseSetID *string
CourseVariantID *string
CourseVariantName *string
RuntimeRouteCode *string
PresentationID *string
PresentationName *string
PresentationType *string
ContentBundleID *string
ContentBundleName *string
ContentEntryURL *string
ContentAssetURL *string
}
type CreateGameSessionParams struct {
SessionPublicID string
UserID string
EventID string
EventReleaseID string
DeviceKey string
ClientType string
AssignmentMode *string
VariantID *string
VariantName *string
RouteCode *string
SessionTokenHash string
SessionTokenExpiresAt time.Time
}
type GameSession struct {
ID string
SessionPublicID string
UserID string
EventID string
EventReleaseID string
DeviceKey string
ClientType string
AssignmentMode *string
VariantID *string
VariantName *string
RouteCode *string
Status string
SessionTokenExpiresAt time.Time
}
func (s *Store) GetEventByPublicID(ctx context.Context, eventPublicID string) (*Event, error) {
row := s.pool.QueryRow(ctx, `
SELECT
e.id,
e.event_public_id,
e.slug,
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,
er.manifest_url,
er.manifest_checksum_sha256,
er.route_code,
er.payload_jsonb::text,
mrb.runtime_binding_public_id,
p.place_public_id,
p.name,
ma.map_asset_public_id,
ma.name,
tr.tile_release_public_id,
cset.course_set_public_id,
cv.course_variant_public_id,
cv.name,
cv.route_code,
ep.presentation_public_id,
ep.name,
ep.presentation_type,
cb.content_bundle_public_id,
cb.name,
cb.entry_url,
cb.asset_root_url
FROM events e
LEFT JOIN event_releases er ON er.id = e.current_release_id
LEFT JOIN map_runtime_bindings mrb ON mrb.id = er.runtime_binding_id
LEFT JOIN places p ON p.id = mrb.place_id
LEFT JOIN map_assets ma ON ma.id = mrb.map_asset_id
LEFT JOIN tile_releases tr ON tr.id = mrb.tile_release_id
LEFT JOIN course_sets cset ON cset.id = mrb.course_set_id
LEFT JOIN course_variants cv ON cv.id = mrb.course_variant_id
LEFT JOIN event_presentations ep ON ep.id = er.presentation_id
LEFT JOIN content_bundles cb ON cb.id = er.content_bundle_id
WHERE e.event_public_id = $1
LIMIT 1
`, eventPublicID)
var event Event
err := row.Scan(
&event.ID,
&event.PublicID,
&event.Slug,
&event.DisplayName,
&event.Summary,
&event.Status,
&event.IsDefaultExperience,
&event.ShowInEventList,
&event.CurrentReleaseID,
&event.CurrentReleasePubID,
&event.ConfigLabel,
&event.ManifestURL,
&event.ManifestChecksum,
&event.RouteCode,
&event.ReleasePayloadJSON,
&event.RuntimeBindingID,
&event.PlacePublicID,
&event.PlaceName,
&event.MapAssetPublicID,
&event.MapAssetName,
&event.TileReleasePublicID,
&event.CourseSetPublicID,
&event.CourseVariantID,
&event.CourseVariantName,
&event.RuntimeRouteCode,
&event.PresentationID,
&event.PresentationName,
&event.PresentationType,
&event.ContentBundleID,
&event.ContentBundleName,
&event.ContentEntryURL,
&event.ContentAssetRootURL,
)
if errors.Is(err, pgx.ErrNoRows) {
return nil, nil
}
if err != nil {
return nil, fmt.Errorf("get event by public id: %w", err)
}
return &event, nil
}
func (s *Store) GetEventByID(ctx context.Context, eventID string) (*Event, error) {
row := s.pool.QueryRow(ctx, `
SELECT
e.id,
e.event_public_id,
e.slug,
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,
er.manifest_url,
er.manifest_checksum_sha256,
er.route_code,
er.payload_jsonb::text,
mrb.runtime_binding_public_id,
p.place_public_id,
p.name,
ma.map_asset_public_id,
ma.name,
tr.tile_release_public_id,
cset.course_set_public_id,
cv.course_variant_public_id,
cv.name,
cv.route_code,
ep.presentation_public_id,
ep.name,
ep.presentation_type,
cb.content_bundle_public_id,
cb.name,
cb.entry_url,
cb.asset_root_url
FROM events e
LEFT JOIN event_releases er ON er.id = e.current_release_id
LEFT JOIN map_runtime_bindings mrb ON mrb.id = er.runtime_binding_id
LEFT JOIN places p ON p.id = mrb.place_id
LEFT JOIN map_assets ma ON ma.id = mrb.map_asset_id
LEFT JOIN tile_releases tr ON tr.id = mrb.tile_release_id
LEFT JOIN course_sets cset ON cset.id = mrb.course_set_id
LEFT JOIN course_variants cv ON cv.id = mrb.course_variant_id
LEFT JOIN event_presentations ep ON ep.id = er.presentation_id
LEFT JOIN content_bundles cb ON cb.id = er.content_bundle_id
WHERE e.id = $1
LIMIT 1
`, eventID)
var event Event
err := row.Scan(
&event.ID,
&event.PublicID,
&event.Slug,
&event.DisplayName,
&event.Summary,
&event.Status,
&event.IsDefaultExperience,
&event.ShowInEventList,
&event.CurrentReleaseID,
&event.CurrentReleasePubID,
&event.ConfigLabel,
&event.ManifestURL,
&event.ManifestChecksum,
&event.RouteCode,
&event.ReleasePayloadJSON,
&event.RuntimeBindingID,
&event.PlacePublicID,
&event.PlaceName,
&event.MapAssetPublicID,
&event.MapAssetName,
&event.TileReleasePublicID,
&event.CourseSetPublicID,
&event.CourseVariantID,
&event.CourseVariantName,
&event.RuntimeRouteCode,
&event.PresentationID,
&event.PresentationName,
&event.PresentationType,
&event.ContentBundleID,
&event.ContentBundleName,
&event.ContentEntryURL,
&event.ContentAssetRootURL,
)
if errors.Is(err, pgx.ErrNoRows) {
return nil, nil
}
if err != nil {
return nil, fmt.Errorf("get event by id: %w", err)
}
return &event, nil
}
func (s *Store) NextEventReleaseNo(ctx context.Context, eventID string) (int, error) {
var next int
if err := s.pool.QueryRow(ctx, `
SELECT COALESCE(MAX(release_no), 0) + 1
FROM event_releases er
WHERE event_id = $1
`, eventID).Scan(&next); err != nil {
return 0, fmt.Errorf("next event release no: %w", err)
}
return next, nil
}
type CreateEventReleaseParams struct {
PublicID string
EventID string
ReleaseNo int
ConfigLabel string
ManifestURL string
ManifestChecksum *string
RouteCode *string
BuildID *string
RuntimeBindingID *string
PresentationID *string
ContentBundleID *string
Status string
PayloadJSON string
}
func (s *Store) CreateEventRelease(ctx context.Context, tx Tx, params CreateEventReleaseParams) (*EventRelease, error) {
row := tx.QueryRow(ctx, `
INSERT INTO event_releases (
release_public_id,
event_id,
release_no,
config_label,
manifest_url,
manifest_checksum_sha256,
route_code,
build_id,
runtime_binding_id,
presentation_id,
content_bundle_id,
status,
payload_jsonb
)
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13::jsonb)
RETURNING id, release_public_id, event_id, release_no, config_label, manifest_url, manifest_checksum_sha256, route_code, build_id, status, published_at
`, params.PublicID, params.EventID, params.ReleaseNo, params.ConfigLabel, params.ManifestURL, params.ManifestChecksum, params.RouteCode, params.BuildID, params.RuntimeBindingID, params.PresentationID, params.ContentBundleID, params.Status, params.PayloadJSON)
var item EventRelease
if err := row.Scan(
&item.ID,
&item.PublicID,
&item.EventID,
&item.ReleaseNo,
&item.ConfigLabel,
&item.ManifestURL,
&item.ManifestChecksum,
&item.RouteCode,
&item.BuildID,
&item.Status,
&item.PublishedAt,
); err != nil {
return nil, fmt.Errorf("create event release: %w", err)
}
return &item, nil
}
func (s *Store) SetCurrentEventRelease(ctx context.Context, tx Tx, eventID, releaseID string) error {
if _, err := tx.Exec(ctx, `
UPDATE events
SET current_release_id = $2
WHERE id = $1
`, eventID, releaseID); err != nil {
return fmt.Errorf("set current event release: %w", err)
}
return nil
}
func (s *Store) CreateGameSession(ctx context.Context, tx Tx, params CreateGameSessionParams) (*GameSession, error) {
row := tx.QueryRow(ctx, `
INSERT INTO game_sessions (
session_public_id,
user_id,
event_id,
event_release_id,
device_key,
client_type,
assignment_mode,
variant_id,
variant_name,
route_code,
session_token_hash,
session_token_expires_at
)
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12)
RETURNING id, session_public_id, user_id, event_id, event_release_id, device_key, client_type, assignment_mode, variant_id, variant_name, route_code, status, session_token_expires_at
`, params.SessionPublicID, params.UserID, params.EventID, params.EventReleaseID, params.DeviceKey, params.ClientType, params.AssignmentMode, params.VariantID, params.VariantName, params.RouteCode, params.SessionTokenHash, params.SessionTokenExpiresAt)
var session GameSession
err := row.Scan(
&session.ID,
&session.SessionPublicID,
&session.UserID,
&session.EventID,
&session.EventReleaseID,
&session.DeviceKey,
&session.ClientType,
&session.AssignmentMode,
&session.VariantID,
&session.VariantName,
&session.RouteCode,
&session.Status,
&session.SessionTokenExpiresAt,
)
if err != nil {
return nil, fmt.Errorf("create game session: %w", err)
}
return &session, nil
}
func (s *Store) ListEventReleasesByEventID(ctx context.Context, eventID string, limit int) ([]EventRelease, error) {
if limit <= 0 || limit > 100 {
limit = 20
}
rows, err := s.pool.Query(ctx, `
SELECT
er.id,
er.release_public_id,
er.event_id,
er.release_no,
er.config_label,
er.manifest_url,
er.manifest_checksum_sha256,
er.route_code,
er.build_id,
er.status,
er.published_at,
mrb.runtime_binding_public_id,
p.place_public_id,
p.name,
ma.map_asset_public_id,
ma.name,
tr.tile_release_public_id,
cset.course_set_public_id,
cv.course_variant_public_id,
cv.name,
cv.route_code,
ep.presentation_public_id,
ep.name,
ep.presentation_type,
cb.content_bundle_public_id,
cb.name,
cb.entry_url,
cb.asset_root_url
FROM event_releases er
LEFT JOIN map_runtime_bindings mrb ON mrb.id = er.runtime_binding_id
LEFT JOIN places p ON p.id = mrb.place_id
LEFT JOIN map_assets ma ON ma.id = mrb.map_asset_id
LEFT JOIN tile_releases tr ON tr.id = mrb.tile_release_id
LEFT JOIN course_sets cset ON cset.id = mrb.course_set_id
LEFT JOIN course_variants cv ON cv.id = mrb.course_variant_id
LEFT JOIN event_presentations ep ON ep.id = er.presentation_id
LEFT JOIN content_bundles cb ON cb.id = er.content_bundle_id
WHERE er.event_id = $1
ORDER BY er.release_no DESC
LIMIT $2
`, eventID, limit)
if err != nil {
return nil, fmt.Errorf("list event releases by event id: %w", err)
}
defer rows.Close()
items := []EventRelease{}
for rows.Next() {
item, err := scanEventReleaseFromRows(rows)
if err != nil {
return nil, err
}
items = append(items, *item)
}
if err := rows.Err(); err != nil {
return nil, fmt.Errorf("iterate event releases by event id: %w", err)
}
return items, nil
}
func (s *Store) GetEventReleaseByPublicID(ctx context.Context, releasePublicID string) (*EventRelease, error) {
row := s.pool.QueryRow(ctx, `
SELECT
er.id,
er.release_public_id,
er.event_id,
er.release_no,
er.config_label,
er.manifest_url,
er.manifest_checksum_sha256,
er.route_code,
er.build_id,
er.status,
er.published_at,
mrb.runtime_binding_public_id,
p.place_public_id,
p.name,
ma.map_asset_public_id,
ma.name,
tr.tile_release_public_id,
cset.course_set_public_id,
cv.course_variant_public_id,
cv.name,
cv.route_code,
ep.presentation_public_id,
ep.name,
ep.presentation_type,
cb.content_bundle_public_id,
cb.name,
cb.entry_url,
cb.asset_root_url
FROM event_releases er
LEFT JOIN map_runtime_bindings mrb ON mrb.id = er.runtime_binding_id
LEFT JOIN places p ON p.id = mrb.place_id
LEFT JOIN map_assets ma ON ma.id = mrb.map_asset_id
LEFT JOIN tile_releases tr ON tr.id = mrb.tile_release_id
LEFT JOIN course_sets cset ON cset.id = mrb.course_set_id
LEFT JOIN course_variants cv ON cv.id = mrb.course_variant_id
LEFT JOIN event_presentations ep ON ep.id = er.presentation_id
LEFT JOIN content_bundles cb ON cb.id = er.content_bundle_id
WHERE er.release_public_id = $1
LIMIT 1
`, releasePublicID)
var item EventRelease
err := row.Scan(
&item.ID,
&item.PublicID,
&item.EventID,
&item.ReleaseNo,
&item.ConfigLabel,
&item.ManifestURL,
&item.ManifestChecksum,
&item.RouteCode,
&item.BuildID,
&item.Status,
&item.PublishedAt,
&item.RuntimeBindingID,
&item.PlacePublicID,
&item.PlaceName,
&item.MapAssetPublicID,
&item.MapAssetName,
&item.TileReleaseID,
&item.CourseSetID,
&item.CourseVariantID,
&item.CourseVariantName,
&item.RuntimeRouteCode,
&item.PresentationID,
&item.PresentationName,
&item.PresentationType,
&item.ContentBundleID,
&item.ContentBundleName,
&item.ContentEntryURL,
&item.ContentAssetURL,
)
if errors.Is(err, pgx.ErrNoRows) {
return nil, nil
}
if err != nil {
return nil, fmt.Errorf("get event release by public id: %w", err)
}
return &item, nil
}
func scanEventReleaseFromRows(rows pgx.Rows) (*EventRelease, error) {
var item EventRelease
err := rows.Scan(
&item.ID,
&item.PublicID,
&item.EventID,
&item.ReleaseNo,
&item.ConfigLabel,
&item.ManifestURL,
&item.ManifestChecksum,
&item.RouteCode,
&item.BuildID,
&item.Status,
&item.PublishedAt,
&item.RuntimeBindingID,
&item.PlacePublicID,
&item.PlaceName,
&item.MapAssetPublicID,
&item.MapAssetName,
&item.TileReleaseID,
&item.CourseSetID,
&item.CourseVariantID,
&item.CourseVariantName,
&item.RuntimeRouteCode,
&item.PresentationID,
&item.PresentationName,
&item.PresentationType,
&item.ContentBundleID,
&item.ContentBundleName,
&item.ContentEntryURL,
&item.ContentAssetURL,
)
if err != nil {
return nil, fmt.Errorf("scan event release row: %w", err)
}
return &item, nil
}
func (s *Store) SetEventReleaseRuntimeBinding(ctx context.Context, tx Tx, releaseID string, runtimeBindingID *string) error {
if _, err := tx.Exec(ctx, `
UPDATE event_releases
SET runtime_binding_id = $2
WHERE id = $1
`, releaseID, runtimeBindingID); err != nil {
return fmt.Errorf("set event release runtime binding: %w", err)
}
return nil
}
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
}

View File

@@ -1,50 +0,0 @@
package postgres
import (
"context"
"fmt"
)
type LoginIdentity struct {
ID string
IdentityType string
Provider string
ProviderSubject string
CountryCode *string
Mobile *string
Status string
}
func (s *Store) ListIdentitiesByUserID(ctx context.Context, userID string) ([]LoginIdentity, error) {
rows, err := s.pool.Query(ctx, `
SELECT id, identity_type, provider, provider_subject, country_code, mobile, status
FROM login_identities
WHERE user_id = $1
ORDER BY created_at ASC
`, userID)
if err != nil {
return nil, fmt.Errorf("list identities by user id: %w", err)
}
defer rows.Close()
var identities []LoginIdentity
for rows.Next() {
var identity LoginIdentity
if err := rows.Scan(
&identity.ID,
&identity.IdentityType,
&identity.Provider,
&identity.ProviderSubject,
&identity.CountryCode,
&identity.Mobile,
&identity.Status,
); err != nil {
return nil, fmt.Errorf("scan identity: %w", err)
}
identities = append(identities, identity)
}
if err := rows.Err(); err != nil {
return nil, fmt.Errorf("iterate identities: %w", err)
}
return identities, nil
}

View File

@@ -1,216 +0,0 @@
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
}

View File

@@ -1,217 +0,0 @@
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
}

View File

@@ -1,66 +0,0 @@
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
}

File diff suppressed because it is too large Load Diff

View File

@@ -1,660 +0,0 @@
package postgres
import (
"context"
"encoding/json"
"errors"
"fmt"
"time"
"github.com/jackc/pgx/v5"
)
type ResourceMap struct {
ID string
PublicID string
Code string
Name string
Status string
Description *string
CurrentVersionID *string
CreatedAt time.Time
UpdatedAt time.Time
}
type ResourceMapVersion struct {
ID string
PublicID string
MapID string
VersionCode string
Status string
MapmetaURL string
TilesRootURL string
PublishedAssetRoot *string
BoundsJSON json.RawMessage
MetadataJSON json.RawMessage
CreatedAt time.Time
UpdatedAt time.Time
}
type ResourcePlayfield struct {
ID string
PublicID string
Code string
Name string
Kind string
Status string
Description *string
CurrentVersionID *string
CreatedAt time.Time
UpdatedAt time.Time
}
type ResourcePlayfieldVersion struct {
ID string
PublicID string
PlayfieldID string
VersionCode string
Status string
SourceType string
SourceURL string
PublishedAssetRoot *string
ControlCount *int
BoundsJSON json.RawMessage
MetadataJSON json.RawMessage
CreatedAt time.Time
UpdatedAt time.Time
}
type ResourcePack struct {
ID string
PublicID string
Code string
Name string
Status string
Description *string
CurrentVersionID *string
CreatedAt time.Time
UpdatedAt time.Time
}
type ResourcePackVersion struct {
ID string
PublicID string
ResourcePackID string
VersionCode string
Status string
ContentEntryURL *string
AudioRootURL *string
ThemeProfileCode *string
PublishedAssetRoot *string
MetadataJSON json.RawMessage
CreatedAt time.Time
UpdatedAt time.Time
}
type CreateResourceMapParams struct {
PublicID string
Code string
Name string
Status string
Description *string
}
type CreateResourceMapVersionParams struct {
PublicID string
MapID string
VersionCode string
Status string
MapmetaURL string
TilesRootURL string
PublishedAssetRoot *string
BoundsJSON map[string]any
MetadataJSON map[string]any
}
type CreateResourcePlayfieldParams struct {
PublicID string
Code string
Name string
Kind string
Status string
Description *string
}
type CreateResourcePlayfieldVersionParams struct {
PublicID string
PlayfieldID string
VersionCode string
Status string
SourceType string
SourceURL string
PublishedAssetRoot *string
ControlCount *int
BoundsJSON map[string]any
MetadataJSON map[string]any
}
type CreateResourcePackParams struct {
PublicID string
Code string
Name string
Status string
Description *string
}
type CreateResourcePackVersionParams struct {
PublicID string
ResourcePackID string
VersionCode string
Status string
ContentEntryURL *string
AudioRootURL *string
ThemeProfileCode *string
PublishedAssetRoot *string
MetadataJSON map[string]any
}
func (s *Store) ListResourceMaps(ctx context.Context, limit int) ([]ResourceMap, error) {
if limit <= 0 || limit > 200 {
limit = 50
}
rows, err := s.pool.Query(ctx, `
SELECT id, map_public_id, code, name, status, description, current_version_id, created_at, updated_at
FROM maps
ORDER BY created_at DESC
LIMIT $1
`, limit)
if err != nil {
return nil, fmt.Errorf("list resource maps: %w", err)
}
defer rows.Close()
items := []ResourceMap{}
for rows.Next() {
item, err := scanResourceMapFromRows(rows)
if err != nil {
return nil, err
}
items = append(items, *item)
}
if err := rows.Err(); err != nil {
return nil, fmt.Errorf("iterate resource maps: %w", err)
}
return items, nil
}
func (s *Store) GetResourceMapByPublicID(ctx context.Context, publicID string) (*ResourceMap, error) {
row := s.pool.QueryRow(ctx, `
SELECT id, map_public_id, code, name, status, description, current_version_id, created_at, updated_at
FROM maps
WHERE map_public_id = $1
LIMIT 1
`, publicID)
return scanResourceMap(row)
}
func (s *Store) CreateResourceMap(ctx context.Context, tx Tx, params CreateResourceMapParams) (*ResourceMap, error) {
row := tx.QueryRow(ctx, `
INSERT INTO maps (map_public_id, code, name, status, description)
VALUES ($1, $2, $3, $4, $5)
RETURNING id, map_public_id, code, name, status, description, current_version_id, created_at, updated_at
`, params.PublicID, params.Code, params.Name, params.Status, params.Description)
return scanResourceMap(row)
}
func (s *Store) ListResourceMapVersions(ctx context.Context, mapID string) ([]ResourceMapVersion, error) {
rows, err := s.pool.Query(ctx, `
SELECT id, version_public_id, map_id, version_code, status, mapmeta_url, tiles_root_url, published_asset_root,
bounds_jsonb::text, metadata_jsonb::text, created_at, updated_at
FROM map_versions
WHERE map_id = $1
ORDER BY created_at DESC
`, mapID)
if err != nil {
return nil, fmt.Errorf("list resource map versions: %w", err)
}
defer rows.Close()
items := []ResourceMapVersion{}
for rows.Next() {
item, err := scanResourceMapVersionFromRows(rows)
if err != nil {
return nil, err
}
items = append(items, *item)
}
if err := rows.Err(); err != nil {
return nil, fmt.Errorf("iterate resource map versions: %w", err)
}
return items, nil
}
func (s *Store) GetResourceMapVersionByPublicID(ctx context.Context, mapPublicID, versionPublicID string) (*ResourceMapVersion, error) {
row := s.pool.QueryRow(ctx, `
SELECT mv.id, mv.version_public_id, mv.map_id, mv.version_code, mv.status, mv.mapmeta_url, mv.tiles_root_url, mv.published_asset_root,
mv.bounds_jsonb::text, mv.metadata_jsonb::text, mv.created_at, mv.updated_at
FROM map_versions mv
JOIN maps m ON m.id = mv.map_id
WHERE m.map_public_id = $1
AND mv.version_public_id = $2
LIMIT 1
`, mapPublicID, versionPublicID)
return scanResourceMapVersion(row)
}
func (s *Store) CreateResourceMapVersion(ctx context.Context, tx Tx, params CreateResourceMapVersionParams) (*ResourceMapVersion, error) {
boundsJSON, err := marshalJSONMap(params.BoundsJSON)
if err != nil {
return nil, fmt.Errorf("marshal map bounds: %w", err)
}
metadataJSON, err := marshalJSONMap(params.MetadataJSON)
if err != nil {
return nil, fmt.Errorf("marshal map metadata: %w", err)
}
row := tx.QueryRow(ctx, `
INSERT INTO map_versions (
version_public_id, map_id, version_code, status, mapmeta_url, tiles_root_url,
published_asset_root, bounds_jsonb, metadata_jsonb
)
VALUES ($1, $2, $3, $4, $5, $6, $7, $8::jsonb, $9::jsonb)
RETURNING id, version_public_id, map_id, version_code, status, mapmeta_url, tiles_root_url, published_asset_root,
bounds_jsonb::text, metadata_jsonb::text, created_at, updated_at
`, params.PublicID, params.MapID, params.VersionCode, params.Status, params.MapmetaURL, params.TilesRootURL, params.PublishedAssetRoot, boundsJSON, metadataJSON)
return scanResourceMapVersion(row)
}
func (s *Store) SetResourceMapCurrentVersion(ctx context.Context, tx Tx, mapID, versionID string) error {
_, err := tx.Exec(ctx, `UPDATE maps SET current_version_id = $2 WHERE id = $1`, mapID, versionID)
if err != nil {
return fmt.Errorf("set resource map current version: %w", err)
}
return nil
}
func (s *Store) ListResourcePlayfields(ctx context.Context, limit int) ([]ResourcePlayfield, error) {
if limit <= 0 || limit > 200 {
limit = 50
}
rows, err := s.pool.Query(ctx, `
SELECT id, playfield_public_id, code, name, kind, status, description, current_version_id, created_at, updated_at
FROM playfields
ORDER BY created_at DESC
LIMIT $1
`, limit)
if err != nil {
return nil, fmt.Errorf("list resource playfields: %w", err)
}
defer rows.Close()
items := []ResourcePlayfield{}
for rows.Next() {
item, err := scanResourcePlayfieldFromRows(rows)
if err != nil {
return nil, err
}
items = append(items, *item)
}
if err := rows.Err(); err != nil {
return nil, fmt.Errorf("iterate resource playfields: %w", err)
}
return items, nil
}
func (s *Store) GetResourcePlayfieldByPublicID(ctx context.Context, publicID string) (*ResourcePlayfield, error) {
row := s.pool.QueryRow(ctx, `
SELECT id, playfield_public_id, code, name, kind, status, description, current_version_id, created_at, updated_at
FROM playfields
WHERE playfield_public_id = $1
LIMIT 1
`, publicID)
return scanResourcePlayfield(row)
}
func (s *Store) CreateResourcePlayfield(ctx context.Context, tx Tx, params CreateResourcePlayfieldParams) (*ResourcePlayfield, error) {
row := tx.QueryRow(ctx, `
INSERT INTO playfields (playfield_public_id, code, name, kind, status, description)
VALUES ($1, $2, $3, $4, $5, $6)
RETURNING id, playfield_public_id, code, name, kind, status, description, current_version_id, created_at, updated_at
`, params.PublicID, params.Code, params.Name, params.Kind, params.Status, params.Description)
return scanResourcePlayfield(row)
}
func (s *Store) ListResourcePlayfieldVersions(ctx context.Context, playfieldID string) ([]ResourcePlayfieldVersion, error) {
rows, err := s.pool.Query(ctx, `
SELECT id, version_public_id, playfield_id, version_code, status, source_type, source_url, published_asset_root,
control_count, bounds_jsonb::text, metadata_jsonb::text, created_at, updated_at
FROM playfield_versions
WHERE playfield_id = $1
ORDER BY created_at DESC
`, playfieldID)
if err != nil {
return nil, fmt.Errorf("list resource playfield versions: %w", err)
}
defer rows.Close()
items := []ResourcePlayfieldVersion{}
for rows.Next() {
item, err := scanResourcePlayfieldVersionFromRows(rows)
if err != nil {
return nil, err
}
items = append(items, *item)
}
if err := rows.Err(); err != nil {
return nil, fmt.Errorf("iterate resource playfield versions: %w", err)
}
return items, nil
}
func (s *Store) GetResourcePlayfieldVersionByPublicID(ctx context.Context, playfieldPublicID, versionPublicID string) (*ResourcePlayfieldVersion, error) {
row := s.pool.QueryRow(ctx, `
SELECT pv.id, pv.version_public_id, pv.playfield_id, pv.version_code, pv.status, pv.source_type, pv.source_url, pv.published_asset_root,
pv.control_count, pv.bounds_jsonb::text, pv.metadata_jsonb::text, pv.created_at, pv.updated_at
FROM playfield_versions pv
JOIN playfields p ON p.id = pv.playfield_id
WHERE p.playfield_public_id = $1
AND pv.version_public_id = $2
LIMIT 1
`, playfieldPublicID, versionPublicID)
return scanResourcePlayfieldVersion(row)
}
func (s *Store) CreateResourcePlayfieldVersion(ctx context.Context, tx Tx, params CreateResourcePlayfieldVersionParams) (*ResourcePlayfieldVersion, error) {
boundsJSON, err := marshalJSONMap(params.BoundsJSON)
if err != nil {
return nil, fmt.Errorf("marshal playfield bounds: %w", err)
}
metadataJSON, err := marshalJSONMap(params.MetadataJSON)
if err != nil {
return nil, fmt.Errorf("marshal playfield metadata: %w", err)
}
row := tx.QueryRow(ctx, `
INSERT INTO playfield_versions (
version_public_id, playfield_id, version_code, status, source_type, source_url,
published_asset_root, control_count, bounds_jsonb, metadata_jsonb
)
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9::jsonb, $10::jsonb)
RETURNING id, version_public_id, playfield_id, version_code, status, source_type, source_url, published_asset_root,
control_count, bounds_jsonb::text, metadata_jsonb::text, created_at, updated_at
`, params.PublicID, params.PlayfieldID, params.VersionCode, params.Status, params.SourceType, params.SourceURL, params.PublishedAssetRoot, params.ControlCount, boundsJSON, metadataJSON)
return scanResourcePlayfieldVersion(row)
}
func (s *Store) SetResourcePlayfieldCurrentVersion(ctx context.Context, tx Tx, playfieldID, versionID string) error {
_, err := tx.Exec(ctx, `UPDATE playfields SET current_version_id = $2 WHERE id = $1`, playfieldID, versionID)
if err != nil {
return fmt.Errorf("set resource playfield current version: %w", err)
}
return nil
}
func (s *Store) ListResourcePacks(ctx context.Context, limit int) ([]ResourcePack, error) {
if limit <= 0 || limit > 200 {
limit = 50
}
rows, err := s.pool.Query(ctx, `
SELECT id, resource_pack_public_id, code, name, status, description, current_version_id, created_at, updated_at
FROM resource_packs
ORDER BY created_at DESC
LIMIT $1
`, limit)
if err != nil {
return nil, fmt.Errorf("list resource packs: %w", err)
}
defer rows.Close()
items := []ResourcePack{}
for rows.Next() {
item, err := scanResourcePackFromRows(rows)
if err != nil {
return nil, err
}
items = append(items, *item)
}
if err := rows.Err(); err != nil {
return nil, fmt.Errorf("iterate resource packs: %w", err)
}
return items, nil
}
func (s *Store) GetResourcePackByPublicID(ctx context.Context, publicID string) (*ResourcePack, error) {
row := s.pool.QueryRow(ctx, `
SELECT id, resource_pack_public_id, code, name, status, description, current_version_id, created_at, updated_at
FROM resource_packs
WHERE resource_pack_public_id = $1
LIMIT 1
`, publicID)
return scanResourcePack(row)
}
func (s *Store) CreateResourcePack(ctx context.Context, tx Tx, params CreateResourcePackParams) (*ResourcePack, error) {
row := tx.QueryRow(ctx, `
INSERT INTO resource_packs (resource_pack_public_id, code, name, status, description)
VALUES ($1, $2, $3, $4, $5)
RETURNING id, resource_pack_public_id, code, name, status, description, current_version_id, created_at, updated_at
`, params.PublicID, params.Code, params.Name, params.Status, params.Description)
return scanResourcePack(row)
}
func (s *Store) ListResourcePackVersions(ctx context.Context, resourcePackID string) ([]ResourcePackVersion, error) {
rows, err := s.pool.Query(ctx, `
SELECT id, version_public_id, resource_pack_id, version_code, status, content_entry_url, audio_root_url,
theme_profile_code, published_asset_root, metadata_jsonb::text, created_at, updated_at
FROM resource_pack_versions
WHERE resource_pack_id = $1
ORDER BY created_at DESC
`, resourcePackID)
if err != nil {
return nil, fmt.Errorf("list resource pack versions: %w", err)
}
defer rows.Close()
items := []ResourcePackVersion{}
for rows.Next() {
item, err := scanResourcePackVersionFromRows(rows)
if err != nil {
return nil, err
}
items = append(items, *item)
}
if err := rows.Err(); err != nil {
return nil, fmt.Errorf("iterate resource pack versions: %w", err)
}
return items, nil
}
func (s *Store) GetResourcePackVersionByPublicID(ctx context.Context, resourcePackPublicID, versionPublicID string) (*ResourcePackVersion, error) {
row := s.pool.QueryRow(ctx, `
SELECT pv.id, pv.version_public_id, pv.resource_pack_id, pv.version_code, pv.status, pv.content_entry_url, pv.audio_root_url,
pv.theme_profile_code, pv.published_asset_root, pv.metadata_jsonb::text, pv.created_at, pv.updated_at
FROM resource_pack_versions pv
JOIN resource_packs rp ON rp.id = pv.resource_pack_id
WHERE rp.resource_pack_public_id = $1
AND pv.version_public_id = $2
LIMIT 1
`, resourcePackPublicID, versionPublicID)
return scanResourcePackVersion(row)
}
func (s *Store) CreateResourcePackVersion(ctx context.Context, tx Tx, params CreateResourcePackVersionParams) (*ResourcePackVersion, error) {
metadataJSON, err := marshalJSONMap(params.MetadataJSON)
if err != nil {
return nil, fmt.Errorf("marshal resource pack metadata: %w", err)
}
row := tx.QueryRow(ctx, `
INSERT INTO resource_pack_versions (
version_public_id, resource_pack_id, version_code, status, content_entry_url,
audio_root_url, theme_profile_code, published_asset_root, metadata_jsonb
)
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9::jsonb)
RETURNING id, version_public_id, resource_pack_id, version_code, status, content_entry_url, audio_root_url,
theme_profile_code, published_asset_root, metadata_jsonb::text, created_at, updated_at
`, params.PublicID, params.ResourcePackID, params.VersionCode, params.Status, params.ContentEntryURL, params.AudioRootURL, params.ThemeProfileCode, params.PublishedAssetRoot, metadataJSON)
return scanResourcePackVersion(row)
}
func (s *Store) SetResourcePackCurrentVersion(ctx context.Context, tx Tx, resourcePackID, versionID string) error {
_, err := tx.Exec(ctx, `UPDATE resource_packs SET current_version_id = $2 WHERE id = $1`, resourcePackID, versionID)
if err != nil {
return fmt.Errorf("set resource pack current version: %w", err)
}
return nil
}
func scanResourceMap(row pgx.Row) (*ResourceMap, error) {
var item ResourceMap
err := row.Scan(&item.ID, &item.PublicID, &item.Code, &item.Name, &item.Status, &item.Description, &item.CurrentVersionID, &item.CreatedAt, &item.UpdatedAt)
if errors.Is(err, pgx.ErrNoRows) {
return nil, nil
}
if err != nil {
return nil, fmt.Errorf("scan resource map: %w", err)
}
return &item, nil
}
func scanResourceMapFromRows(rows pgx.Rows) (*ResourceMap, error) {
var item ResourceMap
err := rows.Scan(&item.ID, &item.PublicID, &item.Code, &item.Name, &item.Status, &item.Description, &item.CurrentVersionID, &item.CreatedAt, &item.UpdatedAt)
if err != nil {
return nil, fmt.Errorf("scan resource map row: %w", err)
}
return &item, nil
}
func scanResourceMapVersion(row pgx.Row) (*ResourceMapVersion, error) {
var item ResourceMapVersion
var boundsJSON string
var metadataJSON string
err := row.Scan(&item.ID, &item.PublicID, &item.MapID, &item.VersionCode, &item.Status, &item.MapmetaURL, &item.TilesRootURL, &item.PublishedAssetRoot, &boundsJSON, &metadataJSON, &item.CreatedAt, &item.UpdatedAt)
if errors.Is(err, pgx.ErrNoRows) {
return nil, nil
}
if err != nil {
return nil, fmt.Errorf("scan resource map version: %w", err)
}
item.BoundsJSON = json.RawMessage(boundsJSON)
item.MetadataJSON = json.RawMessage(metadataJSON)
return &item, nil
}
func scanResourceMapVersionFromRows(rows pgx.Rows) (*ResourceMapVersion, error) {
var item ResourceMapVersion
var boundsJSON string
var metadataJSON string
err := rows.Scan(&item.ID, &item.PublicID, &item.MapID, &item.VersionCode, &item.Status, &item.MapmetaURL, &item.TilesRootURL, &item.PublishedAssetRoot, &boundsJSON, &metadataJSON, &item.CreatedAt, &item.UpdatedAt)
if err != nil {
return nil, fmt.Errorf("scan resource map version row: %w", err)
}
item.BoundsJSON = json.RawMessage(boundsJSON)
item.MetadataJSON = json.RawMessage(metadataJSON)
return &item, nil
}
func scanResourcePlayfield(row pgx.Row) (*ResourcePlayfield, error) {
var item ResourcePlayfield
err := row.Scan(&item.ID, &item.PublicID, &item.Code, &item.Name, &item.Kind, &item.Status, &item.Description, &item.CurrentVersionID, &item.CreatedAt, &item.UpdatedAt)
if errors.Is(err, pgx.ErrNoRows) {
return nil, nil
}
if err != nil {
return nil, fmt.Errorf("scan resource playfield: %w", err)
}
return &item, nil
}
func scanResourcePlayfieldFromRows(rows pgx.Rows) (*ResourcePlayfield, error) {
var item ResourcePlayfield
err := rows.Scan(&item.ID, &item.PublicID, &item.Code, &item.Name, &item.Kind, &item.Status, &item.Description, &item.CurrentVersionID, &item.CreatedAt, &item.UpdatedAt)
if err != nil {
return nil, fmt.Errorf("scan resource playfield row: %w", err)
}
return &item, nil
}
func scanResourcePlayfieldVersion(row pgx.Row) (*ResourcePlayfieldVersion, error) {
var item ResourcePlayfieldVersion
var boundsJSON string
var metadataJSON string
err := row.Scan(&item.ID, &item.PublicID, &item.PlayfieldID, &item.VersionCode, &item.Status, &item.SourceType, &item.SourceURL, &item.PublishedAssetRoot, &item.ControlCount, &boundsJSON, &metadataJSON, &item.CreatedAt, &item.UpdatedAt)
if errors.Is(err, pgx.ErrNoRows) {
return nil, nil
}
if err != nil {
return nil, fmt.Errorf("scan resource playfield version: %w", err)
}
item.BoundsJSON = json.RawMessage(boundsJSON)
item.MetadataJSON = json.RawMessage(metadataJSON)
return &item, nil
}
func scanResourcePlayfieldVersionFromRows(rows pgx.Rows) (*ResourcePlayfieldVersion, error) {
var item ResourcePlayfieldVersion
var boundsJSON string
var metadataJSON string
err := rows.Scan(&item.ID, &item.PublicID, &item.PlayfieldID, &item.VersionCode, &item.Status, &item.SourceType, &item.SourceURL, &item.PublishedAssetRoot, &item.ControlCount, &boundsJSON, &metadataJSON, &item.CreatedAt, &item.UpdatedAt)
if err != nil {
return nil, fmt.Errorf("scan resource playfield version row: %w", err)
}
item.BoundsJSON = json.RawMessage(boundsJSON)
item.MetadataJSON = json.RawMessage(metadataJSON)
return &item, nil
}
func scanResourcePack(row pgx.Row) (*ResourcePack, error) {
var item ResourcePack
err := row.Scan(&item.ID, &item.PublicID, &item.Code, &item.Name, &item.Status, &item.Description, &item.CurrentVersionID, &item.CreatedAt, &item.UpdatedAt)
if errors.Is(err, pgx.ErrNoRows) {
return nil, nil
}
if err != nil {
return nil, fmt.Errorf("scan resource pack: %w", err)
}
return &item, nil
}
func scanResourcePackFromRows(rows pgx.Rows) (*ResourcePack, error) {
var item ResourcePack
err := rows.Scan(&item.ID, &item.PublicID, &item.Code, &item.Name, &item.Status, &item.Description, &item.CurrentVersionID, &item.CreatedAt, &item.UpdatedAt)
if err != nil {
return nil, fmt.Errorf("scan resource pack row: %w", err)
}
return &item, nil
}
func scanResourcePackVersion(row pgx.Row) (*ResourcePackVersion, error) {
var item ResourcePackVersion
var metadataJSON string
err := row.Scan(&item.ID, &item.PublicID, &item.ResourcePackID, &item.VersionCode, &item.Status, &item.ContentEntryURL, &item.AudioRootURL, &item.ThemeProfileCode, &item.PublishedAssetRoot, &metadataJSON, &item.CreatedAt, &item.UpdatedAt)
if errors.Is(err, pgx.ErrNoRows) {
return nil, nil
}
if err != nil {
return nil, fmt.Errorf("scan resource pack version: %w", err)
}
item.MetadataJSON = json.RawMessage(metadataJSON)
return &item, nil
}
func scanResourcePackVersionFromRows(rows pgx.Rows) (*ResourcePackVersion, error) {
var item ResourcePackVersion
var metadataJSON string
err := rows.Scan(&item.ID, &item.PublicID, &item.ResourcePackID, &item.VersionCode, &item.Status, &item.ContentEntryURL, &item.AudioRootURL, &item.ThemeProfileCode, &item.PublishedAssetRoot, &metadataJSON, &item.CreatedAt, &item.UpdatedAt)
if err != nil {
return nil, fmt.Errorf("scan resource pack version row: %w", err)
}
item.MetadataJSON = json.RawMessage(metadataJSON)
return &item, nil
}
func marshalJSONMap(value map[string]any) (string, error) {
if value == nil {
value = map[string]any{}
}
raw, err := json.Marshal(value)
if err != nil {
return "", err
}
return string(raw), nil
}

Some files were not shown because too many files have changed in this diff Show More