第18章 - 测试体系与质量门禁
第 17 章说”跑 content:check 和 audit:levels“,但没说这两条命令各自到底在拦什么、拦不住什么。这一章把质量体系摊开:369 条单测分布在哪些文件、90 条 e2e 各守什么、内容体检与反刷题审计的判定口径、体积门禁怎么量、pnpm verify 为什么把 size 单独串行,以及几条门禁全绿但问题仍在的盲区。
先给结论:这套体系的目标不是”覆盖率数字好看”,而是每个失败分支都有人守,每种”能绕过判定”的走法都被自动跑一遍。数量只是副产品。
18.1 三层门禁:一条命令与两条补充
根 package.json 把门禁拆成三条命令,它们覆盖不同的信任边界:
{
"scripts": {
"typecheck": "vue-tsc --noEmit -p tsconfig.json",
"lint": "eslint . --concurrency auto --cache --cache-location node_modules/.cache/eslint",
"test": "vitest run",
"content:check": "tsx scripts/content-check.ts",
"audit:levels": "tsx scripts/audit-levels.ts",
"pack:check": "node scripts/check-packaging.mjs",
"size": "node scripts/check-size.mjs",
"verify": "node scripts/verify.mjs",
"e2e": "playwright test",
"e2e:dist": "pnpm build && playwright test --config playwright.preview.config.ts"
}
}
| 命令 | 守什么 | 跑在哪 |
|---|---|---|
pnpm verify |
类型、风格、单测、内容、判定审计、打包、产物体积 | CI 的 verify job;release 也跑 |
pnpm e2e |
真实浏览器里的可玩性(dev server) | CI 的 e2e job |
pnpm e2e:dist |
生产产物:懒加载、base: './'、构建期 CSP 注入 |
CI 的 preview job |
三条缺一不可,理由很具体:verify 里的单测跑在 Node 环境,看不到 DOM 与 Phaser;e2e 跑在 Vite dev server 上,不经过生产构建的 transformIndexHtml。历史上那次生产白屏就是”dev e2e 全绿 + 体积达标”同时成立的,详见第 20 章。
# 日常提交前
pnpm verify
# 改过交互、路由、存档、CSP、构建配置时,再补这两条
pnpm e2e
pnpm e2e:dist
18.2 单测:369 条 / 25 个文件的分布
vitest.config.ts 只有 14 行,但 include 的注释是整份配置里最重的一块:
export default defineConfig({
test: {
include: [
'packages/**/src/**/*.test.ts',
'games/**/src/**/*.test.ts',
// 外壳里也有纯逻辑(路由解析、seed、存档接线)。不纳入 include 的测试 =
// 静默不执行,比没写更糟:CI 会假绿。外壳测试必须是纯逻辑(node 环境,不碰 DOM)。
'apps/site/src/**/*.test.ts',
],
environment: 'node',
},
});
按源码统计(it() 静态计数 + content.test.ts 里那条按 20 关展开的循环),当前是 369 例 / 25 个文件:
| 区域 | 文件数 | 例数 | 代表文件与它守的东西 |
|---|---|---|---|
| 六个模板 | 6 | 144 | rule/logic.test.ts(31)、construct(28)、experiment(27)、pattern(25)、simulate(18)、argue(15) |
| 证据 | 3 | 41 | narrative.test.ts(25)、badges.test.ts(9)、report.test.ts(7) |
| 存档 | 2 | 27 | manager.test.ts(15)、save.test.ts(12) |
| 内容 | 1 | 34 | content.test.ts:15 个 it,其中一条按 SHIPPED_LEVEL_IDS 逐关生成 |
| 纯逻辑工具箱 | 3 | 43 | pattern/index.test.ts(24)、stats.test.ts(10)、rng.test.ts(9) |
| 工具 UI 逻辑 | 1 | 12 | metricsRows.test.ts:快照行解码 |
| 画布助手 | 2 | 20 | draw.test.ts(13)、theme.test.ts(7),都不需要 Phaser 运行时 |
| 平台层 | 1 | 13 | web.test.ts:无 IndexedDB 降级、乱序写入、证据裁剪并入聚合 |
| i18n | 1 | 12 | index.test.ts:中英 key 1:1、英文值零 CJK |
| 玩法包一致性 | 1 | 8 | forge.test.ts:manifest 与内容、子路径导出三方对账 |
| 外壳 | 3 | 14 | routePath.test.ts(7)、csp.test.ts(4)、seed.test.ts(3) |
| 文案英文覆盖 | 1 | 1 | i18n-coverage.test.ts:扫模板源码里的中文整句 |
这张表里最值得学的是三处”测试形态”的选择:
(1)失败类用内容里的负例驱动,不在测试里手写。 pattern/logic.test.ts 的做法是逐条跑内容里的负例,断言失败类名一致:
it('rejects negative samples with declared classes', () => {
for (const negative of detail.negativeSamples) {
const verdict = validateArtifact(script, base, negative.artifact);
expect(verdict.passed).toBe(false);
expect(verdict.failure?.class).toBe(negative.expectFailure);
}
});
这样内容一改,单测自动跟着内容走,不会出现”测试里写死一版负数、内容换了另一版”的脱节。
(2)纯逻辑与渲染分开测。 命中区的坐标数学在 packages/toolkit 与 packages/canvas-kit 的纯函数里测,真实点击留给 e2e。第 13 章讲过那条教训:createGame.ts 依赖 Phaser,所以画布相关单测只碰 theme.ts / draw.ts 这种”纯常量 + 纯助手”。
(3)存档迁移用”坏数据”测。 save.test.ts 直接喂一份字段全乱的存档,断言”坏字段只丢自己”:
const migrated = migrate(
{
schemaVersion: 1,
updatedAt: 'yesterday',
profile: { nickname: ' ', startBand: 'not-a-band', createdAt: 'x' },
levels: {
[LEVEL_ID]: {
completed: true,
completedVariants: ['base', '', 42, 'base'],
stamps: ['constraint', 'not-a-stamp', 'constraint'],
// ……
},
},
},
NOW,
);
expect(migrated.updatedAt).toBe(NOW);
存档测试不测”正常路径”——正常路径在 e2e 里;单测专门测”用户换了浏览器、旧数据被改坏、未来版本回空档”这类只在真实使用中出现的事。
模板文案的英文覆盖是另一个值得抄的模式:它不像普通断言那样写死期望值,而是扫描源码,把模板里的中文整句抽出来查英文兜底表:
// packages/templates/src/i18n-coverage.test.ts
for (const match of source.matchAll(/['"]([^'"\n]*[\u4e00-\u9fff][^'"\n]*)['"]/g)) {
const text = match[1] as string;
if (text.length < 4 || FRAGMENT.test(text) || EN[text]) continue;
missing.push(text + '(' + file.split(/[\\/]/).pop() + ')');
}
它只查整句:拼接片段(" / 守 "、" 条边 · 成本 ")首尾带空白或以标点开头,不是可查表的文案,先跳过。这条测试是”新的中文文案没给英文就红”的机器化实现,比人工校读可靠。
18.3 e2e:90 条 / 6 个文件,各守一层
playwright.config.ts 与 playwright.preview.config.ts 只有 baseURL 与 webServer 不同:一个起 pnpm dev(5192),一个起 pnpm preview(5193,读 dist)。两者都设 workers: 1、fullyParallel: false、trace: 'retain-on-failure',因为关卡共享 IndexedDB,并行会互相污染存档。
90 条的分布:
| 文件 | 条数 | 守什么 |
|---|---|---|
level.spec.ts |
7 | 首关:进入、autoplay 落盘、无工具被拒、真实点击 ≤6 次、画布点一格、断网可玩且零外网、刷新后完成状态仍在 |
m1.spec.ts |
52 | SHIPPED_LEVEL_IDS 逐关两条(autoplay、无工具被拒,19 关 ×2=38)+ 14 条真实点击 |
budget.spec.ts |
20 | 每关交互次数预算:≤ target + 1 |
ui-smoke.spec.ts |
8 | 8 个工具面板”关键动作后可见区必须变化” |
narrative.spec.ts |
1 | 完成两关后日志、徽章、报告可用;导出证据包再只读导入 |
i18n.spec.ts |
2 | 切英文并持久化;英文界面下证据与报告也是英文 |
三类用例的写法差异很大,正好对应三种不同的”守”:
autoplay 用例只验”这关能通”:
test('M1 autoplay:' + levelId, async ({ page }) => {
await page.goto('/#/play/' + levelId + '?autoplay=1&instant=1');
expect(await waitEvent(page, 'level:complete:' + levelId, 30_000)).toBe(true);
await expect.poll(async () => {
const save = await saveSnapshot(page);
return save?.levels?.[levelId]?.completed === true;
}, { timeout: 20_000 }).toBe(true);
});
它走的是运行时 autoplay(),和真实操作同一条 dispatch 路径,但绕开了 UI——所以它证明”判定与动作序列自洽”,不证明”玩家点得到”。这个边界后面 18.8 还要再说一次。
真实点击用例必须按设计坐标换算,不能点 DOM 中心——画布里的格子根本不是 DOM 元素,整个画布只有一个 <canvas>。shared.ts 的做法是按画布实际矩形把设计坐标换算成屏幕像素,再点格子中心:
// e2e/shared.ts
export async function clickCanvasCell(page: Page, index: number): Promise<void> {
const point = await designToScreen(page, { width: 900, height: 560 }, 70 + index * (56 + 8) + 56 / 2, 320 + 76 / 2);
await page.mouse.click(point.x, point.y);
}
交互预算用例把”最少几次点击”变成断言,防的是交互退化:
for (const budget of BUDGETS) {
test('交互预算:' + budget.id + ' ≤ ' + (budget.target + 1), async ({ page }) => {
await page.goto('/#/play/' + budget.id);
await startInteractionMeter(page);
await budget.run(page);
await page.getByTestId('level-submit').click();
await expect(page.getByTestId('completion-panel')).toBeVisible();
const used = await interactions(page);
expect(used, budget.id + ' 用了 ' + used + ' 次交互').toBeLessThanOrEqual(budget.target + 1);
});
}
计数不是在业务代码里埋点,而是在页面里挂捕获阶段的 click / change 监听——不侵入实现,也不会漏掉组件内部处理的点击。留 +1 余量是给音效、焦点等实现差异的。
UI 冒烟用例是专门补盲区的一条,ui-smoke.spec.ts 的注释把动机写清楚了:
/**
* UI 冒烟门禁(docs/12 §7-7 / P1-3 遗留项):
* **关键动作之后,可见区必须发生变化**。目的是拦住两类问题:
* 1. "死按钮"——点了屏幕完全不变(历史上 inference/metrics/queue 的"查看区间/指标"就是这样);
* 2. "运行完没反馈"——调完参数点运行,指标区还是旧值。
*/
实现方式很朴素:动作前后各取一次面板 innerText,断言两者不同。它不看截图,所以不会因为字体或渲染差异误报;但它只能证明”有变化”,不能证明”变化是对的”——后者仍靠真实点击用例与人工走查。
18.4 content:check:内容合规的唯一构建期闸门
scripts/content-check.ts 271 行,只做通用检查,不写死某关的解法数值(否则内容一改脚本就红)。它逐个已发布关卡做这些事:
| 组 | 检查 |
|---|---|
| 身份 | 详情 id 与目录 id 一致;摘要/详情的模板、学段、主线、标题、工具列表逐项比对 |
| 双语 | 六个英文字段齐全、零 CJK;英文文案不许出现全角标点 ,。;:!?()【】 |
| 模板 | levelScript 能被该模板的 parseScript 解析;requiredToolArtifacts 都有工具声明产出 |
| 解 | ≥2 条黄金解,id 唯一,逐条过 validateArtifact |
| 负例 | ≥3 条,id 唯一,必须不通过且失败类与声明一致 |
| 变体 | ≥2 个,id 唯一,patch 可被 applyVariant 解析;两个变体同轴时只警告 |
| 反馈 | 可达失败类 ⊆ feedback 的键;messageKey / hintKey / focus 都不能空 |
| 证据 | evidencePlan ≥2 阶段,id 唯一,require 与 descriptionKey 非空 |
| 人工 | manualAudit.reviewed === true 且 reviewedAt 是 ISO 日期 |
| 文案 | 禁词分两级:伤害性词(失败/惩罚/笨蛋/输了/淘汰/粗心/扣分/排名/倒数)→ 错误;建议替换词(必须/聪明/笨)→ 警告 |
几个细节能看出”门禁不是越严越好,而是要有依据”:
- 英文标点只拦全角,弯引号
“”与 en dash–是合法英文排版,不拦; - 中文句子直接当
messageKey时只记警告而不是错误——这是 i18n 迁移期的过渡写法,等收口后再升级; - 变体只要求”≥2 个且轴合法”,不要求覆盖两条不同的轴,因为
docs/02§7.5 本来就没这么要求,凭空加硬约束会让内容作者为难。
最后还有矩阵层检查:LEVEL_SUMMARIES 里的 id 必须在 SHIPPED_LEVEL_IDS 里、unlock.after 不能引用自己或不存在的关卡、已发布清单不能缺摘要。加 --require-all 时,LEVEL_IDS 与 SHIPPED_LEVEL_IDS 必须相等:
pnpm content:check --require-all
# 目标矩阵:20/20 已发布;其余 0 关在后续里程碑(已要求全量)。
CI 的 verify job 在跑完 pnpm verify 之后额外跑这条带开关的命令(verify 自己跑的是不带开关的版本),这样本地开发可以只关心已发布关,而主干必须 20/20。
18.5 audit:levels:反刷题审计的 A 项
内容体检回答”这关写得对不对”,反刷题审计回答”这关能不能被绕过”。scripts/audit-levels.ts 对每关跑 A1/A1b/A2/A3/A4/A6/A8 七项,加两个人工资格检查:
| 项 | 断言 | 为什么需要 |
|---|---|---|
| A1 | 无工具直接 submit 必须失败,且 missingEvidence 非空 |
防”不碰工具也能交” |
| A1b | 空产物 {} 走 validateArtifact 必须被结构反馈拒绝,且 missingEvidence 为空 |
防”把结构错误伪装成缺步骤”,这也是第 20 章里”门禁先于判定”那处缺陷的机器守卫 |
| A2 | actionsFor 至少含一个非选择动作(非 submit/run/reset) |
防”选择题换皮”;只有一种非选择动作时降级为警告 |
| A3 | ≥2 条有效解,且产物 JSON 互不相同 | 防”两条解其实是同一份答案” |
| A4 | 负例逐条触发声明的失败类;可达失败类 ⊆ feedback;≥3 类有负例 |
防”声明了失败类却没有玩家能遇到” |
| A6 | 变体 ≥2 且 patch 可解析;transferVariants 逐条用黄金解验证通过 |
防”迁移门恒真” |
| A8 | evidencePlan ≥2 阶段;autoplay() 真的走到 passed,检查点全满足,且产生过证据草稿 |
防”证据计划只写在 JSON 里” |
| A5/A10 | 只检查 manualAudit.reviewed 与日期合法 |
判断”这关是不是一道选择题”必须靠人,脚本只能要求签字 |
A2 和 A3 的判定逻辑值得抄,因为它们是”把抽象要求写成几行代码”的范例:
/** 纯"选择"动作:只有这些动作的关卡等于选择题换皮(A2 要拦的正是它)。 */
const CHOICE_ONLY = new Set(['submit', 'run', 'reset']);
const actions = verifier.actionsFor(detail, baseVariant, detail.solutions[0]?.artifact ?? {});
const nonSubmit = actions.filter((action: LevelAction) => !CHOICE_ONLY.has(action.type));
const kinds = new Set(nonSubmit.map((action: LevelAction) => action.type));
if (nonSubmit.length === 0) fail(id + ' A2:动作空间只有选择类动作(' + [...] + ')');
else if (kinds.size < 2) warn(id + ' A2:非选择动作只有 ' + [...kinds].join('、') + ' 一种,建议至少两条操作路径');
A8 是唯一”真的跑一遍运行时”的审计项,它不只看 validateArtifact,而是 await runtime.autoplay() 之后读快照:
const drafts: EvidenceDraft[] = [];
const runtime = runtimeFor(detail, (draft) => drafts.push(draft));
const auto = await runtime.autoplay();
const snapshot = runtime.snapshot();
if (!auto.ok) fail(id + ' A8:autoplay 返回失败(' + (auto.message ?? '?') + ')');
else if (!auto.verdict?.passed) fail(id + ' A8:autoplay 没通过(' + (auto.verdict?.failure?.class ?? 'missing-evidence') + ')');
else if (snapshot.phase !== 'passed') fail(id + ' A8:autoplay 通过但 phase=' + snapshot.phase);
这就是为什么 audit:levels 比单测”更接近真实”:它驱动的是同一个 LevelRuntime,只是操作来自 actionsFor 而不是鼠标。
18.6 pack:check 与体积门禁:只量真实存在的东西
pack:check 的注释先立规矩:
/**
* 工程/打包体检:只检查**仓库里真实存在的东西**。
* 原生壳(Tauri/Capacitor)尚未创建,所以这里刻意不检查 tauri.conf.json / capacitor.config.ts:
* 检查不存在的文件只会制造"永远红"或"永远假绿",两者都坏。壳落地后把检查项加进来即可。
*/
它检查:根脚本齐全(dev/build/typecheck/lint/test/content:check/audit:levels/pack:check/size/verify/e2e/e2e:dist)、pnpm-workspace.yaml 存在、站点入口 apps/site/index.html 引用 /src/main.ts 且无外链、Phaser 裁剪版与 MIT 许可就位、20 关摘要/详情的 id 与英文字段一致、每关有 manualAudit;原生壳目录不存在时给提示而非假装通过。
体积门禁用 check-size.mjs,预算是文件顶部的常量:
const BUDGETS = {
initialKb: 85, // 首屏 JS+CSS,gzip
initialRawKb: 260, // 首屏 JS+CSS,原始
chunkKb: 280, // 最大懒加载 chunk,gzip
chunkRawKb: 1100, // 最大懒加载 chunk,原始
distKb: 4000, // dist 总量
assetKb: 200, // 单个非代码资源
assetsKb: 600, // 非代码资源合计
};
它能区分四类对象,这是关键——不能只量 dist 总量:首屏引用的文件(从 index.html 的 src/href 反解)、内联脚本与样式、懒加载 chunk、非代码资源。2026-11-04 的实测:
| 项 | 实测 | 预算 |
|---|---|---|
| 首屏 JS+CSS(gzip) | 69.5 KB | 85 KB |
| 首屏 JS+CSS(原始) | 203.6 KB | 260 KB |
最大懒加载 chunk(vendor-phaser) |
220.3 KB gzip / 849.5 KB 原始 | 280 / 1100 KB |
| dist 总量 | 61 个文件 / 1518.9 KB | 4000 KB |
更难的是它先做产物结构体检,再量体积:
// ---- 产物结构体检(先做,避免"体积很小但整页白屏"的假绿)----
// 背景:CSP 注入曾用正则替换 </head>,命中 index.html 注释里的同名字面量,
// 结果 script 标签与被注入的 meta 一起进了注释,页面只有空 #app。
const htmlWithoutComments = html.replace(/<!--[\s\S]*?-->/g, '');
// 剥掉注释后必须仍有可执行的 module script 与 CSP meta
这一段是”从事故里长出来的门禁”的典型:体积门禁原本只量大小,白屏的产物体积反而更小,所以永远绿。加了结构检查之后,同类问题会在 pnpm size 这一步就暴露,而不用等 e2e:dist 的 90 条全部超时。
18.7 verify 的编排:并行七步,size 单独串行
scripts/verify.mjs 只有 56 行,核心是两段:
const STEPS = [
{ name: 'typecheck', command: 'pnpm typecheck' },
{ name: 'lint', command: 'pnpm lint' },
{ name: 'test', command: 'pnpm test' },
{ name: 'content:check', command: 'pnpm content:check' },
{ name: 'audit:levels', command: 'pnpm audit:levels' },
{ name: 'pack:check', command: 'pnpm pack:check' },
{ name: 'build', command: 'pnpm build' },
];
// ……
const results = await Promise.all(STEPS.map((step, index) => run(step.name, step.command, COLORS[index % COLORS.length])));
const failed = results.filter((result) => result.code !== 0);
if (failed.length > 0) { /* 报失败并退出 1 */ }
const size = await run('size', 'pnpm size', COLORS[0]);
if (size.code !== 0) { /* 体积门禁失败 */ }
为什么 size 不能并行:它读的是 apps/site/dist,而 build 正在写同一个目录。并行跑会读到上一轮产物,出现”体积很小但其实是陈的”假绿。这条教训在 docs/02 §14.1 里写着,代码里就是一行 await——先 Promise.all 等七步,再单独跑第八步。
输出也不是黑箱:每个子进程的 stdout/stderr 按行加 [步骤名] 前缀,颜色区分步骤。失败了能看到是哪一步、哪一行。
CI 侧(.github/workflows/ci.yml)把它拆成三个 job:
jobs:
verify:
runs-on: ubuntu-latest
timeout-minutes: 15
steps:
- run: pnpm install --frozen-lockfile
# typecheck → lint → 单测 → content:check → audit:levels → pack:check → build → size
- run: pnpm verify
- name: 内容矩阵必须 20/20
run: pnpm content:check --require-all
e2e:
needs: verify
# ……安装浏览器后 pnpm e2e
preview:
needs: verify
# ……构建后 pnpm e2e:dist
三个 job 的分工是刻意的:verify 15 分钟超时,管”代码与内容对不对”;e2e 45 分钟,管”真实浏览器能不能玩”;preview 30 分钟,管”生产产物能不能加载”。后两个 needs: verify,所以类型或内容出错时不会白跑一小时浏览器。失败时两个 job 都上传 playwright-report/ 与 test-results/,retention-days: 7。
18.8 门禁的盲区:全绿不等于没问题
这套体系是”从事故里长出来”的,所以它清楚自己漏什么。写代码前值得先读这段,避免把”绿”当成”对”:
| 盲区 | 为什么门禁发现不了 | 现在的兜底 |
|---|---|---|
autoplay 绕开 UI |
A8 走 runtime.autoplay(),动作来自 actionsFor,不经过任何控件 |
ui-smoke.spec.ts(关键动作后必须变化)+ budget.spec.ts(次数)+ 14 条真实点击 |
| 失败反馈”写了但玩家遇不到” | content:check 与 A4 只验”可达失败类 ⊆ feedback”(声明层),不验 UI 能不能显示 |
第 20 章的 Tier 0 改造把 submit 改成先结构校验;但没有自动断言”故意做错时显示的是内容里的那句话” |
| 没有视觉回归基线 | 不开像素比对,截图只在失败时保留 | 五视口人工走查(1280×800 / 1366×768 / 1024×768 / 834×1112 / 390×844) |
| 没有性能基准 | 只有构建期体积门禁;docs/02 里写的 bench-levels.mjs 不存在 |
无。发布说明不得引用”性能达标” |
| 没有 axe 可访问性扫描 | 未安装 axe-core;组件测试也没接 @vue/test-utils |
人工走查;键盘路径有 e2e 覆盖(成人门长按用 Space 替代) |
| 文档与实现会漂移 | 门禁不读 docs/ |
靠人工;已知例子:release.yml 的发布说明仍写”120 单测”,实际是 369 |
倒数第二条最容易被忽视:“测试没被 include”和”没写测试”一样危险。vitest.config.ts 的注释之所以强调”不纳入 include 的测试 = 静默不执行,比没写更糟”,是因为外壳那三个测试(routePath / seed / csp)曾经写过但压根没跑,CI 一路假绿。
同样值得记住的是交互简化那轮(docs/12)的教训:交互预算与 UI 冒烟这两条门禁是新加的,加之前 20 关的”27 击、死按钮、运行完没反馈”全部能过 verify。门禁的覆盖面本身也需要定期用”故意做坏一次”来检验——这是本章最想传达的方法论。
小结
质量体系可以压缩成一条链:
pnpm verify并行七步(typecheck / lint / test / content:check / audit:levels / pack:check / build),最后串行size,因为体积门禁必须读本轮产物;- 单测 369 例 / 25 文件,覆盖模板判定、工具箱纯逻辑、证据与存档、平台降级、i18n 一致性、外壳路由与 CSP;失败类用内容负例驱动,文案英文覆盖用源码扫描;
- e2e 90 条 / 6 文件,autoplay 守”能通”、真实点击守”点得到”、预算守”不多点”、UI 冒烟守”屏幕有变化”、narrative 守”跨页面数据链”、i18n 守”切语言不烂”;
content:check管内容自洽与双语,audit:levels管判定不可绕过,A1b 专门防”结构错误伪装成缺步骤”;pack:check只验真实存在的文件,check-size先做产物结构体检再量体积,量四类对象而不只是 dist 总量;- 盲区(UI 可达性、视觉回归、性能基准、a11y 扫描、文档漂移)已经列明,别把”绿”当成”对”。
下一章进入构建与发布:base: './' 如何让产物能放进任意子目录、CSP 为什么只能在构建期注入、release.yml 目前实际做到哪一步、以及哪些”三端打包”的说法目前还不能宣称。