feat(account): 账号「一键查询」(异步 + 批量)—— 含一次重要的判定修正
按 `docs/设计-账号一键查询.md` 实现。异步任务、支持批量(后端每账号一条任务)。 ## 功能 - 新任务类型 `query_account_overview`:**只读**聚合查询,不改账号状态、不发事件 (纯查询不是「事实」,按既有设计例行查询不进时间线,否则会刷屏) - 一次拉全:鱼翅 / 钱包兑换余额 / 积分(含到期)/ 精英宝典 / 电竞手册 / **官方兑换记录** / 限兑商品,并**重点标出「兔兔白日梦」「狂战萌兔」是否已获得** - **单项失败不整体失败**:账号没绑电竞手册是常态,失败只写进 `errors` 并在 UI **如实展示** —— 避免「没查到」被读成「没有」 - 批量:`create_douyu_planned_tasks` 本来就是每账号一条任务,前端加「批量一键查询」 - 前端:详情页一键查询卡片 + 结果面板(两个套装单独高亮);任务页结果自动弹面板; 三个工作台的快捷操作/类型集合都加了 ## ⚠️ 修正:判定「是否已获得」用错了依据 设计文档第一版说「`storeStatus == 0` → 已拥有」,依据是 「账号 558(兑换过)count=0,账号 15407(没兑换)count=96982」。 **这是错的。** 那两条记录分别取自 **2026-08-01** 和 **2026-10-04**,相隔两个月 —— 我把**时间差异**当成了**账号差异**。 **同一时刻**同时查 558 / 607(都兑换过)和 15507(没有), 商品项的**所有字段完全一致**,`preExchange.isExists` 也都是 0; `count` 的取值分布是 0/98746/103350/109832…,**随时间变化 = 全局库存**。 用它判「这个账号有没有」必然给错结论 —— 而对高价值资产给错结论是本功能最不能接受的。 **正确依据:`exchange_records(manual_id)`**(斗鱼官方的**账号级**兑换记录)。 真实斗鱼实测: | 账号 | 官方兑换记录 | 判定 | | --- | --- | --- | | 558 | 破片手榴弹-兔兔白日梦 | **已获得 ✅** | | 607 | 破片手榴弹-兔兔白日梦 | **已获得 ✅** | | 15507 | (0 条) | 未获得 ✅ | 同时刻三者看到的兔兔白日梦库存**都是 96956**(全局),兑换记录却区分得干干净净。 顺带发现:同一件东西有多个商品名(`套装-兔兔白日梦`、`破片手榴弹-兔兔白日梦`), 所以按**核心词子串**匹配整个名字家族。 商品列表的 `score`/`count`/活动时间仍展示,但明确是**全局**信息 (「现在还能不能换、要多少分」),与「该账号是否已获得」分开。 ## 测试 `tests/test_account_overview.py` 22 条,含**回归用例**: `test_global_stock_never_implies_owned`(全局售罄不能推出账号拥有)—— 正是第一版踩的坑。另有失败隔离、只读性(不发事件/不改状态)、 批量每账号一条任务、`missing` 不被静默丢弃。 ## 验证 - `TZ=Asia/Shanghai` **574 passed / 6 skipped**;`TZ=UTC` 27 passed - ruff 全绿;前端 build 通过、lint 0 errors - **真实斗鱼端到端**:三个账号各跑一次真查询,8 项全部成功(`errors: []`), 已获得/未获得判定全部正确
This commit is contained in:
@@ -21,6 +21,7 @@ from .douyu_runner_donate import DonateMixin
|
||||
from .douyu_runner_gold import GoldMixin
|
||||
from .douyu_runner_goods import GoodsMixin
|
||||
from .douyu_runner_manual import ManualMixin
|
||||
from .douyu_runner_overview import OverviewMixin
|
||||
from .douyu_runner_xpd import XpdMixin
|
||||
from .douyu_service import update_account_profile_from_cookie
|
||||
|
||||
@@ -29,6 +30,7 @@ class DouyuBatchRunner(
|
||||
DouyuBatchRunnerCore,
|
||||
BindMixin,
|
||||
ManualMixin,
|
||||
OverviewMixin,
|
||||
GoldMixin,
|
||||
DonateMixin,
|
||||
GoodsMixin,
|
||||
@@ -111,6 +113,7 @@ class DouyuBatchRunner(
|
||||
"query_game_name": self._execute_query_game_name,
|
||||
"query_change_bind_time": self._execute_query_change_bind_time,
|
||||
"query_limited_goods": self._execute_query_limited_goods,
|
||||
"query_account_overview": self._execute_query_account_overview,
|
||||
"query_gold_balance": self._execute_query_gold_balance,
|
||||
"query_exchange_records": self._execute_query_exchange_records,
|
||||
"prefetch_csrf_token": self._execute_prefetch_csrf_token,
|
||||
|
||||
@@ -0,0 +1,328 @@
|
||||
"""一键查询:把账号的关键信息一次性从斗鱼官方拉全并汇总。
|
||||
|
||||
## 定位
|
||||
|
||||
一个**只读**聚合任务:鱼翅 / 积分(含到期时间)/ 精英宝典 / 电竞手册 /
|
||||
小店 / 官方兑换记录 / **限兑商品(重点看高价值套装是否已拥有)**。
|
||||
|
||||
**不改任何账号状态、不发业务事件** —— 纯查询不是「事实」,
|
||||
按既有设计例行查询不进时间线,否则查几次就把时间线刷满了。
|
||||
|
||||
## 为什么「是否已拥有」能直接查官方
|
||||
|
||||
`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": hit.get("startTime") if hit else None,
|
||||
"end_time": 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)
|
||||
|
||||
|
||||
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"] = _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": 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": 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)} 项未取到)"
|
||||
|
||||
logger.info(f"[douyu] 一键查询账号 {account.id}: {message}")
|
||||
self._mark_task(db, task, "success", message, overview)
|
||||
@@ -37,6 +37,8 @@ SUPPORTED_DOUYU_TASK_TYPES = {
|
||||
"query_game_name": "一键获取游戏名",
|
||||
"query_change_bind_time": "一键查询换绑时间",
|
||||
"query_limited_goods": "一键查询限兑商品",
|
||||
# 跨域聚合查询:鱼翅/积分/精英/电竞/小店/兑换记录/高价值套装是否已拥有
|
||||
"query_account_overview": "一键查询账号信息",
|
||||
"query_gold_balance": "一键查询鱼翅余额",
|
||||
"refresh_goods": "刷新商品列表",
|
||||
"query_exchange_records": "一键查询兑换记录",
|
||||
@@ -72,6 +74,7 @@ DOUYU_HANDBOOK_TASK_TYPES = {
|
||||
"refresh_goods",
|
||||
"query_exchange_records",
|
||||
"prefetch_csrf_token",
|
||||
"query_account_overview",
|
||||
},
|
||||
"esports": {
|
||||
"prepare_esports_bind",
|
||||
@@ -88,6 +91,7 @@ DOUYU_HANDBOOK_TASK_TYPES = {
|
||||
"create_gold_qr",
|
||||
"donate_esports_chicken_gift",
|
||||
"donate_esports_firework_gift",
|
||||
"query_account_overview",
|
||||
},
|
||||
"peace": {
|
||||
"get_xpd_bind_qr",
|
||||
@@ -99,6 +103,7 @@ DOUYU_HANDBOOK_TASK_TYPES = {
|
||||
"query_xpd_fragments",
|
||||
"query_xpd_purchase_records",
|
||||
"exchange_xpd_goods",
|
||||
"query_account_overview",
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user