完善联调标准化与诊断链路
This commit is contained in:
@@ -1,6 +1,6 @@
|
||||
# Backend
|
||||
> 文档版本:v1.12
|
||||
> 最后更新:2026-04-03 13:24:38
|
||||
> 文档版本:v1.17
|
||||
> 最后更新:2026-04-03 16:16:38
|
||||
|
||||
|
||||
这套后端现在已经能支撑一条完整主链:
|
||||
@@ -14,6 +14,27 @@
|
||||
- 真正进入游戏时客户端消费的是 `manifest_url`
|
||||
- `session` 会固化当时实际绑定的 `release`
|
||||
|
||||
当前 workbench 里新增的“当前 Launch 实际配置摘要”仅用于调试:
|
||||
|
||||
- 它会由 backend 代读当前 launch 对应的 manifest
|
||||
- 用来显示:
|
||||
- `configUrl`
|
||||
- `releaseId`
|
||||
- `manifestUrl`
|
||||
- `schemaVersion`
|
||||
- `playfield.kind`
|
||||
- `game.mode`
|
||||
- 这块只服务联调排查,不参与正式客户端运行链路
|
||||
- 正式客户端仍应直接消费 `launch` 返回的:
|
||||
- `launch.config.configUrl`
|
||||
- `launch.resolvedRelease.manifestUrl`
|
||||
|
||||
当前 workbench 里新增的“前端调试日志”也仅用于联调:
|
||||
|
||||
- frontend 可将页面侧调试日志 `POST` 到 `/dev/client-logs`
|
||||
- backend 会临时保留最近 200 条日志,供 workbench 查看与清空
|
||||
- 这块只用于联调排查,不替代正式生产日志体系
|
||||
|
||||
## 文档导航
|
||||
|
||||
- [文档索引](D:/dev/cmr-mini/backend/docs/README.md)
|
||||
@@ -64,5 +85,15 @@ cd D:\dev\cmr-mini\backend
|
||||
- Bootstrap Demo 自动回填最小生产骨架 ID
|
||||
- 一键测试环境:可从空白状态自动准备 demo event、source/build/release、presentation、content bundle、place、map asset、tile release、course source、course set、course variant、runtime binding,并输出逐步日志与预期判定
|
||||
- 一键标准回归:在标准发布链跑通后,继续自动验证 `play / launch / result / history`
|
||||
- 真实输入替换第一刀:`Bootstrap Demo` 已改用真实可访问的 KML 与地图资源 URL
|
||||
- manual 多赛道 demo:已切到真实 `c01.kml / c02.kml` 输入
|
||||
- 前端调试日志:
|
||||
- `POST /dev/client-logs`
|
||||
- `GET /dev/client-logs`
|
||||
- `DELETE /dev/client-logs`
|
||||
- 显式玩法入口:
|
||||
- 顺序赛:`evt_demo_001`
|
||||
- 积分赛:`evt_demo_score_o_001`
|
||||
- 多赛道:`evt_demo_variant_manual_001`
|
||||
|
||||
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
# 开发说明
|
||||
> 文档版本:v1.14
|
||||
> 最后更新:2026-04-03 20:10:25
|
||||
> 文档版本:v1.20
|
||||
> 最后更新:2026-04-03 16:16:38
|
||||
|
||||
|
||||
## 1. 环境变量
|
||||
@@ -39,6 +39,35 @@ cd D:\dev\cmr-mini\backend
|
||||
.\scripts\start-dev.ps1
|
||||
```
|
||||
|
||||
## 3. Workbench 当前重点
|
||||
|
||||
- 推荐联调入口:
|
||||
- `Bootstrap Demo`
|
||||
- `Use Classic Demo / Use Score-O Demo / Use Manual Variant Demo`
|
||||
- `整条链一键验收`
|
||||
- 如果 frontend 需要把页面侧调试日志直接打到 backend,优先使用:
|
||||
- `POST /dev/client-logs`
|
||||
- 然后在 workbench 的 `前端调试日志` 面板里查看
|
||||
- 如果需要判断前端到底拿到了哪份配置,优先看 workbench 的:
|
||||
- `当前 Launch 实际配置摘要`
|
||||
- 这块会直接显示:
|
||||
- `configUrl`
|
||||
- `releaseId`
|
||||
- `manifestUrl`
|
||||
- `schemaVersion`
|
||||
- `playfield.kind`
|
||||
- `game.mode`
|
||||
- 这组信息用于和前端地图页实际消费结果对口排查,避免只靠口头描述“像顺序赛/像积分赛”。
|
||||
- 注意:
|
||||
- 这块摘要由 backend 代读 manifest,只用于 workbench 调试
|
||||
- 这样做是为了避免浏览器直接读取 OSS 时受跨域影响
|
||||
- 它不替代正式客户端加载逻辑
|
||||
- 正式客户端仍必须直接消费 `launch.config.configUrl` 或 `launch.resolvedRelease.manifestUrl`
|
||||
- `前端调试日志` 也是调试专用能力:
|
||||
- backend 当前只在内存里保留最近 200 条
|
||||
- 适合前端把关键事实直接打进来,避免只靠截图和口头描述
|
||||
- 不替代正式生产日志体系
|
||||
|
||||
默认会设置:
|
||||
|
||||
- `APP_ENV=development`
|
||||
@@ -72,8 +101,12 @@ cd D:\dev\cmr-mini\backend
|
||||
当前推荐顺序:
|
||||
|
||||
1. `Bootstrap Demo`
|
||||
2. `一键补齐 Runtime 并发布`
|
||||
3. `一键标准回归`
|
||||
2. 选择一种玩法入口:
|
||||
- `Use Classic Demo`
|
||||
- `Use Score-O Demo`
|
||||
- `Use Manual Variant Demo`
|
||||
3. `一键补齐 Runtime 并发布`
|
||||
4. `一键标准回归`
|
||||
|
||||
当前这条一键链会自动完成:
|
||||
|
||||
@@ -94,6 +127,20 @@ cd D:\dev\cmr-mini\backend
|
||||
- `play / launch / result / history` 回归汇总
|
||||
- demo 活动残留 ongoing session 清理:
|
||||
- 会把 demo event 下历史遗留的 `launched / running` session 自动改成 `cancelled`
|
||||
- 真实输入替换第一刀:
|
||||
- `CourseSource.fileUrl` 当前已切到真实 KML:
|
||||
- `https://oss-mbh5.colormaprun.com/gotomars/kml/lxcb-001/10/c01.kml`
|
||||
- `https://oss-mbh5.colormaprun.com/gotomars/kml/lxcb-001/10/c02.kml`
|
||||
- `TileRelease.tileBaseUrl / metaUrl` 当前已切到真实地图资源:
|
||||
- `https://oss-mbh5.colormaprun.com/gotomars/map/lxcb-001/tiles/`
|
||||
- `https://oss-mbh5.colormaprun.com/gotomars/map/lxcb-001/tiles/meta.json`
|
||||
- manual 多赛道 demo 当前已使用两条真实赛道输入:
|
||||
- `variant_a -> c01.kml`
|
||||
- `variant_b -> c02.kml`
|
||||
- 显式玩法测试入口:
|
||||
- 顺序赛:`evt_demo_001 -> rel_demo_001 -> classic-sequential.json`
|
||||
- 积分赛:`evt_demo_score_o_001 -> rel_demo_score_o_001 -> score-o.json`
|
||||
- 多赛道:`evt_demo_variant_manual_001 -> rel_demo_variant_manual_001`
|
||||
|
||||
当前日志能力:
|
||||
|
||||
@@ -116,6 +163,60 @@ cd D:\dev\cmr-mini\backend
|
||||
- `History`
|
||||
- `Session ID`
|
||||
- `总判定`
|
||||
- workbench 现在还支持查看 frontend 主动上报的调试日志:
|
||||
- `拉取前端日志`
|
||||
- `清空前端日志`
|
||||
- 前端建议最少带:
|
||||
- `eventId`
|
||||
- `releaseId`
|
||||
- `sessionId`
|
||||
- `manifestUrl`
|
||||
- `route`
|
||||
- `game.mode`
|
||||
- `playfield.kind`
|
||||
- 当前页面阶段或动作名
|
||||
|
||||
### 2.2 前端调试日志最小约定
|
||||
|
||||
dev 环境下,frontend 可直接把关键调试事实发到 backend:
|
||||
|
||||
- `POST /dev/client-logs`
|
||||
|
||||
建议请求体最少包含:
|
||||
|
||||
```json
|
||||
{
|
||||
"source": "miniprogram",
|
||||
"level": "info",
|
||||
"category": "runtime",
|
||||
"message": "map page loaded manifest",
|
||||
"eventId": "evt_demo_score_o_001",
|
||||
"releaseId": "rel_xxx",
|
||||
"sessionId": "sess_xxx",
|
||||
"manifestUrl": "https://oss-mbh5.colormaprun.com/...",
|
||||
"route": "pages/map/map",
|
||||
"occurredAt": "2026-04-03T16:16:38+08:00",
|
||||
"details": {
|
||||
"schemaVersion": "1",
|
||||
"playfield.kind": "control-set",
|
||||
"game.mode": "score-o",
|
||||
"phase": "map-init"
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
当前说明:
|
||||
|
||||
- `source`:建议填终端来源,例如 `miniprogram`
|
||||
- `level`:建议填 `info / warn / error`
|
||||
- `category`:建议填 `launch / runtime / cache / network`
|
||||
- `message`:一句话说明当前发生了什么
|
||||
- `details`:放结构化调试细节,backend 原样收下
|
||||
|
||||
辅助接口:
|
||||
|
||||
- `GET /dev/client-logs?limit=50`
|
||||
- `DELETE /dev/client-logs`
|
||||
|
||||
## 3. 当前开发约定
|
||||
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
# API 清单
|
||||
> 文档版本:v1.8
|
||||
> 最后更新:2026-04-03 12:36:15
|
||||
> 文档版本:v1.9
|
||||
> 最后更新:2026-04-03 16:16:38
|
||||
|
||||
|
||||
本文档只记录当前 backend 已实现接口,不写未来规划接口。
|
||||
@@ -436,6 +436,61 @@
|
||||
|
||||
- 自动准备 demo tenant / channel / event / release / card
|
||||
|
||||
### `POST /dev/client-logs`
|
||||
|
||||
环境:
|
||||
|
||||
- 仅 non-production
|
||||
|
||||
用途:
|
||||
|
||||
- 接收 frontend 主动上报的调试日志
|
||||
- 供 backend 通过 workbench 统一查看和排查
|
||||
|
||||
请求体重点:
|
||||
|
||||
- `source`
|
||||
- `level`
|
||||
- `category`
|
||||
- `message`
|
||||
- `eventId`
|
||||
- `releaseId`
|
||||
- `sessionId`
|
||||
- `manifestUrl`
|
||||
- `route`
|
||||
- `occurredAt`
|
||||
- `details`
|
||||
|
||||
补充说明:
|
||||
|
||||
- 当前只保存在内存中
|
||||
- 默认最多保留最近 200 条
|
||||
- 仅用于联调调试
|
||||
|
||||
### `GET /dev/client-logs`
|
||||
|
||||
环境:
|
||||
|
||||
- 仅 non-production
|
||||
|
||||
用途:
|
||||
|
||||
- 获取 frontend 最近上报的调试日志
|
||||
|
||||
查询参数:
|
||||
|
||||
- `limit`
|
||||
|
||||
### `DELETE /dev/client-logs`
|
||||
|
||||
环境:
|
||||
|
||||
- 仅 non-production
|
||||
|
||||
用途:
|
||||
|
||||
- 清空当前内存中的 frontend 调试日志
|
||||
|
||||
### `GET /dev/workbench`
|
||||
|
||||
环境:
|
||||
@@ -459,6 +514,26 @@
|
||||
- scenarios
|
||||
- request history
|
||||
- curl 导出
|
||||
- frontend 调试日志查看/清空
|
||||
|
||||
### `GET /dev/manifest-summary`
|
||||
|
||||
环境:
|
||||
|
||||
- 仅 non-production
|
||||
|
||||
用途:
|
||||
|
||||
- 由 backend 代读指定 manifest
|
||||
- 返回最小调试摘要:
|
||||
- `schemaVersion`
|
||||
- `playfield.kind`
|
||||
- `game.mode`
|
||||
|
||||
补充说明:
|
||||
|
||||
- 只用于 workbench 联调排查
|
||||
- 不参与正式客户端运行链路
|
||||
|
||||
### `GET /dev/config/local-files`
|
||||
|
||||
|
||||
@@ -1,7 +1,11 @@
|
||||
package handlers
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"net/http"
|
||||
neturl "net/url"
|
||||
"time"
|
||||
|
||||
"cmr-backend/internal/httpx"
|
||||
"cmr-backend/internal/service"
|
||||
@@ -24,6 +28,59 @@ func (h *DevHandler) BootstrapDemo(w http.ResponseWriter, r *http.Request) {
|
||||
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)
|
||||
@@ -34,6 +91,75 @@ func (h *DevHandler) Workbench(w http.ResponseWriter, r *http.Request) {
|
||||
_, _ = 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 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])
|
||||
}
|
||||
|
||||
const devWorkbenchHTML = `<!doctype html>
|
||||
<html lang="zh-CN">
|
||||
<head>
|
||||
@@ -191,6 +317,7 @@ const devWorkbenchHTML = `<!doctype html>
|
||||
display: grid;
|
||||
gap: 16px;
|
||||
grid-template-columns: repeat(auto-fit, minmax(280px, 1fr));
|
||||
align-items: start;
|
||||
}
|
||||
.stack {
|
||||
display: grid;
|
||||
@@ -203,6 +330,7 @@ const devWorkbenchHTML = `<!doctype html>
|
||||
padding: 16px;
|
||||
display: grid;
|
||||
gap: 12px;
|
||||
align-content: start;
|
||||
box-shadow: 0 8px 30px rgba(0, 0, 0, 0.18);
|
||||
}
|
||||
.panel h2 {
|
||||
@@ -261,6 +389,46 @@ const devWorkbenchHTML = `<!doctype html>
|
||||
color: var(--text);
|
||||
border: 1px solid var(--line);
|
||||
}
|
||||
.btn-stack {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
.btn-badge {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
min-width: 38px;
|
||||
min-height: 20px;
|
||||
padding: 0 8px;
|
||||
border-radius: 999px;
|
||||
font-size: 11px;
|
||||
font-weight: 800;
|
||||
letter-spacing: 0.02em;
|
||||
background: rgba(255,255,255,0.16);
|
||||
color: #08231a;
|
||||
}
|
||||
.btn-badge.home {
|
||||
background: rgba(79, 209, 165, 0.22);
|
||||
color: #083226;
|
||||
}
|
||||
.btn-badge.game {
|
||||
background: rgba(255, 209, 102, 0.3);
|
||||
color: #3c2a00;
|
||||
}
|
||||
.btn-badge.publish {
|
||||
background: rgba(125, 211, 252, 0.28);
|
||||
color: #082a43;
|
||||
}
|
||||
.btn-badge.verify {
|
||||
background: rgba(251, 146, 60, 0.3);
|
||||
color: #482100;
|
||||
}
|
||||
.btn-badge.recommend {
|
||||
background: rgba(248, 113, 113, 0.28);
|
||||
color: #4a1111;
|
||||
}
|
||||
.actions {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
@@ -469,19 +637,22 @@ const devWorkbenchHTML = `<!doctype html>
|
||||
|
||||
<div class="category-head" id="nav-main" data-modes="frontend config">
|
||||
<div class="category-kicker">Main Flow</div>
|
||||
<h2>联调主区</h2>
|
||||
<p>前台联调和配置发布最常用的入口都在这里。先跑通用户主链,再处理配置与发布。</p>
|
||||
<h2>第一步:选玩法与准备数据</h2>
|
||||
<p>先在这里选当前要测的玩法,workbench 后面的发布链、launch、result、history 都会复用这里的 event。</p>
|
||||
</div>
|
||||
<div class="grid">
|
||||
<section class="panel" data-modes="frontend config admin">
|
||||
<h2>准备 Demo 数据</h2>
|
||||
<p>初始化 demo tenant / channel / event / card。</p>
|
||||
<h2>第一步:选玩法</h2>
|
||||
<p>先在这里选玩法入口。顺序赛、积分赛、多赛道各有一套独立 demo 数据,后面一键流程都会复用这里选中的 event。</p>
|
||||
<div class="actions">
|
||||
<button id="btn-bootstrap">Bootstrap Demo</button>
|
||||
<button class="secondary" id="btn-use-variant-manual-demo">Use Manual Variant Demo</button>
|
||||
<button id="btn-bootstrap"><span class="btn-stack"><span class="btn-badge recommend">先点</span><span>Bootstrap Demo</span></span></button>
|
||||
<button class="secondary" id="btn-use-classic-demo"><span class="btn-stack"><span class="btn-badge home">顺序赛</span><span>Use Classic Demo</span></span></button>
|
||||
<button class="secondary" id="btn-use-score-o-demo"><span class="btn-stack"><span class="btn-badge home">积分赛</span><span>Use Score-O Demo</span></span></button>
|
||||
<button class="secondary" id="btn-use-variant-manual-demo"><span class="btn-stack"><span class="btn-badge home">多赛道</span><span>Use Manual Variant Demo</span></span></button>
|
||||
</div>
|
||||
<div class="kv">
|
||||
<div>默认入口 <code id="bootstrap-entry">tenant_demo / mini-demo / evt_demo_001</code></div>
|
||||
<div>积分赛入口 <code id="bootstrap-score-o-entry">tenant_demo / mini-demo / evt_demo_score_o_001</code></div>
|
||||
<div>多赛道入口 <code id="bootstrap-variant-entry">tenant_demo / mini-demo / evt_demo_variant_manual_001</code></div>
|
||||
</div>
|
||||
</section>
|
||||
@@ -720,23 +891,30 @@ const devWorkbenchHTML = `<!doctype html>
|
||||
|
||||
<div class="category-head" id="nav-fast" data-modes="frontend config admin">
|
||||
<div class="category-kicker">Fast Path</div>
|
||||
<h2>快捷操作</h2>
|
||||
<p>当你只是想验证“能不能跑通”,优先使用这一组。</p>
|
||||
<h2>第二步:选测试目标</h2>
|
||||
<p>玩法选好以后,再决定你现在要测首页、发布链、局内流程,还是整条链一次验收。</p>
|
||||
</div>
|
||||
<div class="grid" style="margin-top:16px;" data-modes="frontend config admin">
|
||||
<div class="grid" style="margin-top:16px; grid-template-columns:minmax(380px,1.2fr) minmax(320px,0.8fr);" data-modes="frontend config admin">
|
||||
<section class="panel" data-modes="frontend config admin">
|
||||
<h2>一键流程</h2>
|
||||
<p>把常用接口串成一键工作流,减少重复点击。</p>
|
||||
<h2>第二步:点测试目标</h2>
|
||||
<p>先选玩法入口,再按“你现在想测什么”点对应按钮。大多数情况下,你只需要点最后一个“一键标准回归”。</p>
|
||||
<div class="actions">
|
||||
<button id="btn-flow-home">Bootstrap + WeChat + Entry Home</button>
|
||||
<button class="secondary" id="btn-flow-launch">Login + Launch + Start</button>
|
||||
<button class="ghost" id="btn-flow-finish">Finish Current Session</button>
|
||||
<button class="ghost" id="btn-flow-result">Finish + Result</button>
|
||||
<button class="secondary" id="btn-flow-admin-default-publish">一键默认绑定发布</button>
|
||||
<button class="secondary" id="btn-flow-admin-runtime-publish">一键补齐 Runtime 并发布</button>
|
||||
<button class="secondary" id="btn-flow-standard-regression">一键标准回归</button>
|
||||
<button id="btn-flow-home"><span class="btn-stack"><span class="btn-badge home">首页</span><span>看首页是否正常</span></span></button>
|
||||
<button class="secondary" id="btn-flow-launch"><span class="btn-stack"><span class="btn-badge game">局内</span><span>快速进一局</span></span></button>
|
||||
<button class="ghost" id="btn-flow-finish"><span class="btn-stack"><span class="btn-badge game">局内</span><span>结束当前这一局</span></span></button>
|
||||
<button class="ghost" id="btn-flow-result"><span class="btn-stack"><span class="btn-badge game">结果</span><span>结束并看结果</span></span></button>
|
||||
<button class="secondary" id="btn-flow-admin-default-publish"><span class="btn-stack"><span class="btn-badge publish">发布</span><span>发布活动配置(默认绑定)</span></span></button>
|
||||
<button class="secondary" id="btn-flow-admin-runtime-publish"><span class="btn-stack"><span class="btn-badge publish">发布</span><span>发布活动配置(自动补 Runtime)</span></span></button>
|
||||
<button class="secondary" id="btn-flow-standard-regression"><span class="btn-stack"><span class="btn-badge verify">推荐</span><span>整条链一键验收</span></span></button>
|
||||
</div>
|
||||
<div class="muted-note">这些流程会复用当前表单里的手机号、设备、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。</div>
|
||||
<div class="muted-note">
|
||||
推荐顺序:
|
||||
<br>1. 先点上面的玩法入口:Use Classic Demo / Use Score-O Demo / Use Manual Variant Demo
|
||||
<br>2. 想直接验收,就点 整条链一键验收
|
||||
<br>3. 想只测发布链,就点 发布活动配置(自动补 Runtime)
|
||||
<br>4. 想只测局内流程,就点 快速进一局、结束并看结果
|
||||
</div>
|
||||
<div class="muted-note">这些流程会复用当前表单里的手机号、设备、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。</div>
|
||||
<div class="subpanel">
|
||||
<div class="muted-note">预期结果</div>
|
||||
<div class="kv">
|
||||
@@ -759,20 +937,45 @@ const devWorkbenchHTML = `<!doctype html>
|
||||
<div>总判定 <code id="flow-regression-overall">待执行</code></div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="subpanel">
|
||||
<div class="muted-note">当前 Launch 实际配置摘要</div>
|
||||
<div class="kv">
|
||||
<div>Config URL <code id="launch-config-url">-</code></div>
|
||||
<div>Release ID <code id="launch-config-release-id">-</code></div>
|
||||
<div>Manifest URL <code id="launch-config-manifest-url">-</code></div>
|
||||
<div>Schema Version <code id="launch-config-schema-version">-</code></div>
|
||||
<div>Playfield Kind <code id="launch-config-playfield-kind">-</code></div>
|
||||
<div>Game Mode <code id="launch-config-game-mode">-</code></div>
|
||||
<div>判定 <code id="launch-config-verdict">待执行</code></div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class="panel" data-modes="common">
|
||||
<h2>请求导出</h2>
|
||||
<p>最后一次请求会生成一条可复制的 curl,后面做问题复现会方便很多。</p>
|
||||
<div class="actions">
|
||||
<button id="btn-copy-curl">Copy Last Curl</button>
|
||||
<button class="ghost" id="btn-clear-history">Clear History</button>
|
||||
</div>
|
||||
<div class="subpanel">
|
||||
<div class="muted-note">Last Curl</div>
|
||||
<div id="curl" class="log" style="min-height:120px; max-height:200px;"></div>
|
||||
</div>
|
||||
</section>
|
||||
<div class="stack" data-modes="common frontend">
|
||||
<section class="panel" data-modes="common">
|
||||
<h2>请求导出</h2>
|
||||
<p>最后一次请求会生成一条可复制的 curl,后面做问题复现会方便很多。</p>
|
||||
<div class="actions">
|
||||
<button id="btn-copy-curl">Copy Last Curl</button>
|
||||
<button class="ghost" id="btn-clear-history">Clear History</button>
|
||||
</div>
|
||||
<div class="subpanel">
|
||||
<div class="muted-note">Last Curl</div>
|
||||
<div id="curl" class="log" style="min-height:120px; max-height:200px;"></div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class="panel" data-modes="common frontend">
|
||||
<h2>前端调试日志</h2>
|
||||
<p>前端可把 launch、manifest、地图页、结果页等调试信息直接打到 backend。这里显示最近日志,便于和 workbench 当前配置对口排查。</p>
|
||||
<div class="actions">
|
||||
<button id="btn-client-logs-refresh">拉取前端日志</button>
|
||||
<button class="ghost" id="btn-client-logs-clear">清空前端日志</button>
|
||||
</div>
|
||||
<div class="muted-note">建议前端至少上报:eventId / releaseId / manifestUrl / game.mode / playfield.kind / 页面阶段。</div>
|
||||
<div id="client-logs" class="log" style="min-height:180px; max-height:420px;"></div>
|
||||
</section>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="category-head" id="nav-admin" data-modes="config admin">
|
||||
@@ -1633,6 +1836,45 @@ const devWorkbenchHTML = `<!doctype html>
|
||||
<div class="api-meta"><div><strong>鉴权:</strong>仅 non-production,无需鉴权</div></div>
|
||||
</div>
|
||||
|
||||
<div class="api-item" data-api="dev workbench 工作台 面板 调试">
|
||||
<div class="api-head"><span class="api-method">GET</span><span class="api-path">/dev/workbench</span></div>
|
||||
<div class="api-desc">开发态工作台页面,集中提供一键流、日志、配置摘要、API 目录和后台运营联调入口。</div>
|
||||
<div class="api-meta"><div><strong>鉴权:</strong>仅 non-production,无需鉴权</div></div>
|
||||
</div>
|
||||
|
||||
<div class="api-item" data-api="dev client logs 前端 调试 日志 上报">
|
||||
<div class="api-head"><span class="api-method">POST</span><span class="api-path">/dev/client-logs</span></div>
|
||||
<div class="api-desc">接收 frontend 主动上报的调试日志,供 backend 在 workbench 中统一查看。</div>
|
||||
<div class="api-meta">
|
||||
<div><strong>鉴权:</strong>仅 non-production,无需鉴权</div>
|
||||
<div><strong>关键参数:</strong><code>source</code>、<code>level</code>、<code>category</code>、<code>message</code>、<code>eventId</code>、<code>releaseId</code>、<code>sessionId</code>、<code>manifestUrl</code>、<code>route</code>、<code>details</code></div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="api-item" data-api="dev client logs 前端 调试 日志 列表">
|
||||
<div class="api-head"><span class="api-method">GET</span><span class="api-path">/dev/client-logs</span></div>
|
||||
<div class="api-desc">获取 frontend 最近上报的调试日志,便于 backend 直接对照排查。</div>
|
||||
<div class="api-meta">
|
||||
<div><strong>鉴权:</strong>仅 non-production,无需鉴权</div>
|
||||
<div><strong>查询参数:</strong><code>limit</code></div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="api-item" data-api="dev client logs 前端 调试 日志 清空">
|
||||
<div class="api-head"><span class="api-method">DELETE</span><span class="api-path">/dev/client-logs</span></div>
|
||||
<div class="api-desc">清空当前内存中的 frontend 调试日志,方便开始新一轮联调。</div>
|
||||
<div class="api-meta"><div><strong>鉴权:</strong>仅 non-production,无需鉴权</div></div>
|
||||
</div>
|
||||
|
||||
<div class="api-item" data-api="dev manifest summary manifest 摘要 代读 调试">
|
||||
<div class="api-head"><span class="api-method">GET</span><span class="api-path">/dev/manifest-summary</span></div>
|
||||
<div class="api-desc">由 backend 代读指定 manifest,并返回 <code>schemaVersion</code>、<code>playfield.kind</code>、<code>game.mode</code> 调试摘要。</div>
|
||||
<div class="api-meta">
|
||||
<div><strong>鉴权:</strong>仅 non-production,无需鉴权</div>
|
||||
<div><strong>查询参数:</strong><code>url</code></div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="api-item" data-api="dev config local files 本地 配置 文件 列表">
|
||||
<div class="api-head"><span class="api-method">GET</span><span class="api-path">/dev/config/local-files</span></div>
|
||||
<div class="api-desc">列出本地配置目录中的 JSON 文件,作为 source config 导入入口。</div>
|
||||
@@ -2257,6 +2499,159 @@ const devWorkbenchHTML = `<!doctype html>
|
||||
$('flow-regression-overall').textContent = overall;
|
||||
}
|
||||
|
||||
function resetLaunchConfigSummary() {
|
||||
$('launch-config-url').textContent = '-';
|
||||
$('launch-config-release-id').textContent = '-';
|
||||
$('launch-config-manifest-url').textContent = '-';
|
||||
$('launch-config-schema-version').textContent = '-';
|
||||
$('launch-config-playfield-kind').textContent = '-';
|
||||
$('launch-config-game-mode').textContent = '-';
|
||||
$('launch-config-verdict').textContent = '待执行';
|
||||
}
|
||||
|
||||
async function resolveLaunchConfigSummary(launchPayload) {
|
||||
const launchData = launchPayload && launchPayload.launch ? launchPayload.launch : {};
|
||||
const config = launchData.config || {};
|
||||
const resolvedRelease = launchData.resolvedRelease || {};
|
||||
const configUrl = config.configUrl || '-';
|
||||
const releaseId = config.releaseId || resolvedRelease.releaseId || '-';
|
||||
const manifestUrl = resolvedRelease.manifestUrl || config.configUrl || '-';
|
||||
const summary = {
|
||||
configUrl: configUrl,
|
||||
releaseId: releaseId,
|
||||
manifestUrl: manifestUrl,
|
||||
schemaVersion: '-',
|
||||
playfieldKind: '-',
|
||||
gameMode: '-',
|
||||
verdict: '未读取 manifest'
|
||||
};
|
||||
const targetUrl = config.configUrl || resolvedRelease.manifestUrl;
|
||||
if (!targetUrl) {
|
||||
summary.verdict = '未通过:launch 未返回 configUrl / manifestUrl';
|
||||
return summary;
|
||||
}
|
||||
try {
|
||||
const proxy = await request('GET', '/dev/manifest-summary?url=' + encodeURIComponent(targetUrl));
|
||||
const data = proxy && proxy.data ? proxy.data : {};
|
||||
summary.schemaVersion = data.schemaVersion || '-';
|
||||
summary.playfieldKind = data.playfieldKind || '-';
|
||||
summary.gameMode = data.gameMode || '-';
|
||||
if (summary.schemaVersion !== '-' && summary.playfieldKind !== '-' && summary.gameMode !== '-') {
|
||||
summary.verdict = '通过:已读取 launch 实际 manifest 摘要';
|
||||
} else {
|
||||
summary.verdict = '未通过:manifest 已读取,但关键信息不完整';
|
||||
}
|
||||
return summary;
|
||||
} catch (error) {
|
||||
summary.verdict = '未通过:manifest 读取异常';
|
||||
summary.error = error && error.message ? error.message : String(error);
|
||||
return summary;
|
||||
}
|
||||
}
|
||||
|
||||
function setLaunchConfigSummary(summary) {
|
||||
const data = summary || {};
|
||||
$('launch-config-url').textContent = data.configUrl || '-';
|
||||
$('launch-config-release-id').textContent = data.releaseId || '-';
|
||||
$('launch-config-manifest-url').textContent = data.manifestUrl || '-';
|
||||
$('launch-config-schema-version').textContent = data.schemaVersion || '-';
|
||||
$('launch-config-playfield-kind').textContent = data.playfieldKind || '-';
|
||||
$('launch-config-game-mode').textContent = data.gameMode || '-';
|
||||
$('launch-config-verdict').textContent = data.verdict || '待执行';
|
||||
}
|
||||
|
||||
function renderClientLogs(items) {
|
||||
const logs = Array.isArray(items) ? items : [];
|
||||
if (!logs.length) {
|
||||
$('client-logs').textContent = '暂无前端调试日志';
|
||||
return;
|
||||
}
|
||||
$('client-logs').textContent = logs.map(function(item) {
|
||||
const lines = [];
|
||||
lines.push('[' + (item.receivedAt || '-') + '] #' + (item.id || '-') + ' ' + (item.level || 'info').toUpperCase() + ' ' + (item.source || 'unknown'));
|
||||
if (item.category) {
|
||||
lines.push('category: ' + item.category);
|
||||
}
|
||||
lines.push('message: ' + (item.message || '-'));
|
||||
if (item.eventId || item.releaseId || item.sessionId) {
|
||||
lines.push('event/release/session: ' + (item.eventId || '-') + ' / ' + (item.releaseId || '-') + ' / ' + (item.sessionId || '-'));
|
||||
}
|
||||
if (item.manifestUrl) {
|
||||
lines.push('manifest: ' + item.manifestUrl);
|
||||
}
|
||||
if (item.route) {
|
||||
lines.push('route: ' + item.route);
|
||||
}
|
||||
if (item.details && Object.keys(item.details).length) {
|
||||
lines.push('details: ' + JSON.stringify(item.details, null, 2));
|
||||
}
|
||||
return lines.join('\n');
|
||||
}).join('\n\n---\n\n');
|
||||
}
|
||||
|
||||
async function refreshClientLogs() {
|
||||
const result = await request('GET', '/dev/client-logs?limit=50');
|
||||
renderClientLogs(result.data);
|
||||
return result;
|
||||
}
|
||||
|
||||
function selectBootstrapContextForEvent(bootstrap, eventId) {
|
||||
const data = bootstrap || {};
|
||||
if (eventId && data.scoreOEventId && eventId === data.scoreOEventId) {
|
||||
return {
|
||||
eventId: data.scoreOEventId,
|
||||
releaseId: data.scoreOReleaseId || '',
|
||||
sourceId: data.scoreOSourceId || '',
|
||||
buildId: data.scoreOBuildId || '',
|
||||
courseSetId: data.scoreOCourseSetId || '',
|
||||
courseVariantId: data.scoreOCourseVariantId || '',
|
||||
runtimeBindingId: data.scoreORuntimeBindingId || ''
|
||||
};
|
||||
}
|
||||
if (eventId && data.variantManualEventId && eventId === data.variantManualEventId) {
|
||||
return {
|
||||
eventId: data.variantManualEventId,
|
||||
releaseId: data.variantManualReleaseId || '',
|
||||
sourceId: data.sourceId || '',
|
||||
buildId: data.buildId || '',
|
||||
courseSetId: data.courseSetId || '',
|
||||
courseVariantId: data.courseVariantId || '',
|
||||
runtimeBindingId: data.runtimeBindingId || ''
|
||||
};
|
||||
}
|
||||
return {
|
||||
eventId: data.eventId || '',
|
||||
releaseId: data.releaseId || '',
|
||||
sourceId: data.sourceId || '',
|
||||
buildId: data.buildId || '',
|
||||
courseSetId: data.courseSetId || '',
|
||||
courseVariantId: data.courseVariantId || '',
|
||||
runtimeBindingId: data.runtimeBindingId || ''
|
||||
};
|
||||
}
|
||||
|
||||
function applyBootstrapContext(bootstrap, explicitEventId) {
|
||||
const eventId = explicitEventId || $('event-id').value || $('admin-event-ref-id').value || '';
|
||||
const selected = selectBootstrapContextForEvent(bootstrap, eventId);
|
||||
state.sourceId = selected.sourceId || state.sourceId;
|
||||
state.buildId = selected.buildId || state.buildId;
|
||||
state.releaseId = selected.releaseId || state.releaseId;
|
||||
$('admin-pipeline-source-id').value = selected.sourceId || $('admin-pipeline-source-id').value;
|
||||
$('admin-pipeline-build-id').value = selected.buildId || $('admin-pipeline-build-id').value;
|
||||
$('admin-pipeline-release-id').value = selected.releaseId || $('admin-pipeline-release-id').value;
|
||||
$('event-release-id').value = selected.releaseId || $('event-release-id').value;
|
||||
$('prod-runtime-event-id').value = selected.eventId || $('prod-runtime-event-id').value;
|
||||
$('prod-place-id').value = bootstrap.placeId || $('prod-place-id').value;
|
||||
$('prod-map-asset-id').value = bootstrap.mapAssetId || $('prod-map-asset-id').value;
|
||||
$('prod-tile-release-id').value = bootstrap.tileReleaseId || $('prod-tile-release-id').value;
|
||||
$('prod-course-source-id').value = bootstrap.courseSourceId || $('prod-course-source-id').value;
|
||||
$('prod-course-set-id').value = selected.courseSetId || $('prod-course-set-id').value;
|
||||
$('prod-course-variant-id').value = selected.courseVariantId || $('prod-course-variant-id').value;
|
||||
$('prod-runtime-binding-id').value = selected.runtimeBindingId || $('prod-runtime-binding-id').value;
|
||||
syncState();
|
||||
return selected;
|
||||
}
|
||||
|
||||
function extractList(payload) {
|
||||
if (Array.isArray(payload)) {
|
||||
return payload;
|
||||
@@ -2306,20 +2701,7 @@ const devWorkbenchHTML = `<!doctype html>
|
||||
writeLog(flowTitle + '.step', { step: 'bootstrap-demo' });
|
||||
const bootstrap = await request('POST', '/dev/bootstrap-demo');
|
||||
if (bootstrap.data) {
|
||||
state.sourceId = bootstrap.data.sourceId || state.sourceId;
|
||||
state.buildId = bootstrap.data.buildId || state.buildId;
|
||||
state.releaseId = bootstrap.data.releaseId || state.releaseId;
|
||||
$('admin-pipeline-source-id').value = bootstrap.data.sourceId || $('admin-pipeline-source-id').value;
|
||||
$('admin-pipeline-build-id').value = bootstrap.data.buildId || $('admin-pipeline-build-id').value;
|
||||
$('admin-pipeline-release-id').value = bootstrap.data.releaseId || $('admin-pipeline-release-id').value;
|
||||
$('prod-runtime-event-id').value = bootstrap.data.eventId || $('prod-runtime-event-id').value;
|
||||
$('prod-place-id').value = bootstrap.data.placeId || $('prod-place-id').value;
|
||||
$('prod-map-asset-id').value = bootstrap.data.mapAssetId || $('prod-map-asset-id').value;
|
||||
$('prod-tile-release-id').value = bootstrap.data.tileReleaseId || $('prod-tile-release-id').value;
|
||||
$('prod-course-source-id').value = bootstrap.data.courseSourceId || $('prod-course-source-id').value;
|
||||
$('prod-course-set-id').value = bootstrap.data.courseSetId || $('prod-course-set-id').value;
|
||||
$('prod-course-variant-id').value = bootstrap.data.courseVariantId || $('prod-course-variant-id').value;
|
||||
$('prod-runtime-binding-id').value = bootstrap.data.runtimeBindingId || $('prod-runtime-binding-id').value;
|
||||
applyBootstrapContext(bootstrap.data, eventId);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2336,6 +2718,9 @@ const devWorkbenchHTML = `<!doctype html>
|
||||
if (eventDetail.data.currentRuntime && eventDetail.data.currentRuntime.runtimeBindingId) {
|
||||
$('admin-release-runtime-binding-id').value = eventDetail.data.currentRuntime.runtimeBindingId;
|
||||
$('prod-runtime-binding-id').value = eventDetail.data.currentRuntime.runtimeBindingId;
|
||||
} else {
|
||||
$('admin-release-runtime-binding-id').value = '';
|
||||
$('prod-runtime-binding-id').value = '';
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2539,6 +2924,9 @@ const devWorkbenchHTML = `<!doctype html>
|
||||
launch.data.launch.resolvedRelease &&
|
||||
launch.data.launch.resolvedRelease.manifestUrl
|
||||
);
|
||||
const launchConfigSummary = await resolveLaunchConfigSummary(launch.data);
|
||||
setLaunchConfigSummary(launchConfigSummary);
|
||||
writeLog(flowTitle + '.launch-summary', launchConfigSummary);
|
||||
|
||||
writeLog(flowTitle + '.step', {
|
||||
step: 'session-start',
|
||||
@@ -2618,6 +3006,41 @@ const devWorkbenchHTML = `<!doctype html>
|
||||
};
|
||||
}
|
||||
|
||||
function applyFrontendDemoSelection(options) {
|
||||
resetLaunchConfigSummary();
|
||||
$('entry-channel-code').value = 'mini-demo';
|
||||
$('entry-channel-type').value = 'wechat_mini';
|
||||
$('event-id').value = options.eventId;
|
||||
$('event-release-id').value = options.releaseId;
|
||||
$('event-variant-id').value = options.variantId || '';
|
||||
$('config-event-id').value = options.eventId;
|
||||
$('admin-event-ref-id').value = options.eventId;
|
||||
$('local-config-file').value = options.localConfigFile || $('local-config-file').value;
|
||||
if (options.gameModeCode) {
|
||||
$('admin-game-mode-code').value = options.gameModeCode;
|
||||
$('prod-course-mode').value = options.gameModeCode;
|
||||
}
|
||||
$('prod-runtime-event-id').value = options.eventId;
|
||||
$('prod-course-set-id').value = options.courseSetId || $('prod-course-set-id').value;
|
||||
$('prod-course-variant-id').value = options.courseVariantId || $('prod-course-variant-id').value;
|
||||
$('prod-runtime-binding-id').value = options.runtimeBindingId || '';
|
||||
$('admin-release-runtime-binding-id').value = options.runtimeBindingId || '';
|
||||
$('admin-pipeline-source-id').value = options.sourceId || '';
|
||||
$('admin-pipeline-build-id').value = options.buildId || '';
|
||||
state.sourceId = options.sourceId || '';
|
||||
state.buildId = options.buildId || '';
|
||||
state.releaseId = options.releaseId || state.releaseId;
|
||||
localStorage.setItem(MODE_KEY, 'frontend');
|
||||
syncWorkbenchMode();
|
||||
writeLog(options.logTitle, {
|
||||
eventId: $('event-id').value,
|
||||
releaseId: $('event-release-id').value,
|
||||
variantId: $('event-variant-id').value || null,
|
||||
localConfigFile: $('local-config-file').value
|
||||
});
|
||||
setStatus(options.statusText);
|
||||
}
|
||||
|
||||
function setStatus(text, isError = false) {
|
||||
statusEl.textContent = text;
|
||||
statusEl.className = isError ? 'status error' : 'status';
|
||||
@@ -3485,6 +3908,10 @@ const devWorkbenchHTML = `<!doctype html>
|
||||
}, true);
|
||||
state.sessionId = result.data.launch.business.sessionId;
|
||||
state.sessionToken = result.data.launch.business.sessionToken;
|
||||
syncState();
|
||||
const configSummary = await resolveLaunchConfigSummary(result.data);
|
||||
setLaunchConfigSummary(configSummary);
|
||||
writeLog('event-launch.summary', configSummary);
|
||||
return result;
|
||||
});
|
||||
|
||||
@@ -4221,6 +4648,16 @@ const devWorkbenchHTML = `<!doctype html>
|
||||
setStatus('ok: history cleared');
|
||||
};
|
||||
|
||||
$('btn-client-logs-refresh').onclick = () => run('dev/client-logs', async () => {
|
||||
return await refreshClientLogs();
|
||||
});
|
||||
|
||||
$('btn-client-logs-clear').onclick = () => run('dev/client-logs/clear', async () => {
|
||||
const result = await request('DELETE', '/dev/client-logs');
|
||||
renderClientLogs([]);
|
||||
return result;
|
||||
});
|
||||
|
||||
$('btn-scenario-save').onclick = saveCurrentScenario;
|
||||
$('btn-scenario-load').onclick = loadSelectedScenario;
|
||||
$('btn-scenario-delete').onclick = deleteSelectedScenario;
|
||||
@@ -4228,7 +4665,10 @@ const devWorkbenchHTML = `<!doctype html>
|
||||
$('btn-scenario-import').onclick = importScenarioFromJSON;
|
||||
|
||||
$('btn-flow-home').onclick = () => run('flow-home', async () => {
|
||||
await request('POST', '/dev/bootstrap-demo');
|
||||
const bootstrap = await request('POST', '/dev/bootstrap-demo');
|
||||
if (bootstrap.data) {
|
||||
applyBootstrapContext(bootstrap.data);
|
||||
}
|
||||
const login = await request('POST', '/auth/login/wechat-mini', {
|
||||
code: $('wechat-code').value,
|
||||
clientType: 'wechat',
|
||||
@@ -4241,43 +4681,70 @@ const devWorkbenchHTML = `<!doctype html>
|
||||
|
||||
$('btn-bootstrap').onclick = () => run('bootstrap-demo', async () => {
|
||||
const result = await request('POST', '/dev/bootstrap-demo');
|
||||
state.sourceId = result.data.sourceId || '';
|
||||
state.buildId = result.data.buildId || '';
|
||||
state.releaseId = result.data.releaseId || state.releaseId || '';
|
||||
if (result.data.releaseId) {
|
||||
$('event-release-id').value = result.data.releaseId;
|
||||
}
|
||||
$('prod-runtime-event-id').value = result.data.eventId || $('prod-runtime-event-id').value;
|
||||
$('prod-place-id').value = result.data.placeId || $('prod-place-id').value;
|
||||
$('prod-map-asset-id').value = result.data.mapAssetId || $('prod-map-asset-id').value;
|
||||
$('prod-tile-release-id').value = result.data.tileReleaseId || $('prod-tile-release-id').value;
|
||||
$('prod-course-source-id').value = result.data.courseSourceId || $('prod-course-source-id').value;
|
||||
$('prod-course-set-id').value = result.data.courseSetId || $('prod-course-set-id').value;
|
||||
$('prod-course-variant-id').value = result.data.courseVariantId || $('prod-course-variant-id').value;
|
||||
$('prod-runtime-binding-id').value = result.data.runtimeBindingId || $('prod-runtime-binding-id').value;
|
||||
applyBootstrapContext(result.data);
|
||||
return result;
|
||||
});
|
||||
|
||||
$('btn-use-classic-demo').onclick = () => run('use-classic-demo', async () => {
|
||||
const result = await request('POST', '/dev/bootstrap-demo');
|
||||
applyFrontendDemoSelection({
|
||||
eventId: result.data.eventId || 'evt_demo_001',
|
||||
releaseId: result.data.releaseId || 'rel_demo_001',
|
||||
localConfigFile: 'classic-sequential.json',
|
||||
gameModeCode: 'classic-sequential',
|
||||
sourceId: result.data.sourceId || '',
|
||||
buildId: result.data.buildId || '',
|
||||
courseSetId: result.data.courseSetId || '',
|
||||
courseVariantId: result.data.courseVariantId || '',
|
||||
runtimeBindingId: result.data.runtimeBindingId || '',
|
||||
logTitle: 'classic-demo-ready',
|
||||
statusText: 'ok: classic demo loaded'
|
||||
});
|
||||
return result;
|
||||
});
|
||||
|
||||
$('btn-use-score-o-demo').onclick = () => run('use-score-o-demo', async () => {
|
||||
const result = await request('POST', '/dev/bootstrap-demo');
|
||||
applyFrontendDemoSelection({
|
||||
eventId: result.data.scoreOEventId || 'evt_demo_score_o_001',
|
||||
releaseId: result.data.scoreOReleaseId || 'rel_demo_score_o_001',
|
||||
localConfigFile: 'score-o.json',
|
||||
gameModeCode: 'score-o',
|
||||
sourceId: result.data.scoreOSourceId || '',
|
||||
buildId: result.data.scoreOBuildId || '',
|
||||
courseSetId: result.data.scoreOCourseSetId || '',
|
||||
courseVariantId: result.data.scoreOCourseVariantId || '',
|
||||
runtimeBindingId: result.data.scoreORuntimeBindingId || '',
|
||||
logTitle: 'score-o-demo-ready',
|
||||
statusText: 'ok: score-o demo loaded'
|
||||
});
|
||||
return result;
|
||||
});
|
||||
|
||||
$('btn-use-variant-manual-demo').onclick = () => run('use-variant-manual-demo', async () => {
|
||||
const result = await request('POST', '/dev/bootstrap-demo');
|
||||
$('entry-channel-code').value = 'mini-demo';
|
||||
$('entry-channel-type').value = 'wechat_mini';
|
||||
$('event-id').value = result.data.variantManualEventId || 'evt_demo_variant_manual_001';
|
||||
$('event-release-id').value = result.data.variantManualReleaseId || 'rel_demo_variant_manual_001';
|
||||
$('event-variant-id').value = 'variant_b';
|
||||
localStorage.setItem(MODE_KEY, 'frontend');
|
||||
syncWorkbenchMode();
|
||||
writeLog('variant-manual-demo-ready', {
|
||||
eventId: $('event-id').value,
|
||||
releaseId: $('event-release-id').value,
|
||||
variantId: $('event-variant-id').value
|
||||
applyFrontendDemoSelection({
|
||||
eventId: result.data.variantManualEventId || 'evt_demo_variant_manual_001',
|
||||
releaseId: result.data.variantManualReleaseId || 'rel_demo_variant_manual_001',
|
||||
variantId: 'variant_b',
|
||||
localConfigFile: 'classic-sequential.json',
|
||||
gameModeCode: 'classic-sequential',
|
||||
sourceId: result.data.sourceId || '',
|
||||
buildId: result.data.buildId || '',
|
||||
courseSetId: result.data.courseSetId || '',
|
||||
courseVariantId: result.data.courseVariantId || '',
|
||||
runtimeBindingId: '',
|
||||
logTitle: 'variant-manual-demo-ready',
|
||||
statusText: 'ok: manual variant demo loaded'
|
||||
});
|
||||
setStatus('ok: manual variant demo loaded');
|
||||
return result;
|
||||
});
|
||||
|
||||
$('btn-flow-launch').onclick = () => run('flow-launch', async () => {
|
||||
await request('POST', '/dev/bootstrap-demo');
|
||||
const bootstrap = await request('POST', '/dev/bootstrap-demo');
|
||||
if (bootstrap.data) {
|
||||
applyBootstrapContext(bootstrap.data);
|
||||
}
|
||||
const smsSend = await request('POST', '/auth/sms/send', {
|
||||
countryCode: $('sms-country').value,
|
||||
mobile: $('sms-mobile').value,
|
||||
@@ -4388,6 +4855,7 @@ const devWorkbenchHTML = `<!doctype html>
|
||||
renderScenarioOptions();
|
||||
applyAPIFilter();
|
||||
syncAPICounts();
|
||||
renderClientLogs([]);
|
||||
writeLog('workbench-ready', { ok: true, hint: 'Use Bootstrap Demo first on a fresh database.' });
|
||||
</script>
|
||||
</body>
|
||||
|
||||
@@ -105,6 +105,10 @@ func NewRouter(
|
||||
if appEnv != "production" {
|
||||
mux.HandleFunc("GET /dev/workbench", devHandler.Workbench)
|
||||
mux.HandleFunc("POST /dev/bootstrap-demo", devHandler.BootstrapDemo)
|
||||
mux.HandleFunc("POST /dev/client-logs", devHandler.CreateClientLog)
|
||||
mux.HandleFunc("GET /dev/client-logs", devHandler.ListClientLogs)
|
||||
mux.HandleFunc("DELETE /dev/client-logs", devHandler.ClearClientLogs)
|
||||
mux.HandleFunc("GET /dev/manifest-summary", devHandler.ManifestSummary)
|
||||
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)
|
||||
|
||||
@@ -3,6 +3,9 @@ package service
|
||||
import (
|
||||
"context"
|
||||
"net/http"
|
||||
"sort"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"cmr-backend/internal/apperr"
|
||||
"cmr-backend/internal/store/postgres"
|
||||
@@ -11,6 +14,39 @@ import (
|
||||
type DevService struct {
|
||||
appEnv string
|
||||
store *postgres.Store
|
||||
mu sync.Mutex
|
||||
logSeq int64
|
||||
logs []ClientDebugLogEntry
|
||||
}
|
||||
|
||||
type ClientDebugLogEntry struct {
|
||||
ID int64 `json:"id"`
|
||||
Source string `json:"source"`
|
||||
Level string `json:"level"`
|
||||
Category string `json:"category,omitempty"`
|
||||
Message string `json:"message"`
|
||||
EventID string `json:"eventId,omitempty"`
|
||||
ReleaseID string `json:"releaseId,omitempty"`
|
||||
SessionID string `json:"sessionId,omitempty"`
|
||||
ManifestURL string `json:"manifestUrl,omitempty"`
|
||||
Route string `json:"route,omitempty"`
|
||||
OccurredAt time.Time `json:"occurredAt"`
|
||||
ReceivedAt time.Time `json:"receivedAt"`
|
||||
Details map[string]any `json:"details,omitempty"`
|
||||
}
|
||||
|
||||
type CreateClientDebugLogInput struct {
|
||||
Source string `json:"source"`
|
||||
Level string `json:"level"`
|
||||
Category string `json:"category"`
|
||||
Message string `json:"message"`
|
||||
EventID string `json:"eventId"`
|
||||
ReleaseID string `json:"releaseId"`
|
||||
SessionID string `json:"sessionId"`
|
||||
ManifestURL string `json:"manifestUrl"`
|
||||
Route string `json:"route"`
|
||||
OccurredAt string `json:"occurredAt"`
|
||||
Details map[string]any `json:"details"`
|
||||
}
|
||||
|
||||
func NewDevService(appEnv string, store *postgres.Store) *DevService {
|
||||
@@ -30,3 +66,83 @@ func (s *DevService) BootstrapDemo(ctx context.Context) (*postgres.DemoBootstrap
|
||||
}
|
||||
return s.store.EnsureDemoData(ctx)
|
||||
}
|
||||
|
||||
func (s *DevService) AddClientDebugLog(_ context.Context, input CreateClientDebugLogInput) (*ClientDebugLogEntry, error) {
|
||||
if !s.Enabled() {
|
||||
return nil, apperr.New(http.StatusNotFound, "not_found", "dev client logs are disabled")
|
||||
}
|
||||
if input.Message == "" {
|
||||
return nil, apperr.New(http.StatusBadRequest, "invalid_request", "message is required")
|
||||
}
|
||||
if input.Source == "" {
|
||||
input.Source = "unknown"
|
||||
}
|
||||
if input.Level == "" {
|
||||
input.Level = "info"
|
||||
}
|
||||
|
||||
occurredAt := time.Now().UTC()
|
||||
if input.OccurredAt != "" {
|
||||
parsed, err := time.Parse(time.RFC3339, input.OccurredAt)
|
||||
if err != nil {
|
||||
return nil, apperr.New(http.StatusBadRequest, "invalid_request", "occurredAt must be RFC3339")
|
||||
}
|
||||
occurredAt = parsed.UTC()
|
||||
}
|
||||
|
||||
entry := ClientDebugLogEntry{
|
||||
Source: input.Source,
|
||||
Level: input.Level,
|
||||
Category: input.Category,
|
||||
Message: input.Message,
|
||||
EventID: input.EventID,
|
||||
ReleaseID: input.ReleaseID,
|
||||
SessionID: input.SessionID,
|
||||
ManifestURL: input.ManifestURL,
|
||||
Route: input.Route,
|
||||
OccurredAt: occurredAt,
|
||||
ReceivedAt: time.Now().UTC(),
|
||||
Details: input.Details,
|
||||
}
|
||||
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
s.logSeq++
|
||||
entry.ID = s.logSeq
|
||||
s.logs = append(s.logs, entry)
|
||||
if len(s.logs) > 200 {
|
||||
s.logs = append([]ClientDebugLogEntry(nil), s.logs[len(s.logs)-200:]...)
|
||||
}
|
||||
copyEntry := entry
|
||||
return ©Entry, nil
|
||||
}
|
||||
|
||||
func (s *DevService) ListClientDebugLogs(_ context.Context, limit int) ([]ClientDebugLogEntry, error) {
|
||||
if !s.Enabled() {
|
||||
return nil, apperr.New(http.StatusNotFound, "not_found", "dev client logs are disabled")
|
||||
}
|
||||
if limit <= 0 || limit > 200 {
|
||||
limit = 50
|
||||
}
|
||||
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
items := append([]ClientDebugLogEntry(nil), s.logs...)
|
||||
sort.Slice(items, func(i, j int) bool {
|
||||
return items[i].ID > items[j].ID
|
||||
})
|
||||
if len(items) > limit {
|
||||
items = items[:limit]
|
||||
}
|
||||
return items, nil
|
||||
}
|
||||
|
||||
func (s *DevService) ClearClientDebugLogs(_ context.Context) error {
|
||||
if !s.Enabled() {
|
||||
return apperr.New(http.StatusNotFound, "not_found", "dev client logs are disabled")
|
||||
}
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
s.logs = nil
|
||||
return nil
|
||||
}
|
||||
|
||||
@@ -6,24 +6,32 @@ import (
|
||||
)
|
||||
|
||||
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"`
|
||||
PlaceID string `json:"placeId"`
|
||||
MapAssetID string `json:"mapAssetId"`
|
||||
TileReleaseID string `json:"tileReleaseId"`
|
||||
CourseSourceID string `json:"courseSourceId"`
|
||||
CourseSetID string `json:"courseSetId"`
|
||||
CourseVariantID string `json:"courseVariantId"`
|
||||
RuntimeBindingID string `json:"runtimeBindingId"`
|
||||
VariantManualEventID string `json:"variantManualEventId"`
|
||||
VariantManualRelease string `json:"variantManualReleaseId"`
|
||||
VariantManualCardID string `json:"variantManualCardId"`
|
||||
CleanedSessionCount int64 `json:"cleanedSessionCount"`
|
||||
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"`
|
||||
PlaceID string `json:"placeId"`
|
||||
MapAssetID string `json:"mapAssetId"`
|
||||
TileReleaseID string `json:"tileReleaseId"`
|
||||
CourseSourceID string `json:"courseSourceId"`
|
||||
CourseSetID string `json:"courseSetId"`
|
||||
CourseVariantID string `json:"courseVariantId"`
|
||||
RuntimeBindingID string `json:"runtimeBindingId"`
|
||||
ScoreOEventID string `json:"scoreOEventId"`
|
||||
ScoreOReleaseID string `json:"scoreOReleaseId"`
|
||||
ScoreOCardID string `json:"scoreOCardId"`
|
||||
ScoreOSourceID string `json:"scoreOSourceId"`
|
||||
ScoreOBuildID string `json:"scoreOBuildId"`
|
||||
ScoreOCourseSetID string `json:"scoreOCourseSetId"`
|
||||
ScoreOCourseVariantID string `json:"scoreOCourseVariantId"`
|
||||
ScoreORuntimeBindingID string `json:"scoreORuntimeBindingId"`
|
||||
VariantManualEventID string `json:"variantManualEventId"`
|
||||
VariantManualRelease string `json:"variantManualReleaseId"`
|
||||
VariantManualCardID string `json:"variantManualCardId"`
|
||||
CleanedSessionCount int64 `json:"cleanedSessionCount"`
|
||||
}
|
||||
|
||||
func (s *Store) EnsureDemoData(ctx context.Context) (*DemoBootstrapSummary, error) {
|
||||
@@ -361,7 +369,7 @@ func (s *Store) EnsureDemoData(ctx context.Context) (*DemoBootstrapSummary, erro
|
||||
)
|
||||
VALUES (
|
||||
'tile_demo_001', $1, 'v2026-04-03', 'published',
|
||||
'https://example.com/tiles/demo/', 'https://example.com/tiles/demo/meta.json', NOW()
|
||||
'https://oss-mbh5.colormaprun.com/gotomars/map/lxcb-001/tiles/', 'https://oss-mbh5.colormaprun.com/gotomars/map/lxcb-001/tiles/meta.json', NOW()
|
||||
)
|
||||
ON CONFLICT (map_asset_id, version_code) DO UPDATE SET
|
||||
status = EXCLUDED.status,
|
||||
@@ -387,7 +395,7 @@ func (s *Store) EnsureDemoData(ctx context.Context) (*DemoBootstrapSummary, erro
|
||||
course_source_public_id, source_type, file_url, import_status
|
||||
)
|
||||
VALUES (
|
||||
'csource_demo_001', 'kml', 'https://example.com/course/demo.kml', 'imported'
|
||||
'csource_demo_001', 'kml', 'https://oss-mbh5.colormaprun.com/gotomars/kml/lxcb-001/10/c01.kml', 'imported'
|
||||
)
|
||||
ON CONFLICT (course_source_public_id) DO UPDATE SET
|
||||
source_type = EXCLUDED.source_type,
|
||||
@@ -398,6 +406,23 @@ func (s *Store) EnsureDemoData(ctx context.Context) (*DemoBootstrapSummary, erro
|
||||
return nil, fmt.Errorf("ensure demo course source: %w", err)
|
||||
}
|
||||
|
||||
var courseSourceVariantBID string
|
||||
if err := tx.QueryRow(ctx, `
|
||||
INSERT INTO course_sources (
|
||||
course_source_public_id, source_type, file_url, import_status
|
||||
)
|
||||
VALUES (
|
||||
'csource_demo_002', 'kml', 'https://oss-mbh5.colormaprun.com/gotomars/kml/lxcb-001/10/c02.kml', 'imported'
|
||||
)
|
||||
ON CONFLICT (course_source_public_id) DO UPDATE SET
|
||||
source_type = EXCLUDED.source_type,
|
||||
file_url = EXCLUDED.file_url,
|
||||
import_status = EXCLUDED.import_status
|
||||
RETURNING id, course_source_public_id
|
||||
`).Scan(&courseSourceVariantBID, new(string)); err != nil {
|
||||
return nil, fmt.Errorf("ensure demo course source variant b: %w", err)
|
||||
}
|
||||
|
||||
var courseSetID, courseSetPublicID string
|
||||
if err := tx.QueryRow(ctx, `
|
||||
INSERT INTO course_sets (
|
||||
@@ -439,6 +464,28 @@ func (s *Store) EnsureDemoData(ctx context.Context) (*DemoBootstrapSummary, erro
|
||||
return nil, fmt.Errorf("ensure demo course variant: %w", err)
|
||||
}
|
||||
|
||||
var courseVariantBID string
|
||||
if err := tx.QueryRow(ctx, `
|
||||
INSERT INTO course_variants (
|
||||
course_variant_public_id, course_set_id, source_id, name, route_code, mode, control_count, status, is_default
|
||||
)
|
||||
VALUES (
|
||||
'cvariant_demo_002', $1, $2, 'Demo Variant B', 'route-demo-b', 'classic-sequential', 10, 'active', false
|
||||
)
|
||||
ON CONFLICT (course_variant_public_id) DO UPDATE SET
|
||||
course_set_id = EXCLUDED.course_set_id,
|
||||
source_id = EXCLUDED.source_id,
|
||||
name = EXCLUDED.name,
|
||||
route_code = EXCLUDED.route_code,
|
||||
mode = EXCLUDED.mode,
|
||||
control_count = EXCLUDED.control_count,
|
||||
status = EXCLUDED.status,
|
||||
is_default = EXCLUDED.is_default
|
||||
RETURNING id, course_variant_public_id
|
||||
`, courseSetID, courseSourceVariantBID).Scan(&courseVariantBID, new(string)); err != nil {
|
||||
return nil, fmt.Errorf("ensure demo course variant b: %w", err)
|
||||
}
|
||||
|
||||
if _, err := tx.Exec(ctx, `
|
||||
UPDATE course_sets
|
||||
SET current_variant_id = $2
|
||||
@@ -529,14 +576,14 @@ func (s *Store) EnsureDemoData(ctx context.Context) (*DemoBootstrapSummary, erro
|
||||
{
|
||||
"id": "variant_a",
|
||||
"name": "A 线",
|
||||
"description": "短线体验版",
|
||||
"description": "短线体验版(c01.kml)",
|
||||
"routeCode": "route-variant-a",
|
||||
"selectable": true
|
||||
},
|
||||
{
|
||||
"id": "variant_b",
|
||||
"name": "B 线",
|
||||
"description": "长线挑战版",
|
||||
"description": "长线挑战版(c02.kml)",
|
||||
"routeCode": "route-variant-b",
|
||||
"selectable": true
|
||||
}
|
||||
@@ -598,6 +645,273 @@ func (s *Store) EnsureDemoData(ctx context.Context) (*DemoBootstrapSummary, erro
|
||||
return nil, fmt.Errorf("ensure variant manual demo card: %w", err)
|
||||
}
|
||||
|
||||
var scoreOEventID string
|
||||
if err := tx.QueryRow(ctx, `
|
||||
INSERT INTO events (
|
||||
tenant_id, event_public_id, slug, display_name, summary, status
|
||||
)
|
||||
VALUES ($1, 'evt_demo_score_o_001', 'demo-score-o-run', 'Demo Score-O Run', '积分赛联调活动', '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(&scoreOEventID); err != nil {
|
||||
return nil, fmt.Errorf("ensure score-o demo event: %w", err)
|
||||
}
|
||||
|
||||
var scoreOReleaseRow 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_score_o_001',
|
||||
$1,
|
||||
1,
|
||||
'Demo Score-O Config v1',
|
||||
'https://oss-mbh5.colormaprun.com/gotomars/event/score-o.json',
|
||||
'demo-score-o-checksum-001',
|
||||
'route-score-o-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
|
||||
`, scoreOEventID).Scan(&scoreOReleaseRow.ID, &scoreOReleaseRow.PublicID); err != nil {
|
||||
return nil, fmt.Errorf("ensure score-o demo release: %w", err)
|
||||
}
|
||||
|
||||
if _, err := tx.Exec(ctx, `
|
||||
UPDATE events
|
||||
SET current_release_id = $2
|
||||
WHERE id = $1
|
||||
`, scoreOEventID, scoreOReleaseRow.ID); err != nil {
|
||||
return nil, fmt.Errorf("attach score-o demo release: %w", err)
|
||||
}
|
||||
|
||||
scoreOSourceNotes := "demo source config imported from local event sample score-o"
|
||||
scoreOSource, err := s.UpsertEventConfigSource(ctx, tx, UpsertEventConfigSourceParams{
|
||||
EventID: scoreOEventID,
|
||||
SourceVersionNo: 1,
|
||||
SourceKind: "event_bundle",
|
||||
SchemaID: "event-source",
|
||||
SchemaVersion: "1",
|
||||
Status: "active",
|
||||
Notes: &scoreOSourceNotes,
|
||||
Source: map[string]any{
|
||||
"schemaVersion": "1",
|
||||
"app": map[string]any{
|
||||
"id": "sample-score-o-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": "control-set",
|
||||
"source": map[string]any{
|
||||
"type": "kml",
|
||||
"url": "../kml/lxcb-001/10/c01.kml",
|
||||
},
|
||||
},
|
||||
"game": map[string]any{
|
||||
"mode": "score-o",
|
||||
},
|
||||
"content": map[string]any{
|
||||
"h5Template": "content-h5-test-template.html",
|
||||
},
|
||||
},
|
||||
})
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("ensure score-o demo event config source: %w", err)
|
||||
}
|
||||
|
||||
scoreOBuildLog := "demo build generated from sample score-o.json"
|
||||
scoreOBuild, err := s.UpsertEventConfigBuild(ctx, tx, UpsertEventConfigBuildParams{
|
||||
EventID: scoreOEventID,
|
||||
SourceID: scoreOSource.ID,
|
||||
BuildNo: 1,
|
||||
BuildStatus: "success",
|
||||
BuildLog: &scoreOBuildLog,
|
||||
Manifest: map[string]any{
|
||||
"schemaVersion": "1",
|
||||
"releaseId": "rel_demo_score_o_001",
|
||||
"version": "2026.04.01",
|
||||
"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",
|
||||
},
|
||||
"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",
|
||||
},
|
||||
},
|
||||
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 score-o demo event config build: %w", err)
|
||||
}
|
||||
|
||||
if err := s.AttachBuildToRelease(ctx, tx, scoreOReleaseRow.ID, scoreOBuild.ID); err != nil {
|
||||
return nil, fmt.Errorf("attach score-o demo build to release: %w", err)
|
||||
}
|
||||
|
||||
var scoreOCardPublicID 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_score_o_001',
|
||||
$1,
|
||||
$2,
|
||||
'event',
|
||||
'Demo Score-O Run',
|
||||
'积分赛联调入口',
|
||||
'https://oss-mbh5.colormaprun.com/gotomars/assets/demo-cover.jpg',
|
||||
$3,
|
||||
'home_primary',
|
||||
98,
|
||||
'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, scoreOEventID).Scan(&scoreOCardPublicID); err != nil {
|
||||
return nil, fmt.Errorf("ensure score-o demo card: %w", err)
|
||||
}
|
||||
|
||||
var scoreOCourseSetID, scoreOCourseSetPublicID string
|
||||
if err := tx.QueryRow(ctx, `
|
||||
INSERT INTO course_sets (
|
||||
course_set_public_id, place_id, map_asset_id, code, mode, name, status
|
||||
)
|
||||
VALUES (
|
||||
'cset_demo_score_o_001', $1, $2, 'cset-demo-score-o-001', 'score-o', 'Demo Score-O Course Set', 'active'
|
||||
)
|
||||
ON CONFLICT (code) DO UPDATE SET
|
||||
place_id = EXCLUDED.place_id,
|
||||
map_asset_id = EXCLUDED.map_asset_id,
|
||||
mode = EXCLUDED.mode,
|
||||
name = EXCLUDED.name,
|
||||
status = EXCLUDED.status
|
||||
RETURNING id, course_set_public_id
|
||||
`, placeID, mapAssetID).Scan(&scoreOCourseSetID, &scoreOCourseSetPublicID); err != nil {
|
||||
return nil, fmt.Errorf("ensure score-o demo course set: %w", err)
|
||||
}
|
||||
|
||||
var scoreOCourseVariantID, scoreOCourseVariantPublicID string
|
||||
if err := tx.QueryRow(ctx, `
|
||||
INSERT INTO course_variants (
|
||||
course_variant_public_id, course_set_id, source_id, name, route_code, mode, control_count, status, is_default
|
||||
)
|
||||
VALUES (
|
||||
'cvariant_demo_score_o_001', $1, $2, 'Demo Score-O Variant', 'route-score-o-001', 'score-o', 10, 'active', true
|
||||
)
|
||||
ON CONFLICT (course_variant_public_id) DO UPDATE SET
|
||||
course_set_id = EXCLUDED.course_set_id,
|
||||
source_id = EXCLUDED.source_id,
|
||||
name = EXCLUDED.name,
|
||||
route_code = EXCLUDED.route_code,
|
||||
mode = EXCLUDED.mode,
|
||||
control_count = EXCLUDED.control_count,
|
||||
status = EXCLUDED.status,
|
||||
is_default = EXCLUDED.is_default
|
||||
RETURNING id, course_variant_public_id
|
||||
`, scoreOCourseSetID, courseSourceID).Scan(&scoreOCourseVariantID, &scoreOCourseVariantPublicID); err != nil {
|
||||
return nil, fmt.Errorf("ensure score-o demo course variant: %w", err)
|
||||
}
|
||||
|
||||
if _, err := tx.Exec(ctx, `
|
||||
UPDATE course_sets
|
||||
SET current_variant_id = $2
|
||||
WHERE id = $1
|
||||
`, scoreOCourseSetID, scoreOCourseVariantID); err != nil {
|
||||
return nil, fmt.Errorf("attach score-o demo course variant: %w", err)
|
||||
}
|
||||
|
||||
var scoreORuntimeBindingID, scoreORuntimeBindingPublicID string
|
||||
if err := tx.QueryRow(ctx, `
|
||||
INSERT INTO map_runtime_bindings (
|
||||
runtime_binding_public_id, event_id, place_id, map_asset_id, tile_release_id, course_set_id, course_variant_id, status, notes
|
||||
)
|
||||
VALUES (
|
||||
'runtime_demo_score_o_001', $1, $2, $3, $4, $5, $6, 'active', 'demo score-o runtime binding'
|
||||
)
|
||||
ON CONFLICT (runtime_binding_public_id) DO UPDATE SET
|
||||
event_id = EXCLUDED.event_id,
|
||||
place_id = EXCLUDED.place_id,
|
||||
map_asset_id = EXCLUDED.map_asset_id,
|
||||
tile_release_id = EXCLUDED.tile_release_id,
|
||||
course_set_id = EXCLUDED.course_set_id,
|
||||
course_variant_id = EXCLUDED.course_variant_id,
|
||||
status = EXCLUDED.status,
|
||||
notes = EXCLUDED.notes
|
||||
RETURNING id, runtime_binding_public_id
|
||||
`, scoreOEventID, placeID, mapAssetID, tileReleaseID, scoreOCourseSetID, scoreOCourseVariantID).Scan(&scoreORuntimeBindingID, &scoreORuntimeBindingPublicID); err != nil {
|
||||
return nil, fmt.Errorf("ensure score-o demo runtime binding: %w", err)
|
||||
}
|
||||
|
||||
var cleanedSessionCount int64
|
||||
if err := tx.QueryRow(ctx, `
|
||||
WITH cleaned AS (
|
||||
@@ -611,7 +925,7 @@ func (s *Store) EnsureDemoData(ctx context.Context) (*DemoBootstrapSummary, erro
|
||||
RETURNING 1
|
||||
)
|
||||
SELECT COUNT(*) FROM cleaned
|
||||
`, []string{eventID, manualEventID}).Scan(&cleanedSessionCount); err != nil {
|
||||
`, []string{eventID, scoreOEventID, manualEventID}).Scan(&cleanedSessionCount); err != nil {
|
||||
return nil, fmt.Errorf("cleanup demo ongoing sessions: %w", err)
|
||||
}
|
||||
|
||||
@@ -620,23 +934,31 @@ func (s *Store) EnsureDemoData(ctx context.Context) (*DemoBootstrapSummary, erro
|
||||
}
|
||||
|
||||
return &DemoBootstrapSummary{
|
||||
TenantCode: "tenant_demo",
|
||||
ChannelCode: "mini-demo",
|
||||
EventID: "evt_demo_001",
|
||||
ReleaseID: releaseRow.PublicID,
|
||||
SourceID: source.ID,
|
||||
BuildID: build.ID,
|
||||
CardID: cardPublicID,
|
||||
PlaceID: placePublicID,
|
||||
MapAssetID: mapAssetPublicID,
|
||||
TileReleaseID: tileReleasePublicID,
|
||||
CourseSourceID: courseSourcePublicID,
|
||||
CourseSetID: courseSetPublicID,
|
||||
CourseVariantID: courseVariantPublicID,
|
||||
RuntimeBindingID: runtimeBindingPublicID,
|
||||
VariantManualEventID: "evt_demo_variant_manual_001",
|
||||
VariantManualRelease: manualReleaseRow.PublicID,
|
||||
VariantManualCardID: manualCardPublicID,
|
||||
CleanedSessionCount: cleanedSessionCount,
|
||||
TenantCode: "tenant_demo",
|
||||
ChannelCode: "mini-demo",
|
||||
EventID: "evt_demo_001",
|
||||
ReleaseID: releaseRow.PublicID,
|
||||
SourceID: source.ID,
|
||||
BuildID: build.ID,
|
||||
CardID: cardPublicID,
|
||||
PlaceID: placePublicID,
|
||||
MapAssetID: mapAssetPublicID,
|
||||
TileReleaseID: tileReleasePublicID,
|
||||
CourseSourceID: courseSourcePublicID,
|
||||
CourseSetID: courseSetPublicID,
|
||||
CourseVariantID: courseVariantPublicID,
|
||||
RuntimeBindingID: runtimeBindingPublicID,
|
||||
ScoreOEventID: "evt_demo_score_o_001",
|
||||
ScoreOReleaseID: scoreOReleaseRow.PublicID,
|
||||
ScoreOCardID: scoreOCardPublicID,
|
||||
ScoreOSourceID: scoreOSource.ID,
|
||||
ScoreOBuildID: scoreOBuild.ID,
|
||||
ScoreOCourseSetID: scoreOCourseSetPublicID,
|
||||
ScoreOCourseVariantID: scoreOCourseVariantPublicID,
|
||||
ScoreORuntimeBindingID: scoreORuntimeBindingPublicID,
|
||||
VariantManualEventID: "evt_demo_variant_manual_001",
|
||||
VariantManualRelease: manualReleaseRow.PublicID,
|
||||
VariantManualCardID: manualCardPublicID,
|
||||
CleanedSessionCount: cleanedSessionCount,
|
||||
}, nil
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user