zai / app /admin /config_manager.py
bg5's picture
Upload 85 files
e0b68d2 verified
Raw History Blame Contribute Delete
42.6 kB
"""Admin config metadata and helpers for the configuration console."""
from __future__ import annotations
import re
from dataclasses import dataclass
from pathlib import Path
from typing import Any, Awaitable, Callable, Mapping
from dotenv import dotenv_values
from app.core.config import settings
from app.utils.env_file import update_env_file
from app.utils.logger import logger
ENV_PATH = Path(".env")
ENV_EXAMPLE_PATH = Path(".env.example")
_ENV_SOURCE_LINE_PATTERN = re.compile(
r"^\s*(?:export\s+)?[A-Za-z_][A-Za-z0-9_]*\s*=.*$"
)
@dataclass(frozen=True)
class ConfigFieldSpec:
key: str
label: str
description: str
value_type: str
default_value: object
input_type: str = "text"
placeholder: str = ""
required: bool = False
wide: bool = False
sensitive: bool = False
restart_required: bool = False
min_value: int | None = None
max_value: int | None = None
db_persist: bool = True
options: tuple[str, ...] = () # 仅 input_type="select" 时使用
@dataclass(frozen=True)
class ConfigSectionSpec:
id: str
title: str
description: str
fields: tuple[ConfigFieldSpec, ...]
icon: str = ""
CONFIG_SECTIONS: tuple[ConfigSectionSpec, ...] = (
ConfigSectionSpec(
id="access",
title="接入与认证",
description="控制上游接口地址、客户端鉴权和 Function Call 行为。",
icon="M15 7a2 2 0 012 2m4 0a6 6 0 01-7.743 5.743L11 17H9v2H7v2H4a1 1 0 01-1-1v-2.586a1 1 0 01.293-.707l5.964-5.964A6 6 0 1121 9z",
fields=(
ConfigFieldSpec(
key="API_ENDPOINT",
label="上游 API 地址",
description="代理请求实际转发到的上游聊天完成接口。",
value_type="str",
default_value="https://chat.z.ai/api/v2/chat/completions",
input_type="url",
placeholder="https://chat.z.ai/api/v2/chat/completions",
required=True,
wide=True,
),
ConfigFieldSpec(
key="RESIN_COMPAT_ENABLED",
label="启用 Resin 兼容账号头",
description="为出站请求附加 X-Resin-Account,并使用上游 token 的稳定哈希值作为账号,便于 Resin 按同一 token 复用出口 IP。",
value_type="bool",
default_value=False,
),
ConfigFieldSpec(
key="AUTH_TOKEN",
label="客户端认证密钥",
description="客户端访问本服务时使用的 Bearer Token。",
value_type="str",
default_value="sk-your-api-key",
input_type="password",
placeholder="sk-your-api-key",
wide=True,
sensitive=True,
),
ConfigFieldSpec(
key="SKIP_AUTH_TOKEN",
label="跳过客户端认证",
description="仅建议开发环境使用,开启后不校验 AUTH_TOKEN。",
value_type="bool",
default_value=False,
),
ConfigFieldSpec(
key="UPSTREAM_BACKGROUND_TASKS",
label="上游背景任务",
description="是否允许上游执行标题生成、搜索总结等背景任务(开启可能消耗更多额度/时间)。",
value_type="bool",
default_value=False,
),
ConfigFieldSpec(
key="SESSION_ENABLED",
label="会话管理",
description="启用官方链式会话复用,关闭则每次请求使用独立会话。",
value_type="bool",
default_value=False,
),
ConfigFieldSpec(
key="SESSION_SYSTEM_INJECT",
label="System 注入模式",
description="控制 system 提示词是否并入用户消息。",
value_type="bool",
default_value=False,
),
ConfigFieldSpec(
key="PRECREATE_CHAT",
label="预创建 Chat ID",
description="直接模式下通过上游接口预创建 chat_id,而非使用随机 UUID。仅在关闭会话管理时生效。",
value_type="bool",
default_value=False,
),
),
),
ConfigSectionSpec(
id="server",
title="服务运行",
description="服务监听、日志、数据库路径和反向代理前缀。",
fields=(
ConfigFieldSpec(
key="SERVICE_NAME",
label="服务名称",
description="显示在进程列表中的服务名称。",
value_type="str",
default_value="api-proxy-server",
placeholder="api-proxy-server",
required=True,
restart_required=True,
db_persist=False,
),
ConfigFieldSpec(
key="LISTEN_PORT",
label="监听端口",
description="HTTP 服务监听端口。",
value_type="int",
default_value=8080,
input_type="number",
min_value=1,
max_value=65535,
required=True,
restart_required=True,
placeholder="8080",
db_persist=False,
),
ConfigFieldSpec(
key="ROOT_PATH",
label="反向代理路径前缀",
description="例如 /api,部署在子路径时使用。",
value_type="str",
default_value="",
placeholder="/api",
restart_required=True,
db_persist=False,
),
ConfigFieldSpec(
key="DEBUG_LOGGING",
label="启用调试日志",
description="开启后控制台和日志文件统一输出 DEBUG,并使用详细格式。",
value_type="bool",
default_value=False,
db_persist=False,
),
ConfigFieldSpec(
key="LOG_FILE_PATH",
label="日志文件路径",
description="相对路径默认相对于项目根目录解析。",
value_type="str",
default_value="logs/app.log",
placeholder="logs/app.log",
wide=True,
db_persist=False,
),
ConfigFieldSpec(
key="LOG_FILE_MAX_BYTES",
label="单个日志文件上限 (Bytes)",
description="超过该大小后自动轮转到备份文件。",
value_type="int",
default_value=5242880,
input_type="number",
min_value=1024,
placeholder="5242880",
db_persist=False,
),
ConfigFieldSpec(
key="LOG_FILE_BACKUP_COUNT",
label="日志备份数量",
description="保留的轮转备份文件数量。",
value_type="int",
default_value=3,
input_type="number",
min_value=1,
placeholder="3",
db_persist=False,
),
ConfigFieldSpec(
key="DB_PATH",
label="数据库路径",
description="SQLite 数据库文件位置。",
value_type="str",
default_value="tokens.db",
placeholder="tokens.db",
required=True,
wide=True,
restart_required=True,
db_persist=False,
),
ConfigFieldSpec(
key="DATABASE_URL",
label="PostgreSQL 连接串",
description="(可选)如果使用 PostgreSQL,请填写形如 postgresql://user:pass@localhost:5432/dbname 的地址。留空则使用 SQLite。",
value_type="str",
default_value="",
placeholder="postgresql://user:pass@localhost:5432/dbname",
wide=True,
restart_required=True,
db_persist=False,
),
ConfigFieldSpec(
key="MAX_UPLOAD_FILE_SIZE",
label="文件上传限制 (Bytes)",
description="允许上传到上游的最大文件大小(默认 10MB)。",
value_type="int",
default_value=10485760,
input_type="number",
min_value=0,
placeholder="10485760",
),
),
),
ConfigSectionSpec(
id="tokens",
title="Token 池策略",
description="失败判定、恢复时间和自动导入、自动维护计划任务。",
icon="M4 7v10c0 1.1.9 2 2 2h12c1.1 0 2-.9 2-2V7c0-1.1-.9-2-2-2H6c-1.1 0-2 .9-2 2zm0 4h16m-16 4h16",
fields=(
ConfigFieldSpec(
key="TOKEN_FAILURE_THRESHOLD",
label="失败阈值",
description="连续失败多少次后将 Token 标记为不可用。",
value_type="int",
default_value=3,
input_type="number",
min_value=1,
required=True,
restart_required=True,
),
ConfigFieldSpec(
key="TOKEN_RECOVERY_TIMEOUT",
label="恢复超时(秒)",
description="失败 Token 重新参与调度前的等待时间。",
value_type="int",
default_value=1800,
input_type="number",
min_value=1,
required=True,
restart_required=True,
),
ConfigFieldSpec(
key="TOKEN_LOAD_BALANCE_STRATEGY",
label="负载策略",
description="控制认证 Token 的分配方式:轮询、最少活跃请求优先,或平滑加权轮询。",
value_type="str",
default_value="smooth_weighted_round_robin",
input_type="select",
options=(
"round_robin",
"least_loaded",
"smooth_weighted_round_robin",
),
required=True,
),
ConfigFieldSpec(
key="TOKEN_AUTO_IMPORT_ENABLED",
label="启用自动导入",
description="按固定周期扫描服务端目录并导入 Token。",
value_type="bool",
default_value=False,
),
ConfigFieldSpec(
key="TOKEN_AUTO_IMPORT_SOURCE_DIR",
label="自动导入目录",
description="服务端本地目录,开启自动导入时需要可访问。",
value_type="str",
default_value="",
placeholder="E:\\tokens\\input",
wide=True,
),
ConfigFieldSpec(
key="TOKEN_AUTO_IMPORT_INTERVAL",
label="自动导入间隔(秒)",
description="自动导入的扫描周期。",
value_type="int",
default_value=300,
input_type="number",
min_value=1,
required=True,
),
ConfigFieldSpec(
key="TOKEN_AUTO_MAINTENANCE_ENABLED",
label="启用自动维护",
description="定时执行去重、健康检查和删除失效 Token。",
value_type="bool",
default_value=False,
),
ConfigFieldSpec(
key="TOKEN_AUTO_MAINTENANCE_INTERVAL",
label="自动维护间隔(秒)",
description="自动维护的执行周期。",
value_type="int",
default_value=1800,
input_type="number",
min_value=1,
required=True,
),
ConfigFieldSpec(
key="TOKEN_AUTO_REMOVE_DUPLICATES",
label="自动去重",
description="自动维护时清理重复 Token。",
value_type="bool",
default_value=True,
),
ConfigFieldSpec(
key="TOKEN_AUTO_HEALTH_CHECK",
label="自动健康检查",
description="自动维护时验证 Token 可用性。",
value_type="bool",
default_value=True,
),
ConfigFieldSpec(
key="TOKEN_AUTO_DELETE_INVALID",
label="自动删除失效 Token",
description="自动维护时移除已验证为无效的 Token。",
value_type="bool",
default_value=False,
),
),
),
ConfigSectionSpec(
id="guest",
title="匿名 Guest 会话池",
description="没有用户 Token 时,控制匿名会话池的容量和维护策略。",
icon="M12 4.354a4 4 0 110 5.292M15 21H3v-1a6 6 0 0112 0v1zm0 0h6v-1a6 6 0 00-9-5.197M13 7a4 4 0 11-8 0 4 4 0 018 0z",
fields=(
ConfigFieldSpec(
key="ANONYMOUS_MODE",
label="启用匿名模式",
description="无可用用户 Token 时允许使用匿名会话。",
value_type="bool",
default_value=True,
restart_required=True,
),
ConfigFieldSpec(
key="GUEST_POOL_SIZE",
label="Guest 池容量",
description="启动和维持的 guest 会话数量。",
value_type="int",
default_value=3,
input_type="number",
min_value=1,
required=True,
restart_required=True,
),
ConfigFieldSpec(
key="GUEST_SESSION_MAX_AGE",
label="Guest 最大存活时间(秒)",
description="单个 guest 会话的最长存活时长。",
value_type="int",
default_value=480,
input_type="number",
min_value=1,
required=True,
restart_required=True,
),
ConfigFieldSpec(
key="GUEST_POOL_MAINTENANCE_INTERVAL",
label="Guest 维护间隔(秒)",
description="后台维护匿名会话池的频率。",
value_type="int",
default_value=30,
input_type="number",
min_value=1,
required=True,
restart_required=True,
),
ConfigFieldSpec(
key="GUEST_CLEANUP_PARALLELISM",
label="清理并行度",
description="并行回收空闲 guest 会话的数量。",
value_type="int",
default_value=4,
input_type="number",
min_value=1,
required=True,
restart_required=True,
),
ConfigFieldSpec(
key="GUEST_HTTP_MAX_KEEPALIVE_CONNECTIONS",
label="Keep-Alive 连接数",
description="guest 会话 HTTP 连接池 keep-alive 上限。",
value_type="int",
default_value=20,
input_type="number",
min_value=1,
required=True,
restart_required=True,
),
ConfigFieldSpec(
key="GUEST_HTTP_MAX_CONNECTIONS",
label="HTTP 总连接数",
description="guest 会话 HTTP 连接池的总连接数上限。",
value_type="int",
default_value=50,
input_type="number",
min_value=1,
required=True,
restart_required=True,
),
),
),
ConfigSectionSpec(
id="models",
title="模型设置",
description="在线模型自动解析与变体生成。模型列表从上游拉取后自动生成 Thinking / Search / Agent / advanced-search 变体。",
icon="M20 7l-8-4-8 4m16 0l-8 4m8-4v10l-8 4m0-10L4 7m8 4v10M4 7v10l8 4",
fields=(
ConfigFieldSpec(
key="MODEL_BLACKLIST",
label="模型黑名单",
description="逗号分隔的上游模型 ID 列表,解析在线模型时跳过这些模型。例如:glm-4-flash,test-model",
value_type="str",
default_value="glm-4-flash",
placeholder="glm-4-flash",
wide=True,
),
ConfigFieldSpec(
key="MODEL_ALIASES",
label="模型别名映射",
description="自定义别名,让客户端可以用自定义名称访问模型。格式:别名=目标模型名,逗号分隔。例如:gpt-4o=GLM-5,gpt-4o-mini=GLM-4.7",
value_type="str",
default_value="",
placeholder="gpt-4o=GLM-5,gpt-4o-mini=GLM-4.7",
wide=True,
),
ConfigFieldSpec(
key="MODEL_AUTO_REFRESH_HOURS",
label="自动刷新周期(小时)",
description="定时从上游拉取在线模型列表并重新生成变体。设为 0 表示禁用自动刷新,仅手动刷新。",
value_type="int",
input_type="number",
default_value=0,
placeholder="0",
min_value=0,
max_value=168,
restart_required=True,
),
),
),
ConfigSectionSpec(
id="proxy",
title="网络代理",
description="用于所有上游访问的全局网络代理配置。",
icon="M21 12a9 9 0 01-9 9m9-9a9 9 0 00-9-9m9 9H3m9 9a9 9 0 01-9-9m9 9c1.657 0 3-4.03 3-9s-1.343-9-3-9m0 18c-1.657 0-3-4.03-3-9s1.343-9 3-9m-9 9a9 9 0 019-9",
fields=(
ConfigFieldSpec(
key="HTTP_PROXY",
label="代理服务器地址",
description="统一代理,支持 http://, https://, socks5:// 等格式。例如:http://127.0.0.1:10808 或 socks5://127.0.0.1:1080。",
value_type="str",
default_value="",
wide=True,
),
),
),
ConfigSectionSpec(
id="timeouts",
title="超时控制",
description="系统各环节的超时时间(单位:秒)。支持小数,修改即时生效。",
icon="M12 8v4l3 3m6-3a9 9 0 11-18 0 9 9 0 0118 0z",
fields=(
ConfigFieldSpec(
key="HTTP_CONNECT_TIMEOUT",
label="连接超时",
description="建立 TCP/TLS 连接的最大时间。",
value_type="float",
default_value=5.0,
input_type="number",
min_value=0.1,
placeholder="5.0",
db_persist=False,
),
ConfigFieldSpec(
key="HTTP_WRITE_TIMEOUT",
label="写入超时",
description="向网络套接字写入请求数据的最大时间。",
value_type="float",
default_value=10.0,
input_type="number",
min_value=0.1,
placeholder="10.0",
db_persist=False,
),
ConfigFieldSpec(
key="HTTP_POOL_TIMEOUT",
label="连接池等待超时",
description="从 HTTP 连接池获取可用连接的最大等待时间。",
value_type="float",
default_value=5.0,
input_type="number",
min_value=0.1,
placeholder="5.0",
db_persist=False,
),
ConfigFieldSpec(
key="HTTP_DEFAULT_READ_TIMEOUT",
label="非流式读取超时",
description="普通请求(如鉴权、获取模型列表)的单次读取超时时间。",
value_type="float",
default_value=60.0,
input_type="number",
min_value=1.0,
placeholder="60.0",
db_persist=False,
),
ConfigFieldSpec(
key="HTTP_STREAM_READ_TIMEOUT",
label="流式单步读取超时",
description="流式聊天中,相邻两个数据块(chunk)之间允许的最大静默时间。",
value_type="float",
default_value=120.0,
input_type="number",
min_value=1.0,
placeholder="120.0",
db_persist=False,
),
ConfigFieldSpec(
key="HTTP_MODEL_FETCH_TIMEOUT",
label="模型拉取超时",
description="请求上游 /v1/models 接口的超时时间。",
value_type="float",
default_value=10.0,
input_type="number",
min_value=1.0,
placeholder="10.0",
db_persist=False,
),
ConfigFieldSpec(
key="CHAT_TOTAL_TIMEOUT",
label="非流式端到端总超时",
description="单次非流式聊天请求的总处理时间上限(包含所有内部重试)。",
value_type="float",
default_value=300.0,
input_type="number",
min_value=1.0,
placeholder="300.0",
db_persist=False,
wide=True,
),
ConfigFieldSpec(
key="HTTP_STREAM_TOTAL_TIMEOUT",
label="流式端到端总超时",
description="单次流式聊天请求的总处理时间上限(包含首包等待及所有抛出数据的总耗时)。",
value_type="float",
default_value=600.0,
input_type="number",
min_value=1.0,
placeholder="600.0",
db_persist=False,
wide=True,
),
),
),
ConfigSectionSpec(
id="captcha",
title="Captcha 验证码",
description="控制验证码服务的启用、地址、超时和重试策略。Captcha token 由独立的 Captcha Token Service 通过浏览器生成。",
icon="M12 2L4 5v6.09c0 5.05 3.41 9.76 8 10.91 4.59-1.15 8-5.86 8-10.91V5l-8-3zm-1 13l-3-3 1.41-1.41L11 14.17l5.59-5.59L18 10l-7 5z",
fields=(
ConfigFieldSpec(
key="CAPTCHA_ENABLED",
label="启用 Captcha",
description="关闭后主服务不再调用 captcha 服务获取 verify_param。",
value_type="bool",
default_value=True,
),
ConfigFieldSpec(
key="CAPTCHA_SERVICE_URL",
label="Captcha 服务地址",
description="独立部署的 Captcha Token Service 地址。",
value_type="str",
default_value="http://127.0.0.1:9000",
placeholder="http://127.0.0.1:9000",
wide=True,
),
ConfigFieldSpec(
key="CAPTCHA_SERVICE_TIMEOUT",
label="Captcha 服务超时(秒)",
description="调用 captcha 服务的超时时间,包含浏览器生成 token 的时间。",
value_type="float",
default_value=18.0,
input_type="number",
min_value=1.0,
placeholder="18.0",
),
ConfigFieldSpec(
key="CAPTCHA_MAX_RETRIES",
label="Captcha 最大重试次数",
description="captcha 验证失败时最多重试次数,每次重试会重新获取 token。",
value_type="int",
default_value=3,
input_type="number",
min_value=1,
placeholder="3",
),
),
),
ConfigSectionSpec(
id="admin",
title="后台安全",
description="管理后台密码和会话密钥。修改后建议重新登录。",
icon="M9 12l2 2 4-4m5.618-4.016A11.955 11.955 0 0112 2.944a11.955 11.955 0 01-8.618 3.04A12.02 12.02 0 003 9c0 5.591 3.824 10.29 9 11.622 5.176-1.332 9-6.03 9-11.622 0-1.042-.133-2.052-.382-3.016z",
fields=(
ConfigFieldSpec(
key="ADMIN_PASSWORD",
label="后台密码",
description="管理后台登录密码。",
value_type="str",
default_value="admin123",
input_type="password",
placeholder="admin123",
required=True,
sensitive=True,
),
ConfigFieldSpec(
key="SESSION_SECRET_KEY",
label="会话密钥",
description="用于后台会话签名的密钥。",
value_type="str",
default_value="your-secret-key-change-in-production",
input_type="password",
placeholder="your-secret-key-change-in-production",
required=True,
sensitive=True,
wide=True,
),
),
),
ConfigSectionSpec(
id="personalization",
title="上游个性化",
description="配置发送给上游的用户画像变量,用于自定义模型回复风格。",
icon="M5.121 17.804A13.937 13.937 0 0112 16c2.5 0 4.847.655 6.879 1.804M15 10a3 3 0 11-6 0 3 3 0 016 0zm6 2a9 9 0 11-18 0 9 9 0 0118 0z",
fields=(
ConfigFieldSpec(
key="UPSTREAM_USER_NAME",
label="用户名称",
description="上游上下文中的 {{USER_NAME}} 变量。",
value_type="str",
default_value="Guest",
),
ConfigFieldSpec(
key="UPSTREAM_USER_LOCATION",
label="用户位置",
description="上游上下文中的 {{USER_LOCATION}} 变量。",
value_type="str",
default_value="Unknown",
),
ConfigFieldSpec(
key="UPSTREAM_USER_TIMEZONE",
label="用户时区",
description="上游上下文中的 {{CURRENT_TIMEZONE}} 变量。",
value_type="str",
default_value="Asia/Shanghai",
),
ConfigFieldSpec(
key="UPSTREAM_USER_LANGUAGE",
label="用户语言",
description="上游上下文中的 {{USER_LANGUAGE}} 变量。",
value_type="str",
default_value="zh-CN",
),
),
),
)
CONFIG_FIELD_SPECS = {
field.key: field
for section in CONFIG_SECTIONS
for field in section.fields
}
MANAGED_ENV_KEYS = tuple(CONFIG_FIELD_SPECS.keys())
DB_PERSIST_KEYS = frozenset(
key for key, field in CONFIG_FIELD_SPECS.items() if field.db_persist
)
ReloadCallback = Callable[[], Awaitable[None]]
def read_env_content(env_path: str | Path = ENV_PATH) -> str:
path = Path(env_path)
if not path.exists():
return ""
return path.read_text(encoding="utf-8")
def get_config_source_snapshot(
*,
env_values: Mapping[str, str] | None = None,
db_values: Mapping[str, Any] | None = None,
keys: tuple[str, ...] = (),
) -> dict[str, dict[str, Any]]:
snapshot: dict[str, dict[str, Any]] = {}
db_items = db_values or {}
source_env = env_values or {}
for key in keys:
field = CONFIG_FIELD_SPECS.get(key)
if field is None:
continue
snapshot[key] = {
"env": source_env.get(key, "<unset>"),
"db": db_items.get(key, "<unset>"),
"db_persist": field.db_persist,
}
return snapshot
def validate_env_source(content: str) -> str:
normalized = content.replace("\r\n", "\n").replace("\r", "\n")
for line_number, line in enumerate(normalized.splitlines(), start=1):
stripped = line.strip()
if not stripped or stripped.startswith("#"):
continue
if not _ENV_SOURCE_LINE_PATTERN.match(line):
raise ValueError(
f"第 {line_number} 行不是合法的 KEY=VALUE 格式。"
)
return normalized
def _serialize_config_value(value: object) -> str:
"""将 Python 值序列化为数据库存储的字符串。"""
if isinstance(value, bool):
return "true" if value else "false"
if value is None:
return ""
return str(value)
def _parse_db_value(raw: str, value_type: str) -> Any:
"""按字段类型解析数据库中的字符串值。"""
if value_type == "bool":
return raw.lower() in ("true", "1", "yes")
if value_type == "int":
try:
return int(raw)
except (ValueError, TypeError):
return None
if value_type == "float":
try:
return float(raw)
except (ValueError, TypeError):
return None
return raw
def _is_valid_option_value(field: ConfigFieldSpec, value: Any) -> bool:
if field.input_type != "select" or not field.options:
return True
return str(value or "") in field.options
def _is_valid_db_override_value(field: ConfigFieldSpec, raw_value: str) -> bool:
parsed = _parse_db_value(raw_value, field.value_type)
if parsed is None and field.value_type != "str":
return False
return _is_valid_option_value(field, parsed)
async def load_db_overrides() -> dict[str, Any]:
"""从数据库加载可持久化的配置值,按类型解析后返回。
仅返回解析成功且属于 DB_PERSIST_KEYS 的条目。
如果数据库不可用则静默返回空字典。
"""
try:
from app.services.config_dao import get_config_dao
dao = get_config_dao()
raw_items = await dao.get_all()
except Exception as exc:
logger.debug(
"读取数据库配置失败,回退到环境变量",
exc_info=settings.DEBUG_LOGGING,
)
return {}
overrides: dict[str, Any] = {}
for key, raw_value in raw_items.items():
if key not in DB_PERSIST_KEYS:
continue
field = CONFIG_FIELD_SPECS.get(key)
if field is None:
continue
parsed = _parse_db_value(raw_value, field.value_type)
if not _is_valid_db_override_value(field, raw_value):
logger.warning("忽略无效的数据库配置项: %s=%s", key, raw_value)
continue
if parsed is not None or field.value_type == "str":
overrides[key] = parsed
return overrides
async def apply_db_overrides(settings_obj: Any = settings) -> None:
"""从数据库加载配置并覆盖到 settings 对象上。"""
overrides = await load_db_overrides()
for key, value in overrides.items():
setattr(settings_obj, key, value)
if overrides:
logger.debug("已从数据库加载 %s 项配置覆盖", len(overrides))
def build_config_page_data(
*,
settings_obj: Any = settings,
env_path: str | Path = ENV_PATH,
env_example_path: str | Path = ENV_EXAMPLE_PATH,
db_values: dict[str, str] | None = None,
) -> dict[str, Any]:
env_file = Path(env_path)
env_content = read_env_content(env_file)
env_values = dotenv_values(env_file) if env_file.exists() else {}
valid_db_keys = {
key
for key, raw_value in (db_values or {}).items()
if (
(field := CONFIG_FIELD_SPECS.get(key)) is not None
and _is_valid_db_override_value(field, raw_value)
)
}
sections: list[dict[str, Any]] = []
total_fields = 0
overridden_fields = 0
sensitive_fields = 0
restart_required_fields = 0
for section in CONFIG_SECTIONS:
rendered_fields: list[dict[str, Any]] = []
for field in section.fields:
total_fields += 1
if field.sensitive:
sensitive_fields += 1
if field.restart_required:
restart_required_fields += 1
# 判断来源优先级:数据库 > .env > 默认值
in_db = field.key in valid_db_keys
in_env = field.key in env_values
if in_db:
source_label = "数据库"
source_badge_class = (
"bg-blue-50 text-blue-700 ring-blue-200"
)
overridden_fields += 1
elif in_env:
source_label = ".env"
source_badge_class = (
"bg-emerald-50 text-emerald-700 ring-emerald-200"
)
overridden_fields += 1
else:
source_label = "默认值"
source_badge_class = (
"bg-slate-100 text-slate-600 ring-slate-200"
)
value = getattr(settings_obj, field.key, field.default_value)
if value is None:
value = ""
rendered_fields.append(
{
"key": field.key,
"label": field.label,
"description": field.description,
"value_type": field.value_type,
"value": value,
"input_type": field.input_type,
"placeholder": field.placeholder,
"required": field.required,
"wide": field.wide,
"sensitive": field.sensitive,
"restart_required": field.restart_required,
"min_value": field.min_value,
"max_value": field.max_value,
"source_label": source_label,
"source_badge_class": source_badge_class,
"options": field.options,
}
)
sections.append(
{
"id": section.id,
"title": section.title,
"description": section.description,
"icon": section.icon,
"fields": rendered_fields,
"field_count": len(rendered_fields),
}
)
return {
"sections": sections,
"env_content": env_content,
"overview": {
"total_sections": len(CONFIG_SECTIONS),
"total_fields": total_fields,
"overridden_fields": overridden_fields,
"default_fields": total_fields - overridden_fields,
"sensitive_fields": sensitive_fields,
"restart_required_fields": restart_required_fields,
"env_exists": env_file.exists(),
"env_path": str(env_file.resolve()),
"env_line_count": len(env_content.splitlines()) if env_content else 0,
"example_exists": Path(env_example_path).exists(),
},
}
def build_form_updates(form_data: Mapping[str, Any]) -> dict[str, object]:
updates: dict[str, object] = {}
for key in MANAGED_ENV_KEYS:
field = CONFIG_FIELD_SPECS[key]
if field.value_type == "bool":
updates[key] = key in form_data
continue
raw_value = str(form_data.get(key, "") or "").strip()
if field.required and raw_value == "":
raise ValueError(f"{field.label} 不能为空。")
if field.value_type == "int":
try:
parsed = int(raw_value)
except ValueError as exc:
raise ValueError(f"{field.label} 必须是整数。") from exc
if field.min_value is not None and parsed < field.min_value:
raise ValueError(
f"{field.label} 不能小于 {field.min_value}。"
)
if field.max_value is not None and parsed > field.max_value:
raise ValueError(
f"{field.label} 不能大于 {field.max_value}。"
)
updates[key] = parsed
continue
if field.value_type == "float":
try:
parsed_float = float(raw_value)
except ValueError as exc:
raise ValueError(f"{field.label} 必须是数字。") from exc
if field.min_value is not None and parsed_float < field.min_value:
raise ValueError(
f"{field.label} 不能小于 {field.min_value}。"
)
if field.max_value is not None and parsed_float > field.max_value:
raise ValueError(
f"{field.label} 不能大于 {field.max_value}。"
)
updates[key] = parsed_float
continue
if field.input_type == "select" and field.options and raw_value not in field.options:
raise ValueError(
f"{field.label} 必须是以下选项之一:{', '.join(field.options)}。"
)
updates[key] = raw_value
return updates
async def _apply_env_change(
writer: Callable[[Path], None],
*,
reload_callback: ReloadCallback,
env_path: str | Path = ENV_PATH,
) -> None:
path = Path(env_path)
had_existing_file = path.exists()
previous_content = read_env_content(path) if had_existing_file else ""
try:
writer(path)
await reload_callback()
except Exception:
if had_existing_file:
path.write_text(previous_content, encoding="utf-8")
elif path.exists():
path.unlink()
try:
await reload_callback()
except Exception as restore_exc:
logger.warning(f"⚠️ 回滚配置后重新加载失败: {restore_exc}")
raise
async def save_form_config(
form_data: Mapping[str, Any],
*,
reload_callback: ReloadCallback,
env_path: str | Path = ENV_PATH,
) -> dict[str, object]:
updates = build_form_updates(form_data)
# 分流:db_persist=True → 数据库,其余 → .env
db_updates: dict[str, str] = {}
env_updates: dict[str, object] = {}
for key, value in updates.items():
if key in DB_PERSIST_KEYS:
db_updates[key] = _serialize_config_value(value)
else:
env_updates[key] = value
# 写入数据库
if db_updates:
try:
from app.services.config_dao import get_config_dao
dao = get_config_dao()
await dao.set_many(db_updates)
logger.debug("已保存 %s 项配置到数据库", len(db_updates))
except Exception as exc:
logger.warning(
"⚠️ 数据库保存失败,回退到环境变量",
exc_info=settings.DEBUG_LOGGING,
)
env_updates.update(updates) # fallback: 全部写入 .env
db_updates.clear()
# 写入 .env(仅非数据库字段或回退字段)
if env_updates:
async def _reload() -> None:
await reload_callback()
def _writer(target_path: Path) -> None:
update_env_file(env_updates, env_path=target_path)
await _apply_env_change(
_writer, reload_callback=_reload, env_path=env_path
)
else:
# 即使没有 env 变更,也需要重新加载以应用 DB 值
await reload_callback()
return updates
async def save_source_config(
env_content: str,
*,
reload_callback: ReloadCallback,
env_path: str | Path = ENV_PATH,
) -> None:
normalized = validate_env_source(env_content)
def _writer(target_path: Path) -> None:
content = normalized.rstrip("\n")
target_path.write_text(
f"{content}\n" if content else "",
encoding="utf-8",
)
await _apply_env_change(
_writer,
reload_callback=reload_callback,
env_path=env_path,
)
async def reset_env_to_example(
*,
reload_callback: ReloadCallback,
env_path: str | Path = ENV_PATH,
env_example_path: str | Path = ENV_EXAMPLE_PATH,
) -> None:
example_path = Path(env_example_path)
if not example_path.exists():
raise FileNotFoundError(".env.example 不存在")
example_content = example_path.read_text(encoding="utf-8")
def _writer(target_path: Path) -> None:
content = example_content.rstrip("\n")
target_path.write_text(
f"{content}\n" if content else "",
encoding="utf-8",
)
await _apply_env_change(
_writer,
reload_callback=reload_callback,
env_path=env_path,
)