Files
live-hub-py/web/backend/services/huya_collab_service.py
T
yml2213 aac264b639 feat(huya): 协作台账店铺字典·新建强校验·同买家多单 + uid 放开(迁移 0057/0058)
- 店铺字典(迁移 0057):大按钮点选 + 「新店」自定义自动登记;重命名可级联、
  停用/备注;列表行内改、详情、筛选条共用同一个 picker
- 新建强校验:订单编号/店铺/CK/礼包名/主页截图必填;贴 CK 即探针预览,
  读不到绑定角色禁止提交;礼包用工作台配置的活动 SID 现拉(浏览器缓存 + 强制刷新)
- 图片:绑定图停用、「绑定斗鱼号」下线(绑定关系改由探针读 bind_game_*),
  主页截图改为新建必填(弹窗内先传 OSS 再随表单落库)
- 同买家多单软识别:按「绑定账号 + 绑定角色」整批一次查询,列表「同买家」列 +
  详情分组,点单号直接切单
- uid 放开唯一性(迁移 0058):同一份 CK 可服务多单,原 409 冲突改为提示 + 审计
- 列表体验:序号列、搜索扩到 11 个字段、CK 状态列截断、新建弹窗放大并去滚动条
- 验证:pytest 932 passed / 6 skipped;接口冒烟 56 项全绿;tsc+eslint+build 通过
2026-10-16 21:29:08 +08:00

2130 lines
75 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""虎牙外部 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, 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
# --------------------------------------------------------------------------
# 业务状态机(一单卡在哪一步)
# --------------------------------------------------------------------------
STATUS_PENDING = "pending"
STATUS_BOUND = "bound"
STATUS_RECHARGED = "recharged"
STATUS_SCRIPTED = "scripted"
STATUS_DONE = "done"
STATUS_VOID = "void"
ORDER_STATUSES: tuple[str, ...] = (
STATUS_PENDING,
STATUS_BOUND,
STATUS_RECHARGED,
STATUS_SCRIPTED,
STATUS_DONE,
STATUS_VOID,
)
ORDER_STATUS_LABELS = {
STATUS_PENDING: "待处理",
STATUS_BOUND: "已绑定",
STATUS_RECHARGED: "已充值",
STATUS_SCRIPTED: "已上脚本",
STATUS_DONE: "已完成",
STATUS_VOID: "已作废",
}
# 「向前一步」的下一站;done 是终点,void 是任意态可达的旁路,都不在这条链上。
NEXT_STATUS = {
STATUS_PENDING: STATUS_BOUND,
STATUS_BOUND: STATUS_RECHARGED,
STATUS_RECHARGED: STATUS_SCRIPTED,
STATUS_SCRIPTED: STATUS_DONE,
}
# 阶段序号:用来同步时间戳(回退时要清掉后面阶段的时间戳,否则状态与时间戳互相矛盾)。
STAGE_INDEX = {
STATUS_PENDING: 0,
STATUS_BOUND: 1,
STATUS_RECHARGED: 2,
STATUS_SCRIPTED: 3,
STATUS_DONE: 4,
}
STATUS_TIMESTAMP_FIELDS = {
STATUS_BOUND: "bound_at",
STATUS_RECHARGED: "recharged_at",
STATUS_SCRIPTED: "scripted_at",
STATUS_DONE: "done_at",
}
# --------------------------------------------------------------------------
# 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:
"""进入「已绑定」的前置校验(在服务层做,前端校验只是体验)。
绑定对象有两条来源:人工填的 `bind_douyu_account`(Excel D 列),或**探针读到的
`bind_game_role`**(2026-10-16 起探针把绑定角色写进快照列)。有任意一条就算"绑上了",
否则只验证过 CK、没读过绑定角色的行会被挡在 `bound` 外面。
"""
missing: list[str] = []
if not str(order.cookie or "").strip():
missing.append("CK")
has_binding = bool(
str(order.bind_douyu_account or "").strip()
or str(order.bind_game_role or "").strip()
)
if not has_binding:
missing.append("绑定账号(人工填绑定斗鱼号,或先「验证 CK」读到绑定角色)")
if not images_of(order, "bind_images"):
missing.append("绑定图")
if missing:
raise CollabError(
f"还不能标记为「已绑定」,缺少:{'、'.join(missing)}",
code="bound_precondition",
)
def _sync_stage_timestamps(
order: HuyaExternalOrder, target: str, now: datetime
) -> None:
"""让各阶段时间戳与目标状态保持一致。
- 目标状态**之前**(含自身)缺失的时间戳补上:状态说「已上脚本」,
时间戳就不能是空的,否则前端那格显示空白、没人知道什么时候做的;
- 目标状态**之后**的时间戳清空:回退之后还留着「已上脚本时间」,
就是状态与时间戳互相矛盾。
"""
if target == STATUS_VOID:
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`` 写入口)。
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
if target != STATUS_VOID and target != NEXT_STATUS.get(from_status):
if not rollback:
next_status = NEXT_STATUS.get(from_status)
hint = (
f"只能推进到「{ORDER_STATUS_LABELS[next_status]}」"
if next_status
else "这单已经走到终点,只能作废"
)
raise CollabError(
f"状态只能向前推进({hint});要回退请显式勾选「回退」",
code="status_not_forward",
)
if from_status == STATUS_VOID:
raise CollabError(
"已作废的单子不能改回流程内状态,请编辑或新建",
code="status_from_void",
)
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(rollback and target != NEXT_STATUS.get(from_status)),
"reason": order.void_reason,
"order_no": order.order_no,
},
)
if commit:
db.commit()
db.refresh(order)
return {"from": from_status, "to": target}
# --------------------------------------------------------------------------
# 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)} 个礼包"
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]
# --------------------------------------------------------------------------
# 店铺字典(新建协作单时点一下选;也支持自定义输入)
# --------------------------------------------------------------------------
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))
# --------------------------------------------------------------------------
# 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,
client: Any | None = None,
commit: bool = True,
) -> HuyaExternalOrder:
"""新建一行(可同时贴 CK)。订单号唯一,重复直接报冲突。
**负责人不填就是当前登录用户**(创建人)—— 新建时不用选,谁建的单归谁;
要转给别人在列表里点一下改负责人即可(负责人不是权限边界,见设计文档 §7.1)。
``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")
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_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 []),
# 这个虎牙号还挂在哪些单上(允许同一份 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
def list_orders(
db: Session,
*,
shop: str = "",
owner_id: int | None = None,
status: str = "",
ck_status: str = "",
keyword: str = "",
include_void: bool = False,
page: int = 1,
page_size: int = 50,
) -> tuple[list[HuyaExternalOrder], int]:
"""共享视图:默认**不按人过滤**(负责人只是筛选维度,不是权限边界)。"""
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)
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)))
# 排序:业务状态(作废排最后)+ 最近改动在前,让「刚被谁动过」的浮上来。
stage = func.field(HuyaExternalOrder.status, *ORDER_STATUSES)
rows = (
query.order_by(stage.asc(), HuyaExternalOrder.updated_at.desc())
.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,
) -> dict:
"""把行对象转成接口输出。
图片存的是 object_key,URL 在这里现拼 —— 库里存 URL 会在换存储/改路径时
全部失效(order_site 踩过「存下来的签名链接过期后到处裂图」)。
``buyer_cluster`` = 同一买家的其它单(见 :func:`buyer_clusters`),由调用方
传进来(列表是一次查询算出整页的,别在这里按行查)。
"""
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,
}
# --------------------------------------------------------------------------
# 同一买家的多单(软识别:只读派生,不落库、不改 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,
}