dashboard-nanobot/backend/services/platform_settings_core.py

339 lines
11 KiB
Python
Raw Normal View History

2026-03-31 04:31:47 +00:00
import json
import os
import re
from datetime import datetime
from typing import Any, Dict, List
from sqlmodel import Session
from core.settings import (
2026-04-03 15:00:08 +00:00
DEFAULT_AUTH_TOKEN_MAX_ACTIVE,
DEFAULT_AUTH_TOKEN_TTL_HOURS,
2026-03-31 04:31:47 +00:00
DEFAULT_CHAT_PULL_PAGE_SIZE,
DEFAULT_COMMAND_AUTO_UNLOCK_SECONDS,
DEFAULT_PAGE_SIZE,
DEFAULT_UPLOAD_MAX_MB,
DEFAULT_WORKSPACE_DOWNLOAD_EXTENSIONS,
STT_ENABLED_DEFAULT,
)
from models.platform import PlatformSetting
from schemas.platform import SystemSettingItem
DEFAULT_ALLOWED_ATTACHMENT_EXTENSIONS: tuple[str, ...] = ()
DEFAULT_ACTIVITY_EVENT_RETENTION_DAYS = 7
ACTIVITY_EVENT_RETENTION_SETTING_KEY = "activity_event_retention_days"
SETTING_KEYS = (
"page_size",
"chat_pull_page_size",
"command_auto_unlock_seconds",
2026-04-03 15:00:08 +00:00
"auth_token_ttl_hours",
"auth_token_max_active",
2026-03-31 04:31:47 +00:00
"upload_max_mb",
"allowed_attachment_extensions",
"workspace_download_extensions",
"speech_enabled",
)
PROTECTED_SETTING_KEYS = set(SETTING_KEYS) | {ACTIVITY_EVENT_RETENTION_SETTING_KEY}
DEPRECATED_SETTING_KEYS = {
"loading_page",
"speech_max_audio_seconds",
"speech_default_language",
"speech_force_simplified",
"speech_audio_preprocess",
"speech_audio_filter",
"speech_initial_prompt",
2026-04-03 15:00:08 +00:00
"sys_auth_token_ttl_days",
"auth_token_ttl_days",
"panel_session_ttl_days",
"bot_session_ttl_days",
2026-03-31 04:31:47 +00:00
}
SYSTEM_SETTING_DEFINITIONS: Dict[str, Dict[str, Any]] = {
"page_size": {
"name": "分页大小",
"category": "ui",
"description": "平台各类列表默认每页条数。",
"value_type": "integer",
"value": DEFAULT_PAGE_SIZE,
"is_public": True,
"sort_order": 5,
},
"chat_pull_page_size": {
"name": "对话懒加载条数",
"category": "chat",
"description": "Bot 对话区向上懒加载时每次读取的消息条数。",
"value_type": "integer",
"value": DEFAULT_CHAT_PULL_PAGE_SIZE,
"is_public": True,
"sort_order": 8,
},
"command_auto_unlock_seconds": {
"name": "发送按钮自动恢复秒数",
"category": "chat",
"description": "对话发送后按钮保持停止态的最长秒数,超时后自动恢复为可发送状态。",
"value_type": "integer",
"value": DEFAULT_COMMAND_AUTO_UNLOCK_SECONDS,
"is_public": True,
"sort_order": 9,
},
2026-04-03 15:00:08 +00:00
"auth_token_ttl_hours": {
"name": "认证 Token 过期小时数",
"category": "auth",
"description": "Panel 与 Bot 登录 Token 的统一有效时长,单位小时。",
"value_type": "integer",
"value": DEFAULT_AUTH_TOKEN_TTL_HOURS,
"is_public": False,
"sort_order": 10,
},
"auth_token_max_active": {
"name": "认证 Token 最大并发数",
"category": "auth",
"description": "同一主体允许同时活跃的 Token 数量,超过时自动撤销最旧 Token。",
"value_type": "integer",
"value": DEFAULT_AUTH_TOKEN_MAX_ACTIVE,
"is_public": False,
"sort_order": 11,
},
2026-03-31 04:31:47 +00:00
"upload_max_mb": {
"name": "上传大小限制",
"category": "upload",
"description": "单文件上传大小限制,单位 MB。",
"value_type": "integer",
"value": DEFAULT_UPLOAD_MAX_MB,
"is_public": False,
2026-04-03 15:00:08 +00:00
"sort_order": 20,
2026-03-31 04:31:47 +00:00
},
"allowed_attachment_extensions": {
"name": "允许附件后缀",
"category": "upload",
"description": "允许上传的附件后缀列表,留空表示不限制。",
"value_type": "json",
"value": list(DEFAULT_ALLOWED_ATTACHMENT_EXTENSIONS),
"is_public": False,
"sort_order": 20,
},
"workspace_download_extensions": {
"name": "工作区下载后缀",
"category": "workspace",
"description": "命中后缀的工作区文件默认走下载模式。",
"value_type": "json",
"value": list(DEFAULT_WORKSPACE_DOWNLOAD_EXTENSIONS),
"is_public": False,
"sort_order": 30,
},
"speech_enabled": {
"name": "语音识别开关",
"category": "speech",
"description": "控制 Bot 语音转写功能是否启用。",
"value_type": "boolean",
"value": STT_ENABLED_DEFAULT,
"is_public": True,
"sort_order": 32,
},
ACTIVITY_EVENT_RETENTION_SETTING_KEY: {
"name": "活动事件保留天数",
"category": "maintenance",
"description": "bot_activity_event 运维事件的保留天数,超期记录会自动清理。",
"value_type": "integer",
"value": DEFAULT_ACTIVITY_EVENT_RETENTION_DAYS,
"is_public": False,
"sort_order": 34,
},
}
def _utcnow() -> datetime:
return datetime.utcnow()
def _normalize_activity_event_retention_days(raw: Any) -> int:
try:
value = int(raw)
except Exception:
value = DEFAULT_ACTIVITY_EVENT_RETENTION_DAYS
return max(1, min(3650, value))
def _normalize_extension(raw: Any) -> str:
text = str(raw or "").strip().lower()
if not text:
return ""
if text.startswith("*."):
text = text[1:]
if not text.startswith("."):
text = f".{text}"
if not re.fullmatch(r"\.[a-z0-9][a-z0-9._+-]{0,31}", text):
return ""
return text
def _normalize_extension_list(rows: Any) -> List[str]:
if not isinstance(rows, list):
return []
normalized: List[str] = []
for item in rows:
ext = _normalize_extension(item)
if ext and ext not in normalized:
normalized.append(ext)
return normalized
def _legacy_env_int(name: str, default: int, min_value: int, max_value: int) -> int:
raw = os.getenv(name)
if raw is None:
return default
try:
value = int(str(raw).strip())
except Exception:
value = default
return max(min_value, min(max_value, value))
def _legacy_env_bool(name: str, default: bool) -> bool:
raw = os.getenv(name)
if raw is None:
return default
return str(raw).strip().lower() in {"1", "true", "yes", "on"}
def _legacy_env_extensions(name: str, default: List[str]) -> List[str]:
raw = os.getenv(name)
if raw is None:
return list(default)
source = re.split(r"[,;\s]+", str(raw))
normalized: List[str] = []
for item in source:
ext = _normalize_extension(item)
if ext and ext not in normalized:
normalized.append(ext)
return normalized
def _bootstrap_platform_setting_values() -> Dict[str, Any]:
return {
"page_size": _legacy_env_int("PAGE_SIZE", DEFAULT_PAGE_SIZE, 1, 100),
"chat_pull_page_size": _legacy_env_int(
"CHAT_PULL_PAGE_SIZE",
DEFAULT_CHAT_PULL_PAGE_SIZE,
10,
500,
),
"command_auto_unlock_seconds": _legacy_env_int(
"COMMAND_AUTO_UNLOCK_SECONDS",
DEFAULT_COMMAND_AUTO_UNLOCK_SECONDS,
1,
600,
),
2026-04-03 15:00:08 +00:00
"auth_token_ttl_hours": _legacy_env_int(
"AUTH_TOKEN_TTL_HOURS",
DEFAULT_AUTH_TOKEN_TTL_HOURS,
1,
720,
),
"auth_token_max_active": _legacy_env_int(
"AUTH_TOKEN_MAX_ACTIVE",
DEFAULT_AUTH_TOKEN_MAX_ACTIVE,
1,
20,
),
2026-03-31 04:31:47 +00:00
"upload_max_mb": _legacy_env_int("UPLOAD_MAX_MB", DEFAULT_UPLOAD_MAX_MB, 1, 2048),
"allowed_attachment_extensions": _legacy_env_extensions(
"ALLOWED_ATTACHMENT_EXTENSIONS",
list(DEFAULT_ALLOWED_ATTACHMENT_EXTENSIONS),
),
"workspace_download_extensions": _legacy_env_extensions(
"WORKSPACE_DOWNLOAD_EXTENSIONS",
list(DEFAULT_WORKSPACE_DOWNLOAD_EXTENSIONS),
),
"speech_enabled": _legacy_env_bool("STT_ENABLED", STT_ENABLED_DEFAULT),
}
def _normalize_setting_key(raw: Any) -> str:
text = str(raw or "").strip()
return re.sub(r"[^a-zA-Z0-9_.-]+", "_", text).strip("._-").lower()
def _normalize_setting_value(value: Any, value_type: str) -> Any:
normalized_type = str(value_type or "json").strip().lower() or "json"
if normalized_type == "integer":
return int(value or 0)
if normalized_type == "float":
return float(value or 0)
if normalized_type == "boolean":
if isinstance(value, bool):
return value
return str(value or "").strip().lower() in {"1", "true", "yes", "on"}
if normalized_type == "string":
return str(value or "")
if normalized_type == "json":
return value
raise ValueError(f"Unsupported value_type: {normalized_type}")
def _read_setting_value(row: PlatformSetting) -> Any:
try:
value = json.loads(row.value_json or "null")
except Exception:
value = None
return _normalize_setting_value(value, row.value_type)
def _setting_item_from_row(row: PlatformSetting) -> Dict[str, Any]:
return SystemSettingItem(
key=row.key,
name=row.name,
category=row.category,
description=row.description,
value_type=row.value_type,
value=_read_setting_value(row),
is_public=bool(row.is_public),
sort_order=int(row.sort_order or 100),
created_at=row.created_at.isoformat() + "Z",
updated_at=row.updated_at.isoformat() + "Z",
).model_dump()
def _upsert_setting_row(
session: Session,
key: str,
*,
name: str,
category: str,
description: str,
value_type: str,
value: Any,
is_public: bool,
sort_order: int,
) -> PlatformSetting:
normalized_key = _normalize_setting_key(key)
if not normalized_key:
raise ValueError("Setting key is required")
normalized_type = str(value_type or "json").strip().lower() or "json"
normalized_value = _normalize_setting_value(value, normalized_type)
now = _utcnow()
row = session.get(PlatformSetting, normalized_key)
if row is None:
row = PlatformSetting(
key=normalized_key,
name=str(name or normalized_key),
category=str(category or "general"),
description=str(description or ""),
value_type=normalized_type,
value_json=json.dumps(normalized_value, ensure_ascii=False),
is_public=bool(is_public),
sort_order=int(sort_order or 100),
created_at=now,
updated_at=now,
)
else:
row.name = str(name or row.name or normalized_key)
row.category = str(category or row.category or "general")
row.description = str(description or row.description or "")
row.value_type = normalized_type
row.value_json = json.dumps(normalized_value, ensure_ascii=False)
row.is_public = bool(is_public)
row.sort_order = int(sort_order or row.sort_order or 100)
row.updated_at = now
session.add(row)
return row