fix(douyu): 加固高价值三态迁移与状态口径
修复迁移中断重跑、旧快照回填覆盖、降级将 unknown 误写成可兑换,以及迁移前 CK 已失效账号未作废旧结论的问题。统一 CK error 的展示与筛选口径,补齐旧快照迁移和兑换兜底反向用例,并同步高价值结论时间语义。同时纳入 .gitignore 更新。
This commit is contained in:
@@ -4,6 +4,10 @@
|
||||
本迁移增加两套独立状态和检查时间,并从旧快照保守回填:
|
||||
只有快照里明确保留了官方 ``status=0/1`` 才恢复可兑换/已达上限;
|
||||
更早的快照没有保存原始 status,统一回填为 unknown,等待一次新查询。
|
||||
|
||||
``high_value_checked_at`` 表示“最近一次形成高价值结论的时间”,结论本身可以是
|
||||
unknown。旧快照虽然没保存原始 status,但确实形成过“当前无法判断”的结论,
|
||||
因此保留旧查询时间,让这些账号按 unknown 的慢速策略重新确认。
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
@@ -49,6 +53,26 @@ def _column_exists(name: str) -> bool:
|
||||
return bool(count)
|
||||
|
||||
|
||||
def _index_exists(name: str) -> bool:
|
||||
count = (
|
||||
op.get_bind()
|
||||
.execute(
|
||||
sa.text(
|
||||
"""
|
||||
SELECT COUNT(*)
|
||||
FROM information_schema.statistics
|
||||
WHERE table_schema = DATABASE()
|
||||
AND table_name = 'accounts'
|
||||
AND index_name = :name
|
||||
"""
|
||||
),
|
||||
{"name": name},
|
||||
)
|
||||
.scalar()
|
||||
)
|
||||
return bool(count)
|
||||
|
||||
|
||||
def _json_dict(value: Any) -> dict[str, Any]:
|
||||
if isinstance(value, dict):
|
||||
return value
|
||||
@@ -83,14 +107,19 @@ def _as_datetime(value: Any) -> datetime | None:
|
||||
|
||||
|
||||
def _backfill_from_snapshot() -> None:
|
||||
"""只回填尚未形成新结论的旧账号,保证迁移中断后可安全重跑。"""
|
||||
conn = op.get_bind()
|
||||
rows = conn.execute(
|
||||
sa.text(
|
||||
"SELECT id, overview_snapshot, overview_queried_at "
|
||||
"FROM accounts WHERE overview_snapshot IS NOT NULL"
|
||||
"SELECT id, overview_snapshot, overview_queried_at, ck_check_status "
|
||||
"FROM accounts "
|
||||
"WHERE overview_snapshot IS NOT NULL "
|
||||
"AND high_value_bairimeng_status IS NULL "
|
||||
"AND high_value_mengtu_status IS NULL "
|
||||
"AND high_value_checked_at IS NULL"
|
||||
)
|
||||
).fetchall()
|
||||
for account_id, raw_snapshot, overview_queried_at in rows:
|
||||
for account_id, raw_snapshot, overview_queried_at, ck_status in rows:
|
||||
snapshot = _json_dict(raw_snapshot)
|
||||
goods = snapshot.get("high_value_goods")
|
||||
states = {key: STATUS_UNKNOWN for key in NAMES}
|
||||
@@ -102,6 +131,11 @@ def _backfill_from_snapshot() -> None:
|
||||
if core in name:
|
||||
states[key] = _snapshot_state(item)
|
||||
break
|
||||
# 迁移前已经判定 CK 失效的账号,缓存结论同样不可继续信任。
|
||||
# 否则部署后列表会显示待确认,但筛选/统计仍把它们算作可兑换,
|
||||
# 直到下一次巡检才由 on_ck_verdict 补上作废。
|
||||
if str(ck_status or "") == "invalid":
|
||||
states = {key: STATUS_UNKNOWN for key in NAMES}
|
||||
checked_at = _as_datetime(snapshot.get("queried_at") or overview_queried_at)
|
||||
conn.execute(
|
||||
sa.text(
|
||||
@@ -123,6 +157,7 @@ def _backfill_from_snapshot() -> None:
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
old_column_exists = _column_exists("high_value_owned")
|
||||
for name in (
|
||||
"high_value_bairimeng_status",
|
||||
"high_value_mengtu_status",
|
||||
@@ -137,7 +172,7 @@ def upgrade() -> None:
|
||||
name,
|
||||
sa.DateTime(),
|
||||
nullable=True,
|
||||
comment="最近一次成功读取官方 storehome 准入状态的时间",
|
||||
comment="最近一次形成高价值结论的时间;结论可以是 unknown",
|
||||
),
|
||||
)
|
||||
else:
|
||||
@@ -150,13 +185,16 @@ def upgrade() -> None:
|
||||
comment="owned / available / unknown;只认官方 storehome.status",
|
||||
),
|
||||
)
|
||||
# 回填只处理新字段全空的账号,所以“加完列但回填前中断”和
|
||||
# “回填成功但后续 DDL 失败”两种 MySQL 非事务迁移都可以直接重跑。
|
||||
_backfill_from_snapshot()
|
||||
op.create_index(
|
||||
"ix_accounts_high_value_checked_at",
|
||||
"accounts",
|
||||
["high_value_checked_at"],
|
||||
)
|
||||
if _column_exists("high_value_owned"):
|
||||
if not _index_exists("ix_accounts_high_value_checked_at"):
|
||||
op.create_index(
|
||||
"ix_accounts_high_value_checked_at",
|
||||
"accounts",
|
||||
["high_value_checked_at"],
|
||||
)
|
||||
if old_column_exists:
|
||||
op.drop_column("accounts", "high_value_owned")
|
||||
|
||||
|
||||
@@ -170,24 +208,36 @@ def downgrade() -> None:
|
||||
sa.text(
|
||||
"""
|
||||
UPDATE accounts
|
||||
SET high_value_owned = COALESCE(
|
||||
(
|
||||
SELECT JSON_ARRAYAGG(t.name)
|
||||
FROM (
|
||||
SELECT CASE WHEN high_value_bairimeng_status = 'owned'
|
||||
THEN '套装-兔兔白日梦' END AS name
|
||||
UNION ALL
|
||||
SELECT CASE WHEN high_value_mengtu_status = 'owned'
|
||||
THEN '套装-狂战萌兔' END AS name
|
||||
) AS t
|
||||
WHERE t.name IS NOT NULL
|
||||
),
|
||||
JSON_ARRAY()
|
||||
)
|
||||
SET high_value_owned = CASE
|
||||
WHEN high_value_bairimeng_status IS NULL
|
||||
OR high_value_bairimeng_status NOT IN ('owned', 'available')
|
||||
OR high_value_mengtu_status IS NULL
|
||||
OR high_value_mengtu_status NOT IN ('owned', 'available')
|
||||
THEN NULL
|
||||
ELSE COALESCE(
|
||||
(
|
||||
SELECT JSON_ARRAYAGG(t.name)
|
||||
FROM (
|
||||
SELECT CASE WHEN high_value_bairimeng_status = 'owned'
|
||||
THEN '套装-兔兔白日梦' END AS name
|
||||
UNION ALL
|
||||
SELECT CASE WHEN high_value_mengtu_status = 'owned'
|
||||
THEN '套装-狂战萌兔' END AS name
|
||||
) AS t
|
||||
WHERE t.name IS NOT NULL
|
||||
),
|
||||
JSON_ARRAY()
|
||||
)
|
||||
END
|
||||
"""
|
||||
)
|
||||
)
|
||||
op.drop_index("ix_accounts_high_value_checked_at", table_name="accounts")
|
||||
op.drop_column("accounts", "high_value_checked_at")
|
||||
op.drop_column("accounts", "high_value_mengtu_status")
|
||||
op.drop_column("accounts", "high_value_bairimeng_status")
|
||||
if _index_exists("ix_accounts_high_value_checked_at"):
|
||||
op.drop_index("ix_accounts_high_value_checked_at", table_name="accounts")
|
||||
for name in (
|
||||
"high_value_checked_at",
|
||||
"high_value_mengtu_status",
|
||||
"high_value_bairimeng_status",
|
||||
):
|
||||
if _column_exists(name):
|
||||
op.drop_column("accounts", name)
|
||||
|
||||
@@ -132,6 +132,8 @@ class Account(Base):
|
||||
high_value_mengtu_status: Mapped[str | None] = mapped_column(
|
||||
String(16), nullable=True
|
||||
)
|
||||
# 最近一次形成高价值结论的时间;结论可以是 owned / available / unknown。
|
||||
# 这是“结论时钟”,不是“成功获取可用 status 的时钟”。
|
||||
high_value_checked_at: Mapped[datetime | None] = mapped_column(
|
||||
UTCDateTime, nullable=True
|
||||
)
|
||||
|
||||
@@ -88,7 +88,8 @@ def high_value_needs_refresh_expr(
|
||||
和「资料是否过期」是**两件事**:
|
||||
|
||||
- ``overview_queried_at`` 说「上次一键查询是什么时候」;
|
||||
- ``high_value_checked_at`` 说「上次**成功读到官方商品状态**是什么时候」。
|
||||
- ``high_value_checked_at`` 说「上次形成高价值结论是什么时候」;
|
||||
结论可以是 ``unknown``。
|
||||
|
||||
两者会脱钩 —— 比如一键查询里商品接口单独失败(其它项都拿到了),
|
||||
资料时间被刷新了,高价值时间却停在原地。再比如 CK 失效那段时间查不动,
|
||||
@@ -103,7 +104,7 @@ def high_value_needs_refresh_expr(
|
||||
而「可兑换」随时会变;
|
||||
- **结论被作废**(状态是 ``unknown``,比如 CK 失效期间被作废):
|
||||
按 ``unknown_seconds`` 慢速重试;
|
||||
- **从来没查到过**(``checked_at IS NULL``):**不算**。那种情况要么
|
||||
- **从来没形成过结论**(``checked_at IS NULL``):**不算**。那种情况要么
|
||||
账号确实没查过资料(本来就该被选中),要么商品接口对这个账号稳定失败
|
||||
(没配 manualID、账号没有宝典)—— 后者若每轮都重选,同一批账号会
|
||||
反复撞墙,挤掉真正有新欠账的账号。它由「资料过期」那条兜着就够了。
|
||||
|
||||
@@ -10,9 +10,9 @@
|
||||
|
||||
## 为什么「是否已拥有」能直接查官方
|
||||
|
||||
`query_limited_goods` 返回的商品项里 `count` / `foreverCount` / `storeStatus`
|
||||
是**账号维度**的(不是全局库存)—— 已用生产镜像库实测,见
|
||||
`extract_high_value_goods` 的文档。
|
||||
`storehome` 商品项里的账号级 `status`(0=可兑换、1=已达上限)才是唯一判据。
|
||||
`count` / `foreverCount` / `storeStatus` 是全局库存或商店状态,不能拿来判断
|
||||
当前账号是否已经兑换。
|
||||
|
||||
## 诚实边界
|
||||
|
||||
@@ -355,7 +355,7 @@ class OverviewMixin:
|
||||
goods_query_ok = True
|
||||
except Exception as exc: # noqa: BLE001
|
||||
errors["limited_goods"] = _err(exc)
|
||||
# 商品列表带有 status 准入状态,结合兑换记录在后文判定高价值资产
|
||||
# 商品列表带有账号级 status;兑换记录只作历史展示,不参与准入判定。
|
||||
pending_goods = goods_items
|
||||
|
||||
# ---- 角色绑定状态(三个作用域都查)----
|
||||
|
||||
@@ -221,8 +221,8 @@ def invalidate_high_value(account: Account) -> None:
|
||||
那条陈旧的「可换」会**重新显示成绿色**,运营就会拿着一份失效结论去兑换。
|
||||
|
||||
所以作废是**单向**的:写 ``unknown``,等下一次真的读到官方 ``storehome``
|
||||
再写回真实的 ``available``/``owned``。``checked_at`` 保留(那确实是
|
||||
「上次成功读到官方状态」的时间),但结论本身已经不算数。
|
||||
再写回真实的 ``available``/``owned``。``checked_at`` 保留它作为
|
||||
「上次形成结论」的时钟,但结论本身已经不算数。
|
||||
"""
|
||||
for key in HIGH_VALUE_KEYS:
|
||||
setattr(account, f"high_value_{key}_status", HIGH_VALUE_STATUS_UNKNOWN)
|
||||
@@ -236,9 +236,10 @@ def high_value_payload(
|
||||
checked_at = account.high_value_checked_at
|
||||
if checked_at is not None and checked_at.tzinfo is None:
|
||||
checked_at = checked_at.replace(tzinfo=UTC)
|
||||
# CK 失效 = 查不动了。此时缓存里的「可兑换」已经无法确认当前是否还成立,
|
||||
# 只能当作待确认 —— 否则运营会拿一个过期结论去兑换。
|
||||
ck_broken = (account.ck_check_status or "") in {"invalid", "error"}
|
||||
# 只有明确判定 `invalid` 才降级展示。`error` 表示这次检测没查成,
|
||||
# 不等于账号状态变了;若也降级,列表会显示已过期,但筛选/统计仍把
|
||||
# 同一账号算作可兑换,造成同一屏两套结论。
|
||||
ck_broken = (account.ck_check_status or "") == "invalid"
|
||||
stale = bool(
|
||||
ck_broken
|
||||
or (
|
||||
|
||||
@@ -72,7 +72,7 @@ function highValueShortTag(label: string, status: HighValueAssetState) {
|
||||
|
||||
function CurrentHighValue({ value }: { value: HighValueStatus }) {
|
||||
if (value.state === 'never') {
|
||||
return <Text type="secondary">尚未成功获取官方商城准入状态</Text>;
|
||||
return <Text type="secondary">尚未形成高价值准入结论</Text>;
|
||||
}
|
||||
if (value.state === 'stale') {
|
||||
return (
|
||||
|
||||
@@ -55,7 +55,7 @@ function highValueAssetTag(
|
||||
function renderHighValueStatus(highValue: HighValueStatus | undefined) {
|
||||
if (!highValue || highValue.state === 'never') {
|
||||
return (
|
||||
<Tooltip title="尚未成功读取官方商城准入状态,点工具栏「批量一键查询」">
|
||||
<Tooltip title="尚未形成高价值结论,点工具栏「批量一键查询」">
|
||||
<Text type="secondary">未查询</Text>
|
||||
</Tooltip>
|
||||
);
|
||||
|
||||
@@ -245,11 +245,11 @@ export function FilterPanel({
|
||||
},
|
||||
{
|
||||
key: 'high_value_never',
|
||||
label: '未读到商城',
|
||||
label: '未形成结论',
|
||||
icon: '❔',
|
||||
count: summary.high_value?.high_value_never,
|
||||
color: '#8c8c8c',
|
||||
tooltip: '从未成功读到官方商城准入状态:商城接口失败,或未配置精英宝典 manualID',
|
||||
tooltip: '从未形成过高价值结论:商城接口失败、未配置精英宝典 manualID,或账号尚未查询',
|
||||
active:
|
||||
highValueFilter === 'high_value_never' &&
|
||||
minPoints === undefined &&
|
||||
|
||||
@@ -83,7 +83,7 @@ export const HIGH_VALUE_OPTIONS = [
|
||||
{ value: 'both_owned', label: '✓ 两套均已拥有' },
|
||||
{ value: 'high_value_broken', label: '⚠️ 高价值但CK失效' },
|
||||
{ value: 'high_value_unknown', label: '❓ 高价值待确认/已过期' },
|
||||
{ value: 'high_value_never', label: '❓ 从未读到官方商城状态' },
|
||||
{ value: 'high_value_never', label: '❓ 从未形成高价值结论' },
|
||||
{ value: 'unqueried', label: '❓ 未查过资料' },
|
||||
];
|
||||
|
||||
|
||||
Reference in New Issue
Block a user