第16章 - 存档-进度与报告导入导出
前几章的数据都在内存里流转:运行时快照、证据草稿、画像与报告。这一章处理它们落地的那一段——存档怎么迁移、证据库怎么裁剪、报告包怎么导出与只读导入,以及”清档”为什么有 API 却没有界面入口。
16.1 存档模型:版本号与字段级迁移
SaveData 的当前版本是 1(SAVE_SCHEMA_VERSION),结构在 packages/game-kit/src/save.ts:
export interface SaveData {
schemaVersion: number;
updatedAt: number;
profile: {
id: string;
nickname: string;
startBand: string;
createdAt: number;
};
levels: Record<LevelId, LevelProgress>;
tools: Record<ToolId, ToolUsage>;
badges: Partial<Record<BadgeId, BadgeAward>>;
journal: JournalEntry[];
}
读档入口是 packages/progress/src/save.ts 的 migrate(raw, now)。它不抛错,任何无法识别的输入都回空档:
// packages/progress/src/save.ts(节选)
/**
* 读档迁移:任何旧版本(含没有版本号的历史档)都只补字段、不清空;
* 只有 schemaVersion 大于当前版本(来自未来)才回空档。
* 永不抛错:无法识别的输入一律回空档。
*/
export function migrate(raw: unknown, now: number): SaveData {
const fallback = emptySave(now);
const parsed = parseRaw(raw);
if (!isRecord(parsed)) return fallback;
const rawVersion = isFiniteNumber(parsed.schemaVersion)
? Math.trunc(parsed.schemaVersion)
: null;
if (rawVersion !== null && rawVersion > SAVE_SCHEMA_VERSION) return fallback;
const rawLevels = isRecord(parsed.levels) ? parsed.levels : {};
const levels = {} as Record<LevelId, LevelProgress>;
for (const levelId of LEVEL_IDS) {
levels[levelId] = sanitizeLevelProgress(levelId, rawLevels[levelId]);
}
// ……tools / badges / journal / profile 同样逐个 sanitize
}
三条纪律都能从这段代码读出来:
- 没有版本号的历史档也能读:
rawVersion === null时按当前版本处理,逐字段补默认值; - 字段级 sanitize,坏字段不连累整份存档:
levels对LEVEL_IDS全量遍历,缺的补空进度、坏的按空进度处理; - 只有”来自未来”才整体回空档:
schemaVersion > 1说明是新版本写的档,旧代码读不懂,回空档比猜字段安全。
需要说明一处文档漂移:docs/02 里写的 MIGRATIONS“旧版本 → 下一版本”函数链当前没有实现。仓库里只有 v1,迁移入口就是 migrate() 的字段 sanitize。真正需要新增 v2 时,应该在 migrate 里按 rawVersion 分支,而不是先凭空造一个迁移数组。
sanitize 的具体口径举例(都在 save.ts 里):
| 字段 | 坏值处理 |
|---|---|
profile.nickname |
非字符串或空白回退”小工匠”;超长按码点截到 12 字 |
profile.startBand |
不在 BANDS 里回退 'k' |
levels[levelId].stamps |
只保留 STAMP_IDS 里的值,去重 |
levels[levelId].lastOutcome |
没有合法 at 时整个字段丢弃 |
tools[tool].uses |
非有限数按 0 |
badges[badgeId] |
没有合法 awardedAt 的授予记录丢弃 |
journal |
坏条目单条丢弃,按 at 排序后保留最后 120 条 |
profile.id |
强制写回 PROFILE_ID,不接受存档里的自报 id |
按码点截断(truncateChars 用 Array.from)是为了不把 emoji 劈成半个代理对;这类细节在 save.test.ts 里有对应用例。
16.2 IndexedDB:四个键与一份内存副本
Web 平台的存储层在 packages/platform/src/web.ts,键名是导出的常量:
// packages/platform/src/web.ts(节选)
export const MMF_DB_NAME = 'mmf';
export const MMF_KV_STORE = 'kv';
export const SAVE_KEY = 'save:local-default';
export const EVIDENCE_KEY = 'evidence:local-default';
export const AGGREGATE_KEY = 'evagg:local-default';
export const MAX_EVIDENCE_EVENTS = 3000;
const OPEN_TIMEOUT_MS = 3000;
四个键的分工:
| 键 | 内容 | 写入方 |
|---|---|---|
save:local-default |
整份 SaveData |
ProgressManager.persist() |
evidence:local-default |
{ version: 1, events: [] },append-only,最多 3000 条 |
EvidenceStore.append() |
evagg:local-default |
EvidenceAggregate(被裁掉事件的计数) |
EvidenceStore.append() 裁剪时 |
localStorage: mmf:pref:* |
设备级偏好(语言/静音/动效/提示) | Preferences |
为什么键名里带 local-default?因为从第一天就按 profileId 分键,v1.0 只创建一个档案,v1.1 做”换人”时不需要迁移数据。当前档案 id 是 packages/progress/src/save.ts 里的常量 PROFILE_ID = 'local-default',存档里的 profile.id 每次读档都会被强制写回这个值。
IndexedDB 之上有一层内存副本,这是最容易被忽略的设计:
// packages/platform/src/web.ts(节选)
/** kv 后端:IndexedDB 优先,读写失败或打不开时用内存副本兜底。 */
function createKvBackend(): KvBackend {
const memory = new Map<string, unknown>();
let dbPromise: Promise<IDBDatabase | null> | null = null;
function openDb(): Promise<IDBDatabase | null> {
dbPromise ??= new Promise<IDBDatabase | null>((resolve) => {
if (typeof indexedDB === 'undefined') { resolve(null); return; }
// 3 秒超时:开不起来就当没有 IndexedDB
// ……
});
return dbPromise;
}
return {
async read(key: string): Promise<unknown> {
// 内存副本永远比 IndexedDB 新:本会话写过就以此为准,避免写盘失败后读回旧值。
if (memory.has(key)) return memory.get(key);
// ……读 IndexedDB,任何异常返回 null
},
async write(key: string, value: unknown): Promise<void> {
memory.set(key, value);
const db = await openDb();
if (!db) return;
// ……事务写入,失败只吞错:内存副本已生效,本会话继续可用
},
// remove 同构
};
}
三个后果值得记住:
- 读优先走内存。同一会话里写过
save之后,后续读到的都是内存副本,不会出现”刚通关但读回旧档”; - IndexedDB 打不开不算失败。3 秒超时后
openDb()返回null,内存模式继续可用,界面提示”这次可能没存上”; kv.read()永不抛错。读失败返回null,再由migrate()回空档,坏档不会阻塞进入工坊。
16.3 证据裁剪与聚合:裁掉最旧的,必须并入聚合
证据库是 append-only 的,但物理上不可能无限增长。上限是 MAX_EVIDENCE_EVENTS = 3000,超出时裁掉最旧的事件:
// packages/platform/src/web.ts(节选)
append(events: EvidenceEvent[]): Promise<void> {
return enqueue(async () => {
const record = await readRecord();
const existing = record?.events ?? [];
// id 判重:失败重试不会把仍在记录里的同一事件写两遍。
const known = new Set(existing.map((event) => event.id));
// ……过滤坏事件与重复 id,得到 fresh
// 先按时间排好再裁剪,时钟抖动导致的乱序写入也不会把"最新"当"最旧"删掉。
const merged = sortByTime([...existing, ...fresh]);
const overflow = Math.max(0, merged.length - MAX_EVIDENCE_EVENTS);
const removed = overflow > 0 ? merged.slice(0, overflow) : [];
const kept = overflow > 0 ? merged.slice(-MAX_EVIDENCE_EVENTS) : merged;
await kv.write(EVIDENCE_KEY, { version: 1, events: kept } satisfies EvidenceRecord);
if (removed.length > 0) {
// 先写明细再写聚合:万一中途掉电,最坏是丢计数,而不是重复计数。
const aggregate = await readAggregate();
await kv.write(AGGREGATE_KEY, accumulateAggregate(aggregate, removed));
}
});
}
这段代码有四个不显然的决定:
- append 串行化(
enqueue)。并发 append 时,若两个事务同时”读全量 → 写回”,后写的会覆盖前一个的新增事件;串行队列保证不丢更新。 - 先排序再裁剪。事件理论上按时间递增写入,但时钟抖动或补写会破坏顺序;先
sortByTime才能保证裁掉的是最旧的。 - id 判重。外壳写失败时会把整批回灌队列重试,重试批次里可能包含已落盘的事件;按 id 判重避免重复。已被裁掉的 id 不再留存,也就无法判重——注释明确写了”这里无法也没有必要再判重”。
- 先写明细、再写聚合。掉电窗口里最坏是”明细已裁、计数未加”,少算几条;反过来则可能重复计数。
裁剪不能”清日志顺便清历史”。docs/10 的审查记录把这条列为真实缺陷:早期裁剪直接丢弃最旧事件,聚合恒为 0,报告与徽章的”历史”就此消失。现在的做法是把被裁事件的计数并入 EvidenceAggregate:
// packages/platform/src/web.ts(节选)
/**
* 把被裁掉的事件计数并入聚合(docs/02 §8.3、§8.4):
* 清理日志只影响能回看的明细,不影响历史计数,报告/徽章不会倒退。
* 平台包不能依赖 evidence(依赖图见 docs/02 §3.1),所以这里保留一份最小实现。
*/
function accumulateAggregate(
base: EvidenceAggregate,
events: readonly EvidenceEvent[],
): EvidenceAggregate {
const next: EvidenceAggregate = {
total: base.total,
byLevel: { ...base.byLevel },
byTool: { ...base.byTool },
byPhase: { ...base.byPhase },
byFailure: { ...base.byFailure },
lastAggregatedAt: base.lastAggregatedAt,
};
for (const event of events) {
next.total += 1;
// ……按关卡 / 工具 / 阶段 / 失败类累计,并更新 lastAggregatedAt
}
return next;
}
于是”能回看的明细”与”历史计数”分开:日志/轨迹读 evidence,报告/徽章读聚合(以及最近事件)。清 evidence 只影响前者,后者不会倒退。
16.4 写盘纪律:串行队列与”失败不打断反馈”
存档写入只有一条路径:ProgressManager.persist(next)。它把每次写入挂在一条 Promise 链上:
// packages/progress/src/manager.ts(节选)
/**
* 串行写盘:并发 completeLevel 时后一次写入一定排在前一次之后,磁盘不会回退到旧存档。
* 写失败只吞掉错误——调用方(关卡反馈)不该因为存储不可用而中断(docs/02 §8.3)。
*/
private persist(next: SaveData): Promise<void> {
const task = this.writeChain.then(() => this.store.save(next));
this.writeChain = task.then(
() => undefined,
() => undefined,
);
return task.catch(() => undefined);
}
两个行为:
- 落盘顺序 == 内存更新顺序。两个关卡几乎同时完成时,后一次写入一定排在前一次之后,磁盘不会回退到旧存档;
- 写失败不抛给调用方。关卡反馈不该因为存储不可用而中断;失败由调用方的
saveFailed标记与调试事件来暴露。
completeLevel() 还有两条边界:未知 levelId 直接返回快照、不落盘(老内容或写错的 id 不应该污染存档);返回值是深拷贝快照,调用方改它不会污染内部状态:
// packages/progress/src/manager.ts(节选)
async completeLevel(input: CompletionInput): Promise<SaveData> {
await this.init();
if (!input || typeof input !== 'object' || !isKnownLevelId(input.levelId)) {
// 未知关卡(老内容/写错的 id)不写存档:读档时它本来就会被 migrate 丢掉。
return this.snapshot();
}
// ……
}
读档本身是 single-flight:init() 用 this.initPromise ??= this.load() 保证并发调用只读一次盘。load() 结束后用 needsPersist(raw, next) 判断”迁移结果与原始读档是否不同”,不同才回写一次,避免每次读档都重写整份存档。
16.5 报告导出:toReportPack 的字段收敛
报告包是”给家长/教师带走”的文件,不含原始事件。导出函数是 toReportPack(report):
// packages/evidence/src/report.ts(节选)
/** 导出包:只保留可公开的短字段,工具 / 关卡 / 维度都收敛成字符串数组。 */
export function toReportPack(report: ReportModel): ReportPack {
const pack: ReportPack = {
version: 1,
generatedAt: report.generatedAt,
tools: report.tools.map((entry) => ({ tool: entry.tool, uses: entry.uses })),
levels: report.levels.map((row) => ({
id: row.levelId,
title: row.title,
completed: row.completed,
stamps: [...row.stamps],
})),
dimensions: report.dimensions.map((dimension) => ({
id: dimension.id,
band: dimension.band,
summary: dimension.summary,
})),
recommendations: report.recommendations.map((recommendation) => ({
title: recommendation.title,
reason: recommendation.reason,
})),
};
if (report.nickname) pack.nickname = report.nickname;
return pack;
}
字段收敛的三个效果:体积可控(没有事件明细,只有计数与短句,parseReportPack 也会把上限压在 512 KB 以内)、隐私可控(没有原始事件、没有自由文本、没有时间线)、跨设备可读(不依赖 SaveData 结构,导入方不需要读档逻辑)。
导出动作本身在 ReportView.vue:
// apps/site/src/views/ReportView.vue(节选)
function exportReport(): void {
if (!report.value) return;
const pack = toReportPack(report.value);
platform.files.download('mmf-report.json', 'application/json', JSON.stringify(pack, null, 2));
}
文件名固定为 mmf-report.json,e2e 直接断言 download.suggestedFilename()。platform.files.download() 用 Blob + URL.createObjectURL 加临时 <a download>,不经过网络;下载完成后 URL.revokeObjectURL() 释放。
16.6 只读导入:parseReportPack 的三道闸门
导入是唯一的外部数据入口,因此校验写得很保守。第一道闸门在文件选择器:
// apps/site/src/views/ReportView.vue(节选)
async function importReport(): Promise<void> {
importError.value = null;
try {
const text = await platform.files.pickText('application/json,.json', 512 * 1024);
if (!text) return;
const pack = parseReportPack(JSON.parse(text));
if (!pack) {
importError.value = '这个文件不是本机的证据包,或版本不符。';
return;
}
imported.value = pack;
} catch {
importError.value = '读取失败:请选择导出的 .json 证据包。';
}
}
pickText(accept, maxBytes) 在平台层就按 file.size > maxBytes 拒绝,超过 512 KB 根本不会读进内存。第二道闸门是 parseReportPack():
// packages/evidence/src/report.ts(节选)
/** 导入包大小上限(docs/02 §16.2:512 KB)。 */
const MAX_REPORT_PACK_BYTES = 512 * 1024;
/**
* 禁止 HTML 片段与脚本 URL:渲染层不 v-html,导入层也不接受(docs/02 §16.2)。
* 只匹配"像标签"或脚本协议的内容;数学里的 "a < b" 不会被误杀。
*/
const MARKUP_PATTERN = /<\s*\/?\s*[a-z][^<>]*>|<!--|javascript:|vbscript:|data:text\/html/i;
校验顺序(实现里就是这个顺序,不能调换):
- 形状:必须是对象、
version === 1、generatedAt是有限数字,否则返回null; - 体积:先
JSON.stringify(input).length > MAX_REPORT_PACK_BYTES整包拒绝(parseReportPack是公开纯函数,不能只依赖文件选择器那一层;try/catch兜住循环引用); - 逐字段清洗:数组
slice截断(tools ≤ 12、levels ≤ 40、dimensions ≤ 8、recommendations ≤ 5),文本按码点截断,数值truncate,band夹到 0–3; - 标记整包拒绝:
text()遇到MARKUP_PATTERN命中就置rejected = true,最后统一返回null。昵称的清洗特意放在拒绝判定之前,因为它同样会进只读视图。
正则的设计目标写在注释里:”只匹配像标签或脚本协议的内容;数学里的 a < b 不会被误杀。” 这是可验证的:report.test.ts 里既有合法包的解析用例,也有畸形包被拒的用例。
第三道闸门在渲染层:不 v-html。导入的 ReportPack 只渲染成一张只读卡片,字段是纯文本插值,且不合并进 SaveData。也就是说教师导入学生报告后,只能看,不能继续在该设备上玩学生的档案。
16.7 多档案与清档:schema 先行,UI 后置
存储键从第一天就带 profileId,但 v1.0 只有一个档案 local-default。这是有意的排序:schema 先行、UI 后置。如果等教室场景来了再改键,迁移成本会落在真实用户数据上;现在改,成本是零。
多档案在 v1.1 要做的事很清楚,代码里也能找到接口形状:
SaveData.profile.id已经存在,读档时被强制写回PROFILE_ID;- 键名拼法固定为
save:<profileId>/evidence:<profileId>/evagg:<profileId>; PROFILE_ID是packages/progress/src/save.ts里的单个常量,改成”从档案索引读”是一处改动。
清档接口已经实现:
// packages/progress/src/manager.ts(节选)
/** 清档:存档回空档、证据与聚合一并清空。 */
async clear(): Promise<SaveData> {
await this.init();
const at = this.clock();
const empty = emptySave(at);
this.current = empty;
await this.persist(empty);
await this.evidence.clear();
this.revisionBatches.clear();
return this.snapshot();
}
EvidenceStore.clear() 在平台侧同时删 EVIDENCE_KEY 与 AGGREGATE_KEY:
// packages/platform/src/web.ts(节选)
clear(): Promise<void> {
return enqueue(async () => {
await kv.remove(EVIDENCE_KEY);
await kv.remove(AGGREGATE_KEY);
});
}
外壳也包了一层 useProgress().clear(),并且在 Pinia 里刷新快照。但是 v1.0 没有任何界面入口调用它:全仓搜索”清除”没有命中,HomeView 的档案柜只有日志、贴纸册、报告三个入口。docs/01 里写的”清除本机数据需要长按 3 秒”目前只有 API,没有 UI;要落地时应复用 AdultGate 的长按模式,放在报告页或档案柜里,而不是随手放一个”清空”按钮。这一条在发布检查表里应算未完成项,不能写成已有功能。
16.8 持久化相关 e2e
存储链路的验收不靠人工点,靠三条既有用例(e2e/level.spec.ts):
| 用例 | 断言什么 |
|---|---|
| autoplay 通关并写入存档 | level:complete 事件 + 调试桥 save 里的完成状态 |
| 刷新后完成状态仍在 | 刷新页面后同一关仍显示已完成,证明 IndexedDB 写入生效 |
| 运行时不发外网请求,断网后工具仍可操作 | page.route('**', ...) 拦截并断言零外链;离线仍能操作 |
另外 e2e/narrative.spec.ts 覆盖了报告包的完整往返:解锁报告 → 导出 mmf-report.json → 用 filechooser 把刚下载的文件再导入 → 断言 report-imported 出现并包含”只读”。
刷新保留这条用例曾经抓到过真实问题:完成事件早于落盘时,e2e 双页用例会读到旧档。现在的顺序在 WorkshopView.completeNow() 里是固定的——先 await progress.completeLevel()、再 refreshBadges()、最后才发 level:complete 调试事件。调试桥发事件不是”通知一下”,而是”存档已就绪”的信号(完整链路见 第18章)。
小结
落地这一段的关键在于”不确定就保守”:
- 读档永不抛错:无法识别的输入回空档,字段级 sanitize 只丢坏字段,只有未来版本才整体回空档;
MIGRATIONS函数链目前不存在,改动前先读migrate(); - 存储键从第一天带
profileId,v1.0 单档案,v1.1 打开多档案时不需要迁移数据; - IndexedDB 之上有内存副本,读优先走内存,打不开就退内存模式,写失败不打断关卡反馈;
- 证据裁剪必须把被裁计数并入
EvidenceAggregate,先写明细再写聚合,否则报告与徽章会倒退; - 存档写盘串行化,保证落盘顺序等于内存更新顺序;
- 导出包只收敛可公开短字段,不含原始事件;导入过三道闸门(512 KB、结构校验、HTML 拒绝),且只读、不合并、不 v-html;
- 清档 API 已存在但没有界面入口,这是已知缺口,不是已交付功能。
至此教程的技术主线结束:从契约与运行时,到六个模板、六件工具、画布与双视图,再到证据、日志、报告与存档。接下来的四章转向工程侧——新关卡实战、测试与门禁、构建发布、以及踩坑记录。