docs: describe the reconcile cursor, re-anchor drifted refs, fix three stale claims
reference.md: new Section 6.5 explains the bimodal cursor, the two token classes (Session.state is not an Email-collection token; newState is), why a truncated walk persists as a walk, and why oldState and destroyed are dropped. Section 6.2 budget table is renamed to the new constant names and gains RECONCILE_INITIAL_CHANGES. state:jmap:since is listed as a pipeline key. opengaps.md: the re-baseline fix is described with the new session_state() name, and the position-walk note no longer claims a state argument is passed as None - the None is folder_id, and the method has no state parameter at all. retired.md: the dead-code removal no longer claims those were the last three #[expect(dead_code)] attributes in src/; reload_async still has one. deployment.md / design.md: the 54+14 anchors the code move had shifted by one hunk are re-anchored, plus two manual corrections (the /reconcile route and the unconditional 204 return). charter.md: baseline 89 -> 93 passed. AGENTS.md: the two anchor counts it quotes (60, 44) were stale; they are 54 and 17. check_sec_refs.py: two-part section refs attributed to an RFC (RFC 8620 Section 2.1) are external, not doc references - the trailing-dot test only caught the three-part form.
This commit is contained in:
parent
ac27e593dd
commit
2447106840
|
|
@ -73,9 +73,9 @@
|
||||||
重试上限」,第 120 行就必须是重试上限;行号漂移而描述跟着跑,比行号写错更危险。
|
重试上限」,第 120 行就必须是重试上限;行号漂移而描述跟着跑,比行号写错更危险。
|
||||||
- **校验器只能证明「该行号存在且非空」,不能证明该行内容符合描述。** 内容级核对必须人工读。
|
- **校验器只能证明「该行号存在且非空」,不能证明该行内容符合描述。** 内容级核对必须人工读。
|
||||||
- **文档引用代码一律用符号名,不写行号。** 行号随任何无关改动重排,符号名稳定。
|
- **文档引用代码一律用符号名,不写行号。** 行号随任何无关改动重排,符号名稳定。
|
||||||
`docs/deployment.md` 是既有例外(运维 runbook,事故中按行跳转,60 个锚点仍保留)。
|
`docs/deployment.md` 是既有例外(运维 runbook,事故中按行跳转,54 个锚点仍保留)。
|
||||||
`docs/reference.md` 的 56 个 `notify.rs` 锚点里约 40 个落在闭合括号、注释行或错误符号上,
|
`docs/reference.md` 的 56 个 `notify.rs` 锚点里约 40 个落在闭合括号、注释行或错误符号上,
|
||||||
而门禁报 0 错,2026-09-29 起全部删除、不再回补。opengaps / retired / design 里还剩 44 个
|
而门禁报 0 错,2026-09-29 起全部删除、不再回补。opengaps / retired / design 里还剩 17 个
|
||||||
存量锚点,**不再新增、也不刻意清理**——它们改动频率低,不值得为此动文档。
|
存量锚点,**不再新增、也不刻意清理**——它们改动频率低,不值得为此动文档。
|
||||||
- 需要定位到行时用 `grep` 符号名,不要用文档里的行号做判断依据。
|
- 需要定位到行时用 `grep` 符号名,不要用文档里的行号做判断依据。
|
||||||
- `scripts/docs_check/audit_anchor_semantics.py` 是**独立脚本**,没接进 `run_all.sh`,
|
- `scripts/docs_check/audit_anchor_semantics.py` 是**独立脚本**,没接进 `run_all.sh`,
|
||||||
|
|
|
||||||
|
|
@ -116,7 +116,7 @@ cd /home/okabe/Repo/messageweave && docker run --rm --user 1000:1000 \
|
||||||
bash -lc 'export PATH=/usr/local/cargo/bin:$PATH; cargo fmt --all -- --check && cargo check --locked && cargo clippy --locked --all-targets -- -D warnings && cargo test --locked 2>&1 | tail -12'
|
bash -lc 'export PATH=/usr/local/cargo/bin:$PATH; cargo fmt --all -- --check && cargo check --locked && cargo clippy --locked --all-targets -- -D warnings && cargo test --locked 2>&1 | tail -12'
|
||||||
```
|
```
|
||||||
|
|
||||||
**当前基线:89 passed / 0 failed / 4 ignored**(2026-09-30 复测;ignored:`real_server_tests::session_list_and_read_smoke` 与 `debug::tests::debug_config_reports_timezone_of_business_configured_app` 需外部真实 JMAP 服务器凭据,`real_redis_ttl_tests::ttl_claim_dedup_sets_the_exactly_requested_expiry` 与 `real_redis_ttl_tests::ttl_consent_and_retry_landing_on_real_redis` 需 `REDIS_TEST_URL`)。
|
**当前基线:93 passed / 0 failed / 4 ignored**(新增 4 个游标解析与 walk 分页单测后复测;ignored:`real_server_tests::session_list_and_read_smoke` 与 `debug::tests::debug_config_reports_timezone_of_business_configured_app` 需外部真实 JMAP 服务器凭据,`real_redis_ttl_tests::ttl_claim_dedup_sets_the_exactly_requested_expiry` 与 `real_redis_ttl_tests::ttl_consent_and_retry_landing_on_real_redis` 需 `REDIS_TEST_URL`)。
|
||||||
|
|
||||||
**注意**:`docker run` 的 bash `-lc` 脚本必须用**单引号**包裹。用双引号会先在宿主机展开 `$PWD` / `$PATH`,容器内找不到 cargo。
|
**注意**:`docker run` 的 bash `-lc` 脚本必须用**单引号**包裹。用双引号会先在宿主机展开 `$PWD` / `$PATH`,容器内找不到 cargo。
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -74,11 +74,11 @@
|
||||||
`hoststack.yaml`、不用重新构建。判定为纯函数(不直接读全局 env)以便单测覆盖,测试在
|
`hoststack.yaml`、不用重新构建。判定为纯函数(不直接读全局 env)以便单测覆盖,测试在
|
||||||
`src/main.rs:217-264`。
|
`src/main.rs:217-264`。
|
||||||
|
|
||||||
缺一即不挂载:`debug_router()` 本身会构造出全部 7 条路由(`src/debug.rs:72-80`),但主入口**只在双因子成立时才合并它**(`src/notify.rs:1765`)。所以未开启时 `/debug/*` 路由根本不存在,请求走 axum 兜底返回普通 `404 not found`——**不是** 401,也不会泄露「此路径存在」。开启成功时打一条 WARN 日志标记该面已打开(`src/main.rs:115-117`,`SAF-LOG-PURITY`:只记开启状态,从不记录 token 值)。
|
缺一即不挂载:`debug_router()` 本身会构造出全部 7 条路由(`src/debug.rs:72-80`),但主入口**只在双因子成立时才合并它**(`src/notify.rs:1769`)。所以未开启时 `/debug/*` 路由根本不存在,请求走 axum 兜底返回普通 `404 not found`——**不是** 401,也不会泄露「此路径存在」。开启成功时打一条 WARN 日志标记该面已打开(`src/main.rs:115-117`,`SAF-LOG-PURITY`:只记开启状态,从不记录 token 值)。
|
||||||
|
|
||||||
**鉴权**
|
**鉴权**
|
||||||
|
|
||||||
7 条路由全部要求 `Authorization: Bearer <DEBUG_TOKEN>`,统一走 `debug_authorized`(`src/debug.rs:47-52`),它委托生产同款 `worker_authorized`(`src/notify.rs:510`),因此令牌是**常数时间比较**(`src/notify.rs:1553`)。失败回 `401 unauthorized`,且不设 `Retry-After`(`src/debug.rs:54-56`)。
|
7 条路由全部要求 `Authorization: Bearer <DEBUG_TOKEN>`,统一走 `debug_authorized`(`src/debug.rs:47-52`),它委托生产同款 `worker_authorized`(`src/notify.rs:514`),因此令牌是**常数时间比较**(`src/notify.rs:1557`)。失败回 `401 unauthorized`,且不设 `Retry-After`(`src/debug.rs:54-56`)。
|
||||||
|
|
||||||
**7 条路由与预期状态码**(注册于 `src/debug.rs:72-80`,路径为字面量,无常量抽取)
|
**7 条路由与预期状态码**(注册于 `src/debug.rs:72-80`,路径为字面量,无常量抽取)
|
||||||
|
|
||||||
|
|
@ -101,7 +101,7 @@
|
||||||
**三条约束,部署时务必确认**
|
**三条约束,部署时务必确认**
|
||||||
|
|
||||||
- **绝不回显凭据值**:`debug_config`(`src/debug.rs:96-152`)对每个 Secret 字段只输出 `*_configured` **布尔**——JMAP 密码、bot token、`worker_token`、`reconcile_token`、LLM `api_key` 一律不落响应体(`SAF-DEBUG-AUTH`)。注意这是「不含凭据」,不是「全脱敏」:**非密文的身份与预算字段是明文返回的**(JMAP session URL 与 username、Telegram `chat_id`、LLM `base_url`/`model`、各类超时与重试数),所以该面仍只能放在可信网络上。
|
- **绝不回显凭据值**:`debug_config`(`src/debug.rs:96-152`)对每个 Secret 字段只输出 `*_configured` **布尔**——JMAP 密码、bot token、`worker_token`、`reconcile_token`、LLM `api_key` 一律不落响应体(`SAF-DEBUG-AUTH`)。注意这是「不含凭据」,不是「全脱敏」:**非密文的身份与预算字段是明文返回的**(JMAP session URL 与 username、Telegram `chat_id`、LLM `base_url`/`model`、各类超时与重试数),所以该面仍只能放在可信网络上。
|
||||||
- **`/debug/notify` 受 chat 白名单约束,但空白名单不拦截**:它复用生产同一份白名单快照(`src/notify.rs:1559`),判定条件是「白名单**非空**且 `chat_id` 不在其中」才回 `403 chat_not_allowed`(`src/debug.rs:257-260`)。因此已配置白名单时无法绕过业务侧发送限制;若白名单未配置(为空)则此判定不生效,`chat_id` 可任意指定——所以启用本面时应确认业务白名单已真正配置。`text` 缺省为固定联调文案,并按 1024 字符截断(`src/debug.rs:265-270`)。
|
- **`/debug/notify` 受 chat 白名单约束,但空白名单不拦截**:它复用生产同一份白名单快照(`src/notify.rs:1563`),判定条件是「白名单**非空**且 `chat_id` 不在其中」才回 `403 chat_not_allowed`(`src/debug.rs:257-260`)。因此已配置白名单时无法绕过业务侧发送限制;若白名单未配置(为空)则此判定不生效,`chat_id` 可任意指定——所以启用本面时应确认业务白名单已真正配置。`text` 缺省为固定联调文案,并按 1024 字符截断(`src/debug.rs:265-270`)。
|
||||||
- **不在 Worker 白名单内,只能直连 origin**:网关的 16 条安全路由(`cloudflare-worker/src/backends.js:9-26`)不含任何 `/debug/*`,Worker 对未白名单路径返回 `404 route not forwarded: /debug/...`(`cloudflare-worker/src/index.js:82-85`)。因此 `/debug/*` 只能通过直连后端 origin 访问;若必须经代理,请自行在代理层加鉴权,不要让公网可达。
|
- **不在 Worker 白名单内,只能直连 origin**:网关的 16 条安全路由(`cloudflare-worker/src/backends.js:9-26`)不含任何 `/debug/*`,Worker 对未白名单路径返回 `404 route not forwarded: /debug/...`(`cloudflare-worker/src/index.js:82-85`)。因此 `/debug/*` 只能通过直连后端 origin 访问;若必须经代理,请自行在代理层加鉴权,不要让公网可达。
|
||||||
|
|
||||||
> **建议**:生产环境不开启。需要远程联调时在控制台设置 `DEBUG_ENABLED=1` 与一次性
|
> **建议**:生产环境不开启。需要远程联调时在控制台设置 `DEBUG_ENABLED=1` 与一次性
|
||||||
|
|
@ -183,13 +183,13 @@ curl -fsS -X POST "https://<你的平台URL>/api/push/register" \
|
||||||
```
|
```
|
||||||
|
|
||||||
注册动作由**本服务自己**向 JMAP 发起,不是在 Stalwart 后台点。Stalwart 随后推送验证码,
|
注册动作由**本服务自己**向 JMAP 发起,不是在 Stalwart 后台点。Stalwart 随后推送验证码,
|
||||||
后端用 JMAP 自动回写——**验证码不由运维提供,也不会从 SPA 配置接受**(`src/notify.rs:1221`)。
|
后端用 JMAP 自动回写——**验证码不由运维提供,也不会从 SPA 配置接受**(`src/notify.rs:1225`)。
|
||||||
|
|
||||||
- `callback_url` 必须是 HTTPS 且**不含用户名 / 密码**,否则 400 `invalid_request`
|
- `callback_url` 必须是 HTTPS 且**不含用户名 / 密码**,否则 400 `invalid_request`
|
||||||
(`src/notify.rs:1324-1326`)。
|
(`src/notify.rs:1328-1330`)。
|
||||||
- 同一个 `callback_url` 重复调用**幂等复用**已有订阅,不会重复注册。
|
- 同一个 `callback_url` 重复调用**幂等复用**已有订阅,不会重复注册。
|
||||||
- 注册期间持 `lock:push-register:{sha256(callback_url)}` 360 s 单飞锁
|
- 注册期间持 `lock:push-register:{sha256(callback_url)}` 360 s 单飞锁
|
||||||
(`src/notify.rs:1324-1326`),撞锁返回 409 `conflict`。
|
(`src/notify.rs:1328-1330`),撞锁返回 409 `conflict`。
|
||||||
- 成功 → `200 {"push_subscription_id":"..."}`,状态置 `pending`,在等 Stalwart 回来验证。
|
- 成功 → `200 {"push_subscription_id":"..."}`,状态置 `pending`,在等 Stalwart 回来验证。
|
||||||
反向确认:`POST /api/push/disable` 在从未注册过该地址时回 `404 push_subscription_not_found`。
|
反向确认:`POST /api/push/disable` 在从未注册过该地址时回 `404 push_subscription_not_found`。
|
||||||
- 订阅权限通常内置 `user` 角色已含;若返回 `forbidden`,到 Stalwart
|
- 订阅权限通常内置 `user` 角色已含;若返回 `forbidden`,到 Stalwart
|
||||||
|
|
@ -383,12 +383,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 容器**。本服务没有常驻消费循环,两条 stream 的唯一消费入口是 `POST /worker`(处理函数 `src/notify.rs:386`,路由 `src/notify.rs:1757`)。全代码库仅两处 `tokio::spawn`——发送重试(`src/channel.rs:205`)与对账锁续租心跳(`src/notify.rs:300`)——都不消费队列;因此**排空必须由外部调度触发**(§6.3.1)。
|
- worker:**不是后台 task,也不是独立 worker 容器**。本服务没有常驻消费循环,两条 stream 的唯一消费入口是 `POST /worker`(处理函数 `src/notify.rs:390`,路由 `src/notify.rs:1761`)。全代码库仅两处 `tokio::spawn`——发送重试(`src/channel.rs:205`)与对账锁续租心跳(`src/notify.rs:301`)——都不消费队列;因此**排空必须由外部调度触发**(§6.3.1)。
|
||||||
- 单次 `/worker` 调用语义:对 `stalwart:jmap` 与 `stalwart:telegram` 两条 stream 各取一批(上限 10,`src/notify.rs:403`;批量读取在 `src/state.rs:499`),逐条处理后确认消费(`src/state.rs:430`);处理失败转重试 / DLQ(`src/notify.rs:493`)。`Email/changes` 增量拉取与 `sinceState` 推进属于 `POST /reconcile`,不在 worker 内。
|
- 单次 `/worker` 调用语义:对 `stalwart:jmap` 与 `stalwart:telegram` 两条 stream 各取一批(上限 10,`src/notify.rs:407`;批量读取在 `src/state.rs:499`),逐条处理后确认消费(`src/state.rs:430`);处理失败转重试 / DLQ(`src/notify.rs:497`)。`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` 供调度器重试。**对账只入队、不消费**——它全程不调用 `read_batch`(全代码库唯一的 `read_batch` 调用点在 `src/notify.rs:416`,属于 worker);排空由另一个端点 `POST /worker` 承担,两个都要排(§6.3.1)。
|
- **"外部 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:420`,属于 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`。
|
||||||
|
|
@ -396,10 +396,10 @@ GET /ready 公开就绪探针;检查配置完整性 + Redis 可达
|
||||||
|
|
||||||
#### 6.3.1 用户侧调度示例(copy-paste 可用)
|
#### 6.3.1 用户侧调度示例(copy-paste 可用)
|
||||||
|
|
||||||
端点有两个:`POST /reconcile`(路由 `src/notify.rs:1756`)与 `POST /worker`(路由
|
端点有两个:`POST /reconcile`(路由 `src/notify.rs:1766`)与 `POST /worker`(路由
|
||||||
`src/notify.rs:1757`),同容器暴露、无端口或协议差异。**两个都必须被外部调度,顺序是先对账、
|
`src/notify.rs:1761`),同容器暴露、无端口或协议差异。**两个都必须被外部调度,顺序是先对账、
|
||||||
再排空。** 两者职责不重叠:`/reconcile` 只做 `Email/changes` 增量入队并推进游标,全程不调用
|
再排空。** 两者职责不重叠:`/reconcile` 只做 `Email/changes` 增量入队并推进游标,全程不调用
|
||||||
`read_batch`;`/worker` 只做消费,全代码库唯一的 `read_batch` 调用点在 `src/notify.rs:416`。
|
`read_batch`;`/worker` 只做消费,全代码库唯一的 `read_batch` 调用点在 `src/notify.rs:420`。
|
||||||
**只排对账等于邮件持续进队列、通知永远发不出去**——这是照抄调度配置时最容易踩的坑。
|
**只排对账等于邮件持续进队列、通知永远发不出去**——这是照抄调度配置时最容易踩的坑。
|
||||||
|
|
||||||
最小可运行版本(推荐起点):
|
最小可运行版本(推荐起点):
|
||||||
|
|
@ -411,13 +411,13 @@ GET /ready 公开就绪探针;检查配置完整性 + Redis 可达
|
||||||
| 鉴权 | Bearer + `reconcile_token` | Bearer + `worker_token` |
|
| 鉴权 | Bearer + `reconcile_token` | Bearer + `worker_token` |
|
||||||
| 可走 Worker 吗 | 可以(在 `SAFE_ROUTES` 内) | 不行,必须直连后端 origin |
|
| 可走 Worker 吗 | 可以(在 `SAFE_ROUTES` 内) | 不行,必须直连后端 origin |
|
||||||
| 幂等 | `SET NX lock:reconcile 300s` 单飞锁,撞锁回 409 | 无单飞锁,靠逐条 `delivery:*` 去重声明防重投 |
|
| 幂等 | `SET NX lock:reconcile 300s` 单飞锁,撞锁回 409 | 无单飞锁,靠逐条 `delivery:*` 去重声明防重投 |
|
||||||
| 单次上限 | 100 条变更(`src/notify.rs:294`) | 10 条消息(`src/notify.rs:403`) |
|
| 单次上限 | 100 条变更(`src/notify.rs:294`) | 10 条消息(`src/notify.rs:407`) |
|
||||||
| 常态返回 | 204 | 204(**不能当成功信号**) |
|
| 常态返回 | 204 | 204(**不能当成功信号**) |
|
||||||
|
|
||||||
两个 token 都是 Redis 业务配置字段(§5),不再从环境变量读取;外部 cron 从自己的 secret
|
两个 token 都是 Redis 业务配置字段(§5),不再从环境变量读取;外部 cron 从自己的 secret
|
||||||
存储取同一个值即可(下方 k8s 示例即此做法)。`/worker` 只认 Bearer token、**没有
|
存储取同一个值即可(下方 k8s 示例即此做法)。`/worker` 只认 Bearer token、**没有
|
||||||
admin-session 兜底**(`src/notify.rs:391`),这是有意的:cron 不需要先登录拿 session。
|
admin-session 兜底**(`src/notify.rs:395`),这是有意的:cron 不需要先登录拿 session。
|
||||||
token 轮换后**无需重启**——worker 处理函数开头先 `refresh_business_config`(`src/notify.rs:386`),
|
token 轮换后**无需重启**——worker 处理函数开头先 `refresh_business_config`(`src/notify.rs:390`),
|
||||||
再从热更新快照取值(`:385`,快照由 `auth_snapshot` 读取)。
|
再从热更新快照取值(`:385`,快照由 `auth_snapshot` 读取)。
|
||||||
|
|
||||||
`/worker` 不在 Worker 转发白名单里(`SAFE_ROUTES`:`/reconcile` 在内、`/worker` 不在),
|
`/worker` 不在 Worker 转发白名单里(`SAFE_ROUTES`:`/reconcile` 在内、`/worker` 不在),
|
||||||
|
|
@ -454,8 +454,8 @@ curl -sS -o /dev/null -w '%{http_code}\n' \
|
||||||
- 两条之间用 `;` 而不是 `&&`:对账撞锁拿 409、或临时 503,都不该阻断排空——上一次对账
|
- 两条之间用 `;` 而不是 `&&`:对账撞锁拿 409、或临时 503,都不该阻断排空——上一次对账
|
||||||
入队的消息同样需要被消费。
|
入队的消息同样需要被消费。
|
||||||
- 排空那条必须是**后端 origin 的直连地址**,不是 Worker 域名。
|
- 排空那条必须是**后端 origin 的直连地址**,不是 Worker 域名。
|
||||||
- 请求体可带 `{"batch":N}`;`N` 会被压回上限 10,不会因传大值而报错(`src/notify.rs:403`)。
|
- 请求体可带 `{"batch":N}`;`N` 会被压回上限 10,不会因传大值而报错(`src/notify.rs:407`)。
|
||||||
传非法 JSON 才是 400 `invalid_request`(`src/notify.rs:407`)。
|
传非法 JSON 才是 400 `invalid_request`(`src/notify.rs:411`)。
|
||||||
|
|
||||||
Kubernetes CronJob 示例:
|
Kubernetes CronJob 示例:
|
||||||
|
|
||||||
|
|
@ -503,20 +503,20 @@ spec:
|
||||||
| 状态码 | 含义 | 处理 |
|
| 状态码 | 含义 | 处理 |
|
||||||
| --- | --- | --- |
|
| --- | --- | --- |
|
||||||
| `/reconcile` 204 | 对账完成(含 since 游标前进 0 条) | 正常,不需要动作 |
|
| `/reconcile` 204 | 对账完成(含 since 游标前进 0 条) | 正常,不需要动作 |
|
||||||
| `/worker` 204 | 本次调用正常结束 | **不能当成功信号**:无消息可读、全部撞去重都回 204,`src/notify.rs:507` 无条件返回 |
|
| `/worker` 204 | 本次调用正常结束 | **不能当成功信号**:无消息可读、全部撞去重都回 204,`src/notify.rs:510` 无条件返回 |
|
||||||
| `/worker` 400 | 请求体不是合法 JSON(`src/notify.rs:408`) | 修 curl;空请求体合法,等价于取默认 10 条 |
|
| `/worker` 400 | 请求体不是合法 JSON(`src/notify.rs:412`) | 修 curl;空请求体合法,等价于取默认 10 条 |
|
||||||
| `/reconcile` 401 | `Authorization` 头缺失或 `reconcile_token` 不匹配(`src/notify.rs:285`) | 检查 cron 的 token 值;重启不解决 |
|
| `/reconcile` 401 | `Authorization` 头缺失或 `reconcile_token` 不匹配(`src/notify.rs:285`) | 检查 cron 的 token 值;重启不解决 |
|
||||||
| `/worker` 401 | Bearer 值与 `worker_token` 不匹配(`src/notify.rs:398`) | 同上;`/worker` 无 admin-session 兜底 |
|
| `/worker` 401 | Bearer 值与 `worker_token` 不匹配(`src/notify.rs:402`) | 同上;`/worker` 无 admin-session 兜底 |
|
||||||
| `/reconcile` 409 | 单飞锁 `lock:reconcile` 被占(`src/notify.rs:291` 取锁,`src/notify.rs:351` 返回) | 直接跳过本次 tick,不要重试 |
|
| `/reconcile` 409 | 单飞锁 `lock:reconcile` 被占(`src/notify.rs:291` 取锁,`src/notify.rs:355` 返回) | 直接跳过本次 tick,不要重试 |
|
||||||
| 503 | 业务配置此刻不可用,或 Redis / JMAP 请求失败 | 可重试;响应带 `Retry-After: 30`(`src/notify.rs:379`) |
|
| 503 | 业务配置此刻不可用,或 Redis / JMAP 请求失败 | 可重试;响应带 `Retry-After: 30`(`src/notify.rs:383`) |
|
||||||
|
|
||||||
重试建议:
|
重试建议:
|
||||||
|
|
||||||
- **不要**在客户端实现重试循环。**调度器每 5 分钟触发一次已经足够**,因为:
|
- **不要**在客户端实现重试循环。**调度器每 5 分钟触发一次已经足够**,因为:
|
||||||
- 单次调用**幂等**:`/reconcile` 有 `SET NX lock:reconcile` 单飞锁
|
- 单次调用**幂等**:`/reconcile` 有 `SET NX lock:reconcile` 单飞锁
|
||||||
(`ttl::RECONCILE_LOCK_SECONDS` = 300s,`src/notify.rs:291`,长对账中每
|
(`ttl::RECONCILE_LOCK_SECONDS` = 300s,`src/notify.rs:291`,长对账中每
|
||||||
`ttl::RECONCILE_HEARTBEAT_SECONDS` = 90s 续租 `src/notify.rs:304`,成功后释放
|
`ttl::RECONCILE_HEARTBEAT_SECONDS` = 90s 续租 `src/notify.rs:305`,成功后释放
|
||||||
`src/notify.rs:346`);`/worker` 靠逐条去重声明防重投,重复触发无害。
|
`src/notify.rs:350`);`/worker` 靠逐条去重声明防重投,重复触发无害。
|
||||||
- 客户端拿到 `409 conflict` 时**不要重试**,直接跳过本次 tick——锁在 TTL 或任务完成后
|
- 客户端拿到 `409 conflict` 时**不要重试**,直接跳过本次 tick——锁在 TTL 或任务完成后
|
||||||
自然释放,下一次调度会重试。
|
自然释放,下一次调度会重试。
|
||||||
- 超时设置建议 ≥ 单飞锁 TTL 的 1/3(即 ≥ 100s)以吸收长对账,但客户端超时不应超过
|
- 超时设置建议 ≥ 单飞锁 TTL 的 1/3(即 ≥ 100s)以吸收长对账,但客户端超时不应超过
|
||||||
|
|
@ -524,7 +524,7 @@ spec:
|
||||||
- 如果你的调度器自带重试能力,把它限制为:**只对 5xx 与超时重试**,指数退避
|
- 如果你的调度器自带重试能力,把它限制为:**只对 5xx 与超时重试**,指数退避
|
||||||
2s / 4s / 8s,上限 60s,总次数 ≤ 3;`204` 与 `409` **永远不重试**。调度器没有自带能力
|
2s / 4s / 8s,上限 60s,总次数 ≤ 3;`204` 与 `409` **永远不重试**。调度器没有自带能力
|
||||||
就不要额外实现——下一个 tick 自然会重试。
|
就不要额外实现——下一个 tick 自然会重试。
|
||||||
- **积压会自然堆到下一次 tick。** `/worker` 单次最多消费 10 条(`src/notify.rs:403`);
|
- **积压会自然堆到下一次 tick。** `/worker` 单次最多消费 10 条(`src/notify.rs:407`);
|
||||||
积压超过 10 条时需要连续多个 tick 才能清完。想更快就调高 cron 频率,**不要**靠加长
|
积压超过 10 条时需要连续多个 tick 才能清完。想更快就调高 cron 频率,**不要**靠加长
|
||||||
curl 超时——那只会抬高挂死概率,不会让更多消息被消费。
|
curl 超时——那只会抬高挂死概率,不会让更多消息被消费。
|
||||||
- `204` 与 `409` 都是正常结果,不要把它们当失败重试——尤其别对 `409` 做紧密循环重试。
|
- `204` 与 `409` 都是正常结果,不要把它们当失败重试——尤其别对 `409` 做紧密循环重试。
|
||||||
|
|
@ -533,7 +533,7 @@ spec:
|
||||||
- **`/reconcile` 不会返回 400**:它没有请求体解析,传了 body 会被静默忽略,所以不要以为要传 `since`。
|
- **`/reconcile` 不会返回 400**:它没有请求体解析,传了 body 会被静默忽略,所以不要以为要传 `since`。
|
||||||
- 走 Worker 转发**无需特殊设置**:网关已为 `POST /reconcile` 单独覆盖超时与尝试次数——超时取
|
- 走 Worker 转发**无需特殊设置**:网关已为 `POST /reconcile` 单独覆盖超时与尝试次数——超时取
|
||||||
`LB_RECONCILE_TIMEOUT_MS`(默认 `320000` ms,大于单飞锁租期 300 s + 心跳余量;锁续租逻辑见
|
`LB_RECONCILE_TIMEOUT_MS`(默认 `320000` ms,大于单飞锁租期 300 s + 心跳余量;锁续租逻辑见
|
||||||
`src/notify.rs:304`),且 `maxAttempts=1` **绝不故障转移**(故障转移只会让第二实例立刻返回
|
`src/notify.rs:305`),且 `maxAttempts=1` **绝不故障转移**(故障转移只会让第二实例立刻返回
|
||||||
`409`)。其余快路径仍用全局 `LB_REQUEST_TIMEOUT_MS`(默认 `10000` ms)与 `LB_MAX_ATTEMPTS`
|
`409`)。其余快路径仍用全局 `LB_REQUEST_TIMEOUT_MS`(默认 `10000` ms)与 `LB_MAX_ATTEMPTS`
|
||||||
(默认 `2`),不受影响。`POST /worker` 走不了 Worker,不受这些参数影响。
|
(默认 `2`),不受影响。`POST /worker` 走不了 Worker,不受这些参数影响。
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -277,7 +277,7 @@ TG /read 3 → handler 取会话里的 folder+page 游标
|
||||||
|
|
||||||
当前**没有 FSM**。命令路由由 `src/worker.rs` 的 `parse_intent` 解析为 `Intent`;AI 授权态是 Redis 里的一个布尔加过期时间(键与 TTL 见 `docs/reference.md` 的 AI 授权态一节),不是多步确认态。
|
当前**没有 FSM**。命令路由由 `src/worker.rs` 的 `parse_intent` 解析为 `Intent`;AI 授权态是 Redis 里的一个布尔加过期时间(键与 TTL 见 `docs/reference.md` 的 AI 授权态一节),不是多步确认态。
|
||||||
|
|
||||||
仍成立的不变量:AI 摘要必须先有用户显式授权,**没有授权态不得调用 LLM**。授权一旦生效,摘要会拉取邮件正文全文送 LLM(`read_email` 请求 `TextBody` + `BodyValues` 并 `fetch_text_body_values(true)`,见 `src/domain/jmap/client.rs`),但正文**不回传给 Telegram**——出站只发摘要,或失败时回退为前 300 字符(`worker.rs:152` 注释:body text never reaches Telegram)。
|
仍成立的不变量:AI 摘要必须先有用户显式授权,**没有授权态不得调用 LLM**。授权一旦生效,摘要会拉取邮件正文全文送 LLM(`read_email` 请求 `TextBody` + `BodyValues` 并 `fetch_text_body_values(true)`,见 `src/domain/jmap/client.rs`),但正文**不回传给 Telegram**——出站只发摘要,或失败时回退为前 300 字符(`worker.rs:227` 注释:body text never reaches Telegram)。
|
||||||
|
|
||||||
曾设计过的 5 态 FSM(`Idle` / `AwaitClarify` / `AwaitConfirm` / `Analyzing` / `AwaitFallback`)连同状态转移表与渠道中立说明,见 `docs/retired.md`。
|
曾设计过的 5 态 FSM(`Idle` / `AwaitClarify` / `AwaitConfirm` / `Analyzing` / `AwaitFallback`)连同状态转移表与渠道中立说明,见 `docs/retired.md`。
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -77,15 +77,15 @@
|
||||||
|
|
||||||
代码的实际行为比条目描述的更糟:`Err` 被原样上抛成 `Err(())`,`reconcile:state` 原样保留——下一次 cron 用同一个 dead `sinceState` 重试,再失败,再 503。游标永久冻结,`/reconcile` 永远返回 `503 reconcile_retry` 且没有任何前进。
|
代码的实际行为比条目描述的更糟:`Err` 被原样上抛成 `Err(())`,`reconcile:state` 原样保留——下一次 cron 用同一个 dead `sinceState` 重试,再失败,再 503。游标永久冻结,`/reconcile` 永远返回 `503 reconcile_retry` 且没有任何前进。
|
||||||
|
|
||||||
**修法**:`/changes` 失败时改走重新基线——取一个新鲜的 `current_state()`,写回 `baseline:{fresh_state}:0` 并 `Ok` 返回。position walk 完全不依赖服务端 changelog 保留策略,所以下一轮从 position 0 重走一遍即可恢复;24h 去重键(`enqueue_reconcile_event`,`ttl::DEDUP_JMAP_SECONDS`)保证重放最坏重复一次、不会漏。新增单测 `reconcile_rebaselines_when_changes_replay_is_stale` 锁定该行为。
|
**修法**:`/changes` 失败时改走重新基线——取一个新鲜的 `session_state()`,写回 `baseline:{fresh_state}:0` 并 `Ok` 返回。position walk 完全不依赖服务端 changelog 保留策略,所以下一轮从 position 0 重走一遍即可恢复;24h 去重键(`enqueue_reconcile_event`,`ttl::DEDUP_JMAP_SECONDS`)保证重放最坏重复一次、不会漏。新增单测 `reconcile_rebaselines_when_changes_replay_is_stale` 锁定该行为。
|
||||||
|
|
||||||
> position walk 路径(`list_emails_page(None, position, 100)`)本身不受影响——它把 `state` 传成 `None`,根本不发服务端 state。
|
> position walk 路径(`list_emails_page(None, position, BASELINE_PAGE_SIZE)`)本身不受影响——它的签名里根本没有服务端 state 参数(`None` 是 `folder_id`),所以不会碰到上面那类 token 语义。
|
||||||
|
|
||||||
## 本轮顺带发现并修掉的部署文档缺陷
|
## 本轮顺带发现并修掉的部署文档缺陷
|
||||||
|
|
||||||
`docs/deployment.md` §6.3.1 原来只给了 `/reconcile` 的调度示例,而 `/worker`——全代码库唯一的队列消费入口——被描述成「运维手工触发」,且刻意不在 Worker 白名单里。**照文档照抄部署 = 邮件持续进队列、通知永远发不出去**,这正是联调环境积压了数天才被手工排空的成因。§6.3.1 已补上 `/worker` 的调度步骤、顺序要求、批量上限与「204 不能当成功信号」的说明。
|
`docs/deployment.md` §6.3.1 原来只给了 `/reconcile` 的调度示例,而 `/worker`——全代码库唯一的队列消费入口——被描述成「运维手工触发」,且刻意不在 Worker 白名单里。**照文档照抄部署 = 邮件持续进队列、通知永远发不出去**,这正是联调环境积压了数天才被手工排空的成因。§6.3.1 已补上 `/worker` 的调度步骤、顺序要求、批量上限与「204 不能当成功信号」的说明。
|
||||||
|
|
||||||
同时修正了 `docs/reference.md` 与 `docs/retired.md` 里 `/worker` 的锚点:原值 `notify.rs:336` 落在 `reconcile` 函数体内,锚点审计不会报错(该行存在且非空),但语义是错的;正确值是 `notify.rs:386`。
|
同时修正了 `docs/reference.md` 与 `docs/retired.md` 里 `/worker` 的锚点:原值 `notify.rs:340` 落在 `reconcile` 函数体内,锚点审计不会报错(该行存在且非空),但语义是错的;正确值是 `notify.rs:390`。
|
||||||
|
|
||||||
## 后续(多账号)
|
## 后续(多账号)
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -89,6 +89,7 @@ and the Chinese prefixes `搜索`, `查找`, `检索` (prefix-matched only, neve
|
||||||
| `delivery:committed:{stream}:{id}` | EX 604_800 | key built and claimed with `claim_dedup` in the delivery path | `dedup_exists` before send |
|
| `delivery:committed:{stream}:{id}` | EX 604_800 | key built and claimed with `claim_dedup` in the delivery path | `dedup_exists` before send |
|
||||||
| `retry:{stream}:{id}` | EX 86_400 | `retry_or_dlq`'s Lua script (`INCR` then `EXPIRE` on the same key) | reclaim path |
|
| `retry:{stream}:{id}` | EX 86_400 | `retry_or_dlq`'s Lua script (`INCR` then `EXPIRE` on the same key) | reclaim path |
|
||||||
| `stalwart:jmap:dlq`, `stalwart:telegram:dlq` | Stream, no EX | the same Lua script's `XADD`; the name is passed in from `retry_or_dlq`'s call site in the worker (with `max_attempts` 3) | **nothing in code reads it** |
|
| `stalwart:jmap:dlq`, `stalwart:telegram:dlq` | Stream, no EX | the same Lua script's `XADD`; the name is passed in from `retry_or_dlq`'s call site in the worker (with `max_attempts` 3) | **nothing in code reads it** |
|
||||||
|
| `state:jmap:since` | none (durable) | `set_reconcile_state` | `get_reconcile_state` before each pass; encoding per §6.5 |
|
||||||
|
|
||||||
There is no `delivery:pending:{stream}` key — "pending" refers to the Redis Streams
|
There is no `delivery:pending:{stream}` key — "pending" refers to the Redis Streams
|
||||||
pending-entries list (PEL), which Redis maintains internally.
|
pending-entries list (PEL), which Redis maintains internally.
|
||||||
|
|
@ -511,13 +512,18 @@ There is **no** `LLM_MAX_RETRIES` environment variable; retry count lives in
|
||||||
|
|
||||||
| Constant | Value | Source |
|
| Constant | Value | Source |
|
||||||
|---|---|---|
|
|---|---|---|
|
||||||
| `MAX_BASELINE_PAGES` | 100 | `worker.rs` |
|
| `BASELINE_PAGE_SIZE` | 100 | `worker.rs` |
|
||||||
| `MAX_BASELINE_EMAILS` | 10_000 | `worker.rs` |
|
| `BASELINE_MAX_PAGES` | 100 | `worker.rs` |
|
||||||
|
| `BASELINE_MAX_EMAILS` | 10_000 | `worker.rs` |
|
||||||
|
| `RECONCILE_MAX_PAGES` | 100 | `worker.rs` |
|
||||||
| `RECONCILE_BUDGET` | 20 s | `worker.rs` |
|
| `RECONCILE_BUDGET` | 20 s | `worker.rs` |
|
||||||
| `CHANGE_WINDOW_CAP` | 4_096 | `worker.rs` |
|
| `CHANGE_WINDOW_CAP` | 4_096 | `worker.rs` |
|
||||||
|
| `RECONCILE_INITIAL_CHANGES` | 100 | `notify.rs` |
|
||||||
| Lock TTL / heartbeat | 300 s / 90 s | `reconcile` and its heartbeat task |
|
| Lock TTL / heartbeat | 300 s / 90 s | `reconcile` and its heartbeat task |
|
||||||
|
|
||||||
`max_changes` is a function parameter, not a constant.
|
`initial_changes` is a function parameter, not a constant: it sizes only the
|
||||||
|
first `/changes` call. The replay phase may widen the window afterwards, up to
|
||||||
|
`CHANGE_WINDOW_CAP`, before it advances the cursor.
|
||||||
|
|
||||||
### 6.3 Idle threshold
|
### 6.3 Idle threshold
|
||||||
|
|
||||||
|
|
@ -540,6 +546,41 @@ stake, never loss.
|
||||||
| `SEARCH_SUBJECT_MAX` | 120 chars | `worker.rs` |
|
| `SEARCH_SUBJECT_MAX` | 120 chars | `worker.rs` |
|
||||||
| `SEARCH_PREVIEW_MAX` | 160 chars | `worker.rs` |
|
| `SEARCH_PREVIEW_MAX` | 160 chars | `worker.rs` |
|
||||||
|
|
||||||
|
### 6.5 Reconcile cursor
|
||||||
|
|
||||||
|
`/reconcile` keeps one cursor, `state:jmap:since`, and it is bimodal:
|
||||||
|
|
||||||
|
- `baseline:{hex-encoded-state}:{position}` — a position walk is in flight. The
|
||||||
|
walk enumerates the collection by position (`list_emails_page`) and `/changes`
|
||||||
|
has not been replayed from it yet.
|
||||||
|
- A bare state string — the walk is done; this is a token to resume
|
||||||
|
`Email/changes` from.
|
||||||
|
|
||||||
|
The two modes are an explicit `ReconcileCursor` variant in `worker.rs`, not a
|
||||||
|
reserved position value. The page cap (`BASELINE_MAX_PAGES`) is a hard stop on
|
||||||
|
the walk: if the walk is cut there, the cursor is persisted **as a walk**, never
|
||||||
|
as a replay, because replay mode asserts the listing is exhausted and replaying
|
||||||
|
`/changes` from a half-listed state would skip whatever still remains.
|
||||||
|
|
||||||
|
Two token classes are in play and they are not interchangeable:
|
||||||
|
|
||||||
|
- `Session.state` (RFC 8620 §2.1) is minted per session and is **not** an
|
||||||
|
`Email`-collection token. A fresh or re-baselined cursor carries it, because
|
||||||
|
the jmap-client `Session` type exposes no per-collection state.
|
||||||
|
- `newState` out of a `Changes` response **is** a collection token.
|
||||||
|
|
||||||
|
A strictly conforming server may therefore reject the very first `/changes` call
|
||||||
|
after a re-baseline with a `sinceState` error. `replay_changes` absorbs it by
|
||||||
|
re-baselining to a fresh token and returning, so the mismatch can only ever bite
|
||||||
|
one call and the next pass restarts the walk; the 24h dedup key
|
||||||
|
(`ttl::DEDUP_JMAP_SECONDS`) bounds the resulting replay to at most one duplicate.
|
||||||
|
|
||||||
|
`Email/changes` also returns `destroyed` and `oldState`. Both are dropped by
|
||||||
|
`EmailChanges`: a deleted email has nothing left to look up, so it drives no
|
||||||
|
notification and does not move the cursor, and `oldState` is the token the
|
||||||
|
caller just sent as `sinceState`. Keeping either would have meant storing a
|
||||||
|
value nothing reads.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## 7. Static assets and response headers
|
## 7. Static assets and response headers
|
||||||
|
|
|
||||||
|
|
@ -75,10 +75,10 @@
|
||||||
|---|---|---|---|
|
|---|---|---|---|
|
||||||
| 把 Telegram 接入拆成「模块入口 / 命令解析 / 会话状态 / 回复渲染」四个文件的目录方案 | 虚构目录方案 | 目标目录树里画出,从未创建 | 命令解析在 `parse_intent`(`src/worker.rs`);会话/授权态走 Redis TTL(`docs/reference.md` 的 AI 授权态一节);渲染函数在 `src/channel.rs` 内 |
|
| 把 Telegram 接入拆成「模块入口 / 命令解析 / 会话状态 / 回复渲染」四个文件的目录方案 | 虚构目录方案 | 目标目录树里画出,从未创建 | 命令解析在 `parse_intent`(`src/worker.rs`);会话/授权态走 Redis TTL(`docs/reference.md` 的 AI 授权态一节);渲染函数在 `src/channel.rs` 内 |
|
||||||
| `delivery:pending:{stream}` | 虚构键 | 与 Redis Streams 的 pending-entries list(PEL)混淆——PEL 由 Redis 内部维护,不是可写键 | 真实键:`delivery:inflight:{stream}:{id}`(EX 60)与 `delivery:committed:{stream}:{id}`(EX 604_800),见 `docs/reference.md` 的投递流水线键一节 |
|
| `delivery:pending:{stream}` | 虚构键 | 与 Redis Streams 的 pending-entries list(PEL)混淆——PEL 由 Redis 内部维护,不是可写键 | 真实键:`delivery:inflight:{stream}:{id}`(EX 60)与 `delivery:committed:{stream}:{id}`(EX 604_800),见 `docs/reference.md` 的投递流水线键一节 |
|
||||||
| `check_config_reload` | 虚构函数 | 未提交草稿中的名字,从未进入代码 | 热更新为 `refresh_business_config`(`src/notify.rs:1126`) |
|
| `check_config_reload` | 虚构函数 | 未提交草稿中的名字,从未进入代码 | 热更新为 `refresh_business_config`(`src/notify.rs:1130`) |
|
||||||
| `push:registration:{sha256(callback_url)}` 曾被写成「360s 注册单飞锁」 | 事实误标 | 该键是 7d 回调→订阅 ID 映射 | 真正的 360s 单飞锁是 `lock:push-register:{sha256(callback_url)}`(`src/notify.rs:1324-1326`) |
|
| `push:registration:{sha256(callback_url)}` 曾被写成「360s 注册单飞锁」 | 事实误标 | 该键是 7d 回调→订阅 ID 映射 | 真正的 360s 单飞锁是 `lock:push-register:{sha256(callback_url)}`(`src/notify.rs:1328-1330`) |
|
||||||
| `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:386`),**不是后台循环**:全仓 `src/` 零命中 `select!`,无信号处理、无常驻 worker 进程。`read_batch`(`state.rs:491`)经 `?` 把 Redis 错误原样上抛(`state.rs:531`),唯一被丢弃的结果是 XGROUP `CREATE` 的 `BUSYGROUP` 幂等保护(`state.rs:499-507`);`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:390`),**不是后台循环**:全仓 `src/` 零命中 `select!`,无信号处理、无常驻 worker 进程。`read_batch`(`state.rs:491`)经 `?` 把 Redis 错误原样上抛(`state.rs:531`),唯一被丢弃的结果是 XGROUP `CREATE` 的 `BUSYGROUP` 幂等保护(`state.rs:499-507`);`retry_or_dlq` 同样经 `?` 上抛。调用方对每一处 `Err` 都返回 `503 service_unavailable` + `retryable=true`,故「循环不因单次失败退出」这一语义前提本身不成立 | 无——不存在该缺口;`docs/opengaps.md`(原名 `docs/roadmap.md`)「代码缺口」4 → 3,`docs/design.md`「已知边界」同句已删 |
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
|
@ -87,7 +87,7 @@
|
||||||
| 把渠道层建成目录模块(含模块入口文件) | 虚构目录 | 渠道层就是 `src/channel.rs` 一个文件 | 无 |
|
| 把渠道层建成目录模块(含模块入口文件) | 虚构目录 | 渠道层就是 `src/channel.rs` 一个文件 | 无 |
|
||||||
| `notify::push_handler` / `notify::worker` / `notify::reconcile` 作为模块路径 | 虚构模块路径 | 这些是 `src/notify.rs` 内的自由函数(`jmap_push` / `worker` / `reconcile`),不是模块路径 | 无 |
|
| `notify::push_handler` / `notify::worker` / `notify::reconcile` 作为模块路径 | 虚构模块路径 | 这些是 `src/notify.rs` 内的自由函数(`jmap_push` / `worker` / `reconcile`),不是模块路径 | 无 |
|
||||||
| `mod_dedup` / `mod_streams` / `mod_sincestate` | 虚构模块名 | `state.rs` / `notify.rs` 都是平铺文件,无子模块;去重与 Streams 逻辑以自由函数存在 | 无 |
|
| `mod_dedup` / `mod_streams` / `mod_sincestate` | 虚构模块名 | `state.rs` / `notify.rs` 都是平铺文件,无子模块;去重与 Streams 逻辑以自由函数存在 | 无 |
|
||||||
| `PushVerification` 类型 | 虚构类型 | 未定义;`register_push`(`notify.rs:1311`)内联处理回调 URL 与验证码回写 | 无 |
|
| `PushVerification` 类型 | 虚构类型 | 未定义;`register_push`(`notify.rs:1315`)内联处理回调 URL 与验证码回写 | 无 |
|
||||||
| `CancellationToken` | 虚构类型 | 未使用;无优雅关闭、无信号处理(`src/` 零命中 `tokio::signal` / `ctrl_c`) | 无 |
|
| `CancellationToken` | 虚构类型 | 未使用;无优雅关闭、无信号处理(`src/` 零命中 `tokio::signal` / `ctrl_c`) | 无 |
|
||||||
| `Preview` 类型 / 4000 字符长邮件保护 / `[继续查看原文]` 按钮 / `/llm-fallback` 按钮 | 虚构类型与 UI | 全部未实现;授权后把全文交给 LLM,失败即回退前 300 字符,无任何截断标注或按钮 | 无 |
|
| `Preview` 类型 / 4000 字符长邮件保护 / `[继续查看原文]` 按钮 / `/llm-fallback` 按钮 | 虚构类型与 UI | 全部未实现;授权后把全文交给 LLM,失败即回退前 300 字符,无任何截断标注或按钮 | 无 |
|
||||||
| `LlmErr`(5 变体) | 虚构枚举 | 真实是 `AiError`,仅 3 个变体(`InvalidEndpoint` / `Request` / `Response`) | 无 |
|
| `LlmErr`(5 变体) | 虚构枚举 | 真实是 `AiError`,仅 3 个变体(`InvalidEndpoint` / `Request` / `Response`) | 无 |
|
||||||
|
|
@ -96,7 +96,7 @@
|
||||||
| 熔断器 / 半开态 / 熔断后 60s 冷却 / 规则兜底(Redis 共享计数) | 未实施设计 | 只有超时 + 重试;LLM 失败静默降级为前 300 字回退,无用户侧提示、无状态记录 | 无 |
|
| 熔断器 / 半开态 / 熔断后 60s 冷却 / 规则兜底(Redis 共享计数) | 未实施设计 | 只有超时 + 重试;LLM 失败静默降级为前 300 字回退,无用户侧提示、无状态记录 | 无 |
|
||||||
| 定时摘要 / 每日邮件摘要推送 | 未实施 | 未实现 | 无 |
|
| 定时摘要 / 每日邮件摘要推送 | 未实施 | 未实现 | 无 |
|
||||||
| 附件下载(`send_document` / `Blob/get` / 下载按钮) | 未实施 | JMAP 侧只读;邮件附件仅以 `has_attachment: bool` 形式出现 | 无 |
|
| 附件下载(`send_document` / `Blob/get` / 下载按钮) | 未实施 | JMAP 侧只读;邮件附件仅以 `has_attachment: bool` 形式出现 | 无 |
|
||||||
| `/flag` / `/unseen` / 发信命令 | 未实施 | 当前识别 6 个意图(帮助 / 同意 / 摘要 / 搜索 / 普通消息 / 未识别);其中 `/search` 已实现(`worker.rs:572`) | 无 |
|
| `/flag` / `/unseen` / 发信命令 | 未实施 | 当前识别 6 个意图(帮助 / 同意 / 摘要 / 搜索 / 普通消息 / 未识别);其中 `/search` 已实现(`worker.rs:680`) | 无 |
|
||||||
| `Identity` 概念 | 未实施 | 账号识别只依赖 `ACCOUNT_ID`,无身份层抽象 | 无 |
|
| `Identity` 概念 | 未实施 | 账号识别只依赖 `ACCOUNT_ID`,无身份层抽象 | 无 |
|
||||||
| 把 `RUN_MODE` 抽成独立配置文件 | 虚构拆分方案 | `RUN_MODE` 曾由 `config.rs` 读取并在 `src/main.rs` 校验取值,**该变量与校验块现已一并删除**(见 §4);从未存在 `validate_env_or_exit` 这类函数 | 无 |
|
| 把 `RUN_MODE` 抽成独立配置文件 | 虚构拆分方案 | `RUN_MODE` 曾由 `config.rs` 读取并在 `src/main.rs` 校验取值,**该变量与校验块现已一并删除**(见 §4);从未存在 `validate_env_or_exit` 这类函数 | 无 |
|
||||||
| docker-compose `message-weave health --addr` 示例 | 虚构命令 | 应用无 CLI 子命令;健康检查端点是 `GET /healthz` 与 `GET /ready` | 见 `docs/deployment.md` 的 Health-check 表 |
|
| docker-compose `message-weave health --addr` 示例 | 虚构命令 | 应用无 CLI 子命令;健康检查端点是 `GET /healthz` 与 `GET /ready` | 见 `docs/deployment.md` 的 Health-check 表 |
|
||||||
|
|
@ -127,7 +127,7 @@
|
||||||
| `Config.port` / `Config.redis_url` 字段 | 已删除字段 | `port` 在两个构造器里硬编码 `8080`,而监听绑定直接从环境变量取值——字段值可与真实监听端口静默不一致,且全仓零读取方;`redis_url` 同样零读取 | `Config` 收窄为 7 字段:`telegram` / `jmap` / `account_id` / `llm` / `auth` / `worker_token` / `timezone`(末项镜像自 `BusinessConfig`,供 worker 渲染时间戳) |
|
| `Config.port` / `Config.redis_url` 字段 | 已删除字段 | `port` 在两个构造器里硬编码 `8080`,而监听绑定直接从环境变量取值——字段值可与真实监听端口静默不一致,且全仓零读取方;`redis_url` 同样零读取 | `Config` 收窄为 7 字段:`telegram` / `jmap` / `account_id` / `llm` / `auth` / `worker_token` / `timezone`(末项镜像自 `BusinessConfig`,供 worker 渲染时间戳) |
|
||||||
| 两个构造器的 `redis_url` 参数 | 已删除参数 | 唯一读者(原 main.rs 第 72 行)在上一轮改造中被移除 | `redis_only()` 与 `from_business(value)`;包装器 `from_business_json` / `from_business_value` 同步去掉首参 |
|
| 两个构造器的 `redis_url` 参数 | 已删除参数 | 唯一读者(原 main.rs 第 72 行)在上一轮改造中被移除 | `redis_only()` 与 `from_business(value)`;包装器 `from_business_json` / `from_business_value` 同步去掉首参 |
|
||||||
| 19 个遗留环境变量名 | 已删除读取 | 见上;这些名字在代码中已无任何读取方 | 名单见 `docs/reference.md` §5.3;语义见 `docs/design.md` §7.1 |
|
| 19 个遗留环境变量名 | 已删除读取 | 见上;这些名字在代码中已无任何读取方 | 名单见 `docs/reference.md` §5.3;语义见 `docs/design.md` §7.1 |
|
||||||
| `Channel` / `Notifier` / `MessageAdapter`(`src/channel.rs`)+ `UserCommand`(`src/domain.rs`) | 已删除占位 trait 与类型 | 三者**无实现、无调用方、无 dyn 绑定**,`#[expect(dead_code)]` 属性是仅有的引用来源;实际 Telegram 出站走 `channel::telegram::TelegramClient`,由 `worker.rs` 的 `MetadataWorker` 与 `notify.rs` 直接持有,从未经过它们。`UserCommand` 的唯一使用者是被删的 `Channel` | `channel.rs` 只留 `pub mod telegram`(`TelegramClient`,`reqwest` 自研);`domain.rs` 只留领域 `Notification`(worker.rs:235 在用)。这是 src/ 里最后 3 个 `#[expect(dead_code)]` |
|
| `Channel` / `Notifier` / `MessageAdapter`(`src/channel.rs`)+ `UserCommand`(`src/domain.rs`) | 已删除占位 trait 与类型 | 三者**无实现、无调用方、无 dyn 绑定**,`#[expect(dead_code)]` 属性是仅有的引用来源;实际 Telegram 出站走 `channel::telegram::TelegramClient`,由 `worker.rs` 的 `MetadataWorker` 与 `notify.rs` 直接持有,从未经过它们。`UserCommand` 的唯一使用者是被删的 `Channel` | `channel.rs` 只留 `pub mod telegram`(`TelegramClient`,`reqwest` 自研);`domain.rs` 只留领域 `Notification` 与 `pub mod jmap` 模块声明(`Notification` 在 worker.rs:330 在用);src/ 里只剩 `reload_async` 那 1 个 `expect(dead_code)`(测试用的重载 API) |
|
||||||
|
|
||||||
> 注意 `LLM_MAX_RETRIES` 与 `LLM_SUMMARY_TARGET_CHARS` 的区别:前者从来不是环境变量
|
> 注意 `LLM_MAX_RETRIES` 与 `LLM_SUMMARY_TARGET_CHARS` 的区别:前者从来不是环境变量
|
||||||
> (Redis 运行参数 `max_retries`,回落默认见 `docs/reference.md` §6.1),后者是常量
|
> (Redis 运行参数 `max_retries`,回落默认见 `docs/reference.md` §6.1),后者是常量
|
||||||
|
|
|
||||||
|
|
@ -72,8 +72,13 @@ def main():
|
||||||
f"{rel}:{lineno}: §{m.group(2)} does not exist "
|
f"{rel}:{lineno}: §{m.group(2)} does not exist "
|
||||||
f"in {target}")
|
f"in {target}")
|
||||||
for m in SEC.finditer(line):
|
for m in SEC.finditer(line):
|
||||||
# RFC-style three-part numbering (`§7.2.3`) is external,
|
# External references are not doc references. Two shapes:
|
||||||
# not a doc reference.
|
# RFC-style three-part numbering (`§7.2.3`), caught below
|
||||||
|
# by the trailing-dot test, and an explicit RFC attribute
|
||||||
|
# on a two-part ref (`RFC 8620 §2.1`).
|
||||||
|
prefix = line[:m.start()]
|
||||||
|
if re.search(r"RFC\s+\d{3,5}\s*$", prefix):
|
||||||
|
continue
|
||||||
if line[m.end():m.end() + 1] == ".":
|
if line[m.end():m.end() + 1] == ".":
|
||||||
continue
|
continue
|
||||||
if m.start() <= override_pos:
|
if m.start() <= override_pos:
|
||||||
|
|
@ -81,8 +86,6 @@ def main():
|
||||||
continue
|
continue
|
||||||
num = m.group(1)
|
num = m.group(1)
|
||||||
# chained refs (`§2.3、§3.2、§5.7`) inherit `target`
|
# chained refs (`§2.3、§3.2、§5.7`) inherit `target`
|
||||||
pos = m.start()
|
|
||||||
prefix = line[:pos]
|
|
||||||
if re.search(r"(?:\.md)[`\s]*$", prefix):
|
if re.search(r"(?:\.md)[`\s]*$", prefix):
|
||||||
target = os.path.basename(
|
target = os.path.basename(
|
||||||
re.search(r"([A-Za-z0-9_.-]+\.md)[`\s]*$",
|
re.search(r"([A-Za-z0-9_.-]+\.md)[`\s]*$",
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue