feat: 重构模型配置存储与 API Key 加密管理

主要修改点:

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 样式: 显示数据库已加载密钥的提示样式
This commit is contained in:
2026-05-08 11:14:04 +08:00
parent c5486dd3d3
commit 86568660a4
15 changed files with 532 additions and 68 deletions

View File

@@ -51,3 +51,13 @@ def verify_admin_secret(password: str, record: dict[str, object]) -> bool:
dklen=key_length, dklen=key_length,
) )
return secrets.compare_digest(derived_key, stored_key) 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}"

View File

@@ -23,6 +23,9 @@ def hash_password(password: str) -> str:
def verify_password(password: str, password_hash: str) -> bool: def verify_password(password: str, password_hash: str) -> bool:
if password_hash.startswith("scrypt$"):
return verify_scrypt_password(password, password_hash)
try: try:
scheme, iterations, encoded_salt, encoded_digest = password_hash.split("$", 3) scheme, iterations, encoded_salt, encoded_digest = password_hash.split("$", 3)
except ValueError: except ValueError:
@@ -40,3 +43,29 @@ def verify_password(password: str, password_hash: str) -> bool:
int(iterations), int(iterations),
) )
return secrets.compare_digest(computed_digest, expected_digest) 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)

View File

@@ -5,6 +5,7 @@ from app.models.employee import Employee
from app.models.organization import OrganizationUnit from app.models.organization import OrganizationUnit
from app.models.reimbursement import ReimbursementRequest from app.models.reimbursement import ReimbursementRequest
from app.models.role import Role 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 import SystemSetting
from app.models.system_setting_secret import SystemSettingSecret from app.models.system_setting_secret import SystemSettingSecret
@@ -16,6 +17,7 @@ __all__ = [
"OrganizationUnit", "OrganizationUnit",
"ReimbursementRequest", "ReimbursementRequest",
"Role", "Role",
"SystemModelSetting",
"SystemSetting", "SystemSetting",
"SystemSettingSecret", "SystemSettingSecret",
] ]

View File

@@ -4,6 +4,7 @@ from app.models.employee import Employee
from app.models.organization import OrganizationUnit from app.models.organization import OrganizationUnit
from app.models.reimbursement import ReimbursementRequest from app.models.reimbursement import ReimbursementRequest
from app.models.role import Role 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 import SystemSetting
from app.models.system_setting_secret import SystemSettingSecret from app.models.system_setting_secret import SystemSettingSecret
@@ -14,6 +15,7 @@ __all__ = [
"OrganizationUnit", "OrganizationUnit",
"ReimbursementRequest", "ReimbursementRequest",
"Role", "Role",
"SystemModelSetting",
"SystemSetting", "SystemSetting",
"SystemSettingSecret", "SystemSettingSecret",
] ]

View File

@@ -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(),
)

View File

@@ -3,6 +3,7 @@ from __future__ import annotations
from sqlalchemy import select from sqlalchemy import select
from sqlalchemy.orm import Session 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 import SystemSetting
from app.models.system_setting_secret import SystemSettingSecret from app.models.system_setting_secret import SystemSettingSecret
@@ -21,6 +22,14 @@ class SettingsRepository:
stmt = select(SystemSettingSecret).where(SystemSettingSecret.id == SETTINGS_ROW_ID) stmt = select(SystemSettingSecret).where(SystemSettingSecret.id == SETTINGS_ROW_ID)
return self.db.execute(stmt).scalars().first() 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: def save_settings(self, settings: SystemSetting) -> SystemSetting:
self.db.add(settings) self.db.add(settings)
self.db.commit() self.db.commit()

View File

@@ -5,21 +5,76 @@ from datetime import datetime
from sqlalchemy.orm import Session 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.config import get_settings
from app.core.secret_box import decrypt_secret, encrypt_secret from app.core.secret_box import decrypt_secret, encrypt_secret
from app.core.security import hash_password, verify_password from app.core.security import hash_password, verify_password
from app.db.base import Base 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 import SystemSetting
from app.models.system_setting_secret import SystemSettingSecret from app.models.system_setting_secret import SystemSettingSecret
from app.repositories.settings import SETTINGS_ROW_ID, SettingsRepository from app.repositories.settings import SETTINGS_ROW_ID, SettingsRepository
from app.schemas.settings import SettingsRead, SettingsWrite from app.schemas.settings import SettingsRead, SettingsWrite
MODEL_SECRET_FIELDS = {
"main": "main_api_key_encrypted", @dataclass(frozen=True, slots=True)
"backup": "backup_api_key_encrypted", class ModelSlotConfig:
"vlm": "vlm_api_key_encrypted", provider_attr: str
"embedding": "embedding_api_key_encrypted", 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() settings_row = self.repository.get_settings()
secrets_row = self.repository.get_secrets() secrets_row = self.repository.get_secrets()
should_commit = False should_commit = False
legacy_admin = read_admin_secret()
if settings_row is None: if settings_row is None:
settings_row = self._build_default_settings() settings_row = self._build_default_settings()
@@ -53,6 +109,13 @@ class SettingsService:
self.db.add(secrets_row) self.db.add(secrets_row)
should_commit = True 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: if should_commit:
self.db.commit() self.db.commit()
self.db.refresh(settings_row) self.db.refresh(settings_row)
@@ -60,12 +123,47 @@ class SettingsService:
return settings_row, secrets_row 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: def get_settings_snapshot(self) -> SettingsRead:
settings_row, secrets_row = self.ensure_settings_ready() 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: def save_settings_snapshot(self, payload: SettingsWrite) -> SettingsRead:
settings_row, secrets_row = self.ensure_settings_ready() settings_row, secrets_row = self.ensure_settings_ready()
model_rows = self.ensure_model_settings_ready(settings_row, secrets_row)
if payload.adminForm.newPassword: if payload.adminForm.newPassword:
if len(payload.adminForm.newPassword) < 5: if len(payload.adminForm.newPassword) < 5:
@@ -88,28 +186,48 @@ class SettingsService:
settings_row.strong_password = payload.adminForm.strongPassword settings_row.strong_password = payload.adminForm.strongPassword
settings_row.login_alert_enabled = payload.adminForm.loginAlertEnabled settings_row.login_alert_enabled = payload.adminForm.loginAlertEnabled
settings_row.main_provider = payload.llmForm.mainProvider self._apply_model_setting(
settings_row.main_model = payload.llmForm.mainModel model_rows["main"],
settings_row.main_endpoint = payload.llmForm.mainEndpoint payload.llmForm.mainProvider,
settings_row.backup_provider = payload.llmForm.backupProvider payload.llmForm.mainModel,
settings_row.backup_model = payload.llmForm.backupModel payload.llmForm.mainEndpoint,
settings_row.backup_endpoint = payload.llmForm.backupEndpoint payload.llmForm.mainApiKey,
settings_row.vlm_provider = payload.llmForm.vlmProvider )
settings_row.vlm_model = payload.llmForm.vlmModel self._apply_model_setting(
settings_row.vlm_endpoint = payload.llmForm.vlmEndpoint model_rows["backup"],
settings_row.embedding_provider = payload.llmForm.embeddingProvider payload.llmForm.backupProvider,
settings_row.embedding_model = payload.llmForm.embeddingModel payload.llmForm.backupModel,
settings_row.embedding_endpoint = payload.llmForm.embeddingEndpoint payload.llmForm.backupEndpoint,
payload.llmForm.backupApiKey,
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._apply_model_setting(
self._replace_secret_if_present(secrets_row, "vlm_api_key_encrypted", payload.llmForm.vlmApiKey) model_rows["vlm"],
self._replace_secret_if_present( payload.llmForm.vlmProvider,
secrets_row, payload.llmForm.vlmModel,
"embedding_api_key_encrypted", 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, 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.log_level = payload.logForm.level
settings_row.retention_days = payload.logForm.retentionDays settings_row.retention_days = payload.logForm.retentionDays
settings_row.archive_cycle = payload.logForm.archiveCycle settings_row.archive_cycle = payload.logForm.archiveCycle
@@ -134,18 +252,23 @@ class SettingsService:
self.db.add(settings_row) self.db.add(settings_row)
self.db.add(secrets_row) self.db.add(secrets_row)
for model_row in model_rows.values():
self.db.add(model_row)
self.db.commit() self.db.commit()
self.db.refresh(settings_row) self.db.refresh(settings_row)
self.db.refresh(secrets_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: 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 "" return ""
_, secrets_row = self.ensure_settings_ready() settings_row, secrets_row = self.ensure_settings_ready()
encrypted_value = getattr(secrets_row, MODEL_SECRET_FIELDS[slot], "") model_rows = self.ensure_model_settings_ready(settings_row, secrets_row)
encrypted_value = model_rows[slot].api_key_encrypted
if not encrypted_value: if not encrypted_value:
return "" return ""
@@ -282,7 +405,32 @@ class SettingsService:
setattr(secret_row, field_name, encrypt_secret(normalized)) setattr(secret_row, field_name, encrypt_secret(normalized))
@staticmethod @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( return SettingsRead(
companyForm={ companyForm={
"companyName": settings_row.company_name, "companyName": settings_row.company_name,
@@ -304,26 +452,26 @@ class SettingsService:
"adminPasswordConfigured": bool(secrets_row.admin_password_hash), "adminPasswordConfigured": bool(secrets_row.admin_password_hash),
}, },
llmForm={ llmForm={
"mainProvider": settings_row.main_provider, "mainProvider": main_model.provider,
"mainModel": settings_row.main_model, "mainModel": main_model.model_name,
"mainEndpoint": settings_row.main_endpoint, "mainEndpoint": main_model.endpoint,
"mainApiKey": "", "mainApiKey": "",
"mainApiKeyConfigured": bool(secrets_row.main_api_key_encrypted), "mainApiKeyConfigured": bool(main_model.api_key_encrypted),
"backupProvider": settings_row.backup_provider, "backupProvider": backup_model.provider,
"backupModel": settings_row.backup_model, "backupModel": backup_model.model_name,
"backupEndpoint": settings_row.backup_endpoint, "backupEndpoint": backup_model.endpoint,
"backupApiKey": "", "backupApiKey": "",
"backupApiKeyConfigured": bool(secrets_row.backup_api_key_encrypted), "backupApiKeyConfigured": bool(backup_model.api_key_encrypted),
"vlmProvider": settings_row.vlm_provider, "vlmProvider": vlm_model.provider,
"vlmModel": settings_row.vlm_model, "vlmModel": vlm_model.model_name,
"vlmEndpoint": settings_row.vlm_endpoint, "vlmEndpoint": vlm_model.endpoint,
"vlmApiKey": "", "vlmApiKey": "",
"vlmApiKeyConfigured": bool(secrets_row.vlm_api_key_encrypted), "vlmApiKeyConfigured": bool(vlm_model.api_key_encrypted),
"embeddingProvider": settings_row.embedding_provider, "embeddingProvider": embedding_model.provider,
"embeddingModel": settings_row.embedding_model, "embeddingModel": embedding_model.model_name,
"embeddingEndpoint": settings_row.embedding_endpoint, "embeddingEndpoint": embedding_model.endpoint,
"embeddingApiKey": "", "embeddingApiKey": "",
"embeddingApiKeyConfigured": bool(secrets_row.embedding_api_key_encrypted), "embeddingApiKeyConfigured": bool(embedding_model.api_key_encrypted),
}, },
logForm={ logForm={
"level": settings_row.log_level, "level": settings_row.log_level,

View File

@@ -6,8 +6,10 @@ from sqlalchemy.pool import StaticPool
from app.db.base import Base from app.db.base import Base
from app.schemas.auth import LoginRequest from app.schemas.auth import LoginRequest
from app.schemas.settings import SettingsWrite
from app.services.auth import AuthService from app.services.auth import AuthService
from app.services.employee import EmployeeService from app.services.employee import EmployeeService
from app.services.settings import SettingsService
def build_session() -> Session: def build_session() -> Session:
@@ -35,18 +37,14 @@ def test_employee_can_login_with_seed_default_password() -> None:
assert result.user.isAdmin is False 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: with build_session() as db:
monkeypatch.setattr( settings_service = SettingsService(db)
"app.services.auth.read_admin_secret", payload = settings_service.get_settings_snapshot().model_dump()
lambda: { payload["adminForm"]["adminAccount"] = "superadmin"
"username": "superadmin", payload["adminForm"]["newPassword"] = "admin123"
"algorithm": "scrypt", payload["adminForm"]["confirmPassword"] = "admin123"
"salt": "00", settings_service.save_settings_snapshot(SettingsWrite(**payload))
"derived_key": "00",
},
)
monkeypatch.setattr("app.services.auth.verify_admin_secret", lambda password, record: password == "admin123")
result = AuthService(db).login( result = AuthService(db).login(
LoginRequest(username="superadmin", password="admin123") LoginRequest(username="superadmin", password="admin123")

View File

@@ -1,13 +1,18 @@
from __future__ import annotations from __future__ import annotations
from pathlib import Path from pathlib import Path
import hashlib
import json
import secrets
import tempfile import tempfile
from sqlalchemy import create_engine from sqlalchemy import create_engine
from sqlalchemy.orm import Session, sessionmaker from sqlalchemy.orm import Session, sessionmaker
from app.core import admin_secret
from app.core import secret_box from app.core import secret_box
from app.db.base import Base 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 import SystemSetting
from app.models.system_setting_secret import SystemSettingSecret from app.models.system_setting_secret import SystemSettingSecret
from app.schemas.settings import SettingsWrite from app.schemas.settings import SettingsWrite
@@ -21,12 +26,13 @@ def build_session(db_file: Path) -> Session:
) )
SystemSetting.__table__.create(bind=engine) SystemSetting.__table__.create(bind=engine)
SystemSettingSecret.__table__.create(bind=engine) SystemSettingSecret.__table__.create(bind=engine)
SystemModelSetting.__table__.create(bind=engine)
session_factory = sessionmaker(bind=engine, autoflush=False, autocommit=False) session_factory = sessionmaker(bind=engine, autoflush=False, autocommit=False)
return session_factory() return session_factory()
def build_temp_secret_dir() -> Path: 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: 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.newPassword == ""
assert saved_snapshot.adminForm.adminPasswordConfigured is True 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.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-root", "54321") is not None
assert service.verify_admin_login("admin@example.com", "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)) service.save_settings_snapshot(SettingsWrite(**second_payload))
assert service.load_saved_model_api_key("main") == "persisted-key" 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

View File

@@ -349,6 +349,21 @@
box-shadow: 0 0 0 4px rgba(16, 185, 129, 0.12); 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 { .test-feedback {
display: flex; display: flex;
align-items: flex-start; align-items: flex-start;

View File

@@ -331,6 +331,7 @@ export function installSessionNavigation(router) {
fetchBootstrapState() fetchBootstrapState()
.then((state) => { .then((state) => {
applyBootstrapState(state) applyBootstrapState(state)
setRuntimeApiBaseUrl(resolveBrowserApiBaseUrl(state))
router.isReady().then(() => reconcileEntryRoute(router)) router.isReady().then(() => reconcileEntryRoute(router))
}) })
.catch(() => { .catch(() => {

View File

@@ -4,18 +4,47 @@ function normalizeApiBaseUrl(value) {
return String(value || '/api/v1').replace(/\/$/, '') 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() { function readStoredApiBaseUrl() {
if (typeof window === 'undefined') { if (typeof window === 'undefined') {
return '' 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) { export function setRuntimeApiBaseUrl(value) {
runtimeApiBaseUrl = normalizeApiBaseUrl(value) runtimeApiBaseUrl = resolveBrowserReachableApiBaseUrl(value)
if (typeof window !== 'undefined') { if (typeof window !== 'undefined') {
window.localStorage.setItem(API_BASE_STORAGE_KEY, runtimeApiBaseUrl) window.localStorage.setItem(API_BASE_STORAGE_KEY, runtimeApiBaseUrl)
@@ -46,7 +75,7 @@ export async function apiRequest(path, options = {}) {
...options ...options
}) })
} catch { } catch {
throw new Error('无法连接后端员工服务,请确认 FastAPI 已启动。') throw new Error('无法连接 FastAPI 后端服务,请确认后端已启动且浏览器可访问后端端口。')
} }
let payload = null let payload = null

View File

@@ -223,8 +223,13 @@
v-model="pageState.llmForm.mainApiKey" v-model="pageState.llmForm.mainApiKey"
type="password" type="password"
autocomplete="off" autocomplete="off"
@focus="clearModelSecretMask('main')"
:placeholder="pageState.llmForm.mainApiKeyConfigured ? '已配置,如需修改请重新输入' : '保存后不会保留在草稿中'" :placeholder="pageState.llmForm.mainApiKeyConfigured ? '已配置,如需修改请重新输入' : '保存后不会保留在草稿中'"
/> />
<small v-if="pageState.llmForm.mainApiKeyConfigured" class="secret-bound-state">
<i class="mdi mdi-database-lock"></i>
<span>已从数据库加密加载测试会使用已保存密钥</span>
</small>
</label> </label>
</div> </div>
<div v-if="getModelTestState('main').message" class="test-feedback" :class="`is-${getModelTestState('main').status}`"> <div v-if="getModelTestState('main').message" class="test-feedback" :class="`is-${getModelTestState('main').status}`">
@@ -271,8 +276,13 @@
v-model="pageState.llmForm.backupApiKey" v-model="pageState.llmForm.backupApiKey"
type="password" type="password"
autocomplete="off" autocomplete="off"
@focus="clearModelSecretMask('backup')"
:placeholder="pageState.llmForm.backupApiKeyConfigured ? '已配置,如需修改请重新输入' : '保存后不会保留在草稿中'" :placeholder="pageState.llmForm.backupApiKeyConfigured ? '已配置,如需修改请重新输入' : '保存后不会保留在草稿中'"
/> />
<small v-if="pageState.llmForm.backupApiKeyConfigured" class="secret-bound-state">
<i class="mdi mdi-database-lock"></i>
<span>已从数据库加密加载测试会使用已保存密钥</span>
</small>
</label> </label>
</div> </div>
<div v-if="getModelTestState('backup').message" class="test-feedback" :class="`is-${getModelTestState('backup').status}`"> <div v-if="getModelTestState('backup').message" class="test-feedback" :class="`is-${getModelTestState('backup').status}`">
@@ -319,8 +329,13 @@
v-model="pageState.llmForm.vlmApiKey" v-model="pageState.llmForm.vlmApiKey"
type="password" type="password"
autocomplete="off" autocomplete="off"
@focus="clearModelSecretMask('vlm')"
:placeholder="pageState.llmForm.vlmApiKeyConfigured ? '已配置,如需修改请重新输入' : '保存后不会保留在草稿中'" :placeholder="pageState.llmForm.vlmApiKeyConfigured ? '已配置,如需修改请重新输入' : '保存后不会保留在草稿中'"
/> />
<small v-if="pageState.llmForm.vlmApiKeyConfigured" class="secret-bound-state">
<i class="mdi mdi-database-lock"></i>
<span>已从数据库加密加载测试会使用已保存密钥</span>
</small>
</label> </label>
</div> </div>
<div v-if="getModelTestState('vlm').message" class="test-feedback" :class="`is-${getModelTestState('vlm').status}`"> <div v-if="getModelTestState('vlm').message" class="test-feedback" :class="`is-${getModelTestState('vlm').status}`">
@@ -367,8 +382,13 @@
v-model="pageState.llmForm.embeddingApiKey" v-model="pageState.llmForm.embeddingApiKey"
type="password" type="password"
autocomplete="off" autocomplete="off"
@focus="clearModelSecretMask('embedding')"
:placeholder="pageState.llmForm.embeddingApiKeyConfigured ? '已配置,如需修改请重新输入' : '保存后不会保留在草稿中'" :placeholder="pageState.llmForm.embeddingApiKeyConfigured ? '已配置,如需修改请重新输入' : '保存后不会保留在草稿中'"
/> />
<small v-if="pageState.llmForm.embeddingApiKeyConfigured" class="secret-bound-state">
<i class="mdi mdi-database-lock"></i>
<span>已从数据库加密加载测试会使用已保存密钥</span>
</small>
</label> </label>
</div> </div>
<div <div

View File

@@ -42,9 +42,13 @@
<div v-if="canSubmit" class="setup-complete"> <div v-if="canSubmit" class="setup-complete">
<p>所有必要步骤已通过检测可以写入配置并进入登录界面</p> <p>所有必要步骤已通过检测可以写入配置并进入登录界面</p>
<button class="primary-btn setup-complete-btn" type="button" :disabled="submitting" @click="submitForm"> <button class="primary-btn setup-complete-btn" type="button" :disabled="submitting" @click="submitForm">
<i class="pi pi-check"></i> <i :class="['pi', submitting ? 'pi-spin pi-spinner' : 'pi-check']"></i>
<span>{{ submitting ? '写入配置中...' : '完成初始化并进入登录' }}</span> <span>{{ submitting ? '写入配置中...' : '完成初始化并进入登录' }}</span>
</button> </button>
<p v-if="progressMessage" class="setup-complete-progress">
<i class="pi pi-spin pi-spinner"></i>
<span>{{ progressMessage }}</span>
</p>
</div> </div>
</aside> </aside>
@@ -132,7 +136,7 @@
<div class="field-grid field-grid-2"> <div class="field-grid field-grid-2">
<label class="field"> <label class="field">
<span>Server Host</span> <span>Server Host</span>
<input v-model.trim="form.server_host" type="text" placeholder="127.0.0.1" required /> <input v-model.trim="form.server_host" type="text" placeholder="0.0.0.0" required />
</label> </label>
<label class="field"> <label class="field">
@@ -226,6 +230,46 @@
</div> </div>
</section> </section>
</main> </main>
<div v-if="startupVisible" class="setup-modal-backdrop" role="alertdialog" aria-modal="true">
<section class="setup-startup-modal" aria-label="后端启动进度">
<header class="setup-startup-head">
<div>
<p class="setup-kicker setup-kicker-light">BACKEND STARTUP</p>
<h2>正在完成系统启动</h2>
<span>{{ progressMessage || '正在准备后端服务...' }}</span>
</div>
<div class="setup-startup-spinner" aria-hidden="true">
<i v-if="!startupCountdownSeconds" class="pi pi-spin pi-spinner"></i>
<strong v-else>{{ startupCountdownSeconds }}</strong>
</div>
</header>
<div class="setup-startup-body">
<ol class="setup-startup-steps">
<li
v-for="step in startupSteps"
:key="step.id"
:class="['setup-startup-step', `is-${step.status || 'pending'}`]"
>
<i :class="startupStepIcon(step.status)"></i>
<div>
<strong>{{ step.label }}</strong>
<span>{{ step.detail }}</span>
</div>
</li>
</ol>
<section class="setup-startup-console" aria-label="后端启动日志">
<div class="setup-startup-console-head">
<strong>执行日志</strong>
<span>server/logs/bootstrap-backend.log</span>
</div>
<pre class="setup-startup-log">{{ startupLog || '等待后端启动输出...' }}</pre>
</section>
</div>
</section>
</div>
</template> </template>
<script setup> <script setup>
@@ -267,6 +311,26 @@ const props = defineProps({
errorMessage: { errorMessage: {
type: String, type: String,
default: '' default: ''
},
progressMessage: {
type: String,
default: ''
},
startupCountdownSeconds: {
type: Number,
default: 0
},
startupLog: {
type: String,
default: ''
},
startupSteps: {
type: Array,
default: () => []
},
startupVisible: {
type: Boolean,
default: false
} }
}) })
@@ -291,6 +355,22 @@ const {
testButtonLabel, testButtonLabel,
testSetup testSetup
} = useSetupView(props, emit) } = useSetupView(props, emit)
function startupStepIcon(status) {
if (status === 'success') {
return 'pi pi-check-circle'
}
if (status === 'error') {
return 'pi pi-times-circle'
}
if (status === 'running') {
return 'pi pi-spin pi-spinner'
}
return 'pi pi-circle'
}
</script> </script>
<style scoped src="../assets/styles/views/setup-view.css"></style> <style scoped src="../assets/styles/views/setup-view.css"></style>

View File

@@ -7,6 +7,7 @@ import { useToast } from '../../composables/useToast.js'
const SETTINGS_STORAGE_KEY = 'x-financial-settings-draft' const SETTINGS_STORAGE_KEY = 'x-financial-settings-draft'
const CURRENT_YEAR = new Date().getFullYear() const CURRENT_YEAR = new Date().getFullYear()
const CUSTOM_OPENAI_PROVIDER = 'Custom OpenAI Compatible' const CUSTOM_OPENAI_PROVIDER = 'Custom OpenAI Compatible'
const MODEL_SECRET_MASK = '********'
const SECTION_DEFINITIONS = [ const SECTION_DEFINITIONS = [
{ {
@@ -117,6 +118,8 @@ const MODEL_TEST_CONFIGS = {
} }
} }
const MODEL_API_KEY_CONFIGS = Object.values(MODEL_TEST_CONFIGS)
function normalizeValue(value) { function normalizeValue(value) {
return String(value ?? '').trim() 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) { function persistSettings(state) {
if (typeof window === 'undefined') { if (typeof window === 'undefined') {
return return
@@ -390,7 +425,7 @@ export default {
nextState.mailForm.password = currentState.mailForm.password nextState.mailForm.password = currentState.mailForm.password
} }
pageState.value = nextState pageState.value = maskConfiguredModelSecrets(nextState)
persistSettings(pageState.value) persistSettings(pageState.value)
updateBrandPreviewFromState(pageState.value) updateBrandPreviewFromState(pageState.value)
} }
@@ -410,7 +445,7 @@ export default {
return { return {
companyForm: { ...pageState.value.companyForm }, companyForm: { ...pageState.value.companyForm },
adminForm: { ...pageState.value.adminForm }, adminForm: { ...pageState.value.adminForm },
llmForm: { ...pageState.value.llmForm }, llmForm: buildLlmPayload(pageState.value.llmForm),
logForm: { ...pageState.value.logForm }, logForm: { ...pageState.value.logForm },
mailForm: { ...pageState.value.mailForm } mailForm: { ...pageState.value.mailForm }
} }
@@ -456,17 +491,26 @@ export default {
function buildModelTestPayload(testKey) { function buildModelTestPayload(testKey) {
const config = MODEL_TEST_CONFIGS[testKey] const config = MODEL_TEST_CONFIGS[testKey]
const llmForm = pageState.value.llmForm const llmForm = pageState.value.llmForm
const apiKey = llmForm[config.apiKeyKey]
return { return {
provider: llmForm[config.providerKey], provider: llmForm[config.providerKey],
model: llmForm[config.modelKey], model: llmForm[config.modelKey],
endpoint: llmForm[config.endpointKey], endpoint: llmForm[config.endpointKey],
api_key: llmForm[config.apiKeyKey], api_key: isModelSecretMask(apiKey) ? '' : apiKey,
capability: config.capability, capability: config.capability,
slot: testKey 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) { async function testModelConnection(testKey) {
const config = MODEL_TEST_CONFIGS[testKey] const config = MODEL_TEST_CONFIGS[testKey]
const payload = buildModelTestPayload(testKey) const payload = buildModelTestPayload(testKey)
@@ -655,6 +699,7 @@ export default {
activeSectionConfig, activeSectionConfig,
activateSection, activateSection,
applyProviderPreset, applyProviderPreset,
clearModelSecretMask,
completedSectionCount, completedSectionCount,
getModelTestState, getModelTestState,
isModelTesting, isModelTesting,