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**。
13. **正文仅在用户明确允许后才外发 AI**(`REQ-AI-EXTERNAL-CONSENT`):默认不外发;发送前须用户显式同意(与第 2 条叠加)。
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。
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`)。应用**不自建、不连接第二个数据库实例**。
@ -73,7 +73,7 @@
阶段 0 脚手架 + HTTPS 入口骨架(已完成,待过 P0 门禁 GATE-P0)
—— 交付:cargo 工程 + 模块骨架(main/config/error/domain/channel/notify)
+ 单端口 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"])
+ 入口鉴权已落地:/reconcile(Bearer) /webhook/tg(secret 头) /push/jmap(verificationCode),fail-closed SAF-AUTH-*
阶段 1 JMAP 只读(`jmap-client` 0.4.2 **已引入**,`ARCH-DEPS-STAGE1`;R1 入口鉴权已在阶段0 落地,不重复实现)
@ -131,7 +131,7 @@
- **AI 3 次失败**:触发熔断 → 弹确认 → 回退带"AI 不可用"徽标;`LLM_ENABLED=false` 全走回退。
- 正文转义、sinceState 存 Redis + 模拟 Redis 删除后由对账 `FLOW-RECONCILE` 恢复、通知去重。
- **入口鉴权(`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` 前不得声称"真机通过"。**
- 领域/渠道解耦测试:领域模块(`src/domain.rs` / `src/ai.rs` / `src/worker.rs`)的公开接口不出现任何渠道 SDK 类型(编译期检查);`JmapBackend` 的 `MockBackend` 可驱动全部 JMAP 领域流程(不联网)。
- 文件行数抽查:>500 行的 `.rs` 文件在 PR 说明中可见(软性,不自动 fail)。
@ -147,8 +147,8 @@
|---|---|---|
| `README.md` / `README.zh-CN.md` | 面向最终用户:是什么 / 五分钟跑起来 / 配置入口 / 安全边界一句话 / 去哪读更多(英文版 / 中文版,内容须对等,顶部互链) | 用户可见行为或流程变更时 |
| `docs/reference.md` | **可核对事实的唯一权威来源**:Redis 键与 TTL、错误 envelope 与错误码、后端路由与网关白名单矩阵、环境变量三层、出站与预算常量 | 公共 API 或 Redis 键名/TTL 变更时(**必更**) |
| `docs/design.md` | 产品行为、架构、模块接口、状态机、数据流、错误处理、测试、实施阶段、渠道抽象(Channel/Notifier/MessageAdapter)、产品/架构类待确认问题 | 行为/接口变更时 |
| `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/design.md` | 产品行为、架构、模块接口、状态机、数据流、错误处理、测试、实施阶段、渠道抽象(Channel/Notifier/MessageAdapter)、历史问题与决策归档(§11,产品/架构类,均已收口) | 行为/接口变更时 |
| `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/retired.md` | **已废弃 / 未采用路线与虚构条目**:每条含「类型 / 原因 / 替代或现状」;记录 teloxide 未采用、FSM 未落地、幽灵文件与虚构键、已删文档。**只写结论与原因,不写当前事实** | 新增废弃项或删除文档时 |
| `AGENTS.md`(本文件) | 目标、硬性安全边界、实现顺序、禁止事项、测试验收、文档引用关系、**跨文档引用索引(§7)** | 安全边界/流程变更时 |
@ -225,8 +225,7 @@
| `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` 公开探针:无鉴权、无敏感信息 | 安全 |
| `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,现已演进) | 架构 |
| `GATE-READY-DEPS` | docs/design.md §10.0 | 余下门禁:`/ready` 补 JMAP session / TG getMe 等**端到端**依赖探测(阶段3.5+ 的剩余部分,含其测试与部署接线) | 流程 |
| `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-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 落地) | 流程 |
| `ARCH-CONFIG-ENV` | docs/design.md §7.1 | 配置=环境变量手工解析,无 figment/TOML | 架构 |

View File

@ -71,8 +71,10 @@ secrets from the environment in normal operation.
**Health.**
`GET /healthz` returns 200 unconditionally — it is liveness, not readiness. `GET /ready`
returns 503 until configuration and Redis are available. Probe `/ready`, not `/healthz`,
when deciding whether to route traffic.
returns 503 until configuration, Redis, and both upstreams (a JMAP session `GET` and
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

View File

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

View File

@ -75,6 +75,7 @@
- 非 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`)。
- 需要在 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 的稳定域名;后端平台入口不对外登记。
- Secrets 运行期注入(环境变量/容器平台 secret),见 §5(同文件内章节链接)
@ -121,7 +122,7 @@ AI 授权期限由用户选择(临时一次、今天、7天或直到撤销)
> 业务鉴权变量由 Redis `config:business` 管理,不再要求作为启动环境变量。缺少 `REDIS_URL` 或非法/缺失 `CONFIG_ENCRYPTION_KEY` 时服务仍监听并提供 SPA、`/api/status` 与探针,状态为 `configuration-setup`,不会伪造持久化成功;配置恢复后再启用业务入口鉴权。
> 示例占位见仓库根目录 [`.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`)
生产进程接受 `REDIS_URL` 与唯一额外启动密钥 `CONFIG_ENCRYPTION_KEY`(32 字节随机
高熵 hex,仅应用运行时持有);其中 Redis ACL 认证是唯一 bootstrap 信任根;业务
密钥和运行参数不再从环境变量读取。空 Redis 仅提供配置页面及一次性的 bootstrap
会话:请求必须以 Redis ACL 密码作为 Bearer 凭据,服务端只做常数时间比较,绝不在
响应、日志或配置值中回显该密码。bootstrap 使用原子 `SET NX` 写入完整业务配置,
竞争请求只有一个成功;无密码 Redis 或认证失败时拒绝初始化。
高熵 hex,仅应用运行时持有);bootstrap 与 admin 会话的认证凭据即 `CONFIG_ENCRYPTION_KEY`
本身;业务密钥和运行参数不再从环境变量读取。空 Redis 仅提供配置页面及一次性的 bootstrap
会话:请求必须以 `CONFIG_ENCRYPTION_KEY` 作为 Bearer 凭据,服务端只做常数时间比较,绝不在
响应、日志或配置值中回显该密钥。bootstrap 使用原子 `SET NX` 写入完整业务配置,
竞争请求只有一个成功;**ACL 密码缺失不再阻塞初始化**(TLS-only 托管 Redis 场景可用),
仅 `CONFIG_ENCRYPTION_KEY` 缺失或鉴权失败时拒绝。
该 ACL 身份必须具备 `config:business`、`config:outbound` 及 admin-session 键的读写权限,
因为它同时是应用 Redis 连接凭据与空库 bootstrap 根信任。bootstrap 完成后公网业务配置
接口只接受 900 秒 admin session;持有 ACL 密码者仍等同根信任,必须按生产 Redis 凭据
同等级保护,不得把该身份误配置为禁止配置键访问。
该 ACL 身份是应用自身的 Redis 连接凭据,仍必须具备 `config:business`、
`config:outbound` 及 `config:admin_session` 键的读写权限(bootstrap 与管理员 PUT 均经此
连接写入),不得误配置为禁止配置键访问;但它不再是任何 HTTP 认证的信任根——业务配置
明文受 `CONFIG_ENCRYPTION_KEY` 做 AES-256-GCM 加密保护,仅持有 ACL 密码读取到的也是
密文。bootstrap 完成后公网业务配置接口只接受 900 秒 admin session。
成功 bootstrap 后,后续配置读写只能使用 Redis 中保存的管理员会话(短 TTL,注销
或配置更新时失效);业务端点使用 Redis 配置中下发的独立令牌。管理员会话只保存
@ -172,7 +175,7 @@ TLS 必须由 Cloudflare 或受信任反向代理终结;代理到容器的链
GET / 业务配置与运行参数管理 SPA
GET /assets/config.js SPA 脚本;GET /assets/styles.css SPA 样式
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
GET|PUT /api/config Bearer admin session 或 WORKER_TOKEN;Redis 错误返回 503
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]
→ 对账补差 FLOW-RECONCILE
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 页会提交最新游标;冷启动基线超出预算时把基线状态与位置编码后持久化,下一次从断点继续,不从头扫描,也不会提前跳过未列举邮件。
> **写入口鉴权(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 条路径可达。
### 6.2 Redis Streams / worker(MOD-STREAMS)
@ -203,6 +206,80 @@ GET /ready 公开就绪探针;检查配置完整性与 Redis 可访
- 对账逻辑:用 `sinceState` 调 `Email/changes` 拉增量 → 与已处理 email_id 求差 → 补发通知 → 推进 `sinceState`。
- **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)
> 目标:**至少 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` |
| **Push 重试** | `FLOW-NEW-MAIL` | `/push/jmap` 校验/入队后**立即 2xx**;若入队失败(Redis 抖动)返回非 2xx,让 Stalwart 按自身策略重试;配合对账兜底 |
| **对账恢复** | `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)
@ -227,9 +304,9 @@ GET /ready 公开就绪探针;检查配置完整性与 Redis 可访
## 7. 健康检查(通用 HTTP 约定)
- `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`):无鉴权、仅返回健康状态、**不含敏感信息**。
- `/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`)
- 仅保留通用平台映射(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 /ready` | 配置与 Redis 就绪前 503 | 就绪探测、负载均衡摘除、Uptime Kuma |
| `GET /ready` | 配置、Redis 或上游探针失败时 503 | 就绪探测、负载均衡摘除、Uptime Kuma |
| `GET /api/status` | 缺必需环境变量时 503 + `{"status":"configuration-setup","missing":[...]}` | 排查"起来了但没干活" |
> **由平台 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) |
| `REQ-AI-EXTERNAL-CONSENT` | **仅当用户明确允许时**才把邮件正文发往外部 AI(默认不外发) |
| `C-REDIS-MANAGED-AOF` | Redis 由**用户托管**并**开启 AOF 持久化**(Bot 不自建/不管理 Redis) |
| `C-HTTPS-URL` | **公网 HTTPS 入口由平台提供**(平台给 bot 一个 HTTPS URL;bot 不持证书、不监听 443) |
| `NFR-RECONCILE-INTERVAL` | **外部 Cron 定期 HTTPS POST `/reconcile`,建议 5–10 分钟**;容器不自建定时器 |
| `C-HTTPS-URL` | **公网 HTTPS 入口由平台提供**(平台给 bot 一个 HTTPS URL;bot 不持证书、不监听 443);`Q-DEP-A` 已决策:具体平台 URL / 域名与证书配置方(平台自动证书 or 自管反代二选一)由部署环境在发布时确定,仓库文档化两条路径与 4 条路径的可达性验证方式,不替部署方做选择 |
| `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) |
| `ARCH-LB-WORKER` | **多实例高可用**:免费 Cloudflare Worker 作为**唯一对外入口 + 故障转移**,后端为 2+ 个不同 serverless 平台的同镜像实例,共享同一 Redis(详见 §10) |
| `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 请求/响应、附件内容;仅允许结构化事件、计数、时间戳、脱敏摘要 |
| `C-NO-STATEFUL-RECOVERY` | **禁止依赖进程内状态做生产恢复**:重启恢复一律走 Redis + JMAP 对账(`FLOW-RECONCILE`);进程内缓存仅为性能优化,丢失必须安全可重入 |
### 9.2 仍需确认(部署相关,仅此)
| # | 待确认 | 关联 ID |
|---|---|---|
| **Q-DEP-A** | 平台 URL / 域名与证书由谁配置(平台自动证书 or 自管反代),以及 4 条路径的可达性验证方式 | `C-HTTPS-URL` |
| **Q-DEP-B** | 外部调度器具体选型(系统 crontab / k8s CronJob / CI scheduled / 第三方 cron)—— 仅影响运维方式,不改变架构 | `NFR-RECONCILE-INTERVAL` |
> **最后一项待确认已清空**:`Q-DEP-A`(平台 URL / 域名与证书配置方)与 `Q-DEP-B`(外部调度器选型)
> 均已决策,分别合并进本表的 `C-HTTPS-URL` 与 `NFR-RECONCILE-INTERVAL` 行。两项都是上线时的运维选择,
> 不影响代码结构、不影响门禁;`docs/roadmap.md` 已不再保留「决策待定」块。
>
> 其余产品/架构问题见 design.md;不再有平台特定部署问题(`NG-SERVERLESS-BIND`)。
---
@ -372,8 +446,8 @@ Stalwart PushSub ────┼─▶ https://lb.<you>.workers.dev ← 唯
### 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`)。
- **健康聚合(`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 副作用。
- **路由 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 session、TG getMe,各 3s、并行,最坏约 3s),不触发业务副作用。
- **故障转移(`forwardWithFailover`)**:每次请求最多 `min(LB_MAX_ATTEMPTS, origins.length)` 次尝试;**仅**超时(AbortError)或 5xx 触发换下一个 origin;4xx/2xx/3xx 直接返回;默认 `LB_MAX_ATTEMPTS=2`(首次 + 1 次故障转移)。
- **随机分摊**:起点 origin 按 `Math.random` 随机化,实现双活;单 origin 配置时退化为确定性。
- **全失败兜底**:返回 `503 All Backends Unavailable`,交由 Telegram / Stalwart 自动重投(**不丢消息**)。
@ -388,6 +462,7 @@ Stalwart PushSub ────┼─▶ https://lb.<you>.workers.dev ← 唯
|---|---|---|
| `/webhook/tg` | 可安全重复(`update_id` 去重) | Worker 选一实例;重试安全 |
| `/push/jmap` | 可安全重复(`(account,email_id)` 去重) | Worker 选一实例 |
| `POST /api/push/register` | 幂等(相同 callback URL 复用) | 已进 safelist,可走 Worker 域名;callback URL 由调用方提供、订阅记录全存共享 Redis,落到哪个后端实例都不影响 |
| `/reconcile` | ❌ **不可扇出** | **Redis 锁**(`SAF-RECONCILE-LOCK`)保证**同一时刻仅一个实例执行**,避免重复对账 |
| Streams worker | 用**相同消费组名**(`MOD-STREAMS-GROUP`) | Redis `XREADGROUP` **自动分摊**给多实例;at-least-once 下同一消息不会重复处理 |

View File

@ -18,17 +18,17 @@
1. [任务与范围](#1-任务与范围)
2. [jmap-client 能力分析](#2-jmap-client-能力分析)
3. [认证与邮箱操作适配](#3-认证与邮箱操作适配)
4. [Telegram Rust 框架选型](#4-telegram-rust-框架选型)
4. [Telegram 渠道实现选型](#4-telegram-渠道实现选型)
5. [整体架构与数据流](#5-整体架构与数据流)
6. [模块划分](#6-模块划分)
7. [配置与安全](#7-配置与安全)
8. [错误处理与可观测性](#8-错误处理与可观测性)
9. [测试策略](#9-测试策略)
10. [分阶段实施计划](#10-分阶段实施计划)
11. [待确认问题(产品/架构类)](#11-待确认问题产品架构类)
11. [历史问题与决策归档(产品/架构类,均已有结论)](#11-历史问题与决策归档产品架构类均已有结论)
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`)。
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。
5. **不持有任何长连接、不自建定时器**(`C-NO-LONG-CONN`);对账由**外部 HTTPS Cron** 触发 `/reconcile`(`FLOW-RECONCILE`)。
- 通知发送与命令处理共享 `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 环境做端到端验收 |
| `web` | axum | `/config` 静态页 + `include_str!` 嵌入 + CSP | 前端由 `web/config.test.mjs` 覆盖 | 已实现 |
> 注:「现状」列是**模块级**口径(模块已落地),不代表行为完备。行为级缺口不在本表内:见 `docs/roadmap.md`「代码缺口」(3 条)与本文件 §10 的未排期待办。
---
## 7. 配置与安全
@ -341,12 +343,13 @@ message-weave/
### 7.1 历史配置读取(已迁移至 Redis)
> **迁移目标(`C-REDIS-ONLY-STATE`)**:生产启动环境仅保留 `REDIS_URL`。空 Redis
> 的首次配置必须通过 Redis ACL 密码认证的一次性 bootstrap/admin 会话完成;不得
> 提供未鉴权写入口。ACL 密码仅用于常数时间 bootstrap 校验,不回显、不记录、不写入
> 业务配置。bootstrap 成功后管理员会话哈希及 TTL 保存在 Redis,重启可恢复;业务
> token 从 Redis 在启动时装载,密钥 GET 永不回显;bootstrap/管理员 PUT 成功后先构建并原子
> 替换客户端,后续请求即时使用新配置,失败保留旧实例。该迁移替代下述阶段0环境变量清单,
> 阶段0列表仅作为历史兼容说明。
> 的首次配置必须通过 `CONFIG_ENCRYPTION_KEY` 认证的一次性 bootstrap/admin 会话完成;
> 不得提供未鉴权写入口。该密钥仅用于常数时间比较,不回显、不记录、不写入业务配置;
> Redis ACL 密码只承担 Redis 连接本身,不再是任何 HTTP 认证凭据。bootstrap 成功后
> 管理员会话哈希及 TTL 保存在 Redis,重启可恢复;业务 token 从 Redis 在启动时装载,
> 密钥 GET 永不回显;bootstrap/管理员 PUT 成功后先构建并原子替换客户端,后续请求即时
> 使用新配置,失败保留旧实例。该迁移替代下述阶段0环境变量清单,阶段0列表仅作为历史
> 兼容说明。
> **实际实现**(`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)。
- **健康探针(`SAF-PROBE-PUBLIC`)**:`/healthz`(`ARCH-HEALTHZ`)与 `/ready` 为**公开探针**——无鉴权、只返回健康状态、**不含任何敏感信息**(不回显配置/密钥/内部错误细节)。
- `/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/状态操作之前先校验白名单,拒绝即终止)。
- **命令最小化**:只暴露必要命令;发邮件等写操作必须二次确认(当前未实现发信,见 §10.3)。
- **速率**:出站侧未建本地令牌桶;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
- `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 后才显示管理会话授权区。
- `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`)。
- `GET /api/config` 与 `PUT /api/config` 只读取和写入非敏感运行参数:
```json
@ -509,7 +512,7 @@ pub enum BotError {
- 分析结果不落盘:处理后无新增磁盘/Redis 写入路径;
- AI 3 次失败 → 熔断确认 → 回退带"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`。
- **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`"的部分;构造"无可用部分"用例断言返回**明确错误**(非空串)。
@ -522,7 +525,7 @@ pub enum BotError {
### 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 自研实现。
- **尚未引入**(文档不得声称已用):`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 行。
**运行监控(`GATE-UPTIME-KUMA`):**
- 使用 Uptime Kuma HTTP(s) Monitor 检查 `/healthz`(进程存活)和 `/ready`(配置/Redis 就绪),分别期望 HTTP 200;`/ready` 不就绪时返回 `503` 与标准错误 envelope(`service_unavailable` + `Retry-After: 30`),Uptime Kuma 仍按状态码判定,不受响应体变化影响。
- `/healthz` 保持纯 liveness;`/ready` 不执行 JMAP/Telegram 请求或业务副作用。
- 使用 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(无条件 `200`);`/ready` 检查配置完整性 + Redis 可达性 + 出站只读探测(JMAP session `GET`、TG `getMe`,各 3s、并行,最坏约 3s),探针只读、不回显 token 或第三方响应、不触发业务副作用,报告体不含敏感信息;由于 `/ready` 是最重的一环(可能 3s),平台侧应优先使用网关聚合的 `/healthz` 作为存活探测,避免高频出站请求。
- 不引入 Prometheus、Exporter 或额外指标端口;真实 Stalwart/Telegram 端到端链路仍需单独联调。
**生产红线(贯穿所有阶段)**:
@ -628,7 +631,6 @@ Push 事件经 Streams 消费并投递到 Telegram,其关键路径交付语义
**已知边界(当前实现仍存在,见 `docs/roadmap.md`)**
- Redis 错误映射:`read_batch` 与 `retry_or_dlq` 在 Redis 出错时仍可能返回 `Ok(())`,消费循环因此不会因单次失败而退出;这类故障只能靠 Redis 侧告警发现。
- 多实例重复投递窗口:XAUTOCLAIM 空闲阈值已由固定 300s 改为「批大小 × 单条上限 300s」;单实例不受影响,仅当单条事件处理耗时接近 300s 上限时,多实例部署下另一实例仍可能提前认领,导致**重复投递(仅重复,不丢)**。
**审计意见 → 收口(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 认证方式(已确认)
- **已确认**:**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)。
- **熔断冷却 / 阈值**(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.**
> 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.
>
> **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` | — |
| 422 | `invalid_configuration` | — |
| 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`
(notify.rs:243-244), and is emitted unconditionally by the readiness handler for a not-ready
503 (notify.rs:1003). The header value is always the literal string `"30"`.
`Retry-After` has two emission sites, both in `notify.rs`, and the 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.
@ -177,7 +184,7 @@ Every error response uses one envelope shape:
## 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 |
|---|---|---|
@ -196,13 +203,23 @@ All registered in `router_with_worker_state_runtime_bootstrap_config` (notify.rs
| GET | `/healthz` | `healthz` |
| GET | `/ready` | `ready` |
| GET | `/api/status` | `setup_status` |
| GET | `/`, `/assets/config.js`, `/assets/styles.css` | `index` / `web_config` |
Response conventions:
- `GET /healthz` returns **200 unconditionally**. It is a liveness probe and must not be
used to decide whether to route traffic.
- `GET /ready` returns **503** when configuration or dependencies are not ready, and carries
`Retry-After: 30` for not-ready responses.
- `GET /ready` returns **503** when configuration, Redis, or an upstream probe is not ready,
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":[...]}`
when a required environment variable is absent.
- Static assets are served with a strict CSP; see §7.
@ -212,35 +229,44 @@ Response conventions:
## 4. Gateway vs backend route matrix
> **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`,
> `C-NO-LONG-CONN`).
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
with the wrong method returns **405** (index.js:73-76), and a missing or unparseable backend
pool returns **503** rather than passing the request through (index.js:80-82).
all: a path missing from `SAFE_ROUTES` returns **404** (index.js:72-73), a registered
path with the wrong method returns **405** (index.js:76-77), and a missing or unparseable
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` ·
`/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 |
|---|---|
| `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 |
| `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 |
Consequence: the SPA's first-boot flow cannot drive `/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.
Consequence: none of the four carries external business traffic, so no second ingress is
needed in front of the backend instances. The SPA's first-boot flow still cannot drive
`/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/roadmap.md` 登记,本文条目保留为决策依据。
> 验证基线:`ed93358`。
> 验证基线:`c5bc7b8`。
---
@ -24,6 +24,8 @@
| `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` 的就绪探测一节 |
| 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 候选对比(评估记录)
下表与下列理由为**当初的评估结论,保留它是决策依据,不是当前技术事实**——「活跃,最新 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` 内 |
| `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`) |
| `push:registration:{sha256(callback_url)}` 曾被写成「360s 注册单飞锁」 | 事实误标 | 该键是 7d 回调→订阅 ID 映射 | 真正的 360s 单飞锁是 `lock:push-register:{sha256(callback_url)}`(`src/notify.rs:810-819`) |
| `MESSAGWEAVE_DOMAIN` 曾被写成「生产未配置时 Worker 白名单失效」 | 虚构断言(未提交草稿/讨论中出现,未进文档) | 该环境变量全仓零命中;Worker 白名单是**无条件 fail-closed**:未知路径 404、method 不符 405、后端缺失或解析失败 503(`cloudflare-worker/src/index.js:69-82`) | 无需配置开关,白名单恒生效 |
| `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:887-890`) |
| `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`) | 无 |
| `ai::config` / `ai::fallback` / `ai::policy` / `ai::audit` | 虚构模块 | LLM 配置在 `config.rs::LlmConfig`,运行时参数在 `state.rs::RuntimeConfig`,无 policy/audit 概念 | 无 |
| `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 逻辑以自由函数存在 | 无 |
| `PushVerification` 类型 | 虚构类型 | 未定义;`register_push`(`notify.rs:668`)内联处理回调 URL 与验证码回写 | 无 |
| `PushVerification` 类型 | 虚构类型 | 未定义;`register_push`(`notify.rs:873`)内联处理回调 URL 与验证码回写 | 无 |
| `CancellationToken` | 虚构类型 | 未使用;无优雅关闭、无信号处理(`src/` 零命中 `tokio::signal` / `ctrl_c`) | 无 |
| `Preview` 类型 / 4000 字符长邮件保护 / `[继续查看原文]` 按钮 / `/llm-fallback` 按钮 | 虚构类型与 UI | 全部未实现;授权后把全文交给 LLM,失败即回退前 300 字符,无任何截断标注或按钮 | 无 |
| `LlmErr`(5 变体) | 虚构枚举 | 真实是 `AiError`,仅 3 个变体(`InvalidEndpoint` / `Request` / `Response`) | 无 |

View File

@ -2,7 +2,7 @@
> 只放**缺口**、**阻塞**与**阶段目标**。已实现能力见 `README.md`、`docs/design.md`、`docs/deployment.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` 变体,因此该路径无法进入。设计文档中的接口表已标注「(未实现)」。
- **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 上限时,多实例部署下另一实例仍可能提前认领(仅重复不丢)。单实例不受影响。
- **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`)。未验证项:真实平台采集器是否落盘、是否可检索。
- **`/ready` 端到端依赖探测**(`GATE-READY-DEPS`)— 当前 `ARCH-READY-BASELINE` 仅覆盖配置完整性 + Redis 可达性;JMAP session / Telegram getMe 等端到端探测尚未接入。
- **真实平台日志采集验收** — 容器不写日志文件、只走 stdout 已由代码审查确认(`C-LOG-STDOUT-ONLY`);结构化日志字段与脱敏已由代码审查确认(`SAF-LOG-PURITY`)。未验证项:真实平台采集器是否落盘、是否可检索。
- **`/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::`。