Files
xiaobai-review/backend/features/accounts/service.py
T
施工员andmultica-agent 3eaa36a8d5 feat(HEL-560): 数据中枢接管数据源/模型池/会员,注册改一次性邀请码
主站
- 新增 m0006 invite_codes 迁移;注册强制邀请码(首个管理员除外),消码与建号
  同一事务,并发提交只有一个能成功
- 新增 /api/hub-admin/* 服务端点(共享 HUB_ADMIN_TOKEN,先于鉴权校验),供数据
  中枢桥接读写会话/密码/模型池/会员/邀请码,并提供供应商模型列表拉取
- 前端:注册表单加邀请码(桌面 login、index.html、移动端);「系统管理」改为
  「数据中枢」入口指向 8766,原模型池与会员管理分区移除,仅留「行情管理」;
  随之清理陈旧 CSS

数据中枢
- 取消独立账号:删除 hub_admin/hub_sessions 与登录、改密、锁定逻辑,改为校验
  主站 xiaobai_session,仅管理员可进,CSRF 由会话派生,危险操作二次确认走主站
- 控制台新增数据源凭证可编辑区(原有内容一项不删)、供应商制模型池(自动拉取
  /models,失败退回卡内手动录入)、会员管理与邀请码页
- 日夜双主题:颜色收敛为同名 token 换值,SVG 改用 inline style 以吃到变量

自测
- 主站 verify_baseline 通过(498 项);数据中枢 235 项通过
- tools/verify_datahub_console.py 端到端跑通两服务真实对话;
  tools/verify_datahub_console_ui.py 浏览器跑通门禁/凭证/模型池/会员/主题/1030 窄屏

Co-authored-by: multica-agent <github@multica.ai>
2026-09-16 11:44:09 +08:00

454 lines
18 KiB
Python

from __future__ import annotations
import hashlib
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)
GRANT_SLIDE_DAYS = 30
GRANT_HARD_DAYS = 180
MAX_GRANTS_PER_DEVICE = 5
SWITCH_REAUTH_MESSAGE = "该账号需重新验证"
INVITE_ALPHABET = "ACDEFGHJKLMNPQRTUVWXY34679"
INVITE_MAX_BATCH = 20
INVITE_LIST_LIMIT = 500
def register(
self,
username: str,
password: str,
device_hash: str = "",
invite_code: str = "",
) -> dict[str, Any]:
username = username.strip()
self.validate_input(username, password)
with self.auth_lock:
code = self.checked_invite_code(invite_code)
salt, password_digest = hash_password(password)
user = self.database.create_user(username, salt, password_digest, code)
result = self.create_session(user)
self.remember_account(device_hash, int(user["id"]), fresh=True)
return result
@classmethod
def normalize_invite_code(cls, value: str) -> str:
raw = "".join(
character
for character in str(value or "").upper()
if character.isalnum()
)
if raw.startswith("XB") and len(raw) == 14:
body = raw[2:]
return f"XB-{body[0:4]}-{body[4:8]}-{body[8:12]}"
return raw[:64]
@staticmethod
def mask_invite_code(code: str) -> str:
groups = str(code or "").split("-")
if len(groups) < 3:
return str(code or "")
return f"{groups[0]}-{groups[1]}-••••"
@staticmethod
def invite_handle(code: str) -> str:
return hashlib.sha256(str(code or "").encode("utf-8")).hexdigest()[:16]
def checked_invite_code(self, invite_code: str) -> str:
if self.database.count_users() == 0:
return ""
code = self.normalize_invite_code(invite_code)
if not code:
raise ValueError("请填写邀请码,注册需要管理员发放的一次性邀请码。")
record = self.database.invite_code(code)
status = str((record or {}).get("status") or "")
if not record:
raise ValueError("邀请码不存在,请向管理员确认。")
if status == "used":
raise ValueError("该邀请码已被使用。")
if status != "unused":
raise ValueError("该邀请码已作废。")
return code
def generate_invite_codes(
self, count: int, note: str = "", created_by: int = 0
) -> list[dict[str, str]]:
try:
total = int(count or 1)
except (TypeError, ValueError) as exc:
raise ValueError("生成数量不正确。") from exc
if total < 1 or total > self.INVITE_MAX_BATCH:
raise ValueError(f"每次最多生成 {self.INVITE_MAX_BATCH} 个邀请码。")
codes: list[str] = []
while len(codes) < total:
body = "".join(secrets.choice(self.INVITE_ALPHABET) for _ in range(12))
code = f"XB-{body[0:4]}-{body[4:8]}-{body[8:12]}"
if code in codes or self.database.invite_code(code):
continue
codes.append(code)
self.database.create_invite_codes(codes, str(note or "").strip()[:60], created_by)
return [{"code": code, "code_id": self.invite_handle(code)} for code in codes]
def _stored_invite_code(self, reference: str) -> str:
normalized = self.normalize_invite_code(reference)
if normalized and self.database.invite_code(normalized):
return normalized
handle = str(reference or "").strip().lower()
for row in self.database.list_invite_codes(self.INVITE_LIST_LIMIT):
if self.invite_handle(str(row["code"])) == handle:
return str(row["code"])
return ""
def revoke_invite_code(self, reference: str) -> None:
code = self._stored_invite_code(reference)
record = self.database.invite_code(code) if code else None
if not record:
raise ValueError("邀请码不存在。")
if str(record.get("status")) == "used":
raise ValueError("该邀请码已被使用,无法作废。")
if not self.database.revoke_invite_code(code):
raise ValueError("该邀请码已作废。")
def invite_overview(self, limit: int = 100) -> dict[str, Any]:
codes = []
for row in self.database.list_invite_codes(limit):
code = str(row["code"])
codes.append(
{
"code_id": self.invite_handle(code),
"code_masked": self.mask_invite_code(code),
"status": str(row["status"]),
"note": str(row.get("note") or ""),
"created_at": str(row.get("created_at") or ""),
"used_at": str(row.get("used_at") or ""),
"revoked_at": str(row.get("revoked_at") or ""),
"used_by_username": str(row.get("used_by_username") or ""),
}
)
return {"summary": self.database.count_invite_codes(), "codes": codes}
def login(self, username: str, password: str, device_hash: 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("账号名或密码不正确。")
result = self.create_session(user)
self.remember_account(device_hash, int(user["id"]), fresh=True)
return result
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("账号不存在。")
self.database.delete_switch_grants_for_user(self.current_user_id)
@staticmethod
def _utc_now() -> datetime:
return datetime.now(timezone.utc)
@classmethod
def _iso(cls, value: datetime) -> str:
return value.isoformat(timespec="seconds")
def remember_account(self, device_hash: str, user_id: int, *, fresh: bool = False) -> None:
if not device_hash or user_id <= 0:
return
now = self._utc_now()
now_text = self._iso(now)
self.database.cleanup_expired_switch_grants(now_text)
existing = None if fresh else self.database.get_switch_grant(device_hash, user_id)
granted_at = parse_iso_datetime(existing["granted_at"]) if existing else now
if granted_at is None:
granted_at = now
expires = min(
now + timedelta(days=self.GRANT_SLIDE_DAYS),
granted_at + timedelta(days=self.GRANT_HARD_DAYS),
)
if not existing:
self.database.prune_switch_grants(device_hash, self.MAX_GRANTS_PER_DEVICE - 1)
self.database.upsert_switch_grant(
device_hash,
user_id,
self._iso(granted_at),
now_text,
self._iso(expires),
)
def list_device_accounts(
self, device_hash: str, current_user_id: int | None = None
) -> dict[str, Any]:
if not device_hash:
return {"accounts": [], "current_user_id": current_user_id}
now_text = self._iso(self._utc_now())
self.database.cleanup_expired_switch_grants(now_text)
accounts = []
for row in self.database.list_switch_grants(device_hash, now_text):
accounts.append(
{
"user_id": int(row["id"]),
"username": str(row["username"]),
"role": str(row.get("role") or "user"),
"membership": self.membership_for_access(row),
"last_used_at": str(row.get("last_used_at") or ""),
}
)
return {"accounts": accounts, "current_user_id": current_user_id}
def switch_account(self, device_hash: str, user_id: int) -> dict[str, Any]:
if not device_hash or user_id <= 0:
raise PermissionError(self.SWITCH_REAUTH_MESSAGE)
now = self._utc_now()
now_text = self._iso(now)
self.database.cleanup_expired_switch_grants(now_text)
grant = self.database.get_switch_grant(device_hash, user_id)
expires = parse_iso_datetime(grant.get("expires_at")) if grant else None
if not grant or not expires or expires <= now:
if grant:
self.database.delete_switch_grant(device_hash, user_id)
raise PermissionError(self.SWITCH_REAUTH_MESSAGE)
user = self.database.user_access(user_id)
if not user:
raise PermissionError(self.SWITCH_REAUTH_MESSAGE)
result = self.create_session(user)
self.remember_account(device_hash, user_id)
return result
def forget_account(self, device_hash: str, user_id: int) -> None:
if device_hash and user_id > 0:
self.database.delete_switch_grant(device_hash, user_id)
def revoke_current_device_grant(self, device_hash: str, user_id: int) -> None:
if device_hash and user_id > 0:
self.database.delete_switch_grant(device_hash, user_id)
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