package handlers
import (
"encoding/json"
"fmt"
"net/http"
neturl "net/url"
"time"
"cmr-backend/internal/httpx"
"cmr-backend/internal/service"
)
type DevHandler struct {
devService *service.DevService
}
func NewDevHandler(devService *service.DevService) *DevHandler {
return &DevHandler{devService: devService}
}
func (h *DevHandler) BootstrapDemo(w http.ResponseWriter, r *http.Request) {
result, err := h.devService.BootstrapDemo(r.Context())
if err != nil {
httpx.WriteError(w, err)
return
}
httpx.WriteJSON(w, http.StatusOK, map[string]any{"data": result})
}
func (h *DevHandler) CreateClientLog(w http.ResponseWriter, r *http.Request) {
if !h.devService.Enabled() {
http.NotFound(w, r)
return
}
var input service.CreateClientDebugLogInput
if err := httpx.DecodeJSON(r, &input); err != nil {
httpx.WriteError(w, fmt.Errorf("decode client log: %w", err))
return
}
entry, err := h.devService.AddClientDebugLog(r.Context(), input)
if err != nil {
httpx.WriteError(w, err)
return
}
httpx.WriteJSON(w, http.StatusOK, map[string]any{"data": entry})
}
func (h *DevHandler) ListClientLogs(w http.ResponseWriter, r *http.Request) {
if !h.devService.Enabled() {
http.NotFound(w, r)
return
}
limit := 50
if raw := r.URL.Query().Get("limit"); raw != "" {
var parsed int
if _, err := fmt.Sscanf(raw, "%d", &parsed); err == nil {
limit = parsed
}
}
items, err := h.devService.ListClientDebugLogs(r.Context(), limit)
if err != nil {
httpx.WriteError(w, err)
return
}
httpx.WriteJSON(w, http.StatusOK, map[string]any{"data": items})
}
func (h *DevHandler) ClearClientLogs(w http.ResponseWriter, r *http.Request) {
if !h.devService.Enabled() {
http.NotFound(w, r)
return
}
if err := h.devService.ClearClientDebugLogs(r.Context()); err != nil {
httpx.WriteError(w, err)
return
}
httpx.WriteJSON(w, http.StatusOK, map[string]any{"data": map[string]any{"cleared": true}})
}
func (h *DevHandler) Workbench(w http.ResponseWriter, r *http.Request) {
if !h.devService.Enabled() {
http.NotFound(w, r)
return
}
w.Header().Set("Content-Type", "text/html; charset=utf-8")
_, _ = w.Write([]byte(devWorkbenchHTML))
}
func (h *DevHandler) ManifestSummary(w http.ResponseWriter, r *http.Request) {
if !h.devService.Enabled() {
http.NotFound(w, r)
return
}
rawURL := r.URL.Query().Get("url")
if rawURL == "" {
httpx.WriteError(w, fmt.Errorf("manifest summary url is required"))
return
}
parsed, err := neturl.Parse(rawURL)
if err != nil || parsed.Scheme == "" || parsed.Host == "" {
httpx.WriteError(w, fmt.Errorf("invalid manifest url"))
return
}
client := &http.Client{Timeout: 15 * time.Second}
resp, err := client.Get(parsed.String())
if err != nil {
httpx.WriteError(w, fmt.Errorf("fetch manifest: %w", err))
return
}
defer resp.Body.Close()
if resp.StatusCode < 200 || resp.StatusCode >= 300 {
httpx.WriteError(w, fmt.Errorf("fetch manifest: http %d", resp.StatusCode))
return
}
var manifest map[string]any
if err := json.NewDecoder(resp.Body).Decode(&manifest); err != nil {
httpx.WriteError(w, fmt.Errorf("decode manifest: %w", err))
return
}
summary := map[string]any{
"url": parsed.String(),
"schemaVersion": pickString(manifest["schemaVersion"]),
"playfieldKind": pickNestedString(manifest, "playfield", "kind"),
"gameMode": pickNestedString(manifest, "game", "mode"),
}
httpx.WriteJSON(w, http.StatusOK, map[string]any{"data": summary})
}
func (h *DevHandler) DemoPresentationSchema(w http.ResponseWriter, r *http.Request) {
if !h.devService.Enabled() {
http.NotFound(w, r)
return
}
key := r.PathValue("demoKey")
payload, ok := demoPresentationAssets[key]
if !ok {
http.NotFound(w, r)
return
}
httpx.WriteJSON(w, http.StatusOK, payload)
}
func (h *DevHandler) DemoContentManifest(w http.ResponseWriter, r *http.Request) {
if !h.devService.Enabled() {
http.NotFound(w, r)
return
}
key := r.PathValue("demoKey")
payload, ok := demoContentAssets[key]
if !ok {
http.NotFound(w, r)
return
}
httpx.WriteJSON(w, http.StatusOK, payload)
}
func pickString(v any) string {
switch t := v.(type) {
case string:
return t
case float64:
return fmt.Sprintf("%.0f", t)
default:
return ""
}
}
func pickNestedString(m map[string]any, parent, child string) string {
value, ok := m[parent]
if !ok {
return ""
}
nested, ok := value.(map[string]any)
if !ok {
return ""
}
return pickString(nested[child])
}
var demoPresentationAssets = map[string]map[string]any{
"classic": {
"templateKey": "event.detail.city-run",
"sourceType": "schema",
"version": "v2026-04-03",
"title": "雪熊领秀城区顺序赛展示定义",
"event": map[string]any{
"title": "雪熊领秀城区顺序赛",
"subtitle": "沿河绿道 6 点经典路线",
},
"card": map[string]any{
"heroTitle": "今日推荐路线",
"heroSubtitle": "城区步道顺序挑战",
"badge": "顺序赛",
},
"detail": map[string]any{
"sections": []map[string]any{
{"type": "hero", "title": "顺序打卡", "subtitle": "沿河绿道 6 点路线"},
{"type": "summary", "items": []string{"预计时长 35 分钟", "适合首次联调与新手体验", "默认使用标准 6 点线路"}},
{"type": "safety", "items": []string{"注意路口减速", "夜间建议结伴测试"}},
},
},
},
"score-o": {
"templateKey": "event.detail.score-o",
"sourceType": "schema",
"version": "v2026-04-03",
"title": "雪熊领秀城区积分赛展示定义",
"event": map[string]any{
"title": "雪熊领秀城区积分赛",
"subtitle": "20 分钟自由取点积分挑战",
},
"card": map[string]any{
"heroTitle": "自由取点",
"heroSubtitle": "在限定时间内尽量拿高分",
"badge": "积分赛",
},
"detail": map[string]any{
"sections": []map[string]any{
{"type": "hero", "title": "20 分钟自由取点", "subtitle": "控制点分值不同,自由规划路线"},
{"type": "summary", "items": []string{"推荐热身后再开局", "适合熟悉地图后做效率测试", "默认接入 score-o 玩法"}},
{"type": "result", "items": []string{"展示积分、完成点数、路线效率"}},
},
},
},
"manual-variant": {
"templateKey": "event.detail.variant-selector",
"sourceType": "schema",
"version": "v2026-04-03",
"title": "雪熊领秀城区多赛道挑战展示定义",
"event": map[string]any{
"title": "雪熊领秀城区多赛道挑战",
"subtitle": "A / B 线手动选择联调活动",
},
"card": map[string]any{
"heroTitle": "多赛道选择",
"heroSubtitle": "同一地点,不同路线长度与难度",
"badge": "多赛道",
},
"detail": map[string]any{
"sections": []map[string]any{
{"type": "hero", "title": "先选赛道再开始", "subtitle": "A 线偏短,B 线偏长"},
{"type": "variants", "items": []string{"A 线:短线体验版", "B 线:长线挑战版"}},
{"type": "summary", "items": []string{"适合验证 variant 选择与回流链", "默认推荐 B 线做联调"}},
},
},
},
}
var demoContentAssets = map[string]map[string]any{
"classic": {
"manifestVersion": "1",
"bundleType": "route_content",
"version": "v2026-04-03",
"title": "雪熊领秀城区顺序赛内容包",
"locale": "zh-CN",
"event": map[string]any{
"title": "雪熊领秀城区顺序赛",
"subtitle": "沿河绿道 6 点经典路线",
},
"hero": map[string]any{
"title": "绿道顺序挑战",
"subtitle": "按照既定顺序依次完成 6 个控制点",
},
"sections": []map[string]any{
{"type": "intro", "title": "活动说明", "body": "适合首次联调与基础顺序赛流程验证。"},
{"type": "tips", "title": "路线提示", "body": "默认路线沿河绿道展开,注意桥下拐点。"},
{"type": "result", "title": "结果页文案", "body": "完成后展示用时、配速与打卡完成率。"},
},
"assets": map[string]any{
"cover": "https://oss-mbh5.colormaprun.com/gotomars/assets/demo-cover.jpg",
"entryHtml": "https://oss-mbh5.colormaprun.com/gotomars/event/content-h5-test-template.html",
},
},
"score-o": {
"manifestVersion": "1",
"bundleType": "result_media",
"version": "v2026-04-03",
"title": "雪熊领秀城区积分赛内容包",
"locale": "zh-CN",
"event": map[string]any{
"title": "雪熊领秀城区积分赛",
"subtitle": "20 分钟自由取点积分挑战",
},
"hero": map[string]any{
"title": "自由规划路线",
"subtitle": "在限定时间内尽量争取更高积分",
},
"sections": []map[string]any{
{"type": "intro", "title": "玩法说明", "body": "每个控制点分值不同,优先测试路径规划与效率。"},
{"type": "tips", "title": "策略建议", "body": "建议先拿近点,再视剩余时间冲刺高分点。"},
{"type": "result", "title": "结果页文案", "body": "结果页重点展示总积分、完成点位与平均速度。"},
},
"assets": map[string]any{
"cover": "https://oss-mbh5.colormaprun.com/gotomars/assets/demo-cover.jpg",
"entryHtml": "https://oss-mbh5.colormaprun.com/gotomars/event/content-h5-test-template.html",
},
},
"manual-variant": {
"manifestVersion": "1",
"bundleType": "route_content",
"version": "v2026-04-03",
"title": "雪熊领秀城区多赛道挑战内容包",
"locale": "zh-CN",
"event": map[string]any{
"title": "雪熊领秀城区多赛道挑战",
"subtitle": "A / B 线手动选择联调活动",
},
"hero": map[string]any{
"title": "同图多赛道",
"subtitle": "先选路线,再验证 launch / result / history 回流",
},
"sections": []map[string]any{
{"type": "intro", "title": "玩法说明", "body": "A 线适合短线体验,B 线适合长线挑战与数据对比。"},
{"type": "variants", "title": "赛道差异", "body": "两条赛道使用不同 KML,用于验证 variant 选择与恢复链。"},
{"type": "result", "title": "结果页文案", "body": "结果页需展示所选赛道名、routeCode 与成绩摘要。"},
},
"assets": map[string]any{
"cover": "https://oss-mbh5.colormaprun.com/gotomars/assets/demo-cover.jpg",
"entryHtml": "https://oss-mbh5.colormaprun.com/gotomars/event/content-h5-test-template.html",
},
},
}
const devWorkbenchHTML = `
CMR Backend Workbench
Developer Workbench
CMR Backend API Flow Panel
把入口、登录、首页、活动详情、launch、session、profile 串成一条完整调试链。这个页面只在非 production 环境开放,适合后续继续扩展成你想要的 API 测试面板。
Main Flow
第一步:选玩法与准备数据
先在这里选当前要测的玩法,workbench 后面的发布链、launch、result、history 都会复用这里的 event。
第一步:选玩法
先在这里准备 demo 数据并选择玩法入口。顺序赛、积分赛、多赛道各有一套独立 demo 数据,后面一键流程都会复用这里选中的 event。
默认入口 tenant_demo / mini-demo / evt_demo_001
积分赛入口 tenant_demo / mini-demo / evt_demo_score_o_001
多赛道入口 tenant_demo / mini-demo / evt_demo_variant_manual_001
说明:Bootstrap Demo(只准备数据) 只负责把三种玩法的 demo 对象和默认样例准备好;Bootstrap + 发布当前玩法 会先准备 demo,再对当前选中的玩法执行一遍“发布活动配置(自动补 Runtime)”。
当前上下文
当前调试上下文,所有按钮共享这一组状态。
Access Token -
Refresh Token -
Source ID -
Build ID -
Release ID -
Session ID -
Session Token -
结果查询
Fast Path
第二步:选测试目标
玩法选好以后,再决定你现在要测首页、发布链、局内流程,还是整条链一次验收。
第二步:点测试目标
先选玩法入口,再按“你现在想测什么”点对应按钮。大多数情况下,你只需要点最后一个“一键标准回归”。
推荐顺序:
1. 先点上面的玩法入口:Use Classic Demo / Use Score-O Demo / Use Manual Variant Demo
2. 想直接验收,就点 整条链一键验收
3. 想只测发布链,就点 发布活动配置(自动补 Runtime)
4. 想只测局内流程,就点 快速进一局、结束并看结果
这些流程会复用当前表单里的手机号、设备、event、channel 等输入。发布活动配置(默认绑定)会自动执行:Get Event -> Import Presentation -> Import Bundle -> Save Event Defaults -> Build Source -> Publish Build -> Get Release。发布活动配置(自动补 Runtime)会在缺少默认 runtime 时自动创建 Runtime Binding,再继续发布链。整条链一键验收会先准备并发布当前玩法,再继续执行:play -> launch -> start -> finish -> result -> history。
预期结果
Release ID -
Presentation -
Content Bundle -
Runtime Binding -
判定 待执行
回归结果汇总
发布链 待执行
Play 待执行
Launch 待执行
Result 待执行
History 待执行
Session ID -
总判定 待执行
当前 Launch 实际配置摘要
Config URL -
Release ID -
Manifest URL -
Schema Version -
Playfield Kind -
Game Mode -
判定 待执行
当前玩法关键状态
Event ID -
Release ID -
Can Launch -
Assignment Mode -
Variant Count -
Game Mode -
Playfield Kind -
请求导出
最后一次请求会生成一条可复制的 curl,后面做问题复现会方便很多。
前端调试日志
前端可把 launch、manifest、地图页、结果页等调试信息直接打到 backend。这里显示最近日志,便于和 workbench 当前配置对口排查。
建议前端至少上报:eventId / releaseId / manifestUrl / game.mode / playfield.kind / 页面阶段。
Advanced
后台运营与发布
这一组给资源对象、Event 组装、Build / Publish / Rollback 使用。默认隐藏,只有需要管理配置和资源时再打开。
第一阶段生产骨架联调台
这里只做总控确认的最小范围:地点、地图资产、瓦片版本、赛道输入源、赛道集合、赛道方案、运行绑定。
场景模板
保存当前表单状态为可复用场景,也支持导入导出 JSON,适合后续切换不同俱乐部、入口和 event。
Scenario JSON
响应日志
最后一次请求的结果会记录在这里,便于后续做请求回放和用例保存。
ready
当前进度:待执行
0 / 0
长流程会在这里显示当前步骤。
请求历史
最近 12 次请求会保留在浏览器本地,刷新页面不会丢。
Reference
API 目录 (0)
需要查路径、参数、鉴权方式时再展开这一块,不影响主链调试。
API 目录
把当前已实现接口按分组放进 workbench,直接看中文说明、鉴权要求和关键参数,不用来回翻文档。
GET/healthz
健康检查接口,用来确认服务是否存活。
POST/auth/sms/send
发送短信验证码,支持登录和绑定手机号两种场景。
POST/auth/login/sms
APP 主登录入口,使用手机号验证码登录并返回 access/refresh token。
POST/auth/login/wechat-mini
微信小程序登录入口。开发环境支持 dev- 前缀 code 直接模拟登录。
POST/auth/bind/mobile
已登录用户绑定手机号,必要时把微信轻账号合并到手机号主账号。
POST/auth/refresh
使用 refresh token 刷新 access token。
POST/auth/logout
登出并撤销 refresh token。
GET/entry/resolve
解析当前入口属于哪个 tenant / channel,是多俱乐部、多公众号接入的入口层基础接口。
GET/cards
只返回卡片列表,适合调试卡片数据本身。
GET/me/entry-home
首页聚合接口,返回用户、tenant、channel、cards、进行中 session 和最近一局。
GET/events/{eventPublicID}
活动详情接口,会带当前发布的 release 和 resolvedRelease。
GET/events/{eventPublicID}/play
活动详情页 / 开始前准备页聚合接口,判断是否可启动、继续还是查看上次结果;第一阶段也会返回多赛道 assignmentMode 和 courseVariants。
POST/events/{eventPublicID}/launch
基于当前 event 的已发布 release 创建一局 session,并返回 config URL、releaseId、sessionToken;多赛道第一阶段支持可选 variantId,并返回最终绑定的 launch.variant。
GET/events/{eventPublicID}/config-sources
查看某个 event 下已经导入过的 source config 列表。
GET/config-sources/{sourceID}
查看单条 source config 明细。
GET/config-builds/{buildID}
查看单次 build 的 manifest 和 asset index。
GET/sessions/{sessionPublicID}
查询一局详情,带 session 状态、event 和 resolvedRelease。
POST/sessions/{sessionPublicID}/start
把 session 从 launched 推进到 running。
POST/sessions/{sessionPublicID}/finish
结束一局并沉淀结果摘要,是结果页数据的来源。
GET/me/sessions
查询用户最近 session 列表。
GET/sessions/{sessionPublicID}/result
单局结果页接口,返回 session 和 result。
GET/me/results
查询用户最近结果列表。
GET/me/profile
“我的页”聚合接口,返回绑定概览、绑定项列表和最近记录摘要。
POST/dev/bootstrap-demo
开发态自举 demo 数据,会准备 tenant、channel、event、release、card、source、build。
GET/dev/workbench
开发态工作台页面,集中提供一键流、日志、配置摘要、API 目录和后台运营联调入口。
POST/dev/client-logs
接收 frontend 主动上报的调试日志,供 backend 在 workbench 中统一查看。
GET/dev/client-logs
获取 frontend 最近上报的调试日志,便于 backend 直接对照排查。
DELETE/dev/client-logs
清空当前内存中的 frontend 调试日志,方便开始新一轮联调。
GET/dev/manifest-summary
由 backend 代读指定 manifest,并返回 schemaVersion、playfield.kind、game.mode 调试摘要。
GET/dev/demo-assets/presentations/{demoKey}
读取联调用的示例展示定义 schema,给 workbench 快速导入。
GET/dev/demo-assets/content-manifests/{demoKey}
读取联调用的示例内容 manifest,给 workbench 快速导入。
GET/dev/config/local-files
列出本地配置目录中的 JSON 文件,作为 source config 导入入口。
POST/dev/events/{eventPublicID}/config-sources/import-local
从本地 event 目录导入 source config。
POST/dev/config-builds/preview
基于 source config 生成 preview build,并产出 preview manifest。
POST/dev/config-builds/publish
把成功的 build 发布成正式 release,并可选直接挂接 runtime binding。
GET/admin/maps
后台地图对象列表接口。
POST/admin/maps
创建地图对象,后续再为它追加版本。
GET/admin/maps/{mapPublicID}
查看单个地图对象和它的版本列表。
POST/admin/maps/{mapPublicID}/versions
为地图对象创建一个版本,挂接 mapmeta 和 tiles 根路径。
GET/admin/places
第一阶段生产骨架的地点对象列表接口。
POST/admin/places
创建地点对象,作为地图资产的上层归属。
GET/admin/places/{placePublicID}
查看地点详情,并带出该地点下的地图资产列表。
POST/admin/places/{placePublicID}/map-assets
在指定地点下创建地图资产,可选挂接已有 legacy map。
GET/admin/map-assets/{mapAssetPublicID}
查看地图资产详情,带出瓦片版本和赛道集合摘要。
POST/admin/map-assets/{mapAssetPublicID}/tile-releases
为地图资产创建瓦片版本,可选关联已有 legacy map version。
GET/admin/course-sources
查看赛道原始输入源列表,承接 KML / GeoJSON 等输入。
POST/admin/course-sources
创建赛道输入源,为后续解析成 CourseVariant 做准备。
GET/admin/course-sources/{sourcePublicID}
查看单个赛道输入源详情。
POST/admin/map-assets/{mapAssetPublicID}/course-sets
在指定地图资产下创建赛道集合。
GET/admin/course-sets/{courseSetPublicID}
查看单个赛道集合详情和 variant 列表。
POST/admin/course-sets/{courseSetPublicID}/variants
为赛道集合创建具体可运行赛道方案。
GET/admin/runtime-bindings
查看活动运行绑定列表。
POST/admin/runtime-bindings
把活动和地点、地图资产、瓦片、赛道集合、variant 绑定起来。
GET/admin/runtime-bindings/{runtimeBindingPublicID}
查看单个运行绑定详情。
GET/admin/playfields
后台赛场对象列表接口。
POST/admin/playfields
创建赛场对象,适合管理 KML / GeoJSON 这类可复用场地资源。
GET/admin/playfields/{playfieldPublicID}
查看单个赛场对象和它的版本列表。
POST/admin/playfields/{playfieldPublicID}/versions
为赛场对象创建一个版本,挂接 KML 等源文件地址和控制点摘要。
GET/admin/resource-packs
后台资源包对象列表接口。
POST/admin/resource-packs
创建资源包对象,用来管理内容页、音频和主题资源。
GET/admin/resource-packs/{resourcePackPublicID}
查看单个资源包对象和它的版本列表。
POST/admin/resource-packs/{resourcePackPublicID}/versions
为资源包对象创建版本,配置内容入口、音频根路径和主题代码。
GET/admin/events
后台 event 列表接口。
POST/admin/events
创建 event 基础信息。
GET/admin/events/{eventPublicID}
查看 event 明细、最新 source 和当前 source 摘要。
PUT/admin/events/{eventPublicID}
更新 event 基础信息。
POST/admin/events/{eventPublicID}/source
把 map/playfield/resource pack 版本和 gameModeCode 组装成 source config。
GET/admin/events/{eventPublicID}/presentations
查看某个 event 下的展示定义列表。
POST/admin/events/{eventPublicID}/presentations
为 event 创建一条最小 presentation 定义,供 release 绑定使用。
POST/admin/events/{eventPublicID}/presentations/import
通过统一导入入口为 event 创建展示定义,先记录 templateKey、sourceType、schemaUrl、version 和 title。
GET/admin/presentations/{presentationPublicID}
查看单条 presentation 明细。
GET/admin/events/{eventPublicID}/content-bundles
查看某个 event 下的内容包列表。
POST/admin/events/{eventPublicID}/content-bundles
为 event 创建一条最小 content bundle,供 release 绑定使用。
POST/admin/events/{eventPublicID}/content-bundles/import
通过统一导入入口为 event 创建内容包,先记录 bundleType、sourceType、manifestUrl、version 和 assetManifest。
GET/admin/content-bundles/{contentBundlePublicID}
查看单条内容包明细。
POST/admin/events/{eventPublicID}/defaults
固化 event 当前默认 active 绑定,供后续 publish 在未显式传参时继承。
GET/admin/events/{eventPublicID}/pipeline
查看 event 下的 source、build、release 流水线概览。
POST/admin/sources/{sourceID}/build
基于 source 生成一条 build 记录和 preview manifest。
GET/admin/builds/{buildID}
查看后台 build 明细。
POST/admin/builds/{buildID}/publish
把后台 build 发布为正式 release,可选直接挂接 runtime binding、presentation 和内容包,并切换为 event 当前发布版本。
GET/admin/releases/{releasePublicID}
查看单个 release 明细,并带出当前已挂接的 runtime 摘要。
POST/admin/releases/{releasePublicID}/runtime-binding
把某个 runtime binding 挂接到指定 release,上游 launch 会透出新的 runtime 摘要。
POST/admin/events/{eventPublicID}/rollback
将 event 当前发布版本回滚到指定 releaseId。
`