From 578e259eeab4d1a8ee943fc660d51481ee456751 Mon Sep 17 00:00:00 2001 From: yml2213 Date: Fri, 6 Nov 2026 13:25:45 +0800 Subject: [PATCH] =?UTF-8?q?fix(douyu):=20=E5=8A=A0=E5=9B=BA=E9=AB=98?= =?UTF-8?q?=E4=BB=B7=E5=80=BC=E4=B8=89=E6=80=81=E8=BF=81=E7=A7=BB=E4=B8=8E?= =?UTF-8?q?=E7=8A=B6=E6=80=81=E5=8F=A3=E5=BE=84?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 修复迁移中断重跑、旧快照回填覆盖、降级将 unknown 误写成可兑换,以及迁移前 CK 已失效账号未作废旧结论的问题。统一 CK error 的展示与筛选口径,补齐旧快照迁移和兑换兜底反向用例,并同步高价值结论时间语义。同时纳入 .gitignore 更新。 --- .gitignore | 2 +- docs/设计-账号全生命周期.md | 18 ++- tests/test_account_overview.py | 40 ++++- tests/test_migration_high_value_roundtrip.py | 152 ++++++++++++++++-- ...20261106_0065_account_high_value_status.py | 106 ++++++++---- web/backend/models.py | 2 + web/backend/services/account_value.py | 5 +- web/backend/services/douyu_runner_overview.py | 8 +- web/backend/services/high_value_service.py | 11 +- web/frontend/src/pages/AccountDetailPage.tsx | 2 +- .../accounts/components/AccountColumns.tsx | 2 +- .../pages/accounts/components/FilterPanel.tsx | 4 +- web/frontend/src/pages/accounts/types.ts | 2 +- 13 files changed, 286 insertions(+), 68 deletions(-) diff --git a/.gitignore b/.gitignore index cab2dd7..4017d4a 100644 --- a/.gitignore +++ b/.gitignore @@ -4,7 +4,7 @@ __pycache__/ *.pyc .claude .vscode - +.openidea # 环境变量(含敏感信息,勿提交) .env diff --git a/docs/设计-账号全生命周期.md b/docs/设计-账号全生命周期.md index f4e39e2..e449f59 100644 --- a/docs/设计-账号全生命周期.md +++ b/docs/设计-账号全生命周期.md @@ -90,6 +90,8 @@ CK 本体和健康结果都在账号上: 列表只返回紧凑的三态 `high_value`(`state` + 每套的 `owned`/`available`/`unknown`), 不返回整份快照,避免响应体积膨胀。 +`high_value_checked_at` 是“最近一次形成高价值结论”的时钟,结论本身可以为 +`unknown`;它不表示每次执行都成功读到了可用的官方 `status`。 一键查询本身是只读聚合,不直接修改生命周期状态;官方兑换记录会以 `source=official_sync` 幂等补入事件流。 @@ -118,7 +120,8 @@ CK 本体和健康结果都在账号上: 两套都降为 `unknown`,等下一次真的读到官方 `storehome` 才写回。 作废发生在**数据层**(CK 检测结论落定处),不是展示层:CK 恢复之后展示层 就不再降级了,只在展示层降级会让陈旧的「可换」重新变绿。 -`error`(查不到)不作废 —— 查不到不等于账号出问题了。 +`error`(查不到)既不作废数据,也不在展示层降级 —— 查不到不等于账号状态变了; +它继续显示上一次有效结论,并计入「高价值但 CK 异常」筛选。 ### 高价值的三类「查不到」必须分开 @@ -127,13 +130,18 @@ CK 本体和健康结果都在账号上: | 筛选 | 判据 | 含义 | | --- | --- | --- | | `unqueried` | `overview_queried_at IS NULL` | 从没跑过一键查询(**旧口径**,保留不变) | -| `high_value_never` | `high_value_checked_at IS NULL` | 从没成功读到官方商城(商城接口失败 / 没配 `manualID`) | -| `high_value_unknown` | `checked_at` 非空,但已过期或状态是 `unknown` | 查过,但当前结论不可信 | +| `high_value_never` | `high_value_checked_at IS NULL` | 从没形成过高价值结论(商城接口失败 / 没配 `manualID`) | +| `high_value_unknown` | `checked_at` 非空,但已过期或状态是 `unknown` | 形成过结论,但当前结论未知或不可信 | -`high_value_never` 必须单独成档:账号可能查过其它资料、只是商城接口对它 -稳定失败。它既不满足 `unqueried`(资料查过),也不满足 `high_value_unknown` +`high_value_never` 必须单独成档:账号可能查过其它资料、只是从未形成高价值结论。 +它既不满足 `unqueried`(资料查过),也不满足 `high_value_unknown` (那条要求 `checked_at` 非空)—— 不单独筛的话,这批账号在界面上**无处可寻**。 +0065 迁移会把旧 `overview_snapshot` 回填成新三态。旧快照没保存原始 +`status` 时状态保守写 `unknown`,但保留旧查询时间作为结论时钟,让这批账号 +进入 6 小时慢速重试;迁移前已经 `CK invalid` 的账号也一律回填为 `unknown`。 +没有旧快照的账号则保持 `checked_at=NULL`。 + ## 自动化策略 - CK 巡检按账号选行,不按登录历史选行。 diff --git a/tests/test_account_overview.py b/tests/test_account_overview.py index 98f5202..eb2c1ed 100644 --- a/tests/test_account_overview.py +++ b/tests/test_account_overview.py @@ -343,8 +343,8 @@ class TestOverviewAggregation: def test_high_value_goods_are_flagged(self): r = self._run().marked["result"] goods = {g["name"]: g for g in r["high_value_goods"]} - assert goods["套装-兔兔白日梦"]["owned"] is True # 兑换记录里有 - assert goods["套装-狂战萌兔"]["owned"] is False # 记录里没有 + assert goods["套装-兔兔白日梦"]["owned"] is True # 官方 status=1 + assert goods["套装-狂战萌兔"]["owned"] is False # 官方 status=0 assert "兔兔白日梦" in r["high_value_goods"][0]["name"] def test_single_item_failure_does_not_fail_the_batch(self): @@ -1920,7 +1920,7 @@ class TestHighValueAccountFilters: assert "acc_no_mall" not in names def test_high_value_never_filter(self): - """「从未读到官方商城状态」这一档必须**单独可筛**。 + """「从未形成高价值结论」这一档必须**单独可筛**。 之前这批账号(商城接口失败 / 没配 manualID)在界面上无处可寻: 既不满足 ``unqueried``(资料查过),也不满足 ``high_value_unknown`` @@ -2222,6 +2222,37 @@ class TestHighValueTriState: assert payload["state"] == "stale" assert payload["stale"] is True + def test_ck_error_keeps_cached_conclusion_in_sync_with_filters(self): + """CK 检测 error 只表示这次没查成,不改变账号结论或筛选口径。""" + from web.backend.services.account_value import ( + high_value_unknown_expr, + suit_available_expr, + ) + from web.backend.services.high_value_service import high_value_payload + + acc = self._account( + high_value_bairimeng_status="available", + high_value_mengtu_status="available", + high_value_checked_at=datetime.now(UTC), + ck_check_status="error", + ) + + payload = high_value_payload(acc) + assert payload["state"] == "complete" + assert payload["stale"] is False + assert ( + self.session.query(Account) + .filter(Account.id == acc.id, suit_available_expr("bairimeng")) + .count() + == 1 + ) + assert ( + self.session.query(Account) + .filter(Account.id == acc.id, high_value_unknown_expr()) + .count() + == 0 + ) + def test_stale_account_is_not_counted_as_available(self): """过期的账号不能进「双套均可兑换」筛选。""" from web.backend.routers.accounts import accounts_summary @@ -2397,9 +2428,10 @@ class TestHighValueAfterExchange: self.account, manual_id="m", rid="r", - commodity_name="套装-狂兔萌兔", + commodity_name="套装-狂战萌兔", ) assert self.account.high_value_bairimeng_status == "unknown" + assert self.account.high_value_mengtu_status == "owned" assert any(level == "warning" for level, _ in runner.logs) def test_unrelated_goods_name_is_ignored(self): diff --git a/tests/test_migration_high_value_roundtrip.py b/tests/test_migration_high_value_roundtrip.py index 2fb28e9..04a8b53 100644 --- a/tests/test_migration_high_value_roundtrip.py +++ b/tests/test_migration_high_value_roundtrip.py @@ -1,6 +1,8 @@ """0065 高价值三态迁移的 upgrade/downgrade 往返验证(真实 MySQL)。""" +import json import secrets +from datetime import UTC, datetime from urllib.parse import quote_plus from alembic import command @@ -31,9 +33,67 @@ def test_0065_upgrade_downgrade_roundtrip(): cfg = Config("alembic.ini") cfg.set_main_option("script_location", "web/backend/migrations") cfg.set_main_option("sqlalchemy.url", mig_url) + + # 先在旧版本落真实数据,验证快照回填,而不是只在空库上验 DDL。 + command.upgrade(cfg, "20261021_0064") + engine = create_engine(mig_url) + queried_at = datetime(2026, 11, 6, 12, 0, 0, tzinfo=UTC).replace(tzinfo=None) + snapshots = { + "snapshot-status": ( + { + "queried_at": "2026-11-06T12:00:00+00:00", + "high_value_goods": [ + {"name": "套装-兔兔白日梦", "status": 1}, + {"name": "套装-狂战萌兔", "status": 0}, + ], + }, + "valid", + ), + "snapshot-no-status": ( + { + "queried_at": "2026-11-05T10:00:00+00:00", + "high_value_goods": [ + {"name": "套装-兔兔白日梦", "owned": True}, + ], + }, + "valid", + ), + "snapshot-invalid": ( + { + "queried_at": "2026-11-04T09:00:00+00:00", + "high_value_goods": [ + {"name": "套装-兔兔白日梦", "status": 1}, + {"name": "套装-狂战萌兔", "status": 0}, + ], + }, + "invalid", + ), + "snapshot-missing": (None, None), + } + with engine.begin() as conn: + for username, (snapshot, ck_status) in snapshots.items(): + conn.execute( + text( + "INSERT INTO accounts (username, password, email, email_password," + " cookie, ck_check_status, overview_snapshot, overview_queried_at," + " high_value_owned)" + " VALUES (:u, 'p', 'e@x.com', 'p', '', :ck, :snapshot, :queried," + " :owned)" + ), + { + "u": username, + "ck": ck_status or "", + "snapshot": json.dumps(snapshot) + if snapshot is not None + else None, + "queried": queried_at if snapshot is not None else None, + "owned": json.dumps([]) if snapshot is not None else None, + }, + ) + command.upgrade(cfg, "head") - insp = inspect(create_engine(mig_url)) + insp = inspect(engine) cols = {c["name"] for c in insp.get_columns("accounts")} assert "high_value_bairimeng_status" in cols assert "high_value_mengtu_status" in cols @@ -43,21 +103,78 @@ def test_0065_upgrade_downgrade_roundtrip(): i["name"] for i in insp.get_indexes("accounts") } - # 塞两行数据:owned/owned 与 owned/unknown,验证降级数组干净 - engine = create_engine(mig_url) + with engine.connect() as conn: + rows = conn.execute( + text( + "SELECT username, high_value_bairimeng_status," + " high_value_mengtu_status, high_value_checked_at" + " FROM accounts ORDER BY username" + ) + ).fetchall() + by_name = {row[0]: row for row in rows} + assert by_name["snapshot-status"][1:3] == ("owned", "available") + assert by_name["snapshot-status"][3] == queried_at + assert by_name["snapshot-no-status"][1:3] == ("unknown", "unknown") + assert by_name["snapshot-no-status"][3] == datetime( + 2026, 11, 5, 10, 0, 0, tzinfo=UTC + ).replace(tzinfo=None) + assert by_name["snapshot-invalid"][1:3] == ("unknown", "unknown") + assert by_name["snapshot-invalid"][3] == datetime( + 2026, 11, 4, 9, 0, 0, tzinfo=UTC + ).replace(tzinfo=None) + assert by_name["snapshot-missing"][1:3] == (None, None) + assert by_name["snapshot-missing"][3] is None + + # 模拟“回填和索引已完成,但删旧列前进程中断”:版本仍停在 0064, + # 旧列还在。重跑必须能成功,且不能覆盖已经形成的新结论。 with engine.begin() as conn: - for i, (b, m) in enumerate([("owned", "owned"), ("owned", "unknown")]): + conn.execute( + text("ALTER TABLE accounts ADD COLUMN high_value_owned JSON NULL") + ) + conn.execute( + text( + "UPDATE accounts SET high_value_bairimeng_status='available'," + " high_value_mengtu_status='unknown', high_value_checked_at=:now" + " WHERE username='snapshot-status'" + ), + { + "now": datetime(2026, 11, 6, 13, 0, 0, tzinfo=UTC).replace( + tzinfo=None + ) + }, + ) + conn.execute(text("UPDATE alembic_version SET version_num='20261021_0064'")) + command.upgrade(cfg, "head") + with engine.connect() as conn: + resumed = conn.execute( + text( + "SELECT high_value_bairimeng_status, high_value_mengtu_status" + " FROM accounts WHERE username='snapshot-status'" + ) + ).one() + assert resumed == ("available", "unknown") + + # 降级无法表达 unknown;必须留 NULL,不能生成 [] 让旧版本误判为可兑换。 + with engine.begin() as conn: + for index, (b, m) in enumerate( + [ + ("available", "available"), + ("owned", "available"), + ("owned", "owned"), + ] + ): conn.execute( text( "INSERT INTO accounts (username, password, email, email_password," - " cookie, high_value_bairimeng_status, high_value_mengtu_status)" - " VALUES (:u, 'p', 'e@x.com', 'p', '', :b, :m)" + " cookie, overview_queried_at, high_value_bairimeng_status," + " high_value_mengtu_status)" + " VALUES (:u, 'p', 'e@x.com', 'p', '', :q, :b, :m)" ), - {"u": f"rt-{i}", "b": b, "m": m}, + {"u": f"downgrade-{index}", "q": queried_at, "b": b, "m": m}, ) command.downgrade(cfg, "20261021_0064") - insp = inspect(create_engine(mig_url)) + insp = inspect(engine) cols = {c["name"] for c in insp.get_columns("accounts")} assert "high_value_owned" in cols assert "high_value_bairimeng_status" not in cols @@ -65,19 +182,26 @@ def test_0065_upgrade_downgrade_roundtrip(): i["name"] for i in insp.get_indexes("accounts") } - with engine.begin() as conn: + with engine.connect() as conn: rows = conn.execute( text( "SELECT username, high_value_owned FROM accounts ORDER BY username" ) ).fetchall() - # JSON 列读出来是字符串;重点是里面**不能有 null**(旧实现会写出 [null,...]) - assert rows[0][1] == '["套装-兔兔白日梦", "套装-狂战萌兔"]', rows[0] - assert rows[1][1] == '["套装-兔兔白日梦"]', rows[1] - assert "null" not in rows[0][1] and "null" not in rows[1][1] + old_values = { + name: json.loads(value) if isinstance(value, str) else value + for name, value in rows + } + assert old_values["snapshot-status"] is None + assert old_values["snapshot-no-status"] is None + assert old_values["snapshot-invalid"] is None + assert old_values["snapshot-missing"] is None + assert old_values["downgrade-0"] == [] + assert old_values["downgrade-1"] == ["套装-兔兔白日梦"] + assert old_values["downgrade-2"] == ["套装-兔兔白日梦", "套装-狂战萌兔"] command.upgrade(cfg, "head") - insp = inspect(create_engine(mig_url)) + insp = inspect(engine) cols = {c["name"] for c in insp.get_columns("accounts")} assert "high_value_owned" not in cols assert "high_value_checked_at" in cols diff --git a/web/backend/migrations/versions/20261106_0065_account_high_value_status.py b/web/backend/migrations/versions/20261106_0065_account_high_value_status.py index 9fdc311..28fe26e 100644 --- a/web/backend/migrations/versions/20261106_0065_account_high_value_status.py +++ b/web/backend/migrations/versions/20261106_0065_account_high_value_status.py @@ -4,6 +4,10 @@ 本迁移增加两套独立状态和检查时间,并从旧快照保守回填: 只有快照里明确保留了官方 ``status=0/1`` 才恢复可兑换/已达上限; 更早的快照没有保存原始 status,统一回填为 unknown,等待一次新查询。 + +``high_value_checked_at`` 表示“最近一次形成高价值结论的时间”,结论本身可以是 +unknown。旧快照虽然没保存原始 status,但确实形成过“当前无法判断”的结论, +因此保留旧查询时间,让这些账号按 unknown 的慢速策略重新确认。 """ from __future__ import annotations @@ -49,6 +53,26 @@ def _column_exists(name: str) -> bool: return bool(count) +def _index_exists(name: str) -> bool: + count = ( + op.get_bind() + .execute( + sa.text( + """ + SELECT COUNT(*) + FROM information_schema.statistics + WHERE table_schema = DATABASE() + AND table_name = 'accounts' + AND index_name = :name + """ + ), + {"name": name}, + ) + .scalar() + ) + return bool(count) + + def _json_dict(value: Any) -> dict[str, Any]: if isinstance(value, dict): return value @@ -83,14 +107,19 @@ def _as_datetime(value: Any) -> datetime | None: def _backfill_from_snapshot() -> None: + """只回填尚未形成新结论的旧账号,保证迁移中断后可安全重跑。""" conn = op.get_bind() rows = conn.execute( sa.text( - "SELECT id, overview_snapshot, overview_queried_at " - "FROM accounts WHERE overview_snapshot IS NOT NULL" + "SELECT id, overview_snapshot, overview_queried_at, ck_check_status " + "FROM accounts " + "WHERE overview_snapshot IS NOT NULL " + "AND high_value_bairimeng_status IS NULL " + "AND high_value_mengtu_status IS NULL " + "AND high_value_checked_at IS NULL" ) ).fetchall() - for account_id, raw_snapshot, overview_queried_at in rows: + for account_id, raw_snapshot, overview_queried_at, ck_status in rows: snapshot = _json_dict(raw_snapshot) goods = snapshot.get("high_value_goods") states = {key: STATUS_UNKNOWN for key in NAMES} @@ -102,6 +131,11 @@ def _backfill_from_snapshot() -> None: if core in name: states[key] = _snapshot_state(item) break + # 迁移前已经判定 CK 失效的账号,缓存结论同样不可继续信任。 + # 否则部署后列表会显示待确认,但筛选/统计仍把它们算作可兑换, + # 直到下一次巡检才由 on_ck_verdict 补上作废。 + if str(ck_status or "") == "invalid": + states = {key: STATUS_UNKNOWN for key in NAMES} checked_at = _as_datetime(snapshot.get("queried_at") or overview_queried_at) conn.execute( sa.text( @@ -123,6 +157,7 @@ def _backfill_from_snapshot() -> None: def upgrade() -> None: + old_column_exists = _column_exists("high_value_owned") for name in ( "high_value_bairimeng_status", "high_value_mengtu_status", @@ -137,7 +172,7 @@ def upgrade() -> None: name, sa.DateTime(), nullable=True, - comment="最近一次成功读取官方 storehome 准入状态的时间", + comment="最近一次形成高价值结论的时间;结论可以是 unknown", ), ) else: @@ -150,13 +185,16 @@ def upgrade() -> None: comment="owned / available / unknown;只认官方 storehome.status", ), ) + # 回填只处理新字段全空的账号,所以“加完列但回填前中断”和 + # “回填成功但后续 DDL 失败”两种 MySQL 非事务迁移都可以直接重跑。 _backfill_from_snapshot() - op.create_index( - "ix_accounts_high_value_checked_at", - "accounts", - ["high_value_checked_at"], - ) - if _column_exists("high_value_owned"): + if not _index_exists("ix_accounts_high_value_checked_at"): + op.create_index( + "ix_accounts_high_value_checked_at", + "accounts", + ["high_value_checked_at"], + ) + if old_column_exists: op.drop_column("accounts", "high_value_owned") @@ -170,24 +208,36 @@ def downgrade() -> None: sa.text( """ UPDATE accounts - SET high_value_owned = COALESCE( - ( - SELECT JSON_ARRAYAGG(t.name) - FROM ( - SELECT CASE WHEN high_value_bairimeng_status = 'owned' - THEN '套装-兔兔白日梦' END AS name - UNION ALL - SELECT CASE WHEN high_value_mengtu_status = 'owned' - THEN '套装-狂战萌兔' END AS name - ) AS t - WHERE t.name IS NOT NULL - ), - JSON_ARRAY() - ) + SET high_value_owned = CASE + WHEN high_value_bairimeng_status IS NULL + OR high_value_bairimeng_status NOT IN ('owned', 'available') + OR high_value_mengtu_status IS NULL + OR high_value_mengtu_status NOT IN ('owned', 'available') + THEN NULL + ELSE COALESCE( + ( + SELECT JSON_ARRAYAGG(t.name) + FROM ( + SELECT CASE WHEN high_value_bairimeng_status = 'owned' + THEN '套装-兔兔白日梦' END AS name + UNION ALL + SELECT CASE WHEN high_value_mengtu_status = 'owned' + THEN '套装-狂战萌兔' END AS name + ) AS t + WHERE t.name IS NOT NULL + ), + JSON_ARRAY() + ) + END """ ) ) - op.drop_index("ix_accounts_high_value_checked_at", table_name="accounts") - op.drop_column("accounts", "high_value_checked_at") - op.drop_column("accounts", "high_value_mengtu_status") - op.drop_column("accounts", "high_value_bairimeng_status") + if _index_exists("ix_accounts_high_value_checked_at"): + op.drop_index("ix_accounts_high_value_checked_at", table_name="accounts") + for name in ( + "high_value_checked_at", + "high_value_mengtu_status", + "high_value_bairimeng_status", + ): + if _column_exists(name): + op.drop_column("accounts", name) diff --git a/web/backend/models.py b/web/backend/models.py index 6263393..e96768c 100644 --- a/web/backend/models.py +++ b/web/backend/models.py @@ -132,6 +132,8 @@ class Account(Base): high_value_mengtu_status: Mapped[str | None] = mapped_column( String(16), nullable=True ) + # 最近一次形成高价值结论的时间;结论可以是 owned / available / unknown。 + # 这是“结论时钟”,不是“成功获取可用 status 的时钟”。 high_value_checked_at: Mapped[datetime | None] = mapped_column( UTCDateTime, nullable=True ) diff --git a/web/backend/services/account_value.py b/web/backend/services/account_value.py index 316cdf6..e801e0f 100644 --- a/web/backend/services/account_value.py +++ b/web/backend/services/account_value.py @@ -88,7 +88,8 @@ def high_value_needs_refresh_expr( 和「资料是否过期」是**两件事**: - ``overview_queried_at`` 说「上次一键查询是什么时候」; - - ``high_value_checked_at`` 说「上次**成功读到官方商品状态**是什么时候」。 + - ``high_value_checked_at`` 说「上次形成高价值结论是什么时候」; + 结论可以是 ``unknown``。 两者会脱钩 —— 比如一键查询里商品接口单独失败(其它项都拿到了), 资料时间被刷新了,高价值时间却停在原地。再比如 CK 失效那段时间查不动, @@ -103,7 +104,7 @@ def high_value_needs_refresh_expr( 而「可兑换」随时会变; - **结论被作废**(状态是 ``unknown``,比如 CK 失效期间被作废): 按 ``unknown_seconds`` 慢速重试; - - **从来没查到过**(``checked_at IS NULL``):**不算**。那种情况要么 + - **从来没形成过结论**(``checked_at IS NULL``):**不算**。那种情况要么 账号确实没查过资料(本来就该被选中),要么商品接口对这个账号稳定失败 (没配 manualID、账号没有宝典)—— 后者若每轮都重选,同一批账号会 反复撞墙,挤掉真正有新欠账的账号。它由「资料过期」那条兜着就够了。 diff --git a/web/backend/services/douyu_runner_overview.py b/web/backend/services/douyu_runner_overview.py index 07a9fbe..d99eba6 100644 --- a/web/backend/services/douyu_runner_overview.py +++ b/web/backend/services/douyu_runner_overview.py @@ -10,9 +10,9 @@ ## 为什么「是否已拥有」能直接查官方 -`query_limited_goods` 返回的商品项里 `count` / `foreverCount` / `storeStatus` -是**账号维度**的(不是全局库存)—— 已用生产镜像库实测,见 -`extract_high_value_goods` 的文档。 +`storehome` 商品项里的账号级 `status`(0=可兑换、1=已达上限)才是唯一判据。 +`count` / `foreverCount` / `storeStatus` 是全局库存或商店状态,不能拿来判断 +当前账号是否已经兑换。 ## 诚实边界 @@ -355,7 +355,7 @@ class OverviewMixin: goods_query_ok = True except Exception as exc: # noqa: BLE001 errors["limited_goods"] = _err(exc) - # 商品列表带有 status 准入状态,结合兑换记录在后文判定高价值资产 + # 商品列表带有账号级 status;兑换记录只作历史展示,不参与准入判定。 pending_goods = goods_items # ---- 角色绑定状态(三个作用域都查)---- diff --git a/web/backend/services/high_value_service.py b/web/backend/services/high_value_service.py index 4b7768e..0abdd5b 100644 --- a/web/backend/services/high_value_service.py +++ b/web/backend/services/high_value_service.py @@ -221,8 +221,8 @@ def invalidate_high_value(account: Account) -> None: 那条陈旧的「可换」会**重新显示成绿色**,运营就会拿着一份失效结论去兑换。 所以作废是**单向**的:写 ``unknown``,等下一次真的读到官方 ``storehome`` - 再写回真实的 ``available``/``owned``。``checked_at`` 保留(那确实是 - 「上次成功读到官方状态」的时间),但结论本身已经不算数。 + 再写回真实的 ``available``/``owned``。``checked_at`` 保留它作为 + 「上次形成结论」的时钟,但结论本身已经不算数。 """ for key in HIGH_VALUE_KEYS: setattr(account, f"high_value_{key}_status", HIGH_VALUE_STATUS_UNKNOWN) @@ -236,9 +236,10 @@ def high_value_payload( checked_at = account.high_value_checked_at if checked_at is not None and checked_at.tzinfo is None: checked_at = checked_at.replace(tzinfo=UTC) - # CK 失效 = 查不动了。此时缓存里的「可兑换」已经无法确认当前是否还成立, - # 只能当作待确认 —— 否则运营会拿一个过期结论去兑换。 - ck_broken = (account.ck_check_status or "") in {"invalid", "error"} + # 只有明确判定 `invalid` 才降级展示。`error` 表示这次检测没查成, + # 不等于账号状态变了;若也降级,列表会显示已过期,但筛选/统计仍把 + # 同一账号算作可兑换,造成同一屏两套结论。 + ck_broken = (account.ck_check_status or "") == "invalid" stale = bool( ck_broken or ( diff --git a/web/frontend/src/pages/AccountDetailPage.tsx b/web/frontend/src/pages/AccountDetailPage.tsx index 01601e2..4873572 100644 --- a/web/frontend/src/pages/AccountDetailPage.tsx +++ b/web/frontend/src/pages/AccountDetailPage.tsx @@ -72,7 +72,7 @@ function highValueShortTag(label: string, status: HighValueAssetState) { function CurrentHighValue({ value }: { value: HighValueStatus }) { if (value.state === 'never') { - return 尚未成功获取官方商城准入状态; + return 尚未形成高价值准入结论; } if (value.state === 'stale') { return ( diff --git a/web/frontend/src/pages/accounts/components/AccountColumns.tsx b/web/frontend/src/pages/accounts/components/AccountColumns.tsx index d1ba7e5..0f8479f 100644 --- a/web/frontend/src/pages/accounts/components/AccountColumns.tsx +++ b/web/frontend/src/pages/accounts/components/AccountColumns.tsx @@ -55,7 +55,7 @@ function highValueAssetTag( function renderHighValueStatus(highValue: HighValueStatus | undefined) { if (!highValue || highValue.state === 'never') { return ( - + 未查询 ); diff --git a/web/frontend/src/pages/accounts/components/FilterPanel.tsx b/web/frontend/src/pages/accounts/components/FilterPanel.tsx index 678c84d..6a8b0f9 100644 --- a/web/frontend/src/pages/accounts/components/FilterPanel.tsx +++ b/web/frontend/src/pages/accounts/components/FilterPanel.tsx @@ -245,11 +245,11 @@ export function FilterPanel({ }, { key: 'high_value_never', - label: '未读到商城', + label: '未形成结论', icon: '❔', count: summary.high_value?.high_value_never, color: '#8c8c8c', - tooltip: '从未成功读到官方商城准入状态:商城接口失败,或未配置精英宝典 manualID', + tooltip: '从未形成过高价值结论:商城接口失败、未配置精英宝典 manualID,或账号尚未查询', active: highValueFilter === 'high_value_never' && minPoints === undefined && diff --git a/web/frontend/src/pages/accounts/types.ts b/web/frontend/src/pages/accounts/types.ts index d102398..d220498 100644 --- a/web/frontend/src/pages/accounts/types.ts +++ b/web/frontend/src/pages/accounts/types.ts @@ -83,7 +83,7 @@ export const HIGH_VALUE_OPTIONS = [ { value: 'both_owned', label: '✓ 两套均已拥有' }, { value: 'high_value_broken', label: '⚠️ 高价值但CK失效' }, { value: 'high_value_unknown', label: '❓ 高价值待确认/已过期' }, - { value: 'high_value_never', label: '❓ 从未读到官方商城状态' }, + { value: 'high_value_never', label: '❓ 从未形成高价值结论' }, { value: 'unqueried', label: '❓ 未查过资料' }, ];