"""斗鱼外部单协作台账服务。 核心边界(和虎牙外部 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, DouyuCollabGift, 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) if points is None and name: manual = ( db.query(DouyuCollabGift).filter(DouyuCollabGift.name == name).first() ) if manual is not None: points = int(manual.points or 0) return name, cid, points if not cid and name: # 接口暂时不返回商品时,管理员配置的礼包名称和积分仍然可用。 manual = db.query(DouyuCollabGift).filter(DouyuCollabGift.name == name).first() if manual is not None: return name, "", points if points is not None else int(manual.points or 0) 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) explicit_points = _to_int(required_points) 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 explicit_points is None and name: live = next( ( item for item in (probe.get("goods") or []) if str(item.get("name") or item.get("commodityName") or "") == name ), None, ) if live is not None: live_cid = _text(live.get("commodity_id"), max_length=64) if live_cid: order.commodity_id = live_cid live_points = _to_int(live.get("score")) if live_points is not None: order.required_points = live_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 _restore_void_status( db: Session, current: User, order: DouyuExternalOrder, *, target: str, reason: str, commit: bool, ) -> DouyuExternalOrder: """超级管理员取消作废,并把订单转为指定状态。""" if current.role != "super_admin": raise CollabError( "仅超级管理员可取消作废", status_code=403, code="admin_required", ) if order.archived_at is not None: raise CollabError("已归档的订单不能改状态", code="order_archived") target_index = STAGE_INDEX.get(target) if target_index is not None and target_index >= STAGE_INDEX[STATUS_TO_RECHARGE]: _assert_ready_for_recharge(order) old_void_reason = order.void_reason or "" # 取消作废等于重新进入状态机:先清空旧流程时间,再按目标状态补当前时间。 for field in STATUS_TIMESTAMP_FIELDS.values(): setattr(order, field, None) if target_index is not None: for name, field in STATUS_TIMESTAMP_FIELDS.items(): if STAGE_INDEX[name] <= target_index: setattr(order, field, _now()) order.status = target order.void_reason = "" order.updated_by = current.id order.version = int(order.version or 0) + 1 record_audit( db, current, action="douyu:collab:void_restore", target=_status_audit_target(order), detail={ "from": STATUS_VOID, "to": target, "previous_void_reason": old_void_reason, "reason": _text(reason, max_length=256), }, ) if commit: db.commit() db.refresh(order) return order 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: if target == STATUS_VOID: raise CollabError("已作废的订单不能改状态", code="order_void") return _restore_void_status( db, current, order, target=target, reason=reason, commit=commit, ) 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_gifts(db: Session, *, active_only: bool = False) -> list[DouyuCollabGift]: query = db.query(DouyuCollabGift) if active_only: query = query.filter(DouyuCollabGift.is_active.is_(True)) return query.order_by( DouyuCollabGift.sort_order.asc(), DouyuCollabGift.id.asc() ).all() def get_gift(db: Session, gift_id: int) -> DouyuCollabGift: gift = db.query(DouyuCollabGift).filter(DouyuCollabGift.id == gift_id).first() if gift is None: raise CollabError("礼包不存在", status_code=404, code="gift_not_found") return gift def create_gift( db: Session, current: User, *, name: str, points: Any, remark: str = "", ) -> DouyuCollabGift: clean = _text(name, max_length=128) if not clean: raise CollabError("礼包名不能为空", code="gift_name_required") clean_points = _to_int(points) if clean_points is None or clean_points < 0: raise CollabError("所需积分不能为空且不能小于 0", code="gift_points_invalid") exists = db.query(DouyuCollabGift).filter(DouyuCollabGift.name == clean).first() if exists: raise CollabError("礼包已存在", status_code=409, code="gift_exists") gift = DouyuCollabGift( name=clean, points=clean_points, remark=_text(remark, max_length=255), created_by=current.id, updated_by=current.id, ) db.add(gift) db.commit() db.refresh(gift) return gift def update_gift( db: Session, current: User, gift: DouyuCollabGift, payload: Mapping[str, Any], ) -> DouyuCollabGift: old_name = gift.name old = { "name": gift.name, "points": gift.points, "sort_order": gift.sort_order, "is_active": gift.is_active, "remark": gift.remark, } if "name" in payload: name = _text(payload.get("name"), max_length=128) if not name: raise CollabError("礼包名不能为空", code="gift_name_required") duplicate = ( db.query(DouyuCollabGift.id) .filter( DouyuCollabGift.name == name, DouyuCollabGift.id != gift.id, ) .first() ) if duplicate: raise CollabError("礼包已存在", status_code=409, code="gift_exists") gift.name = name if "points" in payload: points = _to_int(payload.get("points")) if points is None or points < 0: raise CollabError( "所需积分不能为空且不能小于 0", code="gift_points_invalid" ) gift.points = points if "sort_order" in payload: gift.sort_order = _to_int(payload.get("sort_order")) or 0 if "is_active" in payload: gift.is_active = bool(payload.get("is_active")) if "remark" in payload: gift.remark = _text(payload.get("remark"), max_length=255) gift.updated_by = current.id if gift.name != old_name: # 名称是订单里的展示/匹配口径,改名要同步历史订单; # 积分是下单时的快照,更新字典不回填历史订单。 db.query(DouyuExternalOrder).filter( DouyuExternalOrder.gift_name == old_name ).update({DouyuExternalOrder.gift_name: gift.name}, synchronize_session=False) record_audit( db, current, action="douyu:collab:gift_update", target=f"douyu_collab_gift:{gift.id}", detail={ "old": old, "new": { "name": gift.name, "points": gift.points, "sort_order": gift.sort_order, "is_active": gift.is_active, "remark": gift.remark, }, }, ) db.commit() db.refresh(gift) return gift def delete_gift(db: Session, current: User, gift: DouyuCollabGift) -> dict[str, Any]: gift_name = gift.name gift_points = gift.points used = ( db.query(func.count(DouyuExternalOrder.id)) .filter(DouyuExternalOrder.gift_name == gift_name) .scalar() or 0 ) if used: raise CollabError( f"还有 {used} 个协作单使用「{gift_name}」,请先停用或改名", code="gift_in_use", ) record_audit( db, current, action="douyu:collab:gift_delete", target=f"douyu_collab_gift:{gift.id}", detail={"name": gift_name, "points": gift_points}, ) db.delete(gift) db.commit() return {"success": True, "name": gift_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, }