From 85a6186993208c635d6e28ebf936392969cfaf66 Mon Sep 17 00:00:00 2001 From: yml2213 Date: Sun, 4 Oct 2026 20:17:05 +0800 Subject: [PATCH] =?UTF-8?q?feat(account):=20=E7=94=B5=E7=AB=9E=E6=89=8B?= =?UTF-8?q?=E5=86=8C=E5=88=B0=E6=9C=9F=E6=97=B6=E9=97=B4=E8=90=BD=E5=BA=93?= =?UTF-8?q?=E4=B8=8E=E5=B1=95=E7=A4=BA=EF=BC=9B=E8=BF=81=E7=A7=BB=200048?= =?UTF-8?q?=20=E6=94=B9=E5=90=8D?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## 背景(用户反馈) 「精英宝典和电竞手册已开通的话,应该增加时间展示,可以看到开通时间和过期时间」 - 精英宝典:开通时间来自事件;过期时间 `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。 --- tests/test_account_overview.py | 59 +++++++++++++++++++ tests/test_migrations.py | 2 +- ...y.py => 20261004_0048_high_value_owned.py} | 0 .../20261004_0049_esports_expire_time.py | 52 ++++++++++++++++ web/backend/models.py | 4 ++ web/backend/routers/accounts.py | 1 + web/backend/services/douyu_runner_manual.py | 10 ++++ web/frontend/src/api/types.ts | 2 + web/frontend/src/pages/AccountDetailPage.tsx | 25 ++++++-- web/frontend/src/utils/time.ts | 13 ++++ 10 files changed, 161 insertions(+), 7 deletions(-) rename web/backend/migrations/versions/{20261004_0048_overview_summary.py => 20261004_0048_high_value_owned.py} (100%) create mode 100644 web/backend/migrations/versions/20261004_0049_esports_expire_time.py diff --git a/tests/test_account_overview.py b/tests/test_account_overview.py index b7ea9ee..943714e 100644 --- a/tests/test_account_overview.py +++ b/tests/test_account_overview.py @@ -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 diff --git a/tests/test_migrations.py b/tests/test_migrations.py index 073d397..0a646f1 100644 --- a/tests/test_migrations.py +++ b/tests/test_migrations.py @@ -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 = ( diff --git a/web/backend/migrations/versions/20261004_0048_overview_summary.py b/web/backend/migrations/versions/20261004_0048_high_value_owned.py similarity index 100% rename from web/backend/migrations/versions/20261004_0048_overview_summary.py rename to web/backend/migrations/versions/20261004_0048_high_value_owned.py diff --git a/web/backend/migrations/versions/20261004_0049_esports_expire_time.py b/web/backend/migrations/versions/20261004_0049_esports_expire_time.py new file mode 100644 index 0000000..a058810 --- /dev/null +++ b/web/backend/migrations/versions/20261004_0049_esports_expire_time.py @@ -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) diff --git a/web/backend/models.py b/web/backend/models.py index 65ce0b6..5b1fc71 100644 --- a/web/backend/models.py +++ b/web/backend/models.py @@ -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="") diff --git a/web/backend/routers/accounts.py b/web/backend/routers/accounts.py index ab6b2c4..8fd1785 100644 --- a/web/backend/routers/accounts.py +++ b/web/backend/routers/accounts.py @@ -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, diff --git a/web/backend/services/douyu_runner_manual.py b/web/backend/services/douyu_runner_manual.py index ecc2873..b166546 100644 --- a/web/backend/services/douyu_runner_manual.py +++ b/web/backend/services/douyu_runner_manual.py @@ -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, diff --git a/web/frontend/src/api/types.ts b/web/frontend/src/api/types.ts index 7e2465c..a9a3898 100644 --- a/web/frontend/src/api/types.ts +++ b/web/frontend/src/api/types.ts @@ -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; diff --git a/web/frontend/src/pages/AccountDetailPage.tsx b/web/frontend/src/pages/AccountDetailPage.tsx index ec8d884..e3f29f5 100644 --- a/web/frontend/src/pages/AccountDetailPage.tsx +++ b/web/frontend/src/pages/AccountDetailPage.tsx @@ -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 && ( + + 开通 {formatTime(overview?.opened_at?.elite)} +
+ 到期 {formatUnixSeconds(overview?.elite_expire_time)} +
+ )} + {overview?.esports_opened && ( + + 开通 {formatTime(overview?.opened_at?.esports)} +
+ 到期 {formatUnixSeconds(overview?.esports_expire_time)} +
+ )} @@ -583,12 +599,9 @@ export default function AccountDetailPage() { - 最近登录 {formatTime(stats?.last_login_at)} · 精英开通{' '} - {formatTime(overview?.opened_at?.elite)} · 电竞开通{' '} - {formatTime(overview?.opened_at?.esports)} -
+ 最近登录 {formatTime(stats?.last_login_at)}。 开通时间与累计统计来自「账号事件」,从该功能上线起记录; - 更早的历史请到「登录任务 / 精英宝典 / 电竞手册 / 和平小店」页查看。 + 更早的开通历史**查不到**(官方兑换记录里只有商品兑换,没有开通记录)。
diff --git a/web/frontend/src/utils/time.ts b/web/frontend/src/utils/time.ts index 03722d2..ad42e11 100644 --- a/web/frontend/src/utils/time.ts +++ b/web/frontend/src/utils/time.ts @@ -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()); +}