fix(overview): 纠正高价值套装以商城 status==1 为最高准入裁判

- 根因:斗鱼官方商城接口 status==1 代表「已达兑换上限」,历史代码误以为商品列表全为全局字段而单纯依赖常被官方清空的 exchange_records,导致已兑换账号被误判为可兑换
- 修复:extract_high_value_goods 结合商城完整列表 status==1 现场判定已兑换,exchange_records 作为历史展示与兜底
- 前端:优化工作台与一键查询展示,明确展示「已达上限 N 个: ...」与「未达上限」,消除反向歧义
- 文档:沉淀坑 #9 到 AGENTS.md,并更正设计-账号一键查询.md
This commit is contained in:
yml2213
2026-10-07 14:51:51 +08:00
parent 05b2bf8579
commit 5405d4b1d1
8 changed files with 128 additions and 117 deletions
+1
View File
@@ -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. 仓库事实(速查)
+19 -36
View File
@@ -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`(未上架/未查到) |
#### 名字按子串匹配
+44 -17
View File
@@ -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},
]
}
+2 -2
View File
@@ -659,9 +659,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(
+38 -51
View File
@@ -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
@@ -423,14 +411,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
# ---- 角色绑定状态(三个作用域都查)----
@@ -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<HighValueGoods> = [
@@ -130,20 +135,26 @@ const goodsColumns: ColumnsType<HighValueGoods> = [
width: 200,
render: (name: string, g) =>
g.missing ? (
<Tooltip title={g.reason || '限兑列表和官方兑换记录里都没有它'}>
<Tooltip title={g.reason || '商品列表和官方兑换记录里都没有它'}>
<Text type="secondary">{name}</Text>
</Tooltip>
) : (
<Text strong>{name}</Text>
<Tooltip title={g.reason || undefined}>
<Text strong>{name}</Text>
</Tooltip>
),
},
{
title: '可兑换 / 已兑换',
key: 'owned',
width: 130,
width: 150,
render: (_, g) => {
const state = goodsState(g);
return <Tag color={state.color}>{state.text}</Tag>;
return (
<Tooltip title={g.reason || undefined}>
<Tag color={state.color}>{state.text}</Tag>
</Tooltip>
);
},
},
{
@@ -1260,8 +1260,10 @@ export default function DouyuTasksPage({ handbook }: { handbook: HandbookKind })
return <Text type="secondary">{text}</Text>;
}
const count = resultNumber(latestLimitedTask.result, 'limited_count');
if (count === 0 || text === '无限制商品') return <Tag color="green">无限制商品</Tag>;
return <Text title={text}>{text}</Text>;
if (count === 0 || text === '无限制商品' || text === '无受限商品(均可兑换)') {
return <Tag color="green">未达上限</Tag>;
}
return <Tag color="orange" title={text}>{text}</Tag>;
},
},
{
@@ -327,12 +327,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 {