Forráskód Böngészése

完善一键回归与真实输入准备

zhangyan 4 napja
szülő
commit
114c524044

+ 69 - 2
b2f.md

@@ -1,6 +1,6 @@
 # b2f
-> 文档版本:v1.7
-> 最后更新:2026-04-03 12:36:15
+> 文档版本:v1.10
+> 最后更新:2026-04-03 20:10:25
 
 
 说明:
@@ -103,6 +103,73 @@
 
 ## 已确认
 
+### B2F-024
+
+- 时间:2026-04-03 20:10:25
+- 谁提的:backend
+- 当前事实:
+  - backend 已确认 `evt_demo_variant_manual_001` 曾存在历史残留的 `launched` session,导致 `play.primaryAction=continue`
+  - backend 已把清理逻辑并入 `POST /dev/bootstrap-demo`
+  - 现在每次准备 demo 数据时,都会自动把 demo event 下残留的:
+    - `launched`
+    - `running`
+    session 改成 `cancelled`
+  - 这意味着前端后续再用标准测试链回归时,不需要手工清理旧 demo ongoing
+- 需要对方确认什么:
+  - frontend 遇到这类“明明本地没有恢复快照,但后端仍返回 continue”的情况,优先先重新执行一次 `Bootstrap Demo`
+- 是否已解决:是
+
+### B2F-023
+
+- 时间:2026-04-03 13:24:38
+- 谁提的:backend
+- 当前事实:
+  - backend 已把标准联调回归收成一键流
+  - workbench 当前新增:
+    - `一键标准回归`
+    - `回归结果汇总`
+  - 这条链会在标准发布链之后继续自动验证:
+    - `GET /events/{eventPublicID}/play`
+    - `POST /events/{eventPublicID}/launch`
+    - `GET /sessions/{sessionPublicID}/result`
+    - `GET /me/sessions`
+    - `GET /me/results`
+  - 回归结果会直接显示分项通过/未通过,不再要求 frontend 自己口头判断
+- 需要对方确认什么:
+  - frontend 当前回归优先使用这条一键标准回归链
+- 是否已解决:是
+
+### B2F-022
+
+- 时间:2026-04-03 13:18:42
+- 谁提的:backend
+- 当前事实:
+  - backend 当前已进入“联调标准化阶段”
+  - 当前推荐 frontend 优先使用 workbench 的:
+    - `Bootstrap Demo`
+    - `一键补齐 Runtime 并发布`
+    作为联调回归入口
+  - backend 现在提供的不是零散 demo 文本,而是一套可重复创建的真实测试对象:
+    - `place`
+    - `map asset`
+    - `tile release`
+    - `course source`
+    - `course set`
+    - `course variant`
+    - `runtime binding`
+    - `presentation`
+    - `content bundle`
+    - `release`
+  - 如果联调失败,workbench 当前会直接给出:
+    - 分步日志
+    - 真实错误消息
+    - stack
+    - 最后一次 curl
+    - 预期判定
+- 需要对方确认什么:
+  - frontend 回归时优先基于这条一键测试链,不再先手工拼测试数据
+- 是否已解决:是
+
 ### B2F-019
 
 - 时间:2026-04-03 12:36:15

+ 67 - 2
b2t.md

@@ -1,6 +1,6 @@
 # B2T 协作清单
-> 文档版本:v1.11
-> 最后更新:2026-04-03 13:04:32
+> 文档版本:v1.13
+> 最后更新:2026-04-03 13:24:38
 
 说明:
 
@@ -130,6 +130,55 @@
 
 ## 已完成
 
+### B2T-023
+
+- 时间:2026-04-03 13:24:38
+- 谁提的:backend
+- 当前事实:
+  - backend 已把标准联调入口继续固化为一键回归流
+  - workbench 当前新增:
+    - `一键标准回归`
+    - `回归结果汇总`
+  - 这条链当前会在:
+    - `Bootstrap Demo`
+    - `一键补齐 Runtime 并发布`
+    之后,继续自动验证:
+    - `play`
+    - `launch`
+    - `result`
+    - `history`
+  - 回归汇总当前会直接显示:
+    - 分项通过/未通过
+    - `Session ID`
+    - 总判定
+- 需要对方确认什么:
+  - 无
+- 是否已解决:是
+
+### B2T-021
+
+- 时间:2026-04-03 13:18:42
+- 谁提的:backend
+- 当前事实:
+  - backend 已根据 [t2b.md](D:/dev/cmr-mini/t2b.md) v1.10 的最新口径,切入“联调标准化阶段”
+  - 当前 backend 主线不再是继续扩对象或扩 workbench 管理能力
+  - 当前统一收口为 3 个目标:
+    - 固化“一键测试”链路
+    - 固化详细日志口径
+    - 固化稳定测试数据
+  - 当前最推荐联调方式为:
+    - `Bootstrap Demo`
+    - `一键补齐 Runtime 并发布`
+  - 这条链当前已可从空白环境直接跑通,并可输出:
+    - 分步日志
+    - 真实错误
+    - stack
+    - 最后一次 curl
+    - 预期判定
+- 需要对方确认什么:
+  - 无
+- 是否已解决:是
+
 ### B2T-019
 
 - 时间:2026-04-03 13:04:32
@@ -302,6 +351,22 @@
 
 ## 下一步
 
+### B2T-022
+
+- 时间:2026-04-03 13:18:42
+- 谁提的:backend
+- 当前事实:
+  - backend 当前已具备“一键测试环境”与最小生产骨架测试数据
+  - 后续联调阶段如要进一步贴近生产,只需要逐步替换以下 demo 输入:
+    - 地图资源 URL
+    - KML / 赛道文件
+    - ContentBundle manifest
+    - Presentation schema
+  - 当前不需要继续新增对象层级即可支撑联调
+- 需要对方确认什么:
+  - 总控后续如需要更接近生产的真实测试输入,请直接指定优先级最高的一类资源
+- 是否已解决:否
+
 ### B2T-010
 
 - 时间:2026-04-03 08:52:11

+ 3 - 2
backend/README.md

@@ -1,6 +1,6 @@
 # Backend
-> 文档版本:v1.11
-> 最后更新:2026-04-03 13:04:32
+> 文档版本:v1.12
+> 最后更新:2026-04-03 13:24:38
 
 
 这套后端现在已经能支撑一条完整主链:
@@ -63,5 +63,6 @@ cd D:\dev\cmr-mini\backend
   - Runtime 自动补齐 + 默认绑定发布一键验证
   - 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`
 
 

+ 14 - 2
backend/docs/开发说明.md

@@ -1,6 +1,6 @@
 # 开发说明
-> 文档版本:v1.12
-> 最后更新:2026-04-03 13:04:32
+> 文档版本:v1.14
+> 最后更新:2026-04-03 20:10:25
 
 
 ## 1. 环境变量
@@ -73,6 +73,7 @@ cd D:\dev\cmr-mini\backend
 
 1. `Bootstrap Demo`
 2. `一键补齐 Runtime 并发布`
+3. `一键标准回归`
 
 当前这条一键链会自动完成:
 
@@ -90,6 +91,9 @@ cd D:\dev\cmr-mini\backend
   - `runtime binding`
 - publish
 - release 回读校验
+- `play / launch / result / history` 回归汇总
+- demo 活动残留 ongoing session 清理:
+  - 会把 demo event 下历史遗留的 `launched / running` session 自动改成 `cancelled`
 
 当前日志能力:
 
@@ -104,6 +108,14 @@ cd D:\dev\cmr-mini\backend
   - `Content Bundle`
   - `Runtime Binding`
   - `判定`
+- 成功跑完标准回归后,“回归结果汇总”会直接给出:
+  - `发布链`
+  - `Play`
+  - `Launch`
+  - `Result`
+  - `History`
+  - `Session ID`
+  - `总判定`
 
 ## 3. 当前开发约定
 

+ 216 - 1
backend/internal/httpapi/handlers/dev_handler.go

@@ -734,8 +734,9 @@ const devWorkbenchHTML = `<!doctype html>
           <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>
         </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,再继续发布链。</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">
@@ -746,6 +747,18 @@ const devWorkbenchHTML = `<!doctype html>
             <div>判定 <code id="flow-admin-verdict">待执行</code></div>
           </div>
         </div>
+        <div class="subpanel">
+          <div class="muted-note">回归结果汇总</div>
+          <div class="kv">
+            <div>发布链 <code id="flow-regression-publish-result">待执行</code></div>
+            <div>Play <code id="flow-regression-play-result">待执行</code></div>
+            <div>Launch <code id="flow-regression-launch-result">待执行</code></div>
+            <div>Result <code id="flow-regression-result-result">待执行</code></div>
+            <div>History <code id="flow-regression-history-result">待执行</code></div>
+            <div>Session ID <code id="flow-regression-session-id">-</code></div>
+            <div>总判定 <code id="flow-regression-overall">待执行</code></div>
+          </div>
+        </div>
       </section>
 
       <section class="panel" data-modes="common">
@@ -2217,6 +2230,70 @@ const devWorkbenchHTML = `<!doctype html>
       $('flow-admin-verdict').textContent = verdict;
     }
 
+    function resetStandardRegressionExpectation() {
+      $('flow-regression-publish-result').textContent = '待执行';
+      $('flow-regression-play-result').textContent = '待执行';
+      $('flow-regression-launch-result').textContent = '待执行';
+      $('flow-regression-result-result').textContent = '待执行';
+      $('flow-regression-history-result').textContent = '待执行';
+      $('flow-regression-session-id').textContent = '-';
+      $('flow-regression-overall').textContent = '待执行';
+    }
+
+    function setStandardRegressionExpectation(summary) {
+      const publishText = summary && summary.publish ? summary.publish : '待执行';
+      const playText = summary && summary.play ? summary.play : '待执行';
+      const launchText = summary && summary.launch ? summary.launch : '待执行';
+      const resultText = summary && summary.result ? summary.result : '待执行';
+      const historyText = summary && summary.history ? summary.history : '待执行';
+      const sessionId = summary && summary.sessionId ? summary.sessionId : '-';
+      const overall = summary && summary.overall ? summary.overall : '待执行';
+      $('flow-regression-publish-result').textContent = publishText;
+      $('flow-regression-play-result').textContent = playText;
+      $('flow-regression-launch-result').textContent = launchText;
+      $('flow-regression-result-result').textContent = resultText;
+      $('flow-regression-history-result').textContent = historyText;
+      $('flow-regression-session-id').textContent = sessionId;
+      $('flow-regression-overall').textContent = overall;
+    }
+
+    function extractList(payload) {
+      if (Array.isArray(payload)) {
+        return payload;
+      }
+      if (!payload || typeof payload !== 'object') {
+        return [];
+      }
+      if (Array.isArray(payload.items)) {
+        return payload.items;
+      }
+      if (Array.isArray(payload.results)) {
+        return payload.results;
+      }
+      if (Array.isArray(payload.sessions)) {
+        return payload.sessions;
+      }
+      return [];
+    }
+
+    function listContainsSession(list, sessionId) {
+      if (!sessionId) {
+        return false;
+      }
+      return list.some(function(item) {
+        if (!item || typeof item !== 'object') {
+          return false;
+        }
+        if (item.id && item.id === sessionId) {
+          return true;
+        }
+        if (item.session && item.session.id && item.session.id === sessionId) {
+          return true;
+        }
+        return false;
+      });
+    }
+
     async function runAdminDefaultPublishFlow(options) {
       const ensureRuntime = options && options.ensureRuntime === true;
       const flowTitle = ensureRuntime ? 'flow-admin-runtime-publish' : 'flow-admin-default-publish';
@@ -2407,6 +2484,140 @@ const devWorkbenchHTML = `<!doctype html>
       return releaseDetail;
     }
 
+    async function runStandardRegressionFlow() {
+      const flowTitle = 'flow-standard-regression';
+      const eventId = $('event-id').value || $('admin-event-ref-id').value;
+      if (!trimmedOrUndefined(eventId)) {
+        throw new Error('event id is required');
+      }
+      resetStandardRegressionExpectation();
+
+      writeLog(flowTitle + '.step', { step: 'prepare-release', eventId: eventId });
+      const releaseDetail = await runAdminDefaultPublishFlow({ ensureRuntime: true });
+      const publishPass = $('flow-admin-verdict').textContent.indexOf('通过') === 0;
+
+      writeLog(flowTitle + '.step', {
+        step: 'login-wechat',
+        code: $('wechat-code').value,
+        deviceKey: $('wechat-device').value
+      });
+      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;
+
+      writeLog(flowTitle + '.step', {
+        step: 'event-play',
+        eventId: eventId
+      });
+      const play = await request('GET', '/events/' + encodeURIComponent(eventId) + '/play', undefined, true);
+      const playPass = !!(play.data && play.data.play && play.data.resolvedRelease && play.data.resolvedRelease.manifestUrl);
+
+      writeLog(flowTitle + '.step', {
+        step: 'event-launch',
+        eventId: eventId,
+        releaseId: $('event-release-id').value || state.releaseId,
+        variantId: trimmedOrUndefined($('event-variant-id').value)
+      });
+      const launch = await request('POST', '/events/' + encodeURIComponent(eventId) + '/launch', {
+        releaseId: $('event-release-id').value,
+        variantId: trimmedOrUndefined($('event-variant-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;
+      syncState();
+      const launchPass = !!(
+        launch.data &&
+        launch.data.launch &&
+        launch.data.launch.business &&
+        launch.data.launch.business.sessionId &&
+        launch.data.launch.resolvedRelease &&
+        launch.data.launch.resolvedRelease.manifestUrl
+      );
+
+      writeLog(flowTitle + '.step', {
+        step: 'session-start',
+        sessionId: state.sessionId
+      });
+      await request('POST', '/sessions/' + encodeURIComponent(state.sessionId) + '/start', {
+        sessionToken: state.sessionToken
+      });
+
+      writeLog(flowTitle + '.step', {
+        step: 'session-finish',
+        sessionId: state.sessionId,
+        status: $('finish-status').value
+      });
+      await request('POST', '/sessions/' + encodeURIComponent(state.sessionId) + '/finish', {
+        sessionToken: state.sessionToken,
+        status: $('finish-status').value,
+        summary: buildFinishSummary()
+      });
+
+      writeLog(flowTitle + '.step', {
+        step: 'session-result',
+        sessionId: state.sessionId
+      });
+      const sessionResult = await request('GET', '/sessions/' + encodeURIComponent(state.sessionId) + '/result', undefined, true);
+      const resultPass = !!(
+        sessionResult.data &&
+        sessionResult.data.session &&
+        sessionResult.data.session.id === state.sessionId &&
+        sessionResult.data.result
+      );
+
+      writeLog(flowTitle + '.step', {
+        step: 'history-check',
+        sessionId: state.sessionId
+      });
+      const mySessions = await request('GET', '/me/sessions?limit=10', undefined, true);
+      const myResults = await request('GET', '/me/results?limit=10', undefined, true);
+      const sessionsList = extractList(mySessions.data);
+      const resultsList = extractList(myResults.data);
+      const historyPass = listContainsSession(sessionsList, state.sessionId) && listContainsSession(resultsList, state.sessionId);
+
+      const summary = {
+        publish: publishPass ? '通过:发布链可重复跑通' : '未通过:发布链未返回通过判定',
+        play: playPass ? '通过:play 返回 resolvedRelease / play 摘要' : '未通过:play 缺少关键摘要',
+        launch: launchPass ? '通过:launch 返回 manifest + session' : '未通过:launch 缺少 manifest 或 session',
+        result: resultPass ? '通过:单局 result 可直接回查' : '未通过:单局 result 未回查成功',
+        history: historyPass ? '通过:me/sessions + me/results 均收录本局' : '未通过:history 未同时收录本局',
+        sessionId: state.sessionId || '-',
+        overall: publishPass && playPass && launchPass && resultPass && historyPass ? '通过:launch / play / result / history 回归已跑通' : '未通过:请看上面分项和日志'
+      };
+      setStandardRegressionExpectation(summary);
+      writeLog(flowTitle + '.expected', {
+        eventId: eventId,
+        releaseId: releaseDetail && releaseDetail.data ? releaseDetail.data.id : state.releaseId,
+        sessionId: state.sessionId,
+        publish: summary.publish,
+        play: summary.play,
+        launch: summary.launch,
+        result: summary.result,
+        history: summary.history,
+        overall: summary.overall
+      });
+      persistState();
+      return {
+        data: {
+          eventId: eventId,
+          releaseId: releaseDetail && releaseDetail.data ? releaseDetail.data.id : state.releaseId,
+          sessionId: state.sessionId,
+          publish: publishPass,
+          play: playPass,
+          launch: launchPass,
+          result: resultPass,
+          history: historyPass,
+          overall: publishPass && playPass && launchPass && resultPass && historyPass
+        }
+      };
+    }
+
     function setStatus(text, isError = false) {
       statusEl.textContent = text;
       statusEl.className = isError ? 'status error' : 'status';
@@ -4124,6 +4335,10 @@ const devWorkbenchHTML = `<!doctype html>
       return await runAdminDefaultPublishFlow({ ensureRuntime: true });
     });
 
+    $('btn-flow-standard-regression').onclick = () => run('flow-standard-regression', async () => {
+      return await runStandardRegressionFlow();
+    });
+
     [
       'sms-client-type', 'sms-scene', 'sms-mobile', 'sms-device', 'sms-country', 'sms-code',
       'wechat-code', 'wechat-device', 'local-config-file', 'config-event-id', 'config-runtime-binding-id', 'config-presentation-id', 'config-content-bundle-id', 'entry-channel-code', 'entry-channel-type',

+ 19 - 0
backend/internal/store/postgres/dev_store.go

@@ -23,6 +23,7 @@ type DemoBootstrapSummary struct {
 	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) {
@@ -597,6 +598,23 @@ func (s *Store) EnsureDemoData(ctx context.Context) (*DemoBootstrapSummary, erro
 		return nil, fmt.Errorf("ensure variant manual demo card: %w", err)
 	}
 
+	var cleanedSessionCount int64
+	if err := tx.QueryRow(ctx, `
+		WITH cleaned AS (
+			UPDATE game_sessions
+			SET
+				status = 'cancelled',
+				ended_at = NOW(),
+				updated_at = NOW()
+			WHERE event_id = ANY($1::uuid[])
+			  AND status IN ('launched', 'running')
+			RETURNING 1
+		)
+		SELECT COUNT(*) FROM cleaned
+	`, []string{eventID, manualEventID}).Scan(&cleanedSessionCount); err != nil {
+		return nil, fmt.Errorf("cleanup demo ongoing sessions: %w", err)
+	}
+
 	if err := tx.Commit(ctx); err != nil {
 		return nil, err
 	}
@@ -619,5 +637,6 @@ func (s *Store) EnsureDemoData(ctx context.Context) (*DemoBootstrapSummary, erro
 		VariantManualEventID: "evt_demo_variant_manual_001",
 		VariantManualRelease: manualReleaseRow.PublicID,
 		VariantManualCardID:  manualCardPublicID,
+		CleanedSessionCount:  cleanedSessionCount,
 	}, nil
 }

+ 18 - 2
doc/gameplay/活动运营域摘要第一刀联调回归清单.md

@@ -1,6 +1,6 @@
 # 活动运营域摘要第一刀联调回归清单
-> 文档版本:v1.0
-> 最后更新:2026-04-03 19:38:00
+> 文档版本:v1.1
+> 最后更新:2026-04-03 19:48:00
 
 ## 目标
 
@@ -13,6 +13,22 @@
 - `launch.presentation / launch.contentBundle` 会话快照
 - 与 runtime 主链的相互不干扰
 
+## 联调环境
+
+当前统一使用 backend 提供的一键测试环境做回归,不再各自手工准备多份 demo 对象。
+
+推荐路径:
+
+- 先执行 `Bootstrap Demo`
+- 再执行“一键补齐 Runtime 并发布”
+- 再用稳定 demo 数据进入:
+  - 活动详情页
+  - 准备页
+  - 地图页
+  - 结果页
+
+当前重点不是验证 workbench 本身,而是利用这套统一环境回归前端摘要链是否稳定。
+
 ## 回归项
 
 ### 1. 活动详情页摘要

+ 16 - 3
f2b.md

@@ -1,6 +1,6 @@
 # F2B 协作清单
-> 文档版本:v1.4
-> 最后更新:2026-04-03 19:20:00
+> 文档版本:v1.5
+> 最后更新:2026-04-03 20:02:00
 
 
 说明:
@@ -14,7 +14,20 @@
 
 ## 待确认
 
-- 当前无
+### F2B-011
+
+- 时间:2026-04-03
+- 提出方:前端
+- 当前事实:
+  - 使用 backend 一键测试环境联调 `evt_demo_variant_manual_001` 时,活动页 / 准备页返回:
+    - `primaryAction = continue`
+    - `reason = user has an ongoing session for this event`
+  - 但前端本地当前没有可恢复快照,且本轮联调主观确认“已经没有需要恢复的游戏”
+  - 当前看起来像是 backend 仍认定该用户在该活动下存在 ongoing session
+- 需要对方确认什么:
+  - 请 backend 核对该用户在 `evt_demo_variant_manual_001` 下是否仍有 `launched / running` session 未清掉
+  - 如这是预期行为,请说明推荐的标准清理路径;如不是预期,请修正 ongoing 判定或测试环境回收逻辑
+- 状态:待确认
 
 ---
 

+ 17 - 3
f2t.md

@@ -1,6 +1,6 @@
 # F2T 协作清单
-> 文档版本:v1.6
-> 最后更新:2026-04-03 19:38:00
+> 文档版本:v1.7
+> 最后更新:2026-04-03 19:48:00
 
 说明:
 
@@ -158,8 +158,22 @@
   - 无
 - 是否已解决:是
 
+### F2T-D005
+
+- 时间:2026-04-03 19:48:00
+- 谁提的:frontend
+- 当前事实:
+  - 已按总控最新口径把联调方式标准化
+  - 当前活动运营域摘要第一刀回归默认统一使用 backend 的一键测试环境:
+    - `Bootstrap Demo`
+    - `一键补齐 Runtime 并发布`
+  - 不再建议前后端各自手工铺多份 demo 对象
+- 需要确认什么:
+  - 无
+- 是否已解决:是
+
 ---
 
 ## 下一步
 
-- 当前进入活动运营域摘要第一刀的联调回归与小范围修复阶段
+- 当前进入活动运营域摘要第一刀在 backend 一键测试环境下的联调回归与小范围修复阶段

+ 3 - 2
miniprogram/pages/event-prepare/event-prepare.ts

@@ -1,6 +1,7 @@
 import { loadBackendAuthTokens, loadBackendBaseUrl } from '../../utils/backendAuth'
 import { getEventPlay, launchEvent, type BackendCourseVariantSummary, type BackendEventPlayResult } from '../../utils/backendApi'
 import { adaptBackendLaunchResultToEnvelope } from '../../utils/backendLaunchAdapter'
+import { formatBackendPlayActionText, formatBackendPlayStatusText } from '../../utils/backendPlayCopy'
 import { prepareMapPageUrlForLaunch } from '../../utils/gameLaunch'
 import { HeartRateController } from '../../engine/sensor/heartRateController'
 
@@ -302,8 +303,8 @@ Page({
       releaseText: result.resolvedRelease
         ? `${result.resolvedRelease.configLabel} / ${result.resolvedRelease.releaseId}`
         : '当前无可用 release',
-      actionText: `${result.play.primaryAction} / ${result.play.reason}`,
-      statusText: result.play.canLaunch ? '准备完成,可进入地图' : '当前不可启动',
+      actionText: formatBackendPlayActionText(result.play.primaryAction, result.play.reason),
+      statusText: formatBackendPlayStatusText(result.play.canLaunch, result.play.primaryAction, result.play.reason),
       assignmentMode: result.play.assignmentMode || '',
       variantModeText: formatAssignmentMode(result.play.assignmentMode),
       variantSummaryText: formatVariantSummary(result),

+ 3 - 2
miniprogram/pages/event/event.ts

@@ -1,5 +1,6 @@
 import { loadBackendAuthTokens, loadBackendBaseUrl } from '../../utils/backendAuth'
 import { getEventPlay, type BackendEventPlayResult } from '../../utils/backendApi'
+import { formatBackendPlayActionText, formatBackendPlayStatusText } from '../../utils/backendPlayCopy'
 
 type EventPageData = {
   eventId: string
@@ -136,8 +137,8 @@ Page({
       releaseText: result.resolvedRelease
         ? `${result.resolvedRelease.configLabel} / ${result.resolvedRelease.releaseId}`
         : '当前无可用 release',
-      actionText: `${result.play.primaryAction} / ${result.play.reason}`,
-      statusText: result.play.canLaunch ? '可启动' : '当前不可启动',
+      actionText: formatBackendPlayActionText(result.play.primaryAction, result.play.reason),
+      statusText: formatBackendPlayStatusText(result.play.canLaunch, result.play.primaryAction, result.play.reason),
       variantModeText: formatAssignmentMode(result.play.assignmentMode),
       variantSummaryText: formatVariantSummary(result),
       presentationText: formatPresentationSummary(result),

+ 65 - 0
miniprogram/utils/backendPlayCopy.ts

@@ -0,0 +1,65 @@
+function normalizeReason(reason?: string | null): string {
+  if (!reason) {
+    return ''
+  }
+
+  if (reason === 'user has an ongoing session for this event') {
+    return '当前活动存在未结束对局'
+  }
+  if (reason === 'no ongoing session for this event') {
+    return '当前活动没有进行中的对局'
+  }
+  if (reason === 'ready to launch') {
+    return '当前可直接开始'
+  }
+  if (reason === 'launch blocked') {
+    return '当前启动受限'
+  }
+
+  return reason
+}
+
+function normalizeAction(action?: string | null): string {
+  if (!action) {
+    return '--'
+  }
+
+  if (action === 'continue') {
+    return '继续上一局'
+  }
+  if (action === 'launch' || action === 'start') {
+    return '开始比赛'
+  }
+  if (action === 'preview') {
+    return '查看活动'
+  }
+
+  return action
+}
+
+export function formatBackendPlayActionText(action?: string | null, reason?: string | null): string {
+  const actionText = normalizeAction(action)
+  const reasonText = normalizeReason(reason)
+  if (!reasonText) {
+    return actionText
+  }
+
+  return `${actionText}(${reasonText})`
+}
+
+export function formatBackendPlayStatusText(canLaunch: boolean, action?: string | null, reason?: string | null): string {
+  if (!canLaunch) {
+    return '当前不可启动'
+  }
+
+  if (action === 'continue') {
+    return '检测到未结束对局,可继续进入地图'
+  }
+
+  const reasonText = normalizeReason(reason)
+  if (reasonText) {
+    return `${reasonText},可进入地图`
+  }
+
+  return '可启动'
+}

+ 11 - 6
readme-develop.md

@@ -1,6 +1,6 @@
 # CMR Mini 开发架构阶段总结
-> 文档版本:v1.13
-> 最后更新:2026-04-03 13:08:15
+> 文档版本:v1.14
+> 最后更新:2026-04-03 14:16:17
 
 文档维护约定:
 
@@ -45,6 +45,7 @@
     - `Bootstrap Demo` 已可补齐:
       - `place / map asset / tile release / course source / course set / course variant / runtime binding`
     - `一键补齐 Runtime 并发布` 已可从空白状态跑完整测试链
+    - `一键标准回归` 与 `回归结果汇总` 已接入 workbench
     - workbench 日志已具备:
       - 分步日志
       - 真实错误
@@ -52,14 +53,18 @@
       - 最后一次 curl
       - 预期判定
   - 下一步建议:
-    - 固化“一键测试”链路为联调标准路径
-    - 固化稳定测试数据,不再依赖手工铺对象
-    - 逐步准备更接近生产的真实输入:
-      - 地图资源 URL
+    - 联调标准化第一版视为已完成
+    - 下一步进入“真实输入替换第一刀”
+    - 逐步把 demo 输入替换成更接近生产的真实输入:
       - KML / 赛道文件
+      - 地图资源 URL
       - 内容 manifest
       - presentation schema
       - 活动文案样例
+    - backend 在联调标准化阶段应优先保证:
+      - 从空白环境直接可跑
+      - workbench 日志能明确定位失败步骤
+      - 同一条测试链可重复执行
 - 前端线程建议正式上场时机:
   - 现在已完成活动运营域摘要接线第一刀
   - 当前已完成:

+ 78 - 2
t2b.md

@@ -1,6 +1,6 @@
 # T2B 协作清单
-> 文档版本:v1.10
-> 最后更新:2026-04-03 13:08:15
+> 文档版本:v1.11
+> 最后更新:2026-04-03 14:16:17
 
 说明:
 
@@ -24,6 +24,7 @@ backend 当前已完成:
   - `currentRuntimeBindingId`
 - `publish` 默认继承当前 active 三元组
 - `Bootstrap Demo` 与 `一键补齐 Runtime 并发布` 已可从空白状态跑完整测试链
+- `一键标准回归` 与 `回归结果汇总` 已接入标准联调入口
 - workbench 日志已补齐:
   - 分步日志
   - 真实错误
@@ -41,6 +42,81 @@ backend 当前已完成:
 2. 固化详细日志口径,失败时明确定位在哪一步
 3. 固化稳定测试数据,并逐步支持更接近生产的真实输入
 
+当前认为“联调标准化第一版”已经基本到位,backend 下一步应进入:
+
+**真实输入替换第一刀**
+
+优先顺序建议:
+
+1. 先替换真实 KML / 赛道文件
+2. 再替换真实地图资源 URL
+3. 再替换真实内容 manifest / presentation schema
+4. 最后再补真实活动文案样例
+
+原则:
+
+- 仍走同一条一键回归链
+- 不重新设计联调流程
+- 只是把 demo 输入逐步换成更接近生产的真实输入
+
+当前进一步明确 backend 的执行口径如下:
+
+### 0.1 一键测试链路
+
+请继续以这条链作为唯一标准联调入口维护:
+
+```text
+Bootstrap Demo
+-> 一键补齐 Runtime 并发布
+-> launch / play / result / history 回归
+```
+
+要求:
+
+- 从空白环境直接可跑
+- 不依赖手工预铺 6~8 个对象
+- 同一条链可反复执行
+- 失败时能明确知道卡在哪一跳
+
+### 0.2 详细日志口径
+
+workbench 和相关 backend 调试输出,当前应至少统一包含:
+
+- 当前步骤名
+- 核心输入参数
+- 真实错误信息
+- stack
+- 最后一次 curl
+- 预期判定
+
+不要只输出“失败了”,要能回答:
+
+- 是哪一步失败
+- 为什么失败
+- 用什么请求复现
+
+### 0.3 稳定测试数据
+
+当前 demo 数据不要继续散落手工维护,统一以 backend 准备的一键测试数据为准。
+
+后续逐步支持以下更接近生产的真实输入:
+
+- 地图资源 URL
+- KML / 赛道文件
+- 内容 manifest
+- presentation schema
+- 活动文案样例
+
+### 0.4 当前不建议做
+
+联调标准化阶段不要继续发散去做:
+
+- 新对象扩张
+- 新管理面板
+- 更复杂 workbench UI
+- 复杂后台运营功能
+- 与当前联调闭环无关的页面能力
+
 当前不建议 backend 继续发散去做:
 
 - 更多新对象