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:
yml2213
2026-10-17 23:32:51 +08:00
parent b47dde47e9
commit c441b6c663
10 changed files with 722 additions and 121 deletions
+9 -3
View File
@@ -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) =>
+31 -6
View File
@@ -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[];
}
+109 -51
View File
@@ -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>
)}