新增斗鱼活动任务模块:绑定、宝典、鱼翅、积分、兑换等功能
- 新增 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:
+6
-1
@@ -11,7 +11,7 @@ from fastapi.responses import FileResponse
|
||||
from starlette.middleware.base import BaseHTTPMiddleware
|
||||
|
||||
from .database import init_db
|
||||
from .routers import auth, users, accounts, account_check, login, proxy, cookies, huya
|
||||
from .routers import auth, users, accounts, account_check, login, proxy, cookies, huya, douyu
|
||||
from .schemas import AppInfo
|
||||
from .version import get_app_version
|
||||
from utils import setup_logger
|
||||
@@ -30,12 +30,16 @@ async def lifespan(app: FastAPI):
|
||||
|
||||
from .database import SessionLocal
|
||||
from .services.huya_service import cleanup_orphan_huya_tasks
|
||||
from .services.douyu_service import cleanup_orphan_douyu_tasks
|
||||
|
||||
db = SessionLocal()
|
||||
try:
|
||||
cleaned = cleanup_orphan_huya_tasks(db, message="任务已中断(服务重启)")
|
||||
if cleaned:
|
||||
logger.info(f"启动清理虎牙残留任务: {cleaned} 条")
|
||||
cleaned_douyu = cleanup_orphan_douyu_tasks(db, message="任务已中断(服务重启)")
|
||||
if cleaned_douyu:
|
||||
logger.info(f"启动清理斗鱼残留任务: {cleaned_douyu} 条")
|
||||
finally:
|
||||
db.close()
|
||||
yield
|
||||
@@ -84,6 +88,7 @@ app.include_router(login.router)
|
||||
app.include_router(proxy.router)
|
||||
app.include_router(cookies.router)
|
||||
app.include_router(huya.router)
|
||||
app.include_router(douyu.router)
|
||||
|
||||
|
||||
@app.get("/api/health")
|
||||
|
||||
@@ -0,0 +1,150 @@
|
||||
"""新增斗鱼活动任务表
|
||||
|
||||
Revision ID: 20260724_0008
|
||||
Revises: 20260712_0007
|
||||
Create Date: 2026-07-24
|
||||
"""
|
||||
|
||||
from typing import Sequence, Union
|
||||
|
||||
from alembic import op
|
||||
import sqlalchemy as sa
|
||||
|
||||
|
||||
revision: str = "20260724_0008"
|
||||
down_revision: Union[str, None] = "20260712_0007"
|
||||
branch_labels: Union[str, Sequence[str], None] = None
|
||||
depends_on: Union[str, Sequence[str], None] = None
|
||||
|
||||
|
||||
def _has_table(bind, table_name: str) -> bool:
|
||||
return sa.inspect(bind).has_table(table_name)
|
||||
|
||||
|
||||
def _columns(bind, table_name: str) -> set[str]:
|
||||
if not _has_table(bind, table_name):
|
||||
return set()
|
||||
return {column["name"] for column in sa.inspect(bind).get_columns(table_name)}
|
||||
|
||||
|
||||
def _indexes(bind, table_name: str) -> set[str]:
|
||||
if not _has_table(bind, table_name):
|
||||
return set()
|
||||
return {index["name"] for index in sa.inspect(bind).get_indexes(table_name)}
|
||||
|
||||
|
||||
def _add_column_if_missing(bind, table_name: str, column: sa.Column) -> None:
|
||||
if column.name not in _columns(bind, table_name):
|
||||
op.add_column(table_name, column)
|
||||
|
||||
|
||||
def _create_index_if_missing(bind, name: str, table_name: str, columns: list[str], unique: bool = False) -> None:
|
||||
if name not in _indexes(bind, table_name):
|
||||
op.create_index(name, table_name, columns, unique=unique)
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
bind = op.get_bind()
|
||||
|
||||
_add_column_if_missing(bind, "accounts", sa.Column("uid", sa.String(length=32), nullable=True))
|
||||
_add_column_if_missing(bind, "accounts", sa.Column("nickname", sa.String(length=128), nullable=True))
|
||||
_add_column_if_missing(bind, "accounts", sa.Column("points", sa.Integer(), nullable=True))
|
||||
_add_column_if_missing(bind, "accounts", sa.Column("game_name", sa.String(length=128), nullable=True))
|
||||
_add_column_if_missing(bind, "accounts", sa.Column("game_channel", sa.String(length=128), nullable=True))
|
||||
_add_column_if_missing(bind, "accounts", sa.Column("gold_balance", sa.Integer(), nullable=True))
|
||||
_add_column_if_missing(bind, "accounts", sa.Column("exchange_balance", sa.Integer(), nullable=True))
|
||||
_add_column_if_missing(bind, "accounts", sa.Column("bind_status", sa.String(length=32), nullable=True))
|
||||
_add_column_if_missing(bind, "accounts", sa.Column("change_role_wait_time", sa.Integer(), nullable=True))
|
||||
_add_column_if_missing(bind, "accounts", sa.Column("updated_at", sa.DateTime(), nullable=True))
|
||||
_create_index_if_missing(bind, "ix_accounts_uid", "accounts", ["uid"])
|
||||
|
||||
if not _has_table(bind, "douyu_tasks"):
|
||||
op.create_table(
|
||||
"douyu_tasks",
|
||||
sa.Column("id", sa.Integer(), autoincrement=True, nullable=False),
|
||||
sa.Column("batch_id", sa.String(length=64), nullable=False),
|
||||
sa.Column("account_id", sa.Integer(), nullable=False),
|
||||
sa.Column("task_type", sa.String(length=64), nullable=False),
|
||||
sa.Column("status", sa.String(length=32), nullable=True),
|
||||
sa.Column("message", sa.String(length=512), nullable=True),
|
||||
sa.Column("result", sa.JSON(), nullable=True),
|
||||
sa.Column("created_by", sa.Integer(), nullable=False),
|
||||
sa.Column("created_at", sa.DateTime(), nullable=True),
|
||||
sa.Column("finished_at", sa.DateTime(), nullable=True),
|
||||
sa.ForeignKeyConstraint(["account_id"], ["accounts.id"]),
|
||||
sa.ForeignKeyConstraint(["created_by"], ["users.id"]),
|
||||
sa.PrimaryKeyConstraint("id"),
|
||||
)
|
||||
_create_index_if_missing(bind, "ix_douyu_tasks_batch_id", "douyu_tasks", ["batch_id"])
|
||||
_create_index_if_missing(bind, "ix_douyu_tasks_task_type", "douyu_tasks", ["task_type"])
|
||||
|
||||
if not _has_table(bind, "douyu_config"):
|
||||
op.create_table(
|
||||
"douyu_config",
|
||||
sa.Column("id", sa.Integer(), autoincrement=True, nullable=False),
|
||||
sa.Column("manual_id", sa.String(length=64), nullable=True),
|
||||
sa.Column("rid", sa.String(length=64), nullable=True),
|
||||
sa.Column("bind_act_alias", sa.String(length=64), nullable=True),
|
||||
sa.Column("confirm_act_alias", sa.String(length=64), nullable=True),
|
||||
sa.Column("legacy_act_alias", sa.String(length=64), nullable=True),
|
||||
sa.Column("room_id", sa.String(length=64), nullable=True),
|
||||
sa.Column("elite_amount", sa.Integer(), nullable=True),
|
||||
sa.Column("gold_pay_type", sa.Integer(), nullable=True),
|
||||
sa.Column("gift_id", sa.String(length=64), nullable=True),
|
||||
sa.Column("skin_id", sa.String(length=64), nullable=True),
|
||||
sa.Column("updated_at", sa.DateTime(), nullable=True),
|
||||
sa.PrimaryKeyConstraint("id"),
|
||||
)
|
||||
|
||||
if not _has_table(bind, "douyu_goods_snapshot"):
|
||||
op.create_table(
|
||||
"douyu_goods_snapshot",
|
||||
sa.Column("id", sa.Integer(), autoincrement=True, nullable=False),
|
||||
sa.Column("commodity_id", sa.String(length=64), nullable=False),
|
||||
sa.Column("name", sa.String(length=256), nullable=True),
|
||||
sa.Column("score", sa.Integer(), nullable=True),
|
||||
sa.Column("status", sa.String(length=32), nullable=True),
|
||||
sa.Column("raw", sa.JSON(), nullable=True),
|
||||
sa.Column("updated_at", sa.DateTime(), nullable=True),
|
||||
sa.PrimaryKeyConstraint("id"),
|
||||
)
|
||||
_create_index_if_missing(
|
||||
bind,
|
||||
"ix_douyu_goods_snapshot_commodity_id",
|
||||
"douyu_goods_snapshot",
|
||||
["commodity_id"],
|
||||
)
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
bind = op.get_bind()
|
||||
if _has_table(bind, "douyu_goods_snapshot"):
|
||||
indexes = _indexes(bind, "douyu_goods_snapshot")
|
||||
if "ix_douyu_goods_snapshot_commodity_id" in indexes:
|
||||
op.drop_index("ix_douyu_goods_snapshot_commodity_id", table_name="douyu_goods_snapshot")
|
||||
op.drop_table("douyu_goods_snapshot")
|
||||
if _has_table(bind, "douyu_config"):
|
||||
op.drop_table("douyu_config")
|
||||
if _has_table(bind, "douyu_tasks"):
|
||||
indexes = _indexes(bind, "douyu_tasks")
|
||||
if "ix_douyu_tasks_task_type" in indexes:
|
||||
op.drop_index("ix_douyu_tasks_task_type", table_name="douyu_tasks")
|
||||
if "ix_douyu_tasks_batch_id" in indexes:
|
||||
op.drop_index("ix_douyu_tasks_batch_id", table_name="douyu_tasks")
|
||||
op.drop_table("douyu_tasks")
|
||||
|
||||
account_columns = _columns(bind, "accounts")
|
||||
for column in [
|
||||
"updated_at",
|
||||
"change_role_wait_time",
|
||||
"bind_status",
|
||||
"exchange_balance",
|
||||
"gold_balance",
|
||||
"game_channel",
|
||||
"game_name",
|
||||
"points",
|
||||
"nickname",
|
||||
"uid",
|
||||
]:
|
||||
if column in account_columns:
|
||||
op.drop_column("accounts", column)
|
||||
@@ -55,10 +55,21 @@ class Account(Base):
|
||||
assigned_to = Column(Integer, ForeignKey("users.id"), nullable=True, index=True)
|
||||
tag = Column(String(64), default="")
|
||||
remark = Column(String(256), default="")
|
||||
uid = Column(String(32), default="", index=True)
|
||||
nickname = Column(String(128), default="")
|
||||
points = Column(Integer, nullable=True)
|
||||
game_name = Column(String(128), default="")
|
||||
game_channel = Column(String(128), default="")
|
||||
gold_balance = Column(Integer, nullable=True)
|
||||
exchange_balance = Column(Integer, nullable=True)
|
||||
bind_status = Column(String(32), default="")
|
||||
change_role_wait_time = Column(Integer, nullable=True)
|
||||
created_at = Column(DateTime, default=_utcnow)
|
||||
updated_at = Column(DateTime, default=_utcnow, onupdate=_utcnow)
|
||||
|
||||
assigned_user = relationship("User", back_populates="assigned_accounts", foreign_keys=[assigned_to])
|
||||
login_tasks = relationship("LoginTask", back_populates="account")
|
||||
douyu_tasks = relationship("DouyuTask", back_populates="account")
|
||||
|
||||
|
||||
class LoginTask(Base):
|
||||
@@ -78,6 +89,55 @@ class LoginTask(Base):
|
||||
account = relationship("Account", back_populates="login_tasks")
|
||||
|
||||
|
||||
class DouyuTask(Base):
|
||||
"""斗鱼业务任务"""
|
||||
__tablename__ = "douyu_tasks"
|
||||
|
||||
id = Column(Integer, primary_key=True, autoincrement=True)
|
||||
batch_id = Column(String(64), nullable=False, index=True)
|
||||
account_id = Column(Integer, ForeignKey("accounts.id"), nullable=False)
|
||||
task_type = Column(String(64), nullable=False, index=True)
|
||||
status = Column(String(32), default="pending")
|
||||
message = Column(String(512), default="")
|
||||
result = Column(JSON, nullable=True)
|
||||
created_by = Column(Integer, ForeignKey("users.id"), nullable=False)
|
||||
created_at = Column(DateTime, default=_utcnow)
|
||||
finished_at = Column(DateTime, nullable=True)
|
||||
|
||||
account = relationship("Account", back_populates="douyu_tasks")
|
||||
|
||||
|
||||
class DouyuConfig(Base):
|
||||
"""斗鱼业务配置"""
|
||||
__tablename__ = "douyu_config"
|
||||
|
||||
id = Column(Integer, primary_key=True, autoincrement=True)
|
||||
manual_id = Column(String(64), default="G4KA4Qnz4LDp7")
|
||||
rid = Column(String(64), default="9263298")
|
||||
bind_act_alias = Column(String(64), default="20250213NQCYX")
|
||||
confirm_act_alias = Column(String(64), default="20260120QYOOB")
|
||||
legacy_act_alias = Column(String(64), default="cjm")
|
||||
room_id = Column(String(64), default="9263298")
|
||||
elite_amount = Column(Integer, default=3000)
|
||||
gold_pay_type = Column(Integer, default=1)
|
||||
gift_id = Column(String(64), default="23643")
|
||||
skin_id = Column(String(64), default="2942")
|
||||
updated_at = Column(DateTime, default=_utcnow, onupdate=_utcnow)
|
||||
|
||||
|
||||
class DouyuGoodsSnapshot(Base):
|
||||
"""斗鱼兑换商品快照"""
|
||||
__tablename__ = "douyu_goods_snapshot"
|
||||
|
||||
id = Column(Integer, primary_key=True, autoincrement=True)
|
||||
commodity_id = Column(String(64), nullable=False, index=True)
|
||||
name = Column(String(256), default="")
|
||||
score = Column(Integer, nullable=True)
|
||||
status = Column(String(32), default="")
|
||||
raw = Column(JSON, nullable=True)
|
||||
updated_at = Column(DateTime, default=_utcnow, onupdate=_utcnow)
|
||||
|
||||
|
||||
class HuyaAccount(Base):
|
||||
"""虎牙账号"""
|
||||
__tablename__ = "huya_accounts"
|
||||
|
||||
@@ -23,6 +23,9 @@ PERMISSIONS = {
|
||||
# Cookie
|
||||
"cookie:view": "查看 Cookie",
|
||||
"cookie:export": "导出 Cookie",
|
||||
# 斗鱼活动
|
||||
"douyu:task": "斗鱼任务管理",
|
||||
"douyu:config": "斗鱼配置管理",
|
||||
# 虎牙
|
||||
"huya:account": "虎牙账号管理(兼容旧权限)",
|
||||
"huya:view_all": "查看所有虎牙账号",
|
||||
@@ -57,6 +60,8 @@ ROLE_PERMISSIONS = {
|
||||
"login:view_all",
|
||||
"cookie:view",
|
||||
"cookie:export",
|
||||
"douyu:task",
|
||||
"douyu:config",
|
||||
"huya:account",
|
||||
"huya:view_all",
|
||||
"huya:import",
|
||||
|
||||
@@ -0,0 +1,324 @@
|
||||
"""斗鱼活动任务路由。"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import threading
|
||||
from datetime import datetime, timezone
|
||||
|
||||
from fastapi import APIRouter, Depends, HTTPException, Query, WebSocket, WebSocketDisconnect
|
||||
from sqlalchemy import or_
|
||||
from sqlalchemy.orm import Session, joinedload
|
||||
|
||||
from ..database import SessionLocal, get_db
|
||||
from ..deps import authenticate_websocket, get_current_user, require_permission
|
||||
from ..models import Account, DouyuConfig, DouyuGoodsSnapshot, DouyuTask, User
|
||||
from ..permissions import user_has_permission
|
||||
from ..schemas import (
|
||||
DouyuConfigOut,
|
||||
DouyuConfigUpdate,
|
||||
DouyuGoodsOut,
|
||||
DouyuTaskAccountOut,
|
||||
DouyuTaskBatchRequest,
|
||||
DouyuTaskOut,
|
||||
)
|
||||
from ..services.douyu_runner import DouyuBatchRunner, douyu_batch_registry
|
||||
from ..services.douyu_service import (
|
||||
DOUYU_CONFIG_FIELDS,
|
||||
SUPPORTED_DOUYU_TASK_TYPES,
|
||||
apply_douyu_config_defaults,
|
||||
cleanup_orphan_douyu_tasks,
|
||||
cookie_account_ids_query,
|
||||
create_douyu_planned_tasks,
|
||||
douyu_config_value,
|
||||
ensure_douyu_config,
|
||||
)
|
||||
|
||||
|
||||
router = APIRouter(prefix="/api/douyu", tags=["斗鱼活动"])
|
||||
|
||||
|
||||
def _can_view_all(user: User) -> bool:
|
||||
return user_has_permission(user, "account:view_all")
|
||||
|
||||
|
||||
def _visible_task_accounts_query(db: Session, current: User):
|
||||
"""返回当前用户可用于斗鱼任务的账号查询。"""
|
||||
cookie_ids = cookie_account_ids_query(db).subquery()
|
||||
query = (
|
||||
db.query(Account)
|
||||
.options(joinedload(Account.assigned_user))
|
||||
.filter(Account.id.in_(cookie_ids))
|
||||
)
|
||||
if _can_view_all(current):
|
||||
return query
|
||||
if user_has_permission(current, "account:view_assigned"):
|
||||
return query.filter(Account.assigned_to == current.id)
|
||||
raise HTTPException(status_code=403, detail="无权查看斗鱼账号")
|
||||
|
||||
|
||||
def _account_out(account: Account) -> DouyuTaskAccountOut:
|
||||
return DouyuTaskAccountOut(
|
||||
id=account.id,
|
||||
username=account.username,
|
||||
uid=account.uid or "",
|
||||
nickname=account.nickname or "",
|
||||
tag=account.tag or "",
|
||||
points=account.points,
|
||||
game_name=account.game_name or "",
|
||||
game_channel=account.game_channel or "",
|
||||
gold_balance=account.gold_balance,
|
||||
exchange_balance=account.exchange_balance,
|
||||
bind_status=account.bind_status or "",
|
||||
change_role_wait_time=account.change_role_wait_time,
|
||||
assigned_to=account.assigned_to,
|
||||
assigned_username=account.assigned_user.username if account.assigned_user else None,
|
||||
)
|
||||
|
||||
|
||||
def _task_out(task: DouyuTask) -> DouyuTaskOut:
|
||||
account = task.account
|
||||
return DouyuTaskOut(
|
||||
id=task.id,
|
||||
batch_id=task.batch_id,
|
||||
account_id=task.account_id,
|
||||
account_username=account.username if account else "",
|
||||
account_uid=account.uid if account else "",
|
||||
account_nickname=account.nickname if account else "",
|
||||
task_type=task.task_type,
|
||||
status=task.status or "",
|
||||
message=task.message or "",
|
||||
result=task.result if isinstance(task.result, dict) else None,
|
||||
created_by=task.created_by,
|
||||
created_at=task.created_at,
|
||||
finished_at=task.finished_at,
|
||||
)
|
||||
|
||||
|
||||
def _config_out(config: DouyuConfig) -> DouyuConfigOut:
|
||||
return DouyuConfigOut(
|
||||
manual_id=douyu_config_value("manual_id", config.manual_id),
|
||||
rid=douyu_config_value("rid", config.rid),
|
||||
bind_act_alias=douyu_config_value("bind_act_alias", config.bind_act_alias),
|
||||
confirm_act_alias=douyu_config_value("confirm_act_alias", config.confirm_act_alias),
|
||||
legacy_act_alias=douyu_config_value("legacy_act_alias", config.legacy_act_alias),
|
||||
room_id=douyu_config_value("room_id", config.room_id),
|
||||
elite_amount=douyu_config_value("elite_amount", config.elite_amount),
|
||||
gold_pay_type=douyu_config_value("gold_pay_type", config.gold_pay_type),
|
||||
gift_id=douyu_config_value("gift_id", config.gift_id),
|
||||
skin_id=douyu_config_value("skin_id", config.skin_id),
|
||||
updated_at=config.updated_at,
|
||||
)
|
||||
|
||||
|
||||
@router.get("/task-types")
|
||||
def task_types(current: User = Depends(require_permission("douyu:task"))):
|
||||
"""返回斗鱼任务类型。"""
|
||||
return SUPPORTED_DOUYU_TASK_TYPES
|
||||
|
||||
|
||||
@router.get("/accounts", response_model=list[DouyuTaskAccountOut])
|
||||
def list_task_accounts(
|
||||
search: str = Query(""),
|
||||
db: Session = Depends(get_db),
|
||||
current: User = Depends(get_current_user),
|
||||
):
|
||||
"""查看可执行斗鱼任务的账号(必须有成功 Cookie)。"""
|
||||
query = _visible_task_accounts_query(db, current)
|
||||
search_text = (search or "").strip()
|
||||
if search_text:
|
||||
pattern = f"%{search_text}%"
|
||||
query = query.filter(or_(
|
||||
Account.username.ilike(pattern),
|
||||
Account.uid.ilike(pattern),
|
||||
Account.nickname.ilike(pattern),
|
||||
Account.tag.ilike(pattern),
|
||||
Account.game_name.ilike(pattern),
|
||||
))
|
||||
rows = query.order_by(Account.id.desc()).limit(500).all()
|
||||
return [_account_out(account) for account in rows]
|
||||
|
||||
|
||||
@router.get("/config", response_model=DouyuConfigOut)
|
||||
def get_config(
|
||||
db: Session = Depends(get_db),
|
||||
current: User = Depends(require_permission("douyu:config")),
|
||||
):
|
||||
"""获取斗鱼活动配置。"""
|
||||
return _config_out(ensure_douyu_config(db))
|
||||
|
||||
|
||||
@router.put("/config", response_model=DouyuConfigOut)
|
||||
def update_config(
|
||||
req: DouyuConfigUpdate,
|
||||
db: Session = Depends(get_db),
|
||||
current: User = Depends(require_permission("douyu:config")),
|
||||
):
|
||||
"""更新斗鱼活动配置。"""
|
||||
config = ensure_douyu_config(db)
|
||||
for field in DOUYU_CONFIG_FIELDS:
|
||||
value = getattr(req, field)
|
||||
if value is None:
|
||||
continue
|
||||
setattr(config, field, value.strip() if isinstance(value, str) else value)
|
||||
apply_douyu_config_defaults(config)
|
||||
config.updated_at = datetime.now(timezone.utc)
|
||||
db.commit()
|
||||
db.refresh(config)
|
||||
return _config_out(config)
|
||||
|
||||
|
||||
@router.get("/goods", response_model=list[DouyuGoodsOut])
|
||||
def list_goods(
|
||||
db: Session = Depends(get_db),
|
||||
current: User = Depends(require_permission("douyu:task")),
|
||||
):
|
||||
"""查看已缓存的斗鱼商品快照。"""
|
||||
rows = db.query(DouyuGoodsSnapshot).order_by(DouyuGoodsSnapshot.id.asc()).all()
|
||||
return rows
|
||||
|
||||
|
||||
@router.post("/tasks/batch")
|
||||
async def create_task_batch(
|
||||
req: DouyuTaskBatchRequest,
|
||||
db: Session = Depends(get_db),
|
||||
current: User = Depends(require_permission("douyu:task")),
|
||||
):
|
||||
"""创建斗鱼任务记录并启动后台执行器。"""
|
||||
if not req.account_ids:
|
||||
raise HTTPException(status_code=400, detail="请选择斗鱼账号")
|
||||
|
||||
cleanup_orphan_douyu_tasks(
|
||||
db,
|
||||
active_batch_ids=douyu_batch_registry.active_ids(),
|
||||
statuses=("pending", "running"),
|
||||
message="任务已中断(无执行器接管)",
|
||||
)
|
||||
|
||||
try:
|
||||
batch_id, count = create_douyu_planned_tasks(
|
||||
db,
|
||||
req.account_ids,
|
||||
req.task_type,
|
||||
current.id,
|
||||
req.payload,
|
||||
)
|
||||
except ValueError as exc:
|
||||
raise HTTPException(status_code=400, detail=str(exc)) from exc
|
||||
if count == 0:
|
||||
raise HTTPException(status_code=400, detail="没有可执行的斗鱼账号,请先登录获取 Cookie")
|
||||
|
||||
log_queue = asyncio.Queue()
|
||||
loop = asyncio.get_running_loop()
|
||||
thread_db = SessionLocal()
|
||||
runner = DouyuBatchRunner(
|
||||
db=thread_db,
|
||||
batch_id=batch_id,
|
||||
task_type=req.task_type,
|
||||
payload=req.payload,
|
||||
log_queue=log_queue,
|
||||
loop=loop,
|
||||
concurrency=req.concurrency,
|
||||
)
|
||||
douyu_batch_registry.register(batch_id, log_queue, loop, runner)
|
||||
|
||||
thread = threading.Thread(target=runner.run, daemon=True)
|
||||
thread.start()
|
||||
|
||||
return {"batch_id": batch_id, "count": count, "success": True}
|
||||
|
||||
|
||||
@router.get("/tasks", response_model=list[DouyuTaskOut])
|
||||
def list_tasks(
|
||||
batch_id: str | None = None,
|
||||
db: Session = Depends(get_db),
|
||||
current: User = Depends(require_permission("douyu:task")),
|
||||
):
|
||||
"""查看斗鱼任务记录。"""
|
||||
cleanup_orphan_douyu_tasks(
|
||||
db,
|
||||
active_batch_ids=douyu_batch_registry.active_ids(),
|
||||
statuses=("pending", "running"),
|
||||
message="任务已中断(无执行器接管)",
|
||||
)
|
||||
query = db.query(DouyuTask).options(joinedload(DouyuTask.account))
|
||||
if batch_id:
|
||||
query = query.filter(DouyuTask.batch_id == batch_id)
|
||||
rows = query.order_by(DouyuTask.id.desc()).limit(300).all()
|
||||
return [_task_out(task) for task in rows]
|
||||
|
||||
|
||||
@router.get("/tasks/{task_id}", response_model=DouyuTaskOut)
|
||||
def get_task(
|
||||
task_id: int,
|
||||
db: Session = Depends(get_db),
|
||||
current: User = Depends(require_permission("douyu:task")),
|
||||
):
|
||||
"""获取单条斗鱼任务详情。"""
|
||||
task = (
|
||||
db.query(DouyuTask)
|
||||
.options(joinedload(DouyuTask.account))
|
||||
.filter(DouyuTask.id == task_id)
|
||||
.first()
|
||||
)
|
||||
if not task:
|
||||
raise HTTPException(status_code=404, detail="任务不存在")
|
||||
return _task_out(task)
|
||||
|
||||
|
||||
@router.post("/stop/{batch_id}")
|
||||
def stop_batch(
|
||||
batch_id: str,
|
||||
db: Session = Depends(get_db),
|
||||
current: User = Depends(require_permission("douyu:task")),
|
||||
):
|
||||
"""停止正在运行的斗鱼批次。"""
|
||||
batch = douyu_batch_registry.get(batch_id)
|
||||
if batch:
|
||||
if batch.get("finished"):
|
||||
douyu_batch_registry.pop(batch_id)
|
||||
cleaned = cleanup_orphan_douyu_tasks(db, batch_id=batch_id, message="批次已结束")
|
||||
if cleaned:
|
||||
return {"message": f"批次已结束,已清理 {cleaned} 个残留任务", "success": True}
|
||||
raise HTTPException(status_code=404, detail="批次已结束")
|
||||
batch["runner"].stop()
|
||||
return {"message": "已发送停止信号", "success": True}
|
||||
|
||||
cleaned = cleanup_orphan_douyu_tasks(db, batch_id=batch_id, message="任务已停止(批次不存在)")
|
||||
if cleaned:
|
||||
return {"message": f"已清理 {cleaned} 个残留任务", "success": True}
|
||||
raise HTTPException(status_code=404, detail="批次不存在或已结束")
|
||||
|
||||
|
||||
@router.websocket("/ws/{batch_id}")
|
||||
async def ws_douyu_logs(websocket: WebSocket, batch_id: str):
|
||||
"""斗鱼实时日志推送通道。"""
|
||||
user = authenticate_websocket(websocket)
|
||||
if not user:
|
||||
await websocket.close(code=1008, reason="未授权")
|
||||
return
|
||||
await websocket.accept()
|
||||
|
||||
batch = douyu_batch_registry.get(batch_id)
|
||||
if not batch:
|
||||
await websocket.send_json({"level": "error", "message": "批次不存在或已结束"})
|
||||
await websocket.close()
|
||||
return
|
||||
|
||||
log_queue: asyncio.Queue = batch["log_queue"]
|
||||
try:
|
||||
while True:
|
||||
try:
|
||||
msg = await asyncio.wait_for(log_queue.get(), timeout=30)
|
||||
await websocket.send_json(msg)
|
||||
if msg.get("level") == "result":
|
||||
await asyncio.sleep(0.1)
|
||||
break
|
||||
except asyncio.TimeoutError:
|
||||
await websocket.send_json({"level": "heartbeat", "message": ""})
|
||||
except WebSocketDisconnect:
|
||||
pass
|
||||
finally:
|
||||
latest = douyu_batch_registry.get(batch_id)
|
||||
if latest and latest.get("finished"):
|
||||
douyu_batch_registry.pop(batch_id)
|
||||
@@ -514,6 +514,134 @@ class HuyaRechargeGoodsOut(BaseModel):
|
||||
}
|
||||
|
||||
|
||||
# ---- 斗鱼活动任务 ----
|
||||
class DouyuConfigOut(BaseModel):
|
||||
manual_id: str = "G4KA4Qnz4LDp7"
|
||||
rid: str = "9263298"
|
||||
bind_act_alias: str = "20250213NQCYX"
|
||||
confirm_act_alias: str = "20260120QYOOB"
|
||||
legacy_act_alias: str = "cjm"
|
||||
room_id: str = "9263298"
|
||||
elite_amount: int = 3000
|
||||
gold_pay_type: int = 1
|
||||
gift_id: str = "23643"
|
||||
skin_id: str = "2942"
|
||||
updated_at: Optional[datetime] = None
|
||||
|
||||
@model_serializer
|
||||
def _serialize(self) -> dict[str, Any]:
|
||||
return {
|
||||
"manual_id": self.manual_id,
|
||||
"rid": self.rid,
|
||||
"bind_act_alias": self.bind_act_alias,
|
||||
"confirm_act_alias": self.confirm_act_alias,
|
||||
"legacy_act_alias": self.legacy_act_alias,
|
||||
"room_id": self.room_id,
|
||||
"elite_amount": self.elite_amount,
|
||||
"gold_pay_type": self.gold_pay_type,
|
||||
"gift_id": self.gift_id,
|
||||
"skin_id": self.skin_id,
|
||||
"updated_at": _ensure_tz(self.updated_at).isoformat() if self.updated_at else None,
|
||||
}
|
||||
|
||||
|
||||
class DouyuConfigUpdate(BaseModel):
|
||||
manual_id: Optional[str] = None
|
||||
rid: Optional[str] = None
|
||||
bind_act_alias: Optional[str] = None
|
||||
confirm_act_alias: Optional[str] = None
|
||||
legacy_act_alias: Optional[str] = None
|
||||
room_id: Optional[str] = None
|
||||
elite_amount: Optional[int] = Field(None, ge=1)
|
||||
gold_pay_type: Optional[int] = Field(None, ge=1, le=9)
|
||||
gift_id: Optional[str] = None
|
||||
skin_id: Optional[str] = None
|
||||
|
||||
|
||||
class DouyuTaskBatchRequest(BaseModel):
|
||||
account_ids: list[int]
|
||||
task_type: str
|
||||
concurrency: int = Field(3, ge=1, le=10)
|
||||
payload: dict[str, Any] = Field(default_factory=dict)
|
||||
|
||||
|
||||
class DouyuTaskOut(BaseModel):
|
||||
id: int
|
||||
batch_id: str
|
||||
account_id: int
|
||||
account_username: str = ""
|
||||
account_uid: str = ""
|
||||
account_nickname: str = ""
|
||||
task_type: str
|
||||
status: str
|
||||
message: str = ""
|
||||
result: Optional[dict[str, Any]] = None
|
||||
created_by: int
|
||||
created_at: Optional[datetime] = None
|
||||
finished_at: Optional[datetime] = None
|
||||
|
||||
model_config = ConfigDict(from_attributes=True)
|
||||
|
||||
@model_serializer
|
||||
def _serialize(self) -> dict[str, Any]:
|
||||
return {
|
||||
"id": self.id,
|
||||
"batch_id": self.batch_id,
|
||||
"account_id": self.account_id,
|
||||
"account_username": self.account_username,
|
||||
"account_uid": self.account_uid,
|
||||
"account_nickname": self.account_nickname,
|
||||
"task_type": self.task_type,
|
||||
"status": self.status,
|
||||
"message": self.message,
|
||||
"result": self.result,
|
||||
"created_by": self.created_by,
|
||||
"created_at": _ensure_tz(self.created_at).isoformat() if self.created_at else None,
|
||||
"finished_at": _ensure_tz(self.finished_at).isoformat() if self.finished_at else None,
|
||||
}
|
||||
|
||||
|
||||
class DouyuGoodsOut(BaseModel):
|
||||
id: int
|
||||
commodity_id: str
|
||||
name: str = ""
|
||||
score: Optional[int] = None
|
||||
status: str = ""
|
||||
raw: Optional[dict[str, Any]] = None
|
||||
updated_at: Optional[datetime] = None
|
||||
|
||||
model_config = ConfigDict(from_attributes=True)
|
||||
|
||||
@model_serializer
|
||||
def _serialize(self) -> dict[str, Any]:
|
||||
return {
|
||||
"id": self.id,
|
||||
"commodity_id": self.commodity_id,
|
||||
"name": self.name,
|
||||
"score": self.score,
|
||||
"status": self.status,
|
||||
"raw": self.raw,
|
||||
"updated_at": _ensure_tz(self.updated_at).isoformat() if self.updated_at else None,
|
||||
}
|
||||
|
||||
|
||||
class DouyuTaskAccountOut(BaseModel):
|
||||
id: int
|
||||
username: str
|
||||
uid: str = ""
|
||||
nickname: str = ""
|
||||
tag: str = ""
|
||||
points: Optional[int] = None
|
||||
game_name: str = ""
|
||||
game_channel: str = ""
|
||||
gold_balance: Optional[int] = None
|
||||
exchange_balance: Optional[int] = None
|
||||
bind_status: str = ""
|
||||
change_role_wait_time: Optional[int] = None
|
||||
assigned_to: Optional[int] = None
|
||||
assigned_username: Optional[str] = None
|
||||
|
||||
|
||||
# ---- 代理配置 ----
|
||||
class ProxyConfigOut(BaseModel):
|
||||
enabled: bool = False
|
||||
|
||||
@@ -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()
|
||||
@@ -0,0 +1,195 @@
|
||||
"""斗鱼活动任务服务。"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import uuid
|
||||
from datetime import datetime, timezone
|
||||
|
||||
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",
|
||||
"create_gold_qr": "充值鱼翅",
|
||||
"donate_elite_gift": "赠送精英令",
|
||||
"query_points": "一键查询积分",
|
||||
"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",
|
||||
}
|
||||
|
||||
|
||||
DOUYU_CONFIG_DEFAULTS = {
|
||||
"manual_id": "G4KA4Qnz4LDp7",
|
||||
"rid": "9263298",
|
||||
"bind_act_alias": "20250213NQCYX",
|
||||
"confirm_act_alias": "20260120QYOOB",
|
||||
"legacy_act_alias": "cjm",
|
||||
"room_id": "9263298",
|
||||
"elite_amount": 3000,
|
||||
"gold_pay_type": 1,
|
||||
"gift_id": "23643",
|
||||
"skin_id": "2942",
|
||||
}
|
||||
|
||||
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:
|
||||
normalized = douyu_config_value(field, getattr(config, field, None))
|
||||
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
|
||||
|
||||
|
||||
def latest_success_cookie(db: Session, account_id: int) -> str:
|
||||
"""读取账号最近一次成功登录 Cookie。"""
|
||||
task = (
|
||||
db.query(LoginTask)
|
||||
.filter(
|
||||
LoginTask.account_id == account_id,
|
||||
LoginTask.status == "success",
|
||||
LoginTask.cookie != "",
|
||||
)
|
||||
.order_by(LoginTask.id.desc())
|
||||
.first()
|
||||
)
|
||||
return task.cookie if task else ""
|
||||
|
||||
|
||||
def cookie_account_ids_query(db: Session):
|
||||
"""返回拥有成功 Cookie 的斗鱼账号 ID 查询。"""
|
||||
return (
|
||||
db.query(LoginTask.account_id)
|
||||
.filter(LoginTask.status == "success", LoginTask.cookie != "")
|
||||
.distinct()
|
||||
)
|
||||
|
||||
|
||||
def visible_douyu_task_accounts(db: Session, account_ids: list[int]) -> list[Account]:
|
||||
"""只保留存在成功 Cookie 的斗鱼账号。"""
|
||||
if not account_ids:
|
||||
return []
|
||||
cookie_ids = cookie_account_ids_query(db).subquery()
|
||||
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,
|
||||
created_by: int,
|
||||
payload: dict | None = None,
|
||||
) -> tuple[str, int]:
|
||||
"""创建斗鱼任务记录,等待后台执行器消费。"""
|
||||
if task_type not in SUPPORTED_DOUYU_TASK_TYPES:
|
||||
raise ValueError("不支持的任务类型")
|
||||
|
||||
accounts = visible_douyu_task_accounts(db, account_ids)
|
||||
if task_type == "refresh_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,
|
||||
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(timezone.utc)
|
||||
for task in tasks:
|
||||
task.status = "stopped"
|
||||
task.message = message
|
||||
task.finished_at = now
|
||||
db.commit()
|
||||
return len(tasks)
|
||||
Reference in New Issue
Block a user