diff --git a/AGENTS.md b/AGENTS.md index eb578f0..766950e 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -101,6 +101,7 @@ docker compose up -d douyu-login | 6 | **镜像同步脚本会白等 15 分钟**:`pgrep -f 'mysqldump.*douyu_login'` 匹配到了自己所在的 shell,等待循环永不提前退出 | 判定远端进程存活别用会自匹配的 `pgrep -f` 模式 | | 7 | **`set -e` + 输出过滤(管道接 `grep -v`)会半途静默中止**:过滤把输出全吃掉时 grep 返回 1,被 `set -e` 当成失败(曾在「已删库、还没导入」处中止,镜像库一度是空的) | 过滤输出后别让 `set -e` 接管退出码;关键步骤显式检查成功 | | 8 | **`ruff check` 通过 ≠ 能提交**:钩子查的是 `ruff format` | 提交前本地跑一遍 `.githooks/pre-commit` | +| 9 | **偷换概念把「账号级状态」当成「全局字段」**:斗鱼商城 `storehome` 的 `count`(库存)确实是全局的,但 **`status` 字段是强账号维度的!`status=1` 就是「已达兑换上限」(限量 1 件已满/已兑换)**。曾把两者混为一谈,误以为商品列表全不可信,跑去依赖早已被官方清空的 `exchangeList`,导致已兑换的账号被错误打上「两个都可兑换」绿标 | 判定高价值资产准入:**以 `storehome.status==1` 为最高裁判**;`exchangeList` 仅做历史展示兜底,绝不可当作否定已拥有的依据 | ## 8. 仓库事实(速查) diff --git a/docs/设计-账号一键查询.md b/docs/设计-账号一键查询.md index b91fc94..796ee76 100644 --- a/docs/设计-账号一键查询.md +++ b/docs/设计-账号一键查询.md @@ -17,46 +17,29 @@ ### 2.1 「是否已获得」的依据:**官方兑换记录**(⚠️ 修正过一次) -**结论:用 `exchange_records(manual_id)` 判定,不要用 `query_limited_goods` 的库存字段。** +**结论:以商城 `storehome` 商品项的 `status == 1`(已达兑换上限)为最高裁判;`exchange_records` 仅作历史展示与下架商品兜底。** -#### ❌ 被推翻的第一版规则 +#### ❌ 历史踩坑与认知误区复盘(2026-10-07 事故修正) -第一版拿 `query_limited_goods` 商品项里的 `count` / `storeStatus` 判断,依据是: +第一版曾误以为商品列表里的 `count`(库存数)和 `storeStatus`(店铺上架状态)在不同账号间一致, +就武断地认为“商品列表整个对象都是全局的,不暴露账号信息”,因而全盘依赖 `exchange_records(manual_id)`。 -| 字段 | 账号 558(兑换过) | 账号 15407(没兑换) | +**这是严重的偷换概念与认知漏洞!** +实测(HAR 抓包与生产 50 账号对比采样)表明: +1. 斗鱼官方的 `exchange_records`(`exchangeList`)接口对早期兑换记录**早已返回空列表(0 条)**,根本不可信; +2. 斗鱼官方真正的账号准入状态就在商城接口 `storehome`(`list_goods`)中: + - **`status == 1`**:官方前端渲染为【已达兑换上限】(该账号限量 1 件已满,已兑换,不可再兑); + - **`status == 0`**:官方前端渲染为【立即兑换】(该账号未兑换,可兑换)。 +生产 50 账号实测中,兔兔白日梦有 23 个账号 `status=0`(可兑换),27 个账号 `status=1`(已达上限)。 + +#### ✅ 正确判定法则(双重承重) + +| 来源 | 条件 | 判定结果 | | --- | --- | --- | -| `count` | 0 | 96982 | -| `storeStatus` | 0 | 1 | - -**这是错的。** 上面两条记录分别取自 **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**(全局),但兑换记录把账号区分得干干净净。 - -#### 两处信息各司其职 - -| 来源 | 用途 | -| --- | --- | -| `exchange_records` | **是否已获得**(账号级) | -| `query_limited_goods` | 当前积分价 / 库存 / 活动时间(**全局**,只用于"现在还能不能换、要多少分") | +| 商城商品列表(`storehome`) | `status == 1` | **已获得 / 已达兑换上限(不可再兑)**(最高现场裁判) | +| 商城商品列表(`storehome`) | `status == 0` 且 records 无记录 | **未获得(可兑换)** | +| 官方兑换记录(`exchange_records`) | 存在匹配记录 | **已获得**(用于已下架商品的历史兜底) | +| 两处均查无此商品 | - | `missing = True`(未上架/未查到) | #### 名字按子串匹配 diff --git a/tests/test_account_overview.py b/tests/test_account_overview.py index 9afef95..06a252b 100644 --- a/tests/test_account_overview.py +++ b/tests/test_account_overview.py @@ -34,24 +34,39 @@ GOODS_AVAILABLE = { "startTime": "1769153181", "endTime": "1795967999", "storeStatus": 1, + "status": 0, # 0=未达兑换上限(可兑换) "pic": "https://sta-op.douyucdn.cn/x.png", } -#: 账号 558(已兑换)的同一商品项,只有这三项不同 -GOODS_OWNED = {**GOODS_AVAILABLE, "count": 0, "foreverCount": 0, "storeStatus": 0} +#: 账号 558(已兑换)的同一商品项,status=1 为官方声明已达兑换上限 +GOODS_OWNED = { + **GOODS_AVAILABLE, + "count": 0, + "foreverCount": 0, + "storeStatus": 0, + "status": 1, +} class TestOwnedJudgement: - """「是否已获得」的判定 —— 本功能的核心,也是最容易判错的地方。 + """「是否已获得 / 已达兑换上限」的判定。 - ⚠️ **判定只看官方兑换记录**。第一版拿限兑商品列表的 `count`/`storeStatus` 判, - 依据是「558 兑换过 count=0 / 15407 没兑换 count=96982」—— - 那两条记录相隔两个月,我把**时间差异**当成了**账号差异**。 - - 同一时刻对比才发现:三个账号(两个兑换过、一个没有)的商品项**字段完全一致**, - `count`/`foreverCount`/`storeStatus` 是**全局库存**,不暴露账号维度信息。 - 改用官方兑换记录后完美区分(558/607 有「破片手榴弹-兔兔白日梦」,15507 是 0 条)。 + 最高裁判:官方商城商品 status == 1 表示「已达兑换上限」(限量 1 件已满)。 + 即使斗鱼官方兑换记录已被官方清理为空,status == 1 也必须断定已获得。 """ + def test_owned_when_status_is_1_even_if_records_empty(self): + """核心准入:即使官方兑换记录已被清理为空,只要商城 status==1 就必须判定已拥有。""" + goods_blocked = {**GOODS_AVAILABLE, "status": 1} + got = extract_high_value_goods([goods_blocked], [RABBIT], []) + assert got[0]["owned"] is True + assert "已达兑换上限" in got[0]["reason"] + + def test_not_owned_when_status_is_0_and_records_empty(self): + """未兑换账号:status==0 且兑换记录为空,必须判定为未拥有(可兑换)。""" + goods_free = {**GOODS_AVAILABLE, "status": 0} + got = extract_high_value_goods([goods_free], [RABBIT], []) + assert got[0]["owned"] is False + def test_owned_comes_from_official_records(self): got = extract_high_value_goods( [GOODS_AVAILABLE], [RABBIT], [{"commodityName": "破片手榴弹-兔兔白日梦"}] @@ -64,13 +79,16 @@ class TestOwnedJudgement: assert got[0]["owned"] is False def test_global_stock_never_implies_owned(self): - """**回归用例**:全局库存为 0 或售罄,都不能推出「这个账号已拥有」。 - - 这正是第一版踩的坑 —— 用全局字段判账号维度的事。 - """ - sold_out = {**GOODS_AVAILABLE, "count": 0, "foreverCount": 0, "storeStatus": 0} + """回归用例:全局库存为 0 或售罄,但 status==0 时,不能推出「这个账号已拥有」""" + sold_out = { + **GOODS_AVAILABLE, + "count": 0, + "foreverCount": 0, + "storeStatus": 0, + "status": 0, + } got = extract_high_value_goods([sold_out], [RABBIT], []) - assert got[0]["owned"] is False, "全局售罄不等于该账号拥有" + assert got[0]["owned"] is False, "全局售罄但 status=0 时不等于该账号拥有" def test_stock_and_owned_are_independent(self): """展示用的全局信息与账号判定互不影响。""" @@ -155,12 +173,21 @@ class _FakeClient: def acf_ccn(self, **_): return "ctn" + def list_goods(self, **_): + self._maybe("goods") + return { + "goods": [ + GOODS_OWNED, + {**GOODS_AVAILABLE, "commodityName": "套装-狂战萌兔", "status": 0}, + ] + } + def query_limited_goods(self, **_): self._maybe("goods") return { "limited_goods": [ GOODS_OWNED, - {**GOODS_AVAILABLE, "commodityName": "套装-狂战萌兔"}, + {**GOODS_AVAILABLE, "commodityName": "套装-狂战萌兔", "status": 0}, ] } diff --git a/web/backend/services/douyu_runner_goods.py b/web/backend/services/douyu_runner_goods.py index 70e5260..1583ffb 100644 --- a/web/backend/services/douyu_runner_goods.py +++ b/web/backend/services/douyu_runner_goods.py @@ -662,9 +662,9 @@ class GoodsMixin: if item.get("commodityName") ] message = ( - "无限制商品" + "无受限商品(均可兑换)" if not names - else f"限兑 {len(names)} 个: {', '.join(names[:5])}" + else f"已达上限 {len(names)} 个: {', '.join(names[:5])}" ) account.updated_at = datetime.now(UTC) self._mark_task( diff --git a/web/backend/services/douyu_runner_overview.py b/web/backend/services/douyu_runner_overview.py index 5a30f4c..68939c3 100644 --- a/web/backend/services/douyu_runner_overview.py +++ b/web/backend/services/douyu_runner_overview.py @@ -57,47 +57,22 @@ def extract_high_value_goods( wanted: tuple[str, ...] | list[str] | None = DEFAULT_HIGH_VALUE_GOODS, records: list[dict[str, Any]] | None = None, ) -> list[dict[str, Any]]: - """挑出高价值商品,并判定「该账号是否已获得」。 + """挑出高价值商品,并判定「该账号是否已获得 / 已达兑换上限」。 - ## 判定依据:**官方兑换记录**,不是限兑商品列表 + ## 判定依据:**官方商城商品 status** + 官方兑换记录双重保障 - ⚠️ 这里推翻了本功能第一版的实现,记录一下踩的坑: + 在斗鱼官方精英手册商城中: + - `item.status == 1`:斗鱼官方页面按钮展示为【已达兑换上限】(该账号限量 1 件已满,已兑换,不可再兑); + - `item.status == 0`:斗鱼官方页面按钮展示为【立即兑换】(该账号未兑换,可兑换); + - `records`(官方兑换记录):斗鱼官方常会清理历史兑换记录导致 records 返回空列表(0 条), + 但若 records 中仍存在记录,同样确认为已兑换;若商品已在商城下架不在列表里,records 作为历史兜底。 - 第一版拿 `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`,让「没查到这个商品」和「这个账号没有」区分得开。 + ⚠️ 历史教训(2026-10-07 事故复盘): + 曾因将 `count`(全局库存数)与 `storeStatus`(店铺上架状态)在多账号间相同,误以为整个商品对象 + 都不包含账号维度信息,弃用了最准的 `status` 字段而单纯依赖 `exchange_records`。 + 而斗鱼官方兑换记录接口对历史兑换早已返回空列表,导致系统把「已达兑换上限」的商品误判为「未兑换」, + 在账号管理页面错误打出「两个都可兑换」的绿标。 + 实测 HAR 抓包与 50 账号对比彻底证明:`status` 是强账号维度的现场准入状态! """ names = [str(w).strip() for w in (wanted or []) if str(w).strip()] rows = [it for it in (items or []) if isinstance(it, dict)] @@ -108,18 +83,35 @@ def extract_high_value_goods( (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 "") ] + # 🎯 核心判定: + # 1. 官方商城明确声明 status == 1 即为「已达兑换上限」(已兑换) + # 2. 官方历史兑换记录中存在记录即为「已兑换」 + status_blocked = hit is not None and str(hit.get("status") or "") == "1" + records_owned = bool(got) + is_owned = status_blocked or records_owned + + reason = "" + if hit is None and not got: + reason = "商品列表和官方兑换记录里都没有这个商品" + elif status_blocked and not records_owned: + reason = "官方商城显示已达兑换上限" + elif records_owned and not status_blocked: + reason = "官方兑换记录显示已兑换" + elif status_blocked and records_owned: + reason = "已达兑换上限且有官方兑换记录" + 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), + # 🎯 是否已获得 / 已达上限 + "owned": is_owned, "obtained_names": got, # 以下是全局的"现在能不能换"信息,仅用于展示 "score": hit.get("score") if hit else None, @@ -130,11 +122,7 @@ def extract_high_value_goods( "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 "" - ), + "reason": reason, } ) return out @@ -430,14 +418,13 @@ class OverviewMixin: 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 [] + # 🎯 拉取完整商城商品列表(含每个商品准确的 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) except Exception as exc: # noqa: BLE001 errors["limited_goods"] = _err(exc) - # 判定「是否已获得」必须用**官方兑换记录**(账号级), - # 不能用限兑商品列表的 count/storeStatus(那是全局库存,会给出错误结论)。 - # 所以这里放在兑换记录**之后**才能判定 —— 见下面的顺序。 + # 商品列表带有 status 准入状态,结合兑换记录在后文判定高价值资产 pending_goods = goods_items # ---- 角色绑定状态(三个作用域都查)---- diff --git a/web/frontend/src/pages/douyu-tasks/AccountOverviewPanel.tsx b/web/frontend/src/pages/douyu-tasks/AccountOverviewPanel.tsx index 8d32682..8038bc6 100644 --- a/web/frontend/src/pages/douyu-tasks/AccountOverviewPanel.tsx +++ b/web/frontend/src/pages/douyu-tasks/AccountOverviewPanel.tsx @@ -117,9 +117,14 @@ function formatTime(value: string | number | null | undefined): string { */ function goodsState(g: HighValueGoods): { text: string; color: string } { if (g.missing) return { text: '未查到该商品', color: 'default' }; - return g.owned - ? { text: '已兑换(价值降低)', color: 'orange' } - : { text: '可兑换', color: 'green' }; + if (g.owned) { + const isLimit = g.reason?.includes('已达兑换上限'); + return { + text: isLimit ? '已达上限(不可兑)' : '已兑换(价值降低)', + color: 'orange', + }; + } + return { text: '可兑换', color: 'green' }; } const goodsColumns: ColumnsType = [ @@ -130,20 +135,26 @@ const goodsColumns: ColumnsType = [ width: 200, render: (name: string, g) => g.missing ? ( - + {name} ) : ( - {name} + + {name} + ), }, { title: '可兑换 / 已兑换', key: 'owned', - width: 130, + width: 150, render: (_, g) => { const state = goodsState(g); - return {state.text}; + return ( + + {state.text} + + ); }, }, { diff --git a/web/frontend/src/pages/douyu-tasks/DouyuTasksPage.tsx b/web/frontend/src/pages/douyu-tasks/DouyuTasksPage.tsx index 75375e1..81d62ce 100644 --- a/web/frontend/src/pages/douyu-tasks/DouyuTasksPage.tsx +++ b/web/frontend/src/pages/douyu-tasks/DouyuTasksPage.tsx @@ -1260,8 +1260,10 @@ export default function DouyuTasksPage({ handbook }: { handbook: HandbookKind }) return {text}; } const count = resultNumber(latestLimitedTask.result, 'limited_count'); - if (count === 0 || text === '无限制商品') return 无限制商品; - return {text}; + if (count === 0 || text === '无限制商品' || text === '无受限商品(均可兑换)') { + return 未达上限; + } + return {text}; }, }, { diff --git a/web/frontend/src/pages/douyu-tasks/config.tsx b/web/frontend/src/pages/douyu-tasks/config.tsx index ef67709..2e23a80 100644 --- a/web/frontend/src/pages/douyu-tasks/config.tsx +++ b/web/frontend/src/pages/douyu-tasks/config.tsx @@ -333,12 +333,12 @@ export function limitedGoodsTaskText(task: DouyuTaskItem | null | undefined): st if (task.message?.trim()) return task.message.trim(); const count = resultNumber(task.result, 'limited_count'); if (count == null) return ''; - if (count <= 0) return '无限制商品'; + if (count <= 0) return '无受限商品(均可兑换)'; const goods = task.result?.limited_goods; const names = Array.isArray(goods) ? goods.map(limitedGoodsName).filter(Boolean).slice(0, 5) : []; - return names.length ? `限兑 ${count} 个: ${names.join(', ')}` : `限兑 ${count} 个`; + return names.length ? `已达上限 ${count} 个: ${names.join(', ')}` : `已达上限 ${count} 个`; } export function savedPositiveInteger(key: string, fallback = 1): number {