Files
live-hub-py/web/backend/services/douyu_collab_service.py
T

2182 lines
71 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 台账的区别):
1. **一行 = 一单 = 一个内部斗鱼账号**。协作单只存 ``account_id`` 和用户名快照,
**不复制 CK**;所有查询实时读 ``accounts.cookie``,账号换 CK 后自动生效。
2. 鱼翅和手册积分是两笔资产:先充值鱼翅,再用鱼翅赠送精英令换积分。
查询时两笔都要返回,核账只看手册积分。
3. 同一斗鱼账号允许挂多单;积分核账按账号合并计算所有未作废订单的礼包积分。
4. 兑换是否成功只看当前账号的 ``exchangeList``。``getRecordList`` 是全站动态,
不能拿来判断某一单。
"""
from __future__ import annotations
from collections.abc import Mapping, Sequence
from datetime import UTC, datetime
from typing import Any
from sqlalchemy import func, or_
from sqlalchemy.orm import Session
from core.douyu import DouyuActivityClient
from ..models import (
Account,
AuditLog,
DouyuCollabShop,
DouyuConfig,
DouyuExternalOrder,
DouyuExternalOrderMetric,
DouyuGoodsSnapshot,
User,
)
from .audit_service import record_audit
from .file_service import build_file_url
# ---------------------------------------------------------------------------
# 状态机
# ---------------------------------------------------------------------------
STATUS_INCOMPLETE = "incomplete"
STATUS_TO_RECHARGE = "to_recharge"
STATUS_RECHARGED = "recharged"
STATUS_SCRIPTED = "scripted"
STATUS_DONE = "done"
STATUS_POINTS_ERROR = "points_error"
STATUS_FAILED = "failed"
STATUS_VOID = "void"
ORDER_STATUSES: tuple[str, ...] = (
STATUS_INCOMPLETE,
STATUS_TO_RECHARGE,
STATUS_RECHARGED,
STATUS_SCRIPTED,
STATUS_DONE,
STATUS_POINTS_ERROR,
STATUS_FAILED,
STATUS_VOID,
)
ORDER_STATUS_LABELS = {
STATUS_INCOMPLETE: "待补资料",
STATUS_TO_RECHARGE: "待充值",
STATUS_RECHARGED: "已充值",
STATUS_SCRIPTED: "已上脚本",
STATUS_DONE: "抢购成功",
STATUS_POINTS_ERROR: "积分异常",
STATUS_FAILED: "抢购失败",
STATUS_VOID: "已作废",
}
STATUS_FLOW: tuple[str, ...] = (
STATUS_INCOMPLETE,
STATUS_TO_RECHARGE,
STATUS_RECHARGED,
STATUS_SCRIPTED,
STATUS_DONE,
)
STAGE_INDEX = {
STATUS_INCOMPLETE: 0,
STATUS_TO_RECHARGE: 1,
STATUS_RECHARGED: 2,
STATUS_SCRIPTED: 3,
STATUS_DONE: 4,
}
STATUS_TIMESTAMP_FIELDS = {
STATUS_RECHARGED: "recharged_at",
STATUS_SCRIPTED: "scripted_at",
STATUS_DONE: "done_at",
}
ARCHIVE_SCOPES = ("active", "archived", "all")
ARCHIVABLE_STATUSES = (STATUS_DONE,)
# 同一账号核账只统计“还没有完成兑换”的单:done/scripted 不重复占积分。
POINTS_GROUP_STATUSES: tuple[str, ...] = (
STATUS_TO_RECHARGE,
STATUS_RECHARGED,
STATUS_POINTS_ERROR,
)
POINTS_THRESHOLD_DEFAULT = 100
POINTS_THRESHOLD_MAX = 1_000_000
MAX_IMAGES = 20
CK_EXPORT_BATCH_MAX = 50
CK_EXPORT_ACTION = "douyu:collab:ck_export"
USERNAME_EXPORT_BATCH_MAX = 500
VERDICT_SUCCESS = "success"
VERDICT_FAILED = "failed"
VERDICT_UNKNOWN = "unknown"
CK_VALID = "valid"
CK_INVALID = "invalid"
CK_UNVERIFIED = "unverified"
CK_LABELS = {
CK_VALID: "CK 正常",
CK_INVALID: "CK 异常",
CK_UNVERIFIED: "未检测",
}
class CollabError(RuntimeError):
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 _to_int(value: Any) -> int | None:
if value is None or value == "":
return None
try:
return int(float(value))
except (TypeError, ValueError):
return None
def _text(value: Any, *, max_length: int | None = None) -> str:
text = str(value or "").strip()
return text[:max_length] if max_length else text
def _json_safe(value: Any) -> Any:
if isinstance(value, dict):
return {str(k): _json_safe(v) for k, v in value.items()}
if isinstance(value, list):
return [_json_safe(item) for item in value]
if isinstance(value, (str, int, float, bool)) or value is None:
return value
return str(value)
def _first_value(data: Mapping[str, Any], *keys: str) -> Any:
"""取第一个存在且非 None 的字段;0 是合法值,不能用 `or` 兜。"""
for key in keys:
if key in data and data.get(key) is not None:
return data.get(key)
return None
def normalize_images(value: Any, *, field: str = "home_images") -> list[str]:
if value is None:
return []
if not isinstance(value, list):
raise CollabError(f"{field} 必须是图片 key 数组", code="images_invalid")
keys: list[str] = []
for item in value:
key = _text(item, max_length=512)
if key and key not in keys:
keys.append(key)
if len(keys) > MAX_IMAGES:
raise CollabError(f"最多 {MAX_IMAGES} 张图片", code="images_too_many")
return keys
def normalize_threshold(value: Any) -> int:
number = _to_int(value)
if number is None:
return POINTS_THRESHOLD_DEFAULT
if number < 0:
raise CollabError("积分阈值不能小于 0", code="points_threshold_invalid")
return min(number, POINTS_THRESHOLD_MAX)
def resolve_config(db: Session) -> DouyuConfig:
config = db.query(DouyuConfig).order_by(DouyuConfig.id.asc()).first()
if config is None:
config = DouyuConfig()
db.add(config)
db.flush()
return config
def account_by_username(db: Session, username: str) -> Account | None:
name = _text(username, max_length=128)
if not name:
return None
return (
db.query(Account)
.filter(Account.username == name)
.order_by(Account.id.asc())
.first()
)
def account_by_id(db: Session, account_id: int | None) -> Account | None:
if not account_id:
return None
return db.query(Account).filter(Account.id == account_id).first()
def users_by_id(db: Session, ids: set[int]) -> dict[int, str]:
clean = {int(item) for item in ids if item}
if not clean:
return {}
return {
int(user_id): str(username)
for user_id, username in db.query(User.id, User.username)
.filter(User.id.in_(clean))
.all()
}
def accounts_by_id(db: Session, ids: set[int]) -> dict[int, Account]:
clean = {int(item) for item in ids if item}
if not clean:
return {}
return {
int(account.id): account
for account in db.query(Account).filter(Account.id.in_(clean)).all()
}
def _client_for_account(account: Account) -> DouyuActivityClient:
cookie = str(account.cookie or "").strip()
if not cookie:
raise CollabError("该账号没有 CK", code="account_no_cookie")
return DouyuActivityClient(cookie)
def serialize_live_goods(goods: list[dict[str, Any]]) -> list[dict[str, Any]]:
"""把 storehome 原始商品映射成前端稳定字段。"""
result: list[dict[str, Any]] = []
for raw in goods:
if not isinstance(raw, dict):
continue
commodity_id = _text(
_first_value(raw, "commodityId", "commodity_id"), max_length=64
)
name = _text(_first_value(raw, "commodityName", "name"), max_length=256)
open_status = _to_int(_first_value(raw, "openStatus", "open_status"))
mark = _text(_first_value(raw, "mark"), max_length=32)
status_value = _first_value(raw, "status")
result.append(
{
"commodity_id": commodity_id,
"name": name,
"score": _to_int(_first_value(raw, "score")),
"status": str(status_value) if status_value is not None else "",
"open_status": open_status,
"start_time": _to_int(_first_value(raw, "startTime", "start_time")),
"end_time": _to_int(_first_value(raw, "endTime", "end_time")),
"mark": mark,
"count": _to_int(_first_value(raw, "count")),
"elite_limited": _to_int(_first_value(raw, "eliteLimited")),
"is_limited": bool(mark) or open_status == 0,
"raw": _json_safe(raw),
}
)
return result
def _probe_with_client(
account: Account,
*,
manual_id: str,
rid: str,
act_alias: str,
client: Any | None = None,
with_goods: bool = True,
) -> dict[str, Any]:
"""只读探针:账号 CK → 手册积分 / 绑定角色 / 鱼翅余额 / 商品列表。"""
if not str(account.cookie or "").strip():
return {
"found": True,
"ok": False,
"ck_status": CK_UNVERIFIED,
"message": "该账号还没有 CK",
"errors": ["该账号还没有 CK"],
}
active_client = client or _client_for_account(account)
errors: list[str] = []
elite: dict[str, Any] = {}
bind: dict[str, Any] = {}
gold: dict[str, Any] = {}
goods: list[dict[str, Any]] = []
try:
elite = active_client.elite_user_info(manual_id=manual_id, rid=rid)
except Exception as exc: # noqa: BLE001 - 外部接口边界
errors.append(f"手册状态: {exc}")
try:
bind = active_client.bind_info(act_alias=act_alias)
except Exception as exc: # noqa: BLE001
errors.append(f"绑定角色: {exc}")
try:
gold = active_client.gold_account()
except Exception as exc: # noqa: BLE001
errors.append(f"鱼翅余额: {exc}")
if with_goods:
try:
goods = (
active_client.list_goods(manual_id=manual_id, rid=rid).get("goods")
or []
)
except Exception as exc: # noqa: BLE001
errors.append(f"活动商品: {exc}")
manual_type = _to_int(elite.get("manual_type"))
manual_score = _to_int(elite.get("manual_score"))
expire_time = _to_int(elite.get("expire_time"))
user_info = elite.get("user_info") or {}
profile = DouyuActivityClient.profile_from_cookie(str(account.cookie or ""))
bind_role = _text(bind.get("role_name"), max_length=128)
area_name = _text(bind.get("area_name"), max_length=64)
plat_name = _text(bind.get("plat_name"), max_length=64)
bind_channel = " · ".join(item for item in (area_name, plat_name) if item)
bind_account = _text(bind.get("nickname"), max_length=128)
ck_status = CK_VALID if (elite or bind) else CK_INVALID
ok = bool(bind_role) and not (elite == {} and bind == {})
return {
"found": True,
"ok": ok,
"ck_status": ck_status,
"message": ";".join(errors) if errors else ("" if ok else "未读到绑定角色"),
"errors": errors,
"account_id": int(account.id),
"account_username": str(account.username or ""),
"account_uid": _text(account.uid or profile.get("uid"), max_length=32),
"account_nickname": _text(
user_info.get("nickname") or account.nickname or profile.get("nickname"),
max_length=128,
),
"manual_type": manual_type,
"manual_score": manual_score,
"expire_time": expire_time,
"manual_opened": bool(manual_type is not None and manual_type >= 1),
"bind_game_account": bind_account,
"bind_game_role": bind_role,
"bind_game_channel": bind_channel,
"fish_fin_balance": _to_int(gold.get("gold")),
"goods": serialize_live_goods(goods),
"goods_count": len(goods),
}
def lookup_account(
db: Session,
username: str,
*,
client: Any | None = None,
with_goods: bool = True,
) -> dict[str, Any]:
config = resolve_config(db)
account = account_by_username(db, username)
if account is None:
return {
"found": False,
"ok": False,
"message": "账号不存在,请先到「账号管理」导入并登录",
"account_username": _text(username, max_length=128),
}
probe = _probe_with_client(
account,
manual_id=str(config.manual_id or ""),
rid=str(config.rid or ""),
act_alias=str(config.bind_act_alias or ""),
client=client,
with_goods=with_goods,
)
orders = (
db.query(DouyuExternalOrder)
.filter(
DouyuExternalOrder.account_id == account.id,
DouyuExternalOrder.archived_at.is_(None),
DouyuExternalOrder.status != STATUS_VOID,
)
.order_by(DouyuExternalOrder.id.asc())
.all()
)
probe["orders_using_account"] = [
{"id": int(order.id), "order_no": order.order_no, "status": order.status}
for order in orders
]
probe["ck_status_label"] = CK_LABELS.get(str(probe.get("ck_status")), "")
return probe
def apply_probe_snapshot(
order: DouyuExternalOrder, account: Account, probe: Mapping[str, Any]
) -> None:
order.account_id = int(account.id)
order.account_username = _text(account.username, max_length=128) or _text(
probe.get("account_username"), max_length=128
)
order.account_uid = _text(probe.get("account_uid") or account.uid, max_length=32)
order.account_nickname = _text(
probe.get("account_nickname") or account.nickname, max_length=128
)
order.bind_game_account = _text(probe.get("bind_game_account"), max_length=128)
order.bind_game_role = _text(probe.get("bind_game_role"), max_length=128)
order.bind_game_channel = _text(probe.get("bind_game_channel"), max_length=128)
order.bind_snapshot_at = _now()
fish = _to_int(probe.get("fish_fin_balance"))
points = _to_int(probe.get("manual_score"))
if fish is not None:
order.fish_fin_balance = fish
account.gold_balance = fish
if points is not None:
order.points_balance = points
account.points = points
ck_status = str(probe.get("ck_status") or "")
if ck_status in (CK_VALID, CK_INVALID):
account.ck_check_status = ck_status
account.ck_checked_at = _now()
account.updated_at = _now()
def _assert_version(order: DouyuExternalOrder, expected: int | None) -> None:
if expected is not None and int(expected) != int(order.version or 0):
raise CollabError(
"这行已被别人修改,请刷新后再试",
status_code=409,
code="version_conflict",
)
def _assert_mutable(order: DouyuExternalOrder) -> None:
if order.archived_at is not None:
raise CollabError("已归档的订单不能再编辑", code="order_archived")
if order.status == STATUS_VOID:
raise CollabError("已作废的订单不能再编辑", code="order_void")
def _resolve_gift(
db: Session, *, gift_name: str, commodity_id: str, required_points: Any
) -> tuple[str, str, int | None]:
name = _text(gift_name, max_length=128)
cid = _text(commodity_id, max_length=64)
points = _to_int(required_points)
query = db.query(DouyuGoodsSnapshot)
row = None
if cid:
row = query.filter(DouyuGoodsSnapshot.commodity_id == cid).first()
if row is None and name:
row = query.filter(DouyuGoodsSnapshot.name == name).first()
if row is not None:
if not name:
name = _text(row.name, max_length=128)
if not cid:
cid = _text(row.commodity_id, max_length=64)
if points is None:
points = _to_int(row.score)
return name, cid, points
def _missing_for_recharge(order: DouyuExternalOrder) -> list[str]:
missing: list[str] = []
if not order.account_id or not _text(order.account_username):
missing.append("斗鱼账号")
if not order.gift_name:
missing.append("礼包")
if not order.home_images:
missing.append("主页截图")
if not order.bind_game_role:
missing.append("绑定角色(先校验账号)")
return missing
def _ready_for_recharge(order: DouyuExternalOrder) -> bool:
return not _missing_for_recharge(order)
def _maybe_advance_to_recharge(
db: Session,
current: User,
order: DouyuExternalOrder,
*,
commit: bool = False,
) -> None:
if order.status != STATUS_INCOMPLETE or not _ready_for_recharge(order):
return
old = order.status
order.status = STATUS_TO_RECHARGE
order.updated_by = current.id
order.version = int(order.version or 0) + 1
record_audit(
db,
current,
action="douyu:collab:status_auto",
target=f"douyu_external_order:{order.id}",
detail={"from": old, "to": order.status, "reason": "资料齐全"},
)
if commit:
db.commit()
def create_order(
db: Session,
current: User,
*,
order_no: str,
shop: str = "",
owner_id: int | None = None,
account_username: str = "",
gift_name: str = "",
commodity_id: str = "",
required_points: Any = None,
home_images: Any = None,
remark: str = "",
draft: bool = False,
require_verified: bool = True,
client: Any | None = None,
commit: bool = True,
) -> DouyuExternalOrder:
order_text = _text(order_no, max_length=64)
if not order_text:
raise CollabError("订单号不能为空", code="order_no_required")
exists = (
db.query(DouyuExternalOrder)
.filter(DouyuExternalOrder.order_no == order_text)
.first()
)
if exists is not None:
raise CollabError("订单号已存在", status_code=409, code="order_no_exists")
images = normalize_images(home_images)
name, cid, points = _resolve_gift(
db,
gift_name=gift_name,
commodity_id=commodity_id,
required_points=required_points,
)
order = DouyuExternalOrder(
order_no=order_text,
shop=_text(shop, max_length=64),
owner_id=int(owner_id) if owner_id is not None else int(current.id),
account_username=_text(account_username, max_length=128),
gift_name=name,
commodity_id=cid,
required_points=points,
home_images=images,
remark=_text(remark, max_length=512),
status=STATUS_INCOMPLETE,
points_threshold=POINTS_THRESHOLD_DEFAULT,
created_by=current.id,
updated_by=current.id,
version=0,
)
account: Account | None = None
probe: dict[str, Any] | None = None
if order.account_username:
account = account_by_username(db, order.account_username)
if account is None and not draft:
raise CollabError(
"斗鱼账号不存在,请先到账号管理导入/登录",
code="account_not_found",
)
if account is not None and not draft:
config = resolve_config(db)
probe = _probe_with_client(
account,
manual_id=str(config.manual_id or ""),
rid=str(config.rid or ""),
act_alias=str(config.bind_act_alias or ""),
client=client,
with_goods=True,
)
apply_probe_snapshot(order, account, probe)
if points is None and name:
live = next(
(
item
for item in (probe.get("goods") or [])
if str(item.get("commodityName") or "") == name
),
None,
)
if live is not None:
points = _to_int(live.get("score"))
order.required_points = points
elif account is not None:
# 草稿不联网探针,但既然用户名已存在,先把内部账号关联上,
# 后续补资料时不用再靠“用户名变化”触发解析。
order.account_id = int(account.id)
order.account_uid = _text(account.uid, max_length=32)
order.account_nickname = _text(account.nickname, max_length=128)
if not draft and require_verified:
missing: list[str] = []
if not order.shop:
missing.append("店铺")
if not order.account_username:
missing.append("斗鱼账号")
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",
)
if account is None:
raise CollabError("斗鱼账号不存在", code="account_not_found")
if probe is None or not probe.get("ok") or not probe.get("bind_game_role"):
message = (probe or {}).get("message") or "未读到绑定角色"
raise CollabError(
f"这个账号当前不能用:{message}",
code="account_not_verified",
)
if order.shop:
ensure_shops_registered(db, current, [order.shop])
db.add(order)
db.flush()
record_audit(
db,
current,
action="douyu:collab:create",
target=f"douyu_external_order:{order.id}",
detail={
"order_no": order.order_no,
"shop": order.shop,
"account_username": order.account_username,
"account_id": order.account_id,
"gift_name": order.gift_name,
"required_points": order.required_points,
"bind_game_role": order.bind_game_role,
"home_images": len(order.home_images or []),
"draft": bool(draft),
},
)
_maybe_advance_to_recharge(db, current, order)
if commit:
db.commit()
db.refresh(order)
return order
def update_order(
db: Session,
current: User,
order: DouyuExternalOrder,
payload: Mapping[str, Any],
*,
expected_version: int | None = None,
client: Any | None = None,
commit: bool = True,
) -> dict[str, Any]:
_assert_mutable(order)
_assert_version(order, expected_version)
changes: dict[str, dict[str, Any]] = {}
editable = {
"order_no",
"shop",
"owner_id",
"account_username",
"gift_name",
"commodity_id",
"required_points",
"remark",
}
for field in editable:
if field not in payload:
continue
old = getattr(order, field)
if field == "owner_id":
new = int(payload[field]) if payload[field] is not None else None
elif field == "required_points":
new = _to_int(payload[field])
elif field == "order_no":
new = _text(payload[field], max_length=64)
if new and new != order.order_no:
duplicate = (
db.query(DouyuExternalOrder.id)
.filter(
DouyuExternalOrder.order_no == new,
DouyuExternalOrder.id != order.id,
)
.first()
)
if duplicate:
raise CollabError(
"订单号已存在", status_code=409, code="order_no_exists"
)
else:
new = _text(payload[field], max_length=512 if field == "remark" else 128)
if new != old:
changes[field] = {"old": old, "new": new}
setattr(order, field, new)
if "account_username" in payload and (
order.account_id is None
or not order.bind_game_role
or "account_username" in changes
):
account = account_by_username(db, order.account_username)
if account is None:
if order.status == STATUS_INCOMPLETE:
order.account_id = None
order.account_uid = ""
order.account_nickname = ""
else:
raise CollabError("斗鱼账号不存在", code="account_not_found")
else:
config = resolve_config(db)
probe = _probe_with_client(
account,
manual_id=str(config.manual_id or ""),
rid=str(config.rid or ""),
act_alias=str(config.bind_act_alias or ""),
client=client,
with_goods=True,
)
apply_probe_snapshot(order, account, probe)
changes["account_id"] = {"old": None, "new": int(account.id)}
changes["bind_game_role"] = {"old": "", "new": order.bind_game_role}
if "home_images" in payload:
images = normalize_images(payload.get("home_images"))
if images != (order.home_images or []):
changes["home_images"] = {
"old": len(order.home_images or []),
"new": len(images),
}
order.home_images = images
if order.gift_name and "commodity_id" in payload:
_, cid, points = _resolve_gift(
db,
gift_name=order.gift_name,
commodity_id=str(order.commodity_id or ""),
required_points=order.required_points,
)
order.commodity_id = cid
if points is not None and order.required_points is None:
order.required_points = points
if changes:
order.updated_by = current.id
order.version = int(order.version or 0) + 1
record_audit(
db,
current,
action="douyu:collab:update",
target=f"douyu_external_order:{order.id}",
detail=changes,
)
_maybe_advance_to_recharge(db, current, order)
if commit:
db.commit()
db.refresh(order)
return changes
def set_account(
db: Session,
current: User,
order: DouyuExternalOrder,
username: str,
*,
expected_version: int | None = None,
client: Any | None = None,
commit: bool = True,
) -> dict[str, Any]:
_assert_mutable(order)
_assert_version(order, expected_version)
account = account_by_username(db, username)
if account is None:
raise CollabError("斗鱼账号不存在", code="account_not_found")
config = resolve_config(db)
probe = _probe_with_client(
account,
manual_id=str(config.manual_id or ""),
rid=str(config.rid or ""),
act_alias=str(config.bind_act_alias or ""),
client=client,
with_goods=True,
)
apply_probe_snapshot(order, account, probe)
order.updated_by = current.id
order.version = int(order.version or 0) + 1
record_audit(
db,
current,
action="douyu:collab:account",
target=f"douyu_external_order:{order.id}",
detail={
"account_id": account.id,
"account_username": account.username,
"ck_status": probe.get("ck_status"),
"role": probe.get("bind_game_role"),
},
)
_maybe_advance_to_recharge(db, current, order)
if commit:
db.commit()
db.refresh(order)
return {"order": order, "probe": probe}
def verify_order(
db: Session,
current: User,
order: DouyuExternalOrder,
*,
client: Any | None = None,
commit: bool = True,
) -> dict[str, Any]:
_assert_mutable(order)
account = account_by_id(db, order.account_id) or account_by_username(
db, order.account_username
)
if account is None:
raise CollabError("订单关联的斗鱼账号不存在", code="account_not_found")
config = resolve_config(db)
probe = _probe_with_client(
account,
manual_id=str(config.manual_id or ""),
rid=str(config.rid or ""),
act_alias=str(config.bind_act_alias or ""),
client=client,
with_goods=True,
)
apply_probe_snapshot(order, account, probe)
order.updated_by = current.id
order.version = int(order.version or 0) + 1
record_audit(
db,
current,
action="douyu:collab:verify",
target=f"douyu_external_order:{order.id}",
detail={
"account_id": account.id,
"ck_status": probe.get("ck_status"),
"role": probe.get("bind_game_role"),
"fish_fin_balance": probe.get("fish_fin_balance"),
"points": probe.get("manual_score"),
"message": probe.get("message"),
},
)
_maybe_advance_to_recharge(db, current, order)
if commit:
db.commit()
db.refresh(order)
return {"order": order, "probe": probe}
def _status_audit_target(order: DouyuExternalOrder) -> str:
return f"douyu_external_order:{order.id}"
def _assert_ready_for_recharge(order: DouyuExternalOrder) -> None:
missing: list[str] = []
if not order.account_id or not order.account_username:
missing.append("斗鱼账号")
if not order.gift_name:
missing.append("礼包")
if not order.home_images:
missing.append("主页截图")
if not order.bind_game_role:
missing.append("绑定角色(先校验账号)")
if missing:
raise CollabError(
f"进入待充值前还缺:{'、'.join(missing)}",
code="recharge_precondition",
)
def set_status(
db: Session,
current: User,
order: DouyuExternalOrder,
*,
status: str,
reason: str = "",
rollback: bool = False,
expected_version: int | None = None,
commit: bool = True,
) -> DouyuExternalOrder:
_assert_version(order, expected_version)
target = _text(status, max_length=16)
if target not in ORDER_STATUSES:
raise CollabError("非法状态", code="status_invalid")
if order.archived_at is not None:
raise CollabError("已归档的订单不能改状态", code="order_archived")
if order.status == STATUS_VOID:
raise CollabError("已作废的订单不能改状态", code="order_void")
old = order.status
now = _now()
if target == STATUS_VOID:
order.status = STATUS_VOID
order.void_reason = _text(reason, max_length=256)
order.updated_by = current.id
order.version = int(order.version or 0) + 1
record_audit(
db,
current,
action="douyu:collab:void",
target=_status_audit_target(order),
detail={"from": old, "reason": order.void_reason},
)
if commit:
db.commit()
db.refresh(order)
return order
if target == STATUS_FAILED:
order.status = STATUS_FAILED
order.updated_by = current.id
order.version = int(order.version or 0) + 1
record_audit(
db,
current,
action="douyu:collab:status",
target=_status_audit_target(order),
detail={"from": old, "to": target, "reason": _text(reason, max_length=256)},
)
if commit:
db.commit()
db.refresh(order)
return order
if target == STATUS_POINTS_ERROR:
order.status = STATUS_POINTS_ERROR
order.updated_by = current.id
order.version = int(order.version or 0) + 1
record_audit(
db,
current,
action="douyu:collab:points_error",
target=_status_audit_target(order),
detail={"from": old, "reason": _text(reason, max_length=256)},
)
if commit:
db.commit()
db.refresh(order)
return order
if old in (STATUS_FAILED, STATUS_VOID) and not rollback:
raise CollabError("从失败/作废状态回流程必须显式回退", code="rollback_required")
if target not in STAGE_INDEX:
raise CollabError("该状态不能手工切换", code="status_not_manual")
current_index = STAGE_INDEX.get(old)
target_index = STAGE_INDEX.get(target)
if current_index is None:
current_index = -1
if target_index is None:
raise CollabError("非法状态", code="status_invalid")
if target_index < current_index and not rollback:
raise CollabError("回退状态必须显式确认", code="rollback_required")
if target_index >= STAGE_INDEX[STATUS_TO_RECHARGE]:
_assert_ready_for_recharge(order)
if rollback:
for name, field in STATUS_TIMESTAMP_FIELDS.items():
if STAGE_INDEX.get(name, -1) > target_index:
setattr(order, field, None)
else:
for name, field in STATUS_TIMESTAMP_FIELDS.items():
if current_index < STAGE_INDEX[name] <= target_index:
setattr(order, field, now)
order.status = target
order.updated_by = current.id
order.version = int(order.version or 0) + 1
record_audit(
db,
current,
action="douyu:collab:status",
target=_status_audit_target(order),
detail={
"from": old,
"to": target,
"rollback": bool(rollback),
"reason": _text(reason, max_length=256),
},
)
if commit:
db.commit()
db.refresh(order)
return order
def _match_gift(records: list[dict[str, Any]], gift_name: str) -> int:
name = _text(gift_name)
if len(name) < 2:
return 0
matched = 0
for record in records:
record_name = _text(
record.get("commodityName") or record.get("commodity_name") or ""
)
if not record_name or len(record_name) < 2:
continue
if name in record_name or record_name in name:
matched += 1
return matched
def group_points_total(db: Session, order: DouyuExternalOrder) -> int:
if not order.account_id:
return int(order.required_points or 0)
rows = (
db.query(DouyuExternalOrder)
.filter(
DouyuExternalOrder.account_id == order.account_id,
DouyuExternalOrder.status.in_(POINTS_GROUP_STATUSES),
DouyuExternalOrder.archived_at.is_(None),
)
.all()
)
return sum(int(item.required_points or 0) for item in rows)
def query_activity(
db: Session,
current: User,
order: DouyuExternalOrder,
*,
client: Any | None = None,
source: str = "manual",
include_records: bool = True,
commit: bool = True,
) -> dict[str, Any]:
"""查询鱼翅、积分;``include_records`` 时同时查兑换记录并判定。"""
account = account_by_id(db, order.account_id) or account_by_username(
db, order.account_username
)
if account is None:
raise CollabError("订单关联的斗鱼账号不存在", code="account_not_found")
config = resolve_config(db)
active_client = client or _client_for_account(account)
errors: list[str] = []
elite: dict[str, Any] = {}
gold: dict[str, Any] = {}
records: list[dict[str, Any]] = []
orders: list[dict[str, Any]] = []
try:
elite = active_client.elite_user_info(
manual_id=str(config.manual_id or ""), rid=str(config.rid or "")
)
except Exception as exc: # noqa: BLE001
errors.append(f"积分: {exc}")
try:
gold = active_client.gold_account()
except Exception as exc: # noqa: BLE001
errors.append(f"鱼翅: {exc}")
if include_records:
try:
records = (
active_client.exchange_records(
manual_id=str(config.manual_id or ""), limit=100, offset=0
).get("records")
or []
)
except Exception as exc: # noqa: BLE001
errors.append(f"兑换记录: {exc}")
try:
orders = (
active_client.exchange_orders(
manual_id=str(config.manual_id or ""), page=1, page_size=10
).get("orders")
or []
)
except Exception as exc: # noqa: BLE001
errors.append(f"锁单: {exc}")
manual_score = _to_int(elite.get("manual_score"))
fish = _to_int(gold.get("gold"))
if manual_score is not None:
order.points_balance = manual_score
account.points = manual_score
if fish is not None:
order.fish_fin_balance = fish
account.gold_balance = fish
account.updated_at = _now()
required_total = group_points_total(db, order)
surplus = (
(manual_score - required_total)
if manual_score is not None and required_total > 0
else None
)
order.points_required_total = required_total
order.points_surplus = surplus
order.points_checked_at = _now()
threshold = normalize_threshold(order.points_threshold)
order.points_threshold = threshold
matched = 0
if not include_records:
verdict = VERDICT_UNKNOWN
message = ";".join(errors) if errors else "仅查询鱼翅与积分,未查询兑换记录"
elif errors:
verdict = VERDICT_UNKNOWN
message = ";".join(errors)
elif not order.gift_name:
verdict = VERDICT_UNKNOWN
message = "未填写礼包名,无法判定兑换结果"
else:
matched = _match_gift(records, order.gift_name)
verdict = VERDICT_SUCCESS if matched else VERDICT_FAILED
message = (
f"兑换记录命中「{order.gift_name}」{matched} 条"
if matched
else f"兑换记录里没有「{order.gift_name}」"
)
reason = ""
if surplus is not None:
if surplus < 0:
reason = "shortage"
elif surplus > threshold:
reason = "excess"
else:
reason = "ok"
points_message = ""
if required_total > 0 or manual_score is not None:
points_message = (
f"积分 {manual_score if manual_score is not None else '-'} / "
f"本账号未完成订单合计 {required_total},结余 "
f"{surplus if surplus is not None else '-'}"
)
order.points_check_reason = reason
order.points_check_message = points_message
order.points_check_detail = {
"fish_fin_balance": fish,
"points_balance": manual_score,
"required_total": required_total,
"surplus": surplus,
"threshold": threshold,
"errors": errors,
"exchange_records": len(records),
"locked_orders": len(orders),
}
metric = DouyuExternalOrderMetric(
order_id=order.id,
order_no=order.order_no,
account_id=order.account_id,
account_username=order.account_username,
fish_fin_balance=fish,
points_balance=manual_score,
points_required_total=required_total,
points_surplus=surplus,
records_total=len(records),
records_matched=matched,
verdict=verdict,
message=message,
source=_text(source, max_length=16) or "manual",
raw=_json_safe(
{
"elite": elite,
"gold": gold,
"records": records,
"orders": orders,
}
),
created_by=current.id,
)
db.add(metric)
order.updated_by = current.id
order.version = int(order.version or 0) + 1
record_audit(
db,
current,
action="douyu:collab:activity",
target=_status_audit_target(order),
detail={
"scope": "activity" if include_records else "balances",
"verdict": verdict,
"message": message,
"fish_fin_balance": fish,
"points": manual_score,
"required_total": required_total,
"surplus": surplus,
"records": len(records),
},
)
# 只有查兑换记录时才做业务判定;余额查询保持只读,不动状态。
if include_records:
if order.status == STATUS_SCRIPTED and verdict == VERDICT_SUCCESS:
set_status(
db,
current,
order,
status=STATUS_DONE,
reason=message,
expected_version=None,
commit=False,
)
elif order.status == STATUS_SCRIPTED and verdict == VERDICT_FAILED:
set_status(
db,
current,
order,
status=STATUS_FAILED,
reason=message,
expected_version=None,
commit=False,
)
elif order.status in (STATUS_RECHARGED, STATUS_SCRIPTED) and reason in (
"shortage",
"excess",
):
set_status(
db,
current,
order,
status=STATUS_POINTS_ERROR,
reason=points_message,
expected_version=None,
commit=False,
)
if commit:
db.commit()
db.refresh(order)
db.refresh(metric)
return {
"order": order,
"metric": metric,
"scope": "activity" if include_records else "balances",
"verdict": verdict,
"message": message,
"points_reason": reason,
"points_message": points_message,
"fish_fin_balance": fish,
"points_balance": manual_score,
"required_total": required_total,
"surplus": surplus,
"errors": errors,
}
def ensure_shops_registered(
db: Session, current: User | None, names: list[str]
) -> None:
seen: set[str] = set()
for raw in names:
name = _text(raw, max_length=64)
if not name or name in seen:
continue
seen.add(name)
exists = (
db.query(DouyuCollabShop.id).filter(DouyuCollabShop.name == name).first()
)
if exists:
continue
db.add(
DouyuCollabShop(
name=name,
created_by=current.id if current else None,
updated_by=current.id if current else None,
)
)
def list_shops(db: Session, *, active_only: bool = False) -> list[DouyuCollabShop]:
query = db.query(DouyuCollabShop)
if active_only:
query = query.filter(DouyuCollabShop.is_active.is_(True))
return query.order_by(
DouyuCollabShop.sort_order.asc(), DouyuCollabShop.id.asc()
).all()
def get_shop(db: Session, shop_id: int) -> DouyuCollabShop:
shop = db.query(DouyuCollabShop).filter(DouyuCollabShop.id == shop_id).first()
if shop is None:
raise CollabError("店铺不存在", status_code=404, code="shop_not_found")
return shop
def create_shop(
db: Session, current: User, *, name: str, remark: str = ""
) -> DouyuCollabShop:
clean = _text(name, max_length=64)
if not clean:
raise CollabError("店铺名不能为空", code="shop_name_required")
exists = db.query(DouyuCollabShop).filter(DouyuCollabShop.name == clean).first()
if exists:
raise CollabError("店铺已存在", status_code=409, code="shop_exists")
shop = DouyuCollabShop(
name=clean,
remark=_text(remark, max_length=255),
created_by=current.id,
updated_by=current.id,
)
db.add(shop)
db.commit()
db.refresh(shop)
return shop
def update_shop(
db: Session,
current: User,
shop: DouyuCollabShop,
payload: Mapping[str, Any],
) -> DouyuCollabShop:
old_name = shop.name
old = {
"name": shop.name,
"sort_order": shop.sort_order,
"is_active": shop.is_active,
"remark": shop.remark,
}
if "name" in payload:
name = _text(payload.get("name"), max_length=64)
if not name:
raise CollabError("店铺名不能为空", code="shop_name_required")
duplicate = (
db.query(DouyuCollabShop.id)
.filter(
DouyuCollabShop.name == name,
DouyuCollabShop.id != shop.id,
)
.first()
)
if duplicate:
raise CollabError("店铺已存在", status_code=409, code="shop_exists")
shop.name = name
if "sort_order" in payload:
shop.sort_order = _to_int(payload.get("sort_order")) or 0
if "is_active" in payload:
shop.is_active = bool(payload.get("is_active"))
if "remark" in payload:
shop.remark = _text(payload.get("remark"), max_length=255)
shop.updated_by = current.id
if shop.name != old_name:
db.query(DouyuExternalOrder).filter(DouyuExternalOrder.shop == old_name).update(
{DouyuExternalOrder.shop: shop.name}, synchronize_session=False
)
record_audit(
db,
current,
action="douyu:collab:shop_update",
target=f"douyu_collab_shop:{shop.id}",
detail={
"old": old,
"new": {
"name": shop.name,
"sort_order": shop.sort_order,
"is_active": shop.is_active,
"remark": shop.remark,
},
},
)
db.commit()
db.refresh(shop)
return shop
def delete_shop(db: Session, current: User, shop: DouyuCollabShop) -> dict[str, Any]:
shop_name = shop.name
used = (
db.query(func.count(DouyuExternalOrder.id))
.filter(DouyuExternalOrder.shop == shop_name)
.scalar()
or 0
)
if used:
raise CollabError(
f"还有 {used} 个协作单使用「{shop_name}」,请先改名或停用",
code="shop_in_use",
)
record_audit(
db,
current,
action="douyu:collab:shop_delete",
target=f"douyu_collab_shop:{shop.id}",
detail={"name": shop_name},
)
db.delete(shop)
db.commit()
return {"success": True, "name": shop_name}
def list_gift_names(db: Session) -> list[str]:
rows = (
db.query(DouyuExternalOrder.gift_name)
.filter(DouyuExternalOrder.gift_name != "")
.distinct()
.order_by(DouyuExternalOrder.gift_name.asc())
.all()
)
return [str(name) for (name,) in rows if name]
def refresh_activity_goods(
db: Session,
current: User,
*,
account_id: int | None = None,
client: Any | None = None,
) -> dict[str, Any]:
config = resolve_config(db)
account = account_by_id(db, account_id)
if account is None:
account = (
db.query(Account)
.filter(Account.cookie.isnot(None), Account.cookie != "")
.order_by(Account.id.asc())
.first()
)
if account is None:
raise CollabError("没有带 CK 的斗鱼账号,无法刷新活动商品", code="no_account")
active_client = client or _client_for_account(account)
result = active_client.list_goods(
manual_id=str(config.manual_id or ""), rid=str(config.rid or "")
)
goods = result.get("goods") or []
now = _now()
for raw in goods:
commodity_id = _text(
raw.get("commodityId") or raw.get("commodity_id"), max_length=64
)
if not commodity_id:
continue
row = (
db.query(DouyuGoodsSnapshot)
.filter(DouyuGoodsSnapshot.commodity_id == commodity_id)
.first()
)
if row is None:
row = DouyuGoodsSnapshot(commodity_id=commodity_id)
db.add(row)
row.name = _text(raw.get("commodityName") or raw.get("name"), max_length=256)
row.score = _to_int(raw.get("score"))
status_value = _first_value(raw, "status")
row.status = str(status_value) if status_value is not None else ""
row.raw = _json_safe(raw)
row.updated_at = now
record_audit(
db,
current,
action="douyu:collab:goods_refresh",
target=f"douyu_external_goods:{account.id}",
detail={"count": len(goods), "account_id": account.id},
)
db.commit()
return {"count": len(goods), "goods": serialized_goods(db)}
def serialized_goods(db: Session) -> list[dict[str, Any]]:
rows = db.query(DouyuGoodsSnapshot).order_by(DouyuGoodsSnapshot.id.asc()).all()
result: list[dict[str, Any]] = []
for row in rows:
raw = row.raw or {}
start = _to_int(_first_value(raw, "startTime", "start_time"))
end = _to_int(_first_value(raw, "endTime", "end_time"))
open_status = _to_int(_first_value(raw, "openStatus", "open_status"))
mark = _text(_first_value(raw, "mark"), max_length=32)
result.append(
{
"commodity_id": row.commodity_id,
"name": row.name,
"score": row.score,
"status": row.status,
"open_status": open_status,
"start_time": start,
"end_time": end,
"mark": mark,
"count": _to_int(_first_value(raw, "count")),
"elite_limited": _to_int(_first_value(raw, "eliteLimited")),
"is_limited": bool(mark) or open_status == 0,
"raw": raw,
}
)
return result
def _metric_views(
db: Session, order_ids: list[int]
) -> tuple[dict[int, DouyuExternalOrderMetric], dict[int, DouyuExternalOrderMetric]]:
"""返回最近一条快照,以及最近一条带兑换判定的快照。
余额查询也会落快照,但它的 verdict 是 unknown;列表上的兑换结论应沿用
最近一次「查兑换记录」的结果,不能被余额刷新覆盖。
"""
if not order_ids:
return {}, {}
rows = (
db.query(DouyuExternalOrderMetric)
.filter(DouyuExternalOrderMetric.order_id.in_(order_ids))
.order_by(
DouyuExternalOrderMetric.order_id.asc(),
DouyuExternalOrderMetric.id.desc(),
)
.all()
)
latest: dict[int, DouyuExternalOrderMetric] = {}
verdict_latest: dict[int, DouyuExternalOrderMetric] = {}
for row in rows:
order_id = int(row.order_id)
latest.setdefault(order_id, row)
if not str(row.source or "").startswith("balance"):
verdict_latest.setdefault(order_id, row)
return latest, verdict_latest
def account_clusters(
db: Session, orders: list[DouyuExternalOrder]
) -> dict[int, dict[str, Any]]:
account_ids = {int(order.account_id) for order in orders if order.account_id}
if not account_ids:
return {}
rows = (
db.query(DouyuExternalOrder)
.filter(
DouyuExternalOrder.account_id.in_(account_ids),
DouyuExternalOrder.archived_at.is_(None),
DouyuExternalOrder.status != STATUS_VOID,
)
.order_by(DouyuExternalOrder.id.asc())
.all()
)
grouped: dict[int, list[DouyuExternalOrder]] = {}
for row in rows:
if row.account_id:
grouped.setdefault(int(row.account_id), []).append(row)
result: dict[int, dict[str, Any]] = {}
for account_id, items in grouped.items():
if len(items) <= 1:
continue
result[account_id] = {
"total": len(items),
"orders": [
{
"id": int(item.id),
"order_no": item.order_no,
"status": item.status,
"status_label": ORDER_STATUS_LABELS.get(item.status, item.status),
"gift_name": item.gift_name,
}
for item in items
],
}
return result
def _image_urls(keys: list[str], variant: str = "") -> list[str]:
return [build_file_url(key, variant) for key in keys]
def serialize_order(
order: DouyuExternalOrder,
*,
users: Mapping[int, str] | None = None,
accounts: Mapping[int, Account] | None = None,
cluster: Mapping[str, Any] | None = None,
metric: DouyuExternalOrderMetric | None = None,
verdict_metric: DouyuExternalOrderMetric | None = None,
) -> dict[str, Any]:
users = users or {}
accounts = accounts or {}
account = accounts.get(int(order.account_id)) if order.account_id else None
images = list(order.home_images or [])
payload = {
"id": int(order.id),
"order_no": order.order_no,
"shop": order.shop or "",
"owner_id": order.owner_id,
"owner_name": users.get(int(order.owner_id), "") if order.owner_id else "",
"account_id": order.account_id,
"account_username": order.account_username or "",
"account_uid": order.account_uid or "",
"account_nickname": order.account_nickname or "",
"account_exists": account is not None,
"has_cookie": bool(account and account.cookie),
"account_ck_status": (account.ck_check_status if account else "") or "",
"account_status": (account.status if account else "") 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,
"home_images": images,
"home_image_urls": _image_urls(images),
"home_image_thumb_urls": _image_urls(images, "thumb"),
"gift_name": order.gift_name or "",
"commodity_id": order.commodity_id or "",
"required_points": order.required_points,
"remark": order.remark or "",
"status": order.status or STATUS_INCOMPLETE,
"status_label": ORDER_STATUS_LABELS.get(
order.status or STATUS_INCOMPLETE, order.status or ""
),
"next_status": (
STATUS_FLOW[STAGE_INDEX[order.status] + 1]
if order.status in STAGE_INDEX and order.status != STATUS_DONE
else None
),
"missing_fields": _missing_for_recharge(order),
"fish_fin_balance": order.fish_fin_balance,
"points_balance": order.points_balance,
"points_required_total": order.points_required_total,
"points_surplus": order.points_surplus,
"points_threshold": order.points_threshold,
"points_checked_at": order.points_checked_at,
"points_check_reason": order.points_check_reason or "",
"points_check_message": order.points_check_message or "",
"points_check_detail": order.points_check_detail,
"recharged_at": order.recharged_at,
"scripted_at": order.scripted_at,
"done_at": order.done_at,
"void_reason": order.void_reason or "",
"is_archived": order.archived_at is not None,
"archived_at": order.archived_at,
"archived_by": order.archived_by,
"archived_by_name": (
users.get(int(order.archived_by), "") if order.archived_by else ""
),
"archive_reason": order.archive_reason or "",
"created_by": order.created_by,
"created_by_name": users.get(int(order.created_by), "")
if order.created_by
else "",
"updated_by": order.updated_by,
"updated_by_name": users.get(int(order.updated_by), "")
if order.updated_by
else "",
"version": int(order.version or 0),
"created_at": order.created_at,
"updated_at": order.updated_at,
"account_cluster": dict(cluster) if cluster else None,
"last_metric_at": metric.created_at if metric else None,
"last_metric_message": metric.message if metric else "",
"last_verdict": verdict_metric.verdict if verdict_metric else "",
"last_verdict_label": {
VERDICT_SUCCESS: "兑换成功",
VERDICT_FAILED: "兑换失败",
VERDICT_UNKNOWN: "无法判定",
}.get(verdict_metric.verdict, "")
if verdict_metric
else "",
"last_records_total": verdict_metric.records_total if verdict_metric else 0,
"last_records_matched": verdict_metric.records_matched if verdict_metric else 0,
}
return payload
def _base_order_query(db: Session):
return db.query(DouyuExternalOrder)
def _apply_order_filters(
db: Session,
query,
*,
include_void: bool = False,
archive_scope: str = "active",
status: str = "",
shop: str = "",
owner_id: int | None = None,
gift_name: str = "",
keyword: str = "",
ck_status: str = "",
):
scope = archive_scope if archive_scope in ARCHIVE_SCOPES else "active"
if scope == "active":
query = query.filter(DouyuExternalOrder.archived_at.is_(None))
elif scope == "archived":
query = query.filter(DouyuExternalOrder.archived_at.isnot(None))
if not include_void:
query = query.filter(DouyuExternalOrder.status != STATUS_VOID)
if _text(status):
query = query.filter(DouyuExternalOrder.status == _text(status))
if _text(shop):
query = query.filter(DouyuExternalOrder.shop == _text(shop))
if owner_id is not None:
query = query.filter(DouyuExternalOrder.owner_id == int(owner_id))
if _text(gift_name):
query = query.filter(DouyuExternalOrder.gift_name == _text(gift_name))
ck_scope = _text(ck_status)
if ck_scope:
query = query.outerjoin(Account, Account.id == DouyuExternalOrder.account_id)
if ck_scope == CK_VALID:
query = query.filter(Account.ck_check_status == CK_VALID)
elif ck_scope == CK_INVALID:
query = query.filter(Account.ck_check_status == CK_INVALID)
elif ck_scope == CK_UNVERIFIED:
query = query.filter(
or_(
DouyuExternalOrder.account_id.is_(None),
Account.ck_check_status.is_(None),
Account.ck_check_status.in_(("", CK_UNVERIFIED)),
)
)
search = _text(keyword)
if search:
pattern = f"%{search}%"
query = query.filter(
or_(
DouyuExternalOrder.order_no.ilike(pattern),
DouyuExternalOrder.account_username.ilike(pattern),
DouyuExternalOrder.account_nickname.ilike(pattern),
DouyuExternalOrder.bind_game_role.ilike(pattern),
DouyuExternalOrder.shop.ilike(pattern),
DouyuExternalOrder.remark.ilike(pattern),
)
)
return query
def list_orders(
db: Session,
*,
page: int = 1,
page_size: int = 20,
include_void: bool = False,
archive_scope: str = "active",
status: str = "",
shop: str = "",
owner_id: int | None = None,
gift_name: str = "",
keyword: str = "",
ck_status: str = "",
) -> dict[str, Any]:
query = _apply_order_filters(
db,
_base_order_query(db),
include_void=include_void,
archive_scope=archive_scope,
status=status,
shop=shop,
owner_id=owner_id,
gift_name=gift_name,
keyword=keyword,
ck_status=ck_status,
)
total = (
query.order_by(None).with_entities(func.count(DouyuExternalOrder.id)).scalar()
or 0
)
orders = (
query.order_by(DouyuExternalOrder.id.asc())
.offset((max(1, page) - 1) * page_size)
.limit(page_size)
.all()
)
users = users_by_id(
db,
{
int(item)
for order in orders
for item in (
order.owner_id,
order.created_by,
order.updated_by,
order.archived_by,
)
if item
},
)
accounts = accounts_by_id(
db, {int(order.account_id) for order in orders if order.account_id}
)
clusters = account_clusters(db, orders)
metrics, verdict_metrics = _metric_views(db, [int(order.id) for order in orders])
items = [
serialize_order(
order,
users=users,
accounts=accounts,
cluster=clusters.get(int(order.account_id)) if order.account_id else None,
metric=metrics.get(int(order.id)),
verdict_metric=verdict_metrics.get(int(order.id)),
)
for order in orders
]
return {"items": items, "total": int(total), "page": page, "page_size": page_size}
def get_order(db: Session, order_id: int) -> DouyuExternalOrder:
order = (
db.query(DouyuExternalOrder).filter(DouyuExternalOrder.id == order_id).first()
)
if order is None:
raise CollabError("协作单不存在", status_code=404, code="order_not_found")
return order
def serialize_one(db: Session, order: DouyuExternalOrder) -> dict[str, Any]:
users = users_by_id(
db,
{
int(item)
for item in (
order.owner_id,
order.created_by,
order.updated_by,
order.archived_by,
)
if item
},
)
accounts = accounts_by_id(
db, {int(order.account_id)} if order.account_id else set()
)
clusters = account_clusters(db, [order])
metrics, verdict_metrics = _metric_views(db, [int(order.id)])
return serialize_order(
order,
users=users,
accounts=accounts,
cluster=clusters.get(int(order.account_id)) if order.account_id else None,
metric=metrics.get(int(order.id)),
verdict_metric=verdict_metrics.get(int(order.id)),
)
def summary(
db: Session, *, include_void: bool = False, archive_scope: str = "active"
) -> dict[str, Any]:
query = _apply_order_filters(
db,
_base_order_query(db),
include_void=include_void,
archive_scope=archive_scope,
)
rows = query.with_entities(
DouyuExternalOrder.status, func.count(DouyuExternalOrder.id)
).group_by(DouyuExternalOrder.status)
by_status = {str(status): int(count) for status, count in rows.all()}
total = sum(by_status.values())
ck_invalid = (
query.outerjoin(Account, Account.id == DouyuExternalOrder.account_id)
.with_entities(func.count(DouyuExternalOrder.id))
.filter(Account.ck_check_status == CK_INVALID)
.scalar()
or 0
)
return {
"total": total,
"by_status": by_status,
"ck_invalid": int(ck_invalid),
"archived_total": (
db.query(func.count(DouyuExternalOrder.id))
.filter(DouyuExternalOrder.archived_at.isnot(None))
.scalar()
or 0
),
"account_missing": (
db.query(func.count(DouyuExternalOrder.id))
.filter(
DouyuExternalOrder.account_id.is_(None),
DouyuExternalOrder.archived_at.is_(None),
)
.scalar()
or 0
),
}
def order_metrics(db: Session, order_id: int) -> list[dict[str, Any]]:
rows = (
db.query(DouyuExternalOrderMetric)
.filter(DouyuExternalOrderMetric.order_id == order_id)
.order_by(DouyuExternalOrderMetric.id.desc())
.all()
)
return [serialize_metric(row) for row in rows]
def serialize_metric(row: DouyuExternalOrderMetric) -> dict[str, Any]:
return {
"id": int(row.id),
"order_id": int(row.order_id),
"order_no": row.order_no,
"account_username": row.account_username,
"fish_fin_balance": row.fish_fin_balance,
"points_balance": row.points_balance,
"points_required_total": row.points_required_total,
"points_surplus": row.points_surplus,
"records_total": row.records_total,
"records_matched": row.records_matched,
"verdict": row.verdict,
"verdict_label": {
VERDICT_SUCCESS: "兑换成功",
VERDICT_FAILED: "兑换失败",
VERDICT_UNKNOWN: "无法判定",
}.get(row.verdict, row.verdict),
"message": row.message,
"source": row.source,
"created_at": row.created_at,
}
def order_timeline(
db: Session, order_id: int, *, limit: int = 100
) -> list[dict[str, Any]]:
rows = (
db.query(AuditLog)
.filter(AuditLog.target == f"douyu_external_order:{order_id}")
.order_by(AuditLog.id.desc())
.limit(limit)
.all()
)
return [
{
"id": int(row.id),
"action": row.action,
"username": row.username or "",
"detail": row.detail or "",
"success": row.success,
"created_at": row.created_at,
}
for row in rows
]
def archive_orders(
db: Session,
current: User,
ids: list[int],
*,
reason: str = "",
) -> dict[str, Any]:
clean_ids = [int(item) for item in ids if int(item) > 0]
if not clean_ids:
return {"archived": 0}
rows = (
db.query(DouyuExternalOrder).filter(DouyuExternalOrder.id.in_(clean_ids)).all()
)
archived = 0
now = _now()
for order in rows:
if order.status not in ARCHIVABLE_STATUSES or order.archived_at is not None:
continue
order.archived_at = now
order.archived_by = current.id
order.archive_reason = _text(reason, max_length=256)
order.updated_by = current.id
order.version = int(order.version or 0) + 1
archived += 1
record_audit(
db,
current,
action="douyu:collab:archive",
target=_status_audit_target(order),
detail={"reason": order.archive_reason},
)
db.commit()
return {"archived": archived}
def unarchive_orders(db: Session, current: User, ids: list[int]) -> dict[str, Any]:
clean_ids = [int(item) for item in ids if int(item) > 0]
if not clean_ids:
return {"unarchived": 0}
rows = (
db.query(DouyuExternalOrder).filter(DouyuExternalOrder.id.in_(clean_ids)).all()
)
unarchived = 0
for order in rows:
if order.archived_at is None:
continue
order.archived_at = None
order.archived_by = None
order.archive_reason = ""
order.updated_by = current.id
order.version = int(order.version or 0) + 1
unarchived += 1
record_audit(
db,
current,
action="douyu:collab:unarchive",
target=_status_audit_target(order),
detail={},
)
db.commit()
return {"unarchived": unarchived}
def delete_order(
db: Session, current: User, order: DouyuExternalOrder
) -> dict[str, Any]:
order_no = order.order_no
record_audit(
db,
current,
action="douyu:collab:delete",
target=f"douyu_external_order:{order.id}",
detail={"order_no": order_no, "account": order.account_username},
)
db.delete(order)
db.commit()
return {"success": True, "order_no": order_no}
def export_cks(
db: Session,
current: User,
order_ids: Sequence[int],
*,
commit: bool = True,
) -> dict[str, Any]:
"""导出协作单关联内部账号的 **CK 明文**(前端写剪贴板用)。
斗鱼协作单本身不存 CK,导出的始终是关联 ``accounts.cookie`` 的实时值。
和虎牙台账保持同一口径:
1. 需要独立权限点 ``douyu:collab:ck_export``;
2. 每条成功导出都写审计,但审计里只记单号/账号/CK 状态,**不记 CK 本体**;
3. 未绑定账号、账号没有 CK、订单已删除的行显式跳过并返回原因。
"""
requested = list(dict.fromkeys(int(item) for item in order_ids if item))
if not requested:
raise CollabError("请先勾选要导出的行", code="ck_export_empty")
if len(requested) > CK_EXPORT_BATCH_MAX:
raise CollabError(
f"一次最多导出 {CK_EXPORT_BATCH_MAX} 行,请分批操作",
code="ck_export_too_large",
)
rows = (
db.query(DouyuExternalOrder).filter(DouyuExternalOrder.id.in_(requested)).all()
)
by_id = {int(order.id): order for order in rows}
items: list[dict[str, Any]] = []
skipped: list[dict[str, Any]] = []
for order_id in requested:
order = by_id.get(order_id)
if order is None:
skipped.append(
{
"id": order_id,
"order_no": "",
"reason": "协作单不存在或已删除",
}
)
continue
account = account_by_id(db, order.account_id) or account_by_username(
db, order.account_username
)
ck_text = str(account.cookie or "").strip() if account else ""
if not ck_text:
reason = (
"这单还没绑定斗鱼账号" if account is None else "关联的斗鱼账号还没有 CK"
)
skipped.append(
{
"id": int(order.id),
"order_no": order.order_no,
"reason": reason,
}
)
continue
ck_status = str(account.ck_check_status or "")
items.append(
{
"id": int(order.id),
"order_no": order.order_no,
"account_id": int(account.id),
"account_username": account.username or order.account_username or "",
"account_password": str(account.password or ""),
"account_nickname": account.nickname or order.account_nickname or "",
"status": order.status,
"status_label": ORDER_STATUS_LABELS.get(order.status, order.status),
"ck_status": ck_status,
"ck_status_label": CK_LABELS.get(ck_status, ""),
"ck_text": ck_text,
}
)
record_audit(
db,
current,
action=CK_EXPORT_ACTION,
target=f"douyu_external_order:{order.id}",
detail={
"order_no": order.order_no,
"account_username": account.username or order.account_username or "",
"ck_status": ck_status,
"batch_size": len(requested),
},
)
if commit:
db.commit()
return {
"total": len(requested),
"exported": len(items),
"skipped": skipped,
"items": items,
}
def export_usernames(
db: Session,
order_ids: Sequence[int],
) -> dict[str, Any]:
"""导出勾选订单关联的斗鱼用户名(去重、保持选择顺序)。
用户名不是凭据,能看列表的人本来就能看到,所以这里不额外要求 CK 导出权限,
也不写审计;没有用户名的行会显式跳过。
"""
requested = list(dict.fromkeys(int(item) for item in order_ids if item))
if not requested:
raise CollabError("请先勾选要导出的行", code="username_export_empty")
if len(requested) > USERNAME_EXPORT_BATCH_MAX:
raise CollabError(
f"一次最多导出 {USERNAME_EXPORT_BATCH_MAX} 行,请分批操作",
code="username_export_too_large",
)
rows = (
db.query(DouyuExternalOrder).filter(DouyuExternalOrder.id.in_(requested)).all()
)
by_id = {int(order.id): order for order in rows}
items: list[dict[str, Any]] = []
skipped: list[dict[str, Any]] = []
usernames: list[str] = []
seen: set[str] = set()
for order_id in requested:
order = by_id.get(order_id)
if order is None:
skipped.append(
{
"id": order_id,
"order_no": "",
"reason": "协作单不存在或已删除",
}
)
continue
username = str(order.account_username or "").strip()
if not username:
account = account_by_id(db, order.account_id) or account_by_username(
db, order.account_username
)
username = str(account.username if account else "").strip()
if not username:
skipped.append(
{
"id": int(order.id),
"order_no": order.order_no,
"reason": "这单还没填斗鱼用户名",
}
)
continue
items.append(
{
"id": int(order.id),
"order_no": order.order_no,
"account_username": username,
}
)
if username not in seen:
seen.add(username)
usernames.append(username)
return {
"total": len(requested),
"exported": len(items),
"unique": len(usernames),
"skipped": skipped,
"items": items,
"usernames": usernames,
}