diff --git a/backend/.env.example b/backend/.env.example
new file mode 100644
index 0000000..9871a00
--- /dev/null
+++ b/backend/.env.example
@@ -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
diff --git a/backend/README.md b/backend/README.md
new file mode 100644
index 0000000..2212c2b
--- /dev/null
+++ b/backend/README.md
@@ -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`
diff --git a/backend/cmd/api/main.go b/backend/cmd/api/main.go
new file mode 100644
index 0000000..4537da1
--- /dev/null
+++ b/backend/cmd/api/main.go
@@ -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)
+ }
+}
diff --git a/backend/docs/README.md b/backend/docs/README.md
new file mode 100644
index 0000000..dd44801
--- /dev/null
+++ b/backend/docs/README.md
@@ -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)
diff --git a/backend/docs/todolist.md b/backend/docs/todolist.md
new file mode 100644
index 0000000..346aba2
--- /dev/null
+++ b/backend/docs/todolist.md
@@ -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 运行态语义和故障恢复放弃语义定稳,再继续扩后台配置系统。
diff --git a/backend/docs/前后端联调清单.md b/backend/docs/前后端联调清单.md
new file mode 100644
index 0000000..07a3f24
--- /dev/null
+++ b/backend/docs/前后端联调清单.md
@@ -0,0 +1,369 @@
+# 前后端联调清单
+
+## 1. 目的
+
+这份清单只回答三件事:
+
+1. 小程序当前已经具备哪些接后端的前置能力
+2. backend 当前已经提供了哪些可联调接口
+3. 哪些链路已经能接,哪些链路还缺适配
+
+本文不讨论未来大而全后台方案,只服务当前联调落地。
+
+## 2. 当前结论
+
+当前状态可以概括成一句话:
+
+> backend 业务主链已经可联调;小程序地图运行内核也已经成型;两边之间还缺一层业务接入和会话上报适配。
+
+也就是说:
+
+- 登录、活动详情、launch、session、result 这一条后端链已经可用
+- 小程序地图页已经支持携带 `configUrl / releaseId / sessionId / sessionToken`
+- 但小程序当前仍主要走本地 demo / 直连 OSS manifest
+- 真正的“后端 launch -> 地图页 -> session start/finish/result”还没有正式接上
+
+## 3. 小程序当前已具备的联调基础
+
+## 3.1 启动信封已经成型
+
+地图页不是只吃一个 `configUrl`,而是吃一份启动信封:
+
+- [gameLaunch.ts](D:/dev/cmr-mini/miniprogram/utils/gameLaunch.ts)
+
+当前结构:
+
+- `config.configUrl`
+- `config.configLabel`
+- `config.configChecksumSha256`
+- `config.releaseId`
+- `config.routeCode`
+- `business.source`
+- `business.competitionId`
+- `business.eventId`
+- `business.launchRequestId`
+- `business.participantId`
+- `business.sessionId`
+- `business.sessionToken`
+- `business.sessionTokenExpiresAt`
+- `business.realtimeEndpoint`
+- `business.realtimeToken`
+
+这意味着:
+
+- backend `launch` 返回的数据结构已经能自然装进小程序地图启动链
+- 地图页并不需要重构启动模型,只需要把业务页接到 `GameLaunchEnvelope`
+
+## 3.2 地图页已经支持远端 manifest 启动
+
+- [map.ts](D:/dev/cmr-mini/miniprogram/pages/map/map.ts)
+
+当前地图页会:
+
+1. 解析 `GameLaunchEnvelope`
+2. 调 `loadRemoteMapConfig(configUrl)`
+3. 编译 runtime profile
+4. 启动 `MapEngine`
+
+所以只要后端能给出:
+
+- `manifestUrl`
+- `releaseId`
+- `configChecksumSha256`
+
+地图页就可以直接跑。
+
+## 3.3 会话态字段已经进入地图页
+
+地图页当前已经能接收并持有:
+
+- `sessionId`
+- `sessionToken`
+- `sessionTokenExpiresAt`
+
+这说明后面接:
+
+- `POST /sessions/{id}/start`
+- `POST /sessions/{id}/finish`
+
+不需要再改地图启动协议。
+
+## 3.4 故障恢复也已经具备会话上下文承载
+
+故障恢复快照当前会保留:
+
+- `launchEnvelope`
+- 运行态快照
+
+这意味着一旦接入后端 session 后,恢复链也可以继续沿用同一份 `launchEnvelope`。
+
+## 4. backend 当前已具备的联调基础
+
+## 4.1 路由主链已落地
+
+- [router.go](D:/dev/cmr-mini/backend/internal/httpapi/router.go)
+
+当前已实现:
+
+- `POST /auth/login/wechat-mini`
+- `GET /me/entry-home`
+- `GET /events/{eventPublicID}/play`
+- `POST /events/{eventPublicID}/launch`
+- `POST /sessions/{sessionPublicID}/start`
+- `POST /sessions/{sessionPublicID}/finish`
+- `GET /sessions/{sessionPublicID}/result`
+- `GET /me/results`
+
+## 4.2 launch 返回结构已贴近客户端
+
+- [核心流程.md](D:/dev/cmr-mini/backend/docs/核心流程.md)
+- [接口清单.md](D:/dev/cmr-mini/backend/docs/接口清单.md)
+
+当前 `launch` 返回重点:
+
+- `launch.resolvedRelease.releaseId`
+- `launch.resolvedRelease.manifestUrl`
+- `launch.resolvedRelease.manifestChecksumSha256`
+- `launch.business.sessionId`
+- `launch.business.sessionToken`
+
+这和小程序 `GameLaunchEnvelope` 基本是同一语义。
+
+## 4.3 session 运行态和结果态已分离
+
+- [session_service.go](D:/dev/cmr-mini/backend/internal/service/session_service.go)
+
+当前已经区分:
+
+- 业务登录态:`access_token`
+- 局内运行态:`sessionToken`
+
+这对地图页是对的,因为地图页真正需要的是:
+
+- 进入前有业务 token
+- 进入后局内动作用 sessionToken
+
+## 4.4 开发 workbench 已可用于联调
+
+- [dev_handler.go](D:/dev/cmr-mini/backend/internal/httpapi/handlers/dev_handler.go)
+
+当前 workbench 已能串:
+
+- bootstrap
+- auth
+- entry/home
+- event play / launch
+- session start / finish / detail
+- result 查询
+
+这对前后端联调非常有价值,说明后端已经不是“只看文档”阶段。
+
+## 5. 当前已经能接的链路
+
+## 5.1 P0:登录与业务页前置链
+
+可接:
+
+1. 小程序 `wx.login`
+2. `POST /auth/login/wechat-mini`
+3. 拿到 `accessToken`
+4. 调 `GET /me/entry-home`
+5. 调 `GET /events/{eventPublicID}/play`
+
+当前缺口:
+
+- 小程序还没有正式业务页 API 适配层
+- 还没有统一 token 持久化与请求封装
+
+## 5.2 P0:launch 进入地图
+
+可接:
+
+1. 前置业务页拿到 event play
+2. 调 `POST /events/{eventPublicID}/launch`
+3. 把返回结果映射成 `GameLaunchEnvelope`
+4. `navigateTo('/pages/map/map?...')`
+
+当前缺口:
+
+- 还没有一层 `backend launch -> GameLaunchEnvelope` 的适配函数
+- 当前 `gameLaunch.ts` 仍偏 demo/static config 驱动
+
+## 5.3 P0:finish 回传结果
+
+可接:
+
+1. 地图页结束一局
+2. 提取结果摘要
+3. 用 `sessionId + sessionToken` 调 `POST /sessions/{id}/finish`
+4. 业务页或结果页再查 `GET /sessions/{id}/result`
+
+当前缺口:
+
+- 小程序本地结果页已经有摘要,但还没有正式调用 backend finish
+- finish payload 和本地 `resultSummary` 之间还需要一层映射
+
+## 6. 当前还不能说已经接通的链路
+
+## 6.1 配置后台 source/build/release
+
+backend 当前已经有:
+
+- 表结构
+- 文档模型
+
+但还没有真正开放:
+
+- `config source`
+- `build`
+- `release assets`
+- `preview launch`
+
+也就是说:
+
+**配置后台链还不能联调,只能联业务主链。**
+
+## 6.2 body profile / 遥测个体化
+
+小程序已经有:
+
+- 身体数据入口
+- 遥测 runtime profile
+
+backend 文档里也规划了:
+
+- 用户身体资料
+
+但当前接口清单里还没有明确的 body profile 读接口落到小程序链上,所以这条还不能算当前联调主线。
+
+## 7. 当前最大的接口适配缺口
+
+我认为目前最大缺口只有 4 个:
+
+### 7.1 业务 API 客户端缺失
+
+小程序当前缺:
+
+- 统一 `request` 封装
+- token 持久化
+- access token 刷新
+- backend DTO -> 小程序 view model 适配
+
+### 7.2 launch 适配层缺失
+
+需要一层明确的转换:
+
+`LaunchResponse -> GameLaunchEnvelope`
+
+这里最适合单独做成一个小模块,而不是散落在页面里。
+
+### 7.3 session finish 映射缺失
+
+地图页当前本地已经有:
+
+- 用时
+- 分数
+- 完成点数
+- 里程
+- 速度
+- 最大心率
+
+但还没有一个稳定函数把它映射为 backend finish payload。
+
+### 7.4 业务结果页与地图结果页还未打通
+
+现在地图页已经有自己的结果页。
+
+后面要决定:
+
+- 地图页结果页先本地展示,再异步回传
+- 还是 finish 成功后跳业务结果页
+
+这件事需要前后端统一策略。
+
+## 8. 推荐联调顺序
+
+建议按下面顺序推进,不要跳步:
+
+### 第一步:接微信小程序登录
+
+目标:
+
+- 小程序拿到 `accessToken`
+- 能请求鉴权接口
+
+### 第二步:接 event play
+
+目标:
+
+- 小程序业务页能拿到:
+ - `event`
+ - `resolvedRelease`
+ - `play.canLaunch`
+ - `play.ongoingSession`
+
+### 第三步:接 launch -> map
+
+目标:
+
+- 从后端 launch 返回直接进入地图
+- 不再靠 demo preset 手工切配置
+
+### 第四步:接 start / finish / result
+
+目标:
+
+- 开赛后能回传 start
+- 结束后能回传 finish
+- 结果页能查 backend result
+
+### 第五步:再考虑 ongoing session 恢复
+
+目标:
+
+- backend ongoing session
+- 本地故障恢复
+
+两条链统一口径
+
+## 9. 当前已落地的小程序联调适配
+
+小程序侧当前已经补了第一批适配层:
+
+- [backendAuth.ts](D:/dev/cmr-mini/miniprogram/utils/backendAuth.ts)
+- [backendApi.ts](D:/dev/cmr-mini/miniprogram/utils/backendApi.ts)
+- [backendLaunchAdapter.ts](D:/dev/cmr-mini/miniprogram/utils/backendLaunchAdapter.ts)
+- [index.ts](D:/dev/cmr-mini/miniprogram/pages/index/index.ts)
+
+当前已具备:
+
+- 后端 base URL 本地持久化
+- access / refresh token 本地持久化
+- 微信小程序登录请求封装
+- `event play` 请求封装
+- `launch -> GameLaunchEnvelope` 适配
+- 从首页直接 `launch` 进入地图
+- 地图页 `session start / finish` 上报接入
+
+因此当前主链已从“可分析”进入“可实测”。
+
+## 10. 我建议的最近行动项
+
+如果开始联调,我建议先做这 3 件事:
+
+1. 新增小程序 `backendApi` 请求层
+ 先只包 auth / event play / launch / session finish
+
+2. 新增 `launchAdapter`
+ 把 backend launch 响应稳定转成 `GameLaunchEnvelope`
+
+3. 新增 `finishAdapter`
+ 把地图页结果摘要稳定转成 backend finish payload
+
+这三件做完,前后端主链就能真正接起来。
+
+## 11. 一句话结论
+
+当前最真实的进度判断是:
+
+> backend 业务后端主链已经进入可联调阶段;小程序地图运行内核也已经具备承接能力;下一步最值钱的是补小程序业务 API 层和 launch/finish 两个适配器。
diff --git a/backend/docs/开发说明.md b/backend/docs/开发说明.md
new file mode 100644
index 0000000..8722c2d
--- /dev/null
+++ b/backend/docs/开发说明.md
@@ -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。
diff --git a/backend/docs/接口清单.md b/backend/docs/接口清单.md
new file mode 100644
index 0000000..c895bd5
--- /dev/null
+++ b/backend/docs/接口清单.md
@@ -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`
diff --git a/backend/docs/数据模型.md b/backend/docs/数据模型.md
new file mode 100644
index 0000000..376b385
--- /dev/null
+++ b/backend/docs/数据模型.md
@@ -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,不要先拍脑袋建大而全表。
diff --git a/backend/docs/核心流程.md b/backend/docs/核心流程.md
new file mode 100644
index 0000000..2619be3
--- /dev/null
+++ b/backend/docs/核心流程.md
@@ -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`
diff --git a/backend/docs/系统架构.md b/backend/docs/系统架构.md
new file mode 100644
index 0000000..2b78477
--- /dev/null
+++ b/backend/docs/系统架构.md
@@ -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 直接暴露给实时网关。
diff --git a/backend/docs/配置管理方案.md b/backend/docs/配置管理方案.md
new file mode 100644
index 0000000..7ab9fea
--- /dev/null
+++ b/backend/docs/配置管理方案.md
@@ -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
+
+这样以后无论你配置项怎么继续长,主架构都还能撑住。
diff --git a/backend/go.mod b/backend/go.mod
new file mode 100644
index 0000000..29029dc
--- /dev/null
+++ b/backend/go.mod
@@ -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
+)
diff --git a/backend/go.sum b/backend/go.sum
new file mode 100644
index 0000000..ad68e7a
--- /dev/null
+++ b/backend/go.sum
@@ -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=
diff --git a/backend/internal/app/app.go b/backend/internal/app/app.go
new file mode 100644
index 0000000..b2adba4
--- /dev/null
+++ b/backend/internal/app/app.go
@@ -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()
+ }
+}
diff --git a/backend/internal/app/config.go b/backend/internal/app/config.go
new file mode 100644
index 0000000..0ec5ce7
--- /dev/null
+++ b/backend/internal/app/config.go
@@ -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
+}
diff --git a/backend/internal/apperr/apperr.go b/backend/internal/apperr/apperr.go
new file mode 100644
index 0000000..19c2c6c
--- /dev/null
+++ b/backend/internal/apperr/apperr.go
@@ -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
+}
diff --git a/backend/internal/httpapi/handlers/auth_handler.go b/backend/internal/httpapi/handlers/auth_handler.go
new file mode 100644
index 0000000..a23276f
--- /dev/null
+++ b/backend/internal/httpapi/handlers/auth_handler.go
@@ -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,
+ },
+ })
+}
diff --git a/backend/internal/httpapi/handlers/config_handler.go b/backend/internal/httpapi/handlers/config_handler.go
new file mode 100644
index 0000000..c6e9a9b
--- /dev/null
+++ b/backend/internal/httpapi/handlers/config_handler.go
@@ -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})
+}
diff --git a/backend/internal/httpapi/handlers/dev_handler.go b/backend/internal/httpapi/handlers/dev_handler.go
new file mode 100644
index 0000000..0a9a52d
--- /dev/null
+++ b/backend/internal/httpapi/handlers/dev_handler.go
@@ -0,0 +1,1588 @@
+package handlers
+
+import (
+ "net/http"
+
+ "cmr-backend/internal/httpx"
+ "cmr-backend/internal/service"
+)
+
+type DevHandler struct {
+ devService *service.DevService
+}
+
+func NewDevHandler(devService *service.DevService) *DevHandler {
+ return &DevHandler{devService: devService}
+}
+
+func (h *DevHandler) BootstrapDemo(w http.ResponseWriter, r *http.Request) {
+ result, err := h.devService.BootstrapDemo(r.Context())
+ if err != nil {
+ httpx.WriteError(w, err)
+ return
+ }
+ httpx.WriteJSON(w, http.StatusOK, map[string]any{"data": result})
+}
+
+func (h *DevHandler) Workbench(w http.ResponseWriter, r *http.Request) {
+ if !h.devService.Enabled() {
+ http.NotFound(w, r)
+ return
+ }
+
+ w.Header().Set("Content-Type", "text/html; charset=utf-8")
+ _, _ = w.Write([]byte(devWorkbenchHTML))
+}
+
+const devWorkbenchHTML = `
+
+
+
+
+ CMR Backend Workbench
+
+
+
+
+
+
Developer Workbench
+
CMR Backend API Flow Panel
+
把入口、登录、首页、活动详情、launch、session、profile 串成一条完整调试链。这个页面只在非 production 环境开放,适合后续继续扩展成你想要的 API 测试面板。
+
+
+
+
+ 1. Bootstrap
+ 初始化 demo tenant / channel / event / card。
+
+
+
+
+
默认入口 tenant_demo / mini-demo / evt_demo_001
+
+
+
+
+
+
+ 3. Session State
+ 当前调试上下文,所有按钮共享这一组状态。
+
+
Access Token -
+
Refresh Token -
+
Source ID -
+
Build ID -
+
Release ID -
+
Session ID -
+
Session Token -
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ 9. Results
+
+
+
+
+
+
+
+ 10. Profile
+
+
+
+
+
+
+
+
+
+ 11. Quick Flows
+ 把常用接口串成一键工作流,减少重复点击。
+
+
+
+
+
+
+ 这些流程会复用当前表单里的手机号、设备、event、channel 等输入。
+
+
+
+ 12. Request Export
+ 最后一次请求会生成一条可复制的 curl,后面做问题复现会方便很多。
+
+
+
+
+
+
+
+
+
+
+ 13. Scenarios
+ 保存当前表单状态为可复用场景,也支持导入导出 JSON,适合后续切换不同俱乐部、入口和 event。
+
+
+
+
+
+
+
+
+
+
+
Scenario JSON
+
+
+
+
+
+
+
+
+
+ 14. Response Log
+ 最后一次请求的结果会记录在这里,便于后续做请求回放和用例保存。
+ ready
+
+
+
+
+
+
+ 15. Request History
+ 最近 12 次请求会保留在浏览器本地,刷新页面不会丢。
+
+
+
+
+
+
+ 16. API 列表
+ 把当前已实现接口按分组放进 workbench,直接看中文说明、鉴权要求和关键参数,不用来回翻文档。
+
+
+
+
GET/healthz
+
健康检查接口,用来确认服务是否存活。
+
+
+
+
+
POST/auth/sms/send
+
发送短信验证码,支持登录和绑定手机号两种场景。
+
+
+
+
+
POST/auth/login/sms
+
APP 主登录入口,使用手机号验证码登录并返回 access/refresh token。
+
+
+
+
+
POST/auth/login/wechat-mini
+
微信小程序登录入口。开发环境支持 dev- 前缀 code 直接模拟登录。
+
+
+
+
+
POST/auth/bind/mobile
+
已登录用户绑定手机号,必要时把微信轻账号合并到手机号主账号。
+
+
+
+
+
POST/auth/refresh
+
使用 refresh token 刷新 access token。
+
+
+
+
+
POST/auth/logout
+
登出并撤销 refresh token。
+
+
+
+
+
GET/entry/resolve
+
解析当前入口属于哪个 tenant / channel,是多俱乐部、多公众号接入的入口层基础接口。
+
+
+
+
+
GET/home
+
返回入口首页卡片数据。
+
+
+
+
+
GET/cards
+
只返回卡片列表,适合调试卡片数据本身。
+
+
+
+
+
GET/me/entry-home
+
首页聚合接口,返回用户、tenant、channel、cards、进行中 session 和最近一局。
+
+
+
+
+
GET/events/{eventPublicID}
+
活动详情接口,会带当前发布的 release 和 resolvedRelease。
+
+
+
+
+
GET/events/{eventPublicID}/play
+
活动详情页 / 开始前准备页聚合接口,判断是否可启动、继续还是查看上次结果。
+
+
+
+
+
POST/events/{eventPublicID}/launch
+
基于当前 event 的已发布 release 创建一局 session,并返回 config URL、releaseId、sessionToken。
+
+
+
+
+
GET/events/{eventPublicID}/config-sources
+
查看某个 event 下已经导入过的 source config 列表。
+
+
+
+
+
GET/config-sources/{sourceID}
+
查看单条 source config 明细。
+
+
+
+
+
GET/config-builds/{buildID}
+
查看单次 build 的 manifest 和 asset index。
+
+
+
+
+
GET/sessions/{sessionPublicID}
+
查询一局详情,带 session 状态、event 和 resolvedRelease。
+
+
+
+
+
POST/sessions/{sessionPublicID}/start
+
把 session 从 launched 推进到 running。
+
+
+
+
+
POST/sessions/{sessionPublicID}/finish
+
结束一局并沉淀结果摘要,是结果页数据的来源。
+
+
+
+
+
GET/me/sessions
+
查询用户最近 session 列表。
+
+
+
+
+
GET/sessions/{sessionPublicID}/result
+
单局结果页接口,返回 session 和 result。
+
+
+
+
+
GET/me/results
+
查询用户最近结果列表。
+
+
+
+
+
GET/me
+
返回当前用户基础信息。
+
+
+
+
+
GET/me/profile
+
“我的页”聚合接口,返回绑定概览、绑定项列表和最近记录摘要。
+
+
+
+
+
POST/dev/bootstrap-demo
+
开发态自举 demo 数据,会准备 tenant、channel、event、release、card、source、build。
+
+
+
+
+
GET/dev/config/local-files
+
列出本地配置目录中的 JSON 文件,作为 source config 导入入口。
+
+
+
+
+
POST/dev/events/{eventPublicID}/config-sources/import-local
+
从本地 event 目录导入 source config。
+
+
+
+
+
POST/dev/config-builds/preview
+
基于 source config 生成 preview build,并产出 preview manifest。
+
+
+
+
+
POST/dev/config-builds/publish
+
把成功的 build 发布成正式 release,并自动切换成当前 event 的可启动版本。
+
+
+
+
+
+
+
+
+
+`
diff --git a/backend/internal/httpapi/handlers/entry_handler.go b/backend/internal/httpapi/handlers/entry_handler.go
new file mode 100644
index 0000000..eac2fc3
--- /dev/null
+++ b/backend/internal/httpapi/handlers/entry_handler.go
@@ -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})
+}
diff --git a/backend/internal/httpapi/handlers/entry_home_handler.go b/backend/internal/httpapi/handlers/entry_home_handler.go
new file mode 100644
index 0000000..66a1fab
--- /dev/null
+++ b/backend/internal/httpapi/handlers/entry_home_handler.go
@@ -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})
+}
diff --git a/backend/internal/httpapi/handlers/event_handler.go b/backend/internal/httpapi/handlers/event_handler.go
new file mode 100644
index 0000000..d7eac5e
--- /dev/null
+++ b/backend/internal/httpapi/handlers/event_handler.go
@@ -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})
+}
diff --git a/backend/internal/httpapi/handlers/event_play_handler.go b/backend/internal/httpapi/handlers/event_play_handler.go
new file mode 100644
index 0000000..dbc7a79
--- /dev/null
+++ b/backend/internal/httpapi/handlers/event_play_handler.go
@@ -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})
+}
diff --git a/backend/internal/httpapi/handlers/health_handler.go b/backend/internal/httpapi/handlers/health_handler.go
new file mode 100644
index 0000000..5056d49
--- /dev/null
+++ b/backend/internal/httpapi/handlers/health_handler.go
@@ -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",
+ },
+ })
+}
diff --git a/backend/internal/httpapi/handlers/home_handler.go b/backend/internal/httpapi/handlers/home_handler.go
new file mode 100644
index 0000000..f980b8f
--- /dev/null
+++ b/backend/internal/httpapi/handlers/home_handler.go
@@ -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,
+ }
+}
diff --git a/backend/internal/httpapi/handlers/me_handler.go b/backend/internal/httpapi/handlers/me_handler.go
new file mode 100644
index 0000000..755ba92
--- /dev/null
+++ b/backend/internal/httpapi/handlers/me_handler.go
@@ -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})
+}
diff --git a/backend/internal/httpapi/handlers/profile_handler.go b/backend/internal/httpapi/handlers/profile_handler.go
new file mode 100644
index 0000000..8e9541f
--- /dev/null
+++ b/backend/internal/httpapi/handlers/profile_handler.go
@@ -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})
+}
diff --git a/backend/internal/httpapi/handlers/result_handler.go b/backend/internal/httpapi/handlers/result_handler.go
new file mode 100644
index 0000000..44f5805
--- /dev/null
+++ b/backend/internal/httpapi/handlers/result_handler.go
@@ -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})
+}
diff --git a/backend/internal/httpapi/handlers/session_handler.go b/backend/internal/httpapi/handlers/session_handler.go
new file mode 100644
index 0000000..f6f5f30
--- /dev/null
+++ b/backend/internal/httpapi/handlers/session_handler.go
@@ -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})
+}
diff --git a/backend/internal/httpapi/middleware/auth.go b/backend/internal/httpapi/middleware/auth.go
new file mode 100644
index 0000000..279d1af
--- /dev/null
+++ b/backend/internal/httpapi/middleware/auth.go
@@ -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
+}
diff --git a/backend/internal/httpapi/router.go b/backend/internal/httpapi/router.go
new file mode 100644
index 0000000..e6bb18f
--- /dev/null
+++ b/backend/internal/httpapi/router.go
@@ -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
+}
diff --git a/backend/internal/httpx/httpx.go b/backend/internal/httpx/httpx.go
new file mode 100644
index 0000000..8376a72
--- /dev/null
+++ b/backend/internal/httpx/httpx.go
@@ -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)
+}
diff --git a/backend/internal/platform/jwtx/jwt.go b/backend/internal/platform/jwtx/jwt.go
new file mode 100644
index 0000000..857a072
--- /dev/null
+++ b/backend/internal/platform/jwtx/jwt.go
@@ -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
+}
diff --git a/backend/internal/platform/security/token.go b/backend/internal/platform/security/token.go
new file mode 100644
index 0000000..571552c
--- /dev/null
+++ b/backend/internal/platform/security/token.go
@@ -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
+}
diff --git a/backend/internal/platform/wechatmini/client.go b/backend/internal/platform/wechatmini/client.go
new file mode 100644
index 0000000..ee9a124
--- /dev/null
+++ b/backend/internal/platform/wechatmini/client.go
@@ -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
+}
diff --git a/backend/internal/service/auth_service.go b/backend/internal/service/auth_service.go
new file mode 100644
index 0000000..de0b00a
--- /dev/null
+++ b/backend/internal/service/auth_service.go
@@ -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
+}
diff --git a/backend/internal/service/config_service.go b/backend/internal/service/config_service.go
new file mode 100644
index 0000000..248e2a5
--- /dev/null
+++ b/backend/internal/service/config_service.go
@@ -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
+}
diff --git a/backend/internal/service/dev_service.go b/backend/internal/service/dev_service.go
new file mode 100644
index 0000000..92050d7
--- /dev/null
+++ b/backend/internal/service/dev_service.go
@@ -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)
+}
diff --git a/backend/internal/service/entry_home_service.go b/backend/internal/service/entry_home_service.go
new file mode 100644
index 0000000..9e6ed6f
--- /dev/null
+++ b/backend/internal/service/entry_home_service.go
@@ -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
+}
diff --git a/backend/internal/service/entry_service.go b/backend/internal/service/entry_service.go
new file mode 100644
index 0000000..6bb2152
--- /dev/null
+++ b/backend/internal/service/entry_service.go
@@ -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
+}
diff --git a/backend/internal/service/event_play_service.go b/backend/internal/service/event_play_service.go
new file mode 100644
index 0000000..e3fc5ef
--- /dev/null
+++ b/backend/internal/service/event_play_service.go
@@ -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
+}
diff --git a/backend/internal/service/event_service.go b/backend/internal/service/event_service.go
new file mode 100644
index 0000000..833bbbf
--- /dev/null
+++ b/backend/internal/service/event_service.go
@@ -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
+}
diff --git a/backend/internal/service/home_service.go b/backend/internal/service/home_service.go
new file mode 100644
index 0000000..d991bfa
--- /dev/null
+++ b/backend/internal/service/home_service.go
@@ -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
+}
diff --git a/backend/internal/service/me_service.go b/backend/internal/service/me_service.go
new file mode 100644
index 0000000..8a44aa6
--- /dev/null
+++ b/backend/internal/service/me_service.go
@@ -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
+}
diff --git a/backend/internal/service/profile_service.go b/backend/internal/service/profile_service.go
new file mode 100644
index 0000000..0e02c09
--- /dev/null
+++ b/backend/internal/service/profile_service.go
@@ -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
+}
diff --git a/backend/internal/service/release_view.go b/backend/internal/service/release_view.go
new file mode 100644
index 0000000..a605fab
--- /dev/null
+++ b/backend/internal/service/release_view.go
@@ -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
+}
diff --git a/backend/internal/service/result_service.go b/backend/internal/service/result_service.go
new file mode 100644
index 0000000..a476720
--- /dev/null
+++ b/backend/internal/service/result_service.go
@@ -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
+}
diff --git a/backend/internal/service/session_service.go b/backend/internal/service/session_service.go
new file mode 100644
index 0000000..cdda049
--- /dev/null
+++ b/backend/internal/service/session_service.go
@@ -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)
+}
diff --git a/backend/internal/service/timeutil.go b/backend/internal/service/timeutil.go
new file mode 100644
index 0000000..d913bd8
--- /dev/null
+++ b/backend/internal/service/timeutil.go
@@ -0,0 +1,9 @@
+package service
+
+import "time"
+
+const timeRFC3339 = time.RFC3339
+
+func nowUTC() time.Time {
+ return time.Now().UTC()
+}
diff --git a/backend/internal/store/postgres/auth_store.go b/backend/internal/store/postgres/auth_store.go
new file mode 100644
index 0000000..3c3c4bd
--- /dev/null
+++ b/backend/internal/store/postgres/auth_store.go
@@ -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
+}
diff --git a/backend/internal/store/postgres/card_store.go b/backend/internal/store/postgres/card_store.go
new file mode 100644
index 0000000..394d35b
--- /dev/null
+++ b/backend/internal/store/postgres/card_store.go
@@ -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
+}
diff --git a/backend/internal/store/postgres/config_store.go b/backend/internal/store/postgres/config_store.go
new file mode 100644
index 0000000..5f03303
--- /dev/null
+++ b/backend/internal/store/postgres/config_store.go
@@ -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
+}
diff --git a/backend/internal/store/postgres/db.go b/backend/internal/store/postgres/db.go
new file mode 100644
index 0000000..6355119
--- /dev/null
+++ b/backend/internal/store/postgres/db.go
@@ -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)
+}
diff --git a/backend/internal/store/postgres/dev_store.go b/backend/internal/store/postgres/dev_store.go
new file mode 100644
index 0000000..e0d853b
--- /dev/null
+++ b/backend/internal/store/postgres/dev_store.go
@@ -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
+}
diff --git a/backend/internal/store/postgres/entry_store.go b/backend/internal/store/postgres/entry_store.go
new file mode 100644
index 0000000..5704749
--- /dev/null
+++ b/backend/internal/store/postgres/entry_store.go
@@ -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
+}
diff --git a/backend/internal/store/postgres/event_store.go b/backend/internal/store/postgres/event_store.go
new file mode 100644
index 0000000..f9c3daf
--- /dev/null
+++ b/backend/internal/store/postgres/event_store.go
@@ -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
+}
diff --git a/backend/internal/store/postgres/identity_store.go b/backend/internal/store/postgres/identity_store.go
new file mode 100644
index 0000000..50f5c41
--- /dev/null
+++ b/backend/internal/store/postgres/identity_store.go
@@ -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
+}
diff --git a/backend/internal/store/postgres/result_store.go b/backend/internal/store/postgres/result_store.go
new file mode 100644
index 0000000..1655242
--- /dev/null
+++ b/backend/internal/store/postgres/result_store.go
@@ -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
+}
diff --git a/backend/internal/store/postgres/session_store.go b/backend/internal/store/postgres/session_store.go
new file mode 100644
index 0000000..f54f6fa
--- /dev/null
+++ b/backend/internal/store/postgres/session_store.go
@@ -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
+}
diff --git a/backend/internal/store/postgres/user_store.go b/backend/internal/store/postgres/user_store.go
new file mode 100644
index 0000000..b48e46e
--- /dev/null
+++ b/backend/internal/store/postgres/user_store.go
@@ -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
+}
diff --git a/backend/migrations/0001_init.sql b/backend/migrations/0001_init.sql
new file mode 100644
index 0000000..6ae5ac7
--- /dev/null
+++ b/backend/migrations/0001_init.sql
@@ -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;
diff --git a/backend/migrations/0002_launch.sql b/backend/migrations/0002_launch.sql
new file mode 100644
index 0000000..26d166d
--- /dev/null
+++ b/backend/migrations/0002_launch.sql
@@ -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;
diff --git a/backend/migrations/0003_home.sql b/backend/migrations/0003_home.sql
new file mode 100644
index 0000000..00faba1
--- /dev/null
+++ b/backend/migrations/0003_home.sql
@@ -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;
diff --git a/backend/migrations/0004_results.sql b/backend/migrations/0004_results.sql
new file mode 100644
index 0000000..efbc672
--- /dev/null
+++ b/backend/migrations/0004_results.sql
@@ -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;
diff --git a/backend/migrations/0005_config_pipeline.sql b/backend/migrations/0005_config_pipeline.sql
new file mode 100644
index 0000000..a086473
--- /dev/null
+++ b/backend/migrations/0005_config_pipeline.sql
@@ -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;
diff --git a/backend/scripts/start-dev.ps1 b/backend/scripts/start-dev.ps1
new file mode 100644
index 0000000..b2c5a62
--- /dev/null
+++ b/backend/scripts/start-dev.ps1
@@ -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
diff --git a/todolist.md b/todolist.md
new file mode 100644
index 0000000..80a01be
--- /dev/null
+++ b/todolist.md
@@ -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
+ - 已支持场景保存
+ - 已支持配置发布链调试