From de2e53748d9e907807073b8a4511348e47a67ad6 Mon Sep 17 00:00:00 2001 From: yml2213 Date: Sun, 2 Aug 2026 13:18:56 +0800 Subject: [PATCH] =?UTF-8?q?fix(douyu):=20=E4=BF=AE=E5=A4=8D=E6=9C=AA?= =?UTF-8?q?=E6=8D=A2=E7=BB=91=E6=88=90=E5=8A=9F=E6=97=B6=E8=A7=92=E8=89=B2?= =?UTF-8?q?=E4=BF=A1=E6=81=AF=E8=A2=AB=E5=BE=85=E7=A1=AE=E8=AE=A4=E6=96=B0?= =?UTF-8?q?=E8=A7=92=E8=89=B2=E6=B1=A1=E6=9F=93=20&=20=E5=A2=9E=E5=8A=A0?= =?UTF-8?q?=E7=BB=91=E5=AE=9A=E6=8E=A5=E5=8F=A3=E6=98=8E=E7=BB=86=E6=97=A5?= =?UTF-8?q?=E5=BF=97?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- core/douyu/activity_client.py | 26 +++++- web/backend/services/douyu_runner.py | 107 ++++++++++++++++++---- web/frontend/src/pages/DouyuTasksPage.tsx | 74 +++++++++++++-- 3 files changed, 180 insertions(+), 27 deletions(-) diff --git a/core/douyu/activity_client.py b/core/douyu/activity_client.py index 1eccc7a..c020371 100644 --- a/core/douyu/activity_client.py +++ b/core/douyu/activity_client.py @@ -247,7 +247,27 @@ class DouyuActivityClient: ) if payload.get("error") not in (0, "0", None): raise DouyuActivityError(payload.get("msg") or "查询绑定信息失败") - return self._parse_bind_info(payload, act_alias=act_alias, api_version="v2" if v2 else "v1") + parsed = self._parse_bind_info(payload, act_alias=act_alias, api_version="v2" if v2 else "v1") + self._log_bind_info(parsed) + return parsed + + def _log_bind_info(self, parsed: dict[str, Any]) -> None: + """输出绑定信息接口返回的角色明细(用于排查两个接口/多个 alias 的角色差异)。""" + self.logger( + f"查询绑定信息明细 api={parsed.get('api_version') or '-'} " + f"act={parsed.get('act_alias') or '-'} " + f"role={parsed.get('role_name') or '-'} " + f"area={parsed.get('area_name') or '-'} plat={parsed.get('plat_name') or '-'} " + f"nick={parsed.get('nickname') or '-'} " + f"bound_account={1 if parsed.get('is_bound_account') else 0} " + f"bound_role={1 if parsed.get('is_bound_role') else 0} " + f"bound_act={1 if parsed.get('is_bound_act') else 0} " + f"need_act={1 if parsed.get('need_bind_act') else 0} " + f"need_role={1 if parsed.get('need_bind_role') else 0} " + f"wait={parsed.get('change_role_wait_time') if parsed.get('change_role_wait_time') is not None else '-'} " + f"can_change_role={parsed.get('can_change_role')} " + f"can_change_time={parsed.get('can_change_time') if parsed.get('can_change_time') is not None else '-'}" + ) def _parse_bind_info(self, payload: dict[str, Any], *, act_alias: str, api_version: str) -> dict[str, Any]: """统一解析不同活动的腾讯游戏绑定信息。""" @@ -321,7 +341,9 @@ class DouyuActivityClient: ) if payload.get("error") not in (0, "0", None): raise DouyuActivityError(payload.get("msg") or "查询电竞手册绑定信息失败") - return self._parse_bind_info(payload, act_alias=act_alias, api_version="esports") + parsed = self._parse_bind_info(payload, act_alias=act_alias, api_version="esports") + self._log_bind_info(parsed) + return parsed def get_esports_bind_qr(self, act_alias: str) -> dict[str, Any]: """生成电竞手册的腾讯角色绑定二维码链接。""" diff --git a/web/backend/services/douyu_runner.py b/web/backend/services/douyu_runner.py index ed7f298..4d78146 100644 --- a/web/backend/services/douyu_runner.py +++ b/web/backend/services/douyu_runner.py @@ -272,6 +272,7 @@ class DouyuBatchRunner: f" need_role={1 if snap['need_bind_role'] else 0}" f" wait={snap['change_role_wait_time'] if snap['change_role_wait_time'] is not None else '-'}" f" can_change_time={snap['can_change_time'] if snap['can_change_time'] is not None else '-'}" + f" can={snap['can_change_role']}" f"{pending_text}" ) @@ -716,6 +717,10 @@ class DouyuBatchRunner: self._push_log("warning", f"查询绑定信息失败 act={alias}: {exc}") continue info = {**info, "act_alias": alias} + self._push_log( + "info", + f"绑定信息 act={alias} {self._format_bind_summary(info)}", + ) results.append(info) return results @@ -882,7 +887,9 @@ class DouyuBatchRunner: ], }) role_name = snapshot["role_name"] - self._apply_bind_info_to_account(account, bind_info, "game_queried") + # 待确认角色只回传前端展示,不写入账号表,避免“未换绑成功但角色信息已变新” + account.bind_status = "game_queried" + account.updated_at = datetime.now(timezone.utc) self._push_log("success", f"识别到待确认角色: {role_name} (act={query_alias})") self._update_task_progress( db, @@ -1127,6 +1134,15 @@ class DouyuBatchRunner: before_snapshot = self._bind_snapshot(before) 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, + ) self._push_log( "info", f"确认前状态 confirm_act={confirm_alias or '-'} hit={query_alias or '-'} " @@ -1174,6 +1190,12 @@ class DouyuBatchRunner: # 确认接口优先用配置的确认 alias;没有则回退到命中查询的 alias use_confirm_alias = confirm_alias or query_alias confirm_result = client.confirm_bind(use_confirm_alias) + confirm_raw = confirm_result.get("raw") or {} + self._push_log( + "info", + f"确认绑定接口返回 act={use_confirm_alias} " + f"error={confirm_raw.get('error')} msg={confirm_raw.get('msg') or '-'}", + ) try: after_candidates = self._fetch_bind_info_candidates(client, query_aliases) after = self._pick_bind_info( @@ -1184,9 +1206,15 @@ class DouyuBatchRunner: ) if after_candidates else None if after is None: raise DouyuActivityError("确认后回查绑定信息失败") + self._push_log( + "info", + f"确认后回查 hit={after.get('act_alias') or '-'} " + f"{self._format_bind_summary(after)}", + ) except DouyuActivityError as exc: # 确认接口已成功时,回查失败仍按确认成功处理,但保留错误信息。 - self._apply_bind_info_to_account(account, before, "bind_confirmed") + # 写库优先确认前已生效绑定,避免待确认角色被误写入。 + self._apply_bind_info_to_account(account, before_bound or before, "bind_confirmed") self._mark_task( db, task, @@ -1212,7 +1240,8 @@ class DouyuBatchRunner: final_snapshot = after_snapshot if after_snapshot["role_name"] else before_snapshot final_role_name = final_snapshot["role_name"] or role_name if not after_snapshot["is_bound_act"]: - self._apply_bind_info_to_account(account, final_info, "game_queried") + # 确认未生效:保留确认前已生效绑定,不把待确认新角色写入账号表 + self._apply_bind_info_to_account(account, before_bound or before, "game_queried") self._mark_task( db, task, @@ -1929,32 +1958,60 @@ class DouyuBatchRunner: if not candidates: self._mark_task(db, task, "failed", "查询绑定信息失败,请检查 Cookie 或 actAlias") return - bind_info = self._pick_bind_info( + + # 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, + ) + # 2) 待确认角色(cjm 扫码后未确认;无已绑定时也用于首次绑定展示) + pending_info = self._pick_bind_info( candidates, prefer_pending=True, prefer_aliases=query_aliases, ) or candidates[0] - snapshot = self._bind_snapshot(bind_info) - role_name = snapshot["role_name"] - source_alias = str(bind_info.get("act_alias") or "") - summary = f"act={source_alias or '-'} {self._format_bind_summary(bind_info)}" + pending_role = str(pending_info.get("role_name") or "").strip() + bound_role = str(bound_info.get("role_name") or "") if bound_info else "" + has_pending = bool(pending_role) and (pending_role != bound_role or not bound_info) + + # 查询结果角色 = 已生效绑定;首次绑定(无绑定)时回退待确认角色 + bind_info = bound_info if bound_info else (pending_info if has_pending else None) + snapshot = self._bind_snapshot(bind_info) if bind_info else {} + role_name = snapshot.get("role_name") or "" + is_bound = snapshot.get("is_bound_act", False) + source_alias = str((bind_info or {}).get("act_alias") or "") + summary = ( + f"act={source_alias or '-'} {self._format_bind_summary(bind_info)}" + if bind_info + else "act=- role=-" + ) self._push_log( "info", "查询角色 " - f"aliases={','.join(query_aliases)} hit={source_alias or '-'} " - f"{self._format_bind_summary(bind_info)}", + f"aliases={','.join(query_aliases)} bound_hit={(bound_info or {}).get('act_alias') or '-'} " + f"pending_hit={pending_info.get('act_alias') or '-'} " + f"{self._format_bind_summary(bind_info) if bind_info else 'role=- bound_act=-'}", ) - self._apply_bind_info_to_account(account, bind_info, "game_queried" if role_name else "game_not_bound") - pending = bool(role_name) and not snapshot["is_bound_act"] + # 只有已生效绑定才写账号表;待确认角色不污染 game_name + if bind_info and is_bound: + self._apply_bind_info_to_account(account, bind_info, "game_queried") + else: + account.bind_status = "game_queried" if has_pending else "game_not_bound" + account.updated_at = datetime.now(timezone.utc) result = { "act_alias": source_alias, "query_act_alias": source_alias, "query_act_aliases": query_aliases, **snapshot, - "bind_ready_for_confirm": pending, - "bind_confirmed": snapshot["is_bound_act"], + "bind_ready_for_confirm": has_pending, + "bind_confirmed": is_bound, "bind_phase": ( - "confirmed" if snapshot["is_bound_act"] + "confirmed" if is_bound else ("role_ready" if role_name else "waiting_role") ), "bind_summary": summary, @@ -1967,10 +2024,18 @@ class DouyuBatchRunner: for item in candidates ], } - if not role_name: + # 已绑定角色之外另有待确认新角色时,单独字段展示,不覆盖 role_name + if has_pending and bound_info and pending_role != bound_role: + result["pending_role_name"] = pending_role + result["pending_area_name"] = str(pending_info.get("area_name") or "") + result["pending_plat_name"] = str(pending_info.get("plat_name") or "") + if not bind_info: message = f"未获取到游戏名 | {summary}" - elif snapshot["is_bound_act"]: - message = f"当前已绑定: {role_name} | {summary}" + elif is_bound: + if has_pending and pending_role != bound_role: + message = f"当前已绑定: {bound_role},待确认: {pending_role} | {summary}" + else: + message = f"当前已绑定: {bound_role} | {summary}" else: message = f"待确认角色: {role_name} | {summary}" self._mark_task(db, task, "success" if role_name else "failed", message, result) @@ -2015,6 +2080,12 @@ class DouyuBatchRunner: if not candidates: self._mark_task(db, task, "failed", "查询绑定信息失败,请检查 Cookie 或 actAlias") return + for item in candidates: + self._push_log( + "info", + f"查询换绑时间候选 act={item.get('act_alias') or '-'} " + f"{self._format_bind_summary(item)}", + ) # 换绑时间看“当前已绑定”活动态,不要优先 cjm(cjm 常无 changeRoleWaitTime) bind_info = self._pick_change_wait_bind_info(candidates, config) or candidates[0] snapshot = self._bind_snapshot(bind_info) diff --git a/web/frontend/src/pages/DouyuTasksPage.tsx b/web/frontend/src/pages/DouyuTasksPage.tsx index 4a19d73..5e3a1a8 100644 --- a/web/frontend/src/pages/DouyuTasksPage.tsx +++ b/web/frontend/src/pages/DouyuTasksPage.tsx @@ -26,12 +26,33 @@ import { getExchangeImage, } from '../utils/exchangeImage'; import { getErrorMessage } from '../utils/error'; +import { formatTime } from '../utils/time'; import { message } from '../utils/antdMessage'; const { Text } = Typography; type HandbookKind = 'elite' | 'esports'; +const TASK_STATUS_COLORS: Record = { + planned: 'default', + pending: 'default', + running: 'processing', + success: 'success', + failed: 'error', + error: 'error', + stopped: 'warning', +}; + +const TASK_STATUS_LABELS: Record = { + planned: '已计划', + pending: '等待中', + running: '执行中', + success: '成功', + failed: '失败', + error: '异常', + stopped: '已停止', +}; + const ELITE_QUICK_ACTIONS = [ { key: 'get_bind_qr', icon: }, { key: 'confirm_bind', icon: }, @@ -845,22 +866,29 @@ export default function DouyuTasksPage({ handbook }: { handbook: HandbookKind }) const qrQueryResult = qrTask && qrQueryTask && qrQueryTask.id > qrTask.id ? qrQueryTask.result : null; const qrQueryRunning = Boolean(qrQueryTask && qrTask && qrQueryTask.id > qrTask.id && qrQueryTask.status === 'running'); const qrQueryRoleName = resultText(qrQueryResult, 'role_name'); + const qrQueryPendingRoleName = resultText(qrQueryResult, 'pending_role_name'); const qrQueryIsBoundAct = resultFlag(qrQueryResult, 'is_bound_act') || resultFlag(qrQueryResult, 'bind_confirmed'); const qrQueryPending = Boolean( qrQueryResult - && qrQueryRoleName && ( - qrQueryResult.bind_ready_for_confirm === true - || (!qrQueryIsBoundAct) + qrQueryPendingRoleName + || ( + qrQueryRoleName + && ( + qrQueryResult.bind_ready_for_confirm === true + || (!qrQueryIsBoundAct) + ) + ) ), ); const qrBindReady = bindReadyForConfirm(qrTask) || qrQueryPending; const qrRoleSourceResult = qrQueryPending ? qrQueryResult : (bindReadyForConfirm(qrTask) ? qrResult : null); - const qrRoleName = resultText(qrRoleSourceResult, 'role_name'); - const qrAreaName = resultText(qrRoleSourceResult, 'area_name'); - const qrPlatName = resultText(qrRoleSourceResult, 'plat_name'); + // 待确认角色优先用 pending_* 字段(已绑定角色之外的扫码新角色) + const qrRoleName = resultText(qrRoleSourceResult, 'pending_role_name') || resultText(qrRoleSourceResult, 'role_name'); + const qrAreaName = resultText(qrRoleSourceResult, 'pending_area_name') || resultText(qrRoleSourceResult, 'area_name'); + const qrPlatName = resultText(qrRoleSourceResult, 'pending_plat_name') || resultText(qrRoleSourceResult, 'plat_name'); const qrCurrentRoleName = qrQueryRoleName || resultText(qrResult, 'current_role_name') || ( @@ -943,8 +971,10 @@ export default function DouyuTasksPage({ handbook }: { handbook: HandbookKind }) ? 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) @@ -954,6 +984,9 @@ export default function DouyuTasksPage({ handbook }: { handbook: HandbookKind }) return roleName ? ( {roleName} + {pendingRoleName ? ( + 待确认: {pendingRoleName} + ) : null} {channel ? {channel} : null} ) : 未查; @@ -1035,6 +1068,33 @@ export default function DouyuTasksPage({ handbook }: { handbook: HandbookKind }) return aw - bw; }, }, + { + title: '最近操作', width: 220, + render: (_, record) => { + const latest = latestTaskByAccount.get(record.id); + if (!latest) return 暂无操作; + return ( + + + + {TASK_STATUS_LABELS[latest.status] || latest.status} + + + {taskTypes[latest.task_type] || latest.task_type} + + + {latest.message ? ( + + {latest.message} + + ) : null} + + {formatTime(latest.finished_at || latest.created_at)} + + + ); + }, + }, { title: '二维码', width: 76, align: 'center', render: (_, record) => { @@ -1428,7 +1488,7 @@ export default function DouyuTasksPage({ handbook }: { handbook: HandbookKind }) }, }} tableLayout="fixed" - scroll={{ x: 960, y: Math.max(120, accountTableAreaHeight - 80) }} + scroll={{ x: 1180, y: Math.max(120, accountTableAreaHeight - 80) }} onRow={(record) => ({ onContextMenu: (e) => handleRowContextMenu(record, e), style: { cursor: 'context-menu' },