rebuild(stage-3): establish accounts permissions and secure settings
This commit is contained in:
@@ -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
|
||||
Reference in New Issue
Block a user