Files
live-hub-py/web/backend/services/douyu_service.py
T
yml2213 99db772ddd feat(douyu): 精英宝典支持查询开通状态,开通前先校验且不再误判到账
线上两个问题:工作台看不到账号有没有开通精英宝典;点开通时二维码会「闪一下
就变小」——大图弹窗 2~4 秒后自动关闭,只剩列表里 56px 的小二维码。

根因:
- getUserInfo 的 manualType/expireTime 才是开通状态的权威口径,但只有兑换
  流程用过一次,没有任务类型对外暴露;前端只看积分列,而「开通后积分花完」
  和「从未开通」都是 0 分,区分不了(实测 13456 manualType=1/score=0);
- 开通前不查状态,且到账判定写成「积分 >= 300」:账上本来就有 680 分的账号
  在出码后第一轮轮询就被判成功,前端按 paymentArrived 自动关掉了弹窗。

改动:
- 新增任务类型 query_elite_status:查 manualType/manualScore/expireTime 并
  落库到 accounts.elite_opened / elite_expire_time(迁移 20261001_0036),
  精英工作台加查询按钮与「宝典状态」列(已开通+到期 / 未开通);
- create_elite_qr 开通前先查状态:已开通直接判成功、不再生成支付码(方案 A),
  既消除二维码闪现,也避免给已开通账号开出真实支付单;
- 到账判定改为「积分比开通前增长」,基线用与轮询同源的 credit/balance 取,
  取不到基线才退回旧口径;
- 支付弹窗不再自动关闭:到账/失败/停止都在弹窗里给结论 + 手动关闭;列表小
  二维码在任务进终态后隐藏。

新增 9 条测试覆盖上述行为,回退 runner 时其中 8 条失败。
2026-10-01 19:08:25 +08:00

475 lines
15 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""斗鱼活动任务服务。"""
from __future__ import annotations
import uuid
from datetime import UTC, datetime
from sqlalchemy import and_, func, select
from sqlalchemy.orm import Session
from core.douyu.activity_client import DouyuActivityClient
from core.douyu.cookie_utils import cookie_value
from ..models import Account, DouyuConfig, DouyuTask, LoginTask
SUPPORTED_DOUYU_TASK_TYPES = {
"get_bind_qr": "获取绑定二维码",
"confirm_bind": "确认绑定",
"create_elite_qr": "开通精英宝典30",
"query_elite_status": "查询精英宝典状态",
"prepare_esports_bind": "绑定电竞手册角色",
"get_esports_bind_qr": "切换电竞角色",
"query_esports_game_name": "查询最新游戏角色",
"confirm_esports_bind": "确认电竞手册绑定",
"create_esports_qr": "开通电竞手册30",
"query_esports_points": "查询电竞积分",
"donate_esports_chicken_gift": "赠送冠军鸡腿",
"donate_esports_firework_gift": "赠送冠军烟花",
"refresh_esports_goods": "刷新电竞皮肤商城",
"exchange_esports_goods": "兑换电竞皮肤",
"create_gold_qr": "充值鱼翅",
"donate_elite_gift": "赠送精英令",
"query_points": "一键查询积分",
"lock_goods": "锁定商品",
"pay_locked_order": "支付锁单",
"exchange_goods": "兑换商品",
"query_game_name": "一键获取游戏名",
"query_change_bind_time": "一键查询换绑时间",
"query_limited_goods": "一键查询限兑商品",
"query_gold_balance": "一键查询鱼翅余额",
"refresh_goods": "刷新商品列表",
"query_exchange_records": "一键查询兑换记录",
"prefetch_csrf_token": "一键获取兑换 CSRF Token",
"get_xpd_bind_qr": "生成小店绑定二维码",
"query_xpd_bind_info": "查询小店绑定信息",
"confirm_xpd_bind": "确认小店绑定",
"query_xpd_role": "查询小店绑定角色",
"refresh_xpd_goods": "刷新小店商品列表",
"query_xpd_balance": "查询小店点券余额",
"query_xpd_fragments": "查询小店扭蛋碎片",
"query_xpd_purchase_records": "查询小店兑换记录",
"exchange_xpd_goods": "兑换小店商品",
}
DOUYU_HANDBOOK_SCOPES = {"elite", "esports", "peace"}
DOUYU_HANDBOOK_TASK_TYPES = {
"elite": {
"get_bind_qr",
"confirm_bind",
"create_elite_qr",
"query_elite_status",
"create_gold_qr",
"donate_elite_gift",
"query_points",
"lock_goods",
"pay_locked_order",
"exchange_goods",
"query_game_name",
"query_change_bind_time",
"query_limited_goods",
"query_gold_balance",
"refresh_goods",
"query_exchange_records",
"prefetch_csrf_token",
},
"esports": {
"prepare_esports_bind",
"get_esports_bind_qr",
"query_esports_game_name",
"confirm_esports_bind",
"create_esports_qr",
"query_esports_points",
"query_gold_balance",
"query_change_bind_time",
"query_limited_goods",
"refresh_esports_goods",
"exchange_esports_goods",
"create_gold_qr",
"donate_esports_chicken_gift",
"donate_esports_firework_gift",
},
"peace": {
"get_xpd_bind_qr",
"query_xpd_bind_info",
"confirm_xpd_bind",
"query_xpd_role",
"refresh_xpd_goods",
"query_xpd_balance",
"query_xpd_fragments",
"query_xpd_purchase_records",
"exchange_xpd_goods",
},
}
DOUYU_CONFIG_DEFAULTS = {
"manual_id": "G4KA4Qnz4LDp7",
"rid": "9263298",
"bind_act_alias": "20260120QYOOB",
"confirm_act_alias": "20260120QYOOB",
"legacy_act_alias": "cjm",
"room_id": "9263298",
"elite_amount": 3000,
"esports_manual_id": "1W0yGkOdDbAXb",
"esports_act_alias": "20260226ZMGDG",
"esports_amount": 3000,
"esports_chicken_gift_id": "24766",
"esports_chicken_skin_id": "0",
"esports_firework_gift_id": "24767",
"esports_firework_skin_id": "3850",
"gold_pay_type": 1,
"gold_recharge_channel": "wechat_qr",
"gold_api_product_id": "111570",
"gold_api_account_template_name": "斗鱼昵称",
"gift_id": "23643",
"skin_id": "2942",
"xpd_act_alias": "20260623KDQFH",
"xpd_act_id": "46195",
"xpd_rid": "9263298",
}
DOUYU_CONFIG_FIELDS = tuple(DOUYU_CONFIG_DEFAULTS.keys())
DOUYU_ACTIVE_TASK_STATUSES = ("planned", "pending", "running")
def douyu_config_value(field: str, value):
"""读取配置值;空值自动回退到默认值。"""
default = DOUYU_CONFIG_DEFAULTS[field]
if isinstance(default, int):
try:
return int(value if value is not None else default)
except (TypeError, ValueError):
return default
text = str(value or "").strip()
return text or str(default)
def apply_douyu_config_defaults(config: DouyuConfig) -> bool:
"""补齐斗鱼配置默认值,返回是否发生变更。"""
changed = False
for field in DOUYU_CONFIG_FIELDS:
if (
field == "bind_act_alias"
and str(getattr(config, field, "") or "").strip() == "20250213NQCYX"
):
setattr(config, field, DOUYU_CONFIG_DEFAULTS[field])
changed = True
continue
normalized = douyu_config_value(field, getattr(config, field, None))
if field == "gold_recharge_channel" and normalized not in {
"wechat_qr",
"supplier_api",
}:
normalized = DOUYU_CONFIG_DEFAULTS[field]
if getattr(config, field, None) != normalized:
setattr(config, field, normalized)
changed = True
return changed
def ensure_douyu_config(db: Session) -> DouyuConfig:
"""获取单条斗鱼配置,不存在则创建。"""
config = db.query(DouyuConfig).first()
if config:
if apply_douyu_config_defaults(config):
db.commit()
db.refresh(config)
return config
config = DouyuConfig(**DOUYU_CONFIG_DEFAULTS)
db.add(config)
db.commit()
db.refresh(config)
return config
# 登录/重登尝试的在途状态:判断只看 attempt_*,与记录持有的 CK 无关。
LOGIN_ATTEMPT_IN_FLIGHT_STATUSES = ("pending", "running")
def cookie_holding_filter():
"""判定登录记录当前是否仍持有 Cookie。
账号是否有 CK 只取决于记录里是否还留着 Cookie。登录尝试(attempt_*)
在成功前不会碰 status/cookie/message,所以停止、失败、进程重启都不会
让账号失去 CK;只有 Cookie 真正被清空(账号检测成功)时才不再持有。
"""
return and_(
LoginTask.cookie.isnot(None),
LoginTask.cookie != "",
)
def latest_cookie_login_task(db: Session, account_id: int) -> LoginTask | None:
"""读取账号最近一条仍持有 Cookie 的登录记录。"""
return (
db.query(LoginTask)
.filter(
LoginTask.account_id == account_id,
cookie_holding_filter(),
)
.order_by(LoginTask.id.desc())
.first()
)
def cookie_operable_filter():
"""判定登录记录能否基于现有 Cookie 再做检测或重登。
正在被批次处理的记录(attempt_* 处于在途状态)已由批次接管,并发再点
检测或重登只会互相覆盖,因此排除在外;CK 与 status 不参与判断。
"""
return and_(
cookie_holding_filter(),
func.coalesce(LoginTask.attempt_status, "").notin_(
LOGIN_ATTEMPT_IN_FLIGHT_STATUSES
),
)
def latest_cookie(db: Session, account_id: int) -> str:
"""读取账号当前 Cookie。"""
task = latest_cookie_login_task(db, account_id)
return task.cookie if task else ""
def cookie_holding_account_ids_query(db: Session):
"""返回当前仍持有 Cookie 的斗鱼账号 ID 查询。"""
return db.query(LoginTask.account_id).filter(cookie_holding_filter()).distinct()
def visible_douyu_task_accounts(db: Session, account_ids: list[int]) -> list[Account]:
"""只保留当前仍持有 Cookie 的斗鱼账号。"""
if not account_ids:
return []
cookie_ids = select(LoginTask.account_id).where(cookie_holding_filter())
return (
db.query(Account)
.filter(Account.id.in_(account_ids), Account.id.in_(cookie_ids))
.all()
)
def update_account_profile_from_cookie(account: Account, cookie: str) -> None:
"""从 Cookie 回填 uid/nickname。"""
profile = DouyuActivityClient.profile_from_cookie(cookie)
if profile["uid"]:
account.uid = profile["uid"]
if profile["nickname"]:
account.nickname = profile["nickname"]
def account_uid(account: Account, cookie: str) -> str:
"""优先从账号字段读取 uid,缺失时从 Cookie 中取。"""
return account.uid or cookie_value(cookie, "acf_uid")
def create_douyu_planned_tasks(
db: Session,
account_ids: list[int],
task_type: str,
handbook_scope: str,
created_by: int,
payload: dict | None = None,
) -> tuple[str, int]:
"""创建斗鱼任务记录,等待后台执行器消费。"""
if task_type not in SUPPORTED_DOUYU_TASK_TYPES:
raise ValueError("不支持的任务类型")
if handbook_scope not in DOUYU_HANDBOOK_SCOPES:
raise ValueError("无效的工作台")
if task_type not in DOUYU_HANDBOOK_TASK_TYPES[handbook_scope]:
raise ValueError("该任务不属于当前工作台")
accounts = visible_douyu_task_accounts(db, account_ids)
if (
task_type in {"refresh_goods", "refresh_esports_goods", "refresh_xpd_goods"}
and accounts
):
# 商品快照是全局数据,一个可用 CK 足够;没有 CK 时前端无法选账号创建任务。
accounts = accounts[:1]
batch_id = uuid.uuid4().hex[:12]
payload = payload or {}
for account in accounts:
db.add(
DouyuTask(
batch_id=batch_id,
account_id=account.id,
task_type=task_type,
handbook_scope=handbook_scope,
status="planned",
message="任务已创建,等待执行",
result={"payload": payload} if payload else None,
created_by=created_by,
)
)
db.commit()
return batch_id, len(accounts)
def cleanup_orphan_douyu_tasks(
db: Session,
*,
active_batch_ids: set[str] | None = None,
batch_id: str | None = None,
statuses: tuple[str, ...] = DOUYU_ACTIVE_TASK_STATUSES,
message: str = "任务已中断(服务重启或批次丢失)",
) -> int:
"""清理没有执行器接管的斗鱼任务。"""
query = db.query(DouyuTask).filter(DouyuTask.status.in_(statuses))
if batch_id:
query = query.filter(DouyuTask.batch_id == batch_id)
elif active_batch_ids is not None and active_batch_ids:
query = query.filter(~DouyuTask.batch_id.in_(list(active_batch_ids)))
tasks = query.all()
if not tasks:
return 0
now = datetime.now(UTC)
for task in tasks:
task.status = "stopped"
task.message = message
task.finished_at = now
db.commit()
return len(tasks)
def slim_douyu_goods(goods: object) -> object:
"""保留兑换图片和列表展示需要的商品小字段。"""
if not isinstance(goods, dict):
return None
return {
key: value
for key, value in goods.items()
if key
in {
"commodityId",
"commodity_id",
"commodityName",
"name",
"webPic",
"pic",
"score",
"status",
}
}
def slim_douyu_limited_goods(goods: object) -> list[dict[str, object]]:
"""限兑列表只返回前几个商品名,避免任务列表携带完整原始数组。"""
if not isinstance(goods, list):
return []
result: list[dict[str, object]] = []
for item in goods[:5]:
if not isinstance(item, dict):
continue
result.append(
{
key: value
for key, value in item.items()
if key in {"commodityId", "commodity_id", "commodityName", "name"}
}
)
return result
def strip_douyu_raw_snapshots(value: object) -> object:
"""递归移除任务结果中的原始接口快照。"""
if isinstance(value, dict):
return {
key: strip_douyu_raw_snapshots(item)
for key, item in value.items()
if key != "raw"
}
if isinstance(value, list):
return [strip_douyu_raw_snapshots(item) for item in value]
return value
def sanitize_douyu_task_result(
result: dict | None, task_type: str, *, include_detail: bool = False
) -> dict | None:
"""列表/实时推送接口剥离原始快照与大数组;详情接口保留完整 result。"""
if not isinstance(result, dict):
return result
if include_detail:
return result
data = strip_douyu_raw_snapshots(result)
if not isinstance(data, dict):
return None
removable_keys = {
"bind_info",
"before_bind_info",
"bind_candidates",
"cooldown_bind_info",
"after_bind_info",
"activity_bind_snapshot",
"activity_bind_info",
"points_query",
"exchange_balance_query",
"query_act_aliases",
}
if task_type != "query_xpd_purchase_records":
removable_keys.add("records")
for key in removable_keys:
data.pop(key, None)
# 和平小店任务 result 内嵌 role(含 gameOpenId 等),列表接口剥离其原始快照
role = data.get("role")
if isinstance(role, dict):
data["role"] = strip_douyu_raw_snapshots(role)
goods = data.get("goods")
if isinstance(goods, list):
data.pop("goods", None)
elif isinstance(goods, dict):
data["goods"] = slim_douyu_goods(goods)
if "limited_goods" in data:
data["limited_goods"] = slim_douyu_limited_goods(data.get("limited_goods"))
if task_type not in {
"get_bind_qr",
"prepare_esports_bind",
"get_esports_bind_qr",
"get_xpd_bind_qr",
}:
data.pop("url", None)
if task_type not in {"create_elite_qr", "create_esports_qr", "create_gold_qr"}:
data.pop("pay_url", None)
return data
def douyu_task_payload(task: DouyuTask, *, include_detail: bool = False) -> dict:
"""生成 REST 与 WebSocket 共用的斗鱼任务载荷。"""
account = task.account
def isoformat(value: datetime | None) -> str | None:
if value is None:
return None
if value.tzinfo is None:
value = value.replace(tzinfo=UTC)
return value.isoformat()
return {
"id": task.id,
"batch_id": task.batch_id,
"account_id": task.account_id,
"account_username": (account.username if account else "") or "",
"account_uid": (account.uid if account else "") or "",
"account_nickname": (account.nickname if account else "") or "",
"task_type": task.task_type or "",
"handbook_scope": task.handbook_scope or "legacy",
"status": task.status or "",
"message": task.message or "",
"result": sanitize_douyu_task_result(
task.result if isinstance(task.result, dict) else None,
task.task_type or "",
include_detail=include_detail,
),
"created_by": task.created_by,
"created_at": isoformat(task.created_at),
"finished_at": isoformat(task.finished_at),
}