"""一键查询:把账号的关键信息一次性从斗鱼官方拉全并汇总。 ## 定位 一个**只读**聚合任务:鱼翅 / 积分(含到期时间)/ 精英宝典 / 电竞手册 / 小店 / 官方兑换记录 / **限兑商品(重点看高价值套装是否已拥有)**。 **不改任何账号状态、不发业务事件** —— 纯查询不是「事实」, 按既有设计例行查询不进时间线,否则查几次就把时间线刷满了。 ## 为什么「是否已拥有」能直接查官方 `storehome` 商品项里的账号级 `status`(0=可兑换、1=已达上限)才是唯一判据。 `count` / `foreverCount` / `storeStatus` 是全局库存或商店状态,不能拿来判断 当前账号是否已经兑换。 ## 诚实边界 **单项失败不整体失败**:账号没绑电竞手册是常态,不该让整次查询失败。 失败写进 `errors[项]`,并在 UI 如实展示 —— 避免「没查到」被误读成「没有」。 """ from __future__ import annotations import json from datetime import UTC, datetime from typing import TYPE_CHECKING, Any from loguru import logger from sqlalchemy.orm import Session from core.douyu import DouyuActivityClient from ..models import Account, DouyuTask from .account_binding import derive_from_bind_info, set_bind_status from .business_events import record_official_exchange from .douyu_runner_donate import _parse_expire_at, _points_expire_at from .high_value_service import ( DEFAULT_HIGH_VALUE_GOODS, apply_high_value_goods, extract_high_value_goods, ) #: 官方兑换记录默认取多少条(完整记录可能很多,一次性拉回来没必要) EXCHANGE_RECORD_LIMIT = 20 #: 单项错误的摘要长度(避免把整个 traceback 塞进 task.result) _ERROR_MAX = 200 def _err(exc: BaseException) -> str: return f"{type(exc).__name__}: {exc}"[:_ERROR_MAX] def _group_by_scope( pairs: list[tuple[str, dict[str, Any]]], ) -> dict[str, list[dict[str, Any]]]: """把 (作用域, 记录) 扁平列表按作用域分组。""" grouped: dict[str, list[dict[str, Any]]] = {} for scope, rec in pairs: grouped.setdefault(scope, []).append(rec) return grouped def _record_official_exchanges( db: Session, account: Account, pairs: list[tuple[str, dict[str, Any]]] ) -> int: """把官方兑换记录补成账号事件,返回**新写入**的条数。 幂等由 `record_official_exchange` 的 `source_ref` 保证(含作用域), 所以一键查询反复执行不会重复落事件。 """ added = 0 for scope, rec in pairs: if not isinstance(rec, dict): continue when = _ts(rec.get("time")) occurred = _parse_expire_at(when) if when is not None else None score_raw = rec.get("score") try: score = int(str(score_raw)) if score_raw not in (None, "") else None except (TypeError, ValueError): score = None if record_official_exchange( db, account, commodity_id=str(rec.get("commodityId") or rec.get("commodity_id") or ""), commodity_name=str( rec.get("commodityName") or rec.get("commodity_name") or "" ), score=score, occurred_at=occurred, show_name=str(rec.get("showName") or rec.get("show_name") or ""), exchange_num=rec.get("exchangeNum") or rec.get("count"), scope=scope, ): added += 1 return added def _goods_config(config: dict) -> list[str]: """高价值商品名单:优先用配置,非法/为空则回落默认。""" raw = config.get("high_value_goods") if isinstance(raw, str) and raw.strip(): try: raw = json.loads(raw) except ValueError: raw = [p for p in raw.replace(",", ",").split(",") if p.strip()] if isinstance(raw, (list, tuple)): cleaned = [str(x).strip() for x in raw if str(x).strip()] if cleaned: return cleaned return list(DEFAULT_HIGH_VALUE_GOODS) def _ts(value: Any) -> Any: """斗鱼用 `0` / `"0"` / 空串表示「没有这个时间」。 直接透传的后果:前端把 `0` 当 Unix 时间戳 → 显示成 **1970/1/1 08:00:00**, 运营看到会以为"到期时间是 1970 年"。所以在这里就归成 `None`, 让「没有」和「有一个很早的时间」区分得开。 """ if value is None: return None if isinstance(value, bool): return None if isinstance(value, (int, float)): return None if int(value) <= 0 else value text = str(value).strip() if not text: return None if text.lstrip("-").isdigit() and int(text) <= 0: return None return value class OverviewMixin: """一键查询域:只读聚合,不写状态、不发事件。""" if TYPE_CHECKING: def _client(self, cookie: str) -> DouyuActivityClient: ... def _mark_task(self, *args: Any, **kwargs: Any) -> None: ... def _push_log(self, level: str, message: str) -> None: ... def _refresh_account_gold_balance( self, client: DouyuActivityClient, account: Account ) -> dict: ... def _refresh_account_points( self, client: DouyuActivityClient, account: Account, cookie: str, *, ctn: str = "", ) -> dict: ... def _refresh_elite_handbook( self, client: DouyuActivityClient, account: Account, *, manual_id: str, rid: str = "", ) -> dict: ... def _refresh_esports_handbook( self, client: DouyuActivityClient, account: Account, *, manual_id: str ) -> dict: ... # ---- 三个作用域的官方兑换记录抓取 ---- # # 精英宝典 / 电竞手册 共用 `exchange_records`(只有 manual_id 不同); # 和平小店是另一套(道聚城短时授权 + 购买记录)。 @staticmethod def _scope_configured( scope: str, manual_id: str, esports_manual_id: str, config: dict ) -> bool: if scope == "elite": return bool(manual_id) if scope == "esports": return bool(esports_manual_id) return all( str(config.get(k) or "").strip() for k in ("xpd_act_alias", "xpd_rid", "xpd_act_id") ) def _fetch_bind_info( self, client: DouyuActivityClient, scope: str, alias: str ) -> dict[str, Any]: """按作用域查绑定信息(三个接口不同,收敛在一处)。""" if scope == "elite": return client.bind_info(alias) if scope == "esports": return client.esports_bind_info(alias) return client.xpd_bind_info(act_alias=alias) def _fetch_elite_records( self, client: DouyuActivityClient, config: dict, manual_id: str, esports_manual_id: str, ) -> list[dict[str, Any]]: return ( client.exchange_records( manual_id=manual_id, limit=EXCHANGE_RECORD_LIMIT ).get("records") or [] ) def _fetch_esports_records( self, client: DouyuActivityClient, config: dict, manual_id: str, esports_manual_id: str, ) -> list[dict[str, Any]]: return ( client.exchange_records( manual_id=esports_manual_id, limit=EXCHANGE_RECORD_LIMIT ).get("records") or [] ) def _fetch_xpd_records( self, client: DouyuActivityClient, config: dict, manual_id: str, esports_manual_id: str, ) -> list[dict[str, Any]]: """和平小店:先要一次性短时授权,再查购买记录。""" embed = client.xpd_embed_query( act_alias=str(config["xpd_act_alias"]), rid=str(config["xpd_rid"]), ) return ( client.xpd_purchase_records( embed_query=embed["query"], act_id=str(config["xpd_act_id"]), ).get("records") or [] ) def _execute_query_account_overview( self, db: Session, task: DouyuTask, account: Account, cookie: str, config: dict ): """一键查询:把该账号的关键信息从斗鱼拉全。 每一项独立 try/except —— 单项失败只记进 `errors`, 绝不因此让整次查询失败(账号没绑电竞手册就是最常见的例子)。 """ client = self._client(cookie) manual_id = str(config.get("manual_id") or "").strip() esports_manual_id = str(config.get("esports_manual_id") or "").strip() rid = str(config.get("rid") or "").strip() queried_at = datetime.now(UTC) overview: dict[str, Any] = { "queried_at": queried_at.isoformat(), "assets": {}, "elite": {}, "esports": {}, "high_value_goods": [], "exchange_records": {}, "errors": {}, } errors: dict[str, str] = overview["errors"] # (作用域, 官方记录) 扁平列表,便于补事件 all_records: list[tuple[str, dict[str, Any]]] = [] pending_goods: list[dict[str, Any]] = [] goods_query_ok = False # ---- 鱼翅 + 钱包兑换余额 ---- try: gold = self._refresh_account_gold_balance(client, account) overview["assets"]["gold_balance"] = gold.get("gold_balance") overview["assets"]["exchange_balance"] = gold.get("exchange_balance") except Exception as exc: # noqa: BLE001 外部接口边界需要宽泛兜底 errors["gold"] = _err(exc) # ---- 积分 + 到期时间 ---- try: ctn = client.acf_ccn(refresh_subscribe=False) points = self._refresh_account_points(client, account, cookie, ctn=ctn) overview["assets"]["points"] = points.get("points") # `_refresh_account_points` 只回 {"points", "points_query"}, # 到期时间得从 points_query 里取 —— 复用已有具名函数,不自己扒结构。 expire_raw = _ts(_points_expire_at(points)) overview["assets"]["points_expire_at"] = expire_raw # **也写回账号**:积分到期时间是账号的信息,本来就该由账号表承载并 # 在账号详情里展示 —— 查询只是一次「刷新」,不是另开一份副本。 expire_dt = _parse_expire_at(expire_raw) account.points_expire_at = expire_dt except Exception as exc: # noqa: BLE001 errors["points"] = _err(exc) # ---- 精英宝典(开通状态 / 到期时间 / 积分)---- if not manual_id: errors["elite"] = "未配置精英宝典 manualID" else: try: elite = self._refresh_elite_handbook( client, account, manual_id=manual_id, rid=rid ) overview["elite"] = { "opened": elite.get("elite_opened"), "expire_time": _ts(elite.get("elite_expire_time")), "points": elite.get("elite_manual_score"), } except Exception as exc: # noqa: BLE001 errors["elite"] = _err(exc) # ---- 电竞手册 ---- if not esports_manual_id: errors["esports"] = "未配置电竞手册 manualID" else: try: esports = self._refresh_esports_handbook( client, account, manual_id=esports_manual_id ) manual_type = esports.get("esports_manual_type") # `_refresh_esports_handbook` 不直接给「是否开通」; # 语义以 create_esports_qr 里的判定为准:manual_type >= 1 = 已开通。 opened = ( manual_type is not None and int(manual_type) >= 1 if isinstance(manual_type, (int, float)) else None ) overview["esports"] = { "opened": opened, "manual_type": manual_type, "points": esports.get("esports_points"), "expire_time": _ts(esports.get("esports_expire_time")), } except Exception as exc: # noqa: BLE001 errors["esports"] = _err(exc) # ---- 精英宝典商城商品列表(高价值套装的**唯一**判据来源)---- # # ⚠️ 必须用**精英宝典**的 manual_id(与 `refresh_goods` 任务一致), # 而且**不能**寄生在上面的「电竞手册」分支里:那是两个无关的活动。 # 之前商品列表被写在电竞手册的 else 分支内 —— 于是一旦没配 # `esports_manual_id`,商城商品就永远拉不到,高价值三态就永远 # 落不到 `available`/`owned`(全部显示「待确认」)。 if not manual_id: errors["limited_goods"] = "未配置精英宝典 manualID" else: goods_items: list[dict[str, Any]] = [] try: # 🎯 拉取完整商城商品列表(含每个商品准确的 status 状态:1=已达兑换上限,0=可兑换) goods = client.list_goods(manual_id=manual_id, rid=rid) goods_items = goods.get("goods") or [] overview["limited_goods_total"] = len(goods_items) goods_query_ok = True except Exception as exc: # noqa: BLE001 errors["limited_goods"] = _err(exc) # 商品列表带有账号级 status;兑换记录只作历史展示,不参与准入判定。 pending_goods = goods_items # ---- 角色绑定状态(三个作用域都查)---- # # 这三个值以前**从来没人查过** —— 只是 S6 迁移时从旧的垃圾场列 # `bind_status` 能推的推、推不出的留 `unknown`,于是界面上 97% 显示"未知"。 # 实测校准:开发库 4 个账号里 3 个其实**已绑定**(`is_bound_role=True` 且有角色名), # 全被 unknown 盖住了。 # 和平小店总开关:关着时**完全跳过**它的所有请求。 # 小店目前是关闭状态,不跳过的话每个账号都会产生一条 # "Livelink登录态校验失败"(实测 200 个里失败 195 次,100%), # 既淹掉真错误、又让人以为出故障。跳过时**不记 errors** —— # 这是配置成关的,不是失败。 peace_on = bool(config.get("peace_enabled")) if not peace_on: overview["peace_skipped"] = ( "和平小店已关闭(douyu_config.peace_enabled=False)" ) overview["bind"] = {} for scope, label, alias_key in ( ("elite", "精英宝典", "bind_act_alias"), ("esports", "电竞手册", "esports_act_alias"), ("peace", "和平小店", "xpd_act_alias"), ): if scope == "peace" and not peace_on: continue alias = str(config.get(alias_key) or "").strip() if not alias: errors[f"bind_{scope}"] = f"未配置{label}活动别名" continue try: info = self._fetch_bind_info(client, scope, alias) derived = derive_from_bind_info(info) # 把**明细**也带上:运营要看的是"绑的哪个角色、哪个区服、 # 什么时候能换绑",不是一个"已绑定"就完事。 overview["bind"][scope] = { "derived": derived, "role_name": str(info.get("role_name") or info.get("role") or ""), "area_name": str(info.get("area_name") or ""), "plat_name": str(info.get("plat_name") or ""), "nickname": str(info.get("nickname") or ""), "change_role_wait_time": _ts(info.get("change_role_wait_time")), "can_change_time": _ts(info.get("can_change_time")), } if derived is not None: # 唯一写入者;推不出来(None)时**不动原值** —— 宁可留 unknown, # 也不要把"没查到"写成"没绑定" set_bind_status(account, scope, derived) except Exception as exc: # noqa: BLE001 外部接口边界需要宽泛兜底 errors[f"bind_{scope}"] = _err(exc) # ---- 官方兑换记录(**三个作用域都拉**)---- # # 精英宝典 / 电竞手册 用的是同一个接口,只是 manual_id 不同; # 和平小店是另一套(先拿道聚城短时授权 query,再查购买记录)。 # 任一作用域失败只记进 errors,不影响其余两个。 for scope, label, fetcher in ( ("elite", "精英宝典", self._fetch_elite_records), ("esports", "电竞手册", self._fetch_esports_records), ("peace", "和平小店", self._fetch_xpd_records), ): if scope == "peace" and not peace_on: continue key = f"exchange_records_{scope}" if not self._scope_configured(scope, manual_id, esports_manual_id, config): errors[key] = f"未配置{label}活动参数,跳过" continue try: rows = fetcher(client, config, manual_id, esports_manual_id) except Exception as exc: # noqa: BLE001 外部接口边界需要宽泛兜底 errors[key] = _err(exc) continue overview["exchange_records"][scope] = rows[:EXCHANGE_RECORD_LIMIT] all_records.extend((scope, r) for r in rows) overview[f"exchange_record_count_{scope}"] = len(rows) overview["exchange_record_count"] = len(all_records) # 把官方记录**补成账号事件**(带真实兑换时间、按作用域去重)。 overview["exchange_events_added"] = _record_official_exchanges( db, account, all_records ) overview["exchange_records"] = { scope: rows[:EXCHANGE_RECORD_LIMIT] for scope, rows in _group_by_scope(all_records).items() } # ---- 🎯 高价值套装判定(依赖兑换记录,必须放在它之后)---- if not manual_id: errors["high_value_goods"] = "未配置精英宝典 manualID" else: try: # ⚠️ 必须传**摊平后的记录**:`all_records` 是 (作用域, 记录) 元组列表, # 直接传进去会被 `isinstance(r, dict)` 全过滤掉 —— 表现为 # "所有高价值套装都判定为未获得"(踩过)。 overview["high_value_goods"] = extract_high_value_goods( pending_goods, _goods_config(config), [rec for _scope, rec in all_records], ) if goods_query_ok: apply_high_value_goods( account, overview["high_value_goods"], checked_at=queried_at, ) overview["high_value_checked_at"] = queried_at.isoformat() except Exception as exc: # noqa: BLE001 errors["high_value_goods"] = _err(exc) # 查询会回写账号上的余额/积分缓存(各 _refresh_* 内部已写), # 但**不改生命周期状态**,也不发事件。 account.updated_at = datetime.now(UTC) owned = [ g["name"] for g in overview["high_value_goods"] if not g.get("missing") and g.get("owned") ] message = f"一键查询完成;高价值套装已拥有 {len(owned)} 个" if owned: message += f": {', '.join(owned)}" if errors: message += f"({len(errors)} 项未取到)" # 落到账号上:任务会被保留策略清掉,而「这个账号现在什么情况」是账号的信息, # 应该在账号管理里看,不该依赖一条日志的寿命。 account.overview_snapshot = overview account.overview_queried_at = queried_at logger.info(f"[douyu] 一键查询账号 {account.id}: {message}") self._mark_task(db, task, "success", message, overview)