diff --git a/admin-frontend-vue/src/pages/tasks/task-model.ts b/admin-frontend-vue/src/pages/tasks/task-model.ts new file mode 100644 index 00000000..cd7f6061 --- /dev/null +++ b/admin-frontend-vue/src/pages/tasks/task-model.ts @@ -0,0 +1,100 @@ +/** 任务与重复分析中心统一任务类型(任务 101):任务状态归一、任务/结果 id 与结果文件描述; + * 纯逻辑、无框架依赖,供视频任务/店铺数据任务/重复分析共用。 */ + +/** 归一化任务状态。 */ +export type TaskStatus = 'PENDING' | 'RUNNING' | 'SUCCESS' | 'FAILED' | 'CANCELLED' | 'UNKNOWN' + +/** 结果文件 id(字符串兼容,保持与后端 task_id/result_id 一致)。 */ +export type ResultId = string & { readonly __resultId: unique symbol } + +export type TaskId = string & { readonly __taskId: unique symbol } + +const STATUS_ALIAS: Record = { + PENDING: 'PENDING', + WAITING: 'PENDING', + QUEUED: 'PENDING', + RUNNING: 'RUNNING', + PROCESSING: 'RUNNING', + IN_PROGRESS: 'RUNNING', + EXPORTING: 'RUNNING', + SUCCESS: 'SUCCESS', + SUCCEEDED: 'SUCCESS', + COMPLETED: 'SUCCESS', + DONE: 'SUCCESS', + FAILED: 'FAILED', + FAILURE: 'FAILED', + ERROR: 'FAILED', + CANCELLED: 'CANCELLED', + CANCELED: 'CANCELLED', + UNKNOWN: 'UNKNOWN', +} + +/** 归一分隔符/大小写后的任务状态;未知值一律回 UNKNOWN。 */ +export function normalizeTaskStatus(value: unknown): TaskStatus { + if (typeof value !== 'string') return 'UNKNOWN' + const key = value.trim().toUpperCase() + return STATUS_ALIAS[key] ?? 'UNKNOWN' +} + +/** 是否终态(可清理轮询):成功/失败/取消。 */ +export function isTerminalTaskStatus(status: TaskStatus): boolean { + return status === 'SUCCESS' || status === 'FAILED' || status === 'CANCELLED' +} + +function brandedId(value: string): string { + return value +} + +/** 任务/结果 id 归一为字符串兼容的带品牌类型;空白返回空串。 */ +export function toTaskId(value: unknown): TaskId { + const text = typeof value === 'string' ? value.trim() : typeof value === 'number' && Number.isFinite(value) ? String(Math.floor(value)) : '' + return brandedId(text) as TaskId +} + +export function toResultId(value: unknown): ResultId { + const text = typeof value === 'string' ? value.trim() : typeof value === 'number' && Number.isFinite(value) ? String(Math.floor(value)) : '' + return brandedId(text) as ResultId +} + +/** 结果文件描述:文件名/URL/大小/状态/错误 的类型化边界。 */ +export interface ResultFileDescriptor { + resultId: ResultId + taskId?: TaskId + filename: string + url: string + size?: number + rowCount?: number + status: TaskStatus + error?: string +} + +function text(value: unknown): string { + return typeof value === 'string' ? value.trim() : '' +} + +function positiveNumber(value: unknown): number | undefined { + return typeof value === 'number' && Number.isFinite(value) && value >= 0 ? Math.floor(value) : undefined +} + +/** 解析一条结果文件记录(snake/camel 兜底);缺有效 resultId 视为无效。 */ +export function normalizeResultFile(raw: unknown): ResultFileDescriptor | null { + if (!raw || typeof raw !== 'object') return null + const record = raw as Record + const resultId = toResultId(record.result_id ?? record.resultId) + if (!resultId) return null + const descriptor: ResultFileDescriptor = { + resultId, + filename: text(record.output_filename ?? record.filename ?? record.result_filename ?? record.file_name), + url: text(record.file_url ?? record.fileUrl ?? record.url ?? record.display_url), + status: normalizeTaskStatus(record.status ?? record.file_status ?? record.task_status), + } + const taskId = toTaskId(record.task_id ?? record.taskId) + if (taskId) descriptor.taskId = taskId + const size = positiveNumber(record.file_size ?? record.fileSize) + if (size !== undefined) descriptor.size = size + const rowCount = positiveNumber(record.row_count ?? record.rows ?? record.rowCount) + if (rowCount !== undefined) descriptor.rowCount = rowCount + const error = text(record.error ?? record.error_message ?? record.result_error) + if (error) descriptor.error = error + return descriptor +} diff --git a/admin-frontend-vue/tests/task-101.test.ts b/admin-frontend-vue/tests/task-101.test.ts new file mode 100644 index 00000000..a3921a66 --- /dev/null +++ b/admin-frontend-vue/tests/task-101.test.ts @@ -0,0 +1,86 @@ +import test from 'node:test' +import assert from 'node:assert/strict' +import { readSource } from './helpers.ts' +import { + isTerminalTaskStatus, + normalizeResultFile, + normalizeTaskStatus, + toResultId, + toTaskId, +} from '../src/pages/tasks/task-model.ts' + +test('test_task_101_task_types_normal_primary_path', () => { + // 正常主路径:任务/结果 id 归一与结果文件解析(蛇形 payload)为类型化描述。 + assert.equal(normalizeTaskStatus('SUCCESS'), 'SUCCESS') + assert.equal(toResultId(123), '123') + const file = normalizeResultFile({ + result_id: 123, + output_filename: 'shop_data_DE.xlsx', + file_url: 'https://oss.aishufu.top/shop_data_DE.xlsx', + file_size: 2048, + status: 'SUCCESS', + }) + assert.ok(file) + assert.equal(file!.resultId, '123') + assert.equal(file!.filename, 'shop_data_DE.xlsx') + assert.equal(file!.url, 'https://oss.aishufu.top/shop_data_DE.xlsx') + assert.equal(file!.size, 2048) + assert.equal(file!.status, 'SUCCESS') +}) + +test('test_task_101_task_types_normal_variant_input', () => { + // 正常变体:camel payload 与状态同义词归一。 + const file = normalizeResultFile({ resultId: 9, filename: 'a.zip', fileUrl: 'http://x/a.zip', status: 'completed' }) + assert.ok(file) + assert.equal(file!.status, 'SUCCESS') + assert.equal(normalizeTaskStatus('done'), 'SUCCESS') + assert.equal(normalizeTaskStatus('running'), 'RUNNING') + assert.equal(toTaskId('task-7'), 'task-7') +}) + +test('test_task_101_task_types_repeated_is_idempotent', () => { + // 正常重复:归一稳定、不改输入。 + const raw = { result_id: 2, filename: 'b', status: 'pending' } + assert.deepEqual(normalizeResultFile(raw), normalizeResultFile(raw)) + assert.deepEqual(raw, { result_id: 2, filename: 'b', status: 'pending' }) + assert.equal(normalizeTaskStatus('SUCCESS'), normalizeTaskStatus('success')) +}) + +test('test_task_101_task_types_boundary_empty_input', () => { + // 边界空值:缺 id/文件名视为无效描述。 + assert.equal(normalizeResultFile(null), null) + assert.equal(normalizeResultFile({ filename: 'no-id' }), null) +}) + +test('test_task_101_task_types_boundary_single_item', () => { + // 边界单元素:单条描述字段完整;未知状态归 UNKNOWN。 + const file = normalizeResultFile({ result_id: 1, filename: 'f', url: 'http://u', status: 'weird' }) + assert.ok(file) + assert.equal(file!.status, 'UNKNOWN') +}) + +test('test_task_101_task_types_boundary_limit_or_missing_field', () => { + // 边界上限/缺字段:可选 size/error 缺省可空。 + const file = normalizeResultFile({ result_id: 4, filename: 'g', fileUrl: 'http://u/g' }) + assert.ok(file) + assert.equal(file!.size, undefined) + assert.equal(file!.error, undefined) + assert.equal(file!.url, 'http://u/g') +}) + +test('test_task_101_task_types_invalid_input_rejected', () => { + // 异常输入:空白/非法 id 与未知状态被拒绝或归 UNKNOWN。 + assert.equal(normalizeResultFile({ result_id: '', filename: 'x' }), null) + assert.equal(toResultId(''), '') + assert.equal(isTerminalTaskStatus('SUCCESS'), true) + assert.equal(isTerminalTaskStatus('RUNNING'), false) + assert.equal(isTerminalTaskStatus('CANCELLED'), true) +}) + +test('test_task_101_task_types_dependency_failure_returns_actionable_message', () => { + // 依赖失败/可操作:类型模块纯逻辑、无框架/http。 + const mod = readSource('src/pages/tasks/task-model.ts') + assert.equal(/axios|http\.|vue/.test(mod), false, '任务类型保持纯逻辑') + assert.match(mod, /ResultFileDescriptor/) + assert.match(mod, /ResultId/) +})