"""斗鱼任务执行器:送礼(由 douyu_runner.py 按功能域拆分)。""" from __future__ import annotations from datetime import UTC, datetime from typing import TYPE_CHECKING, Any from sqlalchemy.orm import Session from core.douyu import DouyuActivityClient from ..models import Account, DouyuTask from .business_events import record_gold_spent, record_points_credit DOUYU_GIFT_POINTS_REFRESH_TIMES = 3 DOUYU_GIFT_POINTS_REFRESH_INTERVAL = 2 class DonateMixin: """送礼域:精英令/电竞任务礼物赠送与积分确认。""" if TYPE_CHECKING: _stop: Any def _client(self, cookie: str) -> DouyuActivityClient: ... def _task_payload(self, task: DouyuTask) -> dict: ... def _push_log(self, level: str, message: str) -> None: ... def _mark_task(self, *args: Any, **kwargs: Any) -> None: ... def _update_task_progress(self, *args: Any, **kwargs: Any) -> None: ... def _refresh_account_points( self, client: DouyuActivityClient, account: Account, cookie: str, *, ctn: str | None = None, ) -> dict: ... def _refresh_account_gold_balance( self, client: DouyuActivityClient, account: Account ) -> dict: ... def _refresh_esports_handbook( self, client: DouyuActivityClient, account: Account, *, manual_id: str ) -> dict: ... 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: # noqa: BLE001 外部接口与任务边界需要保留宽泛异常兜底 self._push_log("warning", f"赠送{gift_name}前刷新电竞积分失败: {exc}") baseline_gold = account.gold_balance try: gold_before = self._refresh_account_gold_balance(client, account) db.commit() baseline_gold = gold_before["gold_balance"] except Exception as exc: # noqa: BLE001 外部接口与任务边界需要保留宽泛异常兜底 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: # noqa: BLE001 外部接口与任务边界需要保留宽泛异常兜底 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: # noqa: BLE001 外部接口与任务边界需要保留宽泛异常兜底 refresh_errors.append(f"电竞积分: {exc}") if refresh_errors: result["refresh_errors"] = refresh_errors account.updated_at = datetime.now(UTC) confirmed = bool(result.get("esports_points_changed")) 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}" # 电竞礼物同样产生积分到账 —— 这一类此前**从来不在收集范围** # (不在 COLLECTED_TASK_TYPES 里),时间线上一直是空白。 record_points_credit( db, account, task_id=task.id, gain=_esports_gift_points(result, baseline_points), points_after=account.esports_points, confirmed=confirmed, source_label=gift_name, ) record_gold_spent( db, account, task_id=task.id, amount=_gold_spent(baseline_gold, account.gold_balance), reason=f"赠送{gift_name} x{gift_count}", source_label=f"赠送{gift_name}", balance_after=account.gold_balance, ) 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: # noqa: BLE001 外部接口与任务边界需要保留宽泛异常兜底 self._push_log("warning", f"赠送精英令前刷新积分失败: {exc}") # 鱼翅基线:送礼会**消耗鱼翅**,要记出项就必须有送礼前的余额。 # 直接读 account.gold_balance 是上次刷新的旧值,算出来的差会偏。 baseline_gold = account.gold_balance try: gold_before = self._refresh_account_gold_balance(client, account) db.commit() baseline_gold = gold_before["gold_balance"] except Exception as exc: # noqa: BLE001 外部接口与任务边界需要保留宽泛异常兜底 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: # noqa: BLE001 外部接口与任务边界需要保留宽泛异常兜底 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: # noqa: BLE001 外部接口与任务边界需要保留宽泛异常兜底 refresh_errors.append(f"积分: {exc}") if refresh_errors: result["refresh_errors"] = refresh_errors account.updated_at = datetime.now(UTC) message = f"赠送精英令成功: {gift_count}" if account.gold_balance is not None: message += f",鱼翅余额: {account.gold_balance}" confirmed = bool(result.get("gift_points_confirmed")) # 顺手记下积分到期时刻:斗鱼只在这次查询里给,不记下来以后就没机会了。 # 到期是资产减少,但斗鱼不会通知 —— 由作业在到期后补写事件。 expire_at = _points_expire_at(result) expire_dt = _parse_expire_at(expire_at) if expire_dt is not None: account.points_expire_at = expire_dt if account.points is not None: message += f",积分: {account.points}" if result.get("gift_points_target") is not None and not confirmed: message += f"(未确认涨到 {result['gift_points_target']})" # 积分到账是业务事实。**未确认也记,但把 confirmed 明确写进 detail** —— # 旧收集器把「预期涨幅」当既成事实写进台账(未确认时也记 +980), # 那是把预期记成到账。这里宁可标清楚,也不假装它是确认过的数字。 # 金额用本地可等价重算的值(1 个精英令 ≈ 10 分),不再依赖 result 里的字段。 record_points_credit( db, account, task_id=task.id, gain=max(0, gift_count) * 10, points_after=account.points, expire_at=expire_at, confirmed=confirmed, source_label="精英令", ) # 鱼翅出项:以前只记「积分到账」,花掉的鱼翅没有任何记录 —— # 充值有账、消耗没账,对不上。金额取实测余额差。 record_gold_spent( db, account, task_id=task.id, amount=_gold_spent(baseline_gold, account.gold_balance), reason=f"赠送精英令 x{gift_count}", source_label="赠送精英令", balance_after=account.gold_balance, ) self._mark_task(db, task, "success", message, result) def _points_expire_at(result: dict) -> object: """从积分查询结果里取出到期时间(同样是为了给时间线留全信息)。 旧收集器走的是 `result["points_query"]["raw"]["data"]["willExpireTime"]` 这条 「猜出来」的路径;这里收敛成一个具名函数,只有它知道那段结构。 """ query = result.get("points_query") if not isinstance(query, dict): return None raw = query.get("raw") data = raw.get("data") if isinstance(raw, dict) else None return data.get("willExpireTime") if isinstance(data, dict) else None def _esports_gift_points(result: dict, baseline_points: int | None) -> int | None: """电竞礼物带来的积分增量。 以**实际观测到的积分变化**为准(`esports_points_after_gift` - 基线), 而不是像旧收集器那样去读一个「预期涨幅」。观测不到就返回 None —— 金额宁可空着,也不要写一个猜出来的数字进台账。 """ after = result.get("esports_points_after_gift") if not isinstance(after, (int, float)) or not isinstance( baseline_points, (int, float) ): return None delta = int(after) - int(baseline_points) return delta if delta > 0 else None def _gold_spent(before: int | None, after: int | None) -> int | None: """送礼前后鱼翅余额的差(正数 = 花掉的)。 用**实测余额差**而不是礼物单价:单价会变、活动会打折,只有观测到的差是真的。 任一侧拿不到、或余额没减少(甚至变多,例如同时另一笔充值到账)→ 返回 None, 事件就不写金额 —— 宁可少记,不要编一个数进账。 """ if not isinstance(before, (int, float)) or not isinstance(after, (int, float)): return None delta = int(before) - int(after) return delta if delta > 0 else None def _parse_expire_at(value: object) -> datetime | None: """把斗鱼给的到期时间解析成 datetime。 它可能是秒级时间戳(int/str),也可能是 `YYYY-MM-DD HH:MM:SS` 字符串 —— 两种都见过,所以都兜住;认不出来就返回 None(宁可不记,也不要猜一个时刻)。 """ if value is None or isinstance(value, bool): return None if isinstance(value, (int, float)): seconds = float(value) else: text = str(value).strip() if not text: return None if text.isdigit(): seconds = float(text) else: for fmt in ("%Y-%m-%d %H:%M:%S", "%Y-%m-%dT%H:%M:%S", "%Y-%m-%d"): try: return datetime.strptime(text, fmt).replace(tzinfo=UTC) except ValueError: continue return None # 毫秒时间戳也兜一下 if seconds > 10_000_000_000: seconds /= 1000 try: return datetime.fromtimestamp(seconds, tz=UTC) except (OverflowError, OSError, ValueError): return None