fix(overview): 锁定核心高价值套装并修复未查询空值误判
- 高价值套装精确匹配「套装-兔兔白日梦」与「套装-狂战萌兔」,排除头盔/背包/手榴弹散件干扰 - 增加 is_suit_queried_expr,未查询或 NULL 账号严禁误判为双套均可兑换 - 修复前后端序列化空值保持,完善列表与详情页准入状态和判定依据展示 - 新增头盔排除与 NULL 统计防错回归单测,全量通过 689 个用例
This commit is contained in:
@@ -36,8 +36,8 @@ LoginTask.cookie.isnot(None) & (LoginTask.cookie != "")
|
||||
而列表接口序列化时:
|
||||
|
||||
```python
|
||||
status=attempt_status or t.status # 显示的是「最近一次尝试」
|
||||
message=(attempt_message if attempt_status else "") or t.message
|
||||
status = attempt_status or t.status # 显示的是「最近一次尝试」
|
||||
message = (attempt_message if attempt_status else "") or t.message
|
||||
```
|
||||
|
||||
两者不是一回事。生产实测当时有 **752 行**处于「显示 failed、但 cookie 非空」的状态
|
||||
@@ -46,8 +46,10 @@ message=(attempt_message if attempt_status else "") or t.message
|
||||
新加的「清空失败」按**显示状态**筛:
|
||||
|
||||
```python
|
||||
or_(and_(attempt_status != "", attempt_status == "failed"),
|
||||
and_(attempt_status == "", status == "failed"))
|
||||
or_(
|
||||
and_(attempt_status != "", attempt_status == "failed"),
|
||||
and_(attempt_status == "", status == "failed"),
|
||||
)
|
||||
```
|
||||
|
||||
于是这 752 行连同其它 success 行一起被选中删除。**这不是单纯的边界疏漏,是把两个
|
||||
|
||||
+17
-9
@@ -131,13 +131,19 @@ cookie_updated_at: Mapped[datetime | None] = mapped_column(UTCDateTime, nullable
|
||||
# ── CK 健康状态(原 login_tasks.ck_check_*)──
|
||||
ck_check_status: Mapped[str] = mapped_column(String(16), default="", nullable=False)
|
||||
ck_check_result: Mapped[dict | None] = mapped_column(JSON, nullable=True)
|
||||
ck_checked_at: Mapped[datetime | None] = mapped_column(UTCDateTime, nullable=True, index=True)
|
||||
ck_checked_at: Mapped[datetime | None] = mapped_column(
|
||||
UTCDateTime, nullable=True, index=True
|
||||
)
|
||||
|
||||
# ── 重登调度(原 login_tasks.relogin_*)──
|
||||
relogin_attempts: Mapped[int] = mapped_column(Integer, default=0, nullable=False)
|
||||
relogin_next_at: Mapped[datetime | None] = mapped_column(UTCDateTime, nullable=True, index=True)
|
||||
relogin_next_at: Mapped[datetime | None] = mapped_column(
|
||||
UTCDateTime, nullable=True, index=True
|
||||
)
|
||||
relogin_last_error: Mapped[str] = mapped_column(String(512), default="", nullable=False)
|
||||
relogin_failure_kind: Mapped[str] = mapped_column(String(16), default="", nullable=False)
|
||||
relogin_failure_kind: Mapped[str] = mapped_column(
|
||||
String(16), default="", nullable=False
|
||||
)
|
||||
```
|
||||
|
||||
`login_tasks` 的对应列**本次不动**(既不读写、也不 drop),留到下一次发版再清理 —— 这是回退后路,见 §6、§7。
|
||||
@@ -366,12 +372,14 @@ db.query(Account)
|
||||
**直接消失**(表现为「账号突然没有 CK」,且无从察觉是漏回填)。所以迁移里必须:
|
||||
|
||||
```python
|
||||
expected = db.execute(text(
|
||||
"SELECT COUNT(DISTINCT account_id) FROM login_tasks WHERE cookie IS NOT NULL AND cookie <> ''"
|
||||
)).scalar()
|
||||
actual = db.execute(text(
|
||||
"SELECT COUNT(*) FROM accounts WHERE cookie IS NOT NULL AND cookie <> ''"
|
||||
)).scalar()
|
||||
expected = db.execute(
|
||||
text(
|
||||
"SELECT COUNT(DISTINCT account_id) FROM login_tasks WHERE cookie IS NOT NULL AND cookie <> ''"
|
||||
)
|
||||
).scalar()
|
||||
actual = db.execute(
|
||||
text("SELECT COUNT(*) FROM accounts WHERE cookie IS NOT NULL AND cookie <> ''")
|
||||
).scalar()
|
||||
if actual < expected:
|
||||
raise RuntimeError(f"回填不完整:期望 {expected},实际 {actual},中止迁移")
|
||||
```
|
||||
|
||||
+13
-5
@@ -71,11 +71,19 @@ class AccountEvent(Base):
|
||||
account_id: Mapped[int] = mapped_column(ForeignKey("accounts.id"), index=True)
|
||||
event_type: Mapped[str] = mapped_column(String(32), index=True)
|
||||
occurred_at: Mapped[datetime] = mapped_column(UTCDateTime, index=True)
|
||||
title: Mapped[str] = mapped_column(String(256), default="") # 直接可展示
|
||||
amount: Mapped[int | None] = mapped_column(Integer, nullable=True) # 充值额/消耗积分(有符号)
|
||||
balance_after: Mapped[int | None] = mapped_column(Integer, nullable=True) # 变更后余额
|
||||
detail: Mapped[dict | None] = mapped_column(JSON, nullable=True) # 精简后的细节(非原始大 JSON)
|
||||
source: Mapped[str] = mapped_column(String(24), default="") # login/task/audit/manual
|
||||
title: Mapped[str] = mapped_column(String(256), default="") # 直接可展示
|
||||
amount: Mapped[int | None] = mapped_column(
|
||||
Integer, nullable=True
|
||||
) # 充值额/消耗积分(有符号)
|
||||
balance_after: Mapped[int | None] = mapped_column(
|
||||
Integer, nullable=True
|
||||
) # 变更后余额
|
||||
detail: Mapped[dict | None] = mapped_column(
|
||||
JSON, nullable=True
|
||||
) # 精简后的细节(非原始大 JSON)
|
||||
source: Mapped[str] = mapped_column(
|
||||
String(24), default=""
|
||||
) # login/task/audit/manual
|
||||
source_ref: Mapped[str] = mapped_column(String(96), unique=True) # 幂等键
|
||||
created_at: Mapped[datetime] = mapped_column(UTCDateTime, default=_utcnow)
|
||||
```
|
||||
|
||||
@@ -69,15 +69,28 @@ class TestOwnedJudgement:
|
||||
|
||||
def test_owned_comes_from_official_records(self):
|
||||
got = extract_high_value_goods(
|
||||
[GOODS_AVAILABLE], [RABBIT], [{"commodityName": "破片手榴弹-兔兔白日梦"}]
|
||||
[GOODS_AVAILABLE], [RABBIT], [{"commodityName": "套装-兔兔白日梦"}]
|
||||
)
|
||||
assert got[0]["owned"] is True
|
||||
assert got[0]["obtained_names"] == ["破片手榴弹-兔兔白日梦"]
|
||||
assert got[0]["obtained_names"] == ["套装-兔兔白日梦"]
|
||||
|
||||
def test_not_owned_when_records_empty(self):
|
||||
got = extract_high_value_goods([GOODS_AVAILABLE], [RABBIT], [])
|
||||
assert got[0]["owned"] is False
|
||||
|
||||
def test_accessories_never_imply_suit_owned(self):
|
||||
"""散件配件(手榴弹/头盔/背包)绝不能误判为拥有了高价值套装。"""
|
||||
got = extract_high_value_goods(
|
||||
[GOODS_AVAILABLE],
|
||||
[RABBIT],
|
||||
[
|
||||
{"commodityName": "破片手榴弹-兔兔白日梦"},
|
||||
{"commodityName": "兔兔白日梦头盔"},
|
||||
],
|
||||
)
|
||||
assert got[0]["owned"] is False
|
||||
assert got[0]["obtained_names"] == []
|
||||
|
||||
def test_global_stock_never_implies_owned(self):
|
||||
"""回归用例:全局库存为 0 或售罄,但 status==0 时,不能推出「这个账号已拥有」"""
|
||||
sold_out = {
|
||||
@@ -99,12 +112,12 @@ class TestOwnedJudgement:
|
||||
assert got[0]["owned"] is True
|
||||
assert got[0]["count"] == 0 # 全局库存如实展示
|
||||
|
||||
def test_records_match_by_substring_family(self):
|
||||
"""同一件东西有多个商品名(套装- / 破片手榴弹-),都要算数。"""
|
||||
def test_records_match_suit_names(self):
|
||||
"""兑换记录精确匹配套装名,过滤无关商品。"""
|
||||
got = extract_high_value_goods(
|
||||
[GOODS_AVAILABLE],
|
||||
[RABBIT],
|
||||
[{"commodityName": "破片手榴弹-兔兔白日梦"}, {"commodityName": "别的"}],
|
||||
[{"commodityName": "套装-兔兔白日梦"}, {"commodityName": "别的商品"}],
|
||||
)
|
||||
assert got[0]["owned"] is True
|
||||
assert len(got[0]["obtained_names"]) == 1
|
||||
@@ -1966,3 +1979,40 @@ class TestHighValueAccountFilters:
|
||||
assert hv["unqueried"] == 1 # acc_unqueried
|
||||
assert hv["high_points"] == 2 # 500, 800
|
||||
assert hv["high_value_broken"] == 1 # acc_broken
|
||||
|
||||
def test_helmet_and_backpack_never_match_over_suit(self):
|
||||
"""当商品列表中头盔排在前面时,必须精准匹配到套装-兔兔白日梦,严禁命中头盔。"""
|
||||
items = [
|
||||
{"commodityName": "兔兔白日梦头盔", "commodityId": "helmet_1", "status": 0},
|
||||
{"commodityName": "兔兔白日梦背包", "commodityId": "bag_1", "status": 0},
|
||||
{"commodityName": "套装-兔兔白日梦", "commodityId": "suit_1", "status": 1},
|
||||
]
|
||||
got = extract_high_value_goods(items, ["套装-兔兔白日梦"], [])
|
||||
assert len(got) == 1
|
||||
assert got[0]["name"] == "套装-兔兔白日梦"
|
||||
assert got[0]["commodity_id"] == "suit_1"
|
||||
assert got[0]["owned"] is True
|
||||
assert got[0]["reason"] == "官方商城显示已达兑换上限"
|
||||
|
||||
def test_null_high_value_owned_not_treated_as_both_available(self):
|
||||
"""high_value_owned 为 None 的账号(如被重置过),绝不能误判为双套均可兑换。"""
|
||||
from datetime import datetime, UTC
|
||||
from web.backend.models import Account
|
||||
from web.backend.routers.accounts import accounts_summary
|
||||
|
||||
acc = Account(
|
||||
username="acc_reset_null",
|
||||
password="p",
|
||||
email="e@test.com",
|
||||
email_password="ep",
|
||||
high_value_owned=None,
|
||||
overview_queried_at=datetime.now(UTC), # 虽然时间不为空,但字段是 None
|
||||
)
|
||||
self.session.add(acc)
|
||||
self.session.commit()
|
||||
|
||||
res = accounts_summary(db=self.session, current=self.admin)
|
||||
hv = res.get("high_value", {})
|
||||
# 绝不计入 both_available,应计入 unqueried
|
||||
assert hv["both_available"] == 2
|
||||
assert hv["unqueried"] == 2
|
||||
|
||||
@@ -62,6 +62,7 @@ from ..services.account_value import (
|
||||
SUIT_BAIRIMENG,
|
||||
SUIT_MENGTU,
|
||||
is_high_value_expr,
|
||||
is_suit_queried_expr,
|
||||
suit_owned_expr,
|
||||
suit_sort_rank_expr,
|
||||
)
|
||||
@@ -159,22 +160,23 @@ def _filter_accounts_query(
|
||||
# 高价值套装筛选
|
||||
hv = _query_str(high_value_filter)
|
||||
if hv:
|
||||
suit_queried = is_suit_queried_expr()
|
||||
bairimeng_expr = suit_owned_expr(Account.high_value_owned, SUIT_BAIRIMENG)
|
||||
mengtu_expr = suit_owned_expr(Account.high_value_owned, SUIT_MENGTU)
|
||||
if hv == "both_available":
|
||||
query = query.filter(
|
||||
Account.overview_queried_at.isnot(None),
|
||||
suit_queried,
|
||||
~bairimeng_expr,
|
||||
~mengtu_expr,
|
||||
)
|
||||
elif hv == "can_bairimeng":
|
||||
query = query.filter(
|
||||
Account.overview_queried_at.isnot(None),
|
||||
suit_queried,
|
||||
~bairimeng_expr,
|
||||
)
|
||||
elif hv == "can_mengtu":
|
||||
query = query.filter(
|
||||
Account.overview_queried_at.isnot(None),
|
||||
suit_queried,
|
||||
~mengtu_expr,
|
||||
)
|
||||
elif hv == "owned_bairimeng":
|
||||
@@ -576,7 +578,7 @@ def list_accounts(
|
||||
# 一键查询得到的「已兑换过的高价值套装」(不是整份快照 ——
|
||||
# 列表带不起 1~2KB/账号 的 JSON)。兑换条数不在这里:
|
||||
# 上面的 exchange_count 已经是**官方事件**聚合的结果。
|
||||
high_value_owned=acc.high_value_owned or [],
|
||||
high_value_owned=acc.high_value_owned,
|
||||
# 「有没有查过」靠它判断 —— 否则"查过但没兑换"和"没查过"都是空,分不开
|
||||
overview_queried_at=acc.overview_queried_at,
|
||||
)
|
||||
@@ -640,32 +642,16 @@ def accounts_summary(
|
||||
Account.ck_check_status.in_(["invalid", "error"]),
|
||||
)
|
||||
|
||||
suit_queried = is_suit_queried_expr()
|
||||
hv_row = visible.with_entities(
|
||||
func.count(
|
||||
func.nullif(
|
||||
Account.overview_queried_at.isnot(None)
|
||||
& ~bairimeng_expr
|
||||
& ~mengtu_expr,
|
||||
False,
|
||||
)
|
||||
),
|
||||
func.count(
|
||||
func.nullif(
|
||||
Account.overview_queried_at.isnot(None) & ~bairimeng_expr,
|
||||
False,
|
||||
)
|
||||
),
|
||||
func.count(
|
||||
func.nullif(
|
||||
Account.overview_queried_at.isnot(None) & ~mengtu_expr,
|
||||
False,
|
||||
)
|
||||
),
|
||||
func.count(func.nullif(Account.overview_queried_at.is_(None), False)),
|
||||
func.count(func.nullif(Account.points >= 300, False)),
|
||||
func.count(func.nullif(Account.gold_balance > 0, False)),
|
||||
func.count(func.nullif(Account.elite_opened.is_(True), False)),
|
||||
func.count(func.nullif(high_value_broken_expr, False)),
|
||||
func.count(case((and_(suit_queried, ~bairimeng_expr, ~mengtu_expr), 1))),
|
||||
func.count(case((and_(suit_queried, ~bairimeng_expr), 1))),
|
||||
func.count(case((and_(suit_queried, ~mengtu_expr), 1))),
|
||||
func.count(case((~suit_queried, 1))),
|
||||
func.count(case((Account.points >= 300, 1))),
|
||||
func.count(case((Account.gold_balance > 0, 1))),
|
||||
func.count(case((Account.elite_opened.is_(True), 1))),
|
||||
func.count(case((high_value_broken_expr, 1))),
|
||||
).first()
|
||||
high_value_summary = {
|
||||
"both_available": int(hv_row[0] or 0) if hv_row else 0,
|
||||
@@ -1392,7 +1378,7 @@ def account_overview(
|
||||
"points_expire_at": acc.points_expire_at,
|
||||
"overview_snapshot": acc.overview_snapshot,
|
||||
"overview_queried_at": acc.overview_queried_at,
|
||||
"high_value_owned": acc.high_value_owned or [],
|
||||
"high_value_owned": acc.high_value_owned,
|
||||
"elite_opened": acc.elite_opened,
|
||||
"elite_expire_time": acc.elite_expire_time,
|
||||
"esports_expire_time": acc.esports_expire_time,
|
||||
|
||||
@@ -226,7 +226,8 @@ class AccountOut(BaseModel):
|
||||
# 一键查询得到的(账号表里本来没有的)信息,供列表展示。
|
||||
# ⚠️ 必须在这里声明:Pydantic 默认 `extra='ignore'`,
|
||||
# 没声明的字段会被**静默丢掉** —— 路由明明返回了,接口却看不到。
|
||||
high_value_owned: list[str] = Field(default_factory=list)
|
||||
# 为 None 表示未查询;为 [] 表示已查询且两套均未兑换。
|
||||
high_value_owned: list[str] | None = None
|
||||
# 「有没有做过一键查询」靠它判断(列表不返回整份快照)
|
||||
overview_queried_at: datetime | None = None
|
||||
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -211,8 +211,8 @@ export interface AccountOverview {
|
||||
stats: AccountOverviewStats;
|
||||
/** 最近一次「一键查询」的快照(结构与后端 query_account_overview 的 result 一致) */
|
||||
overview_snapshot: import('../pages/douyu-tasks/AccountOverviewPanel').AccountOverviewResult | null;
|
||||
/** 一键查询得到的已兑换高价值套装 */
|
||||
high_value_owned: string[];
|
||||
/** 一键查询得到的已兑换高价值套装,为 null 时表示未查询 */
|
||||
high_value_owned: string[] | null;
|
||||
overview_queried_at: string | null;
|
||||
/** 开通时间来自事件表(上线后才开始记录,更早的不显示) */
|
||||
opened_at: { elite: string | null; esports: string | null };
|
||||
@@ -315,8 +315,8 @@ export interface AccountItem {
|
||||
exchange_count: number;
|
||||
last_exchange_at: string | null;
|
||||
last_exchange_name: string;
|
||||
/** 一键查询得到的「已兑换过的高价值套装」(账号表里没有,来自官方兑换记录) */
|
||||
high_value_owned: string[];
|
||||
/** 一键查询得到的「已兑换过的高价值套装」,为 null 时表示未查询 */
|
||||
high_value_owned: string[] | null;
|
||||
/** 最近一次一键查询时间 —— 用来区分「查过但没兑换」和「还没查过」 */
|
||||
overview_queried_at: string | null;
|
||||
}
|
||||
|
||||
@@ -756,27 +756,24 @@ export default function AccountsPage() {
|
||||
dataIndex: 'high_value_owned',
|
||||
...sortProps('high_value', '两个都可兑换在前,未查询在后'),
|
||||
width: 170,
|
||||
render: (owned: string[] | undefined, record: AccountItem) => {
|
||||
const used = owned ?? [];
|
||||
// 没查过 ≠ 没兑换:必须分开说,否则会把"没数据"当成"高价值"
|
||||
// 「有没有查过」看查询时间:只看 owned 是否为空的话,
|
||||
// 「查过但没兑换」(高价值)和「没查过」(未知)就分不开了。
|
||||
const queried = Boolean(record.overview_queried_at);
|
||||
if (!queried) {
|
||||
render: (owned: string[] | null | undefined, record: AccountItem) => {
|
||||
// 没查过 ≠ 没兑换:
|
||||
// 只有 high_value_owned 明确为数组且有查询时间时,才算有效查过高价值
|
||||
if (owned === null || owned === undefined || !record.overview_queried_at) {
|
||||
return (
|
||||
<Tooltip title="还没做过一键查询,点工具栏「批量一键查询」">
|
||||
<Tooltip title="尚未查询高价值套装准入状态,点工具栏「批量一键查询」">
|
||||
<Text type="secondary">未查询</Text>
|
||||
</Tooltip>
|
||||
);
|
||||
}
|
||||
if (used.length === 0) {
|
||||
if (owned.length === 0) {
|
||||
return <Tag color="green">两个都可兑换</Tag>;
|
||||
}
|
||||
return (
|
||||
<Space size={4} wrap>
|
||||
{used.map((n) => (
|
||||
{owned.map((n) => (
|
||||
<Tag key={n} color="orange">
|
||||
{n.replace(/^套装-/, '')} 已兑换
|
||||
{n.replace(/^套装-/, '')} 已达上限
|
||||
</Tag>
|
||||
))}
|
||||
</Space>
|
||||
|
||||
@@ -145,9 +145,9 @@ const goodsColumns: ColumnsType<HighValueGoods> = [
|
||||
),
|
||||
},
|
||||
{
|
||||
title: '可兑换 / 已兑换',
|
||||
title: '准入状态',
|
||||
key: 'owned',
|
||||
width: 150,
|
||||
width: 160,
|
||||
render: (_, g) => {
|
||||
const state = goodsState(g);
|
||||
return (
|
||||
@@ -158,21 +158,21 @@ const goodsColumns: ColumnsType<HighValueGoods> = [
|
||||
},
|
||||
},
|
||||
{
|
||||
title: '兑换记录里的名字',
|
||||
key: 'obtained',
|
||||
render: (_, g) =>
|
||||
g.obtained_names?.length ? (
|
||||
<Text style={{ fontSize: 12 }}>{g.obtained_names.join('、')}</Text>
|
||||
) : (
|
||||
<Text type="secondary">—</Text>
|
||||
),
|
||||
title: '判定依据',
|
||||
key: 'reason',
|
||||
width: 220,
|
||||
render: (_, g) => (
|
||||
<Text type={g.owned ? 'warning' : 'secondary'} style={{ fontSize: 13 }}>
|
||||
{g.reason || (g.owned ? '官方限制兑换' : '官方商城可正常兑换')}
|
||||
</Text>
|
||||
),
|
||||
},
|
||||
{
|
||||
title: '积分价',
|
||||
dataIndex: 'score',
|
||||
key: 'score',
|
||||
width: 90,
|
||||
render: (v: number | null | undefined) => v ?? '—',
|
||||
render: (v: number | null | undefined) => (v != null ? `${v} 分` : '—'),
|
||||
},
|
||||
{
|
||||
title: '当前库存',
|
||||
@@ -193,7 +193,7 @@ const goodsColumns: ColumnsType<HighValueGoods> = [
|
||||
{
|
||||
title: '活动时间',
|
||||
key: 'window',
|
||||
width: 200,
|
||||
width: 180,
|
||||
render: (_, g) =>
|
||||
g.missing ? '—' : `${formatTime(g.start_time)} ~ ${formatTime(g.end_time)}`,
|
||||
},
|
||||
@@ -280,7 +280,7 @@ export default function AccountOverviewPanel({
|
||||
|
||||
<Card
|
||||
size="small"
|
||||
title="🎯 高价值套装(没兑换过才值钱)" styles={{ body: { padding: 0 } }}>
|
||||
title="🎯 高价值套装准入(核心关注:套装-兔兔白日梦、套装-狂战萌兔)" styles={{ body: { padding: 0 } }}>
|
||||
{goods.length === 0 ? (
|
||||
<div style={{ padding: 12 }}>
|
||||
<Text type="secondary">没有配置关注的高价值套装,或本次没取到</Text>
|
||||
|
||||
Reference in New Issue
Block a user