# 私有资料阅读站：电脑关机以后，读者仍能阅读

资料在自己的电脑上整理，却希望受邀读者随时能检索、阅读和下载。这时有两个要求同时存在：编辑者要保留原件、人工修改与分发选择的控制权；读者访问又不能依赖编辑者的电脑在线。

这份实践采用本机编辑、云端交付的分工。本机 Python、SQLite 与文件存储拥有完整资料；只有明确选出的内容才进入一份固定的发布快照。Cloudflare Workers 提供阅读入口，D1 保存目录、阅读单元与当前版本，private R2 保存被选中的原件和预览。后来加入 Workers AI 与 Vectorize，帮助读者用自己的说法找回资料。

## 编辑端和阅读端怎样连接

| 组件 | 负责什么 | 为什么这样分 |
|---|---|---|
| 本机资料库 | 收录、编辑、保留来源、决定发布哪些条目 | 本机可见不自动意味着可以分发 |
| 发布工具 | 导出固定快照，写入并读回云端数据，最后切换当前版本 | 内容准备与读者可见分成两步 |
| Worker | 核验读者资格，检索并交付允许读取的内容 | 文件与接口共用读取规则 |
| D1 | 当前发布版本、目录、正文单元、成员与下架状态 | 精确状态可以被查询和核对 |
| private R2 | 原件、预览与其他被选中的对象 | 大文件无需跟着目录查询一起加载 |
| Workers AI / Vectorize | 生成查询向量，召回相近条目 | 找到候选以后，仍由阅读规则决定能否交付 |

Worker 通过 [binding / 资源绑定](../docs/glossary.md#binding--资源绑定)使用分配给它的数据库和对象存储。绑定提供程序访问资源的能力；读者是否有资格查看某条资料，由应用判断。[Workers bindings 官方说明](https://developers.cloudflare.com/workers/runtime-apis/bindings/)解释了这种资源访问方式。

一次阅读大致经过：

```text
读者请求 → 身份与资格检查 → D1 当前发布版本与下架检查
         → 目录或正文；需要文件时再读取 private R2
         → 交付前确认资格与可见性仍然有效
```

R2 没有开启 public bucket 入口。读者取得原件要经过 Worker，而不是拿到一个绕过权限的永久公开地址。R2 bucket 默认不公开，启用公开访问需要明确配置；这与 Worker 自己拥有 R2 binding 是两件事。[R2 public buckets 官方说明](https://developers.cloudflare.com/r2/buckets/public-buckets/)。

最初的阅读入口使用 Access。应用依赖 Access 身份时，验证 JWT 的签名、issuer、audience 与有效期；看见一个身份请求头不够。后来系统显式改用应用自己的成员 Session，让网页账号与 Remote MCP 共用成员资格。两种模式分别配置，没有让任意一种身份通过就算登录的混合入口。[Access JWT 验证文档](https://developers.cloudflare.com/cloudflare-one/access-controls/applications/http-apps/authorization-cookie/validating-json/)解释平台身份的验证方法；成员 Session 与模式切换是这份应用自己的实现。

## 新内容为什么不能边上传边出现

一份资料可能同时需要目录、正文、PDF 页图和原件。若上传了一半就把它交给读者，目录可能能搜到，打开却缺页；语义检索也可能还没收录新条目。

发布工具为此使用三个动作。这是应用的发布设计，不是 Cloudflare 自动替每个网站提供的流程：

1. **Stage：准备候选。** 写入一份固定内容快照，核对它需要的目录行、文件与向量。此时读者继续看旧版本。
2. **Read-back：读回确认。** 写入响应之后，重新读取需要交付的数据，核对身份、内容与数量；全部就绪，候选才标为可启用。
3. **Activate：切换版本。** 只在当前指针仍是预期旧值、下架状态仍符合条件时切到新快照。其他发布者先改了指针，就停止重新核对。

这里的「发布版本」是一次选定的内容快照，不是[任务例子中的执行代次](../docs/glossary.md#generation--执行代次)。以下是合成状态，说明读者什么时候能看见新内容：

| 时刻 | 当前版本 | 新候选 | 读者看到什么 |
|---|---|---|---|
| 开始准备 | G1 | G2 尚未齐全 | G1 |
| 文件完成，向量仍待确认 | G1 | G2 未就绪 | G1 |
| 全部读回一致 | G1 | G2 可启用 | G1 |
| 条件匹配，切换完成 | G2 | G2 已启用 | G2 |

2026-09-30 和 10-02 的实际发布曾在向量读回时遇到超时或服务错误。工具停止而没有切换当前版本；再次处理同一份固定快照时，先核对已经存在的向量，只补真正缺失的部分。10-02 在更长但有限的确认窗口内完成整批读回后，再切换版本。失败期间旧版本继续服务。

Vectorize 的 upsert 是异步操作，返回 mutation identifier 后还需要时间才能查询到；同 ID 的 upsert 会替换旧向量。因此「响应成功」不能直接当作「搜索已经能读到」，也不适合在读回失败时反复覆盖整批数据。[Vectorize API 官方说明](https://developers.cloudflare.com/vectorize/reference/client-api/)。恢复时保留原快照和目标身份，能把一次中断接着做完；重新生成另一批内容则是新的发布。

下架状态独立于发布快照。切回旧版本时，已下架条目仍受读取限制；版本回退本身不能撤销已经下载到读者设备的副本，也不能替代重新核对分发资格。

## 语义搜索只负责找到候选

在这份实践中，embedding 输入来自读者能看见的标题、简介和检索提示，没有为了检索额外发送全部原件正文或私人来源记录。使用的模型是 `@cf/baai/bge-m3`，输出 1024 维向量；模型输入与输出方式见 [Workers AI 模型页](https://developers.cloudflare.com/workers-ai/models/bge-m3/)。

查询先取得原词匹配，同时用 Vectorize 找相近向量，再合并已有条目关联。每个候选都回到 D1，检查它属于当前发布版本、身份相符、没有被下架，并符合筛选条件。文件读取继续经过成员授权。Vectorize 提供按距离找近邻及 namespace 限定范围的能力；把这些候选交回 D1 核验，是应用自己的阅读合同。[Vectorize 查询文档](https://developers.cloudflare.com/vectorize/best-practices/query-vectors/)。

这解决了「记得意思，忘了标题」的一部分问题，代价是候选有数量上限，窄筛选可能把已召回的条目继续排除。页面因此区分精确匹配与语义候选，不把一页相近结果说成所有相关资料，也不把「没找到」解释为资料一定不存在。

## 接上 Remote MCP 后，登录还只是第一步

后来阅读站增加只读 MCP 工具：搜索、描述条目、按版本与单元读取、查看关联、近期更新和本人收藏。工具复用同一份目录与下架规则，没有给模型一个直接访问数据库或管理资料的入口。

2026-09-22 曾出现一个具体失败：网页已经登录，但从外部客户端进入授权页仍返回 403。原因是应用只允许外站顶层导航到首页与登录页，遗漏了精确的授权入口。修复允许进入该页面，同时保留 consent POST 和资料 API 的同源要求；用户仍需明确授权客户端读取。

验收于是分为三个动作：网页登录证明用户身份；consent 证明用户把指定读取权限交给这个客户端；实际 tool call 才证明 token、工具和资料交付一起可用。MCP 的 HTTP 授权规范要求发现授权服务，并验证 token 确实签发给目标资源；网页 Cookie 并不能自动完成这条路径。[MCP 2025-11-25 授权规范](https://modelcontextprotocol.io/specification/2025-11-25/basic/authorization)。

## 当时实际观察到了什么

| 日期 | 实际观察范围 | 留下的限制 |
|---|---|---|
| 2026-09-16～18 | 正式入口登录、目录、代表性文档与原件读取；停止本机预览服务后仍能在云端阅读 | 浏览器下载触发与最终落盘核对不同；不是所有读者设备验收 |
| 2026-09-21 | 真正调用 Workers AI、Vectorize、D1；若干改述查询找回预期条目，正式浏览器显示结果和正文 | 证明代表性召回；没有建立全正文、全问题的检索评测 |
| 2026-09-22 | 从真实 Remote MCP 客户端完成授权，六个只读工具可调用，搜索到正文的路径成立 | 只覆盖该客户端；合法空结果不证明其他客户端兼容 |
| 2026-09-25～30 | 后续认证修改、部署读回与精确 HTTP 探测 | 后续部署没有全部重新完成六工具验收，也未覆盖所有成员的授权路径 |
| 2026-10-02 | 同一候选从中断继续，整批数据读回后切换；正式浏览器能看见新增条目 | 没有重验所有资料、成员、设备或失败路径 |

这些日期来自作者历史运行记录。本文在 2026-10-03 重构解释并查阅上面的公开文档，没有重新登录该系统或运行云端实验；官方机制与作者观察各自承担不同证据。

## 怎样在自己的环境验证

先用不含私人资料的三个小条目：一份短文、一张预览、一条书签。给它们稳定 ID，并明确一个条目暂不允许发布。需要云端执行时，先确定自己的资源、调用预算和清理范围；本仓库没有这份系统的可部署源码。

按用户能观察到的结果逐步检查：

1. 匿名请求无法取得目录、正文和原件；已获准读者能完整读取选中的样本。
2. 在文件或向量尚未齐全时停止发布，读者仍看旧版本；恢复后不出现半份新资料。
3. 在切换前让另一操作改变当前版本，旧预期值的切换应失败；读回真实指针后再决定下一步。
4. 让一个语义候选处于下架状态，搜索和原件读取都不能把它交付。保留一个明确的精确查询，比较原词与语义结果。
5. 关闭编辑端后，从另一设备重新请求目录与正文，确认阅读不依赖本机服务。
6. 若接入 MCP，分别完成登录、明确授权和 search → describe → read；再撤销资格，确认后续读取被拒绝。

若需要多人直接在线编辑、严格实时同步，或只想分享一份公开文件，固定快照发布可能增加不必要的步骤。也可以继续使用已有服务器；需要文件与目录分工时，先读[手册：保存内容，也保存它的来历](../guides/handbook.md#05--保存内容也保存它的来历)。想先观察中断为什么不能直接重放，运行[任务恢复离线例子](../examples/job-state/README.md)；它检查本地状态机制，不实现本篇资料发布系统。
