fix(overview): 锁定核心高价值套装并修复未查询空值误判
- 高价值套装精确匹配「套装-兔兔白日梦」与「套装-狂战萌兔」,排除头盔/背包/手榴弹散件干扰 - 增加 is_suit_queried_expr,未查询或 NULL 账号严禁误判为双套均可兑换 - 修复前后端序列化空值保持,完善列表与详情页准入状态和判定依据展示 - 新增头盔排除与 NULL 统计防错回归单测,全量通过 689 个用例
This commit is contained in:
@@ -42,14 +42,28 @@ def suit_owned_expr(column, text: str):
|
||||
)
|
||||
|
||||
|
||||
def is_suit_queried_expr():
|
||||
"""判断账号是否有效完成过高价值套装查询。
|
||||
|
||||
必须同时满足:
|
||||
1. 有查询时间 overview_queried_at
|
||||
2. high_value_owned 必须是有效 JSON 数组(排除 SQL NULL 与 JSON NULL)
|
||||
"""
|
||||
return and_(
|
||||
Account.overview_queried_at.isnot(None),
|
||||
Account.high_value_owned.isnot(None),
|
||||
cast(Account.high_value_owned, String).like("[%"),
|
||||
)
|
||||
|
||||
|
||||
def value_tier_expr():
|
||||
"""1–5 档。没查过、也没有任何资产信号时落在第 5 档。"""
|
||||
queried = Account.overview_queried_at.isnot(None)
|
||||
hv_queried = is_suit_queried_expr()
|
||||
owns_bairimeng = suit_owned_expr(Account.high_value_owned, SUIT_BAIRIMENG)
|
||||
owns_mengtu = suit_owned_expr(Account.high_value_owned, SUIT_MENGTU)
|
||||
both_available = and_(queried, ~owns_bairimeng, ~owns_mengtu)
|
||||
both_available = and_(hv_queried, ~owns_bairimeng, ~owns_mengtu)
|
||||
one_available = and_(
|
||||
queried,
|
||||
hv_queried,
|
||||
or_(
|
||||
and_(~owns_bairimeng, owns_mengtu),
|
||||
and_(owns_bairimeng, ~owns_mengtu),
|
||||
@@ -59,7 +73,7 @@ def value_tier_expr():
|
||||
(both_available, 1),
|
||||
(or_(one_available, Account.points >= HIGH_VALUE_POINTS), 2),
|
||||
(or_(Account.gold_balance > 0, Account.elite_opened.is_(True)), 3),
|
||||
(queried, 4),
|
||||
(Account.overview_queried_at.isnot(None), 4),
|
||||
else_=5,
|
||||
)
|
||||
|
||||
@@ -79,18 +93,18 @@ def suit_sort_rank_expr():
|
||||
|
||||
不掺积分、鱼翅。那是巡检分档的事,点这一列的人是在排套装。
|
||||
"""
|
||||
queried = Account.overview_queried_at.isnot(None)
|
||||
hv_queried = is_suit_queried_expr()
|
||||
owns_bairimeng = suit_owned_expr(Account.high_value_owned, SUIT_BAIRIMENG)
|
||||
owns_mengtu = suit_owned_expr(Account.high_value_owned, SUIT_MENGTU)
|
||||
both_available = and_(queried, ~owns_bairimeng, ~owns_mengtu)
|
||||
both_available = and_(hv_queried, ~owns_bairimeng, ~owns_mengtu)
|
||||
one_available = and_(
|
||||
queried,
|
||||
hv_queried,
|
||||
or_(
|
||||
and_(~owns_bairimeng, owns_mengtu),
|
||||
and_(owns_bairimeng, ~owns_mengtu),
|
||||
),
|
||||
)
|
||||
both_owned = and_(queried, owns_bairimeng, owns_mengtu)
|
||||
both_owned = and_(hv_queried, owns_bairimeng, owns_mengtu)
|
||||
return case(
|
||||
(both_available, 0),
|
||||
(one_available, 1),
|
||||
|
||||
@@ -37,9 +37,8 @@ from .account_binding import derive_from_bind_info, set_bind_status
|
||||
from .business_events import record_official_exchange
|
||||
from .douyu_runner_donate import _parse_expire_at, _points_expire_at
|
||||
|
||||
#: 默认关注的高价值商品。按**商品名包含**匹配 —— 官方列表里的名字带前缀
|
||||
#: (实际是「套装-兔兔白日梦」),所以配置里写核心词即可。
|
||||
DEFAULT_HIGH_VALUE_GOODS: tuple[str, ...] = ("兔兔白日梦", "狂战萌兔")
|
||||
#: 默认关注的高价值商品。必须精确关注两款核心套装,排除头盔、背包等配件散件。
|
||||
DEFAULT_HIGH_VALUE_GOODS: tuple[str, ...] = ("套装-兔兔白日梦", "套装-狂战萌兔")
|
||||
|
||||
#: 官方兑换记录默认取多少条(完整记录可能很多,一次性拉回来没必要)
|
||||
EXCHANGE_RECORD_LIMIT = 20
|
||||
@@ -47,17 +46,56 @@ EXCHANGE_RECORD_LIMIT = 20
|
||||
#: 单项错误的摘要长度(避免把整个 traceback 塞进 task.result)
|
||||
_ERROR_MAX = 200
|
||||
|
||||
#: 配件散件排除关键词列表(避免把头盔、背包误识别为高价值套装)
|
||||
SUIT_EXCLUDE_KEYWORDS = ("头盔", "背包", "手榴弹", "挂件", "降落伞", "枪")
|
||||
|
||||
|
||||
def _err(exc: BaseException) -> str:
|
||||
return f"{type(exc).__name__}: {exc}"[:_ERROR_MAX]
|
||||
|
||||
|
||||
def _match_suit_item(want: str, rows: list[dict[str, Any]]) -> dict[str, Any] | None:
|
||||
"""在商品列表中精确匹配套装商品,严格排除头盔、背包等配件干扰。"""
|
||||
# 1. 优先完全一致匹配
|
||||
for it in rows:
|
||||
cname = str(it.get("commodityName") or "").strip()
|
||||
if cname == want:
|
||||
return it
|
||||
|
||||
# 2. 核心词(如 兔兔白日梦 / 狂战萌兔)精准匹配,排除配件关键词
|
||||
core = want.replace("套装-", "").strip()
|
||||
# 优先找带「套装」前缀的
|
||||
for it in rows:
|
||||
cname = str(it.get("commodityName") or "").strip()
|
||||
if core in cname and not any(k in cname for k in SUIT_EXCLUDE_KEYWORDS):
|
||||
if "套装" in cname:
|
||||
return it
|
||||
|
||||
# 3. 兜底匹配不带排除词的项
|
||||
for it in rows:
|
||||
cname = str(it.get("commodityName") or "").strip()
|
||||
if core in cname and not any(k in cname for k in SUIT_EXCLUDE_KEYWORDS):
|
||||
return it
|
||||
return None
|
||||
|
||||
|
||||
def _match_suit_records(want: str, recs: list[dict[str, Any]]) -> list[str]:
|
||||
"""在官方兑换记录中匹配对应套装,严格排除配件干扰。"""
|
||||
core = want.replace("套装-", "").strip()
|
||||
got: list[str] = []
|
||||
for r in recs:
|
||||
cname = str(r.get("commodityName") or r.get("name") or "").strip()
|
||||
if core in cname and not any(k in cname for k in SUIT_EXCLUDE_KEYWORDS):
|
||||
got.append(cname)
|
||||
return got
|
||||
|
||||
|
||||
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]]:
|
||||
"""挑出高价值商品,并判定「该账号是否已获得 / 已达兑换上限」。
|
||||
"""挑出高价值套装,并判定「该账号是否已获得 / 已达兑换上限」。
|
||||
|
||||
## 判定依据:**官方商城商品 status** + 官方兑换记录双重保障
|
||||
|
||||
@@ -66,29 +104,15 @@ def extract_high_value_goods(
|
||||
- `item.status == 0`:斗鱼官方页面按钮展示为【立即兑换】(该账号未兑换,可兑换);
|
||||
- `records`(官方兑换记录):斗鱼官方常会清理历史兑换记录导致 records 返回空列表(0 条),
|
||||
但若 records 中仍存在记录,同样确认为已兑换;若商品已在商城下架不在列表里,records 作为历史兜底。
|
||||
|
||||
⚠️ 历史教训(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)]
|
||||
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 "")
|
||||
]
|
||||
hit = _match_suit_item(want, rows)
|
||||
got = _match_suit_records(want, recs)
|
||||
|
||||
# 🎯 核心判定:
|
||||
# 1. 官方商城明确声明 status == 1 即为「已达兑换上限」(已兑换)
|
||||
# 2. 官方历史兑换记录中存在记录即为「已兑换」
|
||||
@@ -113,7 +137,7 @@ def extract_high_value_goods(
|
||||
# 🎯 是否已获得 / 已达上限
|
||||
"owned": is_owned,
|
||||
"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,
|
||||
|
||||
Reference in New Issue
Block a user