diff --git a/frontend-vue/src/shared/api/java-modules.ts b/frontend-vue/src/shared/api/java-modules.ts index dc892422..e6ce05f0 100644 --- a/frontend-vue/src/shared/api/java-modules.ts +++ b/frontend-vue/src/shared/api/java-modules.ts @@ -21,6 +21,7 @@ export * from "./types/modules/price-track.ts"; export * from "./types/modules/shop-match.ts"; export * from "./types/modules/query-asin.ts"; export * from "./types/modules/withdraw.ts"; +export * from "./types/modules/collect-data.ts"; import type { ProductRiskCandidateVo, ProductRiskDashboardVo, @@ -433,257 +434,6 @@ export function synthesizeImageVideoVoice(payload: { text: string; voiceId: stri ); } -// ========== 采集数据 ========== - -export interface CollectDataSourceFile { - fileKey: string; - originalFilename?: string; - relativePath?: string; -} - -export interface CollectDataFilters { - amount?: number | string | null; - minAmount?: number | string | null; - maxAmount?: number | string | null; - min_amount?: number | string | null; - max_amount?: number | string | null; - rank?: boolean | null; - fba?: boolean | null; - fbm?: boolean | null; - countryCodes?: string[]; - countryCode?: string | null; - country_code?: string | null; -} - -export interface CollectDataParseRequest { - user_id: number; - files: CollectDataSourceFile[]; - task_type?: string; - filters?: CollectDataFilters; -} - -export interface CollectDataParseVo { - taskId: number; - taskNo?: string; - sourceFilename?: string; - sourceFileCount?: number; - totalRows?: number; - acceptedRows?: number; - droppedRows?: number; - pageSize?: number; -} - -export interface CollectDataDashboardVo { - pendingTaskCount: number; - processedTaskCount: number; - successTaskCount: number; - failedTaskCount: number; -} - -export interface CollectDataHistoryItem { - resultId?: number; - taskId?: number; - taskNo?: string; - sourceFilename?: string; - resultFilename?: string; - downloadUrl?: string; - taskStatus?: string; - success?: boolean; - error?: string; - rowCount?: number; - dedupeFilteredCount?: number; - invalidFilteredCount?: number; - brandRejectedCount?: number; - finalRowCount?: number; - totalRows?: number; - receivedRows?: number; - processedRows?: number; - collectStage?: string; - currentKeyword?: string; - searchCurrentPage?: number; - searchTotalPages?: number; - detailProcessedAsins?: number; - detailTotalAsins?: number; - progressPercent?: number; - createdAt?: string; - startedAt?: string; - finishedAt?: string; - taskType?: string; - filters?: CollectDataFilters; -} - -export interface CollectDataHistoryVo { - items: CollectDataHistoryItem[]; -} - -export interface CollectDataTaskSummary { - id?: number; - taskNo?: string; - status?: string; - errorMessage?: string; - createdAt?: string; - updatedAt?: string; - startedAt?: string; - finishedAt?: string; - taskType?: string; -} - -export interface CollectDataTaskDetailVo { - task?: CollectDataTaskSummary; - items?: CollectDataHistoryItem[]; -} - -export interface CollectDataTaskBatchVo { - items: CollectDataTaskDetailVo[]; - missingTaskIds?: number[]; -} - -export interface CollectDataItemVo { - id?: number; - rowIndex?: number; - sourceFileKey?: string; - sourceFilename?: string; - keyword?: string; - statusValue?: string; - extra?: Record; -} - -export interface CollectDataItemsPageVo { - taskId?: number; - taskNo?: string; - taskType?: string; - taskStatus?: string; - page?: number; - pageSize?: number; - count?: number; - total?: number; - totalPages?: number; - filters?: CollectDataFilters; - items: CollectDataItemVo[]; -} - -export function parseCollectData( - request: Omit | CollectDataParseRequest, -) { - return unwrapJavaResponse( - post, CollectDataParseRequest>( - `${JAVA_API_PREFIX}/collect-data/parse`, - { ...request, user_id: getCurrentUserId() }, - ), - ); -} - -export function activateCollectDataTask(taskId: number) { - return unwrapJavaResponse( - post, undefined>( - `${JAVA_API_PREFIX}/collect-data/tasks/${taskId}/activate?user_id=${encodeURIComponent(String(getCurrentUserId()))}`, - undefined, - ), - ); -} - -export function getCollectDataItemsPage( - taskId: number, - page: number = 1, - pageSize: number = 50, -) { - return unwrapJavaResponse( - get>( - `${JAVA_API_PREFIX}/collect-data/tasks/${taskId}/items`, - { - params: { - user_id: getCurrentUserId(), - page, - page_size: pageSize, - }, - }, - ), - ); -} - -export function getCollectDataDashboard() { - return unwrapJavaResponse( - get>( - `${JAVA_API_PREFIX}/collect-data/dashboard`, - { params: { user_id: getCurrentUserId() } }, - ), - ); -} - -export function getCollectDataHistory(limit: number = 50) { - return unwrapJavaResponse( - get>( - `${JAVA_API_PREFIX}/collect-data/history`, - { params: { user_id: getCurrentUserId(), limit } }, - ), - ); -} - -export function getCollectDataTaskProgressBatch( - taskIds: number[], - options: TaskProgressBatchOptions = {}, -) { - return postTaskProgressBatch( - `${JAVA_API_PREFIX}/collect-data/tasks/progress/batch`, - taskIds, - options, - ); -} - -export function deleteCollectDataTask(taskId: number) { - return unwrapJavaResponse( - del>( - `${JAVA_API_PREFIX}/collect-data/tasks/${taskId}`, - { params: { user_id: getCurrentUserId() } }, - ), - ); -} - -export function deleteCollectDataHistory(resultId: number) { - return unwrapJavaResponse( - del>( - `${JAVA_API_PREFIX}/collect-data/history/${resultId}`, - { params: { user_id: getCurrentUserId() } }, - ), - ); -} - -export interface CollectDataCountryPreferenceVo { - country_codes: string[]; -} - -export function getCollectDataCountryPreference() { - return unwrapJavaResponse( - get>( - `${JAVA_API_PREFIX}/collect-data/country-preference`, - { params: { user_id: getCurrentUserId() } }, - ), - ); -} - -export function putCollectDataCountryPreference(countryCodes: string[]) { - return unwrapJavaResponse( - put< - JavaApiResponse, - { user_id: number; country_codes: string[] } - >(`${JAVA_API_PREFIX}/collect-data/country-preference`, { - user_id: getCurrentUserId(), - country_codes: countryCodes, - }), - ); -} - -export function failCollectDataTask(taskId: number, error?: string) { - const params = new URLSearchParams({ user_id: String(getCurrentUserId()) }); - if (error) params.set('error', error); - return unwrapJavaResponse( - post, undefined>( - `${JAVA_API_PREFIX}/collect-data/tasks/${taskId}/fail?${params.toString()}`, - undefined, - ), - ); -} - export function getJavaDownloadUrl(path: string) { let raw = path.startsWith("http://") || path.startsWith("https://") diff --git a/frontend-vue/src/shared/api/types/modules/collect-data.ts b/frontend-vue/src/shared/api/types/modules/collect-data.ts new file mode 100644 index 00000000..1e0b3b71 --- /dev/null +++ b/frontend-vue/src/shared/api/types/modules/collect-data.ts @@ -0,0 +1,316 @@ +import { get, post, put, del, type JavaApiResponse, unwrapJavaResponse } from '../../http.ts' +import { buildJavaUrl, JAVA_API_PREFIX } from '../../url.ts' +import { getCurrentUserId } from '../../user.ts' +import { getTaskProgressCacheTtlMs, getTaskProgressTimeoutMs } from '../../../task-progress-config.ts' +import { createTaskProgressRequestCache } from '../../../task-progress-request-cache.ts' + +export interface CollectDataSourceFile { + fileKey: string; + originalFilename?: string; + relativePath?: string; +} + +export interface CollectDataFilters { + amount?: number | string | null; + minAmount?: number | string | null; + maxAmount?: number | string | null; + min_amount?: number | string | null; + max_amount?: number | string | null; + rank?: boolean | null; + fba?: boolean | null; + fbm?: boolean | null; + countryCodes?: string[]; + countryCode?: string | null; + country_code?: string | null; +} + +export interface CollectDataParseRequest { + user_id: number; + files: CollectDataSourceFile[]; + task_type?: string; + filters?: CollectDataFilters; +} + +export interface CollectDataParseVo { + taskId: number; + taskNo?: string; + sourceFilename?: string; + sourceFileCount?: number; + totalRows?: number; + acceptedRows?: number; + droppedRows?: number; + pageSize?: number; +} + +export interface CollectDataDashboardVo { + pendingTaskCount: number; + processedTaskCount: number; + successTaskCount: number; + failedTaskCount: number; +} + +export interface CollectDataHistoryItem { + resultId?: number; + taskId?: number; + taskNo?: string; + sourceFilename?: string; + resultFilename?: string; + downloadUrl?: string; + taskStatus?: string; + success?: boolean; + error?: string; + rowCount?: number; + dedupeFilteredCount?: number; + invalidFilteredCount?: number; + brandRejectedCount?: number; + finalRowCount?: number; + totalRows?: number; + receivedRows?: number; + processedRows?: number; + collectStage?: string; + currentKeyword?: string; + searchCurrentPage?: number; + searchTotalPages?: number; + detailProcessedAsins?: number; + detailTotalAsins?: number; + progressPercent?: number; + createdAt?: string; + startedAt?: string; + finishedAt?: string; + taskType?: string; + filters?: CollectDataFilters; +} + +export interface CollectDataHistoryVo { + items: CollectDataHistoryItem[]; +} + +export interface CollectDataTaskSummary { + id?: number; + taskNo?: string; + status?: string; + errorMessage?: string; + createdAt?: string; + updatedAt?: string; + startedAt?: string; + finishedAt?: string; + taskType?: string; +} + +export interface CollectDataTaskDetailVo { + task?: CollectDataTaskSummary; + items?: CollectDataHistoryItem[]; +} + +export interface CollectDataTaskBatchVo { + items: CollectDataTaskDetailVo[]; + missingTaskIds?: number[]; +} + +export interface CollectDataItemVo { + id?: number; + rowIndex?: number; + sourceFileKey?: string; + sourceFilename?: string; + keyword?: string; + statusValue?: string; + extra?: Record; +} + +export interface CollectDataItemsPageVo { + taskId?: number; + taskNo?: string; + taskType?: string; + taskStatus?: string; + page?: number; + pageSize?: number; + count?: number; + total?: number; + totalPages?: number; + filters?: CollectDataFilters; + items: CollectDataItemVo[]; +} + +export interface CollectDataCountryPreferenceVo { + country_codes: string[]; +} + +interface TaskProgressBatchOptions { + force?: boolean; +} + +const taskProgressResponseCache = createTaskProgressRequestCache({ + ttlMs: () => getTaskProgressCacheTtlMs(), + maxEntries: 100, + maxInflight: 16, +}); + +function normalizeTaskIds(taskIds: number[]) { + return Array.from( + new Set( + (taskIds || []) + .map((taskId) => Number(taskId)) + .filter((taskId) => Number.isFinite(taskId) && taskId > 0), + ), + ).sort((left, right) => left - right); +} + +function buildTaskProgressRequestKey(path: string, taskIds: number[]) { + return `${path}::${taskIds.join(",")}`; +} + +async function postTaskProgressBatch( + path: string, + taskIds: number[], + options: TaskProgressBatchOptions = {}, +) { + const normalizedTaskIds = normalizeTaskIds(taskIds); + if (!normalizedTaskIds.length) { + return { items: [], missingTaskIds: [] } as T; + } + + const cacheKey = buildTaskProgressRequestKey(path, normalizedTaskIds); + const cached = taskProgressResponseCache.get(cacheKey); + if (!options.force && cached !== undefined) { + return cached as T; + } + + const inflight = taskProgressResponseCache.getInflight(cacheKey); + if (!options.force && inflight) { + return (await inflight) as T; + } + + const requestPromise = unwrapJavaResponse( + post, { taskIds: number[] }>(path, { taskIds: normalizedTaskIds }, { + timeout: getTaskProgressTimeoutMs(), + }), + ) + .then((data) => { + taskProgressResponseCache.set(cacheKey, data); + return data; + }) + .finally(() => { + taskProgressResponseCache.endInflight(cacheKey); + }); + + taskProgressResponseCache.startInflight(cacheKey, requestPromise); + return requestPromise; +} + +export function parseCollectData( + request: Omit | CollectDataParseRequest, +) { + return unwrapJavaResponse( + post, CollectDataParseRequest>( + `${JAVA_API_PREFIX}/collect-data/parse`, + { ...request, user_id: getCurrentUserId() }, + ), + ); +} + +export function activateCollectDataTask(taskId: number) { + return unwrapJavaResponse( + post, undefined>( + `${JAVA_API_PREFIX}/collect-data/tasks/${taskId}/activate?user_id=${encodeURIComponent(String(getCurrentUserId()))}`, + undefined, + ), + ); +} + +export function getCollectDataItemsPage( + taskId: number, + page: number = 1, + pageSize: number = 50, +) { + return unwrapJavaResponse( + get>( + `${JAVA_API_PREFIX}/collect-data/tasks/${taskId}/items`, + { + params: { + user_id: getCurrentUserId(), + page, + page_size: pageSize, + }, + }, + ), + ); +} + +export function getCollectDataDashboard() { + return unwrapJavaResponse( + get>( + `${JAVA_API_PREFIX}/collect-data/dashboard`, + { params: { user_id: getCurrentUserId() } }, + ), + ); +} + +export function getCollectDataHistory(limit: number = 50) { + return unwrapJavaResponse( + get>( + `${JAVA_API_PREFIX}/collect-data/history`, + { params: { user_id: getCurrentUserId(), limit } }, + ), + ); +} + +export function getCollectDataTaskProgressBatch( + taskIds: number[], + options: TaskProgressBatchOptions = {}, +) { + return postTaskProgressBatch( + `${JAVA_API_PREFIX}/collect-data/tasks/progress/batch`, + taskIds, + options, + ); +} + +export function deleteCollectDataTask(taskId: number) { + return unwrapJavaResponse( + del>( + `${JAVA_API_PREFIX}/collect-data/tasks/${taskId}`, + { params: { user_id: getCurrentUserId() } }, + ), + ); +} + +export function deleteCollectDataHistory(resultId: number) { + return unwrapJavaResponse( + del>( + `${JAVA_API_PREFIX}/collect-data/history/${resultId}`, + { params: { user_id: getCurrentUserId() } }, + ), + ); +} + +export function getCollectDataCountryPreference() { + return unwrapJavaResponse( + get>( + `${JAVA_API_PREFIX}/collect-data/country-preference`, + { params: { user_id: getCurrentUserId() } }, + ), + ); +} + +export function putCollectDataCountryPreference(countryCodes: string[]) { + return unwrapJavaResponse( + put< + JavaApiResponse, + { user_id: number; country_codes: string[] } + >(`${JAVA_API_PREFIX}/collect-data/country-preference`, { + user_id: getCurrentUserId(), + country_codes: countryCodes, + }), + ); +} + +export function failCollectDataTask(taskId: number, error?: string) { + const params = new URLSearchParams({ user_id: String(getCurrentUserId()) }); + if (error) params.set('error', error); + return unwrapJavaResponse( + post, undefined>( + `${JAVA_API_PREFIX}/collect-data/tasks/${taskId}/fail?${params.toString()}`, + undefined, + ), + ); +} diff --git a/frontend-vue/tests/modules-collect-data.test.ts b/frontend-vue/tests/modules-collect-data.test.ts new file mode 100644 index 00000000..a43d07b3 --- /dev/null +++ b/frontend-vue/tests/modules-collect-data.test.ts @@ -0,0 +1,177 @@ +import { test } from 'node:test' +import assert from 'node:assert/strict' +import { http } from '../src/shared/api/http.ts' +import { + parseCollectData, + activateCollectDataTask, + getCollectDataItemsPage, + getCollectDataDashboard, + getCollectDataHistory, + getCollectDataTaskProgressBatch, + deleteCollectDataTask, + deleteCollectDataHistory, + getCollectDataCountryPreference, + putCollectDataCountryPreference, + failCollectDataTask, +} from '../src/shared/api/types/modules/collect-data.ts' + +function setupWindow() { + ;(globalThis as Record).window = { + localStorage: { getItem: () => '42' }, + location: { origin: 'http://localhost' }, + } +} + +function mockRequest( + t: Parameters[1] extends (t: infer T) => unknown ? T : never, + impl: (config: { url?: string; method?: string; params?: Record; data?: unknown; timeout?: number }) => Promise, +) { + t.mock.method(http, 'request', impl as never) +} + +const okResponse = (data: unknown) => Promise.resolve({ data: { success: true, message: 'ok', data } }) + +test('test_collect_data_parse', async (t) => { + setupWindow() + const calls: { url?: string; method?: string; data?: unknown }[] = [] + mockRequest(t, (config) => { + calls.push(config) + return okResponse({ taskId: 1 }) + }) + await parseCollectData({ files: [{ fileKey: 'k1' }], task_type: 'typeA' }) + assert.equal(calls[0].url, '/newApi/api/collect-data/parse') + assert.equal(calls[0].method, 'POST') + assert.deepEqual(calls[0].data, { user_id: 42, files: [{ fileKey: 'k1' }], task_type: 'typeA' }) +}) + +test('test_collect_data_items_page', async (t) => { + setupWindow() + const calls: { url?: string; params?: Record }[] = [] + mockRequest(t, (config) => { + calls.push(config) + return okResponse({ items: [] }) + }) + await getCollectDataItemsPage(7, 2, 100) + assert.equal(calls[0].url, '/newApi/api/collect-data/tasks/7/items') + assert.deepEqual(calls[0].params, { user_id: 42, page: 2, page_size: 100 }) +}) + +test('test_collect_data_progress_batch', async (t) => { + setupWindow() + const calls: { url?: string; method?: string; data?: unknown; timeout?: number }[] = [] + mockRequest(t, (config) => { + calls.push(config) + return okResponse({ items: [], missingTaskIds: [] }) + }) + const result = await getCollectDataTaskProgressBatch([5, 3], { force: true }) + assert.equal(calls[0].url, '/newApi/api/collect-data/tasks/progress/batch') + assert.equal(calls[0].method, 'POST') + assert.deepEqual(calls[0].data, { taskIds: [3, 5] }) + assert.equal(calls[0].timeout, 10000) + assert.deepEqual(result, { items: [], missingTaskIds: [] }) +}) + +test('test_collect_data_fail', async (t) => { + setupWindow() + const calls: { url?: string; method?: string; data?: unknown }[] = [] + mockRequest(t, (config) => { + calls.push(config) + return okResponse(null) + }) + await failCollectDataTask(7) + await failCollectDataTask(7, '网络错误') + assert.equal(calls[0].url, '/newApi/api/collect-data/tasks/7/fail?user_id=42') + assert.equal( + calls[1].url, + `/newApi/api/collect-data/tasks/7/fail?user_id=42&error=${encodeURIComponent('网络错误')}`, + ) + assert.equal(calls[0].method, 'POST') +}) + +test('test_collect_data_activate', async (t) => { + setupWindow() + const calls: { url?: string; method?: string }[] = [] + mockRequest(t, (config) => { + calls.push(config) + return okResponse(null) + }) + await activateCollectDataTask(7) + assert.equal(calls[0].url, '/newApi/api/collect-data/tasks/7/activate?user_id=42') + assert.equal(calls[0].method, 'POST') +}) + +test('test_collect_data_preference', async (t) => { + setupWindow() + const calls: { url?: string; method?: string; params?: Record; data?: unknown }[] = [] + mockRequest(t, (config) => { + calls.push(config) + return okResponse({ country_codes: ['US'] }) + }) + await getCollectDataCountryPreference() + await putCollectDataCountryPreference(['US']) + assert.equal(calls[0].url, '/newApi/api/collect-data/country-preference') + assert.deepEqual(calls[0].params, { user_id: 42 }) + assert.equal(calls[1].url, '/newApi/api/collect-data/country-preference') + assert.equal(calls[1].method, 'PUT') + assert.deepEqual(calls[1].data, { user_id: 42, country_codes: ['US'] }) +}) + +test('test_collect_data_dashboard_history', async (t) => { + setupWindow() + const calls: { url?: string; params?: Record }[] = [] + mockRequest(t, (config) => { + calls.push(config) + return okResponse({ items: [] }) + }) + await getCollectDataDashboard() + await getCollectDataHistory() + await getCollectDataHistory(10) + assert.equal(calls[0].url, '/newApi/api/collect-data/dashboard') + assert.deepEqual(calls[0].params, { user_id: 42 }) + assert.equal(calls[1].url, '/newApi/api/collect-data/history') + assert.deepEqual(calls[1].params, { user_id: 42, limit: 50 }) + assert.deepEqual(calls[2].params, { user_id: 42, limit: 10 }) +}) + +test('test_collect_data_delete', async (t) => { + setupWindow() + const calls: { url?: string; method?: string; params?: Record }[] = [] + mockRequest(t, (config) => { + calls.push(config) + return okResponse(null) + }) + await deleteCollectDataTask(7) + await deleteCollectDataHistory(9) + assert.equal(calls[0].url, '/newApi/api/collect-data/tasks/7') + assert.equal(calls[0].method, 'DELETE') + assert.deepEqual(calls[0].params, { user_id: 42 }) + assert.equal(calls[1].url, '/newApi/api/collect-data/history/9') + assert.equal(calls[1].method, 'DELETE') +}) + +test('test_collect_data_export_compat', async (t) => { + setupWindow() + const fromJavaModules = await import('../src/shared/api/java-modules.ts') + const fromCollectData = await import('../src/shared/api/types/modules/collect-data.ts') + const names = [ + 'parseCollectData', 'activateCollectDataTask', 'getCollectDataItemsPage', + 'getCollectDataDashboard', 'getCollectDataHistory', 'getCollectDataTaskProgressBatch', + 'deleteCollectDataTask', 'deleteCollectDataHistory', 'getCollectDataCountryPreference', + 'putCollectDataCountryPreference', 'failCollectDataTask', + ] + for (const name of names) { + assert.equal(fromJavaModules[name], fromCollectData[name], `${name} 应为同一引用`) + } + const filters = { minAmount: 10, max_amount: 100, fba: true } + assert.equal(filters.fba, true) +}) + +test('test_collect_data_unwrap_and_error', async (t) => { + setupWindow() + mockRequest(t, () => okResponse({ taskId: 1 })) + const result = await parseCollectData({ files: [] }) + assert.equal(result.taskId, 1) + + mockRequest(t, () => Promise.resolve({ data: { success: false, message: '文件不存在' } })) + await assert.rejects(parseCollectData({ files: [] }), /文件不存在/) +})