Files
live-hub-py/web/backend/services/douyu_runner_overview.py
T
yml2213 aea05952af refactor(account): 一键查询不该重复展示账号字段 —— 改成刷新它们,只留账号表里没有的
用户反馈:「一键查询也太重复了,直接更新账号信息不行吗?为啥很多都要再展示一次?
那建立账号信息表有啥用」——**说得对,是我设计错了**。

## 问题

面板把鱼翅 / 积分 / 钱包余额 / 精英宝典 / 电竞手册又原样列了一遍,
而这些账号表(`accounts`)本来就有对应列,账号详情也在显示同一批值 ——
同一屏出现两份,既冗余又让人分不清哪份是准的。

## 改法:查询 = 刷新,不是另开副本

| 内容 | 处理 |
| --- | --- |
| 鱼翅 / 积分 / 交易余额 / 开通状态 | 写回**账号表**(`_refresh_*` 本来就在写),由账号详情已有界面展示 —— **面板不再重复** |
| **积分到期时间** | 有 `accounts.points_expire_at` 列,但**查询漏了没写、界面也没显示** → 现在写回,并加到账号信息区展示 |
| **高价值套装是否已获得** | 账号表没有 → **只有这个该单独展示** |
| **官方兑换记录** | 账号表没有 → 单独展示 |

面板因此瘦身成只含「🎯 高价值套装」+「兑换记录」(+ 错误提示 + 查询时间),
标题也改成「高价值套装与兑换记录(一键查询)」,不再叫"账号信息"。

## 修了一个连带 bug

积分到期时间一直没被查询写回账号(只在快照里躺着),界面也没展示 ——
现在 `_parse_expire_at` 后写入 `accounts.points_expire_at`,
且斗鱼返回 `0` 时写 `None` 而不是 `0`(否则又显示 1970/1/1)。

## 测试(+3,共 41)

`TestOverviewRefreshesAccountFields`:资产/开通状态确实写回账号列、
**积分到期时间写回**、`willExpireTime="0"` 时清成 None 而非 0。

## 验证

`TZ=Asia/Shanghai` **593 passed / 6 skipped**;ruff 全绿;前端 build 通过、lint 0 errors。
2026-10-04 19:22:34 +08:00

360 lines
15 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""一键查询:把账号的关键信息一次性从斗鱼官方拉全并汇总。
## 定位
一个**只读**聚合任务:鱼翅 / 积分(含到期时间)/ 精英宝典 / 电竞手册 /
小店 / 官方兑换记录 / **限兑商品(重点看高价值套装是否已拥有)**。
**不改任何账号状态、不发业务事件** —— 纯查询不是「事实」,
按既有设计例行查询不进时间线,否则查几次就把时间线刷满了。
## 为什么「是否已拥有」能直接查官方
`query_limited_goods` 返回的商品项里 `count` / `foreverCount` / `storeStatus`
是**账号维度**的(不是全局库存)—— 已用生产镜像库实测,见
`extract_high_value_goods` 的文档。
## 诚实边界
**单项失败不整体失败**:账号没绑电竞手册是常态,不该让整次查询失败。
失败写进 `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 .douyu_runner_donate import _parse_expire_at, _points_expire_at
#: 默认关注的高价值商品。按**商品名包含**匹配 —— 官方列表里的名字带前缀
#: (实际是「套装-兔兔白日梦」),所以配置里写核心词即可。
DEFAULT_HIGH_VALUE_GOODS: tuple[str, ...] = ("兔兔白日梦", "狂战萌兔")
#: 官方兑换记录默认取多少条(完整记录可能很多,一次性拉回来没必要)
EXCHANGE_RECORD_LIMIT = 20
#: 单项错误的摘要长度(避免把整个 traceback 塞进 task.result)
_ERROR_MAX = 200
def _err(exc: BaseException) -> str:
return f"{type(exc).__name__}: {exc}"[:_ERROR_MAX]
def extract_high_value_goods(
items: list[dict[str, Any]] | None,
wanted: tuple[str, ...] | list[str] | None = DEFAULT_HIGH_VALUE_GOODS,
records: list[dict[str, Any]] | None = None,
) -> list[dict[str, Any]]:
"""挑出高价值商品,并判定「该账号是否已获得」。
## 判定依据:**官方兑换记录**,不是限兑商品列表
⚠️ 这里推翻了本功能第一版的实现,记录一下踩的坑:
第一版拿 `query_limited_goods` 商品项里的 `count` / `storeStatus` 判断,
依据是「账号 558(兑换过)count=0,账号 15407(没兑换)count=96982」。
**那是错的** —— 两条记录取自 **2026-08-01** 和 **2026-10-04**,相隔两个月,
我把**时间差异**当成了**账号差异**。
在**同一时刻**对比 558 / 607(都兑换过)和 15507(没有),
商品项的**所有字段完全一致**,`preExchange.isExists` 也都是 0 ——
说明 `count` / `foreverCount` / `storeStatus` 是**全局库存/活动状态**,随时间变化,
**不暴露账号维度的拥有信息**。用它判"已拥有"会给运营错误的结论,
而对高价值资产给错结论是这里最不能接受的失败方式。
**正确依据**:`exchange_records(manual_id)` —— 斗鱼官方的**账号级**兑换记录。
实测同一时刻:
====== ============================== ==========
账号 官方兑换记录 判定
====== ============================== ==========
558 破片手榴弹-兔兔白日梦 已获得
607 破片手榴弹-兔兔白日梦 已获得
15507 (0 条) 未获得
====== ============================== ==========
## 两处信息各司其职
- `records`(官方兑换记录)→ **是否已获得**
- `items`(限兑商品列表)→ 该商品**当前**的积分价 / 库存 / 活动时间(是全局信息,
只用来展示"现在还能不能换、要多少分")
## 名字按子串匹配
同一件东西会有多个商品名(实测有「套装-兔兔白日梦」和「破片手榴弹-兔兔白日梦」),
所以按核心词子串匹配,把整个名字家族都算上。
配置里的名字**一个都不能默默消失**:既不在商品列表、也不在兑换记录里的,
返回 `missing=True`,让「没查到这个商品」和「这个账号没有」区分得开。
"""
names = [str(w).strip() for w in (wanted or []) if str(w).strip()]
rows = [it for it in (items or []) if isinstance(it, dict)]
recs = [r for r in (records or []) if isinstance(r, dict)]
out: list[dict[str, Any]] = []
for want in names:
hit = next(
(it for it in rows if want in str(it.get("commodityName") or "")),
None,
)
# 该账号在这件东西上的**全部**兑换记录(可能是多个变体)
got = [
str(r.get("commodityName") or r.get("name") or "")
for r in recs
if want in str(r.get("commodityName") or r.get("name") or "")
]
out.append(
{
"name": str(hit.get("commodityName") or want) if hit else want,
"commodity_id": str(hit.get("commodityId") or "") if hit else "",
# 🎯 是否已获得:**只看官方兑换记录**
"owned": bool(got),
"obtained_names": got,
# 以下是全局的"现在能不能换"信息,仅用于展示
"score": hit.get("score") if hit else None,
"count": hit.get("count") if hit else None,
"count_limited": hit.get("countLimited") if hit else None,
"start_time": _ts(hit.get("startTime")) if hit else None,
"end_time": _ts(hit.get("endTime")) if hit else None,
"pic": str(hit.get("pic") or "") if hit else "",
"in_goods_list": hit is not None,
"missing": hit is None and not got,
"reason": (
"限兑商品列表和官方兑换记录里都没有这个商品"
if hit is None and not got
else ""
),
}
)
return out
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: ...
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()
overview: dict[str, Any] = {
"queried_at": datetime.now(UTC).isoformat(),
"assets": {},
"elite": {},
"esports": {},
"high_value_goods": [],
"exchange_records": [],
"errors": {},
}
errors: dict[str, str] = overview["errors"]
all_records: list[dict[str, Any]] = []
pending_goods: list[dict[str, Any]] = []
# ---- 鱼翅 + 钱包兑换余额 ----
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)
goods_items: list[dict[str, Any]] = []
try:
goods = client.query_limited_goods(manual_id=manual_id, rid=rid)
goods_items = goods.get("limited_goods") or []
overview["limited_goods_total"] = len(goods_items)
except Exception as exc: # noqa: BLE001
errors["limited_goods"] = _err(exc)
# 判定「是否已获得」必须用**官方兑换记录**(账号级),
# 不能用限兑商品列表的 count/storeStatus(那是全局库存,会给出错误结论)。
# 所以这里放在兑换记录**之后**才能判定 —— 见下面的顺序。
pending_goods = goods_items
# ---- 官方兑换记录 ----
if not manual_id:
errors["exchange_records"] = "未配置精英宝典 manualID"
else:
try:
records = client.exchange_records(
manual_id=manual_id, limit=EXCHANGE_RECORD_LIMIT
)
rows = records.get("records") or []
all_records = rows
overview["exchange_records"] = rows[:EXCHANGE_RECORD_LIMIT]
overview["exchange_record_count"] = len(rows)
except Exception as exc: # noqa: BLE001
errors["exchange_records"] = _err(exc)
all_records = []
# ---- 🎯 高价值套装判定(依赖兑换记录,必须放在它之后)----
if not manual_id:
errors["high_value_goods"] = "未配置精英宝典 manualID"
else:
try:
overview["high_value_goods"] = extract_high_value_goods(
pending_goods, _goods_config(config), all_records
)
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 = datetime.now(UTC)
logger.info(f"[douyu] 一键查询账号 {account.id}: {message}")
self._mark_task(db, task, "success", message, overview)