MessageWeave/docs/reference.zh-CN.md

755 lines
42 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

# MessageWeave — 参考
> [English version / 英文版 → reference.md](reference.md)
> **此文件是可验证事实的单一事实来源。**
> 当任何其他文件与本文件不一致时,以本文件为准。
>
> **根据设计,该文件不包含源行号。** 它们不属于
> 验证:此文件记录*哪个符号*执行*什么*,行号是
> 对同一文件的每次不相关的编辑都会重新编号。 `README.md` 也仅用符号名引用代码;原
> `docs/deployment.md` 运行手册(保留行号)已于 2026-10 合并入 README。
>
> **维护责任。** 对 `src/config.rs` 公共 API 的任何更改,
> `src/state.rs`、`src/worker.rs` 或 `src/notify.rs`,或 **任何 Redis 键名称或 TTL**,
> 必须在同一更改中更新此文件。审阅者:拒绝涉及这些的更改
> 文件而不触及此文件。
全文使用的约定:
- **TTL** 以秒为单位。 “No EX”意味着密钥永不过期,并且在明确删除之前一直有效。
- **写入器/读取器** 命名执行操作的代码符号。符号名称是
身份;这个文件故意不记录行号(见上面的注释),所以
重新编号后仍然如此。
---
---
## 1. Redis 键
### 1.1 配置
| 键 | TTL | 写入方 | 读取方 |
|---|---|---|---|
| `config:business` | 无 EX | `set_business_config`;首次写入通过 `initialize_business_config` 内的 `SET NX` | `get_business_config` |
| `config:business:revision` | 无 EX | 每次配置写入做原子 `INCR`;首次初始化用 `SET 1 NX` | `business_config_revision` |
| `config:outbound` | 无 EX | `set_outbound_config` | `get_outbound_config` |
| `config:enabled` | 无 EX | `set_enabled` | `is_enabled` |
| `config:admin_session` | EX 1800(30 分钟) | `put_admin_session` | `admin_session_valid`;由 `revoke_admin_session` 清除 |
笔记:
- `config:business:revision` 是一个**原子 Redis `INCR`**,而不是读取-修改-写入。每个
承载请求的入口点调用“refresh_business_config”,这仅
当远程修订超出缓存的本地值时重建工作程序 - 即
Guard 可以防止陈旧或格式错误的快照变成重建循环。
- `config:admin_session` 的 TTL 由写入时传入的 `EX 1800` 决定;处理程序也在响应正文中
通过 `expires_in: 1800` 返回相同的有效期。
- `config:enabled` 被视为一个门;缺失或“错误”会导致业务处理中断。
### 1.2 AI 授权
| 键 | TTL | 写入方 | 读取方 |
|---|---|---|---|
| `consent:ai:{chat_id}` | EX 3600 / 86_400 / 604_800 / 31_536_000 | `set_ai_consent` | `ai_consent_until`;由 `clear_ai_consent` 清除 |
存储的值是**绝对 Unix 到期时间戳**,而不是持续时间;钥匙是“EX”
具有相同的持续时间,因此密钥会自行删除。
触发词是精确的中文文字,**没有英文别名** - 应用程序内帮助文本
本身强制执行(“worker.rs”中的应用内帮助文本):
|输入 | TTL |标签|
|---|---|---|
| `/ai on`, `/ai yes`, `开启 ai`, `同意摘要`, `允许 ai` | 3600 | `1小时` |
| `临时`、`一次`(子字符串)| 3600 | `临时1小时` |
| `今天` | 86_400 | `今天` |
| `7天` | 604_800 | `7天` |
| `直到我撤销`, `长期`(子串)| 31_536_000(365 天)| `直到撤销(最长365天)` |
| `/ai off`, `关闭 ai`, `撤销授权`, `停止摘要` | — |撤销|
`/ai ...` 斜杠形式与整个消息完全匹配;中文短语是
子字符串匹配消息中的任何位置(都在“parse_intent”、“worker.rs”内)。
`1小时` / `临时1小时` / `直到撤销(最长365天)`是**类别标签,而不是触发词**:
单独输入标签不会带来任何好处。同意永远不会自动续订。
相同的“parse_intent”(“worker.rs”,“Intent”的变体)路由“/search <关键词>”
以及中文前缀 `搜索`、`查找`、`检索`(只做前缀匹配,绝不做子串匹配),
路由到 `Intent::Search`。搜索不授予授权,也不写任何 Redis 键。
### 1.3 投递流水线
| 键 | TTL | 写入方 | 读取方 |
|---|---|---|---|
| `stalwart:jmap`, `stalwart:telegram` |流式传输,无 EX | `State::enqueue` (一个 `XADD`),从 `telegram_webhook` 和 `jmap_push` 调用;通过 `claim_dedup_and_enqueue` (`worker.rs`) | 协调路径消费者组“stalwart-workers”、消费者“http-worker”上的“read_batch” |
| `delivery:inflight:{stream}:{id}` | EX 60 | 交付路径中由 `claim_dedup` 构建并声明 | 完成后 `release_dedup`
| `delivery:committed:{stream}:{id}` | EX 604_800 | 交付路径中由 `claim_dedup` 构建并声明 | 发送前 `dedup_exists`
| `retry:{stream}:{id}` | EX 86_400 | `retry_or_dlq` 的 Lua 脚本(同一键上的 `INCR` 然后 `EXPIRE`)|回收路径|
| `stalwart:jmap:dlq`, `stalwart:telegram:dlq` |流式传输,无 EX |相同 Lua 脚本的“XADD”;名称从工作线程中的 `retry_or_dlq` 调用站点传入(使用 `max_attempts` 3) | **代码中没有任何内容读取它** |
| `state:jmap:since` | 无(持久)| `set_reconcile_state` |每次传递之前的`get_reconcile_state`;根据 §6.5 进行编码 |
没有 `delivery:pending:{stream}` 键 — “pending”指的是 Redis Streams
待处理条目列表 (PEL),Redis 内部维护。
上面的流名称很具体:“stalwart:jmap”和“stalwart:telegram”;
其余键中的“{stream}”是这两个之一。
该错误记录在“docs/retired.md”中。
每个事件至多交付一次:“delivery:inflight”密钥在 60 秒的窗口内领取
出站发送前,`delivery:committed`将保证延长至发送后 7 天
确认。
DLQ 是 **append-and-ack**:附加到 `stalwart:jmap:dlq` 的相同 Lua 脚本 /
`stalwart:telegram:dlq` 也是 `XACK` 是源流中的消息,因此递增,DLQ
追加和源确认是原子的,全部都在一个 Lua 脚本内。没有代码路径读取 DLQ
返回 — 重播是操作员操作,而不是服务功能。
### 1.4 幂等与速率限制
| 键 | TTL | 写入方 | 读取方 |
|---|---|---|---|
| `dedup:tg:{update_id}` |前 86_400 |使用 `telegram_webhook` 中的 `claim_dedup` 构建并声明,在失败的入队时使用 `release_dedup` 释放 | — |
| `dedup:jmap:{account}:{email}` | EX 86_400 |使用“jmap_push”中的“claim_dedup”构建并声明;在协调路径上重建相同的密钥并使用“claim_dedup_and_enqueue”(“worker.rs”)|声明— |
| `ratelimit:push-verify:{subscription}` | EX 30 | 由 `register_push` 中的 `claim_dedup` 构建并声明;`false` 的声明结果就是 `429 push_verify_rate_limited` 分支 | — |
| `state:jmap:since` | **无 EX** | `set_reconcile_state`(没有 `EX` 的 `SET`)| `get_reconcile_state` |
`state:jmap:since` 是唯一一个在没有显式声明的情况下无限期存在的状态键
删除,按设计:它是协调游标,过期将强制完全删除
下次重新启动时重新设定基线。
### 1.5 锁
| 键 | TTL | 写入方 | 读取方 |
|---|---|---|---|
| `lock:reconcile` | 60 | `reconcile` 中的 `acquire_lock` |心跳任务中的 `renew_lock`(30 秒) |
| `lock:push-register:{sha256(callback_url)}` | 360 | `register_push` 中的 `acquire_lock` | `register_push` 每个退出路径上的 `release_lock`
**为什么注册锁是 360 秒而不是 300 秒。** 锁的寿命必须是最长的
配置 JMAP 请求超时(300 秒),否则缓慢的注册可能会导致
重复。来源评论:
> 锁的寿命必须超过配置的 300 秒最大 JMAP 请求超时时间;这可以防止
> 由于接受重复项而创建缓慢。
### 1.6 推送订阅状态
| 键 | TTL | 写入方 | 读取方 |
|---|---|---|---|
| `push:subscription:{id}` | 无 TTL(0 = 永不过期) | `remember_push_subscription`,从 `jmap_push` 验证路径调用 | `push_subscription_verified`、`forget_push_subscription` |
| `push:subscription:id` | 无 EX | `remember_push_subscription_id` | 当前订阅查询 |
| `push:subscription:{id}:status` | 900 / 300 / 86_400(强制最小 1)| `set_push_subscription_status`;验证请求时为 `pending`,成功时为 `verified`,禁用时为 `disabled` | **无** |
| `push:registration:{sha256(callback_url)}` | EX 604_800 | `remember_push_subscription_for_callback`,从 `register_push` 调用 | `get_push_subscription_for_callback`;通过 `remove_push_subscription_for_callback` 删除 |
| `push:registration:current` | EX 604_800 | `set_push_registration_current`,从 `register_push` 调用 | `get_push_registration_current`;通过 `clear_push_registration_current` 清除,`disable_push` 经 `clear_push_registration_current_if` 清除 |
| `push:orphan:{subscription_id}` | EX 604_800 | `record_push_orphan` |孤儿扫荡|
**三个不同的键经常被混淆。在编辑之前请阅读本文。**
- `push:registration:{sha256(callback_url)}` 是从回调 URL 到
订阅 ID 的**映射**,TTL 7 天。它不是一把锁。
- `push:registration:current` 存的是**当前存活订阅的回调 URL 原文**,同样 7 天 TTL。它在新订阅
创建之后最后写入。用一个不同的回调 URL 注册时,后端会在注册锁内先销毁上一个订阅、再创建
新订阅,因此旧 origin 不再收到推送;`disable_push` 只在被注销的正是当前地址时才清它。
其后缀是字面量 `current`,不会与 per-URL key 的 `session_digest` 十六进制后缀相撞。
它既不是映射也不是锁。
- `lock:push-register:{sha256(callback_url)}` 是**单飞注册锁**,
TTL 360 秒。它不是映射。
前两者 TTL 相同但是用途无关的独立键。锁与 per-URL 映射使用相同的摘要,但完全是不同的键。
**注释:`push:subscription:{id}:status` 设计为只写。**
来源对“state.rs”有明确的注释:
> `push:subscription:{id}:status` 目前是只写的
它是一个用于“redis-cli”检查的操作/可观察性跟踪;它**不是**门
也没有任何代码为鉴权目的读取它。历史上写入过的三个取值是 `pending`(900 s)、
`verified`(300 s)和 `disabled`(86400 s)——各自的调用点见上表。
不要根据它的存在来推断其正确性。
---
## 2. 错误响应封装
每个错误响应都使用一种包络形状:
```json
{ "error": "<machine_code>", "request_id": "<id>" }
```
`error` 是一个**普通的机器可读字符串**,而不是嵌套对象 - 没有 `message`
场。 `request_id` 始终存在。
|状态 |代码|重试后 |
|---|---|---|
| 400 | `invalid_request`、`missing` | — |
| 401 | 401 `未经授权` | — |
| 403 | 403 `forbidden`、`chat_not_allowed`(仅限 `/debug/*`)| — |
| 404 | 404 `push_subscription_not_found` | — |
| 409 | 409 `冲突` | — |
| 422 | 422 `无效配置` | — |
| 429 | 429 `push_verify_rate_limited` | `30` |
| 500 | 500 `内部错误` | — |
| 502 | 502 `telegram_send_failed`(仅限`/debug/*`)| — |
| 503 | 503 `service_unavailable`、`disabled`、`reconcile_retry`、`push_verify_failed`、`push_state_unavailable`、`push_destroy_failed` |可重试时为“30”,或始终用于推送注册/禁用失败 |
`Retry-After` 有两个发射点,都在 `notify.rs` 中,并且值始终是文字
字符串“30”:
- `error_response` 仅当其 `retry` 标志为 true 时才设置它**。准备失败
路径传递 `retry = true`,因此 `/ready` 被该规则覆盖,而不是被
特殊情况。
- `error_response_with_id` 设置它**无条件**。仅用于推送
注册/禁用失败“push_state_unavailable”和“push_destroy_failed”,它们是
总是“503”。
`GET /api/status` 是使用非信封正文报告设置状态的例外情况。
---
## 3. 后端路由
所有 HTTP API 路由均在“router_with_worker_state_runtime_bootstrap”中注册。
最后一个表行中的三个静态 SPA 路由是例外:它们位于
`web::router()` (`src/web.rs`) 并通过 `.merge(web::router())` 合并到路由器中 —
在设置模式路由器中,然后在生产路由器中再次。设置模式路由器安装
这个表只有三个——“/healthz”、“/ready”和“/api/status”——没有业务或
管理路线; §5.1 解释了为什么这是故意的。
|方法|路径|处理器|
|---|---|---|
|POST|`/webhook/tg`|`telegram_webhook`|
|POST|`/push/jmap`|`jmap_push`|
|POST|`/api/push/register`|`register_push`|
|POST|`/api/telegram/register-webhook`|`register_telegram_webhook`|
|POST|`/api/push/disable`|`disable_push`|
|POST|`/reconcile`|`reconcile`|
|POST|`/worker`|`worker`|
|GET, PUT|`/api/config`|`get_config` / `put_config`|
|GET, PUT|`/api/enabled`|`get_enabled` / `put_enabled`|
|GET, PUT|`/api/business-config`|`get_business_config` / `put_business_config`|
|POST|`/api/business-config/preflight`|`preflight_business_config`|
|POST|`/api/bootstrap`|`bootstrap`|
|POST|`/api/admin/session/revoke`|`revoke_admin_session`|
|POST|`/api/admin/session`|`create_admin_session`|
|GET|`/healthz`|`healthz`|
|GET|`/ready`|`ready`|
|GET|`/api/status`|`setup_status`|
|GET|`/`、`/assets/config.js`、`/assets/styles.css`|`web::router()`|
远程调试表面(`debug_router`、`src/debug.rs`)仅在
请求调试界面 - 命令行上的“--debug”或真实的“DEBUG_ENABLED”
env var — 并且“DEBUG_TOKEN”非空**(“SAF-DEBUG-GATE”;门被读入
在构建任一路由器之前的`src/main.rs`)。否则这些路线都不存在并且
请求落入 axum 的通用“404”,而不是 401。所有七个请求都需要
`Authorization: Bearer <DEBUG_TOKEN>`,由 `debug_authorized` 检查,委托给
生产“worker_authorized”,因此比较是恒定时间。
|方法|路径|处理程序 |
|---|---|---|
|获取 | `/调试/ping` | `debug_ping` |
|获取 | `/调试/配置` | `调试配置` |
|获取 | `/调试/redis` | `debug_redis` |
|获取 | `/调试/jmap` | `debug_jmap` |
|获取 | `/调试/电报` | `调试电报` |
|获取 | `/调试/worker` | `debug_worker` |
|发布 | `/调试/通知` | `调试_通知` |
响应约定:
- `GET /healthz` 无条件返回 **200**。它是一个活性探针,不能是
用于决定是否路由流量。
- 当配置、Redis 或上游探测器未准备好时,`GET /ready` 返回 **503**,
并携带“Retry-After: 30”表示未准备好响应。它检查四件事:配置
完整性(`setup_missing` 为空)、Redis 可访问性和两个上游探测器 —
使用配置的基本凭据`GET {jmap_origin}/.well-known/jmap`,以及
`获取 https://api.telegram.org/bot<token>/getMe`。两个探针共享“PROBE_TIMEOUT”= 3000ms
并**并行**(`tokio::join!`),所以最坏的情况
是单次超时,大约3s。成功时,正文是一份报告,其“jmap”/“telegram”字段
是真实的探测结果。探针是简单的可重用函数(`probe_jmap_session`,
`probe_telegram_get_me`) 也由远程调试路径使用,因此它们不能重复。
JMAP 探针有意针对*规范化来源*进行身份验证:探测原始数据
未经身份验证的会话 URL 将永远报告未就绪并使入口停止路由。
- `GET /api/status`(**始终 200**)返回
`{“ready”:<bool>,“mode”:“configured”|“configuration-setup”,“missing”:[...],“version”:“<build-fingerprint>”}`。
“ready”为“false”,“missing”列出了缺少的必需密钥(“REDIS_URL”、
`CONFIG_ENCRYPTION_KEY`)当变量不存在时 - 路由本身永远不会出错,所以它是
可以安全进行民意调查。 `version` 是由 `build.rs` 烘焙的 `BUILD_VERSION` 字符串
(`<git-sha-or-nogit>+<UTC 构建时间>`)。SPA 已不再渲染它,确认部署落地只能查这个接口。
- `/debug/*` 在一个地方返回 **503**:业务执行时`POST /debug/notify`
配置未加载或没有出站客户端 — `service_unavailable`
`重试时间:30`。三个探测端点反而报告失败
**在 200 体内** (`{"ok":false,"detail":...}`),因此永远不会看到无法到达的上游
就像停电一样。当聊天时,“POST /debug/notify”也会返回“403 chat_not_allowed”
在*非空*允许列表之外,并且“502 telegram_send_failed”
发送失败。
- 静态资产由严格的CSP提供服务;参见§7。
---
## 4. 负载均衡器与后端路由对照表
> **独立验证。** 来源:`cloudflare-worker/src/backends.js` `SAFE_ROUTES`,
> 共 19 条,并与 `ROUTE_METHODS`(`index.js`)一起定义每条路径允许的方法。Worker 入口文件是 `src/index.js`
>(`wrangler.toml`);`src/lb.js` 实现请求转发和有界故障转移(`SAF-LB-PASSTHRU`,
> `C-NO-LONG-CONN`)。
该门是**无条件且失败时关闭的**。Worker 不读取任何配置开关:
`SAFE_ROUTES` 中缺少的路径返回 **404**,已注册路径上的错误方法返回 **405**,
缺失或无法解析的后端池返回 **503** 而不是转发请求。
**由 Worker 转发(19):**
`/` · `/assets/config.js` · `/assets/styles.css` · `/api/status` · `/api/config` ·
`/api/business-config` · `/api/business-config/preflight` · `/api/admin/session` ·
`/api/admin/session/revoke` ·
`/api/enabled` ·
`/webhook/tg` · `/push/jmap` · `/api/push/register` · `/api/telegram/register-webhook` · `/api/push/disable` · `/reconcile` ·
`/worker` · `/ready` · `/healthz`
**已在后台注册但未转发(2):**
|路径|为什么负载均衡器上没有它 |
|---|---|
| `POST /api/bootstrap` | 一次性信任引导;不在路由集内,只能在后端自己的地址上访问 |
| `/debug/*`(7 条路由)| 选择加入的远程调试界面(`SAF-DEBUG-GATE`);不在 `SAFE_ROUTES` 中,因此**只能**在后端源自己的地址上访问 |
`GET, PUT /api/enabled`(`SAF-ENABLE-FLAG` 终止开关)**被转发**:
管理 SPA 从 Worker URL 提供,Service 卡片读取它(`loadEnabled`)、切换开关写入它。
两个端点本来就都要求凭据,但凭据并不相同:`/api/enabled` 由 `config_authorized` 校验,
它接受共享 worker token **或**有效的 admin session;而 `POST /api/admin/session` 由
`worker_authorized` 对 admin 凭据(`CONFIG_ENCRYPTION_KEY`)校验,它本身就是发放 session
的入口。因此新增 `/api/enabled` 并没有打开新的凭据路径——`/api/admin/session` 本来就在
转发列表里。
后果:没有任何剩余路由承载外部业务流量,所以后端实例前面不需要第二个入口。
SPA 的首次启动流程仍然无法通过 Worker 的 `/api/bootstrap` 完成——引导要么必须
直接针对后端 origin 执行,要么必须把 bootstrap 路径加入负载均衡器白名单。
`POST /api/push/register`、`POST /api/telegram/register-webhook` 和 `POST /api/push/disable` **被转发**。这些路由可以安全代理:
回调 URL 由客户端在请求正文中提供,在写入任何内容之前就被校验为 URL,并且每个推送订阅记录
都写入共享 Redis 也从共享 Redis 读取(`lock:push-register:{sha256(url)}`、`get_push_subscription_for_callback`),
因此请求由哪个后端服务实例处理并不重要——推送注册不再需要访问某个特定的后端地址。
操作员保存业务配置后,SPA 会通过受保护接口提交两个回调 URL。
---
## 5. 环境变量
三个不同的层。它们不可互换。
### 5.1 启动时配置(2 项必填,2 项可选且有默认值)
|变量|必填 |默认 |来源 |
|---|---|---|---|
| `REDIS_URL` |是的 | — |在启动时读取`src/main.rs` |
| `CONFIG_ENCRYPTION_KEY` |是的 | — | `encryption_key_from_env` |
| `端口` |没有| `8080` | `unwrap_or(8080_u16)`。监听者直接绑定它; `Config` 不携带端口字段 |
| `DEBUG_ENABLED` |没有| — |真值为 `1`/`true`/`TRUE`/`True`/`yes`/`YES` (完全匹配);单独启用调试界面,与 `--debug` 配对作为替代方案 (`SAF-DEBUG-GATE`) |
| `DEBUG_TOKEN` |没有| — |仅在通过 `DEBUG_ENABLED` 或 `--debug`(`SAF-DEBUG-GATE`/`SAF-DEBUG-ORIGIN-ONLY`)请求调试表面后才有效
**缺少必需的变量不会使进程崩溃。**它会记录警告并提供服务
`router_configuration_setup`,仅挂载`/api/status`,
静态 SPA 之上的“/ready”和“/healthz”。 §3 中的业务和管理路线不是
完全注册:“admin_token”为空,因此“constant_time_eq”会拒绝
每个候选人永远,并且“MemoryState”无法持久保存引导程序
无论如何都要写。在此模式下发布到“/api/bootstrap”会产生“404”(路由不存在),而不是“401”
其内容为“使用更好的凭据重试”。容器保持运行并回答状态
在不做任何业务工作的情况下浮出水面。这是一种故意的失败关闭设置姿势,并且它
是“容器正常但没有任何反应”的最可能的原因。
### 5.2 存储在 Redis 中的业务配置
SPA 通过 `GET /api/business-config` 读取配置,并通过 `PUT /api/business-config` 写入配置;
成功写入后,配置会在 1 秒内生效。首次写入会创建配置,后续写入会热加载配置。
`GET` 返回 `configured`、`revision`、`values` 和 `secrets_present` 四个字段:`values` 包含 10 个非敏感字段,
`secrets_present` 则分别标记 `bot_token`、`jmap_password`、`telegram_webhook_secret`、
`reconcile_token`、`worker_token` 和 `llm_api_key` 是否已配置;响应绝不包含这些密钥的值(`SAF-NO-SECRET-ECHO`)。
尚未保存配置时,接口返回 `200`、`configured: false`、`revision: 0`、空的 `values`,且所有存在标记均为 `false`,
因此 SPA 无需为首次配置另设代码路径。
`PUT` 请求体采用**部分更新**:只替换请求中列出的字段,其余字段沿用已存储值(见 `config.rs` 的 `apply`)。
未提交的密钥会保留原值,因此重新提交预填表单是安全的。显式提交空字符串会清空对应密钥。
“空白表示不变”是客户端约定;SPA 会在发送前移除空白密钥字段,服务端不会替客户端过滤。
增量语义仅在配置存在后才有效。没有任何存储,就没有
要回退到的值,因此不完整的补丁会被拒绝,并显示 **422 `invalid_configuration`**
— 第一次保存必须完成。针对后端原点的“POST /api/bootstrap”是
一次性写入路径,从头开始创建配置以实现自动化。过程从来没有
在正常操作中从环境中读取这些。
**坚持并报告,而不是坚持如果可连接。**验证是写入的唯一门,
在“PUT”上,它针对 **合并** 配置运行,而不是提交的正文:补丁
永远不会单独验证,并且完整存储的连线永远不会被丢弃(“apply”在
`config.rs`)。 “validate_business_wire”首先运行,拒绝是真正的“422”。曾经
合并的线路是有效的,它*始终*持续存在 - 合并的线路,而不是请求正文 - 并且
然后才构建客户端并运行
工人交换了。因此,失败的构建会返回 **200** 并显示“persisted: true”,
`runtime_applied: false` 和一个包含 `{component,step,detail}` 对象的 `warnings` 数组,其中
`component` 是 `jmap` 或 `llm`,`step` 是 `connect`、`account`、`config` 或 `build`,而不是
比 503 更安全。保存配置并报告中断而不是隐藏。
`runtime_applied` 是
仅当提交重新加载时才为“true”。这是故意的:无法访问的 JMAP 一定不能
将有效配置转变为静默数据丢失。 `PUT /api/business-config` 和
`POST /api/bootstrap` 都遵循这个契约;在这两种情况下都会返回修订版
`x-business-config-revision` 标头,只有 `PUT /api/business-config` 也会在
响应正文,作为“修订版”(“u64”)。 SPA 将其显示在其状态行中并发送
作为“revision”控制字段返回到请求正文中,后端将其与
存储修订版本并在不匹配时回答“409 冲突”。引导程序的
主体不携带“修订”字段,并且省略它的“PUT”主体保持最后写入获胜。
`POST /api/business-config/preflight` (`preflight_business_config`) 运行相同的
验证和客户端根据提交的线路构建并返回每个组件的判决
不写入任何数据,也不调用正在运行的服务处理流程:
```json
{
"persisted": false,
"validation": { "ok": true, "errors": [] },
"components": {
"jmap": { "ok": false, "errors": [{ "component": "jmap", "step": "connect", "detail": "..." }] },
"llm": null
}
}
```
当验证已经失败时,“components”为“null”(没有必要从
被拒绝的线),当“llm_enabled”/“llm_allow_net”为 false 时,“llm”为“null”
而不是空洞地“好吧”。答案是“配置不好”的预检是
**200**,因为这是呼叫者要求的答案。认证是一样的
`config_authorized` 门作为写入路径;它故意**不**位于`DEBUG_TOKEN`后面。
### 5.3 不保留旧版环境变量配置路径
`Config::from_env()` 及其助手(`required_secret`、`required_nonblank`、`env_bool`)是
**已删除** — `config.rs` 根本不执行 `std::env` 读取。下面的名字是遗产
env 表面并仅列出,以便过时的部署脚本可以被识别为过时的
(在`docs/retired.md`中注册):
`RUN_MODE` · `CHAT_ALLOWLIST` · `TELEGRAM_CHAT_ID` · `LLM_ENABLED` · `LLM_ALLOW_NET` ·
`LLM_API_KEY` · `LLM_BASE_URL` · `LLM_MODEL` · `LLM_SUMMARY_TARGET_CHARS` · `BOT_TOKEN` ·
`JMAP_SESSION_URL` · `JMAP_USERNAME` · `JMAP_PASSWORD` · `ACCOUNT_ID` · `RECONCILE_TOKEN` ·
`TG_WEBHOOK_SECRET` · `WORKER_TOKEN`
`JMAP_*` 系列恰好是三个名称。在生产中,帐户 ID 作为
`jmap_push` 上的 `accountId` 请求正文字段,并在客户端上保存为
`account_id`;唯一剩下的环境读取这些
四个名字在`#[ignore]`真实服务器冒烟测试中
`src/domain/jmap/client.rs`,它不是生产配置界面的一部分。
单个前缀名称是“TELEGRAM_CHAT_ID”。任何其他记录为
`TELEGRAM_BOT_TOKEN` 或类似的内容是文档错误,不是受支持的变量。
**信任根。** SPA 管理员凭据本身是“CONFIG_ENCRYPTION_KEY”:在启动时读取
并保存为“admin_token”,通过以下方式在恒定时间内进行检查
两者顶部的“worker_authorized”
`POST /api/bootstrap` 和 `POST /api/admin/session`。它是
仅与请求承载进行比较——从不回显、记录或存储。会议
由 `/api/admin/session` 发出是一个新生成的随机 32 字节十六进制令牌
(每次调用生成),绝不是凭证;仅其摘要保留在 Redis 中
`config:admin_session`。 `REDIS_URL` 是单独的 Redis 连接字符串:ACL 密码
它(如果有)对 Redis 连接进行身份验证,并且不是任何 HTTP 的凭据
端点。
### 5.4 通知内容渲染
`send_notification` (`src/channel.rs`) 是唯一将通知渲染到
Telegram 文本,它只渲染三个元数据行——不是正文,也不是
大语言模型摘要:
```text
From: Zhang San <zhang@example.com>
Subject: Q4 budget
Received: 2026-09-28 09:15
```
`From:` 是标准显示形式的 JMAP `EmailAddress`:`Name <address>`
发件人带有显示名称,否则为裸地址。 “发件人:”和“主题:”
仅当 JMAP 未返回时才回退到文字(“未知”/“(无主题)”)
财产。
“已收到:”在通知时呈现,而不是存储为文本。服务器保留
JMAP `receivedAt` 作为 Unix 秒并将其转换为配置中的挂钟时间
使用 `%Y-%m-%d %H:%M` 的时区 — 24 小时制,分钟精度,无 AM/PM,无秒。
当 `receivedAt` 不存在,或超出区域的可表示即时范围时,
显示文字“未知”而不是剪辑的时间戳。
时区是一个业务配置字段,`timezone` (`REQ-TIMEZONE-DISPLAY`):
IANA 标识符,默认“亚洲/上海”。只接受 16 个区域,每一个区域
没有夏令时转换 - `Etc/UTC`、`非洲/开罗`、`欧洲/伊斯坦布尔`、
`非洲/内罗毕`、`亚洲/迪拜`、`亚洲/卡拉奇`、`亚洲/加尔各答`、`亚洲/曼谷`、
`亚洲/胡志明市`、`亚洲/上海`、`亚洲/香港`、`亚洲/台北`、`亚洲/新加坡`、
“亚洲/马尼拉”、“亚洲/东京”、“亚洲/首尔”。任何其他标识符都会被拒绝,并显示 **422**
而不是默默地回到猜测。这是一个故意的限制:离线
build 没有 IANA tz 数据库(“chrono-tz”不可用),因此每个区域解析为
固定偏移并且从不跟踪转换。十六个人中没有一个需要一个,这就是为什么
‘亚洲/上海’可以是默认值。与其他业务配置一样,它是经过编辑的
在 SPA 中并热应用,无需重新启动。
---
## 6. 出站与运行时参数
### 6.1 出站请求
|参数|默认 |范围/上限|来源 |
|---|---|---|---|
| `jmap_timeout_ms` | 15_000 | 15_000 100..=300_000 | 100..=300_000默认在`state.rs`中;在写入路径中验证|
| `telegram_timeout_ms` | 10_000 | 10_000 100..=300_000 | 100..=300_000默认在`state.rs`中;在写入路径中验证|
| `llm_timeout_ms` | 30_000 | 100..=300_000 | 100..=300_000默认在`state.rs`中;在写入路径中验证|
| `最大重试次数` | 3 |硬顶 5 |默认在`state.rs`中;在写入路径中被拒绝;重新固定在`channel.rs` |
**没有** `LLM_MAX_RETRIES` 环境变量;重试计数位于
`config:outbound` 并且无论写入什么内容都以 5 为界。
### 6.2 状态协调预算
|恒定|价值|来源 |
|---|---|---|
| `BASELINE_PAGE_SIZE` | 100 | 100 `worker.rs` |
| `BASELINE_MAX_PAGES` | 100 | 100 `worker.rs` |
| `BASELINE_MAX_EMAILS` | 10_000 | 10_000 `worker.rs` |
| `RECONCILE_MAX_PAGES` | 100 | 100 `worker.rs` |
| `协调预算` | 20 秒 | `worker.rs` |
| `CHANGE_WINDOW_CAP` | 4_096 | 4_096 `worker.rs` |
| `协调初始更改` | 100 | 100 `notify.rs` |
|锁定TTL/心跳| 60 秒 / 30 秒 | `reconcile` 及其心跳任务 |
`initial_changes` 是一个函数参数,而不是一个常量:它只调整
第一个“/changes”调用。重播阶段之后可能会扩大窗口,最多
`CHANGE_WINDOW_CAP`,在光标前进之前。
### 6.3 空闲阈值
XAUTOCLAIM 的空闲阈值源自实时出站配置:
```
count × (max_retries + 1) × (jmap_timeout_ms + telegram_timeout_ms + llm_timeout_ms) × 2
```
低于传统 300 秒单事件上限,上限为 6 小时。地板也是
当无法读取配置时回退,因此
Redis 打嗝永远不会折叠窗口。仅在多实例上重复
赌注,永不损失。
### 6.4 搜索限制
|恒定|价值|来源 |
|---|---|---|
| `SEARCH_LIMIT` | 10 | 10 `worker.rs` |
| `SEARCH_SUBJECT_MAX` | 120 个字符 | `worker.rs` |
| `SEARCH_PREVIEW_MAX` | 160 个字符 | `worker.rs` |
### 6.5 状态协调游标
`/reconcile` 保留一个游标,`state:jmap:since`,并且它是双峰的:
- `baseline:{hex-encoded-state}:{position}` — 位置行走正在飞行中。的
walk 按位置 (`list_emails_page`) 和 `/changes` 枚举集合
尚未重播。
- 裸状态字符串 — 步行已完成;这是恢复的令牌
“电子邮件/更改”来自。
这两种模式是“worker.rs”中显式的“ReconcileCursor”变体,而不是
保留位置值。页上限(`BASELINE_MAX_PAGES`)是一个硬停止
步行:如果在那里剪切步行,则光标将保持**作为步行**,从不
作为重播,因为重播模式断言列表已用完并正在重播
来自半列出状态的“/changes”将跳过仍然存在的内容。
有两个令牌类别正在发挥作用,并且它们不可互换:
- `Session.state`(RFC 8620 §2.1)是每个会话创建的,并且**不是**
“电子邮件”-收集令牌。新的或重新设定基线的游标带有它,因为
jmap-client `Session` 类型不公开每个集合的状态。
- “Changes”响应中的“newState”**是**一个集合令牌。
因此,严格遵守的服务器可能会拒绝第一个“/changes”调用
重新设定基线后出现 `sinceState` 错误。 `replay_changes` 通过以下方式吸收它
重新基线到一个新的令牌并返回,因此不匹配只能咬住
一次调用和下一次传递重新开始步行; 24小时重复数据删除密钥
(`ttl::DEDUP_JMAP_SECONDS`) 将生成的重播限制为最多一个重复项。
`Email/changes` 还返回 `destroyed` 和 `oldState`。两者均被丢弃
`EmailChanges`:已删除的电子邮件没有任何内容可供查找,因此它不会驱动
通知并且不移动光标,“oldState”回显
服务器接受的“sinceState”,调用者已经持有该状态。保留任一
意味着存储一个无人读取的值。
---
## 7. 静态资源与响应头
从“src/web.rs”提供服务。
- 仅三个路由:`GET /`、`GET /assets/config.js`、`GET /assets/styles.css`。
其他一切都返回 404。
- 资产在编译时通过“include_str!”嵌入。没有
运行时的文件系统访问。
- 每个响应都带有 `Content-Type`、`Cache-Control: no-store`、
`X-Content-Type-Options: nosniff` 和 `Referrer-Policy: no-referrer`。
- CSP **仅应用于 HTML 文档**:插入到内部
一个 `if html` 块:
```
default-src 'none'; script-src 'self'; style-src 'self'; connect-src 'self';
form-action 'self'; base-uri 'none'; frame-ancestors 'none'
```
- `X-Frame-Options: DENY` 与 CSP 一起设置,仅在 HTML 响应上。
`default-src 'none'` 故意比 `'self'` 更严格:页面默认关闭
并且只有`script-src`、`style-src`和`connect-src`重新打开同源通道。
---
## 8. 尚未实现的功能
在此记录,以便其他地方的引用不会被误认为是已发布的功能。
-“/search”**已**实现(“bfe0fd8”)。适配器:“worker.rs”中的“Intent::Search”和
在`src/domain/jmap.rs`中,由`src/domain/jmap/client.rs`中的`search_emails`支持。
当前无法实现:**邮件正文片段**。jmap-client `0.4.2` 仅公开
来自“SearchSnippet/get”的“emailId”/“subject”/“preview”,其“Filter”类型没有比较器语法,因此
无法通过锁定版本的 crate 实现对邮件各正文部分的高亮。搜索会降级为
当不支持片段时,“主题”/“预览”突出显示以及纯 ID 列表。设计
请注意“docs/design.md”第 5.7 节。
- Cloudflare Worker 不包含 Rust:“cloudflare-worker/src/”仅包含“index.js”,
`lb.js`、`backends.js` 和 `health.js`。在“cloudflare-worker/”下写入的任何“*.rs”路径
是文档错误,而不是源文件。
---
## 参考资料
外部依赖项,全部可达并在验证时返回 HTTP 200:
- axum — https://docs.rs/axum
- redis(Rust 箱,0.27)- https://docs.rs/redis/0.27/redis/
- reqwest(Rust 板条箱,0.13)- https://docs.rs/reqwest/0.13/reqwest/
- jmap-client (0.4.2) — https://docs.rs/jmap-client/0.4.2
- JMAP 规范 — https://jmap.io/spec/
- JMAP RFC 8620 — https://datatracker.ietf.org/doc/rfc8620/
- Redis 流 — https://redis.io/docs/latest/develop/data-types/streams/
- Redis 持久化 — https://redis.io/docs/latest/operate/oss_and_stack/management/persistence/
- Cloudflare Workers — https://developers.cloudflare.com/workers/
- Cloudflare Workers 运行时 API — https://developers.cloudflare.com/workers/runtime-apis/
- RFC 8030(网络推送)— https://www.rfc-editor.org/rfc/rfc8030
- RFC 7231 §7.2.3 — https://www.rfc-editor.org/rfc/rfc7231#section-7.2.3
- Dockerfile 参考 — https://docs.docker.com/reference/dockerfile/
- rust Docker 镜像 — https://hub.docker.com/_/rust
- Uptime Kuma — https://uptime.kuma.pet/
- OWASP 日志记录备忘单 — https://cheatsheetseries.owasp.org/cheatsheets/Logging_Cheat_Sheet.html
未链接,因为它们未解析为文档:`crates.io/crates/jmap-client`
(404)和 `platform.openai.com` 文档路径(403)。项目使用的大语言模型依赖与 OpenAI API 兼容,
并通过 `reqwest` 调用。
## 9. 部署平台细节
面向操作员的序列位于 [README.zh-CN.md](../README.zh-CN.md) 中。本节保留
配置或诊断时有用的特定于平台的行为和设置
部署。
### 9.1 后端部署方式
**HostStack 生产部署。** 仓库中的 `hoststack.yaml` 配置 HostStack 原生 Rust 运行时。
它运行 `cargo fetch --locked` 和 `cargo build --release --locked`,启动
`./target/release/message-weave`,并以 `/healthz` 检查存活状态。通过平台加密配置
`REDIS_URL` 和 `CONFIG_ENCRYPTION_KEY`。YAML 中的服务命令会覆盖控制面板中保存的命令;
移除该文件会恢复 HostStack 默认的 `./target/release/app`,而这个仓库并不生成该二进制文件。
在控制面板命令全部修正且替代部署通过验证前,应继续将此 YAML 文件纳入版本控制。
HostStack 部署不使用仓库的 Dockerfile;镜像构建和运行时由平台管理。Dockerfile 适用于本地容器运行和其他基于 Docker 的主机。
**Docker 镜像路径。** 根 Dockerfile 有两个阶段:
1. `rust:1-slim-bookworm` 使用锁定的 Cargo 依赖项构建发布二进制文件。
2. `debian:bookworm-slim` 接收二进制文件、CA 证书、`tini` 和只读
诊断工具(“curl”、“procps”、“iproute2”、“jq”和“netcat-openbsd”)。它创造了
并以“messageweave”系统用户身份运行,设置“PORT=8080”,仅公开该端口,并且
通过`tini`启动服务。
该映像没有 Redis 进程、数据库或持久数据卷。运行时配置是
由主机注入;敏感凭据不会进入构建参数或镜像层。 Docker 平台
运行状况检查应调用下文 §9.4 中描述的 HTTP 端点。
### 9.2 后端配置与密钥
该进程在启动时仅读取“REDIS_URL”和“CONFIG_ENCRYPTION_KEY”。可选的
远程调试界面使用独立的“DEBUG_ENABLED”开关和“DEBUG_TOKEN”;离开
除非有意启用远程诊断,否则两者在生产中均未设置。完全启动
变量语义和 Redis 业务配置字段位于 [§5](#5-环境变量) 中。
Telegram、JMAP、allowlist、worker、reconcile 和 LLM 业务设置驻留在 Redis 中,
不是进程环境变量。 [§5.2](#52-存储在-redis-中的业务配置)中的字段列表和验证规则
是权威的。成功的初始保存可以使用一次性 `/api/bootstrap` 端点,在后端
自己的地址上完成;该路由不在负载均衡器路由集内,所以发给 Worker 的请求到不了它。
后续编辑使用受保护的配置 API。
### 9.3 回调注册与定时任务
调用 Telegram 的 `setWebhook` 时,使用已配置的 Webhook 密钥,并在 `allowed_updates` 中包含 `message`。
使用 `getWebhookInfo` 检查已注册的 URL;Telegram 不会在响应中回显 Webhook 密钥。更换密钥后,需重新调用 `setWebhook`。
通过 `POST /api/push/register` 和 HTTPS `callback_url` 注册 Stalwart Push。后端会创建订阅并完成 Stalwart 的验证回调;
重复注册相同 URL 是幂等的。用一个不同的回调 URL 注册时,后端会先销毁上一个订阅,再创建新订阅并重指向当前指针映射,
因此旧 origin 不再收到推送。调用 `POST /api/push/disable` 并传入该 URL 可删除订阅。
后端没有内置调度器。外部调度器运行 [`scripts/cron-drain.sh`](../scripts/cron-drain.sh):设置了 `MW_RECONCILE_TOKEN` 时,
脚本先调用 `/reconcile`,再调用 `/worker`。在调度器的密钥管理器中配置 `MW_APP_URL`、`MW_WORKER_TOKEN`,
以及可选的 `MW_RECONCILE_TOKEN`。需要绕过 Worker、直连后端源站时,可用 `MW_WORKER_URL` 覆盖默认地址。
`--once` 用于 cron 单次运行,`--loop` 用于常驻进程;脚本还提供 `--diagnose` 和 `--test-notify` 排查问题。
两个端点成功时均返回 `204` 和空响应正文。若另一轮对账仍持有锁,`/reconcile` 会返回 `409`。
### 9.4 健康检查与发布核验
- `GET /healthz` 是后端活跃度。它并不能证明Redis或上游服务已经准备好。
- `GET /ready` 检查配置、Redis、JMAP 会话和 Telegram 的 `getMe` 端点。
当依赖项不可用时,它会返回“503”和“Retry-After”。上游检查
需要从后端进行出站 HTTPS 访问。
- `GET /api/status` 报告启动准备情况和缺少所需的启动变量名称。
- `GET /healthz-worker` 由 Worker 生成。检查“available >= 1”和“version”;
当原始配置丢失时,HTTP 200 本身就意味着“无后端”。
对于后端代码门,使用中锁定的命令和容器环境
[`charter.md`](charter.zh-CN.md)。文档门必须在主机上运行,因此它
路径审计可以读取Git历史记录。
### 9.5 Cloudflare Worker 与控制面板
Worker 是可选的 HTTPS 负载均衡器。后端来源必须是 HTTPS 字符串,不带任何内容
路径、查询、片段或嵌入凭据。目前公开的来源清单和
“LB_VERSION”在“[vars]”下的“cloudflare-worker/wrangler.toml”中声明。移动
只有来源地址需要保密时,才将 `BACKEND_ORIGINS_JSON` 作为加密变量存储;从不存储
该列表中的凭据。 Worker本身不持有Telegram、JMAP、Redis或后端
商业凭证。
|设置|仪表板价值 |笔记|
|---|---|---|
|根目录 | `cloudflare-worker` |存储库根包含 Rust 服务,而不是 Worker 配置。 |
|应用名称 | `messageweave-lb` |匹配“wrangler.toml”中的“name”。 |
|构建命令 |留空 | Wrangler 在部署期间捆绑 Worker;该字段不是测试命令。 |
|部署命令| `npx wrangler deploy` |将此命令填入必需的部署字段。 |
|预览命令 | `npx wrangler dev --ip 0.0.0.0 --port 8787` |启用预览构建时使用;`wrangler preview` 不是有效命令。 |
在控制面板的 **Settings → Variables & Secrets → Add variable** 中添加 Worker 调整变量。
默认值为 `LB_REQUEST_TIMEOUT_MS=10000`、`LB_MAX_ATTEMPTS=2`、
`LB_RECONCILE_TIMEOUT_MS=320000`、`LB_WORKER_TIMEOUT_MS=300000` 和
`LB_HEALTH_TTL_MS=30000`。这些是可调整的变量,不是敏感凭据。 `LB_VERSION` 属于
在“[vars]”中,以便部署的版本在源代码管理中保持可见。
如果来源列表包含不应公开的地址,请在 **Settings → Variables & Secrets** 下将
`BACKEND_ORIGINS_JSON` 添加为加密变量,并删除其 `[vars]` 条目。
对加密变量的更改无需重建代码即可生效。仅预览环境
只接收为该环境配置的密钥。预览环境可以在没有来源列表时启动,
但无法验证后端转发。
对于 Git 集成,将选定的提交推送到连接到的 GitHub 存储库
设置 `cloudflare-worker` 根目录后的 Cloudflare。构建命令可以是
空;单独运行“npm test”作为 Worker 代码检查。部署后,请求
`/healthz-worker`,确认 `available` 至少为 1,并检查 `version` 是否匹配
`LB_VERSION`。 HTTP 200 的“status: no-backends”表示 Worker 没有可用的源列表;
HTTP 503 的“status: down”意味着所有配置的后端都未通过健康检查。