diff --git a/core/douyu/activity_client.py b/core/douyu/activity_client.py index d49b79e..dfd37b0 100644 --- a/core/douyu/activity_client.py +++ b/core/douyu/activity_client.py @@ -41,6 +41,22 @@ class DouyuActivityClient: PAY_EXCHANGE_ORDER_API = "https://www.douyu.com/wgapi/ordnc/activity/peace/payExchangeOrder" EXCHANGE_ORDER_LIST_API = "https://www.douyu.com/wgapi/ordnc/activity/peace/getExchangeOrderList" EXCHANGE_LIST_API = "https://www.douyu.com/wgapi/ordnc/activity/peace/exchangeList" + # 2026-08-30 页面新增链路(对齐浏览器): + # 预兑 user/queryExchange -> user/confirmExchange;取消预兑 user/queryCancelExchange -> user/cancelExchange; + # 预约到货 user/sub / user/cancelSub;批量兑换 batchExchangeLimit;单步兑换 exchange;自动兑换开关。 + ELITE_USER_INFO_API = "https://www.douyu.com/wgapi/ordnc/activity/peace/getUserInfo" + GOODS_DETAIL_API = "https://www.douyu.com/wgapi/ordnc/activity/peace/storedetail" + EXCHANGE_GIFT_API = "https://www.douyu.com/wgapi/ordnc/activity/peace/exchange" + PRE_EXCHANGE_CHECK_API = "https://www.douyu.com/wgapi/ordnc/activity/peace/user/queryExchange" + PRE_EXCHANGE_CONFIRM_API = "https://www.douyu.com/wgapi/ordnc/activity/peace/user/confirmExchange" + PRE_EXCHANGE_CANCEL_CHECK_API = "https://www.douyu.com/wgapi/ordnc/activity/peace/user/queryCancelExchange" + PRE_EXCHANGE_CANCEL_API = "https://www.douyu.com/wgapi/ordnc/activity/peace/user/cancelExchange" + COMMODITY_SUBSCRIBE_API = "https://www.douyu.com/wgapi/ordnc/activity/peace/user/sub" + COMMODITY_UNSUBSCRIBE_API = "https://www.douyu.com/wgapi/ordnc/activity/peace/user/cancelSub" + BATCH_EXCHANGE_LIMIT_API = "https://www.douyu.com/wgapi/ordnc/activity/peace/batchExchangeLimit" + AUTO_CONVERT_QUERY_API = "https://www.douyu.com/wgapi/ordnc/activity/peace/activity/automaticallyConvert" + AUTO_CONVERT_SAVE_API = "https://www.douyu.com/wgapi/ordnc/activity/peace/activity/automaticallyConvertSave" + EXCHANGE_HOME_API = "https://www.douyu.com/wgapi/ordnc/activity/peace/activity/exchangeHome" CREDIT_BALANCE_API = "https://www.douyu.com/japi/oms/web/peace/credit/balance" CREDIT_DIFF_API = "https://www.douyu.com/japi/oms/web/peace/credit/diff" PEACE_ITEM_API = "https://www.douyu.com/japi/oms/web/peace/item" @@ -186,8 +202,18 @@ class DouyuActivityClient: def _request_json(self, method: str, url: str, source: str, **kwargs) -> dict[str, Any]: return self._json(self._request(method, url, source=source, **kwargs), source) - def csrf_token(self) -> str: - """获取 cvl_csrf_token。""" + def csrf_token(self, *, force_refresh: bool = False) -> str: + """获取 cvl_csrf_token;优先复用 Cookie 中已有值,与浏览器行为一致。 + + 2026-08-30 抓包实证:页面只读 Cookie `cvl_csrf_token` 的值作为 csrfToken + 复用(同一 token 跨多次接口、跨十几秒复用),Cookie 里没有/失效时才调用 + generateCsrf,生成的 token 写回 Cookie 继续复用。 + force_refresh=True 用于服务端提示 token 失效后的强制刷新。 + """ + if not force_refresh: + token = cookie_value(self.cookie, "cvl_csrf_token") + if token: + return token payload = self._request_json( "post", self.CSRF_API, @@ -205,6 +231,58 @@ class DouyuActivityClient: raise DouyuActivityError("响应 Cookie 中没有 cvl_csrf_token") return token + @staticmethod + def _is_csrf_error(payload: dict[str, Any]) -> bool: + """判断响应是否为 csrfToken 校验失败(此时应刷新并重试一次)。""" + return "csrf" in str(payload.get("msg") or "").lower() + + @staticmethod + def _peace_page_referer(rid: str = "") -> str: + """精英手册首页 Referer(对齐浏览器 getUserInfo 等接口)。""" + if rid: + return f"https://www.douyu.com/pages/live-peace-handbook/web?ditchname=pass0&roomId={rid}" + return "https://www.douyu.com/pages/live-peace-handbook/web?ditchname=pass0" + + @staticmethod + def _peace_shop_referer(rid: str = "") -> str: + """精英手册商城页 Referer(对齐浏览器 create/pay/storedetail 等接口)。""" + if rid: + return f"https://www.douyu.com/pages/live-peace-handbook/web/shop?ditchname=pass0&roomId={rid}" + return "https://www.douyu.com/pages/live-peace-handbook/web/shop?ditchname=pass0" + + def _post_csrf_form( + self, + url: str, + source: str, + fields: dict[str, Any], + referer: str, + ) -> dict[str, Any]: + """提交带 csrfToken 的 multipart 表单(对齐浏览器请求层)。 + + 浏览器 csrf 配置 {tvk: cvl_csrf_token, tn: csrfToken} 会把 Cookie 中的 + token 值拼进表单;这里同样复用 Cookie 值,仅在服务端提示 token 失效时 + 刷新一次后重试。 + """ + token = self.csrf_token() + payload = self._request_json( + "post", + url, + source, + files=self._multipart_fields({**fields, "csrfToken": token}), + headers={"Referer": referer}, + ) + if payload.get("error") not in (0, "0") and self._is_csrf_error(payload): + self.logger(f"{source}: csrfToken 校验失败,刷新后重试一次") + token = self.csrf_token(force_refresh=True) + payload = self._request_json( + "post", + url, + source, + files=self._multipart_fields({**fields, "csrfToken": token}), + headers={"Referer": referer}, + ) + return payload + def acf_ccn(self, *, refresh_subscribe: bool = True) -> str: """获取 acf_ccn;必要时通过订阅接口刷新一次。""" response = self._request( @@ -797,21 +875,16 @@ class DouyuActivityClient: """Lock stock for an activity commodity before spending points.""" if num < 1: raise DouyuActivityError("锁单数量必须大于 0") - token = self.csrf_token() - payload = self._request_json( - "post", + payload = self._post_csrf_form( self.CREATE_EXCHANGE_ORDER_API, "锁定兑换商品", - files=self._multipart_fields({ + { "manualID": manual_id, "rid": rid, "commodityID": commodity_id, "num": num, - "csrfToken": token, - }), - headers={ - "Referer": f"https://www.douyu.com/pages/live-peace-handbook/web/shop?ditchname=pass0&roomId={rid}", }, + referer=self._peace_shop_referer(rid), ) if payload.get("error") not in (0, "0"): raise DouyuActivityError(payload.get("msg") or "锁定兑换商品失败") @@ -838,28 +911,22 @@ class DouyuActivityClient: "expire_seconds": data.get("expireSeconds"), "locked_at": locked_at, "expires_at": expires_at, - "csrf_token": token, "raw": payload, } - def pay_exchange_order(self, *, manual_id: str, order_id: str) -> dict[str, Any]: + def pay_exchange_order(self, *, manual_id: str, order_id: str, rid: str = "") -> dict[str, Any]: """Pay a previously locked exchange order with handbook points.""" normalized_order_id = str(order_id).strip() if not normalized_order_id: raise DouyuActivityError("锁单订单号不能为空") - token = self.csrf_token() - payload = self._request_json( - "post", + payload = self._post_csrf_form( self.PAY_EXCHANGE_ORDER_API, "支付锁单", - files=self._multipart_fields({ + { "orderID": normalized_order_id, "manualID": manual_id, - "csrfToken": token, - }), - headers={ - "Referer": "https://www.douyu.com/pages/live-peace-handbook/web/shop?ditchname=pass0", }, + referer=self._peace_shop_referer(rid), ) if payload.get("error") not in (0, "0"): raise DouyuActivityError(payload.get("msg") or "支付锁单失败") @@ -872,7 +939,6 @@ class DouyuActivityClient: "exchange_num": data.get("exchangeNum"), "s_type": data.get("sType"), "g_type": data.get("gType"), - "csrf_token": token, "raw": payload, } @@ -916,6 +982,277 @@ class DouyuActivityClient: "payment": paid, } + def elite_user_info(self, *, manual_id: str, rid: str = "") -> dict[str, Any]: + """查询精英手册状态(对齐页面 getUserInfo,含 manualType/manualScore)。 + + 浏览器每次进入手册页都会先请求该接口,用于判断商品是否"精英专享"、 + 是否已达兑换上限等状态;兑换前先查一次与页面行为一致。 + """ + payload = self._request_json( + "get", + self.ELITE_USER_INFO_API, + "查询手册状态", + params={"manualID": manual_id}, + headers={"Origin": "", "Referer": self._peace_page_referer(rid)}, + ) + if payload.get("error") not in (0, "0", None): + raise DouyuActivityError(payload.get("msg") or "查询手册状态失败") + user_info = (payload.get("data") or {}).get("userInfo") or {} + return { + "manual_id": manual_id, + "manual_type": user_info.get("manualType"), + "manual_score": user_info.get("manualScore"), + "expire_time": user_info.get("expireTime"), + "user_info": user_info, + "raw": payload, + } + + def query_goods_detail(self, *, manual_id: str, commodity_id: str, rid: str = "") -> dict[str, Any]: + """查询商品详情 storedetail(对齐浏览器 getGoodsDetail)。 + + 详情里带有兑换状态机所需的全部标记:preExchange.isExists / subscribeType / + exchangeInfo.userStatus / batchExchange / roomLimited / storeStatus 等。 + """ + payload = self._request_json( + "get", + self.GOODS_DETAIL_API, + "查询商品详情", + params={"manualID": manual_id, "commodityID": commodity_id, "rid": rid or "0"}, + headers={"Origin": "", "Referer": self._peace_shop_referer(rid)}, + ) + if payload.get("error") not in (0, "0"): + raise DouyuActivityError(payload.get("msg") or "查询商品详情失败") + return {"detail": payload.get("data") or {}, "raw": payload} + + @staticmethod + def resolve_exchange_plan( + detail: dict[str, Any], + *, + manual_type: Any = None, + batch_num_limit: Any = None, + ) -> dict[str, Any]: + """对齐浏览器兑换按钮状态机($e / Ve / Ge)给出自动化兑换计划。 + + 浏览器判定顺序(2026-08-30 bundle index.4872272b.js / index.0539fbf8.js): + 存在预扣订单 -> 已达兑换上限 -> 无库存 -> 限定房间 -> 活动未开启(预约/预兑) -> + 精英专享未开通 -> 已预兑等待开放 -> 立即兑换(经典锁单/支付)。 + action: + classic 经典链路 createExchangeOrder -> payExchangeOrder(批量商品 num 受上限约束) + pre_exchange 预兑商品(subscribeType=2 且未开启):user/queryExchange -> user/confirmExchange + subscribe 预约到货商品(subscribeType=1 且未开启):user/sub + wait 已预兑(userStatus=1)但预扣订单尚未生成,等待开放后重试 + blocked 明确不可兑换,text 为原因(文案对齐浏览器/服务端) + """ + detail = detail or {} + pre = detail.get("preExchange") or {} + pre_is_exists = int(pre.get("isExists") or 0) + exchange_info = detail.get("exchangeInfo") or {} + user_status = int(exchange_info.get("userStatus") or 0) + subscribe_type = int(detail.get("subscribeType") or 0) + status = int(detail.get("status") or 0) + store_status = int(detail.get("storeStatus") or 0) + open_status = int(detail.get("openStatus") or 0) + unlimited = ( + int(detail.get("unlimitedMode") or 0) == 1 + and int(detail.get("unlimitedStatus") or 0) == 1 + ) + room_limited = int(detail.get("roomLimited") or 0) == 1 + is_limit_room = int(detail.get("isLimitRoom") or 0) == 1 + elite_limited = int(detail.get("eliteLimited") or 0) == 1 + batch_exchange = int(detail.get("batchExchange") or 0) > 0 + max_num = 1 + if batch_exchange and batch_num_limit is not None: + try: + max_num = max(1, int(batch_num_limit or 1)) + except (TypeError, ValueError): + max_num = 1 + + plan: dict[str, Any] = { + "action": "classic", + "text": "", + "max_num": max_num, + "batch_exchange": batch_exchange, + "pre_exchange": bool(pre_is_exists), + "subscribe_type": subscribe_type, + "user_status": user_status, + } + if pre_is_exists: + plan.update(action="classic", text="存在预扣订单,继续兑换") + elif status == 1 or (subscribe_type == 2 and user_status == 2): + plan.update(action="blocked", text="已达兑换上限") + elif store_status == 0 and not unlimited: + plan.update(action="blocked", text="无库存") + elif room_limited and not is_limit_room: + plan.update(action="blocked", text="限定房间兑换,请前往指定直播间") + elif open_status == 0: + if subscribe_type == 1: + plan.update(action="subscribe", text="活动未开启,预约到货") + elif subscribe_type == 2: + plan.update(action="pre_exchange", text="活动未开启,预兑") + else: + plan.update(action="blocked", text="活动未开启") + elif elite_limited and manual_type is not None and int(manual_type or 0) != 1: + plan.update(action="blocked", text="需要开通精英手册,才可以兑换本道具") + elif subscribe_type == 2 and user_status == 1: + plan.update(action="wait", text="已预兑,等待开放后继续兑换") + else: + plan.update(action="classic", text="立即兑换") + return plan + + def batch_exchange_limit(self, *, manual_id: str, commodity_id: str, rid: str = "") -> dict[str, Any]: + """查询批量兑换上限(对齐页面 batchExchangeLimit,仅 batchExchange=1 商品)。""" + payload = self._post_csrf_form( + self.BATCH_EXCHANGE_LIMIT_API, + "查询批量兑换上限", + {"manualID": manual_id, "commodityID": commodity_id, "rid": rid or "0"}, + referer=self._peace_shop_referer(rid), + ) + if payload.get("error") not in (0, "0"): + raise DouyuActivityError(payload.get("msg") or "查询批量兑换上限失败") + data = payload.get("data") or {} + return {"limit": data.get("limit"), "raw": payload} + + def pre_exchange_check(self, *, manual_id: str, commodity_id: str) -> dict[str, Any]: + """查询预兑状态(对齐页面 user/queryExchange,预兑弹窗前必查)。""" + payload = self._request_json( + "get", + self.PRE_EXCHANGE_CHECK_API, + "查询预兑状态", + params={"manualID": manual_id, "commodityID": commodity_id}, + headers={"Origin": "", "Referer": self._peace_shop_referer()}, + ) + if payload.get("error") not in (0, "0"): + raise DouyuActivityError(payload.get("msg") or "查询预兑状态失败") + return {"data": payload.get("data") or {}, "raw": payload} + + def pre_exchange_cancel_check(self, *, manual_id: str, commodity_id: str) -> dict[str, Any]: + """查询取消预兑信息(对齐页面 user/queryCancelExchange)。""" + payload = self._request_json( + "get", + self.PRE_EXCHANGE_CANCEL_CHECK_API, + "查询取消预兑信息", + params={"manualID": manual_id, "commodityID": commodity_id}, + headers={"Origin": "", "Referer": self._peace_shop_referer()}, + ) + if payload.get("error") not in (0, "0"): + raise DouyuActivityError(payload.get("msg") or "查询取消预兑信息失败") + return {"data": payload.get("data") or {}, "raw": payload} + + def confirm_pre_exchange(self, *, manual_id: str, commodity_id: str, rid: str = "") -> dict[str, Any]: + """确认预兑(对齐页面 user/confirmExchange,成功提示"预兑成功")。""" + payload = self._post_csrf_form( + self.PRE_EXCHANGE_CONFIRM_API, + "确认预兑", + {"manualID": manual_id, "commodityID": commodity_id, "rid": rid or "0"}, + referer=self._peace_shop_referer(rid), + ) + if payload.get("error") not in (0, "0"): + raise DouyuActivityError(payload.get("msg") or "确认预兑失败") + return {"raw": payload} + + def cancel_pre_exchange(self, *, manual_id: str, commodity_id: str, rid: str = "") -> dict[str, Any]: + """取消预兑(对齐页面 user/cancelExchange)。""" + payload = self._post_csrf_form( + self.PRE_EXCHANGE_CANCEL_API, + "取消预兑", + {"manualID": manual_id, "commodityID": commodity_id, "rid": rid or "0"}, + referer=self._peace_shop_referer(rid), + ) + if payload.get("error") not in (0, "0"): + raise DouyuActivityError(payload.get("msg") or "取消预兑失败") + return {"raw": payload} + + def subscribe_commodity(self, *, manual_id: str, commodity_id: str) -> dict[str, Any]: + """预约到货(对齐页面 user/sub,成功提示"预约成功")。""" + payload = self._post_csrf_form( + self.COMMODITY_SUBSCRIBE_API, + "预约到货", + {"manualID": manual_id, "commodityID": commodity_id}, + referer=self._peace_shop_referer(), + ) + if payload.get("error") not in (0, "0"): + raise DouyuActivityError(payload.get("msg") or "预约到货失败") + return {"raw": payload} + + def unsubscribe_commodity(self, *, manual_id: str, commodity_id: str) -> dict[str, Any]: + """取消预约到货(对齐页面 user/cancelSub)。""" + payload = self._post_csrf_form( + self.COMMODITY_UNSUBSCRIBE_API, + "取消预约到货", + {"manualID": manual_id, "commodityID": commodity_id}, + referer=self._peace_shop_referer(), + ) + if payload.get("error") not in (0, "0"): + raise DouyuActivityError(payload.get("msg") or "取消预约到货失败") + return {"raw": payload} + + def exchange_gift(self, *, manual_id: str, commodity_id: str, rid: str = "", num: int = 1) -> dict[str, Any]: + """单步兑换(对齐页面 postExchangeGift -> /ordnc/activity/peace/exchange)。 + + 2026-08-30 bundle 中新链路,页面主要用于兑换中心/自动兑换场景;字段按页面 + 调用({manualID, commodityID, num, rid} + csrfToken)对齐。 + """ + payload = self._post_csrf_form( + self.EXCHANGE_GIFT_API, + "单步兑换", + { + "manualID": manual_id, + "commodityID": commodity_id, + "num": max(1, int(num)), + "rid": rid or "0", + }, + referer=self._peace_shop_referer(rid), + ) + if payload.get("error") not in (0, "0"): + raise DouyuActivityError(payload.get("msg") or "兑换失败") + data = payload.get("data") or {} + return { + "exchange_id": str(data.get("exchangeId") or ""), + "commodity_type": data.get("commodityType"), + "commodity_image": data.get("commodityImage") or "", + "exchange_num": data.get("exchangeNum"), + "s_type": data.get("sType"), + "g_type": data.get("gType"), + "raw": payload, + } + + def auto_convert_switch(self, *, manual_id: str) -> dict[str, Any]: + """查询自动兑换开关(对齐页面 activity/automaticallyConvert)。""" + payload = self._request_json( + "get", + self.AUTO_CONVERT_QUERY_API, + "查询自动兑换开关", + params={"manualID": manual_id}, + headers={"Origin": "", "Referer": self._peace_shop_referer()}, + ) + if payload.get("error") not in (0, "0"): + raise DouyuActivityError(payload.get("msg") or "查询自动兑换开关失败") + return {"data": payload.get("data") or {}, "raw": payload} + + def set_auto_convert(self, *, manual_id: str, switch: Any) -> dict[str, Any]: + """设置自动兑换开关(对齐页面 automaticallyConvertSave {manualID, switch})。""" + payload = self._post_csrf_form( + self.AUTO_CONVERT_SAVE_API, + "设置自动兑换开关", + {"manualID": manual_id, "switch": switch}, + referer=self._peace_shop_referer(), + ) + if payload.get("error") not in (0, "0"): + raise DouyuActivityError(payload.get("msg") or "设置自动兑换开关失败") + return {"raw": payload} + + def exchange_home_info(self) -> dict[str, Any]: + """查询兑换中心信息(对齐页面 activity/exchangeHome)。""" + payload = self._request_json( + "get", + self.EXCHANGE_HOME_API, + "查询兑换中心", + headers={"Origin": "", "Referer": self._peace_shop_referer()}, + ) + if payload.get("error") not in (0, "0"): + raise DouyuActivityError(payload.get("msg") or "查询兑换中心失败") + return {"data": payload.get("data") or {}, "raw": payload} + def gold_account(self) -> dict[str, Any]: """查询 cz 侧鱼翅余额。""" payload = self._request_json( diff --git a/tests/test_douyu_elite_exchange_flow.py b/tests/test_douyu_elite_exchange_flow.py new file mode 100644 index 0000000..29b5bb7 --- /dev/null +++ b/tests/test_douyu_elite_exchange_flow.py @@ -0,0 +1,163 @@ +"""精英手册兑换:csrf 复用、新链路接口与浏览器状态机路由的测试。""" + +import unittest +from unittest.mock import Mock, call + +from core.douyu.activity_client import DouyuActivityClient, DouyuActivityError + + +class CsrfReuseTests(unittest.TestCase): + def test_reuses_cookie_token_without_any_http_call(self): + client = DouyuActivityClient("acf_uid=100; cvl_csrf_token=existing-token") + client._request_json = Mock() + + self.assertEqual(client.csrf_token(), "existing-token") + client._request_json.assert_not_called() + + def test_generates_csrf_when_cookie_missing(self): + client = DouyuActivityClient("acf_uid=100") + + def fake_generate(*_args, **_kwargs): + # 模拟 generateCsrf 响应把新 token 写回 Cookie + client._set_cookie_value("cvl_csrf_token", "new-token") + return {"error": 0} + + client._request_json = Mock(side_effect=fake_generate) + + self.assertEqual(client.csrf_token(), "new-token") + client._request_json.assert_called_once() + + def test_force_refresh_rotates_token(self): + client = DouyuActivityClient("acf_uid=100; cvl_csrf_token=old-token") + client._request_json = Mock(return_value={"error": 0}) + client._set_cookie_value("cvl_csrf_token", "rotated-token") + + self.assertEqual(client.csrf_token(force_refresh=True), "rotated-token") + + def test_refreshes_csrf_once_on_token_error(self): + client = DouyuActivityClient("acf_uid=100") + client.csrf_token = Mock(side_effect=["bad-token", "good-token"]) + client._request_json = Mock(side_effect=[ + {"error": 1, "msg": "csrfToken 校验失败"}, + {"error": 0, "data": {"orderId": "9", "expireSeconds": "300"}}, + ]) + + result = client.create_exchange_order(manual_id="m", rid="r", commodity_id="c") + + self.assertEqual(result["order_id"], "9") + self.assertEqual(client.csrf_token.call_args_list, [call(), call(force_refresh=True)]) + + def test_no_refresh_on_regular_error(self): + client = DouyuActivityClient("acf_uid=100") + client.csrf_token = Mock(return_value="t") + client._request_json = Mock(return_value={"error": 1, "msg": "需要开通精英手册,才可以兑换本道具哦"}) + + with self.assertRaisesRegex(DouyuActivityError, "需要开通精英手册"): + client.create_exchange_order(manual_id="m", rid="r", commodity_id="c") + client.csrf_token.assert_called_once_with() + + def test_pay_referer_includes_room_id_like_browser(self): + client = DouyuActivityClient("acf_uid=100") + client.csrf_token = Mock(return_value="t") + client._request_json = Mock(return_value={"error": 0, "data": {"exchangeId": "11098933"}}) + + client.pay_exchange_order(manual_id="m", order_id="5874", rid="9263298") + + headers = client._request_json.call_args.kwargs["headers"] + self.assertEqual( + headers["Referer"], + "https://www.douyu.com/pages/live-peace-handbook/web/shop?ditchname=pass0&roomId=9263298", + ) + + +class ExchangePlanTests(unittest.TestCase): + """对齐浏览器兑换按钮状态机($e / Ve / Ge)的路由判定。""" + + @staticmethod + def detail(**overrides): + base = { + "status": 0, + "storeStatus": 1, + "openStatus": 1, + "roomLimited": 0, + "isLimitRoom": 0, + "eliteLimited": 0, + "subscribeType": 0, + "exchangeInfo": {"userStatus": 0}, + "preExchange": {"isExists": 0, "expiringTime": "0"}, + "batchExchange": 0, + } + base.update(overrides) + return base + + def test_normal_item_uses_classic_flow(self): + plan = DouyuActivityClient.resolve_exchange_plan(self.detail()) + self.assertEqual(plan["action"], "classic") + self.assertEqual(plan["max_num"], 1) + + def test_pre_exchange_order_still_classic(self): + plan = DouyuActivityClient.resolve_exchange_plan(self.detail(preExchange={"isExists": 1})) + self.assertEqual(plan["action"], "classic") + + def test_reached_exchange_limit_blocked(self): + plan = DouyuActivityClient.resolve_exchange_plan(self.detail(status=1)) + self.assertEqual(plan["action"], "blocked") + self.assertIn("上限", plan["text"]) + + def test_no_stock_blocked(self): + plan = DouyuActivityClient.resolve_exchange_plan(self.detail(storeStatus=0)) + self.assertEqual(plan["action"], "blocked") + self.assertIn("无库存", plan["text"]) + + def test_room_limited_and_not_in_room_blocked(self): + plan = DouyuActivityClient.resolve_exchange_plan( + self.detail(roomLimited=1, isLimitRoom=0) + ) + self.assertEqual(plan["action"], "blocked") + self.assertIn("限定房间", plan["text"]) + + def test_not_open_subscribe_item_subscribes(self): + plan = DouyuActivityClient.resolve_exchange_plan( + self.detail(openStatus=0, subscribeType=1) + ) + self.assertEqual(plan["action"], "subscribe") + + def test_not_open_pre_exchange_item_pre_exchanges(self): + plan = DouyuActivityClient.resolve_exchange_plan( + self.detail(openStatus=0, subscribeType=2) + ) + self.assertEqual(plan["action"], "pre_exchange") + + def test_not_open_plain_item_blocked(self): + plan = DouyuActivityClient.resolve_exchange_plan(self.detail(openStatus=0)) + self.assertEqual(plan["action"], "blocked") + self.assertIn("未开启", plan["text"]) + + def test_elite_limited_requires_manual(self): + plan = DouyuActivityClient.resolve_exchange_plan( + self.detail(eliteLimited=1), manual_type=0 + ) + self.assertEqual(plan["action"], "blocked") + self.assertIn("精英手册", plan["text"]) + # 已开通手册(manualType=1)不受精英专享限制 + plan = DouyuActivityClient.resolve_exchange_plan( + self.detail(eliteLimited=1), manual_type=1 + ) + self.assertEqual(plan["action"], "classic") + + def test_pre_exchanged_waits_for_open(self): + plan = DouyuActivityClient.resolve_exchange_plan( + self.detail(subscribeType=2, exchangeInfo={"userStatus": 1}) + ) + self.assertEqual(plan["action"], "wait") + + def test_batch_exchange_clamps_num(self): + plan = DouyuActivityClient.resolve_exchange_plan( + self.detail(batchExchange=1), batch_num_limit="5" + ) + self.assertEqual(plan["max_num"], 5) + self.assertTrue(plan["batch_exchange"]) + + +if __name__ == "__main__": + unittest.main() \ No newline at end of file diff --git a/tests/test_douyu_gold_recharge_channel.py b/tests/test_douyu_gold_recharge_channel.py index 6e34ee4..9c81416 100644 --- a/tests/test_douyu_gold_recharge_channel.py +++ b/tests/test_douyu_gold_recharge_channel.py @@ -54,7 +54,7 @@ class DouyuGoldRechargeChannelTests(unittest.TestCase): Base.metadata.drop_all(self.engine) self.engine.dispose() - @patch("web.backend.services.douyu_runner.FishFinRechargeClient") + @patch("web.backend.services.douyu_runner_gold.FishFinRechargeClient") def test_supplier_channel_creates_order_using_nickname_and_finishes_on_success(self, client_class): supplier = Mock() supplier.create_order.return_value = { @@ -99,7 +99,7 @@ class DouyuGoldRechargeChannelTests(unittest.TestCase): self.assertNotIn("pay_url", self.task.result) self.assertNotIn("sign", self.task.result["supplier_order"]) - @patch("web.backend.services.douyu_runner.FishFinRechargeClient") + @patch("web.backend.services.douyu_runner_gold.FishFinRechargeClient") def test_supplier_channel_rejects_account_without_nickname(self, client_class): self.account.nickname = "" self.session.commit() @@ -157,7 +157,7 @@ class DouyuGoldRechargeChannelTests(unittest.TestCase): self.assertIn("10001", audit.detail) self.assertNotIn("password", audit.detail) - @patch("web.backend.services.douyu_runner.FishFinRechargeClient") + @patch("web.backend.services.douyu_runner_gold.FishFinRechargeClient") def test_supplier_channel_marks_non_200_create_response_as_failed(self, client_class): supplier = Mock() supplier.create_order.return_value = {"code": 400, "msg": "商品已下架"} diff --git a/web/backend/services/douyu_runner.py b/web/backend/services/douyu_runner.py index 17d5ebc..c7d0731 100644 --- a/web/backend/services/douyu_runner.py +++ b/web/backend/services/douyu_runner.py @@ -1,3145 +1,29 @@ -"""斗鱼活动任务批次执行器。""" +"""斗鱼活动任务批次执行器(入口聚合;功能域已拆分到 douyu_runner_*.py)。""" from __future__ import annotations -import asyncio -import re -import threading -import time -from decimal import Decimal from concurrent.futures import ThreadPoolExecutor, as_completed -from datetime import datetime, timezone -from typing import Optional -from loguru import logger -from sqlalchemy.orm import Session, joinedload - -from core.douyu import ( - DouyuActivityClient, - DouyuActivityError, - FishFinRechargeClient, - FishFinRechargeConfig, - FishFinRechargeError, -) +from sqlalchemy.orm import joinedload +from core.douyu import DouyuActivityError from ..database import SessionLocal -from ..models import Account, DouyuEsportsGoodsSnapshot, DouyuGoodsSnapshot, DouyuTask, DouyuXpdGoodsSnapshot -from .douyu_service import ( - DOUYU_CONFIG_FIELDS, - account_uid, - douyu_config_value, - ensure_douyu_config, - latest_success_login_task, - douyu_task_payload, - update_account_profile_from_cookie, -) +from ..models import DouyuTask from .cookie_check_service import check_douyu_cookie - - -DOUYU_LEGACY_BIND_ACT_ALIAS = "20250213NQCYX" -DOUYU_BIND_ROLE_POLL_SECONDS = 65 -DOUYU_BIND_ROLE_POLL_INTERVAL = 5 -DOUYU_XPD_BIND_POLL_SECONDS = 300 -DOUYU_XPD_BIND_POLL_INTERVAL = 5 -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: - """批量执行斗鱼活动任务,通过队列推送实时日志。""" - - def __init__( - self, - db: Session, - batch_id: str, - task_type: str, - payload: Optional[dict] = None, - log_queue: Optional[asyncio.Queue] = None, - loop: Optional[asyncio.AbstractEventLoop] = None, - concurrency: int = 3, - ): - self.db = db - self.batch_id = batch_id - self.task_type = task_type - self.payload = payload or {} - self.log_queue = log_queue - self.loop = loop - self.concurrency = max(1, min(concurrency, 10)) - self._stop = threading.Event() - self._counter_lock = threading.Lock() - self._started = 0 - - def stop(self): - self._stop.set() - - def _push_log(self, level: str, message: str): - if level == "result": - try: - douyu_batch_registry.mark_finished(self.batch_id) - except NameError: - pass - if level != "result" and message: - log_func = getattr(logger, level, logger.info) - log_func(f"[douyu] {message}") - if self.log_queue and self.loop: - asyncio.run_coroutine_threadsafe( - self.log_queue.put({"level": level, "message": message}), - self.loop, - ) - - @staticmethod - def _account_name(account: Account) -> str: - return account.nickname or account.username or account.uid or f"#{account.id}" - - @staticmethod - def _to_int(value) -> int | None: - if value is None: - return None - try: - return int(value) - except (TypeError, ValueError): - return None - - @staticmethod - def _format_wait_time(seconds: int | None) -> str: - if seconds is None: - return "" - seconds = max(0, int(seconds)) - days, rem = divmod(seconds, 86400) - hours, rem = divmod(rem, 3600) - minutes, sec = divmod(rem, 60) - if days: - return f"{days}天{hours}小时{minutes}分" - if hours: - return f"{hours}小时{minutes}分{sec}秒" - return f"{minutes}分{sec}秒" - - @staticmethod - def _action_act_alias(config: dict, key: str) -> str: - """动作类接口用的活动 alias,排除只用于查询最新角色的 legacy alias。""" - alias = str(config.get(key) or "").strip() - query_only_alias = str(config.get("legacy_act_alias") or "").strip() - if not alias: - return "" - if alias in {query_only_alias, DOUYU_LEGACY_BIND_ACT_ALIAS}: - return "" - return alias - - @classmethod - def _bind_qr_act_alias(cls, config: dict) -> str: - """生成绑定二维码用的活动 alias。""" - return cls._action_act_alias(config, "bind_act_alias") or cls._action_act_alias(config, "confirm_act_alias") - - @staticmethod - def _query_bind_act_aliases(config: dict) -> list[str]: - """查询/轮询角色用的 alias 列表。 - - 现网最新绑定信息在 legacy(cjm);活动 alias 可能仍用于扫码/确认, - 所以按优先级去重返回多个,轮询时取“更像新扫码结果”的那个。 - """ - ordered = [ - str(config.get("legacy_act_alias") or "").strip(), - str(config.get("confirm_act_alias") or "").strip(), - str(config.get("bind_act_alias") or "").strip(), - ] - aliases: list[str] = [] - for alias in ordered: - if alias and alias not in aliases: - aliases.append(alias) - return aliases - - @classmethod - def _confirm_act_alias(cls, config: dict) -> str: - """确认绑定接口用的活动 alias。""" - return cls._action_act_alias(config, "confirm_act_alias") or cls._action_act_alias(config, "bind_act_alias") - - # 兼容旧调用名 - @classmethod - 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( - part for part in [bind_info.get("area_name"), bind_info.get("plat_name")] if part - ) - - @staticmethod - def _is_truthy_flag(value) -> bool: - if value is True: - return True - if value is False or value is None: - return False - text = str(value).strip().lower() - return text in {"1", "true", "yes", "y"} - - @classmethod - def _is_bound_act(cls, bind_info: dict | None) -> bool: - if not bind_info: - return False - return cls._is_truthy_flag(bind_info.get("is_bound_act")) - - @classmethod - def _can_change_role(cls, bind_info: dict | None) -> bool: - """综合 can_change_role 与换绑倒计时判断是否允许换绑。""" - if not bind_info: - return True - if str(bind_info.get("api_version") or "") == "esports": - can_change_time = cls._to_int(bind_info.get("can_change_time")) - if can_change_time is not None: - return can_change_time <= int(datetime.now(timezone.utc).timestamp()) - wait_time = cls._to_int(bind_info.get("change_role_wait_time")) - if wait_time is not None and wait_time > 0: - return False - if bind_info.get("can_change_role") is not None: - return cls._is_truthy_flag(bind_info.get("can_change_role")) - # 已绑定但接口没给倒计时/开关时,默认允许(避免误杀首次绑定) - return True - - @classmethod - def _is_change_cooling(cls, bind_info: dict | None) -> bool: - """是否处于换绑冷却:已有绑定角色且当前不可换绑。""" - if not bind_info: - return False - role_name = str(bind_info.get("role_name") or "").strip() - if not role_name or not cls._is_bound_act(bind_info): - return False - return not cls._can_change_role(bind_info) - - @classmethod - def _is_pending_role( - cls, - bind_info: dict | None, - *, - baseline_role_name: str = "", - baseline_is_bound_act: bool = False, - ) -> bool: - """判断是否出现了可确认的扫码角色。 - - 规则: - 1. 必须有角色名 - 2. 已绑定同一角色(或无 baseline 的已绑定)不算 pending - 3. 角色名相对 baseline 变化,或从已绑定变成待确认,算 pending - 4. need_bind_act / need_bind_role 且角色相对 baseline 有变化,算 pending - 5. 无 baseline 且未绑定但有角色,视为待确认残留 - """ - if not bind_info: - return False - role_name = str(bind_info.get("role_name") or "").strip() - if not role_name: - return False - is_bound_act = cls._is_bound_act(bind_info) - need_bind_act = cls._is_truthy_flag(bind_info.get("need_bind_act")) - need_bind_role = cls._is_truthy_flag(bind_info.get("need_bind_role")) - role_changed = bool(baseline_role_name) and role_name != baseline_role_name - if is_bound_act: - # 已绑定:仅当相对 baseline 角色发生变化时才视为新扫码结果 - return role_changed - if need_bind_act or need_bind_role: - if not baseline_role_name: - return True - return role_changed or baseline_is_bound_act - if not baseline_role_name: - return True - if role_changed: - return True - # 同一角色从已绑定变为未绑定 - return baseline_is_bound_act - - def _bind_snapshot(self, bind_info: dict | None) -> dict: - info = bind_info or {} - role_name = str(info.get("role_name") or "") - return { - "role_name": role_name, - "area_name": info.get("area_name") or "", - "plat_name": info.get("plat_name") or "", - "nickname": info.get("nickname") or "", - "is_bound_act": self._is_bound_act(info), - "is_bound_role": self._is_truthy_flag(info.get("is_bound_role")), - "is_bound_account": self._is_truthy_flag(info.get("is_bound_account")), - "need_bind_act": self._is_truthy_flag(info.get("need_bind_act")), - "need_bind_role": self._is_truthy_flag(info.get("need_bind_role")), - "change_role_wait_time": self._to_int(info.get("change_role_wait_time")), - "can_change_time": self._to_int(info.get("can_change_time")), - "can_change_role": self._can_change_role(info), - "bind_info": info, - } - - def _format_bind_summary(self, bind_info: dict | None, *, pending: bool | None = None) -> str: - snap = self._bind_snapshot(bind_info) - role = snap["role_name"] or "-" - area = snap["area_name"] or "-" - plat = snap["plat_name"] or "-" - pending_text = "" - if pending is not None: - pending_text = f" pending={1 if pending else 0}" - return ( - f"role={role} area={area} plat={plat}" - f" bound_act={1 if snap['is_bound_act'] else 0}" - f" bound_role={1 if snap['is_bound_role'] else 0}" - f" need_act={1 if snap['need_bind_act'] else 0}" - 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}" - ) - - def _apply_bind_info_to_account(self, account: Account, bind_info: dict, status: str) -> None: - role_name = str(bind_info.get("role_name") or "") - account.game_name = role_name or account.game_name - account.game_channel = self._role_channel(bind_info) or account.game_channel - account.change_role_wait_time = self._to_int(bind_info.get("change_role_wait_time")) - account.bind_status = status - account.updated_at = datetime.now(timezone.utc) - - def _apply_esports_bind_info_to_account(self, account: Account, bind_info: dict, status: str) -> None: - """将电竞手册角色状态写入专属字段,避免覆盖精英宝典数据。""" - role_name = str(bind_info.get("role_name") or "") - account.esports_game_name = role_name or account.esports_game_name - account.esports_game_channel = self._role_channel(bind_info) or account.esports_game_channel - account.esports_change_role_wait_time = self._to_int(bind_info.get("change_role_wait_time")) - account.esports_can_change_time = self._to_int(bind_info.get("can_change_time")) - account.esports_bind_status = status - account.updated_at = datetime.now(timezone.utc) - - def _esports_bind_state( - self, - client: DouyuActivityClient, - act_alias: str, - ) -> dict: - """查询电竞手册活动状态,接口同时返回当前角色和换绑冷却信息。""" - activity_info = client.esports_bind_info(act_alias) - activity_snapshot = self._bind_snapshot(activity_info) - esports_bound = activity_snapshot["is_bound_act"] - has_selected_role = bool(activity_snapshot["role_name"]) - tx_act = activity_info.get("tx_act") or {} - return { - **activity_snapshot, - "is_bound_role": has_selected_role, - "act_alias": act_alias, - "game_id": tx_act.get("gameId") or "", - "esports_bound": esports_bound, - "has_selected_role": has_selected_role, - "bind_ready_for_confirm": has_selected_role and not esports_bound, - "bind_confirmed": esports_bound, - "bind_phase": "confirmed" if esports_bound else ("role_ready" if has_selected_role else "waiting_role"), - "activity_bind_info": activity_info, - "activity_bind_snapshot": activity_snapshot, - "role_source": "activity", - } - - @staticmethod - def _esports_role_text(state: dict) -> str: - role_name = str(state.get("role_name") or "") - channel = " / ".join( - str(part) - for part in [state.get("plat_name"), state.get("area_name")] - if part - ) - if not role_name: - return "" - return f"{role_name}({channel})" if channel else role_name - - def _push_task_event(self, task: DouyuTask) -> None: - """向批次 WS 推送任务状态事件(level=task),前端即时更新不依赖轮询。""" - if not self.log_queue or not self.loop: - return - try: - payload = douyu_task_payload(task) - except Exception: - logger.exception("[douyu] 推送任务状态失败: task_id={}", task.id) - return - event = { - "level": "task", - "message": "", - "task": payload, - } - asyncio.run_coroutine_threadsafe(self.log_queue.put(event), self.loop) - - def _mark_task( - self, - db: Session, - task: DouyuTask, - status: str, - message: str, - result: dict | None = None, - ) -> None: - task.status = status - task.message = message[:512] - if result is not None: - task.result = result - task.finished_at = datetime.now(timezone.utc) - db.commit() - self._push_task_event(task) - - def _update_task_progress( - self, - db: Session, - task: DouyuTask, - status: str, - message: str, - result: dict | None = None, - ) -> None: - task.status = status - task.message = message[:512] - if result is not None: - task.result = result - db.commit() - self._push_task_event(task) - - def _upsert_goods(self, db: Session, goods: list[dict]) -> None: - now = datetime.now(timezone.utc) - for raw in goods: - commodity_id = str(raw.get("commodityId") or raw.get("commodity_id") or "") - if not commodity_id: - continue - row = ( - db.query(DouyuGoodsSnapshot) - .filter(DouyuGoodsSnapshot.commodity_id == commodity_id) - .first() - ) - score = self._to_int(raw.get("score")) - if row is None: - row = DouyuGoodsSnapshot(commodity_id=commodity_id) - db.add(row) - row.name = str(raw.get("commodityName") or raw.get("name") or "") - row.score = score - row.status = str(raw.get("status") or "") - row.raw = raw - row.updated_at = now - db.commit() - - def _upsert_esports_goods(self, db: Session, goods: list[dict]) -> None: - now = datetime.now(timezone.utc) - for raw in goods: - commodity_id = str(raw.get("commodityId") or raw.get("commodity_id") or "") - if not commodity_id: - continue - row = ( - db.query(DouyuEsportsGoodsSnapshot) - .filter(DouyuEsportsGoodsSnapshot.commodity_id == commodity_id) - .first() - ) - if row is None: - row = DouyuEsportsGoodsSnapshot(commodity_id=commodity_id) - db.add(row) - row.name = str(raw.get("commodityName") or raw.get("name") or "") - row.score = self._to_int(raw.get("score")) - row.status = str(raw.get("status") or "") - row.raw = raw - row.updated_at = now - db.commit() - - def _upsert_xpd_goods(self, db: Session, goods: list[dict]) -> None: - """同步和平小店商品快照,移除上一次热门抢购等遗留商品。""" - now = datetime.now(timezone.utc) - commodity_ids = { - str(raw.get("commodity_id") or raw.get("iGoodsId") or "") - for raw in goods - } - commodity_ids.discard("") - query = db.query(DouyuXpdGoodsSnapshot) - if commodity_ids: - query.filter(~DouyuXpdGoodsSnapshot.commodity_id.in_(commodity_ids)).delete( - synchronize_session=False, - ) - else: - query.delete(synchronize_session=False) - for raw in goods: - commodity_id = str(raw.get("commodity_id") or raw.get("iGoodsId") or "") - if not commodity_id: - continue - row = ( - db.query(DouyuXpdGoodsSnapshot) - .filter(DouyuXpdGoodsSnapshot.commodity_id == commodity_id) - .first() - ) - if row is None: - row = DouyuXpdGoodsSnapshot(commodity_id=commodity_id) - db.add(row) - row.name = str(raw.get("name") or raw.get("sGoodsName") or "") - row.price = self._to_int(raw.get("price") or raw.get("iPrice")) - row.org_price = self._to_int(raw.get("org_price") or raw.get("iOrgPrice")) - row.category = str(raw.get("category") or raw.get("iCategoryId") or "") - goods_left = raw.get("goods_left") - if goods_left is None: - goods_left = raw.get("iGoodsLeft") - row.goods_left = self._to_int(goods_left) - row.raw = raw - row.updated_at = now - db.commit() - - def _config_info(self, db: Session) -> dict: - config = ensure_douyu_config(db) - return {field: douyu_config_value(field, getattr(config, field, None)) for field in DOUYU_CONFIG_FIELDS} - - def _refresh_account_points( - self, - client: DouyuActivityClient, - account: Account, - cookie: str, - *, - ctn: str | None = None, - ) -> dict: - """刷新账号积分并写回账号表。""" - uid = account_uid(account, cookie) - if not uid: - raise DouyuActivityError("Cookie 中没有 acf_uid,无法查询积分") - ctn_value = ctn or client.acf_ccn(refresh_subscribe=False) - result = client.query_points(uid=uid, ctn=ctn_value) - points = self._to_int(result.get("points")) - account.uid = uid - account.points = points - update_account_profile_from_cookie(account, cookie) - account.updated_at = datetime.now(timezone.utc) - return {"points": points, "points_query": result} - - def _refresh_account_gold_balance(self, client: DouyuActivityClient, account: Account) -> dict: - """刷新鱼翅和钱包兑换余额并写回账号表。""" - gold = client.gold_account() - exchange = client.exchange_balance() - account.gold_balance = self._to_int(gold.get("gold")) - account.exchange_balance = self._to_int(exchange.get("count")) - account.updated_at = datetime.now(timezone.utc) - return { - "gold_balance": account.gold_balance, - "exchange_balance": account.exchange_balance, - "gold": gold, - "exchange_balance_query": exchange, - } - - def _wait_points_after_payment( - self, - db: Session, - task: DouyuTask, - account: Account, - client: DouyuActivityClient, - cookie: str, - ctn: str, - result: dict, - ) -> bool: - """等待宝典支付到账;积分达到 300 视为开通成功。""" - deadline = time.monotonic() + DOUYU_PAYMENT_POLL_SECONDS - result["payment_polling"] = True - result["payment_target_points"] = 300 - poll_count = 0 - last_points = None - while not self._stop.is_set() and time.monotonic() <= deadline: - try: - points_result = self._refresh_account_points(client, account, cookie, ctn=ctn) - db.commit() - poll_count += 1 - last_points = points_result["points"] - result.update(points_result) - result["payment_poll_count"] = poll_count - result["payment_polling"] = True - if last_points is not None and last_points >= 300: - result["payment_polling"] = False - result["elite_opened"] = True - return True - self._update_task_progress( - db, - task, - "running", - f"精英宝典支付码已生成,等待开通到账(当前积分 {last_points if last_points is not None else '-'})", - result, - ) - except Exception as exc: - poll_count += 1 - result["payment_poll_count"] = poll_count - result["payment_poll_error"] = str(exc) - self._update_task_progress(db, task, "running", f"等待开通到账: {exc}", result) - if self._stop.wait(DOUYU_PAYMENT_POLL_INTERVAL): - break - result["payment_polling"] = False - result["elite_opened"] = False - result["points"] = last_points - return False - - def _refresh_esports_handbook( - self, - client: DouyuActivityClient, - account: Account, - *, - manual_id: str, - ) -> dict: - """刷新电竞手册开通状态并将积分写回账号。""" - result = client.esports_user_info(manual_id=manual_id) - manual_type = self._to_int(result.get("manual_type")) - manual_score = self._to_int(result.get("manual_score")) - account.esports_points = manual_score - account.updated_at = datetime.now(timezone.utc) - return { - "esports_manual_type": manual_type, - "esports_manual_score": manual_score, - "esports_expire_time": result.get("expire_time"), - "esports_user_info": result, - "esports_points": manual_score, - "points": manual_score, - } - - def _wait_esports_open_after_payment( - self, - db: Session, - task: DouyuTask, - account: Account, - client: DouyuActivityClient, - *, - manual_id: str, - result: dict, - baseline_manual_type: int | None, - baseline_manual_score: int | None, - ) -> bool: - """等待电竞手册支付到账,以 manualType=1 或积分变化作为成功条件。""" - deadline = time.monotonic() + DOUYU_PAYMENT_POLL_SECONDS - result["payment_polling"] = True - result["esports_manual_type_baseline"] = baseline_manual_type - result["esports_manual_score_baseline"] = baseline_manual_score - poll_count = 0 - last_manual_type = baseline_manual_type - last_manual_score = baseline_manual_score - - while not self._stop.is_set() and time.monotonic() <= deadline: - try: - handbook_result = self._refresh_esports_handbook( - client, - account, - manual_id=manual_id, - ) - db.commit() - poll_count += 1 - last_manual_type = handbook_result["esports_manual_type"] - last_manual_score = handbook_result["esports_manual_score"] - result.update(handbook_result) - result["payment_poll_count"] = poll_count - result["payment_polling"] = True - opened = ( - last_manual_type is not None - and last_manual_type >= 1 - ) or ( - baseline_manual_score is not None - and last_manual_score is not None - and last_manual_score > baseline_manual_score - ) - if opened: - result["payment_polling"] = False - result["esports_opened"] = True - return True - self._update_task_progress( - db, - task, - "running", - "电竞手册支付码已生成,等待开通到账" - f"(类型 {last_manual_type if last_manual_type is not None else '-'}," - f"积分 {last_manual_score if last_manual_score is not None else '-'})", - result, - ) - except Exception as exc: - poll_count += 1 - result["payment_poll_count"] = poll_count - result["payment_poll_error"] = str(exc) - self._update_task_progress(db, task, "running", f"等待电竞手册到账: {exc}", result) - if self._stop.wait(DOUYU_PAYMENT_POLL_INTERVAL): - break - - result["payment_polling"] = False - result["esports_opened"] = False - result["esports_manual_type"] = last_manual_type - result["esports_manual_score"] = last_manual_score - result["esports_points"] = last_manual_score - result["points"] = last_manual_score - return False - - def _wait_gold_balance_after_payment( - self, - db: Session, - task: DouyuTask, - account: Account, - client: DouyuActivityClient, - result: dict, - baseline_gold: int | None, - ) -> bool: - """等待鱼翅充值到账;余额变化后写回账号表。""" - deadline = time.monotonic() + DOUYU_PAYMENT_POLL_SECONDS - result["payment_polling"] = True - result["baseline_gold_balance"] = baseline_gold - poll_count = 0 - last_gold = baseline_gold - baseline_ready = baseline_gold is not None - while not self._stop.is_set() and time.monotonic() <= deadline: - try: - balance_result = self._refresh_account_gold_balance(client, account) - db.commit() - poll_count += 1 - last_gold = balance_result["gold_balance"] - result.update(balance_result) - result["payment_poll_count"] = poll_count - result["payment_polling"] = True - if not baseline_ready and last_gold is not None: - baseline_gold = last_gold - result["baseline_gold_balance"] = baseline_gold - baseline_ready = True - self._update_task_progress( - db, - task, - "running", - f"鱼翅支付码已生成,已记录当前余额 {last_gold},等待到账", - result, - ) - if self._stop.wait(DOUYU_PAYMENT_POLL_INTERVAL): - break - continue - changed = last_gold is not None and (baseline_gold is None or last_gold != baseline_gold) - if changed: - result["payment_polling"] = False - result["gold_recharged"] = True - return True - self._update_task_progress( - db, - task, - "running", - f"鱼翅支付码已生成,等待到账(当前鱼翅 {last_gold if last_gold is not None else '-'})", - result, - ) - except Exception as exc: - poll_count += 1 - result["payment_poll_count"] = poll_count - result["payment_poll_error"] = str(exc) - self._update_task_progress(db, task, "running", f"等待鱼翅到账: {exc}", result) - if self._stop.wait(DOUYU_PAYMENT_POLL_INTERVAL): - break - result["payment_polling"] = False - result["gold_recharged"] = False - result["gold_balance"] = last_gold - return False - - @staticmethod - def _supplier_value(payload: dict, *keys: str): - """兼容供应商将订单字段放在响应根节点、data 或 result 节点。""" - data = payload.get("data") if isinstance(payload.get("data"), dict) else {} - result = payload.get("result") if isinstance(payload.get("result"), dict) else {} - for source in (payload, data, result): - for key in keys: - if source.get(key) is not None: - return source[key] - return None - - @classmethod - def _supplier_order_status(cls, payload: dict) -> int | None: - """提取供应商订单状态,文档约定 0-4。""" - return cls._to_int(cls._supplier_value(payload, "order_status", "orderStatus", "supplier_order_status")) - - @classmethod - def _supplier_message(cls, payload: dict) -> str: - """提取供应商可展示的业务消息。""" - value = cls._supplier_value(payload, "msg", "message", "error_msg") - return str(value or "")[:256] - - @staticmethod - def _supplier_result(payload: dict) -> dict: - """保存必要订单状态,避免把完整供应商响应或签名暴露到任务结果。""" - data = payload.get("data") if isinstance(payload.get("data"), dict) else {} - response_result = payload.get("result") if isinstance(payload.get("result"), dict) else {} - result = { - key: value - for key, value in {**payload, **data, **response_result}.items() - if key not in {"sign", "cards", "card_no", "card_pwd", "recharge_arg"} - } - return result - - @staticmethod - def _supplier_out_order_id(task: DouyuTask) -> str: - """生成可追踪的供应商外部订单号;已有订单号必须在重试时复用。""" - existing = str(task.supplier_out_order_id or "").strip() - if existing: - return existing - batch_token = re.sub(r"[^A-Za-z0-9]", "", str(task.batch_id or "")).upper()[:16] or "LOCAL" - return f"DYGF{batch_token}T{task.id}" - - def _wait_supplier_gold_order( - self, - db: Session, - task: DouyuTask, - client: FishFinRechargeClient, - result: dict, - ) -> int | None: - """轮询供应商直充订单至结束状态。""" - order_no = str(result["out_order_id"]) - deadline = time.monotonic() + DOUYU_PAYMENT_POLL_SECONDS - poll_count = 0 - result["payment_polling"] = True - while not self._stop.is_set() and time.monotonic() <= deadline: - try: - # 回调可能已在另一个数据库会话中结束订单,刷新后直接使用其结果。 - db.refresh(task) - if task.status in {"success", "failed"}: - callback_result = task.result if isinstance(task.result, dict) else result - result.update(callback_result) - result["payment_polling"] = False - return self._supplier_order_status(callback_result) - payload = client.query_order(order_no) - code = self._to_int(self._supplier_value(payload, "code")) - status = self._supplier_order_status(payload) - poll_count += 1 - result.update({ - "payment_poll_count": poll_count, - "supplier_code": code, - "supplier_order_status": status, - "supplier_order": self._supplier_result(payload), - }) - if code != 200: - result["payment_polling"] = False - return status if status in {2, 3, 4} else 4 - if status in {2, 3, 4}: - result["payment_polling"] = False - return status - self._update_task_progress( - db, - task, - "running", - f"供应商直充订单处理中(状态 {status if status is not None else '-'})", - result, - ) - except FishFinRechargeError as exc: - poll_count += 1 - result["payment_poll_count"] = poll_count - result["payment_poll_error"] = str(exc) - self._update_task_progress(db, task, "running", f"查询供应商订单失败: {exc}", result) - if self._stop.wait(DOUYU_PAYMENT_POLL_INTERVAL): - break - result["payment_polling"] = False - return None - - def _refresh_points_after_elite_gift( - self, - db: Session, - task: DouyuTask, - account: Account, - client: DouyuActivityClient, - cookie: str, - ctn: str | None, - result: dict, - baseline_points: int | None, - gift_count: int, - ) -> dict: - """赠送精英令后短轮询积分;1 个精英令约等于 10 积分。""" - expected_gain = max(0, gift_count) * 10 - target_points = baseline_points + expected_gain if baseline_points is not None else None - result["gift_points_baseline"] = baseline_points - result["gift_points_expected_gain"] = expected_gain - result["gift_points_target"] = target_points - - last_points = None - refresh_result: dict = {} - for index in range(1, DOUYU_GIFT_POINTS_REFRESH_TIMES + 1): - refresh_result = self._refresh_account_points(client, account, cookie, ctn=ctn) - db.commit() - last_points = refresh_result["points"] - result.update(refresh_result) - result["gift_points_refresh_count"] = index - if target_points is None or (last_points is not None and last_points >= target_points): - result["gift_points_confirmed"] = target_points is None or last_points is not None - return refresh_result - if index < DOUYU_GIFT_POINTS_REFRESH_TIMES: - self._update_task_progress( - db, - task, - "running", - f"赠送精英令成功,等待积分同步(当前 {last_points if last_points is not None else '-'},预期 {target_points})", - result, - ) - if self._stop.wait(DOUYU_GIFT_POINTS_REFRESH_INTERVAL): - break - - result["gift_points_confirmed"] = False - result["points"] = last_points - return refresh_result - - def _task_payload(self, task: DouyuTask) -> dict: - result = task.result if isinstance(task.result, dict) else {} - payload = result.get("payload") if isinstance(result.get("payload"), dict) else {} - return {**payload, **self.payload} - - def _client(self, cookie: str) -> DouyuActivityClient: - return DouyuActivityClient(cookie, logger=lambda msg: self._push_log("debug", msg)) - - def _fetch_bind_info_candidates( - self, - client: DouyuActivityClient, - aliases: list[str], - ) -> list[dict]: - """按多个 actAlias 查询绑定信息,保留成功结果。""" - results: list[dict] = [] - for alias in aliases: - if not alias: - continue - try: - info = client.bind_info(alias, v2=True) - except DouyuActivityError as exc: - 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 - - def _pick_bind_info( - self, - candidates: list[dict], - *, - baseline_role_name: str = "", - baseline_is_bound_act: bool = False, - prefer_pending: bool = True, - prefer_aliases: list[str] | None = None, - ) -> dict | None: - """从多个 alias 结果里挑最有用的绑定信息。 - - - prefer_pending=True:优先选“待确认/新扫码角色”(通常来自 cjm) - - prefer_aliases:在同等条件下优先指定 alias(如活动当前绑定) - """ - if not candidates: - return None - - def _alias_rank(info: dict) -> int: - alias = str(info.get("act_alias") or "") - if not prefer_aliases: - return 0 - try: - return prefer_aliases.index(alias) - except ValueError: - return len(prefer_aliases) + 1 - - ranked = sorted(enumerate(candidates), key=lambda item: (_alias_rank(item[1]), item[0])) - ordered = [item[1] for item in ranked] - - if prefer_pending: - for info in ordered: - if self._is_pending_role( - info, - baseline_role_name=baseline_role_name, - baseline_is_bound_act=baseline_is_bound_act, - ): - return info - for info in ordered: - if str(info.get("role_name") or "").strip(): - 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], - config: dict, - ) -> dict | None: - """选“扫码前当前已绑定角色”作为 baseline。 - - 活动 alias(20260120QYOOB)只反映当前已绑定; - cjm 才是换绑最新态。baseline 应优先活动 alias 的已绑定结果, - 避免把 cjm 上的待确认新角色误当成扫码前旧角色。 - """ - if not candidates: - return None - return self._pick_current_bound_info(candidates, config) - - def _wait_bind_role_result( - self, - client: DouyuActivityClient, - db: Session, - task: DouyuTask, - account: Account, - query_aliases: list[str], - result: dict, - baseline_role_name: str = "", - baseline_is_bound_act: bool = False, - ) -> tuple[str, dict]: - """生成二维码后轮询绑定信息,直到识别到待确认角色、超时或停止。""" - deadline = time.monotonic() + DOUYU_BIND_ROLE_POLL_SECONDS - result["bind_polling"] = True - result["bind_phase"] = result.get("bind_phase") or "waiting_scan" - result["bind_ready_for_confirm"] = False - result["bind_confirmed"] = False - result["baseline_role_name"] = baseline_role_name - result["baseline_is_bound_act"] = baseline_is_bound_act - result["query_act_aliases"] = query_aliases - self._push_log( - "info", - "开始轮询绑定角色 " - f"aliases={','.join(query_aliases) or '-'} " - f"baseline={baseline_role_name or '-'} bound={1 if baseline_is_bound_act else 0}", - ) - self._update_task_progress(db, task, "running", "已生成绑定二维码,等待扫码绑定", result) - - poll_count = 0 - last_summary = "" - while not self._stop.is_set() and time.monotonic() < deadline: - if self._stop.wait(DOUYU_BIND_ROLE_POLL_INTERVAL): - break - - candidates = self._fetch_bind_info_candidates(client, query_aliases) - if not candidates: - result["bind_poll_error"] = "所有 actAlias 查询绑定信息失败" - self._update_task_progress(db, task, "running", "等待绑定角色同步: 查询失败", result) - continue - - poll_count += 1 - bind_info = self._pick_bind_info( - candidates, - baseline_role_name=baseline_role_name, - baseline_is_bound_act=baseline_is_bound_act, - prefer_pending=True, - prefer_aliases=query_aliases, - ) or candidates[0] - snapshot = self._bind_snapshot(bind_info) - is_pending_role = self._is_pending_role( - bind_info, - baseline_role_name=baseline_role_name, - baseline_is_bound_act=baseline_is_bound_act, - ) - query_alias = str(bind_info.get("act_alias") or "") - summary = ( - f"act={query_alias or '-'} " - f"{self._format_bind_summary(bind_info, pending=is_pending_role)}" - ) - # 字段变化或每 3 次打印一次,避免刷屏但仍能看到过程 - if summary != last_summary or poll_count == 1 or poll_count % 3 == 0: - self._push_log("info", f"轮询绑定#{poll_count}: {summary}") - last_summary = summary - - # 注意:未识别到新角色时,不要把当前已绑定角色写进 role_name, - # 否则前端会把旧角色误当成“待确认角色/查询结果”。 - if is_pending_role: - result.update({ - **snapshot, - "act_alias": query_alias, - "query_act_alias": query_alias, - "bind_ready_for_confirm": True, - "bind_confirmed": False, - "bind_phase": "role_ready", - "bind_polling": False, - "poll_count": poll_count, - "bind_summary": summary, - "bind_candidates": [ - { - "act_alias": item.get("act_alias"), - "role_name": item.get("role_name"), - "is_bound_act": self._is_bound_act(item), - } - for item in candidates - ], - }) - role_name = snapshot["role_name"] - # 待确认角色只回传前端展示,不写入账号表,避免“未换绑成功但角色信息已变新” - 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, - task, - "running", - f"已识别角色: {role_name},待确认绑定", - result, - ) - return role_name, result - - if snapshot["role_name"] and snapshot["is_bound_act"]: - result["current_role_name"] = snapshot["role_name"] - result["current_area_name"] = snapshot["area_name"] - result["current_plat_name"] = snapshot["plat_name"] - - result.update({ - "bind_info": bind_info, - "act_alias": query_alias, - "query_act_alias": query_alias, - "is_bound_act": snapshot["is_bound_act"], - "is_bound_role": snapshot["is_bound_role"], - "is_bound_account": snapshot["is_bound_account"], - "need_bind_act": snapshot["need_bind_act"], - "need_bind_role": snapshot["need_bind_role"], - "change_role_wait_time": snapshot["change_role_wait_time"], - "can_change_role": snapshot["can_change_role"], - "role_name": "", - "area_name": "", - "plat_name": "", - "bind_ready_for_confirm": False, - "bind_confirmed": False, - "bind_phase": "waiting_scan", - "bind_polling": True, - "poll_count": poll_count, - "bind_summary": summary, - }) - self._update_task_progress(db, task, "running", f"等待扫码绑定 ({summary})", result) - - result["bind_polling"] = False - result["bind_ready_for_confirm"] = False - result["bind_phase"] = "stopped" if self._stop.is_set() else "role_timeout" - result["poll_count"] = poll_count - if last_summary: - result["bind_summary"] = last_summary - self._push_log( - "warning", - f"轮询结束 phase={result['bind_phase']} polls={poll_count} last={last_summary or '-'}", - ) - return "", result - - def _execute_refresh_goods(self, db: Session, task: DouyuTask, account: Account, cookie: str, config: dict): - client = self._client(cookie) - result = client.list_goods(manual_id=config["manual_id"], rid=config["rid"]) - goods = result["goods"] - self._upsert_goods(db, goods) - account.bind_status = account.bind_status or "active" - account.updated_at = datetime.now(timezone.utc) - self._mark_task(db, task, "success", f"已刷新商品 {len(goods)} 个", {"goods_count": len(goods), "goods": goods}) - - def _execute_refresh_esports_goods( - self, - db: Session, - task: DouyuTask, - account: Account, - cookie: str, - config: dict, - ): - """刷新电竞手册皮肤商城快照。""" - client = self._client(cookie) - result = client.list_esports_goods( - manual_id=str(config["esports_manual_id"]), - rid=str(config["room_id"]), - ) - goods = result["goods"] - self._upsert_esports_goods(db, goods) - account.esports_bind_status = "esports_goods_refreshed" - account.updated_at = datetime.now(timezone.utc) - self._mark_task( - db, - task, - "success", - f"已刷新电竞皮肤 {len(goods)} 个", - {"goods_count": len(goods), "esports_store_score": result["score"], "goods": goods}, - ) - - def _xpd_role_context(self, client: DouyuActivityClient, config: dict) -> dict: - """获取小店 H5 参数 + 绑定角色信息,小店任务共用。""" - embed = client.xpd_embed_query( - act_alias=str(config["xpd_act_alias"]), - rid=str(config["xpd_rid"]), - ) - role = client.xpd_get_role( - embed_query=embed["query"], - act_id=str(config["xpd_act_id"]), - rid=str(config["xpd_rid"]), - ) - return {"embed": embed, "role": role} - - def _xpd_area_id(self, role: dict, account: Account) -> int: - """角色大区: 优先使用接口值,微信=1、手Q=2,未知回退已存值。""" - raw_area = role.get("area") - if raw_area not in (None, ""): - try: - area_id = int(raw_area) - except (TypeError, ValueError): - area_id = 0 - if area_id > 0: - return area_id - role_type = str(role.get("type") or "") - if role_type == "wx": - return 1 - if role_type == "qq": - return 2 - return account.xpd_area_id or 1 - - def _apply_xpd_role_to_account(self, account: Account, role: dict, area_id: int) -> None: - account.xpd_game_name = str(role.get("role_name") or "") or account.xpd_game_name - account.xpd_openid = str(role.get("game_open_id") or "") or account.xpd_openid - account.xpd_role_id = str(role.get("role_id") or "") or account.xpd_role_id - account.xpd_plat_id = self._to_int(role.get("plat_id")) - account.xpd_area_id = area_id - account.updated_at = datetime.now(timezone.utc) - - def _execute_query_xpd_role( - self, - db: Session, - task: DouyuTask, - account: Account, - cookie: str, - config: dict, - ): - """查询和平小店绑定角色。""" - client = self._client(cookie) - ctx = self._xpd_role_context(client, config) - role = ctx["role"] - if not role.get("role_id"): - self._mark_task(db, task, "failed", "未获取到小店绑定角色") - return - area_id = self._xpd_area_id(role, account) - self._apply_xpd_role_to_account(account, role, area_id) - account.xpd_bind_status = "xpd_bound" - db.commit() - role_text = str(role.get("role_name") or "-") - channel = "微信" if role.get("type") == "wx" else ("手Q" if role.get("type") == "qq" else str(role.get("type") or "-")) - self._mark_task( - db, - task, - "success", - f"小店角色: {role_text}({channel})", - {"role": role, "area_id": area_id}, - ) - - def _execute_get_xpd_bind_qr( - self, - db: Session, - task: DouyuTask, - account: Account, - cookie: str, - config: dict, - ): - """生成和平小店绑定二维码并轮询等待微信扫码绑定/换绑完成。 - - 识别到新角色后仅标记"待确认",不自动回写账号,由用户手动确认绑定。 - """ - client = self._client(cookie) - act_alias = str(config.get("xpd_act_alias") or "").strip() - if not act_alias: - self._mark_task(db, task, "failed", "请先配置小店活动代号 actAlias") - return - result = client.xpd_bind_qr(act_alias=act_alias) - account.xpd_bind_status = "xpd_bind_qr_ready" - account.updated_at = datetime.now(timezone.utc) - db.commit() - # 记录绑定前状态:已绑定账号生成二维码后必须等扫码换绑,不能立即成功 - try: - before = client.xpd_bind_info(act_alias=act_alias) - result["before_bound"] = bool(before.get("bind_role")) - result["before_role_name"] = str(before.get("role_name") or "") - result["before_area_name"] = str(before.get("area_name") or "") - result["before_plat_name"] = str(before.get("plat_name") or "") - except Exception: - result["before_bound"] = False - result["before_role_name"] = "" - result["bind_polling"] = True - self._update_task_progress( - db, - task, - "running", - "二维码已生成,请微信扫码在小程序中绑定角色", - result, - ) - state = self._wait_xpd_bind(db, task, client, act_alias, result) - result["bind_polling"] = False - if state == "stopped": - self._mark_task(db, task, "stopped", "任务已停止", result) - return - if state == "pending": - role_text = str(result.get("role_name") or "-") - self._mark_task(db, task, "success", f"已识别角色: {role_text},待确认绑定", result) - return - self._mark_task( - db, - task, - "failed", - "未检测到小店绑定(二维码仍有效,可再次生成后扫码)", - result, - ) - - def _wait_xpd_bind( - self, - db: Session, - task: DouyuTask, - client: DouyuActivityClient, - act_alias: str, - result: dict, - ) -> str: - """轮询 bindInfo 检测绑定/换绑角色,识别到后停在"待确认",不自动回写账号。 - - - 绑定前未绑定:检测到 bind_role=1 即识别到待确认角色 - - 绑定前已绑定(换绑):检测到角色名变化才算换绑完成,角色不变继续等 - 返回 "pending"=已识别待确认角色, "stopped"=任务停止, "timeout"=超时未识别。 - """ - before_bound = bool(result.get("before_bound")) - before_role_name = str(result.get("before_role_name") or "") - deadline = time.monotonic() + DOUYU_XPD_BIND_POLL_SECONDS - poll_count = 0 - while not self._stop.is_set() and time.monotonic() <= deadline: - try: - info = client.xpd_bind_info(act_alias=act_alias) - poll_count += 1 - result["bind_poll_count"] = poll_count - result["bind_polling"] = True - role_name = str(info.get("role_name") or "") - bound_now = bool(info.get("bind_role")) - changed = before_bound and bool(role_name) and role_name != before_role_name - if (not before_bound and bound_now and role_name) or changed: - result.update({key: value for key, value in info.items() if key != "raw"}) - result["bind_polling"] = False - result["xpd_pending_confirm"] = True - return "pending" - self._update_task_progress( - db, - task, - "running", - f"等待扫码绑定(第 {poll_count} 次)", - result, - ) - except Exception as exc: - poll_count += 1 - result["bind_poll_count"] = poll_count - result["bind_poll_error"] = str(exc) - self._update_task_progress( - db, - task, - "running", - f"等待扫码绑定: {exc}", - result, - ) - if self._stop.wait(DOUYU_XPD_BIND_POLL_INTERVAL): - break - result["bind_polling"] = False - return "stopped" if self._stop.is_set() else "timeout" - - def _execute_confirm_xpd_bind( - self, - db: Session, - task: DouyuTask, - account: Account, - cookie: str, - config: dict, - ): - """确认和平小店绑定:回查 bindInfo,确认绑定角色后将账号回写为已绑定。""" - client = self._client(cookie) - act_alias = str(config.get("xpd_act_alias") or "").strip() - if not act_alias: - self._mark_task(db, task, "failed", "请先配置小店活动代号 actAlias") - return - result = client.xpd_bind_info(act_alias=act_alias) - role_name = str(result.get("role_name") or "") - if not result.get("bind_role") or not role_name: - account.xpd_bind_status = "xpd_not_bound" - account.updated_at = datetime.now(timezone.utc) - db.commit() - self._mark_task(db, task, "failed", "尚未检测到小店绑定角色,请先扫码绑定", result) - return - # 优先用完整角色信息回写(与查询角色一致),失败时回退 bindInfo 角色名 - try: - ctx = self._xpd_role_context(client, config) - role = ctx["role"] - if role.get("role_id"): - self._apply_xpd_role_to_account(account, role, self._xpd_area_id(role, account)) - else: - account.xpd_game_name = role_name - account.updated_at = datetime.now(timezone.utc) - except Exception: - account.xpd_game_name = role_name - account.updated_at = datetime.now(timezone.utc) - account.xpd_bind_status = "xpd_bound" - account.updated_at = datetime.now(timezone.utc) - db.commit() - result["xpd_pending_confirm"] = False - result["xpd_bound"] = True - self._mark_task(db, task, "success", f"小店绑定成功: {role_name}", result) - - def _execute_query_xpd_bind_info( - self, - db: Session, - task: DouyuTask, - account: Account, - cookie: str, - config: dict, - ): - """查询和平小店绑定信息(bindInfo)。 - - 仅查询展示,不回写账号;确认绑定由 confirm_xpd_bind 任务完成。 - """ - client = self._client(cookie) - act_alias = str(config.get("xpd_act_alias") or "").strip() - if not act_alias: - self._mark_task(db, task, "failed", "请先配置小店活动代号 actAlias") - return - result = client.xpd_bind_info(act_alias=act_alias) - status = "已绑定" if result.get("bind_role") else "未绑定" - text = str(result.get("role_name") or result.get("nick") or "-") - self._mark_task( - db, - task, - "success", - f"小店绑定: {status} ({text})", - result, - ) - - def _execute_refresh_xpd_goods( - self, - db: Session, - task: DouyuTask, - account: Account, - cookie: str, - config: dict, - ): - """刷新和平小店商品列表快照(全局数据,任一可用 CK 即可)。""" - client = self._client(cookie) - ctx = self._xpd_role_context(client, config) - role = ctx["role"] - if not role.get("role_id"): - self._mark_task(db, task, "failed", "未获取到小店绑定角色") - return - area_id = self._xpd_area_id(role, account) - result = client.xpd_list_goods( - embed_query=ctx["embed"]["query"], - act_id=str(config["xpd_act_id"]), - openid=str(role.get("game_open_id") or ""), - roleid=str(role.get("role_id") or ""), - areaid=str(area_id), - ) - goods = result["goods"] - self._upsert_xpd_goods(db, goods) - self._apply_xpd_role_to_account(account, role, area_id) - account.xpd_bind_status = "xpd_goods_refreshed" - db.commit() - self._mark_task( - db, - task, - "success", - f"已刷新小店商品 {len(goods)} 个", - {"goods_count": len(goods), "goods": goods}, - ) - - def _execute_query_xpd_balance( - self, - db: Session, - task: DouyuTask, - account: Account, - cookie: str, - config: dict, - ): - """查询和平小店点券余额。""" - client = self._client(cookie) - ctx = self._xpd_role_context(client, config) - role = ctx["role"] - if not role.get("role_id"): - self._mark_task(db, task, "failed", "未获取到小店绑定角色") - return - area_id = self._xpd_area_id(role, account) - role_plat = role.get("plat_id") - plat = str(role_plat) if role_plat not in (None, "") else "1" - result = client.xpd_balance( - embed_query=ctx["embed"]["query"], - act_id=str(config["xpd_act_id"]), - openid=str(role.get("game_open_id") or ""), - roleid=str(role.get("role_id") or ""), - plat=plat, - areaid=str(area_id), - ) - balance = result.get("balance") - self._apply_xpd_role_to_account(account, role, area_id) - account.xpd_balance = balance - account.xpd_bind_status = "xpd_balance_queried" - db.commit() - if balance is None: - self._mark_task(db, task, "failed", "未获取到小店点券余额") - return - self._mark_task( - db, - task, - "success", - f"小店点券余额: {balance}", - {"balance": balance, "role": role, "area_id": area_id}, - ) - - def _execute_query_xpd_fragments( - self, - db: Session, - task: DouyuTask, - account: Account, - cookie: str, - config: dict, - ): - """查询和平小店扭蛋碎片数量。 - - 优先现查角色;getrole 受限(Livelink 风控/失效)时回退账号已存角色, - 保证已绑定账号仍可查询。 - """ - client = self._client(cookie) - act_id = str(config["xpd_act_id"]) - embed_query: dict = {} - openid = str(account.xpd_openid or "") - roleid = str(account.xpd_role_id or "") - stored_plat = account.xpd_plat_id - plat = str(stored_plat) if stored_plat is not None else "1" - areaid = str(account.xpd_area_id or 1) - role: dict = {} - try: - ctx = self._xpd_role_context(client, config) - embed_query = ctx["embed"]["query"] - role = ctx["role"] if isinstance(ctx.get("role"), dict) else {} - if role.get("role_id"): - role_area = self._xpd_area_id(role, account) - openid = str(role.get("game_open_id") or "") or openid - roleid = str(role.get("role_id") or "") or roleid - role_plat = role.get("plat_id") - plat = str(role_plat) if role_plat not in (None, "") else plat - areaid = str(role_area) or areaid - self._apply_xpd_role_to_account(account, role, role_area) - except Exception: - pass - if not openid or not roleid: - self._mark_task(db, task, "failed", "未获取到小店绑定角色,请先生成二维码扫码绑定") - return - result = client.xpd_fragments( - embed_query=embed_query, - act_id=act_id, - openid=openid, - roleid=roleid, - plat=plat, - areaid=areaid, - ) - fragments = result.get("fragments") - account.xpd_fragments = fragments - account.xpd_bind_status = "xpd_fragments_queried" - account.updated_at = datetime.now(timezone.utc) - db.commit() - if fragments is None: - self._mark_task(db, task, "failed", "未获取到小店扭蛋碎片数量") - return - self._mark_task( - db, - task, - "success", - f"小店扭蛋碎片: {fragments}", - {"fragments": fragments, "role": role, "area_id": int(areaid)}, - ) - - def _execute_query_xpd_purchase_records( - self, - db: Session, - task: DouyuTask, - account: Account, - cookie: str, - config: dict, - ): - """查询和平小店道聚城购买记录。""" - client = self._client(cookie) - embed = client.xpd_embed_query( - act_alias=str(config["xpd_act_alias"]), - rid=str(config["xpd_rid"]), - ) - result = client.xpd_purchase_records( - embed_query=embed["query"], - act_id=str(config["xpd_act_id"]), - ) - account.xpd_bind_status = "xpd_purchase_records_queried" - account.updated_at = datetime.now(timezone.utc) - total = result.get("total") or len(result.get("records") or []) - self._mark_task( - db, - task, - "success", - f"小店兑换记录 {total} 条" if total else "暂无小店兑换记录", - result, - ) - - def _execute_exchange_xpd_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: - pay_type = int(payload.get("pay_type") or 1) - except (TypeError, ValueError): - self._mark_task(db, task, "failed", "兑换货币参数无效") - return - if pay_type not in (1, 5): - self._mark_task(db, task, "failed", "小店兑换仅支持点券或扭蛋碎片") - return - - goods = ( - db.query(DouyuXpdGoodsSnapshot) - .filter(DouyuXpdGoodsSnapshot.commodity_id == commodity_id) - .first() - ) - if not goods: - self._mark_task(db, task, "failed", "未找到小店商品快照,请先刷新商品列表") - return - goods_snapshot = goods.raw if isinstance(goods.raw, dict) else {} - goods_raw = goods_snapshot.get("raw") if isinstance(goods_snapshot.get("raw"), dict) else goods_snapshot - price_key = "iPrice" if pay_type == 1 else "iJb2Price" - price = self._to_int(goods_raw.get(price_key)) - if price is None: - price = goods.price if pay_type == 1 else None - if price is None or price <= 0: - currency = "点券" if pay_type == 1 else "扭蛋碎片" - self._mark_task(db, task, "failed", f"该商品不支持使用{currency}兑换") - return - # iGoodsLeft=-1 表示活动未公开库存,不是售罄;只有 0 才阻止兑换。 - if goods.goods_left == 0: - self._mark_task(db, task, "failed", "该商品库存不足,请刷新商品列表后重试") - return - - client = self._client(cookie) - embed = client.xpd_embed_query( - act_alias=str(config["xpd_act_alias"]), - rid=str(config["xpd_rid"]), - ) - role: dict = {} - try: - role = client.xpd_get_role( - embed_query=embed["query"], - act_id=str(config["xpd_act_id"]), - rid=str(config["xpd_rid"]), - ) - if role.get("role_id"): - self._apply_xpd_role_to_account(account, role, self._xpd_area_id(role, account)) - except DouyuActivityError as exc: - self._push_log("warning", f"小店兑换前刷新角色失败,使用已保存角色: {exc}") - if not role.get("role_id") and not account.xpd_role_id: - self._mark_task(db, task, "failed", "未获取到小店绑定角色,请先生成二维码扫码绑定") - return - - result = client.xpd_exchange_goods( - embed_query=embed["query"], - act_id=str(config["xpd_act_id"]), - rid=str(config["xpd_rid"]), - commodity_id=commodity_id, - price=price, - picture=str(goods_raw.get("sGoodsPic") or ""), - pay_type=pay_type, - action_id=str(goods_raw.get("iActionId") or ""), - ) - if pay_type == 1 and result.get("new_balance") is not None: - account.xpd_balance = result["new_balance"] - if pay_type == 5 and result.get("new_balance") is not None: - account.xpd_fragments = result["new_balance"] - account.xpd_bind_status = "xpd_goods_exchanged" - account.updated_at = datetime.now(timezone.utc) - db.commit() - - currency = "点券" if pay_type == 1 else "扭蛋碎片" - display_role = role.get("role_name") or account.xpd_game_name or "" - channel = "微信" if (role.get("type") == "wx" or account.xpd_area_id == 1) else "手Q" - result.update({ - "goods": {**goods_raw, "commodityName": goods.name or ""}, - "game_name": display_role, - "game_channel": channel, - "account_name": account.nickname or account.username or account.uid or f"#{account.id}", - "currency": currency, - }) - self._mark_task(db, task, "success", f"兑换小店商品成功: {goods.name or commodity_id}({price}{currency})", result) - - def _execute_get_bind_qr(self, db: Session, task: DouyuTask, account: Account, cookie: str, config: dict): - client = self._client(cookie) - qr_act_alias = self._bind_qr_act_alias(config) - query_aliases = self._query_bind_act_aliases(config) - if not qr_act_alias and not query_aliases: - self._mark_task(db, task, "failed", "请先配置绑定活动 actAlias") - return - if qr_act_alias and qr_act_alias not in query_aliases: - query_aliases = [qr_act_alias, *query_aliases] - - before_candidates = self._fetch_bind_info_candidates(client, query_aliases) - if not before_candidates: - self._mark_task(db, task, "failed", "查询绑定信息失败,请检查 Cookie 或 actAlias") - return - # baseline 用活动 alias 的“当前已绑定”;pending 检测优先 cjm 的换绑最新态 - before = self._pick_baseline_bind_info(before_candidates, config) or {} - # 换绑冷却必须看活动当前绑定(QYOOB),不要用 cjm - 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_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"] if cooldown_snapshot["is_bound_act"] else "" - ) - wait_time = cooldown_snapshot["change_role_wait_time"] - self._push_log( - "info", - "绑定前状态 " - f"qr_act={qr_act_alias or '-'} query={','.join(query_aliases)} " - f"baseline_hit={before.get('act_alias') or '-'} {self._format_bind_summary(before)} " - f"cooldown_hit={cooldown_info.get('act_alias') or '-'} " - f"{self._format_bind_summary(cooldown_info)} " - f"pending_hit={pending_before.get('act_alias') or '-'} " - f"{self._format_bind_summary(pending_before)}", - ) - - # 生成二维码前强制检查换绑冷却:冷却中直接失败,绝不发码 - if self._is_change_cooling(cooldown_info): - role_label = current_role_name or cooldown_snapshot["role_name"] or "当前角色" - wait_text = self._format_wait_time(wait_time) or "冷却中" - self._push_log( - "warning", - f"换绑冷却中,跳过生成二维码 role={role_label} wait={wait_time if wait_time is not None else '-'} " - f"can={cooldown_info.get('can_change_role')}", - ) - result = { - "act_alias": qr_act_alias or cooldown_info.get("act_alias") or before.get("act_alias"), - "query_act_alias": cooldown_info.get("act_alias") or before.get("act_alias"), - "query_act_aliases": query_aliases, - "before_bind_info": before, - "cooldown_bind_info": cooldown_info, - **cooldown_snapshot, - "current_role_name": role_label if role_label != "当前角色" else current_role_name, - "current_area_name": cooldown_snapshot["area_name"] or before_snapshot["area_name"], - "current_plat_name": cooldown_snapshot["plat_name"] or before_snapshot["plat_name"], - "change_role_wait_time": wait_time, - "change_role_wait_text": self._format_wait_time(wait_time), - "bind_ready_for_confirm": False, - "bind_confirmed": bool(cooldown_snapshot["is_bound_act"] or before_snapshot["is_bound_act"]), - "bind_phase": "change_waiting", - "bind_polling": False, - } - self._apply_bind_info_to_account( - account, - cooldown_info if cooldown_snapshot["role_name"] else before, - "bind_confirmed" if result["bind_confirmed"] else "game_queried", - ) - self._mark_task( - db, - task, - "failed", - f"{role_label} 暂不能换绑,剩余 {wait_text}", - result, - ) - return - - if not qr_act_alias: - self._mark_task(db, task, "failed", "请先配置绑定二维码活动 actAlias") - return - - self._push_log( - "info", - f"换绑校验通过,开始生成二维码 act={qr_act_alias} " - f"role={current_role_name or '-'} wait={wait_time if wait_time is not None else 0}", - ) - qr_result = client.get_bind_qr(qr_act_alias) - result = { - **qr_result, - "act_alias": qr_act_alias, - "query_act_aliases": query_aliases, - "before_bind_info": before, - "current_role_name": current_role_name, - "current_area_name": before_snapshot["area_name"], - "current_plat_name": before_snapshot["plat_name"], - "role_name": "", - "area_name": "", - "plat_name": "", - "bind_ready_for_confirm": False, - "bind_confirmed": False, - "bind_phase": "waiting_scan", - "bind_polling": True, - } - account.bind_status = "bind_qr_generated" - account.updated_at = datetime.now(timezone.utc) - # 关键:先把二维码 progress 出去,前端 running 期间即可弹窗扫码。 - self._update_task_progress(db, task, "running", "已生成绑定二维码,等待扫码绑定", result) - - role_name, result = self._wait_bind_role_result( - client, - db, - task, - account, - query_aliases, - result, - baseline_role_name=current_role_name, - baseline_is_bound_act=before_snapshot["is_bound_act"], - ) - if role_name: - self._mark_task(db, task, "success", f"已识别角色: {role_name},待确认绑定", result) - return - if result.get("bind_phase") == "stopped": - self._mark_task(db, task, "stopped", "任务已停止", result) - return - last_summary = str(result.get("bind_summary") or "") - timeout_msg = "已生成绑定二维码,未检测到新扫码角色" - if current_role_name: - timeout_msg = f"{timeout_msg}(当前仍是 {current_role_name})" - if last_summary: - timeout_msg = f"{timeout_msg} | {last_summary}" - self._mark_task(db, task, "success", timeout_msg, result) - - def _execute_confirm_bind(self, db: Session, task: DouyuTask, account: Account, cookie: str, config: dict): - client = self._client(cookie) - confirm_alias = self._confirm_act_alias(config) - 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 - - before_candidates = self._fetch_bind_info_candidates(client, query_aliases) - if not before_candidates: - self._mark_task(db, task, "failed", "查询绑定信息失败,请检查 Cookie 或 actAlias") - return - before = self._pick_bind_info( - before_candidates, - baseline_role_name="", - baseline_is_bound_act=False, - prefer_pending=True, - prefer_aliases=query_aliases, - ) or before_candidates[0] - 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 = self._pick_current_bound_info( - before_candidates, - config, - extra_prefer_aliases=[confirm_alias], - ) - self._push_log( - "info", - f"确认前状态 confirm_act={confirm_alias or '-'} hit={query_alias or '-'} " - f"{self._format_bind_summary(before)}", - ) - if not role_name: - self._mark_task( - db, - task, - "failed", - "尚未识别到待确认角色,请先扫码完成绑定", - { - "act_alias": confirm_alias or query_alias, - "query_act_alias": query_alias, - "query_act_aliases": query_aliases, - "before_bind_info": before, - **before_snapshot, - "bind_ready_for_confirm": False, - "bind_confirmed": False, - "bind_phase": "waiting_role", - }, - ) - return - - 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, - "success", - f"已绑定: {role_name}", - { - "act_alias": confirm_alias or query_alias, - "query_act_alias": query_alias, - "query_act_aliases": query_aliases, - "before_bind_info": before_bound, - **self._bind_snapshot(before_bound), - "bind_ready_for_confirm": True, - "bind_confirmed": True, - "bind_phase": "confirmed", - }, - ) - return - - # 确认接口优先用配置的确认 alias;没有则回退到命中查询的 alias - use_confirm_alias = confirm_alias or query_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}") - # 待绑定游戏账号侧换绑限制(未到换绑时间等)导致确认失败:保留原绑定并给出明确提示 - 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, - "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: - """确认后回查:只认活动 alias 上的已生效绑定(bound_act=1)。""" - return self._pick_current_bound_info( - candidates, - config, - extra_prefer_aliases=[use_confirm_alias], - ) - - try: - after_candidates = self._fetch_bind_info_candidates(client, query_aliases) - 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: - # 已生效绑定始终未出现:确认未生效(或同步延迟超时),保留原绑定 - 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, - "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: - # 查询接口异常:确认接口已成功时按成功处理,但保留错误信息。 - # 写库优先确认前已生效绑定,避免待确认角色被误写入。 - 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, - "success", - f"绑定成功: {role_name}", - { - "act_alias": use_confirm_alias, - "query_act_alias": query_alias, - "query_act_aliases": query_aliases, - "before_bind_info": before, - "confirm": confirm_result, - **before_snapshot, - "bind_ready_for_confirm": True, - "bind_confirmed": True, - "bind_phase": "confirmed", - "refresh_error": str(exc), - }, - ) - return - - after_snapshot = self._bind_snapshot(after) - 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, - "success", - 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, - "after_bind_info": after, - **after_snapshot, - "bind_ready_for_confirm": True, - "bind_confirmed": True, - "bind_phase": "confirmed", - }, - ) - - def _execute_create_elite_qr(self, db: Session, task: DouyuTask, account: Account, cookie: str, config: dict): - client = self._client(cookie) - ctn = str(self._task_payload(task).get("ctn") or "") - if not ctn: - ctn = client.acf_ccn(refresh_subscribe=True) - act_alias = self._confirm_act_alias(config) or self._bind_qr_act_alias(config) - if not act_alias: - self._mark_task(db, task, "failed", "请先配置开通宝典活动 actAlias") - return - result = client.create_elite_qr( - ctn=ctn, - act_alias=act_alias, - amount=int(config["elite_amount"]), - room_id=str(config["room_id"]), - ) - account.bind_status = "elite_qr_created" - account.updated_at = datetime.now(timezone.utc) - self._update_task_progress(db, task, "running", "精英宝典支付码已生成,等待开通到账", result) - opened = self._wait_points_after_payment(db, task, account, client, cookie, ctn, result) - if self._stop.is_set(): - self._mark_task(db, task, "stopped", "任务已停止", result) - return - if opened: - account.bind_status = "elite_opened" - account.updated_at = datetime.now(timezone.utc) - self._mark_task(db, task, "success", f"精英宝典已开通,积分: {account.points}", result) - return - self._mark_task( - db, - task, - "failed", - f"未检测到精英宝典开通到账,当前积分: {account.points if account.points is not None else '-'}", - result, - ) - - def _execute_prepare_esports_bind( - self, - db: Session, - task: DouyuTask, - account: Account, - cookie: str, - config: dict, - ): - """打开电竞手册绑定面板:查活动状态、当前角色和换绑冷却。""" - client = self._client(cookie) - act_alias = str(config.get("esports_act_alias") or "").strip() - if not act_alias: - self._mark_task(db, task, "failed", "请先配置电竞手册活动 actAlias") - return - - state = self._esports_bind_state(client, act_alias) - esports_bound = state["esports_bound"] - role_text = self._esports_role_text(state) - self._apply_esports_bind_info_to_account( - account, - state, - "esports_bound" if esports_bound else ("esports_bind_ready" if role_text else "game_not_bound"), - ) - # 二维码用于重新选择角色,不应因为已有角色或换绑冷却而隐藏。 - # 冷却是否允许最终由 actBind 返回结果决定。 - qr_result = client.get_esports_bind_qr(act_alias) - result = { - **state, - **qr_result, - "esports_bind_dialog": True, - "can_open_role_selector": True, - } - if esports_bound: - message = f"电竞手册已绑定: {role_text or '-'},可扫码换绑" - elif role_text: - message = f"当前角色: {role_text},可扫码切换角色或直接完成绑定" - else: - message = "请扫码选择游戏角色,完成后查询最新角色" - self._mark_task(db, task, "success", message, result) - - def _execute_get_esports_bind_qr( - self, - db: Session, - task: DouyuTask, - account: Account, - cookie: str, - config: dict, - ): - """生成电竞手册切换角色用的腾讯入口。""" - client = self._client(cookie) - act_alias = str(config.get("esports_act_alias") or "").strip() - if not act_alias: - self._mark_task(db, task, "failed", "请先配置电竞手册活动 actAlias") - return - - state = self._esports_bind_state(client, act_alias) - qr_result = client.get_esports_bind_qr(act_alias) - result = { - **state, - **qr_result, - "esports_bind_dialog": True, - "can_open_role_selector": True, - "bind_phase": "switching_role", - } - account.esports_bind_status = "esports_role_switching" - account.updated_at = datetime.now(timezone.utc) - self._mark_task( - db, - task, - "success", - "请在腾讯页面选择角色,返回后查询最新角色", - result, - ) - - def _execute_confirm_esports_bind( - self, - db: Session, - task: DouyuTask, - account: Account, - cookie: str, - config: dict, - ): - """通过 actBind 确认电竞手册绑定,并回读唯一状态接口确认结果。""" - client = self._client(cookie) - act_alias = str(config.get("esports_act_alias") or "").strip() - if not act_alias: - self._mark_task(db, task, "failed", "请先配置电竞手册活动 actAlias") - return - - account.esports_bind_status = "esports_bind_confirming" - account.updated_at = datetime.now(timezone.utc) - self._update_task_progress( - db, - task, - "running", - "正在确认电竞手册绑定", - { - "bind_phase": "confirming", - "bind_confirmed": False, - }, - ) - confirm_result = client.confirm_esports_bind( - act_alias, - room_id=str(config.get("room_id") or "9263298"), - ) - after_state = self._esports_bind_state(client, act_alias) - result = { - **after_state, - "confirm": confirm_result, - "bind_phase": "confirmed" if after_state["esports_bound"] else "confirm_failed", - } - if not after_state["esports_bound"]: - self._apply_esports_bind_info_to_account(account, after_state, "esports_bind_ready") - self._mark_task( - db, - task, - "failed", - f"电竞手册绑定未生效: {self._esports_role_text(after_state) or '-'}", - result, - ) - return - - self._apply_esports_bind_info_to_account(account, after_state, "esports_bound") - self._mark_task( - db, - task, - "success", - f"电竞手册绑定成功: {self._esports_role_text(after_state) or '-'}", - result, - ) - - def _execute_query_esports_game_name( - self, - db: Session, - task: DouyuTask, - account: Account, - cookie: str, - config: dict, - ): - """查询电竞手册活动返回的最新角色和换绑冷却状态。""" - client = self._client(cookie) - act_alias = str(config.get("esports_act_alias") or "").strip() - if not act_alias: - self._mark_task(db, task, "failed", "请先配置电竞手册活动 actAlias") - return - - state = self._esports_bind_state(client, act_alias) - role_name = state["role_name"] - is_bound = state["esports_bound"] - self._apply_esports_bind_info_to_account( - account, - state, - "esports_bound" if is_bound else ("esports_bind_ready" if role_name else "game_not_bound"), - ) - result = { - **state, - "esports_bind_dialog": True, - "can_open_role_selector": True, - } - if role_name: - message = f"最新角色: {self._esports_role_text(state)}" - else: - message = "未查询到游戏角色,请先切换角色" - self._mark_task(db, task, "success", message, result) - - def _execute_create_esports_qr( - self, - db: Session, - task: DouyuTask, - account: Account, - cookie: str, - config: dict, - ): - """生成电竞手册支付二维码,并通过活动状态确认开通到账。""" - client = self._client(cookie) - ctn = str(self._task_payload(task).get("ctn") or "") - if not ctn: - ctn = client.acf_ccn(refresh_subscribe=True) - act_alias = str(config.get("esports_act_alias") or "").strip() - manual_id = str(config.get("esports_manual_id") or "").strip() - if not act_alias or not manual_id: - self._mark_task(db, task, "failed", "请先配置电竞手册活动 actAlias 和 manualID") - return - - baseline_manual_type = None - baseline_manual_score = None - baseline_result: dict = {} - try: - baseline_result = self._refresh_esports_handbook(client, account, manual_id=manual_id) - baseline_manual_type = baseline_result["esports_manual_type"] - baseline_manual_score = baseline_result["esports_manual_score"] - db.commit() - except Exception as exc: - self._push_log("warning", f"生成电竞手册支付码前查询活动状态失败: {exc}") - - if baseline_manual_type is not None and baseline_manual_type >= 1: - account.esports_bind_status = "esports_opened" - account.updated_at = datetime.now(timezone.utc) - self._mark_task( - db, - task, - "success", - f"电竞手册已开通,积分: {baseline_manual_score if baseline_manual_score is not None else '-'}", - {**baseline_result, "esports_opened": True, "payment_polling": False}, - ) - return - - result = client.create_esports_qr( - ctn=ctn, - act_alias=act_alias, - amount=int(config["esports_amount"]), - room_id=str(config["room_id"]), - ) - result.update(baseline_result) - account.esports_bind_status = "esports_qr_created" - account.updated_at = datetime.now(timezone.utc) - self._update_task_progress(db, task, "running", "电竞手册支付码已生成,等待开通到账", result) - opened = self._wait_esports_open_after_payment( - db, - task, - account, - client, - manual_id=manual_id, - result=result, - baseline_manual_type=baseline_manual_type, - baseline_manual_score=baseline_manual_score, - ) - if self._stop.is_set(): - self._mark_task(db, task, "stopped", "任务已停止", result) - return - if opened: - account.esports_bind_status = "esports_opened" - account.updated_at = datetime.now(timezone.utc) - self._mark_task( - db, - task, - "success", - f"电竞手册已开通,积分: {account.esports_points if account.esports_points is not None else '-'}", - result, - ) - return - self._mark_task( - db, - task, - "failed", - "未检测到电竞手册开通到账" - f",类型: {result.get('esports_manual_type', '-')}," - f"积分: {result.get('esports_manual_score', '-')}", - result, - ) - - def _execute_query_esports_points( - self, - db: Session, - task: DouyuTask, - account: Account, - cookie: str, - config: dict, - ): - """查询电竞手册积分。""" - manual_id = str(config.get("esports_manual_id") or "").strip() - if not manual_id: - self._mark_task(db, task, "failed", "请先配置电竞手册 manualID") - return - client = self._client(cookie) - result = self._refresh_esports_handbook(client, account, manual_id=manual_id) - account.esports_bind_status = "esports_points_queried" - account.updated_at = datetime.now(timezone.utc) - points = result["esports_points"] - self._mark_task(db, task, "success", f"电竞积分: {points if points is not None else '-'}", result) - - def _execute_donate_esports_gift( - self, - db: Session, - task: DouyuTask, - account: Account, - cookie: str, - config: dict, - *, - gift_name: str, - config_gift_id_key: str, - config_skin_id_key: str, - ): - """赠送电竞手册任务礼物并刷新独立积分。""" - payload = self._task_payload(task) - try: - gift_count = max(1, int(payload.get("gift_count") or payload.get("count") or 1)) - except (TypeError, ValueError): - self._mark_task(db, task, "failed", "赠送数量必须是正整数") - return - - manual_id = str(config.get("esports_manual_id") or "").strip() - gift_id = str(payload.get("gift_id") or config.get(config_gift_id_key) or "").strip() - skin_id = str(payload.get("skin_id") or config.get(config_skin_id_key) or "").strip() - room_id = str(payload.get("room_id") or config.get("room_id") or "").strip() - if not manual_id or not gift_id or not skin_id or not room_id: - self._mark_task(db, task, "failed", "请先完整配置电竞手册、房间和礼物参数") - return - - client = self._client(cookie) - baseline_points = account.esports_points - try: - baseline = self._refresh_esports_handbook(client, account, manual_id=manual_id) - baseline_points = baseline["esports_points"] - db.commit() - except Exception as exc: - self._push_log("warning", f"赠送{gift_name}前刷新电竞积分失败: {exc}") - - result = client.donate_esports_gift( - gift_name=gift_name, - gift_count=gift_count, - room_id=room_id, - gift_id=gift_id, - skin_id=skin_id, - ) - result.update( - { - "gift_name": gift_name, - "gift_id": gift_id, - "skin_id": skin_id, - "gift_count": gift_count, - "esports_points_baseline": baseline_points, - } - ) - refresh_errors = [] - try: - result.update(self._refresh_account_gold_balance(client, account)) - except Exception as exc: - refresh_errors.append(f"鱼翅余额: {exc}") - try: - points_result = self._refresh_esports_handbook(client, account, manual_id=manual_id) - result.update(points_result) - result["esports_points_after_gift"] = points_result["esports_points"] - result["esports_points_changed"] = ( - baseline_points is not None - and points_result["esports_points"] is not None - and points_result["esports_points"] != baseline_points - ) - except Exception as exc: - refresh_errors.append(f"电竞积分: {exc}") - if refresh_errors: - result["refresh_errors"] = refresh_errors - - account.esports_bind_status = "esports_gift_donated" - account.updated_at = datetime.now(timezone.utc) - message = f"赠送{gift_name}成功: {gift_count}" - if account.gold_balance is not None: - message += f",鱼翅余额: {account.gold_balance}" - if account.esports_points is not None: - message += f",电竞积分: {account.esports_points}" - self._mark_task(db, task, "success", message, result) - - def _execute_donate_esports_chicken_gift( - self, - db: Session, - task: DouyuTask, - account: Account, - cookie: str, - config: dict, - ): - """赠送冠军鸡腿。""" - self._execute_donate_esports_gift( - db, - task, - account, - cookie, - config, - gift_name="冠军鸡腿", - config_gift_id_key="esports_chicken_gift_id", - config_skin_id_key="esports_chicken_skin_id", - ) - - def _execute_donate_esports_firework_gift( - self, - db: Session, - task: DouyuTask, - account: Account, - cookie: str, - config: dict, - ): - """赠送冠军烟花。""" - self._execute_donate_esports_gift( - db, - task, - account, - cookie, - config, - gift_name="冠军烟花", - config_gift_id_key="esports_firework_gift_id", - config_skin_id_key="esports_firework_skin_id", - ) - - def _execute_create_gold_qr(self, db: Session, task: DouyuTask, account: Account, cookie: str, config: dict): - payload = self._task_payload(task) - amount = int(payload.get("amount") or payload.get("gold_amount") or 1) - channel = str(config.get("gold_recharge_channel") or "wechat_qr") - if channel == "supplier_api": - try: - self._execute_create_gold_supplier_order(db, task, account, cookie, config, amount) - except FishFinRechargeError as exc: - self._mark_task(db, task, "failed", str(exc), {"recharge_channel": "supplier_api"}) - return - client = self._client(cookie) - baseline_gold = account.gold_balance - try: - baseline = self._refresh_account_gold_balance(client, account) - baseline_gold = baseline["gold_balance"] - db.commit() - except Exception as exc: - self._push_log("warning", f"生成鱼翅码前刷新余额失败: {exc}") - result = client.create_gold_qr(amount=amount, pay_type=int(config["gold_pay_type"])) - account.bind_status = "gold_qr_created" - account.updated_at = datetime.now(timezone.utc) - self._update_task_progress(db, task, "running", f"鱼翅 {amount} 元支付码已生成,等待到账", result) - recharged = self._wait_gold_balance_after_payment(db, task, account, client, result, baseline_gold) - if self._stop.is_set(): - self._mark_task(db, task, "stopped", "任务已停止", result) - return - if recharged: - account.bind_status = "gold_recharged" - account.updated_at = datetime.now(timezone.utc) - self._mark_task( - db, - task, - "success", - f"鱼翅已到账,当前余额: {account.gold_balance if account.gold_balance is not None else '-'}", - result, - ) - return - self._mark_task( - db, - task, - "failed", - f"未检测到鱼翅到账,当前余额: {account.gold_balance if account.gold_balance is not None else '-'}", - result, - ) - - def _execute_create_gold_supplier_order( - self, - db: Session, - task: DouyuTask, - account: Account, - cookie: str, - config: dict, - amount: int, - ) -> None: - """创建供应商鱼翅直充订单并轮询订单状态。""" - product_id = str(config.get("gold_api_product_id") or "").strip() - template_name = str(config.get("gold_api_account_template_name") or "斗鱼昵称").strip() - if not product_id: - raise FishFinRechargeError("请先在配置中填写供应商直充商品 ID") - # 充值商品按斗鱼昵称识别账号,UID 只能作为审计信息,不能作为充值值。 - update_account_profile_from_cookie(account, cookie) - recharge_account = str(account.nickname or "").strip() - if not recharge_account: - raise FishFinRechargeError("账号缺少斗鱼昵称,无法发起供应商直充") - - # 首次生成后持久化,网络重试或进程重启都继续查询同一笔订单。 - order_no = self._supplier_out_order_id(task) - task.supplier_out_order_id = order_no - db.commit() - # pay_amount 是用户选择的充值面值;goodsFaceValue=0.993 是供货成本,不能作为支付金额。 - pay_amount = Decimal(amount) - - def trace(event: dict) -> None: - """将脱敏供应商协议信息输出到任务日志,便于线上联调。""" - stage = event.get("stage") - if stage == "request": - params = event.get("params") or {} - self._push_log( - "info", - "供应商直充 | 下单 " - f"| 外部单号={params.get('out_order_id') or '-'} " - f"| 数量={params.get('buy_num') or '-'} " - f"| 金额={params.get('pay_amount') or '-'} " - f"| 商品={params.get('product_id') or '-'}", - ) - if event.get("json_body"): - self._push_log( - "debug", - "供应商协议 | 请求 " - f"| {event.get('method')} {event.get('path')} " - f"| 签名摘要={event.get('sign_digest')} " - f"| 参数={FishFinRechargeClient._json_text(event['json_body'])}", - ) - elif stage == "response": - status = self._to_int(event.get("order_status")) - status_labels = {0: "待处理", 1: "处理中", 2: "成功", 3: "失败", 4: "异常"} - status_text = status_labels.get(status, "-") - reason = str(event.get("fail_reason") or event.get("message") or "-") - self._push_log( - "info", - "供应商直充 | 响应 " - f"| HTTP={event.get('http_status') or '-'} " - f"| 业务码={event.get('code') or '-'} " - f"| 外部单号={event.get('out_order_id') or '-'} " - f"| 供应商单号={event.get('order_id') or '-'} " - f"| 状态={status_text} " - f"| 提示={reason}", - ) - if event.get("response_body"): - self._push_log( - "debug", - "供应商协议 | 响应 " - f"| HTTP={event.get('http_status')} " - f"| 内容={FishFinRechargeClient._json_text(event['response_body'])}", - ) - - client = FishFinRechargeClient(FishFinRechargeConfig.from_env(), trace=trace) - order_payload = client.create_order( - buy_num=amount, - pay_amount=pay_amount, - out_order_id=order_no, - product_id=product_id, - recharge_arg=[{"templateName": template_name, "templateVal": recharge_account}], - order_type=0, - notify_url=client.config.notify_url, - ) - code = self._to_int(self._supplier_value(order_payload, "code")) - status = self._supplier_order_status(order_payload) - result = { - "recharge_channel": "supplier_api", - "out_order_id": order_no, - "order_id": self._supplier_value(order_payload, "order_id", "orderId"), - "recharge_account": recharge_account, - "douyu_uid": str(account.uid or "").strip(), - "buy_num": amount, - "product_id": product_id, - "pay_amount": format(pay_amount.normalize(), "f"), - "order_type": 0, - "supplier_code": code, - "supplier_order_status": status, - "supplier_order": self._supplier_result(order_payload), - } - if code != 200: - self._mark_task(db, task, "failed", self._supplier_message(order_payload) or "供应商创建直充订单失败", result) - return - account.bind_status = "gold_api_order_created" - account.updated_at = datetime.now(timezone.utc) - self._update_task_progress(db, task, "running", "供应商直充订单已创建,等待到账", result) - if status not in {2, 3, 4}: - status = self._wait_supplier_gold_order(db, task, client, result) - if self._stop.is_set(): - self._mark_task(db, task, "stopped", "任务已停止", result) - return - if status == 2: - account.bind_status = "gold_recharged" - account.updated_at = datetime.now(timezone.utc) - self._mark_task(db, task, "success", "供应商直充成功", result) - return - if status in {3, 4}: - self._mark_task(db, task, "failed", "供应商直充失败", result) - return - self._mark_task(db, task, "failed", "供应商直充订单查询超时", result) - - def _execute_donate_elite_gift(self, db: Session, task: DouyuTask, account: Account, cookie: str, config: dict): - payload = self._task_payload(task) - gift_count = int(payload.get("gift_count") or payload.get("count") or 1) - client = self._client(cookie) - ctn = None - baseline_points = account.points - try: - ctn = client.acf_ccn(refresh_subscribe=False) - baseline_result = self._refresh_account_points(client, account, cookie, ctn=ctn) - db.commit() - baseline_points = baseline_result["points"] - except Exception as exc: - self._push_log("warning", f"赠送精英令前刷新积分失败: {exc}") - result = client.donate_elite_gift( - gift_count=gift_count, - room_id=str(payload.get("room_id") or config["room_id"]), - gift_id=str(payload.get("gift_id") or config["gift_id"]), - skin_id=str(payload.get("skin_id") or config["skin_id"]), - ) - result["gift_points_baseline"] = baseline_points - refresh_errors = [] - try: - result.update(self._refresh_account_gold_balance(client, account)) - except Exception as exc: - refresh_errors.append(f"鱼翅余额: {exc}") - try: - result.update( - self._refresh_points_after_elite_gift( - db, - task, - account, - client, - cookie, - ctn, - result, - baseline_points, - gift_count, - ) - ) - except Exception as exc: - refresh_errors.append(f"积分: {exc}") - if refresh_errors: - result["refresh_errors"] = refresh_errors - account.bind_status = "gift_donated" - account.updated_at = datetime.now(timezone.utc) - message = f"赠送精英令成功: {gift_count}" - if account.gold_balance is not None: - message += f",鱼翅余额: {account.gold_balance}" - if account.points is not None: - message += f",积分: {account.points}" - if result.get("gift_points_target") is not None and not result.get("gift_points_confirmed"): - message += f"(未确认涨到 {result['gift_points_target']})" - self._mark_task(db, task, "success", message, result) - - def _execute_query_points(self, db: Session, task: DouyuTask, account: Account, cookie: str, config: dict): - client = self._client(cookie) - ctn = client.acf_ccn(refresh_subscribe=False) - result = self._refresh_account_points(client, account, cookie, ctn=ctn) - account.bind_status = "points_queried" - account.updated_at = datetime.now(timezone.utc) - 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) - commodity_id = str(payload.get("commodity_id") or payload.get("commodityId") or "").strip() - if not commodity_id: - self._mark_task(db, task, "failed", "请选择兑换商品") - return - client = self._client(cookie) - 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", "任务已停止,商品锁单仍可能有效", locked) - return - try: - payment = client.pay_exchange_order( - manual_id=str(config["manual_id"]), - order_id=locked["order_id"], - ) - break - except DouyuActivityError as exc: - last_error = str(exc) - if attempt >= 8: - self._mark_task( - db, - task, - "failed", - f"锁单 {locked['order_id']} 支付失败(已重试{attempt}次): {last_error}", - {"commodity_id": commodity_id, "lock_order": locked, **locked}, - ) - return - self._push_log( - "info", - f" 锁单 {locked['order_id']} 支付重试 {attempt + 1}/8: {last_error}", - ) - time_mod.sleep(0.3) - 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) - .first() - ) - account.bind_status = "goods_exchanged" - account.updated_at = datetime.now(timezone.utc) - # 兑换成功后自动刷新积分,更新账号最新积分信息(失败不阻断兑换成功) - 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}") - self._mark_task( - db, - task, - "success", - f"兑换成功: {(goods.name if goods else '') or commodity_id}", - { - "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, - **(points_refresh or {}), - }, - ) - - def _execute_exchange_esports_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: - quantity = max(1, int(payload.get("quantity") or payload.get("num") or 1)) - except (TypeError, ValueError): - self._mark_task(db, task, "failed", "兑换数量必须是正整数") - return - - manual_id = str(config.get("esports_manual_id") or "").strip() - room_id = str(config.get("room_id") or "").strip() - if not manual_id or not room_id: - self._mark_task(db, task, "failed", "请先配置电竞手册 manualID 和房间 ID") - return - - client = self._client(cookie) - baseline_points = account.esports_points - try: - baseline = self._refresh_esports_handbook(client, account, manual_id=manual_id) - baseline_points = baseline["esports_points"] - db.commit() - except Exception as exc: - self._push_log("warning", f"兑换电竞皮肤前刷新积分失败: {exc}") - - result = client.exchange_esports_goods( - manual_id=manual_id, - rid=room_id, - commodity_id=commodity_id, - quantity=quantity, - ) - goods = ( - db.query(DouyuEsportsGoodsSnapshot) - .filter(DouyuEsportsGoodsSnapshot.commodity_id == commodity_id) - .first() - ) - result["goods"] = goods.raw if goods else None - result["esports_points_baseline"] = baseline_points - try: - points_result = self._refresh_esports_handbook(client, account, manual_id=manual_id) - result.update(points_result) - result["esports_points_after_exchange"] = points_result["esports_points"] - except Exception as exc: - result["esports_points_refresh_error"] = str(exc) - - account.esports_bind_status = "esports_goods_exchanged" - account.updated_at = datetime.now(timezone.utc) - name = (goods.name if goods else "") or commodity_id - message = f"兑换电竞皮肤成功: {name}" - if quantity > 1: - message += f" x{quantity}" - if account.esports_points is not None: - message += f",电竞积分: {account.esports_points}" - result["game_name"] = account.esports_game_name or "" - result["game_channel"] = account.esports_game_channel or "" - result["account_name"] = account.nickname or account.username or account.uid or f"#{account.id}" - self._mark_task(db, task, "success", message, result) - - def _execute_query_game_name(self, db: Session, task: DouyuTask, account: Account, cookie: str, config: dict): - client = self._client(cookie) - query_aliases = self._query_bind_act_aliases(config) - if not query_aliases: - self._mark_task(db, task, "failed", "请先配置绑定活动 actAlias") - return - candidates = self._fetch_bind_info_candidates(client, query_aliases) - if not candidates: - self._mark_task(db, task, "failed", "查询绑定信息失败,请检查 Cookie 或 actAlias") - return - - # 1) 优先“已生效绑定”角色(bound_act=1 且有角色名,通常是活动 alias)。 - # 避免把扫码后未确认的新角色当成当前绑定结果。 - bound_info = self._pick_current_bound_info(candidates, config) - # 2) 待确认角色(cjm 扫码后未确认;无已绑定时也用于首次绑定展示) - pending_info = self._pick_bind_info( - candidates, - prefer_pending=True, - prefer_aliases=query_aliases, - ) or candidates[0] - 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)} 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=-'}", - ) - # 只有已生效绑定才写账号表;待确认角色不污染 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": has_pending, - "bind_confirmed": is_bound, - "bind_phase": ( - "confirmed" if is_bound - else ("role_ready" if role_name else "waiting_role") - ), - "bind_summary": summary, - "bind_candidates": [ - { - "act_alias": item.get("act_alias"), - "role_name": item.get("role_name"), - "is_bound_act": self._is_bound_act(item), - } - for item in candidates - ], - } - # 已绑定角色之外另有待确认新角色时,单独字段展示,不覆盖 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 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) - - def _pick_change_wait_bind_info(self, candidates: list[dict], config: dict) -> dict | None: - """换绑倒计时优先看活动当前绑定(QYOOB),不是 cjm 换绑最新态。""" - if not candidates: - return None - # 冷却是“当前已生效绑定”的属性,不能用 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=action_aliases, - ) - - def _execute_query_change_bind_time(self, db: Session, task: DouyuTask, account: Account, cookie: str, config: dict): - client = self._client(cookie) - query_aliases = self._query_bind_act_aliases(config) - if not query_aliases: - self._mark_task(db, task, "failed", "请先配置绑定活动 actAlias") - return - candidates = self._fetch_bind_info_candidates(client, query_aliases) - 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) - wait_time = snapshot["change_role_wait_time"] - account.change_role_wait_time = wait_time - account.bind_status = "change_time_queried" - account.updated_at = datetime.now(timezone.utc) - source_alias = str(bind_info.get("act_alias") or "") - wait_text = self._format_wait_time(wait_time) - can_change = snapshot["can_change_role"] - role_name = snapshot["role_name"] or "-" - if wait_time is None: - if can_change is False: - status_text = "不可换绑(接口未返回倒计时)" - elif can_change is True: - status_text = "可换绑" - else: - status_text = "未返回换绑倒计时" - elif wait_time <= 0: - status_text = "可换绑" - wait_text = "0" - else: - status_text = f"剩余 {wait_text}" - result = { - "act_alias": source_alias, - "query_act_alias": source_alias, - "query_act_aliases": query_aliases, - **snapshot, - "change_role_wait_text": wait_text, - "bind_ready_for_confirm": bool(snapshot["role_name"]) and not snapshot["is_bound_act"], - "bind_confirmed": snapshot["is_bound_act"], - "bind_summary": f"act={source_alias or '-'} {self._format_bind_summary(bind_info)}", - "bind_candidates": [ - { - "act_alias": item.get("act_alias"), - "role_name": item.get("role_name"), - "is_bound_act": self._is_bound_act(item), - "change_role_wait_time": self._to_int(item.get("change_role_wait_time")), - "can_change_role": item.get("can_change_role"), - } - for item in candidates - ], - } - self._push_log( - "info", - f"查询换绑时间 hit={source_alias or '-'} role={role_name} " - f"wait={wait_time if wait_time is not None else '-'} can={can_change}", - ) - self._mark_task( - db, - task, - "success", - f"{role_name} {status_text} | act={source_alias or '-'}", - result, - ) - - def _execute_query_limited_goods(self, db: Session, task: DouyuTask, account: Account, cookie: str, config: dict): - client = self._client(cookie) - result = client.query_limited_goods(manual_id=str(config["manual_id"]), rid=str(config["rid"])) - limited = result["limited_goods"] - names = [str(item.get("commodityName") or "") for item in limited if item.get("commodityName")] - message = "无限制商品" if not names else f"限兑 {len(names)} 个: {', '.join(names[:5])}" - account.bind_status = "limited_goods_queried" - account.updated_at = datetime.now(timezone.utc) - self._mark_task(db, task, "success", message, {"limited_count": len(limited), "limited_goods": limited}) - - def _execute_query_gold_balance(self, db: Session, task: DouyuTask, account: Account, cookie: str, config: dict): - client = self._client(cookie) - result = self._refresh_account_gold_balance(client, account) - account.bind_status = "gold_balance_queried" - account.updated_at = datetime.now(timezone.utc) - self._mark_task( - db, - task, - "success", - f"鱼翅余额: {account.gold_balance if account.gold_balance is not None else '-'}", - result, - ) - - def _execute_query_exchange_records(self, db: Session, task: DouyuTask, account: Account, cookie: str, config: dict): - client = self._client(cookie) - result = client.exchange_records(manual_id=str(config["manual_id"])) - records = result["records"] - account.bind_status = "exchange_records_queried" - account.updated_at = datetime.now(timezone.utc) - self._mark_task(db, task, "success", f"兑换记录 {len(records)} 条" if records else "暂无兑换记录", result) - - def _execute_prefetch_csrf_token(self, db: Session, task: DouyuTask, account: Account, cookie: str, config: dict): - client = self._client(cookie) - token = client.csrf_token() - account.bind_status = "csrf_token_ready" - account.updated_at = datetime.now(timezone.utc) - self._mark_task(db, task, "success", "获取 csrf_token 成功", {"csrf_token": token, "cookie": client.cookie}) +from .douyu_service import latest_success_login_task, update_account_profile_from_cookie +from .douyu_runner_core import DouyuBatchRunnerCore, douyu_batch_registry # noqa: F401 (douyu_batch_registry 供 routers 重导出) +from .douyu_runner_bind import BindMixin +from .douyu_runner_manual import ManualMixin +from .douyu_runner_gold import GoldMixin +from .douyu_runner_donate import DonateMixin +from .douyu_runner_goods import GoodsMixin +from .douyu_runner_xpd import XpdMixin + + +class DouyuBatchRunner( + DouyuBatchRunnerCore, BindMixin, ManualMixin, GoldMixin, DonateMixin, GoodsMixin, XpdMixin, +): + """批量执行斗鱼活动任务(功能域 Mixin 聚合 + 批次调度)。""" def _execute_one(self, task_id: int, config: dict, total: int): worker_db = SessionLocal() @@ -3280,41 +164,3 @@ class DouyuBatchRunner: self._push_log("result", "") finally: self.db.close() - - -class DouyuBatchRegistry: - """管理运行中的斗鱼任务批次。""" - - def __init__(self): - self._batches: dict[str, dict] = {} - - def register(self, batch_id: str, log_queue: asyncio.Queue, - loop: asyncio.AbstractEventLoop, runner: DouyuBatchRunner): - self._batches[batch_id] = { - "log_queue": log_queue, - "loop": loop, - "runner": runner, - "finished": False, - "updated_at": time.time(), - } - - def get(self, batch_id: str): - return self._batches.get(batch_id) - - def pop(self, batch_id: str): - return self._batches.pop(batch_id, None) - - def mark_finished(self, batch_id: str): - if batch_id in self._batches: - self._batches[batch_id]["finished"] = True - self._batches[batch_id]["updated_at"] = time.time() - - def active_ids(self) -> set[str]: - return { - batch_id - for batch_id, info in self._batches.items() - if not info.get("finished") - } - - -douyu_batch_registry = DouyuBatchRegistry() diff --git a/web/backend/services/douyu_runner_bind.py b/web/backend/services/douyu_runner_bind.py new file mode 100644 index 0000000..ce2c9e4 --- /dev/null +++ b/web/backend/services/douyu_runner_bind.py @@ -0,0 +1,1200 @@ +"""斗鱼任务执行器:角色绑定(由 douyu_runner.py 按功能域拆分)。""" + +from __future__ import annotations +import time +from datetime import datetime, timezone +from sqlalchemy.orm import Session + +from core.douyu import DouyuActivityClient, DouyuActivityError +from ..models import Account, DouyuTask + +DOUYU_LEGACY_BIND_ACT_ALIAS = "20250213NQCYX" +DOUYU_BIND_ROLE_POLL_SECONDS = 65 +DOUYU_BIND_ROLE_POLL_INTERVAL = 5 +DOUYU_CONFIRM_EFFECT_POLL_TIMES = 3 +DOUYU_CONFIRM_EFFECT_POLL_INTERVAL = 3 + +class BindMixin: + """游戏角色绑定域:绑定状态机、扫码/确认绑定、换绑冷却。""" + @staticmethod + def _action_act_alias(config: dict, key: str) -> str: + """动作类接口用的活动 alias,排除只用于查询最新角色的 legacy alias。""" + alias = str(config.get(key) or "").strip() + query_only_alias = str(config.get("legacy_act_alias") or "").strip() + if not alias: + return "" + if alias in {query_only_alias, DOUYU_LEGACY_BIND_ACT_ALIAS}: + return "" + return alias + + @classmethod + def _bind_qr_act_alias(cls, config: dict) -> str: + """生成绑定二维码用的活动 alias。""" + return cls._action_act_alias(config, "bind_act_alias") or cls._action_act_alias(config, "confirm_act_alias") + + @staticmethod + def _query_bind_act_aliases(config: dict) -> list[str]: + """查询/轮询角色用的 alias 列表。 + + 现网最新绑定信息在 legacy(cjm);活动 alias 可能仍用于扫码/确认, + 所以按优先级去重返回多个,轮询时取“更像新扫码结果”的那个。 + """ + ordered = [ + str(config.get("legacy_act_alias") or "").strip(), + str(config.get("confirm_act_alias") or "").strip(), + str(config.get("bind_act_alias") or "").strip(), + ] + aliases: list[str] = [] + for alias in ordered: + if alias and alias not in aliases: + aliases.append(alias) + return aliases + + @classmethod + def _confirm_act_alias(cls, config: dict) -> str: + """确认绑定接口用的活动 alias。""" + return cls._action_act_alias(config, "confirm_act_alias") or cls._action_act_alias(config, "bind_act_alias") + + # 兼容旧调用名 + @classmethod + 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( + part for part in [bind_info.get("area_name"), bind_info.get("plat_name")] if part + ) + + @staticmethod + def _is_truthy_flag(value) -> bool: + if value is True: + return True + if value is False or value is None: + return False + text = str(value).strip().lower() + return text in {"1", "true", "yes", "y"} + + @classmethod + def _is_bound_act(cls, bind_info: dict | None) -> bool: + if not bind_info: + return False + return cls._is_truthy_flag(bind_info.get("is_bound_act")) + + @classmethod + def _can_change_role(cls, bind_info: dict | None) -> bool: + """综合 can_change_role 与换绑倒计时判断是否允许换绑。""" + if not bind_info: + return True + if str(bind_info.get("api_version") or "") == "esports": + can_change_time = cls._to_int(bind_info.get("can_change_time")) + if can_change_time is not None: + return can_change_time <= int(datetime.now(timezone.utc).timestamp()) + wait_time = cls._to_int(bind_info.get("change_role_wait_time")) + if wait_time is not None and wait_time > 0: + return False + if bind_info.get("can_change_role") is not None: + return cls._is_truthy_flag(bind_info.get("can_change_role")) + # 已绑定但接口没给倒计时/开关时,默认允许(避免误杀首次绑定) + return True + + @classmethod + def _is_change_cooling(cls, bind_info: dict | None) -> bool: + """是否处于换绑冷却:已有绑定角色且当前不可换绑。""" + if not bind_info: + return False + role_name = str(bind_info.get("role_name") or "").strip() + if not role_name or not cls._is_bound_act(bind_info): + return False + return not cls._can_change_role(bind_info) + + @classmethod + def _is_pending_role( + cls, + bind_info: dict | None, + *, + baseline_role_name: str = "", + baseline_is_bound_act: bool = False, + ) -> bool: + """判断是否出现了可确认的扫码角色。 + + 规则: + 1. 必须有角色名 + 2. 已绑定同一角色(或无 baseline 的已绑定)不算 pending + 3. 角色名相对 baseline 变化,或从已绑定变成待确认,算 pending + 4. need_bind_act / need_bind_role 且角色相对 baseline 有变化,算 pending + 5. 无 baseline 且未绑定但有角色,视为待确认残留 + """ + if not bind_info: + return False + role_name = str(bind_info.get("role_name") or "").strip() + if not role_name: + return False + is_bound_act = cls._is_bound_act(bind_info) + need_bind_act = cls._is_truthy_flag(bind_info.get("need_bind_act")) + need_bind_role = cls._is_truthy_flag(bind_info.get("need_bind_role")) + role_changed = bool(baseline_role_name) and role_name != baseline_role_name + if is_bound_act: + # 已绑定:仅当相对 baseline 角色发生变化时才视为新扫码结果 + return role_changed + if need_bind_act or need_bind_role: + if not baseline_role_name: + return True + return role_changed or baseline_is_bound_act + if not baseline_role_name: + return True + if role_changed: + return True + # 同一角色从已绑定变为未绑定 + return baseline_is_bound_act + + def _bind_snapshot(self, bind_info: dict | None) -> dict: + info = bind_info or {} + role_name = str(info.get("role_name") or "") + return { + "role_name": role_name, + "area_name": info.get("area_name") or "", + "plat_name": info.get("plat_name") or "", + "nickname": info.get("nickname") or "", + "is_bound_act": self._is_bound_act(info), + "is_bound_role": self._is_truthy_flag(info.get("is_bound_role")), + "is_bound_account": self._is_truthy_flag(info.get("is_bound_account")), + "need_bind_act": self._is_truthy_flag(info.get("need_bind_act")), + "need_bind_role": self._is_truthy_flag(info.get("need_bind_role")), + "change_role_wait_time": self._to_int(info.get("change_role_wait_time")), + "can_change_time": self._to_int(info.get("can_change_time")), + "can_change_role": self._can_change_role(info), + "bind_info": info, + } + + def _format_bind_summary(self, bind_info: dict | None, *, pending: bool | None = None) -> str: + snap = self._bind_snapshot(bind_info) + role = snap["role_name"] or "-" + area = snap["area_name"] or "-" + plat = snap["plat_name"] or "-" + pending_text = "" + if pending is not None: + pending_text = f" pending={1 if pending else 0}" + return ( + f"role={role} area={area} plat={plat}" + f" bound_act={1 if snap['is_bound_act'] else 0}" + f" bound_role={1 if snap['is_bound_role'] else 0}" + f" need_act={1 if snap['need_bind_act'] else 0}" + 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}" + ) + + def _apply_bind_info_to_account(self, account: Account, bind_info: dict, status: str) -> None: + role_name = str(bind_info.get("role_name") or "") + account.game_name = role_name or account.game_name + account.game_channel = self._role_channel(bind_info) or account.game_channel + account.change_role_wait_time = self._to_int(bind_info.get("change_role_wait_time")) + account.bind_status = status + account.updated_at = datetime.now(timezone.utc) + + def _apply_esports_bind_info_to_account(self, account: Account, bind_info: dict, status: str) -> None: + """将电竞手册角色状态写入专属字段,避免覆盖精英宝典数据。""" + role_name = str(bind_info.get("role_name") or "") + account.esports_game_name = role_name or account.esports_game_name + account.esports_game_channel = self._role_channel(bind_info) or account.esports_game_channel + account.esports_change_role_wait_time = self._to_int(bind_info.get("change_role_wait_time")) + account.esports_can_change_time = self._to_int(bind_info.get("can_change_time")) + account.esports_bind_status = status + account.updated_at = datetime.now(timezone.utc) + + def _esports_bind_state( + self, + client: DouyuActivityClient, + act_alias: str, + ) -> dict: + """查询电竞手册活动状态,接口同时返回当前角色和换绑冷却信息。""" + activity_info = client.esports_bind_info(act_alias) + activity_snapshot = self._bind_snapshot(activity_info) + esports_bound = activity_snapshot["is_bound_act"] + has_selected_role = bool(activity_snapshot["role_name"]) + tx_act = activity_info.get("tx_act") or {} + return { + **activity_snapshot, + "is_bound_role": has_selected_role, + "act_alias": act_alias, + "game_id": tx_act.get("gameId") or "", + "esports_bound": esports_bound, + "has_selected_role": has_selected_role, + "bind_ready_for_confirm": has_selected_role and not esports_bound, + "bind_confirmed": esports_bound, + "bind_phase": "confirmed" if esports_bound else ("role_ready" if has_selected_role else "waiting_role"), + "activity_bind_info": activity_info, + "activity_bind_snapshot": activity_snapshot, + "role_source": "activity", + } + + @staticmethod + def _esports_role_text(state: dict) -> str: + role_name = str(state.get("role_name") or "") + channel = " / ".join( + str(part) + for part in [state.get("plat_name"), state.get("area_name")] + if part + ) + if not role_name: + return "" + return f"{role_name}({channel})" if channel else role_name + + def _fetch_bind_info_candidates( + self, + client: DouyuActivityClient, + aliases: list[str], + ) -> list[dict]: + """按多个 actAlias 查询绑定信息,保留成功结果。""" + results: list[dict] = [] + for alias in aliases: + if not alias: + continue + try: + info = client.bind_info(alias, v2=True) + except DouyuActivityError as exc: + 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 + + def _pick_bind_info( + self, + candidates: list[dict], + *, + baseline_role_name: str = "", + baseline_is_bound_act: bool = False, + prefer_pending: bool = True, + prefer_aliases: list[str] | None = None, + ) -> dict | None: + """从多个 alias 结果里挑最有用的绑定信息。 + + - prefer_pending=True:优先选“待确认/新扫码角色”(通常来自 cjm) + - prefer_aliases:在同等条件下优先指定 alias(如活动当前绑定) + """ + if not candidates: + return None + + def _alias_rank(info: dict) -> int: + alias = str(info.get("act_alias") or "") + if not prefer_aliases: + return 0 + try: + return prefer_aliases.index(alias) + except ValueError: + return len(prefer_aliases) + 1 + + ranked = sorted(enumerate(candidates), key=lambda item: (_alias_rank(item[1]), item[0])) + ordered = [item[1] for item in ranked] + + if prefer_pending: + for info in ordered: + if self._is_pending_role( + info, + baseline_role_name=baseline_role_name, + baseline_is_bound_act=baseline_is_bound_act, + ): + return info + for info in ordered: + if str(info.get("role_name") or "").strip(): + 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], + config: dict, + ) -> dict | None: + """选“扫码前当前已绑定角色”作为 baseline。 + + 活动 alias(20260120QYOOB)只反映当前已绑定; + cjm 才是换绑最新态。baseline 应优先活动 alias 的已绑定结果, + 避免把 cjm 上的待确认新角色误当成扫码前旧角色。 + """ + if not candidates: + return None + return self._pick_current_bound_info(candidates, config) + + def _wait_bind_role_result( + self, + client: DouyuActivityClient, + db: Session, + task: DouyuTask, + account: Account, + query_aliases: list[str], + result: dict, + baseline_role_name: str = "", + baseline_is_bound_act: bool = False, + ) -> tuple[str, dict]: + """生成二维码后轮询绑定信息,直到识别到待确认角色、超时或停止。""" + deadline = time.monotonic() + DOUYU_BIND_ROLE_POLL_SECONDS + result["bind_polling"] = True + result["bind_phase"] = result.get("bind_phase") or "waiting_scan" + result["bind_ready_for_confirm"] = False + result["bind_confirmed"] = False + result["baseline_role_name"] = baseline_role_name + result["baseline_is_bound_act"] = baseline_is_bound_act + result["query_act_aliases"] = query_aliases + self._push_log( + "info", + "开始轮询绑定角色 " + f"aliases={','.join(query_aliases) or '-'} " + f"baseline={baseline_role_name or '-'} bound={1 if baseline_is_bound_act else 0}", + ) + self._update_task_progress(db, task, "running", "已生成绑定二维码,等待扫码绑定", result) + + poll_count = 0 + last_summary = "" + while not self._stop.is_set() and time.monotonic() < deadline: + if self._stop.wait(DOUYU_BIND_ROLE_POLL_INTERVAL): + break + + candidates = self._fetch_bind_info_candidates(client, query_aliases) + if not candidates: + result["bind_poll_error"] = "所有 actAlias 查询绑定信息失败" + self._update_task_progress(db, task, "running", "等待绑定角色同步: 查询失败", result) + continue + + poll_count += 1 + bind_info = self._pick_bind_info( + candidates, + baseline_role_name=baseline_role_name, + baseline_is_bound_act=baseline_is_bound_act, + prefer_pending=True, + prefer_aliases=query_aliases, + ) or candidates[0] + snapshot = self._bind_snapshot(bind_info) + is_pending_role = self._is_pending_role( + bind_info, + baseline_role_name=baseline_role_name, + baseline_is_bound_act=baseline_is_bound_act, + ) + query_alias = str(bind_info.get("act_alias") or "") + summary = ( + f"act={query_alias or '-'} " + f"{self._format_bind_summary(bind_info, pending=is_pending_role)}" + ) + # 字段变化或每 3 次打印一次,避免刷屏但仍能看到过程 + if summary != last_summary or poll_count == 1 or poll_count % 3 == 0: + self._push_log("info", f"轮询绑定#{poll_count}: {summary}") + last_summary = summary + + # 注意:未识别到新角色时,不要把当前已绑定角色写进 role_name, + # 否则前端会把旧角色误当成“待确认角色/查询结果”。 + if is_pending_role: + result.update({ + **snapshot, + "act_alias": query_alias, + "query_act_alias": query_alias, + "bind_ready_for_confirm": True, + "bind_confirmed": False, + "bind_phase": "role_ready", + "bind_polling": False, + "poll_count": poll_count, + "bind_summary": summary, + "bind_candidates": [ + { + "act_alias": item.get("act_alias"), + "role_name": item.get("role_name"), + "is_bound_act": self._is_bound_act(item), + } + for item in candidates + ], + }) + role_name = snapshot["role_name"] + # 待确认角色只回传前端展示,不写入账号表,避免“未换绑成功但角色信息已变新” + 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, + task, + "running", + f"已识别角色: {role_name},待确认绑定", + result, + ) + return role_name, result + + if snapshot["role_name"] and snapshot["is_bound_act"]: + result["current_role_name"] = snapshot["role_name"] + result["current_area_name"] = snapshot["area_name"] + result["current_plat_name"] = snapshot["plat_name"] + + result.update({ + "bind_info": bind_info, + "act_alias": query_alias, + "query_act_alias": query_alias, + "is_bound_act": snapshot["is_bound_act"], + "is_bound_role": snapshot["is_bound_role"], + "is_bound_account": snapshot["is_bound_account"], + "need_bind_act": snapshot["need_bind_act"], + "need_bind_role": snapshot["need_bind_role"], + "change_role_wait_time": snapshot["change_role_wait_time"], + "can_change_role": snapshot["can_change_role"], + "role_name": "", + "area_name": "", + "plat_name": "", + "bind_ready_for_confirm": False, + "bind_confirmed": False, + "bind_phase": "waiting_scan", + "bind_polling": True, + "poll_count": poll_count, + "bind_summary": summary, + }) + self._update_task_progress(db, task, "running", f"等待扫码绑定 ({summary})", result) + + result["bind_polling"] = False + result["bind_ready_for_confirm"] = False + result["bind_phase"] = "stopped" if self._stop.is_set() else "role_timeout" + result["poll_count"] = poll_count + if last_summary: + result["bind_summary"] = last_summary + self._push_log( + "warning", + f"轮询结束 phase={result['bind_phase']} polls={poll_count} last={last_summary or '-'}", + ) + return "", result + + def _execute_get_bind_qr(self, db: Session, task: DouyuTask, account: Account, cookie: str, config: dict): + client = self._client(cookie) + qr_act_alias = self._bind_qr_act_alias(config) + query_aliases = self._query_bind_act_aliases(config) + if not qr_act_alias and not query_aliases: + self._mark_task(db, task, "failed", "请先配置绑定活动 actAlias") + return + if qr_act_alias and qr_act_alias not in query_aliases: + query_aliases = [qr_act_alias, *query_aliases] + + before_candidates = self._fetch_bind_info_candidates(client, query_aliases) + if not before_candidates: + self._mark_task(db, task, "failed", "查询绑定信息失败,请检查 Cookie 或 actAlias") + return + # baseline 用活动 alias 的“当前已绑定”;pending 检测优先 cjm 的换绑最新态 + before = self._pick_baseline_bind_info(before_candidates, config) or {} + # 换绑冷却必须看活动当前绑定(QYOOB),不要用 cjm + 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_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"] if cooldown_snapshot["is_bound_act"] else "" + ) + wait_time = cooldown_snapshot["change_role_wait_time"] + self._push_log( + "info", + "绑定前状态 " + f"qr_act={qr_act_alias or '-'} query={','.join(query_aliases)} " + f"baseline_hit={before.get('act_alias') or '-'} {self._format_bind_summary(before)} " + f"cooldown_hit={cooldown_info.get('act_alias') or '-'} " + f"{self._format_bind_summary(cooldown_info)} " + f"pending_hit={pending_before.get('act_alias') or '-'} " + f"{self._format_bind_summary(pending_before)}", + ) + + # 生成二维码前强制检查换绑冷却:冷却中直接失败,绝不发码 + if self._is_change_cooling(cooldown_info): + role_label = current_role_name or cooldown_snapshot["role_name"] or "当前角色" + wait_text = self._format_wait_time(wait_time) or "冷却中" + self._push_log( + "warning", + f"换绑冷却中,跳过生成二维码 role={role_label} wait={wait_time if wait_time is not None else '-'} " + f"can={cooldown_info.get('can_change_role')}", + ) + result = { + "act_alias": qr_act_alias or cooldown_info.get("act_alias") or before.get("act_alias"), + "query_act_alias": cooldown_info.get("act_alias") or before.get("act_alias"), + "query_act_aliases": query_aliases, + "before_bind_info": before, + "cooldown_bind_info": cooldown_info, + **cooldown_snapshot, + "current_role_name": role_label if role_label != "当前角色" else current_role_name, + "current_area_name": cooldown_snapshot["area_name"] or before_snapshot["area_name"], + "current_plat_name": cooldown_snapshot["plat_name"] or before_snapshot["plat_name"], + "change_role_wait_time": wait_time, + "change_role_wait_text": self._format_wait_time(wait_time), + "bind_ready_for_confirm": False, + "bind_confirmed": bool(cooldown_snapshot["is_bound_act"] or before_snapshot["is_bound_act"]), + "bind_phase": "change_waiting", + "bind_polling": False, + } + self._apply_bind_info_to_account( + account, + cooldown_info if cooldown_snapshot["role_name"] else before, + "bind_confirmed" if result["bind_confirmed"] else "game_queried", + ) + self._mark_task( + db, + task, + "failed", + f"{role_label} 暂不能换绑,剩余 {wait_text}", + result, + ) + return + + if not qr_act_alias: + self._mark_task(db, task, "failed", "请先配置绑定二维码活动 actAlias") + return + + self._push_log( + "info", + f"换绑校验通过,开始生成二维码 act={qr_act_alias} " + f"role={current_role_name or '-'} wait={wait_time if wait_time is not None else 0}", + ) + qr_result = client.get_bind_qr(qr_act_alias) + result = { + **qr_result, + "act_alias": qr_act_alias, + "query_act_aliases": query_aliases, + "before_bind_info": before, + "current_role_name": current_role_name, + "current_area_name": before_snapshot["area_name"], + "current_plat_name": before_snapshot["plat_name"], + "role_name": "", + "area_name": "", + "plat_name": "", + "bind_ready_for_confirm": False, + "bind_confirmed": False, + "bind_phase": "waiting_scan", + "bind_polling": True, + } + account.bind_status = "bind_qr_generated" + account.updated_at = datetime.now(timezone.utc) + # 关键:先把二维码 progress 出去,前端 running 期间即可弹窗扫码。 + self._update_task_progress(db, task, "running", "已生成绑定二维码,等待扫码绑定", result) + + role_name, result = self._wait_bind_role_result( + client, + db, + task, + account, + query_aliases, + result, + baseline_role_name=current_role_name, + baseline_is_bound_act=before_snapshot["is_bound_act"], + ) + if role_name: + self._mark_task(db, task, "success", f"已识别角色: {role_name},待确认绑定", result) + return + if result.get("bind_phase") == "stopped": + self._mark_task(db, task, "stopped", "任务已停止", result) + return + last_summary = str(result.get("bind_summary") or "") + timeout_msg = "已生成绑定二维码,未检测到新扫码角色" + if current_role_name: + timeout_msg = f"{timeout_msg}(当前仍是 {current_role_name})" + if last_summary: + timeout_msg = f"{timeout_msg} | {last_summary}" + self._mark_task(db, task, "success", timeout_msg, result) + + def _execute_confirm_bind(self, db: Session, task: DouyuTask, account: Account, cookie: str, config: dict): + client = self._client(cookie) + confirm_alias = self._confirm_act_alias(config) + 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 + + before_candidates = self._fetch_bind_info_candidates(client, query_aliases) + if not before_candidates: + self._mark_task(db, task, "failed", "查询绑定信息失败,请检查 Cookie 或 actAlias") + return + before = self._pick_bind_info( + before_candidates, + baseline_role_name="", + baseline_is_bound_act=False, + prefer_pending=True, + prefer_aliases=query_aliases, + ) or before_candidates[0] + 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 = self._pick_current_bound_info( + before_candidates, + config, + extra_prefer_aliases=[confirm_alias], + ) + self._push_log( + "info", + f"确认前状态 confirm_act={confirm_alias or '-'} hit={query_alias or '-'} " + f"{self._format_bind_summary(before)}", + ) + if not role_name: + self._mark_task( + db, + task, + "failed", + "尚未识别到待确认角色,请先扫码完成绑定", + { + "act_alias": confirm_alias or query_alias, + "query_act_alias": query_alias, + "query_act_aliases": query_aliases, + "before_bind_info": before, + **before_snapshot, + "bind_ready_for_confirm": False, + "bind_confirmed": False, + "bind_phase": "waiting_role", + }, + ) + return + + 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, + "success", + f"已绑定: {role_name}", + { + "act_alias": confirm_alias or query_alias, + "query_act_alias": query_alias, + "query_act_aliases": query_aliases, + "before_bind_info": before_bound, + **self._bind_snapshot(before_bound), + "bind_ready_for_confirm": True, + "bind_confirmed": True, + "bind_phase": "confirmed", + }, + ) + return + + # 确认接口优先用配置的确认 alias;没有则回退到命中查询的 alias + use_confirm_alias = confirm_alias or query_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}") + # 待绑定游戏账号侧换绑限制(未到换绑时间等)导致确认失败:保留原绑定并给出明确提示 + 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, + "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: + """确认后回查:只认活动 alias 上的已生效绑定(bound_act=1)。""" + return self._pick_current_bound_info( + candidates, + config, + extra_prefer_aliases=[use_confirm_alias], + ) + + try: + after_candidates = self._fetch_bind_info_candidates(client, query_aliases) + 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: + # 已生效绑定始终未出现:确认未生效(或同步延迟超时),保留原绑定 + 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, + "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: + # 查询接口异常:确认接口已成功时按成功处理,但保留错误信息。 + # 写库优先确认前已生效绑定,避免待确认角色被误写入。 + 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, + "success", + f"绑定成功: {role_name}", + { + "act_alias": use_confirm_alias, + "query_act_alias": query_alias, + "query_act_aliases": query_aliases, + "before_bind_info": before, + "confirm": confirm_result, + **before_snapshot, + "bind_ready_for_confirm": True, + "bind_confirmed": True, + "bind_phase": "confirmed", + "refresh_error": str(exc), + }, + ) + return + + after_snapshot = self._bind_snapshot(after) + 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, + "success", + 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, + "after_bind_info": after, + **after_snapshot, + "bind_ready_for_confirm": True, + "bind_confirmed": True, + "bind_phase": "confirmed", + }, + ) + + def _execute_prepare_esports_bind( + self, + db: Session, + task: DouyuTask, + account: Account, + cookie: str, + config: dict, + ): + """打开电竞手册绑定面板:查活动状态、当前角色和换绑冷却。""" + client = self._client(cookie) + act_alias = str(config.get("esports_act_alias") or "").strip() + if not act_alias: + self._mark_task(db, task, "failed", "请先配置电竞手册活动 actAlias") + return + + state = self._esports_bind_state(client, act_alias) + esports_bound = state["esports_bound"] + role_text = self._esports_role_text(state) + self._apply_esports_bind_info_to_account( + account, + state, + "esports_bound" if esports_bound else ("esports_bind_ready" if role_text else "game_not_bound"), + ) + # 二维码用于重新选择角色,不应因为已有角色或换绑冷却而隐藏。 + # 冷却是否允许最终由 actBind 返回结果决定。 + qr_result = client.get_esports_bind_qr(act_alias) + result = { + **state, + **qr_result, + "esports_bind_dialog": True, + "can_open_role_selector": True, + } + if esports_bound: + message = f"电竞手册已绑定: {role_text or '-'},可扫码换绑" + elif role_text: + message = f"当前角色: {role_text},可扫码切换角色或直接完成绑定" + else: + message = "请扫码选择游戏角色,完成后查询最新角色" + self._mark_task(db, task, "success", message, result) + + def _execute_get_esports_bind_qr( + self, + db: Session, + task: DouyuTask, + account: Account, + cookie: str, + config: dict, + ): + """生成电竞手册切换角色用的腾讯入口。""" + client = self._client(cookie) + act_alias = str(config.get("esports_act_alias") or "").strip() + if not act_alias: + self._mark_task(db, task, "failed", "请先配置电竞手册活动 actAlias") + return + + state = self._esports_bind_state(client, act_alias) + qr_result = client.get_esports_bind_qr(act_alias) + result = { + **state, + **qr_result, + "esports_bind_dialog": True, + "can_open_role_selector": True, + "bind_phase": "switching_role", + } + account.esports_bind_status = "esports_role_switching" + account.updated_at = datetime.now(timezone.utc) + self._mark_task( + db, + task, + "success", + "请在腾讯页面选择角色,返回后查询最新角色", + result, + ) + + def _execute_confirm_esports_bind( + self, + db: Session, + task: DouyuTask, + account: Account, + cookie: str, + config: dict, + ): + """通过 actBind 确认电竞手册绑定,并回读唯一状态接口确认结果。""" + client = self._client(cookie) + act_alias = str(config.get("esports_act_alias") or "").strip() + if not act_alias: + self._mark_task(db, task, "failed", "请先配置电竞手册活动 actAlias") + return + + account.esports_bind_status = "esports_bind_confirming" + account.updated_at = datetime.now(timezone.utc) + self._update_task_progress( + db, + task, + "running", + "正在确认电竞手册绑定", + { + "bind_phase": "confirming", + "bind_confirmed": False, + }, + ) + confirm_result = client.confirm_esports_bind( + act_alias, + room_id=str(config.get("room_id") or "9263298"), + ) + after_state = self._esports_bind_state(client, act_alias) + result = { + **after_state, + "confirm": confirm_result, + "bind_phase": "confirmed" if after_state["esports_bound"] else "confirm_failed", + } + if not after_state["esports_bound"]: + self._apply_esports_bind_info_to_account(account, after_state, "esports_bind_ready") + self._mark_task( + db, + task, + "failed", + f"电竞手册绑定未生效: {self._esports_role_text(after_state) or '-'}", + result, + ) + return + + self._apply_esports_bind_info_to_account(account, after_state, "esports_bound") + self._mark_task( + db, + task, + "success", + f"电竞手册绑定成功: {self._esports_role_text(after_state) or '-'}", + result, + ) + + def _execute_query_esports_game_name( + self, + db: Session, + task: DouyuTask, + account: Account, + cookie: str, + config: dict, + ): + """查询电竞手册活动返回的最新角色和换绑冷却状态。""" + client = self._client(cookie) + act_alias = str(config.get("esports_act_alias") or "").strip() + if not act_alias: + self._mark_task(db, task, "failed", "请先配置电竞手册活动 actAlias") + return + + state = self._esports_bind_state(client, act_alias) + role_name = state["role_name"] + is_bound = state["esports_bound"] + self._apply_esports_bind_info_to_account( + account, + state, + "esports_bound" if is_bound else ("esports_bind_ready" if role_name else "game_not_bound"), + ) + result = { + **state, + "esports_bind_dialog": True, + "can_open_role_selector": True, + } + if role_name: + message = f"最新角色: {self._esports_role_text(state)}" + else: + message = "未查询到游戏角色,请先切换角色" + self._mark_task(db, task, "success", message, result) + + def _execute_query_game_name(self, db: Session, task: DouyuTask, account: Account, cookie: str, config: dict): + client = self._client(cookie) + query_aliases = self._query_bind_act_aliases(config) + if not query_aliases: + self._mark_task(db, task, "failed", "请先配置绑定活动 actAlias") + return + candidates = self._fetch_bind_info_candidates(client, query_aliases) + if not candidates: + self._mark_task(db, task, "failed", "查询绑定信息失败,请检查 Cookie 或 actAlias") + return + + # 1) 优先“已生效绑定”角色(bound_act=1 且有角色名,通常是活动 alias)。 + # 避免把扫码后未确认的新角色当成当前绑定结果。 + bound_info = self._pick_current_bound_info(candidates, config) + # 2) 待确认角色(cjm 扫码后未确认;无已绑定时也用于首次绑定展示) + pending_info = self._pick_bind_info( + candidates, + prefer_pending=True, + prefer_aliases=query_aliases, + ) or candidates[0] + 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)} 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=-'}", + ) + # 只有已生效绑定才写账号表;待确认角色不污染 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": has_pending, + "bind_confirmed": is_bound, + "bind_phase": ( + "confirmed" if is_bound + else ("role_ready" if role_name else "waiting_role") + ), + "bind_summary": summary, + "bind_candidates": [ + { + "act_alias": item.get("act_alias"), + "role_name": item.get("role_name"), + "is_bound_act": self._is_bound_act(item), + } + for item in candidates + ], + } + # 已绑定角色之外另有待确认新角色时,单独字段展示,不覆盖 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 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) + + def _pick_change_wait_bind_info(self, candidates: list[dict], config: dict) -> dict | None: + """换绑倒计时优先看活动当前绑定(QYOOB),不是 cjm 换绑最新态。""" + if not candidates: + return None + # 冷却是“当前已生效绑定”的属性,不能用 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=action_aliases, + ) + + def _execute_query_change_bind_time(self, db: Session, task: DouyuTask, account: Account, cookie: str, config: dict): + client = self._client(cookie) + query_aliases = self._query_bind_act_aliases(config) + if not query_aliases: + self._mark_task(db, task, "failed", "请先配置绑定活动 actAlias") + return + candidates = self._fetch_bind_info_candidates(client, query_aliases) + 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) + wait_time = snapshot["change_role_wait_time"] + account.change_role_wait_time = wait_time + account.bind_status = "change_time_queried" + account.updated_at = datetime.now(timezone.utc) + source_alias = str(bind_info.get("act_alias") or "") + wait_text = self._format_wait_time(wait_time) + can_change = snapshot["can_change_role"] + role_name = snapshot["role_name"] or "-" + if wait_time is None: + if can_change is False: + status_text = "不可换绑(接口未返回倒计时)" + elif can_change is True: + status_text = "可换绑" + else: + status_text = "未返回换绑倒计时" + elif wait_time <= 0: + status_text = "可换绑" + wait_text = "0" + else: + status_text = f"剩余 {wait_text}" + result = { + "act_alias": source_alias, + "query_act_alias": source_alias, + "query_act_aliases": query_aliases, + **snapshot, + "change_role_wait_text": wait_text, + "bind_ready_for_confirm": bool(snapshot["role_name"]) and not snapshot["is_bound_act"], + "bind_confirmed": snapshot["is_bound_act"], + "bind_summary": f"act={source_alias or '-'} {self._format_bind_summary(bind_info)}", + "bind_candidates": [ + { + "act_alias": item.get("act_alias"), + "role_name": item.get("role_name"), + "is_bound_act": self._is_bound_act(item), + "change_role_wait_time": self._to_int(item.get("change_role_wait_time")), + "can_change_role": item.get("can_change_role"), + } + for item in candidates + ], + } + self._push_log( + "info", + f"查询换绑时间 hit={source_alias or '-'} role={role_name} " + f"wait={wait_time if wait_time is not None else '-'} can={can_change}", + ) + self._mark_task( + db, + task, + "success", + f"{role_name} {status_text} | act={source_alias or '-'}", + result, + ) + diff --git a/web/backend/services/douyu_runner_core.py b/web/backend/services/douyu_runner_core.py new file mode 100644 index 0000000..a14f099 --- /dev/null +++ b/web/backend/services/douyu_runner_core.py @@ -0,0 +1,279 @@ +"""斗鱼任务执行器:公共基础(由 douyu_runner.py 按功能域拆分)。""" + +from __future__ import annotations + +import asyncio +import threading +import time +from datetime import datetime, timezone +from typing import Optional + +from loguru import logger +from sqlalchemy.orm import Session + +from core.douyu import DouyuActivityClient +from ..models import Account, DouyuEsportsGoodsSnapshot, DouyuGoodsSnapshot, DouyuTask, DouyuXpdGoodsSnapshot +from .douyu_service import DOUYU_CONFIG_FIELDS, douyu_config_value, ensure_douyu_config, douyu_task_payload + +# 支付/到账轮询(手册与充值共用) +DOUYU_PAYMENT_POLL_SECONDS = 600 +DOUYU_PAYMENT_POLL_INTERVAL = 5 + +class DouyuBatchRunnerCore: + """斗鱼任务执行器公共基础:批次状态、日志、任务落库与客户端构造。""" + def __init__( + self, + db: Session, + batch_id: str, + task_type: str, + payload: Optional[dict] = None, + log_queue: Optional[asyncio.Queue] = None, + loop: Optional[asyncio.AbstractEventLoop] = None, + concurrency: int = 3, + ): + self.db = db + self.batch_id = batch_id + self.task_type = task_type + self.payload = payload or {} + self.log_queue = log_queue + self.loop = loop + self.concurrency = max(1, min(concurrency, 10)) + self._stop = threading.Event() + self._counter_lock = threading.Lock() + self._started = 0 + + def stop(self): + self._stop.set() + + def _push_log(self, level: str, message: str): + if level == "result": + try: + douyu_batch_registry.mark_finished(self.batch_id) + except NameError: + pass + if level != "result" and message: + log_func = getattr(logger, level, logger.info) + log_func(f"[douyu] {message}") + if self.log_queue and self.loop: + asyncio.run_coroutine_threadsafe( + self.log_queue.put({"level": level, "message": message}), + self.loop, + ) + + @staticmethod + def _account_name(account: Account) -> str: + return account.nickname or account.username or account.uid or f"#{account.id}" + + @staticmethod + def _to_int(value) -> int | None: + if value is None: + return None + try: + return int(value) + except (TypeError, ValueError): + return None + + @staticmethod + def _format_wait_time(seconds: int | None) -> str: + if seconds is None: + return "" + seconds = max(0, int(seconds)) + days, rem = divmod(seconds, 86400) + hours, rem = divmod(rem, 3600) + minutes, sec = divmod(rem, 60) + if days: + return f"{days}天{hours}小时{minutes}分" + if hours: + return f"{hours}小时{minutes}分{sec}秒" + return f"{minutes}分{sec}秒" + + def _push_task_event(self, task: DouyuTask) -> None: + """向批次 WS 推送任务状态事件(level=task),前端即时更新不依赖轮询。""" + if not self.log_queue or not self.loop: + return + try: + payload = douyu_task_payload(task) + except Exception: + logger.exception("[douyu] 推送任务状态失败: task_id={}", task.id) + return + event = { + "level": "task", + "message": "", + "task": payload, + } + asyncio.run_coroutine_threadsafe(self.log_queue.put(event), self.loop) + + def _mark_task( + self, + db: Session, + task: DouyuTask, + status: str, + message: str, + result: dict | None = None, + ) -> None: + task.status = status + task.message = message[:512] + if result is not None: + task.result = result + task.finished_at = datetime.now(timezone.utc) + db.commit() + self._push_task_event(task) + + def _update_task_progress( + self, + db: Session, + task: DouyuTask, + status: str, + message: str, + result: dict | None = None, + ) -> None: + task.status = status + task.message = message[:512] + if result is not None: + task.result = result + db.commit() + self._push_task_event(task) + + def _upsert_goods(self, db: Session, goods: list[dict]) -> None: + now = datetime.now(timezone.utc) + for raw in goods: + commodity_id = str(raw.get("commodityId") or raw.get("commodity_id") or "") + if not commodity_id: + continue + row = ( + db.query(DouyuGoodsSnapshot) + .filter(DouyuGoodsSnapshot.commodity_id == commodity_id) + .first() + ) + score = self._to_int(raw.get("score")) + if row is None: + row = DouyuGoodsSnapshot(commodity_id=commodity_id) + db.add(row) + row.name = str(raw.get("commodityName") or raw.get("name") or "") + row.score = score + row.status = str(raw.get("status") or "") + row.raw = raw + row.updated_at = now + db.commit() + + def _upsert_esports_goods(self, db: Session, goods: list[dict]) -> None: + now = datetime.now(timezone.utc) + for raw in goods: + commodity_id = str(raw.get("commodityId") or raw.get("commodity_id") or "") + if not commodity_id: + continue + row = ( + db.query(DouyuEsportsGoodsSnapshot) + .filter(DouyuEsportsGoodsSnapshot.commodity_id == commodity_id) + .first() + ) + if row is None: + row = DouyuEsportsGoodsSnapshot(commodity_id=commodity_id) + db.add(row) + row.name = str(raw.get("commodityName") or raw.get("name") or "") + row.score = self._to_int(raw.get("score")) + row.status = str(raw.get("status") or "") + row.raw = raw + row.updated_at = now + db.commit() + + def _upsert_xpd_goods(self, db: Session, goods: list[dict]) -> None: + """同步和平小店商品快照,移除上一次热门抢购等遗留商品。""" + now = datetime.now(timezone.utc) + commodity_ids = { + str(raw.get("commodity_id") or raw.get("iGoodsId") or "") + for raw in goods + } + commodity_ids.discard("") + query = db.query(DouyuXpdGoodsSnapshot) + if commodity_ids: + query.filter(~DouyuXpdGoodsSnapshot.commodity_id.in_(commodity_ids)).delete( + synchronize_session=False, + ) + else: + query.delete(synchronize_session=False) + for raw in goods: + commodity_id = str(raw.get("commodity_id") or raw.get("iGoodsId") or "") + if not commodity_id: + continue + row = ( + db.query(DouyuXpdGoodsSnapshot) + .filter(DouyuXpdGoodsSnapshot.commodity_id == commodity_id) + .first() + ) + if row is None: + row = DouyuXpdGoodsSnapshot(commodity_id=commodity_id) + db.add(row) + row.name = str(raw.get("name") or raw.get("sGoodsName") or "") + row.price = self._to_int(raw.get("price") or raw.get("iPrice")) + row.org_price = self._to_int(raw.get("org_price") or raw.get("iOrgPrice")) + row.category = str(raw.get("category") or raw.get("iCategoryId") or "") + goods_left = raw.get("goods_left") + if goods_left is None: + goods_left = raw.get("iGoodsLeft") + row.goods_left = self._to_int(goods_left) + row.raw = raw + row.updated_at = now + db.commit() + + def _config_info(self, db: Session) -> dict: + config = ensure_douyu_config(db) + return {field: douyu_config_value(field, getattr(config, field, None)) for field in DOUYU_CONFIG_FIELDS} + + def _task_payload(self, task: DouyuTask) -> dict: + result = task.result if isinstance(task.result, dict) else {} + payload = result.get("payload") if isinstance(result.get("payload"), dict) else {} + return {**payload, **self.payload} + + def _client(self, cookie: str) -> DouyuActivityClient: + return DouyuActivityClient(cookie, logger=lambda msg: self._push_log("debug", msg)) + + def _sleep_interruptible(self, seconds: float) -> bool: + """分段睡眠,任务停止时提前返回;返回 False 表示已被停止。""" + waited = 0.0 + step = 0.5 + while waited < seconds: + if self._stop.is_set(): + return False + time.sleep(min(step, seconds - waited)) + waited += step + return not self._stop.is_set() + + +class DouyuBatchRegistry: + """管理运行中的斗鱼任务批次。""" + + def __init__(self): + self._batches: dict[str, dict] = {} + + def register(self, batch_id: str, log_queue: asyncio.Queue, + loop: asyncio.AbstractEventLoop, runner: DouyuBatchRunner): + self._batches[batch_id] = { + "log_queue": log_queue, + "loop": loop, + "runner": runner, + "finished": False, + "updated_at": time.time(), + } + + def get(self, batch_id: str): + return self._batches.get(batch_id) + + def pop(self, batch_id: str): + return self._batches.pop(batch_id, None) + + def mark_finished(self, batch_id: str): + if batch_id in self._batches: + self._batches[batch_id]["finished"] = True + self._batches[batch_id]["updated_at"] = time.time() + + def active_ids(self) -> set[str]: + return { + batch_id + for batch_id, info in self._batches.items() + if not info.get("finished") + } + + +douyu_batch_registry = DouyuBatchRegistry() + diff --git a/web/backend/services/douyu_runner_donate.py b/web/backend/services/douyu_runner_donate.py new file mode 100644 index 0000000..198f030 --- /dev/null +++ b/web/backend/services/douyu_runner_donate.py @@ -0,0 +1,234 @@ +"""斗鱼任务执行器:送礼(由 douyu_runner.py 按功能域拆分)。""" + +from __future__ import annotations +from datetime import datetime, timezone +from sqlalchemy.orm import Session + +from core.douyu import DouyuActivityClient +from ..models import Account, DouyuTask + +DOUYU_GIFT_POINTS_REFRESH_TIMES = 3 +DOUYU_GIFT_POINTS_REFRESH_INTERVAL = 2 + +class DonateMixin: + """送礼域:精英令/电竞任务礼物赠送与积分确认。""" + def _refresh_points_after_elite_gift( + self, + db: Session, + task: DouyuTask, + account: Account, + client: DouyuActivityClient, + cookie: str, + ctn: str | None, + result: dict, + baseline_points: int | None, + gift_count: int, + ) -> dict: + """赠送精英令后短轮询积分;1 个精英令约等于 10 积分。""" + expected_gain = max(0, gift_count) * 10 + target_points = baseline_points + expected_gain if baseline_points is not None else None + result["gift_points_baseline"] = baseline_points + result["gift_points_expected_gain"] = expected_gain + result["gift_points_target"] = target_points + + last_points = None + refresh_result: dict = {} + for index in range(1, DOUYU_GIFT_POINTS_REFRESH_TIMES + 1): + refresh_result = self._refresh_account_points(client, account, cookie, ctn=ctn) + db.commit() + last_points = refresh_result["points"] + result.update(refresh_result) + result["gift_points_refresh_count"] = index + if target_points is None or (last_points is not None and last_points >= target_points): + result["gift_points_confirmed"] = target_points is None or last_points is not None + return refresh_result + if index < DOUYU_GIFT_POINTS_REFRESH_TIMES: + self._update_task_progress( + db, + task, + "running", + f"赠送精英令成功,等待积分同步(当前 {last_points if last_points is not None else '-'},预期 {target_points})", + result, + ) + if self._stop.wait(DOUYU_GIFT_POINTS_REFRESH_INTERVAL): + break + + result["gift_points_confirmed"] = False + result["points"] = last_points + return refresh_result + + def _execute_donate_esports_gift( + self, + db: Session, + task: DouyuTask, + account: Account, + cookie: str, + config: dict, + *, + gift_name: str, + config_gift_id_key: str, + config_skin_id_key: str, + ): + """赠送电竞手册任务礼物并刷新独立积分。""" + payload = self._task_payload(task) + try: + gift_count = max(1, int(payload.get("gift_count") or payload.get("count") or 1)) + except (TypeError, ValueError): + self._mark_task(db, task, "failed", "赠送数量必须是正整数") + return + + manual_id = str(config.get("esports_manual_id") or "").strip() + gift_id = str(payload.get("gift_id") or config.get(config_gift_id_key) or "").strip() + skin_id = str(payload.get("skin_id") or config.get(config_skin_id_key) or "").strip() + room_id = str(payload.get("room_id") or config.get("room_id") or "").strip() + if not manual_id or not gift_id or not skin_id or not room_id: + self._mark_task(db, task, "failed", "请先完整配置电竞手册、房间和礼物参数") + return + + client = self._client(cookie) + baseline_points = account.esports_points + try: + baseline = self._refresh_esports_handbook(client, account, manual_id=manual_id) + baseline_points = baseline["esports_points"] + db.commit() + except Exception as exc: + self._push_log("warning", f"赠送{gift_name}前刷新电竞积分失败: {exc}") + + result = client.donate_esports_gift( + gift_name=gift_name, + gift_count=gift_count, + room_id=room_id, + gift_id=gift_id, + skin_id=skin_id, + ) + result.update( + { + "gift_name": gift_name, + "gift_id": gift_id, + "skin_id": skin_id, + "gift_count": gift_count, + "esports_points_baseline": baseline_points, + } + ) + refresh_errors = [] + try: + result.update(self._refresh_account_gold_balance(client, account)) + except Exception as exc: + refresh_errors.append(f"鱼翅余额: {exc}") + try: + points_result = self._refresh_esports_handbook(client, account, manual_id=manual_id) + result.update(points_result) + result["esports_points_after_gift"] = points_result["esports_points"] + result["esports_points_changed"] = ( + baseline_points is not None + and points_result["esports_points"] is not None + and points_result["esports_points"] != baseline_points + ) + except Exception as exc: + refresh_errors.append(f"电竞积分: {exc}") + if refresh_errors: + result["refresh_errors"] = refresh_errors + + account.esports_bind_status = "esports_gift_donated" + account.updated_at = datetime.now(timezone.utc) + message = f"赠送{gift_name}成功: {gift_count}" + if account.gold_balance is not None: + message += f",鱼翅余额: {account.gold_balance}" + if account.esports_points is not None: + message += f",电竞积分: {account.esports_points}" + self._mark_task(db, task, "success", message, result) + + def _execute_donate_esports_chicken_gift( + self, + db: Session, + task: DouyuTask, + account: Account, + cookie: str, + config: dict, + ): + """赠送冠军鸡腿。""" + self._execute_donate_esports_gift( + db, + task, + account, + cookie, + config, + gift_name="冠军鸡腿", + config_gift_id_key="esports_chicken_gift_id", + config_skin_id_key="esports_chicken_skin_id", + ) + + def _execute_donate_esports_firework_gift( + self, + db: Session, + task: DouyuTask, + account: Account, + cookie: str, + config: dict, + ): + """赠送冠军烟花。""" + self._execute_donate_esports_gift( + db, + task, + account, + cookie, + config, + gift_name="冠军烟花", + config_gift_id_key="esports_firework_gift_id", + config_skin_id_key="esports_firework_skin_id", + ) + + def _execute_donate_elite_gift(self, db: Session, task: DouyuTask, account: Account, cookie: str, config: dict): + payload = self._task_payload(task) + gift_count = int(payload.get("gift_count") or payload.get("count") or 1) + client = self._client(cookie) + ctn = None + baseline_points = account.points + try: + ctn = client.acf_ccn(refresh_subscribe=False) + baseline_result = self._refresh_account_points(client, account, cookie, ctn=ctn) + db.commit() + baseline_points = baseline_result["points"] + except Exception as exc: + self._push_log("warning", f"赠送精英令前刷新积分失败: {exc}") + result = client.donate_elite_gift( + gift_count=gift_count, + room_id=str(payload.get("room_id") or config["room_id"]), + gift_id=str(payload.get("gift_id") or config["gift_id"]), + skin_id=str(payload.get("skin_id") or config["skin_id"]), + ) + result["gift_points_baseline"] = baseline_points + refresh_errors = [] + try: + result.update(self._refresh_account_gold_balance(client, account)) + except Exception as exc: + refresh_errors.append(f"鱼翅余额: {exc}") + try: + result.update( + self._refresh_points_after_elite_gift( + db, + task, + account, + client, + cookie, + ctn, + result, + baseline_points, + gift_count, + ) + ) + except Exception as exc: + refresh_errors.append(f"积分: {exc}") + if refresh_errors: + result["refresh_errors"] = refresh_errors + account.bind_status = "gift_donated" + account.updated_at = datetime.now(timezone.utc) + message = f"赠送精英令成功: {gift_count}" + if account.gold_balance is not None: + message += f",鱼翅余额: {account.gold_balance}" + if account.points is not None: + message += f",积分: {account.points}" + if result.get("gift_points_target") is not None and not result.get("gift_points_confirmed"): + message += f"(未确认涨到 {result['gift_points_target']})" + self._mark_task(db, task, "success", message, result) + diff --git a/web/backend/services/douyu_runner_gold.py b/web/backend/services/douyu_runner_gold.py new file mode 100644 index 0000000..28f527a --- /dev/null +++ b/web/backend/services/douyu_runner_gold.py @@ -0,0 +1,365 @@ +"""斗鱼任务执行器:鱼翅充值(由 douyu_runner.py 按功能域拆分)。""" + +from __future__ import annotations +import re +import time +from decimal import Decimal +from datetime import datetime, timezone +from sqlalchemy.orm import Session + +from core.douyu import DouyuActivityClient, FishFinRechargeClient, FishFinRechargeConfig, FishFinRechargeError +from ..models import Account, DouyuTask +from .douyu_service import update_account_profile_from_cookie + +class GoldMixin: + """鱼翅充值域:扫码充值、供应商直充与到账轮询。""" + def _refresh_account_gold_balance(self, client: DouyuActivityClient, account: Account) -> dict: + """刷新鱼翅和钱包兑换余额并写回账号表。""" + gold = client.gold_account() + exchange = client.exchange_balance() + account.gold_balance = self._to_int(gold.get("gold")) + account.exchange_balance = self._to_int(exchange.get("count")) + account.updated_at = datetime.now(timezone.utc) + return { + "gold_balance": account.gold_balance, + "exchange_balance": account.exchange_balance, + "gold": gold, + "exchange_balance_query": exchange, + } + + def _wait_gold_balance_after_payment( + self, + db: Session, + task: DouyuTask, + account: Account, + client: DouyuActivityClient, + result: dict, + baseline_gold: int | None, + ) -> bool: + """等待鱼翅充值到账;余额变化后写回账号表。""" + deadline = time.monotonic() + DOUYU_PAYMENT_POLL_SECONDS + result["payment_polling"] = True + result["baseline_gold_balance"] = baseline_gold + poll_count = 0 + last_gold = baseline_gold + baseline_ready = baseline_gold is not None + while not self._stop.is_set() and time.monotonic() <= deadline: + try: + balance_result = self._refresh_account_gold_balance(client, account) + db.commit() + poll_count += 1 + last_gold = balance_result["gold_balance"] + result.update(balance_result) + result["payment_poll_count"] = poll_count + result["payment_polling"] = True + if not baseline_ready and last_gold is not None: + baseline_gold = last_gold + result["baseline_gold_balance"] = baseline_gold + baseline_ready = True + self._update_task_progress( + db, + task, + "running", + f"鱼翅支付码已生成,已记录当前余额 {last_gold},等待到账", + result, + ) + if self._stop.wait(DOUYU_PAYMENT_POLL_INTERVAL): + break + continue + changed = last_gold is not None and (baseline_gold is None or last_gold != baseline_gold) + if changed: + result["payment_polling"] = False + result["gold_recharged"] = True + return True + self._update_task_progress( + db, + task, + "running", + f"鱼翅支付码已生成,等待到账(当前鱼翅 {last_gold if last_gold is not None else '-'})", + result, + ) + except Exception as exc: + poll_count += 1 + result["payment_poll_count"] = poll_count + result["payment_poll_error"] = str(exc) + self._update_task_progress(db, task, "running", f"等待鱼翅到账: {exc}", result) + if self._stop.wait(DOUYU_PAYMENT_POLL_INTERVAL): + break + result["payment_polling"] = False + result["gold_recharged"] = False + result["gold_balance"] = last_gold + return False + + @staticmethod + def _supplier_value(payload: dict, *keys: str): + """兼容供应商将订单字段放在响应根节点、data 或 result 节点。""" + data = payload.get("data") if isinstance(payload.get("data"), dict) else {} + result = payload.get("result") if isinstance(payload.get("result"), dict) else {} + for source in (payload, data, result): + for key in keys: + if source.get(key) is not None: + return source[key] + return None + + @classmethod + def _supplier_order_status(cls, payload: dict) -> int | None: + """提取供应商订单状态,文档约定 0-4。""" + return cls._to_int(cls._supplier_value(payload, "order_status", "orderStatus", "supplier_order_status")) + + @classmethod + def _supplier_message(cls, payload: dict) -> str: + """提取供应商可展示的业务消息。""" + value = cls._supplier_value(payload, "msg", "message", "error_msg") + return str(value or "")[:256] + + @staticmethod + def _supplier_result(payload: dict) -> dict: + """保存必要订单状态,避免把完整供应商响应或签名暴露到任务结果。""" + data = payload.get("data") if isinstance(payload.get("data"), dict) else {} + response_result = payload.get("result") if isinstance(payload.get("result"), dict) else {} + result = { + key: value + for key, value in {**payload, **data, **response_result}.items() + if key not in {"sign", "cards", "card_no", "card_pwd", "recharge_arg"} + } + return result + + @staticmethod + def _supplier_out_order_id(task: DouyuTask) -> str: + """生成可追踪的供应商外部订单号;已有订单号必须在重试时复用。""" + existing = str(task.supplier_out_order_id or "").strip() + if existing: + return existing + batch_token = re.sub(r"[^A-Za-z0-9]", "", str(task.batch_id or "")).upper()[:16] or "LOCAL" + return f"DYGF{batch_token}T{task.id}" + + def _wait_supplier_gold_order( + self, + db: Session, + task: DouyuTask, + client: FishFinRechargeClient, + result: dict, + ) -> int | None: + """轮询供应商直充订单至结束状态。""" + order_no = str(result["out_order_id"]) + deadline = time.monotonic() + DOUYU_PAYMENT_POLL_SECONDS + poll_count = 0 + result["payment_polling"] = True + while not self._stop.is_set() and time.monotonic() <= deadline: + try: + # 回调可能已在另一个数据库会话中结束订单,刷新后直接使用其结果。 + db.refresh(task) + if task.status in {"success", "failed"}: + callback_result = task.result if isinstance(task.result, dict) else result + result.update(callback_result) + result["payment_polling"] = False + return self._supplier_order_status(callback_result) + payload = client.query_order(order_no) + code = self._to_int(self._supplier_value(payload, "code")) + status = self._supplier_order_status(payload) + poll_count += 1 + result.update({ + "payment_poll_count": poll_count, + "supplier_code": code, + "supplier_order_status": status, + "supplier_order": self._supplier_result(payload), + }) + if code != 200: + result["payment_polling"] = False + return status if status in {2, 3, 4} else 4 + if status in {2, 3, 4}: + result["payment_polling"] = False + return status + self._update_task_progress( + db, + task, + "running", + f"供应商直充订单处理中(状态 {status if status is not None else '-'})", + result, + ) + except FishFinRechargeError as exc: + poll_count += 1 + result["payment_poll_count"] = poll_count + result["payment_poll_error"] = str(exc) + self._update_task_progress(db, task, "running", f"查询供应商订单失败: {exc}", result) + if self._stop.wait(DOUYU_PAYMENT_POLL_INTERVAL): + break + result["payment_polling"] = False + return None + + def _execute_create_gold_qr(self, db: Session, task: DouyuTask, account: Account, cookie: str, config: dict): + payload = self._task_payload(task) + amount = int(payload.get("amount") or payload.get("gold_amount") or 1) + channel = str(config.get("gold_recharge_channel") or "wechat_qr") + if channel == "supplier_api": + try: + self._execute_create_gold_supplier_order(db, task, account, cookie, config, amount) + except FishFinRechargeError as exc: + self._mark_task(db, task, "failed", str(exc), {"recharge_channel": "supplier_api"}) + return + client = self._client(cookie) + baseline_gold = account.gold_balance + try: + baseline = self._refresh_account_gold_balance(client, account) + baseline_gold = baseline["gold_balance"] + db.commit() + except Exception as exc: + self._push_log("warning", f"生成鱼翅码前刷新余额失败: {exc}") + result = client.create_gold_qr(amount=amount, pay_type=int(config["gold_pay_type"])) + account.bind_status = "gold_qr_created" + account.updated_at = datetime.now(timezone.utc) + self._update_task_progress(db, task, "running", f"鱼翅 {amount} 元支付码已生成,等待到账", result) + recharged = self._wait_gold_balance_after_payment(db, task, account, client, result, baseline_gold) + if self._stop.is_set(): + self._mark_task(db, task, "stopped", "任务已停止", result) + return + if recharged: + account.bind_status = "gold_recharged" + account.updated_at = datetime.now(timezone.utc) + self._mark_task( + db, + task, + "success", + f"鱼翅已到账,当前余额: {account.gold_balance if account.gold_balance is not None else '-'}", + result, + ) + return + self._mark_task( + db, + task, + "failed", + f"未检测到鱼翅到账,当前余额: {account.gold_balance if account.gold_balance is not None else '-'}", + result, + ) + + def _execute_create_gold_supplier_order( + self, + db: Session, + task: DouyuTask, + account: Account, + cookie: str, + config: dict, + amount: int, + ) -> None: + """创建供应商鱼翅直充订单并轮询订单状态。""" + product_id = str(config.get("gold_api_product_id") or "").strip() + template_name = str(config.get("gold_api_account_template_name") or "斗鱼昵称").strip() + if not product_id: + raise FishFinRechargeError("请先在配置中填写供应商直充商品 ID") + # 充值商品按斗鱼昵称识别账号,UID 只能作为审计信息,不能作为充值值。 + update_account_profile_from_cookie(account, cookie) + recharge_account = str(account.nickname or "").strip() + if not recharge_account: + raise FishFinRechargeError("账号缺少斗鱼昵称,无法发起供应商直充") + + # 首次生成后持久化,网络重试或进程重启都继续查询同一笔订单。 + order_no = self._supplier_out_order_id(task) + task.supplier_out_order_id = order_no + db.commit() + # pay_amount 是用户选择的充值面值;goodsFaceValue=0.993 是供货成本,不能作为支付金额。 + pay_amount = Decimal(amount) + + def trace(event: dict) -> None: + """将脱敏供应商协议信息输出到任务日志,便于线上联调。""" + stage = event.get("stage") + if stage == "request": + params = event.get("params") or {} + self._push_log( + "info", + "供应商直充 | 下单 " + f"| 外部单号={params.get('out_order_id') or '-'} " + f"| 数量={params.get('buy_num') or '-'} " + f"| 金额={params.get('pay_amount') or '-'} " + f"| 商品={params.get('product_id') or '-'}", + ) + if event.get("json_body"): + self._push_log( + "debug", + "供应商协议 | 请求 " + f"| {event.get('method')} {event.get('path')} " + f"| 签名摘要={event.get('sign_digest')} " + f"| 参数={FishFinRechargeClient._json_text(event['json_body'])}", + ) + elif stage == "response": + status = self._to_int(event.get("order_status")) + status_labels = {0: "待处理", 1: "处理中", 2: "成功", 3: "失败", 4: "异常"} + status_text = status_labels.get(status, "-") + reason = str(event.get("fail_reason") or event.get("message") or "-") + self._push_log( + "info", + "供应商直充 | 响应 " + f"| HTTP={event.get('http_status') or '-'} " + f"| 业务码={event.get('code') or '-'} " + f"| 外部单号={event.get('out_order_id') or '-'} " + f"| 供应商单号={event.get('order_id') or '-'} " + f"| 状态={status_text} " + f"| 提示={reason}", + ) + if event.get("response_body"): + self._push_log( + "debug", + "供应商协议 | 响应 " + f"| HTTP={event.get('http_status')} " + f"| 内容={FishFinRechargeClient._json_text(event['response_body'])}", + ) + + client = FishFinRechargeClient(FishFinRechargeConfig.from_env(), trace=trace) + order_payload = client.create_order( + buy_num=amount, + pay_amount=pay_amount, + out_order_id=order_no, + product_id=product_id, + recharge_arg=[{"templateName": template_name, "templateVal": recharge_account}], + order_type=0, + notify_url=client.config.notify_url, + ) + code = self._to_int(self._supplier_value(order_payload, "code")) + status = self._supplier_order_status(order_payload) + result = { + "recharge_channel": "supplier_api", + "out_order_id": order_no, + "order_id": self._supplier_value(order_payload, "order_id", "orderId"), + "recharge_account": recharge_account, + "douyu_uid": str(account.uid or "").strip(), + "buy_num": amount, + "product_id": product_id, + "pay_amount": format(pay_amount.normalize(), "f"), + "order_type": 0, + "supplier_code": code, + "supplier_order_status": status, + "supplier_order": self._supplier_result(order_payload), + } + if code != 200: + self._mark_task(db, task, "failed", self._supplier_message(order_payload) or "供应商创建直充订单失败", result) + return + account.bind_status = "gold_api_order_created" + account.updated_at = datetime.now(timezone.utc) + self._update_task_progress(db, task, "running", "供应商直充订单已创建,等待到账", result) + if status not in {2, 3, 4}: + status = self._wait_supplier_gold_order(db, task, client, result) + if self._stop.is_set(): + self._mark_task(db, task, "stopped", "任务已停止", result) + return + if status == 2: + account.bind_status = "gold_recharged" + account.updated_at = datetime.now(timezone.utc) + self._mark_task(db, task, "success", "供应商直充成功", result) + return + if status in {3, 4}: + self._mark_task(db, task, "failed", "供应商直充失败", result) + return + self._mark_task(db, task, "failed", "供应商直充订单查询超时", result) + + def _execute_query_gold_balance(self, db: Session, task: DouyuTask, account: Account, cookie: str, config: dict): + client = self._client(cookie) + result = self._refresh_account_gold_balance(client, account) + account.bind_status = "gold_balance_queried" + account.updated_at = datetime.now(timezone.utc) + self._mark_task( + db, + task, + "success", + f"鱼翅余额: {account.gold_balance if account.gold_balance is not None else '-'}", + result, + ) + diff --git a/web/backend/services/douyu_runner_goods.py b/web/backend/services/douyu_runner_goods.py new file mode 100644 index 0000000..cc5d08b --- /dev/null +++ b/web/backend/services/douyu_runner_goods.py @@ -0,0 +1,485 @@ +"""斗鱼任务执行器:商城兑换(由 douyu_runner.py 按功能域拆分)。""" + +from __future__ import annotations +import random +import time +from datetime import datetime, timezone +from sqlalchemy.orm import Session + +from core.douyu import DouyuActivityClient, DouyuActivityError +from ..models import Account, DouyuEsportsGoodsSnapshot, DouyuGoodsSnapshot, DouyuTask + +# 兑换节奏与重试(对齐 8.30 浏览器抓包:锁单->支付间隔约 2.2~4.2s;火爆类错误要长退避而不是秒级连打) +DOUYU_EXCHANGE_PRE_CREATE_JITTER = (0.3, 1.2) +DOUYU_EXCHANGE_LOCK_PAY_DELAY_RANGE = (2.0, 4.0) +DOUYU_EXCHANGE_LOCK_TTL_SECONDS = 300 +DOUYU_EXCHANGE_RATE_LIMIT_HINTS = ("火爆", "频繁", "稍后", "太热", "限流", "繁忙", "人多", "排队", "手慢") +DOUYU_EXCHANGE_RATE_LIMIT_BACKOFFS = (30, 60, 90) +DOUYU_EXCHANGE_GENERIC_BACKOFFS = (2, 5, 10, 20) + +class GoodsMixin: + """商城兑换域:商品刷新、锁单/支付/兑换、兑换节奏与退避重试。""" + def _execute_refresh_goods(self, db: Session, task: DouyuTask, account: Account, cookie: str, config: dict): + client = self._client(cookie) + result = client.list_goods(manual_id=config["manual_id"], rid=config["rid"]) + goods = result["goods"] + self._upsert_goods(db, goods) + account.bind_status = account.bind_status or "active" + account.updated_at = datetime.now(timezone.utc) + self._mark_task(db, task, "success", f"已刷新商品 {len(goods)} 个", {"goods_count": len(goods), "goods": goods}) + + def _execute_refresh_esports_goods( + self, + db: Session, + task: DouyuTask, + account: Account, + cookie: str, + config: dict, + ): + """刷新电竞手册皮肤商城快照。""" + client = self._client(cookie) + result = client.list_esports_goods( + manual_id=str(config["esports_manual_id"]), + rid=str(config["room_id"]), + ) + goods = result["goods"] + self._upsert_esports_goods(db, goods) + account.esports_bind_status = "esports_goods_refreshed" + account.updated_at = datetime.now(timezone.utc) + self._mark_task( + db, + task, + "success", + f"已刷新电竞皮肤 {len(goods)} 个", + {"goods_count": len(goods), "esports_store_score": result["score"], "goods": goods}, + ) + + 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, + rid=str(config.get("rid") or ""), + ) + 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, + ) + + @staticmethod + def _is_rate_limited_error(message: str) -> bool: + """识别频控/火爆类错误文案,命中时按长退避重试。""" + return any(hint in message for hint in DOUYU_EXCHANGE_RATE_LIMIT_HINTS) + + def _pay_exchange_with_backoff( + self, + client: DouyuActivityClient, + *, + manual_id: str, + order_id: str, + rid: str, + locked_at: float, + ) -> dict | None: + """支付锁单,按错误类型退避重试(不再 0.3s 无脑连打)。 + + 频控/火爆类错误:30/60/90 秒退避,最多 4 次尝试且不超出锁单有效期; + 其他错误:2/5/10/20 秒退避,最多 5 次尝试。 + """ + for attempt in range(6): + if self._stop.is_set(): + return None + try: + return client.pay_exchange_order( + manual_id=manual_id, + order_id=order_id, + rid=rid, + ) + except DouyuActivityError as exc: + last_error = str(exc) + if self._is_rate_limited_error(last_error): + if attempt >= len(DOUYU_EXCHANGE_RATE_LIMIT_BACKOFFS): + raise DouyuActivityError( + f"锁单 {order_id} 支付被频控拦截(已退避重试{attempt}次): {last_error}" + ) from exc + backoff = DOUYU_EXCHANGE_RATE_LIMIT_BACKOFFS[attempt] + else: + if attempt >= len(DOUYU_EXCHANGE_GENERIC_BACKOFFS): + raise DouyuActivityError( + f"锁单 {order_id} 支付失败(已重试{attempt}次): {last_error}" + ) from exc + backoff = DOUYU_EXCHANGE_GENERIC_BACKOFFS[attempt] + # 超出锁单有效期(服务端 300s)前留 15s 余量,放弃继续重试 + remaining = locked_at + DOUYU_EXCHANGE_LOCK_TTL_SECONDS - time.time() + if remaining - backoff < 15: + raise DouyuActivityError( + f"锁单 {order_id} 支付失败且临近过期({int(remaining)}s): {last_error}" + ) from exc + kind = "频控" if self._is_rate_limited_error(last_error) else "错误" + self._push_log( + "info", + f" 锁单 {order_id} 支付{kind}退避 {backoff:.0f}s 后重试 {attempt + 1}/" + f"{len(DOUYU_EXCHANGE_RATE_LIMIT_BACKOFFS) if self._is_rate_limited_error(last_error) else len(DOUYU_EXCHANGE_GENERIC_BACKOFFS) + 1}: {last_error}", + ) + if not self._sleep_interruptible(backoff): + return None + return None + + def _execute_exchange_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 + client = self._client(cookie) + manual_id = str(config["manual_id"]) + rid = str(config.get("rid") or "") + result: dict = {"commodity_id": commodity_id} + + # ---- 对齐浏览器:先查手册状态 + 商品详情,按页面状态机选兑换链路 ---- + manual_type = None + try: + user_info = client.elite_user_info(manual_id=manual_id, rid=rid) + manual_type = user_info.get("manual_type") + except DouyuActivityError as exc: + self._push_log("warning", f"查询手册状态失败,按经典流程继续: {exc}") + detail = None + try: + detail = client.query_goods_detail(manual_id=manual_id, commodity_id=commodity_id, rid=rid)["detail"] + except DouyuActivityError as exc: + self._push_log("warning", f"查询商品详情失败,按经典流程继续: {exc}") + + batch_num_limit = None + if detail and int(detail.get("batchExchange") or 0) > 0: + try: + batch_num_limit = client.batch_exchange_limit( + manual_id=manual_id, commodity_id=commodity_id, rid=rid + )["limit"] + except DouyuActivityError as exc: + self._push_log("warning", f"查询批量兑换上限失败: {exc}") + + plan = DouyuActivityClient.resolve_exchange_plan( + detail or {}, manual_type=manual_type, batch_num_limit=batch_num_limit + ) + result["plan"] = plan + if detail: + result["detail"] = detail + action = plan["action"] + + def finish_failed(message: str) -> None: + self._mark_task( + db, task, "failed", message, + {"commodity_id": commodity_id, "plan": plan, "detail": detail}, + ) + + if action in ("blocked", "wait"): + finish_failed(f"兑换失败: {plan['text']}") + return + + goods = ( + db.query(DouyuGoodsSnapshot) + .filter(DouyuGoodsSnapshot.commodity_id == commodity_id) + .first() + ) + name = (goods.name if goods else "") or commodity_id + + if action == "subscribe": + try: + client.subscribe_commodity(manual_id=manual_id, commodity_id=commodity_id) + except DouyuActivityError as exc: + finish_failed(f"预约到货失败: {exc}") + return + account.bind_status = "goods_subscribed" + account.updated_at = datetime.now(timezone.utc) + self._mark_task( + db, task, "success", f"已预约到货: {name} ({plan['text']})", + {"commodity_id": commodity_id, "plan": plan, "detail": detail}, + ) + return + + if action == "pre_exchange": + try: + check = client.pre_exchange_check(manual_id=manual_id, commodity_id=commodity_id) + user_status = int((check.get("data") or {}).get("userStatus") or 0) + if user_status == 1 and not plan["pre_exchange"]: + self._mark_task( + db, task, "success", f"已预兑: {name},等待开放后继续兑换", + {"commodity_id": commodity_id, "plan": plan, "detail": detail, "check": check}, + ) + return + confirm = client.confirm_pre_exchange(manual_id=manual_id, commodity_id=commodity_id, rid=rid) + except DouyuActivityError as exc: + finish_failed(f"预兑失败: {exc}") + return + account.bind_status = "goods_pre_exchanged" + account.updated_at = datetime.now(timezone.utc) + self._mark_task( + db, task, "success", f"预兑成功: {name},开放后可继续兑换", + {"commodity_id": commodity_id, "plan": plan, "detail": detail, "check": check, "confirm": confirm}, + ) + return + + # ---- 经典链路:锁单 -> 人类节奏等待 -> 支付(错误分类退避重试)---- + try: + num = max(1, int(payload.get("num") or 1)) + except (TypeError, ValueError): + num = 1 + num = min(num, int(plan.get("max_num") or 1)) + + # 少量随机抖动(浏览器点击商品到锁单之间有人工间隔) + self._push_log("info", f" 兑换 {name}: {plan['text']}") + if not self._sleep_interruptible(random.uniform(*DOUYU_EXCHANGE_PRE_CREATE_JITTER)): + self._mark_task(db, task, "stopped", "任务已停止", result) + return + try: + locked = client.create_exchange_order( + manual_id=manual_id, + rid=rid, + commodity_id=commodity_id, + num=num, + ) + except DouyuActivityError as exc: + # 锁单本身被频控时做一次长退避重试,避免无脑连打 + backoff = DOUYU_EXCHANGE_RATE_LIMIT_BACKOFFS[0] if self._is_rate_limited_error(str(exc)) else 3 + self._push_log("info", f" 锁定兑换商品失败({exc}),{backoff:.0f}s 后重试一次") + if not self._sleep_interruptible(backoff): + self._mark_task(db, task, "stopped", "任务已停止", result) + return + try: + locked = client.create_exchange_order( + manual_id=manual_id, + rid=rid, + commodity_id=commodity_id, + num=num, + ) + except DouyuActivityError as exc2: + finish_failed(f"锁定兑换商品失败: {exc2}") + return + result.update({**locked, "lock_order": locked, "goods": goods.raw if goods else None}) + + # 锁单成功后按浏览器节奏(抓包实测 2.24s~4.2s)等待再支付 + delay = random.uniform(*DOUYU_EXCHANGE_LOCK_PAY_DELAY_RANGE) + self._push_log("info", f" 锁单成功(订单 {locked['order_id']}),{delay:.1f}s 后支付") + if not self._sleep_interruptible(delay): + self._mark_task(db, task, "stopped", "任务已停止,商品锁单仍可能有效", locked) + return + try: + payment = self._pay_exchange_with_backoff( + client, + manual_id=manual_id, + order_id=locked["order_id"], + rid=rid, + locked_at=locked["locked_at"], + ) + except DouyuActivityError as exc: + finish_failed(str(exc)) + return + if payment is None: + self._mark_task(db, task, "stopped", "任务已停止,商品锁单仍可能有效", locked) + return + + result.update({ + "order_id": locked["order_id"], + "exchange_id": payment["exchange_id"], + "commodity_image": payment["commodity_image"] or locked["commodity_image"], + "exchange_num": payment["exchange_num"], + "payment": payment, + }) + account.bind_status = "goods_exchanged" + account.updated_at = datetime.now(timezone.utc) + # 兑换成功后自动刷新积分,更新账号最新积分信息(失败不阻断兑换成功) + 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}") + self._mark_task( + db, + task, + "success", + f"兑换成功: {name}", + { + "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, + **(points_refresh or {}), + }, + ) + + def _execute_exchange_esports_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: + quantity = max(1, int(payload.get("quantity") or payload.get("num") or 1)) + except (TypeError, ValueError): + self._mark_task(db, task, "failed", "兑换数量必须是正整数") + return + + manual_id = str(config.get("esports_manual_id") or "").strip() + room_id = str(config.get("room_id") or "").strip() + if not manual_id or not room_id: + self._mark_task(db, task, "failed", "请先配置电竞手册 manualID 和房间 ID") + return + + client = self._client(cookie) + baseline_points = account.esports_points + try: + baseline = self._refresh_esports_handbook(client, account, manual_id=manual_id) + baseline_points = baseline["esports_points"] + db.commit() + except Exception as exc: + self._push_log("warning", f"兑换电竞皮肤前刷新积分失败: {exc}") + + result = client.exchange_esports_goods( + manual_id=manual_id, + rid=room_id, + commodity_id=commodity_id, + quantity=quantity, + ) + goods = ( + db.query(DouyuEsportsGoodsSnapshot) + .filter(DouyuEsportsGoodsSnapshot.commodity_id == commodity_id) + .first() + ) + result["goods"] = goods.raw if goods else None + result["esports_points_baseline"] = baseline_points + try: + points_result = self._refresh_esports_handbook(client, account, manual_id=manual_id) + result.update(points_result) + result["esports_points_after_exchange"] = points_result["esports_points"] + except Exception as exc: + result["esports_points_refresh_error"] = str(exc) + + account.esports_bind_status = "esports_goods_exchanged" + account.updated_at = datetime.now(timezone.utc) + name = (goods.name if goods else "") or commodity_id + message = f"兑换电竞皮肤成功: {name}" + if quantity > 1: + message += f" x{quantity}" + if account.esports_points is not None: + message += f",电竞积分: {account.esports_points}" + result["game_name"] = account.esports_game_name or "" + result["game_channel"] = account.esports_game_channel or "" + result["account_name"] = account.nickname or account.username or account.uid or f"#{account.id}" + self._mark_task(db, task, "success", message, result) + + def _execute_query_limited_goods(self, db: Session, task: DouyuTask, account: Account, cookie: str, config: dict): + client = self._client(cookie) + result = client.query_limited_goods(manual_id=str(config["manual_id"]), rid=str(config["rid"])) + limited = result["limited_goods"] + names = [str(item.get("commodityName") or "") for item in limited if item.get("commodityName")] + message = "无限制商品" if not names else f"限兑 {len(names)} 个: {', '.join(names[:5])}" + account.bind_status = "limited_goods_queried" + account.updated_at = datetime.now(timezone.utc) + self._mark_task(db, task, "success", message, {"limited_count": len(limited), "limited_goods": limited}) + + def _execute_query_exchange_records(self, db: Session, task: DouyuTask, account: Account, cookie: str, config: dict): + client = self._client(cookie) + result = client.exchange_records(manual_id=str(config["manual_id"])) + records = result["records"] + account.bind_status = "exchange_records_queried" + account.updated_at = datetime.now(timezone.utc) + self._mark_task(db, task, "success", f"兑换记录 {len(records)} 条" if records else "暂无兑换记录", result) + + def _execute_prefetch_csrf_token(self, db: Session, task: DouyuTask, account: Account, cookie: str, config: dict): + client = self._client(cookie) + token = client.csrf_token() + account.bind_status = "csrf_token_ready" + account.updated_at = datetime.now(timezone.utc) + self._mark_task(db, task, "success", "获取 csrf_token 成功", {"csrf_token": token, "cookie": client.cookie}) + diff --git a/web/backend/services/douyu_runner_manual.py b/web/backend/services/douyu_runner_manual.py new file mode 100644 index 0000000..cba66a7 --- /dev/null +++ b/web/backend/services/douyu_runner_manual.py @@ -0,0 +1,326 @@ +"""斗鱼任务执行器:手册开通与积分(由 douyu_runner.py 按功能域拆分)。""" + +from __future__ import annotations +import time +from datetime import datetime, timezone +from sqlalchemy.orm import Session + +from core.douyu import DouyuActivityClient, DouyuActivityError +from ..models import Account, DouyuTask +from .douyu_service import account_uid, update_account_profile_from_cookie + +class ManualMixin: + """手册域:精英/电竞手册开通支付、积分查询与到账轮询。""" + def _refresh_account_points( + self, + client: DouyuActivityClient, + account: Account, + cookie: str, + *, + ctn: str | None = None, + ) -> dict: + """刷新账号积分并写回账号表。""" + uid = account_uid(account, cookie) + if not uid: + raise DouyuActivityError("Cookie 中没有 acf_uid,无法查询积分") + ctn_value = ctn or client.acf_ccn(refresh_subscribe=False) + result = client.query_points(uid=uid, ctn=ctn_value) + points = self._to_int(result.get("points")) + account.uid = uid + account.points = points + update_account_profile_from_cookie(account, cookie) + account.updated_at = datetime.now(timezone.utc) + return {"points": points, "points_query": result} + + def _wait_points_after_payment( + self, + db: Session, + task: DouyuTask, + account: Account, + client: DouyuActivityClient, + cookie: str, + ctn: str, + result: dict, + ) -> bool: + """等待宝典支付到账;积分达到 300 视为开通成功。""" + deadline = time.monotonic() + DOUYU_PAYMENT_POLL_SECONDS + result["payment_polling"] = True + result["payment_target_points"] = 300 + poll_count = 0 + last_points = None + while not self._stop.is_set() and time.monotonic() <= deadline: + try: + points_result = self._refresh_account_points(client, account, cookie, ctn=ctn) + db.commit() + poll_count += 1 + last_points = points_result["points"] + result.update(points_result) + result["payment_poll_count"] = poll_count + result["payment_polling"] = True + if last_points is not None and last_points >= 300: + result["payment_polling"] = False + result["elite_opened"] = True + return True + self._update_task_progress( + db, + task, + "running", + f"精英宝典支付码已生成,等待开通到账(当前积分 {last_points if last_points is not None else '-'})", + result, + ) + except Exception as exc: + poll_count += 1 + result["payment_poll_count"] = poll_count + result["payment_poll_error"] = str(exc) + self._update_task_progress(db, task, "running", f"等待开通到账: {exc}", result) + if self._stop.wait(DOUYU_PAYMENT_POLL_INTERVAL): + break + result["payment_polling"] = False + result["elite_opened"] = False + result["points"] = last_points + return False + + def _refresh_esports_handbook( + self, + client: DouyuActivityClient, + account: Account, + *, + manual_id: str, + ) -> dict: + """刷新电竞手册开通状态并将积分写回账号。""" + result = client.esports_user_info(manual_id=manual_id) + manual_type = self._to_int(result.get("manual_type")) + manual_score = self._to_int(result.get("manual_score")) + account.esports_points = manual_score + account.updated_at = datetime.now(timezone.utc) + return { + "esports_manual_type": manual_type, + "esports_manual_score": manual_score, + "esports_expire_time": result.get("expire_time"), + "esports_user_info": result, + "esports_points": manual_score, + "points": manual_score, + } + + def _wait_esports_open_after_payment( + self, + db: Session, + task: DouyuTask, + account: Account, + client: DouyuActivityClient, + *, + manual_id: str, + result: dict, + baseline_manual_type: int | None, + baseline_manual_score: int | None, + ) -> bool: + """等待电竞手册支付到账,以 manualType=1 或积分变化作为成功条件。""" + deadline = time.monotonic() + DOUYU_PAYMENT_POLL_SECONDS + result["payment_polling"] = True + result["esports_manual_type_baseline"] = baseline_manual_type + result["esports_manual_score_baseline"] = baseline_manual_score + poll_count = 0 + last_manual_type = baseline_manual_type + last_manual_score = baseline_manual_score + + while not self._stop.is_set() and time.monotonic() <= deadline: + try: + handbook_result = self._refresh_esports_handbook( + client, + account, + manual_id=manual_id, + ) + db.commit() + poll_count += 1 + last_manual_type = handbook_result["esports_manual_type"] + last_manual_score = handbook_result["esports_manual_score"] + result.update(handbook_result) + result["payment_poll_count"] = poll_count + result["payment_polling"] = True + opened = ( + last_manual_type is not None + and last_manual_type >= 1 + ) or ( + baseline_manual_score is not None + and last_manual_score is not None + and last_manual_score > baseline_manual_score + ) + if opened: + result["payment_polling"] = False + result["esports_opened"] = True + return True + self._update_task_progress( + db, + task, + "running", + "电竞手册支付码已生成,等待开通到账" + f"(类型 {last_manual_type if last_manual_type is not None else '-'}," + f"积分 {last_manual_score if last_manual_score is not None else '-'})", + result, + ) + except Exception as exc: + poll_count += 1 + result["payment_poll_count"] = poll_count + result["payment_poll_error"] = str(exc) + self._update_task_progress(db, task, "running", f"等待电竞手册到账: {exc}", result) + if self._stop.wait(DOUYU_PAYMENT_POLL_INTERVAL): + break + + result["payment_polling"] = False + result["esports_opened"] = False + result["esports_manual_type"] = last_manual_type + result["esports_manual_score"] = last_manual_score + result["esports_points"] = last_manual_score + result["points"] = last_manual_score + return False + + def _execute_create_elite_qr(self, db: Session, task: DouyuTask, account: Account, cookie: str, config: dict): + client = self._client(cookie) + ctn = str(self._task_payload(task).get("ctn") or "") + if not ctn: + ctn = client.acf_ccn(refresh_subscribe=True) + act_alias = self._confirm_act_alias(config) or self._bind_qr_act_alias(config) + if not act_alias: + self._mark_task(db, task, "failed", "请先配置开通宝典活动 actAlias") + return + result = client.create_elite_qr( + ctn=ctn, + act_alias=act_alias, + amount=int(config["elite_amount"]), + room_id=str(config["room_id"]), + ) + account.bind_status = "elite_qr_created" + account.updated_at = datetime.now(timezone.utc) + self._update_task_progress(db, task, "running", "精英宝典支付码已生成,等待开通到账", result) + opened = self._wait_points_after_payment(db, task, account, client, cookie, ctn, result) + if self._stop.is_set(): + self._mark_task(db, task, "stopped", "任务已停止", result) + return + if opened: + account.bind_status = "elite_opened" + account.updated_at = datetime.now(timezone.utc) + self._mark_task(db, task, "success", f"精英宝典已开通,积分: {account.points}", result) + return + self._mark_task( + db, + task, + "failed", + f"未检测到精英宝典开通到账,当前积分: {account.points if account.points is not None else '-'}", + result, + ) + + def _execute_create_esports_qr( + self, + db: Session, + task: DouyuTask, + account: Account, + cookie: str, + config: dict, + ): + """生成电竞手册支付二维码,并通过活动状态确认开通到账。""" + client = self._client(cookie) + ctn = str(self._task_payload(task).get("ctn") or "") + if not ctn: + ctn = client.acf_ccn(refresh_subscribe=True) + act_alias = str(config.get("esports_act_alias") or "").strip() + manual_id = str(config.get("esports_manual_id") or "").strip() + if not act_alias or not manual_id: + self._mark_task(db, task, "failed", "请先配置电竞手册活动 actAlias 和 manualID") + return + + baseline_manual_type = None + baseline_manual_score = None + baseline_result: dict = {} + try: + baseline_result = self._refresh_esports_handbook(client, account, manual_id=manual_id) + baseline_manual_type = baseline_result["esports_manual_type"] + baseline_manual_score = baseline_result["esports_manual_score"] + db.commit() + except Exception as exc: + self._push_log("warning", f"生成电竞手册支付码前查询活动状态失败: {exc}") + + if baseline_manual_type is not None and baseline_manual_type >= 1: + account.esports_bind_status = "esports_opened" + account.updated_at = datetime.now(timezone.utc) + self._mark_task( + db, + task, + "success", + f"电竞手册已开通,积分: {baseline_manual_score if baseline_manual_score is not None else '-'}", + {**baseline_result, "esports_opened": True, "payment_polling": False}, + ) + return + + result = client.create_esports_qr( + ctn=ctn, + act_alias=act_alias, + amount=int(config["esports_amount"]), + room_id=str(config["room_id"]), + ) + result.update(baseline_result) + account.esports_bind_status = "esports_qr_created" + account.updated_at = datetime.now(timezone.utc) + self._update_task_progress(db, task, "running", "电竞手册支付码已生成,等待开通到账", result) + opened = self._wait_esports_open_after_payment( + db, + task, + account, + client, + manual_id=manual_id, + result=result, + baseline_manual_type=baseline_manual_type, + baseline_manual_score=baseline_manual_score, + ) + if self._stop.is_set(): + self._mark_task(db, task, "stopped", "任务已停止", result) + return + if opened: + account.esports_bind_status = "esports_opened" + account.updated_at = datetime.now(timezone.utc) + self._mark_task( + db, + task, + "success", + f"电竞手册已开通,积分: {account.esports_points if account.esports_points is not None else '-'}", + result, + ) + return + self._mark_task( + db, + task, + "failed", + "未检测到电竞手册开通到账" + f",类型: {result.get('esports_manual_type', '-')}," + f"积分: {result.get('esports_manual_score', '-')}", + result, + ) + + def _execute_query_esports_points( + self, + db: Session, + task: DouyuTask, + account: Account, + cookie: str, + config: dict, + ): + """查询电竞手册积分。""" + manual_id = str(config.get("esports_manual_id") or "").strip() + if not manual_id: + self._mark_task(db, task, "failed", "请先配置电竞手册 manualID") + return + client = self._client(cookie) + result = self._refresh_esports_handbook(client, account, manual_id=manual_id) + account.esports_bind_status = "esports_points_queried" + account.updated_at = datetime.now(timezone.utc) + points = result["esports_points"] + self._mark_task(db, task, "success", f"电竞积分: {points if points is not None else '-'}", result) + + def _execute_query_points(self, db: Session, task: DouyuTask, account: Account, cookie: str, config: dict): + client = self._client(cookie) + ctn = client.acf_ccn(refresh_subscribe=False) + result = self._refresh_account_points(client, account, cookie, ctn=ctn) + account.bind_status = "points_queried" + account.updated_at = datetime.now(timezone.utc) + points = result["points"] + self._mark_task(db, task, "success", f"积分: {points if points is not None else '-'}", result) + diff --git a/web/backend/services/douyu_runner_xpd.py b/web/backend/services/douyu_runner_xpd.py new file mode 100644 index 0000000..104f59c --- /dev/null +++ b/web/backend/services/douyu_runner_xpd.py @@ -0,0 +1,528 @@ +"""斗鱼任务执行器:和平小店(由 douyu_runner.py 按功能域拆分)。""" + +from __future__ import annotations +import time +from datetime import datetime, timezone +from sqlalchemy.orm import Session + +from core.douyu import DouyuActivityClient, DouyuActivityError +from ..models import Account, DouyuTask, DouyuXpdGoodsSnapshot + +DOUYU_XPD_BIND_POLL_SECONDS = 300 +DOUYU_XPD_BIND_POLL_INTERVAL = 5 + +class XpdMixin: + """和平小店域:绑定、商品、余额、碎片与兑换。""" + def _xpd_role_context(self, client: DouyuActivityClient, config: dict) -> dict: + """获取小店 H5 参数 + 绑定角色信息,小店任务共用。""" + embed = client.xpd_embed_query( + act_alias=str(config["xpd_act_alias"]), + rid=str(config["xpd_rid"]), + ) + role = client.xpd_get_role( + embed_query=embed["query"], + act_id=str(config["xpd_act_id"]), + rid=str(config["xpd_rid"]), + ) + return {"embed": embed, "role": role} + + def _xpd_area_id(self, role: dict, account: Account) -> int: + """角色大区: 优先使用接口值,微信=1、手Q=2,未知回退已存值。""" + raw_area = role.get("area") + if raw_area not in (None, ""): + try: + area_id = int(raw_area) + except (TypeError, ValueError): + area_id = 0 + if area_id > 0: + return area_id + role_type = str(role.get("type") or "") + if role_type == "wx": + return 1 + if role_type == "qq": + return 2 + return account.xpd_area_id or 1 + + def _apply_xpd_role_to_account(self, account: Account, role: dict, area_id: int) -> None: + account.xpd_game_name = str(role.get("role_name") or "") or account.xpd_game_name + account.xpd_openid = str(role.get("game_open_id") or "") or account.xpd_openid + account.xpd_role_id = str(role.get("role_id") or "") or account.xpd_role_id + account.xpd_plat_id = self._to_int(role.get("plat_id")) + account.xpd_area_id = area_id + account.updated_at = datetime.now(timezone.utc) + + def _execute_query_xpd_role( + self, + db: Session, + task: DouyuTask, + account: Account, + cookie: str, + config: dict, + ): + """查询和平小店绑定角色。""" + client = self._client(cookie) + ctx = self._xpd_role_context(client, config) + role = ctx["role"] + if not role.get("role_id"): + self._mark_task(db, task, "failed", "未获取到小店绑定角色") + return + area_id = self._xpd_area_id(role, account) + self._apply_xpd_role_to_account(account, role, area_id) + account.xpd_bind_status = "xpd_bound" + db.commit() + role_text = str(role.get("role_name") or "-") + channel = "微信" if role.get("type") == "wx" else ("手Q" if role.get("type") == "qq" else str(role.get("type") or "-")) + self._mark_task( + db, + task, + "success", + f"小店角色: {role_text}({channel})", + {"role": role, "area_id": area_id}, + ) + + def _execute_get_xpd_bind_qr( + self, + db: Session, + task: DouyuTask, + account: Account, + cookie: str, + config: dict, + ): + """生成和平小店绑定二维码并轮询等待微信扫码绑定/换绑完成。 + + 识别到新角色后仅标记"待确认",不自动回写账号,由用户手动确认绑定。 + """ + client = self._client(cookie) + act_alias = str(config.get("xpd_act_alias") or "").strip() + if not act_alias: + self._mark_task(db, task, "failed", "请先配置小店活动代号 actAlias") + return + result = client.xpd_bind_qr(act_alias=act_alias) + account.xpd_bind_status = "xpd_bind_qr_ready" + account.updated_at = datetime.now(timezone.utc) + db.commit() + # 记录绑定前状态:已绑定账号生成二维码后必须等扫码换绑,不能立即成功 + try: + before = client.xpd_bind_info(act_alias=act_alias) + result["before_bound"] = bool(before.get("bind_role")) + result["before_role_name"] = str(before.get("role_name") or "") + result["before_area_name"] = str(before.get("area_name") or "") + result["before_plat_name"] = str(before.get("plat_name") or "") + except Exception: + result["before_bound"] = False + result["before_role_name"] = "" + result["bind_polling"] = True + self._update_task_progress( + db, + task, + "running", + "二维码已生成,请微信扫码在小程序中绑定角色", + result, + ) + state = self._wait_xpd_bind(db, task, client, act_alias, result) + result["bind_polling"] = False + if state == "stopped": + self._mark_task(db, task, "stopped", "任务已停止", result) + return + if state == "pending": + role_text = str(result.get("role_name") or "-") + self._mark_task(db, task, "success", f"已识别角色: {role_text},待确认绑定", result) + return + self._mark_task( + db, + task, + "failed", + "未检测到小店绑定(二维码仍有效,可再次生成后扫码)", + result, + ) + + def _wait_xpd_bind( + self, + db: Session, + task: DouyuTask, + client: DouyuActivityClient, + act_alias: str, + result: dict, + ) -> str: + """轮询 bindInfo 检测绑定/换绑角色,识别到后停在"待确认",不自动回写账号。 + + - 绑定前未绑定:检测到 bind_role=1 即识别到待确认角色 + - 绑定前已绑定(换绑):检测到角色名变化才算换绑完成,角色不变继续等 + 返回 "pending"=已识别待确认角色, "stopped"=任务停止, "timeout"=超时未识别。 + """ + before_bound = bool(result.get("before_bound")) + before_role_name = str(result.get("before_role_name") or "") + deadline = time.monotonic() + DOUYU_XPD_BIND_POLL_SECONDS + poll_count = 0 + while not self._stop.is_set() and time.monotonic() <= deadline: + try: + info = client.xpd_bind_info(act_alias=act_alias) + poll_count += 1 + result["bind_poll_count"] = poll_count + result["bind_polling"] = True + role_name = str(info.get("role_name") or "") + bound_now = bool(info.get("bind_role")) + changed = before_bound and bool(role_name) and role_name != before_role_name + if (not before_bound and bound_now and role_name) or changed: + result.update({key: value for key, value in info.items() if key != "raw"}) + result["bind_polling"] = False + result["xpd_pending_confirm"] = True + return "pending" + self._update_task_progress( + db, + task, + "running", + f"等待扫码绑定(第 {poll_count} 次)", + result, + ) + except Exception as exc: + poll_count += 1 + result["bind_poll_count"] = poll_count + result["bind_poll_error"] = str(exc) + self._update_task_progress( + db, + task, + "running", + f"等待扫码绑定: {exc}", + result, + ) + if self._stop.wait(DOUYU_XPD_BIND_POLL_INTERVAL): + break + result["bind_polling"] = False + return "stopped" if self._stop.is_set() else "timeout" + + def _execute_confirm_xpd_bind( + self, + db: Session, + task: DouyuTask, + account: Account, + cookie: str, + config: dict, + ): + """确认和平小店绑定:回查 bindInfo,确认绑定角色后将账号回写为已绑定。""" + client = self._client(cookie) + act_alias = str(config.get("xpd_act_alias") or "").strip() + if not act_alias: + self._mark_task(db, task, "failed", "请先配置小店活动代号 actAlias") + return + result = client.xpd_bind_info(act_alias=act_alias) + role_name = str(result.get("role_name") or "") + if not result.get("bind_role") or not role_name: + account.xpd_bind_status = "xpd_not_bound" + account.updated_at = datetime.now(timezone.utc) + db.commit() + self._mark_task(db, task, "failed", "尚未检测到小店绑定角色,请先扫码绑定", result) + return + # 优先用完整角色信息回写(与查询角色一致),失败时回退 bindInfo 角色名 + try: + ctx = self._xpd_role_context(client, config) + role = ctx["role"] + if role.get("role_id"): + self._apply_xpd_role_to_account(account, role, self._xpd_area_id(role, account)) + else: + account.xpd_game_name = role_name + account.updated_at = datetime.now(timezone.utc) + except Exception: + account.xpd_game_name = role_name + account.updated_at = datetime.now(timezone.utc) + account.xpd_bind_status = "xpd_bound" + account.updated_at = datetime.now(timezone.utc) + db.commit() + result["xpd_pending_confirm"] = False + result["xpd_bound"] = True + self._mark_task(db, task, "success", f"小店绑定成功: {role_name}", result) + + def _execute_query_xpd_bind_info( + self, + db: Session, + task: DouyuTask, + account: Account, + cookie: str, + config: dict, + ): + """查询和平小店绑定信息(bindInfo)。 + + 仅查询展示,不回写账号;确认绑定由 confirm_xpd_bind 任务完成。 + """ + client = self._client(cookie) + act_alias = str(config.get("xpd_act_alias") or "").strip() + if not act_alias: + self._mark_task(db, task, "failed", "请先配置小店活动代号 actAlias") + return + result = client.xpd_bind_info(act_alias=act_alias) + status = "已绑定" if result.get("bind_role") else "未绑定" + text = str(result.get("role_name") or result.get("nick") or "-") + self._mark_task( + db, + task, + "success", + f"小店绑定: {status} ({text})", + result, + ) + + def _execute_refresh_xpd_goods( + self, + db: Session, + task: DouyuTask, + account: Account, + cookie: str, + config: dict, + ): + """刷新和平小店商品列表快照(全局数据,任一可用 CK 即可)。""" + client = self._client(cookie) + ctx = self._xpd_role_context(client, config) + role = ctx["role"] + if not role.get("role_id"): + self._mark_task(db, task, "failed", "未获取到小店绑定角色") + return + area_id = self._xpd_area_id(role, account) + result = client.xpd_list_goods( + embed_query=ctx["embed"]["query"], + act_id=str(config["xpd_act_id"]), + openid=str(role.get("game_open_id") or ""), + roleid=str(role.get("role_id") or ""), + areaid=str(area_id), + ) + goods = result["goods"] + self._upsert_xpd_goods(db, goods) + self._apply_xpd_role_to_account(account, role, area_id) + account.xpd_bind_status = "xpd_goods_refreshed" + db.commit() + self._mark_task( + db, + task, + "success", + f"已刷新小店商品 {len(goods)} 个", + {"goods_count": len(goods), "goods": goods}, + ) + + def _execute_query_xpd_balance( + self, + db: Session, + task: DouyuTask, + account: Account, + cookie: str, + config: dict, + ): + """查询和平小店点券余额。""" + client = self._client(cookie) + ctx = self._xpd_role_context(client, config) + role = ctx["role"] + if not role.get("role_id"): + self._mark_task(db, task, "failed", "未获取到小店绑定角色") + return + area_id = self._xpd_area_id(role, account) + role_plat = role.get("plat_id") + plat = str(role_plat) if role_plat not in (None, "") else "1" + result = client.xpd_balance( + embed_query=ctx["embed"]["query"], + act_id=str(config["xpd_act_id"]), + openid=str(role.get("game_open_id") or ""), + roleid=str(role.get("role_id") or ""), + plat=plat, + areaid=str(area_id), + ) + balance = result.get("balance") + self._apply_xpd_role_to_account(account, role, area_id) + account.xpd_balance = balance + account.xpd_bind_status = "xpd_balance_queried" + db.commit() + if balance is None: + self._mark_task(db, task, "failed", "未获取到小店点券余额") + return + self._mark_task( + db, + task, + "success", + f"小店点券余额: {balance}", + {"balance": balance, "role": role, "area_id": area_id}, + ) + + def _execute_query_xpd_fragments( + self, + db: Session, + task: DouyuTask, + account: Account, + cookie: str, + config: dict, + ): + """查询和平小店扭蛋碎片数量。 + + 优先现查角色;getrole 受限(Livelink 风控/失效)时回退账号已存角色, + 保证已绑定账号仍可查询。 + """ + client = self._client(cookie) + act_id = str(config["xpd_act_id"]) + embed_query: dict = {} + openid = str(account.xpd_openid or "") + roleid = str(account.xpd_role_id or "") + stored_plat = account.xpd_plat_id + plat = str(stored_plat) if stored_plat is not None else "1" + areaid = str(account.xpd_area_id or 1) + role: dict = {} + try: + ctx = self._xpd_role_context(client, config) + embed_query = ctx["embed"]["query"] + role = ctx["role"] if isinstance(ctx.get("role"), dict) else {} + if role.get("role_id"): + role_area = self._xpd_area_id(role, account) + openid = str(role.get("game_open_id") or "") or openid + roleid = str(role.get("role_id") or "") or roleid + role_plat = role.get("plat_id") + plat = str(role_plat) if role_plat not in (None, "") else plat + areaid = str(role_area) or areaid + self._apply_xpd_role_to_account(account, role, role_area) + except Exception: + pass + if not openid or not roleid: + self._mark_task(db, task, "failed", "未获取到小店绑定角色,请先生成二维码扫码绑定") + return + result = client.xpd_fragments( + embed_query=embed_query, + act_id=act_id, + openid=openid, + roleid=roleid, + plat=plat, + areaid=areaid, + ) + fragments = result.get("fragments") + account.xpd_fragments = fragments + account.xpd_bind_status = "xpd_fragments_queried" + account.updated_at = datetime.now(timezone.utc) + db.commit() + if fragments is None: + self._mark_task(db, task, "failed", "未获取到小店扭蛋碎片数量") + return + self._mark_task( + db, + task, + "success", + f"小店扭蛋碎片: {fragments}", + {"fragments": fragments, "role": role, "area_id": int(areaid)}, + ) + + def _execute_query_xpd_purchase_records( + self, + db: Session, + task: DouyuTask, + account: Account, + cookie: str, + config: dict, + ): + """查询和平小店道聚城购买记录。""" + client = self._client(cookie) + embed = client.xpd_embed_query( + act_alias=str(config["xpd_act_alias"]), + rid=str(config["xpd_rid"]), + ) + result = client.xpd_purchase_records( + embed_query=embed["query"], + act_id=str(config["xpd_act_id"]), + ) + account.xpd_bind_status = "xpd_purchase_records_queried" + account.updated_at = datetime.now(timezone.utc) + total = result.get("total") or len(result.get("records") or []) + self._mark_task( + db, + task, + "success", + f"小店兑换记录 {total} 条" if total else "暂无小店兑换记录", + result, + ) + + def _execute_exchange_xpd_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: + pay_type = int(payload.get("pay_type") or 1) + except (TypeError, ValueError): + self._mark_task(db, task, "failed", "兑换货币参数无效") + return + if pay_type not in (1, 5): + self._mark_task(db, task, "failed", "小店兑换仅支持点券或扭蛋碎片") + return + + goods = ( + db.query(DouyuXpdGoodsSnapshot) + .filter(DouyuXpdGoodsSnapshot.commodity_id == commodity_id) + .first() + ) + if not goods: + self._mark_task(db, task, "failed", "未找到小店商品快照,请先刷新商品列表") + return + goods_snapshot = goods.raw if isinstance(goods.raw, dict) else {} + goods_raw = goods_snapshot.get("raw") if isinstance(goods_snapshot.get("raw"), dict) else goods_snapshot + price_key = "iPrice" if pay_type == 1 else "iJb2Price" + price = self._to_int(goods_raw.get(price_key)) + if price is None: + price = goods.price if pay_type == 1 else None + if price is None or price <= 0: + currency = "点券" if pay_type == 1 else "扭蛋碎片" + self._mark_task(db, task, "failed", f"该商品不支持使用{currency}兑换") + return + # iGoodsLeft=-1 表示活动未公开库存,不是售罄;只有 0 才阻止兑换。 + if goods.goods_left == 0: + self._mark_task(db, task, "failed", "该商品库存不足,请刷新商品列表后重试") + return + + client = self._client(cookie) + embed = client.xpd_embed_query( + act_alias=str(config["xpd_act_alias"]), + rid=str(config["xpd_rid"]), + ) + role: dict = {} + try: + role = client.xpd_get_role( + embed_query=embed["query"], + act_id=str(config["xpd_act_id"]), + rid=str(config["xpd_rid"]), + ) + if role.get("role_id"): + self._apply_xpd_role_to_account(account, role, self._xpd_area_id(role, account)) + except DouyuActivityError as exc: + self._push_log("warning", f"小店兑换前刷新角色失败,使用已保存角色: {exc}") + if not role.get("role_id") and not account.xpd_role_id: + self._mark_task(db, task, "failed", "未获取到小店绑定角色,请先生成二维码扫码绑定") + return + + result = client.xpd_exchange_goods( + embed_query=embed["query"], + act_id=str(config["xpd_act_id"]), + rid=str(config["xpd_rid"]), + commodity_id=commodity_id, + price=price, + picture=str(goods_raw.get("sGoodsPic") or ""), + pay_type=pay_type, + action_id=str(goods_raw.get("iActionId") or ""), + ) + if pay_type == 1 and result.get("new_balance") is not None: + account.xpd_balance = result["new_balance"] + if pay_type == 5 and result.get("new_balance") is not None: + account.xpd_fragments = result["new_balance"] + account.xpd_bind_status = "xpd_goods_exchanged" + account.updated_at = datetime.now(timezone.utc) + db.commit() + + currency = "点券" if pay_type == 1 else "扭蛋碎片" + display_role = role.get("role_name") or account.xpd_game_name or "" + channel = "微信" if (role.get("type") == "wx" or account.xpd_area_id == 1) else "手Q" + result.update({ + "goods": {**goods_raw, "commodityName": goods.name or ""}, + "game_name": display_role, + "game_channel": channel, + "account_name": account.nickname or account.username or account.uid or f"#{account.id}", + "currency": currency, + }) + self._mark_task(db, task, "success", f"兑换小店商品成功: {goods.name or commodity_id}({price}{currency})", result) +