diff --git a/web/backend/services/douyu_runner.py b/web/backend/services/douyu_runner.py index 4d78146..bb5b78f 100644 --- a/web/backend/services/douyu_runner.py +++ b/web/backend/services/douyu_runner.py @@ -33,6 +33,8 @@ DOUYU_PAYMENT_POLL_SECONDS = 600 DOUYU_PAYMENT_POLL_INTERVAL = 5 DOUYU_GIFT_POINTS_REFRESH_TIMES = 3 DOUYU_GIFT_POINTS_REFRESH_INTERVAL = 2 +DOUYU_CONFIRM_EFFECT_POLL_TIMES = 3 +DOUYU_CONFIRM_EFFECT_POLL_INTERVAL = 3 class DouyuBatchRunner: @@ -1189,30 +1191,98 @@ class DouyuBatchRunner: # 确认接口优先用配置的确认 alias;没有则回退到命中查询的 alias use_confirm_alias = confirm_alias or query_alias - confirm_result = client.confirm_bind(use_confirm_alias) + try: + confirm_result = client.confirm_bind(use_confirm_alias) + except DouyuActivityError as exc: + confirm_msg = str(exc) + self._push_log("warning", f"确认绑定接口失败: {confirm_msg}") + # 待绑定游戏账号侧换绑限制(未到换绑时间等)导致确认失败:保留原绑定并给出明确提示 + self._apply_bind_info_to_account(account, before_bound or before, "game_queried") + self._mark_task( + db, + task, + "failed", + f"待绑定游戏账号({role_name or '-'})未到换绑时间(不是斗鱼/虎牙账号),请重新换账号扫码绑定", + { + "act_alias": use_confirm_alias, + "query_act_alias": query_alias, + "query_act_aliases": query_aliases, + "before_bind_info": before, + **before_snapshot, + "confirm_error": confirm_msg, + "bind_ready_for_confirm": True, + "bind_confirmed": False, + "bind_phase": "confirm_failed", + }, + ) + return 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 '-'}", ) + 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, + ) + try: after_candidates = self._fetch_bind_info_candidates(client, query_aliases) - after = self._pick_bind_info( - after_candidates, - baseline_role_name="", - baseline_is_bound_act=False, - prefer_pending=False, - ) if after_candidates else None - if after is None: + if not after_candidates: raise DouyuActivityError("确认后回查绑定信息失败") + after = _pick_bound_after(after_candidates) + # 已生效绑定存在同步延迟:确认接口已成功但未生效时短轮询等待 + if after is None: + for _ in range(DOUYU_CONFIRM_EFFECT_POLL_TIMES): + if self._stop.wait(DOUYU_CONFIRM_EFFECT_POLL_INTERVAL): + break + after_candidates = self._fetch_bind_info_candidates(client, query_aliases) + if not after_candidates: + break + after = _pick_bound_after(after_candidates) + if after is not None: + break + if after is None: + # 已生效绑定始终未出现:确认未生效(或同步延迟超时),保留原绑定 + self._apply_bind_info_to_account(account, before_bound or before, "game_queried") + self._mark_task( + db, + task, + "failed", + f"待绑定游戏账号({role_name or '-'})未到换绑时间(不是斗鱼/虎牙账号),请重新换账号扫码绑定", + { + "act_alias": use_confirm_alias, + "query_act_alias": query_alias, + "query_act_aliases": query_aliases, + "before_bind_info": before, + "confirm": confirm_result, + "after_bind_info": None, + **before_snapshot, + "bind_ready_for_confirm": True, + "bind_confirmed": False, + "bind_phase": "confirm_failed", + "confirm_wait_error": "确认后短轮询未等到已生效绑定", + }, + ) + return 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_bound or before, "bind_confirmed") self._mark_task( @@ -1236,33 +1306,8 @@ class DouyuBatchRunner: return after_snapshot = self._bind_snapshot(after) - final_info = after if after_snapshot["role_name"] else before - 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, before_bound or before, "game_queried") - self._mark_task( - db, - task, - "failed", - f"确认绑定未生效: {final_role_name}", - { - "act_alias": use_confirm_alias, - "query_act_alias": after.get("act_alias") or query_alias, - "query_act_aliases": query_aliases, - "before_bind_info": before, - "confirm": confirm_result, - **final_snapshot, - "bind_ready_for_confirm": True, - "bind_confirmed": False, - "bind_phase": "confirm_failed", - "after_bind_info": after, - }, - ) - return - - self._apply_bind_info_to_account(account, final_info, "bind_confirmed") + final_role_name = after_snapshot["role_name"] or role_name + self._apply_bind_info_to_account(account, after, "bind_confirmed") self._mark_task( db, task, @@ -1275,7 +1320,7 @@ class DouyuBatchRunner: "before_bind_info": before, "confirm": confirm_result, "after_bind_info": after, - **final_snapshot, + **after_snapshot, "bind_ready_for_confirm": True, "bind_confirmed": True, "bind_phase": "confirmed", diff --git a/web/frontend/src/pages/DouyuTasksPage.tsx b/web/frontend/src/pages/DouyuTasksPage.tsx index 5e3a1a8..c30cef3 100644 --- a/web/frontend/src/pages/DouyuTasksPage.tsx +++ b/web/frontend/src/pages/DouyuTasksPage.tsx @@ -4,7 +4,7 @@ import { } from 'antd'; import type { TableProps } from 'antd'; import { - CheckCircleOutlined, CopyOutlined, CreditCardOutlined, FieldTimeOutlined, GiftOutlined, + CheckCircleOutlined, CopyOutlined, CreditCardOutlined, ExclamationCircleOutlined, FieldTimeOutlined, GiftOutlined, ImportOutlined, QrcodeOutlined, ReloadOutlined, SearchOutlined, SettingOutlined, ShoppingOutlined, StopOutlined, } from '@ant-design/icons'; @@ -116,6 +116,7 @@ const ESPORTS_TASK_TYPES = new Set([ ]); const DOUYU_GOLD_AMOUNT_STORAGE_KEY = 'douyu_task_gold_amount'; const DOUYU_GIFT_COUNT_STORAGE_KEY = 'douyu_task_gift_count'; +const CONFIRM_FAIL_TIP_SECONDS = 6; function resultText(result: Record | null | undefined, key: string): string { const value = result?.[key]; @@ -308,6 +309,9 @@ export default function DouyuTasksPage({ handbook }: { handbook: HandbookKind }) const autoOpenedEsportsBindTaskIds = useRef>(new Set()); const autoOpenedPayTaskIds = useRef>(new Set()); const autoCopiedExchangeTaskIds = useRef>(new Set()); + const autoNotifiedConfirmFailedTaskIds = useRef>(new Set()); + const [confirmFailTip, setConfirmFailTip] = useState<{ taskId: number; message: string } | null>(null); + const [confirmFailCountdown, setConfirmFailCountdown] = useState(0); const [exchangePreview, setExchangePreview] = useState<{ task: DouyuTaskItem; url: string } | null>(null); const autoOpenQrReady = useRef(false); const autoOpenEsportsBindReady = useRef(false); @@ -608,6 +612,40 @@ export default function DouyuTasksPage({ handbook }: { handbook: HandbookKind }) ); }, [visibleTasks]); + // 确认绑定失败自动弹提示(页面中央弹窗,自动消失,一次性去重) + useEffect(() => { + if (!autoOpenQrReady.current) return; + const failedTasks = visibleTasks + .filter((task) => ( + task.task_type === 'confirm_bind' + && ['failed', 'error'].includes(task.status) + && (task.message || '').includes('待绑定游戏账号') + && !autoNotifiedConfirmFailedTaskIds.current.has(task.id) + )) + .sort((a, b) => b.id - a.id); + if (failedTasks.length === 0) return; + for (const task of failedTasks) autoNotifiedConfirmFailedTaskIds.current.add(task.id); + const latest = failedTasks[0]; + setConfirmFailTip({ taskId: latest.id, message: latest.message }); + }, [visibleTasks]); + + // 弹窗倒计时自动关闭 + useEffect(() => { + if (!confirmFailTip) return; + setConfirmFailCountdown(CONFIRM_FAIL_TIP_SECONDS); + const timer = setInterval(() => { + setConfirmFailCountdown((cur) => { + if (cur <= 1) { + clearInterval(timer); + setConfirmFailTip(null); + return 0; + } + return cur - 1; + }); + }, 1000); + return () => clearInterval(timer); + }, [confirmFailTip]); + const copyExchangePreview = async () => { if (!exchangePreview) return; try { @@ -1891,6 +1929,27 @@ export default function DouyuTasksPage({ handbook }: { handbook: HandbookKind }) )} + + {/* 确认绑定失败提示 Modal(居中,自动消失) */} + 绑定失败提示} + open={Boolean(confirmFailTip)} + onCancel={() => setConfirmFailTip(null)} + footer={null} + centered + closable={false} + width={420} + > +
+ +
+ {confirmFailTip?.message || ''} +
+ + {confirmFailCountdown} 秒后自动关闭 + +
+
); }