docs: /ready 端到端就绪语义同步 + 删除 Redis 错误映射虚构断言

两件事一批提交(全部 docs-only,无代码改动):

① /ready 语义升级为端到端就绪(对应 commit c5bc7b8 的实际行为)
- ARCH-READY-BASELINE 从「配置完整性 + Redis 可达性」改为:配置完整性
  + Redis 可达 + JMAP session 探针 + Telegram getMe 探针,四者全过返 200
  与就绪报告,任一失败返 503 + 标准错误 envelope + Retry-After: 30
- 探针细节:各 PROBE_TIMEOUT 3000ms、tokio::join! 并行、最坏约 3s
  (先前文档写「串行 / 最坏 6s」是错的,已全量修正 10 处,含 AGENTS.md /
  design.md / deployment.md / roadmap.md / reference.md / README 中英)
- LB 探针超时下限从「> 6s」改为「> 3s(建议 >= 10s)」
- JMAP 探针实际行为:取 JMAP_SESSION_URL 的 origin 后 GET
  {origin}/.well-known/jmap 并带配置的 Basic 认证(不是打裸 session URL);
  原因是裸 URL 不带认证会永远 not-ready,进而让 ingress 停止路由
- deployment.md 新增 /ready 出站 egress 硬依赖说明:egress 不通则长期 503,
  而 reqwest 以 default-features=false 编译不解析 HTTPS_PROXY
- reference.md Retry-After 规则改准:error_response 仅在 retry=true 时设置;
  error_response_with_id 无条件设置但仅用于 push register/disable 的
  push_state_unavailable / push_destroy_failed(恒 503);503 错误码表补上
  这两个此前漏掉的码
- GATE-READY-DEPS 门禁已闭合,从 AGENTS.md 与 roadmap.md 删除该 ID

② 删除「Redis 错误映射」这条虚构断言(代码缺口 4 -> 3)
- roadmap.md 与 design.md:634 的同一条断言删除;design.md 脚注「4 条」->「3 条」
- 根因:消费入口是 HTTP handler worker(notify.rs),不是后台循环
  —— 全仓 src/ 零 select!、无信号处理、无常驻 worker 进程;read_batch 与
  retry_or_dlq 的 Err 均经 ? 上抛,调用方一律返 503 + retryable=true。
  原先描述的「循环不因单次失败退出」这个前提本身不成立
- retired.md 新增一条虚构断言记录,完整保留原始断言与推翻依据
- roadmap 验收待办同步:/ready 端到端探测转已完成,并注明两个探针是可复用
  纯函数、④ 远程 debug 应直接复用同一实现,不要另写一套

③ 顺手修正本轮代码移动导致的失效行号锚
- retired.md:refresh_business_config 628->704;lock:push-register 810-819->887-890;
  register_push 668->873;notify::push_handler 改引真实函数名 jmap_push /
  worker / reconcile(原行号 250/97/136 全错,且 push_handler 非真实函数名);
  worker safelist fail-closed index.js:69-82 -> :77-92
- reference.md:router 1238-1265 -> 1250-1277;/worker 314 -> 326;
  路由表补上此前漏掉的 /、/assets/config.js、/assets/styles.css

基线:三处「验证基线」(roadmap.md:5、retired.md:7、reference.md:6)由
ed93358 更新为 c5bc7b8。

校验:8 个 md 代码块偶数、无行尾空格、无 CR;5 个文件全部表格列数一致
(剥掉 code span 后再计数);相对链接与锚点全部可达。
Rust 门禁不受影响(无 src/ 改动),基线仍为 45 passed / 0 failed / 1 ignored。
This commit is contained in:
anonsaber 2026-09-28 12:08:33 +08:00
parent c5bc7b8d82
commit cc645020d9
8 changed files with 198 additions and 98 deletions

View File

@ -41,7 +41,7 @@
12. **状态仅外部 Redis(用户托管 + AOF)**:会话/去重/Redis Streams/熔断计数/sinceState 一律走外部 Redis(`C-REDIS-ONLY-STATE`/`C-REDIS-MANAGED-AOF`),禁止 SQLite/本地卷作持久层(`NG-SQLITE-PERSIST`/`NG-LOCAL-VOLUME`);Redis 丢失由 JMAP 对账 `FLOW-RECONCILE` 重建 sinceState,**事实源在 JMAP**。 12. **状态仅外部 Redis(用户托管 + AOF)**:会话/去重/Redis Streams/熔断计数/sinceState 一律走外部 Redis(`C-REDIS-ONLY-STATE`/`C-REDIS-MANAGED-AOF`),禁止 SQLite/本地卷作持久层(`NG-SQLITE-PERSIST`/`NG-LOCAL-VOLUME`);Redis 丢失由 JMAP 对账 `FLOW-RECONCILE` 重建 sinceState,**事实源在 JMAP**。
13. **正文仅在用户明确允许后才外发 AI**(`REQ-AI-EXTERNAL-CONSENT`):默认不外发;发送前须用户显式同意(与第 2 条叠加)。 13. **正文仅在用户明确允许后才外发 AI**(`REQ-AI-EXTERNAL-CONSENT`):默认不外发;发送前须用户显式同意(与第 2 条叠加)。
14. **单账户**(`REQ-SINGLE-ACCOUNT`):一个实例只接一个 Stalwart 账户;多账户 = 多个 bot 实例,不做多账户单实例。 14. **单账户**(`REQ-SINGLE-ACCOUNT`):一个实例只接一个 Stalwart 账户;多账户 = 多个 bot 实例,不做多账户单实例。
15. **三入口鉴权 fail-closed(硬约束 `SAF-AUTH-RECONCILE`/`SAF-AUTH-TG-WEBHOOK`/`SAF-AUTH-JMAP-PUSH`)**:三条写路径必须先鉴权——`/reconcile` 校验 `Authorization: Bearer RECONCILE_TOKEN`、`/webhook/tg` 校验 `X-Telegram-Bot-Api-Secret-Token == TG_WEBHOOK_SECRET`、`/push/jmap` 校验请求体 `pushSubscriptionId` + `verificationCode`——按 `pushSubscriptionId` 查 Redis 短期验证状态(值存 `session_digest` 摘要,见 `src/notify.rs` `jmap_push`),状态缺失或摘要不符即拒绝;`/reconcile`、`/webhook/tg` 的凭证比较用**常数时间**(`subtle`),失败 `401` 且**在鉴权通过前不得产生任何副作用**;三密钥必填,缺失即启动失败,**禁止任何"未配置则放行"降级**。`/healthz`(`ARCH-HEALTHZ`)与 `/ready` 为**公开探针**(`SAF-PROBE-PUBLIC`:无鉴权、仅健康状态、不含敏感信息);**`/ready` 已做基础探测**(`ARCH-READY-BASELINE`):配置完整性 + Redis 可达性,不就绪返 `503`(标准错误 envelope `{"error":"service_unavailable","request_id":<id>}` + `Retry-After: 30`;Uptime Kuma 按状态码 200/503 监控,不受响应体影响);**仍不做** JMAP session / TG getMe 等端到端探测(余下范围见 `GATE-READY-DEPS`)——**禁止**声称 `/ready` 已验证端到端依赖。 15. **三入口鉴权 fail-closed(硬约束 `SAF-AUTH-RECONCILE`/`SAF-AUTH-TG-WEBHOOK`/`SAF-AUTH-JMAP-PUSH`)**:三条写路径必须先鉴权——`/reconcile` 校验 `Authorization: Bearer RECONCILE_TOKEN`、`/webhook/tg` 校验 `X-Telegram-Bot-Api-Secret-Token == TG_WEBHOOK_SECRET`、`/push/jmap` 校验请求体 `pushSubscriptionId` + `verificationCode`——按 `pushSubscriptionId` 查 Redis 短期验证状态(值存 `session_digest` 摘要,见 `src/notify.rs` `jmap_push`),状态缺失或摘要不符即拒绝;`/reconcile`、`/webhook/tg` 的凭证比较用**常数时间**(`subtle`),失败 `401` 且**在鉴权通过前不得产生任何副作用**;三密钥必填,缺失即启动失败,**禁止任何"未配置则放行"降级**。`/healthz`(`ARCH-HEALTHZ`)与 `/ready` 为**公开探针**(`SAF-PROBE-PUBLIC`:无鉴权、仅健康状态、不含敏感信息);**`/ready` 已做端到端探测**(`ARCH-READY-BASELINE`):配置完整性 + Redis 可达性 + 出站只读探测(JMAP session `GET` / TG `getMe`,各 `PROBE_TIMEOUT`=3000ms、并行执行,最坏约 3s),任一失败返 `503`(标准错误 envelope `{"error":"service_unavailable","request_id":<id>}` + `Retry-After: 30`;Uptime Kuma 按状态码 200/503 监控,不受响应体影响);出站探测要求 egress 可达 JMAP host 与 `api.telegram.org:443`(bot token 仅用于拼 URL,不日志/不回显)——**禁止再声称 `/ready` 只做基础探测**。
16. **多实例高可用(`ARCH-LB-WORKER`)**:允许同一镜像跨多个 serverless 平台实例化、前置免费 Cloudflare Worker 做唯一入口与故障转移(`C-LB-SINGLE-REG-URL`),共享同一 Redis(`C-REDIS-ONLY-STATE`)。**信任模型为透传(`SAF-LB-PASSTHRU`)——后端鉴权不可省**(小平台无防火墙/ACL,后端入口可能被公网直连);多实例**必须共享同一组 `SAF-AUTH-*` secret**(`C-LB-SHARED-SECRETS`)。`/reconcile` **禁止多实例并发**,用 Redis 锁单实例执行(`SAF-RECONCILE-LOCK`);Streams 用**同一消费组**分摊(`MOD-STREAMS-GROUP`);Worker 可提供**聚合健康视图**(`MOD-HEALTH-AGG`)。**Redis 单点故障不在本方案范围**(`NFR-HA-MULTI-INSTANCE`,用户外部解决)。业务代码无需为 LB 改动。详见 deployment.md §10。 16. **多实例高可用(`ARCH-LB-WORKER`)**:允许同一镜像跨多个 serverless 平台实例化、前置免费 Cloudflare Worker 做唯一入口与故障转移(`C-LB-SINGLE-REG-URL`),共享同一 Redis(`C-REDIS-ONLY-STATE`)。**信任模型为透传(`SAF-LB-PASSTHRU`)——后端鉴权不可省**(小平台无防火墙/ACL,后端入口可能被公网直连);多实例**必须共享同一组 `SAF-AUTH-*` secret**(`C-LB-SHARED-SECRETS`)。`/reconcile` **禁止多实例并发**,用 Redis 锁单实例执行(`SAF-RECONCILE-LOCK`);Streams 用**同一消费组**分摊(`MOD-STREAMS-GROUP`);Worker 可提供**聚合健康视图**(`MOD-HEALTH-AGG`)。**Redis 单点故障不在本方案范围**(`NFR-HA-MULTI-INSTANCE`,用户外部解决)。业务代码无需为 LB 改动。详见 deployment.md §10。
17. **生产红线(无数据库 / 无本地写入 / 标准输出日志,`C-NO-DB` / `C-NO-LOCAL-WRITE` / `C-LOG-STDOUT-ONLY` / `SAF-LOG-PURITY` / `C-NO-STATEFUL-RECOVERY`)**: 17. **生产红线(无数据库 / 无本地写入 / 标准输出日志,`C-NO-DB` / `C-NO-LOCAL-WRITE` / `C-LOG-STDOUT-ONLY` / `SAF-LOG-PURITY` / `C-NO-STATEFUL-RECOVERY`)**:
- **不使用任何数据库**(`C-NO-DB`):无 SQLite / Postgres / MySQL / 嵌入式数据库;Redis 是唯一生产状态存储(`C-REDIS-ONLY-STATE`)。应用**不自建、不连接第二个数据库实例**。 - **不使用任何数据库**(`C-NO-DB`):无 SQLite / Postgres / MySQL / 嵌入式数据库;Redis 是唯一生产状态存储(`C-REDIS-ONLY-STATE`)。应用**不自建、不连接第二个数据库实例**。
@ -73,7 +73,7 @@
阶段 0 脚手架 + HTTPS 入口骨架(已完成,待过 P0 门禁 GATE-P0) 阶段 0 脚手架 + HTTPS 入口骨架(已完成,待过 P0 门禁 GATE-P0)
—— 交付:cargo 工程 + 模块骨架(main/config/error/domain/channel/notify) —— 交付:cargo 工程 + 模块骨架(main/config/error/domain/channel/notify)
+ 单端口 axum 路由占位(/webhook/tg /push/jmap /reconcile /healthz /ready) + 单端口 axum 路由占位(/webhook/tg /push/jmap /reconcile /healthz /ready)
+ /healthz=liveness;/ready 阶段0 交付为占位 200,现已演进为基础探测(ARCH-READY-BASELINE:配置完整性 + Redis 可达性,不就绪→503;仍不做端到端探测,余下见 GATE-READY-DEPS) + /healthz=liveness;/ready 阶段0 交付为占位 200,现已演进为端到端探测(ARCH-READY-BASELINE:配置完整性 + Redis 可达性 + 出站只读探测 JMAP session GET / TG getMe,各 3000ms,不就绪→503)
+ env 配置骨架 + 最小测试;实际依赖:axum 0.8 / serde / serde_json / thiserror / secrecy / subtle / url / tokio / tracing;jmap-client =0.4.2(default-features=false, features=["async","rustls"]) + env 配置骨架 + 最小测试;实际依赖:axum 0.8 / serde / serde_json / thiserror / secrecy / subtle / url / tokio / tracing;jmap-client =0.4.2(default-features=false, features=["async","rustls"])
+ 入口鉴权已落地:/reconcile(Bearer) /webhook/tg(secret 头) /push/jmap(verificationCode),fail-closed SAF-AUTH-* + 入口鉴权已落地:/reconcile(Bearer) /webhook/tg(secret 头) /push/jmap(verificationCode),fail-closed SAF-AUTH-*
阶段 1 JMAP 只读(`jmap-client` 0.4.2 **已引入**,`ARCH-DEPS-STAGE1`;R1 入口鉴权已在阶段0 落地,不重复实现) 阶段 1 JMAP 只读(`jmap-client` 0.4.2 **已引入**,`ARCH-DEPS-STAGE1`;R1 入口鉴权已在阶段0 落地,不重复实现)
@ -131,7 +131,7 @@
- **AI 3 次失败**:触发熔断 → 弹确认 → 回退带"AI 不可用"徽标;`LLM_ENABLED=false` 全走回退。 - **AI 3 次失败**:触发熔断 → 弹确认 → 回退带"AI 不可用"徽标;`LLM_ENABLED=false` 全走回退。
- 正文转义、sinceState 存 Redis + 模拟 Redis 删除后由对账 `FLOW-RECONCILE` 恢复、通知去重。 - 正文转义、sinceState 存 Redis + 模拟 Redis 删除后由对账 `FLOW-RECONCILE` 恢复、通知去重。
- **入口鉴权(`SAF-AUTH-*`)**:`/reconcile`/`/webhook/tg`/`/push/jmap` 在缺失或错误凭证下返回 `401` 且**无副作用**(无 Redis 写入、无 JMAP/AI 调用);正确凭证放行。 - **入口鉴权(`SAF-AUTH-*`)**:`/reconcile`/`/webhook/tg`/`/push/jmap` 在缺失或错误凭证下返回 `401` 且**无副作用**(无 Redis 写入、无 JMAP/AI 调用);正确凭证放行。
- **健康探针(`SAF-PROBE-PUBLIC`)**:`/healthz` 返回 `200`;`/ready` 已做基础探测(`ARCH-READY-BASELINE`:配置完整性 + Redis 可达性,不就绪返 `503`)——测试**可以**断言 `/ready` 在基础依赖不就绪时返回 `503`,但**不得**断言其已执行 JMAP session / TG getMe 等端到端探测(余下范围 `GATE-READY-DEPS`);两者响应体均不含敏感信息。 - **健康探针(`SAF-PROBE-PUBLIC`)**:`/healthz` 返回 `200`;`/ready` 做端到端探测(`ARCH-READY-BASELINE`:配置完整性 + Redis 可达性 + 出站只读探测 JMAP session `GET` / TG `getMe`,各 3000ms,任一失败返 `503`)——测试**可以**断言 `/ready` 在配置 / Redis / 上游探针失败时返回 `503`;两个探针(`probe_jmap_session` / `probe_telegram_get_me`)是可复用纯函数,④ 远程 debug 直接调用,**不要另写一套**;两者响应体均不含敏感信息。
- **JMAP 只读 adapter(`MOD-JMAP-CLIENT`/`GATE-G1-JMAP-READONLY`)**:mock 测试覆盖 session/account 选择(`ACCOUNT_ID` 空→主账户、显式值校验)、**URL 归一化**(`JMAP_SESSION_URL` 基地址与完整 `…/.well-known/jmap` 两种输入结果一致、无重复路径、`http://` 拒绝、内嵌凭据拒绝,`REQ-JMAP-SESSION-URL`/`SAF-JMAP-URL`)、`list_folders`/`list_emails`(`limit` 边界)/`read_email`(多 part 拼接与"无可用部分"明确错误,`REQ-JMAP-RAW-MULTIPART`)、`received_at` 解析;真机测试用 `#[ignore]` 标记、经环境变量驱动(运行:`cargo test -- --ignored jmap::`;仅编译:`cargo test --no-run`),**缺环境时清晰跳过且不泄密**,CI 默认不跑真机用例。**G1/D-G1-1 代码已实现,待真实 `cargo test -- --ignored jmap::` 验证;未实际运行 `--ignored` 前不得声称"真机通过"。** - **JMAP 只读 adapter(`MOD-JMAP-CLIENT`/`GATE-G1-JMAP-READONLY`)**:mock 测试覆盖 session/account 选择(`ACCOUNT_ID` 空→主账户、显式值校验)、**URL 归一化**(`JMAP_SESSION_URL` 基地址与完整 `…/.well-known/jmap` 两种输入结果一致、无重复路径、`http://` 拒绝、内嵌凭据拒绝,`REQ-JMAP-SESSION-URL`/`SAF-JMAP-URL`)、`list_folders`/`list_emails`(`limit` 边界)/`read_email`(多 part 拼接与"无可用部分"明确错误,`REQ-JMAP-RAW-MULTIPART`)、`received_at` 解析;真机测试用 `#[ignore]` 标记、经环境变量驱动(运行:`cargo test -- --ignored jmap::`;仅编译:`cargo test --no-run`),**缺环境时清晰跳过且不泄密**,CI 默认不跑真机用例。**G1/D-G1-1 代码已实现,待真实 `cargo test -- --ignored jmap::` 验证;未实际运行 `--ignored` 前不得声称"真机通过"。**
- 领域/渠道解耦测试:领域模块(`src/domain.rs` / `src/ai.rs` / `src/worker.rs`)的公开接口不出现任何渠道 SDK 类型(编译期检查);`JmapBackend` 的 `MockBackend` 可驱动全部 JMAP 领域流程(不联网)。 - 领域/渠道解耦测试:领域模块(`src/domain.rs` / `src/ai.rs` / `src/worker.rs`)的公开接口不出现任何渠道 SDK 类型(编译期检查);`JmapBackend` 的 `MockBackend` 可驱动全部 JMAP 领域流程(不联网)。
- 文件行数抽查:>500 行的 `.rs` 文件在 PR 说明中可见(软性,不自动 fail)。 - 文件行数抽查:>500 行的 `.rs` 文件在 PR 说明中可见(软性,不自动 fail)。
@ -147,8 +147,8 @@
|---|---|---| |---|---|---|
| `README.md` / `README.zh-CN.md` | 面向最终用户:是什么 / 五分钟跑起来 / 配置入口 / 安全边界一句话 / 去哪读更多(英文版 / 中文版,内容须对等,顶部互链) | 用户可见行为或流程变更时 | | `README.md` / `README.zh-CN.md` | 面向最终用户:是什么 / 五分钟跑起来 / 配置入口 / 安全边界一句话 / 去哪读更多(英文版 / 中文版,内容须对等,顶部互链) | 用户可见行为或流程变更时 |
| `docs/reference.md` | **可核对事实的唯一权威来源**:Redis 键与 TTL、错误 envelope 与错误码、后端路由与网关白名单矩阵、环境变量三层、出站与预算常量 | 公共 API 或 Redis 键名/TTL 变更时(**必更**) | | `docs/reference.md` | **可核对事实的唯一权威来源**:Redis 键与 TTL、错误 envelope 与错误码、后端路由与网关白名单矩阵、环境变量三层、出站与预算常量 | 公共 API 或 Redis 键名/TTL 变更时(**必更**) |
| `docs/design.md` | 产品行为、架构、模块接口、状态机、数据流、错误处理、测试、实施阶段、渠道抽象(Channel/Notifier/MessageAdapter)、产品/架构类待确认问题 | 行为/接口变更时 | | `docs/design.md` | 产品行为、架构、模块接口、状态机、数据流、错误处理、测试、实施阶段、渠道抽象(Channel/Notifier/MessageAdapter)、历史问题与决策归档(§11,产品/架构类,均已收口) | 行为/接口变更时 |
| `docs/deployment.md` | 通用 HTTPS-only Docker、Debian、构建/运行时、Secrets、Redis(状态唯一载体)、Webhook/Push/对账短请求路由、**多实例 LB/HA(Worker 前置,§10)**、健康检查、CI、2 项待确认(`Q-DEP-A` 平台 URL/域名与证书配置 / `Q-DEP-B` 外部调度器选型) | 部署/发布变更时 | | `docs/deployment.md` | 通用 HTTPS-only Docker、Debian、构建/运行时、Secrets、Redis(状态唯一载体)、Webhook/Push/对账短请求路由、**多实例 LB/HA(Worker 前置,§10)**、健康检查、CI、外部调度示例、已确认决策表(`Q-DEP-A`/`Q-DEP-B` 已决策归档) | 部署/发布变更时 |
| `docs/roadmap.md` | **只放缺口、阻塞与阶段目标**(取代 `docs/todo.md`,已删除) | 缺口或阻塞项增减时 | | `docs/roadmap.md` | **只放缺口、阻塞与阶段目标**(取代 `docs/todo.md`,已删除) | 缺口或阻塞项增减时 |
| `docs/retired.md` | **已废弃 / 未采用路线与虚构条目**:每条含「类型 / 原因 / 替代或现状」;记录 teloxide 未采用、FSM 未落地、幽灵文件与虚构键、已删文档。**只写结论与原因,不写当前事实** | 新增废弃项或删除文档时 | | `docs/retired.md` | **已废弃 / 未采用路线与虚构条目**:每条含「类型 / 原因 / 替代或现状」;记录 teloxide 未采用、FSM 未落地、幽灵文件与虚构键、已删文档。**只写结论与原因,不写当前事实** | 新增废弃项或删除文档时 |
| `AGENTS.md`(本文件) | 目标、硬性安全边界、实现顺序、禁止事项、测试验收、文档引用关系、**跨文档引用索引(§7)** | 安全边界/流程变更时 | | `AGENTS.md`(本文件) | 目标、硬性安全边界、实现顺序、禁止事项、测试验收、文档引用关系、**跨文档引用索引(§7)** | 安全边界/流程变更时 |
@ -225,8 +225,7 @@
| `SAF-NO-SECRET-ECHO` | src/config.rs `SecretString` 字段与公共配置 API | 密钥字段不实现 `Debug` 且永不进入公共配置 API 响应;Redis 线格式类型私有,不得序列化进 HTTP 响应 | 安全 | | `SAF-NO-SECRET-ECHO` | src/config.rs `SecretString` 字段与公共配置 API | 密钥字段不实现 `Debug` 且永不进入公共配置 API 响应;Redis 线格式类型私有,不得序列化进 HTTP 响应 | 安全 |
| `SAF-PROBE-PUBLIC` | AGENTS.md §2 / design.md §7.3 | `/healthz`、`/ready` 公开探针:无鉴权、无敏感信息 | 安全 | | `SAF-PROBE-PUBLIC` | AGENTS.md §2 / design.md §7.3 | `/healthz`、`/ready` 公开探针:无鉴权、无敏感信息 | 安全 |
| `ARCH-HEALTHZ` | docs/design.md §7.3/§10.0 | `/healthz` liveness(进程存活),语义长期稳定 | 架构 | | `ARCH-HEALTHZ` | docs/design.md §7.3/§10.0 | `/healthz` liveness(进程存活),语义长期稳定 | 架构 |
| `ARCH-READY-BASELINE` | docs/design.md §7.3 / src/notify.rs `ready` | `/ready` 基础探测:配置完整性 + Redis 可达性,就绪 `200`(就绪报告 JSON),不就绪 `503`(标准错误 envelope `{"error":"service_unavailable","request_id":<id>}` + `Retry-After: 30`);**不**含 JMAP session / TG getMe 端到端探测(阶段0 交付时曾为占位 200,现已演进) | 架构 | | `ARCH-READY-BASELINE` | docs/design.md §7.3 / src/notify.rs `ready`+`probe_jmap_session`+`probe_telegram_get_me` | `/ready` 端到端就绪:配置完整性 + Redis 可达性 + **出站只读探测**(JMAP session `GET`、Telegram `getMe`,各 `PROBE_TIMEOUT`=3000ms、并行,最坏约 3s),四者全过 `200`(就绪报告 JSON 含真实 `jmap`/`telegram` 字段),任一失败 `503`(标准错误 envelope `{"error":"service_unavailable","request_id":<id>}` + `Retry-After: 30`);探针只读、无状态写入(`refresh_business_config` 仅读 Redis),bot token 仅用于拼 URL | 架构 |
| `GATE-READY-DEPS` | docs/design.md §10.0 | 余下门禁:`/ready` 补 JMAP session / TG getMe 等**端到端**依赖探测(阶段3.5+ 的剩余部分,含其测试与部署接线) | 流程 |
| `GATE-P0` | docs/design.md §10.0 | 阶段0 P0 门禁(fmt/clippy/test 过 Debian 容器等 8 项) | 流程 | | `GATE-P0` | docs/design.md §10.0 | 阶段0 P0 门禁(fmt/clippy/test 过 Debian 容器等 8 项) | 流程 |
| `BOUND-STAGE1` | docs/design.md §10.0 | 阶段1 推进边界(过 GATE-P0 才进;R1 入口鉴权已在阶段0 落地) | 流程 | | `BOUND-STAGE1` | docs/design.md §10.0 | 阶段1 推进边界(过 GATE-P0 才进;R1 入口鉴权已在阶段0 落地) | 流程 |
| `ARCH-CONFIG-ENV` | docs/design.md §7.1 | 配置=环境变量手工解析,无 figment/TOML | 架构 | | `ARCH-CONFIG-ENV` | docs/design.md §7.1 | 配置=环境变量手工解析,无 figment/TOML | 架构 |

View File

@ -71,8 +71,10 @@ secrets from the environment in normal operation.
**Health.** **Health.**
`GET /healthz` returns 200 unconditionally — it is liveness, not readiness. `GET /ready` `GET /healthz` returns 200 unconditionally — it is liveness, not readiness. `GET /ready`
returns 503 until configuration and Redis are available. Probe `/ready`, not `/healthz`, returns 503 until configuration, Redis, and both upstreams (a JMAP session `GET` and
when deciding whether to route traffic. Telegram `getMe`, 3s each) are reachable. Probe `/ready`, not `/healthz`, when deciding
whether to route traffic — but `/ready` is the heavier probe (~3s worst case), so a monitor
that must stay cheap should watch the gateway's aggregated `/healthz`.
## 4. Security boundary, in one sentence ## 4. Security boundary, in one sentence

View File

@ -65,8 +65,10 @@ docker run --env-file .env -p 8080:8080 messageweave:latest
项配置。正常运行中进程从不从环境变量读取业务密钥。 项配置。正常运行中进程从不从环境变量读取业务密钥。
**健康检查。** **健康检查。**
`GET /healthz` 无条件返回 200——它是存活探测,不是就绪探测。`GET /ready` 在配置与 Redis `GET /healthz` 无条件返回 200——它是存活探测,不是就绪探测。`GET /ready` 在配置、Redis 与
可用之前返回 503。判断是否可路由流量时,请探测 `/ready` 而不是 `/healthz`。 两条上游(JMAP session `GET`、Telegram `getMe`,各 3s)可达之前返回 503。判断是否可路由
流量时,请探测 `/ready` 而不是 `/healthz`;但 `/ready` 是较重的探针(最坏约 3s),需要
保持轻量的监控请看网关聚合的 `/healthz`。
## 4. 安全边界,一句话 ## 4. 安全边界,一句话

View File

@ -75,6 +75,7 @@
- 非 root 用户运行(保留既有约定) - 非 root 用户运行(保留既有约定)
- **公网 HTTPS 入口(`C-HTTPS-URL` 已确认)**:运维在平台上给 bot 配一个公网 HTTPS URL(如 `https://bot.example.com`),平台 ingress/反代把 `https://…/webhook/tg`、`/push/jmap`、`/reconcile` 路由到容器 `PORT`。**bot 自身不申请证书、不监听 443**;证书由平台/反代管理(`C-HTTPS-INBOUND`)。 - **公网 HTTPS 入口(`C-HTTPS-URL` 已确认)**:运维在平台上给 bot 配一个公网 HTTPS URL(如 `https://bot.example.com`),平台 ingress/反代把 `https://…/webhook/tg`、`/push/jmap`、`/reconcile` 路由到容器 `PORT`。**bot 自身不申请证书、不监听 443**;证书由平台/反代管理(`C-HTTPS-INBOUND`)。
- 需要在 Stalwart 与 Telegram 两侧使用这个公网 URL:Telegram `setWebhook` 指向 `/webhook/tg`;管理员调用 `POST /api/push/register` 并提交该 URL 的 `/push/jmap` 路径,后端负责执行 `PushSubscription/set create`。服务不会自动猜测平台公网域名。 - 需要在 Stalwart 与 Telegram 两侧使用这个公网 URL:Telegram `setWebhook` 指向 `/webhook/tg`;管理员调用 `POST /api/push/register` 并提交该 URL 的 `/push/jmap` 路径,后端负责执行 `PushSubscription/set create`。服务不会自动猜测平台公网域名。
- **出站 egress 是 `/ready` 的硬依赖**:就绪探针向配置的 JMAP session host 与 `api.telegram.org:443` 发起只读 `GET`。该 egress 不通时 `/ready` 会长期 `503`,即使配置与 Redis 都正常。若 egress 必须经 HTTP(S) 代理则**无法使用**:应用内 `reqwest` 以 `default-features = false` 编译(`Cargo.toml:12`),未启用 `proxy` feature,**不解析 `HTTPS_PROXY`/`HTTP_PROXY`**;这种情况下请用网关聚合的 `/healthz`(无条件 200)作为存活监控,`/ready` 仅作人工排查。
- **多实例 LB/HA 时登记的是 Worker URL 而非各后端 URL**(`C-LB-SINGLE-REG-URL`,§10):Telegram / Stalwart / Cron 只认 Worker 的稳定域名;后端平台入口不对外登记。 - **多实例 LB/HA 时登记的是 Worker URL 而非各后端 URL**(`C-LB-SINGLE-REG-URL`,§10):Telegram / Stalwart / Cron 只认 Worker 的稳定域名;后端平台入口不对外登记。
- Secrets 运行期注入(环境变量/容器平台 secret),见 §5(同文件内章节链接) - Secrets 运行期注入(环境变量/容器平台 secret),见 §5(同文件内章节链接)
@ -121,7 +122,7 @@ AI 授权期限由用户选择(临时一次、今天、7天或直到撤销)
> 业务鉴权变量由 Redis `config:business` 管理,不再要求作为启动环境变量。缺少 `REDIS_URL` 或非法/缺失 `CONFIG_ENCRYPTION_KEY` 时服务仍监听并提供 SPA、`/api/status` 与探针,状态为 `configuration-setup`,不会伪造持久化成功;配置恢复后再启用业务入口鉴权。 > 业务鉴权变量由 Redis `config:business` 管理,不再要求作为启动环境变量。缺少 `REDIS_URL` 或非法/缺失 `CONFIG_ENCRYPTION_KEY` 时服务仍监听并提供 SPA、`/api/status` 与探针,状态为 `configuration-setup`,不会伪造持久化成功;配置恢复后再启用业务入口鉴权。
> 示例占位见仓库根目录 [`.env.example`](../.env.example)(仅占位符,**严禁**放入真实密钥)。 > 示例占位见仓库根目录 [`.env.example`](../.env.example)(仅占位符,**严禁**放入真实密钥)。
> **配置管理页面**:服务根路径 `/` 提供嵌入 Rust 二进制的 SPA。输入 Redis URL 的 ACL 密码后,`POST /api/admin/session` 签发 900 秒 admin session;页面只在内存中保存 opaque session。运行参数通过 `/api/config` 读取和保存;完整业务配置通过 `PUT /api/business-config` 替换并热加载。业务配置 API 不提供 GET,密钥不会回显;每次完整替换都需重新输入必填密钥。配置保存在外部 Redis(`C-REDIS-ONLY-STATE`),静态资源编译时随二进制打包,无运行期本地文件。 > **配置管理页面**:服务根路径 `/` 提供嵌入 Rust 二进制的 SPA。输入 `CONFIG_ENCRYPTION_KEY` 后,`POST /api/admin/session` 签发 900 秒 admin session;页面只在内存中保存 opaque session。运行参数通过 `/api/config` 读取和保存;完整业务配置通过 `PUT /api/business-config` 替换并热加载。业务配置 API 不提供 GET,密钥不会回显;每次完整替换都需重新输入必填密钥。配置保存在外部 Redis(`C-REDIS-ONLY-STATE`),静态资源编译时随二进制打包,无运行期本地文件。
--- ---
@ -130,16 +131,18 @@ AI 授权期限由用户选择(临时一次、今天、7天或直到撤销)
### 6.0 Redis-only 配置迁移与 bootstrap 威胁模型(`C-REDIS-ONLY-STATE`) ### 6.0 Redis-only 配置迁移与 bootstrap 威胁模型(`C-REDIS-ONLY-STATE`)
生产进程接受 `REDIS_URL` 与唯一额外启动密钥 `CONFIG_ENCRYPTION_KEY`(32 字节随机 生产进程接受 `REDIS_URL` 与唯一额外启动密钥 `CONFIG_ENCRYPTION_KEY`(32 字节随机
高熵 hex,仅应用运行时持有);其中 Redis ACL 认证是唯一 bootstrap 信任根;业务 高熵 hex,仅应用运行时持有);bootstrap 与 admin 会话的认证凭据即 `CONFIG_ENCRYPTION_KEY`
密钥和运行参数不再从环境变量读取。空 Redis 仅提供配置页面及一次性的 bootstrap 本身;业务密钥和运行参数不再从环境变量读取。空 Redis 仅提供配置页面及一次性的 bootstrap
会话:请求必须以 Redis ACL 密码作为 Bearer 凭据,服务端只做常数时间比较,绝不在 会话:请求必须以 `CONFIG_ENCRYPTION_KEY` 作为 Bearer 凭据,服务端只做常数时间比较,绝不在
响应、日志或配置值中回显该密码。bootstrap 使用原子 `SET NX` 写入完整业务配置, 响应、日志或配置值中回显该密钥。bootstrap 使用原子 `SET NX` 写入完整业务配置,
竞争请求只有一个成功;无密码 Redis 或认证失败时拒绝初始化。 竞争请求只有一个成功;**ACL 密码缺失不再阻塞初始化**(TLS-only 托管 Redis 场景可用),
仅 `CONFIG_ENCRYPTION_KEY` 缺失或鉴权失败时拒绝。
该 ACL 身份必须具备 `config:business`、`config:outbound` 及 admin-session 键的读写权限, 该 ACL 身份是应用自身的 Redis 连接凭据,仍必须具备 `config:business`、
因为它同时是应用 Redis 连接凭据与空库 bootstrap 根信任。bootstrap 完成后公网业务配置 `config:outbound` 及 `config:admin_session` 键的读写权限(bootstrap 与管理员 PUT 均经此
接口只接受 900 秒 admin session;持有 ACL 密码者仍等同根信任,必须按生产 Redis 凭据 连接写入),不得误配置为禁止配置键访问;但它不再是任何 HTTP 认证的信任根——业务配置
同等级保护,不得把该身份误配置为禁止配置键访问。 明文受 `CONFIG_ENCRYPTION_KEY` 做 AES-256-GCM 加密保护,仅持有 ACL 密码读取到的也是
密文。bootstrap 完成后公网业务配置接口只接受 900 秒 admin session。
成功 bootstrap 后,后续配置读写只能使用 Redis 中保存的管理员会话(短 TTL,注销 成功 bootstrap 后,后续配置读写只能使用 Redis 中保存的管理员会话(短 TTL,注销
或配置更新时失效);业务端点使用 Redis 配置中下发的独立令牌。管理员会话只保存 或配置更新时失效);业务端点使用 Redis 配置中下发的独立令牌。管理员会话只保存
@ -172,7 +175,7 @@ TLS 必须由 Cloudflare 或受信任反向代理终结;代理到容器的链
GET / 业务配置与运行参数管理 SPA GET / 业务配置与运行参数管理 SPA
GET /assets/config.js SPA 脚本;GET /assets/styles.css SPA 样式 GET /assets/config.js SPA 脚本;GET /assets/styles.css SPA 样式
GET /api/status 公开启动状态;只返回 ready/mode/missing 环境变量名 GET /api/status 公开启动状态;只返回 ready/mode/missing 环境变量名
POST /api/admin/session Bearer Redis ACL 密码;成功返回 900 秒 admin session POST /api/admin/session Bearer CONFIG_ENCRYPTION_KEY;成功返回 900 秒 admin session
POST /api/admin/session/revoke Bearer admin session;成功返回 204 POST /api/admin/session/revoke Bearer admin session;成功返回 204
GET|PUT /api/config Bearer admin session 或 WORKER_TOKEN;Redis 错误返回 503 GET|PUT /api/config Bearer admin session 或 WORKER_TOKEN;Redis 错误返回 503
PUT /api/business-config Bearer admin session 或 WORKER_TOKEN;完整替换,成功返回 204 PUT /api/business-config Bearer admin session 或 WORKER_TOKEN;完整替换,成功返回 204
@ -183,11 +186,11 @@ POST /push/jmap Stalwart Push 回调 → [鉴权 SAF-AUTH-JMAP-PUSH: Body
POST /reconcile 外部 HTTPS Cron 触发 → [鉴权 SAF-AUTH-RECONCILE: Authorization: Bearer RECONCILE_TOKEN] POST /reconcile 外部 HTTPS Cron 触发 → [鉴权 SAF-AUTH-RECONCILE: Authorization: Bearer RECONCILE_TOKEN]
→ 对账补差 FLOW-RECONCILE → 对账补差 FLOW-RECONCILE
GET /healthz liveness(ARCH-HEALTHZ:进程存活;公开探针 SAF-PROBE-PUBLIC,无鉴权、无敏感信息) GET /healthz liveness(ARCH-HEALTHZ:进程存活;公开探针 SAF-PROBE-PUBLIC,无鉴权、无敏感信息)
GET /ready 公开就绪探针;检查配置完整性与 Redis 可访问性,就绪返回 200+就绪报告 JSON,未就绪返回 503+标准错误 envelope(无鉴权、无敏感信息) GET /ready 公开就绪探针;检查配置完整性 + Redis 可达性 + 出站只读探测(JMAP `/.well-known/jmap` 带 Basic 认证、Telegram getMe,各 3s),四者全过返回 200+就绪报告 JSON,任一失败返回 503+标准错误 envelope(无鉴权、无敏感信息)
``` ```
`/reconcile` 使用 Redis owner-token 单飞锁并在长任务期间续租;每次最多处理 100 页、10,000 封基线邮件或 20 秒。已完成入队的 changes 页会提交最新游标;冷启动基线超出预算时把基线状态与位置编码后持久化,下一次从断点继续,不从头扫描,也不会提前跳过未列举邮件。 `/reconcile` 使用 Redis owner-token 单飞锁并在长任务期间续租;每次最多处理 100 页、10,000 封基线邮件或 20 秒。已完成入队的 changes 页会提交最新游标;冷启动基线超出预算时把基线状态与位置编码后持久化,下一次从断点继续,不从头扫描,也不会提前跳过未列举邮件。
> **写入口鉴权(fail-closed,`SAF-AUTH-*`)**:Webhook、Push、Reconcile 以及 Push 注册接口都必须先通过鉴权,**失败返回 `401` 且不产生副作用**;比较使用常数时间(`subtle`,防时序侧信道)。secret 未配置 → 启动失败,**无"缺省放行"**。 > **写入口鉴权(fail-closed,`SAF-AUTH-*`)**:Webhook、Push、Reconcile 以及 Push 注册接口都必须先通过鉴权,**失败返回 `401` 且不产生副作用**;比较使用常数时间(`subtle`,防时序侧信道)。secret 未配置 → 启动失败,**无"缺省放行"**。
> **健康探针(`SAF-PROBE-PUBLIC`)**:`/healthz`、`/ready` 仅返回健康状态、**不含敏感信息**。`/healthz` 只表示进程存活;`/ready` 检查配置完整性与 Redis 可访问性,不发起 JMAP/Telegram 请求,也不触发邮件同步或其他副作用。 > **健康探针(`SAF-PROBE-PUBLIC`)**:`/healthz`、`/ready` 仅返回健康状态、**不含敏感信息**。`/healthz` 只表示进程存活;`/ready` 检查配置完整性与 Redis 可访问性,并发起只读出站探测(JMAP `/.well-known/jmap` 带 Basic 认证 与 Telegram getMe,各 3s、并行);不触发邮件同步等业务副作用。
> **"公网 HTTPS 入口"是什么**(`C-HTTPS-URL`):平台给 bot 一个公网 HTTPS 域名,外部(Telegram / Stalwart / 调度器)通过它访问上面这些路径;容器只处理明文 HTTP,TLS 由平台终止。运维只需在平台上配置域名/证书并确保 4 条路径可达。 > **"公网 HTTPS 入口"是什么**(`C-HTTPS-URL`):平台给 bot 一个公网 HTTPS 域名,外部(Telegram / Stalwart / 调度器)通过它访问上面这些路径;容器只处理明文 HTTP,TLS 由平台终止。运维只需在平台上配置域名/证书并确保 4 条路径可达。
### 6.2 Redis Streams / worker(MOD-STREAMS) ### 6.2 Redis Streams / worker(MOD-STREAMS)
@ -203,6 +206,80 @@ GET /ready 公开就绪探针;检查配置完整性与 Redis 可访
- 对账逻辑:用 `sinceState` 调 `Email/changes` 拉增量 → 与已处理 email_id 求差 → 补发通知 → 推进 `sinceState`。 - 对账逻辑:用 `sinceState` 调 `Email/changes` 拉增量 → 与已处理 email_id 求差 → 补发通知 → 推进 `sinceState`。
- **Redis 丢失恢复**:sinceState 存 Redis(`MOD-SINCESTATE`,AOF 持久化 `C-REDIS-MANAGED-AOF`);即便 Redis 全丢,对账扫描 JMAP(`Email/query` 最近 N 封 + changes)也能重建游标并补发——**事实源在 JMAP,Redis 只是加速层**。 - **Redis 丢失恢复**:sinceState 存 Redis(`MOD-SINCESTATE`,AOF 持久化 `C-REDIS-MANAGED-AOF`);即便 Redis 全丢,对账扫描 JMAP(`Email/query` 最近 N 封 + changes)也能重建游标并补发——**事实源在 JMAP,Redis 只是加速层**。
#### 6.3.1 用户侧调度示例(copy-paste 可用)
端点:`POST https://<你的平台URL>/reconcile`。鉴权:`Authorization: Bearer <RECONCILE_TOKEN>`
(`src/config.rs:356` 启动必填,服务端常数时间比较,`notify.rs:149-152`)。
最小示例:
```bash
curl -sS -o /dev/null -w '%{http_code}\n' \
-X POST https://<你的平台URL>/reconcile \
-H "Authorization: Bearer $RECONCILE_TOKEN" \
--max-time 60
```
调度器任选其一(均为外部,容器不自建定时器):
```cron
# 系统 crontab,每 5 分钟
*/5 * * * * curl -fsS -m 60 -X POST https://<你的平台URL>/reconcile -H "Authorization: Bearer $RECONCILE_TOKEN" >/dev/null
```
```yaml
# Kubernetes CronJob,每 5 分钟
apiVersion: batch/v1
kind: CronJob
metadata:
name: messageweave-reconcile
spec:
schedule: "*/5 * * * *"
concurrencyPolicy: Forbid
successfulJobsHistoryLimit: 1
failedJobsHistoryLimit: 1
jobTemplate:
spec:
template:
spec:
restartPolicy: Never
containers:
- name: reconcile
image: curlimages/curl:8
# 用 shell 启动,才能展开 $RECONCILE_TOKEN;直接 exec curl 时 $(…) 不会被替换
command: ["/bin/sh", "-c"]
args:
- 'curl -fsS -m 60 -X POST "https://<你的平台URL>/reconcile" -H "Authorization: Bearer $RECONCILE_TOKEN"'
env:
- name: RECONCILE_TOKEN
valueFrom:
secretKeyRef:
name: messageweave
key: reconcile-token
```
**期望状态码**
| 状态码 | 含义 | 调度器怎么办 |
|---|---|---|
| `204` | 成功,游标已推进(无响应体) | 正常,不做任何处理 |
| `401` | `RECONCILE_TOKEN` 缺失或错误 | 不要重试;检查凭据 |
| `409` | 单飞锁被占用,另有实例正在对账 | 不要重试;等下一个调度周期 |
| `503` | 业务开关关闭,或 JMAP/Redis 故障 | 按 `Retry-After: 30` 头重试,或等下一个周期 |
失败响应体统一为 `{"error": <code>, "request_id": <实例 owner id>}`,可直接落日志。
**重试建议**
- 对账逻辑本身是幂等的:重复调用只会重复走 `Email/changes` 增量并二次去重入队(`MOD-DEDUP`
兜底),不会重复通知。所以对 `503` 可以放心重试。
- `204` 与 `409` 都是正常结果,不要把它们当失败重试——尤其别对 `409` 做紧密循环重试。
- 调度间隔按 `NFR-RECONCILE-INTERVAL` 取 **5–10 分钟**即可。Push 是主路径,对账只是兜底。
- 走 Worker 转发时注意:网关默认单请求超时 10 s、最多 2 次尝试(`index.js:29-30`),而
单飞锁租期是 300 s(对账持锁心跳每 30 s 续租至 90 s,`notify.rs:159`/`172`)。一次完整
对账可能超出网关转发窗口——若确实经 Worker 调度,请把 `LB_REQUEST_TIMEOUT_MS` 调到
大于锁租期,或让调度器直接打到后端 origin。
### 6.4 可靠性策略(Reliability) ### 6.4 可靠性策略(Reliability)
> 目标:**至少 99.9% 通知可用性**(`NFR-NOTIFY-SLA`),允许少量延迟(不追求秒级保证)。下述机制共同保证"不丢、少重、可恢复"。 > 目标:**至少 99.9% 通知可用性**(`NFR-NOTIFY-SLA`),允许少量延迟(不追求秒级保证)。下述机制共同保证"不丢、少重、可恢复"。
@ -213,7 +290,7 @@ GET /ready 公开就绪探针;检查配置完整性与 Redis 可访
| **幂等去重** | `MOD-DEDUP` | 以 `(account, email_id)` 为幂等键,`SET NX`(TTL 覆盖重投窗口)。重复投递直接跳过,保证 at-least-once 下**不重复通知**。TG 侧同理用 `update_id` | | **幂等去重** | `MOD-DEDUP` | 以 `(account, email_id)` 为幂等键,`SET NX`(TTL 覆盖重投窗口)。重复投递直接跳过,保证 at-least-once 下**不重复通知**。TG 侧同理用 `update_id` |
| **Push 重试** | `FLOW-NEW-MAIL` | `/push/jmap` 校验/入队后**立即 2xx**;若入队失败(Redis 抖动)返回非 2xx,让 Stalwart 按自身策略重试;配合对账兜底 | | **Push 重试** | `FLOW-NEW-MAIL` | `/push/jmap` 校验/入队后**立即 2xx**;若入队失败(Redis 抖动)返回非 2xx,让 Stalwart 按自身策略重试;配合对账兜底 |
| **对账恢复** | `FLOW-RECONCILE` | 外部 Cron 每 5–10 分钟调用 `/reconcile`;从 Redis 恢复 `state:jmap:since`,按 `Email/changes` 补差,入队成功后推进游标,失败返回 `503` | | **对账恢复** | `FLOW-RECONCILE` | 外部 Cron 每 5–10 分钟调用 `/reconcile`;从 Redis 恢复 `state:jmap:since`,按 `Email/changes` 补差,入队成功后推进游标,失败返回 `503` |
| **运行监控 / 告警** | Uptime Kuma | 使用 HTTP(s) Monitor 检查 `/healthz`(进程存活)和 `/ready`(配置/Redis 就绪),分别期望 200;对 503、超时、TLS/DNS/路由故障告警。应用不引入 Prometheus、Exporter 或额外指标端口。 | | **运行监控 / 告警** | Uptime Kuma | 使用 HTTP(s) Monitor 检查 `/healthz`(进程存活,无条件 200)和 `/ready`(配置 / Redis / 上游可达就绪),分别期望 200;对 503、超时、TLS/DNS/路由故障告警。`/ready` 最坏约 3s(两个 3s 探针并行),探针超时设 ≥10s。应用不引入 Prometheus、Exporter 或额外指标端口。 |
### 6.5 99.9% 可用性目标与边界(NFR-NOTIFY-SLA) ### 6.5 99.9% 可用性目标与边界(NFR-NOTIFY-SLA)
@ -227,9 +304,9 @@ GET /ready 公开就绪探针;检查配置完整性与 Redis 可访
## 7. 健康检查(通用 HTTP 约定) ## 7. 健康检查(通用 HTTP 约定)
- `GET /healthz` → liveness(`ARCH-HEALTHZ`;进程存活,长期语义) - `GET /healthz` → liveness(`ARCH-HEALTHZ`;进程存活,长期语义)
- `GET /ready` → 基础就绪探针(`ARCH-READY-BASELINE`):检查配置完整性与 Redis 可访问性;就绪 `200`(就绪报告 JSON),不就绪 `503`(标准错误 envelope `{"error":"service_unavailable","request_id":<id>}` + `Retry-After: 30`);**不代表** Redis/JMAP/TG 依赖已通过端到端检查。Uptime Kuma 按状态码(期望 200)监控,不受响应体变化影响。 - `GET /ready` → 端到端就绪探针(`ARCH-READY-BASELINE`):检查配置完整性 + Redis 可达性 + 出站只读探测(`GET {jmap_origin}/.well-known/jmap` 带 Basic 认证、`GET https://api.telegram.org/bot<token>/getMe`,各 3s 超时、**并行**(`tokio::join!`),最坏约 3s);四者全过 `200`(就绪报告 JSON 含真实 `jmap`/`telegram` 字段),任一失败 `503`(标准错误 envelope `{"error":"service_unavailable","request_id":<id>}` + `Retry-After: 30`)。因此**LB / ingress 的探针超时必须 > 3s(建议 ≥10s)**。Uptime Kuma 按状态码(期望 200)监控,不受响应体变化影响。
- 两者均为**公开探针**(`SAF-PROBE-PUBLIC`):无鉴权、仅返回健康状态、**不含敏感信息**。 - 两者均为**公开探针**(`SAF-PROBE-PUBLIC`):无鉴权、仅返回健康状态、**不含敏感信息**。
- `/ready` 已检查配置完整性与 Redis 可访问性,但不代表 JMAP/Telegram 已完成真实端到端验收;编排与 Uptime Kuma 可用它做基础就绪探测。 - `/ready` 已对 JMAP session 与 Telegram getMe 做**真实出站探测**(3s/个),但不覆盖真实消息投递验收;编排与 Uptime Kuma 可用它做端到端就绪探测,注意其最坏约 3s,LB 探测超时需 > 3s(见上)。
- Docker `HEALTHCHECK` 指向同一监听端口(app 内置 `health` 子命令或 wget 同端口),**不依赖独立端口**(`C-NO-TCP-EXPOSE`) - Docker `HEALTHCHECK` 指向同一监听端口(app 内置 `health` 子命令或 wget 同端口),**不依赖独立端口**(`C-NO-TCP-EXPOSE`)
- 仅保留通用平台映射(compose `HEALTHCHECK`、k8s probe);不写 Cloud Run/Fly 等特指内容(`NG-SERVERLESS-BIND`) - 仅保留通用平台映射(compose `HEALTHCHECK`、k8s probe);不写 Cloud Run/Fly 等特指内容(`NG-SERVERLESS-BIND`)
@ -274,7 +351,7 @@ docker run --env-file .env -p 8080:8080 messageweave:latest
| 端点 | 语义 | 用途 | | 端点 | 语义 | 用途 |
|---|---|---| |---|---|---|
| `GET /healthz` | 无条件 200 | 存活探测(进程还活着) | | `GET /healthz` | 无条件 200 | 存活探测(进程还活着) |
| `GET /ready` | 配置与 Redis 就绪前 503 | 就绪探测、负载均衡摘除、Uptime Kuma | | `GET /ready` | 配置、Redis 或上游探针失败时 503 | 就绪探测、负载均衡摘除、Uptime Kuma |
| `GET /api/status` | 缺必需环境变量时 503 + `{"status":"configuration-setup","missing":[...]}` | 排查"起来了但没干活" | | `GET /api/status` | 缺必需环境变量时 503 + `{"status":"configuration-setup","missing":[...]}` | 排查"起来了但没干活" |
> **由平台 ingress 探测 `/ready`。**镜像内没有 curl/wget、也没有 CLI 子命令,不要依赖容器内 > **由平台 ingress 探测 `/ready`。**镜像内没有 curl/wget、也没有 CLI 子命令,不要依赖容器内
@ -311,8 +388,8 @@ docker run --env-file .env -p 8080:8080 messageweave:latest
| `C-AUTH-APP-BASIC` | Stalwart 认证 = **App Password + Basic**(不用主密码、不用 OAuth) | | `C-AUTH-APP-BASIC` | Stalwart 认证 = **App Password + Basic**(不用主密码、不用 OAuth) |
| `REQ-AI-EXTERNAL-CONSENT` | **仅当用户明确允许时**才把邮件正文发往外部 AI(默认不外发) | | `REQ-AI-EXTERNAL-CONSENT` | **仅当用户明确允许时**才把邮件正文发往外部 AI(默认不外发) |
| `C-REDIS-MANAGED-AOF` | Redis 由**用户托管**并**开启 AOF 持久化**(Bot 不自建/不管理 Redis) | | `C-REDIS-MANAGED-AOF` | Redis 由**用户托管**并**开启 AOF 持久化**(Bot 不自建/不管理 Redis) |
| `C-HTTPS-URL` | **公网 HTTPS 入口由平台提供**(平台给 bot 一个 HTTPS URL;bot 不持证书、不监听 443) | | `C-HTTPS-URL` | **公网 HTTPS 入口由平台提供**(平台给 bot 一个 HTTPS URL;bot 不持证书、不监听 443);`Q-DEP-A` 已决策:具体平台 URL / 域名与证书配置方(平台自动证书 or 自管反代二选一)由部署环境在发布时确定,仓库文档化两条路径与 4 条路径的可达性验证方式,不替部署方做选择 |
| `NFR-RECONCILE-INTERVAL` | **外部 Cron 定期 HTTPS POST `/reconcile`,建议 5–10 分钟**;容器不自建定时器 | | `NFR-RECONCILE-INTERVAL` | **外部 Cron 定期 HTTPS POST `/reconcile`,建议 5–10 分钟**;容器不自建定时器;`Q-DEP-B` 已决策:调度器不限定实现,任意 shell / cron / K8s CronJob / CI scheduled 均可,用户侧示例见 §6.3.1 |
| `NFR-NOTIFY-SLA` | **允许少量通知延迟;通知可用性目标 ≥ 99.9%**(边界见 §6.5) | | `NFR-NOTIFY-SLA` | **允许少量通知延迟;通知可用性目标 ≥ 99.9%**(边界见 §6.5) |
| `ARCH-LB-WORKER` | **多实例高可用**:免费 Cloudflare Worker 作为**唯一对外入口 + 故障转移**,后端为 2+ 个不同 serverless 平台的同镜像实例,共享同一 Redis(详见 §10) | | `ARCH-LB-WORKER` | **多实例高可用**:免费 Cloudflare Worker 作为**唯一对外入口 + 故障转移**,后端为 2+ 个不同 serverless 平台的同镜像实例,共享同一 Redis(详见 §10) |
| `SAF-LB-PASSTHRU` | 信任模型 = **透传(A)**:Worker 不改写鉴权信息,**后端必须继续 fail-closed 校验**(小平台无防火墙/ACL,"后端不对公网暴露"不可行) | | `SAF-LB-PASSTHRU` | 信任模型 = **透传(A)**:Worker 不改写鉴权信息,**后端必须继续 fail-closed 校验**(小平台无防火墙/ACL,"后端不对公网暴露"不可行) |
@ -325,13 +402,10 @@ docker run --env-file .env -p 8080:8080 messageweave:latest
| `SAF-LOG-PURITY` | **日志与 Redis 写入不得包含**:密钥、邮件正文、AI 请求/响应、附件内容;仅允许结构化事件、计数、时间戳、脱敏摘要 | | `SAF-LOG-PURITY` | **日志与 Redis 写入不得包含**:密钥、邮件正文、AI 请求/响应、附件内容;仅允许结构化事件、计数、时间戳、脱敏摘要 |
| `C-NO-STATEFUL-RECOVERY` | **禁止依赖进程内状态做生产恢复**:重启恢复一律走 Redis + JMAP 对账(`FLOW-RECONCILE`);进程内缓存仅为性能优化,丢失必须安全可重入 | | `C-NO-STATEFUL-RECOVERY` | **禁止依赖进程内状态做生产恢复**:重启恢复一律走 Redis + JMAP 对账(`FLOW-RECONCILE`);进程内缓存仅为性能优化,丢失必须安全可重入 |
### 9.2 仍需确认(部署相关,仅此) > **最后一项待确认已清空**:`Q-DEP-A`(平台 URL / 域名与证书配置方)与 `Q-DEP-B`(外部调度器选型)
> 均已决策,分别合并进本表的 `C-HTTPS-URL` 与 `NFR-RECONCILE-INTERVAL` 行。两项都是上线时的运维选择,
| # | 待确认 | 关联 ID | > 不影响代码结构、不影响门禁;`docs/roadmap.md` 已不再保留「决策待定」块。
|---|---|---| >
| **Q-DEP-A** | 平台 URL / 域名与证书由谁配置(平台自动证书 or 自管反代),以及 4 条路径的可达性验证方式 | `C-HTTPS-URL` |
| **Q-DEP-B** | 外部调度器具体选型(系统 crontab / k8s CronJob / CI scheduled / 第三方 cron)—— 仅影响运维方式,不改变架构 | `NFR-RECONCILE-INTERVAL` |
> 其余产品/架构问题见 design.md;不再有平台特定部署问题(`NG-SERVERLESS-BIND`)。 > 其余产品/架构问题见 design.md;不再有平台特定部署问题(`NG-SERVERLESS-BIND`)。
--- ---
@ -372,8 +446,8 @@ Stalwart PushSub ────┼─▶ https://lb.<you>.workers.dev ← 唯
### 10.4 路由与故障转移 ### 10.4 路由与故障转移
- **路由 safelist**(`C-LB-SINGLE-REG-URL`):Worker 只透传 `GET /`、SPA 静态资源、`GET /api/status`、`GET|PUT /api/config`、`PUT /api/business-config`、`POST /api/admin/session`、`POST /api/admin/session/revoke`、`POST /webhook/tg`、`POST /push/jmap`、`POST /reconcile`、`GET /ready`;**未知路径 404、method 不符 405**,不透传至后端。**`POST /api/push/register` 不在 safelist 内**(Worker 对其返回 404),Push 注册必须按 §10.5 直接访问某个后端实例地址,不能走 Worker 域名。`/api/status` 只返回启动状态和缺少的环境变量名;所有管理 API 的 Bearer 鉴权由后端执行(`SAF-LB-PASSTHRU`)。 - **路由 safelist**(`C-LB-SINGLE-REG-URL`):Worker 只透传 **14 条**(`backends.js:9-24` 的 `SAFE_ROUTES` + `index.js:40-55` 的 `ROUTE_METHODS`):`GET /`、`/assets/config.js`、`/assets/styles.css`、`GET /api/status`、`GET|PUT /api/config`、`PUT /api/business-config`、`POST /api/admin/session`、`POST /api/admin/session/revoke`、`POST /webhook/tg`、`POST /push/jmap`、`POST /api/push/register`、`POST /api/push/disable`、`POST /reconcile`、`GET /ready`;**未知路径 404、method 不符 405**,不透传至后端。**后端另有 4 条不在 safelist**:`GET|PUT /api/enabled`(内部开关)、`POST /api/bootstrap`(一次性信任引导,**不能**走 Worker 域名)、`POST /worker`(运维手工触发的 worker 端点,Bearer 鉴权)、`GET /healthz`(网关自行聚合,不转发)——**这 4 条都不承载外部业务流量,因此后端实例前不需要第二道入口**。`/api/status` 只返回启动状态和缺少的环境变量名;所有管理 API 的 Bearer 鉴权由后端执行(`SAF-LB-PASSTHRU`)。
- **健康聚合(`MOD-HEALTH-AGG`)**:Worker 自行承载 `GET /healthz`,按 TTL 缓存(默认 30s,`LB_HEALTH_TTL_MS` 可调)探测各后端 `/healthz`,返回 `{status, available, total, backends:[{origin,up,status}]}`;≥1 后端 up → 200,全 down → 503。`/ready` 透传给后端,检查后端配置与 Redis 基础就绪,但不触发 JMAP/Telegram 副作用。 - **健康聚合(`MOD-HEALTH-AGG`)**:Worker 自行承载 `GET /healthz`,按 TTL 缓存(默认 30s,`LB_HEALTH_TTL_MS` 可调)探测各后端 `/healthz`,返回 `{status, available, total, backends:[{origin,up,status}]}`;≥1 后端 up → 200,全 down → 503。`/ready` 透传给后端,做配置 + Redis + 出站只读探测(JMAP session、TG getMe,各 3s、并行,最坏约 3s),不触发业务副作用。
- **故障转移(`forwardWithFailover`)**:每次请求最多 `min(LB_MAX_ATTEMPTS, origins.length)` 次尝试;**仅**超时(AbortError)或 5xx 触发换下一个 origin;4xx/2xx/3xx 直接返回;默认 `LB_MAX_ATTEMPTS=2`(首次 + 1 次故障转移)。 - **故障转移(`forwardWithFailover`)**:每次请求最多 `min(LB_MAX_ATTEMPTS, origins.length)` 次尝试;**仅**超时(AbortError)或 5xx 触发换下一个 origin;4xx/2xx/3xx 直接返回;默认 `LB_MAX_ATTEMPTS=2`(首次 + 1 次故障转移)。
- **随机分摊**:起点 origin 按 `Math.random` 随机化,实现双活;单 origin 配置时退化为确定性。 - **随机分摊**:起点 origin 按 `Math.random` 随机化,实现双活;单 origin 配置时退化为确定性。
- **全失败兜底**:返回 `503 All Backends Unavailable`,交由 Telegram / Stalwart 自动重投(**不丢消息**)。 - **全失败兜底**:返回 `503 All Backends Unavailable`,交由 Telegram / Stalwart 自动重投(**不丢消息**)。
@ -388,6 +462,7 @@ Stalwart PushSub ────┼─▶ https://lb.<you>.workers.dev ← 唯
|---|---|---| |---|---|---|
| `/webhook/tg` | 可安全重复(`update_id` 去重) | Worker 选一实例;重试安全 | | `/webhook/tg` | 可安全重复(`update_id` 去重) | Worker 选一实例;重试安全 |
| `/push/jmap` | 可安全重复(`(account,email_id)` 去重) | Worker 选一实例 | | `/push/jmap` | 可安全重复(`(account,email_id)` 去重) | Worker 选一实例 |
| `POST /api/push/register` | 幂等(相同 callback URL 复用) | 已进 safelist,可走 Worker 域名;callback URL 由调用方提供、订阅记录全存共享 Redis,落到哪个后端实例都不影响 |
| `/reconcile` | ❌ **不可扇出** | **Redis 锁**(`SAF-RECONCILE-LOCK`)保证**同一时刻仅一个实例执行**,避免重复对账 | | `/reconcile` | ❌ **不可扇出** | **Redis 锁**(`SAF-RECONCILE-LOCK`)保证**同一时刻仅一个实例执行**,避免重复对账 |
| Streams worker | 用**相同消费组名**(`MOD-STREAMS-GROUP`) | Redis `XREADGROUP` **自动分摊**给多实例;at-least-once 下同一消息不会重复处理 | | Streams worker | 用**相同消费组名**(`MOD-STREAMS-GROUP`) | Redis `XREADGROUP` **自动分摊**给多实例;at-least-once 下同一消息不会重复处理 |

View File

@ -18,17 +18,17 @@
1. [任务与范围](#1-任务与范围) 1. [任务与范围](#1-任务与范围)
2. [jmap-client 能力分析](#2-jmap-client-能力分析) 2. [jmap-client 能力分析](#2-jmap-client-能力分析)
3. [认证与邮箱操作适配](#3-认证与邮箱操作适配) 3. [认证与邮箱操作适配](#3-认证与邮箱操作适配)
4. [Telegram Rust 框架选型](#4-telegram-rust-框架选型) 4. [Telegram 渠道实现选型](#4-telegram-渠道实现选型)
5. [整体架构与数据流](#5-整体架构与数据流) 5. [整体架构与数据流](#5-整体架构与数据流)
6. [模块划分](#6-模块划分) 6. [模块划分](#6-模块划分)
7. [配置与安全](#7-配置与安全) 7. [配置与安全](#7-配置与安全)
8. [错误处理与可观测性](#8-错误处理与可观测性) 8. [错误处理与可观测性](#8-错误处理与可观测性)
9. [测试策略](#9-测试策略) 9. [测试策略](#9-测试策略)
10. [分阶段实施计划](#10-分阶段实施计划) 10. [分阶段实施计划](#10-分阶段实施计划)
11. [待确认问题(产品/架构类)](#11-待确认问题产品架构类) 11. [历史问题与决策归档(产品/架构类,均已有结论)](#11-历史问题与决策归档产品架构类均已有结论)
12. [AI 辅助能力:架构、确认门槛、失败回退](#12-ai-辅助能力架构确认门槛失败回退) 12. [AI 辅助能力:架构、确认门槛、失败回退](#12-ai-辅助能力架构确认门槛失败回退)
> 部署/平台类决策已确认(单账户、App Password+Basic、Redis 托管+AOF、平台 HTTPS URL、外部 Cron)并收敛;**无未完成代码缺口**,仅余 `Q-DEP-A`/`Q-DEP-B` 两项上线时的运维选择,见 `docs/roadmap.md`。 > 部署/平台类决策已确认(单账户、App Password+Basic、Redis 托管+AOF、平台 HTTPS URL、外部 Cron)并**全部收敛归档**(含 `Q-DEP-A`/`Q-DEP-B`,见 `docs/deployment.md` 的已确认决策一节);**未完成的代码缺口见 `docs/roadmap.md`**。
--- ---
@ -233,7 +233,7 @@ Telegram 渠道用 `src/channel.rs` 的 `reqwest` 自研实现(`ARCH-DEPS-STAG
- 启动时: - 启动时:
1. 加载配置(`config::Config`)。 1. 加载配置(`config::Config`)。
2. 构造 `JmapService`(`Client::connect` 完成 session 解析、account_id 缓存、mailbox role→id 映射预热);sinceState 从外部 Redis 恢复(`MOD-SINCESTATE`)。 2. 构造 `JmapService`(`Client::connect` 完成 session 解析、account_id 缓存、mailbox role→id 映射预热);sinceState 从外部 Redis 恢复(`MOD-SINCESTATE`)。
3. 启动 **HTTP 入口**(axum,单端口 `PORT`):`/webhook/tg`、`/push/jmap`、`/reconcile`、`/healthz`、`/ready`;其中三条写路径先经 `SAF-AUTH-*` 入口鉴权(fail-closed,§7.3),`/healthz`、`/ready` 为公开轻量探针(`SAF-PROBE-PUBLIC`),不代表外部依赖已完成端到端验收。 3. 启动 **HTTP 入口**(axum,单端口 `PORT`):`/webhook/tg`、`/push/jmap`、`/reconcile`、`/healthz`、`/ready`;其中三条写路径先经 `SAF-AUTH-*` 入口鉴权(fail-closed,§7.3),`/healthz`、`/ready` 为公开探针(`SAF-PROBE-PUBLIC`);`/ready` 已做端到端探测(配置 + Redis + 出站只读探测 JMAP session `GET` 与 TG `getMe`,各 3s、并行,最坏约 3s)。
4. 启动 **Redis Streams worker**(后台 task)消费 Push 事件 → `Email/changes` → 通知 → 发往 TG → 推进 sinceState → XACK。 4. 启动 **Redis Streams worker**(后台 task)消费 Push 事件 → `Email/changes` → 通知 → 发往 TG → 推进 sinceState → XACK。
5. **不持有任何长连接、不自建定时器**(`C-NO-LONG-CONN`);对账由**外部 HTTPS Cron** 触发 `/reconcile`(`FLOW-RECONCILE`)。 5. **不持有任何长连接、不自建定时器**(`C-NO-LONG-CONN`);对账由**外部 HTTPS Cron** 触发 `/reconcile`(`FLOW-RECONCILE`)。
- 通知发送与命令处理共享 `Arc<JmapService>`,内部 `tokio::sync::RwLock` 保护可变缓存;跨请求状态一律落外部 Redis(`C-REDIS-ONLY-STATE`)。 - 通知发送与命令处理共享 `Arc<JmapService>`,内部 `tokio::sync::RwLock` 保护可变缓存;跨请求状态一律落外部 Redis(`C-REDIS-ONLY-STATE`)。
@ -334,6 +334,8 @@ message-weave/
| `notify` | axum;redis | HTTP 鉴权、全局开关、`/push/jmap` 入队、`Email/changes` 对账和游标提交 | webhook 校验失败路径覆盖 403 | 已覆盖真实对账路径;仍需真实 Stalwart 环境做端到端验收 | | `notify` | axum;redis | HTTP 鉴权、全局开关、`/push/jmap` 入队、`Email/changes` 对账和游标提交 | webhook 校验失败路径覆盖 403 | 已覆盖真实对账路径;仍需真实 Stalwart 环境做端到端验收 |
| `web` | axum | `/config` 静态页 + `include_str!` 嵌入 + CSP | 前端由 `web/config.test.mjs` 覆盖 | 已实现 | | `web` | axum | `/config` 静态页 + `include_str!` 嵌入 + CSP | 前端由 `web/config.test.mjs` 覆盖 | 已实现 |
> 注:「现状」列是**模块级**口径(模块已落地),不代表行为完备。行为级缺口不在本表内:见 `docs/roadmap.md`「代码缺口」(3 条)与本文件 §10 的未排期待办。
--- ---
## 7. 配置与安全 ## 7. 配置与安全
@ -341,12 +343,13 @@ message-weave/
### 7.1 历史配置读取(已迁移至 Redis) ### 7.1 历史配置读取(已迁移至 Redis)
> **迁移目标(`C-REDIS-ONLY-STATE`)**:生产启动环境仅保留 `REDIS_URL`。空 Redis > **迁移目标(`C-REDIS-ONLY-STATE`)**:生产启动环境仅保留 `REDIS_URL`。空 Redis
> 的首次配置必须通过 Redis ACL 密码认证的一次性 bootstrap/admin 会话完成;不得 > 的首次配置必须通过 `CONFIG_ENCRYPTION_KEY` 认证的一次性 bootstrap/admin 会话完成;
> 提供未鉴权写入口。ACL 密码仅用于常数时间 bootstrap 校验,不回显、不记录、不写入 > 不得提供未鉴权写入口。该密钥仅用于常数时间比较,不回显、不记录、不写入业务配置;
> 业务配置。bootstrap 成功后管理员会话哈希及 TTL 保存在 Redis,重启可恢复;业务 > Redis ACL 密码只承担 Redis 连接本身,不再是任何 HTTP 认证凭据。bootstrap 成功后
> token 从 Redis 在启动时装载,密钥 GET 永不回显;bootstrap/管理员 PUT 成功后先构建并原子 > 管理员会话哈希及 TTL 保存在 Redis,重启可恢复;业务 token 从 Redis 在启动时装载,
> 替换客户端,后续请求即时使用新配置,失败保留旧实例。该迁移替代下述阶段0环境变量清单, > 密钥 GET 永不回显;bootstrap/管理员 PUT 成功后先构建并原子替换客户端,后续请求即时
> 阶段0列表仅作为历史兼容说明。 > 使用新配置,失败保留旧实例。该迁移替代下述阶段0环境变量清单,阶段0列表仅作为历史
> 兼容说明。
> **实际实现**(`ARCH-CONFIG-ENV`):阶段0 起配置**只从环境变量读取**,由 `config::Config::from_env()` 手工解析(`std::env::var`),**不使用 figment、不使用 TOML 配置文件**。缺失必填项即启动失败;布尔值接受 `true/1/yes` 与 `false/0/no`。 > **实际实现**(`ARCH-CONFIG-ENV`):阶段0 起配置**只从环境变量读取**,由 `config::Config::from_env()` 手工解析(`std::env::var`),**不使用 figment、不使用 TOML 配置文件**。缺失必填项即启动失败;布尔值接受 `true/1/yes` 与 `false/0/no`。
@ -391,7 +394,7 @@ message-weave/
比较使用**常数时间**算法(`subtle`,防时序侧信道);校验失败一律 `401` 且**在鉴权通过前不产生任何副作用/状态变更**。业务配置完成后,Webhook、Push、Reconcile 和 Push 注册接口的凭证必须有效;启动引导变量缺失时进入配置引导模式,不绕过鉴权(§7.1)。 比较使用**常数时间**算法(`subtle`,防时序侧信道);校验失败一律 `401` 且**在鉴权通过前不产生任何副作用/状态变更**。业务配置完成后,Webhook、Push、Reconcile 和 Push 注册接口的凭证必须有效;启动引导变量缺失时进入配置引导模式,不绕过鉴权(§7.1)。
- **健康探针(`SAF-PROBE-PUBLIC`)**:`/healthz`(`ARCH-HEALTHZ`)与 `/ready` 为**公开探针**——无鉴权、只返回健康状态、**不含任何敏感信息**(不回显配置/密钥/内部错误细节)。 - **健康探针(`SAF-PROBE-PUBLIC`)**:`/healthz`(`ARCH-HEALTHZ`)与 `/ready` 为**公开探针**——无鉴权、只返回健康状态、**不含任何敏感信息**(不回显配置/密钥/内部错误细节)。
- `/healthz` = liveness(进程存活),语义长期稳定。 - `/healthz` = liveness(进程存活),语义长期稳定。
- `/ready` 检查配置完整性与 Redis 可访问性;未就绪返回 `503`(标准错误 envelope `{"error":"service_unavailable","request_id":<id>}` + `Retry-After: 30`),就绪返回 `200` 与就绪报告(`{"status":"ready","configured":...,...}`);不执行 JMAP/Telegram 请求,也不触发邮件同步等业务副作用。 - `/ready` 做端到端探测:配置完整性 + Redis 可达性 + 出站只读探测(`GET {jmap_origin}/.well-known/jmap`,带配置的 Basic 认证;`GET https://api.telegram.org/bot<token>/getMe`;各 `PROBE_TIMEOUT` = 3000ms、**并行**(`tokio::join!`),最坏约 3s);四者全过 `200` 与就绪报告(`{"status":"ready","configured":...,"jmap":...,"telegram":...}`,其中 `jmap`/`telegram` 是真实探针结果),任一失败 `503`(标准错误 envelope `{"error":"service_unavailable","request_id":<id>}` + `Retry-After: 30`)。探针只读、只读配置状态,**不**触发邮件同步等业务副作用,也**不**回显 token 或第三方响应内容;`refresh_business_config` 仅读 Redis,无状态写入。因此 `/ready` 要求到 JMAP host 与 `api.telegram.org:443` 的出站 egress 可达(若该 egress 需要代理则 `/ready` 不可用,见 deployment.md)。
- **chat 白名单(硬约束 `SAF-CHAT-ALLOWLIST`)**:`CHAT_ALLOWLIST` 是**必填**配置;任何入站事件(TG 命令 / 回调触发的动作)在**做任何 JMAP 调用、AI 调用或状态变更之前**,必须先校验 `chat.id ∈ CHAT_ALLOWLIST`,不在白名单则**直接拒绝并终止**(防止 token 泄露后被任意人调用)。阶段0 已完成 `CHAT_ALLOWLIST` 解析骨架;强制拒绝逻辑已随 Telegram 渠道接入落地(`src/notify.rs` 的 `telegram_webhook` 在任何 JMAP/AI/状态操作之前先校验白名单,拒绝即终止)。 - **chat 白名单(硬约束 `SAF-CHAT-ALLOWLIST`)**:`CHAT_ALLOWLIST` 是**必填**配置;任何入站事件(TG 命令 / 回调触发的动作)在**做任何 JMAP 调用、AI 调用或状态变更之前**,必须先校验 `chat.id ∈ CHAT_ALLOWLIST`,不在白名单则**直接拒绝并终止**(防止 token 泄露后被任意人调用)。阶段0 已完成 `CHAT_ALLOWLIST` 解析骨架;强制拒绝逻辑已随 Telegram 渠道接入落地(`src/notify.rs` 的 `telegram_webhook` 在任何 JMAP/AI/状态操作之前先校验白名单,拒绝即终止)。
- **命令最小化**:只暴露必要命令;发邮件等写操作必须二次确认(当前未实现发信,见 §10.3)。 - **命令最小化**:只暴露必要命令;发邮件等写操作必须二次确认(当前未实现发信,见 §10.3)。
- **速率**:出站侧未建本地令牌桶;Telegram 出站发送按 Redis 运行参数 `max_retries`(默认 3、上限 5)重试;当前仅对 Push 验证码写入做 Redis 限流(`ratelimit:push-verify:*`)。Telegram 服务端 30 msg/s 限制下的 429 不做专门的自动退避处理。 - **速率**:出站侧未建本地令牌桶;Telegram 出站发送按 Redis 运行参数 `max_retries`(默认 3、上限 5)重试;当前仅对 Push 验证码写入做 Redis 限流(`ratelimit:push-verify:*`)。Telegram 服务端 30 msg/s 限制下的 429 不做专门的自动退避处理。
@ -399,7 +402,7 @@ message-weave/
### 7.3.1 配置管理 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[] }`,只列缺少的环境变量名称。缺少 `REDIS_URL` 或 `CONFIG_ENCRYPTION_KEY` 时,SPA 只显示配置引导状态与缺失变量;服务状态确认 ready=true 后才显示管理会话授权区。 - `GET /api/status` 公开返回 `{ "ready": boolean, "mode": "configured" | "configuration-setup", "missing": string[] }`,只列缺少的环境变量名称。缺少 `REDIS_URL` 或 `CONFIG_ENCRYPTION_KEY` 时,SPA 只显示配置引导状态与缺失变量;服务状态确认 ready=true 后才显示管理会话授权区。
- `POST /api/admin/session` 接受 `Authorization: Bearer <REDIS_URL ACL password>`,返回 `{ "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`。
- 管理页面仅在 JavaScript 内存中保存 session。请求设置 `credentials: omit`、`cache: no-store`,不使用 Cookie、localStorage 或 sessionStorage。管理 API 接受有效 admin session;兼容路径也接受 `WORKER_TOKEN`。Worker 原样透传鉴权头(`SAF-LB-PASSTHRU`)。 - 管理页面仅在 JavaScript 内存中保存 session。请求设置 `credentials: omit`、`cache: no-store`,不使用 Cookie、localStorage 或 sessionStorage。管理 API 接受有效 admin session;兼容路径也接受 `WORKER_TOKEN`。Worker 原样透传鉴权头(`SAF-LB-PASSTHRU`)。
- `GET /api/config` 与 `PUT /api/config` 只读取和写入非敏感运行参数: - `GET /api/config` 与 `PUT /api/config` 只读取和写入非敏感运行参数:
```json ```json
@ -509,7 +512,7 @@ pub enum BotError {
- 分析结果不落盘:处理后无新增磁盘/Redis 写入路径; - 分析结果不落盘:处理后无新增磁盘/Redis 写入路径;
- AI 3 次失败 → 熔断确认 → 回退带"AI 不可用"徽标。 - AI 3 次失败 → 熔断确认 → 回退带"AI 不可用"徽标。
- **入口鉴权(`SAF-AUTH-*`)**:`/reconcile`、`/webhook/tg`、`/push/jmap` 在**缺少或错误的**凭证下返回 `401`,并断言鉴权失败时**无副作用**(无 Redis 写入、无 JMAP/AI 调用);正确凭证放行。 - **入口鉴权(`SAF-AUTH-*`)**:`/reconcile`、`/webhook/tg`、`/push/jmap` 在**缺少或错误的**凭证下返回 `401`,并断言鉴权失败时**无副作用**(无 Redis 写入、无 JMAP/AI 调用);正确凭证放行。
- **健康探针(`SAF-PROBE-PUBLIC`)**:`/healthz` 返回 `200` 表示进程存活;`/ready` 以 `200/503` 表示配置与 Redis 是否就绪——就绪返回就绪报告 JSON,不就绪返回标准错误 envelope(`service_unavailable` + `Retry-After: 30`);响应体不含敏感信息。Uptime Kuma 按状态码(`/ready` 期望 200)监控,不受响应体变化影响。 - **健康探针(`SAF-PROBE-PUBLIC`)**:`/healthz` 返回 `200` 表示进程存活;`/ready` 以 `200/503` 表示就绪——检查配置完整性 + Redis 可达性 + 出站只读探测(`GET {jmap_origin}/.well-known/jmap` 带 Basic 认证、`GET https://api.telegram.org/bot<token>/getMe`,各 3s、并行,最坏约 3s),任一失败返回标准错误 envelope(`service_unavailable` + `Retry-After: 30`),全过返回就绪报告 JSON;响应体不含敏感信息。Uptime Kuma 按状态码(`/ready` 期望 200)监控,不受响应体变化影响。
- **渠道解耦**:领域模块(`src/domain.rs` / `src/ai.rs` / `src/worker.rs`)的公开接口不出现任何渠道 SDK 类型;渠道装配集中在 `src/channel.rs`。 - **渠道解耦**:领域模块(`src/domain.rs` / `src/ai.rs` / `src/worker.rs`)的公开接口不出现任何渠道 SDK 类型;渠道装配集中在 `src/channel.rs`。
- **JMAP session URL(`REQ-JMAP-SESSION-URL`/`SAF-JMAP-URL`)**:基地址与完整 `…/.well-known/jmap` 两种输入**均接受且归一化结果一致**,传给 `Client::connect` 的 URL **不含重复 `/.well-known/jmap`**;`http://` 被拒绝;**内嵌凭据(`https://user:pass@host`)被拒绝**;危险 query 被拒绝。 - **JMAP session URL(`REQ-JMAP-SESSION-URL`/`SAF-JMAP-URL`)**:基地址与完整 `…/.well-known/jmap` 两种输入**均接受且归一化结果一致**,传给 `Client::connect` 的 URL **不含重复 `/.well-known/jmap`**;`http://` 被拒绝;**内嵌凭据(`https://user:pass@host`)被拒绝**;危险 query 被拒绝。
- **JMAP 多 part 原文(`REQ-JMAP-RAW-MULTIPART`)**:`read_email` 对多 part 正文按 `text_body` 顺序拼接"有 `part_id` 且有 `bodyValue`"的部分;构造"无可用部分"用例断言返回**明确错误**(非空串)。 - **JMAP 多 part 原文(`REQ-JMAP-RAW-MULTIPART`)**:`read_email` 对多 part 正文按 `text_body` 顺序拼接"有 `part_id` 且有 `bodyValue`"的部分;构造"无可用部分"用例断言返回**明确错误**(非空串)。
@ -522,7 +525,7 @@ pub enum BotError {
### 10.0 阶段 0:脚手架与 HTTPS 入口骨架(已完成,`GATE-P0` 已过) ### 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/JMAP 依赖已完成业务验收。 > **当前实际边界**:单端口 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),但不覆盖真实消息投递验收。
- **实际依赖**(Cargo.toml 现状,`ARCH-DEPS-STAGE0`/`ARCH-DEPS-STAGE1` + `ARCH-DEPS-STAGE4`):`axum 0.8`(单一 HTTPS 入口)、`async-trait`、`secrecy`、`subtle`(常数时间鉴权比较)、`serde`、`serde_json`、`thiserror`、`tokio`、`tracing`、`tracing-subscriber`、`url`(`JMAP_SESSION_URL` 归一化解析)、`jmap-client =0.4.2`(`default-features = false, features = ["async","rustls"]`)、`redis 0.27`(Redis XPING/PING 活性探测,`ARCH-STATE-REDIS`)、`reqwest 0.13`(JMAP/TG HTTP 客户端);dev-dependencies:`tower 0.5`(路由测试)。`teloxide` 未引入(`ARCH-DEPS-STAGE4`):Telegram 渠道在 `src/channel.rs` 用 reqwest 自研实现。 - **实际依赖**(Cargo.toml 现状,`ARCH-DEPS-STAGE0`/`ARCH-DEPS-STAGE1` + `ARCH-DEPS-STAGE4`):`axum 0.8`(单一 HTTPS 入口)、`async-trait`、`secrecy`、`subtle`(常数时间鉴权比较)、`serde`、`serde_json`、`thiserror`、`tokio`、`tracing`、`tracing-subscriber`、`url`(`JMAP_SESSION_URL` 归一化解析)、`jmap-client =0.4.2`(`default-features = false, features = ["async","rustls"]`)、`redis 0.27`(Redis XPING/PING 活性探测,`ARCH-STATE-REDIS`)、`reqwest 0.13`(JMAP/TG HTTP 客户端);dev-dependencies:`tower 0.5`(路由测试)。`teloxide` 未引入(`ARCH-DEPS-STAGE4`):Telegram 渠道在 `src/channel.rs` 用 reqwest 自研实现。
- **尚未引入**(文档不得声称已用):`teloxide` 等任何 Telegram Bot 框架(Telegram 出站由 `src/channel.rs` 用 `reqwest` 直发,评估记录见 `docs/retired.md`)。**未使用 figment**:配置为手工 `std::env` 解析(`ARCH-CONFIG-ENV`,§7.1)。 - **尚未引入**(文档不得声称已用):`teloxide` 等任何 Telegram Bot 框架(Telegram 出站由 `src/channel.rs` 用 `reqwest` 直发,评估记录见 `docs/retired.md`)。**未使用 figment**:配置为手工 `std::env` 解析(`ARCH-CONFIG-ENV`,§7.1)。
@ -542,8 +545,8 @@ pub enum BotError {
8. 所有新增代码有引用稳定 ID 的必要注释;单 `.rs` ≤ 500 行。 8. 所有新增代码有引用稳定 ID 的必要注释;单 `.rs` ≤ 500 行。
**运行监控(`GATE-UPTIME-KUMA`):** **运行监控(`GATE-UPTIME-KUMA`):**
- 使用 Uptime Kuma HTTP(s) Monitor 检查 `/healthz`(进程存活)和 `/ready`(配置/Redis 就绪),分别期望 HTTP 200;`/ready` 不就绪时返回 `503` 与标准错误 envelope(`service_unavailable` + `Retry-After: 30`),Uptime Kuma 仍按状态码判定,不受响应体变化影响。 - 使用 Uptime Kuma HTTP(s) Monitor 检查 `/healthz`(进程存活)和 `/ready`(配置 / Redis / 上游可达就绪),分别期望 HTTP 200;`/ready` 最坏约 3s,探针超时需设 ≥10s;`/ready` 不就绪时返回 `503` 与标准错误 envelope(`service_unavailable` + `Retry-After: 30`),Uptime Kuma 仍按状态码判定,不受响应体变化影响。
- `/healthz` 保持纯 liveness;`/ready` 不执行 JMAP/Telegram 请求或业务副作用。 - `/healthz` 是纯 liveness(无条件 `200`);`/ready` 检查配置完整性 + Redis 可达性 + 出站只读探测(JMAP session `GET`、TG `getMe`,各 3s、并行,最坏约 3s),探针只读、不回显 token 或第三方响应、不触发业务副作用,报告体不含敏感信息;由于 `/ready` 是最重的一环(可能 3s),平台侧应优先使用网关聚合的 `/healthz` 作为存活探测,避免高频出站请求。
- 不引入 Prometheus、Exporter 或额外指标端口;真实 Stalwart/Telegram 端到端链路仍需单独联调。 - 不引入 Prometheus、Exporter 或额外指标端口;真实 Stalwart/Telegram 端到端链路仍需单独联调。
**生产红线(贯穿所有阶段)**: **生产红线(贯穿所有阶段)**:
@ -628,7 +631,6 @@ Push 事件经 Streams 消费并投递到 Telegram,其关键路径交付语义
**已知边界(当前实现仍存在,见 `docs/roadmap.md`)** **已知边界(当前实现仍存在,见 `docs/roadmap.md`)**
- Redis 错误映射:`read_batch` 与 `retry_or_dlq` 在 Redis 出错时仍可能返回 `Ok(())`,消费循环因此不会因单次失败而退出;这类故障只能靠 Redis 侧告警发现。
- 多实例重复投递窗口:XAUTOCLAIM 空闲阈值已由固定 300s 改为「批大小 × 单条上限 300s」;单实例不受影响,仅当单条事件处理耗时接近 300s 上限时,多实例部署下另一实例仍可能提前认领,导致**重复投递(仅重复,不丢)**。 - 多实例重复投递窗口:XAUTOCLAIM 空闲阈值已由固定 300s 改为「批大小 × 单条上限 300s」;单实例不受影响,仅当单条事件处理耗时接近 300s 上限时,多实例部署下另一实例仍可能提前认领,导致**重复投递(仅重复,不丢)**。
**审计意见 → 收口(2026-09-26)** **审计意见 → 收口(2026-09-26)**
@ -643,9 +645,9 @@ Push 事件经 Streams 消费并投递到 Telegram,其关键路径交付语义
--- ---
## 11. 待确认问题(产品/架构类) ## 11. 历史问题与决策归档(产品/架构类,均已有结论)
> ⚠️ 部署/平台类决策已确认(单账户 / App Password+Basic / Redis 托管+AOF / 平台 HTTPS URL / 外部 Cron 对账),仅余 `Q-DEP-A`/`Q-DEP-B` 两项上线时的运维选择(不阻塞代码),见 `docs/roadmap.md`。 > ⚠️ 部署/平台类决策已**全部确认**(单账户 / App Password+Basic / Redis 托管+AOF / 平台 HTTPS URL / 外部 Cron 对账 / `Q-DEP-A` 平台 URL 与证书配置方 / `Q-DEP-B` 调度器选型),已归档到 `docs/deployment.md` 的已确认决策一节,不在本文重复。
### 11.1 认证方式(已确认) ### 11.1 认证方式(已确认)
- **已确认**:**App Password + Basic**(`C-AUTH-APP-BASIC`)。不用主密码、不用 OAuth2 Bearer(无 OIDC 需求)。 - **已确认**:**App Password + Basic**(`C-AUTH-APP-BASIC`)。不用主密码、不用 OAuth2 Bearer(无 OIDC 需求)。
@ -673,7 +675,7 @@ Push 事件经 Streams 消费并投递到 Telegram,其关键路径交付语义
- **LLM 提供方 / 网出许可**(Q25、Q26、Q30):`LLM_BASE_URL` 由部署方指定(只校验 https),`LLM_ALLOW_NET` 默认 `true`;`LLM_ENABLED=false` 时直接走 `LlmClient::noop()`,不探测(见 §12.2)。 - **LLM 提供方 / 网出许可**(Q25、Q26、Q30):`LLM_BASE_URL` 由部署方指定(只校验 https),`LLM_ALLOW_NET` 默认 `true`;`LLM_ENABLED=false` 时直接走 `LlmClient::noop()`,不探测(见 §12.2)。
- **熔断冷却 / 阈值**(Q27):不适用——熔断本身未实现(见 §12.4)。 - **熔断冷却 / 阈值**(Q27):不适用——熔断本身未实现(见 §12.4)。
仍真正未决的只有两项,且都是上线时的运维选择,已收敛到 `docs/roadmap.md` 的「决策待定」,不在本文重复:`Q-DEP-A`(平台 URL / 域名与证书由谁配置)、`Q-DEP-B`(外部 Cron 用哪个调度器)。 部署/平台类决策已无未决项:`Q-DEP-A`(平台 URL / 域名与证书由谁配置,由部署环境在发布时确定)与 `Q-DEP-B`(外部 Cron 用哪个调度器,不限定实现)均已决策,归档到 `docs/deployment.md` 的已确认决策一节,不在本文重复。
--- ---

View File

@ -3,7 +3,7 @@
> **This file is the single source of truth for verifiable facts.** > **This file is the single source of truth for verifiable facts.**
> When any other document disagrees with this one, this one wins. > When any other document disagrees with this one, this one wins.
> >
> **Verified against commit `ed93358`.** Line numbers in this file were read from that > **Verified against commit `c5bc7b8`.** Line numbers in this file were read from that
> commit with the working tree clean. > commit with the working tree clean.
> >
> **Maintenance responsibility.** Any change to the public API of `src/config.rs`, > **Maintenance responsibility.** Any change to the public API of `src/config.rs`,
@ -165,11 +165,18 @@ Every error response uses one envelope shape:
| 409 | `conflict` | — | | 409 | `conflict` | — |
| 422 | `invalid_configuration` | — | | 422 | `invalid_configuration` | — |
| 500 | `internal_error` | — | | 500 | `internal_error` | — |
| 503 | `service_unavailable`, `disabled`, `push_subscription_not_found`, `push_verify_rate_limited` | `30` when retryable | | 503 | `service_unavailable`, `disabled`, `push_subscription_not_found`, `push_verify_rate_limited`, `push_state_unavailable`, `push_destroy_failed` | `30` when retryable, or always for the push register/disable failures |
`Retry-After` is set only when the `retry` flag is true, inside `error_response` `Retry-After` has two emission sites, both in `notify.rs`, and the value is always the literal
(notify.rs:243-244), and is emitted unconditionally by the readiness handler for a not-ready string `"30"`:
503 (notify.rs:1003). The header value is always the literal string `"30"`.
- `error_response` (`notify.rs:316-324`, inserted at `:320`) sets it **only when its `retry`
flag is true**. The readiness failure path (`notify.rs:157`) passes `retry = true`, so `/ready`
is covered by that rule rather than by a special case.
- `error_response_with_id` (`notify.rs:1076-1084`, inserted at `:1082`) sets it
**unconditionally**. It is used only for the push register/disable failures
`push_state_unavailable` and `push_destroy_failed` (`notify.rs:941`, `:958`, `:975`,
`:1012`), which are always `503`.
`GET /api/status` is the exception that reports setup state with a non-envelope body. `GET /api/status` is the exception that reports setup state with a non-envelope body.
@ -177,7 +184,7 @@ Every error response uses one envelope shape:
## 3. Backend routes ## 3. Backend routes
All registered in `router_with_worker_state_runtime_bootstrap_config` (notify.rs:1187-1201). All registered in `router_with_worker_state_runtime_bootstrap_config` (notify.rs:1250-1277).
| Method | Path | Handler | | Method | Path | Handler |
|---|---|---| |---|---|---|
@ -196,13 +203,23 @@ All registered in `router_with_worker_state_runtime_bootstrap_config` (notify.rs
| GET | `/healthz` | `healthz` | | GET | `/healthz` | `healthz` |
| GET | `/ready` | `ready` | | GET | `/ready` | `ready` |
| GET | `/api/status` | `setup_status` | | GET | `/api/status` | `setup_status` |
| GET | `/`, `/assets/config.js`, `/assets/styles.css` | `index` / `web_config` |
Response conventions: Response conventions:
- `GET /healthz` returns **200 unconditionally**. It is a liveness probe and must not be - `GET /healthz` returns **200 unconditionally**. It is a liveness probe and must not be
used to decide whether to route traffic. used to decide whether to route traffic.
- `GET /ready` returns **503** when configuration or dependencies are not ready, and carries - `GET /ready` returns **503** when configuration, Redis, or an upstream probe is not ready,
`Retry-After: 30` for not-ready responses. and carries `Retry-After: 30` for not-ready responses. It checks four things: config
completeness (`setup_missing` empty), Redis reachability, and two upstream probes —
`GET {jmap_origin}/.well-known/jmap` with the configured Basic credentials, and
`GET https://api.telegram.org/bot<token>/getMe`. Both probes share `PROBE_TIMEOUT` = 3000ms
(`notify.rs:69`) and run **in parallel** (`tokio::join!`, `notify.rs:143`), so the worst case
is a single timeout, about 3s. On success the body is a report whose `jmap`/`telegram` fields
are real probe results. The probes are plain reusable functions (`probe_jmap_session`,
`probe_telegram_get_me`) also used by the remote-debug path, so they must not be duplicated.
The JMAP probe deliberately authenticates against the *normalized origin*: probing the raw
session URL unauthenticated would report not-ready forever and make ingress stop routing.
- `GET /api/status` returns **503** with body `{"status":"configuration-setup","missing":[...]}` - `GET /api/status` returns **503** with body `{"status":"configuration-setup","missing":[...]}`
when a required environment variable is absent. when a required environment variable is absent.
- Static assets are served with a strict CSP; see §7. - Static assets are served with a strict CSP; see §7.
@ -212,35 +229,44 @@ Response conventions:
## 4. Gateway vs backend route matrix ## 4. Gateway vs backend route matrix
> **Independently verified.** Source: `cloudflare-worker/src/backends.js` `SAFE_ROUTES` > **Independently verified.** Source: `cloudflare-worker/src/backends.js` `SAFE_ROUTES`
> (backends.js:9-22), 12 entries. The worker entry point is `src/index.js` > (backends.js:9-24), 14 entries, alongside `ROUTE_METHODS` (index.js:40-55)
> which fixes one method set per path. The worker entry point is `src/index.js`
> (wrangler.toml:20); `src/lb.js` performs forwarding and bounded failover (`SAF-LB-PASSTHRU`, > (wrangler.toml:20); `src/lb.js` performs forwarding and bounded failover (`SAF-LB-PASSTHRU`,
> `C-NO-LONG-CONN`). > `C-NO-LONG-CONN`).
The gate is **unconditional and fail-closed**. The worker reads no configuration switches at The gate is **unconditional and fail-closed**. The worker reads no configuration switches at
all: a path missing from `SAFE_ROUTES` returns **404** (index.js:69-72), a registered path all: a path missing from `SAFE_ROUTES` returns **404** (index.js:72-73), a registered
with the wrong method returns **405** (index.js:73-76), and a missing or unparseable backend path with the wrong method returns **405** (index.js:76-77), and a missing or unparseable
pool returns **503** rather than passing the request through (index.js:80-82). backend pool returns **503** rather than passing the request through (index.js:84-87).
**Forwarded by the worker (12):** **Forwarded by the worker (14):**
`/` · `/assets/config.js` · `/assets/styles.css` · `/api/status` · `/api/config` · `/` · `/assets/config.js` · `/assets/styles.css` · `/api/status` · `/api/config` ·
`/api/business-config` · `/api/admin/session` · `/api/admin/session/revoke` · `/api/business-config` · `/api/admin/session` · `/api/admin/session/revoke` ·
`/webhook/tg` · `/push/jmap` · `/reconcile` · `/ready` `/webhook/tg` · `/push/jmap` · `/api/push/register` · `/api/push/disable` · `/reconcile` ·
`/ready`
**Registered on the backend but NOT forwarded (6):** **Registered on the backend but NOT forwarded (4):**
| Path | Why it is absent from the gateway | | Path | Why it is absent from the gateway |
|---|---| |---|---|
| `POST /api/push/register` | Push registration requires the callback URL exchange to complete directly against the backend |
| `POST /api/push/disable` | Same reasoning as registration |
| `GET, PUT /api/enabled` | Internal operational switch, not exposed through the public gateway | | `GET, PUT /api/enabled` | Internal operational switch, not exposed through the public gateway |
| `POST /api/bootstrap` | One-shot trust bootstrap; kept off the public path | | `POST /api/bootstrap` | One-shot trust bootstrap; kept off the public path |
| `POST /worker` | Internal worker endpoint | | `POST /worker` | Operator-invoked worker trigger (Bearer-auth'd, `notify.rs:326`); not part of the public gateway path |
| `GET /healthz` | Liveness is aggregated by the gateway itself | | `GET /healthz` | Liveness is aggregated by the gateway itself |
Consequence: the SPA's first-boot flow cannot drive `/api/bootstrap` through the worker. Consequence: none of the four carries external business traffic, so no second ingress is
Bootstrap must be performed against the backend origin directly, or the bootstrap path must needed in front of the backend instances. The SPA's first-boot flow still cannot drive
be added to the gateway allowlist. `/api/bootstrap` through the worker — bootstrap must be performed against the backend origin
directly, or the bootstrap path must be added to the gateway allowlist.
`POST /api/push/register` and `POST /api/push/disable` **are** forwarded. Both are safe to
proxy: the callback URL is supplied by the client in the request body (`notify.rs:851`,
validated as a URL at :868), and every push subscription record is written to and read back
from the shared Redis (`lock:push-register:{sha256(url)}`, `get_push_subscription_for_callback`
at `notify.rs:894`), so it does not matter which backend instance the worker picks. Push
registration therefore no longer requires hitting a specific backend address — see
deployment.md §10.5.
--- ---

View File

@ -4,7 +4,7 @@
> 目的是**留原因、防复发**——以后看到这些名字,能立刻知道该用什么。 > 目的是**留原因、防复发**——以后看到这些名字,能立刻知道该用什么。
> 当前真实实现与可核对事实见 `docs/reference.md`;缺口与阻塞见 `docs/roadmap.md`;当前设计见 `docs/design.md`。 > 当前真实实现与可核对事实见 `docs/reference.md`;缺口与阻塞见 `docs/roadmap.md`;当前设计见 `docs/design.md`。
> 本文**没有行动项**。若某条变成要做的事,去 `docs/roadmap.md` 登记,本文条目保留为决策依据。 > 本文**没有行动项**。若某条变成要做的事,去 `docs/roadmap.md` 登记,本文条目保留为决策依据。
> 验证基线:`ed93358`。 > 验证基线:`c5bc7b8`。
--- ---
@ -24,6 +24,8 @@
| `tests/telegram_dispatch.rs` | 目标形态测试,未落地 | 依附 teloxide 测试模式 | `src/channel.rs` 的单元测试(`#[test]`) | | `tests/telegram_dispatch.rs` | 目标形态测试,未落地 | 依附 teloxide 测试模式 | `src/channel.rs` 的单元测试(`#[test]`) |
| docker-compose `healthcheck` 示例(`message-weave health --addr ...`) | 已删除的示例 | 运行镜像 `debian:bookworm-slim` 内无 `curl`/`wget`,且 `src/main.rs` 无 CLI 子命令解析,示例照抄必失败 | 由平台 ingress 探测 `/ready`;说明见 `docs/deployment.md` 的就绪探测一节 | | docker-compose `healthcheck` 示例(`message-weave health --addr ...`) | 已删除的示例 | 运行镜像 `debian:bookworm-slim` 内无 `curl`/`wget`,且 `src/main.rs` 无 CLI 子命令解析,示例照抄必失败 | 由平台 ingress 探测 `/ready`;说明见 `docs/deployment.md` 的就绪探测一节 |
| 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 候选对比(评估记录) ### 1.1 teloxide 候选对比(评估记录)
下表与下列理由为**当初的评估结论,保留它是决策依据,不是当前技术事实**——「活跃,最新 0.17,下载量大」等框架属性未经本轮复核,不代表这些 crate 的当前版本状态。 下表与下列理由为**当初的评估结论,保留它是决策依据,不是当前技术事实**——「活跃,最新 0.17,下载量大」等框架属性未经本轮复核,不代表这些 crate 的当前版本状态。
@ -72,18 +74,19 @@
|---|---|---|---| |---|---|---|---|
| `channel/telegram/{mod,commands,session,render}.rs` | 虚构文件 | 目标目录树里画出,从未创建 | 命令解析在 `parse_intent`(`src/worker.rs`);会话/授权态走 Redis TTL(`docs/reference.md` 的 AI 授权态一节);渲染函数在 `src/channel.rs` 内 | | `channel/telegram/{mod,commands,session,render}.rs` | 虚构文件 | 目标目录树里画出,从未创建 | 命令解析在 `parse_intent`(`src/worker.rs`);会话/授权态走 Redis TTL(`docs/reference.md` 的 AI 授权态一节);渲染函数在 `src/channel.rs` 内 |
| `delivery:pending:{stream}` | 虚构键 | 与 Redis Streams 的 pending-entries list(PEL)混淆——PEL 由 Redis 内部维护,不是可写键 | 真实键:`delivery:inflight:{stream}:{id}`(EX 60)与 `delivery:committed:{stream}:{id}`(EX 604_800),见 `docs/reference.md` 的投递流水线键一节 | | `delivery:pending:{stream}` | 虚构键 | 与 Redis Streams 的 pending-entries list(PEL)混淆——PEL 由 Redis 内部维护,不是可写键 | 真实键:`delivery:inflight:{stream}:{id}`(EX 60)与 `delivery:committed:{stream}:{id}`(EX 604_800),见 `docs/reference.md` 的投递流水线键一节 |
| `check_config_reload` | 虚构函数 | 未提交草稿中的名字,从未进入代码 | 热更新为 `refresh_business_config`(`src/notify.rs:628`) | | `check_config_reload` | 虚构函数 | 未提交草稿中的名字,从未进入代码 | 热更新为 `refresh_business_config`(`src/notify.rs:704`) |
| `push:registration:{sha256(callback_url)}` 曾被写成「360s 注册单飞锁」 | 事实误标 | 该键是 7d 回调→订阅 ID 映射 | 真正的 360s 单飞锁是 `lock:push-register:{sha256(callback_url)}`(`src/notify.rs:810-819`) | | `push:registration:{sha256(callback_url)}` 曾被写成「360s 注册单飞锁」 | 事实误标 | 该键是 7d 回调→订阅 ID 映射 | 真正的 360s 单飞锁是 `lock:push-register:{sha256(callback_url)}`(`src/notify.rs:887-890`) |
| `MESSAGWEAVE_DOMAIN` 曾被写成「生产未配置时 Worker 白名单失效」 | 虚构断言(未提交草稿/讨论中出现,未进文档) | 该环境变量全仓零命中;Worker 白名单是**无条件 fail-closed**:未知路径 404、method 不符 405、后端缺失或解析失败 503(`cloudflare-worker/src/index.js:69-82`) | 无需配置开关,白名单恒生效 | | `MESSAGWEAVE_DOMAIN` 曾被写成「生产未配置时 Worker 白名单失效」 | 虚构断言(未提交草稿/讨论中出现,未进文档) | 该环境变量全仓零命中;Worker 白名单是**无条件 fail-closed**:未知路径 404、method 不符 405、后端缺失或解析失败 503(`cloudflare-worker/src/index.js:77-92`) | 无需配置开关,白名单恒生效 |
| `read_batch` / `retry_or_dlq`「Redis 出错时仍可能返回 `Ok(())`,消费循环因此不会因单次失败退出」 | 虚构断言 | 消费入口是 HTTP handler `worker`(`notify.rs:326`),**不是后台循环**:全仓 `src/` 零命中 `select!`,无信号处理、无常驻 worker 进程。`read_batch`(`state.rs:343`)经 `?` 把 Redis 错误原样上抛(:379),唯一被丢弃的结果是 XGROUP `CREATE` 的 `BUSYGROUP` 幂等保护(:376);`retry_or_dlq` 同样经 `?` 上抛。调用方对每一处 `Err` 都返回 `503 service_unavailable` + `retryable=true`,故「循环不因单次失败退出」这一语义前提本身不成立 | 无——不存在该缺口;`docs/roadmap.md`「代码缺口」4 → 3,`docs/design.md`「已知边界」同句已删 |
--- ---
| `src/domain/llm/` 整个模块族 | 虚构模块树 | `domain/` 下只有 `jmap.rs` 与 `jmap/client.rs`;LLM 只有 `src/ai.rs` 一个文件(`LlmClient`) | 无 | | `src/domain/llm/` 整个模块族 | 虚构模块树 | `domain/` 下只有 `jmap.rs` 与 `jmap/client.rs`;LLM 只有 `src/ai.rs` 一个文件(`LlmClient`) | 无 |
| `ai::config` / `ai::fallback` / `ai::policy` / `ai::audit` | 虚构模块 | LLM 配置在 `config.rs::LlmConfig`,运行时参数在 `state.rs::RuntimeConfig`,无 policy/audit 概念 | 无 | | `ai::config` / `ai::fallback` / `ai::policy` / `ai::audit` | 虚构模块 | LLM 配置在 `config.rs::LlmConfig`,运行时参数在 `state.rs::RuntimeConfig`,无 policy/audit 概念 | 无 |
| `channel/mod.rs` | 虚构文件 | 渠道层就是 `src/channel.rs`,不是 `channel/` 目录 | 无 | | `channel/mod.rs` | 虚构文件 | 渠道层就是 `src/channel.rs`,不是 `channel/` 目录 | 无 |
| `notify::push_handler` / `notify::worker` / `notify::reconcile` 作为模块路径 | 虚构模块路径 | 这些是 `src/notify.rs` 内的自由函数(`notify.rs:250` / `:97` / `:136`),不是模块路径 | 无 | | `notify::push_handler` / `notify::worker` / `notify::reconcile` 作为模块路径 | 虚构模块路径 | 这些是 `src/notify.rs` 内的自由函数(`jmap_push` / `worker` / `reconcile`),不是模块路径 | 无 |
| `mod_dedup` / `mod_streams` / `mod_sincestate` | 虚构模块名 | `state.rs` / `notify.rs` 都是平铺文件,无子模块;去重与 Streams 逻辑以自由函数存在 | 无 | | `mod_dedup` / `mod_streams` / `mod_sincestate` | 虚构模块名 | `state.rs` / `notify.rs` 都是平铺文件,无子模块;去重与 Streams 逻辑以自由函数存在 | 无 |
| `PushVerification` 类型 | 虚构类型 | 未定义;`register_push`(`notify.rs:668`)内联处理回调 URL 与验证码回写 | 无 | | `PushVerification` 类型 | 虚构类型 | 未定义;`register_push`(`notify.rs:873`)内联处理回调 URL 与验证码回写 | 无 |
| `CancellationToken` | 虚构类型 | 未使用;无优雅关闭、无信号处理(`src/` 零命中 `tokio::signal` / `ctrl_c`) | 无 | | `CancellationToken` | 虚构类型 | 未使用;无优雅关闭、无信号处理(`src/` 零命中 `tokio::signal` / `ctrl_c`) | 无 |
| `Preview` 类型 / 4000 字符长邮件保护 / `[继续查看原文]` 按钮 / `/llm-fallback` 按钮 | 虚构类型与 UI | 全部未实现;授权后把全文交给 LLM,失败即回退前 300 字符,无任何截断标注或按钮 | 无 | | `Preview` 类型 / 4000 字符长邮件保护 / `[继续查看原文]` 按钮 / `/llm-fallback` 按钮 | 虚构类型与 UI | 全部未实现;授权后把全文交给 LLM,失败即回退前 300 字符,无任何截断标注或按钮 | 无 |
| `LlmErr`(5 变体) | 虚构枚举 | 真实是 `AiError`,仅 3 个变体(`InvalidEndpoint` / `Request` / `Response`) | 无 | | `LlmErr`(5 变体) | 虚构枚举 | 真实是 `AiError`,仅 3 个变体(`InvalidEndpoint` / `Request` / `Response`) | 无 |

View File

@ -2,7 +2,7 @@
> 只放**缺口**、**阻塞**与**阶段目标**。已实现能力见 `README.md`、`docs/design.md`、`docs/deployment.md`; > 只放**缺口**、**阻塞**与**阶段目标**。已实现能力见 `README.md`、`docs/design.md`、`docs/deployment.md`;
> 可核对的事实(Redis 键、TTL、错误码、路由)见 `docs/reference.md`。 > 可核对的事实(Redis 键、TTL、错误码、路由)见 `docs/reference.md`。
> 验证基线:`ed93358`。门禁命令见 `docs/deployment.md` 的 Gate 一节。 > 验证基线:`c5bc7b8`。门禁命令见 `docs/deployment.md` 的 Gate 一节。
## 阶段目标 ## 阶段目标
@ -21,19 +21,10 @@
- **`/search` + `SearchSnippet/get` 高亮** — 未实现。`src/` 中 `SearchSnippet` / `search_snippet` 零命中,worker 的 `Intent` 枚举也无 `Search` 变体,因此该路径无法进入。设计文档中的接口表已标注「(未实现)」。 - **`/search` + `SearchSnippet/get` 高亮** — 未实现。`src/` 中 `SearchSnippet` / `search_snippet` 零命中,worker 的 `Intent` 枚举也无 `Search` 变体,因此该路径无法进入。设计文档中的接口表已标注「(未实现)」。
- **Telegram 429 退避实测** — `src/channel.rs` 的 `max_retries`(默认 3,硬上限 5)不针对 Telegram 服务端 30 msg/s 限流做专门退避;429 行为无实测数据。需真实 Bot 压测后再决定是否引入指数退避。 - **Telegram 429 退避实测** — `src/channel.rs` 的 `max_retries`(默认 3,硬上限 5)不针对 Telegram 服务端 30 msg/s 限流做专门退避;429 行为无实测数据。需真实 Bot 压测后再决定是否引入指数退避。
- **Redis 错误映射** — `read_batch` 与 `retry_or_dlq` 在 Redis 出错时仍可能返回 `Ok(())`,消费循环不会因单次失败退出,这类故障只能靠 Redis 侧告警发现。(可选改进)
- **多实例重复投递窗口(残余)** — XAUTOCLAIM 空闲阈值已按批大小缩放(批大小 × 单条上限 300s);仅当单条事件处理耗时接近 300s 上限时,多实例部署下另一实例仍可能提前认领(仅重复不丢)。单实例不受影响。 - **多实例重复投递窗口(残余)** — XAUTOCLAIM 空闲阈值已按批大小缩放(批大小 × 单条上限 300s);仅当单条事件处理耗时接近 300s 上限时,多实例部署下另一实例仍可能提前认领(仅重复不丢)。单实例不受影响。
- **Worker safelist 与注册流程不一致** — 网关白名单是无条件 fail-closed 的(无开关可放宽,见 `docs/reference.md` 的网关路由矩阵一节)。后端共注册 18 条路由,其中 12 条经 Worker 转发、6 条不转发;不转发的 6 条里只有 `POST /api/push/register` 与 `POST /api/push/disable` 按设计**需要**业务流量直连后端(另有 `GET, PUT /api/enabled`、`POST /api/bootstrap`、`POST /worker`、`GET /healthz` 四条属内部运维路径,不需要业务流量)。因此实际缺口只有这两个端点。**待决策**:把它们加进 safelist(单入口,但放宽了「只转发明细路由」的边界),还是维持双入口(边界严,但运维要配两条路径)。
## 决策待定
这两条都是**上线时的运维选择,不是设计分歧**——不影响代码结构、不影响门禁、可以无限期挂着而不产生技术债。建议处理方式:**先按任一默认值写进部署文档,等真的上平台时再确认**;确认前不要为它们开 issue 或分支。
- **平台 URL / 域名与证书配置方**(`Q-DEP-A`,关联 `C-HTTPS-URL`;默认按「平台自动证书 + 自管反代二选一,先文档化两条路径」处理)— 平台自动证书 or 自管反代,以及 4 条路径的可达性验证方式。
- **外部调度器选型**(`Q-DEP-B`,关联 `NFR-RECONCILE-INTERVAL`;默认按「k8s CronJob」写)— 系统 crontab / k8s CronJob / CI scheduled / 第三方 cron。仅影响运维方式,不改变架构。
## 验收待办 ## 验收待办
- **真实平台日志采集验收**(`Q-DEP-B` 允许延后)— 容器不写日志文件、只走 stdout 已由代码审查确认(`C-LOG-STDOUT-ONLY`);结构化日志字段与脱敏已由代码审查确认(`SAF-LOG-PURITY`)。未验证项:真实平台采集器是否落盘、是否可检索。 - **真实平台日志采集验收** — 容器不写日志文件、只走 stdout 已由代码审查确认(`C-LOG-STDOUT-ONLY`);结构化日志字段与脱敏已由代码审查确认(`SAF-LOG-PURITY`)。未验证项:真实平台采集器是否落盘、是否可检索。
- **`/ready` 端到端依赖探测**(`GATE-READY-DEPS`)— 当前 `ARCH-READY-BASELINE` 仅覆盖配置完整性 + Redis 可达性;JMAP session / Telegram getMe 等端到端探测尚未接入。 - **`/ready` 端到端依赖探测(已完成)** — JMAP session `GET` 与 Telegram `getMe` 探针已接入 `/ready`(`src/notify.rs` 的 `probe_jmap_session` / `probe_telegram_get_me`,各 `PROBE_TIMEOUT` 3000ms、**并行**(`tokio::join!`),最坏约 3s):配置完整性 + Redis 可达性 + 两个上游全过返 `200` 与就绪报告,任一失败返 `503` + `Retry-After: 30`。两个探针是可复用纯函数,④ 远程 debug 直接复用同一实现,**不要另写一套**。`ARCH-READY-BASELINE` 语义已更新为端到端就绪。剩余的真实环境验证见上面「真实 Stalwart 邮箱联调」与「真实 Telegram Bot 联调」。
- **JMAP 只读 adapter 真机验证**(`GATE-G1-JMAP-READONLY`)— 代码已实现(mock + `#[ignore]` 真机测试),待 `cargo test -- --ignored jmap::`。 - **JMAP 只读 adapter 真机验证**(`GATE-G1-JMAP-READONLY`)— 代码已实现(mock + `#[ignore]` 真机测试),待 `cargo test -- --ignored jmap::`。