Add backend foundation and config-driven workbench

This commit is contained in:
2026-04-01 15:01:44 +08:00
parent 88b8f05f03
commit 94a1f0ba78
68 changed files with 10833 additions and 0 deletions

20
backend/.env.example Normal file
View 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
View 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
View 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
View 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
View 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 运行态语义和故障恢复放弃语义定稳,再继续扩后台配置系统。

View 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 P0launch 进入地图
可接:
1. 前置业务页拿到 event play
2.`POST /events/{eventPublicID}/launch`
3. 把返回结果映射成 `GameLaunchEnvelope`
4. `navigateTo('/pages/map/map?...')`
当前缺口:
- 还没有一层 `backend launch -> GameLaunchEnvelope` 的适配函数
- 当前 `gameLaunch.ts` 仍偏 demo/static config 驱动
## 5.3 P0finish 回传结果
可接:
1. 地图页结束一局
2. 提取结果摘要
3.`sessionId + sessionToken``POST /sessions/{id}/finish`
4. 业务页或结果页再查 `GET /sessions/{id}/result`
当前缺口:
- 小程序本地结果页已经有摘要,但还没有正式调用 backend finish
- finish payload 和本地 `resultSummary` 之间还需要一层映射
## 6. 当前还不能说已经接通的链路
## 6.1 配置后台 source/build/release
backend 当前已经有:
- 表结构
- 文档模型
但还没有真正开放:
- `config source`
- `build`
- `release assets`
- `preview launch`
也就是说:
**配置后台链还不能联调,只能联业务主链。**
## 6.2 body profile / 遥测个体化
小程序已经有:
- 身体数据入口
- 遥测 runtime profile
backend 文档里也规划了:
- 用户身体资料
但当前接口清单里还没有明确的 body profile 读接口落到小程序链上,所以这条还不能算当前联调主线。
## 7. 当前最大的接口适配缺口
我认为目前最大缺口只有 4 个:
### 7.1 业务 API 客户端缺失
小程序当前缺:
- 统一 `request` 封装
- token 持久化
- access token 刷新
- backend DTO -> 小程序 view model 适配
### 7.2 launch 适配层缺失
需要一层明确的转换:
`LaunchResponse -> GameLaunchEnvelope`
这里最适合单独做成一个小模块,而不是散落在页面里。
### 7.3 session finish 映射缺失
地图页当前本地已经有:
- 用时
- 分数
- 完成点数
- 里程
- 速度
- 最大心率
但还没有一个稳定函数把它映射为 backend finish payload。
### 7.4 业务结果页与地图结果页还未打通
现在地图页已经有自己的结果页。
后面要决定:
- 地图页结果页先本地展示,再异步回传
- 还是 finish 成功后跳业务结果页
这件事需要前后端统一策略。
## 8. 推荐联调顺序
建议按下面顺序推进,不要跳步:
### 第一步:接微信小程序登录
目标:
- 小程序拿到 `accessToken`
- 能请求鉴权接口
### 第二步:接 event play
目标:
- 小程序业务页能拿到:
- `event`
- `resolvedRelease`
- `play.canLaunch`
- `play.ongoingSession`
### 第三步:接 launch -> map
目标:
- 从后端 launch 返回直接进入地图
- 不再靠 demo preset 手工切配置
### 第四步:接 start / finish / result
目标:
- 开赛后能回传 start
- 结束后能回传 finish
- 结果页能查 backend result
### 第五步:再考虑 ongoing session 恢复
目标:
- backend ongoing session
- 本地故障恢复
两条链统一口径
## 9. 当前已落地的小程序联调适配
小程序侧当前已经补了第一批适配层:
- [backendAuth.ts](D:/dev/cmr-mini/miniprogram/utils/backendAuth.ts)
- [backendApi.ts](D:/dev/cmr-mini/miniprogram/utils/backendApi.ts)
- [backendLaunchAdapter.ts](D:/dev/cmr-mini/miniprogram/utils/backendLaunchAdapter.ts)
- [index.ts](D:/dev/cmr-mini/miniprogram/pages/index/index.ts)
当前已具备:
- 后端 base URL 本地持久化
- access / refresh token 本地持久化
- 微信小程序登录请求封装
- `event play` 请求封装
- `launch -> GameLaunchEnvelope` 适配
- 从首页直接 `launch` 进入地图
- 地图页 `session start / finish` 上报接入
因此当前主链已从“可分析”进入“可实测”。
## 10. 我建议的最近行动项
如果开始联调,我建议先做这 3 件事:
1. 新增小程序 `backendApi` 请求层
先只包 auth / event play / launch / session finish
2. 新增 `launchAdapter`
把 backend launch 响应稳定转成 `GameLaunchEnvelope`
3. 新增 `finishAdapter`
把地图页结果摘要稳定转成 backend finish payload
这三件做完,前后端主链就能真正接起来。
## 11. 一句话结论
当前最真实的进度判断是:
> backend 业务后端主链已经进入可联调阶段;小程序地图运行内核也已经具备承接能力;下一步最值钱的是补小程序业务 API 层和 launch/finish 两个适配器。

View File

@@ -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。

View 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`

View 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不要先拍脑袋建大而全表。

View 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`

View 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 直接暴露给实时网关。

View 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
View 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
View 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=

View 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()
}
}

View 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
}

View 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
}

View 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,
},
})
}

View 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})
}

File diff suppressed because it is too large Load Diff

View 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})
}

View 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})
}

View 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})
}

View 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})
}

View 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",
},
})
}

View 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,
}
}

View 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})
}

View 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})
}

View 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})
}

View 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})
}

View 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
}

View 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
}

View 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)
}

View 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
}

View 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
}

View 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
}

View 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
}

View 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
}

View 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)
}

View 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
}

View 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
}

View 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
}

View 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
}

View 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
}

View 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
}

View 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
}

View 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
}

View 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
}

View 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)
}

View File

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

View 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
}

View 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
}

View 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
}

View 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)
}

View 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
}

View 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
}

View 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
}

View 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
}

View 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
}

View 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
}

View 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
}

View 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;

View 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;

View 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;

View 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;

View 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;

View 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

292
todolist.md Normal file
View File

@@ -0,0 +1,292 @@
# CMR 联调协作清单
本文档用于后端、前端和你之间的联调协作。
约定:
- 所有新开发事项先进入 `待确认事项`,只有你确认后才能移动到 `已确认可开发`
- 我会在这里提出后端接入要求、接口变更和联调建议
- 前端同学可以在这里补页面进度、阻塞问题和接口反馈
- 这里是协作清单,不替代正式接口文档和方案文档
状态说明:
- `待确认`:已提出,但未获你确认
- `已确认`:你已确认,可以进入开发
- `联调中`:前后端已经开始接
- `已完成`:开发和联调完成
- `阻塞`:存在明确阻塞项
---
## 1. 当前联调目标
当前优先目标:
- 把前台壳层的登录、首页、活动详情、开始前准备、结果页先接通
- 让配置导入、preview、publish、launch 这条配置驱动链可被稳定验证
- 用 workbench 和接口文档降低前后端联调成本
当前后端已经具备:
- 统一登录
- 微信小程序登录
- 手机号绑定与账号合并
- 入口解析
- 首页聚合
- 活动详情与 play 聚合
- launch / session / result 主链路
- 配置导入 / preview / publish
- API workbench
相关文档:
- [后端总览 README](D:/dev/cmr-mini/backend/README.md)
- [接口清单](D:/dev/cmr-mini/backend/docs/接口清单.md)
- [开发说明](D:/dev/cmr-mini/backend/docs/开发说明.md)
- [配置管理方案](D:/dev/cmr-mini/backend/docs/配置管理方案.md)
---
## 2. 待确认事项
### T-001 首页首批页面范围
- 状态:`待确认`
- 建议负责人:你
- 说明:建议前端首批只接这 5 个页面,不要同时铺太多页面
- 建议范围:
- 登录页
- 首页
- 活动详情页
- 开始前准备页
- 结果页
### T-002 配置驱动联调入口
- 状态:`待确认`
- 建议负责人:你
- 说明:建议首批统一使用 `evt_demo_001` 做联调,不在前端直接读根目录 `event` 文件,统一由后端 `release/manifest` 下发
### T-003 前端联调顺序
- 状态:`待确认`
- 建议负责人:你
- 说明:建议按这个顺序接,避免页面壳层先做散
- 建议顺序:
- 登录
- 首页
- 活动详情 / play
- launch
- session start / finish
- result
---
## 3. 已确认可开发
暂无。
---
## 4. 前端待接接口
### F-001 登录页接入
- 状态:`待确认`
- 建议负责人:前端
- 页面:登录页
- 接口:
- `POST /auth/login/wechat-mini`
- `POST /auth/sms/send`
- `POST /auth/login/sms`
- `POST /auth/bind/mobile`
- 说明:
- APP 以手机号登录为主
- 小程序可先微信登录,后续再绑定手机号
### F-002 首页接入
- 状态:`待确认`
- 建议负责人:前端
- 页面:首页
- 接口:
- `GET /me/entry-home`
- 说明:
- 首页不要自己拼多个接口
- 直接以聚合接口为主
### F-003 活动详情与开始前准备接入
- 状态:`待确认`
- 建议负责人:前端
- 页面:活动详情页、开始前准备页
- 接口:
- `GET /events/{eventPublicID}/play`
- `POST /events/{eventPublicID}/launch`
- 说明:
- `play` 用于决定按钮文案和状态
- `launch` 成功后进入游戏
### F-004 结果页接入
- 状态:`待确认`
- 建议负责人:前端
- 页面:结果页
- 接口:
- `GET /sessions/{sessionPublicID}/result`
- `GET /me/results`
- 说明:
- 单局页用 `session result`
- 列表页用 `my results`
### F-005 我的页接入
- 状态:`待确认`
- 建议负责人:前端
- 页面:我的页
- 接口:
- `GET /me/profile`
- 说明:
- 不建议前端自己拼绑定信息和最近记录
---
## 5. 后端待补能力
### B-001 发布后的 release 管理
- 状态:`待确认`
- 建议负责人:后端
- 说明:当前已经支持 import / preview / publish但还缺正式的 release 列表、回滚和历史查看
### B-002 更通用的 play context
- 状态:`待确认`
- 建议负责人:后端
- 说明:当前 launch 仍是 `event` 入口为主,后续需要抽象成更通用的 `play context -> launch`
### B-003 配置校验报告
- 状态:`待确认`
- 建议负责人:后端
- 说明:当前 preview 已可用,但还缺面向配置运营的结构化校验报告
---
## 6. 当前后端已可联调接口
登录与用户:
- `POST /auth/sms/send`
- `POST /auth/login/sms`
- `POST /auth/login/wechat-mini`
- `POST /auth/bind/mobile`
- `GET /me`
- `GET /me/profile`
首页与入口:
- `GET /entry/resolve`
- `GET /home`
- `GET /cards`
- `GET /me/entry-home`
活动与游戏启动:
- `GET /events/{eventPublicID}`
- `GET /events/{eventPublicID}/play`
- `POST /events/{eventPublicID}/launch`
局内与结果:
- `GET /sessions/{sessionPublicID}`
- `POST /sessions/{sessionPublicID}/start`
- `POST /sessions/{sessionPublicID}/finish`
- `GET /sessions/{sessionPublicID}/result`
- `GET /me/sessions`
- `GET /me/results`
配置管理:
- `GET /dev/config/local-files`
- `POST /dev/events/{eventPublicID}/config-sources/import-local`
- `POST /dev/config-builds/preview`
- `POST /dev/config-builds/publish`
开发工具:
- `POST /dev/bootstrap-demo`
- `GET /dev/workbench`
---
## 7. 联调建议
### S-001 前端联调统一入口
- 状态:`待确认`
- 建议负责人:后端 + 前端
- 说明:建议首批所有联调都通过 demo 数据进行,统一使用:
- `channelCode=mini-demo`
- `channelType=wechat_mini`
- `eventPublicID=evt_demo_001`
### S-002 配置驱动约束
- 状态:`待确认`
- 建议负责人:后端 + 前端
- 说明:
- 前端进入游戏时不要直接拼根目录配置文件路径
- 必须使用后端下发的:
- `releaseId`
- `manifestUrl`
- `manifestChecksumSha256`
### S-003 联调工具优先级
- 状态:`已确认`
- 建议负责人:后端
- 说明:
- 日常联调优先使用 workbench
- 接口说明优先看 workbench 里的 `API 列表`
- 深入字段说明再看 [接口清单](D:/dev/cmr-mini/backend/docs/接口清单.md)
---
## 8. 阻塞记录
暂无。
---
## 9. 完成记录
### R-001 后端主链路已打通
- 状态:`已完成`
- 负责人:后端
- 说明:
- 登录
- 首页聚合
- 活动 play
- launch
- session
- result
### R-002 配置 import / preview / publish 已打通
- 状态:`已完成`
- 负责人:后端
- 说明:
- 已验证 `import-local -> preview -> publish -> launch`
### R-003 API workbench 已上线
- 状态:`已完成`
- 负责人:后端
- 说明:
- 已支持中文 API 列表
- 已支持 quick flows
- 已支持场景保存
- 已支持配置发布链调试