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)
This commit is contained in:
anonsaber 2026-09-29 20:55:54 +08:00
parent 94b6baf7bb
commit 8b1afac430
6 changed files with 194 additions and 113 deletions

View File

@ -9,7 +9,7 @@
> >
> 三者冲突时:**可核对事实以 `reference.md` 为准,项目约束以本文件为准,质量规则以 `../AGENTS.md` 为准。** > 三者冲突时:**可核对事实以 `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 外部依赖测试 ### 6.6 外部依赖测试
Stalwart 与 Telegram 的业务凭据已在真机联调通过(出站与查询方向),托管 Redis 已连接; Stalwart 与 Telegram 的业务凭据已在真机联调通过——出站、查询与**入站回调**两个方向
**尚未验证的是入站回调方向**(见下方阻塞项)。真实环境集成测试必须用 `#[ignore]` 标注, 均已验证(`setWebhook` 与 `PushSubscription` 的注册、回调、验证往返全部走通),托管 Redis
缺凭据时静默跳过(不泄露凭据、不判失败)。 已连接。真实环境集成测试必须用 `#[ignore]` 标注,缺凭据时静默跳过(不泄露凭据、不判失败)。
**环境阻塞项**(未验证,不得声称已验证)见 [`opengaps.md`](opengaps.md)「阻塞」区: **环境阻塞项**(未验证,不得声称已验证)见 [`opengaps.md`](opengaps.md)「阻塞」区:
真实 Stalwart、真实 Telegram Bot、`Email/changes` 的 `newState` 语义、 `Email/changes` 的 `newState` 语义、五个键的 TTL 实测。
Push callback 公网映射。
## 7. 文档边界 ## 7. 文档边界

View File

@ -140,6 +140,69 @@
- **多实例 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(同文件内章节链接)
### 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<BOT_TOKEN>/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 <admin session 或 WORKER_TOKEN>" \
-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 管理与环境变量 ## 5. Secret 管理与环境变量
@ -315,12 +378,12 @@ GET /ready 公开就绪探针;检查配置完整性 + Redis 可达
### 6.2 Redis Streams / worker(MOD-STREAMS) ### 6.2 Redis Streams / worker(MOD-STREAMS)
- Push 回调只做:校验 → 去重 → 入队 → ACK;不阻塞 - Push 回调只做:校验 → 去重 → 入队 → ACK;不阻塞
- worker(同容器后台 task 或独立 worker 容器,2 选 1 均由 compose/编排决定): - 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)。
`XREADGROUP → Email/changes → 推送 TG → 推进 sinceState → XACK` - 单次 `/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 二次兜底) - 消费组 at-least-once:未 ACK 消息自动重投;处理幂等(MOD-DEDUP 二次兜底)
### 6.3 外部 HTTPS Cron 对账(FLOW-RECONCILE) ### 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`):兼顾"少延迟"与"低开销";这是可用性的兜底频率。 - **建议间隔 5–10 分钟**(`NFR-RECONCILE-INTERVAL`):兼顾"少延迟"与"低开销";这是可用性的兜底频率。
- 可用调度器(任选其一,均为外部):系统 crontab+curl / k8s CronJob / GitHub Actions scheduled / 第三方 cron 服务。 - 可用调度器(任选其一,均为外部):系统 crontab+curl / k8s CronJob / GitHub Actions scheduled / 第三方 cron 服务。
- 对账逻辑:用 `sinceState` 调 `Email/changes` 拉增量 → 与已处理 email_id 求差 → 补发通知 → 推进 `sinceState`。 - 对账逻辑:用 `sinceState` 调 `Email/changes` 拉增量 → 与已处理 email_id 求差 → 补发通知 → 推进 `sinceState`。
@ -328,78 +391,136 @@ GET /ready 公开就绪探针;检查配置完整性 + Redis 可达
#### 6.3.1 用户侧调度示例(copy-paste 可用) #### 6.3.1 用户侧调度示例(copy-paste 可用)
端点:`POST https://<你的平台URL>/reconcile`。鉴权:`Authorization: Bearer <RECONCILE_TOKEN>` 端点有两个:`POST /reconcile`(路由 `src/notify.rs:1607`)与 `POST /worker`(路由
(`src/config.rs:147` fail-closed 必填,服务端常数时间比较,`notify.rs:228-231`)。 `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 ```bash
curl -sS -o /dev/null -w '%{http_code}\n' \ curl -fsS -X POST "https://<你的平台URL>/reconcile" \
-X POST https://<你的平台URL>/reconcile \ -H "Authorization: Bearer <RECONCILE_TOKEN>" \
-H "Authorization: Bearer $RECONCILE_TOKEN" \ -H "content-type: application/json" \
--max-time 60 -d '{"since":"<STATE>"}'
``` ```
调度器任选其一(均为外部,容器不自建定时器): 最小调度(两步一条 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 ```yaml
# Kubernetes CronJob,每 5 分钟
apiVersion: batch/v1 apiVersion: batch/v1
kind: CronJob kind: CronJob
metadata: metadata:
name: messageweave-reconcile name: messageweave-drain
spec: spec:
schedule: "*/5 * * * *" schedule: "*/5 * * * *"
concurrencyPolicy: Forbid concurrencyPolicy: Forbid
successfulJobsHistoryLimit: 1 successfulJobsHistoryLimit: 1
failedJobsHistoryLimit: 1 failedJobsHistoryLimit: 1
startingDeadlineSeconds: 120
jobTemplate: jobTemplate:
spec: spec:
template: template:
spec: spec:
restartPolicy: Never restartPolicy: Never
containers: containers:
- name: reconcile - name: drain
image: curlimages/curl:8 image: curlimages/curl:8
# 用 shell 启动,才能展开 $RECONCILE_TOKEN;直接 exec curl 时 $(…) 不会被替换
command: ["/bin/sh", "-c"] command: ["/bin/sh", "-c"]
args: 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: env:
- name: APP_URL
value: "https://<你的平台URL>"
- name: WORKER_ORIGIN
value: "https://<后端origin直连地址>"
- name: RECONCILE_TOKEN - name: RECONCILE_TOKEN
valueFrom: valueFrom:
secretKeyRef: secretKeyRef: {name: messageweave, key: reconcile-token}
name: messageweave - name: WORKER_TOKEN
key: reconcile-token valueFrom:
secretKeyRef: {name: messageweave, key: worker-token}
``` ```
**期望状态码** 期望状态码:
| 状态码 | 含义 | 调度器怎么办 | | 状态码 | 含义 | 处理 |
| --- | --- | --- | | --- | --- | --- |
| `204` | 成功,游标已推进(无响应体) | 正常,不做任何处理 | | `/reconcile` 204 | 对账完成(含 since 游标前进 0 条) | 正常,不需要动作 |
| `401` | `RECONCILE_TOKEN` 缺失或错误 | 不要重试;检查凭据 | | `/worker` 204 | 本次调用正常结束 | **不能当成功信号**:无消息可读、全部撞去重都回 204,`src/notify.rs:491` 无条件返回 |
| `409` | 单飞锁被占用,另有实例正在对账 | 不要重试;等下一个调度周期 | | `/reconcile` 400 | `since` 不是合法游标 | 检查请求体,不要盲重试 |
| `503` | 业务开关关闭,或 JMAP/Redis 故障 | 按 `Retry-After: 30` 头重试,或等下一个周期 | | `/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": <code>, "request_id": <实例 owner id>}`,可直接落日志。 重试建议:
**重试建议** - **不要**在客户端实现重试循环。**调度器每 5 分钟触发一次已经足够**,因为:
- 单次调用**幂等**:`/reconcile` 有 `SET NX lock:reconcile 300s` 单飞锁
- 对账逻辑本身是幂等的:重复调用只会重复走 `Email/changes` 增量并二次去重入队(`MOD-DEDUP` (`src/notify.rs:289`,长对账中每 90s 续租 `src/notify.rs:302`,成功后释放
兜底),不会重复通知。所以对 `503` 可以放心重试。 `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` 做紧密循环重试。 - `204` 与 `409` 都是正常结果,不要把它们当失败重试——尤其别对 `409` 做紧密循环重试。
- 调度间隔按 `NFR-RECONCILE-INTERVAL` 取 **5–10 分钟**即可。Push 是主路径,对账只是兜底。 - 调度间隔按 `NFR-RECONCILE-INTERVAL` 取 **5–10 分钟**。Push 是主路径,对账是兜底;
但**排空不是兜底项**——没有它,邮件只会一直进队列,一行通知都不会发出来。
- 走 Worker 转发**无需特殊设置**:网关已为 `POST /reconcile` 单独覆盖超时与尝试次数——超时取 - 走 Worker 转发**无需特殊设置**:网关已为 `POST /reconcile` 单独覆盖超时与尝试次数——超时取
`LB_RECONCILE_TIMEOUT_MS`(默认 `320000` ms,大于单飞锁租期 300 s + 心跳余量;锁续租逻辑见 `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` `409`)。其余快路径仍用全局 `LB_REQUEST_TIMEOUT_MS`(默认 `10000` ms)与 `LB_MAX_ATTEMPTS`
(默认 `2`),不受影响。 (默认 `2`),不受影响。`POST /worker` 走不了 Worker,不受这些参数影响。
### 6.4 可靠性策略(Reliability) ### 6.4 可靠性策略(Reliability)
@ -600,7 +721,7 @@ Stalwart PushSub ────┼─▶ https://lb.<you>.workers.dev ← 唯
### 10.4 路由与故障转移 ### 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),不触发业务副作用。 - **健康聚合(`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),其余路由不受影响。 - **故障转移(`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 配置时退化为确定性。 - **随机分摊**:起点 origin 按 `Math.random` 随机化,实现双活;单 origin 配置时退化为确定性。

View File

@ -684,8 +684,8 @@ Push 事件经 Streams 消费并投递到 Telegram,其关键路径交付语义
- 影响:`Credentials::Basic` 构造;无需 OAuth client / token 自动刷新模块。 - 影响:`Credentials::Basic` 构造;无需 OAuth client / token 自动刷新模块。
### 11.2 实时通道(已定,见 deployment.md) ### 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 仍是必须的可靠补偿通道。 - 通道 = **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`,不在本文以问题形式留存。 - Stalwart 侧接入已在真实环境验证:内置角色具备 `PushSubscription` 权限,注册、验证往返与真实回调投递全部走通。剩下待校准的是 Stalwart 回调重试次数与 TTL / 对账间隔的匹配,作为验收项登记在 `docs/opengaps.md`,不在本文以问题形式留存。
### 11.3 部署形态(已确认,架构相关) ### 11.3 部署形态(已确认,架构相关)
- **已确认**:**单账户实现**(`REQ-SINGLE-ACCOUNT`);多账户暂用**多个 bot 实例**(各自 token/配置),**不做多账户单实例**(因此无需 chat→account 路由与 `JmapService` 池化)。 - **已确认**:**单账户实现**(`REQ-SINGLE-ACCOUNT`);多账户暂用**多个 bot 实例**(各自 token/配置),**不做多账户单实例**(因此无需 chat→account 路由与 `JmapService` 池化)。

View File

@ -4,76 +4,45 @@
## 你要做的事 ## 你要做的事
代码侧**没有**待你实现的项。四项阻塞里,两项需要你跑一条 curl,一项只需要你告诉我一个事实,一项需要你腾一个停机窗口。 代码侧**没有**待你实现的项。剩下的一件事需要你腾一个停机窗口,另一件由我跑:
| # | 事项 | 谁做 | 你要动手吗 | | # | 事项 | 谁做 | 你要动手吗 |
| --- | --- | --- | --- | | --- | --- | --- | --- |
| 1 | Telegram `setWebhook` | 你 | 跑 1 条 curl,然后发一条消息 | | 1 | `Email/changes` 的 `newState` 语义 | 你 + 我 | 腾一次停机窗口 |
| 2 | Stalwart `PushSubscription` | 你 | 跑 1 条 curl(注册由本服务自己向 JMAP 发起) | | 2 | 单飞锁 TTL 实测 | 我 | 不需要,我自己跑 |
| 3 | 回调公网映射 | 你 | 只回答一个问题:用哪个组件做映射 |
| 4 | `Email/changes` 的 `newState` 语义 | 你 + 我 | 腾一次停机窗口 |
| 5 | 单飞锁 TTL 实测 | 我 | 不需要的,依赖 #2 成功后我自己跑 |
## 阶段目标 ## 阶段目标
剩余目标收窄为:**在 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(`<SECRET>` = 业务配置里的 `telegram_webhook_secret`): 我来做的:恢复后跑 `POST /reconcile`,再看 `/debug/worker` 的 `reconcile_cursor`。预期是重新变成一个**新的 `baseline:` 游标**(重新基线化成功),而不是报错或停在旧游标。实测语义回填本节。
```bash
curl -sS "https://api.telegram.org/bot<BOT_TOKEN>/setWebhook" \
-d url=https://messageweave-eu-1.motofans.club/webhook/tg \
-d secret_token=<SECRET> \
-d 'allowed_updates=["message"]'
```
不传 `allowed_updates` 时默认订阅全部更新类型;显式收窄到 `message` 与本服务只解析 `message.text` 一致。
2. 确认已登记:
```bash
curl -sS "https://api.telegram.org/bot<BOT_TOKEN>/getWebhookInfo"
```
看 `result.url` 等于上面那个地址。**Telegram 不回显 `secret_token`**,所以 secret 写对没有只能靠第 3 步反过来验。
3. 用允许名单里的 chat id 发一条 `/help`,然后告诉我「发了」。
我来收尾:`POST /api/worker` 排空队列并确认回帖。如果没收到,`getWebhookInfo` 里的 `last_error_message` 与 `last_error_date` 是最快的定位线索。 ### 2. 单飞锁 TTL 实测
**轮换注意:** 在 SPA 改了 `telegram_webhook_secret` 而**没有**重跑 `setWebhook`,Telegram 手里就是旧值——之后所有真实回调都会被 401 拒掉,**不报错、无告警,只是静默收不到消息**。改了就重跑第 1 步。 TTL 参数已在代码里写死、单测也已覆盖,但真实场景下的实际过期行为从未观测过。前置依赖(订阅注册成功、真实回调在投递)已解除,这一项不再依赖任何人的配合。
### 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 <WORKER_TOKEN>" \
-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:tg:{update_id}` | Telegram 更新去重 | 86400s | `src/notify.rs:248` |
| `dedup:jmap:{account_id}:{email_id}` | JMAP 更新去重 | 86400s | `src/notify.rs:1134` | | `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:` 游标**(重新基线化成功),而不是报错或停在旧游标。实测语义回填本节。
## 后续(多账号) ## 后续(多账号)

View File

@ -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 | | Path | Why it is absent from the 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` | 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 | | `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 | | `/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 |

View File

@ -78,7 +78,7 @@
| `check_config_reload` | 虚构函数 | 未提交草稿中的名字,从未进入代码 | 热更新为 `refresh_business_config`(`src/notify.rs:718`) | | `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`) | | `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`) | 无需配置开关,白名单恒生效 | | `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`「已知边界」同句已删 |
--- ---