From 86568660a4eeeb76f367097f4e44e366333bedbb Mon Sep 17 00:00:00 2001 From: caoxiaozhu Date: Fri, 8 May 2026 11:14:04 +0800 Subject: [PATCH] =?UTF-8?q?feat:=20=E9=87=8D=E6=9E=84=E6=A8=A1=E5=9E=8B?= =?UTF-8?q?=E9=85=8D=E7=BD=AE=E5=AD=98=E5=82=A8=E4=B8=8E=20API=20Key=20?= =?UTF-8?q?=E5=8A=A0=E5=AF=86=E7=AE=A1=E7=90=86?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 主要修改点: 1. 遗留密码格式兼容 (server/src/app/core/admin_secret.py) - 新增 legacy_admin_secret_to_password_hash(): 将旧版 admin secret 记录转换为标准 scrypt 哈希格式 2. Scrypt 密码验证增强 (server/src/app/core/security.py) - verify_password(): 新增 scrypt$ 前缀检测,分流到专用验证函数 - 新增 verify_scrypt_password(): 解析 scrypt$ 格式哈希并验证 3. 模型配置存储重构 (server/src/app/models/system_model_setting.py) - 新增 SystemModelSetting 模型(slot 为 PK) - 字段: slot, provider, model_name, endpoint, capability, priority, enabled, api_key_encrypted, created_at, updated_at 4. Settings Repository 扩展 (server/src/app/repositories/settings.py) - 新增 get_model_settings(): 获取所有模型配置 - 新增 get_model_setting(slot): 按 slot 获取单个模型配置 5. Settings Service 重构 (server/src/app/services/settings.py) - 新增 ModelSlotConfig dataclass: 封装单个模型槽位的配置属性 - 新增 MODEL_SLOT_CONFIGS 字典: main/backup/vlm/embedding 四个槽位配置 - 重构 save_model_settings(): 批量保存模型配置到 SystemModelSetting 表 - 新增 load_model_settings(): 从 SystemModelSetting 表加载所有模型配置 - read_settings(): 整合 legacy secrets 与新的 SystemModelSetting 表数据 - write_settings(): 拆分 model secrets 到 SystemModelSetting 表 - decrypt_model_secret(): 新增从数据库读取加密的 API Key 6. 数据库模型注册 (server/src/app/db/base.py) - 注册 SystemModelSetting 模型 7. 前端 API URL 智能解析 (web/src/services/api.js) - 新增 isLoopbackHost(): 判断是否为回环地址 - 新增 resolveBrowserReachableApiBaseUrl(): 当后端配置为回环地址但浏览器非回环时,自动替换为浏览器 host - 改进错误信息: "无法连接 FastAPI 后端服务,请确认后端已启动且浏览器可访问后端端口。" 8. 前端 Session 导航增强 (web/src/composables/useSystemState.js) - installSessionNavigation(): 调用 fetchBootstrapState 后设置运行时 API Base URL 9. Settings 视图增强 (web/src/views/SettingsView.vue) - API Key 输入框: 新增 @focus="clearModelSecretMask('xxx')" 清除遮罩 - 新增 .secret-bound-state 提示: 显示"已从数据库加密加载,测试会使用已保存密钥" 10. Settings 脚本增强 (web/src/views/scripts/SettingsView.js) - 新增 clearModelSecretMask(slot): 清除指定槽位的 API Key 遮罩状态 11. CSS 样式 (web/src/assets/styles/views/settings-view.css) - 新增 .secret-bound-state 样式: 显示数据库已加载密钥的提示样式 --- server/src/app/core/admin_secret.py | 10 + server/src/app/core/security.py | 29 +++ server/src/app/db/base.py | 2 + server/src/app/models/__init__.py | 2 + server/src/app/models/system_model_setting.py | 28 ++ server/src/app/repositories/settings.py | 9 + server/src/app/services/settings.py | 242 ++++++++++++++---- server/tests/test_auth_service.py | 20 +- server/tests/test_settings_persistence.py | 50 +++- web/src/assets/styles/views/settings-view.css | 15 ++ web/src/composables/useSystemState.js | 1 + web/src/services/api.js | 37 ++- web/src/views/SettingsView.vue | 20 ++ web/src/views/SetupView.vue | 84 +++++- web/src/views/scripts/SettingsView.js | 51 +++- 15 files changed, 532 insertions(+), 68 deletions(-) create mode 100644 server/src/app/models/system_model_setting.py diff --git a/server/src/app/core/admin_secret.py b/server/src/app/core/admin_secret.py index 865843a..35f0aa1 100644 --- a/server/src/app/core/admin_secret.py +++ b/server/src/app/core/admin_secret.py @@ -51,3 +51,13 @@ def verify_admin_secret(password: str, record: dict[str, object]) -> bool: dklen=key_length, ) return secrets.compare_digest(derived_key, stored_key) + + +def legacy_admin_secret_to_password_hash(record: dict[str, object]) -> str: + salt = str(record["salt"]) + derived_key = str(record["derived_key"]) + key_length = int(record.get("key_length", 64)) + n_value = int(record.get("N", 16384)) + r_value = int(record.get("r", 8)) + p_value = int(record.get("p", 1)) + return f"scrypt${n_value}${r_value}${p_value}${key_length}${salt}${derived_key}" diff --git a/server/src/app/core/security.py b/server/src/app/core/security.py index ee5bb30..a7cefcc 100644 --- a/server/src/app/core/security.py +++ b/server/src/app/core/security.py @@ -23,6 +23,9 @@ def hash_password(password: str) -> str: def verify_password(password: str, password_hash: str) -> bool: + if password_hash.startswith("scrypt$"): + return verify_scrypt_password(password, password_hash) + try: scheme, iterations, encoded_salt, encoded_digest = password_hash.split("$", 3) except ValueError: @@ -40,3 +43,29 @@ def verify_password(password: str, password_hash: str) -> bool: int(iterations), ) return secrets.compare_digest(computed_digest, expected_digest) + + +def verify_scrypt_password(password: str, password_hash: str) -> bool: + try: + scheme, n_value, r_value, p_value, key_length, salt_hex, derived_key_hex = password_hash.split("$", 6) + except ValueError: + return False + + if scheme != "scrypt": + return False + + try: + salt = bytes.fromhex(salt_hex) + expected_key = bytes.fromhex(derived_key_hex) + derived_key = hashlib.scrypt( + password.encode("utf-8"), + salt=salt, + n=int(n_value), + r=int(r_value), + p=int(p_value), + dklen=int(key_length), + ) + except ValueError: + return False + + return secrets.compare_digest(derived_key, expected_key) diff --git a/server/src/app/db/base.py b/server/src/app/db/base.py index 612b2bb..9082282 100644 --- a/server/src/app/db/base.py +++ b/server/src/app/db/base.py @@ -5,6 +5,7 @@ from app.models.employee import Employee from app.models.organization import OrganizationUnit from app.models.reimbursement import ReimbursementRequest from app.models.role import Role +from app.models.system_model_setting import SystemModelSetting from app.models.system_setting import SystemSetting from app.models.system_setting_secret import SystemSettingSecret @@ -16,6 +17,7 @@ __all__ = [ "OrganizationUnit", "ReimbursementRequest", "Role", + "SystemModelSetting", "SystemSetting", "SystemSettingSecret", ] diff --git a/server/src/app/models/__init__.py b/server/src/app/models/__init__.py index a25a818..b4a9671 100644 --- a/server/src/app/models/__init__.py +++ b/server/src/app/models/__init__.py @@ -4,6 +4,7 @@ from app.models.employee import Employee from app.models.organization import OrganizationUnit from app.models.reimbursement import ReimbursementRequest from app.models.role import Role +from app.models.system_model_setting import SystemModelSetting from app.models.system_setting import SystemSetting from app.models.system_setting_secret import SystemSettingSecret @@ -14,6 +15,7 @@ __all__ = [ "OrganizationUnit", "ReimbursementRequest", "Role", + "SystemModelSetting", "SystemSetting", "SystemSettingSecret", ] diff --git a/server/src/app/models/system_model_setting.py b/server/src/app/models/system_model_setting.py new file mode 100644 index 0000000..0a38dbd --- /dev/null +++ b/server/src/app/models/system_model_setting.py @@ -0,0 +1,28 @@ +from __future__ import annotations + +from datetime import datetime + +from sqlalchemy import Boolean, DateTime, Integer, String, Text, func +from sqlalchemy.orm import Mapped, mapped_column + +from app.db.base_class import Base + + +class SystemModelSetting(Base): + __tablename__ = "system_model_settings" + + slot: Mapped[str] = mapped_column(String(32), primary_key=True) + provider: Mapped[str] = mapped_column(String(64), default="") + model_name: Mapped[str] = mapped_column(String(255), default="") + endpoint: Mapped[str] = mapped_column(String(512), default="") + capability: Mapped[str] = mapped_column(String(32), default="chat") + priority: Mapped[int] = mapped_column(Integer, default=0) + enabled: Mapped[bool] = mapped_column(Boolean, default=True) + api_key_encrypted: Mapped[str] = mapped_column(Text, default="") + + created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now()) + updated_at: Mapped[datetime] = mapped_column( + DateTime(timezone=True), + server_default=func.now(), + onupdate=func.now(), + ) diff --git a/server/src/app/repositories/settings.py b/server/src/app/repositories/settings.py index e099f74..49908d7 100644 --- a/server/src/app/repositories/settings.py +++ b/server/src/app/repositories/settings.py @@ -3,6 +3,7 @@ from __future__ import annotations from sqlalchemy import select from sqlalchemy.orm import Session +from app.models.system_model_setting import SystemModelSetting from app.models.system_setting import SystemSetting from app.models.system_setting_secret import SystemSettingSecret @@ -21,6 +22,14 @@ class SettingsRepository: stmt = select(SystemSettingSecret).where(SystemSettingSecret.id == SETTINGS_ROW_ID) return self.db.execute(stmt).scalars().first() + def get_model_settings(self) -> list[SystemModelSetting]: + stmt = select(SystemModelSetting) + return list(self.db.execute(stmt).scalars().all()) + + def get_model_setting(self, slot: str) -> SystemModelSetting | None: + stmt = select(SystemModelSetting).where(SystemModelSetting.slot == slot) + return self.db.execute(stmt).scalars().first() + def save_settings(self, settings: SystemSetting) -> SystemSetting: self.db.add(settings) self.db.commit() diff --git a/server/src/app/services/settings.py b/server/src/app/services/settings.py index ed8a71b..4028016 100644 --- a/server/src/app/services/settings.py +++ b/server/src/app/services/settings.py @@ -5,21 +5,76 @@ from datetime import datetime from sqlalchemy.orm import Session -from app.core.admin_secret import read_admin_secret, verify_admin_secret +from app.core.admin_secret import legacy_admin_secret_to_password_hash, 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_model_setting import SystemModelSetting 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(frozen=True, slots=True) +class ModelSlotConfig: + provider_attr: str + model_attr: str + endpoint_attr: str + legacy_secret_attr: str + default_provider: str + default_model: str + default_endpoint: str + capability: str + priority: int + + +MODEL_SLOT_CONFIGS = { + "main": ModelSlotConfig( + provider_attr="main_provider", + model_attr="main_model", + endpoint_attr="main_endpoint", + legacy_secret_attr="main_api_key_encrypted", + default_provider="Codex", + default_model="codex-mini-latest", + default_endpoint="https://api.openai.com/v1", + capability="chat", + priority=10, + ), + "backup": ModelSlotConfig( + provider_attr="backup_provider", + model_attr="backup_model", + endpoint_attr="backup_endpoint", + legacy_secret_attr="backup_api_key_encrypted", + default_provider="GLM", + default_model="glm-5.1", + default_endpoint="https://open.bigmodel.cn/api/paas/v4/", + capability="chat", + priority=20, + ), + "vlm": ModelSlotConfig( + provider_attr="vlm_provider", + model_attr="vlm_model", + endpoint_attr="vlm_endpoint", + legacy_secret_attr="vlm_api_key_encrypted", + default_provider="Gemini", + default_model="gemini-2.5-flash", + default_endpoint="https://generativelanguage.googleapis.com/v1beta/openai/", + capability="chat", + priority=30, + ), + "embedding": ModelSlotConfig( + provider_attr="embedding_provider", + model_attr="embedding_model", + endpoint_attr="embedding_endpoint", + legacy_secret_attr="embedding_api_key_encrypted", + default_provider="GLM", + default_model="Embedding-3", + default_endpoint="https://open.bigmodel.cn/api/paas/v4/", + capability="embedding", + priority=40, + ), } @@ -42,6 +97,7 @@ class SettingsService: settings_row = self.repository.get_settings() secrets_row = self.repository.get_secrets() should_commit = False + legacy_admin = read_admin_secret() if settings_row is None: settings_row = self._build_default_settings() @@ -53,6 +109,13 @@ class SettingsService: self.db.add(secrets_row) should_commit = True + if legacy_admin is not None and not secrets_row.admin_password_hash: + secrets_row.admin_password_hash = legacy_admin_secret_to_password_hash(legacy_admin) + admin_username = str(legacy_admin.get("username", "")).strip() + if admin_username and str(settings_row.admin_account or "").strip() in {"", "superadmin"}: + settings_row.admin_account = admin_username + should_commit = True + if should_commit: self.db.commit() self.db.refresh(settings_row) @@ -60,12 +123,47 @@ class SettingsService: return settings_row, secrets_row + def ensure_model_settings_ready( + self, + settings_row: SystemSetting, + secrets_row: SystemSettingSecret, + ) -> dict[str, SystemModelSetting]: + model_rows = {row.slot: row for row in self.repository.get_model_settings()} + should_commit = False + + for slot, config in MODEL_SLOT_CONFIGS.items(): + if slot in model_rows: + continue + + model_row = SystemModelSetting( + slot=slot, + provider=str(getattr(settings_row, config.provider_attr, "") or config.default_provider), + model_name=str(getattr(settings_row, config.model_attr, "") or config.default_model), + endpoint=str(getattr(settings_row, config.endpoint_attr, "") or config.default_endpoint), + capability=config.capability, + priority=config.priority, + enabled=True, + api_key_encrypted=str(getattr(secrets_row, config.legacy_secret_attr, "") or ""), + ) + self.db.add(model_row) + model_rows[slot] = model_row + should_commit = True + + if should_commit: + self.db.commit() + for model_row in model_rows.values(): + self.db.refresh(model_row) + + return model_rows + def get_settings_snapshot(self) -> SettingsRead: settings_row, secrets_row = self.ensure_settings_ready() - return self._serialize(settings_row, secrets_row) + model_rows = self.ensure_model_settings_ready(settings_row, secrets_row) + return self._serialize(settings_row, secrets_row, model_rows) def save_settings_snapshot(self, payload: SettingsWrite) -> SettingsRead: settings_row, secrets_row = self.ensure_settings_ready() + model_rows = self.ensure_model_settings_ready(settings_row, secrets_row) if payload.adminForm.newPassword: if len(payload.adminForm.newPassword) < 5: @@ -88,28 +186,48 @@ class SettingsService: 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", + self._apply_model_setting( + model_rows["main"], + payload.llmForm.mainProvider, + payload.llmForm.mainModel, + payload.llmForm.mainEndpoint, + payload.llmForm.mainApiKey, + ) + self._apply_model_setting( + model_rows["backup"], + payload.llmForm.backupProvider, + payload.llmForm.backupModel, + payload.llmForm.backupEndpoint, + payload.llmForm.backupApiKey, + ) + self._apply_model_setting( + model_rows["vlm"], + payload.llmForm.vlmProvider, + payload.llmForm.vlmModel, + payload.llmForm.vlmEndpoint, + payload.llmForm.vlmApiKey, + ) + self._apply_model_setting( + model_rows["embedding"], + payload.llmForm.embeddingProvider, + payload.llmForm.embeddingModel, + payload.llmForm.embeddingEndpoint, payload.llmForm.embeddingApiKey, ) + settings_row.main_provider = model_rows["main"].provider + settings_row.main_model = model_rows["main"].model_name + settings_row.main_endpoint = model_rows["main"].endpoint + settings_row.backup_provider = model_rows["backup"].provider + settings_row.backup_model = model_rows["backup"].model_name + settings_row.backup_endpoint = model_rows["backup"].endpoint + settings_row.vlm_provider = model_rows["vlm"].provider + settings_row.vlm_model = model_rows["vlm"].model_name + settings_row.vlm_endpoint = model_rows["vlm"].endpoint + settings_row.embedding_provider = model_rows["embedding"].provider + settings_row.embedding_model = model_rows["embedding"].model_name + settings_row.embedding_endpoint = model_rows["embedding"].endpoint + settings_row.log_level = payload.logForm.level settings_row.retention_days = payload.logForm.retentionDays settings_row.archive_cycle = payload.logForm.archiveCycle @@ -134,18 +252,23 @@ class SettingsService: self.db.add(settings_row) self.db.add(secrets_row) + for model_row in model_rows.values(): + self.db.add(model_row) self.db.commit() self.db.refresh(settings_row) self.db.refresh(secrets_row) + for model_row in model_rows.values(): + self.db.refresh(model_row) - return self._serialize(settings_row, secrets_row) + return self._serialize(settings_row, secrets_row, model_rows) def load_saved_model_api_key(self, slot: str | None) -> str: - if not slot or slot not in MODEL_SECRET_FIELDS: + if not slot or slot not in MODEL_SLOT_CONFIGS: return "" - _, secrets_row = self.ensure_settings_ready() - encrypted_value = getattr(secrets_row, MODEL_SECRET_FIELDS[slot], "") + settings_row, secrets_row = self.ensure_settings_ready() + model_rows = self.ensure_model_settings_ready(settings_row, secrets_row) + encrypted_value = model_rows[slot].api_key_encrypted if not encrypted_value: return "" @@ -282,7 +405,32 @@ class SettingsService: setattr(secret_row, field_name, encrypt_secret(normalized)) @staticmethod - def _serialize(settings_row: SystemSetting, secrets_row: SystemSettingSecret) -> SettingsRead: + def _apply_model_setting( + model_row: SystemModelSetting, + provider: str, + model_name: str, + endpoint: str, + api_key: str, + ) -> None: + model_row.provider = provider + model_row.model_name = model_name + model_row.endpoint = endpoint + + normalized_api_key = api_key.strip() + if normalized_api_key: + model_row.api_key_encrypted = encrypt_secret(normalized_api_key) + + @staticmethod + def _serialize( + settings_row: SystemSetting, + secrets_row: SystemSettingSecret, + model_rows: dict[str, SystemModelSetting], + ) -> SettingsRead: + main_model = model_rows["main"] + backup_model = model_rows["backup"] + vlm_model = model_rows["vlm"] + embedding_model = model_rows["embedding"] + return SettingsRead( companyForm={ "companyName": settings_row.company_name, @@ -304,26 +452,26 @@ class SettingsService: "adminPasswordConfigured": bool(secrets_row.admin_password_hash), }, llmForm={ - "mainProvider": settings_row.main_provider, - "mainModel": settings_row.main_model, - "mainEndpoint": settings_row.main_endpoint, + "mainProvider": main_model.provider, + "mainModel": main_model.model_name, + "mainEndpoint": main_model.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, + "mainApiKeyConfigured": bool(main_model.api_key_encrypted), + "backupProvider": backup_model.provider, + "backupModel": backup_model.model_name, + "backupEndpoint": backup_model.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, + "backupApiKeyConfigured": bool(backup_model.api_key_encrypted), + "vlmProvider": vlm_model.provider, + "vlmModel": vlm_model.model_name, + "vlmEndpoint": vlm_model.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, + "vlmApiKeyConfigured": bool(vlm_model.api_key_encrypted), + "embeddingProvider": embedding_model.provider, + "embeddingModel": embedding_model.model_name, + "embeddingEndpoint": embedding_model.endpoint, "embeddingApiKey": "", - "embeddingApiKeyConfigured": bool(secrets_row.embedding_api_key_encrypted), + "embeddingApiKeyConfigured": bool(embedding_model.api_key_encrypted), }, logForm={ "level": settings_row.log_level, diff --git a/server/tests/test_auth_service.py b/server/tests/test_auth_service.py index fa06bdf..0fff7d1 100644 --- a/server/tests/test_auth_service.py +++ b/server/tests/test_auth_service.py @@ -6,8 +6,10 @@ from sqlalchemy.pool import StaticPool from app.db.base import Base from app.schemas.auth import LoginRequest +from app.schemas.settings import SettingsWrite from app.services.auth import AuthService from app.services.employee import EmployeeService +from app.services.settings import SettingsService def build_session() -> Session: @@ -35,18 +37,14 @@ def test_employee_can_login_with_seed_default_password() -> None: assert result.user.isAdmin is False -def test_admin_can_login_with_secret(monkeypatch) -> None: +def test_admin_can_login_with_database_password() -> None: with build_session() as db: - monkeypatch.setattr( - "app.services.auth.read_admin_secret", - lambda: { - "username": "superadmin", - "algorithm": "scrypt", - "salt": "00", - "derived_key": "00", - }, - ) - monkeypatch.setattr("app.services.auth.verify_admin_secret", lambda password, record: password == "admin123") + settings_service = SettingsService(db) + payload = settings_service.get_settings_snapshot().model_dump() + payload["adminForm"]["adminAccount"] = "superadmin" + payload["adminForm"]["newPassword"] = "admin123" + payload["adminForm"]["confirmPassword"] = "admin123" + settings_service.save_settings_snapshot(SettingsWrite(**payload)) result = AuthService(db).login( LoginRequest(username="superadmin", password="admin123") diff --git a/server/tests/test_settings_persistence.py b/server/tests/test_settings_persistence.py index a3f31c6..7c8669a 100644 --- a/server/tests/test_settings_persistence.py +++ b/server/tests/test_settings_persistence.py @@ -1,13 +1,18 @@ from __future__ import annotations from pathlib import Path +import hashlib +import json +import secrets import tempfile from sqlalchemy import create_engine from sqlalchemy.orm import Session, sessionmaker +from app.core import admin_secret from app.core import secret_box from app.db.base import Base +from app.models.system_model_setting import SystemModelSetting from app.models.system_setting import SystemSetting from app.models.system_setting_secret import SystemSettingSecret from app.schemas.settings import SettingsWrite @@ -21,12 +26,13 @@ def build_session(db_file: Path) -> Session: ) SystemSetting.__table__.create(bind=engine) SystemSettingSecret.__table__.create(bind=engine) + SystemModelSetting.__table__.create(bind=engine) session_factory = sessionmaker(bind=engine, autoflush=False, autocommit=False) return session_factory() def build_temp_secret_dir() -> Path: - return Path(tempfile.mkdtemp(prefix="xf-settings-test-", dir="D:\\tmp")) + return Path(tempfile.mkdtemp(prefix="xf-settings-test-")) def test_settings_service_persists_non_secret_and_secret_fields(monkeypatch) -> None: @@ -61,6 +67,11 @@ def test_settings_service_persists_non_secret_and_secret_fields(monkeypatch) -> assert saved_snapshot.adminForm.newPassword == "" assert saved_snapshot.adminForm.adminPasswordConfigured is True + model_row = db.get(SystemModelSetting, "main") + assert model_row is not None + assert model_row.model_name == "glm-4.5" + assert model_row.api_key_encrypted + assert service.load_saved_model_api_key("main") == "main-secret" assert service.verify_admin_login("admin-root", "54321") is not None assert service.verify_admin_login("admin@example.com", "54321") is not None @@ -82,3 +93,40 @@ def test_blank_secret_input_does_not_clear_saved_secret(monkeypatch) -> None: service.save_settings_snapshot(SettingsWrite(**second_payload)) assert service.load_saved_model_api_key("main") == "persisted-key" + + +def test_legacy_setup_admin_password_is_migrated_to_database(monkeypatch) -> None: + temp_dir = build_temp_secret_dir() + admin_file = temp_dir / "admin.json" + monkeypatch.setattr(admin_secret, "ADMIN_SECRET_FILE", admin_file) + monkeypatch.setattr(secret_box, "SECRET_KEY_FILE", temp_dir / "settings.key") + monkeypatch.setattr(Base.metadata, "create_all", lambda *args, **kwargs: None) + + password = "setup-secret" + salt = secrets.token_bytes(16) + derived_key = hashlib.scrypt(password.encode("utf-8"), salt=salt, n=16384, r=8, p=1, dklen=64) + admin_file.write_text( + json.dumps( + { + "algorithm": "scrypt", + "username": "setup-admin", + "salt": salt.hex(), + "derived_key": derived_key.hex(), + "key_length": 64, + "N": 16384, + "r": 8, + "p": 1, + } + ), + encoding="utf-8", + ) + + with build_session(temp_dir / "settings.db") as db: + service = SettingsService(db) + snapshot = service.get_settings_snapshot() + secrets_row = db.get(SystemSettingSecret, "default") + + assert snapshot.adminForm.adminPasswordConfigured is True + assert secrets_row is not None + assert secrets_row.admin_password_hash.startswith("scrypt$") + assert service.verify_admin_login("setup-admin", password) is not None diff --git a/web/src/assets/styles/views/settings-view.css b/web/src/assets/styles/views/settings-view.css index 3e949b5..89598a7 100644 --- a/web/src/assets/styles/views/settings-view.css +++ b/web/src/assets/styles/views/settings-view.css @@ -349,6 +349,21 @@ box-shadow: 0 0 0 4px rgba(16, 185, 129, 0.12); } +.secret-bound-state { + min-height: 24px; + display: inline-flex; + align-items: center; + gap: 7px; + color: #047857; + font-size: 12px; + font-weight: 750; + line-height: 1.45; +} + +.secret-bound-state i { + font-size: 15px; +} + .test-feedback { display: flex; align-items: flex-start; diff --git a/web/src/composables/useSystemState.js b/web/src/composables/useSystemState.js index eb0169b..f50888b 100644 --- a/web/src/composables/useSystemState.js +++ b/web/src/composables/useSystemState.js @@ -331,6 +331,7 @@ export function installSessionNavigation(router) { fetchBootstrapState() .then((state) => { applyBootstrapState(state) + setRuntimeApiBaseUrl(resolveBrowserApiBaseUrl(state)) router.isReady().then(() => reconcileEntryRoute(router)) }) .catch(() => { diff --git a/web/src/services/api.js b/web/src/services/api.js index 22559a0..e18a1cc 100644 --- a/web/src/services/api.js +++ b/web/src/services/api.js @@ -4,18 +4,47 @@ function normalizeApiBaseUrl(value) { return String(value || '/api/v1').replace(/\/$/, '') } +function isLoopbackHost(hostname) { + const normalized = String(hostname || '').trim().toLowerCase() + return normalized === '127.0.0.1' || normalized === 'localhost' || normalized === '0.0.0.0' || normalized === '::1' +} + +function resolveBrowserReachableApiBaseUrl(value) { + const normalized = normalizeApiBaseUrl(value) + + if (typeof window === 'undefined') { + return normalized + } + + try { + const apiUrl = new URL(normalized) + const browserHost = window.location.hostname + + if (isLoopbackHost(apiUrl.hostname) && browserHost && !isLoopbackHost(browserHost)) { + apiUrl.hostname = browserHost + return normalizeApiBaseUrl(apiUrl.toString()) + } + } catch { + return normalized + } + + return normalized +} + function readStoredApiBaseUrl() { if (typeof window === 'undefined') { return '' } - return window.localStorage.getItem(API_BASE_STORAGE_KEY) || '' + return resolveBrowserReachableApiBaseUrl(window.localStorage.getItem(API_BASE_STORAGE_KEY) || '') } -let runtimeApiBaseUrl = normalizeApiBaseUrl(readStoredApiBaseUrl() || import.meta.env.VITE_API_BASE_URL || '/api/v1') +let runtimeApiBaseUrl = resolveBrowserReachableApiBaseUrl( + readStoredApiBaseUrl() || import.meta.env.VITE_API_BASE_URL || '/api/v1' +) export function setRuntimeApiBaseUrl(value) { - runtimeApiBaseUrl = normalizeApiBaseUrl(value) + runtimeApiBaseUrl = resolveBrowserReachableApiBaseUrl(value) if (typeof window !== 'undefined') { window.localStorage.setItem(API_BASE_STORAGE_KEY, runtimeApiBaseUrl) @@ -46,7 +75,7 @@ export async function apiRequest(path, options = {}) { ...options }) } catch { - throw new Error('无法连接后端员工服务,请确认 FastAPI 已启动。') + throw new Error('无法连接 FastAPI 后端服务,请确认后端已启动且浏览器可访问后端端口。') } let payload = null diff --git a/web/src/views/SettingsView.vue b/web/src/views/SettingsView.vue index fae3ee6..2c56bb6 100644 --- a/web/src/views/SettingsView.vue +++ b/web/src/views/SettingsView.vue @@ -223,8 +223,13 @@ v-model="pageState.llmForm.mainApiKey" type="password" autocomplete="off" + @focus="clearModelSecretMask('main')" :placeholder="pageState.llmForm.mainApiKeyConfigured ? '已配置,如需修改请重新输入' : '保存后不会保留在草稿中'" /> + + + 已从数据库加密加载,测试会使用已保存密钥。 +
@@ -271,8 +276,13 @@ v-model="pageState.llmForm.backupApiKey" type="password" autocomplete="off" + @focus="clearModelSecretMask('backup')" :placeholder="pageState.llmForm.backupApiKeyConfigured ? '已配置,如需修改请重新输入' : '保存后不会保留在草稿中'" /> + + + 已从数据库加密加载,测试会使用已保存密钥。 +
@@ -319,8 +329,13 @@ v-model="pageState.llmForm.vlmApiKey" type="password" autocomplete="off" + @focus="clearModelSecretMask('vlm')" :placeholder="pageState.llmForm.vlmApiKeyConfigured ? '已配置,如需修改请重新输入' : '保存后不会保留在草稿中'" /> + + + 已从数据库加密加载,测试会使用已保存密钥。 +
@@ -367,8 +382,13 @@ v-model="pageState.llmForm.embeddingApiKey" type="password" autocomplete="off" + @focus="clearModelSecretMask('embedding')" :placeholder="pageState.llmForm.embeddingApiKeyConfigured ? '已配置,如需修改请重新输入' : '保存后不会保留在草稿中'" /> + + + 已从数据库加密加载,测试会使用已保存密钥。 +

所有必要步骤已通过检测,可以写入配置并进入登录界面。

+

+ + {{ progressMessage }} +

@@ -132,7 +136,7 @@
+ +
+
+
+
+

BACKEND STARTUP

+

正在完成系统启动

+ {{ progressMessage || '正在准备后端服务...' }} +
+ +
+ +
+
    +
  1. + +
    + {{ step.label }} + {{ step.detail }} +
    +
  2. +
+ +
+
+ 执行日志 + server/logs/bootstrap-backend.log +
+
{{ startupLog || '等待后端启动输出...' }}
+
+
+
+
diff --git a/web/src/views/scripts/SettingsView.js b/web/src/views/scripts/SettingsView.js index 3085ced..f9b8bf7 100644 --- a/web/src/views/scripts/SettingsView.js +++ b/web/src/views/scripts/SettingsView.js @@ -7,6 +7,7 @@ import { useToast } from '../../composables/useToast.js' const SETTINGS_STORAGE_KEY = 'x-financial-settings-draft' const CURRENT_YEAR = new Date().getFullYear() const CUSTOM_OPENAI_PROVIDER = 'Custom OpenAI Compatible' +const MODEL_SECRET_MASK = '********' const SECTION_DEFINITIONS = [ { @@ -117,6 +118,8 @@ const MODEL_TEST_CONFIGS = { } } +const MODEL_API_KEY_CONFIGS = Object.values(MODEL_TEST_CONFIGS) + function normalizeValue(value) { return String(value ?? '').trim() } @@ -277,6 +280,38 @@ function sanitizeForStorage(state) { } } +function getModelConfiguredKey(apiKeyKey) { + return `${apiKeyKey}Configured` +} + +function isModelSecretMask(value) { + return value === MODEL_SECRET_MASK +} + +function maskConfiguredModelSecrets(state) { + for (const config of MODEL_API_KEY_CONFIGS) { + const configuredKey = getModelConfiguredKey(config.apiKeyKey) + + if (state.llmForm[configuredKey] && !normalizeValue(state.llmForm[config.apiKeyKey])) { + state.llmForm[config.apiKeyKey] = MODEL_SECRET_MASK + } + } + + return state +} + +function buildLlmPayload(llmForm) { + const payload = { ...llmForm } + + for (const config of MODEL_API_KEY_CONFIGS) { + if (isModelSecretMask(payload[config.apiKeyKey])) { + payload[config.apiKeyKey] = '' + } + } + + return payload +} + function persistSettings(state) { if (typeof window === 'undefined') { return @@ -390,7 +425,7 @@ export default { nextState.mailForm.password = currentState.mailForm.password } - pageState.value = nextState + pageState.value = maskConfiguredModelSecrets(nextState) persistSettings(pageState.value) updateBrandPreviewFromState(pageState.value) } @@ -410,7 +445,7 @@ export default { return { companyForm: { ...pageState.value.companyForm }, adminForm: { ...pageState.value.adminForm }, - llmForm: { ...pageState.value.llmForm }, + llmForm: buildLlmPayload(pageState.value.llmForm), logForm: { ...pageState.value.logForm }, mailForm: { ...pageState.value.mailForm } } @@ -456,17 +491,26 @@ export default { function buildModelTestPayload(testKey) { const config = MODEL_TEST_CONFIGS[testKey] const llmForm = pageState.value.llmForm + const apiKey = llmForm[config.apiKeyKey] return { provider: llmForm[config.providerKey], model: llmForm[config.modelKey], endpoint: llmForm[config.endpointKey], - api_key: llmForm[config.apiKeyKey], + api_key: isModelSecretMask(apiKey) ? '' : apiKey, capability: config.capability, slot: testKey } } + function clearModelSecretMask(testKey) { + const config = MODEL_TEST_CONFIGS[testKey] + + if (isModelSecretMask(pageState.value.llmForm[config.apiKeyKey])) { + pageState.value.llmForm[config.apiKeyKey] = '' + } + } + async function testModelConnection(testKey) { const config = MODEL_TEST_CONFIGS[testKey] const payload = buildModelTestPayload(testKey) @@ -655,6 +699,7 @@ export default { activeSectionConfig, activateSection, applyProviderPreset, + clearModelSecretMask, completedSectionCount, getModelTestState, isModelTesting,