fix(account): 修一键查询「一直循环」—— 快照字段加到了列表接口而不是详情接口
用户报:账号详情点「一键查询」会一直循环(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。
This commit is contained in:
@@ -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
|
||||
|
||||
@@ -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,体积爆炸)"
|
||||
)
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user