# 可选的原生同会话接收续传协议

> 本文保留最初的同会话协议基线。当前已新增协商式跨任务持久续传，完整增量契约、校验阶段、暂停和清理规则见[持久续传](NATIVE_DURABLE_RESUME_ZH.md)。下文“未实现跨重启”等边界仅指未协商持久能力的原模式。

本文描述当前实现的 LegnaSend 扩展，协议版本为 1。它**不是管理端 bearer 密钥 API**，不替换 LocalSend v2，也不是浏览器下载恢复或跨重启持久传输协议。

## 协商与适用范围

首先完成保持不变的 LocalSend v2 准备与接收审批握手，然后使用其中已批准的 `sessionId`、`fileId` 和**该文件自己的** `token`。不会要求原版 LocalSend 增加必填字段或接口。

发送端只考虑至少 **1 MiB（1,048,576 字节）**、初始位置为零的普通文件。探测能力前先打开来源并保存大小/修改时间快照，能力成功后才计算完整 SHA-256。小文件、字节流和不支持的来源描述符保持整文件发送。来源发生变化时失败，不静默切换成新内容。

接收端仅为明确批准使用普通持久缓存路径的文件发布能力。图库后处理、Android SAF/content URI、Android SD 卡提供器目标保持原始 v2 整文件传输。固定每块 1 MiB，当前最多 1,048,576 块，即最大 **1 TiB**。

只有**能力接口**返回 HTTP **404、405 或 501**，才回退到原始 `POST /api/localsend/v2/upload`，正文仍是完整原始文件字节。鉴权错误、超时、畸形能力、哈希错误及后续续传失败都不是该回退条件。成功能力必须包含 `version: 1`、`supported: true`、`blockSize: 1048576`；当前不以成功响应中的 `supported: false` 表示不支持。

## 身份校验与公共查询参数

基础路径：`/api/legnasend/v1/receive-resume/`。

所有请求携带经过 URL 编码的 `sessionId`、`fileId`、`token`。接收端还核对原始发送者的来源 IP（适用时包括 IPv6 scope）及 TLS 证书指纹。HTTP 模式没有证书身份，但仍核对已批准的来源和 token。HTTPS 使用与原生传输相同的证书固定、任务路由绑定客户端。管理端 bearer 密钥不赋予访问这些接口的权限。

`block`、`status`、`finish`、`abort` 还需携带 `open` 返回的 `resumeId`；`block` 另需十进制 `offset`。查询字段按操作白名单校验，重复或空字段、未知字段、超过 4096 字节的值、超过 16 KiB 的原始查询串均拒绝。token 和查询串属于敏感信息，不应写入诊断日志。

## 六个接口

| 方法及路径后缀 | 请求 | 成功响应 |
|---|---|---|
| `GET capabilities` | 公共查询参数，无正文 | 能力 JSON |
| `POST open` | 公共查询参数；JSON 包含 `size`、`sha256` | 回执 JSON |
| `PUT block` | 公共参数及 `resumeId`、`offset`；原始块字节；头 `X-LegnaSend-Block-Sha256` | 块提交后的回执 |
| `GET status` | 公共参数及 `resumeId` | 当前回执；操作仍占用时返回 409 |
| `POST finish` | 公共参数及 `resumeId`，无需正文 | 校验并发布成功后返回 `state: "complete"` 回执 |
| `POST abort` | 公共参数及 `resumeId`，无需正文 | `{"version":1,"state":"cancelling"}`；若已发布则返回完成回执 |

能力示例：

```json
{"version":1,"supported":true,"blockSize":1048576}
```

打开请求正文，实际调用必须将示意哈希替换为完整文件的真实 SHA-256：

```json
{"size":2097153,"sha256":"0000000000000000000000000000000000000000000000000000000000000000"}
```

`size` 必须等于 v2 已批准的文件大小；原 DTO 如有校验和也必须一致。即使原始准备请求没有校验和，这里仍要求完整 SHA-256。接受恰好 64 个十六进制字符并转成小写，拒绝未知 JSON 字段。打开正文限制为 1024 字节，正文读取期限为 10 秒。

回执示例：

```json
{
  "version":1,
  "resumeId":"11111111-1111-4111-8111-111111111111",
  "blockSize":1048576,
  "offset":1048576,
  "size":2097153,
  "sha256":"0000000000000000000000000000000000000000000000000000000000000000",
  "state":"receiving"
}
```

对外回执状态为 `ready`、`receiving`、`complete`。初始化期间返回 409；失败、取消或过期条目返回 410，而不是可继续使用的回执。同一已鉴权 session/file/size/hash 重复 `open` 会复用条目，不重复分配保存目标；内容身份不同返回 409。

## 区块与恢复规则

- 每次发送恰好 `min(1048576, size - offset)` 个字节，不使用 multipart 包装或聚合文件格式。偏移顺序推进，必须等于接收端已提交偏移；非最终偏移按区块边界对齐。
- 区块头中的 SHA-256 是本次原始块字节的哈希，不是整文件哈希。长度不足返回 400 并取消该条目；块哈希不符返回 422 并取消。正文超过预算返回 413，区块正文读取期限为 60 秒。
- 同一条目同时只允许一个操作持有；忙碌时查询或写入返回 409。不能将忙碌理解为检查点丢失，也不能因此创建新会话。
- 块或完成请求响应丢失时，先查 `status` 再发送。已经提交的块可以在 HTTP 连接断开后保留。偏移未变时可重试未确认块；偏移已推进则跳过已确认字节。盲目重发已提交偏移会收到 409。
- 当前发送端共用最多三次重连/忙碌退避，分别等待 1、2、4 秒，并检查取消。打开请求可幂等重试；区块/完成请求的传输错误或 409 通过 status 核对。每个续传 HTTP 请求超时为 30 秒，成功与错误响应正文预算均为 8192 字节。
- 发送端拒绝 resume ID、大小、哈希、块大小不匹配，非法对齐，检查点倒退，status 超过最近可能提交的块尾，普通块回执未落在本次块尾，以及 finish 非完成状态。上述错误不会转换成整文件回退。

## 发布、取消与生命周期

已经收到字节或 `offset == size` **不等于最终成功**。`finish` 校验完整缓存，向本次拥有的暂存文件导出原始字节，再以不覆盖已有目标的方式发布；发送最终成功回执前清理本次缓存和暂存。缓存是目标目录旁的 `.ls` 文件，不是交付文件格式，也不是新的原版传输格式。

HTTP 请求中断本身不会取消已批准文件。接收端在短暂恢复窗口中保留内存事务；工作线程自**初始化或最后一次成功提交区块起 60 秒**后过期，在操作之间检查。轮询 `status` 不延长期限。获取保存目标也有 60 秒期限。正在阻塞的操作系统发布调用不会在某个计时点被强行撤回。

发送端取得有效 resume ID 后，传输失败或取消会尽力调用该文件的 `abort`，预算为两秒；不会为此取消同会话其他文件。显式原始会话取消或监听停止会撤销对应续传条目。对已完成条目调用 abort 返回完成回执，不删除已交付文件。

成功事务最终处理后，完成回执保留身份与元数据约 60 秒，**不保留目标描述符**，供完成响应丢失后的核对使用；停服或会话撤销可提前移除。注册表最多 72 个条目，与普通缓存接收共享最多八个缓存工作线程，容量不足返回 429。

主要错误：400 请求或正文无效、403 身份/token 不符、404 能力不支持或操作未知、408 正文超时、409 忙碌/检查点/身份冲突、410 条目缺失/撤销/过期、413 正文超限、422 块哈希不符、429 容量不足、500 初始化/存储/发布失败。取消竞态中可能返回 410 而不是最终回执，应以实际结果核对，不能仅凭已发送字节推断文件已发布。

## 明确边界与证据

当前不支持应用或监听重启后重建原会话/resume ID。重新批准的新会话是新尝试；已有启动清理负责本应用拥有的非活动残留，`.ls` 记录持久存在不代表线上的跨重启恢复能力。SAF、图库和提供器目标续传未由此扩展实现。同一续传文件按块顺序上传，不是多线程并行上传。

实现文件：`packages/core/src/http/client/resumable_upload.rs`、`packages/core/src/http/server/receive_resume.rs`、`packages/core/src/http/server/common/receive_cache.rs`、`packages/localsend_isolates/lib/src/task/server/receive_resume_capability.dart`。


abort应答表示已请求取消，不表示与取消并发的磁盘发布已撤回；已经发布的文件保留实际成功结果，不删除成品。

## 来源结束控制扩展

这是可选的持久恢复扩展，不适用前文的共同会话查询参数规则，也不改变任何原版 LocalSend v2 必需字段或接口。

能力示例：

```json
{"version":1,"supported":true,"blockSize":1048576,"durable":{"version":1,"sourceEnd":{"version":1}}}
```

主动启用时，在持久 open 的 `recovery` 对象内增加 `"sourceEnd":1`；省略时旧请求形状和行为仍然有效。目标与记录核验完成后，回执增加：

```json
{"sourceEnd":{"version":1,"grantId":"GRANT_UUID","round":"ROUND_UUID","token":"BASE64URL_32_RANDOM_BYTES","expiresAtUnixMs":1800000000000}}
```

初始 `verifying` 回执可以省略该字段。支持此能力的发送端必须先收到授权并持久保存，再发送第一个文件块。当前私有宿主确认最多等待 30 秒且支持取消。恢复键不等于授权，明确协商为不支持与请求未响应也不同。

`POST /api/legnasend/v1/receive-resume/source-end` **不携带查询参数**，JSON 正文为：

```json
{"version":1,"requestId":"REQUEST_UUID","grantId":"GRANT_UUID","round":"ROUND_UUID","token":"BASE64URL_32_RANDOM_BYTES"}
```

正文上限 4096 字节，读取期限五秒，UUID 使用标准规范格式。未知字段、格式错误的密钥及查询参数均拒绝。TLS 沿用原固定证书客户端，接收端匹配登记的对方证书；关闭 TLS 时严格匹配原 IP。密钥不进入网址、日志或公共管理 API。

HTTP 200 返回的是**明确结果类型**，不是无条件成功：

```json
{"outcome":"cleared","receiptId":"RECEIPT_UUID","removedFiles":1,"unlinkedBytes":1049000}
```

结果包括 `cleared`、`publishedPreserved`、`active`、`publicationPending`、`retainedUnknown`、`unknownOrExpired`、`superseded`、`authorizationRequired`。只有 `cleared` 是清理回执。`publishedPreserved` 表示没有删除已发布目标；其它结果保留实际不确定性或保护状态，不得显示删除成功。回执身份可以为空，受保护或未知结果的删除计数为零。同一有效授权重复请求会重放原持久回执及计数，不重复统计删除。网络请求失败仍是结果未知，不冒充任何明确结果。

清理先获取真实非活跃记录锁，再核验轮次、缓存事务和文件身份。新附加会替代旧授权，旧授权不删除新附加记录，活跃或正在发布的操作不强制取消。已发布文件绝不作为删除目标。原记录到期时间为绝对期限，重试不会延长。控制请求最多占用四个实际工作线程，授权账本最多 256 条；额度不足不能绕锁或切换旧接口。
