diff --git a/web/backend/models.py b/web/backend/models.py index 9fa0fab..7c4ebce 100644 --- a/web/backend/models.py +++ b/web/backend/models.py @@ -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") diff --git a/web/backend/schemas.py b/web/backend/schemas.py index c9f3b38..41b1154 100644 --- a/web/backend/schemas.py +++ b/web/backend/schemas.py @@ -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" diff --git a/web/backend/services/douyu_runner.py b/web/backend/services/douyu_runner.py index bb5b78f..ef9a462 100644 --- a/web/backend/services/douyu_runner.py +++ b/web/backend/services/douyu_runner.py @@ -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): diff --git a/web/frontend/src/pages/DouyuTasksPage.tsx b/web/frontend/src/pages/DouyuTasksPage.tsx index a5041e5..90ffc7d 100644 --- a/web/frontend/src/pages/DouyuTasksPage.tsx +++ b/web/frontend/src/pages/DouyuTasksPage.tsx @@ -334,6 +334,29 @@ export default function DouyuTasksPage({ handbook }: { handbook: HandbookKind }) return map; }, [tasks]); + const latestConfirmBindTaskByAccount = useMemo(() => { + const map = new Map(); + 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(); + 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(); 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 { + 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 ? ( - - {roleName} + 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 未查; + return ( + + {roleName ? ( + + + {pendingRoleName ? `当前: ${roleName}` : roleName} + + {channel ? ( + + {channel} + + ) : null} + + ) : null} {pendingRoleName ? ( - 待确认: {pendingRoleName} + + + 待确认: {pendingRoleName} + + {pendingChannel ? ( + + {pendingChannel} + + ) : null} + ) : null} - {channel ? {channel} : null} - ) : 未查; + ); }, }, {