diff --git a/next/README.md b/next/README.md index f618953..ac8cd87 100644 --- a/next/README.md +++ b/next/README.md @@ -34,6 +34,7 @@ npm.cmd run dev - `APP_LOG_LEVEL`:`DEBUG`、`INFO`、`WARNING`、`ERROR`或`CRITICAL`。 - `APP_HOST`、`APP_PORT`:监听地址和端口;迁移开发端口为8780。 - `APP_TIMEZONE`:固定为`Asia/Shanghai`,其他值会拒绝启动。 +- `APP_ENCRYPTION_KEY`:Fernet密钥;生产环境必填。开发环境首次启动时只在数据目录生成本地密钥文件。 数据库维护: diff --git a/next/backend/bootstrap/container.py b/next/backend/bootstrap/container.py index a33da3e..de3a1d1 100644 --- a/next/backend/bootstrap/container.py +++ b/next/backend/bootstrap/container.py @@ -5,6 +5,14 @@ from dataclasses import dataclass from backend.bootstrap.settings import Settings from backend.database.connection import Database from backend.database.repositories.status import DatabaseStatusRepository +from backend.features.accounts.credentials import SystemCredentialService +from backend.features.accounts.model_pool import ModelPoolRepository, ModelPoolService +from backend.features.accounts.repository import AccountRepository, SystemCredentialRepository +from backend.features.accounts.service import ( + AccountService, + MembershipService, +) +from backend.security import PasswordHasher, load_or_create_cipher @dataclass(frozen=True, slots=True) @@ -12,12 +20,24 @@ class ApplicationContainer: settings: Settings database: Database database_status: DatabaseStatusRepository + accounts: AccountService + memberships: MembershipService + system_credentials: SystemCredentialService + model_pool: ModelPoolService def build_container(settings: Settings) -> ApplicationContainer: database = Database(settings.database_path) + account_repository = AccountRepository() + credential_repository = SystemCredentialRepository() + model_pool_repository = ModelPoolRepository() + cipher = load_or_create_cipher(settings) return ApplicationContainer( settings=settings, database=database, database_status=DatabaseStatusRepository(database), + accounts=AccountService(database, account_repository, PasswordHasher(), cipher), + memberships=MembershipService(database, account_repository), + system_credentials=SystemCredentialService(database, credential_repository, cipher), + model_pool=ModelPoolService(database, model_pool_repository, cipher), ) diff --git a/next/backend/bootstrap/settings.py b/next/backend/bootstrap/settings.py index e090cd0..ff274b3 100644 --- a/next/backend/bootstrap/settings.py +++ b/next/backend/bootstrap/settings.py @@ -38,6 +38,7 @@ class Settings: log_level: str host: str port: int + encryption_key: str | None timezone: str = "Asia/Shanghai" @classmethod @@ -60,6 +61,9 @@ class Settings: log_level = os.getenv("APP_LOG_LEVEL", "INFO").strip().upper() if log_level not in {"DEBUG", "INFO", "WARNING", "ERROR", "CRITICAL"}: raise ConfigurationError("APP_LOG_LEVEL is invalid") + encryption_key = os.getenv("APP_ENCRYPTION_KEY", "").strip() or None + if environment == "production" and encryption_key is None: + raise ConfigurationError("APP_ENCRYPTION_KEY is required in production") return cls( environment=environment, debug=environment == "development", @@ -69,6 +73,7 @@ class Settings: log_level=log_level, host=os.getenv("APP_HOST", "127.0.0.1").strip() or "127.0.0.1", port=_parse_port(os.getenv("APP_PORT", "8780")), + encryption_key=encryption_key, timezone=timezone, ) @@ -84,4 +89,5 @@ class Settings: log_level="CRITICAL", host="127.0.0.1", port=8780, + encryption_key=None, ) diff --git a/next/backend/database/migrations/m0001_accounts.py b/next/backend/database/migrations/m0001_accounts.py new file mode 100644 index 0000000..97909a9 --- /dev/null +++ b/next/backend/database/migrations/m0001_accounts.py @@ -0,0 +1,95 @@ +from __future__ import annotations + +import sqlite3 + +from backend.database.migrations.runner import Migration + + +def upgrade(connection: sqlite3.Connection) -> None: + statements = ( + """ + CREATE TABLE users ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + username TEXT NOT NULL, + username_key TEXT NOT NULL UNIQUE, + password_hash TEXT NOT NULL, + is_admin INTEGER NOT NULL DEFAULT 0 CHECK (is_admin IN (0, 1)), + status TEXT NOT NULL DEFAULT 'active' CHECK (status IN ('active', 'disabled')), + created_at TEXT NOT NULL, + updated_at TEXT NOT NULL + ) + """, + """ + CREATE TABLE memberships ( + user_id INTEGER PRIMARY KEY REFERENCES users(id) ON DELETE CASCADE, + state TEXT NOT NULL DEFAULT 'inactive' + CHECK (state IN ('inactive', 'active', 'disabled')), + expires_at TEXT, + is_permanent INTEGER NOT NULL DEFAULT 0 CHECK (is_permanent IN (0, 1)), + daily_llm_limit INTEGER NOT NULL DEFAULT 50 + CHECK (daily_llm_limit BETWEEN 1 AND 1000), + updated_at TEXT NOT NULL, + updated_by INTEGER REFERENCES users(id) ON DELETE SET NULL + ) + """, + """ + CREATE TABLE sessions ( + token_hash TEXT PRIMARY KEY, + user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE, + csrf_hash TEXT NOT NULL, + created_at TEXT NOT NULL, + expires_at TEXT NOT NULL, + last_seen_at TEXT NOT NULL + ) + """, + "CREATE INDEX sessions_user_id_idx ON sessions(user_id)", + "CREATE INDEX sessions_expires_at_idx ON sessions(expires_at)", + """ + CREATE TABLE birth_profiles ( + user_id INTEGER PRIMARY KEY REFERENCES users(id) ON DELETE CASCADE, + encrypted_payload TEXT NOT NULL, + created_at TEXT NOT NULL, + updated_at TEXT NOT NULL + ) + """, + """ + CREATE TABLE llm_usage_daily ( + user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE, + usage_date TEXT NOT NULL, + successful_calls INTEGER NOT NULL DEFAULT 0 CHECK (successful_calls >= 0), + updated_at TEXT NOT NULL, + PRIMARY KEY (user_id, usage_date) + ) + """, + """ + CREATE TABLE system_credentials ( + name TEXT PRIMARY KEY, + encrypted_value TEXT NOT NULL, + updated_at TEXT NOT NULL, + updated_by INTEGER NOT NULL REFERENCES users(id) ON DELETE RESTRICT + ) + """, + ) + for statement in statements: + connection.execute(statement) + + +def downgrade(connection: sqlite3.Connection) -> None: + for table in ( + "system_credentials", + "llm_usage_daily", + "birth_profiles", + "sessions", + "memberships", + "users", + ): + connection.execute(f"DROP TABLE {table}") + + +MIGRATION = Migration( + version=1, + name="create_accounts", + signature="accounts:v1:users-memberships-sessions-profiles-usage-credentials", + upgrade=upgrade, + downgrade=downgrade, +) diff --git a/next/backend/database/migrations/m0002_model_pool.py b/next/backend/database/migrations/m0002_model_pool.py new file mode 100644 index 0000000..9ce18eb --- /dev/null +++ b/next/backend/database/migrations/m0002_model_pool.py @@ -0,0 +1,54 @@ +from __future__ import annotations + +import sqlite3 + +from backend.database.migrations.runner import Migration + + +def upgrade(connection: sqlite3.Connection) -> None: + connection.execute( + """ + CREATE TABLE llm_models ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + display_name TEXT NOT NULL, + display_name_key TEXT NOT NULL UNIQUE, + base_url TEXT NOT NULL, + model_identifier TEXT NOT NULL, + encrypted_api_key TEXT NOT NULL, + created_at TEXT NOT NULL, + updated_at TEXT NOT NULL, + updated_by INTEGER NOT NULL REFERENCES users(id) ON DELETE RESTRICT + ) + """ + ) + connection.execute( + """ + CREATE TABLE llm_configuration ( + id INTEGER PRIMARY KEY CHECK (id = 1), + primary_model_id INTEGER REFERENCES llm_models(id) ON DELETE RESTRICT, + fallback_model_id INTEGER REFERENCES llm_models(id) ON DELETE RESTRICT, + updated_at TEXT, + updated_by INTEGER REFERENCES users(id) ON DELETE RESTRICT, + CHECK ( + fallback_model_id IS NULL + OR primary_model_id IS NULL + OR fallback_model_id <> primary_model_id + ) + ) + """ + ) + connection.execute("INSERT INTO llm_configuration (id) VALUES (1)") + + +def downgrade(connection: sqlite3.Connection) -> None: + connection.execute("DROP TABLE llm_configuration") + connection.execute("DROP TABLE llm_models") + + +MIGRATION = Migration( + version=2, + name="create_model_pool", + signature="model-pool:v1:models-primary-fallback-encrypted-key", + upgrade=upgrade, + downgrade=downgrade, +) diff --git a/next/backend/database/migrations/registry.py b/next/backend/database/migrations/registry.py index dea4f6a..e0ddd26 100644 --- a/next/backend/database/migrations/registry.py +++ b/next/backend/database/migrations/registry.py @@ -1,3 +1,5 @@ +from backend.database.migrations.m0001_accounts import MIGRATION as ACCOUNTS +from backend.database.migrations.m0002_model_pool import MIGRATION as MODEL_POOL from backend.database.migrations.runner import Migration -MIGRATIONS: tuple[Migration, ...] = () +MIGRATIONS: tuple[Migration, ...] = (ACCOUNTS, MODEL_POOL) diff --git a/next/backend/errors.py b/next/backend/errors.py new file mode 100644 index 0000000..7450022 --- /dev/null +++ b/next/backend/errors.py @@ -0,0 +1,7 @@ +from dataclasses import dataclass + + +@dataclass(slots=True) +class BusinessError(Exception): + code: str + message: str diff --git a/next/backend/features/accounts/__init__.py b/next/backend/features/accounts/__init__.py new file mode 100644 index 0000000..23428a9 --- /dev/null +++ b/next/backend/features/accounts/__init__.py @@ -0,0 +1,3 @@ +from backend.features.accounts.service import AccountService, MembershipService + +__all__ = ["AccountService", "MembershipService"] diff --git a/next/backend/features/accounts/auth.py b/next/backend/features/accounts/auth.py new file mode 100644 index 0000000..f9bc4b8 --- /dev/null +++ b/next/backend/features/accounts/auth.py @@ -0,0 +1,67 @@ +from __future__ import annotations + +from typing import Annotated + +from fastapi import Depends, Request + +from backend.features.accounts.models import Principal +from backend.http.errors import AppError + +SESSION_COOKIE = "xiaobai_session" +CSRF_COOKIE = "xiaobai_csrf" + + +def require_principal(request: Request) -> Principal: + service = request.app.state.container.accounts + principal = service.authenticate(request.cookies.get(SESSION_COOKIE)) + if principal is None: + raise AppError("authentication_required", "请先登录。", 401) + return principal + + +AuthenticatedPrincipal = Annotated[Principal, Depends(require_principal)] + + +def require_csrf( + request: Request, + principal: AuthenticatedPrincipal, +) -> Principal: + valid = request.app.state.container.accounts.verify_csrf( + principal, + request.headers.get("X-CSRF-Token"), + request.cookies.get(CSRF_COOKIE), + ) + if not valid: + raise AppError("csrf_failed", "页面状态已过期,请刷新后重试。", 403) + return principal + + +CsrfPrincipal = Annotated[Principal, Depends(require_csrf)] + + +def require_admin(principal: AuthenticatedPrincipal) -> Principal: + if not principal.user.is_admin: + raise AppError("access_denied", "当前账号无权执行此操作。", 403) + return principal + + +def require_admin_write(principal: CsrfPrincipal) -> Principal: + if not principal.user.is_admin: + raise AppError("access_denied", "当前账号无权执行此操作。", 403) + return principal + + +AdminPrincipal = Annotated[Principal, Depends(require_admin)] +AdminWritePrincipal = Annotated[Principal, Depends(require_admin_write)] + + +def require_smart_access( + request: Request, + principal: AuthenticatedPrincipal, +) -> Principal: + if not request.app.state.container.memberships.can_use_smart_features(principal): + raise AppError("membership_required", "该功能仅对会员开放。", 403) + return principal + + +SmartAccessPrincipal = Annotated[Principal, Depends(require_smart_access)] diff --git a/next/backend/features/accounts/credentials.py b/next/backend/features/accounts/credentials.py new file mode 100644 index 0000000..aca15cc --- /dev/null +++ b/next/backend/features/accounts/credentials.py @@ -0,0 +1,57 @@ +from __future__ import annotations + +from typing import Literal + +from backend.database.connection import Database +from backend.errors import BusinessError +from backend.features.accounts.models import Principal +from backend.features.accounts.repository import SystemCredentialRepository +from backend.features.accounts.service import now_utc +from backend.security import SecretCipher + +CredentialName = Literal["tushare_token", "ifind_refresh_token", "ifind_access_token"] +ALLOWED_CREDENTIALS: tuple[CredentialName, ...] = ( + "tushare_token", + "ifind_refresh_token", + "ifind_access_token", +) + + +class SystemCredentialService: + def __init__( + self, + database: Database, + repository: SystemCredentialRepository, + cipher: SecretCipher, + ) -> None: + self._database = database + self._repository = repository + self._cipher = cipher + + def list_status(self) -> tuple[dict[str, str | bool | None], ...]: + with self._database.read() as connection: + configured = self._repository.list_status(connection) + return tuple( + { + "name": name, + "configured": name in configured, + "updated_at": configured.get(name), + } + for name in ALLOWED_CREDENTIALS + ) + + def save(self, actor: Principal, name: str, value: str) -> None: + if name not in ALLOWED_CREDENTIALS: + raise BusinessError("invalid_credential", "系统凭据类型无效。") + cleaned = value.strip() + if not cleaned or len(cleaned) > 4096: + raise BusinessError("invalid_credential", "系统凭据内容无效。") + with self._database.transaction() as connection: + self._repository.save( + connection, name, self._cipher.encrypt(cleaned), actor.user.id, now_utc() + ) + + def get(self, name: CredentialName) -> str | None: + with self._database.read() as connection: + encrypted = self._repository.get_encrypted(connection, name) + return self._cipher.decrypt(encrypted) if encrypted else None diff --git a/next/backend/features/accounts/model_pool.py b/next/backend/features/accounts/model_pool.py new file mode 100644 index 0000000..bf02700 --- /dev/null +++ b/next/backend/features/accounts/model_pool.py @@ -0,0 +1,336 @@ +from __future__ import annotations + +import sqlite3 +import unicodedata +from datetime import datetime +from urllib.parse import urlsplit + +from backend.database.connection import Database +from backend.errors import BusinessError +from backend.features.accounts.models import ( + ModelPoolItem, + ModelPoolRecord, + ModelRuntimeConfig, + Principal, +) +from backend.features.accounts.service import now_utc +from backend.security import SecretCipher + + +def _record(row: sqlite3.Row) -> ModelPoolRecord: + return ModelPoolRecord( + id=int(row["id"]), + display_name=str(row["display_name"]), + display_name_key=str(row["display_name_key"]), + base_url=str(row["base_url"]), + model_identifier=str(row["model_identifier"]), + encrypted_api_key=str(row["encrypted_api_key"]), + created_at=datetime.fromisoformat(str(row["created_at"])), + updated_at=datetime.fromisoformat(str(row["updated_at"])), + updated_by=int(row["updated_by"]), + ) + + +class ModelPoolRepository: + @staticmethod + def count(connection: sqlite3.Connection) -> int: + return int(connection.execute("SELECT COUNT(*) FROM llm_models").fetchone()[0]) + + @staticmethod + def create( + connection: sqlite3.Connection, + display_name: str, + display_name_key: str, + base_url: str, + model_identifier: str, + encrypted_api_key: str, + actor_id: int, + now: datetime, + ) -> ModelPoolRecord: + timestamp = now.isoformat(timespec="seconds") + cursor = connection.execute( + """ + INSERT INTO llm_models ( + display_name, display_name_key, base_url, model_identifier, + encrypted_api_key, created_at, updated_at, updated_by + ) VALUES (?, ?, ?, ?, ?, ?, ?, ?) + """, + ( + display_name, + display_name_key, + base_url, + model_identifier, + encrypted_api_key, + timestamp, + timestamp, + actor_id, + ), + ) + row = connection.execute( + "SELECT * FROM llm_models WHERE id = ?", (cursor.lastrowid,) + ).fetchone() + return _record(row) + + @staticmethod + def get(connection: sqlite3.Connection, model_id: int) -> ModelPoolRecord | None: + row = connection.execute( + "SELECT * FROM llm_models WHERE id = ?", (model_id,) + ).fetchone() + return _record(row) if row else None + + @staticmethod + def list(connection: sqlite3.Connection) -> tuple[ModelPoolRecord, ...]: + return tuple( + _record(row) + for row in connection.execute("SELECT * FROM llm_models ORDER BY id") + ) + + @staticmethod + def update( + connection: sqlite3.Connection, + model_id: int, + display_name: str, + display_name_key: str, + base_url: str, + model_identifier: str, + encrypted_api_key: str, + actor_id: int, + now: datetime, + ) -> ModelPoolRecord | None: + connection.execute( + """ + UPDATE llm_models SET + display_name = ?, display_name_key = ?, base_url = ?, model_identifier = ?, + encrypted_api_key = ?, updated_at = ?, updated_by = ? + WHERE id = ? + """, + ( + display_name, + display_name_key, + base_url, + model_identifier, + encrypted_api_key, + now.isoformat(timespec="seconds"), + actor_id, + model_id, + ), + ) + return ModelPoolRepository.get(connection, model_id) + + @staticmethod + def delete(connection: sqlite3.Connection, model_id: int) -> None: + connection.execute("DELETE FROM llm_models WHERE id = ?", (model_id,)) + + @staticmethod + def selection(connection: sqlite3.Connection) -> tuple[int | None, int | None]: + row = connection.execute( + "SELECT primary_model_id, fallback_model_id FROM llm_configuration WHERE id = 1" + ).fetchone() + return row["primary_model_id"], row["fallback_model_id"] + + @staticmethod + def save_selection( + connection: sqlite3.Connection, + primary_id: int, + fallback_id: int | None, + actor_id: int, + now: datetime, + ) -> None: + connection.execute( + """ + UPDATE llm_configuration SET + primary_model_id = ?, fallback_model_id = ?, updated_at = ?, updated_by = ? + WHERE id = 1 + """, + ( + primary_id, + fallback_id, + now.isoformat(timespec="seconds"), + actor_id, + ), + ) + + +class ModelPoolService: + MAX_MODELS = 20 + + def __init__( + self, + database: Database, + repository: ModelPoolRepository, + cipher: SecretCipher, + ) -> None: + self._database = database + self._repository = repository + self._cipher = cipher + + def list(self) -> tuple[ModelPoolItem, ...]: + with self._database.read() as connection: + records = self._repository.list(connection) + primary_id, fallback_id = self._repository.selection(connection) + return tuple( + self._to_item(record, primary_id, fallback_id) + for record in records + ) + + def get_item(self, model_id: int) -> ModelPoolItem: + with self._database.read() as connection: + record = self._repository.get(connection, model_id) + primary_id, fallback_id = self._repository.selection(connection) + if record is None: + raise BusinessError("model_not_found", "模型不存在。") + return self._to_item(record, primary_id, fallback_id) + + def create( + self, + actor: Principal, + display_name: str, + base_url: str, + model_identifier: str, + api_key: str, + ) -> ModelPoolItem: + name, key, url, identifier = self._validate( + display_name, base_url, model_identifier + ) + cleaned_key = self._validate_api_key(api_key) + now = now_utc() + try: + with self._database.transaction() as connection: + if self._repository.count(connection) >= self.MAX_MODELS: + raise BusinessError("model_pool_full", "模型池最多可添加20个模型。") + record = self._repository.create( + connection, + name, + key, + url, + identifier, + self._cipher.encrypt(cleaned_key), + actor.user.id, + now, + ) + primary_id, _ = self._repository.selection(connection) + if primary_id is None: + self._repository.save_selection( + connection, record.id, None, actor.user.id, now + ) + return self.get_item(record.id) + except sqlite3.IntegrityError as exc: + raise BusinessError("model_name_taken", "模型显示名称已存在。") from exc + + def update( + self, + actor: Principal, + model_id: int, + display_name: str, + base_url: str, + model_identifier: str, + api_key: str | None, + ) -> ModelPoolItem: + name, key, url, identifier = self._validate( + display_name, base_url, model_identifier + ) + now = now_utc() + try: + with self._database.transaction() as connection: + current = self._repository.get(connection, model_id) + if current is None: + raise BusinessError("model_not_found", "模型不存在。") + encrypted_key = ( + self._cipher.encrypt(self._validate_api_key(api_key)) + if api_key is not None + else current.encrypted_api_key + ) + updated = self._repository.update( + connection, + model_id, + name, + key, + url, + identifier, + encrypted_key, + actor.user.id, + now, + ) + if updated is None: + raise BusinessError("model_not_found", "模型不存在。") + return self.get_item(updated.id) + except sqlite3.IntegrityError as exc: + raise BusinessError("model_name_taken", "模型显示名称已存在。") from exc + + def delete(self, model_id: int) -> None: + with self._database.transaction() as connection: + current = self._repository.get(connection, model_id) + if current is None: + raise BusinessError("model_not_found", "模型不存在。") + primary_id, fallback_id = self._repository.selection(connection) + if model_id in {primary_id, fallback_id}: + raise BusinessError("model_in_use", "请先调整主模型或辅助模型后再删除。") + self._repository.delete(connection, model_id) + + def select( + self, actor: Principal, primary_id: int, fallback_id: int | None + ) -> None: + if fallback_id == primary_id: + raise BusinessError("duplicate_model_role", "主模型和辅助模型不能相同。") + with self._database.transaction() as connection: + if self._repository.get(connection, primary_id) is None: + raise BusinessError("model_not_found", "主模型不存在。") + if fallback_id is not None and self._repository.get(connection, fallback_id) is None: + raise BusinessError("model_not_found", "辅助模型不存在。") + self._repository.save_selection( + connection, primary_id, fallback_id, actor.user.id, now_utc() + ) + + def runtime_config(self) -> ModelRuntimeConfig: + with self._database.read() as connection: + primary_id, fallback_id = self._repository.selection(connection) + primary = self._repository.get(connection, primary_id) if primary_id else None + fallback = self._repository.get(connection, fallback_id) if fallback_id else None + if primary is None: + raise BusinessError("model_not_configured", "智能解读服务尚未配置。") + return ModelRuntimeConfig(primary=primary, fallback=fallback) + + def decrypt_api_key(self, record: ModelPoolRecord) -> str: + return self._cipher.decrypt(record.encrypted_api_key) + + @staticmethod + def _to_item( + record: ModelPoolRecord, + primary_id: int | None, + fallback_id: int | None, + ) -> ModelPoolItem: + return ModelPoolItem( + id=record.id, + display_name=record.display_name, + base_url=record.base_url, + model_identifier=record.model_identifier, + has_api_key=bool(record.encrypted_api_key), + is_primary=record.id == primary_id, + is_fallback=record.id == fallback_id, + updated_at=record.updated_at, + ) + + @staticmethod + def _validate( + display_name: str, base_url: str, model_identifier: str + ) -> tuple[str, str, str, str]: + name = unicodedata.normalize("NFKC", display_name.strip()) + if not 1 <= len(name) <= 40: + raise BusinessError("invalid_model", "模型显示名称应为1至40个字符。") + url = base_url.strip().rstrip("/") + parsed = urlsplit(url) + if parsed.scheme not in {"http", "https"} or not parsed.netloc: + raise BusinessError("invalid_model", "模型服务地址无效。") + if parsed.username or parsed.password: + raise BusinessError("invalid_model", "模型服务地址不能包含账号或密码。") + identifier = model_identifier.strip() + if not 1 <= len(identifier) <= 128: + raise BusinessError("invalid_model", "模型标识应为1至128个字符。") + return name, name.casefold(), url, identifier + + @staticmethod + def _validate_api_key(api_key: str) -> str: + cleaned = api_key.strip() + if not 1 <= len(cleaned) <= 4096: + raise BusinessError("invalid_model", "模型密钥无效。") + return cleaned diff --git a/next/backend/features/accounts/models.py b/next/backend/features/accounts/models.py new file mode 100644 index 0000000..41d1822 --- /dev/null +++ b/next/backend/features/accounts/models.py @@ -0,0 +1,115 @@ +from __future__ import annotations + +from dataclasses import dataclass +from datetime import datetime + + +@dataclass(frozen=True, slots=True) +class UserRecord: + id: int + username: str + username_key: str + password_hash: str + is_admin: bool + status: str + created_at: datetime + updated_at: datetime + + +@dataclass(frozen=True, slots=True) +class MembershipRecord: + user_id: int + state: str + expires_at: datetime | None + is_permanent: bool + daily_llm_limit: int + updated_at: datetime + updated_by: int | None + + +@dataclass(frozen=True, slots=True) +class SessionRecord: + token_hash: str + csrf_hash: str + user: UserRecord + membership: MembershipRecord + expires_at: datetime + + +@dataclass(frozen=True, slots=True) +class Principal: + token_hash: str + csrf_hash: str + user: UserRecord + membership: MembershipRecord + + +@dataclass(frozen=True, slots=True) +class SessionIssue: + token: str + csrf_token: str + principal: Principal + + +@dataclass(frozen=True, slots=True) +class MembershipView: + status: str + active: bool + is_permanent: bool + expires_at: datetime | None + remaining_days: int | None + daily_limit: int + used_today: int + remaining_today: int | None + quota_exempt: bool + + +@dataclass(frozen=True, slots=True) +class BirthProfile: + birth_date: str + birth_time: str + gender: str + updated_at: datetime + + +@dataclass(frozen=True, slots=True) +class MembershipAccount: + user: UserRecord + membership: MembershipRecord + + +@dataclass(frozen=True, slots=True) +class MembershipAccountView: + user: UserRecord + membership: MembershipView + + +@dataclass(frozen=True, slots=True) +class ModelPoolRecord: + id: int + display_name: str + display_name_key: str + base_url: str + model_identifier: str + encrypted_api_key: str + created_at: datetime + updated_at: datetime + updated_by: int + + +@dataclass(frozen=True, slots=True) +class ModelPoolItem: + id: int + display_name: str + base_url: str + model_identifier: str + has_api_key: bool + is_primary: bool + is_fallback: bool + updated_at: datetime + + +@dataclass(frozen=True, slots=True) +class ModelRuntimeConfig: + primary: ModelPoolRecord + fallback: ModelPoolRecord | None diff --git a/next/backend/features/accounts/repository.py b/next/backend/features/accounts/repository.py new file mode 100644 index 0000000..83197f9 --- /dev/null +++ b/next/backend/features/accounts/repository.py @@ -0,0 +1,357 @@ +from __future__ import annotations + +import sqlite3 +from datetime import datetime + +from backend.features.accounts.models import ( + MembershipAccount, + MembershipRecord, + SessionRecord, + UserRecord, +) + + +def _datetime(value: str) -> datetime: + return datetime.fromisoformat(value) + + +def _optional_datetime(value: str | None) -> datetime | None: + return _datetime(value) if value else None + + +def _user(row: sqlite3.Row) -> UserRecord: + return UserRecord( + id=int(row["id"]), + username=str(row["username"]), + username_key=str(row["username_key"]), + password_hash=str(row["password_hash"]), + is_admin=bool(row["is_admin"]), + status=str(row["status"]), + created_at=_datetime(str(row["created_at"])), + updated_at=_datetime(str(row["updated_at"])), + ) + + +def _membership(row: sqlite3.Row, prefix: str = "") -> MembershipRecord: + return MembershipRecord( + user_id=int(row[f"{prefix}user_id"]), + state=str(row[f"{prefix}state"]), + expires_at=_optional_datetime(row[f"{prefix}expires_at"]), + is_permanent=bool(row[f"{prefix}is_permanent"]), + daily_llm_limit=int(row[f"{prefix}daily_llm_limit"]), + updated_at=_datetime(str(row[f"{prefix}updated_at"])), + updated_by=( + int(row[f"{prefix}updated_by"]) if row[f"{prefix}updated_by"] is not None else None + ), + ) + + +class AccountRepository: + @staticmethod + def count_users(connection: sqlite3.Connection) -> int: + return int(connection.execute("SELECT COUNT(*) FROM users").fetchone()[0]) + + @staticmethod + def create_user( + connection: sqlite3.Connection, + username: str, + username_key: str, + password_hash: str, + is_admin: bool, + now: datetime, + ) -> UserRecord: + timestamp = now.isoformat(timespec="seconds") + cursor = connection.execute( + """ + INSERT INTO users + (username, username_key, password_hash, is_admin, created_at, updated_at) + VALUES (?, ?, ?, ?, ?, ?) + """, + (username, username_key, password_hash, int(is_admin), timestamp, timestamp), + ) + return UserRecord( + id=int(cursor.lastrowid), + username=username, + username_key=username_key, + password_hash=password_hash, + is_admin=is_admin, + status="active", + created_at=now, + updated_at=now, + ) + + @staticmethod + def get_user_by_username_key( + connection: sqlite3.Connection, username_key: str + ) -> UserRecord | None: + row = connection.execute( + "SELECT * FROM users WHERE username_key = ?", (username_key,) + ).fetchone() + return _user(row) if row else None + + @staticmethod + def get_user_by_id(connection: sqlite3.Connection, user_id: int) -> UserRecord | None: + row = connection.execute("SELECT * FROM users WHERE id = ?", (user_id,)).fetchone() + return _user(row) if row else None + + @staticmethod + def create_default_membership( + connection: sqlite3.Connection, user_id: int, now: datetime + ) -> MembershipRecord: + timestamp = now.isoformat(timespec="seconds") + connection.execute( + """ + INSERT INTO memberships (user_id, state, daily_llm_limit, updated_at) + VALUES (?, 'inactive', 50, ?) + """, + (user_id, timestamp), + ) + return MembershipRecord(user_id, "inactive", None, False, 50, now, None) + + @staticmethod + def get_membership(connection: sqlite3.Connection, user_id: int) -> MembershipRecord | None: + row = connection.execute( + "SELECT * FROM memberships WHERE user_id = ?", (user_id,) + ).fetchone() + return _membership(row) if row else None + + @staticmethod + def update_password( + connection: sqlite3.Connection, user_id: int, password_hash: str, now: datetime + ) -> None: + connection.execute( + "UPDATE users SET password_hash = ?, updated_at = ? WHERE id = ?", + (password_hash, now.isoformat(timespec="seconds"), user_id), + ) + + @staticmethod + def create_session( + connection: sqlite3.Connection, + token_hash: str, + csrf_hash: str, + user_id: int, + now: datetime, + expires_at: datetime, + ) -> None: + timestamp = now.isoformat(timespec="seconds") + connection.execute( + """ + INSERT INTO sessions + (token_hash, user_id, csrf_hash, created_at, expires_at, last_seen_at) + VALUES (?, ?, ?, ?, ?, ?) + """, + ( + token_hash, + user_id, + csrf_hash, + timestamp, + expires_at.isoformat(timespec="seconds"), + timestamp, + ), + ) + + @staticmethod + def get_session(connection: sqlite3.Connection, token_hash: str) -> SessionRecord | None: + row = connection.execute( + """ + SELECT + u.*, + m.user_id AS membership_user_id, + m.state AS membership_state, + m.expires_at AS membership_expires_at, + m.is_permanent AS membership_is_permanent, + m.daily_llm_limit AS membership_daily_llm_limit, + m.updated_at AS membership_updated_at, + m.updated_by AS membership_updated_by, + s.token_hash AS session_token_hash, + s.csrf_hash AS session_csrf_hash, + s.expires_at AS session_expires_at + FROM sessions s + JOIN users u ON u.id = s.user_id + JOIN memberships m ON m.user_id = u.id + WHERE s.token_hash = ? + """, + (token_hash,), + ).fetchone() + if row is None: + return None + return SessionRecord( + token_hash=str(row["session_token_hash"]), + csrf_hash=str(row["session_csrf_hash"]), + user=_user(row), + membership=_membership(row, "membership_"), + expires_at=_datetime(str(row["session_expires_at"])), + ) + + @staticmethod + def delete_session(connection: sqlite3.Connection, token_hash: str) -> None: + connection.execute("DELETE FROM sessions WHERE token_hash = ?", (token_hash,)) + + @staticmethod + def delete_other_sessions( + connection: sqlite3.Connection, user_id: int, token_hash: str + ) -> None: + connection.execute( + "DELETE FROM sessions WHERE user_id = ? AND token_hash <> ?", + (user_id, token_hash), + ) + + @staticmethod + def get_encrypted_profile(connection: sqlite3.Connection, user_id: int) -> sqlite3.Row | None: + return connection.execute( + "SELECT encrypted_payload, updated_at FROM birth_profiles WHERE user_id = ?", + (user_id,), + ).fetchone() + + @staticmethod + def save_encrypted_profile( + connection: sqlite3.Connection, + user_id: int, + encrypted_payload: str, + now: datetime, + ) -> None: + timestamp = now.isoformat(timespec="seconds") + connection.execute( + """ + INSERT INTO birth_profiles (user_id, encrypted_payload, created_at, updated_at) + VALUES (?, ?, ?, ?) + ON CONFLICT(user_id) DO UPDATE SET + encrypted_payload = excluded.encrypted_payload, + updated_at = excluded.updated_at + """, + (user_id, encrypted_payload, timestamp, timestamp), + ) + + @staticmethod + def delete_profile(connection: sqlite3.Connection, user_id: int) -> None: + connection.execute("DELETE FROM birth_profiles WHERE user_id = ?", (user_id,)) + + @staticmethod + def usage_today(connection: sqlite3.Connection, user_id: int, usage_date: str) -> int: + row = connection.execute( + """ + SELECT successful_calls FROM llm_usage_daily + WHERE user_id = ? AND usage_date = ? + """, + (user_id, usage_date), + ).fetchone() + return int(row["successful_calls"]) if row else 0 + + @staticmethod + def usage_for_users(connection: sqlite3.Connection, usage_date: str) -> dict[int, int]: + return { + int(row["user_id"]): int(row["successful_calls"]) + for row in connection.execute( + "SELECT user_id, successful_calls FROM llm_usage_daily WHERE usage_date = ?", + (usage_date,), + ) + } + + @staticmethod + def list_memberships(connection: sqlite3.Connection) -> tuple[MembershipAccount, ...]: + rows = connection.execute( + """ + SELECT + u.*, + m.user_id AS membership_user_id, + m.state AS membership_state, + m.expires_at AS membership_expires_at, + m.is_permanent AS membership_is_permanent, + m.daily_llm_limit AS membership_daily_llm_limit, + m.updated_at AS membership_updated_at, + m.updated_by AS membership_updated_by + FROM users u + JOIN memberships m ON m.user_id = u.id + ORDER BY u.id + """ + ).fetchall() + return tuple(MembershipAccount(_user(row), _membership(row, "membership_")) for row in rows) + + @staticmethod + def get_membership_account( + connection: sqlite3.Connection, user_id: int + ) -> MembershipAccount | None: + row = connection.execute( + """ + SELECT + u.*, + m.user_id AS membership_user_id, + m.state AS membership_state, + m.expires_at AS membership_expires_at, + m.is_permanent AS membership_is_permanent, + m.daily_llm_limit AS membership_daily_llm_limit, + m.updated_at AS membership_updated_at, + m.updated_by AS membership_updated_by + FROM users u + JOIN memberships m ON m.user_id = u.id + WHERE u.id = ? + """, + (user_id,), + ).fetchone() + return MembershipAccount(_user(row), _membership(row, "membership_")) if row else None + + @staticmethod + def update_membership( + connection: sqlite3.Connection, + user_id: int, + state: str, + expires_at: datetime | None, + is_permanent: bool, + daily_limit: int, + updated_by: int, + now: datetime, + ) -> MembershipRecord | None: + connection.execute( + """ + UPDATE memberships SET + state = ?, expires_at = ?, is_permanent = ?, daily_llm_limit = ?, + updated_at = ?, updated_by = ? + WHERE user_id = ? + """, + ( + state, + expires_at.isoformat(timespec="seconds") if expires_at else None, + int(is_permanent), + daily_limit, + now.isoformat(timespec="seconds"), + updated_by, + user_id, + ), + ) + return AccountRepository.get_membership(connection, user_id) + + +class SystemCredentialRepository: + @staticmethod + def list_status(connection: sqlite3.Connection) -> dict[str, str]: + return { + str(row["name"]): str(row["updated_at"]) + for row in connection.execute("SELECT name, updated_at FROM system_credentials") + } + + @staticmethod + def save( + connection: sqlite3.Connection, + name: str, + encrypted_value: str, + updated_by: int, + now: datetime, + ) -> None: + connection.execute( + """ + INSERT INTO system_credentials (name, encrypted_value, updated_at, updated_by) + VALUES (?, ?, ?, ?) + ON CONFLICT(name) DO UPDATE SET + encrypted_value = excluded.encrypted_value, + updated_at = excluded.updated_at, + updated_by = excluded.updated_by + """, + (name, encrypted_value, now.isoformat(timespec="seconds"), updated_by), + ) + + @staticmethod + def get_encrypted(connection: sqlite3.Connection, name: str) -> str | None: + row = connection.execute( + "SELECT encrypted_value FROM system_credentials WHERE name = ?", (name,) + ).fetchone() + return str(row["encrypted_value"]) if row else None diff --git a/next/backend/features/accounts/routes.py b/next/backend/features/accounts/routes.py new file mode 100644 index 0000000..eaa86b7 --- /dev/null +++ b/next/backend/features/accounts/routes.py @@ -0,0 +1,314 @@ +from __future__ import annotations + +from datetime import date, time +from typing import Annotated + +from fastapi import APIRouter, Path, Request, Response + +from backend.features.accounts.auth import ( + CSRF_COOKIE, + SESSION_COOKIE, + AdminPrincipal, + AdminWritePrincipal, + AuthenticatedPrincipal, + CsrfPrincipal, +) +from backend.features.accounts.models import ( + BirthProfile, + MembershipAccountView, + MembershipView, + Principal, + SessionIssue, +) +from backend.features.accounts.schemas import ( + AccountIdentityResponse, + AuthResponse, + BirthProfileInput, + BirthProfileResponse, + CredentialInput, + CredentialsInput, + CredentialStatusResponse, + MembershipAdminResponse, + MembershipStatusResponse, + MembershipUpdateInput, + MessageResponse, + ModelInput, + ModelPoolItemResponse, + ModelSelectionInput, + ModelUpdateInput, + PasswordChangeInput, +) + +router = APIRouter() + + +def _membership_response(view: MembershipView, smart_access: bool) -> MembershipStatusResponse: + return MembershipStatusResponse( + status=view.status, + active=view.active, + is_permanent=view.is_permanent, + expires_at=view.expires_at, + remaining_days=view.remaining_days, + daily_limit=view.daily_limit, + used_today=view.used_today, + remaining_today=view.remaining_today, + quota_exempt=view.quota_exempt, + smart_access=smart_access, + ) + + +def _identity(request: Request, principal: Principal) -> AccountIdentityResponse: + memberships = request.app.state.container.memberships + view = memberships.view_for(principal) + badges = (["admin"] if principal.user.is_admin else []) + (["member"] if view.active else []) + return AccountIdentityResponse( + id=principal.user.id, + username=principal.user.username, + is_admin=principal.user.is_admin, + membership_status=view.status, + membership_active=view.active, + smart_access=memberships.can_use_smart_features(principal), + badges=badges, + ) + + +def _set_session_cookies(request: Request, response: Response, issue: SessionIssue) -> None: + secure = request.app.state.settings.environment == "production" + max_age = 30 * 24 * 60 * 60 + response.set_cookie( + SESSION_COOKIE, + issue.token, + max_age=max_age, + httponly=True, + secure=secure, + samesite="lax", + path="/", + ) + response.set_cookie( + CSRF_COOKIE, + issue.csrf_token, + max_age=max_age, + httponly=False, + secure=secure, + samesite="lax", + path="/", + ) + + +def _clear_session_cookies(response: Response) -> None: + response.delete_cookie(SESSION_COOKIE, path="/") + response.delete_cookie(CSRF_COOKIE, path="/") + + +def _profile_response(profile: BirthProfile | None) -> BirthProfileResponse: + if profile is None: + return BirthProfileResponse(configured=False) + return BirthProfileResponse( + configured=True, + birth_date=date.fromisoformat(profile.birth_date), + birth_time=time.fromisoformat(profile.birth_time), + gender=profile.gender, + updated_at=profile.updated_at, + ) + + +def _admin_membership_response( + account: MembershipAccountView, +) -> MembershipAdminResponse: + return MembershipAdminResponse( + user_id=account.user.id, + username=account.user.username, + is_admin=account.user.is_admin, + membership=_membership_response( + account.membership, + account.user.is_admin or account.membership.active, + ), + ) + + +@router.post("/auth/register", response_model=AuthResponse, status_code=201) +def register(payload: CredentialsInput, request: Request, response: Response) -> AuthResponse: + issue = request.app.state.container.accounts.register(payload.username, payload.password) + _set_session_cookies(request, response, issue) + return AuthResponse(account=_identity(request, issue.principal), csrf_token=issue.csrf_token) + + +@router.post("/auth/login", response_model=AuthResponse) +def login(payload: CredentialsInput, request: Request, response: Response) -> AuthResponse: + issue = request.app.state.container.accounts.login(payload.username, payload.password) + _set_session_cookies(request, response, issue) + return AuthResponse(account=_identity(request, issue.principal), csrf_token=issue.csrf_token) + + +@router.get("/auth/session", response_model=AccountIdentityResponse) +def session(request: Request, principal: AuthenticatedPrincipal): + return _identity(request, principal) + + +@router.post("/auth/logout", response_model=MessageResponse) +@router.post("/auth/switch-account", response_model=MessageResponse) +def logout( + request: Request, + response: Response, + principal: CsrfPrincipal, +) -> MessageResponse: + request.app.state.container.accounts.logout(principal) + _clear_session_cookies(response) + return MessageResponse(message="已退出当前账号。") + + +@router.patch("/account/password", response_model=MessageResponse) +def change_password( + payload: PasswordChangeInput, + request: Request, + principal: CsrfPrincipal, +) -> MessageResponse: + request.app.state.container.accounts.change_password( + principal, + payload.current_password, + payload.new_password, + payload.confirmation, + ) + return MessageResponse(message="密码已修改。") + + +@router.get("/account/profile", response_model=BirthProfileResponse) +def get_profile(request: Request, principal: AuthenticatedPrincipal) -> BirthProfileResponse: + return _profile_response(request.app.state.container.accounts.get_profile(principal.user.id)) + + +@router.put("/account/profile", response_model=BirthProfileResponse) +def save_profile( + payload: BirthProfileInput, + request: Request, + principal: CsrfPrincipal, +) -> BirthProfileResponse: + profile = request.app.state.container.accounts.save_profile( + principal.user.id, + payload.birth_date, + payload.birth_time.isoformat(timespec="minutes"), + payload.gender, + ) + return _profile_response(profile) + + +@router.delete("/account/profile", response_model=MessageResponse) +def delete_profile(request: Request, principal: CsrfPrincipal) -> MessageResponse: + request.app.state.container.accounts.delete_profile(principal.user.id) + return MessageResponse(message="个人出生资料已删除。") + + +@router.get("/account/membership", response_model=MembershipStatusResponse) +def get_membership(request: Request, principal: AuthenticatedPrincipal) -> MembershipStatusResponse: + memberships = request.app.state.container.memberships + view = memberships.view_for(principal) + return _membership_response(view, memberships.can_use_smart_features(principal)) + + +@router.get( + "/admin/memberships", + response_model=list[MembershipAdminResponse], +) +def list_memberships(request: Request, _principal: AdminPrincipal) -> list[MembershipAdminResponse]: + return [ + _admin_membership_response(account) + for account in request.app.state.container.memberships.list_accounts() + ] + + +@router.patch( + "/admin/memberships/{user_id}", + response_model=MembershipAdminResponse, +) +def update_membership( + payload: MembershipUpdateInput, + request: Request, + user_id: Annotated[int, Path(ge=1)], + principal: AdminWritePrincipal, +) -> MembershipAdminResponse: + request.app.state.container.memberships.update( + principal, user_id, payload.action, payload.duration, payload.daily_limit + ) + return _admin_membership_response(request.app.state.container.memberships.get_account(user_id)) + + +@router.get( + "/admin/system/credentials", + response_model=list[CredentialStatusResponse], +) +def list_credentials( + request: Request, _principal: AdminPrincipal +) -> tuple[dict[str, str | bool | None], ...]: + return request.app.state.container.system_credentials.list_status() + + +@router.put("/admin/system/credentials/{name}", response_model=MessageResponse) +def save_credential( + payload: CredentialInput, + request: Request, + name: Annotated[str, Path(min_length=1, max_length=64)], + principal: AdminWritePrincipal, +) -> MessageResponse: + request.app.state.container.system_credentials.save(principal, name, payload.value) + return MessageResponse(message="系统凭据已保存。") + + +@router.get("/admin/models", response_model=list[ModelPoolItemResponse]) +def list_models( + request: Request, _principal: AdminPrincipal +) -> tuple[object, ...]: + return request.app.state.container.model_pool.list() + + +@router.post("/admin/models", response_model=ModelPoolItemResponse, status_code=201) +def create_model( + payload: ModelInput, + request: Request, + principal: AdminWritePrincipal, +) -> ModelPoolItemResponse: + return request.app.state.container.model_pool.create( + principal, + payload.display_name, + payload.base_url, + payload.model_identifier, + payload.api_key, + ) + + +@router.put("/admin/models/selection", response_model=MessageResponse) +def select_models( + payload: ModelSelectionInput, + request: Request, + principal: AdminWritePrincipal, +) -> MessageResponse: + request.app.state.container.model_pool.select( + principal, payload.primary_model_id, payload.fallback_model_id + ) + return MessageResponse(message="主模型和辅助模型已更新。") + + +@router.put("/admin/models/{model_id}", response_model=ModelPoolItemResponse) +def update_model( + payload: ModelUpdateInput, + request: Request, + model_id: Annotated[int, Path(ge=1)], + principal: AdminWritePrincipal, +) -> ModelPoolItemResponse: + return request.app.state.container.model_pool.update( + principal, + model_id, + payload.display_name, + payload.base_url, + payload.model_identifier, + payload.api_key, + ) + + +@router.delete("/admin/models/{model_id}", response_model=MessageResponse) +def delete_model( + request: Request, + model_id: Annotated[int, Path(ge=1)], + _principal: AdminWritePrincipal, +) -> MessageResponse: + request.app.state.container.model_pool.delete(model_id) + return MessageResponse(message="模型已删除。") diff --git a/next/backend/features/accounts/schemas.py b/next/backend/features/accounts/schemas.py new file mode 100644 index 0000000..a41d6f9 --- /dev/null +++ b/next/backend/features/accounts/schemas.py @@ -0,0 +1,121 @@ +from __future__ import annotations + +from datetime import date, datetime, time +from typing import Literal + +from pydantic import BaseModel, Field + +from backend.features.accounts.service import PROFILE_PRIVACY_NOTICE + + +class CredentialsInput(BaseModel): + username: str + password: str + + +class PasswordChangeInput(BaseModel): + current_password: str + new_password: str + confirmation: str + + +class AccountIdentityResponse(BaseModel): + id: int + username: str + is_admin: bool + membership_status: str + membership_active: bool + smart_access: bool + badges: list[str] + + +class AuthResponse(BaseModel): + account: AccountIdentityResponse + csrf_token: str + + +class MessageResponse(BaseModel): + message: str + + +class BirthProfileInput(BaseModel): + birth_date: date + birth_time: time + gender: Literal["male", "female"] + + +class BirthProfileResponse(BaseModel): + configured: bool + birth_date: date | None = None + birth_time: time | None = None + gender: str | None = None + updated_at: datetime | None = None + privacy_notice: str = PROFILE_PRIVACY_NOTICE + + +class MembershipStatusResponse(BaseModel): + status: str + active: bool + is_permanent: bool + expires_at: datetime | None + remaining_days: int | None + daily_limit: int + used_today: int + remaining_today: int | None + quota_exempt: bool + smart_access: bool + description: str = "开通会员后可使用智能选股、问师、问天、复盘助手等智能功能。" + usage_package_available: bool = False + + +class MembershipAdminResponse(BaseModel): + user_id: int + username: str + is_admin: bool + membership: MembershipStatusResponse + + +class MembershipUpdateInput(BaseModel): + action: Literal["activate", "disable", "set_limit"] + duration: Literal["1_month", "3_months", "12_months", "3_years", "permanent"] | None = None + daily_limit: int | None = Field(default=None, ge=1, le=1000) + + +class CredentialStatusResponse(BaseModel): + name: str + configured: bool + updated_at: datetime | None + + +class CredentialInput(BaseModel): + value: str = Field(min_length=1, max_length=4096) + + +class ModelInput(BaseModel): + display_name: str = Field(min_length=1, max_length=40) + base_url: str = Field(min_length=1, max_length=2048) + model_identifier: str = Field(min_length=1, max_length=128) + api_key: str = Field(min_length=1, max_length=4096) + + +class ModelUpdateInput(BaseModel): + display_name: str = Field(min_length=1, max_length=40) + base_url: str = Field(min_length=1, max_length=2048) + model_identifier: str = Field(min_length=1, max_length=128) + api_key: str | None = Field(default=None, min_length=1, max_length=4096) + + +class ModelPoolItemResponse(BaseModel): + id: int + display_name: str + base_url: str + model_identifier: str + has_api_key: bool + is_primary: bool + is_fallback: bool + updated_at: datetime + + +class ModelSelectionInput(BaseModel): + primary_model_id: int = Field(ge=1) + fallback_model_id: int | None = Field(default=None, ge=1) diff --git a/next/backend/features/accounts/service.py b/next/backend/features/accounts/service.py new file mode 100644 index 0000000..677dd43 --- /dev/null +++ b/next/backend/features/accounts/service.py @@ -0,0 +1,382 @@ +from __future__ import annotations + +import calendar +import hashlib +import hmac +import json +import re +import secrets +import sqlite3 +import unicodedata +from datetime import UTC, date, datetime, timedelta +from math import ceil +from zoneinfo import ZoneInfo + +from backend.database.connection import Database +from backend.errors import BusinessError +from backend.features.accounts.models import ( + BirthProfile, + MembershipAccountView, + MembershipRecord, + MembershipView, + Principal, + SessionIssue, + UserRecord, +) +from backend.features.accounts.repository import AccountRepository +from backend.security import PasswordHasher, PasswordPolicyError, SecretCipher + +USERNAME_PATTERN = re.compile(r"[A-Za-z0-9_\-\u4e00-\u9fff]{3,30}") +SHANGHAI = ZoneInfo("Asia/Shanghai") +PROFILE_PRIVACY_NOTICE = "原始出生资料加密保存,仅当前账号可见。" +SESSION_LIFETIME = timedelta(days=30) + + +def now_utc() -> datetime: + return datetime.now(UTC) + + +def username_key(username: str) -> str: + return unicodedata.normalize("NFKC", username).casefold() + + +def normalize_username(raw_username: str) -> tuple[str, str]: + username = unicodedata.normalize("NFKC", raw_username.strip()) + if not USERNAME_PATTERN.fullmatch(username): + raise BusinessError( + "invalid_username", "账号名应为3至30位中文、字母、数字、下划线或连字符。" + ) + return username, username_key(username) + + +def hash_token(token: str) -> str: + return hashlib.sha256(token.encode("utf-8")).hexdigest() + + +def add_months(value: datetime, months: int) -> datetime: + month_index = value.year * 12 + value.month - 1 + months + year, zero_based_month = divmod(month_index, 12) + month = zero_based_month + 1 + day = min(value.day, calendar.monthrange(year, month)[1]) + return value.replace(year=year, month=month, day=day) + + +def membership_active(membership: MembershipRecord, now: datetime) -> bool: + return membership.state == "active" and ( + membership.is_permanent + or (membership.expires_at is not None and membership.expires_at > now) + ) + + +def membership_view( + user: UserRecord, + membership: MembershipRecord, + used_today: int, + now: datetime, +) -> MembershipView: + active = membership_active(membership, now) + if membership.state == "disabled": + status = "disabled" + elif active: + status = "active" + else: + status = "not_open" + remaining_days = None + if active and not membership.is_permanent and membership.expires_at is not None: + remaining_days = max(0, ceil((membership.expires_at - now).total_seconds() / 86400)) + quota_exempt = user.is_admin + return MembershipView( + status=status, + active=active, + is_permanent=membership.is_permanent, + expires_at=membership.expires_at if not membership.is_permanent else None, + remaining_days=remaining_days, + daily_limit=membership.daily_llm_limit, + used_today=used_today, + remaining_today=(None if quota_exempt else max(0, membership.daily_llm_limit - used_today)), + quota_exempt=quota_exempt, + ) + + +class AccountService: + def __init__( + self, + database: Database, + repository: AccountRepository, + password_hasher: PasswordHasher, + cipher: SecretCipher, + ) -> None: + self._database = database + self._repository = repository + self._password_hasher = password_hasher + self._cipher = cipher + self._dummy_password_hash = password_hasher.hash("not-a-real-account-123!") + + def register(self, raw_username: str, password: str) -> SessionIssue: + username, normalized_key = normalize_username(raw_username) + try: + password_hash = self._password_hasher.hash(password) + except PasswordPolicyError as exc: + raise BusinessError("invalid_password", str(exc)) from exc + now = now_utc() + try: + with self._database.transaction() as connection: + is_admin = self._repository.count_users(connection) == 0 + user = self._repository.create_user( + connection, username, normalized_key, password_hash, is_admin, now + ) + membership = self._repository.create_default_membership(connection, user.id, now) + return self._issue_session(connection, user, membership, now) + except sqlite3.IntegrityError as exc: + raise BusinessError("username_taken", "该账号名已被使用。") from exc + + def login(self, raw_username: str, password: str) -> SessionIssue: + normalized_key = username_key(raw_username.strip()) + now = now_utc() + with self._database.transaction() as connection: + user = self._repository.get_user_by_username_key(connection, normalized_key) + encoded_hash = user.password_hash if user else self._dummy_password_hash + password_valid = self._password_hasher.verify(password, encoded_hash) + if user is None or not password_valid or user.status != "active": + raise BusinessError("invalid_credentials", "账号或密码错误。") + membership = self._repository.get_membership(connection, user.id) + if membership is None: + raise BusinessError("account_unavailable", "账号暂不可用,请联系管理员。") + return self._issue_session(connection, user, membership, now) + + def authenticate(self, raw_token: str | None) -> Principal | None: + if not raw_token: + return None + token_hash = hash_token(raw_token) + now = now_utc() + with self._database.read() as connection: + session = self._repository.get_session(connection, token_hash) + if session is None: + return None + if session.expires_at <= now or session.user.status != "active": + with self._database.transaction() as connection: + self._repository.delete_session(connection, token_hash) + return None + return Principal( + token_hash=session.token_hash, + csrf_hash=session.csrf_hash, + user=session.user, + membership=session.membership, + ) + + @staticmethod + def verify_csrf( + principal: Principal, + header_token: str | None, + cookie_token: str | None, + ) -> bool: + if not header_token or not cookie_token: + return False + if not hmac.compare_digest(header_token, cookie_token): + return False + return hmac.compare_digest(hash_token(header_token), principal.csrf_hash) + + def logout(self, principal: Principal) -> None: + with self._database.transaction() as connection: + self._repository.delete_session(connection, principal.token_hash) + + def change_password( + self, + principal: Principal, + current_password: str, + new_password: str, + confirmation: str, + ) -> None: + if new_password != confirmation: + raise BusinessError("password_mismatch", "两次输入的新密码不一致。") + if not self._password_hasher.verify(current_password, principal.user.password_hash): + raise BusinessError("invalid_current_password", "当前密码不正确。") + try: + new_hash = self._password_hasher.hash(new_password) + except PasswordPolicyError as exc: + raise BusinessError("invalid_password", str(exc)) from exc + now = now_utc() + with self._database.transaction() as connection: + self._repository.update_password(connection, principal.user.id, new_hash, now) + self._repository.delete_other_sessions( + connection, principal.user.id, principal.token_hash + ) + + def get_profile(self, user_id: int) -> BirthProfile | None: + with self._database.read() as connection: + row = self._repository.get_encrypted_profile(connection, user_id) + if row is None: + return None + try: + payload = json.loads(self._cipher.decrypt(str(row["encrypted_payload"]))) + except (ValueError, json.JSONDecodeError, KeyError) as exc: + raise BusinessError( + "profile_unavailable", "个人资料暂时无法读取,请联系管理员。" + ) from exc + return BirthProfile( + birth_date=str(payload["birth_date"]), + birth_time=str(payload["birth_time"]), + gender=str(payload["gender"]), + updated_at=datetime.fromisoformat(str(row["updated_at"])), + ) + + def save_profile( + self, user_id: int, birth_date: date, birth_time: str, gender: str + ) -> BirthProfile: + today = datetime.now(SHANGHAI).date() + if birth_date > today: + raise BusinessError("invalid_birth_date", "出生日期不能晚于今天。") + payload = json.dumps( + { + "birth_date": birth_date.isoformat(), + "birth_time": birth_time, + "gender": gender, + }, + ensure_ascii=False, + separators=(",", ":"), + sort_keys=True, + ) + now = now_utc() + with self._database.transaction() as connection: + self._repository.save_encrypted_profile( + connection, user_id, self._cipher.encrypt(payload), now + ) + return BirthProfile(birth_date.isoformat(), birth_time, gender, now) + + def delete_profile(self, user_id: int) -> None: + with self._database.transaction() as connection: + self._repository.delete_profile(connection, user_id) + + def _issue_session( + self, + connection: sqlite3.Connection, + user: UserRecord, + membership: MembershipRecord, + now: datetime, + ) -> SessionIssue: + token = secrets.token_urlsafe(32) + csrf_token = secrets.token_urlsafe(32) + token_hash = hash_token(token) + csrf_hash = hash_token(csrf_token) + self._repository.create_session( + connection, + token_hash, + csrf_hash, + user.id, + now, + now + SESSION_LIFETIME, + ) + return SessionIssue( + token=token, + csrf_token=csrf_token, + principal=Principal(token_hash, csrf_hash, user, membership), + ) + + +class MembershipService: + DURATION_MONTHS = { + "1_month": 1, + "3_months": 3, + "12_months": 12, + "3_years": 36, + } + + def __init__(self, database: Database, repository: AccountRepository) -> None: + self._database = database + self._repository = repository + + def view_for(self, principal: Principal) -> MembershipView: + today = datetime.now(SHANGHAI).date().isoformat() + with self._database.read() as connection: + used_today = self._repository.usage_today(connection, principal.user.id, today) + return membership_view(principal.user, principal.membership, used_today, now_utc()) + + def can_use_smart_features(self, principal: Principal) -> bool: + return principal.user.is_admin or membership_active(principal.membership, now_utc()) + + def list_accounts(self) -> tuple[MembershipAccountView, ...]: + today = datetime.now(SHANGHAI).date().isoformat() + now = now_utc() + with self._database.read() as connection: + accounts = self._repository.list_memberships(connection) + usage = self._repository.usage_for_users(connection, today) + return tuple( + MembershipAccountView( + account.user, + membership_view( + account.user, + account.membership, + usage.get(account.user.id, 0), + now, + ), + ) + for account in accounts + ) + + def get_account(self, user_id: int) -> MembershipAccountView: + today = datetime.now(SHANGHAI).date().isoformat() + with self._database.read() as connection: + account = self._repository.get_membership_account(connection, user_id) + used_today = self._repository.usage_today(connection, user_id, today) + if account is None: + raise BusinessError("account_not_found", "账号不存在。") + return MembershipAccountView( + account.user, + membership_view(account.user, account.membership, used_today, now_utc()), + ) + + def update( + self, + actor: Principal, + user_id: int, + action: str, + duration: str | None, + daily_limit: int | None, + ) -> MembershipRecord: + now = now_utc() + with self._database.transaction() as connection: + current = self._repository.get_membership(connection, user_id) + if current is None: + raise BusinessError("account_not_found", "账号不存在。") + limit = daily_limit if daily_limit is not None else current.daily_llm_limit + if not 1 <= limit <= 1000: + raise BusinessError("invalid_daily_limit", "每日智能分析上限应为1至1000次。") + if action == "disable": + state = "disabled" + expires_at = current.expires_at + permanent = current.is_permanent + elif action == "activate": + if duration == "permanent": + state, expires_at, permanent = "active", None, True + elif duration in self.DURATION_MONTHS: + if current.state == "active" and current.is_permanent: + raise BusinessError("permanent_membership", "永久会员无需续期。") + base = ( + current.expires_at + if membership_active(current, now) and current.expires_at is not None + else now + ) + state = "active" + expires_at = add_months(base, self.DURATION_MONTHS[duration]) + permanent = False + else: + raise BusinessError("invalid_membership_duration", "请选择有效的会员时长。") + elif action == "set_limit": + state = current.state + expires_at = current.expires_at + permanent = current.is_permanent + else: + raise BusinessError("invalid_membership_action", "会员操作无效。") + updated = self._repository.update_membership( + connection, + user_id, + state, + expires_at, + permanent, + limit, + actor.user.id, + now, + ) + if updated is None: + raise BusinessError("account_not_found", "账号不存在。") + return updated diff --git a/next/backend/http/errors.py b/next/backend/http/errors.py index 1153686..153c132 100644 --- a/next/backend/http/errors.py +++ b/next/backend/http/errors.py @@ -9,10 +9,12 @@ from fastapi.exceptions import RequestValidationError from fastapi.responses import JSONResponse from starlette.exceptions import HTTPException +from backend.errors import BusinessError + logger = logging.getLogger(__name__) -@dataclass(frozen=True, slots=True) +@dataclass(slots=True) class AppError(Exception): code: str message: str @@ -39,6 +41,23 @@ def _response(request: Request, status_code: int, code: str, message: str) -> JS def install_error_handlers(application: FastAPI) -> None: + @application.exception_handler(BusinessError) + async def business_error(request: Request, error: BusinessError) -> JSONResponse: + status_code = { + "invalid_credentials": HTTPStatus.UNAUTHORIZED, + "account_unavailable": HTTPStatus.FORBIDDEN, + "username_taken": HTTPStatus.CONFLICT, + "account_not_found": HTTPStatus.NOT_FOUND, + "model_not_found": HTTPStatus.NOT_FOUND, + "permanent_membership": HTTPStatus.CONFLICT, + "model_in_use": HTTPStatus.CONFLICT, + "model_name_taken": HTTPStatus.CONFLICT, + "model_pool_full": HTTPStatus.CONFLICT, + "model_not_configured": HTTPStatus.SERVICE_UNAVAILABLE, + "profile_unavailable": HTTPStatus.SERVICE_UNAVAILABLE, + }.get(error.code, HTTPStatus.BAD_REQUEST) + return _response(request, status_code, error.code, error.message) + @application.exception_handler(AppError) async def application_error(request: Request, error: AppError) -> JSONResponse: return _response(request, error.status_code, error.code, error.message) diff --git a/next/backend/http/router.py b/next/backend/http/router.py index 867a738..ffb17bc 100644 --- a/next/backend/http/router.py +++ b/next/backend/http/router.py @@ -1,6 +1,8 @@ from fastapi import APIRouter +from backend.features.accounts.routes import router as accounts_router from backend.http.routes.health import router as health_router api_router = APIRouter() api_router.include_router(health_router) +api_router.include_router(accounts_router) diff --git a/next/backend/security/__init__.py b/next/backend/security/__init__.py new file mode 100644 index 0000000..10f458c --- /dev/null +++ b/next/backend/security/__init__.py @@ -0,0 +1,4 @@ +from backend.security.cipher import SecretCipher, load_or_create_cipher +from backend.security.passwords import PasswordHasher, PasswordPolicyError + +__all__ = ["PasswordHasher", "PasswordPolicyError", "SecretCipher", "load_or_create_cipher"] diff --git a/next/backend/security/cipher.py b/next/backend/security/cipher.py new file mode 100644 index 0000000..363a66b --- /dev/null +++ b/next/backend/security/cipher.py @@ -0,0 +1,50 @@ +from __future__ import annotations + +import os +from dataclasses import dataclass + +from cryptography.fernet import Fernet, InvalidToken + +from backend.bootstrap.settings import ConfigurationError, Settings + + +@dataclass(frozen=True, slots=True) +class SecretCipher: + _fernet: Fernet + + @classmethod + def from_key(cls, key: str) -> SecretCipher: + try: + return cls(Fernet(key.encode("ascii"))) + except (ValueError, UnicodeEncodeError) as exc: + raise ConfigurationError("APP_ENCRYPTION_KEY is not a valid Fernet key") from exc + + def encrypt(self, value: str) -> str: + return self._fernet.encrypt(value.encode("utf-8")).decode("ascii") + + def decrypt(self, value: str) -> str: + try: + return self._fernet.decrypt(value.encode("ascii")).decode("utf-8") + except (InvalidToken, UnicodeDecodeError, UnicodeEncodeError) as exc: + raise ValueError("Encrypted value cannot be decrypted") from exc + + +def load_or_create_cipher(settings: Settings) -> SecretCipher: + if settings.encryption_key is not None: + return SecretCipher.from_key(settings.encryption_key) + if settings.environment == "production": + raise ConfigurationError("APP_ENCRYPTION_KEY is required in production") + + key_file = settings.data_directory / ".encryption.key" + key_file.parent.mkdir(parents=True, exist_ok=True) + if key_file.exists(): + return SecretCipher.from_key(key_file.read_text(encoding="ascii").strip()) + + key = Fernet.generate_key().decode("ascii") + try: + with key_file.open("x", encoding="ascii") as handle: + handle.write(key) + os.chmod(key_file, 0o600) + except FileExistsError: + key = key_file.read_text(encoding="ascii").strip() + return SecretCipher.from_key(key) diff --git a/next/backend/security/passwords.py b/next/backend/security/passwords.py new file mode 100644 index 0000000..95c6888 --- /dev/null +++ b/next/backend/security/passwords.py @@ -0,0 +1,68 @@ +from __future__ import annotations + +import base64 +import hashlib +import hmac +import secrets +from dataclasses import dataclass + + +class PasswordPolicyError(ValueError): + pass + + +@dataclass(frozen=True, slots=True) +class PasswordHasher: + work_factor: int = 2**15 + block_size: int = 8 + parallelism: int = 1 + salt_bytes: int = 16 + key_bytes: int = 32 + + def validate(self, password: str) -> None: + if not 8 <= len(password) <= 128: + raise PasswordPolicyError("密码长度应为8至128位。") + categories = ( + any(character.isalpha() for character in password), + any(character.isdigit() for character in password), + any(not character.isalnum() for character in password), + ) + if sum(categories) < 2: + raise PasswordPolicyError("密码至少包含字母、数字、符号中的两类。") + + def hash(self, password: str) -> str: + self.validate(password) + salt = secrets.token_bytes(self.salt_bytes) + digest = hashlib.scrypt( + password.encode("utf-8"), + salt=salt, + n=self.work_factor, + r=self.block_size, + p=self.parallelism, + dklen=self.key_bytes, + maxmem=64 * 1024 * 1024, + ) + encoded_salt = base64.urlsafe_b64encode(salt).decode("ascii") + encoded_digest = base64.urlsafe_b64encode(digest).decode("ascii") + return ( + f"scrypt${self.work_factor}${self.block_size}${self.parallelism}" + f"${encoded_salt}${encoded_digest}" + ) + + def verify(self, password: str, encoded: str) -> bool: + try: + scheme, work_factor, block_size, parallelism, salt, expected = encoded.split("$") + if scheme != "scrypt": + return False + digest = hashlib.scrypt( + password.encode("utf-8"), + salt=base64.urlsafe_b64decode(salt.encode("ascii")), + n=int(work_factor), + r=int(block_size), + p=int(parallelism), + dklen=self.key_bytes, + maxmem=64 * 1024 * 1024, + ) + return hmac.compare_digest(base64.urlsafe_b64encode(digest).decode("ascii"), expected) + except (TypeError, ValueError): + return False diff --git a/next/requirements.txt b/next/requirements.txt index 00ff448..a77be9d 100644 --- a/next/requirements.txt +++ b/next/requirements.txt @@ -1,3 +1,4 @@ +cryptography==49.0.0 fastapi==0.141.0 pydantic==2.13.4 tzdata==2026.3 diff --git a/next/tests/__init__.py b/next/tests/__init__.py new file mode 100644 index 0000000..429b3a2 --- /dev/null +++ b/next/tests/__init__.py @@ -0,0 +1 @@ +"""Tests for the rebuilt application.""" diff --git a/next/tests/support.py b/next/tests/support.py new file mode 100644 index 0000000..387bbce --- /dev/null +++ b/next/tests/support.py @@ -0,0 +1,28 @@ +from __future__ import annotations + +import asyncio +from collections.abc import Awaitable, Callable + +import httpx +from fastapi import FastAPI + +type Scenario[Result] = Callable[[httpx.AsyncClient], Awaitable[Result]] + + +def run_scenario[Result](application: FastAPI, scenario: Scenario[Result]) -> Result: + async def run() -> Result: + transport = httpx.ASGITransport(app=application, raise_app_exceptions=False) + async with application.router.lifespan_context(application): + async with httpx.AsyncClient( + transport=transport, base_url="http://testserver" + ) as client: + return await scenario(client) + + return asyncio.run(run()) + + +def request(application: FastAPI, path: str) -> httpx.Response: + async def get(client: httpx.AsyncClient) -> httpx.Response: + return await client.get(path) + + return run_scenario(application, get) diff --git a/next/tests/test_accounts.py b/next/tests/test_accounts.py new file mode 100644 index 0000000..4319dca --- /dev/null +++ b/next/tests/test_accounts.py @@ -0,0 +1,377 @@ +from __future__ import annotations + +import sqlite3 +from dataclasses import dataclass +from datetime import datetime + +import httpx + +from backend.bootstrap.application import create_application +from backend.bootstrap.settings import Settings +from backend.features.accounts.auth import CSRF_COOKIE, SESSION_COOKIE, SmartAccessPrincipal +from backend.features.accounts.service import add_months +from tests.support import run_scenario + +ADMIN_PASSWORD = "Admin-pass-123!" +USER_PASSWORD = "User-pass-123!" + + +@dataclass(frozen=True) +class BrowserSession: + session: str + csrf: str + + +def current_session(client: httpx.AsyncClient) -> BrowserSession: + return BrowserSession( + session=client.cookies.get(SESSION_COOKIE), + csrf=client.cookies.get(CSRF_COOKIE), + ) + + +def use_session(client: httpx.AsyncClient, session: BrowserSession) -> None: + client.cookies.clear() + client.cookies.set(SESSION_COOKIE, session.session) + client.cookies.set(CSRF_COOKIE, session.csrf) + + +def csrf_headers(session: BrowserSession) -> dict[str, str]: + return {"X-CSRF-Token": session.csrf} + + +async def register( + client: httpx.AsyncClient, username: str, password: str +) -> tuple[httpx.Response, BrowserSession]: + response = await client.post( + "/api/auth/register", json={"username": username, "password": password} + ) + return response, current_session(client) + + +def test_first_account_is_admin_and_second_is_regular_user(tmp_path) -> None: + application = create_application(Settings.for_test(tmp_path)) + + async def scenario(client: httpx.AsyncClient) -> None: + admin_response, admin_session = await register(client, "leefer", ADMIN_PASSWORD) + assert admin_response.status_code == 201 + assert admin_response.json()["account"] == { + "id": 1, + "username": "leefer", + "is_admin": True, + "membership_status": "not_open", + "membership_active": False, + "smart_access": True, + "badges": ["admin"], + } + + client.cookies.clear() + user_response, user_session = await register(client, "小白用户", USER_PASSWORD) + assert user_response.status_code == 201 + assert user_response.json()["account"]["is_admin"] is False + assert user_response.json()["account"]["smart_access"] is False + assert user_response.json()["account"]["badges"] == [] + + use_session(client, admin_session) + assert (await client.get("/api/auth/session")).json()["is_admin"] is True + use_session(client, user_session) + assert (await client.get("/api/auth/session")).json()["is_admin"] is False + + with sqlite3.connect(application.state.settings.database_path) as connection: + stored = {row[0] for row in connection.execute("SELECT token_hash FROM sessions")} + assert admin_session.session not in stored + assert user_session.session not in stored + assert all(len(value) == 64 for value in stored) + + run_scenario(application, scenario) + + +def test_csrf_password_change_and_other_session_revocation(tmp_path) -> None: + application = create_application(Settings.for_test(tmp_path)) + + async def scenario(client: httpx.AsyncClient) -> None: + _, first_session = await register(client, "secure-user", USER_PASSWORD) + second_login = await client.post( + "/api/auth/login", + json={"username": "secure-user", "password": USER_PASSWORD}, + ) + assert second_login.status_code == 200 + second_session = current_session(client) + + without_csrf = await client.patch( + "/api/account/password", + json={ + "current_password": USER_PASSWORD, + "new_password": "Changed-pass-456!", + "confirmation": "Changed-pass-456!", + }, + ) + assert without_csrf.status_code == 403 + assert without_csrf.json()["error"]["code"] == "csrf_failed" + + wrong_current = await client.patch( + "/api/account/password", + headers=csrf_headers(second_session), + json={ + "current_password": "Wrong-pass-999!", + "new_password": "Changed-pass-456!", + "confirmation": "Changed-pass-456!", + }, + ) + assert wrong_current.status_code == 400 + assert wrong_current.json()["error"]["code"] == "invalid_current_password" + + changed = await client.patch( + "/api/account/password", + headers=csrf_headers(second_session), + json={ + "current_password": USER_PASSWORD, + "new_password": "Changed-pass-456!", + "confirmation": "Changed-pass-456!", + }, + ) + assert changed.status_code == 200 + + use_session(client, first_session) + assert (await client.get("/api/auth/session")).status_code == 401 + client.cookies.clear() + old_login = await client.post( + "/api/auth/login", + json={"username": "secure-user", "password": USER_PASSWORD}, + ) + assert old_login.status_code == 401 + assert old_login.json()["error"]["message"] == "账号或密码错误。" + new_login = await client.post( + "/api/auth/login", + json={"username": "secure-user", "password": "Changed-pass-456!"}, + ) + assert new_login.status_code == 200 + + run_scenario(application, scenario) + + +def test_birth_profile_is_encrypted_and_isolated_by_account(tmp_path) -> None: + application = create_application(Settings.for_test(tmp_path)) + + async def scenario(client: httpx.AsyncClient) -> None: + _, first_session = await register(client, "profile-a", USER_PASSWORD) + saved = await client.put( + "/api/account/profile", + headers=csrf_headers(first_session), + json={"birth_date": "1990-03-08", "birth_time": "08:30", "gender": "male"}, + ) + assert saved.status_code == 200 + assert saved.json()["configured"] is True + assert "仅当前账号可见" in saved.json()["privacy_notice"] + + client.cookies.clear() + _, second_session = await register(client, "profile-b", USER_PASSWORD) + assert (await client.get("/api/account/profile")).json()["configured"] is False + + with sqlite3.connect(application.state.settings.database_path) as connection: + encrypted = connection.execute( + "SELECT encrypted_payload FROM birth_profiles WHERE user_id = 1" + ).fetchone()[0] + assert "1990-03-08" not in encrypted + assert "08:30" not in encrypted + + use_session(client, first_session) + own_profile = await client.get("/api/account/profile") + assert own_profile.json()["birth_date"] == "1990-03-08" + deleted = await client.delete("/api/account/profile", headers=csrf_headers(first_session)) + assert deleted.status_code == 200 + assert (await client.get("/api/account/profile")).json()["configured"] is False + + use_session(client, second_session) + assert (await client.get("/api/account/profile")).json()["configured"] is False + + run_scenario(application, scenario) + + +def test_membership_and_admin_are_independent_dimensions(tmp_path) -> None: + application = create_application(Settings.for_test(tmp_path)) + + async def scenario(client: httpx.AsyncClient) -> None: + _, admin_session = await register(client, "admin-user", ADMIN_PASSWORD) + client.cookies.clear() + user_response, user_session = await register(client, "member-user", USER_PASSWORD) + user_id = user_response.json()["account"]["id"] + + use_session(client, admin_session) + activated = await client.patch( + f"/api/admin/memberships/{user_id}", + headers=csrf_headers(admin_session), + json={"action": "activate", "duration": "1_month", "daily_limit": 80}, + ) + assert activated.status_code == 200 + assert activated.json()["membership"]["active"] is True + assert activated.json()["membership"]["daily_limit"] == 80 + + admin_membership = await client.get("/api/account/membership") + assert admin_membership.json()["active"] is False + assert admin_membership.json()["smart_access"] is True + + use_session(client, user_session) + user_identity = await client.get("/api/auth/session") + assert user_identity.json()["badges"] == ["member"] + assert user_identity.json()["smart_access"] is True + + use_session(client, admin_session) + permanent = await client.patch( + "/api/admin/memberships/1", + headers=csrf_headers(admin_session), + json={"action": "activate", "duration": "permanent"}, + ) + assert permanent.status_code == 200 + assert permanent.json()["membership"]["is_permanent"] is True + assert permanent.json()["membership"]["expires_at"] is None + assert permanent.json()["membership"]["remaining_days"] is None + identity = await client.get("/api/auth/session") + assert identity.json()["badges"] == ["admin", "member"] + + run_scenario(application, scenario) + + +def test_non_admin_cannot_read_or_write_system_credentials(tmp_path) -> None: + application = create_application(Settings.for_test(tmp_path)) + + async def scenario(client: httpx.AsyncClient) -> None: + _, admin_session = await register(client, "system-admin", ADMIN_PASSWORD) + client.cookies.clear() + _, user_session = await register(client, "system-user", USER_PASSWORD) + + denied_read = await client.get("/api/admin/system/credentials") + assert denied_read.status_code == 403 + denied_write = await client.put( + "/api/admin/system/credentials/tushare_token", + headers=csrf_headers(user_session), + json={"value": "user-must-not-save-this"}, + ) + assert denied_write.status_code == 403 + + use_session(client, admin_session) + saved = await client.put( + "/api/admin/system/credentials/tushare_token", + headers=csrf_headers(admin_session), + json={"value": "real-test-credential-value"}, + ) + assert saved.status_code == 200 + statuses = await client.get("/api/admin/system/credentials") + assert statuses.status_code == 200 + configured = next(item for item in statuses.json() if item["name"] == "tushare_token") + assert configured["configured"] is True + assert "value" not in configured + assert "real-test-credential-value" not in statuses.text + + with sqlite3.connect(application.state.settings.database_path) as connection: + encrypted = connection.execute( + "SELECT encrypted_value FROM system_credentials WHERE name = 'tushare_token'" + ).fetchone()[0] + assert encrypted != "real-test-credential-value" + assert "real-test-credential-value" not in encrypted + + run_scenario(application, scenario) + + +def test_session_cookies_duplicate_username_and_logout(tmp_path) -> None: + application = create_application(Settings.for_test(tmp_path)) + + async def scenario(client: httpx.AsyncClient) -> None: + response, session = await register(client, "CaseUser", USER_PASSWORD) + cookies = response.headers.get_list("set-cookie") + session_cookie = next(value for value in cookies if value.startswith(f"{SESSION_COOKIE}=")) + csrf_cookie = next(value for value in cookies if value.startswith(f"{CSRF_COOKIE}=")) + assert "HttpOnly" in session_cookie + assert "SameSite=lax" in session_cookie + assert "HttpOnly" not in csrf_cookie + assert "SameSite=lax" in csrf_cookie + + client.cookies.clear() + duplicate = await client.post( + "/api/auth/register", + json={"username": "caseuser", "password": USER_PASSWORD}, + ) + assert duplicate.status_code == 409 + assert duplicate.json()["error"]["code"] == "username_taken" + + use_session(client, session) + logged_out = await client.post("/api/auth/logout", headers=csrf_headers(session)) + assert logged_out.status_code == 200 + assert (await client.get("/api/auth/session")).status_code == 401 + + run_scenario(application, scenario) + + +def test_active_membership_renews_from_existing_expiry_and_disable_keeps_it(tmp_path) -> None: + application = create_application(Settings.for_test(tmp_path)) + + async def scenario(client: httpx.AsyncClient) -> None: + _, admin_session = await register(client, "renew-admin", ADMIN_PASSWORD) + client.cookies.clear() + user_response, _ = await register(client, "renew-user", USER_PASSWORD) + user_id = user_response.json()["account"]["id"] + use_session(client, admin_session) + + first = await client.patch( + f"/api/admin/memberships/{user_id}", + headers=csrf_headers(admin_session), + json={"action": "activate", "duration": "1_month"}, + ) + first_expiry = datetime.fromisoformat(first.json()["membership"]["expires_at"]) + extended = await client.patch( + f"/api/admin/memberships/{user_id}", + headers=csrf_headers(admin_session), + json={"action": "activate", "duration": "3_months"}, + ) + extended_expiry = datetime.fromisoformat(extended.json()["membership"]["expires_at"]) + assert extended_expiry == add_months(first_expiry, 3) + + disabled = await client.patch( + f"/api/admin/memberships/{user_id}", + headers=csrf_headers(admin_session), + json={"action": "disable"}, + ) + assert disabled.json()["membership"]["status"] == "disabled" + assert disabled.json()["membership"]["active"] is False + assert ( + datetime.fromisoformat(disabled.json()["membership"]["expires_at"]) == extended_expiry + ) + + run_scenario(application, scenario) + + +def test_smart_access_allows_admin_and_member_but_not_regular_user(tmp_path) -> None: + application = create_application(Settings.for_test(tmp_path)) + + @application.get("/api/test/smart-access") + def smart_access_probe(_principal: SmartAccessPrincipal) -> dict[str, bool]: + return {"allowed": True} + + async def scenario(client: httpx.AsyncClient) -> None: + _, admin_session = await register(client, "smart-admin", ADMIN_PASSWORD) + client.cookies.clear() + member_response, member_session = await register( + client, "smart-member", USER_PASSWORD + ) + member_id = member_response.json()["account"]["id"] + client.cookies.clear() + _, user_session = await register(client, "smart-user", USER_PASSWORD) + + denied = await client.get("/api/test/smart-access") + assert denied.status_code == 403 + assert denied.json()["error"]["code"] == "membership_required" + + use_session(client, admin_session) + assert (await client.get("/api/test/smart-access")).status_code == 200 + activated = await client.patch( + f"/api/admin/memberships/{member_id}", + headers=csrf_headers(admin_session), + json={"action": "activate", "duration": "1_month"}, + ) + assert activated.status_code == 200 + + use_session(client, member_session) + assert (await client.get("/api/test/smart-access")).status_code == 200 + use_session(client, user_session) + assert (await client.get("/api/test/smart-access")).status_code == 403 + + run_scenario(application, scenario) diff --git a/next/tests/test_cipher.py b/next/tests/test_cipher.py new file mode 100644 index 0000000..1c58eb1 --- /dev/null +++ b/next/tests/test_cipher.py @@ -0,0 +1,43 @@ +import pytest +from cryptography.fernet import Fernet + +from backend.bootstrap.settings import ConfigurationError, Settings +from backend.security import SecretCipher, load_or_create_cipher + + +def test_development_cipher_persists_in_ignored_data_directory(tmp_path) -> None: + settings = Settings.for_test(tmp_path) + first = load_or_create_cipher(settings) + encrypted = first.encrypt("private profile") + + second = load_or_create_cipher(settings) + + assert second.decrypt(encrypted) == "private profile" + assert (tmp_path / ".encryption.key").exists() + assert "private profile" not in encrypted + + +def test_explicit_encryption_key_is_reusable(tmp_path) -> None: + key = Fernet.generate_key().decode("ascii") + settings = Settings.for_test(tmp_path) + configured = Settings( + environment=settings.environment, + debug=settings.debug, + data_directory=settings.data_directory, + database_path=settings.database_path, + log_file=settings.log_file, + log_level=settings.log_level, + host=settings.host, + port=settings.port, + encryption_key=key, + ) + + first = load_or_create_cipher(configured).encrypt("secret") + second = SecretCipher.from_key(key).decrypt(first) + + assert second == "secret" + + +def test_invalid_encryption_key_is_rejected() -> None: + with pytest.raises(ConfigurationError): + SecretCipher.from_key("invalid") diff --git a/next/tests/test_health.py b/next/tests/test_health.py index 1e7bb52..f09e088 100644 --- a/next/tests/test_health.py +++ b/next/tests/test_health.py @@ -1,23 +1,9 @@ -import asyncio - -import httpx from fastapi import Query from backend.bootstrap.application import create_application from backend.bootstrap.settings import Settings from backend.http.errors import AppError - - -def request(application, path: str) -> httpx.Response: - async def run() -> httpx.Response: - transport = httpx.ASGITransport(app=application, raise_app_exceptions=False) - async with application.router.lifespan_context(application): - async with httpx.AsyncClient( - transport=transport, base_url="http://testserver" - ) as client: - return await client.get(path) - - return asyncio.run(run()) +from tests.support import request def test_health_reports_runtime_environment(tmp_path) -> None: diff --git a/next/tests/test_migrations.py b/next/tests/test_migrations.py index 6536243..5bc57e8 100644 --- a/next/tests/test_migrations.py +++ b/next/tests/test_migrations.py @@ -4,7 +4,7 @@ import sqlite3 import pytest -from backend.database import Database, Migration, MigrationError, MigrationRunner +from backend.database import MIGRATIONS, Database, Migration, MigrationError, MigrationRunner from backend.database.repositories import DatabaseStatusRepository @@ -104,3 +104,24 @@ def test_non_contiguous_database_history_is_rejected(tmp_path) -> None: with pytest.raises(MigrationError, match="not contiguous"): runner.upgrade((first, second)) + + +def test_real_account_schema_can_upgrade_and_rollback(tmp_path) -> None: + database = Database(tmp_path / "app.db") + runner = MigrationRunner(database) + + assert runner.upgrade(MIGRATIONS) == (1, 2) + assert { + "users", + "memberships", + "sessions", + "birth_profiles", + "llm_usage_daily", + "system_credentials", + "llm_models", + "llm_configuration", + } <= table_names(database) + + assert runner.downgrade(MIGRATIONS, target_version=0) == (2, 1) + assert "users" not in table_names(database) + assert "llm_models" not in table_names(database) diff --git a/next/tests/test_model_pool.py b/next/tests/test_model_pool.py new file mode 100644 index 0000000..e8277ac --- /dev/null +++ b/next/tests/test_model_pool.py @@ -0,0 +1,159 @@ +from __future__ import annotations + +import sqlite3 + +import httpx + +from backend.bootstrap.application import create_application +from backend.bootstrap.settings import Settings +from tests.support import run_scenario +from tests.test_accounts import ( + ADMIN_PASSWORD, + USER_PASSWORD, + csrf_headers, + register, + use_session, +) + + +def model_payload(index: int, api_key: str | None = None) -> dict[str, str]: + return { + "display_name": f"模型 {index}", + "base_url": f"https://model-{index}.example.com/v1", + "model_identifier": f"model-{index}", + "api_key": api_key or f"secret-key-{index}", + } + + +def test_model_pool_is_admin_only_and_never_exposes_keys(tmp_path) -> None: + application = create_application(Settings.for_test(tmp_path)) + + async def scenario(client: httpx.AsyncClient) -> None: + _, admin_session = await register(client, "model-admin", ADMIN_PASSWORD) + client.cookies.clear() + _, user_session = await register(client, "model-user", USER_PASSWORD) + + denied_read = await client.get("/api/admin/models") + assert denied_read.status_code == 403 + denied_write = await client.post( + "/api/admin/models", + headers=csrf_headers(user_session), + json=model_payload(1), + ) + assert denied_write.status_code == 403 + + use_session(client, admin_session) + created = await client.post( + "/api/admin/models", + headers=csrf_headers(admin_session), + json=model_payload(1, "private-alpha-key"), + ) + assert created.status_code == 201 + assert created.json()["is_primary"] is True + assert created.json()["is_fallback"] is False + assert created.json()["has_api_key"] is True + assert "api_key" not in created.json() + assert "private-alpha-key" not in created.text + + with sqlite3.connect(application.state.settings.database_path) as connection: + encrypted_before = connection.execute( + "SELECT encrypted_api_key FROM llm_models WHERE id = 1" + ).fetchone()[0] + assert encrypted_before != "private-alpha-key" + assert "private-alpha-key" not in encrypted_before + + updated_payload = model_payload(1) + updated_payload.pop("api_key") + updated_payload["display_name"] = "主模型" + updated = await client.put( + "/api/admin/models/1", + headers=csrf_headers(admin_session), + json=updated_payload, + ) + assert updated.status_code == 200 + assert updated.json()["display_name"] == "主模型" + + with sqlite3.connect(application.state.settings.database_path) as connection: + encrypted_after = connection.execute( + "SELECT encrypted_api_key FROM llm_models WHERE id = 1" + ).fetchone()[0] + assert encrypted_after == encrypted_before + + run_scenario(application, scenario) + + +def test_model_selection_deletion_guards_and_runtime_config(tmp_path) -> None: + application = create_application(Settings.for_test(tmp_path)) + + async def scenario(client: httpx.AsyncClient) -> None: + _, admin_session = await register(client, "selection-admin", ADMIN_PASSWORD) + for index in (1, 2, 3): + response = await client.post( + "/api/admin/models", + headers=csrf_headers(admin_session), + json=model_payload(index), + ) + assert response.status_code == 201 + + duplicate_roles = await client.put( + "/api/admin/models/selection", + headers=csrf_headers(admin_session), + json={"primary_model_id": 2, "fallback_model_id": 2}, + ) + assert duplicate_roles.status_code == 400 + assert duplicate_roles.json()["error"]["code"] == "duplicate_model_role" + + selected = await client.put( + "/api/admin/models/selection", + headers=csrf_headers(admin_session), + json={"primary_model_id": 2, "fallback_model_id": 1}, + ) + assert selected.status_code == 200 + models = (await client.get("/api/admin/models")).json() + assert next(item for item in models if item["id"] == 2)["is_primary"] is True + assert next(item for item in models if item["id"] == 1)["is_fallback"] is True + + selected_delete = await client.delete( + "/api/admin/models/1", headers=csrf_headers(admin_session) + ) + assert selected_delete.status_code == 409 + assert selected_delete.json()["error"]["code"] == "model_in_use" + unselected_delete = await client.delete( + "/api/admin/models/3", headers=csrf_headers(admin_session) + ) + assert unselected_delete.status_code == 200 + + runtime = application.state.container.model_pool.runtime_config() + assert runtime.primary.id == 2 + assert runtime.fallback is not None + assert runtime.fallback.id == 1 + assert ( + application.state.container.model_pool.decrypt_api_key(runtime.primary) + == "secret-key-2" + ) + + run_scenario(application, scenario) + + +def test_model_pool_rejects_twenty_first_model(tmp_path) -> None: + application = create_application(Settings.for_test(tmp_path)) + + async def scenario(client: httpx.AsyncClient) -> None: + _, admin_session = await register(client, "capacity-admin", ADMIN_PASSWORD) + for index in range(1, 21): + response = await client.post( + "/api/admin/models", + headers=csrf_headers(admin_session), + json=model_payload(index), + ) + assert response.status_code == 201 + + rejected = await client.post( + "/api/admin/models", + headers=csrf_headers(admin_session), + json=model_payload(21), + ) + assert rejected.status_code == 409 + assert rejected.json()["error"]["code"] == "model_pool_full" + + run_scenario(application, scenario) diff --git a/next/tests/test_passwords.py b/next/tests/test_passwords.py new file mode 100644 index 0000000..0873f50 --- /dev/null +++ b/next/tests/test_passwords.py @@ -0,0 +1,20 @@ +import pytest + +from backend.security import PasswordHasher, PasswordPolicyError + + +@pytest.mark.parametrize("password", ["onlyletters", "12345678", "short1!"]) +def test_password_policy_rejects_weak_values(password: str) -> None: + with pytest.raises(PasswordPolicyError): + PasswordHasher().hash(password) + + +def test_password_hash_uses_independent_salts_and_verifies() -> None: + hasher = PasswordHasher() + first = hasher.hash("Valid-password-123!") + second = hasher.hash("Valid-password-123!") + + assert first != second + assert "Valid-password-123!" not in first + assert hasher.verify("Valid-password-123!", first) + assert not hasher.verify("Wrong-password-123!", first) diff --git a/next/tests/test_settings.py b/next/tests/test_settings.py index 9f98d08..d89b281 100644 --- a/next/tests/test_settings.py +++ b/next/tests/test_settings.py @@ -32,3 +32,11 @@ def test_test_settings_do_not_create_log_files(tmp_path: Path) -> None: assert settings.log_file is None assert settings.database_path.parent == tmp_path + + +def test_production_requires_encryption_key(monkeypatch) -> None: + monkeypatch.setenv("APP_ENV", "production") + monkeypatch.delenv("APP_ENCRYPTION_KEY", raising=False) + + with pytest.raises(ConfigurationError, match="APP_ENCRYPTION_KEY"): + Settings.from_environment()