From 44b762ea8c2919b5e5ffdf616f1a67c7a4bb6b05 Mon Sep 17 00:00:00 2001 From: yml2213 Date: Wed, 7 Oct 2026 15:27:30 +0800 Subject: [PATCH] =?UTF-8?q?fix(overview):=20=E9=94=81=E5=AE=9A=E6=A0=B8?= =?UTF-8?q?=E5=BF=83=E9=AB=98=E4=BB=B7=E5=80=BC=E5=A5=97=E8=A3=85=E5=B9=B6?= =?UTF-8?q?=E4=BF=AE=E5=A4=8D=E6=9C=AA=E6=9F=A5=E8=AF=A2=E7=A9=BA=E5=80=BC?= =?UTF-8?q?=E8=AF=AF=E5=88=A4?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 高价值套装精确匹配「套装-兔兔白日梦」与「套装-狂战萌兔」,排除头盔/背包/手榴弹散件干扰 - 增加 is_suit_queried_expr,未查询或 NULL 账号严禁误判为双套均可兑换 - 修复前后端序列化空值保持,完善列表与详情页准入状态和判定依据展示 - 新增头盔排除与 NULL 统计防错回归单测,全量通过 689 个用例 --- docs/事故复盘-CK误删与恢复.md | 10 +-- docs/设计-登录任务与CK解耦.md | 26 ++++--- docs/设计-账号详情页与事件历史.md | 18 +++-- tests/test_account_overview.py | 60 ++++++++++++++-- web/backend/routers/accounts.py | 46 +++++-------- web/backend/schemas.py | 3 +- web/backend/services/account_value.py | 30 +++++--- web/backend/services/douyu_runner_overview.py | 68 +++++++++++++------ web/frontend/src/api/types.ts | 8 +-- web/frontend/src/pages/AccountsPage.tsx | 19 +++--- .../douyu-tasks/AccountOverviewPanel.tsx | 26 +++---- 11 files changed, 202 insertions(+), 112 deletions(-) diff --git a/docs/事故复盘-CK误删与恢复.md b/docs/事故复盘-CK误删与恢复.md index a3a9d40..cb0a878 100644 --- a/docs/事故复盘-CK误删与恢复.md +++ b/docs/事故复盘-CK误删与恢复.md @@ -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 行一起被选中删除。**这不是单纯的边界疏漏,是把两个 diff --git a/docs/设计-登录任务与CK解耦.md b/docs/设计-登录任务与CK解耦.md index a117d3d..ff5702c 100644 --- a/docs/设计-登录任务与CK解耦.md +++ b/docs/设计-登录任务与CK解耦.md @@ -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},中止迁移") ``` diff --git a/docs/设计-账号详情页与事件历史.md b/docs/设计-账号详情页与事件历史.md index f51d823..037f6e9 100644 --- a/docs/设计-账号详情页与事件历史.md +++ b/docs/设计-账号详情页与事件历史.md @@ -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) ``` diff --git a/tests/test_account_overview.py b/tests/test_account_overview.py index e7ea11c..54594d1 100644 --- a/tests/test_account_overview.py +++ b/tests/test_account_overview.py @@ -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 diff --git a/web/backend/routers/accounts.py b/web/backend/routers/accounts.py index 9b862f7..2ef3c62 100644 --- a/web/backend/routers/accounts.py +++ b/web/backend/routers/accounts.py @@ -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, diff --git a/web/backend/schemas.py b/web/backend/schemas.py index 10af0ce..a17f78a 100644 --- a/web/backend/schemas.py +++ b/web/backend/schemas.py @@ -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 diff --git a/web/backend/services/account_value.py b/web/backend/services/account_value.py index 53ef4ce..8455256 100644 --- a/web/backend/services/account_value.py +++ b/web/backend/services/account_value.py @@ -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), diff --git a/web/backend/services/douyu_runner_overview.py b/web/backend/services/douyu_runner_overview.py index 0b78287..678a942 100644 --- a/web/backend/services/douyu_runner_overview.py +++ b/web/backend/services/douyu_runner_overview.py @@ -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, diff --git a/web/frontend/src/api/types.ts b/web/frontend/src/api/types.ts index 2d2ef6d..c3d0405 100644 --- a/web/frontend/src/api/types.ts +++ b/web/frontend/src/api/types.ts @@ -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; } diff --git a/web/frontend/src/pages/AccountsPage.tsx b/web/frontend/src/pages/AccountsPage.tsx index cd2ad71..9957375 100644 --- a/web/frontend/src/pages/AccountsPage.tsx +++ b/web/frontend/src/pages/AccountsPage.tsx @@ -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 ( - + 未查询 ); } - if (used.length === 0) { + if (owned.length === 0) { return 两个都可兑换; } return ( - {used.map((n) => ( + {owned.map((n) => ( - {n.replace(/^套装-/, '')} 已兑换 + {n.replace(/^套装-/, '')} 已达上限 ))} diff --git a/web/frontend/src/pages/douyu-tasks/AccountOverviewPanel.tsx b/web/frontend/src/pages/douyu-tasks/AccountOverviewPanel.tsx index 8038bc6..0540cab 100644 --- a/web/frontend/src/pages/douyu-tasks/AccountOverviewPanel.tsx +++ b/web/frontend/src/pages/douyu-tasks/AccountOverviewPanel.tsx @@ -145,9 +145,9 @@ const goodsColumns: ColumnsType = [ ), }, { - title: '可兑换 / 已兑换', + title: '准入状态', key: 'owned', - width: 150, + width: 160, render: (_, g) => { const state = goodsState(g); return ( @@ -158,21 +158,21 @@ const goodsColumns: ColumnsType = [ }, }, { - title: '兑换记录里的名字', - key: 'obtained', - render: (_, g) => - g.obtained_names?.length ? ( - {g.obtained_names.join('、')} - ) : ( - — - ), + title: '判定依据', + key: 'reason', + width: 220, + render: (_, g) => ( + + {g.reason || (g.owned ? '官方限制兑换' : '官方商城可正常兑换')} + + ), }, { 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 = [ { 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({ + title="🎯 高价值套装准入(核心关注:套装-兔兔白日梦、套装-狂战萌兔)" styles={{ body: { padding: 0 } }}> {goods.length === 0 ? (
没有配置关注的高价值套装,或本次没取到