rebuild(stage-3): establish accounts permissions and secure settings

This commit is contained in:
leefer
2026-07-30 01:33:19 +08:00
parent 2ff35eb6df
commit f69972c3c0
31 changed files with 2742 additions and 18 deletions
@@ -0,0 +1,3 @@
from backend.features.accounts.service import AccountService, MembershipService
__all__ = ["AccountService", "MembershipService"]
+67
View File
@@ -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
+115
View File
@@ -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
+314
View File
@@ -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="模型已删除。")
+121
View File
@@ -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)
+382
View File
@@ -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