rebuild(stage-3): establish accounts permissions and secure settings
This commit is contained in:
@@ -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密钥;生产环境必填。开发环境首次启动时只在数据目录生成本地密钥文件。
|
||||
|
||||
数据库维护:
|
||||
|
||||
|
||||
@@ -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),
|
||||
)
|
||||
|
||||
@@ -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,
|
||||
)
|
||||
|
||||
@@ -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,
|
||||
)
|
||||
@@ -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,
|
||||
)
|
||||
@@ -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)
|
||||
|
||||
@@ -0,0 +1,7 @@
|
||||
from dataclasses import dataclass
|
||||
|
||||
|
||||
@dataclass(slots=True)
|
||||
class BusinessError(Exception):
|
||||
code: str
|
||||
message: str
|
||||
@@ -0,0 +1,3 @@
|
||||
from backend.features.accounts.service import AccountService, MembershipService
|
||||
|
||||
__all__ = ["AccountService", "MembershipService"]
|
||||
@@ -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)]
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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="模型已删除。")
|
||||
@@ -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)
|
||||
@@ -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
|
||||
@@ -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)
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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"]
|
||||
@@ -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)
|
||||
@@ -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
|
||||
@@ -1,3 +1,4 @@
|
||||
cryptography==49.0.0
|
||||
fastapi==0.141.0
|
||||
pydantic==2.13.4
|
||||
tzdata==2026.3
|
||||
|
||||
@@ -0,0 +1 @@
|
||||
"""Tests for the rebuilt application."""
|
||||
@@ -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)
|
||||
@@ -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)
|
||||
@@ -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")
|
||||
@@ -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:
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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)
|
||||
@@ -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)
|
||||
@@ -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()
|
||||
|
||||
Reference in New Issue
Block a user