2834 lines
101 KiB
Python
2834 lines
101 KiB
Python
"""虎牙外部 CK 协作台账的服务层(模型:``models.HuyaExternalOrder``)。
|
||
|
||
设计见 ``docs/设计-虎牙外部CK协作台账.md``。改这里之前先读文档 §2.3 与 §10.2,
|
||
两条边界不是 bug:
|
||
|
||
1. **不写 ``huya_accounts``、不做外键**:CK 独立存本表;
|
||
2. **不 import 任何任务执行器**(``huya_runner*`` / ``huya_tasks``):
|
||
绑定/充值/上脚本都在别的工具里做,本表只记录结果。
|
||
S5 的探针只 import ``core.huya.http_client``(只读 RPC),同样不碰 runner。
|
||
|
||
本模块是**两套状态机的唯一写入口**(设计文档 §4.3 防漂移):
|
||
|
||
- ``set_status()`` —— 业务进度 ``status``
|
||
- ``set_ck_status()`` / ``set_ck()`` / ``rebuild_ck()`` —— 凭据状态 ``ck_status``
|
||
|
||
接口层不允许直接改这两个列,否则状态与时间戳会各自漂移。
|
||
"""
|
||
|
||
from __future__ import annotations
|
||
|
||
import dataclasses
|
||
import hashlib
|
||
import re
|
||
import time
|
||
from collections.abc import Mapping, Sequence
|
||
from datetime import UTC, datetime
|
||
from typing import Any
|
||
|
||
from loguru import logger
|
||
from sqlalchemy import func, or_, tuple_
|
||
from sqlalchemy.orm import Session
|
||
|
||
from ..huya_defaults import (
|
||
HUYA_DEFAULT_BIND_ACT_ID,
|
||
HUYA_DEFAULT_OUTER_ACT_ID,
|
||
HUYA_DEFAULT_SID,
|
||
)
|
||
from ..models import (
|
||
HuyaCollabShop,
|
||
HuyaConfig,
|
||
HuyaExternalOrder,
|
||
HuyaExternalOrderMetric,
|
||
User,
|
||
)
|
||
from .audit_service import record_audit
|
||
from .file_service import build_file_url
|
||
from .file_storage import normalize_object_key
|
||
from .huya_service import build_web_cookie_from_raw, parse_huya_cookie_line
|
||
|
||
# --------------------------------------------------------------------------
|
||
# 业务状态机(一单卡在哪一步)
|
||
# --------------------------------------------------------------------------
|
||
|
||
# 「待补资料」是流程链的起点:只登记订单号 + 店铺,CK / 礼包 / 主页截图等客户资料
|
||
# 到了再补(2026-10-17 用户需求)。资料齐全后才能进「待处理」。
|
||
STATUS_INCOMPLETE = "incomplete"
|
||
STATUS_PENDING = "pending"
|
||
STATUS_BOUND = "bound"
|
||
STATUS_RECHARGED = "recharged"
|
||
STATUS_SCRIPTED = "scripted"
|
||
STATUS_DONE = "done"
|
||
STATUS_FAILED = "failed"
|
||
STATUS_VOID = "void"
|
||
|
||
ORDER_STATUSES: tuple[str, ...] = (
|
||
STATUS_INCOMPLETE,
|
||
STATUS_PENDING,
|
||
STATUS_BOUND,
|
||
STATUS_RECHARGED,
|
||
STATUS_SCRIPTED,
|
||
STATUS_DONE,
|
||
STATUS_FAILED,
|
||
STATUS_VOID,
|
||
)
|
||
|
||
ORDER_STATUS_LABELS = {
|
||
STATUS_INCOMPLETE: "待补资料",
|
||
STATUS_PENDING: "待处理",
|
||
# 人工核对过「探针读到的绑定角色」与「主页截图」一致,才允许进这一步(见 _assert_can_enter_bound)
|
||
STATUS_BOUND: "已确认绑定",
|
||
STATUS_RECHARGED: "已充值",
|
||
STATUS_SCRIPTED: "已上脚本",
|
||
# 抢购跑到结果:查兑换记录能确认(自动改)也可以人工切(2026-10-16 用户把「已完成」改叫这个)
|
||
STATUS_DONE: "抢购成功",
|
||
# 旁路终态:抢购后查兑换记录,没查到这一单的礼包 → 判失败(人工确认后可作废或回退重试)
|
||
STATUS_FAILED: "抢购失败",
|
||
STATUS_VOID: "已作废",
|
||
}
|
||
|
||
# 流程链(时间戳同步与「能不能向前走」都只看这条链);failed / void 是**旁路**终态。
|
||
STATUS_FLOW: tuple[str, ...] = (
|
||
STATUS_INCOMPLETE,
|
||
STATUS_PENDING,
|
||
STATUS_BOUND,
|
||
STATUS_RECHARGED,
|
||
STATUS_SCRIPTED,
|
||
STATUS_DONE,
|
||
)
|
||
|
||
# 「向前一步」的下一站;done 是终点,failed / void 是任意态可达的旁路,都不在这条链上。
|
||
NEXT_STATUS = {
|
||
STATUS_INCOMPLETE: STATUS_PENDING,
|
||
STATUS_PENDING: STATUS_BOUND,
|
||
STATUS_BOUND: STATUS_RECHARGED,
|
||
STATUS_RECHARGED: STATUS_SCRIPTED,
|
||
STATUS_SCRIPTED: STATUS_DONE,
|
||
}
|
||
|
||
# 阶段序号:用来同步时间戳(回退时要清掉后面阶段的时间戳,否则状态与时间戳互相矛盾)。
|
||
STAGE_INDEX = {
|
||
STATUS_INCOMPLETE: 0,
|
||
STATUS_PENDING: 1,
|
||
STATUS_BOUND: 2,
|
||
STATUS_RECHARGED: 3,
|
||
STATUS_SCRIPTED: 4,
|
||
STATUS_DONE: 5,
|
||
}
|
||
|
||
STATUS_TIMESTAMP_FIELDS = {
|
||
STATUS_BOUND: "bound_at",
|
||
STATUS_RECHARGED: "recharged_at",
|
||
STATUS_SCRIPTED: "scripted_at",
|
||
STATUS_DONE: "done_at",
|
||
}
|
||
|
||
|
||
def is_forward_status(from_status: str, target: str) -> bool:
|
||
"""这一步算不算「向前」(人工切换允许往前跳步,回退必须显式声明)。
|
||
|
||
2026-10-16 用户拍板:以前只允许一步一步推进,但人工实际会跳(比如没记充值就直接上脚本)。
|
||
现在规则:
|
||
|
||
- 流程链内:目标阶段序号 **≥ 当前** 就是向前(含原地重复设置,幂等);
|
||
- `void`(作废):任意流程内状态都能进;
|
||
- `failed`(抢购失败):流程内任意状态都能进(兑换记录查出来没有就是没有);
|
||
- 从 `void` / `failed` 回到流程内:**必须** rollback(这两步是"要人工再确认一次"的)。
|
||
"""
|
||
if target == STATUS_VOID or target == STATUS_FAILED:
|
||
return from_status not in (STATUS_VOID, STATUS_FAILED)
|
||
if from_status in (STATUS_VOID, STATUS_FAILED):
|
||
return False
|
||
return STAGE_INDEX[target] >= STAGE_INDEX[from_status]
|
||
|
||
|
||
# --------------------------------------------------------------------------
|
||
# CK 状态机(凭据本身)
|
||
# --------------------------------------------------------------------------
|
||
|
||
CK_UNVERIFIED = "unverified"
|
||
CK_VALID = "valid"
|
||
CK_INVALID = "invalid"
|
||
CK_DISABLED = "disabled"
|
||
|
||
CK_STATUSES: tuple[str, ...] = (CK_UNVERIFIED, CK_VALID, CK_INVALID, CK_DISABLED)
|
||
|
||
CK_STATUS_LABELS = {
|
||
CK_UNVERIFIED: "未验证",
|
||
CK_VALID: "有效",
|
||
CK_INVALID: "已失效",
|
||
CK_DISABLED: "已停用",
|
||
}
|
||
|
||
# 只有这两种状态代表「刚跑过探针」,需要刷新 ck_checked_at。
|
||
_CK_CHECKED_STATUSES = (CK_VALID, CK_INVALID)
|
||
|
||
# --------------------------------------------------------------------------
|
||
# 字段与图片槽位
|
||
# --------------------------------------------------------------------------
|
||
|
||
EDITABLE_FIELDS: tuple[str, ...] = (
|
||
"order_no",
|
||
"shop",
|
||
"owner_id",
|
||
"bind_douyu_account",
|
||
"bind_douyu_uid",
|
||
"gift_name",
|
||
"remark",
|
||
)
|
||
|
||
FIELD_LIMITS = {
|
||
"order_no": 64,
|
||
"shop": 64,
|
||
"shop_remark": 255,
|
||
"bind_douyu_account": 128,
|
||
"bind_douyu_uid": 32,
|
||
"gift_name": 128,
|
||
"remark": 512,
|
||
"void_reason": 256,
|
||
}
|
||
|
||
FIELD_LABELS = {
|
||
"order_no": "订单编号",
|
||
"shop": "店铺",
|
||
"shop_remark": "店铺备注",
|
||
"owner_id": "负责人",
|
||
"bind_douyu_account": "绑定斗鱼号",
|
||
"bind_douyu_uid": "绑定斗鱼号 uid",
|
||
"gift_name": "礼包名",
|
||
"remark": "备注",
|
||
"void_reason": "作废原因",
|
||
}
|
||
|
||
IMAGE_SLOTS: tuple[str, ...] = ("bind_images", "home_images")
|
||
|
||
IMAGE_SLOT_LABELS = {
|
||
"bind_images": "绑定图",
|
||
"home_images": "主页截图",
|
||
}
|
||
|
||
MAX_IMAGES_PER_SLOT = 20
|
||
|
||
# 网页运行 CK 才有这两类字段;没有 → 粘贴进来的其实是 App 原始凭据,
|
||
# 顺手存进 raw_cookie,以后就能用「重建」续命(不用重新找人要 CK)。
|
||
_WEB_COOKIE_HINTS = ("guid=", "_qimei_uuid42")
|
||
|
||
|
||
class CollabError(RuntimeError):
|
||
"""协作台账的业务错误,路由层据此转成对应 HTTP 状态码。"""
|
||
|
||
def __init__(self, detail: str, *, status_code: int = 400, code: str = ""):
|
||
super().__init__(detail)
|
||
self.detail = detail
|
||
self.status_code = status_code
|
||
self.code = code
|
||
|
||
|
||
# --------------------------------------------------------------------------
|
||
# 内部工具
|
||
# --------------------------------------------------------------------------
|
||
|
||
|
||
def _now() -> datetime:
|
||
return datetime.now(UTC)
|
||
|
||
|
||
def clean_text(
|
||
value: Any, *, field: str, required: bool = False, max_length: int | None = None
|
||
) -> str:
|
||
"""统一收口字段清洗:去空白、限长、必填校验。
|
||
|
||
**不静默截断**:超长直接报错,否则用户填了 100 字备注、库里只剩 512 字节
|
||
之前的片段,事后没人知道被切过。
|
||
"""
|
||
text = str(value if value is not None else "").strip()
|
||
limit = max_length or FIELD_LIMITS.get(field, 255)
|
||
if len(text) > limit:
|
||
raise CollabError(
|
||
f"{FIELD_LABELS.get(field, field)}不能超过 {limit} 个字符",
|
||
code="field_too_long",
|
||
)
|
||
if required and not text:
|
||
raise CollabError(
|
||
f"{FIELD_LABELS.get(field, field)}不能为空", code="field_required"
|
||
)
|
||
return text
|
||
|
||
|
||
def normalize_images(value: Any, *, field: str) -> list[str]:
|
||
"""把图片字段统一成 object_key 数组(去空、去重、挡路径穿越、限张数)。"""
|
||
if value is None:
|
||
return []
|
||
if isinstance(value, str):
|
||
items: Sequence[Any] = [part for part in re.split(r"[,\s]+", value) if part]
|
||
elif isinstance(value, (list, tuple)):
|
||
items = list(value)
|
||
else:
|
||
raise CollabError(
|
||
f"{IMAGE_SLOT_LABELS.get(field, field)}格式不正确", code="images_invalid"
|
||
)
|
||
|
||
result: list[str] = []
|
||
for item in items:
|
||
text = str(item or "").strip()
|
||
if not text:
|
||
continue
|
||
try:
|
||
key = normalize_object_key(text)
|
||
except ValueError as exc:
|
||
raise CollabError(
|
||
f"{IMAGE_SLOT_LABELS.get(field, field)}的文件 key 不正确",
|
||
code="image_key_invalid",
|
||
) from exc
|
||
if key not in result:
|
||
result.append(key)
|
||
|
||
if len(result) > MAX_IMAGES_PER_SLOT:
|
||
raise CollabError(
|
||
f"{IMAGE_SLOT_LABELS.get(field, field)}最多 {MAX_IMAGES_PER_SLOT} 张",
|
||
code="images_too_many",
|
||
)
|
||
return result
|
||
|
||
|
||
def images_of(order: HuyaExternalOrder, slot: str) -> list[str]:
|
||
"""安全读取图片数组(JSON 列历史上可能是 None)。"""
|
||
value = getattr(order, slot, None)
|
||
return [str(item) for item in value] if isinstance(value, list) else []
|
||
|
||
|
||
def look_like_web_cookie(cookie: str) -> bool:
|
||
"""判断一份 CK 是否已是「网页运行态」(含 guid/_qimei_uuid42 设备字段)。"""
|
||
text = cookie or ""
|
||
return any(hint in text for hint in _WEB_COOKIE_HINTS)
|
||
|
||
|
||
def parse_ck(text: str) -> dict:
|
||
"""解析粘贴进来的 CK(复用 huya_service 的纯函数,不依赖账号表)。"""
|
||
parsed = parse_huya_cookie_line(text or "")
|
||
if not parsed:
|
||
raise CollabError(
|
||
"CK 解析失败:没识别到虎牙 uid(支持「纯 CK」或「账号----密码----CK」)",
|
||
code="ck_invalid",
|
||
)
|
||
return parsed
|
||
|
||
|
||
def _touch(order: HuyaExternalOrder, current: User | None) -> None:
|
||
"""每次写入统一推进版本号与最后修改人(乐观锁靠 version,不用 updated_at)。"""
|
||
order.updated_by = current.id if current else None
|
||
order.version = int(order.version or 0) + 1
|
||
order.updated_at = _now()
|
||
|
||
|
||
def _audit_target(order: HuyaExternalOrder) -> str:
|
||
return f"huya_external_order:{order.id}"
|
||
|
||
|
||
def assert_version(order: HuyaExternalOrder, expected_version: int | None) -> None:
|
||
"""乐观锁:客户端提交时带的版本必须与库里一致,否则说明别人先改了。"""
|
||
if expected_version is None:
|
||
return
|
||
if int(expected_version) != int(order.version or 0):
|
||
raise CollabError(
|
||
f"这行已被 {_last_editor_name(order) or '其他人'} 改过(版本 {order.version}),"
|
||
"请刷新后重试;直接覆盖会丢掉对方的修改",
|
||
status_code=409,
|
||
code="version_conflict",
|
||
)
|
||
|
||
|
||
def _last_editor_name(order: HuyaExternalOrder) -> str:
|
||
editor = getattr(order, "updated_by_user", None)
|
||
return str(getattr(editor, "username", "") or "")
|
||
|
||
|
||
def _assert_order_no_available(
|
||
db: Session, order_no: str, *, exclude_id: int | None = None
|
||
) -> None:
|
||
query = db.query(HuyaExternalOrder).filter(HuyaExternalOrder.order_no == order_no)
|
||
if exclude_id is not None:
|
||
query = query.filter(HuyaExternalOrder.id != exclude_id)
|
||
other = query.first()
|
||
if other is not None:
|
||
raise CollabError(
|
||
f"订单编号 {order_no} 已被第 {other.id} 行占用",
|
||
status_code=409,
|
||
code="order_no_conflict",
|
||
)
|
||
|
||
|
||
def uid_used_orders(
|
||
db: Session,
|
||
uid: str | None,
|
||
*,
|
||
exclude_id: int | None = None,
|
||
limit: int = 10,
|
||
) -> list[HuyaExternalOrder]:
|
||
"""这个虎牙号名下**其它**订单(按创建时间升序)。
|
||
|
||
2026-10-16 用户拍板**放开**「一个虎牙号只能挂一单」:同一个买家第二单常常继续用同一份
|
||
CK。所以这里**不再抛 409**(旧行为见设计文档 §1 的变更记录),只用来在界面上提示
|
||
「这份 CK 已经挂在哪些单上」、以及在审计里留一笔复用痕迹。
|
||
"""
|
||
text = str(uid or "").strip()
|
||
if not text:
|
||
return []
|
||
query = (
|
||
db.query(HuyaExternalOrder)
|
||
.filter(HuyaExternalOrder.uid == text)
|
||
.order_by(HuyaExternalOrder.created_at.asc(), HuyaExternalOrder.id.asc())
|
||
)
|
||
if exclude_id is not None:
|
||
query = query.filter(HuyaExternalOrder.id != exclude_id)
|
||
return query.limit(limit).all()
|
||
|
||
|
||
def brief_order(order: HuyaExternalOrder) -> dict[str, Any]:
|
||
"""一行的最小视图(列表/详情里"另一个单"那种展示用)。"""
|
||
return {
|
||
"id": int(order.id),
|
||
"order_no": order.order_no or "",
|
||
"shop": order.shop or "",
|
||
"gift_name": order.gift_name or "",
|
||
"uid": order.uid or "",
|
||
"status": order.status,
|
||
"status_label": ORDER_STATUS_LABELS.get(order.status, order.status),
|
||
"created_at": order.created_at,
|
||
}
|
||
|
||
|
||
def uids_in_use(
|
||
db: Session, uid: str | None, *, exclude_id: int | None = None
|
||
) -> list[dict]:
|
||
"""给预览用的「这个虎牙号已经在哪些单上」(没有就空列表)。"""
|
||
return [
|
||
brief_order(order) for order in uid_used_orders(db, uid, exclude_id=exclude_id)
|
||
]
|
||
|
||
|
||
def _apply_ck(order: HuyaExternalOrder, cookie_text: str) -> dict:
|
||
"""把一份 CK 写进行对象(不含冲突检查与审计,两层调用方共用)。"""
|
||
parsed = parse_ck(cookie_text)
|
||
cookie = str(parsed.get("cookie") or "")
|
||
order.uid = str(parsed.get("uid") or "")
|
||
order.yyuid = str(parsed.get("yyuid") or "")
|
||
order.hy_username = str(parsed.get("username") or "")[:128]
|
||
order.cookie = cookie
|
||
# 粘贴的若是 App 原始凭据(没有网页设备字段),顺手留一份 raw_cookie,
|
||
# 以后被风控标记时能直接「重建」而不必重新要 CK。
|
||
if not look_like_web_cookie(cookie):
|
||
order.raw_cookie = cookie
|
||
order.ck_status = CK_UNVERIFIED
|
||
order.ck_checked_at = None
|
||
order.ck_check_message = ""
|
||
return {
|
||
"uid": order.uid,
|
||
"cookie_kind": "web" if look_like_web_cookie(cookie) else "raw",
|
||
}
|
||
|
||
|
||
# --------------------------------------------------------------------------
|
||
# 业务状态机
|
||
# --------------------------------------------------------------------------
|
||
|
||
|
||
def _assert_can_enter_bound(order: HuyaExternalOrder) -> None:
|
||
"""进入「已确认绑定」的前置校验(服务层做,前端校验只是体验)。
|
||
|
||
2026-10-16 用户口径:**人工核对过「探针读到的绑定角色」与「主页截图」一致**,才允许
|
||
切到这一步。所以这里要两样东西齐全:
|
||
|
||
- **绑定角色**(`bind_game_role`,探针写)—— 没读到角色就没人知道该跟哪张图对;
|
||
- **主页截图**(`home_images`,人工上传)—— 没有图就没有"核对"这件事的依据。
|
||
|
||
绑定图已停用(绑定关系改由探针读),不再要求;`bind_douyu_account` 也不再算数
|
||
(那是人工自由文本,且已从界面下线)。
|
||
"""
|
||
missing: list[str] = []
|
||
if not str(order.cookie or "").strip():
|
||
missing.append("CK")
|
||
if not str(order.bind_game_role or "").strip():
|
||
missing.append("绑定角色(先贴 CK 让它自动探针读一次)")
|
||
if not images_of(order, "home_images"):
|
||
missing.append("主页截图(至少 1 张)")
|
||
if missing:
|
||
raise CollabError(
|
||
f"还不能标记为「{ORDER_STATUS_LABELS[STATUS_BOUND]}」,缺少:{'、'.join(missing)}",
|
||
code="bound_precondition",
|
||
)
|
||
|
||
|
||
def _assert_can_leave_incomplete(order: HuyaExternalOrder) -> None:
|
||
"""离开「待补资料」的前置校验:客户资料必须齐全。
|
||
|
||
只在 ``incomplete`` 这个起点用:CK / 礼包名 / 主页截图至少各一份,
|
||
否则「待处理」只是个空壳(后面谁都处理不了)。作废 / 判失败是旁路,不拦。
|
||
"""
|
||
missing: list[str] = []
|
||
if not str(order.cookie or "").strip():
|
||
missing.append("外部 CK")
|
||
if not str(order.gift_name or "").strip():
|
||
missing.append("礼包名")
|
||
if not images_of(order, "home_images"):
|
||
missing.append("主页截图(至少 1 张)")
|
||
if missing:
|
||
raise CollabError(
|
||
f"「{ORDER_STATUS_LABELS[STATUS_INCOMPLETE]}」还不能往前改,缺少:{'、'.join(missing)}",
|
||
code="incomplete_precondition",
|
||
)
|
||
|
||
|
||
def _sync_stage_timestamps(
|
||
order: HuyaExternalOrder, target: str, now: datetime
|
||
) -> None:
|
||
"""让各阶段时间戳与目标状态保持一致。
|
||
|
||
- 目标状态**之前**(含自身)缺失的时间戳补上:状态说「已上脚本」,
|
||
时间戳就不能是空的,否则前端那格显示空白、没人知道什么时候做的;
|
||
- 目标状态**之后**的时间戳清空:回退之后还留着「已上脚本时间」,
|
||
就是状态与时间戳互相矛盾;
|
||
- `void` / `failed` 是旁路终态:不动流程链上的时间戳(从"已上脚本"判失败时,
|
||
"什么时候上的脚本"这条信息必须留着)。
|
||
"""
|
||
if target in (STATUS_VOID, STATUS_FAILED):
|
||
return
|
||
target_index = STAGE_INDEX[target]
|
||
for stage, field in STATUS_TIMESTAMP_FIELDS.items():
|
||
if STAGE_INDEX[stage] <= target_index:
|
||
if getattr(order, field) is None:
|
||
setattr(order, field, now)
|
||
else:
|
||
setattr(order, field, None)
|
||
|
||
|
||
def set_status(
|
||
db: Session,
|
||
current: User | None,
|
||
order: HuyaExternalOrder,
|
||
target: str,
|
||
*,
|
||
reason: str = "",
|
||
rollback: bool = False,
|
||
commit: bool = True,
|
||
) -> dict:
|
||
"""推进/回退业务状态(本模块唯一的 ``status`` 写入口)。
|
||
|
||
人工切换**允许往前跳步**(如 待处理 → 已上脚本),回退必须显式 ``rollback=True``;
|
||
规则见 :func:`is_forward_status`。
|
||
|
||
Args:
|
||
target: 目标状态,必须在 ``ORDER_STATUSES`` 里。
|
||
reason: 作废原因(``target=void`` 时必填)。
|
||
rollback: 目标不是「向前」时必须显式置 True 才允许改。
|
||
"""
|
||
if target not in ORDER_STATUSES:
|
||
raise CollabError(f"未知状态:{target}", code="status_unknown")
|
||
if target == order.status:
|
||
return {}
|
||
|
||
from_status = order.status
|
||
forward = is_forward_status(from_status, target)
|
||
if not forward:
|
||
if not rollback:
|
||
next_status = NEXT_STATUS.get(from_status)
|
||
hint = (
|
||
f"要往前的话下一步是「{ORDER_STATUS_LABELS[next_status]}」(也可以直接跳到更后面的状态)"
|
||
if next_status
|
||
else "这单已经走到终点"
|
||
)
|
||
raise CollabError(
|
||
f"不能把「{ORDER_STATUS_LABELS.get(from_status, from_status)}」改回"
|
||
f"「{ORDER_STATUS_LABELS[target]}」({hint});确实要回退请显式勾选「回退」",
|
||
code="status_not_forward",
|
||
)
|
||
if from_status == STATUS_VOID:
|
||
raise CollabError(
|
||
"已作废的单子不能改回流程内状态,请编辑或新建",
|
||
code="status_from_void",
|
||
)
|
||
|
||
if from_status == STATUS_INCOMPLETE and target not in (
|
||
STATUS_VOID,
|
||
STATUS_FAILED,
|
||
):
|
||
# 「待补资料」是起点:客户资料没齐之前不许往前,否则下一个环节无从下手。
|
||
_assert_can_leave_incomplete(order)
|
||
|
||
if target == STATUS_BOUND:
|
||
_assert_can_enter_bound(order)
|
||
|
||
now = _now()
|
||
if target == STATUS_VOID:
|
||
order.void_reason = clean_text(reason, field="void_reason", required=True)
|
||
else:
|
||
# 从作废改回流程内状态时,作废原因要清掉,不然列表上会自相矛盾。
|
||
order.void_reason = ""
|
||
|
||
order.status = target
|
||
_sync_stage_timestamps(order, target, now)
|
||
_touch(order, current)
|
||
|
||
record_audit(
|
||
db,
|
||
current,
|
||
action="huya:collab:status",
|
||
target=_audit_target(order),
|
||
detail={
|
||
"from": from_status,
|
||
"to": target,
|
||
"rollback": bool(not forward),
|
||
"reason": order.void_reason,
|
||
"order_no": order.order_no,
|
||
},
|
||
)
|
||
if commit:
|
||
db.commit()
|
||
db.refresh(order)
|
||
return {"from": from_status, "to": target}
|
||
|
||
|
||
def set_status_batch(
|
||
db: Session,
|
||
current: User | None,
|
||
orders: Sequence[HuyaExternalOrder],
|
||
target: str,
|
||
*,
|
||
reason: str = "",
|
||
rollback: bool = False,
|
||
) -> dict:
|
||
"""批量改状态:勾选多行一次切到同一个状态(部分失败不影响其它行)。
|
||
|
||
逐行用 SAVEPOINT 隔离(同 :func:`import_orders` 的做法):某一行因为前置校验没过
|
||
(例如没主页截图不能进「已确认绑定」)只记在这一条的 ``message`` 里,整体不整体失败。
|
||
上限与批量验证一致(``PROBE_BATCH_MAX``)—— 一次别点太多,人会看不过来。
|
||
"""
|
||
rows = list(orders)
|
||
if not rows:
|
||
raise CollabError("请先勾选要改状态的行", code="status_batch_empty")
|
||
if len(rows) > PROBE_BATCH_MAX:
|
||
raise CollabError(
|
||
f"一次最多改 {PROBE_BATCH_MAX} 行,请分批操作",
|
||
code="status_batch_too_large",
|
||
)
|
||
|
||
results: list[dict] = []
|
||
updated = 0
|
||
for order in rows:
|
||
try:
|
||
with db.begin_nested():
|
||
change = set_status(
|
||
db,
|
||
current,
|
||
order,
|
||
target,
|
||
reason=reason,
|
||
rollback=rollback,
|
||
commit=False,
|
||
)
|
||
except CollabError as exc:
|
||
results.append(
|
||
{
|
||
"id": int(order.id),
|
||
"order_no": order.order_no,
|
||
"ok": False,
|
||
"message": exc.detail,
|
||
}
|
||
)
|
||
continue
|
||
if change:
|
||
updated += 1
|
||
results.append(
|
||
{
|
||
"id": int(order.id),
|
||
"order_no": order.order_no,
|
||
"ok": True,
|
||
"message": (
|
||
f"{ORDER_STATUS_LABELS.get(change['from'], change['from'])}"
|
||
f" → {ORDER_STATUS_LABELS.get(change['to'], change['to'])}"
|
||
if change
|
||
else "已经是这个状态"
|
||
),
|
||
}
|
||
)
|
||
db.commit()
|
||
for order in rows:
|
||
db.refresh(order)
|
||
return {
|
||
"total": len(rows),
|
||
"updated": updated,
|
||
"failed": len(rows) - updated,
|
||
"target": target,
|
||
"results": results,
|
||
}
|
||
|
||
|
||
# --------------------------------------------------------------------------
|
||
# CK 写入口
|
||
# --------------------------------------------------------------------------
|
||
|
||
|
||
def set_ck(
|
||
db: Session,
|
||
current: User | None,
|
||
order: HuyaExternalOrder,
|
||
cookie_text: str,
|
||
*,
|
||
commit: bool = True,
|
||
) -> dict:
|
||
"""贴入 / 换一份 CK,状态回到「未验证」。
|
||
|
||
同一个虎牙号**允许**挂在多张单上(2026-10-16 用户拍板放开 uid 唯一性):
|
||
复用到的那些单号只记一笔审计,不挡操作 —— 挡了就变成"同一个买家的第二单建不了"。
|
||
"""
|
||
before = str(order.cookie or "")
|
||
detail = _apply_ck(order, cookie_text)
|
||
reused = [
|
||
item.order_no for item in uid_used_orders(db, order.uid, exclude_id=order.id)
|
||
]
|
||
_touch(order, current)
|
||
record_audit(
|
||
db,
|
||
current,
|
||
action="huya:collab:ck",
|
||
target=_audit_target(order),
|
||
detail={
|
||
"kind": "paste",
|
||
"uid": order.uid,
|
||
"order_no": order.order_no,
|
||
# 刻意**不**把 cookie 交给审计层:即便 record_audit 会脱敏,
|
||
# 凭据也不该多经一手(脱敏规则一改就是凭据泄漏)。
|
||
# 只留一个「内容是否变了」的布尔,够用来追溯换没换过。
|
||
"credential_changed": before != str(order.cookie or ""),
|
||
# 这个虎牙号还挂在哪些单上(同一份 CK 服务多单是允许的,留个痕)
|
||
"uid_reused_in": reused,
|
||
},
|
||
)
|
||
if commit:
|
||
db.commit()
|
||
db.refresh(order)
|
||
return detail
|
||
|
||
|
||
def set_ck_status(
|
||
db: Session,
|
||
current: User | None,
|
||
order: HuyaExternalOrder,
|
||
status: str,
|
||
*,
|
||
message: str = "",
|
||
commit: bool = True,
|
||
) -> dict:
|
||
"""改写凭据状态(本模块唯一的 ``ck_status`` 写入口)。
|
||
|
||
``valid`` / ``invalid`` 视为「刚跑过探针」,会刷新 ``ck_checked_at``;
|
||
``disabled`` 是人工停用,不动探针时间。
|
||
"""
|
||
if status not in CK_STATUSES:
|
||
raise CollabError(f"未知 CK 状态:{status}", code="ck_status_unknown")
|
||
previous = order.ck_status
|
||
order.ck_status = status
|
||
order.ck_check_message = clean_text(
|
||
message, field="ck_check_message", max_length=256
|
||
)
|
||
if status in _CK_CHECKED_STATUSES:
|
||
order.ck_checked_at = _now()
|
||
_touch(order, current)
|
||
record_audit(
|
||
db,
|
||
current,
|
||
action="huya:collab:ck_status",
|
||
target=_audit_target(order),
|
||
detail={
|
||
"from": previous,
|
||
"to": status,
|
||
"message": order.ck_check_message,
|
||
"order_no": order.order_no,
|
||
},
|
||
)
|
||
if commit:
|
||
db.commit()
|
||
db.refresh(order)
|
||
return {"from": previous, "to": status}
|
||
|
||
|
||
def rebuild_ck(
|
||
db: Session, current: User | None, order: HuyaExternalOrder, *, commit: bool = True
|
||
) -> dict:
|
||
"""用 ``raw_cookie`` 重建网页运行 CK(复用 huya_service 的纯函数)。"""
|
||
if not str(order.raw_cookie or "").strip():
|
||
raise CollabError(
|
||
"这行没有 App 原始凭据,无法重建网页 CK(请重新贴一份)",
|
||
code="no_raw_cookie",
|
||
)
|
||
try:
|
||
rebuilt = build_web_cookie_from_raw(
|
||
uid=str(order.uid or ""),
|
||
yyuid=str(order.yyuid or ""),
|
||
username=str(order.hy_username or ""),
|
||
raw_cookie=str(order.raw_cookie or ""),
|
||
)
|
||
except ValueError as exc:
|
||
raise CollabError(f"重建失败:{exc}", code="rebuild_failed") from exc
|
||
|
||
order.cookie = rebuilt
|
||
order.ck_status = CK_UNVERIFIED
|
||
order.ck_checked_at = None
|
||
order.ck_check_message = "已从 App 原始凭据重建,待验证"
|
||
_touch(order, current)
|
||
record_audit(
|
||
db,
|
||
current,
|
||
action="huya:collab:ck",
|
||
target=_audit_target(order),
|
||
detail={
|
||
"kind": "rebuild",
|
||
"uid": order.uid,
|
||
"order_no": order.order_no,
|
||
"credential_changed": True,
|
||
},
|
||
)
|
||
if commit:
|
||
db.commit()
|
||
db.refresh(order)
|
||
return {"uid": order.uid, "cookie_kind": "web"}
|
||
|
||
|
||
# 人工能改的 CK 状态:`valid` / `invalid` 只能由探针给(人工标「有效」等于绕过验证)。
|
||
MANUAL_CK_STATUSES: tuple[str, ...] = (CK_UNVERIFIED, CK_DISABLED)
|
||
|
||
|
||
def set_manual_ck_status(
|
||
db: Session,
|
||
current: User | None,
|
||
order: HuyaExternalOrder,
|
||
status: str,
|
||
*,
|
||
message: str = "",
|
||
commit: bool = True,
|
||
) -> dict:
|
||
"""人工改凭据状态(只允许「已停用」/「未验证」),仍然走 :func:`set_ck_status` 落库。"""
|
||
if status not in MANUAL_CK_STATUSES:
|
||
raise CollabError(
|
||
"人工只能把 CK 标成「已停用」或「未验证」;有效/失效由探针判定",
|
||
code="ck_status_manual_only",
|
||
)
|
||
return set_ck_status(db, current, order, status, message=message, commit=commit)
|
||
|
||
|
||
def set_images(
|
||
db: Session,
|
||
current: User | None,
|
||
order: HuyaExternalOrder,
|
||
slot: str,
|
||
value: Any,
|
||
*,
|
||
commit: bool = True,
|
||
) -> dict:
|
||
"""整槽替换图片(前端上传完拿到 object_key 后一次性提交)。"""
|
||
if slot not in IMAGE_SLOTS:
|
||
raise CollabError(f"未知图片槽位:{slot}", code="image_slot_unknown")
|
||
before = images_of(order, slot)
|
||
after = normalize_images(value, field=slot)
|
||
if before == after:
|
||
return {"slot": slot, "images": after}
|
||
setattr(order, slot, after)
|
||
_touch(order, current)
|
||
record_audit(
|
||
db,
|
||
current,
|
||
action="huya:collab:image",
|
||
target=_audit_target(order),
|
||
detail={
|
||
"slot": slot,
|
||
"slot_label": IMAGE_SLOT_LABELS[slot],
|
||
"added": [key for key in after if key not in before],
|
||
"removed": [key for key in before if key not in after],
|
||
"order_no": order.order_no,
|
||
},
|
||
)
|
||
if commit:
|
||
db.commit()
|
||
db.refresh(order)
|
||
return {"slot": slot, "images": after}
|
||
|
||
|
||
# --------------------------------------------------------------------------
|
||
# CK 探针(S5,只读验证)
|
||
# --------------------------------------------------------------------------
|
||
|
||
# 探针参数与 web/backend/huya_defaults.py 的默认活动一致(2026-10-16 真机实测)。
|
||
PROBE_BIND_ACT_ID = int(HUYA_DEFAULT_BIND_ACT_ID)
|
||
PROBE_OUTER_ACT_ID = HUYA_DEFAULT_OUTER_ACT_ID
|
||
PROBE_TIMEOUT_SECONDS = 15.0
|
||
|
||
# 探针独占的绑定快照列(每次验证**覆盖写入**,人工不编辑):
|
||
# 「这单现在到底绑了哪个游戏账号、哪个角色」只认这几列。
|
||
PROBE_SNAPSHOT_FIELDS: tuple[str, ...] = (
|
||
"bind_game_account",
|
||
"bind_game_role",
|
||
"bind_game_channel",
|
||
)
|
||
|
||
# 活动礼包(get_act_prize_list)用的 SID **兜底值**:真正该用的是工作台配置里的那个
|
||
# (``huya_config.sid``,见 :func:`resolve_gifts_sid`)。
|
||
# 2026-10-16 真机定论:**SID 就是活动 ID,会随活动轮换** —— 库里 9 月的活动是 2203,
|
||
# 今天是 2277。写死默认值时接口照样 ``status=200`` 但 **0 个商品**,界面上就是
|
||
# 「礼包列表空白」(换 SID 立刻回到 30 个礼包,跟 CK、UA、解析都无关)。
|
||
GIFTS_SID = int(HUYA_DEFAULT_SID)
|
||
# 一次最多回多少个礼包(避免前端下拉塞几百项)。
|
||
GIFTS_LIMIT = 200
|
||
# 同一份 CK 的探针结论缓存时长("预览一次 + 提交校验一次" 只打一次接口)。
|
||
PROBE_CACHE_TTL_SECONDS = 90.0
|
||
_probe_cache: dict[str, tuple[float, CkProbeResult]] = {}
|
||
|
||
# 批量验证**串行 + 间隔**:不做定时轮询,一次也别勾太多,避免把账号送进风控名单。
|
||
PROBE_BATCH_INTERVAL_SECONDS = 1.0
|
||
PROBE_BATCH_MAX = 50
|
||
|
||
_CK_MESSAGE_LIMIT = 256 # ck_check_message 列宽
|
||
_PROBE_PIECE_LIMIT = 40 # 拼消息时单段文本的上限
|
||
|
||
|
||
def _clip(value: Any, limit: int = _PROBE_PIECE_LIMIT) -> str:
|
||
"""把探针文本裁到可读长度(``ck_check_message`` 只有 256 字符)。"""
|
||
text = str(value if value is not None else "").strip()
|
||
return text if len(text) <= limit else text[: limit - 3] + "..."
|
||
|
||
|
||
def _probe_cache_key(uid: int, cookie: str) -> str:
|
||
"""探针缓存键:uid + CK 的指纹(只存哈希,不留凭据原文)。"""
|
||
digest = hashlib.sha256(f"{uid}|{cookie}".encode()).hexdigest()
|
||
return digest
|
||
|
||
|
||
@dataclasses.dataclass(frozen=True)
|
||
class CkProbeResult:
|
||
"""一次只读探针的结论(不落库;落库由 :func:`verify_ck` 负责)。"""
|
||
|
||
ok: bool
|
||
message: str
|
||
snapshot: dict[str, str] = dataclasses.field(default_factory=dict)
|
||
|
||
|
||
def _build_probe_client() -> Any:
|
||
"""探针客户端(HTTP cdnws 只读通道)。
|
||
|
||
只做只读 RPC:**不建 ``HuyaAccount``、不 import 任何 runner**(设计文档 §2.3)。
|
||
测试替换这个工厂就能离线跑,不真连虎牙。
|
||
"""
|
||
from core.huya.http_client import HuyaHttpClient
|
||
|
||
return HuyaHttpClient(
|
||
logger=lambda message: logger.debug(f"[huya-collab] {message}")
|
||
)
|
||
|
||
|
||
def _probe_snapshot(resp: Any) -> dict[str, str]:
|
||
"""探针读到的**绑定对象**:绑定在该虎牙号上的游戏账号 + 角色。
|
||
|
||
2026-10-16 真机确认:``check_user_bind_game_account`` 返回的是**游戏账号**
|
||
(账号昵称 / 角色名 / 平台·区服),**不是虎牙号自己的昵称** —— 虎牙昵称要
|
||
`get_grow_level_user_info`,可那个接口不认凭据、当不了探针,所以 ``nickname``
|
||
一个字段都不碰(那是「虎牙号昵称」,硬写就是假数据)。
|
||
|
||
这几个列由探针**独占并覆盖写入**(``bind_snapshot_at`` 记读取时间):
|
||
"这单现在到底绑了哪个号、哪个角色"只有这里可信(人工填的是自由文本)。
|
||
"""
|
||
account_data = getattr(resp, "accountData", None)
|
||
game_account = getattr(account_data, "gameAccount", None)
|
||
game_role = getattr(account_data, "gameRole", None)
|
||
|
||
snapshot: dict[str, str] = dict.fromkeys(PROBE_SNAPSHOT_FIELDS, "")
|
||
account_name = _clip(getattr(game_account, "nick", "")) or _clip(
|
||
getattr(game_role, "roleName", "")
|
||
)
|
||
if account_name:
|
||
snapshot["bind_game_account"] = account_name
|
||
role_name = _clip(getattr(game_role, "roleName", ""))
|
||
if role_name:
|
||
snapshot["bind_game_role"] = role_name
|
||
channel = " · ".join(
|
||
piece
|
||
for piece in (
|
||
_clip(getattr(game_role, "platName", "")),
|
||
_clip(getattr(game_role, "areaName", "")),
|
||
)
|
||
if piece
|
||
)
|
||
if channel:
|
||
snapshot["bind_game_channel"] = channel
|
||
return snapshot
|
||
|
||
|
||
def _probe_success_message(resp: Any) -> str:
|
||
"""成功时的可读结论(列表上直接能看到探针读到的绑定对象)。"""
|
||
account_data = getattr(resp, "accountData", None)
|
||
game_account = getattr(account_data, "gameAccount", None)
|
||
game_role = getattr(account_data, "gameRole", None)
|
||
|
||
parts: list[str] = []
|
||
nick = _clip(getattr(game_account, "nick", ""))
|
||
role_name = _clip(getattr(game_role, "roleName", ""))
|
||
if nick:
|
||
parts.append(f"账号 {nick}")
|
||
if role_name:
|
||
parts.append(f"角色 {role_name}")
|
||
channel = " · ".join(
|
||
piece
|
||
for piece in (
|
||
_clip(getattr(game_role, "platName", "")),
|
||
_clip(getattr(game_role, "areaName", "")),
|
||
)
|
||
if piece
|
||
)
|
||
if channel:
|
||
parts.append(channel)
|
||
detail = " / ".join(parts)
|
||
return (f"有效:{detail}" if detail else "有效")[:_CK_MESSAGE_LIMIT]
|
||
|
||
|
||
def probe_ck(order: HuyaExternalOrder, *, client: Any | None = None) -> CkProbeResult:
|
||
"""只读探针:这份 CK 还能不能过(不写库、不发敏感动作)。
|
||
|
||
判据(2026-10-16 真机对照确定,**别改成「有数据就算成功」**):
|
||
响应**非 None 且 ``status == 200``**。
|
||
|
||
实测支持这个判据:裸 cookie(无任何凭据)+ 真 uid → 返回 ``None``;
|
||
真 CK + 别人的 uid → 也返回 ``None``;只有「真 CK + 真 uid」才拿到 200 + 真实数据。
|
||
|
||
同一个 CK 在 ``PROBE_CACHE_TTL_SECONDS`` 秒内复用上次结论:新建弹窗先"预览"、
|
||
提交时再校验,不该真的打两次虎牙接口。**注入 client 时不走缓存**(测试要真结论)。
|
||
"""
|
||
uid_text = str(order.uid or "").strip()
|
||
cookie = str(order.cookie or "").strip()
|
||
if not uid_text or not cookie:
|
||
return CkProbeResult(False, "这行还没有可验证的 CK(uid 或凭据为空)")
|
||
try:
|
||
uid = int(uid_text)
|
||
except ValueError:
|
||
return CkProbeResult(False, f"CK 里的 uid 不是数字:{_clip(uid_text)}")
|
||
|
||
cache_key = _probe_cache_key(uid, cookie) if client is None else ""
|
||
if cache_key:
|
||
cached = _probe_cache.get(cache_key)
|
||
if cached and time.time() - cached[0] < PROBE_CACHE_TTL_SECONDS:
|
||
return cached[1]
|
||
|
||
try:
|
||
probe_client = client or _build_probe_client()
|
||
resp = probe_client.check_user_bind_game_account(
|
||
uid=uid,
|
||
cookie=cookie,
|
||
b_act_id=PROBE_BIND_ACT_ID,
|
||
is_use_outer_act_id=1,
|
||
outer_act_id=PROBE_OUTER_ACT_ID,
|
||
timeout=PROBE_TIMEOUT_SECONDS,
|
||
)
|
||
except Exception as exc: # noqa: BLE001 探针异常=验证不通过,不能让接口 500
|
||
logger.debug(f"[huya-collab] CK 探针异常:{type(exc).__name__}: {exc}")
|
||
return CkProbeResult(False, f"探针调用失败:{type(exc).__name__}: {_clip(exc)}")
|
||
|
||
if resp is None:
|
||
return CkProbeResult(False, "探针没有返回数据(凭据无效或已被风控)")
|
||
status = int(getattr(resp, "status", 0) or 0)
|
||
if status != 200:
|
||
msg = _clip(getattr(resp, "msg", ""))
|
||
suffix = f":{msg}" if msg else ""
|
||
return CkProbeResult(False, f"探针返回 status={status}{suffix}")
|
||
result = CkProbeResult(True, _probe_success_message(resp), _probe_snapshot(resp))
|
||
if cache_key:
|
||
_probe_cache[cache_key] = (time.time(), result)
|
||
return result
|
||
|
||
|
||
def resolve_gifts_sid(db: Session) -> int:
|
||
"""当前活动 SID:**优先用工作台配置**(``huya_config.sid``),读不到再回落到默认值。
|
||
|
||
2026-10-16 真机定位:礼包接口的 SID 是「活动 ID」,会随活动轮换(9 月 2203 → 今天 2277),
|
||
对旧 SID 打过去是 ``status=200`` + 0 个商品 —— 界面上就表现为「礼包列表空白」。
|
||
工作台「虎牙配置」里改过 SID 之后,这里跟着变,不用改代码。
|
||
|
||
只读:配置行不存在时**不会**顺手建一行(预览是只读动作,设计文档 §2.3)。
|
||
"""
|
||
try:
|
||
row = db.query(HuyaConfig).first()
|
||
except Exception as exc: # noqa: BLE001 配置读失败不该让预览 500
|
||
logger.debug(
|
||
f"[huya-collab] 读取虎牙活动 SID 失败:{type(exc).__name__}: {exc}"
|
||
)
|
||
return GIFTS_SID
|
||
try:
|
||
value = int(str(getattr(row, "sid", "") or "").strip())
|
||
except (TypeError, ValueError):
|
||
return GIFTS_SID
|
||
return value or GIFTS_SID
|
||
|
||
|
||
def fetch_activity_gifts(
|
||
uid: int,
|
||
cookie: str,
|
||
*,
|
||
sid: int | None = None,
|
||
client: Any | None = None,
|
||
) -> tuple[list[dict], str]:
|
||
"""用这份 CK 拉**活动可兑换礼包**列表(只读,不落库)。
|
||
|
||
``sid`` 不传就用 :data:`GIFTS_SID` 兜底;接口层会传工作台配置里的活动 ID
|
||
(:func:`resolve_gifts_sid`)。
|
||
|
||
Returns:
|
||
``(礼包列表, 可读结论)`` —— 每项是 ``{product_id, name, price, ...}``;
|
||
拉不到时返回 ``([], "…")``,不影响 CK 本身的有效性结论。
|
||
"""
|
||
sid_value = int(sid or GIFTS_SID)
|
||
try:
|
||
gift_client = client or _build_probe_client()
|
||
resp = gift_client.get_act_prize_list(
|
||
uid=int(uid), cookie=cookie, sid=sid_value
|
||
)
|
||
except Exception as exc: # noqa: BLE001 拉礼包失败不该让整个预览 500
|
||
logger.debug(f"[huya-collab] 拉取活动礼包异常:{type(exc).__name__}: {exc}")
|
||
return [], f"拉取礼包失败:{type(exc).__name__}: {_clip(exc)}"
|
||
|
||
if resp is None:
|
||
return [], "拉取礼包失败:接口没有返回数据"
|
||
status = int(getattr(resp, "status", 0) or 0)
|
||
if status != 200:
|
||
msg = _clip(getattr(resp, "msg", ""))
|
||
return [], f"拉取礼包失败:status={status}{(':' + msg) if msg else ''}"
|
||
|
||
raw_goods = (resp.to_dict() or {}).get("goods") or []
|
||
gifts: list[dict] = []
|
||
for item in raw_goods:
|
||
product_id = str(item.get("product_id") or "").strip()
|
||
name = str(item.get("name") or "").strip()
|
||
if not product_id or not name:
|
||
continue
|
||
gifts.append(
|
||
{
|
||
"product_id": product_id,
|
||
"name": name[:256],
|
||
"price": item.get("price"),
|
||
"remain_text": str(item.get("remain_text") or "")[:64],
|
||
"stock": item.get("stock"),
|
||
"score": item.get("score"),
|
||
"category_name": str(item.get("category_name") or "")[:64],
|
||
"icon": str(item.get("icon") or "")[:512],
|
||
}
|
||
)
|
||
gifts = gifts[:GIFTS_LIMIT]
|
||
if not gifts:
|
||
# 200 + 空列表 = 这个 SID 不是当前活动(见 resolve_gifts_sid 的真机定位)。
|
||
return [], (
|
||
f"活动 SID={sid_value} 没返回商品:接口通了但列表是空的,"
|
||
"多半是活动换 ID 了 —— 去工作台「虎牙配置」把活动 SID 改成当前的再试"
|
||
)
|
||
return gifts, f"已拉取 {len(gifts)} 个礼包"
|
||
|
||
|
||
# --------------------------------------------------------------------------
|
||
# 账号全生命周期(积分 / 兑换记录:抢购之后靠它判断到底兑换成功没有)
|
||
# --------------------------------------------------------------------------
|
||
|
||
ACTIVITY_QUERY_TIMEOUT_SECONDS = 15.0
|
||
|
||
VERDICT_SUCCESS = "success"
|
||
VERDICT_FAILED = "failed"
|
||
VERDICT_UNKNOWN = "unknown"
|
||
|
||
VERDICT_LABELS = {
|
||
VERDICT_SUCCESS: "兑换成功",
|
||
VERDICT_FAILED: "兑换失败",
|
||
VERDICT_UNKNOWN: "无法判定",
|
||
}
|
||
|
||
# metric.raw 里最多留多少条兑换记录(避免 JSON 列被几百条记录撑爆)
|
||
_METRIC_RECORD_LIMIT = 50
|
||
# 兑换记录里跟订单礼包名比对时,只取有意义的长度(太短的名字容易误匹配)
|
||
_MIN_GIFT_MATCH_LENGTH = 2
|
||
|
||
|
||
def _metric_message(text: Any, limit: int = 480) -> str:
|
||
piece = str(text or "").strip()
|
||
return piece if len(piece) <= limit else piece[: limit - 3] + "..."
|
||
|
||
|
||
def gift_matches_records(
|
||
records: Sequence[Mapping[str, Any]], gift_name: str
|
||
) -> list[dict[str, Any]]:
|
||
"""在兑换记录里找这一单的礼包(名字完全相等或互相包含都算)。
|
||
|
||
虎牙记录里的 ``prize_name`` 与活动列表里的名字基本一字不差,但偶尔带后缀/前缀,
|
||
所以用「互相包含」宽松匹配;太短的名字(<2 字)不参与,免得 "礼包" 两个字匹配一堆。
|
||
"""
|
||
wanted = str(gift_name or "").strip()
|
||
if len(wanted) < _MIN_GIFT_MATCH_LENGTH:
|
||
return []
|
||
matched: list[dict[str, Any]] = []
|
||
for record in records:
|
||
name = str(record.get("prize_name") or "").strip()
|
||
if not name:
|
||
continue
|
||
if name == wanted or wanted in name or name in wanted:
|
||
matched.append(dict(record))
|
||
return matched
|
||
|
||
|
||
def _extract_score(resp: Any) -> tuple[int | None, int | None, int | None, str]:
|
||
"""从 ``getUserScore`` 响应里取「可用 / 累计获得 / 已消耗」+ 可读结论。"""
|
||
if resp is None:
|
||
return None, None, None, "积分接口没有返回数据"
|
||
status = int(getattr(resp, "status", 0) or 0)
|
||
if status != 200:
|
||
msg = _clip(getattr(resp, "msg", ""))
|
||
return (
|
||
None,
|
||
None,
|
||
None,
|
||
f"积分接口 status={status}{(':' + msg) if msg else ''}",
|
||
)
|
||
available = getattr(resp, "available_score", None)
|
||
if available is None:
|
||
available = getattr(resp, "score", 0)
|
||
gained = int(getattr(resp, "newGainScore", 0) or 0) or int(
|
||
getattr(resp, "gainScore", 0) or 0
|
||
)
|
||
used = int(getattr(resp, "newUsedScore", 0) or 0) or int(
|
||
getattr(resp, "usedScore", 0) or 0
|
||
)
|
||
return int(available or 0), gained, used, ""
|
||
|
||
|
||
def _extract_records(resp: Any) -> tuple[list[dict[str, Any]], str]:
|
||
"""从 ``getUserPrizeRecords`` 响应里取兑换记录列表 + 可读结论。"""
|
||
if resp is None:
|
||
return [], "兑换记录接口没有返回数据"
|
||
status = int(getattr(resp, "status", 0) or 0)
|
||
if status != 200:
|
||
msg = _clip(getattr(resp, "msg", ""))
|
||
return [], f"兑换记录接口 status={status}{(':' + msg) if msg else ''}"
|
||
data = resp.to_dict() or {}
|
||
return list(data.get("records") or []), ""
|
||
|
||
|
||
def serialize_metric(
|
||
metric: HuyaExternalOrderMetric, *, users: Mapping[int, str] | None = None
|
||
) -> dict:
|
||
"""一条生命周期快照的输出(时间线用)。"""
|
||
names = users or {}
|
||
raw = metric.raw if isinstance(metric.raw, dict) else {}
|
||
matched = raw.get("matched") if isinstance(raw.get("matched"), list) else []
|
||
return {
|
||
"id": int(metric.id),
|
||
"order_id": int(metric.order_id),
|
||
"order_no": metric.order_no or "",
|
||
"uid": metric.uid or "",
|
||
"sid": metric.sid,
|
||
"score": metric.score,
|
||
"gained_score": metric.gained_score,
|
||
"used_score": metric.used_score,
|
||
"records_total": int(metric.records_total or 0),
|
||
"records_matched": int(metric.records_matched or 0),
|
||
"verdict": metric.verdict or VERDICT_UNKNOWN,
|
||
"verdict_label": VERDICT_LABELS.get(metric.verdict, metric.verdict or ""),
|
||
"message": metric.message or "",
|
||
"source": metric.source or "manual",
|
||
"matched_preview": [
|
||
{
|
||
"prize_name": str(item.get("prize_name") or ""),
|
||
"score": item.get("score"),
|
||
"status_label": str(item.get("status_label") or ""),
|
||
"order_id": str(item.get("order_id") or ""),
|
||
"exchange_time": item.get("exchange_time"),
|
||
}
|
||
for item in matched[:5]
|
||
],
|
||
"created_by": metric.created_by,
|
||
"created_by_name": names.get(int(metric.created_by or 0), ""),
|
||
"created_at": metric.created_at,
|
||
}
|
||
|
||
|
||
def list_metrics(
|
||
db: Session, order: HuyaExternalOrder, *, limit: int = 100
|
||
) -> list[HuyaExternalOrderMetric]:
|
||
"""某个订单的生命周期快照(**倒序**:最近一次在前,界面直接铺时间线)。"""
|
||
return (
|
||
db.query(HuyaExternalOrderMetric)
|
||
.filter(HuyaExternalOrderMetric.order_id == int(order.id))
|
||
.order_by(HuyaExternalOrderMetric.id.desc())
|
||
.limit(max(1, min(500, int(limit))))
|
||
.all()
|
||
)
|
||
|
||
|
||
def latest_metrics(
|
||
db: Session, orders: Sequence[HuyaExternalOrder]
|
||
) -> dict[int, HuyaExternalOrderMetric]:
|
||
"""一次查出这批行各自「最近一条」快照(列表要显示最新积分,别按行发 SQL)。"""
|
||
ids = [int(order.id) for order in orders]
|
||
if not ids:
|
||
return {}
|
||
newest = (
|
||
db.query(func.max(HuyaExternalOrderMetric.id).label("metric_id"))
|
||
.filter(HuyaExternalOrderMetric.order_id.in_(ids))
|
||
.group_by(HuyaExternalOrderMetric.order_id)
|
||
.subquery()
|
||
)
|
||
metric_ids = [row[0] for row in db.query(newest.c.metric_id).all()]
|
||
if not metric_ids:
|
||
return {}
|
||
rows = (
|
||
db.query(HuyaExternalOrderMetric)
|
||
.filter(HuyaExternalOrderMetric.id.in_(metric_ids))
|
||
.all()
|
||
)
|
||
return {int(row.order_id): row for row in rows}
|
||
|
||
|
||
def query_order_activity(
|
||
db: Session,
|
||
current: User | None,
|
||
order: HuyaExternalOrder,
|
||
*,
|
||
client: Any | None = None,
|
||
source: str = "manual",
|
||
commit: bool = True,
|
||
) -> dict:
|
||
"""查一次这个账号的**积分 + 兑换记录**,落一条生命周期快照,必要时自动改状态。
|
||
|
||
判定规则(2026-10-16 用户口径):
|
||
|
||
| 情况 | verdict | 业务状态 |
|
||
| --- | --- | --- |
|
||
| 兑换记录里有这一单的礼包 | ``success`` | 「已上脚本」的单 → 自动「抢购成功」 |
|
||
| 接口通了,但记录里没有这个礼包 | ``failed`` | 「已上脚本」的单 → 自动「抢购失败」 |
|
||
| 接口没通 / 这单没填礼包名 | ``unknown`` | **不动**(数据不够,不许瞎判) |
|
||
|
||
只有 ``已上脚本`` 的单会被自动改状态 —— 抢购是上脚本之后的事,其它阶段去改属于误判;
|
||
被跳过时结论里会写清楚原因。每次调用都写一条 metric(哪怕接口失败),
|
||
「积分变化 + 兑换记录」两条线索合起来才看得清有没有真的抢到。
|
||
"""
|
||
if order.status == STATUS_VOID:
|
||
raise CollabError("已作废的单子不查积分/兑换记录", code="void_order")
|
||
uid = str(order.uid or "").strip()
|
||
cookie = str(order.cookie or "").strip()
|
||
if not uid or not cookie:
|
||
raise CollabError("这行还没有 CK,先贴一份再查积分/兑换记录", code="no_cookie")
|
||
|
||
sid = resolve_gifts_sid(db)
|
||
activity_client = client or _build_probe_client()
|
||
score_resp: Any = None
|
||
records_resp: Any = None
|
||
errors: list[str] = []
|
||
try:
|
||
score_resp = activity_client.query_user_score(
|
||
uid=int(uid), cookie=cookie, sid=sid, timeout=ACTIVITY_QUERY_TIMEOUT_SECONDS
|
||
)
|
||
except Exception as exc: # noqa: BLE001 一个接口炸了不影响另一个
|
||
errors.append(f"积分接口异常:{type(exc).__name__}: {_clip(exc)}")
|
||
try:
|
||
records_resp = activity_client.get_user_prize_records(
|
||
uid=int(uid), cookie=cookie, sid=sid, timeout=ACTIVITY_QUERY_TIMEOUT_SECONDS
|
||
)
|
||
except Exception as exc: # noqa: BLE001
|
||
errors.append(f"兑换记录接口异常:{type(exc).__name__}: {_clip(exc)}")
|
||
|
||
score, gained, used, score_msg = _extract_score(score_resp)
|
||
records, records_msg = _extract_records(records_resp)
|
||
matched = gift_matches_records(records, order.gift_name)
|
||
|
||
if not str(order.gift_name or "").strip():
|
||
verdict = VERDICT_UNKNOWN
|
||
message = "这单没填礼包名,没法跟兑换记录比对"
|
||
elif records_msg or errors:
|
||
verdict = VERDICT_UNKNOWN
|
||
message = (
|
||
";".join([*errors, records_msg]).strip(";") or "兑换记录没取到,无法判定"
|
||
)
|
||
elif matched:
|
||
verdict = VERDICT_SUCCESS
|
||
latest = matched[0]
|
||
message = (
|
||
f"兑换记录里查到 {len(matched)} 条匹配「{order.gift_name}」"
|
||
f"(最近一条:{latest.get('status_label') or '状态未知'}"
|
||
f"{(',单号 ' + str(latest.get('order_id'))) if latest.get('order_id') else ''})"
|
||
)
|
||
else:
|
||
verdict = VERDICT_FAILED
|
||
message = (
|
||
f"兑换记录里没有「{order.gift_name}」"
|
||
f"(当前共 {len(records)} 条记录,都没有匹配上)"
|
||
)
|
||
|
||
auto_status = ""
|
||
if verdict == VERDICT_SUCCESS:
|
||
target = STATUS_DONE
|
||
elif verdict == VERDICT_FAILED:
|
||
target = STATUS_FAILED
|
||
else:
|
||
target = ""
|
||
if target and order.status == STATUS_SCRIPTED:
|
||
set_status(db, current, order, target, commit=False)
|
||
auto_status = target
|
||
elif target:
|
||
message = (
|
||
f"{message}(当前状态是「{ORDER_STATUS_LABELS.get(order.status, order.status)}」,"
|
||
f"只有「{ORDER_STATUS_LABELS[STATUS_SCRIPTED]}」的单才会按查询结果自动改状态)"
|
||
)
|
||
|
||
raw: dict[str, Any] = {
|
||
"verdict": verdict,
|
||
"score_available": score,
|
||
"score_gained": gained,
|
||
"score_used": used,
|
||
"records_count": len(records),
|
||
"matched": matched[:5],
|
||
"records": records[:_METRIC_RECORD_LIMIT],
|
||
}
|
||
if score_resp is not None:
|
||
try:
|
||
raw["score_response"] = score_resp.to_dict()
|
||
except Exception as exc: # noqa: BLE001 没有 to_dict 的假客户端(测试)就不存原始体
|
||
logger.debug(f"[huya-collab] 积分原始体不入库:{type(exc).__name__}: {exc}")
|
||
|
||
metric = HuyaExternalOrderMetric(
|
||
order_id=int(order.id),
|
||
order_no=order.order_no or "",
|
||
uid=uid,
|
||
sid=sid,
|
||
score=score,
|
||
gained_score=gained,
|
||
used_score=used,
|
||
records_total=len(records),
|
||
records_matched=len(matched),
|
||
verdict=verdict,
|
||
message=_metric_message(
|
||
";".join(piece for piece in (score_msg, message) if piece)
|
||
),
|
||
source=source,
|
||
raw=raw,
|
||
created_by=current.id if current else None,
|
||
)
|
||
db.add(metric)
|
||
db.flush()
|
||
record_audit(
|
||
db,
|
||
current,
|
||
action="huya:collab:activity_query",
|
||
target=_audit_target(order),
|
||
detail={
|
||
"order_no": order.order_no,
|
||
"uid": uid,
|
||
"sid": sid,
|
||
"source": source,
|
||
"score": score,
|
||
"records_total": len(records),
|
||
"records_matched": len(matched),
|
||
"verdict": verdict,
|
||
"auto_status": auto_status,
|
||
},
|
||
)
|
||
if commit:
|
||
db.commit()
|
||
db.refresh(order)
|
||
db.refresh(metric)
|
||
return {
|
||
"ok": verdict != VERDICT_UNKNOWN,
|
||
"order_id": int(order.id),
|
||
"order_no": order.order_no,
|
||
"uid": uid,
|
||
"sid": sid,
|
||
"verdict": verdict,
|
||
"verdict_label": VERDICT_LABELS.get(verdict, verdict),
|
||
"score": score,
|
||
"gained_score": gained,
|
||
"used_score": used,
|
||
"records_total": len(records),
|
||
"records_matched": len(matched),
|
||
"message": metric.message,
|
||
"auto_status": auto_status,
|
||
"auto_status_label": ORDER_STATUS_LABELS.get(auto_status, auto_status),
|
||
"status": order.status,
|
||
"status_label": ORDER_STATUS_LABELS.get(order.status, order.status),
|
||
"metric": serialize_metric(metric),
|
||
}
|
||
|
||
|
||
def query_activity_batch(
|
||
db: Session,
|
||
current: User | None,
|
||
orders: Sequence[HuyaExternalOrder],
|
||
*,
|
||
client: Any | None = None,
|
||
interval_seconds: float = PROBE_BATCH_INTERVAL_SECONDS,
|
||
) -> dict:
|
||
"""勾选批量查询积分 / 兑换记录:**串行 + 间隔**(防风控),单条失败不影响其它。"""
|
||
rows = list(orders)
|
||
if not rows:
|
||
raise CollabError("请先勾选要查询的行", code="activity_batch_empty")
|
||
if len(rows) > PROBE_BATCH_MAX:
|
||
raise CollabError(
|
||
f"一次最多查 {PROBE_BATCH_MAX} 行,请分批查询",
|
||
code="activity_batch_too_large",
|
||
)
|
||
|
||
results: list[dict] = []
|
||
for index, order in enumerate(rows):
|
||
if index and interval_seconds > 0:
|
||
time.sleep(interval_seconds)
|
||
try:
|
||
with db.begin_nested():
|
||
results.append(
|
||
query_order_activity(
|
||
db, current, order, client=client, source="batch", commit=False
|
||
)
|
||
)
|
||
except CollabError as exc:
|
||
results.append(
|
||
{
|
||
"ok": False,
|
||
"order_id": int(order.id),
|
||
"order_no": order.order_no,
|
||
"verdict": VERDICT_UNKNOWN,
|
||
"verdict_label": VERDICT_LABELS[VERDICT_UNKNOWN],
|
||
"message": exc.detail,
|
||
"auto_status": "",
|
||
"status": order.status,
|
||
"status_label": ORDER_STATUS_LABELS.get(order.status, order.status),
|
||
}
|
||
)
|
||
db.commit()
|
||
for order in rows:
|
||
db.refresh(order)
|
||
valid = [item for item in results if item.get("verdict") == VERDICT_SUCCESS]
|
||
failed = [item for item in results if item.get("verdict") == VERDICT_FAILED]
|
||
unknown = [item for item in results if item.get("verdict") == VERDICT_UNKNOWN]
|
||
auto_changed = [item for item in results if item.get("auto_status")]
|
||
return {
|
||
"total": len(results),
|
||
"success": len(valid),
|
||
"failed": len(failed),
|
||
"unknown": len(unknown),
|
||
"auto_changed": len(auto_changed),
|
||
"results": results,
|
||
}
|
||
|
||
|
||
def preview_ck(
|
||
ck_text: Any,
|
||
*,
|
||
with_gifts: bool = True,
|
||
sid: int | None = None,
|
||
client: Any | None = None,
|
||
) -> dict:
|
||
"""**只读预览**一份待提交的 CK:解析 + 探针(+ 顺带拉活动礼包),一行都不写库。
|
||
|
||
新建协作单时用它给出「CK 有效 + 绑定角色」的即时反馈;提交时 :func:`create_order`
|
||
还会再校验一次(同一份 CK 命中探针缓存,不会重复打接口)。
|
||
|
||
``sid`` = 活动 ID(接口层传工作台配置里的那个,见 :func:`resolve_gifts_sid`):
|
||
它决定礼包列表能不能拉到,**不影响 CK 有效性结论**。
|
||
"""
|
||
parsed = parse_ck(str(ck_text or ""))
|
||
uid_text = str(parsed.get("uid") or "")
|
||
cookie = str(parsed.get("cookie") or "")
|
||
|
||
probe_target = HuyaExternalOrder(order_no="preview")
|
||
probe_target.uid = uid_text
|
||
probe_target.cookie = cookie
|
||
result = probe_ck(probe_target, client=client)
|
||
|
||
gifts: list[dict] = []
|
||
gifts_message = ""
|
||
if with_gifts and result.ok and uid_text:
|
||
gifts, gifts_message = fetch_activity_gifts(
|
||
int(uid_text), cookie, sid=sid, client=client
|
||
)
|
||
return {
|
||
"ok": result.ok,
|
||
"message": result.message,
|
||
"uid": uid_text,
|
||
"yyuid": str(parsed.get("yyuid") or ""),
|
||
"hy_username": str(parsed.get("username") or ""),
|
||
"ck_status": CK_VALID if result.ok else CK_INVALID,
|
||
"bind_game_account": result.snapshot.get("bind_game_account", ""),
|
||
"bind_game_role": result.snapshot.get("bind_game_role", ""),
|
||
"bind_game_channel": result.snapshot.get("bind_game_channel", ""),
|
||
"has_role": bool(result.snapshot.get("bind_game_role")),
|
||
"gifts": gifts,
|
||
"gifts_message": gifts_message,
|
||
}
|
||
|
||
|
||
def verify_ck(
|
||
db: Session,
|
||
current: User | None,
|
||
order: HuyaExternalOrder,
|
||
*,
|
||
client: Any | None = None,
|
||
commit: bool = True,
|
||
) -> dict:
|
||
"""跑一次只读探针并把结论落库。
|
||
|
||
- 结论**只**通过 :func:`set_ck_status` 写(``ck_status`` 的唯一写入口,审计不会丢);
|
||
- 成功时**覆盖写入**绑定快照(``bind_game_account`` / ``bind_game_role`` /
|
||
``bind_game_channel`` + ``bind_snapshot_at``):这几列是探针独占的"现在绑了谁",
|
||
覆盖前会把旧值记进 ``huya:collab:probe_snapshot`` 审计,出问题能查回来;
|
||
- 人工填的那些列(含 ``bind_douyu_account``)**一律不碰**;
|
||
- CK 失效**不改业务状态**(设计文档 §4.2:已绑定/已充值的单子业务上可能仍然有效)。
|
||
"""
|
||
if not str(order.cookie or "").strip():
|
||
raise CollabError("这行还没有 CK,先贴一份再验证", code="no_cookie")
|
||
if order.status == STATUS_VOID:
|
||
raise CollabError(
|
||
"已作废的行不验证 CK(要重新用请先改回流程内状态)", code="void_order"
|
||
)
|
||
|
||
result = probe_ck(order, client=client)
|
||
binding: dict[str, str] = {
|
||
field_name: str(getattr(order, field_name) or "")
|
||
for field_name in PROBE_SNAPSHOT_FIELDS
|
||
}
|
||
if result.ok:
|
||
before = dict(binding)
|
||
for field_name in PROBE_SNAPSHOT_FIELDS:
|
||
setattr(order, field_name, result.snapshot.get(field_name, ""))
|
||
order.bind_snapshot_at = _now()
|
||
binding = {
|
||
field_name: str(getattr(order, field_name) or "")
|
||
for field_name in PROBE_SNAPSHOT_FIELDS
|
||
}
|
||
if binding != before:
|
||
# 快照变了(或第一次读到)才记一条,别每次验证都刷两行审计。
|
||
record_audit(
|
||
db,
|
||
current,
|
||
action="huya:collab:probe_snapshot",
|
||
target=_audit_target(order),
|
||
detail={
|
||
"order_no": order.order_no,
|
||
"before": before,
|
||
"after": binding,
|
||
},
|
||
)
|
||
|
||
target = CK_VALID if result.ok else CK_INVALID
|
||
set_ck_status(db, current, order, target, message=result.message, commit=False)
|
||
if commit:
|
||
db.commit()
|
||
db.refresh(order)
|
||
return {
|
||
"id": order.id,
|
||
"order_no": order.order_no,
|
||
"ok": result.ok,
|
||
"ck_status": target,
|
||
"message": result.message,
|
||
"binding": binding,
|
||
}
|
||
|
||
|
||
def _probe_row_error(order: HuyaExternalOrder, message: str) -> dict:
|
||
"""批量验证里「这一行没跑成」的统一形状(状态保持原样,不谎报 invalid)。"""
|
||
return {
|
||
"id": order.id,
|
||
"order_no": order.order_no,
|
||
"ok": False,
|
||
"ck_status": order.ck_status,
|
||
"message": _clip(message, _CK_MESSAGE_LIMIT),
|
||
"binding": {
|
||
field_name: str(getattr(order, field_name) or "")
|
||
for field_name in PROBE_SNAPSHOT_FIELDS
|
||
},
|
||
}
|
||
|
||
|
||
def verify_ck_batch(
|
||
db: Session,
|
||
current: User | None,
|
||
orders: Sequence[HuyaExternalOrder],
|
||
*,
|
||
client: Any | None = None,
|
||
interval_seconds: float | None = None,
|
||
commit: bool = True,
|
||
) -> dict:
|
||
"""批量验证:**串行 + 间隔**(人工触发,不做定时轮询),单条失败不影响其他条。
|
||
|
||
Returns:
|
||
``{total, valid, failed, results: [{id, order_no, ok, ck_status, message, binding}]}``
|
||
"""
|
||
rows = list(orders)
|
||
if len(rows) > PROBE_BATCH_MAX:
|
||
raise CollabError(
|
||
f"一次最多验证 {PROBE_BATCH_MAX} 条,请分批勾选",
|
||
code="verify_batch_too_large",
|
||
)
|
||
interval = (
|
||
PROBE_BATCH_INTERVAL_SECONDS
|
||
if interval_seconds is None
|
||
else max(0.0, float(interval_seconds))
|
||
)
|
||
|
||
results: list[dict] = []
|
||
for index, order in enumerate(rows):
|
||
if index and interval:
|
||
time.sleep(interval)
|
||
try:
|
||
# 每行一个 SAVEPOINT:某行在写库阶段炸了也只脏这一行(同 import_orders)。
|
||
with db.begin_nested():
|
||
results.append(
|
||
verify_ck(db, current, order, client=client, commit=False)
|
||
)
|
||
db.flush()
|
||
except CollabError as exc:
|
||
results.append(_probe_row_error(order, exc.detail))
|
||
except Exception as exc: # noqa: BLE001 单条失败不该带崩整批
|
||
logger.debug(f"[huya-collab] 批量验证单行失败:{type(exc).__name__}: {exc}")
|
||
results.append(_probe_row_error(order, f"{type(exc).__name__}: {exc}"))
|
||
|
||
if commit:
|
||
db.commit()
|
||
valid = sum(1 for row in results if row["ok"])
|
||
return {
|
||
"total": len(results),
|
||
"valid": valid,
|
||
"failed": len(results) - valid,
|
||
"results": results,
|
||
}
|
||
|
||
|
||
def list_orders_by_ids(db: Session, ids: Sequence[int]) -> list[HuyaExternalOrder]:
|
||
"""按 id 取行(保持勾选顺序;已被别人删掉的 id 直接忽略)。"""
|
||
wanted = list(dict.fromkeys(int(value) for value in ids))
|
||
if not wanted:
|
||
raise CollabError("请先选择要验证的行", code="verify_empty")
|
||
if len(wanted) > PROBE_BATCH_MAX:
|
||
raise CollabError(
|
||
f"一次最多验证 {PROBE_BATCH_MAX} 条,请分批勾选",
|
||
code="verify_batch_too_large",
|
||
)
|
||
rows = db.query(HuyaExternalOrder).filter(HuyaExternalOrder.id.in_(wanted)).all()
|
||
by_id = {int(row.id): row for row in rows}
|
||
return [by_id[value] for value in wanted if value in by_id]
|
||
|
||
|
||
# 导出 CK 的审计动作名(导出=把凭据带出系统,必须留痕)
|
||
CK_EXPORT_ACTION = "huya:collab:ck_export"
|
||
|
||
|
||
def export_cks(
|
||
db: Session,
|
||
current: User | None,
|
||
orders: Sequence[HuyaExternalOrder],
|
||
*,
|
||
commit: bool = True,
|
||
) -> dict:
|
||
"""把勾选行的 **CK 明文**取出来(前端写剪贴板用)。
|
||
|
||
这是本模块唯一一个把凭据**往外送**的读接口,所以三条约束写死在这里:
|
||
|
||
1. **权限单独一个点**(``huya:collab:ck_export``,默认只给运营)—— 贴 CK 是「录入」,
|
||
导出是「把凭据带走」,两件事不该共用一个权限(口径同 ``huya:cookie:export``);
|
||
2. **逐行写审计**(``huya:collab:ck_export``):只记「谁在什么时候导出了哪一单」,
|
||
**绝不把 CK 本体写进审计/日志**(列表接口也照旧不返回 cookie);
|
||
3. **没 CK 的行显式跳过并回原因**,不静默丢;也不替用户猜 ——
|
||
作废/停用的单只要还有 CK 照样导出,界面把状态标出来让人自己判断。
|
||
|
||
Returns:
|
||
``{total, exported, skipped: [{id, order_no, reason}], items: [{..., ck_text}]}``
|
||
——只有这个接口会返回 ``ck_text``。
|
||
"""
|
||
rows = list(orders)
|
||
if not rows:
|
||
raise CollabError("请先勾选要导出的行", code="ck_export_empty")
|
||
if len(rows) > PROBE_BATCH_MAX:
|
||
raise CollabError(
|
||
f"一次最多导出 {PROBE_BATCH_MAX} 行,请分批操作", code="ck_export_too_large"
|
||
)
|
||
|
||
items: list[dict] = []
|
||
skipped: list[dict] = []
|
||
for order in rows:
|
||
ck_text = str(order.cookie or "").strip()
|
||
if not ck_text:
|
||
skipped.append(
|
||
{
|
||
"id": int(order.id),
|
||
"order_no": order.order_no,
|
||
"reason": "这行还没贴 CK",
|
||
}
|
||
)
|
||
continue
|
||
items.append(
|
||
{
|
||
"id": int(order.id),
|
||
"order_no": order.order_no,
|
||
"uid": str(order.uid or ""),
|
||
"nickname": order.nickname or "",
|
||
"status": order.status,
|
||
"status_label": ORDER_STATUS_LABELS.get(order.status, order.status),
|
||
"ck_status": order.ck_status,
|
||
"ck_status_label": CK_STATUS_LABELS.get(
|
||
order.ck_status, order.ck_status
|
||
),
|
||
"ck_text": ck_text,
|
||
}
|
||
)
|
||
record_audit(
|
||
db,
|
||
current,
|
||
action=CK_EXPORT_ACTION,
|
||
target=_audit_target(order),
|
||
detail={
|
||
"order_no": order.order_no,
|
||
"uid": str(order.uid or ""),
|
||
"ck_status": order.ck_status,
|
||
"batch_size": len(rows),
|
||
},
|
||
)
|
||
if commit:
|
||
db.commit()
|
||
return {
|
||
"total": len(rows),
|
||
"exported": len(items),
|
||
"skipped": skipped,
|
||
"items": items,
|
||
}
|
||
|
||
|
||
# --------------------------------------------------------------------------
|
||
# 店铺字典(新建协作单时点一下选;也支持自定义输入)
|
||
# --------------------------------------------------------------------------
|
||
|
||
SHOP_NAME_LIMIT = 64
|
||
|
||
|
||
def normalize_shop_name(value: Any) -> str:
|
||
"""店铺名统一收口:去空白 + 限长(空串 = 不填店铺)。"""
|
||
return clean_text(value, field="shop", max_length=SHOP_NAME_LIMIT)
|
||
|
||
|
||
def _shop_audit_target(shop: HuyaCollabShop) -> str:
|
||
return f"huya_collab_shop:{shop.id}"
|
||
|
||
|
||
def _touch_shop(shop: HuyaCollabShop, current: User | None) -> None:
|
||
shop.updated_by = current.id if current else None
|
||
shop.updated_at = _now()
|
||
|
||
|
||
def serialize_shop(
|
||
shop: HuyaCollabShop, *, users: Mapping[int, str] | None = None
|
||
) -> dict:
|
||
names = users or {}
|
||
|
||
def name_of(value: int | None) -> str:
|
||
return names.get(int(value), "") if value else ""
|
||
|
||
return {
|
||
"id": shop.id,
|
||
"name": shop.name or "",
|
||
"sort_order": int(shop.sort_order or 0),
|
||
"is_active": bool(shop.is_active),
|
||
"remark": shop.remark or "",
|
||
"created_by_name": name_of(shop.created_by),
|
||
"updated_by_name": name_of(shop.updated_by),
|
||
"created_at": shop.created_at,
|
||
"updated_at": shop.updated_at,
|
||
}
|
||
|
||
|
||
def list_shop_records(db: Session) -> list[HuyaCollabShop]:
|
||
"""全部店铺(**含停用的**:管理弹窗要能看到;新建下拉由前端按 `is_active` 过滤)。"""
|
||
return (
|
||
db.query(HuyaCollabShop)
|
||
.order_by(HuyaCollabShop.sort_order.asc(), HuyaCollabShop.name.asc())
|
||
.all()
|
||
)
|
||
|
||
|
||
def get_shop(db: Session, shop_id: int) -> HuyaCollabShop:
|
||
shop = db.query(HuyaCollabShop).filter(HuyaCollabShop.id == shop_id).first()
|
||
if shop is None:
|
||
raise CollabError(
|
||
"这个店铺不存在(可能已被别人删除)", status_code=404, code="shop_not_found"
|
||
)
|
||
return shop
|
||
|
||
|
||
def _assert_shop_name_available(
|
||
db: Session, name: str, *, exclude_id: int | None = None
|
||
) -> None:
|
||
query = db.query(HuyaCollabShop).filter(HuyaCollabShop.name == name)
|
||
if exclude_id is not None:
|
||
query = query.filter(HuyaCollabShop.id != exclude_id)
|
||
other = query.first()
|
||
if other is not None:
|
||
raise CollabError(
|
||
f"店铺「{name}」已经在字典里了(第 {other.id} 行)",
|
||
status_code=409,
|
||
code="shop_name_conflict",
|
||
)
|
||
|
||
|
||
def create_shop(
|
||
db: Session,
|
||
current: User | None,
|
||
*,
|
||
name: Any,
|
||
sort_order: int = 0,
|
||
remark: Any = "",
|
||
commit: bool = True,
|
||
) -> HuyaCollabShop:
|
||
"""新增一个店铺(重名 409)。"""
|
||
cleaned = normalize_shop_name(name)
|
||
if not cleaned:
|
||
raise CollabError("店铺名不能为空", code="shop_name_required")
|
||
_assert_shop_name_available(db, cleaned)
|
||
shop = HuyaCollabShop(
|
||
name=cleaned,
|
||
sort_order=int(sort_order or 0),
|
||
is_active=True,
|
||
remark=clean_text(remark, field="shop_remark", max_length=255),
|
||
created_by=current.id if current else None,
|
||
updated_by=current.id if current else None,
|
||
)
|
||
db.add(shop)
|
||
db.flush()
|
||
record_audit(
|
||
db,
|
||
current,
|
||
action="huya:collab:shop_create",
|
||
target=_shop_audit_target(shop),
|
||
detail={"name": shop.name, "sort_order": shop.sort_order},
|
||
)
|
||
if commit:
|
||
db.commit()
|
||
db.refresh(shop)
|
||
return shop
|
||
|
||
|
||
def update_shop(
|
||
db: Session,
|
||
current: User | None,
|
||
shop: HuyaCollabShop,
|
||
payload: Mapping[str, Any],
|
||
*,
|
||
rename_orders: bool = True,
|
||
commit: bool = True,
|
||
) -> dict:
|
||
"""改店铺(名字 / 排序 / 停用 / 备注),返回字段级 diff。
|
||
|
||
``rename_orders=True``(默认)时,改名会**级联**把已有订单里的旧名字一起改掉,
|
||
否则筛选会出现"改名前的老名字"这一堆孤儿值;不想要级联就显式传 False。
|
||
"""
|
||
changes: dict[str, dict[str, Any]] = {}
|
||
old_name = shop.name or ""
|
||
renamed_orders = 0
|
||
|
||
if "name" in payload:
|
||
new_name = normalize_shop_name(payload["name"])
|
||
if not new_name:
|
||
raise CollabError("店铺名不能为空", code="shop_name_required")
|
||
if new_name != old_name:
|
||
_assert_shop_name_available(db, new_name, exclude_id=shop.id)
|
||
changes["name"] = {"old": old_name, "new": new_name}
|
||
shop.name = new_name
|
||
|
||
if "sort_order" in payload:
|
||
value = int(payload["sort_order"] or 0)
|
||
if value != int(shop.sort_order or 0):
|
||
changes["sort_order"] = {"old": int(shop.sort_order or 0), "new": value}
|
||
shop.sort_order = value
|
||
|
||
if "is_active" in payload:
|
||
value = bool(payload["is_active"])
|
||
if value != bool(shop.is_active):
|
||
changes["is_active"] = {"old": bool(shop.is_active), "new": value}
|
||
shop.is_active = value
|
||
|
||
if "remark" in payload:
|
||
value = clean_text(payload["remark"], field="shop_remark", max_length=255)
|
||
if value != (shop.remark or ""):
|
||
changes["remark"] = {"old": shop.remark or "", "new": value}
|
||
shop.remark = value
|
||
|
||
if not changes:
|
||
return {}
|
||
|
||
if "name" in changes and rename_orders:
|
||
# 批量改名:订单里存的是名字字符串(见 models.HuyaCollabShop 的取舍说明)。
|
||
renamed_orders = (
|
||
db.query(HuyaExternalOrder)
|
||
.filter(HuyaExternalOrder.shop == old_name)
|
||
.update(
|
||
{HuyaExternalOrder.shop: changes["name"]["new"]},
|
||
synchronize_session=False,
|
||
)
|
||
)
|
||
|
||
_touch_shop(shop, current)
|
||
record_audit(
|
||
db,
|
||
current,
|
||
action="huya:collab:shop_update",
|
||
target=_shop_audit_target(shop),
|
||
detail={"changes": changes, "renamed_orders": renamed_orders},
|
||
)
|
||
if commit:
|
||
db.commit()
|
||
db.refresh(shop)
|
||
return changes
|
||
|
||
|
||
def delete_shop(
|
||
db: Session, current: User | None, shop: HuyaCollabShop, *, commit: bool = True
|
||
) -> dict:
|
||
"""从字典里删掉(需 ``huya:collab:manage``)。
|
||
|
||
**不动订单**:订单里存的是名字字符串,历史单子照旧;想让以后不再用它,
|
||
用「停用」更稳。
|
||
"""
|
||
snapshot = {"id": shop.id, "name": shop.name or ""}
|
||
record_audit(
|
||
db,
|
||
current,
|
||
action="huya:collab:shop_delete",
|
||
target=_shop_audit_target(shop),
|
||
detail=snapshot,
|
||
)
|
||
db.delete(shop)
|
||
if commit:
|
||
db.commit()
|
||
return snapshot
|
||
|
||
|
||
def ensure_shops_registered(
|
||
db: Session, current: User | None, names: Sequence[Any]
|
||
) -> list[str]:
|
||
"""把订单里用到的店铺名补进字典(新建 / 编辑 / 粘贴导入共用),返回新登记的名字。
|
||
|
||
这样"自定义输入一个新店铺"就能直接出现在下次的下拉里,不用先去管理弹窗加一遍。
|
||
"""
|
||
wanted = [str(name or "").strip() for name in names]
|
||
wanted = [name for name in dict.fromkeys(wanted) if name]
|
||
if not wanted:
|
||
return []
|
||
known = {
|
||
row[0]
|
||
for row in db.query(HuyaCollabShop.name)
|
||
.filter(HuyaCollabShop.name.in_(wanted))
|
||
.all()
|
||
}
|
||
created: list[str] = []
|
||
for name in wanted:
|
||
if name in known or len(name) > SHOP_NAME_LIMIT:
|
||
continue
|
||
db.add(
|
||
HuyaCollabShop(
|
||
name=name,
|
||
sort_order=0,
|
||
is_active=True,
|
||
remark="",
|
||
created_by=current.id if current else None,
|
||
updated_by=current.id if current else None,
|
||
)
|
||
)
|
||
created.append(name)
|
||
if created:
|
||
# 先 flush 让同一批里的重名在唯一索引上炸出来(而不是拖到 commit)。
|
||
db.flush()
|
||
record_audit(
|
||
db,
|
||
current,
|
||
action="huya:collab:shop_create",
|
||
target="huya_collab_shops",
|
||
detail={"auto_registered": created},
|
||
)
|
||
return created
|
||
|
||
|
||
def list_shops(db: Session) -> list[str]:
|
||
"""字典里的店铺名(筛选/下拉用;停用的也在,前端自己决定展示哪些)。"""
|
||
return sorted(shop.name for shop in list_shop_records(db))
|
||
|
||
|
||
def list_gift_names(db: Session) -> list[str]:
|
||
"""台账里出现过的礼包/皮肤名(去重、去空;筛选下拉用)。"""
|
||
rows = db.query(HuyaExternalOrder.gift_name).distinct().all()
|
||
return sorted(
|
||
{str(row[0]).strip() for row in rows if row[0] and str(row[0]).strip()}
|
||
)
|
||
|
||
|
||
# --------------------------------------------------------------------------
|
||
# CRUD
|
||
# --------------------------------------------------------------------------
|
||
|
||
|
||
def create_order(
|
||
db: Session,
|
||
current: User,
|
||
*,
|
||
order_no: str,
|
||
shop: str = "",
|
||
owner_id: int | None = None,
|
||
ck_text: str = "",
|
||
bind_douyu_account: str = "",
|
||
gift_name: str = "",
|
||
home_images: Any = None,
|
||
remark: str = "",
|
||
require_verified: bool = False,
|
||
draft: bool = False,
|
||
client: Any | None = None,
|
||
commit: bool = True,
|
||
) -> HuyaExternalOrder:
|
||
"""新建一行(可同时贴 CK)。订单号唯一,重复直接报冲突。
|
||
|
||
**负责人不填就是当前登录用户**(创建人)—— 新建时不用选,谁建的单归谁;
|
||
要转给别人在列表里点一下改负责人即可(负责人不是权限边界,见设计文档 §7.1)。
|
||
|
||
``draft=True``(**先建单后补资料**):只要求订单号,店铺也允许先空着;
|
||
状态直接进「待补资料」,CK / 礼包名 / 主页截图都可以等客户资料到了再补。
|
||
这条路径不跑探针、不做严格校验(2026-10-17 用户需求)。
|
||
|
||
``require_verified=True``(**人工新建表单用**,粘贴导入不传)时按"人工必填项"严格校验:
|
||
**订单编号 / 店铺 / CK / 礼包名 / 主页截图**都不许空,且 CK 必须探针通、
|
||
**读到绑定角色**才让建;读不到角色直接 400 —— 这条在服务层做,前端只是体验(AGENTS §2)。
|
||
校验全部在落库**之前**完成,失败不会留下半行。
|
||
|
||
图片只收**主页截图**(``home_images``):绑定图不再人工上传 —— 绑了谁由探针自动读
|
||
(``bind_game_*``),人工截图没有意义(2026-10-16 用户拍板)。
|
||
"""
|
||
order_no = clean_text(order_no, field="order_no", required=True)
|
||
_assert_order_no_available(db, order_no)
|
||
home_images_keys = normalize_images(home_images, field="home_images")
|
||
|
||
if draft:
|
||
# 草稿单不验 CK、不跑探针:先落一行占住订单号,资料到了在详情里补。
|
||
require_verified = False
|
||
|
||
order = HuyaExternalOrder(
|
||
order_no=order_no,
|
||
shop=clean_text(shop, field="shop"),
|
||
owner_id=_clean_owner_id(owner_id) if owner_id is not None else current.id,
|
||
bind_douyu_account=clean_text(bind_douyu_account, field="bind_douyu_account"),
|
||
gift_name=clean_text(gift_name, field="gift_name"),
|
||
remark=clean_text(remark, field="remark"),
|
||
bind_images=[],
|
||
home_images=home_images_keys,
|
||
status=STATUS_INCOMPLETE if draft else STATUS_PENDING,
|
||
ck_status=CK_UNVERIFIED,
|
||
created_by=current.id,
|
||
updated_by=current.id,
|
||
version=0,
|
||
)
|
||
|
||
uid_reused: list[str] = []
|
||
ck_provided = bool(str(ck_text or "").strip())
|
||
if ck_provided:
|
||
_apply_ck(order, ck_text)
|
||
# 同一个虎牙号允许挂多单(2026-10-16 放开 uid 唯一性):只记复用痕迹,不拦
|
||
uid_reused = [item.order_no for item in uid_used_orders(db, order.uid)]
|
||
|
||
probe_result: CkProbeResult | None = None
|
||
if require_verified:
|
||
missing: list[str] = []
|
||
if not order.shop:
|
||
missing.append("店铺")
|
||
if not ck_provided:
|
||
missing.append("外部 CK")
|
||
if not order.gift_name:
|
||
missing.append("礼包名")
|
||
if not order.home_images:
|
||
missing.append("主页截图")
|
||
if missing:
|
||
raise CollabError(
|
||
f"下面这几项必须填:{'、'.join(missing)}", code="create_field_required"
|
||
)
|
||
# 先探针再落库:读不到绑定角色就不许建(临时对象,不会留下半行数据)。
|
||
probe_result = probe_ck(order, client=client)
|
||
role = probe_result.snapshot.get("bind_game_role", "")
|
||
if not probe_result.ok or not role:
|
||
raise CollabError(
|
||
f"这份 CK 不能用:{probe_result.message}"
|
||
+ ("(探针通了但读不到绑定角色)" if probe_result.ok else ""),
|
||
code="ck_not_verified",
|
||
)
|
||
for field_name in PROBE_SNAPSHOT_FIELDS:
|
||
setattr(order, field_name, probe_result.snapshot.get(field_name, ""))
|
||
order.bind_snapshot_at = _now()
|
||
|
||
if order.shop:
|
||
# 自定义输入的店铺名顺手登记进字典 → 下次下拉里就能直接点选。
|
||
ensure_shops_registered(db, current, [order.shop])
|
||
|
||
db.add(order)
|
||
db.flush() # 先拿到 id,审计里要带 target
|
||
if probe_result is not None and probe_result.ok:
|
||
# 结论仍然走 set_ck_status 这个唯一写入口(审计与 ck_checked_at 不会漂)。
|
||
set_ck_status(
|
||
db, current, order, CK_VALID, message=probe_result.message, commit=False
|
||
)
|
||
record_audit(
|
||
db,
|
||
current,
|
||
action="huya:collab:create",
|
||
target=_audit_target(order),
|
||
detail={
|
||
"order_no": order.order_no,
|
||
"shop": order.shop,
|
||
"uid": order.uid or "",
|
||
"ck_status": order.ck_status,
|
||
"gift_name": order.gift_name,
|
||
"bind_game_role": order.bind_game_role or "",
|
||
"home_images": len(order.home_images or []),
|
||
"draft": bool(draft),
|
||
# 这个虎牙号还挂在哪些单上(允许同一份 CK 服务多单,留个痕)
|
||
"uid_reused_in": uid_reused,
|
||
},
|
||
)
|
||
if commit:
|
||
db.commit()
|
||
db.refresh(order)
|
||
return order
|
||
|
||
|
||
def update_order(
|
||
db: Session,
|
||
current: User,
|
||
order: HuyaExternalOrder,
|
||
payload: Mapping[str, Any],
|
||
*,
|
||
expected_version: int | None = None,
|
||
commit: bool = True,
|
||
) -> dict:
|
||
"""编辑可改字段,返回**字段级 diff**(``{字段: {old, new}}``)。
|
||
|
||
乐观锁:``expected_version`` 与库里不一致 → 409,绝不静默覆盖
|
||
(这正是 Excel 协同最痛的地方)。
|
||
"""
|
||
assert_version(order, expected_version)
|
||
|
||
changes: dict[str, dict[str, Any]] = {}
|
||
for field in EDITABLE_FIELDS:
|
||
if field not in payload:
|
||
continue
|
||
if field == "owner_id":
|
||
new_value: Any = _clean_owner_id(payload[field])
|
||
else:
|
||
new_value = clean_text(
|
||
payload[field],
|
||
field=field,
|
||
required=(field == "order_no"),
|
||
)
|
||
old_value = getattr(order, field)
|
||
if old_value == new_value:
|
||
continue
|
||
if field == "order_no":
|
||
_assert_order_no_available(db, new_value, exclude_id=order.id)
|
||
changes[field] = {"old": old_value, "new": new_value}
|
||
setattr(order, field, new_value)
|
||
|
||
if not changes:
|
||
return {}
|
||
|
||
if "shop" in changes:
|
||
# 改成字典里没有的名字也允许(自定义),顺手登记,筛选下拉不会漏。
|
||
ensure_shops_registered(db, current, [changes["shop"]["new"]])
|
||
|
||
_touch(order, current)
|
||
record_audit(
|
||
db,
|
||
current,
|
||
action="huya:collab:update",
|
||
target=_audit_target(order),
|
||
detail={"order_no": order.order_no, "changes": changes},
|
||
)
|
||
if commit:
|
||
db.commit()
|
||
db.refresh(order)
|
||
return changes
|
||
|
||
|
||
def delete_order(
|
||
db: Session, current: User, order: HuyaExternalOrder, *, commit: bool = True
|
||
) -> dict:
|
||
"""物理删除(需 ``huya:collab:manage``)。
|
||
|
||
只删行,**不删对象存储里的图**:撤图不可逆,交给孤儿清理脚本按保留期处理
|
||
(设计文档 §6.4)。
|
||
"""
|
||
snapshot = {
|
||
"order_no": order.order_no,
|
||
"uid": order.uid or "",
|
||
"images": images_of(order, "bind_images") + images_of(order, "home_images"),
|
||
}
|
||
record_audit(
|
||
db,
|
||
current,
|
||
action="huya:collab:delete",
|
||
target=_audit_target(order),
|
||
detail={
|
||
"order_no": snapshot["order_no"],
|
||
"uid": snapshot["uid"],
|
||
"image_count": len(snapshot["images"]),
|
||
},
|
||
)
|
||
db.delete(order)
|
||
if commit:
|
||
db.commit()
|
||
return snapshot
|
||
|
||
|
||
def _clean_owner_id(value: Any) -> int | None:
|
||
if value is None or value == "":
|
||
return None
|
||
try:
|
||
owner_id = int(value)
|
||
except (TypeError, ValueError) as exc:
|
||
raise CollabError("负责人必须是用户 ID", code="owner_invalid") from exc
|
||
if owner_id <= 0:
|
||
return None
|
||
return owner_id
|
||
|
||
|
||
# --------------------------------------------------------------------------
|
||
# 查询
|
||
# --------------------------------------------------------------------------
|
||
|
||
|
||
def get_order(db: Session, order_id: int) -> HuyaExternalOrder:
|
||
order = db.query(HuyaExternalOrder).filter(HuyaExternalOrder.id == order_id).first()
|
||
if order is None:
|
||
raise CollabError(
|
||
"这一行不存在(可能已被别人删除)", status_code=404, code="order_not_found"
|
||
)
|
||
return order
|
||
|
||
|
||
ORDER_SORT_KEYS: tuple[str, ...] = (
|
||
"seq",
|
||
"order_no",
|
||
"shop",
|
||
"owner",
|
||
"bind",
|
||
"gift_name",
|
||
"status",
|
||
"uid",
|
||
"ck_status",
|
||
"updated_at",
|
||
"created_at",
|
||
)
|
||
|
||
|
||
def list_orders(
|
||
db: Session,
|
||
*,
|
||
shop: str = "",
|
||
owner_id: int | None = None,
|
||
status: str = "",
|
||
ck_status: str = "",
|
||
gift_name: str = "",
|
||
keyword: str = "",
|
||
include_void: bool = False,
|
||
sort_by: str = "seq",
|
||
sort_order: str = "asc",
|
||
page: int = 1,
|
||
page_size: int = 50,
|
||
) -> tuple[list[HuyaExternalOrder], int]:
|
||
"""共享视图:默认**不按人过滤**(负责人只是筛选维度,不是权限边界)。
|
||
|
||
默认按 ``id`` 升序(即创建顺序),新建的行永远追加在最后;表头排序走白名单,
|
||
不允许前端把任意列名拼进 SQL。"""
|
||
query = db.query(HuyaExternalOrder)
|
||
if shop:
|
||
query = query.filter(HuyaExternalOrder.shop == shop)
|
||
if owner_id:
|
||
query = query.filter(HuyaExternalOrder.owner_id == owner_id)
|
||
if status:
|
||
query = query.filter(HuyaExternalOrder.status == status)
|
||
if ck_status:
|
||
query = query.filter(HuyaExternalOrder.ck_status == ck_status)
|
||
skin = (gift_name or "").strip()
|
||
if skin:
|
||
query = query.filter(HuyaExternalOrder.gift_name == skin)
|
||
if not include_void:
|
||
query = query.filter(HuyaExternalOrder.status != STATUS_VOID)
|
||
text = (keyword or "").strip()
|
||
if text:
|
||
like = f"%{text}%"
|
||
query = query.filter(
|
||
or_(
|
||
HuyaExternalOrder.order_no.like(like),
|
||
HuyaExternalOrder.shop.like(like),
|
||
HuyaExternalOrder.bind_douyu_account.like(like),
|
||
HuyaExternalOrder.bind_game_account.like(like),
|
||
HuyaExternalOrder.bind_game_role.like(like),
|
||
HuyaExternalOrder.bind_game_channel.like(like),
|
||
HuyaExternalOrder.uid.like(like),
|
||
HuyaExternalOrder.nickname.like(like),
|
||
HuyaExternalOrder.hy_username.like(like),
|
||
HuyaExternalOrder.gift_name.like(like),
|
||
HuyaExternalOrder.remark.like(like),
|
||
)
|
||
)
|
||
|
||
total = query.count()
|
||
page = max(1, int(page or 1))
|
||
page_size = min(200, max(1, int(page_size or 50)))
|
||
|
||
key = (sort_by or "seq").strip().lower()
|
||
if key not in ORDER_SORT_KEYS:
|
||
key = "seq"
|
||
descending = (sort_order or "asc").strip().lower() == "desc"
|
||
|
||
# 负责人按显示的用户名排序(不是 id),需要一次 outerjoin;其它列都直接落在订单表上。
|
||
if key == "owner":
|
||
query = query.outerjoin(User, HuyaExternalOrder.owner_id == User.id)
|
||
sort_column = User.username
|
||
elif key == "status":
|
||
# 与页头状态顺序保持一致,避免状态列按字母序排得莫名其妙。
|
||
sort_column = func.field(HuyaExternalOrder.status, *ORDER_STATUSES)
|
||
elif key == "bind":
|
||
sort_column = HuyaExternalOrder.bind_game_account
|
||
elif key == "seq":
|
||
sort_column = HuyaExternalOrder.id
|
||
else:
|
||
sort_column = getattr(HuyaExternalOrder, key)
|
||
|
||
ordered = sort_column.desc() if descending else sort_column.asc()
|
||
rows = (
|
||
query.order_by(ordered, HuyaExternalOrder.id.asc())
|
||
.offset((page - 1) * page_size)
|
||
.limit(page_size)
|
||
.all()
|
||
)
|
||
return rows, total
|
||
|
||
|
||
def summary(db: Session) -> dict:
|
||
"""页头统计条:各状态计数 + CK 失效数。"""
|
||
status_rows = (
|
||
db.query(HuyaExternalOrder.status, func.count(HuyaExternalOrder.id))
|
||
.group_by(HuyaExternalOrder.status)
|
||
.all()
|
||
)
|
||
by_status = {str(status): int(count) for status, count in status_rows}
|
||
ck_invalid = (
|
||
db.query(func.count(HuyaExternalOrder.id))
|
||
.filter(HuyaExternalOrder.ck_status == CK_INVALID)
|
||
.scalar()
|
||
)
|
||
return {
|
||
"total": sum(by_status.values()),
|
||
"by_status": {code: by_status.get(code, 0) for code in ORDER_STATUSES},
|
||
"ck_invalid": int(ck_invalid or 0),
|
||
}
|
||
|
||
|
||
def user_names(db: Session, ids: Sequence[Any]) -> dict[int, str]:
|
||
"""一批用户 id → 用户名(避免每行懒加载,N+1)。"""
|
||
clean = {int(value) for value in ids if value}
|
||
if not clean:
|
||
return {}
|
||
rows = db.query(User.id, User.username).filter(User.id.in_(clean)).all()
|
||
return {int(row[0]): str(row[1]) for row in rows}
|
||
|
||
|
||
def users_by_id(db: Session, orders: Sequence[HuyaExternalOrder]) -> dict[int, str]:
|
||
"""一次性把涉及的用户名查出来,避免每行都懒加载(N+1)。"""
|
||
return user_names(
|
||
db,
|
||
[
|
||
value
|
||
for order in orders
|
||
for value in (order.owner_id, order.created_by, order.updated_by)
|
||
],
|
||
)
|
||
|
||
|
||
def serialize_order(
|
||
order: HuyaExternalOrder,
|
||
*,
|
||
users: Mapping[int, str] | None = None,
|
||
buyer_cluster: Mapping[str, Any] | None = None,
|
||
last_metric: HuyaExternalOrderMetric | None = None,
|
||
) -> dict:
|
||
"""把行对象转成接口输出。
|
||
|
||
图片存的是 object_key,URL 在这里现拼 —— 库里存 URL 会在换存储/改路径时
|
||
全部失效(order_site 踩过「存下来的签名链接过期后到处裂图」)。
|
||
|
||
``buyer_cluster`` = 同一买家的其它单(见 :func:`buyer_clusters`),
|
||
``last_metric`` = 最近一次「积分 / 兑换记录」快照(见 :func:`latest_metrics`),
|
||
都由调用方传进来(列表是一次查询算出整页的,别在这里按行查)。
|
||
"""
|
||
names = users or {}
|
||
bind_images = images_of(order, "bind_images")
|
||
home_images = images_of(order, "home_images")
|
||
|
||
def urls(keys: list[str], variant: str = "") -> list[str]:
|
||
return [build_file_url(key, variant) for key in keys]
|
||
|
||
def name_of(value: int | None) -> str:
|
||
return names.get(int(value), "") if value else ""
|
||
|
||
return {
|
||
"id": order.id,
|
||
"order_no": order.order_no,
|
||
"shop": order.shop or "",
|
||
"owner_id": order.owner_id,
|
||
"owner_name": name_of(order.owner_id),
|
||
"uid": order.uid or "",
|
||
"yyuid": order.yyuid or "",
|
||
"hy_username": order.hy_username or "",
|
||
"nickname": order.nickname or "",
|
||
# 只暴露「有没有」,CK 本体绝不出接口。
|
||
"has_cookie": bool(order.cookie),
|
||
"has_raw_cookie": bool(order.raw_cookie),
|
||
"ck_status": order.ck_status,
|
||
"ck_status_label": CK_STATUS_LABELS.get(order.ck_status, order.ck_status),
|
||
"ck_checked_at": order.ck_checked_at,
|
||
"ck_check_message": order.ck_check_message or "",
|
||
"bind_douyu_account": order.bind_douyu_account or "",
|
||
"bind_douyu_uid": order.bind_douyu_uid or "",
|
||
# 探针读到的绑定对象(只读快照,人工不可编辑)
|
||
"bind_game_account": order.bind_game_account or "",
|
||
"bind_game_role": order.bind_game_role or "",
|
||
"bind_game_channel": order.bind_game_channel or "",
|
||
"bind_snapshot_at": order.bind_snapshot_at,
|
||
"bind_images": bind_images,
|
||
"home_images": home_images,
|
||
"bind_image_urls": urls(bind_images),
|
||
"home_image_urls": urls(home_images),
|
||
"bind_image_thumb_urls": urls(bind_images, "thumb"),
|
||
"home_image_thumb_urls": urls(home_images, "thumb"),
|
||
"gift_name": order.gift_name or "",
|
||
"remark": order.remark or "",
|
||
"status": order.status,
|
||
"status_label": ORDER_STATUS_LABELS.get(order.status, order.status),
|
||
"next_status": NEXT_STATUS.get(order.status),
|
||
"bound_at": order.bound_at,
|
||
"recharged_at": order.recharged_at,
|
||
"scripted_at": order.scripted_at,
|
||
"done_at": order.done_at,
|
||
"void_reason": order.void_reason or "",
|
||
"points": order.points,
|
||
"elite_opened": order.elite_opened,
|
||
"snapshot_at": order.snapshot_at,
|
||
"created_by": order.created_by,
|
||
"created_by_name": name_of(order.created_by),
|
||
"updated_by": order.updated_by,
|
||
"updated_by_name": name_of(order.updated_by),
|
||
"version": int(order.version or 0),
|
||
"created_at": order.created_at,
|
||
"updated_at": order.updated_at,
|
||
# 同一买家的其它单(软识别,只读派生;没有就是 null)
|
||
"buyer_cluster": dict(buyer_cluster) if buyer_cluster else None,
|
||
# 最近一次「积分 / 兑换记录」快照(生命周期时间线在详情里,列表只带最新一条)
|
||
"last_score": last_metric.score if last_metric else None,
|
||
"last_metric_at": last_metric.created_at if last_metric else None,
|
||
"last_verdict": (last_metric.verdict or "") if last_metric else "",
|
||
"last_verdict_label": (
|
||
VERDICT_LABELS.get(last_metric.verdict, last_metric.verdict or "")
|
||
if last_metric
|
||
else ""
|
||
),
|
||
"last_metric_message": (last_metric.message or "") if last_metric else "",
|
||
}
|
||
|
||
|
||
# --------------------------------------------------------------------------
|
||
# 同一买家的多单(软识别:只读派生,不落库、不改 uid 唯一性规则)
|
||
# --------------------------------------------------------------------------
|
||
|
||
# 买家指纹 = 探针读到的「绑定账号 + 绑定角色」(同一个游戏角色 = 同一个买家)。
|
||
# **不拿 uid 当主键**:一单一份外部 CK,同一个买家买两件往往是两份不同的 CK(uid 不同),
|
||
# 拿 uid 去比反而永远比不中;uid 只作为明细里的辅助信息展示。
|
||
BUYER_KEY_METHOD = "bind_game_account+bind_game_role"
|
||
|
||
|
||
def buyer_key_of(order: HuyaExternalOrder) -> tuple[str, str] | None:
|
||
"""一行的买家指纹;探针还没读到「绑定账号 + 绑定角色」时返回 None(不参与归组)。"""
|
||
account = str(order.bind_game_account or "").strip()
|
||
role = str(order.bind_game_role or "").strip()
|
||
if not account or not role:
|
||
return None
|
||
return (account, role)
|
||
|
||
|
||
def buyer_clusters(
|
||
db: Session, orders: Sequence[HuyaExternalOrder]
|
||
) -> dict[int, dict[str, Any]]:
|
||
"""算出这批行各自「同一买家的其它单」(不是按行发 SQL:整批一次查完)。
|
||
|
||
只有真的 ≥2 单才给结果 —— 单独一单不必在界面上标「同买家 1 单」。
|
||
|
||
返回 ``{order_id: {key, method, total, others: [...]}}``,``others`` 按创建时间升序。
|
||
"""
|
||
keys: dict[tuple[str, str], None] = {}
|
||
for order in orders:
|
||
key = buyer_key_of(order)
|
||
if key is not None:
|
||
keys[key] = None
|
||
if not keys:
|
||
return {}
|
||
|
||
pairs = list(keys)
|
||
rows = (
|
||
db.query(HuyaExternalOrder)
|
||
.filter(
|
||
tuple_(
|
||
HuyaExternalOrder.bind_game_account, HuyaExternalOrder.bind_game_role
|
||
).in_(pairs)
|
||
)
|
||
.order_by(HuyaExternalOrder.created_at.asc(), HuyaExternalOrder.id.asc())
|
||
.all()
|
||
)
|
||
|
||
grouped: dict[tuple[str, str], list[HuyaExternalOrder]] = {}
|
||
for row in rows:
|
||
key = buyer_key_of(row)
|
||
if key is not None and key in keys:
|
||
grouped.setdefault(key, []).append(row)
|
||
|
||
clusters: dict[int, dict[str, Any]] = {}
|
||
for key, siblings in grouped.items():
|
||
if len(siblings) < 2:
|
||
continue
|
||
for row in siblings:
|
||
others = [
|
||
{
|
||
"id": int(item.id),
|
||
"order_no": item.order_no or "",
|
||
"shop": item.shop or "",
|
||
"gift_name": item.gift_name or "",
|
||
"uid": item.uid or "",
|
||
"status": item.status,
|
||
"status_label": ORDER_STATUS_LABELS.get(item.status, item.status),
|
||
"created_at": item.created_at,
|
||
}
|
||
for item in siblings
|
||
if int(item.id) != int(row.id)
|
||
]
|
||
clusters[int(row.id)] = {
|
||
"key": f"{key[0]} · {key[1]}",
|
||
"method": BUYER_KEY_METHOD,
|
||
"total": len(siblings),
|
||
"others": others,
|
||
}
|
||
return clusters
|
||
|
||
|
||
# --------------------------------------------------------------------------
|
||
# 批量导入(Excel 粘贴)
|
||
# --------------------------------------------------------------------------
|
||
|
||
IMPORT_COLUMNS: tuple[str, ...] = (
|
||
"shop",
|
||
"order_no",
|
||
"owner",
|
||
"bind_douyu_account",
|
||
"gift_name",
|
||
"remark",
|
||
)
|
||
|
||
# 表头识别:中文(Excel 原表头)+ 英文别名。按位置兜底要求 6 列顺序一致。
|
||
_HEADER_ALIASES: dict[str, str] = {
|
||
"店铺": "shop",
|
||
"shop": "shop",
|
||
"订单编号": "order_no",
|
||
"订单号": "order_no",
|
||
"order_no": "order_no",
|
||
"orderno": "order_no",
|
||
"负责人": "owner",
|
||
"owner": "owner",
|
||
"绑定斗鱼号": "bind_douyu_account",
|
||
"绑定斗鱼": "bind_douyu_account",
|
||
"斗鱼号": "bind_douyu_account",
|
||
"bind_douyu_account": "bind_douyu_account",
|
||
"礼包": "gift_name",
|
||
"礼包名": "gift_name",
|
||
"商品": "gift_name",
|
||
"gift_name": "gift_name",
|
||
"备注": "remark",
|
||
"remark": "remark",
|
||
}
|
||
|
||
|
||
def parse_import_text(text: str) -> tuple[list[str], list[tuple[int, list[str]]]]:
|
||
"""把粘贴的文本切成 (列顺序, [(行号, 单元格)])。
|
||
|
||
支持 Excel 直接复制出来的 TSV(``\\t``),也兼容逗号/分号分隔。
|
||
第一行若能被识别成表头,则按表头映射列;否则按固定列序
|
||
``店铺 / 订单编号 / 负责人 / 绑定斗鱼号 / 礼包 / 备注``。
|
||
"""
|
||
rows: list[tuple[int, list[str]]] = []
|
||
for index, raw_line in enumerate((text or "").splitlines(), start=1):
|
||
line = raw_line.rstrip("\r\n")
|
||
if not line.strip():
|
||
continue
|
||
if "\t" in line:
|
||
cells = line.split("\t")
|
||
elif ";" in line:
|
||
cells = line.split(";")
|
||
elif "," in line:
|
||
cells = line.split(",")
|
||
else:
|
||
cells = [line]
|
||
rows.append((index, [cell.strip() for cell in cells]))
|
||
|
||
if not rows:
|
||
return list(IMPORT_COLUMNS), []
|
||
|
||
first_index, first_cells = rows[0]
|
||
mapped: list[str] = []
|
||
for cell in first_cells:
|
||
key = _HEADER_ALIASES.get(cell.strip().lower()) or _HEADER_ALIASES.get(
|
||
cell.strip()
|
||
)
|
||
mapped.append(key or "")
|
||
if mapped.count("order_no") == 1 and len(mapped) >= 2:
|
||
logger.debug(f"协作台账导入:识别到表头(行 {first_index}){mapped}")
|
||
return mapped, rows[1:]
|
||
return list(IMPORT_COLUMNS), rows
|
||
|
||
|
||
def import_orders(
|
||
db: Session,
|
||
current: User,
|
||
text: str,
|
||
*,
|
||
owner_by_name: Mapping[str, int] | None = None,
|
||
commit: bool = True,
|
||
) -> dict:
|
||
"""批量导入:逐行处理,**单行失败不影响其他行**(AGENTS §2),错误按行号返回。
|
||
|
||
Returns:
|
||
``{created, updated, skipped, errors: [{line, order_no, message}]}``
|
||
"""
|
||
columns, rows = parse_import_text(text)
|
||
owners = {
|
||
str(name).strip(): int(uid) for name, uid in (owner_by_name or {}).items()
|
||
}
|
||
created = 0
|
||
updated = 0
|
||
skipped = 0
|
||
errors: list[dict[str, Any]] = []
|
||
|
||
seen_order_nos: set[str] = set()
|
||
|
||
for line_no, cells in rows:
|
||
record = {
|
||
column: (cells[index] if index < len(cells) else "")
|
||
for index, column in enumerate(columns)
|
||
if column
|
||
}
|
||
order_no = str(record.get("order_no") or "").strip()
|
||
if not order_no:
|
||
skipped += 1
|
||
continue
|
||
if order_no in seen_order_nos:
|
||
# 同一批粘贴里重复出现:只处理第一条,否则后一条会把刚建的覆盖掉。
|
||
errors.append(
|
||
{
|
||
"line": line_no,
|
||
"order_no": order_no,
|
||
"message": "本次粘贴里重复出现,只处理第一条",
|
||
}
|
||
)
|
||
continue
|
||
seen_order_nos.add(order_no)
|
||
|
||
owner_name = str(record.get("owner") or "").strip()
|
||
payload = {
|
||
"shop": record.get("shop", ""),
|
||
"bind_douyu_account": record.get("bind_douyu_account", ""),
|
||
"gift_name": record.get("gift_name", ""),
|
||
"remark": record.get("remark", ""),
|
||
}
|
||
if owner_name:
|
||
if owner_name not in owners:
|
||
errors.append(
|
||
{
|
||
"line": line_no,
|
||
"order_no": order_no,
|
||
"message": f"负责人「{owner_name}」不存在或不是活跃用户",
|
||
}
|
||
)
|
||
continue
|
||
payload["owner_id"] = owners[owner_name]
|
||
|
||
outcome = ""
|
||
try:
|
||
# 每行套一个 SAVEPOINT:这一行炸了只回滚这一行,整批继续。
|
||
# 若直接往同一个事务里写,一次约束冲突就会把会话打成失败态,
|
||
# 后面每一行都跟着报错 —— 那就成了「单行失败带崩整批」。
|
||
with db.begin_nested():
|
||
existing = (
|
||
db.query(HuyaExternalOrder)
|
||
.filter(HuyaExternalOrder.order_no == order_no)
|
||
.first()
|
||
)
|
||
if existing is None:
|
||
create_order(
|
||
db,
|
||
current,
|
||
order_no=order_no,
|
||
shop=payload.get("shop", ""),
|
||
owner_id=payload.get("owner_id"),
|
||
bind_douyu_account=payload.get("bind_douyu_account", ""),
|
||
gift_name=payload.get("gift_name", ""),
|
||
remark=payload.get("remark", ""),
|
||
commit=False,
|
||
)
|
||
outcome = "created"
|
||
else:
|
||
# 已存在按「更新」处理(设计文档 §10.2 E);导入不带
|
||
# expected_version,视为「明确要覆盖」。
|
||
changes = update_order(
|
||
db,
|
||
current,
|
||
existing,
|
||
payload,
|
||
expected_version=None,
|
||
commit=False,
|
||
)
|
||
outcome = "updated" if changes else "skipped"
|
||
# SessionLocal 是 autoflush=False:显式 flush,让后面几行的
|
||
# 重复检查能看到刚写入的这行。
|
||
db.flush()
|
||
except CollabError as exc:
|
||
errors.append(
|
||
{"line": line_no, "order_no": order_no, "message": exc.detail}
|
||
)
|
||
continue
|
||
except Exception as exc: # noqa: BLE001 单行失败不该带崩整批导入
|
||
errors.append(
|
||
{
|
||
"line": line_no,
|
||
"order_no": order_no,
|
||
"message": f"{type(exc).__name__}: {exc}",
|
||
}
|
||
)
|
||
continue
|
||
|
||
if outcome == "created":
|
||
created += 1
|
||
elif outcome == "updated":
|
||
updated += 1
|
||
else:
|
||
skipped += 1
|
||
|
||
if commit:
|
||
db.commit()
|
||
return {
|
||
"created": created,
|
||
"updated": updated,
|
||
"skipped": skipped,
|
||
"errors": errors,
|
||
"parsed_columns": columns,
|
||
}
|