From 94a1f0ba78e41f4c28a232cba853fbaa1282d594 Mon Sep 17 00:00:00 2001 From: zhangyan Date: Wed, 1 Apr 2026 15:01:44 +0800 Subject: [PATCH] Add backend foundation and config-driven workbench --- backend/.env.example | 20 + backend/README.md | 43 + backend/cmd/api/main.go | 55 + backend/docs/README.md | 54 + backend/docs/todolist.md | 255 +++ backend/docs/前后端联调清单.md | 369 ++++ backend/docs/开发说明.md | 182 ++ backend/docs/接口清单.md | 425 +++++ backend/docs/数据模型.md | 171 ++ backend/docs/核心流程.md | 204 +++ backend/docs/系统架构.md | 200 +++ backend/docs/配置管理方案.md | 412 +++++ backend/go.mod | 17 + backend/go.sum | 30 + backend/internal/app/app.go | 64 + backend/internal/app/config.go | 73 + backend/internal/apperr/apperr.go | 29 + .../internal/httpapi/handlers/auth_handler.go | 129 ++ .../httpapi/handlers/config_handler.go | 107 ++ .../internal/httpapi/handlers/dev_handler.go | 1588 +++++++++++++++++ .../httpapi/handlers/entry_handler.go | 31 + .../httpapi/handlers/entry_home_handler.go | 40 + .../httpapi/handlers/event_handler.go | 51 + .../httpapi/handlers/event_play_handler.go | 37 + .../httpapi/handlers/health_handler.go | 21 + .../internal/httpapi/handlers/home_handler.go | 53 + .../internal/httpapi/handlers/me_handler.go | 34 + .../httpapi/handlers/profile_handler.go | 34 + .../httpapi/handlers/result_handler.go | 58 + .../httpapi/handlers/session_handler.go | 88 + backend/internal/httpapi/middleware/auth.go | 50 + backend/internal/httpapi/router.go | 80 + backend/internal/httpx/httpx.go | 39 + backend/internal/platform/jwtx/jwt.go | 67 + backend/internal/platform/security/token.go | 47 + .../internal/platform/wechatmini/client.go | 120 ++ backend/internal/service/auth_service.go | 595 ++++++ backend/internal/service/config_service.go | 678 +++++++ backend/internal/service/dev_service.go | 32 + .../internal/service/entry_home_service.go | 164 ++ backend/internal/service/entry_service.go | 79 + .../internal/service/event_play_service.go | 131 ++ backend/internal/service/event_service.go | 195 ++ backend/internal/service/home_service.go | 159 ++ backend/internal/service/me_service.go | 43 + backend/internal/service/profile_service.go | 119 ++ backend/internal/service/release_view.go | 56 + backend/internal/service/result_service.go | 94 + backend/internal/service/session_service.go | 324 ++++ backend/internal/service/timeutil.go | 9 + backend/internal/store/postgres/auth_store.go | 310 ++++ backend/internal/store/postgres/card_store.go | 93 + .../internal/store/postgres/config_store.go | 323 ++++ backend/internal/store/postgres/db.go | 46 + backend/internal/store/postgres/dev_store.go | 324 ++++ .../internal/store/postgres/entry_store.go | 74 + .../internal/store/postgres/event_store.go | 263 +++ .../internal/store/postgres/identity_store.go | 50 + .../internal/store/postgres/result_store.go | 367 ++++ .../internal/store/postgres/session_store.go | 299 ++++ backend/internal/store/postgres/user_store.go | 94 + backend/migrations/0001_init.sql | 123 ++ backend/migrations/0002_launch.sql | 72 + backend/migrations/0003_home.sql | 32 + backend/migrations/0004_results.sql | 26 + backend/migrations/0005_config_pipeline.sql | 61 + backend/scripts/start-dev.ps1 | 29 + todolist.md | 292 +++ 68 files changed, 10833 insertions(+) create mode 100644 backend/.env.example create mode 100644 backend/README.md create mode 100644 backend/cmd/api/main.go create mode 100644 backend/docs/README.md create mode 100644 backend/docs/todolist.md create mode 100644 backend/docs/前后端联调清单.md create mode 100644 backend/docs/开发说明.md create mode 100644 backend/docs/接口清单.md create mode 100644 backend/docs/数据模型.md create mode 100644 backend/docs/核心流程.md create mode 100644 backend/docs/系统架构.md create mode 100644 backend/docs/配置管理方案.md create mode 100644 backend/go.mod create mode 100644 backend/go.sum create mode 100644 backend/internal/app/app.go create mode 100644 backend/internal/app/config.go create mode 100644 backend/internal/apperr/apperr.go create mode 100644 backend/internal/httpapi/handlers/auth_handler.go create mode 100644 backend/internal/httpapi/handlers/config_handler.go create mode 100644 backend/internal/httpapi/handlers/dev_handler.go create mode 100644 backend/internal/httpapi/handlers/entry_handler.go create mode 100644 backend/internal/httpapi/handlers/entry_home_handler.go create mode 100644 backend/internal/httpapi/handlers/event_handler.go create mode 100644 backend/internal/httpapi/handlers/event_play_handler.go create mode 100644 backend/internal/httpapi/handlers/health_handler.go create mode 100644 backend/internal/httpapi/handlers/home_handler.go create mode 100644 backend/internal/httpapi/handlers/me_handler.go create mode 100644 backend/internal/httpapi/handlers/profile_handler.go create mode 100644 backend/internal/httpapi/handlers/result_handler.go create mode 100644 backend/internal/httpapi/handlers/session_handler.go create mode 100644 backend/internal/httpapi/middleware/auth.go create mode 100644 backend/internal/httpapi/router.go create mode 100644 backend/internal/httpx/httpx.go create mode 100644 backend/internal/platform/jwtx/jwt.go create mode 100644 backend/internal/platform/security/token.go create mode 100644 backend/internal/platform/wechatmini/client.go create mode 100644 backend/internal/service/auth_service.go create mode 100644 backend/internal/service/config_service.go create mode 100644 backend/internal/service/dev_service.go create mode 100644 backend/internal/service/entry_home_service.go create mode 100644 backend/internal/service/entry_service.go create mode 100644 backend/internal/service/event_play_service.go create mode 100644 backend/internal/service/event_service.go create mode 100644 backend/internal/service/home_service.go create mode 100644 backend/internal/service/me_service.go create mode 100644 backend/internal/service/profile_service.go create mode 100644 backend/internal/service/release_view.go create mode 100644 backend/internal/service/result_service.go create mode 100644 backend/internal/service/session_service.go create mode 100644 backend/internal/service/timeutil.go create mode 100644 backend/internal/store/postgres/auth_store.go create mode 100644 backend/internal/store/postgres/card_store.go create mode 100644 backend/internal/store/postgres/config_store.go create mode 100644 backend/internal/store/postgres/db.go create mode 100644 backend/internal/store/postgres/dev_store.go create mode 100644 backend/internal/store/postgres/entry_store.go create mode 100644 backend/internal/store/postgres/event_store.go create mode 100644 backend/internal/store/postgres/identity_store.go create mode 100644 backend/internal/store/postgres/result_store.go create mode 100644 backend/internal/store/postgres/session_store.go create mode 100644 backend/internal/store/postgres/user_store.go create mode 100644 backend/migrations/0001_init.sql create mode 100644 backend/migrations/0002_launch.sql create mode 100644 backend/migrations/0003_home.sql create mode 100644 backend/migrations/0004_results.sql create mode 100644 backend/migrations/0005_config_pipeline.sql create mode 100644 backend/scripts/start-dev.ps1 create mode 100644 todolist.md diff --git a/backend/.env.example b/backend/.env.example new file mode 100644 index 0000000..9871a00 --- /dev/null +++ b/backend/.env.example @@ -0,0 +1,20 @@ +APP_ENV=development +HTTP_ADDR=:8080 +DATABASE_URL=postgres://postgres:asdf*123@192.168.100.77:5432/cmr20260401?sslmode=disable + +JWT_ISSUER=cmr-backend +JWT_ACCESS_SECRET=change-me-in-production +JWT_ACCESS_TTL=2h +AUTH_REFRESH_TTL=720h + +AUTH_SMS_CODE_TTL=10m +AUTH_SMS_COOLDOWN=60s +AUTH_SMS_PROVIDER=console +AUTH_DEV_SMS_CODE= + +WECHAT_MINI_APP_ID= +WECHAT_MINI_APP_SECRET= +WECHAT_MINI_DEV_PREFIX=dev- + +LOCAL_EVENT_DIR=..\event +ASSET_BASE_URL=https://oss-mbh5.colormaprun.com/gotomars diff --git a/backend/README.md b/backend/README.md new file mode 100644 index 0000000..2212c2b --- /dev/null +++ b/backend/README.md @@ -0,0 +1,43 @@ +# Backend + +这套后端现在已经能支撑一条完整主链: + +`entry -> auth -> home/cards -> event play -> launch -> session -> result` + +并且已经按“配置驱动游戏”收口: + +- 业务对象是 `event` +- 运行配置对象是 `event_release` +- 真正进入游戏时客户端消费的是 `manifest_url` +- `session` 会固化当时实际绑定的 `release` + +## 文档导航 + +- [文档索引](D:/dev/cmr-mini/backend/docs/README.md) +- [系统架构](D:/dev/cmr-mini/backend/docs/系统架构.md) +- [核心流程](D:/dev/cmr-mini/backend/docs/核心流程.md) +- [API 清单](D:/dev/cmr-mini/backend/docs/接口清单.md) +- [数据模型](D:/dev/cmr-mini/backend/docs/数据模型.md) +- [配置管理方案](D:/dev/cmr-mini/backend/docs/配置管理方案.md) +- [开发说明](D:/dev/cmr-mini/backend/docs/开发说明.md) + +## 快速启动 + +1. 配置环境变量,参考 [`.env.example`](D:/dev/cmr-mini/backend/.env.example) +2. 按顺序执行 [migrations](D:/dev/cmr-mini/backend/migrations) +3. 启动服务 + +```powershell +cd D:\dev\cmr-mini\backend +go run .\cmd\api +``` + +## 当前重点 + +- 统一登录:短信 + 微信小程序 +- 多入口:`tenant + entry_channel` +- 首页聚合:`/home`、`/cards`、`/me/entry-home` +- 配置驱动启动:`/events/{id}/play`、`/events/{id}/launch` +- 局生命周期:`start / finish / detail` +- 局后结果:`/sessions/{id}/result`、`/me/results` +- 开发工作台:`/dev/workbench` diff --git a/backend/cmd/api/main.go b/backend/cmd/api/main.go new file mode 100644 index 0000000..4537da1 --- /dev/null +++ b/backend/cmd/api/main.go @@ -0,0 +1,55 @@ +package main + +import ( + "context" + "log/slog" + "net/http" + "os" + "os/signal" + "syscall" + "time" + + "cmr-backend/internal/app" +) + +func main() { + ctx := context.Background() + + cfg, err := app.LoadConfigFromEnv() + if err != nil { + slog.Error("load config failed", "error", err) + os.Exit(1) + } + + application, err := app.New(ctx, cfg) + if err != nil { + slog.Error("create app failed", "error", err) + os.Exit(1) + } + defer application.Close() + + server := &http.Server{ + Addr: cfg.HTTPAddr, + Handler: application.Router(), + ReadHeaderTimeout: 5 * time.Second, + } + + go func() { + slog.Info("api server started", "addr", cfg.HTTPAddr) + if err := server.ListenAndServe(); err != nil && err != http.ErrServerClosed { + slog.Error("server stopped unexpectedly", "error", err) + os.Exit(1) + } + }() + + stop := make(chan os.Signal, 1) + signal.Notify(stop, syscall.SIGINT, syscall.SIGTERM) + <-stop + + shutdownCtx, cancel := context.WithTimeout(context.Background(), 10*time.Second) + defer cancel() + if err := server.Shutdown(shutdownCtx); err != nil { + slog.Error("graceful shutdown failed", "error", err) + os.Exit(1) + } +} diff --git a/backend/docs/README.md b/backend/docs/README.md new file mode 100644 index 0000000..dd44801 --- /dev/null +++ b/backend/docs/README.md @@ -0,0 +1,54 @@ +# Backend Docs + +这套文档服务两个目的: + +1. 让后面开发时能快速查到当前后端边界 +2. 把“配置驱动游戏”的核心约束写清楚,避免业务层和游戏层重新耦合 + +## 建议阅读顺序 + +1. [系统架构](D:/dev/cmr-mini/backend/docs/系统架构.md) +2. [核心流程](D:/dev/cmr-mini/backend/docs/核心流程.md) +3. [API 清单](D:/dev/cmr-mini/backend/docs/接口清单.md) +4. [数据模型](D:/dev/cmr-mini/backend/docs/数据模型.md) +5. [配置管理方案](D:/dev/cmr-mini/backend/docs/配置管理方案.md) +6. [前后端联调清单](D:/dev/cmr-mini/backend/docs/前后端联调清单.md) +7. [TodoList](D:/dev/cmr-mini/backend/docs/todolist.md) +8. [开发说明](D:/dev/cmr-mini/backend/docs/开发说明.md) + +## 当前系统范围 + +当前 backend 已覆盖: + +- 多租户入口识别 +- APP 短信登录 +- 微信小程序登录 +- 手机号绑定与账号合并 +- 首页卡片与入口聚合 +- Event 详情与 play 上下文 +- 以 `event_release` 为核心的 launch +- session 生命周期 +- session 结果沉淀 +- 开发 workbench + +下一阶段建议重点: + +- 可伸缩配置管理 +- source/build/release 分层 +- 配置构建器 +- 发布资产清单 + +## 当前最重要的设计约束 + +- 用户是平台级,不是俱乐部级 +- 渠道是入口,不是用户体系 +- `event` 是业务对象,不是运行配置本体 +- `event_release` 才是进入游戏时真正绑定的配置发布对象 +- `game_session` 必须固化当时实际使用的 release + +## 代码入口 + +- 程序入口:[main.go](D:/dev/cmr-mini/backend/cmd/api/main.go) +- 应用装配:[app.go](D:/dev/cmr-mini/backend/internal/app/app.go) +- 路由注册:[router.go](D:/dev/cmr-mini/backend/internal/httpapi/router.go) +- migration:[migrations](D:/dev/cmr-mini/backend/migrations) diff --git a/backend/docs/todolist.md b/backend/docs/todolist.md new file mode 100644 index 0000000..346aba2 --- /dev/null +++ b/backend/docs/todolist.md @@ -0,0 +1,255 @@ +# Backend TodoList + +## 1. 目标 + +这份 TodoList 只列当前需要 backend 配合联调和近期应推进的事项。 + +原则: + +- 不重复写已经稳定可用的能力 +- 优先写会影响前后端联调闭环的点 +- 边界不清的事项单独标记“需确认” + +## 2. 当前联调现状 + +当前已经可联调的主链: + +- 微信小程序登录 +- `GET /events/{eventPublicID}/play` +- `POST /events/{eventPublicID}/launch` +- `POST /sessions/{sessionPublicID}/start` +- `POST /sessions/{sessionPublicID}/finish` +- `GET /sessions/{sessionPublicID}/result` + +小程序侧已经具备: + +- backend 地址和 token 持久化 +- `launch -> GameLaunchEnvelope` 适配 +- 进入地图后自动上报 `session start` +- 对局结束后自动上报 `session finish` + +所以 backend 现在最重要的不是再扩散接口,而是把当前契约和语义收稳。 + +## 3. P0 必做 + +## 3.1 固定 session 状态语义 + +需要 backend 明确并固定: + +- `finished` +- `failed` +- `cancelled` + +建议当前口径: + +- 正常打终点完成:`finished` +- 超时结束:`failed` +- 主动退出 / 放弃恢复:`cancelled` + +说明: + +- 小程序现在已经按这个方向接 +- 如果 backend 想改这 3 个状态语义,需要先讨论,不要单边改 + +## 3.2 明确“放弃恢复”的后端处理 + +这是当前最值得后端配合确认的一点。 + +当前小程序本地恢复逻辑已经是: + +- 进入程序检测到未正常结束对局 +- 弹确认框 +- 玩家可“继续恢复”或“放弃” + +现在本地“放弃”只会清除本地恢复快照。 + +backend 需要确认的目标语义是: + +> 玩家点击“放弃恢复”后,这一局是否应同时在业务后端标记为 `cancelled`。 + +我建议 backend 采用: + +- **是,应标记为 `cancelled`** + +原因: + +- 否则 `ongoingSession` 会继续存在 +- `/events/{id}/play` 和 `/me/entry-home` 可能一直把它当成可继续的局 +- 会和小程序本地“已放弃”产生语义分叉 + +建议 backend 配合确认: + +1. `POST /sessions/{id}/finish` 使用 `status=cancelled` 是否就是官方放弃语义 +2. 如果客户端持有旧 `sessionToken`,恢复放弃时是否允许直接调用 `finish(cancelled)` +3. `cancelled` 后,`event play` 和 `entry-home` 中不再返回为 `ongoingSession` + +备注: + +- 如果 backend 认可这套语义,小程序侧下一步就可以把“点击放弃恢复”改成同步调用 `finish(cancelled)`。 + +## 3.3 保证 start / finish 幂等与重复调用安全 + +联调和真实环境里,以下情况很常见: + +- 网络重试 +- 页面重进 +- 故障恢复后二次补报 +- 用户重复点击 + +backend 需要确认: + +- `start` 重复调用的幂等语义 +- `finish` 重复调用的幂等语义 + +建议: + +- `start`:如果已 `running`,返回当前 session,视为成功 +- `finish`:如果已进入终态,返回当前 session/result,视为成功 + +目的: + +- 不把客户端补偿逻辑变成一堆冲突分支 + +## 3.4 固定 `launch` 返回契约,不随意漂移 + +当前客户端已经按下面这些字段接入: + +- `launch.resolvedRelease.releaseId` +- `launch.resolvedRelease.manifestUrl` +- `launch.resolvedRelease.manifestChecksumSha256` +- `launch.config.configUrl` +- `launch.config.configLabel` +- `launch.config.releaseId` +- `launch.config.routeCode` +- `launch.business.sessionId` +- `launch.business.sessionToken` +- `launch.business.sessionTokenExpiresAt` + +backend 现在需要做的是: + +- 先保持这些字段名稳定 +- 如果要调整命名或层级,先沟通 + +## 4. P1 应尽快做 + +## 4.1 增加用户身体资料读取接口 + +小程序侧已经有: + +- telemetry profile 合并入口 +- 心率/卡路里计算逻辑 + +backend 下一步建议提供: + +- 当前用户 body profile 查询接口 + +建议返回至少包含: + +- `birthDate` 或 `heartRateAge` +- `weightKg` +- `restingHeartRateBpm` +- `maxHeartRateBpm`(可选) + +这样后面心率页和消耗估算就能真实接业务数据。 + +## 4.2 给 `session result` 补一点稳定摘要字段校验 + +客户端现在会上报: + +- `finalDurationSec` +- `finalScore` +- `completedControls` +- `totalControls` +- `distanceMeters` +- `averageSpeedKmh` + +backend 建议补两件事: + +- 合理性校验 +- 空值容忍 + +不要因为某个可选字段缺失就整局 finish 失败。 + +## 4.3 dev workbench 增加一组“恢复 / 取消恢复”场景按钮 + +当前 workbench 已经很好用了。 + +建议后续再补: + +- 标记 session 为 `cancelled` +- 查询 ongoing session +- 快速查看某个用户最新 session 状态 + +这会很适合配合小程序故障恢复联调。 + +## 5. P2 下一阶段 + +## 5.1 配置后台 source / build / release 真正开始做 + +当前已经有: + +- 表结构 +- 架构文档 + +还缺: + +- source CRUD +- build 触发 +- manifest 产物生成 +- release 发布 +- asset index 查询 + +这个建议在当前主链联稳之后再推进。 + +## 5.2 page / cards / competition 等业务对象继续长出来 + +这部分不是当前联调阻塞项,但后面会成为业务壳的重要组成。 + +## 6. 需要先讨论再动的边界 + +这些事项 backend 不建议自己先拍板: + +### 6.1 `failed` 是否专指超时 + +当前建议是: + +- 超时 -> `failed` +- 主动退出 / 放弃恢复 -> `cancelled` + +如果 backend 有别的语义方案,需要先统一。 + +### 6.2 放弃恢复是否一定写后端 + +我个人建议写后端,并落成 `cancelled`。 + +但如果 backend 团队认为: + +- 放弃恢复只影响本地 +- 业务上仍允许以后继续从服务端 ongoing session 恢复 + +那就必须明确告知客户端,不然两边会冲突。 + +### 6.3 result 页是以后继续本地展示,还是跳业务结果页 + +当前客户端是本地结果页。 + +backend 后面如果要接业务结果页,最好提前定: + +- finish 成功后是否仍停留地图内结果页 +- 还是跳业务壳结果页 + +## 7. 我建议的最近动作 + +backend 现在最值得先做的,不是扩接口,而是先确认下面 3 条: + +1. `finished / failed / cancelled` 三态语义 +2. 放弃恢复是否写 `cancelled` +3. `start / finish` 是否按幂等处理 + +这 3 条一旦确定,前后端联调会顺很多。 + +## 8. 一句话结论 + +当前 backend 最重要的任务不是“再加更多接口”,而是: + +> 先把 session 运行态语义和故障恢复放弃语义定稳,再继续扩后台配置系统。 diff --git a/backend/docs/前后端联调清单.md b/backend/docs/前后端联调清单.md new file mode 100644 index 0000000..07a3f24 --- /dev/null +++ b/backend/docs/前后端联调清单.md @@ -0,0 +1,369 @@ +# 前后端联调清单 + +## 1. 目的 + +这份清单只回答三件事: + +1. 小程序当前已经具备哪些接后端的前置能力 +2. backend 当前已经提供了哪些可联调接口 +3. 哪些链路已经能接,哪些链路还缺适配 + +本文不讨论未来大而全后台方案,只服务当前联调落地。 + +## 2. 当前结论 + +当前状态可以概括成一句话: + +> backend 业务主链已经可联调;小程序地图运行内核也已经成型;两边之间还缺一层业务接入和会话上报适配。 + +也就是说: + +- 登录、活动详情、launch、session、result 这一条后端链已经可用 +- 小程序地图页已经支持携带 `configUrl / releaseId / sessionId / sessionToken` +- 但小程序当前仍主要走本地 demo / 直连 OSS manifest +- 真正的“后端 launch -> 地图页 -> session start/finish/result”还没有正式接上 + +## 3. 小程序当前已具备的联调基础 + +## 3.1 启动信封已经成型 + +地图页不是只吃一个 `configUrl`,而是吃一份启动信封: + +- [gameLaunch.ts](D:/dev/cmr-mini/miniprogram/utils/gameLaunch.ts) + +当前结构: + +- `config.configUrl` +- `config.configLabel` +- `config.configChecksumSha256` +- `config.releaseId` +- `config.routeCode` +- `business.source` +- `business.competitionId` +- `business.eventId` +- `business.launchRequestId` +- `business.participantId` +- `business.sessionId` +- `business.sessionToken` +- `business.sessionTokenExpiresAt` +- `business.realtimeEndpoint` +- `business.realtimeToken` + +这意味着: + +- backend `launch` 返回的数据结构已经能自然装进小程序地图启动链 +- 地图页并不需要重构启动模型,只需要把业务页接到 `GameLaunchEnvelope` + +## 3.2 地图页已经支持远端 manifest 启动 + +- [map.ts](D:/dev/cmr-mini/miniprogram/pages/map/map.ts) + +当前地图页会: + +1. 解析 `GameLaunchEnvelope` +2. 调 `loadRemoteMapConfig(configUrl)` +3. 编译 runtime profile +4. 启动 `MapEngine` + +所以只要后端能给出: + +- `manifestUrl` +- `releaseId` +- `configChecksumSha256` + +地图页就可以直接跑。 + +## 3.3 会话态字段已经进入地图页 + +地图页当前已经能接收并持有: + +- `sessionId` +- `sessionToken` +- `sessionTokenExpiresAt` + +这说明后面接: + +- `POST /sessions/{id}/start` +- `POST /sessions/{id}/finish` + +不需要再改地图启动协议。 + +## 3.4 故障恢复也已经具备会话上下文承载 + +故障恢复快照当前会保留: + +- `launchEnvelope` +- 运行态快照 + +这意味着一旦接入后端 session 后,恢复链也可以继续沿用同一份 `launchEnvelope`。 + +## 4. backend 当前已具备的联调基础 + +## 4.1 路由主链已落地 + +- [router.go](D:/dev/cmr-mini/backend/internal/httpapi/router.go) + +当前已实现: + +- `POST /auth/login/wechat-mini` +- `GET /me/entry-home` +- `GET /events/{eventPublicID}/play` +- `POST /events/{eventPublicID}/launch` +- `POST /sessions/{sessionPublicID}/start` +- `POST /sessions/{sessionPublicID}/finish` +- `GET /sessions/{sessionPublicID}/result` +- `GET /me/results` + +## 4.2 launch 返回结构已贴近客户端 + +- [核心流程.md](D:/dev/cmr-mini/backend/docs/核心流程.md) +- [接口清单.md](D:/dev/cmr-mini/backend/docs/接口清单.md) + +当前 `launch` 返回重点: + +- `launch.resolvedRelease.releaseId` +- `launch.resolvedRelease.manifestUrl` +- `launch.resolvedRelease.manifestChecksumSha256` +- `launch.business.sessionId` +- `launch.business.sessionToken` + +这和小程序 `GameLaunchEnvelope` 基本是同一语义。 + +## 4.3 session 运行态和结果态已分离 + +- [session_service.go](D:/dev/cmr-mini/backend/internal/service/session_service.go) + +当前已经区分: + +- 业务登录态:`access_token` +- 局内运行态:`sessionToken` + +这对地图页是对的,因为地图页真正需要的是: + +- 进入前有业务 token +- 进入后局内动作用 sessionToken + +## 4.4 开发 workbench 已可用于联调 + +- [dev_handler.go](D:/dev/cmr-mini/backend/internal/httpapi/handlers/dev_handler.go) + +当前 workbench 已能串: + +- bootstrap +- auth +- entry/home +- event play / launch +- session start / finish / detail +- result 查询 + +这对前后端联调非常有价值,说明后端已经不是“只看文档”阶段。 + +## 5. 当前已经能接的链路 + +## 5.1 P0:登录与业务页前置链 + +可接: + +1. 小程序 `wx.login` +2. `POST /auth/login/wechat-mini` +3. 拿到 `accessToken` +4. 调 `GET /me/entry-home` +5. 调 `GET /events/{eventPublicID}/play` + +当前缺口: + +- 小程序还没有正式业务页 API 适配层 +- 还没有统一 token 持久化与请求封装 + +## 5.2 P0:launch 进入地图 + +可接: + +1. 前置业务页拿到 event play +2. 调 `POST /events/{eventPublicID}/launch` +3. 把返回结果映射成 `GameLaunchEnvelope` +4. `navigateTo('/pages/map/map?...')` + +当前缺口: + +- 还没有一层 `backend launch -> GameLaunchEnvelope` 的适配函数 +- 当前 `gameLaunch.ts` 仍偏 demo/static config 驱动 + +## 5.3 P0:finish 回传结果 + +可接: + +1. 地图页结束一局 +2. 提取结果摘要 +3. 用 `sessionId + sessionToken` 调 `POST /sessions/{id}/finish` +4. 业务页或结果页再查 `GET /sessions/{id}/result` + +当前缺口: + +- 小程序本地结果页已经有摘要,但还没有正式调用 backend finish +- finish payload 和本地 `resultSummary` 之间还需要一层映射 + +## 6. 当前还不能说已经接通的链路 + +## 6.1 配置后台 source/build/release + +backend 当前已经有: + +- 表结构 +- 文档模型 + +但还没有真正开放: + +- `config source` +- `build` +- `release assets` +- `preview launch` + +也就是说: + +**配置后台链还不能联调,只能联业务主链。** + +## 6.2 body profile / 遥测个体化 + +小程序已经有: + +- 身体数据入口 +- 遥测 runtime profile + +backend 文档里也规划了: + +- 用户身体资料 + +但当前接口清单里还没有明确的 body profile 读接口落到小程序链上,所以这条还不能算当前联调主线。 + +## 7. 当前最大的接口适配缺口 + +我认为目前最大缺口只有 4 个: + +### 7.1 业务 API 客户端缺失 + +小程序当前缺: + +- 统一 `request` 封装 +- token 持久化 +- access token 刷新 +- backend DTO -> 小程序 view model 适配 + +### 7.2 launch 适配层缺失 + +需要一层明确的转换: + +`LaunchResponse -> GameLaunchEnvelope` + +这里最适合单独做成一个小模块,而不是散落在页面里。 + +### 7.3 session finish 映射缺失 + +地图页当前本地已经有: + +- 用时 +- 分数 +- 完成点数 +- 里程 +- 速度 +- 最大心率 + +但还没有一个稳定函数把它映射为 backend finish payload。 + +### 7.4 业务结果页与地图结果页还未打通 + +现在地图页已经有自己的结果页。 + +后面要决定: + +- 地图页结果页先本地展示,再异步回传 +- 还是 finish 成功后跳业务结果页 + +这件事需要前后端统一策略。 + +## 8. 推荐联调顺序 + +建议按下面顺序推进,不要跳步: + +### 第一步:接微信小程序登录 + +目标: + +- 小程序拿到 `accessToken` +- 能请求鉴权接口 + +### 第二步:接 event play + +目标: + +- 小程序业务页能拿到: + - `event` + - `resolvedRelease` + - `play.canLaunch` + - `play.ongoingSession` + +### 第三步:接 launch -> map + +目标: + +- 从后端 launch 返回直接进入地图 +- 不再靠 demo preset 手工切配置 + +### 第四步:接 start / finish / result + +目标: + +- 开赛后能回传 start +- 结束后能回传 finish +- 结果页能查 backend result + +### 第五步:再考虑 ongoing session 恢复 + +目标: + +- backend ongoing session +- 本地故障恢复 + +两条链统一口径 + +## 9. 当前已落地的小程序联调适配 + +小程序侧当前已经补了第一批适配层: + +- [backendAuth.ts](D:/dev/cmr-mini/miniprogram/utils/backendAuth.ts) +- [backendApi.ts](D:/dev/cmr-mini/miniprogram/utils/backendApi.ts) +- [backendLaunchAdapter.ts](D:/dev/cmr-mini/miniprogram/utils/backendLaunchAdapter.ts) +- [index.ts](D:/dev/cmr-mini/miniprogram/pages/index/index.ts) + +当前已具备: + +- 后端 base URL 本地持久化 +- access / refresh token 本地持久化 +- 微信小程序登录请求封装 +- `event play` 请求封装 +- `launch -> GameLaunchEnvelope` 适配 +- 从首页直接 `launch` 进入地图 +- 地图页 `session start / finish` 上报接入 + +因此当前主链已从“可分析”进入“可实测”。 + +## 10. 我建议的最近行动项 + +如果开始联调,我建议先做这 3 件事: + +1. 新增小程序 `backendApi` 请求层 + 先只包 auth / event play / launch / session finish + +2. 新增 `launchAdapter` + 把 backend launch 响应稳定转成 `GameLaunchEnvelope` + +3. 新增 `finishAdapter` + 把地图页结果摘要稳定转成 backend finish payload + +这三件做完,前后端主链就能真正接起来。 + +## 11. 一句话结论 + +当前最真实的进度判断是: + +> backend 业务后端主链已经进入可联调阶段;小程序地图运行内核也已经具备承接能力;下一步最值钱的是补小程序业务 API 层和 launch/finish 两个适配器。 diff --git a/backend/docs/开发说明.md b/backend/docs/开发说明.md new file mode 100644 index 0000000..8722c2d --- /dev/null +++ b/backend/docs/开发说明.md @@ -0,0 +1,182 @@ +# 开发说明 + +## 1. 环境变量 + +参考 [`.env.example`](D:/dev/cmr-mini/backend/.env.example)。 + +当前最关键的变量: + +- `APP_ENV` +- `HTTP_ADDR` +- `DATABASE_URL` +- `JWT_ACCESS_SECRET` +- `AUTH_SMS_PROVIDER` +- `AUTH_DEV_SMS_CODE` +- `WECHAT_MINI_APP_ID` +- `WECHAT_MINI_APP_SECRET` +- `WECHAT_MINI_DEV_PREFIX` +- `LOCAL_EVENT_DIR` +- `ASSET_BASE_URL` + +## 2. 本地启动 + +```powershell +cd D:\dev\cmr-mini\backend +go run .\cmd\api +``` + +如果你想固定跑开发工作台常用端口 `18090`,直接执行: + +```powershell +cd D:\dev\cmr-mini\backend +.\scripts\start-dev.ps1 +``` + +默认会设置: + +- `APP_ENV=development` +- `HTTP_ADDR=:18090` +- `DATABASE_URL=postgres://postgres:asdf*123@192.168.100.77:5432/cmr20260401?sslmode=disable` +- `AUTH_SMS_PROVIDER=console` +- `WECHAT_MINI_DEV_PREFIX=dev-` + +启动后可直接打开: + +- [http://127.0.0.1:18090/dev/workbench](http://127.0.0.1:18090/dev/workbench) + +## 3. 当前开发约定 + +### 3.1 开发阶段先不用 Redis + +当前第一版全部依赖: + +- PostgreSQL +- JWT +- refresh token 持久化 + +Redis 后面只在需要性能优化、限流或短期票据缓存时再接。 + +### 3.2 开发环境短信 + +当前默认可走 `console` provider。 + +用途: + +- 本地联调无需接真实短信供应商 + +### 3.3 微信小程序开发态 + +当前支持 `dev-` 前缀 code。 + +适合: + +- 后端联调 +- workbench 快速验证 + +### 3.4 本地配置目录 + +当前支持从根目录 [event](D:/dev/cmr-mini/event) 导入本地配置文件。 + +相关环境变量: + +- `LOCAL_EVENT_DIR` +- `ASSET_BASE_URL` + +作用: + +- `LOCAL_EVENT_DIR` 决定本地 source config 从哪里读 +- `ASSET_BASE_URL` 决定 preview build 时如何把相对资源路径归一化成可运行 URL + +## 4. Migration + +当前 migration 文件在 [migrations](D:/dev/cmr-mini/backend/migrations)。 + +执行原则: + +1. 按编号顺序执行 +2. schema 变更只通过新增 migration 完成 +3. 不直接改线上已执行 migration + +## 5. 开发工作台 + +### `POST /dev/bootstrap-demo` + +它会保证 demo 数据存在: + +- `tenant_demo` +- `mini-demo` +- `evt_demo_001` +- `rel_demo_001` +- `card_demo_001` + +### `GET /dev/workbench` + +这是当前最重要的联调工具。 + +可以直接测试: + +- 登录 +- 入口解析 +- 首页聚合 +- event play +- 配置导入、preview build、publish build +- launch +- session start / finish +- result +- profile + +并且支持: + +- quick flow +- scenario 保存/导入/导出 +- curl 导出 +- request history + +## 6. 当前推荐联调顺序 + +### 场景一:小程序快速进入 + +1. `bootstrap-demo` +2. `login/wechat-mini` +3. `me/entry-home` +4. `events/{id}/play` +5. `events/{id}/launch` +6. `sessions/{id}/start` +7. `sessions/{id}/finish` +8. `sessions/{id}/result` + +### 场景二:APP 主身份 + +1. `auth/sms/send` +2. `auth/login/sms` +3. `me/entry-home` +4. `launch` +5. `session` +6. `result` + +### 场景三:微信轻账号绑定手机号 + +1. `login/wechat-mini` +2. `auth/sms/send` with `scene=bind_mobile` +3. `auth/bind/mobile` +4. `me/profile` + +### 场景四:配置发布到可启动 release + +1. `bootstrap-demo` +2. `dev/events/{eventPublicID}/config-sources/import-local` +3. `dev/config-builds/preview` +4. `dev/config-builds/publish` +5. `events/{id}` +6. `events/{id}/launch` + +## 7. 当前后续开发建议 + +文档整理完之后,后面建议按这个顺序继续: + +1. 抽出更通用的 `play context -> launch` 模型 +2. 补赛事与报名层 +3. 补页面配置和白标首页 +4. 再考虑实时网关票据 + +不要跳回去把玩法规则塞进 backend。 diff --git a/backend/docs/接口清单.md b/backend/docs/接口清单.md new file mode 100644 index 0000000..c895bd5 --- /dev/null +++ b/backend/docs/接口清单.md @@ -0,0 +1,425 @@ +# API 清单 + +本文档只记录当前 backend 已实现接口,不写未来规划接口。 + +## 1. Health + +### `GET /healthz` + +用途: + +- 健康检查 + +## 2. Auth + +### `POST /auth/sms/send` + +用途: + +- 发登录验证码 +- 发绑定手机号验证码 + +核心参数: + +- `countryCode` +- `mobile` +- `clientType` +- `deviceKey` +- `scene` + +### `POST /auth/login/sms` + +用途: + +- APP 手机号验证码登录 + +返回重点: + +- `user` +- `tokens.accessToken` +- `tokens.refreshToken` + +### `POST /auth/login/wechat-mini` + +用途: + +- 微信小程序登录 + +开发态: + +- 支持 `dev-` 前缀 code + +### `POST /auth/bind/mobile` + +鉴权: + +- Bearer token + +用途: + +- 已登录用户绑定手机号 +- 必要时执行账号合并 + +### `POST /auth/refresh` + +用途: + +- 刷新 access token + +### `POST /auth/logout` + +用途: + +- 撤销 refresh token + +## 3. Entry / Home + +### `GET /entry/resolve` + +用途: + +- 解析当前入口归属哪个 tenant / channel + +查询参数: + +- `channelCode` +- `channelType` +- `platformAppId` +- `tenantCode` + +### `GET /home` + +用途: + +- 返回入口首页卡片 + +### `GET /cards` + +用途: + +- 只返回卡片列表 + +### `GET /me/entry-home` + +鉴权: + +- Bearer token + +用途: + +- 首页聚合接口 + +返回重点: + +- `user` +- `tenant` +- `channel` +- `cards` +- `ongoingSession` +- `recentSession` + +## 4. Event + +### `GET /events/{eventPublicID}` + +用途: + +- Event 详情 + +返回重点: + +- `event` +- `release` +- `resolvedRelease` + +### `GET /events/{eventPublicID}/play` + +鉴权: + +- Bearer token + +用途: + +- 活动详情页 / 开始前准备页聚合 + +返回重点: + +- `event` +- `release` +- `resolvedRelease` +- `play.canLaunch` +- `play.primaryAction` +- `play.launchSource` +- `play.ongoingSession` +- `play.recentSession` + +### `POST /events/{eventPublicID}/launch` + +鉴权: + +- Bearer token + +用途: + +- 基于当前 event 的可启动 release 创建一局 session + +请求体重点: + +- `releaseId` +- `clientType` +- `deviceKey` + +返回重点: + +- `launch.source` +- `launch.resolvedRelease` +- `launch.config` +- `launch.business.sessionId` +- `launch.business.sessionToken` + +### `GET /events/{eventPublicID}/config-sources` + +鉴权: + +- Bearer token + +用途: + +- 查看某个 event 的 source config 列表 + +### `GET /config-sources/{sourceID}` + +鉴权: + +- Bearer token + +用途: + +- 查看单条 source config 明细 + +### `GET /config-builds/{buildID}` + +鉴权: + +- Bearer token + +用途: + +- 查看单次 build 明细 + +## 5. Session + +### `GET /sessions/{sessionPublicID}` + +鉴权: + +- Bearer token + +用途: + +- 查询一局详情 + +返回重点: + +- `session` +- `event` +- `resolvedRelease` + +### `POST /sessions/{sessionPublicID}/start` + +鉴权: + +- `sessionToken` + +用途: + +- 将 session 从 `launched` 推进到 `running` + +### `POST /sessions/{sessionPublicID}/finish` + +鉴权: + +- `sessionToken` + +用途: + +- 结束一局 +- 同时沉淀结果摘要 + +请求体重点: + +- `sessionToken` +- `status` +- `summary.finalDurationSec` +- `summary.finalScore` +- `summary.completedControls` +- `summary.totalControls` +- `summary.distanceMeters` +- `summary.averageSpeedKmh` +- `summary.maxHeartRateBpm` + +### `GET /me/sessions` + +鉴权: + +- Bearer token + +用途: + +- 查询用户最近 session + +## 6. Result + +### `GET /sessions/{sessionPublicID}/result` + +鉴权: + +- Bearer token + +用途: + +- 查询单局结果页数据 + +返回重点: + +- `session` +- `result` + +`session` 中会带: + +- `releaseId` +- `configLabel` + +### `GET /me/results` + +鉴权: + +- Bearer token + +用途: + +- 查询用户最近结果列表 + +## 7. Profile + +### `GET /me` + +鉴权: + +- Bearer token + +用途: + +- 当前用户基础信息 + +### `GET /me/profile` + +鉴权: + +- Bearer token + +用途: + +- “我的页”聚合接口 + +返回重点: + +- `user` +- `bindings` +- `recentSessions` + +## 8. Dev + +### `POST /dev/bootstrap-demo` + +环境: + +- 仅 non-production + +用途: + +- 自动准备 demo tenant / channel / event / release / card + +### `GET /dev/workbench` + +环境: + +- 仅 non-production + +用途: + +- 后端自带 API 测试面板 + +当前支持: + +- bootstrap +- auth +- entry/home +- event/play/launch +- session start/finish/detail +- result 查询 +- profile 查询 +- quick flows +- scenarios +- request history +- curl 导出 + +### `GET /dev/config/local-files` + +环境: + +- 仅 non-production + +用途: + +- 列出本地配置目录中的 JSON 文件 + +### `POST /dev/events/{eventPublicID}/config-sources/import-local` + +环境: + +- 仅 non-production + +用途: + +- 从本地配置目录导入 source config + +请求体重点: + +- `fileName` +- `notes` + +### `POST /dev/config-builds/preview` + +环境: + +- 仅 non-production + +用途: + +- 基于 source config 生成 preview build + +请求体重点: + +- `sourceId` + +### `POST /dev/config-builds/publish` + +环境: + +- 仅 non-production + +用途: + +- 将成功的 preview build 发布成正式 release +- 自动切换 `event.current_release_id` + +请求体重点: + +- `buildId` + +返回重点: + +- `release.releaseId` +- `release.manifestUrl` +- `release.configLabel` diff --git a/backend/docs/数据模型.md b/backend/docs/数据模型.md new file mode 100644 index 0000000..376b385 --- /dev/null +++ b/backend/docs/数据模型.md @@ -0,0 +1,171 @@ +# 数据模型 + +当前 migration 共 5 版。 + +## 1. 迁移清单 + +- [0001_init.sql](D:/dev/cmr-mini/backend/migrations/0001_init.sql) +- [0002_launch.sql](D:/dev/cmr-mini/backend/migrations/0002_launch.sql) +- [0003_home.sql](D:/dev/cmr-mini/backend/migrations/0003_home.sql) +- [0004_results.sql](D:/dev/cmr-mini/backend/migrations/0004_results.sql) +- [0005_config_pipeline.sql](D:/dev/cmr-mini/backend/migrations/0005_config_pipeline.sql) + +## 2. 表分组 + +### 2.1 多租户与入口 + +- `tenants` +- `entry_channels` + +职责: + +- 识别品牌壳 +- 识别渠道入口 +- 承接后续俱乐部 / 政府公众号 / H5 / 二维码入口 + +### 2.2 用户与登录 + +- `users` +- `login_identities` +- `auth_sms_codes` +- `auth_refresh_tokens` + +职责: + +- 平台级用户 +- 多身份登录 +- 验证码记录 +- refresh token 持久化 + +当前身份示例: + +- `mobile` +- `wechat_mini_openid` +- `wechat_unionid` + +### 2.3 业务对象与配置发布 + +- `events` +- `event_releases` + +职责分工: + +- `events` 管业务对象身份和展示 +- `event_releases` 管发布后的运行配置入口 + +关键字段: + +- `events.current_release_id` +- `event_releases.release_public_id` +- `event_releases.config_label` +- `event_releases.manifest_url` +- `event_releases.manifest_checksum_sha256` +- `event_releases.route_code` + +### 2.4 首页与入口卡片 + +- `cards` + +职责: + +- 支撑首页卡片 +- 运营入口聚合 +- tenant/channel 维度展示控制 + +### 2.5 运行态 + +- `game_sessions` +- `session_results` + +职责: + +- 固化一局游戏 +- 固化该局绑定的 release +- 固化局后结果摘要 + +### 2.6 配置构建与发布资产 + +- `event_config_sources` +- `event_config_builds` +- `event_release_assets` + +职责: + +- 保存编辑态 source config +- 保存构建后的 manifest 和 asset index +- 保存正式 release 关联的资产清单 + +## 3. 当前最关键的关系 + +### `tenant -> entry_channel` + +一个 tenant 下可有多个渠道入口。 + +### `user -> login_identity` + +一个平台用户可绑定多个登录身份。 + +### `event -> event_release` + +一个 event 可有多个 release。 + +客户端真正进入游戏时,最终会消费其中一份 release 的 manifest。 + +### `event_release -> game_session` + +一局 session 必须绑定一份明确的 release。 + +这是当前系统最关键的配置驱动约束。 + +### `game_session -> session_result` + +一局结束后可有一条结果摘要。 + +### `event_config_source -> event_config_build -> event_release` + +这是后续配置生命周期主链: + +- source 是编辑态 +- build 是构建态 +- release 是发布态 + +## 4. 当前已落库但仍应注意的边界 + +### 4.1 不要把玩法细节塞回事件主表 + +当前数据库只记录: + +- 发布关系 +- manifest 入口 +- 结果摘要 + +玩法解释器仍应留在游戏客户端。 + +### 4.2 不要让历史局跟随当前 release 漂移 + +即使 event 后面发布新版本: + +- 旧 session 仍然指向旧 `event_release_id` +- 旧 result 仍然对应旧 release + +### 4.3 不要把登录态和运行态混在一起 + +当前已有两种 token: + +- `access_token` +- `sessionToken` + +后面如果加实时网关,也应继续区分。 + +## 5. 当前缺口 + +当前 schema 还没有这些模块: + +- `competitions` +- `registrations` +- `page_configs` +- `clubs` +- `client_devices` +- 实时票据 / 网关票据 + +这些后面要按真正业务需要补 migration,不要先拍脑袋建大而全表。 diff --git a/backend/docs/核心流程.md b/backend/docs/核心流程.md new file mode 100644 index 0000000..2619be3 --- /dev/null +++ b/backend/docs/核心流程.md @@ -0,0 +1,204 @@ +# 核心流程 + +## 1. 总流程 + +```mermaid +flowchart LR + A["Entry Resolve"] --> B["Auth"] + B --> C["Home / Cards"] + C --> D["Event Play"] + D --> E["Resolve Release"] + E --> F["Launch Session"] + F --> G["Client Load Manifest"] + G --> H["Session Start / Finish"] + H --> I["Result / History"] +``` + +## 2. 入口解析 + +入口层先解决: + +- 用户从哪个渠道进来 +- 当前归属哪个 `tenant` +- 当前品牌壳和首页卡片应该加载什么 + +当前对应接口: + +- `GET /entry/resolve` +- `GET /home` +- `GET /cards` +- `GET /me/entry-home` + +## 3. 登录流程 + +### 3.1 APP + +APP 当前主链是手机号验证码: + +1. `POST /auth/sms/send` +2. `POST /auth/login/sms` +3. 返回 `access_token + refresh_token` + +### 3.2 微信小程序 + +微信小程序当前主链是: + +1. 客户端 `wx.login` +2. `POST /auth/login/wechat-mini` +3. 后端换取 `openid` +4. 返回 `access_token + refresh_token` + +开发环境也支持 `dev-` 前缀 code。 + +### 3.3 绑定与合并 + +当小程序用户后续绑定手机号时: + +1. 先发 `bind_mobile` 场景验证码 +2. `POST /auth/bind/mobile` +3. 如果手机号已属于别的用户,则合并到手机号主账号 + +当前策略是: + +- 手机号账号优先 +- 微信轻账号并入手机号账号 + +## 4. 首页流程 + +首页不是固定首页,而是“入口上下文首页”。 + +当前聚合接口: + +- `GET /me/entry-home` + +它会返回: + +- 当前用户 +- 当前 tenant +- 当前 channel +- 当前 cards +- 继续中的 session +- 最近一局 session + +## 5. Event Play 流程 + +活动详情页或开始前准备页不应该只拿 `event`。 + +它还必须拿到: + +- 当前是否可启动 +- 当前会落到哪份 `release` +- 是否有 ongoing session +- 当前推荐动作是什么 + +当前聚合接口: + +- `GET /events/{eventPublicID}/play` + +它会返回: + +- `event` +- `release` +- `resolvedRelease` +- `play.canLaunch` +- `play.primaryAction` +- `play.launchSource` +- `play.ongoingSession` +- `play.recentSession` + +## 6. Launch 流程 + +### 6.1 当前原则 + +启动一局游戏时,不是“启动一个 event”。 + +而是: + +> 基于 event 当前可启动的 release,创建一条固化 release 的 session。 + +### 6.2 当前接口 + +- `POST /events/{eventPublicID}/launch` + +当前请求体支持: + +- `releaseId` +- `clientType` +- `deviceKey` + +当前返回会带: + +- `launch.source` +- `launch.resolvedRelease` +- `launch.config` +- `launch.business.sessionId` +- `launch.business.sessionToken` + +### 6.3 客户端应如何使用 + +客户端进入游戏前,应以返回中的这几项为准: + +- `launch.resolvedRelease.releaseId` +- `launch.resolvedRelease.manifestUrl` +- `launch.resolvedRelease.manifestChecksumSha256` + +而不是再拿 `event` 自己去猜。 + +## 7. Session 流程 + +### 7.1 当前接口 + +- `GET /sessions/{sessionPublicID}` +- `POST /sessions/{sessionPublicID}/start` +- `POST /sessions/{sessionPublicID}/finish` +- `GET /me/sessions` + +### 7.2 鉴权模型 + +查询接口: + +- 用 `access_token` + +局内动作接口: + +- 用 `sessionToken` + +这保证了业务登录态和一局游戏运行态是分开的。 + +## 8. 结果流程 + +### 8.1 当前接口 + +- `GET /sessions/{sessionPublicID}/result` +- `GET /me/results` + +### 8.2 当前 finish payload + +`finish` 当前支持上传结果摘要: + +- `finalDurationSec` +- `finalScore` +- `completedControls` +- `totalControls` +- `distanceMeters` +- `averageSpeedKmh` +- `maxHeartRateBpm` + +### 8.3 结果页约束 + +结果页应该基于 session 结果查看,不应该回头去查当前 event 当前 release。 + +因为: + +- 一个 event 未来可能发布新版本 +- 历史结果必须追溯到当时真实跑过的那份 release + +## 9. 当前最应该坚持的流程约束 + +业务主线应始终保持为: + +`entry -> auth -> event play -> resolve release -> launch -> session -> result` + +不要退回成: + +`event -> launch -> game` diff --git a/backend/docs/系统架构.md b/backend/docs/系统架构.md new file mode 100644 index 0000000..2b78477 --- /dev/null +++ b/backend/docs/系统架构.md @@ -0,0 +1,200 @@ +# 系统架构 + +## 1. 目标 + +当前 backend 不是一个“给地图页喂数据的简单服务”,而是一个业务壳后端。 + +它负责: + +- 用户与登录 +- 多租户与多入口 +- 首页与业务入口聚合 +- Event 业务对象 +- 配置发布解析 +- 启动一局游戏 +- session 生命周期 +- 结果沉淀 + +它不负责: + +- 解释游戏玩法细节 +- 运行时解析复杂地图规则 +- 直接下发数据库编辑态对象给客户端 + +## 2. 分层 + +### 2.1 平台层 + +平台层统一处理: + +- `tenant` +- `entry_channel` +- `user` +- `login_identity` +- `auth_refresh_token` + +这层是整个平台共用能力。 + +### 2.2 业务层 + +业务层统一处理: + +- `card` +- `event` +- `event_play` +- `entry_home` +- `profile` + +它面向页面和运营入口,但不直接承载游戏规则。 + +### 2.3 配置发布层 + +配置发布层统一处理: + +- `event_release` +- `manifest_url` +- `manifest_checksum_sha256` +- `route_code` + +这层是“客户端真正进入游戏时要消费的运行配置入口”。 + +### 2.4 运行层 + +运行层统一处理: + +- `game_session` +- `session_token` +- `session_results` + +这层不关心编辑态,只关心“一局游戏”。 + +## 3. 最重要的对象关系 + +### 3.1 `event` + +`event` 是业务对象。 + +它负责: + +- 活动身份 +- 展示名称 +- 业务状态 +- 当前指向的发布版本 + +它不是客户端实际运行的配置文件本体。 + +### 3.2 `event_release` + +`event_release` 是配置发布对象。 + +它负责: + +- 这次发布的 `manifest_url` +- 配置标签 `config_label` +- 可选校验值 +- 可选 `route_code` + +进入游戏时,客户端真正需要的是这里。 + +### 3.3 `game_session` + +`game_session` 是运行对象。 + +它必须固化: + +- 当前用户 +- 当前 event +- 当前实际使用的 `event_release` +- 当前 `session_token` + +这样后续哪怕 event 切到新 release,旧 session 也不会漂移。 + +## 4. 配置驱动原则 + +这套系统必须坚持下面这条原则: + +> 业务层先解析出一份可启动的 release,客户端再基于这份 release 的 manifest 进入游戏。 + +不能走成: + +> 客户端拿到 event 后自己再去推断该加载哪份配置 + +所以当前接口都在往这个方向收口: + +- `GET /events/{id}/play` 会返回 `resolvedRelease` +- `POST /events/{id}/launch` 会返回 `resolvedRelease` +- `GET /sessions/{id}` 会返回 `resolvedRelease` +- `GET /sessions/{id}/result` 能追溯到当时的 release + +## 5. 代码分层 + +### 5.1 入口层 + +- [main.go](D:/dev/cmr-mini/backend/cmd/api/main.go) +- [app.go](D:/dev/cmr-mini/backend/internal/app/app.go) +- [config.go](D:/dev/cmr-mini/backend/internal/app/config.go) + +### 5.2 HTTP 层 + +- [router.go](D:/dev/cmr-mini/backend/internal/httpapi/router.go) +- [handlers](D:/dev/cmr-mini/backend/internal/httpapi/handlers) +- [middleware](D:/dev/cmr-mini/backend/internal/httpapi/middleware) + +### 5.3 用例层 + +- [service](D:/dev/cmr-mini/backend/internal/service) + +当前主要服务: + +- `AuthService` +- `EntryService` +- `HomeService` +- `EntryHomeService` +- `EventService` +- `EventPlayService` +- `SessionService` +- `ResultService` +- `ProfileService` +- `DevService` + +### 5.4 数据层 + +- [store/postgres](D:/dev/cmr-mini/backend/internal/store/postgres) + +特点: + +- 手写 SQL +- `pgx` 连接池 +- 不依赖 ORM + +### 5.5 平台适配层 + +- [jwtx](D:/dev/cmr-mini/backend/internal/platform/jwtx) +- [security](D:/dev/cmr-mini/backend/internal/platform/security) +- [wechatmini](D:/dev/cmr-mini/backend/internal/platform/wechatmini) + +## 6. 当前边界 + +### 6.1 backend 管什么 + +- 业务身份 +- 配置发布解析 +- 启动编排 +- 一局的生命周期和结果 + +### 6.2 游戏客户端管什么 + +- 下载 `manifest_url` +- 解析运行配置 +- 驱动地图和玩法 +- 产生过程数据和结束摘要 + +### 6.3 后续网关该怎么接 + +后面如果接实时网关,建议仍然走: + +- backend 负责登录与 launch +- launch 或 session 负责产出短期实时票据 +- 网关只认 backend 签发的运行态票据 + +不要把微信身份或业务 token 直接暴露给实时网关。 diff --git a/backend/docs/配置管理方案.md b/backend/docs/配置管理方案.md new file mode 100644 index 0000000..7ab9fea --- /dev/null +++ b/backend/docs/配置管理方案.md @@ -0,0 +1,412 @@ +# 配置管理方案 + +## 1. 目标 + +后续 backend 不应该只“管理一个 event JSON 文件”,而应该管理一整套可伸缩的配置生命周期。 + +这套生命周期至少要覆盖: + +1. 编辑态源配置 +2. 构建态中间产物 +3. 对外发布版本 +4. 启动时绑定的 release +5. 运行完成后的 session 追溯 + +核心目标不是支持当前字段,而是支持以后继续加字段时,主架构不需要推翻。 + +## 2. 当前现状 + +当前根目录下的 [event](D:/dev/cmr-mini/event) 已经保存了最小启动配置样例: + +- [classic-sequential.json](D:/dev/cmr-mini/event/classic-sequential.json) +- [score-o.json](D:/dev/cmr-mini/event/score-o.json) + +从这两个样例看,当前“最小启动配置”已经有了很好的雏形: + +- `app` +- `map` +- `playfield` +- `game.mode` + +这类文件很适合作为运行时 manifest 的基础形态。 + +但如果后续继续往里面堆: + +- 赛事规则 +- 计分规则 +- 内容页 +- 安全策略 +- 品牌配置 +- 多媒体资源 +- telemetry 开关 +- 实验字段 + +就不能再只靠单个最终 JSON 手工维护了。 + +## 3. 核心原则 + +### 3.1 稳定的是层,不是字段 + +后端要稳定的是这些层: + +- `source config` +- `build` +- `release` +- `launch` +- `session` + +而不是把所有具体配置字段都设计成强结构数据库列。 + +### 3.2 编辑态和运行态必须分离 + +编辑态: + +- 配置项可以很多 +- 允许草稿 +- 允许试验字段 +- 允许中间状态 + +运行态: + +- 必须稳定 +- 必须可校验 +- 必须有版本 +- 必须能被客户端直接消费 + +### 3.3 客户端只消费发布产物 + +客户端进入游戏时,不应直接读取编辑态对象。 + +客户端应该只消费: + +- `manifest_url` +- `manifest_checksum_sha256` +- 与 manifest 配套的发布资源 + +### 3.4 session 必须固化 release + +只要一局启动了: + +- 必须固化 `event_release_id` +- 后续 event 切新发布,不影响老 session +- 结果页和历史页都必须能回看当时那份配置 + +## 4. 三层配置模型 + +## 4.1 第一层:源配置 + +这是编辑态配置。 + +建议特点: + +- 允许字段增长 +- 允许草稿 +- 允许频繁修改 +- 主要存 `jsonb` + +它对应“最大启动配置”或“完整编辑配置集合”。 + +### 可能包含的块 + +- `app` +- `branding` +- `map` +- `playfield` +- `game` +- `rules` +- `scoring` +- `timeControl` +- `content` +- `assets` +- `safety` +- `telemetry` +- `featureFlags` + +## 4.2 第二层:构建产物 + +这是后端根据源配置构建出来的中间结果。 + +建议职责: + +- schema 校验 +- 引用资源补全 +- 相对路径转绝对路径 +- 生成最终 manifest +- 生成资产清单 +- 记录构建日志 + +这一层是后续做“预览构建”“草稿预览”“发布前检查”的关键。 + +## 4.3 第三层:发布版本 + +这是正式对外运行时版本。 + +建议职责: + +- 绑定 build 结果 +- 绑定 manifest URL +- 绑定 checksum +- 绑定资源清单 +- 进入 launch 链路 + +当前已有的 `event_releases` 就是这层的起点,但后面还需要更完整的 build / assets 支撑。 + +## 5. 最小启动配置和最大配置怎么定义 + +建议不要把“最小配置 / 最大配置”当成数据库对象名,而要作为两种形态理解。 + +### 5.1 最小启动配置 + +就是客户端能开局所必需的最小 manifest。 + +建议包含: + +- `schemaVersion` +- `releaseId` +- `app` +- `map` +- `playfield` +- `game` +- 必要资源引用 + +特点: + +- 结构稳定 +- 字段尽量少 +- 客户端可直接消费 + +### 5.2 最大配置 + +就是完整编辑态 source config。 + +特点: + +- 字段可以很多 +- 块可以不断扩展 +- 不要求直接给客户端消费 +- 构建后才会变成运行时 manifest + +## 6. 当前 event 目录该扮演什么角色 + +当前根目录 [event](D:/dev/cmr-mini/event) 建议继续保留,但角色要明确: + +它应该是: + +- 本地源配置样例目录 +- 构建输入参考目录 +- 调试和原型验证输入 + +它不应该直接承担: + +- 线上唯一配置源 +- 发布版本存储 +- 客户端直接运行入口 + +线上真正的运行入口应当是: + +- 数据库里的 release 元数据 +- 对象存储/CDN 里的 manifest 和资源 + +## 7. 数据模型建议 + +在当前 [数据模型.md](D:/dev/cmr-mini/backend/docs/数据模型.md) 基础上,建议新增 3 张核心表。 + +这 3 张表的第一版 migration 已经落在: + +- [0005_config_pipeline.sql](D:/dev/cmr-mini/backend/migrations/0005_config_pipeline.sql) + +## 7.1 `event_config_sources` + +用途: + +- 存编辑态源配置版本 + +建议字段: + +- `id` +- `event_id` +- `source_version_no` +- `source_kind` +- `schema_id` +- `schema_version` +- `status` +- `source_jsonb` +- `notes` +- `created_by_user_id` +- `created_at` + +说明: + +- `source_jsonb` 存完整编辑态配置 +- `schema_id + schema_version` 用来做校验 + +## 7.2 `event_config_builds` + +用途: + +- 存一次构建的结果 + +建议字段: + +- `id` +- `event_id` +- `source_id` +- `build_no` +- `build_status` +- `build_log` +- `manifest_jsonb` +- `asset_index_jsonb` +- `created_by_user_id` +- `created_at` + +说明: + +- `manifest_jsonb` 是构建后得到的运行 manifest +- `asset_index_jsonb` 是构建时收集到的资源清单 + +## 7.3 `event_release_assets` + +用途: + +- 存 release 的资源清单 + +建议字段: + +- `id` +- `event_release_id` +- `asset_type` +- `asset_key` +- `asset_path` +- `asset_url` +- `checksum` +- `size_bytes` +- `meta_jsonb` + +说明: + +- 这张表非常适合后面做资源核对、回滚、调试和发布检查 + +## 8. 强结构和弱结构怎么分 + +## 8.1 强结构字段 + +这些字段后端应强约束: + +- `event_id` +- `release_id` +- `manifest_url` +- `manifest_checksum_sha256` +- `status` +- `published_at` +- `session_public_id` +- `event_release_id` + +这些是运行链路基础,不适合做成松散字段。 + +## 8.2 弱结构字段 + +这些字段建议主要放 `jsonb`: + +- 玩法规则 +- 计分策略 +- 文案内容 +- H5 内容块 +- 品牌视觉配置 +- 资源扩展配置 +- feature flags +- 实验字段 + +这样后面新增字段时,主链路不会被迫重构。 + +## 9. 后端后续能力建议 + +## 9.1 源配置管理 + +建议支持: + +- 保存草稿 source +- 查看 source 历史版本 +- source diff +- 从文件导入 source + +## 9.2 构建能力 + +建议支持: + +- 校验 source schema +- 校验资源引用存在 +- 生成 manifest +- 生成 asset index +- 输出 build log + +## 9.3 发布能力 + +建议支持: + +- 从某个 build 发布 release +- 生成 `manifest_url` +- 上传 release 资产 +- 标记当前生效 release +- 回滚旧 release + +## 9.4 调试能力 + +建议支持: + +- 预览构建结果 +- 查看某个 release 资产清单 +- 查看某个 session 实际绑定的 release 和 manifest + +## 10. 推荐 API 路线 + +建议后面按这个顺序补接口: + +### 第一批:source + +- `POST /events/{id}/config-sources` +- `GET /events/{id}/config-sources` +- `GET /config-sources/{id}` + +### 第二批:build + +- `POST /config-sources/{id}/build` +- `GET /builds/{id}` +- `GET /builds/{id}/manifest` + +### 第三批:release + +- `POST /builds/{id}/release` +- `GET /releases/{id}` +- `GET /releases/{id}/assets` + +### 第四批:preview + +- `GET /events/{id}/preview-play` +- `POST /builds/{id}/preview-launch` + +## 11. 推荐开发顺序 + +当前最值得先做的不是配置后台 UI,而是配置构建器。 + +建议顺序: + +1. 先定义 source config 和 manifest 的字段边界 +2. 先建 `event_config_sources` +3. 先做 schema 校验器 +4. 先做 build 产物生成 +5. 再建 `event_config_builds` +6. 再做正式 release 发布 +7. 最后才做后台编辑器 + +原因很简单: + +- 没有 build/release 核心能力,后台只是个大表单 +- 先把构建链打通,后面各种管理壳层才有基础 + +## 12. 一句话结论 + +后续 backend 不该做成“管理一个越来越大的 event JSON 文件”,而应该做成: + +> 源配置管理 + 构建产物管理 + release 发布管理 + session 绑定 release + +这样以后无论你配置项怎么继续长,主架构都还能撑住。 diff --git a/backend/go.mod b/backend/go.mod new file mode 100644 index 0000000..29029dc --- /dev/null +++ b/backend/go.mod @@ -0,0 +1,17 @@ +module cmr-backend + +go 1.25.1 + +require ( + github.com/golang-jwt/jwt/v5 v5.2.2 + github.com/jackc/pgx/v5 v5.7.6 +) + +require ( + github.com/jackc/pgpassfile v1.0.0 // indirect + github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 // indirect + github.com/jackc/puddle/v2 v2.2.2 // indirect + golang.org/x/crypto v0.37.0 // indirect + golang.org/x/sync v0.13.0 // indirect + golang.org/x/text v0.24.0 // indirect +) diff --git a/backend/go.sum b/backend/go.sum new file mode 100644 index 0000000..ad68e7a --- /dev/null +++ b/backend/go.sum @@ -0,0 +1,30 @@ +github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= +github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/golang-jwt/jwt/v5 v5.2.2 h1:Rl4B7itRWVtYIHFrSNd7vhTiz9UpLdi6gZhZ3wEeDy8= +github.com/golang-jwt/jwt/v5 v5.2.2/go.mod h1:pqrtFR0X4osieyHYxtmOUWsAWrfe1Q5UVIyoH402zdk= +github.com/jackc/pgpassfile v1.0.0 h1:/6Hmqy13Ss2zCq62VdNG8tM1wchn8zjSGOBJ6icpsIM= +github.com/jackc/pgpassfile v1.0.0/go.mod h1:CEx0iS5ambNFdcRtxPj5JhEz+xB6uRky5eyVu/W2HEg= +github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 h1:iCEnooe7UlwOQYpKFhBabPMi4aNAfoODPEFNiAnClxo= +github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761/go.mod h1:5TJZWKEWniPve33vlWYSoGYefn3gLQRzjfDlhSJ9ZKM= +github.com/jackc/pgx/v5 v5.7.6 h1:rWQc5FwZSPX58r1OQmkuaNicxdmExaEz5A2DO2hUuTk= +github.com/jackc/pgx/v5 v5.7.6/go.mod h1:aruU7o91Tc2q2cFp5h4uP3f6ztExVpyVv88Xl/8Vl8M= +github.com/jackc/puddle/v2 v2.2.2 h1:PR8nw+E/1w0GLuRFSmiioY6UooMp6KJv0/61nB7icHo= +github.com/jackc/puddle/v2 v2.2.2/go.mod h1:vriiEXHvEE654aYKXXjOvZM39qJ0q+azkZFrfEOc3H4= +github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= +github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= +github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= +github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI= +github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= +github.com/stretchr/testify v1.8.1 h1:w7B6lhMri9wdJUVmEZPGGhZzrYTPvgJArz7wNPgYKsk= +github.com/stretchr/testify v1.8.1/go.mod h1:w2LPCIKwWwSfY2zedu0+kehJoqGctiVI29o6fzry7u4= +golang.org/x/crypto v0.37.0 h1:kJNSjF/Xp7kU0iB2Z+9viTPMW4EqqsrywMXLJOOsXSE= +golang.org/x/crypto v0.37.0/go.mod h1:vg+k43peMZ0pUMhYmVAWysMK35e6ioLh3wB8ZCAfbVc= +golang.org/x/sync v0.13.0 h1:AauUjRAJ9OSnvULf/ARrrVywoJDy0YS2AwQ98I37610= +golang.org/x/sync v0.13.0/go.mod h1:1dzgHSNfp02xaA81J2MS99Qcpr2w7fw1gpm99rleRqA= +golang.org/x/text v0.24.0 h1:dd5Bzh4yt5KYA8f9CJHCP4FB4D51c2c6JvN37xJJkJ0= +golang.org/x/text v0.24.0/go.mod h1:L8rBsPeo2pSS+xqN0d5u2ikmjtmoJbDBT1b7nHvFCdU= +gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= +gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= +gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= +gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= diff --git a/backend/internal/app/app.go b/backend/internal/app/app.go new file mode 100644 index 0000000..b2adba4 --- /dev/null +++ b/backend/internal/app/app.go @@ -0,0 +1,64 @@ +package app + +import ( + "context" + "net/http" + + "cmr-backend/internal/httpapi" + "cmr-backend/internal/platform/jwtx" + "cmr-backend/internal/platform/wechatmini" + "cmr-backend/internal/service" + "cmr-backend/internal/store/postgres" +) + +type App struct { + router http.Handler + store *postgres.Store +} + +func New(ctx context.Context, cfg Config) (*App, error) { + pool, err := postgres.Open(ctx, cfg.DatabaseURL) + if err != nil { + return nil, err + } + + store := postgres.NewStore(pool) + jwtManager := jwtx.NewManager(cfg.JWTIssuer, cfg.JWTAccessSecret, cfg.JWTAccessTTL) + wechatMiniClient := wechatmini.NewClient(cfg.WechatMiniAppID, cfg.WechatMiniSecret, cfg.WechatMiniDevPrefix) + authService := service.NewAuthService(service.AuthSettings{ + AppEnv: cfg.AppEnv, + RefreshTTL: cfg.RefreshTTL, + SMSCodeTTL: cfg.SMSCodeTTL, + SMSCodeCooldown: cfg.SMSCodeCooldown, + SMSProvider: cfg.SMSProvider, + DevSMSCode: cfg.DevSMSCode, + WechatMini: wechatMiniClient, + }, store, jwtManager) + entryService := service.NewEntryService(store) + entryHomeService := service.NewEntryHomeService(store) + eventService := service.NewEventService(store) + eventPlayService := service.NewEventPlayService(store) + configService := service.NewConfigService(store, cfg.LocalEventDir, cfg.AssetBaseURL) + homeService := service.NewHomeService(store) + profileService := service.NewProfileService(store) + resultService := service.NewResultService(store) + sessionService := service.NewSessionService(store) + devService := service.NewDevService(cfg.AppEnv, store) + meService := service.NewMeService(store) + router := httpapi.NewRouter(cfg.AppEnv, jwtManager, authService, entryService, entryHomeService, eventService, eventPlayService, configService, homeService, profileService, resultService, sessionService, devService, meService) + + return &App{ + router: router, + store: store, + }, nil +} + +func (a *App) Router() http.Handler { + return a.router +} + +func (a *App) Close() { + if a.store != nil { + a.store.Close() + } +} diff --git a/backend/internal/app/config.go b/backend/internal/app/config.go new file mode 100644 index 0000000..0ec5ce7 --- /dev/null +++ b/backend/internal/app/config.go @@ -0,0 +1,73 @@ +package app + +import ( + "fmt" + "os" + "path/filepath" + "time" +) + +type Config struct { + AppEnv string + HTTPAddr string + DatabaseURL string + JWTIssuer string + JWTAccessSecret string + JWTAccessTTL time.Duration + RefreshTTL time.Duration + SMSCodeTTL time.Duration + SMSCodeCooldown time.Duration + SMSProvider string + DevSMSCode string + WechatMiniAppID string + WechatMiniSecret string + WechatMiniDevPrefix string + LocalEventDir string + AssetBaseURL string +} + +func LoadConfigFromEnv() (Config, error) { + cfg := Config{ + AppEnv: getEnv("APP_ENV", "development"), + HTTPAddr: getEnv("HTTP_ADDR", ":8080"), + DatabaseURL: os.Getenv("DATABASE_URL"), + JWTIssuer: getEnv("JWT_ISSUER", "cmr-backend"), + JWTAccessSecret: getEnv("JWT_ACCESS_SECRET", "change-me-in-production"), + JWTAccessTTL: getDurationEnv("JWT_ACCESS_TTL", 2*time.Hour), + RefreshTTL: getDurationEnv("AUTH_REFRESH_TTL", 30*24*time.Hour), + SMSCodeTTL: getDurationEnv("AUTH_SMS_CODE_TTL", 10*time.Minute), + SMSCodeCooldown: getDurationEnv("AUTH_SMS_COOLDOWN", 60*time.Second), + SMSProvider: getEnv("AUTH_SMS_PROVIDER", "console"), + DevSMSCode: os.Getenv("AUTH_DEV_SMS_CODE"), + WechatMiniAppID: getEnv("WECHAT_MINI_APP_ID", ""), + WechatMiniSecret: getEnv("WECHAT_MINI_APP_SECRET", ""), + WechatMiniDevPrefix: getEnv("WECHAT_MINI_DEV_PREFIX", "dev-"), + LocalEventDir: getEnv("LOCAL_EVENT_DIR", filepath.Clean("..\\event")), + AssetBaseURL: getEnv("ASSET_BASE_URL", "https://oss-mbh5.colormaprun.com/gotomars"), + } + + if cfg.DatabaseURL == "" { + return Config{}, fmt.Errorf("DATABASE_URL is required") + } + if cfg.JWTAccessSecret == "" { + return Config{}, fmt.Errorf("JWT_ACCESS_SECRET is required") + } + + return cfg, nil +} + +func getEnv(key, fallback string) string { + if value := os.Getenv(key); value != "" { + return value + } + return fallback +} + +func getDurationEnv(key string, fallback time.Duration) time.Duration { + if value := os.Getenv(key); value != "" { + if parsed, err := time.ParseDuration(value); err == nil { + return parsed + } + } + return fallback +} diff --git a/backend/internal/apperr/apperr.go b/backend/internal/apperr/apperr.go new file mode 100644 index 0000000..19c2c6c --- /dev/null +++ b/backend/internal/apperr/apperr.go @@ -0,0 +1,29 @@ +package apperr + +import "errors" + +type Error struct { + Status int `json:"-"` + Code string `json:"code"` + Message string `json:"message"` +} + +func (e *Error) Error() string { + return e.Message +} + +func New(status int, code, message string) *Error { + return &Error{ + Status: status, + Code: code, + Message: message, + } +} + +func From(err error) *Error { + var appErr *Error + if errors.As(err, &appErr) { + return appErr + } + return nil +} diff --git a/backend/internal/httpapi/handlers/auth_handler.go b/backend/internal/httpapi/handlers/auth_handler.go new file mode 100644 index 0000000..a23276f --- /dev/null +++ b/backend/internal/httpapi/handlers/auth_handler.go @@ -0,0 +1,129 @@ +package handlers + +import ( + "net/http" + + "cmr-backend/internal/apperr" + "cmr-backend/internal/httpapi/middleware" + "cmr-backend/internal/httpx" + "cmr-backend/internal/service" +) + +type AuthHandler struct { + authService *service.AuthService +} + +func NewAuthHandler(authService *service.AuthService) *AuthHandler { + return &AuthHandler{authService: authService} +} + +func (h *AuthHandler) SendSMSCode(w http.ResponseWriter, r *http.Request) { + var req service.SendSMSCodeInput + if err := httpx.DecodeJSON(r, &req); err != nil { + httpx.WriteError(w, apperr.New(http.StatusBadRequest, "invalid_json", "invalid request body")) + return + } + + result, err := h.authService.SendSMSCode(r.Context(), req) + if err != nil { + httpx.WriteError(w, err) + return + } + + httpx.WriteJSON(w, http.StatusOK, map[string]any{"data": result}) +} + +func (h *AuthHandler) LoginSMS(w http.ResponseWriter, r *http.Request) { + var req service.LoginSMSInput + if err := httpx.DecodeJSON(r, &req); err != nil { + httpx.WriteError(w, apperr.New(http.StatusBadRequest, "invalid_json", "invalid request body")) + return + } + + result, err := h.authService.LoginSMS(r.Context(), req) + if err != nil { + httpx.WriteError(w, err) + return + } + + httpx.WriteJSON(w, http.StatusOK, map[string]any{"data": result}) +} + +func (h *AuthHandler) LoginWechatMini(w http.ResponseWriter, r *http.Request) { + var req service.LoginWechatMiniInput + if err := httpx.DecodeJSON(r, &req); err != nil { + httpx.WriteError(w, apperr.New(http.StatusBadRequest, "invalid_json", "invalid request body")) + return + } + + result, err := h.authService.LoginWechatMini(r.Context(), req) + if err != nil { + httpx.WriteError(w, err) + return + } + + httpx.WriteJSON(w, http.StatusOK, map[string]any{"data": result}) +} + +func (h *AuthHandler) BindMobile(w http.ResponseWriter, r *http.Request) { + var req service.BindMobileInput + if err := httpx.DecodeJSON(r, &req); err != nil { + httpx.WriteError(w, apperr.New(http.StatusBadRequest, "invalid_json", "invalid request body")) + return + } + + auth := middleware.GetAuthContext(r.Context()) + if auth == nil { + httpx.WriteError(w, apperr.New(http.StatusUnauthorized, "unauthorized", "missing auth context")) + return + } + req.UserID = auth.UserID + + result, err := h.authService.BindMobile(r.Context(), req) + if err != nil { + httpx.WriteError(w, err) + return + } + + httpx.WriteJSON(w, http.StatusOK, map[string]any{"data": result}) +} + +func (h *AuthHandler) Refresh(w http.ResponseWriter, r *http.Request) { + var req service.RefreshTokenInput + if err := httpx.DecodeJSON(r, &req); err != nil { + httpx.WriteError(w, apperr.New(http.StatusBadRequest, "invalid_json", "invalid request body")) + return + } + + result, err := h.authService.Refresh(r.Context(), req) + if err != nil { + httpx.WriteError(w, err) + return + } + + httpx.WriteJSON(w, http.StatusOK, map[string]any{"data": result}) +} + +func (h *AuthHandler) Logout(w http.ResponseWriter, r *http.Request) { + var req service.LogoutInput + if err := httpx.DecodeJSON(r, &req); err != nil { + httpx.WriteError(w, apperr.New(http.StatusBadRequest, "invalid_json", "invalid request body")) + return + } + + auth := middleware.GetAuthContext(r.Context()) + if auth != nil && req.UserID == "" { + req.UserID = auth.UserID + } + + if err := h.authService.Logout(r.Context(), req); err != nil { + httpx.WriteError(w, err) + return + } + + httpx.WriteJSON(w, http.StatusOK, map[string]any{ + "data": map[string]any{ + "loggedOut": true, + }, + }) +} diff --git a/backend/internal/httpapi/handlers/config_handler.go b/backend/internal/httpapi/handlers/config_handler.go new file mode 100644 index 0000000..c6e9a9b --- /dev/null +++ b/backend/internal/httpapi/handlers/config_handler.go @@ -0,0 +1,107 @@ +package handlers + +import ( + "net/http" + "strconv" + + "cmr-backend/internal/apperr" + "cmr-backend/internal/httpx" + "cmr-backend/internal/service" +) + +type ConfigHandler struct { + configService *service.ConfigService +} + +func NewConfigHandler(configService *service.ConfigService) *ConfigHandler { + return &ConfigHandler{configService: configService} +} + +func (h *ConfigHandler) ListLocalFiles(w http.ResponseWriter, r *http.Request) { + result, err := h.configService.ListLocalEventFiles() + if err != nil { + httpx.WriteError(w, err) + return + } + httpx.WriteJSON(w, http.StatusOK, map[string]any{"data": result}) +} + +func (h *ConfigHandler) ImportLocal(w http.ResponseWriter, r *http.Request) { + var req service.ImportLocalEventConfigInput + if err := httpx.DecodeJSON(r, &req); err != nil { + httpx.WriteError(w, apperr.New(http.StatusBadRequest, "invalid_json", "invalid request body")) + return + } + req.EventPublicID = r.PathValue("eventPublicID") + + result, err := h.configService.ImportLocalEventConfig(r.Context(), req) + if err != nil { + httpx.WriteError(w, err) + return + } + httpx.WriteJSON(w, http.StatusOK, map[string]any{"data": result}) +} + +func (h *ConfigHandler) BuildPreview(w http.ResponseWriter, r *http.Request) { + var req service.BuildPreviewInput + if err := httpx.DecodeJSON(r, &req); err != nil { + httpx.WriteError(w, apperr.New(http.StatusBadRequest, "invalid_json", "invalid request body")) + return + } + + result, err := h.configService.BuildPreview(r.Context(), req) + if err != nil { + httpx.WriteError(w, err) + return + } + httpx.WriteJSON(w, http.StatusOK, map[string]any{"data": result}) +} + +func (h *ConfigHandler) PublishBuild(w http.ResponseWriter, r *http.Request) { + var req service.PublishBuildInput + if err := httpx.DecodeJSON(r, &req); err != nil { + httpx.WriteError(w, apperr.New(http.StatusBadRequest, "invalid_json", "invalid request body")) + return + } + + result, err := h.configService.PublishBuild(r.Context(), req) + if err != nil { + httpx.WriteError(w, err) + return + } + httpx.WriteJSON(w, http.StatusOK, map[string]any{"data": result}) +} + +func (h *ConfigHandler) ListSources(w http.ResponseWriter, r *http.Request) { + limit := 20 + if raw := r.URL.Query().Get("limit"); raw != "" { + if parsed, err := strconv.Atoi(raw); err == nil { + limit = parsed + } + } + + result, err := h.configService.ListEventConfigSources(r.Context(), r.PathValue("eventPublicID"), limit) + if err != nil { + httpx.WriteError(w, err) + return + } + httpx.WriteJSON(w, http.StatusOK, map[string]any{"data": result}) +} + +func (h *ConfigHandler) GetSource(w http.ResponseWriter, r *http.Request) { + result, err := h.configService.GetEventConfigSource(r.Context(), r.PathValue("sourceID")) + if err != nil { + httpx.WriteError(w, err) + return + } + httpx.WriteJSON(w, http.StatusOK, map[string]any{"data": result}) +} + +func (h *ConfigHandler) GetBuild(w http.ResponseWriter, r *http.Request) { + result, err := h.configService.GetEventConfigBuild(r.Context(), r.PathValue("buildID")) + if err != nil { + httpx.WriteError(w, err) + return + } + httpx.WriteJSON(w, http.StatusOK, map[string]any{"data": result}) +} diff --git a/backend/internal/httpapi/handlers/dev_handler.go b/backend/internal/httpapi/handlers/dev_handler.go new file mode 100644 index 0000000..0a9a52d --- /dev/null +++ b/backend/internal/httpapi/handlers/dev_handler.go @@ -0,0 +1,1588 @@ +package handlers + +import ( + "net/http" + + "cmr-backend/internal/httpx" + "cmr-backend/internal/service" +) + +type DevHandler struct { + devService *service.DevService +} + +func NewDevHandler(devService *service.DevService) *DevHandler { + return &DevHandler{devService: devService} +} + +func (h *DevHandler) BootstrapDemo(w http.ResponseWriter, r *http.Request) { + result, err := h.devService.BootstrapDemo(r.Context()) + if err != nil { + httpx.WriteError(w, err) + return + } + httpx.WriteJSON(w, http.StatusOK, map[string]any{"data": result}) +} + +func (h *DevHandler) Workbench(w http.ResponseWriter, r *http.Request) { + if !h.devService.Enabled() { + http.NotFound(w, r) + return + } + + w.Header().Set("Content-Type", "text/html; charset=utf-8") + _, _ = w.Write([]byte(devWorkbenchHTML)) +} + +const devWorkbenchHTML = ` + + + + + CMR Backend Workbench + + + +
+
+
Developer Workbench
+

CMR Backend API Flow Panel

+

把入口、登录、首页、活动详情、launch、session、profile 串成一条完整调试链。这个页面只在非 production 环境开放,适合后续继续扩展成你想要的 API 测试面板。

+
+ +
+
+

1. Bootstrap

+

初始化 demo tenant / channel / event / card。

+
+ +
+
+
默认入口 tenant_demo / mini-demo / evt_demo_001
+
+
+ +
+

2. Config Pipeline

+

从本地 event 目录导入 source config,生成 preview build,并可直接发布成当前 release。

+
+ + +
+
+ + +
+
+ + + + + + +
+
+ +
+

3. Session State

+

当前调试上下文,所有按钮共享这一组状态。

+
+
Access Token -
+
Refresh Token -
+
Source ID -
+
Build ID -
+
Release ID -
+
Session ID -
+
Session Token -
+
+
+ +
+
+ +
+

4. SMS Auth

+
+ + +
+
+ + +
+
+ + +
+
+ + + +
+
+ +
+

5. WeChat Mini

+

开发环境可直接使用 dev-xxx code。

+
+ + +
+
+ +
+
+ +
+

6. Entry / Home

+
+ + +
+
+ + + +
+
+ +
+

7. Event

+
+ + +
+
+ +
+
+
+ + + +
+
+ +
+

8. Session

+
+ + +
+
+ +
+
+
+ + +
+
+ + +
+
+ + +
+
+ +
+
+
+ + + + +
+
+ +
+

9. Results

+
+ + +
+
+ +
+

10. Profile

+
+ + +
+
+
+ +
+
+

11. Quick Flows

+

把常用接口串成一键工作流,减少重复点击。

+
+ + + + +
+
这些流程会复用当前表单里的手机号、设备、event、channel 等输入。
+
+ +
+

12. Request Export

+

最后一次请求会生成一条可复制的 curl,后面做问题复现会方便很多。

+
+ + +
+
+
Last Curl
+
+
+
+
+ +
+
+

13. Scenarios

+

保存当前表单状态为可复用场景,也支持导入导出 JSON,适合后续切换不同俱乐部、入口和 event。

+
+ + +
+
+ + + +
+
+
Scenario JSON
+ +
+ + +
+
+
+ +
+

14. Response Log

+

最后一次请求的结果会记录在这里,便于后续做请求回放和用例保存。

+
ready
+
+
+
+ +
+
+

15. Request History

+

最近 12 次请求会保留在浏览器本地,刷新页面不会丢。

+
+
+
+ +
+
+

16. API 列表

+

把当前已实现接口按分组放进 workbench,直接看中文说明、鉴权要求和关键参数,不用来回翻文档。

+
+ +
共 24 个接口,支持按关键词筛选。
+
+
+
+
GET/healthz
+
健康检查接口,用来确认服务是否存活。
+
鉴权:无需鉴权
+
+ +
+
POST/auth/sms/send
+
发送短信验证码,支持登录和绑定手机号两种场景。
+
+
鉴权:无需鉴权
+
关键参数:countryCodemobileclientTypedeviceKeyscene
+
+
+ +
+
POST/auth/login/sms
+
APP 主登录入口,使用手机号验证码登录并返回 access/refresh token。
+
+
鉴权:无需鉴权
+
关键参数:mobilecodeclientTypedeviceKey
+
+
+ +
+
POST/auth/login/wechat-mini
+
微信小程序登录入口。开发环境支持 dev- 前缀 code 直接模拟登录。
+
+
鉴权:无需鉴权
+
关键参数:codeclientType=wechatdeviceKey
+
+
+ +
+
POST/auth/bind/mobile
+
已登录用户绑定手机号,必要时把微信轻账号合并到手机号主账号。
+
+
鉴权:Bearer token
+
关键参数:mobilecodeclientTypedeviceKey
+
+
+ +
+
POST/auth/refresh
+
使用 refresh token 刷新 access token。
+
+
鉴权:无需 Bearer token
+
关键参数:refreshTokenclientTypedeviceKey
+
+
+ +
+
POST/auth/logout
+
登出并撤销 refresh token。
+
鉴权:可带 Bearer token
+
+ +
+
GET/entry/resolve
+
解析当前入口属于哪个 tenant / channel,是多俱乐部、多公众号接入的入口层基础接口。
+
+
鉴权:无需鉴权
+
查询参数:channelCodechannelTypeplatformAppIdtenantCode
+
+
+ +
+
GET/home
+
返回入口首页卡片数据。
+
鉴权:无需鉴权
+
+ +
+
GET/cards
+
只返回卡片列表,适合调试卡片数据本身。
+
鉴权:无需鉴权
+
+ +
+
GET/me/entry-home
+
首页聚合接口,返回用户、tenant、channel、cards、进行中 session 和最近一局。
+
鉴权:Bearer token
+
+ +
+
GET/events/{eventPublicID}
+
活动详情接口,会带当前发布的 release 和 resolvedRelease。
+
鉴权:无需鉴权
+
+ +
+
GET/events/{eventPublicID}/play
+
活动详情页 / 开始前准备页聚合接口,判断是否可启动、继续还是查看上次结果。
+
鉴权:Bearer token
+
+ +
+
POST/events/{eventPublicID}/launch
+
基于当前 event 的已发布 release 创建一局 session,并返回 config URL、releaseId、sessionToken。
+
+
鉴权:Bearer token
+
关键参数:releaseIdclientTypedeviceKey
+
+
+ +
+
GET/events/{eventPublicID}/config-sources
+
查看某个 event 下已经导入过的 source config 列表。
+
鉴权:Bearer token
+
+ +
+
GET/config-sources/{sourceID}
+
查看单条 source config 明细。
+
鉴权:Bearer token
+
+ +
+
GET/config-builds/{buildID}
+
查看单次 build 的 manifest 和 asset index。
+
鉴权:Bearer token
+
+ +
+
GET/sessions/{sessionPublicID}
+
查询一局详情,带 session 状态、event 和 resolvedRelease。
+
鉴权:Bearer token
+
+ +
+
POST/sessions/{sessionPublicID}/start
+
把 session 从 launched 推进到 running
+
+
鉴权:sessionToken
+
关键参数:sessionToken
+
+
+ +
+
POST/sessions/{sessionPublicID}/finish
+
结束一局并沉淀结果摘要,是结果页数据的来源。
+
+
鉴权:sessionToken
+
关键参数:sessionTokenstatussummary.*
+
+
+ +
+
GET/me/sessions
+
查询用户最近 session 列表。
+
鉴权:Bearer token
+
+ +
+
GET/sessions/{sessionPublicID}/result
+
单局结果页接口,返回 session 和 result。
+
鉴权:Bearer token
+
+ +
+
GET/me/results
+
查询用户最近结果列表。
+
鉴权:Bearer token
+
+ +
+
GET/me
+
返回当前用户基础信息。
+
鉴权:Bearer token
+
+ +
+
GET/me/profile
+
“我的页”聚合接口,返回绑定概览、绑定项列表和最近记录摘要。
+
鉴权:Bearer token
+
+ +
+
POST/dev/bootstrap-demo
+
开发态自举 demo 数据,会准备 tenant、channel、event、release、card、source、build。
+
鉴权:仅 non-production,无需鉴权
+
+ +
+
GET/dev/config/local-files
+
列出本地配置目录中的 JSON 文件,作为 source config 导入入口。
+
鉴权:仅 non-production,无需鉴权
+
+ +
+
POST/dev/events/{eventPublicID}/config-sources/import-local
+
从本地 event 目录导入 source config。
+
+
鉴权:仅 non-production,无需鉴权
+
关键参数:fileNamenotes
+
+
+ +
+
POST/dev/config-builds/preview
+
基于 source config 生成 preview build,并产出 preview manifest。
+
+
鉴权:仅 non-production,无需鉴权
+
关键参数:sourceId
+
+
+ +
+
POST/dev/config-builds/publish
+
把成功的 build 发布成正式 release,并自动切换成当前 event 的可启动版本。
+
+
鉴权:仅 non-production,无需鉴权
+
关键参数:buildId
+
+
+
+
+
+
+ + + +` diff --git a/backend/internal/httpapi/handlers/entry_handler.go b/backend/internal/httpapi/handlers/entry_handler.go new file mode 100644 index 0000000..eac2fc3 --- /dev/null +++ b/backend/internal/httpapi/handlers/entry_handler.go @@ -0,0 +1,31 @@ +package handlers + +import ( + "net/http" + + "cmr-backend/internal/httpx" + "cmr-backend/internal/service" +) + +type EntryHandler struct { + entryService *service.EntryService +} + +func NewEntryHandler(entryService *service.EntryService) *EntryHandler { + return &EntryHandler{entryService: entryService} +} + +func (h *EntryHandler) Resolve(w http.ResponseWriter, r *http.Request) { + result, err := h.entryService.Resolve(r.Context(), service.ResolveEntryInput{ + ChannelCode: r.URL.Query().Get("channelCode"), + ChannelType: r.URL.Query().Get("channelType"), + PlatformAppID: r.URL.Query().Get("platformAppId"), + TenantCode: r.URL.Query().Get("tenantCode"), + }) + if err != nil { + httpx.WriteError(w, err) + return + } + + httpx.WriteJSON(w, http.StatusOK, map[string]any{"data": result}) +} diff --git a/backend/internal/httpapi/handlers/entry_home_handler.go b/backend/internal/httpapi/handlers/entry_home_handler.go new file mode 100644 index 0000000..66a1fab --- /dev/null +++ b/backend/internal/httpapi/handlers/entry_home_handler.go @@ -0,0 +1,40 @@ +package handlers + +import ( + "net/http" + + "cmr-backend/internal/apperr" + "cmr-backend/internal/httpapi/middleware" + "cmr-backend/internal/httpx" + "cmr-backend/internal/service" +) + +type EntryHomeHandler struct { + entryHomeService *service.EntryHomeService +} + +func NewEntryHomeHandler(entryHomeService *service.EntryHomeService) *EntryHomeHandler { + return &EntryHomeHandler{entryHomeService: entryHomeService} +} + +func (h *EntryHomeHandler) Get(w http.ResponseWriter, r *http.Request) { + auth := middleware.GetAuthContext(r.Context()) + if auth == nil { + httpx.WriteError(w, apperr.New(http.StatusUnauthorized, "unauthorized", "missing auth context")) + return + } + + result, err := h.entryHomeService.GetEntryHome(r.Context(), service.EntryHomeInput{ + UserID: auth.UserID, + ChannelCode: r.URL.Query().Get("channelCode"), + ChannelType: r.URL.Query().Get("channelType"), + PlatformAppID: r.URL.Query().Get("platformAppId"), + TenantCode: r.URL.Query().Get("tenantCode"), + }) + if err != nil { + httpx.WriteError(w, err) + return + } + + httpx.WriteJSON(w, http.StatusOK, map[string]any{"data": result}) +} diff --git a/backend/internal/httpapi/handlers/event_handler.go b/backend/internal/httpapi/handlers/event_handler.go new file mode 100644 index 0000000..d7eac5e --- /dev/null +++ b/backend/internal/httpapi/handlers/event_handler.go @@ -0,0 +1,51 @@ +package handlers + +import ( + "net/http" + + "cmr-backend/internal/apperr" + "cmr-backend/internal/httpapi/middleware" + "cmr-backend/internal/httpx" + "cmr-backend/internal/service" +) + +type EventHandler struct { + eventService *service.EventService +} + +func NewEventHandler(eventService *service.EventService) *EventHandler { + return &EventHandler{eventService: eventService} +} + +func (h *EventHandler) GetDetail(w http.ResponseWriter, r *http.Request) { + eventPublicID := r.PathValue("eventPublicID") + result, err := h.eventService.GetEventDetail(r.Context(), eventPublicID) + if err != nil { + httpx.WriteError(w, err) + return + } + httpx.WriteJSON(w, http.StatusOK, map[string]any{"data": result}) +} + +func (h *EventHandler) Launch(w http.ResponseWriter, r *http.Request) { + auth := middleware.GetAuthContext(r.Context()) + if auth == nil { + httpx.WriteError(w, apperr.New(http.StatusUnauthorized, "unauthorized", "missing auth context")) + return + } + + var req service.LaunchEventInput + if err := httpx.DecodeJSON(r, &req); err != nil { + httpx.WriteError(w, apperr.New(http.StatusBadRequest, "invalid_json", "invalid request body")) + return + } + req.EventPublicID = r.PathValue("eventPublicID") + req.UserID = auth.UserID + + result, err := h.eventService.LaunchEvent(r.Context(), req) + if err != nil { + httpx.WriteError(w, err) + return + } + httpx.WriteJSON(w, http.StatusOK, map[string]any{"data": result}) +} diff --git a/backend/internal/httpapi/handlers/event_play_handler.go b/backend/internal/httpapi/handlers/event_play_handler.go new file mode 100644 index 0000000..dbc7a79 --- /dev/null +++ b/backend/internal/httpapi/handlers/event_play_handler.go @@ -0,0 +1,37 @@ +package handlers + +import ( + "net/http" + + "cmr-backend/internal/apperr" + "cmr-backend/internal/httpapi/middleware" + "cmr-backend/internal/httpx" + "cmr-backend/internal/service" +) + +type EventPlayHandler struct { + eventPlayService *service.EventPlayService +} + +func NewEventPlayHandler(eventPlayService *service.EventPlayService) *EventPlayHandler { + return &EventPlayHandler{eventPlayService: eventPlayService} +} + +func (h *EventPlayHandler) Get(w http.ResponseWriter, r *http.Request) { + auth := middleware.GetAuthContext(r.Context()) + if auth == nil { + httpx.WriteError(w, apperr.New(http.StatusUnauthorized, "unauthorized", "missing auth context")) + return + } + + result, err := h.eventPlayService.GetEventPlay(r.Context(), service.EventPlayInput{ + EventPublicID: r.PathValue("eventPublicID"), + UserID: auth.UserID, + }) + if err != nil { + httpx.WriteError(w, err) + return + } + + httpx.WriteJSON(w, http.StatusOK, map[string]any{"data": result}) +} diff --git a/backend/internal/httpapi/handlers/health_handler.go b/backend/internal/httpapi/handlers/health_handler.go new file mode 100644 index 0000000..5056d49 --- /dev/null +++ b/backend/internal/httpapi/handlers/health_handler.go @@ -0,0 +1,21 @@ +package handlers + +import ( + "net/http" + + "cmr-backend/internal/httpx" +) + +type HealthHandler struct{} + +func NewHealthHandler() *HealthHandler { + return &HealthHandler{} +} + +func (h *HealthHandler) Get(w http.ResponseWriter, r *http.Request) { + httpx.WriteJSON(w, http.StatusOK, map[string]any{ + "data": map[string]any{ + "status": "ok", + }, + }) +} diff --git a/backend/internal/httpapi/handlers/home_handler.go b/backend/internal/httpapi/handlers/home_handler.go new file mode 100644 index 0000000..f980b8f --- /dev/null +++ b/backend/internal/httpapi/handlers/home_handler.go @@ -0,0 +1,53 @@ +package handlers + +import ( + "net/http" + "strconv" + + "cmr-backend/internal/httpx" + "cmr-backend/internal/service" +) + +type HomeHandler struct { + homeService *service.HomeService +} + +func NewHomeHandler(homeService *service.HomeService) *HomeHandler { + return &HomeHandler{homeService: homeService} +} + +func (h *HomeHandler) GetHome(w http.ResponseWriter, r *http.Request) { + result, err := h.homeService.GetHome(r.Context(), buildListCardsInput(r)) + if err != nil { + httpx.WriteError(w, err) + return + } + httpx.WriteJSON(w, http.StatusOK, map[string]any{"data": result}) +} + +func (h *HomeHandler) GetCards(w http.ResponseWriter, r *http.Request) { + result, err := h.homeService.ListCards(r.Context(), buildListCardsInput(r)) + if err != nil { + httpx.WriteError(w, err) + return + } + httpx.WriteJSON(w, http.StatusOK, map[string]any{"data": result}) +} + +func buildListCardsInput(r *http.Request) service.ListCardsInput { + limit := 20 + if raw := r.URL.Query().Get("limit"); raw != "" { + if parsed, err := strconv.Atoi(raw); err == nil { + limit = parsed + } + } + + return service.ListCardsInput{ + ChannelCode: r.URL.Query().Get("channelCode"), + ChannelType: r.URL.Query().Get("channelType"), + PlatformAppID: r.URL.Query().Get("platformAppId"), + TenantCode: r.URL.Query().Get("tenantCode"), + Slot: r.URL.Query().Get("slot"), + Limit: limit, + } +} diff --git a/backend/internal/httpapi/handlers/me_handler.go b/backend/internal/httpapi/handlers/me_handler.go new file mode 100644 index 0000000..755ba92 --- /dev/null +++ b/backend/internal/httpapi/handlers/me_handler.go @@ -0,0 +1,34 @@ +package handlers + +import ( + "net/http" + + "cmr-backend/internal/apperr" + "cmr-backend/internal/httpapi/middleware" + "cmr-backend/internal/httpx" + "cmr-backend/internal/service" +) + +type MeHandler struct { + meService *service.MeService +} + +func NewMeHandler(meService *service.MeService) *MeHandler { + return &MeHandler{meService: meService} +} + +func (h *MeHandler) Get(w http.ResponseWriter, r *http.Request) { + auth := middleware.GetAuthContext(r.Context()) + if auth == nil { + httpx.WriteError(w, apperr.New(http.StatusUnauthorized, "unauthorized", "missing auth context")) + return + } + + user, err := h.meService.GetMe(r.Context(), auth.UserID) + if err != nil { + httpx.WriteError(w, err) + return + } + + httpx.WriteJSON(w, http.StatusOK, map[string]any{"data": user}) +} diff --git a/backend/internal/httpapi/handlers/profile_handler.go b/backend/internal/httpapi/handlers/profile_handler.go new file mode 100644 index 0000000..8e9541f --- /dev/null +++ b/backend/internal/httpapi/handlers/profile_handler.go @@ -0,0 +1,34 @@ +package handlers + +import ( + "net/http" + + "cmr-backend/internal/apperr" + "cmr-backend/internal/httpapi/middleware" + "cmr-backend/internal/httpx" + "cmr-backend/internal/service" +) + +type ProfileHandler struct { + profileService *service.ProfileService +} + +func NewProfileHandler(profileService *service.ProfileService) *ProfileHandler { + return &ProfileHandler{profileService: profileService} +} + +func (h *ProfileHandler) Get(w http.ResponseWriter, r *http.Request) { + auth := middleware.GetAuthContext(r.Context()) + if auth == nil { + httpx.WriteError(w, apperr.New(http.StatusUnauthorized, "unauthorized", "missing auth context")) + return + } + + result, err := h.profileService.GetProfile(r.Context(), auth.UserID) + if err != nil { + httpx.WriteError(w, err) + return + } + + httpx.WriteJSON(w, http.StatusOK, map[string]any{"data": result}) +} diff --git a/backend/internal/httpapi/handlers/result_handler.go b/backend/internal/httpapi/handlers/result_handler.go new file mode 100644 index 0000000..44f5805 --- /dev/null +++ b/backend/internal/httpapi/handlers/result_handler.go @@ -0,0 +1,58 @@ +package handlers + +import ( + "net/http" + "strconv" + + "cmr-backend/internal/apperr" + "cmr-backend/internal/httpapi/middleware" + "cmr-backend/internal/httpx" + "cmr-backend/internal/service" +) + +type ResultHandler struct { + resultService *service.ResultService +} + +func NewResultHandler(resultService *service.ResultService) *ResultHandler { + return &ResultHandler{resultService: resultService} +} + +func (h *ResultHandler) GetSessionResult(w http.ResponseWriter, r *http.Request) { + auth := middleware.GetAuthContext(r.Context()) + if auth == nil { + httpx.WriteError(w, apperr.New(http.StatusUnauthorized, "unauthorized", "missing auth context")) + return + } + + result, err := h.resultService.GetSessionResult(r.Context(), r.PathValue("sessionPublicID"), auth.UserID) + if err != nil { + httpx.WriteError(w, err) + return + } + + httpx.WriteJSON(w, http.StatusOK, map[string]any{"data": result}) +} + +func (h *ResultHandler) ListMine(w http.ResponseWriter, r *http.Request) { + auth := middleware.GetAuthContext(r.Context()) + if auth == nil { + httpx.WriteError(w, apperr.New(http.StatusUnauthorized, "unauthorized", "missing auth context")) + return + } + + limit := 20 + if raw := r.URL.Query().Get("limit"); raw != "" { + if parsed, err := strconv.Atoi(raw); err == nil { + limit = parsed + } + } + + result, err := h.resultService.ListMyResults(r.Context(), auth.UserID, limit) + if err != nil { + httpx.WriteError(w, err) + return + } + + httpx.WriteJSON(w, http.StatusOK, map[string]any{"data": result}) +} diff --git a/backend/internal/httpapi/handlers/session_handler.go b/backend/internal/httpapi/handlers/session_handler.go new file mode 100644 index 0000000..f6f5f30 --- /dev/null +++ b/backend/internal/httpapi/handlers/session_handler.go @@ -0,0 +1,88 @@ +package handlers + +import ( + "net/http" + "strconv" + + "cmr-backend/internal/apperr" + "cmr-backend/internal/httpapi/middleware" + "cmr-backend/internal/httpx" + "cmr-backend/internal/service" +) + +type SessionHandler struct { + sessionService *service.SessionService +} + +func NewSessionHandler(sessionService *service.SessionService) *SessionHandler { + return &SessionHandler{sessionService: sessionService} +} + +func (h *SessionHandler) GetDetail(w http.ResponseWriter, r *http.Request) { + auth := middleware.GetAuthContext(r.Context()) + if auth == nil { + httpx.WriteError(w, apperr.New(http.StatusUnauthorized, "unauthorized", "missing auth context")) + return + } + + result, err := h.sessionService.GetSession(r.Context(), r.PathValue("sessionPublicID"), auth.UserID) + if err != nil { + httpx.WriteError(w, err) + return + } + httpx.WriteJSON(w, http.StatusOK, map[string]any{"data": result}) +} + +func (h *SessionHandler) ListMine(w http.ResponseWriter, r *http.Request) { + auth := middleware.GetAuthContext(r.Context()) + if auth == nil { + httpx.WriteError(w, apperr.New(http.StatusUnauthorized, "unauthorized", "missing auth context")) + return + } + + limit := 20 + if raw := r.URL.Query().Get("limit"); raw != "" { + if parsed, err := strconv.Atoi(raw); err == nil { + limit = parsed + } + } + + result, err := h.sessionService.ListMySessions(r.Context(), auth.UserID, limit) + if err != nil { + httpx.WriteError(w, err) + return + } + httpx.WriteJSON(w, http.StatusOK, map[string]any{"data": result}) +} + +func (h *SessionHandler) Start(w http.ResponseWriter, r *http.Request) { + var req service.SessionActionInput + if err := httpx.DecodeJSON(r, &req); err != nil { + httpx.WriteError(w, apperr.New(http.StatusBadRequest, "invalid_json", "invalid request body: "+err.Error())) + return + } + req.SessionPublicID = r.PathValue("sessionPublicID") + + result, err := h.sessionService.StartSession(r.Context(), req) + if err != nil { + httpx.WriteError(w, err) + return + } + httpx.WriteJSON(w, http.StatusOK, map[string]any{"data": result}) +} + +func (h *SessionHandler) Finish(w http.ResponseWriter, r *http.Request) { + var req service.FinishSessionInput + if err := httpx.DecodeJSON(r, &req); err != nil { + httpx.WriteError(w, apperr.New(http.StatusBadRequest, "invalid_json", "invalid request body: "+err.Error())) + return + } + req.SessionPublicID = r.PathValue("sessionPublicID") + + result, err := h.sessionService.FinishSession(r.Context(), req) + if err != nil { + httpx.WriteError(w, err) + return + } + httpx.WriteJSON(w, http.StatusOK, map[string]any{"data": result}) +} diff --git a/backend/internal/httpapi/middleware/auth.go b/backend/internal/httpapi/middleware/auth.go new file mode 100644 index 0000000..279d1af --- /dev/null +++ b/backend/internal/httpapi/middleware/auth.go @@ -0,0 +1,50 @@ +package middleware + +import ( + "context" + "net/http" + "strings" + + "cmr-backend/internal/apperr" + "cmr-backend/internal/httpx" + "cmr-backend/internal/platform/jwtx" +) + +type authContextKey string + +const authKey authContextKey = "auth" + +type AuthContext struct { + UserID string + UserPublicID string +} + +func NewAuthMiddleware(jwtManager *jwtx.Manager) func(http.Handler) http.Handler { + return func(next http.Handler) http.Handler { + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + authHeader := strings.TrimSpace(r.Header.Get("Authorization")) + if authHeader == "" || !strings.HasPrefix(authHeader, "Bearer ") { + httpx.WriteError(w, apperr.New(http.StatusUnauthorized, "unauthorized", "missing bearer token")) + return + } + + token := strings.TrimSpace(strings.TrimPrefix(authHeader, "Bearer ")) + claims, err := jwtManager.ParseAccessToken(token) + if err != nil { + httpx.WriteError(w, apperr.New(http.StatusUnauthorized, "invalid_token", "invalid access token")) + return + } + + ctx := context.WithValue(r.Context(), authKey, &AuthContext{ + UserID: claims.UserID, + UserPublicID: claims.UserPublicID, + }) + next.ServeHTTP(w, r.WithContext(ctx)) + }) + } +} + +func GetAuthContext(ctx context.Context) *AuthContext { + auth, _ := ctx.Value(authKey).(*AuthContext) + return auth +} diff --git a/backend/internal/httpapi/router.go b/backend/internal/httpapi/router.go new file mode 100644 index 0000000..e6bb18f --- /dev/null +++ b/backend/internal/httpapi/router.go @@ -0,0 +1,80 @@ +package httpapi + +import ( + "net/http" + + "cmr-backend/internal/httpapi/handlers" + "cmr-backend/internal/httpapi/middleware" + "cmr-backend/internal/platform/jwtx" + "cmr-backend/internal/service" +) + +func NewRouter( + appEnv string, + jwtManager *jwtx.Manager, + authService *service.AuthService, + entryService *service.EntryService, + entryHomeService *service.EntryHomeService, + eventService *service.EventService, + eventPlayService *service.EventPlayService, + configService *service.ConfigService, + homeService *service.HomeService, + profileService *service.ProfileService, + resultService *service.ResultService, + sessionService *service.SessionService, + devService *service.DevService, + meService *service.MeService, +) http.Handler { + mux := http.NewServeMux() + + healthHandler := handlers.NewHealthHandler() + authHandler := handlers.NewAuthHandler(authService) + entryHandler := handlers.NewEntryHandler(entryService) + entryHomeHandler := handlers.NewEntryHomeHandler(entryHomeService) + eventHandler := handlers.NewEventHandler(eventService) + eventPlayHandler := handlers.NewEventPlayHandler(eventPlayService) + configHandler := handlers.NewConfigHandler(configService) + homeHandler := handlers.NewHomeHandler(homeService) + profileHandler := handlers.NewProfileHandler(profileService) + resultHandler := handlers.NewResultHandler(resultService) + sessionHandler := handlers.NewSessionHandler(sessionService) + devHandler := handlers.NewDevHandler(devService) + meHandler := handlers.NewMeHandler(meService) + authMiddleware := middleware.NewAuthMiddleware(jwtManager) + + mux.HandleFunc("GET /healthz", healthHandler.Get) + mux.HandleFunc("GET /home", homeHandler.GetHome) + mux.HandleFunc("GET /cards", homeHandler.GetCards) + mux.HandleFunc("GET /entry/resolve", entryHandler.Resolve) + if appEnv != "production" { + mux.HandleFunc("GET /dev/workbench", devHandler.Workbench) + mux.HandleFunc("POST /dev/bootstrap-demo", devHandler.BootstrapDemo) + mux.HandleFunc("GET /dev/config/local-files", configHandler.ListLocalFiles) + mux.HandleFunc("POST /dev/events/{eventPublicID}/config-sources/import-local", configHandler.ImportLocal) + mux.HandleFunc("POST /dev/config-builds/preview", configHandler.BuildPreview) + mux.HandleFunc("POST /dev/config-builds/publish", configHandler.PublishBuild) + } + mux.Handle("GET /me/entry-home", authMiddleware(http.HandlerFunc(entryHomeHandler.Get))) + mux.Handle("GET /me/profile", authMiddleware(http.HandlerFunc(profileHandler.Get))) + mux.HandleFunc("GET /events/{eventPublicID}", eventHandler.GetDetail) + mux.Handle("GET /events/{eventPublicID}/play", authMiddleware(http.HandlerFunc(eventPlayHandler.Get))) + mux.Handle("GET /events/{eventPublicID}/config-sources", authMiddleware(http.HandlerFunc(configHandler.ListSources))) + mux.Handle("POST /events/{eventPublicID}/launch", authMiddleware(http.HandlerFunc(eventHandler.Launch))) + mux.Handle("GET /config-sources/{sourceID}", authMiddleware(http.HandlerFunc(configHandler.GetSource))) + mux.Handle("GET /config-builds/{buildID}", authMiddleware(http.HandlerFunc(configHandler.GetBuild))) + mux.Handle("GET /sessions/{sessionPublicID}", authMiddleware(http.HandlerFunc(sessionHandler.GetDetail))) + mux.Handle("GET /sessions/{sessionPublicID}/result", authMiddleware(http.HandlerFunc(resultHandler.GetSessionResult))) + mux.HandleFunc("POST /sessions/{sessionPublicID}/start", sessionHandler.Start) + mux.HandleFunc("POST /sessions/{sessionPublicID}/finish", sessionHandler.Finish) + mux.HandleFunc("POST /auth/sms/send", authHandler.SendSMSCode) + mux.HandleFunc("POST /auth/login/sms", authHandler.LoginSMS) + mux.HandleFunc("POST /auth/login/wechat-mini", authHandler.LoginWechatMini) + mux.Handle("POST /auth/bind/mobile", authMiddleware(http.HandlerFunc(authHandler.BindMobile))) + mux.HandleFunc("POST /auth/refresh", authHandler.Refresh) + mux.HandleFunc("POST /auth/logout", authHandler.Logout) + mux.Handle("GET /me", authMiddleware(http.HandlerFunc(meHandler.Get))) + mux.Handle("GET /me/sessions", authMiddleware(http.HandlerFunc(sessionHandler.ListMine))) + mux.Handle("GET /me/results", authMiddleware(http.HandlerFunc(resultHandler.ListMine))) + + return mux +} diff --git a/backend/internal/httpx/httpx.go b/backend/internal/httpx/httpx.go new file mode 100644 index 0000000..8376a72 --- /dev/null +++ b/backend/internal/httpx/httpx.go @@ -0,0 +1,39 @@ +package httpx + +import ( + "encoding/json" + "net/http" + + "cmr-backend/internal/apperr" +) + +func WriteJSON(w http.ResponseWriter, status int, payload any) { + w.Header().Set("Content-Type", "application/json; charset=utf-8") + w.WriteHeader(status) + _ = json.NewEncoder(w).Encode(payload) +} + +func WriteError(w http.ResponseWriter, err error) { + if appErr := apperr.From(err); appErr != nil { + WriteJSON(w, appErr.Status, map[string]any{ + "error": map[string]any{ + "code": appErr.Code, + "message": appErr.Message, + }, + }) + return + } + + WriteJSON(w, http.StatusInternalServerError, map[string]any{ + "error": map[string]any{ + "code": "internal_error", + "message": "internal server error", + }, + }) +} + +func DecodeJSON(r *http.Request, dst any) error { + decoder := json.NewDecoder(r.Body) + decoder.DisallowUnknownFields() + return decoder.Decode(dst) +} diff --git a/backend/internal/platform/jwtx/jwt.go b/backend/internal/platform/jwtx/jwt.go new file mode 100644 index 0000000..857a072 --- /dev/null +++ b/backend/internal/platform/jwtx/jwt.go @@ -0,0 +1,67 @@ +package jwtx + +import ( + "fmt" + "time" + + "github.com/golang-jwt/jwt/v5" +) + +type Manager struct { + issuer string + secret []byte + ttl time.Duration +} + +type AccessClaims struct { + UserID string `json:"uid"` + UserPublicID string `json:"upub"` + jwt.RegisteredClaims +} + +func NewManager(issuer, secret string, ttl time.Duration) *Manager { + return &Manager{ + issuer: issuer, + secret: []byte(secret), + ttl: ttl, + } +} + +func (m *Manager) IssueAccessToken(userID, userPublicID string) (string, time.Time, error) { + expiresAt := time.Now().UTC().Add(m.ttl) + claims := AccessClaims{ + UserID: userID, + UserPublicID: userPublicID, + RegisteredClaims: jwt.RegisteredClaims{ + Issuer: m.issuer, + Subject: userID, + ExpiresAt: jwt.NewNumericDate(expiresAt), + IssuedAt: jwt.NewNumericDate(time.Now().UTC()), + }, + } + + token := jwt.NewWithClaims(jwt.SigningMethodHS256, claims) + signed, err := token.SignedString(m.secret) + if err != nil { + return "", time.Time{}, err + } + return signed, expiresAt, nil +} + +func (m *Manager) ParseAccessToken(tokenString string) (*AccessClaims, error) { + token, err := jwt.ParseWithClaims(tokenString, &AccessClaims{}, func(token *jwt.Token) (any, error) { + if token.Method != jwt.SigningMethodHS256 { + return nil, fmt.Errorf("unexpected signing method") + } + return m.secret, nil + }) + if err != nil { + return nil, err + } + + claims, ok := token.Claims.(*AccessClaims) + if !ok || !token.Valid { + return nil, fmt.Errorf("invalid token claims") + } + return claims, nil +} diff --git a/backend/internal/platform/security/token.go b/backend/internal/platform/security/token.go new file mode 100644 index 0000000..571552c --- /dev/null +++ b/backend/internal/platform/security/token.go @@ -0,0 +1,47 @@ +package security + +import ( + "crypto/rand" + "crypto/sha256" + "encoding/base64" + "encoding/hex" +) + +func GenerateToken(byteLength int) (string, error) { + raw := make([]byte, byteLength) + if _, err := rand.Read(raw); err != nil { + return "", err + } + return base64.RawURLEncoding.EncodeToString(raw), nil +} + +func GenerateNumericCode(length int) (string, error) { + if length <= 0 { + length = 6 + } + + const digits = "0123456789" + raw := make([]byte, length) + if _, err := rand.Read(raw); err != nil { + return "", err + } + + code := make([]byte, length) + for i := range raw { + code[i] = digits[int(raw[i])%len(digits)] + } + return string(code), nil +} + +func HashText(value string) string { + sum := sha256.Sum256([]byte(value)) + return hex.EncodeToString(sum[:]) +} + +func GeneratePublicID(prefix string) (string, error) { + raw := make([]byte, 8) + if _, err := rand.Read(raw); err != nil { + return "", err + } + return prefix + "_" + hex.EncodeToString(raw), nil +} diff --git a/backend/internal/platform/wechatmini/client.go b/backend/internal/platform/wechatmini/client.go new file mode 100644 index 0000000..ee9a124 --- /dev/null +++ b/backend/internal/platform/wechatmini/client.go @@ -0,0 +1,120 @@ +package wechatmini + +import ( + "context" + "crypto/sha1" + "encoding/hex" + "encoding/json" + "fmt" + "io" + "net/http" + "net/url" + "strings" + "time" +) + +type Client struct { + appID string + appSecret string + devPrefix string + httpClient *http.Client +} + +type Session struct { + AppID string + OpenID string + UnionID string + SessionKey string +} + +type code2SessionResponse struct { + OpenID string `json:"openid"` + SessionKey string `json:"session_key"` + UnionID string `json:"unionid"` + ErrCode int `json:"errcode"` + ErrMsg string `json:"errmsg"` +} + +func NewClient(appID, appSecret, devPrefix string) *Client { + return &Client{ + appID: appID, + appSecret: appSecret, + devPrefix: devPrefix, + httpClient: &http.Client{Timeout: 8 * time.Second}, + } +} + +func (c *Client) ExchangeCode(ctx context.Context, code string) (*Session, error) { + code = strings.TrimSpace(code) + if code == "" { + return nil, fmt.Errorf("wechat code is required") + } + + if c.devPrefix != "" && strings.HasPrefix(code, c.devPrefix) { + suffix := strings.TrimPrefix(code, c.devPrefix) + if suffix == "" { + suffix = "default" + } + return &Session{ + AppID: fallbackString(c.appID, "dev-mini-app"), + OpenID: "dev_openid_" + normalizeDevID(suffix), + UnionID: "", + }, nil + } + + if c.appID == "" || c.appSecret == "" { + return nil, fmt.Errorf("wechat mini app credentials are not configured") + } + + values := url.Values{} + values.Set("appid", c.appID) + values.Set("secret", c.appSecret) + values.Set("js_code", code) + values.Set("grant_type", "authorization_code") + + req, err := http.NewRequestWithContext(ctx, http.MethodGet, "https://api.weixin.qq.com/sns/jscode2session?"+values.Encode(), nil) + if err != nil { + return nil, err + } + + resp, err := c.httpClient.Do(req) + if err != nil { + return nil, err + } + defer resp.Body.Close() + + body, err := io.ReadAll(resp.Body) + if err != nil { + return nil, err + } + + var parsed code2SessionResponse + if err := json.Unmarshal(body, &parsed); err != nil { + return nil, err + } + if parsed.ErrCode != 0 { + return nil, fmt.Errorf("wechat code2session failed: %d %s", parsed.ErrCode, parsed.ErrMsg) + } + if parsed.OpenID == "" { + return nil, fmt.Errorf("wechat code2session returned empty openid") + } + + return &Session{ + AppID: c.appID, + OpenID: parsed.OpenID, + UnionID: parsed.UnionID, + SessionKey: parsed.SessionKey, + }, nil +} + +func normalizeDevID(value string) string { + sum := sha1.Sum([]byte(value)) + return hex.EncodeToString(sum[:])[:16] +} + +func fallbackString(value, fallback string) string { + if value != "" { + return value + } + return fallback +} diff --git a/backend/internal/service/auth_service.go b/backend/internal/service/auth_service.go new file mode 100644 index 0000000..de0b00a --- /dev/null +++ b/backend/internal/service/auth_service.go @@ -0,0 +1,595 @@ +package service + +import ( + "context" + "encoding/json" + "net/http" + "strings" + "time" + + "cmr-backend/internal/apperr" + "cmr-backend/internal/platform/jwtx" + "cmr-backend/internal/platform/security" + "cmr-backend/internal/platform/wechatmini" + "cmr-backend/internal/store/postgres" +) + +type AuthSettings struct { + AppEnv string + RefreshTTL time.Duration + SMSCodeTTL time.Duration + SMSCodeCooldown time.Duration + SMSProvider string + DevSMSCode string + WechatMini *wechatmini.Client +} + +type AuthService struct { + cfg AuthSettings + store *postgres.Store + jwtManager *jwtx.Manager +} + +type SendSMSCodeInput struct { + CountryCode string `json:"countryCode"` + Mobile string `json:"mobile"` + ClientType string `json:"clientType"` + DeviceKey string `json:"deviceKey"` + Scene string `json:"scene"` +} + +type SendSMSCodeResult struct { + TTLSeconds int64 `json:"ttlSeconds"` + CooldownSeconds int64 `json:"cooldownSeconds"` + DevCode *string `json:"devCode,omitempty"` +} + +type LoginSMSInput struct { + CountryCode string `json:"countryCode"` + Mobile string `json:"mobile"` + Code string `json:"code"` + ClientType string `json:"clientType"` + DeviceKey string `json:"deviceKey"` +} + +type LoginWechatMiniInput struct { + Code string `json:"code"` + ClientType string `json:"clientType"` + DeviceKey string `json:"deviceKey"` +} + +type BindMobileInput struct { + UserID string `json:"-"` + CountryCode string `json:"countryCode"` + Mobile string `json:"mobile"` + Code string `json:"code"` + ClientType string `json:"clientType"` + DeviceKey string `json:"deviceKey"` +} + +type RefreshTokenInput struct { + RefreshToken string `json:"refreshToken"` + ClientType string `json:"clientType"` + DeviceKey string `json:"deviceKey"` +} + +type LogoutInput struct { + RefreshToken string `json:"refreshToken"` + UserID string `json:"-"` +} + +type AuthUser struct { + ID string `json:"id"` + PublicID string `json:"publicId"` + Status string `json:"status"` + Nickname *string `json:"nickname,omitempty"` + AvatarURL *string `json:"avatarUrl,omitempty"` +} + +type AuthTokens struct { + AccessToken string `json:"accessToken"` + AccessTokenExpiresAt string `json:"accessTokenExpiresAt"` + RefreshToken string `json:"refreshToken"` + RefreshTokenExpiresAt string `json:"refreshTokenExpiresAt"` +} + +type AuthResult struct { + User AuthUser `json:"user"` + Tokens AuthTokens `json:"tokens"` + NewUser bool `json:"newUser"` +} + +func NewAuthService(cfg AuthSettings, store *postgres.Store, jwtManager *jwtx.Manager) *AuthService { + return &AuthService{ + cfg: cfg, + store: store, + jwtManager: jwtManager, + } +} + +func (s *AuthService) SendSMSCode(ctx context.Context, input SendSMSCodeInput) (*SendSMSCodeResult, error) { + input.CountryCode = normalizeCountryCode(input.CountryCode) + input.Mobile = normalizeMobile(input.Mobile) + input.Scene = normalizeScene(input.Scene) + + if err := validateClientType(input.ClientType); err != nil { + return nil, err + } + if input.Mobile == "" || input.DeviceKey == "" { + return nil, apperr.New(http.StatusBadRequest, "invalid_params", "mobile and deviceKey are required") + } + + latest, err := s.store.GetLatestSMSCodeMeta(ctx, input.CountryCode, input.Mobile, input.ClientType, input.Scene) + if err != nil { + return nil, err + } + now := time.Now().UTC() + if latest != nil && latest.CooldownUntil.After(now) { + return nil, apperr.New(http.StatusTooManyRequests, "sms_cooldown", "sms code sent too frequently") + } + + code := s.cfg.DevSMSCode + if code == "" { + code, err = security.GenerateNumericCode(6) + if err != nil { + return nil, err + } + } + + expiresAt := now.Add(s.cfg.SMSCodeTTL) + cooldownUntil := now.Add(s.cfg.SMSCodeCooldown) + if err := s.store.CreateSMSCode(ctx, postgres.CreateSMSCodeParams{ + Scene: input.Scene, + CountryCode: input.CountryCode, + Mobile: input.Mobile, + ClientType: input.ClientType, + DeviceKey: input.DeviceKey, + CodeHash: security.HashText(code), + ProviderName: s.cfg.SMSProvider, + ProviderDebug: map[string]any{"mode": s.cfg.SMSProvider}, + ExpiresAt: expiresAt, + CooldownUntil: cooldownUntil, + }); err != nil { + return nil, err + } + + result := &SendSMSCodeResult{ + TTLSeconds: int64(s.cfg.SMSCodeTTL.Seconds()), + CooldownSeconds: int64(s.cfg.SMSCodeCooldown.Seconds()), + } + if strings.EqualFold(s.cfg.SMSProvider, "console") || strings.EqualFold(s.cfg.AppEnv, "development") { + result.DevCode = &code + } + return result, nil +} + +func (s *AuthService) LoginSMS(ctx context.Context, input LoginSMSInput) (*AuthResult, error) { + input.CountryCode = normalizeCountryCode(input.CountryCode) + input.Mobile = normalizeMobile(input.Mobile) + input.Code = strings.TrimSpace(input.Code) + + if err := validateClientType(input.ClientType); err != nil { + return nil, err + } + if input.Mobile == "" || input.DeviceKey == "" || input.Code == "" { + return nil, apperr.New(http.StatusBadRequest, "invalid_params", "mobile, code and deviceKey are required") + } + + codeRecord, err := s.store.GetLatestValidSMSCode(ctx, input.CountryCode, input.Mobile, input.ClientType, "login") + if err != nil { + return nil, err + } + if codeRecord == nil || codeRecord.CodeHash != security.HashText(input.Code) { + return nil, apperr.New(http.StatusUnauthorized, "invalid_sms_code", "invalid sms code") + } + + tx, err := s.store.Begin(ctx) + if err != nil { + return nil, err + } + defer tx.Rollback(ctx) + + consumed, err := s.store.ConsumeSMSCode(ctx, tx, codeRecord.ID) + if err != nil { + return nil, err + } + if !consumed { + return nil, apperr.New(http.StatusUnauthorized, "invalid_sms_code", "sms code already used") + } + + user, err := s.store.FindUserByMobile(ctx, tx, input.CountryCode, input.Mobile) + if err != nil { + return nil, err + } + + newUser := false + if user == nil { + userPublicID, err := security.GeneratePublicID("usr") + if err != nil { + return nil, err + } + + user, err = s.store.CreateUser(ctx, tx, postgres.CreateUserParams{ + PublicID: userPublicID, + Status: "active", + }) + if err != nil { + return nil, err + } + + if err := s.store.CreateMobileIdentity(ctx, tx, postgres.CreateMobileIdentityParams{ + UserID: user.ID, + CountryCode: input.CountryCode, + Mobile: input.Mobile, + Provider: "mobile", + ProviderSubj: input.CountryCode + ":" + input.Mobile, + IdentityType: "mobile", + }); err != nil { + return nil, err + } + newUser = true + } + + if err := s.store.TouchUserLogin(ctx, tx, user.ID); err != nil { + return nil, err + } + + result, err := s.issueAuthResult(ctx, tx, *user, input.ClientType, input.DeviceKey, newUser) + if err != nil { + return nil, err + } + + if err := tx.Commit(ctx); err != nil { + return nil, err + } + return result, nil +} + +func (s *AuthService) Refresh(ctx context.Context, input RefreshTokenInput) (*AuthResult, error) { + input.RefreshToken = strings.TrimSpace(input.RefreshToken) + if err := validateClientType(input.ClientType); err != nil { + return nil, err + } + if input.RefreshToken == "" { + return nil, apperr.New(http.StatusBadRequest, "invalid_params", "refreshToken is required") + } + + tx, err := s.store.Begin(ctx) + if err != nil { + return nil, err + } + defer tx.Rollback(ctx) + + record, err := s.store.GetRefreshTokenForUpdate(ctx, tx, security.HashText(input.RefreshToken)) + if err != nil { + return nil, err + } + if record == nil || record.IsRevoked || record.ExpiresAt.Before(time.Now().UTC()) { + return nil, apperr.New(http.StatusUnauthorized, "invalid_refresh_token", "refresh token is invalid or expired") + } + + if input.ClientType != "" && input.ClientType != record.ClientType { + return nil, apperr.New(http.StatusUnauthorized, "invalid_refresh_token", "refresh token client mismatch") + } + if input.DeviceKey != "" && record.DeviceKey != nil && input.DeviceKey != *record.DeviceKey { + return nil, apperr.New(http.StatusUnauthorized, "invalid_refresh_token", "refresh token device mismatch") + } + + user, err := s.store.GetUserByID(ctx, tx, record.UserID) + if err != nil { + return nil, err + } + if user == nil { + return nil, apperr.New(http.StatusUnauthorized, "invalid_refresh_token", "refresh token user not found") + } + + result, refreshTokenID, err := s.issueAuthResultWithRefreshID(ctx, tx, *user, record.ClientType, nullableStringValue(record.DeviceKey), false) + if err != nil { + return nil, err + } + if err := s.store.RotateRefreshToken(ctx, tx, record.ID, refreshTokenID); err != nil { + return nil, err + } + + if err := tx.Commit(ctx); err != nil { + return nil, err + } + return result, nil +} + +func (s *AuthService) LoginWechatMini(ctx context.Context, input LoginWechatMiniInput) (*AuthResult, error) { + input.Code = strings.TrimSpace(input.Code) + if err := validateClientType(input.ClientType); err != nil { + return nil, err + } + if input.ClientType != "wechat" { + return nil, apperr.New(http.StatusBadRequest, "invalid_client_type", "wechat mini login requires clientType=wechat") + } + if input.Code == "" || strings.TrimSpace(input.DeviceKey) == "" { + return nil, apperr.New(http.StatusBadRequest, "invalid_params", "code and deviceKey are required") + } + if s.cfg.WechatMini == nil { + return nil, apperr.New(http.StatusNotImplemented, "wechat_not_configured", "wechat mini provider is not configured") + } + + session, err := s.cfg.WechatMini.ExchangeCode(ctx, input.Code) + if err != nil { + return nil, apperr.New(http.StatusUnauthorized, "wechat_login_failed", err.Error()) + } + + openIDSubject := session.AppID + ":" + session.OpenID + unionIDSubject := strings.TrimSpace(session.UnionID) + + tx, err := s.store.Begin(ctx) + if err != nil { + return nil, err + } + defer tx.Rollback(ctx) + + user, err := s.store.FindUserByProviderSubject(ctx, tx, "wechat_mini", openIDSubject) + if err != nil { + return nil, err + } + if user == nil && unionIDSubject != "" { + user, err = s.store.FindUserByProviderSubject(ctx, tx, "wechat_unionid", unionIDSubject) + if err != nil { + return nil, err + } + } + + newUser := false + if user == nil { + userPublicID, err := security.GeneratePublicID("usr") + if err != nil { + return nil, err + } + user, err = s.store.CreateUser(ctx, tx, postgres.CreateUserParams{ + PublicID: userPublicID, + Status: "active", + }) + if err != nil { + return nil, err + } + newUser = true + } + + profileJSON, err := json.Marshal(map[string]any{ + "appId": session.AppID, + }) + if err != nil { + return nil, err + } + + if err := s.store.CreateIdentity(ctx, tx, postgres.CreateIdentityParams{ + UserID: user.ID, + IdentityType: "wechat_mini_openid", + Provider: "wechat_mini", + ProviderSubj: openIDSubject, + ProfileJSON: string(profileJSON), + }); err != nil { + return nil, err + } + + if unionIDSubject != "" { + if err := s.store.CreateIdentity(ctx, tx, postgres.CreateIdentityParams{ + UserID: user.ID, + IdentityType: "wechat_unionid", + Provider: "wechat_unionid", + ProviderSubj: unionIDSubject, + ProfileJSON: "{}", + }); err != nil { + return nil, err + } + } + + if err := s.store.TouchUserLogin(ctx, tx, user.ID); err != nil { + return nil, err + } + + result, err := s.issueAuthResult(ctx, tx, *user, input.ClientType, input.DeviceKey, newUser) + if err != nil { + return nil, err + } + + if err := tx.Commit(ctx); err != nil { + return nil, err + } + return result, nil +} + +func (s *AuthService) BindMobile(ctx context.Context, input BindMobileInput) (*AuthResult, error) { + input.CountryCode = normalizeCountryCode(input.CountryCode) + input.Mobile = normalizeMobile(input.Mobile) + input.Code = strings.TrimSpace(input.Code) + + if err := validateClientType(input.ClientType); err != nil { + return nil, err + } + if input.UserID == "" || input.Mobile == "" || input.Code == "" || strings.TrimSpace(input.DeviceKey) == "" { + return nil, apperr.New(http.StatusBadRequest, "invalid_params", "user, mobile, code and deviceKey are required") + } + + codeRecord, err := s.store.GetLatestValidSMSCode(ctx, input.CountryCode, input.Mobile, input.ClientType, "bind_mobile") + if err != nil { + return nil, err + } + if codeRecord == nil || codeRecord.CodeHash != security.HashText(input.Code) { + return nil, apperr.New(http.StatusUnauthorized, "invalid_sms_code", "invalid sms code") + } + + tx, err := s.store.Begin(ctx) + if err != nil { + return nil, err + } + defer tx.Rollback(ctx) + + consumed, err := s.store.ConsumeSMSCode(ctx, tx, codeRecord.ID) + if err != nil { + return nil, err + } + if !consumed { + return nil, apperr.New(http.StatusUnauthorized, "invalid_sms_code", "sms code already used") + } + + currentUser, err := s.store.GetUserByID(ctx, tx, input.UserID) + if err != nil { + return nil, err + } + if currentUser == nil { + return nil, apperr.New(http.StatusNotFound, "user_not_found", "current user not found") + } + + mobileUser, err := s.store.FindUserByMobile(ctx, tx, input.CountryCode, input.Mobile) + if err != nil { + return nil, err + } + + finalUser := currentUser + newlyBound := false + + if mobileUser == nil { + if err := s.store.CreateMobileIdentity(ctx, tx, postgres.CreateMobileIdentityParams{ + UserID: currentUser.ID, + CountryCode: input.CountryCode, + Mobile: input.Mobile, + Provider: "mobile", + ProviderSubj: input.CountryCode + ":" + input.Mobile, + IdentityType: "mobile", + }); err != nil { + return nil, err + } + newlyBound = true + } else if mobileUser.ID != currentUser.ID { + if err := s.store.TransferNonMobileIdentities(ctx, tx, currentUser.ID, mobileUser.ID); err != nil { + return nil, err + } + if err := s.store.RevokeRefreshTokensByUserID(ctx, tx, currentUser.ID); err != nil { + return nil, err + } + if err := s.store.DeactivateUser(ctx, tx, currentUser.ID); err != nil { + return nil, err + } + finalUser = mobileUser + } + + if err := s.store.TouchUserLogin(ctx, tx, finalUser.ID); err != nil { + return nil, err + } + + result, err := s.issueAuthResult(ctx, tx, *finalUser, input.ClientType, input.DeviceKey, newlyBound) + if err != nil { + return nil, err + } + + if err := tx.Commit(ctx); err != nil { + return nil, err + } + return result, nil +} + +func (s *AuthService) Logout(ctx context.Context, input LogoutInput) error { + if strings.TrimSpace(input.RefreshToken) == "" { + return nil + } + return s.store.RevokeRefreshToken(ctx, security.HashText(strings.TrimSpace(input.RefreshToken))) +} + +func (s *AuthService) issueAuthResult( + ctx context.Context, + tx postgres.Tx, + user postgres.User, + clientType string, + deviceKey string, + newUser bool, +) (*AuthResult, error) { + result, _, err := s.issueAuthResultWithRefreshID(ctx, tx, user, clientType, deviceKey, newUser) + return result, err +} + +func (s *AuthService) issueAuthResultWithRefreshID( + ctx context.Context, + tx postgres.Tx, + user postgres.User, + clientType string, + deviceKey string, + newUser bool, +) (*AuthResult, string, error) { + accessToken, accessExpiresAt, err := s.jwtManager.IssueAccessToken(user.ID, user.PublicID) + if err != nil { + return nil, "", err + } + + refreshToken, err := security.GenerateToken(32) + if err != nil { + return nil, "", err + } + refreshTokenHash := security.HashText(refreshToken) + refreshExpiresAt := time.Now().UTC().Add(s.cfg.RefreshTTL) + + refreshID, err := s.store.CreateRefreshToken(ctx, tx, postgres.CreateRefreshTokenParams{ + UserID: user.ID, + ClientType: clientType, + DeviceKey: deviceKey, + TokenHash: refreshTokenHash, + ExpiresAt: refreshExpiresAt, + }) + if err != nil { + return nil, "", err + } + + return &AuthResult{ + User: AuthUser{ + ID: user.ID, + PublicID: user.PublicID, + Status: user.Status, + Nickname: user.Nickname, + AvatarURL: user.AvatarURL, + }, + Tokens: AuthTokens{ + AccessToken: accessToken, + AccessTokenExpiresAt: accessExpiresAt.Format(time.RFC3339), + RefreshToken: refreshToken, + RefreshTokenExpiresAt: refreshExpiresAt.Format(time.RFC3339), + }, + NewUser: newUser, + }, refreshID, nil +} + +func validateClientType(clientType string) error { + switch clientType { + case "app", "wechat": + return nil + default: + return apperr.New(http.StatusBadRequest, "invalid_client_type", "clientType must be app or wechat") + } +} + +func normalizeCountryCode(value string) string { + value = strings.TrimSpace(value) + if value == "" { + return "86" + } + return strings.TrimPrefix(value, "+") +} + +func normalizeMobile(value string) string { + value = strings.TrimSpace(value) + value = strings.ReplaceAll(value, " ", "") + value = strings.ReplaceAll(value, "-", "") + return value +} + +func normalizeScene(value string) string { + value = strings.TrimSpace(value) + if value == "" { + return "login" + } + return value +} + +func nullableStringValue(value *string) string { + if value == nil { + return "" + } + return *value +} diff --git a/backend/internal/service/config_service.go b/backend/internal/service/config_service.go new file mode 100644 index 0000000..248e2a5 --- /dev/null +++ b/backend/internal/service/config_service.go @@ -0,0 +1,678 @@ +package service + +import ( + "context" + "encoding/json" + "fmt" + "net/http" + "os" + "path/filepath" + "sort" + "strings" + + "cmr-backend/internal/apperr" + "cmr-backend/internal/platform/security" + "cmr-backend/internal/store/postgres" +) + +type ConfigService struct { + store *postgres.Store + localEventDir string + assetBaseURL string +} + +type ConfigPipelineSummary struct { + SourceTable string `json:"sourceTable"` + BuildTable string `json:"buildTable"` + ReleaseAssetsTable string `json:"releaseAssetsTable"` +} + +type LocalEventFile struct { + FileName string `json:"fileName"` + FullPath string `json:"fullPath"` +} + +type EventConfigSourceView struct { + ID string `json:"id"` + EventID string `json:"eventId"` + SourceVersionNo int `json:"sourceVersionNo"` + SourceKind string `json:"sourceKind"` + SchemaID string `json:"schemaId"` + SchemaVersion string `json:"schemaVersion"` + Status string `json:"status"` + Notes *string `json:"notes,omitempty"` + Source map[string]any `json:"source"` +} + +type EventConfigBuildView struct { + ID string `json:"id"` + EventID string `json:"eventId"` + SourceID string `json:"sourceId"` + BuildNo int `json:"buildNo"` + BuildStatus string `json:"buildStatus"` + BuildLog *string `json:"buildLog,omitempty"` + Manifest map[string]any `json:"manifest"` + AssetIndex []map[string]any `json:"assetIndex"` +} + +type PublishedReleaseView struct { + EventID string `json:"eventId"` + Release ResolvedReleaseView `json:"release"` + ReleaseNo int `json:"releaseNo"` + PublishedAt string `json:"publishedAt"` +} + +type ImportLocalEventConfigInput struct { + EventPublicID string + FileName string `json:"fileName"` + Notes *string `json:"notes,omitempty"` +} + +type BuildPreviewInput struct { + SourceID string `json:"sourceId"` +} + +type PublishBuildInput struct { + BuildID string `json:"buildId"` +} + +func NewConfigService(store *postgres.Store, localEventDir, assetBaseURL string) *ConfigService { + return &ConfigService{ + store: store, + localEventDir: localEventDir, + assetBaseURL: strings.TrimRight(assetBaseURL, "/"), + } +} + +func (s *ConfigService) PipelineSummary() ConfigPipelineSummary { + return ConfigPipelineSummary{ + SourceTable: "event_config_sources", + BuildTable: "event_config_builds", + ReleaseAssetsTable: "event_release_assets", + } +} + +func (s *ConfigService) ListLocalEventFiles() ([]LocalEventFile, error) { + dir, err := filepath.Abs(s.localEventDir) + if err != nil { + return nil, apperr.New(http.StatusInternalServerError, "config_dir_invalid", "failed to resolve local event directory") + } + entries, err := os.ReadDir(dir) + if err != nil { + return nil, apperr.New(http.StatusInternalServerError, "config_dir_unavailable", "failed to read local event directory") + } + + files := make([]LocalEventFile, 0) + for _, entry := range entries { + if entry.IsDir() { + continue + } + if strings.ToLower(filepath.Ext(entry.Name())) != ".json" { + continue + } + files = append(files, LocalEventFile{ + FileName: entry.Name(), + FullPath: filepath.Join(dir, entry.Name()), + }) + } + sort.Slice(files, func(i, j int) bool { + return files[i].FileName < files[j].FileName + }) + return files, nil +} + +func (s *ConfigService) ListEventConfigSources(ctx context.Context, eventPublicID string, limit int) ([]EventConfigSourceView, error) { + event, err := s.requireEvent(ctx, eventPublicID) + if err != nil { + return nil, err + } + + items, err := s.store.ListEventConfigSourcesByEventID(ctx, event.ID, limit) + if err != nil { + return nil, err + } + + results := make([]EventConfigSourceView, 0, len(items)) + for i := range items { + view, err := buildEventConfigSourceView(&items[i], event.PublicID) + if err != nil { + return nil, err + } + results = append(results, *view) + } + return results, nil +} + +func (s *ConfigService) GetEventConfigSource(ctx context.Context, sourceID string) (*EventConfigSourceView, error) { + record, err := s.store.GetEventConfigSourceByID(ctx, strings.TrimSpace(sourceID)) + if err != nil { + return nil, err + } + if record == nil { + return nil, apperr.New(http.StatusNotFound, "config_source_not_found", "config source not found") + } + return buildEventConfigSourceView(record, "") +} + +func (s *ConfigService) GetEventConfigBuild(ctx context.Context, buildID string) (*EventConfigBuildView, error) { + record, err := s.store.GetEventConfigBuildByID(ctx, strings.TrimSpace(buildID)) + if err != nil { + return nil, err + } + if record == nil { + return nil, apperr.New(http.StatusNotFound, "config_build_not_found", "config build not found") + } + return buildEventConfigBuildView(record) +} + +func (s *ConfigService) ImportLocalEventConfig(ctx context.Context, input ImportLocalEventConfigInput) (*EventConfigSourceView, error) { + event, err := s.requireEvent(ctx, input.EventPublicID) + if err != nil { + return nil, err + } + + fileName := strings.TrimSpace(filepath.Base(input.FileName)) + if fileName == "" || strings.Contains(fileName, "..") || strings.ToLower(filepath.Ext(fileName)) != ".json" { + return nil, apperr.New(http.StatusBadRequest, "invalid_params", "valid json fileName is required") + } + + dir, err := filepath.Abs(s.localEventDir) + if err != nil { + return nil, apperr.New(http.StatusInternalServerError, "config_dir_invalid", "failed to resolve local event directory") + } + path := filepath.Join(dir, fileName) + raw, err := os.ReadFile(path) + if err != nil { + return nil, apperr.New(http.StatusNotFound, "config_file_not_found", "local config file not found") + } + + source := map[string]any{} + if err := json.Unmarshal(raw, &source); err != nil { + return nil, apperr.New(http.StatusBadRequest, "config_json_invalid", "local config file is not valid json") + } + if err := validateSourceConfig(source); err != nil { + return nil, err + } + + nextVersion, err := s.store.NextEventConfigSourceVersion(ctx, event.ID) + if err != nil { + return nil, err + } + + note := input.Notes + if note == nil || strings.TrimSpace(*note) == "" { + defaultNote := "imported from local event file: " + fileName + note = &defaultNote + } + + tx, err := s.store.Begin(ctx) + if err != nil { + return nil, err + } + defer tx.Rollback(ctx) + + record, err := s.store.UpsertEventConfigSource(ctx, tx, postgres.UpsertEventConfigSourceParams{ + EventID: event.ID, + SourceVersionNo: nextVersion, + SourceKind: "event_bundle", + SchemaID: "event-source", + SchemaVersion: resolveSchemaVersion(source), + Status: "active", + Source: source, + Notes: note, + }) + if err != nil { + return nil, err + } + + if err := tx.Commit(ctx); err != nil { + return nil, err + } + return buildEventConfigSourceView(record, event.PublicID) +} + +func (s *ConfigService) BuildPreview(ctx context.Context, input BuildPreviewInput) (*EventConfigBuildView, error) { + sourceRecord, err := s.store.GetEventConfigSourceByID(ctx, strings.TrimSpace(input.SourceID)) + if err != nil { + return nil, err + } + if sourceRecord == nil { + return nil, apperr.New(http.StatusNotFound, "config_source_not_found", "config source not found") + } + + source, err := decodeJSONObject(sourceRecord.SourceJSON) + if err != nil { + return nil, apperr.New(http.StatusInternalServerError, "config_source_invalid", "stored source config is invalid") + } + if err := validateSourceConfig(source); err != nil { + return nil, err + } + + buildNo, err := s.store.NextEventConfigBuildNo(ctx, sourceRecord.EventID) + if err != nil { + return nil, err + } + + previewReleaseID := fmt.Sprintf("preview_%d", buildNo) + manifest := s.buildPreviewManifest(source, previewReleaseID) + assetIndex := s.buildAssetIndex(manifest) + buildLog := "preview build generated from source " + sourceRecord.ID + + tx, err := s.store.Begin(ctx) + if err != nil { + return nil, err + } + defer tx.Rollback(ctx) + + record, err := s.store.UpsertEventConfigBuild(ctx, tx, postgres.UpsertEventConfigBuildParams{ + EventID: sourceRecord.EventID, + SourceID: sourceRecord.ID, + BuildNo: buildNo, + BuildStatus: "success", + BuildLog: &buildLog, + Manifest: manifest, + AssetIndex: assetIndex, + }) + if err != nil { + return nil, err + } + + if err := tx.Commit(ctx); err != nil { + return nil, err + } + return buildEventConfigBuildView(record) +} + +func (s *ConfigService) PublishBuild(ctx context.Context, input PublishBuildInput) (*PublishedReleaseView, error) { + buildRecord, err := s.store.GetEventConfigBuildByID(ctx, strings.TrimSpace(input.BuildID)) + if err != nil { + return nil, err + } + if buildRecord == nil { + return nil, apperr.New(http.StatusNotFound, "config_build_not_found", "config build not found") + } + if buildRecord.BuildStatus != "success" { + return nil, apperr.New(http.StatusConflict, "config_build_not_publishable", "config build is not publishable") + } + + event, err := s.store.GetEventByID(ctx, buildRecord.EventID) + if err != nil { + return nil, err + } + if event == nil { + return nil, apperr.New(http.StatusNotFound, "event_not_found", "event not found") + } + + manifest, err := decodeJSONObject(buildRecord.ManifestJSON) + if err != nil { + return nil, apperr.New(http.StatusInternalServerError, "config_build_invalid", "stored build manifest is invalid") + } + assetIndex, err := decodeJSONArray(buildRecord.AssetIndexJSON) + if err != nil { + return nil, apperr.New(http.StatusInternalServerError, "config_build_invalid", "stored build asset index is invalid") + } + + releaseNo, err := s.store.NextEventReleaseNo(ctx, event.ID) + if err != nil { + return nil, err + } + releasePublicID, err := security.GeneratePublicID("rel") + if err != nil { + return nil, err + } + + configLabel := deriveConfigLabel(event, manifest, releaseNo) + manifestURL := fmt.Sprintf("%s/event/releases/%s/%s/manifest.json", s.assetBaseURL, event.PublicID, releasePublicID) + checksum := security.HashText(buildRecord.ManifestJSON) + routeCode := deriveRouteCode(manifest) + + tx, err := s.store.Begin(ctx) + if err != nil { + return nil, err + } + defer tx.Rollback(ctx) + + releaseRecord, err := s.store.CreateEventRelease(ctx, tx, postgres.CreateEventReleaseParams{ + PublicID: releasePublicID, + EventID: event.ID, + ReleaseNo: releaseNo, + ConfigLabel: configLabel, + ManifestURL: manifestURL, + ManifestChecksum: &checksum, + RouteCode: routeCode, + BuildID: &buildRecord.ID, + Status: "published", + PayloadJSON: buildRecord.ManifestJSON, + }) + if err != nil { + return nil, err + } + + if err := s.store.ReplaceEventReleaseAssets(ctx, tx, releaseRecord.ID, s.mapBuildAssetsToReleaseAssets(releaseRecord.ID, manifestURL, &checksum, assetIndex)); err != nil { + return nil, err + } + + if err := s.store.SetCurrentEventRelease(ctx, tx, event.ID, releaseRecord.ID); err != nil { + return nil, err + } + + if err := tx.Commit(ctx); err != nil { + return nil, err + } + + return &PublishedReleaseView{ + EventID: event.PublicID, + Release: ResolvedReleaseView{ + LaunchMode: LaunchModeManifestRelease, + Source: LaunchSourceEventCurrentRelease, + EventID: event.PublicID, + ReleaseID: releaseRecord.PublicID, + ConfigLabel: releaseRecord.ConfigLabel, + ManifestURL: releaseRecord.ManifestURL, + ManifestChecksumSha256: releaseRecord.ManifestChecksum, + RouteCode: releaseRecord.RouteCode, + }, + ReleaseNo: releaseRecord.ReleaseNo, + PublishedAt: releaseRecord.PublishedAt.Format(timeRFC3339), + }, nil +} + +func (s *ConfigService) requireEvent(ctx context.Context, eventPublicID string) (*postgres.Event, error) { + eventPublicID = strings.TrimSpace(eventPublicID) + if eventPublicID == "" { + return nil, apperr.New(http.StatusBadRequest, "invalid_params", "event id is required") + } + event, err := s.store.GetEventByPublicID(ctx, eventPublicID) + if err != nil { + return nil, err + } + if event == nil { + return nil, apperr.New(http.StatusNotFound, "event_not_found", "event not found") + } + return event, nil +} + +func buildEventConfigSourceView(record *postgres.EventConfigSource, eventPublicID string) (*EventConfigSourceView, error) { + source, err := decodeJSONObject(record.SourceJSON) + if err != nil { + return nil, err + } + view := &EventConfigSourceView{ + ID: record.ID, + EventID: eventPublicID, + SourceVersionNo: record.SourceVersionNo, + SourceKind: record.SourceKind, + SchemaID: record.SchemaID, + SchemaVersion: record.SchemaVersion, + Status: record.Status, + Notes: record.Notes, + Source: source, + } + return view, nil +} + +func buildEventConfigBuildView(record *postgres.EventConfigBuild) (*EventConfigBuildView, error) { + manifest, err := decodeJSONObject(record.ManifestJSON) + if err != nil { + return nil, err + } + assetIndex, err := decodeJSONArray(record.AssetIndexJSON) + if err != nil { + return nil, err + } + return &EventConfigBuildView{ + ID: record.ID, + EventID: record.EventID, + SourceID: record.SourceID, + BuildNo: record.BuildNo, + BuildStatus: record.BuildStatus, + BuildLog: record.BuildLog, + Manifest: manifest, + AssetIndex: assetIndex, + }, nil +} + +func validateSourceConfig(source map[string]any) error { + requiredMap := func(parent map[string]any, key string) (map[string]any, error) { + value, ok := parent[key] + if !ok { + return nil, apperr.New(http.StatusBadRequest, "config_missing_field", "missing required field: "+key) + } + asMap, ok := value.(map[string]any) + if !ok { + return nil, apperr.New(http.StatusBadRequest, "config_invalid_field", "invalid object field: "+key) + } + return asMap, nil + } + requiredString := func(parent map[string]any, key string) error { + value, ok := parent[key] + if !ok { + return apperr.New(http.StatusBadRequest, "config_missing_field", "missing required field: "+key) + } + text, ok := value.(string) + if !ok || strings.TrimSpace(text) == "" { + return apperr.New(http.StatusBadRequest, "config_invalid_field", "invalid string field: "+key) + } + return nil + } + + if err := requiredString(source, "schemaVersion"); err != nil { + return err + } + app, err := requiredMap(source, "app") + if err != nil { + return err + } + if err := requiredString(app, "id"); err != nil { + return err + } + if err := requiredString(app, "title"); err != nil { + return err + } + m, err := requiredMap(source, "map") + if err != nil { + return err + } + if err := requiredString(m, "tiles"); err != nil { + return err + } + if err := requiredString(m, "mapmeta"); err != nil { + return err + } + playfield, err := requiredMap(source, "playfield") + if err != nil { + return err + } + if err := requiredString(playfield, "kind"); err != nil { + return err + } + playfieldSource, err := requiredMap(playfield, "source") + if err != nil { + return err + } + if err := requiredString(playfieldSource, "type"); err != nil { + return err + } + if err := requiredString(playfieldSource, "url"); err != nil { + return err + } + game, err := requiredMap(source, "game") + if err != nil { + return err + } + if err := requiredString(game, "mode"); err != nil { + return err + } + return nil +} + +func resolveSchemaVersion(source map[string]any) string { + if value, ok := source["schemaVersion"].(string); ok && strings.TrimSpace(value) != "" { + return value + } + return "1" +} + +func (s *ConfigService) buildPreviewManifest(source map[string]any, previewReleaseID string) map[string]any { + manifest := cloneJSONObject(source) + manifest["releaseId"] = previewReleaseID + manifest["preview"] = true + manifest["assetBaseUrl"] = s.assetBaseURL + if version, ok := manifest["version"]; !ok || version == "" { + manifest["version"] = "preview" + } + + if m, ok := manifest["map"].(map[string]any); ok { + if tiles, ok := m["tiles"].(string); ok { + m["tiles"] = s.normalizeAssetURL(tiles) + } + if meta, ok := m["mapmeta"].(string); ok { + m["mapmeta"] = s.normalizeAssetURL(meta) + } + } + if playfield, ok := manifest["playfield"].(map[string]any); ok { + if src, ok := playfield["source"].(map[string]any); ok { + if url, ok := src["url"].(string); ok { + src["url"] = s.normalizeAssetURL(url) + } + } + } + if assets, ok := manifest["assets"].(map[string]any); ok { + for key, value := range assets { + if text, ok := value.(string); ok { + assets[key] = s.normalizeAssetURL(text) + } + } + } + + return manifest +} + +func (s *ConfigService) buildAssetIndex(manifest map[string]any) []map[string]any { + var assets []map[string]any + if m, ok := manifest["map"].(map[string]any); ok { + if tiles, ok := m["tiles"].(string); ok { + assets = append(assets, map[string]any{"assetType": "tiles", "assetKey": "tiles-root", "assetUrl": tiles}) + } + if meta, ok := m["mapmeta"].(string); ok { + assets = append(assets, map[string]any{"assetType": "mapmeta", "assetKey": "mapmeta", "assetUrl": meta}) + } + } + if playfield, ok := manifest["playfield"].(map[string]any); ok { + if src, ok := playfield["source"].(map[string]any); ok { + if url, ok := src["url"].(string); ok { + assets = append(assets, map[string]any{"assetType": "playfield", "assetKey": "playfield-source", "assetUrl": url}) + } + } + } + if rawAssets, ok := manifest["assets"].(map[string]any); ok { + keys := make([]string, 0, len(rawAssets)) + for key := range rawAssets { + keys = append(keys, key) + } + sort.Strings(keys) + for _, key := range keys { + if url, ok := rawAssets[key].(string); ok { + assets = append(assets, map[string]any{"assetType": "other", "assetKey": key, "assetUrl": url}) + } + } + } + return assets +} + +func (s *ConfigService) normalizeAssetURL(value string) string { + value = strings.TrimSpace(value) + if value == "" { + return value + } + if strings.HasPrefix(value, "http://") || strings.HasPrefix(value, "https://") { + return value + } + trimmed := strings.TrimPrefix(value, "../") + trimmed = strings.TrimPrefix(trimmed, "./") + trimmed = strings.TrimLeft(trimmed, "/") + return s.assetBaseURL + "/" + trimmed +} + +func cloneJSONObject(source map[string]any) map[string]any { + raw, _ := json.Marshal(source) + cloned := map[string]any{} + _ = json.Unmarshal(raw, &cloned) + return cloned +} + +func decodeJSONObject(raw string) (map[string]any, error) { + result := map[string]any{} + if err := json.Unmarshal([]byte(raw), &result); err != nil { + return nil, err + } + return result, nil +} + +func decodeJSONArray(raw string) ([]map[string]any, error) { + if strings.TrimSpace(raw) == "" { + return []map[string]any{}, nil + } + var result []map[string]any + if err := json.Unmarshal([]byte(raw), &result); err != nil { + return nil, err + } + return result, nil +} + +func deriveConfigLabel(event *postgres.Event, manifest map[string]any, releaseNo int) string { + if app, ok := manifest["app"].(map[string]any); ok { + if title, ok := app["title"].(string); ok && strings.TrimSpace(title) != "" { + return fmt.Sprintf("%s Release %d", strings.TrimSpace(title), releaseNo) + } + } + if event != nil && strings.TrimSpace(event.DisplayName) != "" { + return fmt.Sprintf("%s Release %d", event.DisplayName, releaseNo) + } + return fmt.Sprintf("Release %d", releaseNo) +} + +func deriveRouteCode(manifest map[string]any) *string { + if playfield, ok := manifest["playfield"].(map[string]any); ok { + if value, ok := playfield["kind"].(string); ok && strings.TrimSpace(value) != "" { + route := strings.TrimSpace(value) + return &route + } + } + return nil +} + +func (s *ConfigService) mapBuildAssetsToReleaseAssets(eventReleaseID, manifestURL string, checksum *string, assetIndex []map[string]any) []postgres.UpsertEventReleaseAssetParams { + assets := []postgres.UpsertEventReleaseAssetParams{ + { + EventReleaseID: eventReleaseID, + AssetType: "manifest", + AssetKey: "manifest", + AssetURL: manifestURL, + Checksum: checksum, + Meta: map[string]any{"source": "published-build"}, + }, + } + + for _, asset := range assetIndex { + assetType, _ := asset["assetType"].(string) + assetKey, _ := asset["assetKey"].(string) + assetURL, _ := asset["assetUrl"].(string) + if strings.TrimSpace(assetType) == "" || strings.TrimSpace(assetKey) == "" || strings.TrimSpace(assetURL) == "" { + continue + } + mappedType := assetType + if mappedType != "manifest" && mappedType != "mapmeta" && mappedType != "tiles" && mappedType != "playfield" && mappedType != "content_html" && mappedType != "media" { + mappedType = "other" + } + assets = append(assets, postgres.UpsertEventReleaseAssetParams{ + EventReleaseID: eventReleaseID, + AssetType: mappedType, + AssetKey: assetKey, + AssetURL: assetURL, + Meta: asset, + }) + } + + return assets +} diff --git a/backend/internal/service/dev_service.go b/backend/internal/service/dev_service.go new file mode 100644 index 0000000..92050d7 --- /dev/null +++ b/backend/internal/service/dev_service.go @@ -0,0 +1,32 @@ +package service + +import ( + "context" + "net/http" + + "cmr-backend/internal/apperr" + "cmr-backend/internal/store/postgres" +) + +type DevService struct { + appEnv string + store *postgres.Store +} + +func NewDevService(appEnv string, store *postgres.Store) *DevService { + return &DevService{ + appEnv: appEnv, + store: store, + } +} + +func (s *DevService) Enabled() bool { + return s.appEnv != "production" +} + +func (s *DevService) BootstrapDemo(ctx context.Context) (*postgres.DemoBootstrapSummary, error) { + if !s.Enabled() { + return nil, apperr.New(http.StatusNotFound, "not_found", "dev bootstrap is disabled") + } + return s.store.EnsureDemoData(ctx) +} diff --git a/backend/internal/service/entry_home_service.go b/backend/internal/service/entry_home_service.go new file mode 100644 index 0000000..9e6ed6f --- /dev/null +++ b/backend/internal/service/entry_home_service.go @@ -0,0 +1,164 @@ +package service + +import ( + "context" + "net/http" + "strings" + + "cmr-backend/internal/apperr" + "cmr-backend/internal/store/postgres" +) + +type EntryHomeService struct { + store *postgres.Store +} + +type EntryHomeInput struct { + UserID string + ChannelCode string + ChannelType string + PlatformAppID string + TenantCode string +} + +type EntryHomeResult struct { + User struct { + ID string `json:"id"` + PublicID string `json:"publicId"` + Status string `json:"status"` + Nickname *string `json:"nickname,omitempty"` + AvatarURL *string `json:"avatarUrl,omitempty"` + } `json:"user"` + Tenant struct { + ID string `json:"id"` + Code string `json:"code"` + Name string `json:"name"` + } `json:"tenant"` + Channel struct { + ID string `json:"id"` + Code string `json:"code"` + Type string `json:"type"` + PlatformAppID *string `json:"platformAppId,omitempty"` + DisplayName string `json:"displayName"` + Status string `json:"status"` + IsDefault bool `json:"isDefault"` + } `json:"channel"` + Cards []CardResult `json:"cards"` + OngoingSession *EntrySessionSummary `json:"ongoingSession,omitempty"` + RecentSession *EntrySessionSummary `json:"recentSession,omitempty"` +} + +type EntrySessionSummary struct { + ID string `json:"id"` + Status string `json:"status"` + EventID string `json:"eventId"` + EventName string `json:"eventName"` + ReleaseID *string `json:"releaseId,omitempty"` + ConfigLabel *string `json:"configLabel,omitempty"` + RouteCode *string `json:"routeCode,omitempty"` + LaunchedAt string `json:"launchedAt"` + StartedAt *string `json:"startedAt,omitempty"` + EndedAt *string `json:"endedAt,omitempty"` +} + +func NewEntryHomeService(store *postgres.Store) *EntryHomeService { + return &EntryHomeService{store: store} +} + +func (s *EntryHomeService) GetEntryHome(ctx context.Context, input EntryHomeInput) (*EntryHomeResult, error) { + input.UserID = strings.TrimSpace(input.UserID) + if input.UserID == "" { + return nil, apperr.New(http.StatusUnauthorized, "unauthorized", "user is required") + } + + user, err := s.store.GetUserByID(ctx, s.store.Pool(), input.UserID) + if err != nil { + return nil, err + } + if user == nil { + return nil, apperr.New(http.StatusNotFound, "user_not_found", "user not found") + } + + entry, err := s.store.FindEntryChannel(ctx, postgres.FindEntryChannelParams{ + ChannelCode: strings.TrimSpace(input.ChannelCode), + ChannelType: strings.TrimSpace(input.ChannelType), + PlatformAppID: strings.TrimSpace(input.PlatformAppID), + TenantCode: strings.TrimSpace(input.TenantCode), + }) + if err != nil { + return nil, err + } + if entry == nil { + return nil, apperr.New(http.StatusNotFound, "entry_channel_not_found", "entry channel not found") + } + + cards, err := s.store.ListCardsForEntry(ctx, entry.TenantID, &entry.ID, "home_primary", nowUTC(), 20) + if err != nil { + return nil, err + } + + sessions, err := s.store.ListSessionsByUserID(ctx, user.ID, 10) + if err != nil { + return nil, err + } + + result := &EntryHomeResult{ + Cards: mapCards(cards), + } + result.User.ID = user.ID + result.User.PublicID = user.PublicID + result.User.Status = user.Status + result.User.Nickname = user.Nickname + result.User.AvatarURL = user.AvatarURL + result.Tenant.ID = entry.TenantID + result.Tenant.Code = entry.TenantCode + result.Tenant.Name = entry.TenantName + result.Channel.ID = entry.ID + result.Channel.Code = entry.ChannelCode + result.Channel.Type = entry.ChannelType + result.Channel.PlatformAppID = entry.PlatformAppID + result.Channel.DisplayName = entry.DisplayName + result.Channel.Status = entry.Status + result.Channel.IsDefault = entry.IsDefault + + if len(sessions) > 0 { + recent := buildEntrySessionSummary(&sessions[0]) + result.RecentSession = &recent + } + + for i := range sessions { + if sessions[i].Status == "launched" || sessions[i].Status == "running" { + ongoing := buildEntrySessionSummary(&sessions[i]) + result.OngoingSession = &ongoing + break + } + } + + return result, nil +} + +func buildEntrySessionSummary(session *postgres.Session) EntrySessionSummary { + summary := EntrySessionSummary{ + ID: session.SessionPublicID, + Status: session.Status, + RouteCode: session.RouteCode, + LaunchedAt: session.LaunchedAt.Format(timeRFC3339), + } + if session.EventPublicID != nil { + summary.EventID = *session.EventPublicID + } + if session.EventDisplayName != nil { + summary.EventName = *session.EventDisplayName + } + summary.ReleaseID = session.ReleasePublicID + summary.ConfigLabel = session.ConfigLabel + if session.StartedAt != nil { + value := session.StartedAt.Format(timeRFC3339) + summary.StartedAt = &value + } + if session.EndedAt != nil { + value := session.EndedAt.Format(timeRFC3339) + summary.EndedAt = &value + } + return summary +} diff --git a/backend/internal/service/entry_service.go b/backend/internal/service/entry_service.go new file mode 100644 index 0000000..6bb2152 --- /dev/null +++ b/backend/internal/service/entry_service.go @@ -0,0 +1,79 @@ +package service + +import ( + "context" + "net/http" + "strings" + + "cmr-backend/internal/apperr" + "cmr-backend/internal/store/postgres" +) + +type EntryService struct { + store *postgres.Store +} + +type ResolveEntryInput struct { + ChannelCode string + ChannelType string + PlatformAppID string + TenantCode string +} + +type ResolveEntryResult struct { + Tenant struct { + ID string `json:"id"` + Code string `json:"code"` + Name string `json:"name"` + } `json:"tenant"` + Channel struct { + ID string `json:"id"` + Code string `json:"code"` + Type string `json:"type"` + PlatformAppID *string `json:"platformAppId,omitempty"` + DisplayName string `json:"displayName"` + Status string `json:"status"` + IsDefault bool `json:"isDefault"` + } `json:"channel"` +} + +func NewEntryService(store *postgres.Store) *EntryService { + return &EntryService{store: store} +} + +func (s *EntryService) Resolve(ctx context.Context, input ResolveEntryInput) (*ResolveEntryResult, error) { + input.ChannelCode = strings.TrimSpace(input.ChannelCode) + input.ChannelType = strings.TrimSpace(input.ChannelType) + input.PlatformAppID = strings.TrimSpace(input.PlatformAppID) + input.TenantCode = strings.TrimSpace(input.TenantCode) + + if input.ChannelCode == "" && input.PlatformAppID == "" { + return nil, apperr.New(http.StatusBadRequest, "invalid_params", "channelCode or platformAppId is required") + } + + entry, err := s.store.FindEntryChannel(ctx, postgres.FindEntryChannelParams{ + ChannelCode: input.ChannelCode, + ChannelType: input.ChannelType, + PlatformAppID: input.PlatformAppID, + TenantCode: input.TenantCode, + }) + if err != nil { + return nil, err + } + if entry == nil { + return nil, apperr.New(http.StatusNotFound, "entry_channel_not_found", "entry channel not found") + } + + result := &ResolveEntryResult{} + result.Tenant.ID = entry.TenantID + result.Tenant.Code = entry.TenantCode + result.Tenant.Name = entry.TenantName + result.Channel.ID = entry.ID + result.Channel.Code = entry.ChannelCode + result.Channel.Type = entry.ChannelType + result.Channel.PlatformAppID = entry.PlatformAppID + result.Channel.DisplayName = entry.DisplayName + result.Channel.Status = entry.Status + result.Channel.IsDefault = entry.IsDefault + return result, nil +} diff --git a/backend/internal/service/event_play_service.go b/backend/internal/service/event_play_service.go new file mode 100644 index 0000000..e3fc5ef --- /dev/null +++ b/backend/internal/service/event_play_service.go @@ -0,0 +1,131 @@ +package service + +import ( + "context" + "net/http" + "strings" + + "cmr-backend/internal/apperr" + "cmr-backend/internal/store/postgres" +) + +type EventPlayService struct { + store *postgres.Store +} + +type EventPlayInput struct { + EventPublicID string + UserID string +} + +type EventPlayResult struct { + Event struct { + ID string `json:"id"` + Slug string `json:"slug"` + DisplayName string `json:"displayName"` + Summary *string `json:"summary,omitempty"` + Status string `json:"status"` + } `json:"event"` + Release *struct { + ID string `json:"id"` + ConfigLabel string `json:"configLabel"` + ManifestURL string `json:"manifestUrl"` + ManifestChecksumSha256 *string `json:"manifestChecksumSha256,omitempty"` + RouteCode *string `json:"routeCode,omitempty"` + } `json:"release,omitempty"` + ResolvedRelease *ResolvedReleaseView `json:"resolvedRelease,omitempty"` + Play struct { + CanLaunch bool `json:"canLaunch"` + PrimaryAction string `json:"primaryAction"` + Reason string `json:"reason"` + LaunchSource string `json:"launchSource,omitempty"` + OngoingSession *EntrySessionSummary `json:"ongoingSession,omitempty"` + RecentSession *EntrySessionSummary `json:"recentSession,omitempty"` + } `json:"play"` +} + +func NewEventPlayService(store *postgres.Store) *EventPlayService { + return &EventPlayService{store: store} +} + +func (s *EventPlayService) GetEventPlay(ctx context.Context, input EventPlayInput) (*EventPlayResult, error) { + input.EventPublicID = strings.TrimSpace(input.EventPublicID) + input.UserID = strings.TrimSpace(input.UserID) + if input.EventPublicID == "" { + return nil, apperr.New(http.StatusBadRequest, "invalid_params", "event id is required") + } + if input.UserID == "" { + return nil, apperr.New(http.StatusUnauthorized, "unauthorized", "user is required") + } + + event, err := s.store.GetEventByPublicID(ctx, input.EventPublicID) + if err != nil { + return nil, err + } + if event == nil { + return nil, apperr.New(http.StatusNotFound, "event_not_found", "event not found") + } + + sessions, err := s.store.ListSessionsByUserAndEvent(ctx, input.UserID, event.ID, 10) + if err != nil { + return nil, err + } + + result := &EventPlayResult{} + result.Event.ID = event.PublicID + result.Event.Slug = event.Slug + result.Event.DisplayName = event.DisplayName + result.Event.Summary = event.Summary + result.Event.Status = event.Status + if event.CurrentReleasePubID != nil && event.ConfigLabel != nil && event.ManifestURL != nil { + result.Release = &struct { + ID string `json:"id"` + ConfigLabel string `json:"configLabel"` + ManifestURL string `json:"manifestUrl"` + ManifestChecksumSha256 *string `json:"manifestChecksumSha256,omitempty"` + RouteCode *string `json:"routeCode,omitempty"` + }{ + ID: *event.CurrentReleasePubID, + ConfigLabel: *event.ConfigLabel, + ManifestURL: *event.ManifestURL, + ManifestChecksumSha256: event.ManifestChecksum, + RouteCode: event.RouteCode, + } + } + result.ResolvedRelease = buildResolvedReleaseFromEvent(event, LaunchSourceEventCurrentRelease) + + if len(sessions) > 0 { + recent := buildEntrySessionSummary(&sessions[0]) + result.Play.RecentSession = &recent + } + for i := range sessions { + if sessions[i].Status == "launched" || sessions[i].Status == "running" { + ongoing := buildEntrySessionSummary(&sessions[i]) + result.Play.OngoingSession = &ongoing + break + } + } + + canLaunch := event.Status == "active" && event.CurrentReleaseID != nil && event.ManifestURL != nil + result.Play.CanLaunch = canLaunch + if canLaunch { + result.Play.LaunchSource = LaunchSourceEventCurrentRelease + } + + switch { + case result.Play.OngoingSession != nil: + result.Play.PrimaryAction = "continue" + result.Play.Reason = "user has an ongoing session for this event" + case canLaunch: + result.Play.PrimaryAction = "start" + result.Play.Reason = "event is active and launchable" + case result.Play.RecentSession != nil: + result.Play.PrimaryAction = "review_last_result" + result.Play.Reason = "event is not launchable, but user has previous session history" + default: + result.Play.PrimaryAction = "unavailable" + result.Play.Reason = "event is not launchable" + } + + return result, nil +} diff --git a/backend/internal/service/event_service.go b/backend/internal/service/event_service.go new file mode 100644 index 0000000..833bbbf --- /dev/null +++ b/backend/internal/service/event_service.go @@ -0,0 +1,195 @@ +package service + +import ( + "context" + "net/http" + "strings" + "time" + + "cmr-backend/internal/apperr" + "cmr-backend/internal/platform/security" + "cmr-backend/internal/store/postgres" +) + +type EventService struct { + store *postgres.Store +} + +type EventDetailResult struct { + Event struct { + ID string `json:"id"` + Slug string `json:"slug"` + DisplayName string `json:"displayName"` + Summary *string `json:"summary,omitempty"` + Status string `json:"status"` + } `json:"event"` + Release *struct { + ID string `json:"id"` + ConfigLabel string `json:"configLabel"` + ManifestURL string `json:"manifestUrl"` + ManifestChecksumSha256 *string `json:"manifestChecksumSha256,omitempty"` + RouteCode *string `json:"routeCode,omitempty"` + } `json:"release,omitempty"` + ResolvedRelease *ResolvedReleaseView `json:"resolvedRelease,omitempty"` +} + +type LaunchEventInput struct { + EventPublicID string + UserID string + ReleaseID string `json:"releaseId,omitempty"` + ClientType string `json:"clientType"` + DeviceKey string `json:"deviceKey"` +} + +type LaunchEventResult struct { + Event struct { + ID string `json:"id"` + DisplayName string `json:"displayName"` + } `json:"event"` + Launch struct { + Source string `json:"source"` + ResolvedRelease *ResolvedReleaseView `json:"resolvedRelease,omitempty"` + Config struct { + ConfigURL string `json:"configUrl"` + ConfigLabel string `json:"configLabel"` + ConfigChecksumSha256 *string `json:"configChecksumSha256,omitempty"` + ReleaseID string `json:"releaseId"` + RouteCode *string `json:"routeCode,omitempty"` + } `json:"config"` + Business struct { + Source string `json:"source"` + EventID string `json:"eventId"` + SessionID string `json:"sessionId"` + SessionToken string `json:"sessionToken"` + SessionTokenExpiresAt string `json:"sessionTokenExpiresAt"` + RouteCode *string `json:"routeCode,omitempty"` + } `json:"business"` + } `json:"launch"` +} + +func NewEventService(store *postgres.Store) *EventService { + return &EventService{store: store} +} + +func (s *EventService) GetEventDetail(ctx context.Context, eventPublicID string) (*EventDetailResult, error) { + eventPublicID = strings.TrimSpace(eventPublicID) + if eventPublicID == "" { + return nil, apperr.New(http.StatusBadRequest, "invalid_params", "event id is required") + } + + event, err := s.store.GetEventByPublicID(ctx, eventPublicID) + if err != nil { + return nil, err + } + if event == nil { + return nil, apperr.New(http.StatusNotFound, "event_not_found", "event not found") + } + + result := &EventDetailResult{} + result.Event.ID = event.PublicID + result.Event.Slug = event.Slug + result.Event.DisplayName = event.DisplayName + result.Event.Summary = event.Summary + result.Event.Status = event.Status + + if event.CurrentReleasePubID != nil && event.ConfigLabel != nil && event.ManifestURL != nil { + result.Release = &struct { + ID string `json:"id"` + ConfigLabel string `json:"configLabel"` + ManifestURL string `json:"manifestUrl"` + ManifestChecksumSha256 *string `json:"manifestChecksumSha256,omitempty"` + RouteCode *string `json:"routeCode,omitempty"` + }{ + ID: *event.CurrentReleasePubID, + ConfigLabel: *event.ConfigLabel, + ManifestURL: *event.ManifestURL, + ManifestChecksumSha256: event.ManifestChecksum, + RouteCode: event.RouteCode, + } + } + result.ResolvedRelease = buildResolvedReleaseFromEvent(event, LaunchSourceEventCurrentRelease) + + return result, nil +} + +func (s *EventService) LaunchEvent(ctx context.Context, input LaunchEventInput) (*LaunchEventResult, error) { + input.EventPublicID = strings.TrimSpace(input.EventPublicID) + input.ReleaseID = strings.TrimSpace(input.ReleaseID) + input.DeviceKey = strings.TrimSpace(input.DeviceKey) + if err := validateClientType(input.ClientType); err != nil { + return nil, err + } + if input.EventPublicID == "" || input.UserID == "" || input.DeviceKey == "" { + return nil, apperr.New(http.StatusBadRequest, "invalid_params", "event id, user and deviceKey are required") + } + + event, err := s.store.GetEventByPublicID(ctx, input.EventPublicID) + if err != nil { + return nil, err + } + if event == nil { + return nil, apperr.New(http.StatusNotFound, "event_not_found", "event not found") + } + if event.Status != "active" { + return nil, apperr.New(http.StatusConflict, "event_not_launchable", "event is not active") + } + if event.CurrentReleaseID == nil || event.CurrentReleasePubID == nil || event.ConfigLabel == nil || event.ManifestURL == nil { + return nil, apperr.New(http.StatusConflict, "event_release_missing", "event does not have a published release") + } + if input.ReleaseID != "" && input.ReleaseID != *event.CurrentReleasePubID { + return nil, apperr.New(http.StatusConflict, "release_not_launchable", "requested release is not the current published release") + } + + tx, err := s.store.Begin(ctx) + if err != nil { + return nil, err + } + defer tx.Rollback(ctx) + + sessionPublicID, err := security.GeneratePublicID("sess") + if err != nil { + return nil, err + } + sessionToken, err := security.GenerateToken(32) + if err != nil { + return nil, err + } + sessionTokenExpiresAt := time.Now().UTC().Add(2 * time.Hour) + + session, err := s.store.CreateGameSession(ctx, tx, postgres.CreateGameSessionParams{ + SessionPublicID: sessionPublicID, + UserID: input.UserID, + EventID: event.ID, + EventReleaseID: *event.CurrentReleaseID, + DeviceKey: input.DeviceKey, + ClientType: input.ClientType, + RouteCode: event.RouteCode, + SessionTokenHash: security.HashText(sessionToken), + SessionTokenExpiresAt: sessionTokenExpiresAt, + }) + if err != nil { + return nil, err + } + + if err := tx.Commit(ctx); err != nil { + return nil, err + } + + result := &LaunchEventResult{} + result.Event.ID = event.PublicID + result.Event.DisplayName = event.DisplayName + result.Launch.Source = LaunchSourceEventCurrentRelease + result.Launch.ResolvedRelease = buildResolvedReleaseFromEvent(event, LaunchSourceEventCurrentRelease) + result.Launch.Config.ConfigURL = *event.ManifestURL + result.Launch.Config.ConfigLabel = *event.ConfigLabel + result.Launch.Config.ConfigChecksumSha256 = event.ManifestChecksum + result.Launch.Config.ReleaseID = *event.CurrentReleasePubID + result.Launch.Config.RouteCode = event.RouteCode + result.Launch.Business.Source = "direct-event" + result.Launch.Business.EventID = event.PublicID + result.Launch.Business.SessionID = session.SessionPublicID + result.Launch.Business.SessionToken = sessionToken + result.Launch.Business.SessionTokenExpiresAt = session.SessionTokenExpiresAt.Format(time.RFC3339) + result.Launch.Business.RouteCode = event.RouteCode + return result, nil +} diff --git a/backend/internal/service/home_service.go b/backend/internal/service/home_service.go new file mode 100644 index 0000000..d991bfa --- /dev/null +++ b/backend/internal/service/home_service.go @@ -0,0 +1,159 @@ +package service + +import ( + "context" + "net/http" + "strings" + "time" + + "cmr-backend/internal/apperr" + "cmr-backend/internal/store/postgres" +) + +type HomeService struct { + store *postgres.Store +} + +type ListCardsInput struct { + ChannelCode string + ChannelType string + PlatformAppID string + TenantCode string + Slot string + Limit int +} + +type CardResult struct { + ID string `json:"id"` + Type string `json:"type"` + Title string `json:"title"` + Subtitle *string `json:"subtitle,omitempty"` + CoverURL *string `json:"coverUrl,omitempty"` + DisplaySlot string `json:"displaySlot"` + DisplayPriority int `json:"displayPriority"` + Event *struct { + ID string `json:"id"` + DisplayName string `json:"displayName"` + Summary *string `json:"summary,omitempty"` + } `json:"event,omitempty"` + HTMLURL *string `json:"htmlUrl,omitempty"` +} + +type HomeResult struct { + Tenant struct { + ID string `json:"id"` + Code string `json:"code"` + Name string `json:"name"` + } `json:"tenant"` + Channel struct { + ID string `json:"id"` + Code string `json:"code"` + Type string `json:"type"` + PlatformAppID *string `json:"platformAppId,omitempty"` + DisplayName string `json:"displayName"` + Status string `json:"status"` + IsDefault bool `json:"isDefault"` + } `json:"channel"` + Cards []CardResult `json:"cards"` +} + +func NewHomeService(store *postgres.Store) *HomeService { + return &HomeService{store: store} +} + +func (s *HomeService) ListCards(ctx context.Context, input ListCardsInput) ([]CardResult, error) { + entry, err := s.resolveEntry(ctx, input) + if err != nil { + return nil, err + } + + cards, err := s.store.ListCardsForEntry(ctx, entry.TenantID, &entry.ID, normalizeSlot(input.Slot), time.Now().UTC(), input.Limit) + if err != nil { + return nil, err + } + return mapCards(cards), nil +} + +func (s *HomeService) GetHome(ctx context.Context, input ListCardsInput) (*HomeResult, error) { + entry, err := s.resolveEntry(ctx, input) + if err != nil { + return nil, err + } + + cards, err := s.store.ListCardsForEntry(ctx, entry.TenantID, &entry.ID, normalizeSlot(input.Slot), time.Now().UTC(), input.Limit) + if err != nil { + return nil, err + } + + result := &HomeResult{ + Cards: mapCards(cards), + } + result.Tenant.ID = entry.TenantID + result.Tenant.Code = entry.TenantCode + result.Tenant.Name = entry.TenantName + result.Channel.ID = entry.ID + result.Channel.Code = entry.ChannelCode + result.Channel.Type = entry.ChannelType + result.Channel.PlatformAppID = entry.PlatformAppID + result.Channel.DisplayName = entry.DisplayName + result.Channel.Status = entry.Status + result.Channel.IsDefault = entry.IsDefault + return result, nil +} + +func (s *HomeService) resolveEntry(ctx context.Context, input ListCardsInput) (*postgres.EntryChannel, error) { + entry, err := s.store.FindEntryChannel(ctx, postgres.FindEntryChannelParams{ + ChannelCode: strings.TrimSpace(input.ChannelCode), + ChannelType: strings.TrimSpace(input.ChannelType), + PlatformAppID: strings.TrimSpace(input.PlatformAppID), + TenantCode: strings.TrimSpace(input.TenantCode), + }) + if err != nil { + return nil, err + } + if entry == nil { + return nil, apperr.New(http.StatusNotFound, "entry_channel_not_found", "entry channel not found") + } + return entry, nil +} + +func normalizeSlot(slot string) string { + slot = strings.TrimSpace(slot) + if slot == "" { + return "home_primary" + } + return slot +} + +func mapCards(cards []postgres.Card) []CardResult { + results := make([]CardResult, 0, len(cards)) + for _, card := range cards { + item := CardResult{ + ID: card.PublicID, + Type: card.CardType, + Title: card.Title, + Subtitle: card.Subtitle, + CoverURL: card.CoverURL, + DisplaySlot: card.DisplaySlot, + DisplayPriority: card.DisplayPriority, + HTMLURL: card.HTMLURL, + } + if card.EventPublicID != nil || card.EventDisplayName != nil { + item.Event = &struct { + ID string `json:"id"` + DisplayName string `json:"displayName"` + Summary *string `json:"summary,omitempty"` + }{ + Summary: card.EventSummary, + } + if card.EventPublicID != nil { + item.Event.ID = *card.EventPublicID + } + if card.EventDisplayName != nil { + item.Event.DisplayName = *card.EventDisplayName + } + } + results = append(results, item) + } + return results +} diff --git a/backend/internal/service/me_service.go b/backend/internal/service/me_service.go new file mode 100644 index 0000000..8a44aa6 --- /dev/null +++ b/backend/internal/service/me_service.go @@ -0,0 +1,43 @@ +package service + +import ( + "context" + "net/http" + + "cmr-backend/internal/apperr" + "cmr-backend/internal/store/postgres" +) + +type MeService struct { + store *postgres.Store +} + +type MeResult struct { + ID string `json:"id"` + PublicID string `json:"publicId"` + Status string `json:"status"` + Nickname *string `json:"nickname,omitempty"` + AvatarURL *string `json:"avatarUrl,omitempty"` +} + +func NewMeService(store *postgres.Store) *MeService { + return &MeService{store: store} +} + +func (s *MeService) GetMe(ctx context.Context, userID string) (*MeResult, error) { + user, err := s.store.GetUserByID(ctx, s.store.Pool(), userID) + if err != nil { + return nil, err + } + if user == nil { + return nil, apperr.New(http.StatusNotFound, "user_not_found", "user not found") + } + + return &MeResult{ + ID: user.ID, + PublicID: user.PublicID, + Status: user.Status, + Nickname: user.Nickname, + AvatarURL: user.AvatarURL, + }, nil +} diff --git a/backend/internal/service/profile_service.go b/backend/internal/service/profile_service.go new file mode 100644 index 0000000..0e02c09 --- /dev/null +++ b/backend/internal/service/profile_service.go @@ -0,0 +1,119 @@ +package service + +import ( + "context" + "net/http" + + "cmr-backend/internal/apperr" + "cmr-backend/internal/store/postgres" +) + +type ProfileService struct { + store *postgres.Store +} + +type ProfileResult struct { + User struct { + ID string `json:"id"` + PublicID string `json:"publicId"` + Status string `json:"status"` + Nickname *string `json:"nickname,omitempty"` + AvatarURL *string `json:"avatarUrl,omitempty"` + } `json:"user"` + Bindings struct { + HasMobile bool `json:"hasMobile"` + HasWechatMini bool `json:"hasWechatMini"` + HasWechatUnion bool `json:"hasWechatUnion"` + Items []ProfileBindingItem `json:"items"` + } `json:"bindings"` + RecentSessions []EntrySessionSummary `json:"recentSessions"` +} + +type ProfileBindingItem struct { + IdentityType string `json:"identityType"` + Provider string `json:"provider"` + Status string `json:"status"` + CountryCode *string `json:"countryCode,omitempty"` + Mobile *string `json:"mobile,omitempty"` + MaskedLabel string `json:"maskedLabel"` +} + +func NewProfileService(store *postgres.Store) *ProfileService { + return &ProfileService{store: store} +} + +func (s *ProfileService) GetProfile(ctx context.Context, userID string) (*ProfileResult, error) { + if userID == "" { + return nil, apperr.New(http.StatusUnauthorized, "unauthorized", "user is required") + } + + user, err := s.store.GetUserByID(ctx, s.store.Pool(), userID) + if err != nil { + return nil, err + } + if user == nil { + return nil, apperr.New(http.StatusNotFound, "user_not_found", "user not found") + } + + identities, err := s.store.ListIdentitiesByUserID(ctx, userID) + if err != nil { + return nil, err + } + + sessions, err := s.store.ListSessionsByUserID(ctx, userID, 5) + if err != nil { + return nil, err + } + + result := &ProfileResult{} + result.User.ID = user.ID + result.User.PublicID = user.PublicID + result.User.Status = user.Status + result.User.Nickname = user.Nickname + result.User.AvatarURL = user.AvatarURL + + for _, identity := range identities { + item := ProfileBindingItem{ + IdentityType: identity.IdentityType, + Provider: identity.Provider, + Status: identity.Status, + CountryCode: identity.CountryCode, + Mobile: identity.Mobile, + MaskedLabel: maskIdentity(identity), + } + result.Bindings.Items = append(result.Bindings.Items, item) + + switch identity.Provider { + case "mobile": + result.Bindings.HasMobile = true + case "wechat_mini": + result.Bindings.HasWechatMini = true + case "wechat_unionid": + result.Bindings.HasWechatUnion = true + } + } + + for i := range sessions { + result.RecentSessions = append(result.RecentSessions, buildEntrySessionSummary(&sessions[i])) + } + + return result, nil +} + +func maskIdentity(identity postgres.LoginIdentity) string { + if identity.Provider == "mobile" && identity.Mobile != nil { + value := *identity.Mobile + if len(value) >= 7 { + return value[:3] + "****" + value[len(value)-4:] + } + return value + } + + if identity.Provider == "wechat_mini" { + return "WeChat Mini bound" + } + if identity.Provider == "wechat_unionid" { + return "WeChat Union bound" + } + return identity.Provider +} diff --git a/backend/internal/service/release_view.go b/backend/internal/service/release_view.go new file mode 100644 index 0000000..a605fab --- /dev/null +++ b/backend/internal/service/release_view.go @@ -0,0 +1,56 @@ +package service + +import "cmr-backend/internal/store/postgres" + +const ( + LaunchSourceEventCurrentRelease = "event_current_release" + LaunchModeManifestRelease = "manifest_release" +) + +type ResolvedReleaseView struct { + LaunchMode string `json:"launchMode"` + Source string `json:"source"` + EventID string `json:"eventId"` + ReleaseID string `json:"releaseId"` + ConfigLabel string `json:"configLabel"` + ManifestURL string `json:"manifestUrl"` + ManifestChecksumSha256 *string `json:"manifestChecksumSha256,omitempty"` + RouteCode *string `json:"routeCode,omitempty"` +} + +func buildResolvedReleaseFromEvent(event *postgres.Event, source string) *ResolvedReleaseView { + if event == nil || event.CurrentReleasePubID == nil || event.ConfigLabel == nil || event.ManifestURL == nil { + return nil + } + + return &ResolvedReleaseView{ + LaunchMode: LaunchModeManifestRelease, + Source: source, + EventID: event.PublicID, + ReleaseID: *event.CurrentReleasePubID, + ConfigLabel: *event.ConfigLabel, + ManifestURL: *event.ManifestURL, + ManifestChecksumSha256: event.ManifestChecksum, + RouteCode: event.RouteCode, + } +} + +func buildResolvedReleaseFromSession(session *postgres.Session, source string) *ResolvedReleaseView { + if session == nil || session.ReleasePublicID == nil || session.ConfigLabel == nil || session.ManifestURL == nil { + return nil + } + + view := &ResolvedReleaseView{ + LaunchMode: LaunchModeManifestRelease, + Source: source, + ReleaseID: *session.ReleasePublicID, + ConfigLabel: *session.ConfigLabel, + ManifestURL: *session.ManifestURL, + ManifestChecksumSha256: session.ManifestChecksum, + RouteCode: session.RouteCode, + } + if session.EventPublicID != nil { + view.EventID = *session.EventPublicID + } + return view +} diff --git a/backend/internal/service/result_service.go b/backend/internal/service/result_service.go new file mode 100644 index 0000000..a476720 --- /dev/null +++ b/backend/internal/service/result_service.go @@ -0,0 +1,94 @@ +package service + +import ( + "context" + "encoding/json" + "net/http" + + "cmr-backend/internal/apperr" + "cmr-backend/internal/store/postgres" +) + +type ResultService struct { + store *postgres.Store +} + +type SessionResultView struct { + Session EntrySessionSummary `json:"session"` + Result ResultSummaryPayload `json:"result"` +} + +type ResultSummaryPayload struct { + Status string `json:"status"` + FinalDurationSec *int `json:"finalDurationSec,omitempty"` + FinalScore *int `json:"finalScore,omitempty"` + CompletedControls *int `json:"completedControls,omitempty"` + TotalControls *int `json:"totalControls,omitempty"` + DistanceMeters *float64 `json:"distanceMeters,omitempty"` + AverageSpeedKmh *float64 `json:"averageSpeedKmh,omitempty"` + MaxHeartRateBpm *int `json:"maxHeartRateBpm,omitempty"` + Summary map[string]any `json:"summary,omitempty"` +} + +func NewResultService(store *postgres.Store) *ResultService { + return &ResultService{store: store} +} + +func (s *ResultService) GetSessionResult(ctx context.Context, sessionPublicID, userID string) (*SessionResultView, error) { + record, err := s.store.GetSessionResultByPublicID(ctx, sessionPublicID) + if err != nil { + return nil, err + } + if record == nil { + return nil, apperr.New(http.StatusNotFound, "session_not_found", "session not found") + } + if userID != "" && record.UserID != userID { + return nil, apperr.New(http.StatusForbidden, "session_forbidden", "session does not belong to current user") + } + return buildSessionResultView(record), nil +} + +func (s *ResultService) ListMyResults(ctx context.Context, userID string, limit int) ([]SessionResultView, error) { + if userID == "" { + return nil, apperr.New(http.StatusUnauthorized, "unauthorized", "user is required") + } + + records, err := s.store.ListSessionResultsByUserID(ctx, userID, limit) + if err != nil { + return nil, err + } + + results := make([]SessionResultView, 0, len(records)) + for i := range records { + results = append(results, *buildSessionResultView(&records[i])) + } + return results, nil +} + +func buildSessionResultView(record *postgres.SessionResultRecord) *SessionResultView { + view := &SessionResultView{ + Session: buildEntrySessionSummary(&record.Session), + Result: ResultSummaryPayload{ + Status: record.Status, + }, + } + + if record.Result != nil { + view.Result.Status = record.Result.ResultStatus + view.Result.FinalDurationSec = record.Result.FinalDurationSec + view.Result.FinalScore = record.Result.FinalScore + view.Result.CompletedControls = record.Result.CompletedControls + view.Result.TotalControls = record.Result.TotalControls + view.Result.DistanceMeters = record.Result.DistanceMeters + view.Result.AverageSpeedKmh = record.Result.AverageSpeedKmh + view.Result.MaxHeartRateBpm = record.Result.MaxHeartRateBpm + if record.Result.SummaryJSON != "" { + summary := map[string]any{} + if err := json.Unmarshal([]byte(record.Result.SummaryJSON), &summary); err == nil && len(summary) > 0 { + view.Result.Summary = summary + } + } + } + + return view +} diff --git a/backend/internal/service/session_service.go b/backend/internal/service/session_service.go new file mode 100644 index 0000000..cdda049 --- /dev/null +++ b/backend/internal/service/session_service.go @@ -0,0 +1,324 @@ +package service + +import ( + "context" + "encoding/json" + "net/http" + "strings" + "time" + + "cmr-backend/internal/apperr" + "cmr-backend/internal/platform/security" + "cmr-backend/internal/store/postgres" +) + +type SessionService struct { + store *postgres.Store +} + +type SessionResult struct { + Session struct { + ID string `json:"id"` + Status string `json:"status"` + ClientType string `json:"clientType"` + DeviceKey string `json:"deviceKey"` + RouteCode *string `json:"routeCode,omitempty"` + SessionTokenExpiresAt string `json:"sessionTokenExpiresAt"` + LaunchedAt string `json:"launchedAt"` + StartedAt *string `json:"startedAt,omitempty"` + EndedAt *string `json:"endedAt,omitempty"` + } `json:"session"` + Event struct { + ID string `json:"id"` + DisplayName string `json:"displayName"` + } `json:"event"` + ResolvedRelease *ResolvedReleaseView `json:"resolvedRelease,omitempty"` +} + +type SessionActionInput struct { + SessionPublicID string + SessionToken string `json:"sessionToken"` +} + +type FinishSessionInput struct { + SessionPublicID string + SessionToken string `json:"sessionToken"` + Status string `json:"status"` + Summary *SessionSummaryInput `json:"summary,omitempty"` +} + +type SessionSummaryInput struct { + FinalDurationSec *int `json:"finalDurationSec,omitempty"` + FinalScore *int `json:"finalScore,omitempty"` + CompletedControls *int `json:"completedControls,omitempty"` + TotalControls *int `json:"totalControls,omitempty"` + DistanceMeters *float64 `json:"distanceMeters,omitempty"` + AverageSpeedKmh *float64 `json:"averageSpeedKmh,omitempty"` + MaxHeartRateBpm *int `json:"maxHeartRateBpm,omitempty"` +} + +func NewSessionService(store *postgres.Store) *SessionService { + return &SessionService{store: store} +} + +func (s *SessionService) GetSession(ctx context.Context, sessionPublicID, userID string) (*SessionResult, error) { + sessionPublicID = strings.TrimSpace(sessionPublicID) + if sessionPublicID == "" { + return nil, apperr.New(http.StatusBadRequest, "invalid_params", "session id is required") + } + + session, err := s.store.GetSessionByPublicID(ctx, sessionPublicID) + if err != nil { + return nil, err + } + if session == nil { + return nil, apperr.New(http.StatusNotFound, "session_not_found", "session not found") + } + if userID != "" && session.UserID != userID { + return nil, apperr.New(http.StatusForbidden, "session_forbidden", "session does not belong to current user") + } + + return buildSessionResult(session), nil +} + +func (s *SessionService) ListMySessions(ctx context.Context, userID string, limit int) ([]SessionResult, error) { + if userID == "" { + return nil, apperr.New(http.StatusUnauthorized, "unauthorized", "user is required") + } + + sessions, err := s.store.ListSessionsByUserID(ctx, userID, limit) + if err != nil { + return nil, err + } + + results := make([]SessionResult, 0, len(sessions)) + for i := range sessions { + results = append(results, *buildSessionResult(&sessions[i])) + } + return results, nil +} + +func (s *SessionService) StartSession(ctx context.Context, input SessionActionInput) (*SessionResult, error) { + session, err := s.validateSessionAction(ctx, input.SessionPublicID, input.SessionToken) + if err != nil { + return nil, err + } + + if session.Status == "finished" || session.Status == "cancelled" || session.Status == "failed" { + return nil, apperr.New(http.StatusConflict, "session_not_startable", "session cannot be started") + } + + tx, err := s.store.Begin(ctx) + if err != nil { + return nil, err + } + defer tx.Rollback(ctx) + + locked, err := s.store.GetSessionByPublicIDForUpdate(ctx, tx, input.SessionPublicID) + if err != nil { + return nil, err + } + if locked == nil { + return nil, apperr.New(http.StatusNotFound, "session_not_found", "session not found") + } + if err := s.verifySessionToken(locked, input.SessionToken); err != nil { + return nil, err + } + if locked.Status == "finished" || locked.Status == "cancelled" || locked.Status == "failed" { + return nil, apperr.New(http.StatusConflict, "session_not_startable", "session cannot be started") + } + + if err := s.store.StartSession(ctx, tx, locked.ID); err != nil { + return nil, err + } + + updated, err := s.store.GetSessionByPublicIDForUpdate(ctx, tx, input.SessionPublicID) + if err != nil { + return nil, err + } + if err := tx.Commit(ctx); err != nil { + return nil, err + } + return buildSessionResult(updated), nil +} + +func (s *SessionService) FinishSession(ctx context.Context, input FinishSessionInput) (*SessionResult, error) { + input.Status = normalizeFinishStatus(input.Status) + session, err := s.validateSessionAction(ctx, input.SessionPublicID, input.SessionToken) + if err != nil { + return nil, err + } + + if session.Status == "finished" || session.Status == "cancelled" || session.Status == "failed" { + return buildSessionResult(session), nil + } + + tx, err := s.store.Begin(ctx) + if err != nil { + return nil, err + } + defer tx.Rollback(ctx) + + locked, err := s.store.GetSessionByPublicIDForUpdate(ctx, tx, input.SessionPublicID) + if err != nil { + return nil, err + } + if locked == nil { + return nil, apperr.New(http.StatusNotFound, "session_not_found", "session not found") + } + if err := s.verifySessionToken(locked, input.SessionToken); err != nil { + return nil, err + } + + if locked.Status == "finished" || locked.Status == "cancelled" || locked.Status == "failed" { + if err := tx.Commit(ctx); err != nil { + return nil, err + } + return buildSessionResult(locked), nil + } + + if err := s.store.FinishSession(ctx, tx, postgres.FinishSessionParams{ + SessionID: locked.ID, + Status: input.Status, + }); err != nil { + return nil, err + } + + updated, err := s.store.GetSessionByPublicIDForUpdate(ctx, tx, input.SessionPublicID) + if err != nil { + return nil, err + } + if _, err := s.store.UpsertSessionResult(ctx, tx, postgres.UpsertSessionResultParams{ + SessionID: updated.ID, + ResultStatus: input.Status, + Summary: buildSummaryMap(input.Summary), + FinalDurationSec: resolveDurationSeconds(updated, input.Summary), + FinalScore: summaryInt(input.Summary, func(v *SessionSummaryInput) *int { return v.FinalScore }), + CompletedControls: summaryInt(input.Summary, func(v *SessionSummaryInput) *int { return v.CompletedControls }), + TotalControls: summaryInt(input.Summary, func(v *SessionSummaryInput) *int { return v.TotalControls }), + DistanceMeters: summaryFloat(input.Summary, func(v *SessionSummaryInput) *float64 { return v.DistanceMeters }), + AverageSpeedKmh: summaryFloat(input.Summary, func(v *SessionSummaryInput) *float64 { return v.AverageSpeedKmh }), + MaxHeartRateBpm: summaryInt(input.Summary, func(v *SessionSummaryInput) *int { return v.MaxHeartRateBpm }), + }); err != nil { + return nil, err + } + if err := tx.Commit(ctx); err != nil { + return nil, err + } + return buildSessionResult(updated), nil +} + +func (s *SessionService) validateSessionAction(ctx context.Context, sessionPublicID, sessionToken string) (*postgres.Session, error) { + sessionPublicID = strings.TrimSpace(sessionPublicID) + sessionToken = strings.TrimSpace(sessionToken) + if sessionPublicID == "" || sessionToken == "" { + return nil, apperr.New(http.StatusBadRequest, "invalid_params", "session id and sessionToken are required") + } + + session, err := s.store.GetSessionByPublicID(ctx, sessionPublicID) + if err != nil { + return nil, err + } + if session == nil { + return nil, apperr.New(http.StatusNotFound, "session_not_found", "session not found") + } + if err := s.verifySessionToken(session, sessionToken); err != nil { + return nil, err + } + return session, nil +} + +func (s *SessionService) verifySessionToken(session *postgres.Session, sessionToken string) error { + if session.SessionTokenExpiresAt.Before(time.Now().UTC()) { + return apperr.New(http.StatusUnauthorized, "session_token_expired", "session token expired") + } + if session.SessionTokenHash != security.HashText(sessionToken) { + return apperr.New(http.StatusUnauthorized, "invalid_session_token", "invalid session token") + } + return nil +} + +func buildSessionResult(session *postgres.Session) *SessionResult { + result := &SessionResult{} + result.Session.ID = session.SessionPublicID + result.Session.Status = session.Status + result.Session.ClientType = session.ClientType + result.Session.DeviceKey = session.DeviceKey + result.Session.RouteCode = session.RouteCode + result.Session.SessionTokenExpiresAt = session.SessionTokenExpiresAt.Format(time.RFC3339) + result.Session.LaunchedAt = session.LaunchedAt.Format(time.RFC3339) + if session.StartedAt != nil { + value := session.StartedAt.Format(time.RFC3339) + result.Session.StartedAt = &value + } + if session.EndedAt != nil { + value := session.EndedAt.Format(time.RFC3339) + result.Session.EndedAt = &value + } + if session.EventPublicID != nil { + result.Event.ID = *session.EventPublicID + } + if session.EventDisplayName != nil { + result.Event.DisplayName = *session.EventDisplayName + } + result.ResolvedRelease = buildResolvedReleaseFromSession(session, LaunchSourceEventCurrentRelease) + return result +} + +func normalizeFinishStatus(value string) string { + switch strings.TrimSpace(value) { + case "failed": + return "failed" + case "cancelled": + return "cancelled" + default: + return "finished" + } +} + +func buildSummaryMap(summary *SessionSummaryInput) map[string]any { + if summary == nil { + return map[string]any{} + } + raw, err := json.Marshal(summary) + if err != nil { + return map[string]any{} + } + result := map[string]any{} + if err := json.Unmarshal(raw, &result); err != nil { + return map[string]any{} + } + return result +} + +func resolveDurationSeconds(session *postgres.Session, summary *SessionSummaryInput) *int { + if summary != nil && summary.FinalDurationSec != nil { + return summary.FinalDurationSec + } + if session.StartedAt != nil { + endAt := time.Now().UTC() + if session.EndedAt != nil { + endAt = *session.EndedAt + } + seconds := int(endAt.Sub(*session.StartedAt).Seconds()) + if seconds < 0 { + seconds = 0 + } + return &seconds + } + return nil +} + +func summaryInt(summary *SessionSummaryInput, getter func(*SessionSummaryInput) *int) *int { + if summary == nil { + return nil + } + return getter(summary) +} + +func summaryFloat(summary *SessionSummaryInput, getter func(*SessionSummaryInput) *float64) *float64 { + if summary == nil { + return nil + } + return getter(summary) +} diff --git a/backend/internal/service/timeutil.go b/backend/internal/service/timeutil.go new file mode 100644 index 0000000..d913bd8 --- /dev/null +++ b/backend/internal/service/timeutil.go @@ -0,0 +1,9 @@ +package service + +import "time" + +const timeRFC3339 = time.RFC3339 + +func nowUTC() time.Time { + return time.Now().UTC() +} diff --git a/backend/internal/store/postgres/auth_store.go b/backend/internal/store/postgres/auth_store.go new file mode 100644 index 0000000..3c3c4bd --- /dev/null +++ b/backend/internal/store/postgres/auth_store.go @@ -0,0 +1,310 @@ +package postgres + +import ( + "context" + "encoding/json" + "errors" + "fmt" + "net/http" + "time" + + "cmr-backend/internal/apperr" + + "github.com/jackc/pgx/v5" +) + +type SMSCodeMeta struct { + ID string + CodeHash string + ExpiresAt time.Time + CooldownUntil time.Time +} + +type CreateSMSCodeParams struct { + Scene string + CountryCode string + Mobile string + ClientType string + DeviceKey string + CodeHash string + ProviderName string + ProviderDebug map[string]any + ExpiresAt time.Time + CooldownUntil time.Time +} + +type CreateMobileIdentityParams struct { + UserID string + IdentityType string + Provider string + ProviderSubj string + CountryCode string + Mobile string +} + +type CreateIdentityParams struct { + UserID string + IdentityType string + Provider string + ProviderSubj string + CountryCode *string + Mobile *string + ProfileJSON string +} + +type CreateRefreshTokenParams struct { + UserID string + ClientType string + DeviceKey string + TokenHash string + ExpiresAt time.Time +} + +type RefreshTokenRecord struct { + ID string + UserID string + ClientType string + DeviceKey *string + ExpiresAt time.Time + IsRevoked bool +} + +func (s *Store) GetLatestSMSCodeMeta(ctx context.Context, countryCode, mobile, clientType, scene string) (*SMSCodeMeta, error) { + row := s.pool.QueryRow(ctx, ` + SELECT id, code_hash, expires_at, cooldown_until + FROM auth_sms_codes + WHERE country_code = $1 AND mobile = $2 AND client_type = $3 AND scene = $4 + ORDER BY created_at DESC + LIMIT 1 + `, countryCode, mobile, clientType, scene) + + var record SMSCodeMeta + err := row.Scan(&record.ID, &record.CodeHash, &record.ExpiresAt, &record.CooldownUntil) + if errors.Is(err, pgx.ErrNoRows) { + return nil, nil + } + if err != nil { + return nil, fmt.Errorf("query latest sms code meta: %w", err) + } + return &record, nil +} + +func (s *Store) CreateSMSCode(ctx context.Context, params CreateSMSCodeParams) error { + payload, err := json.Marshal(map[string]any{ + "provider": params.ProviderName, + "debug": params.ProviderDebug, + }) + if err != nil { + return err + } + + _, err = s.pool.Exec(ctx, ` + INSERT INTO auth_sms_codes ( + scene, country_code, mobile, client_type, device_key, code_hash, + provider_payload_jsonb, expires_at, cooldown_until + ) + VALUES ($1, $2, $3, $4, $5, $6, $7::jsonb, $8, $9) + `, params.Scene, params.CountryCode, params.Mobile, params.ClientType, params.DeviceKey, params.CodeHash, string(payload), params.ExpiresAt, params.CooldownUntil) + if err != nil { + return fmt.Errorf("insert sms code: %w", err) + } + return nil +} + +func (s *Store) GetLatestValidSMSCode(ctx context.Context, countryCode, mobile, clientType, scene string) (*SMSCodeMeta, error) { + row := s.pool.QueryRow(ctx, ` + SELECT id, code_hash, expires_at, cooldown_until + FROM auth_sms_codes + WHERE country_code = $1 + AND mobile = $2 + AND client_type = $3 + AND scene = $4 + AND consumed_at IS NULL + AND expires_at > NOW() + ORDER BY created_at DESC + LIMIT 1 + `, countryCode, mobile, clientType, scene) + + var record SMSCodeMeta + err := row.Scan(&record.ID, &record.CodeHash, &record.ExpiresAt, &record.CooldownUntil) + if errors.Is(err, pgx.ErrNoRows) { + return nil, nil + } + if err != nil { + return nil, fmt.Errorf("query latest valid sms code: %w", err) + } + return &record, nil +} + +func (s *Store) ConsumeSMSCode(ctx context.Context, tx Tx, id string) (bool, error) { + commandTag, err := tx.Exec(ctx, ` + UPDATE auth_sms_codes + SET consumed_at = NOW() + WHERE id = $1 AND consumed_at IS NULL + `, id) + if err != nil { + return false, fmt.Errorf("consume sms code: %w", err) + } + return commandTag.RowsAffected() == 1, nil +} + +func (s *Store) CreateMobileIdentity(ctx context.Context, tx Tx, params CreateMobileIdentityParams) error { + countryCode := params.CountryCode + mobile := params.Mobile + return s.CreateIdentity(ctx, tx, CreateIdentityParams{ + UserID: params.UserID, + IdentityType: params.IdentityType, + Provider: params.Provider, + ProviderSubj: params.ProviderSubj, + CountryCode: &countryCode, + Mobile: &mobile, + ProfileJSON: "{}", + }) +} + +func (s *Store) CreateIdentity(ctx context.Context, tx Tx, params CreateIdentityParams) error { + _, err := tx.Exec(ctx, ` + INSERT INTO login_identities ( + user_id, identity_type, provider, provider_subject, country_code, mobile, status, profile_jsonb + ) + VALUES ($1, $2, $3, $4, $5, $6, 'active', $7::jsonb) + ON CONFLICT (provider, provider_subject) DO NOTHING + `, params.UserID, params.IdentityType, params.Provider, params.ProviderSubj, params.CountryCode, params.Mobile, zeroJSON(params.ProfileJSON)) + if err != nil { + return fmt.Errorf("create identity: %w", err) + } + return nil +} + +func (s *Store) FindUserByProviderSubject(ctx context.Context, tx Tx, provider, providerSubject string) (*User, error) { + row := tx.QueryRow(ctx, ` + SELECT u.id, u.user_public_id, u.status, u.nickname, u.avatar_url + FROM users u + JOIN login_identities li ON li.user_id = u.id + WHERE li.provider = $1 + AND li.provider_subject = $2 + AND li.status = 'active' + LIMIT 1 + `, provider, providerSubject) + return scanUser(row) +} + +func (s *Store) CreateRefreshToken(ctx context.Context, tx Tx, params CreateRefreshTokenParams) (string, error) { + row := tx.QueryRow(ctx, ` + INSERT INTO auth_refresh_tokens (user_id, client_type, device_key, token_hash, expires_at) + VALUES ($1, $2, NULLIF($3, ''), $4, $5) + RETURNING id + `, params.UserID, params.ClientType, params.DeviceKey, params.TokenHash, params.ExpiresAt) + + var id string + if err := row.Scan(&id); err != nil { + return "", fmt.Errorf("create refresh token: %w", err) + } + return id, nil +} + +func (s *Store) GetRefreshTokenForUpdate(ctx context.Context, tx Tx, tokenHash string) (*RefreshTokenRecord, error) { + row := tx.QueryRow(ctx, ` + SELECT id, user_id, client_type, device_key, expires_at, revoked_at IS NOT NULL + FROM auth_refresh_tokens + WHERE token_hash = $1 + FOR UPDATE + `, tokenHash) + + var record RefreshTokenRecord + err := row.Scan(&record.ID, &record.UserID, &record.ClientType, &record.DeviceKey, &record.ExpiresAt, &record.IsRevoked) + if errors.Is(err, pgx.ErrNoRows) { + return nil, nil + } + if err != nil { + return nil, fmt.Errorf("query refresh token for update: %w", err) + } + return &record, nil +} + +func (s *Store) RotateRefreshToken(ctx context.Context, tx Tx, oldTokenID, newTokenID string) error { + _, err := tx.Exec(ctx, ` + UPDATE auth_refresh_tokens + SET revoked_at = NOW(), replaced_by_token_id = $2 + WHERE id = $1 + `, oldTokenID, newTokenID) + if err != nil { + return fmt.Errorf("rotate refresh token: %w", err) + } + return nil +} + +func (s *Store) RevokeRefreshToken(ctx context.Context, tokenHash string) error { + commandTag, err := s.pool.Exec(ctx, ` + UPDATE auth_refresh_tokens + SET revoked_at = COALESCE(revoked_at, NOW()) + WHERE token_hash = $1 + `, tokenHash) + if err != nil { + return fmt.Errorf("revoke refresh token: %w", err) + } + if commandTag.RowsAffected() == 0 { + return apperr.New(http.StatusNotFound, "refresh_token_not_found", "refresh token not found") + } + return nil +} + +func (s *Store) RevokeRefreshTokensByUserID(ctx context.Context, tx Tx, userID string) error { + _, err := tx.Exec(ctx, ` + UPDATE auth_refresh_tokens + SET revoked_at = COALESCE(revoked_at, NOW()) + WHERE user_id = $1 + `, userID) + if err != nil { + return fmt.Errorf("revoke refresh tokens by user id: %w", err) + } + return nil +} + +func (s *Store) TransferNonMobileIdentities(ctx context.Context, tx Tx, sourceUserID, targetUserID string) error { + if sourceUserID == targetUserID { + return nil + } + + _, err := tx.Exec(ctx, ` + INSERT INTO login_identities ( + user_id, identity_type, provider, provider_subject, country_code, mobile, status, profile_jsonb, created_at, updated_at + ) + SELECT + $2, + li.identity_type, + li.provider, + li.provider_subject, + li.country_code, + li.mobile, + li.status, + li.profile_jsonb, + li.created_at, + li.updated_at + FROM login_identities li + WHERE li.user_id = $1 + AND li.provider <> 'mobile' + ON CONFLICT (provider, provider_subject) DO NOTHING + `, sourceUserID, targetUserID) + if err != nil { + return fmt.Errorf("copy non-mobile identities: %w", err) + } + + _, err = tx.Exec(ctx, ` + DELETE FROM login_identities + WHERE user_id = $1 + AND provider <> 'mobile' + `, sourceUserID) + if err != nil { + return fmt.Errorf("delete source non-mobile identities: %w", err) + } + + return nil +} + +func zeroJSON(value string) string { + if value == "" { + return "{}" + } + return value +} diff --git a/backend/internal/store/postgres/card_store.go b/backend/internal/store/postgres/card_store.go new file mode 100644 index 0000000..394d35b --- /dev/null +++ b/backend/internal/store/postgres/card_store.go @@ -0,0 +1,93 @@ +package postgres + +import ( + "context" + "fmt" + "time" +) + +type Card struct { + ID string + PublicID string + CardType string + Title string + Subtitle *string + CoverURL *string + DisplaySlot string + DisplayPriority int + EntryChannelID *string + EventPublicID *string + EventDisplayName *string + EventSummary *string + HTMLURL *string +} + +func (s *Store) ListCardsForEntry(ctx context.Context, tenantID string, entryChannelID *string, slot string, now time.Time, limit int) ([]Card, error) { + if limit <= 0 || limit > 100 { + limit = 20 + } + if slot == "" { + slot = "home_primary" + } + + rows, err := s.pool.Query(ctx, ` + SELECT + c.id, + c.card_public_id, + c.card_type, + c.title, + c.subtitle, + c.cover_url, + c.display_slot, + c.display_priority, + c.entry_channel_id, + e.event_public_id, + e.display_name, + e.summary, + c.html_url + FROM cards c + LEFT JOIN events e ON e.id = c.event_id + WHERE c.tenant_id = $1 + AND ($2::uuid IS NULL OR c.entry_channel_id = $2 OR c.entry_channel_id IS NULL) + AND c.display_slot = $3 + AND c.status = 'active' + AND (c.starts_at IS NULL OR c.starts_at <= $4) + AND (c.ends_at IS NULL OR c.ends_at >= $4) + ORDER BY + CASE WHEN $2::uuid IS NOT NULL AND c.entry_channel_id = $2 THEN 0 ELSE 1 END, + c.display_priority DESC, + c.created_at ASC + LIMIT $5 + `, tenantID, entryChannelID, slot, now, limit) + if err != nil { + return nil, fmt.Errorf("list cards for entry: %w", err) + } + defer rows.Close() + + var cards []Card + for rows.Next() { + var card Card + if err := rows.Scan( + &card.ID, + &card.PublicID, + &card.CardType, + &card.Title, + &card.Subtitle, + &card.CoverURL, + &card.DisplaySlot, + &card.DisplayPriority, + &card.EntryChannelID, + &card.EventPublicID, + &card.EventDisplayName, + &card.EventSummary, + &card.HTMLURL, + ); err != nil { + return nil, fmt.Errorf("scan card: %w", err) + } + cards = append(cards, card) + } + if err := rows.Err(); err != nil { + return nil, fmt.Errorf("iterate cards: %w", err) + } + return cards, nil +} diff --git a/backend/internal/store/postgres/config_store.go b/backend/internal/store/postgres/config_store.go new file mode 100644 index 0000000..5f03303 --- /dev/null +++ b/backend/internal/store/postgres/config_store.go @@ -0,0 +1,323 @@ +package postgres + +import ( + "context" + "encoding/json" + "errors" + "fmt" + + "github.com/jackc/pgx/v5" +) + +type EventConfigSource struct { + ID string + EventID string + SourceVersionNo int + SourceKind string + SchemaID string + SchemaVersion string + Status string + SourceJSON string + Notes *string +} + +type EventConfigBuild struct { + ID string + EventID string + SourceID string + BuildNo int + BuildStatus string + BuildLog *string + ManifestJSON string + AssetIndexJSON string +} + +type EventReleaseAsset struct { + ID string + EventReleaseID string + AssetType string + AssetKey string + AssetPath *string + AssetURL string + Checksum *string + SizeBytes *int64 + MetaJSON string +} + +type UpsertEventConfigSourceParams struct { + EventID string + SourceVersionNo int + SourceKind string + SchemaID string + SchemaVersion string + Status string + Source map[string]any + Notes *string +} + +type UpsertEventConfigBuildParams struct { + EventID string + SourceID string + BuildNo int + BuildStatus string + BuildLog *string + Manifest map[string]any + AssetIndex []map[string]any +} + +type UpsertEventReleaseAssetParams struct { + EventReleaseID string + AssetType string + AssetKey string + AssetPath *string + AssetURL string + Checksum *string + SizeBytes *int64 + Meta map[string]any +} + +func (s *Store) UpsertEventConfigSource(ctx context.Context, tx Tx, params UpsertEventConfigSourceParams) (*EventConfigSource, error) { + sourceJSON, err := json.Marshal(params.Source) + if err != nil { + return nil, fmt.Errorf("marshal event config source: %w", err) + } + + row := tx.QueryRow(ctx, ` + INSERT INTO event_config_sources ( + event_id, source_version_no, source_kind, schema_id, schema_version, status, source_jsonb, notes + ) + VALUES ($1, $2, $3, $4, $5, $6, $7::jsonb, $8) + ON CONFLICT (event_id, source_version_no) DO UPDATE SET + source_kind = EXCLUDED.source_kind, + schema_id = EXCLUDED.schema_id, + schema_version = EXCLUDED.schema_version, + status = EXCLUDED.status, + source_jsonb = EXCLUDED.source_jsonb, + notes = EXCLUDED.notes + RETURNING id, event_id, source_version_no, source_kind, schema_id, schema_version, status, source_jsonb::text, notes + `, params.EventID, params.SourceVersionNo, params.SourceKind, params.SchemaID, params.SchemaVersion, params.Status, string(sourceJSON), params.Notes) + + var item EventConfigSource + if err := row.Scan( + &item.ID, + &item.EventID, + &item.SourceVersionNo, + &item.SourceKind, + &item.SchemaID, + &item.SchemaVersion, + &item.Status, + &item.SourceJSON, + &item.Notes, + ); err != nil { + return nil, fmt.Errorf("upsert event config source: %w", err) + } + return &item, nil +} + +func (s *Store) UpsertEventConfigBuild(ctx context.Context, tx Tx, params UpsertEventConfigBuildParams) (*EventConfigBuild, error) { + manifestJSON, err := json.Marshal(params.Manifest) + if err != nil { + return nil, fmt.Errorf("marshal event config manifest: %w", err) + } + assetIndexJSON, err := json.Marshal(params.AssetIndex) + if err != nil { + return nil, fmt.Errorf("marshal event config asset index: %w", err) + } + + row := tx.QueryRow(ctx, ` + INSERT INTO event_config_builds ( + event_id, source_id, build_no, build_status, build_log, manifest_jsonb, asset_index_jsonb + ) + VALUES ($1, $2, $3, $4, $5, $6::jsonb, $7::jsonb) + ON CONFLICT (event_id, build_no) DO UPDATE SET + source_id = EXCLUDED.source_id, + build_status = EXCLUDED.build_status, + build_log = EXCLUDED.build_log, + manifest_jsonb = EXCLUDED.manifest_jsonb, + asset_index_jsonb = EXCLUDED.asset_index_jsonb + RETURNING id, event_id, source_id, build_no, build_status, build_log, manifest_jsonb::text, asset_index_jsonb::text + `, params.EventID, params.SourceID, params.BuildNo, params.BuildStatus, params.BuildLog, string(manifestJSON), string(assetIndexJSON)) + + var item EventConfigBuild + if err := row.Scan( + &item.ID, + &item.EventID, + &item.SourceID, + &item.BuildNo, + &item.BuildStatus, + &item.BuildLog, + &item.ManifestJSON, + &item.AssetIndexJSON, + ); err != nil { + return nil, fmt.Errorf("upsert event config build: %w", err) + } + return &item, nil +} + +func (s *Store) AttachBuildToRelease(ctx context.Context, tx Tx, releaseID, buildID string) error { + if _, err := tx.Exec(ctx, ` + UPDATE event_releases + SET build_id = $2 + WHERE id = $1 + `, releaseID, buildID); err != nil { + return fmt.Errorf("attach build to release: %w", err) + } + return nil +} + +func (s *Store) ReplaceEventReleaseAssets(ctx context.Context, tx Tx, eventReleaseID string, assets []UpsertEventReleaseAssetParams) error { + if _, err := tx.Exec(ctx, `DELETE FROM event_release_assets WHERE event_release_id = $1`, eventReleaseID); err != nil { + return fmt.Errorf("clear event release assets: %w", err) + } + + for _, asset := range assets { + metaJSON, err := json.Marshal(asset.Meta) + if err != nil { + return fmt.Errorf("marshal event release asset meta: %w", err) + } + if _, err := tx.Exec(ctx, ` + INSERT INTO event_release_assets ( + event_release_id, asset_type, asset_key, asset_path, asset_url, checksum, size_bytes, meta_jsonb + ) + VALUES ($1, $2, $3, $4, $5, $6, $7, $8::jsonb) + `, eventReleaseID, asset.AssetType, asset.AssetKey, asset.AssetPath, asset.AssetURL, asset.Checksum, asset.SizeBytes, string(metaJSON)); err != nil { + return fmt.Errorf("insert event release asset: %w", err) + } + } + return nil +} + +func (s *Store) NextEventConfigSourceVersion(ctx context.Context, eventID string) (int, error) { + var next int + if err := s.pool.QueryRow(ctx, ` + SELECT COALESCE(MAX(source_version_no), 0) + 1 + FROM event_config_sources + WHERE event_id = $1 + `, eventID).Scan(&next); err != nil { + return 0, fmt.Errorf("next event config source version: %w", err) + } + return next, nil +} + +func (s *Store) NextEventConfigBuildNo(ctx context.Context, eventID string) (int, error) { + var next int + if err := s.pool.QueryRow(ctx, ` + SELECT COALESCE(MAX(build_no), 0) + 1 + FROM event_config_builds + WHERE event_id = $1 + `, eventID).Scan(&next); err != nil { + return 0, fmt.Errorf("next event config build no: %w", err) + } + return next, nil +} + +func (s *Store) ListEventConfigSourcesByEventID(ctx context.Context, eventID string, limit int) ([]EventConfigSource, error) { + if limit <= 0 || limit > 100 { + limit = 20 + } + rows, err := s.pool.Query(ctx, ` + SELECT id, event_id, source_version_no, source_kind, schema_id, schema_version, status, source_jsonb::text, notes + FROM event_config_sources + WHERE event_id = $1 + ORDER BY source_version_no DESC + LIMIT $2 + `, eventID, limit) + if err != nil { + return nil, fmt.Errorf("list event config sources: %w", err) + } + defer rows.Close() + + var items []EventConfigSource + for rows.Next() { + item, err := scanEventConfigSourceFromRows(rows) + if err != nil { + return nil, err + } + items = append(items, *item) + } + if err := rows.Err(); err != nil { + return nil, fmt.Errorf("iterate event config sources: %w", err) + } + return items, nil +} + +func (s *Store) GetEventConfigSourceByID(ctx context.Context, sourceID string) (*EventConfigSource, error) { + row := s.pool.QueryRow(ctx, ` + SELECT id, event_id, source_version_no, source_kind, schema_id, schema_version, status, source_jsonb::text, notes + FROM event_config_sources + WHERE id = $1 + LIMIT 1 + `, sourceID) + return scanEventConfigSource(row) +} + +func (s *Store) GetEventConfigBuildByID(ctx context.Context, buildID string) (*EventConfigBuild, error) { + row := s.pool.QueryRow(ctx, ` + SELECT id, event_id, source_id, build_no, build_status, build_log, manifest_jsonb::text, asset_index_jsonb::text + FROM event_config_builds + WHERE id = $1 + LIMIT 1 + `, buildID) + return scanEventConfigBuild(row) +} + +func scanEventConfigSource(row pgx.Row) (*EventConfigSource, error) { + var item EventConfigSource + err := row.Scan( + &item.ID, + &item.EventID, + &item.SourceVersionNo, + &item.SourceKind, + &item.SchemaID, + &item.SchemaVersion, + &item.Status, + &item.SourceJSON, + &item.Notes, + ) + if errors.Is(err, pgx.ErrNoRows) { + return nil, nil + } + if err != nil { + return nil, fmt.Errorf("scan event config source: %w", err) + } + return &item, nil +} + +func scanEventConfigSourceFromRows(rows pgx.Rows) (*EventConfigSource, error) { + var item EventConfigSource + if err := rows.Scan( + &item.ID, + &item.EventID, + &item.SourceVersionNo, + &item.SourceKind, + &item.SchemaID, + &item.SchemaVersion, + &item.Status, + &item.SourceJSON, + &item.Notes, + ); err != nil { + return nil, fmt.Errorf("scan event config source row: %w", err) + } + return &item, nil +} + +func scanEventConfigBuild(row pgx.Row) (*EventConfigBuild, error) { + var item EventConfigBuild + err := row.Scan( + &item.ID, + &item.EventID, + &item.SourceID, + &item.BuildNo, + &item.BuildStatus, + &item.BuildLog, + &item.ManifestJSON, + &item.AssetIndexJSON, + ) + if errors.Is(err, pgx.ErrNoRows) { + return nil, nil + } + if err != nil { + return nil, fmt.Errorf("scan event config build: %w", err) + } + return &item, nil +} diff --git a/backend/internal/store/postgres/db.go b/backend/internal/store/postgres/db.go new file mode 100644 index 0000000..6355119 --- /dev/null +++ b/backend/internal/store/postgres/db.go @@ -0,0 +1,46 @@ +package postgres + +import ( + "context" + "fmt" + + "github.com/jackc/pgx/v5" + "github.com/jackc/pgx/v5/pgxpool" +) + +type Store struct { + pool *pgxpool.Pool +} + +type Tx = pgx.Tx + +func Open(ctx context.Context, databaseURL string) (*pgxpool.Pool, error) { + pool, err := pgxpool.New(ctx, databaseURL) + if err != nil { + return nil, fmt.Errorf("open postgres pool: %w", err) + } + + if err := pool.Ping(ctx); err != nil { + pool.Close() + return nil, fmt.Errorf("ping postgres: %w", err) + } + return pool, nil +} + +func NewStore(pool *pgxpool.Pool) *Store { + return &Store{pool: pool} +} + +func (s *Store) Pool() *pgxpool.Pool { + return s.pool +} + +func (s *Store) Close() { + if s.pool != nil { + s.pool.Close() + } +} + +func (s *Store) Begin(ctx context.Context) (pgx.Tx, error) { + return s.pool.Begin(ctx) +} diff --git a/backend/internal/store/postgres/dev_store.go b/backend/internal/store/postgres/dev_store.go new file mode 100644 index 0000000..e0d853b --- /dev/null +++ b/backend/internal/store/postgres/dev_store.go @@ -0,0 +1,324 @@ +package postgres + +import ( + "context" + "fmt" +) + +type DemoBootstrapSummary struct { + TenantCode string `json:"tenantCode"` + ChannelCode string `json:"channelCode"` + EventID string `json:"eventId"` + ReleaseID string `json:"releaseId"` + SourceID string `json:"sourceId"` + BuildID string `json:"buildId"` + CardID string `json:"cardId"` +} + +func (s *Store) EnsureDemoData(ctx context.Context) (*DemoBootstrapSummary, error) { + tx, err := s.Begin(ctx) + if err != nil { + return nil, err + } + defer tx.Rollback(ctx) + + var tenantID string + if err := tx.QueryRow(ctx, ` + INSERT INTO tenants (tenant_code, name, status) + VALUES ('tenant_demo', 'Demo Tenant', 'active') + ON CONFLICT (tenant_code) DO UPDATE SET + name = EXCLUDED.name, + status = EXCLUDED.status + RETURNING id + `).Scan(&tenantID); err != nil { + return nil, fmt.Errorf("ensure demo tenant: %w", err) + } + + var channelID string + if err := tx.QueryRow(ctx, ` + INSERT INTO entry_channels ( + tenant_id, channel_code, channel_type, platform_app_id, display_name, status, is_default + ) + VALUES ($1, 'mini-demo', 'wechat_mini', 'wx-demo-appid', 'Demo Mini Channel', 'active', true) + ON CONFLICT (tenant_id, channel_code) DO UPDATE SET + channel_type = EXCLUDED.channel_type, + platform_app_id = EXCLUDED.platform_app_id, + display_name = EXCLUDED.display_name, + status = EXCLUDED.status, + is_default = EXCLUDED.is_default + RETURNING id + `, tenantID).Scan(&channelID); err != nil { + return nil, fmt.Errorf("ensure demo entry channel: %w", err) + } + + var eventID string + if err := tx.QueryRow(ctx, ` + INSERT INTO events ( + tenant_id, event_public_id, slug, display_name, summary, status + ) + VALUES ($1, 'evt_demo_001', 'demo-city-run', 'Demo City Run', 'Launch flow demo event', 'active') + ON CONFLICT (event_public_id) DO UPDATE SET + tenant_id = EXCLUDED.tenant_id, + slug = EXCLUDED.slug, + display_name = EXCLUDED.display_name, + summary = EXCLUDED.summary, + status = EXCLUDED.status + RETURNING id + `, tenantID).Scan(&eventID); err != nil { + return nil, fmt.Errorf("ensure demo event: %w", err) + } + + var releaseRow struct { + ID string + PublicID string + } + if err := tx.QueryRow(ctx, ` + INSERT INTO event_releases ( + release_public_id, + event_id, + release_no, + config_label, + manifest_url, + manifest_checksum_sha256, + route_code, + status + ) + VALUES ( + 'rel_demo_001', + $1, + 1, + 'Demo Config v1', + 'https://oss-mbh5.colormaprun.com/gotomars/event/classic-sequential.json', + 'demo-checksum-001', + 'route-demo-001', + 'published' + ) + ON CONFLICT (release_public_id) DO UPDATE SET + event_id = EXCLUDED.event_id, + config_label = EXCLUDED.config_label, + manifest_url = EXCLUDED.manifest_url, + manifest_checksum_sha256 = EXCLUDED.manifest_checksum_sha256, + route_code = EXCLUDED.route_code, + status = EXCLUDED.status + RETURNING id, release_public_id + `, eventID).Scan(&releaseRow.ID, &releaseRow.PublicID); err != nil { + return nil, fmt.Errorf("ensure demo release: %w", err) + } + + if _, err := tx.Exec(ctx, ` + UPDATE events + SET current_release_id = $2 + WHERE id = $1 + `, eventID, releaseRow.ID); err != nil { + return nil, fmt.Errorf("attach demo release: %w", err) + } + + sourceNotes := "demo source config imported from local event sample" + source, err := s.UpsertEventConfigSource(ctx, tx, UpsertEventConfigSourceParams{ + EventID: eventID, + SourceVersionNo: 1, + SourceKind: "event_bundle", + SchemaID: "event-source", + SchemaVersion: "1", + Status: "active", + Notes: &sourceNotes, + Source: map[string]any{ + "app": map[string]any{ + "id": "sample-classic-001", + "title": "顺序赛示例", + }, + "branding": map[string]any{ + "tenantCode": "tenant_demo", + "entryChannel": "mini-demo", + }, + "map": map[string]any{ + "tiles": "../map/lxcb-001/tiles/", + "mapmeta": "../map/lxcb-001/tiles/meta.json", + }, + "playfield": map[string]any{ + "kind": "course", + "source": map[string]any{ + "type": "kml", + "url": "../kml/lxcb-001/10/c01.kml", + }, + }, + "game": map[string]any{ + "mode": "classic-sequential", + }, + "content": map[string]any{ + "h5Template": "content-h5-test-template.html", + }, + }, + }) + if err != nil { + return nil, fmt.Errorf("ensure demo event config source: %w", err) + } + + buildLog := "demo build generated from sample classic-sequential.json" + build, err := s.UpsertEventConfigBuild(ctx, tx, UpsertEventConfigBuildParams{ + EventID: eventID, + SourceID: source.ID, + BuildNo: 1, + BuildStatus: "success", + BuildLog: &buildLog, + Manifest: map[string]any{ + "schemaVersion": "1", + "releaseId": "rel_demo_001", + "version": "2026.04.01", + "app": map[string]any{ + "id": "sample-classic-001", + "title": "顺序赛示例", + }, + "map": map[string]any{ + "tiles": "https://oss-mbh5.colormaprun.com/gotomars/map/lxcb-001/tiles/", + "mapmeta": "https://oss-mbh5.colormaprun.com/gotomars/map/lxcb-001/tiles/meta.json", + }, + "playfield": map[string]any{ + "kind": "course", + "source": map[string]any{ + "type": "kml", + "url": "https://oss-mbh5.colormaprun.com/gotomars/kml/lxcb-001/10/c01.kml", + }, + }, + "game": map[string]any{ + "mode": "classic-sequential", + }, + "assets": map[string]any{ + "contentHtml": "https://oss-mbh5.colormaprun.com/gotomars/event/content-h5-test-template.html", + }, + }, + AssetIndex: []map[string]any{ + { + "assetType": "manifest", + "assetKey": "manifest", + }, + { + "assetType": "mapmeta", + "assetKey": "mapmeta", + }, + { + "assetType": "playfield", + "assetKey": "playfield-kml", + }, + { + "assetType": "content_html", + "assetKey": "content-html", + }, + }, + }) + if err != nil { + return nil, fmt.Errorf("ensure demo event config build: %w", err) + } + + if err := s.AttachBuildToRelease(ctx, tx, releaseRow.ID, build.ID); err != nil { + return nil, fmt.Errorf("attach demo build to release: %w", err) + } + + tilesPath := "map/lxcb-001/tiles/" + mapmetaPath := "map/lxcb-001/tiles/meta.json" + playfieldPath := "kml/lxcb-001/10/c01.kml" + contentPath := "event/content-h5-test-template.html" + manifestChecksum := "demo-checksum-001" + if err := s.ReplaceEventReleaseAssets(ctx, tx, releaseRow.ID, []UpsertEventReleaseAssetParams{ + { + EventReleaseID: releaseRow.ID, + AssetType: "manifest", + AssetKey: "manifest", + AssetURL: "https://oss-mbh5.colormaprun.com/gotomars/event/classic-sequential.json", + Checksum: &manifestChecksum, + Meta: map[string]any{"source": "release-manifest"}, + }, + { + EventReleaseID: releaseRow.ID, + AssetType: "tiles", + AssetKey: "tiles-root", + AssetPath: &tilesPath, + AssetURL: "https://oss-mbh5.colormaprun.com/gotomars/map/lxcb-001/tiles/", + Meta: map[string]any{"kind": "directory"}, + }, + { + EventReleaseID: releaseRow.ID, + AssetType: "mapmeta", + AssetKey: "mapmeta", + AssetPath: &mapmetaPath, + AssetURL: "https://oss-mbh5.colormaprun.com/gotomars/map/lxcb-001/tiles/meta.json", + Meta: map[string]any{"format": "json"}, + }, + { + EventReleaseID: releaseRow.ID, + AssetType: "playfield", + AssetKey: "course-kml", + AssetPath: &playfieldPath, + AssetURL: "https://oss-mbh5.colormaprun.com/gotomars/kml/lxcb-001/10/c01.kml", + Meta: map[string]any{"format": "kml"}, + }, + { + EventReleaseID: releaseRow.ID, + AssetType: "content_html", + AssetKey: "content-html", + AssetPath: &contentPath, + AssetURL: "https://oss-mbh5.colormaprun.com/gotomars/event/content-h5-test-template.html", + Meta: map[string]any{"kind": "content-page"}, + }, + }); err != nil { + return nil, fmt.Errorf("ensure demo event release assets: %w", err) + } + + var cardPublicID string + if err := tx.QueryRow(ctx, ` + INSERT INTO cards ( + card_public_id, + tenant_id, + entry_channel_id, + card_type, + title, + subtitle, + cover_url, + event_id, + display_slot, + display_priority, + status + ) + VALUES ( + 'card_demo_001', + $1, + $2, + 'event', + 'Demo City Run', + '今日推荐路线', + 'https://oss-mbh5.colormaprun.com/gotomars/assets/demo-cover.jpg', + $3, + 'home_primary', + 100, + 'active' + ) + ON CONFLICT (card_public_id) DO UPDATE SET + tenant_id = EXCLUDED.tenant_id, + entry_channel_id = EXCLUDED.entry_channel_id, + card_type = EXCLUDED.card_type, + title = EXCLUDED.title, + subtitle = EXCLUDED.subtitle, + cover_url = EXCLUDED.cover_url, + event_id = EXCLUDED.event_id, + display_slot = EXCLUDED.display_slot, + display_priority = EXCLUDED.display_priority, + status = EXCLUDED.status + RETURNING card_public_id + `, tenantID, channelID, eventID).Scan(&cardPublicID); err != nil { + return nil, fmt.Errorf("ensure demo card: %w", err) + } + + if err := tx.Commit(ctx); err != nil { + return nil, err + } + + return &DemoBootstrapSummary{ + TenantCode: "tenant_demo", + ChannelCode: "mini-demo", + EventID: "evt_demo_001", + ReleaseID: releaseRow.PublicID, + SourceID: source.ID, + BuildID: build.ID, + CardID: cardPublicID, + }, nil +} diff --git a/backend/internal/store/postgres/entry_store.go b/backend/internal/store/postgres/entry_store.go new file mode 100644 index 0000000..5704749 --- /dev/null +++ b/backend/internal/store/postgres/entry_store.go @@ -0,0 +1,74 @@ +package postgres + +import ( + "context" + "errors" + "fmt" + + "github.com/jackc/pgx/v5" +) + +type EntryChannel struct { + ID string + ChannelCode string + ChannelType string + PlatformAppID *string + DisplayName string + Status string + IsDefault bool + TenantID string + TenantCode string + TenantName string +} + +type FindEntryChannelParams struct { + ChannelCode string + ChannelType string + PlatformAppID string + TenantCode string +} + +func (s *Store) FindEntryChannel(ctx context.Context, params FindEntryChannelParams) (*EntryChannel, error) { + row := s.pool.QueryRow(ctx, ` + SELECT + ec.id, + ec.channel_code, + ec.channel_type, + ec.platform_app_id, + ec.display_name, + ec.status, + ec.is_default, + t.id, + t.tenant_code, + t.name + FROM entry_channels ec + JOIN tenants t ON t.id = ec.tenant_id + WHERE ($1 = '' OR ec.channel_code = $1) + AND ($2 = '' OR ec.channel_type = $2) + AND ($3 = '' OR COALESCE(ec.platform_app_id, '') = $3) + AND ($4 = '' OR t.tenant_code = $4) + ORDER BY ec.is_default DESC, ec.created_at ASC + LIMIT 1 + `, params.ChannelCode, params.ChannelType, params.PlatformAppID, params.TenantCode) + + var entry EntryChannel + err := row.Scan( + &entry.ID, + &entry.ChannelCode, + &entry.ChannelType, + &entry.PlatformAppID, + &entry.DisplayName, + &entry.Status, + &entry.IsDefault, + &entry.TenantID, + &entry.TenantCode, + &entry.TenantName, + ) + if errors.Is(err, pgx.ErrNoRows) { + return nil, nil + } + if err != nil { + return nil, fmt.Errorf("find entry channel: %w", err) + } + return &entry, nil +} diff --git a/backend/internal/store/postgres/event_store.go b/backend/internal/store/postgres/event_store.go new file mode 100644 index 0000000..f9c3daf --- /dev/null +++ b/backend/internal/store/postgres/event_store.go @@ -0,0 +1,263 @@ +package postgres + +import ( + "context" + "errors" + "fmt" + "time" + + "github.com/jackc/pgx/v5" +) + +type Event struct { + ID string + PublicID string + Slug string + DisplayName string + Summary *string + Status string + CurrentReleaseID *string + CurrentReleasePubID *string + ConfigLabel *string + ManifestURL *string + ManifestChecksum *string + RouteCode *string +} + +type EventRelease struct { + ID string + PublicID string + EventID string + ReleaseNo int + ConfigLabel string + ManifestURL string + ManifestChecksum *string + RouteCode *string + BuildID *string + Status string + PublishedAt time.Time +} + +type CreateGameSessionParams struct { + SessionPublicID string + UserID string + EventID string + EventReleaseID string + DeviceKey string + ClientType string + RouteCode *string + SessionTokenHash string + SessionTokenExpiresAt time.Time +} + +type GameSession struct { + ID string + SessionPublicID string + UserID string + EventID string + EventReleaseID string + DeviceKey string + ClientType string + RouteCode *string + Status string + SessionTokenExpiresAt time.Time +} + +func (s *Store) GetEventByPublicID(ctx context.Context, eventPublicID string) (*Event, error) { + row := s.pool.QueryRow(ctx, ` + SELECT + e.id, + e.event_public_id, + e.slug, + e.display_name, + e.summary, + e.status, + e.current_release_id, + er.release_public_id, + er.config_label, + er.manifest_url, + er.manifest_checksum_sha256, + er.route_code + FROM events e + LEFT JOIN event_releases er ON er.id = e.current_release_id + WHERE e.event_public_id = $1 + LIMIT 1 + `, eventPublicID) + + var event Event + err := row.Scan( + &event.ID, + &event.PublicID, + &event.Slug, + &event.DisplayName, + &event.Summary, + &event.Status, + &event.CurrentReleaseID, + &event.CurrentReleasePubID, + &event.ConfigLabel, + &event.ManifestURL, + &event.ManifestChecksum, + &event.RouteCode, + ) + if errors.Is(err, pgx.ErrNoRows) { + return nil, nil + } + if err != nil { + return nil, fmt.Errorf("get event by public id: %w", err) + } + return &event, nil +} + +func (s *Store) GetEventByID(ctx context.Context, eventID string) (*Event, error) { + row := s.pool.QueryRow(ctx, ` + SELECT + e.id, + e.event_public_id, + e.slug, + e.display_name, + e.summary, + e.status, + e.current_release_id, + er.release_public_id, + er.config_label, + er.manifest_url, + er.manifest_checksum_sha256, + er.route_code + FROM events e + LEFT JOIN event_releases er ON er.id = e.current_release_id + WHERE e.id = $1 + LIMIT 1 + `, eventID) + + var event Event + err := row.Scan( + &event.ID, + &event.PublicID, + &event.Slug, + &event.DisplayName, + &event.Summary, + &event.Status, + &event.CurrentReleaseID, + &event.CurrentReleasePubID, + &event.ConfigLabel, + &event.ManifestURL, + &event.ManifestChecksum, + &event.RouteCode, + ) + if errors.Is(err, pgx.ErrNoRows) { + return nil, nil + } + if err != nil { + return nil, fmt.Errorf("get event by id: %w", err) + } + return &event, nil +} + +func (s *Store) NextEventReleaseNo(ctx context.Context, eventID string) (int, error) { + var next int + if err := s.pool.QueryRow(ctx, ` + SELECT COALESCE(MAX(release_no), 0) + 1 + FROM event_releases + WHERE event_id = $1 + `, eventID).Scan(&next); err != nil { + return 0, fmt.Errorf("next event release no: %w", err) + } + return next, nil +} + +type CreateEventReleaseParams struct { + PublicID string + EventID string + ReleaseNo int + ConfigLabel string + ManifestURL string + ManifestChecksum *string + RouteCode *string + BuildID *string + Status string + PayloadJSON string +} + +func (s *Store) CreateEventRelease(ctx context.Context, tx Tx, params CreateEventReleaseParams) (*EventRelease, error) { + row := tx.QueryRow(ctx, ` + INSERT INTO event_releases ( + release_public_id, + event_id, + release_no, + config_label, + manifest_url, + manifest_checksum_sha256, + route_code, + build_id, + status, + payload_jsonb + ) + VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10::jsonb) + RETURNING id, release_public_id, event_id, release_no, config_label, manifest_url, manifest_checksum_sha256, route_code, build_id, status, published_at + `, params.PublicID, params.EventID, params.ReleaseNo, params.ConfigLabel, params.ManifestURL, params.ManifestChecksum, params.RouteCode, params.BuildID, params.Status, params.PayloadJSON) + + var item EventRelease + if err := row.Scan( + &item.ID, + &item.PublicID, + &item.EventID, + &item.ReleaseNo, + &item.ConfigLabel, + &item.ManifestURL, + &item.ManifestChecksum, + &item.RouteCode, + &item.BuildID, + &item.Status, + &item.PublishedAt, + ); err != nil { + return nil, fmt.Errorf("create event release: %w", err) + } + return &item, nil +} + +func (s *Store) SetCurrentEventRelease(ctx context.Context, tx Tx, eventID, releaseID string) error { + if _, err := tx.Exec(ctx, ` + UPDATE events + SET current_release_id = $2 + WHERE id = $1 + `, eventID, releaseID); err != nil { + return fmt.Errorf("set current event release: %w", err) + } + return nil +} + +func (s *Store) CreateGameSession(ctx context.Context, tx Tx, params CreateGameSessionParams) (*GameSession, error) { + row := tx.QueryRow(ctx, ` + INSERT INTO game_sessions ( + session_public_id, + user_id, + event_id, + event_release_id, + device_key, + client_type, + route_code, + session_token_hash, + session_token_expires_at + ) + VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9) + RETURNING id, session_public_id, user_id, event_id, event_release_id, device_key, client_type, route_code, status, session_token_expires_at + `, params.SessionPublicID, params.UserID, params.EventID, params.EventReleaseID, params.DeviceKey, params.ClientType, params.RouteCode, params.SessionTokenHash, params.SessionTokenExpiresAt) + + var session GameSession + err := row.Scan( + &session.ID, + &session.SessionPublicID, + &session.UserID, + &session.EventID, + &session.EventReleaseID, + &session.DeviceKey, + &session.ClientType, + &session.RouteCode, + &session.Status, + &session.SessionTokenExpiresAt, + ) + if err != nil { + return nil, fmt.Errorf("create game session: %w", err) + } + return &session, nil +} diff --git a/backend/internal/store/postgres/identity_store.go b/backend/internal/store/postgres/identity_store.go new file mode 100644 index 0000000..50f5c41 --- /dev/null +++ b/backend/internal/store/postgres/identity_store.go @@ -0,0 +1,50 @@ +package postgres + +import ( + "context" + "fmt" +) + +type LoginIdentity struct { + ID string + IdentityType string + Provider string + ProviderSubject string + CountryCode *string + Mobile *string + Status string +} + +func (s *Store) ListIdentitiesByUserID(ctx context.Context, userID string) ([]LoginIdentity, error) { + rows, err := s.pool.Query(ctx, ` + SELECT id, identity_type, provider, provider_subject, country_code, mobile, status + FROM login_identities + WHERE user_id = $1 + ORDER BY created_at ASC + `, userID) + if err != nil { + return nil, fmt.Errorf("list identities by user id: %w", err) + } + defer rows.Close() + + var identities []LoginIdentity + for rows.Next() { + var identity LoginIdentity + if err := rows.Scan( + &identity.ID, + &identity.IdentityType, + &identity.Provider, + &identity.ProviderSubject, + &identity.CountryCode, + &identity.Mobile, + &identity.Status, + ); err != nil { + return nil, fmt.Errorf("scan identity: %w", err) + } + identities = append(identities, identity) + } + if err := rows.Err(); err != nil { + return nil, fmt.Errorf("iterate identities: %w", err) + } + return identities, nil +} diff --git a/backend/internal/store/postgres/result_store.go b/backend/internal/store/postgres/result_store.go new file mode 100644 index 0000000..1655242 --- /dev/null +++ b/backend/internal/store/postgres/result_store.go @@ -0,0 +1,367 @@ +package postgres + +import ( + "context" + "encoding/json" + "fmt" + + "github.com/jackc/pgx/v5" +) + +type SessionResult struct { + ID string + SessionID string + ResultStatus string + SummaryJSON string + FinalDurationSec *int + FinalScore *int + CompletedControls *int + TotalControls *int + DistanceMeters *float64 + AverageSpeedKmh *float64 + MaxHeartRateBpm *int +} + +type UpsertSessionResultParams struct { + SessionID string + ResultStatus string + Summary map[string]any + FinalDurationSec *int + FinalScore *int + CompletedControls *int + TotalControls *int + DistanceMeters *float64 + AverageSpeedKmh *float64 + MaxHeartRateBpm *int +} + +type SessionResultRecord struct { + Session + Result *SessionResult +} + +func (s *Store) UpsertSessionResult(ctx context.Context, tx Tx, params UpsertSessionResultParams) (*SessionResult, error) { + summaryJSON, err := json.Marshal(params.Summary) + if err != nil { + return nil, fmt.Errorf("marshal session summary: %w", err) + } + + row := tx.QueryRow(ctx, ` + INSERT INTO session_results ( + session_id, + result_status, + summary_jsonb, + final_duration_sec, + final_score, + completed_controls, + total_controls, + distance_meters, + average_speed_kmh, + max_heart_rate_bpm + ) + VALUES ($1, $2, $3::jsonb, $4, $5, $6, $7, $8, $9, $10) + ON CONFLICT (session_id) DO UPDATE SET + result_status = EXCLUDED.result_status, + summary_jsonb = EXCLUDED.summary_jsonb, + final_duration_sec = EXCLUDED.final_duration_sec, + final_score = EXCLUDED.final_score, + completed_controls = EXCLUDED.completed_controls, + total_controls = EXCLUDED.total_controls, + distance_meters = EXCLUDED.distance_meters, + average_speed_kmh = EXCLUDED.average_speed_kmh, + max_heart_rate_bpm = EXCLUDED.max_heart_rate_bpm + RETURNING + id, + session_id, + result_status, + summary_jsonb::text, + final_duration_sec, + final_score, + completed_controls, + total_controls, + distance_meters::float8, + average_speed_kmh::float8, + max_heart_rate_bpm + `, params.SessionID, params.ResultStatus, string(summaryJSON), params.FinalDurationSec, params.FinalScore, params.CompletedControls, params.TotalControls, params.DistanceMeters, params.AverageSpeedKmh, params.MaxHeartRateBpm) + + return scanSessionResult(row) +} + +func (s *Store) GetSessionResultByPublicID(ctx context.Context, sessionPublicID string) (*SessionResultRecord, error) { + row := s.pool.QueryRow(ctx, ` + SELECT + gs.id, + gs.session_public_id, + gs.user_id, + gs.event_id, + gs.event_release_id, + er.release_public_id, + er.config_label, + er.manifest_url, + er.manifest_checksum_sha256, + gs.device_key, + gs.client_type, + gs.route_code, + gs.status, + gs.session_token_hash, + gs.session_token_expires_at, + gs.launched_at, + gs.started_at, + gs.ended_at, + e.event_public_id, + e.display_name, + sr.id, + sr.session_id, + sr.result_status, + sr.summary_jsonb::text, + sr.final_duration_sec, + sr.final_score, + sr.completed_controls, + sr.total_controls, + sr.distance_meters::float8, + sr.average_speed_kmh::float8, + sr.max_heart_rate_bpm + FROM game_sessions gs + JOIN events e ON e.id = gs.event_id + JOIN event_releases er ON er.id = gs.event_release_id + LEFT JOIN session_results sr ON sr.session_id = gs.id + WHERE gs.session_public_id = $1 + LIMIT 1 + `, sessionPublicID) + return scanSessionResultRecord(row) +} + +func (s *Store) ListSessionResultsByUserID(ctx context.Context, userID string, limit int) ([]SessionResultRecord, error) { + if limit <= 0 || limit > 100 { + limit = 20 + } + + rows, err := s.pool.Query(ctx, ` + SELECT + gs.id, + gs.session_public_id, + gs.user_id, + gs.event_id, + gs.event_release_id, + er.release_public_id, + er.config_label, + er.manifest_url, + er.manifest_checksum_sha256, + gs.device_key, + gs.client_type, + gs.route_code, + gs.status, + gs.session_token_hash, + gs.session_token_expires_at, + gs.launched_at, + gs.started_at, + gs.ended_at, + e.event_public_id, + e.display_name, + sr.id, + sr.session_id, + sr.result_status, + sr.summary_jsonb::text, + sr.final_duration_sec, + sr.final_score, + sr.completed_controls, + sr.total_controls, + sr.distance_meters::float8, + sr.average_speed_kmh::float8, + sr.max_heart_rate_bpm + FROM game_sessions gs + JOIN events e ON e.id = gs.event_id + JOIN event_releases er ON er.id = gs.event_release_id + LEFT JOIN session_results sr ON sr.session_id = gs.id + WHERE gs.user_id = $1 + AND gs.status IN ('finished', 'failed', 'cancelled') + ORDER BY COALESCE(gs.ended_at, gs.updated_at, gs.created_at) DESC + LIMIT $2 + `, userID, limit) + if err != nil { + return nil, fmt.Errorf("list session results by user id: %w", err) + } + defer rows.Close() + + var items []SessionResultRecord + for rows.Next() { + item, err := scanSessionResultRecordFromRows(rows) + if err != nil { + return nil, err + } + items = append(items, *item) + } + if err := rows.Err(); err != nil { + return nil, fmt.Errorf("iterate session results by user id: %w", err) + } + return items, nil +} + +func scanSessionResult(row pgx.Row) (*SessionResult, error) { + var result SessionResult + err := row.Scan( + &result.ID, + &result.SessionID, + &result.ResultStatus, + &result.SummaryJSON, + &result.FinalDurationSec, + &result.FinalScore, + &result.CompletedControls, + &result.TotalControls, + &result.DistanceMeters, + &result.AverageSpeedKmh, + &result.MaxHeartRateBpm, + ) + if err != nil { + return nil, fmt.Errorf("scan session result: %w", err) + } + return &result, nil +} + +func scanSessionResultRecord(row pgx.Row) (*SessionResultRecord, error) { + var record SessionResultRecord + var resultID *string + var resultSessionID *string + var resultStatus *string + var resultSummaryJSON *string + var finalDurationSec *int + var finalScore *int + var completedControls *int + var totalControls *int + var distanceMeters *float64 + var averageSpeedKmh *float64 + var maxHeartRateBpm *int + + err := row.Scan( + &record.ID, + &record.SessionPublicID, + &record.UserID, + &record.EventID, + &record.EventReleaseID, + &record.ReleasePublicID, + &record.ConfigLabel, + &record.ManifestURL, + &record.ManifestChecksum, + &record.DeviceKey, + &record.ClientType, + &record.RouteCode, + &record.Status, + &record.SessionTokenHash, + &record.SessionTokenExpiresAt, + &record.LaunchedAt, + &record.StartedAt, + &record.EndedAt, + &record.EventPublicID, + &record.EventDisplayName, + &resultID, + &resultSessionID, + &resultStatus, + &resultSummaryJSON, + &finalDurationSec, + &finalScore, + &completedControls, + &totalControls, + &distanceMeters, + &averageSpeedKmh, + &maxHeartRateBpm, + ) + if err != nil { + if err == pgx.ErrNoRows { + return nil, nil + } + return nil, fmt.Errorf("scan session result record: %w", err) + } + + if resultID != nil { + record.Result = &SessionResult{ + ID: *resultID, + SessionID: derefString(resultSessionID), + ResultStatus: derefString(resultStatus), + SummaryJSON: derefString(resultSummaryJSON), + FinalDurationSec: finalDurationSec, + FinalScore: finalScore, + CompletedControls: completedControls, + TotalControls: totalControls, + DistanceMeters: distanceMeters, + AverageSpeedKmh: averageSpeedKmh, + MaxHeartRateBpm: maxHeartRateBpm, + } + } + + return &record, nil +} + +func scanSessionResultRecordFromRows(rows pgx.Rows) (*SessionResultRecord, error) { + var record SessionResultRecord + var resultID *string + var resultSessionID *string + var resultStatus *string + var resultSummaryJSON *string + var finalDurationSec *int + var finalScore *int + var completedControls *int + var totalControls *int + var distanceMeters *float64 + var averageSpeedKmh *float64 + var maxHeartRateBpm *int + + err := rows.Scan( + &record.ID, + &record.SessionPublicID, + &record.UserID, + &record.EventID, + &record.EventReleaseID, + &record.ReleasePublicID, + &record.ConfigLabel, + &record.ManifestURL, + &record.ManifestChecksum, + &record.DeviceKey, + &record.ClientType, + &record.RouteCode, + &record.Status, + &record.SessionTokenHash, + &record.SessionTokenExpiresAt, + &record.LaunchedAt, + &record.StartedAt, + &record.EndedAt, + &record.EventPublicID, + &record.EventDisplayName, + &resultID, + &resultSessionID, + &resultStatus, + &resultSummaryJSON, + &finalDurationSec, + &finalScore, + &completedControls, + &totalControls, + &distanceMeters, + &averageSpeedKmh, + &maxHeartRateBpm, + ) + if err != nil { + return nil, fmt.Errorf("scan session result row: %w", err) + } + if resultID != nil { + record.Result = &SessionResult{ + ID: *resultID, + SessionID: derefString(resultSessionID), + ResultStatus: derefString(resultStatus), + SummaryJSON: derefString(resultSummaryJSON), + FinalDurationSec: finalDurationSec, + FinalScore: finalScore, + CompletedControls: completedControls, + TotalControls: totalControls, + DistanceMeters: distanceMeters, + AverageSpeedKmh: averageSpeedKmh, + MaxHeartRateBpm: maxHeartRateBpm, + } + } + return &record, nil +} + +func derefString(value *string) string { + if value == nil { + return "" + } + return *value +} diff --git a/backend/internal/store/postgres/session_store.go b/backend/internal/store/postgres/session_store.go new file mode 100644 index 0000000..f54f6fa --- /dev/null +++ b/backend/internal/store/postgres/session_store.go @@ -0,0 +1,299 @@ +package postgres + +import ( + "context" + "errors" + "fmt" + "time" + + "github.com/jackc/pgx/v5" +) + +type Session struct { + ID string + SessionPublicID string + UserID string + EventID string + EventReleaseID string + ReleasePublicID *string + ConfigLabel *string + ManifestURL *string + ManifestChecksum *string + DeviceKey string + ClientType string + RouteCode *string + Status string + SessionTokenHash string + SessionTokenExpiresAt time.Time + LaunchedAt time.Time + StartedAt *time.Time + EndedAt *time.Time + EventPublicID *string + EventDisplayName *string +} + +type FinishSessionParams struct { + SessionID string + Status string +} + +func (s *Store) GetSessionByPublicID(ctx context.Context, sessionPublicID string) (*Session, error) { + row := s.pool.QueryRow(ctx, ` + SELECT + gs.id, + gs.session_public_id, + gs.user_id, + gs.event_id, + gs.event_release_id, + er.release_public_id, + er.config_label, + er.manifest_url, + er.manifest_checksum_sha256, + gs.device_key, + gs.client_type, + gs.route_code, + gs.status, + gs.session_token_hash, + gs.session_token_expires_at, + gs.launched_at, + gs.started_at, + gs.ended_at, + e.event_public_id, + e.display_name + FROM game_sessions gs + JOIN events e ON e.id = gs.event_id + JOIN event_releases er ON er.id = gs.event_release_id + WHERE gs.session_public_id = $1 + LIMIT 1 + `, sessionPublicID) + return scanSession(row) +} + +func (s *Store) GetSessionByPublicIDForUpdate(ctx context.Context, tx Tx, sessionPublicID string) (*Session, error) { + row := tx.QueryRow(ctx, ` + SELECT + gs.id, + gs.session_public_id, + gs.user_id, + gs.event_id, + gs.event_release_id, + er.release_public_id, + er.config_label, + er.manifest_url, + er.manifest_checksum_sha256, + gs.device_key, + gs.client_type, + gs.route_code, + gs.status, + gs.session_token_hash, + gs.session_token_expires_at, + gs.launched_at, + gs.started_at, + gs.ended_at, + e.event_public_id, + e.display_name + FROM game_sessions gs + JOIN events e ON e.id = gs.event_id + JOIN event_releases er ON er.id = gs.event_release_id + WHERE gs.session_public_id = $1 + FOR UPDATE + `, sessionPublicID) + return scanSession(row) +} + +func (s *Store) ListSessionsByUserID(ctx context.Context, userID string, limit int) ([]Session, error) { + if limit <= 0 || limit > 100 { + limit = 20 + } + + rows, err := s.pool.Query(ctx, ` + SELECT + gs.id, + gs.session_public_id, + gs.user_id, + gs.event_id, + gs.event_release_id, + er.release_public_id, + er.config_label, + er.manifest_url, + er.manifest_checksum_sha256, + gs.device_key, + gs.client_type, + gs.route_code, + gs.status, + gs.session_token_hash, + gs.session_token_expires_at, + gs.launched_at, + gs.started_at, + gs.ended_at, + e.event_public_id, + e.display_name + FROM game_sessions gs + JOIN events e ON e.id = gs.event_id + JOIN event_releases er ON er.id = gs.event_release_id + WHERE gs.user_id = $1 + ORDER BY gs.created_at DESC + LIMIT $2 + `, userID, limit) + if err != nil { + return nil, fmt.Errorf("list sessions by user id: %w", err) + } + defer rows.Close() + + var sessions []Session + for rows.Next() { + session, err := scanSessionFromRows(rows) + if err != nil { + return nil, err + } + sessions = append(sessions, *session) + } + if err := rows.Err(); err != nil { + return nil, fmt.Errorf("iterate sessions by user id: %w", err) + } + return sessions, nil +} + +func (s *Store) ListSessionsByUserAndEvent(ctx context.Context, userID, eventID string, limit int) ([]Session, error) { + if limit <= 0 || limit > 100 { + limit = 20 + } + + rows, err := s.pool.Query(ctx, ` + SELECT + gs.id, + gs.session_public_id, + gs.user_id, + gs.event_id, + gs.event_release_id, + er.release_public_id, + er.config_label, + er.manifest_url, + er.manifest_checksum_sha256, + gs.device_key, + gs.client_type, + gs.route_code, + gs.status, + gs.session_token_hash, + gs.session_token_expires_at, + gs.launched_at, + gs.started_at, + gs.ended_at, + e.event_public_id, + e.display_name + FROM game_sessions gs + JOIN events e ON e.id = gs.event_id + JOIN event_releases er ON er.id = gs.event_release_id + WHERE gs.user_id = $1 + AND gs.event_id = $2 + ORDER BY gs.created_at DESC + LIMIT $3 + `, userID, eventID, limit) + if err != nil { + return nil, fmt.Errorf("list sessions by user and event: %w", err) + } + defer rows.Close() + + var sessions []Session + for rows.Next() { + session, err := scanSessionFromRows(rows) + if err != nil { + return nil, err + } + sessions = append(sessions, *session) + } + if err := rows.Err(); err != nil { + return nil, fmt.Errorf("iterate sessions by user and event: %w", err) + } + return sessions, nil +} + +func (s *Store) StartSession(ctx context.Context, tx Tx, sessionID string) error { + _, err := tx.Exec(ctx, ` + UPDATE game_sessions + SET status = CASE WHEN status = 'launched' THEN 'running' ELSE status END, + started_at = COALESCE(started_at, NOW()) + WHERE id = $1 + `, sessionID) + if err != nil { + return fmt.Errorf("start session: %w", err) + } + return nil +} + +func (s *Store) FinishSession(ctx context.Context, tx Tx, params FinishSessionParams) error { + _, err := tx.Exec(ctx, ` + UPDATE game_sessions + SET status = $2, + started_at = COALESCE(started_at, NOW()), + ended_at = COALESCE(ended_at, NOW()) + WHERE id = $1 + `, params.SessionID, params.Status) + if err != nil { + return fmt.Errorf("finish session: %w", err) + } + return nil +} + +func scanSession(row pgx.Row) (*Session, error) { + var session Session + err := row.Scan( + &session.ID, + &session.SessionPublicID, + &session.UserID, + &session.EventID, + &session.EventReleaseID, + &session.ReleasePublicID, + &session.ConfigLabel, + &session.ManifestURL, + &session.ManifestChecksum, + &session.DeviceKey, + &session.ClientType, + &session.RouteCode, + &session.Status, + &session.SessionTokenHash, + &session.SessionTokenExpiresAt, + &session.LaunchedAt, + &session.StartedAt, + &session.EndedAt, + &session.EventPublicID, + &session.EventDisplayName, + ) + if errors.Is(err, pgx.ErrNoRows) { + return nil, nil + } + if err != nil { + return nil, fmt.Errorf("scan session: %w", err) + } + return &session, nil +} + +func scanSessionFromRows(rows pgx.Rows) (*Session, error) { + var session Session + err := rows.Scan( + &session.ID, + &session.SessionPublicID, + &session.UserID, + &session.EventID, + &session.EventReleaseID, + &session.ReleasePublicID, + &session.ConfigLabel, + &session.ManifestURL, + &session.ManifestChecksum, + &session.DeviceKey, + &session.ClientType, + &session.RouteCode, + &session.Status, + &session.SessionTokenHash, + &session.SessionTokenExpiresAt, + &session.LaunchedAt, + &session.StartedAt, + &session.EndedAt, + &session.EventPublicID, + &session.EventDisplayName, + ) + if err != nil { + return nil, fmt.Errorf("scan session row: %w", err) + } + return &session, nil +} diff --git a/backend/internal/store/postgres/user_store.go b/backend/internal/store/postgres/user_store.go new file mode 100644 index 0000000..b48e46e --- /dev/null +++ b/backend/internal/store/postgres/user_store.go @@ -0,0 +1,94 @@ +package postgres + +import ( + "context" + "errors" + "fmt" + + "github.com/jackc/pgx/v5" +) + +type User struct { + ID string + PublicID string + Status string + Nickname *string + AvatarURL *string +} + +type CreateUserParams struct { + PublicID string + Status string +} + +type queryRower interface { + QueryRow(context.Context, string, ...any) pgx.Row +} + +func (s *Store) FindUserByMobile(ctx context.Context, tx Tx, countryCode, mobile string) (*User, error) { + row := tx.QueryRow(ctx, ` + SELECT u.id, u.user_public_id, u.status, u.nickname, u.avatar_url + FROM users u + JOIN login_identities li ON li.user_id = u.id + WHERE li.provider = 'mobile' + AND li.country_code = $1 + AND li.mobile = $2 + AND li.status = 'active' + LIMIT 1 + `, countryCode, mobile) + return scanUser(row) +} + +func (s *Store) CreateUser(ctx context.Context, tx Tx, params CreateUserParams) (*User, error) { + row := tx.QueryRow(ctx, ` + INSERT INTO users (user_public_id, status) + VALUES ($1, $2) + RETURNING id, user_public_id, status, nickname, avatar_url + `, params.PublicID, params.Status) + return scanUser(row) +} + +func (s *Store) TouchUserLogin(ctx context.Context, tx Tx, userID string) error { + _, err := tx.Exec(ctx, ` + UPDATE users + SET last_login_at = NOW() + WHERE id = $1 + `, userID) + if err != nil { + return fmt.Errorf("touch user last login: %w", err) + } + return nil +} + +func (s *Store) DeactivateUser(ctx context.Context, tx Tx, userID string) error { + _, err := tx.Exec(ctx, ` + UPDATE users + SET status = 'deleted', updated_at = NOW() + WHERE id = $1 + `, userID) + if err != nil { + return fmt.Errorf("deactivate user: %w", err) + } + return nil +} + +func (s *Store) GetUserByID(ctx context.Context, db queryRower, userID string) (*User, error) { + row := db.QueryRow(ctx, ` + SELECT id, user_public_id, status, nickname, avatar_url + FROM users + WHERE id = $1 + `, userID) + return scanUser(row) +} + +func scanUser(row pgx.Row) (*User, error) { + var user User + err := row.Scan(&user.ID, &user.PublicID, &user.Status, &user.Nickname, &user.AvatarURL) + if errors.Is(err, pgx.ErrNoRows) { + return nil, nil + } + if err != nil { + return nil, fmt.Errorf("scan user: %w", err) + } + return &user, nil +} diff --git a/backend/migrations/0001_init.sql b/backend/migrations/0001_init.sql new file mode 100644 index 0000000..6ae5ac7 --- /dev/null +++ b/backend/migrations/0001_init.sql @@ -0,0 +1,123 @@ +BEGIN; + +CREATE EXTENSION IF NOT EXISTS pgcrypto; + +CREATE OR REPLACE FUNCTION set_updated_at() +RETURNS TRIGGER AS $$ +BEGIN + NEW.updated_at = NOW(); + RETURN NEW; +END; +$$ LANGUAGE plpgsql; + +CREATE TABLE tenants ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + tenant_code TEXT NOT NULL UNIQUE, + name TEXT NOT NULL, + status TEXT NOT NULL DEFAULT 'active' CHECK (status IN ('draft', 'active', 'disabled', 'archived')), + theme_jsonb JSONB NOT NULL DEFAULT '{}'::jsonb, + settings_jsonb JSONB NOT NULL DEFAULT '{}'::jsonb, + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW() +); + +CREATE TRIGGER tenants_set_updated_at +BEFORE UPDATE ON tenants +FOR EACH ROW EXECUTE FUNCTION set_updated_at(); + +CREATE TABLE entry_channels ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + tenant_id UUID NOT NULL REFERENCES tenants(id) ON DELETE CASCADE, + channel_code TEXT NOT NULL, + channel_type TEXT NOT NULL CHECK (channel_type IN ('app', 'wechat_mini', 'wechat_oa', 'h5', 'qr')), + platform_app_id TEXT, + display_name TEXT NOT NULL, + status TEXT NOT NULL DEFAULT 'active' CHECK (status IN ('draft', 'active', 'disabled', 'archived')), + is_default BOOLEAN NOT NULL DEFAULT FALSE, + config_jsonb JSONB NOT NULL DEFAULT '{}'::jsonb, + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + UNIQUE (tenant_id, channel_code) +); + +CREATE INDEX entry_channels_tenant_id_idx ON entry_channels(tenant_id); +CREATE INDEX entry_channels_platform_app_id_idx ON entry_channels(platform_app_id); + +CREATE TRIGGER entry_channels_set_updated_at +BEFORE UPDATE ON entry_channels +FOR EACH ROW EXECUTE FUNCTION set_updated_at(); + +CREATE TABLE users ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + user_public_id TEXT NOT NULL UNIQUE, + default_tenant_id UUID REFERENCES tenants(id) ON DELETE SET NULL, + status TEXT NOT NULL DEFAULT 'active' CHECK (status IN ('active', 'disabled', 'deleted')), + nickname TEXT, + avatar_url TEXT, + last_login_at TIMESTAMPTZ, + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW() +); + +CREATE INDEX users_default_tenant_id_idx ON users(default_tenant_id); + +CREATE TRIGGER users_set_updated_at +BEFORE UPDATE ON users +FOR EACH ROW EXECUTE FUNCTION set_updated_at(); + +CREATE TABLE login_identities ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + user_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE, + identity_type TEXT NOT NULL CHECK (identity_type IN ('mobile', 'wechat_mini_openid', 'wechat_oa_openid', 'wechat_unionid')), + provider TEXT NOT NULL, + provider_subject TEXT NOT NULL, + country_code TEXT, + mobile TEXT, + status TEXT NOT NULL DEFAULT 'active' CHECK (status IN ('active', 'disabled', 'deleted')), + profile_jsonb JSONB NOT NULL DEFAULT '{}'::jsonb, + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + UNIQUE (provider, provider_subject) +); + +CREATE INDEX login_identities_user_id_idx ON login_identities(user_id); +CREATE INDEX login_identities_mobile_idx ON login_identities(country_code, mobile); + +CREATE TRIGGER login_identities_set_updated_at +BEFORE UPDATE ON login_identities +FOR EACH ROW EXECUTE FUNCTION set_updated_at(); + +CREATE TABLE auth_sms_codes ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + scene TEXT NOT NULL, + country_code TEXT NOT NULL, + mobile TEXT NOT NULL, + client_type TEXT NOT NULL CHECK (client_type IN ('app', 'wechat')), + device_key TEXT NOT NULL, + code_hash TEXT NOT NULL, + provider_payload_jsonb JSONB NOT NULL DEFAULT '{}'::jsonb, + expires_at TIMESTAMPTZ NOT NULL, + cooldown_until TIMESTAMPTZ NOT NULL, + consumed_at TIMESTAMPTZ, + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW() +); + +CREATE INDEX auth_sms_codes_lookup_idx +ON auth_sms_codes(country_code, mobile, client_type, scene, created_at DESC); + +CREATE TABLE auth_refresh_tokens ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + user_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE, + client_type TEXT NOT NULL CHECK (client_type IN ('app', 'wechat')), + device_key TEXT, + token_hash TEXT NOT NULL UNIQUE, + issued_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + expires_at TIMESTAMPTZ NOT NULL, + revoked_at TIMESTAMPTZ, + replaced_by_token_id UUID REFERENCES auth_refresh_tokens(id) ON DELETE SET NULL +); + +CREATE INDEX auth_refresh_tokens_user_id_idx ON auth_refresh_tokens(user_id); +CREATE INDEX auth_refresh_tokens_expires_at_idx ON auth_refresh_tokens(expires_at); + +COMMIT; diff --git a/backend/migrations/0002_launch.sql b/backend/migrations/0002_launch.sql new file mode 100644 index 0000000..26d166d --- /dev/null +++ b/backend/migrations/0002_launch.sql @@ -0,0 +1,72 @@ +BEGIN; + +CREATE TABLE events ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + tenant_id UUID REFERENCES tenants(id) ON DELETE SET NULL, + event_public_id TEXT NOT NULL UNIQUE, + slug TEXT NOT NULL UNIQUE, + display_name TEXT NOT NULL, + summary TEXT, + status TEXT NOT NULL DEFAULT 'draft' CHECK (status IN ('draft', 'active', 'disabled', 'archived')), + current_release_id UUID, + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW() +); + +CREATE INDEX events_tenant_id_idx ON events(tenant_id); +CREATE INDEX events_status_idx ON events(status); + +CREATE TRIGGER events_set_updated_at +BEFORE UPDATE ON events +FOR EACH ROW EXECUTE FUNCTION set_updated_at(); + +CREATE TABLE event_releases ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + release_public_id TEXT NOT NULL UNIQUE, + event_id UUID NOT NULL REFERENCES events(id) ON DELETE CASCADE, + release_no INTEGER NOT NULL, + config_label TEXT NOT NULL, + manifest_url TEXT NOT NULL, + manifest_checksum_sha256 TEXT, + route_code TEXT, + status TEXT NOT NULL DEFAULT 'published' CHECK (status IN ('draft', 'published', 'retired', 'failed')), + payload_jsonb JSONB NOT NULL DEFAULT '{}'::jsonb, + published_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + UNIQUE (event_id, release_no) +); + +CREATE INDEX event_releases_event_id_idx ON event_releases(event_id); +CREATE INDEX event_releases_status_idx ON event_releases(status); + +ALTER TABLE events +ADD CONSTRAINT events_current_release_fk +FOREIGN KEY (current_release_id) REFERENCES event_releases(id) ON DELETE SET NULL; + +CREATE TABLE game_sessions ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + session_public_id TEXT NOT NULL UNIQUE, + user_id UUID NOT NULL REFERENCES users(id) ON DELETE RESTRICT, + event_id UUID NOT NULL REFERENCES events(id) ON DELETE RESTRICT, + event_release_id UUID NOT NULL REFERENCES event_releases(id) ON DELETE RESTRICT, + device_key TEXT NOT NULL, + client_type TEXT NOT NULL CHECK (client_type IN ('app', 'wechat')), + route_code TEXT, + status TEXT NOT NULL DEFAULT 'launched' CHECK (status IN ('launched', 'running', 'finished', 'failed', 'cancelled')), + session_token_hash TEXT NOT NULL UNIQUE, + session_token_expires_at TIMESTAMPTZ NOT NULL, + launched_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + started_at TIMESTAMPTZ, + ended_at TIMESTAMPTZ, + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW() +); + +CREATE INDEX game_sessions_user_id_idx ON game_sessions(user_id); +CREATE INDEX game_sessions_event_id_idx ON game_sessions(event_id); +CREATE INDEX game_sessions_status_idx ON game_sessions(status); + +CREATE TRIGGER game_sessions_set_updated_at +BEFORE UPDATE ON game_sessions +FOR EACH ROW EXECUTE FUNCTION set_updated_at(); + +COMMIT; diff --git a/backend/migrations/0003_home.sql b/backend/migrations/0003_home.sql new file mode 100644 index 0000000..00faba1 --- /dev/null +++ b/backend/migrations/0003_home.sql @@ -0,0 +1,32 @@ +BEGIN; + +CREATE TABLE cards ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + card_public_id TEXT NOT NULL UNIQUE, + tenant_id UUID NOT NULL REFERENCES tenants(id) ON DELETE CASCADE, + entry_channel_id UUID REFERENCES entry_channels(id) ON DELETE SET NULL, + card_type TEXT NOT NULL CHECK (card_type IN ('event', 'html', 'notice')), + title TEXT NOT NULL, + subtitle TEXT, + cover_url TEXT, + event_id UUID REFERENCES events(id) ON DELETE SET NULL, + html_url TEXT, + display_slot TEXT NOT NULL DEFAULT 'home_primary', + display_priority INTEGER NOT NULL DEFAULT 0, + status TEXT NOT NULL DEFAULT 'active' CHECK (status IN ('draft', 'active', 'disabled', 'archived')), + starts_at TIMESTAMPTZ, + ends_at TIMESTAMPTZ, + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW() +); + +CREATE INDEX cards_tenant_id_idx ON cards(tenant_id); +CREATE INDEX cards_entry_channel_id_idx ON cards(entry_channel_id); +CREATE INDEX cards_event_id_idx ON cards(event_id); +CREATE INDEX cards_display_idx ON cards(display_slot, status, display_priority DESC); + +CREATE TRIGGER cards_set_updated_at +BEFORE UPDATE ON cards +FOR EACH ROW EXECUTE FUNCTION set_updated_at(); + +COMMIT; diff --git a/backend/migrations/0004_results.sql b/backend/migrations/0004_results.sql new file mode 100644 index 0000000..efbc672 --- /dev/null +++ b/backend/migrations/0004_results.sql @@ -0,0 +1,26 @@ +BEGIN; + +CREATE TABLE session_results ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + session_id UUID NOT NULL UNIQUE REFERENCES game_sessions(id) ON DELETE CASCADE, + result_status TEXT NOT NULL CHECK (result_status IN ('finished', 'failed', 'cancelled')), + summary_jsonb JSONB NOT NULL DEFAULT '{}'::jsonb, + final_duration_sec INTEGER, + final_score INTEGER, + completed_controls INTEGER, + total_controls INTEGER, + distance_meters NUMERIC(10,2), + average_speed_kmh NUMERIC(8,3), + max_heart_rate_bpm INTEGER, + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW() +); + +CREATE INDEX session_results_result_status_idx ON session_results(result_status); +CREATE INDEX session_results_created_at_idx ON session_results(created_at DESC); + +CREATE TRIGGER session_results_set_updated_at +BEFORE UPDATE ON session_results +FOR EACH ROW EXECUTE FUNCTION set_updated_at(); + +COMMIT; diff --git a/backend/migrations/0005_config_pipeline.sql b/backend/migrations/0005_config_pipeline.sql new file mode 100644 index 0000000..a086473 --- /dev/null +++ b/backend/migrations/0005_config_pipeline.sql @@ -0,0 +1,61 @@ +BEGIN; + +CREATE TABLE event_config_sources ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + event_id UUID NOT NULL REFERENCES events(id) ON DELETE CASCADE, + source_version_no INTEGER NOT NULL, + source_kind TEXT NOT NULL DEFAULT 'event_bundle' CHECK (source_kind IN ('event_bundle', 'manifest_only', 'preset')), + schema_id TEXT NOT NULL DEFAULT 'event-source', + schema_version TEXT NOT NULL DEFAULT '1', + status TEXT NOT NULL DEFAULT 'draft' CHECK (status IN ('draft', 'active', 'archived')), + source_jsonb JSONB NOT NULL DEFAULT '{}'::jsonb, + notes TEXT, + created_by_user_id UUID REFERENCES users(id) ON DELETE SET NULL, + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + UNIQUE (event_id, source_version_no) +); + +CREATE INDEX event_config_sources_event_id_idx ON event_config_sources(event_id); +CREATE INDEX event_config_sources_status_idx ON event_config_sources(status); + +CREATE TABLE event_config_builds ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + event_id UUID NOT NULL REFERENCES events(id) ON DELETE CASCADE, + source_id UUID NOT NULL REFERENCES event_config_sources(id) ON DELETE CASCADE, + build_no INTEGER NOT NULL, + build_status TEXT NOT NULL DEFAULT 'success' CHECK (build_status IN ('pending', 'running', 'success', 'failed', 'cancelled')), + build_log TEXT, + manifest_jsonb JSONB NOT NULL DEFAULT '{}'::jsonb, + asset_index_jsonb JSONB NOT NULL DEFAULT '[]'::jsonb, + created_by_user_id UUID REFERENCES users(id) ON DELETE SET NULL, + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + UNIQUE (event_id, build_no) +); + +CREATE INDEX event_config_builds_event_id_idx ON event_config_builds(event_id); +CREATE INDEX event_config_builds_source_id_idx ON event_config_builds(source_id); +CREATE INDEX event_config_builds_status_idx ON event_config_builds(build_status); + +ALTER TABLE event_releases +ADD COLUMN build_id UUID REFERENCES event_config_builds(id) ON DELETE SET NULL; + +CREATE INDEX event_releases_build_id_idx ON event_releases(build_id); + +CREATE TABLE event_release_assets ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + event_release_id UUID NOT NULL REFERENCES event_releases(id) ON DELETE CASCADE, + asset_type TEXT NOT NULL CHECK (asset_type IN ('manifest', 'mapmeta', 'tiles', 'playfield', 'content_html', 'media', 'other')), + asset_key TEXT NOT NULL, + asset_path TEXT, + asset_url TEXT NOT NULL, + checksum TEXT, + size_bytes BIGINT, + meta_jsonb JSONB NOT NULL DEFAULT '{}'::jsonb, + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + UNIQUE (event_release_id, asset_key) +); + +CREATE INDEX event_release_assets_release_id_idx ON event_release_assets(event_release_id); +CREATE INDEX event_release_assets_asset_type_idx ON event_release_assets(asset_type); + +COMMIT; diff --git a/backend/scripts/start-dev.ps1 b/backend/scripts/start-dev.ps1 new file mode 100644 index 0000000..b2c5a62 --- /dev/null +++ b/backend/scripts/start-dev.ps1 @@ -0,0 +1,29 @@ +Set-StrictMode -Version Latest +$ErrorActionPreference = "Stop" + +$scriptDir = Split-Path -Parent $MyInvocation.MyCommand.Path +$backendDir = Split-Path -Parent $scriptDir + +Set-Location $backendDir + +$env:APP_ENV = if ($env:APP_ENV) { $env:APP_ENV } else { "development" } +$env:HTTP_ADDR = if ($env:HTTP_ADDR) { $env:HTTP_ADDR } else { ":18090" } +$env:DATABASE_URL = if ($env:DATABASE_URL) { $env:DATABASE_URL } else { "postgres://postgres:asdf*123@192.168.100.77:5432/cmr20260401?sslmode=disable" } +$env:JWT_ACCESS_SECRET = if ($env:JWT_ACCESS_SECRET) { $env:JWT_ACCESS_SECRET } else { "change-me-in-production" } +$env:AUTH_SMS_PROVIDER = if ($env:AUTH_SMS_PROVIDER) { $env:AUTH_SMS_PROVIDER } else { "console" } +$env:WECHAT_MINI_DEV_PREFIX = if ($env:WECHAT_MINI_DEV_PREFIX) { $env:WECHAT_MINI_DEV_PREFIX } else { "dev-" } + +Write-Host "CMR backend dev server" -ForegroundColor Cyan +Write-Host ("APP_ENV=" + $env:APP_ENV) +Write-Host ("HTTP_ADDR=" + $env:HTTP_ADDR) +Write-Host ("DATABASE_URL=" + $env:DATABASE_URL) +Write-Host "" +Write-Host "Workbench:" -ForegroundColor Yellow +$workbenchAddr = $env:HTTP_ADDR +if ($workbenchAddr.StartsWith(":")) { + $workbenchAddr = "127.0.0.1" + $workbenchAddr +} +Write-Host ("http://" + $workbenchAddr + "/dev/workbench") +Write-Host "" + +go run .\cmd\api diff --git a/todolist.md b/todolist.md new file mode 100644 index 0000000..80a01be --- /dev/null +++ b/todolist.md @@ -0,0 +1,292 @@ +# CMR 联调协作清单 + +本文档用于后端、前端和你之间的联调协作。 + +约定: + +- 所有新开发事项先进入 `待确认事项`,只有你确认后才能移动到 `已确认可开发` +- 我会在这里提出后端接入要求、接口变更和联调建议 +- 前端同学可以在这里补页面进度、阻塞问题和接口反馈 +- 这里是协作清单,不替代正式接口文档和方案文档 + +状态说明: + +- `待确认`:已提出,但未获你确认 +- `已确认`:你已确认,可以进入开发 +- `联调中`:前后端已经开始接 +- `已完成`:开发和联调完成 +- `阻塞`:存在明确阻塞项 + +--- + +## 1. 当前联调目标 + +当前优先目标: + +- 把前台壳层的登录、首页、活动详情、开始前准备、结果页先接通 +- 让配置导入、preview、publish、launch 这条配置驱动链可被稳定验证 +- 用 workbench 和接口文档降低前后端联调成本 + +当前后端已经具备: + +- 统一登录 +- 微信小程序登录 +- 手机号绑定与账号合并 +- 入口解析 +- 首页聚合 +- 活动详情与 play 聚合 +- launch / session / result 主链路 +- 配置导入 / preview / publish +- API workbench + +相关文档: + +- [后端总览 README](D:/dev/cmr-mini/backend/README.md) +- [接口清单](D:/dev/cmr-mini/backend/docs/接口清单.md) +- [开发说明](D:/dev/cmr-mini/backend/docs/开发说明.md) +- [配置管理方案](D:/dev/cmr-mini/backend/docs/配置管理方案.md) + +--- + +## 2. 待确认事项 + +### T-001 首页首批页面范围 + +- 状态:`待确认` +- 建议负责人:你 +- 说明:建议前端首批只接这 5 个页面,不要同时铺太多页面 +- 建议范围: + - 登录页 + - 首页 + - 活动详情页 + - 开始前准备页 + - 结果页 + +### T-002 配置驱动联调入口 + +- 状态:`待确认` +- 建议负责人:你 +- 说明:建议首批统一使用 `evt_demo_001` 做联调,不在前端直接读根目录 `event` 文件,统一由后端 `release/manifest` 下发 + +### T-003 前端联调顺序 + +- 状态:`待确认` +- 建议负责人:你 +- 说明:建议按这个顺序接,避免页面壳层先做散 +- 建议顺序: + - 登录 + - 首页 + - 活动详情 / play + - launch + - session start / finish + - result + +--- + +## 3. 已确认可开发 + +暂无。 + +--- + +## 4. 前端待接接口 + +### F-001 登录页接入 + +- 状态:`待确认` +- 建议负责人:前端 +- 页面:登录页 +- 接口: + - `POST /auth/login/wechat-mini` + - `POST /auth/sms/send` + - `POST /auth/login/sms` + - `POST /auth/bind/mobile` +- 说明: + - APP 以手机号登录为主 + - 小程序可先微信登录,后续再绑定手机号 + +### F-002 首页接入 + +- 状态:`待确认` +- 建议负责人:前端 +- 页面:首页 +- 接口: + - `GET /me/entry-home` +- 说明: + - 首页不要自己拼多个接口 + - 直接以聚合接口为主 + +### F-003 活动详情与开始前准备接入 + +- 状态:`待确认` +- 建议负责人:前端 +- 页面:活动详情页、开始前准备页 +- 接口: + - `GET /events/{eventPublicID}/play` + - `POST /events/{eventPublicID}/launch` +- 说明: + - `play` 用于决定按钮文案和状态 + - `launch` 成功后进入游戏 + +### F-004 结果页接入 + +- 状态:`待确认` +- 建议负责人:前端 +- 页面:结果页 +- 接口: + - `GET /sessions/{sessionPublicID}/result` + - `GET /me/results` +- 说明: + - 单局页用 `session result` + - 列表页用 `my results` + +### F-005 我的页接入 + +- 状态:`待确认` +- 建议负责人:前端 +- 页面:我的页 +- 接口: + - `GET /me/profile` +- 说明: + - 不建议前端自己拼绑定信息和最近记录 + +--- + +## 5. 后端待补能力 + +### B-001 发布后的 release 管理 + +- 状态:`待确认` +- 建议负责人:后端 +- 说明:当前已经支持 import / preview / publish,但还缺正式的 release 列表、回滚和历史查看 + +### B-002 更通用的 play context + +- 状态:`待确认` +- 建议负责人:后端 +- 说明:当前 launch 仍是 `event` 入口为主,后续需要抽象成更通用的 `play context -> launch` + +### B-003 配置校验报告 + +- 状态:`待确认` +- 建议负责人:后端 +- 说明:当前 preview 已可用,但还缺面向配置运营的结构化校验报告 + +--- + +## 6. 当前后端已可联调接口 + +登录与用户: + +- `POST /auth/sms/send` +- `POST /auth/login/sms` +- `POST /auth/login/wechat-mini` +- `POST /auth/bind/mobile` +- `GET /me` +- `GET /me/profile` + +首页与入口: + +- `GET /entry/resolve` +- `GET /home` +- `GET /cards` +- `GET /me/entry-home` + +活动与游戏启动: + +- `GET /events/{eventPublicID}` +- `GET /events/{eventPublicID}/play` +- `POST /events/{eventPublicID}/launch` + +局内与结果: + +- `GET /sessions/{sessionPublicID}` +- `POST /sessions/{sessionPublicID}/start` +- `POST /sessions/{sessionPublicID}/finish` +- `GET /sessions/{sessionPublicID}/result` +- `GET /me/sessions` +- `GET /me/results` + +配置管理: + +- `GET /dev/config/local-files` +- `POST /dev/events/{eventPublicID}/config-sources/import-local` +- `POST /dev/config-builds/preview` +- `POST /dev/config-builds/publish` + +开发工具: + +- `POST /dev/bootstrap-demo` +- `GET /dev/workbench` + +--- + +## 7. 联调建议 + +### S-001 前端联调统一入口 + +- 状态:`待确认` +- 建议负责人:后端 + 前端 +- 说明:建议首批所有联调都通过 demo 数据进行,统一使用: + - `channelCode=mini-demo` + - `channelType=wechat_mini` + - `eventPublicID=evt_demo_001` + +### S-002 配置驱动约束 + +- 状态:`待确认` +- 建议负责人:后端 + 前端 +- 说明: + - 前端进入游戏时不要直接拼根目录配置文件路径 + - 必须使用后端下发的: + - `releaseId` + - `manifestUrl` + - `manifestChecksumSha256` + +### S-003 联调工具优先级 + +- 状态:`已确认` +- 建议负责人:后端 +- 说明: + - 日常联调优先使用 workbench + - 接口说明优先看 workbench 里的 `API 列表` + - 深入字段说明再看 [接口清单](D:/dev/cmr-mini/backend/docs/接口清单.md) + +--- + +## 8. 阻塞记录 + +暂无。 + +--- + +## 9. 完成记录 + +### R-001 后端主链路已打通 + +- 状态:`已完成` +- 负责人:后端 +- 说明: + - 登录 + - 首页聚合 + - 活动 play + - launch + - session + - result + +### R-002 配置 import / preview / publish 已打通 + +- 状态:`已完成` +- 负责人:后端 +- 说明: + - 已验证 `import-local -> preview -> publish -> launch` + +### R-003 API workbench 已上线 + +- 状态:`已完成` +- 负责人:后端 +- 说明: + - 已支持中文 API 列表 + - 已支持 quick flows + - 已支持场景保存 + - 已支持配置发布链调试