236 lines
6.8 KiB
Python
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)
|