- 新增 activity_client.py:封装斗鱼活动/兑换/充值/送礼接口 - 新增 cookie_utils.py:Cookie 解析与规范化工具 - 新增 douyu_service/douyu_runner:斗鱼任务服务层与批量执行器 - 新增 douyu 路由:任务类型查询、账号列表、配置管理、商品管理、批量任务、WebSocket 日志 - 新增 models/schemas:DouyuTask/DouyuConfig/DouyuGoodsSnapshot 模型,Account 扩展点数/鱼翅/绑定状态等字段 - 新增数据库迁移:斗鱼活动相关表与 accounts 字段补充 - 新增前端 DouyuTasksPage 任务操作台页面 - 兑换商品请求添加 sec-ch-ua 反检测头 - 兑换商品支持最多 8 次重试 + csrf_token 自动刷新 - 注册 douyu:task / douyu:config 权限点 - 侧边栏新增斗鱼分组与任务操作台菜单入口
491 lines
21 KiB
Python
491 lines
21 KiB
Python
"""斗鱼活动任务批次执行器。"""
|
|
|
|
from __future__ import annotations
|
|
|
|
import asyncio
|
|
import threading
|
|
import time
|
|
from concurrent.futures import ThreadPoolExecutor, as_completed
|
|
from datetime import datetime, timezone
|
|
from typing import Optional
|
|
|
|
from loguru import logger
|
|
from sqlalchemy.orm import Session, joinedload
|
|
|
|
from core.douyu import DouyuActivityClient, DouyuActivityError
|
|
|
|
from ..database import SessionLocal
|
|
from ..models import Account, DouyuGoodsSnapshot, DouyuTask
|
|
from .douyu_service import (
|
|
DOUYU_CONFIG_FIELDS,
|
|
account_uid,
|
|
douyu_config_value,
|
|
ensure_douyu_config,
|
|
latest_success_cookie,
|
|
update_account_profile_from_cookie,
|
|
)
|
|
|
|
|
|
class DouyuBatchRunner:
|
|
"""批量执行斗鱼活动任务,通过队列推送实时日志。"""
|
|
|
|
def __init__(
|
|
self,
|
|
db: Session,
|
|
batch_id: str,
|
|
task_type: str,
|
|
payload: Optional[dict] = None,
|
|
log_queue: Optional[asyncio.Queue] = None,
|
|
loop: Optional[asyncio.AbstractEventLoop] = None,
|
|
concurrency: int = 3,
|
|
):
|
|
self.db = db
|
|
self.batch_id = batch_id
|
|
self.task_type = task_type
|
|
self.payload = payload or {}
|
|
self.log_queue = log_queue
|
|
self.loop = loop
|
|
self.concurrency = max(1, min(concurrency, 10))
|
|
self._stop = threading.Event()
|
|
self._counter_lock = threading.Lock()
|
|
self._started = 0
|
|
|
|
def stop(self):
|
|
self._stop.set()
|
|
|
|
def _push_log(self, level: str, message: str):
|
|
if level == "result":
|
|
try:
|
|
douyu_batch_registry.mark_finished(self.batch_id)
|
|
except NameError:
|
|
pass
|
|
if level != "result" and message:
|
|
log_func = getattr(logger, level, logger.info)
|
|
log_func(f"[douyu] {message}")
|
|
if self.log_queue and self.loop:
|
|
asyncio.run_coroutine_threadsafe(
|
|
self.log_queue.put({"level": level, "message": message}),
|
|
self.loop,
|
|
)
|
|
|
|
@staticmethod
|
|
def _account_name(account: Account) -> str:
|
|
return account.nickname or account.username or account.uid or f"#{account.id}"
|
|
|
|
@staticmethod
|
|
def _to_int(value) -> int | None:
|
|
if value is None:
|
|
return None
|
|
try:
|
|
return int(value)
|
|
except (TypeError, ValueError):
|
|
return None
|
|
|
|
@staticmethod
|
|
def _format_wait_time(seconds: int | None) -> str:
|
|
if seconds is None:
|
|
return ""
|
|
seconds = max(0, int(seconds))
|
|
days, rem = divmod(seconds, 86400)
|
|
hours, rem = divmod(rem, 3600)
|
|
minutes, sec = divmod(rem, 60)
|
|
if days:
|
|
return f"{days}天{hours}小时{minutes}分"
|
|
if hours:
|
|
return f"{hours}小时{minutes}分{sec}秒"
|
|
return f"{minutes}分{sec}秒"
|
|
|
|
def _mark_task(
|
|
self,
|
|
db: Session,
|
|
task: DouyuTask,
|
|
status: str,
|
|
message: str,
|
|
result: dict | None = None,
|
|
) -> None:
|
|
task.status = status
|
|
task.message = message[:512]
|
|
if result is not None:
|
|
task.result = result
|
|
task.finished_at = datetime.now(timezone.utc)
|
|
db.commit()
|
|
|
|
def _upsert_goods(self, db: Session, goods: list[dict]) -> None:
|
|
now = datetime.now(timezone.utc)
|
|
for raw in goods:
|
|
commodity_id = str(raw.get("commodityId") or raw.get("commodity_id") or "")
|
|
if not commodity_id:
|
|
continue
|
|
row = (
|
|
db.query(DouyuGoodsSnapshot)
|
|
.filter(DouyuGoodsSnapshot.commodity_id == commodity_id)
|
|
.first()
|
|
)
|
|
score = self._to_int(raw.get("score"))
|
|
if row is None:
|
|
row = DouyuGoodsSnapshot(commodity_id=commodity_id)
|
|
db.add(row)
|
|
row.name = str(raw.get("commodityName") or raw.get("name") or "")
|
|
row.score = score
|
|
row.status = str(raw.get("status") or "")
|
|
row.raw = raw
|
|
row.updated_at = now
|
|
db.commit()
|
|
|
|
def _config_info(self, db: Session) -> dict:
|
|
config = ensure_douyu_config(db)
|
|
return {field: douyu_config_value(field, getattr(config, field, None)) for field in DOUYU_CONFIG_FIELDS}
|
|
|
|
def _task_payload(self, task: DouyuTask) -> dict:
|
|
result = task.result if isinstance(task.result, dict) else {}
|
|
payload = result.get("payload") if isinstance(result.get("payload"), dict) else {}
|
|
return {**payload, **self.payload}
|
|
|
|
def _client(self, cookie: str) -> DouyuActivityClient:
|
|
return DouyuActivityClient(cookie, logger=lambda msg: self._push_log("debug", msg))
|
|
|
|
def _execute_refresh_goods(self, db: Session, task: DouyuTask, account: Account, cookie: str, config: dict):
|
|
client = self._client(cookie)
|
|
result = client.list_goods(manual_id=config["manual_id"], rid=config["rid"])
|
|
goods = result["goods"]
|
|
self._upsert_goods(db, goods)
|
|
account.bind_status = account.bind_status or "active"
|
|
account.updated_at = datetime.now(timezone.utc)
|
|
self._mark_task(db, task, "success", f"已刷新商品 {len(goods)} 个", {"goods_count": len(goods), "goods": goods})
|
|
|
|
def _execute_get_bind_qr(self, db: Session, task: DouyuTask, account: Account, cookie: str, config: dict):
|
|
client = self._client(cookie)
|
|
result = client.get_bind_qr(str(config["bind_act_alias"]))
|
|
account.bind_status = "bind_qr_generated"
|
|
account.updated_at = datetime.now(timezone.utc)
|
|
self._mark_task(db, task, "success", "绑定二维码已生成", result)
|
|
|
|
def _execute_confirm_bind(self, db: Session, task: DouyuTask, account: Account, cookie: str, config: dict):
|
|
client = self._client(cookie)
|
|
before = client.bind_info(str(config["legacy_act_alias"]), v2=True)
|
|
result = client.confirm_bind(str(config["confirm_act_alias"]))
|
|
after = client.bind_info(str(config["confirm_act_alias"]), v2=False)
|
|
role_name = after.get("role_name") or before.get("role_name") or ""
|
|
account.game_name = role_name or account.game_name
|
|
account.game_channel = " / ".join(part for part in [after.get("area_name"), after.get("plat_name")] if part)
|
|
account.bind_status = "bind_confirmed"
|
|
account.updated_at = datetime.now(timezone.utc)
|
|
self._mark_task(db, task, "success", f"绑定成功: {role_name or '已确认'}", {"before": before, "confirm": result, "bind_info": after})
|
|
|
|
def _execute_create_elite_qr(self, db: Session, task: DouyuTask, account: Account, cookie: str, config: dict):
|
|
client = self._client(cookie)
|
|
ctn = str(self._task_payload(task).get("ctn") or "")
|
|
if not ctn:
|
|
ctn = client.acf_ccn(refresh_subscribe=True)
|
|
result = client.create_elite_qr(
|
|
ctn=ctn,
|
|
act_alias=str(config["confirm_act_alias"]),
|
|
amount=int(config["elite_amount"]),
|
|
room_id=str(config["room_id"]),
|
|
)
|
|
account.bind_status = "elite_qr_created"
|
|
account.updated_at = datetime.now(timezone.utc)
|
|
self._mark_task(db, task, "success", "精英宝典支付码已生成", result)
|
|
|
|
def _execute_create_gold_qr(self, db: Session, task: DouyuTask, account: Account, cookie: str, config: dict):
|
|
payload = self._task_payload(task)
|
|
amount = int(payload.get("amount") or payload.get("gold_amount") or 1)
|
|
client = self._client(cookie)
|
|
result = client.create_gold_qr(amount=amount, pay_type=int(config["gold_pay_type"]))
|
|
account.bind_status = "gold_qr_created"
|
|
account.updated_at = datetime.now(timezone.utc)
|
|
self._mark_task(db, task, "success", f"鱼翅 {amount} 元支付码已生成", result)
|
|
|
|
def _execute_donate_elite_gift(self, db: Session, task: DouyuTask, account: Account, cookie: str, config: dict):
|
|
payload = self._task_payload(task)
|
|
gift_count = int(payload.get("gift_count") or payload.get("count") or 1)
|
|
client = self._client(cookie)
|
|
result = client.donate_elite_gift(
|
|
gift_count=gift_count,
|
|
room_id=str(payload.get("room_id") or config["room_id"]),
|
|
gift_id=str(payload.get("gift_id") or config["gift_id"]),
|
|
skin_id=str(payload.get("skin_id") or config["skin_id"]),
|
|
)
|
|
account.bind_status = "gift_donated"
|
|
account.updated_at = datetime.now(timezone.utc)
|
|
self._mark_task(db, task, "success", f"赠送精英令成功: {gift_count}", result)
|
|
|
|
def _execute_query_points(self, db: Session, task: DouyuTask, account: Account, cookie: str, config: dict):
|
|
client = self._client(cookie)
|
|
ctn = client.acf_ccn(refresh_subscribe=False)
|
|
uid = account_uid(account, cookie)
|
|
if not uid:
|
|
self._mark_task(db, task, "failed", "Cookie 中没有 acf_uid,无法查询积分")
|
|
return
|
|
result = client.query_points(uid=uid, ctn=ctn)
|
|
points = self._to_int(result.get("points"))
|
|
account.uid = uid
|
|
account.points = points
|
|
update_account_profile_from_cookie(account, cookie)
|
|
account.bind_status = "points_queried"
|
|
account.updated_at = datetime.now(timezone.utc)
|
|
self._mark_task(db, task, "success", f"积分: {points if points is not None else '-'}", result)
|
|
|
|
def _execute_exchange_goods(self, db: Session, task: DouyuTask, account: Account, cookie: str, config: dict):
|
|
import time as time_mod
|
|
payload = self._task_payload(task)
|
|
commodity_id = str(payload.get("commodity_id") or payload.get("commodityId") or "").strip()
|
|
if not commodity_id:
|
|
self._mark_task(db, task, "failed", "请选择兑换商品")
|
|
return
|
|
client = self._client(cookie)
|
|
ctn = client.acf_ccn(refresh_subscribe=False)
|
|
result = None
|
|
last_error = ""
|
|
for attempt in range(8 + 1):
|
|
if self._stop.is_set():
|
|
self._mark_task(db, task, "stopped", "任务已停止")
|
|
return
|
|
try:
|
|
result = client.exchange_goods(
|
|
manual_id=str(config["manual_id"]),
|
|
rid=str(config["rid"]),
|
|
commodity_id=commodity_id,
|
|
ctn=ctn,
|
|
)
|
|
break
|
|
except DouyuActivityError as exc:
|
|
last_error = str(exc)
|
|
if attempt >= 8:
|
|
self._mark_task(db, task, "failed", f"兑换失败(已重试{attempt}次): {last_error}")
|
|
return
|
|
error_lower = last_error.lower()
|
|
if any(kw in error_lower for kw in ("无效", "太快", "csrf")):
|
|
self._push_log("info", f" 重试 {attempt + 1}/8: {last_error},刷新 csrf_token...")
|
|
try:
|
|
token = client.csrf_token()
|
|
self._push_log("debug", f" csrf_token 已刷新: {token[:12]}...")
|
|
except Exception:
|
|
pass
|
|
else:
|
|
self._push_log("info", f" 重试 {attempt + 1}/8: {last_error}")
|
|
time_mod.sleep(0.3)
|
|
if result is None:
|
|
self._mark_task(db, task, "failed", f"兑换失败: {last_error}")
|
|
return
|
|
goods = (
|
|
db.query(DouyuGoodsSnapshot)
|
|
.filter(DouyuGoodsSnapshot.commodity_id == commodity_id)
|
|
.first()
|
|
)
|
|
account.bind_status = "goods_exchanged"
|
|
account.updated_at = datetime.now(timezone.utc)
|
|
self._mark_task(
|
|
db,
|
|
task,
|
|
"success",
|
|
f"兑换成功: {(goods.name if goods else '') or commodity_id}",
|
|
{"goods": goods.raw if goods else None, **result},
|
|
)
|
|
|
|
def _execute_query_game_name(self, db: Session, task: DouyuTask, account: Account, cookie: str, config: dict):
|
|
client = self._client(cookie)
|
|
result = client.bind_info(str(config["confirm_act_alias"]), v2=False)
|
|
if not result.get("role_name"):
|
|
result = client.bind_info(str(config["legacy_act_alias"]), v2=True)
|
|
role_name = result.get("role_name") or ""
|
|
account.game_name = role_name
|
|
account.game_channel = " / ".join(part for part in [result.get("area_name"), result.get("plat_name")] if part)
|
|
account.bind_status = "game_queried" if role_name else "game_not_bound"
|
|
account.change_role_wait_time = self._to_int(result.get("change_role_wait_time"))
|
|
account.updated_at = datetime.now(timezone.utc)
|
|
message = f"游戏名: {role_name}" if role_name else "未获取到游戏名"
|
|
self._mark_task(db, task, "success" if role_name else "failed", message, result)
|
|
|
|
def _execute_query_change_bind_time(self, db: Session, task: DouyuTask, account: Account, cookie: str, config: dict):
|
|
client = self._client(cookie)
|
|
result = client.bind_info(str(config["confirm_act_alias"]), v2=True)
|
|
wait_time = self._to_int(result.get("change_role_wait_time"))
|
|
account.change_role_wait_time = wait_time
|
|
account.bind_status = "change_time_queried"
|
|
account.updated_at = datetime.now(timezone.utc)
|
|
result["change_role_wait_text"] = self._format_wait_time(wait_time)
|
|
self._mark_task(db, task, "success", f"换绑剩余: {result['change_role_wait_text'] or '-'}", result)
|
|
|
|
def _execute_query_limited_goods(self, db: Session, task: DouyuTask, account: Account, cookie: str, config: dict):
|
|
client = self._client(cookie)
|
|
result = client.query_limited_goods(manual_id=str(config["manual_id"]), rid=str(config["rid"]))
|
|
limited = result["limited_goods"]
|
|
names = [str(item.get("commodityName") or "") for item in limited if item.get("commodityName")]
|
|
message = "无限制商品" if not names else f"限兑 {len(names)} 个: {', '.join(names[:5])}"
|
|
account.bind_status = "limited_goods_queried"
|
|
account.updated_at = datetime.now(timezone.utc)
|
|
self._mark_task(db, task, "success", message, {"limited_count": len(limited), "limited_goods": limited})
|
|
|
|
def _execute_query_gold_balance(self, db: Session, task: DouyuTask, account: Account, cookie: str, config: dict):
|
|
client = self._client(cookie)
|
|
gold = client.gold_account()
|
|
exchange = client.exchange_balance()
|
|
account.gold_balance = self._to_int(gold.get("gold"))
|
|
account.exchange_balance = self._to_int(exchange.get("count"))
|
|
account.bind_status = "gold_balance_queried"
|
|
account.updated_at = datetime.now(timezone.utc)
|
|
self._mark_task(
|
|
db,
|
|
task,
|
|
"success",
|
|
f"鱼翅余额: {account.gold_balance if account.gold_balance is not None else '-'}",
|
|
{"gold": gold, "exchange_balance": exchange},
|
|
)
|
|
|
|
def _execute_query_exchange_records(self, db: Session, task: DouyuTask, account: Account, cookie: str, config: dict):
|
|
client = self._client(cookie)
|
|
result = client.exchange_records(manual_id=str(config["manual_id"]))
|
|
records = result["records"]
|
|
account.bind_status = "exchange_records_queried"
|
|
account.updated_at = datetime.now(timezone.utc)
|
|
self._mark_task(db, task, "success", f"兑换记录 {len(records)} 条" if records else "暂无兑换记录", result)
|
|
|
|
def _execute_prefetch_csrf_token(self, db: Session, task: DouyuTask, account: Account, cookie: str, config: dict):
|
|
client = self._client(cookie)
|
|
token = client.csrf_token()
|
|
account.bind_status = "csrf_token_ready"
|
|
account.updated_at = datetime.now(timezone.utc)
|
|
self._mark_task(db, task, "success", "获取 csrf_token 成功", {"csrf_token": token, "cookie": client.cookie})
|
|
|
|
def _execute_one(self, task_id: int, config: dict, total: int):
|
|
worker_db = SessionLocal()
|
|
try:
|
|
task = (
|
|
worker_db.query(DouyuTask)
|
|
.options(joinedload(DouyuTask.account))
|
|
.filter(DouyuTask.id == task_id)
|
|
.first()
|
|
)
|
|
if not task or self._stop.is_set():
|
|
return
|
|
account = task.account
|
|
task.status = "running"
|
|
task.message = "执行中"
|
|
worker_db.commit()
|
|
|
|
with self._counter_lock:
|
|
self._started += 1
|
|
current = self._started
|
|
|
|
self._push_log("info", f"[{current}/{total}] 开始: {self._account_name(account)}")
|
|
cookie = latest_success_cookie(worker_db, account.id)
|
|
if not cookie:
|
|
self._mark_task(worker_db, task, "failed", "账号没有成功登录 Cookie")
|
|
self._push_log("warning", f"[{current}] {self._account_name(account)} 无 Cookie")
|
|
return
|
|
update_account_profile_from_cookie(account, cookie)
|
|
|
|
handler = {
|
|
"refresh_goods": self._execute_refresh_goods,
|
|
"get_bind_qr": self._execute_get_bind_qr,
|
|
"confirm_bind": self._execute_confirm_bind,
|
|
"create_elite_qr": self._execute_create_elite_qr,
|
|
"create_gold_qr": self._execute_create_gold_qr,
|
|
"donate_elite_gift": self._execute_donate_elite_gift,
|
|
"query_points": self._execute_query_points,
|
|
"exchange_goods": self._execute_exchange_goods,
|
|
"query_game_name": self._execute_query_game_name,
|
|
"query_change_bind_time": self._execute_query_change_bind_time,
|
|
"query_limited_goods": self._execute_query_limited_goods,
|
|
"query_gold_balance": self._execute_query_gold_balance,
|
|
"query_exchange_records": self._execute_query_exchange_records,
|
|
"prefetch_csrf_token": self._execute_prefetch_csrf_token,
|
|
}.get(task.task_type)
|
|
if handler is None:
|
|
self._mark_task(worker_db, task, "failed", "不支持的任务类型")
|
|
return
|
|
|
|
handler(worker_db, task, account, cookie, config)
|
|
self._push_log("success", f"[{current}] {self._account_name(account)} {task.message}")
|
|
except DouyuActivityError as exc:
|
|
if "task" in locals() and task:
|
|
self._mark_task(worker_db, task, "failed", str(exc))
|
|
self._push_log("warning", f"斗鱼任务失败: {exc}")
|
|
except Exception as exc:
|
|
if "task" in locals() and task:
|
|
self._mark_task(worker_db, task, "error", str(exc))
|
|
self._push_log("error", f"斗鱼任务异常: {exc}")
|
|
finally:
|
|
worker_db.close()
|
|
|
|
def run(self):
|
|
"""执行批次任务。"""
|
|
self._push_log("info", f"斗鱼任务批次 {self.batch_id} 开始")
|
|
try:
|
|
config = self._config_info(self.db)
|
|
tasks = (
|
|
self.db.query(DouyuTask)
|
|
.filter(DouyuTask.batch_id == self.batch_id, DouyuTask.status == "planned")
|
|
.order_by(DouyuTask.id.asc())
|
|
.all()
|
|
)
|
|
if not tasks:
|
|
self._push_log("warning", "没有可执行的斗鱼任务")
|
|
self._push_log("result", "")
|
|
return
|
|
|
|
for task in tasks:
|
|
task.status = "pending"
|
|
task.message = "等待执行"
|
|
self.db.commit()
|
|
|
|
total = len(tasks)
|
|
with ThreadPoolExecutor(max_workers=self.concurrency) as executor:
|
|
futures = []
|
|
for task in tasks:
|
|
if self._stop.is_set():
|
|
break
|
|
futures.append(executor.submit(self._execute_one, task.id, config, total))
|
|
for future in as_completed(futures):
|
|
try:
|
|
future.result()
|
|
except Exception as exc:
|
|
self._push_log("error", f"Worker 异常: {exc}")
|
|
|
|
if self._stop.is_set():
|
|
self._push_log("warning", f"斗鱼任务批次 {self.batch_id} 已停止")
|
|
else:
|
|
self._push_log("info", f"斗鱼任务批次 {self.batch_id} 完成")
|
|
self._push_log("result", "")
|
|
finally:
|
|
self.db.close()
|
|
|
|
|
|
class DouyuBatchRegistry:
|
|
"""管理运行中的斗鱼任务批次。"""
|
|
|
|
def __init__(self):
|
|
self._batches: dict[str, dict] = {}
|
|
|
|
def register(self, batch_id: str, log_queue: asyncio.Queue,
|
|
loop: asyncio.AbstractEventLoop, runner: DouyuBatchRunner):
|
|
self._batches[batch_id] = {
|
|
"log_queue": log_queue,
|
|
"loop": loop,
|
|
"runner": runner,
|
|
"finished": False,
|
|
"updated_at": time.time(),
|
|
}
|
|
|
|
def get(self, batch_id: str):
|
|
return self._batches.get(batch_id)
|
|
|
|
def pop(self, batch_id: str):
|
|
return self._batches.pop(batch_id, None)
|
|
|
|
def mark_finished(self, batch_id: str):
|
|
if batch_id in self._batches:
|
|
self._batches[batch_id]["finished"] = True
|
|
self._batches[batch_id]["updated_at"] = time.time()
|
|
|
|
def active_ids(self) -> set[str]:
|
|
return {
|
|
batch_id
|
|
for batch_id, info in self._batches.items()
|
|
if not info.get("finished")
|
|
}
|
|
|
|
|
|
douyu_batch_registry = DouyuBatchRegistry()
|