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 (h *DevHandler) DemoGameManifest(w http.ResponseWriter, r *http.Request) {
if !h.devService.Enabled() {
http.NotFound(w, r)
return
}
key := r.PathValue("demoKey")
payload, ok := demoGameManifestAssets[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": "4 条路线手动选择联调活动",
},
"card": map[string]any{
"heroTitle": "多赛道选择",
"heroSubtitle": "同一地点,不同路线长度与难度",
"badge": "多赛道",
},
"detail": map[string]any{
"sections": []map[string]any{
{"type": "hero", "title": "先选赛道再开始", "subtitle": "路线 01 ~ 04 四选一"},
{"type": "variants", "items": []string{"路线 01", "路线 02", "路线 03", "路线 04"}},
{"type": "summary", "items": []string{"适合验证 variant 选择与回流链", "默认推荐 路线 04 做联调"}},
},
},
},
}
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": "4 条路线手动选择联调活动",
},
"hero": map[string]any{
"title": "同图多赛道",
"subtitle": "先选路线,再验证 launch / result / history 回流",
},
"sections": []map[string]any{
{"type": "intro", "title": "玩法说明", "body": "路线 01 ~ 04 使用四条不同 KML,用于验证多赛道选择与回流。"},
{"type": "variants", "title": "赛道差异", "body": "四条赛道共享同一张底图,但各自使用独立 KML。"},
{"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",
},
},
}
var demoGameManifestAssets = map[string]map[string]any{
"classic": {
"schemaVersion": "1",
"releaseId": "rel_demo_001",
"version": "2026.04.07",
"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",
},
"preview": demoGamePreview("classic"),
"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",
},
},
"score-o": {
"schemaVersion": "1",
"releaseId": "rel_demo_score_o_001",
"version": "2026.04.07",
"app": map[string]any{
"id": "sample-score-o-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",
},
"preview": demoGamePreview("score-o"),
"playfield": map[string]any{
"kind": "control-set",
"source": map[string]any{
"type": "kml",
"url": "https://oss-mbh5.colormaprun.com/gotomars/kml/lxcb-001/10/c01.kml",
},
},
"game": map[string]any{
"mode": "score-o",
},
"assets": map[string]any{
"contentHtml": "https://oss-mbh5.colormaprun.com/gotomars/event/content-h5-test-template.html",
},
},
"manual-variant": {
"schemaVersion": "1",
"releaseId": "rel_demo_variant_manual_001",
"version": "2026.04.07",
"app": map[string]any{
"id": "sample-variant-manual-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",
},
"preview": demoGamePreview("manual-variant"),
"playfield": map[string]any{
"kind": "course",
"source": map[string]any{
"type": "kml",
"url": "https://oss-mbh5.colormaprun.com/gotomars/kml/lxcb-001/2026-04-07/route04.kml",
},
},
"game": map[string]any{
"mode": "classic-sequential",
},
"play": map[string]any{
"assignmentMode": "manual",
"courseVariants": []map[string]any{
{
"id": "variant_a",
"name": "路线 01",
"description": "route01.kml",
"routeCode": "route-variant-a",
"selectable": true,
},
{
"id": "variant_b",
"name": "路线 02",
"description": "route02.kml",
"routeCode": "route-variant-b",
"selectable": true,
},
{
"id": "variant_c",
"name": "路线 03",
"description": "route03.kml",
"routeCode": "route-variant-c",
"selectable": true,
},
{
"id": "variant_d",
"name": "路线 04",
"description": "route04.kml",
"routeCode": "route-variant-d",
"selectable": true,
},
},
},
"assets": map[string]any{
"contentHtml": "https://oss-mbh5.colormaprun.com/gotomars/event/content-h5-test-template.html",
},
},
}
func demoGamePreview(kind string) map[string]any {
baseTiles := map[string]any{
"tileBaseUrl": "https://oss-mbh5.colormaprun.com/gotomars/map/lxcb-001/tiles/",
"zoom": 15,
"tileSize": 256,
}
viewport := map[string]any{
"width": 800,
"height": 450,
"minLon": 117.0000,
"minLat": 36.6000,
"maxLon": 117.0800,
"maxLat": 36.6600,
}
switch kind {
case "score-o":
return map[string]any{
"mode": "readonly",
"baseTiles": baseTiles,
"viewport": viewport,
"selectedVariantId": "variant_score_main",
"variants": []map[string]any{
{
"variantId": "variant_score_main",
"name": "积分赛主赛道",
"routeCode": "route-score-o-001",
"controls": []map[string]any{
{"id": "start", "kind": "start", "lon": 117.012, "lat": 36.612, "label": "起点"},
{"id": "s1", "kind": "control", "lon": 117.021, "lat": 36.618, "label": "10分点"},
{"id": "s2", "kind": "control", "lon": 117.034, "lat": 36.624, "label": "20分点"},
{"id": "s3", "kind": "control", "lon": 117.046, "lat": 36.616, "label": "15分点"},
{"id": "finish", "kind": "finish", "lon": 117.025, "lat": 36.606, "label": "终点"},
},
"legs": []map[string]any{
{"from": "start", "to": "s1"},
{"from": "s1", "to": "s2"},
{"from": "s2", "to": "s3"},
{"from": "s3", "to": "finish"},
},
},
},
}
case "manual-variant":
return map[string]any{
"mode": "readonly",
"baseTiles": baseTiles,
"viewport": viewport,
"selectedVariantId": "variant_d",
"variants": []map[string]any{
{
"variantId": "variant_a",
"name": "路线 01",
"routeCode": "route-variant-a",
"controls": []map[string]any{
{"id": "start", "kind": "start", "lon": 117.011, "lat": 36.611, "label": "A起点"},
{"id": "a1", "kind": "control", "lon": 117.020, "lat": 36.616, "label": "A1"},
{"id": "a2", "kind": "control", "lon": 117.028, "lat": 36.621, "label": "A2"},
{"id": "finish", "kind": "finish", "lon": 117.034, "lat": 36.626, "label": "A终点"},
},
"legs": []map[string]any{
{"from": "start", "to": "a1"},
{"from": "a1", "to": "a2"},
{"from": "a2", "to": "finish"},
},
},
{
"variantId": "variant_b",
"name": "路线 02",
"routeCode": "route-variant-b",
"controls": []map[string]any{
{"id": "start", "kind": "start", "lon": 117.014, "lat": 36.609, "label": "B起点"},
{"id": "b1", "kind": "control", "lon": 117.025, "lat": 36.615, "label": "B1"},
{"id": "b2", "kind": "control", "lon": 117.038, "lat": 36.622, "label": "B2"},
{"id": "b3", "kind": "control", "lon": 117.051, "lat": 36.629, "label": "B3"},
{"id": "finish", "kind": "finish", "lon": 117.060, "lat": 36.634, "label": "B终点"},
},
"legs": []map[string]any{
{"from": "start", "to": "b1"},
{"from": "b1", "to": "b2"},
{"from": "b2", "to": "b3"},
{"from": "b3", "to": "finish"},
},
},
{
"variantId": "variant_c",
"name": "路线 03",
"routeCode": "route-variant-c",
"controls": []map[string]any{
{"id": "1", "kind": "control", "lon": 117.000649296107, "lat": 36.5921631022497, "label": "1"},
{"id": "2", "kind": "control", "lon": 117.000665559813, "lat": 36.5919574366878, "label": "2"},
{"id": "3", "kind": "control", "lon": 117.001397426578, "lat": 36.5915983367736, "label": "3"},
{"id": "4", "kind": "control", "lon": 117.000441933857, "lat": 36.5915004001434, "label": "4"},
{"id": "5", "kind": "control", "lon": 117.000340285695, "lat": 36.5909356298175, "label": "5"},
{"id": "6", "kind": "control", "lon": 116.999860506371, "lat": 36.5912131186443, "label": "6"},
{"id": "7", "kind": "control", "lon": 116.999823913032, "lat": 36.591572220351, "label": "7"},
{"id": "8", "kind": "control", "lon": 116.999108309973, "lat": 36.5919019395375, "label": "8"},
{"id": "9", "kind": "control", "lon": 116.999689737459, "lat": 36.5922740961347, "label": "9"},
{"id": "10", "kind": "control", "lon": 117.000649296107, "lat": 36.5921631022497, "label": "10"},
},
"legs": []map[string]any{
{"from": "1", "to": "2"},
{"from": "2", "to": "3"},
{"from": "3", "to": "4"},
{"from": "4", "to": "5"},
{"from": "5", "to": "6"},
{"from": "6", "to": "7"},
{"from": "7", "to": "8"},
{"from": "8", "to": "9"},
{"from": "9", "to": "10"},
},
},
{
"variantId": "variant_d",
"name": "路线 04",
"routeCode": "route-variant-d",
"controls": []map[string]any{
{"id": "1", "kind": "control", "lon": 117.000649296107, "lat": 36.5921631022497, "label": "1"},
{"id": "2", "kind": "control", "lon": 117.000803801313, "lat": 36.5919411140006, "label": "2"},
{"id": "3", "kind": "control", "lon": 117.000937976887, "lat": 36.5916113949816, "label": "3"},
{"id": "4", "kind": "control", "lon": 117.000238637533, "lat": 36.5914742836876, "label": "4"},
{"id": "5", "kind": "control", "lon": 117.00058830721, "lat": 36.5905340853955, "label": "5"},
{"id": "6", "kind": "control", "lon": 116.998941606987, "lat": 36.5908278985923, "label": "6"},
{"id": "7", "kind": "control", "lon": 116.998774904002, "lat": 36.5913306430232, "label": "7"},
{"id": "8", "kind": "control", "lon": 116.999710067091, "lat": 36.5917615642144, "label": "8"},
{"id": "9", "kind": "control", "lon": 116.999766990062, "lat": 36.5921141343085, "label": "9"},
{"id": "10", "kind": "control", "lon": 117.000649296107, "lat": 36.5921631022497, "label": "10"},
},
"legs": []map[string]any{
{"from": "1", "to": "2"},
{"from": "2", "to": "3"},
{"from": "3", "to": "4"},
{"from": "4", "to": "5"},
{"from": "5", "to": "6"},
{"from": "6", "to": "7"},
{"from": "7", "to": "8"},
{"from": "8", "to": "9"},
{"from": "9", "to": "10"},
},
},
},
}
default:
return map[string]any{
"mode": "readonly",
"baseTiles": baseTiles,
"viewport": viewport,
"selectedVariantId": "variant_classic_main",
"variants": []map[string]any{
{
"variantId": "variant_classic_main",
"name": "顺序赛主赛道",
"routeCode": "route-demo-a",
"controls": []map[string]any{
{"id": "start", "kind": "start", "lon": 117.010, "lat": 36.610, "label": "起点"},
{"id": "c1", "kind": "control", "lon": 117.018, "lat": 36.615, "label": "1"},
{"id": "c2", "kind": "control", "lon": 117.027, "lat": 36.621, "label": "2"},
{"id": "c3", "kind": "control", "lon": 117.036, "lat": 36.627, "label": "3"},
{"id": "finish", "kind": "finish", "lon": 117.044, "lat": 36.632, "label": "终点"},
},
"legs": []map[string]any{
{"from": "start", "to": "c1"},
{"from": "c1", "to": "c2"},
{"from": "c2", "to": "c3"},
{"from": "c3", "to": "finish"},
},
},
},
}
}
}
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 -
准备页地图预览状态
Preview Mode -
Tile Base URL -
Zoom -
Viewport -
Selected Variant -
Preview Variant Count -
First Variant Controls -
First Variant Legs -
请求导出
最后一次请求会生成一条可复制的 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/admin/assets
列出当前 backend 已纳管的正式资源对象,统一查看上传文件和外链登记结果。
POST/admin/assets/register-link
登记一个已有正式外链为受管资源,不要求先上传文件。
POST/admin/assets/upload
通过 backend 上传文件到 OSS,并自动登记为受管资源对象。
GET/admin/assets/{assetPublicID}
查看单个已纳管资源的明细,包括 publicUrl、objectKey、版本、checksum 等。
GET/cards
只返回卡片列表,适合调试卡片数据本身。
GET/experience-maps
地图资源列表接口,返回 place/map 摘要、默认体验活动数量和默认活动 ID 列表。
GET/experience-maps/{mapAssetPublicID}
地图详情接口,返回地图基础信息和当前挂在该地图下的默认体验活动摘要。
GET/public/experience-maps
游客模式地图列表,返回可公开体验的地图与默认活动摘要。
GET/public/experience-maps/{mapAssetPublicID}
游客模式地图详情,返回该地图下的默认体验活动列表。
GET/me/entry-home
首页聚合接口,返回用户、tenant、channel、cards、进行中 session 和最近一局。
GET/events/{eventPublicID}
活动详情接口,会带当前发布的 release 和 resolvedRelease。
GET/public/events/{eventPublicID}
游客模式活动详情,只允许默认体验活动进入。
GET/events/{eventPublicID}/play
活动详情页 / 开始前准备页聚合接口,判断是否可启动、继续还是查看上次结果;第一阶段也会返回多赛道 assignmentMode 和 courseVariants。
GET/public/events/{eventPublicID}/play
游客模式准备页聚合接口,返回 canLaunch、多赛道信息和 preview。
POST/events/{eventPublicID}/launch
基于当前 event 的已发布 release 创建一局 session,并返回 config URL、releaseId、sessionToken;多赛道第一阶段支持可选 variantId,并返回最终绑定的 launch.variant。
POST/public/events/{eventPublicID}/launch
游客模式启动一局,返回与正式 launch 基本同构的结构,并带 business.isGuest = true。
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 目录和后台运营联调入口。
GET/admin/ops-workbench
运维后台第一期页面,专门处理资源录入、OSS 纳管、地图瓦片导入和 KML 批量导入。
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/manifests/{demoKey}
读取联调用的示例游戏 manifest,供顺序赛、积分赛、多赛道统一切换调试入口。
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。
POST/ops/auth/sms/send
发送运维账号登录或注册验证码。
POST/ops/auth/register
注册独立运维账号,首个账号默认授予 owner。
POST/ops/auth/login/sms
运维账号手机号验证码登录。
POST/ops/auth/refresh
刷新运维 access token。
POST/ops/auth/logout
撤销运维 refresh token。
GET/ops/me
获取当前运维账号信息和主角色。
GET/ops/admin/summary
运维后台总览聚合接口,返回资源、活动、运行绑定和发布版本统计。
GET/ops/admin/region-options
返回运维后台地点管理使用的全国省市两级列表,页面可直接用于省市选择。
GET/ops/admin/assets
读取受管资源列表,给运维后台资源总览与录入校验使用。
POST/ops/admin/assets/register-link
登记已有正式外链资源,统一纳管到 backend 资源对象。
POST/ops/admin/assets/upload
上传文件给 backend,再统一存入 OSS,运维不用处理底层存储细节。
GET/ops/admin/assets/{assetPublicID}
查看单个受管资源详情,包括类型、版本、正式 URL 和元数据。
GET/ops/admin/places
运维后台读取地点列表,作为地图管理流程入口。
POST/ops/admin/places
运维后台新建地点。
GET/ops/admin/places/{placePublicID}
运维后台读取地点详情,并带出当前地点下地图资源。
GET/ops/admin/map-assets
运维后台地图列表接口,返回地点、当前瓦片版本和关联活动摘要。
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}
查看单个运行绑定详情。
POST/admin/ops/tile-releases/import
运维入口第一期:按 placeCode / mapAssetCode / versionCode 导入或复用地点、地图资产和瓦片版本,并可直接设为当前版本。
POST/admin/ops/course-sets/import-kml-batch
运维入口第一期:批量登记多条 KML,自动补 course source / course set / variants,并支持指定默认赛道。
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。
`