Files
live-hub-py/web/backend/migrations/versions/20261004_0048_high_value_owned.py
T
yml2213 85a6186993 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。
2026-10-04 20:17:05 +08:00

114 lines
4.3 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""账号列表要显示一键查询的结果:加一个**极小**的字段
账号列表的「兑换记录」列一直来自我们自己的 `account_events`,
而事件是**上线后才开始记**的 —— 更早的兑换在列表里就是空的,
看起来像"从没兑换过"(用户报的正是这个:账号 6966677706 明明有官方兑换记录,
列表却显示未兑换)。
一键查询能查到官方兑换记录,但那份 JSON 有 1~2KB。
账号列表是全站最热的接口之一,15000 个账号各带一份会把响应放大到几十 MB,
所以**不能**直接把 `overview_snapshot` 返回给列表。
这里加一个只含列表所需两项的小字段:
`{"owned": ["兔兔白日梦"], "exchanges": 2}`
"""
from __future__ import annotations
import json
import sqlalchemy as sa
from alembic import op
revision: str = "20261004_0048"
down_revision: str | None = "20261004_0047"
branch_labels: str | None = None
depends_on: str | None = None
def _backfill_from_snapshot() -> None:
"""从已有的 `overview_snapshot` 回填。
必须回填:加这个字段之前已经查过的账号,快照里**早就有**这两项信息,
不回填的话列表会对它们显示「未查询」—— 明明查过,界面却在骗人。
"""
conn = op.get_bind()
rows = conn.execute(
sa.text(
"SELECT id, overview_snapshot FROM accounts "
"WHERE overview_snapshot IS NOT NULL"
)
).fetchall()
updated = 0
for account_id, snapshot in rows:
data = snapshot
if isinstance(data, (str, bytes)):
try:
data = json.loads(data)
except ValueError:
continue
if not isinstance(data, dict):
continue
goods = data.get("high_value_goods")
owned = [
g.get("name")
for g in (goods if isinstance(goods, list) else [])
if isinstance(g, dict) and g.get("owned") and g.get("name")
]
# 只留「已兑换过哪些高价值套装」。
# 兑换条数不再存这里 —— 列表改成从**官方事件**聚合(口径更准,
# 而且不用维护两套计数)。
owned_value = owned
conn.execute(
sa.text("UPDATE accounts SET high_value_owned = :owned WHERE id = :id"),
{"owned": json.dumps(owned_value, ensure_ascii=False), "id": account_id},
)
updated += 1
# ⚠️ **不要**在这里 `conn.commit()`。
# 曾经加过,后果很隐蔽:DDL(ALTER TABLE)在 MySQL 上是自动提交的,
# 但 commit 会打乱 alembic 自己的版本记账 —— 表现为
# **列加上了、版本号却没更新**,下次启动升级时又去 ADD COLUMN,
# 直接 `Duplicate column name` 把服务起不来(生产上就是启动即挂)。
# 数据改动用 `op.get_bind().execute(...)` 写即可,提交由 alembic 负责
# (仓库里 0042 的 S1 回填就是这么写的,一直正常)。
if updated:
print(f"[0048] 已从快照回填 {updated} 个账号的 high_value_owned")
def _column_exists(name: str) -> bool:
"""列是否已存在。
应用启动时会自动跑迁移,所以迁移必须**可安全重跑** ——
万一出现「列已在、版本号没更新」的状态(本迁移就踩过),
无脑 ADD COLUMN 会直接 `Duplicate column name` 让服务起不来。
"""
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("high_value_owned"):
op.add_column(
"accounts",
sa.Column(
"high_value_owned",
sa.JSON(),
nullable=True,
comment="一键查询得到的「已兑换过的高价值套装名」列表(列表用,极小)",
),
)
# 无论列是不是这一步加的,都跑回填(幂等:覆盖式写入)
_backfill_from_snapshot()
def downgrade() -> None:
op.drop_column("accounts", "high_value_owned")