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:
@@ -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
|
||||
|
||||
@@ -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)
|
||||
@@ -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="")
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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>
|
||||
|
||||
|
||||
@@ -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());
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user