migration: preserve startup accounts and system slice
This commit is contained in:
@@ -0,0 +1,24 @@
|
||||
__all__ = [
|
||||
"AccountHttpMixin",
|
||||
"AccountService",
|
||||
"SecretVault",
|
||||
"hash_password",
|
||||
"token_hash",
|
||||
"verify_password",
|
||||
]
|
||||
|
||||
|
||||
def __getattr__(name: str):
|
||||
if name == "AccountHttpMixin":
|
||||
from .http import AccountHttpMixin
|
||||
|
||||
return AccountHttpMixin
|
||||
if name == "AccountService":
|
||||
from .service import AccountService
|
||||
|
||||
return AccountService
|
||||
if name in {"SecretVault", "hash_password", "token_hash", "verify_password"}:
|
||||
from . import security
|
||||
|
||||
return getattr(security, name)
|
||||
raise AttributeError(name)
|
||||
@@ -0,0 +1,110 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
from http import HTTPStatus
|
||||
|
||||
|
||||
class AccountHttpMixin:
|
||||
def auth_register(self) -> None:
|
||||
try:
|
||||
body = self.read_json_body()
|
||||
result = self.application_service.register_account(
|
||||
str(body.get("username") or ""),
|
||||
str(body.get("password") or ""),
|
||||
)
|
||||
self.send_json(
|
||||
{
|
||||
"ok": True,
|
||||
"authenticated": True,
|
||||
"user": result["user"],
|
||||
"csrf_token": result["csrf_token"],
|
||||
},
|
||||
HTTPStatus.CREATED,
|
||||
{"Set-Cookie": self.session_cookie(result["session_token"])},
|
||||
)
|
||||
except (ValueError, json.JSONDecodeError) as exc:
|
||||
self.send_json({"error": str(exc)}, HTTPStatus.BAD_REQUEST)
|
||||
|
||||
def auth_login(self) -> None:
|
||||
try:
|
||||
body = self.read_json_body()
|
||||
result = self.application_service.login_account(
|
||||
str(body.get("username") or ""),
|
||||
str(body.get("password") or ""),
|
||||
)
|
||||
self.send_json(
|
||||
{
|
||||
"ok": True,
|
||||
"authenticated": True,
|
||||
"user": result["user"],
|
||||
"csrf_token": result["csrf_token"],
|
||||
},
|
||||
headers={"Set-Cookie": self.session_cookie(result["session_token"])},
|
||||
)
|
||||
except (ValueError, json.JSONDecodeError) as exc:
|
||||
self.send_json({"error": str(exc)}, HTTPStatus.UNAUTHORIZED)
|
||||
|
||||
def auth_me(self) -> None:
|
||||
service = self.application_service
|
||||
if not self.require_auth(send_error=False):
|
||||
self.send_json(
|
||||
{
|
||||
"ok": True,
|
||||
"authenticated": False,
|
||||
"registration_required": service.database.count_users() == 0,
|
||||
}
|
||||
)
|
||||
return
|
||||
self.send_json(
|
||||
{
|
||||
"ok": True,
|
||||
"authenticated": True,
|
||||
"user": {
|
||||
"id": int(self.auth_user["id"]),
|
||||
"username": str(self.auth_user["username"]),
|
||||
"role": str(self.auth_user.get("role") or "user"),
|
||||
"membership": service.membership(),
|
||||
},
|
||||
"csrf_token": str(self.auth_user["csrf_token"]),
|
||||
}
|
||||
)
|
||||
|
||||
def auth_logout(self) -> None:
|
||||
raw_token = self.session_token()
|
||||
if raw_token:
|
||||
from backend.features.accounts.security import token_hash
|
||||
|
||||
self.application_service.database.delete_session(token_hash(raw_token))
|
||||
self.send_json(
|
||||
{"ok": True},
|
||||
headers={"Set-Cookie": self.session_cookie("", clear=True)},
|
||||
)
|
||||
|
||||
def save_birth_profile(self) -> None:
|
||||
try:
|
||||
body = self.read_json_body()
|
||||
personal = self.application_service.save_birth_profile(body)
|
||||
self.send_json({"ok": True, "personal": personal})
|
||||
except (ValueError, json.JSONDecodeError) as exc:
|
||||
self.send_json({"error": str(exc)}, HTTPStatus.BAD_REQUEST)
|
||||
|
||||
def change_password(self) -> None:
|
||||
try:
|
||||
body = self.read_json_body()
|
||||
current = str(body.get("current_password") or "")
|
||||
new = str(body.get("new_password") or "")
|
||||
confirmation = str(body.get("confirm_password") or "")
|
||||
if new != confirmation:
|
||||
raise ValueError("两次输入的新密码不一致。")
|
||||
self.application_service.change_password(current, new)
|
||||
self.send_json({"ok": True})
|
||||
except (ValueError, json.JSONDecodeError) as exc:
|
||||
self.send_json({"error": str(exc)}, HTTPStatus.BAD_REQUEST)
|
||||
|
||||
def save_membership(self) -> None:
|
||||
try:
|
||||
service = self.application_service
|
||||
service.update_membership(self.read_json_body())
|
||||
self.send_json({"ok": True, "users": service.admin_users()})
|
||||
except (ValueError, json.JSONDecodeError) as exc:
|
||||
self.send_json({"error": str(exc)}, HTTPStatus.BAD_REQUEST)
|
||||
@@ -0,0 +1,236 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import sqlite3
|
||||
from datetime import datetime, timezone
|
||||
from typing import Any
|
||||
|
||||
|
||||
class AccountRepositoryMixin:
|
||||
"""Original SQLite account persistence methods, moved without query changes."""
|
||||
|
||||
def count_users(self) -> int:
|
||||
with self.connect() as connection:
|
||||
row = connection.execute("SELECT COUNT(*) AS total FROM users").fetchone()
|
||||
return int(row["total"] if row else 0)
|
||||
|
||||
def first_user_id(self) -> int:
|
||||
with self.connect() as connection:
|
||||
row = connection.execute("SELECT MIN(id) AS id FROM users").fetchone()
|
||||
return int(row["id"] or 0) if row else 0
|
||||
|
||||
def create_user(
|
||||
self,
|
||||
username: str,
|
||||
password_salt: str,
|
||||
password_hash: str,
|
||||
) -> dict[str, Any]:
|
||||
now = datetime.now(timezone.utc).isoformat(timespec="seconds")
|
||||
try:
|
||||
with self.connect() as connection:
|
||||
role = "admin" if int(connection.execute("SELECT COUNT(*) FROM users").fetchone()[0]) == 0 else "user"
|
||||
cursor = connection.execute(
|
||||
"""
|
||||
INSERT INTO users
|
||||
(username, password_salt, password_hash, role, created_at, updated_at)
|
||||
VALUES (?, ?, ?, ?, ?, ?)
|
||||
""",
|
||||
(username, password_salt, password_hash, role, now, now),
|
||||
)
|
||||
user_id = int(cursor.lastrowid)
|
||||
except sqlite3.IntegrityError as exc:
|
||||
raise ValueError("该账号名已被使用。") from exc
|
||||
return {"id": user_id, "username": username, "role": role, "created_at": now}
|
||||
|
||||
def user_by_username(self, username: str) -> dict[str, Any] | None:
|
||||
with self.connect() as connection:
|
||||
row = connection.execute(
|
||||
"""
|
||||
SELECT id, username, password_salt, password_hash, role, llm_mode,
|
||||
membership_status, membership_plan, membership_starts_at,
|
||||
membership_expires_at, created_at
|
||||
FROM users WHERE username = ? COLLATE NOCASE
|
||||
""",
|
||||
(username,),
|
||||
).fetchone()
|
||||
return dict(row) if row else None
|
||||
|
||||
def user_password(self, user_id: int) -> dict[str, str] | None:
|
||||
with self.connect() as connection:
|
||||
row = connection.execute(
|
||||
"SELECT password_salt, password_hash FROM users WHERE id = ?",
|
||||
(user_id,),
|
||||
).fetchone()
|
||||
return dict(row) if row else None
|
||||
|
||||
def update_user_password(self, user_id: int, password_salt: str, password_hash: str) -> bool:
|
||||
now = datetime.now(timezone.utc).isoformat(timespec="seconds")
|
||||
with self.connect() as connection:
|
||||
cursor = connection.execute(
|
||||
"UPDATE users SET password_salt = ?, password_hash = ?, updated_at = ? WHERE id = ?",
|
||||
(password_salt, password_hash, now, user_id),
|
||||
)
|
||||
return cursor.rowcount > 0
|
||||
|
||||
def delete_user(self, user_id: int) -> bool:
|
||||
with self.connect() as connection:
|
||||
cursor = connection.execute("DELETE FROM users WHERE id = ?", (user_id,))
|
||||
return cursor.rowcount > 0
|
||||
|
||||
def create_session(
|
||||
self,
|
||||
session_hash: str,
|
||||
user_id: int,
|
||||
csrf_token: str,
|
||||
expires_at: str,
|
||||
) -> None:
|
||||
now = datetime.now(timezone.utc).isoformat(timespec="seconds")
|
||||
with self.connect() as connection:
|
||||
connection.execute("DELETE FROM user_sessions WHERE expires_at <= ?", (now,))
|
||||
connection.execute(
|
||||
"""
|
||||
INSERT INTO user_sessions
|
||||
(token_hash, user_id, csrf_token, expires_at, created_at, last_seen_at)
|
||||
VALUES (?, ?, ?, ?, ?, ?)
|
||||
""",
|
||||
(session_hash, user_id, csrf_token, expires_at, now, now),
|
||||
)
|
||||
|
||||
def session_user(self, session_hash: str) -> dict[str, Any] | None:
|
||||
now = datetime.now(timezone.utc).isoformat(timespec="seconds")
|
||||
with self.connect() as connection:
|
||||
row = connection.execute(
|
||||
"""
|
||||
SELECT u.id, u.username, u.role, u.llm_mode, u.membership_status,
|
||||
u.membership_plan, u.membership_starts_at, u.membership_expires_at,
|
||||
u.created_at, s.csrf_token, s.expires_at
|
||||
FROM user_sessions AS s
|
||||
JOIN users AS u ON u.id = s.user_id
|
||||
WHERE s.token_hash = ? AND s.expires_at > ?
|
||||
""",
|
||||
(session_hash, now),
|
||||
).fetchone()
|
||||
if row:
|
||||
connection.execute(
|
||||
"UPDATE user_sessions SET last_seen_at = ? WHERE token_hash = ?",
|
||||
(now, session_hash),
|
||||
)
|
||||
return dict(row) if row else None
|
||||
|
||||
def delete_session(self, session_hash: str) -> bool:
|
||||
with self.connect() as connection:
|
||||
cursor = connection.execute(
|
||||
"DELETE FROM user_sessions WHERE token_hash = ?",
|
||||
(session_hash,),
|
||||
)
|
||||
return cursor.rowcount > 0
|
||||
|
||||
def get_user_credentials(self, user_id: int) -> str:
|
||||
with self.connect() as connection:
|
||||
row = connection.execute(
|
||||
"SELECT encrypted_payload FROM user_credentials WHERE user_id = ?",
|
||||
(user_id,),
|
||||
).fetchone()
|
||||
return str(row["encrypted_payload"]) if row else ""
|
||||
|
||||
def save_user_credentials(self, user_id: int, encrypted_payload: str) -> None:
|
||||
now = datetime.now(timezone.utc).isoformat(timespec="seconds")
|
||||
with self.connect() as connection:
|
||||
connection.execute(
|
||||
"""
|
||||
INSERT INTO user_credentials (user_id, encrypted_payload, updated_at)
|
||||
VALUES (?, ?, ?)
|
||||
ON CONFLICT(user_id) DO UPDATE SET
|
||||
encrypted_payload = excluded.encrypted_payload,
|
||||
updated_at = excluded.updated_at
|
||||
""",
|
||||
(user_id, encrypted_payload, now),
|
||||
)
|
||||
|
||||
def list_user_credentials(self) -> list[dict[str, Any]]:
|
||||
with self.connect() as connection:
|
||||
rows = connection.execute(
|
||||
"SELECT user_id, encrypted_payload FROM user_credentials ORDER BY user_id"
|
||||
).fetchall()
|
||||
return [dict(row) for row in rows]
|
||||
|
||||
def user_access(self, user_id: int) -> dict[str, Any] | None:
|
||||
with self.connect() as connection:
|
||||
row = connection.execute(
|
||||
"""
|
||||
SELECT id, username, role, llm_mode, membership_status, membership_plan,
|
||||
membership_starts_at, membership_expires_at, created_at
|
||||
FROM users WHERE id = ?
|
||||
""",
|
||||
(user_id,),
|
||||
).fetchone()
|
||||
return dict(row) if row else None
|
||||
|
||||
def list_users(self) -> list[dict[str, Any]]:
|
||||
with self.connect() as connection:
|
||||
rows = connection.execute(
|
||||
"""
|
||||
SELECT id, username, role, llm_mode, membership_status, membership_plan,
|
||||
membership_starts_at, membership_expires_at, created_at
|
||||
FROM users ORDER BY id
|
||||
"""
|
||||
).fetchall()
|
||||
return [dict(row) for row in rows]
|
||||
|
||||
def update_user_llm_mode(self, user_id: int, mode: str) -> None:
|
||||
now = datetime.now(timezone.utc).isoformat(timespec="seconds")
|
||||
with self.connect() as connection:
|
||||
connection.execute(
|
||||
"UPDATE users SET llm_mode = ?, updated_at = ? WHERE id = ?",
|
||||
(mode, now, user_id),
|
||||
)
|
||||
|
||||
def update_membership(
|
||||
self,
|
||||
user_id: int,
|
||||
status: str,
|
||||
plan: str,
|
||||
starts_at: str | None,
|
||||
expires_at: str | None,
|
||||
) -> bool:
|
||||
now = datetime.now(timezone.utc).isoformat(timespec="seconds")
|
||||
with self.connect() as connection:
|
||||
cursor = connection.execute(
|
||||
"""
|
||||
UPDATE users
|
||||
SET membership_status = ?, membership_plan = ?,
|
||||
membership_starts_at = ?, membership_expires_at = ?, updated_at = ?
|
||||
WHERE id = ?
|
||||
""",
|
||||
(status, plan, starts_at, expires_at, now, user_id),
|
||||
)
|
||||
return cursor.rowcount > 0
|
||||
|
||||
def get_user_birth_profile(self, user_id: int) -> str:
|
||||
with self.connect() as connection:
|
||||
row = connection.execute(
|
||||
"SELECT encrypted_payload FROM user_birth_profiles WHERE user_id = ?",
|
||||
(user_id,),
|
||||
).fetchone()
|
||||
return str(row["encrypted_payload"]) if row else ""
|
||||
|
||||
def save_user_birth_profile(self, user_id: int, encrypted_payload: str) -> None:
|
||||
now = datetime.now(timezone.utc).isoformat(timespec="seconds")
|
||||
with self.connect() as connection:
|
||||
connection.execute(
|
||||
"""
|
||||
INSERT INTO user_birth_profiles (user_id, encrypted_payload, updated_at)
|
||||
VALUES (?, ?, ?)
|
||||
ON CONFLICT(user_id) DO UPDATE SET
|
||||
encrypted_payload = excluded.encrypted_payload,
|
||||
updated_at = excluded.updated_at
|
||||
""",
|
||||
(user_id, encrypted_payload, now),
|
||||
)
|
||||
|
||||
def delete_user_birth_profile(self, user_id: int) -> bool:
|
||||
with self.connect() as connection:
|
||||
cursor = connection.execute(
|
||||
"DELETE FROM user_birth_profiles WHERE user_id = ?",
|
||||
(user_id,),
|
||||
)
|
||||
return cursor.rowcount > 0
|
||||
@@ -0,0 +1,71 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import base64
|
||||
import hashlib
|
||||
import hmac
|
||||
import json
|
||||
import os
|
||||
from typing import Any
|
||||
|
||||
from cryptography.fernet import Fernet, InvalidToken
|
||||
|
||||
|
||||
PASSWORD_SCRYPT_N = 2**14
|
||||
PASSWORD_SCRYPT_R = 8
|
||||
PASSWORD_SCRYPT_P = 1
|
||||
|
||||
|
||||
class SecretVault:
|
||||
def __init__(self, key: str) -> None:
|
||||
try:
|
||||
self._fernet = Fernet(key.encode("ascii"))
|
||||
except (ValueError, TypeError) as exc:
|
||||
raise ValueError("APP_ENCRYPTION_KEY 格式无效。") from exc
|
||||
|
||||
@staticmethod
|
||||
def generate_key() -> str:
|
||||
return Fernet.generate_key().decode("ascii")
|
||||
|
||||
def encrypt_json(self, payload: dict[str, Any]) -> str:
|
||||
raw = json.dumps(payload, ensure_ascii=False, separators=(",", ":")).encode("utf-8")
|
||||
return self._fernet.encrypt(raw).decode("ascii")
|
||||
|
||||
def decrypt_json(self, token: str) -> dict[str, Any]:
|
||||
if not token:
|
||||
return {}
|
||||
try:
|
||||
payload = json.loads(self._fernet.decrypt(token.encode("ascii")).decode("utf-8"))
|
||||
except (InvalidToken, UnicodeDecodeError, json.JSONDecodeError) as exc:
|
||||
raise ValueError("账号加密数据无法解密,请检查 APP_ENCRYPTION_KEY。") from exc
|
||||
if not isinstance(payload, dict):
|
||||
raise ValueError("账号加密数据格式无效。")
|
||||
return payload
|
||||
|
||||
|
||||
def hash_password(password: str, salt: bytes | None = None) -> tuple[str, str]:
|
||||
raw_salt = salt or os.urandom(16)
|
||||
digest = hashlib.scrypt(
|
||||
password.encode("utf-8"),
|
||||
salt=raw_salt,
|
||||
n=PASSWORD_SCRYPT_N,
|
||||
r=PASSWORD_SCRYPT_R,
|
||||
p=PASSWORD_SCRYPT_P,
|
||||
dklen=32,
|
||||
)
|
||||
return (
|
||||
base64.urlsafe_b64encode(raw_salt).decode("ascii"),
|
||||
base64.urlsafe_b64encode(digest).decode("ascii"),
|
||||
)
|
||||
|
||||
|
||||
def verify_password(password: str, salt_text: str, expected_hash: str) -> bool:
|
||||
try:
|
||||
salt = base64.urlsafe_b64decode(salt_text.encode("ascii"))
|
||||
_, actual_hash = hash_password(password, salt)
|
||||
except (ValueError, TypeError):
|
||||
return False
|
||||
return hmac.compare_digest(actual_hash, expected_hash)
|
||||
|
||||
|
||||
def token_hash(token: str) -> str:
|
||||
return hashlib.sha256(token.encode("utf-8")).hexdigest()
|
||||
@@ -0,0 +1,256 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import secrets
|
||||
import threading
|
||||
from collections.abc import Callable
|
||||
from datetime import date, datetime, timedelta, timezone
|
||||
from typing import Any
|
||||
|
||||
from backend.bootstrap.config import (
|
||||
SESSION_MAX_AGE,
|
||||
USERNAME_PATTERN,
|
||||
add_months,
|
||||
normalize_date,
|
||||
parse_iso_datetime,
|
||||
)
|
||||
from backend.features.accounts.security import (
|
||||
SecretVault,
|
||||
hash_password,
|
||||
token_hash,
|
||||
verify_password,
|
||||
)
|
||||
|
||||
|
||||
class AccountService:
|
||||
"""Preserved account, session, membership and birth-profile behavior."""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
database: Any,
|
||||
vault: SecretVault,
|
||||
current_user_supplier: Callable[[], int],
|
||||
access_supplier: Callable[[], dict[str, Any]],
|
||||
bind_user: Callable[[int], None],
|
||||
personal_field_builder: Callable[..., dict[str, Any]],
|
||||
auth_lock: threading.Lock,
|
||||
) -> None:
|
||||
self.database = database
|
||||
self.vault = vault
|
||||
self.current_user_supplier = current_user_supplier
|
||||
self.access_supplier = access_supplier
|
||||
self.bind_user = bind_user
|
||||
self.personal_field_builder = personal_field_builder
|
||||
self.auth_lock = auth_lock
|
||||
|
||||
@property
|
||||
def current_user_id(self) -> int:
|
||||
return int(self.current_user_supplier())
|
||||
|
||||
@staticmethod
|
||||
def membership_for_access(access: dict[str, Any]) -> dict[str, Any]:
|
||||
now = datetime.now(timezone.utc)
|
||||
starts = parse_iso_datetime(access.get("membership_starts_at"))
|
||||
expires = parse_iso_datetime(access.get("membership_expires_at"))
|
||||
subscribed = (
|
||||
access.get("membership_status") == "active"
|
||||
and (not starts or starts <= now)
|
||||
and (not expires or expires > now)
|
||||
)
|
||||
is_admin = str(access.get("role")) == "admin"
|
||||
active = is_admin or subscribed
|
||||
remaining_seconds = None
|
||||
if expires:
|
||||
remaining_seconds = max(0, int((expires - now).total_seconds()))
|
||||
return {
|
||||
"active": active,
|
||||
"subscribed": subscribed,
|
||||
"status": "active" if subscribed else str(access.get("membership_status") or "inactive"),
|
||||
"plan": str(access.get("membership_plan") or ""),
|
||||
"starts_at": str(access.get("membership_starts_at") or ""),
|
||||
"expires_at": str(access.get("membership_expires_at") or ""),
|
||||
"is_admin": is_admin,
|
||||
"remaining_seconds": remaining_seconds,
|
||||
"remaining_days": None if remaining_seconds is None else (remaining_seconds + 86399) // 86400,
|
||||
}
|
||||
|
||||
def membership(self) -> dict[str, Any]:
|
||||
access = self.access_supplier() or self.database.user_access(self.current_user_id) or {}
|
||||
return self.membership_for_access(access)
|
||||
|
||||
def register(self, username: str, password: str) -> dict[str, Any]:
|
||||
username = username.strip()
|
||||
self.validate_input(username, password)
|
||||
with self.auth_lock:
|
||||
salt, password_digest = hash_password(password)
|
||||
user = self.database.create_user(username, salt, password_digest)
|
||||
return self.create_session(user)
|
||||
|
||||
def login(self, username: str, password: str) -> dict[str, Any]:
|
||||
username = username.strip()
|
||||
if not username or not password:
|
||||
raise ValueError("账号名和密码不能为空。")
|
||||
user = self.database.user_by_username(username)
|
||||
if not user or not verify_password(
|
||||
password,
|
||||
str(user.get("password_salt") or ""),
|
||||
str(user.get("password_hash") or ""),
|
||||
):
|
||||
raise ValueError("账号名或密码不正确。")
|
||||
return self.create_session(user)
|
||||
|
||||
def change_password(self, current_password: str, new_password: str) -> None:
|
||||
current_password = str(current_password or "")
|
||||
access = self.database.user_access(self.current_user_id)
|
||||
self.validate_input(str(access["username"]), new_password)
|
||||
credentials = self.database.user_password(self.current_user_id)
|
||||
if not credentials or not verify_password(
|
||||
current_password,
|
||||
str(credentials.get("password_salt") or ""),
|
||||
str(credentials.get("password_hash") or ""),
|
||||
):
|
||||
raise ValueError("当前密码不正确。")
|
||||
salt, digest = hash_password(new_password)
|
||||
if not self.database.update_user_password(self.current_user_id, salt, digest):
|
||||
raise ValueError("账号不存在。")
|
||||
|
||||
def create_session(self, user: dict[str, Any]) -> dict[str, Any]:
|
||||
session_token = secrets.token_urlsafe(32)
|
||||
csrf_token = secrets.token_urlsafe(24)
|
||||
expires = datetime.now(timezone.utc) + timedelta(seconds=SESSION_MAX_AGE)
|
||||
self.database.create_session(
|
||||
token_hash(session_token),
|
||||
int(user["id"]),
|
||||
csrf_token,
|
||||
expires.isoformat(timespec="seconds"),
|
||||
)
|
||||
self.bind_user(int(user["id"]))
|
||||
access = self.database.user_access(int(user["id"])) or {}
|
||||
return {
|
||||
"user": {
|
||||
"id": int(user["id"]),
|
||||
"username": str(user["username"]),
|
||||
"role": str(access.get("role") or "user"),
|
||||
"membership": self.membership(),
|
||||
},
|
||||
"session_token": session_token,
|
||||
"csrf_token": csrf_token,
|
||||
}
|
||||
|
||||
@staticmethod
|
||||
def validate_input(username: str, password: str) -> None:
|
||||
if not USERNAME_PATTERN.fullmatch(username):
|
||||
raise ValueError("账号名应为 3 至 30 位中文、字母、数字、下划线或连字符。")
|
||||
if len(password) < 8 or len(password) > 128:
|
||||
raise ValueError("密码长度应为 8 至 128 位。")
|
||||
if password.isalpha() or password.isdigit():
|
||||
raise ValueError("密码应同时包含字母、数字或符号中的至少两类。")
|
||||
|
||||
def save_birth_profile(self, payload: dict[str, Any]) -> dict[str, Any]:
|
||||
birth_datetime = str(payload.get("birth_datetime") or "").strip()
|
||||
gender = str(payload.get("gender") or "unspecified").strip()
|
||||
current_date = normalize_date(str(payload.get("trade_date") or date.today().isoformat()))
|
||||
personal = self.personal_field_builder(birth_datetime, gender, current_date)
|
||||
encrypted = self.vault.encrypt_json(
|
||||
{"birth_datetime": birth_datetime, "gender": gender}
|
||||
)
|
||||
self.database.save_user_birth_profile(self.current_user_id, encrypted)
|
||||
return self.public_personal_profile(personal)
|
||||
|
||||
def stored_birth_profile(self) -> dict[str, str] | None:
|
||||
encrypted = self.database.get_user_birth_profile(self.current_user_id)
|
||||
if not encrypted:
|
||||
return None
|
||||
payload = self.vault.decrypt_json(encrypted)
|
||||
birth_datetime = str(payload.get("birth_datetime") or "").strip()
|
||||
if not birth_datetime:
|
||||
return None
|
||||
return {
|
||||
"birth_datetime": birth_datetime,
|
||||
"gender": str(payload.get("gender") or "unspecified"),
|
||||
}
|
||||
|
||||
def personal_field(
|
||||
self,
|
||||
current_date: str,
|
||||
current_field: dict[str, Any],
|
||||
public: bool = False,
|
||||
) -> dict[str, Any] | None:
|
||||
stored = self.stored_birth_profile()
|
||||
if not stored:
|
||||
return None
|
||||
personal = self.personal_field_builder(
|
||||
stored["birth_datetime"],
|
||||
stored["gender"],
|
||||
current_date,
|
||||
current_field,
|
||||
)
|
||||
if public:
|
||||
return self.public_personal_profile(personal)
|
||||
personal.pop("birth", None)
|
||||
return personal
|
||||
|
||||
@staticmethod
|
||||
def public_personal_profile(personal: dict[str, Any]) -> dict[str, Any]:
|
||||
allowed = {
|
||||
"day_master",
|
||||
"ten_god_tendency",
|
||||
"element_balance",
|
||||
"balance_tendency",
|
||||
"current",
|
||||
"notice",
|
||||
}
|
||||
return {key: value for key, value in personal.items() if key in allowed}
|
||||
|
||||
def update_membership(self, payload: dict[str, Any]) -> None:
|
||||
try:
|
||||
user_id = int(payload.get("user_id"))
|
||||
except (TypeError, ValueError) as exc:
|
||||
raise ValueError("会员账号不正确。") from exc
|
||||
status = str(payload.get("status") or "inactive")
|
||||
if status not in {"active", "inactive", "suspended"}:
|
||||
raise ValueError("会员状态不正确。")
|
||||
access = self.database.user_access(user_id)
|
||||
if not access:
|
||||
raise ValueError("用户不存在。")
|
||||
starts_at = None
|
||||
expires_at = None
|
||||
plan = ""
|
||||
if status == "active":
|
||||
duration = str(payload.get("duration") or "").strip()
|
||||
durations = {
|
||||
"1_month": (1, "1个月"),
|
||||
"3_months": (3, "3个月"),
|
||||
"12_months": (12, "12个月"),
|
||||
"3_years": (36, "3年"),
|
||||
"permanent": (0, "永久"),
|
||||
}
|
||||
if duration not in durations:
|
||||
raise ValueError("请选择会员开通时长。")
|
||||
now = datetime.now(timezone.utc)
|
||||
existing_start = parse_iso_datetime(access.get("membership_starts_at"))
|
||||
existing_expiry = parse_iso_datetime(access.get("membership_expires_at"))
|
||||
starts = existing_start if existing_start and existing_start <= now else now
|
||||
months, plan = durations[duration]
|
||||
starts_at = starts.isoformat(timespec="seconds")
|
||||
if months:
|
||||
renewal_base = existing_expiry if existing_expiry and existing_expiry > now else now
|
||||
expires_at = add_months(renewal_base, months).isoformat(timespec="seconds")
|
||||
if not self.database.update_membership(
|
||||
user_id, status, plan, starts_at, expires_at
|
||||
):
|
||||
raise ValueError("用户不存在。")
|
||||
|
||||
def admin_users(
|
||||
self, usage_supplier: Callable[[int], int]
|
||||
) -> list[dict[str, Any]]:
|
||||
rows = []
|
||||
for user in self.database.list_users():
|
||||
membership = self.membership_for_access(user)
|
||||
used = usage_supplier(int(user["id"])) if membership["active"] else 0
|
||||
rows.append({
|
||||
**user,
|
||||
"membership_active": membership["active"],
|
||||
"membership_subscribed": membership["subscribed"],
|
||||
"used_today": used,
|
||||
})
|
||||
return rows
|
||||
Reference in New Issue
Block a user