From 8b1afac430748f881fef8a9b3806d215bddd7b50 Mon Sep 17 00:00:00 2001 From: anonsaber Date: Tue, 29 Sep 2026 20:55:54 +0800 Subject: [PATCH] docs: close two integration blockers and document the worker drain schedule - opengaps: drop the Telegram inbound callback and Stalwart PushSubscription blockers, both verified live; 2 items remain (newState semantics, TTL measurement). The public-callback mapping question is also resolved -- the integration env has no Worker in front and Stalwart hits the backend origin directly, while /push/jmap is already in the Worker safelist for production - deployment 4.1 (new): full setWebhook and /api/push/register procedure, expected replies, failure symptoms, and the setWebHook rotation trap (the deployed secret must be re-registered after any TG_WEBHOOK_SECRET change) - deployment 6.2: the worker is neither a background task nor a separate container; POST /worker is the only consumer entry point - deployment 6.3.1: add the /worker drain step to the cron and k8s CronJob examples, plus a two-endpoint comparison table, ordering requirements, the 10-message batch cap, and the "204 is not a success signal" warning - deployment 10.4: describe /worker as scheduler-driven, not manually invoked - reference, retired: /worker anchor notify.rs:331 -> 380; the old value fell inside the reconcile function body, so the anchor audit passed while being semantically wrong - charter 6.6: narrow the blocker list; code baseline b2dbe7c -> fc686ab - design 11.2: PushSubscription permission now verified; the external Cron must call both /reconcile and /worker GATE-P0: cargo fmt / check / clippy clean, 69 passed, 1 ignored GATE-DOCS: 6/6 clean (ANCHOR 299, SECTION-REF 234, PATH 428) --- docs/charter.md | 11 ++- docs/deployment.md | 197 ++++++++++++++++++++++++++++++++++++--------- docs/design.md | 4 +- docs/opengaps.md | 91 ++++++--------------- docs/reference.md | 2 +- docs/retired.md | 2 +- 6 files changed, 194 insertions(+), 113 deletions(-) diff --git a/docs/charter.md b/docs/charter.md index 3ca295e..5fdeb51 100644 --- a/docs/charter.md +++ b/docs/charter.md @@ -9,7 +9,7 @@ > > 三者冲突时:**可核对事实以 `reference.md` 为准,项目约束以本文件为准,质量规则以 `../AGENTS.md` 为准。** -> **代码基线** `b2dbe7c`(src/ 行号锚点)|`b2dbe7c`(文档)。 +> **代码基线** `fc686ab`(src/ 行号锚点)|`fc686ab`(文档)。 --- @@ -155,13 +155,12 @@ cd /home/okabe/Repo/messageweave/cloudflare-worker && node --test test/*.test.js ### 6.6 外部依赖测试 -Stalwart 与 Telegram 的业务凭据已在真机联调通过(出站与查询方向),托管 Redis 已连接; -**尚未验证的是入站回调方向**(见下方阻塞项)。真实环境集成测试必须用 `#[ignore]` 标注, -缺凭据时静默跳过(不泄露凭据、不判失败)。 +Stalwart 与 Telegram 的业务凭据已在真机联调通过——出站、查询与**入站回调**两个方向 +均已验证(`setWebhook` 与 `PushSubscription` 的注册、回调、验证往返全部走通),托管 Redis +已连接。真实环境集成测试必须用 `#[ignore]` 标注,缺凭据时静默跳过(不泄露凭据、不判失败)。 **环境阻塞项**(未验证,不得声称已验证)见 [`opengaps.md`](opengaps.md)「阻塞」区: -真实 Stalwart、真实 Telegram Bot、`Email/changes` 的 `newState` 语义、 -Push callback 公网映射。 +`Email/changes` 的 `newState` 语义、五个键的 TTL 实测。 ## 7. 文档边界 diff --git a/docs/deployment.md b/docs/deployment.md index 93d58ba..bd6b5d2 100644 --- a/docs/deployment.md +++ b/docs/deployment.md @@ -140,6 +140,69 @@ - **多实例 LB/HA 时登记的是 Worker URL 而非各后端 URL**(`C-LB-SINGLE-REG-URL`,§10):Telegram / Stalwart / Cron 只认 Worker 的稳定域名;后端平台入口不对外登记。 - Secrets 运行期注入(环境变量/容器平台 secret),见 §5(同文件内章节链接) +### 4.1 接通两侧回调(操作步骤,已在真机联调验证) + +后端起来、`/ready` 全绿之后,还需要**手动执行两件事**邮件才会进来。两步都由本服务执行 +Telegram / JMAP 侧的写操作,运维只提供 URL 与 token。 + +**第一步:Telegram 入站(`setWebhook`)** + +```bash +curl -fsS -X POST "https://api.telegram.org/bot<你的BOT_TOKEN>/setWebhook" \ + -H "content-type: application/json" \ + -d '{ + "url": "https://<你的平台URL>/webhook/tg", + "secret_token": "<必须与业务配置 TG_WEBHOOK_SECRET 逐字符一致>", + "allowed_updates": ["message"] + }' +``` + +预期回执 `{"ok":true,"result":true,"description":"Webhook was set"}`。 + +- `secret_token` 必须与 `TG_WEBHOOK_SECRET` **逐字符一致**:Telegram 把它放进请求头 + `X-Telegram-Bot-Api-Secret-Token`,后端常数时间比对,不等即 401 且无任何副作用。 +- `allowed_updates` **必须显式包含 `message`**。漏掉时 Telegram 不推送普通消息,队列里 + 什么也没有——看起来像「回调没通」,实际是订阅范围不对。 +- 回调地址走 Worker 域名(`/webhook/tg` 在 safelist 内)还是后端 origin 直连都可以; + 前面有 Worker 时按 §10 只登记 Worker 的稳定 URL。 +- 确认已登记:`curl -fsS https://api.telegram.org/bot/getWebhookInfo`,看 + `result.url`。Telegram 不回显 `secret_token`,所以 secret 写对没有只能靠最后一步反证。 +- **轮换注意**:在 SPA 改了 `TG_WEBHOOK_SECRET` 而**没有**重跑 `setWebhook`,Telegram + 手里就是旧值——之后所有真实回调都会被 401 拒掉,不报错、无告警,只是静默收不到消息。 + 改了就重跑本步。 + +**第二步:Stalwart 出站订阅(`POST /api/push/register`)** + +```bash +curl -fsS -X POST "https://<你的平台URL>/api/push/register" \ + -H "Authorization: Bearer " \ + -H "content-type: application/json" \ + -d '{"callback_url":"https://<你的平台URL>/push/jmap"}' +``` + +注册动作由**本服务自己**向 JMAP 发起,不是在 Stalwart 后台点。Stalwart 随后推送验证码, +后端用 JMAP 自动回写——**验证码不由运维提供,也不会从 SPA 配置接受**(`src/notify.rs:1163`)。 + +- `callback_url` 必须是 HTTPS 且**不含用户名 / 密码**,否则 400 `invalid_request` + (`src/notify.rs:1174`)。 +- 同一个 `callback_url` 重复调用**幂等复用**已有订阅,不会重复注册。 +- 注册期间持 `lock:push-register:{sha256(callback_url)}` 360 s 单飞锁 + (`src/notify.rs:1178` / `src/notify.rs:1186`),撞锁返回 409 `conflict`。 +- 成功 → `200 {"push_subscription_id":"..."}`,状态置 `pending`,在等 Stalwart 回来验证。 + 反向确认:`POST /api/push/disable` 在从未注册过该地址时回 `404 push_subscription_not_found`。 +- 订阅权限通常内置 `user` 角色已含;若返回 `forbidden`,到 Stalwart + `/admin → Management → Directory → Accounts/Roles` 检查。 +- **撤销**:带 `{"callback_url":"..."}` 打 `POST /api/push/disable`。 + +**第三步:排空(`POST /worker`)** + +前两步只负责把事件送进队列。把邮件从队列取出来并发成 Telegram 消息,靠外部 cron 周期性 +调用 `POST /worker` 完成——见 §6.3.1。**缺这一步,邮件会持续积压在 `stalwart:jmap`, +一行通知都不会发出来。** + +**验证**:群内发 `/help`,`/worker` 排空后应收到自动回复(入站方向);给自己发一封新邮件, +排空后应收到该邮件通知(出站方向)。 + --- ## 5. Secret 管理与环境变量 @@ -315,12 +378,12 @@ GET /ready 公开就绪探针;检查配置完整性 + Redis 可达 ### 6.2 Redis Streams / worker(MOD-STREAMS) - Push 回调只做:校验 → 去重 → 入队 → ACK;不阻塞 -- worker(同容器后台 task 或独立 worker 容器,2 选 1 均由 compose/编排决定): - `XREADGROUP → Email/changes → 推送 TG → 推进 sinceState → XACK` +- worker:**不是后台 task,也不是独立 worker 容器**。本服务没有常驻消费循环,两条 stream 的唯一消费入口是 `POST /worker`(处理函数 `src/notify.rs:380`,路由 `src/notify.rs:1608`)。全代码库仅两处 `tokio::spawn`——发送重试(`src/channel.rs:205`)与对账锁续租心跳(`src/notify.rs:298`)——都不消费队列;因此**排空必须由外部调度触发**(§6.3.1)。 +- 单次 `/worker` 调用语义:对 `stalwart:jmap` 与 `stalwart:telegram` 两条 stream 各取一批(上限 10,`src/notify.rs:397`;批量读取在 `src/state.rs:444`),逐条处理后确认消费(`src/state.rs:324`);处理失败转重试 / DLQ(`src/notify.rs:478`)。`Email/changes` 增量拉取与 `sinceState` 推进属于 `POST /reconcile`,不在 worker 内。 - 消费组 at-least-once:未 ACK 消息自动重投;处理幂等(MOD-DEDUP 二次兜底) ### 6.3 外部 HTTPS Cron 对账(FLOW-RECONCILE) -- **"外部 Cron"是什么**(已确认):bot **不自建定时器、不持有调度**(`C-NO-LONG-CONN`,无 `tokio-cron`)。由**外部调度器**周期性发起 `POST https://<平台URL>/reconcile`(带 `RECONCILE_TOKEN`)。端点执行鉴权、全局开关检查与 Redis 单飞锁,调用 JMAP `Email/changes` 分页并将事件幂等入 Streams;全部入队成功后才持久化 `state:jmap:since`,依赖失败返回 `503` 供调度器重试。 +- **"外部 Cron"是什么**(已确认):bot **不自建定时器、不持有调度**(`C-NO-LONG-CONN`,无 `tokio-cron`)。由**外部调度器**周期性发起 `POST https://<平台URL>/reconcile`(带 `RECONCILE_TOKEN`)。端点执行鉴权、全局开关检查与 Redis 单飞锁,调用 JMAP `Email/changes` 分页并将事件幂等入 Streams;全部入队成功后才持久化 `state:jmap:since`,依赖失败返回 `503` 供调度器重试。**对账只入队、不消费**——它全程不调用 `read_batch`(全代码库唯一的 `read_batch` 调用点在 `src/notify.rs:410`,属于 worker);排空由另一个端点 `POST /worker` 承担,两个都要排(§6.3.1)。 - **建议间隔 5–10 分钟**(`NFR-RECONCILE-INTERVAL`):兼顾"少延迟"与"低开销";这是可用性的兜底频率。 - 可用调度器(任选其一,均为外部):系统 crontab+curl / k8s CronJob / GitHub Actions scheduled / 第三方 cron 服务。 - 对账逻辑:用 `sinceState` 调 `Email/changes` 拉增量 → 与已处理 email_id 求差 → 补发通知 → 推进 `sinceState`。 @@ -328,78 +391,136 @@ GET /ready 公开就绪探针;检查配置完整性 + Redis 可达 #### 6.3.1 用户侧调度示例(copy-paste 可用) -端点:`POST https://<你的平台URL>/reconcile`。鉴权:`Authorization: Bearer ` -(`src/config.rs:147` fail-closed 必填,服务端常数时间比较,`notify.rs:228-231`)。 +端点有两个:`POST /reconcile`(路由 `src/notify.rs:1607`)与 `POST /worker`(路由 +`src/notify.rs:1608`),同容器暴露、无端口或协议差异。**两个都必须被外部调度,顺序是先对账、 +再排空。** 两者职责不重叠:`/reconcile` 只做 `Email/changes` 增量入队并推进游标,全程不调用 +`read_batch`;`/worker` 只做消费,全代码库唯一的 `read_batch` 调用点在 `src/notify.rs:410`。 +**只排对账等于邮件持续进队列、通知永远发不出去**——这是照抄调度配置时最容易踩的坑。 -最小示例: +最小可运行版本(推荐起点): + +| 项 | `/reconcile` | `/worker` | +| --- | --- | --- | +| 作用 | 增量入队 + 推进游标 | 排空两条 stream 并发通知 | +| 调度顺序 | 先 | 紧随其后 | +| 鉴权 | Bearer + `reconcile_token` | Bearer + `worker_token` | +| 可走 Worker 吗 | 可以(在 `SAFE_ROUTES` 内) | 不行,必须直连后端 origin | +| 幂等 | `SET NX lock:reconcile 300s` 单飞锁,撞锁回 409 | 无单飞锁,靠逐条 `delivery:*` 去重声明防重投 | +| 单次上限 | 100 条变更(`src/notify.rs:293`) | 10 条消息(`src/notify.rs:397`) | +| 常态返回 | 204 | 204(**不能当成功信号**) | + +两个 token 都是 Redis 业务配置字段(§5),不再从环境变量读取;外部 cron 从自己的 secret +存储取同一个值即可(下方 k8s 示例即此做法)。`/worker` 只认 Bearer token、**没有 +admin-session 兜底**(`src/notify.rs:386`),这是有意的:cron 不需要先登录拿 session。 +token 轮换后**无需重启**——worker 处理函数开头先 `refresh_business_config`,再从热更新快照 +取值(`src/notify.rs:1425`)。 + +`/worker` 不在 Worker 转发白名单里(`SAFE_ROUTES`:`/reconcile` 在内、`/worker` 不在), +走 Worker 会被 404 `route not forwarded` 挡掉,必须打后端 origin。见 §10.4。 + +最小示例(curl 直连 Worker): ```bash -curl -sS -o /dev/null -w '%{http_code}\n' \ - -X POST https://<你的平台URL>/reconcile \ - -H "Authorization: Bearer $RECONCILE_TOKEN" \ - --max-time 60 +curl -fsS -X POST "https://<你的平台URL>/reconcile" \ + -H "Authorization: Bearer " \ + -H "content-type: application/json" \ + -d '{"since":""}' ``` -调度器任选其一(均为外部,容器不自建定时器): +最小调度(两步一条 cron,顺序不能反): -```cron -# 系统 crontab,每 5 分钟 -*/5 * * * * curl -fsS -m 60 -X POST https://<你的平台URL>/reconcile -H "Authorization: Bearer $RECONCILE_TOKEN" >/dev/null ``` +# 每 5 分钟:先对账入队,再排空队列 +*/5 * * * * curl -fsS -m 60 -X POST https://<你的平台URL>/reconcile \ + -H "Authorization: Bearer $RECONCILE_TOKEN" >/dev/null ; \ + curl -fsS -m 60 -X POST https://<后端origin直连地址>/worker \ + -H "Authorization: Bearer $WORKER_TOKEN" >/dev/null +``` + +- 两条之间用 `;` 而不是 `&&`:对账撞锁拿 409、或临时 503,都不该阻断排空——上一次对账 + 入队的消息同样需要被消费。 +- 排空那条必须是**后端 origin 的直连地址**,不是 Worker 域名。 +- 请求体可带 `{"batch":N}`;`N` 会被压回上限 10,不会因传大值而报错(`src/notify.rs:397`)。 + 传非法 JSON 才是 400 `invalid_request`(`src/notify.rs:402`)。 + +Kubernetes CronJob 示例: ```yaml -# Kubernetes CronJob,每 5 分钟 apiVersion: batch/v1 kind: CronJob metadata: - name: messageweave-reconcile + name: messageweave-drain spec: schedule: "*/5 * * * *" concurrencyPolicy: Forbid successfulJobsHistoryLimit: 1 failedJobsHistoryLimit: 1 + startingDeadlineSeconds: 120 jobTemplate: spec: template: spec: restartPolicy: Never containers: - - name: reconcile + - name: drain 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"' + - | + curl -fsS -m 60 -X POST "${APP_URL}/reconcile" \ + -H "Authorization: Bearer ${RECONCILE_TOKEN}" >/dev/null + curl -fsS -m 60 -X POST "${WORKER_ORIGIN}/worker" \ + -H "Authorization: Bearer ${WORKER_TOKEN}" >/dev/null env: + - name: APP_URL + value: "https://<你的平台URL>" + - name: WORKER_ORIGIN + value: "https://<后端origin直连地址>" - name: RECONCILE_TOKEN valueFrom: - secretKeyRef: - name: messageweave - key: reconcile-token + secretKeyRef: {name: messageweave, key: reconcile-token} + - name: WORKER_TOKEN + valueFrom: + secretKeyRef: {name: messageweave, key: worker-token} ``` -**期望状态码** +期望状态码: -| 状态码 | 含义 | 调度器怎么办 | -|---|---|---| -| `204` | 成功,游标已推进(无响应体) | 正常,不做任何处理 | -| `401` | `RECONCILE_TOKEN` 缺失或错误 | 不要重试;检查凭据 | -| `409` | 单飞锁被占用,另有实例正在对账 | 不要重试;等下一个调度周期 | -| `503` | 业务开关关闭,或 JMAP/Redis 故障 | 按 `Retry-After: 30` 头重试,或等下一个周期 | +| 状态码 | 含义 | 处理 | +| --- | --- | --- | +| `/reconcile` 204 | 对账完成(含 since 游标前进 0 条) | 正常,不需要动作 | +| `/worker` 204 | 本次调用正常结束 | **不能当成功信号**:无消息可读、全部撞去重都回 204,`src/notify.rs:491` 无条件返回 | +| `/reconcile` 400 | `since` 不是合法游标 | 检查请求体,不要盲重试 | +| `/worker` 400 | 请求体不是合法 JSON(`src/notify.rs:402`) | 修 curl | +| `/reconcile` 401 | `Authorization` 头缺失或 `reconcile_token` 不匹配(`src/notify.rs:279`) | 检查 cron 的 token 值;重启不解决 | +| `/worker` 401 | Bearer 值与 `worker_token` 不匹配(`src/notify.rs:386`) | 同上;`/worker` 无 admin-session 兜底 | +| `/reconcile` 409 | 单飞锁 `lock:reconcile` 被占(`src/notify.rs:289`) | 直接跳过本次 tick,不要重试 | +| 503 | 业务配置此刻不可用,或 Redis / JMAP 请求失败 | 可重试;响应带 `Retry-After: 30`(`src/notify.rs:374`) | -失败响应体统一为 `{"error": , "request_id": <实例 owner id>}`,可直接落日志。 +重试建议: -**重试建议** - -- 对账逻辑本身是幂等的:重复调用只会重复走 `Email/changes` 增量并二次去重入队(`MOD-DEDUP` - 兜底),不会重复通知。所以对 `503` 可以放心重试。 +- **不要**在客户端实现重试循环。**调度器每 5 分钟触发一次已经足够**,因为: + - 单次调用**幂等**:`/reconcile` 有 `SET NX lock:reconcile 300s` 单飞锁 + (`src/notify.rs:289`,长对账中每 90s 续租 `src/notify.rs:302`,成功后释放 + `src/notify.rs:338`);`/worker` 靠逐条去重声明防重投,重复触发无害。 + - 客户端拿到 `409 conflict` 时**不要重试**,直接跳过本次 tick——锁在 TTL 或任务完成后 + 自然释放,下一次调度会重试。 +- 超时设置建议 ≥ 单飞锁 TTL 的 1/3(即 ≥ 100s)以吸收长对账,但客户端超时不应超过 + 调度间隔。 +- 如果你的调度器自带重试能力,把它限制为:**只对 5xx 与超时重试**,指数退避 + 2s / 4s / 8s,上限 60s,总次数 ≤ 3;`204` 与 `409` **永远不重试**。调度器没有自带能力 + 就不要额外实现——下一个 tick 自然会重试。 +- **积压会自然堆到下一次 tick。** `/worker` 单次最多消费 10 条(`src/notify.rs:397`); + 积压超过 10 条时需要连续多个 tick 才能清完。想更快就调高 cron 频率,**不要**靠加长 + curl 超时——那只会抬高挂死概率,不会让更多消息被消费。 - `204` 与 `409` 都是正常结果,不要把它们当失败重试——尤其别对 `409` 做紧密循环重试。 -- 调度间隔按 `NFR-RECONCILE-INTERVAL` 取 **5–10 分钟**即可。Push 是主路径,对账只是兜底。 +- 调度间隔按 `NFR-RECONCILE-INTERVAL` 取 **5–10 分钟**。Push 是主路径,对账是兜底; + 但**排空不是兜底项**——没有它,邮件只会一直进队列,一行通知都不会发出来。 - 走 Worker 转发**无需特殊设置**:网关已为 `POST /reconcile` 单独覆盖超时与尝试次数——超时取 `LB_RECONCILE_TIMEOUT_MS`(默认 `320000` ms,大于单飞锁租期 300 s + 心跳余量;锁续租逻辑见 - `notify.rs:240`/`252`),且 `maxAttempts=1` **绝不故障转移**(故障转移只会让第二实例立刻返回 + `src/notify.rs:302`),且 `maxAttempts=1` **绝不故障转移**(故障转移只会让第二实例立刻返回 `409`)。其余快路径仍用全局 `LB_REQUEST_TIMEOUT_MS`(默认 `10000` ms)与 `LB_MAX_ATTEMPTS` - (默认 `2`),不受影响。 + (默认 `2`),不受影响。`POST /worker` 走不了 Worker,不受这些参数影响。 ### 6.4 可靠性策略(Reliability) @@ -600,7 +721,7 @@ Stalwart PushSub ────┼─▶ https://lb..workers.dev ← 唯 ### 10.4 路由与故障转移 -- **路由 safelist**(`C-LB-SINGLE-REG-URL`):Worker 只透传 **15 条**(`SAFE_ROUTES` at `backends.js:9-25` + `ROUTE_METHODS` at `index.js:45-61`):`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`、`GET|PUT /api/enabled`、`POST /webhook/tg`、`POST /push/jmap`、`POST /api/push/register`、`POST /api/push/disable`、`POST /reconcile`、`GET /ready`;**未知路径 404、method 不符 405**,不透传至后端。**后端另有 4 类不在 safelist**:`POST /api/bootstrap`(一次性信任引导,**不能**走 Worker 域名)、`POST /worker`(运维手工触发的 worker 端点,Bearer 鉴权)、`GET /healthz`(网关自行聚合,不转发)、`/debug/*`(7 条远程联调面,`SAF-DEBUG-GATE`,只能直连后端 origin,见 §2.1)——**这 4 类都不承载外部业务流量,因此后端实例前不需要第二道入口**。`GET|PUT /api/enabled` 属于必须透传的例外:管理 SPA 本身只部署在 Worker 域名下,业务总开关由它读取与切换(`loadEnabled` 在 `web/config.js:586` 读、开关写入在 `:958`),缺了这条白名单,SPA 里那个开关在后端直连模式下可用、经网关部署时恒 404;该路由已强制 admin-session Bearer 鉴权,暴露面与同在白名单内的 `/api/admin/session` 完全一致。`/api/status` 只返回启动状态和缺少的环境变量名;所有管理 API 的 Bearer 鉴权由后端执行(`SAF-LB-PASSTHRU`)。 +- **路由 safelist**(`C-LB-SINGLE-REG-URL`):Worker 只透传 **15 条**(`SAFE_ROUTES` at `backends.js:9-25` + `ROUTE_METHODS` at `index.js:45-61`):`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`、`GET|PUT /api/enabled`、`POST /webhook/tg`、`POST /push/jmap`、`POST /api/push/register`、`POST /api/push/disable`、`POST /reconcile`、`GET /ready`;**未知路径 404、method 不符 405**,不透传至后端。**后端另有 4 类不在 safelist**:`POST /api/bootstrap`(一次性信任引导,**不能**走 Worker 域名)、`POST /worker`(队列排空端点,由外部调度周期性调用而非人工触发,Bearer 鉴权,见 §6.3.1)、`GET /healthz`(网关自行聚合,不转发)、`/debug/*`(7 条远程联调面,`SAF-DEBUG-GATE`,只能直连后端 origin,见 §2.1)——**这 4 类都不承载外部业务流量,因此后端实例前不需要第二道入口**。`GET|PUT /api/enabled` 属于必须透传的例外:管理 SPA 本身只部署在 Worker 域名下,业务总开关由它读取与切换(`loadEnabled` 在 `web/config.js:586` 读、开关写入在 `:958`),缺了这条白名单,SPA 里那个开关在后端直连模式下可用、经网关部署时恒 404;该路由已强制 admin-session Bearer 鉴权,暴露面与同在白名单内的 `/api/admin/session` 完全一致。`/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 次故障转移)。**例外:`POST /reconcile` 固定 `maxAttempts=1`,绝不故障转移**——它持集群级单飞锁,切实例只会立刻撞 `409`;其单请求超时取 `LB_RECONCILE_TIMEOUT_MS`(默认 `320000` ms),其余路由不受影响。 - **随机分摊**:起点 origin 按 `Math.random` 随机化,实现双活;单 origin 配置时退化为确定性。 diff --git a/docs/design.md b/docs/design.md index ed828ca..cd8ea9f 100644 --- a/docs/design.md +++ b/docs/design.md @@ -684,8 +684,8 @@ Push 事件经 Streams 消费并投递到 Telegram,其关键路径交付语义 - 影响:`Credentials::Basic` 构造;无需 OAuth client / token 自动刷新模块。 ### 11.2 实时通道(已定,见 deployment.md) -- 通道 = **JMAP Push HTTPS 回调 + 外部 Cron `/reconcile` 对账**(`C-NO-LONG-CONN`/`C-HTTPS-INBOUND`);EventSource/SSE/WebSocket 均**非目标**(`NG-POLLING-SSE`)。Push 注册通过受保护的 `POST /api/push/register` 显式触发,外部 Cron 仍是必须的可靠补偿通道。 -- 两条尚需在真实 Stalwart 上校准的点——账号角色是否具备 `PushSubscription` 权限、以及 Stalwart 回调重试次数与 TTL/对账间隔的匹配——已作为验收项登记在 `docs/opengaps.md`,不在本文以问题形式留存。 +- 通道 = **JMAP Push HTTPS 回调 + 外部 Cron 对账与排空**(`C-NO-LONG-CONN`/`C-HTTPS-INBOUND`);EventSource/SSE/WebSocket 均**非目标**(`NG-POLLING-SSE`)。Push 注册通过受保护的 `POST /api/push/register` 显式触发;外部 Cron 必须同时调 `/reconcile`(增量入队)与 `/worker`(排空并发通知)——**只调对账会让通知永远发不出去**(deployment.md §6.3.1)。 +- Stalwart 侧接入已在真实环境验证:内置角色具备 `PushSubscription` 权限,注册、验证往返与真实回调投递全部走通。剩下待校准的是 Stalwart 回调重试次数与 TTL / 对账间隔的匹配,作为验收项登记在 `docs/opengaps.md`,不在本文以问题形式留存。 ### 11.3 部署形态(已确认,架构相关) - **已确认**:**单账户实现**(`REQ-SINGLE-ACCOUNT`);多账户暂用**多个 bot 实例**(各自 token/配置),**不做多账户单实例**(因此无需 chat→account 路由与 `JmapService` 池化)。 diff --git a/docs/opengaps.md b/docs/opengaps.md index c504122..fda2a8f 100644 --- a/docs/opengaps.md +++ b/docs/opengaps.md @@ -4,76 +4,45 @@ ## 你要做的事 -代码侧**没有**待你实现的项。四项阻塞里,两项需要你跑一条 curl,一项只需要你告诉我一个事实,一项需要你腾一个停机窗口。 +代码侧**没有**待你实现的项。剩下的一件事需要你腾一个停机窗口,另一件由我跑: | # | 事项 | 谁做 | 你要动手吗 | | --- | --- | --- | --- | -| 1 | Telegram `setWebhook` | 你 | 跑 1 条 curl,然后发一条消息 | -| 2 | Stalwart `PushSubscription` | 你 | 跑 1 条 curl(注册由本服务自己向 JMAP 发起) | -| 3 | 回调公网映射 | 你 | 只回答一个问题:用哪个组件做映射 | -| 4 | `Email/changes` 的 `newState` 语义 | 你 + 我 | 腾一次停机窗口 | -| 5 | 单飞锁 TTL 实测 | 我 | 不需要的,依赖 #2 成功后我自己跑 | +| 1 | `Email/changes` 的 `newState` 语义 | 你 + 我 | 腾一次停机窗口 | +| 2 | 单飞锁 TTL 实测 | 我 | 不需要,我自己跑 | ## 阶段目标 -剩余目标收窄为:**在 Telegram 侧执行 `setWebhook`**,以及在 Stalwart 侧走通 `PushSubscription` 回调链路,消掉下面 4 条阻塞项。出站查询方向(JMAP 只读 → Telegram 回帖)、`/search`、出站限流退避、多实例去重均已完成并真机通过。 +剩下一条需要停机窗口的阻塞:**在真实环境里确认 `Email/changes` 的 `newState` 语义**;另有一条由我补测的 TTL 实测(第 2 项),不依赖你。 + +上一轮真机联调已经关掉了两条阻塞: + +- **Telegram 入站方向**:`setWebhook` 之后群里发 `/help`,`POST /worker` 排空即收到自动回复。此前 `/webhook/tg` 收到的全部是伪造 update,Telegram 侧从未收到过 `setWebhook`,所以这个方向一直没有被真实流量验证过。 +- **Stalwart `PushSubscription`**:`stalwart:jmap` 连续数天有真实、互不重复的推送事件。该 stream 全代码库只有两个写入点——`src/notify.rs:1139`(JMAP 回调入队)与 `src/worker.rs:374`(对账增量入队)——所以有事件出现就只能是订阅活着且在投递。同一次观测也顺带验证了去重键在跨日规模下没有重复投递。 + +两条的操作步骤已移到面向使用者的 `docs/deployment.md` §4.1,本文件不再跟踪它们。 + +顺带把原第 3 项里的「回调公网映射」也解掉了:当前联调环境前面没有 Cloudflare Worker,Stalwart 直接打到后端 origin 的 `/push/jmap` 且在真实投递;生产拓扑下 `/push/jmap` 本就在 Worker safelist 内(`docs/deployment.md` §10.4)。两种拓扑下映射都由现有部署解决,「用哪个组件做映射」不再需要回答。 + +### 本轮顺带发现并修掉的部署文档缺陷 + +`docs/deployment.md` §6.3.1 原来只给了 `/reconcile` 的调度示例,而 `/worker`——全代码库唯一的队列消费入口——被描述成「运维手工触发」,且刻意不在 Worker 白名单里。**照文档照抄部署 = 邮件持续进队列、通知永远发不出去**,这正是联调环境积压了数天才被手工排空的成因。§6.3.1 已补上 `/worker` 的调度步骤、顺序要求、批量上限与「204 不能当成功信号」的说明。 + +同时修正了 `docs/reference.md` 与 `docs/retired.md` 里 `/worker` 的锚点:原值 `notify.rs:331` 落在 `reconcile` 函数体内,锚点审计不会报错(该行存在且非空),但语义是错的;正确值是 `notify.rs:380`。 ## 阻塞 -### 1. 真实 Telegram 回调(Telegram → 本服务方向) +### 1. `Email/changes` 的 `newState` 语义 -出站方向已验证,但**Telegram → 本服务从未被真实流量验证过**:到目前为止 `/webhook/tg` 收到的全部是伪造 update。Telegram 侧还从未收到过 `setWebhook`,所以 Telegram 既不知道回调地址,手里也没有 secret。 +`jmap-client` 的 `fetchChanges` 在服务器无法回放旧增量时会返回 `newState`,要求客户端重新基线化。本代码库的 `since` 游标处理只在测试服务器上跑过,**尚未出现一次服务器要求重新基线化的真实场景**。 -**你要做的:** +**你要做的:** 腾一次停机窗口——在邮箱还有新邮件进入的时候把本服务停掉,停到 Stalwart 的增量历史过期为止(保留时长由你的 Stalwart 配置决定;窗口要长于它)。停机期间**不要**跑 `POST /reconcile`。 -1. 登记 webhook(`` = 业务配置里的 `telegram_webhook_secret`): - ```bash - curl -sS "https://api.telegram.org/bot/setWebhook" \ - -d url=https://messageweave-eu-1.motofans.club/webhook/tg \ - -d secret_token= \ - -d 'allowed_updates=["message"]' - ``` - 不传 `allowed_updates` 时默认订阅全部更新类型;显式收窄到 `message` 与本服务只解析 `message.text` 一致。 -2. 确认已登记: - ```bash - curl -sS "https://api.telegram.org/bot/getWebhookInfo" - ``` - 看 `result.url` 等于上面那个地址。**Telegram 不回显 `secret_token`**,所以 secret 写对没有只能靠第 3 步反过来验。 -3. 用允许名单里的 chat id 发一条 `/help`,然后告诉我「发了」。 +我来做的:恢复后跑 `POST /reconcile`,再看 `/debug/worker` 的 `reconcile_cursor`。预期是重新变成一个**新的 `baseline:` 游标**(重新基线化成功),而不是报错或停在旧游标。实测语义回填本节。 -我来收尾:`POST /api/worker` 排空队列并确认回帖。如果没收到,`getWebhookInfo` 里的 `last_error_message` 与 `last_error_date` 是最快的定位线索。 +### 2. 单飞锁 TTL 实测 -**轮换注意:** 在 SPA 改了 `telegram_webhook_secret` 而**没有**重跑 `setWebhook`,Telegram 手里就是旧值——之后所有真实回调都会被 401 拒掉,**不报错、无告警,只是静默收不到消息**。改了就重跑第 1 步。 - -### 2. 真实 Stalwart `PushSubscription` - -`PushSubscription/set` 的验证往返只在本地单测里跑过(测试指向一个永不连通的地址);生产环境从未注册过订阅,所以 Stalwart 从未发过一次真实 push 回调,`/push/jmap` 的验证握手分支从未被真实请求走过。 - -**先说清分工:** 注册动作由**本服务自己**向 JMAP 发起,不是在 Stalwart 后台点。调用即完成 `PushSubscription/set` create 并限制订阅类型;**验证码由 Stalwart 生成、经回调送回来,本服务自动完成验证,不接受 SPA 里配置的验证码**(`src/notify.rs:1163`)。所以你不需要去拿验证码,你只需要让回调地址能被 Stalwart 访问到。 - -**你要做的:** - -1. 确认回调地址公网可达,且 Stalwart 出站能访问到它(防火墙/代理放行)。是 Stalwart **回调**你,不是你去连它。 -2. 注册: - ```bash - curl -sS -X POST "https://messageweave-eu-1.motofans.club/api/push/register" \ - -H "Authorization: Bearer " \ - -H "content-type: application/json" \ - -d '{"callback_url":"https://messageweave-eu-1.motofans.club/push/jmap"}' - ``` - 成功 → `200 {"push_subscription_id":"..."}`,状态置 `pending`(TTL 900s,`src/notify.rs:1240`),在等 Stalwart 回来验证。 -3. Stalwart 的验证请求到达后,本服务自动验证并置 `verified`(TTL 300s,`src/notify.rs:1125`)。日志在 stdout(`SAF-LOG-STDOUT-ONLY`)。 -4. 确认已落库:再跑一次第 2 步,返回里多出 `"idempotent":true` 即说明订阅已持久化。也可反向确认——`POST /api/push/disable` 在从未注册过该地址时回 `404 push_subscription_not_found`。 - -**失败怎么读:** `400 invalid_request` = 回调地址不是 HTTPS,或 URL 里带了用户名/密码(本服务禁止这两者,`src/notify.rs:1174`);`409 conflict` = 注册单飞锁还被上一次请求占着,稍后重试;`503` = 业务配置此刻不可用或 JMAP 请求失败,锁已释放,可重试。 - -**取消注册:** 带 `{"callback_url":"..."}` 打 `POST /api/push/disable`。 - -### 3. Push callback 公网映射 + 单飞锁 TTL - -**这部分你不需要跑命令,只需要回答一个问题:公网映射由哪个组件负责。** 当前这个测试部署前面没有 Cloudflare Worker,我不知道该在哪里放行 `/push/jmap`。候选是 `cloudflare-worker/` 里已有的实现(从未部署)、nginx,或其他 ingress。 - -TTL 参数已在代码里写死、单测也已覆盖,但真实场景下的实际过期行为从未观测过: +TTL 参数已在代码里写死、单测也已覆盖,但真实场景下的实际过期行为从未观测过。前置依赖(订阅注册成功、真实回调在投递)已解除,这一项不再依赖任何人的配合。 | 键 | 用途 | TTL | 位置 | | --- | --- | --- | --- | @@ -83,15 +52,7 @@ TTL 参数已在代码里写死、单测也已覆盖,但真实场景下的实 | `dedup:tg:{update_id}` | Telegram 更新去重 | 86400s | `src/notify.rs:248` | | `dedup:jmap:{account_id}:{email_id}` | JMAP 更新去重 | 86400s | `src/notify.rs:1134` | -我来做的:订阅注册成功(第 2 项)之后,触发一次真实回调并记录这五个键的实际过期行为,把实测值回填本节。在这之前本节保持阻塞。 - -### 4. `Email/changes` 的 `newState` 语义 - -`jmap-client` 的 `fetchChanges` 在服务器无法回放旧增量时会返回 `newState`,要求客户端重新基线化。本代码库的 `since` 游标处理只在测试服务器上跑过,**尚未出现一次服务器要求重新基线化的真实场景**。 - -**你要做的:** 腾一次停机窗口——在邮箱还有新邮件进入的时候把本服务停掉,停到 Stalwart 的增量历史过期为止(保留时长由你的 Stalwart 配置决定;窗口要长于它)。停机期间**不要**跑 `POST /reconcile`。 - -我来做的:恢复后跑 `POST /reconcile`,再看 `/debug/worker` 的 `reconcile_cursor`。预期是重新变成一个**新的 `baseline:` 游标**(重新基线化成功),而不是报错或停在旧游标。实测语义回填本节。 +我来做的:触发一次真实回调并记录这五个键的实际过期行为,把实测值回填本节。在这之前本节保持阻塞。 ## 后续(多账号) diff --git a/docs/reference.md b/docs/reference.md index 7af5d92..fd65d63 100644 --- a/docs/reference.md +++ b/docs/reference.md @@ -303,7 +303,7 @@ backend pool returns **503** rather than passing the request through (index.js:8 | Path | Why it is absent from the gateway | |---|---| | `POST /api/bootstrap` | One-shot trust bootstrap; kept off the public path | -| `POST /worker` | Operator-invoked worker trigger (Bearer-auth'd, `notify.rs:331`); not part of the public gateway path | +| `POST /worker` | Queue drain endpoint — driven by the external scheduler, not a human (Bearer-auth'd, `notify.rs:380`; the cron is spelled out in deployment.md §6.3.1); not part of the public gateway path | | `GET /healthz` | Liveness is aggregated by the gateway itself | | `/debug/*` (7 routes) | Opt-in remote-debug surface (`SAF-DEBUG-GATE`); absent from `SAFE_ROUTES`, so it is reachable **only** by talking to the backend origin directly | diff --git a/docs/retired.md b/docs/retired.md index 21114ca..9d873cb 100644 --- a/docs/retired.md +++ b/docs/retired.md @@ -78,7 +78,7 @@ | `check_config_reload` | 虚构函数 | 未提交草稿中的名字,从未进入代码 | 热更新为 `refresh_business_config`(`src/notify.rs:718`) | | `push:registration:{sha256(callback_url)}` 曾被写成「360s 注册单飞锁」 | 事实误标 | 该键是 7d 回调→订阅 ID 映射 | 真正的 360s 单飞锁是 `lock:push-register:{sha256(callback_url)}`(`src/notify.rs:898-901`) | | `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:331`),**不是后台循环**:全仓 `src/` 零命中 `select!`,无信号处理、无常驻 worker 进程。`read_batch`(`state.rs:385`)经 `?` 把 Redis 错误原样上抛(:425),唯一被丢弃的结果是 XGROUP `CREATE` 的 `BUSYGROUP` 幂等保护(:394-401);`retry_or_dlq` 同样经 `?` 上抛。调用方对每一处 `Err` 都返回 `503 service_unavailable` + `retryable=true`,故「循环不因单次失败退出」这一语义前提本身不成立 | 无——不存在该缺口;`docs/opengaps.md`(原名 `docs/roadmap.md`)「代码缺口」4 → 3,`docs/design.md`「已知边界」同句已删 | +| `read_batch` / `retry_or_dlq`「Redis 出错时仍可能返回 `Ok(())`,消费循环因此不会因单次失败退出」 | 虚构断言 | 消费入口是 HTTP handler `worker`(`notify.rs:380`),**不是后台循环**:全仓 `src/` 零命中 `select!`,无信号处理、无常驻 worker 进程。`read_batch`(`state.rs:385`)经 `?` 把 Redis 错误原样上抛(:425),唯一被丢弃的结果是 XGROUP `CREATE` 的 `BUSYGROUP` 幂等保护(:394-401);`retry_or_dlq` 同样经 `?` 上抛。调用方对每一处 `Err` 都返回 `503 service_unavailable` + `retryable=true`,故「循环不因单次失败退出」这一语义前提本身不成立 | 无——不存在该缺口;`docs/opengaps.md`(原名 `docs/roadmap.md`)「代码缺口」4 → 3,`docs/design.md`「已知边界」同句已删 | ---