feat(huya): 批量查询拆成「查积分」与「查兑换记录」两个按钮
前端工具栏原来一个按钮同时查两件事,拆开后每次只查一半,所以重点在 「没查的那一半不能被当成没查到」: - 接口加 scope(both / score / records),默认 both 保持原行为; 只查积分时不产生判定(空串「未判定」,与 unknown「无法判定」分开), 也**不会**自动改业务状态(连判定都没有,凭什么改)。 - 列表的积分与判定各取「最近一条查到过它的」快照:点「查积分」不再把 整页判定徽标冲成未判定,点「查兑换记录」也不再清空积分;但新的 unknown 仍会盖掉旧的 success(抖动结果不许藏)。 - 结果弹窗 / 积分列 Tooltip / 生命周期时间线都按 scope 显示, 不再把「本次没查」显示成「积分 -」「兑换记录 0 条」。 - 新增 12 例测试(TestCollabActivityScopeSplit),并做了 4 次反向验证 (去掉判定分支 / 判定跟随最新 / 积分跟随最新 / 删掉响应字段),全部确认变红。 顺带修掉文档漂移:AGENTS.md 的迁移 head 早就过期(main 实际 20261016_0059、dev 20261008_0053),并补上「两侧已分叉成两个 head、 合并 dev 前必须先 alembic merge」这条;设计文档补 §11.9。
This commit is contained in:
@@ -34,6 +34,7 @@ import type {
|
||||
HuyaCollabAuditItem,
|
||||
HuyaCollabActivityBatchResult,
|
||||
HuyaCollabActivityItem,
|
||||
HuyaCollabActivityScope,
|
||||
HuyaCollabCkExportResult,
|
||||
HuyaCollabCkPreview,
|
||||
HuyaCollabCkRequest,
|
||||
@@ -271,11 +272,16 @@ export const huyaApi = {
|
||||
api.post<HuyaCollabActivityItem, HuyaCollabActivityItem>(
|
||||
`/huya/external-orders/${id}/activity/query`,
|
||||
),
|
||||
/** 勾选多行查询积分 + 兑换记录(串行带间隔) */
|
||||
batchQueryCollabOrderActivity: (ids: number[]) =>
|
||||
/**
|
||||
* 勾选多行查积分 / 查兑换记录(串行带间隔)。
|
||||
*
|
||||
* `scope` 决定查哪一半(页面上是两个独立按钮):`score` 只查积分、
|
||||
* `records` 只查兑换记录、`both` 两个都查(默认,保持旧行为)。
|
||||
*/
|
||||
batchQueryCollabOrderActivity: (ids: number[], scope: HuyaCollabActivityScope = 'both') =>
|
||||
api.post<HuyaCollabActivityBatchResult, HuyaCollabActivityBatchResult>(
|
||||
'/huya/external-orders/activity/query',
|
||||
{ ids },
|
||||
{ ids, scope },
|
||||
),
|
||||
/** 这一单的「账号全生命周期」时间线(每次查询的快照,新→旧) */
|
||||
collabOrderMetrics: (id: number) =>
|
||||
|
||||
@@ -1274,12 +1274,20 @@ export interface HuyaCollabOrder {
|
||||
* 只有真的 ≥2 单才有值,否则是 null。
|
||||
*/
|
||||
buyer_cluster: HuyaCollabBuyerCluster | null;
|
||||
/** 最近一次「查积分 + 兑换记录」的快照(完整时间线走 GET /{id}/metrics) */
|
||||
/**
|
||||
* 最近一次「查积分 / 查兑换记录」的快照(完整时间线走 GET /{id}/metrics)。
|
||||
*
|
||||
* 拆分两个查询入口后,积分与判定**各自取最近一条查到过它的**:
|
||||
* 只查积分的那次不产生判定,只查兑换记录的那次不产生积分。
|
||||
*/
|
||||
last_score: number | null;
|
||||
last_metric_at: string | null;
|
||||
last_metric_message: string;
|
||||
last_verdict: HuyaCollabVerdict | '';
|
||||
last_verdict_label: string;
|
||||
last_metric_message: string;
|
||||
/** 那条判定是什么时候查出来的(与 `last_metric_at` 可能不是同一次) */
|
||||
last_verdict_at: string | null;
|
||||
last_verdict_message: string;
|
||||
}
|
||||
|
||||
/** 「同一买家的其它单」里的一条 */
|
||||
@@ -1307,6 +1315,13 @@ export interface HuyaCollabBuyerCluster {
|
||||
/** 系统与接口的判定结论(人工不写,只读) */
|
||||
export type HuyaCollabVerdict = 'success' | 'failed' | 'unknown';
|
||||
|
||||
/**
|
||||
* 本次查询查了哪一半(两个批量按钮各用一个):
|
||||
* `both` 两个接口都查、`score` 只查积分、`records` 只查兑换记录。
|
||||
* 只查积分时没有判定结论,条目的 `verdict` 是**空串**(不是 `unknown`)。
|
||||
*/
|
||||
export type HuyaCollabActivityScope = 'both' | 'score' | 'records';
|
||||
|
||||
/** 「账号全生命周期」里匹配到的兑换记录 */
|
||||
export interface HuyaCollabMetricMatched {
|
||||
prize_name: string;
|
||||
@@ -1316,7 +1331,7 @@ export interface HuyaCollabMetricMatched {
|
||||
exchange_time: number | null;
|
||||
}
|
||||
|
||||
/** 一次「查积分 + 兑换记录」的快照(只追加,不更新) */
|
||||
/** 一次「查积分 / 兑换记录」的快照(只追加,不更新) */
|
||||
export interface HuyaCollabMetric {
|
||||
id: number;
|
||||
order_id: number;
|
||||
@@ -1328,7 +1343,10 @@ export interface HuyaCollabMetric {
|
||||
used_score: number | null;
|
||||
records_total: number;
|
||||
records_matched: number;
|
||||
verdict: HuyaCollabVerdict;
|
||||
/** 这条快照查了哪一半:时间线据此决定显示「积分」还是「本次没查积分」 */
|
||||
scope: HuyaCollabActivityScope;
|
||||
/** 只查积分的那条快照是**空串**(本次没查兑换记录,不算「无法判定」) */
|
||||
verdict: HuyaCollabVerdict | '';
|
||||
verdict_label: string;
|
||||
message: string;
|
||||
/** manual(人工点)/ batch(勾选批量) */
|
||||
@@ -1344,7 +1362,10 @@ export interface HuyaCollabActivityItem {
|
||||
ok: boolean;
|
||||
order_id: number;
|
||||
order_no: string;
|
||||
verdict: HuyaCollabVerdict;
|
||||
/** 本次查的是哪一半(只查积分时 verdict 是空串) */
|
||||
scope: HuyaCollabActivityScope | '';
|
||||
scope_label: string;
|
||||
verdict: HuyaCollabVerdict | '';
|
||||
verdict_label: string;
|
||||
score: number | null;
|
||||
gained_score: number | null;
|
||||
@@ -1359,13 +1380,17 @@ export interface HuyaCollabActivityItem {
|
||||
status_label: string;
|
||||
}
|
||||
|
||||
/** 批量查询汇总 */
|
||||
/** 批量查询汇总(`success + failed + unknown + not_judged = total`) */
|
||||
export interface HuyaCollabActivityBatchResult {
|
||||
total: number;
|
||||
success: number;
|
||||
failed: number;
|
||||
unknown: number;
|
||||
/** 本次没查兑换记录、因此没有判定结论的行数(只查积分时全落这里) */
|
||||
not_judged: number;
|
||||
auto_changed: number;
|
||||
scope: HuyaCollabActivityScope | '';
|
||||
scope_label: string;
|
||||
results: HuyaCollabActivityItem[];
|
||||
}
|
||||
|
||||
|
||||
@@ -15,12 +15,13 @@ import {
|
||||
import type { TableProps } from 'antd';
|
||||
import {
|
||||
CheckCircleOutlined, CloudUploadOutlined, CopyOutlined, DeleteOutlined, EditOutlined, FileAddOutlined,
|
||||
FileExcelOutlined, LinkOutlined, PlusOutlined, ReloadOutlined, SafetyCertificateOutlined, ShopOutlined,
|
||||
StopOutlined, SwapOutlined, SyncOutlined, UndoOutlined,
|
||||
FileExcelOutlined, GiftOutlined, LinkOutlined, PlusOutlined, ReloadOutlined, SafetyCertificateOutlined,
|
||||
ShopOutlined, StopOutlined, SwapOutlined, SyncOutlined, UndoOutlined,
|
||||
} from '@ant-design/icons';
|
||||
import {
|
||||
fileApi,
|
||||
huyaApi,
|
||||
type HuyaCollabActivityScope,
|
||||
type HuyaCollabAuditItem,
|
||||
type HuyaCollabCkExportResult,
|
||||
type HuyaCollabCkPreview,
|
||||
@@ -788,21 +789,41 @@ export default function HuyaCollabPage() {
|
||||
}
|
||||
};
|
||||
|
||||
/** 勾选批量查询(串行带间隔,防风控) */
|
||||
const queryActivitySelected = async () => {
|
||||
/**
|
||||
* 勾选批量查询(串行带间隔,防风控)。
|
||||
*
|
||||
* `scope` 决定查哪一半 —— 界面上是**两个独立按钮**(2026-10-17 用户要求):
|
||||
* 「查积分」只打积分接口,不会产生判定、也**不会自动改状态**;
|
||||
* 「查兑换记录」只打兑换记录接口,判定与自动改状态照旧。
|
||||
*/
|
||||
const queryActivitySelected = async (scope: HuyaCollabActivityScope) => {
|
||||
if (selectedIds.length === 0) return;
|
||||
setQuerying(true);
|
||||
try {
|
||||
const result = await huyaApi.batchQueryCollabOrderActivity(selectedIds);
|
||||
const result = await huyaApi.batchQueryCollabOrderActivity(selectedIds, scope);
|
||||
const scoreScope = scope === 'score';
|
||||
const missed = result.results.filter((item) => !item.ok).length;
|
||||
modal.info({
|
||||
title: `查询完成:成功 ${result.success} / 失败 ${result.failed} / 无法判定 ${result.unknown}`
|
||||
+ (result.auto_changed ? `(${result.auto_changed} 条按结果自动改状态)` : ''),
|
||||
title: scoreScope
|
||||
? `查积分完成:${result.total} 条` + (missed ? `(${missed} 条没拿到积分)` : '')
|
||||
: `查兑换记录完成:成功 ${result.success} / 失败 ${result.failed} / 无法判定 ${result.unknown}`
|
||||
+ (result.auto_changed ? `(${result.auto_changed} 条按结果自动改状态)` : ''),
|
||||
width: 760,
|
||||
content: (
|
||||
<Space direction="vertical" style={{ width: '100%' }}>
|
||||
{scoreScope && (
|
||||
<Text type="secondary" style={{ fontSize: 12 }}>
|
||||
这一趟只查积分,没有查兑换记录,所以不会改业务状态(判定那栏保持上一次的结论)。
|
||||
</Text>
|
||||
)}
|
||||
{result.results.map((item) => (
|
||||
<Text key={item.order_id} type={item.verdict === 'failed' ? 'danger' : undefined}>
|
||||
{item.order_no}:{item.verdict_label} · {item.message}
|
||||
<Text
|
||||
key={item.order_id}
|
||||
type={!item.ok ? 'danger' : item.verdict === 'failed' ? 'danger' : undefined}
|
||||
>
|
||||
{item.order_no}:
|
||||
{/* 只查积分时 verdict 是空的(未判定),没必要每行都挂一个「未判定」标签 */}
|
||||
{scoreScope ? item.message : `${item.verdict_label} · ${item.message}`}
|
||||
{item.auto_status ? `;状态已改为「${item.auto_status_label}」` : ''}
|
||||
</Text>
|
||||
))}
|
||||
@@ -1767,8 +1788,16 @@ export default function HuyaCollabPage() {
|
||||
return <Text type="secondary" style={{ fontSize: 12 }}>还没查</Text>;
|
||||
}
|
||||
const verdict = VERDICT_META[row.last_verdict];
|
||||
// 积分与判定可能是**两次不同的查询**(查积分 / 查兑换记录是两个按钮):
|
||||
// 徽标保留上一次的判定结论,各自把自己的时间与结论写清楚,别让人以为徽标也是刚刚那次的。
|
||||
const tip = [
|
||||
`最新查询(${formatTime(row.last_metric_at)}):${row.last_metric_message || '—'}`,
|
||||
verdict
|
||||
? `判定(${formatTime(row.last_verdict_at)}):${row.last_verdict_message || '—'}`
|
||||
: '',
|
||||
].filter(Boolean).join('\n');
|
||||
return (
|
||||
<Tooltip title={`${row.last_metric_message || ''}(${formatTime(row.last_metric_at)})`}>
|
||||
<Tooltip title={<span style={{ whiteSpace: 'pre-line' }}>{tip}</span>}>
|
||||
<Space direction="vertical" size={0}>
|
||||
<Text strong>{row.last_score ?? '-'}</Text>
|
||||
{verdict && <Tag color={verdict.color} style={{ marginTop: 2 }}>{verdict.label}</Tag>}
|
||||
@@ -1996,11 +2025,21 @@ export default function HuyaCollabPage() {
|
||||
</Dropdown>
|
||||
)}
|
||||
{canEdit && (
|
||||
<Button icon={<SyncOutlined />} disabled={selectedIds.length === 0 || busy}
|
||||
loading={querying}
|
||||
onClick={() => void queryActivitySelected()}>
|
||||
批量查积分/兑换{selectedIds.length > 0 ? `(${selectedIds.length})` : ''}
|
||||
</Button>
|
||||
// 查积分 / 查兑换记录**拆成两个按钮**(2026-10-17):查积分只写积分快照,
|
||||
// 既不判定也不自动改状态;只有查兑换记录才产出判定并可能自动改状态。
|
||||
// 合成一个按钮时「只想要积分」也会顺带把状态改了,两边互相干扰。
|
||||
<>
|
||||
<Button icon={<SyncOutlined />} disabled={selectedIds.length === 0 || busy}
|
||||
loading={querying}
|
||||
onClick={() => void queryActivitySelected('score')}>
|
||||
批量查积分{selectedIds.length > 0 ? `(${selectedIds.length})` : ''}
|
||||
</Button>
|
||||
<Button icon={<GiftOutlined />} disabled={selectedIds.length === 0 || busy}
|
||||
loading={querying}
|
||||
onClick={() => void queryActivitySelected('records')}>
|
||||
批量查兑换记录{selectedIds.length > 0 ? `(${selectedIds.length})` : ''}
|
||||
</Button>
|
||||
</>
|
||||
)}
|
||||
{canExportCk && (
|
||||
// 导出 CK 到剪贴板(勾选几行就导几行;明文只在这个弹窗里出现)
|
||||
@@ -2367,45 +2406,64 @@ export default function HuyaCollabPage() {
|
||||
)}
|
||||
<Timeline
|
||||
pending={metricsLoading ? '查询中…' : undefined}
|
||||
items={metrics.map((item) => ({
|
||||
color: item.verdict === 'success' ? 'green' : item.verdict === 'failed' ? 'red' : 'gray',
|
||||
children: (
|
||||
<Space direction="vertical" size={2} style={{ width: '100%' }}>
|
||||
<Space size={6} wrap>
|
||||
<Tag color={VERDICT_META[item.verdict]?.color}>{item.verdict_label}</Tag>
|
||||
<Text strong>积分 {item.score ?? '-'}</Text>
|
||||
items={metrics.map((item) => {
|
||||
// 一次查询只查一半时,另一半**压根没查** —— 别把「没查」显示成「积分 -」「0 条」
|
||||
const scope = item.scope || 'both';
|
||||
const hasScore = scope !== 'records';
|
||||
const hasRecords = scope !== 'score';
|
||||
return {
|
||||
color: item.verdict === 'success' ? 'green' : item.verdict === 'failed' ? 'red' : 'gray',
|
||||
children: (
|
||||
<Space direction="vertical" size={2} style={{ width: '100%' }}>
|
||||
<Space size={6} wrap>
|
||||
<Tag color={VERDICT_META[item.verdict]?.color}>{item.verdict_label}</Tag>
|
||||
{hasScore ? (
|
||||
<>
|
||||
<Text strong>积分 {item.score ?? '-'}</Text>
|
||||
<Text type="secondary" style={{ fontSize: 12 }}>
|
||||
获得 {item.gained_score ?? '-'} / 消耗 {item.used_score ?? '-'}
|
||||
</Text>
|
||||
</>
|
||||
) : (
|
||||
<Text type="secondary" style={{ fontSize: 12 }}>本次没查积分</Text>
|
||||
)}
|
||||
{hasRecords ? (
|
||||
<Text type="secondary" style={{ fontSize: 12 }}>
|
||||
兑换记录 {item.records_total} 条(命中 {item.records_matched})
|
||||
</Text>
|
||||
) : (
|
||||
<Text type="secondary" style={{ fontSize: 12 }}>本次没查兑换记录</Text>
|
||||
)}
|
||||
</Space>
|
||||
{item.message && (
|
||||
<Text type={item.verdict === 'failed' ? 'danger' : 'secondary'} style={{ fontSize: 12 }}>
|
||||
{item.message}
|
||||
</Text>
|
||||
)}
|
||||
{item.matched_preview.length > 0 && (
|
||||
<Space direction="vertical" size={0}>
|
||||
{item.matched_preview.map((matched, index) => (
|
||||
<Text key={`${matched.order_id}-${index}`} style={{ fontSize: 12 }}>
|
||||
<Text code style={{ fontSize: 12 }}>{matched.prize_name || '(无礼包名)'}</Text>
|
||||
{' '}
|
||||
<Text type="secondary" style={{ fontSize: 12 }}>
|
||||
{matched.status_label}
|
||||
{matched.order_id ? ` · 兑换单 ${matched.order_id}` : ''}
|
||||
{matched.exchange_time ? ` · ${formatTime(new Date(matched.exchange_time * 1000).toISOString())}` : ''}
|
||||
</Text>
|
||||
</Text>
|
||||
))}
|
||||
</Space>
|
||||
)}
|
||||
<Text type="secondary" style={{ fontSize: 12 }}>
|
||||
获得 {item.gained_score ?? '-'} / 消耗 {item.used_score ?? '-'}
|
||||
{' · '}兑换记录 {item.records_total} 条(命中 {item.records_matched})
|
||||
{formatTime(item.created_at)} · {item.created_by_name || '系统'}
|
||||
{' · '}{item.source === 'batch' ? '批量查询' : '手动查询'}
|
||||
{' · '}{scope === 'both' ? '积分 + 兑换记录' : scope === 'score' ? '只查积分' : '只查兑换记录'}
|
||||
</Text>
|
||||
</Space>
|
||||
{item.message && (
|
||||
<Text type={item.verdict === 'failed' ? 'danger' : 'secondary'} style={{ fontSize: 12 }}>
|
||||
{item.message}
|
||||
</Text>
|
||||
)}
|
||||
{item.matched_preview.length > 0 && (
|
||||
<Space direction="vertical" size={0}>
|
||||
{item.matched_preview.map((matched, index) => (
|
||||
<Text key={`${matched.order_id}-${index}`} style={{ fontSize: 12 }}>
|
||||
<Text code style={{ fontSize: 12 }}>{matched.prize_name || '(无礼包名)'}</Text>
|
||||
{' '}
|
||||
<Text type="secondary" style={{ fontSize: 12 }}>
|
||||
{matched.status_label}
|
||||
{matched.order_id ? ` · 兑换单 ${matched.order_id}` : ''}
|
||||
{matched.exchange_time ? ` · ${formatTime(new Date(matched.exchange_time * 1000).toISOString())}` : ''}
|
||||
</Text>
|
||||
</Text>
|
||||
))}
|
||||
</Space>
|
||||
)}
|
||||
<Text type="secondary" style={{ fontSize: 12 }}>
|
||||
{formatTime(item.created_at)} · {item.created_by_name || '系统'}
|
||||
{' · '}{item.source === 'batch' ? '批量查询' : '手动查询'}
|
||||
</Text>
|
||||
</Space>
|
||||
),
|
||||
}))}
|
||||
),
|
||||
};
|
||||
})}
|
||||
/>
|
||||
</Space>
|
||||
)}
|
||||
|
||||
Reference in New Issue
Block a user