新增斗鱼活动任务模块:绑定、宝典、鱼翅、积分、兑换等功能
- 新增 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:
@@ -5,6 +5,7 @@ from .login_api import LoginAPIStrategy
|
||||
from .login_api_wgapi import WgapiLoginAPI
|
||||
from .login_api_iframe import IframeLoginAPI
|
||||
from .email_verifier import EmailVerifier
|
||||
from .activity_client import DouyuActivityClient, DouyuActivityError
|
||||
|
||||
__all__ = [
|
||||
"DouyuLogin",
|
||||
@@ -13,4 +14,6 @@ __all__ = [
|
||||
"WgapiLoginAPI",
|
||||
"IframeLoginAPI",
|
||||
"EmailVerifier",
|
||||
"DouyuActivityClient",
|
||||
"DouyuActivityError",
|
||||
]
|
||||
|
||||
@@ -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}
|
||||
@@ -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
@@ -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)
|
||||
@@ -17,6 +17,7 @@ const LoginTasksPage = lazy(() => import('./pages/LoginTasksPage'));
|
||||
const ProxyPage = lazy(() => import('./pages/ProxyPage'));
|
||||
const UsersPage = lazy(() => import('./pages/UsersPage'));
|
||||
const CookiePage = lazy(() => import('./pages/CookiePage'));
|
||||
const DouyuTasksPage = lazy(() => import('./pages/DouyuTasksPage'));
|
||||
const HuyaAccountsPage = lazy(() => import('./pages/HuyaAccountsPage'));
|
||||
const HuyaAssignmentsPage = lazy(() => import('./pages/HuyaAssignmentsPage'));
|
||||
const HuyaCookiePage = lazy(() => import('./pages/HuyaCookiePage'));
|
||||
@@ -71,6 +72,7 @@ function AppContent() {
|
||||
<Route path="assignments" element={lazyRoute(<AssignmentsPage />)} />
|
||||
<Route path="login-tasks" element={lazyRoute(<LoginTasksPage />)} />
|
||||
<Route path="cookies" element={lazyRoute(<CookiePage />)} />
|
||||
<Route path="douyu/tasks" element={lazyRoute(<DouyuTasksPage />)} />
|
||||
<Route path="huya/accounts" element={lazyRoute(<HuyaAccountsPage />)} />
|
||||
<Route path="huya/register" element={lazyRoute(<HuyaRegisterPage />)} />
|
||||
<Route path="huya/assignments" element={lazyRoute(<HuyaAssignmentsPage />)} />
|
||||
|
||||
@@ -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}`),
|
||||
};
|
||||
@@ -4,6 +4,7 @@ export { accountApi } from './accounts';
|
||||
export { appApi } from './app';
|
||||
export { authApi } from './auth';
|
||||
export { cookieApi } from './cookies';
|
||||
export { douyuApi } from './douyu';
|
||||
export { huyaApi } from './huya';
|
||||
export { loginApi } from './login';
|
||||
export { proxyApi } from './proxy';
|
||||
|
||||
@@ -168,6 +168,78 @@ export interface CookieItem {
|
||||
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 ====================
|
||||
|
||||
export interface HuyaAccountItem {
|
||||
|
||||
@@ -70,6 +70,9 @@ export default function MainLayout({ onLogout }: { onLogout?: () => void }) {
|
||||
if (can('cookie:view')) {
|
||||
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'])) {
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user