feat(douyu): 高价值套装改用官方 storehome 三态判定

高价值套装的准入判定改为只认官方精英宝典 storehome 里账号级的
status:0=可兑换、1=已达兑换上限,商品缺失/接口异常=unknown。
兑换记录和积分记录只作历史展示,不参与是否拥有的判定。

原来的 high_value_owned(JSON 名单)无法区分「确认可兑换」和
「官方接口没返回商品」,也无法表达部分失败。改为两套独立状态 +
检查时间(迁移 20261106_0065),旧快照保守回填:status 缺失的
一律 unknown,不猜。

CK 判定失效时在数据层作废结论(降为 unknown),不是展示层降级。
官方 status 是账号维度的,但 CK 失效那段时间账号可能被别人手工
登录并兑换(这些号共享给客服),缓存里的「可兑换」就不成立了;
只在展示层降级的话,CK 恢复后那条陈旧的「可换」会重新变绿。
error(查不到)不作废 —— 查不到不等于账号出问题了。

高价值结论有自己的时钟:资料刷新除了看 overview_queried_at,还看
high_value_checked_at。两者会脱钩(商品接口单独失败时资料时间被刷新、
高价值时间停在原地),少了这一条这类账号会永久停在旧结论。作废的
结论按 6 小时慢速重试,但「从来没查到过」不因此重选 —— 那是接口对
该账号稳定失败,每轮都重选只会反复撞墙。

商城商品列表改用精英宝典的 manualID 拉取,不再寄生在电竞手册分支里
(否则没配电竞手册的配置就永远拉不到商城,三态全部停在待确认)。

筛选拆成三类「查不到」:unqueried(未查资料,旧口径不变)、
high_value_never(从未读到商城)、high_value_unknown(查过但结论
未知/过期)。之前第二类无处可寻 —— 它既不满足 unqueried 也不满足
high_value_unknown。
This commit is contained in:
yml2213
2026-11-06 12:25:08 +08:00
parent e7e187cf57
commit 4c1aff47da
23 changed files with 1658 additions and 317 deletions
+26 -5
View File
@@ -98,10 +98,31 @@ export interface HighValueSummary {
can_bairimeng: number;
can_mengtu: number;
unqueried: number;
high_value_unknown: number;
high_value_never: number;
high_points: number;
has_gold: number;
elite_opened: number;
high_value_broken: number;
owned_bairimeng: number;
owned_mengtu: number;
both_owned: number;
}
export type HighValueAssetState = 'owned' | 'available' | 'unknown' | '';
export type HighValueQueryState = 'never' | 'partial' | 'stale' | 'complete';
export interface HighValueStatus {
state: HighValueQueryState;
checked_at: string | null;
stale: boolean;
fresh: boolean;
bairimeng: HighValueAssetState;
mengtu: HighValueAssetState;
names: {
bairimeng: string;
mengtu: string;
};
}
export interface BasicSummary {
@@ -222,8 +243,8 @@ export interface AccountOverview {
stats: AccountOverviewStats;
/** 最近一次「一键查询」的快照(结构与后端 query_account_overview 的 result 一致) */
overview_snapshot: import('../pages/douyu-tasks/AccountOverviewPanel').AccountOverviewResult | null;
/** 一键查询得到的已兑换高价值套装,为 null 时表示未查询 */
high_value_owned: string[] | null;
/** 官方 storehome 的三态准入,不知道时绝不等于可兑换 */
high_value: HighValueStatus;
overview_queried_at: string | null;
/** 开通时间来自事件表(上线后才开始记录,更早的不显示) */
opened_at: { elite: string | null; esports: string | null };
@@ -374,9 +395,9 @@ export interface AccountItem {
exchange_count: number;
last_exchange_at: string | null;
last_exchange_name: string;
/** 一键查询得到的「已兑换过的高价值套装」,为 null 时表示未查询 */
high_value_owned: string[] | null;
/** 最近一次一键查询时间 —— 用来区分「查过但没兑换」和「还没查过」 */
/** 官方 storehome 的三态准入 */
high_value: HighValueStatus;
/** 最近一次完整一键查询时间 */
overview_queried_at: string | null;
}
+36 -1
View File
@@ -7,7 +7,7 @@ import type { TableProps } from 'antd';
import {
ArrowLeftOutlined, EditOutlined, ReloadOutlined, ThunderboltOutlined,
} from '@ant-design/icons';
import { accountApi, douyuApi, userApi, type AccountEventItem, type AccountOverview, type UserInfo } from '../api/modules';
import { accountApi, douyuApi, userApi, type AccountEventItem, type AccountOverview, type HighValueAssetState, type HighValueStatus, type UserInfo } from '../api/modules';
import AccountOverviewPanel from './douyu-tasks/AccountOverviewPanel';
import { getErrorMessage } from '../utils/error';
import { message } from '../utils/antdMessage';
@@ -64,6 +64,33 @@ const BIND_TONES: Record<string, string> = {
unknown: 'default',
};
function highValueShortTag(label: string, status: HighValueAssetState) {
if (status === 'owned') return <Tag color="orange">{label}已达上限</Tag>;
if (status === 'available') return <Tag color="green">{label}可换</Tag>;
return <Tag>{label}待确认</Tag>;
}
function CurrentHighValue({ value }: { value: HighValueStatus }) {
if (value.state === 'never') {
return <Text type="secondary">尚未成功获取官方商城准入状态</Text>;
}
if (value.state === 'stale') {
return (
<Tooltip title={`上次检查:${formatTime(value.checked_at)}`}>
<Tag>官方状态已过期,需要重新查询</Tag>
</Tooltip>
);
}
return (
<Tooltip title={`官方 storehome 检查于 ${formatTime(value.checked_at)}`}>
<Space wrap size={4}>
{highValueShortTag('白日梦', value.bairimeng)}
{highValueShortTag('萌兔', value.mengtu)}
</Space>
</Tooltip>
);
}
function eventTag(type: string) {
const style = EVENT_STYLE[type] ?? { label: type, color: 'default' };
return <Tag color={style.color}>{style.label}</Tag>;
@@ -665,6 +692,14 @@ export default function AccountDetailPage() {
{ovError}
</Text>
)}
{overview?.high_value && (
<div style={{ marginBottom: 10 }}>
<Text type="secondary" style={{ marginRight: 8 }}>
当前官方准入:
</Text>
<CurrentHighValue value={overview.high_value} />
</div>
)}
{overview?.overview_snapshot ? (
<AccountOverviewPanel result={overview.overview_snapshot} />
) : (
@@ -2,7 +2,7 @@ import type { TableProps } from 'antd';
import { Space, Button, Typography, Tag, Tooltip, Select, Popconfirm } from 'antd';
import { CopyOutlined, DeleteOutlined } from '@ant-design/icons';
import type { NavigateFunction } from 'react-router-dom';
import type { AccountItem } from '../../../api/modules';
import type { AccountItem, HighValueAssetState, HighValueStatus } from '../../../api/modules';
import { message } from '../../../utils/antdMessage';
import { copyTextToClipboard } from '../../../utils/clipboard';
import { formatTimeShort } from '../../../utils/time';
@@ -39,6 +39,49 @@ function BindChip({ scope, status }: { scope: string; status?: string }) {
);
}
function highValueAssetTag(
shortName: string,
state: HighValueAssetState,
) {
if (state === 'owned') {
return <Tag color="orange" style={{ marginInlineEnd: 0 }}>{shortName}已换</Tag>;
}
if (state === 'available') {
return <Tag color="green" style={{ marginInlineEnd: 0 }}>{shortName}可换</Tag>;
}
return <Tag style={{ marginInlineEnd: 0 }}>{shortName}待确认</Tag>;
}
function renderHighValueStatus(highValue: HighValueStatus | undefined) {
if (!highValue || highValue.state === 'never') {
return (
<Tooltip title="尚未成功读取官方商城准入状态,点工具栏「批量一键查询」">
<Text type="secondary">未查询</Text>
</Tooltip>
);
}
if (highValue.state === 'stale') {
return (
<Tooltip
title={`官方商城结果已过期,上次检查:${formatTimeShort(highValue.checked_at)}`}
>
<Tag>数据已过期</Tag>
</Tooltip>
);
}
const checked = highValue.checked_at
? `官方 storehome 检查于 ${formatTimeShort(highValue.checked_at)}`
: '官方 storehome 状态';
return (
<Tooltip title={checked}>
<Space size={4} wrap>
{highValueAssetTag('白日梦', highValue.bairimeng)}
{highValueAssetTag('萌兔', highValue.mengtu)}
</Space>
</Tooltip>
);
}
export interface UseAccountColumnsOptions {
navigate: NavigateFunction;
sortKey: AccountSortKey | '';
@@ -270,39 +313,23 @@ export function buildAccountColumns({
},
{
title: '高价值套装',
dataIndex: 'high_value_owned',
dataIndex: 'high_value',
...sortProps('high_value', '两个都可兑换在前,未查询在后'),
width: 170,
render: (owned: string[] | null | undefined, record: AccountItem) => {
if (owned === null || owned === undefined || !record.overview_queried_at) {
return (
<Tooltip title="尚未查询高价值套装准入状态,点工具栏「批量一键查询」">
<Text type="secondary">未查询</Text>
</Tooltip>
);
}
if (owned.length === 0) {
return <Tag color="green">两个都可兑换</Tag>;
}
return (
<Space size={4} wrap>
{owned.map((n) => (
<Tag key={n} color="orange">
{n.replace(/^套装-/, '')} 已达上限
</Tag>
))}
</Space>
);
},
width: 190,
render: (_, record: AccountItem) => renderHighValueStatus(record.high_value),
},
{
title: '兑换记录',
title: '官方兑换记录',
dataIndex: 'exchange_count',
...sortProps('exchange', '未兑换在前'),
...sortProps('exchange', '没有官方记录在前'),
width: 170,
render: (_, record: AccountItem) => {
if (!record.exchange_count) {
return <Text type="secondary">未兑换</Text>;
return (
<Tooltip title="官方兑换列表当前没有记录;官方会清理历史,不能据此推断账号没换过">
<Text type="secondary">无官方记录</Text>
</Tooltip>
);
}
return (
<Tooltip title="点击查看全部官方兑换记录">
@@ -217,6 +217,58 @@ export function FilterPanel({
setCurrentPage(1);
},
},
{
key: 'high_value_unknown',
label: '高价值待确认',
icon: '❓',
count: summary.high_value?.high_value_unknown,
color: '#8c8c8c',
tooltip: '官方商城未返回套装、查询部分失败,或上次结果已超过 7 天',
active:
highValueFilter === 'high_value_unknown' &&
minPoints === undefined &&
maxPoints === undefined &&
minGold === undefined &&
maxGold === undefined &&
eliteOpened === undefined &&
esportsOpened === undefined &&
!roleBindStatus,
onClick: () => {
if (highValueFilter === 'high_value_unknown') {
setHighValueFilter('');
} else {
onResetAdvancedFiltersState();
setHighValueFilter('high_value_unknown');
}
setCurrentPage(1);
},
},
{
key: 'high_value_never',
label: '未读到商城',
icon: '❔',
count: summary.high_value?.high_value_never,
color: '#8c8c8c',
tooltip: '从未成功读到官方商城准入状态:商城接口失败,或未配置精英宝典 manualID',
active:
highValueFilter === 'high_value_never' &&
minPoints === undefined &&
maxPoints === undefined &&
minGold === undefined &&
maxGold === undefined &&
eliteOpened === undefined &&
esportsOpened === undefined &&
!roleBindStatus,
onClick: () => {
if (highValueFilter === 'high_value_never') {
setHighValueFilter('');
} else {
onResetAdvancedFiltersState();
setHighValueFilter('high_value_never');
}
setCurrentPage(1);
},
},
{
key: 'high_points',
label: '积分≥300',
+2
View File
@@ -82,6 +82,8 @@ export const HIGH_VALUE_OPTIONS = [
{ value: 'owned_mengtu', label: '✓ 已拥有萌兔' },
{ value: 'both_owned', label: '✓ 两套均已拥有' },
{ value: 'high_value_broken', label: '⚠️ 高价值但CK失效' },
{ value: 'high_value_unknown', label: '❓ 高价值待确认/已过期' },
{ value: 'high_value_never', label: '❓ 从未读到官方商城状态' },
{ value: 'unqueried', label: '❓ 未查过资料' },
];
@@ -5,22 +5,27 @@ const { Text } = Typography;
/** 一键查询的结果结构(后端 `query_account_overview` 写入 task.result)。 */
export interface HighValueGoods {
key?: 'bairimeng' | 'mengtu';
name: string;
commodity_id?: string;
score?: number | null;
count?: number | null;
status?: number | string | null;
store_status?: number | null;
count_limited?: number | null;
start_time?: string | number | null;
end_time?: string | number | null;
pic?: string;
/** **是否已获得** —— 依据是斗鱼官方的账号级兑换记录,不是全局库存 */
/** 是否已达兑换上限 —— 唯一依据是官方 storehome.status */
owned?: boolean | null;
available?: boolean | null;
/** owned / available / unknown */
state?: 'owned' | 'available' | 'unknown';
/** 官方兑换记录里命中的商品名(可能是「破片手榴弹-兔兔白日梦」这类变体) */
obtained_names?: string[];
/** 该商品当前是否在限兑列表里(全局信息,与"是否已获得"无关) */
in_goods_list?: boolean;
/** 限兑列表和兑换记录里都没有这个商品 */
/** 官方商城本次没有返回这个商品 */
missing?: boolean;
reason?: string;
}
@@ -67,6 +72,7 @@ const ERROR_LABELS: Record<string, string> = {
points: '积分',
elite: '精英宝典',
esports: '电竞手册',
limited_goods: '官方商城商品列表',
high_value_goods: '高价值套装',
exchange_records: '兑换记录',
};
@@ -97,13 +103,6 @@ function formatTime(value: string | number | null | undefined): string {
}
/**
* 高价值套装卡片。
*
* ⚠️ 措辞刻意不用「已拥有」:`storeStatus=0` 也可能是**全局售罄/活动下架**,
* 我们没法只凭它断言账号真的拿到了。所以写「不可再兑」并把 count 一起显示,
* 让运营自己判断 —— 这比给一个可能错的结论好。
*/
/**
* 高价值套装的展示状态。
*
@@ -112,19 +111,13 @@ function formatTime(value: string | number | null | undefined): string {
* 兑换过 → 「已兑换」**橙色**(价值降低)
* 同一件东西两个都兑换过,账号价值最低。
*
* 「是否已获得」只看**官方兑换记录**:`count`/`store_status` 是全局库存,
* 同一时刻不同账号看到的都一样,不能用来判断这个账号有没有。
* 当前准入只看官方 storehome.status:1=已达上限、0=可兑换。
* count/foreverCount/storeStatus 是全局库存或商店状态,不能判断账号是否已换。
*/
function goodsState(g: HighValueGoods): { text: string; color: string } {
if (g.missing) return { text: '未查到该商品', color: 'default' };
if (g.owned) {
const isLimit = g.reason?.includes('已达兑换上限');
return {
text: isLimit ? '已达上限(不可兑)' : '已兑换(价值降低)',
color: 'orange',
};
}
return { text: '可兑换', color: 'green' };
if (g.state === 'owned') return { text: '已达上限(不可兑)', color: 'orange' };
if (g.state === 'available') return { text: '可兑换', color: 'green' };
return { text: '状态未知', color: 'default' };
}
const goodsColumns: ColumnsType<HighValueGoods> = [
@@ -135,7 +128,7 @@ const goodsColumns: ColumnsType<HighValueGoods> = [
width: 200,
render: (name: string, g) =>
g.missing ? (
<Tooltip title={g.reason || '商品列表和官方兑换记录里都没有它'}>
<Tooltip title={g.reason || '官方商城本次没有返回该商品'}>
<Text type="secondary">{name}</Text>
</Tooltip>
) : (
@@ -162,8 +155,11 @@ const goodsColumns: ColumnsType<HighValueGoods> = [
key: 'reason',
width: 220,
render: (_, g) => (
<Text type={g.owned ? 'warning' : 'secondary'} style={{ fontSize: 13 }}>
{g.reason || (g.owned ? '官方限制兑换' : '官方商城可正常兑换')}
<Text
type={g.state === 'owned' ? 'warning' : 'secondary'}
style={{ fontSize: 13 }}
>
{g.reason || (g.state === 'available' ? '官方商城可正常兑换' : '等待官方状态')}
</Text>
),
},