“导出数据”看起来像一行 JSON.stringify(),真正落到课堂无障碍应用里却要回答更多问题:导出的对象属于哪个 schema 版本,时间是什么格式,当前使用 RelationalStore 还是内存降级,字幕、板书与任务是否完整,原始音频有没有被打包,以及用户复制、分享或保存后由谁继续承担保护责任。

听见课堂当前实现了一条克制的本地导出链路:ClassroomService 从 Repository 回读课程、字幕、扫描与任务,组装 ClassroomDataExport,明确写入 schemaVersion=2、ISO 导出时间、存储模式与 rawAudioIncluded=false;P11 设置页只负责触发动作,并把生成的 JSON 复制到系统剪贴板。它不是已经完成的加密备份或文件分享系统,但建立了可演进、可审计的数据契约。本文从真实模型和代码出发,拆解这条链路的边界。

课堂数据安全导出的结构与隐私边界

一、先区分“序列化”“导出”和“备份”

三个词经常被混用,但产品承诺完全不同。

动作 当前是否具备 主要结果 不能顺带声称什么
序列化 已实现 将领域对象转为 JSON 字符串 不代表数据已经离开应用
复制导出 已实现 将 JSON 写入系统剪贴板 不代表生成了文件、加密或可恢复备份
文件导出 未实现 由用户选择位置并写入文件 不能用剪贴板成功替代
备份恢复 未实现 可校验、可导入、可恢复到兼容版本 不能只凭 JSON 可读就宣布可恢复
外部分享 未实现 通过系统分享面板交给目标应用 需要额外的授权、撤销和风险提示

当前 P11 的成功文案是“课堂数据已导出为 JSON 并复制到剪贴板;不包含原始音频”。这句话准确描述了真实动作,没有把复制到剪贴板包装成文件备份。

二、导出对象本身就是一份数据契约

common-core 中的 ClassroomDataExport 不只是临时对象,它定义了导出载荷的稳定外壳:

export class ClassroomDataExport {
  schemaVersion: number;
  exportedAt: string;
  storageMode: string;
  course: CourseSummary;
  transcript: Array<TranscriptSegment>;
  scans: Array<ScanNote>;
  tasks: Array<TaskItem>;
  rawAudioIncluded: boolean;
}

这里把“描述信息”和“业务数据”放在同一份载荷中。接收方不用先猜版本、生成时间和来源模式,也不用扫描全部字段后才知道是否包含原始音频。

如果只导出四个业务数组,未来增加字段或改变任务时间结构时,旧数据会失去解释依据。稳定外壳是兼容、校验与迁移的起点。

三、当前源码使用 schemaVersion 2,旧报告不能替代现状

当前 RelationalClassroomRepositorySCHEMA_VERSION 已经是 2,任务表加入 due_at_ms,课程表加入 color_tokenexportClassroomData() 同样写入版本 2。这让数据库结构与导出契约保持同一代语义。

项目较早的 P11 验收报告记录过 schema v1,那是当时运行快照,不应被改写成“当前仍是 v1”。文章与交付文档需要同时保留两类事实:

  • 历史证据说明某次测试在什么版本上通过;
  • 当前源码说明现在生成的导出载荷是什么版本;
  • 如果没有重新跑导出测试,就不能把历史 v1 证据冒充为 v2 运行证明。

这种版本意识不仅用于数据库迁移,也用于技术文章的真实性管理。

四、exportedAt 应表示生成时刻,而不是课堂开始时间

Service 使用 new Date().toISOString() 生成 exportedAt。ISO 8601 字符串携带明确的 UTC 语义,适合日志比对、导入审计和跨时区处理。

课堂开始时间属于 course.startTime,字幕时间属于每条 TranscriptSegment.timestamp,任务截止时间属于 dueTextdueAtMillis。这些业务时间不能被一个导出时间替代。

导入或审计时至少会同时遇到四类时间:

  • 课堂发生时间;
  • 字幕或板书证据时间;
  • 任务截止时间;
  • 导出文件生成时间。

把它们混在一个字段中,会让“什么时候发生”和“什么时候复制”无法区分。

五、storageMode 是能力真相,不是装饰字段

项目启动时优先初始化 RelationalClassroomRepository;失败后切换到 InMemoryClassroomRepository,页面显示可理解的存储状态。导出载荷把这项状态写入 storageMode

当值为 RelationalStore 时,数据来自本机持久化数据库;当值为“内存降级”时,导出只反映当前进程里的临时数据。两份 JSON 可能结构完全相同,但可恢复性和持久化来源不同。

这也是为什么导出时不能只看“有内容”。如果应用正处于内存降级,用户应知道重启可能丢失尚未持久化的数据,及时复制 JSON 只是临时自救,不代表数据库已经恢复正常。

六、课程、字幕、扫描和任务构成当前导出边界

ClassroomDataExport 聚合四类课堂事实:

数据域 典型字段 隐私与解释风险
课程 标题、教师、教室、开始时间、进度 可能暴露课程安排和身份线索
字幕 说话人、时间、正文、重点标记 可能包含完整课堂对话和敏感提问
扫描 标题、OCR 正文、来源、置信度 可能包含板书、试题、姓名或图片识别内容
任务 标题、截止时间、来源、确认和完成状态 可能暴露个人学习计划与执行情况

这些字段是当前产品闭环需要的结构化证据。系统权限、主题、字幕字号、相机授权状态和应用日志不属于该课堂数据导出对象;把所有 Preferences 和诊断日志一股脑塞进 JSON,反而会扩大泄露面。

七、rawAudioIncluded=false 必须和真实存储策略一致

当前导出对象固定写入 rawAudioIncluded=false。这不是“忘了导出音频”,而是与项目的隐私边界一致:实时听写链路不默认生成原始录音文件,导出也不伪造不存在的音频附件。

这个布尔字段有两层价值:

  1. 接收方不用通过“没找到 audio 字段”来猜测;
  2. 产品可以明确向用户说明导出不包含原始声音。

如果未来允许用户主动录音,不能简单把字段改为 true。还要定义文件清单、媒体格式、时长、哈希、加密、删除策略、授权记录以及 JSON 与二进制附件之间的关联方式。

八、Service 负责回读和组装,页面不拼数据

真实导出逻辑集中在 ClassroomService

async exportClassroomData(): Promise<string> {
  const course = await this.repository.getTodayCourse();
  const transcript = await this.repository.getTranscript();
  const scans = await this.repository.getScanNotes();
  const tasks = await this.repository.getTasks();
  const payload = new ClassroomDataExport(
    2,
    new Date().toISOString(),
    this.storageMode,
    course,
    transcript,
    scans,
    tasks,
    false
  );
  return JSON.stringify(payload);
}

页面没有分别读取四个数组再拼对象。这样做可以保证其他入口未来复用同一导出契约,也能把字段裁剪、版本升级和隐私规则放在业务层,而不是散落到某个按钮回调中。

从 Repository 回读到剪贴板交付的导出链路

九、页面只负责忙碌态、剪贴板和用户反馈

P11 的 copyClassroomExport() 先用 isSettingsMutationBusy 阻止重复提交,再调用 Service,最后通过系统 Pasteboard 写入纯文本:

const exportText: string = await this.service.exportClassroomData();
const data: pasteboard.PasteData = pasteboard.createData(
  pasteboard.MIMETYPE_TEXT_PLAIN,
  exportText
);
await pasteboard.getSystemPasteboard().setData(data);

成功时提示“不包含原始音频”,失败时提示“课堂数据未变更”。页面关心的是交互生命周期,不知道四类数据如何查询,也不负责修改导出版本。

需要注意,系统剪贴板可能被用户粘贴到其他应用,也可能受到系统清理策略影响。当前代码没有实现自动过期、主动清空或剪贴板读取审计,因此文章只把它称为“复制导出”,不声称具备安全文件容器。

十、一个可读 JSON 仍需要机器可校验

简化后的载荷可以长这样:

{
  "schemaVersion": 2,
  "exportedAt": "2026-09-01T00:00:00.000Z",
  "storageMode": "RelationalStore",
  "course": { "id": "course-physics-01", "title": "课堂标题" },
  "transcript": [],
  "scans": [],
  "tasks": [],
  "rawAudioIncluded": false
}

这只是结构示例,不是项目真实课堂数据。正式导出需要进一步定义 JSON Schema:字段类型、必填项、枚举、字符串长度、未知字段策略和最大数组长度。否则“能被 JSON.parse() 解析”仍不等于“符合本应用的数据契约”。

十一、隐私字段控制要采用白名单而不是事后打码

最稳妥的导出方式是显式构造允许字段,而不是先 JSON.stringify() 整个页面或数据库对象,再用字符串替换删除敏感内容。

白名单策略可以分三层:

  • 必需:课程 ID、结构化证据、确认状态、schema 与生成时间;
  • 可选:教师、教室、OCR 来源等用户可选择字段;
  • 禁止:权限令牌、签名信息、调试日志、缓存路径、应用内部对象和未授权媒体。

对于字幕与 OCR 正文,“它属于业务数据”不等于“默认适合外发”。真实产品应在导出前展示影响范围,并允许用户按课程、日期或数据类型裁剪;当前项目尚未实现这层选择界面。

十二、文件选择、加密和分享属于下一层能力

如果把当前剪贴板导出升级为文件流程,推荐的产品链路是:

选择导出范围 → 展示隐私摘要 → 生成版本化 JSON
→ 用户选择保存位置 → 可选口令或设备侧加密
→ 写入完成与哈希校验 → 用户主动分享或保留

这里至少新增文件选择、写入权限边界、异常清理、重复文件名、空间不足、加密密钥和分享授权等问题。分享成功后,应用通常无法控制第三方如何继续复制,所以“撤销授权”只能作用于应用自身或受控云端链接,不能承诺收回已经复制出去的本地文件。

当前项目没有实现上述链路;把它写成演进方案,能帮助后续设计,而不会夸大当前能力。

十三、导入功能不能直接信任导出的 JSON

即使 JSON 是本应用生成的,重新导入时也要按不可信输入处理:

  • 先验证 schemaVersion 是否受支持;
  • 校验数组上限、字符串长度与必填字段;
  • 拒绝重复 ID 或定义明确的冲突策略;
  • 旧版本先迁移到当前模型,再进入 Repository;
  • 在事务中校验并写入,失败时不留下半套数据;
  • 导入前展示影响范围并提供可恢复基线。

当前工程只有导出,没有产品化导入。TASK-007 验收期间使用数据库备份恢复测试环境,是工程验收手段,不等同于用户可用的 JSON 导入能力。

十四、大数据量和失败路径不能等上线再想

当前数据量较小,Service 顺序读取四类数据并一次性 JSON.stringify() 足够简单。但长课堂、多课程和大量 OCR 图片元数据会带来内存峰值、剪贴板大小限制和界面等待时间。

后续可以考虑分页读取、流式写文件、导出进度、取消操作和后台限制。无论如何,失败时应满足两个后置条件:原课堂数据不被修改,未完成的目标文件或临时片段被清理。

当前实现已经通过 try/finally 恢复按钮忙碌态,并用错误文案说明数据未变更;文件残留与流式取消仍是未实现边界。

十五、怎样验证导出不是只看一条 Toast

有效验收至少覆盖以下矩阵:

场景 必查结果
正常 RelationalStore JSON 可解析,版本、时间、存储模式和四类数据正确
内存降级 storageMode 明确显示降级,不包装成持久化备份
空课程 结构仍合法,空对象与空数组语义明确
不含音频 rawAudioIncludedfalse,载荷没有音频文件引用
剪贴板失败 页面退出忙碌态,课堂数据不变,不显示成功
字幕/OCR 长文本 中文、换行、引号和特殊字符可往返解析
schema 升级 当前版本正确,旧版本证据和新实现不混写
隐私检查 不含令牌、签名、日志、内部路径和未授权媒体

项目已有 P11 运行证据证明当时 Pasteboard.setData() 成功并出现“不包含原始音频”提示;当前源码已经升级导出版本为 2,但本轮文章写作没有重新执行真机或模拟器导出,因此两种证据需要分开报告。

十六、总结与落地清单

安全导出的第一步不是加密算法,而是先定义清楚“导出什么、为什么导出、以什么版本解释、明确不包含什么”。听见课堂用 ClassroomDataExport、Service 聚合和 rawAudioIncluded=false 建立了最小可信契约,再由 P11 完成用户主动触发与剪贴板交付。

交付前可以检查:

  • schemaVersion 与当前导出模型一致;
  • exportedAt 使用明确时区的标准时间;
  • storageMode 能区分持久化与内存降级;
  • 课程、字幕、扫描和任务边界清楚;
  • 原始音频是否包含有显式布尔声明;
  • 页面不拼接 Repository 数据;
  • 重复点击受到忙碌态保护;
  • 失败不会修改课堂数据或伪造成功提示;
  • 导出字段采用白名单,不携带令牌、签名和日志;
  • 剪贴板、文件、分享和备份使用准确术语;
  • 导入按不可信输入校验并采用事务;
  • 历史验收版本与当前源码版本分别记录;
  • 未实现的文件加密、分享撤销和 JSON 导入不包装成现有能力。

下一篇将把视角从“把数据拿出来”转到“证明数据真的留得住、删得掉、恢复得回来”,分析删除、强停、换 PID、重启回读和可恢复基线如何组成一套破坏性持久化验收。

Logo

讨论HarmonyOS开发技术,专注于API与组件、DevEco Studio、测试、元服务和应用上架分发等。

更多推荐