docs: translate Chinese document headings

This commit is contained in:
anonsaber 2026-10-01 03:21:57 +08:00
parent 1d4959a855
commit e4a0a4b7db
6 changed files with 176 additions and 176 deletions

View File

@ -15,7 +15,7 @@
--- ---
## 1. Project goals ## 1. 项目目标
一个**部署在 Docker + 外部托管 Redis** 的 JMAP 邮件通知服务: 一个**部署在 Docker + 外部托管 Redis** 的 JMAP 邮件通知服务:
@ -27,7 +27,7 @@
**不做**:多租户、账号体系、Web UI 之外的客户端、消息持久化、邮件双向同步。 **不做**:多租户、账号体系、Web UI 之外的客户端、消息持久化、邮件双向同步。
## 2. Technology selection (locked) ## 2. 技术选型(已锁定)
| 层 | 选型 | 版本 | | 层 | 选型 | 版本 |
|---|---|---| |---|---|---|
@ -45,7 +45,7 @@
| 前端 | 原生 HTML + 单文件 JS + 单文件 CSS,**无构建工具、无框架** | — | | 前端 | 原生 HTML + 单文件 JS + 单文件 CSS,**无构建工具、无框架** | — |
| 网关 | Cloudflare Worker(纯 JS,零依赖) | — | | 网关 | Cloudflare Worker(纯 JS,零依赖) | — |
## 3. Security boundary (cannot be violated) ## 3. 安全边界(不可违反)
23 条,编号 `SAF-*` / `REQ-*` / `C-*`,全部登记在 §8 注册表。 23 条,编号 `SAF-*` / `REQ-*` / `C-*`,全部登记在 §8 注册表。
@ -73,7 +73,7 @@
22. **AI 分析不持久化**:分析结果与授权状态之外的中间态不落盘。 22. **AI 分析不持久化**:分析结果与授权状态之外的中间态不落盘。
23. **JMAP capability 动态发现**:禁止硬编码 JMAP 能力列表,必须运行时读取。 23. **JMAP capability 动态发现**:禁止硬编码 JMAP 能力列表,必须运行时读取。
## 4. Implementation phase (historical sequence, used to explain code structure) ## 4. 实施阶段(历史顺序,用于解释代码结构)
| 阶段 | 内容 | 稳定 ID | | 阶段 | 内容 | 稳定 ID |
|---|---|---| |---|---|---|
@ -86,7 +86,7 @@
**阶段编号是历史顺序,不是当前状态声明。** 当前状态以 `reference.md` 与代码为准。 **阶段编号是历史顺序,不是当前状态声明。** 当前状态以 `reference.md` 与代码为准。
## 5. Prohibited matters ## 5. 禁止事项
1. **不得引入未在本文件 §2 登记的依赖。** 新增依赖须先在本文件登记并说明理由。 1. **不得引入未在本文件 §2 登记的依赖。** 新增依赖须先在本文件登记并说明理由。
2. **不得为通过门禁而删除测试、跳过校验、放宽检查级别、删除访问控制。** 2. **不得为通过门禁而删除测试、跳过校验、放宽检查级别、删除访问控制。**
@ -99,15 +99,15 @@
9. **不得在 `web/`、`cloudflare-worker/` 之外引入前端资源**。 9. **不得在 `web/`、`cloudflare-worker/` 之外引入前端资源**。
10. **不得让业务配置静默回退到环境变量。** 10. **不得让业务配置静默回退到环境变量。**
## 6. Test acceptance and access control ## 6. 测试验收与访问控制
### 6.1 Change verification closed loop ### 6.1 变更验证闭环
任何涉及运行时行为或可核对事实的改动,**同一改动内**完成:实现 → 测试 → 文档。 任何涉及运行时行为或可核对事实的改动,**同一改动内**完成:实现 → 测试 → 文档。
不得只改代码不改文档,也不得只改文档不改代码。 不得只改代码不改文档,也不得只改文档不改代码。
### 6.2 GATE-P0 (code access control) ### 6.2 GATE-P0(代码门禁)
Debian 最小发行版容器内执行,容器内必须显式 `export PATH=/usr/local/cargo/bin:$PATH`: Debian 最小发行版容器内执行,容器内必须显式 `export PATH=/usr/local/cargo/bin:$PATH`:
@ -122,7 +122,7 @@ cd /home/okabe/Repo/messageweave && docker run --rm --user 1000:1000 \
**注意**:`docker run` 的 bash `-lc` 脚本必须用**单引号**包裹。用双引号会先在宿主机展开 `$PWD` / `$PATH`,容器内找不到 cargo。 **注意**:`docker run` 的 bash `-lc` 脚本必须用**单引号**包裹。用双引号会先在宿主机展开 `$PWD` / `$PATH`,容器内找不到 cargo。
### 6.3 GATE-DOCS (Document Access Control) ### 6.3 GATE-DOCS(文档门禁)
```bash ```bash
cd /home/okabe/Repo/messageweave && bash scripts/docs_check/run_all.sh cd /home/okabe/Repo/messageweave && bash scripts/docs_check/run_all.sh
@ -143,19 +143,19 @@ cd /home/okabe/Repo/messageweave && bash scripts/docs_check/run_all.sh
**`0 error` 只证明「可达」**:行号存在、引用可解析、表格列数一致、标记就位。 **`0 error` 只证明「可达」**:行号存在、引用可解析、表格列数一致、标记就位。
**它不证明语义正确。** 报告校验结果时必须说明这个区分。 **它不证明语义正确。** 报告校验结果时必须说明这个区分。
### 6.4 Front-end testing ### 6.4 前端测试
```bash ```bash
cd /home/okabe/Repo/messageweave/web && node --test *.test.mjs cd /home/okabe/Repo/messageweave/web && node --test *.test.mjs
cd /home/okabe/Repo/messageweave/cloudflare-worker && node --test test/*.test.js cd /home/okabe/Repo/messageweave/cloudflare-worker && node --test test/*.test.js
``` ```
### 6.5 Invariants must have assertions ### 6.5 不变量必须有断言
§3 的每条安全不变量都要有一条测试或代码级断言能证明它成立, §3 的每条安全不变量都要有一条测试或代码级断言能证明它成立,
而不是只写在文档里。纯重构不得改变测试总数。 而不是只写在文档里。纯重构不得改变测试总数。
### 6.6 External dependency testing ### 6.6 外部依赖测试
Stalwart 与 Telegram 的业务凭据已在真机联调通过——出站、查询与**入站回调**两个方向 Stalwart 与 Telegram 的业务凭据已在真机联调通过——出站、查询与**入站回调**两个方向
均已验证(`setWebhook` 与 `PushSubscription` 的注册、回调、验证往返全部走通),托管 Redis 均已验证(`setWebhook` 与 `PushSubscription` 的注册、回调、验证往返全部走通),托管 Redis
@ -163,7 +163,7 @@ Stalwart 与 Telegram 的业务凭据已在真机联调通过——出站、查
**环境阻塞项:无。** 最后两项也已验证:TTL 数值在真实 Upstash 实例上实测(`SET … NX EX` / `SET … EX 3600` / Lua `EXPIRE 86400` 三处原子写入点,连跑 6 轮全绿),`Email/changes` 的 `newState` 续传在真实 Stalwart 环境触发过(见 docs/design.md §10.5)。[`opengaps.md`](opengaps.zh-CN.md)「阻塞」区当前为空。 **环境阻塞项:无。** 最后两项也已验证:TTL 数值在真实 Upstash 实例上实测(`SET … NX EX` / `SET … EX 3600` / Lua `EXPIRE 86400` 三处原子写入点,连跑 6 轮全绿),`Email/changes` 的 `newState` 续传在真实 Stalwart 环境触发过(见 docs/design.md §10.5)。[`opengaps.md`](opengaps.zh-CN.md)「阻塞」区当前为空。
## 7. Document boundaries ## 7. 文档边界
每个文档只回答一个问题;**不得跨文档复制表格、清单或数值。** 每个文档只回答一个问题;**不得跨文档复制表格、清单或数值。**
@ -188,7 +188,7 @@ Stalwart 与 Telegram 的业务凭据已在真机联调通过——出站、查
- **改公共 API / Redis 键与 TTL / 配置项 / 错误码 / 用户可见文案时,同轮同步全部相关文档。** - **改公共 API / Redis 键与 TTL / 配置项 / 错误码 / 用户可见文案时,同轮同步全部相关文档。**
- **新增文档必须加入 `scripts/docs_check/` 各校验器的文档清单**,否则它永远不会被校验。 - **新增文档必须加入 `scripts/docs_check/` 各校验器的文档清单**,否则它永远不会被校验。
## 8. Stable ID registry ## 8. 稳定 ID 注册表
**唯一权威索引表。** 跨文档引用一律用稳定 ID(不用 `§x.y` 章节号)。 **唯一权威索引表。** 跨文档引用一律用稳定 ID(不用 `§x.y` 章节号)。

View File

@ -6,9 +6,9 @@
选择任何可以运行后端容器的平台,提供加密环境 选择任何可以运行后端容器的平台,提供加密环境
密钥,并连接到外部管理的 Redis 服务。可选的 Cloudflare Worker 密钥,并连接到外部管理的 Redis 服务。可选的 Cloudflare Worker
gateway 在一个或多个后端前面添加一个稳定的 URL。特定于平台的设置是 gateway 在一个或多个后端前面添加一个稳定的 URL。特定于平台的设置是
在[部署平台参考](reference.zh-CN.md#9-deployment-platform-details)中。 在[部署平台参考](reference.zh-CN.md#9-部署平台细节)中。
## 0. Before deployment ## 0. 部署前准备
准备启用持久性和公共 HTTPS URL 的外部管理的 Redis 服务 准备启用持久性和公共 HTTPS URL 的外部管理的 Redis 服务
到达后端。平台或代理终止 TLS; MessageWeave 监听其中一个 到达后端。平台或代理终止 TLS; MessageWeave 监听其中一个
@ -21,9 +21,9 @@ HTTP 端口(“PORT”,默认“8080”)。该过程需要两个加密的
生产环境没有本地数据库、数据量或内部调度程序。完整的安全边界 生产环境没有本地数据库、数据量或内部调度程序。完整的安全边界
禁止模式位于 [`charter.md`](charter.zh-CN.md) 中。 禁止模式位于 [`charter.md`](charter.zh-CN.md) 中。
## 1. Deploy a backend ## 1. 部署后端
### Container platform ### 容器平台
构建根 Dockerfile 并在平台中配置所需的机密。对于本地人来说 构建根 Dockerfile 并在平台中配置所需的机密。对于本地人来说
容器冒烟,将 `.env.example` 复制到未跟踪的 `.env`,替换两个占位符, 容器冒烟,将 `.env.example` 复制到未跟踪的 `.env`,替换两个占位符,
@ -36,9 +36,9 @@ docker run --rm --env-file .env -p 8080:8080 messageweave:latest
该镜像使用 Rust 构建器和 Debian slim 运行时,以非 root 用户身份运行,并公开 该镜像使用 Rust 构建器和 Debian slim 运行时,以非 root 用户身份运行,并公开
仅端口“8080”。不要安装数据或日志卷。完整图像内容位于 仅端口“8080”。不要安装数据或日志卷。完整图像内容位于
[平台参考](reference.zh-CN.md#91-backend-deployment-paths)。 [平台参考](reference.zh-CN.md#91-后端部署方式)。
## 2. First-time configuration ## 2. 首次配置
在原点打开后端的配置页面进行初始设置。使用 在原点打开后端的配置页面进行初始设置。使用
`CONFIG_ENCRYPTION_KEY` 建立管理会话,然后完成一次性引导 `CONFIG_ENCRYPTION_KEY` 建立管理会话,然后完成一次性引导
@ -50,19 +50,19 @@ docker run --rm --env-file .env -p 8080:8080 messageweave:latest
JMAP HTTPS 会话 URL 和应用程序密码、Webhook 机密和工作人员令牌。设置一个 JMAP HTTPS 会话 URL 和应用程序密码、Webhook 机密和工作人员令牌。设置一个
如果使用计划的协调,则协调令牌。仅当用户 如果使用计划的协调,则协调令牌。仅当用户
已明确同意外部处理。秘密值不会由 已明确同意外部处理。秘密值不会由
配置API;完整的字段语义位于[配置参考](reference.zh-CN.md#5-environment-variables)中。 配置API;完整的字段语义位于[配置参考](reference.zh-CN.md#5-环境变量)中。
### 2.1 Optional remote diagnostics ### 2.1 可选的远程诊断
在生产中保留“DEBUG_ENABLED”和“DEBUG_TOKEN”未设置,除非远程诊断 在生产中保留“DEBUG_ENABLED”和“DEBUG_TOKEN”未设置,除非远程诊断
刻意需要的。两者都必须配置为挂载诊断路由,并且这些路由 刻意需要的。两者都必须配置为挂载诊断路由,并且这些路由
只能通过后端源访问。诊断表面和响应详细信息 只能通过后端源访问。诊断表面和响应详细信息
记录在[参考](reference.zh-CN.md#3-backend-routes)中。 记录在[参考](reference.zh-CN.md#3-后端路由)中。
## 3. Build and run the Docker image ## 3. 构建并运行 Docker 镜像
根 Dockerfile 是容器部署路径。它有独立的 Rust 构建器和 根 Dockerfile 是容器部署路径。它有独立的 Rust 构建器和
Debian slim 运行时阶段;有关运行时内容,请参阅 [§8.2](#82-docker-image-details)。 Debian slim 运行时阶段;有关运行时内容,请参阅 [§8.2](#82-docker-镜像细节)。
## 4.1 注册 Telegram 和 Stalwart 回调 ## 4.1 注册 Telegram 和 Stalwart 回调
@ -70,19 +70,19 @@ Debian slim 运行时阶段;有关运行时内容,请参阅 [§8.2](#82-dock
Telegram 使用已保存的 webhook secret 和 `allowed_updates: ["message"]`。注册后,Stalwart 会自动完成验证回调和 verification-code 写回。可以安全地重复注册;更改回调 origin 会更新 Telegram webhook,并为新的 URL 创建 Stalwart subscription。 Telegram 使用已保存的 webhook secret 和 `allowed_updates: ["message"]`。注册后,Stalwart 会自动完成验证回调和 verification-code 写回。可以安全地重复注册;更改回调 origin 会更新 Telegram webhook,并为新的 URL 创建 Stalwart subscription。
## 5. Runtime configuration ## 5. 运行时配置
将启动时进程配置与 Redis 驻留业务配置分开。 将启动时进程配置与 Redis 驻留业务配置分开。
进程启动时仅需要“REDIS_URL”和“CONFIG_ENCRYPTION_KEY”。完整的 进程启动时仅需要“REDIS_URL”和“CONFIG_ENCRYPTION_KEY”。完整的
业务字段列表在【配置参考】(reference.md#5-environment-variables)中。 业务字段列表在【配置参考】(reference.md#5-environment-variables)中。
## 6. Short-request runtime and scheduling ## 6. 短请求运行模式与任务调度
该服务一次处理一个请求,并且不保留 JMAP 事件流、Telegram 该服务一次处理一个请求,并且不保留 JMAP 事件流、Telegram
长轮询,或打开后台计时器。 Telegram/JMAP 推送提供快捷路径;的 长轮询,或打开后台计时器。 Telegram/JMAP 推送提供快捷路径;的
外部调度程序调用下面描述的端点。 外部调度程序调用下面描述的端点。
### 6.3 Schedule queue draining and reconciliation ### 6.3 调度队列排空与状态协调
后端没有内部调度程序。外部 cron、计划作业或平台计时器 后端没有内部调度程序。外部 cron、计划作业或平台计时器
必须调用 [`scripts/cron-drain.sh`](../scripts/cron-drain.sh);否则排队的通知 必须调用 [`scripts/cron-drain.sh`](../scripts/cron-drain.sh);否则排队的通知
@ -101,28 +101,28 @@ MW_APP_URL='https://<PUBLIC_URL>' bash scripts/cron-drain.sh --once
调节节奏通常为 5-10 分钟。两个成功的端点都返回“204” 调节节奏通常为 5-10 分钟。两个成功的端点都返回“204”
身体空虚;不要将其视为错误。脚本的“--diagnose”模式有帮助 身体空虚;不要将其视为错误。脚本的“--diagnose”模式有帮助
将服务配置问题与调度问题分开。其论点和 将服务配置问题与调度问题分开。其论点和
操作模式列在[调度程序参考](reference.zh-CN.md#93-callback-registration-and-scheduled-work)中。 操作模式列在[调度程序参考](reference.zh-CN.md#93-回调注册与定时任务)中。
### 6.4 Reliability behavior ### 6.4 可靠性行为
Worker 仅重试超时和后端“5xx”响应;它返回“4xx”响应 Worker 仅重试超时和后端“5xx”响应;它返回“4xx”响应
直接。 `/reconcile` 和 `/worker` 具有更长的每条路由超时,并且不会故障转移到 直接。 `/reconcile` 和 `/worker` 具有更长的每条路由超时,并且不会故障转移到
第二个后端,避免重复工作。后端队列处理至少一次并且依赖 第二个后端,避免重复工作。后端队列处理至少一次并且依赖
关于 Redis 支持的幂等性。请参阅路线和状态参考以了解完整的行为。 关于 Redis 支持的幂等性。请参阅路线和状态参考以了解完整的行为。
### 6.5 Delivery objective ### 6.5 投递目标
该服务允许短暂的通知延迟,并以至少 99.9% 的通知率为目标 该服务允许短暂的通知延迟,并以至少 99.9% 的通知率为目标
可用性。 Redis 可用性和外部调度间隔超出范围 可用性。 Redis 可用性和外部调度间隔超出范围
应用程序的控制;准确的交付保证在“NFR-NOTIFY-SLA”中进行了描述,并且 应用程序的控制;准确的交付保证在“NFR-NOTIFY-SLA”中进行了描述,并且
项目章程中的“NFR-RECONCILE-INTERVAL”。 项目章程中的“NFR-RECONCILE-INTERVAL”。
## 7. Health checks ## 7. 健康检查
使用 `/healthz` 检查进程存活(`src/notify.rs:118`),使用 `/ready` 检查依赖就绪 使用 `/healthz` 检查进程存活(`src/notify.rs:118`),使用 `/ready` 检查依赖就绪
(`src/notify.rs:193`)。验证步骤见[§8.1](#81-verify-the-deployment)。 (`src/notify.rs:193`)。验证步骤见[§8.1](#81-验证部署)。
## 8.1 Verify the deployment ## 8.1 验证部署
首先检查后端: 首先检查后端:
@ -141,7 +141,7 @@ Worker 仅重试超时和后端“5xx”响应;它返回“4xx”响应
确认外部调度程序耗尽其通知。健康的“/healthz”只能证明 确认外部调度程序耗尽其通知。健康的“/healthz”只能证明
进程活跃度;它并不能证明 Redis 或上游任一者可用。 进程活跃度;它并不能证明 Redis 或上游任一者可用。
## 8.2 Docker image details ## 8.2 Docker 镜像细节
根 Dockerfile 的构建器使用 `rust:1-slim-bookworm`;运行时间是 根 Dockerfile 的构建器使用 `rust:1-slim-bookworm`;运行时间是
`debian:bookworm-slim`。运行时安装 CA 证书、`tini`、`curl`、`procps`、 `debian:bookworm-slim`。运行时安装 CA 证书、`tini`、`curl`、`procps`、
@ -149,62 +149,62 @@ Worker 仅重试超时和后端“5xx”响应;它返回“4xx”响应
`PORT=8080`,并通过 `tini` 启动二进制文件。它没有“VOLUME”声明并且确实 `PORT=8080`,并通过 `tini` 启动二进制文件。它没有“VOLUME”声明并且确实
不启动Redis。这些信息描述仓库提供的 Docker 镜像;其他平台可以使用自己的构建器和运行时镜像。 不启动Redis。这些信息描述仓库提供的 Docker 镜像;其他平台可以使用自己的构建器和运行时镜像。
## 9. Confirmed deployment choices ## 9. 已确认的部署选择
这些部署决定已经确定;它们不是每次安装的先决条件。 这些部署决定已经确定;它们不是每次安装的先决条件。
### 9.1 Public ingress and state ### 9.1 公共入口与状态存储
后端使用外部 HTTPS 入口和外部 Redis。 Telegram、JMAP 推送和 后端使用外部 HTTPS 入口和外部 Redis。 Telegram、JMAP 推送和
计划的请求使用选定的公共 URL。当Worker网关启用后,注册 计划的请求使用选定的公共 URL。当Worker网关启用后,注册
Telegram 和 Stalwart 的稳定 Worker URL;不要在网关处公开凭据。 Telegram 和 Stalwart 的稳定 Worker URL;不要在网关处公开凭据。
本节中的稳定 ID 指向项目章程的注册表。 本节中的稳定 ID 指向项目章程的注册表。
## 10. Optional Cloudflare Worker gateway ## 10. 可选的 Cloudflare Worker 网关
Worker 为多个后端源提供一个公共 HTTPS 入口点。所有后端 Worker 为多个后端源提供一个公共 HTTPS 入口点。所有后端
必须共享相同的Redis和业务配置。 Worker 仅转发其固定的 必须共享相同的Redis和业务配置。 Worker 仅转发其固定的
路由白名单; bootstrap 和 `/debug/*` 仍然仅限原始。详细路线事实在 路由白名单; bootstrap 和 `/debug/*` 仍然仅限原始。详细路线事实在
[网关路由矩阵](reference.zh-CN.md#4-gateway-vs-backend-route-matrix)。 [网关路由矩阵](reference.zh-CN.md#4-网关与后端路由对照表)。
### 10.1 Topology ### 10.1 部署拓扑
Telegram webhooks、Stalwart 推送回调和外部调度可以使用稳定的 Worker Telegram webhooks、Stalwart 推送回调和外部调度可以使用稳定的 Worker
网址。 Worker 转发到后端 HTTPS 源;它不连接到 Redis 或 JMAP。 网址。 Worker 转发到后端 HTTPS 源;它不连接到 Redis 或 JMAP。
### 10.2 Multi-instance prerequisites ### 10.2 多实例前提
多个后端可以共享流量,因为请求状态存储在 Redis 中,重复 多个后端可以共享流量,因为请求状态存储在 Redis 中,重复
交付已进行重复数据删除,协调修复了错过的推送事件。无粘性会话 交付已进行重复数据删除,协调修复了错过的推送事件。无粘性会话
或需要更改应用程序代码。 或需要更改应用程序代码。
### 10.3 Trust model ### 10.3 信任模型
Worker 传递请求标头和正文。仍需要后端身份验证 Worker 传递请求标头和正文。仍需要后端身份验证
因为后端源可能是直接可达的。每个实例必须使用相同的共享 因为后端源可能是直接可达的。每个实例必须使用相同的共享
业务凭证和 Redis 状态。 业务凭证和 Redis 状态。
### 10.4 Routes and failover ### 10.4 路由与故障转移
HTTP 请求访问 Worker 时,会先收到永久的 `308` 重定向,前往路径和查询参数相同的 HTTP 请求访问 Worker 时,会先收到永久的 `308` 重定向,前往路径和查询参数相同的
HTTPS URL,之后才进行路由校验或转发。Worker 使用“404”拒绝未注册的路由,并使用“405”拒绝错误的方法。普通 HTTPS URL,之后才进行路由校验或转发。Worker 使用“404”拒绝未注册的路由,并使用“405”拒绝错误的方法。普通
请求最多尝试“LB_MAX_ATTEMPTS”来源(默认两个),并且只有超时或“5xx” 请求最多尝试“LB_MAX_ATTEMPTS”来源(默认两个),并且只有超时或“5xx”
触发故障转移。 `/reconcile` 和 `/worker` 是具有较长超时时间的单次尝试路由 触发故障转移。 `/reconcile` 和 `/worker` 是具有较长超时时间的单次尝试路由
覆盖。使用[参考](reference.zh-CN.md#4-gateway-vs-backend-route-matrix)中的路由矩阵和调整值。 覆盖。使用[参考](reference.zh-CN.md#4-网关与后端路由对照表)中的路由矩阵和调整值。
### 10.5 Callback URLs ### 10.5 回调地址
使用网关时,将Telegram的webhook和Stalwart的push回调设置给Worker 使用网关时,将Telegram的webhook和Stalwart的push回调设置给Worker
域。外部调度程序可以对转发路径使用相同的 URL。引导程序和 域。外部调度程序可以对转发路径使用相同的 URL。引导程序和
诊断端点仍然需要后端源。 诊断端点仍然需要后端源。
### 10.6 Gateway health ### 10.6 网关健康状态
`GET /healthz-worker` 报告 Worker 版本和后端可用性。两者都使用 `GET /healthz-worker` 报告 Worker 版本和后端可用性。两者都使用
用于验证部署的“可用”和“版本”; `status: no-backends` 可以返回 用于验证部署的“可用”和“版本”; `status: no-backends` 可以返回
当未配置有效源时,HTTP 200。 当未配置有效源时,HTTP 200。
### 10.7 Boundaries ### 10.7 功能边界
Worker不是Redis代理,不会使后端无状态;共享外部Redis Worker不是Redis代理,不会使后端无状态;共享外部Redis
仍然需要。它不添加数据库、长期连接或第二层 仍然需要。它不添加数据库、长期连接或第二层

View File

@ -16,50 +16,50 @@
--- ---
## 0. Document navigation ## 0. 文档导航
1. [任务与范围](#1-task-and-scope) 1. [任务与范围](#1-任务与范围)
2. [jmap-client 能力分析](#2-jmap-client-capability-analysis) 2. [jmap-client 能力分析](#2-jmap-client-能力分析)
3. [认证与邮箱操作适配](#3-authentication-and-email-operation-adaptation) 3. [认证与邮件操作适配](#3-认证与邮件操作适配)
4. [Telegram 渠道实现选型](#4-telegram-channel-implementation-selection) 4. [Telegram 渠道实现选型](#4-telegram-渠道实现选型)
5. [整体架构与数据流](#5-overall-architecture-and-data-flow) 5. [整体架构与数据流](#5-整体架构与数据流)
6. [模块划分](#6-module-division) 6. [模块划分](#6-模块划分)
7. [配置与安全](#7-configuration-and-security) 7. [配置与安全](#7-配置与安全)
8. [错误处理与可观测性](#8-error-handling-and-observability) 8. [错误处理与可观测性](#8-错误处理与可观测性)
9. [测试策略](#9-test-strategy) 9. [测试策略](#9-测试策略)
10. [分阶段实施计划](#10-phased-implementation-plan) 10. [分阶段实施计划](#10-分阶段实施计划)
11. [历史问题与决策归档(产品/架构类,均已有结论)](#11-archive-of-historical-issues-and-decisions-productarchitecture-category-all-have-conclusions) 11. [历史问题与决策归档(产品/架构类,均已有结论)](#11-历史问题与决策归档产品架构类均已有结论)
12. [AI 辅助能力:架构、确认门槛、失败回退](#12-ai-auxiliary-capabilities-architecture-confirmation-threshold-failure-fallback) 12. [AI 辅助能力:架构、确认门槛与失败回退](#12-ai-辅助能力架构确认门槛与失败回退)
> 部署/平台类决策已确认(单账户、App Password+Basic、Redis 托管+AOF、平台 HTTPS URL、外部 Cron)并**全部收敛归档**(含 `Q-DEP-A`/`Q-DEP-B`,见 `docs/deployment.md` 的已确认决策一节);**未完成的代码缺口见 `docs/opengaps.md`**。 > 部署/平台类决策已确认(单账户、App Password+Basic、Redis 托管+AOF、平台 HTTPS URL、外部 Cron)并**全部收敛归档**(含 `Q-DEP-A`/`Q-DEP-B`,见 `docs/deployment.md` 的已确认决策一节);**未完成的代码缺口见 `docs/opengaps.md`**。
--- ---
## 1. Task and Scope ## 1. 任务与范围
### 1.1 Goals ### 1.1 目标
构建一个 Rust 写的 Telegram 机器人,作为 Stalwart JMAP 邮箱的**个人邮件助手**: 构建一个 Rust 写的 Telegram 机器人,作为 Stalwart JMAP 邮箱的**个人邮件助手**:
- 通过 Telegram 命令查询/阅读邮件、查看文件夹、发送邮件、管理关键词等。 - 通过 Telegram 命令查询/阅读邮件、查看文件夹、发送邮件、管理关键词等。
- 利用 JMAP 的 **Push HTTPS 回调**(+ 外部 Cron 对账兜底)在新邮件到达时**主动推送到 Telegram**。(EventSource/SSE/长轮询为非目标,见 deployment.md `NG-POLLING-SSE`/`NG-LONG-POLLING`/`C-NO-LONG-CONN`。) - 利用 JMAP 的 **Push HTTPS 回调**(+ 外部 Cron 对账兜底)在新邮件到达时**主动推送到 Telegram**。(EventSource/SSE/长轮询为非目标,见 deployment.md `NG-POLLING-SSE`/`NG-LONG-POLLING`/`C-NO-LONG-CONN`。)
- 单用户或多账户部署(默认面向单账户自托管场景)。 - 单用户或多账户部署(默认面向单账户自托管场景)。
### 1.2 Scope (this stage) ### 1.2 本阶段范围
- ✅ 调研 + 方案设计(本文档) - ✅ 调研 + 方案设计(本文档)
- ❌ 不创建/修改项目代码、不初始化 cargo 工程 - ❌ 不创建/修改项目代码、不初始化 cargo 工程
- ✅ 输出可供 lead 与用户评审的详细方案,标注关键不确定项 - ✅ 输出可供 lead 与用户评审的详细方案,标注关键不确定项
### 1.3 Research basis ### 1.3 研究依据
- [`stalwartlabs/jmap-client`](https://github.com/stalwartlabs/jmap-client)(main 分支,截至本次调研) - [`stalwartlabs/jmap-client`](https://github.com/stalwartlabs/jmap-client)(main 分支,截至本次调研)
- 项目目录:`/home/okabe/Repo/messageweave/`(工具链要求见 §10.0 与 `../AGENTS.md §3.3`) - 项目目录:`/home/okabe/Repo/messageweave/`(工具链要求见 §10.0 与 `../AGENTS.md §3.3`)
--- ---
## 2. jmap-client capability analysis ## 2. jmap-client 能力分析
来源:直接阅读 `stalwartlabs/jmap-client` 仓库的 `stalwartlabs/jmap-client/src/lib.rs`、`stalwartlabs/jmap-client/src/client.rs`、`stalwartlabs/jmap-client/src/email/`、`stalwartlabs/jmap-client/src/email_submission/`、`stalwartlabs/jmap-client/src/event_source/`、`stalwartlabs/jmap-client/Cargo.toml`、`stalwartlabs/jmap-client/README.md`、`stalwartlabs/jmap-client/examples/`。 来源:直接阅读 `stalwartlabs/jmap-client` 仓库的 `stalwartlabs/jmap-client/src/lib.rs`、`stalwartlabs/jmap-client/src/client.rs`、`stalwartlabs/jmap-client/src/email/`、`stalwartlabs/jmap-client/src/email_submission/`、`stalwartlabs/jmap-client/src/event_source/`、`stalwartlabs/jmap-client/Cargo.toml`、`stalwartlabs/jmap-client/README.md`、`stalwartlabs/jmap-client/examples/`。
### 2.1 crate overview ### 2.1 crate 概览
| 项 | 值 | | 项 | 值 |
|---|---| |---|---|
| crate 名 | `jmap-client`(crates.io) | | crate 名 | `jmap-client`(crates.io) |
@ -69,7 +69,7 @@
| `forbid(unsafe_code)` | 是(lib.rs 顶部声明)✅ | | `forbid(unsafe_code)` | 是(lib.rs 顶部声明)✅ |
| 默认 features | **0.4.2 实测 `default = ["async", "websockets", "aws_lc_rs"]`**(含 WebSocket 栈 `tokio-tungstenite`)。⚠️ `default-features = true` 会**隐式启用 WebSocket**,违反 `C-NO-LONG-CONN`。**本项目实际选用 `default-features = false, features = ["async", "rustls"]`(无 `websockets`)**,以 `Cargo.toml` 为准。 | | 默认 features | **0.4.2 实测 `default = ["async", "websockets", "aws_lc_rs"]`**(含 WebSocket 栈 `tokio-tungstenite`)。⚠️ `default-features = true` 会**隐式启用 WebSocket**,违反 `C-NO-LONG-CONN`。**本项目实际选用 `default-features = false, features = ["async", "rustls"]`(无 `websockets`)**,以 `Cargo.toml` 为准。 |
### 2.2 Module structure (`src/`) ### 2.2 模块结构(`src/`)
``` ```
lib.rs 顶层:URI / Method / DataType / PushObject / Error 枚举 lib.rs 顶层:URI / Method / DataType / PushObject / Error 枚举
client.rs Client:认证、connect、session、send_request、event_source、ws client.rs Client:认证、connect、session、send_request、event_source、ws
@ -88,7 +88,7 @@ event_source/ SSE 流:mod / parser / stream
client_ws/ WebSocket 客户端(feature = "websockets") client_ws/ WebSocket 客户端(feature = "websockets")
``` ```
### 2.3 Key capabilities mapped to this Bot ### 2.3 与本 Bot 对应的关键能力
| Bot 需求 | jmap-client API | 备注 | | Bot 需求 | jmap-client API | 备注 |
|---|---|---| |---|---|---|
| 登录/会话 | `Client::new().credentials(...).connect(url)` | 支持 Basic 与 Bearer;connect 解析 session URL、capabilities | | 登录/会话 | `Client::new().credentials(...).connect(url)` | 支持 Basic 与 Bearer;connect 解析 session URL、capabilities |
@ -102,14 +102,14 @@ client_ws/ WebSocket 客户端(feature = "websockets")
| 实时通知(Push + 对账兜底) | Push HTTPS 回调 → `StateChange`;外部 Cron 调用 `/reconcile` 使用 `Email/changes` 补差 | 需公网 HTTPS 入口(deployment.md `C-HTTPS-INBOUND`/`FLOW-NEW-MAIL`);Push 不是唯一可靠来源 | | 实时通知(Push + 对账兜底) | Push HTTPS 回调 → `StateChange`;外部 Cron 调用 `/reconcile` 使用 `Email/changes` 补差 | 需公网 HTTPS 入口(deployment.md `C-HTTPS-INBOUND`/`FLOW-NEW-MAIL`);Push 不是唯一可靠来源 |
| SSE / WebSocket(非目标) | `event_source` / `client_ws` | 本部署**不使用**(deployment.md `NG-POLLING-SSE`/`NG-LONG-POLLING`/`C-NO-LONG-CONN`);仅列 crate 能力供调研 | | SSE / WebSocket(非目标) | `event_source` / `client_ws` | 本部署**不使用**(deployment.md `NG-POLLING-SSE`/`NG-LONG-POLLING`/`C-NO-LONG-CONN`);仅列 crate 能力供调研 |
### 2.4 Authentication mechanism (`stalwartlabs/jmap-client/src/client.rs`) ### 2.4 认证机制(`stalwartlabs/jmap-client/src/client.rs`)
- `Credentials::Basic { username, secret }` — 用户名/密码,Stalwart 原生支持。 - `Credentials::Basic { username, secret }` — 用户名/密码,Stalwart 原生支持。
- `Credentials::Bearer { token, .. }` — OAuth2 access_token;可选 `refresh_token` + `refresh_url` + `refresh_grace`,client 会在过期前自动刷新。 - `Credentials::Bearer { token, .. }` — OAuth2 access_token;可选 `refresh_token` + `refresh_url` + `refresh_grace`,client 会在过期前自动刷新。
- `connect()` 作用:GET session URL → 解析 `accounts`/`capabilities`/`download_url`/`upload_url`/`event_source_url`,缓存 account_id。 - `connect()` 作用:GET session URL → 解析 `accounts`/`capabilities`/`download_url`/`upload_url`/`event_source_url`,缓存 account_id。
- 支持自定义 `reqwest::Client`(`Client::new().client(reqwest_client)`):可注入代理、TLS 配置、超时、UA。 - 支持自定义 `reqwest::Client`(`Client::new().client(reqwest_client)`):可注入代理、TLS 配置、超时、UA。
- 支持 `accept_long_responses` / `event_source(ping, ..)` 用于长连接保活。 - 支持 `accept_long_responses` / `event_source(ping, ..)` 用于长连接保活。
### 2.5 Error model (`Error` enumeration) ### 2.5 错误模型(`Error` 枚举)
``` ```
Transport(reqwest::Error) 网络/TLS/超时 Transport(reqwest::Error) 网络/TLS/超时
Parse(serde_json::Error) 序列化 Parse(serde_json::Error) 序列化
@ -122,7 +122,7 @@ WebSocket(...) ws 错误(feature 开启时)
``` ```
- `MethodErrorType` 细粒度:`ServerUnavailable`/`ServerFail`/`RateLimit`/`InvalidArguments`/`Forbidden`/`StateMismatch`/`TooManyChanges`… → 可直接驱动 Bot 的重试/限流/状态重置策略。 - `MethodErrorType` 细粒度:`ServerUnavailable`/`ServerFail`/`RateLimit`/`InvalidArguments`/`Forbidden`/`StateMismatch`/`TooManyChanges`… → 可直接驱动 Bot 的重试/限流/状态重置策略。
### 2.6 Real-time channel decision-making (determined, see deployment.md) ### 2.6 实时通信渠道的决策(已确定,参见 deployment.md)
> 部署目标 = **通用 HTTPS-only Docker、无长连接**(deployment.md `C-NO-LONG-CONN`/`C-HTTPS-INBOUND`)。 > 部署目标 = **通用 HTTPS-only Docker、无长连接**(deployment.md `C-NO-LONG-CONN`/`C-HTTPS-INBOUND`)。
> 当前通道 = JMAP Push HTTPS 回调(先由管理员通过 `POST /api/push/register` 显式注册,见 §7.3)+ 外部 Cron `/reconcile` 对账兜底。Push 订阅不会自动创建,不能把 Push 当作唯一可靠来源。 > 当前通道 = JMAP Push HTTPS 回调(先由管理员通过 `POST /api/push/register` 显式注册,见 §7.3)+ 外部 Cron `/reconcile` 对账兜底。Push 订阅不会自动创建,不能把 Push 当作唯一可靠来源。
@ -138,16 +138,16 @@ WebSocket(...) ws 错误(feature 开启时)
--- ---
## 3. Authentication and email operation adaptation ## 3. 认证与邮件操作适配
### 3.1 Authentication Strategy (Confirmed: App Password + Basic) ### 3.1 认证策略(已确认:应用专用密码 + Basic)
- **确认采用**(`C-AUTH-APP-BASIC`):`Credentials::Basic`(Stalwart 账户邮箱 + **应用专用密码 App Password**)。App Password 可独立吊销/设到期,不用主密码。配置注入,不入代码。 - **确认采用**(`C-AUTH-APP-BASIC`):`Credentials::Basic`(Stalwart 账户邮箱 + **应用专用密码 App Password**)。App Password 可独立吊销/设到期,不用主密码。配置注入,不入代码。
- **不采用**:OAuth/OIDC `Bearer`(无 OIDC 需求时增加复杂度,不选);主密码 Basic。 - **不采用**:OAuth/OIDC `Bearer`(无 OIDC 需求时增加复杂度,不选);主密码 Basic。
- **安全**:密码仅存配置或密钥管理器;运行期用 `secrecy::SecretString` 包裹,日志永不打印明文(见 §7.2)。 - **安全**:密码仅存配置或密钥管理器;运行期用 `secrecy::SecretString` 包裹,日志永不打印明文(见 §7.2)。
- 运行期注入方式见 deployment.md(`C-NO-SECRET-IN-IMAGE`)。 - 运行期注入方式见 deployment.md(`C-NO-SECRET-IN-IMAGE`)。
- **单账户**(`REQ-SINGLE-ACCOUNT`):本实例只接一个 Stalwart 账户;多账户 = 部署多个 bot 实例。 - **单账户**(`REQ-SINGLE-ACCOUNT`):本实例只接一个 Stalwart 账户;多账户 = 部署多个 bot 实例。
### 3.2 Mailbox operation adaptation layer (JMAP ↔ Bot semantics) ### 3.2 邮箱操作适配层(JMAP ↔ Bot 语义)
建议在 `domain::jmap` 模块封装一层领域语义,对上层只暴露业务动词: 建议在 `domain::jmap` 模块封装一层领域语义,对上层只暴露业务动词:
| Bot 动词 | 封装函数 | 内部 JMAP | | Bot 动词 | 封装函数 | 内部 JMAP |
@ -163,7 +163,7 @@ WebSocket(...) ws 错误(feature 开启时)
--- ---
## 4. Telegram channel implementation selection ## 4. Telegram 渠道实现选型
Telegram 渠道用 `src/channel.rs` 的 `reqwest` 自研实现(`ARCH-DEPS-STAGE4`),**不引入第三方 Bot 框架**。 Telegram 渠道用 `src/channel.rs` 的 `reqwest` 自研实现(`ARCH-DEPS-STAGE4`),**不引入第三方 Bot 框架**。
评估过 `teloxide` 但**未采用**:本项目只用 Bot API 的少数调用,不值得为它引入 dptree 调度与自带会话中间件这两层抽象;`throttle` 与 Redis 会话能力用 `src/channel.rs` 的重试与外部 Redis TTL 各实现了一部分。 评估过 `teloxide` 但**未采用**:本项目只用 Bot API 的少数调用,不值得为它引入 dptree 调度与自带会话中间件这两层抽象;`throttle` 与 Redis 会话能力用 `src/channel.rs` 的重试与外部 Redis TTL 各实现了一部分。
@ -171,9 +171,9 @@ Telegram 渠道用 `src/channel.rs` 的 `reqwest` 自研实现(`ARCH-DEPS-STAG
--- ---
## 5. Overall architecture and data flow ## 5. 整体架构与数据流
### 5.1 High-level topology ### 5.1 总体拓扑
``` ```
┌───────────────────────────────────────────────────┐ ┌───────────────────────────────────────────────────┐
│ Telegram │ │ Telegram │
@ -212,7 +212,7 @@ Telegram 渠道用 `src/channel.rs` 的 `reqwest` 自研实现(`ARCH-DEPS-STAG
> EventSource/SSE 与长轮询均为非目标(`NG-POLLING-SSE`/`NG-LONG-POLLING`)。 > EventSource/SSE 与长轮询均为非目标(`NG-POLLING-SSE`/`NG-LONG-POLLING`)。
> 渠道解耦(见 §5.2):领域层与渠道层以领域 Command / Notification 交互,领域层不出现任何具体渠道 SDK 类型;钉钉/飞书仅保留扩展位,不提前实现。 > 渠道解耦(见 §5.2):领域层与渠道层以领域 Command / Notification 交互,领域层不出现任何具体渠道 SDK 类型;钉钉/飞书仅保留扩展位,不提前实现。
### 5.2 Channel abstraction and multi-channel expansion strategy ### 5.2 渠道抽象与多渠道扩展策略
- **现状**:首个(也是当前唯一)渠道是 Telegram。目标是保留平行扩展钉钉/飞书的能力,**但不提前实现**。 - **现状**:首个(也是当前唯一)渠道是 Telegram。目标是保留平行扩展钉钉/飞书的能力,**但不提前实现**。
- **原则**:领域层与渠道层解耦——邮件/JMAP/AI/意图状态机**不得依赖 Telegram 类型**。 - **原则**:领域层与渠道层解耦——邮件/JMAP/AI/意图状态机**不得依赖 Telegram 类型**。
- **抽象(不过度设计)**:不预留空 trait。`src/channel.rs` 曾有三个零实现的 - **抽象(不过度设计)**:不预留空 trait。`src/channel.rs` 曾有三个零实现的
@ -228,7 +228,7 @@ Telegram 渠道用 `src/channel.rs` 的 `reqwest` 自研实现(`ARCH-DEPS-STAG
- 新增渠道 = 新 adapter + 装配,不改领域层。真需要抽象时**带实现一起加**,不留空 trait。 - 新增渠道 = 新 adapter + 装配,不改领域层。真需要抽象时**带实现一起加**,不留空 trait。
- **不做什么**:不定义多态配置注册表、不预先抽象"渠道能力矩阵"、不建 plugins 机制;按需再演进(YAGNI)。 - **不做什么**:不定义多态配置注册表、不预先抽象"渠道能力矩阵"、不建 plugins 机制;按需再演进(YAGNI)。
### 5.3 Running model (short request, no long connection) ### 5.3 运行模式(短请求、无长连接)
- 单一二进制 `message-weave`,`#[tokio::main]`。 - 单一二进制 `message-weave`,`#[tokio::main]`。
- 启动时: - 启动时:
1. 加载配置(`config::Config`)。 1. 加载配置(`config::Config`)。
@ -247,7 +247,7 @@ Telegram 渠道用 `src/channel.rs` 的 `reqwest` 自研实现(`ARCH-DEPS-STAG
- 优雅退出:信号 + `CancellationToken`,退出前 flush Redis Streams 待处理条目(**不写本地文件**,`C-NO-LOCAL-WRITE`)。 - 优雅退出:信号 + `CancellationToken`,退出前 flush Redis Streams 待处理条目(**不写本地文件**,`C-NO-LOCAL-WRITE`)。
- **当前现状**:完成配置引导、HTTP 入口和写入口鉴权(`R1`/`SAF-AUTH-*`,fail-closed);JMAP session、Email/changes、PushSubscription create/update、Redis Streams worker 和 `/reconcile` 已接入。`/reconcile` 与 Push 闭环仍需在真实 Stalwart、Redis、Telegram 环境完成端到端验收,不能仅凭本地门禁宣称生产链路已验证。 - **当前现状**:完成配置引导、HTTP 入口和写入口鉴权(`R1`/`SAF-AUTH-*`,fail-closed);JMAP session、Email/changes、PushSubscription create/update、Redis Streams worker 和 `/reconcile` 已接入。`/reconcile` 与 Push 闭环仍需在真实 Stalwart、Redis、Telegram 环境完成端到端验收,不能仅凭本地门禁宣称生产链路已验证。
### 5.4 Data flow: new email push (critical path, FLOW-NEW-MAIL) ### 5.4 数据流:新邮件推送(关键路径,FLOW-NEW-MAIL)
``` ```
Stalwart JMAP Push → POST /push/jmap (StateChange{Email/EmailDelivery: new_state}) Stalwart JMAP Push → POST /push/jmap (StateChange{Email/EmailDelivery: new_state})
→ 校验 pushSubscriptionId + verificationCode → 幂等去重(MOD-DEDUP) → 入 Redis Streams(MOD-STREAMS) → 立即 2xx ACK → 校验 pushSubscriptionId + verificationCode → 幂等去重(MOD-DEDUP) → 入 Redis Streams(MOD-STREAMS) → 立即 2xx ACK
@ -262,7 +262,7 @@ Stalwart JMAP Push → POST /push/jmap (StateChange{Email/EmailDelivery: new_sta
> 通知只含发件人/主题/时间(+附件数),正文绝不出现在通知里(见 `docs/charter.md §3` 安全边界 `SAF-NOTIFY-META`)。 > 通知只含发件人/主题/时间(+附件数),正文绝不出现在通知里(见 `docs/charter.md §3` 安全边界 `SAF-NOTIFY-META`)。
> 无长连接:Push 为短请求回调,对账由**外部 HTTPS Cron**触发(`C-NO-LONG-CONN`),非容器内自持定时器。 > 无长连接:Push 为短请求回调,对账由**外部 HTTPS Cron**触发(`C-NO-LONG-CONN`),非容器内自持定时器。
### 5.5 Data flow: command `/read <seq>` (long mail branch) ### 5.5 数据流:`/read <seq>` 命令(长邮件分支)
``` ```
TG /read 3 → handler 取会话里的 folder+page 游标 TG /read 3 → handler 取会话里的 folder+page 游标
→ JmapService.list_emails(folder, page=3) → JmapService.list_emails(folder, page=3)
@ -275,7 +275,7 @@ TG /read 3 → handler 取会话里的 folder+page 游标
("AI 总结"分支必须用户确认后才把正文交 LLM 生成 ~300 字摘要;见 §12.3) ("AI 总结"分支必须用户确认后才把正文交 LLM 生成 ~300 字摘要;见 §12.3)
``` ```
### 5.6 Session State Machine (Conversation FSM) ### 5.6 对话状态机(FSM)
当前**没有 FSM**。命令路由由 `src/worker.rs` 的 `parse_intent` 解析为 `Intent`;AI 授权态是 Redis 里的一个布尔加过期时间(键与 TTL 见 `docs/reference.md` 的 AI 授权态一节),不是多步确认态。 当前**没有 FSM**。命令路由由 `src/worker.rs` 的 `parse_intent` 解析为 `Intent`;AI 授权态是 Redis 里的一个布尔加过期时间(键与 TTL 见 `docs/reference.md` 的 AI 授权态一节),不是多步确认态。
@ -283,7 +283,7 @@ TG /read 3 → handler 取会话里的 folder+page 游标
曾设计过的 5 态 FSM(`Idle` / `AwaitClarify` / `AwaitConfirm` / `Analyzing` / `AwaitFallback`)连同状态转移表与渠道中立说明,见 `docs/retired.md`。 曾设计过的 5 态 FSM(`Idle` / `AwaitClarify` / `AwaitConfirm` / `Analyzing` / `AwaitFallback`)连同状态转移表与渠道中立说明,见 `docs/retired.md`。
### 5.7 Email search (`/search`, `bfe0fd8` implemented) ### 5.7 邮件搜索(`/search`,已在 `bfe0fd8` 实现)
`/search <关键词>` 与中文前缀 `搜索`/`查找`/`检索`(**仅前缀匹配**,避免"帮我搜一下…"被劫持成搜索)触发 `Intent::Search`,经 `JmapService::search_emails(account_id, query, limit)` 走 `email_query`(`Filter::text`) 取 ID + `SearchSnippet/get` 取 `subject`/`preview` 高亮。 `/search <关键词>` 与中文前缀 `搜索`/`查找`/`检索`(**仅前缀匹配**,避免"帮我搜一下…"被劫持成搜索)触发 `Intent::Search`,经 `JmapService::search_emails(account_id, query, limit)` 走 `email_query`(`Filter::text`) 取 ID + `SearchSnippet/get` 取 `subject`/`preview` 高亮。
@ -298,7 +298,7 @@ TG /read 3 → handler 取会话里的 folder+page 游标
--- ---
## 6. Module division ## 6. 模块划分
实际工程结构(本文件所有代码锚点均指此结构;历史的目标模块拆分计划见 `docs/retired.md`): 实际工程结构(本文件所有代码锚点均指此结构;历史的目标模块拆分计划见 `docs/retired.md`):
``` ```
@ -331,7 +331,7 @@ message-weave/
测试:**没有集成测试目录**,也没有独立的 util 工具模块。所有 Rust 测试是各模块内的 `#[cfg(test)]` 单元测试;前端唯一测试是 `web/config.test.mjs`。 测试:**没有集成测试目录**,也没有独立的 util 工具模块。所有 Rust 测试是各模块内的 `#[cfg(test)]` 单元测试;前端唯一测试是 `web/config.test.mjs`。
### 6.1 Module Responsibility Matrix ### 6.1 模块职责矩阵
> **依赖现况**:`jmap-client 0.4.2`、`redis 0.27`、`reqwest 0.13` **均已引入**(`ARCH-DEPS-STAGE1` / `ARCH-DEPS-STAGE4`,版本以 `Cargo.toml` 为准)。`teloxide` 经评估**未引入**,Telegram 渠道用 `reqwest` 自研实现(§4,详见 `docs/retired.md`)。下表每一行都是**真实存在的模块**。 > **依赖现况**:`jmap-client 0.4.2`、`redis 0.27`、`reqwest 0.13` **均已引入**(`ARCH-DEPS-STAGE1` / `ARCH-DEPS-STAGE4`,版本以 `Cargo.toml` 为准)。`teloxide` 经评估**未引入**,Telegram 渠道用 `reqwest` 自研实现(§4,详见 `docs/retired.md`)。下表每一行都是**真实存在的模块**。
@ -352,9 +352,9 @@ message-weave/
--- ---
## 7. Configuration and Security ## 7. 配置与安全
### 7.1 Historical configuration reading (migrated to Redis) ### 7.1 历史配置读取(已迁移至 Redis)
> **迁移目标(`C-REDIS-ONLY-STATE`)**:生产启动环境仅保留 `REDIS_URL`。空 Redis > **迁移目标(`C-REDIS-ONLY-STATE`)**:生产启动环境仅保留 `REDIS_URL`。空 Redis
> 的首次配置必须通过 `CONFIG_ENCRYPTION_KEY` 认证的一次性 bootstrap/admin 会话完成; > 的首次配置必须通过 `CONFIG_ENCRYPTION_KEY` 认证的一次性 bootstrap/admin 会话完成;
@ -398,14 +398,14 @@ message-weave/
> **入口鉴权密钥(`TG_WEBHOOK_SECRET`/`RECONCILE_TOKEN`)在业务配置完成后必须有效**(fail-closed,`SAF-AUTH-*`)。JMAP Push 的验证码由 Stalwart 在订阅创建后动态生成,不属于业务配置。启动引导变量缺失时服务进入配置引导模式并保持 SPA 可访问;不会因为缺少启动变量而伪造业务成功,也不会绕过入口鉴权。 > **入口鉴权密钥(`TG_WEBHOOK_SECRET`/`RECONCILE_TOKEN`)在业务配置完成后必须有效**(fail-closed,`SAF-AUTH-*`)。JMAP Push 的验证码由 Stalwart 在订阅创建后动态生成,不属于业务配置。启动引导变量缺失时服务进入配置引导模式并保持 SPA 可访问;不会因为缺少启动变量而伪造业务成功,也不会绕过入口鉴权。
> **JMAP session URL 归一化(`REQ-JMAP-SESSION-URL`/`SAF-JMAP-URL`)**:`JMAP_SESSION_URL` 既接受**服务基地址**(`https://mail.example.com`)也接受**完整 session URL**(`https://mail.example.com/.well-known/jmap`)。代码在调用 `jmap_client::Client::connect` **之前统一归一化为 origin/base**——`jmap-client` 会自动追加 `/.well-known/jmap`,故**不会出现重复路径**(如 `…/.well-known/jmap/.well-known/jmap`)。约束:**仅 HTTPS**(http 拒绝);**禁止 URL 内嵌用户名/密码**(凭据只经 `JMAP_USERNAME`/`JMAP_PASSWORD`,`C-AUTH-APP-BASIC`);拒绝危险 query。**重定向白名单**:`jmap-client` 默认**拒绝一切重定向**,而 Stalwart 会把 `/.well-known/jmap` 307 跳到 `/jmap/session`,因此连接时把**配置里的 origin host 自身**加入信任名单,服务端改写出的其它 host 仍被拒绝(`follow_redirects([trusted_redirect_host(base)])`)。已知上游隐患:Stalwart 的 307 `Location` 会把 `user:pass` 写进 URL,且客户端把该 URL 带凭据回传——故传输错误串**不做额外日志**。(状态:D-G1-1 **代码已实现,并已用真实 Stalwart 实例通过 `cargo test -- --ignored jmap::` 验证**。) > **JMAP session URL 归一化(`REQ-JMAP-SESSION-URL`/`SAF-JMAP-URL`)**:`JMAP_SESSION_URL` 既接受**服务基地址**(`https://mail.example.com`)也接受**完整 session URL**(`https://mail.example.com/.well-known/jmap`)。代码在调用 `jmap_client::Client::connect` **之前统一归一化为 origin/base**——`jmap-client` 会自动追加 `/.well-known/jmap`,故**不会出现重复路径**(如 `…/.well-known/jmap/.well-known/jmap`)。约束:**仅 HTTPS**(http 拒绝);**禁止 URL 内嵌用户名/密码**(凭据只经 `JMAP_USERNAME`/`JMAP_PASSWORD`,`C-AUTH-APP-BASIC`);拒绝危险 query。**重定向白名单**:`jmap-client` 默认**拒绝一切重定向**,而 Stalwart 会把 `/.well-known/jmap` 307 跳到 `/jmap/session`,因此连接时把**配置里的 origin host 自身**加入信任名单,服务端改写出的其它 host 仍被拒绝(`follow_redirects([trusted_redirect_host(base)])`)。已知上游隐患:Stalwart 的 307 `Location` 会把 `user:pass` 写进 URL,且客户端把该 URL 带凭据回传——故传输错误串**不做额外日志**。(状态:D-G1-1 **代码已实现,并已用真实 Stalwart 实例通过 `cargo test -- --ignored jmap::` 验证**。)
### 7.2 Key Management ### 7.2 密钥管理
- **禁止**把 token/密码写入仓库或任何配置文件(本项目配置不进仓库,`ARCH-CONFIG-ENV`)。 - **禁止**把 token/密码写入仓库或任何配置文件(本项目配置不进仓库,`ARCH-CONFIG-ENV`)。
- 启动期只读 2 个进程级变量(`REDIS_URL`、`CONFIG_ENCRYPTION_KEY`,由 `src/main.rs` 直接 `std::env::var`;缺任一即降级到只读 setup 模式);业务配置从 Redis 经 `config` 层反序列化(**无** `${VAR}` 插值/figment/配置文件,`ARCH-CONFIG-ENV`)。 - 启动期只读 2 个进程级变量(`REDIS_URL`、`CONFIG_ENCRYPTION_KEY`,由 `src/main.rs` 直接 `std::env::var`;缺任一即降级到只读 setup 模式);业务配置从 Redis 经 `config` 层反序列化(**无** `${VAR}` 插值/figment/配置文件,`ARCH-CONFIG-ENV`)。
- 运行期用 `secrecy::SecretString` 包裹,`Debug` 实现打 `***`。 - 运行期用 `secrecy::SecretString` 包裹,`Debug` 实现打 `***`。
- 日志过滤:`tracing` 字段层屏蔽 `Authorization`/`password`/`token`。 - 日志过滤:`tracing` 字段层屏蔽 `Authorization`/`password`/`token`。
- 运行期 secret 注入方式(env / `*_FILE` / 编排器 secret)见 deployment.md(`C-NO-SECRET-IN-IMAGE`)。 - 运行期 secret 注入方式(env / `*_FILE` / 编排器 secret)见 deployment.md(`C-NO-SECRET-IN-IMAGE`)。
### 7.3 Access control ### 7.3 访问控制
- **入口鉴权(硬约束 `SAF-AUTH-RECONCILE`/`SAF-AUTH-TG-WEBHOOK`/`SAF-AUTH-JMAP-PUSH`)**:三条写路径必须先鉴权,**fail-closed**—— - **入口鉴权(硬约束 `SAF-AUTH-RECONCILE`/`SAF-AUTH-TG-WEBHOOK`/`SAF-AUTH-JMAP-PUSH`)**:三条写路径必须先鉴权,**fail-closed**——
- `/reconcile`:请求头 `Authorization: Bearer <RECONCILE_TOKEN>`; - `/reconcile`:请求头 `Authorization: Bearer <RECONCILE_TOKEN>`;
- `/webhook/tg`:请求头 `X-Telegram-Bot-Api-Secret-Token == TG_WEBHOOK_SECRET`; - `/webhook/tg`:请求头 `X-Telegram-Bot-Api-Secret-Token == TG_WEBHOOK_SECRET`;
@ -418,7 +418,7 @@ message-weave/
- **命令最小化**:只暴露必要命令;发邮件等写操作必须二次确认(当前未实现发信,见 §10.3)。 - **命令最小化**:只暴露必要命令;发邮件等写操作必须二次确认(当前未实现发信,见 §10.3)。
- **速率**:出站侧未建本地令牌桶;Telegram 出站发送按 Redis 运行参数 `max_retries` 重试(默认值与硬上限见 `docs/reference.md` §6.1);当前仅对 Push 验证码写入做 Redis 限流(`ratelimit:push-verify:*`)。Telegram 服务端 30 msg/s 限制下的 429 **按 `parameters.retry_after` 秒自动退避**(`channel.rs`,`f4cae00`):`retry_after_ms` 解析后截断到 60s 预算上限,缺该字段或非数字时回退指数退避 `backoff_delay_ms`(250ms 起、封顶 4s),整体重试预算 60s。仍不做本地令牌桶限流——超出预算直接返回失败,交由上游重试。 - **速率**:出站侧未建本地令牌桶;Telegram 出站发送按 Redis 运行参数 `max_retries` 重试(默认值与硬上限见 `docs/reference.md` §6.1);当前仅对 Push 验证码写入做 Redis 限流(`ratelimit:push-verify:*`)。Telegram 服务端 30 msg/s 限制下的 429 **按 `parameters.retry_after` 秒自动退避**(`channel.rs`,`f4cae00`):`retry_after_ms` 解析后截断到 60s 预算上限,缺该字段或非数字时回退指数退避 `backoff_delay_ms`(250ms 起、封顶 4s),整体重试预算 60s。仍不做本地令牌桶限流——超出预算直接返回失败,交由上游重试。
### 7.3.1 Configuration Management API ### 7.3.1 配置管理 API
- `GET /` 提供嵌入 Rust 二进制的 SPA;`/assets/config.js` 与 `/assets/styles.css` 提供页面资源。服务不在运行时读取或写入本地文件(`C-NO-LOCAL-WRITE`)。 - `GET /` 提供嵌入 Rust 二进制的 SPA;`/assets/config.js` 与 `/assets/styles.css` 提供页面资源。服务不在运行时读取或写入本地文件(`C-NO-LOCAL-WRITE`)。
- `GET /api/status` 公开返回 `{ "ready": boolean, "mode": "configured" | "configuration-setup", "missing": string[], "version": string }`,只列缺少的环境变量名称。缺少 `REDIS_URL` 或 `CONFIG_ENCRYPTION_KEY` 时,SPA 只显示配置引导状态与缺失变量;服务状态确认 ready=true 后才显示管理会话授权区。`version` 是 `build.rs` 在编译期烘焙的 `BUILD_VERSION`(`<git-sha 或 nogit>+<UTC 构建时间>`),SPA 页脚据此确认部署是否真正落地。 - `GET /api/status` 公开返回 `{ "ready": boolean, "mode": "configured" | "configuration-setup", "missing": string[], "version": string }`,只列缺少的环境变量名称。缺少 `REDIS_URL` 或 `CONFIG_ENCRYPTION_KEY` 时,SPA 只显示配置引导状态与缺失变量;服务状态确认 ready=true 后才显示管理会话授权区。`version` 是 `build.rs` 在编译期烘焙的 `BUILD_VERSION`(`<git-sha 或 nogit>+<UTC 构建时间>`),SPA 页脚据此确认部署是否真正落地。
- `POST /api/admin/session` 接受 `Authorization: Bearer <CONFIG_ENCRYPTION_KEY>`,返回 `{ "session": "<opaque>", "expires_in": 900 }`;admin session 仅存 Redis 中的摘要并在 900 秒后过期。`POST /api/admin/session/revoke` 撤销当前 session,成功返回 `204`。 - `POST /api/admin/session` 接受 `Authorization: Bearer <CONFIG_ENCRYPTION_KEY>`,返回 `{ "session": "<opaque>", "expires_in": 900 }`;admin session 仅存 Redis 中的摘要并在 900 秒后过期。`POST /api/admin/session/revoke` 撤销当前 session,成功返回 `204`。
@ -439,12 +439,12 @@ message-weave/
- `POST /api/bootstrap` 也接受**完整的** `BusinessConfigWire`(不做补丁合并),并共用上面「持久化与报错分离」的语义:校验失败是 `422`、持久化后构建失败仍是 `200` + warnings。因为不合并,它是唯一能**从零创建**业务配置的补丁路径(PUT 在已存配置缺失且补丁不全时返回 `422`,见上条)。 - `POST /api/bootstrap` 也接受**完整的** `BusinessConfigWire`(不做补丁合并),并共用上面「持久化与报错分离」的语义:校验失败是 `422`、持久化后构建失败仍是 `200` + warnings。因为不合并,它是唯一能**从零创建**业务配置的补丁路径(PUT 在已存配置缺失且补丁不全时返回 `422`,见上条)。
- `PUT /api/config` 的运行参数错误使用 `401`(未授权)、`400`(JSON 无效)、`422`(范围错误)、`503`(Redis 不可用);GET 读取失败时也以 `503` 表示 Redis 不可用。业务 PUT 使用 `401`(未授权)、`400`(JSON 无效)、`422`(补丁无法解析、已存配置不可解密、合并结果校验失败、首次保存时补丁不完整)、`409`(提交的 `revision` 已过期)、`500`(合并结果序列化失败)与 `503`(Redis 不可达、写入失败或 reload 提交失败);业务 GET 使用 `401`、`400`、`422`(已存配置不可解密)与 `503`(Redis 不可达)——注意 `Invalid` 是 422 不是 503,`Unreachable` 才是 503。**业务 PUT 有 `409`**:`revision` 是**请求体里的控制字段**(不是 header)——SPA 读配置时记下 `revision`,之后的每次增量提交都带回同一个值;已存 revision 与请求 revision 不等时返回 `409 conflict`,既不写入也不自增 revision,SPA 收到 `409` 会重新读回已存值再让操作者改,所以旧页面不会静默覆盖新的保存。请求体不带 `revision` 时保持 last-write-wins,兼容从不读 revision 的调用方。该比较是**先比较后写入**、非原子:两个请求若在同一瞬间竞争,两个都会通过、后到的覆盖先到的;没有 Lua 脚本时乐观锁的代价就是这个残留窗口,而补丁只带操作者实际改过的字段,窗口影响有限。`409 conflict` 的其余出现处是三把锁的竞争(`reconcile` 与 `register_push` / `register_worker_token` 的 `acquire_lock` 返回 false),与业务配置写入无关。依赖客户端构建失败**不**返回错误状态码,而是 `200` + warnings(见上条)。其余错误不回显敏感数据。Redis/会话初始化未完成时管理页面显示 `503` 状态;运行参数在重新读取成功前禁用保存(`C-REDIS-ONLY-STATE`)。LLM 启用时,业务配置要求提供 HTTPS Base URL、非空 API key 和模型名。 - `PUT /api/config` 的运行参数错误使用 `401`(未授权)、`400`(JSON 无效)、`422`(范围错误)、`503`(Redis 不可用);GET 读取失败时也以 `503` 表示 Redis 不可用。业务 PUT 使用 `401`(未授权)、`400`(JSON 无效)、`422`(补丁无法解析、已存配置不可解密、合并结果校验失败、首次保存时补丁不完整)、`409`(提交的 `revision` 已过期)、`500`(合并结果序列化失败)与 `503`(Redis 不可达、写入失败或 reload 提交失败);业务 GET 使用 `401`、`400`、`422`(已存配置不可解密)与 `503`(Redis 不可达)——注意 `Invalid` 是 422 不是 503,`Unreachable` 才是 503。**业务 PUT 有 `409`**:`revision` 是**请求体里的控制字段**(不是 header)——SPA 读配置时记下 `revision`,之后的每次增量提交都带回同一个值;已存 revision 与请求 revision 不等时返回 `409 conflict`,既不写入也不自增 revision,SPA 收到 `409` 会重新读回已存值再让操作者改,所以旧页面不会静默覆盖新的保存。请求体不带 `revision` 时保持 last-write-wins,兼容从不读 revision 的调用方。该比较是**先比较后写入**、非原子:两个请求若在同一瞬间竞争,两个都会通过、后到的覆盖先到的;没有 Lua 脚本时乐观锁的代价就是这个残留窗口,而补丁只带操作者实际改过的字段,窗口影响有限。`409 conflict` 的其余出现处是三把锁的竞争(`reconcile` 与 `register_push` / `register_worker_token` 的 `acquire_lock` 返回 false),与业务配置写入无关。依赖客户端构建失败**不**返回错误状态码,而是 `200` + warnings(见上条)。其余错误不回显敏感数据。Redis/会话初始化未完成时管理页面显示 `503` 状态;运行参数在重新读取成功前禁用保存(`C-REDIS-ONLY-STATE`)。LLM 启用时,业务配置要求提供 HTTPS Base URL、非空 API key 和模型名。
### 7.4 TLS (code/dependent behavior) ### 7.4 TLS(代码与依赖行为)
- TLS provider 由依赖 feature 决定:**jmap-client 0.4.2 默认含 `aws_lc_rs`(并引入 `rustls`)**,`default-features = true` 时并非"rustls 默认"。**本项目实际 `default-features = false, features = ["async","rustls"]`**(不启用 `aws_lc_rs`/`websockets`),最终以 `Cargo.toml` 为准。 - TLS provider 由依赖 feature 决定:**jmap-client 0.4.2 默认含 `aws_lc_rs`(并引入 `rustls`)**,`default-features = true` 时并非"rustls 默认"。**本项目实际 `default-features = false, features = ["async","rustls"]`**(不启用 `aws_lc_rs`/`websockets`),最终以 `Cargo.toml` 为准。
- 证书校验**默认强制**,仅测试可关。 - 证书校验**默认强制**,仅测试可关。
- 镜像内系统 CA 信任库、内部 CA 注入、入站 webhook TLS 终止等运维细节见 deployment.md。 - 镜像内系统 CA 信任库、内部 CA 注入、入站 webhook TLS 终止等运维细节见 deployment.md。
### 7.5 Production red line: no database / no local writing / standard output log (deployment.md §0/§8.2) ### 7.5 生产红线:无数据库、无本地写入、日志写入标准输出(deployment.md §0/§8.2)
本节汇总跨代码与部署的绝对红线,**不得**在代码中引入任何"看起来方便"的本地状态: 本节汇总跨代码与部署的绝对红线,**不得**在代码中引入任何"看起来方便"的本地状态:
@ -458,15 +458,15 @@ message-weave/
**验证**:静态检查代码不出现本地路径常量 / `std::fs` 非测试调用 / 文件日志后端;运行期通过容器 `/proc/mounts` 与 `docker inspect` 确认无本地卷挂载;部署检查清单见 deployment.md §8.2。 **验证**:静态检查代码不出现本地路径常量 / `std::fs` 非测试调用 / 文件日志后端;运行期通过容器 `/proc/mounts` 与 `docker inspect` 确认无本地卷挂载;部署检查清单见 deployment.md §8.2。
### 7.6 Why is the remote joint debugging interface absolutely closed by default? ### 7.6 为什么远程联调接口必须默认完全关闭?
该面存在的唯一理由是缩短生产排障路径:在无法登录容器、只能靠外部请求观察系统时,需要有人能在不发版的前提下探到 JMAP/Telegram 连通性与当前业务配置。代价是它的入口软度必然高于三条写路径——除只读探测外还保留一次**真实出站发送**,且复用业务白名单而非独立白名单。因此设计选择是「默认关闭」而不是「默认开启、靠网关挡」:进程满足「`DEBUG_ENABLED` 为真值或命令行带 `--debug`」**且**设置了非空 `DEBUG_TOKEN` 时才会挂载这组路由(`SAF-DEBUG-GATE`);缺一时路由在路由器里**根本不存在**,请求落到 axum 通用 `404`,而非「存在但 401」——后者会泄漏路由存在性。也不存在第三种状态:没有「未配置即开放」的回退,也没有任何配置项能把它设为默认开启。`DEBUG_ENABLED` 走环境变量而非启动命令,是为了让开关能在不发版的前提下从平台控制台改一次完成——启动命令保持静态。再叠一层位置约束:它不在网关的安全路由白名单内,即便后端开错,经平台入口也会被 fail-closed 拒掉,唯一可达路径是直连后端 origin。这四处——双因子挂载、404 而非 401、无默认开启回退、网关不可达——共同构成「暴露面默认为零」的架构决策。启用方式、逐端点状态码与部署确认清单见 deployment.md §2.1。 该面存在的唯一理由是缩短生产排障路径:在无法登录容器、只能靠外部请求观察系统时,需要有人能在不发版的前提下探到 JMAP/Telegram 连通性与当前业务配置。代价是它的入口软度必然高于三条写路径——除只读探测外还保留一次**真实出站发送**,且复用业务白名单而非独立白名单。因此设计选择是「默认关闭」而不是「默认开启、靠网关挡」:进程满足「`DEBUG_ENABLED` 为真值或命令行带 `--debug`」**且**设置了非空 `DEBUG_TOKEN` 时才会挂载这组路由(`SAF-DEBUG-GATE`);缺一时路由在路由器里**根本不存在**,请求落到 axum 通用 `404`,而非「存在但 401」——后者会泄漏路由存在性。也不存在第三种状态:没有「未配置即开放」的回退,也没有任何配置项能把它设为默认开启。`DEBUG_ENABLED` 走环境变量而非启动命令,是为了让开关能在不发版的前提下从平台控制台改一次完成——启动命令保持静态。再叠一层位置约束:它不在网关的安全路由白名单内,即便后端开错,经平台入口也会被 fail-closed 拒掉,唯一可达路径是直连后端 origin。这四处——双因子挂载、404 而非 401、无默认开启回退、网关不可达——共同构成「暴露面默认为零」的架构决策。启用方式、逐端点状态码与部署确认清单见 deployment.md §2.1。
--- ---
## 8. Error handling and observability ## 8. 错误处理与可观测性
### 8.1 Unified error enumeration ### 8.1 统一错误枚举
当前实现(`src/error.rs` 全文,4 个变体): 当前实现(`src/error.rs` 全文,4 个变体):
@ -488,7 +488,7 @@ pub enum BotError {
设计目标形态曾包含 `Jmap(#[from] jmap_client::Error)`、`Telegram(#[from] teloxide::errors::RequestError)`、`Storage(#[from] redis::RedisError)`、`RateLimited`、`Unauthorized(i64)`、`Llm(LlmErr)` 六个额外变体;`Telegram` 随 `teloxide` 未采用而废弃,其余至今未落地,完整清单见 `docs/retired.md`。 设计目标形态曾包含 `Jmap(#[from] jmap_client::Error)`、`Telegram(#[from] teloxide::errors::RequestError)`、`Storage(#[from] redis::RedisError)`、`RateLimited`、`Unauthorized(i64)`、`Llm(LlmErr)` 六个额外变体;`Telegram` 随 `teloxide` 未采用而废弃,其余至今未落地,完整清单见 `docs/retired.md`。
### 8.2 Error → User Message Mapping ### 8.2 错误到用户消息的映射
| 底层错误 | Bot 行为 | | 底层错误 | Bot 行为 |
|---|---| |---|---|
| `Transport` / `ServerUnavailable` / `RateLimit` | 指数退避重试(`util::retry`),超阈值给用户"暂时不可用,稍后重试" | | `Transport` / `ServerUnavailable` / `RateLimit` | 指数退避重试(`util::retry`),超阈值给用户"暂时不可用,稍后重试" |
@ -498,7 +498,7 @@ pub enum BotError {
| `StateMismatch` | 重置 sinceState → 全量补一次(防丢/防重) | | `StateMismatch` | 重置 sinceState → 全量补一次(防丢/防重) |
| `Llm(*)` | 见 §12.7(熔断/回退/徽标) | | `Llm(*)` | 见 §12.7(熔断/回退/徽标) |
### 8.3 Observability ### 8.3 可观测性
- `tracing`(直接依赖)+ `tracing-subscriber`(fmt + EnvFilter)。 - `tracing`(直接依赖)+ `tracing-subscriber`(fmt + EnvFilter)。
- `tracing`(直接依赖)+ `tracing-subscriber`(fmt + EnvFilter)。**当前只记录启动期事件**:`src/main.rs` 共 5 处 `info!`/`warn!`(启动变量缺失降级到 setup 模式、启动横幅、debug 端点开启、JMAP 服务不可用降级×2)。请求级事件(Push 回调、Reconcile 拉取、渠道推送、LLM 耗时)**尚未实现**——`src/` 里除 `main.rs` 外没有任何 tracing 调用,也没有命名 span(无 `#[instrument]` / `span!`)。 - `tracing`(直接依赖)+ `tracing-subscriber`(fmt + EnvFilter)。**当前只记录启动期事件**:`src/main.rs` 共 5 处 `info!`/`warn!`(启动变量缺失降级到 setup 模式、启动横幅、debug 端点开启、JMAP 服务不可用降级×2)。请求级事件(Push 回调、Reconcile 拉取、渠道推送、LLM 耗时)**尚未实现**——`src/` 里除 `main.rs` 外没有任何 tracing 调用,也没有命名 span(无 `#[instrument]` / `span!`)。
- 指标(可选 `metrics` crate):Push 回调到达数、去重命中率、Redis Streams 积压深度(pending)、DLQ 条数、对账补差条数、JMAP 请求延迟、推送失败率。**当前未接入任何指标后端**(`metrics` 不在 `Cargo.toml`)。 - 指标(可选 `metrics` crate):Push 回调到达数、去重命中率、Redis Streams 积压深度(pending)、DLQ 条数、对账补差条数、JMAP 请求延迟、推送失败率。**当前未接入任何指标后端**(`metrics` 不在 `Cargo.toml`)。
@ -507,9 +507,9 @@ pub enum BotError {
--- ---
## 9. Test strategy ## 9. 测试策略
### 9.1 Levels ### 9.1 测试层级
| 层 | 位置 | 内容 | | 层 | 位置 | 内容 |
|---|---|---| |---|---|---|
@ -520,13 +520,13 @@ pub enum BotError {
**没有集成测试目录**;前端唯一测试是 `web/config.test.mjs`(Node 原生断言)。 **没有集成测试目录**;前端唯一测试是 `web/config.test.mjs`(Node 原生断言)。
### 9.2 mock strategy ### 9.2 mock 策略
- `JmapService` 持有 trait `JmapBackend`(`list_emails` / `read_email` / `send_email` / `changes` 等),生产实现包装 `jmap_client::Client`(`src/domain/jmap/client.rs`),测试用 `MockBackend`(`src/domain/jmap.rs`)。 - `JmapService` 持有 trait `JmapBackend`(`list_emails` / `read_email` / `send_email` / `changes` 等),生产实现包装 `jmap_client::Client`(`src/domain/jmap/client.rs`),测试用 `MockBackend`(`src/domain/jmap.rs`)。
- Telegram 出站不引入任何框架 mock:`src/channel.rs` 起一个进程内 `TcpListener`,用 `AtomicUsize` 记录请求次数,验证 URL、状态码与超时/重试行为。 - Telegram 出站不引入任何框架 mock:`src/channel.rs` 起一个进程内 `TcpListener`,用 `AtomicUsize` 记录请求次数,验证 URL、状态码与超时/重试行为。
- `src/ai.rs` 通过构造 `LlmClient` 的配置断言 HTTPS 强制(`llm_requires_https`);摘要逻辑在 mock OpenAI 兼容端点上验证。 - `src/ai.rs` 通过构造 `LlmClient` 的配置断言 HTTPS 强制(`llm_requires_https`);摘要逻辑在 mock OpenAI 兼容端点上验证。
- 渠道与领域解耦靠类型约束(`domain.rs` 的公开接口不出现渠道 SDK 类型),不依赖 mock channel。 - 渠道与领域解耦靠类型约束(`domain.rs` 的公开接口不出现渠道 SDK 类型),不依赖 mock channel。
### 9.3 Key invariant testing ### 9.3 关键不变量测试
- `sinceState` 存外部 Redis:模拟重启后从正确游标续传;模拟 Redis 清空后由对账(`reconcile`,`FLOW-RECONCILE`)从 JMAP 恢复游标。 - `sinceState` 存外部 Redis:模拟重启后从正确游标续传;模拟 Redis 清空后由对账(`reconcile`,`FLOW-RECONCILE`)从 JMAP 恢复游标。
- 通知去重:同一 `email_id` 不重复推送。 - 通知去重:同一 `email_id` 不重复推送。
- 正文转义:含 `<script>` 的邮件正文在 HTML 模式下被转义。 - 正文转义:含 `<script>` 的邮件正文在 HTML 模式下被转义。
@ -545,11 +545,11 @@ pub enum BotError {
--- ---
## 10. Phased implementation plan ## 10. 分阶段实施计划
> 前提:先 `rustup` 装工具链(stable)。CI 与发布流见 deployment.md。 > 前提:先 `rustup` 装工具链(stable)。CI 与发布流见 deployment.md。
### 10.0 Phase 0: Scaffolding and HTTPS entry skeleton (completed, `GATE-P0` passed) ### 10.0 阶段 0:脚手架与 HTTPS 入口骨架(已完成,`GATE-P0` 通过)
> **当前实际边界**:单端口 axum 入口提供 `/webhook/tg`、`/push/jmap`、`/reconcile`、`/healthz`、`/ready`。三条写路径的入口鉴权已 fail-closed 落地(`R1`/`SAF-AUTH-*`);`/reconcile` 已使用 JMAP `Email/changes` 分页、Redis `state:jmap:since` 和 Redis 单飞锁,只有全部事件入队成功后才推进游标;`/healthz` 为 liveness,`/ready` 做端到端探测(配置 + Redis + 出站只读探测,最坏约 3s),但不覆盖真实消息投递验收。 > **当前实际边界**:单端口 axum 入口提供 `/webhook/tg`、`/push/jmap`、`/reconcile`、`/healthz`、`/ready`。三条写路径的入口鉴权已 fail-closed 落地(`R1`/`SAF-AUTH-*`);`/reconcile` 已使用 JMAP `Email/changes` 分页、Redis `state:jmap:since` 和 Redis 单飞锁,只有全部事件入队成功后才推进游标;`/healthz` 为 liveness,`/ready` 做端到端探测(配置 + Redis + 出站只读探测,最坏约 3s),但不覆盖真实消息投递验收。
@ -595,7 +595,7 @@ pub enum BotError {
- **仍需注意**:本地 CI 门禁通过不等于真实 Stalwart、Redis、Telegram 链路已验收;公网部署前应完成真实端到端测试和外部监控配置。 - **仍需注意**:本地 CI 门禁通过不等于真实 Stalwart、Redis、Telegram 链路已验收;公网部署前应完成真实端到端测试和外部监控配置。
- 阶段1 只做 JMAP 只读(`list_folders`/`list_emails`/`read_email`),不引入发送/推送/AI。 - 阶段1 只做 JMAP 只读(`list_folders`/`list_emails`/`read_email`),不引入发送/推送/AI。
### 10.1 Phase 1: JMAP Read Only (1.5d) ### 10.1 阶段 1:JMAP 只读(1.5 天)
> 前置:`GATE-P0` 全项通过(`BOUND-STAGE1`)。 > 前置:`GATE-P0` 全项通过(`BOUND-STAGE1`)。
@ -606,7 +606,7 @@ pub enum BotError {
- `#[ignore]` 真实测试(`GATE-G1-JMAP-READONLY`):经**环境变量**驱动,运行命令 `cargo test -- --ignored jmap::`(`--ignored` 是 libtest 参数,必须置于 `--` 之后);仅编译不执行用 `cargo test --no-run`(编译含 `#[ignore]` 的测试目标)。**缺环境时清晰跳过且不泄露任何密钥**;CI 默认不跑真机用例,mock 测试继续保留。**真机用例通过与否须以实际 `--ignored` 运行为准——不得在未运行的情况下声称"真机通过"。** - `#[ignore]` 真实测试(`GATE-G1-JMAP-READONLY`):经**环境变量**驱动,运行命令 `cargo test -- --ignored jmap::`(`--ignored` 是 libtest 参数,必须置于 `--` 之后);仅编译不执行用 `cargo test --no-run`(编译含 `#[ignore]` 的测试目标)。**缺环境时清晰跳过且不泄露任何密钥**;CI 默认不跑真机用例,mock 测试继续保留。**真机用例通过与否须以实际 `--ignored` 运行为准——不得在未运行的情况下声称"真机通过"。**
- 验收:`cargo test`(mock)全绿;`cargo test -- --ignored jmap::`(有真机环境时)能连真实服务器列文件夹/邮件并读原文(未实际运行前不得声称已通过)。 - 验收:`cargo test`(mock)全绿;`cargo test -- --ignored jmap::`(有真机环境时)能连真实服务器列文件夹/邮件并读原文(未实际运行前不得声称已通过)。
### 10.2 Phase 2: Channel Adaptation (Completed) ### 10.2 阶段 2:渠道适配(已完成)
- `src/channel.rs` 实现 `TelegramClient`(`send_text` / `send_notification`,`reqwest` 自研);领域类型 `Notification` 在 `src/domain.rs`。 - `src/channel.rs` 实现 `TelegramClient`(`send_text` / `send_notification`,`reqwest` 自研);领域类型 `Notification` 在 `src/domain.rs`。
- Telegram 装配用 `reqwest` 直发 `https://api.telegram.org/bot{token}/sendMessage`(**未引入 teloxide**;评估记录见 `docs/retired.md`)。 - Telegram 装配用 `reqwest` 直发 `https://api.telegram.org/bot{token}/sendMessage`(**未引入 teloxide**;评估记录见 `docs/retired.md`)。
- 入站:`POST /webhook/tg` 校验 secret token → 按 `update_id` 去重(`dedup:tg:{update_id}`)→ 入 Redis Streams → 2xx;出站为 Telegram 唯一用到的 Bot API 端点。 - 入站:`POST /webhook/tg` 校验 secret token → 按 `update_id` 去重(`dedup:tg:{update_id}`)→ 入 Redis Streams → 2xx;出站为 Telegram 唯一用到的 Bot API 端点。
@ -615,16 +615,16 @@ pub enum BotError {
- 会话存储走外部 Redis(`C-REDIS-ONLY-STATE`;**不用 SQLite**)。 - 会话存储走外部 Redis(`C-REDIS-ONLY-STATE`;**不用 SQLite**)。
- 验收:本地 webhook 形态,测试客户端发消息能得到回复;领域模块不导入任何渠道 SDK 类型。 - 验收:本地 webhook 形态,测试客户端发消息能得到回复;领域模块不导入任何渠道 SDK 类型。
### 10.3 Phase 3: Sending Email + Status Management (**Not implemented**) ### 10.3 阶段 3:发送邮件与状态管理(**尚未实现**)
- `send_email`(draft + submission_set)**当前未实现**:`JmapBackend` 只有只读动词。 - `send_email`(draft + submission_set)**当前未实现**:`JmapBackend` 只有只读动词。
- 发信流程原本计划用多步 FSM 收集 to/subject/body,**从未实现**(见 `docs/retired.md` 的对话 FSM 一节)。 - 发信流程原本计划用多步 FSM 收集 to/subject/body,**从未实现**(见 `docs/retired.md` 的对话 FSM 一节)。
- `/flag /unseen` 关键词标记**未实现**。 - `/flag /unseen` 关键词标记**未实现**。
- 阶段 3 属**未实施范围**,不是缺口:`docs/opengaps.md` 只登记「仍未完成 / 仍未验证」的事项,未排期进入实施的未来阶段不在此列。 - 阶段 3 属**未实施范围**,不是缺口:`docs/opengaps.md` 只登记「仍未完成 / 仍未验证」的事项,未排期进入实施的未来阶段不在此列。
### 10.4 Phase 3.5: LLM facade + rollback (1.5d, not executed) ### 10.4 阶段 3.5:LLM 门面与回退(1.5 天,尚未执行)
未实施,见 `docs/retired.md`。 未实施,见 `docs/retired.md`。
### 10.5 Implemented capabilities: real-time push (Push callback + Streams worker + external Cron reconciliation) ### 10.5 已实现能力:实时推送(推送回调 + Streams 工作进程 + 外部 Cron 定时协调)
`src/notify.rs` 是**单个近 2000 行的平铺文件**,没有子模块(下表中不存在 `notify::push_handler` / `notify::worker` / `notify::reconcile` / `mod_streams` / `mod_sincestate` 这些模块名,对应实现是文件内的自由函数): `src/notify.rs` 是**单个近 2000 行的平铺文件**,没有子模块(下表中不存在 `notify::push_handler` / `notify::worker` / `notify::reconcile` / `mod_streams` / `mod_sincestate` 这些模块名,对应实现是文件内的自由函数):
- `register_push`(`POST /api/push/register`):接受显式 HTTPS callback URL 并创建订阅;收到 Stalwart 推送时用 JMAP `PushSubscription/set` 回写 `verificationCode`。 - `register_push`(`POST /api/push/register`):接受显式 HTTPS callback URL 并创建订阅;收到 Stalwart 推送时用 JMAP `PushSubscription/set` 回写 `verificationCode`。
- `jmap_push`(`POST /push/jmap`):校验 subscription ID + verificationCode → 去重 → 入 Redis Streams → 2xx。 - `jmap_push`(`POST /push/jmap`):校验 subscription ID + verificationCode → 去重 → 入 Redis Streams → 2xx。
@ -633,7 +633,7 @@ pub enum BotError {
- sinceState 写外部 Redis(`state.rs`,`C-REDIS-ONLY-STATE`;不用 SQLite/文件 `NG-SQLITE-PERSIST`/`NG-LOCAL-VOLUME`)。 - sinceState 写外部 Redis(`state.rs`,`C-REDIS-ONLY-STATE`;不用 SQLite/文件 `NG-SQLITE-PERSIST`/`NG-LOCAL-VOLUME`)。
- 目标验收:向 Stalwart 发测试邮件,经 Push 回调 + worker,Telegram 在 ~秒级收到推送;重启不重发;清空 Redis 后由对账补发。当前代码门禁已通过,但仍需在真实 Stalwart、Redis 和 Telegram 环境完成端到端验证,不能将此处目标当作已验证事实。 - 目标验收:向 Stalwart 发测试邮件,经 Push 回调 + worker,Telegram 在 ~秒级收到推送;重启不重发;清空 Redis 后由对账补发。当前代码门禁已通过,但仍需在真实 Stalwart、Redis 和 Telegram 环境完成端到端验证,不能将此处目标当作已验证事实。
### 10.5.1 Redis Streams event guarantee boundary (FLOW run semantics) ### 10.5.1 Redis Streams 事件保证边界(FLOW 运行语义)
Push 事件经 Streams 消费并投递到 Telegram,其关键路径交付语义如下: Push 事件经 Streams 消费并投递到 Telegram,其关键路径交付语义如下:
@ -673,29 +673,29 @@ Push 事件经 Streams 消费并投递到 Telegram,其关键路径交付语义
- 其余低风险项均已收口:未知 stream 的空值改为 `Err`(fail-closed,进重试/DLQ);`push:disable` 经 `forget_push_subscription` 清理验证码摘要键;`SET NX EX` TTL 下限收紧为 `.max(1)`;XAUTOCLAIM 空闲阈值按批大小缩放;无 payload 的畸形流条目由 `ack_malformed` 经 `XACK` 移出 PEL;CSPRNG 兜底 owner-token 改为「时间 + PID + 计数器」,不再使用常量。 - 其余低风险项均已收口:未知 stream 的空值改为 `Err`(fail-closed,进重试/DLQ);`push:disable` 经 `forget_push_subscription` 清理验证码摘要键;`SET NX EX` TTL 下限收紧为 `.max(1)`;XAUTOCLAIM 空闲阈值按批大小缩放;无 payload 的畸形流条目由 `ack_malformed` 经 `XACK` 移出 PEL;CSPRNG 兜底 owner-token 改为「时间 + PID + 计数器」,不再使用常量。
- 未排期待办(阶段 5「搜索 + 搜索片段」)**已收口(`bfe0fd8`)**:`/search` 走 `email_query`(`Filter::text`) + `SearchSnippet/get`,高亮降级与截断上限见 §2.3、§5.7;正文级高亮在锁定版本做不到(见 §2.3 备注)。阶段 5 待办已清零。 - 未排期待办(阶段 5「搜索 + 搜索片段」)**已收口(`bfe0fd8`)**:`/search` 走 `email_query`(`Filter::text`) + `SearchSnippet/get`,高亮降级与截断上限见 §2.3、§5.7;正文级高亮在锁定版本做不到(见 §2.3 备注)。阶段 5 待办已清零。
### 10.6 Total estimated time ### 10.6 总体时间估算
~10.5 人日(不含等待用户确认与真实联调排障)。 ~10.5 人日(不含等待用户确认与真实联调排障)。
--- ---
## 11. Archive of historical issues and decisions (product/architecture category, all have conclusions) ## 11. 历史问题与决策归档(产品/架构类,均已有结论)
> ⚠️ 部署/平台类决策已**全部确认**(单账户 / App Password+Basic / Redis 托管+AOF / 平台 HTTPS URL / 外部 Cron 对账 / `Q-DEP-A` 平台 URL 与证书配置方 / `Q-DEP-B` 调度器选型),已归档到 `docs/deployment.md` 的已确认决策一节,不在本文重复。 > ⚠️ 部署/平台类决策已**全部确认**(单账户 / App Password+Basic / Redis 托管+AOF / 平台 HTTPS URL / 外部 Cron 对账 / `Q-DEP-A` 平台 URL 与证书配置方 / `Q-DEP-B` 调度器选型),已归档到 `docs/deployment.md` 的已确认决策一节,不在本文重复。
### 11.1 Authentication method (confirmed) ### 11.1 认证方式(已确认)
- **已确认**:**App Password + Basic**(`C-AUTH-APP-BASIC`)。不用主密码、不用 OAuth2 Bearer(无 OIDC 需求)。 - **已确认**:**App Password + Basic**(`C-AUTH-APP-BASIC`)。不用主密码、不用 OAuth2 Bearer(无 OIDC 需求)。
- 影响:`Credentials::Basic` 构造;无需 OAuth client / token 自动刷新模块。 - 影响:`Credentials::Basic` 构造;无需 OAuth client / token 自动刷新模块。
### 11.2 Real-time channel (decided, see deployment.md) ### 11.2 实时通信渠道(已决定,参见 deployment.md)
- 通道 = **JMAP Push HTTPS 回调 + 外部 Cron 对账与排空**(`C-NO-LONG-CONN`/`C-HTTPS-INBOUND`);EventSource/SSE/WebSocket 均**非目标**(`NG-POLLING-SSE`)。Push 注册通过受保护的 `POST /api/push/register` 显式触发;外部 Cron 必须同时调 `/reconcile`(增量入队)与 `/worker`(排空并发通知)——**只调对账会让通知永远发不出去**(deployment.md §6.3.1)。 - 通道 = **JMAP Push HTTPS 回调 + 外部 Cron 对账与排空**(`C-NO-LONG-CONN`/`C-HTTPS-INBOUND`);EventSource/SSE/WebSocket 均**非目标**(`NG-POLLING-SSE`)。Push 注册通过受保护的 `POST /api/push/register` 显式触发;外部 Cron 必须同时调 `/reconcile`(增量入队)与 `/worker`(排空并发通知)——**只调对账会让通知永远发不出去**(deployment.md §6.3.1)。
- Stalwart 侧接入已在真实环境验证:内置角色具备 `PushSubscription` 权限,注册、验证往返与真实回调投递全部走通。回调重试次数与幂等键 TTL / 对账间隔的匹配属于运维调参项,不是验收门槛:`docs/deployment.md` §5 给重试建议、§6.3.1 给对账间隔取值、§7 说明幂等键 TTL 必须覆盖 TG rate-limit 回退上限。`docs/opengaps.md` 当前无未完成项。 - Stalwart 侧接入已在真实环境验证:内置角色具备 `PushSubscription` 权限,注册、验证往返与真实回调投递全部走通。回调重试次数与幂等键 TTL / 对账间隔的匹配属于运维调参项,不是验收门槛:`docs/deployment.md` §5 给重试建议、§6.3.1 给对账间隔取值、§7 说明幂等键 TTL 必须覆盖 TG rate-limit 回退上限。`docs/opengaps.md` 当前无未完成项。
### 11.3 Deployment form (confirmed, architecture related) ### 11.3 部署形式(已确认,架构相关)
- **已确认**:**单账户实现**(`REQ-SINGLE-ACCOUNT`);多账户暂用**多个 bot 实例**(各自 token/配置),**不做多账户单实例**(因此无需 chat→account 路由与 `JmapService` 池化)。2026-09-28 用户已就此决策,决定边界与将来若要做时的改动面登记在 `docs/opengaps.md` §3。 - **已确认**:**单账户实现**(`REQ-SINGLE-ACCOUNT`);多账户暂用**多个 bot 实例**(各自 token/配置),**不做多账户单实例**(因此无需 chat→account 路由与 `JmapService` 池化)。2026-09-28 用户已就此决策,决定边界与将来若要做时的改动面登记在 `docs/opengaps.md` §3。
- 部署形态 = **webhook-only + 通用 HTTPS-only Docker + 外部 Cron 对账**(deployment.md `C-NO-LONG-CONN`/`NG-LONG-POLLING`/`NFR-RECONCILE-INTERVAL`)。 - 部署形态 = **webhook-only + 通用 HTTPS-only Docker + 外部 Cron 对账**(deployment.md `C-NO-LONG-CONN`/`NG-LONG-POLLING`/`NFR-RECONCILE-INTERVAL`)。
- **多实例 LB/HA(已确认,`ARCH-LB-WORKER`)**:可选在多个 serverless 平台部署同镜像、共享同一 Redis,前置免费 Cloudflare Worker 做唯一入口与故障转移;Worker 代码位于子目录 [`cloudflare-worker/`](../cloudflare-worker/)(非本 Rust 二进制),部署见 `docs/deployment.md` 的 Worker 部署一节。信任模型为**透传**(后端仍 fail-closed 校验,`SAF-LB-PASSTHRU`),后端间**共享同一组 secret**(`C-LB-SHARED-SECRETS`),`/reconcile` **Redis 锁**单实例(`SAF-RECONCILE-LOCK`),Worker 提供**聚合健康视图**(`MOD-HEALTH-AGG`)。**Redis 单点故障不在本方案范围**(`NFR-HA-MULTI-INSTANCE`,用户外部解决)。双活或主备均可。详见 `docs/deployment.md` 的多实例部署一节。 - **多实例 LB/HA(已确认,`ARCH-LB-WORKER`)**:可选在多个 serverless 平台部署同镜像、共享同一 Redis,前置免费 Cloudflare Worker 做唯一入口与故障转移;Worker 代码位于子目录 [`cloudflare-worker/`](../cloudflare-worker/)(非本 Rust 二进制),部署见 `docs/deployment.md` 的 Worker 部署一节。信任模型为**透传**(后端仍 fail-closed 校验,`SAF-LB-PASSTHRU`),后端间**共享同一组 secret**(`C-LB-SHARED-SECRETS`),`/reconcile` **Redis 锁**单实例(`SAF-RECONCILE-LOCK`),Worker 提供**聚合健康视图**(`MOD-HEALTH-AGG`)。**Redis 单点故障不在本方案范围**(`NFR-HA-MULTI-INSTANCE`,用户外部解决)。双活或主备均可。详见 `docs/deployment.md` 的多实例部署一节。
### 11.4 Early questions answered by code (no longer pending confirmation) ### 11.4 已由代码回答的早期问题(无需再确认)
以下问题在设计阶段以 Q6–Q30 形式列出,**代码落地时已各自给出答案**,因此不再是"待确认项",此处只记结论: 以下问题在设计阶段以 Q6–Q30 形式列出,**代码落地时已各自给出答案**,因此不再是"待确认项",此处只记结论:
- **消息格式**(Q6):出站只有 `send_text`(`src/channel.rs:114`),其载荷结构体 `SendMessage`(`src/channel.rs:60`)只有 `chat_id` + `text` 两个字段,**没有 `parse_mode`**——Telegram 收到后按纯文本渲染,既不用 HTML 也不用 MarkdownV2。 - **消息格式**(Q6):出站只有 `send_text`(`src/channel.rs:114`),其载荷结构体 `SendMessage`(`src/channel.rs:60`)只有 `chat_id` + `text` 两个字段,**没有 `parse_mode`**——Telegram 收到后按纯文本渲染,既不用 HTML 也不用 MarkdownV2。
- **长邮件 / 原文直发**(Q7、Q29):不存在"正文超过 4000 字符不发全文"的策略。`read_email` 拉全文送 LLM;AI 失败时回退为前 300 字符且**无"已截断"标注**(见 §12.3/§12.5)。 - **长邮件 / 原文直发**(Q7、Q29):不存在"正文超过 4000 字符不发全文"的策略。`read_email` 拉全文送 LLM;AI 失败时回退为前 300 字符且**无"已截断"标注**(见 §12.3/§12.5)。
@ -712,7 +712,7 @@ Push 事件经 Streams 消费并投递到 Telegram,其关键路径交付语义
--- ---
## 12. AI auxiliary capabilities: architecture, confirmation threshold, failure fallback ## 12. AI 辅助能力:架构、确认门槛与失败回退
> **用户已确认的 7 项需求(本节据此设计,后续所有表述以此为准)**: > **用户已确认的 7 项需求(本节据此设计,后续所有表述以此为准)**:
> 1. **AI 仅在被明确要求并确认后接触正文**(`REQ-AI-CONFIRM` / `REQ-AI-EXTERNAL-CONSENT`):只有当用户明确发起"分析/总结/翻译"意图**并再次确认**后,才允许把正文喂给 LLM;**仅当用户明确允许时才向外部 AI 发送邮件正文**(默认不外发);其余场景(包括查看原文)**一律不经 AI**,由 JMAP 直取。 > 1. **AI 仅在被明确要求并确认后接触正文**(`REQ-AI-CONFIRM` / `REQ-AI-EXTERNAL-CONSENT`):只有当用户明确发起"分析/总结/翻译"意图**并再次确认**后,才允许把正文喂给 LLM;**仅当用户明确允许时才向外部 AI 发送邮件正文**(默认不外发);其余场景(包括查看原文)**一律不经 AI**,由 JMAP 直取。
@ -725,7 +725,7 @@ Push 事件经 Streams 消费并投递到 Telegram,其关键路径交付语义
> >
> 本节仅做设计,不写业务代码。 > 本节仅做设计,不写业务代码。
### 12.1 Module division (current implementation) ### 12.1 模块划分(当前实现)
LLM 相关代码只有**一个文件**: LLM 相关代码只有**一个文件**:
``` ```
src/ai.rs # LlmClient(唯一实现):summarize() 打 OpenAI 兼容 /chat/completions src/ai.rs # LlmClient(唯一实现):summarize() 打 OpenAI 兼容 /chat/completions
@ -734,7 +734,7 @@ src/ai.rs # LlmClient(唯一实现):summarize() 打 OpenAI 兼容 /chat/
LLM 能力只存在于 `src/ai.rs` 一个文件;**没有**配置 / 回退 / 策略 / 审计分层——熔断、规则回退、审计 span 全部未实现(见 §12.4–12.7)。 LLM 能力只存在于 `src/ai.rs` 一个文件;**没有**配置 / 回退 / 策略 / 审计分层——熔断、规则回退、审计 span 全部未实现(见 §12.4–12.7)。
`summarize()` 只有**一个**调用点:`worker.rs` 的 `/summary` 已授权摘要分支。新邮件通知**不调用 LLM** —— `send_notification` 只渲染 `From:` / `Subject:` / `Received:` 三行元数据,正文永不外发(见 docs/reference.md §5.4)。 `summarize()` 只有**一个**调用点:`worker.rs` 的 `/summary` 已授权摘要分支。新邮件通知**不调用 LLM** —— `send_notification` 只渲染 `From:` / `Subject:` / `Received:` 三行元数据,正文永不外发(见 docs/reference.md §5.4)。
### 12.2 LLM variable list (current implementation) ### 12.2 LLM 变量清单(当前实现)
> 下表 `LLM_*` 名称**已不是环境变量**:`Config::from_env()` 删除后代码对它们零读取, > 下表 `LLM_*` 名称**已不是环境变量**:`Config::from_env()` 删除后代码对它们零读取,
> 全部经 Redis 业务配置装载(§7.1)。表中「默认」为 `config` 层构造器内的回落值。 > 全部经 Redis 业务配置装载(§7.1)。表中「默认」为 `config` 层构造器内的回落值。
@ -751,7 +751,7 @@ LLM 能力只存在于 `src/ai.rs` 一个文件;**没有**配置 / 回退 /
**不存在** `LLM_TEMPERATURE` / `LLM_MAX_TOKENS` / `LLM_TIMEOUT_SECS` / `LLM_MAX_RETRIES`:请求体只有 `model` / `messages` / `max_tokens`(= `max_chars * 2`),没有 temperature,也没有独立于 `llm_timeout_ms` 的超时开关。API key 只从配置读,不入源码、不打日志(`SAF-LOG-PURITY`),运行期 secret 注入方式见 deployment.md(`C-NO-SECRET-IN-IMAGE`)。 **不存在** `LLM_TEMPERATURE` / `LLM_MAX_TOKENS` / `LLM_TIMEOUT_SECS` / `LLM_MAX_RETRIES`:请求体只有 `model` / `messages` / `max_tokens`(= `max_chars * 2`),没有 temperature,也没有独立于 `llm_timeout_ms` 的超时开关。API key 只从配置读,不入源码、不打日志(`SAF-LOG-PURITY`),运行期 secret 注入方式见 deployment.md(`C-NO-SECRET-IN-IMAGE`)。
### 12.3 Text acquisition strategy and long email processing (requirement 1/2/3/5) ### 12.3 文本获取策略与长邮件处理(需求 1/2/3/5)
- **查看原文 = JMAP 直取**:`Intent::Query` 与任何"看正文"动作走 `JmapService::read_email`,**不经 LLM**;LLM 不在查看路径上。 - **查看原文 = JMAP 直取**:`Intent::Query` 与任何"看正文"动作走 `JmapService::read_email`,**不经 LLM**;LLM 不在查看路径上。
- **AI 接触正文的前置门槛**:只有当用户发起明确的"分析/总结/翻译"意图**并再次确认**(例如点 `[AI 总结]` 按钮 / 执行 `/summarize`)后,才把正文作为 LLM 输入。未经确认 → LLM 看不到正文。 - **AI 接触正文的前置门槛**:只有当用户发起明确的"分析/总结/翻译"意图**并再次确认**(例如点 `[AI 总结]` 按钮 / 执行 `/summarize`)后,才把正文作为 LLM 输入。未经确认 → LLM 看不到正文。
- **没有"长邮件保护"逻辑**:不存在 4000 字符阈值、不存在 `Preview` 类型、不存在 `[继续查看原文]` 按钮。授权后的摘要路径就是把**全文**交给 LLM,LLM 失败时回退为前 300 字符,全程无截断标注。 - **没有"长邮件保护"逻辑**:不存在 4000 字符阈值、不存在 `Preview` 类型、不存在 `[继续查看原文]` 按钮。授权后的摘要路径就是把**全文**交给 LLM,LLM 失败时回退为前 300 字符,全程无截断标注。
@ -759,42 +759,42 @@ LLM 能力只存在于 `src/ai.rs` 一个文件;**没有**配置 / 回退 /
- **附件功能完全未实现**:`src/domain/jmap.rs` 明确注释"no mutation, attachment, AI, or streaming APIs",`read_email` 只返回正文文本;领域模型里唯一的附件信息是 `has_attachment: bool`。Telegram 出站只调 `sendMessage`,**不存在** `send_document` / `Blob/get` 流式下载 / 下载按钮。 - **附件功能完全未实现**:`src/domain/jmap.rs` 明确注释"no mutation, attachment, AI, or streaming APIs",`read_email` 只返回正文文本;领域模型里唯一的附件信息是 `has_attachment: bool`。Telegram 出站只调 `sendMessage`,**不存在** `send_document` / `Blob/get` 流式下载 / 下载按钮。
- AI 摘要的输入只有邮件正文文本,没有附件通道。 - AI 摘要的输入只有邮件正文文本,没有附件通道。
### 12.3.1 AI authorization period and Redis TTL (`REQ-AI-CONSENT`) ### 12.3.1 AI 授权时限与 Redis TTL(`REQ-AI-CONSENT`)
- AI 授权必须由用户明确选择期限;可选 `临时一次`(一次授权窗口,3600 秒)、今天(86400 秒)、7 天(604800 秒)或直到撤销(最长 365 天,即 31536000 秒)。触发词按原文包含匹配(`临时`/`一次`/`今天`/`7天`/`直到我撤销`/`长期`,以及 `/ai on`、`/ai yes`、`/ai off`),不支持英文别名。 - AI 授权必须由用户明确选择期限;可选 `临时一次`(一次授权窗口,3600 秒)、今天(86400 秒)、7 天(604800 秒)或直到撤销(最长 365 天,即 31536000 秒)。触发词按原文包含匹配(`临时`/`一次`/`今天`/`7天`/`直到我撤销`/`长期`,以及 `/ai on`、`/ai yes`、`/ai off`),不支持英文别名。
- Redis 的 consent key 仅保存 chat id 对应的到期 Unix 时间,并使用**与所选期限完全相同**的 `SET EX` 秒数;不使用隐式 30 天默认值,也不保存正文或摘要。 - Redis 的 consent key 仅保存 chat id 对应的到期 Unix 时间,并使用**与所选期限完全相同**的 `SET EX` 秒数;不使用隐式 30 天默认值,也不保存正文或摘要。
- 到期由 Redis TTL 和读取时的到期校验共同保证,摘要请求回到元数据模式并提示“授权已到期”;用户重新选择期限后才可再次授权。`/ai off` 立即删除 key。 - 到期由 Redis TTL 和读取时的到期校验共同保证,摘要请求回到元数据模式并提示“授权已到期”;用户重新选择期限后才可再次授权。`/ai off` 立即删除 key。
### 12.4 Failure detection (current implementation) ### 12.4 故障检测(当前实现)
`LlmClient::summarize`(`src/ai.rs`)**只有重试,没有熔断**: `LlmClient::summarize`(`src/ai.rs`)**只有重试,没有熔断**:
- 超时 `llm_timeout_ms`、重试次数 `max_retries`(单位、默认值、下限与硬上限均见 `docs/reference.md` §6.1;两项与 Telegram 出站共用同一配置)。 - 超时 `llm_timeout_ms`、重试次数 `max_retries`(单位、默认值、下限与硬上限均见 `docs/reference.md` §6.1;两项与 Telegram 出站共用同一配置)。
- 只对上一次的失败重试:429 与 5xx 会重试;**其他 4xx(含 401/403)立即返回错误,不重试**。 - 只对上一次的失败重试:429 与 5xx 会重试;**其他 4xx(含 401/403)立即返回错误,不重试**。
- 重试耗尽或全部超时 → `Err(AiError::Response)`;URL 解析失败或非 https → `Err(AiError::InvalidEndpoint)`;JSON 反序列化失败或缺 `choices[0].message.content` → `Err(AiError::Response)`。 - 重试耗尽或全部超时 → `Err(AiError::Response)`;URL 解析失败或非 https → `Err(AiError::InvalidEndpoint)`;JSON 反序列化失败或缺 `choices[0].message.content` → `Err(AiError::Response)`。
- `AiError` 只有三个变体:`InvalidEndpoint` / `Request` / `Response`。**不存在** 429/401/Timeout 的细分类型。 - `AiError` 只有三个变体:`InvalidEndpoint` / `Request` / `Response`。**不存在** 429/401/Timeout 的细分类型。
### 12.5 Non-AI fallback (current implementation) ### 12.5 非 AI 回退(当前实现)
`worker.rs` 的 `fallback()` 就是**取正文前 300 个字符**,仅此而已: `worker.rs` 的 `fallback()` 就是**取正文前 300 个字符**,仅此而已:
- AI 授权有效且 LLM 调用失败 → 静默换成这 300 字符,**不提示用户**、不加"AI 不可用"标记。 - AI 授权有效且 LLM 调用失败 → 静默换成这 300 字符,**不提示用户**、不加"AI 不可用"标记。
- AI 未授权 → 不发摘要,回一条 `发件人 — 主题` + "授权已到期或尚未授权"。 - AI 未授权 → 不发摘要,回一条 `发件人 — 主题` + "授权已到期或尚未授权"。
- 因此不存在熔断半开态、不存在确认回退的 inline-button、不存在 60s 冷却期、不存在写进 Redis 的熔断计数(Redis 上没有任何 LLM 相关状态键)。 - 因此不存在熔断半开态、不存在确认回退的 inline-button、不存在 60s 冷却期、不存在写进 Redis 的熔断计数(Redis 上没有任何 LLM 相关状态键)。
### 12.6 Stateless/Containerized Adaptation ### 12.6 无状态化与容器化适配
- 摘要**不持久化、不缓存**:`worker.rs` 每次即时调用 LLM,结果不写 Redis、不落盘。与 `C-REDIS-ONLY-STATE` 一致。 - 摘要**不持久化、不缓存**:`worker.rs` 每次即时调用 LLM,结果不写 Redis、不落盘。与 `C-REDIS-ONLY-STATE` 一致。
- 日志不记 prompt 明文与 API key(`SAF-LOG-PURITY`)——**但当前日志也不记任何 LLM 调用事件**(见 §8.3)。 - 日志不记 prompt 明文与 API key(`SAF-LOG-PURITY`)——**但当前日志也不记任何 LLM 调用事件**(见 §8.3)。
- 生效的运行参数只有 `llm_timeout_ms`、`max_retries`、`LLM_SUMMARY_TARGET_CHARS`(对应 `LlmClient::max_chars`,对摘要输出截断)。**不存在** `LLM_MAX_RETRIES` 这个环境变量。 - 生效的运行参数只有 `llm_timeout_ms`、`max_retries`、`LLM_SUMMARY_TARGET_CHARS`(对应 `LlmClient::max_chars`,对摘要输出截断)。**不存在** `LLM_MAX_RETRIES` 这个环境变量。
- 请求体只有 `model` / `messages` / `max_tokens`(`max_tokens = max_chars * 2`);**没有 temperature、没有 system prompt**——正文直接作为唯一的 user 消息发出。 - 请求体只有 `model` / `messages` / `max_tokens`(`max_tokens = max_chars * 2`);**没有 temperature、没有 system prompt**——正文直接作为唯一的 user 消息发出。
### 12.7 Error handling and observability (continued from §8) ### 12.7 错误处理与可观测性(§8 的补充)
**未实现** `BotError::Llm` 变体(`BotError` 只有 `Config`/`Io`/`Json`/`State` 四个),LLM 错误在 `worker.rs` 内被就地吞掉并降级为回退文本,不向上传播。 **未实现** `BotError::Llm` 变体(`BotError` 只有 `Config`/`Io`/`Json`/`State` 四个),LLM 错误在 `worker.rs` 内被就地吞掉并降级为回退文本,不向上传播。
**未实现** `llm.call` 的 `tracing` span(无 prompt 哈希、response 长度、latency 记录)。这两项连同 12.4 的熔断/半开设计、12.5 的规则回退与"AI 不可用"徽标,一并记入 `docs/retired.md`。 **未实现** `llm.call` 的 `tracing` span(无 prompt 哈希、response 长度、latency 记录)。这两项连同 12.4 的熔断/半开设计、12.5 的规则回退与"AI 不可用"徽标,一并记入 `docs/retired.md`。
### "Phase 3.5" in the 12.8 plan has not been implemented ### 第 12.8 节计划中的“阶段 3.5”尚未实现
原计划要在阶段 3 与 4 之间插入「阶段 3.5:LLM 门面 + 回退(1.5d)」,交付 `llm::client` / `llm::policy` / `llm::fallback` 三个模块,外加 4000 字符长邮件阈值、"AI 不可用"徽标和 3 次熔断 + Redis 共享计数。 原计划要在阶段 3 与 4 之间插入「阶段 3.5:LLM 门面 + 回退(1.5d)」,交付 `llm::client` / `llm::policy` / `llm::fallback` 三个模块,外加 4000 字符长邮件阈值、"AI 不可用"徽标和 3 次熔断 + Redis 共享计数。
**这套计划没有按设计执行**:LLM 能力最终落在单文件 `src/ai.rs`(见 §12.1),没有门面、没有 policy、没有 fallback 模块,也没有徽标和阈值分支。被放弃的部分记入 `docs/retired.md`。 **这套计划没有按设计执行**:LLM 能力最终落在单文件 `src/ai.rs`(见 §12.1),没有门面、没有 policy、没有 fallback 模块,也没有徽标和阈值分支。被放弃的部分记入 `docs/retired.md`。
本节原有一组 Q25–Q30 的设计提问也已作废——代码落地时已各自给出结论,答案见 §11.4。 本节原有一组 Q25–Q30 的设计提问也已作废——代码落地时已各自给出结论,答案见 §11.4。
--- ---
## Attachment: Research basis (can be reviewed) ## 附录:研究依据(可复核)
- `stalwartlabs/jmap-client` main 分支(路径均相对该仓库根):`stalwartlabs/jmap-client/src/lib.rs`(URI/Method/DataType/Error)、`stalwartlabs/jmap-client/src/client.rs`(认证/连接/event_source)、`stalwartlabs/jmap-client/src/email/`、`stalwartlabs/jmap-client/src/email_submission/helpers.rs`、`stalwartlabs/jmap-client/src/event_source/`、`stalwartlabs/jmap-client/src/push_subscription/`(create/verify/update_types/destroy)、`stalwartlabs/jmap-client/src/core/error.rs`、`stalwartlabs/jmap-client/Cargo.toml`、`stalwartlabs/jmap-client/README.md`、`stalwartlabs/jmap-client/examples/`。 - `stalwartlabs/jmap-client` main 分支(路径均相对该仓库根):`stalwartlabs/jmap-client/src/lib.rs`(URI/Method/DataType/Error)、`stalwartlabs/jmap-client/src/client.rs`(认证/连接/event_source)、`stalwartlabs/jmap-client/src/email/`、`stalwartlabs/jmap-client/src/email_submission/helpers.rs`、`stalwartlabs/jmap-client/src/event_source/`、`stalwartlabs/jmap-client/src/push_subscription/`(create/verify/update_types/destroy)、`stalwartlabs/jmap-client/src/core/error.rs`、`stalwartlabs/jmap-client/Cargo.toml`、`stalwartlabs/jmap-client/README.md`、`stalwartlabs/jmap-client/examples/`。
- crates.io:`jmap-client` 元数据。 - crates.io:`jmap-client` 元数据。
- `stalwartlabs/mail-server` main 分支:`stalwartlabs/mail-server/crates/common/src/auth/credential.rs`(Password/AppPassword/ApiKey)、`stalwartlabs/mail-server/crates/http/src/auth/authenticate.rs`(AccessScope 权限裁剪)、`stalwartlabs/mail-server/crates/jmap/src/push/`、`stalwartlabs/mail-server/api/v1/openapi.yml`(`securitySchemes`: basicAuth/bearerAuth/liveToken 60s)。 - `stalwartlabs/mail-server` main 分支:`stalwartlabs/mail-server/crates/common/src/auth/credential.rs`(Password/AppPassword/ApiKey)、`stalwartlabs/mail-server/crates/http/src/auth/authenticate.rs`(AccessScope 权限裁剪)、`stalwartlabs/mail-server/crates/jmap/src/push/`、`stalwartlabs/mail-server/api/v1/openapi.yml`(`securitySchemes`: basicAuth/bearerAuth/liveToken 60s)。

View File

@ -1,4 +1,4 @@
# 未完成项(Open Gaps) # 未完成项
> [English version / 英文版 → opengaps.md](opengaps.md) > [English version / 英文版 → opengaps.md](opengaps.md)
@ -6,13 +6,13 @@
已实现的接口与架构设计见 `docs/reference.md`、`docs/design.md`、`docs/deployment.md`,已退役的能力见 `docs/retired.md`。验证基线以 `docs/design.md`「P0 门禁」段落为准。 已实现的接口与架构设计见 `docs/reference.md`、`docs/design.md`、`docs/deployment.md`,已退役的能力见 `docs/retired.md`。验证基线以 `docs/design.md`「P0 门禁」段落为准。
## What you have to do ## 待办事项
**无。** **无。**
TTL 那条真机绿灯已经用你给的 Upstash URL 跑过了(见 §1),不需要再交任何东西。 TTL 那条真机绿灯已经用你给的 Upstash URL 跑过了(见 §1),不需要再交任何东西。
## Stage goal ## 阶段目标
| # | 项 | 状态 | | # | 项 | 状态 |
| --- | --- | --- | | --- | --- | --- |
@ -25,13 +25,13 @@ TTL 那条真机绿灯已经用你给的 Upstash URL 跑过了(见 §1),
第 5 项不在阶段目标内:多账号产品决策,2026-09-28 已由用户定为不做,决定边界与将来要做的改动面见 §3。 第 5 项不在阶段目标内:多账号产品决策,2026-09-28 已由用户定为不做,决定边界与将来要做的改动面见 §3。
## blocking ## 阻塞项
**无。** 四条阶段目标全部关闭,代码侧与真机侧都通了,唯一悬置的产品决策也已定案(§3)。 **无。** 四条阶段目标全部关闭,代码侧与真机侧都通了,唯一悬置的产品决策也已定案(§3)。
## Closed ## 已关闭
### 1. TTL actual measurement (closed: constant closing + real machine assertion run-through) ### 1. TTL 实测(已关闭:常量收敛并在真实机器上完成断言验证)
原条目:全代码库 **12 个 TTL 写入点、17 个 TTL 值**,但只有三个真正落到 Redis 的原子命令(`set_nx_ex` 的 `SET … NX EX`、`retry_or_dlq` Lua 里的 `EXPIRE`、`set_ai_consent` 的 `SET … EX`)在真实 Redis 上被断言过实际 PTTL;其余全是裸字面量。测试替身 `MemoryState::claim_dedup` 直接忽略 TTL 参数,所以单测覆盖的是调用路径,不是过期时长——把一个 24 小时去重窗口改成 `60_480` 仍能编译通过、单测全绿。 原条目:全代码库 **12 个 TTL 写入点、17 个 TTL 值**,但只有三个真正落到 Redis 的原子命令(`set_nx_ex` 的 `SET … NX EX`、`retry_or_dlq` Lua 里的 `EXPIRE`、`set_ai_consent` 的 `SET … EX`)在真实 Redis 上被断言过实际 PTTL;其余全是裸字面量。测试替身 `MemoryState::claim_dedup` 直接忽略 TTL 参数,所以单测覆盖的是调用路径,不是过期时长——把一个 24 小时去重窗口改成 `60_480` 仍能编译通过、单测全绿。
@ -81,7 +81,7 @@ TTL 那条真机绿灯已经用你给的 Upstash URL 跑过了(见 §1),
**两条 TTL 断言已在真实 Upstash 实例上跑通(`rediss://`,TLS-only;明文 `redis://` 会在 `AUTH` 后收到空回复断连,故必须走 TLS)**:`SET … NX EX`(`claim_dedup`)、`SET … EX 3600`(`set_ai_consent`)、Lua 内嵌 `EXPIRE 86400`(`retry_or_dlq`)三处原子写入点的实际 PTTL 全部落在 2s 容差内。原条目里够不到的 B 类 Lua 内嵌值现在也是真机验证,不只是常量引用。 **两条 TTL 断言已在真实 Upstash 实例上跑通(`rediss://`,TLS-only;明文 `redis://` 会在 `AUTH` 后收到空回复断连,故必须走 TLS)**:`SET … NX EX`(`claim_dedup`)、`SET … EX 3600`(`set_ai_consent`)、Lua 内嵌 `EXPIRE 86400`(`retry_or_dlq`)三处原子写入点的实际 PTTL 全部落在 2s 容差内。原条目里够不到的 B 类 Lua 内嵌值现在也是真机验证,不只是常量引用。
### 2. `newState` semantics of `Email/changes` (commit `269c8f6`) ### 2. `Email/changes` 的 `newState` 语义(提交 `269c8f6`)
原条目担心「服务器无法回放旧增量时返回 `newState`,要求客户端重新基线化」。但 jmap-client 0.4.2 的 `ChangesResponse` 只有 `accountId` / `oldState` / `newState` / `hasMoreChanges` / `created` / `updated` / `destroyed` 加展开的调用参数——**没有 reset / re-baseline 信号字段**。所以 `/changes` 失败在代码里就是一个普通的 `Err`,无法区分「临时故障」和「服务端不再回放这个 state」。这个条目因此无法通过任何观测手段闭环:停机窗口里能观测到的仍然是同一个 `Err`。 原条目担心「服务器无法回放旧增量时返回 `newState`,要求客户端重新基线化」。但 jmap-client 0.4.2 的 `ChangesResponse` 只有 `accountId` / `oldState` / `newState` / `hasMoreChanges` / `created` / `updated` / `destroyed` 加展开的调用参数——**没有 reset / re-baseline 信号字段**。所以 `/changes` 失败在代码里就是一个普通的 `Err`,无法区分「临时故障」和「服务端不再回放这个 state」。这个条目因此无法通过任何观测手段闭环:停机窗口里能观测到的仍然是同一个 `Err`。
@ -91,7 +91,7 @@ TTL 那条真机绿灯已经用你给的 Upstash URL 跑过了(见 §1),
> position walk 路径(`list_emails_page(None, position, BASELINE_PAGE_SIZE)`)本身不受影响——它的签名里根本没有服务端 state 参数(`None` 是 `folder_id`),所以不会碰到上面那类 token 语义。 > position walk 路径(`list_emails_page(None, position, BASELINE_PAGE_SIZE)`)本身不受影响——它的签名里根本没有服务端 state 参数(`None` 是 `folder_id`),所以不会碰到上面那类 token 语义。
### 3. Multiple accounts (decided: not to do, 2026-09-28 user decision) ### 3. 多账户(已决定不做,用户于 2026-09-28 决定)
单账户由需求固定(`REQ-SINGLE-ACCOUNT`),从未进入阶段目标。用户于 2026-09-28 明确「暂时不做多账户」,此缺口关闭。`docs/design.md` §11.3、`docs/charter.md` 的 `REQ-SINGLE-ACCOUNT` 行与 `docs/deployment.md` 的环境变量表 / 需求映射表原本就按此表述,本轮未改这三处。 单账户由需求固定(`REQ-SINGLE-ACCOUNT`),从未进入阶段目标。用户于 2026-09-28 明确「暂时不做多账户」,此缺口关闭。`docs/design.md` §11.3、`docs/charter.md` 的 `REQ-SINGLE-ACCOUNT` 行与 `docs/deployment.md` 的环境变量表 / 需求映射表原本就按此表述,本轮未改这三处。
@ -110,7 +110,7 @@ TTL 那条真机绿灯已经用你给的 Upstash URL 跑过了(见 §1),
这条不是被跳过,是被明确拒绝:单实例多账户省下的一个部署单元,换不来它引入的路由与游标复杂度。 这条不是被跳过,是被明确拒绝:单实例多账户省下的一个部署单元,换不来它引入的路由与游标复杂度。
## Deployment document defects discovered and fixed in this round ## 本轮发现并修复的部署文档问题
`docs/deployment.md` §6.3.1 原来只给了 `/reconcile` 的调度示例,而 `/worker`——全代码库唯一的队列消费入口——被描述成「运维手工触发」,且刻意不在 Worker 白名单里。**照文档照抄部署 = 邮件持续进队列、通知永远发不出去**,这正是联调环境积压了数天才被手工排空的成因。§6.3.1 已补上 `/worker` 的调度步骤、顺序要求、批量上限与「204 不能当成功信号」的说明。 `docs/deployment.md` §6.3.1 原来只给了 `/reconcile` 的调度示例,而 `/worker`——全代码库唯一的队列消费入口——被描述成「运维手工触发」,且刻意不在 Worker 白名单里。**照文档照抄部署 = 邮件持续进队列、通知永远发不出去**,这正是联调环境积压了数天才被手工排空的成因。§6.3.1 已补上 `/worker` 的调度步骤、顺序要求、批量上限与「204 不能当成功信号」的说明。

View File

@ -28,9 +28,9 @@
--- ---
## 1. Redis keys ## 1. Redis 键
### 1.1 Configuration ### 1.1 配置
|关键| TTL |作家 |读者| |关键| TTL |作家 |读者|
|---|---|---|---| |---|---|---|---|
@ -51,7 +51,7 @@
在处理程序一侧。 在处理程序一侧。
- `config:enabled` 被视为一个门;缺失或“错误”会导致业务处理中断。 - `config:enabled` 被视为一个门;缺失或“错误”会导致业务处理中断。
### 1.2 AI consent ### 1.2 AI 授权
|关键| TTL |作家 |读者| |关键| TTL |作家 |读者|
|---|---|---|---| |---|---|---|---|
@ -82,7 +82,7 @@
以及中文前缀“搜索”、“查找”、“搜索”(仅匹配前缀,绝不是子字符串) 以及中文前缀“搜索”、“查找”、“搜索”(仅匹配前缀,绝不是子字符串)
`意图::搜索`。搜索未授予同意并且不写入 Redis 密钥。 `意图::搜索`。搜索未授予同意并且不写入 Redis 密钥。
### 1.3 Delivery pipeline ### 1.3 投递流水线
|关键| TTL |作家 |读者| |关键| TTL |作家 |读者|
|---|---|---|---| |---|---|---|---|
@ -108,7 +108,7 @@ DLQ 是 **append-and-ack**:附加到 `stalwart:jmap:dlq` 的相同 Lua 脚本
追加和源确认是原子的,全部都在一个 Lua 脚本内。没有代码路径读取 DLQ 追加和源确认是原子的,全部都在一个 Lua 脚本内。没有代码路径读取 DLQ
返回 — 重播是操作员操作,而不是服务功能。 返回 — 重播是操作员操作,而不是服务功能。
### 1.4 Idempotency and rate limits ### 1.4 幂等与速率限制
|关键| TTL |作家 |读者| |关键| TTL |作家 |读者|
|---|---|---|---| |---|---|---|---|
@ -121,7 +121,7 @@ DLQ 是 **append-and-ack**:附加到 `stalwart:jmap:dlq` 的相同 Lua 脚本
删除,按设计:它是协调游标,过期将强制完全删除 删除,按设计:它是协调游标,过期将强制完全删除
下次重新启动时重新设定基线。 下次重新启动时重新设定基线。
### 1.5 Locks ### 1.5 锁
|关键| TTL |作家 |读者| |关键| TTL |作家 |读者|
|---|---|---|---| |---|---|---|---|
@ -135,7 +135,7 @@ DLQ 是 **append-and-ack**:附加到 `stalwart:jmap:dlq` 的相同 Lua 脚本
> 锁的寿命必须超过配置的 300 秒最大 JMAP 请求超时时间;这可以防止 > 锁的寿命必须超过配置的 300 秒最大 JMAP 请求超时时间;这可以防止
> 由于接受重复项而创建缓慢。 > 由于接受重复项而创建缓慢。
### 1.6 Push subscription state ### 1.6 推送订阅状态
|关键| TTL |作家 |读者| |关键| TTL |作家 |读者|
|---|---|---|---| |---|---|---|---|
@ -166,7 +166,7 @@ DLQ 是 **append-and-ack**:附加到 `stalwart:jmap:dlq` 的相同 Lua 脚本
--- ---
## 2. Error envelope ## 2. 错误响应封装
每个错误响应都使用一种包络形状: 每个错误响应都使用一种包络形状:
@ -204,7 +204,7 @@ DLQ 是 **append-and-ack**:附加到 `stalwart:jmap:dlq` 的相同 Lua 脚本
--- ---
## 3. Backend routes ## 3. 后端路由
所有 HTTP API 路由均在“router_with_worker_state_runtime_bootstrap”中注册。 所有 HTTP API 路由均在“router_with_worker_state_runtime_bootstrap”中注册。
最后一个表行中的三个静态 SPA 路由是例外:它们位于 最后一个表行中的三个静态 SPA 路由是例外:它们位于
@ -284,7 +284,7 @@ env var — 并且“DEBUG_TOKEN”非空**(“SAF-DEBUG-GATE”;门被读
--- ---
## 4. Gateway vs backend route matrix ## 4. 网关与后端路由对照表
> **独立验证。** 来源:`cloudflare-worker/src/backends.js` `SAFE_ROUTES`, > **独立验证。** 来源:`cloudflare-worker/src/backends.js` `SAFE_ROUTES`,
> 18 个条目,以及 `ROUTE_METHODS` (`index.js`) > 18 个条目,以及 `ROUTE_METHODS` (`index.js`)
@ -333,11 +333,11 @@ proxy:回调 URL 由客户端在请求正文中提供,
--- ---
## 5. Environment variables ## 5. 环境变量
三个不同的层。它们不可互换。 三个不同的层。它们不可互换。
### 5.1 Boot-time (2 required, 2 optional with defaults) ### 5.1 启动时配置(2 项必填,2 项可选且有默认值)
|变量|必填 |默认 |来源 | |变量|必填 |默认 |来源 |
|---|---|---|---| |---|---|---|---|
@ -362,7 +362,7 @@ proxy:回调 URL 由客户端在请求正文中提供,
在不做任何业务工作的情况下浮出水面。这是一种故意的失败关闭设置姿势,并且它 在不做任何业务工作的情况下浮出水面。这是一种故意的失败关闭设置姿势,并且它
是“容器正常但没有任何反应”的最可能的原因。 是“容器正常但没有任何反应”的最可能的原因。
### 5.2 Redis-resident business configuration ### 5.2 存储在 Redis 中的业务配置
SPA 通过 `GET /api/business-config` 读取并写入 SPA 通过 `GET /api/business-config` 读取并写入
`PUT /api/business-config` (1秒内生效);第一次成功写入创建 `PUT /api/business-config` (1秒内生效);第一次成功写入创建
@ -425,7 +425,7 @@ patch** 仅替换其命名的字段并保留其余字段的存储值(`apply`
**200**,因为这是呼叫者要求的答案。认证是一样的 **200**,因为这是呼叫者要求的答案。认证是一样的
`config_authorized` 门作为写入路径;它故意**不**位于`DEBUG_TOKEN`后面。 `config_authorized` 门作为写入路径;它故意**不**位于`DEBUG_TOKEN`后面。
### 5.3 No legacy environment path ### 5.3 不保留旧版环境变量配置路径
`Config::from_env()` 及其助手(`required_secret`、`required_nonblank`、`env_bool`)是 `Config::from_env()` 及其助手(`required_secret`、`required_nonblank`、`env_bool`)是
**已删除** — `config.rs` 根本不执行 `std::env` 读取。下面的名字是遗产 **已删除** — `config.rs` 根本不执行 `std::env` 读取。下面的名字是遗产
@ -457,7 +457,7 @@ env 表面并仅列出,以便过时的部署脚本可以被识别为过时的
它(如果有)对 Redis 连接进行身份验证,并且不是任何 HTTP 的凭据 它(如果有)对 Redis 连接进行身份验证,并且不是任何 HTTP 的凭据
端点。 端点。
### 5.4 Notification rendering ### 5.4 通知内容渲染
`send_notification` (`src/channel.rs`) 是唯一将通知渲染到 `send_notification` (`src/channel.rs`) 是唯一将通知渲染到
Telegram 文本,它只渲染三个元数据行——不是正文,也不是 Telegram 文本,它只渲染三个元数据行——不是正文,也不是
@ -494,9 +494,9 @@ build 没有 IANA tz 数据库(“chrono-tz”不可用),因此每个区
--- ---
## 6. Outbound and runtime parameters ## 6. 出站与运行时参数
### 6.1 Outbound ### 6.1 出站请求
|参数|默认 |范围/上限|来源 | |参数|默认 |范围/上限|来源 |
|---|---|---|---| |---|---|---|---|
@ -508,7 +508,7 @@ build 没有 IANA tz 数据库(“chrono-tz”不可用),因此每个区
**没有** `LLM_MAX_RETRIES` 环境变量;重试计数位于 **没有** `LLM_MAX_RETRIES` 环境变量;重试计数位于
`config:outbound` 并且无论写入什么内容都以 5 为界。 `config:outbound` 并且无论写入什么内容都以 5 为界。
### 6.2 Reconcile budgets ### 6.2 状态协调预算
|恒定|价值|来源 | |恒定|价值|来源 |
|---|---|---| |---|---|---|
@ -525,7 +525,7 @@ build 没有 IANA tz 数据库(“chrono-tz”不可用),因此每个区
第一个“/changes”调用。重播阶段之后可能会扩大窗口,最多 第一个“/changes”调用。重播阶段之后可能会扩大窗口,最多
`CHANGE_WINDOW_CAP`,在光标前进之前。 `CHANGE_WINDOW_CAP`,在光标前进之前。
### 6.3 Idle threshold ### 6.3 空闲阈值
XAUTOCLAIM 的空闲阈值源自实时出站配置: XAUTOCLAIM 的空闲阈值源自实时出站配置:
@ -538,7 +538,7 @@ count × (max_retries + 1) × (jmap_timeout_ms + telegram_timeout_ms + llm_timeo
Redis 打嗝永远不会折叠窗口。仅在多实例上重复 Redis 打嗝永远不会折叠窗口。仅在多实例上重复
赌注,永不损失。 赌注,永不损失。
### 6.4 Search limits ### 6.4 搜索限制
|恒定|价值|来源 | |恒定|价值|来源 |
|---|---|---| |---|---|---|
@ -546,7 +546,7 @@ Redis 打嗝永远不会折叠窗口。仅在多实例上重复
| `SEARCH_SUBJECT_MAX` | 120 个字符 | `worker.rs` | | `SEARCH_SUBJECT_MAX` | 120 个字符 | `worker.rs` |
| `SEARCH_PREVIEW_MAX` | 160 个字符 | `worker.rs` | | `SEARCH_PREVIEW_MAX` | 160 个字符 | `worker.rs` |
### 6.5 Reconcile cursor ### 6.5 状态协调游标
`/reconcile` 保留一个游标,`state:jmap:since`,并且它是双峰的: `/reconcile` 保留一个游标,`state:jmap:since`,并且它是双峰的:
@ -583,7 +583,7 @@ Redis 打嗝永远不会折叠窗口。仅在多实例上重复
--- ---
## 7. Static assets and response headers ## 7. 静态资源与响应头
从“src/web.rs”提供服务。 从“src/web.rs”提供服务。
@ -608,7 +608,7 @@ Redis 打嗝永远不会折叠窗口。仅在多实例上重复
--- ---
## 8. Not implemented ## 8. 尚未实现的功能
在此记录,以便其他地方的引用不会被误认为是已发布的功能。 在此记录,以便其他地方的引用不会被误认为是已发布的功能。
@ -625,7 +625,7 @@ Redis 打嗝永远不会折叠窗口。仅在多实例上重复
--- ---
## References ## 参考资料
外部依赖项,全部可达并在验证时返回 HTTP 200: 外部依赖项,全部可达并在验证时返回 HTTP 200:
@ -650,13 +650,13 @@ Redis 打嗝永远不会折叠窗口。仅在多实例上重复
(404) 和 `platform.openai.com` 文档路径 (403)。该项目描述了法学硕士 (404) 和 `platform.openai.com` 文档路径 (403)。该项目描述了法学硕士
依赖项与 OpenAI 兼容,并通过“reqwest”记录它。 依赖项与 OpenAI 兼容,并通过“reqwest”记录它。
## 9. Deployment platform details ## 9. 部署平台细节
面向操作员的序列位于 [`deployment.md`](deployment.zh-CN.md) 中。本节保留 面向操作员的序列位于 [`deployment.md`](deployment.zh-CN.md) 中。本节保留
配置或诊断时有用的特定于平台的行为和设置 配置或诊断时有用的特定于平台的行为和设置
部署。 部署。
### 9.1 Backend deployment paths ### 9.1 后端部署方式
**HostStack 生产路径。** 存储库的 `hoststack.yaml` 是生产路径 **HostStack 生产路径。** 存储库的 `hoststack.yaml` 是生产路径
HostStack 的本机 Rust 运行时的配置。它运行 `cargo fetch --locked`,构建 HostStack 的本机 Rust 运行时的配置。它运行 `cargo fetch --locked`,构建
@ -682,20 +682,20 @@ HostStack路径不执行存储库Dockerfile。它的建造者和运行者是
由主机注入;秘密不会进入构建参数或图像层。 Docker 平台 由主机注入;秘密不会进入构建参数或图像层。 Docker 平台
运行状况检查应调用 [`deployment.md`](deployment.zh-CN.md) 中描述的 HTTP 端点。 运行状况检查应调用 [`deployment.md`](deployment.zh-CN.md) 中描述的 HTTP 端点。
### 9.2 Backend configuration and secrets ### 9.2 后端配置与密钥
该进程在启动时仅读取“REDIS_URL”和“CONFIG_ENCRYPTION_KEY”。可选的 该进程在启动时仅读取“REDIS_URL”和“CONFIG_ENCRYPTION_KEY”。可选的
远程调试界面使用独立的“DEBUG_ENABLED”开关和“DEBUG_TOKEN”;离开 远程调试界面使用独立的“DEBUG_ENABLED”开关和“DEBUG_TOKEN”;离开
除非有意启用远程诊断,否则两者在生产中均未设置。完全启动 除非有意启用远程诊断,否则两者在生产中均未设置。完全启动
变量语义和 Redis 业务配置字段位于 [§5](#5-environment-variables) 中。 变量语义和 Redis 业务配置字段位于 [§5](#5-环境变量) 中。
Telegram、JMAP、allowlist、worker、reconcile 和 LLM 业务设置驻留在 Redis 中, Telegram、JMAP、allowlist、worker、reconcile 和 LLM 业务设置驻留在 Redis 中,
不是进程环境变量。 [§5.2](#52-redis-resident-business-configuration)中的字段列表和验证规则 不是进程环境变量。 [§5.2](#52-存储在-redis-中的业务配置)中的字段列表和验证规则
是权威的。成功的初始保存可以使用一次性“/api/bootstrap”端点 是权威的。成功的初始保存可以使用一次性“/api/bootstrap”端点
在后端原点;该路由不通过 Worker 公开。后续编辑使用 在后端原点;该路由不通过 Worker 公开。后续编辑使用
受保护的配置 API。 受保护的配置 API。
### 9.3 Callback registration and scheduled work ### 9.3 回调注册与定时任务
Telegram 的“setWebhook”请求必须使用配置的 Webhook 密钥并包含“message” Telegram 的“setWebhook”请求必须使用配置的 Webhook 密钥并包含“message”
在“允许的更新”中。使用“getWebhookInfo”检查生成的 URL;电报不回 在“允许的更新”中。使用“getWebhookInfo”检查生成的 URL;电报不回
@ -714,7 +714,7 @@ Telegram 的“setWebhook”请求必须使用配置的 Webhook 密钥并包含
漏极端点返回“204”,响应正文为空; `/reconcile` 可以返回 `409` 而 漏极端点返回“204”,响应正文为空; `/reconcile` 可以返回 `409` 而
另一个协调拥有该锁。 另一个协调拥有该锁。
### 9.4 Health and release checks ### 9.4 健康检查与发布核验
- `GET /healthz` 是后端活跃度。它并不能证明Redis或上游服务已经准备好。 - `GET /healthz` 是后端活跃度。它并不能证明Redis或上游服务已经准备好。
- `GET /ready` 检查配置、Redis、JMAP 会话和 Telegram 的 `getMe` 端点。 - `GET /ready` 检查配置、Redis、JMAP 会话和 Telegram 的 `getMe` 端点。
@ -728,7 +728,7 @@ Telegram 的“setWebhook”请求必须使用配置的 Webhook 密钥并包含
[`charter.md`](charter.zh-CN.md)。文档门必须在主机上运行,因此它 [`charter.md`](charter.zh-CN.md)。文档门必须在主机上运行,因此它
路径审计可以读取Git历史记录。 路径审计可以读取Git历史记录。
### 9.5 Cloudflare Worker and Dashboard ### 9.5 Cloudflare Worker 与控制面板
Worker 是可选的 HTTPS 网关。后端来源必须是 HTTPS 字符串,不带任何内容 Worker 是可选的 HTTPS 网关。后端来源必须是 HTTPS 字符串,不带任何内容
路径、查询、片段或嵌入凭据。目前公开的来源清单和 路径、查询、片段或嵌入凭据。目前公开的来源清单和

View File

@ -1,4 +1,4 @@
# 废弃与未采用路线(Retired Routes) # 废弃与未采用的方案
> [English version / 英文版 → retired.md](retired.md) > [English version / 英文版 → retired.md](retired.md)
@ -11,7 +11,7 @@
--- ---
## 1. Route not taken ## 1. 未采用的方案
| 条目 | 类型 | 原因 | 替代或现状 | | 条目 | 类型 | 原因 | 替代或现状 |
|---|---|---|---| |---|---|---|---|
@ -29,7 +29,7 @@
| SPA 管理凭据 = `REDIS_URL` 的 Redis ACL 密码 | 未采用路线 | 混淆基础设施凭据与 UI 管理密码;Redis 无 ACL 密码(TLS-only 托管 Redis)时 `bootstrap_token` 的 `.is_empty()` 守卫让 SPA 永久 401 | 改用 `CONFIG_ENCRYPTION_KEY`(启动必填的 32 字节高熵 hex,常数时间比较) | | SPA 管理凭据 = `REDIS_URL` 的 Redis ACL 密码 | 未采用路线 | 混淆基础设施凭据与 UI 管理密码;Redis 无 ACL 密码(TLS-only 托管 Redis)时 `bootstrap_token` 的 `.is_empty()` 守卫让 SPA 永久 401 | 改用 `CONFIG_ENCRYPTION_KEY`(启动必填的 32 字节高熵 hex,常数时间比较) |
### 1.1 Teloxide candidate comparison (evaluation record) ### 1.1 Teloxide 候选方案对比(评估记录)
下表与下列理由为**当初的评估结论,保留它是决策依据,不是当前技术事实**——「活跃,最新 0.17,下载量大」等框架属性未经本轮复核,不代表这些 crate 的当前版本状态。 下表与下列理由为**当初的评估结论,保留它是决策依据,不是当前技术事实**——「活跃,最新 0.17,下载量大」等框架属性未经本轮复核,不代表这些 crate 的当前版本状态。
@ -51,7 +51,7 @@
**替代/降级**:若 teloxide 升级或破坏性改动,可退到更薄的 `teloxide-core`(保留核心与 types,去掉 dispatcher 抽象);若需多账户高吞吐,用 `webhooks-axum` + 共享 `axum::Router`。以上两条均随 teloxide 未采用而不再成立。 **替代/降级**:若 teloxide 升级或破坏性改动,可退到更薄的 `teloxide-core`(保留核心与 types,去掉 dispatcher 抽象);若需多账户高吞吐,用 `webhooks-axum` + 共享 `axum::Router`。以上两条均随 teloxide 未采用而不再成立。
### 1.2 Dialogue FSM state transition table (target design, not yet implemented) ### 1.2 对话 FSM 状态转移表(目标设计,尚未实现)
当前没有 FSM:`src/worker.rs` 的 `parse_intent` 直接解析为 `Intent`,AI 授权是 Redis 里的一个布尔加过期时间。曾设计过下面 5 个状态: 当前没有 FSM:`src/worker.rs` 的 `parse_intent` 直接解析为 `Intent`,AI 授权是 Redis 里的一个布尔加过期时间。曾设计过下面 5 个状态:
@ -69,7 +69,7 @@
--- ---
## 2. Fictional entries (documentation has been written, code has never existed) ## 2. 虚构条目(文档曾写过,代码从未存在)
这些名字**从未在代码里存在**。它们有的出现在目标形态的目录树里,有的出现在未提交的草稿与讨论记录里,共同风险是让人误以为"这个功能已经实现",故在此登记防止再次出现。 这些名字**从未在代码里存在**。它们有的出现在目标形态的目录树里,有的出现在未提交的草稿与讨论记录里,共同风险是让人误以为"这个功能已经实现",故在此登记防止再次出现。
@ -104,7 +104,7 @@
| docker-compose `message-weave health --addr` 示例 | 虚构命令 | 应用无 CLI 子命令;健康检查端点是 `GET /healthz` 与 `GET /ready` | 见 `docs/deployment.md` 的 Health-check 表 | | docker-compose `message-weave health --addr` 示例 | 虚构命令 | 应用无 CLI 子命令;健康检查端点是 `GET /healthz` 与 `GET /ready` | 见 `docs/deployment.md` 的 Health-check 表 |
| 早期设计辩论问题(消息格式 / 长邮件阈值 / 附件策略 / Identity / 监控 / LLM 供应商 / 熔断等 17 条) | 已由代码回答 | 均已被实现的代码给出答案,不再属于待确认项 | 见 `docs/design.md` 的「已由代码回答的早期问题」一节 | | 早期设计辩论问题(消息格式 / 长邮件阈值 / 附件策略 / Identity / 监控 / LLM 供应商 / 熔断等 17 条) | 已由代码回答 | 均已被实现的代码给出答案,不再属于待确认项 | 见 `docs/design.md` 的「已由代码回答的早期问题」一节 |
## 3. Deleted documents ## 3. 已删除的文档
| 条目 | 类型 | 原因 | 替代或现状 | | 条目 | 类型 | 原因 | 替代或现状 |
|---|---|---|---| |---|---|---|---|
@ -113,7 +113,7 @@
--- ---
## 4. Deleted compatibility path (code used to exist, now deleted) ## 4. 已删除的兼容路径(代码曾存在,现已删除)
与前两节不同:下列条目**曾经真实存在于代码**,既非虚构也非"未采用"。它们属于早期为兼容 与前两节不同:下列条目**曾经真实存在于代码**,既非虚构也非"未采用"。它们属于早期为兼容
环境变量部署形态而保留的解析层,现已整体删除——项目早期无需任何兼容性承诺,删除比保留更便宜。 环境变量部署形态而保留的解析层,现已整体删除——项目早期无需任何兼容性承诺,删除比保留更便宜。
@ -135,7 +135,7 @@
> (Redis 运行参数 `max_retries`,回落默认见 `docs/reference.md` §6.1),后者是常量 > (Redis 运行参数 `max_retries`,回落默认见 `docs/reference.md` §6.1),后者是常量
> (两个构造器内硬编码 300,未进入业务配置 wire)。 > (两个构造器内硬编码 300,未进入业务配置 wire)。
### 4.1 Business/management routes mounted in boot mode ### 4.1 启动模式下挂载的业务/管理路由
`router_configuration_setup` 原先直接委托全量工厂 `router_with_worker_state_runtime_bootstrap_config`, `router_configuration_setup` 原先直接委托全量工厂 `router_with_worker_state_runtime_bootstrap_config`,
因此 12 条业务与管理路由在引导模式下全部被注册,却**一条也走不通**:引导模式的 `admin_token` 是空值, 因此 12 条业务与管理路由在引导模式下全部被注册,却**一条也走不通**:引导模式的 `admin_token` 是空值,
@ -154,7 +154,7 @@
--- ---
## 5. How to restart one of them ## 5. 如何重新启用其中一项方案
1. 先在 `docs/opengaps.md` 登记为缺口并归属阶段。 1. 先在 `docs/opengaps.md` 登记为缺口并归属阶段。
2. 若涉及 Redis 键、TTL、HTTP 路由或默认值,同轮更新 `docs/reference.md`。 2. 若涉及 Redis 键、TTL、HTTP 路由或默认值,同轮更新 `docs/reference.md`。