新增斗鱼活动任务模块:绑定、宝典、鱼翅、积分、兑换等功能

- 新增 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:
yml2213
2026-07-24 16:11:39 +08:00
parent f91c05c108
commit 62099e8d68
17 changed files with 2506 additions and 1 deletions
+3
View File
@@ -5,6 +5,7 @@ from .login_api import LoginAPIStrategy
from .login_api_wgapi import WgapiLoginAPI from .login_api_wgapi import WgapiLoginAPI
from .login_api_iframe import IframeLoginAPI from .login_api_iframe import IframeLoginAPI
from .email_verifier import EmailVerifier from .email_verifier import EmailVerifier
from .activity_client import DouyuActivityClient, DouyuActivityError
__all__ = [ __all__ = [
"DouyuLogin", "DouyuLogin",
@@ -13,4 +14,6 @@ __all__ = [
"WgapiLoginAPI", "WgapiLoginAPI",
"IframeLoginAPI", "IframeLoginAPI",
"EmailVerifier", "EmailVerifier",
"DouyuActivityClient",
"DouyuActivityError",
] ]
+465
View File
@@ -0,0 +1,465 @@
"""斗鱼活动、兑换与充值接口客户端。"""
from __future__ import annotations
import json
import random
import string
from collections.abc import Callable
from typing import Any
from urllib.parse import unquote
import requests
from .cookie_utils import cookie_pairs, cookie_value, normalize_cookie_pairs, normalize_douyu_cookie
PC_UA = (
"Mozilla/5.0 (Windows NT 10.0; Win64; x64) "
"AppleWebKit/537.36 (KHTML, like Gecko) "
"Chrome/133.0.0.0 Safari/537.36"
)
class DouyuActivityError(RuntimeError):
"""斗鱼活动接口异常。"""
class DouyuActivityClient:
"""封装斗鱼活动页、兑换、充值和送礼相关接口。"""
CSRF_API = "https://www.douyu.com/japi/carnival/nc/common/generateCsrf"
ACF_CCN_API = "https://www.douyu.com/curl/csrfApi/getCsrfCookie?"
ACF_CCN_FALLBACK_API = "https://www.douyu.com/curl/csrfNlApi/getCsrfCookie"
SUBSCRIBE_API = "https://www.douyu.com/wgapi/livenc/asubscribe/userSubStatus"
ROLE_PARAM_API = "https://www.douyu.com/japi/carnivalApi/tencent/roleParam"
BIND_INFO_API = "https://www.douyu.com/japi/carnivalApi/tencent/bindInfo"
BIND_INFO_V2_API = "https://www.douyu.com/japi/carnivalApi/v2/tencent/bindInfo"
BIND_API = "https://www.douyu.com/japi/carnivalApi/tencent/bind"
GOODS_API = "https://www.douyu.com/wgapi/ordnc/activity/peace/storehome"
EXCHANGE_API = "https://www.douyu.com/wgapi/ordnc/activity/peace/exchange"
EXCHANGE_LIST_API = "https://www.douyu.com/wgapi/ordnc/activity/peace/exchangeList"
CREDIT_BALANCE_API = "https://www.douyu.com/japi/oms/web/peace/credit/balance"
CREDIT_DIFF_API = "https://www.douyu.com/japi/oms/web/peace/credit/diff"
PEACE_ITEM_API = "https://www.douyu.com/japi/oms/web/peace/item"
PAY_QR_API = "https://www.douyu.com/japi/oms/web/pay/getQrCode"
GOLD_QR_API = "https://cz.douyu.com/m/gold/getQrCode"
GOLD_ACCOUNT_API = "https://cz.douyu.com/item/gold/account"
EXCHANGE_BALANCE_API = "https://www.douyu.com/wjapi/nc/exchange/fim"
DONATE_API = "https://www.douyu.com/japi/gift/donate/mainsite/v3"
def __init__(
self,
cookie: str,
*,
logger: Callable[[str], None] | None = None,
timeout: tuple[float, float] = (8, 20),
):
self.cookie = normalize_douyu_cookie(cookie)
self.timeout = timeout
self.logger = logger or (lambda _msg: None)
self.session = requests.Session()
self.session.trust_env = False
self.session.headers.update({
"User-Agent": PC_UA,
"Accept": "application/json, text/plain, */*",
"Accept-Language": "zh-CN,zh;q=0.9",
})
self._load_cookie(self.cookie)
@staticmethod
def profile_from_cookie(cookie: str) -> dict[str, str]:
"""从斗鱼 Cookie 提取账号基础信息。"""
nickname = cookie_value(cookie, "acf_nickname")
return {
"uid": cookie_value(cookie, "acf_uid"),
"nickname": unquote(nickname) if nickname else "",
}
def _load_cookie(self, cookie: str) -> None:
for key, value in cookie_pairs(cookie):
self.session.cookies.set(key, value, domain=".douyu.com", path="/")
def _merge_response_cookie(self, response: requests.Response) -> None:
if not response.cookies:
return
self.cookie = normalize_cookie_pairs([
*cookie_pairs(self.cookie),
*[(key, value) for key, value in response.cookies.items()],
])
self._load_cookie(self.cookie)
def _headers(self, referer: str = "https://www.douyu.com/") -> dict[str, str]:
return {
"User-Agent": PC_UA,
"Accept": "application/json, text/plain, */*",
"Accept-Language": "zh-CN,zh;q=0.9",
"Origin": "https://www.douyu.com",
"Referer": referer,
"Cookie": self.cookie,
}
def _request(
self,
method: str,
url: str,
*,
source: str,
headers: dict[str, str] | None = None,
**kwargs,
) -> requests.Response:
req_headers = self._headers()
if headers:
req_headers.update(headers)
response = self.session.request(
method,
url,
headers=req_headers,
timeout=kwargs.pop("timeout", self.timeout),
**kwargs,
)
self._merge_response_cookie(response)
self.logger(f"{source}: {response.status_code}")
response.raise_for_status()
return response
def _json(self, response: requests.Response, source: str) -> dict[str, Any]:
text = response.text.strip()
if not text:
raise DouyuActivityError(f"{source} 返回为空")
try:
return response.json()
except json.JSONDecodeError as exc:
raise DouyuActivityError(f"{source} 返回不是 JSON: {text[:200]}") from exc
def _request_json(self, method: str, url: str, source: str, **kwargs) -> dict[str, Any]:
return self._json(self._request(method, url, source=source, **kwargs), source)
def csrf_token(self) -> str:
"""获取 cvl_csrf_token。"""
payload = self._request_json(
"post",
self.CSRF_API,
"生成 CSRF",
data="{}",
headers={
"Content-Type": "application/json;charset=UTF-8",
"Referer": "https://www.douyu.com/pages/live-peace-handbook/web/shop?ditchname=pass0",
},
)
if payload.get("error") not in (0, "0", None):
raise DouyuActivityError(payload.get("msg") or "生成 CSRF 失败")
token = cookie_value(self.cookie, "cvl_csrf_token")
if not token:
raise DouyuActivityError("响应 Cookie 中没有 cvl_csrf_token")
return token
def acf_ccn(self, *, refresh_subscribe: bool = True) -> str:
"""获取 acf_ccn;必要时通过订阅接口刷新一次。"""
response = self._request(
"get",
self.ACF_CCN_API,
source="获取 acf_ccn",
headers={
"Origin": "",
"Referer": "https://www.douyu.com/pages/ord-task-center?clientType=web&panelSource=1&rid=0",
},
)
ctn = cookie_value(self.cookie, "acf_ccn")
if not ctn:
response = self._request(
"get",
self.ACF_CCN_FALLBACK_API,
source="获取 acf_ccn 备用接口",
headers={"Origin": ""},
)
ctn = cookie_value(self.cookie, "acf_ccn")
if not ctn:
raise DouyuActivityError(f"获取 acf_ccn 失败: {response.text[:200]}")
if refresh_subscribe:
payload = self._request_json(
"post",
self.SUBSCRIBE_API,
"刷新订阅状态",
data={"subscribe": "cmn", "ctn": ctn},
headers={
"Content-Type": "application/x-www-form-urlencoded; charset=UTF-8",
"Referer": "https://www.douyu.com/",
},
)
if payload.get("error") not in (0, "0"):
raise DouyuActivityError(payload.get("msg") or "刷新订阅状态失败")
ctn = cookie_value(self.cookie, "acf_ccn") or ctn
return ctn
def get_bind_qr(self, act_alias: str) -> dict[str, Any]:
"""生成腾讯游戏绑定二维码链接。"""
ctn = self.acf_ccn(refresh_subscribe=True)
payload = self._request_json(
"post",
self.ROLE_PARAM_API,
"获取绑定二维码",
data={"actAlias": act_alias, "ctn": ctn},
headers={
"Content-Type": "application/x-www-form-urlencoded; charset=UTF-8",
"Referer": "https://www.douyu.com/",
},
)
url = (payload.get("data") or {}).get("url") or ""
if not url:
raise DouyuActivityError(payload.get("msg") or "获取绑定二维码失败")
return {"url": url, "ctn": ctn, "raw": payload}
def bind_info(self, act_alias: str, *, v2: bool = False) -> dict[str, Any]:
"""查询绑定游戏账号信息。"""
url = self.BIND_INFO_V2_API if v2 else self.BIND_INFO_API
payload = self._request_json(
"get",
url,
"查询绑定信息",
params={"actAlias": act_alias},
headers={"Origin": "", "Referer": "https://www.douyu.com/"},
)
data = payload.get("data") or {}
game_account = data.get("gameAccount") or {}
game_role = data.get("gameRole") or {}
return {
"bind_account": data.get("bindAccount"),
"bind_role": data.get("bindRole"),
"bind_act": data.get("bindAct"),
"nickname": game_account.get("nick") or "",
"role_name": game_role.get("roleName") or "",
"area_name": game_role.get("areaName") or "",
"plat_name": game_role.get("platName") or "",
"change_role_wait_time": data.get("changeRoleWaitTime"),
"can_change_role": data.get("canChangeRole"),
"raw": payload,
}
def confirm_bind(self, act_alias: str) -> dict[str, Any]:
"""确认绑定当前扫码选择的游戏角色。"""
token = self.csrf_token()
payload = self._request_json(
"post",
self.BIND_API,
"确认绑定",
data={"actAlias": act_alias, "csrfToken": token},
headers={
"Content-Type": "application/x-www-form-urlencoded; charset=UTF-8",
"Referer": "https://www.douyu.com/",
},
)
if payload.get("error") not in (0, "0"):
raise DouyuActivityError(payload.get("msg") or "确认绑定失败")
return {"csrf_token": token, "raw": payload}
def create_elite_qr(self, *, ctn: str, act_alias: str, amount: int, room_id: str) -> dict[str, Any]:
"""生成精英宝典支付二维码。"""
item_payload = self._request_json(
"post",
self.PEACE_ITEM_API,
"校验精英宝典",
json={"itemId": 1, "ctn": ctn},
headers={"Content-Type": "application/json;charset=UTF-8"},
)
if item_payload.get("error") not in (0, "0"):
raise DouyuActivityError(item_payload.get("msg") or "精英宝典校验失败")
payload = self._request_json(
"post",
self.PAY_QR_API,
"生成精英宝典支付码",
json={
"bizType": "peace",
"biz": {"actAlias": act_alias, "amount": amount, "roomId": int(room_id or 0)},
"ctn": ctn,
},
headers={"Content-Type": "application/json;charset=UTF-8"},
)
qr = (payload.get("data") or {}).get("qr") or ""
if not qr:
raise DouyuActivityError(payload.get("msg") or "生成精英宝典支付码失败")
return {"pay_url": qr, "ctn": ctn, "item": item_payload, "raw": payload}
def create_gold_qr(self, *, amount: int, pay_type: int = 1, product_id: str = "DYTV_Product_Gold_1") -> dict[str, Any]:
"""生成鱼翅充值支付二维码。"""
payload = self._request_json(
"post",
self.GOLD_QR_API,
"生成鱼翅充值码",
data={
"pay_type": str(pay_type),
"product_id": product_id,
"number": str(amount),
"toname": "",
"source": "dy_box",
},
headers={
"Content-Type": "application/x-www-form-urlencoded; charset=UTF-8",
"Origin": "https://cz.douyu.com",
"Referer": "https://cz.douyu.com/",
},
)
data = payload.get("data") or {}
pay_url = data.get("code_url") or payload.get("code_url") or ""
token = data.get("token") or payload.get("token") or ""
if not pay_url:
raise DouyuActivityError(payload.get("msg") or "生成鱼翅充值码失败")
return {"pay_url": pay_url, "token": token, "raw": payload}
def donate_elite_gift(
self,
*,
gift_count: int,
room_id: str,
gift_id: str,
skin_id: str,
) -> dict[str, Any]:
"""赠送精英令礼物。"""
payload = self._request_json(
"post",
self.DONATE_API,
"赠送精英令",
data={
"giftId": gift_id,
"giftCount": str(gift_count),
"roomId": room_id,
"bizExt": json.dumps({"isMainGift": 1}, separators=(",", ":")),
"skinId": skin_id,
},
headers={
"Content-Type": "application/x-www-form-urlencoded; charset=UTF-8",
"Referer": f"https://www.douyu.com/{room_id}",
},
)
if payload.get("error") not in (0, "0") and payload.get("msg") != "success":
raise DouyuActivityError(payload.get("msg") or "赠送精英令失败")
return {"raw": payload}
def query_points(self, *, uid: str, ctn: str) -> dict[str, Any]:
"""查询手册积分余额。"""
payload = self._request_json(
"post",
self.CREDIT_BALANCE_API,
"查询手册积分",
json={"uid": str(uid), "ctn": ctn},
headers={"Content-Type": "application/json;charset=UTF-8"},
)
data = payload.get("data") or {}
return {"points": data.get("balance"), "raw": payload}
def query_points_diff(self, *, uid: str, ctn: str) -> dict[str, Any]:
"""查询手册积分差额。"""
payload = self._request_json(
"post",
self.CREDIT_DIFF_API,
"查询手册积分差额",
json={"userId": str(uid), "ctn": ctn},
headers={"Content-Type": "application/json;charset=UTF-8"},
)
data = payload.get("data") or {}
return {"diff": data.get("diff"), "raw": payload}
def list_goods(self, *, manual_id: str, rid: str) -> dict[str, Any]:
"""刷新活动商品列表。"""
payload = self._request_json(
"get",
self.GOODS_API,
"刷新商品列表",
params={"manualID": manual_id, "rid": rid},
headers={
"Origin": "",
"Referer": f"https://www.douyu.com/pages/live-peace-handbook/web/shop?ditchname=pass0&roomId={rid}",
},
)
goods = (payload.get("data") or {}).get("list") or []
return {"goods": goods, "raw": payload}
def query_limited_goods(self, *, manual_id: str, rid: str) -> dict[str, Any]:
"""查询当前账号限兑商品。"""
result = self.list_goods(manual_id=manual_id, rid=rid)
limited = [
item for item in result["goods"]
if str(item.get("status") or "") == "1"
]
return {"limited_goods": limited, "raw": result["raw"]}
def exchange_records(self, *, manual_id: str, limit: int = 100, offset: int = 0) -> dict[str, Any]:
"""查询兑换记录。"""
payload = self._request_json(
"get",
self.EXCHANGE_LIST_API,
"查询兑换记录",
params={"manualID": manual_id, "limit": limit, "offset": offset},
headers={"Origin": "", "Referer": "https://www.douyu.com/"},
)
records = (payload.get("data") or {}).get("list") or []
return {"records": records, "raw": payload}
def exchange_goods(self, *, manual_id: str, rid: str, commodity_id: str, ctn: str | None = None) -> dict[str, Any]:
"""兑换活动商品。"""
ctn_value = ctn or cookie_value(self.cookie, "acf_ccn") or self.acf_ccn(refresh_subscribe=False)
token = self.csrf_token()
randstr = "".join(random.choices(string.ascii_letters + string.digits, k=16))
data = {
"manualID": manual_id,
"rid": rid,
"commodityID": commodity_id,
"ctn": ctn_value,
"csrfToken": token,
"randstr": randstr,
"token[error][code]": "-1",
"token[data]": "",
}
payload = self._request_json(
"post",
self.EXCHANGE_API,
"兑换商品",
data=data,
headers={
"Content-Type": "application/x-www-form-urlencoded; charset=UTF-8",
"Referer": f"https://www.douyu.com/pages/live-peace-handbook/web/shop?ditchname=pass0&roomId={rid}",
"sec-ch-ua": f'"Not_A Brand";v="8", "Chromium";v="120", "Google Chrome";v="120"',
"sec-ch-ua-mobile": "?0",
"sec-ch-ua-platform": '"Windows"',
"Sec-Fetch-Site": "same-origin",
"Sec-Fetch-Mode": "cors",
"Sec-Fetch-Dest": "empty",
},
)
if payload.get("error") not in (0, "0"):
raise DouyuActivityError(payload.get("msg") or "兑换商品失败")
return {
"commodity_id": commodity_id,
"ctn": ctn_value,
"csrf_token": token,
"randstr": randstr,
"raw": payload,
}
def gold_account(self) -> dict[str, Any]:
"""查询 cz 侧鱼翅余额。"""
payload = self._request_json(
"get",
self.GOLD_ACCOUNT_API,
"查询鱼翅余额",
headers={"Origin": "https://cz.douyu.com", "Referer": "https://cz.douyu.com/"},
)
data = payload.get("data") or {}
return {"gold": data.get("gold"), "raw": payload}
def exchange_balance(self) -> dict[str, Any]:
"""查询钱包兑换中心余额。"""
payload = self._request_json(
"get",
self.EXCHANGE_BALANCE_API,
"查询鱼刺余额",
params={"appCode": "YJTX"},
headers={
"Origin": "",
"Referer": "https://www.douyu.com/member/walletcenter",
},
)
data = payload.get("data") or {}
return {"count": data.get("count"), "raw": payload}
+61
View File
@@ -0,0 +1,61 @@
"""斗鱼 Cookie 规范化工具。"""
from __future__ import annotations
from collections.abc import Iterable, Mapping
import requests
def cookie_pairs(cookie: str) -> list[tuple[str, str]]:
"""把浏览器 Cookie 字符串拆成 key/value 对。"""
pairs: list[tuple[str, str]] = []
for part in (cookie or "").split(";"):
item = part.strip()
if not item or "=" not in item:
continue
key, value = item.split("=", 1)
key = key.strip()
if key:
pairs.append((key, value.strip()))
return pairs
def normalize_cookie_pairs(pairs: Iterable[tuple[str, str]]) -> str:
"""按 Cookie key 去重,保留最后一次出现的值。"""
ordered_keys: list[str] = []
values: dict[str, str] = {}
for raw_key, raw_value in pairs:
key = str(raw_key).strip()
if not key:
continue
if key not in values:
ordered_keys.append(key)
values[key] = "" if raw_value is None else str(raw_value).strip()
return "; ".join(f"{key}={values[key]}" for key in ordered_keys)
def normalize_douyu_cookie(
cookie: str | Mapping[str, str] | requests.cookies.RequestsCookieJar | None,
) -> str:
"""把 Cookie 字符串、dict 或 CookieJar 转成去重后的浏览器 Cookie 字符串。"""
if not cookie:
return ""
if isinstance(cookie, str):
return normalize_cookie_pairs(cookie_pairs(cookie))
return normalize_cookie_pairs(cookie.items())
def cookie_value(
cookie: str | Mapping[str, str] | requests.cookies.RequestsCookieJar | None,
key: str,
) -> str:
"""从 Cookie 中读取 key;有重复时以最后一次出现为准。"""
if not cookie or not key:
return ""
pairs = cookie_pairs(cookie) if isinstance(cookie, str) else list(cookie.items())
value = ""
for item_key, item_value in pairs:
if item_key == key:
value = "" if item_value is None else str(item_value).strip()
return value
+6 -1
View File
@@ -11,7 +11,7 @@ from fastapi.responses import FileResponse
from starlette.middleware.base import BaseHTTPMiddleware from starlette.middleware.base import BaseHTTPMiddleware
from .database import init_db 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 .schemas import AppInfo
from .version import get_app_version from .version import get_app_version
from utils import setup_logger from utils import setup_logger
@@ -30,12 +30,16 @@ async def lifespan(app: FastAPI):
from .database import SessionLocal from .database import SessionLocal
from .services.huya_service import cleanup_orphan_huya_tasks from .services.huya_service import cleanup_orphan_huya_tasks
from .services.douyu_service import cleanup_orphan_douyu_tasks
db = SessionLocal() db = SessionLocal()
try: try:
cleaned = cleanup_orphan_huya_tasks(db, message="任务已中断(服务重启)") cleaned = cleanup_orphan_huya_tasks(db, message="任务已中断(服务重启)")
if cleaned: if cleaned:
logger.info(f"启动清理虎牙残留任务: {cleaned}") logger.info(f"启动清理虎牙残留任务: {cleaned}")
cleaned_douyu = cleanup_orphan_douyu_tasks(db, message="任务已中断(服务重启)")
if cleaned_douyu:
logger.info(f"启动清理斗鱼残留任务: {cleaned_douyu}")
finally: finally:
db.close() db.close()
yield yield
@@ -84,6 +88,7 @@ app.include_router(login.router)
app.include_router(proxy.router) app.include_router(proxy.router)
app.include_router(cookies.router) app.include_router(cookies.router)
app.include_router(huya.router) app.include_router(huya.router)
app.include_router(douyu.router)
@app.get("/api/health") @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)
+60
View File
@@ -55,10 +55,21 @@ class Account(Base):
assigned_to = Column(Integer, ForeignKey("users.id"), nullable=True, index=True) assigned_to = Column(Integer, ForeignKey("users.id"), nullable=True, index=True)
tag = Column(String(64), default="") tag = Column(String(64), default="")
remark = Column(String(256), 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) 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]) assigned_user = relationship("User", back_populates="assigned_accounts", foreign_keys=[assigned_to])
login_tasks = relationship("LoginTask", back_populates="account") login_tasks = relationship("LoginTask", back_populates="account")
douyu_tasks = relationship("DouyuTask", back_populates="account")
class LoginTask(Base): class LoginTask(Base):
@@ -78,6 +89,55 @@ class LoginTask(Base):
account = relationship("Account", back_populates="login_tasks") 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): class HuyaAccount(Base):
"""虎牙账号""" """虎牙账号"""
__tablename__ = "huya_accounts" __tablename__ = "huya_accounts"
+5
View File
@@ -23,6 +23,9 @@ PERMISSIONS = {
# Cookie # Cookie
"cookie:view": "查看 Cookie", "cookie:view": "查看 Cookie",
"cookie:export": "导出 Cookie", "cookie:export": "导出 Cookie",
# 斗鱼活动
"douyu:task": "斗鱼任务管理",
"douyu:config": "斗鱼配置管理",
# 虎牙 # 虎牙
"huya:account": "虎牙账号管理(兼容旧权限)", "huya:account": "虎牙账号管理(兼容旧权限)",
"huya:view_all": "查看所有虎牙账号", "huya:view_all": "查看所有虎牙账号",
@@ -57,6 +60,8 @@ ROLE_PERMISSIONS = {
"login:view_all", "login:view_all",
"cookie:view", "cookie:view",
"cookie:export", "cookie:export",
"douyu:task",
"douyu:config",
"huya:account", "huya:account",
"huya:view_all", "huya:view_all",
"huya:import", "huya:import",
+324
View File
@@ -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)
+128
View File
@@ -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): class ProxyConfigOut(BaseModel):
enabled: bool = False enabled: bool = False
+490
View File
@@ -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()
+195
View File
@@ -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)
+2
View File
@@ -17,6 +17,7 @@ const LoginTasksPage = lazy(() => import('./pages/LoginTasksPage'));
const ProxyPage = lazy(() => import('./pages/ProxyPage')); const ProxyPage = lazy(() => import('./pages/ProxyPage'));
const UsersPage = lazy(() => import('./pages/UsersPage')); const UsersPage = lazy(() => import('./pages/UsersPage'));
const CookiePage = lazy(() => import('./pages/CookiePage')); const CookiePage = lazy(() => import('./pages/CookiePage'));
const DouyuTasksPage = lazy(() => import('./pages/DouyuTasksPage'));
const HuyaAccountsPage = lazy(() => import('./pages/HuyaAccountsPage')); const HuyaAccountsPage = lazy(() => import('./pages/HuyaAccountsPage'));
const HuyaAssignmentsPage = lazy(() => import('./pages/HuyaAssignmentsPage')); const HuyaAssignmentsPage = lazy(() => import('./pages/HuyaAssignmentsPage'));
const HuyaCookiePage = lazy(() => import('./pages/HuyaCookiePage')); const HuyaCookiePage = lazy(() => import('./pages/HuyaCookiePage'));
@@ -71,6 +72,7 @@ function AppContent() {
<Route path="assignments" element={lazyRoute(<AssignmentsPage />)} /> <Route path="assignments" element={lazyRoute(<AssignmentsPage />)} />
<Route path="login-tasks" element={lazyRoute(<LoginTasksPage />)} /> <Route path="login-tasks" element={lazyRoute(<LoginTasksPage />)} />
<Route path="cookies" element={lazyRoute(<CookiePage />)} /> <Route path="cookies" element={lazyRoute(<CookiePage />)} />
<Route path="douyu/tasks" element={lazyRoute(<DouyuTasksPage />)} />
<Route path="huya/accounts" element={lazyRoute(<HuyaAccountsPage />)} /> <Route path="huya/accounts" element={lazyRoute(<HuyaAccountsPage />)} />
<Route path="huya/register" element={lazyRoute(<HuyaRegisterPage />)} /> <Route path="huya/register" element={lazyRoute(<HuyaRegisterPage />)} />
<Route path="huya/assignments" element={lazyRoute(<HuyaAssignmentsPage />)} /> <Route path="huya/assignments" element={lazyRoute(<HuyaAssignmentsPage />)} />
+25
View File
@@ -0,0 +1,25 @@
import api from './client';
import type {
DouyuConfig,
DouyuGoodsItem,
DouyuTaskAccountItem,
DouyuTaskBatchRequest,
DouyuTaskBatchResult,
DouyuTaskItem,
MessageResponse,
} from './types';
export const douyuApi = {
taskTypes: () => api.get<Record<string, string>, Record<string, string>>('/douyu/task-types'),
listAccounts: (params?: { search?: string }) =>
api.get<DouyuTaskAccountItem[], DouyuTaskAccountItem[]>('/douyu/accounts', { params }),
getConfig: () => api.get<DouyuConfig, DouyuConfig>('/douyu/config'),
updateConfig: (data: Partial<DouyuConfig>) => api.put<DouyuConfig, DouyuConfig>('/douyu/config', data),
listGoods: () => api.get<DouyuGoodsItem[], DouyuGoodsItem[]>('/douyu/goods'),
createTasks: (data: DouyuTaskBatchRequest) =>
api.post<DouyuTaskBatchResult, DouyuTaskBatchResult>('/douyu/tasks/batch', data),
listTasks: (batchId?: string) =>
api.get<DouyuTaskItem[], DouyuTaskItem[]>('/douyu/tasks', { params: batchId ? { batch_id: batchId } : {} }),
getTask: (taskId: number) => api.get<DouyuTaskItem, DouyuTaskItem>(`/douyu/tasks/${taskId}`),
stopBatch: (batchId: string) => api.post<MessageResponse, MessageResponse>(`/douyu/stop/${batchId}`),
};
+1
View File
@@ -4,6 +4,7 @@ export { accountApi } from './accounts';
export { appApi } from './app'; export { appApi } from './app';
export { authApi } from './auth'; export { authApi } from './auth';
export { cookieApi } from './cookies'; export { cookieApi } from './cookies';
export { douyuApi } from './douyu';
export { huyaApi } from './huya'; export { huyaApi } from './huya';
export { loginApi } from './login'; export { loginApi } from './login';
export { proxyApi } from './proxy'; export { proxyApi } from './proxy';
+72
View File
@@ -168,6 +168,78 @@ export interface CookieItem {
account_password: string; account_password: string;
} }
// ==================== Douyu Activity ====================
export interface DouyuTaskAccountItem {
id: number;
username: string;
uid: string;
nickname: string;
tag: string;
points: number | null;
game_name: string;
game_channel: string;
gold_balance: number | null;
exchange_balance: number | null;
bind_status: string;
change_role_wait_time: number | null;
assigned_to: number | null;
assigned_username: string | null;
}
export interface DouyuConfig {
manual_id: string;
rid: string;
bind_act_alias: string;
confirm_act_alias: string;
legacy_act_alias: string;
room_id: string;
elite_amount: number;
gold_pay_type: number;
gift_id: string;
skin_id: string;
updated_at: string | null;
}
export interface DouyuTaskBatchRequest {
account_ids: number[];
task_type: string;
concurrency?: number;
payload?: Record<string, unknown>;
}
export interface DouyuTaskBatchResult {
batch_id: string;
count: number;
success: boolean;
}
export interface DouyuTaskItem {
id: number;
batch_id: string;
account_id: number;
account_username: string;
account_uid: string;
account_nickname: string;
task_type: string;
status: string;
message: string;
result: Record<string, unknown> | null;
created_by: number;
created_at: string | null;
finished_at: string | null;
}
export interface DouyuGoodsItem {
id: number;
commodity_id: string;
name: string;
score: number | null;
status: string;
raw: Record<string, unknown> | null;
updated_at: string | null;
}
// ==================== Huya ==================== // ==================== Huya ====================
export interface HuyaAccountItem { export interface HuyaAccountItem {
+3
View File
@@ -70,6 +70,9 @@ export default function MainLayout({ onLogout }: { onLogout?: () => void }) {
if (can('cookie:view')) { if (can('cookie:view')) {
douyuItems.push({ key: '/cookies', label: 'Cookie 管理', icon: <KeyOutlined /> }); douyuItems.push({ key: '/cookies', label: 'Cookie 管理', icon: <KeyOutlined /> });
} }
if (can('douyu:task')) {
douyuItems.push({ key: '/douyu/tasks', label: '任务操作台', icon: <ShoppingCartOutlined /> });
}
// 虎牙 // 虎牙
if (canAny(['huya:account', 'huya:view_all', 'huya:view_assigned'])) { if (canAny(['huya:account', 'huya:view_all', 'huya:view_assigned'])) {
+516
View File
@@ -0,0 +1,516 @@
import { useCallback, useEffect, useMemo, useState } from 'react';
import {
Button, Card, Col, Form, Input, InputNumber, Modal, QRCode, Row, Select, Space, Table, Tag, Typography, theme,
} from 'antd';
import type { TableProps } from 'antd';
import {
CheckCircleOutlined, CreditCardOutlined, FieldTimeOutlined, GiftOutlined, LinkOutlined,
QrcodeOutlined, ReloadOutlined, SearchOutlined, SettingOutlined, ShoppingOutlined, StopOutlined,
} from '@ant-design/icons';
import {
douyuApi,
type DouyuConfig,
type DouyuGoodsItem,
type DouyuTaskAccountItem,
type DouyuTaskItem,
} from '../api/modules';
import RealtimeLogPanel from '../components/RealtimeLogPanel';
import { useWebSocketLogs } from '../hooks/useWebSocketLogs';
import { formatTime } from '../utils/time';
import { getErrorMessage } from '../utils/error';
import { message } from '../utils/antdMessage';
const { Text } = Typography;
const FALLBACK_TASK_TYPES: Record<string, string> = {
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',
};
const TASK_ICONS: Record<string, React.ReactNode> = {
get_bind_qr: <QrcodeOutlined />,
confirm_bind: <CheckCircleOutlined />,
create_elite_qr: <CreditCardOutlined />,
create_gold_qr: <CreditCardOutlined />,
donate_elite_gift: <GiftOutlined />,
query_points: <SearchOutlined />,
exchange_goods: <ShoppingOutlined />,
query_game_name: <SearchOutlined />,
query_change_bind_time: <FieldTimeOutlined />,
query_limited_goods: <SearchOutlined />,
query_gold_balance: <SearchOutlined />,
refresh_goods: <ReloadOutlined />,
query_exchange_records: <FieldTimeOutlined />,
prefetch_csrf_token: <LinkOutlined />,
};
const STATUS_COLORS: Record<string, string> = {
planned: 'default',
pending: 'default',
running: 'processing',
success: 'success',
failed: 'error',
error: 'error',
stopped: 'warning',
};
const STATUS_LABELS: Record<string, string> = {
planned: '已计划',
pending: '等待中',
running: '执行中',
success: '成功',
failed: '失败',
error: '异常',
stopped: '已停止',
};
function accountLabel(account: DouyuTaskAccountItem): string {
const name = account.nickname || account.username || account.uid || `#${account.id}`;
const tag = account.tag ? ` [${account.tag}]` : '';
const game = account.game_name ? ` / ${account.game_name}` : '';
return `${name}${tag}${game}`;
}
function resultText(result: Record<string, unknown> | null | undefined, key: string): string {
const value = result?.[key];
return typeof value === 'string' ? value : '';
}
function resultNumber(result: Record<string, unknown> | null | undefined, key: string): number | null {
const value = result?.[key];
if (typeof value === 'number') return value;
if (typeof value === 'string' && value.trim()) return Number(value) || null;
return null;
}
function taskPayUrl(task: DouyuTaskItem | null): string {
return resultText(task?.result, 'pay_url') || resultText(task?.result, 'url');
}
function goodsLabel(item: DouyuGoodsItem): string {
return `${item.name || item.commodity_id}${item.score ? ` / ${item.score}积分` : ''}`;
}
const defaultConfig: DouyuConfig = {
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',
updated_at: null,
};
export default function DouyuTasksPage() {
const { token } = theme.useToken();
const [accounts, setAccounts] = useState<DouyuTaskAccountItem[]>([]);
const [goods, setGoods] = useState<DouyuGoodsItem[]>([]);
const [tasks, setTasks] = useState<DouyuTaskItem[]>([]);
const [taskTypes, setTaskTypes] = useState<Record<string, string>>(FALLBACK_TASK_TYPES);
const [config, setConfig] = useState<DouyuConfig>(defaultConfig);
const [selectedAccountIds, setSelectedAccountIds] = useState<number[]>([]);
const [selectedGoodsId, setSelectedGoodsId] = useState('');
const [concurrency, setConcurrency] = useState(3);
const [goldAmount, setGoldAmount] = useState(1);
const [giftCount, setGiftCount] = useState(1);
const [loading, setLoading] = useState(false);
const [runningBatchId, setRunningBatchId] = useState('');
const [configOpen, setConfigOpen] = useState(false);
const [payTask, setPayTask] = useState<DouyuTaskItem | null>(null);
const [configForm] = Form.useForm<DouyuConfig>();
const logs = useWebSocketLogs();
const loadData = useCallback(async () => {
setLoading(true);
try {
const [accountResult, goodsResult, taskResult, configResult, taskTypeResult] = await Promise.allSettled([
douyuApi.listAccounts(),
douyuApi.listGoods(),
douyuApi.listTasks(),
douyuApi.getConfig(),
douyuApi.taskTypes(),
]);
if (accountResult.status === 'fulfilled') setAccounts(accountResult.value);
if (goodsResult.status === 'fulfilled') setGoods(goodsResult.value);
if (taskResult.status === 'fulfilled') setTasks(taskResult.value);
if (configResult.status === 'fulfilled') setConfig(configResult.value);
if (taskTypeResult.status === 'fulfilled') setTaskTypes(taskTypeResult.value);
const errors = [
accountResult.status === 'rejected' ? `账号: ${getErrorMessage(accountResult.reason)}` : '',
goodsResult.status === 'rejected' ? `商品: ${getErrorMessage(goodsResult.reason)}` : '',
taskResult.status === 'rejected' ? `任务: ${getErrorMessage(taskResult.reason)}` : '',
configResult.status === 'rejected' ? `配置: ${getErrorMessage(configResult.reason)}` : '',
].filter(Boolean);
if (errors.length) message.warning(errors.join(''));
} finally {
setLoading(false);
}
}, []);
useEffect(() => {
loadData();
}, [loadData]);
useEffect(() => {
if (configOpen) configForm.setFieldsValue(config);
}, [configOpen, config, configForm]);
const activeTask = tasks.find((item) => ['planned', 'pending', 'running'].includes(item.status));
const goodsOptions = useMemo(
() => goods.map((item) => ({ value: item.commodity_id, label: goodsLabel(item) })),
[goods],
);
const startTask = async (taskType: string) => {
if (selectedAccountIds.length === 0) {
message.warning('请先选择斗鱼账号');
return;
}
if (taskType === 'exchange_goods' && !selectedGoodsId) {
message.warning('请先选择兑换商品');
return;
}
const payload: Record<string, unknown> = {};
if (taskType === 'exchange_goods') {
payload.commodity_id = selectedGoodsId;
}
if (taskType === 'create_gold_qr') {
payload.amount = goldAmount;
}
if (taskType === 'donate_elite_gift') {
payload.gift_count = giftCount;
}
try {
const result = await douyuApi.createTasks({
account_ids: selectedAccountIds,
task_type: taskType,
concurrency,
payload,
});
setRunningBatchId(result.batch_id);
logs.connect(`/api/douyu/ws/${result.batch_id}`, {
clear: true,
onResult: () => {
setRunningBatchId('');
loadData();
},
});
message.success(`已创建 ${result.count} 个任务`);
setTimeout(loadData, 500);
} catch (error) {
message.error(getErrorMessage(error));
}
};
const stopTask = async () => {
if (!runningBatchId) return;
try {
await douyuApi.stopBatch(runningBatchId);
message.success('已发送停止信号');
} catch (error) {
message.error(getErrorMessage(error));
}
};
const saveConfig = async () => {
try {
const values = await configForm.validateFields();
const saved = await douyuApi.updateConfig(values);
setConfig(saved);
setConfigOpen(false);
message.success('配置已保存');
} catch (error) {
message.error(getErrorMessage(error));
}
};
const openPayTask = (task: DouyuTaskItem) => {
const url = taskPayUrl(task);
if (!url) {
message.warning('任务结果中没有二维码链接');
return;
}
setPayTask(task);
};
const accountColumns: TableProps<DouyuTaskAccountItem>['columns'] = [
{
title: '账号',
dataIndex: 'username',
width: 190,
render: (_, record) => (
<Space direction="vertical" size={0}>
<Text strong>{record.nickname || record.username}</Text>
<Text type="secondary" style={{ fontSize: 12 }}>UID {record.uid || '-'}</Text>
</Space>
),
},
{
title: '游戏名',
dataIndex: 'game_name',
width: 180,
render: (_, record) => record.game_name ? (
<Space direction="vertical" size={0}>
<Text>{record.game_name}</Text>
<Text type="secondary" style={{ fontSize: 12 }}>{record.game_channel || '-'}</Text>
</Space>
) : <Text type="secondary"></Text>,
},
{
title: '积分',
dataIndex: 'points',
width: 90,
render: (value) => value ?? <Text type="secondary">-</Text>,
sorter: (a, b) => (a.points ?? -1) - (b.points ?? -1),
},
{
title: '鱼翅',
dataIndex: 'gold_balance',
width: 90,
render: (value) => value ?? <Text type="secondary">-</Text>,
},
{
title: '状态',
dataIndex: 'bind_status',
width: 120,
render: (value) => value ? <Tag>{value}</Tag> : <Text type="secondary">-</Text>,
},
];
const taskColumns: TableProps<DouyuTaskItem>['columns'] = [
{
title: '账号',
dataIndex: 'account_username',
width: 150,
render: (_, record) => record.account_nickname || record.account_username || record.account_uid,
},
{
title: '任务',
dataIndex: 'task_type',
width: 170,
render: (value) => taskTypes[value] || value,
},
{
title: '状态',
dataIndex: 'status',
width: 90,
render: (value) => <Tag color={STATUS_COLORS[value] || 'default'}>{STATUS_LABELS[value] || value}</Tag>,
},
{
title: '结果',
dataIndex: 'message',
ellipsis: true,
render: (_, record) => {
const payUrl = taskPayUrl(record);
const points = resultNumber(record.result, 'points');
return (
<Space wrap>
<Text>{record.message || '-'}</Text>
{typeof points === 'number' && <Tag color="blue"> {points}</Tag>}
{payUrl && (
<Button size="small" icon={<QrcodeOutlined />} onClick={() => openPayTask(record)}>
</Button>
)}
</Space>
);
},
},
{
title: '时间',
dataIndex: 'finished_at',
width: 170,
render: (_, record) => formatTime(record.finished_at || record.created_at),
},
];
return (
<div style={{ display: 'flex', flexDirection: 'column', gap: 12 }}>
<Space style={{ justifyContent: 'space-between', width: '100%' }}>
<div>
<h2 style={{ margin: 0 }}></h2>
<Text type="secondary"></Text>
</div>
<Space>
<Button icon={<ReloadOutlined />} onClick={loadData} loading={loading}></Button>
<Button icon={<SettingOutlined />} onClick={() => setConfigOpen(true)}></Button>
{runningBatchId && (
<Button danger icon={<StopOutlined />} onClick={stopTask}></Button>
)}
</Space>
</Space>
<Row gutter={[12, 12]}>
<Col xs={24} xl={15}>
<Card
size="small"
title="账号"
extra={<Tag color="blue"> {selectedAccountIds.length}</Tag>}
>
<Table
rowKey="id"
size="small"
loading={loading}
rowSelection={{
selectedRowKeys: selectedAccountIds,
onChange: (keys) => setSelectedAccountIds(keys.map(Number)),
}}
columns={accountColumns}
dataSource={accounts}
pagination={{ pageSize: 8, showSizeChanger: false }}
scroll={{ x: 700 }}
/>
</Card>
</Col>
<Col xs={24} xl={9}>
<Card size="small" title="操作">
<Space direction="vertical" style={{ width: '100%' }} size={10}>
<Select
mode="multiple"
value={selectedAccountIds}
onChange={setSelectedAccountIds}
options={accounts.map((item) => ({ value: item.id, label: accountLabel(item) }))}
placeholder="选择账号"
maxTagCount="responsive"
style={{ width: '100%' }}
/>
<Space.Compact style={{ width: '100%' }}>
<InputNumber min={1} max={10} value={concurrency} onChange={(value) => setConcurrency(value || 1)} style={{ width: 110 }} />
<Input value="并发数" disabled />
</Space.Compact>
<Row gutter={[8, 8]}>
{[
'get_bind_qr',
'confirm_bind',
'create_elite_qr',
'query_points',
'query_game_name',
'query_change_bind_time',
'query_limited_goods',
'query_gold_balance',
].map((key) => (
<Col span={12} key={key}>
<Button block icon={TASK_ICONS[key]} onClick={() => startTask(key)} disabled={!!activeTask}>
{taskTypes[key] || key}
</Button>
</Col>
))}
</Row>
<Card size="small" title="兑换">
<Space direction="vertical" style={{ width: '100%' }}>
<Select
value={selectedGoodsId || undefined}
onChange={setSelectedGoodsId}
options={goodsOptions}
placeholder="选择兑换商品"
showSearch
optionFilterProp="label"
style={{ width: '100%' }}
/>
<Space wrap>
<Button icon={<ReloadOutlined />} onClick={() => startTask('refresh_goods')} disabled={!!activeTask}>
</Button>
<Button type="primary" icon={<ShoppingOutlined />} onClick={() => startTask('exchange_goods')} disabled={!!activeTask}>
</Button>
</Space>
</Space>
</Card>
<Card size="small" title="充值与送礼">
<Space direction="vertical" style={{ width: '100%' }}>
<Space.Compact style={{ width: '100%' }}>
<InputNumber min={1} value={goldAmount} onChange={(value) => setGoldAmount(value || 1)} style={{ width: 120 }} />
<Button icon={<CreditCardOutlined />} onClick={() => startTask('create_gold_qr')} disabled={!!activeTask}>
</Button>
</Space.Compact>
<Space.Compact style={{ width: '100%' }}>
<InputNumber min={1} value={giftCount} onChange={(value) => setGiftCount(value || 1)} style={{ width: 120 }} />
<Button icon={<GiftOutlined />} onClick={() => startTask('donate_elite_gift')} disabled={!!activeTask}>
</Button>
</Space.Compact>
</Space>
</Card>
<RealtimeLogPanel logs={logs.logs} connected={logs.connected} height={180} />
</Space>
</Card>
</Col>
</Row>
<Card size="small" title="任务记录">
<Table
rowKey="id"
size="small"
columns={taskColumns}
dataSource={tasks}
loading={loading}
pagination={{ pageSize: 10, showSizeChanger: false }}
scroll={{ x: 900 }}
/>
</Card>
<Modal
title="斗鱼活动配置"
open={configOpen}
onCancel={() => setConfigOpen(false)}
onOk={saveConfig}
okText="保存"
cancelText="取消"
width={720}
>
<Form form={configForm} layout="vertical" initialValues={config}>
<Row gutter={12}>
<Col span={12}><Form.Item label="manualID" name="manual_id"><Input /></Form.Item></Col>
<Col span={12}><Form.Item label="RID" name="rid"><Input /></Form.Item></Col>
<Col span={12}><Form.Item label="绑定二维码活动" name="bind_act_alias"><Input /></Form.Item></Col>
<Col span={12}><Form.Item label="确认绑定活动" name="confirm_act_alias"><Input /></Form.Item></Col>
<Col span={12}><Form.Item label="旧版查询活动" name="legacy_act_alias"><Input /></Form.Item></Col>
<Col span={12}><Form.Item label="房间 ID" name="room_id"><Input /></Form.Item></Col>
<Col span={12}><Form.Item label="宝典金额(分)" name="elite_amount"><InputNumber min={1} style={{ width: '100%' }} /></Form.Item></Col>
<Col span={12}><Form.Item label="鱼翅支付方式" name="gold_pay_type"><InputNumber min={1} style={{ width: '100%' }} /></Form.Item></Col>
<Col span={12}><Form.Item label="精英令礼物 ID" name="gift_id"><Input /></Form.Item></Col>
<Col span={12}><Form.Item label="皮肤 ID" name="skin_id"><Input /></Form.Item></Col>
</Row>
</Form>
</Modal>
<Modal
title="二维码"
open={!!payTask}
onCancel={() => setPayTask(null)}
footer={null}
centered
>
{payTask && (
<Space direction="vertical" align="center" style={{ width: '100%' }}>
<Text strong>{taskTypes[payTask.task_type] || payTask.task_type}</Text>
<QRCode value={taskPayUrl(payTask)} size={260} />
<Text copyable style={{ maxWidth: '100%', color: token.colorTextSecondary }}>
{taskPayUrl(payTask)}
</Text>
</Space>
)}
</Modal>
</div>
);
}