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