refactor(account): 一键查询不该重复展示账号字段 —— 改成刷新它们,只留账号表里没有的

用户反馈:「一键查询也太重复了,直接更新账号信息不行吗?为啥很多都要再展示一次?
那建立账号信息表有啥用」——**说得对,是我设计错了**。

## 问题

面板把鱼翅 / 积分 / 钱包余额 / 精英宝典 / 电竞手册又原样列了一遍,
而这些账号表(`accounts`)本来就有对应列,账号详情也在显示同一批值 ——
同一屏出现两份,既冗余又让人分不清哪份是准的。

## 改法:查询 = 刷新,不是另开副本

| 内容 | 处理 |
| --- | --- |
| 鱼翅 / 积分 / 交易余额 / 开通状态 | 写回**账号表**(`_refresh_*` 本来就在写),由账号详情已有界面展示 —— **面板不再重复** |
| **积分到期时间** | 有 `accounts.points_expire_at` 列,但**查询漏了没写、界面也没显示** → 现在写回,并加到账号信息区展示 |
| **高价值套装是否已获得** | 账号表没有 → **只有这个该单独展示** |
| **官方兑换记录** | 账号表没有 → 单独展示 |

面板因此瘦身成只含「🎯 高价值套装」+「兑换记录」(+ 错误提示 + 查询时间),
标题也改成「高价值套装与兑换记录(一键查询)」,不再叫"账号信息"。

## 修了一个连带 bug

积分到期时间一直没被查询写回账号(只在快照里躺着),界面也没展示 ——
现在 `_parse_expire_at` 后写入 `accounts.points_expire_at`,
且斗鱼返回 `0` 时写 `None` 而不是 `0`(否则又显示 1970/1/1)。

## 测试(+3,共 41)

`TestOverviewRefreshesAccountFields`:资产/开通状态确实写回账号列、
**积分到期时间写回**、`willExpireTime="0"` 时清成 None 而非 0。

## 验证

`TZ=Asia/Shanghai` **593 passed / 6 skipped**;ruff 全绿;前端 build 通过、lint 0 errors。
This commit is contained in:
yml2213
2026-10-04 19:22:34 +08:00
parent c2520613ca
commit aea05952af
6 changed files with 137 additions and 47 deletions
+106
View File
@@ -575,3 +575,109 @@ class TestSnapshotExposedOnDetailOnly:
assert "overview_snapshot" not in item, (
"列表接口不该返回完整快照(每个账号一份 JSON,体积爆炸)"
)
class TestOverviewRefreshesAccountFields:
"""查询的职责是**刷新账号字段**,不是另开一份副本。
用户反馈很直接:「一键查询这也太重复了,直接更新账号信息不行吗?
那建立账号信息表有啥用」。
对 —— 鱼翅 / 积分 / 交易余额 / 开通状态账号表本来就有对应列,
查询把新值写进去、由已有界面展示即可。这里钉住写回行为,
尤其是**积分到期时间**:它有 `accounts.points_expire_at` 列,
但之前忘了写(也没在界面上显示)。
"""
def setup_method(self):
self.engine = get_test_engine()
self.session = make_test_session(self.engine)
self.user = User(username="rf-admin", password_hash="h", role="super_admin")
self.session.add(self.user)
self.session.flush()
self.account = Account(
username="rf-acc", password="p", email="r@e.com", email_password="ep"
)
self.session.add(self.account)
self.session.commit()
def teardown_method(self):
self.session.close()
def _run(self, *, expire_at: str = "1795967999"):
from web.backend.services.douyu_runner_overview import OverviewMixin
class _H(OverviewMixin):
def _client(self, cookie):
return _FakeClient()
def _mark_task(self, *a, **k):
pass
def _push_log(self, *a, **k):
pass
def _refresh_account_gold_balance(self, c, a):
a.gold_balance = 88
a.exchange_balance = 9
return {"gold_balance": 88, "exchange_balance": 9}
def _refresh_account_points(self, c, a, cookie, *, ctn=None):
a.points = 777
return {
"points": 777,
"points_query": {"raw": {"data": {"willExpireTime": expire_at}}},
}
def _refresh_elite_handbook(self, c, a, *, manual_id, rid=""):
a.elite_opened = True
return {
"elite_opened": True,
"elite_expire_time": 0,
"elite_manual_score": 5,
}
def _refresh_esports_handbook(self, c, a, *, manual_id):
a.esports_opened = True
return {"esports_manual_type": 1, "esports_manual_score": 3}
task = DouyuTask(
batch_id="rf",
account_id=self.account.id,
task_type="query_account_overview",
handbook_scope="elite",
status="running",
message="",
created_by=self.user.id,
)
self.session.add(task)
self.session.commit()
_H()._execute_query_account_overview(
self.session,
task,
self.account,
"c",
{"manual_id": "M", "esports_manual_id": "E", "rid": "R"},
)
self.session.commit()
self.session.refresh(self.account)
def test_assets_are_written_to_account_columns(self):
self._run()
assert self.account.points == 777
assert self.account.gold_balance == 88
assert self.account.exchange_balance == 9
assert self.account.elite_opened is True
assert self.account.esports_opened is True
def test_points_expire_at_is_written(self):
"""积分到期时间有专门的列,查询必须写它(之前漏了)。"""
self._run(expire_at="1795967999")
assert self.account.points_expire_at is not None
assert self.account.points_expire_at.year != 1970
def test_no_expiry_clears_the_column_not_zero(self):
"""斗鱼用 0 表示没有到期时间 —— 不能存成 0(会显示 1970/1/1)。"""
self._run(expire_at="0")
assert self.account.points_expire_at is None
assert self.account.overview_snapshot["assets"]["points_expire_at"] is None
+2
View File
@@ -999,6 +999,8 @@ def account_overview(
"game_channel": acc.game_channel,
**bind_payload(acc),
# 最近一次「一键查询」快照(列表接口刻意**不**返回,避免体积爆炸)
# 积分到期时间:账号自己的字段,账号详情里展示(一键查询会刷新它)
"points_expire_at": acc.points_expire_at,
"overview_snapshot": acc.overview_snapshot,
"overview_queried_at": acc.overview_queried_at,
"elite_opened": acc.elite_opened,
@@ -33,7 +33,7 @@ from sqlalchemy.orm import Session
from core.douyu import DouyuActivityClient
from ..models import Account, DouyuTask
from .douyu_runner_donate import _points_expire_at
from .douyu_runner_donate import _parse_expire_at, _points_expire_at
#: 默认关注的高价值商品。按**商品名包含**匹配 —— 官方列表里的名字带前缀
#: (实际是「套装-兔兔白日梦」),所以配置里写核心词即可。
@@ -246,7 +246,12 @@ class OverviewMixin:
overview["assets"]["points"] = points.get("points")
# `_refresh_account_points` 只回 {"points", "points_query"},
# 到期时间得从 points_query 里取 —— 复用已有具名函数,不自己扒结构。
overview["assets"]["points_expire_at"] = _ts(_points_expire_at(points))
expire_raw = _ts(_points_expire_at(points))
overview["assets"]["points_expire_at"] = expire_raw
# **也写回账号**:积分到期时间是账号的信息,本来就该由账号表承载并
# 在账号详情里展示 —— 查询只是一次「刷新」,不是另开一份副本。
expire_dt = _parse_expire_at(expire_raw)
account.points_expire_at = expire_dt
except Exception as exc: # noqa: BLE001
errors["points"] = _err(exc)
+2
View File
@@ -155,6 +155,8 @@ export interface AccountOverview {
points: number | null;
gold_balance: number | null;
exchange_balance: number | null;
/** 积分到期时间(一键查询会刷新它) */
points_expire_at: string | null;
game_name: string;
game_channel: string;
/** 三列各管一个作用域:unknown / unbound / pending / bound */
+10 -3
View File
@@ -380,7 +380,7 @@ export default function AccountDetailPage() {
title={
<Space>
<ThunderboltOutlined />
<span>一键查询(斗鱼实时)</span>
<span>高价值套装与兑换记录(一键查询)</span>
</Space>
}
extra={
@@ -406,8 +406,9 @@ export default function AccountDetailPage() {
/>
) : (
<Text type="secondary">
实时向斗鱼查询:鱼翅 / 积分(含到期时间)/ 精英宝典 / 电竞手册 / 官方兑换记录,
并重点标出「兔兔白日梦」「狂战萌兔」是否已获得。结果会**存在这个账号上**。
实时向斗鱼查一次这个账号:鱼翅 / 积分 / 开通状态会**就地刷新到下面的账号信息**,
这里只单独展示账号表里没有的两项 —— 「兔兔白日梦」「狂战萌兔」是否已获得,
以及官方兑换记录。
</Text>
)}
</Card>
@@ -574,6 +575,12 @@ export default function AccountDetailPage() {
<div style={{ display: 'flex', gap: 32, marginTop: 16, flexWrap: 'wrap' }}>
<Statistic title="当前积分" value={overview?.points ?? '-'} />
<Statistic
title="积分到期"
// 斗鱼用 0 表示「没有到期时间」;不处理会显示成 1970/1/1
value={overview?.points_expire_at ? formatTime(overview.points_expire_at) : '-'}
valueStyle={{ fontSize: 20 }}
/>
<Statistic title="鱼翅余额" value={overview?.gold_balance ?? '-'} />
<Statistic title="兑换余额" value={overview?.exchange_balance ?? '-'} />
<Statistic
@@ -82,11 +82,6 @@ function formatTime(value: string | number | null | undefined): string {
return Number.isNaN(d.getTime()) ? text : d.toLocaleString('zh-CN');
}
function yesNo(value: boolean | null | undefined): string {
if (value === true) return '已开通';
if (value === false) return '未开通';
return '未知';
}
/**
* 高价值套装卡片。
@@ -145,6 +140,16 @@ function HighValueCard({ goods }: { goods: HighValueGoods }) {
);
}
/**
* 一键查询结果面板。
*
* ⚠️ **只展示账号表里没有的东西**(高价值套装是否已获得、官方兑换记录)。
*
* 鱼翅 / 积分 / 钱包余额 / 开通状态这些,账号表本来就有对应字段
* (`gold_balance` / `points` / `exchange_balance` / `elite_opened` / `esports_opened`),
* 查询的作用是**刷新它们**,然后由账号详情里已有的界面直接反映出来 ——
* 在这里再复制一份展示是多余的,还会让人分不清哪份才是准的。
*/
export default function AccountOverviewPanel({
result,
queriedAt,
@@ -154,7 +159,6 @@ export default function AccountOverviewPanel({
queriedAt?: string | null;
}) {
if (!result) return <Empty description="暂无结果" />;
const assets = result.assets || {};
const errors = result.errors || {};
const goods = result.high_value_goods || [];
const records = result.exchange_records || [];
@@ -195,42 +199,6 @@ export default function AccountOverviewPanel({
/>
)}
<Card size="small" title="资产">
<Descriptions size="small" column={2}>
<Descriptions.Item label="鱼翅">
{assets.gold_balance ?? '-'}
</Descriptions.Item>
<Descriptions.Item label="钱包兑换余额">
{assets.exchange_balance ?? '-'}
</Descriptions.Item>
<Descriptions.Item label="积分">{assets.points ?? '-'}</Descriptions.Item>
<Descriptions.Item label="积分到期">
{formatTime(assets.points_expire_at)}
</Descriptions.Item>
</Descriptions>
</Card>
<Card size="small" title="开通状态">
<Descriptions size="small" column={2}>
<Descriptions.Item label="精英宝典">
<Tag color={result.elite?.opened ? 'green' : 'default'}>
{yesNo(result.elite?.opened)}
</Tag>
</Descriptions.Item>
<Descriptions.Item label="宝典到期">
{formatTime(result.elite?.expire_time)}
</Descriptions.Item>
<Descriptions.Item label="电竞手册">
<Tag color={result.esports?.opened ? 'green' : 'default'}>
{yesNo(result.esports?.opened)}
</Tag>
</Descriptions.Item>
<Descriptions.Item label="电竞积分">
{result.esports?.points ?? '-'}
</Descriptions.Item>
</Descriptions>
</Card>
<Card size="small" title="🎯 高价值套装">
{goods.length === 0 ? (
<Text type="secondary">没有配置关注的高价值套装,或本次没取到</Text>