关于 data-export-and-restore
面向无服务器应用中可移植数据导出与恢复实现的开发技能。它区分可完整还原的备份与供人阅读的 CSV、PDF 导出,并规定校验、暂存、回滚和安全发布文件的做法。
主要功能
- 01区分备份与可读导出
- 02带版本封套及校验和验证
- 03暂存并整体恢复
- 04规范值与稳定 ID
- 05流式写入并通过重命名发布
- 06防止 CSV 公式注入
适用场景
- 01设计可恢复的应用备份格式
- 02恢复数据且不改动在线数据库
- 03导出适用于电子表格的安全 CSV
- 04测试对损坏或过时文件的导入处理
安装
复制下面的命令,在终端中运行。
npx skills add https:/ / github.com/ zakariaf/ flutter-skills --skill data-export-and-restore安装后在 Claude Code 中输入/data-export-and-restore确认已加载。
包含的文件
除 SKILL.md 外,安装时一起下载的参考资料和脚本。
examples/backup_envelope.dartcsv_writer.dartreferences/formats-and-encoding.mdrestore-and-merge-policy.md详细介绍
数据导出与恢复
在没有服务端的应用中,导出文件是用户数据唯一一份可以在手机损坏后留存的副本,而恢复则是用户唯一能自行调用的恢复途径。两者的风险方向相反:有损导出会悄然变成某人的备份,而未完成的恢复会毁掉它本该保护的数据库。本技能适用于可移植制品;设备上的 VACUUM INTO 快照和 WAL 规则属于 persistence-drift。
详细内容见 references/formats-and-encoding.md(CSV/JSON/PDF 机制)和 references/restore-and-merge-policy.md(验证流程、合并策略)。
不可妥协的规则
- 备份和导出是两种不同的功能,绝不可混为一谈。 备份精确、供机器读取、可往返还原,而且是还原操作唯一接受的输入。导出(供电子表格使用的 CSV、供人阅读的 PDF)是用于互操作的产物,设计上有损,必须拒绝将其用作还原源。原因:一旦 CSV 可以还原,一个原本为便于阅读而设计的格式就会变成有人赖以保存记录的文件。
- 每个备份都带有封套,且还原时会先读取封套。 封套包含
formatVersion、schemaVersion、appVersion、exportedAtUtc、针对负载的校验和,以及负载。还原时会验证校验和;如果schemaVersion大于应用的版本,就会用通俗的文字说明并拒绝还原;如果负载版本较旧,则通过与数据库相同的、只向前迁移的路径进行迁移(run-migration)。原因:没有版本信息的文件无法被拒绝,只能被误读。 - 还原必须全有或全无,绝不原地修改正在使用的数据库。 将数据导入全新的暂存数据库,验证所有内容,关闭每个句柄,然后通过原子重命名发布;保留之前的文件作为回滚文件,直到新文件成功打开。原因:部分还原比还原失败更糟——它会留下用户无法说明、你也无法复现的状态。
- 机器格式只承载规范值。 使用整数次要单位、SI 整数、ISO-8601 UTC 时刻、稳定 ID、未经翻译的枚举代码。在待解析的文件中,绝不使用本地化数字、格式化日期、货币符号或翻译后的标签。只在面向人的导出中进行本地化,而这些导出绝不重新导入。原因:
١٢٫٥、12.5和12,5表示相同数量,却会被解析成三种不同的结果。 - 身份由内容分配,绝不使用 rowid。 每条可导出的记录在创建时都会获得一个稳定 ID(UUID/ULID)。原因:重新导入同一文件必须具有幂等性——使用自增 ID 则会重复创建每条记录。
- 说明合并策略,并在执行前展示。 全量替换(先清除再导入)和按 ID 合并(按已存储的
updatedAtUtc采用最后写入者胜出)代表不同的承诺;确认提示必须指出即将执行哪种策略,以及哪些数据会丢失。原因:对一个即将失去一周数据的用户来说,“还原”听起来像是“把我的数据加回来”。 - 流式写入临时文件,再通过重命名发布。 通过
IOSink将产物写入应用自己的临时目录,并仅在最后一个字节写入并刷新后将其重命名到目标位置。原因:将 10 万行导出内容组装到一个String中会耗尽廉价手机的内存;半写入的文件绝不能被分享。 - 未经用户明确操作,任何数据都不得离开沙盒;所有操作都必须通过注入的 Gateway。 不自动上传、不后台同步、不做“贴心”的云端副本。分享面板和文件保存选择器必须置于
ShareGateway/FileExportGateway(service-boundary-and-native)之后,并在测试中使用替身。 - CSV 必须同时做好 RFC 4180 转义和公式注入防护。 对包含分隔符、引号、CR 或 LF 的字段加引号;字段内的引号需加倍;并中和开头的
=、+、-、@、制表符或 CR,避免电子表格执行文本内容而非将其作为文本显示。原因:未转义的单元格会成为接收方电子表格中的代码执行途径,也可能导致你自己的数据行丢失。 - 导出和还原必须返回带类型的结果,绝不在 UI 中抛出异常。 使用
Result<ExportArtifact, ExportFailure>/Result<RestoreReport, RestoreFailure>,并为每种拒绝原因提供独立的失败类型(校验和错误、版本不受支持、负载格式错误、空间不足、权限被拒)。原因:对阅读提示的人来说,没有原因说明的“还原失败”与数据丢失无法区分。 - 导出失败时不得留下任何产物。 在每条失败路径中都删除临时文件,绝不发布部分文件。原因:截断的文件看起来像备份,却会在数月后才暴露问题。
- 说明导出的性质。 除非你有意实现了加密,否则导出内容都是明文且未加密;UI 必须在分享时说明这一点,且声明必须与代码一致(声明措辞见
release-and-store-shipping)。
信封
/// The only shape restore accepts. Version fields come FIRST so a truncated or
/// foreign file is rejected by the header rather than by a mid-parse exception.
({
int formatVersion, // this envelope's own shape — bump independently of the DB
int schemaVersion, // the DB schema the payload was written from
String appVersion, // provenance for support, never a compatibility check
DateTime exportedAtUtc, // UTC, from the injected Clock
String payloadSha256, // checksum over the payload bytes, verified before parsing
Map<String, Object?> payload,
});
formatVersion 和 schemaVersion 是有意分开的:信封可以新增字段而无需数据库迁移,数据库也可以迁移而不改变文件结构。单一的“版本”数字会将二者混为一谈,让下次变更时陷入错误的二选一。完整且可运行的结构:examples/backup_envelope.dart。
恢复:验证、暂存、替换
Future<Result<RestoreReport, RestoreFailure>> restore(File picked) async {
// 1. Header and integrity BEFORE anything is parsed or opened.
final envelope = readEnvelope(picked);
switch (envelope) {
case Err(:final failure): return Err(failure); // malformed / bad checksum
case Ok(:final value) when value.schemaVersion > kSchemaVersion:
return const Err(RestoreFailure.newerThanApp()); // refuse, never guess
case Ok():
break;
}
// 2. Import into a STAGING database — the live one is never touched yet.
final staging = await openStagingDatabase();
final imported = await staging.transaction(() => _importAll(envelope.value.payload));
if (imported case Err(:final failure)) {
await staging.close();
await deleteStagingFiles(); // user's data still intact
return Err(failure);
}
// 3. Migrate the staged copy forward through the SAME path as the app database.
// 4. Close every handle, then publish by rename, keeping the old file until the
// new one opens cleanly. See references/restore-and-merge-policy.md.
return publishStagedDatabase(staging);
}
暂存文件、迁移路径和重命名顺序共同构成了完整的正确性保障——如果恢复时直接写入应用正在打开的数据库,就无法在失败时让用户回到原来的状态。
选择格式
| 需求 | 格式 | 可恢复? |
|---|---|---|
| 应用可以读回的完整备份 | 信封 + JSON 负载(或复制的数据库文件) | 可以 — 这是唯一可恢复的格式 |
| 用于电子表格或其他工具的数据行 | CSV、RFC 4180、规范值 | 不可以 |
| 供人阅读、打印或归档的内容 | PDF,完成本地化和格式化 | 不可以 |
面向用户的导出内容应像其他渲染内容一样在边缘进行本地化(value-objects-money-and-units、i18n-rtl-l10n);机器格式绝不进行本地化。只有通过 persistence-drift 中支持 WAL 安全的原语,才能直接复制正在使用的数据库文件作为有效备份——绝不能对打开的数据库使用 File.copy。
必须存在的测试
- 对抗性样例的往返测试。 导出 → 导入空数据库 → 再次导出 → 字节完全相同。样例包含撇号、引号、逗号、嵌入的换行符、表情符号、带双向文本标记的 RTL 文本、仅含空白的字符串、支持的最大数字、有夏令时歧义的本地时刻,以及一对 null 与空值。
'test1'能通过所有环节毫无证明意义。 - 重复导入的幂等性。 按 ID 合并时,同一文件导入两次应产生相同状态,而不是重复数据。
- 每种拒绝情况都要有测试: 文件被截断、校验和字节被翻转、
schemaVersion比应用高 1、必须迁移的旧版schemaVersion、空负载,以及完全不是这种格式的文件。每种情况都要断言类型化错误,并确认线上数据库字节未发生变化。 - 失败时不留产物。 强制触发中途写入失败;断言导出目录中不存在可发布的文件。
- CSV 转义。 含有
=cmd()的单元格、含有a,b"c\nd的单元格,以及 RTL 单元格,都应通过真实解析器完成写入→读取往返。
反模式
- 将 CSV 用作备份格式 — 没有信封、没有类型、没有版本控制;它会成为某人的唯一副本。
- 未带版本号的导出文件 — 第一次架构变更就会让所有现有文件无法读取,或者更糟,悄无声息地被错误解析。
- 直接恢复到线上数据库 — 没有回滚;中途导入失败会留下无人能解释的状态。
- 将自增 rowid 用作导出标识 — 重新导入时会重复所有数据。
- 在机器格式中格式化数字或日期 — 输出依赖区域设置,只能在生成它的机器上往返还原。
- 用
String/StringBuffer构建导出内容 — 100 行时没问题;在你支持的最廉价设备上,10 万行时会因 OOM 崩溃。 - 直接写入可共享路径 — 写入中途崩溃会发布被截断的文件,看起来却仍像一份备份。
- “为安全起见”自动上传或自动备份 — 这会把离线应用变成数据处理方,改变商店中的隐私声明,并使相关承诺不再成立。
- 信任用户选取文件的扩展名或名称 — 应验证信封,而不是路径。
- 显示通用的“导入失败”消息 — 用户无法分辨是文件错误还是数据丢失;应根据类型化错误说明原因。
- 在用户预期替换时悄悄合并(或反之) — 应在确认信息中说明策略,并使用与代码相同的措辞。
完成定义
- 备份和导出是独立路径;恢复会拒绝导出产物。
- 每份备份都包含
formatVersion、schemaVersion、appVersion、exportedAtUtc和负载校验和,并按此顺序置于头部。 - 恢复会验证校验和、拒绝较新的
schemaVersion,并通过与数据库相同的仅向前迁移路径迁移 较旧的负载。 - 恢复会将内容导入暂存数据库,再通过重命名发布;失败时,线上数据库的字节内容保持不变。
- 机器可读格式仅包含规范值和稳定的内容 ID。
- 合并与替换策略在代码中明确规定,并在确认界面中标明。
- 写入会流式写入临时文件,并通过原子重命名发布;失败时会删除临时文件,不发布任何内容。
- 分享/保存操作仅在用户明确操作时通过注入的 Gateway 执行;不会自动上传任何内容。
- CSV 输出遵循 RFC 4180 规则进行引用,并转义公式注入内容。
- 导出/恢复会返回类型化的
Result,每种拒绝原因对应一个失败结果。 - 在恶意测试样例上,往返、幂等重复导入、每种拒绝情况、无部分产物以及 CSV 转义测试均通过。
相关技能
persistence-drift— 支持 WAL 安全的设备端快照基础功能,以及导入器写入所经由的 DAO 层;切勿对正在使用的数据库调用File.copy。run-migration— 迁移较旧负载所经由的仅向前迁移路径。error-handling-typed-results—Result/Failure主干,以及此功能面向用户时所依赖的绝不丢失数据保证。value-objects-money-and-units— 规范化存储(最小货币单位、SI、UTC),让机器可读格式支持往返转换。i18n-rtl-l10n— 在渲染时本地化,以及本地化数字为何绝不能写入文件。service-boundary-and-native—ShareGateway/文件选择器边界及其伪实现。release-and-store-shipping— 导出功能不得破坏的隐私声明。testing-strategy— 恶意测试样例、属性/往返测试、注入的Clock。
参考资料
- RFC 4180 — CSV 文件的通用格式和 MIME 类型:https://www.rfc-editor.org/rfc/rfc4180
- Dart —
dart:ioIOSink、File.rename:https://api.dart.dev/stable/dart-io/File-class.html path_provider— 应用目录:https://pub.dev/packages/path_providershare_plus— 分享面板(包括sharePositionOrigin):https://pub.dev/packages/share_plusfile_selector— 保存/打开对话框:https://pub.dev/packages/file_selectorcrypto— 用于负载校验和的 SHA-256:https://pub.dev/packages/crypto- OWASP — CSV 注入:https://owasp.org/www-community/attacks/CSV_Injection