Compare commits
1 Commits
codex/repo
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
| 71e1866e99 |
13
.gitignore
vendored
13
.gitignore
vendored
@@ -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
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -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
500
b2b.md
@@ -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
283
b2f.md
@@ -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
425
b2t.md
@@ -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`
|
||||
- 不开新战线,只做收口、稳定、验证。
|
||||
@@ -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
|
||||
@@ -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`
|
||||
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
|
||||
|
||||
@@ -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 第一阶段最小契约定稳,再继续向配置与后台模型延伸。
|
||||
|
||||
|
||||
@@ -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 P0:launch 进入地图
|
||||
|
||||
可接:
|
||||
|
||||
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 P0:finish 回传结果
|
||||
|
||||
可接:
|
||||
|
||||
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 两个适配器。
|
||||
|
||||
|
||||
@@ -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 发布 的最小运营后台。
|
||||
|
||||
@@ -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。
|
||||
|
||||
|
||||
1971
backend/docs/接口清单.md
1971
backend/docs/接口清单.md
File diff suppressed because it is too large
Load Diff
@@ -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,不要先拍脑袋建大而全表。
|
||||
|
||||
|
||||
@@ -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`
|
||||
|
||||
业务接口必须保持统一,终端差异只进入上下文,不进入对象模型分叉。
|
||||
|
||||
|
||||
@@ -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 直接暴露给实时网关。
|
||||
|
||||
|
||||
@@ -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,而不是做成“小程序跑通后再重构”的临时结构。
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
这样以后无论你配置项怎么继续长,主架构都还能撑住。
|
||||
|
||||
|
||||
@@ -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
|
||||
)
|
||||
@@ -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=
|
||||
@@ -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()
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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})
|
||||
}
|
||||
@@ -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})
|
||||
}
|
||||
@@ -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})
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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,
|
||||
},
|
||||
})
|
||||
}
|
||||
@@ -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
@@ -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})
|
||||
}
|
||||
@@ -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})
|
||||
}
|
||||
@@ -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})
|
||||
}
|
||||
@@ -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})
|
||||
}
|
||||
@@ -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",
|
||||
},
|
||||
})
|
||||
}
|
||||
@@ -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,
|
||||
}
|
||||
}
|
||||
@@ -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})
|
||||
}
|
||||
@@ -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})
|
||||
}
|
||||
@@ -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})
|
||||
}
|
||||
@@ -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
@@ -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})
|
||||
}
|
||||
@@ -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})
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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})
|
||||
}
|
||||
@@ -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})
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
@@ -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
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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 ©Entry, 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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -1,9 +0,0 @@
|
||||
package service
|
||||
|
||||
import "time"
|
||||
|
||||
const timeRFC3339 = time.RFC3339
|
||||
|
||||
func nowUTC() time.Time {
|
||||
return time.Now().UTC()
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
@@ -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
Reference in New Issue
Block a user