feat(account): 电竞手册到期时间落库与展示;迁移 0048 改名

## 背景(用户反馈)

「精英宝典和电竞手册已开通的话,应该增加时间展示,可以看到开通时间和过期时间」

- 精英宝典:开通时间来自事件;过期时间 `elite_expire_time` 本来就有
- 电竞手册:开通时间来自事件;**过期时间原来没有落库** ——
  只在 `_refresh_esports_handbook` 的返回值里带着,界面上只能看到「已开通」

## 改动

- 新增 `accounts.esports_expire_time`(迁移 0049,与 `elite_expire_time` 对称),
  刷新电竞手册时写回
- 详情页:**已开通才显示**「开通 x / 到期 x」两行小字;顺手删掉底部那段
  重复的「开通时间」说明(已挪到每项旁边)
- 新增 `formatUnixSeconds()`:斗鱼的到期时间是**秒级时间戳**,
  原 `formatTime()` 只收字符串;新函数对 `0`/空/负数一律显示 `-`

## 明确回答:历史开通记录**查不到**

实测官方兑换记录里**只有商品兑换、没有开通记录**(账号 61:精英 2 条都是套装)。
我们自己的 `elite_opened` / `esports_opened` 事件也只有上线后的。
所以历史上的开通时间拿不回来 —— 按用户说的「没有就算了」,
并把这点写进界面说明,别再让人去找。

## 测的时候抓出一个真 bug(又是 1970)

电竞到期时间的 `"0"`(斗鱼用它表示「没有到期时间」)被原样存成 `0`
→ 界面会显示 **1970/1/1**。已修(`0`/空 → `None`)并加回归用例 ——
这个坑在积分到期上踩过一次,这次差点重演。

## 迁移 0048 改名

`20261004_0048_overview_summary.py` → `20261004_0048_high_value_owned.py`:
上一轮把内容从「汇总」收敛成单一用途的 `high_value_owned`,文件名没跟着改。
revision id 不变、迁移状态不受影响;同时清了 `__pycache__`
(本仓库被过期 `.pyc` 咬过,症状是「改了没生效」)。

## 验证(本地)

`TZ=Asia/Shanghai` **608 passed / 6 skipped**;ruff 全绿;前端 build 通过、lint 0 errors。
This commit is contained in:
yml2213
2026-10-04 20:17:05 +08:00
parent 6865b916ba
commit 85a6186993
10 changed files with 161 additions and 7 deletions
+59
View File
@@ -1098,3 +1098,62 @@ class TestLatestExchangeFollowsEventTime:
assert "新商品" in self._latest(), (
"「最近一次」按 id 排了 —— 补录的历史事件 id 更大,会被错当成最近一次"
)
class TestEsportsExpireTimePersisted:
"""电竞手册到期时间要**落库**(与精英对称)。
以前它只在 `_refresh_esports_handbook` 的返回值里带着、没写进账号,
所以界面上电竞手册只能显示「已开通」、看不到什么时候到期(用户反馈)。
"""
def setup_method(self):
self.engine = get_test_engine()
self.session = make_test_session(self.engine)
self.user = User(username="exp-admin", password_hash="h", role="super_admin")
self.session.add(self.user)
self.session.flush()
self.account = Account(
username="exp-acc", password="p", email="e@e.com", email_password="ep"
)
self.session.add(self.account)
self.session.commit()
def teardown_method(self):
self.session.close()
def _refresh(self, expire_time):
from web.backend.services.douyu_runner_manual import ManualMixin
class _H(ManualMixin):
def _client(self, cookie):
return None
def _push_log(self, *a, **k):
pass
def _to_int(self, value):
try:
return int(value)
except (TypeError, ValueError):
return None
class _Client:
@staticmethod
def esports_user_info(*, manual_id):
return {"manual_type": 1, "manual_score": 7, "expire_time": expire_time}
_H()._refresh_esports_handbook(_Client(), self.account, manual_id="E")
self.session.commit()
self.session.refresh(self.account)
def test_expire_time_is_written(self):
self._refresh("1795967999")
assert self.account.esports_expire_time == 1795967999
def test_no_expiry_stores_none_not_zero(self):
"""斗鱼用 0 / 空表示没有到期时间 —— 不能存成 0(界面会显示 1970/1/1)。"""
self._refresh("0")
assert self.account.esports_expire_time is None
self._refresh("")
assert self.account.esports_expire_time is None
+1 -1
View File
@@ -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 = "20261004_0048"
HEAD_REVISION = "20261004_0049"
# 迁移新增、但不声明在模型里的复合查询索引。
EXTRA_INDEXES = (
@@ -0,0 +1,52 @@
"""电竞手册到期时间:新增 `accounts.esports_expire_time`
与 `elite_expire_time` 对称。以前这个值只在 `_refresh_esports_handbook`
的返回值里带着、没落库,所以界面上电竞手册只能显示「已开通」,
看不到什么时候到期(用户反馈要"开通时间和过期时间")。
历史值拿不回来(官方兑换记录里**只有商品兑换、没有开通记录**),
所以这一列从今往后才有数据 —— 已开通的账号会在下次刷新时补上。
"""
from __future__ import annotations
import sqlalchemy as sa
from alembic import op
revision: str = "20261004_0049"
down_revision: str | None = "20261004_0048"
branch_labels: str | None = None
depends_on: str | None = None
COLUMN = "esports_expire_time"
def _column_exists(name: str) -> bool:
"""可安全重跑(应用启动时会自动跑迁移)。"""
conn = op.get_bind()
count = conn.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 upgrade() -> None:
if not _column_exists(COLUMN):
op.add_column(
"accounts",
sa.Column(
COLUMN,
sa.Integer(),
nullable=True,
comment="电竞手册到期时间(Unix 秒,与 elite_expire_time 对称)",
),
)
def downgrade() -> None:
op.drop_column("accounts", COLUMN)
+4
View File
@@ -143,6 +143,10 @@ class Account(Base):
esports_change_role_wait_time: Mapped[int | None] = mapped_column(
Integer, nullable=True
)
# 电竞手册**到期时间**(与 `elite_expire_time` 对称)。
# 以前只在 `_refresh_esports_handbook` 的返回值里带着,没落库 ——
# 于是界面上电竞只能显示"已开通"、看不到到期时间。
esports_expire_time: Mapped[int | None] = mapped_column(Integer, nullable=True)
esports_can_change_time: Mapped[int | None] = mapped_column(Integer, nullable=True)
xpd_game_name: Mapped[str] = mapped_column(String(128), default="")
xpd_openid: Mapped[str] = mapped_column(String(128), default="")
+1
View File
@@ -1082,6 +1082,7 @@ def account_overview(
"high_value_owned": acc.high_value_owned or [],
"elite_opened": acc.elite_opened,
"elite_expire_time": acc.elite_expire_time,
"esports_expire_time": acc.esports_expire_time,
"esports_points": acc.esports_points,
"esports_bind_status": acc.esports_bind_status,
"esports_game_name": acc.esports_game_name,
@@ -203,6 +203,16 @@ class ManualMixin:
manual_type = self._to_int(result.get("manual_type"))
manual_score = self._to_int(result.get("manual_score"))
account.esports_points = manual_score
# 到期时间也落库 —— 否则界面只能显示"已开通",看不到什么时候到期
expire_raw = result.get("expire_time")
expire_int = (
int(expire_raw) if str(expire_raw or "").strip().isdigit() else None
)
# ⚠️ 斗鱼用 0 表示「没有到期时间」。直接存 0 的话界面会显示 1970/1/1
# (这个坑在积分到期上踩过一次,这里别再踩)。
account.esports_expire_time = (
expire_int if expire_int and expire_int > 0 else None
)
account.updated_at = datetime.now(UTC)
return {
"esports_manual_type": manual_type,
+2
View File
@@ -169,6 +169,8 @@ export interface AccountOverview {
esports_bind_label: string;
/** 电竞手册是否已开通(与 elite_opened 对称,S6 从垃圾场列里救出来的) */
esports_opened: boolean | null;
/** 电竞手册到期时间(Unix 秒,与 elite_expire_time 对称) */
esports_expire_time: number | null;
xpd_bind_status: BindStatus;
xpd_bind_label: string;
esports_game_name: string;
+19 -6
View File
@@ -11,7 +11,7 @@ import { accountApi, douyuApi, userApi, type AccountEventItem, type AccountOverv
import AccountOverviewPanel from './douyu-tasks/AccountOverviewPanel';
import { getErrorMessage } from '../utils/error';
import { message } from '../utils/antdMessage';
import { formatTime } from '../utils/time';
import { formatTime, formatUnixSeconds } from '../utils/time';
const { Text, Title } = Typography;
@@ -549,6 +549,15 @@ export default function AccountDetailPage() {
color: overview?.elite_opened ? '#3f8600' : undefined,
}}
/>
{/* 已开通才显示时间。开通时间来自事件(**上线后才有**);
更早的历史查不到 —— 官方兑换记录里只有商品兑换,没有开通记录。 */}
{overview?.elite_opened && (
<Text type="secondary" style={{ fontSize: 12 }}>
开通 {formatTime(overview?.opened_at?.elite)}
<br />
到期 {formatUnixSeconds(overview?.elite_expire_time)}
</Text>
)}
</Col>
<Col span={4}>
<Statistic
@@ -556,6 +565,13 @@ export default function AccountDetailPage() {
value={overview?.esports_opened ? '已开通' : '未开通'}
valueStyle={{ fontSize: 18 }}
/>
{overview?.esports_opened && (
<Text type="secondary" style={{ fontSize: 12 }}>
开通 {formatTime(overview?.opened_at?.esports)}
<br />
到期 {formatUnixSeconds(overview?.esports_expire_time)}
</Text>
)}
</Col>
</Row>
@@ -583,12 +599,9 @@ export default function AccountDetailPage() {
</Row>
<Text type="secondary" style={{ display: 'block', marginTop: 12, fontSize: 12 }}>
最近登录 {formatTime(stats?.last_login_at)} · 精英开通{' '}
{formatTime(overview?.opened_at?.elite)} · 电竞开通{' '}
{formatTime(overview?.opened_at?.esports)}
<br />
最近登录 {formatTime(stats?.last_login_at)}。
开通时间与累计统计来自「账号事件」,从该功能上线起记录;
更早的历史请到「登录任务 / 精英宝典 / 电竞手册 / 和平小店」页查看。
更早的开通历史**查不到**(官方兑换记录里只有商品兑换,没有开通记录)。
</Text>
</Card>
+13
View File
@@ -34,3 +34,16 @@ export function formatTimeShort(val: string | null | undefined): string {
return d.isValid() ? d.format('MM-DD HH:mm') : val;
}
/**
* 斗鱼给的**秒级时间戳** → 可读文本。
*
* ⚠️ `0` / `null` / 负数 一律显示 `-`:斗鱼用 0 表示「没有这个时间」,
* 而 `new Date(0)` 是 **1970/1/1** —— 直接格式化会让运营以为
* "到期时间是 1970 年"(这个 bug 真出现过)。
*/
export function formatUnixSeconds(val: number | string | null | undefined): string {
if (val === null || val === undefined || val === '') return '-';
const seconds = typeof val === 'number' ? val : Number(val);
if (!Number.isFinite(seconds) || seconds <= 0) return '-';
return formatTime(new Date(seconds * 1000).toISOString());
}