task-101(任务与重复分析中心): 定义统一任务状态和结果文件类型

新增 src/pages/tasks/task-model.ts:TaskStatus 归一(含同义词/终态判定)、
TaskId/ResultId 字符串兼容品牌类型与 ResultFileDescriptor 解析(snake/camel)。

TDD: task-101.test.ts 8 用例先 RED 后 GREEN。
This commit is contained in:
2026-09-05 17:01:08 +08:00
parent bf0cecef88
commit a877488f8f
2 changed files with 186 additions and 0 deletions
@@ -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<string, TaskStatus> = {
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<string, unknown>
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
}