Add backend foundation and config-driven workbench
This commit is contained in:
20
backend/.env.example
Normal file
20
backend/.env.example
Normal file
@@ -0,0 +1,20 @@
|
||||
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
|
||||
43
backend/README.md
Normal file
43
backend/README.md
Normal file
@@ -0,0 +1,43 @@
|
||||
# Backend
|
||||
|
||||
这套后端现在已经能支撑一条完整主链:
|
||||
|
||||
`entry -> auth -> home/cards -> event play -> launch -> session -> result`
|
||||
|
||||
并且已经按“配置驱动游戏”收口:
|
||||
|
||||
- 业务对象是 `event`
|
||||
- 运行配置对象是 `event_release`
|
||||
- 真正进入游戏时客户端消费的是 `manifest_url`
|
||||
- `session` 会固化当时实际绑定的 `release`
|
||||
|
||||
## 文档导航
|
||||
|
||||
- [文档索引](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)
|
||||
|
||||
## 快速启动
|
||||
|
||||
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
|
||||
go run .\cmd\api
|
||||
```
|
||||
|
||||
## 当前重点
|
||||
|
||||
- 统一登录:短信 + 微信小程序
|
||||
- 多入口:`tenant + entry_channel`
|
||||
- 首页聚合:`/home`、`/cards`、`/me/entry-home`
|
||||
- 配置驱动启动:`/events/{id}/play`、`/events/{id}/launch`
|
||||
- 局生命周期:`start / finish / detail`
|
||||
- 局后结果:`/sessions/{id}/result`、`/me/results`
|
||||
- 开发工作台:`/dev/workbench`
|
||||
55
backend/cmd/api/main.go
Normal file
55
backend/cmd/api/main.go
Normal file
@@ -0,0 +1,55 @@
|
||||
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)
|
||||
}
|
||||
}
|
||||
54
backend/docs/README.md
Normal file
54
backend/docs/README.md
Normal file
@@ -0,0 +1,54 @@
|
||||
# Backend Docs
|
||||
|
||||
这套文档服务两个目的:
|
||||
|
||||
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. [TodoList](D:/dev/cmr-mini/backend/docs/todolist.md)
|
||||
8. [开发说明](D:/dev/cmr-mini/backend/docs/开发说明.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)
|
||||
255
backend/docs/todolist.md
Normal file
255
backend/docs/todolist.md
Normal file
@@ -0,0 +1,255 @@
|
||||
# Backend TodoList
|
||||
|
||||
## 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 现在最重要的不是再扩散接口,而是把当前契约和语义收稳。
|
||||
|
||||
## 3. P0 必做
|
||||
|
||||
## 3.1 固定 session 状态语义
|
||||
|
||||
需要 backend 明确并固定:
|
||||
|
||||
- `finished`
|
||||
- `failed`
|
||||
- `cancelled`
|
||||
|
||||
建议当前口径:
|
||||
|
||||
- 正常打终点完成:`finished`
|
||||
- 超时结束:`failed`
|
||||
- 主动退出 / 放弃恢复:`cancelled`
|
||||
|
||||
说明:
|
||||
|
||||
- 小程序现在已经按这个方向接
|
||||
- 如果 backend 想改这 3 个状态语义,需要先讨论,不要单边改
|
||||
|
||||
## 3.2 明确“放弃恢复”的后端处理
|
||||
|
||||
这是当前最值得后端配合确认的一点。
|
||||
|
||||
当前小程序本地恢复逻辑已经是:
|
||||
|
||||
- 进入程序检测到未正常结束对局
|
||||
- 弹确认框
|
||||
- 玩家可“继续恢复”或“放弃”
|
||||
|
||||
现在本地“放弃”只会清除本地恢复快照。
|
||||
|
||||
backend 需要确认的目标语义是:
|
||||
|
||||
> 玩家点击“放弃恢复”后,这一局是否应同时在业务后端标记为 `cancelled`。
|
||||
|
||||
我建议 backend 采用:
|
||||
|
||||
- **是,应标记为 `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`
|
||||
|
||||
备注:
|
||||
|
||||
- 如果 backend 认可这套语义,小程序侧下一步就可以把“点击放弃恢复”改成同步调用 `finish(cancelled)`。
|
||||
|
||||
## 3.3 保证 start / finish 幂等与重复调用安全
|
||||
|
||||
联调和真实环境里,以下情况很常见:
|
||||
|
||||
- 网络重试
|
||||
- 页面重进
|
||||
- 故障恢复后二次补报
|
||||
- 用户重复点击
|
||||
|
||||
backend 需要确认:
|
||||
|
||||
- `start` 重复调用的幂等语义
|
||||
- `finish` 重复调用的幂等语义
|
||||
|
||||
建议:
|
||||
|
||||
- `start`:如果已 `running`,返回当前 session,视为成功
|
||||
- `finish`:如果已进入终态,返回当前 session/result,视为成功
|
||||
|
||||
目的:
|
||||
|
||||
- 不把客户端补偿逻辑变成一堆冲突分支
|
||||
|
||||
## 3.4 固定 `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 现在需要做的是:
|
||||
|
||||
- 先保持这些字段名稳定
|
||||
- 如果要调整命名或层级,先沟通
|
||||
|
||||
## 4. P1 应尽快做
|
||||
|
||||
## 4.1 增加用户身体资料读取接口
|
||||
|
||||
小程序侧已经有:
|
||||
|
||||
- telemetry profile 合并入口
|
||||
- 心率/卡路里计算逻辑
|
||||
|
||||
backend 下一步建议提供:
|
||||
|
||||
- 当前用户 body profile 查询接口
|
||||
|
||||
建议返回至少包含:
|
||||
|
||||
- `birthDate` 或 `heartRateAge`
|
||||
- `weightKg`
|
||||
- `restingHeartRateBpm`
|
||||
- `maxHeartRateBpm`(可选)
|
||||
|
||||
这样后面心率页和消耗估算就能真实接业务数据。
|
||||
|
||||
## 4.2 给 `session result` 补一点稳定摘要字段校验
|
||||
|
||||
客户端现在会上报:
|
||||
|
||||
- `finalDurationSec`
|
||||
- `finalScore`
|
||||
- `completedControls`
|
||||
- `totalControls`
|
||||
- `distanceMeters`
|
||||
- `averageSpeedKmh`
|
||||
|
||||
backend 建议补两件事:
|
||||
|
||||
- 合理性校验
|
||||
- 空值容忍
|
||||
|
||||
不要因为某个可选字段缺失就整局 finish 失败。
|
||||
|
||||
## 4.3 dev workbench 增加一组“恢复 / 取消恢复”场景按钮
|
||||
|
||||
当前 workbench 已经很好用了。
|
||||
|
||||
建议后续再补:
|
||||
|
||||
- 标记 session 为 `cancelled`
|
||||
- 查询 ongoing session
|
||||
- 快速查看某个用户最新 session 状态
|
||||
|
||||
这会很适合配合小程序故障恢复联调。
|
||||
|
||||
## 5. P2 下一阶段
|
||||
|
||||
## 5.1 配置后台 source / build / release 真正开始做
|
||||
|
||||
当前已经有:
|
||||
|
||||
- 表结构
|
||||
- 架构文档
|
||||
|
||||
还缺:
|
||||
|
||||
- source CRUD
|
||||
- build 触发
|
||||
- manifest 产物生成
|
||||
- release 发布
|
||||
- asset index 查询
|
||||
|
||||
这个建议在当前主链联稳之后再推进。
|
||||
|
||||
## 5.2 page / cards / competition 等业务对象继续长出来
|
||||
|
||||
这部分不是当前联调阻塞项,但后面会成为业务壳的重要组成。
|
||||
|
||||
## 6. 需要先讨论再动的边界
|
||||
|
||||
这些事项 backend 不建议自己先拍板:
|
||||
|
||||
### 6.1 `failed` 是否专指超时
|
||||
|
||||
当前建议是:
|
||||
|
||||
- 超时 -> `failed`
|
||||
- 主动退出 / 放弃恢复 -> `cancelled`
|
||||
|
||||
如果 backend 有别的语义方案,需要先统一。
|
||||
|
||||
### 6.2 放弃恢复是否一定写后端
|
||||
|
||||
我个人建议写后端,并落成 `cancelled`。
|
||||
|
||||
但如果 backend 团队认为:
|
||||
|
||||
- 放弃恢复只影响本地
|
||||
- 业务上仍允许以后继续从服务端 ongoing session 恢复
|
||||
|
||||
那就必须明确告知客户端,不然两边会冲突。
|
||||
|
||||
### 6.3 result 页是以后继续本地展示,还是跳业务结果页
|
||||
|
||||
当前客户端是本地结果页。
|
||||
|
||||
backend 后面如果要接业务结果页,最好提前定:
|
||||
|
||||
- finish 成功后是否仍停留地图内结果页
|
||||
- 还是跳业务壳结果页
|
||||
|
||||
## 7. 我建议的最近动作
|
||||
|
||||
backend 现在最值得先做的,不是扩接口,而是先确认下面 3 条:
|
||||
|
||||
1. `finished / failed / cancelled` 三态语义
|
||||
2. 放弃恢复是否写 `cancelled`
|
||||
3. `start / finish` 是否按幂等处理
|
||||
|
||||
这 3 条一旦确定,前后端联调会顺很多。
|
||||
|
||||
## 8. 一句话结论
|
||||
|
||||
当前 backend 最重要的任务不是“再加更多接口”,而是:
|
||||
|
||||
> 先把 session 运行态语义和故障恢复放弃语义定稳,再继续扩后台配置系统。
|
||||
369
backend/docs/前后端联调清单.md
Normal file
369
backend/docs/前后端联调清单.md
Normal file
@@ -0,0 +1,369 @@
|
||||
# 前后端联调清单
|
||||
|
||||
## 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 两个适配器。
|
||||
182
backend/docs/开发说明.md
Normal file
182
backend/docs/开发说明.md
Normal file
@@ -0,0 +1,182 @@
|
||||
# 开发说明
|
||||
|
||||
## 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`
|
||||
- `LOCAL_EVENT_DIR`
|
||||
- `ASSET_BASE_URL`
|
||||
|
||||
## 2. 本地启动
|
||||
|
||||
```powershell
|
||||
cd D:\dev\cmr-mini\backend
|
||||
go run .\cmd\api
|
||||
```
|
||||
|
||||
如果你想固定跑开发工作台常用端口 `18090`,直接执行:
|
||||
|
||||
```powershell
|
||||
cd D:\dev\cmr-mini\backend
|
||||
.\scripts\start-dev.ps1
|
||||
```
|
||||
|
||||
默认会设置:
|
||||
|
||||
- `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)
|
||||
|
||||
## 3. 当前开发约定
|
||||
|
||||
### 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
|
||||
|
||||
## 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
|
||||
|
||||
并且支持:
|
||||
|
||||
- quick flow
|
||||
- scenario 保存/导入/导出
|
||||
- curl 导出
|
||||
- request history
|
||||
|
||||
## 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`
|
||||
|
||||
## 7. 当前后续开发建议
|
||||
|
||||
文档整理完之后,后面建议按这个顺序继续:
|
||||
|
||||
1. 抽出更通用的 `play context -> launch` 模型
|
||||
2. 补赛事与报名层
|
||||
3. 补页面配置和白标首页
|
||||
4. 再考虑实时网关票据
|
||||
|
||||
不要跳回去把玩法规则塞进 backend。
|
||||
425
backend/docs/接口清单.md
Normal file
425
backend/docs/接口清单.md
Normal file
@@ -0,0 +1,425 @@
|
||||
# API 清单
|
||||
|
||||
本文档只记录当前 backend 已实现接口,不写未来规划接口。
|
||||
|
||||
## 1. Health
|
||||
|
||||
### `GET /healthz`
|
||||
|
||||
用途:
|
||||
|
||||
- 健康检查
|
||||
|
||||
## 2. Auth
|
||||
|
||||
### `POST /auth/sms/send`
|
||||
|
||||
用途:
|
||||
|
||||
- 发登录验证码
|
||||
- 发绑定手机号验证码
|
||||
|
||||
核心参数:
|
||||
|
||||
- `countryCode`
|
||||
- `mobile`
|
||||
- `clientType`
|
||||
- `deviceKey`
|
||||
- `scene`
|
||||
|
||||
### `POST /auth/login/sms`
|
||||
|
||||
用途:
|
||||
|
||||
- APP 手机号验证码登录
|
||||
|
||||
返回重点:
|
||||
|
||||
- `user`
|
||||
- `tokens.accessToken`
|
||||
- `tokens.refreshToken`
|
||||
|
||||
### `POST /auth/login/wechat-mini`
|
||||
|
||||
用途:
|
||||
|
||||
- 微信小程序登录
|
||||
|
||||
开发态:
|
||||
|
||||
- 支持 `dev-` 前缀 code
|
||||
|
||||
### `POST /auth/bind/mobile`
|
||||
|
||||
鉴权:
|
||||
|
||||
- Bearer token
|
||||
|
||||
用途:
|
||||
|
||||
- 已登录用户绑定手机号
|
||||
- 必要时执行账号合并
|
||||
|
||||
### `POST /auth/refresh`
|
||||
|
||||
用途:
|
||||
|
||||
- 刷新 access token
|
||||
|
||||
### `POST /auth/logout`
|
||||
|
||||
用途:
|
||||
|
||||
- 撤销 refresh token
|
||||
|
||||
## 3. Entry / Home
|
||||
|
||||
### `GET /entry/resolve`
|
||||
|
||||
用途:
|
||||
|
||||
- 解析当前入口归属哪个 tenant / channel
|
||||
|
||||
查询参数:
|
||||
|
||||
- `channelCode`
|
||||
- `channelType`
|
||||
- `platformAppId`
|
||||
- `tenantCode`
|
||||
|
||||
### `GET /home`
|
||||
|
||||
用途:
|
||||
|
||||
- 返回入口首页卡片
|
||||
|
||||
### `GET /cards`
|
||||
|
||||
用途:
|
||||
|
||||
- 只返回卡片列表
|
||||
|
||||
### `GET /me/entry-home`
|
||||
|
||||
鉴权:
|
||||
|
||||
- Bearer token
|
||||
|
||||
用途:
|
||||
|
||||
- 首页聚合接口
|
||||
|
||||
返回重点:
|
||||
|
||||
- `user`
|
||||
- `tenant`
|
||||
- `channel`
|
||||
- `cards`
|
||||
- `ongoingSession`
|
||||
- `recentSession`
|
||||
|
||||
## 4. Event
|
||||
|
||||
### `GET /events/{eventPublicID}`
|
||||
|
||||
用途:
|
||||
|
||||
- Event 详情
|
||||
|
||||
返回重点:
|
||||
|
||||
- `event`
|
||||
- `release`
|
||||
- `resolvedRelease`
|
||||
|
||||
### `GET /events/{eventPublicID}/play`
|
||||
|
||||
鉴权:
|
||||
|
||||
- Bearer token
|
||||
|
||||
用途:
|
||||
|
||||
- 活动详情页 / 开始前准备页聚合
|
||||
|
||||
返回重点:
|
||||
|
||||
- `event`
|
||||
- `release`
|
||||
- `resolvedRelease`
|
||||
- `play.canLaunch`
|
||||
- `play.primaryAction`
|
||||
- `play.launchSource`
|
||||
- `play.ongoingSession`
|
||||
- `play.recentSession`
|
||||
|
||||
### `POST /events/{eventPublicID}/launch`
|
||||
|
||||
鉴权:
|
||||
|
||||
- Bearer token
|
||||
|
||||
用途:
|
||||
|
||||
- 基于当前 event 的可启动 release 创建一局 session
|
||||
|
||||
请求体重点:
|
||||
|
||||
- `releaseId`
|
||||
- `clientType`
|
||||
- `deviceKey`
|
||||
|
||||
返回重点:
|
||||
|
||||
- `launch.source`
|
||||
- `launch.resolvedRelease`
|
||||
- `launch.config`
|
||||
- `launch.business.sessionId`
|
||||
- `launch.business.sessionToken`
|
||||
|
||||
### `GET /events/{eventPublicID}/config-sources`
|
||||
|
||||
鉴权:
|
||||
|
||||
- Bearer token
|
||||
|
||||
用途:
|
||||
|
||||
- 查看某个 event 的 source config 列表
|
||||
|
||||
### `GET /config-sources/{sourceID}`
|
||||
|
||||
鉴权:
|
||||
|
||||
- Bearer token
|
||||
|
||||
用途:
|
||||
|
||||
- 查看单条 source config 明细
|
||||
|
||||
### `GET /config-builds/{buildID}`
|
||||
|
||||
鉴权:
|
||||
|
||||
- Bearer token
|
||||
|
||||
用途:
|
||||
|
||||
- 查看单次 build 明细
|
||||
|
||||
## 5. Session
|
||||
|
||||
### `GET /sessions/{sessionPublicID}`
|
||||
|
||||
鉴权:
|
||||
|
||||
- Bearer token
|
||||
|
||||
用途:
|
||||
|
||||
- 查询一局详情
|
||||
|
||||
返回重点:
|
||||
|
||||
- `session`
|
||||
- `event`
|
||||
- `resolvedRelease`
|
||||
|
||||
### `POST /sessions/{sessionPublicID}/start`
|
||||
|
||||
鉴权:
|
||||
|
||||
- `sessionToken`
|
||||
|
||||
用途:
|
||||
|
||||
- 将 session 从 `launched` 推进到 `running`
|
||||
|
||||
### `POST /sessions/{sessionPublicID}/finish`
|
||||
|
||||
鉴权:
|
||||
|
||||
- `sessionToken`
|
||||
|
||||
用途:
|
||||
|
||||
- 结束一局
|
||||
- 同时沉淀结果摘要
|
||||
|
||||
请求体重点:
|
||||
|
||||
- `sessionToken`
|
||||
- `status`
|
||||
- `summary.finalDurationSec`
|
||||
- `summary.finalScore`
|
||||
- `summary.completedControls`
|
||||
- `summary.totalControls`
|
||||
- `summary.distanceMeters`
|
||||
- `summary.averageSpeedKmh`
|
||||
- `summary.maxHeartRateBpm`
|
||||
|
||||
### `GET /me/sessions`
|
||||
|
||||
鉴权:
|
||||
|
||||
- Bearer token
|
||||
|
||||
用途:
|
||||
|
||||
- 查询用户最近 session
|
||||
|
||||
## 6. Result
|
||||
|
||||
### `GET /sessions/{sessionPublicID}/result`
|
||||
|
||||
鉴权:
|
||||
|
||||
- Bearer token
|
||||
|
||||
用途:
|
||||
|
||||
- 查询单局结果页数据
|
||||
|
||||
返回重点:
|
||||
|
||||
- `session`
|
||||
- `result`
|
||||
|
||||
`session` 中会带:
|
||||
|
||||
- `releaseId`
|
||||
- `configLabel`
|
||||
|
||||
### `GET /me/results`
|
||||
|
||||
鉴权:
|
||||
|
||||
- Bearer token
|
||||
|
||||
用途:
|
||||
|
||||
- 查询用户最近结果列表
|
||||
|
||||
## 7. Profile
|
||||
|
||||
### `GET /me`
|
||||
|
||||
鉴权:
|
||||
|
||||
- Bearer token
|
||||
|
||||
用途:
|
||||
|
||||
- 当前用户基础信息
|
||||
|
||||
### `GET /me/profile`
|
||||
|
||||
鉴权:
|
||||
|
||||
- Bearer token
|
||||
|
||||
用途:
|
||||
|
||||
- “我的页”聚合接口
|
||||
|
||||
返回重点:
|
||||
|
||||
- `user`
|
||||
- `bindings`
|
||||
- `recentSessions`
|
||||
|
||||
## 8. Dev
|
||||
|
||||
### `POST /dev/bootstrap-demo`
|
||||
|
||||
环境:
|
||||
|
||||
- 仅 non-production
|
||||
|
||||
用途:
|
||||
|
||||
- 自动准备 demo tenant / channel / event / release / card
|
||||
|
||||
### `GET /dev/workbench`
|
||||
|
||||
环境:
|
||||
|
||||
- 仅 non-production
|
||||
|
||||
用途:
|
||||
|
||||
- 后端自带 API 测试面板
|
||||
|
||||
当前支持:
|
||||
|
||||
- bootstrap
|
||||
- auth
|
||||
- entry/home
|
||||
- event/play/launch
|
||||
- session start/finish/detail
|
||||
- result 查询
|
||||
- profile 查询
|
||||
- quick flows
|
||||
- scenarios
|
||||
- request history
|
||||
- curl 导出
|
||||
|
||||
### `GET /dev/config/local-files`
|
||||
|
||||
环境:
|
||||
|
||||
- 仅 non-production
|
||||
|
||||
用途:
|
||||
|
||||
- 列出本地配置目录中的 JSON 文件
|
||||
|
||||
### `POST /dev/events/{eventPublicID}/config-sources/import-local`
|
||||
|
||||
环境:
|
||||
|
||||
- 仅 non-production
|
||||
|
||||
用途:
|
||||
|
||||
- 从本地配置目录导入 source config
|
||||
|
||||
请求体重点:
|
||||
|
||||
- `fileName`
|
||||
- `notes`
|
||||
|
||||
### `POST /dev/config-builds/preview`
|
||||
|
||||
环境:
|
||||
|
||||
- 仅 non-production
|
||||
|
||||
用途:
|
||||
|
||||
- 基于 source config 生成 preview build
|
||||
|
||||
请求体重点:
|
||||
|
||||
- `sourceId`
|
||||
|
||||
### `POST /dev/config-builds/publish`
|
||||
|
||||
环境:
|
||||
|
||||
- 仅 non-production
|
||||
|
||||
用途:
|
||||
|
||||
- 将成功的 preview build 发布成正式 release
|
||||
- 自动切换 `event.current_release_id`
|
||||
|
||||
请求体重点:
|
||||
|
||||
- `buildId`
|
||||
|
||||
返回重点:
|
||||
|
||||
- `release.releaseId`
|
||||
- `release.manifestUrl`
|
||||
- `release.configLabel`
|
||||
171
backend/docs/数据模型.md
Normal file
171
backend/docs/数据模型.md
Normal file
@@ -0,0 +1,171 @@
|
||||
# 数据模型
|
||||
|
||||
当前 migration 共 5 版。
|
||||
|
||||
## 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)
|
||||
|
||||
## 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`
|
||||
|
||||
### 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 维度展示控制
|
||||
|
||||
### 2.5 运行态
|
||||
|
||||
- `game_sessions`
|
||||
- `session_results`
|
||||
|
||||
职责:
|
||||
|
||||
- 固化一局游戏
|
||||
- 固化该局绑定的 release
|
||||
- 固化局后结果摘要
|
||||
|
||||
### 2.6 配置构建与发布资产
|
||||
|
||||
- `event_config_sources`
|
||||
- `event_config_builds`
|
||||
- `event_release_assets`
|
||||
|
||||
职责:
|
||||
|
||||
- 保存编辑态 source config
|
||||
- 保存构建后的 manifest 和 asset index
|
||||
- 保存正式 release 关联的资产清单
|
||||
|
||||
## 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 是发布态
|
||||
|
||||
## 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,不要先拍脑袋建大而全表。
|
||||
204
backend/docs/核心流程.md
Normal file
204
backend/docs/核心流程.md
Normal file
@@ -0,0 +1,204 @@
|
||||
# 核心流程
|
||||
|
||||
## 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"]
|
||||
```
|
||||
|
||||
## 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`
|
||||
|
||||
### 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`
|
||||
- 是否有 ongoing session
|
||||
- 当前推荐动作是什么
|
||||
|
||||
当前聚合接口:
|
||||
|
||||
- `GET /events/{eventPublicID}/play`
|
||||
|
||||
它会返回:
|
||||
|
||||
- `event`
|
||||
- `release`
|
||||
- `resolvedRelease`
|
||||
- `play.canLaunch`
|
||||
- `play.primaryAction`
|
||||
- `play.launchSource`
|
||||
- `play.ongoingSession`
|
||||
- `play.recentSession`
|
||||
|
||||
## 6. Launch 流程
|
||||
|
||||
### 6.1 当前原则
|
||||
|
||||
启动一局游戏时,不是“启动一个 event”。
|
||||
|
||||
而是:
|
||||
|
||||
> 基于 event 当前可启动的 release,创建一条固化 release 的 session。
|
||||
|
||||
### 6.2 当前接口
|
||||
|
||||
- `POST /events/{eventPublicID}/launch`
|
||||
|
||||
当前请求体支持:
|
||||
|
||||
- `releaseId`
|
||||
- `clientType`
|
||||
- `deviceKey`
|
||||
|
||||
当前返回会带:
|
||||
|
||||
- `launch.source`
|
||||
- `launch.resolvedRelease`
|
||||
- `launch.config`
|
||||
- `launch.business.sessionId`
|
||||
- `launch.business.sessionToken`
|
||||
|
||||
### 6.3 客户端应如何使用
|
||||
|
||||
客户端进入游戏前,应以返回中的这几项为准:
|
||||
|
||||
- `launch.resolvedRelease.releaseId`
|
||||
- `launch.resolvedRelease.manifestUrl`
|
||||
- `launch.resolvedRelease.manifestChecksumSha256`
|
||||
|
||||
而不是再拿 `event` 自己去猜。
|
||||
|
||||
## 7. Session 流程
|
||||
|
||||
### 7.1 当前接口
|
||||
|
||||
- `GET /sessions/{sessionPublicID}`
|
||||
- `POST /sessions/{sessionPublicID}/start`
|
||||
- `POST /sessions/{sessionPublicID}/finish`
|
||||
- `GET /me/sessions`
|
||||
|
||||
### 7.2 鉴权模型
|
||||
|
||||
查询接口:
|
||||
|
||||
- 用 `access_token`
|
||||
|
||||
局内动作接口:
|
||||
|
||||
- 用 `sessionToken`
|
||||
|
||||
这保证了业务登录态和一局游戏运行态是分开的。
|
||||
|
||||
## 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
|
||||
|
||||
## 9. 当前最应该坚持的流程约束
|
||||
|
||||
业务主线应始终保持为:
|
||||
|
||||
`entry -> auth -> event play -> resolve release -> launch -> session -> result`
|
||||
|
||||
不要退回成:
|
||||
|
||||
`event -> launch -> game`
|
||||
200
backend/docs/系统架构.md
Normal file
200
backend/docs/系统架构.md
Normal file
@@ -0,0 +1,200 @@
|
||||
# 系统架构
|
||||
|
||||
## 1. 目标
|
||||
|
||||
当前 backend 不是一个“给地图页喂数据的简单服务”,而是一个业务壳后端。
|
||||
|
||||
它负责:
|
||||
|
||||
- 用户与登录
|
||||
- 多租户与多入口
|
||||
- 首页与业务入口聚合
|
||||
- Event 业务对象
|
||||
- 配置发布解析
|
||||
- 启动一局游戏
|
||||
- session 生命周期
|
||||
- 结果沉淀
|
||||
|
||||
它不负责:
|
||||
|
||||
- 解释游戏玩法细节
|
||||
- 运行时解析复杂地图规则
|
||||
- 直接下发数据库编辑态对象给客户端
|
||||
|
||||
## 2. 分层
|
||||
|
||||
### 2.1 平台层
|
||||
|
||||
平台层统一处理:
|
||||
|
||||
- `tenant`
|
||||
- `entry_channel`
|
||||
- `user`
|
||||
- `login_identity`
|
||||
- `auth_refresh_token`
|
||||
|
||||
这层是整个平台共用能力。
|
||||
|
||||
### 2.2 业务层
|
||||
|
||||
业务层统一处理:
|
||||
|
||||
- `card`
|
||||
- `event`
|
||||
- `event_play`
|
||||
- `entry_home`
|
||||
- `profile`
|
||||
|
||||
它面向页面和运营入口,但不直接承载游戏规则。
|
||||
|
||||
### 2.3 配置发布层
|
||||
|
||||
配置发布层统一处理:
|
||||
|
||||
- `event_release`
|
||||
- `manifest_url`
|
||||
- `manifest_checksum_sha256`
|
||||
- `route_code`
|
||||
|
||||
这层是“客户端真正进入游戏时要消费的运行配置入口”。
|
||||
|
||||
### 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
|
||||
|
||||
## 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`
|
||||
- 解析运行配置
|
||||
- 驱动地图和玩法
|
||||
- 产生过程数据和结束摘要
|
||||
|
||||
### 6.3 后续网关该怎么接
|
||||
|
||||
后面如果接实时网关,建议仍然走:
|
||||
|
||||
- backend 负责登录与 launch
|
||||
- launch 或 session 负责产出短期实时票据
|
||||
- 网关只认 backend 签发的运行态票据
|
||||
|
||||
不要把微信身份或业务 token 直接暴露给实时网关。
|
||||
412
backend/docs/配置管理方案.md
Normal file
412
backend/docs/配置管理方案.md
Normal file
@@ -0,0 +1,412 @@
|
||||
# 配置管理方案
|
||||
|
||||
## 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
|
||||
|
||||
这样以后无论你配置项怎么继续长,主架构都还能撑住。
|
||||
17
backend/go.mod
Normal file
17
backend/go.mod
Normal file
@@ -0,0 +1,17 @@
|
||||
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
|
||||
)
|
||||
30
backend/go.sum
Normal file
30
backend/go.sum
Normal file
@@ -0,0 +1,30 @@
|
||||
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=
|
||||
64
backend/internal/app/app.go
Normal file
64
backend/internal/app/app.go
Normal file
@@ -0,0 +1,64 @@
|
||||
package app
|
||||
|
||||
import (
|
||||
"context"
|
||||
"net/http"
|
||||
|
||||
"cmr-backend/internal/httpapi"
|
||||
"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)
|
||||
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)
|
||||
eventService := service.NewEventService(store)
|
||||
eventPlayService := service.NewEventPlayService(store)
|
||||
configService := service.NewConfigService(store, cfg.LocalEventDir, cfg.AssetBaseURL)
|
||||
homeService := service.NewHomeService(store)
|
||||
profileService := service.NewProfileService(store)
|
||||
resultService := service.NewResultService(store)
|
||||
sessionService := service.NewSessionService(store)
|
||||
devService := service.NewDevService(cfg.AppEnv, store)
|
||||
meService := service.NewMeService(store)
|
||||
router := httpapi.NewRouter(cfg.AppEnv, jwtManager, authService, entryService, entryHomeService, eventService, eventPlayService, configService, homeService, 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()
|
||||
}
|
||||
}
|
||||
73
backend/internal/app/config.go
Normal file
73
backend/internal/app/config.go
Normal file
@@ -0,0 +1,73 @@
|
||||
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
|
||||
}
|
||||
|
||||
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"),
|
||||
}
|
||||
|
||||
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
|
||||
}
|
||||
29
backend/internal/apperr/apperr.go
Normal file
29
backend/internal/apperr/apperr.go
Normal file
@@ -0,0 +1,29 @@
|
||||
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
|
||||
}
|
||||
129
backend/internal/httpapi/handlers/auth_handler.go
Normal file
129
backend/internal/httpapi/handlers/auth_handler.go
Normal file
@@ -0,0 +1,129 @@
|
||||
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,
|
||||
},
|
||||
})
|
||||
}
|
||||
107
backend/internal/httpapi/handlers/config_handler.go
Normal file
107
backend/internal/httpapi/handlers/config_handler.go
Normal file
@@ -0,0 +1,107 @@
|
||||
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})
|
||||
}
|
||||
1588
backend/internal/httpapi/handlers/dev_handler.go
Normal file
1588
backend/internal/httpapi/handlers/dev_handler.go
Normal file
File diff suppressed because it is too large
Load Diff
31
backend/internal/httpapi/handlers/entry_handler.go
Normal file
31
backend/internal/httpapi/handlers/entry_handler.go
Normal file
@@ -0,0 +1,31 @@
|
||||
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})
|
||||
}
|
||||
40
backend/internal/httpapi/handlers/entry_home_handler.go
Normal file
40
backend/internal/httpapi/handlers/entry_home_handler.go
Normal file
@@ -0,0 +1,40 @@
|
||||
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})
|
||||
}
|
||||
51
backend/internal/httpapi/handlers/event_handler.go
Normal file
51
backend/internal/httpapi/handlers/event_handler.go
Normal file
@@ -0,0 +1,51 @@
|
||||
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})
|
||||
}
|
||||
37
backend/internal/httpapi/handlers/event_play_handler.go
Normal file
37
backend/internal/httpapi/handlers/event_play_handler.go
Normal file
@@ -0,0 +1,37 @@
|
||||
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})
|
||||
}
|
||||
21
backend/internal/httpapi/handlers/health_handler.go
Normal file
21
backend/internal/httpapi/handlers/health_handler.go
Normal file
@@ -0,0 +1,21 @@
|
||||
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",
|
||||
},
|
||||
})
|
||||
}
|
||||
53
backend/internal/httpapi/handlers/home_handler.go
Normal file
53
backend/internal/httpapi/handlers/home_handler.go
Normal file
@@ -0,0 +1,53 @@
|
||||
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,
|
||||
}
|
||||
}
|
||||
34
backend/internal/httpapi/handlers/me_handler.go
Normal file
34
backend/internal/httpapi/handlers/me_handler.go
Normal file
@@ -0,0 +1,34 @@
|
||||
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})
|
||||
}
|
||||
34
backend/internal/httpapi/handlers/profile_handler.go
Normal file
34
backend/internal/httpapi/handlers/profile_handler.go
Normal file
@@ -0,0 +1,34 @@
|
||||
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})
|
||||
}
|
||||
58
backend/internal/httpapi/handlers/result_handler.go
Normal file
58
backend/internal/httpapi/handlers/result_handler.go
Normal file
@@ -0,0 +1,58 @@
|
||||
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})
|
||||
}
|
||||
88
backend/internal/httpapi/handlers/session_handler.go
Normal file
88
backend/internal/httpapi/handlers/session_handler.go
Normal file
@@ -0,0 +1,88 @@
|
||||
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})
|
||||
}
|
||||
50
backend/internal/httpapi/middleware/auth.go
Normal file
50
backend/internal/httpapi/middleware/auth.go
Normal file
@@ -0,0 +1,50 @@
|
||||
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
|
||||
}
|
||||
|
||||
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
|
||||
}
|
||||
|
||||
ctx := context.WithValue(r.Context(), authKey, &AuthContext{
|
||||
UserID: claims.UserID,
|
||||
UserPublicID: claims.UserPublicID,
|
||||
})
|
||||
next.ServeHTTP(w, r.WithContext(ctx))
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func GetAuthContext(ctx context.Context) *AuthContext {
|
||||
auth, _ := ctx.Value(authKey).(*AuthContext)
|
||||
return auth
|
||||
}
|
||||
80
backend/internal/httpapi/router.go
Normal file
80
backend/internal/httpapi/router.go
Normal file
@@ -0,0 +1,80 @@
|
||||
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,
|
||||
entryService *service.EntryService,
|
||||
entryHomeService *service.EntryHomeService,
|
||||
eventService *service.EventService,
|
||||
eventPlayService *service.EventPlayService,
|
||||
configService *service.ConfigService,
|
||||
homeService *service.HomeService,
|
||||
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)
|
||||
entryHandler := handlers.NewEntryHandler(entryService)
|
||||
entryHomeHandler := handlers.NewEntryHomeHandler(entryHomeService)
|
||||
eventHandler := handlers.NewEventHandler(eventService)
|
||||
eventPlayHandler := handlers.NewEventPlayHandler(eventPlayService)
|
||||
configHandler := handlers.NewConfigHandler(configService)
|
||||
homeHandler := handlers.NewHomeHandler(homeService)
|
||||
profileHandler := handlers.NewProfileHandler(profileService)
|
||||
resultHandler := handlers.NewResultHandler(resultService)
|
||||
sessionHandler := handlers.NewSessionHandler(sessionService)
|
||||
devHandler := handlers.NewDevHandler(devService)
|
||||
meHandler := handlers.NewMeHandler(meService)
|
||||
authMiddleware := middleware.NewAuthMiddleware(jwtManager)
|
||||
|
||||
mux.HandleFunc("GET /healthz", healthHandler.Get)
|
||||
mux.HandleFunc("GET /home", homeHandler.GetHome)
|
||||
mux.HandleFunc("GET /cards", homeHandler.GetCards)
|
||||
mux.HandleFunc("GET /entry/resolve", entryHandler.Resolve)
|
||||
if appEnv != "production" {
|
||||
mux.HandleFunc("GET /dev/workbench", devHandler.Workbench)
|
||||
mux.HandleFunc("POST /dev/bootstrap-demo", devHandler.BootstrapDemo)
|
||||
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.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.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)))
|
||||
|
||||
return mux
|
||||
}
|
||||
39
backend/internal/httpx/httpx.go
Normal file
39
backend/internal/httpx/httpx.go
Normal file
@@ -0,0 +1,39 @@
|
||||
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)
|
||||
}
|
||||
67
backend/internal/platform/jwtx/jwt.go
Normal file
67
backend/internal/platform/jwtx/jwt.go
Normal file
@@ -0,0 +1,67 @@
|
||||
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"`
|
||||
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) {
|
||||
expiresAt := time.Now().UTC().Add(m.ttl)
|
||||
claims := AccessClaims{
|
||||
UserID: userID,
|
||||
UserPublicID: userPublicID,
|
||||
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
|
||||
}
|
||||
47
backend/internal/platform/security/token.go
Normal file
47
backend/internal/platform/security/token.go
Normal file
@@ -0,0 +1,47 @@
|
||||
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
|
||||
}
|
||||
120
backend/internal/platform/wechatmini/client.go
Normal file
120
backend/internal/platform/wechatmini/client.go
Normal file
@@ -0,0 +1,120 @@
|
||||
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
|
||||
}
|
||||
595
backend/internal/service/auth_service.go
Normal file
595
backend/internal/service/auth_service.go
Normal file
@@ -0,0 +1,595 @@
|
||||
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
|
||||
}
|
||||
678
backend/internal/service/config_service.go
Normal file
678
backend/internal/service/config_service.go
Normal file
@@ -0,0 +1,678 @@
|
||||
package service
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"sort"
|
||||
"strings"
|
||||
|
||||
"cmr-backend/internal/apperr"
|
||||
"cmr-backend/internal/platform/security"
|
||||
"cmr-backend/internal/store/postgres"
|
||||
)
|
||||
|
||||
type ConfigService struct {
|
||||
store *postgres.Store
|
||||
localEventDir string
|
||||
assetBaseURL string
|
||||
}
|
||||
|
||||
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"`
|
||||
}
|
||||
|
||||
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"`
|
||||
}
|
||||
|
||||
func NewConfigService(store *postgres.Store, localEventDir, assetBaseURL string) *ConfigService {
|
||||
return &ConfigService{
|
||||
store: store,
|
||||
localEventDir: localEventDir,
|
||||
assetBaseURL: strings.TrimRight(assetBaseURL, "/"),
|
||||
}
|
||||
}
|
||||
|
||||
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")
|
||||
}
|
||||
|
||||
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)
|
||||
checksum := security.HashText(buildRecord.ManifestJSON)
|
||||
routeCode := deriveRouteCode(manifest)
|
||||
|
||||
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,
|
||||
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, &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),
|
||||
}, 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 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"},
|
||||
},
|
||||
}
|
||||
|
||||
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
|
||||
}
|
||||
32
backend/internal/service/dev_service.go
Normal file
32
backend/internal/service/dev_service.go
Normal file
@@ -0,0 +1,32 @@
|
||||
package service
|
||||
|
||||
import (
|
||||
"context"
|
||||
"net/http"
|
||||
|
||||
"cmr-backend/internal/apperr"
|
||||
"cmr-backend/internal/store/postgres"
|
||||
)
|
||||
|
||||
type DevService struct {
|
||||
appEnv string
|
||||
store *postgres.Store
|
||||
}
|
||||
|
||||
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)
|
||||
}
|
||||
164
backend/internal/service/entry_home_service.go
Normal file
164
backend/internal/service/entry_home_service.go
Normal file
@@ -0,0 +1,164 @@
|
||||
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"`
|
||||
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 sessions[i].Status == "launched" || sessions[i].Status == "running" {
|
||||
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,
|
||||
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
|
||||
}
|
||||
79
backend/internal/service/entry_service.go
Normal file
79
backend/internal/service/entry_service.go
Normal file
@@ -0,0 +1,79 @@
|
||||
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
|
||||
}
|
||||
131
backend/internal/service/event_play_service.go
Normal file
131
backend/internal/service/event_play_service.go
Normal file
@@ -0,0 +1,131 @@
|
||||
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"`
|
||||
Play struct {
|
||||
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
|
||||
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)
|
||||
|
||||
if len(sessions) > 0 {
|
||||
recent := buildEntrySessionSummary(&sessions[0])
|
||||
result.Play.RecentSession = &recent
|
||||
}
|
||||
for i := range sessions {
|
||||
if sessions[i].Status == "launched" || sessions[i].Status == "running" {
|
||||
ongoing := buildEntrySessionSummary(&sessions[i])
|
||||
result.Play.OngoingSession = &ongoing
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
canLaunch := event.Status == "active" && event.CurrentReleaseID != nil && event.ManifestURL != nil
|
||||
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 = "event is active and launchable"
|
||||
case result.Play.RecentSession != nil:
|
||||
result.Play.PrimaryAction = "review_last_result"
|
||||
result.Play.Reason = "event is not launchable, but user has previous session history"
|
||||
default:
|
||||
result.Play.PrimaryAction = "unavailable"
|
||||
result.Play.Reason = "event is not launchable"
|
||||
}
|
||||
|
||||
return result, nil
|
||||
}
|
||||
195
backend/internal/service/event_service.go
Normal file
195
backend/internal/service/event_service.go
Normal file
@@ -0,0 +1,195 @@
|
||||
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"`
|
||||
}
|
||||
|
||||
type LaunchEventInput struct {
|
||||
EventPublicID string
|
||||
UserID string
|
||||
ReleaseID string `json:"releaseId,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"`
|
||||
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"`
|
||||
} `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)
|
||||
|
||||
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.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 event.Status != "active" {
|
||||
return nil, apperr.New(http.StatusConflict, "event_not_launchable", "event is not active")
|
||||
}
|
||||
if event.CurrentReleaseID == nil || event.CurrentReleasePubID == nil || event.ConfigLabel == nil || event.ManifestURL == nil {
|
||||
return nil, apperr.New(http.StatusConflict, "event_release_missing", "event does not have a published release")
|
||||
}
|
||||
if input.ReleaseID != "" && input.ReleaseID != *event.CurrentReleasePubID {
|
||||
return nil, apperr.New(http.StatusConflict, "release_not_launchable", "requested release is not the current published release")
|
||||
}
|
||||
|
||||
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,
|
||||
RouteCode: event.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.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 = event.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 = event.RouteCode
|
||||
return result, nil
|
||||
}
|
||||
159
backend/internal/service/home_service.go
Normal file
159
backend/internal/service/home_service.go
Normal file
@@ -0,0 +1,159 @@
|
||||
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"`
|
||||
CoverURL *string `json:"coverUrl,omitempty"`
|
||||
DisplaySlot string `json:"displaySlot"`
|
||||
DisplayPriority int `json:"displayPriority"`
|
||||
Event *struct {
|
||||
ID string `json:"id"`
|
||||
DisplayName string `json:"displayName"`
|
||||
Summary *string `json:"summary,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 {
|
||||
item := CardResult{
|
||||
ID: card.PublicID,
|
||||
Type: card.CardType,
|
||||
Title: card.Title,
|
||||
Subtitle: card.Subtitle,
|
||||
CoverURL: card.CoverURL,
|
||||
DisplaySlot: card.DisplaySlot,
|
||||
DisplayPriority: card.DisplayPriority,
|
||||
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"`
|
||||
}{
|
||||
Summary: card.EventSummary,
|
||||
}
|
||||
if card.EventPublicID != nil {
|
||||
item.Event.ID = *card.EventPublicID
|
||||
}
|
||||
if card.EventDisplayName != nil {
|
||||
item.Event.DisplayName = *card.EventDisplayName
|
||||
}
|
||||
}
|
||||
results = append(results, item)
|
||||
}
|
||||
return results
|
||||
}
|
||||
43
backend/internal/service/me_service.go
Normal file
43
backend/internal/service/me_service.go
Normal file
@@ -0,0 +1,43 @@
|
||||
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
|
||||
}
|
||||
119
backend/internal/service/profile_service.go
Normal file
119
backend/internal/service/profile_service.go
Normal file
@@ -0,0 +1,119 @@
|
||||
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
|
||||
}
|
||||
56
backend/internal/service/release_view.go
Normal file
56
backend/internal/service/release_view.go
Normal file
@@ -0,0 +1,56 @@
|
||||
package service
|
||||
|
||||
import "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"`
|
||||
}
|
||||
|
||||
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 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
|
||||
}
|
||||
94
backend/internal/service/result_service.go
Normal file
94
backend/internal/service/result_service.go
Normal file
@@ -0,0 +1,94 @@
|
||||
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
|
||||
}
|
||||
324
backend/internal/service/session_service.go
Normal file
324
backend/internal/service/session_service.go
Normal file
@@ -0,0 +1,324 @@
|
||||
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 SessionResult struct {
|
||||
Session struct {
|
||||
ID string `json:"id"`
|
||||
Status string `json:"status"`
|
||||
ClientType string `json:"clientType"`
|
||||
DeviceKey string `json:"deviceKey"`
|
||||
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)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
if session.Status == "finished" || session.Status == "cancelled" || session.Status == "failed" {
|
||||
return nil, apperr.New(http.StatusConflict, "session_not_startable", "session cannot be started")
|
||||
}
|
||||
|
||||
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); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if locked.Status == "finished" || locked.Status == "cancelled" || locked.Status == "failed" {
|
||||
return nil, apperr.New(http.StatusConflict, "session_not_startable", "session cannot be started")
|
||||
}
|
||||
|
||||
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 err := tx.Commit(ctx); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return buildSessionResult(updated), nil
|
||||
}
|
||||
|
||||
func (s *SessionService) FinishSession(ctx context.Context, input FinishSessionInput) (*SessionResult, error) {
|
||||
input.Status = normalizeFinishStatus(input.Status)
|
||||
session, err := s.validateSessionAction(ctx, input.SessionPublicID, input.SessionToken)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
if session.Status == "finished" || session.Status == "cancelled" || session.Status == "failed" {
|
||||
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); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
if locked.Status == "finished" || locked.Status == "cancelled" || locked.Status == "failed" {
|
||||
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) (*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); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return session, nil
|
||||
}
|
||||
|
||||
func (s *SessionService) verifySessionToken(session *postgres.Session, sessionToken string) error {
|
||||
if session.SessionTokenExpiresAt.Before(time.Now().UTC()) {
|
||||
return apperr.New(http.StatusUnauthorized, "session_token_expired", "session token expired")
|
||||
}
|
||||
if session.SessionTokenHash != security.HashText(sessionToken) {
|
||||
return apperr.New(http.StatusUnauthorized, "invalid_session_token", "invalid session token")
|
||||
}
|
||||
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.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 {
|
||||
switch strings.TrimSpace(value) {
|
||||
case "failed":
|
||||
return "failed"
|
||||
case "cancelled":
|
||||
return "cancelled"
|
||||
default:
|
||||
return "finished"
|
||||
}
|
||||
}
|
||||
|
||||
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)
|
||||
}
|
||||
9
backend/internal/service/timeutil.go
Normal file
9
backend/internal/service/timeutil.go
Normal file
@@ -0,0 +1,9 @@
|
||||
package service
|
||||
|
||||
import "time"
|
||||
|
||||
const timeRFC3339 = time.RFC3339
|
||||
|
||||
func nowUTC() time.Time {
|
||||
return time.Now().UTC()
|
||||
}
|
||||
310
backend/internal/store/postgres/auth_store.go
Normal file
310
backend/internal/store/postgres/auth_store.go
Normal file
@@ -0,0 +1,310 @@
|
||||
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
|
||||
}
|
||||
93
backend/internal/store/postgres/card_store.go
Normal file
93
backend/internal/store/postgres/card_store.go
Normal file
@@ -0,0 +1,93 @@
|
||||
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
|
||||
EntryChannelID *string
|
||||
EventPublicID *string
|
||||
EventDisplayName *string
|
||||
EventSummary *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.entry_channel_id,
|
||||
e.event_public_id,
|
||||
e.display_name,
|
||||
e.summary,
|
||||
c.html_url
|
||||
FROM cards c
|
||||
LEFT JOIN events e ON e.id = c.event_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.EntryChannelID,
|
||||
&card.EventPublicID,
|
||||
&card.EventDisplayName,
|
||||
&card.EventSummary,
|
||||
&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
|
||||
}
|
||||
323
backend/internal/store/postgres/config_store.go
Normal file
323
backend/internal/store/postgres/config_store.go
Normal file
@@ -0,0 +1,323 @@
|
||||
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 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
|
||||
}
|
||||
46
backend/internal/store/postgres/db.go
Normal file
46
backend/internal/store/postgres/db.go
Normal file
@@ -0,0 +1,46 @@
|
||||
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)
|
||||
}
|
||||
324
backend/internal/store/postgres/dev_store.go
Normal file
324
backend/internal/store/postgres/dev_store.go
Normal file
@@ -0,0 +1,324 @@
|
||||
package postgres
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
)
|
||||
|
||||
type DemoBootstrapSummary struct {
|
||||
TenantCode string `json:"tenantCode"`
|
||||
ChannelCode string `json:"channelCode"`
|
||||
EventID string `json:"eventId"`
|
||||
ReleaseID string `json:"releaseId"`
|
||||
SourceID string `json:"sourceId"`
|
||||
BuildID string `json:"buildId"`
|
||||
CardID string `json:"cardId"`
|
||||
}
|
||||
|
||||
func (s *Store) EnsureDemoData(ctx context.Context) (*DemoBootstrapSummary, error) {
|
||||
tx, err := s.Begin(ctx)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer tx.Rollback(ctx)
|
||||
|
||||
var tenantID string
|
||||
if err := tx.QueryRow(ctx, `
|
||||
INSERT INTO tenants (tenant_code, name, status)
|
||||
VALUES ('tenant_demo', 'Demo Tenant', 'active')
|
||||
ON CONFLICT (tenant_code) DO UPDATE SET
|
||||
name = EXCLUDED.name,
|
||||
status = EXCLUDED.status
|
||||
RETURNING id
|
||||
`).Scan(&tenantID); err != nil {
|
||||
return nil, fmt.Errorf("ensure demo tenant: %w", err)
|
||||
}
|
||||
|
||||
var channelID string
|
||||
if err := tx.QueryRow(ctx, `
|
||||
INSERT INTO entry_channels (
|
||||
tenant_id, channel_code, channel_type, platform_app_id, display_name, status, is_default
|
||||
)
|
||||
VALUES ($1, 'mini-demo', 'wechat_mini', 'wx-demo-appid', 'Demo Mini Channel', 'active', true)
|
||||
ON CONFLICT (tenant_id, channel_code) DO UPDATE SET
|
||||
channel_type = EXCLUDED.channel_type,
|
||||
platform_app_id = EXCLUDED.platform_app_id,
|
||||
display_name = EXCLUDED.display_name,
|
||||
status = EXCLUDED.status,
|
||||
is_default = EXCLUDED.is_default
|
||||
RETURNING id
|
||||
`, tenantID).Scan(&channelID); err != nil {
|
||||
return nil, fmt.Errorf("ensure demo entry channel: %w", err)
|
||||
}
|
||||
|
||||
var eventID string
|
||||
if err := tx.QueryRow(ctx, `
|
||||
INSERT INTO events (
|
||||
tenant_id, event_public_id, slug, display_name, summary, status
|
||||
)
|
||||
VALUES ($1, 'evt_demo_001', 'demo-city-run', 'Demo City Run', 'Launch flow demo event', 'active')
|
||||
ON CONFLICT (event_public_id) DO UPDATE SET
|
||||
tenant_id = EXCLUDED.tenant_id,
|
||||
slug = EXCLUDED.slug,
|
||||
display_name = EXCLUDED.display_name,
|
||||
summary = EXCLUDED.summary,
|
||||
status = EXCLUDED.status
|
||||
RETURNING id
|
||||
`, tenantID).Scan(&eventID); err != nil {
|
||||
return nil, fmt.Errorf("ensure demo event: %w", err)
|
||||
}
|
||||
|
||||
var releaseRow struct {
|
||||
ID string
|
||||
PublicID string
|
||||
}
|
||||
if err := tx.QueryRow(ctx, `
|
||||
INSERT INTO event_releases (
|
||||
release_public_id,
|
||||
event_id,
|
||||
release_no,
|
||||
config_label,
|
||||
manifest_url,
|
||||
manifest_checksum_sha256,
|
||||
route_code,
|
||||
status
|
||||
)
|
||||
VALUES (
|
||||
'rel_demo_001',
|
||||
$1,
|
||||
1,
|
||||
'Demo Config v1',
|
||||
'https://oss-mbh5.colormaprun.com/gotomars/event/classic-sequential.json',
|
||||
'demo-checksum-001',
|
||||
'route-demo-001',
|
||||
'published'
|
||||
)
|
||||
ON CONFLICT (release_public_id) DO UPDATE SET
|
||||
event_id = EXCLUDED.event_id,
|
||||
config_label = EXCLUDED.config_label,
|
||||
manifest_url = EXCLUDED.manifest_url,
|
||||
manifest_checksum_sha256 = EXCLUDED.manifest_checksum_sha256,
|
||||
route_code = EXCLUDED.route_code,
|
||||
status = EXCLUDED.status
|
||||
RETURNING id, release_public_id
|
||||
`, eventID).Scan(&releaseRow.ID, &releaseRow.PublicID); err != nil {
|
||||
return nil, fmt.Errorf("ensure demo release: %w", err)
|
||||
}
|
||||
|
||||
if _, err := tx.Exec(ctx, `
|
||||
UPDATE events
|
||||
SET current_release_id = $2
|
||||
WHERE id = $1
|
||||
`, eventID, releaseRow.ID); err != nil {
|
||||
return nil, fmt.Errorf("attach demo release: %w", err)
|
||||
}
|
||||
|
||||
sourceNotes := "demo source config imported from local event sample"
|
||||
source, err := s.UpsertEventConfigSource(ctx, tx, UpsertEventConfigSourceParams{
|
||||
EventID: eventID,
|
||||
SourceVersionNo: 1,
|
||||
SourceKind: "event_bundle",
|
||||
SchemaID: "event-source",
|
||||
SchemaVersion: "1",
|
||||
Status: "active",
|
||||
Notes: &sourceNotes,
|
||||
Source: map[string]any{
|
||||
"app": map[string]any{
|
||||
"id": "sample-classic-001",
|
||||
"title": "顺序赛示例",
|
||||
},
|
||||
"branding": map[string]any{
|
||||
"tenantCode": "tenant_demo",
|
||||
"entryChannel": "mini-demo",
|
||||
},
|
||||
"map": map[string]any{
|
||||
"tiles": "../map/lxcb-001/tiles/",
|
||||
"mapmeta": "../map/lxcb-001/tiles/meta.json",
|
||||
},
|
||||
"playfield": map[string]any{
|
||||
"kind": "course",
|
||||
"source": map[string]any{
|
||||
"type": "kml",
|
||||
"url": "../kml/lxcb-001/10/c01.kml",
|
||||
},
|
||||
},
|
||||
"game": map[string]any{
|
||||
"mode": "classic-sequential",
|
||||
},
|
||||
"content": map[string]any{
|
||||
"h5Template": "content-h5-test-template.html",
|
||||
},
|
||||
},
|
||||
})
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("ensure demo event config source: %w", err)
|
||||
}
|
||||
|
||||
buildLog := "demo build generated from sample classic-sequential.json"
|
||||
build, err := s.UpsertEventConfigBuild(ctx, tx, UpsertEventConfigBuildParams{
|
||||
EventID: eventID,
|
||||
SourceID: source.ID,
|
||||
BuildNo: 1,
|
||||
BuildStatus: "success",
|
||||
BuildLog: &buildLog,
|
||||
Manifest: map[string]any{
|
||||
"schemaVersion": "1",
|
||||
"releaseId": "rel_demo_001",
|
||||
"version": "2026.04.01",
|
||||
"app": map[string]any{
|
||||
"id": "sample-classic-001",
|
||||
"title": "顺序赛示例",
|
||||
},
|
||||
"map": map[string]any{
|
||||
"tiles": "https://oss-mbh5.colormaprun.com/gotomars/map/lxcb-001/tiles/",
|
||||
"mapmeta": "https://oss-mbh5.colormaprun.com/gotomars/map/lxcb-001/tiles/meta.json",
|
||||
},
|
||||
"playfield": map[string]any{
|
||||
"kind": "course",
|
||||
"source": map[string]any{
|
||||
"type": "kml",
|
||||
"url": "https://oss-mbh5.colormaprun.com/gotomars/kml/lxcb-001/10/c01.kml",
|
||||
},
|
||||
},
|
||||
"game": map[string]any{
|
||||
"mode": "classic-sequential",
|
||||
},
|
||||
"assets": map[string]any{
|
||||
"contentHtml": "https://oss-mbh5.colormaprun.com/gotomars/event/content-h5-test-template.html",
|
||||
},
|
||||
},
|
||||
AssetIndex: []map[string]any{
|
||||
{
|
||||
"assetType": "manifest",
|
||||
"assetKey": "manifest",
|
||||
},
|
||||
{
|
||||
"assetType": "mapmeta",
|
||||
"assetKey": "mapmeta",
|
||||
},
|
||||
{
|
||||
"assetType": "playfield",
|
||||
"assetKey": "playfield-kml",
|
||||
},
|
||||
{
|
||||
"assetType": "content_html",
|
||||
"assetKey": "content-html",
|
||||
},
|
||||
},
|
||||
})
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("ensure demo event config build: %w", err)
|
||||
}
|
||||
|
||||
if err := s.AttachBuildToRelease(ctx, tx, releaseRow.ID, build.ID); err != nil {
|
||||
return nil, fmt.Errorf("attach demo build to release: %w", err)
|
||||
}
|
||||
|
||||
tilesPath := "map/lxcb-001/tiles/"
|
||||
mapmetaPath := "map/lxcb-001/tiles/meta.json"
|
||||
playfieldPath := "kml/lxcb-001/10/c01.kml"
|
||||
contentPath := "event/content-h5-test-template.html"
|
||||
manifestChecksum := "demo-checksum-001"
|
||||
if err := s.ReplaceEventReleaseAssets(ctx, tx, releaseRow.ID, []UpsertEventReleaseAssetParams{
|
||||
{
|
||||
EventReleaseID: releaseRow.ID,
|
||||
AssetType: "manifest",
|
||||
AssetKey: "manifest",
|
||||
AssetURL: "https://oss-mbh5.colormaprun.com/gotomars/event/classic-sequential.json",
|
||||
Checksum: &manifestChecksum,
|
||||
Meta: map[string]any{"source": "release-manifest"},
|
||||
},
|
||||
{
|
||||
EventReleaseID: releaseRow.ID,
|
||||
AssetType: "tiles",
|
||||
AssetKey: "tiles-root",
|
||||
AssetPath: &tilesPath,
|
||||
AssetURL: "https://oss-mbh5.colormaprun.com/gotomars/map/lxcb-001/tiles/",
|
||||
Meta: map[string]any{"kind": "directory"},
|
||||
},
|
||||
{
|
||||
EventReleaseID: releaseRow.ID,
|
||||
AssetType: "mapmeta",
|
||||
AssetKey: "mapmeta",
|
||||
AssetPath: &mapmetaPath,
|
||||
AssetURL: "https://oss-mbh5.colormaprun.com/gotomars/map/lxcb-001/tiles/meta.json",
|
||||
Meta: map[string]any{"format": "json"},
|
||||
},
|
||||
{
|
||||
EventReleaseID: releaseRow.ID,
|
||||
AssetType: "playfield",
|
||||
AssetKey: "course-kml",
|
||||
AssetPath: &playfieldPath,
|
||||
AssetURL: "https://oss-mbh5.colormaprun.com/gotomars/kml/lxcb-001/10/c01.kml",
|
||||
Meta: map[string]any{"format": "kml"},
|
||||
},
|
||||
{
|
||||
EventReleaseID: releaseRow.ID,
|
||||
AssetType: "content_html",
|
||||
AssetKey: "content-html",
|
||||
AssetPath: &contentPath,
|
||||
AssetURL: "https://oss-mbh5.colormaprun.com/gotomars/event/content-h5-test-template.html",
|
||||
Meta: map[string]any{"kind": "content-page"},
|
||||
},
|
||||
}); err != nil {
|
||||
return nil, fmt.Errorf("ensure demo event release assets: %w", err)
|
||||
}
|
||||
|
||||
var cardPublicID string
|
||||
if err := tx.QueryRow(ctx, `
|
||||
INSERT INTO cards (
|
||||
card_public_id,
|
||||
tenant_id,
|
||||
entry_channel_id,
|
||||
card_type,
|
||||
title,
|
||||
subtitle,
|
||||
cover_url,
|
||||
event_id,
|
||||
display_slot,
|
||||
display_priority,
|
||||
status
|
||||
)
|
||||
VALUES (
|
||||
'card_demo_001',
|
||||
$1,
|
||||
$2,
|
||||
'event',
|
||||
'Demo City Run',
|
||||
'今日推荐路线',
|
||||
'https://oss-mbh5.colormaprun.com/gotomars/assets/demo-cover.jpg',
|
||||
$3,
|
||||
'home_primary',
|
||||
100,
|
||||
'active'
|
||||
)
|
||||
ON CONFLICT (card_public_id) DO UPDATE SET
|
||||
tenant_id = EXCLUDED.tenant_id,
|
||||
entry_channel_id = EXCLUDED.entry_channel_id,
|
||||
card_type = EXCLUDED.card_type,
|
||||
title = EXCLUDED.title,
|
||||
subtitle = EXCLUDED.subtitle,
|
||||
cover_url = EXCLUDED.cover_url,
|
||||
event_id = EXCLUDED.event_id,
|
||||
display_slot = EXCLUDED.display_slot,
|
||||
display_priority = EXCLUDED.display_priority,
|
||||
status = EXCLUDED.status
|
||||
RETURNING card_public_id
|
||||
`, tenantID, channelID, eventID).Scan(&cardPublicID); err != nil {
|
||||
return nil, fmt.Errorf("ensure demo card: %w", err)
|
||||
}
|
||||
|
||||
if err := tx.Commit(ctx); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return &DemoBootstrapSummary{
|
||||
TenantCode: "tenant_demo",
|
||||
ChannelCode: "mini-demo",
|
||||
EventID: "evt_demo_001",
|
||||
ReleaseID: releaseRow.PublicID,
|
||||
SourceID: source.ID,
|
||||
BuildID: build.ID,
|
||||
CardID: cardPublicID,
|
||||
}, nil
|
||||
}
|
||||
74
backend/internal/store/postgres/entry_store.go
Normal file
74
backend/internal/store/postgres/entry_store.go
Normal file
@@ -0,0 +1,74 @@
|
||||
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
|
||||
}
|
||||
263
backend/internal/store/postgres/event_store.go
Normal file
263
backend/internal/store/postgres/event_store.go
Normal file
@@ -0,0 +1,263 @@
|
||||
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
|
||||
CurrentReleaseID *string
|
||||
CurrentReleasePubID *string
|
||||
ConfigLabel *string
|
||||
ManifestURL *string
|
||||
ManifestChecksum *string
|
||||
RouteCode *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
|
||||
}
|
||||
|
||||
type CreateGameSessionParams struct {
|
||||
SessionPublicID string
|
||||
UserID string
|
||||
EventID string
|
||||
EventReleaseID string
|
||||
DeviceKey string
|
||||
ClientType 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
|
||||
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.current_release_id,
|
||||
er.release_public_id,
|
||||
er.config_label,
|
||||
er.manifest_url,
|
||||
er.manifest_checksum_sha256,
|
||||
er.route_code
|
||||
FROM events e
|
||||
LEFT JOIN event_releases er ON er.id = e.current_release_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.CurrentReleaseID,
|
||||
&event.CurrentReleasePubID,
|
||||
&event.ConfigLabel,
|
||||
&event.ManifestURL,
|
||||
&event.ManifestChecksum,
|
||||
&event.RouteCode,
|
||||
)
|
||||
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.current_release_id,
|
||||
er.release_public_id,
|
||||
er.config_label,
|
||||
er.manifest_url,
|
||||
er.manifest_checksum_sha256,
|
||||
er.route_code
|
||||
FROM events e
|
||||
LEFT JOIN event_releases er ON er.id = e.current_release_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.CurrentReleaseID,
|
||||
&event.CurrentReleasePubID,
|
||||
&event.ConfigLabel,
|
||||
&event.ManifestURL,
|
||||
&event.ManifestChecksum,
|
||||
&event.RouteCode,
|
||||
)
|
||||
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
|
||||
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
|
||||
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,
|
||||
status,
|
||||
payload_jsonb
|
||||
)
|
||||
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10::jsonb)
|
||||
RETURNING id, release_public_id, event_id, release_no, config_label, manifest_url, manifest_checksum_sha256, route_code, build_id, status, published_at
|
||||
`, params.PublicID, params.EventID, params.ReleaseNo, params.ConfigLabel, params.ManifestURL, params.ManifestChecksum, params.RouteCode, params.BuildID, params.Status, params.PayloadJSON)
|
||||
|
||||
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,
|
||||
route_code,
|
||||
session_token_hash,
|
||||
session_token_expires_at
|
||||
)
|
||||
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9)
|
||||
RETURNING id, session_public_id, user_id, event_id, event_release_id, device_key, client_type, route_code, status, session_token_expires_at
|
||||
`, params.SessionPublicID, params.UserID, params.EventID, params.EventReleaseID, params.DeviceKey, params.ClientType, 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.RouteCode,
|
||||
&session.Status,
|
||||
&session.SessionTokenExpiresAt,
|
||||
)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("create game session: %w", err)
|
||||
}
|
||||
return &session, nil
|
||||
}
|
||||
50
backend/internal/store/postgres/identity_store.go
Normal file
50
backend/internal/store/postgres/identity_store.go
Normal file
@@ -0,0 +1,50 @@
|
||||
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
|
||||
}
|
||||
367
backend/internal/store/postgres/result_store.go
Normal file
367
backend/internal/store/postgres/result_store.go
Normal file
@@ -0,0 +1,367 @@
|
||||
package postgres
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
|
||||
"github.com/jackc/pgx/v5"
|
||||
)
|
||||
|
||||
type SessionResult struct {
|
||||
ID string
|
||||
SessionID string
|
||||
ResultStatus string
|
||||
SummaryJSON string
|
||||
FinalDurationSec *int
|
||||
FinalScore *int
|
||||
CompletedControls *int
|
||||
TotalControls *int
|
||||
DistanceMeters *float64
|
||||
AverageSpeedKmh *float64
|
||||
MaxHeartRateBpm *int
|
||||
}
|
||||
|
||||
type UpsertSessionResultParams struct {
|
||||
SessionID string
|
||||
ResultStatus string
|
||||
Summary map[string]any
|
||||
FinalDurationSec *int
|
||||
FinalScore *int
|
||||
CompletedControls *int
|
||||
TotalControls *int
|
||||
DistanceMeters *float64
|
||||
AverageSpeedKmh *float64
|
||||
MaxHeartRateBpm *int
|
||||
}
|
||||
|
||||
type SessionResultRecord struct {
|
||||
Session
|
||||
Result *SessionResult
|
||||
}
|
||||
|
||||
func (s *Store) UpsertSessionResult(ctx context.Context, tx Tx, params UpsertSessionResultParams) (*SessionResult, error) {
|
||||
summaryJSON, err := json.Marshal(params.Summary)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("marshal session summary: %w", err)
|
||||
}
|
||||
|
||||
row := tx.QueryRow(ctx, `
|
||||
INSERT INTO session_results (
|
||||
session_id,
|
||||
result_status,
|
||||
summary_jsonb,
|
||||
final_duration_sec,
|
||||
final_score,
|
||||
completed_controls,
|
||||
total_controls,
|
||||
distance_meters,
|
||||
average_speed_kmh,
|
||||
max_heart_rate_bpm
|
||||
)
|
||||
VALUES ($1, $2, $3::jsonb, $4, $5, $6, $7, $8, $9, $10)
|
||||
ON CONFLICT (session_id) DO UPDATE SET
|
||||
result_status = EXCLUDED.result_status,
|
||||
summary_jsonb = EXCLUDED.summary_jsonb,
|
||||
final_duration_sec = EXCLUDED.final_duration_sec,
|
||||
final_score = EXCLUDED.final_score,
|
||||
completed_controls = EXCLUDED.completed_controls,
|
||||
total_controls = EXCLUDED.total_controls,
|
||||
distance_meters = EXCLUDED.distance_meters,
|
||||
average_speed_kmh = EXCLUDED.average_speed_kmh,
|
||||
max_heart_rate_bpm = EXCLUDED.max_heart_rate_bpm
|
||||
RETURNING
|
||||
id,
|
||||
session_id,
|
||||
result_status,
|
||||
summary_jsonb::text,
|
||||
final_duration_sec,
|
||||
final_score,
|
||||
completed_controls,
|
||||
total_controls,
|
||||
distance_meters::float8,
|
||||
average_speed_kmh::float8,
|
||||
max_heart_rate_bpm
|
||||
`, params.SessionID, params.ResultStatus, string(summaryJSON), params.FinalDurationSec, params.FinalScore, params.CompletedControls, params.TotalControls, params.DistanceMeters, params.AverageSpeedKmh, params.MaxHeartRateBpm)
|
||||
|
||||
return scanSessionResult(row)
|
||||
}
|
||||
|
||||
func (s *Store) GetSessionResultByPublicID(ctx context.Context, sessionPublicID string) (*SessionResultRecord, error) {
|
||||
row := s.pool.QueryRow(ctx, `
|
||||
SELECT
|
||||
gs.id,
|
||||
gs.session_public_id,
|
||||
gs.user_id,
|
||||
gs.event_id,
|
||||
gs.event_release_id,
|
||||
er.release_public_id,
|
||||
er.config_label,
|
||||
er.manifest_url,
|
||||
er.manifest_checksum_sha256,
|
||||
gs.device_key,
|
||||
gs.client_type,
|
||||
gs.route_code,
|
||||
gs.status,
|
||||
gs.session_token_hash,
|
||||
gs.session_token_expires_at,
|
||||
gs.launched_at,
|
||||
gs.started_at,
|
||||
gs.ended_at,
|
||||
e.event_public_id,
|
||||
e.display_name,
|
||||
sr.id,
|
||||
sr.session_id,
|
||||
sr.result_status,
|
||||
sr.summary_jsonb::text,
|
||||
sr.final_duration_sec,
|
||||
sr.final_score,
|
||||
sr.completed_controls,
|
||||
sr.total_controls,
|
||||
sr.distance_meters::float8,
|
||||
sr.average_speed_kmh::float8,
|
||||
sr.max_heart_rate_bpm
|
||||
FROM game_sessions gs
|
||||
JOIN events e ON e.id = gs.event_id
|
||||
JOIN event_releases er ON er.id = gs.event_release_id
|
||||
LEFT JOIN session_results sr ON sr.session_id = gs.id
|
||||
WHERE gs.session_public_id = $1
|
||||
LIMIT 1
|
||||
`, sessionPublicID)
|
||||
return scanSessionResultRecord(row)
|
||||
}
|
||||
|
||||
func (s *Store) ListSessionResultsByUserID(ctx context.Context, userID string, limit int) ([]SessionResultRecord, error) {
|
||||
if limit <= 0 || limit > 100 {
|
||||
limit = 20
|
||||
}
|
||||
|
||||
rows, err := s.pool.Query(ctx, `
|
||||
SELECT
|
||||
gs.id,
|
||||
gs.session_public_id,
|
||||
gs.user_id,
|
||||
gs.event_id,
|
||||
gs.event_release_id,
|
||||
er.release_public_id,
|
||||
er.config_label,
|
||||
er.manifest_url,
|
||||
er.manifest_checksum_sha256,
|
||||
gs.device_key,
|
||||
gs.client_type,
|
||||
gs.route_code,
|
||||
gs.status,
|
||||
gs.session_token_hash,
|
||||
gs.session_token_expires_at,
|
||||
gs.launched_at,
|
||||
gs.started_at,
|
||||
gs.ended_at,
|
||||
e.event_public_id,
|
||||
e.display_name,
|
||||
sr.id,
|
||||
sr.session_id,
|
||||
sr.result_status,
|
||||
sr.summary_jsonb::text,
|
||||
sr.final_duration_sec,
|
||||
sr.final_score,
|
||||
sr.completed_controls,
|
||||
sr.total_controls,
|
||||
sr.distance_meters::float8,
|
||||
sr.average_speed_kmh::float8,
|
||||
sr.max_heart_rate_bpm
|
||||
FROM game_sessions gs
|
||||
JOIN events e ON e.id = gs.event_id
|
||||
JOIN event_releases er ON er.id = gs.event_release_id
|
||||
LEFT JOIN session_results sr ON sr.session_id = gs.id
|
||||
WHERE gs.user_id = $1
|
||||
AND gs.status IN ('finished', 'failed', 'cancelled')
|
||||
ORDER BY COALESCE(gs.ended_at, gs.updated_at, gs.created_at) DESC
|
||||
LIMIT $2
|
||||
`, userID, limit)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("list session results by user id: %w", err)
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
var items []SessionResultRecord
|
||||
for rows.Next() {
|
||||
item, err := scanSessionResultRecordFromRows(rows)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
items = append(items, *item)
|
||||
}
|
||||
if err := rows.Err(); err != nil {
|
||||
return nil, fmt.Errorf("iterate session results by user id: %w", err)
|
||||
}
|
||||
return items, nil
|
||||
}
|
||||
|
||||
func scanSessionResult(row pgx.Row) (*SessionResult, error) {
|
||||
var result SessionResult
|
||||
err := row.Scan(
|
||||
&result.ID,
|
||||
&result.SessionID,
|
||||
&result.ResultStatus,
|
||||
&result.SummaryJSON,
|
||||
&result.FinalDurationSec,
|
||||
&result.FinalScore,
|
||||
&result.CompletedControls,
|
||||
&result.TotalControls,
|
||||
&result.DistanceMeters,
|
||||
&result.AverageSpeedKmh,
|
||||
&result.MaxHeartRateBpm,
|
||||
)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("scan session result: %w", err)
|
||||
}
|
||||
return &result, nil
|
||||
}
|
||||
|
||||
func scanSessionResultRecord(row pgx.Row) (*SessionResultRecord, error) {
|
||||
var record SessionResultRecord
|
||||
var resultID *string
|
||||
var resultSessionID *string
|
||||
var resultStatus *string
|
||||
var resultSummaryJSON *string
|
||||
var finalDurationSec *int
|
||||
var finalScore *int
|
||||
var completedControls *int
|
||||
var totalControls *int
|
||||
var distanceMeters *float64
|
||||
var averageSpeedKmh *float64
|
||||
var maxHeartRateBpm *int
|
||||
|
||||
err := row.Scan(
|
||||
&record.ID,
|
||||
&record.SessionPublicID,
|
||||
&record.UserID,
|
||||
&record.EventID,
|
||||
&record.EventReleaseID,
|
||||
&record.ReleasePublicID,
|
||||
&record.ConfigLabel,
|
||||
&record.ManifestURL,
|
||||
&record.ManifestChecksum,
|
||||
&record.DeviceKey,
|
||||
&record.ClientType,
|
||||
&record.RouteCode,
|
||||
&record.Status,
|
||||
&record.SessionTokenHash,
|
||||
&record.SessionTokenExpiresAt,
|
||||
&record.LaunchedAt,
|
||||
&record.StartedAt,
|
||||
&record.EndedAt,
|
||||
&record.EventPublicID,
|
||||
&record.EventDisplayName,
|
||||
&resultID,
|
||||
&resultSessionID,
|
||||
&resultStatus,
|
||||
&resultSummaryJSON,
|
||||
&finalDurationSec,
|
||||
&finalScore,
|
||||
&completedControls,
|
||||
&totalControls,
|
||||
&distanceMeters,
|
||||
&averageSpeedKmh,
|
||||
&maxHeartRateBpm,
|
||||
)
|
||||
if err != nil {
|
||||
if err == pgx.ErrNoRows {
|
||||
return nil, nil
|
||||
}
|
||||
return nil, fmt.Errorf("scan session result record: %w", err)
|
||||
}
|
||||
|
||||
if resultID != nil {
|
||||
record.Result = &SessionResult{
|
||||
ID: *resultID,
|
||||
SessionID: derefString(resultSessionID),
|
||||
ResultStatus: derefString(resultStatus),
|
||||
SummaryJSON: derefString(resultSummaryJSON),
|
||||
FinalDurationSec: finalDurationSec,
|
||||
FinalScore: finalScore,
|
||||
CompletedControls: completedControls,
|
||||
TotalControls: totalControls,
|
||||
DistanceMeters: distanceMeters,
|
||||
AverageSpeedKmh: averageSpeedKmh,
|
||||
MaxHeartRateBpm: maxHeartRateBpm,
|
||||
}
|
||||
}
|
||||
|
||||
return &record, nil
|
||||
}
|
||||
|
||||
func scanSessionResultRecordFromRows(rows pgx.Rows) (*SessionResultRecord, error) {
|
||||
var record SessionResultRecord
|
||||
var resultID *string
|
||||
var resultSessionID *string
|
||||
var resultStatus *string
|
||||
var resultSummaryJSON *string
|
||||
var finalDurationSec *int
|
||||
var finalScore *int
|
||||
var completedControls *int
|
||||
var totalControls *int
|
||||
var distanceMeters *float64
|
||||
var averageSpeedKmh *float64
|
||||
var maxHeartRateBpm *int
|
||||
|
||||
err := rows.Scan(
|
||||
&record.ID,
|
||||
&record.SessionPublicID,
|
||||
&record.UserID,
|
||||
&record.EventID,
|
||||
&record.EventReleaseID,
|
||||
&record.ReleasePublicID,
|
||||
&record.ConfigLabel,
|
||||
&record.ManifestURL,
|
||||
&record.ManifestChecksum,
|
||||
&record.DeviceKey,
|
||||
&record.ClientType,
|
||||
&record.RouteCode,
|
||||
&record.Status,
|
||||
&record.SessionTokenHash,
|
||||
&record.SessionTokenExpiresAt,
|
||||
&record.LaunchedAt,
|
||||
&record.StartedAt,
|
||||
&record.EndedAt,
|
||||
&record.EventPublicID,
|
||||
&record.EventDisplayName,
|
||||
&resultID,
|
||||
&resultSessionID,
|
||||
&resultStatus,
|
||||
&resultSummaryJSON,
|
||||
&finalDurationSec,
|
||||
&finalScore,
|
||||
&completedControls,
|
||||
&totalControls,
|
||||
&distanceMeters,
|
||||
&averageSpeedKmh,
|
||||
&maxHeartRateBpm,
|
||||
)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("scan session result row: %w", err)
|
||||
}
|
||||
if resultID != nil {
|
||||
record.Result = &SessionResult{
|
||||
ID: *resultID,
|
||||
SessionID: derefString(resultSessionID),
|
||||
ResultStatus: derefString(resultStatus),
|
||||
SummaryJSON: derefString(resultSummaryJSON),
|
||||
FinalDurationSec: finalDurationSec,
|
||||
FinalScore: finalScore,
|
||||
CompletedControls: completedControls,
|
||||
TotalControls: totalControls,
|
||||
DistanceMeters: distanceMeters,
|
||||
AverageSpeedKmh: averageSpeedKmh,
|
||||
MaxHeartRateBpm: maxHeartRateBpm,
|
||||
}
|
||||
}
|
||||
return &record, nil
|
||||
}
|
||||
|
||||
func derefString(value *string) string {
|
||||
if value == nil {
|
||||
return ""
|
||||
}
|
||||
return *value
|
||||
}
|
||||
299
backend/internal/store/postgres/session_store.go
Normal file
299
backend/internal/store/postgres/session_store.go
Normal file
@@ -0,0 +1,299 @@
|
||||
package postgres
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"time"
|
||||
|
||||
"github.com/jackc/pgx/v5"
|
||||
)
|
||||
|
||||
type Session struct {
|
||||
ID string
|
||||
SessionPublicID string
|
||||
UserID string
|
||||
EventID string
|
||||
EventReleaseID string
|
||||
ReleasePublicID *string
|
||||
ConfigLabel *string
|
||||
ManifestURL *string
|
||||
ManifestChecksum *string
|
||||
DeviceKey string
|
||||
ClientType string
|
||||
RouteCode *string
|
||||
Status string
|
||||
SessionTokenHash string
|
||||
SessionTokenExpiresAt time.Time
|
||||
LaunchedAt time.Time
|
||||
StartedAt *time.Time
|
||||
EndedAt *time.Time
|
||||
EventPublicID *string
|
||||
EventDisplayName *string
|
||||
}
|
||||
|
||||
type FinishSessionParams struct {
|
||||
SessionID string
|
||||
Status string
|
||||
}
|
||||
|
||||
func (s *Store) GetSessionByPublicID(ctx context.Context, sessionPublicID string) (*Session, error) {
|
||||
row := s.pool.QueryRow(ctx, `
|
||||
SELECT
|
||||
gs.id,
|
||||
gs.session_public_id,
|
||||
gs.user_id,
|
||||
gs.event_id,
|
||||
gs.event_release_id,
|
||||
er.release_public_id,
|
||||
er.config_label,
|
||||
er.manifest_url,
|
||||
er.manifest_checksum_sha256,
|
||||
gs.device_key,
|
||||
gs.client_type,
|
||||
gs.route_code,
|
||||
gs.status,
|
||||
gs.session_token_hash,
|
||||
gs.session_token_expires_at,
|
||||
gs.launched_at,
|
||||
gs.started_at,
|
||||
gs.ended_at,
|
||||
e.event_public_id,
|
||||
e.display_name
|
||||
FROM game_sessions gs
|
||||
JOIN events e ON e.id = gs.event_id
|
||||
JOIN event_releases er ON er.id = gs.event_release_id
|
||||
WHERE gs.session_public_id = $1
|
||||
LIMIT 1
|
||||
`, sessionPublicID)
|
||||
return scanSession(row)
|
||||
}
|
||||
|
||||
func (s *Store) GetSessionByPublicIDForUpdate(ctx context.Context, tx Tx, sessionPublicID string) (*Session, error) {
|
||||
row := tx.QueryRow(ctx, `
|
||||
SELECT
|
||||
gs.id,
|
||||
gs.session_public_id,
|
||||
gs.user_id,
|
||||
gs.event_id,
|
||||
gs.event_release_id,
|
||||
er.release_public_id,
|
||||
er.config_label,
|
||||
er.manifest_url,
|
||||
er.manifest_checksum_sha256,
|
||||
gs.device_key,
|
||||
gs.client_type,
|
||||
gs.route_code,
|
||||
gs.status,
|
||||
gs.session_token_hash,
|
||||
gs.session_token_expires_at,
|
||||
gs.launched_at,
|
||||
gs.started_at,
|
||||
gs.ended_at,
|
||||
e.event_public_id,
|
||||
e.display_name
|
||||
FROM game_sessions gs
|
||||
JOIN events e ON e.id = gs.event_id
|
||||
JOIN event_releases er ON er.id = gs.event_release_id
|
||||
WHERE gs.session_public_id = $1
|
||||
FOR UPDATE
|
||||
`, sessionPublicID)
|
||||
return scanSession(row)
|
||||
}
|
||||
|
||||
func (s *Store) ListSessionsByUserID(ctx context.Context, userID string, limit int) ([]Session, error) {
|
||||
if limit <= 0 || limit > 100 {
|
||||
limit = 20
|
||||
}
|
||||
|
||||
rows, err := s.pool.Query(ctx, `
|
||||
SELECT
|
||||
gs.id,
|
||||
gs.session_public_id,
|
||||
gs.user_id,
|
||||
gs.event_id,
|
||||
gs.event_release_id,
|
||||
er.release_public_id,
|
||||
er.config_label,
|
||||
er.manifest_url,
|
||||
er.manifest_checksum_sha256,
|
||||
gs.device_key,
|
||||
gs.client_type,
|
||||
gs.route_code,
|
||||
gs.status,
|
||||
gs.session_token_hash,
|
||||
gs.session_token_expires_at,
|
||||
gs.launched_at,
|
||||
gs.started_at,
|
||||
gs.ended_at,
|
||||
e.event_public_id,
|
||||
e.display_name
|
||||
FROM game_sessions gs
|
||||
JOIN events e ON e.id = gs.event_id
|
||||
JOIN event_releases er ON er.id = gs.event_release_id
|
||||
WHERE gs.user_id = $1
|
||||
ORDER BY gs.created_at DESC
|
||||
LIMIT $2
|
||||
`, userID, limit)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("list sessions by user id: %w", err)
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
var sessions []Session
|
||||
for rows.Next() {
|
||||
session, err := scanSessionFromRows(rows)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
sessions = append(sessions, *session)
|
||||
}
|
||||
if err := rows.Err(); err != nil {
|
||||
return nil, fmt.Errorf("iterate sessions by user id: %w", err)
|
||||
}
|
||||
return sessions, nil
|
||||
}
|
||||
|
||||
func (s *Store) ListSessionsByUserAndEvent(ctx context.Context, userID, eventID string, limit int) ([]Session, error) {
|
||||
if limit <= 0 || limit > 100 {
|
||||
limit = 20
|
||||
}
|
||||
|
||||
rows, err := s.pool.Query(ctx, `
|
||||
SELECT
|
||||
gs.id,
|
||||
gs.session_public_id,
|
||||
gs.user_id,
|
||||
gs.event_id,
|
||||
gs.event_release_id,
|
||||
er.release_public_id,
|
||||
er.config_label,
|
||||
er.manifest_url,
|
||||
er.manifest_checksum_sha256,
|
||||
gs.device_key,
|
||||
gs.client_type,
|
||||
gs.route_code,
|
||||
gs.status,
|
||||
gs.session_token_hash,
|
||||
gs.session_token_expires_at,
|
||||
gs.launched_at,
|
||||
gs.started_at,
|
||||
gs.ended_at,
|
||||
e.event_public_id,
|
||||
e.display_name
|
||||
FROM game_sessions gs
|
||||
JOIN events e ON e.id = gs.event_id
|
||||
JOIN event_releases er ON er.id = gs.event_release_id
|
||||
WHERE gs.user_id = $1
|
||||
AND gs.event_id = $2
|
||||
ORDER BY gs.created_at DESC
|
||||
LIMIT $3
|
||||
`, userID, eventID, limit)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("list sessions by user and event: %w", err)
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
var sessions []Session
|
||||
for rows.Next() {
|
||||
session, err := scanSessionFromRows(rows)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
sessions = append(sessions, *session)
|
||||
}
|
||||
if err := rows.Err(); err != nil {
|
||||
return nil, fmt.Errorf("iterate sessions by user and event: %w", err)
|
||||
}
|
||||
return sessions, nil
|
||||
}
|
||||
|
||||
func (s *Store) StartSession(ctx context.Context, tx Tx, sessionID string) error {
|
||||
_, err := tx.Exec(ctx, `
|
||||
UPDATE game_sessions
|
||||
SET status = CASE WHEN status = 'launched' THEN 'running' ELSE status END,
|
||||
started_at = COALESCE(started_at, NOW())
|
||||
WHERE id = $1
|
||||
`, sessionID)
|
||||
if err != nil {
|
||||
return fmt.Errorf("start session: %w", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *Store) FinishSession(ctx context.Context, tx Tx, params FinishSessionParams) error {
|
||||
_, err := tx.Exec(ctx, `
|
||||
UPDATE game_sessions
|
||||
SET status = $2,
|
||||
started_at = COALESCE(started_at, NOW()),
|
||||
ended_at = COALESCE(ended_at, NOW())
|
||||
WHERE id = $1
|
||||
`, params.SessionID, params.Status)
|
||||
if err != nil {
|
||||
return fmt.Errorf("finish session: %w", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func scanSession(row pgx.Row) (*Session, error) {
|
||||
var session Session
|
||||
err := row.Scan(
|
||||
&session.ID,
|
||||
&session.SessionPublicID,
|
||||
&session.UserID,
|
||||
&session.EventID,
|
||||
&session.EventReleaseID,
|
||||
&session.ReleasePublicID,
|
||||
&session.ConfigLabel,
|
||||
&session.ManifestURL,
|
||||
&session.ManifestChecksum,
|
||||
&session.DeviceKey,
|
||||
&session.ClientType,
|
||||
&session.RouteCode,
|
||||
&session.Status,
|
||||
&session.SessionTokenHash,
|
||||
&session.SessionTokenExpiresAt,
|
||||
&session.LaunchedAt,
|
||||
&session.StartedAt,
|
||||
&session.EndedAt,
|
||||
&session.EventPublicID,
|
||||
&session.EventDisplayName,
|
||||
)
|
||||
if errors.Is(err, pgx.ErrNoRows) {
|
||||
return nil, nil
|
||||
}
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("scan session: %w", err)
|
||||
}
|
||||
return &session, nil
|
||||
}
|
||||
|
||||
func scanSessionFromRows(rows pgx.Rows) (*Session, error) {
|
||||
var session Session
|
||||
err := rows.Scan(
|
||||
&session.ID,
|
||||
&session.SessionPublicID,
|
||||
&session.UserID,
|
||||
&session.EventID,
|
||||
&session.EventReleaseID,
|
||||
&session.ReleasePublicID,
|
||||
&session.ConfigLabel,
|
||||
&session.ManifestURL,
|
||||
&session.ManifestChecksum,
|
||||
&session.DeviceKey,
|
||||
&session.ClientType,
|
||||
&session.RouteCode,
|
||||
&session.Status,
|
||||
&session.SessionTokenHash,
|
||||
&session.SessionTokenExpiresAt,
|
||||
&session.LaunchedAt,
|
||||
&session.StartedAt,
|
||||
&session.EndedAt,
|
||||
&session.EventPublicID,
|
||||
&session.EventDisplayName,
|
||||
)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("scan session row: %w", err)
|
||||
}
|
||||
return &session, nil
|
||||
}
|
||||
94
backend/internal/store/postgres/user_store.go
Normal file
94
backend/internal/store/postgres/user_store.go
Normal file
@@ -0,0 +1,94 @@
|
||||
package postgres
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
|
||||
"github.com/jackc/pgx/v5"
|
||||
)
|
||||
|
||||
type User struct {
|
||||
ID string
|
||||
PublicID string
|
||||
Status string
|
||||
Nickname *string
|
||||
AvatarURL *string
|
||||
}
|
||||
|
||||
type CreateUserParams struct {
|
||||
PublicID string
|
||||
Status string
|
||||
}
|
||||
|
||||
type queryRower interface {
|
||||
QueryRow(context.Context, string, ...any) pgx.Row
|
||||
}
|
||||
|
||||
func (s *Store) FindUserByMobile(ctx context.Context, tx Tx, countryCode, mobile 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 = 'mobile'
|
||||
AND li.country_code = $1
|
||||
AND li.mobile = $2
|
||||
AND li.status = 'active'
|
||||
LIMIT 1
|
||||
`, countryCode, mobile)
|
||||
return scanUser(row)
|
||||
}
|
||||
|
||||
func (s *Store) CreateUser(ctx context.Context, tx Tx, params CreateUserParams) (*User, error) {
|
||||
row := tx.QueryRow(ctx, `
|
||||
INSERT INTO users (user_public_id, status)
|
||||
VALUES ($1, $2)
|
||||
RETURNING id, user_public_id, status, nickname, avatar_url
|
||||
`, params.PublicID, params.Status)
|
||||
return scanUser(row)
|
||||
}
|
||||
|
||||
func (s *Store) TouchUserLogin(ctx context.Context, tx Tx, userID string) error {
|
||||
_, err := tx.Exec(ctx, `
|
||||
UPDATE users
|
||||
SET last_login_at = NOW()
|
||||
WHERE id = $1
|
||||
`, userID)
|
||||
if err != nil {
|
||||
return fmt.Errorf("touch user last login: %w", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *Store) DeactivateUser(ctx context.Context, tx Tx, userID string) error {
|
||||
_, err := tx.Exec(ctx, `
|
||||
UPDATE users
|
||||
SET status = 'deleted', updated_at = NOW()
|
||||
WHERE id = $1
|
||||
`, userID)
|
||||
if err != nil {
|
||||
return fmt.Errorf("deactivate user: %w", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *Store) GetUserByID(ctx context.Context, db queryRower, userID string) (*User, error) {
|
||||
row := db.QueryRow(ctx, `
|
||||
SELECT id, user_public_id, status, nickname, avatar_url
|
||||
FROM users
|
||||
WHERE id = $1
|
||||
`, userID)
|
||||
return scanUser(row)
|
||||
}
|
||||
|
||||
func scanUser(row pgx.Row) (*User, error) {
|
||||
var user User
|
||||
err := row.Scan(&user.ID, &user.PublicID, &user.Status, &user.Nickname, &user.AvatarURL)
|
||||
if errors.Is(err, pgx.ErrNoRows) {
|
||||
return nil, nil
|
||||
}
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("scan user: %w", err)
|
||||
}
|
||||
return &user, nil
|
||||
}
|
||||
123
backend/migrations/0001_init.sql
Normal file
123
backend/migrations/0001_init.sql
Normal file
@@ -0,0 +1,123 @@
|
||||
BEGIN;
|
||||
|
||||
CREATE EXTENSION IF NOT EXISTS pgcrypto;
|
||||
|
||||
CREATE OR REPLACE FUNCTION set_updated_at()
|
||||
RETURNS TRIGGER AS $$
|
||||
BEGIN
|
||||
NEW.updated_at = NOW();
|
||||
RETURN NEW;
|
||||
END;
|
||||
$$ LANGUAGE plpgsql;
|
||||
|
||||
CREATE TABLE tenants (
|
||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
tenant_code TEXT NOT NULL UNIQUE,
|
||||
name TEXT NOT NULL,
|
||||
status TEXT NOT NULL DEFAULT 'active' CHECK (status IN ('draft', 'active', 'disabled', 'archived')),
|
||||
theme_jsonb JSONB NOT NULL DEFAULT '{}'::jsonb,
|
||||
settings_jsonb JSONB NOT NULL DEFAULT '{}'::jsonb,
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
|
||||
);
|
||||
|
||||
CREATE TRIGGER tenants_set_updated_at
|
||||
BEFORE UPDATE ON tenants
|
||||
FOR EACH ROW EXECUTE FUNCTION set_updated_at();
|
||||
|
||||
CREATE TABLE entry_channels (
|
||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
tenant_id UUID NOT NULL REFERENCES tenants(id) ON DELETE CASCADE,
|
||||
channel_code TEXT NOT NULL,
|
||||
channel_type TEXT NOT NULL CHECK (channel_type IN ('app', 'wechat_mini', 'wechat_oa', 'h5', 'qr')),
|
||||
platform_app_id TEXT,
|
||||
display_name TEXT NOT NULL,
|
||||
status TEXT NOT NULL DEFAULT 'active' CHECK (status IN ('draft', 'active', 'disabled', 'archived')),
|
||||
is_default BOOLEAN NOT NULL DEFAULT FALSE,
|
||||
config_jsonb JSONB NOT NULL DEFAULT '{}'::jsonb,
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||
UNIQUE (tenant_id, channel_code)
|
||||
);
|
||||
|
||||
CREATE INDEX entry_channels_tenant_id_idx ON entry_channels(tenant_id);
|
||||
CREATE INDEX entry_channels_platform_app_id_idx ON entry_channels(platform_app_id);
|
||||
|
||||
CREATE TRIGGER entry_channels_set_updated_at
|
||||
BEFORE UPDATE ON entry_channels
|
||||
FOR EACH ROW EXECUTE FUNCTION set_updated_at();
|
||||
|
||||
CREATE TABLE users (
|
||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
user_public_id TEXT NOT NULL UNIQUE,
|
||||
default_tenant_id UUID REFERENCES tenants(id) ON DELETE SET NULL,
|
||||
status TEXT NOT NULL DEFAULT 'active' CHECK (status IN ('active', 'disabled', 'deleted')),
|
||||
nickname TEXT,
|
||||
avatar_url TEXT,
|
||||
last_login_at TIMESTAMPTZ,
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
|
||||
);
|
||||
|
||||
CREATE INDEX users_default_tenant_id_idx ON users(default_tenant_id);
|
||||
|
||||
CREATE TRIGGER users_set_updated_at
|
||||
BEFORE UPDATE ON users
|
||||
FOR EACH ROW EXECUTE FUNCTION set_updated_at();
|
||||
|
||||
CREATE TABLE login_identities (
|
||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
user_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE,
|
||||
identity_type TEXT NOT NULL CHECK (identity_type IN ('mobile', 'wechat_mini_openid', 'wechat_oa_openid', 'wechat_unionid')),
|
||||
provider TEXT NOT NULL,
|
||||
provider_subject TEXT NOT NULL,
|
||||
country_code TEXT,
|
||||
mobile TEXT,
|
||||
status TEXT NOT NULL DEFAULT 'active' CHECK (status IN ('active', 'disabled', 'deleted')),
|
||||
profile_jsonb JSONB NOT NULL DEFAULT '{}'::jsonb,
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||
UNIQUE (provider, provider_subject)
|
||||
);
|
||||
|
||||
CREATE INDEX login_identities_user_id_idx ON login_identities(user_id);
|
||||
CREATE INDEX login_identities_mobile_idx ON login_identities(country_code, mobile);
|
||||
|
||||
CREATE TRIGGER login_identities_set_updated_at
|
||||
BEFORE UPDATE ON login_identities
|
||||
FOR EACH ROW EXECUTE FUNCTION set_updated_at();
|
||||
|
||||
CREATE TABLE auth_sms_codes (
|
||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
scene TEXT NOT NULL,
|
||||
country_code TEXT NOT NULL,
|
||||
mobile TEXT NOT NULL,
|
||||
client_type TEXT NOT NULL CHECK (client_type IN ('app', 'wechat')),
|
||||
device_key TEXT NOT NULL,
|
||||
code_hash TEXT NOT NULL,
|
||||
provider_payload_jsonb JSONB NOT NULL DEFAULT '{}'::jsonb,
|
||||
expires_at TIMESTAMPTZ NOT NULL,
|
||||
cooldown_until TIMESTAMPTZ NOT NULL,
|
||||
consumed_at TIMESTAMPTZ,
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
|
||||
);
|
||||
|
||||
CREATE INDEX auth_sms_codes_lookup_idx
|
||||
ON auth_sms_codes(country_code, mobile, client_type, scene, created_at DESC);
|
||||
|
||||
CREATE TABLE auth_refresh_tokens (
|
||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
user_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE,
|
||||
client_type TEXT NOT NULL CHECK (client_type IN ('app', 'wechat')),
|
||||
device_key TEXT,
|
||||
token_hash TEXT NOT NULL UNIQUE,
|
||||
issued_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||
expires_at TIMESTAMPTZ NOT NULL,
|
||||
revoked_at TIMESTAMPTZ,
|
||||
replaced_by_token_id UUID REFERENCES auth_refresh_tokens(id) ON DELETE SET NULL
|
||||
);
|
||||
|
||||
CREATE INDEX auth_refresh_tokens_user_id_idx ON auth_refresh_tokens(user_id);
|
||||
CREATE INDEX auth_refresh_tokens_expires_at_idx ON auth_refresh_tokens(expires_at);
|
||||
|
||||
COMMIT;
|
||||
72
backend/migrations/0002_launch.sql
Normal file
72
backend/migrations/0002_launch.sql
Normal file
@@ -0,0 +1,72 @@
|
||||
BEGIN;
|
||||
|
||||
CREATE TABLE events (
|
||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
tenant_id UUID REFERENCES tenants(id) ON DELETE SET NULL,
|
||||
event_public_id TEXT NOT NULL UNIQUE,
|
||||
slug TEXT NOT NULL UNIQUE,
|
||||
display_name TEXT NOT NULL,
|
||||
summary TEXT,
|
||||
status TEXT NOT NULL DEFAULT 'draft' CHECK (status IN ('draft', 'active', 'disabled', 'archived')),
|
||||
current_release_id UUID,
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
|
||||
);
|
||||
|
||||
CREATE INDEX events_tenant_id_idx ON events(tenant_id);
|
||||
CREATE INDEX events_status_idx ON events(status);
|
||||
|
||||
CREATE TRIGGER events_set_updated_at
|
||||
BEFORE UPDATE ON events
|
||||
FOR EACH ROW EXECUTE FUNCTION set_updated_at();
|
||||
|
||||
CREATE TABLE event_releases (
|
||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
release_public_id TEXT NOT NULL UNIQUE,
|
||||
event_id UUID NOT NULL REFERENCES events(id) ON DELETE CASCADE,
|
||||
release_no INTEGER NOT NULL,
|
||||
config_label TEXT NOT NULL,
|
||||
manifest_url TEXT NOT NULL,
|
||||
manifest_checksum_sha256 TEXT,
|
||||
route_code TEXT,
|
||||
status TEXT NOT NULL DEFAULT 'published' CHECK (status IN ('draft', 'published', 'retired', 'failed')),
|
||||
payload_jsonb JSONB NOT NULL DEFAULT '{}'::jsonb,
|
||||
published_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||
UNIQUE (event_id, release_no)
|
||||
);
|
||||
|
||||
CREATE INDEX event_releases_event_id_idx ON event_releases(event_id);
|
||||
CREATE INDEX event_releases_status_idx ON event_releases(status);
|
||||
|
||||
ALTER TABLE events
|
||||
ADD CONSTRAINT events_current_release_fk
|
||||
FOREIGN KEY (current_release_id) REFERENCES event_releases(id) ON DELETE SET NULL;
|
||||
|
||||
CREATE TABLE game_sessions (
|
||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
session_public_id TEXT NOT NULL UNIQUE,
|
||||
user_id UUID NOT NULL REFERENCES users(id) ON DELETE RESTRICT,
|
||||
event_id UUID NOT NULL REFERENCES events(id) ON DELETE RESTRICT,
|
||||
event_release_id UUID NOT NULL REFERENCES event_releases(id) ON DELETE RESTRICT,
|
||||
device_key TEXT NOT NULL,
|
||||
client_type TEXT NOT NULL CHECK (client_type IN ('app', 'wechat')),
|
||||
route_code TEXT,
|
||||
status TEXT NOT NULL DEFAULT 'launched' CHECK (status IN ('launched', 'running', 'finished', 'failed', 'cancelled')),
|
||||
session_token_hash TEXT NOT NULL UNIQUE,
|
||||
session_token_expires_at TIMESTAMPTZ NOT NULL,
|
||||
launched_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||
started_at TIMESTAMPTZ,
|
||||
ended_at TIMESTAMPTZ,
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
|
||||
);
|
||||
|
||||
CREATE INDEX game_sessions_user_id_idx ON game_sessions(user_id);
|
||||
CREATE INDEX game_sessions_event_id_idx ON game_sessions(event_id);
|
||||
CREATE INDEX game_sessions_status_idx ON game_sessions(status);
|
||||
|
||||
CREATE TRIGGER game_sessions_set_updated_at
|
||||
BEFORE UPDATE ON game_sessions
|
||||
FOR EACH ROW EXECUTE FUNCTION set_updated_at();
|
||||
|
||||
COMMIT;
|
||||
32
backend/migrations/0003_home.sql
Normal file
32
backend/migrations/0003_home.sql
Normal file
@@ -0,0 +1,32 @@
|
||||
BEGIN;
|
||||
|
||||
CREATE TABLE cards (
|
||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
card_public_id TEXT NOT NULL UNIQUE,
|
||||
tenant_id UUID NOT NULL REFERENCES tenants(id) ON DELETE CASCADE,
|
||||
entry_channel_id UUID REFERENCES entry_channels(id) ON DELETE SET NULL,
|
||||
card_type TEXT NOT NULL CHECK (card_type IN ('event', 'html', 'notice')),
|
||||
title TEXT NOT NULL,
|
||||
subtitle TEXT,
|
||||
cover_url TEXT,
|
||||
event_id UUID REFERENCES events(id) ON DELETE SET NULL,
|
||||
html_url TEXT,
|
||||
display_slot TEXT NOT NULL DEFAULT 'home_primary',
|
||||
display_priority INTEGER NOT NULL DEFAULT 0,
|
||||
status TEXT NOT NULL DEFAULT 'active' CHECK (status IN ('draft', 'active', 'disabled', 'archived')),
|
||||
starts_at TIMESTAMPTZ,
|
||||
ends_at TIMESTAMPTZ,
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
|
||||
);
|
||||
|
||||
CREATE INDEX cards_tenant_id_idx ON cards(tenant_id);
|
||||
CREATE INDEX cards_entry_channel_id_idx ON cards(entry_channel_id);
|
||||
CREATE INDEX cards_event_id_idx ON cards(event_id);
|
||||
CREATE INDEX cards_display_idx ON cards(display_slot, status, display_priority DESC);
|
||||
|
||||
CREATE TRIGGER cards_set_updated_at
|
||||
BEFORE UPDATE ON cards
|
||||
FOR EACH ROW EXECUTE FUNCTION set_updated_at();
|
||||
|
||||
COMMIT;
|
||||
26
backend/migrations/0004_results.sql
Normal file
26
backend/migrations/0004_results.sql
Normal file
@@ -0,0 +1,26 @@
|
||||
BEGIN;
|
||||
|
||||
CREATE TABLE session_results (
|
||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
session_id UUID NOT NULL UNIQUE REFERENCES game_sessions(id) ON DELETE CASCADE,
|
||||
result_status TEXT NOT NULL CHECK (result_status IN ('finished', 'failed', 'cancelled')),
|
||||
summary_jsonb JSONB NOT NULL DEFAULT '{}'::jsonb,
|
||||
final_duration_sec INTEGER,
|
||||
final_score INTEGER,
|
||||
completed_controls INTEGER,
|
||||
total_controls INTEGER,
|
||||
distance_meters NUMERIC(10,2),
|
||||
average_speed_kmh NUMERIC(8,3),
|
||||
max_heart_rate_bpm INTEGER,
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
|
||||
);
|
||||
|
||||
CREATE INDEX session_results_result_status_idx ON session_results(result_status);
|
||||
CREATE INDEX session_results_created_at_idx ON session_results(created_at DESC);
|
||||
|
||||
CREATE TRIGGER session_results_set_updated_at
|
||||
BEFORE UPDATE ON session_results
|
||||
FOR EACH ROW EXECUTE FUNCTION set_updated_at();
|
||||
|
||||
COMMIT;
|
||||
61
backend/migrations/0005_config_pipeline.sql
Normal file
61
backend/migrations/0005_config_pipeline.sql
Normal file
@@ -0,0 +1,61 @@
|
||||
BEGIN;
|
||||
|
||||
CREATE TABLE event_config_sources (
|
||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
event_id UUID NOT NULL REFERENCES events(id) ON DELETE CASCADE,
|
||||
source_version_no INTEGER NOT NULL,
|
||||
source_kind TEXT NOT NULL DEFAULT 'event_bundle' CHECK (source_kind IN ('event_bundle', 'manifest_only', 'preset')),
|
||||
schema_id TEXT NOT NULL DEFAULT 'event-source',
|
||||
schema_version TEXT NOT NULL DEFAULT '1',
|
||||
status TEXT NOT NULL DEFAULT 'draft' CHECK (status IN ('draft', 'active', 'archived')),
|
||||
source_jsonb JSONB NOT NULL DEFAULT '{}'::jsonb,
|
||||
notes TEXT,
|
||||
created_by_user_id UUID REFERENCES users(id) ON DELETE SET NULL,
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||
UNIQUE (event_id, source_version_no)
|
||||
);
|
||||
|
||||
CREATE INDEX event_config_sources_event_id_idx ON event_config_sources(event_id);
|
||||
CREATE INDEX event_config_sources_status_idx ON event_config_sources(status);
|
||||
|
||||
CREATE TABLE event_config_builds (
|
||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
event_id UUID NOT NULL REFERENCES events(id) ON DELETE CASCADE,
|
||||
source_id UUID NOT NULL REFERENCES event_config_sources(id) ON DELETE CASCADE,
|
||||
build_no INTEGER NOT NULL,
|
||||
build_status TEXT NOT NULL DEFAULT 'success' CHECK (build_status IN ('pending', 'running', 'success', 'failed', 'cancelled')),
|
||||
build_log TEXT,
|
||||
manifest_jsonb JSONB NOT NULL DEFAULT '{}'::jsonb,
|
||||
asset_index_jsonb JSONB NOT NULL DEFAULT '[]'::jsonb,
|
||||
created_by_user_id UUID REFERENCES users(id) ON DELETE SET NULL,
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||
UNIQUE (event_id, build_no)
|
||||
);
|
||||
|
||||
CREATE INDEX event_config_builds_event_id_idx ON event_config_builds(event_id);
|
||||
CREATE INDEX event_config_builds_source_id_idx ON event_config_builds(source_id);
|
||||
CREATE INDEX event_config_builds_status_idx ON event_config_builds(build_status);
|
||||
|
||||
ALTER TABLE event_releases
|
||||
ADD COLUMN build_id UUID REFERENCES event_config_builds(id) ON DELETE SET NULL;
|
||||
|
||||
CREATE INDEX event_releases_build_id_idx ON event_releases(build_id);
|
||||
|
||||
CREATE TABLE event_release_assets (
|
||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
event_release_id UUID NOT NULL REFERENCES event_releases(id) ON DELETE CASCADE,
|
||||
asset_type TEXT NOT NULL CHECK (asset_type IN ('manifest', 'mapmeta', 'tiles', 'playfield', 'content_html', 'media', 'other')),
|
||||
asset_key TEXT NOT NULL,
|
||||
asset_path TEXT,
|
||||
asset_url TEXT NOT NULL,
|
||||
checksum TEXT,
|
||||
size_bytes BIGINT,
|
||||
meta_jsonb JSONB NOT NULL DEFAULT '{}'::jsonb,
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||
UNIQUE (event_release_id, asset_key)
|
||||
);
|
||||
|
||||
CREATE INDEX event_release_assets_release_id_idx ON event_release_assets(event_release_id);
|
||||
CREATE INDEX event_release_assets_asset_type_idx ON event_release_assets(asset_type);
|
||||
|
||||
COMMIT;
|
||||
29
backend/scripts/start-dev.ps1
Normal file
29
backend/scripts/start-dev.ps1
Normal file
@@ -0,0 +1,29 @@
|
||||
Set-StrictMode -Version Latest
|
||||
$ErrorActionPreference = "Stop"
|
||||
|
||||
$scriptDir = Split-Path -Parent $MyInvocation.MyCommand.Path
|
||||
$backendDir = Split-Path -Parent $scriptDir
|
||||
|
||||
Set-Location $backendDir
|
||||
|
||||
$env:APP_ENV = if ($env:APP_ENV) { $env:APP_ENV } else { "development" }
|
||||
$env:HTTP_ADDR = if ($env:HTTP_ADDR) { $env:HTTP_ADDR } else { ":18090" }
|
||||
$env:DATABASE_URL = if ($env:DATABASE_URL) { $env:DATABASE_URL } else { "postgres://postgres:asdf*123@192.168.100.77:5432/cmr20260401?sslmode=disable" }
|
||||
$env:JWT_ACCESS_SECRET = if ($env:JWT_ACCESS_SECRET) { $env:JWT_ACCESS_SECRET } else { "change-me-in-production" }
|
||||
$env:AUTH_SMS_PROVIDER = if ($env:AUTH_SMS_PROVIDER) { $env:AUTH_SMS_PROVIDER } else { "console" }
|
||||
$env:WECHAT_MINI_DEV_PREFIX = if ($env:WECHAT_MINI_DEV_PREFIX) { $env:WECHAT_MINI_DEV_PREFIX } else { "dev-" }
|
||||
|
||||
Write-Host "CMR backend dev server" -ForegroundColor Cyan
|
||||
Write-Host ("APP_ENV=" + $env:APP_ENV)
|
||||
Write-Host ("HTTP_ADDR=" + $env:HTTP_ADDR)
|
||||
Write-Host ("DATABASE_URL=" + $env:DATABASE_URL)
|
||||
Write-Host ""
|
||||
Write-Host "Workbench:" -ForegroundColor Yellow
|
||||
$workbenchAddr = $env:HTTP_ADDR
|
||||
if ($workbenchAddr.StartsWith(":")) {
|
||||
$workbenchAddr = "127.0.0.1" + $workbenchAddr
|
||||
}
|
||||
Write-Host ("http://" + $workbenchAddr + "/dev/workbench")
|
||||
Write-Host ""
|
||||
|
||||
go run .\cmd\api
|
||||
Reference in New Issue
Block a user