Skip to content

Commit 15a8eee

Browse files
committed
feat(B3.2): 运行时配置热更 + 管理台设置页
新增 runtime_settings 表与 RuntimeSettings 覆盖层:热更项读 DB 覆盖值、 其余透明委托 Settings,**DB 覆盖 > .env**。管理台新增第 7 页「运行时配置」, 改完立即生效、无需重启(推翻 PROPOSAL Q11「无设置页」)。 - config.live():标量/零参 callable 归一成取值器,测试仍传标量, 避免为热更改动全部 Settings()/Scheduler() 构造点 - 热更 13 项:默认模型、模型黑名单、到期主/次窗口、会话粘性 TTL、 成长不可逆开关、成长与探测周期、pacer min/max、CB 聊天最小间隔、 活跃上报开关与时点 - 消费端每轮/每请求现读:Scheduler(含 expiry_windows 归一化)、 ConversationAffinity TTL、Pacer(校验挪到读取期)、Executor default_model/max_auto_continues、GrowthTask、TaskRunner 周期; 活跃上报由「关就不装配」改为恒建对象 + 每轮 gate,才能热开 - GET/PUT /api/settings(admin + CSRF + 审计日志),组合校验 (pacer_min ≤ pacer_max)按「这一批写完之后」的对端值,失败整批不落库 - SCHEMA_VERSION 9→10(纯加表);老库升级测试补断言 - 文档:README 新增「管理台热更」小节、TECHNICAL §3.8、PROPOSAL Q34 后端 1256 passed / 行+分支 100%;前端 tsc + vitest(125) + build 全绿。
1 parent 28022eb commit 15a8eee

29 files changed

Lines changed: 1741 additions & 75 deletions

‎PROPOSAL.md‎

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -18,7 +18,7 @@
1818
| Q8 | 协议出口 | v1 仅 OpenAI;v1.1 加 Anthropic |
1919
| Q9 | 存储 | SQLite,schema 重新设计,凭证入加密列 |
2020
| Q10 | 配额 | 不做配额,只做按人统计 |
21-
| Q11 | 前端范围 | 中等 6 页:Dashboard / 凭证 / API Key / 统计 / Playground / 登录(无设置页,配置走 env) |
21+
| Q11 | 前端范围 | Dashboard / 凭证 / API Key / 统计 / Playground / 运行时配置 / 登录(Q34 推翻「无设置页」:13 项可热更配置走管理台,其余配置仍走 env) |
2222
| Q12 | 调度策略 | 统一健康度 + 到期积分指标 + 冷却状态机,保留手动 pin |
2323
| Q13 | Anthropic | v1.1,架构预留中立事件层 |
2424
| Q14 | 测试 | 全量 100%(行 + 分支),契约测试优先 |
@@ -39,6 +39,7 @@
3939
| Q31 | 到期积分排序 | 两级字典序:主窗口(36h)+ 次窗口(7 天)内到期积分总量依次做排序键(均 env 可配);落库到期阶梯而非单一日期 |
4040
| Q32 | Responses 出口 | v1 只做 `chat/completions` 子集:`POST /v1/responses` 与 chat 共用同一 executor,出口 translator 可注入;`include`/`store`/`previous_response_id` 按 Codex CLI 实测取舍(见 TECHNICAL §3.7) |
4141
| Q33 | 凭证暂停语义 | 复用现有 `enabled`(不新增 `manual_disabled` 列):`enabled=0` 实测已只摘对话流量,后台任务(签到/刷新/成长/探测)只认 `disabled`;UI 文案统一为「暂停/取消暂停」以区别于系统禁用后的「恢复」(见计划 B3.1 实测收窄) |
42+
| Q34 | 运行时配置热更 | **推翻 Q11 的「无设置页」**:新增 `runtime_settings` 表 + `RuntimeSettings` 覆盖层 + 管理台第 7 页「运行时配置」。白名单 13 项(默认模型 / 模型黑名单 / 到期两个窗口 / 会话粘性 TTL / 成长不可逆开关 / 成长与探测周期 / 两个节流窗口 / CB 聊天最小间隔 / 活跃上报开关与时点)改完立即生效,无需重启;**DB 覆盖值优先于 .env**,UI 与日志明示,可「恢复默认」清掉覆盖。启动期项(密钥 / 端口 / 数据目录 / 上游白名单)不进白名单——它们决定进程如何启动,运行期改只会让内存与磁盘静默分叉 |
4243

4344
## 2. 目标与非目标
4445

‎README.en.md‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -26,6 +26,7 @@ upstream channels, with a shared credential pool, unified scheduling, and per-us
2626
- **Hardened admin surface**: login rate limiting (global/IP/username + PBKDF2 concurrency cap), CSRF checks on writes, request body limits, security headers, Host allowlist
2727
- **Model catalog hygiene**: `MODEL_BLOCKLIST` filters placeholder/legacy models; cached model list as fallback when upstreams fail; credit rates and token limits passed through to `/v1/models` and the Playground
2828
- **Per-credential pause**: the admin credential menu's "Pause" removes one credential from *chat traffic only* — quota probing, token refresh, daily check-in, growth-center and activity-report tasks keep running (they only honor the system hard-disable `disabled`). Unlike "Disabled", which means the upstream rejected the session and requires re-login plus "Restore"
29+
- **Runtime-configurable settings**: 13 settings (default model, model blocklist, both expiry windows, conversation-sticky TTL, irreversible growth actions, growth/probe intervals, both pacer bounds, CodeBuddy chat interval, activity-report toggle/hour) can be changed from the admin UI's *Runtime settings* page and take effect immediately — no restart. **DB overrides win over `.env`**; the page marks each row as "DB override" and offers "Reset to default" to fall back to `.env`. Startup-only knobs (`APP_SECRET`, `PORT`, `DATA_DIR`, allowlists) are deliberately excluded
2930

3031
## Quick start
3132

‎README.md‎

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -187,6 +187,19 @@ CodeBuddy 成长中心的「连登天数 / 活跃地图」按日统计客户端
187187
| `AUTO_CONTINUE_MAX` | `10` | 上游以 `finish_reason=length` 截断时同凭证自动续写的最多次数;`0` 关闭(见 TECHNICAL.md §3.4) |
188188
| `HOST` / `PORT` | `127.0.0.1` / `8000` | 监听地址与端口(compose 默认 `0.0.0.0`,`PORT` 同时决定宿主机映射端口) |
189189

190+
### 管理台热更(运行时配置)
191+
192+
上表中带「可热更」语义的 13 项可以不改 `.env`、不重启,直接在管理台「运行时配置」页修改:
193+
194+
`DEFAULT_MODEL`、`MODEL_BLOCKLIST`、`QUOTA_EXPIRY_WINDOW_SECONDS`、`QUOTA_EXPIRY_SECONDARY_WINDOW_SECONDS`、`CONVERSATION_STICKY_SECONDS`、`GROWTH_IRREVERSIBLE_ACTIONS`、`GROWTH_INTERVAL_MINUTES`、`QUOTA_PROBE_MINUTES`、`CODEBUDDY_CHAT_MIN_INTERVAL`、`PACER_MIN_SECONDS`、`PACER_MAX_SECONDS`、`ACTIVITY_REPORT_ENABLED`、`ACTIVITY_REPORT_HOUR`。
195+
196+
要点:
197+
198+
- **优先级 `DB 覆盖值 > .env`**:改过之后 .env 对该项不再生效,页面会标「DB 覆盖」;「恢复默认」删掉覆盖行,才重新回落 .env。日志同步记录是谁改的。
199+
- 值存 `runtime_settings` 表(纯 key/value),新增可热更项不需要迁移;白名单外的 key、非法类型/越界值在写入前被拒,读取时坏行跳过并记警告。
200+
- 启动期项(`APP_SECRET` / `PORT` / `DATA_DIR` / `USERS_FILE` / 上游端点白名单)**不在**白名单,改它们仍需重启:它们决定进程如何启动,运行期变更只会让内存与磁盘静默分叉。
201+
- 接口:`GET /api/settings` 读快照,`PUT /api/settings` 写(admin + CSRF),body 形如 `{"values": {"QUOTA_PROBE_MINUTES 对应的 key": 15}}`,传 `null` 表示恢复默认。
202+
190203
## 部署注意
191204

192205
- **挂载目录属主**:容器内以 uid 1001(`appuser`)运行,`./data` 与 `./secrets`

‎TECHNICAL.md‎

Lines changed: 52 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -27,7 +27,8 @@ coding2api/
2727
├── pyproject.toml # uv 项目;[tool.pytest.ini_options] 设 coverage 目标
2828
├── src/
2929
│ ├── main.py # FastAPI 组装、lifespan、路由挂载(只做接线)
30-
│ ├── config.py # pydantic-settings:README「配置」全部 env
30+
│ ├── config.py # pydantic-settings:README「配置」全部 env;live() 归一化标量/取值器
31+
│ ├── runtime_settings.py # 运行时配置覆盖层(B3.2):DB 覆盖 > env,白名单 + 校验 + snapshot
3132
│ ├── webapp/ # HTTP 边缘层(横切关注点,与业务装配分开)
3233
│ │ ├── limits.py # 请求体上限 ASGI 中间件(登录 8KB / 其余 16MB)
3334
│ │ ├── security.py # Host 白名单 + 安全响应头(CSP/nosniff)
@@ -98,6 +99,7 @@ coding2api/
9899
│ ├── authorize.py # GET /authorize(TRAE 回调落点)
99100
│ ├── admin_credentials.py # 凭证 CRUD / toggle / pin / probe / checkin / 成长中心 / 账号切换
100101
│ ├── admin_keys.py # API Key CRUD
102+
│ ├── admin_settings.py # 运行时配置:GET 快照 / PUT 覆盖(admin + CSRF,B3.2)
101103
│ ├── admin_stats.py # 统计查询(overview / by-provider / timeline / model-timeline)
102104
│ ├── admin_auth.py # 登录 / 登出 / 会话;上游登录 start/poll/cancel
103105
│ ├── playground.py # 会话调试端点(无需 API Key)
@@ -392,6 +394,55 @@ item 类型(`local_shell_call`/`custom_tool_call` 等)显式 400。
392394

393395
---
394396

397+
### 3.8 运行时配置热更(B3.2,推翻 Q11「无设置页」)
398+
399+
**问题**:12+ 项运行期语义的配置(默认模型、到期窗口、节流区间、后台周期、活跃上报开关)此前只在
400+
`build_app` 启动时读一次并烘焙进对象(`Scheduler(...)`、`Pacer(...)`、`growth_interval_minutes=...`),
401+
改一项要重启整个进程;管理台没有任何入口。
402+
403+
**分层**(这是设计核心,不是实现细节):
404+
405+
| 类别 | 例子 | 位置 | 变更方式 |
406+
|---|---|---|---|
407+
| 启动期不可变项 | `APP_SECRET` / `HOST` / `PORT` / `DATA_DIR` / `USERS_FILE` / `CODEBUDDY_ALLOWED_ENDPOINTS` | `config.Settings`(frozen) | 改 env + 重启;**不进白名单**,管理台改不了 |
408+
| 运行时可覆盖项 | 见 `runtime_settings.HOT_SETTINGS`(13 项) | `RuntimeSettings` 覆盖层 | 管理台改,立即生效 |
409+
410+
启动期项之所以拒绝热更:它们决定进程如何启动(监听地址、加密密钥、上游白名单),
411+
运行期变更只会让「当前进程」与「磁盘配置」静默分叉,而分叉后的行为无法从任一处推断。
412+
413+
**生效优先级**:`runtime_settings` 表(DB 覆盖)> `.env`(默认值来源)。
414+
覆盖行只有被管理台改过的 key;「恢复默认」= 删除该行,不是写入 env 当前值。
415+
UI 与日志都必须明示「DB 覆盖 .env」,否则用户改 `.env` 不生效会当成 bug。
416+
417+
**读取机制**:`RuntimeSettings` 对热更 key 返回覆盖值,其余属性 `__getattr__` 透明委托给
418+
`Settings`——调用方仍写 `settings.default_model`,不必感知覆盖层。覆盖值在内存缓存,
419+
写入后 `reload()` 刷新;读取路径不查库(每次选号/建请求都查库的开销远超热更省的收益)。
420+
421+
**消费端**:`config.live(value)` 把「标量或零参 callable」统一成取值器。生产装配传
422+
`lambda: runtime.xxx`(每次读当前值),测试与一次性任务仍传标量。已接线的热更点:
423+
424+
- `Scheduler.expiry_window` / `secondary_expiry_window`:读取时跑 `expiry_windows()`
425+
归一化(主窗口 ≤0 → 次窗口一并归零),避免只热更主窗口导致次窗口单独排序
426+
- `ConversationAffinity.ttl_seconds`:存量条目按写入时的到期时刻失效,调小 TTL 不会让旧条目突然作废
427+
- `Pacer.min/max_seconds`:校验从构造期挪到读取期(覆盖层把下限改到上限之上只能在用时拒绝),
428+
但构造时仍校验一次尽早失败
429+
- `Executor`:`default_model` / `max_auto_continues` 每次请求现读
430+
- `GrowthTask.allow_irreversible`:每轮现读
431+
- `TaskRunner` 周期(探测/成长/刷新/清理)与活跃上报开关、时点:每轮 `sleep` 前现读
432+
- 活跃上报改为**恒建对象**(构造成本为零),由 `TaskRunner._activity_enabled` callable 每轮 gate:
433+
只有对象先存在,管理台才能把默认关闭的它热开到不需要重启
434+
435+
**校验**:白名单外 key 直接拒绝;类型(`int`/`float`/`bool`/`str`)与范围(min/max)在写入前校验;
436+
跨字段组合(`pacer_min ≤ pacer_max`)用「这一批写完之后」的对端值比较,校验失败则整批不落库
437+
(半新半旧的组合比拒绝更糟)。表里的坏行(白名单外/类型非法)在读取时跳过并记警告——
438+
一行坏数据不能让服务起不来。
439+
440+
**接口**:`GET /api/settings`(admin)返回 `snapshot()`(`key`/`env_name`/`label`/`description`/
441+
`kind`/`value`/`default`/`overridden`)+ 覆盖计数;`PUT /api/settings`(admin + CSRF)body
442+
`{"values": {key: 标量 | null}}`,`null` 表示恢复默认。写操作记审计日志(谁改了哪些 key)。
443+
444+
---
445+
395446
## 4. Provider 协议(Q16=A 细接口)
396447

397448
```python

‎src/api/admin_settings.py‎

Lines changed: 50 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,50 @@
1+
"""运行时配置热更:读取覆盖层快照 / 写入覆盖值(B3.2)。
2+
3+
语义约定:**DB 覆盖值优先于 .env**。管理台写下的值存进 runtime_settings 表,
4+
进程重启后依然生效;要回到 .env 的默认值,把该项「恢复默认」(提交 null)。
5+
界面与日志都必须明示这一点,否则用户会以为改 .env 就能改回来。
6+
"""
7+
8+
from __future__ import annotations
9+
10+
import logging
11+
12+
from fastapi import APIRouter, Depends
13+
14+
from ..auth.rbac import require_admin
15+
from ..compat.openai.request import InvalidRequest
16+
from ..runtime_settings import InvalidSetting
17+
from .deps import Services, csrf_protected, principal_from_request
18+
19+
logger = logging.getLogger(__name__)
20+
21+
22+
def create_router(services: Services) -> APIRouter:
23+
router = APIRouter()
24+
runtime = services.settings
25+
26+
@router.get("/api/settings")
27+
async def list_settings(principal=Depends(principal_from_request)):
28+
require_admin(principal)
29+
return {
30+
"settings": runtime.snapshot(),
31+
"overridden": sum(1 for item in runtime.snapshot() if item["overridden"]),
32+
}
33+
34+
@router.put("/api/settings")
35+
async def update_settings(payload: dict,
36+
_csrf: None = Depends(csrf_protected),
37+
principal=Depends(principal_from_request)):
38+
require_admin(principal)
39+
values = payload.get("values")
40+
if not isinstance(values, dict):
41+
raise InvalidRequest("values must be an object")
42+
try:
43+
runtime.set_many(values)
44+
except InvalidSetting as error:
45+
raise InvalidRequest(str(error)) from error
46+
keys = ", ".join(sorted(values)) or "(空)"
47+
logger.info("管理员 %s 更新运行时配置 %s(DB 覆盖 .env)", principal.username, keys)
48+
return {"settings": runtime.snapshot()}
49+
50+
return router

‎src/api/deps.py‎

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -23,6 +23,7 @@
2323
from ..config import Settings
2424
from ..db.repo import ApiKeyRepository, CredentialRepository, GrowthRepository
2525
from ..engine.executor import Executor
26+
from ..runtime_settings import RuntimeSettings
2627
from ..stats.query import StatsQuery
2728

2829
SESSION_COOKIE = "coding2api_session"
@@ -37,7 +38,7 @@ class Services:
3738
统一迁移会扩大改动面而无实际收益。
3839
"""
3940

40-
settings: Settings
41+
settings: Settings | RuntimeSettings
4142
credentials: CredentialRepository
4243
growth_events: GrowthRepository
4344
api_keys: ApiKeyRepository

‎src/config.py‎

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,9 @@
55

66
from __future__ import annotations
77

8+
from collections.abc import Callable
89
from functools import cached_property
10+
from typing import Any
911

1012
from pydantic_settings import BaseSettings, SettingsConfigDict
1113

@@ -124,3 +126,15 @@ def load_settings(env: dict[str, str] | None = None) -> Settings:
124126
def validate_endpoint_allowed(endpoint: str, settings: Settings) -> bool:
125127
"""上游地址必须落在白名单内,否则拒绝发出真实 Token(PROPOSAL §8)。"""
126128
return endpoint.strip() in settings.allowed_endpoints
129+
130+
131+
def live(value: Any) -> Callable[[], Any]:
132+
"""把「标量或零参 callable」统一成取当前值的零参 callable(B3.2)。
133+
134+
热更消费方(调度器 / 节流器 / 后台任务 / 执行器)持有的都是这个封装:
135+
生产装配传零参 lambda 实时读运行时覆盖层,测试仍可传标量。两条路径
136+
共用同一套逻辑,不必为「可热更」把每个构造点都改成传 callable。
137+
"""
138+
if callable(value):
139+
return value
140+
return lambda: value

‎src/db/migrate.py‎

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -16,7 +16,9 @@
1616
SCHEMA_NAME = "schema.sql"
1717

1818
# 当前 schema 版本。新增列/表、删表时 +1,并在下方对应元组里补增量。
19-
SCHEMA_VERSION = 9
19+
# 10:新增 runtime_settings 表(B3.2 运行时配置热更新)。新增表只需进
20+
# schema.sql(CREATE TABLE IF NOT EXISTS 对老库同样生效),无需迁移动作。
21+
SCHEMA_VERSION = 10
2022

2123
# (表, 列定义):历史库升级时逐条补列
2224
_MIGRATION_COLUMNS: tuple[tuple[str, str], ...] = (

‎src/db/repo.py‎

Lines changed: 36 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -401,3 +401,39 @@ def recent(self, credential_id: str, limit: int = 20) -> list[dict[str, Any]]:
401401
"SELECT * FROM growth_events WHERE credential_id = ? ORDER BY ts DESC LIMIT ?",
402402
(credential_id, max(1, limit))).fetchall()
403403
return [dict(row) for row in rows]
404+
405+
406+
class RuntimeSettingsRepository:
407+
"""运行时配置覆盖(runtime_settings):只存管理台改过的 key。
408+
409+
纯 key/value/updated_at 三列,不在这里做类型校验——白名单与取值范围
410+
属于 `src/runtime_settings.py`(配置语义),仓储只负责存取。这样新增一个
411+
可热更项不需要改 schema,与「表结构只加不改」的纪律一致。
412+
"""
413+
414+
def __init__(self, db) -> None:
415+
self._db = db
416+
417+
def load(self) -> dict[str, str]:
418+
rows = self._db.connect().execute(
419+
"SELECT key, value FROM runtime_settings").fetchall()
420+
return {row["key"]: row["value"] for row in rows}
421+
422+
def set(self, key: str, value: str, now: int | None = None) -> None:
423+
with self._db.transaction() as conn:
424+
conn.execute(
425+
"INSERT INTO runtime_settings (key, value, updated_at) VALUES (?,?,?) "
426+
"ON CONFLICT(key) DO UPDATE SET value = excluded.value, "
427+
"updated_at = excluded.updated_at",
428+
(key, value, int(now if now is not None else time.time())),
429+
)
430+
431+
def delete(self, key: str) -> None:
432+
with self._db.transaction() as conn:
433+
conn.execute("DELETE FROM runtime_settings WHERE key = ?", (key,))
434+
435+
def updated_at(self) -> dict[str, int]:
436+
"""key → 最近一次修改时间(界面显示「何时改的」)。"""
437+
rows = self._db.connect().execute(
438+
"SELECT key, updated_at FROM runtime_settings").fetchall()
439+
return {row["key"]: row["updated_at"] for row in rows}

‎src/db/schema.sql‎

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -122,3 +122,16 @@ CREATE TABLE IF NOT EXISTS credential_model_cooldowns (
122122

123123
CREATE INDEX IF NOT EXISTS idx_model_cooling_until
124124
ON credential_model_cooldowns(cooling_until);
125+
126+
-- 运行时配置覆盖(B3.2):只存「管理台改过」的 key,未出现的 key 回落 env。
127+
--
128+
-- 为什么单独建表而不是加列到别的表:键集合随版本演进(新增可热更项不
129+
-- 需要迁移),且 key/value 都是文本,值的类型与取值范围由
130+
-- src/runtime_settings.py 的 HOT_SETTINGS 白名单校验——表本身不做约束,
131+
-- 白名单外/类型非法的行在读取时被忽略并记日志,不让一行坏数据把服务拖崩。
132+
-- 注意:这里存的是**覆盖意图**,不是权威值;env 仍是默认值来源。
133+
CREATE TABLE IF NOT EXISTS runtime_settings (
134+
key TEXT PRIMARY KEY,
135+
value TEXT NOT NULL, -- 统一以文本存储,读时按白名单类型解析
136+
updated_at INTEGER NOT NULL
137+
);

0 commit comments

Comments
 (0)