feat: add system settings with model connectivity and encrypted storage

This commit is contained in:
2026-05-08 08:56:52 +08:00
parent e8f3d97d6a
commit adda87a01d
21 changed files with 1888 additions and 291 deletions

View File

@@ -5,7 +5,6 @@ from dataclasses import dataclass
from sqlalchemy import func, select
from sqlalchemy.orm import Session, selectinload
from app.core.admin_secret import read_admin_secret, verify_admin_secret
from app.core.config import get_settings
from app.core.logging import get_logger
from app.core.security import verify_password
@@ -13,6 +12,7 @@ from app.models.employee import Employee
from app.schemas.auth import AuthUserRead, LoginRequest, LoginResponse
from app.services.employee import EmployeeService
from app.services.employee_seed import ROLE_DISPLAY_ORDER
from app.services.settings import SettingsService
logger = get_logger("app.services.auth")
@@ -53,34 +53,25 @@ class AuthService:
employee_user = self._authenticate_employee(identifier, password)
if employee_user is not None:
logger.info("Employee login succeeded identifier=%s role_codes=%s", identifier, ",".join(employee_user.role_codes))
logger.info(
"Employee login succeeded identifier=%s role_codes=%s",
identifier,
",".join(employee_user.role_codes),
)
return LoginResponse(user=self._serialize_user(employee_user))
logger.warning("Login failed identifier=%s", identifier)
raise ValueError("账号或密码错误。")
def _authenticate_admin(self, identifier: str, password: str) -> AuthenticatedUser | None:
record = read_admin_secret()
record = SettingsService(self.db).verify_admin_login(identifier, password)
if record is None:
return None
admin_username = str(record.get("username", "")).strip()
admin_email = str(self.settings.admin_email or "").strip()
normalized_identifier = identifier.casefold()
allowed_identifiers = {
value.casefold()
for value in [admin_username, admin_email]
if value
}
if normalized_identifier not in allowed_identifiers:
return None
if not verify_admin_secret(password, record):
return None
admin_username = record.account.strip()
admin_email = record.email.strip()
display_name = admin_username or admin_email or "系统管理员"
return AuthenticatedUser(
username=admin_username or admin_email,
name=display_name,

View File

@@ -0,0 +1,216 @@
from __future__ import annotations
import json
from datetime import datetime, timezone
from http import HTTPStatus
from typing import Any
from urllib.error import HTTPError, URLError
from urllib.parse import quote
from urllib.request import Request, urlopen
from app.schemas.settings import ModelConnectivityTestRead, ModelConnectivityTestRequest
AZURE_API_VERSION = "2024-10-21"
DEFAULT_TIMEOUT_SECONDS = 12
class ConnectivityCheckError(Exception):
def __init__(self, message: str, status_code: int | None = None) -> None:
super().__init__(message)
self.status_code = status_code
def probe_model_connectivity(payload: ModelConnectivityTestRequest) -> ModelConnectivityTestRead:
checked_at = datetime.now(timezone.utc)
try:
if payload.provider == "Azure OpenAI":
status_code = _probe_azure_openai(payload)
elif payload.provider == "Ollama":
status_code = _probe_ollama(payload)
else:
status_code = _probe_openai_compatible(payload)
detail = f"{payload.provider} 已连接,模型 {payload.model} 可正常访问。"
return ModelConnectivityTestRead(
ok=True,
provider=payload.provider,
model=payload.model,
endpoint=payload.endpoint,
capability=payload.capability,
detail=detail,
status_code=status_code,
checked_at=checked_at,
)
except ConnectivityCheckError as exc:
return ModelConnectivityTestRead(
ok=False,
provider=payload.provider,
model=payload.model,
endpoint=payload.endpoint,
capability=payload.capability,
detail=str(exc),
status_code=exc.status_code,
checked_at=checked_at,
)
def _probe_openai_compatible(payload: ModelConnectivityTestRequest) -> int:
normalized_endpoint = _normalize_endpoint(payload.endpoint)
headers = _build_headers(api_key=payload.api_key, use_bearer=True)
if payload.capability == "embedding":
url = _ensure_path(normalized_endpoint, "embeddings")
body = {"model": payload.model, "input": "connectivity test"}
else:
url = _ensure_path(normalized_endpoint, "chat/completions")
body = {
"model": payload.model,
"messages": [{"role": "user", "content": "ping"}],
"max_tokens": 1,
}
status_code, _ = _send_json_request("POST", url, headers=headers, payload=body)
return status_code
def _probe_ollama(payload: ModelConnectivityTestRequest) -> int:
normalized_endpoint = _normalize_endpoint(payload.endpoint)
headers = _build_headers(api_key=payload.api_key, use_bearer=False)
if payload.capability == "embedding":
url = _ensure_path(normalized_endpoint, "api/embed")
body = {"model": payload.model, "input": "connectivity test"}
else:
url = _ensure_path(normalized_endpoint, "api/chat")
body = {
"model": payload.model,
"messages": [{"role": "user", "content": "ping"}],
"stream": False,
}
status_code, _ = _send_json_request("POST", url, headers=headers, payload=body)
return status_code
def _probe_azure_openai(payload: ModelConnectivityTestRequest) -> int:
deployment_base = _build_azure_deployment_base(payload.endpoint, payload.model)
headers = _build_headers(api_key=payload.api_key, use_bearer=False, use_api_key=True)
if payload.capability == "embedding":
url = f"{deployment_base}/embeddings?api-version={AZURE_API_VERSION}"
body = {"input": "connectivity test"}
else:
url = f"{deployment_base}/chat/completions?api-version={AZURE_API_VERSION}"
body = {
"messages": [{"role": "user", "content": "ping"}],
"max_tokens": 1,
}
status_code, _ = _send_json_request("POST", url, headers=headers, payload=body)
return status_code
def _build_azure_deployment_base(endpoint: str, model: str) -> str:
normalized_endpoint = _normalize_endpoint(endpoint)
quoted_model = quote(model, safe="")
if "/openai/deployments/" in normalized_endpoint:
return normalized_endpoint
if "/openai/v1" in normalized_endpoint:
resource_root = normalized_endpoint.split("/openai/v1", maxsplit=1)[0]
return f"{resource_root}/openai/deployments/{quoted_model}"
if normalized_endpoint.endswith("/openai"):
return f"{normalized_endpoint}/deployments/{quoted_model}"
return f"{normalized_endpoint}/openai/deployments/{quoted_model}"
def _build_headers(
api_key: str | None,
*,
use_bearer: bool,
use_api_key: bool = False,
) -> dict[str, str]:
headers = {
"Content-Type": "application/json",
"Accept": "application/json",
}
if api_key:
if use_api_key:
headers["api-key"] = api_key
elif use_bearer:
headers["Authorization"] = f"Bearer {api_key}"
return headers
def _normalize_endpoint(endpoint: str) -> str:
normalized = endpoint.strip()
if not normalized:
raise ConnectivityCheckError("接口地址不能为空。", status_code=HTTPStatus.BAD_REQUEST)
return normalized.rstrip("/")
def _ensure_path(endpoint: str, suffix: str) -> str:
suffix = suffix.lstrip("/")
if endpoint.endswith(suffix):
return endpoint
return f"{endpoint}/{suffix}"
def _send_json_request(
method: str,
url: str,
*,
headers: dict[str, str],
payload: dict[str, Any],
) -> tuple[int, Any]:
data = json.dumps(payload).encode("utf-8")
request = Request(url=url, data=data, headers=headers, method=method)
try:
with urlopen(request, timeout=DEFAULT_TIMEOUT_SECONDS) as response:
body = response.read().decode("utf-8") if response.length != 0 else ""
return response.status, _parse_json_body(body)
except HTTPError as exc:
body = exc.read().decode("utf-8", errors="ignore")
message = _extract_error_message(_parse_json_body(body)) or f"模型接口返回 {exc.code}"
raise ConnectivityCheckError(message, status_code=exc.code) from exc
except URLError as exc:
reason = getattr(exc, "reason", exc)
raise ConnectivityCheckError(f"无法连接到模型接口:{reason}") from exc
except TimeoutError as exc:
raise ConnectivityCheckError("模型接口连接超时,请检查地址或网络。") from exc
def _parse_json_body(body: str) -> Any:
if not body:
return None
try:
return json.loads(body)
except json.JSONDecodeError:
return {"message": body}
def _extract_error_message(payload: Any) -> str | None:
if payload is None:
return None
if isinstance(payload, dict):
if isinstance(payload.get("detail"), str):
return payload["detail"]
if isinstance(payload.get("message"), str):
return payload["message"]
error_payload = payload.get("error")
if isinstance(error_payload, dict) and isinstance(error_payload.get("message"), str):
return error_payload["message"]
if isinstance(payload, str):
return payload
return None

View File

@@ -0,0 +1,352 @@
from __future__ import annotations
from dataclasses import dataclass
from datetime import datetime
from sqlalchemy.orm import Session
from app.core.admin_secret import read_admin_secret, verify_admin_secret
from app.core.config import get_settings
from app.core.secret_box import decrypt_secret, encrypt_secret
from app.core.security import hash_password, verify_password
from app.db.base import Base
from app.models.system_setting import SystemSetting
from app.models.system_setting_secret import SystemSettingSecret
from app.repositories.settings import SETTINGS_ROW_ID, SettingsRepository
from app.schemas.settings import SettingsRead, SettingsWrite
MODEL_SECRET_FIELDS = {
"main": "main_api_key_encrypted",
"backup": "backup_api_key_encrypted",
"vlm": "vlm_api_key_encrypted",
"embedding": "embedding_api_key_encrypted",
}
@dataclass(slots=True)
class AdminCredentialRecord:
account: str
email: str
password_hash: str
class SettingsService:
def __init__(self, db: Session) -> None:
self.db = db
self.repository = SettingsRepository(db)
self.runtime_settings = get_settings()
def ensure_settings_ready(self) -> tuple[SystemSetting, SystemSettingSecret]:
Base.metadata.create_all(bind=self.db.get_bind())
settings_row = self.repository.get_settings()
secrets_row = self.repository.get_secrets()
should_commit = False
if settings_row is None:
settings_row = self._build_default_settings()
self.db.add(settings_row)
should_commit = True
if secrets_row is None:
secrets_row = SystemSettingSecret(id=SETTINGS_ROW_ID)
self.db.add(secrets_row)
should_commit = True
if should_commit:
self.db.commit()
self.db.refresh(settings_row)
self.db.refresh(secrets_row)
return settings_row, secrets_row
def get_settings_snapshot(self) -> SettingsRead:
settings_row, secrets_row = self.ensure_settings_ready()
return self._serialize(settings_row, secrets_row)
def save_settings_snapshot(self, payload: SettingsWrite) -> SettingsRead:
settings_row, secrets_row = self.ensure_settings_ready()
if payload.adminForm.newPassword:
if len(payload.adminForm.newPassword) < 5:
raise ValueError("管理员密码至少需要 5 位。")
if payload.adminForm.newPassword != payload.adminForm.confirmPassword:
raise ValueError("两次输入的管理员密码不一致。")
secrets_row.admin_password_hash = hash_password(payload.adminForm.newPassword)
settings_row.company_name = payload.companyForm.companyName
settings_row.display_name = payload.companyForm.displayName
settings_row.company_code = payload.companyForm.companyCode
settings_row.record_number = payload.companyForm.recordNumber
settings_row.copyright_text = payload.companyForm.copyright
settings_row.admin_account = payload.adminForm.adminAccount
settings_row.admin_email = payload.adminForm.adminEmail
settings_row.session_timeout = payload.adminForm.sessionTimeout
settings_row.notice_email = payload.adminForm.noticeEmail
settings_row.mfa_enabled = payload.adminForm.mfaEnabled
settings_row.strong_password = payload.adminForm.strongPassword
settings_row.login_alert_enabled = payload.adminForm.loginAlertEnabled
settings_row.main_provider = payload.llmForm.mainProvider
settings_row.main_model = payload.llmForm.mainModel
settings_row.main_endpoint = payload.llmForm.mainEndpoint
settings_row.backup_provider = payload.llmForm.backupProvider
settings_row.backup_model = payload.llmForm.backupModel
settings_row.backup_endpoint = payload.llmForm.backupEndpoint
settings_row.vlm_provider = payload.llmForm.vlmProvider
settings_row.vlm_model = payload.llmForm.vlmModel
settings_row.vlm_endpoint = payload.llmForm.vlmEndpoint
settings_row.embedding_provider = payload.llmForm.embeddingProvider
settings_row.embedding_model = payload.llmForm.embeddingModel
settings_row.embedding_endpoint = payload.llmForm.embeddingEndpoint
self._replace_secret_if_present(secrets_row, "main_api_key_encrypted", payload.llmForm.mainApiKey)
self._replace_secret_if_present(secrets_row, "backup_api_key_encrypted", payload.llmForm.backupApiKey)
self._replace_secret_if_present(secrets_row, "vlm_api_key_encrypted", payload.llmForm.vlmApiKey)
self._replace_secret_if_present(
secrets_row,
"embedding_api_key_encrypted",
payload.llmForm.embeddingApiKey,
)
settings_row.log_level = payload.logForm.level
settings_row.retention_days = payload.logForm.retentionDays
settings_row.archive_cycle = payload.logForm.archiveCycle
settings_row.log_path = payload.logForm.logPath
settings_row.alert_email = payload.logForm.alertEmail
settings_row.operation_audit = payload.logForm.operationAudit
settings_row.login_audit = payload.logForm.loginAudit
settings_row.mask_sensitive = payload.logForm.maskSensitive
settings_row.smtp_host = payload.mailForm.smtpHost
settings_row.smtp_port = payload.mailForm.port
settings_row.smtp_encryption = payload.mailForm.encryption
settings_row.sender_name = payload.mailForm.senderName
settings_row.sender_address = payload.mailForm.senderAddress
settings_row.smtp_username = payload.mailForm.username
settings_row.alert_enabled = payload.mailForm.alertEnabled
settings_row.digest_enabled = payload.mailForm.digestEnabled
settings_row.digest_time = payload.mailForm.digestTime
settings_row.default_receiver = payload.mailForm.defaultReceiver
self._replace_secret_if_present(secrets_row, "smtp_password_encrypted", payload.mailForm.password)
self.db.add(settings_row)
self.db.add(secrets_row)
self.db.commit()
self.db.refresh(settings_row)
self.db.refresh(secrets_row)
return self._serialize(settings_row, secrets_row)
def load_saved_model_api_key(self, slot: str | None) -> str:
if not slot or slot not in MODEL_SECRET_FIELDS:
return ""
_, secrets_row = self.ensure_settings_ready()
encrypted_value = getattr(secrets_row, MODEL_SECRET_FIELDS[slot], "")
if not encrypted_value:
return ""
return decrypt_secret(encrypted_value)
def get_admin_credentials(self) -> AdminCredentialRecord | None:
settings_row, secrets_row = self.ensure_settings_ready()
if secrets_row.admin_password_hash:
return AdminCredentialRecord(
account=settings_row.admin_account,
email=settings_row.admin_email,
password_hash=secrets_row.admin_password_hash,
)
legacy_record = read_admin_secret()
if legacy_record is None:
return None
username = str(legacy_record.get("username", "")).strip()
email = str(settings_row.admin_email or self.runtime_settings.admin_email or "").strip()
password_hash = ""
# Legacy admin.json uses scrypt fields rather than the app password format.
# The auth flow handles this file separately when no DB-backed admin password exists.
if username or email:
return AdminCredentialRecord(account=username, email=email, password_hash=password_hash)
return None
def verify_admin_login(self, identifier: str, password: str) -> AdminCredentialRecord | None:
settings_row, secrets_row = self.ensure_settings_ready()
normalized_identifier = identifier.casefold()
if secrets_row.admin_password_hash:
allowed_identifiers = {
value.casefold()
for value in [settings_row.admin_account, settings_row.admin_email]
if value
}
if normalized_identifier not in allowed_identifiers:
return None
if not verify_password(password, secrets_row.admin_password_hash):
return None
return AdminCredentialRecord(
account=settings_row.admin_account,
email=settings_row.admin_email,
password_hash=secrets_row.admin_password_hash,
)
legacy_record = read_admin_secret()
if legacy_record is None:
return None
admin_username = str(legacy_record.get("username", "")).strip()
admin_email = str(settings_row.admin_email or self.runtime_settings.admin_email or "").strip()
allowed_identifiers = {
value.casefold()
for value in [admin_username, admin_email]
if value
}
if normalized_identifier not in allowed_identifiers:
return None
if not verify_admin_secret(password, legacy_record):
return None
return AdminCredentialRecord(account=admin_username, email=admin_email, password_hash="")
def _build_default_settings(self) -> SystemSetting:
current_year = datetime.now().year
company_name = str(self.runtime_settings.company_name or "X-Financial").strip() or "X-Financial"
company_code = str(self.runtime_settings.company_code or "XF-001").strip() or "XF-001"
admin_email = str(self.runtime_settings.admin_email or "").strip()
legacy_admin = read_admin_secret() or {}
admin_account = str(legacy_admin.get("username", "")).strip() or "superadmin"
return SystemSetting(
id=SETTINGS_ROW_ID,
company_name=company_name,
display_name=company_name,
company_code=company_code,
record_number="",
copyright_text=f"Copyright © 2024-{current_year} {company_name}. All Rights Reserved.",
admin_account=admin_account,
admin_email=admin_email,
session_timeout=30,
notice_email=admin_email,
mfa_enabled=True,
strong_password=True,
login_alert_enabled=True,
main_provider="Codex",
main_model="codex-mini-latest",
main_endpoint="https://api.openai.com/v1",
backup_provider="GLM",
backup_model="glm-5.1",
backup_endpoint="https://open.bigmodel.cn/api/paas/v4/",
vlm_provider="Gemini",
vlm_model="gemini-2.5-flash",
vlm_endpoint="https://generativelanguage.googleapis.com/v1beta/openai/",
embedding_provider="GLM",
embedding_model="Embedding-3",
embedding_endpoint="https://open.bigmodel.cn/api/paas/v4/",
log_level="INFO",
retention_days=180,
archive_cycle="weekly",
log_path="server/logs/app.log",
alert_email=admin_email,
operation_audit=True,
login_audit=True,
mask_sensitive=True,
smtp_host="smtp.exmail.qq.com",
smtp_port=465,
smtp_encryption="SSL/TLS",
sender_name=company_name,
sender_address=admin_email,
smtp_username=admin_email,
alert_enabled=True,
digest_enabled=False,
digest_time="09:00",
default_receiver=admin_email,
)
@staticmethod
def _replace_secret_if_present(secret_row: SystemSettingSecret, field_name: str, value: str) -> None:
normalized = value.strip()
if not normalized:
return
setattr(secret_row, field_name, encrypt_secret(normalized))
@staticmethod
def _serialize(settings_row: SystemSetting, secrets_row: SystemSettingSecret) -> SettingsRead:
return SettingsRead(
companyForm={
"companyName": settings_row.company_name,
"displayName": settings_row.display_name,
"companyCode": settings_row.company_code,
"recordNumber": settings_row.record_number,
"copyright": settings_row.copyright_text,
},
adminForm={
"adminAccount": settings_row.admin_account,
"adminEmail": settings_row.admin_email,
"newPassword": "",
"confirmPassword": "",
"sessionTimeout": settings_row.session_timeout,
"noticeEmail": settings_row.notice_email,
"mfaEnabled": settings_row.mfa_enabled,
"strongPassword": settings_row.strong_password,
"loginAlertEnabled": settings_row.login_alert_enabled,
"adminPasswordConfigured": bool(secrets_row.admin_password_hash),
},
llmForm={
"mainProvider": settings_row.main_provider,
"mainModel": settings_row.main_model,
"mainEndpoint": settings_row.main_endpoint,
"mainApiKey": "",
"mainApiKeyConfigured": bool(secrets_row.main_api_key_encrypted),
"backupProvider": settings_row.backup_provider,
"backupModel": settings_row.backup_model,
"backupEndpoint": settings_row.backup_endpoint,
"backupApiKey": "",
"backupApiKeyConfigured": bool(secrets_row.backup_api_key_encrypted),
"vlmProvider": settings_row.vlm_provider,
"vlmModel": settings_row.vlm_model,
"vlmEndpoint": settings_row.vlm_endpoint,
"vlmApiKey": "",
"vlmApiKeyConfigured": bool(secrets_row.vlm_api_key_encrypted),
"embeddingProvider": settings_row.embedding_provider,
"embeddingModel": settings_row.embedding_model,
"embeddingEndpoint": settings_row.embedding_endpoint,
"embeddingApiKey": "",
"embeddingApiKeyConfigured": bool(secrets_row.embedding_api_key_encrypted),
},
logForm={
"level": settings_row.log_level,
"retentionDays": settings_row.retention_days,
"archiveCycle": settings_row.archive_cycle,
"logPath": settings_row.log_path,
"alertEmail": settings_row.alert_email,
"operationAudit": settings_row.operation_audit,
"loginAudit": settings_row.login_audit,
"maskSensitive": settings_row.mask_sensitive,
},
mailForm={
"smtpHost": settings_row.smtp_host,
"port": settings_row.smtp_port,
"encryption": settings_row.smtp_encryption,
"senderName": settings_row.sender_name,
"senderAddress": settings_row.sender_address,
"username": settings_row.smtp_username,
"password": "",
"passwordConfigured": bool(secrets_row.smtp_password_encrypted),
"alertEnabled": settings_row.alert_enabled,
"digestEnabled": settings_row.digest_enabled,
"digestTime": settings_row.digest_time,
"defaultReceiver": settings_row.default_receiver,
},
)