Files
cmr-mini/backend/internal/httpapi/handlers/dev_handler.go

1589 lines
60 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
package handlers
import (
"net/http"
"cmr-backend/internal/httpx"
"cmr-backend/internal/service"
)
type DevHandler struct {
devService *service.DevService
}
func NewDevHandler(devService *service.DevService) *DevHandler {
return &DevHandler{devService: devService}
}
func (h *DevHandler) BootstrapDemo(w http.ResponseWriter, r *http.Request) {
result, err := h.devService.BootstrapDemo(r.Context())
if err != nil {
httpx.WriteError(w, err)
return
}
httpx.WriteJSON(w, http.StatusOK, map[string]any{"data": result})
}
func (h *DevHandler) Workbench(w http.ResponseWriter, r *http.Request) {
if !h.devService.Enabled() {
http.NotFound(w, r)
return
}
w.Header().Set("Content-Type", "text/html; charset=utf-8")
_, _ = w.Write([]byte(devWorkbenchHTML))
}
const devWorkbenchHTML = `<!doctype html>
<html lang="zh-CN">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>CMR Backend Workbench</title>
<style>
:root {
--bg: #0d1418;
--panel: #132129;
--panel-alt: #182b34;
--text: #e9f1f5;
--muted: #8ea3ad;
--line: #29424d;
--accent: #4fd1a5;
--accent-2: #ffd166;
--danger: #ff6b6b;
--mono: "Consolas", "SFMono-Regular", monospace;
--sans: "Segoe UI", "PingFang SC", "Noto Sans SC", sans-serif;
}
* { box-sizing: border-box; }
body {
margin: 0;
background:
radial-gradient(circle at top right, rgba(79, 209, 165, 0.12), transparent 24%),
radial-gradient(circle at bottom left, rgba(255, 209, 102, 0.10), transparent 28%),
var(--bg);
color: var(--text);
font-family: var(--sans);
}
.shell {
max-width: 1400px;
margin: 0 auto;
padding: 28px 24px 40px;
}
.hero {
display: grid;
gap: 8px;
margin-bottom: 22px;
}
.eyebrow {
color: var(--accent);
text-transform: uppercase;
letter-spacing: 0.14em;
font-size: 12px;
font-weight: 700;
}
h1 {
margin: 0;
font-size: 34px;
line-height: 1.1;
}
.hero p {
margin: 0;
max-width: 920px;
color: var(--muted);
line-height: 1.6;
}
.grid {
display: grid;
gap: 16px;
grid-template-columns: repeat(auto-fit, minmax(280px, 1fr));
}
.stack {
display: grid;
gap: 16px;
}
.panel {
background: linear-gradient(180deg, rgba(255,255,255,0.02), rgba(255,255,255,0.01)), var(--panel);
border: 1px solid var(--line);
border-radius: 18px;
padding: 16px;
display: grid;
gap: 12px;
box-shadow: 0 8px 30px rgba(0, 0, 0, 0.18);
}
.panel h2 {
margin: 0;
font-size: 18px;
}
.panel p {
margin: 0;
color: var(--muted);
font-size: 13px;
line-height: 1.5;
}
.row {
display: grid;
gap: 8px;
}
.row.two {
grid-template-columns: repeat(2, minmax(0, 1fr));
}
label {
display: grid;
gap: 6px;
font-size: 12px;
color: var(--muted);
}
input, textarea, select {
width: 100%;
border: 1px solid var(--line);
border-radius: 12px;
background: var(--panel-alt);
color: var(--text);
padding: 10px 12px;
font: inherit;
}
textarea {
min-height: 90px;
resize: vertical;
font-family: var(--mono);
font-size: 12px;
}
button {
border: 0;
border-radius: 12px;
padding: 10px 14px;
background: var(--accent);
color: #062419;
font-weight: 700;
cursor: pointer;
}
button.secondary {
background: var(--accent-2);
color: #312200;
}
button.ghost {
background: transparent;
color: var(--text);
border: 1px solid var(--line);
}
.actions {
display: flex;
flex-wrap: wrap;
gap: 8px;
}
.kv {
display: grid;
gap: 6px;
font-size: 12px;
color: var(--muted);
}
.kv code {
display: block;
padding: 8px 10px;
border-radius: 10px;
background: rgba(255,255,255,0.04);
color: var(--text);
font-family: var(--mono);
word-break: break-all;
}
.log {
min-height: 220px;
max-height: 520px;
overflow: auto;
white-space: pre-wrap;
word-break: break-word;
font-family: var(--mono);
font-size: 12px;
line-height: 1.55;
background: #0a1013;
border: 1px solid var(--line);
border-radius: 16px;
padding: 14px;
}
.subpanel {
display: grid;
gap: 8px;
padding: 12px;
border-radius: 14px;
background: rgba(255,255,255,0.03);
border: 1px solid rgba(255,255,255,0.05);
}
.history {
display: grid;
gap: 8px;
max-height: 280px;
overflow: auto;
}
.history-item {
padding: 10px 12px;
border-radius: 12px;
background: rgba(255,255,255,0.04);
border: 1px solid rgba(255,255,255,0.05);
font-family: var(--mono);
font-size: 12px;
line-height: 1.5;
}
.history-item strong {
color: var(--accent);
}
.muted-note {
color: var(--muted);
font-size: 12px;
line-height: 1.5;
}
.api-toolbar {
display: flex;
flex-wrap: wrap;
gap: 10px;
align-items: center;
}
.api-toolbar input {
max-width: 360px;
}
.api-catalog {
display: grid;
gap: 12px;
}
.api-item {
display: grid;
gap: 8px;
padding: 14px;
border-radius: 14px;
background: rgba(255,255,255,0.03);
border: 1px solid rgba(255,255,255,0.06);
}
.api-item.hidden {
display: none;
}
.api-head {
display: flex;
flex-wrap: wrap;
gap: 8px 12px;
align-items: center;
}
.api-method {
padding: 4px 8px;
border-radius: 999px;
background: rgba(79, 209, 165, 0.14);
color: var(--accent);
font-family: var(--mono);
font-size: 12px;
font-weight: 700;
}
.api-path {
font-family: var(--mono);
font-size: 13px;
color: var(--text);
word-break: break-all;
}
.api-desc {
color: var(--muted);
font-size: 13px;
line-height: 1.6;
}
.api-meta {
display: grid;
gap: 6px;
font-size: 12px;
color: var(--muted);
}
.api-meta strong {
color: var(--text);
font-weight: 600;
}
.status {
color: var(--accent);
font-weight: 700;
}
.status.error {
color: var(--danger);
}
@media (max-width: 900px) {
.row.two { grid-template-columns: 1fr; }
.shell { padding: 20px 16px 32px; }
}
</style>
</head>
<body>
<div class="shell">
<div class="hero">
<div class="eyebrow">Developer Workbench</div>
<h1>CMR Backend API Flow Panel</h1>
<p>把入口、登录、首页、活动详情、launch、session、profile 串成一条完整调试链。这个页面只在非 production 环境开放,适合后续继续扩展成你想要的 API 测试面板。</p>
</div>
<div class="grid">
<section class="panel">
<h2>1. Bootstrap</h2>
<p>初始化 demo tenant / channel / event / card。</p>
<div class="actions">
<button id="btn-bootstrap">Bootstrap Demo</button>
</div>
<div class="kv">
<div>默认入口 <code id="bootstrap-entry">tenant_demo / mini-demo / evt_demo_001</code></div>
</div>
</section>
<section class="panel">
<h2>2. Config Pipeline</h2>
<p>从本地 event 目录导入 source config生成 preview build并可直接发布成当前 release。</p>
<div class="row two">
<label>Local Config File
<input id="local-config-file" value="classic-sequential.json">
</label>
<label>Event ID
<input id="config-event-id" value="evt_demo_001">
</label>
</div>
<div class="row two">
<label>Source ID
<input id="config-source-id" placeholder="import 后自动填充">
</label>
<label>Build ID
<input id="config-build-id" placeholder="preview 后自动填充">
</label>
</div>
<div class="actions">
<button id="btn-config-files">List Local Files</button>
<button id="btn-config-import">Import Local</button>
<button class="secondary" id="btn-config-preview">Build Preview</button>
<button class="secondary" id="btn-config-publish">Publish Build</button>
<button class="ghost" id="btn-config-source">Get Source</button>
<button class="ghost" id="btn-config-build">Get Build</button>
</div>
</section>
<section class="panel">
<h2>3. Session State</h2>
<p>当前调试上下文,所有按钮共享这一组状态。</p>
<div class="kv">
<div>Access Token <code id="state-access">-</code></div>
<div>Refresh Token <code id="state-refresh">-</code></div>
<div>Source ID <code id="state-source">-</code></div>
<div>Build ID <code id="state-build">-</code></div>
<div>Release ID <code id="state-release">-</code></div>
<div>Session ID <code id="state-session">-</code></div>
<div>Session Token <code id="state-session-token">-</code></div>
</div>
<div class="actions">
<button class="ghost" id="btn-clear-state">Clear State</button>
</div>
</section>
<section class="panel">
<h2>4. SMS Auth</h2>
<div class="row two">
<label>Client Type
<select id="sms-client-type">
<option value="app">app</option>
<option value="wechat">wechat</option>
</select>
</label>
<label>Scene
<select id="sms-scene">
<option value="login">login</option>
<option value="bind_mobile">bind_mobile</option>
</select>
</label>
</div>
<div class="row two">
<label>Mobile
<input id="sms-mobile" value="13800138000">
</label>
<label>Device Key
<input id="sms-device" value="workbench-device-001">
</label>
</div>
<div class="row two">
<label>Country Code
<input id="sms-country" value="86">
</label>
<label>Code
<input id="sms-code" placeholder="send 后自动填充 devCode">
</label>
</div>
<div class="actions">
<button id="btn-send-sms">Send SMS</button>
<button class="secondary" id="btn-login-sms">Login SMS</button>
<button class="ghost" id="btn-bind-mobile">Bind Mobile</button>
</div>
</section>
<section class="panel">
<h2>5. WeChat Mini</h2>
<p>开发环境可直接使用 dev-xxx code。</p>
<div class="row two">
<label>Code
<input id="wechat-code" value="dev-workbench-user">
</label>
<label>Device Key
<input id="wechat-device" value="wechat-device-001">
</label>
</div>
<div class="actions">
<button id="btn-login-wechat">Login WeChat Mini</button>
</div>
</section>
<section class="panel">
<h2>6. Entry / Home</h2>
<div class="row two">
<label>Channel Code
<input id="entry-channel-code" value="mini-demo">
</label>
<label>Channel Type
<input id="entry-channel-type" value="wechat_mini">
</label>
</div>
<div class="actions">
<button id="btn-resolve-entry">Resolve Entry</button>
<button id="btn-home">Home</button>
<button class="secondary" id="btn-entry-home">My Entry Home</button>
</div>
</section>
<section class="panel">
<h2>7. Event</h2>
<div class="row two">
<label>Event ID
<input id="event-id" value="evt_demo_001">
</label>
<label>Release ID
<input id="event-release-id" value="rel_demo_001">
</label>
</div>
<div class="row two">
<label>Launch Device
<input id="event-device" value="workbench-device-001">
</label>
<div></div>
</div>
<div class="actions">
<button id="btn-event-detail">Event Detail</button>
<button id="btn-event-play">Event Play</button>
<button class="secondary" id="btn-launch">Launch</button>
</div>
</section>
<section class="panel">
<h2>8. Session</h2>
<div class="row two">
<label>Session ID
<input id="session-id" placeholder="launch 后自动填充">
</label>
<label>Session Token
<input id="session-token" placeholder="launch 后自动填充">
</label>
</div>
<div class="row two">
<label>Finish Status
<select id="finish-status">
<option value="finished">finished</option>
<option value="failed">failed</option>
<option value="cancelled">cancelled</option>
</select>
</label>
<div></div>
</div>
<div class="row two">
<label>Duration Sec
<input id="finish-duration" type="number" value="960">
</label>
<label>Score
<input id="finish-score" type="number" value="88">
</label>
</div>
<div class="row two">
<label>Completed Controls
<input id="finish-controls-done" type="number" value="7">
</label>
<label>Total Controls
<input id="finish-controls-total" type="number" value="8">
</label>
</div>
<div class="row two">
<label>Distance Meters
<input id="finish-distance" type="number" step="0.01" value="5230">
</label>
<label>Average Speed KM/H
<input id="finish-speed" type="number" step="0.001" value="6.45">
</label>
</div>
<div class="row two">
<label>Max Heart Rate BPM
<input id="finish-heart-rate" type="number" value="168">
</label>
<div></div>
</div>
<div class="actions">
<button id="btn-session-detail">Session Detail</button>
<button id="btn-session-start">Start Session</button>
<button class="secondary" id="btn-session-finish">Finish Session</button>
<button class="ghost" id="btn-my-sessions">My Sessions</button>
</div>
</section>
<section class="panel">
<h2>9. Results</h2>
<div class="actions">
<button id="btn-session-result">Session Result</button>
<button id="btn-my-results">My Results</button>
</div>
</section>
<section class="panel">
<h2>10. Profile</h2>
<div class="actions">
<button id="btn-me">/me</button>
<button id="btn-profile">/me/profile</button>
</div>
</section>
</div>
<div class="grid" style="margin-top:16px;">
<section class="panel">
<h2>11. Quick Flows</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>
</div>
<div class="muted-note">这些流程会复用当前表单里的手机号、设备、event、channel 等输入。</div>
</section>
<section class="panel">
<h2>12. Request Export</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>
<div class="grid" style="margin-top:16px;">
<section class="panel">
<h2>13. Scenarios</h2>
<p>保存当前表单状态为可复用场景,也支持导入导出 JSON适合后续切换不同俱乐部、入口和 event。</p>
<div class="row two">
<label>Scenario Name
<input id="scenario-name" placeholder="例如俱乐部A-小程序-Launch流">
</label>
<label>Saved / Preset
<select id="scenario-select"></select>
</label>
</div>
<div class="actions">
<button id="btn-scenario-save">Save Current</button>
<button class="secondary" id="btn-scenario-load">Load Selected</button>
<button class="ghost" id="btn-scenario-delete">Delete Selected</button>
</div>
<div class="subpanel">
<div class="muted-note">Scenario JSON</div>
<textarea id="scenario-json" placeholder="导出后可复制,导入时贴回这里"></textarea>
<div class="actions">
<button id="btn-scenario-export">Export Selected</button>
<button class="secondary" id="btn-scenario-import">Import JSON</button>
</div>
</div>
</section>
<section class="panel">
<h2>14. Response Log</h2>
<p>最后一次请求的结果会记录在这里,便于后续做请求回放和用例保存。</p>
<div id="status" class="status">ready</div>
<div id="log" class="log"></div>
</section>
</div>
<div class="grid" style="margin-top:16px;">
<section class="panel">
<h2>15. Request History</h2>
<p>最近 12 次请求会保留在浏览器本地,刷新页面不会丢。</p>
<div id="history" class="history"></div>
</section>
</div>
<div class="grid" style="margin-top:16px;">
<section class="panel">
<h2>16. API 列表</h2>
<p>把当前已实现接口按分组放进 workbench直接看中文说明、鉴权要求和关键参数不用来回翻文档。</p>
<div class="api-toolbar">
<input id="api-filter" placeholder="搜索路径、用途、参数,例如 launch / wechat / result">
<div class="muted-note">共 24 个接口,支持按关键词筛选。</div>
</div>
<div id="api-catalog" class="api-catalog">
<div class="api-item" data-api="healthz 健康检查 服务状态">
<div class="api-head"><span class="api-method">GET</span><span class="api-path">/healthz</span></div>
<div class="api-desc">健康检查接口,用来确认服务是否存活。</div>
<div class="api-meta"><div><strong>鉴权:</strong>无需鉴权</div></div>
</div>
<div class="api-item" data-api="auth sms send 验证码 登录 绑定 手机">
<div class="api-head"><span class="api-method">POST</span><span class="api-path">/auth/sms/send</span></div>
<div class="api-desc">发送短信验证码,支持登录和绑定手机号两种场景。</div>
<div class="api-meta">
<div><strong>鉴权:</strong>无需鉴权</div>
<div><strong>关键参数:</strong><code>countryCode</code>、<code>mobile</code>、<code>clientType</code>、<code>deviceKey</code>、<code>scene</code></div>
</div>
</div>
<div class="api-item" data-api="auth login sms app 手机号 验证码 登录">
<div class="api-head"><span class="api-method">POST</span><span class="api-path">/auth/login/sms</span></div>
<div class="api-desc">APP 主登录入口,使用手机号验证码登录并返回 access/refresh token。</div>
<div class="api-meta">
<div><strong>鉴权:</strong>无需鉴权</div>
<div><strong>关键参数:</strong><code>mobile</code>、<code>code</code>、<code>clientType</code>、<code>deviceKey</code></div>
</div>
</div>
<div class="api-item" data-api="auth login wechat mini 微信 小程序 code openid 登录">
<div class="api-head"><span class="api-method">POST</span><span class="api-path">/auth/login/wechat-mini</span></div>
<div class="api-desc">微信小程序登录入口。开发环境支持 <code>dev-</code> 前缀 code 直接模拟登录。</div>
<div class="api-meta">
<div><strong>鉴权:</strong>无需鉴权</div>
<div><strong>关键参数:</strong><code>code</code>、<code>clientType=wechat</code>、<code>deviceKey</code></div>
</div>
</div>
<div class="api-item" data-api="auth bind mobile 绑定 手机号 合并 账号">
<div class="api-head"><span class="api-method">POST</span><span class="api-path">/auth/bind/mobile</span></div>
<div class="api-desc">已登录用户绑定手机号,必要时把微信轻账号合并到手机号主账号。</div>
<div class="api-meta">
<div><strong>鉴权:</strong>Bearer token</div>
<div><strong>关键参数:</strong><code>mobile</code>、<code>code</code>、<code>clientType</code>、<code>deviceKey</code></div>
</div>
</div>
<div class="api-item" data-api="auth refresh token 刷新 登录态">
<div class="api-head"><span class="api-method">POST</span><span class="api-path">/auth/refresh</span></div>
<div class="api-desc">使用 refresh token 刷新 access token。</div>
<div class="api-meta">
<div><strong>鉴权:</strong>无需 Bearer token</div>
<div><strong>关键参数:</strong><code>refreshToken</code>、<code>clientType</code>、<code>deviceKey</code></div>
</div>
</div>
<div class="api-item" data-api="auth logout 登出 撤销 refresh token">
<div class="api-head"><span class="api-method">POST</span><span class="api-path">/auth/logout</span></div>
<div class="api-desc">登出并撤销 refresh token。</div>
<div class="api-meta"><div><strong>鉴权:</strong>可带 Bearer token</div></div>
</div>
<div class="api-item" data-api="entry resolve tenant channel 入口 解析">
<div class="api-head"><span class="api-method">GET</span><span class="api-path">/entry/resolve</span></div>
<div class="api-desc">解析当前入口属于哪个 tenant / channel是多俱乐部、多公众号接入的入口层基础接口。</div>
<div class="api-meta">
<div><strong>鉴权:</strong>无需鉴权</div>
<div><strong>查询参数:</strong><code>channelCode</code>、<code>channelType</code>、<code>platformAppId</code>、<code>tenantCode</code></div>
</div>
</div>
<div class="api-item" data-api="home 首页 卡片 列表">
<div class="api-head"><span class="api-method">GET</span><span class="api-path">/home</span></div>
<div class="api-desc">返回入口首页卡片数据。</div>
<div class="api-meta"><div><strong>鉴权:</strong>无需鉴权</div></div>
</div>
<div class="api-item" data-api="cards 卡片 列表">
<div class="api-head"><span class="api-method">GET</span><span class="api-path">/cards</span></div>
<div class="api-desc">只返回卡片列表,适合调试卡片数据本身。</div>
<div class="api-meta"><div><strong>鉴权:</strong>无需鉴权</div></div>
</div>
<div class="api-item" data-api="me entry home 首页 聚合 ongoing recent">
<div class="api-head"><span class="api-method">GET</span><span class="api-path">/me/entry-home</span></div>
<div class="api-desc">首页聚合接口返回用户、tenant、channel、cards、进行中 session 和最近一局。</div>
<div class="api-meta"><div><strong>鉴权:</strong>Bearer token</div></div>
</div>
<div class="api-item" data-api="event detail 活动 详情 release resolvedRelease">
<div class="api-head"><span class="api-method">GET</span><span class="api-path">/events/{eventPublicID}</span></div>
<div class="api-desc">活动详情接口,会带当前发布的 release 和 resolvedRelease。</div>
<div class="api-meta"><div><strong>鉴权:</strong>无需鉴权</div></div>
</div>
<div class="api-item" data-api="event play 活动 准备页 聚合 canLaunch continue review">
<div class="api-head"><span class="api-method">GET</span><span class="api-path">/events/{eventPublicID}/play</span></div>
<div class="api-desc">活动详情页 / 开始前准备页聚合接口,判断是否可启动、继续还是查看上次结果。</div>
<div class="api-meta"><div><strong>鉴权:</strong>Bearer token</div></div>
</div>
<div class="api-item" data-api="event launch 启动 一局 release manifest sessionToken">
<div class="api-head"><span class="api-method">POST</span><span class="api-path">/events/{eventPublicID}/launch</span></div>
<div class="api-desc">基于当前 event 的已发布 release 创建一局 session并返回 config URL、releaseId、sessionToken。</div>
<div class="api-meta">
<div><strong>鉴权:</strong>Bearer token</div>
<div><strong>关键参数:</strong><code>releaseId</code>、<code>clientType</code>、<code>deviceKey</code></div>
</div>
</div>
<div class="api-item" data-api="config sources event source 配置 列表">
<div class="api-head"><span class="api-method">GET</span><span class="api-path">/events/{eventPublicID}/config-sources</span></div>
<div class="api-desc">查看某个 event 下已经导入过的 source config 列表。</div>
<div class="api-meta"><div><strong>鉴权:</strong>Bearer token</div></div>
</div>
<div class="api-item" data-api="config source detail 源配置 明细">
<div class="api-head"><span class="api-method">GET</span><span class="api-path">/config-sources/{sourceID}</span></div>
<div class="api-desc">查看单条 source config 明细。</div>
<div class="api-meta"><div><strong>鉴权:</strong>Bearer token</div></div>
</div>
<div class="api-item" data-api="config build detail 预览 build 明细 manifest assets">
<div class="api-head"><span class="api-method">GET</span><span class="api-path">/config-builds/{buildID}</span></div>
<div class="api-desc">查看单次 build 的 manifest 和 asset index。</div>
<div class="api-meta"><div><strong>鉴权:</strong>Bearer token</div></div>
</div>
<div class="api-item" data-api="session detail 一局 详情 resolvedRelease">
<div class="api-head"><span class="api-method">GET</span><span class="api-path">/sessions/{sessionPublicID}</span></div>
<div class="api-desc">查询一局详情,带 session 状态、event 和 resolvedRelease。</div>
<div class="api-meta"><div><strong>鉴权:</strong>Bearer token</div></div>
</div>
<div class="api-item" data-api="session start running 开始 一局 sessionToken">
<div class="api-head"><span class="api-method">POST</span><span class="api-path">/sessions/{sessionPublicID}/start</span></div>
<div class="api-desc">把 session 从 <code>launched</code> 推进到 <code>running</code>。</div>
<div class="api-meta">
<div><strong>鉴权:</strong><code>sessionToken</code></div>
<div><strong>关键参数:</strong><code>sessionToken</code></div>
</div>
</div>
<div class="api-item" data-api="session finish 结束 成绩 摘要 result summary sessionToken">
<div class="api-head"><span class="api-method">POST</span><span class="api-path">/sessions/{sessionPublicID}/finish</span></div>
<div class="api-desc">结束一局并沉淀结果摘要,是结果页数据的来源。</div>
<div class="api-meta">
<div><strong>鉴权:</strong><code>sessionToken</code></div>
<div><strong>关键参数:</strong><code>sessionToken</code>、<code>status</code>、<code>summary.*</code></div>
</div>
</div>
<div class="api-item" data-api="me sessions 我的 最近 局 列表">
<div class="api-head"><span class="api-method">GET</span><span class="api-path">/me/sessions</span></div>
<div class="api-desc">查询用户最近 session 列表。</div>
<div class="api-meta"><div><strong>鉴权:</strong>Bearer token</div></div>
</div>
<div class="api-item" data-api="session result 单局 结果 页 成绩">
<div class="api-head"><span class="api-method">GET</span><span class="api-path">/sessions/{sessionPublicID}/result</span></div>
<div class="api-desc">单局结果页接口,返回 session 和 result。</div>
<div class="api-meta"><div><strong>鉴权:</strong>Bearer token</div></div>
</div>
<div class="api-item" data-api="me results 我的 成绩 结果 列表">
<div class="api-head"><span class="api-method">GET</span><span class="api-path">/me/results</span></div>
<div class="api-desc">查询用户最近结果列表。</div>
<div class="api-meta"><div><strong>鉴权:</strong>Bearer token</div></div>
</div>
<div class="api-item" data-api="me 当前用户 信息">
<div class="api-head"><span class="api-method">GET</span><span class="api-path">/me</span></div>
<div class="api-desc">返回当前用户基础信息。</div>
<div class="api-meta"><div><strong>鉴权:</strong>Bearer token</div></div>
</div>
<div class="api-item" data-api="me profile 我的页 聚合 绑定 最近记录">
<div class="api-head"><span class="api-method">GET</span><span class="api-path">/me/profile</span></div>
<div class="api-desc">“我的页”聚合接口,返回绑定概览、绑定项列表和最近记录摘要。</div>
<div class="api-meta"><div><strong>鉴权:</strong>Bearer token</div></div>
</div>
<div class="api-item" data-api="dev bootstrap demo 初始化 示例 数据">
<div class="api-head"><span class="api-method">POST</span><span class="api-path">/dev/bootstrap-demo</span></div>
<div class="api-desc">开发态自举 demo 数据,会准备 tenant、channel、event、release、card、source、build。</div>
<div class="api-meta"><div><strong>鉴权:</strong>仅 non-production无需鉴权</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>
<div class="api-meta"><div><strong>鉴权:</strong>仅 non-production无需鉴权</div></div>
</div>
<div class="api-item" data-api="dev import local source config 导入 本地 event json">
<div class="api-head"><span class="api-method">POST</span><span class="api-path">/dev/events/{eventPublicID}/config-sources/import-local</span></div>
<div class="api-desc">从本地 event 目录导入 source config。</div>
<div class="api-meta">
<div><strong>鉴权:</strong>仅 non-production无需鉴权</div>
<div><strong>关键参数:</strong><code>fileName</code>、<code>notes</code></div>
</div>
</div>
<div class="api-item" data-api="dev config preview build 预览 manifest asset index">
<div class="api-head"><span class="api-method">POST</span><span class="api-path">/dev/config-builds/preview</span></div>
<div class="api-desc">基于 source config 生成 preview build并产出 preview manifest。</div>
<div class="api-meta">
<div><strong>鉴权:</strong>仅 non-production无需鉴权</div>
<div><strong>关键参数:</strong><code>sourceId</code></div>
</div>
</div>
<div class="api-item" data-api="dev config publish build 发布 release 当前版本">
<div class="api-head"><span class="api-method">POST</span><span class="api-path">/dev/config-builds/publish</span></div>
<div class="api-desc">把成功的 build 发布成正式 release并自动切换成当前 event 的可启动版本。</div>
<div class="api-meta">
<div><strong>鉴权:</strong>仅 non-production无需鉴权</div>
<div><strong>关键参数:</strong><code>buildId</code></div>
</div>
</div>
</div>
</section>
</div>
</div>
<script>
const STORAGE_KEY = 'cmr-backend-workbench-state-v1';
const HISTORY_KEY = 'cmr-backend-workbench-history-v1';
const SCENARIO_KEY = 'cmr-backend-workbench-scenarios-v1';
const state = {
accessToken: '',
refreshToken: '',
sourceId: '',
buildId: '',
releaseId: '',
sessionId: '',
sessionToken: '',
lastCurl: ''
};
const $ = (id) => document.getElementById(id);
const logEl = $('log');
const curlEl = $('curl');
const historyEl = $('history');
const statusEl = $('status');
const builtInScenarios = [
{
id: 'preset-demo-wechat',
builtin: true,
name: 'Preset: Demo WeChat Flow',
fields: {
smsClientType: 'wechat',
smsScene: 'login',
smsMobile: '13800138000',
smsDevice: 'workbench-device-001',
smsCountry: '86',
smsCode: '',
wechatCode: 'dev-workbench-user',
wechatDevice: 'wechat-device-001',
entryChannelCode: 'mini-demo',
entryChannelType: 'wechat_mini',
eventId: 'evt_demo_001',
eventReleaseId: 'rel_demo_001',
eventDevice: 'wechat-device-001',
finishStatus: 'finished',
finishDuration: '960',
finishScore: '88',
finishControlsDone: '7',
finishControlsTotal: '8',
finishDistance: '5230',
finishSpeed: '6.45',
finishHeartRate: '168'
}
},
{
id: 'preset-demo-app-launch',
builtin: true,
name: 'Preset: Demo App Launch Flow',
fields: {
smsClientType: 'app',
smsScene: 'login',
smsMobile: '13800138000',
smsDevice: 'workbench-device-001',
smsCountry: '86',
smsCode: '',
wechatCode: 'dev-workbench-user',
wechatDevice: 'wechat-device-001',
entryChannelCode: 'mini-demo',
entryChannelType: 'wechat_mini',
eventId: 'evt_demo_001',
eventReleaseId: 'rel_demo_001',
eventDevice: 'workbench-device-001',
finishStatus: 'finished',
finishDuration: '960',
finishScore: '88',
finishControlsDone: '7',
finishControlsTotal: '8',
finishDistance: '5230',
finishSpeed: '6.45',
finishHeartRate: '168'
}
}
];
function syncState() {
$('state-access').textContent = state.accessToken || '-';
$('state-refresh').textContent = state.refreshToken || '-';
$('state-source').textContent = state.sourceId || '-';
$('state-build').textContent = state.buildId || '-';
$('state-release').textContent = state.releaseId || '-';
$('state-session').textContent = state.sessionId || '-';
$('state-session-token').textContent = state.sessionToken || '-';
$('config-source-id').value = state.sourceId || '';
$('config-build-id').value = state.buildId || '';
$('event-release-id').value = state.releaseId || $('event-release-id').value;
$('session-id').value = state.sessionId || '';
$('session-token').value = state.sessionToken || '';
curlEl.textContent = state.lastCurl || '-';
persistState();
}
function setStatus(text, isError = false) {
statusEl.textContent = text;
statusEl.className = isError ? 'status error' : 'status';
}
function writeLog(title, payload) {
logEl.textContent = '[' + new Date().toLocaleString() + '] ' + title + '\n' + JSON.stringify(payload, null, 2);
}
function persistState() {
const payload = {
state,
fields: collectFields()
};
localStorage.setItem(STORAGE_KEY, JSON.stringify(payload));
}
function collectFields() {
return {
smsClientType: $('sms-client-type').value,
smsScene: $('sms-scene').value,
smsMobile: $('sms-mobile').value,
smsDevice: $('sms-device').value,
smsCountry: $('sms-country').value,
smsCode: $('sms-code').value,
wechatCode: $('wechat-code').value,
wechatDevice: $('wechat-device').value,
localConfigFile: $('local-config-file').value,
configEventId: $('config-event-id').value,
entryChannelCode: $('entry-channel-code').value,
entryChannelType: $('entry-channel-type').value,
eventId: $('event-id').value,
eventReleaseId: $('event-release-id').value,
eventDevice: $('event-device').value,
finishStatus: $('finish-status').value,
finishDuration: $('finish-duration').value,
finishScore: $('finish-score').value,
finishControlsDone: $('finish-controls-done').value,
finishControlsTotal: $('finish-controls-total').value,
finishDistance: $('finish-distance').value,
finishSpeed: $('finish-speed').value,
finishHeartRate: $('finish-heart-rate').value
};
}
function restoreState() {
const raw = localStorage.getItem(STORAGE_KEY);
if (!raw) {
return;
}
try {
const payload = JSON.parse(raw);
if (payload.state) {
state.accessToken = payload.state.accessToken || '';
state.refreshToken = payload.state.refreshToken || '';
state.sourceId = payload.state.sourceId || '';
state.buildId = payload.state.buildId || '';
state.sessionId = payload.state.sessionId || '';
state.sessionToken = payload.state.sessionToken || '';
state.lastCurl = payload.state.lastCurl || '';
}
applyFields(payload.fields || {});
} catch (_) {}
}
function applyFields(fields) {
$('sms-client-type').value = fields.smsClientType || $('sms-client-type').value;
$('sms-scene').value = fields.smsScene || $('sms-scene').value;
$('sms-mobile').value = fields.smsMobile || $('sms-mobile').value;
$('sms-device').value = fields.smsDevice || $('sms-device').value;
$('sms-country').value = fields.smsCountry || $('sms-country').value;
$('sms-code').value = fields.smsCode || '';
$('wechat-code').value = fields.wechatCode || $('wechat-code').value;
$('wechat-device').value = fields.wechatDevice || $('wechat-device').value;
$('local-config-file').value = fields.localConfigFile || $('local-config-file').value;
$('config-event-id').value = fields.configEventId || $('config-event-id').value;
$('entry-channel-code').value = fields.entryChannelCode || $('entry-channel-code').value;
$('entry-channel-type').value = fields.entryChannelType || $('entry-channel-type').value;
$('event-id').value = fields.eventId || $('event-id').value;
$('event-release-id').value = fields.eventReleaseId || $('event-release-id').value;
$('event-device').value = fields.eventDevice || $('event-device').value;
$('finish-status').value = fields.finishStatus || $('finish-status').value;
$('finish-duration').value = fields.finishDuration || $('finish-duration').value;
$('finish-score').value = fields.finishScore || $('finish-score').value;
$('finish-controls-done').value = fields.finishControlsDone || $('finish-controls-done').value;
$('finish-controls-total').value = fields.finishControlsTotal || $('finish-controls-total').value;
$('finish-distance').value = fields.finishDistance || $('finish-distance').value;
$('finish-speed').value = fields.finishSpeed || $('finish-speed').value;
$('finish-heart-rate').value = fields.finishHeartRate || $('finish-heart-rate').value;
}
function parseIntOrNull(value) {
if (value === '' || value === null || value === undefined) {
return null;
}
const parsed = parseInt(value, 10);
return Number.isNaN(parsed) ? null : parsed;
}
function parseFloatOrNull(value) {
if (value === '' || value === null || value === undefined) {
return null;
}
const parsed = parseFloat(value);
return Number.isNaN(parsed) ? null : parsed;
}
function buildFinishSummary() {
const summary = {
finalDurationSec: parseIntOrNull($('finish-duration').value),
finalScore: parseIntOrNull($('finish-score').value),
completedControls: parseIntOrNull($('finish-controls-done').value),
totalControls: parseIntOrNull($('finish-controls-total').value),
distanceMeters: parseFloatOrNull($('finish-distance').value),
averageSpeedKmh: parseFloatOrNull($('finish-speed').value),
maxHeartRateBpm: parseIntOrNull($('finish-heart-rate').value)
};
Object.keys(summary).forEach(function(key) {
if (summary[key] === null) {
delete summary[key];
}
});
return summary;
}
function buildCurl(method, url, body, headers) {
let curl = 'curl -X ' + method + ' "' + window.location.origin + url + '"';
Object.entries(headers || {}).forEach(function(entry) {
curl += ' -H "' + entry[0] + ': ' + String(entry[1]).replace(/"/g, '\\"') + '"';
});
if (body !== undefined) {
curl += " --data-raw '" + JSON.stringify(body).replace(/'/g, "'\"'\"'") + "'";
}
return curl;
}
function getHistory() {
const raw = localStorage.getItem(HISTORY_KEY);
if (!raw) {
return [];
}
try {
const list = JSON.parse(raw);
return Array.isArray(list) ? list : [];
} catch (_) {
return [];
}
}
function pushHistory(item) {
const next = [item].concat(getHistory()).slice(0, 12);
localStorage.setItem(HISTORY_KEY, JSON.stringify(next));
renderHistory();
}
function renderHistory() {
const history = getHistory();
historyEl.innerHTML = '';
if (!history.length) {
historyEl.innerHTML = '<div class="muted-note">No requests yet.</div>';
return;
}
history.forEach(function(item) {
const node = document.createElement('div');
node.className = 'history-item';
node.innerHTML =
'<strong>' + item.title + '</strong><br>' +
item.time + '<br>' +
'status=' + item.status + '<br>' +
'url=' + item.url;
historyEl.appendChild(node);
});
}
function applyAPIFilter() {
const keyword = $('api-filter').value.trim().toLowerCase();
document.querySelectorAll('.api-item').forEach(function(node) {
const haystack = String(node.dataset.api || '').toLowerCase();
if (!keyword || haystack.indexOf(keyword) >= 0) {
node.classList.remove('hidden');
} else {
node.classList.add('hidden');
}
});
}
function getSavedScenarios() {
const raw = localStorage.getItem(SCENARIO_KEY);
if (!raw) {
return [];
}
try {
const list = JSON.parse(raw);
return Array.isArray(list) ? list : [];
} catch (_) {
return [];
}
}
function setSavedScenarios(items) {
localStorage.setItem(SCENARIO_KEY, JSON.stringify(items));
renderScenarioOptions();
}
function allScenarios() {
return builtInScenarios.concat(getSavedScenarios());
}
function renderScenarioOptions() {
const select = $('scenario-select');
const scenarios = allScenarios();
select.innerHTML = '';
if (!scenarios.length) {
select.innerHTML = '<option value="">No scenarios</option>';
return;
}
scenarios.forEach(function(item) {
const option = document.createElement('option');
option.value = item.id;
option.textContent = item.name + (item.builtin ? ' [preset]' : '');
select.appendChild(option);
});
}
function findScenario(id) {
return allScenarios().find(function(item) {
return item.id === id;
}) || null;
}
function saveCurrentScenario() {
const name = $('scenario-name').value.trim();
if (!name) {
setStatus('error: scenario name required', true);
return;
}
const saved = getSavedScenarios();
const scenario = {
id: 'custom-' + Date.now(),
builtin: false,
name: name,
fields: collectFields()
};
saved.unshift(scenario);
setSavedScenarios(saved.slice(0, 20));
$('scenario-select').value = scenario.id;
$('scenario-json').value = JSON.stringify(scenario, null, 2);
setStatus('ok: scenario saved');
}
function loadSelectedScenario() {
const scenario = findScenario($('scenario-select').value);
if (!scenario) {
setStatus('error: scenario not found', true);
return;
}
applyFields(scenario.fields || {});
$('scenario-name').value = scenario.name || '';
$('scenario-json').value = JSON.stringify(scenario, null, 2);
persistState();
setStatus('ok: scenario loaded');
}
function deleteSelectedScenario() {
const id = $('scenario-select').value;
const scenario = findScenario(id);
if (!scenario || scenario.builtin) {
setStatus('error: builtin scenario cannot be deleted', true);
return;
}
const next = getSavedScenarios().filter(function(item) {
return item.id !== id;
});
setSavedScenarios(next);
$('scenario-json').value = '';
setStatus('ok: scenario deleted');
}
function exportSelectedScenario() {
const scenario = findScenario($('scenario-select').value);
if (!scenario) {
setStatus('error: scenario not found', true);
return;
}
$('scenario-json').value = JSON.stringify(scenario, null, 2);
setStatus('ok: scenario exported');
}
function importScenarioFromJSON() {
const raw = $('scenario-json').value.trim();
if (!raw) {
setStatus('error: scenario json is empty', true);
return;
}
try {
const scenario = JSON.parse(raw);
if (!scenario.name || !scenario.fields) {
throw new Error('scenario must include name and fields');
}
const saved = getSavedScenarios();
saved.unshift({
id: 'custom-' + Date.now(),
builtin: false,
name: String(scenario.name),
fields: scenario.fields
});
setSavedScenarios(saved.slice(0, 20));
setStatus('ok: scenario imported');
} catch (err) {
setStatus('error: invalid scenario json', true);
}
}
async function request(method, url, body, needAuth = false) {
const headers = {};
if (body !== undefined) {
headers['Content-Type'] = 'application/json';
}
if (needAuth) {
headers['Authorization'] = 'Bearer ' + state.accessToken;
}
state.lastCurl = buildCurl(method, url, body, headers);
syncState();
const resp = await fetch(url, {
method,
headers,
body: body === undefined ? undefined : JSON.stringify(body)
});
const data = await resp.json().catch(() => ({}));
if (!resp.ok) {
throw { status: resp.status, body: data, url: url, method: method };
}
return data;
}
async function run(title, fn) {
setStatus('running: ' + title);
try {
const result = await fn();
setStatus('ok: ' + title);
writeLog(title, result);
pushHistory({
title: title,
time: new Date().toLocaleString(),
status: 'ok',
url: state.lastCurl
});
syncState();
} catch (err) {
setStatus('error: ' + title, true);
writeLog(title, err);
pushHistory({
title: title,
time: new Date().toLocaleString(),
status: 'error',
url: state.lastCurl
});
}
}
$('btn-clear-state').onclick = () => {
state.accessToken = '';
state.refreshToken = '';
state.sourceId = '';
state.buildId = '';
state.releaseId = '';
state.sessionId = '';
state.sessionToken = '';
state.lastCurl = '';
syncState();
writeLog('clear-state', { ok: true });
setStatus('ready');
};
$('btn-config-files').onclick = () => run('config/local-files', () =>
request('GET', '/dev/config/local-files')
);
$('btn-config-import').onclick = () => run('config/import-local', async () => {
const result = await request('POST', '/dev/events/' + encodeURIComponent($('config-event-id').value) + '/config-sources/import-local', {
fileName: $('local-config-file').value
});
state.sourceId = result.data.id;
return result;
});
$('btn-config-preview').onclick = () => run('config/build-preview', async () => {
const result = await request('POST', '/dev/config-builds/preview', {
sourceId: $('config-source-id').value
});
state.buildId = result.data.id;
return result;
});
$('btn-config-publish').onclick = () => run('config/publish-build', async () => {
const result = await request('POST', '/dev/config-builds/publish', {
buildId: $('config-build-id').value
});
state.releaseId = result.data.release.releaseId;
$('event-release-id').value = result.data.release.releaseId;
return result;
});
$('btn-config-source').onclick = () => run('config/get-source', () =>
request('GET', '/config-sources/' + encodeURIComponent($('config-source-id').value), undefined, true)
);
$('btn-config-build').onclick = () => run('config/get-build', () =>
request('GET', '/config-builds/' + encodeURIComponent($('config-build-id').value), undefined, true)
);
$('btn-send-sms').onclick = () => run('auth/sms/send', async () => {
const result = await request('POST', '/auth/sms/send', {
countryCode: $('sms-country').value,
mobile: $('sms-mobile').value,
clientType: $('sms-client-type').value,
deviceKey: $('sms-device').value,
scene: $('sms-scene').value
});
if (result.data && result.data.devCode) {
$('sms-code').value = result.data.devCode;
}
return result;
});
$('btn-login-sms').onclick = () => run('auth/login/sms', async () => {
const result = await request('POST', '/auth/login/sms', {
countryCode: $('sms-country').value,
mobile: $('sms-mobile').value,
code: $('sms-code').value,
clientType: $('sms-client-type').value,
deviceKey: $('sms-device').value
});
state.accessToken = result.data.tokens.accessToken;
state.refreshToken = result.data.tokens.refreshToken;
return result;
});
$('btn-bind-mobile').onclick = () => run('auth/bind/mobile', async () => {
const result = await request('POST', '/auth/bind/mobile', {
countryCode: $('sms-country').value,
mobile: $('sms-mobile').value,
code: $('sms-code').value,
clientType: $('sms-client-type').value,
deviceKey: $('sms-device').value
}, true);
state.accessToken = result.data.tokens.accessToken;
state.refreshToken = result.data.tokens.refreshToken;
return result;
});
$('btn-login-wechat').onclick = () => run('auth/login/wechat-mini', async () => {
const result = await request('POST', '/auth/login/wechat-mini', {
code: $('wechat-code').value,
clientType: 'wechat',
deviceKey: $('wechat-device').value
});
state.accessToken = result.data.tokens.accessToken;
state.refreshToken = result.data.tokens.refreshToken;
return result;
});
$('btn-resolve-entry').onclick = () => run('entry/resolve', () =>
request('GET', '/entry/resolve?channelCode=' + encodeURIComponent($('entry-channel-code').value) + '&channelType=' + encodeURIComponent($('entry-channel-type').value))
);
$('btn-home').onclick = () => run('home', () =>
request('GET', '/home?channelCode=' + encodeURIComponent($('entry-channel-code').value) + '&channelType=' + encodeURIComponent($('entry-channel-type').value))
);
$('btn-entry-home').onclick = () => run('me/entry-home', () =>
request('GET', '/me/entry-home?channelCode=' + encodeURIComponent($('entry-channel-code').value) + '&channelType=' + encodeURIComponent($('entry-channel-type').value), undefined, true)
);
$('btn-event-detail').onclick = () => run('event-detail', () =>
request('GET', '/events/' + encodeURIComponent($('event-id').value))
);
$('btn-event-play').onclick = () => run('event-play', () =>
request('GET', '/events/' + encodeURIComponent($('event-id').value) + '/play', undefined, true)
);
$('btn-launch').onclick = () => run('event-launch', async () => {
const result = await request('POST', '/events/' + encodeURIComponent($('event-id').value) + '/launch', {
releaseId: $('event-release-id').value,
clientType: $('sms-client-type').value,
deviceKey: $('event-device').value
}, true);
state.sessionId = result.data.launch.business.sessionId;
state.sessionToken = result.data.launch.business.sessionToken;
return result;
});
$('btn-session-detail').onclick = () => run('session-detail', () =>
request('GET', '/sessions/' + encodeURIComponent($('session-id').value), undefined, true)
);
$('btn-session-start').onclick = () => run('session-start', () =>
request('POST', '/sessions/' + encodeURIComponent($('session-id').value) + '/start', {
sessionToken: $('session-token').value
})
);
$('btn-session-finish').onclick = () => run('session-finish', () =>
request('POST', '/sessions/' + encodeURIComponent($('session-id').value) + '/finish', {
sessionToken: $('session-token').value,
status: $('finish-status').value,
summary: buildFinishSummary()
})
);
$('btn-my-sessions').onclick = () => run('me/sessions', () =>
request('GET', '/me/sessions?limit=10', undefined, true)
);
$('btn-session-result').onclick = () => run('session-result', () =>
request('GET', '/sessions/' + encodeURIComponent($('session-id').value) + '/result', undefined, true)
);
$('btn-my-results').onclick = () => run('me/results', () =>
request('GET', '/me/results?limit=10', undefined, true)
);
$('btn-me').onclick = () => run('me', () =>
request('GET', '/me', undefined, true)
);
$('btn-profile').onclick = () => run('me/profile', () =>
request('GET', '/me/profile', undefined, true)
);
$('btn-copy-curl').onclick = async () => {
if (!state.lastCurl) {
setStatus('error: no curl to copy', true);
return;
}
try {
await navigator.clipboard.writeText(state.lastCurl);
setStatus('ok: curl copied');
} catch (_) {
setStatus('error: clipboard unavailable', true);
}
};
$('btn-clear-history').onclick = () => {
localStorage.removeItem(HISTORY_KEY);
renderHistory();
setStatus('ok: history cleared');
};
$('btn-scenario-save').onclick = saveCurrentScenario;
$('btn-scenario-load').onclick = loadSelectedScenario;
$('btn-scenario-delete').onclick = deleteSelectedScenario;
$('btn-scenario-export').onclick = exportSelectedScenario;
$('btn-scenario-import').onclick = importScenarioFromJSON;
$('btn-flow-home').onclick = () => run('flow-home', async () => {
await request('POST', '/dev/bootstrap-demo');
const login = await request('POST', '/auth/login/wechat-mini', {
code: $('wechat-code').value,
clientType: 'wechat',
deviceKey: $('wechat-device').value
});
state.accessToken = login.data.tokens.accessToken;
state.refreshToken = login.data.tokens.refreshToken;
return await request('GET', '/me/entry-home?channelCode=' + encodeURIComponent($('entry-channel-code').value) + '&channelType=' + encodeURIComponent($('entry-channel-type').value), undefined, true);
});
$('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;
}
return result;
});
$('btn-flow-launch').onclick = () => run('flow-launch', async () => {
await request('POST', '/dev/bootstrap-demo');
const smsSend = await request('POST', '/auth/sms/send', {
countryCode: $('sms-country').value,
mobile: $('sms-mobile').value,
clientType: $('sms-client-type').value,
deviceKey: $('sms-device').value,
scene: 'login'
});
if (smsSend.data && smsSend.data.devCode) {
$('sms-code').value = smsSend.data.devCode;
}
const login = await request('POST', '/auth/login/sms', {
countryCode: $('sms-country').value,
mobile: $('sms-mobile').value,
code: $('sms-code').value,
clientType: $('sms-client-type').value,
deviceKey: $('sms-device').value
});
state.accessToken = login.data.tokens.accessToken;
state.refreshToken = login.data.tokens.refreshToken;
const launch = await request('POST', '/events/' + encodeURIComponent($('event-id').value) + '/launch', {
releaseId: $('event-release-id').value,
clientType: $('sms-client-type').value,
deviceKey: $('event-device').value
}, true);
state.sessionId = launch.data.launch.business.sessionId;
state.sessionToken = launch.data.launch.business.sessionToken;
return await request('POST', '/sessions/' + encodeURIComponent(state.sessionId) + '/start', {
sessionToken: state.sessionToken
});
});
$('btn-flow-finish').onclick = () => run('flow-finish', async () => {
return await request('POST', '/sessions/' + encodeURIComponent($('session-id').value) + '/finish', {
sessionToken: $('session-token').value,
status: $('finish-status').value,
summary: buildFinishSummary()
});
});
$('btn-flow-result').onclick = () => run('flow-result', async () => {
await request('POST', '/sessions/' + encodeURIComponent($('session-id').value) + '/finish', {
sessionToken: $('session-token').value,
status: $('finish-status').value,
summary: buildFinishSummary()
});
return await request('GET', '/sessions/' + encodeURIComponent($('session-id').value) + '/result', undefined, true);
});
[
'sms-client-type', 'sms-scene', 'sms-mobile', 'sms-device', 'sms-country', 'sms-code',
'wechat-code', 'wechat-device', 'local-config-file', 'config-event-id', 'entry-channel-code', 'entry-channel-type',
'event-id', 'event-release-id', 'event-device', 'finish-status', 'finish-duration', 'finish-score',
'finish-controls-done', 'finish-controls-total', 'finish-distance', 'finish-speed',
'finish-heart-rate'
].forEach(function(id) {
$(id).addEventListener('change', persistState);
$(id).addEventListener('input', persistState);
});
$('api-filter').addEventListener('input', applyAPIFilter);
restoreState();
syncState();
renderHistory();
renderScenarioOptions();
applyAPIFilter();
writeLog('workbench-ready', { ok: true, hint: 'Use Bootstrap Demo first on a fresh database.' });
</script>
</body>
</html>`