feat(douyu): support elite handbook lock orders

This commit is contained in:
yml2213
2026-08-28 14:06:11 +08:00
parent 49aae8b74c
commit 9ee4e49a50
8 changed files with 471 additions and 59 deletions
+127 -19
View File
@@ -2686,6 +2686,99 @@ class DouyuBatchRunner:
points = result["points"]
self._mark_task(db, task, "success", f"积分: {points if points is not None else '-'}", result)
def _execute_lock_goods(self, db: Session, task: DouyuTask, account: Account, cookie: str, config: dict):
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
try:
num = int(payload.get("num") or 1)
except (TypeError, ValueError):
num = 1
client = self._client(cookie)
result = client.create_exchange_order(
manual_id=str(config["manual_id"]),
rid=str(config["rid"]),
commodity_id=commodity_id,
num=max(1, num),
)
goods = (
db.query(DouyuGoodsSnapshot)
.filter(DouyuGoodsSnapshot.commodity_id == commodity_id)
.first()
)
account.bind_status = "goods_locked"
account.updated_at = datetime.now(timezone.utc)
expire_seconds = result.get("expire_seconds")
expire_text = f"{expire_seconds} 秒内有效" if expire_seconds else ""
self._mark_task(
db,
task,
"success",
f"锁单成功: {(goods.name if goods else '') or commodity_id}(订单 {result['order_id']}{expire_text}",
{
"goods": goods.raw if goods else None,
"game_name": account.game_name or "",
"game_channel": account.game_channel or "",
"account_name": account.nickname or account.username or account.uid or f"#{account.id}",
**result,
},
)
def _execute_pay_locked_order(
self,
db: Session,
task: DouyuTask,
account: Account,
cookie: str,
config: dict,
):
payload = self._task_payload(task)
order_id = str(payload.get("order_id") or payload.get("orderId") or "").strip()
commodity_id = str(payload.get("commodity_id") or payload.get("commodityId") or "").strip()
if not order_id:
self._mark_task(db, task, "failed", "锁单订单号不能为空")
return
client = self._client(cookie)
payment = client.pay_exchange_order(
manual_id=str(config["manual_id"]),
order_id=order_id,
)
goods = None
if commodity_id:
goods = (
db.query(DouyuGoodsSnapshot)
.filter(DouyuGoodsSnapshot.commodity_id == commodity_id)
.first()
)
account.bind_status = "goods_exchanged"
account.updated_at = datetime.now(timezone.utc)
result = {
"commodity_id": commodity_id,
"order_id": order_id,
"exchange_id": payment["exchange_id"],
"commodity_image": payment["commodity_image"],
"exchange_num": payment["exchange_num"],
"payment": payment,
"goods": goods.raw if goods else None,
"game_name": account.game_name or "",
"game_channel": account.game_channel or "",
"account_name": account.nickname or account.username or account.uid or f"#{account.id}",
}
try:
ctn = client.acf_ccn(refresh_subscribe=False)
result.update(self._refresh_account_points(client, account, cookie, ctn=ctn))
except Exception as exc:
self._push_log("warning", f"支付锁单后刷新积分失败: {exc}")
self._mark_task(
db,
task,
"success",
f"锁单支付成功: {(goods.name if goods else '') or commodity_id or order_id}",
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)
@@ -2694,40 +2787,52 @@ class DouyuBatchRunner:
self._mark_task(db, task, "failed", "请选择兑换商品")
return
client = self._client(cookie)
ctn = client.acf_ccn(refresh_subscribe=False)
result = None
locked = client.create_exchange_order(
manual_id=str(config["manual_id"]),
rid=str(config["rid"]),
commodity_id=commodity_id,
num=1,
)
payment = None
last_error = ""
for attempt in range(8 + 1):
if self._stop.is_set():
self._mark_task(db, task, "stopped", "任务已停止")
self._mark_task(db, task, "stopped", "任务已停止,商品锁单仍可能有效", locked)
return
try:
result = client.exchange_goods(
payment = client.pay_exchange_order(
manual_id=str(config["manual_id"]),
rid=str(config["rid"]),
commodity_id=commodity_id,
ctn=ctn,
order_id=locked["order_id"],
)
break
except DouyuActivityError as exc:
last_error = str(exc)
if attempt >= 8:
self._mark_task(db, task, "failed", f"兑换失败(已重试{attempt}次): {last_error}")
self._mark_task(
db,
task,
"failed",
f"锁单 {locked['order_id']} 支付失败(已重试{attempt}次): {last_error}",
{"commodity_id": commodity_id, "lock_order": locked, **locked},
)
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}")
self._push_log(
"info",
f" 锁单 {locked['order_id']} 支付重试 {attempt + 1}/8: {last_error}",
)
time_mod.sleep(0.3)
if result is None:
if payment is None:
self._mark_task(db, task, "failed", f"兑换失败: {last_error}")
return
result = {
"commodity_id": commodity_id,
"order_id": locked["order_id"],
"exchange_id": payment["exchange_id"],
"commodity_image": payment["commodity_image"] or locked["commodity_image"],
"exchange_num": payment["exchange_num"],
"lock_order": locked,
"payment": payment,
}
goods = (
db.query(DouyuGoodsSnapshot)
.filter(DouyuGoodsSnapshot.commodity_id == commodity_id)
@@ -2738,6 +2843,7 @@ class DouyuBatchRunner:
# 兑换成功后自动刷新积分,更新账号最新积分信息(失败不阻断兑换成功)
points_refresh = None
try:
ctn = client.acf_ccn(refresh_subscribe=False)
points_refresh = self._refresh_account_points(client, account, cookie, ctn=ctn)
except Exception as exc:
self._push_log("warning", f"兑换后刷新积分失败: {exc}")
@@ -3077,6 +3183,8 @@ class DouyuBatchRunner:
"create_gold_qr": self._execute_create_gold_qr,
"donate_elite_gift": self._execute_donate_elite_gift,
"query_points": self._execute_query_points,
"lock_goods": self._execute_lock_goods,
"pay_locked_order": self._execute_pay_locked_order,
"exchange_goods": self._execute_exchange_goods,
"exchange_esports_goods": self._execute_exchange_esports_goods,
"query_game_name": self._execute_query_game_name,
+3 -1
View File
@@ -29,6 +29,8 @@ SUPPORTED_DOUYU_TASK_TYPES = { "get_bind_qr": "获取绑定二维码",
"create_gold_qr": "充值鱼翅",
"donate_elite_gift": "赠送精英令",
"query_points": "一键查询积分",
"lock_goods": "锁定商品",
"pay_locked_order": "支付锁单",
"exchange_goods": "兑换商品",
"query_game_name": "一键获取游戏名",
"query_change_bind_time": "一键查询换绑时间",
@@ -52,7 +54,7 @@ DOUYU_HANDBOOK_SCOPES = {"elite", "esports", "peace"}
DOUYU_HANDBOOK_TASK_TYPES = {
"elite": {
"get_bind_qr", "confirm_bind", "create_elite_qr", "create_gold_qr", "donate_elite_gift",
"query_points", "exchange_goods", "query_game_name", "query_change_bind_time",
"query_points", "lock_goods", "pay_locked_order", "exchange_goods", "query_game_name", "query_change_bind_time",
"query_limited_goods", "query_gold_balance", "refresh_goods", "query_exchange_records",
"prefetch_csrf_token",
},
+72 -3
View File
@@ -63,6 +63,7 @@ const ELITE_QUICK_ACTIONS = [
{ key: 'query_change_bind_time', icon: <FieldTimeOutlined /> },
{ key: 'query_limited_goods', icon: <SearchOutlined /> },
{ key: 'query_gold_balance', icon: <SearchOutlined /> },
{ key: 'lock_goods', icon: <ShoppingOutlined /> },
{ key: 'exchange_goods', icon: <ShoppingOutlined /> },
{ key: 'create_gold_qr', icon: <CreditCardOutlined /> },
{ key: 'donate_elite_gift', icon: <GiftOutlined /> },
@@ -102,6 +103,8 @@ const ELITE_TASK_TYPES = new Set([
'create_gold_qr',
'donate_elite_gift',
'query_points',
'lock_goods',
'pay_locked_order',
'exchange_goods',
'query_game_name',
'query_change_bind_time',
@@ -150,6 +153,12 @@ function resultText(result: Record<string, unknown> | null | undefined, key: str
return typeof value === 'string' ? value : '';
}
function taskPayloadText(task: DouyuTaskItem, key: string): string {
const payload = task.result?.payload;
if (!payload || typeof payload !== 'object' || Array.isArray(payload)) return '';
return resultText(payload as Record<string, unknown>, key);
}
function resultFlag(result: Record<string, unknown> | null | undefined, key: string): boolean {
const value = result?.[key];
if (value === true) return true;
@@ -419,6 +428,7 @@ export default function DouyuTasksPage({ handbook }: { handbook: HandbookKind })
const [confirmFailCountdown, setConfirmFailCountdown] = useState(0);
const [exchangePreview, setExchangePreview] = useState<{ task: DouyuTaskItem; url: string } | null>(null);
const [xpdPurchaseRecordsAccountId, setXpdPurchaseRecordsAccountId] = useState<number | null>(null);
const [nowSeconds, setNowSeconds] = useState(0);
const locallyStartedBatchIds = useRef<Set<string>>(new Set());
const autoOpenQrReady = useRef(false);
const autoOpenEsportsBindReady = useRef(false);
@@ -426,6 +436,12 @@ export default function DouyuTasksPage({ handbook }: { handbook: HandbookKind })
const logs = useWebSocketLogs();
const canConfig = can('douyu:config');
useEffect(() => {
const updateNow = () => setNowSeconds(Math.floor(Date.now() / 1000));
updateNow();
const timer = window.setInterval(updateNow, 1000);
return () => window.clearInterval(timer);
}, []);
const latestQueryGameTaskByAccount = useMemo(() => {
const map = new Map<number, DouyuTaskItem>();
for (const task of tasks) {
@@ -568,6 +584,24 @@ export default function DouyuTasksPage({ handbook }: { handbook: HandbookKind })
return map;
}, [visibleTasks]);
const latestLockedTaskByAccount = useMemo(() => {
const paidOrders = new Set(
visibleTasks
.filter((task) => task.task_type === 'pay_locked_order' && task.status === 'success')
.map((task) => `${task.account_id}:${resultText(task.result, 'order_id')}`)
.filter(Boolean),
);
const map = new Map<number, DouyuTaskItem>();
for (const task of visibleTasks) {
if (task.task_type !== 'lock_goods' || task.status !== 'success') continue;
const orderId = resultText(task.result, 'order_id');
if (!orderId || paidOrders.has(`${task.account_id}:${orderId}`)) continue;
const previous = map.get(task.account_id);
if (!previous || task.id > previous.id) map.set(task.account_id, task);
}
return map;
}, [visibleTasks]);
const latestXpdPurchaseRecordsTaskByAccount = useMemo(() => {
const map = new Map<number, DouyuTaskItem>();
for (const task of tasks) {
@@ -958,7 +992,7 @@ export default function DouyuTasksPage({ handbook }: { handbook: HandbookKind })
return;
}
// 多批次并发:不再拦截,允许在已有批次运行时继续创建新批次
if (['exchange_goods', 'exchange_esports_goods', 'exchange_xpd_goods'].includes(taskType) && !selectedGoodsId) {
if (['lock_goods', 'exchange_goods', 'exchange_esports_goods', 'exchange_xpd_goods'].includes(taskType) && !selectedGoodsId) {
message.warning('请先选择兑换商品');
return;
}
@@ -973,7 +1007,7 @@ export default function DouyuTasksPage({ handbook }: { handbook: HandbookKind })
}
}
const payload: Record<string, unknown> = { ...extraPayload };
if (['exchange_goods', 'exchange_esports_goods', 'exchange_xpd_goods'].includes(taskType)) {
if (['lock_goods', 'exchange_goods', 'exchange_esports_goods', 'exchange_xpd_goods'].includes(taskType)) {
payload.commodity_id = selectedGoodsId;
}
if (taskType === 'exchange_xpd_goods') payload.pay_type = xpdPayType;
@@ -1624,6 +1658,40 @@ export default function DouyuTasksPage({ handbook }: { handbook: HandbookKind })
);
},
}] : []),
...(!isPeaceHandbook && !isEsportsHandbook ? [{
title: '锁单', width: 150, align: 'center' as const,
render: (_: unknown, record: DouyuTaskAccountItem) => {
const task = latestLockedTaskByAccount.get(record.id);
if (!task) return <Text type="secondary">-</Text>;
const orderId = resultText(task.result, 'order_id');
const commodityId = resultText(task.result, 'commodity_id');
const expiresAt = resultNumber(task.result, 'expires_at');
const expired = expiresAt != null && nowSeconds > 0 && expiresAt <= nowSeconds;
const paymentTask = visibleTasks.find((item) => (
item.account_id === record.id
&& item.task_type === 'pay_locked_order'
&& taskPayloadText(item, 'order_id') === orderId
&& ['planned', 'pending', 'running'].includes(item.status)
));
return (
<Space direction="vertical" size={1}>
<Text style={{ fontSize: 12 }}>{orderId}</Text>
<Button
size="small"
type="primary"
disabled={expired || !orderId}
loading={Boolean(paymentTask)}
onClick={() => void startTask('pay_locked_order', [record.id], {
order_id: orderId,
commodity_id: commodityId,
})}
>
{expired ? '已过期' : '支付锁单'}
</Button>
</Space>
);
},
}] : []),
];
const [configFormValues, setConfigFormValues] = useState<DouyuConfig | null>(null);
@@ -1653,7 +1721,7 @@ export default function DouyuTasksPage({ handbook }: { handbook: HandbookKind })
|| selectedXpdGoodsSoldOut
|| !xpdPaymentOptions.some((option) => option.value === xpdPayType);
}
if (['exchange_goods', 'exchange_esports_goods'].includes(taskType)) return !selectedGoodsId;
if (['lock_goods', 'exchange_goods', 'exchange_esports_goods'].includes(taskType)) return !selectedGoodsId;
// 选中账号中有正在执行的任务时禁用,避免同账号并发不同类型任务(并发写状态字段)
return selectedHasRunning;
};
@@ -1854,6 +1922,7 @@ export default function DouyuTasksPage({ handbook }: { handbook: HandbookKind })
/>
<div style={actionGridStyle}>
{renderActionButton('refresh_goods')}
{renderActionButton('lock_goods')}
{renderActionButton('exchange_goods', 'primary')}
</div>
</Space>
+11 -3
View File
@@ -1,6 +1,11 @@
import type { DouyuTaskItem } from '../api/types';
const EXCHANGE_TASK_TYPES = new Set(['exchange_goods', 'exchange_esports_goods', 'exchange_xpd_goods']);
const EXCHANGE_TASK_TYPES = new Set([
'exchange_goods',
'pay_locked_order',
'exchange_esports_goods',
'exchange_xpd_goods',
]);
const CARD_W = 320;
const CARD_H = 470;
const FONT_FAMILY = '"PingFang SC", "Microsoft YaHei", "Helvetica Neue", Arial, sans-serif';
@@ -29,11 +34,14 @@ function goodsOf(task: DouyuTaskItem): {
const name = (rawString(goods, 'commodityName')
|| rawString(goods, 'name')
|| rawString(goods, 'sGoodsName')
|| task.message.replace(/^兑换[^:]*[:]?\s*/, '').trim()
|| task.message.replace(/^(?:兑换[^:]*|锁单支付成功)[:]?\s*/, '').trim()
|| rawString(task.result, 'commodity_id'))
.replace(/[(]\d+\s*(?:点券|碎片|积分)[)]\s*$/, '')
.trim();
const pic = rawString(goods, 'webPic') || rawString(goods, 'pic') || rawString(goods, 'sGoodsPic');
const pic = rawString(goods, 'webPic')
|| rawString(goods, 'pic')
|| rawString(goods, 'sGoodsPic')
|| rawString(task.result, 'commodity_image');
const roleName = rawString(task.result, 'game_name');
const channel = rawString(task.result, 'game_channel');
const accountName = rawString(task.result, 'account_name')