Compare commits

..

1 Commits

Author SHA1 Message Date
71e1866e99 merge: integrate map engine north reference work 2026-03-20 11:41:36 +08:00
314 changed files with 682 additions and 69976 deletions

9
.gitignore vendored
View File

@@ -1,14 +1,10 @@
node_modules/
.tmp-ts/
.tmp-runtime-smoke/
miniprogram_npm/
dist/
build/
coverage/
project.private.config.json
project.config.json
private.wx0c8b079993bb9d7a.key
wX5FOd926R.txt
*.log
npm-debug.log*
yarn-debug.log*
@@ -22,8 +18,3 @@ pnpm-debug.log*
*.swp
.DS_Store
Thumbs.db
realtime-gateway/bin/
realtime-gateway/.tmp-gateway.*
oss-html.ps1
tools/ossutil.exe
tools/accesskey.txt

View File

@@ -1,84 +0,0 @@
{
"schemaVersion": "1",
"version": "2026.03.25",
"app": {
"id": "sample-classic-001",
"title": "顺序赛示例",
"locale": "zh-CN"
},
"map": {
"tiles": "sample-map/tiles/",
"mapmeta": "sample-map/tiles/meta.json",
"declination": 6.91,
"initialView": {
"zoom": 17
}
},
"playfield": {
"kind": "course",
"source": {
"type": "kml",
"url": "sample-course/course.kml"
},
"CPRadius": 6,
"metadata": {
"title": "顺序赛路线示例",
"code": "classic-001"
}
},
"game": {
"mode": "classic-sequential",
"rulesVersion": "1",
"session": {
"startManually": true,
"requiresStartPunch": true,
"requiresFinishPunch": true,
"autoFinishOnLastControl": false,
"maxDurationSec": 5400
},
"punch": {
"policy": "enter-confirm",
"radiusMeters": 10
},
"sequence": {
"skip": {
"enabled": false,
"radiusMeters": 30,
"requiresConfirm": true
}
},
"guidance": {
"showLegs": true,
"legAnimation": true,
"allowFocusSelection": false
},
"visibility": {
"revealFullPlayfieldAfterStartPunch": true
},
"finish": {
"finishControlAlwaysSelectable": false
},
"telemetry": {
"heartRate": {
"age": 30,
"restingHeartRateBpm": 62,
"userWeightKg": 65
}
},
"feedback": {
"audioProfile": "default",
"hapticsProfile": "default",
"uiEffectsProfile": "default"
}
},
"resources": {
"audioProfile": "default",
"contentProfile": "default",
"themeProfile": "default-race"
},
"debug": {
"allowModeSwitch": false,
"allowMockInput": false,
"allowSimulator": false
}
}

View File

@@ -1,108 +0,0 @@
{
"schemaVersion": "1",
"version": "2026.03.25",
"app": {
"id": "sample-score-o-001",
"title": "积分赛示例",
"locale": "zh-CN"
},
"map": {
"tiles": "sample-map/tiles/",
"mapmeta": "sample-map/tiles/meta.json",
"declination": 6.91,
"initialView": {
"zoom": 17
}
},
"playfield": {
"kind": "control-set",
"source": {
"type": "kml",
"url": "sample-course/course.kml"
},
"CPRadius": 6,
"controlOverrides": {
"control-1": {
"score": 10
},
"control-2": {
"score": 20
},
"control-3": {
"score": 30
},
"control-4": {
"score": 40
},
"control-5": {
"score": 50
},
"control-6": {
"score": 60
},
"control-7": {
"score": 70
},
"control-8": {
"score": 80
}
},
"metadata": {
"title": "积分赛控制点示例2 起终点 + 8 积分点)",
"code": "score-o-001"
}
},
"game": {
"mode": "score-o",
"rulesVersion": "1",
"session": {
"startManually": true,
"requiresStartPunch": true,
"requiresFinishPunch": false,
"autoFinishOnLastControl": false,
"maxDurationSec": 5400
},
"punch": {
"policy": "enter-confirm",
"radiusMeters": 10,
"requiresFocusSelection": true
},
"scoring": {
"type": "score",
"defaultControlScore": 10
},
"guidance": {
"showLegs": false,
"legAnimation": false,
"allowFocusSelection": true
},
"visibility": {
"revealFullPlayfieldAfterStartPunch": true
},
"finish": {
"finishControlAlwaysSelectable": true
},
"telemetry": {
"heartRate": {
"age": 30,
"restingHeartRateBpm": 62,
"userWeightKg": 65
}
},
"feedback": {
"audioProfile": "default",
"hapticsProfile": "default",
"uiEffectsProfile": "default"
}
},
"resources": {
"audioProfile": "default",
"contentProfile": "default",
"themeProfile": "default-race"
},
"debug": {
"allowModeSwitch": false,
"allowMockInput": false,
"allowSimulator": false
}
}

162
b2f.md
View File

@@ -1,162 +0,0 @@
# b2f
说明:
- 只写事实和请求
- 每条固定包含:时间、谁提的、当前事实、需要对方确认什么、是否已解决
---
## 待确认
### B2F-001
- 时间2026-04-01
- 谁提的backend
- 当前事实:
- backend 当前主链已经可联调:
- `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`
- 当前建议统一使用 demo 入口:
- `eventPublicID = evt_demo_001`
- `channelCode = mini-demo`
- `channelType = wechat_mini`
- 需要对方确认什么:
- frontend 是否按这组 demo 数据作为当前唯一联调入口
- 是否已解决:否
### B2F-002
- 时间2026-04-01
- 谁提的backend
- 当前事实:
- 进入游戏的正式流程必须以 `launch` 返回值为准
- backend 当前约定字段:
- `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`
- 需要对方确认什么:
- frontend 是否确认正式流程只消费上述字段,不再自行推断 release URL
- 是否已解决:否
### B2F-003
- 时间2026-04-01
- 谁提的backend
- 当前事实:
- backend 准备把“放弃恢复”收口为 `finish(cancelled)` 语义
- 当前语义尚未最终拍板
- 需要对方确认什么:
- frontend 是否可以先预埋“放弃恢复”调用位,但在语义确认前不默认启用
- 是否已解决:否
---
## 已确认
### B2F-004
- 时间2026-04-01
- 谁提的backend
- 当前事实:
- 正式联调时不应回退到本地样例配置路径
- 不应直接读取根目录 `event/*.json`
- 应只认 launch 返回的 `manifestUrl`
- 需要对方确认什么:
-
- 是否已解决:是
### B2F-005
- 时间2026-04-01
- 谁提的backend
- 当前事实:
- 接口说明优先看 workbench 里的中文 API 列表
- 深入字段说明再看 [接口清单](D:/dev/cmr-mini/backend/docs/接口清单.md)
- 需要对方确认什么:
-
- 是否已解决:是
---
## 阻塞
### B2F-006
- 时间2026-04-01
- 谁提的backend
- 当前事实:
- 如果 frontend 再出现 manifest 加载失败backend 仅靠一句“加载失败”无法定位
- 需要对方确认什么:
- 如再出现此类问题,请一次性提供:
- `eventPublicID`
- `releaseId`
- `manifestUrl`
- 页面报错文案
- 控制台日志
- 网络请求日志
- 是否已解决:否
---
## 已完成
### B2F-007
- 时间2026-04-01
- 谁提的backend
- 当前事实:
- backend 已修复 `publish build` 只写 DB、不上传 OSS 的问题
- 新发布的 demo release manifest 已可正常访问
- 当前可用 release
- `eventPublicID = evt_demo_001`
- `releaseId = rel_e7dd953743c5c0d2`
- `manifestUrl = https://oss-mbh5.colormaprun.com/gotomars/event/releases/evt_demo_001/rel_e7dd953743c5c0d2/manifest.json`
- 需要对方确认什么:
-
- 是否已解决:是
### B2F-008
- 时间2026-04-01
- 谁提的backend
- 当前事实:
- backend workbench 已支持中文 API 列表
- 当前可用于日常联调:
- `POST /dev/bootstrap-demo`
- `GET /dev/workbench`
- 需要对方确认什么:
-
- 是否已解决:是
---
## 下一步
### B2F-009
- 时间2026-04-01
- 谁提的backend
- 当前事实:
- backend 下一步会优先处理 P0
- 固定 `finished / failed / cancelled`
- 明确“放弃恢复”是否落 `cancelled`
- 收稳 `start / finish` 幂等
- 需要对方确认什么:
- frontend 当前优先配合:
- 用最新 demo release 回归 `play -> launch -> map load`
- 确认正式流程只认 launch 返回的 `manifestUrl`
- 预埋“放弃恢复”调用位
- 是否已解决:否

View File

@@ -1,24 +0,0 @@
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
ASSET_PUBLIC_BASE_URL=https://oss-mbh5.colormaprun.com
ASSET_BUCKET_ROOT=oss://color-map-html
OSSUTIL_PATH=..\tools\ossutil.exe
OSSUTIL_CONFIG_FILE=C:\Users\your-user\.ossutilconfig

View File

@@ -1,44 +0,0 @@
# 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)
- [开发说明](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`

View File

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

View File

@@ -1,56 +0,0 @@
# 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. [前后端联调清单](D:/dev/cmr-mini/backend/docs/前后端联调清单.md)
8. [TodoList](D:/dev/cmr-mini/backend/docs/todolist.md)
9. [开发说明](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)

View File

@@ -1,332 +0,0 @@
# 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 现在最重要的不是再扩散接口,而是把当前契约和语义收稳。
当前已确认不再阻塞主链的事项:
- `evt_demo_001` 的 release manifest 现已可正常加载
- 小程序已能进入地图
- 模拟定位 / 调试日志问题已回到小程序与模拟器侧,不再属于 backend 当前阻塞
前端当前需要配合的事项:
- 正式联调时始终以 `launch.resolvedRelease.manifestUrl` 为准,不再回退到本地样例配置路径
- 如果再出现配置加载失败,反馈完整上下文:
- `eventPublicID`
- `releaseId`
- `manifestUrl`
- 页面报错文案
- 控制台 / 网络日志
- 当前 demo 联调建议统一使用:
- `eventPublicID = evt_demo_001`
- `channelCode = mini-demo`
- `channelType = wechat_mini`
## 3. P0 必做
## 3.0 固定 session 状态语义
需要 backend 明确并固定:
- `finished`
- `failed`
- `cancelled`
建议当前口径:
- 正常打终点完成:`finished`
- 超时结束:`failed`
- 主动退出 / 放弃恢复:`cancelled`
说明:
- 小程序现在已经按这个方向接
- 如果 backend 想改这 3 个状态语义,需要先讨论,不要单边改
## 3.1 明确“放弃恢复”的后端处理
这是当前最值得后端配合确认的一点。
当前小程序本地恢复逻辑已经是:
- 进入程序检测到未正常结束对局
- 弹确认框
- 玩家可“继续恢复”或“放弃”
现在本地“放弃”只会清除本地恢复快照。
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.2 保证 start / finish 幂等与重复调用安全
联调和真实环境里,以下情况很常见:
- 网络重试
- 页面重进
- 故障恢复后二次补报
- 用户重复点击
backend 需要确认:
- `start` 重复调用的幂等语义
- `finish` 重复调用的幂等语义
建议:
- `start`:如果已 `running`,返回当前 session视为成功
- `finish`:如果已进入终态,返回当前 session/result视为成功
目的:
- 不把客户端补偿逻辑变成一堆冲突分支
## 3.3 固定 `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 现在需要做的是:
- 先保持这些字段名稳定
- 如果要调整命名或层级,先沟通
前端当前需要做的是:
- 只消费当前已约定字段
- 不额外推断 release URL
- 不把本地样例配置路径混进正式 launch 流程
- 如果字段缺失或命名变化,直接在联调清单里标阻塞
## 4. P1 应尽快做
## 4.1 给首页 / play / result 的 ongoing 语义再做一次回归确认
当前前端已经开始走:
- 首页聚合
- `event play`
- `launch`
- `session start / finish`
- 本地故障恢复
backend 建议再回归确认这几个接口对“进行中 session”的口径一致
- `/me/entry-home`
- `/events/{eventPublicID}/play`
- `/sessions/{sessionPublicID}/result`
重点确认:
1. `cancelled` 后不再继续出现在 ongoing 入口
2. `failed` 后不再继续出现在 ongoing 入口
3. `finished` 后结果页与首页摘要字段一致
## 4.2 增加用户身体资料读取接口
小程序侧已经有:
- telemetry profile 合并入口
- 心率/卡路里计算逻辑
backend 下一步建议提供:
- 当前用户 body profile 查询接口
建议返回至少包含:
- `birthDate``heartRateAge`
- `weightKg`
- `restingHeartRateBpm`
- `maxHeartRateBpm`(可选)
这样后面心率页和消耗估算就能真实接业务数据。
## 4.3 给 `session result` 补一点稳定摘要字段校验
客户端现在会上报:
- `finalDurationSec`
- `finalScore`
- `completedControls`
- `totalControls`
- `distanceMeters`
- `averageSpeedKmh`
backend 建议补两件事:
- 合理性校验
- 空值容忍
不要因为某个可选字段缺失就整局 finish 失败。
## 4.4 dev workbench 增加一组“恢复 / 取消恢复”场景按钮
当前 workbench 已经很好用了。
建议后续再补:
- 标记 session 为 `cancelled`
- 查询 ongoing session
- 快速查看某个用户最新 session 状态
这会很适合配合小程序故障恢复联调。
## 4.5 前端预埋“放弃恢复”调用位
这项先预埋,不要先自行定语义。
前端建议准备好:
- 在“放弃恢复”按钮点击后,预留调用 `finish(cancelled)` 的位置
- 但是否正式启用,要等 backend 把 `cancelled` 语义确认完
这样一旦 backend 确认语义,小程序就能快速切过去,不需要再改一轮页面流程。
## 5. P2 下一阶段
## 5.1 配置后台 source / build / release 真正开始做
当前已经有:
- 表结构
- 架构文档
还缺:
- source CRUD
- build 触发
- manifest 产物生成
- release 发布
- asset index 查询
这个建议在当前主链联稳之后再推进。
## 5.2 page / cards / competition 等业务对象继续长出来
这部分不是当前联调阻塞项,但后面会成为业务壳的重要组成。
## 5.3 兼顾未来 APP 的统一后端约束
backend 后续建设需要继续坚持:
- 不做“小程序专用后端”
- 用户模型保持平台级
- `event / release / session / result` 不按终端拆两套
- 终端差异只通过上下文字段和运行时适配处理
建议优先保持:
- 业务接口统一
- 配置发布结构统一
- 结果沉淀结构统一
这样后面 APP 接入时不会推翻现有 backend 结构。
## 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 运行态语义、放弃恢复语义和 ongoing session 口径定稳,再继续扩后台配置系统。

View File

@@ -1,369 +0,0 @@
# 前后端联调清单
## 1. 目的
这份清单只回答三件事:
1. 小程序当前已经具备哪些接后端的前置能力
2. backend 当前已经提供了哪些可联调接口
3. 哪些链路已经能接,哪些链路还缺适配
本文不讨论未来大而全后台方案,只服务当前联调落地。
## 2. 当前结论
当前状态可以概括成一句话:
> backend 业务主链已经可联调;小程序地图运行内核也已经成型;两边之间还缺一层业务接入和会话上报适配。
也就是说:
- 登录、活动详情、launch、session、result 这一条后端链已经可用
- 小程序地图页已经支持携带 `configUrl / releaseId / sessionId / sessionToken`
- 但小程序当前仍主要走本地 demo / 直连 OSS manifest
- 真正的“后端 launch -> 地图页 -> session start/finish/result”还没有正式接上
## 3. 小程序当前已具备的联调基础
## 3.1 启动信封已经成型
地图页不是只吃一个 `configUrl`,而是吃一份启动信封:
- [gameLaunch.ts](D:/dev/cmr-mini/miniprogram/utils/gameLaunch.ts)
当前结构:
- `config.configUrl`
- `config.configLabel`
- `config.configChecksumSha256`
- `config.releaseId`
- `config.routeCode`
- `business.source`
- `business.competitionId`
- `business.eventId`
- `business.launchRequestId`
- `business.participantId`
- `business.sessionId`
- `business.sessionToken`
- `business.sessionTokenExpiresAt`
- `business.realtimeEndpoint`
- `business.realtimeToken`
这意味着:
- backend `launch` 返回的数据结构已经能自然装进小程序地图启动链
- 地图页并不需要重构启动模型,只需要把业务页接到 `GameLaunchEnvelope`
## 3.2 地图页已经支持远端 manifest 启动
- [map.ts](D:/dev/cmr-mini/miniprogram/pages/map/map.ts)
当前地图页会:
1. 解析 `GameLaunchEnvelope`
2.`loadRemoteMapConfig(configUrl)`
3. 编译 runtime profile
4. 启动 `MapEngine`
所以只要后端能给出:
- `manifestUrl`
- `releaseId`
- `configChecksumSha256`
地图页就可以直接跑。
## 3.3 会话态字段已经进入地图页
地图页当前已经能接收并持有:
- `sessionId`
- `sessionToken`
- `sessionTokenExpiresAt`
这说明后面接:
- `POST /sessions/{id}/start`
- `POST /sessions/{id}/finish`
不需要再改地图启动协议。
## 3.4 故障恢复也已经具备会话上下文承载
故障恢复快照当前会保留:
- `launchEnvelope`
- 运行态快照
这意味着一旦接入后端 session 后,恢复链也可以继续沿用同一份 `launchEnvelope`
## 4. backend 当前已具备的联调基础
## 4.1 路由主链已落地
- [router.go](D:/dev/cmr-mini/backend/internal/httpapi/router.go)
当前已实现:
- `POST /auth/login/wechat-mini`
- `GET /me/entry-home`
- `GET /events/{eventPublicID}/play`
- `POST /events/{eventPublicID}/launch`
- `POST /sessions/{sessionPublicID}/start`
- `POST /sessions/{sessionPublicID}/finish`
- `GET /sessions/{sessionPublicID}/result`
- `GET /me/results`
## 4.2 launch 返回结构已贴近客户端
- [核心流程.md](D:/dev/cmr-mini/backend/docs/核心流程.md)
- [接口清单.md](D:/dev/cmr-mini/backend/docs/接口清单.md)
当前 `launch` 返回重点:
- `launch.resolvedRelease.releaseId`
- `launch.resolvedRelease.manifestUrl`
- `launch.resolvedRelease.manifestChecksumSha256`
- `launch.business.sessionId`
- `launch.business.sessionToken`
这和小程序 `GameLaunchEnvelope` 基本是同一语义。
## 4.3 session 运行态和结果态已分离
- [session_service.go](D:/dev/cmr-mini/backend/internal/service/session_service.go)
当前已经区分:
- 业务登录态:`access_token`
- 局内运行态:`sessionToken`
这对地图页是对的,因为地图页真正需要的是:
- 进入前有业务 token
- 进入后局内动作用 sessionToken
## 4.4 开发 workbench 已可用于联调
- [dev_handler.go](D:/dev/cmr-mini/backend/internal/httpapi/handlers/dev_handler.go)
当前 workbench 已能串:
- bootstrap
- auth
- entry/home
- event play / launch
- session start / finish / detail
- result 查询
这对前后端联调非常有价值,说明后端已经不是“只看文档”阶段。
## 5. 当前已经能接的链路
## 5.1 P0登录与业务页前置链
可接:
1. 小程序 `wx.login`
2. `POST /auth/login/wechat-mini`
3. 拿到 `accessToken`
4.`GET /me/entry-home`
5.`GET /events/{eventPublicID}/play`
当前缺口:
- 小程序还没有正式业务页 API 适配层
- 还没有统一 token 持久化与请求封装
## 5.2 P0launch 进入地图
可接:
1. 前置业务页拿到 event play
2.`POST /events/{eventPublicID}/launch`
3. 把返回结果映射成 `GameLaunchEnvelope`
4. `navigateTo('/pages/map/map?...')`
当前缺口:
- 还没有一层 `backend launch -> GameLaunchEnvelope` 的适配函数
- 当前 `gameLaunch.ts` 仍偏 demo/static config 驱动
## 5.3 P0finish 回传结果
可接:
1. 地图页结束一局
2. 提取结果摘要
3.`sessionId + sessionToken``POST /sessions/{id}/finish`
4. 业务页或结果页再查 `GET /sessions/{id}/result`
当前缺口:
- 小程序本地结果页已经有摘要,但还没有正式调用 backend finish
- finish payload 和本地 `resultSummary` 之间还需要一层映射
## 6. 当前还不能说已经接通的链路
## 6.1 配置后台 source/build/release
backend 当前已经有:
- 表结构
- 文档模型
但还没有真正开放:
- `config source`
- `build`
- `release assets`
- `preview launch`
也就是说:
**配置后台链还不能联调,只能联业务主链。**
## 6.2 body profile / 遥测个体化
小程序已经有:
- 身体数据入口
- 遥测 runtime profile
backend 文档里也规划了:
- 用户身体资料
但当前接口清单里还没有明确的 body profile 读接口落到小程序链上,所以这条还不能算当前联调主线。
## 7. 当前最大的接口适配缺口
我认为目前最大缺口只有 4 个:
### 7.1 业务 API 客户端缺失
小程序当前缺:
- 统一 `request` 封装
- token 持久化
- access token 刷新
- backend DTO -> 小程序 view model 适配
### 7.2 launch 适配层缺失
需要一层明确的转换:
`LaunchResponse -> GameLaunchEnvelope`
这里最适合单独做成一个小模块,而不是散落在页面里。
### 7.3 session finish 映射缺失
地图页当前本地已经有:
- 用时
- 分数
- 完成点数
- 里程
- 速度
- 最大心率
但还没有一个稳定函数把它映射为 backend finish payload。
### 7.4 业务结果页与地图结果页还未打通
现在地图页已经有自己的结果页。
后面要决定:
- 地图页结果页先本地展示,再异步回传
- 还是 finish 成功后跳业务结果页
这件事需要前后端统一策略。
## 8. 推荐联调顺序
建议按下面顺序推进,不要跳步:
### 第一步:接微信小程序登录
目标:
- 小程序拿到 `accessToken`
- 能请求鉴权接口
### 第二步:接 event play
目标:
- 小程序业务页能拿到:
- `event`
- `resolvedRelease`
- `play.canLaunch`
- `play.ongoingSession`
### 第三步:接 launch -> map
目标:
- 从后端 launch 返回直接进入地图
- 不再靠 demo preset 手工切配置
### 第四步:接 start / finish / result
目标:
- 开赛后能回传 start
- 结束后能回传 finish
- 结果页能查 backend result
### 第五步:再考虑 ongoing session 恢复
目标:
- backend ongoing session
- 本地故障恢复
两条链统一口径
## 9. 当前已落地的小程序联调适配
小程序侧当前已经补了第一批适配层:
- [backendAuth.ts](D:/dev/cmr-mini/miniprogram/utils/backendAuth.ts)
- [backendApi.ts](D:/dev/cmr-mini/miniprogram/utils/backendApi.ts)
- [backendLaunchAdapter.ts](D:/dev/cmr-mini/miniprogram/utils/backendLaunchAdapter.ts)
- [index.ts](D:/dev/cmr-mini/miniprogram/pages/index/index.ts)
当前已具备:
- 后端 base URL 本地持久化
- access / refresh token 本地持久化
- 微信小程序登录请求封装
- `event play` 请求封装
- `launch -> GameLaunchEnvelope` 适配
- 从首页直接 `launch` 进入地图
- 地图页 `session start / finish` 上报接入
因此当前主链已从“可分析”进入“可实测”。
## 10. 我建议的最近行动项
如果开始联调,我建议先做这 3 件事:
1. 新增小程序 `backendApi` 请求层
先只包 auth / event play / launch / session finish
2. 新增 `launchAdapter`
把 backend launch 响应稳定转成 `GameLaunchEnvelope`
3. 新增 `finishAdapter`
把地图页结果摘要稳定转成 backend finish payload
这三件做完,前后端主链就能真正接起来。
## 11. 一句话结论
当前最真实的进度判断是:
> backend 业务后端主链已经进入可联调阶段;小程序地图运行内核也已经具备承接能力;下一步最值钱的是补小程序业务 API 层和 launch/finish 两个适配器。

View File

@@ -1,194 +0,0 @@
# 开发说明
## 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`
- `ASSET_PUBLIC_BASE_URL`
- `ASSET_BUCKET_ROOT`
- `OSSUTIL_PATH`
- `OSSUTIL_CONFIG_FILE`
## 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
- `ASSET_PUBLIC_BASE_URL` 决定 publish 时如何把公开 URL 映射到 OSS 对象 key
- `ASSET_BUCKET_ROOT` 决定发布对象上传到哪个 bucket 根路径
- `OSSUTIL_PATH``OSSUTIL_CONFIG_FILE` 决定 backend 发布 manifest 时使用哪个 OSS 客户端
## 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
补充说明:
- `publish build` 现在会真实上传 `manifest.json``asset-index.json` 到 OSS
- 如果上传失败,接口会直接报错,不再出现“数据库里已有 release但 OSS 上没有对象”的假成功
并且支持:
- quick flow
- scenario 保存/导入/导出
- curl 导出
- request history
## 6. 当前推荐联调顺序
### 场景一:小程序快速进入
1. `bootstrap-demo`
2. `login/wechat-mini`
3. `me/entry-home`
4. `events/{id}/play`
5. `events/{id}/launch`
6. `sessions/{id}/start`
7. `sessions/{id}/finish`
8. `sessions/{id}/result`
### 场景二APP 主身份
1. `auth/sms/send`
2. `auth/login/sms`
3. `me/entry-home`
4. `launch`
5. `session`
6. `result`
### 场景三:微信轻账号绑定手机号
1. `login/wechat-mini`
2. `auth/sms/send` with `scene=bind_mobile`
3. `auth/bind/mobile`
4. `me/profile`
### 场景四:配置发布到可启动 release
1. `bootstrap-demo`
2. `dev/events/{eventPublicID}/config-sources/import-local`
3. `dev/config-builds/preview`
4. `dev/config-builds/publish`
5. `events/{id}`
6. `events/{id}/launch`
## 7. 当前后续开发建议
文档整理完之后,后面建议按这个顺序继续:
1. 抽出更通用的 `play context -> launch` 模型
2. 补赛事与报名层
3. 补页面配置和白标首页
4. 再考虑实时网关票据
不要跳回去把玩法规则塞进 backend。

View File

@@ -1,425 +0,0 @@
# API 清单
本文档只记录当前 backend 已实现接口,不写未来规划接口。
## 1. Health
### `GET /healthz`
用途:
- 健康检查
## 2. Auth
### `POST /auth/sms/send`
用途:
- 发登录验证码
- 发绑定手机号验证码
核心参数:
- `countryCode`
- `mobile`
- `clientType`
- `deviceKey`
- `scene`
### `POST /auth/login/sms`
用途:
- APP 手机号验证码登录
返回重点:
- `user`
- `tokens.accessToken`
- `tokens.refreshToken`
### `POST /auth/login/wechat-mini`
用途:
- 微信小程序登录
开发态:
- 支持 `dev-` 前缀 code
### `POST /auth/bind/mobile`
鉴权:
- Bearer token
用途:
- 已登录用户绑定手机号
- 必要时执行账号合并
### `POST /auth/refresh`
用途:
- 刷新 access token
### `POST /auth/logout`
用途:
- 撤销 refresh token
## 3. Entry / Home
### `GET /entry/resolve`
用途:
- 解析当前入口归属哪个 tenant / channel
查询参数:
- `channelCode`
- `channelType`
- `platformAppId`
- `tenantCode`
### `GET /home`
用途:
- 返回入口首页卡片
### `GET /cards`
用途:
- 只返回卡片列表
### `GET /me/entry-home`
鉴权:
- Bearer token
用途:
- 首页聚合接口
返回重点:
- `user`
- `tenant`
- `channel`
- `cards`
- `ongoingSession`
- `recentSession`
## 4. Event
### `GET /events/{eventPublicID}`
用途:
- Event 详情
返回重点:
- `event`
- `release`
- `resolvedRelease`
### `GET /events/{eventPublicID}/play`
鉴权:
- Bearer token
用途:
- 活动详情页 / 开始前准备页聚合
返回重点:
- `event`
- `release`
- `resolvedRelease`
- `play.canLaunch`
- `play.primaryAction`
- `play.launchSource`
- `play.ongoingSession`
- `play.recentSession`
### `POST /events/{eventPublicID}/launch`
鉴权:
- Bearer token
用途:
- 基于当前 event 的可启动 release 创建一局 session
请求体重点:
- `releaseId`
- `clientType`
- `deviceKey`
返回重点:
- `launch.source`
- `launch.resolvedRelease`
- `launch.config`
- `launch.business.sessionId`
- `launch.business.sessionToken`
### `GET /events/{eventPublicID}/config-sources`
鉴权:
- Bearer token
用途:
- 查看某个 event 的 source config 列表
### `GET /config-sources/{sourceID}`
鉴权:
- Bearer token
用途:
- 查看单条 source config 明细
### `GET /config-builds/{buildID}`
鉴权:
- Bearer token
用途:
- 查看单次 build 明细
## 5. Session
### `GET /sessions/{sessionPublicID}`
鉴权:
- Bearer token
用途:
- 查询一局详情
返回重点:
- `session`
- `event`
- `resolvedRelease`
### `POST /sessions/{sessionPublicID}/start`
鉴权:
- `sessionToken`
用途:
- 将 session 从 `launched` 推进到 `running`
### `POST /sessions/{sessionPublicID}/finish`
鉴权:
- `sessionToken`
用途:
- 结束一局
- 同时沉淀结果摘要
请求体重点:
- `sessionToken`
- `status`
- `summary.finalDurationSec`
- `summary.finalScore`
- `summary.completedControls`
- `summary.totalControls`
- `summary.distanceMeters`
- `summary.averageSpeedKmh`
- `summary.maxHeartRateBpm`
### `GET /me/sessions`
鉴权:
- Bearer token
用途:
- 查询用户最近 session
## 6. Result
### `GET /sessions/{sessionPublicID}/result`
鉴权:
- Bearer token
用途:
- 查询单局结果页数据
返回重点:
- `session`
- `result`
`session` 中会带:
- `releaseId`
- `configLabel`
### `GET /me/results`
鉴权:
- Bearer token
用途:
- 查询用户最近结果列表
## 7. Profile
### `GET /me`
鉴权:
- Bearer token
用途:
- 当前用户基础信息
### `GET /me/profile`
鉴权:
- Bearer token
用途:
- “我的页”聚合接口
返回重点:
- `user`
- `bindings`
- `recentSessions`
## 8. Dev
### `POST /dev/bootstrap-demo`
环境:
- 仅 non-production
用途:
- 自动准备 demo tenant / channel / event / release / card
### `GET /dev/workbench`
环境:
- 仅 non-production
用途:
- 后端自带 API 测试面板
当前支持:
- bootstrap
- auth
- entry/home
- event/play/launch
- session start/finish/detail
- result 查询
- profile 查询
- quick flows
- scenarios
- request history
- curl 导出
### `GET /dev/config/local-files`
环境:
- 仅 non-production
用途:
- 列出本地配置目录中的 JSON 文件
### `POST /dev/events/{eventPublicID}/config-sources/import-local`
环境:
- 仅 non-production
用途:
- 从本地配置目录导入 source config
请求体重点:
- `fileName`
- `notes`
### `POST /dev/config-builds/preview`
环境:
- 仅 non-production
用途:
- 基于 source config 生成 preview build
请求体重点:
- `sourceId`
### `POST /dev/config-builds/publish`
环境:
- 仅 non-production
用途:
- 将成功的 preview build 发布成正式 release
- 自动切换 `event.current_release_id`
请求体重点:
- `buildId`
返回重点:
- `release.releaseId`
- `release.manifestUrl`
- `release.configLabel`

View File

@@ -1,171 +0,0 @@
# 数据模型
当前 migration 共 5 版。
## 1. 迁移清单
- [0001_init.sql](D:/dev/cmr-mini/backend/migrations/0001_init.sql)
- [0002_launch.sql](D:/dev/cmr-mini/backend/migrations/0002_launch.sql)
- [0003_home.sql](D:/dev/cmr-mini/backend/migrations/0003_home.sql)
- [0004_results.sql](D:/dev/cmr-mini/backend/migrations/0004_results.sql)
- [0005_config_pipeline.sql](D:/dev/cmr-mini/backend/migrations/0005_config_pipeline.sql)
## 2. 表分组
### 2.1 多租户与入口
- `tenants`
- `entry_channels`
职责:
- 识别品牌壳
- 识别渠道入口
- 承接后续俱乐部 / 政府公众号 / H5 / 二维码入口
### 2.2 用户与登录
- `users`
- `login_identities`
- `auth_sms_codes`
- `auth_refresh_tokens`
职责:
- 平台级用户
- 多身份登录
- 验证码记录
- refresh token 持久化
当前身份示例:
- `mobile`
- `wechat_mini_openid`
- `wechat_unionid`
### 2.3 业务对象与配置发布
- `events`
- `event_releases`
职责分工:
- `events` 管业务对象身份和展示
- `event_releases` 管发布后的运行配置入口
关键字段:
- `events.current_release_id`
- `event_releases.release_public_id`
- `event_releases.config_label`
- `event_releases.manifest_url`
- `event_releases.manifest_checksum_sha256`
- `event_releases.route_code`
### 2.4 首页与入口卡片
- `cards`
职责:
- 支撑首页卡片
- 运营入口聚合
- tenant/channel 维度展示控制
### 2.5 运行态
- `game_sessions`
- `session_results`
职责:
- 固化一局游戏
- 固化该局绑定的 release
- 固化局后结果摘要
### 2.6 配置构建与发布资产
- `event_config_sources`
- `event_config_builds`
- `event_release_assets`
职责:
- 保存编辑态 source config
- 保存构建后的 manifest 和 asset index
- 保存正式 release 关联的资产清单
## 3. 当前最关键的关系
### `tenant -> entry_channel`
一个 tenant 下可有多个渠道入口。
### `user -> login_identity`
一个平台用户可绑定多个登录身份。
### `event -> event_release`
一个 event 可有多个 release。
客户端真正进入游戏时,最终会消费其中一份 release 的 manifest。
### `event_release -> game_session`
一局 session 必须绑定一份明确的 release。
这是当前系统最关键的配置驱动约束。
### `game_session -> session_result`
一局结束后可有一条结果摘要。
### `event_config_source -> event_config_build -> event_release`
这是后续配置生命周期主链:
- source 是编辑态
- build 是构建态
- release 是发布态
## 4. 当前已落库但仍应注意的边界
### 4.1 不要把玩法细节塞回事件主表
当前数据库只记录:
- 发布关系
- manifest 入口
- 结果摘要
玩法解释器仍应留在游戏客户端。
### 4.2 不要让历史局跟随当前 release 漂移
即使 event 后面发布新版本:
- 旧 session 仍然指向旧 `event_release_id`
- 旧 result 仍然对应旧 release
### 4.3 不要把登录态和运行态混在一起
当前已有两种 token
- `access_token`
- `sessionToken`
后面如果加实时网关,也应继续区分。
## 5. 当前缺口
当前 schema 还没有这些模块:
- `competitions`
- `registrations`
- `page_configs`
- `clubs`
- `client_devices`
- 实时票据 / 网关票据
这些后面要按真正业务需要补 migration不要先拍脑袋建大而全表。

View File

@@ -1,228 +0,0 @@
# 核心流程
## 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"]
```
补充说明:
- 这条主流程既服务当前小程序,也要服务未来 APP
- 终端差异主要体现在登录方式、设备能力和运行时 UI不应拆成两套业务流程
## 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`
说明:
- APP 是未来更强接入端,后端设计必须预留身体资料、设备绑定、遥测摘要等扩展空间
### 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`
补充约束:
- `launch` 是统一业务启动入口,不应因为 APP / 小程序差异复制两套接口
- 终端差异通过 `clientType``deviceKey`、后续能力声明字段处理
### 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`
也不要走成:
`mini event -> mini launch -> mini game`
或:
`app event -> app launch -> app game`
业务接口必须保持统一,终端差异只进入上下文,不进入对象模型分叉。

View File

@@ -1,233 +0,0 @@
# 系统架构
## 1. 目标
当前 backend 不是一个“给地图页喂数据的简单服务”,而是一个业务壳后端。
它负责:
- 用户与登录
- 多租户与多入口
- 首页与业务入口聚合
- Event 业务对象
- 配置发布解析
- 启动一局游戏
- session 生命周期
- 结果沉淀
它不负责:
- 解释游戏玩法细节
- 运行时解析复杂地图规则
- 直接下发数据库编辑态对象给客户端
补充约束:
- 这套 backend 必须服务未来 APP不是“小程序专用后端”
- 登录方式可以按终端区分,但业务对象和业务接口不能按端分裂成两套
## 2. 分层
### 2.1 平台层
平台层统一处理:
- `tenant`
- `entry_channel`
- `user`
- `login_identity`
- `auth_refresh_token`
这层是整个平台共用能力。
它必须同时支撑:
- APP
- 微信小程序
- 后续公众号 / H5 / 其他渠道
### 2.2 业务层
业务层统一处理:
- `card`
- `event`
- `event_play`
- `entry_home`
- `profile`
它面向页面和运营入口,但不直接承载游戏规则。
### 2.3 配置发布层
配置发布层统一处理:
- `event_release`
- `manifest_url`
- `manifest_checksum_sha256`
- `route_code`
这层是“客户端真正进入游戏时要消费的运行配置入口”。
这里的发布结构应保持终端中立:
- 不写死为小程序专用结构
- 不直接依赖某个端的页面实现
- 允许 APP 和小程序共用同一份 release / manifest
### 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
补充约束:
- release / manifest 只描述运行配置,不承载某个端的页面状态
- 玩家设置、设备能力差异、运行时 UI 编译由客户端自行处理
- 后端负责“发布可运行配置”,不是“替某个端生成最终运行时 profile”
## 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`
- 解析运行配置
- 驱动地图和玩法
- 产生过程数据和结束摘要
适用范围:
- 微信小程序客户端
- 未来 APP 客户端
也就是说:
- 后端按统一业务模型输出
- 终端差异放在客户端运行时适配层,不放在后端业务接口层
### 6.3 后续网关该怎么接
后面如果接实时网关,建议仍然走:
- backend 负责登录与 launch
- launch 或 session 负责产出短期实时票据
- 网关只认 backend 签发的运行态票据
不要把微信身份或业务 token 直接暴露给实时网关。

View File

@@ -1,589 +0,0 @@
# 资源对象与目录方案
本文档用于把“地图复用、KML 复用、内容资源复用、配置发布”统一收成一套后端可执行方案。
目标:
- 不再把所有资源都塞进单个 `event/*.json`
- 让地图、KML、内容模板、主题资源都能独立复用
-`event` 只负责“组合与覆盖”,不拥有底层资源本体
-`release` 能稳定追溯当时到底用了哪一份地图、哪一份 KML、哪一套资源包
- 让同一套资源对象既能服务小程序,也能服务未来 APP
---
## 1. 设计结论
后端后续不要按“一个活动一个完整资源目录”来设计,而要按“资源对象库 + Event 组合 + Release 固化”来设计。
建议统一拆成这 5 类对象:
1. `Map`
2. `Playfield`
3. `GameMode`
4. `ResourcePack`
5. `Event`
它们的关系是:
- `Map`:地图底座
- `Playfield`:空间对象 / KML / 控制点集
- `GameMode`:玩法默认规则
- `ResourcePack`:内容、主题、音频等资源档
- `Event`:业务活动对象,只做引用与少量覆盖
最终客户端吃的仍然不是这些编辑态对象,而是:
- `Release`
- `manifest.json`
补充约束:
- manifest 必须保持终端中立
- 不要在资源对象层把目录或字段设计成“小程序专用资源包”
- APP 与小程序应共享同一套资源对象和 release 记录
---
## 2. 为什么必须这么拆
你现在遇到的核心问题不是“目录怎么摆”,而是“哪些资源会复用”。
当前最典型的复用场景:
- 同一张地图会被多个活动复用
- 同一份 KML / 控制点集会被多个活动复用
- 同一套 H5 内容模板会被多个活动复用
- 同一套主题和音频资源会被多个活动复用
如果继续按 `event -> 自己拥有所有文件` 设计,后面会出现:
- 地图重复拷贝
- KML 重复上传
- 同一资源多个活动版本不一致
- 一次资源修复需要改很多 event
- 历史 session 无法明确追溯当时使用的是哪一版资源
所以正确做法不是“每个 event 一套全量文件”,而是:
`共享资源对象 -> event 引用 -> release 固化版本`
---
## 3. 五类核心对象
## 3.1 Map
作用:
- 表示一张可复用地图底座
最少应包含:
- `code`
- `name`
- `status`
- `tiles root`
- `mapmeta`
- 可选边界、缩放、投影信息
典型场景:
- 一个公园底图
- 一张校园底图
- 一张城区底图
注意:
- `Map` 不等于某场活动
- `Map` 是共享资产
## 3.2 Playfield
作用:
- 表示一份可复用场地对象数据
最常见的承载就是:
- `KML`
- `GeoJSON`
- 控制点集
最少应包含:
- `code`
- `name`
- `kind`
- `sourceType`
- `sourceFile`
- 可选提取元数据
- 控制点数量
- 边界范围
- 是否包含起终点
注意:
- `Playfield` 是共享对象,不属于某个 event 私有
- 同一份 KML 可以被多个 event 复用
## 3.3 GameMode
作用:
- 表示一种玩法模式的默认规则对象
例如:
- `classic-sequential`
- `score-o`
最少应包含:
- `code`
- `mode`
- `defaults json`
注意:
- 它不是最终 event 配置
- 只是玩法默认值来源之一
## 3.4 ResourcePack
作用:
- 表示一套可复用资源档
当前最适合放进来的有:
- `audioProfile`
- `contentProfile`
- `themeProfile`
一个资源包内部可以包含:
- 内容模板
- H5 页面
- 图片
- 图标
- 音效
- 主题色与主题变量
## 3.5 Event
作用:
- 表示一个业务活动实例
它应该只负责:
- 引用哪个 `Map`
- 引用哪个 `Playfield`
- 引用哪个 `GameMode`
- 引用哪个 `ResourcePack`
- 叠加少量 `Event Overrides`
不要让 `Event` 负责:
- 保存整份地图资源
- 保存 KML 原件
- 承担所有玩法默认规则
- 拷贝整套资源包
---
## 4. 推荐目录结构
仓库内建议把“源资源”和“活动源配置”拆开。
推荐结构:
```text
resources/
maps/
lxcb-001/
v2026-03-30/
mapmeta.json
tiles/
playfields/
c01/
v2026-03-30/
course.kml
meta.json
resource-packs/
default-race/
v2026-03-30/
content/
content.html
audio/
theme/
game-modes/
classic-sequential/
v1/
mode.json
score-o/
v1/
mode.json
events/
evt-demo-001/
source.json
```
说明:
- `resources/` 放共享对象的源资源
- `game-modes/` 放玩法默认规则对象
- `events/` 只放活动级 source config
当前根目录 [event](D:/dev/cmr-mini/event) 可以继续保留作为过渡区,但后面建议逐步迁到:
- `events/`
- `resources/`
- `game-modes/`
---
## 5. Event Source 应该怎么写
后续 `event source` 不建议继续直接写死地图路径和 KML 路径,而应该引用对象版本。
推荐形态:
```json
{
"schemaVersion": "1",
"version": "2026.04.01",
"app": {
"id": "evt-demo-001",
"title": "积分赛示例"
},
"refs": {
"map": {
"code": "lxcb-001",
"version": "v2026-03-30"
},
"playfield": {
"code": "c01",
"version": "v2026-03-30"
},
"gameMode": {
"code": "score-o",
"version": "v1"
},
"resourcePack": {
"code": "default-race",
"version": "v2026-03-30"
}
},
"overrides": {
"game": {
"session": {
"maxDurationSec": 5400
},
"punch": {
"radiusMeters": 5
}
},
"playfield": {
"metadata": {
"title": "示例路线",
"code": "demo-001"
}
}
}
}
```
这样 `Event` 管的是:
- 引用
- 覆盖
不是:
- 全量资源路径
- 全量运行时配置
---
## 6. Manifest 生成规则
build / publish 时Go 中间层应做装配:
`Map + Playfield + GameMode + ResourcePack + Event Overrides -> manifest.json`
最终生成给客户端的 manifest 可以保持现在的运行结构,例如:
```json
{
"schemaVersion": "1",
"releaseId": "rel_xxx",
"version": "2026.04.01",
"app": {
"id": "evt-demo-001",
"title": "积分赛示例"
},
"map": {
"tiles": "https://.../maps/lxcb-001/v2026-03-30/tiles/",
"mapmeta": "https://.../maps/lxcb-001/v2026-03-30/mapmeta.json"
},
"playfield": {
"kind": "control-set",
"source": {
"type": "kml",
"url": "https://.../playfields/c01/v2026-03-30/course.kml"
}
},
"game": {
"mode": "score-o"
},
"resources": {
"audioProfile": "default",
"contentProfile": "default",
"themeProfile": "default-race"
}
}
```
这层才是客户端真正消费的配置。
重要边界:
- 后端负责对象装配和发布
- 前端继续负责运行时 profile 编译
- 不把玩家设置和运行时状态写回发布配置
再补一条:
- 不把 APP 专属页面状态或小程序专属页面状态写进 manifest
- 如需终端能力差异,后续通过能力声明或运行时适配层处理
---
## 7. OSS / CDN 目录建议
线上目录不要再继续以:
- `gotomars/event/classic-sequential.json`
- `gotomars/event/score-o.json`
这种“玩法文件名”方式长期演进。
建议改成版本化结构:
```text
gotomars/maps/{mapCode}/{version}/...
gotomars/playfields/{playfieldCode}/{version}/...
gotomars/resource-packs/{packCode}/{version}/...
gotomars/game-modes/{modeCode}/{version}/mode.json
gotomars/event-releases/{eventPublicID}/{releasePublicID}/manifest.json
gotomars/event-releases/{eventPublicID}/{releasePublicID}/asset-index.json
```
好处:
- 共享资源独立版本化
- event release 只固化引用
- 历史 session 可以回溯
- 同一个 map / KML 修复时不会污染所有旧 release
- APP 与小程序可共用相同资源版本,不必重复发两套发布目录
---
## 8. 数据库建模建议
推荐按“主表 + version 表”建模。
建议对象:
- `maps`
- `map_versions`
- `playfields`
- `playfield_versions`
- `game_modes`
- `game_mode_versions`
- `resource_packs`
- `resource_pack_versions`
- `events`
- `event_versions`
- `event_releases`
其中:
- 主表存稳定元信息
- version 表存 `jsonb` 内容和具体资源引用
例如:
### `maps`
- `id`
- `code`
- `name`
- `status`
- `current_version_id`
### `map_versions`
- `id`
- `map_id`
- `version_code`
- `content_jsonb`
- `published_asset_root`
- `status`
### `playfields`
- `id`
- `code`
- `name`
- `kind`
- `status`
- `current_version_id`
### `playfield_versions`
- `id`
- `playfield_id`
- `version_code`
- `source_type`
- `content_jsonb`
- `asset_root`
- `status`
### `resource_packs`
- `id`
- `code`
- `name`
- `status`
- `current_version_id`
### `resource_pack_versions`
- `id`
- `resource_pack_id`
- `version_code`
- `content_jsonb`
- `asset_root`
- `status`
### `game_modes`
- `id`
- `code`
- `name`
- `status`
- `current_version_id`
### `game_mode_versions`
- `id`
- `game_mode_id`
- `version_code`
- `content_jsonb`
- `status`
### `event_versions`
- `id`
- `event_id`
- `version_code`
- `map_version_id`
- `playfield_version_id`
- `game_mode_version_id`
- `resource_pack_version_id`
- `overrides_jsonb`
- `status`
核心点:
- `Event` 不直接指向文件 URL
- `EventVersion` 指向对象版本
- `Release` 固化当时装配结果
---
## 9. 后端职责边界
后端应强管理:
- 对象关系
- 版本关系
- 引用有效性
- 发布装配
- 发布记录
后端不应强管理:
- 每个玩法的所有细字段解释
- 所有 HUD / 动画 / 实验细项的强结构化列
- 玩家运行时设置
- 玩家实时状态
同样不应做:
- 为 APP 和小程序各维护一套资源目录规范
- 为 APP 和小程序各发布一套不同语义的 event 配置
适合继续走 `jsonb` 的内容:
- `game.sequence.*`
- `game.guidance.*`
- `game.presentation.*`
- `playfield.controlOverrides.*`
- 各类实验性字段
---
## 10. 推荐实施顺序
建议不要一次重构到底,按下面顺序推进:
1. 先把概念定住
- `Map`
- `Playfield`
- `GameMode`
- `ResourcePack`
- `Event`
2. 先做文档和目录规范
3. 后端先补对象模型和 version 表草案
4. 配置构建器改成“按引用装配”
5. 发布器改成“版本化共享资源 + event release manifest”
6. 最后再做正式后台 UI
---
## 11. 当前阶段的务实建议
在完全切换到对象化模型前,当前仓库可以先这样过渡:
- 继续保留 [event](D:/dev/cmr-mini/event) 作为最小样例区
- 继续保留现有 `import-local -> preview -> publish`
- 但新的 source 设计和目录设计先按本文档收口
也就是说:
- 短期不推翻现有链路
- 中期把资源引用模型补进来
- 长期把单文件 `event/*.json` 迁到对象化配置系统
---
## 12. 一句话结论
后端资源管理的正确方向不是“每个活动一堆文件”,而是:
`共享资源对象库 + Event 引用装配 + Release 固化发布`
只有这样地图复用、KML 复用、资源包复用、多活动发布才能长期稳定。
并且这套模型必须从一开始就兼顾未来 APP而不是做成“小程序跑通后再重构”的临时结构。

View File

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

View File

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

View File

@@ -1,30 +0,0 @@
github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/golang-jwt/jwt/v5 v5.2.2 h1:Rl4B7itRWVtYIHFrSNd7vhTiz9UpLdi6gZhZ3wEeDy8=
github.com/golang-jwt/jwt/v5 v5.2.2/go.mod h1:pqrtFR0X4osieyHYxtmOUWsAWrfe1Q5UVIyoH402zdk=
github.com/jackc/pgpassfile v1.0.0 h1:/6Hmqy13Ss2zCq62VdNG8tM1wchn8zjSGOBJ6icpsIM=
github.com/jackc/pgpassfile v1.0.0/go.mod h1:CEx0iS5ambNFdcRtxPj5JhEz+xB6uRky5eyVu/W2HEg=
github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 h1:iCEnooe7UlwOQYpKFhBabPMi4aNAfoODPEFNiAnClxo=
github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761/go.mod h1:5TJZWKEWniPve33vlWYSoGYefn3gLQRzjfDlhSJ9ZKM=
github.com/jackc/pgx/v5 v5.7.6 h1:rWQc5FwZSPX58r1OQmkuaNicxdmExaEz5A2DO2hUuTk=
github.com/jackc/pgx/v5 v5.7.6/go.mod h1:aruU7o91Tc2q2cFp5h4uP3f6ztExVpyVv88Xl/8Vl8M=
github.com/jackc/puddle/v2 v2.2.2 h1:PR8nw+E/1w0GLuRFSmiioY6UooMp6KJv0/61nB7icHo=
github.com/jackc/puddle/v2 v2.2.2/go.mod h1:vriiEXHvEE654aYKXXjOvZM39qJ0q+azkZFrfEOc3H4=
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI=
github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
github.com/stretchr/testify v1.8.1 h1:w7B6lhMri9wdJUVmEZPGGhZzrYTPvgJArz7wNPgYKsk=
github.com/stretchr/testify v1.8.1/go.mod h1:w2LPCIKwWwSfY2zedu0+kehJoqGctiVI29o6fzry7u4=
golang.org/x/crypto v0.37.0 h1:kJNSjF/Xp7kU0iB2Z+9viTPMW4EqqsrywMXLJOOsXSE=
golang.org/x/crypto v0.37.0/go.mod h1:vg+k43peMZ0pUMhYmVAWysMK35e6ioLh3wB8ZCAfbVc=
golang.org/x/sync v0.13.0 h1:AauUjRAJ9OSnvULf/ARrrVywoJDy0YS2AwQ98I37610=
golang.org/x/sync v0.13.0/go.mod h1:1dzgHSNfp02xaA81J2MS99Qcpr2w7fw1gpm99rleRqA=
golang.org/x/text v0.24.0 h1:dd5Bzh4yt5KYA8f9CJHCP4FB4D51c2c6JvN37xJJkJ0=
golang.org/x/text v0.24.0/go.mod h1:L8rBsPeo2pSS+xqN0d5u2ikmjtmoJbDBT1b7nHvFCdU=
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=

View File

@@ -1,66 +0,0 @@
package app
import (
"context"
"net/http"
"cmr-backend/internal/httpapi"
"cmr-backend/internal/platform/assets"
"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)
assetPublisher := assets.NewOSSUtilPublisher(cfg.OSSUtilPath, cfg.OSSUtilConfigFile, cfg.AssetBucketRoot, cfg.AssetPublicBaseURL)
configService := service.NewConfigService(store, cfg.LocalEventDir, cfg.AssetBaseURL, assetPublisher)
homeService := service.NewHomeService(store)
profileService := service.NewProfileService(store)
resultService := service.NewResultService(store)
sessionService := service.NewSessionService(store)
devService := service.NewDevService(cfg.AppEnv, store)
meService := service.NewMeService(store)
router := httpapi.NewRouter(cfg.AppEnv, jwtManager, authService, entryService, entryHomeService, eventService, eventPlayService, configService, homeService, profileService, resultService, sessionService, devService, meService)
return &App{
router: router,
store: store,
}, nil
}
func (a *App) Router() http.Handler {
return a.router
}
func (a *App) Close() {
if a.store != nil {
a.store.Close()
}
}

View File

@@ -1,89 +0,0 @@
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
AssetPublicBaseURL string
AssetBucketRoot string
OSSUtilPath string
OSSUtilConfigFile 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"),
AssetPublicBaseURL: getEnv("ASSET_PUBLIC_BASE_URL", "https://oss-mbh5.colormaprun.com"),
AssetBucketRoot: getEnv("ASSET_BUCKET_ROOT", "oss://color-map-html"),
OSSUtilPath: getEnv("OSSUTIL_PATH", filepath.Clean("..\\tools\\ossutil.exe")),
OSSUtilConfigFile: getEnv("OSSUTIL_CONFIG_FILE", filepath.Join(mustUserHomeDir(), ".ossutilconfig")),
}
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
}
func mustUserHomeDir() string {
home, err := os.UserHomeDir()
if err != nil {
return "."
}
return home
}

View File

@@ -1,29 +0,0 @@
package apperr
import "errors"
type Error struct {
Status int `json:"-"`
Code string `json:"code"`
Message string `json:"message"`
}
func (e *Error) Error() string {
return e.Message
}
func New(status int, code, message string) *Error {
return &Error{
Status: status,
Code: code,
Message: message,
}
}
func From(err error) *Error {
var appErr *Error
if errors.As(err, &appErr) {
return appErr
}
return nil
}

View File

@@ -1,129 +0,0 @@
package handlers
import (
"net/http"
"cmr-backend/internal/apperr"
"cmr-backend/internal/httpapi/middleware"
"cmr-backend/internal/httpx"
"cmr-backend/internal/service"
)
type AuthHandler struct {
authService *service.AuthService
}
func NewAuthHandler(authService *service.AuthService) *AuthHandler {
return &AuthHandler{authService: authService}
}
func (h *AuthHandler) SendSMSCode(w http.ResponseWriter, r *http.Request) {
var req service.SendSMSCodeInput
if err := httpx.DecodeJSON(r, &req); err != nil {
httpx.WriteError(w, apperr.New(http.StatusBadRequest, "invalid_json", "invalid request body"))
return
}
result, err := h.authService.SendSMSCode(r.Context(), req)
if err != nil {
httpx.WriteError(w, err)
return
}
httpx.WriteJSON(w, http.StatusOK, map[string]any{"data": result})
}
func (h *AuthHandler) LoginSMS(w http.ResponseWriter, r *http.Request) {
var req service.LoginSMSInput
if err := httpx.DecodeJSON(r, &req); err != nil {
httpx.WriteError(w, apperr.New(http.StatusBadRequest, "invalid_json", "invalid request body"))
return
}
result, err := h.authService.LoginSMS(r.Context(), req)
if err != nil {
httpx.WriteError(w, err)
return
}
httpx.WriteJSON(w, http.StatusOK, map[string]any{"data": result})
}
func (h *AuthHandler) LoginWechatMini(w http.ResponseWriter, r *http.Request) {
var req service.LoginWechatMiniInput
if err := httpx.DecodeJSON(r, &req); err != nil {
httpx.WriteError(w, apperr.New(http.StatusBadRequest, "invalid_json", "invalid request body"))
return
}
result, err := h.authService.LoginWechatMini(r.Context(), req)
if err != nil {
httpx.WriteError(w, err)
return
}
httpx.WriteJSON(w, http.StatusOK, map[string]any{"data": result})
}
func (h *AuthHandler) BindMobile(w http.ResponseWriter, r *http.Request) {
var req service.BindMobileInput
if err := httpx.DecodeJSON(r, &req); err != nil {
httpx.WriteError(w, apperr.New(http.StatusBadRequest, "invalid_json", "invalid request body"))
return
}
auth := middleware.GetAuthContext(r.Context())
if auth == nil {
httpx.WriteError(w, apperr.New(http.StatusUnauthorized, "unauthorized", "missing auth context"))
return
}
req.UserID = auth.UserID
result, err := h.authService.BindMobile(r.Context(), req)
if err != nil {
httpx.WriteError(w, err)
return
}
httpx.WriteJSON(w, http.StatusOK, map[string]any{"data": result})
}
func (h *AuthHandler) Refresh(w http.ResponseWriter, r *http.Request) {
var req service.RefreshTokenInput
if err := httpx.DecodeJSON(r, &req); err != nil {
httpx.WriteError(w, apperr.New(http.StatusBadRequest, "invalid_json", "invalid request body"))
return
}
result, err := h.authService.Refresh(r.Context(), req)
if err != nil {
httpx.WriteError(w, err)
return
}
httpx.WriteJSON(w, http.StatusOK, map[string]any{"data": result})
}
func (h *AuthHandler) Logout(w http.ResponseWriter, r *http.Request) {
var req service.LogoutInput
if err := httpx.DecodeJSON(r, &req); err != nil {
httpx.WriteError(w, apperr.New(http.StatusBadRequest, "invalid_json", "invalid request body"))
return
}
auth := middleware.GetAuthContext(r.Context())
if auth != nil && req.UserID == "" {
req.UserID = auth.UserID
}
if err := h.authService.Logout(r.Context(), req); err != nil {
httpx.WriteError(w, err)
return
}
httpx.WriteJSON(w, http.StatusOK, map[string]any{
"data": map[string]any{
"loggedOut": true,
},
})
}

View File

@@ -1,107 +0,0 @@
package handlers
import (
"net/http"
"strconv"
"cmr-backend/internal/apperr"
"cmr-backend/internal/httpx"
"cmr-backend/internal/service"
)
type ConfigHandler struct {
configService *service.ConfigService
}
func NewConfigHandler(configService *service.ConfigService) *ConfigHandler {
return &ConfigHandler{configService: configService}
}
func (h *ConfigHandler) ListLocalFiles(w http.ResponseWriter, r *http.Request) {
result, err := h.configService.ListLocalEventFiles()
if err != nil {
httpx.WriteError(w, err)
return
}
httpx.WriteJSON(w, http.StatusOK, map[string]any{"data": result})
}
func (h *ConfigHandler) ImportLocal(w http.ResponseWriter, r *http.Request) {
var req service.ImportLocalEventConfigInput
if err := httpx.DecodeJSON(r, &req); err != nil {
httpx.WriteError(w, apperr.New(http.StatusBadRequest, "invalid_json", "invalid request body"))
return
}
req.EventPublicID = r.PathValue("eventPublicID")
result, err := h.configService.ImportLocalEventConfig(r.Context(), req)
if err != nil {
httpx.WriteError(w, err)
return
}
httpx.WriteJSON(w, http.StatusOK, map[string]any{"data": result})
}
func (h *ConfigHandler) BuildPreview(w http.ResponseWriter, r *http.Request) {
var req service.BuildPreviewInput
if err := httpx.DecodeJSON(r, &req); err != nil {
httpx.WriteError(w, apperr.New(http.StatusBadRequest, "invalid_json", "invalid request body"))
return
}
result, err := h.configService.BuildPreview(r.Context(), req)
if err != nil {
httpx.WriteError(w, err)
return
}
httpx.WriteJSON(w, http.StatusOK, map[string]any{"data": result})
}
func (h *ConfigHandler) PublishBuild(w http.ResponseWriter, r *http.Request) {
var req service.PublishBuildInput
if err := httpx.DecodeJSON(r, &req); err != nil {
httpx.WriteError(w, apperr.New(http.StatusBadRequest, "invalid_json", "invalid request body"))
return
}
result, err := h.configService.PublishBuild(r.Context(), req)
if err != nil {
httpx.WriteError(w, err)
return
}
httpx.WriteJSON(w, http.StatusOK, map[string]any{"data": result})
}
func (h *ConfigHandler) ListSources(w http.ResponseWriter, r *http.Request) {
limit := 20
if raw := r.URL.Query().Get("limit"); raw != "" {
if parsed, err := strconv.Atoi(raw); err == nil {
limit = parsed
}
}
result, err := h.configService.ListEventConfigSources(r.Context(), r.PathValue("eventPublicID"), limit)
if err != nil {
httpx.WriteError(w, err)
return
}
httpx.WriteJSON(w, http.StatusOK, map[string]any{"data": result})
}
func (h *ConfigHandler) GetSource(w http.ResponseWriter, r *http.Request) {
result, err := h.configService.GetEventConfigSource(r.Context(), r.PathValue("sourceID"))
if err != nil {
httpx.WriteError(w, err)
return
}
httpx.WriteJSON(w, http.StatusOK, map[string]any{"data": result})
}
func (h *ConfigHandler) GetBuild(w http.ResponseWriter, r *http.Request) {
result, err := h.configService.GetEventConfigBuild(r.Context(), r.PathValue("buildID"))
if err != nil {
httpx.WriteError(w, err)
return
}
httpx.WriteJSON(w, http.StatusOK, map[string]any{"data": result})
}

File diff suppressed because it is too large Load Diff

View File

@@ -1,31 +0,0 @@
package handlers
import (
"net/http"
"cmr-backend/internal/httpx"
"cmr-backend/internal/service"
)
type EntryHandler struct {
entryService *service.EntryService
}
func NewEntryHandler(entryService *service.EntryService) *EntryHandler {
return &EntryHandler{entryService: entryService}
}
func (h *EntryHandler) Resolve(w http.ResponseWriter, r *http.Request) {
result, err := h.entryService.Resolve(r.Context(), service.ResolveEntryInput{
ChannelCode: r.URL.Query().Get("channelCode"),
ChannelType: r.URL.Query().Get("channelType"),
PlatformAppID: r.URL.Query().Get("platformAppId"),
TenantCode: r.URL.Query().Get("tenantCode"),
})
if err != nil {
httpx.WriteError(w, err)
return
}
httpx.WriteJSON(w, http.StatusOK, map[string]any{"data": result})
}

View File

@@ -1,40 +0,0 @@
package handlers
import (
"net/http"
"cmr-backend/internal/apperr"
"cmr-backend/internal/httpapi/middleware"
"cmr-backend/internal/httpx"
"cmr-backend/internal/service"
)
type EntryHomeHandler struct {
entryHomeService *service.EntryHomeService
}
func NewEntryHomeHandler(entryHomeService *service.EntryHomeService) *EntryHomeHandler {
return &EntryHomeHandler{entryHomeService: entryHomeService}
}
func (h *EntryHomeHandler) Get(w http.ResponseWriter, r *http.Request) {
auth := middleware.GetAuthContext(r.Context())
if auth == nil {
httpx.WriteError(w, apperr.New(http.StatusUnauthorized, "unauthorized", "missing auth context"))
return
}
result, err := h.entryHomeService.GetEntryHome(r.Context(), service.EntryHomeInput{
UserID: auth.UserID,
ChannelCode: r.URL.Query().Get("channelCode"),
ChannelType: r.URL.Query().Get("channelType"),
PlatformAppID: r.URL.Query().Get("platformAppId"),
TenantCode: r.URL.Query().Get("tenantCode"),
})
if err != nil {
httpx.WriteError(w, err)
return
}
httpx.WriteJSON(w, http.StatusOK, map[string]any{"data": result})
}

View File

@@ -1,51 +0,0 @@
package handlers
import (
"net/http"
"cmr-backend/internal/apperr"
"cmr-backend/internal/httpapi/middleware"
"cmr-backend/internal/httpx"
"cmr-backend/internal/service"
)
type EventHandler struct {
eventService *service.EventService
}
func NewEventHandler(eventService *service.EventService) *EventHandler {
return &EventHandler{eventService: eventService}
}
func (h *EventHandler) GetDetail(w http.ResponseWriter, r *http.Request) {
eventPublicID := r.PathValue("eventPublicID")
result, err := h.eventService.GetEventDetail(r.Context(), eventPublicID)
if err != nil {
httpx.WriteError(w, err)
return
}
httpx.WriteJSON(w, http.StatusOK, map[string]any{"data": result})
}
func (h *EventHandler) Launch(w http.ResponseWriter, r *http.Request) {
auth := middleware.GetAuthContext(r.Context())
if auth == nil {
httpx.WriteError(w, apperr.New(http.StatusUnauthorized, "unauthorized", "missing auth context"))
return
}
var req service.LaunchEventInput
if err := httpx.DecodeJSON(r, &req); err != nil {
httpx.WriteError(w, apperr.New(http.StatusBadRequest, "invalid_json", "invalid request body"))
return
}
req.EventPublicID = r.PathValue("eventPublicID")
req.UserID = auth.UserID
result, err := h.eventService.LaunchEvent(r.Context(), req)
if err != nil {
httpx.WriteError(w, err)
return
}
httpx.WriteJSON(w, http.StatusOK, map[string]any{"data": result})
}

View File

@@ -1,37 +0,0 @@
package handlers
import (
"net/http"
"cmr-backend/internal/apperr"
"cmr-backend/internal/httpapi/middleware"
"cmr-backend/internal/httpx"
"cmr-backend/internal/service"
)
type EventPlayHandler struct {
eventPlayService *service.EventPlayService
}
func NewEventPlayHandler(eventPlayService *service.EventPlayService) *EventPlayHandler {
return &EventPlayHandler{eventPlayService: eventPlayService}
}
func (h *EventPlayHandler) Get(w http.ResponseWriter, r *http.Request) {
auth := middleware.GetAuthContext(r.Context())
if auth == nil {
httpx.WriteError(w, apperr.New(http.StatusUnauthorized, "unauthorized", "missing auth context"))
return
}
result, err := h.eventPlayService.GetEventPlay(r.Context(), service.EventPlayInput{
EventPublicID: r.PathValue("eventPublicID"),
UserID: auth.UserID,
})
if err != nil {
httpx.WriteError(w, err)
return
}
httpx.WriteJSON(w, http.StatusOK, map[string]any{"data": result})
}

View File

@@ -1,21 +0,0 @@
package handlers
import (
"net/http"
"cmr-backend/internal/httpx"
)
type HealthHandler struct{}
func NewHealthHandler() *HealthHandler {
return &HealthHandler{}
}
func (h *HealthHandler) Get(w http.ResponseWriter, r *http.Request) {
httpx.WriteJSON(w, http.StatusOK, map[string]any{
"data": map[string]any{
"status": "ok",
},
})
}

View File

@@ -1,53 +0,0 @@
package handlers
import (
"net/http"
"strconv"
"cmr-backend/internal/httpx"
"cmr-backend/internal/service"
)
type HomeHandler struct {
homeService *service.HomeService
}
func NewHomeHandler(homeService *service.HomeService) *HomeHandler {
return &HomeHandler{homeService: homeService}
}
func (h *HomeHandler) GetHome(w http.ResponseWriter, r *http.Request) {
result, err := h.homeService.GetHome(r.Context(), buildListCardsInput(r))
if err != nil {
httpx.WriteError(w, err)
return
}
httpx.WriteJSON(w, http.StatusOK, map[string]any{"data": result})
}
func (h *HomeHandler) GetCards(w http.ResponseWriter, r *http.Request) {
result, err := h.homeService.ListCards(r.Context(), buildListCardsInput(r))
if err != nil {
httpx.WriteError(w, err)
return
}
httpx.WriteJSON(w, http.StatusOK, map[string]any{"data": result})
}
func buildListCardsInput(r *http.Request) service.ListCardsInput {
limit := 20
if raw := r.URL.Query().Get("limit"); raw != "" {
if parsed, err := strconv.Atoi(raw); err == nil {
limit = parsed
}
}
return service.ListCardsInput{
ChannelCode: r.URL.Query().Get("channelCode"),
ChannelType: r.URL.Query().Get("channelType"),
PlatformAppID: r.URL.Query().Get("platformAppId"),
TenantCode: r.URL.Query().Get("tenantCode"),
Slot: r.URL.Query().Get("slot"),
Limit: limit,
}
}

View File

@@ -1,34 +0,0 @@
package handlers
import (
"net/http"
"cmr-backend/internal/apperr"
"cmr-backend/internal/httpapi/middleware"
"cmr-backend/internal/httpx"
"cmr-backend/internal/service"
)
type MeHandler struct {
meService *service.MeService
}
func NewMeHandler(meService *service.MeService) *MeHandler {
return &MeHandler{meService: meService}
}
func (h *MeHandler) Get(w http.ResponseWriter, r *http.Request) {
auth := middleware.GetAuthContext(r.Context())
if auth == nil {
httpx.WriteError(w, apperr.New(http.StatusUnauthorized, "unauthorized", "missing auth context"))
return
}
user, err := h.meService.GetMe(r.Context(), auth.UserID)
if err != nil {
httpx.WriteError(w, err)
return
}
httpx.WriteJSON(w, http.StatusOK, map[string]any{"data": user})
}

View File

@@ -1,34 +0,0 @@
package handlers
import (
"net/http"
"cmr-backend/internal/apperr"
"cmr-backend/internal/httpapi/middleware"
"cmr-backend/internal/httpx"
"cmr-backend/internal/service"
)
type ProfileHandler struct {
profileService *service.ProfileService
}
func NewProfileHandler(profileService *service.ProfileService) *ProfileHandler {
return &ProfileHandler{profileService: profileService}
}
func (h *ProfileHandler) Get(w http.ResponseWriter, r *http.Request) {
auth := middleware.GetAuthContext(r.Context())
if auth == nil {
httpx.WriteError(w, apperr.New(http.StatusUnauthorized, "unauthorized", "missing auth context"))
return
}
result, err := h.profileService.GetProfile(r.Context(), auth.UserID)
if err != nil {
httpx.WriteError(w, err)
return
}
httpx.WriteJSON(w, http.StatusOK, map[string]any{"data": result})
}

View File

@@ -1,58 +0,0 @@
package handlers
import (
"net/http"
"strconv"
"cmr-backend/internal/apperr"
"cmr-backend/internal/httpapi/middleware"
"cmr-backend/internal/httpx"
"cmr-backend/internal/service"
)
type ResultHandler struct {
resultService *service.ResultService
}
func NewResultHandler(resultService *service.ResultService) *ResultHandler {
return &ResultHandler{resultService: resultService}
}
func (h *ResultHandler) GetSessionResult(w http.ResponseWriter, r *http.Request) {
auth := middleware.GetAuthContext(r.Context())
if auth == nil {
httpx.WriteError(w, apperr.New(http.StatusUnauthorized, "unauthorized", "missing auth context"))
return
}
result, err := h.resultService.GetSessionResult(r.Context(), r.PathValue("sessionPublicID"), auth.UserID)
if err != nil {
httpx.WriteError(w, err)
return
}
httpx.WriteJSON(w, http.StatusOK, map[string]any{"data": result})
}
func (h *ResultHandler) ListMine(w http.ResponseWriter, r *http.Request) {
auth := middleware.GetAuthContext(r.Context())
if auth == nil {
httpx.WriteError(w, apperr.New(http.StatusUnauthorized, "unauthorized", "missing auth context"))
return
}
limit := 20
if raw := r.URL.Query().Get("limit"); raw != "" {
if parsed, err := strconv.Atoi(raw); err == nil {
limit = parsed
}
}
result, err := h.resultService.ListMyResults(r.Context(), auth.UserID, limit)
if err != nil {
httpx.WriteError(w, err)
return
}
httpx.WriteJSON(w, http.StatusOK, map[string]any{"data": result})
}

View File

@@ -1,88 +0,0 @@
package handlers
import (
"net/http"
"strconv"
"cmr-backend/internal/apperr"
"cmr-backend/internal/httpapi/middleware"
"cmr-backend/internal/httpx"
"cmr-backend/internal/service"
)
type SessionHandler struct {
sessionService *service.SessionService
}
func NewSessionHandler(sessionService *service.SessionService) *SessionHandler {
return &SessionHandler{sessionService: sessionService}
}
func (h *SessionHandler) GetDetail(w http.ResponseWriter, r *http.Request) {
auth := middleware.GetAuthContext(r.Context())
if auth == nil {
httpx.WriteError(w, apperr.New(http.StatusUnauthorized, "unauthorized", "missing auth context"))
return
}
result, err := h.sessionService.GetSession(r.Context(), r.PathValue("sessionPublicID"), auth.UserID)
if err != nil {
httpx.WriteError(w, err)
return
}
httpx.WriteJSON(w, http.StatusOK, map[string]any{"data": result})
}
func (h *SessionHandler) ListMine(w http.ResponseWriter, r *http.Request) {
auth := middleware.GetAuthContext(r.Context())
if auth == nil {
httpx.WriteError(w, apperr.New(http.StatusUnauthorized, "unauthorized", "missing auth context"))
return
}
limit := 20
if raw := r.URL.Query().Get("limit"); raw != "" {
if parsed, err := strconv.Atoi(raw); err == nil {
limit = parsed
}
}
result, err := h.sessionService.ListMySessions(r.Context(), auth.UserID, limit)
if err != nil {
httpx.WriteError(w, err)
return
}
httpx.WriteJSON(w, http.StatusOK, map[string]any{"data": result})
}
func (h *SessionHandler) Start(w http.ResponseWriter, r *http.Request) {
var req service.SessionActionInput
if err := httpx.DecodeJSON(r, &req); err != nil {
httpx.WriteError(w, apperr.New(http.StatusBadRequest, "invalid_json", "invalid request body: "+err.Error()))
return
}
req.SessionPublicID = r.PathValue("sessionPublicID")
result, err := h.sessionService.StartSession(r.Context(), req)
if err != nil {
httpx.WriteError(w, err)
return
}
httpx.WriteJSON(w, http.StatusOK, map[string]any{"data": result})
}
func (h *SessionHandler) Finish(w http.ResponseWriter, r *http.Request) {
var req service.FinishSessionInput
if err := httpx.DecodeJSON(r, &req); err != nil {
httpx.WriteError(w, apperr.New(http.StatusBadRequest, "invalid_json", "invalid request body: "+err.Error()))
return
}
req.SessionPublicID = r.PathValue("sessionPublicID")
result, err := h.sessionService.FinishSession(r.Context(), req)
if err != nil {
httpx.WriteError(w, err)
return
}
httpx.WriteJSON(w, http.StatusOK, map[string]any{"data": result})
}

View File

@@ -1,50 +0,0 @@
package middleware
import (
"context"
"net/http"
"strings"
"cmr-backend/internal/apperr"
"cmr-backend/internal/httpx"
"cmr-backend/internal/platform/jwtx"
)
type authContextKey string
const authKey authContextKey = "auth"
type AuthContext struct {
UserID string
UserPublicID string
}
func NewAuthMiddleware(jwtManager *jwtx.Manager) func(http.Handler) http.Handler {
return func(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
authHeader := strings.TrimSpace(r.Header.Get("Authorization"))
if authHeader == "" || !strings.HasPrefix(authHeader, "Bearer ") {
httpx.WriteError(w, apperr.New(http.StatusUnauthorized, "unauthorized", "missing bearer token"))
return
}
token := strings.TrimSpace(strings.TrimPrefix(authHeader, "Bearer "))
claims, err := jwtManager.ParseAccessToken(token)
if err != nil {
httpx.WriteError(w, apperr.New(http.StatusUnauthorized, "invalid_token", "invalid access token"))
return
}
ctx := context.WithValue(r.Context(), authKey, &AuthContext{
UserID: claims.UserID,
UserPublicID: claims.UserPublicID,
})
next.ServeHTTP(w, r.WithContext(ctx))
})
}
}
func GetAuthContext(ctx context.Context) *AuthContext {
auth, _ := ctx.Value(authKey).(*AuthContext)
return auth
}

View File

@@ -1,80 +0,0 @@
package httpapi
import (
"net/http"
"cmr-backend/internal/httpapi/handlers"
"cmr-backend/internal/httpapi/middleware"
"cmr-backend/internal/platform/jwtx"
"cmr-backend/internal/service"
)
func NewRouter(
appEnv string,
jwtManager *jwtx.Manager,
authService *service.AuthService,
entryService *service.EntryService,
entryHomeService *service.EntryHomeService,
eventService *service.EventService,
eventPlayService *service.EventPlayService,
configService *service.ConfigService,
homeService *service.HomeService,
profileService *service.ProfileService,
resultService *service.ResultService,
sessionService *service.SessionService,
devService *service.DevService,
meService *service.MeService,
) http.Handler {
mux := http.NewServeMux()
healthHandler := handlers.NewHealthHandler()
authHandler := handlers.NewAuthHandler(authService)
entryHandler := handlers.NewEntryHandler(entryService)
entryHomeHandler := handlers.NewEntryHomeHandler(entryHomeService)
eventHandler := handlers.NewEventHandler(eventService)
eventPlayHandler := handlers.NewEventPlayHandler(eventPlayService)
configHandler := handlers.NewConfigHandler(configService)
homeHandler := handlers.NewHomeHandler(homeService)
profileHandler := handlers.NewProfileHandler(profileService)
resultHandler := handlers.NewResultHandler(resultService)
sessionHandler := handlers.NewSessionHandler(sessionService)
devHandler := handlers.NewDevHandler(devService)
meHandler := handlers.NewMeHandler(meService)
authMiddleware := middleware.NewAuthMiddleware(jwtManager)
mux.HandleFunc("GET /healthz", healthHandler.Get)
mux.HandleFunc("GET /home", homeHandler.GetHome)
mux.HandleFunc("GET /cards", homeHandler.GetCards)
mux.HandleFunc("GET /entry/resolve", entryHandler.Resolve)
if appEnv != "production" {
mux.HandleFunc("GET /dev/workbench", devHandler.Workbench)
mux.HandleFunc("POST /dev/bootstrap-demo", devHandler.BootstrapDemo)
mux.HandleFunc("GET /dev/config/local-files", configHandler.ListLocalFiles)
mux.HandleFunc("POST /dev/events/{eventPublicID}/config-sources/import-local", configHandler.ImportLocal)
mux.HandleFunc("POST /dev/config-builds/preview", configHandler.BuildPreview)
mux.HandleFunc("POST /dev/config-builds/publish", configHandler.PublishBuild)
}
mux.Handle("GET /me/entry-home", authMiddleware(http.HandlerFunc(entryHomeHandler.Get)))
mux.Handle("GET /me/profile", authMiddleware(http.HandlerFunc(profileHandler.Get)))
mux.HandleFunc("GET /events/{eventPublicID}", eventHandler.GetDetail)
mux.Handle("GET /events/{eventPublicID}/play", authMiddleware(http.HandlerFunc(eventPlayHandler.Get)))
mux.Handle("GET /events/{eventPublicID}/config-sources", authMiddleware(http.HandlerFunc(configHandler.ListSources)))
mux.Handle("POST /events/{eventPublicID}/launch", authMiddleware(http.HandlerFunc(eventHandler.Launch)))
mux.Handle("GET /config-sources/{sourceID}", authMiddleware(http.HandlerFunc(configHandler.GetSource)))
mux.Handle("GET /config-builds/{buildID}", authMiddleware(http.HandlerFunc(configHandler.GetBuild)))
mux.Handle("GET /sessions/{sessionPublicID}", authMiddleware(http.HandlerFunc(sessionHandler.GetDetail)))
mux.Handle("GET /sessions/{sessionPublicID}/result", authMiddleware(http.HandlerFunc(resultHandler.GetSessionResult)))
mux.HandleFunc("POST /sessions/{sessionPublicID}/start", sessionHandler.Start)
mux.HandleFunc("POST /sessions/{sessionPublicID}/finish", sessionHandler.Finish)
mux.HandleFunc("POST /auth/sms/send", authHandler.SendSMSCode)
mux.HandleFunc("POST /auth/login/sms", authHandler.LoginSMS)
mux.HandleFunc("POST /auth/login/wechat-mini", authHandler.LoginWechatMini)
mux.Handle("POST /auth/bind/mobile", authMiddleware(http.HandlerFunc(authHandler.BindMobile)))
mux.HandleFunc("POST /auth/refresh", authHandler.Refresh)
mux.HandleFunc("POST /auth/logout", authHandler.Logout)
mux.Handle("GET /me", authMiddleware(http.HandlerFunc(meHandler.Get)))
mux.Handle("GET /me/sessions", authMiddleware(http.HandlerFunc(sessionHandler.ListMine)))
mux.Handle("GET /me/results", authMiddleware(http.HandlerFunc(resultHandler.ListMine)))
return mux
}

View File

@@ -1,39 +0,0 @@
package httpx
import (
"encoding/json"
"net/http"
"cmr-backend/internal/apperr"
)
func WriteJSON(w http.ResponseWriter, status int, payload any) {
w.Header().Set("Content-Type", "application/json; charset=utf-8")
w.WriteHeader(status)
_ = json.NewEncoder(w).Encode(payload)
}
func WriteError(w http.ResponseWriter, err error) {
if appErr := apperr.From(err); appErr != nil {
WriteJSON(w, appErr.Status, map[string]any{
"error": map[string]any{
"code": appErr.Code,
"message": appErr.Message,
},
})
return
}
WriteJSON(w, http.StatusInternalServerError, map[string]any{
"error": map[string]any{
"code": "internal_error",
"message": "internal server error",
},
})
}
func DecodeJSON(r *http.Request, dst any) error {
decoder := json.NewDecoder(r.Body)
decoder.DisallowUnknownFields()
return decoder.Decode(dst)
}

View File

@@ -1,96 +0,0 @@
package assets
import (
"context"
"fmt"
"os"
"os/exec"
"path/filepath"
"strings"
)
type OSSUtilPublisher struct {
ossutilPath string
configFile string
bucketRoot string
publicBaseURL string
}
func NewOSSUtilPublisher(ossutilPath, configFile, bucketRoot, publicBaseURL string) *OSSUtilPublisher {
return &OSSUtilPublisher{
ossutilPath: strings.TrimSpace(ossutilPath),
configFile: strings.TrimSpace(configFile),
bucketRoot: strings.TrimRight(strings.TrimSpace(bucketRoot), "/"),
publicBaseURL: strings.TrimRight(strings.TrimSpace(publicBaseURL), "/"),
}
}
func (p *OSSUtilPublisher) Enabled() bool {
return p != nil &&
p.ossutilPath != "" &&
p.configFile != "" &&
p.bucketRoot != "" &&
p.publicBaseURL != ""
}
func (p *OSSUtilPublisher) UploadJSON(ctx context.Context, publicURL string, payload []byte) error {
if !p.Enabled() {
return fmt.Errorf("asset publisher is not configured")
}
if len(payload) == 0 {
return fmt.Errorf("payload is empty")
}
objectKey, err := p.objectKeyFromPublicURL(publicURL)
if err != nil {
return err
}
if _, err := os.Stat(p.ossutilPath); err != nil {
return fmt.Errorf("ossutil not found: %w", err)
}
if _, err := os.Stat(p.configFile); err != nil {
return fmt.Errorf("ossutil config not found: %w", err)
}
tmpFile, err := os.CreateTemp("", "cmr-manifest-*.json")
if err != nil {
return fmt.Errorf("create temp file: %w", err)
}
tmpPath := tmpFile.Name()
defer os.Remove(tmpPath)
if _, err := tmpFile.Write(payload); err != nil {
tmpFile.Close()
return fmt.Errorf("write temp file: %w", err)
}
if err := tmpFile.Close(); err != nil {
return fmt.Errorf("close temp file: %w", err)
}
target := p.bucketRoot + "/" + objectKey
cmd := exec.CommandContext(ctx, p.ossutilPath, "cp", "-f", tmpPath, target, "--config-file", p.configFile)
output, err := cmd.CombinedOutput()
if err != nil {
return fmt.Errorf("upload object %s failed: %w: %s", objectKey, err, strings.TrimSpace(string(output)))
}
return nil
}
func (p *OSSUtilPublisher) objectKeyFromPublicURL(publicURL string) (string, error) {
publicURL = strings.TrimSpace(publicURL)
if publicURL == "" {
return "", fmt.Errorf("public url is required")
}
if !strings.HasPrefix(publicURL, p.publicBaseURL+"/") {
return "", fmt.Errorf("public url %s does not match public base %s", publicURL, p.publicBaseURL)
}
relative := strings.TrimPrefix(publicURL, p.publicBaseURL+"/")
relative = strings.ReplaceAll(relative, "\\", "/")
relative = strings.TrimLeft(relative, "/")
if relative == "" {
return "", fmt.Errorf("public url %s resolved to empty object key", publicURL)
}
return filepath.ToSlash(relative), nil
}

View File

@@ -1,67 +0,0 @@
package jwtx
import (
"fmt"
"time"
"github.com/golang-jwt/jwt/v5"
)
type Manager struct {
issuer string
secret []byte
ttl time.Duration
}
type AccessClaims struct {
UserID string `json:"uid"`
UserPublicID string `json:"upub"`
jwt.RegisteredClaims
}
func NewManager(issuer, secret string, ttl time.Duration) *Manager {
return &Manager{
issuer: issuer,
secret: []byte(secret),
ttl: ttl,
}
}
func (m *Manager) IssueAccessToken(userID, userPublicID string) (string, time.Time, error) {
expiresAt := time.Now().UTC().Add(m.ttl)
claims := AccessClaims{
UserID: userID,
UserPublicID: userPublicID,
RegisteredClaims: jwt.RegisteredClaims{
Issuer: m.issuer,
Subject: userID,
ExpiresAt: jwt.NewNumericDate(expiresAt),
IssuedAt: jwt.NewNumericDate(time.Now().UTC()),
},
}
token := jwt.NewWithClaims(jwt.SigningMethodHS256, claims)
signed, err := token.SignedString(m.secret)
if err != nil {
return "", time.Time{}, err
}
return signed, expiresAt, nil
}
func (m *Manager) ParseAccessToken(tokenString string) (*AccessClaims, error) {
token, err := jwt.ParseWithClaims(tokenString, &AccessClaims{}, func(token *jwt.Token) (any, error) {
if token.Method != jwt.SigningMethodHS256 {
return nil, fmt.Errorf("unexpected signing method")
}
return m.secret, nil
})
if err != nil {
return nil, err
}
claims, ok := token.Claims.(*AccessClaims)
if !ok || !token.Valid {
return nil, fmt.Errorf("invalid token claims")
}
return claims, nil
}

View File

@@ -1,47 +0,0 @@
package security
import (
"crypto/rand"
"crypto/sha256"
"encoding/base64"
"encoding/hex"
)
func GenerateToken(byteLength int) (string, error) {
raw := make([]byte, byteLength)
if _, err := rand.Read(raw); err != nil {
return "", err
}
return base64.RawURLEncoding.EncodeToString(raw), nil
}
func GenerateNumericCode(length int) (string, error) {
if length <= 0 {
length = 6
}
const digits = "0123456789"
raw := make([]byte, length)
if _, err := rand.Read(raw); err != nil {
return "", err
}
code := make([]byte, length)
for i := range raw {
code[i] = digits[int(raw[i])%len(digits)]
}
return string(code), nil
}
func HashText(value string) string {
sum := sha256.Sum256([]byte(value))
return hex.EncodeToString(sum[:])
}
func GeneratePublicID(prefix string) (string, error) {
raw := make([]byte, 8)
if _, err := rand.Read(raw); err != nil {
return "", err
}
return prefix + "_" + hex.EncodeToString(raw), nil
}

View File

@@ -1,120 +0,0 @@
package wechatmini
import (
"context"
"crypto/sha1"
"encoding/hex"
"encoding/json"
"fmt"
"io"
"net/http"
"net/url"
"strings"
"time"
)
type Client struct {
appID string
appSecret string
devPrefix string
httpClient *http.Client
}
type Session struct {
AppID string
OpenID string
UnionID string
SessionKey string
}
type code2SessionResponse struct {
OpenID string `json:"openid"`
SessionKey string `json:"session_key"`
UnionID string `json:"unionid"`
ErrCode int `json:"errcode"`
ErrMsg string `json:"errmsg"`
}
func NewClient(appID, appSecret, devPrefix string) *Client {
return &Client{
appID: appID,
appSecret: appSecret,
devPrefix: devPrefix,
httpClient: &http.Client{Timeout: 8 * time.Second},
}
}
func (c *Client) ExchangeCode(ctx context.Context, code string) (*Session, error) {
code = strings.TrimSpace(code)
if code == "" {
return nil, fmt.Errorf("wechat code is required")
}
if c.devPrefix != "" && strings.HasPrefix(code, c.devPrefix) {
suffix := strings.TrimPrefix(code, c.devPrefix)
if suffix == "" {
suffix = "default"
}
return &Session{
AppID: fallbackString(c.appID, "dev-mini-app"),
OpenID: "dev_openid_" + normalizeDevID(suffix),
UnionID: "",
}, nil
}
if c.appID == "" || c.appSecret == "" {
return nil, fmt.Errorf("wechat mini app credentials are not configured")
}
values := url.Values{}
values.Set("appid", c.appID)
values.Set("secret", c.appSecret)
values.Set("js_code", code)
values.Set("grant_type", "authorization_code")
req, err := http.NewRequestWithContext(ctx, http.MethodGet, "https://api.weixin.qq.com/sns/jscode2session?"+values.Encode(), nil)
if err != nil {
return nil, err
}
resp, err := c.httpClient.Do(req)
if err != nil {
return nil, err
}
defer resp.Body.Close()
body, err := io.ReadAll(resp.Body)
if err != nil {
return nil, err
}
var parsed code2SessionResponse
if err := json.Unmarshal(body, &parsed); err != nil {
return nil, err
}
if parsed.ErrCode != 0 {
return nil, fmt.Errorf("wechat code2session failed: %d %s", parsed.ErrCode, parsed.ErrMsg)
}
if parsed.OpenID == "" {
return nil, fmt.Errorf("wechat code2session returned empty openid")
}
return &Session{
AppID: c.appID,
OpenID: parsed.OpenID,
UnionID: parsed.UnionID,
SessionKey: parsed.SessionKey,
}, nil
}
func normalizeDevID(value string) string {
sum := sha1.Sum([]byte(value))
return hex.EncodeToString(sum[:])[:16]
}
func fallbackString(value, fallback string) string {
if value != "" {
return value
}
return fallback
}

View File

@@ -1,595 +0,0 @@
package service
import (
"context"
"encoding/json"
"net/http"
"strings"
"time"
"cmr-backend/internal/apperr"
"cmr-backend/internal/platform/jwtx"
"cmr-backend/internal/platform/security"
"cmr-backend/internal/platform/wechatmini"
"cmr-backend/internal/store/postgres"
)
type AuthSettings struct {
AppEnv string
RefreshTTL time.Duration
SMSCodeTTL time.Duration
SMSCodeCooldown time.Duration
SMSProvider string
DevSMSCode string
WechatMini *wechatmini.Client
}
type AuthService struct {
cfg AuthSettings
store *postgres.Store
jwtManager *jwtx.Manager
}
type SendSMSCodeInput struct {
CountryCode string `json:"countryCode"`
Mobile string `json:"mobile"`
ClientType string `json:"clientType"`
DeviceKey string `json:"deviceKey"`
Scene string `json:"scene"`
}
type SendSMSCodeResult struct {
TTLSeconds int64 `json:"ttlSeconds"`
CooldownSeconds int64 `json:"cooldownSeconds"`
DevCode *string `json:"devCode,omitempty"`
}
type LoginSMSInput struct {
CountryCode string `json:"countryCode"`
Mobile string `json:"mobile"`
Code string `json:"code"`
ClientType string `json:"clientType"`
DeviceKey string `json:"deviceKey"`
}
type LoginWechatMiniInput struct {
Code string `json:"code"`
ClientType string `json:"clientType"`
DeviceKey string `json:"deviceKey"`
}
type BindMobileInput struct {
UserID string `json:"-"`
CountryCode string `json:"countryCode"`
Mobile string `json:"mobile"`
Code string `json:"code"`
ClientType string `json:"clientType"`
DeviceKey string `json:"deviceKey"`
}
type RefreshTokenInput struct {
RefreshToken string `json:"refreshToken"`
ClientType string `json:"clientType"`
DeviceKey string `json:"deviceKey"`
}
type LogoutInput struct {
RefreshToken string `json:"refreshToken"`
UserID string `json:"-"`
}
type AuthUser struct {
ID string `json:"id"`
PublicID string `json:"publicId"`
Status string `json:"status"`
Nickname *string `json:"nickname,omitempty"`
AvatarURL *string `json:"avatarUrl,omitempty"`
}
type AuthTokens struct {
AccessToken string `json:"accessToken"`
AccessTokenExpiresAt string `json:"accessTokenExpiresAt"`
RefreshToken string `json:"refreshToken"`
RefreshTokenExpiresAt string `json:"refreshTokenExpiresAt"`
}
type AuthResult struct {
User AuthUser `json:"user"`
Tokens AuthTokens `json:"tokens"`
NewUser bool `json:"newUser"`
}
func NewAuthService(cfg AuthSettings, store *postgres.Store, jwtManager *jwtx.Manager) *AuthService {
return &AuthService{
cfg: cfg,
store: store,
jwtManager: jwtManager,
}
}
func (s *AuthService) SendSMSCode(ctx context.Context, input SendSMSCodeInput) (*SendSMSCodeResult, error) {
input.CountryCode = normalizeCountryCode(input.CountryCode)
input.Mobile = normalizeMobile(input.Mobile)
input.Scene = normalizeScene(input.Scene)
if err := validateClientType(input.ClientType); err != nil {
return nil, err
}
if input.Mobile == "" || input.DeviceKey == "" {
return nil, apperr.New(http.StatusBadRequest, "invalid_params", "mobile and deviceKey are required")
}
latest, err := s.store.GetLatestSMSCodeMeta(ctx, input.CountryCode, input.Mobile, input.ClientType, input.Scene)
if err != nil {
return nil, err
}
now := time.Now().UTC()
if latest != nil && latest.CooldownUntil.After(now) {
return nil, apperr.New(http.StatusTooManyRequests, "sms_cooldown", "sms code sent too frequently")
}
code := s.cfg.DevSMSCode
if code == "" {
code, err = security.GenerateNumericCode(6)
if err != nil {
return nil, err
}
}
expiresAt := now.Add(s.cfg.SMSCodeTTL)
cooldownUntil := now.Add(s.cfg.SMSCodeCooldown)
if err := s.store.CreateSMSCode(ctx, postgres.CreateSMSCodeParams{
Scene: input.Scene,
CountryCode: input.CountryCode,
Mobile: input.Mobile,
ClientType: input.ClientType,
DeviceKey: input.DeviceKey,
CodeHash: security.HashText(code),
ProviderName: s.cfg.SMSProvider,
ProviderDebug: map[string]any{"mode": s.cfg.SMSProvider},
ExpiresAt: expiresAt,
CooldownUntil: cooldownUntil,
}); err != nil {
return nil, err
}
result := &SendSMSCodeResult{
TTLSeconds: int64(s.cfg.SMSCodeTTL.Seconds()),
CooldownSeconds: int64(s.cfg.SMSCodeCooldown.Seconds()),
}
if strings.EqualFold(s.cfg.SMSProvider, "console") || strings.EqualFold(s.cfg.AppEnv, "development") {
result.DevCode = &code
}
return result, nil
}
func (s *AuthService) LoginSMS(ctx context.Context, input LoginSMSInput) (*AuthResult, error) {
input.CountryCode = normalizeCountryCode(input.CountryCode)
input.Mobile = normalizeMobile(input.Mobile)
input.Code = strings.TrimSpace(input.Code)
if err := validateClientType(input.ClientType); err != nil {
return nil, err
}
if input.Mobile == "" || input.DeviceKey == "" || input.Code == "" {
return nil, apperr.New(http.StatusBadRequest, "invalid_params", "mobile, code and deviceKey are required")
}
codeRecord, err := s.store.GetLatestValidSMSCode(ctx, input.CountryCode, input.Mobile, input.ClientType, "login")
if err != nil {
return nil, err
}
if codeRecord == nil || codeRecord.CodeHash != security.HashText(input.Code) {
return nil, apperr.New(http.StatusUnauthorized, "invalid_sms_code", "invalid sms code")
}
tx, err := s.store.Begin(ctx)
if err != nil {
return nil, err
}
defer tx.Rollback(ctx)
consumed, err := s.store.ConsumeSMSCode(ctx, tx, codeRecord.ID)
if err != nil {
return nil, err
}
if !consumed {
return nil, apperr.New(http.StatusUnauthorized, "invalid_sms_code", "sms code already used")
}
user, err := s.store.FindUserByMobile(ctx, tx, input.CountryCode, input.Mobile)
if err != nil {
return nil, err
}
newUser := false
if user == nil {
userPublicID, err := security.GeneratePublicID("usr")
if err != nil {
return nil, err
}
user, err = s.store.CreateUser(ctx, tx, postgres.CreateUserParams{
PublicID: userPublicID,
Status: "active",
})
if err != nil {
return nil, err
}
if err := s.store.CreateMobileIdentity(ctx, tx, postgres.CreateMobileIdentityParams{
UserID: user.ID,
CountryCode: input.CountryCode,
Mobile: input.Mobile,
Provider: "mobile",
ProviderSubj: input.CountryCode + ":" + input.Mobile,
IdentityType: "mobile",
}); err != nil {
return nil, err
}
newUser = true
}
if err := s.store.TouchUserLogin(ctx, tx, user.ID); err != nil {
return nil, err
}
result, err := s.issueAuthResult(ctx, tx, *user, input.ClientType, input.DeviceKey, newUser)
if err != nil {
return nil, err
}
if err := tx.Commit(ctx); err != nil {
return nil, err
}
return result, nil
}
func (s *AuthService) Refresh(ctx context.Context, input RefreshTokenInput) (*AuthResult, error) {
input.RefreshToken = strings.TrimSpace(input.RefreshToken)
if err := validateClientType(input.ClientType); err != nil {
return nil, err
}
if input.RefreshToken == "" {
return nil, apperr.New(http.StatusBadRequest, "invalid_params", "refreshToken is required")
}
tx, err := s.store.Begin(ctx)
if err != nil {
return nil, err
}
defer tx.Rollback(ctx)
record, err := s.store.GetRefreshTokenForUpdate(ctx, tx, security.HashText(input.RefreshToken))
if err != nil {
return nil, err
}
if record == nil || record.IsRevoked || record.ExpiresAt.Before(time.Now().UTC()) {
return nil, apperr.New(http.StatusUnauthorized, "invalid_refresh_token", "refresh token is invalid or expired")
}
if input.ClientType != "" && input.ClientType != record.ClientType {
return nil, apperr.New(http.StatusUnauthorized, "invalid_refresh_token", "refresh token client mismatch")
}
if input.DeviceKey != "" && record.DeviceKey != nil && input.DeviceKey != *record.DeviceKey {
return nil, apperr.New(http.StatusUnauthorized, "invalid_refresh_token", "refresh token device mismatch")
}
user, err := s.store.GetUserByID(ctx, tx, record.UserID)
if err != nil {
return nil, err
}
if user == nil {
return nil, apperr.New(http.StatusUnauthorized, "invalid_refresh_token", "refresh token user not found")
}
result, refreshTokenID, err := s.issueAuthResultWithRefreshID(ctx, tx, *user, record.ClientType, nullableStringValue(record.DeviceKey), false)
if err != nil {
return nil, err
}
if err := s.store.RotateRefreshToken(ctx, tx, record.ID, refreshTokenID); err != nil {
return nil, err
}
if err := tx.Commit(ctx); err != nil {
return nil, err
}
return result, nil
}
func (s *AuthService) LoginWechatMini(ctx context.Context, input LoginWechatMiniInput) (*AuthResult, error) {
input.Code = strings.TrimSpace(input.Code)
if err := validateClientType(input.ClientType); err != nil {
return nil, err
}
if input.ClientType != "wechat" {
return nil, apperr.New(http.StatusBadRequest, "invalid_client_type", "wechat mini login requires clientType=wechat")
}
if input.Code == "" || strings.TrimSpace(input.DeviceKey) == "" {
return nil, apperr.New(http.StatusBadRequest, "invalid_params", "code and deviceKey are required")
}
if s.cfg.WechatMini == nil {
return nil, apperr.New(http.StatusNotImplemented, "wechat_not_configured", "wechat mini provider is not configured")
}
session, err := s.cfg.WechatMini.ExchangeCode(ctx, input.Code)
if err != nil {
return nil, apperr.New(http.StatusUnauthorized, "wechat_login_failed", err.Error())
}
openIDSubject := session.AppID + ":" + session.OpenID
unionIDSubject := strings.TrimSpace(session.UnionID)
tx, err := s.store.Begin(ctx)
if err != nil {
return nil, err
}
defer tx.Rollback(ctx)
user, err := s.store.FindUserByProviderSubject(ctx, tx, "wechat_mini", openIDSubject)
if err != nil {
return nil, err
}
if user == nil && unionIDSubject != "" {
user, err = s.store.FindUserByProviderSubject(ctx, tx, "wechat_unionid", unionIDSubject)
if err != nil {
return nil, err
}
}
newUser := false
if user == nil {
userPublicID, err := security.GeneratePublicID("usr")
if err != nil {
return nil, err
}
user, err = s.store.CreateUser(ctx, tx, postgres.CreateUserParams{
PublicID: userPublicID,
Status: "active",
})
if err != nil {
return nil, err
}
newUser = true
}
profileJSON, err := json.Marshal(map[string]any{
"appId": session.AppID,
})
if err != nil {
return nil, err
}
if err := s.store.CreateIdentity(ctx, tx, postgres.CreateIdentityParams{
UserID: user.ID,
IdentityType: "wechat_mini_openid",
Provider: "wechat_mini",
ProviderSubj: openIDSubject,
ProfileJSON: string(profileJSON),
}); err != nil {
return nil, err
}
if unionIDSubject != "" {
if err := s.store.CreateIdentity(ctx, tx, postgres.CreateIdentityParams{
UserID: user.ID,
IdentityType: "wechat_unionid",
Provider: "wechat_unionid",
ProviderSubj: unionIDSubject,
ProfileJSON: "{}",
}); err != nil {
return nil, err
}
}
if err := s.store.TouchUserLogin(ctx, tx, user.ID); err != nil {
return nil, err
}
result, err := s.issueAuthResult(ctx, tx, *user, input.ClientType, input.DeviceKey, newUser)
if err != nil {
return nil, err
}
if err := tx.Commit(ctx); err != nil {
return nil, err
}
return result, nil
}
func (s *AuthService) BindMobile(ctx context.Context, input BindMobileInput) (*AuthResult, error) {
input.CountryCode = normalizeCountryCode(input.CountryCode)
input.Mobile = normalizeMobile(input.Mobile)
input.Code = strings.TrimSpace(input.Code)
if err := validateClientType(input.ClientType); err != nil {
return nil, err
}
if input.UserID == "" || input.Mobile == "" || input.Code == "" || strings.TrimSpace(input.DeviceKey) == "" {
return nil, apperr.New(http.StatusBadRequest, "invalid_params", "user, mobile, code and deviceKey are required")
}
codeRecord, err := s.store.GetLatestValidSMSCode(ctx, input.CountryCode, input.Mobile, input.ClientType, "bind_mobile")
if err != nil {
return nil, err
}
if codeRecord == nil || codeRecord.CodeHash != security.HashText(input.Code) {
return nil, apperr.New(http.StatusUnauthorized, "invalid_sms_code", "invalid sms code")
}
tx, err := s.store.Begin(ctx)
if err != nil {
return nil, err
}
defer tx.Rollback(ctx)
consumed, err := s.store.ConsumeSMSCode(ctx, tx, codeRecord.ID)
if err != nil {
return nil, err
}
if !consumed {
return nil, apperr.New(http.StatusUnauthorized, "invalid_sms_code", "sms code already used")
}
currentUser, err := s.store.GetUserByID(ctx, tx, input.UserID)
if err != nil {
return nil, err
}
if currentUser == nil {
return nil, apperr.New(http.StatusNotFound, "user_not_found", "current user not found")
}
mobileUser, err := s.store.FindUserByMobile(ctx, tx, input.CountryCode, input.Mobile)
if err != nil {
return nil, err
}
finalUser := currentUser
newlyBound := false
if mobileUser == nil {
if err := s.store.CreateMobileIdentity(ctx, tx, postgres.CreateMobileIdentityParams{
UserID: currentUser.ID,
CountryCode: input.CountryCode,
Mobile: input.Mobile,
Provider: "mobile",
ProviderSubj: input.CountryCode + ":" + input.Mobile,
IdentityType: "mobile",
}); err != nil {
return nil, err
}
newlyBound = true
} else if mobileUser.ID != currentUser.ID {
if err := s.store.TransferNonMobileIdentities(ctx, tx, currentUser.ID, mobileUser.ID); err != nil {
return nil, err
}
if err := s.store.RevokeRefreshTokensByUserID(ctx, tx, currentUser.ID); err != nil {
return nil, err
}
if err := s.store.DeactivateUser(ctx, tx, currentUser.ID); err != nil {
return nil, err
}
finalUser = mobileUser
}
if err := s.store.TouchUserLogin(ctx, tx, finalUser.ID); err != nil {
return nil, err
}
result, err := s.issueAuthResult(ctx, tx, *finalUser, input.ClientType, input.DeviceKey, newlyBound)
if err != nil {
return nil, err
}
if err := tx.Commit(ctx); err != nil {
return nil, err
}
return result, nil
}
func (s *AuthService) Logout(ctx context.Context, input LogoutInput) error {
if strings.TrimSpace(input.RefreshToken) == "" {
return nil
}
return s.store.RevokeRefreshToken(ctx, security.HashText(strings.TrimSpace(input.RefreshToken)))
}
func (s *AuthService) issueAuthResult(
ctx context.Context,
tx postgres.Tx,
user postgres.User,
clientType string,
deviceKey string,
newUser bool,
) (*AuthResult, error) {
result, _, err := s.issueAuthResultWithRefreshID(ctx, tx, user, clientType, deviceKey, newUser)
return result, err
}
func (s *AuthService) issueAuthResultWithRefreshID(
ctx context.Context,
tx postgres.Tx,
user postgres.User,
clientType string,
deviceKey string,
newUser bool,
) (*AuthResult, string, error) {
accessToken, accessExpiresAt, err := s.jwtManager.IssueAccessToken(user.ID, user.PublicID)
if err != nil {
return nil, "", err
}
refreshToken, err := security.GenerateToken(32)
if err != nil {
return nil, "", err
}
refreshTokenHash := security.HashText(refreshToken)
refreshExpiresAt := time.Now().UTC().Add(s.cfg.RefreshTTL)
refreshID, err := s.store.CreateRefreshToken(ctx, tx, postgres.CreateRefreshTokenParams{
UserID: user.ID,
ClientType: clientType,
DeviceKey: deviceKey,
TokenHash: refreshTokenHash,
ExpiresAt: refreshExpiresAt,
})
if err != nil {
return nil, "", err
}
return &AuthResult{
User: AuthUser{
ID: user.ID,
PublicID: user.PublicID,
Status: user.Status,
Nickname: user.Nickname,
AvatarURL: user.AvatarURL,
},
Tokens: AuthTokens{
AccessToken: accessToken,
AccessTokenExpiresAt: accessExpiresAt.Format(time.RFC3339),
RefreshToken: refreshToken,
RefreshTokenExpiresAt: refreshExpiresAt.Format(time.RFC3339),
},
NewUser: newUser,
}, refreshID, nil
}
func validateClientType(clientType string) error {
switch clientType {
case "app", "wechat":
return nil
default:
return apperr.New(http.StatusBadRequest, "invalid_client_type", "clientType must be app or wechat")
}
}
func normalizeCountryCode(value string) string {
value = strings.TrimSpace(value)
if value == "" {
return "86"
}
return strings.TrimPrefix(value, "+")
}
func normalizeMobile(value string) string {
value = strings.TrimSpace(value)
value = strings.ReplaceAll(value, " ", "")
value = strings.ReplaceAll(value, "-", "")
return value
}
func normalizeScene(value string) string {
value = strings.TrimSpace(value)
if value == "" {
return "login"
}
return value
}
func nullableStringValue(value *string) string {
if value == nil {
return ""
}
return *value
}

View File

@@ -1,699 +0,0 @@
package service
import (
"context"
"encoding/json"
"fmt"
"net/http"
"os"
"path/filepath"
"sort"
"strings"
"cmr-backend/internal/apperr"
"cmr-backend/internal/platform/assets"
"cmr-backend/internal/platform/security"
"cmr-backend/internal/store/postgres"
)
type ConfigService struct {
store *postgres.Store
localEventDir string
assetBaseURL string
publisher *assets.OSSUtilPublisher
}
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, publisher *assets.OSSUtilPublisher) *ConfigService {
return &ConfigService{
store: store,
localEventDir: localEventDir,
assetBaseURL: strings.TrimRight(assetBaseURL, "/"),
publisher: publisher,
}
}
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)
assetIndexURL := fmt.Sprintf("%s/event/releases/%s/%s/asset-index.json", s.assetBaseURL, event.PublicID, releasePublicID)
checksum := security.HashText(buildRecord.ManifestJSON)
routeCode := deriveRouteCode(manifest)
if s.publisher == nil || !s.publisher.Enabled() {
return nil, apperr.New(http.StatusInternalServerError, "asset_publish_unavailable", "asset publisher is not configured")
}
if err := s.publisher.UploadJSON(ctx, manifestURL, []byte(buildRecord.ManifestJSON)); err != nil {
return nil, apperr.New(http.StatusInternalServerError, "asset_publish_failed", "failed to upload manifest: "+err.Error())
}
if err := s.publisher.UploadJSON(ctx, assetIndexURL, []byte(buildRecord.AssetIndexJSON)); err != nil {
return nil, apperr.New(http.StatusInternalServerError, "asset_publish_failed", "failed to upload asset index: "+err.Error())
}
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, assetIndexURL, &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, assetIndexURL 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"},
},
{
EventReleaseID: eventReleaseID,
AssetType: "other",
AssetKey: "asset-index",
AssetURL: assetIndexURL,
Meta: map[string]any{"source": "published-build"},
},
}
for _, asset := range assetIndex {
assetType, _ := asset["assetType"].(string)
assetKey, _ := asset["assetKey"].(string)
assetURL, _ := asset["assetUrl"].(string)
if strings.TrimSpace(assetType) == "" || strings.TrimSpace(assetKey) == "" || strings.TrimSpace(assetURL) == "" {
continue
}
mappedType := assetType
if mappedType != "manifest" && mappedType != "mapmeta" && mappedType != "tiles" && mappedType != "playfield" && mappedType != "content_html" && mappedType != "media" {
mappedType = "other"
}
assets = append(assets, postgres.UpsertEventReleaseAssetParams{
EventReleaseID: eventReleaseID,
AssetType: mappedType,
AssetKey: assetKey,
AssetURL: assetURL,
Meta: asset,
})
}
return assets
}

View File

@@ -1,32 +0,0 @@
package service
import (
"context"
"net/http"
"cmr-backend/internal/apperr"
"cmr-backend/internal/store/postgres"
)
type DevService struct {
appEnv string
store *postgres.Store
}
func NewDevService(appEnv string, store *postgres.Store) *DevService {
return &DevService{
appEnv: appEnv,
store: store,
}
}
func (s *DevService) Enabled() bool {
return s.appEnv != "production"
}
func (s *DevService) BootstrapDemo(ctx context.Context) (*postgres.DemoBootstrapSummary, error) {
if !s.Enabled() {
return nil, apperr.New(http.StatusNotFound, "not_found", "dev bootstrap is disabled")
}
return s.store.EnsureDemoData(ctx)
}

View File

@@ -1,164 +0,0 @@
package service
import (
"context"
"net/http"
"strings"
"cmr-backend/internal/apperr"
"cmr-backend/internal/store/postgres"
)
type EntryHomeService struct {
store *postgres.Store
}
type EntryHomeInput struct {
UserID string
ChannelCode string
ChannelType string
PlatformAppID string
TenantCode string
}
type EntryHomeResult struct {
User struct {
ID string `json:"id"`
PublicID string `json:"publicId"`
Status string `json:"status"`
Nickname *string `json:"nickname,omitempty"`
AvatarURL *string `json:"avatarUrl,omitempty"`
} `json:"user"`
Tenant struct {
ID string `json:"id"`
Code string `json:"code"`
Name string `json:"name"`
} `json:"tenant"`
Channel struct {
ID string `json:"id"`
Code string `json:"code"`
Type string `json:"type"`
PlatformAppID *string `json:"platformAppId,omitempty"`
DisplayName string `json:"displayName"`
Status string `json:"status"`
IsDefault bool `json:"isDefault"`
} `json:"channel"`
Cards []CardResult `json:"cards"`
OngoingSession *EntrySessionSummary `json:"ongoingSession,omitempty"`
RecentSession *EntrySessionSummary `json:"recentSession,omitempty"`
}
type EntrySessionSummary struct {
ID string `json:"id"`
Status string `json:"status"`
EventID string `json:"eventId"`
EventName string `json:"eventName"`
ReleaseID *string `json:"releaseId,omitempty"`
ConfigLabel *string `json:"configLabel,omitempty"`
RouteCode *string `json:"routeCode,omitempty"`
LaunchedAt string `json:"launchedAt"`
StartedAt *string `json:"startedAt,omitempty"`
EndedAt *string `json:"endedAt,omitempty"`
}
func NewEntryHomeService(store *postgres.Store) *EntryHomeService {
return &EntryHomeService{store: store}
}
func (s *EntryHomeService) GetEntryHome(ctx context.Context, input EntryHomeInput) (*EntryHomeResult, error) {
input.UserID = strings.TrimSpace(input.UserID)
if input.UserID == "" {
return nil, apperr.New(http.StatusUnauthorized, "unauthorized", "user is required")
}
user, err := s.store.GetUserByID(ctx, s.store.Pool(), input.UserID)
if err != nil {
return nil, err
}
if user == nil {
return nil, apperr.New(http.StatusNotFound, "user_not_found", "user not found")
}
entry, err := s.store.FindEntryChannel(ctx, postgres.FindEntryChannelParams{
ChannelCode: strings.TrimSpace(input.ChannelCode),
ChannelType: strings.TrimSpace(input.ChannelType),
PlatformAppID: strings.TrimSpace(input.PlatformAppID),
TenantCode: strings.TrimSpace(input.TenantCode),
})
if err != nil {
return nil, err
}
if entry == nil {
return nil, apperr.New(http.StatusNotFound, "entry_channel_not_found", "entry channel not found")
}
cards, err := s.store.ListCardsForEntry(ctx, entry.TenantID, &entry.ID, "home_primary", nowUTC(), 20)
if err != nil {
return nil, err
}
sessions, err := s.store.ListSessionsByUserID(ctx, user.ID, 10)
if err != nil {
return nil, err
}
result := &EntryHomeResult{
Cards: mapCards(cards),
}
result.User.ID = user.ID
result.User.PublicID = user.PublicID
result.User.Status = user.Status
result.User.Nickname = user.Nickname
result.User.AvatarURL = user.AvatarURL
result.Tenant.ID = entry.TenantID
result.Tenant.Code = entry.TenantCode
result.Tenant.Name = entry.TenantName
result.Channel.ID = entry.ID
result.Channel.Code = entry.ChannelCode
result.Channel.Type = entry.ChannelType
result.Channel.PlatformAppID = entry.PlatformAppID
result.Channel.DisplayName = entry.DisplayName
result.Channel.Status = entry.Status
result.Channel.IsDefault = entry.IsDefault
if len(sessions) > 0 {
recent := buildEntrySessionSummary(&sessions[0])
result.RecentSession = &recent
}
for i := range sessions {
if sessions[i].Status == "launched" || sessions[i].Status == "running" {
ongoing := buildEntrySessionSummary(&sessions[i])
result.OngoingSession = &ongoing
break
}
}
return result, nil
}
func buildEntrySessionSummary(session *postgres.Session) EntrySessionSummary {
summary := EntrySessionSummary{
ID: session.SessionPublicID,
Status: session.Status,
RouteCode: session.RouteCode,
LaunchedAt: session.LaunchedAt.Format(timeRFC3339),
}
if session.EventPublicID != nil {
summary.EventID = *session.EventPublicID
}
if session.EventDisplayName != nil {
summary.EventName = *session.EventDisplayName
}
summary.ReleaseID = session.ReleasePublicID
summary.ConfigLabel = session.ConfigLabel
if session.StartedAt != nil {
value := session.StartedAt.Format(timeRFC3339)
summary.StartedAt = &value
}
if session.EndedAt != nil {
value := session.EndedAt.Format(timeRFC3339)
summary.EndedAt = &value
}
return summary
}

View File

@@ -1,79 +0,0 @@
package service
import (
"context"
"net/http"
"strings"
"cmr-backend/internal/apperr"
"cmr-backend/internal/store/postgres"
)
type EntryService struct {
store *postgres.Store
}
type ResolveEntryInput struct {
ChannelCode string
ChannelType string
PlatformAppID string
TenantCode string
}
type ResolveEntryResult struct {
Tenant struct {
ID string `json:"id"`
Code string `json:"code"`
Name string `json:"name"`
} `json:"tenant"`
Channel struct {
ID string `json:"id"`
Code string `json:"code"`
Type string `json:"type"`
PlatformAppID *string `json:"platformAppId,omitempty"`
DisplayName string `json:"displayName"`
Status string `json:"status"`
IsDefault bool `json:"isDefault"`
} `json:"channel"`
}
func NewEntryService(store *postgres.Store) *EntryService {
return &EntryService{store: store}
}
func (s *EntryService) Resolve(ctx context.Context, input ResolveEntryInput) (*ResolveEntryResult, error) {
input.ChannelCode = strings.TrimSpace(input.ChannelCode)
input.ChannelType = strings.TrimSpace(input.ChannelType)
input.PlatformAppID = strings.TrimSpace(input.PlatformAppID)
input.TenantCode = strings.TrimSpace(input.TenantCode)
if input.ChannelCode == "" && input.PlatformAppID == "" {
return nil, apperr.New(http.StatusBadRequest, "invalid_params", "channelCode or platformAppId is required")
}
entry, err := s.store.FindEntryChannel(ctx, postgres.FindEntryChannelParams{
ChannelCode: input.ChannelCode,
ChannelType: input.ChannelType,
PlatformAppID: input.PlatformAppID,
TenantCode: input.TenantCode,
})
if err != nil {
return nil, err
}
if entry == nil {
return nil, apperr.New(http.StatusNotFound, "entry_channel_not_found", "entry channel not found")
}
result := &ResolveEntryResult{}
result.Tenant.ID = entry.TenantID
result.Tenant.Code = entry.TenantCode
result.Tenant.Name = entry.TenantName
result.Channel.ID = entry.ID
result.Channel.Code = entry.ChannelCode
result.Channel.Type = entry.ChannelType
result.Channel.PlatformAppID = entry.PlatformAppID
result.Channel.DisplayName = entry.DisplayName
result.Channel.Status = entry.Status
result.Channel.IsDefault = entry.IsDefault
return result, nil
}

View File

@@ -1,131 +0,0 @@
package service
import (
"context"
"net/http"
"strings"
"cmr-backend/internal/apperr"
"cmr-backend/internal/store/postgres"
)
type EventPlayService struct {
store *postgres.Store
}
type EventPlayInput struct {
EventPublicID string
UserID string
}
type EventPlayResult struct {
Event struct {
ID string `json:"id"`
Slug string `json:"slug"`
DisplayName string `json:"displayName"`
Summary *string `json:"summary,omitempty"`
Status string `json:"status"`
} `json:"event"`
Release *struct {
ID string `json:"id"`
ConfigLabel string `json:"configLabel"`
ManifestURL string `json:"manifestUrl"`
ManifestChecksumSha256 *string `json:"manifestChecksumSha256,omitempty"`
RouteCode *string `json:"routeCode,omitempty"`
} `json:"release,omitempty"`
ResolvedRelease *ResolvedReleaseView `json:"resolvedRelease,omitempty"`
Play struct {
CanLaunch bool `json:"canLaunch"`
PrimaryAction string `json:"primaryAction"`
Reason string `json:"reason"`
LaunchSource string `json:"launchSource,omitempty"`
OngoingSession *EntrySessionSummary `json:"ongoingSession,omitempty"`
RecentSession *EntrySessionSummary `json:"recentSession,omitempty"`
} `json:"play"`
}
func NewEventPlayService(store *postgres.Store) *EventPlayService {
return &EventPlayService{store: store}
}
func (s *EventPlayService) GetEventPlay(ctx context.Context, input EventPlayInput) (*EventPlayResult, error) {
input.EventPublicID = strings.TrimSpace(input.EventPublicID)
input.UserID = strings.TrimSpace(input.UserID)
if input.EventPublicID == "" {
return nil, apperr.New(http.StatusBadRequest, "invalid_params", "event id is required")
}
if input.UserID == "" {
return nil, apperr.New(http.StatusUnauthorized, "unauthorized", "user is required")
}
event, err := s.store.GetEventByPublicID(ctx, input.EventPublicID)
if err != nil {
return nil, err
}
if event == nil {
return nil, apperr.New(http.StatusNotFound, "event_not_found", "event not found")
}
sessions, err := s.store.ListSessionsByUserAndEvent(ctx, input.UserID, event.ID, 10)
if err != nil {
return nil, err
}
result := &EventPlayResult{}
result.Event.ID = event.PublicID
result.Event.Slug = event.Slug
result.Event.DisplayName = event.DisplayName
result.Event.Summary = event.Summary
result.Event.Status = event.Status
if event.CurrentReleasePubID != nil && event.ConfigLabel != nil && event.ManifestURL != nil {
result.Release = &struct {
ID string `json:"id"`
ConfigLabel string `json:"configLabel"`
ManifestURL string `json:"manifestUrl"`
ManifestChecksumSha256 *string `json:"manifestChecksumSha256,omitempty"`
RouteCode *string `json:"routeCode,omitempty"`
}{
ID: *event.CurrentReleasePubID,
ConfigLabel: *event.ConfigLabel,
ManifestURL: *event.ManifestURL,
ManifestChecksumSha256: event.ManifestChecksum,
RouteCode: event.RouteCode,
}
}
result.ResolvedRelease = buildResolvedReleaseFromEvent(event, LaunchSourceEventCurrentRelease)
if len(sessions) > 0 {
recent := buildEntrySessionSummary(&sessions[0])
result.Play.RecentSession = &recent
}
for i := range sessions {
if sessions[i].Status == "launched" || sessions[i].Status == "running" {
ongoing := buildEntrySessionSummary(&sessions[i])
result.Play.OngoingSession = &ongoing
break
}
}
canLaunch := event.Status == "active" && event.CurrentReleaseID != nil && event.ManifestURL != nil
result.Play.CanLaunch = canLaunch
if canLaunch {
result.Play.LaunchSource = LaunchSourceEventCurrentRelease
}
switch {
case result.Play.OngoingSession != nil:
result.Play.PrimaryAction = "continue"
result.Play.Reason = "user has an ongoing session for this event"
case canLaunch:
result.Play.PrimaryAction = "start"
result.Play.Reason = "event is active and launchable"
case result.Play.RecentSession != nil:
result.Play.PrimaryAction = "review_last_result"
result.Play.Reason = "event is not launchable, but user has previous session history"
default:
result.Play.PrimaryAction = "unavailable"
result.Play.Reason = "event is not launchable"
}
return result, nil
}

View File

@@ -1,195 +0,0 @@
package service
import (
"context"
"net/http"
"strings"
"time"
"cmr-backend/internal/apperr"
"cmr-backend/internal/platform/security"
"cmr-backend/internal/store/postgres"
)
type EventService struct {
store *postgres.Store
}
type EventDetailResult struct {
Event struct {
ID string `json:"id"`
Slug string `json:"slug"`
DisplayName string `json:"displayName"`
Summary *string `json:"summary,omitempty"`
Status string `json:"status"`
} `json:"event"`
Release *struct {
ID string `json:"id"`
ConfigLabel string `json:"configLabel"`
ManifestURL string `json:"manifestUrl"`
ManifestChecksumSha256 *string `json:"manifestChecksumSha256,omitempty"`
RouteCode *string `json:"routeCode,omitempty"`
} `json:"release,omitempty"`
ResolvedRelease *ResolvedReleaseView `json:"resolvedRelease,omitempty"`
}
type LaunchEventInput struct {
EventPublicID string
UserID string
ReleaseID string `json:"releaseId,omitempty"`
ClientType string `json:"clientType"`
DeviceKey string `json:"deviceKey"`
}
type LaunchEventResult struct {
Event struct {
ID string `json:"id"`
DisplayName string `json:"displayName"`
} `json:"event"`
Launch struct {
Source string `json:"source"`
ResolvedRelease *ResolvedReleaseView `json:"resolvedRelease,omitempty"`
Config struct {
ConfigURL string `json:"configUrl"`
ConfigLabel string `json:"configLabel"`
ConfigChecksumSha256 *string `json:"configChecksumSha256,omitempty"`
ReleaseID string `json:"releaseId"`
RouteCode *string `json:"routeCode,omitempty"`
} `json:"config"`
Business struct {
Source string `json:"source"`
EventID string `json:"eventId"`
SessionID string `json:"sessionId"`
SessionToken string `json:"sessionToken"`
SessionTokenExpiresAt string `json:"sessionTokenExpiresAt"`
RouteCode *string `json:"routeCode,omitempty"`
} `json:"business"`
} `json:"launch"`
}
func NewEventService(store *postgres.Store) *EventService {
return &EventService{store: store}
}
func (s *EventService) GetEventDetail(ctx context.Context, eventPublicID string) (*EventDetailResult, error) {
eventPublicID = strings.TrimSpace(eventPublicID)
if eventPublicID == "" {
return nil, apperr.New(http.StatusBadRequest, "invalid_params", "event id is required")
}
event, err := s.store.GetEventByPublicID(ctx, eventPublicID)
if err != nil {
return nil, err
}
if event == nil {
return nil, apperr.New(http.StatusNotFound, "event_not_found", "event not found")
}
result := &EventDetailResult{}
result.Event.ID = event.PublicID
result.Event.Slug = event.Slug
result.Event.DisplayName = event.DisplayName
result.Event.Summary = event.Summary
result.Event.Status = event.Status
if event.CurrentReleasePubID != nil && event.ConfigLabel != nil && event.ManifestURL != nil {
result.Release = &struct {
ID string `json:"id"`
ConfigLabel string `json:"configLabel"`
ManifestURL string `json:"manifestUrl"`
ManifestChecksumSha256 *string `json:"manifestChecksumSha256,omitempty"`
RouteCode *string `json:"routeCode,omitempty"`
}{
ID: *event.CurrentReleasePubID,
ConfigLabel: *event.ConfigLabel,
ManifestURL: *event.ManifestURL,
ManifestChecksumSha256: event.ManifestChecksum,
RouteCode: event.RouteCode,
}
}
result.ResolvedRelease = buildResolvedReleaseFromEvent(event, LaunchSourceEventCurrentRelease)
return result, nil
}
func (s *EventService) LaunchEvent(ctx context.Context, input LaunchEventInput) (*LaunchEventResult, error) {
input.EventPublicID = strings.TrimSpace(input.EventPublicID)
input.ReleaseID = strings.TrimSpace(input.ReleaseID)
input.DeviceKey = strings.TrimSpace(input.DeviceKey)
if err := validateClientType(input.ClientType); err != nil {
return nil, err
}
if input.EventPublicID == "" || input.UserID == "" || input.DeviceKey == "" {
return nil, apperr.New(http.StatusBadRequest, "invalid_params", "event id, user and deviceKey are required")
}
event, err := s.store.GetEventByPublicID(ctx, input.EventPublicID)
if err != nil {
return nil, err
}
if event == nil {
return nil, apperr.New(http.StatusNotFound, "event_not_found", "event not found")
}
if event.Status != "active" {
return nil, apperr.New(http.StatusConflict, "event_not_launchable", "event is not active")
}
if event.CurrentReleaseID == nil || event.CurrentReleasePubID == nil || event.ConfigLabel == nil || event.ManifestURL == nil {
return nil, apperr.New(http.StatusConflict, "event_release_missing", "event does not have a published release")
}
if input.ReleaseID != "" && input.ReleaseID != *event.CurrentReleasePubID {
return nil, apperr.New(http.StatusConflict, "release_not_launchable", "requested release is not the current published release")
}
tx, err := s.store.Begin(ctx)
if err != nil {
return nil, err
}
defer tx.Rollback(ctx)
sessionPublicID, err := security.GeneratePublicID("sess")
if err != nil {
return nil, err
}
sessionToken, err := security.GenerateToken(32)
if err != nil {
return nil, err
}
sessionTokenExpiresAt := time.Now().UTC().Add(2 * time.Hour)
session, err := s.store.CreateGameSession(ctx, tx, postgres.CreateGameSessionParams{
SessionPublicID: sessionPublicID,
UserID: input.UserID,
EventID: event.ID,
EventReleaseID: *event.CurrentReleaseID,
DeviceKey: input.DeviceKey,
ClientType: input.ClientType,
RouteCode: event.RouteCode,
SessionTokenHash: security.HashText(sessionToken),
SessionTokenExpiresAt: sessionTokenExpiresAt,
})
if err != nil {
return nil, err
}
if err := tx.Commit(ctx); err != nil {
return nil, err
}
result := &LaunchEventResult{}
result.Event.ID = event.PublicID
result.Event.DisplayName = event.DisplayName
result.Launch.Source = LaunchSourceEventCurrentRelease
result.Launch.ResolvedRelease = buildResolvedReleaseFromEvent(event, LaunchSourceEventCurrentRelease)
result.Launch.Config.ConfigURL = *event.ManifestURL
result.Launch.Config.ConfigLabel = *event.ConfigLabel
result.Launch.Config.ConfigChecksumSha256 = event.ManifestChecksum
result.Launch.Config.ReleaseID = *event.CurrentReleasePubID
result.Launch.Config.RouteCode = event.RouteCode
result.Launch.Business.Source = "direct-event"
result.Launch.Business.EventID = event.PublicID
result.Launch.Business.SessionID = session.SessionPublicID
result.Launch.Business.SessionToken = sessionToken
result.Launch.Business.SessionTokenExpiresAt = session.SessionTokenExpiresAt.Format(time.RFC3339)
result.Launch.Business.RouteCode = event.RouteCode
return result, nil
}

View File

@@ -1,159 +0,0 @@
package service
import (
"context"
"net/http"
"strings"
"time"
"cmr-backend/internal/apperr"
"cmr-backend/internal/store/postgres"
)
type HomeService struct {
store *postgres.Store
}
type ListCardsInput struct {
ChannelCode string
ChannelType string
PlatformAppID string
TenantCode string
Slot string
Limit int
}
type CardResult struct {
ID string `json:"id"`
Type string `json:"type"`
Title string `json:"title"`
Subtitle *string `json:"subtitle,omitempty"`
CoverURL *string `json:"coverUrl,omitempty"`
DisplaySlot string `json:"displaySlot"`
DisplayPriority int `json:"displayPriority"`
Event *struct {
ID string `json:"id"`
DisplayName string `json:"displayName"`
Summary *string `json:"summary,omitempty"`
} `json:"event,omitempty"`
HTMLURL *string `json:"htmlUrl,omitempty"`
}
type HomeResult struct {
Tenant struct {
ID string `json:"id"`
Code string `json:"code"`
Name string `json:"name"`
} `json:"tenant"`
Channel struct {
ID string `json:"id"`
Code string `json:"code"`
Type string `json:"type"`
PlatformAppID *string `json:"platformAppId,omitempty"`
DisplayName string `json:"displayName"`
Status string `json:"status"`
IsDefault bool `json:"isDefault"`
} `json:"channel"`
Cards []CardResult `json:"cards"`
}
func NewHomeService(store *postgres.Store) *HomeService {
return &HomeService{store: store}
}
func (s *HomeService) ListCards(ctx context.Context, input ListCardsInput) ([]CardResult, error) {
entry, err := s.resolveEntry(ctx, input)
if err != nil {
return nil, err
}
cards, err := s.store.ListCardsForEntry(ctx, entry.TenantID, &entry.ID, normalizeSlot(input.Slot), time.Now().UTC(), input.Limit)
if err != nil {
return nil, err
}
return mapCards(cards), nil
}
func (s *HomeService) GetHome(ctx context.Context, input ListCardsInput) (*HomeResult, error) {
entry, err := s.resolveEntry(ctx, input)
if err != nil {
return nil, err
}
cards, err := s.store.ListCardsForEntry(ctx, entry.TenantID, &entry.ID, normalizeSlot(input.Slot), time.Now().UTC(), input.Limit)
if err != nil {
return nil, err
}
result := &HomeResult{
Cards: mapCards(cards),
}
result.Tenant.ID = entry.TenantID
result.Tenant.Code = entry.TenantCode
result.Tenant.Name = entry.TenantName
result.Channel.ID = entry.ID
result.Channel.Code = entry.ChannelCode
result.Channel.Type = entry.ChannelType
result.Channel.PlatformAppID = entry.PlatformAppID
result.Channel.DisplayName = entry.DisplayName
result.Channel.Status = entry.Status
result.Channel.IsDefault = entry.IsDefault
return result, nil
}
func (s *HomeService) resolveEntry(ctx context.Context, input ListCardsInput) (*postgres.EntryChannel, error) {
entry, err := s.store.FindEntryChannel(ctx, postgres.FindEntryChannelParams{
ChannelCode: strings.TrimSpace(input.ChannelCode),
ChannelType: strings.TrimSpace(input.ChannelType),
PlatformAppID: strings.TrimSpace(input.PlatformAppID),
TenantCode: strings.TrimSpace(input.TenantCode),
})
if err != nil {
return nil, err
}
if entry == nil {
return nil, apperr.New(http.StatusNotFound, "entry_channel_not_found", "entry channel not found")
}
return entry, nil
}
func normalizeSlot(slot string) string {
slot = strings.TrimSpace(slot)
if slot == "" {
return "home_primary"
}
return slot
}
func mapCards(cards []postgres.Card) []CardResult {
results := make([]CardResult, 0, len(cards))
for _, card := range cards {
item := CardResult{
ID: card.PublicID,
Type: card.CardType,
Title: card.Title,
Subtitle: card.Subtitle,
CoverURL: card.CoverURL,
DisplaySlot: card.DisplaySlot,
DisplayPriority: card.DisplayPriority,
HTMLURL: card.HTMLURL,
}
if card.EventPublicID != nil || card.EventDisplayName != nil {
item.Event = &struct {
ID string `json:"id"`
DisplayName string `json:"displayName"`
Summary *string `json:"summary,omitempty"`
}{
Summary: card.EventSummary,
}
if card.EventPublicID != nil {
item.Event.ID = *card.EventPublicID
}
if card.EventDisplayName != nil {
item.Event.DisplayName = *card.EventDisplayName
}
}
results = append(results, item)
}
return results
}

View File

@@ -1,43 +0,0 @@
package service
import (
"context"
"net/http"
"cmr-backend/internal/apperr"
"cmr-backend/internal/store/postgres"
)
type MeService struct {
store *postgres.Store
}
type MeResult struct {
ID string `json:"id"`
PublicID string `json:"publicId"`
Status string `json:"status"`
Nickname *string `json:"nickname,omitempty"`
AvatarURL *string `json:"avatarUrl,omitempty"`
}
func NewMeService(store *postgres.Store) *MeService {
return &MeService{store: store}
}
func (s *MeService) GetMe(ctx context.Context, userID string) (*MeResult, error) {
user, err := s.store.GetUserByID(ctx, s.store.Pool(), userID)
if err != nil {
return nil, err
}
if user == nil {
return nil, apperr.New(http.StatusNotFound, "user_not_found", "user not found")
}
return &MeResult{
ID: user.ID,
PublicID: user.PublicID,
Status: user.Status,
Nickname: user.Nickname,
AvatarURL: user.AvatarURL,
}, nil
}

View File

@@ -1,119 +0,0 @@
package service
import (
"context"
"net/http"
"cmr-backend/internal/apperr"
"cmr-backend/internal/store/postgres"
)
type ProfileService struct {
store *postgres.Store
}
type ProfileResult struct {
User struct {
ID string `json:"id"`
PublicID string `json:"publicId"`
Status string `json:"status"`
Nickname *string `json:"nickname,omitempty"`
AvatarURL *string `json:"avatarUrl,omitempty"`
} `json:"user"`
Bindings struct {
HasMobile bool `json:"hasMobile"`
HasWechatMini bool `json:"hasWechatMini"`
HasWechatUnion bool `json:"hasWechatUnion"`
Items []ProfileBindingItem `json:"items"`
} `json:"bindings"`
RecentSessions []EntrySessionSummary `json:"recentSessions"`
}
type ProfileBindingItem struct {
IdentityType string `json:"identityType"`
Provider string `json:"provider"`
Status string `json:"status"`
CountryCode *string `json:"countryCode,omitempty"`
Mobile *string `json:"mobile,omitempty"`
MaskedLabel string `json:"maskedLabel"`
}
func NewProfileService(store *postgres.Store) *ProfileService {
return &ProfileService{store: store}
}
func (s *ProfileService) GetProfile(ctx context.Context, userID string) (*ProfileResult, error) {
if userID == "" {
return nil, apperr.New(http.StatusUnauthorized, "unauthorized", "user is required")
}
user, err := s.store.GetUserByID(ctx, s.store.Pool(), userID)
if err != nil {
return nil, err
}
if user == nil {
return nil, apperr.New(http.StatusNotFound, "user_not_found", "user not found")
}
identities, err := s.store.ListIdentitiesByUserID(ctx, userID)
if err != nil {
return nil, err
}
sessions, err := s.store.ListSessionsByUserID(ctx, userID, 5)
if err != nil {
return nil, err
}
result := &ProfileResult{}
result.User.ID = user.ID
result.User.PublicID = user.PublicID
result.User.Status = user.Status
result.User.Nickname = user.Nickname
result.User.AvatarURL = user.AvatarURL
for _, identity := range identities {
item := ProfileBindingItem{
IdentityType: identity.IdentityType,
Provider: identity.Provider,
Status: identity.Status,
CountryCode: identity.CountryCode,
Mobile: identity.Mobile,
MaskedLabel: maskIdentity(identity),
}
result.Bindings.Items = append(result.Bindings.Items, item)
switch identity.Provider {
case "mobile":
result.Bindings.HasMobile = true
case "wechat_mini":
result.Bindings.HasWechatMini = true
case "wechat_unionid":
result.Bindings.HasWechatUnion = true
}
}
for i := range sessions {
result.RecentSessions = append(result.RecentSessions, buildEntrySessionSummary(&sessions[i]))
}
return result, nil
}
func maskIdentity(identity postgres.LoginIdentity) string {
if identity.Provider == "mobile" && identity.Mobile != nil {
value := *identity.Mobile
if len(value) >= 7 {
return value[:3] + "****" + value[len(value)-4:]
}
return value
}
if identity.Provider == "wechat_mini" {
return "WeChat Mini bound"
}
if identity.Provider == "wechat_unionid" {
return "WeChat Union bound"
}
return identity.Provider
}

View File

@@ -1,56 +0,0 @@
package service
import "cmr-backend/internal/store/postgres"
const (
LaunchSourceEventCurrentRelease = "event_current_release"
LaunchModeManifestRelease = "manifest_release"
)
type ResolvedReleaseView struct {
LaunchMode string `json:"launchMode"`
Source string `json:"source"`
EventID string `json:"eventId"`
ReleaseID string `json:"releaseId"`
ConfigLabel string `json:"configLabel"`
ManifestURL string `json:"manifestUrl"`
ManifestChecksumSha256 *string `json:"manifestChecksumSha256,omitempty"`
RouteCode *string `json:"routeCode,omitempty"`
}
func buildResolvedReleaseFromEvent(event *postgres.Event, source string) *ResolvedReleaseView {
if event == nil || event.CurrentReleasePubID == nil || event.ConfigLabel == nil || event.ManifestURL == nil {
return nil
}
return &ResolvedReleaseView{
LaunchMode: LaunchModeManifestRelease,
Source: source,
EventID: event.PublicID,
ReleaseID: *event.CurrentReleasePubID,
ConfigLabel: *event.ConfigLabel,
ManifestURL: *event.ManifestURL,
ManifestChecksumSha256: event.ManifestChecksum,
RouteCode: event.RouteCode,
}
}
func buildResolvedReleaseFromSession(session *postgres.Session, source string) *ResolvedReleaseView {
if session == nil || session.ReleasePublicID == nil || session.ConfigLabel == nil || session.ManifestURL == nil {
return nil
}
view := &ResolvedReleaseView{
LaunchMode: LaunchModeManifestRelease,
Source: source,
ReleaseID: *session.ReleasePublicID,
ConfigLabel: *session.ConfigLabel,
ManifestURL: *session.ManifestURL,
ManifestChecksumSha256: session.ManifestChecksum,
RouteCode: session.RouteCode,
}
if session.EventPublicID != nil {
view.EventID = *session.EventPublicID
}
return view
}

View File

@@ -1,94 +0,0 @@
package service
import (
"context"
"encoding/json"
"net/http"
"cmr-backend/internal/apperr"
"cmr-backend/internal/store/postgres"
)
type ResultService struct {
store *postgres.Store
}
type SessionResultView struct {
Session EntrySessionSummary `json:"session"`
Result ResultSummaryPayload `json:"result"`
}
type ResultSummaryPayload struct {
Status string `json:"status"`
FinalDurationSec *int `json:"finalDurationSec,omitempty"`
FinalScore *int `json:"finalScore,omitempty"`
CompletedControls *int `json:"completedControls,omitempty"`
TotalControls *int `json:"totalControls,omitempty"`
DistanceMeters *float64 `json:"distanceMeters,omitempty"`
AverageSpeedKmh *float64 `json:"averageSpeedKmh,omitempty"`
MaxHeartRateBpm *int `json:"maxHeartRateBpm,omitempty"`
Summary map[string]any `json:"summary,omitempty"`
}
func NewResultService(store *postgres.Store) *ResultService {
return &ResultService{store: store}
}
func (s *ResultService) GetSessionResult(ctx context.Context, sessionPublicID, userID string) (*SessionResultView, error) {
record, err := s.store.GetSessionResultByPublicID(ctx, sessionPublicID)
if err != nil {
return nil, err
}
if record == nil {
return nil, apperr.New(http.StatusNotFound, "session_not_found", "session not found")
}
if userID != "" && record.UserID != userID {
return nil, apperr.New(http.StatusForbidden, "session_forbidden", "session does not belong to current user")
}
return buildSessionResultView(record), nil
}
func (s *ResultService) ListMyResults(ctx context.Context, userID string, limit int) ([]SessionResultView, error) {
if userID == "" {
return nil, apperr.New(http.StatusUnauthorized, "unauthorized", "user is required")
}
records, err := s.store.ListSessionResultsByUserID(ctx, userID, limit)
if err != nil {
return nil, err
}
results := make([]SessionResultView, 0, len(records))
for i := range records {
results = append(results, *buildSessionResultView(&records[i]))
}
return results, nil
}
func buildSessionResultView(record *postgres.SessionResultRecord) *SessionResultView {
view := &SessionResultView{
Session: buildEntrySessionSummary(&record.Session),
Result: ResultSummaryPayload{
Status: record.Status,
},
}
if record.Result != nil {
view.Result.Status = record.Result.ResultStatus
view.Result.FinalDurationSec = record.Result.FinalDurationSec
view.Result.FinalScore = record.Result.FinalScore
view.Result.CompletedControls = record.Result.CompletedControls
view.Result.TotalControls = record.Result.TotalControls
view.Result.DistanceMeters = record.Result.DistanceMeters
view.Result.AverageSpeedKmh = record.Result.AverageSpeedKmh
view.Result.MaxHeartRateBpm = record.Result.MaxHeartRateBpm
if record.Result.SummaryJSON != "" {
summary := map[string]any{}
if err := json.Unmarshal([]byte(record.Result.SummaryJSON), &summary); err == nil && len(summary) > 0 {
view.Result.Summary = summary
}
}
}
return view
}

View File

@@ -1,324 +0,0 @@
package service
import (
"context"
"encoding/json"
"net/http"
"strings"
"time"
"cmr-backend/internal/apperr"
"cmr-backend/internal/platform/security"
"cmr-backend/internal/store/postgres"
)
type SessionService struct {
store *postgres.Store
}
type SessionResult struct {
Session struct {
ID string `json:"id"`
Status string `json:"status"`
ClientType string `json:"clientType"`
DeviceKey string `json:"deviceKey"`
RouteCode *string `json:"routeCode,omitempty"`
SessionTokenExpiresAt string `json:"sessionTokenExpiresAt"`
LaunchedAt string `json:"launchedAt"`
StartedAt *string `json:"startedAt,omitempty"`
EndedAt *string `json:"endedAt,omitempty"`
} `json:"session"`
Event struct {
ID string `json:"id"`
DisplayName string `json:"displayName"`
} `json:"event"`
ResolvedRelease *ResolvedReleaseView `json:"resolvedRelease,omitempty"`
}
type SessionActionInput struct {
SessionPublicID string
SessionToken string `json:"sessionToken"`
}
type FinishSessionInput struct {
SessionPublicID string
SessionToken string `json:"sessionToken"`
Status string `json:"status"`
Summary *SessionSummaryInput `json:"summary,omitempty"`
}
type SessionSummaryInput struct {
FinalDurationSec *int `json:"finalDurationSec,omitempty"`
FinalScore *int `json:"finalScore,omitempty"`
CompletedControls *int `json:"completedControls,omitempty"`
TotalControls *int `json:"totalControls,omitempty"`
DistanceMeters *float64 `json:"distanceMeters,omitempty"`
AverageSpeedKmh *float64 `json:"averageSpeedKmh,omitempty"`
MaxHeartRateBpm *int `json:"maxHeartRateBpm,omitempty"`
}
func NewSessionService(store *postgres.Store) *SessionService {
return &SessionService{store: store}
}
func (s *SessionService) GetSession(ctx context.Context, sessionPublicID, userID string) (*SessionResult, error) {
sessionPublicID = strings.TrimSpace(sessionPublicID)
if sessionPublicID == "" {
return nil, apperr.New(http.StatusBadRequest, "invalid_params", "session id is required")
}
session, err := s.store.GetSessionByPublicID(ctx, sessionPublicID)
if err != nil {
return nil, err
}
if session == nil {
return nil, apperr.New(http.StatusNotFound, "session_not_found", "session not found")
}
if userID != "" && session.UserID != userID {
return nil, apperr.New(http.StatusForbidden, "session_forbidden", "session does not belong to current user")
}
return buildSessionResult(session), nil
}
func (s *SessionService) ListMySessions(ctx context.Context, userID string, limit int) ([]SessionResult, error) {
if userID == "" {
return nil, apperr.New(http.StatusUnauthorized, "unauthorized", "user is required")
}
sessions, err := s.store.ListSessionsByUserID(ctx, userID, limit)
if err != nil {
return nil, err
}
results := make([]SessionResult, 0, len(sessions))
for i := range sessions {
results = append(results, *buildSessionResult(&sessions[i]))
}
return results, nil
}
func (s *SessionService) StartSession(ctx context.Context, input SessionActionInput) (*SessionResult, error) {
session, err := s.validateSessionAction(ctx, input.SessionPublicID, input.SessionToken)
if err != nil {
return nil, err
}
if session.Status == "finished" || session.Status == "cancelled" || session.Status == "failed" {
return nil, apperr.New(http.StatusConflict, "session_not_startable", "session cannot be started")
}
tx, err := s.store.Begin(ctx)
if err != nil {
return nil, err
}
defer tx.Rollback(ctx)
locked, err := s.store.GetSessionByPublicIDForUpdate(ctx, tx, input.SessionPublicID)
if err != nil {
return nil, err
}
if locked == nil {
return nil, apperr.New(http.StatusNotFound, "session_not_found", "session not found")
}
if err := s.verifySessionToken(locked, input.SessionToken); err != nil {
return nil, err
}
if locked.Status == "finished" || locked.Status == "cancelled" || locked.Status == "failed" {
return nil, apperr.New(http.StatusConflict, "session_not_startable", "session cannot be started")
}
if err := s.store.StartSession(ctx, tx, locked.ID); err != nil {
return nil, err
}
updated, err := s.store.GetSessionByPublicIDForUpdate(ctx, tx, input.SessionPublicID)
if err != nil {
return nil, err
}
if err := tx.Commit(ctx); err != nil {
return nil, err
}
return buildSessionResult(updated), nil
}
func (s *SessionService) FinishSession(ctx context.Context, input FinishSessionInput) (*SessionResult, error) {
input.Status = normalizeFinishStatus(input.Status)
session, err := s.validateSessionAction(ctx, input.SessionPublicID, input.SessionToken)
if err != nil {
return nil, err
}
if session.Status == "finished" || session.Status == "cancelled" || session.Status == "failed" {
return buildSessionResult(session), nil
}
tx, err := s.store.Begin(ctx)
if err != nil {
return nil, err
}
defer tx.Rollback(ctx)
locked, err := s.store.GetSessionByPublicIDForUpdate(ctx, tx, input.SessionPublicID)
if err != nil {
return nil, err
}
if locked == nil {
return nil, apperr.New(http.StatusNotFound, "session_not_found", "session not found")
}
if err := s.verifySessionToken(locked, input.SessionToken); err != nil {
return nil, err
}
if locked.Status == "finished" || locked.Status == "cancelled" || locked.Status == "failed" {
if err := tx.Commit(ctx); err != nil {
return nil, err
}
return buildSessionResult(locked), nil
}
if err := s.store.FinishSession(ctx, tx, postgres.FinishSessionParams{
SessionID: locked.ID,
Status: input.Status,
}); err != nil {
return nil, err
}
updated, err := s.store.GetSessionByPublicIDForUpdate(ctx, tx, input.SessionPublicID)
if err != nil {
return nil, err
}
if _, err := s.store.UpsertSessionResult(ctx, tx, postgres.UpsertSessionResultParams{
SessionID: updated.ID,
ResultStatus: input.Status,
Summary: buildSummaryMap(input.Summary),
FinalDurationSec: resolveDurationSeconds(updated, input.Summary),
FinalScore: summaryInt(input.Summary, func(v *SessionSummaryInput) *int { return v.FinalScore }),
CompletedControls: summaryInt(input.Summary, func(v *SessionSummaryInput) *int { return v.CompletedControls }),
TotalControls: summaryInt(input.Summary, func(v *SessionSummaryInput) *int { return v.TotalControls }),
DistanceMeters: summaryFloat(input.Summary, func(v *SessionSummaryInput) *float64 { return v.DistanceMeters }),
AverageSpeedKmh: summaryFloat(input.Summary, func(v *SessionSummaryInput) *float64 { return v.AverageSpeedKmh }),
MaxHeartRateBpm: summaryInt(input.Summary, func(v *SessionSummaryInput) *int { return v.MaxHeartRateBpm }),
}); err != nil {
return nil, err
}
if err := tx.Commit(ctx); err != nil {
return nil, err
}
return buildSessionResult(updated), nil
}
func (s *SessionService) validateSessionAction(ctx context.Context, sessionPublicID, sessionToken string) (*postgres.Session, error) {
sessionPublicID = strings.TrimSpace(sessionPublicID)
sessionToken = strings.TrimSpace(sessionToken)
if sessionPublicID == "" || sessionToken == "" {
return nil, apperr.New(http.StatusBadRequest, "invalid_params", "session id and sessionToken are required")
}
session, err := s.store.GetSessionByPublicID(ctx, sessionPublicID)
if err != nil {
return nil, err
}
if session == nil {
return nil, apperr.New(http.StatusNotFound, "session_not_found", "session not found")
}
if err := s.verifySessionToken(session, sessionToken); err != nil {
return nil, err
}
return session, nil
}
func (s *SessionService) verifySessionToken(session *postgres.Session, sessionToken string) error {
if session.SessionTokenExpiresAt.Before(time.Now().UTC()) {
return apperr.New(http.StatusUnauthorized, "session_token_expired", "session token expired")
}
if session.SessionTokenHash != security.HashText(sessionToken) {
return apperr.New(http.StatusUnauthorized, "invalid_session_token", "invalid session token")
}
return nil
}
func buildSessionResult(session *postgres.Session) *SessionResult {
result := &SessionResult{}
result.Session.ID = session.SessionPublicID
result.Session.Status = session.Status
result.Session.ClientType = session.ClientType
result.Session.DeviceKey = session.DeviceKey
result.Session.RouteCode = session.RouteCode
result.Session.SessionTokenExpiresAt = session.SessionTokenExpiresAt.Format(time.RFC3339)
result.Session.LaunchedAt = session.LaunchedAt.Format(time.RFC3339)
if session.StartedAt != nil {
value := session.StartedAt.Format(time.RFC3339)
result.Session.StartedAt = &value
}
if session.EndedAt != nil {
value := session.EndedAt.Format(time.RFC3339)
result.Session.EndedAt = &value
}
if session.EventPublicID != nil {
result.Event.ID = *session.EventPublicID
}
if session.EventDisplayName != nil {
result.Event.DisplayName = *session.EventDisplayName
}
result.ResolvedRelease = buildResolvedReleaseFromSession(session, LaunchSourceEventCurrentRelease)
return result
}
func normalizeFinishStatus(value string) string {
switch strings.TrimSpace(value) {
case "failed":
return "failed"
case "cancelled":
return "cancelled"
default:
return "finished"
}
}
func buildSummaryMap(summary *SessionSummaryInput) map[string]any {
if summary == nil {
return map[string]any{}
}
raw, err := json.Marshal(summary)
if err != nil {
return map[string]any{}
}
result := map[string]any{}
if err := json.Unmarshal(raw, &result); err != nil {
return map[string]any{}
}
return result
}
func resolveDurationSeconds(session *postgres.Session, summary *SessionSummaryInput) *int {
if summary != nil && summary.FinalDurationSec != nil {
return summary.FinalDurationSec
}
if session.StartedAt != nil {
endAt := time.Now().UTC()
if session.EndedAt != nil {
endAt = *session.EndedAt
}
seconds := int(endAt.Sub(*session.StartedAt).Seconds())
if seconds < 0 {
seconds = 0
}
return &seconds
}
return nil
}
func summaryInt(summary *SessionSummaryInput, getter func(*SessionSummaryInput) *int) *int {
if summary == nil {
return nil
}
return getter(summary)
}
func summaryFloat(summary *SessionSummaryInput, getter func(*SessionSummaryInput) *float64) *float64 {
if summary == nil {
return nil
}
return getter(summary)
}

View File

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

View File

@@ -1,310 +0,0 @@
package postgres
import (
"context"
"encoding/json"
"errors"
"fmt"
"net/http"
"time"
"cmr-backend/internal/apperr"
"github.com/jackc/pgx/v5"
)
type SMSCodeMeta struct {
ID string
CodeHash string
ExpiresAt time.Time
CooldownUntil time.Time
}
type CreateSMSCodeParams struct {
Scene string
CountryCode string
Mobile string
ClientType string
DeviceKey string
CodeHash string
ProviderName string
ProviderDebug map[string]any
ExpiresAt time.Time
CooldownUntil time.Time
}
type CreateMobileIdentityParams struct {
UserID string
IdentityType string
Provider string
ProviderSubj string
CountryCode string
Mobile string
}
type CreateIdentityParams struct {
UserID string
IdentityType string
Provider string
ProviderSubj string
CountryCode *string
Mobile *string
ProfileJSON string
}
type CreateRefreshTokenParams struct {
UserID string
ClientType string
DeviceKey string
TokenHash string
ExpiresAt time.Time
}
type RefreshTokenRecord struct {
ID string
UserID string
ClientType string
DeviceKey *string
ExpiresAt time.Time
IsRevoked bool
}
func (s *Store) GetLatestSMSCodeMeta(ctx context.Context, countryCode, mobile, clientType, scene string) (*SMSCodeMeta, error) {
row := s.pool.QueryRow(ctx, `
SELECT id, code_hash, expires_at, cooldown_until
FROM auth_sms_codes
WHERE country_code = $1 AND mobile = $2 AND client_type = $3 AND scene = $4
ORDER BY created_at DESC
LIMIT 1
`, countryCode, mobile, clientType, scene)
var record SMSCodeMeta
err := row.Scan(&record.ID, &record.CodeHash, &record.ExpiresAt, &record.CooldownUntil)
if errors.Is(err, pgx.ErrNoRows) {
return nil, nil
}
if err != nil {
return nil, fmt.Errorf("query latest sms code meta: %w", err)
}
return &record, nil
}
func (s *Store) CreateSMSCode(ctx context.Context, params CreateSMSCodeParams) error {
payload, err := json.Marshal(map[string]any{
"provider": params.ProviderName,
"debug": params.ProviderDebug,
})
if err != nil {
return err
}
_, err = s.pool.Exec(ctx, `
INSERT INTO auth_sms_codes (
scene, country_code, mobile, client_type, device_key, code_hash,
provider_payload_jsonb, expires_at, cooldown_until
)
VALUES ($1, $2, $3, $4, $5, $6, $7::jsonb, $8, $9)
`, params.Scene, params.CountryCode, params.Mobile, params.ClientType, params.DeviceKey, params.CodeHash, string(payload), params.ExpiresAt, params.CooldownUntil)
if err != nil {
return fmt.Errorf("insert sms code: %w", err)
}
return nil
}
func (s *Store) GetLatestValidSMSCode(ctx context.Context, countryCode, mobile, clientType, scene string) (*SMSCodeMeta, error) {
row := s.pool.QueryRow(ctx, `
SELECT id, code_hash, expires_at, cooldown_until
FROM auth_sms_codes
WHERE country_code = $1
AND mobile = $2
AND client_type = $3
AND scene = $4
AND consumed_at IS NULL
AND expires_at > NOW()
ORDER BY created_at DESC
LIMIT 1
`, countryCode, mobile, clientType, scene)
var record SMSCodeMeta
err := row.Scan(&record.ID, &record.CodeHash, &record.ExpiresAt, &record.CooldownUntil)
if errors.Is(err, pgx.ErrNoRows) {
return nil, nil
}
if err != nil {
return nil, fmt.Errorf("query latest valid sms code: %w", err)
}
return &record, nil
}
func (s *Store) ConsumeSMSCode(ctx context.Context, tx Tx, id string) (bool, error) {
commandTag, err := tx.Exec(ctx, `
UPDATE auth_sms_codes
SET consumed_at = NOW()
WHERE id = $1 AND consumed_at IS NULL
`, id)
if err != nil {
return false, fmt.Errorf("consume sms code: %w", err)
}
return commandTag.RowsAffected() == 1, nil
}
func (s *Store) CreateMobileIdentity(ctx context.Context, tx Tx, params CreateMobileIdentityParams) error {
countryCode := params.CountryCode
mobile := params.Mobile
return s.CreateIdentity(ctx, tx, CreateIdentityParams{
UserID: params.UserID,
IdentityType: params.IdentityType,
Provider: params.Provider,
ProviderSubj: params.ProviderSubj,
CountryCode: &countryCode,
Mobile: &mobile,
ProfileJSON: "{}",
})
}
func (s *Store) CreateIdentity(ctx context.Context, tx Tx, params CreateIdentityParams) error {
_, err := tx.Exec(ctx, `
INSERT INTO login_identities (
user_id, identity_type, provider, provider_subject, country_code, mobile, status, profile_jsonb
)
VALUES ($1, $2, $3, $4, $5, $6, 'active', $7::jsonb)
ON CONFLICT (provider, provider_subject) DO NOTHING
`, params.UserID, params.IdentityType, params.Provider, params.ProviderSubj, params.CountryCode, params.Mobile, zeroJSON(params.ProfileJSON))
if err != nil {
return fmt.Errorf("create identity: %w", err)
}
return nil
}
func (s *Store) FindUserByProviderSubject(ctx context.Context, tx Tx, provider, providerSubject string) (*User, error) {
row := tx.QueryRow(ctx, `
SELECT u.id, u.user_public_id, u.status, u.nickname, u.avatar_url
FROM users u
JOIN login_identities li ON li.user_id = u.id
WHERE li.provider = $1
AND li.provider_subject = $2
AND li.status = 'active'
LIMIT 1
`, provider, providerSubject)
return scanUser(row)
}
func (s *Store) CreateRefreshToken(ctx context.Context, tx Tx, params CreateRefreshTokenParams) (string, error) {
row := tx.QueryRow(ctx, `
INSERT INTO auth_refresh_tokens (user_id, client_type, device_key, token_hash, expires_at)
VALUES ($1, $2, NULLIF($3, ''), $4, $5)
RETURNING id
`, params.UserID, params.ClientType, params.DeviceKey, params.TokenHash, params.ExpiresAt)
var id string
if err := row.Scan(&id); err != nil {
return "", fmt.Errorf("create refresh token: %w", err)
}
return id, nil
}
func (s *Store) GetRefreshTokenForUpdate(ctx context.Context, tx Tx, tokenHash string) (*RefreshTokenRecord, error) {
row := tx.QueryRow(ctx, `
SELECT id, user_id, client_type, device_key, expires_at, revoked_at IS NOT NULL
FROM auth_refresh_tokens
WHERE token_hash = $1
FOR UPDATE
`, tokenHash)
var record RefreshTokenRecord
err := row.Scan(&record.ID, &record.UserID, &record.ClientType, &record.DeviceKey, &record.ExpiresAt, &record.IsRevoked)
if errors.Is(err, pgx.ErrNoRows) {
return nil, nil
}
if err != nil {
return nil, fmt.Errorf("query refresh token for update: %w", err)
}
return &record, nil
}
func (s *Store) RotateRefreshToken(ctx context.Context, tx Tx, oldTokenID, newTokenID string) error {
_, err := tx.Exec(ctx, `
UPDATE auth_refresh_tokens
SET revoked_at = NOW(), replaced_by_token_id = $2
WHERE id = $1
`, oldTokenID, newTokenID)
if err != nil {
return fmt.Errorf("rotate refresh token: %w", err)
}
return nil
}
func (s *Store) RevokeRefreshToken(ctx context.Context, tokenHash string) error {
commandTag, err := s.pool.Exec(ctx, `
UPDATE auth_refresh_tokens
SET revoked_at = COALESCE(revoked_at, NOW())
WHERE token_hash = $1
`, tokenHash)
if err != nil {
return fmt.Errorf("revoke refresh token: %w", err)
}
if commandTag.RowsAffected() == 0 {
return apperr.New(http.StatusNotFound, "refresh_token_not_found", "refresh token not found")
}
return nil
}
func (s *Store) RevokeRefreshTokensByUserID(ctx context.Context, tx Tx, userID string) error {
_, err := tx.Exec(ctx, `
UPDATE auth_refresh_tokens
SET revoked_at = COALESCE(revoked_at, NOW())
WHERE user_id = $1
`, userID)
if err != nil {
return fmt.Errorf("revoke refresh tokens by user id: %w", err)
}
return nil
}
func (s *Store) TransferNonMobileIdentities(ctx context.Context, tx Tx, sourceUserID, targetUserID string) error {
if sourceUserID == targetUserID {
return nil
}
_, err := tx.Exec(ctx, `
INSERT INTO login_identities (
user_id, identity_type, provider, provider_subject, country_code, mobile, status, profile_jsonb, created_at, updated_at
)
SELECT
$2,
li.identity_type,
li.provider,
li.provider_subject,
li.country_code,
li.mobile,
li.status,
li.profile_jsonb,
li.created_at,
li.updated_at
FROM login_identities li
WHERE li.user_id = $1
AND li.provider <> 'mobile'
ON CONFLICT (provider, provider_subject) DO NOTHING
`, sourceUserID, targetUserID)
if err != nil {
return fmt.Errorf("copy non-mobile identities: %w", err)
}
_, err = tx.Exec(ctx, `
DELETE FROM login_identities
WHERE user_id = $1
AND provider <> 'mobile'
`, sourceUserID)
if err != nil {
return fmt.Errorf("delete source non-mobile identities: %w", err)
}
return nil
}
func zeroJSON(value string) string {
if value == "" {
return "{}"
}
return value
}

View File

@@ -1,93 +0,0 @@
package postgres
import (
"context"
"fmt"
"time"
)
type Card struct {
ID string
PublicID string
CardType string
Title string
Subtitle *string
CoverURL *string
DisplaySlot string
DisplayPriority int
EntryChannelID *string
EventPublicID *string
EventDisplayName *string
EventSummary *string
HTMLURL *string
}
func (s *Store) ListCardsForEntry(ctx context.Context, tenantID string, entryChannelID *string, slot string, now time.Time, limit int) ([]Card, error) {
if limit <= 0 || limit > 100 {
limit = 20
}
if slot == "" {
slot = "home_primary"
}
rows, err := s.pool.Query(ctx, `
SELECT
c.id,
c.card_public_id,
c.card_type,
c.title,
c.subtitle,
c.cover_url,
c.display_slot,
c.display_priority,
c.entry_channel_id,
e.event_public_id,
e.display_name,
e.summary,
c.html_url
FROM cards c
LEFT JOIN events e ON e.id = c.event_id
WHERE c.tenant_id = $1
AND ($2::uuid IS NULL OR c.entry_channel_id = $2 OR c.entry_channel_id IS NULL)
AND c.display_slot = $3
AND c.status = 'active'
AND (c.starts_at IS NULL OR c.starts_at <= $4)
AND (c.ends_at IS NULL OR c.ends_at >= $4)
ORDER BY
CASE WHEN $2::uuid IS NOT NULL AND c.entry_channel_id = $2 THEN 0 ELSE 1 END,
c.display_priority DESC,
c.created_at ASC
LIMIT $5
`, tenantID, entryChannelID, slot, now, limit)
if err != nil {
return nil, fmt.Errorf("list cards for entry: %w", err)
}
defer rows.Close()
var cards []Card
for rows.Next() {
var card Card
if err := rows.Scan(
&card.ID,
&card.PublicID,
&card.CardType,
&card.Title,
&card.Subtitle,
&card.CoverURL,
&card.DisplaySlot,
&card.DisplayPriority,
&card.EntryChannelID,
&card.EventPublicID,
&card.EventDisplayName,
&card.EventSummary,
&card.HTMLURL,
); err != nil {
return nil, fmt.Errorf("scan card: %w", err)
}
cards = append(cards, card)
}
if err := rows.Err(); err != nil {
return nil, fmt.Errorf("iterate cards: %w", err)
}
return cards, nil
}

View File

@@ -1,323 +0,0 @@
package postgres
import (
"context"
"encoding/json"
"errors"
"fmt"
"github.com/jackc/pgx/v5"
)
type EventConfigSource struct {
ID string
EventID string
SourceVersionNo int
SourceKind string
SchemaID string
SchemaVersion string
Status string
SourceJSON string
Notes *string
}
type EventConfigBuild struct {
ID string
EventID string
SourceID string
BuildNo int
BuildStatus string
BuildLog *string
ManifestJSON string
AssetIndexJSON string
}
type EventReleaseAsset struct {
ID string
EventReleaseID string
AssetType string
AssetKey string
AssetPath *string
AssetURL string
Checksum *string
SizeBytes *int64
MetaJSON string
}
type UpsertEventConfigSourceParams struct {
EventID string
SourceVersionNo int
SourceKind string
SchemaID string
SchemaVersion string
Status string
Source map[string]any
Notes *string
}
type UpsertEventConfigBuildParams struct {
EventID string
SourceID string
BuildNo int
BuildStatus string
BuildLog *string
Manifest map[string]any
AssetIndex []map[string]any
}
type UpsertEventReleaseAssetParams struct {
EventReleaseID string
AssetType string
AssetKey string
AssetPath *string
AssetURL string
Checksum *string
SizeBytes *int64
Meta map[string]any
}
func (s *Store) UpsertEventConfigSource(ctx context.Context, tx Tx, params UpsertEventConfigSourceParams) (*EventConfigSource, error) {
sourceJSON, err := json.Marshal(params.Source)
if err != nil {
return nil, fmt.Errorf("marshal event config source: %w", err)
}
row := tx.QueryRow(ctx, `
INSERT INTO event_config_sources (
event_id, source_version_no, source_kind, schema_id, schema_version, status, source_jsonb, notes
)
VALUES ($1, $2, $3, $4, $5, $6, $7::jsonb, $8)
ON CONFLICT (event_id, source_version_no) DO UPDATE SET
source_kind = EXCLUDED.source_kind,
schema_id = EXCLUDED.schema_id,
schema_version = EXCLUDED.schema_version,
status = EXCLUDED.status,
source_jsonb = EXCLUDED.source_jsonb,
notes = EXCLUDED.notes
RETURNING id, event_id, source_version_no, source_kind, schema_id, schema_version, status, source_jsonb::text, notes
`, params.EventID, params.SourceVersionNo, params.SourceKind, params.SchemaID, params.SchemaVersion, params.Status, string(sourceJSON), params.Notes)
var item EventConfigSource
if err := row.Scan(
&item.ID,
&item.EventID,
&item.SourceVersionNo,
&item.SourceKind,
&item.SchemaID,
&item.SchemaVersion,
&item.Status,
&item.SourceJSON,
&item.Notes,
); err != nil {
return nil, fmt.Errorf("upsert event config source: %w", err)
}
return &item, nil
}
func (s *Store) UpsertEventConfigBuild(ctx context.Context, tx Tx, params UpsertEventConfigBuildParams) (*EventConfigBuild, error) {
manifestJSON, err := json.Marshal(params.Manifest)
if err != nil {
return nil, fmt.Errorf("marshal event config manifest: %w", err)
}
assetIndexJSON, err := json.Marshal(params.AssetIndex)
if err != nil {
return nil, fmt.Errorf("marshal event config asset index: %w", err)
}
row := tx.QueryRow(ctx, `
INSERT INTO event_config_builds (
event_id, source_id, build_no, build_status, build_log, manifest_jsonb, asset_index_jsonb
)
VALUES ($1, $2, $3, $4, $5, $6::jsonb, $7::jsonb)
ON CONFLICT (event_id, build_no) DO UPDATE SET
source_id = EXCLUDED.source_id,
build_status = EXCLUDED.build_status,
build_log = EXCLUDED.build_log,
manifest_jsonb = EXCLUDED.manifest_jsonb,
asset_index_jsonb = EXCLUDED.asset_index_jsonb
RETURNING id, event_id, source_id, build_no, build_status, build_log, manifest_jsonb::text, asset_index_jsonb::text
`, params.EventID, params.SourceID, params.BuildNo, params.BuildStatus, params.BuildLog, string(manifestJSON), string(assetIndexJSON))
var item EventConfigBuild
if err := row.Scan(
&item.ID,
&item.EventID,
&item.SourceID,
&item.BuildNo,
&item.BuildStatus,
&item.BuildLog,
&item.ManifestJSON,
&item.AssetIndexJSON,
); err != nil {
return nil, fmt.Errorf("upsert event config build: %w", err)
}
return &item, nil
}
func (s *Store) AttachBuildToRelease(ctx context.Context, tx Tx, releaseID, buildID string) error {
if _, err := tx.Exec(ctx, `
UPDATE event_releases
SET build_id = $2
WHERE id = $1
`, releaseID, buildID); err != nil {
return fmt.Errorf("attach build to release: %w", err)
}
return nil
}
func (s *Store) ReplaceEventReleaseAssets(ctx context.Context, tx Tx, eventReleaseID string, assets []UpsertEventReleaseAssetParams) error {
if _, err := tx.Exec(ctx, `DELETE FROM event_release_assets WHERE event_release_id = $1`, eventReleaseID); err != nil {
return fmt.Errorf("clear event release assets: %w", err)
}
for _, asset := range assets {
metaJSON, err := json.Marshal(asset.Meta)
if err != nil {
return fmt.Errorf("marshal event release asset meta: %w", err)
}
if _, err := tx.Exec(ctx, `
INSERT INTO event_release_assets (
event_release_id, asset_type, asset_key, asset_path, asset_url, checksum, size_bytes, meta_jsonb
)
VALUES ($1, $2, $3, $4, $5, $6, $7, $8::jsonb)
`, eventReleaseID, asset.AssetType, asset.AssetKey, asset.AssetPath, asset.AssetURL, asset.Checksum, asset.SizeBytes, string(metaJSON)); err != nil {
return fmt.Errorf("insert event release asset: %w", err)
}
}
return nil
}
func (s *Store) NextEventConfigSourceVersion(ctx context.Context, eventID string) (int, error) {
var next int
if err := s.pool.QueryRow(ctx, `
SELECT COALESCE(MAX(source_version_no), 0) + 1
FROM event_config_sources
WHERE event_id = $1
`, eventID).Scan(&next); err != nil {
return 0, fmt.Errorf("next event config source version: %w", err)
}
return next, nil
}
func (s *Store) NextEventConfigBuildNo(ctx context.Context, eventID string) (int, error) {
var next int
if err := s.pool.QueryRow(ctx, `
SELECT COALESCE(MAX(build_no), 0) + 1
FROM event_config_builds
WHERE event_id = $1
`, eventID).Scan(&next); err != nil {
return 0, fmt.Errorf("next event config build no: %w", err)
}
return next, nil
}
func (s *Store) ListEventConfigSourcesByEventID(ctx context.Context, eventID string, limit int) ([]EventConfigSource, error) {
if limit <= 0 || limit > 100 {
limit = 20
}
rows, err := s.pool.Query(ctx, `
SELECT id, event_id, source_version_no, source_kind, schema_id, schema_version, status, source_jsonb::text, notes
FROM event_config_sources
WHERE event_id = $1
ORDER BY source_version_no DESC
LIMIT $2
`, eventID, limit)
if err != nil {
return nil, fmt.Errorf("list event config sources: %w", err)
}
defer rows.Close()
var items []EventConfigSource
for rows.Next() {
item, err := scanEventConfigSourceFromRows(rows)
if err != nil {
return nil, err
}
items = append(items, *item)
}
if err := rows.Err(); err != nil {
return nil, fmt.Errorf("iterate event config sources: %w", err)
}
return items, nil
}
func (s *Store) GetEventConfigSourceByID(ctx context.Context, sourceID string) (*EventConfigSource, error) {
row := s.pool.QueryRow(ctx, `
SELECT id, event_id, source_version_no, source_kind, schema_id, schema_version, status, source_jsonb::text, notes
FROM event_config_sources
WHERE id = $1
LIMIT 1
`, sourceID)
return scanEventConfigSource(row)
}
func (s *Store) GetEventConfigBuildByID(ctx context.Context, buildID string) (*EventConfigBuild, error) {
row := s.pool.QueryRow(ctx, `
SELECT id, event_id, source_id, build_no, build_status, build_log, manifest_jsonb::text, asset_index_jsonb::text
FROM event_config_builds
WHERE id = $1
LIMIT 1
`, buildID)
return scanEventConfigBuild(row)
}
func scanEventConfigSource(row pgx.Row) (*EventConfigSource, error) {
var item EventConfigSource
err := row.Scan(
&item.ID,
&item.EventID,
&item.SourceVersionNo,
&item.SourceKind,
&item.SchemaID,
&item.SchemaVersion,
&item.Status,
&item.SourceJSON,
&item.Notes,
)
if errors.Is(err, pgx.ErrNoRows) {
return nil, nil
}
if err != nil {
return nil, fmt.Errorf("scan event config source: %w", err)
}
return &item, nil
}
func scanEventConfigSourceFromRows(rows pgx.Rows) (*EventConfigSource, error) {
var item EventConfigSource
if err := rows.Scan(
&item.ID,
&item.EventID,
&item.SourceVersionNo,
&item.SourceKind,
&item.SchemaID,
&item.SchemaVersion,
&item.Status,
&item.SourceJSON,
&item.Notes,
); err != nil {
return nil, fmt.Errorf("scan event config source row: %w", err)
}
return &item, nil
}
func scanEventConfigBuild(row pgx.Row) (*EventConfigBuild, error) {
var item EventConfigBuild
err := row.Scan(
&item.ID,
&item.EventID,
&item.SourceID,
&item.BuildNo,
&item.BuildStatus,
&item.BuildLog,
&item.ManifestJSON,
&item.AssetIndexJSON,
)
if errors.Is(err, pgx.ErrNoRows) {
return nil, nil
}
if err != nil {
return nil, fmt.Errorf("scan event config build: %w", err)
}
return &item, nil
}

View File

@@ -1,46 +0,0 @@
package postgres
import (
"context"
"fmt"
"github.com/jackc/pgx/v5"
"github.com/jackc/pgx/v5/pgxpool"
)
type Store struct {
pool *pgxpool.Pool
}
type Tx = pgx.Tx
func Open(ctx context.Context, databaseURL string) (*pgxpool.Pool, error) {
pool, err := pgxpool.New(ctx, databaseURL)
if err != nil {
return nil, fmt.Errorf("open postgres pool: %w", err)
}
if err := pool.Ping(ctx); err != nil {
pool.Close()
return nil, fmt.Errorf("ping postgres: %w", err)
}
return pool, nil
}
func NewStore(pool *pgxpool.Pool) *Store {
return &Store{pool: pool}
}
func (s *Store) Pool() *pgxpool.Pool {
return s.pool
}
func (s *Store) Close() {
if s.pool != nil {
s.pool.Close()
}
}
func (s *Store) Begin(ctx context.Context) (pgx.Tx, error) {
return s.pool.Begin(ctx)
}

View File

@@ -1,324 +0,0 @@
package postgres
import (
"context"
"fmt"
)
type DemoBootstrapSummary struct {
TenantCode string `json:"tenantCode"`
ChannelCode string `json:"channelCode"`
EventID string `json:"eventId"`
ReleaseID string `json:"releaseId"`
SourceID string `json:"sourceId"`
BuildID string `json:"buildId"`
CardID string `json:"cardId"`
}
func (s *Store) EnsureDemoData(ctx context.Context) (*DemoBootstrapSummary, error) {
tx, err := s.Begin(ctx)
if err != nil {
return nil, err
}
defer tx.Rollback(ctx)
var tenantID string
if err := tx.QueryRow(ctx, `
INSERT INTO tenants (tenant_code, name, status)
VALUES ('tenant_demo', 'Demo Tenant', 'active')
ON CONFLICT (tenant_code) DO UPDATE SET
name = EXCLUDED.name,
status = EXCLUDED.status
RETURNING id
`).Scan(&tenantID); err != nil {
return nil, fmt.Errorf("ensure demo tenant: %w", err)
}
var channelID string
if err := tx.QueryRow(ctx, `
INSERT INTO entry_channels (
tenant_id, channel_code, channel_type, platform_app_id, display_name, status, is_default
)
VALUES ($1, 'mini-demo', 'wechat_mini', 'wx-demo-appid', 'Demo Mini Channel', 'active', true)
ON CONFLICT (tenant_id, channel_code) DO UPDATE SET
channel_type = EXCLUDED.channel_type,
platform_app_id = EXCLUDED.platform_app_id,
display_name = EXCLUDED.display_name,
status = EXCLUDED.status,
is_default = EXCLUDED.is_default
RETURNING id
`, tenantID).Scan(&channelID); err != nil {
return nil, fmt.Errorf("ensure demo entry channel: %w", err)
}
var eventID string
if err := tx.QueryRow(ctx, `
INSERT INTO events (
tenant_id, event_public_id, slug, display_name, summary, status
)
VALUES ($1, 'evt_demo_001', 'demo-city-run', 'Demo City Run', 'Launch flow demo event', 'active')
ON CONFLICT (event_public_id) DO UPDATE SET
tenant_id = EXCLUDED.tenant_id,
slug = EXCLUDED.slug,
display_name = EXCLUDED.display_name,
summary = EXCLUDED.summary,
status = EXCLUDED.status
RETURNING id
`, tenantID).Scan(&eventID); err != nil {
return nil, fmt.Errorf("ensure demo event: %w", err)
}
var releaseRow struct {
ID string
PublicID string
}
if err := tx.QueryRow(ctx, `
INSERT INTO event_releases (
release_public_id,
event_id,
release_no,
config_label,
manifest_url,
manifest_checksum_sha256,
route_code,
status
)
VALUES (
'rel_demo_001',
$1,
1,
'Demo Config v1',
'https://oss-mbh5.colormaprun.com/gotomars/event/classic-sequential.json',
'demo-checksum-001',
'route-demo-001',
'published'
)
ON CONFLICT (release_public_id) DO UPDATE SET
event_id = EXCLUDED.event_id,
config_label = EXCLUDED.config_label,
manifest_url = EXCLUDED.manifest_url,
manifest_checksum_sha256 = EXCLUDED.manifest_checksum_sha256,
route_code = EXCLUDED.route_code,
status = EXCLUDED.status
RETURNING id, release_public_id
`, eventID).Scan(&releaseRow.ID, &releaseRow.PublicID); err != nil {
return nil, fmt.Errorf("ensure demo release: %w", err)
}
if _, err := tx.Exec(ctx, `
UPDATE events
SET current_release_id = $2
WHERE id = $1
`, eventID, releaseRow.ID); err != nil {
return nil, fmt.Errorf("attach demo release: %w", err)
}
sourceNotes := "demo source config imported from local event sample"
source, err := s.UpsertEventConfigSource(ctx, tx, UpsertEventConfigSourceParams{
EventID: eventID,
SourceVersionNo: 1,
SourceKind: "event_bundle",
SchemaID: "event-source",
SchemaVersion: "1",
Status: "active",
Notes: &sourceNotes,
Source: map[string]any{
"app": map[string]any{
"id": "sample-classic-001",
"title": "顺序赛示例",
},
"branding": map[string]any{
"tenantCode": "tenant_demo",
"entryChannel": "mini-demo",
},
"map": map[string]any{
"tiles": "../map/lxcb-001/tiles/",
"mapmeta": "../map/lxcb-001/tiles/meta.json",
},
"playfield": map[string]any{
"kind": "course",
"source": map[string]any{
"type": "kml",
"url": "../kml/lxcb-001/10/c01.kml",
},
},
"game": map[string]any{
"mode": "classic-sequential",
},
"content": map[string]any{
"h5Template": "content-h5-test-template.html",
},
},
})
if err != nil {
return nil, fmt.Errorf("ensure demo event config source: %w", err)
}
buildLog := "demo build generated from sample classic-sequential.json"
build, err := s.UpsertEventConfigBuild(ctx, tx, UpsertEventConfigBuildParams{
EventID: eventID,
SourceID: source.ID,
BuildNo: 1,
BuildStatus: "success",
BuildLog: &buildLog,
Manifest: map[string]any{
"schemaVersion": "1",
"releaseId": "rel_demo_001",
"version": "2026.04.01",
"app": map[string]any{
"id": "sample-classic-001",
"title": "顺序赛示例",
},
"map": map[string]any{
"tiles": "https://oss-mbh5.colormaprun.com/gotomars/map/lxcb-001/tiles/",
"mapmeta": "https://oss-mbh5.colormaprun.com/gotomars/map/lxcb-001/tiles/meta.json",
},
"playfield": map[string]any{
"kind": "course",
"source": map[string]any{
"type": "kml",
"url": "https://oss-mbh5.colormaprun.com/gotomars/kml/lxcb-001/10/c01.kml",
},
},
"game": map[string]any{
"mode": "classic-sequential",
},
"assets": map[string]any{
"contentHtml": "https://oss-mbh5.colormaprun.com/gotomars/event/content-h5-test-template.html",
},
},
AssetIndex: []map[string]any{
{
"assetType": "manifest",
"assetKey": "manifest",
},
{
"assetType": "mapmeta",
"assetKey": "mapmeta",
},
{
"assetType": "playfield",
"assetKey": "playfield-kml",
},
{
"assetType": "content_html",
"assetKey": "content-html",
},
},
})
if err != nil {
return nil, fmt.Errorf("ensure demo event config build: %w", err)
}
if err := s.AttachBuildToRelease(ctx, tx, releaseRow.ID, build.ID); err != nil {
return nil, fmt.Errorf("attach demo build to release: %w", err)
}
tilesPath := "map/lxcb-001/tiles/"
mapmetaPath := "map/lxcb-001/tiles/meta.json"
playfieldPath := "kml/lxcb-001/10/c01.kml"
contentPath := "event/content-h5-test-template.html"
manifestChecksum := "demo-checksum-001"
if err := s.ReplaceEventReleaseAssets(ctx, tx, releaseRow.ID, []UpsertEventReleaseAssetParams{
{
EventReleaseID: releaseRow.ID,
AssetType: "manifest",
AssetKey: "manifest",
AssetURL: "https://oss-mbh5.colormaprun.com/gotomars/event/classic-sequential.json",
Checksum: &manifestChecksum,
Meta: map[string]any{"source": "release-manifest"},
},
{
EventReleaseID: releaseRow.ID,
AssetType: "tiles",
AssetKey: "tiles-root",
AssetPath: &tilesPath,
AssetURL: "https://oss-mbh5.colormaprun.com/gotomars/map/lxcb-001/tiles/",
Meta: map[string]any{"kind": "directory"},
},
{
EventReleaseID: releaseRow.ID,
AssetType: "mapmeta",
AssetKey: "mapmeta",
AssetPath: &mapmetaPath,
AssetURL: "https://oss-mbh5.colormaprun.com/gotomars/map/lxcb-001/tiles/meta.json",
Meta: map[string]any{"format": "json"},
},
{
EventReleaseID: releaseRow.ID,
AssetType: "playfield",
AssetKey: "course-kml",
AssetPath: &playfieldPath,
AssetURL: "https://oss-mbh5.colormaprun.com/gotomars/kml/lxcb-001/10/c01.kml",
Meta: map[string]any{"format": "kml"},
},
{
EventReleaseID: releaseRow.ID,
AssetType: "content_html",
AssetKey: "content-html",
AssetPath: &contentPath,
AssetURL: "https://oss-mbh5.colormaprun.com/gotomars/event/content-h5-test-template.html",
Meta: map[string]any{"kind": "content-page"},
},
}); err != nil {
return nil, fmt.Errorf("ensure demo event release assets: %w", err)
}
var cardPublicID string
if err := tx.QueryRow(ctx, `
INSERT INTO cards (
card_public_id,
tenant_id,
entry_channel_id,
card_type,
title,
subtitle,
cover_url,
event_id,
display_slot,
display_priority,
status
)
VALUES (
'card_demo_001',
$1,
$2,
'event',
'Demo City Run',
'今日推荐路线',
'https://oss-mbh5.colormaprun.com/gotomars/assets/demo-cover.jpg',
$3,
'home_primary',
100,
'active'
)
ON CONFLICT (card_public_id) DO UPDATE SET
tenant_id = EXCLUDED.tenant_id,
entry_channel_id = EXCLUDED.entry_channel_id,
card_type = EXCLUDED.card_type,
title = EXCLUDED.title,
subtitle = EXCLUDED.subtitle,
cover_url = EXCLUDED.cover_url,
event_id = EXCLUDED.event_id,
display_slot = EXCLUDED.display_slot,
display_priority = EXCLUDED.display_priority,
status = EXCLUDED.status
RETURNING card_public_id
`, tenantID, channelID, eventID).Scan(&cardPublicID); err != nil {
return nil, fmt.Errorf("ensure demo card: %w", err)
}
if err := tx.Commit(ctx); err != nil {
return nil, err
}
return &DemoBootstrapSummary{
TenantCode: "tenant_demo",
ChannelCode: "mini-demo",
EventID: "evt_demo_001",
ReleaseID: releaseRow.PublicID,
SourceID: source.ID,
BuildID: build.ID,
CardID: cardPublicID,
}, nil
}

View File

@@ -1,74 +0,0 @@
package postgres
import (
"context"
"errors"
"fmt"
"github.com/jackc/pgx/v5"
)
type EntryChannel struct {
ID string
ChannelCode string
ChannelType string
PlatformAppID *string
DisplayName string
Status string
IsDefault bool
TenantID string
TenantCode string
TenantName string
}
type FindEntryChannelParams struct {
ChannelCode string
ChannelType string
PlatformAppID string
TenantCode string
}
func (s *Store) FindEntryChannel(ctx context.Context, params FindEntryChannelParams) (*EntryChannel, error) {
row := s.pool.QueryRow(ctx, `
SELECT
ec.id,
ec.channel_code,
ec.channel_type,
ec.platform_app_id,
ec.display_name,
ec.status,
ec.is_default,
t.id,
t.tenant_code,
t.name
FROM entry_channels ec
JOIN tenants t ON t.id = ec.tenant_id
WHERE ($1 = '' OR ec.channel_code = $1)
AND ($2 = '' OR ec.channel_type = $2)
AND ($3 = '' OR COALESCE(ec.platform_app_id, '') = $3)
AND ($4 = '' OR t.tenant_code = $4)
ORDER BY ec.is_default DESC, ec.created_at ASC
LIMIT 1
`, params.ChannelCode, params.ChannelType, params.PlatformAppID, params.TenantCode)
var entry EntryChannel
err := row.Scan(
&entry.ID,
&entry.ChannelCode,
&entry.ChannelType,
&entry.PlatformAppID,
&entry.DisplayName,
&entry.Status,
&entry.IsDefault,
&entry.TenantID,
&entry.TenantCode,
&entry.TenantName,
)
if errors.Is(err, pgx.ErrNoRows) {
return nil, nil
}
if err != nil {
return nil, fmt.Errorf("find entry channel: %w", err)
}
return &entry, nil
}

View File

@@ -1,263 +0,0 @@
package postgres
import (
"context"
"errors"
"fmt"
"time"
"github.com/jackc/pgx/v5"
)
type Event struct {
ID string
PublicID string
Slug string
DisplayName string
Summary *string
Status string
CurrentReleaseID *string
CurrentReleasePubID *string
ConfigLabel *string
ManifestURL *string
ManifestChecksum *string
RouteCode *string
}
type EventRelease struct {
ID string
PublicID string
EventID string
ReleaseNo int
ConfigLabel string
ManifestURL string
ManifestChecksum *string
RouteCode *string
BuildID *string
Status string
PublishedAt time.Time
}
type CreateGameSessionParams struct {
SessionPublicID string
UserID string
EventID string
EventReleaseID string
DeviceKey string
ClientType string
RouteCode *string
SessionTokenHash string
SessionTokenExpiresAt time.Time
}
type GameSession struct {
ID string
SessionPublicID string
UserID string
EventID string
EventReleaseID string
DeviceKey string
ClientType string
RouteCode *string
Status string
SessionTokenExpiresAt time.Time
}
func (s *Store) GetEventByPublicID(ctx context.Context, eventPublicID string) (*Event, error) {
row := s.pool.QueryRow(ctx, `
SELECT
e.id,
e.event_public_id,
e.slug,
e.display_name,
e.summary,
e.status,
e.current_release_id,
er.release_public_id,
er.config_label,
er.manifest_url,
er.manifest_checksum_sha256,
er.route_code
FROM events e
LEFT JOIN event_releases er ON er.id = e.current_release_id
WHERE e.event_public_id = $1
LIMIT 1
`, eventPublicID)
var event Event
err := row.Scan(
&event.ID,
&event.PublicID,
&event.Slug,
&event.DisplayName,
&event.Summary,
&event.Status,
&event.CurrentReleaseID,
&event.CurrentReleasePubID,
&event.ConfigLabel,
&event.ManifestURL,
&event.ManifestChecksum,
&event.RouteCode,
)
if errors.Is(err, pgx.ErrNoRows) {
return nil, nil
}
if err != nil {
return nil, fmt.Errorf("get event by public id: %w", err)
}
return &event, nil
}
func (s *Store) GetEventByID(ctx context.Context, eventID string) (*Event, error) {
row := s.pool.QueryRow(ctx, `
SELECT
e.id,
e.event_public_id,
e.slug,
e.display_name,
e.summary,
e.status,
e.current_release_id,
er.release_public_id,
er.config_label,
er.manifest_url,
er.manifest_checksum_sha256,
er.route_code
FROM events e
LEFT JOIN event_releases er ON er.id = e.current_release_id
WHERE e.id = $1
LIMIT 1
`, eventID)
var event Event
err := row.Scan(
&event.ID,
&event.PublicID,
&event.Slug,
&event.DisplayName,
&event.Summary,
&event.Status,
&event.CurrentReleaseID,
&event.CurrentReleasePubID,
&event.ConfigLabel,
&event.ManifestURL,
&event.ManifestChecksum,
&event.RouteCode,
)
if errors.Is(err, pgx.ErrNoRows) {
return nil, nil
}
if err != nil {
return nil, fmt.Errorf("get event by id: %w", err)
}
return &event, nil
}
func (s *Store) NextEventReleaseNo(ctx context.Context, eventID string) (int, error) {
var next int
if err := s.pool.QueryRow(ctx, `
SELECT COALESCE(MAX(release_no), 0) + 1
FROM event_releases
WHERE event_id = $1
`, eventID).Scan(&next); err != nil {
return 0, fmt.Errorf("next event release no: %w", err)
}
return next, nil
}
type CreateEventReleaseParams struct {
PublicID string
EventID string
ReleaseNo int
ConfigLabel string
ManifestURL string
ManifestChecksum *string
RouteCode *string
BuildID *string
Status string
PayloadJSON string
}
func (s *Store) CreateEventRelease(ctx context.Context, tx Tx, params CreateEventReleaseParams) (*EventRelease, error) {
row := tx.QueryRow(ctx, `
INSERT INTO event_releases (
release_public_id,
event_id,
release_no,
config_label,
manifest_url,
manifest_checksum_sha256,
route_code,
build_id,
status,
payload_jsonb
)
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10::jsonb)
RETURNING id, release_public_id, event_id, release_no, config_label, manifest_url, manifest_checksum_sha256, route_code, build_id, status, published_at
`, params.PublicID, params.EventID, params.ReleaseNo, params.ConfigLabel, params.ManifestURL, params.ManifestChecksum, params.RouteCode, params.BuildID, params.Status, params.PayloadJSON)
var item EventRelease
if err := row.Scan(
&item.ID,
&item.PublicID,
&item.EventID,
&item.ReleaseNo,
&item.ConfigLabel,
&item.ManifestURL,
&item.ManifestChecksum,
&item.RouteCode,
&item.BuildID,
&item.Status,
&item.PublishedAt,
); err != nil {
return nil, fmt.Errorf("create event release: %w", err)
}
return &item, nil
}
func (s *Store) SetCurrentEventRelease(ctx context.Context, tx Tx, eventID, releaseID string) error {
if _, err := tx.Exec(ctx, `
UPDATE events
SET current_release_id = $2
WHERE id = $1
`, eventID, releaseID); err != nil {
return fmt.Errorf("set current event release: %w", err)
}
return nil
}
func (s *Store) CreateGameSession(ctx context.Context, tx Tx, params CreateGameSessionParams) (*GameSession, error) {
row := tx.QueryRow(ctx, `
INSERT INTO game_sessions (
session_public_id,
user_id,
event_id,
event_release_id,
device_key,
client_type,
route_code,
session_token_hash,
session_token_expires_at
)
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9)
RETURNING id, session_public_id, user_id, event_id, event_release_id, device_key, client_type, route_code, status, session_token_expires_at
`, params.SessionPublicID, params.UserID, params.EventID, params.EventReleaseID, params.DeviceKey, params.ClientType, params.RouteCode, params.SessionTokenHash, params.SessionTokenExpiresAt)
var session GameSession
err := row.Scan(
&session.ID,
&session.SessionPublicID,
&session.UserID,
&session.EventID,
&session.EventReleaseID,
&session.DeviceKey,
&session.ClientType,
&session.RouteCode,
&session.Status,
&session.SessionTokenExpiresAt,
)
if err != nil {
return nil, fmt.Errorf("create game session: %w", err)
}
return &session, nil
}

View File

@@ -1,50 +0,0 @@
package postgres
import (
"context"
"fmt"
)
type LoginIdentity struct {
ID string
IdentityType string
Provider string
ProviderSubject string
CountryCode *string
Mobile *string
Status string
}
func (s *Store) ListIdentitiesByUserID(ctx context.Context, userID string) ([]LoginIdentity, error) {
rows, err := s.pool.Query(ctx, `
SELECT id, identity_type, provider, provider_subject, country_code, mobile, status
FROM login_identities
WHERE user_id = $1
ORDER BY created_at ASC
`, userID)
if err != nil {
return nil, fmt.Errorf("list identities by user id: %w", err)
}
defer rows.Close()
var identities []LoginIdentity
for rows.Next() {
var identity LoginIdentity
if err := rows.Scan(
&identity.ID,
&identity.IdentityType,
&identity.Provider,
&identity.ProviderSubject,
&identity.CountryCode,
&identity.Mobile,
&identity.Status,
); err != nil {
return nil, fmt.Errorf("scan identity: %w", err)
}
identities = append(identities, identity)
}
if err := rows.Err(); err != nil {
return nil, fmt.Errorf("iterate identities: %w", err)
}
return identities, nil
}

View File

@@ -1,367 +0,0 @@
package postgres
import (
"context"
"encoding/json"
"fmt"
"github.com/jackc/pgx/v5"
)
type SessionResult struct {
ID string
SessionID string
ResultStatus string
SummaryJSON string
FinalDurationSec *int
FinalScore *int
CompletedControls *int
TotalControls *int
DistanceMeters *float64
AverageSpeedKmh *float64
MaxHeartRateBpm *int
}
type UpsertSessionResultParams struct {
SessionID string
ResultStatus string
Summary map[string]any
FinalDurationSec *int
FinalScore *int
CompletedControls *int
TotalControls *int
DistanceMeters *float64
AverageSpeedKmh *float64
MaxHeartRateBpm *int
}
type SessionResultRecord struct {
Session
Result *SessionResult
}
func (s *Store) UpsertSessionResult(ctx context.Context, tx Tx, params UpsertSessionResultParams) (*SessionResult, error) {
summaryJSON, err := json.Marshal(params.Summary)
if err != nil {
return nil, fmt.Errorf("marshal session summary: %w", err)
}
row := tx.QueryRow(ctx, `
INSERT INTO session_results (
session_id,
result_status,
summary_jsonb,
final_duration_sec,
final_score,
completed_controls,
total_controls,
distance_meters,
average_speed_kmh,
max_heart_rate_bpm
)
VALUES ($1, $2, $3::jsonb, $4, $5, $6, $7, $8, $9, $10)
ON CONFLICT (session_id) DO UPDATE SET
result_status = EXCLUDED.result_status,
summary_jsonb = EXCLUDED.summary_jsonb,
final_duration_sec = EXCLUDED.final_duration_sec,
final_score = EXCLUDED.final_score,
completed_controls = EXCLUDED.completed_controls,
total_controls = EXCLUDED.total_controls,
distance_meters = EXCLUDED.distance_meters,
average_speed_kmh = EXCLUDED.average_speed_kmh,
max_heart_rate_bpm = EXCLUDED.max_heart_rate_bpm
RETURNING
id,
session_id,
result_status,
summary_jsonb::text,
final_duration_sec,
final_score,
completed_controls,
total_controls,
distance_meters::float8,
average_speed_kmh::float8,
max_heart_rate_bpm
`, params.SessionID, params.ResultStatus, string(summaryJSON), params.FinalDurationSec, params.FinalScore, params.CompletedControls, params.TotalControls, params.DistanceMeters, params.AverageSpeedKmh, params.MaxHeartRateBpm)
return scanSessionResult(row)
}
func (s *Store) GetSessionResultByPublicID(ctx context.Context, sessionPublicID string) (*SessionResultRecord, error) {
row := s.pool.QueryRow(ctx, `
SELECT
gs.id,
gs.session_public_id,
gs.user_id,
gs.event_id,
gs.event_release_id,
er.release_public_id,
er.config_label,
er.manifest_url,
er.manifest_checksum_sha256,
gs.device_key,
gs.client_type,
gs.route_code,
gs.status,
gs.session_token_hash,
gs.session_token_expires_at,
gs.launched_at,
gs.started_at,
gs.ended_at,
e.event_public_id,
e.display_name,
sr.id,
sr.session_id,
sr.result_status,
sr.summary_jsonb::text,
sr.final_duration_sec,
sr.final_score,
sr.completed_controls,
sr.total_controls,
sr.distance_meters::float8,
sr.average_speed_kmh::float8,
sr.max_heart_rate_bpm
FROM game_sessions gs
JOIN events e ON e.id = gs.event_id
JOIN event_releases er ON er.id = gs.event_release_id
LEFT JOIN session_results sr ON sr.session_id = gs.id
WHERE gs.session_public_id = $1
LIMIT 1
`, sessionPublicID)
return scanSessionResultRecord(row)
}
func (s *Store) ListSessionResultsByUserID(ctx context.Context, userID string, limit int) ([]SessionResultRecord, error) {
if limit <= 0 || limit > 100 {
limit = 20
}
rows, err := s.pool.Query(ctx, `
SELECT
gs.id,
gs.session_public_id,
gs.user_id,
gs.event_id,
gs.event_release_id,
er.release_public_id,
er.config_label,
er.manifest_url,
er.manifest_checksum_sha256,
gs.device_key,
gs.client_type,
gs.route_code,
gs.status,
gs.session_token_hash,
gs.session_token_expires_at,
gs.launched_at,
gs.started_at,
gs.ended_at,
e.event_public_id,
e.display_name,
sr.id,
sr.session_id,
sr.result_status,
sr.summary_jsonb::text,
sr.final_duration_sec,
sr.final_score,
sr.completed_controls,
sr.total_controls,
sr.distance_meters::float8,
sr.average_speed_kmh::float8,
sr.max_heart_rate_bpm
FROM game_sessions gs
JOIN events e ON e.id = gs.event_id
JOIN event_releases er ON er.id = gs.event_release_id
LEFT JOIN session_results sr ON sr.session_id = gs.id
WHERE gs.user_id = $1
AND gs.status IN ('finished', 'failed', 'cancelled')
ORDER BY COALESCE(gs.ended_at, gs.updated_at, gs.created_at) DESC
LIMIT $2
`, userID, limit)
if err != nil {
return nil, fmt.Errorf("list session results by user id: %w", err)
}
defer rows.Close()
var items []SessionResultRecord
for rows.Next() {
item, err := scanSessionResultRecordFromRows(rows)
if err != nil {
return nil, err
}
items = append(items, *item)
}
if err := rows.Err(); err != nil {
return nil, fmt.Errorf("iterate session results by user id: %w", err)
}
return items, nil
}
func scanSessionResult(row pgx.Row) (*SessionResult, error) {
var result SessionResult
err := row.Scan(
&result.ID,
&result.SessionID,
&result.ResultStatus,
&result.SummaryJSON,
&result.FinalDurationSec,
&result.FinalScore,
&result.CompletedControls,
&result.TotalControls,
&result.DistanceMeters,
&result.AverageSpeedKmh,
&result.MaxHeartRateBpm,
)
if err != nil {
return nil, fmt.Errorf("scan session result: %w", err)
}
return &result, nil
}
func scanSessionResultRecord(row pgx.Row) (*SessionResultRecord, error) {
var record SessionResultRecord
var resultID *string
var resultSessionID *string
var resultStatus *string
var resultSummaryJSON *string
var finalDurationSec *int
var finalScore *int
var completedControls *int
var totalControls *int
var distanceMeters *float64
var averageSpeedKmh *float64
var maxHeartRateBpm *int
err := row.Scan(
&record.ID,
&record.SessionPublicID,
&record.UserID,
&record.EventID,
&record.EventReleaseID,
&record.ReleasePublicID,
&record.ConfigLabel,
&record.ManifestURL,
&record.ManifestChecksum,
&record.DeviceKey,
&record.ClientType,
&record.RouteCode,
&record.Status,
&record.SessionTokenHash,
&record.SessionTokenExpiresAt,
&record.LaunchedAt,
&record.StartedAt,
&record.EndedAt,
&record.EventPublicID,
&record.EventDisplayName,
&resultID,
&resultSessionID,
&resultStatus,
&resultSummaryJSON,
&finalDurationSec,
&finalScore,
&completedControls,
&totalControls,
&distanceMeters,
&averageSpeedKmh,
&maxHeartRateBpm,
)
if err != nil {
if err == pgx.ErrNoRows {
return nil, nil
}
return nil, fmt.Errorf("scan session result record: %w", err)
}
if resultID != nil {
record.Result = &SessionResult{
ID: *resultID,
SessionID: derefString(resultSessionID),
ResultStatus: derefString(resultStatus),
SummaryJSON: derefString(resultSummaryJSON),
FinalDurationSec: finalDurationSec,
FinalScore: finalScore,
CompletedControls: completedControls,
TotalControls: totalControls,
DistanceMeters: distanceMeters,
AverageSpeedKmh: averageSpeedKmh,
MaxHeartRateBpm: maxHeartRateBpm,
}
}
return &record, nil
}
func scanSessionResultRecordFromRows(rows pgx.Rows) (*SessionResultRecord, error) {
var record SessionResultRecord
var resultID *string
var resultSessionID *string
var resultStatus *string
var resultSummaryJSON *string
var finalDurationSec *int
var finalScore *int
var completedControls *int
var totalControls *int
var distanceMeters *float64
var averageSpeedKmh *float64
var maxHeartRateBpm *int
err := rows.Scan(
&record.ID,
&record.SessionPublicID,
&record.UserID,
&record.EventID,
&record.EventReleaseID,
&record.ReleasePublicID,
&record.ConfigLabel,
&record.ManifestURL,
&record.ManifestChecksum,
&record.DeviceKey,
&record.ClientType,
&record.RouteCode,
&record.Status,
&record.SessionTokenHash,
&record.SessionTokenExpiresAt,
&record.LaunchedAt,
&record.StartedAt,
&record.EndedAt,
&record.EventPublicID,
&record.EventDisplayName,
&resultID,
&resultSessionID,
&resultStatus,
&resultSummaryJSON,
&finalDurationSec,
&finalScore,
&completedControls,
&totalControls,
&distanceMeters,
&averageSpeedKmh,
&maxHeartRateBpm,
)
if err != nil {
return nil, fmt.Errorf("scan session result row: %w", err)
}
if resultID != nil {
record.Result = &SessionResult{
ID: *resultID,
SessionID: derefString(resultSessionID),
ResultStatus: derefString(resultStatus),
SummaryJSON: derefString(resultSummaryJSON),
FinalDurationSec: finalDurationSec,
FinalScore: finalScore,
CompletedControls: completedControls,
TotalControls: totalControls,
DistanceMeters: distanceMeters,
AverageSpeedKmh: averageSpeedKmh,
MaxHeartRateBpm: maxHeartRateBpm,
}
}
return &record, nil
}
func derefString(value *string) string {
if value == nil {
return ""
}
return *value
}

View File

@@ -1,299 +0,0 @@
package postgres
import (
"context"
"errors"
"fmt"
"time"
"github.com/jackc/pgx/v5"
)
type Session struct {
ID string
SessionPublicID string
UserID string
EventID string
EventReleaseID string
ReleasePublicID *string
ConfigLabel *string
ManifestURL *string
ManifestChecksum *string
DeviceKey string
ClientType string
RouteCode *string
Status string
SessionTokenHash string
SessionTokenExpiresAt time.Time
LaunchedAt time.Time
StartedAt *time.Time
EndedAt *time.Time
EventPublicID *string
EventDisplayName *string
}
type FinishSessionParams struct {
SessionID string
Status string
}
func (s *Store) GetSessionByPublicID(ctx context.Context, sessionPublicID string) (*Session, error) {
row := s.pool.QueryRow(ctx, `
SELECT
gs.id,
gs.session_public_id,
gs.user_id,
gs.event_id,
gs.event_release_id,
er.release_public_id,
er.config_label,
er.manifest_url,
er.manifest_checksum_sha256,
gs.device_key,
gs.client_type,
gs.route_code,
gs.status,
gs.session_token_hash,
gs.session_token_expires_at,
gs.launched_at,
gs.started_at,
gs.ended_at,
e.event_public_id,
e.display_name
FROM game_sessions gs
JOIN events e ON e.id = gs.event_id
JOIN event_releases er ON er.id = gs.event_release_id
WHERE gs.session_public_id = $1
LIMIT 1
`, sessionPublicID)
return scanSession(row)
}
func (s *Store) GetSessionByPublicIDForUpdate(ctx context.Context, tx Tx, sessionPublicID string) (*Session, error) {
row := tx.QueryRow(ctx, `
SELECT
gs.id,
gs.session_public_id,
gs.user_id,
gs.event_id,
gs.event_release_id,
er.release_public_id,
er.config_label,
er.manifest_url,
er.manifest_checksum_sha256,
gs.device_key,
gs.client_type,
gs.route_code,
gs.status,
gs.session_token_hash,
gs.session_token_expires_at,
gs.launched_at,
gs.started_at,
gs.ended_at,
e.event_public_id,
e.display_name
FROM game_sessions gs
JOIN events e ON e.id = gs.event_id
JOIN event_releases er ON er.id = gs.event_release_id
WHERE gs.session_public_id = $1
FOR UPDATE
`, sessionPublicID)
return scanSession(row)
}
func (s *Store) ListSessionsByUserID(ctx context.Context, userID string, limit int) ([]Session, error) {
if limit <= 0 || limit > 100 {
limit = 20
}
rows, err := s.pool.Query(ctx, `
SELECT
gs.id,
gs.session_public_id,
gs.user_id,
gs.event_id,
gs.event_release_id,
er.release_public_id,
er.config_label,
er.manifest_url,
er.manifest_checksum_sha256,
gs.device_key,
gs.client_type,
gs.route_code,
gs.status,
gs.session_token_hash,
gs.session_token_expires_at,
gs.launched_at,
gs.started_at,
gs.ended_at,
e.event_public_id,
e.display_name
FROM game_sessions gs
JOIN events e ON e.id = gs.event_id
JOIN event_releases er ON er.id = gs.event_release_id
WHERE gs.user_id = $1
ORDER BY gs.created_at DESC
LIMIT $2
`, userID, limit)
if err != nil {
return nil, fmt.Errorf("list sessions by user id: %w", err)
}
defer rows.Close()
var sessions []Session
for rows.Next() {
session, err := scanSessionFromRows(rows)
if err != nil {
return nil, err
}
sessions = append(sessions, *session)
}
if err := rows.Err(); err != nil {
return nil, fmt.Errorf("iterate sessions by user id: %w", err)
}
return sessions, nil
}
func (s *Store) ListSessionsByUserAndEvent(ctx context.Context, userID, eventID string, limit int) ([]Session, error) {
if limit <= 0 || limit > 100 {
limit = 20
}
rows, err := s.pool.Query(ctx, `
SELECT
gs.id,
gs.session_public_id,
gs.user_id,
gs.event_id,
gs.event_release_id,
er.release_public_id,
er.config_label,
er.manifest_url,
er.manifest_checksum_sha256,
gs.device_key,
gs.client_type,
gs.route_code,
gs.status,
gs.session_token_hash,
gs.session_token_expires_at,
gs.launched_at,
gs.started_at,
gs.ended_at,
e.event_public_id,
e.display_name
FROM game_sessions gs
JOIN events e ON e.id = gs.event_id
JOIN event_releases er ON er.id = gs.event_release_id
WHERE gs.user_id = $1
AND gs.event_id = $2
ORDER BY gs.created_at DESC
LIMIT $3
`, userID, eventID, limit)
if err != nil {
return nil, fmt.Errorf("list sessions by user and event: %w", err)
}
defer rows.Close()
var sessions []Session
for rows.Next() {
session, err := scanSessionFromRows(rows)
if err != nil {
return nil, err
}
sessions = append(sessions, *session)
}
if err := rows.Err(); err != nil {
return nil, fmt.Errorf("iterate sessions by user and event: %w", err)
}
return sessions, nil
}
func (s *Store) StartSession(ctx context.Context, tx Tx, sessionID string) error {
_, err := tx.Exec(ctx, `
UPDATE game_sessions
SET status = CASE WHEN status = 'launched' THEN 'running' ELSE status END,
started_at = COALESCE(started_at, NOW())
WHERE id = $1
`, sessionID)
if err != nil {
return fmt.Errorf("start session: %w", err)
}
return nil
}
func (s *Store) FinishSession(ctx context.Context, tx Tx, params FinishSessionParams) error {
_, err := tx.Exec(ctx, `
UPDATE game_sessions
SET status = $2,
started_at = COALESCE(started_at, NOW()),
ended_at = COALESCE(ended_at, NOW())
WHERE id = $1
`, params.SessionID, params.Status)
if err != nil {
return fmt.Errorf("finish session: %w", err)
}
return nil
}
func scanSession(row pgx.Row) (*Session, error) {
var session Session
err := row.Scan(
&session.ID,
&session.SessionPublicID,
&session.UserID,
&session.EventID,
&session.EventReleaseID,
&session.ReleasePublicID,
&session.ConfigLabel,
&session.ManifestURL,
&session.ManifestChecksum,
&session.DeviceKey,
&session.ClientType,
&session.RouteCode,
&session.Status,
&session.SessionTokenHash,
&session.SessionTokenExpiresAt,
&session.LaunchedAt,
&session.StartedAt,
&session.EndedAt,
&session.EventPublicID,
&session.EventDisplayName,
)
if errors.Is(err, pgx.ErrNoRows) {
return nil, nil
}
if err != nil {
return nil, fmt.Errorf("scan session: %w", err)
}
return &session, nil
}
func scanSessionFromRows(rows pgx.Rows) (*Session, error) {
var session Session
err := rows.Scan(
&session.ID,
&session.SessionPublicID,
&session.UserID,
&session.EventID,
&session.EventReleaseID,
&session.ReleasePublicID,
&session.ConfigLabel,
&session.ManifestURL,
&session.ManifestChecksum,
&session.DeviceKey,
&session.ClientType,
&session.RouteCode,
&session.Status,
&session.SessionTokenHash,
&session.SessionTokenExpiresAt,
&session.LaunchedAt,
&session.StartedAt,
&session.EndedAt,
&session.EventPublicID,
&session.EventDisplayName,
)
if err != nil {
return nil, fmt.Errorf("scan session row: %w", err)
}
return &session, nil
}

View File

@@ -1,94 +0,0 @@
package postgres
import (
"context"
"errors"
"fmt"
"github.com/jackc/pgx/v5"
)
type User struct {
ID string
PublicID string
Status string
Nickname *string
AvatarURL *string
}
type CreateUserParams struct {
PublicID string
Status string
}
type queryRower interface {
QueryRow(context.Context, string, ...any) pgx.Row
}
func (s *Store) FindUserByMobile(ctx context.Context, tx Tx, countryCode, mobile string) (*User, error) {
row := tx.QueryRow(ctx, `
SELECT u.id, u.user_public_id, u.status, u.nickname, u.avatar_url
FROM users u
JOIN login_identities li ON li.user_id = u.id
WHERE li.provider = 'mobile'
AND li.country_code = $1
AND li.mobile = $2
AND li.status = 'active'
LIMIT 1
`, countryCode, mobile)
return scanUser(row)
}
func (s *Store) CreateUser(ctx context.Context, tx Tx, params CreateUserParams) (*User, error) {
row := tx.QueryRow(ctx, `
INSERT INTO users (user_public_id, status)
VALUES ($1, $2)
RETURNING id, user_public_id, status, nickname, avatar_url
`, params.PublicID, params.Status)
return scanUser(row)
}
func (s *Store) TouchUserLogin(ctx context.Context, tx Tx, userID string) error {
_, err := tx.Exec(ctx, `
UPDATE users
SET last_login_at = NOW()
WHERE id = $1
`, userID)
if err != nil {
return fmt.Errorf("touch user last login: %w", err)
}
return nil
}
func (s *Store) DeactivateUser(ctx context.Context, tx Tx, userID string) error {
_, err := tx.Exec(ctx, `
UPDATE users
SET status = 'deleted', updated_at = NOW()
WHERE id = $1
`, userID)
if err != nil {
return fmt.Errorf("deactivate user: %w", err)
}
return nil
}
func (s *Store) GetUserByID(ctx context.Context, db queryRower, userID string) (*User, error) {
row := db.QueryRow(ctx, `
SELECT id, user_public_id, status, nickname, avatar_url
FROM users
WHERE id = $1
`, userID)
return scanUser(row)
}
func scanUser(row pgx.Row) (*User, error) {
var user User
err := row.Scan(&user.ID, &user.PublicID, &user.Status, &user.Nickname, &user.AvatarURL)
if errors.Is(err, pgx.ErrNoRows) {
return nil, nil
}
if err != nil {
return nil, fmt.Errorf("scan user: %w", err)
}
return &user, nil
}

View File

@@ -1,123 +0,0 @@
BEGIN;
CREATE EXTENSION IF NOT EXISTS pgcrypto;
CREATE OR REPLACE FUNCTION set_updated_at()
RETURNS TRIGGER AS $$
BEGIN
NEW.updated_at = NOW();
RETURN NEW;
END;
$$ LANGUAGE plpgsql;
CREATE TABLE tenants (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
tenant_code TEXT NOT NULL UNIQUE,
name TEXT NOT NULL,
status TEXT NOT NULL DEFAULT 'active' CHECK (status IN ('draft', 'active', 'disabled', 'archived')),
theme_jsonb JSONB NOT NULL DEFAULT '{}'::jsonb,
settings_jsonb JSONB NOT NULL DEFAULT '{}'::jsonb,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);
CREATE TRIGGER tenants_set_updated_at
BEFORE UPDATE ON tenants
FOR EACH ROW EXECUTE FUNCTION set_updated_at();
CREATE TABLE entry_channels (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
tenant_id UUID NOT NULL REFERENCES tenants(id) ON DELETE CASCADE,
channel_code TEXT NOT NULL,
channel_type TEXT NOT NULL CHECK (channel_type IN ('app', 'wechat_mini', 'wechat_oa', 'h5', 'qr')),
platform_app_id TEXT,
display_name TEXT NOT NULL,
status TEXT NOT NULL DEFAULT 'active' CHECK (status IN ('draft', 'active', 'disabled', 'archived')),
is_default BOOLEAN NOT NULL DEFAULT FALSE,
config_jsonb JSONB NOT NULL DEFAULT '{}'::jsonb,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
UNIQUE (tenant_id, channel_code)
);
CREATE INDEX entry_channels_tenant_id_idx ON entry_channels(tenant_id);
CREATE INDEX entry_channels_platform_app_id_idx ON entry_channels(platform_app_id);
CREATE TRIGGER entry_channels_set_updated_at
BEFORE UPDATE ON entry_channels
FOR EACH ROW EXECUTE FUNCTION set_updated_at();
CREATE TABLE users (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
user_public_id TEXT NOT NULL UNIQUE,
default_tenant_id UUID REFERENCES tenants(id) ON DELETE SET NULL,
status TEXT NOT NULL DEFAULT 'active' CHECK (status IN ('active', 'disabled', 'deleted')),
nickname TEXT,
avatar_url TEXT,
last_login_at TIMESTAMPTZ,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);
CREATE INDEX users_default_tenant_id_idx ON users(default_tenant_id);
CREATE TRIGGER users_set_updated_at
BEFORE UPDATE ON users
FOR EACH ROW EXECUTE FUNCTION set_updated_at();
CREATE TABLE login_identities (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
user_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE,
identity_type TEXT NOT NULL CHECK (identity_type IN ('mobile', 'wechat_mini_openid', 'wechat_oa_openid', 'wechat_unionid')),
provider TEXT NOT NULL,
provider_subject TEXT NOT NULL,
country_code TEXT,
mobile TEXT,
status TEXT NOT NULL DEFAULT 'active' CHECK (status IN ('active', 'disabled', 'deleted')),
profile_jsonb JSONB NOT NULL DEFAULT '{}'::jsonb,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
UNIQUE (provider, provider_subject)
);
CREATE INDEX login_identities_user_id_idx ON login_identities(user_id);
CREATE INDEX login_identities_mobile_idx ON login_identities(country_code, mobile);
CREATE TRIGGER login_identities_set_updated_at
BEFORE UPDATE ON login_identities
FOR EACH ROW EXECUTE FUNCTION set_updated_at();
CREATE TABLE auth_sms_codes (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
scene TEXT NOT NULL,
country_code TEXT NOT NULL,
mobile TEXT NOT NULL,
client_type TEXT NOT NULL CHECK (client_type IN ('app', 'wechat')),
device_key TEXT NOT NULL,
code_hash TEXT NOT NULL,
provider_payload_jsonb JSONB NOT NULL DEFAULT '{}'::jsonb,
expires_at TIMESTAMPTZ NOT NULL,
cooldown_until TIMESTAMPTZ NOT NULL,
consumed_at TIMESTAMPTZ,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);
CREATE INDEX auth_sms_codes_lookup_idx
ON auth_sms_codes(country_code, mobile, client_type, scene, created_at DESC);
CREATE TABLE auth_refresh_tokens (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
user_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE,
client_type TEXT NOT NULL CHECK (client_type IN ('app', 'wechat')),
device_key TEXT,
token_hash TEXT NOT NULL UNIQUE,
issued_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
expires_at TIMESTAMPTZ NOT NULL,
revoked_at TIMESTAMPTZ,
replaced_by_token_id UUID REFERENCES auth_refresh_tokens(id) ON DELETE SET NULL
);
CREATE INDEX auth_refresh_tokens_user_id_idx ON auth_refresh_tokens(user_id);
CREATE INDEX auth_refresh_tokens_expires_at_idx ON auth_refresh_tokens(expires_at);
COMMIT;

View File

@@ -1,72 +0,0 @@
BEGIN;
CREATE TABLE events (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
tenant_id UUID REFERENCES tenants(id) ON DELETE SET NULL,
event_public_id TEXT NOT NULL UNIQUE,
slug TEXT NOT NULL UNIQUE,
display_name TEXT NOT NULL,
summary TEXT,
status TEXT NOT NULL DEFAULT 'draft' CHECK (status IN ('draft', 'active', 'disabled', 'archived')),
current_release_id UUID,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);
CREATE INDEX events_tenant_id_idx ON events(tenant_id);
CREATE INDEX events_status_idx ON events(status);
CREATE TRIGGER events_set_updated_at
BEFORE UPDATE ON events
FOR EACH ROW EXECUTE FUNCTION set_updated_at();
CREATE TABLE event_releases (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
release_public_id TEXT NOT NULL UNIQUE,
event_id UUID NOT NULL REFERENCES events(id) ON DELETE CASCADE,
release_no INTEGER NOT NULL,
config_label TEXT NOT NULL,
manifest_url TEXT NOT NULL,
manifest_checksum_sha256 TEXT,
route_code TEXT,
status TEXT NOT NULL DEFAULT 'published' CHECK (status IN ('draft', 'published', 'retired', 'failed')),
payload_jsonb JSONB NOT NULL DEFAULT '{}'::jsonb,
published_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
UNIQUE (event_id, release_no)
);
CREATE INDEX event_releases_event_id_idx ON event_releases(event_id);
CREATE INDEX event_releases_status_idx ON event_releases(status);
ALTER TABLE events
ADD CONSTRAINT events_current_release_fk
FOREIGN KEY (current_release_id) REFERENCES event_releases(id) ON DELETE SET NULL;
CREATE TABLE game_sessions (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
session_public_id TEXT NOT NULL UNIQUE,
user_id UUID NOT NULL REFERENCES users(id) ON DELETE RESTRICT,
event_id UUID NOT NULL REFERENCES events(id) ON DELETE RESTRICT,
event_release_id UUID NOT NULL REFERENCES event_releases(id) ON DELETE RESTRICT,
device_key TEXT NOT NULL,
client_type TEXT NOT NULL CHECK (client_type IN ('app', 'wechat')),
route_code TEXT,
status TEXT NOT NULL DEFAULT 'launched' CHECK (status IN ('launched', 'running', 'finished', 'failed', 'cancelled')),
session_token_hash TEXT NOT NULL UNIQUE,
session_token_expires_at TIMESTAMPTZ NOT NULL,
launched_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
started_at TIMESTAMPTZ,
ended_at TIMESTAMPTZ,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);
CREATE INDEX game_sessions_user_id_idx ON game_sessions(user_id);
CREATE INDEX game_sessions_event_id_idx ON game_sessions(event_id);
CREATE INDEX game_sessions_status_idx ON game_sessions(status);
CREATE TRIGGER game_sessions_set_updated_at
BEFORE UPDATE ON game_sessions
FOR EACH ROW EXECUTE FUNCTION set_updated_at();
COMMIT;

View File

@@ -1,32 +0,0 @@
BEGIN;
CREATE TABLE cards (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
card_public_id TEXT NOT NULL UNIQUE,
tenant_id UUID NOT NULL REFERENCES tenants(id) ON DELETE CASCADE,
entry_channel_id UUID REFERENCES entry_channels(id) ON DELETE SET NULL,
card_type TEXT NOT NULL CHECK (card_type IN ('event', 'html', 'notice')),
title TEXT NOT NULL,
subtitle TEXT,
cover_url TEXT,
event_id UUID REFERENCES events(id) ON DELETE SET NULL,
html_url TEXT,
display_slot TEXT NOT NULL DEFAULT 'home_primary',
display_priority INTEGER NOT NULL DEFAULT 0,
status TEXT NOT NULL DEFAULT 'active' CHECK (status IN ('draft', 'active', 'disabled', 'archived')),
starts_at TIMESTAMPTZ,
ends_at TIMESTAMPTZ,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);
CREATE INDEX cards_tenant_id_idx ON cards(tenant_id);
CREATE INDEX cards_entry_channel_id_idx ON cards(entry_channel_id);
CREATE INDEX cards_event_id_idx ON cards(event_id);
CREATE INDEX cards_display_idx ON cards(display_slot, status, display_priority DESC);
CREATE TRIGGER cards_set_updated_at
BEFORE UPDATE ON cards
FOR EACH ROW EXECUTE FUNCTION set_updated_at();
COMMIT;

View File

@@ -1,26 +0,0 @@
BEGIN;
CREATE TABLE session_results (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
session_id UUID NOT NULL UNIQUE REFERENCES game_sessions(id) ON DELETE CASCADE,
result_status TEXT NOT NULL CHECK (result_status IN ('finished', 'failed', 'cancelled')),
summary_jsonb JSONB NOT NULL DEFAULT '{}'::jsonb,
final_duration_sec INTEGER,
final_score INTEGER,
completed_controls INTEGER,
total_controls INTEGER,
distance_meters NUMERIC(10,2),
average_speed_kmh NUMERIC(8,3),
max_heart_rate_bpm INTEGER,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);
CREATE INDEX session_results_result_status_idx ON session_results(result_status);
CREATE INDEX session_results_created_at_idx ON session_results(created_at DESC);
CREATE TRIGGER session_results_set_updated_at
BEFORE UPDATE ON session_results
FOR EACH ROW EXECUTE FUNCTION set_updated_at();
COMMIT;

View File

@@ -1,61 +0,0 @@
BEGIN;
CREATE TABLE event_config_sources (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
event_id UUID NOT NULL REFERENCES events(id) ON DELETE CASCADE,
source_version_no INTEGER NOT NULL,
source_kind TEXT NOT NULL DEFAULT 'event_bundle' CHECK (source_kind IN ('event_bundle', 'manifest_only', 'preset')),
schema_id TEXT NOT NULL DEFAULT 'event-source',
schema_version TEXT NOT NULL DEFAULT '1',
status TEXT NOT NULL DEFAULT 'draft' CHECK (status IN ('draft', 'active', 'archived')),
source_jsonb JSONB NOT NULL DEFAULT '{}'::jsonb,
notes TEXT,
created_by_user_id UUID REFERENCES users(id) ON DELETE SET NULL,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
UNIQUE (event_id, source_version_no)
);
CREATE INDEX event_config_sources_event_id_idx ON event_config_sources(event_id);
CREATE INDEX event_config_sources_status_idx ON event_config_sources(status);
CREATE TABLE event_config_builds (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
event_id UUID NOT NULL REFERENCES events(id) ON DELETE CASCADE,
source_id UUID NOT NULL REFERENCES event_config_sources(id) ON DELETE CASCADE,
build_no INTEGER NOT NULL,
build_status TEXT NOT NULL DEFAULT 'success' CHECK (build_status IN ('pending', 'running', 'success', 'failed', 'cancelled')),
build_log TEXT,
manifest_jsonb JSONB NOT NULL DEFAULT '{}'::jsonb,
asset_index_jsonb JSONB NOT NULL DEFAULT '[]'::jsonb,
created_by_user_id UUID REFERENCES users(id) ON DELETE SET NULL,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
UNIQUE (event_id, build_no)
);
CREATE INDEX event_config_builds_event_id_idx ON event_config_builds(event_id);
CREATE INDEX event_config_builds_source_id_idx ON event_config_builds(source_id);
CREATE INDEX event_config_builds_status_idx ON event_config_builds(build_status);
ALTER TABLE event_releases
ADD COLUMN build_id UUID REFERENCES event_config_builds(id) ON DELETE SET NULL;
CREATE INDEX event_releases_build_id_idx ON event_releases(build_id);
CREATE TABLE event_release_assets (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
event_release_id UUID NOT NULL REFERENCES event_releases(id) ON DELETE CASCADE,
asset_type TEXT NOT NULL CHECK (asset_type IN ('manifest', 'mapmeta', 'tiles', 'playfield', 'content_html', 'media', 'other')),
asset_key TEXT NOT NULL,
asset_path TEXT,
asset_url TEXT NOT NULL,
checksum TEXT,
size_bytes BIGINT,
meta_jsonb JSONB NOT NULL DEFAULT '{}'::jsonb,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
UNIQUE (event_release_id, asset_key)
);
CREATE INDEX event_release_assets_release_id_idx ON event_release_assets(event_release_id);
CREATE INDEX event_release_assets_asset_type_idx ON event_release_assets(asset_type);
COMMIT;

View File

@@ -1,49 +0,0 @@
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-" }
$env:LOCAL_EVENT_DIR = if ($env:LOCAL_EVENT_DIR) { $env:LOCAL_EVENT_DIR } else { "D:\dev\cmr-mini\event" }
$env:ASSET_BASE_URL = if ($env:ASSET_BASE_URL) { $env:ASSET_BASE_URL } else { "https://oss-mbh5.colormaprun.com/gotomars" }
$env:ASSET_PUBLIC_BASE_URL = if ($env:ASSET_PUBLIC_BASE_URL) { $env:ASSET_PUBLIC_BASE_URL } else { "https://oss-mbh5.colormaprun.com" }
$env:ASSET_BUCKET_ROOT = if ($env:ASSET_BUCKET_ROOT) { $env:ASSET_BUCKET_ROOT } else { "oss://color-map-html" }
$env:OSSUTIL_PATH = if ($env:OSSUTIL_PATH) { $env:OSSUTIL_PATH } else { "D:\dev\cmr-mini\tools\ossutil.exe" }
$env:OSSUTIL_CONFIG_FILE = if ($env:OSSUTIL_CONFIG_FILE) { $env:OSSUTIL_CONFIG_FILE } else { (Join-Path $HOME ".ossutilconfig") }
if (-not (Test-Path $env:LOCAL_EVENT_DIR)) {
throw ("LOCAL_EVENT_DIR not found: " + $env:LOCAL_EVENT_DIR)
}
if (-not (Test-Path $env:OSSUTIL_PATH)) {
Write-Warning ("OSSUTIL_PATH not found: " + $env:OSSUTIL_PATH)
}
if (-not (Test-Path $env:OSSUTIL_CONFIG_FILE)) {
Write-Warning ("OSSUTIL_CONFIG_FILE not found: " + $env:OSSUTIL_CONFIG_FILE)
}
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 ("LOCAL_EVENT_DIR=" + $env:LOCAL_EVENT_DIR)
Write-Host ("ASSET_BASE_URL=" + $env:ASSET_BASE_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

View File

@@ -1,13 +0,0 @@
Set-StrictMode -Version Latest
$ErrorActionPreference = "Stop"
$backendDir = Split-Path -Parent $MyInvocation.MyCommand.Path
$scriptPath = Join-Path $backendDir "scripts\start-dev.ps1"
if (-not (Test-Path $scriptPath)) {
throw ("Backend start script not found: " + $scriptPath)
}
Set-Location $backendDir
powershell -ExecutionPolicy Bypass -File $scriptPath

View File

@@ -1,333 +0,0 @@
结果页会根据客户的要求不停的变换,用什么方案能实现这个需求,其实其他的弹出内容也都存在这个问题,样式,内容都时根据客户需求变化的,怎样一种方案设计比较好呢?
我想改造下GPS模拟器做成一个数据中转程序这样后面的开发模拟家长端监控场控数据回放就都能支持了类似于路由器只中转不保存数据你觉得可行否或者还有没有更好的方案
技术栈要轻量,健壮,性能第一,类似软路由这类应用
目前已经把卡片架构的底座搭出来了,进度可以概括成:
已经完成的
1. 原生内容卡主链已经成立
现在控制点内容不再是单一硬编码弹层,而是已经支持:
title / body
clickTitle / clickBody
autoPopup
once
priority
template
而且:
start-1 / control-N / finish-1 都可配置
起终点同位置时,点击内容优先级也已经处理了
2. 原生内容卡模板化已经落地
目前已经有 3 种原生模板:
minimal
story
focus
默认策略也定了:
起点/终点focus
普通点story
3. 内容展示机制已经不是“弹了就完”
现在已经有:
自动弹
手动查看入口
队列
优先级覆盖
once 本局只展示一次
也就是说,已经开始像正式的“内容体验层”了。
4. H5 分工已经定案并接进去了
这条边界已经试清楚了:
即时内容弹窗:原生
H5详情页 / 互动任务页 / 结果页
H5 不再直接顶替原生弹窗
当前实际链路是:
原生内容卡 -> 查看详情 CTA -> H5 详情页
5. H5 容器页已经能用了
企业主体切过来后:
web-view 已经能打开
最小测试链路已经通了
但也验证了一个结论:
小程序 web-view 不适合做局部弹窗内容区
适合整页详情/任务/结果页
现在还没完全做完的
1. 原生内容卡还不够“组件化”
现在模板化已经有了,但还没完全形成更正式的:
卡片 ViewModel
卡片模板注册表
模板渲染规范
现在还是“已可用,但还不够体系化”。
2. CTA 还比较初级
现在 查看详情 能用了,但还缺:
CTA 文案配置化
CTA 图标 / 样式分级
多动作支持
查看详情
开始互动
拍照任务
语音留言
3. 结果页卡片体系还没完全接完
结果页最小闭环做过,但还没真正进入:
原生模板化
H5 增强版
数据模型与展示模板分层
4. 原生有限 DSL 还没开始正式做
我们已经把方向定了:
原生模板
原生有限 DSL
H5 扩展
但目前真正落代码的主要还是原生模板层DSL 还没开始。
所以当前阶段判断
如果按“卡片架构”来讲,我会定义成:
已经完成了第一阶段 60%~70%
主链和边界已经对了
配置和模板能力已经起了
还差把它做成真正可长期扩展的“卡片系统”
这周最该做什么
我建议这周把这 3 件事做完,卡片架构就算基本立住了:
1. 把原生内容卡正式收成组件化结构
目标:
ContentCardModel
模板映射
渲染入口统一
2. 把 CTA 体系做出来
目标:
CTA 类型
CTA 文案
CTA 动作模型
原生 CTA -> H5 详情/任务
3. 把结果页也纳入同一套卡片/模板思路
目标:
原生最小结果页模板化
和内容卡共用部分卡片能力
为 H5 结果页增强留接口
一句话结论
现在卡片架构已经从“零散弹层”进化到“有配置、有模板、有 H5 分工的内容系统雏形”了。
这周如果继续往下做,最值得的是把:
原生内容卡组件化
CTA 动作体系
结果页模板化
这三件事补齐。
如果你愿意,我下一步可以直接给你列一版:
本周卡片架构实施清单,按文件和优先级拆开。
好的,列个清单看看
可以,按这周目标,我建议清单拆成 3 层。
P0 本周必须完成
原生内容卡模型收口
目标:
把当前散在地图页状态里的内容卡字段收成统一 ContentCardModel
明确字段:
template
title
body
motionClass
priority
once
cta
建议落点:
D:\dev\cmr-mini\miniprogram\game\experience
D:\dev\cmr-mini\miniprogram\engine\map\mapEngine.ts
CTA 动作体系
目标:
不再只有一个硬编码 查看详情
统一成动作模型,例如:
open-detail
open-task
close
CTA 支持:
label
type
target
建议先只做一主一辅两个按钮位。
原生内容卡模板注册表
目标:
把 minimal / story / focus 从“字符串分支”变成正式模板映射
先明确每个模板:
标题样式
正文字数策略
CTA 区布局
动画等级差异
结果页最小模板化
目标:
把当前结果页按内容卡思路收成一个稳定模板
至少区分:
result-minimal
result-rich 预留
P1 本周最好完成
配置字典补齐卡片字段
目标:
文档里明确:
template
contentExperience
clickExperience
cta
默认逻辑
样例配置同步更新
文件:
D:\dev\cmr-mini\doc\config-option-dictionary.md
D:\dev\cmr-mini\event\classic-sequential.json
D:\dev\cmr-mini\event\score-o.json
内容卡与结果页的 ViewModel 分层
目标:
内容卡和结果页都先走 ViewModel
页面不直接拼业务字段
建议模型:
ContentCardModel
ResultCardModel
动画字典补卡片节点
目标:
把以下节点补进动画字典:
内容卡进入
内容卡关闭
CTA 点击
结果页进入
结果主数字进入
P2 可以放下周
原生有限 DSL 第一版
目标:
不直接做万能布局
先支持有限区块组合
H5 详情/任务页 CTA 扩展
目标:
不只 查看详情
后面支持:
拍照打卡
语音留言
开始互动
结果页 H5 增强版入口
目标:
原生结果页保底
H5 作为增强页入口
推荐执行顺序
ContentCardModel
CTA 动作体系
模板注册表
结果页模板化
文档和样例同步
动画字典补节点
一句话
这周先把“内容卡是一个系统”这件事做实,再把结果页纳入同一思路。
如果你要,我下一步可以直接按这个清单开始做 P0-1先收 ContentCardModel + CTA 动作体系。
CTA是啥来
CTA 就是:
Call To Action
中文可以理解成:
行动按钮
引导操作
下一步动作
在你这个项目里,最直观的例子就是内容卡上的:
查看详情
关闭
以后还可以是:
开始互动
拍照打卡
语音留言
查看结果
继续前进
也就是说CTA 不是内容本身,而是用户看到卡片后,可以立刻执行的动作入口。
在你现在这个项目里
例如这张卡:
标题:比赛开始
正文:从这里出发,先熟悉地图方向...
CTA
查看详情
关闭
所以“CTA 动作体系”说白了就是:
把这些按钮从临时写死,收成统一的动作模型。
比如以后配置里可以变成:
{
"cta": {
"type": "open-detail",
"label": "查看详情"
}
}
一句话:
CTA 就是卡片上引导用户下一步操作的按钮。
1. 先帮我指定一份游戏的最小配置模板,也就是说,去掉所有选配项,最小的游戏可跑配置,每个选项要带说明
2. 再给我一份当前开发状态下最全的配置模板,每个选项,参数要带说明
3. 将这2个文档落成文件
先把原生内容卡收口,这是系统默认配置,所有配置不起作用得时候,他们兜底,
在原生卡片上CTA可以有拍照语音开启H5深度内容等能力。
其中有一个自动出题目的能力就是2-3位数得加减运算并提供3个备选项让客户选择同时有个倒计时倒计时结束或选择答案后弹出对错提示。答对题目有积分答错或没答没有积分正确打点后也收割改点积分顺序赛默认是1积分积分赛根据实际点位积分来。
先实现以上功能
接着实现几个功能,细节的问题稍后说
打卡点的样式我需要几套样式,现在是单一标准空心圆圈,太枯燥。
我有几个想法:
1. 顺序赛,可以定制打卡点样式,可以定制路线腿样式
2. 积分赛,可以定制打卡点样式,不同积分可以不同颜色。
基于上面的想法,你有好的实施方案吗?先讨论
好的,测试可以,接着讨论下一个问题
轨迹我的想法是用户轨迹有三种形式无轨迹全轨迹拖尾轨迹如果不走轨迹最终消失就是轨迹指着GPS点跑你有什么方案先讨论
轨迹选项:无,彗尾,全轨迹
轨迹样式:
尾巴:短,中,长
颜色可以放8-16种基本色亮色
再说GPS点用户位置的GPS点样式也是定制的先说默认样式可以定制显示与不显示。现在的样式有点呆和粗糙我想给GPS点上加一个方向指示的小三角跟着朝向转你能理解吗另外GPS点也有3种大小用户自己可设置默认中等大小即可颜色也可设置。最重要的是根据我们的经验很多客户希望可以定制这个定位点具有商业属性例如换成商家的LOGO这个有方案吗先讨论。
再深一点自定GPS点能不能做成动画的停止一个动画跑起来又是一个动画甚至可以做些额外的动作。
开个小差我想临时加个功能在咱的GPS模拟器加个日志输出功能把调试期间不方便打在调试面板里的信息输出到模拟器上你觉得如何这样更方便后期调试如果可以先给个方案

View File

@@ -1,358 +0,0 @@
# 动画字典 v1
## 1. 目的
这份文档用于统一说明:
- 当前系统里**已经存在**的动画触发点
- 哪些事件**适合继续补动画**
- 每个动画应该归属到哪一层
- `lite` 模式下哪些动画应该保留,哪些应该降级
一句话:
**动画字典 = 事件到动画效果的映射表。**
它的价值是:
- 防止动画实现越来越散
- 方便做性能分级
- 方便新玩法复用已有动画语言
- 方便以后做动画 profile / theme
---
## 2. 分层约定
当前动画建议分成 4 层:
### 2.1 地图空间动画
作用在地图对象本身。
例如:
- 当前目标点 pulse
- 可打点外环
- 已完成点 settle
- 已跳过点灰化
- 迷雾 reveal
- 金币收集爆点
归属:
- `MapPresentation`
- `MapScene`
- `WebGL renderer`
### 2.2 HUD 动画
作用在底部 HUD、数字、状态块。
例如:
- 进度跃迁
- 点距变化
- 速度/里程/心率数字轻量过渡
归属:
- 页面层 `WXML / WXSS`
- `MapEngine` 只负责下发状态类名
### 2.3 UI / Feedback 动画
作用在全局提示、局部闪光、反馈层。
例如:
- 顶部提示条出现
- 局部 stage flash
- 成功提示
- 错误/警示提示
归属:
- `feedbackConfig`
- `UiEffectDirector`
- `FeedbackDirector`
- 页面层消费
### 2.4 微交互动画
作用在按钮、弹层、设置面板等。
当前优先级最低。
---
## 3. 已实现动画字典
下面这一部分是当前已经落到代码里的内容。
| 事件/状态 | 地图空间动画 | HUD 动画 | UI/Feedback 动画 | lite 保留 |
|---|---|---|---|---|
| `control_ready` | 当前目标点更亮、ready 强调 | 打点相关状态切换 | 无 | 是 |
| `control_completed:start` | 开始点完成 settle 外环 | 进度/点距轻反馈 | 轻量成功反馈 | 是 |
| `control_completed:control` | 普通检查点完成 settle 外环 | 进度跃迁、点距动效 | 轻量地图 flash / pulse | 是 |
| `control_completed:finish` | 终点完成 settle 外环更明显 | 进度完成反馈 | finish 级反馈更强 | 是 |
| `control_skipped` | 当前点灰化、斜杠标记 | 当前目标推进 | 轻提示(较轻) | 是 |
| `target_changed` | 当前目标高亮切换 | 目标相关 HUD 状态切换 | 无 | 是 |
| `heart_rate_updated` | 无 | 心率数字过渡 | 无 | 是 |
| `timer_tick` | 无 | 计时器数字轻量过渡 | 无 | `lite` 可降级 |
| `mileage_updated` | 无 | 里程数字过渡 | 无 | `lite` 可降级 |
| `speed_updated` | 无 | 速度数字过渡 | 无 | `lite` 可降级 |
| `panel_progress_changed` | 无 | 进度数字 / 状态切换 | 无 | 是 |
| `map_pulse` | 地图局部 pulse | 无 | 可伴随轻 stage flash | `lite` 简化 |
| `stage_flash` | 无 | 无 | 局部屏幕闪光 | `lite` 可关闭 |
---
## 4. 推荐新增动画字典
下面这些是**非常适合继续补齐**的事件。
### 4.1 高优先级
#### `session_started`
建议效果:
- 地图:起点轻脉冲或开场吸附感
- HUD开始态切换
- UI顶部提示条轻弹入
原因:
- 这是对局开始的第一感受
- 很适合统一“进入比赛状态”的语言
#### `target_transition`
指:
- 一个点完成后
- 下一个目标点接管当前目标状态
建议效果:
- 地图:旧点 settle新点接管 pulse
- HUD目标进度轻切换
- UI可以不额外做
原因:
- 当前这条链已经有基础,但还不够“连续”
- 这是顺序赛最值得打磨的高频体验
#### `control_skipped`
建议效果:
- 地图:当前点灰化过渡更明显
- HUD提示“已跳过 x 号点”
- UI轻量 skip toast / 轻闪光
原因:
- 逻辑已经有
- 状态已经有
- 动画语言还不够完整
---
### 4.2 中优先级
#### `gps_lock_changed:on`
建议效果:
- 地图:回中吸附感
- HUD
- UI轻提示“已锁定定位点”
#### `gps_lock_changed:off`
建议效果:
- 地图:无
- HUD
- UI轻提示“已解除锁定”
#### `center_scale_ruler_changed`
建议效果:
- UI比例尺淡入/淡出
原因:
- 比例尺是工具型功能
- 不需要强动画,但可以更自然
#### `north_reference_changed`
建议效果:
- UI小提示
- 指北针顶部文案轻切换
---
### 4.3 下一阶段重点
#### `heart_rate_zone_changed`
建议效果:
- HUD颜色平滑切换
- UI边缘呼吸 / 警示
- 地图:一般不建议直接做
原因:
- 未来心率玩法、危险状态会很依赖这条
#### `danger_state_changed`
适用于未来:
- 幽灵追逐
- 危险区
- 高压状态
建议效果:
- UI边缘呼吸
- HUD局部强调
- 地图:危险区波纹
#### `zone_entered / zone_cleared`
适用于未来:
- 区域玩法
- 迷雾玩法
- 领地玩法
建议效果:
- 地图:区域高亮 / reveal
- HUD区域状态变化
- UI轻量提示
---
## 5. 不建议过度动画化的节点
下面这些不建议做太重:
- 每次 GPS 更新
- 每次地图缩放
- 每次地图拖动
- 每次 compass heading 变化
- 每次 telemetry 微小变化
原因:
- 高频
- 成本高
- 极易拖垮低端机
原则:
**高频输入只做平滑,不做明显动画。**
---
## 6. 动画等级建议
当前已有两级:
- `standard`
- `lite`
建议动画字典里每项都标清楚在 `lite` 下如何处理。
### `standard`
- 完整 pulse
- 完整 settle
- HUD 数字过渡正常
- 局部 flash 保留
### `lite`
- pulse 层数减少
- settle 更轻
- HUD 数字动画缩短或关闭
- flash 关闭或极轻
---
## 7. 下一步推荐落地顺序
建议按这个顺序继续推进:
1. `session_started`
2. `target_transition`
3. `control_skipped`
4. `gps_lock_changed`
5. `heart_rate_zone_changed`
6. `danger_state_changed`
---
## 8. 执行原则
后面继续做动画时,遵循这几条:
### 8.1 先看事件,再决定动画
不要先想“做个好看的效果”,而是先问:
- 哪个事件需要被用户感知?
- 这个事件最适合地图 / HUD / UI 哪一层?
### 8.2 只强化高价值反馈
优先强化:
- 开始
- 打点成功
- 目标切换
- 跳点
- 危险状态
### 8.3 低端机优先
任何新增动画都要先问:
- `lite` 下要不要保留?
- 不保留是否影响信息传达?
### 8.4 动画不替代状态
动画只做状态的表现,不能替代状态本身。
也就是:
- 先有状态
- 再有动画
---
## 9. 阶段结论
当前动画系统已经具备:
- 主链
- 分层
- 高价值基础动画
- 性能等级
下一步的重点不是继续零散加动效,而是:
**按动画字典逐项补齐高频体验链。**

View File

@@ -1,364 +0,0 @@
# 动画接入工作流
## 1. 目的
这份文档用于说明:
- 设计公司提供的动画稿,应该如何转成程序可执行的内容
- 我方应该如何组织这类工作
- 动画接入前、中、后的协作方式
一句话:
**不要把动画稿直接塞进程序,要先把它转成“工程规格”。**
---
## 2. 核心原则
### 2.1 交付的是规格,不只是效果
设计公司不能只给:
- 视频
- GIF
- 一张效果图
至少还需要给:
- 动画名称
- 触发时机
- 作用对象
- 起始状态
- 结束状态
- 时长
- 延迟
- 缓动
- 是否循环
- 是否可中断
- 资源形式
- 降级方案
### 2.2 先分类,再决定技术实现
不是所有动画都适合用同一种实现方式。
必须先判断它属于:
- 地图空间动画
- HUD 动画
- UI / Feedback 动画
- 过场 / Cutscene 动画
### 2.3 先看信息价值,再看视觉效果
优先实现:
- 帮助用户理解状态的动画
- 引导视线的动画
- 强化反馈的动画
延后实现:
- 纯装饰性动画
- 对玩法理解没有帮助的长演出
### 2.4 低端机优先
任何动画都要先问:
- `lite` 模式下怎么降级?
- 不做时是否影响信息表达?
---
## 3. 推荐流程
建议每个动画都按下面这条流程走:
### 第一步:设计交付
设计提供:
- 动画视觉稿
- 动画规格参数
- 资源文件
- 适配说明
### 第二步:整理进动画字典
先把动画纳入事件字典,而不是直接实现。
例如:
- 什么事件触发它
- 作用在哪一层
- `standard / lite` 如何处理
### 第三步:技术归类
判断该动画由哪一层承接:
- `MapPresentation / Renderer`
- 页面 `WXML / WXSS`
- `UiEffectDirector`
- 单独的 `Cutscene`
### 第四步:选择实现方式
例如:
- 纯程序动画
- 序列帧 / sprite
- Lottie
- 视频
### 第五步:做性能分级
明确:
- `standard` 怎么做
- `lite` 怎么做
- 是否允许完全关闭
### 第六步:联调与验收
至少验证:
- 触发正确
- 状态切换正确
- 多次连续触发是否正常
- 可中断行为是否正确
- 低端机是否流畅
---
## 4. 动画实现方式建议
### 4.1 纯程序动画
适合:
- 地图 pulse
- 数字过渡
- HUD 状态变化
- 按钮反馈
优点:
- 最轻
- 最稳定
- 最适合小程序
### 4.2 Lottie / 骨骼类动画
适合:
- 局部高质量 UI 动效
- 成功反馈
- 勋章 / 特殊提示
风险:
- 小程序里复杂 Lottie 可能卡
### 4.3 序列帧 / Sprite
适合:
- 爆点
- 金币收集
- 局部地图特效
风险:
- 占内存
- 占包体
### 4.4 视频 / 过场动画
适合:
- 章节开场
- 比赛结果页
- 特殊演出
不适合:
- 地图过程中的高频反馈
---
## 5. 当前项目里的推荐落点
### 地图空间动画
放在:
- `MapPresentation`
- `MapScene`
- `WebGL Renderer`
### HUD 动画
放在:
- 页面层 `WXML / WXSS`
### Feedback 动画
放在:
- `feedbackConfig`
- `UiEffectDirector`
- `FeedbackDirector`
### 过场动画
建议未来独立一层:
- `Transition`
- `Cutscene`
---
## 6. 当前建议的协作方式
以后和设计公司协作时,建议这样分工:
### 设计公司负责
- 视觉稿
- 动画节奏
- 参数规格
- 资源文件
- 降级建议
### 我方负责
- 事件归类
- 技术选型
- 动画字典维护
- 分层接入
- 性能分级
---
## 7. 推荐的工作顺序
建议以后优先接这类动画:
1. 打点成功
2. 目标切换
3. 跳点
4. 高压 / 危险反馈
5. 章节开场 / 过场动画
---
## 8. 结论
动画接入的正确方式不是:
**拿到设计稿 -> 直接写代码**
而是:
**拿到设计稿 -> 转成动画规格 -> 纳入动画字典 -> 选技术实现 -> 做性能分级 -> 再接程序**
---
## 9. 规格模板
后续和设计公司、外包或内部设计协作时,建议每个动画至少补齐下面这些字段:
- 动画 ID
- 动画名称
- 所属页面/模块
- 所属层级:地图空间 / HUD / UI反馈 / 过场
- 触发事件
- 触发条件
- 是否高频
- 作用对象
- 起始状态 / 结束状态
- 动画形式:程序动画 / Lottie / 序列帧 / 视频
- 资源文件
- 时长 / 延迟 / 缓动
- 是否循环
- 是否可中断
- 重复触发策略:覆盖 / 忽略 / 重启 / 排队
- `standard` 表现
- `lite` 表现
- 是否允许关闭
- 验收标准
推荐最小记录格式:
```md
动画 ID
动画名称:
所属页面/模块:
所属层级:
触发事件:
触发条件:
动画形式:
时长:
缓动:
是否循环:
是否可中断:
重复触发策略:
standard 表现:
lite 表现:
验收标准:
```
---
## 10. 接入评审清单
在接设计稿或准备开发前,至少先过一遍这份清单:
### 设计侧
- 动画目标是否明确
- 触发条件是否明确
- 是否有完整规格和资源
- 是否提供 `lite` 降级方案
### 技术侧
- 动画属于哪一层
- 最合适的实现方式是什么
- 是否真的需要资源文件,还是纯程序动画就够
### 性能侧
- 是否高频触发
- `lite` 下是保留、简化还是关闭
- 是否会带来高频 `setData`、持续循环动画或额外桥接成本
### 交互侧
- 是否可中断
- 多次连续触发会怎样
- 是否会和现有地图 pulse、HUD 动效、stage 效果冲突
### 验收侧
- 触发时机是否正确
- 结束时机是否正确
- 连续触发是否稳定
- `standard / lite` 是否都可用
- 低端机是否可接受
---
## 11. 归档说明
原来的:
- `animation-integration-spec.md`
- `animation-review-checklist.md`
已经归档到 [archive/animation](/D:/dev/cmr-mini/doc/archive/animation),当前以本文件为统一入口。

View File

@@ -1,193 +0,0 @@
# 动画体系阶段性小结
## 1. 当前定位
目前动画体系已经从“页面里临时加 class”的阶段进入了**有主链、有分层、有性能分级**的阶段。
当前主链可以概括为:
- 事件触发
- `feedbackConfig`
- `UiEffectDirector`
- `FeedbackDirector`
- `MapEngine`
- 页面层 / 渲染层消费
也就是:
**事件 -> 效果配置 -> 宿主提交 -> 页面 / Renderer 落地**
这说明动画已经不再是零散实现,而开始进入架构化管理。
---
## 2. 已经完成的内容
### 2.1 HUD 动效
已经完成:
- 打点成功后的 `进度` 动效
- 打点成功后的 `点距` 动效
- HUD 数字轻量过渡:
- 计时
- 里程
- 速度
- 心率
这些动效已经接入正式链路,不是页面单独临时处理。
### 2.2 地图空间动画
已经完成:
- 当前目标点状态强调
- 可打点状态强调
- 已完成点状态过渡
- 已跳过点灰态与标记
- 开始点 / 终点完成后的 settle 外环
- 轻量地图 pulse
### 2.3 局部 UI / Stage 动效
已经完成:
- 轻量 stage flash
- 顶部提示和局部反馈的基础动画承载链
### 2.4 动画性能分级
已经完成 2 级动画分级:
- `standard`
- `lite`
当前 `lite` 的主要策略包括:
- 减少 pulse 层数
- 降低几何分段
- 降低渲染动画频率
- 关闭部分 HUD 动画
- 关闭或减弱某些 stage/UI 动效
这意味着动画体系已经开始考虑**低端机表现**,不是只追求效果。
---
## 3. 当前架构上的价值
动画体系现在已经带来了几个明确收益:
- 动效不再散落在多个页面细节里
- 高频状态变化有了统一反馈语言
- 地图状态和 HUD 状态开始形成一致体验
- 性能分级已经进入体系,可服务低端机
从架构角度看,这意味着:
**动画已经成为正式能力层,而不是临时视觉补丁。**
---
## 4. 当前还不够完整的地方
虽然主链已经成型,但当前还没有完全形成“动画字典”和完整 profile 体系。
目前仍然存在这些不足:
- 哪些事件触发哪些动画,还没有整理成统一字典
- 部分高频状态切换还不够连续
- `跳点` 已有逻辑和状态,但动画语言还不完整
- 危险/高压状态动画还没有正式开始
- 动画 profile 还没有真正配置化
所以当前阶段可以定义为:
**第一阶段后半段:主链已成型,但还需要把高频体验打磨完整。**
---
## 5. 下一阶段最值得做的事情
### 5.1 先整理动画字典
建议先把动画按事件梳理出来,例如:
- `session_started`
- `control_ready`
- `control_completed:start`
- `control_completed:control`
- `control_completed:finish`
- `control_skipped`
- `gps_lock_changed`
- `guidance_state_changed`
- `heart_rate_zone_changed`
并明确每个事件对应:
- 地图动画
- HUD 动画
- UI 动画
- `lite` 下是否保留
这一步是当前最值得优先完成的工作。
### 5.2 补完整“目标状态切换连续感”
继续打磨:
- 当前目标
- 进入可打点
- 打点成功
- 切到下一个目标
让这一整段切换更连贯、更有节奏。
### 5.3 补齐“跳点”动画
建议下一步把跳点也正式纳入动画体系:
- 跳点确认后
- 当前点灰化
- 下一个目标接管强调
- HUD 给出轻量反馈
### 5.4 再做危险 / 高压反馈
这部分适合进入下一阶段:
- 高心率反馈
- 危险区反馈
- 幽灵追逐反馈
- 边缘呼吸 / 紧张感动效
这条线很适合后续玩法扩展。
---
## 6. 建议的实施顺序
推荐继续推进的顺序:
1. 动画字典整理
2. 目标切换连续感补齐
3. 跳点动画补齐
4. 危险 / 高压状态动画
5. 更进一步的配置化 profile
---
## 7. 结论
当前动画体系已经是一个明确的阶段性成果:
- 有主链
- 有分层
- 有高频核心动画
- 有性能分级
接下来最该做的不是“继续零散加动画”,而是:
**把现有能力收成动画字典,并优先打磨目标切换与跳点这两条高频体验链。**

View File

@@ -1,164 +0,0 @@
# 动画接入规格模板
## 1. 用途
这份模板用于:
- 设计公司交付动画时填写
- 开发接入前确认规格
- 作为动画实现与验收依据
建议:一个动画一条记录。
---
## 2. 基础信息模板
```md
动画 ID
动画名称:
所属页面/模块:
所属层级:地图空间 / HUD / UI反馈 / 过场
优先级:高 / 中 / 低
触发事件:
触发条件:
是否高频:
作用对象:
起始状态:
结束状态:
动画形式:程序动画 / Lottie / 序列帧 / 视频 / 其他
资源文件:
时长:
延迟:
缓动:
是否循环:
是否可中断:
重复触发策略:覆盖 / 忽略 / 重启 / 排队
standard 表现:
lite 表现:
是否允许关闭:
设计说明:
开发备注:
验收标准:
```
---
## 3. 字段说明
### 动画 ID
要求:
- 全局唯一
- 使用英文或稳定标识
例如:
- `control_complete_flash`
- `target_ready_pulse`
- `session_intro_banner`
### 所属层级
建议四选一:
- 地图空间
- HUD
- UI反馈
- 过场
### 触发事件
必须明确写出触发它的事件。
例如:
- `control_completed:control`
- `control_skipped`
- `session_started`
- `heart_rate_zone_changed:red`
### 重复触发策略
必须提前约定:
- `覆盖`
- `忽略`
- `重启`
- `排队`
否则高频触发时容易行为不一致。
### standard / lite
每个动画都必须给出两档建议。
例如:
- `standard`:完整 pulse + 外环
- `lite`:保留单层 pulse去掉外环
---
## 4. 推荐填写示例
```md
动画 IDcontrol_complete_flash
动画名称:打点成功轻闪光
所属页面/模块:地图页
所属层级UI反馈
优先级:高
触发事件control_completed:control
触发条件:普通检查点打点成功
是否高频:是
作用对象:地图主舞台
起始状态:正常地图状态
结束状态:恢复正常地图状态
动画形式:程序动画
资源文件:无
时长320ms
延迟0ms
缓动ease-out
是否循环:否
是否可中断:是
重复触发策略:重启
standard 表现:淡白色局部 flash透明度较明显
lite 表现:透明度降低 50%,时长缩短到 220ms
是否允许关闭:是
设计说明:突出“完成打点”的即时成功感
开发备注:通过 UiEffectDirector 下发 stageFx
验收标准:普通打点时稳定触发,连续打点不拖尾
```
---
## 5. 对设计公司的要求
建议以后明确告诉设计公司:
- 不只要演示稿
- 必须提供参数规格
- 必须提供资源清单
- 必须说明低配降级方案
---
## 6. 结论
这份模板的目的是把动画从“视觉稿”变成“工程规格”。
只有规格明确,程序才能稳定接入。

View File

@@ -1,163 +0,0 @@
# 动画接入评审清单
## 1. 用途
这份清单用于在接设计稿或准备开发前,快速判断:
- 这个动画能不能接
- 应该接到哪一层
- 有没有性能风险
- 有没有交付缺口
---
## 2. 设计稿评审
### 2.1 动画目标是否明确
- 这个动画是为了表达什么?
- 它是状态反馈,还是纯装饰?
- 用户不看它,会不会影响理解?
### 2.2 触发条件是否明确
- 由哪个事件触发?
- 是否会高频触发?
- 是否允许重复触发?
### 2.3 交付是否完整
- 是否有参数规格?
- 是否有资源文件?
- 是否有尺寸 / 比例说明?
- 是否有 `lite` 降级说明?
---
## 3. 技术评审
### 3.1 该动画属于哪一层
- 地图空间
- HUD
- UI反馈
- 过场
### 3.2 最合适的实现方式是什么
- 程序动画
- Lottie
- 序列帧
- 视频
### 3.3 是否真的需要资源文件
很多动画其实可以纯程序实现,不需要额外资源。
如果只是:
- pulse
- 渐隐
- 平移
- 数字过渡
优先用程序动画。
---
## 4. 性能评审
### 4.1 是否高频
如果是高频事件,不适合做重动画:
- GPS 更新
- compass heading 更新
- 拖动 / 缩放
- telemetry 微小变化
### 4.2 lite 模式怎么处理
必须明确:
- 保留
- 简化
- 关闭
### 4.3 是否会增加页面层负担
要判断:
- 会不会引入高频 `setData`
- 会不会创建大数组
- 会不会增加持续循环动画
- 会不会增加桥接成本
---
## 5. 交互评审
### 5.1 是否可中断
- 用户切页面怎么办?
- 状态瞬间变化怎么办?
- 连续触发怎么办?
### 5.2 是否会和现有动画冲突
- 同一事件是否已有动画?
- 是否会重复表达同一个信息?
- 是否和现有地图 pulse / HUD 动效叠加过重?
---
## 6. 当前项目特别注意项
### 6.1 地图过程中的动画必须克制
因为当前项目:
- 地图是主舞台
- 低端机性能敏感
- 页面桥接成本高
所以:
- 地图上的高频动画必须轻量
- 尽量减少页面层大范围动画
### 6.2 优先动画化高价值节点
优先做:
- 打点成功
- 目标切换
- 跳点
- 危险 / 高压反馈
延后做:
- 纯装饰性微动效
- 复杂长演出
---
## 7. 验收清单
动画接入完成后,至少确认:
- 触发时机正确
- 结束时机正确
- 多次连续触发稳定
- `standard / lite` 都可用
- 低端机可接受
- 不破坏现有状态链
---
## 8. 结论
动画接入前,只要这份清单里有明显回答不出来的问题,就不应该直接开做。
先补规格,再接程序。

View File

@@ -1,451 +0,0 @@
# 动效系统设计方案
本文档用于整理当前项目后续的动画 / 动效建设方案,目标不是单纯“让界面更花”,而是把动画正式纳入现有架构,成为:
- 状态感知工具
- 空间注意力引导工具
- 操作反馈工具
- 节奏增强工具
当前系统已经具备:
- 地图引擎
- 规则引擎
- telemetry
- presentation
- feedback
因此动画系统最合理的做法,不是零散补丁,而是按层管理、按事件驱动、按配置扩展。
---
## 1. 设计原则
后续动画建设建议遵循以下原则:
### 1.1 动画服务于玩法,不只是装饰
动画优先回答这些问题:
- 现在发生了什么
- 用户该看哪里
- 这次操作是否成功
- 当前节奏是在紧张、平稳还是危险
### 1.2 动画要分层
不要把所有动画都堆在页面层的 class 切换里。
后续应按:
- 地图空间动画
- HUD 动画
- 反馈动画
- 页面微交互动画
分层管理。
### 1.3 动画要和事件绑定
动画应该由事件或状态变化触发,而不是页面自己猜。
例如:
- `control_completed`
- `control_skipped`
- `guidance_state_changed`
- `session_started`
- `session_finished`
- `heart_rate_zone_changed`
- `gps_lock_changed`
### 1.4 动画要支持降级
低端机和正式版都需要降级策略。
后续建议统一支持:
- `animationsEnabled`
- `animationLevel = low / medium / high`
---
## 2. 动画分层方案
## 2.1 地图空间动画
这一层最重要,也最贴玩法。
适合放在:
- `MapPresentation`
- `MapScene`
- `WebGL renderer`
典型内容:
- 当前目标点脉冲
- 可打点状态强化
- 已完成点过渡
- 已跳过点灰态过渡
- 地图 pulse
- 危险区呼吸
- 迷雾 reveal 扩散
- 金币收集爆点
- 幽灵感知圈变化
### 这一层的特点
- 与地图空间对象绑定
- 最不适合用 WXML 硬拼
- 应由渲染层持续驱动
---
## 2.2 HUD 动画
这一层用于数值和状态提示,不直接改地图对象。
适合放在:
- 页面层
- HUD 组件层
典型内容:
- 目标距离数字滑变
- 进度数字跳变
- 心率区间颜色过渡
- 计时器关键时刻闪烁
- 按钮状态点亮 / 失活过渡
- 玩法专属状态块的显隐和强调
### 这一层的特点
- 更适合 CSS / WXSS animation
- 应避免过重
- 高优先级字段可以做轻动画,避免全屏大动作
---
## 2.3 反馈动画
这一层最适合和声音、震动一起看,属于事件消费型动画。
适合放在:
- `FeedbackDirector`
- `UIEffectDirector`
典型内容:
- 打点成功 toast
- 警告 shake
- 成功 burst
- stage flash
- 局部 pulse
- 失败 / 结束反馈
### 当前已有雏形
目前系统已经有一些反馈类动效基础:
- `punchFeedbackFxClass`
- `mapPulse`
- `stageFx`
这条线后续最值得继续系统化。
---
## 2.4 页面微交互动画
这一层优先级最低。
典型内容:
- 按钮轻微过渡
- 面板弹入弹出
- 信息卡展开收起
- 调试面板展开收起
### 原则
- 可以做,但不要先重投入
- 不要让它抢过地图和玩法本身的注意力
---
## 3. 当前最值得优先打磨的动画
如果要开始投入动画,我建议先做这 4 组。
## 3.1 打点成功动画体系
这是当前项目最值得优先打磨的一组。
建议包含:
- 控制点本体状态变化
- 地图局部 pulse
- HUD 进度跳变
- 成功提示 toast
- 声音与震动协同
### 为什么优先
- 高频
- 用户感知强
- 直接决定“打点有没有爽感”
---
## 3.2 目标点状态动画体系
建议把目标点的几种状态做清晰区分:
- 未完成
- 当前目标
- 可打点
- 已完成
- 已跳过
每个状态至少应在:
- 颜色
- 脉冲
- 强弱
上有明显区别。
### 为什么优先
- 这是地图玩法的核心视觉语言
- 对理解规则和空间注意力引导都很关键
---
## 3.3 锁定 / 自动转图状态动画
建议补强以下体验:
- 开启 GPS 锁定时的吸附反馈
- 锁定关闭时的提示
- 自动转图切换时的更自然缓动
- 特殊状态下的方向感提示
### 为什么优先
- 当前地图交互已经很强
- 这块稍微打磨就很有“专业感”
---
## 3.4 危险 / 高压状态动画
这条非常适合未来玩法扩展,尤其是:
- 幽灵追逐赛
- 心率驱动玩法
- 高压任务模式
建议后续支持:
- 边缘呼吸
- 危险圈脉冲
- 压力提示颜色递进
- 节奏增强
---
## 4. 事件驱动建议
动画最好不要由页面层直接“看到状态变了就自己猜”,而应由事件或 presentation 状态明确驱动。
建议优先整理以下动画事件:
- `session_started`
- `session_finished`
- `session_cancelled`
- `control_completed:start`
- `control_completed:control`
- `control_completed:finish`
- `control_skipped`
- `guidance_state_changed`
- `gps_lock_changed`
- `heart_rate_zone_changed`
- `danger_state_changed`
这些事件后续可以统一映射到:
- sound
- haptics
- uiEffects
- map animation
---
## 5. 配置化建议
后续动画不应只写死在代码里。
建议逐步走向 profile 化。
例如:
```json
"game": {
"feedback": {
"uiEffectsProfile": "default-race",
"mapAnimationProfile": "default-map"
}
}
```
### 后续 profile 可承载的内容
- 某类事件是否启用动效
- 动效持续时间
- 动效强度
- 颜色风格
- 是否允许低端机降级
---
## 6. 建议增加统一动画配置
建议后续统一支持:
```json
"game": {
"animation": {
"enabled": true,
"level": "medium"
}
}
```
建议值:
- `enabled`
- `level = low / medium / high`
### 用途
- 低端机降级
- 调试关闭
- 正式版保守
---
## 7. 技术落地建议
## 7.1 地图动画
应继续放在地图引擎和 renderer 内处理。
不要让页面层承担:
- 点位 pulse
- 区域 reveal
- 轨迹闪动
- 目标高亮
这些都更适合:
- `MapPresentation`
- `MapScene`
- `WebGL renderer`
---
## 7.2 HUD 动画
适合继续放在页面层。
建议:
- 尽量轻量
- 尽量做过渡,不做大面积复杂动画
- 高优先级字段做细微跃迁即可
---
## 7.3 反馈动画
应继续走:
- `FeedbackDirector`
- `UIEffectDirector`
这条线后续很适合继续统一:
- 哪个事件触发什么动画
- 持续多久
- 是否叠加 sound / haptics
---
## 8. 实施顺序建议
不建议一口气铺太多动画。
推荐顺序:
1. `打点成功动画体系`
2. `目标点状态动画体系`
3. `HUD 数字与状态过渡`
4. `锁定 / 自动转图状态动画`
5. `危险 / 高压反馈动画`
6. 最后再做页面微交互动画
---
## 9. 第一阶段建议任务
如果下一步准备开始做动画,建议第一阶段先只收下面这些:
### 任务 1
整理一份动画事件字典:
- 哪些事件会触发动画
- 动画归属哪一层
- 对应目的是什么
### 任务 2
把打点成功链系统化:
- 点位变化
- HUD 跳变
- pulse
- toast
### 任务 3
统一目标点状态动画:
- 当前目标
- 可打点
- 已完成
- 已跳过
### 任务 4
补一个动画总开关:
- `animationsEnabled`
- `animationLevel`
---
## 10. 当前阶段结论
当前项目已经具备做动画体系的基础。
最正确的方向不是继续零散补动效,而是:
- 先按层组织动画
- 再按事件驱动
- 最后再做配置化和降级
一句话总结:
**后续动画建设应以“打点成功”和“目标状态”两条高频体验为起点,把动画正式纳入现有架构,而不是继续做零散样式补丁。**

View File

@@ -1,417 +0,0 @@
# 配置驱动应用的后台管理方案建议
本文用于整理当前这类“配置驱动型地图游戏应用”的后台管理建议,面向:
- PostgreSQL 数据库
- Go 中间层
- 后台管理系统
- 客户端静态配置发布
目标是解决一个核心问题:
**配置文件会越来越大,如何在后台可维护、可复用、可审核、可发布、可回滚。**
---
## 1. 总体原则
最稳的方案不是“数据库直接存一大份 `game.json` 给客户端读”,而是:
**数据库管理编辑态,发布时编译成运行态静态配置文件。**
也就是两套形态:
### 编辑态
- 存在 PostgreSQL
- 适合后台表单编辑
- 支持版本管理
- 支持对象复用
- 支持审核、比对、回滚
### 运行态
- 由 Go 中间层装配生成
- 输出为静态 JSON
- 上传到 OSS/CDN
- 客户端只读取发布后的静态配置
这条路线最适合当前项目。
---
## 2. 不建议的做法
不建议把后台做成:
- 一张表里存一个超大的 `jsonb`
- 后台直接编辑整份 `game.json`
- 客户端通过 API 动态拼装所有配置
这样后面会遇到这些问题:
- 配置复用困难
- diff 难看
- 回滚困难
- 审核困难
- 局部编辑体验差
- 客户端运行态不稳定
---
## 3. 推荐的核心对象
建议后台和数据库先固定这 5 个核心对象:
### `Map`
地图底座。
负责:
- 瓦片资源
- meta 信息
- 磁偏角
- 初始视角
### `Playfield`
玩法空间对象定义。
负责:
- KML 来源
- 控制点覆盖信息
- 区域对象
- 危险区
- 采集物
- 起终点信息
说明:
- `Playfield` 是上位概念
- `course` 只是其中一种特化形式
### `GameMode`
玩法模板。
负责:
- 顺序赛
- 积分赛
- 后续幽灵赛、迷雾赛、金币赛等
也就是:
- `game.mode`
- `session`
- `punch`
- `scoring`
- `guidance`
- `visibility`
- `finish`
- `telemetry`
- `feedback`
### `ResourcePack`
资源包。
负责:
- 音效 profile
- 文创内容
- 图标
- HUD 主题
- 动效 profile
### `Event`
最终活动实例。
负责引用:
- 一个 `Map`
- 一个 `Playfield`
- 一个 `GameMode`
- 一个 `ResourcePack`
并允许少量活动级覆盖。
一句话:
**Event = Map + Playfield + GameMode + ResourcePack + EventOverrides**
---
## 4. 数据库建模建议
建议每个核心对象都分成:
- 主表
- version 表
### 4.1 主表
主表存稳定元信息:
- `id`
- `slug`
- `name`
- `status`
- `current_version_id`
- `created_at`
- `updated_at`
### 4.2 version 表
version 表存每个版本的具体内容:
- `id`
- `parent_id`
- `version_no`
- `schema_version`
- `content_jsonb`
- `created_by`
- `created_at`
- `change_note`
### 4.3 推荐表
建议至少有:
- `maps`
- `map_versions`
- `playfields`
- `playfield_versions`
- `game_modes`
- `game_mode_versions`
- `resource_packs`
- `resource_pack_versions`
- `events`
- `event_versions`
---
## 5. 为什么要做版本表
版本表的价值非常大:
- 支持草稿
- 支持发布版
- 支持 diff
- 支持回滚
- 支持审计
- 支持多人协作
如果没有版本表,后面后台管理一定会越来越难维护。
---
## 6. JSONB 的使用建议
推荐策略是:
- 稳定字段结构化
- 变化快的配置内容放 `jsonb`
例如主表中:
- `slug`
- `name`
- `status`
放结构化列。
而玩法具体配置、资源清单、覆盖字段,放在 `content_jsonb`
这样兼顾:
- 查询效率
- 结构灵活性
- 配置扩展性
---
## 7. 后台编辑方式建议
后台不要直接给运营一个大 JSON 编辑框作为主要方式。
推荐做法:
- 地图编辑页
- Playfield 编辑页
- 玩法规则页
- 资源包页
- 活动编排页
按模块表单化编辑。
最后由 Go 中间层负责装配成最终配置 JSON。
也就是:
**后台是“编辑结构化对象”,不是“手工拼最终运行文件”。**
---
## 8. 发布机制建议
发布时建议按下面流程:
1. 后台选定某个 `Event Version`
2. Go 中间层读取它引用的:
- `Map Version`
- `Playfield Version`
- `GameMode Version`
- `ResourcePack Version`
3. 做装配
4. 做校验
5. 生成最终运行态 JSON
6. 上传 OSS/CDN
7. 记录一条 release
客户端只读:
- 已发布的静态配置 URL
不要让客户端直接查数据库 API 动态拼。
---
## 9. 推荐增加 Release 层
建议增加:
- `event_releases`
字段例如:
- `id`
- `event_id`
- `event_version_id`
- `release_no`
- `manifest_url`
- `published_by`
- `published_at`
- `status`
它的作用:
- 一键回滚
- 客户端锁定某次 release
- 管理历史发布记录
- 灰度验证
---
## 10. Go 中间层建议职责
Go 中间层不要只做 CRUD。
建议它至少承担这 4 类职责:
### 10.1 校验
- schema 校验
- 引用存在校验
- 字段完整性校验
- 规则约束校验
### 10.2 装配
把:
- `Map`
- `Playfield`
- `GameMode`
- `ResourcePack`
- `Event Overrides`
装配成最终配置结构。
### 10.3 发布
- 生成最终静态 JSON
- 上传到 OSS/CDN
- 记录 release
### 10.4 对比与预览
- 给后台显示 diff
- 给发布前做预览
一句话:
**Go 中间层本质上是配置编译器。**
---
## 11. 校验建议
建议尽量做强校验。
至少包括:
- schemaVersion 合法
- 引用对象存在
- KML 路径存在
- 地图 meta 存在
- 玩法字段完整
以及玩法特定约束,例如:
- 顺序赛必须有 start / finish
- 积分赛 control set 需要 score 或可派生 score
- `punch.radiusMeters > 0`
- `skip.radiusMeters > punch.radiusMeters`
这样能把很多错误挡在发布前。
---
## 12. 和当前静态目录的关系
当前你已经有类似目录:
- `map/`
- `kml/`
- `event/`
这很好,可以继续保留。
建议把它理解成:
- 数据库 = 编辑态
- 这些目录 = 发布产物态
也就是后台发布后Go 中间层继续生成:
- `event/classic-sequential.json`
- `event/score-o.json`
- `map/...`
- `kml/...`
客户端保持现有读取方式不变。
---
## 13. 推荐的后续实施顺序
建议按这个顺序落地:
### 第一步
先建 5 个核心对象模型:
- `Map`
- `Playfield`
- `GameMode`
- `ResourcePack`
- `Event`
### 第二步
为每个对象补版本表。
### 第三步
Go 中间层实现“装配成最终 JSON”。
### 第四步
实现“发布到 OSS/CDN”。
### 第五步
后台逐步从 JSON 编辑过渡到模块化表单编辑。
---
## 14. 一句话总结
这类配置驱动应用最稳的后台方案是:
**PostgreSQL 管结构化、可版本化的编辑态对象Go 中间层负责校验、装配和发布;客户端只消费发布后的静态 JSON。**
这样才能做到:
- 可复用
- 可扩展
- 可审核
- 可回滚
- 可稳定运行

View File

@@ -1,356 +0,0 @@
# 积分赛配置文档(基础版)
本文档用于给服务端和后台配置设计提供一份可直接落地的积分赛基础模板。
目标是先把积分赛入口结构定稳,后续程序功能再逐步细化。
---
## 1. 适用玩法
适用于最基础的积分赛玩法:
- 手动开始
- 先打开始点
- 多个检查点自由收集
- 控制点有固定分值
- 可选终点
- 支持选中目标点后打卡
---
## 2. 顶层结构
推荐主配置结构如下:
```json
{
"schemaVersion": "1",
"version": "2026.03.25",
"app": {},
"map": {},
"playfield": {},
"game": {},
"resources": {},
"debug": {}
}
```
---
## 3. 完整示例
```json
{
"schemaVersion": "1",
"version": "2026.03.25",
"app": {
"id": "lxcb-001",
"title": "雪熊领秀城区积分赛",
"locale": "zh-CN"
},
"map": {
"tiles": "lxcb-001/tiles/",
"mapmeta": "lxcb-001/tiles/meta.json",
"declination": 6.91,
"initialView": {
"zoom": 17
}
},
"playfield": {
"kind": "control-set",
"source": {
"type": "kml",
"url": "lxcb-001/course/c01.kml"
},
"CPRadius": 6,
"controlOverrides": {
"control-1": {
"score": 10
},
"control-2": {
"score": 20
},
"control-3": {
"score": 30
},
"control-4": {
"score": 40
}
},
"metadata": {
"title": "校园积分赛控制点集",
"code": "score-o-c01"
}
},
"game": {
"mode": "score-o",
"rulesVersion": "1",
"session": {
"startManually": true,
"requiresStartPunch": true,
"requiresFinishPunch": false,
"autoFinishOnLastControl": false,
"maxDurationSec": 5400
},
"punch": {
"policy": "enter-confirm",
"radiusMeters": 10,
"requiresFocusSelection": true
},
"scoring": {
"type": "score",
"defaultControlScore": 10
},
"guidance": {
"showLegs": false,
"legAnimation": false,
"allowFocusSelection": true
},
"visibility": {
"revealFullPlayfieldAfterStartPunch": true
},
"finish": {
"finishControlAlwaysSelectable": true
},
"telemetry": {
"heartRate": {
"age": 30,
"restingHeartRateBpm": 62,
"userWeightKg": 65
}
},
"feedback": {
"audioProfile": "default",
"hapticsProfile": "default",
"uiEffectsProfile": "default"
}
},
"resources": {
"audioProfile": "default",
"contentProfile": "default",
"themeProfile": "default-race"
},
"debug": {
"allowModeSwitch": false,
"allowMockInput": false,
"allowSimulator": false
}
}
```
---
## 4. 字段说明
### `app`
- `id`
活动或配置实例 id
- `title`
活动标题
- `locale`
语言环境
### `map`
- `tiles`
瓦片根路径
- `mapmeta`
地图 meta 地址
- `declination`
磁偏角
- `initialView.zoom`
初始缩放级别
### `playfield`
- `kind`
当前推荐 `control-set`
- `source.type`
当前推荐 `kml`
- `source.url`
KML 地址
- `CPRadius`
检查点绘制半径
- `controlOverrides`
每个控制点的积分和后续扩展元数据
### `playfield.controlOverrides`
当前阶段最推荐至少放:
- `score`
示例:
```json
"control-1": {
"score": 10
}
```
这样可以保证:
- KML 只提供点位与空间结构
- 分值由配置控制
### `game.session`
- `startManually`
是否手动开始
- `requiresStartPunch`
是否必须先打开始点
- `requiresFinishPunch`
是否必须打终点
- `autoFinishOnLastControl`
积分赛通常为 `false`
- `maxDurationSec`
最大时长
### `game.punch`
- `policy`
当前推荐 `enter-confirm`
- `radiusMeters`
打点半径
- `requiresFocusSelection`
是否必须先选中目标点后才能打卡
### `game.scoring`
- `type`
当前推荐 `score`
- `defaultControlScore`
如果某个点没单独配置分数时的默认值
### `game.guidance`
- `showLegs`
积分赛基础版建议 `false`
- `legAnimation`
积分赛基础版建议 `false`
- `allowFocusSelection`
建议 `true`
### `game.visibility`
- `revealFullPlayfieldAfterStartPunch`
开始点打卡后是否显示完整控制点集合
### `game.finish`
- `finishControlAlwaysSelectable`
积分赛建议支持随时选终点结束时,设为 `true`
### `game.telemetry`
通用体能参数。
### `game.feedback`
反馈 profile 绑定。
### `resources`
资源 profile 绑定。
### `debug`
调试相关开关。
---
## 5. 当前阶段推荐必填字段
积分赛当前阶段建议至少保证以下字段存在:
- `map.tiles`
- `map.mapmeta`
- `map.declination`
- `playfield.kind`
- `playfield.source.type`
- `playfield.source.url`
- `playfield.CPRadius`
- `game.mode`
- `game.punch.policy`
- `game.punch.radiusMeters`
- `game.punch.requiresFocusSelection`
- `game.scoring.type`
---
## 6. 当前阶段建议默认值
如果服务端还没有全部细项,建议先采用以下默认值:
```json
{
"game": {
"session": {
"startManually": true,
"requiresStartPunch": true,
"requiresFinishPunch": false,
"autoFinishOnLastControl": false
},
"punch": {
"policy": "enter-confirm",
"radiusMeters": 10,
"requiresFocusSelection": true
},
"scoring": {
"type": "score",
"defaultControlScore": 10
},
"guidance": {
"showLegs": false,
"legAnimation": false,
"allowFocusSelection": true
},
"finish": {
"finishControlAlwaysSelectable": true
}
}
}
```
---
## 7. 积分赛当前阶段推荐的设计边界
当前阶段建议坚持以下边界:
- KML 提供点位和几何底稿
- 配置提供分值和玩法解释
- 不把积分逻辑写进 KML
- 不把自由收集逻辑写成固定路线逻辑
也就是说:
- `playfield.kind = control-set`
- `controlOverrides.score` 负责分值
- `game.guidance.allowFocusSelection = true` 负责选中目标逻辑
---
## 8. 适合当前客户端的迁移原则
当前客户端迁移时,建议服务端先完成:
1. 将积分赛点位分值迁入 `playfield.controlOverrides`
2. 将玩法规则迁入 `game.session / game.punch / game.scoring / game.guidance / game.finish`
3. 不急着一次性接入动态积分和复杂规则
先把静态积分赛入口结构定稳,再逐步扩展动态积分和高级玩法能力。
---
## 9. 一句话结论
积分赛配置当前阶段建议:
-`playfield.kind = control-set`
- 用 KML 承载控制点空间底稿
-`playfield.controlOverrides` 承载点位分值
-`game.scoring / game.punch / game.guidance / game.finish` 承载玩法规则
- 先把静态积分赛入口结构定稳,后续再扩动态积分与更复杂玩法

View File

@@ -1,588 +0,0 @@
# 游戏配置文件设计方案(阶段讨论稿)
本文档用于整理当前阶段推荐的配置文件设计方案,供后端、客户端和后台管理设计参考。
目标是让配置真正成为游戏的驱动入口,同时兼顾后续多玩法、多资源、多活动复用。
---
## 1. 设计目标
配置文件系统需要解决以下问题:
- 驱动地图、玩法、资源、调试开关
- 支持顺序赛、积分赛以及后续更多玩法
- 支持将来后台管理系统的内容编排
- 保证地图空间信息与玩法语义分层
- 保证当前阶段可平滑迁移,不推翻已有实现
当前推荐原则:
- 配置只描述,不执行逻辑
- 地图、空间对象、玩法规则、资源包分层
- KML 负责空间底稿,不负责复杂玩法语义
- 主配置先保持单文件,后续再升级为 manifest 组合
---
## 2. 顶层配置结构
当前推荐主入口配置结构如下:
```json
{
"schemaVersion": "1",
"version": "2026.03.25",
"app": {},
"map": {},
"playfield": {},
"game": {},
"resources": {},
"debug": {}
}
```
各层职责如下:
- `app`
活动级或应用级基础信息
- `map`
地图底图和空间底座
- `playfield`
当前玩法使用的空间对象定义
- `game`
当前玩法规则配置
- `resources`
资源包与 profile
- `debug`
调试与开发开关
---
## 3. 为什么不再以 course 作为总抽象
在定向语义里,`course` 是准确术语,表示路线。
但从系统长期扩展看,`course` 并不是所有玩法的上位概念。
例如:
- 顺序赛有明显的 `course`
- 积分赛更像一组控制点与分数
- 金币赛更像可收集点集合
- 幽灵赛可能包含危险区、隐身点、追逐者
- 迷雾赛可能包含 reveal 点、扫描点、区域
因此推荐:
- 将上位内容模型提升为 `playfield`
- `course` 只作为 `playfield.kind` 的一种
例如:
```json
{
"playfield": {
"kind": "course"
}
}
```
或:
```json
{
"playfield": {
"kind": "control-set"
}
}
```
---
## 4. KML 与配置的边界
当前推荐边界非常明确:
### 4.1 KML 负责空间底稿
KML 适合描述:
- 点坐标
- 起点 / 检查点 / 终点
- 顺序号
- 点位名称
- 腿线几何
### 4.2 配置负责玩法解释
配置负责描述:
- 点位分值
- 打点规则
- 显隐规则
- 动态积分
- 道具能力
- 迷雾规则
- 占领规则
- 特殊玩法语义
一句话总结:
**KML 描述空间事实,配置描述玩法解释。**
---
## 5. 推荐的字段结构
### 5.1 `app`
用于活动级基础信息。
示例:
```json
{
"app": {
"id": "lxcb-001",
"title": "雪熊领秀城区定向赛",
"locale": "zh-CN"
}
}
```
### 5.2 `map`
用于地图底图与空间底座。
示例:
```json
{
"map": {
"tiles": "lxcb-001/tiles/",
"mapmeta": "lxcb-001/tiles/meta.json",
"declination": 6.91,
"initialView": {
"zoom": 17
}
}
}
```
### 5.3 `playfield`
用于描述当前玩法使用的空间对象及其来源。
示例:
```json
{
"playfield": {
"kind": "course",
"source": {
"type": "kml",
"url": "lxcb-001/course/c01.kml"
},
"CPRadius": 6,
"controlOverrides": {},
"metadata": {}
}
}
```
建议后续逐步支持的对象包括:
- `controls`
- `collectibles`
- `zones`
- `hazards`
- `links`
- `spawnPoints`
### 5.4 `game`
用于描述玩法规则。
推荐统一结构如下:
```json
{
"game": {
"mode": "",
"rulesVersion": "1",
"session": {},
"punch": {},
"scoring": {},
"guidance": {},
"visibility": {},
"finish": {},
"telemetry": {},
"feedback": {}
}
}
```
#### `session`
控制一局游戏的流程参数:
- 是否手动开始
- 是否必须打开始点
- 是否必须打结束点
- 是否允许自动结束
- 最大时长
#### `punch`
控制打点规则:
- 打点策略
- 打点半径
- 是否必须选中后打卡
#### `scoring`
控制积分与结算:
- 完成型
- 固定分
- 动态分
#### `guidance`
控制引导方式:
- 是否显示腿线
- 是否显示腿线动画
- 是否允许 focus 选择
#### `visibility`
控制显隐逻辑:
- 是否开始后显示全图
- 是否采用迷雾
#### `finish`
控制结束规则:
- 是否必须打终点
- 是否允许随时结束
#### `telemetry`
控制通用运动信息参数:
- 年龄
- 静息心率
- 体重
#### `feedback`
控制反馈 profile
- 音频
- 震动
- UI 动效
### 5.5 `resources`
用于描述资源 profile。
示例:
```json
{
"resources": {
"audioProfile": "default",
"contentProfile": "default",
"themeProfile": "default-race"
}
}
```
当前阶段建议先保持轻量,后续再逐步拆成资源包 manifest。
### 5.6 `debug`
用于开发和调试相关开关。
示例:
```json
{
"debug": {
"allowModeSwitch": false,
"allowMockInput": false,
"allowSimulator": false
}
}
```
---
## 6. 顺序赛示例配置
```json
{
"schemaVersion": "1",
"version": "2026.03.25",
"app": {
"id": "lxcb-001",
"title": "雪熊领秀城区顺序赛"
},
"map": {
"tiles": "lxcb-001/tiles/",
"mapmeta": "lxcb-001/tiles/meta.json",
"declination": 6.91
},
"playfield": {
"kind": "course",
"source": {
"type": "kml",
"url": "lxcb-001/course/c01.kml"
},
"CPRadius": 6
},
"game": {
"mode": "classic-sequential",
"rulesVersion": "1",
"session": {
"requiresStartPunch": true,
"requiresFinishPunch": true,
"autoFinishOnLastControl": false,
"startManually": true
},
"punch": {
"policy": "enter-confirm",
"radiusMeters": 10
},
"guidance": {
"showLegs": true,
"legAnimation": true,
"allowFocusSelection": false
},
"visibility": {
"revealFullPlayfieldAfterStartPunch": true
},
"telemetry": {
"heartRate": {
"age": 30,
"restingHeartRateBpm": 62,
"userWeightKg": 65
}
},
"feedback": {
"audioProfile": "default",
"hapticsProfile": "default",
"uiEffectsProfile": "default"
}
},
"resources": {
"audioProfile": "default",
"contentProfile": "default"
},
"debug": {
"allowModeSwitch": false,
"allowMockInput": false
}
}
```
---
## 7. 积分赛示例配置
```json
{
"schemaVersion": "1",
"version": "2026.03.25",
"app": {
"id": "lxcb-001",
"title": "雪熊领秀城区积分赛"
},
"map": {
"tiles": "lxcb-001/tiles/",
"mapmeta": "lxcb-001/tiles/meta.json",
"declination": 6.91
},
"playfield": {
"kind": "control-set",
"source": {
"type": "kml",
"url": "lxcb-001/course/c01.kml"
},
"CPRadius": 6,
"controlOverrides": {
"control-1": { "score": 10 },
"control-2": { "score": 20 },
"control-3": { "score": 30 }
}
},
"game": {
"mode": "score-o",
"rulesVersion": "1",
"session": {
"requiresStartPunch": true,
"requiresFinishPunch": false,
"startManually": true
},
"punch": {
"policy": "enter-confirm",
"radiusMeters": 10,
"requiresFocusSelection": true
},
"guidance": {
"showLegs": false,
"legAnimation": false,
"allowFocusSelection": true
},
"scoring": {
"type": "score"
},
"finish": {
"finishControlAlwaysSelectable": true
},
"telemetry": {
"heartRate": {
"age": 30,
"restingHeartRateBpm": 62,
"userWeightKg": 65
}
},
"feedback": {
"audioProfile": "default",
"hapticsProfile": "default",
"uiEffectsProfile": "default"
}
},
"resources": {
"audioProfile": "default",
"contentProfile": "default"
},
"debug": {
"allowModeSwitch": false,
"allowMockInput": false
}
}
```
---
## 8. 当前老字段到新结构的迁移建议
### 地图层
- `map` -> `map.tiles`
- `mapmeta` -> `map.mapmeta`
- `declination` -> `map.declination`
### 路线层
- `course` -> `playfield.source.url`
- `CPRadius` -> `playfield.CPRadius`
### 玩法层
- `game.mode` -> `game.mode`
- `game.punchPolicy` -> `game.punch.policy`
- `PunchRadius` -> `game.punch.radiusMeters`
- `game.autoFinishOnLastControl` -> `game.session.autoFinishOnLastControl`
### telemetry 层
- `game.telemetry.age` -> `game.telemetry.heartRate.age`
- `game.telemetry.restingHeartRateBpm` -> `game.telemetry.heartRate.restingHeartRateBpm`
- `game.telemetry.userWeightKg` -> `game.telemetry.heartRate.userWeightKg`
### feedback 层
- `game.audio` -> `game.feedback.audio``resources.audioProfiles`
- `game.haptics` -> `game.feedback.haptics``resources.hapticsProfiles`
- `game.uiEffects` -> `game.feedback.uiEffects``resources.uiEffectsProfiles`
当前建议迁移策略:
- 第一阶段:代码同时兼容老字段和新结构
- 第二阶段:线上配置逐步切换
- 第三阶段:再清理旧字段兼容逻辑
---
## 9. 未来推荐的 manifest 方向
当前阶段主配置建议先保持单文件。
但未来配置规模变大时,推荐升级成多 manifest 组合:
```json
{
"schemaVersion": "1",
"version": "2026.03.25",
"map": {
"manifest": "maps/lxcb-001/map.json"
},
"playfield": {
"manifest": "playfields/lxcb-001/c01.json"
},
"game": {
"manifest": "modes/score-o/default.json"
},
"resources": {
"manifest": "packs/spring-2026/resources.json"
},
"debug": {}
}
```
这样可以支持:
- 一张地图挂多种玩法
- 一条 playfield 挂多种规则
- 一种玩法切换不同资源包
- 后台管理做拼装式发布
---
## 10. 服务端和后台管理的推荐核心对象
后续从服务端和后台管理的复用角度,建议围绕以下核心对象建模:
- `Map`
- `Playfield`
- `GameMode`
- `ResourcePack`
- `Event`
其中:
- `Map`
地图底图与空间底座
- `Playfield`
当前玩法场景中的空间对象定义
- `GameMode`
玩法规则模板
- `ResourcePack`
资源包与 profile
- `Event`
一次实际发布的活动实例
推荐关系可以理解为:
`Event = Map + Playfield + GameMode + ResourcePack + 发布参数`
---
## 11. 当前阶段推荐结论
当前阶段最推荐的方案是:
- 先保留单个 `game.json`
- 结构升级为 `app / map / playfield / game / resources / debug`
- 保留 KML 作为空间底稿来源
- 不再让 `course` 成为总抽象,而是提升为更通用的 `playfield`
- 让代码先双兼容,再逐步迁移线上配置
一句话总结:
**KML 描述空间事实,配置描述玩法解释;主配置按 `map / playfield / game / resources / debug` 分层,后续再升级成 manifest 组合。**

View File

@@ -1,314 +0,0 @@
# 顺序赛配置文档(基础版)
本文档用于给服务端和后台配置设计提供一份可直接落地的顺序赛基础模板。
目标是先把入口配置结构定稳,后续程序功能再逐步细化。
---
## 1. 适用玩法
适用于最基础的顺序打点玩法:
- 手动开始
- 先打开始点
- 按顺序打检查点
- 最后打终点
- 支持正常打点半径
- 预留后续扩展规则空间
---
## 2. 顶层结构
推荐主配置结构如下:
```json
{
"schemaVersion": "1",
"version": "2026.03.25",
"app": {},
"map": {},
"playfield": {},
"game": {},
"resources": {},
"debug": {}
}
```
---
## 3. 完整示例
```json
{
"schemaVersion": "1",
"version": "2026.03.25",
"app": {
"id": "lxcb-001",
"title": "雪熊领秀城区顺序赛",
"locale": "zh-CN"
},
"map": {
"tiles": "lxcb-001/tiles/",
"mapmeta": "lxcb-001/tiles/meta.json",
"declination": 6.91,
"initialView": {
"zoom": 17
}
},
"playfield": {
"kind": "course",
"source": {
"type": "kml",
"url": "lxcb-001/course/c01.kml"
},
"CPRadius": 6,
"metadata": {
"title": "校园经典路线",
"code": "c01"
}
},
"game": {
"mode": "classic-sequential",
"rulesVersion": "1",
"session": {
"startManually": true,
"requiresStartPunch": true,
"requiresFinishPunch": true,
"autoFinishOnLastControl": false,
"maxDurationSec": 5400
},
"punch": {
"policy": "enter-confirm",
"radiusMeters": 10
},
"sequence": {
"skip": {
"enabled": false,
"radiusMeters": 30,
"requiresConfirm": true
}
},
"guidance": {
"showLegs": true,
"legAnimation": true,
"allowFocusSelection": false
},
"visibility": {
"revealFullPlayfieldAfterStartPunch": true
},
"finish": {
"finishControlAlwaysSelectable": false
},
"telemetry": {
"heartRate": {
"age": 30,
"restingHeartRateBpm": 62,
"userWeightKg": 65
}
},
"feedback": {
"audioProfile": "default",
"hapticsProfile": "default",
"uiEffectsProfile": "default"
}
},
"resources": {
"audioProfile": "default",
"contentProfile": "default",
"themeProfile": "default-race"
},
"debug": {
"allowModeSwitch": false,
"allowMockInput": false,
"allowSimulator": false
}
}
```
---
## 4. 字段说明
### `app`
- `id`
活动或配置实例 id
- `title`
活动标题
- `locale`
语言环境
### `map`
- `tiles`
瓦片根路径
- `mapmeta`
地图 meta 地址
- `declination`
磁偏角
- `initialView.zoom`
初始缩放级别
### `playfield`
- `kind`
当前为 `course`
- `source.type`
当前推荐为 `kml`
- `source.url`
KML 地址
- `CPRadius`
检查点绘制半径,单位米
- `metadata`
路线元数据
### `game.session`
- `startManually`
是否需要先点击开始按钮
- `requiresStartPunch`
是否必须先打开始点
- `requiresFinishPunch`
是否必须打终点
- `autoFinishOnLastControl`
是否打完最后一个检查点自动结束
- `maxDurationSec`
最大比赛时长
### `game.punch`
- `policy`
当前推荐 `enter-confirm`
- `radiusMeters`
正常打点半径
### `game.sequence`
- `skip`
顺序赛跳点规则
- `enabled`
是否允许跳点
- `radiusMeters`
跳点半径,必须大于打点半径
- `requiresConfirm`
是否必须用户确认后跳点
当前基础版建议先关闭:
```json
"enabled": false
```
### `game.guidance`
- `showLegs`
是否显示腿线
- `legAnimation`
是否显示当前腿动画
- `allowFocusSelection`
顺序赛一般为 `false`
### `game.visibility`
- `revealFullPlayfieldAfterStartPunch`
开始点打卡后是否显示完整路线
### `game.finish`
- `finishControlAlwaysSelectable`
顺序赛一般为 `false`
### `game.telemetry`
通用体能参数。
### `game.feedback`
反馈 profile 绑定。
### `resources`
资源 profile 绑定。
### `debug`
调试相关开关。
---
## 5. 当前阶段推荐必填字段
顺序赛当前阶段建议至少保证以下字段存在:
- `map.tiles`
- `map.mapmeta`
- `map.declination`
- `playfield.kind`
- `playfield.source.type`
- `playfield.source.url`
- `playfield.CPRadius`
- `game.mode`
- `game.punch.policy`
- `game.punch.radiusMeters`
- `game.session.requiresStartPunch`
- `game.session.requiresFinishPunch`
---
## 6. 当前阶段建议默认值
如果服务端还没有全部配置细项,建议先采用以下默认值:
```json
{
"game": {
"session": {
"startManually": true,
"requiresStartPunch": true,
"requiresFinishPunch": true,
"autoFinishOnLastControl": false
},
"punch": {
"policy": "enter-confirm",
"radiusMeters": 10
},
"sequence": {
"skip": {
"enabled": false,
"radiusMeters": 30,
"requiresConfirm": true
}
},
"guidance": {
"showLegs": true,
"legAnimation": true,
"allowFocusSelection": false
}
}
}
```
---
## 7. 适合当前客户端的迁移原则
当前客户端迁移时,建议服务端先完成:
1. 将老字段逐步迁入 `map / playfield / game / resources / debug`
2. 保持基础字段完整
3. 不急着一次性把所有高级规则上线
优先把“入口结构”夯实,再逐步扩玩法参数。
---
## 8. 一句话结论
顺序赛配置当前阶段建议:
-`playfield.kind = course`
- 用 KML 承载空间底稿
-`game.session / game.punch / game.sequence / game.guidance` 承载玩法规则
- 先把基础入口结构定稳,后续再细化跳点、惩罚、特殊引导等高级规则

View File

@@ -1,416 +0,0 @@
# 默认配置模板文档(当前实现版)
本文档提供一份 **当前客户端可直接使用的默认配置模板**
目标是:
- 给服务端/后台一个稳定的起步模板
- 保证即使只填最少字段,也能正常跑起来
- 随开发持续补充和维护
说明:
- 本模板优先保证“可运行”
- 高级字段可以逐步补
- 文创内容和点击内容也已经纳入模板
---
## 1. 顶层默认模板
```json
{
"schemaVersion": "1",
"version": "2026.03.27",
"app": {
"id": "sample-event-001",
"title": "示例活动",
"locale": "zh-CN"
},
"map": {
"tiles": "../map/lxcb-001/tiles/",
"mapmeta": "../map/lxcb-001/tiles/meta.json",
"declination": 6.91,
"initialView": {
"zoom": 17
}
},
"playfield": {
"kind": "course",
"source": {
"type": "kml",
"url": "../kml/lxcb-001/10/c01.kml"
},
"CPRadius": 6,
"controlOverrides": {},
"metadata": {
"title": "默认路线",
"code": "default-001"
}
},
"game": {
"mode": "classic-sequential",
"rulesVersion": "1",
"session": {
"startManually": true,
"requiresStartPunch": true,
"requiresFinishPunch": true,
"autoFinishOnLastControl": false,
"maxDurationSec": 5400
},
"punch": {
"policy": "enter-confirm",
"radiusMeters": 5,
"requiresFocusSelection": false
},
"sequence": {
"skip": {
"enabled": false,
"radiusMeters": 30,
"requiresConfirm": true
}
},
"scoring": {
"type": "score",
"defaultControlScore": 10
},
"guidance": {
"showLegs": true,
"legAnimation": true,
"allowFocusSelection": false
},
"visibility": {
"revealFullPlayfieldAfterStartPunch": true
},
"finish": {
"finishControlAlwaysSelectable": false
},
"telemetry": {
"heartRate": {
"age": 30,
"restingHeartRateBpm": 62,
"userWeightKg": 65
}
},
"feedback": {
"audioProfile": "default",
"hapticsProfile": "default",
"uiEffectsProfile": "default"
}
},
"resources": {
"audioProfile": "default",
"contentProfile": "default",
"themeProfile": "default-race"
},
"debug": {
"allowModeSwitch": false,
"allowMockInput": false,
"allowSimulator": false
}
}
```
---
## 2. 顺序赛推荐默认模板
```json
{
"schemaVersion": "1",
"version": "2026.03.27",
"app": {
"id": "sample-classic-001",
"title": "顺序赛示例",
"locale": "zh-CN"
},
"map": {
"tiles": "../map/lxcb-001/tiles/",
"mapmeta": "../map/lxcb-001/tiles/meta.json",
"declination": 6.91,
"initialView": {
"zoom": 17
}
},
"playfield": {
"kind": "course",
"source": {
"type": "kml",
"url": "../kml/lxcb-001/10/c01.kml"
},
"CPRadius": 6,
"controlOverrides": {
"start-1": {
"title": "比赛开始",
"body": "从这里出发,先熟悉地图方向。",
"autoPopup": true,
"once": true,
"priority": 1,
"clickTitle": "起点说明",
"clickBody": "点击起点可再次查看起跑说明。"
},
"control-1": {
"title": "第一检查点",
"body": "完成这个点后沿主路继续前进。",
"autoPopup": true,
"once": false,
"priority": 1,
"clickTitle": "第一检查点",
"clickBody": "点击查看该点位的补充说明。"
},
"finish-1": {
"title": "比赛结束",
"body": "恭喜完成本次路线。",
"autoPopup": true,
"once": true,
"priority": 2,
"clickTitle": "终点说明",
"clickBody": "点击终点可再次查看结束说明。"
}
},
"metadata": {
"title": "顺序赛路线示例",
"code": "classic-001"
}
},
"game": {
"mode": "classic-sequential",
"rulesVersion": "1",
"session": {
"startManually": true,
"requiresStartPunch": true,
"requiresFinishPunch": true,
"autoFinishOnLastControl": false,
"maxDurationSec": 5400
},
"punch": {
"policy": "enter-confirm",
"radiusMeters": 5,
"requiresFocusSelection": false
},
"sequence": {
"skip": {
"enabled": false,
"radiusMeters": 30,
"requiresConfirm": true
}
},
"guidance": {
"showLegs": true,
"legAnimation": true,
"allowFocusSelection": false
},
"visibility": {
"revealFullPlayfieldAfterStartPunch": true
},
"finish": {
"finishControlAlwaysSelectable": false
},
"telemetry": {
"heartRate": {
"age": 30,
"restingHeartRateBpm": 62,
"userWeightKg": 65
}
},
"feedback": {
"audioProfile": "default",
"hapticsProfile": "default",
"uiEffectsProfile": "default"
}
},
"resources": {
"audioProfile": "default",
"contentProfile": "default",
"themeProfile": "default-race"
},
"debug": {
"allowModeSwitch": false,
"allowMockInput": false,
"allowSimulator": false
}
}
```
---
## 3. 积分赛推荐默认模板
```json
{
"schemaVersion": "1",
"version": "2026.03.27",
"app": {
"id": "sample-score-o-001",
"title": "积分赛示例",
"locale": "zh-CN"
},
"map": {
"tiles": "../map/lxcb-001/tiles/",
"mapmeta": "../map/lxcb-001/tiles/meta.json",
"declination": 6.91,
"initialView": {
"zoom": 17
}
},
"playfield": {
"kind": "control-set",
"source": {
"type": "kml",
"url": "../kml/lxcb-001/10/c01.kml"
},
"CPRadius": 6,
"controlOverrides": {
"start-1": {
"title": "比赛开始",
"body": "从这里触发,先熟悉地图方向。",
"autoPopup": true,
"once": true,
"priority": 1,
"clickTitle": "积分赛起点",
"clickBody": "点击起点可查看自由打点规则。"
},
"control-1": {
"score": 10,
"clickTitle": "1号点",
"clickBody": "这是一个基础积分点。"
},
"control-2": {
"score": 20,
"autoPopup": false,
"once": true,
"priority": 1,
"clickTitle": "2号点",
"clickBody": "这个点配置成点击查看。"
},
"finish-1": {
"title": "比赛结束",
"body": "恭喜完成本次路线。",
"autoPopup": true,
"once": true,
"priority": 2,
"clickTitle": "终点说明",
"clickBody": "点击终点可再次查看结束说明。"
}
},
"metadata": {
"title": "积分赛控制点示例",
"code": "score-o-001"
}
},
"game": {
"mode": "score-o",
"rulesVersion": "1",
"session": {
"startManually": true,
"requiresStartPunch": true,
"requiresFinishPunch": false,
"autoFinishOnLastControl": false,
"maxDurationSec": 5400
},
"punch": {
"policy": "enter-confirm",
"radiusMeters": 5,
"requiresFocusSelection": false
},
"scoring": {
"type": "score",
"defaultControlScore": 10
},
"guidance": {
"showLegs": false,
"legAnimation": false,
"allowFocusSelection": true
},
"visibility": {
"revealFullPlayfieldAfterStartPunch": true
},
"finish": {
"finishControlAlwaysSelectable": true
},
"telemetry": {
"heartRate": {
"age": 30,
"restingHeartRateBpm": 62,
"userWeightKg": 65
}
},
"feedback": {
"audioProfile": "default",
"hapticsProfile": "default",
"uiEffectsProfile": "default"
}
},
"resources": {
"audioProfile": "default",
"contentProfile": "default",
"themeProfile": "default-race"
},
"debug": {
"allowModeSwitch": false,
"allowMockInput": false,
"allowSimulator": false
}
}
```
---
## 4. 默认逻辑说明
### 4.1 内容展示默认逻辑
- `title/body`
- 未配置时使用系统默认文案
- `clickTitle/clickBody`
- 未配置时回退到 `title/body`
- `autoPopup`
- 默认允许自动弹出
- `once`
- 默认 `false`
- `priority`
- 普通点默认 `1`
- 终点默认 `2`
- 自动打点时:
- 自动打点完成后不自动弹内容
- 点击内容仍可用
### 4.2 玩法默认逻辑
- 顺序赛默认:
- 必须起点
- 必须终点
- 不自动结束
- 跳点默认关闭
- 积分赛默认:
- 必须起点
- 终点可选
- 不自动结束
- 默认分值 `10`
### 4.3 资源默认逻辑
- `audioProfile = default`
- `contentProfile = default`
- `themeProfile = default-race`
---
## 5. 建议维护方式
后续每次配置能力扩展时,建议同步维护:
1. [D:\dev\cmr-mini\config-option-dictionary.md](D:/dev/cmr-mini/doc/doc/config-option-dictionary.md)
2. [D:\dev\cmr-mini\config-default-template.md](D:/dev/cmr-mini/doc/doc/config-default-template.md)
3. [D:\dev\cmr-mini\event\classic-sequential.json](D:/dev/cmr-mini/event/classic-sequential.json)
4. [D:\dev\cmr-mini\event\score-o.json](D:/dev/cmr-mini/event/score-o.json)
这样可以保证:
- 客户端实现
- 服务端配置
- 后台录入
- 联调样例
始终保持一致。

View File

@@ -1,414 +0,0 @@
# H5 体验接入方案
本文档用于定义当前项目中 **原生小程序 + H5 定制内容** 的混合接入方案。
目标:
- 保留原生地图与实时游戏主流程
- 把高频变化、强定制的内容页交给 H5
- 保证 H5 失败时,原生仍可完整兜底
- 为后续客户定制、品牌包装、互动任务扩展留出稳定接口
---
## 1. 结论
当前最合适的方向不是“所有定制都 H5 化”,而是:
**原生负责核心游戏层H5 负责定制体验层。**
也就是:
- 地图、打点、GPS、指北针、HUD、规则状态机继续原生
- 结算页、文创详情、拍照任务、语音留言、小游戏、品牌包装页交给 H5
---
## 2. 适合 H5 化的内容
当前最适合 H5 承接的是:
- 结算页
- 打点后的定制**详情页/互动页**
- 文创详情页
- 活动品牌页
- 富图文任务页
- 拍照上传 / 语音留言 / 小游戏类互动页
- 表单、问卷、抽奖、作品提交页
不建议 H5 化的部分:
- 地图主界面
- 打点逻辑
- 自动转图
- 指北针
- HUD
- GPS / 心率等实时能力主链
- 需要强实时状态同步的高频游戏弹层
- 游戏中的即时原生内容弹窗
一句话:
**核心实时游戏层保留原生,变化快的定制内容层交给 H5。**
---
## 2.1 当前阶段的定案
经过真机验证,当前项目已经明确:
- 小程序 `web-view` 在企业主体环境下可以正常打开
- 但它不适合作为“原生弹窗里的局部 H5 内容区”使用
- 真机上更接近整页原生容器,局部裁切、壳子覆盖、原生关闭按钮都不稳定
因此当前正式定案为:
- **打点后的即时内容:原生内容卡**
- **H5只作为详情页 / 互动任务页 / 全屏结果页**
也就是说:
- `content popup` 继续原生
- 原生内容卡上提供 `查看详情`
-`查看详情` 后再进入 H5
- H5 打不开时,原生内容卡继续兜底
---
## 3. 总体架构
推荐分成三层:
### 3.1 原生层
职责:
- 地图与渲染
- GPS / 指北针 / 自动转图
- 打点状态机
- HUD
- 心率 / telemetry
- 原生内容卡兜底
- 原生结果页兜底
- 核心状态与本地缓存
### 3.2 H5 体验层
职责:
- 定制内容展示
- 品牌包装
- 富交互任务
- 定制结算页
- 富图文与媒体内容
### 3.3 Bridge 层
职责:
- 原生向 H5 注入上下文
- H5 向原生请求能力
- H5 把结果回传原生
---
## 4. 两种 H5 页面类型
### 4.1 Content Experience Page
用于游戏中途的**详情体验页**或**互动任务页**。
典型场景:
- 控制点打卡后点击 `查看详情`
- 控制点点击后进入图文详情页
- 拍照上传任务
- 语音留言任务
- 小游戏互动页
- 问答/表单类互动页
### 4.2 Result Experience Page
用于游戏结束后的定制结算页。
典型场景:
- 活动定制结算
- 奖章 / 解锁内容
- 排名 / 分享
- 作品提交
- 品牌化结束页
---
## 5. 原生兜底原则
这是最重要的约束。
### 原则 1核心流程先在原生完成
例如:
- 打点成功必须先由原生确认
- 比赛结束必须先由原生确认
- H5 只是附加体验,不拥有核心状态
- 原生内容卡必须先可独立工作
### 原则 2H5 打不开时回退原生
如果:
- 网络失败
- H5 地址失效
- 加载超时
- Bridge 初始化失败
则直接回退:
- 原生内容卡
- 原生结果页
### 原则 3H5 不控制比赛状态
H5 可以展示、收集信息、提交任务结果。
但不能决定:
- 是否打卡成功
- 是否比赛完成
- 是否跳点成功
这些只能由原生控制。
### 原则 4H5 是可选增强,不是主流程依赖
即使 H5 没有打开:
- 游戏仍应可继续
- 用户仍能完成路线
- 用户仍能看到最小内容或最小结果
---
## 6. 配置模型建议
后续建议对“内容体验”和“结果体验”都支持两种类型:
- `native`
- `h5`
### 6.1 内容体验配置示例
```json
{
"contentExperience": {
"type": "h5",
"url": "https://example.com/content/control-3",
"bridge": "content-v1",
"fallback": "native"
}
}
```
这个字段当前应理解为:
- `contentExperience` = 原生内容卡上的 H5 **详情/互动扩展**
- 不是直接顶替原生内容弹窗
或:
```json
{
"contentExperience": {
"type": "native"
}
}
```
### 6.2 结果页配置示例
```json
{
"resultExperience": {
"type": "h5",
"url": "https://example.com/result/score-o",
"bridge": "result-v1",
"fallback": "native"
}
}
```
### 6.3 建议扩展字段
后续还可以逐步加入:
- `template`
- `theme`
- `timeoutMs`
- `allowClose`
- `prefetch`
- `requiresNetwork`
---
## 7. 内容页与结果页的推荐职责
### 原生最小内容卡
负责:
- 最小图文说明
- 最小点击查看
- 自动弹出兜底
### H5 内容页
负责:
- 强样式定制
- 多媒体内容
- 任务型互动
- 客户活动包装
### 原生最小结果页
负责:
- 结果一定可见
- 成绩一定可回顾
- 无网络也能展示基础结果
### H5 结果页
负责:
- 品牌化包装
- 排名/分享
- 作品提交
- 奖章、解锁、收集册
---
## 8. 性能与体验要求
H5 接入时必须注意:
- 不阻塞原生主流程
- 不把高频实时状态强行桥接到 H5
- 不在地图进行中频繁开重页面
- 低端机上优先简化交互和媒体资源
推荐策略:
- 内容详情页可以 H5
- 地图中高频反馈继续原生
- 结算增强页可以 H5
- 结果最小摘要必须原生兜底
---
## 9. 当前建议实施顺序
### 第一步
先实现:
- 原生最小兜底内容卡
- 原生最小结果页
### 第二步
新增一个通用 H5 容器页,用于承接:
- 内容页
- 结果页
### 第三步
定义 Bridge 协议,并先支持最核心动作:
- 关闭
- 获取上下文
- 拍照
- 录音
- 提交结果
### 第四步
再让配置决定:
- 当前活动走原生
- 还是走 H5
### 第五步
最后再逐步扩到:
- 上传能力
- 分享能力
- 小游戏任务
- 作品提交
---
## 10. 下一步建议
当前最适合的下一步不是直接写复杂 H5 页面,而是:
1. 先定义原生与 H5 的统一入口模型
2. 先把 Bridge 协议做小而稳
3. 先做一个通用 H5 容器页
4. 先让一个简单内容页或一个简单结果页跑通
---
## 11. 当前建议结论
最稳的方案不是“把定制内容都做成 H5”而是
**原生保底H5 承接定制体验。**
这样既能支持客户高频变化需求,也不会破坏核心游戏体验。
---
## 12. 当前主体能力约束补充
最近实际排查已经确认:
- 当前最初使用的是**个人主体**小程序
在这个前提下,`web-view` 能力可能直接受到限制。
这意味着:
- H5 页面本身可在浏览器打开
- 小程序里仍然可能无法通过 `web-view` 打开
因此当前 H5 接入方案需要增加一个现实前提:
### 12.1 个人主体下
可以先做:
- 容器页
- Bridge 协议
- 配置结构
- 原生兜底逻辑
但不要指望所有 H5 内容页都能在当前环境稳定跑通。
### 12.2 企业主体下
企业主体审核通过后,再优先回归:
- 最小 `web-view` 测试页
- 内容体验页 H5
- 结果页 H5
也就是说:
当前 H5 方案仍然成立,但在企业主体生效前,应按“预留 + 待验证”看待。
详细说明见:
- [platform-capability-notes.md](D:/dev/cmr-mini/doc/debug/平台能力说明.md)

View File

@@ -1,233 +0,0 @@
# Experience Shell 方案
本文档用于定义小程序中 H5 定制内容的承载方式。目标不是把 H5 做成真正的同页弹窗,而是做成:
- 独立页面路由
- 原生壳子控制外观
- `web-view` 只负责内容区
这样既保留了 H5 的定制能力,也能让用户感受更接近“弹窗”或“抽屉”。
---
## 1. 设计目标
当前 H5 内容页已经能打开,但整页全屏切换比较生硬,用户体验不够好。
新的 `experience-shell` 目标是:
- 视觉上像弹窗
- 保持原生关闭、回退、失败兜底逻辑
- 不把地图主页面和 `web-view` 强绑在一起
- 为后续结果页 H5、文创内容 H5 复用
---
## 2. 核心原则
### 2.1 不做真正同页 H5 弹窗
微信小程序里的 `web-view` 更适合放在独立页面中承载。
不要尝试把 `web-view` 直接叠在地图页上方做真弹窗,否则后续很容易遇到:
- 层级冲突
- 手势冲突
- iOS / Android 表现不一致
- 遮罩和关闭逻辑变脏
### 2.2 原生壳子 + H5 内容区
最终结构应该是:
- 原生遮罩
- 原生标题栏
- 原生关闭按钮
- `web-view` 内容区
也就是:
```text
experience-shell
├─ backdrop
├─ native header
└─ web-view body
```
---
## 3. 支持的展示方式
第一阶段只支持 3 种:
- `sheet`
- `dialog`
- `fullscreen`
### 3.1 `sheet`
适合:
- 打点后的文创内容
- 拍照任务
- 轻互动内容
视觉:
- 自底部升起
- 圆角卡片
- 半透明暗背景
### 3.2 `dialog`
适合:
- 结果页
- 中短内容
- 重要说明
视觉:
- 居中大卡片
- 更聚焦
### 3.3 `fullscreen`
适合:
- 长内容
- 强定制专题页
- 复杂表单/小游戏
---
## 4. 配置结构
H5 内容配置建议支持:
```json
{
"type": "h5",
"url": "https://example.com/content/control-1",
"bridge": "content-v1",
"presentation": "sheet"
}
```
字段说明:
- `type`
当前支持 `native` / `h5`
- `url`
H5 页面地址
- `bridge`
bridge 版本
- `presentation`
展示方式,支持:
- `sheet`
- `dialog`
- `fullscreen`
默认值建议:
- 内容体验默认 `sheet`
- 结果页默认 `dialog`
---
## 5. 原生壳子职责
原生壳子负责:
- 遮罩
- 标题、副标题
- 关闭按钮
- 页面进入/退出动画
- H5 打开失败回退
原生壳子不负责:
- H5 页面内部业务逻辑
- H5 具体视觉排版
---
## 6. 关闭与回退逻辑
### 6.1 原生关闭
原生必须始终支持:
- 右上/头部关闭
- 返回键关闭
- 失败时自动关闭并回退
### 6.2 H5 请求关闭
H5 可以通过 bridge 发:
- `close`
然后由原生统一关闭壳子页。
### 6.3 H5 失败回退
如果出现:
- URL 无效
- 页面打不开
- bridge 初始化失败
统一回退到:
- 原生内容卡
- 原生结果页
---
## 7. 动画建议
### `sheet`
- 遮罩淡入
- 面板自下而上出现
### `dialog`
- 遮罩淡入
- 面板轻微放大进入
### `lite`
在低端机或 `lite` 模式下:
- 只保留 opacity
- 降低位移动画强度
---
## 8. 推荐接入顺序
### 第一阶段
- 先把当前 `experience-webview` 升级成 shell
- 先支持 `sheet`
- 先接 `content-v1`
### 第二阶段
-`dialog`
- 结果页 H5 开始复用壳子
### 第三阶段
- 主题样式可配置
- 过场动画接入
---
## 9. 一句话结论
小程序里的 H5 不应该直接作为“生硬全页”使用,也不应该强行做成“地图页上的真弹窗”。
最稳的方案是:
**独立页面承载,但由原生壳子把它做成 `sheet / dialog / fullscreen` 三种体验形态。**

View File

@@ -1,329 +0,0 @@
# 游戏中文创体验层方案
## 1. 目标
为游戏过程中的文创内容建立一层独立承载能力,不把内容弹窗、图文卡片、讲解信息散落在:
- 规则层
- 页面层
- HUD 逻辑
- 反馈层
这层的目标是:
- 在正确时机触发内容体验
- 统一内容展示方式
- 可配置、可复用、可扩展
- 不破坏当前地图与规则主链
一句话:
**把“中途内容体验”从临时弹窗提升为正式能力层。**
---
## 2. 当前现状
当前项目已经具备一部分基础:
- `control.displayContent`
- `UiEffectDirector.showContentCard(...)`
- 页面层已有 `contentCardVisible / contentCardTitle / contentCardBody`
- 打点完成后可展示内容卡
这说明:
- 内容展示能力已经有雏形
- 但触发方式还偏单一
- 内容形式也还比较轻
- 还没有形成正式的“内容体验层”模型
---
## 3. 设计原则
### 3.1 内容体验不等于短反馈
短反馈仍然属于:
- 音效
- 震动
- HUD 提示
- 地图 pulse
文创体验属于更重的一层,应与 `FeedbackDirector` 区分。
### 3.2 内容体验不直接写死在规则里
规则层只负责:
- 是否触发
- 触发什么体验条目
规则层不负责:
- 页面怎么弹
- 卡片长什么样
- 是否带图片、音频、讲解按钮
### 3.3 内容体验必须配置驱动
以后不同活动、不同地图、不同玩法需要不同内容。
所以这层必须可配置:
- 哪个点触发
- 何时触发
- 弹什么
- 是否只弹一次
- 优先级如何
---
## 4. 建议的新层级
建议增加一层:
- `ContentExperienceLayer`
放在概念上与这些层并列:
- `MapPresentation`
- `HUD`
- `Feedback`
- `ResultScene`
职责:
- 接收体验触发
- 管理当前激活内容项
- 控制展示与关闭
- 向页面层输出当前体验模型
---
## 5. 建议的数据模型
### 5.1 ExperienceEntry
```ts
type ExperienceTrigger =
| 'control_completed'
| 'zone_entered'
| 'session_finished'
| 'manual'
type ExperienceDisplayMode =
| 'content-card'
| 'full-panel'
| 'audio-guide'
| 'unlock-card'
interface ExperienceEntry {
id: string
trigger: ExperienceTrigger
controlId?: string
zoneId?: string
title: string
body: string
imageRef?: string
audioRef?: string
displayMode: ExperienceDisplayMode
once: boolean
priority: number
}
```
### 5.2 ExperienceRuntimeState
```ts
interface ExperienceRuntimeState {
activeEntryId: string | null
dismissedEntryIds: string[]
consumedEntryIds: string[]
}
```
---
## 6. 配置建议
建议在配置中增加一段:
```json
{
"resources": {
"contentEntries": {
"cp-3-story": {
"title": "校史地标",
"body": "这里是校园历史演变的重要节点。",
"imageRef": "content/campus-history-01.png",
"displayMode": "content-card"
}
}
},
"game": {
"experience": {
"entries": [
{
"id": "cp-3-story",
"trigger": "control_completed",
"controlId": "control-3",
"once": true,
"priority": 10
}
]
}
}
}
```
这意味着:
- 资源层管理内容资源
- 玩法配置决定何时触发
---
## 7. 触发来源
第一阶段建议支持 3 种触发:
### 7.1 打点完成触发
最适合当前项目,价值最高。
例如:
- 完成某个控制点后弹一张文创卡
- 开始点完成后弹赛事导览卡
- 终点完成后弹纪念卡
### 7.2 区域进入触发
适合后续:
- 地标介绍
- 迷雾探索
- 特定区域故事点
### 7.3 结算后解锁触发
适合后续与结算页联动:
- 收藏卡
- 奖章
- 文创奖励
---
## 8. 页面表现建议
第一阶段先做最小闭环,不追求复杂视觉。
### 8.1 第一阶段
支持:
- 当前已有的 `content-card`
- 标题
- 正文
- 关闭
### 8.2 第二阶段
再支持:
- 图片
- 按钮
- 章节式展开
- 音频讲解
---
## 9. 与当前架构的关系
### 规则层
负责:
- 触发某条体验事件
不负责:
- 具体展示细节
### Feedback
继续负责:
- 短反馈
- 动效
- 音效
### ContentExperienceLayer
负责:
- 中等时长的信息体验
### 页面层
负责:
- 渲染当前体验模型
---
## 10. 第一阶段最小实施范围
建议第一阶段只做:
1. `control_completed -> experience entry`
2. `content-card` 展示
3. `once` 语义
4. 手动关闭
5. 配置驱动
不要一上来做:
- 图片轮播
- 视频
- 复杂音频控制
- 多层交互
---
## 11. 推荐实施顺序
1. 定义 `ExperienceEntry`
2. 在配置解析层接 `game.experience.entries`
3. 在规则完成事件里派发体验触发
4. MapEngine 增加体验状态承载
5. 页面层继续复用当前 `content-card`
6. 再逐步升级 UI
---
## 12. 长期价值
这层建好后,后续可以自然承接:
- 文创卡片
- 地标解说
- 解锁收藏
- 故事节点
- 活动内品牌内容
它不只服务当前顺序赛/积分赛,而是服务整条产品体验链。
---
## 13. 结论
当前最正确的方向不是继续在页面里零散补内容弹窗,而是:
**把游戏中途的文创与故事体验正式抽成一层独立的 `ContentExperienceLayer`。**
第一阶段先用“控制点完成触发内容卡”跑通最小闭环,后面再逐步扩成完整体验系统。

View File

@@ -1,294 +0,0 @@
# 游戏结算层方案
## 1. 目标
为游戏结束后的结果展示建立独立结算层,不把结算逻辑散落在:
- 规则层
- HUD
- 顶部提示
- 页面临时弹窗
目标是:
- 统一承接结束态
- 展示成绩与摘要信息
- 支撑不同玩法的结算差异
- 为后续文创奖励、奖章、分享做扩展位
一句话:
**把“比赛结束后显示点什么”提升为正式的结果场景能力。**
---
## 2. 当前现状
当前项目已经有:
- `session_finished`
- `gameSessionStatus = finished`
- 基础成绩、里程、时长、心率等 telemetry
- 游戏信息面板可读取当前状态快照
但还没有正式的:
- `ResultScene`
- `SummaryModel`
- 结束后专属页面承载
---
## 3. 设计原则
### 3.1 结算不应只是提示条
结束不是一个瞬时反馈,而是一次阶段切换。
所以它需要独立层,而不是只弹一句:
- 已完成
- 已结束
### 3.2 结算要与玩法解耦
顺序赛、积分赛、后续幽灵赛、金币赛,结算内容不同。
所以应该有:
- 通用结算结构
- 玩法补充区块
### 3.3 结算要可扩
后续可能加入:
- 奖章
- 排名
- 收藏卡
- 文创解锁
- 分享图
所以一开始就要留结构。
---
## 4. 建议的新层级
建议增加:
- `ResultScene`
概念上与这些层并列:
- `MapPresentation`
- `HUD`
- `Feedback`
- `ContentExperienceLayer`
职责:
- 承接结束态
- 持有结算模型
- 控制显示与关闭
- 为玩法结果提供统一展示结构
---
## 5. 建议的数据模型
### 5.1 SummaryModel
```ts
interface ResultSummaryModel {
title: string
subtitle: string
mode: string
finished: boolean
durationMs: number
distanceMeters: number
averageSpeedKmh: number | null
calories: number | null
averageHeartRateBpm: number | null
completedCount: number
skippedCount: number
totalCount: number
score: number | null
extraRows: Array<{ label: string; value: string }>
}
```
### 5.2 ResultSceneState
```ts
interface ResultSceneState {
visible: boolean
summary: ResultSummaryModel | null
}
```
---
## 6. 第一阶段应展示什么
建议先做一版“基础结算页”,不要一上来做复杂演出。
### 通用区域
- 赛事名称
- 玩法名称
- 完成状态
- 总用时
- 总里程
- 平均速度
- 卡路里
- 平均心率
### 玩法区域
顺序赛:
- 完成控制点数量
- 跳过点数量
- 总控制点数量
积分赛:
- 总得分
- 已完成点数
- 未完成点数
### 操作区
- 返回地图
- 关闭
- 后续再加重开 / 分享
---
## 7. 配置建议
建议在配置中预留:
```json
{
"game": {
"result": {
"enabled": true,
"showTelemetry": true,
"showCollectedContent": true,
"showAwards": false,
"template": "default"
}
}
}
```
这意味着:
- 结算是否启用
- 展示哪些区块
- 用哪个模板
都可配置。
---
## 8. 与当前架构的关系
### 规则层
负责:
- 产出 `session_finished`
### Telemetry
负责:
- 提供里程、速度、心率、卡路里等数据
### MapEngine
负责:
- 在结束时汇总通用结算模型
- 把结果快照送到页面层
### 页面层
负责:
- 渲染结算页
---
## 9. 第一阶段最小实施范围
建议第一阶段只做:
1. `session_finished -> ResultScene`
2. 基础 summary 展示
3. 顺序赛 / 积分赛的简单差异化字段
4. 手动关闭 / 返回地图
先不要一上来做:
- 复杂章节动画
- 排名
- 分享图生成
- 复杂奖章系统
---
## 10. 后续扩展方向
这层建好后,可以逐步加:
- 文创奖励
- 奖章 / 成就
- 排名
- 解锁内容
- 分享卡
- 二次引导
---
## 11. 推荐实施顺序
1. 定义 `ResultSummaryModel`
2.`MapEngine` 汇总结束快照
3. 页面层增加结果面板
4. 顺序赛 / 积分赛各补一组玩法字段
5. 再考虑动画、奖励和品牌内容
---
## 12. 与文创体验层的配合
后续建议:
- 文创体验层
- 承接“游戏中途”的体验
- 结算层
- 承接“游戏结束后”的体验
二者不要混。
如果后续结算后要解锁文创卡片,可以由:
- `ResultScene`
- 显示结算
- 结算完成后
- 再触发内容奖励卡
---
## 13. 结论
当前最合适的方向不是继续在结束时零散堆文案,而是:
**正式增加一层 `ResultScene`,承接顺序赛、积分赛以及未来更多玩法的统一结算体验。**
第一阶段先做基础 summary后续再逐步接入文创奖励、奖章、排名和过场动画。

View File

@@ -1,51 +0,0 @@
# CMR-Mini 项目深度分析报告 (GeminiAnalysis.md)
## 1. 项目定位与核心愿景
**CMR-Mini** 是一个运行在微信小程序环境中的高性能**定向越野 (Orienteering)** 实时竞赛/练习引擎。其核心竞争力在于通过自研的 **WebGL 地图渲染管线** 提供流畅的地图交互并结合高精度多传感器融合技术GPS、罗盘、心率、加速度计等实现精准的运动反馈。
## 2. 核心系统架构分析
### 2.1 地图渲染引擎 (Map Engine)
* **渲染技术**:采用 `Single WebGL Pipeline`。相比微信原生地图组件具有更高的定制化能力特别是在“Heading-Up”朝向朝上模式下的性能表现。
* **瓦片管理**:通过 `TileStore` 实现三级缓存(内存 -> 磁盘 -> 网络),并支持 `tilePersistentCache`
* **投影逻辑**:采用 `WGS84 -> WorldTile -> Camera -> Screen` 的标准 GIS 变换链,能够精准处理地理坐标到屏幕像素的映射。
### 2.2 传感器融合系统 (Sensor System)
* **CompassHeadingController**:核心逻辑在于罗盘数据 (`wx.onCompassChange`) 与设备姿态 (`wx.onDeviceMotionChange`) 的协同。
* **LocationController**:支持真实 GPS 数据与 Mock 模拟器(通过 WebSocket 连接 `mock-gps-sim` 工具)的无缝切换。
* **TelemetryRuntime**:实现了运动参数的实时计算,包括速度、距离目标点距离、心率分区等指标。
### 2.3 游戏逻辑与规则 (Game Logic)
* **GameRuntime**:驱动对局状态机,支持“顺序赛 (Classic Sequential)”与“积分赛 (Score-O)”。
* **PunchPolicy**:实现了自动进入检查点范围触发、手动打点、跳过点位等业务逻辑。
## 3. 指北针 (Compass) 平滑度瓶颈分析
根据目前的实现,指北针的卡顿感主要源于以下三个层面:
1. **采样频率与插值逻辑**
* 目前使用 `interpolateHeadingDeg` 进行线性差值,且 `ABSOLUTE_HEADING_CORRECTION` 为固定系数 (0.44)。这种静态系数在“静态微调”时显得不够敏锐,在“快速旋转”时又显得滞后。
2. **Android/iOS 差异化丢帧**
* Android 传感器回调频率不稳定。
* 逻辑中对 `direction` 进行了严格的数值有效性判断,若系统由于硬件抖动返回短时异常值,会导致视觉上的“跳帧”。
3. **UI 同步周期限制**
* `MapEngine``UI_SYNC_INTERVAL_MS` 设置为 80ms这意味着视觉反馈的最高帧率仅为 12.5Hz,远低于屏幕刷新率,导致指针转动不够丝滑。
## 4. 优化技术路线建议
### 4.1 引入指数加权移动平均 (EWMA) 的动态系数
建议根据旋转角速度动态调整平滑系数。当检测到瞬时角位移较大时,降低平滑度以追求响应速度;当位移较小时,增加平滑度以过滤手抖带来的噪声。
### 4.2 视觉平滑:使用 CSS Transform 或 WebGL 帧间补偿
目前数据是由控制器下发到 UI 的。建议:
* **方案 A (推荐)**:在 UI 层(`.wxml`/`.wxss`)利用 `transition: transform 0.1s linear;` 实现视觉层面的自动补帧。
* **方案 B**:在 WebGL 渲染循环内进行帧间插值,将数据的 12.5Hz 提升到 渲染循环的 60Hz。
### 4.3 预测与死区 (Dead-zone) 过滤
`CompassHeadingController` 中加入微小位移的死区过滤逻辑,避免由于硬件高频微小抖动导致的视图高频重绘,降低系统功耗的同时提升视觉稳定性。
## 5. 结论
CMR-Mini 已经建立了一个非常坚实的专业定向越野引擎基础。后续的优化重点应从“功能的实现”转向“交互的极致平滑”,特别是针对指北针这类核心导向组件,需要更精细化的信号处理策略。
---
*Generated by Gemini CLI Analysis Tool*

View File

@@ -1,210 +0,0 @@
# 临时玩法讨论记录
本文档用于临时记录以下讨论内容:
- 贪吃蛇式玩法是否适配当前架构
- 超级玛丽拾金币式玩法是否适配当前架构
- 这些玩法是否需要大改现有系统
当前结论仅用于阶段讨论,不作为正式设计冻结文档。
---
## 1. 结论
当前这两类玩法都适合现有架构。
- `贪吃蛇式玩法`:适合
- `区域拾金币玩法`:适合
- 二者都不需要推翻现有主架构
- 主要工作会集中在:
- 新的 `RulePlugin`
- 新的 `modeState`
- 新的 `map/hud presentation`
- 少量内容模型扩展
也就是说,这两类玩法更像是在现有底座上继续长新玩法,而不是重做底层。
---
## 2. 为什么适合当前架构
当前系统已经拆出了以下关键层:
- 地图引擎
- 规则引擎
- telemetry 信息层
- map / hud presentation
- feedback 反馈层
- 真实 / 模拟传感输入
这意味着:
- 地图只负责显示和交互能力
- 规则层只负责玩法推进
- telemetry 只负责通用过程信息
- feedback 只负责声音、震动、动效等效果消费
因此后续新增玩法,原则上主要是“新增规则和表现”,而不是“重写地图页”。
---
## 3. 贪吃蛇式玩法分析
### 3.1 玩法本质
这类玩法通常包含:
- 玩家位置持续更新
- 轨迹形成蛇身
- 尾巴按规则增长或收缩
- 撞到自己、奖励点、危险区后触发状态变化
### 3.2 适配当前架构的原因
当前架构已经具备:
- 持续 GPS 输入
- 持续 telemetry 更新
- 规则事件驱动推进
- 地图轨迹绘制能力
- 统一反馈系统
因此它天然可以承载:
- 尾巴增长
- 尾巴裁切
- 自碰撞
- 收集奖励
- 危险区域
### 3.3 真正需要新增的内容
主要是玩法私有状态,而不是底层推翻:
- `snakeBody`
- `tailLength`
- `tailWindow`
- `collisionState`
- `collectibleState`
这些都应放入该玩法自己的 `modeState`
### 3.4 对当前架构的压力点
这类玩法会推动当前系统继续增强:
- `modeState` 承载更复杂连续状态
- `MapPresentation` 支持蛇身/危险区/奖励点等更多图元
- 规则层处理持续碰撞判定
但这些属于增强,不属于重构。
---
## 4. 区域拾金币玩法分析
### 4.1 玩法本质
这类玩法通常包含:
- 玩家在某片区域内自由移动
- 经过或进入范围后收集金币
- 有时间限制、连击或区域目标
- 可附带终点或出口点
### 4.2 适配当前架构的原因
它本质上非常接近:
- 自由收集
- 多目标高亮
- 局部 HUD 提示
而这些当前在 `score-o` 里已经有相当基础。
因此它可以看作:
- `score-o` 的泛化版
- 或“自由收集类玩法”的一个子类
### 4.3 真正需要新增的内容
这类玩法一般需要:
- 新点位类型:`coin / pickup / bonus`
- 新 HUD 信息:已收集数、剩余金币、区域完成度
- 新表现:金币图标、收集动效、区域边界
### 4.4 对当前架构的压力点
这类玩法比蛇尾玩法对底座压力更小。
它主要会推动:
- 内容模型从“控制点”继续泛化
- `MapPresentation` 支持更多点位类型
- HUD 能容纳玩法专属信息
但依然不需要大改主架构。
---
## 5. 需要补强的底座点
如果未来真的开发这两类玩法,最值得继续补强的是:
- 更明确的 `modeState` 规范
- 更强的 `MapPresentation`
- 更通用的内容模型
- 更清晰的玩法事件字典
建议后续逐步支持的通用对象类型:
- `control`
- `collectible`
- `bonus`
- `hazard`
- `trigger`
- `zone`
- `exit`
建议后续逐步支持的通用事件:
- `item_collected`
- `zone_entered`
- `zone_left`
- `self_collision`
- `combo_started`
- `combo_broken`
- `area_cleared`
---
## 6. 当前判断标准
如果未来实现这些玩法时出现以下现象,说明架构边界可能需要重审:
- 必须大改 `MapEngine`
- 必须大改 `TelemetryRuntime`
- 必须让渲染器自己猜玩法规则
- 必须把玩法私有状态塞进全局 telemetry
如果没有出现这些情况,而主要只是新增:
- `RulePlugin`
- `modeState`
- `presentation`
- `feedback`
那就说明当前架构是适配的。
---
## 7. 当前阶段总判断
结论可以总结成一句话:
当前这套架构不仅适合传统定向和积分赛,也适合继续承载更游戏化的运动玩法。
像贪吃蛇式玩法和区域拾金币玩法,都更像是“新增玩法插件”,而不是“推翻现有底座”。

View File

@@ -1,571 +0,0 @@
# 传感器接入待开发方案
本文档用于整理当前项目后续可利用的传感器能力,分为:
- 微信小程序能力边界
- 原生 Flutter App 能力边界
- 两端统一的抽象建议
- 推荐落地顺序
目标不是一次性接入所有传感器而是优先接入对当前地图玩法、自动转图、运动状态识别、HUD/反馈最有价值的能力。
---
## 1. 总体原则
传感器接入必须遵守以下原则:
- 原始传感器数据只放在 `engine/sensor`
- 融合后的高级状态放在 `telemetry`
- 地图引擎只消费“对地图有意义的结果”
- 规则引擎只在玩法确实需要时消费高级状态
- 不要把原始三轴值直接喂给地图或玩法逻辑
推荐统一产出的高级状态包括:
- `movementState`
- `headingSource`
- `devicePose`
- `headingConfidence`
- `cadenceSpm`
- `motionIntensity`
---
## 2. 微信小程序可用传感器
### 2.1 当前确认可用
基于微信小程序官方 API 与项目内 typings当前可直接使用的能力包括
- `Location`
- `wx.startLocationUpdate`
- `wx.startLocationUpdateBackground`
- `wx.onLocationChange`
- `Accelerometer`
- `wx.startAccelerometer`
- `wx.onAccelerometerChange`
- `Compass`
- `wx.startCompass`
- `wx.onCompassChange`
- `DeviceMotion`
- `wx.startDeviceMotionListening`
- `wx.onDeviceMotionChange`
- `Gyroscope`
- `wx.startGyroscope`
- `wx.onGyroscopeChange`
- `WeRunData`
- `wx.getWeRunData`
### 2.2 当前确认不可直接获得的原始能力
微信小程序没有直接开放以下原始传感器接口:
- `Gravity`
- `Linear Acceleration`
- `Rotation Vector`
- `Geomagnetic Field` 原始三轴
- `Proximity`
- 原始 `Step Counter`
说明:
- `wx.getWeRunData` 不是实时步数传感器流
- 它更适合中长期统计,不适合实时地图玩法
---
## 3. 微信小程序推荐应用方案
### 3.1 第一优先级
#### A. Gyroscope
用途:
- 提升自动转图平滑度
- 降低跑步中手机晃动导致的朝向抖动
- 增强指北针和地图旋转过渡体验
推荐产出:
- `turnRate`
- `headingSmoothFactor`
- `headingStability`
#### B. DeviceMotion
用途:
- 识别手机姿态
- 判断设备是竖持、倾斜还是接近平放
- 配合 gyro 增强朝向可信度
推荐产出:
- `devicePose`
- `orientationConfidence`
- `tiltState`
#### C. Compass
用途:
- 静止或低速时,作为持机朝向基准
- 指北针展示
推荐角色:
- 继续保留
- 作为“静止朝向输入”
- 不再单独承担跑动中的全部朝向逻辑
### 3.2 第二优先级
#### D. Accelerometer
用途:
- 辅助识别是否真的在移动
- 识别急停、抖动、运动强度变化
推荐产出:
- `motionIntensity`
- `movementConfidence`
说明:
- 不建议直接用原始加速度驱动地图行为
- 应和 GPS、gyro 一起融合使用
#### E. Location
用途:
- 当前定位
- 轨迹
- 目标距离
- movement heading
- 速度估计
推荐角色:
- 继续作为地图和玩法核心输入
- 后续更多与 gyro / accelerometer 配合使用
### 3.3 当前不建议优先投入
#### F. WeRunData
用途:
- 日级步数统计
- 长周期运动数据
当前不建议投入原因:
- 不是实时传感器
- 不适合当前地图实时玩法主链
---
## 4. 微信小程序推荐先产出的高级状态
### A. movementState
建议值:
- `idle`
- `walk`
- `run`
来源:
- GPS 速度
- accelerometer
- device motion
### B. headingSource
建议值:
- `sensor`
- `blended`
- `movement`
来源:
- compass
- gyroscope
- GPS track
### C. devicePose
建议值:
- `upright`
- `tilted`
- `flat`
来源:
- device motion
- gyroscope
### D. headingConfidence
建议值:
- `low`
- `medium`
- `high`
来源:
- compass
- gyroscope
- GPS 精度
- movement heading 是否可靠
---
## 5. 原生 Flutter App 可用传感器
原生 Flutter App 的能力边界明显更强,后续如果迁移或并行开发,可直接利用系统原始传感器。
### 5.1 可考虑直接接入
- `Location / GNSS`
- `Compass / Magnetometer`
- `Gyroscope`
- `Accelerometer`
- `Linear Acceleration`
- `Gravity`
- `Rotation Vector`
- `Step Counter / Pedometer`
- `Barometer`(如设备支持)
- `Proximity`(视玩法需求)
说明:
- Flutter 本身一般通过插件获取这些能力
- 具体以 Android / iOS 可用性差异为准
### 5.2 Flutter 相对小程序的主要优势
- 能直接拿到更完整的原始传感器矩阵
- 更适合做高质量姿态融合
- 更适合做步数、步频、跑动状态识别
- 可更深度控制后台行为和采样频率
---
## 6. Flutter 推荐应用方案
### 6.1 第一优先级
#### A. Rotation Vector
用途:
- 作为地图自动转图的高质量姿态输入
- 优于单纯磁力计 + 罗盘
推荐产出:
- `deviceHeadingDeg`
- `devicePose`
- `headingConfidence`
#### B. Gyroscope
用途:
- 旋转平滑
- 快速转身检测
- 姿态短时补偿
#### C. Linear Acceleration
用途:
- 识别运动状态
- 急停、冲刺、抖动判定
推荐产出:
- `motionIntensity`
- `movementState`
#### D. Step Counter
用途:
- 实时步数
- 步频
- 跑步状态识别
- 训练/卡路里模型增强
推荐产出:
- `stepCount`
- `cadenceSpm`
- `movementState`
### 6.2 第二优先级
#### E. Gravity
用途:
- 持机姿态识别
- 平放/竖持策略切换
#### F. Magnetometer
用途:
- 作为姿态融合底层输入
建议:
- 不建议单独直接映射到业务逻辑
- 主要与 rotation vector / gyro 融合
#### G. Barometer
用途:
- 海拔变化
- 爬升检测
适合:
- 户外定向训练
- 赛后统计
---
## 7. Flutter 推荐先产出的高级状态
### A. movementState
建议值:
- `idle`
- `walk`
- `run`
- `sprint`
来源:
- GPS
- step counter
- linear acceleration
### B. cadenceSpm
用途:
- 训练分析
- 卡路里估算增强
- 玩法资源逻辑
### C. devicePose
建议值:
- `upright`
- `tilted`
- `flat`
### D. headingSource
建议值:
- `sensor`
- `blended`
- `movement`
### E. headingConfidence
建议值:
- `low`
- `medium`
- `high`
### F. elevationTrend
建议值:
- `flat`
- `ascending`
- `descending`
来源:
- barometer
- GPS altitude
---
## 8. 两端统一抽象建议
尽管两端可用传感器不同,但建议统一抽象,不让上层感知平台差异。
### 8.1 原始层
放在:
- `engine/sensor`
职责:
- 读取平台原始传感器
- 做最基础的节流、归一化、权限处理
### 8.2 融合层
放在:
- `telemetry`
职责:
- 生成统一高级状态
- 对外屏蔽平台差异
建议统一输出:
- `movementState`
- `devicePose`
- `headingSource`
- `headingConfidence`
- `cadenceSpm`
- `motionIntensity`
### 8.3 消费层
#### 地图引擎消费
- `headingSource`
- `devicePose`
- `headingConfidence`
#### 规则层消费
- `movementState`
- `cadenceSpm`
- `motionIntensity`
#### HUD / Feedback 消费
- `movementState`
- `cadenceSpm`
- 心率 / 卡路里 / 训练强度
---
## 9. 推荐接入顺序
### 微信小程序第一阶段
先接:
- `Gyroscope`
- `DeviceMotion`
目标:
- 提升自动转图质量
- 产出更稳定的姿态与朝向可信度
### 微信小程序第二阶段
再接:
- `Accelerometer`
目标:
- 提升 movement state 识别
### Flutter 第一阶段
先接:
- `Rotation Vector`
- `Gyroscope`
- `Linear Acceleration`
目标:
- 直接建立高质量朝向与运动状态底座
### Flutter 第二阶段
再接:
- `Step Counter`
- `Gravity`
目标:
- 增强运动统计与姿态判断
---
## 10. 当前最值得优先投入的方向
如果只从当前项目收益看,最值得优先做的是:
### 微信小程序
- `Gyroscope`
- `DeviceMotion`
### Flutter
- `Rotation Vector`
- `Gyroscope`
- `Linear Acceleration`
原因:
- 这些能力最直接影响地图体验
- 最贴近当前自动转图、前进方向、姿态识别需求
- 复用价值高
---
## 11. 一句话结论
### 微信小程序
可用传感器有限,但足够继续做:
- 更稳的自动转图
- 更好的朝向平滑
- 更好的运动状态识别
最值得优先接入的是:
- `Gyroscope`
- `DeviceMotion`
- `Accelerometer`
### 原生 Flutter App
可利用的原始传感器更完整,建议未来重点发挥:
- `Rotation Vector`
- `Gyroscope`
- `Linear Acceleration`
- `Step Counter`
两端都应遵守同一个原则:
**原始传感器进 `engine/sensor`,高级状态进 `telemetry`,上层只消费统一状态。**

View File

@@ -1,331 +0,0 @@
# 多人模拟器改造待开发文档
本文档用于记录“公网模拟器支持多人开发/多人联调”的待开发方案。
当前仅作为设计与排期参考,不代表已经进入实现阶段。
---
## 1. 目标
当前外部模拟器已经支持:
- mock GPS
- mock heart rate
- 公网 WebSocket 接入
但当前模型更接近“单会话广播”。
如果多人同时开发或联调,容易出现:
- A 的 GPS 影响 B 的小程序
- C 的心率影响 D 的 HUD
- 同一公网模拟器服务缺乏隔离能力
因此需要把模拟器体系升级成:
- 多房间
- 多身份
- 按目标订阅
最终目标是:
- 多人共用同一个公网模拟服务
- 各自的数据流互不干扰
- 为未来多人玩法联调留好底座
---
## 2. 当前问题本质
当前模拟器通信模型更像:
- 一个 WebSocket 服务
- 模拟器侧发布消息
- 小程序侧直接接收
这个模型在单人开发时足够。
但在多人开发时,缺少以下维度:
- `room`
- `actorId`
- `channel`
没有这些维度时,服务端无法做消息隔离与路由控制。
---
## 3. 建议的第一阶段方案
第一阶段不追求复杂功能,只解决“多人不串流”的核心问题。
### 3.1 核心模型
为所有模拟消息增加 3 个维度:
- `room`
- `actorId`
- `channel`
含义如下:
- `room`
表示一个独立测试空间
- `actorId`
表示房间中的一个具体模拟源
- `channel`
表示消息类型,例如 `gps``heart_rate`
### 3.2 第一阶段目标
第一阶段完成后应满足:
- A 和 B 可以共用同一个公网模拟器服务
- A 的小程序只接 A 的数据
- B 的小程序只接 B 的数据
- GPS 与心率都能隔离
---
## 4. 推荐协议
### 4.1 模拟器注册
```json
{
"type": "register_simulator",
"room": "team-dev",
"actorId": "sim-a"
}
```
### 4.2 小程序订阅
```json
{
"type": "subscribe",
"room": "team-dev",
"actorId": "sim-a",
"channels": ["gps", "heart_rate"]
}
```
### 4.3 发布 GPS
```json
{
"type": "publish",
"room": "team-dev",
"actorId": "sim-a",
"channel": "gps",
"payload": {
"type": "mock_gps",
"timestamp": 1711267200000,
"lat": 31.2304,
"lon": 121.4737,
"accuracyMeters": 6,
"speedMps": 2.4,
"headingDeg": 135
}
}
```
### 4.4 发布心率
```json
{
"type": "publish",
"room": "team-dev",
"actorId": "sim-a",
"channel": "heart_rate",
"payload": {
"type": "mock_heart_rate",
"timestamp": 1711267200000,
"bpm": 148
}
}
```
---
## 5. 服务端改造建议
### 5.1 服务端职责
服务端从“直接广播”升级成“按订阅路由”。
它需要维护每个 WebSocket 连接的元数据:
```ts
type ClientSession = {
socketId: string
role: 'simulator' | 'app'
room: string | null
actorId: string | null
channels: Set<string>
}
```
### 5.2 路由规则
服务端收到 `publish` 后,只转发给满足以下条件的客户端:
- `role === 'app'`
- `room` 一致
- `actorId` 一致
- `channels` 包含当前 `channel`
这一步完成后,多人使用同一个公网服务时就不会互串。
### 5.3 第一阶段不需要的复杂能力
第一阶段不建议先做:
- 房间成员列表
- 在线人数统计
- 历史消息回放
- 房间消息缓存
- 权限控制
这些可以等基础隔离跑通后再扩。
---
## 6. 小程序侧改造建议
### 6.1 调试面板新增字段
建议在调试面板中新增:
- `Mock Room`
- `Mock Actor`
- `保存房间/身份`
当前 GPS 和心率已经都有 mock bridge后续建议最终共用同一个逻辑目标
- 同一个桥接地址
- 同一个 `room`
- 同一个 `actorId`
### 6.2 连接流程
小程序连上 mock bridge 后,自动发送:
```json
{
"type": "subscribe",
"room": "...",
"actorId": "...",
"channels": ["gps", "heart_rate"]
}
```
这样:
- GPS 模拟只接自己的 `gps`
- 心率模拟只接自己的 `heart_rate`
### 6.3 当前架构适配性
这项改造与当前架构是兼容的。
原因:
- 它主要发生在传感层和调试链
- 不需要改规则层
- 不需要改 telemetry 语义
- 不需要改地图引擎主逻辑
---
## 7. 外部模拟器改造建议
### 7.1 第一阶段 UI 最小改动
模拟器左侧面板新增两个输入项:
- `Room`
- `Actor ID`
后续所有 GPS / 心率发送都自动带上它们。
### 7.2 推荐默认使用方式
多人开发时建议:
- 大家共用同一个公网服务地址
- `room` 用项目或阶段名
- `actorId` 用开发者自己名字或实例名
示例:
- room: `team-dev`
- actorId: `zhangsan`
- actorId: `lisi`
### 7.3 后续可扩展能力
后续如果要继续增强,可以加:
- 房间成员列表
- 一键复制当前房间配置
- 旁观模式
- 同房间多个 actor 同时显示
- 共享路径模板
---
## 8. 为什么这项改造值得做
这不只是为了多人开发方便。
它还会直接为未来这些方向打基础:
- 多人玩法联调
- 团队对抗玩法
- 领地争夺玩法
- 多角色追逐玩法
也就是说:
今天为“多人模拟器”加的 `room + actorId + channel`,未来可以直接演进成多人玩法调试底座。
---
## 9. 建议实施顺序
### 第一阶段
- 服务端支持 `register_simulator / subscribe / publish`
- 消息带 `room + actorId + channel`
- 小程序支持订阅指定 `room + actorId`
- 外部模拟器增加 `room / actorId`
### 第二阶段
- 增加房间成员列表
- 增加在线状态
- 增加多 actor 可视化
### 第三阶段
- 接多人玩法联调
- 接角色维度
- 接会话回放与共享调试
---
## 10. 第一阶段验收标准
第一阶段完成后,至少应满足:
1. 两个人同时连同一个公网模拟器服务,不串 GPS
2. 两个人同时连同一个公网模拟器服务,不串心率
3. 同一个房间中,不同 `actorId` 可以隔离
4. 一个小程序实例可以只接收自己配置的目标流
---
## 11. 当前结论
这项改造建议先保留为待开发事项。
当前阶段不急着实现,但应作为后续多人开发与多人玩法联调的重要底座能力。

View File

@@ -1,5 +0,0 @@
结果页会根据客户的要求不停的变换,用什么方案能实现这个需求,其实其他的弹出内容也都存在这个问题,样式,内容都时根据客户需求变化的,怎样一种方案设计比较好呢?

View File

@@ -1,26 +0,0 @@
# 文档归档索引
这里存放的是已经完成历史使命的阶段性方案稿、重复模板和临时记录。
这些文档仍然保留,目的是:
- 方便回看历史讨论
- 追踪某个方案为什么被放弃
- 避免直接删除造成信息丢失
但它们已经不是当前推荐的主阅读入口。
## 归档分类
- [配置归档](/D:/dev/cmr-mini/doc/archive/config)
- [动画归档](/D:/dev/cmr-mini/doc/archive/animation)
- [体验归档](/D:/dev/cmr-mini/doc/archive/experience)
- [临时笔记归档](/D:/dev/cmr-mini/doc/archive/notes)
## 当前推荐入口
- 总索引:[doc/index.md](/D:/dev/cmr-mini/doc/文档索引.md)
- 配置索引:[config-docs-index.md](/D:/dev/cmr-mini/doc/config/配置文档索引.md)
- 动画工作流:[animation-integration-workflow.md](/D:/dev/cmr-mini/doc/animation/动画接入工作流.md)
- 混合体验架构:[hybrid-experience-architecture.md](/D:/dev/cmr-mini/doc/experience/混合体验架构方案.md)

View File

@@ -1,696 +0,0 @@
# 业务后端数据库初版方案
## 1. 目标
本文档定义本项目业务后端第一版数据库方案。
第一版目标不是一次性覆盖整个平台所有能力,而是先稳定支撑以下范围:
- 微信小程序登录与用户身份
- 用户身体资料
- 多租户与俱乐部基础隔离
- 首页卡片、赛事、地图、Event 查询
- 配置对象的版本化管理与发布
- `launch` 启动与 `session_token`
- 小程序业务层与配置驱动游戏层对接
明确不纳入第一版数据库的能力:
- 支付与分账
- UGC 审核流
- 订单退款
- 成绩回放明细
- GPS / 心率明细归档
- 复杂运营报表
这些能力建议放到后续 migration。
## 2. 核心原则
### 2.1 业务数据与配置数据分层
数据库里同时存在两类对象:
- 业务状态对象
- 配置发布对象
两者要分层,不要揉成一个“超级事件表”。
### 2.2 运行态配置仍然走发布产物
数据库管理的是编辑态与发布关系,客户端运行时最终仍然消费静态发布结果,例如:
- `manifest_url`
- `manifest_checksum_sha256`
- 地图资源路径
- Event 发布配置
不要让客户端在运行时直接拼数据库对象。
### 2.3 游戏规则不进业务表细字段
业务后端负责:
- 用户
- 赛事
- 报名
- 发布
- 启动
业务后端不应该成为所有玩法字段的解释器。玩法细节优先放在版本化 `jsonb` 内容中。
### 2.4 所有对外 ID 使用 public_id
数据库内部主键统一使用 `uuid`
对客户端暴露的对象使用稳定 `public_id`
- `user_public_id`
- `competition_public_id`
- `map_public_id`
- `event_public_id`
- `release_public_id`
- `session_public_id`
原因:
- 内外 ID 解耦
- 便于迁移
- 便于白标和多端统一
- 避免顺序 ID 暴露内部增长信息
### 2.5 token 只存 hash不存明文
以下内容不应明文入库:
- 短信验证码
- refresh token
- session token
数据库只保存 hash。
## 3. 第一版建议模块
第一版数据库建议拆成 6 组:
1. 租户与组织
2. 用户与登录
3. 配置对象与发布
4. 赛事业务对象
5. 页面与卡片
6. 启动与 session
## 4. 表清单
### 4.1 租户与组织
#### `tenants`
平台租户。
建议字段:
- `id`
- `tenant_code`
- `name`
- `status`
- `theme_jsonb`
- `settings_jsonb`
- `created_at`
- `updated_at`
说明:
- `theme_jsonb` 存白标主题
- `settings_jsonb` 存租户级开关
#### `clubs`
租户下的俱乐部或品牌实体。
建议字段:
- `id`
- `tenant_id`
- `club_code`
- `name`
- `status`
- `profile_jsonb`
- `created_at`
- `updated_at`
说明:
- 第一版建议 club 从属于 tenant
- 不建议一开始做过深的组织树
### 4.2 用户与登录
#### `app_users`
平台用户主表。
建议字段:
- `id`
- `user_public_id`
- `default_tenant_id`
- `status`
- `nickname`
- `avatar_url`
- `last_login_at`
- `created_at`
- `updated_at`
#### `login_identities`
登录身份绑定表。
建议字段:
- `id`
- `user_id`
- `identity_type`
- `provider`
- `provider_subject`
- `country_code`
- `mobile`
- `status`
- `profile_jsonb`
- `created_at`
- `updated_at`
身份示例:
- 手机号
- 微信 `openid`
- 微信 `unionid`
#### `client_devices`
客户端设备标识记录。
建议字段:
- `id`
- `device_key`
- `platform`
- `first_seen_at`
- `last_seen_at`
- `meta_jsonb`
说明:
- `device_key` 对应前端 `device_id`
#### `auth_sms_codes`
短信验证码发送与校验记录。
建议字段:
- `id`
- `scene`
- `country_code`
- `mobile`
- `client_type`
- `device_key`
- `code_hash`
- `provider_payload_jsonb`
- `expires_at`
- `cooldown_until`
- `consumed_at`
- `created_at`
#### `auth_refresh_tokens`
刷新 token 持久化表。
建议字段:
- `id`
- `user_id`
- `client_type`
- `device_key`
- `token_hash`
- `issued_at`
- `expires_at`
- `revoked_at`
- `replaced_by_token_id`
#### `user_body_profiles`
用户当前身体档案。
建议字段:
- `id`
- `user_id`
- `status`
- `completed_at`
- `current_version_id`
- `created_at`
- `updated_at`
#### `user_body_profile_versions`
身体档案历史版本。
建议字段:
- `id`
- `profile_id`
- `version_no`
- `gender`
- `birth_date`
- `height_cm`
- `weight_kg`
- `resting_heart_rate_bpm`
- `max_heart_rate_bpm`
- `created_at`
### 4.3 配置对象与发布
这一组直接对应你现有的配置驱动架构。
#### `maps` / `map_versions`
地图对象及版本。
主表管理:
- `map_public_id`
- `slug`
- `name`
- `status`
- `tenant_id`
- `current_version_id`
版本表管理:
- `version_no`
- `content_jsonb`
- `created_at`
#### `playfields` / `playfield_versions`
路线、点位、场地定义。
#### `game_modes` / `game_mode_versions`
玩法模式配置,例如:
- `classic-sequential`
- `score-o`
#### `resource_packs` / `resource_pack_versions`
资源包,例如:
- 音效
- 素材包
- UI 资源集
#### `events` / `event_versions`
Event 本身与版本。
建议:
- `events` 管对象身份
- `event_versions` 管编辑态装配结果
`event_versions` 推荐显式引用:
- `map_version_id`
- `playfield_version_id`
- `game_mode_version_id`
- `resource_pack_version_id`
同时保留:
- `content_jsonb`
这样既有强关系,又保留灵活字段。
#### `event_releases`
发布记录表。
建议字段:
- `id`
- `release_public_id`
- `event_id`
- `event_version_id`
- `release_no`
- `manifest_url`
- `manifest_checksum_sha256`
- `status`
- `published_by_user_id`
- `published_at`
- `payload_jsonb`
说明:
- 客户端主要读这张表产出的 URL 与校验值
- `events.current_release_id` 可指向当前对外生效版本
### 4.4 赛事业务对象
#### `competitions`
赛事主表。
建议字段:
- `id`
- `competition_public_id`
- `tenant_id`
- `club_id`
- `slug`
- `display_name`
- `status`
- `registration_enabled`
- `leaderboard_enabled`
- `realtime_board_enabled`
- `competition_start_at`
- `competition_end_at`
- `content_jsonb`
- `created_at`
- `updated_at`
#### `competition_events`
赛事与 Event 的关联表。
建议字段:
- `id`
- `competition_id`
- `event_id`
- `event_release_id`
- `is_default`
- `sort_order`
- `relation_status`
- `created_at`
说明:
- 支持赛事绑定多个 Event
- 支持按赛事锁定某个 release
#### `registrations`
报名记录。
建议字段:
- `id`
- `registration_public_id`
- `competition_id`
- `user_id`
- `group_id`
- `status`
- `form_payload_jsonb`
- `approved_at`
- `cancelled_at`
- `created_at`
- `updated_at`
说明:
- 第一版先不强行拆复杂参赛人结构
- `form_payload_jsonb` 足够承接早期变化
### 4.5 页面与卡片
#### `page_configs` / `page_config_versions`
H5 / 白标页面配置。
主表建议字段:
- `id`
- `tenant_id`
- `club_id`
- `page_code`
- `name`
- `status`
- `current_version_id`
- `created_at`
- `updated_at`
版本表建议字段:
- `id`
- `page_config_id`
- `version_no`
- `dsl_jsonb`
- `theme_jsonb`
- `feature_flags_jsonb`
- `status`
- `created_at`
#### `cards`
首页卡片与运营入口。
建议字段:
- `id`
- `card_public_id`
- `tenant_id`
- `club_id`
- `card_type`
- `display_name`
- `competition_id`
- `event_id`
- `map_id`
- `page_config_id`
- `html_url`
- `cover_url`
- `display_slot`
- `display_priority`
- `status`
- `starts_at`
- `ends_at`
- `created_at`
- `updated_at`
说明:
- 这张表直接支撑 `/cards`
- 允许卡片指向赛事、页面或其他目标
### 4.6 启动与 session
#### `game_sessions`
游戏启动记录。
建议字段:
- `id`
- `session_public_id`
- `tenant_id`
- `user_id`
- `competition_id`
- `registration_id`
- `event_id`
- `event_release_id`
- `launch_request_id`
- `participant_public_id`
- `device_key`
- `client_type`
- `route_code`
- `status`
- `session_token_hash`
- `session_token_expires_at`
- `realtime_endpoint`
- `realtime_token_hash`
- `launched_at`
- `started_at`
- `ended_at`
- `created_at`
- `updated_at`
说明:
- 第一版闭环到 `launch` 即可
- `session_token` 用于后续 session 相关接口开放后继续扩展
- `launch_request_id` 需要唯一,支撑幂等
## 5. 当前 API 到表的映射
### `POST /auth/sms/send`
写:
- `auth_sms_codes`
### `POST /auth/login/sms`
读写:
- `auth_sms_codes`
- `app_users`
- `login_identities`
- `user_body_profiles`
- `user_body_profile_versions`
- `auth_refresh_tokens`
### `POST /auth/login/wechat`
读写:
- `app_users`
- `login_identities`
- `user_body_profiles`
- `user_body_profile_versions`
- `auth_refresh_tokens`
### `POST /auth/refresh`
读写:
- `auth_refresh_tokens`
### `PUT /me/body-profile`
读写:
- `user_body_profiles`
- `user_body_profile_versions`
### `GET /cards`
读:
- `cards`
- 可选补充 `competitions`
### `GET /competitions/{competition_id}`
读:
- `competitions`
- `competition_events`
- `events`
- `event_releases`
- `registrations`
### `GET /events/{event_id}` / `GET /competitions/{competition_id}/events/{event_id}`
读:
- `events`
- `event_releases`
- `maps`
- `competitions`
- `registrations`
### `POST /competitions/{competition_id}/registrations`
写:
- `registrations`
### `POST /events/{event_id}/launch`
读写:
- `events`
- `event_releases`
- `registrations` 可选
- `game_sessions`
## 6. 第一版不建议做复杂拆分的地方
以下字段第一版优先用 `jsonb`,不要先做一堆子表:
- 赛事详情扩展内容
- 报名附加表单
- 页面 DSL
- theme 配置
- feature flags
- Event 覆盖项
- 配置对象的实验字段
原因很简单:
- 你现在业务和玩法都在快速变化
- 先保留灵活性比过度范式化更重要
## 7. 第一版不建议进入数据库的内容
第一版不建议落库:
- 实时网关运行态内存结构
- GPS 点逐秒明细
- 心率逐秒明细
- WebGL 渲染状态
- 设备桥接瞬时事件
这些应该仍然留在:
- `realtime-gateway`
- 对象存储
- 后续归档服务
不应直接压进业务主库。
## 8. 推荐 migration 顺序
建议按下面顺序建表:
1. 租户与组织
2. 用户与登录
3. 配置对象与版本表
4. 发布表
5. 赛事与关联
6. 页面与卡片
7. session
这样依赖关系最清晰。
## 9. 第二版可新增的模块
建议后续 migration 再补:
- `orders`
- `payment_transactions`
- `refunds`
- `ugc_assets`
- `ugc_posts`
- `ugc_reviews`
- `session_uploads`
- `session_results`
- `gps_tracks`
- `heart_rate_streams`
- `channel_entries`
- `campaigns`
## 10. 当前最适合你的起步方式
如果你现在准备开始做后端,我建议不要先写所有 API而是按这个顺序开工
1. 先建数据库与 migration
2. 先写用户、赛事、Event、launch 这 4 个核心域
3. 先让小程序跑通登录 -> 看赛事 -> launch -> 进入游戏
4. 再补报名
5. 再补页面配置、卡片、俱乐部首页
6. 支付和 UGC 放到后续版本
## 11. 一句话总结
第一版数据库应该同时支撑两件事:
- 业务闭环
- 配置发布
但不能把它们混成一套随意增长的表结构。
正确方向是:
> PostgreSQL 存业务状态 + 版本化配置对象Go API 负责查询与发布编排,客户端继续消费发布后的运行态配置。

View File

@@ -1,406 +0,0 @@
# 全局规则与配置维度清单
本文档用于定义当前系统中**跨玩法共用**的全局规则块和配置维度,作为后续所有玩法设计文档、配置文件设计、后台录入和联调的统一骨架。
目标:
- 统一“一个玩法设计文档至少要覆盖哪些公共规则块”
- 统一“一个游戏配置文件通常会包含哪些跨玩法公共配置”
- 为后续新增系统能力时提供持续维护入口
说明:
- 本文档讲的是**全局规则块**
- 它不替代具体玩法规则文档
- 它也不替代具体玩法的可配置项清单
- 推荐和 [顺序打点规则说明文档](D:/dev/cmr-mini/doc/games/顺序打点/规则说明文档.md)、[顺序打点游戏配置项](D:/dev/cmr-mini/doc/games/顺序打点/游戏配置项.md)、[配置选项字典](D:/dev/cmr-mini/doc/config/配置选项字典.md) 配合阅读
- 后续玩法设计建议统一使用 [玩法设计文档模板](D:/dev/cmr-mini/doc/gameplay/玩法设计文档模板.md)
---
## 1. 设计原则
后续每个玩法设计文档,至少建议覆盖以下三层:
1. 玩法专属规则
2. 全局规则块选型
3. 配置落点与默认值
也就是说,后续写一个新玩法时,不应该只写:
- 怎么玩
- 怎么赢
还应该明确:
- 用哪套定位点样式
- 用哪套轨迹策略
- 是否启用腿线动画
- 是否启用内容弹层
- 是否启用音效、震动和 HUD 反馈
- 哪些沿用系统默认值,哪些做玩法覆盖
---
## 2. 推荐公共骨架
当前推荐所有玩法配置继续沿用以下顶层骨架:
```json
{
"schemaVersion": "1",
"version": "2026.03.31",
"app": {},
"map": {},
"playfield": {},
"game": {},
"resources": {},
"debug": {}
}
```
其中:
- `app` 管活动级基础信息
- `map` 管地图底图和视口底座
- `playfield` 管场地对象、路线和点位内容
- `game` 管玩法规则和全局运行规则
- `resources` 管资源档和主题档
- `debug` 管调试与模拟能力
---
## 3. 全局规则块总表
| 规则块 | 建议落点 | 作用 | 是否建议每个玩法都明确 |
| --- | --- | --- | --- |
| 活动基础信息 | `app.*` | 定义活动身份、标题和语言环境 | 是 |
| 地图底座 | `map.*` | 定义地图来源、磁偏角和初始视口 | 是 |
| 场地对象 | `playfield.*` | 定义路线、控制点、对象集和内容覆盖 | 是 |
| 对局流程 | `game.session.*` | 定义开局、结束、时长和起终点要求 | 是 |
| 打点判定 | `game.punch.*` | 定义打点触发方式和判定半径 | 是 |
| 顺序推进 / 跳点 | `game.sequence.*` | 定义顺序赛推进和跳点规则 | 顺序类玩法必须明确 |
| 计分模型 | `game.scoring.*` | 定义分值模型和点位得分规则 | 有计分时必须明确 |
| 引导显示 | `game.guidance.*` | 定义腿线、目标聚焦和地图引导 | 是 |
| 可见性策略 | `game.visibility.*` | 定义开局是否隐藏对象、何时揭示全场 | 是 |
| 完赛规则 | `game.finish.*` | 定义终点生效条件和结束逻辑 | 是 |
| 内容体验 | `playfield.controlOverrides.*` | 定义点位弹窗、H5、点击内容 | 有内容玩法建议明确 |
| 点位表现 | `game.presentation.*.controls` | 定义控制点不同状态样式 | 是 |
| 腿线表现 | `game.presentation.*.legs` | 定义路线连接线样式和动效 | 有路线玩法建议明确 |
| 轨迹表现 | `game.presentation.track.*` | 定义玩家轨迹展示策略 | 是 |
| 定位点表现 | `game.presentation.gpsMarker.*` | 定义 GPS 点样式和动画 | 是 |
| 遥测参数 | `game.telemetry.*` | 定义心率等计算参数,作为活动默认值 | 用到相关能力时明确 |
| 反馈系统 | `game.feedback.*` | 定义音效、震动、UI 动效 | 是 |
| 资源档 | `resources.*` | 定义音频、主题、内容资源档 | 是 |
| 调试能力 | `debug.*` | 定义模拟输入和调试开关 | 开发阶段建议明确 |
---
## 4. 各规则块说明
### 4.1 活动基础信息
| 名称 | 字段 | 说明 | 可选项 / 取值 | 默认值 |
| --- | --- | --- | --- | --- |
| 活动 ID | `app.id` | 活动或配置实例唯一标识 | 任意字符串 | 无,建议必填 |
| 活动标题 | `app.title` | 页面展示和结算展示的标题 | 任意字符串 | 无,建议必填 |
| 语言环境 | `app.locale` | 文案和内容环境 | 当前常用:`zh-CN` | `zh-CN` |
### 4.2 地图底座
| 名称 | 字段 | 说明 | 可选项 / 取值 | 默认值 |
| --- | --- | --- | --- | --- |
| 瓦片路径 | `map.tiles` | 地图瓦片资源位置 | 路径字符串 | 无,建议必填 |
| 地图元数据 | `map.mapmeta` | 地图 meta 文件 | 路径字符串 | 无,建议必填 |
| 磁偏角 | `map.declination` | 影响真北/磁北换算 | `number` | `0` |
| 初始缩放 | `map.initialView.zoom` | 地图初始缩放级别 | `number` | 由客户端初始视口逻辑接管,建议 `17` |
### 4.3 场地对象
| 名称 | 字段 | 说明 | 可选项 / 取值 | 默认值 |
| --- | --- | --- | --- | --- |
| 场地类型 | `playfield.kind` | 定义玩法使用的对象集合类型 | `course` `control-set` | 由玩法决定,顺序赛常用 `course` |
| 场地来源类型 | `playfield.source.type` | 定义空间底稿来源 | 当前支持:`kml` | `kml` |
| 场地来源地址 | `playfield.source.url` | KML 或其他空间资源地址 | 路径字符串 | 无,建议必填 |
| 控制点绘制半径 | `playfield.CPRadius` | 影响地图上控制点圈的展示大小 | `number` | `6` |
| 路线标题 | `playfield.metadata.title` | 路线或对象集标题 | 任意字符串 | 无 |
| 路线编码 | `playfield.metadata.code` | 路线或对象集编码 | 任意字符串 | 无 |
### 4.4 对局流程
| 名称 | 字段 | 说明 | 可选项 / 取值 | 默认值 |
| --- | --- | --- | --- | --- |
| 手动开始 | `game.session.startManually` | 是否需要玩家主动点开始按钮 | `true` `false` | 顺序赛默认 `false` |
| 必须打起点 | `game.session.requiresStartPunch` | 是否要求起点打卡后才正式开赛 | `true` `false` | 顺序赛默认 `true` |
| 必须打终点 | `game.session.requiresFinishPunch` | 是否要求终点打卡才能完赛 | `true` `false` | 顺序赛默认 `true` |
| 最后点自动结束 | `game.session.autoFinishOnLastControl` | 最后一个普通点完成后是否直接结束 | `true` `false` | `false` |
| 最大时长 | `game.session.maxDurationSec` | 单局允许的最大比赛时长 | `number` | `5400` |
### 4.5 打点判定
| 名称 | 字段 | 说明 | 可选项 / 取值 | 默认值 |
| --- | --- | --- | --- | --- |
| 打点策略 | `game.punch.policy` | 控制进入范围后如何完成打点 | `enter-confirm` `enter` | `enter-confirm` |
| 打点半径 | `game.punch.radiusMeters` | 打点命中的半径阈值 | `number` | `5` |
| 必须先聚焦目标 | `game.punch.requiresFocusSelection` | 是否必须先选中目标点才能打卡 | `true` `false` | 顺序赛默认 `false`,积分赛默认 `true` |
### 4.6 顺序推进 / 跳点
| 名称 | 字段 | 说明 | 可选项 / 取值 | 默认值 |
| --- | --- | --- | --- | --- |
| 是否允许跳点 | `game.sequence.skip.enabled` | 顺序玩法是否允许跳过当前目标点 | `true` `false` | 顺序赛默认 `true` |
| 跳点半径 | `game.sequence.skip.radiusMeters` | 触发跳点的距离阈值 | `number` | `game.punch.radiusMeters * 2` |
| 跳点确认 | `game.sequence.skip.requiresConfirm` | 触发跳点时是否需要二次确认 | `true` `false` | `false` |
### 4.7 计分模型
| 名称 | 字段 | 说明 | 可选项 / 取值 | 默认值 |
| --- | --- | --- | --- | --- |
| 计分模型 | `game.scoring.type` | 定义玩法采用哪种积分模型 | 当前建议:`score` | `score` |
| 默认控制点分值 | `game.scoring.defaultControlScore` | 普通点未单独配置时的默认分值 | `number` | 顺序赛默认 `1`,积分赛默认 `10` |
备注:
- 顺序赛当前基础分、答题奖励分等更细规则仍以玩法规则文档为准,但系统默认基础分已按 `1` 落地
- 后续如果顺序赛积分逻辑进一步配置化,应优先补到 `game.scoring`
### 4.8 引导显示
| 名称 | 字段 | 说明 | 可选项 / 取值 | 默认值 |
| --- | --- | --- | --- | --- |
| 显示腿线 | `game.guidance.showLegs` | 是否绘制路线连接线 | `true` `false` | 顺序赛默认 `true` |
| 腿线动画 | `game.guidance.legAnimation` | 是否启用腿线动画效果 | `true` `false` | 顺序赛默认 `true` |
| 允许地图选点 | `game.guidance.allowFocusSelection` | 是否允许用户点击地图切换目标 | `true` `false` | 顺序赛默认 `false` |
补充说明:
- 黑色顶部引导提示条属于公共引导层。
- 当前默认在引导文案发生有效变化时,提示条会播放一次轻量入场动画,并辅以一次轻震动。
- 当内容卡、答题卡或结果页出现时,引导提示条默认让位。
- 顶部引导提示条的反馈与距离引导反馈分离管理,不能互相替代。
- 当前距离引导反馈默认分为三档:
- 远距离:弱提醒,间隔更长
- 接近目标:提醒频率提升
- 可打点:高频确认提醒
### 4.9 可见性策略
| 名称 | 字段 | 说明 | 可选项 / 取值 | 默认值 |
| --- | --- | --- | --- | --- |
| 起点后揭示全场 | `game.visibility.revealFullPlayfieldAfterStartPunch` | 打完起点后是否显示全部控制点与路线 | `true` `false` | `true` |
### 4.10 完赛规则
| 名称 | 字段 | 说明 | 可选项 / 取值 | 默认值 |
| --- | --- | --- | --- | --- |
| 终点始终可选 | `game.finish.finishControlAlwaysSelectable` | 终点是否无条件可生效 | `true` `false` | 顺序赛默认 `false` |
### 4.11 内容体验
| 名称 | 字段 | 说明 | 可选项 / 取值 | 默认值 |
| --- | --- | --- | --- | --- |
| 自动弹窗模板 | `playfield.controlOverrides.<key>.template` | 点位完成后默认卡片模板 | `minimal` `story` `focus` | 起终点常用 `focus`,普通点常用 `story` |
| 自动弹窗开关 | `playfield.controlOverrides.<key>.autoPopup` | 完成点位后是否自动弹内容 | `true` `false` | 最小模板默认 `false` |
| 自动内容仅一次 | `playfield.controlOverrides.<key>.once` | 本局是否只自动展示一次 | `true` `false` | `false` |
| 内容承载方式 | `playfield.controlOverrides.<key>.contentExperience.type` | 自动内容使用原生还是 H5 | `native` `h5` | 当前按点位配置 |
| 内容展示形态 | `playfield.controlOverrides.<key>.contentExperience.presentation` | H5 内容如何呈现 | 运行建议 `fullscreen`;兼容历史值 `sheet` `dialog` | `fullscreen` |
| 点击承载方式 | `playfield.controlOverrides.<key>.clickExperience.type` | 点击点位时使用原生还是 H5 | `native` `h5` | 当前按点位配置 |
| 点击展示形态 | `playfield.controlOverrides.<key>.clickExperience.presentation` | 点击 H5 如何呈现 | 运行建议 `fullscreen`;兼容历史值 `sheet` `dialog` | `fullscreen` |
说明:
- 最小模板下,点击检查点默认不弹任何详情卡,也不直接打开答题卡。
- 最小模板下,起点和普通点完成后默认不弹白色内容卡。
- 点击内容能力改为显式配置能力;只有配置了 `clickTitle` / `clickBody` / `clickExperience` 之一时,点击点位才会产生内容反馈。
- 完成后自动弹白卡也改为显式配置能力;只有明确开启 `autoPopup = true` 时,完成点位后才会弹出白色内容卡。
- 点击详情卡片当前默认不展示 H5 详情按钮,但 `clickExperience` 和 CTA 能力保留。
- 当前真机约束下,`web-view` 不再承担局部弹窗职责原生内容卡负责即时弹出H5 详情统一按整页承载。
- 连续点击不同检查点时,新的点击卡片会直接替换当前卡片,不进入手动关闭队列。
- 黑色顶部提示条只承担操作引导,不承载点位内容或结果信息。
- 当白色内容卡、答题卡或结果页出现时,黑色顶部提示条默认让位,不与内容层抢注意力。
- 白色内容卡当前分为两类:
- 浏览型:点击点位查看说明,无按钮,默认约 `4` 秒自动消失,点击屏幕任意位置可关闭。
- 交互型:打点完成后的即时内容卡,可带 CTA 或进入答题流程。
- 终点完成默认直接进入原生成绩总览页,不再额外叠加终点白色内容卡;如需再次查看终点说明,需显式配置点击内容能力。
### 4.12 点位表现
| 名称 | 字段 | 说明 | 可选项 / 取值 | 默认值 |
| --- | --- | --- | --- | --- |
| 点位样式 | `game.presentation.<mode>.controls.<state>.style` | 控制点形状和结构 | `classic-ring` `solid-dot` `double-ring` `badge` `pulse-core` | 由玩法状态决定 |
| 点位主色 | `game.presentation.<mode>.controls.<state>.colorHex` | 控制点颜色 | 十六进制颜色字符串 | 顺序赛默认参考传统定向紫红色 |
| 点位尺寸倍率 | `game.presentation.<mode>.controls.<state>.sizeScale` | 控制点大小缩放 | `number` | `1` 或按状态定制 |
| 强调环倍率 | `game.presentation.<mode>.controls.<state>.accentRingScale` | 外环强调强度 | `number` | 按状态定制 |
| 光晕强度 | `game.presentation.<mode>.controls.<state>.glowStrength` | 点位光晕表现 | 建议 `0 ~ 1` | 按状态定制 |
| 标签倍率 | `game.presentation.<mode>.controls.<state>.labelScale` | 点位编号大小 | `number` | `1` 或按状态定制 |
| 标签颜色 | `game.presentation.<mode>.controls.<state>.labelColorHex` | 点位编号颜色 | 十六进制颜色字符串 | 按状态定制 |
状态建议至少考虑:
- `default`
- `current`
- `completed`
- `skipped`
- `start`
- `finish`
### 4.13 腿线表现
| 名称 | 字段 | 说明 | 可选项 / 取值 | 默认值 |
| --- | --- | --- | --- | --- |
| 腿线样式 | `game.presentation.<mode>.legs.<state>.style` | 连接线风格 | `classic-leg` `dashed-leg` `glow-leg` `progress-leg` | 顺序赛默认建议 `classic-leg` |
| 腿线主色 | `game.presentation.<mode>.legs.<state>.colorHex` | 连接线颜色 | 十六进制颜色字符串 | 顺序赛默认建议传统定向紫红色 |
| 腿线宽度倍率 | `game.presentation.<mode>.legs.<state>.widthScale` | 连接线粗细 | `number` | 视玩法决定 |
| 腿线光晕强度 | `game.presentation.<mode>.legs.<state>.glowStrength` | 连接线发光程度 | 建议 `0 ~ 1` | 视玩法决定 |
状态建议至少考虑:
- `default`
- `completed`
### 4.14 轨迹表现
| 名称 | 字段 | 说明 | 可选项 / 取值 | 默认值 |
| --- | --- | --- | --- | --- |
| 轨迹模式 | `game.presentation.track.mode` | 决定是否显示轨迹以及显示方式 | `none` `tail` `full` | 顺序赛建议 `full` |
| 轨迹风格 | `game.presentation.track.style` | 整体视觉风格 | `classic` `neon` | 当前默认 `neon` |
| 拖尾档位 | `game.presentation.track.tailLength` | 拖尾长度档位 | `short` `medium` `long` | 视玩法决定 |
| 预设色盘 | `game.presentation.track.colorPreset` | 轨迹预设颜色方案 | `mint` `cyan` `sky` `blue` `violet` `pink` `orange` `yellow` | 按玩法决定 |
| 拖尾长度 | `game.presentation.track.tailMeters` | 实际拖尾长度 | `number` | 可覆盖档位映射 |
| 拖尾时窗 | `game.presentation.track.tailMaxSeconds` | 最大拖尾时间窗口 | `number` | 选填 |
| 静止淡出 | `game.presentation.track.fadeOutWhenStill` | 静止时是否逐步淡出 | `true` `false` | 选填 |
| 静止阈值 | `game.presentation.track.stillSpeedKmh` | 判定静止的速度阈值 | `number` | 选填 |
| 淡出时长 | `game.presentation.track.fadeOutDurationMs` | 静止后淡出时长 | `number` | 选填 |
| 轨迹主色 | `game.presentation.track.colorHex` | 主体轨迹颜色 | 十六进制颜色字符串 | 未配时回退到预设色盘 |
| 轨迹头部色 | `game.presentation.track.headColorHex` | 轨迹头部高亮颜色 | 十六进制颜色字符串 | 未配时回退到预设色盘 |
| 轨迹宽度 | `game.presentation.track.widthPx` | 主体轨迹宽度 | `number` | 选填 |
| 头部宽度 | `game.presentation.track.headWidthPx` | 头部高亮宽度 | `number` | 选填 |
| 轨迹光晕 | `game.presentation.track.glowStrength` | 轨迹光晕强度 | 建议 `0 ~ 1` | 选填 |
### 4.15 定位点表现
| 名称 | 字段 | 说明 | 可选项 / 取值 | 默认值 |
| --- | --- | --- | --- | --- |
| 是否显示定位点 | `game.presentation.gpsMarker.visible` | 控制 GPS 点是否可见 | `true` `false` | `true` |
| 定位点样式 | `game.presentation.gpsMarker.style` | GPS 点基础形态 | `dot` `beacon` `disc` `badge` | `beacon` |
| 定位点尺寸 | `game.presentation.gpsMarker.size` | GPS 点大小档位 | `small` `medium` `large` | `medium` |
| 定位点预设色盘 | `game.presentation.gpsMarker.colorPreset` | GPS 点默认色彩方案 | `mint` `cyan` `sky` `blue` `violet` `pink` `orange` `yellow` | `cyan` |
| 定位点主色 | `game.presentation.gpsMarker.colorHex` | GPS 点主体颜色 | 十六进制颜色字符串 | 未配时回退到预设色盘 |
| 外环颜色 | `game.presentation.gpsMarker.ringColorHex` | GPS 点外环颜色 | 十六进制颜色字符串 | 未配时回退到预设色盘 |
| 朝向指示颜色 | `game.presentation.gpsMarker.indicatorColorHex` | 小三角或朝向指示颜色 | 十六进制颜色字符串 | 未配时回退到预设色盘 |
| 显示朝向指示 | `game.presentation.gpsMarker.showHeadingIndicator` | 是否显示跟随朝向旋转的指示三角 | `true` `false` | `true` |
| 定位点动画档 | `game.presentation.gpsMarker.animationProfile` | GPS 点动画风格 | `minimal` `dynamic-runner` `warning-reactive` | `dynamic-runner` |
| 中心 logo 地址 | `game.presentation.gpsMarker.logoUrl` | 品牌中心贴片资源 | URL 字符串 | 选填 |
| logo 嵌入方式 | `game.presentation.gpsMarker.logoMode` | 品牌贴片嵌入方式 | `center-badge` | 当前支持该值 |
### 4.16 遥测参数
| 名称 | 字段 | 说明 | 可选项 / 取值 | 默认值 |
| --- | --- | --- | --- | --- |
| 年龄 | `game.telemetry.heartRate.age` | 心率估算的年龄参数 | `number` | `30` |
| 静息心率 | `game.telemetry.heartRate.restingHeartRateBpm` | 心率估算基础参数 | `number` | `62` |
| 体重 | `game.telemetry.heartRate.userWeightKg` | 卡路里或体征估算参数 | `number` | `65` |
说明:
- 这里定义的是活动级默认值
- 如果后续接入线上玩家身体数据接口,线上数据应覆盖这里的同名字段
### 4.17 HUD 信息面板
| 名称 | 字段 / 归属 | 说明 | 可选项 / 取值 | 默认值 |
| --- | --- | --- | --- | --- |
| HUD 页数 | 公共 UI 壳子 | 当前 HUD 固定为 `2` 页 | `2` | `2` |
| HUD 第 1 页 | 公共 UI 壳子 | 比赛主信息页,承载动作、时间、里程、目标距离、进度等异型槽位 | 固定结构 | 启用 |
| HUD 第 2 页 | 公共 UI 壳子 | 心率 / 遥测页,承载心率、卡路里、均速、精度等异型槽位 | 固定结构 | 启用 |
| HUD 项目映射 | 玩法映射 | 不同玩法可将不同数据映射到相同 HUD 槽位 | 按玩法定义 | 按玩法默认 |
| HUD 目标摘要 | 玩法映射 | 第 1 页动作区下方的摘要文案,用于显示当前目标或提示信息 | `string` | 按玩法默认 |
| HUD 进度摘要 | 玩法映射 | 第 1 页进度区显示的核心摘要,不同玩法可映射为总分、完成进度、跳点数等 | `string` | 按玩法默认 |
说明:
- HUD 属于公共层能力,不属于某一个玩法专属规则。
- 当前系统采用“公共 HUD 壳子 + 玩法项目映射”的方式。
- 例如:
- 积分赛可将进度位映射为“总分 + 收集进度”
- 顺序打点可将进度位映射为“完成进度 + 跳点数”
### 4.18 反馈系统
| 名称 | 字段 | 说明 | 可选项 / 取值 | 默认值 |
| --- | --- | --- | --- | --- |
| 音效反馈档 | `game.feedback.audioProfile` | 控制音效方案 | `string` | `default` |
| 震动反馈档 | `game.feedback.hapticsProfile` | 控制震动策略 | `string` | `default` |
| UI 动效档 | `game.feedback.uiEffectsProfile` | 控制 UI 动效策略 | `string` | `default` |
| 距离提示音阈值 | `game.audio.*DistanceMeters` | 控制远距离 / 接近 / 可打点三档距离提示的生效距离 | `number` | 远距离 `80`,接近 `20`,可打点 `5` |
| 距离提示音间隔 | `game.audio.cues.guidance:*.*` | 控制三档距离提示音的循环间隔、音量和音源 | `number` / `string` / `boolean` | 远距离 `4800ms`,接近 `950ms`,可打点 `650ms` |
说明:
- 系统当前已支持音效反馈能力。
- 默认答题正确复用控制点完成音效,答题错误和答题超时复用警告音效。
- 引导提示条当前默认改用轻震动,不再播放引导提示音。
- 目标距离引导当前默认分为 `distant / approaching / ready` 三档,作为独立反馈链路,可使用距离提示音,不与顶部引导提示绑定。
- 当前三档距离提示已支持分别配置距离阈值与循环间隔。
### 4.19 资源档
| 名称 | 字段 | 说明 | 可选项 / 取值 | 默认值 |
| --- | --- | --- | --- | --- |
| 音效资源档 | `resources.audioProfile` | 选择音频资源包 | `string` | `default` |
| 内容资源档 | `resources.contentProfile` | 选择内容资源包 | `string` | `default` |
| 主题资源档 | `resources.themeProfile` | 选择主题和视觉资源包 | `string` | `default-race` |
### 4.20 调试能力
| 名称 | 字段 | 说明 | 可选项 / 取值 | 默认值 |
| --- | --- | --- | --- | --- |
| 允许玩法切换 | `debug.allowModeSwitch` | 是否允许在调试时切玩法 | `true` `false` | `false` |
| 允许模拟输入 | `debug.allowMockInput` | 是否允许用模拟数据代替真实输入 | `true` `false` | `false` |
| 允许模拟器 | `debug.allowSimulator` | 是否开放调试模拟器面板 | `true` `false` | `false` |
---
## 5. 后续所有玩法设计文档的建议结构
后续每新增一种玩法,设计文档建议至少包含以下章节:
1. 玩法目标与一句话规则
2. 开局流程与结束流程
3. 核心判定与胜负条件
4. 计分规则
5. 对象模型与场地对象要求
6. 全局规则块选型
7. 默认值与玩法覆盖项
8. 最小可跑配置示例
其中第 6 节“全局规则块选型”建议至少回答:
- 用哪套定位点样式
- 用哪套轨迹显示策略
- 是否显示腿线和腿线动画
- 起点后是否揭示全场
- 是否需要内容弹层和 H5 承载
- 用哪套反馈档
- 是否依赖心率等遥测参数
---
## 6. 维护约定
后续每次新增全局系统能力时,建议至少同步更新以下文档:
1. 本文档
2. [配置选项字典](D:/dev/cmr-mini/doc/config/配置选项字典.md)
3. [当前最全配置模板](D:/dev/cmr-mini/doc/config/当前最全配置模板.md)
4. 对应能力的专项文档
- 例如轨迹、GPS 点样式、GPS 点动画、内容体验、反馈系统
5. 至少一个玩法的配置样例
这样可以保证:
- 玩法设计有统一骨架
- 配置字段有统一归档
- 后台配置管理有明确输入目标
- 后续扩展不会只长代码、不长文档

View File

@@ -1,407 +0,0 @@
# 配置频繁变更场景下的后台管理方案
本文用于整理一套更适合“配置项变化很频繁”的后台方案。
适用前提:
- 配置驱动型应用
- 游戏规则和字段会持续变化
- PostgreSQL 作为主数据库
- Go 作为中间层
- 客户端最终读取静态 JSON
核心目标是:
**在保证后端稳定的前提下,让前端和玩法配置可以持续快速迭代。**
---
## 1. 核心原则
这版方案的核心思想只有一句:
**后端管理“容器、版本、引用、发布”,不要深度管理每个细字段。**
也就是说:
- 后端负责管理对象关系
- 后端负责管理版本和发布
- 后端负责做基础校验
- 后端尽量不要写死每个玩法里的所有字段细节
---
## 2. 总体结构
推荐分成 3 层:
### 2.1 编辑层
后台管理系统面向的是“对象”,不是最终运行文件。
建议核心对象仍然是:
- `Map`
- `Playfield`
- `GameMode`
- `ResourcePack`
- `Event`
### 2.2 装配层
Go 中间层负责:
- 读取对象
- 合并引用
- 基础校验
- 生成最终运行态配置
### 2.3 发布层
装配完成后,生成静态 JSON 上传到 OSS/CDN。
客户端只读取:
- 已发布的静态配置
---
## 3. 数据库存什么
数据库建议只存两类数据:
### 3.1 稳定元信息
结构化列保存:
- `id`
- `slug`
- `name`
- `status`
- `current_version_id`
- `created_at`
- `updated_at`
### 3.2 易变配置内容
使用 `jsonb` 保存:
- `content_jsonb`
也就是说,每个对象都建议拆成:
- 主表
- version 表
例如:
- `maps` / `map_versions`
- `playfields` / `playfield_versions`
- `game_modes` / `game_mode_versions`
- `resource_packs` / `resource_pack_versions`
- `events` / `event_versions`
这套结构最适合承接频繁变化的配置字段。
---
## 4. 为什么要用 version 表
配置频繁变化时,版本表非常重要:
- 支持草稿
- 支持当前版
- 支持发布版
- 支持历史回滚
- 支持 diff
- 支持审计
如果没有版本表,配置演进到后面会越来越难控。
---
## 5. 后端真正该负责的内容
后端建议强管理下面这 4 件事:
### 5.1 对象关系
例如:
- Event 引用哪个 Map
- Event 引用哪个 Playfield
- Event 引用哪个 GameMode
- Event 引用哪个 ResourcePack
### 5.2 版本机制
例如:
- 草稿
- 当前版本
- 发布版本
- 回滚历史
### 5.3 基础校验
只做真正稳定的校验:
- 顶层结构是否合法
- 引用是否存在
- schemaVersion 是否兼容
- 必填对象是否齐全
### 5.4 发布装配
把编辑态对象装配成最终运行态 JSON。
---
## 6. 后端不要过度负责的内容
后端不要把下面这些写死:
- 每个玩法的小规则字段
- 每个 HUD 开关
- 每个实验性参数
- 每个视觉细节配置
- 每次快速迭代里新增的小配置项
这些变化太频繁,应该优先放在 `jsonb` 内容里,由前端消费。
一句话:
**后端不要成为“所有细字段的业务解释器”。**
---
## 7. 配置校验的推荐分层
建议分成 3 层校验。
### 7.1 通用结构校验
所有配置都校验:
- `schemaVersion`
- `map`
- `playfield`
- `game`
### 7.2 公共字段校验
只校验稳定公共字段,例如:
- `game.mode` 必须存在
- `game.punch.radiusMeters > 0`
### 7.3 玩法校验器
`game.mode` 分发,例如:
- `classic-sequential` validator
- `score-o` validator
但这里有个重要原则:
**未识别字段默认允许透传。**
也就是说:
- 不要因为多了一个新字段就发布失败
- 只有破坏基础结构或关键规则时才拦截
---
## 8. 后台编辑策略
后台不要追求“一开始把所有字段都做成完美表单”。
建议分成两类:
### 8.1 稳定字段
做正式表单:
- 名称
- 状态
- 模式
- 地图引用
- Playfield 引用
- 资源包引用
- 关键半径
- 是否必须起点/终点
### 8.2 易变字段
先保留模块化 JSON 编辑区:
- `game.sequence`
- `game.guidance`
- `game.visibility`
- `game.feedback`
- `playfield.controlOverrides`
- 其他试验性字段
等这些字段稳定后,再逐步升级成正式表单。
这会比一开始硬做全表单更现实。
---
## 9. 推荐的发布模型
建议增加一层:
- `event_releases`
推荐字段:
- `id`
- `event_id`
- `event_version_id`
- `release_no`
- `manifest_url`
- `published_by`
- `published_at`
- `status`
发布流程:
1. 后台选择某个 `event_version`
2. Go 层装配最终配置
3. Go 层校验
4. 上传 OSS/CDN
5. 写入 release 记录
客户端只消费:
- 某次 release 对应的静态 JSON
---
## 10. Go 中间层的职责
Go 中间层建议承担 4 类职责:
### 10.1 装配器
负责把:
- `Map`
- `Playfield`
- `GameMode`
- `ResourcePack`
- `Event Overrides`
装配成最终运行态配置。
### 10.2 校验器
负责:
- 通用校验
- 公共字段校验
- 按玩法分发的插件式校验
### 10.3 发布器
负责:
- 生成静态 JSON
- 上传 OSS/CDN
- 写入 release
### 10.4 预览 / Diff
负责:
- 给后台看发布前的预览
- 对比不同版本差异
一句话:
**Go 中间层本质上是配置编译器,不只是 CRUD 服务。**
---
## 11. 这套方案为什么适合当前项目
因为当前项目的真实情况就是:
- 配置字段变化快
- 玩法在持续演进
- 前端经常需要新增规则项
- 客户端更适合消费静态配置
如果后端每次都跟着细字段改表、改结构、改接口,成本会非常高。
这套方案可以避免:
- 频繁 migration
- 后端字段爆炸
- 每次小字段变更都改很多 Go 代码
---
## 12. 推荐你现在就定死的原则
### 原则 1
**数据库结构稳定,配置内容灵活。**
### 原则 2
**后端强管理对象关系,不强管理每个细字段。**
### 原则 3
**未知字段默认允许透传。**
### 原则 4
**客户端消费细规则,后端负责发布与校验。**
### 原则 5
**最终运行态永远是静态 JSON。**
---
## 13. 和当前目录结构的关系
如果当前静态目录是:
- `map/`
- `kml/`
- `event/`
这套可以继续保留。
理解方式是:
- 数据库 = 编辑态
- Go 装配 = 发布态转换
- OSS 目录 = 运行态产物
也就是说后台发布后,继续生成:
- `event/classic-sequential.json`
- `event/score-o.json`
- `map/...`
- `kml/...`
客户端现有读取逻辑无需推翻。
---
## 14. 推荐实施顺序
建议按下面顺序推进:
### 第一步
先建 5 个核心对象:
- `Map`
- `Playfield`
- `GameMode`
- `ResourcePack`
- `Event`
### 第二步
为每个对象补 version 表。
### 第三步
Go 中间层先做最小装配功能。
### 第四步
实现发布到 OSS/CDN。
### 第五步
后台逐步把稳定字段表单化。
### 第六步
把易变字段继续保留为 JSON 编辑区。
---
## 15. 一句话总结
这套更适合频繁变化配置项的后台方案是:
**PostgreSQL 存“版本化对象 + jsonb 内容”Go 中间层做“装配 + 校验 + 发布”,客户端只读静态发布结果。**

View File

@@ -1,761 +0,0 @@
# 游戏配置全量模板(当前开发实现版)
本文档提供一份 **截至当前开发状态,客户端已实现或已正式消费的较完整配置模板**
目标:
- 给后端、后台、联调一份“当前最全可用模板”
- 帮助梳理哪些字段已经生效
- 后续新增字段时,以这份模板持续补充
说明:
- 本模板优先以**当前客户端代码真实实现**为准
- 不是未来终态,只代表“当前这一版已经能消费的字段”
- 以顺序赛为主模板,同时说明积分赛差异点
---
## 1. 当前最全模板
```json
{
"schemaVersion": "1",
"version": "2026.03.30",
"app": {
"id": "sample-full-001",
"title": "完整配置示例",
"locale": "zh-CN"
},
"settings": {
"autoRotateEnabled": {
"value": true,
"isLocked": false
},
"trackDisplayMode": {
"value": "tail",
"isLocked": false
},
"gpsMarkerStyle": {
"value": "beacon",
"isLocked": true
},
"showCenterScaleRuler": {
"value": false,
"isLocked": false
}
},
"map": {
"tiles": "../map/lxcb-001/tiles/",
"mapmeta": "../map/lxcb-001/tiles/meta.json",
"declination": 6.91,
"initialView": {
"zoom": 17
}
},
"playfield": {
"kind": "course",
"source": {
"type": "kml",
"url": "../kml/lxcb-001/10/c01.kml"
},
"CPRadius": 6,
"controlDefaults": {
"score": 10,
"template": "story",
"autoPopup": false,
"pointStyle": "classic-ring",
"pointColorHex": "#cc006b"
},
"metadata": {
"title": "完整路线示例",
"code": "full-001"
},
"controlOverrides": {
"start-1": {
"template": "focus",
"title": "比赛开始",
"body": "从这里触发,先熟悉地图方向。",
"clickTitle": "起点说明",
"clickBody": "点击起点可再次查看起跑说明。",
"autoPopup": true,
"once": true,
"priority": 1,
"contentExperience": {
"type": "h5",
"url": "https://example.com/content/start-1",
"bridge": "content-v1",
"presentation": "fullscreen"
},
"clickExperience": {
"type": "h5",
"url": "https://example.com/content/start-1-click",
"bridge": "content-v1",
"presentation": "fullscreen"
}
},
"control-1": {
"template": "story",
"score": 10,
"title": "第一检查点",
"body": "完成该点后继续推进。",
"clickTitle": "第一检查点",
"clickBody": "点击查看该点的补充说明。",
"autoPopup": true,
"once": false,
"priority": 1,
"contentExperience": {
"type": "h5",
"url": "https://example.com/content/control-1",
"bridge": "content-v1",
"presentation": "fullscreen"
},
"clickExperience": {
"type": "h5",
"url": "https://example.com/content/control-1-click",
"bridge": "content-v1",
"presentation": "fullscreen"
}
},
"control-2": {
"template": "minimal",
"title": "第二检查点",
"body": "这个点配置成手动查看内容。",
"clickTitle": "第二检查点",
"clickBody": "点击查看手动内容。",
"autoPopup": false,
"once": true,
"priority": 1
},
"finish-1": {
"template": "focus",
"title": "比赛结束",
"body": "恭喜完成本次路线。",
"clickTitle": "终点说明",
"clickBody": "点击终点可再次查看结束说明。",
"autoPopup": false,
"once": true,
"priority": 2,
"clickExperience": {
"type": "h5",
"url": "https://example.com/content/finish-1-click",
"bridge": "content-v1",
"presentation": "fullscreen"
}
}
},
"legDefaults": {
"style": "classic-leg",
"colorHex": "#cc006b",
"widthScale": 1
}
},
"game": {
"mode": "classic-sequential",
"rulesVersion": "1",
"session": {
"startManually": false,
"requiresStartPunch": true,
"requiresFinishPunch": true,
"autoFinishOnLastControl": false,
"minCompletedControlsBeforeFinish": 1,
"maxDurationSec": 5400
},
"punch": {
"policy": "enter-confirm",
"radiusMeters": 5,
"requiresFocusSelection": false
},
"sequence": {
"skip": {
"enabled": true,
"radiusMeters": 10,
"requiresConfirm": false
}
},
"guidance": {
"showLegs": true,
"legAnimation": true,
"allowFocusSelection": false
},
"visibility": {
"revealFullPlayfieldAfterStartPunch": true
},
"finish": {
"finishControlAlwaysSelectable": false
},
"telemetry": {
"heartRate": {
"age": 30,
"restingHeartRateBpm": 62,
"userWeightKg": 65
}
},
"audio": {
"distantDistanceMeters": 80,
"approachDistanceMeters": 20,
"readyDistanceMeters": 5,
"cues": {
"guidance:distant": {
"loopGapMs": 4800,
"volume": 0.34
},
"guidance:approaching": {
"loopGapMs": 950,
"volume": 0.58
},
"guidance:ready": {
"loopGapMs": 650,
"volume": 0.68
}
}
},
"feedback": {
"audioProfile": "default",
"hapticsProfile": "default",
"uiEffectsProfile": "default"
}
},
"resources": {
"audioProfile": "default",
"contentProfile": "default",
"themeProfile": "default-race"
},
"debug": {
"allowModeSwitch": false,
"allowMockInput": false,
"allowSimulator": false
}
}
```
---
## 2. 顶层字段说明
### `schemaVersion`
- 类型:`string`
- 必填:是
- 说明:配置结构版本
- 当前建议值:`"1"`
### `version`
- 类型:`string`
- 必填:是
- 说明:配置内容版本号
### `app`
- 类型:`object`
- 必填:是
- 说明:活动级基础信息
### `map`
- 类型:`object`
- 必填:是
- 说明:地图底座信息
### `playfield`
- 类型:`object`
- 必填:是
- 说明:点位空间、内容覆盖、点位元信息
### `game`
- 类型:`object`
- 必填:是
- 说明:玩法规则与对局流程
### `resources`
- 类型:`object`
- 必填:否
- 说明:资源 profile 引用
### `debug`
- 类型:`object`
- 必填:否
- 说明:调试开关
---
## 3. `app` 字段说明
### `app.id`
- 类型:`string`
- 说明:活动配置 ID
### `app.title`
- 类型:`string`
- 说明:活动标题 / 比赛名称
### `app.locale`
- 类型:`string`
- 说明:语言环境
- 当前常用值:`zh-CN`
---
## 4. `map` 字段说明
### `map.tiles`
- 类型:`string`
- 必填:是
- 说明:瓦片根路径
### `map.mapmeta`
- 类型:`string`
- 必填:是
- 说明:地图 meta 文件路径
### `map.declination`
- 类型:`number`
- 必填:否
- 说明:磁偏角
- 影响:真北 / 磁北换算
### `map.initialView.zoom`
- 类型:`number`
- 必填:否
- 说明:初始缩放级别
- 建议默认值:`17`
---
## 5. `playfield` 字段说明
### `playfield.kind`
- 类型:`string`
- 说明:空间对象类型
- 当前常用值:
- `course`
- `control-set`
### `playfield.source.type`
- 类型:`string`
- 说明:空间来源类型
- 当前推荐值:`kml`
### `playfield.source.url`
- 类型:`string`
- 说明KML 路径
### `playfield.CPRadius`
- 类型:`number`
- 说明:检查点绘制半径
- 建议默认值:`6`
### `playfield.metadata.title`
- 类型:`string`
- 说明:路线标题
### `playfield.metadata.code`
- 类型:`string`
- 说明:路线编码
---
## 6. `playfield.controlDefaults` / `playfield.controlOverrides` 字段说明
推荐优先级:
`系统默认值 -> 玩法默认值 -> playfield.controlDefaults -> playfield.controlOverrides`
### `playfield.controlDefaults`
- 类型:`object`
- 说明:普通检查点的活动级默认配置
- 作用:减少重复书写,未单独覆盖的普通检查点默认继承这里
### `playfield.controlOverrides`
- 类型:`object`
- 说明:起点、普通点、终点的单点覆盖
- 作用:只写与活动默认不同的点
### key 命名规则
- 起点:`start-1`
- 普通点:`control-1``control-2``control-3`
- 终点:`finish-1`
### 当前支持字段
#### `template`
- 类型:`string`
- 说明:原生内容卡模板
- 当前支持:
- `minimal`
- `story`
- `focus`
#### `score`
- 类型:`number`
- 说明:积分赛点位分值
#### `title`
- 类型:`string`
- 说明:打点完成后自动弹出的标题
#### `body`
- 类型:`string`
- 说明:打点完成后自动弹出的正文
#### `clickTitle`
- 类型:`string`
- 说明:点击点位时弹出的标题
- 默认逻辑:最小模板下默认不启用;仅在显式配置点击内容能力时生效
#### `clickBody`
- 类型:`string`
- 说明:点击点位时弹出的正文
- 默认逻辑:最小模板下默认不启用;仅在显式配置点击内容能力时生效
#### `autoPopup`
- 类型:`boolean`
- 说明:打点完成后是否自动弹出
- 默认逻辑:最小模板下默认 `false`
- 特殊逻辑:`game.punch.policy = "enter"` 时不自动弹原生内容
- 补充说明:白色内容卡已改为显式配置启用;普通点只有显式设置 `autoPopup = true` 才会在打点后先弹白卡
- 补充说明:终点完成后默认直接进入结果页,不走白色内容卡链路
#### `once`
- 类型:`boolean`
- 说明:本局只展示一次
- 默认逻辑:`false`
#### `priority`
- 类型:`number`
- 说明:内容优先级,越大越高
- 默认逻辑:
- 普通点:`1`
- 终点:`2`
#### `contentExperience`
- 类型:`object`
- 说明:打点完成后的 H5 详情/互动扩展配置
- 注意:当前不是直接顶替原生弹窗,而是通过原生卡片 CTA 进入
#### `contentExperience.type`
- 类型:`string`
- 说明:内容扩展承载类型
- 当前支持:
- `native`
- `h5`
#### `contentExperience.url`
- 类型:`string`
- 说明H5 详情页地址
#### `contentExperience.bridge`
- 类型:`string`
- 说明Bridge 协议版本
- 当前推荐值:`content-v1`
#### `contentExperience.presentation`
- 类型:`string`
- 说明H5 内容页展示形态
- 当前运行建议:
- `fullscreen`
- 兼容历史值:
- `dialog`
- `sheet`
- 备注:经过真机验证,`web-view` 不再承担局部弹窗职责;当前应按“原生内容卡 + H5 全屏详情页/任务页”理解。
#### `clickExperience`
- 类型:`object`
- 说明:点击点位时的 H5 详情/互动扩展配置
- 规则同 `contentExperience`
---
## 7. `game` 字段说明
### `game.mode`
- 类型:`string`
- 说明:玩法模式
- 当前常用值:
- `classic-sequential`
- `score-o`
### `game.rulesVersion`
- 类型:`string`
- 说明:规则版本号
### `game.session.startManually`
- 类型:`boolean`
- 说明:是否手动开始
- 顺序赛建议默认值:`false`
### `game.session.requiresStartPunch`
- 类型:`boolean`
- 说明:是否必须打起点
### `game.session.requiresFinishPunch`
- 类型:`boolean`
- 说明:是否必须打终点
### `game.session.autoFinishOnLastControl`
- 类型:`boolean`
- 说明:最后一个目标完成后是否自动结束
### `game.session.minCompletedControlsBeforeFinish`
- 类型:`number`
- 说明:终点生效前至少需要完成的普通检查点数量
- 建议默认值:
- 顺序赛:`0`
- 积分赛:`1`
### `game.session.maxDurationSec`
- 类型:`number`
- 说明:最大对局时长,单位秒
### `game.punch.policy`
- 类型:`string`
- 说明:打点策略
- 当前常用值:
- `enter-confirm`
- `enter`
### `game.punch.radiusMeters`
- 类型:`number`
- 说明:打点半径,单位米
### `game.punch.requiresFocusSelection`
- 类型:`boolean`
- 说明:是否需要先聚焦/选中目标再打点
- 建议默认值:
- 顺序赛:`false`
- 积分赛:`false`
### `game.sequence.skip.enabled`
- 类型:`boolean`
- 说明:顺序赛是否允许跳点
### `game.sequence.skip.radiusMeters`
- 类型:`number`
- 说明:跳点可用半径
- 顺序赛建议默认值:打点半径的 `2`
### `game.sequence.skip.requiresConfirm`
- 类型:`boolean`
- 说明:跳点是否需要二次确认
- 顺序赛建议默认值:`false`
### `game.guidance.showLegs`
- 类型:`boolean`
- 说明:是否显示路线腿段
### `game.guidance.legAnimation`
- 类型:`boolean`
- 说明:是否开启路线腿段动画
### `game.guidance.allowFocusSelection`
- 类型:`boolean`
- 说明:是否允许点击点位选中目标
### `game.visibility.revealFullPlayfieldAfterStartPunch`
- 类型:`boolean`
- 说明:打完起点后是否显示完整场地
### `game.finish.finishControlAlwaysSelectable`
- 类型:`boolean`
- 说明:终点是否始终可选
### `game.telemetry.heartRate.age`
- 类型:`number`
- 说明:用户年龄
### `game.telemetry.heartRate.restingHeartRateBpm`
- 类型:`number`
- 说明:静息心率
### `game.telemetry.heartRate.userWeightKg`
- 类型:`number`
- 说明:体重,单位公斤
### `game.feedback.audioProfile`
- 类型:`string`
- 说明:音效 profile
### `game.feedback.hapticsProfile`
- 类型:`string`
- 说明:震动 profile
### `game.feedback.uiEffectsProfile`
- 类型:`string`
- 说明UI 动效 profile
### `game.audio`
- 类型:`object`
- 说明:高级音频运行时配置,用于控制三档距离提示音的距离阈值和 cue 参数
### `game.audio.distantDistanceMeters`
- 类型:`number`
- 说明:远距离提示音阈值
- 建议默认值:`80`
### `game.audio.approachDistanceMeters`
- 类型:`number`
- 说明:接近提示音阈值
- 建议默认值:`20`
### `game.audio.readyDistanceMeters`
- 类型:`number`
- 说明:可打点提示音阈值
- 建议默认值:`5`
- 备注:
- 运行时不会低于 `game.punch.radiusMeters`
### `game.audio.cues["guidance:distant" | "guidance:approaching" | "guidance:ready"]`
- 类型:`object`
- 说明:三档距离提示音的 cue 级配置
- 当前支持字段:
- `src`
- `volume`
- `loop`
- `loopGapMs`
---
## 8. `resources` 字段说明
### `resources.audioProfile`
- 类型:`string`
- 说明:资源音效配置档
### `resources.contentProfile`
- 类型:`string`
- 说明:内容资源配置档
### `resources.themeProfile`
- 类型:`string`
- 说明:主题配置档
---
## 9. `debug` 字段说明
### `debug.allowModeSwitch`
- 类型:`boolean`
- 说明:是否允许玩法切换调试
### `debug.allowMockInput`
- 类型:`boolean`
- 说明:是否允许模拟输入
### `debug.allowSimulator`
- 类型:`boolean`
- 说明:是否允许模拟器调试
---
## 10. 积分赛差异点
如果使用积分赛,通常要修改这几项:
```json
{
"playfield": {
"kind": "control-set"
},
"game": {
"mode": "score-o",
"session": {
"startManually": false
},
"punch": {
"requiresFocusSelection": true
},
"guidance": {
"showLegs": false,
"legAnimation": false,
"allowFocusSelection": true
},
"finish": {
"finishControlAlwaysSelectable": true
}
}
}
```
并在 `playfield.controlDefaults` 中先写普通点统一默认,必要时再在 `playfield.controlOverrides` 中为少量特殊点补:
- `score`
---
## 11. 推荐配套阅读
- [D:\dev\cmr-mini\doc\config-template-minimal-game.md](D:/dev/cmr-mini/doc/config/最小游戏配置模板.md)
- [D:\dev\cmr-mini\doc\config-option-dictionary.md](D:/dev/cmr-mini/doc/config/配置选项字典.md)
- [D:\dev\cmr-mini\doc\config-docs-index.md](D:/dev/cmr-mini/doc/config/配置文档索引.md)

Some files were not shown because too many files have changed in this diff Show More