387 lines
15 KiB
Python
387 lines
15 KiB
Python
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.llm.repository import LLMRepository
|
|
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, usage: LLMRepository
|
|
) -> None:
|
|
self._database = database
|
|
self._repository = repository
|
|
self._usage = usage
|
|
|
|
def view_for(self, principal: Principal) -> MembershipView:
|
|
today = datetime.now(SHANGHAI).date().isoformat()
|
|
with self._database.read() as connection:
|
|
used_today = self._usage.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._usage.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._usage.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
|