From c2520613ca6a32ec4739fdd74262aa9b4e29143f Mon Sep 17 00:00:00 2001 From: yml2213 Date: Sun, 4 Oct 2026 19:19:53 +0800 Subject: [PATCH] =?UTF-8?q?fix(account):=20=E4=BF=AE=E4=B8=80=E9=94=AE?= =?UTF-8?q?=E6=9F=A5=E8=AF=A2=E3=80=8C=E4=B8=80=E7=9B=B4=E5=BE=AA=E7=8E=AF?= =?UTF-8?q?=E3=80=8D=E2=80=94=E2=80=94=20=E5=BF=AB=E7=85=A7=E5=AD=97?= =?UTF-8?q?=E6=AE=B5=E5=8A=A0=E5=88=B0=E4=BA=86=E5=88=97=E8=A1=A8=E6=8E=A5?= =?UTF-8?q?=E5=8F=A3=E8=80=8C=E4=B8=8D=E6=98=AF=E8=AF=A6=E6=83=85=E6=8E=A5?= =?UTF-8?q?=E5=8F=A3?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 用户报:账号详情点「一键查询」会一直循环(Network 里刷一片 `overview` 请求)。 ## 根因 上一轮我把 `overview_snapshot` / `overview_queried_at` 加到了**列表**接口 (锚点 `**bind_payload(acc),` 第一次出现的位置在列表构造器里),详情接口没有。 两层后果: 1. **详情不返回 `overview_queried_at`** → 前端 `marker !== before` 永远相等 → 轮询到 120 秒超时。看起来就是"点一下一直转圈刷请求"。 2. **列表每个账号都带一整份 JSON 快照** → 15000 个账号的响应体积爆炸 (这个是我自己引入的回归,比第一个更隐蔽)。 ## 修法 - 字段从**列表**移除、加到**详情**(列表刻意不返回,注释写明原因) - 前端轮询**加自证**:第一次拿到响应时若没有标记字段,**立刻报错退出**, 而不是傻等 120 秒 —— 这类"字段没返回"的 bug 不该再以死循环的形式暴露 ## ⚠️ 过程中我又踩了一个,被既有用例抓住 删那 4 行时**把 `**bind_payload(acc),` 一起删掉了**,导致列表不再返回绑定列 (`role_bind_status` 显示的是 Pydantic 默认值 `unknown`)。 `TestListViewKeyColumns::test_item_carries_key_columns` **立刻变红** —— 既有用例替我把这个真回归挡下来了。已恢复,并确认 diff 只剩预期的 3 行。 ## 新增回归用例(+2,共 38) `TestSnapshotExposedOnDetailOnly`: - 详情**必须**返回快照与查询时间(不返回前端就会轮询到超时) - 列表**不得**返回完整快照(每个账号一份 JSON,体积爆炸) 并做了**反向验证**:把"详情不返回字段"的 bug 注入回去 → 用例立刻红。 另外把 `TestListViewKeyColumns::_item` 按用户名查找改成按 id —— `make_account("key")` 每次建新账号而库里一直累积,按用户名可能命中很早以前那个 (这次就让用例假红过一次)。 ## 验证 `TZ=Asia/Shanghai` **590 passed / 6 skipped**;`TZ=UTC` 80 passed; ruff 全绿;前端 build 通过、lint 0 errors。 --- tests/test_account_lifecycle.py | 14 +++- tests/test_account_overview.py | 69 ++++++++++++++++++++ web/backend/routers/accounts.py | 6 +- web/frontend/src/pages/AccountDetailPage.tsx | 20 ++++-- 4 files changed, 101 insertions(+), 8 deletions(-) diff --git a/tests/test_account_lifecycle.py b/tests/test_account_lifecycle.py index 75a6ef2..7ff66c8 100644 --- a/tests/test_account_lifecycle.py +++ b/tests/test_account_lifecycle.py @@ -745,6 +745,18 @@ class TestListViewKeyColumns(_Base): return item raise AssertionError(f"列表里没有 {username}") + def _item_by_id(self, account, **kwargs): + """按 **id** 取刚建的那条。 + + ⚠️ 按用户名取是不可靠的:`make_account("key")` 每次都建**新**账号, + 测试库却一直在累积,于是"用户名 == key"可能命中**很早以前**建的那个 + (它的绑定状态当然是 unknown)。这个用例就因此假红过一次。 + """ + for item in self._items(**kwargs): + if item.id == account.id: + return item + raise AssertionError(f"列表里没有 id={account.id}") + def test_item_carries_key_columns(self): from web.backend.services.account_binding import ( BIND_BOUND, @@ -758,7 +770,7 @@ class TestListViewKeyColumns(_Base): set_bind_status(account, SCOPE_ELITE, BIND_BOUND) self.session.commit() - item = self._item("key") + item = self._item_by_id(account) assert item.has_cookie is True assert item.ck_check_status == "valid" assert item.points == 1234 diff --git a/tests/test_account_overview.py b/tests/test_account_overview.py index c9a778d..90131b9 100644 --- a/tests/test_account_overview.py +++ b/tests/test_account_overview.py @@ -506,3 +506,72 @@ class TestSnapshotIsWrittenToAccount: assert self.account.overview_snapshot["assets"]["gold_balance"] == 7 assert self.account.overview_snapshot is not None assert first["assets"]["gold_balance"] == 7 + + +class TestSnapshotExposedOnDetailOnly: + """快照必须出现在**详情**接口,**不能**出现在列表接口。 + + 这里踩过一个坑:字段被加到了列表接口而不是详情接口,后果有两层 —— + + 1. 详情不返回 `overview_queried_at` → 前端轮询永远检测不到变化 → + 一直刷请求到 120 秒超时(用户报的「点一键查询就一直循环」); + 2. 列表**每个账号**都带一整份 JSON 快照 → 15000 个账号的响应体积爆炸。 + + 所以两头都要钉住。 + """ + + def setup_method(self): + self.engine = get_test_engine() + self.session = make_test_session(self.engine) + self.admin = User(username="snapx-admin", password_hash="h", role="super_admin") + self.session.add(self.admin) + self.session.flush() + self.account = Account( + username="snapx-acc", + password="p", + email="x@e.com", + email_password="ep", + cookie="acf_uid=1; acf_ccn=c", + overview_snapshot={"assets": {"gold_balance": 3}}, + overview_queried_at=datetime.now(UTC), + ) + self.session.add(self.account) + self.session.commit() + + def teardown_method(self): + self.session.close() + + def test_detail_returns_snapshot_and_timestamp(self): + from web.backend.routers.accounts import account_overview + + result = account_overview(self.account.id, db=self.session, current=self.admin) + assert "overview_snapshot" in result, "详情必须返回快照,否则前端没法展示" + assert "overview_queried_at" in result, ( + "详情必须返回查询时间 —— 前端靠它判断「查完了没有」," + "不返回就会一直轮询到超时" + ) + assert result["overview_queried_at"] is not None + + def test_list_does_not_carry_the_snapshot(self): + """列表里带整份快照会让 15000 个账号的响应爆炸。""" + from web.backend.routers.accounts import list_accounts + + # 直接调用时**未显式传**的参数拿到的是 FastAPI 的 `Query` 对象,而它是 + # truthy 的(`assigned_only=Query(False)` 会被当成 True,只返回分配给 + # 自己的账号)—— 所以关键参数都要显式传。 + # 另外测试库里有几百个账号,必须用 search 收敛到夹具本身,否则会翻页。 + result = list_accounts( + assigned_only=False, + has_cookie=False, + search="snapx-acc", + page=1, + page_size=20, + db=self.session, + current=self.admin, + ) + items = result["items"] if isinstance(result, dict) else result + assert items, "夹具账号应出现在列表里" + for item in items: + assert "overview_snapshot" not in item, ( + "列表接口不该返回完整快照(每个账号一份 JSON,体积爆炸)" + ) diff --git a/web/backend/routers/accounts.py b/web/backend/routers/accounts.py index 02b9213..dd90412 100644 --- a/web/backend/routers/accounts.py +++ b/web/backend/routers/accounts.py @@ -306,9 +306,6 @@ 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), - # 最近一次「一键查询」快照:存在账号上,不依赖任务日志的寿命 - overview_snapshot=acc.overview_snapshot, - overview_queried_at=acc.overview_queried_at, ) # 只有管理员可看完整字段(密码、邮箱等) if can_include_sensitive: @@ -1001,6 +998,9 @@ def account_overview( "game_name": acc.game_name, "game_channel": acc.game_channel, **bind_payload(acc), + # 最近一次「一键查询」快照(列表接口刻意**不**返回,避免体积爆炸) + "overview_snapshot": acc.overview_snapshot, + "overview_queried_at": acc.overview_queried_at, "elite_opened": acc.elite_opened, "elite_expire_time": acc.elite_expire_time, "esports_points": acc.esports_points, diff --git a/web/frontend/src/pages/AccountDetailPage.tsx b/web/frontend/src/pages/AccountDetailPage.tsx index ac021e5..9e819b5 100644 --- a/web/frontend/src/pages/AccountDetailPage.tsx +++ b/web/frontend/src/pages/AccountDetailPage.tsx @@ -139,8 +139,12 @@ export default function AccountDetailPage() { // 一键查询:建一个只读聚合任务 → 轮询**账号自身**直到快照更新。 // - // 结果写在账号上(`overview_snapshot`),不是只留在任务里 —— - // 所以轮询账号就够了,也不依赖任务日志的寿命。 + // 结果写在账号上(`overview_snapshot`),所以轮询账号就够了。 + // + // ⚠️ 轮询必须**能自证在推进**。之前的 bug:详情接口忘了返回 + // `overview_queried_at`(字段加到了列表接口上),于是比较永远相等、 + // 轮询满 120 秒 —— 用户看到的就是"点一下就一直转圈刷请求"。 + // 所以这里加了:拿不到标记字段就**立刻报错退出**,而不是傻等。 const runOverview = useCallback(async () => { if (!id) return; const accountId = Number(id); @@ -153,11 +157,19 @@ export default function AccountDetailPage() { task_type: 'query_account_overview', handbook_scope: 'elite', }); - const deadline = Date.now() + 120_000; // 要串好几个斗鱼请求,给足时间 + const deadline = Date.now() + 120_000; + let first = true; while (Date.now() < deadline) { await new Promise((resolve) => setTimeout(resolve, 2000)); const fresh = await accountApi.overview(accountId); - if ((fresh.overview_queried_at ?? '') !== before) { + const marker = fresh.overview_queried_at; + if (first && marker === undefined) { + // 接口没返回标记字段 —— 再怎么轮询也不可能变化,直接如实报错 + setOvError('接口未返回查询时间标记,无法判断是否完成(请检查后端详情接口)'); + return; + } + first = false; + if ((marker ?? '') !== before) { setOverview(fresh); message.success('查询完成'); return;