# 运行时配置(管理面板) `/admin` → **设置** 页可以改绝大多数原本只能写在 `.env` 里的配置。 ## 优先级 ``` .env(进程启动时读一次) ← 默认值 ↓ 被覆盖 wa:settings:runtime(Redis JSON) ← 管理面板写入 ↓ 生效配置(进程内的同一个 cfg 对象) ``` - Redis 里**没有**某一项时,就用 `.env` 的值。 - 面板里把某项改回 `.env` 默认值,会**删除**该覆盖 —— 以后再改 `.env` 又能生效。 - 「全部恢复默认」删除整份 Redis 覆盖文档。 ## 传播 每个节点每 **5 秒** 读一次 `wa:settings:runtime`,有变化才写入本进程配置并推给各服务。 所以:改动在**本节点立即生效**,其他节点**最多 5 秒**。没有用 pub/sub —— 一次 GET 相对请求路径上的 Redis 流量可以忽略,也省掉一条订阅连接。 ## 范围 **不可配置(env-only)**,因为它们要么在能读 Redis 之前就得正确,要么改了会破坏已有数据: | 变量 | 原因 | |------|------| | `REDIS_URL` | 鸡生蛋:覆盖本身存在 Redis 里 | | `LLM_BASE_URL` / `LLM_API_KEY` / `LLM_MODEL` | 平台模型凭证 | | `LLM_PROVIDER_SECRET` | 改了之后所有用户已加密的自定义模型 key 全部解不开 | | `WECHAT_AI_TOKEN` / `LINUXDO_ADMIN_IDS` | 权限根,改错会把自己锁在外面 | | `SESSION_COOKIE_NAME` / `COOKIE_SECURE` | 改了当场踢掉所有会话 | | `PUBLIC_BASE_URL` / `CORS_ORIGINS` | 站点自身 URL 与跨域白名单 | | `WECHAT_AI_HOST` / `WECHAT_AI_PORT` | 监听端口在 `listen()` 时固定 | 其余全部在面板里,包括 `TOOLS_BASE_URL` / `TOOLS_API_KEY`(密钥在 UI 里只显示掩码)。 `apps/api/src/runtime-config.test.ts` 有一条守卫用例:**任何新增的 AppConfig 字段**要么进 `SETTING_SPECS`,要么进那份 env-only 名单,否则测试直接失败。 `DEFAULT_PERSONA_SLUG` 刻意不在面板里:`cfg.defaultPersonaSlug` 在代码里没有任何消费方, 默认人设走的是数据库 `is_default`(管理台「人设」→ 设默认)。放进面板只会得到一个 「保存成功但什么都没发生」的控件。 ## 需重启的项 绝大多数配置是热生效的。以下几项写入 Redis 后会打上橙色「需重启」徽章: | 项 | 为什么 | |----|--------| | `WORKER_ENABLED` | `worker.start()` 只在启动时跑一次 | | `REPLY_CONCURRENCY` | 消费者池在 `start()` 一次性拉起,没有单消费者取消机制,缩容做不到 | | `LOG_LEVEL` | Fastify 创建实例时固化 logger | | `STICKER_MAX_BYTES` | 路由注册时固化 `bodyLimit` | ## 热生效是怎么做到的 `cfg` 对象**按引用**传给 `registerRoutes` 和各个服务,所以: - **路由**:87 处 `ctx.cfg.*` 是每请求读的,原地改 `cfg` 就够了,零改动。 - **在构造时快照选项的服务**:由 `runtime-config-apply.ts` 显式推送 —— `ChatService` / `TryChatService` / `BotWorkerManager` / `ActivityBus` 各有一个 `applyRuntimeOptions`(或 `applyRuntimeConfig`)。 几个需要特殊处理、否则会静默失效的地方: - `ChatService.webSearch` 原本在构造函数里判断一次,`WEB_SEARCH_ENABLED=false` 启动就永远 是 `null`。现在按 tools 配置的指纹惰性重建。 - `BotWorkerManager` 的 10 个 `readonly` 标量改成可写,且 setter 里复刻了构造函数的 clamp —— 面板不能写进一个构造函数本来会拒绝的值(比如 `leaseRenewSec=0`)。 - `leaseRenewSec` 还决定 `setInterval` 周期,改动时重新装载定时器。 - `ProactiveScheduler.globalEnabled` 被 `start()` 闩住(早退且留下 `stopped=true`), false→true 必须重新进 `start()`,光改标志没用。 - `setRedisCommandHook` 现在无条件安装,否则 `DATA_STREAM_ENABLED` 这个开关永远是死的。 ## API **整个页面(含只读)限超管。** 超管 = 仍是管理员的用户里 `created_at` 最早那位。 | Method | Path | 权限 | 说明 | |--------|------|------|------| | GET | `/api/v1/admin/settings/runtime` | **超管** | 分组 + 全部项(当前值 / env 默认值 / 是否已覆盖 / 是否需重启)+ 交叉校验警告 | | PATCH | `/api/v1/admin/settings/runtime` | **超管** | `{ patch: {key: value}, reset: [key] }` | | POST | `/api/v1/admin/settings/runtime/reset` | **超管** | 删除全部覆盖 | 连读也限超管:这个面板能拿到 tools 网关密钥,能整个集群关掉 worker, payload 本身也把每一个调参项和当前生效值列了出来。普通管理员访问返回 403 `super_admin_required`。 前端跟着服务端一起收口,和「节点 / 广播 / 数据流」用同一套三处门禁: `switchTab` 拦截并回落到「系统」页、`boot()` 隐藏侧栏按钮与 `
`、 命令面板(Ctrl/⌘K)过滤掉该条目。 两个写操作都会记审计(`runtime_settings_updated` / `runtime_settings_reset`), 含变更的 key 列表与需重启的 key。 密钥字段(`type: "secret"`)的约定: - GET 只返回掩码 `••••••••`,永远不回真值 - PATCH 传空字符串 = **不修改** - PATCH 传 `-` = **清空** - 把掩码原样回传会被忽略,不会变成字面值 ## 交叉校验 保存时会返回警告(只提示,不改用户填的值):租约 TTL ≤ 续约间隔、延迟上下限倒挂、 `memoryFullInjectMax < memoryTopK`、开了联网但没配 tools 网关、配了网关但密钥为空、 二次过滤与模型直出 JSON 同时开启。 ## Redis 读失败时的行为 「读不到」和「不存在」必须分开——把两者都当成「没有覆盖」会让一次网络抖动 把整个节点悄悄退回 `.env`(此前被面板收紧的开关会瞬间放开)。所以: - **轮询读失败** → 保持上一份已知配置不动,打一条日志,`refresh()` 返回 false。 - **写路径读失败** → 直接拒绝,返回 **503**。绝不能基于失败的读做合并再写回, 那会把其他所有覆盖一起抹掉。 - **例外:全部恢复默认**。它本来就要丢弃旧文档,所以读不到时也允许执行 —— 这是文档损坏时唯一的产品内自救路径。 ## 取值受限的项 `SettingSpec.options` 声明闭集,面板渲染成下拉框,服务端对不在集合里的值直接拒绝 (不做替换,免得把拼写错误藏起来)。目前只有 `LOG_LEVEL` 用到:pino 遇到未知级别会在 Fastify 建实例时抛错,而这一项又是「需重启」,一个拼写错误会在下次重启时让每个节点 反复崩溃,而且管理接口起不来、改不回去。 ## 并发写 `patch()` 是读-改-写:读 Redis 当前文档 → 合并 → 整份写回。合并是**按文档**而不是按字段的, 所以两个超管在两个节点同时保存,后写的那份会带着自己的改动覆盖掉前一份的全部改动。 为此写路径上加了一把短锁 `wa:settings:runtime:lock`(`SET NX EX 5`,最多重试 5 次 × 120ms),把整个集群的读-改-写串起来。锁只在写时用,读路径完全不碰它。 拿不到锁时不阻塞管理员,仍然继续写 —— 锁是降低窗口,不是强一致保证;写操作是超管专属 且极低频,这个取舍是刻意的。 ## 多节点注意 `MAX_BOTS_PER_WORKER`、`LEASE_TTL_SEC`、`REBALANCE_SLACK` 参与的是**集群共享**的租约/再平衡 协议。因为覆盖存在 Redis、所有节点读同一份,各节点最终看到的是同一组值 —— 但在 5 秒收敛 窗口内可能短暂不一致,表现为一次多余的再平衡。改这几项建议避开高峰。