Browse Source

Add backend foundation and config-driven workbench

zhangyan 2 weeks ago
parent
commit
94a1f0ba78
68 changed files with 10833 additions and 0 deletions
  1. 20 0
      backend/.env.example
  2. 43 0
      backend/README.md
  3. 55 0
      backend/cmd/api/main.go
  4. 54 0
      backend/docs/README.md
  5. 255 0
      backend/docs/todolist.md
  6. 369 0
      backend/docs/前后端联调清单.md
  7. 182 0
      backend/docs/开发说明.md
  8. 425 0
      backend/docs/接口清单.md
  9. 171 0
      backend/docs/数据模型.md
  10. 204 0
      backend/docs/核心流程.md
  11. 200 0
      backend/docs/系统架构.md
  12. 412 0
      backend/docs/配置管理方案.md
  13. 17 0
      backend/go.mod
  14. 30 0
      backend/go.sum
  15. 64 0
      backend/internal/app/app.go
  16. 73 0
      backend/internal/app/config.go
  17. 29 0
      backend/internal/apperr/apperr.go
  18. 129 0
      backend/internal/httpapi/handlers/auth_handler.go
  19. 107 0
      backend/internal/httpapi/handlers/config_handler.go
  20. 1588 0
      backend/internal/httpapi/handlers/dev_handler.go
  21. 31 0
      backend/internal/httpapi/handlers/entry_handler.go
  22. 40 0
      backend/internal/httpapi/handlers/entry_home_handler.go
  23. 51 0
      backend/internal/httpapi/handlers/event_handler.go
  24. 37 0
      backend/internal/httpapi/handlers/event_play_handler.go
  25. 21 0
      backend/internal/httpapi/handlers/health_handler.go
  26. 53 0
      backend/internal/httpapi/handlers/home_handler.go
  27. 34 0
      backend/internal/httpapi/handlers/me_handler.go
  28. 34 0
      backend/internal/httpapi/handlers/profile_handler.go
  29. 58 0
      backend/internal/httpapi/handlers/result_handler.go
  30. 88 0
      backend/internal/httpapi/handlers/session_handler.go
  31. 50 0
      backend/internal/httpapi/middleware/auth.go
  32. 80 0
      backend/internal/httpapi/router.go
  33. 39 0
      backend/internal/httpx/httpx.go
  34. 67 0
      backend/internal/platform/jwtx/jwt.go
  35. 47 0
      backend/internal/platform/security/token.go
  36. 120 0
      backend/internal/platform/wechatmini/client.go
  37. 595 0
      backend/internal/service/auth_service.go
  38. 678 0
      backend/internal/service/config_service.go
  39. 32 0
      backend/internal/service/dev_service.go
  40. 164 0
      backend/internal/service/entry_home_service.go
  41. 79 0
      backend/internal/service/entry_service.go
  42. 131 0
      backend/internal/service/event_play_service.go
  43. 195 0
      backend/internal/service/event_service.go
  44. 159 0
      backend/internal/service/home_service.go
  45. 43 0
      backend/internal/service/me_service.go
  46. 119 0
      backend/internal/service/profile_service.go
  47. 56 0
      backend/internal/service/release_view.go
  48. 94 0
      backend/internal/service/result_service.go
  49. 324 0
      backend/internal/service/session_service.go
  50. 9 0
      backend/internal/service/timeutil.go
  51. 310 0
      backend/internal/store/postgres/auth_store.go
  52. 93 0
      backend/internal/store/postgres/card_store.go
  53. 323 0
      backend/internal/store/postgres/config_store.go
  54. 46 0
      backend/internal/store/postgres/db.go
  55. 324 0
      backend/internal/store/postgres/dev_store.go
  56. 74 0
      backend/internal/store/postgres/entry_store.go
  57. 263 0
      backend/internal/store/postgres/event_store.go
  58. 50 0
      backend/internal/store/postgres/identity_store.go
  59. 367 0
      backend/internal/store/postgres/result_store.go
  60. 299 0
      backend/internal/store/postgres/session_store.go
  61. 94 0
      backend/internal/store/postgres/user_store.go
  62. 123 0
      backend/migrations/0001_init.sql
  63. 72 0
      backend/migrations/0002_launch.sql
  64. 32 0
      backend/migrations/0003_home.sql
  65. 26 0
      backend/migrations/0004_results.sql
  66. 61 0
      backend/migrations/0005_config_pipeline.sql
  67. 29 0
      backend/scripts/start-dev.ps1
  68. 292 0
      todolist.md

+ 20 - 0
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

+ 43 - 0
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`

+ 55 - 0
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)
+	}
+}

+ 54 - 0
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)

+ 255 - 0
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 运行态语义和故障恢复放弃语义定稳,再继续扩后台配置系统。

+ 369 - 0
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 两个适配器。

+ 182 - 0
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。

+ 425 - 0
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`

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

+ 204 - 0
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`

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

+ 412 - 0
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
+
+这样以后无论你配置项怎么继续长,主架构都还能撑住。

+ 17 - 0
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
+)

+ 30 - 0
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=

+ 64 - 0
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()
+	}
+}

+ 73 - 0
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
+}

+ 29 - 0
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
+}

+ 129 - 0
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,
+		},
+	})
+}

+ 107 - 0
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})
+}

+ 1588 - 0
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 = `<!doctype html>
+<html lang="zh-CN">
+<head>
+  <meta charset="utf-8">
+  <meta name="viewport" content="width=device-width, initial-scale=1">
+  <title>CMR Backend Workbench</title>
+  <style>
+    :root {
+      --bg: #0d1418;
+      --panel: #132129;
+      --panel-alt: #182b34;
+      --text: #e9f1f5;
+      --muted: #8ea3ad;
+      --line: #29424d;
+      --accent: #4fd1a5;
+      --accent-2: #ffd166;
+      --danger: #ff6b6b;
+      --mono: "Consolas", "SFMono-Regular", monospace;
+      --sans: "Segoe UI", "PingFang SC", "Noto Sans SC", sans-serif;
+    }
+    * { box-sizing: border-box; }
+    body {
+      margin: 0;
+      background:
+        radial-gradient(circle at top right, rgba(79, 209, 165, 0.12), transparent 24%),
+        radial-gradient(circle at bottom left, rgba(255, 209, 102, 0.10), transparent 28%),
+        var(--bg);
+      color: var(--text);
+      font-family: var(--sans);
+    }
+    .shell {
+      max-width: 1400px;
+      margin: 0 auto;
+      padding: 28px 24px 40px;
+    }
+    .hero {
+      display: grid;
+      gap: 8px;
+      margin-bottom: 22px;
+    }
+    .eyebrow {
+      color: var(--accent);
+      text-transform: uppercase;
+      letter-spacing: 0.14em;
+      font-size: 12px;
+      font-weight: 700;
+    }
+    h1 {
+      margin: 0;
+      font-size: 34px;
+      line-height: 1.1;
+    }
+    .hero p {
+      margin: 0;
+      max-width: 920px;
+      color: var(--muted);
+      line-height: 1.6;
+    }
+    .grid {
+      display: grid;
+      gap: 16px;
+      grid-template-columns: repeat(auto-fit, minmax(280px, 1fr));
+    }
+    .stack {
+      display: grid;
+      gap: 16px;
+    }
+    .panel {
+      background: linear-gradient(180deg, rgba(255,255,255,0.02), rgba(255,255,255,0.01)), var(--panel);
+      border: 1px solid var(--line);
+      border-radius: 18px;
+      padding: 16px;
+      display: grid;
+      gap: 12px;
+      box-shadow: 0 8px 30px rgba(0, 0, 0, 0.18);
+    }
+    .panel h2 {
+      margin: 0;
+      font-size: 18px;
+    }
+    .panel p {
+      margin: 0;
+      color: var(--muted);
+      font-size: 13px;
+      line-height: 1.5;
+    }
+    .row {
+      display: grid;
+      gap: 8px;
+    }
+    .row.two {
+      grid-template-columns: repeat(2, minmax(0, 1fr));
+    }
+    label {
+      display: grid;
+      gap: 6px;
+      font-size: 12px;
+      color: var(--muted);
+    }
+    input, textarea, select {
+      width: 100%;
+      border: 1px solid var(--line);
+      border-radius: 12px;
+      background: var(--panel-alt);
+      color: var(--text);
+      padding: 10px 12px;
+      font: inherit;
+    }
+    textarea {
+      min-height: 90px;
+      resize: vertical;
+      font-family: var(--mono);
+      font-size: 12px;
+    }
+    button {
+      border: 0;
+      border-radius: 12px;
+      padding: 10px 14px;
+      background: var(--accent);
+      color: #062419;
+      font-weight: 700;
+      cursor: pointer;
+    }
+    button.secondary {
+      background: var(--accent-2);
+      color: #312200;
+    }
+    button.ghost {
+      background: transparent;
+      color: var(--text);
+      border: 1px solid var(--line);
+    }
+    .actions {
+      display: flex;
+      flex-wrap: wrap;
+      gap: 8px;
+    }
+    .kv {
+      display: grid;
+      gap: 6px;
+      font-size: 12px;
+      color: var(--muted);
+    }
+    .kv code {
+      display: block;
+      padding: 8px 10px;
+      border-radius: 10px;
+      background: rgba(255,255,255,0.04);
+      color: var(--text);
+      font-family: var(--mono);
+      word-break: break-all;
+    }
+    .log {
+      min-height: 220px;
+      max-height: 520px;
+      overflow: auto;
+      white-space: pre-wrap;
+      word-break: break-word;
+      font-family: var(--mono);
+      font-size: 12px;
+      line-height: 1.55;
+      background: #0a1013;
+      border: 1px solid var(--line);
+      border-radius: 16px;
+      padding: 14px;
+    }
+    .subpanel {
+      display: grid;
+      gap: 8px;
+      padding: 12px;
+      border-radius: 14px;
+      background: rgba(255,255,255,0.03);
+      border: 1px solid rgba(255,255,255,0.05);
+    }
+    .history {
+      display: grid;
+      gap: 8px;
+      max-height: 280px;
+      overflow: auto;
+    }
+    .history-item {
+      padding: 10px 12px;
+      border-radius: 12px;
+      background: rgba(255,255,255,0.04);
+      border: 1px solid rgba(255,255,255,0.05);
+      font-family: var(--mono);
+      font-size: 12px;
+      line-height: 1.5;
+    }
+    .history-item strong {
+      color: var(--accent);
+    }
+    .muted-note {
+      color: var(--muted);
+      font-size: 12px;
+      line-height: 1.5;
+    }
+    .api-toolbar {
+      display: flex;
+      flex-wrap: wrap;
+      gap: 10px;
+      align-items: center;
+    }
+    .api-toolbar input {
+      max-width: 360px;
+    }
+    .api-catalog {
+      display: grid;
+      gap: 12px;
+    }
+    .api-item {
+      display: grid;
+      gap: 8px;
+      padding: 14px;
+      border-radius: 14px;
+      background: rgba(255,255,255,0.03);
+      border: 1px solid rgba(255,255,255,0.06);
+    }
+    .api-item.hidden {
+      display: none;
+    }
+    .api-head {
+      display: flex;
+      flex-wrap: wrap;
+      gap: 8px 12px;
+      align-items: center;
+    }
+    .api-method {
+      padding: 4px 8px;
+      border-radius: 999px;
+      background: rgba(79, 209, 165, 0.14);
+      color: var(--accent);
+      font-family: var(--mono);
+      font-size: 12px;
+      font-weight: 700;
+    }
+    .api-path {
+      font-family: var(--mono);
+      font-size: 13px;
+      color: var(--text);
+      word-break: break-all;
+    }
+    .api-desc {
+      color: var(--muted);
+      font-size: 13px;
+      line-height: 1.6;
+    }
+    .api-meta {
+      display: grid;
+      gap: 6px;
+      font-size: 12px;
+      color: var(--muted);
+    }
+    .api-meta strong {
+      color: var(--text);
+      font-weight: 600;
+    }
+    .status {
+      color: var(--accent);
+      font-weight: 700;
+    }
+    .status.error {
+      color: var(--danger);
+    }
+    @media (max-width: 900px) {
+      .row.two { grid-template-columns: 1fr; }
+      .shell { padding: 20px 16px 32px; }
+    }
+  </style>
+</head>
+<body>
+  <div class="shell">
+    <div class="hero">
+      <div class="eyebrow">Developer Workbench</div>
+      <h1>CMR Backend API Flow Panel</h1>
+      <p>把入口、登录、首页、活动详情、launch、session、profile 串成一条完整调试链。这个页面只在非 production 环境开放,适合后续继续扩展成你想要的 API 测试面板。</p>
+    </div>
+
+    <div class="grid">
+      <section class="panel">
+        <h2>1. Bootstrap</h2>
+        <p>初始化 demo tenant / channel / event / card。</p>
+        <div class="actions">
+          <button id="btn-bootstrap">Bootstrap Demo</button>
+        </div>
+        <div class="kv">
+          <div>默认入口 <code id="bootstrap-entry">tenant_demo / mini-demo / evt_demo_001</code></div>
+        </div>
+      </section>
+
+      <section class="panel">
+        <h2>2. Config Pipeline</h2>
+        <p>从本地 event 目录导入 source config,生成 preview build,并可直接发布成当前 release。</p>
+        <div class="row two">
+          <label>Local Config File
+            <input id="local-config-file" value="classic-sequential.json">
+          </label>
+          <label>Event ID
+            <input id="config-event-id" value="evt_demo_001">
+          </label>
+        </div>
+        <div class="row two">
+          <label>Source ID
+            <input id="config-source-id" placeholder="import 后自动填充">
+          </label>
+          <label>Build ID
+            <input id="config-build-id" placeholder="preview 后自动填充">
+          </label>
+        </div>
+        <div class="actions">
+          <button id="btn-config-files">List Local Files</button>
+          <button id="btn-config-import">Import Local</button>
+          <button class="secondary" id="btn-config-preview">Build Preview</button>
+          <button class="secondary" id="btn-config-publish">Publish Build</button>
+          <button class="ghost" id="btn-config-source">Get Source</button>
+          <button class="ghost" id="btn-config-build">Get Build</button>
+        </div>
+      </section>
+
+      <section class="panel">
+        <h2>3. Session State</h2>
+        <p>当前调试上下文,所有按钮共享这一组状态。</p>
+        <div class="kv">
+          <div>Access Token <code id="state-access">-</code></div>
+          <div>Refresh Token <code id="state-refresh">-</code></div>
+          <div>Source ID <code id="state-source">-</code></div>
+          <div>Build ID <code id="state-build">-</code></div>
+          <div>Release ID <code id="state-release">-</code></div>
+          <div>Session ID <code id="state-session">-</code></div>
+          <div>Session Token <code id="state-session-token">-</code></div>
+        </div>
+        <div class="actions">
+          <button class="ghost" id="btn-clear-state">Clear State</button>
+        </div>
+      </section>
+
+      <section class="panel">
+        <h2>4. SMS Auth</h2>
+        <div class="row two">
+          <label>Client Type
+            <select id="sms-client-type">
+              <option value="app">app</option>
+              <option value="wechat">wechat</option>
+            </select>
+          </label>
+          <label>Scene
+            <select id="sms-scene">
+              <option value="login">login</option>
+              <option value="bind_mobile">bind_mobile</option>
+            </select>
+          </label>
+        </div>
+        <div class="row two">
+          <label>Mobile
+            <input id="sms-mobile" value="13800138000">
+          </label>
+          <label>Device Key
+            <input id="sms-device" value="workbench-device-001">
+          </label>
+        </div>
+        <div class="row two">
+          <label>Country Code
+            <input id="sms-country" value="86">
+          </label>
+          <label>Code
+            <input id="sms-code" placeholder="send 后自动填充 devCode">
+          </label>
+        </div>
+        <div class="actions">
+          <button id="btn-send-sms">Send SMS</button>
+          <button class="secondary" id="btn-login-sms">Login SMS</button>
+          <button class="ghost" id="btn-bind-mobile">Bind Mobile</button>
+        </div>
+      </section>
+
+      <section class="panel">
+        <h2>5. WeChat Mini</h2>
+        <p>开发环境可直接使用 dev-xxx code。</p>
+        <div class="row two">
+          <label>Code
+            <input id="wechat-code" value="dev-workbench-user">
+          </label>
+          <label>Device Key
+            <input id="wechat-device" value="wechat-device-001">
+          </label>
+        </div>
+        <div class="actions">
+          <button id="btn-login-wechat">Login WeChat Mini</button>
+        </div>
+      </section>
+
+      <section class="panel">
+        <h2>6. Entry / Home</h2>
+        <div class="row two">
+          <label>Channel Code
+            <input id="entry-channel-code" value="mini-demo">
+          </label>
+          <label>Channel Type
+            <input id="entry-channel-type" value="wechat_mini">
+          </label>
+        </div>
+        <div class="actions">
+          <button id="btn-resolve-entry">Resolve Entry</button>
+          <button id="btn-home">Home</button>
+          <button class="secondary" id="btn-entry-home">My Entry Home</button>
+        </div>
+      </section>
+
+      <section class="panel">
+        <h2>7. Event</h2>
+        <div class="row two">
+          <label>Event ID
+            <input id="event-id" value="evt_demo_001">
+          </label>
+          <label>Release ID
+            <input id="event-release-id" value="rel_demo_001">
+          </label>
+        </div>
+        <div class="row two">
+          <label>Launch Device
+            <input id="event-device" value="workbench-device-001">
+          </label>
+          <div></div>
+        </div>
+        <div class="actions">
+          <button id="btn-event-detail">Event Detail</button>
+          <button id="btn-event-play">Event Play</button>
+          <button class="secondary" id="btn-launch">Launch</button>
+        </div>
+      </section>
+
+      <section class="panel">
+        <h2>8. Session</h2>
+        <div class="row two">
+          <label>Session ID
+            <input id="session-id" placeholder="launch 后自动填充">
+          </label>
+          <label>Session Token
+            <input id="session-token" placeholder="launch 后自动填充">
+          </label>
+        </div>
+        <div class="row two">
+          <label>Finish Status
+            <select id="finish-status">
+              <option value="finished">finished</option>
+              <option value="failed">failed</option>
+              <option value="cancelled">cancelled</option>
+            </select>
+          </label>
+          <div></div>
+        </div>
+        <div class="row two">
+          <label>Duration Sec
+            <input id="finish-duration" type="number" value="960">
+          </label>
+          <label>Score
+            <input id="finish-score" type="number" value="88">
+          </label>
+        </div>
+        <div class="row two">
+          <label>Completed Controls
+            <input id="finish-controls-done" type="number" value="7">
+          </label>
+          <label>Total Controls
+            <input id="finish-controls-total" type="number" value="8">
+          </label>
+        </div>
+        <div class="row two">
+          <label>Distance Meters
+            <input id="finish-distance" type="number" step="0.01" value="5230">
+          </label>
+          <label>Average Speed KM/H
+            <input id="finish-speed" type="number" step="0.001" value="6.45">
+          </label>
+        </div>
+        <div class="row two">
+          <label>Max Heart Rate BPM
+            <input id="finish-heart-rate" type="number" value="168">
+          </label>
+          <div></div>
+        </div>
+        <div class="actions">
+          <button id="btn-session-detail">Session Detail</button>
+          <button id="btn-session-start">Start Session</button>
+          <button class="secondary" id="btn-session-finish">Finish Session</button>
+          <button class="ghost" id="btn-my-sessions">My Sessions</button>
+        </div>
+      </section>
+
+      <section class="panel">
+        <h2>9. Results</h2>
+        <div class="actions">
+          <button id="btn-session-result">Session Result</button>
+          <button id="btn-my-results">My Results</button>
+        </div>
+      </section>
+
+      <section class="panel">
+        <h2>10. Profile</h2>
+        <div class="actions">
+          <button id="btn-me">/me</button>
+          <button id="btn-profile">/me/profile</button>
+        </div>
+      </section>
+    </div>
+
+    <div class="grid" style="margin-top:16px;">
+      <section class="panel">
+        <h2>11. Quick Flows</h2>
+        <p>把常用接口串成一键工作流,减少重复点击。</p>
+        <div class="actions">
+          <button id="btn-flow-home">Bootstrap + WeChat + Entry Home</button>
+          <button class="secondary" id="btn-flow-launch">Login + Launch + Start</button>
+          <button class="ghost" id="btn-flow-finish">Finish Current Session</button>
+          <button class="ghost" id="btn-flow-result">Finish + Result</button>
+        </div>
+        <div class="muted-note">这些流程会复用当前表单里的手机号、设备、event、channel 等输入。</div>
+      </section>
+
+      <section class="panel">
+        <h2>12. Request Export</h2>
+        <p>最后一次请求会生成一条可复制的 curl,后面做问题复现会方便很多。</p>
+        <div class="actions">
+          <button id="btn-copy-curl">Copy Last Curl</button>
+          <button class="ghost" id="btn-clear-history">Clear History</button>
+        </div>
+        <div class="subpanel">
+          <div class="muted-note">Last Curl</div>
+          <div id="curl" class="log" style="min-height:120px; max-height:200px;"></div>
+        </div>
+      </section>
+    </div>
+
+    <div class="grid" style="margin-top:16px;">
+      <section class="panel">
+        <h2>13. Scenarios</h2>
+        <p>保存当前表单状态为可复用场景,也支持导入导出 JSON,适合后续切换不同俱乐部、入口和 event。</p>
+        <div class="row two">
+          <label>Scenario Name
+            <input id="scenario-name" placeholder="例如:俱乐部A-小程序-Launch流">
+          </label>
+          <label>Saved / Preset
+            <select id="scenario-select"></select>
+          </label>
+        </div>
+        <div class="actions">
+          <button id="btn-scenario-save">Save Current</button>
+          <button class="secondary" id="btn-scenario-load">Load Selected</button>
+          <button class="ghost" id="btn-scenario-delete">Delete Selected</button>
+        </div>
+        <div class="subpanel">
+          <div class="muted-note">Scenario JSON</div>
+          <textarea id="scenario-json" placeholder="导出后可复制,导入时贴回这里"></textarea>
+          <div class="actions">
+            <button id="btn-scenario-export">Export Selected</button>
+            <button class="secondary" id="btn-scenario-import">Import JSON</button>
+          </div>
+        </div>
+      </section>
+
+      <section class="panel">
+        <h2>14. Response Log</h2>
+        <p>最后一次请求的结果会记录在这里,便于后续做请求回放和用例保存。</p>
+        <div id="status" class="status">ready</div>
+        <div id="log" class="log"></div>
+      </section>
+    </div>
+
+    <div class="grid" style="margin-top:16px;">
+      <section class="panel">
+        <h2>15. Request History</h2>
+        <p>最近 12 次请求会保留在浏览器本地,刷新页面不会丢。</p>
+        <div id="history" class="history"></div>
+      </section>
+    </div>
+
+    <div class="grid" style="margin-top:16px;">
+      <section class="panel">
+        <h2>16. API 列表</h2>
+        <p>把当前已实现接口按分组放进 workbench,直接看中文说明、鉴权要求和关键参数,不用来回翻文档。</p>
+        <div class="api-toolbar">
+          <input id="api-filter" placeholder="搜索路径、用途、参数,例如 launch / wechat / result">
+          <div class="muted-note">共 24 个接口,支持按关键词筛选。</div>
+        </div>
+        <div id="api-catalog" class="api-catalog">
+          <div class="api-item" data-api="healthz 健康检查 服务状态">
+            <div class="api-head"><span class="api-method">GET</span><span class="api-path">/healthz</span></div>
+            <div class="api-desc">健康检查接口,用来确认服务是否存活。</div>
+            <div class="api-meta"><div><strong>鉴权:</strong>无需鉴权</div></div>
+          </div>
+
+          <div class="api-item" data-api="auth sms send 验证码 登录 绑定 手机">
+            <div class="api-head"><span class="api-method">POST</span><span class="api-path">/auth/sms/send</span></div>
+            <div class="api-desc">发送短信验证码,支持登录和绑定手机号两种场景。</div>
+            <div class="api-meta">
+              <div><strong>鉴权:</strong>无需鉴权</div>
+              <div><strong>关键参数:</strong><code>countryCode</code>、<code>mobile</code>、<code>clientType</code>、<code>deviceKey</code>、<code>scene</code></div>
+            </div>
+          </div>
+
+          <div class="api-item" data-api="auth login sms app 手机号 验证码 登录">
+            <div class="api-head"><span class="api-method">POST</span><span class="api-path">/auth/login/sms</span></div>
+            <div class="api-desc">APP 主登录入口,使用手机号验证码登录并返回 access/refresh token。</div>
+            <div class="api-meta">
+              <div><strong>鉴权:</strong>无需鉴权</div>
+              <div><strong>关键参数:</strong><code>mobile</code>、<code>code</code>、<code>clientType</code>、<code>deviceKey</code></div>
+            </div>
+          </div>
+
+          <div class="api-item" data-api="auth login wechat mini 微信 小程序 code openid 登录">
+            <div class="api-head"><span class="api-method">POST</span><span class="api-path">/auth/login/wechat-mini</span></div>
+            <div class="api-desc">微信小程序登录入口。开发环境支持 <code>dev-</code> 前缀 code 直接模拟登录。</div>
+            <div class="api-meta">
+              <div><strong>鉴权:</strong>无需鉴权</div>
+              <div><strong>关键参数:</strong><code>code</code>、<code>clientType=wechat</code>、<code>deviceKey</code></div>
+            </div>
+          </div>
+
+          <div class="api-item" data-api="auth bind mobile 绑定 手机号 合并 账号">
+            <div class="api-head"><span class="api-method">POST</span><span class="api-path">/auth/bind/mobile</span></div>
+            <div class="api-desc">已登录用户绑定手机号,必要时把微信轻账号合并到手机号主账号。</div>
+            <div class="api-meta">
+              <div><strong>鉴权:</strong>Bearer token</div>
+              <div><strong>关键参数:</strong><code>mobile</code>、<code>code</code>、<code>clientType</code>、<code>deviceKey</code></div>
+            </div>
+          </div>
+
+          <div class="api-item" data-api="auth refresh token 刷新 登录态">
+            <div class="api-head"><span class="api-method">POST</span><span class="api-path">/auth/refresh</span></div>
+            <div class="api-desc">使用 refresh token 刷新 access token。</div>
+            <div class="api-meta">
+              <div><strong>鉴权:</strong>无需 Bearer token</div>
+              <div><strong>关键参数:</strong><code>refreshToken</code>、<code>clientType</code>、<code>deviceKey</code></div>
+            </div>
+          </div>
+
+          <div class="api-item" data-api="auth logout 登出 撤销 refresh token">
+            <div class="api-head"><span class="api-method">POST</span><span class="api-path">/auth/logout</span></div>
+            <div class="api-desc">登出并撤销 refresh token。</div>
+            <div class="api-meta"><div><strong>鉴权:</strong>可带 Bearer token</div></div>
+          </div>
+
+          <div class="api-item" data-api="entry resolve tenant channel 入口 解析">
+            <div class="api-head"><span class="api-method">GET</span><span class="api-path">/entry/resolve</span></div>
+            <div class="api-desc">解析当前入口属于哪个 tenant / channel,是多俱乐部、多公众号接入的入口层基础接口。</div>
+            <div class="api-meta">
+              <div><strong>鉴权:</strong>无需鉴权</div>
+              <div><strong>查询参数:</strong><code>channelCode</code>、<code>channelType</code>、<code>platformAppId</code>、<code>tenantCode</code></div>
+            </div>
+          </div>
+
+          <div class="api-item" data-api="home 首页 卡片 列表">
+            <div class="api-head"><span class="api-method">GET</span><span class="api-path">/home</span></div>
+            <div class="api-desc">返回入口首页卡片数据。</div>
+            <div class="api-meta"><div><strong>鉴权:</strong>无需鉴权</div></div>
+          </div>
+
+          <div class="api-item" data-api="cards 卡片 列表">
+            <div class="api-head"><span class="api-method">GET</span><span class="api-path">/cards</span></div>
+            <div class="api-desc">只返回卡片列表,适合调试卡片数据本身。</div>
+            <div class="api-meta"><div><strong>鉴权:</strong>无需鉴权</div></div>
+          </div>
+
+          <div class="api-item" data-api="me entry home 首页 聚合 ongoing recent">
+            <div class="api-head"><span class="api-method">GET</span><span class="api-path">/me/entry-home</span></div>
+            <div class="api-desc">首页聚合接口,返回用户、tenant、channel、cards、进行中 session 和最近一局。</div>
+            <div class="api-meta"><div><strong>鉴权:</strong>Bearer token</div></div>
+          </div>
+
+          <div class="api-item" data-api="event detail 活动 详情 release resolvedRelease">
+            <div class="api-head"><span class="api-method">GET</span><span class="api-path">/events/{eventPublicID}</span></div>
+            <div class="api-desc">活动详情接口,会带当前发布的 release 和 resolvedRelease。</div>
+            <div class="api-meta"><div><strong>鉴权:</strong>无需鉴权</div></div>
+          </div>
+
+          <div class="api-item" data-api="event play 活动 准备页 聚合 canLaunch continue review">
+            <div class="api-head"><span class="api-method">GET</span><span class="api-path">/events/{eventPublicID}/play</span></div>
+            <div class="api-desc">活动详情页 / 开始前准备页聚合接口,判断是否可启动、继续还是查看上次结果。</div>
+            <div class="api-meta"><div><strong>鉴权:</strong>Bearer token</div></div>
+          </div>
+
+          <div class="api-item" data-api="event launch 启动 一局 release manifest sessionToken">
+            <div class="api-head"><span class="api-method">POST</span><span class="api-path">/events/{eventPublicID}/launch</span></div>
+            <div class="api-desc">基于当前 event 的已发布 release 创建一局 session,并返回 config URL、releaseId、sessionToken。</div>
+            <div class="api-meta">
+              <div><strong>鉴权:</strong>Bearer token</div>
+              <div><strong>关键参数:</strong><code>releaseId</code>、<code>clientType</code>、<code>deviceKey</code></div>
+            </div>
+          </div>
+
+          <div class="api-item" data-api="config sources event source 配置 列表">
+            <div class="api-head"><span class="api-method">GET</span><span class="api-path">/events/{eventPublicID}/config-sources</span></div>
+            <div class="api-desc">查看某个 event 下已经导入过的 source config 列表。</div>
+            <div class="api-meta"><div><strong>鉴权:</strong>Bearer token</div></div>
+          </div>
+
+          <div class="api-item" data-api="config source detail 源配置 明细">
+            <div class="api-head"><span class="api-method">GET</span><span class="api-path">/config-sources/{sourceID}</span></div>
+            <div class="api-desc">查看单条 source config 明细。</div>
+            <div class="api-meta"><div><strong>鉴权:</strong>Bearer token</div></div>
+          </div>
+
+          <div class="api-item" data-api="config build detail 预览 build 明细 manifest assets">
+            <div class="api-head"><span class="api-method">GET</span><span class="api-path">/config-builds/{buildID}</span></div>
+            <div class="api-desc">查看单次 build 的 manifest 和 asset index。</div>
+            <div class="api-meta"><div><strong>鉴权:</strong>Bearer token</div></div>
+          </div>
+
+          <div class="api-item" data-api="session detail 一局 详情 resolvedRelease">
+            <div class="api-head"><span class="api-method">GET</span><span class="api-path">/sessions/{sessionPublicID}</span></div>
+            <div class="api-desc">查询一局详情,带 session 状态、event 和 resolvedRelease。</div>
+            <div class="api-meta"><div><strong>鉴权:</strong>Bearer token</div></div>
+          </div>
+
+          <div class="api-item" data-api="session start running 开始 一局 sessionToken">
+            <div class="api-head"><span class="api-method">POST</span><span class="api-path">/sessions/{sessionPublicID}/start</span></div>
+            <div class="api-desc">把 session 从 <code>launched</code> 推进到 <code>running</code>。</div>
+            <div class="api-meta">
+              <div><strong>鉴权:</strong><code>sessionToken</code></div>
+              <div><strong>关键参数:</strong><code>sessionToken</code></div>
+            </div>
+          </div>
+
+          <div class="api-item" data-api="session finish 结束 成绩 摘要 result summary sessionToken">
+            <div class="api-head"><span class="api-method">POST</span><span class="api-path">/sessions/{sessionPublicID}/finish</span></div>
+            <div class="api-desc">结束一局并沉淀结果摘要,是结果页数据的来源。</div>
+            <div class="api-meta">
+              <div><strong>鉴权:</strong><code>sessionToken</code></div>
+              <div><strong>关键参数:</strong><code>sessionToken</code>、<code>status</code>、<code>summary.*</code></div>
+            </div>
+          </div>
+
+          <div class="api-item" data-api="me sessions 我的 最近 局 列表">
+            <div class="api-head"><span class="api-method">GET</span><span class="api-path">/me/sessions</span></div>
+            <div class="api-desc">查询用户最近 session 列表。</div>
+            <div class="api-meta"><div><strong>鉴权:</strong>Bearer token</div></div>
+          </div>
+
+          <div class="api-item" data-api="session result 单局 结果 页 成绩">
+            <div class="api-head"><span class="api-method">GET</span><span class="api-path">/sessions/{sessionPublicID}/result</span></div>
+            <div class="api-desc">单局结果页接口,返回 session 和 result。</div>
+            <div class="api-meta"><div><strong>鉴权:</strong>Bearer token</div></div>
+          </div>
+
+          <div class="api-item" data-api="me results 我的 成绩 结果 列表">
+            <div class="api-head"><span class="api-method">GET</span><span class="api-path">/me/results</span></div>
+            <div class="api-desc">查询用户最近结果列表。</div>
+            <div class="api-meta"><div><strong>鉴权:</strong>Bearer token</div></div>
+          </div>
+
+          <div class="api-item" data-api="me 当前用户 信息">
+            <div class="api-head"><span class="api-method">GET</span><span class="api-path">/me</span></div>
+            <div class="api-desc">返回当前用户基础信息。</div>
+            <div class="api-meta"><div><strong>鉴权:</strong>Bearer token</div></div>
+          </div>
+
+          <div class="api-item" data-api="me profile 我的页 聚合 绑定 最近记录">
+            <div class="api-head"><span class="api-method">GET</span><span class="api-path">/me/profile</span></div>
+            <div class="api-desc">“我的页”聚合接口,返回绑定概览、绑定项列表和最近记录摘要。</div>
+            <div class="api-meta"><div><strong>鉴权:</strong>Bearer token</div></div>
+          </div>
+
+          <div class="api-item" data-api="dev bootstrap demo 初始化 示例 数据">
+            <div class="api-head"><span class="api-method">POST</span><span class="api-path">/dev/bootstrap-demo</span></div>
+            <div class="api-desc">开发态自举 demo 数据,会准备 tenant、channel、event、release、card、source、build。</div>
+            <div class="api-meta"><div><strong>鉴权:</strong>仅 non-production,无需鉴权</div></div>
+          </div>
+
+          <div class="api-item" data-api="dev config local files 本地 配置 文件 列表">
+            <div class="api-head"><span class="api-method">GET</span><span class="api-path">/dev/config/local-files</span></div>
+            <div class="api-desc">列出本地配置目录中的 JSON 文件,作为 source config 导入入口。</div>
+            <div class="api-meta"><div><strong>鉴权:</strong>仅 non-production,无需鉴权</div></div>
+          </div>
+
+          <div class="api-item" data-api="dev import local source config 导入 本地 event json">
+            <div class="api-head"><span class="api-method">POST</span><span class="api-path">/dev/events/{eventPublicID}/config-sources/import-local</span></div>
+            <div class="api-desc">从本地 event 目录导入 source config。</div>
+            <div class="api-meta">
+              <div><strong>鉴权:</strong>仅 non-production,无需鉴权</div>
+              <div><strong>关键参数:</strong><code>fileName</code>、<code>notes</code></div>
+            </div>
+          </div>
+
+          <div class="api-item" data-api="dev config preview build 预览 manifest asset index">
+            <div class="api-head"><span class="api-method">POST</span><span class="api-path">/dev/config-builds/preview</span></div>
+            <div class="api-desc">基于 source config 生成 preview build,并产出 preview manifest。</div>
+            <div class="api-meta">
+              <div><strong>鉴权:</strong>仅 non-production,无需鉴权</div>
+              <div><strong>关键参数:</strong><code>sourceId</code></div>
+            </div>
+          </div>
+
+          <div class="api-item" data-api="dev config publish build 发布 release 当前版本">
+            <div class="api-head"><span class="api-method">POST</span><span class="api-path">/dev/config-builds/publish</span></div>
+            <div class="api-desc">把成功的 build 发布成正式 release,并自动切换成当前 event 的可启动版本。</div>
+            <div class="api-meta">
+              <div><strong>鉴权:</strong>仅 non-production,无需鉴权</div>
+              <div><strong>关键参数:</strong><code>buildId</code></div>
+            </div>
+          </div>
+        </div>
+      </section>
+    </div>
+  </div>
+
+  <script>
+    const STORAGE_KEY = 'cmr-backend-workbench-state-v1';
+    const HISTORY_KEY = 'cmr-backend-workbench-history-v1';
+    const SCENARIO_KEY = 'cmr-backend-workbench-scenarios-v1';
+    const state = {
+      accessToken: '',
+      refreshToken: '',
+      sourceId: '',
+      buildId: '',
+      releaseId: '',
+      sessionId: '',
+      sessionToken: '',
+      lastCurl: ''
+    };
+
+    const $ = (id) => document.getElementById(id);
+    const logEl = $('log');
+    const curlEl = $('curl');
+    const historyEl = $('history');
+    const statusEl = $('status');
+    const builtInScenarios = [
+      {
+        id: 'preset-demo-wechat',
+        builtin: true,
+        name: 'Preset: Demo WeChat Flow',
+        fields: {
+          smsClientType: 'wechat',
+          smsScene: 'login',
+          smsMobile: '13800138000',
+          smsDevice: 'workbench-device-001',
+          smsCountry: '86',
+          smsCode: '',
+          wechatCode: 'dev-workbench-user',
+          wechatDevice: 'wechat-device-001',
+          entryChannelCode: 'mini-demo',
+          entryChannelType: 'wechat_mini',
+          eventId: 'evt_demo_001',
+          eventReleaseId: 'rel_demo_001',
+          eventDevice: 'wechat-device-001',
+          finishStatus: 'finished',
+          finishDuration: '960',
+          finishScore: '88',
+          finishControlsDone: '7',
+          finishControlsTotal: '8',
+          finishDistance: '5230',
+          finishSpeed: '6.45',
+          finishHeartRate: '168'
+        }
+      },
+      {
+        id: 'preset-demo-app-launch',
+        builtin: true,
+        name: 'Preset: Demo App Launch Flow',
+        fields: {
+          smsClientType: 'app',
+          smsScene: 'login',
+          smsMobile: '13800138000',
+          smsDevice: 'workbench-device-001',
+          smsCountry: '86',
+          smsCode: '',
+          wechatCode: 'dev-workbench-user',
+          wechatDevice: 'wechat-device-001',
+          entryChannelCode: 'mini-demo',
+          entryChannelType: 'wechat_mini',
+          eventId: 'evt_demo_001',
+          eventReleaseId: 'rel_demo_001',
+          eventDevice: 'workbench-device-001',
+          finishStatus: 'finished',
+          finishDuration: '960',
+          finishScore: '88',
+          finishControlsDone: '7',
+          finishControlsTotal: '8',
+          finishDistance: '5230',
+          finishSpeed: '6.45',
+          finishHeartRate: '168'
+        }
+      }
+    ];
+
+    function syncState() {
+      $('state-access').textContent = state.accessToken || '-';
+      $('state-refresh').textContent = state.refreshToken || '-';
+      $('state-source').textContent = state.sourceId || '-';
+      $('state-build').textContent = state.buildId || '-';
+      $('state-release').textContent = state.releaseId || '-';
+      $('state-session').textContent = state.sessionId || '-';
+      $('state-session-token').textContent = state.sessionToken || '-';
+      $('config-source-id').value = state.sourceId || '';
+      $('config-build-id').value = state.buildId || '';
+      $('event-release-id').value = state.releaseId || $('event-release-id').value;
+      $('session-id').value = state.sessionId || '';
+      $('session-token').value = state.sessionToken || '';
+      curlEl.textContent = state.lastCurl || '-';
+      persistState();
+    }
+
+    function setStatus(text, isError = false) {
+      statusEl.textContent = text;
+      statusEl.className = isError ? 'status error' : 'status';
+    }
+
+    function writeLog(title, payload) {
+      logEl.textContent = '[' + new Date().toLocaleString() + '] ' + title + '\n' + JSON.stringify(payload, null, 2);
+    }
+
+    function persistState() {
+      const payload = {
+        state,
+        fields: collectFields()
+      };
+      localStorage.setItem(STORAGE_KEY, JSON.stringify(payload));
+    }
+
+    function collectFields() {
+      return {
+        smsClientType: $('sms-client-type').value,
+        smsScene: $('sms-scene').value,
+        smsMobile: $('sms-mobile').value,
+        smsDevice: $('sms-device').value,
+        smsCountry: $('sms-country').value,
+        smsCode: $('sms-code').value,
+        wechatCode: $('wechat-code').value,
+        wechatDevice: $('wechat-device').value,
+        localConfigFile: $('local-config-file').value,
+        configEventId: $('config-event-id').value,
+        entryChannelCode: $('entry-channel-code').value,
+        entryChannelType: $('entry-channel-type').value,
+        eventId: $('event-id').value,
+        eventReleaseId: $('event-release-id').value,
+        eventDevice: $('event-device').value,
+        finishStatus: $('finish-status').value,
+        finishDuration: $('finish-duration').value,
+        finishScore: $('finish-score').value,
+        finishControlsDone: $('finish-controls-done').value,
+        finishControlsTotal: $('finish-controls-total').value,
+        finishDistance: $('finish-distance').value,
+        finishSpeed: $('finish-speed').value,
+        finishHeartRate: $('finish-heart-rate').value
+      };
+    }
+
+    function restoreState() {
+      const raw = localStorage.getItem(STORAGE_KEY);
+      if (!raw) {
+        return;
+      }
+      try {
+        const payload = JSON.parse(raw);
+        if (payload.state) {
+          state.accessToken = payload.state.accessToken || '';
+          state.refreshToken = payload.state.refreshToken || '';
+          state.sourceId = payload.state.sourceId || '';
+          state.buildId = payload.state.buildId || '';
+          state.sessionId = payload.state.sessionId || '';
+          state.sessionToken = payload.state.sessionToken || '';
+          state.lastCurl = payload.state.lastCurl || '';
+        }
+        applyFields(payload.fields || {});
+      } catch (_) {}
+    }
+
+    function applyFields(fields) {
+      $('sms-client-type').value = fields.smsClientType || $('sms-client-type').value;
+      $('sms-scene').value = fields.smsScene || $('sms-scene').value;
+      $('sms-mobile').value = fields.smsMobile || $('sms-mobile').value;
+      $('sms-device').value = fields.smsDevice || $('sms-device').value;
+      $('sms-country').value = fields.smsCountry || $('sms-country').value;
+      $('sms-code').value = fields.smsCode || '';
+      $('wechat-code').value = fields.wechatCode || $('wechat-code').value;
+      $('wechat-device').value = fields.wechatDevice || $('wechat-device').value;
+      $('local-config-file').value = fields.localConfigFile || $('local-config-file').value;
+      $('config-event-id').value = fields.configEventId || $('config-event-id').value;
+      $('entry-channel-code').value = fields.entryChannelCode || $('entry-channel-code').value;
+      $('entry-channel-type').value = fields.entryChannelType || $('entry-channel-type').value;
+      $('event-id').value = fields.eventId || $('event-id').value;
+      $('event-release-id').value = fields.eventReleaseId || $('event-release-id').value;
+      $('event-device').value = fields.eventDevice || $('event-device').value;
+      $('finish-status').value = fields.finishStatus || $('finish-status').value;
+      $('finish-duration').value = fields.finishDuration || $('finish-duration').value;
+      $('finish-score').value = fields.finishScore || $('finish-score').value;
+      $('finish-controls-done').value = fields.finishControlsDone || $('finish-controls-done').value;
+      $('finish-controls-total').value = fields.finishControlsTotal || $('finish-controls-total').value;
+      $('finish-distance').value = fields.finishDistance || $('finish-distance').value;
+      $('finish-speed').value = fields.finishSpeed || $('finish-speed').value;
+      $('finish-heart-rate').value = fields.finishHeartRate || $('finish-heart-rate').value;
+    }
+
+    function parseIntOrNull(value) {
+      if (value === '' || value === null || value === undefined) {
+        return null;
+      }
+      const parsed = parseInt(value, 10);
+      return Number.isNaN(parsed) ? null : parsed;
+    }
+
+    function parseFloatOrNull(value) {
+      if (value === '' || value === null || value === undefined) {
+        return null;
+      }
+      const parsed = parseFloat(value);
+      return Number.isNaN(parsed) ? null : parsed;
+    }
+
+    function buildFinishSummary() {
+      const summary = {
+        finalDurationSec: parseIntOrNull($('finish-duration').value),
+        finalScore: parseIntOrNull($('finish-score').value),
+        completedControls: parseIntOrNull($('finish-controls-done').value),
+        totalControls: parseIntOrNull($('finish-controls-total').value),
+        distanceMeters: parseFloatOrNull($('finish-distance').value),
+        averageSpeedKmh: parseFloatOrNull($('finish-speed').value),
+        maxHeartRateBpm: parseIntOrNull($('finish-heart-rate').value)
+      };
+      Object.keys(summary).forEach(function(key) {
+        if (summary[key] === null) {
+          delete summary[key];
+        }
+      });
+      return summary;
+    }
+
+    function buildCurl(method, url, body, headers) {
+      let curl = 'curl -X ' + method + ' "' + window.location.origin + url + '"';
+      Object.entries(headers || {}).forEach(function(entry) {
+        curl += ' -H "' + entry[0] + ': ' + String(entry[1]).replace(/"/g, '\\"') + '"';
+      });
+      if (body !== undefined) {
+        curl += " --data-raw '" + JSON.stringify(body).replace(/'/g, "'\"'\"'") + "'";
+      }
+      return curl;
+    }
+
+    function getHistory() {
+      const raw = localStorage.getItem(HISTORY_KEY);
+      if (!raw) {
+        return [];
+      }
+      try {
+        const list = JSON.parse(raw);
+        return Array.isArray(list) ? list : [];
+      } catch (_) {
+        return [];
+      }
+    }
+
+    function pushHistory(item) {
+      const next = [item].concat(getHistory()).slice(0, 12);
+      localStorage.setItem(HISTORY_KEY, JSON.stringify(next));
+      renderHistory();
+    }
+
+    function renderHistory() {
+      const history = getHistory();
+      historyEl.innerHTML = '';
+      if (!history.length) {
+        historyEl.innerHTML = '<div class="muted-note">No requests yet.</div>';
+        return;
+      }
+      history.forEach(function(item) {
+        const node = document.createElement('div');
+        node.className = 'history-item';
+        node.innerHTML =
+          '<strong>' + item.title + '</strong><br>' +
+          item.time + '<br>' +
+          'status=' + item.status + '<br>' +
+          'url=' + item.url;
+        historyEl.appendChild(node);
+      });
+    }
+
+    function applyAPIFilter() {
+      const keyword = $('api-filter').value.trim().toLowerCase();
+      document.querySelectorAll('.api-item').forEach(function(node) {
+        const haystack = String(node.dataset.api || '').toLowerCase();
+        if (!keyword || haystack.indexOf(keyword) >= 0) {
+          node.classList.remove('hidden');
+        } else {
+          node.classList.add('hidden');
+        }
+      });
+    }
+
+    function getSavedScenarios() {
+      const raw = localStorage.getItem(SCENARIO_KEY);
+      if (!raw) {
+        return [];
+      }
+      try {
+        const list = JSON.parse(raw);
+        return Array.isArray(list) ? list : [];
+      } catch (_) {
+        return [];
+      }
+    }
+
+    function setSavedScenarios(items) {
+      localStorage.setItem(SCENARIO_KEY, JSON.stringify(items));
+      renderScenarioOptions();
+    }
+
+    function allScenarios() {
+      return builtInScenarios.concat(getSavedScenarios());
+    }
+
+    function renderScenarioOptions() {
+      const select = $('scenario-select');
+      const scenarios = allScenarios();
+      select.innerHTML = '';
+      if (!scenarios.length) {
+        select.innerHTML = '<option value="">No scenarios</option>';
+        return;
+      }
+      scenarios.forEach(function(item) {
+        const option = document.createElement('option');
+        option.value = item.id;
+        option.textContent = item.name + (item.builtin ? ' [preset]' : '');
+        select.appendChild(option);
+      });
+    }
+
+    function findScenario(id) {
+      return allScenarios().find(function(item) {
+        return item.id === id;
+      }) || null;
+    }
+
+    function saveCurrentScenario() {
+      const name = $('scenario-name').value.trim();
+      if (!name) {
+        setStatus('error: scenario name required', true);
+        return;
+      }
+      const saved = getSavedScenarios();
+      const scenario = {
+        id: 'custom-' + Date.now(),
+        builtin: false,
+        name: name,
+        fields: collectFields()
+      };
+      saved.unshift(scenario);
+      setSavedScenarios(saved.slice(0, 20));
+      $('scenario-select').value = scenario.id;
+      $('scenario-json').value = JSON.stringify(scenario, null, 2);
+      setStatus('ok: scenario saved');
+    }
+
+    function loadSelectedScenario() {
+      const scenario = findScenario($('scenario-select').value);
+      if (!scenario) {
+        setStatus('error: scenario not found', true);
+        return;
+      }
+      applyFields(scenario.fields || {});
+      $('scenario-name').value = scenario.name || '';
+      $('scenario-json').value = JSON.stringify(scenario, null, 2);
+      persistState();
+      setStatus('ok: scenario loaded');
+    }
+
+    function deleteSelectedScenario() {
+      const id = $('scenario-select').value;
+      const scenario = findScenario(id);
+      if (!scenario || scenario.builtin) {
+        setStatus('error: builtin scenario cannot be deleted', true);
+        return;
+      }
+      const next = getSavedScenarios().filter(function(item) {
+        return item.id !== id;
+      });
+      setSavedScenarios(next);
+      $('scenario-json').value = '';
+      setStatus('ok: scenario deleted');
+    }
+
+    function exportSelectedScenario() {
+      const scenario = findScenario($('scenario-select').value);
+      if (!scenario) {
+        setStatus('error: scenario not found', true);
+        return;
+      }
+      $('scenario-json').value = JSON.stringify(scenario, null, 2);
+      setStatus('ok: scenario exported');
+    }
+
+    function importScenarioFromJSON() {
+      const raw = $('scenario-json').value.trim();
+      if (!raw) {
+        setStatus('error: scenario json is empty', true);
+        return;
+      }
+      try {
+        const scenario = JSON.parse(raw);
+        if (!scenario.name || !scenario.fields) {
+          throw new Error('scenario must include name and fields');
+        }
+        const saved = getSavedScenarios();
+        saved.unshift({
+          id: 'custom-' + Date.now(),
+          builtin: false,
+          name: String(scenario.name),
+          fields: scenario.fields
+        });
+        setSavedScenarios(saved.slice(0, 20));
+        setStatus('ok: scenario imported');
+      } catch (err) {
+        setStatus('error: invalid scenario json', true);
+      }
+    }
+
+    async function request(method, url, body, needAuth = false) {
+      const headers = {};
+      if (body !== undefined) {
+        headers['Content-Type'] = 'application/json';
+      }
+      if (needAuth) {
+        headers['Authorization'] = 'Bearer ' + state.accessToken;
+      }
+      state.lastCurl = buildCurl(method, url, body, headers);
+      syncState();
+      const resp = await fetch(url, {
+        method,
+        headers,
+        body: body === undefined ? undefined : JSON.stringify(body)
+      });
+      const data = await resp.json().catch(() => ({}));
+      if (!resp.ok) {
+        throw { status: resp.status, body: data, url: url, method: method };
+      }
+      return data;
+    }
+
+    async function run(title, fn) {
+      setStatus('running: ' + title);
+      try {
+        const result = await fn();
+        setStatus('ok: ' + title);
+        writeLog(title, result);
+        pushHistory({
+          title: title,
+          time: new Date().toLocaleString(),
+          status: 'ok',
+          url: state.lastCurl
+        });
+        syncState();
+      } catch (err) {
+        setStatus('error: ' + title, true);
+        writeLog(title, err);
+        pushHistory({
+          title: title,
+          time: new Date().toLocaleString(),
+          status: 'error',
+          url: state.lastCurl
+        });
+      }
+    }
+
+    $('btn-clear-state').onclick = () => {
+      state.accessToken = '';
+      state.refreshToken = '';
+      state.sourceId = '';
+      state.buildId = '';
+      state.releaseId = '';
+      state.sessionId = '';
+      state.sessionToken = '';
+      state.lastCurl = '';
+      syncState();
+      writeLog('clear-state', { ok: true });
+      setStatus('ready');
+    };
+
+    $('btn-config-files').onclick = () => run('config/local-files', () =>
+      request('GET', '/dev/config/local-files')
+    );
+
+    $('btn-config-import').onclick = () => run('config/import-local', async () => {
+      const result = await request('POST', '/dev/events/' + encodeURIComponent($('config-event-id').value) + '/config-sources/import-local', {
+        fileName: $('local-config-file').value
+      });
+      state.sourceId = result.data.id;
+      return result;
+    });
+
+    $('btn-config-preview').onclick = () => run('config/build-preview', async () => {
+      const result = await request('POST', '/dev/config-builds/preview', {
+        sourceId: $('config-source-id').value
+      });
+      state.buildId = result.data.id;
+      return result;
+    });
+
+    $('btn-config-publish').onclick = () => run('config/publish-build', async () => {
+      const result = await request('POST', '/dev/config-builds/publish', {
+        buildId: $('config-build-id').value
+      });
+      state.releaseId = result.data.release.releaseId;
+      $('event-release-id').value = result.data.release.releaseId;
+      return result;
+    });
+
+    $('btn-config-source').onclick = () => run('config/get-source', () =>
+      request('GET', '/config-sources/' + encodeURIComponent($('config-source-id').value), undefined, true)
+    );
+
+    $('btn-config-build').onclick = () => run('config/get-build', () =>
+      request('GET', '/config-builds/' + encodeURIComponent($('config-build-id').value), undefined, true)
+    );
+
+    $('btn-send-sms').onclick = () => run('auth/sms/send', async () => {
+      const result = await request('POST', '/auth/sms/send', {
+        countryCode: $('sms-country').value,
+        mobile: $('sms-mobile').value,
+        clientType: $('sms-client-type').value,
+        deviceKey: $('sms-device').value,
+        scene: $('sms-scene').value
+      });
+      if (result.data && result.data.devCode) {
+        $('sms-code').value = result.data.devCode;
+      }
+      return result;
+    });
+
+    $('btn-login-sms').onclick = () => run('auth/login/sms', async () => {
+      const result = await request('POST', '/auth/login/sms', {
+        countryCode: $('sms-country').value,
+        mobile: $('sms-mobile').value,
+        code: $('sms-code').value,
+        clientType: $('sms-client-type').value,
+        deviceKey: $('sms-device').value
+      });
+      state.accessToken = result.data.tokens.accessToken;
+      state.refreshToken = result.data.tokens.refreshToken;
+      return result;
+    });
+
+    $('btn-bind-mobile').onclick = () => run('auth/bind/mobile', async () => {
+      const result = await request('POST', '/auth/bind/mobile', {
+        countryCode: $('sms-country').value,
+        mobile: $('sms-mobile').value,
+        code: $('sms-code').value,
+        clientType: $('sms-client-type').value,
+        deviceKey: $('sms-device').value
+      }, true);
+      state.accessToken = result.data.tokens.accessToken;
+      state.refreshToken = result.data.tokens.refreshToken;
+      return result;
+    });
+
+    $('btn-login-wechat').onclick = () => run('auth/login/wechat-mini', async () => {
+      const result = await request('POST', '/auth/login/wechat-mini', {
+        code: $('wechat-code').value,
+        clientType: 'wechat',
+        deviceKey: $('wechat-device').value
+      });
+      state.accessToken = result.data.tokens.accessToken;
+      state.refreshToken = result.data.tokens.refreshToken;
+      return result;
+    });
+
+    $('btn-resolve-entry').onclick = () => run('entry/resolve', () =>
+      request('GET', '/entry/resolve?channelCode=' + encodeURIComponent($('entry-channel-code').value) + '&channelType=' + encodeURIComponent($('entry-channel-type').value))
+    );
+
+    $('btn-home').onclick = () => run('home', () =>
+      request('GET', '/home?channelCode=' + encodeURIComponent($('entry-channel-code').value) + '&channelType=' + encodeURIComponent($('entry-channel-type').value))
+    );
+
+    $('btn-entry-home').onclick = () => run('me/entry-home', () =>
+      request('GET', '/me/entry-home?channelCode=' + encodeURIComponent($('entry-channel-code').value) + '&channelType=' + encodeURIComponent($('entry-channel-type').value), undefined, true)
+    );
+
+    $('btn-event-detail').onclick = () => run('event-detail', () =>
+      request('GET', '/events/' + encodeURIComponent($('event-id').value))
+    );
+
+    $('btn-event-play').onclick = () => run('event-play', () =>
+      request('GET', '/events/' + encodeURIComponent($('event-id').value) + '/play', undefined, true)
+    );
+
+    $('btn-launch').onclick = () => run('event-launch', async () => {
+      const result = await request('POST', '/events/' + encodeURIComponent($('event-id').value) + '/launch', {
+        releaseId: $('event-release-id').value,
+        clientType: $('sms-client-type').value,
+        deviceKey: $('event-device').value
+      }, true);
+      state.sessionId = result.data.launch.business.sessionId;
+      state.sessionToken = result.data.launch.business.sessionToken;
+      return result;
+    });
+
+    $('btn-session-detail').onclick = () => run('session-detail', () =>
+      request('GET', '/sessions/' + encodeURIComponent($('session-id').value), undefined, true)
+    );
+
+    $('btn-session-start').onclick = () => run('session-start', () =>
+      request('POST', '/sessions/' + encodeURIComponent($('session-id').value) + '/start', {
+        sessionToken: $('session-token').value
+      })
+    );
+
+    $('btn-session-finish').onclick = () => run('session-finish', () =>
+      request('POST', '/sessions/' + encodeURIComponent($('session-id').value) + '/finish', {
+        sessionToken: $('session-token').value,
+        status: $('finish-status').value,
+        summary: buildFinishSummary()
+      })
+    );
+
+    $('btn-my-sessions').onclick = () => run('me/sessions', () =>
+      request('GET', '/me/sessions?limit=10', undefined, true)
+    );
+
+    $('btn-session-result').onclick = () => run('session-result', () =>
+      request('GET', '/sessions/' + encodeURIComponent($('session-id').value) + '/result', undefined, true)
+    );
+
+    $('btn-my-results').onclick = () => run('me/results', () =>
+      request('GET', '/me/results?limit=10', undefined, true)
+    );
+
+    $('btn-me').onclick = () => run('me', () =>
+      request('GET', '/me', undefined, true)
+    );
+
+    $('btn-profile').onclick = () => run('me/profile', () =>
+      request('GET', '/me/profile', undefined, true)
+    );
+
+    $('btn-copy-curl').onclick = async () => {
+      if (!state.lastCurl) {
+        setStatus('error: no curl to copy', true);
+        return;
+      }
+      try {
+        await navigator.clipboard.writeText(state.lastCurl);
+        setStatus('ok: curl copied');
+      } catch (_) {
+        setStatus('error: clipboard unavailable', true);
+      }
+    };
+
+    $('btn-clear-history').onclick = () => {
+      localStorage.removeItem(HISTORY_KEY);
+      renderHistory();
+      setStatus('ok: history cleared');
+    };
+
+    $('btn-scenario-save').onclick = saveCurrentScenario;
+    $('btn-scenario-load').onclick = loadSelectedScenario;
+    $('btn-scenario-delete').onclick = deleteSelectedScenario;
+    $('btn-scenario-export').onclick = exportSelectedScenario;
+    $('btn-scenario-import').onclick = importScenarioFromJSON;
+
+    $('btn-flow-home').onclick = () => run('flow-home', async () => {
+      await request('POST', '/dev/bootstrap-demo');
+      const login = await request('POST', '/auth/login/wechat-mini', {
+        code: $('wechat-code').value,
+        clientType: 'wechat',
+        deviceKey: $('wechat-device').value
+      });
+      state.accessToken = login.data.tokens.accessToken;
+      state.refreshToken = login.data.tokens.refreshToken;
+      return await request('GET', '/me/entry-home?channelCode=' + encodeURIComponent($('entry-channel-code').value) + '&channelType=' + encodeURIComponent($('entry-channel-type').value), undefined, true);
+    });
+
+    $('btn-bootstrap').onclick = () => run('bootstrap-demo', async () => {
+      const result = await request('POST', '/dev/bootstrap-demo');
+      state.sourceId = result.data.sourceId || '';
+      state.buildId = result.data.buildId || '';
+      state.releaseId = result.data.releaseId || state.releaseId || '';
+      if (result.data.releaseId) {
+        $('event-release-id').value = result.data.releaseId;
+      }
+      return result;
+    });
+
+    $('btn-flow-launch').onclick = () => run('flow-launch', async () => {
+      await request('POST', '/dev/bootstrap-demo');
+      const smsSend = await request('POST', '/auth/sms/send', {
+        countryCode: $('sms-country').value,
+        mobile: $('sms-mobile').value,
+        clientType: $('sms-client-type').value,
+        deviceKey: $('sms-device').value,
+        scene: 'login'
+      });
+      if (smsSend.data && smsSend.data.devCode) {
+        $('sms-code').value = smsSend.data.devCode;
+      }
+      const login = await request('POST', '/auth/login/sms', {
+        countryCode: $('sms-country').value,
+        mobile: $('sms-mobile').value,
+        code: $('sms-code').value,
+        clientType: $('sms-client-type').value,
+        deviceKey: $('sms-device').value
+      });
+      state.accessToken = login.data.tokens.accessToken;
+      state.refreshToken = login.data.tokens.refreshToken;
+      const launch = await request('POST', '/events/' + encodeURIComponent($('event-id').value) + '/launch', {
+        releaseId: $('event-release-id').value,
+        clientType: $('sms-client-type').value,
+        deviceKey: $('event-device').value
+      }, true);
+      state.sessionId = launch.data.launch.business.sessionId;
+      state.sessionToken = launch.data.launch.business.sessionToken;
+      return await request('POST', '/sessions/' + encodeURIComponent(state.sessionId) + '/start', {
+        sessionToken: state.sessionToken
+      });
+    });
+
+    $('btn-flow-finish').onclick = () => run('flow-finish', async () => {
+      return await request('POST', '/sessions/' + encodeURIComponent($('session-id').value) + '/finish', {
+        sessionToken: $('session-token').value,
+        status: $('finish-status').value,
+        summary: buildFinishSummary()
+      });
+    });
+
+    $('btn-flow-result').onclick = () => run('flow-result', async () => {
+      await request('POST', '/sessions/' + encodeURIComponent($('session-id').value) + '/finish', {
+        sessionToken: $('session-token').value,
+        status: $('finish-status').value,
+        summary: buildFinishSummary()
+      });
+      return await request('GET', '/sessions/' + encodeURIComponent($('session-id').value) + '/result', undefined, true);
+    });
+
+    [
+      'sms-client-type', 'sms-scene', 'sms-mobile', 'sms-device', 'sms-country', 'sms-code',
+      'wechat-code', 'wechat-device', 'local-config-file', 'config-event-id', 'entry-channel-code', 'entry-channel-type',
+      'event-id', 'event-release-id', 'event-device', 'finish-status', 'finish-duration', 'finish-score',
+      'finish-controls-done', 'finish-controls-total', 'finish-distance', 'finish-speed',
+      'finish-heart-rate'
+    ].forEach(function(id) {
+      $(id).addEventListener('change', persistState);
+      $(id).addEventListener('input', persistState);
+    });
+
+    $('api-filter').addEventListener('input', applyAPIFilter);
+
+    restoreState();
+    syncState();
+    renderHistory();
+    renderScenarioOptions();
+    applyAPIFilter();
+    writeLog('workbench-ready', { ok: true, hint: 'Use Bootstrap Demo first on a fresh database.' });
+  </script>
+</body>
+</html>`

+ 31 - 0
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})
+}

+ 40 - 0
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})
+}

+ 51 - 0
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})
+}

+ 37 - 0
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})
+}

+ 21 - 0
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",
+		},
+	})
+}

+ 53 - 0
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,
+	}
+}

+ 34 - 0
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})
+}

+ 34 - 0
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})
+}

+ 58 - 0
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})
+}

+ 88 - 0
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})
+}

+ 50 - 0
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
+}

+ 80 - 0
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
+}

+ 39 - 0
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)
+}

+ 67 - 0
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
+}

+ 47 - 0
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
+}

+ 120 - 0
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
+}

+ 595 - 0
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
+}

+ 678 - 0
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
+}

+ 32 - 0
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)
+}

+ 164 - 0
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
+}

+ 79 - 0
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
+}

+ 131 - 0
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
+}

+ 195 - 0
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
+}

+ 159 - 0
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
+}

+ 43 - 0
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
+}

+ 119 - 0
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
+}

+ 56 - 0
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
+}

+ 94 - 0
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
+}

+ 324 - 0
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)
+}

+ 9 - 0
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()
+}

+ 310 - 0
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
+}

+ 93 - 0
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
+}

+ 323 - 0
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
+}

+ 46 - 0
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)
+}

+ 324 - 0
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
+}

+ 74 - 0
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
+}

+ 263 - 0
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
+}

+ 50 - 0
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
+}

+ 367 - 0
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
+}

+ 299 - 0
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
+}

+ 94 - 0
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
+}

+ 123 - 0
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;

+ 72 - 0
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;

+ 32 - 0
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;

+ 26 - 0
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;

+ 61 - 0
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;

+ 29 - 0
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

+ 292 - 0
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
+  - 已支持场景保存
+  - 已支持配置发布链调试