记忆转出容器、许可与失败处理
客户端已经编写记忆转出的许可桥接和本机打包代码。当前作为维护基线的 Account 主目录源码没有对应记忆转出模块与 /api/memory-export/redeem 路由,因此本页描述实现契约与本机文件格式,不代表当前跨服务转出已经可用。App 工作树中的历史 Account 副本不能代替权威 Account 的运行版本与真实验收。
转出包含什么
转出目标是当前安装的本机记忆列表及其原始附件。它不包含工作台任务、网页收藏、浏览器 Cookie、聊天历史、聊天 token、MLS 私钥或设备 vault 密钥。记忆中用户自己录入的敏感信息仍会作为记录原文进入文件。
本机记忆不按聊天账号分库,同一安装中不同账号看到同一库。Account 许可验证的是当前账号完成了额外验证,不能据此证明每条本机记忆属于该账号,也不是为该账号分发某份云备份。改 Account 密码、退出或切换账号不会删除或重加密记忆;设备密钥和账号凭据的责任见本地记忆。
外层 JSON 契约
| 字段 | 类型 | 语义 |
|---|---|---|
format |
字符串 | 固定 cn.thestarline.memory-export |
version |
整数 | 当前为 1,表示转出容器版本 |
entries |
数组 | 每条转出记录;不是按用户 ID 分组 |
entries[].record |
对象 | 该平台的完整记录数据 |
entries[].attachmentData |
对象 | 附件 ID 到原始字节标准 Base64 字符串的映射 |
下面是一个完整的合成 Android 容器,附件明文仅为六个 ASCII 字节 sample:
{
"format": "cn.thestarline.memory-export",
"version": 1,
"entries": [{
"record": {
"id": "11111111-1111-4111-8111-111111111111",
"title": "格式示例",
"body": "合成说明",
"kind": "general",
"createdAt": 978307200000,
"updatedAt": 978307200000,
"attachments": [{
"id": "22222222-2222-4222-8222-222222222222",
"name": "sample.txt",
"mimeType": "text/plain",
"size": 6
}]
},
"attachmentData": {
"22222222-2222-4222-8222-222222222222": "c2FtcGxl"
}
}]
}
容器没有创建时间、平台名称、账号归属、签名、文件摘要、加密算法或自动恢复指令字段。version=1 也不表示 iOS 与 Android 内层记录完全一样。JSON 对象键顺序不能用于判断相同内容;iOS 对逐条编码对象启用 sortedKeys,Android 保留原记录 JSON 字符串,不能依赖字节级排序一致。
平台内层差异
| 概念 | iOS record |
Android record |
|---|---|---|
| 正文 | text |
body |
| 自由记录类型 | kind=note |
kind=general |
| 模板字段 | fields 字符串字典 |
直接在记录顶层保存各字段 |
| 动态主题 | 可选 generatedSchema,含主题、图标及字段定义 |
固定模板,或 suggestedCategory 与通用字段 |
| 创建与修改时间 | Date 默认编码:2001-01-01 起算秒,可含小数 | Unix 毫秒 |
| 附件元数据 | id/name/storedName/byteCount/isImage |
id/name/mimeType/size |
| 历史未知键 | 按 Codable 已知模型编码 | 原 JSON 可保留未展示的历史键 |
表单日期如 followUpOn 仍是 yyyy-MM-dd 字符串,预约时间如 startsAt 为不带时区的本地日期时间字符串;它们不是 createdAt 的另一种写法。iOS 附件映射键使用 UUID.uuidString,应与该 record 的附件标识关联。Android 对同一记录的附件 ID 去重后写映射。记录级附件清单与 attachmentData 需要一起检查,不能仅看文件名判断附件是否完整。
打包读取的是通过本机密钥解开的原始附件,文件中的 Base64 不再保留原 vault 加密层。它不是客户端缓存图、缩略图或服务器媒体链接。两端格式细节和示例见本机数据格式。
许可桥接的请求步骤
- 用户从“设置/数据与隐私”发起;客户端要求已登录且记忆可读取,Android 还阻止空列表开始。
- 客户端携带聊天 Bearer token 请求
GET /memory-export/start。聊天服务要求身份桥接配置可用,创建绑定当前用户的 state 和服务端 verifier,本地 flow 有效五分钟。 - 响应含
url、state;URL 指向 Account 的/memory-export,参数包括state、code_challenge、client_id。客户端预期 Account 完成主验证与邮箱二次验证,随后返回starlineworkbench://memory-export的 state 与一次性 ticket。这部分需要在权威 Account 版本完成对接。 - 客户端将回调 state 与本机操作匹配,再调用
POST /memory-export/exchange,JSON 仅含state、ticket。 - 聊天服务消费匹配当前用户的 flow,以服务端机密客户端认证请求 Account
/api/memory-export/redeem,发送 state、ticket 和保存的 verifier,并检查返回用户与当前 Account subject 一致。 - 客户端只有收到
authorized=true才进入本机打包或保存位置选择阶段。记忆正文和附件不在这些许可请求中上传。
客户端没有 Account 机密客户端 secret,不能跳过兑换、将普通登录 ticket 改作转出票据或因网页显示成功而直接生成文件。聊天服务先消费本地 flow 再访问 redeem,后续失败不能假设同一 flow 还可重复使用,应重新开始许可流程。当前缺失 Account 对接意味着文件格式单测通过仍不足以完成第 3–6 步。
iOS:验证后生成,再交系统分享
iOS DataPrivacyView.exportMemories 等待 ChatStore.authorizeMemoryExport。系统授权会话返回后,代码核对 state、原 token 与用户,再向聊天服务兑换许可。成功后 MemoryExportDocument.write 创建带随机后缀的临时目录,将其排除系统备份,并创建 complete 文件保护的“星线记忆.json”。
写入先输出容器头,再逐条编码 record;每条先从 MemoryStore 解密其全部附件为 Data 字典,JSONEncoder 将 Data 编成 Base64。完成后同步文件,再交 UIActivityViewController。代码避免先把整个输出拼成一段巨大字符串,但单条的附件和编码结果仍占内存,不是常量内存的备份引擎。
验证取消或失败发生在写文件之前。系统分享页出现时,临时明文文件已经生成;取消分享只结束该次分享,不能描述为“从未生成过明文”。分享完成或页关闭时清理临时目录,下次启动也会清理残留的转出目录。清理使用尽力删除,不保证收件应用或用户已保存副本同时消失。
Android:授权、选位置、打包复制
Android 的操作阶段为 IDLE → STARTING → VERIFYING → EXCHANGING → READY → CHOOSING → SAVING → COMPLETE。状态对象绑定启动时的 token 和 userId,并用 operation 序号阻止旧操作继续。回调 state 不属于当前等待操作时忽略;账号切换、退出、离开转出页或主动取消都会使旧许可失效。
READY 后通过 CreateDocument(application/json) 请求用户选保存位置。没有选择目标 URI 就取消,不调用本机 writer。选择后捕获当前记录 JSON 与附件 ID,后台逐条写临时容器,再以 64 KiB 缓冲复制到目标 URI。写入记录、附件与复制循环之间都会检查当前协程、账号和许可仍有效。完成后才显示已保存数量并使本机许可失效。
Android writer 对缺失附件或无效 ID 抛错,防止静默输出缺附件的“成功”文件;失败或取消会删临时目录,并尽力通过 DocumentsContract 删除未完成目标文档。第三方文档提供者可能拒绝删除,因此清理是尝试,不是对任意云盘的无条件保证。成功后临时目录在 finally 中删除,启动也清理旧残留。
失败结果与排查
| 场景 | 当前含义与处理 |
|---|---|
| 未登录、账号资料未恢复 | 先完成正常聊天登录,不用本机伪造身份 |
| vault 损坏或缺密钥 | 原数据保护锁仍生效,转出不能绕过解密失败 |
| 开始接口不可用 | 检查桥接配置和当前 Account 对接,不能只重试分享页 |
| state、账号或 ticket 不匹配 | 当前操作无效,重新完整验证 |
| redeem 缺失、失败或超时 | 无有效许可;当前基线确有缺口,不标“已转出” |
| 某附件读取失败 | 整次 writer 失败并尝试清理,不跳过该附件冒充完整 |
| 目标位置不可写、空间不足 | 保留源记忆,报告保存失败,重新选择位置 |
| 进程在验证或写入时被终止 | 本轮没有断电/杀进程验收;不能假设自动续传或目标文件完整 |
正常路径也需检查 JSON 可解析、entries 数量、每条元数据和附件映射的关系,以及原始附件字节。检查使用合成资料,不把真实记录、票据、verifier 或输出全文写入公开报告。Base64 长度约为原始字节数的 4/3,另有 JSON 元数据开销;足够的原附件空间不保证目标空间也足够。
明文、换机与恢复边界
输出 JSON 可直接被能读到文件的程序解析,Base64 是编码而非加密。保存到外部文档提供者或发送给其他应用后,接收方的权限、同步、保留和删除策略由相应服务决定;App 清理临时文件不能撤回那些副本。源码没有为转出包设置密码、接收人密钥或自动过期机制。
当前没有把该文件导回 App 的通用入口,也没有自动云同步、自动恢复、冲突合并或跨端 schema 转换。因此“转出原始资料”不等于“换机后可在 App 恢复”。复制加密 vault 文件也不能代替迁移:文件还依赖设备安全存储中的密钥。卸载前应先确认需要保留的资料已安全取得,不能仅凭一份文件名或未完成的许可页判断备份可用。
源码依据与本轮范围
客户端实现位于 iOS Features/Profile/MemoryExportDocument.swift、ProfileView.swift、ChatStore.authorizeMemoryExport 和 Android MemoryExport.kt、MemoryExportDocument.kt;聊天侧为 src/server.mjs 的 memory-export 路由与 database.mjs 的五分钟 flow。Account 能力按当前维护主目录核对。本轮只审阅这些源码和文档,未调用许可 API、生成真实数据包、运行模拟器或执行多因素流程;后续验收见QA矩阵与协议操作示例。