MessageWeave/docs/deployment.md

84 KiB
Raw Blame History

MessageWeave 部署方案(通用 HTTPS-only Docker)

部署目标:通用 Docker 容器平台(任何能跑 Docker 的 VPS / 云主机 / k8s / 自托管容器平台), 不绑定 Cloud Run / Lambda / CF Workers / Deno Deploy 等任何具体平台(NG-SERVERLESS-BIND)。

保持 Docker health / PORT 等通用 HTTP 约定(C-PORT)。 部署形态 = 短请求模型:Telegram Webhook + Stalwart JMAP Push HTTPS 回调 + 外部 HTTPS Cron 对账。 硬约束见 §0;非目标见 §1; 架构 / 产品行为以 design.md 为准,本文只写部署/运维。


0. 硬约束

下述约束均为不可违反项(对应 docs/charter.md §3 安全边界)。跨文档引用一律用稳定 ID,索引见 docs/charter.md §8「稳定 ID 注册表」。

ID 约束
C-DOCKER 必须是 Docker 容器;禁止非 Docker 直装
C-DEBIAN-SLIM Debian 系 slim,禁止 Alpine/musl
C-NO-SECRET-IN-IMAGE secrets 不进镜像(构建 ARG/ENV 不得含 token/密码)
C-RUSTLS rustls + rustls-native-roots,不依赖系统 OpenSSL
C-HTTPS-INBOUND 只提供 HTTPS 入站:容器内仅监听明文 HTTP,TLS 由平台入口/反向代理统一终止;无代理场景才允许容器内自服务 TLS
C-HTTPS-URL 公网 HTTPS 入口由平台提供:平台/反代给 bot 一个公网 HTTPS URL(如 https://bot.example.com),Telegram Webhook 与 Stalwart Push 回调都指向该 URL 的路径;bot 自身不申请证书、不监听 443
C-NO-TCP-EXPOSE 不依赖 TCP 端口暴露:全容器单监听端口 PORT(默认 8080);对外只走 HTTP 路由(webhook / push / reconcile / health);不暴露任何附加 TCP 端口(如 admin 端口)
C-NO-LONG-CONN 不使用任何长连接:无 JMAP EventSource/SSE、无 WebSocket、无 Telegram 长轮询。实时性仅由 Push HTTPS 回调 + 外部 HTTPS Cron 对账 保证
C-REDIS-ONLY-STATE 无状态形态只依赖外部 Redis:会话、去重、Redis Streams、熔断计数、sinceState 游标 全部走 Redis;不用 SQLite、不用本地卷(NG-SQLITE-PERSIST / NG-LOCAL-VOLUME)
C-REDIS-MANAGED-AOF Redis 由用户托管(自建或托管服务)并开启 AOF 持久化:Bot 不自建、不管理 Redis 进程;AOF 保证 Streams 队列 / 去重表 / sinceState 重启不丢
C-NO-DB 生产不使用任何数据库:无 SQLite/Postgres/MySQL/嵌入式数据库;Redis 是唯一的状态存储(C-REDIS-ONLY-STATE)。应用不得自带、不自建、不连接第二个数据库实例
C-NO-LOCAL-WRITE 禁止本地文件/目录写入:无日志文件、无数据文件、无临时缓存、不挂载本地卷(NG-LOCAL-VOLUME/NG-SQLITE-PERSIST)
C-LOG-STDOUT-ONLY 日志只写 stdout/stderr(容器平台/运行时负责采集落盘);禁用 rolling-file、FileAppender 等文件日志后端;日志中不得出现密钥、邮件正文、AI 请求/响应内容(SAF-LOG-PURITY)
SAF-LOG-PURITY 日志/Redis 写入内容仅限:结构化事件 ID、状态机转移、计数、时间戳、脱敏后的请求摘要(request id、状态码、耗时);禁止 password/token/secret/Authorization 原文、JMAP 正文、LLM prompt/completion、附件内容
C-NO-STATEFUL-RECOVERY 禁止依赖进程内状态做生产恢复:任何"重启续跑"(去重、sinceState、Streams 断点、熔断计数、会话)一律由外部 Redis + JMAP 对账(FLOW-RECONCILE/C-REDIS-ONLY-STATE)实现;进程内缓存仅为性能优化,丢失必须安全可重入

1. 非目标(Non-Goals)

以下均为历史早期参考,非目标、不再支持。不得在实现中复活为运行模式。

ID 非目标 说明
NG-SERVER-MODE 常驻长连接运行模式(历史名 RUN_MODE=server) 早期 EventSource+长轮询 常驻设计,已删除;RUN_MODE 变量本身也已随 Config::from_env() 一并删除,代码中已无该标识符
NG-POLLING-SSE JMAP EventSource/SSE 长连接订阅 与 C-NO-LONG-CONN 冲突;实时通道只用 Push 回调
NG-LONG-POLLING Telegram 长轮询运行模式 与 C-NO-LONG-CONN 冲突;只用 Webhook
NG-SQLITE-PERSIST SQLite 会话 / sinceState 本地持久化 状态只走外部 Redis(C-REDIS-ONLY-STATE)
NG-LOCAL-VOLUME /app/data 等本地卷持久化 无本地持久层(C-REDIS-ONLY-STATE)
NG-SERVERLESS-BIND 绑定 Cloud Run / Lambda / CF Workers / Deno 等具体平台 部署目标是通用 Docker 容器平台;不绑定平台

2. Debian Slim 选型

(保留既有内容:debian:bookworm-slim 的运行基础、非 root 用户、时区、CA 证书等通用约定)

2.1 可选远程联调面 /debug/*(SAF-DEBUG-GATE)

生产入口默认绝对关闭:/debug/* 是一套只读探测 + 单条出站通知的远程联调面,只有部署者主动开启才会出现。

双因子启用条件(SAF-DEBUG-GATE,两者必须同时成立)

  1. 「开启信号」成立——满足其一即可:进程命令行带 --debug(本地 / 开发),或 DEBUG_ENABLED 环境变量为真值(1 / true / TRUE / True / yes / YES,大小写 严格匹配,其余取值一律视为关闭)。判定逻辑在 src/main.rs:15-25 的纯函数 debug_surface_requested,读取点在 src/main.rs:102-105。
  2. DEBUG_TOKEN 环境变量必须存在且非空(读取于 src/main.rs:106-114)。

DEBUG_ENABLED 而不是 DEBUG_TOKEN 单独生效,是刻意的:只看 token 是否配置会退化掉双因子 ——token 是长期存在平台上的 Secret,让它自己开门就等于没有第二道门。--debug 与 DEBUG_ENABLED 二选一即可,因为开关信号本身就是一道门,第二道门始终是 token。

为什么生产用 DEBUG_ENABLED 而不是改启动命令加 --debug:这样启动命令保持静态, 开关就成了控制台上一个环境变量的事,改一次 + 重新部署即可,不用提交、不用改 hoststack.yaml、不用重新构建。判定为纯函数(不直接读全局 env)以便单测覆盖,测试在 src/main.rs:217-264。

缺一即不挂载: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:514),因此令牌是常数时间比较(src/notify.rs:1557)。失败回 401 unauthorized,且不设 Retry-After(src/debug.rs:54-56)。

7 条路由与预期状态码(注册于 src/debug.rs:72-80,路径为字面量,无常量抽取)

路由 成功 失败
GET /debug/ping 200 {"ok":true} 401
GET /debug/config 200 见下段 401
GET /debug/redis 200 {"reachable":true,"global_enabled":…} 401;Redis 探活失败仍为 200 {"reachable":false,"detail":"<classified>"},不返回 503
GET /debug/jmap 200 {"ok":true} 401;探针失败仍为 200 {"ok":false,"detail":…}(与 /ready 共用同一探针)
GET /debug/telegram 200 {"ok":true} 401;同上,探针失败返回 200 + ok:false
GET /debug/worker 200(revision、reconcile_cursor、outbound 预算) 401
POST /debug/notify 200 {"ok":true,"result":{…}} 401;业务配置未加载或无出站客户端 → 503 service_unavailable + Retry-After: 30(src/debug.rs:58-60);chat_id 不在白名单 → 403 chat_not_allowed(src/debug.rs:62-64);Telegram 发送失败 → 502 telegram_send_failed(src/debug.rs:66-68)

/debug/redis 失败时的 detail 取值(src/debug.rs:176-194):authentication_failed / io_error / busy_loading / client_error / response_error(redis 侧,ErrorKind 为 #[non_exhaustive],未识别归 response_error)或 state_lock_poisoned / encryption_failed(本进程侧)。这一分类就是区分「密码写错」与「Redis 宕了」的唯一依据。

/debug/config 的响应字段(src/debug.rs:96-152):恒有 revision、setup_missing、business_configured、allowlist_size;业务配置存在时再加 timezone(IANA 时区名字符串,如 Asia/Tokyo)、jmap.{session_url,username,account_id}、telegram.{chat_id,webhook_secret_configured}、worker.{worker_token_configured,reconcile_token_configured}、llm.{enabled,allow_net,api_key_configured,base_url,model}、outbound.{jmap_timeout_ms,telegram_timeout_ms,llm_timeout_ms,max_retries};配置缺失时只回 business_configured:false + allowlist_size。

方法不匹配先于鉴权判定(例如 GET /debug/notify 返回 405)。

三条约束,部署时务必确认

  • 绝不回显凭据值: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:1563),判定条件是「白名单非空且 chat_id 不在其中」才回 403 chat_not_allowed(src/debug.rs:257-260)。因此已配置白名单时无法绕过业务侧发送限制;若白名单未配置(为空)则此判定不生效,chat_id 可任意指定——所以启用本面时应确认业务白名单已真正配置。text 缺省为固定联调文案,并按 1024 字符截断(src/debug.rs:265-270)。
  • 不在 Worker 白名单内,只能直连 origin:网关的 18 条安全路由(cloudflare-worker/src/backends.js:10-30)不含任何 /debug/*,Worker 对未白名单路径返回 404 route not forwarded: /debug/...(cloudflare-worker/src/index.js:95-96)。因此 /debug/* 只能通过直连后端 origin 访问;若必须经代理,请自行在代理层加鉴权,不要让公网可达。

建议:生产环境不开启。需要远程联调时在控制台设置 DEBUG_ENABLED=1 与一次性 DEBUG_TOKEN,重新部署后联调;结束立即把 DEBUG_ENABLED 置空并轮换 DEBUG_TOKEN。 全程不需要改动启动命令或提交代码。长期暴露面走 §5 的 Secret 管理流程单独审批。


3. Rust 多阶段构建与运行

边界:本章与仓库根目录的 Dockerfile 是「仅本地开发 / 本地容器调试」路径, 生产不执行它。 线上走 §5.1 的 hoststack.yaml + runtime: rust,由 HostStack 自带 agent 在 rust:slim-trixie 构建、拷进 debian:trixie-slim runner 运行,完全不经过 本 Dockerfile(连 Debian 版本都不同)。因此本章的 ENTRYPOINT(tini)、 EXPOSE 8080、内置诊断工具在生产容器中一律不生效。生产启动命令的真源是 hoststack.yaml 的 start.command。本地用 docker build + docker run 验证行为时 才走本章。

3.1 构建阶段

(保留既有 rust:bookworm 构建 + 缓存分层约定;产物为单个静态编译二进制 message-weave)

实现现状:单端口 axum 入口、jmap-client =0.4.2 只读 adapter、Redis Streams、Telegram Webhook、JMAP Push 入队和 /reconcile 已接入;三条入口路由鉴权 fail-closed 落地(R1/SAF-AUTH-*)。本地 Debian 容器门禁已通过;真实 Stalwart、Redis、Telegram 的端到端链路仍需部署后验证。配置引导模式会在启动变量缺失时保持 HTTP/SPA 可访问,不伪造业务成功。

3.2 运行阶段(收敛为单端口)

  • EXPOSE 8080(遵循 C-NO-TCP-EXPOSE,不再暴露 9191 admin 端口)
  • ENV PORT=8080(可选,缺省即 8080;REDIS_URL 与 CONFIG_ENCRYPTION_KEY 必须由运行期注入,不写入镜像)
  • 健康检查走同一 HTTP 监听(见 §7),不依赖独立端口
  • 不声明 VOLUME,无 /app/data 本地卷(NG-LOCAL-VOLUME)

4. 运行时用户 / 证书 / TLS 与公网 HTTPS 入口

  • 非 root 用户运行(保留既有约定)
  • 公网 HTTPS 入口(C-HTTPS-URL 已确认):运维在平台上给 bot 配一个公网 HTTPS URL(如 https://bot.example.com),平台 ingress/反代把 https://…/webhook/tg、/push/jmap、/reconcile 路由到容器 PORT。bot 自身不申请证书、不监听 443;证书由平台/反代管理(C-HTTPS-INBOUND)。
  • 需要在 Stalwart 与 Telegram 两侧使用这个公网 URL:Telegram setWebhook 指向 /webhook/tg;管理员调用 POST /api/push/register 并提交该 URL 的 /push/jmap 路径,后端负责执行 PushSubscription/set create。服务不会自动猜测平台公网域名。
  • 出站 egress 是 /ready 的硬依赖:就绪探针向配置的 JMAP session host 与 api.telegram.org:443 发起只读 GET。该 egress 不通时 /ready 会长期 503,即使配置与 Redis 都正常。若 egress 必须经 HTTP(S) 代理则无法使用:应用内 reqwest 以 default-features = false 编译(Cargo.toml:12),未启用 proxy feature,不解析 HTTPS_PROXY/HTTP_PROXY;这种情况下请用网关聚合的 /healthz-worker(无条件 200)作为存活监控,/ready 仅作人工排查。
  • 多实例 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)

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)

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:1225)。

  • callback_url 必须是 HTTPS 且不含用户名 / 密码,否则 400 invalid_request (src/notify.rs:1328-1330)。
  • 同一个 callback_url 重复调用幂等复用已有订阅,不会重复注册。
  • 注册期间持 lock:push-register:{sha256(callback_url)} 360 s 单飞锁 (src/notify.rs:1328-1330),撞锁返回 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(仓库内有现成脚本 scripts/cron-drain.sh,参数全走 环境变量)。缺这一步,邮件会持续积压在 stalwart:jmap,一行通知都不会发出来。

验证:群内发 /help,/worker 排空后应收到自动回复(入站方向);给自己发一封新邮件, 排空后应收到该邮件通知(出站方向)。


5. Secret 管理与环境变量

5.1 HostStack 原生 Rust 部署

HostStack 使用仓库根目录 hoststack.yaml:runtime: rust 的 agent 在 rust:slim-trixie 里执行 install.command(cargo fetch --locked)与 build.command(cargo build --release --locked),把产物拷进 debian:trixie-slim 的 runner 容器,以 start.command(./target/release/message-weave) 启动,监听单个 HTTP 端口,并以 /healthz 做 interval 30 秒、timeout 5 秒的健康检查。 install: 是合法的 schema 键——install / build / start 是三条独立命令, 依赖获取不写进 build.command。服务命令里不带 --debug,也不需要带——远程联调开关 用 DEBUG_ENABLED 环境变量控制(见 §2.1),改控制台即可,启动命令保持静态。 REDIS_URL 与 CONFIG_ENCRYPTION_KEY 必须配置为 HostStack Secret;不得将密钥值写入 YAML、镜像、日志 或代码仓库。本仓库的 Dockerfile 不由该路径执行——runner 镜像是 HostStack 自带的 debian:trixie-slim,不是本文件的 debian:bookworm-slim,见 §3 与 §8.1 的说明。

构建指纹 BUILD_VERSION(build.rs):编译期用 git rev-parse --short HEAD + date -u 拼出 <sha>+<UTC 时间戳> 并写入 BUILD_VERSION。git 在构建镜像里通常不存在, 此时退回 nogit+<时间戳> —— 时间戳部分是真正可用的判据,不会静默退化成固定值。 镜像里的 cargo build 会自动执行该 build script(Cargo 对 workspace 根 build.rs 无条件执行, 无需额外配置)。SPA 页脚与 GET /api/status 的 version 字段都取自它,用来确认部署是否真正落地。

配置优先级(三层,从 HostStack agent 源码实测确认):部署时 agent 先把控制台 services 表里已存的值填进 payload,再用 hoststack.yaml 声明的值覆盖,最后才用运行时 框架默认值补缺。源码判定条件是 if (svcConfig.install?.command) 才覆盖,且没有 && !payload.x 之类的短路——YAML 声明了就以 YAML 为准、无条件覆盖控制台值;YAML 省略的字段则沿用控制台已存的那个值。框架默认值只有在前两者都为空时才填,而 agent 的 注释明确写着 "trigger-payload always populates payload.x from the DB",所以对已建好的 服务这条默认值分支实际上是死代码。这也意味着删掉本文件不会退回安全的默认值—— 它直接采用控制台现存的那个值。

因此目前不能删。控制台里现存的 Start Command 是 ./target/release/app, 这是 HostStack 为 Rust runtime 自带的默认值、与本仓库包名无关:Cargo.toml 只声明 一个 [[bin]](名字 message-weave,路径 src/main.rs),仓库里既没有 app 这个 二进制目标也没有对应源文件。直接删掉 hoststack.yaml 后,下一次部署会以 ./target/release/app 启动,进程在绑定端口前就以 not found 退出。 线上实测印证:控制台 UI 显示 ./target/release/app,而实际 PID 1 是 ./target/release/message-weave——说明现在生效的是 YAML,不是控制台。

决策记录:最初倾向是"从控制台 UI 管理更方便,把 hoststack.yaml 拿掉"。 但按上面的优先级,这个前提不成立——直接删 YAML 服务起不来。当前决定:保留 hoststack.yaml。 保留的代价是双写(在控制台改命令不会生效,必须改 YAML); 保留的理由是部署命令进入 git 历史、可 review 可回滚,而控制台值散在平台侧、改动无痕迹。

这个双写不是我们多此一举,命令字段从设计上就不回写控制台:agent 源码里只有 yaml_port_declared 与 yaml_disk_declared 两个回写事件,命令没有——每次部署都是 拿 YAML 临时覆盖 services 行里的旧值。port 与 disk 之所以要回写,是因为 autoscaling 与 sleep-wake 直接读那行;命令只有部署路径自己读,所以不值得持久化。 结论是控制台对命令字段永远是过期缓存,不存在"以 UI 为准"的稳态——这也是 HostStack 把它叫 Infrastructure-as-Code 的原因(源码原文:hoststack.yaml is Infrastructure-as-Code: when the repo declares a command, it wins over whatever happens to be cached on the services row)。

关于这个文件的来历:它不是 HostStack 生成的,也没有官方模板可抄——agent 镜像里 既没有 init 类命令也没有内嵌的模板 YAML,只负责在仓库根读取 hoststack.yaml 或 hoststack.yml。本文件是 a571033 手写的,所以"官方推荐怎么写"没有标准答案,只有 schema(见上一条的未知键行为)。

若日后确要移除,必须先在控制台把三条命令改对(install = cargo fetch --locked、 build = cargo build --release --locked、start = ./target/release/message-weave), 再删本文件并重新部署一次验证;同时把本段连同该前提移到 docs/retired.md 并记下 移除 commit。不要只删文件就以为控制台值会自动变对。

校验是宽松的,不是拒绝式的:agent 的部署路径不应用 zod schema 校验, install / build / start 等都是可选对象,未知键会被静默丢弃而不是报错, 拼错一个键名(如 healthcheck 全小写)不会让部署失败,只会让对应配置悄悄失效。 因此 hoststack validate(本地类型检查,不发起 API 调用)是唯一有效的防线, 应放进 CI;本机无 Node 环境时至少保留一次人工 review。

全局业务开关通过受保护的 GET|PUT /api/enabled 管理,持久化 Redis key 为 config:enabled,默认关闭且读取失败 fail-closed。关闭时 /webhook/tg、/push/jmap、 /reconcile、/worker 返回 503,不执行入队、ACK 或业务处理;SPA、/api/status、管理 会话和配置 API 保持可用,打开后立即生效。

迁移后生产环境仅需 REDIS_URL 与 CONFIG_ENCRYPTION_KEY。下表字段已不再从环境变量读取(Config::from_env() 及其助手 required_secret/required_nonblank/env_bool 已删除,config.rs 零 std::env 读取;登记见 docs/retired.md),全部经 Redis 业务配置由 /api/bootstrap 或管理员 PUT 写入并在成功后热重建客户端。表内 ✅ 指 Redis 业务配置的 fail-closed 必填(validate_nonblank,空白即拒绝),不代表需要注入生产容器。不要把下表变量注入容器:注入也不会被读取。

(保留既有 Secrets 注入约定:禁入镜像、secrecy 包裹、日志屏蔽)

Redis 业务配置字段 必填 说明
BOT_TOKEN ✅ Telegram Bot Token
TELEGRAM_CHAT_ID ✅ Worker 元数据通知目标 chat id(与入站白名单分离)
JMAP_SESSION_URL ✅ Stalwart JMAP session URL(REQ-JMAP-SESSION-URL):填服务基地址(https://mail.example.com)或完整 …/.well-known/jmap 均可;代码归一化为 origin/base 后再交 jmap-client,不产生重复路径。仅 HTTPS;禁止 URL 内嵌凭据(SAF-JMAP-URL)
JMAP_USERNAME / JMAP_PASSWORD ✅ Stalwart 认证 = App Password + Basic(已确认 C-AUTH-APP-BASIC):账号填邮箱,密码填在 Stalwart 生成的应用专用密码(可独立吊销/设到期);不用主密码、不用 OAuth。通常内置 user 角色已包含 PushSubscription 的读取/创建/修改/删除权限,无需手工配置;若实际调用返回 forbidden,再到 /admin → Management → Directory → Accounts/Roles 检查
CHAT_ALLOWLIST ✅ 聊天白名单(硬约束 SAF-CHAT-ALLOWLIST):逗号分隔整数 chat id;处理任何事件前先校验,非白名单直接拒绝
REDIS_URL ✅ 外部 Redis(用户托管 + AOF)(C-REDIS-ONLY-STATE/C-REDIS-MANAGED-AOF):session / dedup / Streams / fuse / sinceState 全部在此;支持 redis:// 与带默认 ACL 用户的 rediss://default:<url-encoded-password>@host:6379/0,Redis 进程不在此 compose 内
PORT 默认 8080 单监听端口(C-NO-TCP-EXPOSE)
RECONCILE_TOKEN ✅ /reconcile 的 Authorization: Bearer <token> 承载令牌(SAF-AUTH-RECONCILE)。因 /reconcile 路由始终挂载,此字段为必填(SecretString)
WORKER_TOKEN ✅ /worker 的有界处理令牌;管理 API 兼容接受该 Bearer 值,SPA 使用短期 Redis admin session
TG_WEBHOOK_SECRET ✅ /webhook/tg 校验请求头 X-Telegram-Bot-Api-Secret-Token(SAF-AUTH-TG-WEBHOOK)。须与 Telegram setWebhook 的 secret_token 完全一致(SecretString)
DEBUG_ENABLED + DEBUG_TOKEN 可选,默认关闭 远程联调面 /debug/* 的双因子开关(SAF-DEBUG-GATE):DEBUG_ENABLED 为真值(或命令行带 --debug,用于本地开发)且 DEBUG_TOKEN 非空才挂载 7 条路由,缺一即路由不存在、请求走通用 404。令牌为 SecretString,常数时间比较、不进日志(SAF-LOG-PURITY)。生产环境不配置,见 §2.1
Push registration/verification 已接入,需显式注册 POST /api/push/register 接受 HTTPS callback URL 并调用 PushSubscription/set create;相同 callback URL 重复请求幂等复用;/push/jmap 接收 Stalwart 生成的验证码并自动回写,Redis 保存订阅 ID 和短期验证状态
LLM_* 可选 OpenAI-compatible 业务配置字段(REQ-LLM-OPENAI-COMPAT);其中 LLM_MAX_RETRIES 不是业务配置字段而是 Redis 运行参数(回落默认见 docs/reference.md §6.1);仅当用户明确允许时才把邮件正文外发 AI(REQ-AI-EXTERNAL-CONSENT)
ACCOUNT_ID 默认空 单账户(REQ-SINGLE-ACCOUNT):留空则取 session 主账户;多账户 = 部署多个 bot 实例(各自独立 token/配置),不做多账户单实例
TIMEZONE 默认 Asia/Shanghai 通知里的收件时间按此时区渲染(REQ-TIMEZONE-DISPLAY,wire 键为小写 timezone)。在配置页第 04 节「通知显示 / Notification display」的选择器里设置,不是环境变量。只接受 16 个固定偏移时区(Etc/UTC、Africa/Cairo、Europe/Istanbul、Africa/Nairobi、Asia/Dubai、Asia/Karachi、Asia/Kolkata、Asia/Bangkok、Asia/Ho_Chi_Minh、Asia/Shanghai、Asia/Hong_Kong、Asia/Taipei、Asia/Singapore、Asia/Manila、Asia/Tokyo、Asia/Seoul),不在表内时 PUT /api/business-config 返回 422 且旧配置继续生效。不跟踪夏令时:离线构建没有 IANA tz 数据库(chrono-tz 不可用),所以只收录全年无夏令时的区域;需要伦敦/纽约这类区域请自行换算成对应的固定偏移区域

AI 授权期限由用户选择(临时一次、今天、7天或直到撤销),Redis 仅保存 chat id、授权状态和带 TTL 的到期时间;不会保存正文或摘要。到期后摘要请求回到元数据模式并提示重新授权。

业务鉴权变量由 Redis config:business 管理,不再要求作为启动环境变量。缺少 REDIS_URL 或非法/缺失 CONFIG_ENCRYPTION_KEY 时服务仍监听并提供 SPA、/api/status 与探针,状态为 configuration-setup,不会伪造持久化成功;配置恢复后再启用业务入口鉴权。 示例占位见仓库根目录 .env.example(仅占位符,严禁放入真实密钥)。

配置管理页面:服务根路径 / 提供嵌入 Rust 二进制的 SPA。输入 CONFIG_ENCRYPTION_KEY 后,POST /api/admin/session 签发 900 秒 admin session;页面只在内存中保存 opaque session。运行参数通过 /api/config 读取和保存;完整业务配置通过 PUT /api/business-config 替换并热加载。业务配置 API 通过 GET /api/business-config 回读已保存配置:返回 10 个非密钥字段的值 + 6 个密钥的存在性布尔(SAF-NO-SECRET-ECHO,不返回密钥值本身);PUT 接受部分补丁,只替换提交中出现的字段,省略的密钥沿用已存值,因此表单回读后只需修改要改的字段,无需重新填写密钥。但后端不把空字符串当作"未提交":显式提交 "" 会真的清空该密钥,"留空即保留"这一保证由 SPA 在提交前丢弃空白密钥字段实现;chat_allowlist 提交空数组会被拒绝(422)。配置保存在外部 Redis(C-REDIS-ONLY-STATE),静态资源编译时随二进制打包,无运行期本地文件。


6. 运行模式与短请求模型

6.0 Redis-only 配置迁移与 bootstrap 威胁模型(C-REDIS-ONLY-STATE)

生产进程接受 REDIS_URL 与唯一额外启动密钥 CONFIG_ENCRYPTION_KEY(32 字节随机 高熵 hex,仅应用运行时持有);bootstrap 与 admin 会话的认证凭据即 CONFIG_ENCRYPTION_KEY 本身;业务密钥和运行参数不再从环境变量读取。空 Redis 仅提供配置页面及一次性的 bootstrap 会话:请求必须以 CONFIG_ENCRYPTION_KEY 作为 Bearer 凭据,服务端只做常数时间比较,绝不在 响应、日志或配置值中回显该密钥。bootstrap 使用原子 SET NX 写入完整业务配置, 竞争请求只有一个成功;ACL 密码缺失不再阻塞初始化(TLS-only 托管 Redis 场景可用), 仅 CONFIG_ENCRYPTION_KEY 缺失或鉴权失败时拒绝。

该 ACL 身份是应用自身的 Redis 连接凭据,仍必须具备 config:business、 config:outbound 及 config:admin_session 键的读写权限(bootstrap 与管理员 PUT 均经此 连接写入),不得误配置为禁止配置键访问;但它不再是任何 HTTP 认证的信任根——业务配置 明文受 CONFIG_ENCRYPTION_KEY 做 AES-256-GCM 加密保护,仅持有 ACL 密码读取到的也是 密文。bootstrap 完成后公网业务配置接口只接受 900 秒 admin session。

成功 bootstrap 后,后续配置读写只能使用 Redis 中保存的管理员会话(短 TTL,注销 或配置更新时失效);业务端点使用 Redis 配置中下发的独立令牌。管理员会话只保存 不可逆哈希和过期时间,重启后按 Redis TTL 恢复,进程不保存本地状态。GET 永不回显 任何密钥值,只返回密钥是否存在(presence 布尔),配置缺失或业务依赖未就绪时保持 HTTP/SPA 可用但业务路由返回未就绪。

config:business 使用 AES-256-GCM(版本、随机 nonce、认证 tag 均在密文 envelope 中) 写入 Redis;应用启动时使用 CONFIG_ENCRYPTION_KEY 解密,密钥永不写 Redis、日志、响应 或浏览器存储。该保护只防 Redis 内容泄露,不替代 rediss、VPN 或受信网络对 redis:// 窃听/篡改的防护;公网 Redis 必须使用 TLS/VPN/隧道。

bootstrap 或管理员 PUT 修改 config:business 后,服务会先校验并构建全部 JMAP/Telegram/LLM 客户端,再原子替换 worker;构建失败保留旧配置与任务。后续请求即时使用新快照,多实例在 请求边界按 revision 有界检查并尝试刷新;失败时保留旧实例,避免忙循环。

TLS 必须由 Cloudflare 或受信任反向代理终结;代理到容器的链路只能位于受控私网, 并应校验可信转发头后才允许 bootstrap。容器端口不得直接暴露公网。SPA 仅在当前页面 内存保存 opaque admin session,不使用 localStorage、sessionStorage 或 Cookie 持久化。

单一运行形态:进程只有单条服务命令 ./target/release/message-weave,webhook handler、JMAP Push 回调、Redis Streams worker 与 /reconcile 同处一个进程;对账是一次性 HTTP 端点 POST /reconcile(由外部调度器/容器任务调用),不是独立子命令,也没有 RUN_MODE 变量(该标识符已随 Config::from_env() 删除,见 docs/retired.md)。无长连接(C-NO-LONG-CONN)。

6.1 入口路由(单端口 PORT,全部经平台 HTTPS URL 入站)

# 平台公网 URL: https://bot.example.com  → 反代 → 容器 127.0.0.1:8080
GET  /                  业务配置与运行参数管理 SPA
GET  /assets/config.js  SPA 脚本;GET /assets/styles.css  SPA 样式
GET  /api/status              公开启动状态;只返回 ready/mode/missing 环境变量名 + version 构建指纹
POST /api/admin/session       Bearer CONFIG_ENCRYPTION_KEY;成功返回 900 秒 admin session
POST /api/admin/session/revoke Bearer admin session;成功返回 204
GET|PUT /api/config           Bearer admin session 或 WORKER_TOKEN;Redis 错误返回 503
GET|PUT /api/business-config  Bearer admin session 或 WORKER_TOKEN;GET 回读非密钥值 + 6 个密钥存在性布尔(不回显密钥值),
                              PUT 接受部分补丁(省略的密钥沿用已存值),校验通过后必持久化,
                              客户端构建失败返回 200+warnings(不阻断保存),reload 成功才 runtime_applied=true
POST /api/business-config/preflight Bearer admin session 或 WORKER_TOKEN;同样的校验与客户端构建,
                              不写入、不换 worker;返回 200 + 逐组件结论
POST /api/bootstrap           与 PUT 同契约(失败也持久化并回 200+warnings)
POST /webhook/tg      TG Webhook → [鉴权 SAF-AUTH-TG-WEBHOOK: 头 X-Telegram-Bot-Api-Secret-Token]
                      → 快速 2xx ACK → 幂等去重(MOD-DEDUP) → 命令处理 → 同步回复(快路径)
POST /push/jmap       Stalwart Push 回调 → [鉴权 SAF-AUTH-JMAP-PUSH: Body pushSubscriptionId + verificationCode]
                      → 幂等去重(MOD-DEDUP) → 入 Redis Streams(MOD-STREAMS) → 立即 2xx ACK(慢任务异步)
POST /reconcile      外部 HTTPS Cron 触发 → [鉴权 SAF-AUTH-RECONCILE: Authorization: Bearer RECONCILE_TOKEN]
                      → 对账补差 FLOW-RECONCILE
GET  /healthz        liveness(ARCH-HEALTHZ:进程存活;公开探针 SAF-PROBE-PUBLIC,无鉴权、无敏感信息)
GET  /ready          公开就绪探针;检查配置完整性 + Redis 可达性 + 出站只读探测(JMAP `/.well-known/jmap` 带 Basic 认证、Telegram getMe,各 3s),四者全过返回 200+就绪报告 JSON,任一失败返回 503+标准错误 envelope(无鉴权、无敏感信息)

/reconcile 使用 Redis owner-token 单飞锁并在长任务期间续租;每次最多处理 100 页、10,000 封基线邮件或 20 秒。已完成入队的 changes 页会提交最新游标;冷启动基线超出预算时把基线状态与位置编码后持久化,下一次从断点继续,不从头扫描,也不会提前跳过未列举邮件。

写入口鉴权(fail-closed,SAF-AUTH-*):Webhook、Push、Reconcile 以及 Push 注册接口都必须先通过鉴权,失败返回 401 且不产生副作用;比较使用常数时间(subtle,防时序侧信道)。secret 未配置 → 启动失败,无"缺省放行"。 健康探针(SAF-PROBE-PUBLIC):/healthz、/ready 仅返回健康状态、不含敏感信息。/healthz 只表示进程存活;/ready 检查配置完整性与 Redis 可访问性,并发起只读出站探测(JMAP /.well-known/jmap 带 Basic 认证 与 Telegram getMe,各 3s、并行);不触发邮件同步等业务副作用。 "公网 HTTPS 入口"是什么(C-HTTPS-URL):平台给 bot 一个公网 HTTPS 域名,外部(Telegram / Stalwart / 调度器)通过它访问上面这些路径;容器只处理明文 HTTP,TLS 由平台终止。运维只需在平台上配置域名/证书并确保 4 条路径可达。

6.2 Redis Streams / worker(MOD-STREAMS)

  • Push 回调只做:校验 → 去重 → 入队 → ACK;不阻塞
  • 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: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 二次兜底)

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:420,属于 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。
  • Redis 丢失恢复:sinceState 存 Redis(MOD-SINCESTATE,AOF 持久化 C-REDIS-MANAGED-AOF);即便 Redis 全丢,对账扫描 JMAP(Email/query 最近 N 封 + changes)也能重建游标并补发——事实源在 JMAP,Redis 只是加速层。

6.3.1 用户侧调度示例(copy-paste 可用)

端点有两个:POST /reconcile(路由 src/notify.rs:1766)与 POST /worker(路由 src/notify.rs:1761),同容器暴露、无端口或协议差异。两个都必须被外部调度,顺序是先对账、 再排空。 两者职责不重叠:/reconcile 只做 Email/changes 增量入队并推进游标,全程不调用 read_batch;/worker 只做消费,全代码库唯一的 read_batch 调用点在 src/notify.rs:420。 只排对账等于邮件持续进队列、通知永远发不出去——这是照抄调度配置时最容易踩的坑。

最小可运行版本(推荐起点):

项 /reconcile /worker
作用 增量入队 + 推进游标 排空两条 stream 并发通知
调度顺序 先 紧随其后
鉴权 Bearer + reconcile_token Bearer + worker_token
可走 Worker 吗 可以(在 SAFE_ROUTES 内) 可以(在 SAFE_ROUTES 内)
LB 超时 LB_RECONCILE_TIMEOUT_MS 默认 320s,maxAttempts=1 不故障转移 LB_WORKER_TIMEOUT_MS 默认 300s,maxAttempts=1 不故障转移
幂等 SET NX lock:reconcile 300s 单飞锁,撞锁回 409 无单飞锁,靠逐条 delivery:* 去重声明防重投
单次上限 100 条变更(src/notify.rs:294) 10 条消息(src/notify.rs:407)
常态返回 204 204(不能当成功信号)

两个 token 都是 Redis 业务配置字段(§5),不再从环境变量读取;外部 cron 从自己的 secret 存储取同一个值即可(下方 k8s 示例即此做法)。/worker 只认 Bearer token、没有 admin-session 兜底(src/notify.rs:395),这是有意的:cron 不需要先登录拿 session。 token 轮换后无需重启——worker 处理函数开头先 refresh_business_config(src/notify.rs:390), 再从热更新快照取值(:385,快照由 auth_snapshot 读取)。

两个端点都在 Worker 转发白名单里,cron 打 Worker 域名和打后端 origin 都能用。它们走 per-route 覆盖:各自用独立的更长超时(见上表)并且 maxAttempts=1 绝不故障转移——全局 10s 超时会把一次正常的排空误判成失败,而第二个实例对 /reconcile 必然撞锁 409、对 /worker 只会重复排同一批消息。/worker 此前被挡在白名单外(走 Worker 是 404 route not forwarded), 现已加入;它和 /reconcile 一样只接受 Bearer token,不带人工流量。见 §10.4。

不想自己拼 curl 的话,仓库里有现成脚本 scripts/cron-drain.sh:两步都做了,参数全部走 环境变量(MW_APP_URL / MW_WORKER_URL / MW_RECONCILE_TOKEN / MW_WORKER_TOKEN 等),凭据不写任何文件。两个额外开关专门用来回答「为什么没收到 Telegram」:

  • --diagnose:依次查 /api/status、/debug/config(配置是否保存、chat_id 是否为占位值、 webhook secret 是否配置)、/debug/redis、/debug/telegram(bot token 是否有效)、 /debug/jmap、/debug/worker(游标是否还停在 null)。配置问题会直接点出来,不用猜。
  • --test-notify:绕过队列直接走生产出站路径发一条测试消息。这条能收到,说明 token/chat_id/allowlist 全对,问题一定在调度;收不到,问题就在这一层,跟 cron 无关。

诊断端点需要后端以 --debug 启动并设置 MW_DEBUG_TOKEN,且必须直连 origin。

最小示例(手动烟测,打印状态码;/reconcile 不接受请求体,游标存在服务端 Redis 里):

curl -sS -o /dev/null -w '%{http_code}\n' \
  -X POST "https://<你的平台URL>/reconcile" \
  -H "Authorization: Bearer <RECONCILE_TOKEN>" \
  --max-time 60

/worker 也先手动跑一遍,可以直接打 Worker 域名(它与 /reconcile 一样在转发白名单里,见 §10.4)。空请求体是合法的,等价于取默认 10 条,不用带 -d;它可能跑几分钟,-m 要给够(LB 侧默认 LB_WORKER_TIMEOUT_MS = 300s):

curl -sS -o /dev/null -w '%{http_code}\n' \
  -X POST "https://<你的平台URL>/worker" \
  -H "Authorization: Bearer <WORKER_TOKEN>" \
  --max-time 300

最小调度(两步一条 cron,顺序不能反):

# 每 5 分钟:先对账入队,再排空队列(两步都走 Worker 域名,顺序不能反)
*/5 * * * * curl -fsS -m 320 -X POST https://<你的平台URL>/reconcile \
    -H "Authorization: Bearer $RECONCILE_TOKEN" >/dev/null ; \
  curl -fsS -m 300 -X POST https://<你的平台URL>/worker \
    -H "Authorization: Bearer $WORKER_TOKEN" >/dev/null
  • 两条之间用 ; 而不是 &&:对账撞锁拿 409、或临时 503,都不该阻断排空——上一次对账 入队的消息同样需要被消费。
  • 两条都用 Worker 域名即可(都在转发白名单里);-m 要比 LB 侧的 per-route 超时略大, 否则 curl 会先掐掉一个本来能完成的排空。
  • 请求体可带 {"batch":N};N 会被压回上限 10,不会因传大值而报错(src/notify.rs:407)。 传非法 JSON 才是 400 invalid_request(src/notify.rs:411)。

Kubernetes CronJob 示例:

apiVersion: batch/v1
kind: CronJob
metadata:
  name: messageweave-drain
spec:
  schedule: "*/5 * * * *"
  concurrencyPolicy: Forbid
  successfulJobsHistoryLimit: 1
  failedJobsHistoryLimit: 1
  startingDeadlineSeconds: 120
  jobTemplate:
    spec:
      template:
        spec:
          restartPolicy: Never
          containers:
            - name: drain
              image: curlimages/curl:8
              command: ["/bin/sh", "-c"]
              args:
                - |
                  curl -fsS -m 320 -X POST "${APP_URL}/reconcile" \
                    -H "Authorization: Bearer ${RECONCILE_TOKEN}" >/dev/null
                  curl -fsS -m 300 -X POST "${APP_URL}/worker" \
                    -H "Authorization: Bearer ${WORKER_TOKEN}" >/dev/null                  
              env:
                - name: APP_URL
                  value: "https://<你的平台URL>"
                - name: RECONCILE_TOKEN
                  valueFrom:
                    secretKeyRef: {name: messageweave, key: reconcile-token}
                - name: WORKER_TOKEN
                  valueFrom:
                    secretKeyRef: {name: messageweave, key: worker-token}

期望状态码:

状态码 含义 处理
/reconcile 204 对账完成(含 since 游标前进 0 条) 正常,不需要动作
/worker 204 本次调用正常结束 不能当成功信号:无消息可读、全部撞去重都回 204,src/notify.rs:510 无条件返回
/worker 400 请求体不是合法 JSON(src/notify.rs:412) 修 curl;空请求体合法,等价于取默认 10 条
/reconcile 401 Authorization 头缺失或 reconcile_token 不匹配(src/notify.rs:285) 检查 cron 的 token 值;重启不解决
/worker 401 Bearer 值与 worker_token 不匹配(src/notify.rs:402) 同上;/worker 无 admin-session 兜底
/reconcile 409 单飞锁 lock:reconcile 被占(src/notify.rs:291 取锁,src/notify.rs:355 返回) 直接跳过本次 tick,不要重试
503 业务配置此刻不可用,或 Redis / JMAP 请求失败 可重试;响应带 Retry-After: 30(src/notify.rs:383)

重试建议:

  • 不要在客户端实现重试循环。调度器每 5 分钟触发一次已经足够,因为:
    • 单次调用幂等:/reconcile 有 SET NX lock:reconcile 单飞锁 (ttl::RECONCILE_LOCK_SECONDS = 300s,src/notify.rs:291,长对账中每 ttl::RECONCILE_HEARTBEAT_SECONDS = 90s 续租 src/notify.rs:305,成功后释放 src/notify.rs:350);/worker 靠逐条去重声明防重投,重复触发无害。
    • 客户端拿到 409 conflict 时不要重试,直接跳过本次 tick——锁在 TTL 或任务完成后 自然释放,下一次调度会重试。
  • 超时设置建议 ≥ 单飞锁 TTL 的 1/3(即 ≥ 100s)以吸收长对账,但客户端超时不应超过 调度间隔。
  • 如果你的调度器自带重试能力,把它限制为:只对 5xx 与超时重试,指数退避 2s / 4s / 8s,上限 60s,总次数 ≤ 3;204 与 409 永远不重试。调度器没有自带能力 就不要额外实现——下一个 tick 自然会重试。
  • 积压会自然堆到下一次 tick。 /worker 单次最多消费 10 条(src/notify.rs:407); 积压超过 10 条时需要连续多个 tick 才能清完。想更快就调高 cron 频率,不要靠加长 curl 超时——那只会抬高挂死概率,不会让更多消息被消费。
  • 204 与 409 都是正常结果,不要把它们当失败重试——尤其别对 409 做紧密循环重试。
  • 调度间隔按 NFR-RECONCILE-INTERVAL 取 5–10 分钟。Push 是主路径,对账是兜底; 但排空不是兜底项——没有它,邮件只会一直进队列,一行通知都不会发出来。
  • /reconcile 不会返回 400:它没有请求体解析,传了 body 会被静默忽略,所以不要以为要传 since。
  • 走 Worker 转发无需特殊设置:网关已为两个排空端点单独覆盖超时与尝试次数——POST /reconcile 取 LB_RECONCILE_TIMEOUT_MS(默认 320000 ms,大于单飞锁租期 300 s + 心跳余量;锁续租逻辑见 src/notify.rs:305),POST /worker 取 LB_WORKER_TIMEOUT_MS(默认 300000 ms = 后端单条事件 下界,batch 拉大时按需上调),两者都 maxAttempts=1 绝不故障转移(对 /reconcile 切实例会立刻 返回 409,对 /worker 切实例只会重复排同一批消息)。其余快路径仍用全局 LB_REQUEST_TIMEOUT_MS (默认 10000 ms)与 LB_MAX_ATTEMPTS(默认 2),不受影响。

6.4 可靠性策略(Reliability)

目标:至少 99.9% 通知可用性(NFR-NOTIFY-SLA),允许少量延迟(不追求秒级保证)。下述机制共同保证"不丢、少重、可恢复"。

机制 ID 做法
Streams ACK / 重试 MOD-STREAMS worker 用消费组 XREADGROUP;处理成功才 XACK;未 ACK 消息在被认领后重投(at-least-once)。处理失败时不 ACK,自然重试;设最大投递次数上限,超限转死信(XADD 到 dlq)并告警,避免毒丸阻塞
幂等去重 MOD-DEDUP 以 (account, email_id) 为幂等键,SET NX(TTL 覆盖重投窗口)。重复投递直接跳过,保证 at-least-once 下不重复通知。TG 侧同理用 update_id
Push 重试 FLOW-NEW-MAIL /push/jmap 校验/入队后立即 2xx;若入队失败(Redis 抖动)返回非 2xx,让 Stalwart 按自身策略重试;配合对账兜底
对账恢复 FLOW-RECONCILE 外部 Cron 每 5–10 分钟调用 /reconcile;从 Redis 恢复 state:jmap:since,按 Email/changes 补差,入队成功后推进游标,失败返回 503
运行监控 / 告警 Uptime Kuma 使用 HTTP(s) Monitor 检查 /healthz(进程存活,无条件 200)和 /ready(配置 / Redis / 上游可达就绪),分别期望 200;对 503、超时、TLS/DNS/路由故障告警。/ready 最坏约 3s(两个 3s 探针并行),探针超时设 ≥10s。应用不引入 Prometheus、Exporter 或额外指标端口。

6.5 99.9% 可用性目标与边界(NFR-NOTIFY-SLA)

  • 定义:在外部依赖(Stalwart / Telegram / Redis)可用的前提下,通知链路的可用性目标 ≥ 99.9%(约每月 ≤43 分钟不可用)。
  • 允许的延迟:用户已确认接受少量通知延迟——正常情况下 Push 回调应为秒级;依赖抖动/需对账兜底时,延迟上界为对账间隔(≤10 分钟)。
  • 非目标边界:99.9% 是通知可用性目标,不含 Stalwart/Telegram/Redis 自身故障时间;三者任一长时间不可用属外部依赖故障,不计入本 bot 的可用性预算(但 bot 应在恢复后经对账自动补齐)。
  • 降级行为:Redis 不可用时,去重/会话退化为进程内短窗口、Push 回调返回非 2xx 交 Stalwart 重试;恢复后由对账补齐。不因 Redis 抖动而漏发最终通知(以对账为准)。

7. 健康检查(通用 HTTP 约定)

  • GET /healthz → liveness(ARCH-HEALTHZ;进程存活,长期语义)
  • GET /ready → 端到端就绪探针(ARCH-READY-BASELINE):检查配置完整性 + Redis 可达性 + 出站只读探测(GET {jmap_origin}/.well-known/jmap 带 Basic 认证、GET https://api.telegram.org/bot<token>/getMe,各 3s 超时、并行(tokio::join!),最坏约 3s);四者全过 200(就绪报告 JSON 含真实 jmap/telegram 字段),任一失败 503(标准错误 envelope {"error":"service_unavailable","request_id":<id>} + Retry-After: 30)。因此LB / ingress 的探针超时必须 > 3s(建议 ≥10s)。Uptime Kuma 按状态码(期望 200)监控,不受响应体变化影响。
  • 两者均为公开探针(SAF-PROBE-PUBLIC):无鉴权、仅返回健康状态、不含敏感信息。
  • /ready 已对 JMAP session 与 Telegram getMe 做真实出站探测(3s/个),但不覆盖真实消息投递验收;编排与 Uptime Kuma 可用它做端到端就绪探测,注意其最坏约 3s,LB 探测超时需 > 3s(见上)。
  • Docker HEALTHCHECK 指向同一监听端口(wget/curl 探同端口),不依赖独立端口(C-NO-TCP-EXPOSE)。二进制没有 health 子命令可指,见 §8.1 的说明
  • 仅保留通用平台映射(compose HEALTHCHECK、k8s probe);不写 Cloud Run/Fly 等特指内容(NG-SERVERLESS-BIND)

8. CI 发布与镜像验收

8.0 代码门禁(GATE-P0,进入阶段1前必须通过)

  • cargo fmt --check
  • cargo clippy --all-targets -- -D warnings
  • cargo test

以上三条在 Debian rust:1-slim-bookworm 容器内执行(C-DEBIAN-SLIM);详见 design.md §10.0。

容器以 --user 1000:1000 运行,并把 RUSTUP_HOME/CARGO_HOME 挂载到仓库内持久缓存目录(-v "$PWD/.gate-cache/rustup:$RUSTUP_HOME" -v "$PWD/.gate-cache/cargo:$CARGO_HOME"),否则缓存写入因权限失败、工具链每次重建;.gate-cache/ 已在 .gitignore 中,不得提交。门禁结果必须基于当前仓库快照重跑得到,不得复用先前快照的通过结果。

(其余 CI:镜像构建 → 镜像扫描 → 非 root → /ready 端点到容器验证;运行方式见 8.1)

8.1 运行方式与就绪探测

本节的两条部署路径不要混淆:上面的构建/运行命令是本仓库 Dockerfile 路径(本地 docker run 或其他容器宿主机);线上 HostStack 部署走 §5.1 的 hoststack.yaml + runtime: rust agent, 在 rust:slim-trixie 里构建、拷进 debian:trixie-slim 的 runner 容器运行, 完全不执行本 Dockerfile——两者连 Debian 版本都不同(bookworm vs trixie)。 服务 argv 是 ./target/release/message-weave 而不是镜像里的 tini -- /usr/local/bin/message-weave,因此镜像内的诊断工具与 ENTRYPOINT 在线上都不生效。 判定当前跑的是哪条路径,看容器内 /proc/1/cmdline 与 /etc/os-release。

**本仓库没有 docker-compose.yml。**此前本节给过一段 compose 片段,其中的 healthcheck 写成 message-weave health --addr 127.0.0.1:8080 —— 这个子命令不存在(src/main.rs 无任何 CLI 参数解析,二进制只起 axum 服务)。照抄那段示例必然得到:healthcheck 永久失败,或容器被判定 healthy 却什么都不做。示例已删除,改为下面的事实约定。

镜像(runtime 阶段)现已内置 curl,因此在 Dockerfile 部署路径下用 HEALTHCHECK 探活在 技术上可行;HostStack 官方文档也把 Dockerfile HEALTHCHECK 定为把新副本加入 Traefik 池前的 readiness 关卡。但 runtime: rust 的 HostStack agent 不走本 Dockerfile,该路径的 readiness 由 hoststack.yaml 的 healthCheck.path 决定,所以仍以平台探针为准——/ready 是唯一同时校验 redis/jmap/telegram 的信号,HEALTHCHECK 只能证明进程活着。

运行

cp .env.example .env           # 仅需 REDIS_URL 与 CONFIG_ENCRYPTION_KEY
docker build -t messageweave:latest .
docker run --env-file .env -p 8080:8080 messageweave:latest
  • 单端口 8080;平台 ingress/反代映射 HTTPS → 8080(C-HTTPS-INBOUND)。
  • runtime 阶段为 debian:bookworm-slim,非 root(useradd --system,实测 uid 999,非 1000); 无本地 volume、无数据库引擎(C-NO-LOCAL-WRITE / C-NO-DB)。
  • 镜像内置只读诊断工具:curl、procps(ps/pgrep/free)、iproute2(ss/ip)、jq、 netcat-openbsd(nc),合计实测 19.7 MB(全量文件系统 du 对比 debian:bookworm-slim)。容器 rootfs 只读,这些工具只能在构建期装入, 运行期无法 apt 安装——这也是它们写进 Dockerfile 而不是留给运维现场装的原因。 刻意不装 bind9-dnsutils(dig):它连带 libicudata.so.72 等多级依赖要多花 42.7 MB,是其余 7 个工具总和的两倍多;DNS 解析用基础镜像自带的 getent 即可 (getent hosts <h> 出 A 记录,getent ahostsv4 / getent ahostsv6 分开取)。 但这条只对 Dockerfile 部署路径成立:runtime: rust 的 agent 在 rust:slim-trixie 里构建、把产物拷进 debian:trixie-slim 的 runner 容器运行, 完全不执行本 Dockerfile,因此这些工具在 HostStack 服务里并不存在(线上实测 pgrep: not found)。该路径下改用内核接口诊断:/proc/1/cmdline(实际 argv)、 /proc/1/environ(实际 env)、/proc/net/tcp、/proc/<pid>/fd。 HostStack 生产部署的容器安全档(由 agent 镜像内的部署执行器源码实测确认)为 readonlyRootfs: true + dropCapabilities: true + noNewPrivileges: true + pidsLimit: 256;/tmp 与 /var/tmp 是 rw,nosuid,nodev,size=64m 的 tmpfs—— 注意没有 noexec,但重启即清空,所以不要在那里放可执行文件或期望重启后仍在的文件。 dev 环境与此不同:dev 档为 readonlyRootfs: false、noNewPrivileges: false, rootfs 可写,同一个二进制在 dev 与生产的可写行为可能不同。
  • Redis 由外部已认证实例提供,不与此服务同容器运行(C-REDIS-EXTERNAL)。

就绪探测:用 /ready,不要用 /healthz

端点 语义 用途
GET /healthz 无条件 200 存活探测(进程还活着)
GET /ready 配置、Redis 或上游探针失败时 503 就绪探测、负载均衡摘除、Uptime Kuma
GET /api/status 恒 200;返回 {"ready":bool,"mode":"configured"|"configuration-setup","missing":[...],"version":"<构建指纹>"},只列缺少的环境变量名称;version 为 BUILD_VERSION(<git-sha 或 nogit>+<UTC 构建时间>) 排查"起来了但没干活";version 用于确认部署是否落地

**由平台 ingress 探测 /ready。**镜像现已内置 curl(见 Dockerfile runtime 阶段), 所以在 Dockerfile 部署路径下容器内写 HEALTHCHECK 在技术上可行(HostStack 的 runtime: rust 路径不走该 Dockerfile,readiness 由 hoststack.yaml 的 healthCheck.path 决定);但 /healthz 只能证明进程活着,/ready 才同时校验 Redis 与上游,因此仍以平台探针为准。

**缺必需环境变量不会崩溃,但也不会干活。**缺 REDIS_URL 或 CONFIG_ENCRYPTION_KEY 时,进程 降级为只读配置路由(内存态、空 token、NoopWorker),容器继续应答请求但不做任何业务。排查这类 现象:看启动日志告警,或查 GET /api/status 返回的 missing 列表。

8.2 镜像与部署验证检查项(红线自检)

以下检查在每次发布前/CI 中执行,用于固化 §0 新增红线:

# 检查项 关联 ID 命令/方法
1 镜像内无数据库引擎:不装/不含 SQLite、Postgres、MySQL 二进制或数据文件 C-NO-DB docker run --rm <image> sh -c 'command -v sqlite3 psql mysql 2>/dev/null; [ -z "$(find / -maxdepth 4 -name "*.db" -o -name "*.sqlite*" 2>/dev/null)" ]' 应为空
2 容器内不出现本地可写挂载:compose/k8s 不声明 volumes: 用于数据/日志目录 C-NO-LOCAL-WRITE / NG-LOCAL-VOLUME 人工/CI lint 检查 compose 文件、k8s manifest 不含本地 volume 声明
3 日志仅 stdout/stderr:应用启动参数不得含 file/rolling-file 后端;tracing 配置为 stdout C-LOG-STDOUT-ONLY 检查代码:grep -rEn 'rolling|FileAppender|RollingFileAppender' src/ 应为空;RUST_LOG 目标不含 file:
4 日志/Redis 无敏感数据:日志字段白名单 + CI 扫描 SAF-LOG-PURITY 代码评审 + 静态扫描(如 gitleaks 扫描 Redis 写入调用,验证 SETEX/SET 参数无密钥/正文/AI 内容)
5 重启恢复不依赖进程内状态:所有"续跑"逻辑必须读 Redis;进程内缓存仅性能优化 C-NO-STATEFUL-RECOVERY 代码评审:grep -rn 'static mut|lazy_static' src/ 不得用于生产恢复路径
6 Redis 由外部提供:容器不运行 redis-server;compose 不启动 Redis 服务 C-REDIS-MANAGED-AOF docker run --rm <image> sh -c 'command -v redis-server && exit 1 || exit 0' 应为 0

任一检查失败,禁止发布。


9. 已确认决策与少量待办

9.1 已确认决策(不再作为待确认问题)

ID 决策
REQ-SINGLE-ACCOUNT 单账户实现;多账户暂用多个 bot 实例(各自 token/配置),不做多账户单实例
C-AUTH-APP-BASIC Stalwart 认证 = App Password + Basic(不用主密码、不用 OAuth)
REQ-AI-EXTERNAL-CONSENT 仅当用户明确允许时才把邮件正文发往外部 AI(默认不外发)
C-REDIS-MANAGED-AOF Redis 由用户托管并开启 AOF 持久化(Bot 不自建/不管理 Redis)
C-HTTPS-URL 公网 HTTPS 入口由平台提供(平台给 bot 一个 HTTPS URL;bot 不持证书、不监听 443);Q-DEP-A 已决策:具体平台 URL / 域名与证书配置方(平台自动证书 or 自管反代二选一)由部署环境在发布时确定,仓库文档化两条路径与 4 条路径的可达性验证方式,不替部署方做选择
NFR-RECONCILE-INTERVAL 外部 Cron 定期 HTTPS POST /reconcile,建议 5–10 分钟;容器不自建定时器;Q-DEP-B 已决策:调度器不限定实现,任意 shell / cron / K8s CronJob / CI scheduled 均可,用户侧示例见 §6.3.1
NFR-NOTIFY-SLA 允许少量通知延迟;通知可用性目标 ≥ 99.9%(边界见 §6.5)
ARCH-LB-WORKER 多实例高可用:免费 Cloudflare Worker 作为唯一对外入口 + 故障转移,后端为 2+ 个不同 serverless 平台的同镜像实例,共享同一 Redis(详见 §10)
SAF-LB-PASSTHRU 信任模型 = 透传(A):Worker 不改写鉴权信息,后端必须继续 fail-closed 校验(小平台无防火墙/ACL,"后端不对公网暴露"不可行)
SAF-RECONCILE-LOCK /reconcile 不扇出:用 Redis 锁保证同一时刻仅一个实例执行
MOD-HEALTH-AGG Worker 暴露聚合健康视图,供外部监控
NFR-HA-MULTI-INSTANCE 双活或主备均可;Redis 单点故障不在本方案范围(用户外部解决,短暂不可用可接受)
C-NO-DB 生产不使用任何数据库:Redis 是唯一状态存储;应用不自建/不连接第二个数据库
C-NO-LOCAL-WRITE 禁止本地文件/目录写入:无日志文件、无数据文件、无临时缓存、不挂载本地卷
C-LOG-STDOUT-ONLY 日志只写 stdout/stderr(平台负责采集落盘);禁用文件日志后端
SAF-LOG-PURITY 日志与 Redis 写入不得包含:密钥、邮件正文、AI 请求/响应、附件内容;仅允许结构化事件、计数、时间戳、脱敏摘要
C-NO-STATEFUL-RECOVERY 禁止依赖进程内状态做生产恢复:重启恢复一律走 Redis + JMAP 对账(FLOW-RECONCILE);进程内缓存仅为性能优化,丢失必须安全可重入

最后一项待确认已清空:Q-DEP-A(平台 URL / 域名与证书配置方)与 Q-DEP-B(外部调度器选型) 均已决策,分别合并进本表的 C-HTTPS-URL 与 NFR-RECONCILE-INTERVAL 行。两项都是上线时的运维选择, 不影响代码结构、不影响门禁;docs/opengaps.md 已不再保留「决策待定」块。

其余产品/架构问题见 design.md;不再有平台特定部署问题(NG-SERVERLESS-BIND)。


10. 多实例高可用与 Worker 前置负载均衡(ARCH-LB-WORKER)

目标:在多个 serverless 平台各部署一份相同镜像(各自不同的 HTTPS 入口),最前面用一个免费 Cloudflare Worker 作为唯一对外入口 + 故障转移;所有实例共享同一个外部 Redis。双活(round-robin)或主备(active-standby)均可。业务代码无需改动——之所以可行,是因为既有设计已满足前提(见下)。

10.1 拓扑(ARCH-LB-WORKER)

Telegram setWebhook ─┐
Stalwart PushSub ────┼─▶ https://lb.<you>.workers.dev      ← 唯一登记 URL(C-LB-SINGLE-REG-URL)
外部 Cron ───────────┘        │  Cloudflare Worker(免费)
                              ├─▶ https://a.<platform1>/…  ─┐
                              └─▶ https://b.<platform2>/…  ─┼─▶ 同一个外部 Redis(C-REDIS-ONLY-STATE)
                                 (同一镜像、同一配置、同一组 secret)─┘
  • 只登记 Worker 的稳定 URL(C-LB-SINGLE-REG-URL):Telegram setWebhook、Stalwart PushSubscription.url、外部 Cron 都指向 Worker(https://lb.example.com/webhook/tg、/push/jmap、/reconcile)。后端易变的平台入口不对外登记。
  • 后端 = 与 §3 相同的镜像、相同 env、相同 Redis;不引入长连接(Worker 纯请求-响应,C-NO-LONG-CONN)。

10.2 为什么可直接支持多实例(既有前提)

前提 ID 作用
状态仅外部 Redis C-REDIS-ONLY-STATE 任意实例可服务任意请求,无需粘性会话
幂等去重 MOD-DEDUP 重复/重试投递不会重复通知,故"多实例 + 重试"安全
对账补差 FLOW-RECONCILE 兜住 Push 丢失 / cold-start 窗口

因此 LB/HA 是纯运维拓扑变化;领域逻辑与渠道层无需改动。

10.3 信任模型 = 透传(A)(SAF-LB-PASSTHRU)

  • Worker 原样转发 header / body / pushSubscriptionId / verificationCode,不做鉴权改写。
  • 后端必须继续 fail-closed 校验三条入口(SAF-AUTH-*)——不可省。原因:小平台通常不提供防火墙/ACL,后端 HTTPS 入口可能被公网直连;因此"仅靠 Worker 防护"不成立(方案 B「后端不对公网暴露」在本场景不可行)。
  • 多实例必须共享业务鉴权配置(C-LB-SHARED-SECRETS):TG_WEBHOOK_SECRET / RECONCILE_TOKEN 在所有实例上完全一致;Push verification 状态由 Redis 共享。

10.4 路由与故障转移

  • 路由 safelist(C-LB-SINGLE-REG-URL):Worker 只透传 18 条(SAFE_ROUTES at backends.js:10-30 + ROUTE_METHODS at index.js:54-73):GET /、/assets/config.js、/assets/styles.css、GET /api/status、GET|PUT /api/config、GET|PUT /api/business-config、POST /api/business-config/preflight、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、POST /worker(两条都是外部 cron 触发的排空端点,Bearer 鉴权,见 §6.3.1)、GET /ready、GET /healthz(源站自身健康检查;LB 自己的聚合探针在 /healthz-worker,不在列);未知路径 404、method 不符 405,不透传至后端。后端另有 2 类不在 safelist:POST /api/bootstrap(一次性信任引导,不能走 Worker 域名)、/debug/*(7 条远程联调面,SAF-DEBUG-GATE,只能直连后端 origin,见 §2.1)——这 2 类都不承载外部业务流量,因此后端实例前不需要第二道入口。GET|PUT /api/enabled 属于必须透传的例外:管理 SPA 本身只部署在 Worker 域名下,业务总开关由它读取与切换(loadEnabled 在 web/config.js:676 读、开关写入在 :1048),缺了这条白名单,SPA 里那个开关在后端直连模式下可用、经网关部署时恒 404;该路由已强制 admin-session Bearer 鉴权,暴露面与同在白名单内的 /api/admin/session 完全一致。GET|PUT /api/business-config 与 POST /api/business-config/preflight 同属此类必须透传的路由:SPA 由 Worker 服务,配置回读、提交前校验与保存都由它发起,缺了这两条,回读与预校验经网关部署时恒 404/405,整条配置管理页面只剩后端直连可用;两者同样强制 admin-session Bearer 鉴权,且回读只返回密钥存在性布尔(SAF-NO-SECRET-ECHO),不新增凭据暴露面。/api/status 只返回启动状态和缺少的环境变量名;所有管理 API 的 Bearer 鉴权由后端执行(SAF-LB-PASSTHRU)。
  • 健康聚合(MOD-HEALTH-AGG):Worker 自行承载 GET /healthz-worker,按 TTL 缓存(默认 30s,LB_HEALTH_TTL_MS 可调)探测各后端 /healthz,返回 {status, version, available, total, backends:[{origin,up,status}]}(version 取 LB_VERSION,用于确认线上是哪次部署在回答);≥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 与 POST /worker 固定 maxAttempts=1,绝不故障转移——前者切实例只会立刻撞 409(集群级单飞锁),后者切实例只会重复排同一批消息;这两条也各有独立超时:LB_RECONCILE_TIMEOUT_MS(默认 320000 ms)与 LB_WORKER_TIMEOUT_MS(默认 300000 ms = 后端单条事件下界),其余路由不受影响。
  • 随机分摊:起点 origin 按 Math.random 随机化,实现双活;单 origin 配置时退化为确定性。
  • 全失败兜底:返回 503 All Backends Unavailable,交由 Telegram / Stalwart 自动重投(不丢消息)。
  • 超时预算(LB_REQUEST_TIMEOUT_MS,默认 10s)> 最坏 cold start。
  • 重复兜底:Worker 重试 + Telegram 重投造成的重复由 MOD-DEDUP 吸收;幂等键 TTL 必须覆盖 Worker 重试与 Telegram 重投窗口。
  • 不代理 Redis/JMAP(C-NO-DB / C-REDIS-ONLY-STATE):Worker 只做请求转发,后端各连自己的 Redis;Worker 不做数据库侧检查。
  • 无长连接(C-NO-LONG-CONN):纯请求-响应,body 一次性 arrayBuffer 回灌;无 WS/SSE/长轮询。

10.5 各入口差异(重要)

入口 是否可扇出/重复 处理
/webhook/tg 可安全重复(update_id 去重) Worker 选一实例;重试安全
/push/jmap 可安全重复((account,email_id) 去重) Worker 选一实例
POST /api/push/register 幂等(相同 callback URL 复用) 已进 safelist,可走 Worker 域名;callback URL 由调用方提供、订阅记录全存共享 Redis,落到哪个后端实例都不影响
/reconcile ❌ 不可扇出 Redis 锁(SAF-RECONCILE-LOCK)保证同一时刻仅一个实例执行,避免重复对账
Streams worker 用相同消费组名(MOD-STREAMS-GROUP) Redis XREADGROUP 自动分摊给多实例;at-least-once 下同一消息不会重复处理

10.6 聚合健康视图(MOD-HEALTH-AGG)

  • Worker 额外暴露聚合端点,报告各后端存活数与整体可用性,供外部监控/告警使用;比单实例探针更有意义。

10.7 边界与不在本方案范围

  • Redis 单点故障不在本方案范围(NFR-HA-MULTI-INSTANCE):由用户在外部解决;其短暂不可用可接受(Redis 全丢可由 FLOW-RECONCILE 从 JMAP 重建 sinceState;dedup/会话丢失只导致少量重复通知,符合 NFR-NOTIFY-SLA)。
  • Cloudflare Worker 免费额度:预期足够(requests/day、subrequest、CPU/超时上限需上线后实测确认)。
  • 不引入长连接、不引入平台特定绑定(C-NO-LONG-CONN/NG-SERVERLESS-BIND)。

10.8 部署 Cloudflare Worker(ARCH-LB-WORKER 具体步骤)

代码位于仓库子目录 cloudflare-worker/,非本 Rust 二进制的一部分;Worker 只做请求转发,不运行 Redis/JMAP,不引入数据库(C-NO-DB)。

前置

  • 已按 §3 部署 ≥1 个后端镜像(多实例则部署 2+ 个),各后端 PORT/路由同 §6.1。
  • 后端各实例 env 必须共享同一组 secret(C-LB-SHARED-SECRETS)。
  • 已注册 Cloudflare 账号与 Workers 计划。

配置(origin 列表当前是 cloudflare-worker/wrangler.toml 的 [vars],见下方明文例外)

cd cloudflare-worker
npm install --no-audit --no-fund
npx wrangler deploy
# 可选(带默认值,dashboard 的 Add Variable 或 CLI 二选一)
npx wrangler secret put LB_REQUEST_TIMEOUT_MS   # 默认 10000ms
npx wrangler secret put LB_MAX_ATTEMPTS         # 默认 2(首次 + 1 次故障转移)
npx wrangler secret put LB_RECONCILE_TIMEOUT_MS # 默认 320000ms,仅 POST /reconcile 生效
npx wrangler secret put LB_WORKER_TIMEOUT_MS    # 默认 300000ms,仅 POST /worker 生效
npx wrangler secret put LB_HEALTH_TTL_MS        # 默认 30000ms

wrangler.toml 里的当前值:

[vars]
BACKEND_ORIGINS_JSON = '["https://messageweave-eu-1.motofans.club"]'
LB_VERSION = "2026.10.3"

明文例外及其边界:origin 只是公开可达的 https 地址,不是凭据,所以可以随仓库走(好处是 配置在 PR 里可审,预览环境也能拿到)。一旦要放内网地址、带内嵌凭据的 URL、或不想公开的 staging 主机名,立刻从 [vars] 删掉、改用 secret 注入(git 历史不可逆,删掉不等于没进过库):

npx wrangler secret put BACKEND_ORIGINS_JSON

BACKEND_ORIGINS_JSON 示例(仅允许 https:// origin,其它会让配置解析失败,C-HTTPS-INBOUND):

[
  "https://messageweave-a.example.com",
  "https://messageweave-b.example.com"
]

必须是字符串数组,不是 {"url":…, "weight":…} 对象数组。 cloudflare-worker/src/backends.js 的 parseBackendOrigin 只认 typeof raw === "string" 的 origin, 对象项会让整份配置解析失败;权重也不存在——故障转移是 cloudflare-worker/src/lb.js:107 用 rng() 均匀选一个随机起始源再顺序尝试,没有加权。 症状值得留意:配置是 cloudflare-worker/src/index.js:105 每个请求重新读取并解析的, 所以写错不会「启动即 503」,而是每个请求都返回 503(misconfigured backends), 日志里也没有异常栈可看。

照抄会踩的两条实测坑

  1. wrangler.toml 只能用 # 注释。 TOML 不认 JSDoc 的 /** */,* 会被报成 Unknown character "47",deploy / secret put / dev 在读配置阶段就失败,根本走不到你的 secret。 这条已修掉:wrangler.toml 现已全部展开成 # 行,wrangler deploy --dry-run 通过(上传 31.48 KiB)。
  2. npm test 在 Node 22.23 上原先跑不起来:脚本写的是 node --test test/,Node 会把 test/ 当成模块路径去 require,报 MODULE_NOT_FOUND。已改成 node --test(不加参数自动发现 test/), 当前为 37 个用例全通过(cloudflare-worker/test/backends.test.js 6 / cloudflare-worker/test/health.test.js 5 / cloudflare-worker/test/index.test.js 14 / cloudflare-worker/test/lb.test.js 12;index 含 /reconcile 与 /worker 两条 per-route 长超时回归,lb 含 2 条不可变响应头回归,health 含 1 条 version 回显)。

部署与验证

cd cloudflare-worker
npx wrangler deploy --env production
# 验证 LB 健康聚合
curl https://<your-worker>.workers.dev/healthz-worker

Telegram / Stalwart / Cron 登记的唯一 URL 改为 https://<your-worker>.workers.dev/webhook/tg 等(C-LB-SINGLE-REG-URL);各后端平台的 HTTPS 入口不再对外登记(小平台入口可能被公网直连,故后端鉴权不可省,SAF-LB-PASSTHRU)。

验证(发布前必做)

  1. cd cloudflare-worker && npm test(Node 单测,代理逻辑 + 健康聚合 + 超时/失败重试 + safelist)。
  2. npx wrangler deploy --env production 后 curl <worker-url>/healthz-worker 看响应体里的 available: 必须 available ≥ 1 才算接通;同时核对 version 等于 wrangler.toml 里的 LB_VERSION, 不等就说明跑的还是旧部署。 ⚠️ 只看 HTTP 200 会误判:/healthz-worker 是 Worker 自己生成的(不透传后端,cloudflare-worker/src/index.js:87-91), 而且 BACKEND_ORIGINS_JSON 缺失、格式错或为空时,handleHealth 会吞掉解析异常并返回 HTTP 200 + {"status":"no-backends","available":0,"total":0}(cloudflare-worker/src/lb.js:155 的 catch)。 所以「200」本身不证明配置正确,必须看 available。判据速查: status=ok 且 available≥1 → 正常;no-backends(200)→ secret 没设好或 JSON 格式错;down(503)→ 后端全挂。
  3. curl -X POST https://<worker>/webhook/tg -H "Content-Type: application/json" -d '{"test":1}' 应得到后端返回(非 503)。
  4. curl -X POST https://<worker>/unknown 应得到 404;curl -X GET https://<worker>/reconcile 应得到 405。
  5. 日志仅出现 method/path/origin/失败类别,绝不含 header/body/secret(SAF-LOG-PURITY)。

边界说明

  • Worker 未实现 /reconcile 的 Redis 锁(SAF-RECONCILE-LOCK)——该锁由后端在 /reconcile 处理器内部执行;Worker 只做透传。详见 SAF-RECONCILE-LOCK 条目。
  • Worker 不代理 Redis、不检查数据库侧可用性(C-NO-DB / C-REDIS-ONLY-STATE)。
  • Worker 不落地 JMAP/Telegram 的 secret;Push verification 由后端通过 JMAP API 动态完成并只在 Redis 短期保存状态。

10.9 用 Cloudflare 网页版部署(Dashboard / Git 集成)

CLI 不是唯一入口。两条网页路径:

路径 适合 注意
Workers & Pages → Create application → Code Editor 首次部署、4 个文件手工粘贴 代码无 git 追溯,改动后容易和本仓库漂移
Git 集成(Workers & Pages → 选 GitHub 仓库) 长期最干净,push 自动部署 必须设根目录,否则跑在仓库根上找不到 wrangler.toml

Git 集成必须改的四个字段(Cloudflare 的默认值对不上本仓库):

字段 默认值(❌) 应改成(✅)
根目录 / Root directory(在「高级设置」里) 仓库根 cloudflare-worker
应用名 UI 里自填的名字 与 wrangler.toml 的 name 一致,否则 wrangler 会部署成另一个名字
构建命令(可选) npx wrangler deploy npm test
部署命令(必需) npx wrangler deploy 保持不动
预览命令(勾选「启用预览构建」后为必需) npx wrangler preview npx wrangler dev --ip 0.0.0.0 --port 8787

四条理由(全部实测):

  1. 根目录是决定性的一步。 本仓库根是 Rust 后端,Worker 在 cloudflare-worker/ 子目录。 不设根目录时 Cloudflare 在仓库根跑 wrangler,那里没有 wrangler.toml,构建直接失败。 「高级设置」默认是折叠的——展开它,根目录就是里面的字段;设了之后, 构建 / 部署 / 预览三个命令都在 cloudflare-worker/ 里执行。
  2. 应用名要跟 wrangler.toml 的 name 对齐。 wrangler 部署用的是 toml 里的 name, UI 上的名字不一致时,你部署出去的是 toml 里那个名字的 Worker,UI 上那个应用永远等不到部署。
  3. 构建命令推荐填 npm test,不必 npm ci。 部署命令里的 npx wrangler deploy 已经把活干完了:wrangler 自己用 esbuild 把 4 个源文件打成一个文件再上传(实测 --outdir 产物是单个 index.js,33,078 字节 + sourcemap;src/ 里零外部依赖), 所以「构建」和「部署」在本项目是同一个动作,构建命令留空也不会失败。填 npm test 的价值不在打包:Cloudflare 的默认安装步骤实测跑 bun install(CI 日志 bun@1.2.15, nodejs@24.18.0 → Saved lockfile),bun 不读 npm 的锁文件、按自己的解析 装依赖,仓库里已提交的锁文件被完全绕过;而 npm test 是 32 个测试构成的部署前门禁, 不依赖 node_modules(零外部依赖,空目录里也能跑通 32/32,实测 2.6 秒),所以无论 谁来装依赖都有效。想严格锁定 wrangler 版本就换成 npm ci --no-audit --no-fund && npm test,代价是每次重新下载安装 35 个包(实测约 86 秒)。 npx wrangler deploy 属于部署命令字段(必需,保持默认),把 deploy 塞进构建命令才是错的。
  4. wrangler preview 子命令不存在。 wrangler 4.144.0 的子命令只有 dev / deploy / deployments,默认那个值填了必然失败。预览命令填 npx wrangler dev --ip 0.0.0.0 --port 8787 (8787 是 wrangler dev 的默认端口,--ip 0.0.0.0 让 Cloudflare 的预览代理能连进来)。 ⚠️ 但预览环境拿不到 dashboard 里加的 secret:现在 BACKEND_ORIGINS_JSON 写在 wrangler.toml 的 [vars] 里,预览因此也能跑通转发;一旦改回 secret 注入,预览就只剩 「Worker 能被拉起 + 路由表生效 + /healthz-worker 返回 200 但 status 是 no-backends」, 不能验证后端转发,转发只能看生产。(仓库里也不该有 .dev.vars,它只对 wrangler dev 生效。)

Node 版本不用管。 Cloudflare 环境实测给的是 nodejs@24.18.0,本地在 Node 24.21.0 与 22.23.3 上都验过(32/32 通过)。wrangler 已升到 4.144.0:3.114.17 在 CI 里会打印 The version of Wrangler you are using is now out-of-date … to prevent critical errors, 4.144.0 在该警告消失,且 wrangler 4 与 wrangler 3 一样会按 wrangler.toml 的 name 部署(toml 里的 name = "messageweave-lb" 保持不变即可)。lockfile 锁定 wrangler 4.144.0, 但 CI 的默认安装走 bun、不读这个锁文件,所以实际版本由 ^4.144.0 浮动(今天正好解析到 4.144.0);要把版本钉死,就把构建命令换成 npm ci --no-audit --no-fund && npm test。

secret 仍在网页上配:Workers & Pages → 你的应用 → Settings → Variables & Secrets → Encrypt(不是 Plain Text)→ 加 BACKEND_ORIGINS_JSON。wrangler.toml 的 vars = {} 意味着所有配置都走 secret,网页版对应位置是 Settings → General 的 Variables(留空)与 Encrypt(填 secret)。改 secret 即时生效,不需要重新部署。

部署前确认已 push:Git 集成拉的是 GitHub 上的 commit,本地改动不 push 不会生效。

Git 集成的一个纪律:每次 push 到默认分支都自动部署,所以绝不把 .dev.vars 或 任何明文 secret 提交进仓库(本仓库里没有,.gitignore 也排除了)。

验证方式与 CLI 完全相同,见上方「验证(发布前必做)」,尤其第 2 条: 看 /healthz-worker 响应体的 available,不要只看 HTTP 200。


附:稳定 ID 引用索引

跨文档稳定 ID 的唯一权威索引表在 docs/charter.md §8「稳定 ID 注册表」。 本文使用到的 ID(C-* / NG-* / MOD-* / FLOW-* 等)均在其中登记定义点位置与一句话说明;内容搬家时只改 charter.md 索引表的"文件/锚点"列,所有引用本身零改动。