task-115(任务与重复分析中心): 实现重复检查总览指标

新增 duplicate-model.ts(总览/店铺分布/明细项/出现记录 snake 解析) 与
duplicate-api.ts(fetchDuplicateOverview GET /duplicate-check-overview)。

TDD: task-115.test.ts 8 用例先 RED 后 GREEN。
This commit is contained in:
2026-09-05 17:23:12 +08:00
parent c919780875
commit 61b2d7152d
3 changed files with 242 additions and 0 deletions
@@ -0,0 +1,13 @@
/** 店铺数据重复检查适配(任务 115-119):/api/admin/shop-data-crawl/duplicate-check-* 系列端点。 */
import { http } from '@/api/http'
import { parseDuplicateOverview, type DuplicateOverview } from './duplicate-model.ts'
export const DUPLICATE_CHECK_ENDPOINT = '/api/admin/shop-data-crawl'
/** 加载撞款总览(可选 force=1 触发同步重扫)。 */
export async function fetchDuplicateOverview(force = false): Promise<DuplicateOverview> {
const { data } = await http.get<unknown>(`${DUPLICATE_CHECK_ENDPOINT}/duplicate-check-overview`, {
params: force ? { force: '1' } : undefined,
})
return parseDuplicateOverview(data)
}
@@ -0,0 +1,140 @@
/** 重复检查模型(任务 115-118):撞款总览/店铺分布/明细项/详情解析;与 Java shopduplicatecheck snake 契约对齐,纯逻辑。 */
import { unwrap } from '../../api/envelope.ts'
export interface DuplicateMetrics {
shopCount: number
asinTotal: number
recordTotal: number
duplicateAsinTotal: number
duplicateShopCount: number
siteCount: number
asinPerShop: number
source: string
}
export interface DuplicateShop {
shopName: string
groupName: string
countryCodes: string[]
asinCount: number
recordCount: number
}
export interface DuplicateOccurrence {
asin: string
date: string
price: string
brand: string
shopName: string
groupName: string
countryCodes: string[]
country: string
}
export interface DuplicateItem {
asin: string
shopCount: number
recordCount: number
occurrences: DuplicateOccurrence[]
}
export interface DuplicateOverview {
pending: boolean
scannedAt: string
summary: DuplicateMetrics | null
shops: DuplicateShop[]
}
function text(value: unknown): string {
return typeof value === 'string' ? value.trim() : ''
}
function count(value: unknown): number {
return typeof value === 'number' && Number.isFinite(value) ? Math.floor(value) : 0
}
function double(value: unknown): number {
return typeof value === 'number' && Number.isFinite(value) ? value : 0
}
function strings(value: unknown): string[] {
return Array.isArray(value) ? value.map((code) => text(code)).filter(Boolean) : []
}
/** 解析单店铺分布行。 */
export function parseDuplicateShop(raw: unknown): DuplicateShop | null {
if (!raw || typeof raw !== 'object') return null
const r = raw as Record<string, unknown>
return {
shopName: text(r.shop_name ?? r.shopName),
groupName: text(r.group_name ?? r.groupName),
countryCodes: strings(r.country_codes ?? r.countryCodes),
asinCount: count(r.asin_count ?? r.asinCount),
recordCount: count(r.record_count ?? r.recordCount),
}
}
/** 解析单条上架出现。 */
export function parseDuplicateOccurrence(raw: unknown): DuplicateOccurrence | null {
if (!raw || typeof raw !== 'object') return null
const r = raw as Record<string, unknown>
return {
asin: text(r.asin),
date: text(r.date),
price: text(r.price),
brand: text(r.brand),
shopName: text(r.shop_name ?? r.shopName),
groupName: text(r.group_name ?? r.groupName),
countryCodes: strings(r.country_codes ?? r.countryCodes),
country: text(r.country),
}
}
/** 解析 ASIN 维度明细项。 */
export function parseDuplicateItem(raw: unknown): DuplicateItem | null {
if (!raw || typeof raw !== 'object') return null
const r = raw as Record<string, unknown>
const asin = text(r.asin)
if (!asin) return null
return {
asin,
shopCount: count(r.shop_count ?? r.shopCount),
recordCount: count(r.record_count ?? r.recordCount),
occurrences: Array.isArray(r.occurrences)
? r.occurrences.map((occ) => parseDuplicateOccurrence(occ)).filter((occ): occ is DuplicateOccurrence => occ !== null)
: [],
}
}
/** 总览负载(data: {pending,scanned_at,summary,shops}) → 类型化;缺省回 pending 空态。 */
export function parseDuplicateOverview(payload: unknown): DuplicateOverview {
const core = unwrap<unknown>(payload)
const record = core && typeof core === 'object' ? (core as Record<string, unknown>) : {}
const summary = record.summary && typeof record.summary === 'object' && Object.keys(record.summary as Record<string, unknown>).length
? parseDuplicateMetrics(record.summary)
: null
const shops = Array.isArray(record.shops)
? record.shops.map((raw) => parseDuplicateShop(raw)).filter((shop): shop is DuplicateShop => shop !== null)
: []
return {
pending: record.pending === true || summary === null,
scannedAt: text(record.scanned_at ?? record.scannedAt),
summary,
shops,
}
}
function parseDuplicateMetrics(raw: unknown): DuplicateMetrics | null {
if (!raw || typeof raw !== 'object') return null
const r = raw as Record<string, unknown>
return {
shopCount: count(r.shop_count ?? r.shopCount),
asinTotal: count(r.asin_total ?? r.asinTotal),
recordTotal: count(r.record_total ?? r.recordTotal),
duplicateAsinTotal: count(r.duplicate_asin_total ?? r.duplicateAsinTotal),
duplicateShopCount: count(r.duplicate_shop_count ?? r.duplicateShopCount),
siteCount: count(r.site_count ?? r.siteCount),
asinPerShop: double(r.asin_per_shop ?? r.asinPerShop),
source: text(r.source),
}
}