【听见课堂 HarmonyOS NEXT 实战系列 19】本地课堂数据如何安全导出:JSON schema 与隐私字段控制
“导出数据”看起来像一行 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,旧报告不能替代现状
当前 RelationalClassroomRepository 的 SCHEMA_VERSION 已经是 2,任务表加入 due_at_ms,课程表加入 color_token;exportClassroomData() 同样写入版本 2。这让数据库结构与导出契约保持同一代语义。
项目较早的 P11 验收报告记录过 schema v1,那是当时运行快照,不应被改写成“当前仍是 v1”。文章与交付文档需要同时保留两类事实:
- 历史证据说明某次测试在什么版本上通过;
- 当前源码说明现在生成的导出载荷是什么版本;
- 如果没有重新跑导出测试,就不能把历史 v1 证据冒充为 v2 运行证明。
这种版本意识不仅用于数据库迁移,也用于技术文章的真实性管理。
四、exportedAt 应表示生成时刻,而不是课堂开始时间
Service 使用 new Date().toISOString() 生成 exportedAt。ISO 8601 字符串携带明确的 UTC 语义,适合日志比对、导入审计和跨时区处理。
课堂开始时间属于 course.startTime,字幕时间属于每条 TranscriptSegment.timestamp,任务截止时间属于 dueText 与 dueAtMillis。这些业务时间不能被一个导出时间替代。
导入或审计时至少会同时遇到四类时间:
- 课堂发生时间;
- 字幕或板书证据时间;
- 任务截止时间;
- 导出文件生成时间。
把它们混在一个字段中,会让“什么时候发生”和“什么时候复制”无法区分。
五、storageMode 是能力真相,不是装饰字段
项目启动时优先初始化 RelationalClassroomRepository;失败后切换到 InMemoryClassroomRepository,页面显示可理解的存储状态。导出载荷把这项状态写入 storageMode。
当值为 RelationalStore 时,数据来自本机持久化数据库;当值为“内存降级”时,导出只反映当前进程里的临时数据。两份 JSON 可能结构完全相同,但可恢复性和持久化来源不同。
这也是为什么导出时不能只看“有内容”。如果应用正处于内存降级,用户应知道重启可能丢失尚未持久化的数据,及时复制 JSON 只是临时自救,不代表数据库已经恢复正常。
六、课程、字幕、扫描和任务构成当前导出边界
ClassroomDataExport 聚合四类课堂事实:
| 数据域 | 典型字段 | 隐私与解释风险 |
|---|---|---|
| 课程 | 标题、教师、教室、开始时间、进度 | 可能暴露课程安排和身份线索 |
| 字幕 | 说话人、时间、正文、重点标记 | 可能包含完整课堂对话和敏感提问 |
| 扫描 | 标题、OCR 正文、来源、置信度 | 可能包含板书、试题、姓名或图片识别内容 |
| 任务 | 标题、截止时间、来源、确认和完成状态 | 可能暴露个人学习计划与执行情况 |
这些字段是当前产品闭环需要的结构化证据。系统权限、主题、字幕字号、相机授权状态和应用日志不属于该课堂数据导出对象;把所有 Preferences 和诊断日志一股脑塞进 JSON,反而会扩大泄露面。
七、rawAudioIncluded=false 必须和真实存储策略一致
当前导出对象固定写入 rawAudioIncluded=false。这不是“忘了导出音频”,而是与项目的隐私边界一致:实时听写链路不默认生成原始录音文件,导出也不伪造不存在的音频附件。
这个布尔字段有两层价值:
- 接收方不用通过“没找到 audio 字段”来猜测;
- 产品可以明确向用户说明导出不包含原始声音。
如果未来允许用户主动录音,不能简单把字段改为 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);
}
页面没有分别读取四个数组再拼对象。这样做可以保证其他入口未来复用同一导出契约,也能把字段裁剪、版本升级和隐私规则放在业务层,而不是散落到某个按钮回调中。

九、页面只负责忙碌态、剪贴板和用户反馈
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 明确显示降级,不包装成持久化备份 |
| 空课程 | 结构仍合法,空对象与空数组语义明确 |
| 不含音频 | rawAudioIncluded 为 false,载荷没有音频文件引用 |
| 剪贴板失败 | 页面退出忙碌态,课堂数据不变,不显示成功 |
| 字幕/OCR 长文本 | 中文、换行、引号和特殊字符可往返解析 |
| schema 升级 | 当前版本正确,旧版本证据和新实现不混写 |
| 隐私检查 | 不含令牌、签名、日志、内部路径和未授权媒体 |
项目已有 P11 运行证据证明当时 Pasteboard.setData() 成功并出现“不包含原始音频”提示;当前源码已经升级导出版本为 2,但本轮文章写作没有重新执行真机或模拟器导出,因此两种证据需要分开报告。
十六、总结与落地清单
安全导出的第一步不是加密算法,而是先定义清楚“导出什么、为什么导出、以什么版本解释、明确不包含什么”。听见课堂用 ClassroomDataExport、Service 聚合和 rawAudioIncluded=false 建立了最小可信契约,再由 P11 完成用户主动触发与剪贴板交付。
交付前可以检查:
-
schemaVersion与当前导出模型一致; -
exportedAt使用明确时区的标准时间; -
storageMode能区分持久化与内存降级; - 课程、字幕、扫描和任务边界清楚;
- 原始音频是否包含有显式布尔声明;
- 页面不拼接 Repository 数据;
- 重复点击受到忙碌态保护;
- 失败不会修改课堂数据或伪造成功提示;
- 导出字段采用白名单,不携带令牌、签名和日志;
- 剪贴板、文件、分享和备份使用准确术语;
- 导入按不可信输入校验并采用事务;
- 历史验收版本与当前源码版本分别记录;
- 未实现的文件加密、分享撤销和 JSON 导入不包装成现有能力。
下一篇将把视角从“把数据拿出来”转到“证明数据真的留得住、删得掉、恢复得回来”,分析删除、强停、换 PID、重启回读和可恢复基线如何组成一套破坏性持久化验收。
更多推荐


所有评论(0)