# 记忆转出容器、许可与失败处理

客户端已经编写记忆转出的许可桥接和本机打包代码。当前作为维护基线的 Account 主目录源码没有对应记忆转出模块与 `/api/memory-export/redeem` 路由，因此本页描述实现契约与本机文件格式，不代表当前跨服务转出已经可用。App 工作树中的历史 Account 副本不能代替权威 Account 的运行版本与真实验收。

## 转出包含什么

转出目标是当前安装的本机记忆列表及其原始附件。它不包含工作台任务、网页收藏、浏览器 Cookie、聊天历史、聊天 token、MLS 私钥或设备 vault 密钥。记忆中用户自己录入的敏感信息仍会作为记录原文进入文件。

本机记忆不按聊天账号分库，同一安装中不同账号看到同一库。Account 许可验证的是当前账号完成了额外验证，不能据此证明每条本机记忆属于该账号，也不是为该账号分发某份云备份。改 Account 密码、退出或切换账号不会删除或重加密记忆；设备密钥和账号凭据的责任见[本地记忆](https://document.thestarline.cn/app/local-memory.md)。

## 外层 JSON 契约

| 字段 | 类型 | 语义 |
| --- | --- | --- |
| `format` | 字符串 | 固定 `cn.thestarline.memory-export` |
| `version` | 整数 | 当前为 `1`，表示转出容器版本 |
| `entries` | 数组 | 每条转出记录；不是按用户 ID 分组 |
| `entries[].record` | 对象 | 该平台的完整记录数据 |
| `entries[].attachmentData` | 对象 | 附件 ID 到原始字节标准 Base64 字符串的映射 |

下面是一个完整的合成 Android 容器，附件明文仅为六个 ASCII 字节 `sample`：

```json
{
  "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 加密层。它不是客户端缓存图、缩略图或服务器媒体链接。两端格式细节和示例见[本机数据格式](https://document.thestarline.cn/app/local-data-formats.md)。

## 许可桥接的请求步骤

1. 用户从“设置／数据与隐私”发起；客户端要求已登录且记忆可读取，Android 还阻止空列表开始。
2. 客户端携带聊天 Bearer token 请求 `GET /memory-export/start`。聊天服务要求身份桥接配置可用，创建绑定当前用户的 state 和服务端 verifier，本地 flow 有效五分钟。
3. 响应含 `url`、`state`；URL 指向 Account 的 `/memory-export`，参数包括 `state`、`code_challenge`、`client_id`。客户端预期 Account 完成主验证与邮箱二次验证，随后返回 `starlineworkbench://memory-export` 的 state 与一次性 ticket。这部分需要在权威 Account 版本完成对接。
4. 客户端将回调 state 与本机操作匹配，再调用 `POST /memory-export/exchange`，JSON 仅含 `state`、`ticket`。
5. 聊天服务消费匹配当前用户的 flow，以服务端机密客户端认证请求 Account `/api/memory-export/redeem`，发送 state、ticket 和保存的 verifier，并检查返回用户与当前 Account subject 一致。
6. 客户端只有收到 `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矩阵](https://document.thestarline.cn/app/qa-matrix.md)与[协议操作示例](https://document.thestarline.cn/app/protocol-recipes.md)。
