新增斗鱼活动任务模块:绑定、宝典、鱼翅、积分、兑换等功能
- 新增 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 权限点 - 侧边栏新增斗鱼分组与任务操作台菜单入口
This commit is contained in:
@@ -0,0 +1,490 @@
|
||||
"""斗鱼活动任务批次执行器。"""
|
||||
|
||||
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()
|
||||
Reference in New Issue
Block a user