diff --git a/docs/设计-账号全生命周期.md b/docs/设计-账号全生命周期.md index 43fe01e..f4e39e2 100644 --- a/docs/设计-账号全生命周期.md +++ b/docs/设计-账号全生命周期.md @@ -86,12 +86,54 @@ CK 本体和健康结果都在账号上: - `points`、`gold_balance`、`exchange_balance` - `elite_opened`、`esports_opened`、各作用域绑定状态 - `overview_snapshot`、`overview_queried_at` -- `high_value_owned` +- `high_value_bairimeng_status`、`high_value_mengtu_status`、`high_value_checked_at` -列表只返回 `high_value_owned` 等小字段,不返回整份快照,避免响应体积膨胀。 +列表只返回紧凑的三态 `high_value`(`state` + 每套的 `owned`/`available`/`unknown`), +不返回整份快照,避免响应体积膨胀。 一键查询本身是只读聚合,不直接修改生命周期状态;官方兑换记录会以 `source=official_sync` 幂等补入事件流。 +## 高价值套装的准入判定 + +**唯一裁判是官方精英宝典 `storehome` 商品列表里账号级的 `status`**: + +- `status=0` → 该账号当前可兑换(`available`); +- `status=1` → 该账号已达兑换上限(`owned`); +- 商品缺失、列表为空、接口异常、部分失败 → `unknown`。 + +兑换记录和积分记录会被官方清理或暂时查不到,**只作历史展示**,不参与是否拥有的判定。 +`unknown` 与超过 7 天的结果(`stale`)在列表、筛选和统计里都显示为「待确认」, +绝不显示成「两个都可兑换」。 + +本系统兑换成功后立即回查一次 `storehome`;回查失败则以「本次兑换已成功」保守收口 +为 `owned`,并记 warning,不带崩已成功的兑换。 + +商城商品列表用**精英宝典**的 `manualID` 拉取,与电竞手册配置无关 —— +两套活动互不相干,商品列表绝不能寄生在电竞分支里(否则没配电竞手册的 +配置就永远拉不到商城,高价值三态全部停在「待确认」)。 + +**CK 失效会作废高价值结论**:官方 `storehome.status` 是账号维度的,正常只随 +这个账号自己的兑换而变;但 CK 失效那段时间账号**可能被别人手工登录并兑换** +(这些号共享给客服),缓存里的「可兑换」就不成立了。作废是**单向**的 —— +两套都降为 `unknown`,等下一次真的读到官方 `storehome` 才写回。 +作废发生在**数据层**(CK 检测结论落定处),不是展示层:CK 恢复之后展示层 +就不再降级了,只在展示层降级会让陈旧的「可换」重新变绿。 +`error`(查不到)不作废 —— 查不到不等于账号出问题了。 + +### 高价值的三类「查不到」必须分开 + +这三种都**不是**「可兑换」,但成因不同,排查方式也不同,筛选时不能混成一档: + +| 筛选 | 判据 | 含义 | +| --- | --- | --- | +| `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` 必须单独成档:账号可能查过其它资料、只是商城接口对它 +稳定失败。它既不满足 `unqueried`(资料查过),也不满足 `high_value_unknown` +(那条要求 `checked_at` 非空)—— 不单独筛的话,这批账号在界面上**无处可寻**。 + ## 自动化策略 - CK 巡检按账号选行,不按登录历史选行。 @@ -99,6 +141,16 @@ CK 本体和健康结果都在账号上: - 非鉴权失败计入 `error`,失败率过高时自动暂停作业。 - 资料刷新只处理 `ck_check_status=valid`、允许自动管理且非人工态的账号。 - “从未查询过”的账号优先于“查询结果已过期”的账号。 +- **高价值结论有自己的时钟**:资料刷新除了看 `overview_queried_at`, + 还看 `high_value_checked_at`。两者会脱钩 —— 一键查询里商品接口单独失败时 + 资料时间被刷新、高价值时间停在原地,CK 失效那段时间也一样。少了这一条, + 这类账号的高价值状态会**永久**停在旧结论。 + 用的是**同一把尺子**(界面上那个「同一账号多久重检」),不另设窗口。 + 「从来没查到过高价值结论」不因此重选:那是商品接口对这个账号稳定失败, + 每轮都重选只会让同一批账号反复撞墙,挤掉真正有新欠账的账号。 +- 结论被**作废**(`unknown`)的账号按 6 小时慢速重试:CK 恢复后必须尽快 + 重新确认(失效期间可能已被别人兑换),但也不能更密 —— + 商品接口对某些账号稳定失败,那类账号会一直是 `unknown`。 ## 代码入口 diff --git a/tests/test_account_overview.py b/tests/test_account_overview.py index f7ac7a8..98f5202 100644 --- a/tests/test_account_overview.py +++ b/tests/test_account_overview.py @@ -6,8 +6,9 @@ from __future__ import annotations -from datetime import UTC, datetime +from datetime import UTC, datetime, timedelta from pathlib import Path +from unittest.mock import Mock import pytest @@ -67,11 +68,12 @@ class TestOwnedJudgement: got = extract_high_value_goods([goods_free], [RABBIT], []) assert got[0]["owned"] is False - def test_owned_comes_from_official_records(self): + def test_records_never_override_available_status(self): got = extract_high_value_goods( [GOODS_AVAILABLE], [RABBIT], [{"commodityName": "套装-兔兔白日梦"}] ) - assert got[0]["owned"] is True + assert got[0]["state"] == "available" + assert got[0]["owned"] is False assert got[0]["obtained_names"] == ["套装-兔兔白日梦"] def test_not_owned_when_records_empty(self): @@ -105,7 +107,7 @@ class TestOwnedJudgement: def test_stock_and_owned_are_independent(self): """展示用的全局信息与账号判定互不影响。""" - sold_out = {**GOODS_AVAILABLE, "count": 0, "storeStatus": 0} + sold_out = {**GOODS_OWNED, "count": 0, "storeStatus": 0} got = extract_high_value_goods( [sold_out], [RABBIT], [{"commodityName": "套装-兔兔白日梦"}] ) @@ -119,7 +121,7 @@ class TestOwnedJudgement: [RABBIT], [{"commodityName": "套装-兔兔白日梦"}, {"commodityName": "别的商品"}], ) - assert got[0]["owned"] is True + assert got[0]["state"] == "available" assert len(got[0]["obtained_names"]) == 1 def test_missing_goods_is_not_silently_dropped(self): @@ -127,18 +129,17 @@ class TestOwnedJudgement: got = extract_high_value_goods([GOODS_AVAILABLE], [RABBIT, "不存在的套装"], []) assert len(got) == 2 assert got[1]["missing"] is True - assert "都没有这个商品" in got[1]["reason"] + assert got[1]["state"] == "unknown" + assert "官方商城未返回" in got[1]["reason"] - def test_goods_in_records_but_not_list_is_not_missing(self): - """兑换记录里有、但当前限兑列表里没有 —— 不是 missing,是已获得。 - - 实测就有这种情况(活动窗口结束但记录仍在),不能误报成「没这个商品」。 - """ + def test_records_without_storehome_status_stay_unknown(self): + """兑换记录可能被清理或暂时查不到,不能替代官方当前 status。""" got = extract_high_value_goods( [], [RABBIT], [{"commodityName": "套装-兔兔白日梦"}] ) - assert got[0]["owned"] is True - assert got[0]["missing"] is False + assert got[0]["state"] == "unknown" + assert got[0]["owned"] is False + assert got[0]["missing"] is True assert got[0]["in_goods_list"] is False def test_empty_list_and_no_records_keeps_all_names(self): @@ -152,9 +153,14 @@ class TestOwnedJudgement: {**GOODS_AVAILABLE, "commodityName": "套装-狂战萌兔", "commodityId": "X"}, ] got = extract_high_value_goods( - items, list(DEFAULT_HIGH_VALUE_GOODS), [{"commodityName": "套装-狂战萌兔"}] + [ + items[0], + {**items[1], "status": 1}, + ], + list(DEFAULT_HIGH_VALUE_GOODS), + [{"commodityName": "套装-狂战萌兔"}], ) - assert [g["owned"] for g in got] == [False, True] + assert [g["state"] for g in got] == ["available", "owned"] def test_tolerates_garbage(self): got = extract_high_value_goods( @@ -162,6 +168,7 @@ class TestOwnedJudgement: [RABBIT], [None, "y", {"commodityName": ""}], ) + assert got[0]["state"] == "available" assert got[0]["owned"] is False def test_custom_goods_config_overrides_default(self): @@ -365,6 +372,43 @@ class TestOverviewAggregation: assert h.marked["status"] == "success" assert r["high_value_goods"] == [] + def test_goods_query_does_not_depend_on_esports_config(self): + """高价值套装来自**精英宝典**商城 —— 与电竞手册配置无关。 + + 回归:商品列表查询曾经被写在「电竞手册」的 else 分支里,于是 + 一旦没配 ``esports_manual_id``,商城商品就永远拉不到, + 高价值三态永远落不到 ``available``/``owned``(全部显示「待确认」)。 + """ + seen: list[str] = [] + + class _Client(_FakeClient): + def list_goods(self, *, manual_id, rid): + seen.append(manual_id) + return super().list_goods(manual_id=manual_id, rid=rid) + + h = _Harness(_Client()) + h._execute_query_account_overview( + self.session, + self.task, + self.account, + "cookie", + { + "manual_id": "ELITE-M", + # 故意**不配** esports_manual_id + "rid": "R1", + "bind_act_alias": "B", + }, + ) + r = h.marked["result"] + assert seen == ["ELITE-M"], "商城商品必须用精英宝典的 manualID 拉" + assert "esports" in r["errors"] # 电竞那一项照常报未配置 + assert r["limited_goods_total"] == 2 + # 高价值三态照样落库(官方 status 说了算) + self.session.commit() + self.session.refresh(self.account) + assert self.account.high_value_bairimeng_status == "owned" + assert self.account.high_value_mengtu_status == "available" + def test_message_counts_owned_suits(self): assert "兔兔白日梦" in self._run().marked["message"] assert "已拥有 1 个" in self._run().marked["message"] @@ -808,7 +852,7 @@ class TestSummaryFeedsAccountList: - 一键查询拉的是**斗鱼官方**记录,本来能查到,但那份 JSON 有 1~2KB, 不能塞进列表(15000 个账号会把响应放大到几十 MB)。 - 所以加了这个**极小**汇总字段 `high_value_owned`:`{"owned": [...], "exchanges": N}`。 + 所以加了这个**极小**汇总字段 `high_value`(官方 storehome 的三态准入)。 """ def setup_method(self): @@ -823,7 +867,9 @@ class TestSummaryFeedsAccountList: email="s@e.com", email_password="ep", cookie="acf_uid=1; acf_ccn=c", - high_value_owned=["套装-兔兔白日梦"], + high_value_bairimeng_status="owned", + high_value_mengtu_status="available", + high_value_checked_at=datetime.now(UTC), overview_queried_at=datetime.now(UTC), overview_snapshot={"high_value_goods": []}, ) @@ -854,9 +900,9 @@ class TestSummaryFeedsAccountList: 只断言属性的测试会**假绿**(踩过),所以这里一律看 `model_dump()`。 """ dumped = self._list()[0].model_dump() - assert dumped["high_value_owned"] == ["套装-兔兔白日梦"], ( - "字段没出现在序列化结果里 —— 前端拿到的 JSON 就没有它" - ) + assert dumped["high_value"]["state"] == "complete" + assert dumped["high_value"]["bairimeng"] == "owned" + assert dumped["high_value"]["mengtu"] == "available" def test_list_serializes_queried_marker(self): """要能区分「查过但没兑换」和「还没查过」—— 否则列表会把未知当高价值。""" @@ -948,10 +994,9 @@ class TestSummaryFeedsAccountList: ) self.session.commit() self.session.refresh(self.account) - owned = self.account.high_value_owned - assert owned is not None, "查询跑完必须写「已兑换过的高价值套装」" - # _FakeClient 的兑换记录里有一条「破片手榴弹-兔兔白日梦」 - assert "兔兔白日梦" in "".join(owned) + assert self.account.high_value_bairimeng_status == "owned" + assert self.account.high_value_mengtu_status == "available" + assert self.account.high_value_checked_at is not None class TestOverviewSummaryMigrationIsSafeToRerun: @@ -1671,7 +1716,9 @@ class TestHighValueAccountFilters: email="e1@test.com", email_password="p", overview_queried_at=now, - high_value_owned=[], + high_value_bairimeng_status="available", + high_value_mengtu_status="available", + high_value_checked_at=now, points=500, gold_balance=10, elite_opened=True, @@ -1685,7 +1732,9 @@ class TestHighValueAccountFilters: email="e2@test.com", email_password="p", overview_queried_at=now, - high_value_owned=["套装-兔兔白日梦"], + high_value_bairimeng_status="owned", + high_value_mengtu_status="available", + high_value_checked_at=now, points=100, gold_balance=0, elite_opened=False, @@ -1699,7 +1748,9 @@ class TestHighValueAccountFilters: email="e3@test.com", email_password="p", overview_queried_at=now, - high_value_owned=["套装-狂战萌兔"], + high_value_bairimeng_status="available", + high_value_mengtu_status="owned", + high_value_checked_at=now, points=50, gold_balance=20, elite_opened=True, @@ -1713,7 +1764,9 @@ class TestHighValueAccountFilters: email="e4@test.com", email_password="p", overview_queried_at=now, - high_value_owned=["套装-兔兔白日梦", "套装-狂战萌兔"], + high_value_bairimeng_status="owned", + high_value_mengtu_status="owned", + high_value_checked_at=now, points=0, gold_balance=0, elite_opened=False, @@ -1727,7 +1780,9 @@ class TestHighValueAccountFilters: email="e5@test.com", email_password="p", overview_queried_at=None, - high_value_owned=None, + high_value_bairimeng_status=None, + high_value_mengtu_status=None, + high_value_checked_at=None, points=None, gold_balance=None, elite_opened=None, @@ -1741,13 +1796,34 @@ class TestHighValueAccountFilters: email="e6@test.com", email_password="p", overview_queried_at=now, - high_value_owned=[], + high_value_bairimeng_status="available", + high_value_mengtu_status="available", + high_value_checked_at=now, points=800, gold_balance=0, elite_opened=True, ck_check_status="invalid", status="normal", ) + # 7. 查过资料,但**从没读到官方商城**(商城接口对这个账号稳定失败)。 + # 这类账号既不是「未查资料」(overview_queried_at 有值), + # 也不属于「待确认」(high_value_checked_at 为 NULL)。 + # —— 之前它在界面上**无处可寻**,是本次补的缺口。 + self.acc_no_mall = Account( + username="acc_no_mall", + password="p", + email="e7@test.com", + email_password="p", + overview_queried_at=now, + high_value_bairimeng_status=None, + high_value_mengtu_status=None, + high_value_checked_at=None, + points=120, + gold_balance=0, + elite_opened=False, + ck_check_status="valid", + status="normal", + ) self.session.add_all( [ self.acc_crown, @@ -1756,6 +1832,7 @@ class TestHighValueAccountFilters: self.acc_both_owned, self.acc_unqueried, self.acc_broken, + self.acc_no_mall, ] ) self.session.commit() @@ -1832,6 +1909,49 @@ class TestHighValueAccountFilters: assert "acc_broken" in names assert "acc_both_owned" not in names + def test_unqueried_filter_keeps_its_original_meaning(self): + """「未查资料」= 从没跑过一键查询(**旧口径**保留)。 + + ⚠️ 别把它悄悄改成「没读到官方商城」:那是另一个集合。 + 查过资料但商城接口失败的账号(acc_no_mall)**不算**未查资料。 + """ + names = [it.username for it in self._query(high_value_filter="unqueried")] + assert names == ["acc_unqueried"] + assert "acc_no_mall" not in names + + def test_high_value_never_filter(self): + """「从未读到官方商城状态」这一档必须**单独可筛**。 + + 之前这批账号(商城接口失败 / 没配 manualID)在界面上无处可寻: + 既不满足 ``unqueried``(资料查过),也不满足 ``high_value_unknown`` + (那条要求 ``high_value_checked_at`` 非空)。 + """ + names = [ + it.username for it in self._query(high_value_filter="high_value_never") + ] + assert set(names) == {"acc_unqueried", "acc_no_mall"} + assert "acc_crown" not in names + + def test_high_value_unknown_filter_excludes_never_checked(self): + """「待确认」= **查过**但结论未知/过期,不含「从没查过」。""" + names = [ + it.username for it in self._query(high_value_filter="high_value_unknown") + ] + assert "acc_unqueried" not in names + assert "acc_no_mall" not in names + + def test_summary_counts_never_and_unknown_separately(self): + """统计里「从没读到」和「待确认」必须分开计数,不能混成一个数。""" + from web.backend.routers.accounts import accounts_summary + + res = accounts_summary(db=self.session, current=self.admin) + hv = res.get("high_value", {}) + # 未查资料:1(acc_unqueried);从没读到商城:2(acc_unqueried + acc_no_mall) + assert hv["unqueried"] == 1 + assert hv["high_value_never"] == 2 + # 查过但结论未知/过期:0(其它账号状态都是 available/owned) + assert hv["high_value_unknown"] == 0 + def test_list_sorts_by_suit_availability(self): # 升序:两套都能换 → 只剩一套 → 都换过 → 没查过。同档按 id。 names = [it.username for it in self._query(sort="high_value", order="asc")] @@ -1842,9 +1962,11 @@ class TestHighValueAccountFilters: "acc_mengtu_owned", "acc_both_owned", "acc_unqueried", + "acc_no_mall", ] names = [it.username for it in self._query(sort="high_value", order="desc")] assert names == [ + "acc_no_mall", "acc_unqueried", "acc_both_owned", "acc_mengtu_owned", @@ -1907,6 +2029,7 @@ class TestHighValueAccountFilters: "acc_mengtu_owned", "acc_unqueried", "acc_broken", + "acc_no_mall", "acc_crown", "acc_both_owned", ] @@ -1918,6 +2041,7 @@ class TestHighValueAccountFilters: "acc_mengtu_owned", "acc_unqueried", "acc_broken", + "acc_no_mall", ] def test_list_sorts_points_status_bind_and_tag(self): @@ -1936,6 +2060,7 @@ class TestHighValueAccountFilters: assert [it.username for it in self._query(sort="points", order="desc")] == [ "acc_broken", "acc_crown", + "acc_no_mall", "acc_mengtu_owned", "acc_bairimeng_owned", "acc_both_owned", @@ -1945,6 +2070,7 @@ class TestHighValueAccountFilters: "acc_both_owned", "acc_bairimeng_owned", "acc_mengtu_owned", + "acc_no_mall", "acc_crown", "acc_broken", "acc_unqueried", @@ -1964,6 +2090,7 @@ class TestHighValueAccountFilters: "acc_broken", "acc_crown", "acc_mengtu_owned", + "acc_no_mall", "acc_unqueried", ] tagged = [it.username for it in self._query(sort="tag", order="asc")] @@ -1994,8 +2121,8 @@ class TestHighValueAccountFilters: 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 的账号(如被重置过),绝不能误判为双套均可兑换。""" + def test_unknown_high_value_status_not_treated_as_both_available(self): + """官方状态 unknown 绝不能误判为双套均可兑换。""" from datetime import UTC, datetime from web.backend.models import Account @@ -2006,14 +2133,286 @@ class TestHighValueAccountFilters: password="p", email="e@test.com", email_password="ep", - high_value_owned=None, - overview_queried_at=datetime.now(UTC), # 虽然时间不为空,但字段是 None + high_value_bairimeng_status="unknown", + high_value_mengtu_status="unknown", + high_value_checked_at=datetime.now(UTC), + overview_queried_at=datetime.now(UTC), ) self.session.add(acc) self.session.commit() res = accounts_summary(db=self.session, current=self.admin) hv = res.get("high_value", {}) - # 绝不计入 both_available,应计入 unqueried + # 绝不计入 both_available,应计入待确认 assert hv["both_available"] == 2 - assert hv["unqueried"] == 2 + assert hv["high_value_unknown"] == 1 + + +class TestHighValueTriState: + """三态本身:可兑换 / 已达上限 / 未知,以及过期与 CK 失效的降级。""" + + def setup_method(self): + self.engine = get_test_engine() + self.session = make_test_session(self.engine) + + def teardown_method(self): + self.session.close() + + def _account(self, **kwargs): + kwargs.setdefault( + "username", + f"hv-{id(kwargs)}-{kwargs.get('high_value_bairimeng_status')}", + ) + acc = Account( + password="p", + email="e@test.com", + email_password="ep", + **kwargs, + ) + self.session.add(acc) + self.session.commit() + return acc + + def test_never_when_never_checked(self): + from web.backend.services.high_value_service import high_value_payload + + acc = self._account() + payload = high_value_payload(acc) + assert payload["state"] == "never" + assert payload["bairimeng"] == "" + assert payload["mengtu"] == "" + + def test_partial_when_one_suit_unknown(self): + from web.backend.services.high_value_service import high_value_payload + + acc = self._account( + high_value_bairimeng_status="available", + high_value_mengtu_status="unknown", + high_value_checked_at=datetime.now(UTC), + ) + payload = high_value_payload(acc) + assert payload["state"] == "partial" + assert payload["bairimeng"] == "available" + assert payload["mengtu"] == "unknown" + + def test_stale_after_seven_days(self): + 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) - timedelta(days=8), + ) + payload = high_value_payload(acc) + assert payload["state"] == "stale" + assert payload["stale"] is True + assert payload["fresh"] is False + + def test_ck_invalid_downgrades_to_stale(self): + """CK 失效 = 查不动了,缓存里的「可兑换」必须降级为待确认。""" + 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="invalid", + ) + payload = high_value_payload(acc) + assert payload["state"] == "stale" + assert payload["stale"] is True + + def test_stale_account_is_not_counted_as_available(self): + """过期的账号不能进「双套均可兑换」筛选。""" + from web.backend.routers.accounts import accounts_summary + + admin = User(username="hv-stale-admin", password_hash="h", role="super_admin") + self.session.add(admin) + self.session.flush() + self._account( + username="hv-stale-acc", + high_value_bairimeng_status="available", + high_value_mengtu_status="available", + high_value_checked_at=datetime.now(UTC) - timedelta(days=8), + ) + self.session.commit() + + res = accounts_summary(db=self.session, current=admin) + hv = res.get("high_value", {}) + assert hv["both_available"] == 0 + assert hv["high_value_unknown"] == 1 + + +class TestHighValueInvalidatedByCkFailure: + """CK 失效 → 高价值结论**作废**;CK 恢复后**不能**直接显示旧的「可换」。 + + 官方 ``storehome.status`` 是账号维度的,正常只随这个账号自己的兑换而变。 + 但 CK 失效那段时间,账号**可能被别人手工登录并兑换**(这些号共享给客服) + —— 缓存里的「可兑换」就不成立了。 + + ⚠️ 关键在于:作废必须发生在**数据层**,不能只靠展示层降级。 + CK 恢复之后展示层就不再降级了 —— 那条陈旧的「可换」会重新变绿, + 运营会拿着一份失效结论去兑换。 + """ + + def setup_method(self): + self.engine = get_test_engine() + self.session = make_test_session(self.engine) + + def teardown_method(self): + self.session.close() + + def _account(self, **kwargs): + kwargs.setdefault("username", f"hvinv-{id(kwargs)}") + acc = Account( + password="p", + email="e@test.com", + email_password="ep", + **kwargs, + ) + self.session.add(acc) + self.session.commit() + return acc + + def test_ck_invalid_invalidates_high_value(self): + from web.backend.services.account_lifecycle import on_ck_verdict + + acc = self._account( + high_value_bairimeng_status="available", + high_value_mengtu_status="available", + high_value_checked_at=datetime.now(UTC), + ck_check_status="valid", + ) + on_ck_verdict( + self.session, acc, "invalid", detail={}, occurred_at=datetime.now(UTC) + ) + self.session.commit() + self.session.refresh(acc) + assert acc.high_value_bairimeng_status == "unknown" + assert acc.high_value_mengtu_status == "unknown" + + def test_ck_recovery_does_not_resurrect_stale_conclusion(self): + """CK 恢复 valid 后,陈旧结论**不能**重新显示成「可换」。""" + from web.backend.services.account_lifecycle import on_ck_verdict + 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="valid", + ) + on_ck_verdict( + self.session, acc, "invalid", detail={}, occurred_at=datetime.now(UTC) + ) + # 重登成功 → CK 恢复 + on_ck_verdict( + self.session, acc, "valid", detail={}, occurred_at=datetime.now(UTC) + ) + self.session.commit() + self.session.refresh(acc) + + payload = high_value_payload(acc) + assert payload["bairimeng"] == "unknown", "作废的结论不能因为 CK 恢复就复活" + assert payload["mengtu"] == "unknown" + assert payload["state"] in {"partial", "stale"} + + def test_ck_error_does_not_invalidate(self): + """`error`(查不到)**不**作废 —— 查不到不等于账号出问题了。""" + from web.backend.services.account_lifecycle import on_ck_verdict + + acc = self._account( + high_value_bairimeng_status="available", + high_value_mengtu_status="owned", + high_value_checked_at=datetime.now(UTC), + ck_check_status="valid", + ) + on_ck_verdict( + self.session, acc, "error", detail={}, occurred_at=datetime.now(UTC) + ) + self.session.commit() + self.session.refresh(acc) + assert acc.high_value_bairimeng_status == "available" + assert acc.high_value_mengtu_status == "owned" + + +class TestHighValueAfterExchange: + """兑换成功后,官方准入必须立刻收口,不能被 storehome 的缓存延迟覆盖回去。""" + + def setup_method(self): + self.engine = get_test_engine() + self.session = make_test_session(self.engine) + self.account = Account( + username="hv-exchange", + password="p", + email="e@test.com", + email_password="ep", + cookie="acf_uid=1; acf_ccn=c", + ) + self.session.add(self.account) + self.session.commit() + + def teardown_method(self): + self.session.close() + + def _runner(self, goods): + from web.backend.services.douyu_runner_goods import GoodsMixin + + class _Runner(GoodsMixin): + def __init__(self): + self.logs = [] + + def _push_log(self, level, message): + self.logs.append((level, message)) + + runner = _Runner() + client = Mock() + client.list_goods.return_value = {"goods": goods} + return runner, client + + def test_storehome_cache_delay_still_marks_owned(self): + """官方刚兑换完还返回旧的 status=0 —— 兑换成功是确定事实,必须是 owned。""" + runner, client = self._runner( + [ + {"commodityName": "套装-兔兔白日梦", "commodityId": "x", "status": 0}, + {"commodityName": "套装-狂战萌兔", "commodityId": "y", "status": 0}, + ] + ) + runner._refresh_high_value_after_exchange( + client, + self.account, + manual_id="m", + rid="r", + commodity_name="套装-兔兔白日梦", + ) + assert self.account.high_value_bairimeng_status == "owned" + assert self.account.high_value_mengtu_status == "available" + + def test_storehome_refresh_failure_falls_back_to_owned(self): + """回查失败不能带崩已成功的兑换,也不能把套装留在「可兑换」。""" + runner, client = self._runner([]) + client.list_goods.side_effect = RuntimeError("network down") + runner._refresh_high_value_after_exchange( + client, + self.account, + manual_id="m", + rid="r", + commodity_name="套装-狂兔萌兔", + ) + assert self.account.high_value_bairimeng_status == "unknown" + assert any(level == "warning" for level, _ in runner.logs) + + def test_unrelated_goods_name_is_ignored(self): + """换的不是高价值套装时,不能乱改高价值状态。""" + runner, client = self._runner( + [{"commodityName": "套装-兔兔白日梦", "commodityId": "x", "status": 0}] + ) + runner._refresh_high_value_after_exchange( + client, + self.account, + manual_id="m", + rid="r", + commodity_name="精英手册-其他道具", + ) + assert self.account.high_value_bairimeng_status == "available" + assert self.account.high_value_mengtu_status == "unknown" diff --git a/tests/test_account_overview_refresh_job.py b/tests/test_account_overview_refresh_job.py index d0cba3c..49fe39a 100644 --- a/tests/test_account_overview_refresh_job.py +++ b/tests/test_account_overview_refresh_job.py @@ -44,6 +44,8 @@ class _Base: queried_days_ago: int | None = None, auto_managed: bool = True, status: str = "active", + hv_checked_days_ago: float | None = None, + hv_status: str | None = None, ) -> Account: acc = Account( username=name, @@ -59,6 +61,13 @@ class _Base: acc.overview_queried_at = datetime.now(UTC) - timedelta( days=queried_days_ago ) + if hv_checked_days_ago is not None: + acc.high_value_checked_at = datetime.now(UTC) - timedelta( + days=hv_checked_days_ago + ) + if hv_status is not None: + acc.high_value_bairimeng_status = hv_status + acc.high_value_mengtu_status = hv_status self.session.add(acc) self.session.commit() return acc @@ -156,6 +165,110 @@ class TestSelection(_Base): assert job.count_remaining(self.session, self.job, datetime.now(UTC)) >= 4 +class TestHighValueRefresh(_Base): + """高价值结论有**自己独立的时钟** —— 资料刷新必须跟着它走。 + + 缺口长这样:一键查询里商品接口单独失败(其它项都拿到了), + ``overview_queried_at`` 被刷新了、``high_value_checked_at`` 却停在原地。 + 资料看起来很新,于是**再也不会被选中** —— 高价值状态永久停在旧结论。 + """ + + def test_stale_high_value_is_selected_even_if_overview_is_fresh(self): + """资料 1 天前查过(很新),但高价值结论 30 天前 —— 必须重选。""" + self.account( + "ovr-hv-stale", + queried_days_ago=1, + hv_checked_days_ago=30, + hv_status="available", + ) + assert "ovr-hv-stale" in [a.username for a in self.selected()] + + def test_fresh_high_value_is_not_selected(self): + """资料和高价值结论都新鲜 —— 不该被重选(避免重复请求)。""" + self.account( + "ovr-hv-fresh", + queried_days_ago=1, + hv_checked_days_ago=1, + hv_status="available", + ) + assert "ovr-hv-fresh" not in [a.username for a in self.selected()] + + def test_never_checked_high_value_is_not_selected_on_its_own(self): + """从来没查到过高价值结论,**不**因此被重选。 + + 这种情况要么是「账号根本还没查过资料」(本来就该被选中), + 要么是**商品接口对这个账号稳定失败**(没配 manualID、账号没有宝典)。 + 后者若每轮都重选,同一批账号会反复撞墙,把真正有新欠账的挤掉。 + 「资料过期」那条已经兜住了它。 + """ + self.account("ovr-hv-never", queried_days_ago=1) + assert "ovr-hv-never" not in [a.username for a in self.selected()] + + def test_high_value_uses_the_same_ruler_as_the_ui_field(self): + """高价值过期**用同一把尺子**(界面上那个「同一账号多久重检」)。 + + 之前这里读的是 `job.config["refresh_days"]`,界面上那个字段对本作业 + 就是死的;同理,若高价值另设一个窗口,那个字段对本作业就变成半死的。 + """ + self.job.interval_seconds = 100 * 24 * 3600 + self.session.commit() + # 资料 30 天前(按 100 天不该重选),高价值结论 60 天前(同样不该) + self.account( + "ovr-hv-60d", + queried_days_ago=30, + hv_checked_days_ago=60, + hv_status="available", + ) + assert "ovr-hv-60d" not in [a.username for a in self.selected()] + + # 缩回 7 天 → 高价值 60 天前就过期了,要重选 + self.job.interval_seconds = 7 * 24 * 3600 + self.session.commit() + assert "ovr-hv-60d" in [a.username for a in self.selected()] + + def test_high_value_refresh_still_requires_valid_ck(self): + """高价值过期**不能**绕过 CK 有效性 —— CK 失效的查了也是白查。""" + self.account( + "ovr-hv-stale-invalid", + ck="invalid", + queried_days_ago=1, + hv_checked_days_ago=30, + hv_status="available", + ) + assert "ovr-hv-stale-invalid" not in [a.username for a in self.selected()] + + def test_invalidated_conclusion_is_rechecked_within_six_hours(self): + """结论被**作废**(unknown)的账号,6 小时内要重查 —— 哪怕重检间隔是 7 天。 + + 场景:CK 失效期间账号可能被别人手工登录并兑换(这些号共享给客服), + 所以 CK 一判定失效就把结论作废。CK 恢复之后必须**尽快**重新确认 —— + 否则运营会一直拿着一份「待确认」的结论,而它其实已经变了。 + + ⚠️ 这里用的是「资料重检间隔」(7 天)之外的**另一个**窗口: + 作废的结论等 7 天太久了,运营等不了。但也不能更密 —— + 商城接口对某些账号稳定失败,那类账号会一直是 unknown,每轮都重选 + 就是反复撞墙,挤掉真正有新欠账的账号。 + """ + # 资料 1 天前(很新,按 7 天不该重选),高价值结论是 10 小时前的 unknown + self.account( + "ovr-hv-invalidated", + queried_days_ago=1, + hv_checked_days_ago=10 / 24, + hv_status="unknown", + ) + assert "ovr-hv-invalidated" in [a.username for a in self.selected()] + + def test_invalidated_conclusion_is_not_rechecked_too_often(self): + """作废的结论 6 小时内**不**重选 —— 不能反复撞同一个失败的接口。""" + self.account( + "ovr-hv-invalidated-fresh", + queried_days_ago=1, + hv_checked_days_ago=1 / 24, # 1 小时前 + hv_status="unknown", + ) + assert "ovr-hv-invalidated-fresh" not in [a.username for a in self.selected()] + + class TestCircuitBreaker(_Base): """失败率过高就**自己把自己关掉**(风控保护)。 diff --git a/tests/test_automation_ck_health.py b/tests/test_automation_ck_health.py index 9a4ac81..2a4e277 100644 --- a/tests/test_automation_ck_health.py +++ b/tests/test_automation_ck_health.py @@ -197,7 +197,9 @@ class TestCkHealthSelection(_Base): ) valuable = self.make_account("valuable", ck_checked_at=now - timedelta(hours=7)) valuable.overview_queried_at = now - valuable.high_value_owned = [] + valuable.high_value_bairimeng_status = "available" + valuable.high_value_mengtu_status = "available" + valuable.high_value_checked_at = now valuable.points = 800 self.session.commit() diff --git a/tests/test_automation_ck_relogin.py b/tests/test_automation_ck_relogin.py index e2589df..8838d52 100644 --- a/tests/test_automation_ck_relogin.py +++ b/tests/test_automation_ck_relogin.py @@ -265,10 +265,14 @@ class TestSelection(_Base): plain = self.make_account("plain") one_suit = self.make_account("one-suit") one_suit.overview_queried_at = datetime.now(UTC) - one_suit.high_value_owned = ["套装-兔兔白日梦"] + one_suit.high_value_bairimeng_status = "owned" + one_suit.high_value_mengtu_status = "available" + one_suit.high_value_checked_at = datetime.now(UTC) both = self.make_account("both") both.overview_queried_at = datetime.now(UTC) - both.high_value_owned = [] + both.high_value_bairimeng_status = "available" + both.high_value_mengtu_status = "available" + both.high_value_checked_at = datetime.now(UTC) self.session.commit() ids = self._ids(job) diff --git a/tests/test_migration_high_value_roundtrip.py b/tests/test_migration_high_value_roundtrip.py new file mode 100644 index 0000000..2fb28e9 --- /dev/null +++ b/tests/test_migration_high_value_roundtrip.py @@ -0,0 +1,87 @@ +"""0065 高价值三态迁移的 upgrade/downgrade 往返验证(真实 MySQL)。""" + +import secrets +from urllib.parse import quote_plus + +from alembic import command +from alembic.config import Config +from sqlalchemy import create_engine, inspect, text + +import web.backend.database as database_module +from tests.support.db import get_mysql_credentials + + +def test_0065_upgrade_downgrade_roundtrip(): + creds = get_mysql_credentials() + mig_db = f"douyu_login_mig_rt_{secrets.token_hex(4)}" + mig_url = ( + f"mysql+pymysql://{quote_plus(creds['user'])}:{quote_plus(creds['password'])}" + f"@{creds['host']}:{creds['port']}/{mig_db}?charset=utf8mb4" + ) + admin_engine = create_engine( + f"mysql+pymysql://{quote_plus(creds['user'])}:{quote_plus(creds['password'])}" + f"@{creds['host']}:{creds['port']}/?charset=utf8mb4" + ) + with admin_engine.begin() as conn: + conn.execute(text(f"CREATE DATABASE `{mig_db}` CHARACTER SET utf8mb4")) + + original = database_module.DATABASE_URL + try: + database_module.DATABASE_URL = mig_url + cfg = Config("alembic.ini") + cfg.set_main_option("script_location", "web/backend/migrations") + cfg.set_main_option("sqlalchemy.url", mig_url) + command.upgrade(cfg, "head") + + insp = inspect(create_engine(mig_url)) + cols = {c["name"] for c in insp.get_columns("accounts")} + assert "high_value_bairimeng_status" in cols + assert "high_value_mengtu_status" in cols + assert "high_value_checked_at" in cols + assert "high_value_owned" not in cols + assert "ix_accounts_high_value_checked_at" in { + i["name"] for i in insp.get_indexes("accounts") + } + + # 塞两行数据:owned/owned 与 owned/unknown,验证降级数组干净 + engine = create_engine(mig_url) + with engine.begin() as conn: + for i, (b, m) in enumerate([("owned", "owned"), ("owned", "unknown")]): + 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)" + ), + {"u": f"rt-{i}", "b": b, "m": m}, + ) + + command.downgrade(cfg, "20261021_0064") + insp = inspect(create_engine(mig_url)) + cols = {c["name"] for c in insp.get_columns("accounts")} + assert "high_value_owned" in cols + assert "high_value_bairimeng_status" not in cols + assert "ix_accounts_high_value_checked_at" not in { + i["name"] for i in insp.get_indexes("accounts") + } + + with engine.begin() 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] + + command.upgrade(cfg, "head") + insp = inspect(create_engine(mig_url)) + cols = {c["name"] for c in insp.get_columns("accounts")} + assert "high_value_owned" not in cols + assert "high_value_checked_at" in cols + finally: + database_module.DATABASE_URL = original + with admin_engine.begin() as conn: + conn.execute(text(f"DROP DATABASE IF EXISTS `{mig_db}`")) diff --git a/tests/test_migrations.py b/tests/test_migrations.py index 926eaa6..0d137dc 100644 --- a/tests/test_migrations.py +++ b/tests/test_migrations.py @@ -26,7 +26,7 @@ TESTS_DIR = Path(__file__).resolve().parent PROJECT_ROOT = TESTS_DIR.parent VERSIONS_DIR = PROJECT_ROOT / "web" / "backend" / "migrations" / "versions" -HEAD_REVISION = "20261021_0064" +HEAD_REVISION = "20261106_0065" # 迁移新增、但不声明在模型里的复合查询索引。 EXTRA_INDEXES = ( 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 new file mode 100644 index 0000000..9fdc311 --- /dev/null +++ b/web/backend/migrations/versions/20261106_0065_account_high_value_status.py @@ -0,0 +1,193 @@ +"""把高价值套装从“拥有名单”改为官方准入三态。 + +旧的 ``high_value_owned`` 无法区分“确认两套可兑换”和“官方接口没返回商品”。 +本迁移增加两套独立状态和检查时间,并从旧快照保守回填: +只有快照里明确保留了官方 ``status=0/1`` 才恢复可兑换/已达上限; +更早的快照没有保存原始 status,统一回填为 unknown,等待一次新查询。 +""" + +from __future__ import annotations + +import json +from datetime import datetime +from typing import Any + +import sqlalchemy as sa +from alembic import op + +revision: str = "20261106_0065" +down_revision: str | None = "20261021_0064" +branch_labels: str | None = None +depends_on: str | None = None + +STATUS_OWNED = "owned" +STATUS_AVAILABLE = "available" +STATUS_UNKNOWN = "unknown" +NAMES = { + "bairimeng": "兔兔白日梦", + "mengtu": "狂战萌兔", +} + + +def _column_exists(name: str) -> bool: + count = ( + op.get_bind() + .execute( + sa.text( + """ + SELECT COUNT(*) + FROM information_schema.columns + WHERE table_schema = DATABASE() + AND table_name = 'accounts' + AND column_name = :name + """ + ), + {"name": name}, + ) + .scalar() + ) + return bool(count) + + +def _json_dict(value: Any) -> dict[str, Any]: + if isinstance(value, dict): + return value + if isinstance(value, (str, bytes)): + try: + parsed = json.loads(value) + except ValueError: + return {} + return parsed if isinstance(parsed, dict) else {} + return {} + + +def _snapshot_state(item: dict[str, Any]) -> str: + status = str(item.get("status")).strip() if item.get("status") is not None else "" + if status == "0": + return STATUS_AVAILABLE + if status == "1": + return STATUS_OWNED + return STATUS_UNKNOWN + + +def _as_datetime(value: Any) -> datetime | None: + if isinstance(value, datetime): + return value.replace(tzinfo=None) if value.tzinfo else value + if isinstance(value, str) and value.strip(): + try: + parsed = datetime.fromisoformat(value.strip()) + except ValueError: + return None + return parsed.replace(tzinfo=None) if parsed.tzinfo else parsed + return 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" + ) + ).fetchall() + for account_id, raw_snapshot, overview_queried_at in rows: + snapshot = _json_dict(raw_snapshot) + goods = snapshot.get("high_value_goods") + states = {key: STATUS_UNKNOWN for key in NAMES} + for item in goods if isinstance(goods, list) else []: + if not isinstance(item, dict): + continue + name = str(item.get("name") or "") + for key, core in NAMES.items(): + if core in name: + states[key] = _snapshot_state(item) + break + checked_at = _as_datetime(snapshot.get("queried_at") or overview_queried_at) + conn.execute( + sa.text( + """ + UPDATE accounts + SET high_value_bairimeng_status = :bairimeng, + high_value_mengtu_status = :mengtu, + high_value_checked_at = :checked_at + WHERE id = :id + """ + ), + { + "id": account_id, + "bairimeng": states["bairimeng"], + "mengtu": states["mengtu"], + "checked_at": checked_at, + }, + ) + + +def upgrade() -> None: + for name in ( + "high_value_bairimeng_status", + "high_value_mengtu_status", + "high_value_checked_at", + ): + if _column_exists(name): + continue + if name == "high_value_checked_at": + op.add_column( + "accounts", + sa.Column( + name, + sa.DateTime(), + nullable=True, + comment="最近一次成功读取官方 storehome 准入状态的时间", + ), + ) + else: + op.add_column( + "accounts", + sa.Column( + name, + sa.String(length=16), + nullable=True, + comment="owned / available / unknown;只认官方 storehome.status", + ), + ) + _backfill_from_snapshot() + op.create_index( + "ix_accounts_high_value_checked_at", + "accounts", + ["high_value_checked_at"], + ) + if _column_exists("high_value_owned"): + op.drop_column("accounts", "high_value_owned") + + +def downgrade() -> None: + if not _column_exists("high_value_owned"): + op.add_column( + "accounts", sa.Column("high_value_owned", sa.JSON(), nullable=True) + ) + conn = op.get_bind() + conn.execute( + 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() + ) + """ + ) + ) + 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") diff --git a/web/backend/models.py b/web/backend/models.py index 7cf01ac..6263393 100644 --- a/web/backend/models.py +++ b/web/backend/models.py @@ -124,14 +124,17 @@ class Account(Base): role_bind_status: Mapped[str] = mapped_column( String(16), default="unknown", server_default="unknown" ) - # 一键查询得到的「该账号已兑换过的高价值套装名」列表(如 ["套装-兔兔白日梦"])。 - # - # 为什么单开一列、而不是让列表读 `overview_snapshot`:那份 JSON 有 1~2KB, - # 账号列表是全站最热的接口之一,15000 个账号各带一份会把响应放大到几十 MB。 - # 这里只有几个短字符串。 - # - # 兑换**条数**不存这里 —— 列表直接从官方事件聚合,不维护第二套计数。 - high_value_owned: Mapped[list | None] = mapped_column(JSON, nullable=True) + # 高价值套装的当前准入状态只认官方 storehome.status: + # available=当前可兑换 / owned=已达兑换上限 / unknown=未查到或接口没返回。 + high_value_bairimeng_status: Mapped[str | None] = mapped_column( + String(16), nullable=True + ) + high_value_mengtu_status: Mapped[str | None] = mapped_column( + String(16), nullable=True + ) + high_value_checked_at: Mapped[datetime | None] = mapped_column( + UTCDateTime, nullable=True + ) # 最近一次「一键查询」的快照(结构化结果)。存在账号上而不是只留在任务里: # 任务会被保留策略清掉,而运营看的是"这个账号现在什么情况"。 overview_snapshot: Mapped[dict | None] = mapped_column(JSON, nullable=True) @@ -245,6 +248,8 @@ class Account(Base): Index("ix_accounts_status_relogin_next", "status", "relogin_next_at"), # 列表/统计按生命周期状态筛选 Index("ix_accounts_status", "status"), + # 高价值三态的过期判定和待刷新筛选 + Index("ix_accounts_high_value_checked_at", "high_value_checked_at"), ) assigned_user: Mapped[User | None] = relationship( diff --git a/web/backend/routers/accounts.py b/web/backend/routers/accounts.py index 2f25822..9bc2435 100644 --- a/web/backend/routers/accounts.py +++ b/web/backend/routers/accounts.py @@ -57,15 +57,15 @@ from ..services.account_lifecycle import ( ) from ..services.account_service import parse_and_build_accounts from ..services.account_value import ( - SUIT_BAIRIMENG, - SUIT_MENGTU, + high_value_unknown_expr, is_high_value_expr, - is_suit_queried_expr, + suit_available_expr, suit_owned_expr, suit_sort_rank_expr, ) from ..services.audit_service import record_account_action, record_audit from ..services.douyu_service import cookie_holding_filter +from ..services.high_value_service import high_value_payload from ..services.login_failure import classify_failure, failure_kind_label router = APIRouter(prefix="/api/accounts", tags=["账号管理"]) @@ -205,33 +205,34 @@ 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) + bairimeng_available = suit_available_expr("bairimeng") + mengtu_available = suit_available_expr("mengtu") + bairimeng_owned = suit_owned_expr("bairimeng") + mengtu_owned = suit_owned_expr("mengtu") if hv == "both_available": - query = query.filter( - suit_queried, - ~bairimeng_expr, - ~mengtu_expr, - ) + query = query.filter(bairimeng_available, mengtu_available) elif hv == "can_bairimeng": - query = query.filter( - suit_queried, - ~bairimeng_expr, - ) + query = query.filter(bairimeng_available) elif hv == "can_mengtu": - query = query.filter( - suit_queried, - ~mengtu_expr, - ) + query = query.filter(mengtu_available) elif hv == "owned_bairimeng": - query = query.filter(bairimeng_expr) + query = query.filter(bairimeng_owned) elif hv == "owned_mengtu": - query = query.filter(mengtu_expr) + query = query.filter(mengtu_owned) elif hv == "both_owned": - query = query.filter(bairimeng_expr, mengtu_expr) + query = query.filter(bairimeng_owned, mengtu_owned) elif hv == "unqueried": + # 「未查资料」= 从没跑过一键查询(**旧口径**,保留不变)。 + # ⚠️ 别和「没读到官方商城」混为一谈:账号可能查过其它资料, + # 只是商城接口对它稳定失败 —— 那是下面 high_value_never 那一档。 query = query.filter(Account.overview_queried_at.is_(None)) + elif hv == "high_value_never": + # 从没成功读到官方 storehome:商城接口失败 / 没配 manualID。 + # 高价值三态里的「unknown」要求 checked_at 非空(查过但没结论), + # 所以这一档必须单独筛,否则这批账号在界面上**无处可寻**。 + query = query.filter(Account.high_value_checked_at.is_(None)) + elif hv == "high_value_unknown": + query = query.filter(high_value_unknown_expr()) elif hv == "high_value_broken": query = query.filter( is_high_value_expr(), @@ -457,7 +458,7 @@ def list_accounts( ), high_value_filter: str = Query( "", - description="按高价值套装状态筛选:both_available/can_bairimeng/can_mengtu/owned_bairimeng/owned_mengtu/both_owned/unqueried/high_value_broken", + description="按高价值套装官方准入筛选:both_available/can_bairimeng/can_mengtu/owned_bairimeng/owned_mengtu/both_owned/unqueried/high_value_never/high_value_unknown/high_value_broken", ), min_points: int | None = Query(None, description="最低积分"), max_points: int | None = Query(None, description="最高积分"), @@ -646,11 +647,8 @@ def list_accounts( last_exchange_at=exchange_stats.get(acc.id, (0, None, ""))[1], last_exchange_name=exchange_stats.get(acc.id, (0, None, ""))[2], **bind_payload(acc), - # 一键查询得到的「已兑换过的高价值套装」(不是整份快照 —— - # 列表带不起 1~2KB/账号 的 JSON)。兑换条数不在这里: - # 上面的 exchange_count 已经是**官方事件**聚合的结果。 - high_value_owned=acc.high_value_owned, - # 「有没有查过」靠它判断 —— 否则"查过但没兑换"和"没查过"都是空,分不开 + # 只返回官方 storehome 的紧凑三态,不把整份快照带进列表。 + high_value=high_value_payload(acc), overview_queried_at=acc.overview_queried_at, ) # 只有管理员可看完整字段(密码、邮箱等) @@ -792,33 +790,45 @@ def accounts_summary( login_failures = {str(row[0] or ""): int(row[1] or 0) for row in failure_rows} # 高价值及核心资产分布(单次聚合,供前端快捷胶囊展示) - bairimeng_expr = suit_owned_expr(Account.high_value_owned, SUIT_BAIRIMENG) - mengtu_expr = suit_owned_expr(Account.high_value_owned, SUIT_MENGTU) + bairimeng_available = suit_available_expr("bairimeng") + mengtu_available = suit_available_expr("mengtu") + bairimeng_owned = suit_owned_expr("bairimeng") + mengtu_owned = suit_owned_expr("mengtu") high_value_broken_expr = and_( is_high_value_expr(), Account.ck_check_status.in_(["invalid", "error"]), ) - suit_queried = is_suit_queried_expr() hv_row = visible.with_entities( - 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((and_(bairimeng_available, mengtu_available), 1))), + func.count(case((bairimeng_available, 1))), + func.count(case((mengtu_available, 1))), + func.count(case((Account.overview_queried_at.is_(None), 1))), + func.count(case((Account.high_value_checked_at.is_(None), 1))), + func.count(case((high_value_unknown_expr(), 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))), + func.count(case((bairimeng_owned, 1))), + func.count(case((mengtu_owned, 1))), + func.count(case((and_(bairimeng_owned, mengtu_owned), 1))), ).first() high_value_summary = { "both_available": int(hv_row[0] or 0) if hv_row else 0, "can_bairimeng": int(hv_row[1] or 0) if hv_row else 0, "can_mengtu": int(hv_row[2] or 0) if hv_row else 0, "unqueried": int(hv_row[3] or 0) if hv_row else 0, - "high_points": int(hv_row[4] or 0) if hv_row else 0, - "has_gold": int(hv_row[5] or 0) if hv_row else 0, - "elite_opened": int(hv_row[6] or 0) if hv_row else 0, - "high_value_broken": int(hv_row[7] or 0) if hv_row else 0, + # 从没成功读到官方商城结果的账号数(与「查过但没结论」分开)。 + "high_value_never": int(hv_row[4] or 0) if hv_row else 0, + "high_value_unknown": int(hv_row[5] or 0) if hv_row else 0, + "high_points": int(hv_row[6] or 0) if hv_row else 0, + "has_gold": int(hv_row[7] or 0) if hv_row else 0, + "elite_opened": int(hv_row[8] or 0) if hv_row else 0, + "high_value_broken": int(hv_row[9] or 0) if hv_row else 0, + "owned_bairimeng": int(hv_row[10] or 0) if hv_row else 0, + "owned_mengtu": int(hv_row[11] or 0) if hv_row else 0, + "both_owned": int(hv_row[12] or 0) if hv_row else 0, } return { @@ -1642,7 +1652,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, + "high_value": high_value_payload(acc), "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 55dd194..1506bc3 100644 --- a/web/backend/schemas.py +++ b/web/backend/schemas.py @@ -308,12 +308,9 @@ class AccountOut(BaseModel): exchange_count: int = 0 last_exchange_at: datetime | None = None last_exchange_name: str = "" - # 一键查询得到的(账号表里本来没有的)信息,供列表展示。 - # ⚠️ 必须在这里声明:Pydantic 默认 `extra='ignore'`, - # 没声明的字段会被**静默丢掉** —— 路由明明返回了,接口却看不到。 - # 为 None 表示未查询;为 [] 表示已查询且两套均未兑换。 - high_value_owned: list[str] | None = None - # 「有没有做过一键查询」靠它判断(列表不返回整份快照) + # 官方 storehome 的高价值套装三态。状态值只允许 available/owned/unknown; + # 外层 state 再给出 never/partial/stale/complete,避免“没查到”被当成“可兑换”。 + high_value: dict[str, Any] = Field(default_factory=dict) overview_queried_at: datetime | None = None model_config = ConfigDict(from_attributes=True) @@ -352,11 +349,10 @@ class AccountOut(BaseModel): "exchange_count": self.exchange_count, "last_exchange_at": _isoformat(self.last_exchange_at), "last_exchange_name": self.last_exchange_name, - # ⚠️ 这个 `_serialize` 是**手写**的:字段只声明在模型上**不够**, - # 不在这里列出来就会被丢掉。踩过 —— 路由返回的对象属性明明有值, - # 前端拿到的 JSON 里却没有(属性访问能过、序列化后没有, - # 只断言属性的测试因此假绿)。 - "high_value_owned": self.high_value_owned, + "high_value": { + **self.high_value, + "checked_at": _isoformat(self.high_value.get("checked_at")), + }, "overview_queried_at": _isoformat(self.overview_queried_at), } diff --git a/web/backend/services/account_lifecycle.py b/web/backend/services/account_lifecycle.py index fb43a1a..ebc34bd 100644 --- a/web/backend/services/account_lifecycle.py +++ b/web/backend/services/account_lifecycle.py @@ -43,6 +43,7 @@ from .account_events_service import ( EVENT_STATUS_CHANGED, emit, ) +from .high_value_service import invalidate_high_value # ── 状态取值域(6 个)────────────────────────────────────── # 刻意**不做** `relogin`(退避重试中):它完全等价于 @@ -276,6 +277,19 @@ def on_ck_verdict( now = occurred_at or datetime.now(UTC) if verdict != "error": account.ck_check_status = verdict + # CK 失效 → 高价值结论**作废**。 + # + # 官方 storehome.status 是账号维度的,正常只随这个账号自己的兑换而变; + # 但 CK 失效那段时间,账号**可能被别人手工登录并兑换**(这些号共享给 + # 客服),缓存里的「可兑换」就不成立了。 + # + # ⚠️ 必须**在这里**作废(而不是只在展示层降级):CK 恢复之后,展示层 + # 就不再降级了 —— 那条陈旧的「可换」会重新显示成绿色,运营会拿一份 + # 失效结论去兑换。作废是单向的:等下一次真的读到官方 storehome 才写回。 + # + # `error`(查不到)**不**作废:查不到不等于账号出问题了。 + if verdict == "invalid": + invalidate_high_value(account) data = detail or {} account.ck_check_result = { "fish_ball": data.get("fish_ball"), diff --git a/web/backend/services/account_value.py b/web/backend/services/account_value.py index 8455256..316cdf6 100644 --- a/web/backend/services/account_value.py +++ b/web/backend/services/account_value.py @@ -2,7 +2,7 @@ 列表上的「高价值待救」和巡检 / 重登共用这里的口径,避免两套规则各说各的。 -分档只读账号上已经有的字段(兑换记录、积分、鱼翅、宝典),不看标签。 +分档只读账号上已经有的当前字段(官方准入三态、积分、鱼翅、宝典),不看标签。 数字越小越值钱: 1. 查过资料,兔兔白日梦和狂战萌兔都还没换 @@ -16,14 +16,20 @@ from __future__ import annotations -import json +from datetime import UTC, datetime, timedelta -from sqlalchemy import String, and_, case, cast, or_ +from sqlalchemy import and_, case, or_ from ..models import Account +from .high_value_service import ( + HIGH_VALUE_FRESH_SECONDS, + HIGH_VALUE_STATUS_AVAILABLE, + HIGH_VALUE_STATUS_OWNED, + HIGH_VALUE_STATUS_UNKNOWN, + HIGH_VALUE_UNKNOWN_RETRY_SECONDS, + high_value_fresh_cutoff, +) -SUIT_BAIRIMENG = "兔兔白日梦" -SUIT_MENGTU = "狂战萌兔" HIGH_VALUE_POINTS = 300 # 第 1、2 档都是「还值得抢救」的号。 @@ -33,42 +39,109 @@ HIGH_VALUE_TIER_MAX = 2 HIGH_VALUE_RECHECK_SECONDS = 6 * 3600 -def suit_owned_expr(column, text: str): - """JSON 列里是否包含这个套装名。MySQL 与 SQLite 都按字符串匹配。""" - escaped = json.dumps(text).strip('"') - return or_( - cast(column, String).like(f"%{text}%"), - cast(column, String).like(f"%{escaped}%"), - ) +def high_value_fresh_expr(*, now: datetime | None = None): + """高价值三态是否仍在有效期内。""" + cutoff = high_value_fresh_cutoff(now=now or datetime.now(UTC)) + return Account.high_value_checked_at >= cutoff -def is_suit_queried_expr(): - """判断账号是否有效完成过高价值套装查询。 +def suit_status_expr(key: str): + return getattr(Account, f"high_value_{key}_status") - 必须同时满足: - 1. 有查询时间 overview_queried_at - 2. high_value_owned 必须是有效 JSON 数组(排除 SQL NULL 与 JSON NULL) - """ + +def suit_available_expr(key: str, *, now: datetime | None = None): return and_( - Account.overview_queried_at.isnot(None), - Account.high_value_owned.isnot(None), - cast(Account.high_value_owned, String).like("[%"), + high_value_fresh_expr(now=now), + suit_status_expr(key) == HIGH_VALUE_STATUS_AVAILABLE, ) -def value_tier_expr(): - """1–5 档。没查过、也没有任何资产信号时落在第 5 档。""" - 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_(hv_queried, ~owns_bairimeng, ~owns_mengtu) - one_available = and_( - hv_queried, +def suit_owned_expr(key: str, *, now: datetime | None = None): + return and_( + high_value_fresh_expr(now=now), + suit_status_expr(key) == HIGH_VALUE_STATUS_OWNED, + ) + + +def high_value_unknown_expr(*, now: datetime | None = None): + """已检查过,但结果过期或至少一套官方状态未知。""" + return and_( + Account.high_value_checked_at.isnot(None), or_( - and_(~owns_bairimeng, owns_mengtu), - and_(owns_bairimeng, ~owns_mengtu), + ~high_value_fresh_expr(now=now), + suit_status_expr("bairimeng").is_(None), + suit_status_expr("bairimeng") == HIGH_VALUE_STATUS_UNKNOWN, + suit_status_expr("mengtu").is_(None), + suit_status_expr("mengtu") == HIGH_VALUE_STATUS_UNKNOWN, ), ) + + +def high_value_needs_refresh_expr( + *, + now: datetime | None = None, + max_seconds: int = HIGH_VALUE_FRESH_SECONDS, + unknown_seconds: int = HIGH_VALUE_UNKNOWN_RETRY_SECONDS, +): + """高价值结论**已经过期** —— 需要再拉一次官方 ``storehome``。 + + 和「资料是否过期」是**两件事**: + + - ``overview_queried_at`` 说「上次一键查询是什么时候」; + - ``high_value_checked_at`` 说「上次**成功读到官方商品状态**是什么时候」。 + + 两者会脱钩 —— 比如一键查询里商品接口单独失败(其它项都拿到了), + 资料时间被刷新了,高价值时间却停在原地。再比如 CK 失效那段时间查不动, + 账号恢复后高价值结论还挂着「查不到」。 + + 所以定时刷新必须看**后者**:高价值结论过期就该再查一次 —— 哪怕账号的 + 整体资料看起来还很新。 + + 三种情况: + + - **过期**(查过但超过 ``max_seconds``):尽快重拉 —— 结论太旧, + 而「可兑换」随时会变; + - **结论被作废**(状态是 ``unknown``,比如 CK 失效期间被作废): + 按 ``unknown_seconds`` 慢速重试; + - **从来没查到过**(``checked_at IS NULL``):**不算**。那种情况要么 + 账号确实没查过资料(本来就该被选中),要么商品接口对这个账号稳定失败 + (没配 manualID、账号没有宝典)—— 后者若每轮都重选,同一批账号会 + 反复撞墙,挤掉真正有新欠账的账号。它由「资料过期」那条兜着就够了。 + + ``max_seconds`` 由调用方给:默认 7 天,与列表上「待确认」的判定同源。 + """ + seconds = max(int(max_seconds or 0), 60) + unknown = max(int(unknown_seconds or 0), 60) + current = now or datetime.now(UTC) + cutoff = current - timedelta(seconds=seconds) + unknown_cutoff = current - timedelta(seconds=unknown) + unknown_status = or_( + suit_status_expr("bairimeng").is_(None), + suit_status_expr("bairimeng") == HIGH_VALUE_STATUS_UNKNOWN, + suit_status_expr("mengtu").is_(None), + suit_status_expr("mengtu") == HIGH_VALUE_STATUS_UNKNOWN, + ) + return and_( + Account.high_value_checked_at.isnot(None), + or_( + Account.high_value_checked_at < cutoff, + and_( + unknown_status, + Account.high_value_checked_at < unknown_cutoff, + ), + ), + ) + + +def value_tier_expr(*, now: datetime | None = None): + """1–5 档。没查过、也没有任何资产信号时落在第 5 档。""" + available_bairimeng = suit_available_expr("bairimeng", now=now) + available_mengtu = suit_available_expr("mengtu", now=now) + both_available = and_(available_bairimeng, available_mengtu) + one_available = or_( + available_bairimeng, + available_mengtu, + ) return case( (both_available, 1), (or_(one_available, Account.points >= HIGH_VALUE_POINTS), 2), @@ -78,12 +151,12 @@ def value_tier_expr(): ) -def is_high_value_expr(): +def is_high_value_expr(*, now: datetime | None = None): """第 1、2 档:巡检加密、重登插队、列表「高价值待救」都用这一条。""" - return value_tier_expr() <= HIGH_VALUE_TIER_MAX + return value_tier_expr(now=now) <= HIGH_VALUE_TIER_MAX -def suit_sort_rank_expr(): +def suit_sort_rank_expr(*, now: datetime | None = None): """列表「高价值套装」列的排序,和格子上看到的一致。数字越小越值钱。 0 查过、两套都还能换 @@ -93,18 +166,16 @@ def suit_sort_rank_expr(): 不掺积分、鱼翅。那是巡检分档的事,点这一列的人是在排套装。 """ - 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_(hv_queried, ~owns_bairimeng, ~owns_mengtu) - one_available = and_( - hv_queried, - or_( - and_(~owns_bairimeng, owns_mengtu), - and_(owns_bairimeng, ~owns_mengtu), - ), + available_bairimeng = suit_available_expr("bairimeng", now=now) + available_mengtu = suit_available_expr("mengtu", now=now) + owned_bairimeng = suit_owned_expr("bairimeng", now=now) + owned_mengtu = suit_owned_expr("mengtu", now=now) + both_available = and_(available_bairimeng, available_mengtu) + one_available = or_( + available_bairimeng, + available_mengtu, ) - both_owned = and_(hv_queried, owns_bairimeng, owns_mengtu) + both_owned = and_(owned_bairimeng, owned_mengtu) return case( (both_available, 0), (one_available, 1), diff --git a/web/backend/services/automation/jobs/account_overview_refresh.py b/web/backend/services/automation/jobs/account_overview_refresh.py index b8fc704..5ce4d83 100644 --- a/web/backend/services/automation/jobs/account_overview_refresh.py +++ b/web/backend/services/automation/jobs/account_overview_refresh.py @@ -48,6 +48,7 @@ from sqlalchemy.orm import Session from ....models import Account, AutomationJob from ...account_lifecycle import MANUAL_STATES +from ...account_value import high_value_needs_refresh_expr from ...douyu_runner import DouyuBatchRunner from ...douyu_service import cookie_operable_filter, create_douyu_planned_tasks @@ -126,6 +127,14 @@ def _base_query(db: Session, job: AutomationJob, now: datetime): or_( Account.overview_queried_at.is_(None), Account.overview_queried_at < cutoff, + # 高价值结论有**自己独立的时钟**(`high_value_checked_at`): + # 一键查询里商品接口单独失败时,资料时间被刷新了、高价值时间却 + # 停在原地;CK 失效那段时间查不动也一样。少了这一条,这类账号 + # 的高价值状态会**永久**停在旧结论,没人再拉它。 + # + # 用的是**同一把尺子**(作业上那个「同一账号多久重检」), + # 不是另设一个窗口 —— 否则界面上那个字段对本作业就又变成半死的。 + high_value_needs_refresh_expr(now=now, max_seconds=recheck_seconds), ), ) diff --git a/web/backend/services/douyu_runner_goods.py b/web/backend/services/douyu_runner_goods.py index 96b09fb..1509e44 100644 --- a/web/backend/services/douyu_runner_goods.py +++ b/web/backend/services/douyu_runner_goods.py @@ -13,6 +13,14 @@ from core.douyu import DouyuActivityClient, DouyuActivityError from ..models import Account, DouyuEsportsGoodsSnapshot, DouyuGoodsSnapshot, DouyuTask from .business_events import record_exchange +from .high_value_service import ( + DEFAULT_HIGH_VALUE_GOODS, + HIGH_VALUE_STATUS_OWNED, + apply_high_value_goods, + extract_high_value_goods, + high_value_key, + mark_high_value_owned, +) # 兑换节奏与重试(对齐 8.30 浏览器抓包:锁单->支付间隔约 2.2~4.2s;火爆类错误要长退避而不是秒级连打) DOUYU_EXCHANGE_PRE_CREATE_JITTER = (0.3, 1.2) @@ -65,15 +73,55 @@ class GoodsMixin: result = client.list_goods(manual_id=config["manual_id"], rid=config["rid"]) goods = result["goods"] self._upsert_goods(db, goods) + high_value_goods = extract_high_value_goods(goods, DEFAULT_HIGH_VALUE_GOODS, []) + apply_high_value_goods(account, high_value_goods) account.updated_at = datetime.now(UTC) self._mark_task( db, task, "success", f"已刷新商品 {len(goods)} 个", - {"goods_count": len(goods), "goods": goods}, + { + "goods_count": len(goods), + "goods": goods, + "high_value_goods": high_value_goods, + }, ) + def _refresh_high_value_after_exchange( + self, + client: DouyuActivityClient, + account: Account, + *, + manual_id: str, + rid: str, + commodity_name: str, + ) -> None: + """兑换成功后立即刷新官方准入;失败时只标记本次已换套装。""" + try: + goods = client.list_goods(manual_id=manual_id, rid=rid).get("goods") or [] + evaluated = extract_high_value_goods(goods, DEFAULT_HIGH_VALUE_GOODS, []) + apply_high_value_goods(account, evaluated) + key = high_value_key(commodity_name) + selected = next( + (item for item in evaluated if item.get("key") == key), None + ) + # ⚠️ 官方 storehome 有缓存延迟:刚兑换完立刻查,它可能还返回旧的 + # status=0(可兑换)。但**兑换成功是确定事实**,所以本次换掉的套装 + # 必须收口成「已达上限」,不能被随后的旧快照覆盖回「可兑换」。 + if key and ( + selected is None or selected.get("state") != HIGH_VALUE_STATUS_OWNED + ): + mark_high_value_owned(account, commodity_name) + except Exception as exc: # noqa: BLE001 旁路刷新,不能带崩已成功的兑换 + self._push_log( + "warning", f"兑换成功但刷新高价值准入失败,按本次兑换结果收口: {exc}" + ) + # 回查完全失败 = 两套的当前状态都查不到,一律记「未知」, + # 只有本次换掉的那套按「兑换成功」收口为「已达上限」。 + apply_high_value_goods(account, []) + mark_high_value_owned(account, commodity_name) + def _execute_refresh_esports_goods( self, db: Session, @@ -530,6 +578,13 @@ class GoodsMixin: points_after=(points_refresh or {}).get("points"), scope="elite", ) + self._refresh_high_value_after_exchange( + client, + account, + manual_id=manual_id, + rid=rid, + commodity_name=name, + ) self._mark_task( db, task, @@ -653,6 +708,9 @@ class GoodsMixin: manual_id=str(config["manual_id"]), rid=str(config["rid"]) ) limited = result["limited_goods"] + goods = (result.get("raw") or {}).get("data", {}).get("list") or [] + high_value_goods = extract_high_value_goods(goods, DEFAULT_HIGH_VALUE_GOODS, []) + apply_high_value_goods(account, high_value_goods) names = [ str(item.get("commodityName") or "") for item in limited @@ -669,7 +727,11 @@ class GoodsMixin: task, "success", message, - {"limited_count": len(limited), "limited_goods": limited}, + { + "limited_count": len(limited), + "limited_goods": limited, + "high_value_goods": high_value_goods, + }, ) def _execute_query_exchange_records( diff --git a/web/backend/services/douyu_runner_overview.py b/web/backend/services/douyu_runner_overview.py index 8b7b631..07a9fbe 100644 --- a/web/backend/services/douyu_runner_overview.py +++ b/web/backend/services/douyu_runner_overview.py @@ -36,9 +36,11 @@ from ..models import Account, DouyuTask 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, ...] = ("套装-兔兔白日梦", "套装-狂战萌兔") +from .high_value_service import ( + DEFAULT_HIGH_VALUE_GOODS, + apply_high_value_goods, + extract_high_value_goods, +) #: 官方兑换记录默认取多少条(完整记录可能很多,一次性拉回来没必要) EXCHANGE_RECORD_LIMIT = 20 @@ -46,115 +48,11 @@ 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 "套装" in cname - and not any(k in cname for k in SUIT_EXCLUDE_KEYWORDS) - ): - 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** + 官方兑换记录双重保障 - - 在斗鱼官方精英手册商城中: - - `item.status == 1`:斗鱼官方页面按钮展示为【已达兑换上限】(该账号限量 1 件已满,已兑换,不可再兑); - - `item.status == 0`:斗鱼官方页面按钮展示为【立即兑换】(该账号未兑换,可兑换); - - `records`(官方兑换记录):斗鱼官方常会清理历史兑换记录导致 records 返回空列表(0 条), - 但若 records 中仍存在记录,同样确认为已兑换;若商品已在商城下架不在列表里,records 作为历史兜底。 - """ - 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 = _match_suit_item(want, rows) - got = _match_suit_records(want, recs) - - # 🎯 核心判定: - # 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": 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, - "start_time": _ts(hit.get("startTime")) if hit else None, - "end_time": _ts(hit.get("endTime")) if hit else None, - "pic": str(hit.get("pic") or "") if hit else "", - "in_goods_list": hit is not None, - "missing": hit is None and not got, - "reason": reason, - } - ) - return out - - def _group_by_scope( pairs: list[tuple[str, dict[str, Any]]], ) -> dict[str, list[dict[str, Any]]]: @@ -357,8 +255,9 @@ class OverviewMixin: esports_manual_id = str(config.get("esports_manual_id") or "").strip() rid = str(config.get("rid") or "").strip() + queried_at = datetime.now(UTC) overview: dict[str, Any] = { - "queried_at": datetime.now(UTC).isoformat(), + "queried_at": queried_at.isoformat(), "assets": {}, "elite": {}, "esports": {}, @@ -370,6 +269,7 @@ class OverviewMixin: # (作用域, 官方记录) 扁平列表,便于补事件 all_records: list[tuple[str, dict[str, Any]]] = [] pending_goods: list[dict[str, Any]] = [] + goods_query_ok = False # ---- 鱼翅 + 钱包兑换余额 ---- try: @@ -436,12 +336,23 @@ class OverviewMixin: except Exception as exc: # noqa: BLE001 errors["esports"] = _err(exc) + # ---- 精英宝典商城商品列表(高价值套装的**唯一**判据来源)---- + # + # ⚠️ 必须用**精英宝典**的 manual_id(与 `refresh_goods` 任务一致), + # 而且**不能**寄生在上面的「电竞手册」分支里:那是两个无关的活动。 + # 之前商品列表被写在电竞手册的 else 分支内 —— 于是一旦没配 + # `esports_manual_id`,商城商品就永远拉不到,高价值三态就永远 + # 落不到 `available`/`owned`(全部显示「待确认」)。 + if not manual_id: + errors["limited_goods"] = "未配置精英宝典 manualID" + else: goods_items: list[dict[str, Any]] = [] try: # 🎯 拉取完整商城商品列表(含每个商品准确的 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) + goods_query_ok = True except Exception as exc: # noqa: BLE001 errors["limited_goods"] = _err(exc) # 商品列表带有 status 准入状态,结合兑换记录在后文判定高价值资产 @@ -545,6 +456,13 @@ class OverviewMixin: _goods_config(config), [rec for _scope, rec in all_records], ) + if goods_query_ok: + apply_high_value_goods( + account, + overview["high_value_goods"], + checked_at=queried_at, + ) + overview["high_value_checked_at"] = queried_at.isoformat() except Exception as exc: # noqa: BLE001 errors["high_value_goods"] = _err(exc) @@ -566,11 +484,7 @@ class OverviewMixin: # 落到账号上:任务会被保留策略清掉,而「这个账号现在什么情况」是账号的信息, # 应该在账号管理里看,不该依赖一条日志的寿命。 account.overview_snapshot = overview - account.overview_queried_at = datetime.now(UTC) - # 再落「已兑换过的高价值套装名」给列表用(列表不能带整份快照) - account.high_value_owned = [ - g["name"] for g in overview.get("high_value_goods", []) if g.get("owned") - ] + account.overview_queried_at = queried_at logger.info(f"[douyu] 一键查询账号 {account.id}: {message}") self._mark_task(db, task, "success", message, overview) diff --git a/web/backend/services/high_value_service.py b/web/backend/services/high_value_service.py new file mode 100644 index 0000000..4b7768e --- /dev/null +++ b/web/backend/services/high_value_service.py @@ -0,0 +1,277 @@ +"""斗鱼高价值套装的官方准入状态。 + +唯一裁判是精英宝典 ``storehome`` 商品列表中的账号级 ``status``: + +- ``0`` = 当前账号可兑换; +- ``1`` = 当前账号已达兑换上限; +- 商品缺失、列表为空、请求失败 = 未知。 + +兑换记录和积分记录会长期保留,但可能被官方清理或暂时查不到,因此只作历史展示, +不参与当前是否拥有的判定。 +""" + +from __future__ import annotations + +from datetime import UTC, datetime, timedelta +from typing import Any + +from ..models import Account + +HIGH_VALUE_STATUS_OWNED = "owned" +HIGH_VALUE_STATUS_AVAILABLE = "available" +HIGH_VALUE_STATUS_UNKNOWN = "unknown" + +HIGH_VALUE_KEYS: tuple[str, ...] = ("bairimeng", "mengtu") +HIGH_VALUE_NAMES: dict[str, str] = { + "bairimeng": "套装-兔兔白日梦", + "mengtu": "套装-狂战萌兔", +} +DEFAULT_HIGH_VALUE_GOODS: tuple[str, ...] = tuple(HIGH_VALUE_NAMES.values()) + +# 列表展示的“当前准入”最多接受 7 天前的官方结果;过期只能显示待确认。 +HIGH_VALUE_FRESH_SECONDS = 7 * 24 * 3600 + +#: 结论被作废(CK 失效期间)后,多久再拉一次官方状态。 +#: +#: 取 6 小时是**故意**的:CK 恢复之后,账号的高价值结论必须尽快重新确认 —— +#: 失效期间账号可能被别人兑换过,运营正要拿这条结论去兑换。 +#: 但也不能更密:商品接口对某些账号稳定失败(没配 manualID),那类账号的 +#: 结论会一直是 ``unknown``,每轮都重选就是反复撞墙。 +HIGH_VALUE_UNKNOWN_RETRY_SECONDS = 6 * 3600 + +# 防止头盔、背包等同一套装家族的散件被误当成整套。 +SUIT_EXCLUDE_KEYWORDS = ("头盔", "背包", "手榴弹", "挂件", "降落伞", "枪") + + +def high_value_key(name: str) -> str: + """把配置里的商品名映射为稳定的短键。""" + text = str(name or "").strip() + for key, suit_name in HIGH_VALUE_NAMES.items(): + if suit_name.replace("套装-", "") in text: + return key + return "" + + +def _match_suit_item(want: str, rows: list[dict[str, Any]]) -> dict[str, Any] | None: + """精确匹配整套,严格排除头盔、背包等散件。""" + for item in rows: + if str(item.get("commodityName") or "").strip() == want: + return item + + core = want.replace("套装-", "").strip() + for item in rows: + name = str(item.get("commodityName") or "").strip() + if ( + core in name + and "套装" in name + and not any(word in name for word in SUIT_EXCLUDE_KEYWORDS) + ): + return item + + for item in rows: + name = str(item.get("commodityName") or "").strip() + if core in name and not any(word in name for word in SUIT_EXCLUDE_KEYWORDS): + return item + return None + + +def _match_suit_records(want: str, records: list[dict[str, Any]]) -> list[str]: + """匹配历史记录,仅用于界面展示,不参与当前准入判定。""" + core = want.replace("套装-", "").strip() + matched: list[str] = [] + for record in records: + name = str(record.get("commodityName") or record.get("name") or "").strip() + if core in name and not any(word in name for word in SUIT_EXCLUDE_KEYWORDS): + matched.append(name) + return matched + + +def _official_state(item: dict[str, Any] | None) -> tuple[str, Any]: + if item is None: + return HIGH_VALUE_STATUS_UNKNOWN, None + status = item.get("status") + normalized = str(status).strip() if status is not None else "" + if normalized == "0": + return HIGH_VALUE_STATUS_AVAILABLE, status + if normalized == "1": + return HIGH_VALUE_STATUS_OWNED, status + return HIGH_VALUE_STATUS_UNKNOWN, status + + +def _ts(value: Any) -> Any: + """斗鱼用 0/空串表示没有时间,归一成 None。""" + if value is None or isinstance(value, bool): + return None + if isinstance(value, (int, float)): + return None if int(value) <= 0 else value + text = str(value).strip() + if not text: + return None + if text.lstrip("-").isdigit() and int(text) <= 0: + return None + return value + + +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]]: + """按官方 ``storehome.status`` 生成高价值套装的当前准入状态。""" + names = [str(name).strip() for name in (wanted or []) if str(name).strip()] + rows = [item for item in (items or []) if isinstance(item, dict)] + historical = [item for item in (records or []) if isinstance(item, dict)] + result: list[dict[str, Any]] = [] + + for wanted_name in names: + item = _match_suit_item(wanted_name, rows) + state, raw_status = _official_state(item) + obtained_names = _match_suit_records(wanted_name, historical) + if state == HIGH_VALUE_STATUS_OWNED: + reason = "官方商城显示已达兑换上限" + elif state == HIGH_VALUE_STATUS_AVAILABLE: + reason = "官方商城显示当前可兑换" + elif item is None: + reason = "官方商城未返回该套装,当前状态未知" + else: + reason = f"官方商城状态异常(status={raw_status!r})" + if obtained_names and state != HIGH_VALUE_STATUS_UNKNOWN: + reason += ";历史兑换记录仅供展示" + + result.append( + { + "key": high_value_key(wanted_name), + "name": str(item.get("commodityName") or wanted_name) + if item + else wanted_name, + "commodity_id": str(item.get("commodityId") or "") if item else "", + "state": state, + # 兼容详情页旧字段;是否拥有的唯一来源仍是官方 status。 + "owned": state == HIGH_VALUE_STATUS_OWNED, + "available": state == HIGH_VALUE_STATUS_AVAILABLE, + "status": raw_status, + "obtained_names": obtained_names, + "score": item.get("score") if item else None, + "count": item.get("count") if item else None, + "count_limited": item.get("countLimited") if item else None, + "start_time": _ts(item.get("startTime")) if item else None, + "end_time": _ts(item.get("endTime")) if item else None, + "pic": str(item.get("pic") or "") if item else "", + "in_goods_list": item is not None, + "missing": item is None, + "reason": reason, + } + ) + return result + + +def apply_high_value_goods( + account: Account, + goods: list[dict[str, Any]], + *, + checked_at: datetime | None = None, +) -> None: + """把一次成功读取的官方商城结果写到账号当前状态。""" + by_key = { + str(item.get("key") or high_value_key(str(item.get("name") or ""))): item + for item in goods + if isinstance(item, dict) + } + for key in HIGH_VALUE_KEYS: + item = by_key.get(key) or {} + state = str(item.get("state") or HIGH_VALUE_STATUS_UNKNOWN) + if state not in { + HIGH_VALUE_STATUS_OWNED, + HIGH_VALUE_STATUS_AVAILABLE, + HIGH_VALUE_STATUS_UNKNOWN, + }: + state = HIGH_VALUE_STATUS_UNKNOWN + setattr(account, f"high_value_{key}_status", state) + account.high_value_checked_at = checked_at or datetime.now(UTC) + + +def mark_high_value_owned( + account: Account, + commodity_name: str, + *, + checked_at: datetime | None = None, +) -> bool: + """官方兑换成功后立即收口单个套装状态。 + + 正常路径会再查一次 ``storehome``;这个函数是查询失败时的保守兜底, + 依据是官方兑换接口已经成功,不再依赖可能被清理的兑换记录。 + """ + key = high_value_key(commodity_name) + if not key: + return False + setattr(account, f"high_value_{key}_status", HIGH_VALUE_STATUS_OWNED) + account.high_value_checked_at = checked_at or datetime.now(UTC) + return True + + +def invalidate_high_value(account: Account) -> None: + """CK 失效时把高价值结论**作废**(降为「待确认」)。 + + 为什么 CK 一失效就要作废:官方 ``storehome.status`` 是**账号维度**的, + 正常只随这个账号自己的兑换而变。但 CK 失效那段时间,账号**可能被别人 + 手工登录并兑换**(这些号是共享给客服的)—— 这时缓存里的「可兑换」就 + 不成立了。 + + 更要命的是 CK 恢复之后:如果不作废,``high_value_payload`` 不再降级, + 那条陈旧的「可换」会**重新显示成绿色**,运营就会拿着一份失效结论去兑换。 + + 所以作废是**单向**的:写 ``unknown``,等下一次真的读到官方 ``storehome`` + 再写回真实的 ``available``/``owned``。``checked_at`` 保留(那确实是 + 「上次成功读到官方状态」的时间),但结论本身已经不算数。 + """ + for key in HIGH_VALUE_KEYS: + setattr(account, f"high_value_{key}_status", HIGH_VALUE_STATUS_UNKNOWN) + + +def high_value_payload( + account: Account, *, now: datetime | None = None +) -> dict[str, Any]: + """给列表/详情使用的紧凑三态结果。""" + current = now or datetime.now(UTC) + 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"} + stale = bool( + ck_broken + or ( + checked_at + and current - checked_at > timedelta(seconds=HIGH_VALUE_FRESH_SECONDS) + ) + ) + values = { + key: str(getattr(account, f"high_value_{key}_status", "") or "") + for key in HIGH_VALUE_KEYS + } + has_unknown = any( + value not in {HIGH_VALUE_STATUS_OWNED, HIGH_VALUE_STATUS_AVAILABLE} + for value in values.values() + ) + if checked_at is None: + state = "never" + elif stale: + state = "stale" + elif has_unknown: + state = "partial" + else: + state = "complete" + return { + "state": state, + "checked_at": checked_at, + "stale": stale, + "fresh": state in {"complete", "partial"}, + **values, + "names": HIGH_VALUE_NAMES, + } + + +def high_value_fresh_cutoff(*, now: datetime | None = None) -> datetime: + current = now or datetime.now(UTC) + return current - timedelta(seconds=HIGH_VALUE_FRESH_SECONDS) diff --git a/web/frontend/src/api/types.ts b/web/frontend/src/api/types.ts index 56be72d..421a4eb 100644 --- a/web/frontend/src/api/types.ts +++ b/web/frontend/src/api/types.ts @@ -98,10 +98,31 @@ export interface HighValueSummary { can_bairimeng: number; can_mengtu: number; unqueried: number; + high_value_unknown: number; + high_value_never: number; high_points: number; has_gold: number; elite_opened: number; high_value_broken: number; + owned_bairimeng: number; + owned_mengtu: number; + both_owned: number; +} + +export type HighValueAssetState = 'owned' | 'available' | 'unknown' | ''; +export type HighValueQueryState = 'never' | 'partial' | 'stale' | 'complete'; + +export interface HighValueStatus { + state: HighValueQueryState; + checked_at: string | null; + stale: boolean; + fresh: boolean; + bairimeng: HighValueAssetState; + mengtu: HighValueAssetState; + names: { + bairimeng: string; + mengtu: string; + }; } export interface BasicSummary { @@ -222,8 +243,8 @@ export interface AccountOverview { stats: AccountOverviewStats; /** 最近一次「一键查询」的快照(结构与后端 query_account_overview 的 result 一致) */ overview_snapshot: import('../pages/douyu-tasks/AccountOverviewPanel').AccountOverviewResult | null; - /** 一键查询得到的已兑换高价值套装,为 null 时表示未查询 */ - high_value_owned: string[] | null; + /** 官方 storehome 的三态准入,不知道时绝不等于可兑换 */ + high_value: HighValueStatus; overview_queried_at: string | null; /** 开通时间来自事件表(上线后才开始记录,更早的不显示) */ opened_at: { elite: string | null; esports: string | null }; @@ -374,9 +395,9 @@ export interface AccountItem { exchange_count: number; last_exchange_at: string | null; last_exchange_name: string; - /** 一键查询得到的「已兑换过的高价值套装」,为 null 时表示未查询 */ - high_value_owned: string[] | null; - /** 最近一次一键查询时间 —— 用来区分「查过但没兑换」和「还没查过」 */ + /** 官方 storehome 的三态准入 */ + high_value: HighValueStatus; + /** 最近一次完整一键查询时间 */ overview_queried_at: string | null; } diff --git a/web/frontend/src/pages/AccountDetailPage.tsx b/web/frontend/src/pages/AccountDetailPage.tsx index 8fc9dfa..01601e2 100644 --- a/web/frontend/src/pages/AccountDetailPage.tsx +++ b/web/frontend/src/pages/AccountDetailPage.tsx @@ -7,7 +7,7 @@ import type { TableProps } from 'antd'; import { ArrowLeftOutlined, EditOutlined, ReloadOutlined, ThunderboltOutlined, } from '@ant-design/icons'; -import { accountApi, douyuApi, userApi, type AccountEventItem, type AccountOverview, type UserInfo } from '../api/modules'; +import { accountApi, douyuApi, userApi, type AccountEventItem, type AccountOverview, type HighValueAssetState, type HighValueStatus, type UserInfo } from '../api/modules'; import AccountOverviewPanel from './douyu-tasks/AccountOverviewPanel'; import { getErrorMessage } from '../utils/error'; import { message } from '../utils/antdMessage'; @@ -64,6 +64,33 @@ const BIND_TONES: Record = { unknown: 'default', }; +function highValueShortTag(label: string, status: HighValueAssetState) { + if (status === 'owned') return {label}已达上限; + if (status === 'available') return {label}可换; + return {label}待确认; +} + +function CurrentHighValue({ value }: { value: HighValueStatus }) { + if (value.state === 'never') { + return 尚未成功获取官方商城准入状态; + } + if (value.state === 'stale') { + return ( + + 官方状态已过期,需要重新查询 + + ); + } + return ( + + + {highValueShortTag('白日梦', value.bairimeng)} + {highValueShortTag('萌兔', value.mengtu)} + + + ); +} + function eventTag(type: string) { const style = EVENT_STYLE[type] ?? { label: type, color: 'default' }; return {style.label}; @@ -665,6 +692,14 @@ export default function AccountDetailPage() { {ovError} )} + {overview?.high_value && ( +
+ + 当前官方准入: + + +
+ )} {overview?.overview_snapshot ? ( ) : ( diff --git a/web/frontend/src/pages/accounts/components/AccountColumns.tsx b/web/frontend/src/pages/accounts/components/AccountColumns.tsx index 699f38a..d1ba7e5 100644 --- a/web/frontend/src/pages/accounts/components/AccountColumns.tsx +++ b/web/frontend/src/pages/accounts/components/AccountColumns.tsx @@ -2,7 +2,7 @@ import type { TableProps } from 'antd'; import { Space, Button, Typography, Tag, Tooltip, Select, Popconfirm } from 'antd'; import { CopyOutlined, DeleteOutlined } from '@ant-design/icons'; import type { NavigateFunction } from 'react-router-dom'; -import type { AccountItem } from '../../../api/modules'; +import type { AccountItem, HighValueAssetState, HighValueStatus } from '../../../api/modules'; import { message } from '../../../utils/antdMessage'; import { copyTextToClipboard } from '../../../utils/clipboard'; import { formatTimeShort } from '../../../utils/time'; @@ -39,6 +39,49 @@ function BindChip({ scope, status }: { scope: string; status?: string }) { ); } +function highValueAssetTag( + shortName: string, + state: HighValueAssetState, +) { + if (state === 'owned') { + return {shortName}已换; + } + if (state === 'available') { + return {shortName}可换; + } + return {shortName}待确认; +} + +function renderHighValueStatus(highValue: HighValueStatus | undefined) { + if (!highValue || highValue.state === 'never') { + return ( + + 未查询 + + ); + } + if (highValue.state === 'stale') { + return ( + + 数据已过期 + + ); + } + const checked = highValue.checked_at + ? `官方 storehome 检查于 ${formatTimeShort(highValue.checked_at)}` + : '官方 storehome 状态'; + return ( + + + {highValueAssetTag('白日梦', highValue.bairimeng)} + {highValueAssetTag('萌兔', highValue.mengtu)} + + + ); +} + export interface UseAccountColumnsOptions { navigate: NavigateFunction; sortKey: AccountSortKey | ''; @@ -270,39 +313,23 @@ export function buildAccountColumns({ }, { title: '高价值套装', - dataIndex: 'high_value_owned', + dataIndex: 'high_value', ...sortProps('high_value', '两个都可兑换在前,未查询在后'), - width: 170, - render: (owned: string[] | null | undefined, record: AccountItem) => { - if (owned === null || owned === undefined || !record.overview_queried_at) { - return ( - - 未查询 - - ); - } - if (owned.length === 0) { - return 两个都可兑换; - } - return ( - - {owned.map((n) => ( - - {n.replace(/^套装-/, '')} 已达上限 - - ))} - - ); - }, + width: 190, + render: (_, record: AccountItem) => renderHighValueStatus(record.high_value), }, { - title: '兑换记录', + title: '官方兑换记录', dataIndex: 'exchange_count', - ...sortProps('exchange', '未兑换在前'), + ...sortProps('exchange', '没有官方记录在前'), width: 170, render: (_, record: AccountItem) => { if (!record.exchange_count) { - return 未兑换; + return ( + + 无官方记录 + + ); } return ( diff --git a/web/frontend/src/pages/accounts/components/FilterPanel.tsx b/web/frontend/src/pages/accounts/components/FilterPanel.tsx index a7f31c1..678c84d 100644 --- a/web/frontend/src/pages/accounts/components/FilterPanel.tsx +++ b/web/frontend/src/pages/accounts/components/FilterPanel.tsx @@ -217,6 +217,58 @@ export function FilterPanel({ setCurrentPage(1); }, }, + { + key: 'high_value_unknown', + label: '高价值待确认', + icon: '❓', + count: summary.high_value?.high_value_unknown, + color: '#8c8c8c', + tooltip: '官方商城未返回套装、查询部分失败,或上次结果已超过 7 天', + active: + highValueFilter === 'high_value_unknown' && + minPoints === undefined && + maxPoints === undefined && + minGold === undefined && + maxGold === undefined && + eliteOpened === undefined && + esportsOpened === undefined && + !roleBindStatus, + onClick: () => { + if (highValueFilter === 'high_value_unknown') { + setHighValueFilter(''); + } else { + onResetAdvancedFiltersState(); + setHighValueFilter('high_value_unknown'); + } + setCurrentPage(1); + }, + }, + { + key: 'high_value_never', + label: '未读到商城', + icon: '❔', + count: summary.high_value?.high_value_never, + color: '#8c8c8c', + tooltip: '从未成功读到官方商城准入状态:商城接口失败,或未配置精英宝典 manualID', + active: + highValueFilter === 'high_value_never' && + minPoints === undefined && + maxPoints === undefined && + minGold === undefined && + maxGold === undefined && + eliteOpened === undefined && + esportsOpened === undefined && + !roleBindStatus, + onClick: () => { + if (highValueFilter === 'high_value_never') { + setHighValueFilter(''); + } else { + onResetAdvancedFiltersState(); + setHighValueFilter('high_value_never'); + } + setCurrentPage(1); + }, + }, { key: 'high_points', label: '积分≥300', diff --git a/web/frontend/src/pages/accounts/types.ts b/web/frontend/src/pages/accounts/types.ts index 441b971..d102398 100644 --- a/web/frontend/src/pages/accounts/types.ts +++ b/web/frontend/src/pages/accounts/types.ts @@ -82,6 +82,8 @@ export const HIGH_VALUE_OPTIONS = [ { value: 'owned_mengtu', label: '✓ 已拥有萌兔' }, { value: 'both_owned', label: '✓ 两套均已拥有' }, { value: 'high_value_broken', label: '⚠️ 高价值但CK失效' }, + { value: 'high_value_unknown', label: '❓ 高价值待确认/已过期' }, + { value: 'high_value_never', label: '❓ 从未读到官方商城状态' }, { value: 'unqueried', label: '❓ 未查过资料' }, ]; diff --git a/web/frontend/src/pages/douyu-tasks/AccountOverviewPanel.tsx b/web/frontend/src/pages/douyu-tasks/AccountOverviewPanel.tsx index 0540cab..69ba61f 100644 --- a/web/frontend/src/pages/douyu-tasks/AccountOverviewPanel.tsx +++ b/web/frontend/src/pages/douyu-tasks/AccountOverviewPanel.tsx @@ -5,22 +5,27 @@ const { Text } = Typography; /** 一键查询的结果结构(后端 `query_account_overview` 写入 task.result)。 */ export interface HighValueGoods { + key?: 'bairimeng' | 'mengtu'; name: string; commodity_id?: string; score?: number | null; count?: number | null; + status?: number | string | null; store_status?: number | null; count_limited?: number | null; start_time?: string | number | null; end_time?: string | number | null; pic?: string; - /** **是否已获得** —— 依据是斗鱼官方的账号级兑换记录,不是全局库存 */ + /** 是否已达兑换上限 —— 唯一依据是官方 storehome.status */ owned?: boolean | null; + available?: boolean | null; + /** owned / available / unknown */ + state?: 'owned' | 'available' | 'unknown'; /** 官方兑换记录里命中的商品名(可能是「破片手榴弹-兔兔白日梦」这类变体) */ obtained_names?: string[]; /** 该商品当前是否在限兑列表里(全局信息,与"是否已获得"无关) */ in_goods_list?: boolean; - /** 限兑列表和兑换记录里都没有这个商品 */ + /** 官方商城本次没有返回这个商品 */ missing?: boolean; reason?: string; } @@ -67,6 +72,7 @@ const ERROR_LABELS: Record = { points: '积分', elite: '精英宝典', esports: '电竞手册', + limited_goods: '官方商城商品列表', high_value_goods: '高价值套装', exchange_records: '兑换记录', }; @@ -97,13 +103,6 @@ function formatTime(value: string | number | null | undefined): string { } -/** - * 高价值套装卡片。 - * - * ⚠️ 措辞刻意不用「已拥有」:`storeStatus=0` 也可能是**全局售罄/活动下架**, - * 我们没法只凭它断言账号真的拿到了。所以写「不可再兑」并把 count 一起显示, - * 让运营自己判断 —— 这比给一个可能错的结论好。 - */ /** * 高价值套装的展示状态。 * @@ -112,19 +111,13 @@ function formatTime(value: string | number | null | undefined): string { * 兑换过 → 「已兑换」**橙色**(价值降低) * 同一件东西两个都兑换过,账号价值最低。 * - * 「是否已获得」只看**官方兑换记录**:`count`/`store_status` 是全局库存, - * 同一时刻不同账号看到的都一样,不能用来判断这个账号有没有。 + * 当前准入只看官方 storehome.status:1=已达上限、0=可兑换。 + * count/foreverCount/storeStatus 是全局库存或商店状态,不能判断账号是否已换。 */ function goodsState(g: HighValueGoods): { text: string; color: string } { - if (g.missing) return { text: '未查到该商品', color: 'default' }; - if (g.owned) { - const isLimit = g.reason?.includes('已达兑换上限'); - return { - text: isLimit ? '已达上限(不可兑)' : '已兑换(价值降低)', - color: 'orange', - }; - } - return { text: '可兑换', color: 'green' }; + if (g.state === 'owned') return { text: '已达上限(不可兑)', color: 'orange' }; + if (g.state === 'available') return { text: '可兑换', color: 'green' }; + return { text: '状态未知', color: 'default' }; } const goodsColumns: ColumnsType = [ @@ -135,7 +128,7 @@ const goodsColumns: ColumnsType = [ width: 200, render: (name: string, g) => g.missing ? ( - + {name} ) : ( @@ -162,8 +155,11 @@ const goodsColumns: ColumnsType = [ key: 'reason', width: 220, render: (_, g) => ( - - {g.reason || (g.owned ? '官方限制兑换' : '官方商城可正常兑换')} + + {g.reason || (g.state === 'available' ? '官方商城可正常兑换' : '等待官方状态')} ), },