Merge branch 'main' into dev

This commit is contained in:
yml2213
2026-10-07 14:52:22 +08:00
8 changed files with 128 additions and 117 deletions
+2 -2
View File
@@ -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(
+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
@@ -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
# ---- 角色绑定状态(三个作用域都查)----
@@ -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>;
},
},
{
@@ -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 {