Files
live-hub-py/web/backend/services/douyu_runner_overview.py
T
yml2213 3866b49087 fix(account): 一键查询按反馈收敛 —— 只在账号管理展示、结果落账号、修 1970 显示
按用户反馈改了四处。

## ① 结果只在账号管理展示(不再出现在精英宝典/电竞手册页)

移除了工作台页的结果弹窗与导入。**类型集合保留**(三个工作台都有)——
因为账号页批量建任务要用 `handbook_scope` 过 `DOUYU_HANDBOOK_TASK_TYPES` 校验,
去掉会被「该任务不属于当前工作台」拦掉;去掉的只是**快捷操作按钮**。

## ② 不再每次弹出

原来任务跑完就自动弹面板,而且任务会被反复轮询到。现在整个弹窗机制去掉了:
结果落在账号上,打开账号就能看。

## ③ 结果写进账号(新增迁移 0047)

新增 `accounts.overview_snapshot`(JSON)+ `overview_queried_at`,覆盖式只留最新一份。
详情页直接读账号上的快照,不再轮询任务。

**为什么放账号而不是只留任务里**:任务会被保留策略清掉(长保留档 90 天),
而「这个账号现在什么情况」是**账号的信息**,不该依赖一条日志的寿命。

## ④ 修 1970/1/1 08:00:00(真 bug)

斗鱼用 `0` / `"0"` / 空串表示「没有这个时间」。原实现直接透传,
前端 `new Date(0)` → **1970/1/1 08:00:00** —— 运营会以为"到期时间是 1970 年"。

两端都修了:
- 后端新增 `_ts()`,把 `0`/`"0"`/空/负数 归成 `None`(积分到期、宝典到期、
  电竞到期、商品活动时间四处都过一遍)
- 前端 `formatTime` 也把 `0`/负数显示成 `-`(上游再出错也不至于显示 1970)

## 测试(+14 条,共 36)

- `TestTimestampNormalisation`:`0`/`"0"`/空/负数 → `None`;真实时间戳透传
- `TestSnapshotIsWrittenToAccount`:快照与时间落到账号上、
  **`willExpireTime="0"` 不能落库成 0**(1970 的回归用例)、覆盖式不追加

## 验证

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

355 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 _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 里取 —— 复用已有具名函数,不自己扒结构。
overview["assets"]["points_expire_at"] = _ts(_points_expire_at(points))
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)