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 baselineb2dbe7c->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:
parent
94b6baf7bb
commit
8b1afac430
|
|
@ -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. 文档边界
|
||||
|
||||
|
|
|
|||
|
|
@ -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<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 管理与环境变量
|
||||
|
|
@ -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 <RECONCILE_TOKEN>`
|
||||
(`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 <RECONCILE_TOKEN>" \
|
||||
-H "content-type: application/json" \
|
||||
-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
|
||||
# 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": <code>, "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.<you>.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 配置时退化为确定性。
|
||||
|
|
|
|||
|
|
@ -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` 池化)。
|
||||
|
|
|
|||
|
|
@ -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(`<SECRET>` = 业务配置里的 `telegram_webhook_secret`):
|
||||
```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 /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 <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: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:` 游标**(重新基线化成功),而不是报错或停在旧游标。实测语义回填本节。
|
||||
我来做的:触发一次真实回调并记录这五个键的实际过期行为,把实测值回填本节。在这之前本节保持阻塞。
|
||||
|
||||
## 后续(多账号)
|
||||
|
||||
|
|
|
|||
|
|
@ -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 |
|
||||
|
||||
|
|
|
|||
|
|
@ -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`「已知边界」同句已删 |
|
||||
|
||||
---
|
||||
|
||||
|
|
|
|||
Loading…
Reference in New Issue