diff --git a/server/src/app/models/system_setting.py b/server/src/app/models/system_setting.py index 2db3838..4594df9 100644 --- a/server/src/app/models/system_setting.py +++ b/server/src/app/models/system_setting.py @@ -20,7 +20,7 @@ class SystemSetting(Base): copyright_text: Mapped[str] = mapped_column(String(255), default="") theme_skin: Mapped[str] = mapped_column(String(64), default="sky") - admin_account: Mapped[str] = mapped_column(String(120), default="superadmin") + admin_account: Mapped[str] = mapped_column(String(120), default="admin") admin_email: Mapped[str] = mapped_column(String(255), default="") session_timeout: Mapped[int] = mapped_column(Integer, default=30) conversation_retention_days: Mapped[int] = mapped_column(Integer, default=3) diff --git a/server/src/app/services/auth.py b/server/src/app/services/auth.py index c796940..66e3c26 100644 --- a/server/src/app/services/auth.py +++ b/server/src/app/services/auth.py @@ -5,6 +5,7 @@ from datetime import UTC, datetime, timedelta from typing import Any from sqlalchemy import func, or_, select +from sqlalchemy.exc import SQLAlchemyError from sqlalchemy.orm import Session, selectinload from app.core.config import get_settings @@ -127,8 +128,15 @@ class AuthService: if not self.settings.setup_completed: return None - EmployeeService(self.db).ensure_directory_ready() - employee = self._find_employee_by_email(identifier) + try: + employee = self._find_employee_by_email(identifier) + except SQLAlchemyError: + self.db.rollback() + employee = None + + if employee is None: + EmployeeService(self.db).ensure_directory_ready() + employee = self._find_employee_by_email(identifier) if employee is None or not employee.password_hash: return None diff --git a/server/src/app/services/employee.py b/server/src/app/services/employee.py index f7ea375..dcb6bd8 100644 --- a/server/src/app/services/employee.py +++ b/server/src/app/services/employee.py @@ -2,6 +2,7 @@ from __future__ import annotations from collections import Counter from datetime import UTC, date, datetime +import threading from typing import Any from sqlalchemy import select @@ -81,11 +82,31 @@ def prepare_employee_directory() -> None: class EmployeeService: + _directory_ready_lock = threading.Lock() + _directory_ready_keys: set[tuple[str, int]] = set() + def __init__(self, db: Session) -> None: self.db = db self.repository = EmployeeRepository(db) + @staticmethod + def _bind_cache_key(db: Session) -> tuple[str, int]: + bind = db.get_bind() + return (bind.url.render_as_string(hide_password=True), id(bind.pool)) + def ensure_directory_ready(self) -> None: + cache_key = self._bind_cache_key(self.db) + if cache_key in self._directory_ready_keys: + return + + with self._directory_ready_lock: + if cache_key in self._directory_ready_keys: + return + + self._ensure_directory_ready_uncached() + self._directory_ready_keys.add(cache_key) + + def _ensure_directory_ready_uncached(self) -> None: try: Base.metadata.create_all(bind=self.db.get_bind()) ensure_employee_schema(self.db) diff --git a/server/src/app/services/settings.py b/server/src/app/services/settings.py index e849ab6..c7070d0 100644 --- a/server/src/app/services/settings.py +++ b/server/src/app/services/settings.py @@ -1,6 +1,7 @@ from __future__ import annotations import logging +import threading from dataclasses import dataclass from datetime import datetime @@ -28,9 +29,13 @@ from app.services.hermes_sync import ( logger = logging.getLogger(__name__) - -@dataclass(frozen=True, slots=True) -class ModelSlotConfig: +DEFAULT_ADMIN_ACCOUNT = "admin" +DEFAULT_ADMIN_PASSWORD = "admin" +LEGACY_DEFAULT_ADMIN_ACCOUNTS = {"", "superadmin"} + + +@dataclass(frozen=True, slots=True) +class ModelSlotConfig: provider_attr: str model_attr: str endpoint_attr: str @@ -106,14 +111,27 @@ class OnlyOfficeRuntimeConfig: class SettingsService: - def __init__(self, db: Session) -> None: - self.db = db - self.repository = SettingsRepository(db) - self.runtime_settings = get_settings() - + _schema_ready_lock = threading.Lock() + _schema_ready_keys: set[tuple[str, int]] = set() + + def __init__(self, db: Session) -> None: + self.db = db + self.repository = SettingsRepository(db) + self.runtime_settings = get_settings() + + @staticmethod + def _bind_cache_key(db: Session) -> tuple[str, int]: + bind = db.get_bind() + return (bind.url.render_as_string(hide_password=True), id(bind.pool)) + def ensure_settings_ready(self) -> tuple[SystemSetting, SystemSettingSecret]: - Base.metadata.create_all(bind=self.db.get_bind()) - self._ensure_settings_schema() + cache_key = self._bind_cache_key(self.db) + if cache_key not in self._schema_ready_keys: + with self._schema_ready_lock: + if cache_key not in self._schema_ready_keys: + Base.metadata.create_all(bind=self.db.get_bind()) + self._ensure_settings_schema() + self._schema_ready_keys.add(cache_key) settings_row = self.repository.get_settings() secrets_row = self.repository.get_secrets() @@ -130,12 +148,17 @@ 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"}: + 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 LEGACY_DEFAULT_ADMIN_ACCOUNTS: settings_row.admin_account = admin_username should_commit = True + elif legacy_admin is None and not secrets_row.admin_password_hash: + secrets_row.admin_password_hash = hash_password(DEFAULT_ADMIN_PASSWORD) + if str(settings_row.admin_account or "").strip() in LEGACY_DEFAULT_ADMIN_ACCOUNTS: + settings_row.admin_account = DEFAULT_ADMIN_ACCOUNT + should_commit = True if self._sync_onlyoffice_defaults(settings_row, secrets_row): should_commit = True @@ -454,7 +477,7 @@ class SettingsService: 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" + admin_account = str(legacy_admin.get("username", "")).strip() or DEFAULT_ADMIN_ACCOUNT return SystemSetting( id=SETTINGS_ROW_ID, diff --git a/server/src/app/services/user_session_metrics.py b/server/src/app/services/user_session_metrics.py index ea83ff7..15bd44d 100644 --- a/server/src/app/services/user_session_metrics.py +++ b/server/src/app/services/user_session_metrics.py @@ -2,6 +2,7 @@ from __future__ import annotations import uuid from datetime import UTC, datetime +import threading from typing import Any from sqlalchemy import or_, select @@ -14,10 +15,30 @@ MAX_SESSION_DURATION_MS = 24 * 60 * 60 * 1000 class UserSessionMetricService: + _storage_ready_lock = threading.Lock() + _storage_ready_keys: set[tuple[str, int]] = set() + def __init__(self, db: Session) -> None: self.db = db + @staticmethod + def _bind_cache_key(db: Session) -> tuple[str, int]: + bind = db.get_bind() + return (bind.url.render_as_string(hide_password=True), id(bind.pool)) + def ensure_storage_ready(self) -> None: + cache_key = self._bind_cache_key(self.db) + if cache_key in self._storage_ready_keys: + return + + with self._storage_ready_lock: + if cache_key in self._storage_ready_keys: + return + + self._ensure_storage_ready_uncached() + self._storage_ready_keys.add(cache_key) + + def _ensure_storage_ready_uncached(self) -> None: Base.metadata.create_all(bind=self.db.get_bind(), tables=[UserSessionMetric.__table__]) def start_session( diff --git a/server/tests/test_auth_service.py b/server/tests/test_auth_service.py index 22416e0..f6605c3 100644 --- a/server/tests/test_auth_service.py +++ b/server/tests/test_auth_service.py @@ -7,7 +7,7 @@ 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.auth import AuthService, AuthenticatedUser from app.services.employee import EmployeeService from app.services.settings import SettingsService @@ -97,3 +97,49 @@ def test_reenabled_employee_can_login_again() -> None: assert result.ok is True assert result.user.username == employee.email + + +def test_employee_login_skips_directory_bootstrap_when_employee_exists(monkeypatch) -> None: + with build_session() as db: + service = AuthService(db) + calls: list[str] = [] + + class ExistingEmployee: + email = "demo@example.com" + password_hash = "hash" + employment_status = "在职" + + def fail_if_bootstrapped(self) -> None: + calls.append("ensure_directory_ready") + raise AssertionError("existing employee login should not run directory bootstrap") + + monkeypatch.setattr(AuthService, "_find_employee_by_email", lambda self, _: ExistingEmployee()) + monkeypatch.setattr("app.services.auth.verify_password", lambda password, password_hash: True) + monkeypatch.setattr( + AuthService, + "_build_employee_user", + lambda self, employee: AuthenticatedUser( + username=employee.email, + name="Demo", + role="使用者", + department="", + position="", + grade="", + employee_no="", + manager_name="", + location="", + cost_center="", + finance_owner_name="", + risk_profile={}, + role_codes=["user"], + email=employee.email, + avatar="D", + ), + ) + monkeypatch.setattr(EmployeeService, "ensure_directory_ready", fail_if_bootstrapped) + + user = service._authenticate_employee("demo@example.com", "123456") + + assert user is not None + assert user.username == "demo@example.com" + assert calls == [] diff --git a/server/tests/test_settings_persistence.py b/server/tests/test_settings_persistence.py index 66d1eb1..14b6753 100644 --- a/server/tests/test_settings_persistence.py +++ b/server/tests/test_settings_persistence.py @@ -186,6 +186,23 @@ def test_legacy_setup_admin_password_is_migrated_to_database(monkeypatch) -> Non assert service.verify_admin_login("setup-admin", password) is not None +def test_default_admin_credentials_are_written_to_database(monkeypatch) -> None: + temp_dir = build_temp_secret_dir() + monkeypatch.setattr(admin_secret, "ADMIN_SECRET_FILE", temp_dir / "missing-admin.json") + monkeypatch.setattr(secret_box, "SECRET_KEY_FILE", temp_dir / "settings.key") + monkeypatch.setattr(Base.metadata, "create_all", lambda *args, **kwargs: None) + monkeypatch.setenv("HERMES_HOME", str(temp_dir / ".hermes")) + + with build_session(temp_dir / "settings.db") as db: + service = SettingsService(db) + settings_row, secrets_row = service.ensure_settings_ready() + + assert settings_row.admin_account == "admin" + assert secrets_row.admin_password_hash + assert service.verify_admin_login("admin", "admin") is not None + assert service.verify_admin_login("superadmin", "admin") is None + + def test_settings_service_syncs_models_to_hermes_config(monkeypatch) -> None: temp_dir = build_temp_secret_dir() hermes_home = temp_dir / ".hermes"