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 && (
+
+ 到期 {formatUnixSeconds(overview?.elite_expire_time)}
+