优化斗鱼绑定状态同步

This commit is contained in:
yml2213
2026-08-02 17:54:36 +08:00
parent c62f52a067
commit a466e11c9c
4 changed files with 200 additions and 108 deletions
+1 -1
View File
@@ -120,7 +120,7 @@ class DouyuConfig(Base):
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")
bind_act_alias = Column(String(64), default="20260120QYOOB")
confirm_act_alias = Column(String(64), default="20260120QYOOB")
legacy_act_alias = Column(String(64), default="cjm")
room_id = Column(String(64), default="9263298")
+1 -1
View File
@@ -542,7 +542,7 @@ class HuyaRechargeGoodsOut(BaseModel):
class DouyuConfigOut(BaseModel):
manual_id: str = "G4KA4Qnz4LDp7"
rid: str = "9263298"
bind_act_alias: str = "20250213NQCYX"
bind_act_alias: str = "20260120QYOOB"
confirm_act_alias: str = "20260120QYOOB"
legacy_act_alias: str = "cjm"
room_id: str = "9263298"
+89 -83
View File
@@ -150,6 +150,16 @@ class DouyuBatchRunner:
def _current_bind_act_alias(cls, config: dict) -> str:
return cls._confirm_act_alias(config) or cls._bind_qr_act_alias(config)
@classmethod
def _action_act_aliases(cls, config: dict) -> list[str]:
"""当前活动动作 alias;不包含只用于查询最新扫码态的 legacy/cjm。"""
aliases: list[str] = []
for key in ("confirm_act_alias", "bind_act_alias"):
alias = cls._action_act_alias(config, key)
if alias and alias not in aliases:
aliases.append(alias)
return aliases
@staticmethod
def _role_channel(bind_info: dict) -> str:
return " / ".join(
@@ -194,8 +204,7 @@ class DouyuBatchRunner:
if not bind_info:
return False
role_name = str(bind_info.get("role_name") or "").strip()
is_bound = cls._is_bound_act(bind_info) or bool(role_name)
if not is_bound:
if not role_name or not cls._is_bound_act(bind_info):
return False
return not cls._can_change_role(bind_info)
@@ -768,6 +777,32 @@ class DouyuBatchRunner:
return info
return ordered[0]
def _pick_current_bound_info(
self,
candidates: list[dict],
config: dict,
*,
extra_prefer_aliases: list[str] | None = None,
) -> dict | None:
"""选当前活动已生效绑定,避免把 legacy/cjm 的待确认态当成当前角色。"""
if not candidates:
return None
prefer = []
for alias in [*(extra_prefer_aliases or []), *self._action_act_aliases(config)]:
alias = str(alias or "").strip()
if alias and alias not in prefer:
prefer.append(alias)
bound = [
info
for info in candidates
if self._is_bound_act(info) and str(info.get("role_name") or "").strip()
and str(info.get("act_alias") or "") in prefer
]
if not bound:
return None
return sorted(bound, key=lambda info: prefer.index(str(info.get("act_alias") or "")))[0]
def _pick_baseline_bind_info(
self,
candidates: list[dict],
@@ -781,26 +816,7 @@ class DouyuBatchRunner:
"""
if not candidates:
return None
prefer = []
for key in ("confirm_act_alias", "bind_act_alias", "legacy_act_alias"):
alias = str(config.get(key) or "").strip()
if alias and alias not in prefer:
prefer.append(alias)
# 先找“已绑定且有角色”的活动结果
for alias in prefer:
for info in candidates:
if str(info.get("act_alias") or "") != alias:
continue
if self._is_bound_act(info) and str(info.get("role_name") or "").strip():
return info
# 再退回任意有角色的结果(仍按活动 alias 优先)
return self._pick_bind_info(
candidates,
prefer_pending=False,
prefer_aliases=prefer,
)
return self._pick_current_bound_info(candidates, config)
def _wait_bind_role_result(
self,
@@ -992,19 +1008,21 @@ class DouyuBatchRunner:
self._mark_task(db, task, "failed", "查询绑定信息失败,请检查 Cookie 或 actAlias")
return
# baseline 用活动 alias 的“当前已绑定”;pending 检测优先 cjm 的换绑最新态
before = self._pick_baseline_bind_info(before_candidates, config) or before_candidates[0]
before = self._pick_baseline_bind_info(before_candidates, config) or {}
# 换绑冷却必须看活动当前绑定(QYOOB),不要用 cjm
cooldown_info = self._pick_change_wait_bind_info(before_candidates, config) or before
cooldown_info = self._pick_change_wait_bind_info(before_candidates, config)
pending_before = self._pick_bind_info(
before_candidates,
baseline_role_name=str(before.get("role_name") or ""),
baseline_is_bound_act=self._is_bound_act(before),
prefer_pending=True,
prefer_aliases=query_aliases,
) or before
) or before or before_candidates[0]
before_snapshot = self._bind_snapshot(before)
cooldown_snapshot = self._bind_snapshot(cooldown_info)
current_role_name = before_snapshot["role_name"] or cooldown_snapshot["role_name"]
current_role_name = before_snapshot["role_name"] or (
cooldown_snapshot["role_name"] if cooldown_snapshot["is_bound_act"] else ""
)
wait_time = cooldown_snapshot["change_role_wait_time"]
self._push_log(
"info",
@@ -1118,6 +1136,9 @@ class DouyuBatchRunner:
query_aliases = self._query_bind_act_aliases(config)
if confirm_alias and confirm_alias not in query_aliases:
query_aliases = [confirm_alias, *query_aliases]
if not confirm_alias:
self._mark_task(db, task, "failed", "请先配置确认绑定活动 actAlias")
return
if not query_aliases:
self._mark_task(db, task, "failed", "请先配置绑定活动 actAlias")
return
@@ -1137,13 +1158,10 @@ class DouyuBatchRunner:
role_name = before_snapshot["role_name"]
query_alias = str(before.get("act_alias") or "")
# 确认前“已生效绑定”角色(bound_act=1),确认失败/回查失败时写库用,避免待确认角色污染 game_name
before_bound = next(
(
info
for info in before_candidates
if self._is_bound_act(info) and str(info.get("role_name") or "").strip()
),
None,
before_bound = self._pick_current_bound_info(
before_candidates,
config,
extra_prefer_aliases=[confirm_alias],
)
self._push_log(
"info",
@@ -1169,8 +1187,13 @@ class DouyuBatchRunner:
)
return
if before_snapshot["is_bound_act"]:
self._apply_bind_info_to_account(account, before, "bind_confirmed")
before_is_current_bound = (
before_bound is not None
and str(before.get("act_alias") or "") == str(before_bound.get("act_alias") or "")
and role_name == str(before_bound.get("role_name") or "")
)
if before_is_current_bound:
self._apply_bind_info_to_account(account, before_bound, "bind_confirmed")
self._mark_task(
db,
task,
@@ -1180,8 +1203,8 @@ class DouyuBatchRunner:
"act_alias": confirm_alias or query_alias,
"query_act_alias": query_alias,
"query_act_aliases": query_aliases,
"before_bind_info": before,
**before_snapshot,
"before_bind_info": before_bound,
**self._bind_snapshot(before_bound),
"bind_ready_for_confirm": True,
"bind_confirmed": True,
"bind_phase": "confirmed",
@@ -1197,7 +1220,11 @@ class DouyuBatchRunner:
confirm_msg = str(exc)
self._push_log("warning", f"确认绑定接口失败: {confirm_msg}")
# 待绑定游戏账号侧换绑限制(未到换绑时间等)导致确认失败:保留原绑定并给出明确提示
self._apply_bind_info_to_account(account, before_bound or before, "game_queried")
if before_bound is not None:
self._apply_bind_info_to_account(account, before_bound, "game_queried")
else:
account.bind_status = "game_queried"
account.updated_at = datetime.now(timezone.utc)
self._mark_task(
db,
task,
@@ -1223,18 +1250,11 @@ class DouyuBatchRunner:
f"error={confirm_raw.get('error')} msg={confirm_raw.get('msg') or '-'}",
)
def _pick_bound_after(candidates: list[dict]) -> dict | None:
"""确认后回查:只认已生效绑定(bound_act=1)。
cjm 的角色名恒有且 bound_act 恒为 0,若按 alias 顺序挑选会永远
判定“确认未生效”,必须按 bound_act=1 判定确认结果。
"""
return next(
(
info
for info in candidates
if self._is_bound_act(info) and str(info.get("role_name") or "").strip()
),
None,
"""确认后回查:只认活动 alias 上的已生效绑定(bound_act=1)。"""
return self._pick_current_bound_info(
candidates,
config,
extra_prefer_aliases=[use_confirm_alias],
)
try:
@@ -1255,7 +1275,11 @@ class DouyuBatchRunner:
break
if after is None:
# 已生效绑定始终未出现:确认未生效(或同步延迟超时),保留原绑定
self._apply_bind_info_to_account(account, before_bound or before, "game_queried")
if before_bound is not None:
self._apply_bind_info_to_account(account, before_bound, "game_queried")
else:
account.bind_status = "game_queried"
account.updated_at = datetime.now(timezone.utc)
self._mark_task(
db,
task,
@@ -1284,7 +1308,11 @@ class DouyuBatchRunner:
except DouyuActivityError as exc:
# 查询接口异常:确认接口已成功时按成功处理,但保留错误信息。
# 写库优先确认前已生效绑定,避免待确认角色被误写入。
self._apply_bind_info_to_account(account, before_bound or before, "bind_confirmed")
if before_bound is not None:
self._apply_bind_info_to_account(account, before_bound, "bind_confirmed")
else:
account.bind_status = "bind_confirmed"
account.updated_at = datetime.now(timezone.utc)
self._mark_task(
db,
task,
@@ -2006,14 +2034,7 @@ class DouyuBatchRunner:
# 1) 优先“已生效绑定”角色(bound_act=1 且有角色名,通常是活动 alias)。
# 避免把扫码后未确认的新角色当成当前绑定结果。
bound_info = next(
(
info
for info in candidates
if self._is_bound_act(info) and str(info.get("role_name") or "").strip()
),
None,
)
bound_info = self._pick_current_bound_info(candidates, config)
# 2) 待确认角色(cjm 扫码后未确认;无已绑定时也用于首次绑定展示)
pending_info = self._pick_bind_info(
candidates,
@@ -2089,30 +2110,15 @@ class DouyuBatchRunner:
"""换绑倒计时优先看活动当前绑定(QYOOB),不是 cjm 换绑最新态。"""
if not candidates:
return None
# 1) 优先活动 alias 上有角色的结果
baseline = self._pick_baseline_bind_info(candidates, config)
if baseline is not None:
wait = self._to_int(baseline.get("change_role_wait_time"))
if wait is not None:
return baseline
# 2) 任意带 wait_time 的结果里取 wait 最大的
with_wait = []
for info in candidates:
wait = self._to_int(info.get("change_role_wait_time"))
if wait is not None:
with_wait.append((wait, info))
if with_wait:
with_wait.sort(key=lambda item: item[0], reverse=True)
return with_wait[0][1]
# 3) 回退 baseline / 首个有角色
return baseline or self._pick_bind_info(
candidates,
# 冷却是“当前已生效绑定”的属性,不能用 legacy/cjm 的待确认角色判断。
current_bound = self._pick_current_bound_info(candidates, config)
if current_bound is not None:
return current_bound
action_aliases = self._action_act_aliases(config)
return self._pick_bind_info(
[info for info in candidates if str(info.get("act_alias") or "") in action_aliases],
prefer_pending=False,
prefer_aliases=[
str(config.get("confirm_act_alias") or "").strip(),
str(config.get("bind_act_alias") or "").strip(),
str(config.get("legacy_act_alias") or "").strip(),
],
prefer_aliases=action_aliases,
)
def _execute_query_change_bind_time(self, db: Session, task: DouyuTask, account: Account, cookie: str, config: dict):
+109 -23
View File
@@ -334,6 +334,29 @@ export default function DouyuTasksPage({ handbook }: { handbook: HandbookKind })
return map;
}, [tasks]);
const latestConfirmBindTaskByAccount = useMemo(() => {
const map = new Map<number, DouyuTaskItem>();
for (const task of tasks) {
if (task.task_type !== 'confirm_bind') continue;
if (!task.result) continue;
const previous = map.get(task.account_id);
if (!previous || task.id > previous.id) map.set(task.account_id, task);
}
return map;
}, [tasks]);
const latestConfirmBindFailureByAccount = useMemo(() => {
const map = new Map<number, DouyuTaskItem>();
for (const task of tasks) {
if (task.task_type !== 'confirm_bind') continue;
if (!['failed', 'error'].includes(task.status)) continue;
if (!(task.message || '').includes('待绑定游戏账号')) continue;
const previous = map.get(task.account_id);
if (!previous || task.id > previous.id) map.set(task.account_id, task);
}
return map;
}, [tasks]);
const latestEsportsStateTaskByAccount = useMemo(() => {
const map = new Map<number, DouyuTaskItem>();
for (const task of tasks) {
@@ -356,7 +379,7 @@ export default function DouyuTasksPage({ handbook }: { handbook: HandbookKind })
[tasks, activeQrTaskId],
);
// 换绑时间:优先最近一次查询/拦截任务结果,回退账号落库字段
// 换绑时间:优先最近一次查询/拦截/确认成功结果,回退账号落库字段
const latestChangeWaitByAccount = useMemo(() => {
const map = new Map<number, {
wait: number | null; canChangeTime: number | null; text: string; taskId: number;
@@ -364,9 +387,20 @@ export default function DouyuTasksPage({ handbook }: { handbook: HandbookKind })
for (const task of tasks) {
const taskTypes = isEsportsHandbook
? ['prepare_esports_bind', 'get_esports_bind_qr', 'query_esports_game_name', 'confirm_esports_bind']
: ['query_change_bind_time', 'get_bind_qr'];
: ['query_change_bind_time', 'get_bind_qr', 'confirm_bind'];
if (!taskTypes.includes(task.task_type)) continue;
if (!['success', 'failed'].includes(task.status)) continue;
if (
!isEsportsHandbook
&& task.task_type === 'confirm_bind'
&& (
task.status !== 'success'
|| !(
resultFlag(task.result, 'bind_confirmed')
|| resultFlag(task.result, 'is_bound_act')
)
)
) continue;
const wait = resultNumber(task.result, 'change_role_wait_time');
const canChangeTime = resultNumber(task.result, 'can_change_time');
// get_bind_qr 只有冷却拦截时才有 wait;无 wait 时跳过,避免覆盖
@@ -644,6 +678,14 @@ export default function DouyuTasksPage({ handbook }: { handbook: HandbookKind })
for (const task of failedTasks) autoNotifiedConfirmFailedTaskIds.current.add(task.id);
const latest = failedTasks[0];
setConfirmFailTip({ taskId: latest.id, message: latest.message });
setQrTaskIds((prev) => {
const next = prev.filter((id) => {
const task = visibleTasks.find((item) => item.id === id);
return !task || task.account_id !== latest.account_id;
});
setActiveQrTaskId((cur) => (cur != null && !next.includes(cur) ? (next[next.length - 1] ?? null) : cur));
return next;
});
}, [visibleTasks]);
// 弹窗倒计时自动关闭
@@ -1019,32 +1061,76 @@ export default function DouyuTasksPage({ handbook }: { handbook: HandbookKind })
),
},
{
title: '游戏名', dataIndex: 'game_name', width: 130,
title: '游戏名', dataIndex: 'game_name', width: 170,
render: (_, record) => {
const queryResult = (
isEsportsHandbook
? latestEsportsStateTaskByAccount.get(record.id)
: latestQueryGameTaskByAccount.get(record.id)
)?.result || null;
// role_name = 当前已生效绑定角色;pending_role_name = 扫码后待确认的新角色
const roleName = resultText(queryResult, 'role_name')
|| (isEsportsHandbook ? record.esports_game_name : record.game_name);
const pendingRoleName = resultText(queryResult, 'pending_role_name');
const channel = (
[resultText(queryResult, 'area_name'), resultText(queryResult, 'plat_name')]
.filter(Boolean)
.join(' / ')
|| (isEsportsHandbook ? record.esports_game_channel : record.game_channel)
const queryTask = isEsportsHandbook
? latestEsportsStateTaskByAccount.get(record.id) || null
: latestQueryGameTaskByAccount.get(record.id) || null;
const confirmTask = isEsportsHandbook ? null : latestConfirmBindTaskByAccount.get(record.id) || null;
const confirmSucceeded = Boolean(
confirmTask
&& confirmTask.status === 'success'
&& (
resultFlag(confirmTask.result, 'bind_confirmed')
|| resultFlag(confirmTask.result, 'is_bound_act')
),
);
return roleName ? (
<Space direction="vertical" size={0}>
<Text>{roleName}</Text>
const stateTask = (
confirmSucceeded
&& (!queryTask || confirmTask!.id > queryTask.id)
) ? confirmTask : queryTask;
const confirmFailureTask = latestConfirmBindFailureByAccount.get(record.id);
const clearPending = Boolean(
!isEsportsHandbook
&& confirmFailureTask
&& stateTask
&& confirmFailureTask.id > stateTask.id,
);
const stateResult = stateTask?.result || null;
const stateRoleConfirmed = resultFlag(stateResult, 'is_bound_act') || resultFlag(stateResult, 'bind_confirmed');
const stateRoleName = clearPending && !stateRoleConfirmed ? '' : resultText(stateResult, 'role_name');
// role_name = 当前已生效绑定角色;pending_role_name = 扫码后待确认的新角色
const roleName = stateRoleName
|| (isEsportsHandbook ? record.esports_game_name : record.game_name);
const stateChannel = clearPending && !stateRoleConfirmed
? ''
: [resultText(stateResult, 'area_name'), resultText(stateResult, 'plat_name')]
.filter(Boolean)
.join(' / ');
const pendingRoleName = clearPending || confirmSucceeded ? '' : resultText(stateResult, 'pending_role_name');
const channel = stateChannel || (isEsportsHandbook ? record.esports_game_channel : record.game_channel);
const pendingChannel = clearPending || confirmSucceeded ? '' : [resultText(stateResult, 'pending_area_name'), resultText(stateResult, 'pending_plat_name')]
.filter(Boolean)
.join(' / ');
if (!roleName && !pendingRoleName) return <Text type="secondary"></Text>;
return (
<Space direction="vertical" size={2} style={{ lineHeight: 1.35 }}>
{roleName ? (
<Space direction="vertical" size={0}>
<Text ellipsis title={roleName}>
{pendingRoleName ? `当前: ${roleName}` : roleName}
</Text>
{channel ? (
<Text type="secondary" style={{ fontSize: 12 }} ellipsis title={channel}>
{channel}
</Text>
) : null}
</Space>
) : null}
{pendingRoleName ? (
<Text type="warning" style={{ fontSize: 12 }}>: {pendingRoleName}</Text>
<Space direction="vertical" size={0}>
<Text type="warning" style={{ fontSize: 12 }} ellipsis title={pendingRoleName}>
: {pendingRoleName}
</Text>
{pendingChannel ? (
<Text type="warning" style={{ fontSize: 12 }} ellipsis title={pendingChannel}>
{pendingChannel}
</Text>
) : null}
</Space>
) : null}
{channel ? <Text type="secondary" style={{ fontSize: 12 }}>{channel}</Text> : null}
</Space>
) : <Text type="secondary"></Text>;
);
},
},
{