Files
gaokao-volunteer-system/admin/users.py
Hermes Agent 3f46811e49
Some checks failed
CI / pytest (Python 3.10) (push) Has been cancelled
CI / pytest (Python 3.11) (push) Has been cancelled
CI / pytest (Python 3.12) (push) Has been cancelled
release: cut v2.1
2026-06-13 14:49:58 +08:00

236 lines
6.8 KiB
Python

"""管理后台用户管理聚合 (T6.3).
用户管理的口径基于 orders 表中的下单人/考生信息做聚合:
- 有手机号时按 customer_phone_hash 聚合
- 无手机号时退回 customer_wechat 指纹
- 再退回订单号,避免丢失孤立记录
公开 API:
- build_user_list_payload : 用户列表 + 搜索 + 分页
- build_user_detail_payload : 用户详情 + 订单明细
"""
from __future__ import annotations
import hashlib
import re
from dataclasses import dataclass
from typing import Any, Optional
from admin.db import get_connection
from data.orders.crypto import hash_for_index
from data.orders.models import Order
_USER_LIST_DEFAULT_LIMIT = 50
_USER_LIST_MAX_LIMIT = 200
@dataclass(frozen=True)
class UserOrderRecord:
order: Order
def to_dict(self) -> dict[str, Any]:
return self.order.to_dict(decrypt_sensitive="mask")
@dataclass(frozen=True)
class UserSummary:
user_key: str
customer_name: Optional[str]
customer_phone: Optional[str]
customer_wechat: Optional[str]
candidate_name: Optional[str]
candidate_province: Optional[str]
order_count: int
total_amount_cents: int
latest_order_at: Optional[str]
latest_status: Optional[str]
def to_dict(self) -> dict[str, Any]:
return {
"user_key": self.user_key,
"customer_name": self.customer_name,
"customer_phone": self.customer_phone,
"customer_wechat": self.customer_wechat,
"candidate_name": self.candidate_name,
"candidate_province": self.candidate_province,
"order_count": int(self.order_count),
"total_amount_cents": int(self.total_amount_cents),
"latest_order_at": self.latest_order_at,
"latest_status": self.latest_status,
}
@dataclass(frozen=True)
class UserDetail(UserSummary):
orders: list[dict[str, Any]]
def to_dict(self) -> dict[str, Any]:
data = super().to_dict()
data["orders"] = list(self.orders)
return data
_PHONE_SPLIT_RE = re.compile(r"[\s\-+()]")
_NON_DIGIT_RE = re.compile(r"\D+")
def _normalize_phone(value: Optional[str]) -> str:
if value is None:
return ""
s = _PHONE_SPLIT_RE.sub("", value.strip())
s = _NON_DIGIT_RE.sub("", s)
if len(s) > 11 and s.startswith("86"):
s = s[2:]
return s
def _wechat_fingerprint(value: Optional[str]) -> str:
if value is None:
return ""
s = value.strip().lower()
if not s:
return ""
return hashlib.sha256(s.encode("utf-8")).hexdigest()
def _user_key(order: Order) -> str:
phone = _normalize_phone(order.customer_phone)
if phone:
return f"phone:{order.customer_phone_hash or phone}"
wechat = _wechat_fingerprint(order.customer_wechat)
if wechat:
return f"wechat:{wechat}"
return f"order:{order.id}"
def _load_orders(orders_db_path: str) -> list[Order]:
with get_connection(orders_db_path) as conn:
rows = conn.execute(
"SELECT * FROM orders ORDER BY created_at DESC, id DESC"
).fetchall()
return [Order.from_db_row(dict(row)) for row in rows]
def _matches_query(order: Order, query: str) -> bool:
needle = query.strip().lower()
if not needle:
return True
phone = _normalize_phone(query)
if phone:
if _normalize_phone(order.customer_phone) == phone:
return True
if (
order.customer_phone_hash
and order.customer_phone_hash
== hash_for_index(phone)
):
return True
haystack = [
order.id,
order.external_id,
order.source,
order.service_version,
order.status,
order.customer_name,
order.customer_phone,
order.customer_wechat,
order.candidate_name,
order.candidate_province,
order.candidate_interests,
order.candidate_strong_subjects,
order.candidate_weak_subjects,
order.candidate_family,
order.assigned_consultant,
order.notes,
]
return any(needle in (value or "").lower() for value in haystack)
def _group_orders(
orders: list[Order], query: Optional[str] = None
) -> list[tuple[str, list[Order]]]:
groups: dict[str, list[Order]] = {}
order_keys: list[str] = []
for order in orders:
if query and not _matches_query(order, query):
continue
key = _user_key(order)
if key not in groups:
groups[key] = []
order_keys.append(key)
groups[key].append(order)
return [(key, groups[key]) for key in order_keys]
def _build_summary(user_key: str, orders: list[Order]) -> UserSummary:
latest = orders[0]
total_amount_cents = sum(int(o.amount_cents or 0) for o in orders)
masked = latest.to_dict(decrypt_sensitive="mask")
return UserSummary(
user_key=user_key,
customer_name=masked.get("customer_name"),
customer_phone=masked.get("customer_phone"),
customer_wechat=masked.get("customer_wechat"),
candidate_name=masked.get("candidate_name"),
candidate_province=masked.get("candidate_province"),
order_count=len(orders),
total_amount_cents=total_amount_cents,
latest_order_at=latest.created_at,
latest_status=latest.status,
)
def _build_detail(user_key: str, orders: list[Order]) -> UserDetail:
summary = _build_summary(user_key, orders)
return UserDetail(
**summary.__dict__,
orders=[UserOrderRecord(order=o).to_dict() for o in orders],
)
def _paginate_groups(
groups: list[tuple[str, list[Order]]],
*,
limit: int,
offset: int,
) -> list[tuple[str, list[Order]]]:
return groups[offset : offset + limit]
def build_user_list_payload(
orders_db_path: str,
*,
query: Optional[str] = None,
limit: int = _USER_LIST_DEFAULT_LIMIT,
offset: int = 0,
) -> dict[str, Any]:
"""返回用户列表 payload。"""
if not (1 <= limit <= _USER_LIST_MAX_LIMIT):
raise ValueError(f"limit 越界 (1..{_USER_LIST_MAX_LIMIT}): {limit}")
if offset < 0:
raise ValueError(f"offset 不能为负: {offset}")
orders = _load_orders(orders_db_path)
groups = _group_orders(orders, query=query)
total = len(groups)
page = _paginate_groups(groups, limit=limit, offset=offset)
items = [_build_summary(key, grouped).to_dict() for key, grouped in page]
return {
"total": total,
"limit": limit,
"offset": offset,
"query": query,
"items": items,
}
def build_user_detail_payload(orders_db_path: str, user_key: str) -> dict[str, Any]:
"""返回单个用户详情 payload。"""
orders = _load_orders(orders_db_path)
for key, grouped in _group_orders(orders):
if key == user_key:
return _build_detail(key, grouped).to_dict()
raise LookupError(user_key)