2182 lines
71 KiB
Python
2182 lines
71 KiB
Python
"""斗鱼外部单协作台账服务。
|
||
|
||
核心边界(和虎牙外部 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,
|
||
}
|