diff --git a/packages/shared/src/config/system-configs.ts b/packages/shared/src/config/system-configs.ts index 1f2da5b..7e8e54a 100644 --- a/packages/shared/src/config/system-configs.ts +++ b/packages/shared/src/config/system-configs.ts @@ -13,6 +13,7 @@ export const SYSTEM_CONFIG_GROUP_KEYS = [ 'notify', 'limits', 'threshold', + 'task', 'audit', ] as const; @@ -45,6 +46,12 @@ export const SYSTEM_CONFIG_GROUPS: readonly { description: '爆量判定、审核超时与质量告警', sortOrder: 60, }, + { + key: 'task', + name: '任务与预估', + description: '演练任务项上限与提交时的成本 / 时长预估(A6)', + sortOrder: 65, + }, { key: 'audit', name: '审计与日志', @@ -459,6 +466,54 @@ export const SYSTEM_CONFIG_DEFINITIONS: readonly SystemConfigDefinition[] = [ sortOrder: 260, }, + // 任务与预估(A6) + { + key: 'task.demo.max_items', + group: 'task', + label: '演练任务项上限', + description: '单次演练任务最多包含的任务项数量;硬上限 100 由提交 schema 兜底', + impact: '影响任务提交校验(超限 40001)与预估条数;修改后新提交生效,历史任务不受影响', + type: 'number', + defaultValue: 20, + unit: '项', + min: 1, + max: 100, + editable: true, + public: true, + sortOrder: 265, + }, + { + key: 'task.estimate.demo_cost_per_item', + group: 'task', + label: '演练任务单项预估成本', + description: '提交演练任务时每个任务项的预估成本(元);仅用于返回 estimatedCost', + impact: '仅影响提交响应的预估金额,不产生真实计费;缺失时提交被拒绝(40001)', + type: 'number', + defaultValue: 0.02, + unit: '元/项', + min: 0, + max: 100, + step: 0.01, + editable: true, + public: false, + sortOrder: 270, + }, + { + key: 'task.estimate.demo_seconds_per_item', + group: 'task', + label: '演练任务单项预估耗时', + description: '提交演练任务时每个任务项的预估耗时(秒);仅用于返回 estimatedSeconds', + impact: '仅影响提交响应的预估时长,实际耗时以 worker 执行结果为准;缺失时提交被拒绝(40001)', + type: 'number', + defaultValue: 3, + unit: '秒/项', + min: 1, + max: 3600, + editable: true, + public: false, + sortOrder: 275, + }, + // 审计与日志(A5) { key: 'audit.retention_months', diff --git a/packages/shared/src/index.ts b/packages/shared/src/index.ts index 397f148..bea889a 100644 --- a/packages/shared/src/index.ts +++ b/packages/shared/src/index.ts @@ -14,5 +14,7 @@ export * from './schemas/config.js'; export * from './schemas/dict.js'; export * from './schemas/identity.js'; export * from './schemas/roles.js'; +export * from './schemas/tasks.js'; +export * from './tasks/task-protocol.js'; export * from './time.js'; export * from './ulid.js'; diff --git a/packages/shared/src/schemas/tasks.spec.ts b/packages/shared/src/schemas/tasks.spec.ts new file mode 100644 index 0000000..1fdc368 --- /dev/null +++ b/packages/shared/src/schemas/tasks.spec.ts @@ -0,0 +1,237 @@ +import { describe, expect, it } from 'vitest'; + +import { TASK_ITEM_HARD_LIMIT } from '../tasks/task-protocol.js'; +import { + mediaTaskCancelResponseSchema, + mediaTaskDetailQuerySchema, + mediaTaskDetailSchema, + mediaTaskItemSchema, + mediaTaskListQuerySchema, + mediaTaskListResponseSchema, + mediaTaskRetryResponseSchema, + mediaTaskSubmitResponseSchema, + mediaTaskSubmitSchema, + mediaTaskSummarySchema, + taskDoneEventPayloadSchema, + taskSseEventSchema, +} from './tasks.js'; + +const CUSTOMER_ID = '01J9Z000000000000000000001'; +const TASK_ID = '01J9Z000000000000000000002'; +const USER_ID = '01J9Z000000000000000000003'; +const ITEM_ID = '01J9Z000000000000000000004'; +const NOW = '2026-11-04T12:00:00.000Z'; + +const validSubmit = { + type: 'demo', + customerId: CUSTOMER_ID, + itemCount: 5, + mode: 'mixed', + note: '演练任务', +}; + +describe('任务提交契约(A6-BE-01)', () => { + it('接受演练任务入参并 trim note', () => { + const parsed = mediaTaskSubmitSchema.parse({ ...validSubmit, note: ' 说明 ' }); + expect(parsed.type).toBe('demo'); + expect(parsed.note).toBe('说明'); + }); + + it('拒绝非 demo 类型,提示 M0 仅支持演练任务', () => { + const result = mediaTaskSubmitSchema.safeParse({ ...validSubmit, type: 'script' }); + expect(result.success).toBe(false); + expect(result.success ? '' : result.error.issues[0]?.message).toBe('M0 仅支持演练任务'); + }); + + it('itemCount 硬上限为 TASK_ITEM_HARD_LIMIT(业务上限读系统配置)', () => { + expect(TASK_ITEM_HARD_LIMIT).toBe(100); + expect(mediaTaskSubmitSchema.safeParse({ ...validSubmit, itemCount: 100 }).success).toBe(true); + expect(mediaTaskSubmitSchema.safeParse({ ...validSubmit, itemCount: 101 }).success).toBe(false); + expect(mediaTaskSubmitSchema.safeParse({ ...validSubmit, itemCount: 0 }).success).toBe(false); + expect(mediaTaskSubmitSchema.safeParse({ ...validSubmit, itemCount: 1.5 }).success).toBe(false); + }); + + it('拒绝未知字段 / 非法客户 id / 非法模式 / 超长备注', () => { + expect(mediaTaskSubmitSchema.safeParse({ ...validSubmit, extra: 1 }).success).toBe(false); + expect( + mediaTaskSubmitSchema.safeParse({ ...validSubmit, customerId: 'not-ulid' }).success, + ).toBe(false); + expect(mediaTaskSubmitSchema.safeParse({ ...validSubmit, mode: 'unknown' }).success).toBe( + false, + ); + expect(mediaTaskSubmitSchema.safeParse({ ...validSubmit, note: 'x'.repeat(201) }).success).toBe( + false, + ); + }); + + it('提交响应字段名固定:taskId / estimatedItems / estimatedCost / estimatedSeconds', () => { + const parsed = mediaTaskSubmitResponseSchema.parse({ + taskId: TASK_ID, + estimatedItems: 5, + estimatedCost: 0.1, + estimatedSeconds: 15, + }); + expect(parsed).toEqual({ + taskId: TASK_ID, + estimatedItems: 5, + estimatedCost: 0.1, + estimatedSeconds: 15, + }); + expect( + mediaTaskSubmitResponseSchema.safeParse({ taskId: TASK_ID, estimatedItems: 5 }).success, + ).toBe(false); + }); +}); + +const validSummary = { + id: TASK_ID, + type: 'demo', + status: 'partial_failed', + title: '演练任务 · 花间集 · 5 项', + customerId: CUSTOMER_ID, + customerName: '花间集', + itemTotal: 5, + itemSucceeded: 3, + itemFailed: 2, + itemSkipped: 0, + progress: 100, + estimatedItems: 5, + estimatedCost: 0.1, + estimatedSeconds: 15, + createdBy: USER_ID, + createdByName: '陈墨', + createdAt: NOW, +}; + +describe('任务摘要 / 详情 / 列表契约', () => { + it('摘要必填字段完整;可选时间缺省(null 不返回)', () => { + const parsed = mediaTaskSummarySchema.parse(validSummary); + expect(parsed.startedAt).toBeUndefined(); + expect(parsed.finishedAt).toBeUndefined(); + expect(mediaTaskSummarySchema.safeParse({ ...validSummary, status: 'unknown' }).success).toBe( + false, + ); + }); + + it('详情 = 摘要 + payload / 取消请求 / 错误 / items / itemsNextCursor', () => { + const detail = mediaTaskDetailSchema.parse({ + ...validSummary, + payload: { mode: 'mixed', note: null }, + cancelRequestedAt: NOW, + lastErrorCode: 'ITEM_FAILED', + lastErrorMessage: '2 项失败', + items: [ + { + id: ITEM_ID, + seq: 1, + status: 'failed', + failureCode: 'DEMO_FAIL', + failureMessage: '演练失败', + attempts: 3, + startedAt: NOW, + finishedAt: NOW, + }, + ], + itemsNextCursor: null, + }); + expect(detail.items).toHaveLength(1); + expect(detail.itemsNextCursor).toBeNull(); + expect(detail.payload).toEqual({ mode: 'mixed', note: null }); + }); + + it('任务项:failureCode / failureMessage 可选,attempts 必填非负', () => { + const item = mediaTaskItemSchema.parse({ id: ITEM_ID, seq: 2, status: 'queued', attempts: 0 }); + expect(item.failureCode).toBeUndefined(); + expect( + mediaTaskItemSchema.safeParse({ id: ITEM_ID, seq: 0, status: 'queued', attempts: 0 }).success, + ).toBe(false); + expect( + mediaTaskItemSchema.safeParse({ id: ITEM_ID, seq: 1, status: 'queued', attempts: -1 }) + .success, + ).toBe(false); + }); + + it('列表 query:snake_case 筛选 + limit 强转 + 默认 20', () => { + const parsed = mediaTaskListQuerySchema.parse({ + type: 'demo', + status: 'partial_failed', + customer_id: CUSTOMER_ID, + created_from: NOW, + created_to: NOW, + mine: 'true', + keyword: ' 演练 ', + limit: '50', + }); + expect(parsed).toMatchObject({ + type: 'demo', + status: 'partial_failed', + mine: 'true', + keyword: '演练', + limit: 50, + }); + expect(mediaTaskListQuerySchema.parse({}).limit).toBe(20); + expect(mediaTaskListQuerySchema.safeParse({ limit: 101 }).success).toBe(false); + expect(mediaTaskListQuerySchema.safeParse({ mine: true }).success).toBe(false); + expect(mediaTaskListQuerySchema.safeParse({ type: 'unknown' }).success).toBe(false); + }); + + it('列表响应 { items, next_cursor }', () => { + const parsed = mediaTaskListResponseSchema.parse({ + items: [validSummary], + next_cursor: 'abc', + }); + expect(parsed.next_cursor).toBe('abc'); + expect( + mediaTaskListResponseSchema.safeParse({ items: [validSummary], next_cursor: 1 }).success, + ).toBe(false); + }); + + it('详情 query 只接受 items_cursor', () => { + expect(mediaTaskDetailQuerySchema.parse({}).items_cursor).toBeUndefined(); + expect(mediaTaskDetailQuerySchema.parse({ items_cursor: 'x' }).items_cursor).toBe('x'); + }); +}); + +describe('取消 / 重试响应与 SSE 事件契约', () => { + it('取消响应 { taskId, status }', () => { + expect(mediaTaskCancelResponseSchema.parse({ taskId: TASK_ID, status: 'canceled' })).toEqual({ + taskId: TASK_ID, + status: 'canceled', + }); + expect( + mediaTaskCancelResponseSchema.safeParse({ taskId: TASK_ID, status: 'unknown' }).success, + ).toBe(false); + }); + + it('重试响应 { taskId, status, retriedItems }', () => { + expect( + mediaTaskRetryResponseSchema.parse({ taskId: TASK_ID, status: 'queued', retriedItems: 2 }), + ).toEqual({ taskId: TASK_ID, status: 'queued', retriedItems: 2 }); + }); + + it('SSE 事件信封:id / event / data', () => { + expect( + taskSseEventSchema.parse({ + id: '12', + event: 'progress', + data: { taskId: TASK_ID, status: 'running', progress: 40 }, + }), + ).toMatchObject({ id: '12', event: 'progress' }); + expect(taskSseEventSchema.safeParse({ id: '12', event: 'unknown', data: {} }).success).toBe( + false, + ); + }); + + it('done 事件载荷可校验(终态补发)', () => { + const payload = taskDoneEventPayloadSchema.parse({ + taskId: TASK_ID, + status: 'pending_review', + progress: 100, + }); + expect(payload.status).toBe('pending_review'); + expect( + taskDoneEventPayloadSchema.safeParse({ taskId: TASK_ID, status: 'running', progress: 50 }) + .success, + ).toBe(true); + }); +}); diff --git a/packages/shared/src/schemas/tasks.ts b/packages/shared/src/schemas/tasks.ts new file mode 100644 index 0000000..5acab32 --- /dev/null +++ b/packages/shared/src/schemas/tasks.ts @@ -0,0 +1,216 @@ +/** + * 任务中心共享契约(A6-BE-01 / A6-BE-03 / A6-BE-04) + * + * 依据:开发计划 §6.7 / §7.6;技术架构 §6.4(异步任务协议)/ §3.6(状态机)。 + * 约定: + * - body / 响应字段用 camelCase(C-01);列表 query 用 snake_case; + * - 列表 `{ items, next_cursor }`,单资源不套 `data`(C-02); + * - 时间字段一律 ISO 字符串(UTC),金额 `estimatedCost` 用 number(元,2 位小数); + * - 提交响应字段固定为 `{ taskId, estimatedItems, estimatedCost, estimatedSeconds }`。 + */ +import { z } from 'zod'; + +import { + TASK_EVENT_TYPES, + TASK_ITEM_HARD_LIMIT, + TASK_ITEM_STATUSES, + TASK_STATUSES, + TASK_TYPES, +} from '../tasks/task-protocol.js'; +import { isoDateTimeSchema, ulidSchema } from './common.js'; + +export const taskTypeSchema = z.enum(TASK_TYPES); +export const taskStatusSchema = z.enum(TASK_STATUSES); +export const taskItemStatusSchema = z.enum(TASK_ITEM_STATUSES); +export const taskEventTypeSchema = z.enum(TASK_EVENT_TYPES); + +/** 演练任务模式:全部成功 / 混合失败 / 全部失败(worker 按此产出,便于验收状态机) */ +export const mediaTaskDemoModeSchema = z.enum(['success', 'mixed', 'fail']); +export type MediaTaskDemoMode = z.infer; + +/** + * 提交任务入参(M0 仅演练任务)。 + * `itemCount` 只做硬上限(TASK_ITEM_HARD_LIMIT),业务上限读系统配置 `task.demo.max_items`。 + */ +export const mediaTaskSubmitSchema = z + .object({ + type: z.enum(['demo'], { errorMap: () => ({ message: 'M0 仅支持演练任务' }) }), + customerId: ulidSchema, + itemCount: z.number().int().min(1).max(TASK_ITEM_HARD_LIMIT), + mode: mediaTaskDemoModeSchema, + note: z.string().trim().max(200).optional(), + }) + .strict(); + +export type MediaTaskSubmitInput = z.input; +export type MediaTaskSubmitParsed = z.output; + +/** 提交响应:预估条数 / 成本 / 时长缺一不可(A6-BE-01 明确字段名) */ +export const mediaTaskSubmitResponseSchema = z.object({ + taskId: ulidSchema, + estimatedItems: z.number().int().positive(), + estimatedCost: z.number().nonnegative(), + estimatedSeconds: z.number().int().nonnegative(), +}); + +export type MediaTaskSubmitResponse = z.infer; + +/** 任务摘要(列表 / 详情共用;任务头不返回 payload 与大 JSON) */ +export const mediaTaskSummarySchema = z.object({ + id: ulidSchema, + type: taskTypeSchema, + status: taskStatusSchema, + title: z.string().min(1).max(200), + customerId: ulidSchema, + customerName: z.string().min(1).max(128), + itemTotal: z.number().int().nonnegative(), + itemSucceeded: z.number().int().nonnegative(), + itemFailed: z.number().int().nonnegative(), + itemSkipped: z.number().int().nonnegative(), + progress: z.number().int().min(0).max(100), + estimatedItems: z.number().int().nonnegative(), + estimatedCost: z.number().nonnegative(), + estimatedSeconds: z.number().int().nonnegative(), + createdBy: ulidSchema, + createdByName: z.string().min(1).max(64), + createdAt: isoDateTimeSchema, + startedAt: isoDateTimeSchema.optional(), + finishedAt: isoDateTimeSchema.optional(), +}); + +export type MediaTaskSummary = z.infer; + +/** 任务项:失败行含 failureCode / failureMessage(失败原因必填,来自字典) */ +export const mediaTaskItemSchema = z.object({ + id: ulidSchema, + seq: z.number().int().positive(), + status: taskItemStatusSchema, + failureCode: z.string().max(64).optional(), + failureMessage: z.string().max(500).optional(), + attempts: z.number().int().nonnegative(), + startedAt: isoDateTimeSchema.optional(), + finishedAt: isoDateTimeSchema.optional(), +}); + +export type MediaTaskItem = z.infer; + +/** + * 任务详情:摘要 + payload 快照 + 首屏 items(默认 100 条)+ 续拉游标。 + * `itemsNextCursor` 为 null 表示没有更多任务项。 + */ +export const mediaTaskDetailSchema = mediaTaskSummarySchema.extend({ + payload: z.record(z.unknown()), + cancelRequestedAt: isoDateTimeSchema.optional(), + lastErrorCode: z.string().max(32).optional(), + lastErrorMessage: z.string().max(500).optional(), + items: z.array(mediaTaskItemSchema), + itemsNextCursor: z.string().nullable(), +}); + +export type MediaTaskDetail = z.infer; + +/** 详情 query:items 续拉游标(首屏不传) */ +export const mediaTaskDetailQuerySchema = z.object({ + items_cursor: z.string().min(1).max(512).optional(), +}); + +export type MediaTaskDetailQuery = z.infer; + +/** SSE query:`last_event_id` 与 `Last-Event-ID` 头等价(header 优先) */ +export const mediaTaskSseQuerySchema = z.object({ + last_event_id: z.string().min(1).max(32).optional(), +}); + +export type MediaTaskSseQuery = z.infer; + +/** 列表筛选 + 游标分页(query 用 snake_case;稳定排序 createdAt DESC, id DESC) */ +export const mediaTaskListQuerySchema = z.object({ + type: taskTypeSchema.optional(), + status: taskStatusSchema.optional(), + customer_id: ulidSchema.optional(), + created_from: isoDateTimeSchema.optional(), + created_to: isoDateTimeSchema.optional(), + mine: z.enum(['true', 'false']).optional(), + keyword: z.string().trim().min(1).max(100).optional(), + cursor: z.string().min(1).max(512).optional(), + limit: z.coerce.number().int().min(1).max(100).default(20), +}); + +export type MediaTaskListQuery = z.infer; + +export const mediaTaskListResponseSchema = z.object({ + items: z.array(mediaTaskSummarySchema), + next_cursor: z.string().nullable(), +}); + +export type MediaTaskListResponse = z.infer; + +export const mediaTaskCancelResponseSchema = z.object({ + taskId: ulidSchema, + status: taskStatusSchema, +}); + +export type MediaTaskCancelResponse = z.infer; + +export const mediaTaskRetryResponseSchema = z.object({ + taskId: ulidSchema, + status: taskStatusSchema, + retriedItems: z.number().int().nonnegative(), +}); + +export type MediaTaskRetryResponse = z.infer; + +// --------------------------------------------------------------------------- +// SSE 事件载荷(apps/api 写入 task_events.payload_json,前端按 event 类型解析) +// --------------------------------------------------------------------------- + +/** `item`:单个任务项状态变更 */ +export const taskItemEventPayloadSchema = z.object({ + taskId: ulidSchema, + itemId: ulidSchema, + seq: z.number().int().positive(), + status: taskItemStatusSchema, + failureCode: z.string().max(64).optional(), + failureMessage: z.string().max(500).optional(), +}); + +export type TaskItemEventPayload = z.infer; + +/** `progress`:任务头进度快照(进度落库,断线重连按 seq 续推) */ +export const taskProgressEventPayloadSchema = z.object({ + taskId: ulidSchema, + status: taskStatusSchema, + progress: z.number().int().min(0).max(100), + itemTotal: z.number().int().nonnegative(), + itemSucceeded: z.number().int().nonnegative(), + itemFailed: z.number().int().nonnegative(), + itemSkipped: z.number().int().nonnegative(), +}); + +export type TaskProgressEventPayload = z.infer; + +/** `done`:任务收敛(待预览 / 部分失败 / 失败 / 已取消),SSE 随后关闭 */ +export const taskDoneEventPayloadSchema = z.object({ + taskId: ulidSchema, + status: taskStatusSchema, + progress: z.number().int().min(0).max(100), +}); + +export type TaskDoneEventPayload = z.infer; + +/** `error`:任务级错误(错误码与统一错误码同源) */ +export const taskErrorEventPayloadSchema = z.object({ + code: z.number().int(), + message: z.string().min(1).max(500), +}); + +export type TaskErrorEventPayload = z.infer; + +/** SSE 事件元素(序列化单测与前端契约共用) */ +export const taskSseEventSchema = z.object({ + id: z.string().min(1), + event: taskEventTypeSchema, + data: z.unknown(), +}); + +export type TaskSseEvent = z.infer; diff --git a/packages/shared/src/tasks/task-protocol.spec.ts b/packages/shared/src/tasks/task-protocol.spec.ts new file mode 100644 index 0000000..40446e1 --- /dev/null +++ b/packages/shared/src/tasks/task-protocol.spec.ts @@ -0,0 +1,243 @@ +import { describe, expect, it } from 'vitest'; + +import { + canCancelTask, + canRetryTask, + isTaskActive, + isTaskCountersFinished, + isTaskTerminal, + queueGroupOf, + queueOfTaskType, + queuesOfGroup, + resolveFinishedStatus, + TASK_ITEM_STATUSES, + TASK_QUEUE_GROUPS, + TASK_QUEUES, + TASK_STATUS_LABELS, + TASK_STATUSES, + TASK_TYPE_LABELS, + TASK_TYPES, + taskItemStatusLabel, + taskProgress, + type TaskStatus, + taskStatusLabel, + taskTypeLabel, + transitionAllowed, +} from './task-protocol.js'; + +describe('任务协议枚举(A6-BE-01 / 架构 §3.6)', () => { + it('九态与架构 §3.6 完全一致', () => { + expect(TASK_STATUSES).toEqual([ + 'queued', + 'running', + 'partial_failed', + 'pending_review', + 'selected', + 'exported', + 'archived', + 'failed', + 'canceled', + ]); + expect(Object.keys(TASK_STATUS_LABELS)).toHaveLength(9); + }); + + it('任务类型 / 任务项状态的 code 与中文名一一对应', () => { + expect(TASK_TYPES).toEqual(['demo', 'script', 'image', 'video', 'export', 'verify']); + expect(Object.keys(TASK_TYPE_LABELS)).toEqual([...TASK_TYPES]); + expect(TASK_ITEM_STATUSES).toEqual([ + 'queued', + 'running', + 'pending_review', + 'failed', + 'skipped', + ]); + }); + + it('中文名查询:已知回显中文,未知回退 code', () => { + expect(taskTypeLabel('demo')).toBe('演练任务'); + expect(taskTypeLabel('unknown')).toBe('unknown'); + expect(taskStatusLabel('partial_failed')).toBe('部分失败'); + expect(taskStatusLabel('unknown')).toBe('unknown'); + expect(taskItemStatusLabel('skipped')).toBe('已跳过'); + expect(taskItemStatusLabel('unknown')).toBe('unknown'); + }); +}); + +describe('九态迁移表(架构 §3.6)', () => { + const ALLOWED: Record = { + queued: ['running', 'canceled'], + running: ['pending_review', 'partial_failed', 'failed', 'canceled'], + partial_failed: ['running', 'queued', 'failed'], + pending_review: ['selected'], + selected: ['exported'], + exported: ['archived'], + archived: [], + failed: [], + canceled: [], + }; + + it('全量 9×9 迁移矩阵与预期一致(含禁止迁移)', () => { + for (const from of TASK_STATUSES) { + for (const to of TASK_STATUSES) { + expect(transitionAllowed(from, to), `${from} → ${to}`).toBe(ALLOWED[from].includes(to)); + } + } + }); + + it('关键链路:排队 → 生成中 → 待预览 / 部分失败 / 失败 / 已取消', () => { + expect(transitionAllowed('queued', 'running')).toBe(true); + expect(transitionAllowed('running', 'pending_review')).toBe(true); + expect(transitionAllowed('running', 'partial_failed')).toBe(true); + expect(transitionAllowed('running', 'failed')).toBe(true); + expect(transitionAllowed('running', 'canceled')).toBe(true); + // 排队任务可被直接取消(取消只影响未开始项) + expect(transitionAllowed('queued', 'canceled')).toBe(true); + }); + + it('重试链路:部分失败 → 排队 / 生成中;全部失败 → 失败', () => { + expect(transitionAllowed('partial_failed', 'queued')).toBe(true); + expect(transitionAllowed('partial_failed', 'running')).toBe(true); + expect(transitionAllowed('partial_failed', 'failed')).toBe(true); + // 部分失败不能直接到待预览 / 已取消 + expect(transitionAllowed('partial_failed', 'pending_review')).toBe(false); + expect(transitionAllowed('partial_failed', 'canceled')).toBe(false); + }); + + it('已导出不可回退,终态无出边', () => { + expect(transitionAllowed('exported', 'archived')).toBe(true); + for (const to of TASK_STATUSES) { + expect(transitionAllowed('exported', to) && to !== 'archived').toBe(false); + expect(transitionAllowed('archived', to)).toBe(false); + expect(transitionAllowed('failed', to)).toBe(false); + expect(transitionAllowed('canceled', to)).toBe(false); + } + }); + + it('待预览重试属 B3,本批不放行回生成中', () => { + expect(transitionAllowed('pending_review', 'running')).toBe(false); + expect(transitionAllowed('pending_review', 'selected')).toBe(true); + }); +}); + +describe('可取消 / 可重试 / 终态判定', () => { + it('仅排队与生成中可取消', () => { + for (const status of TASK_STATUSES) { + expect(canCancelTask(status)).toBe(status === 'queued' || status === 'running'); + } + }); + + it('仅部分失败可重试(架构 §3.6:只有待预览 / 部分失败可重试,待预览属 B3)', () => { + for (const status of TASK_STATUSES) { + expect(canRetryTask(status)).toBe(status === 'partial_failed'); + } + }); + + it('终态集合:待预览 / 已勾选 / 已导出 / 已入库 / 失败 / 已取消', () => { + const terminal = ['pending_review', 'selected', 'exported', 'archived', 'failed', 'canceled']; + for (const status of TASK_STATUSES) { + expect(isTaskTerminal(status)).toBe(terminal.includes(status)); + } + }); + + it('仍在生成中:仅排队与生成中', () => { + expect(isTaskActive('queued')).toBe(true); + expect(isTaskActive('running')).toBe(true); + expect(isTaskActive('partial_failed')).toBe(false); + expect(isTaskActive('canceled')).toBe(false); + }); +}); + +describe('队列映射(A6-BE-02 契约:9 队列 / 5 分组)', () => { + it('共 9 个队列,名称唯一,分组均在五组内', () => { + expect(TASK_QUEUES).toHaveLength(9); + expect(new Set(TASK_QUEUES.map((queue) => queue.name)).size).toBe(9); + + const groups = new Set(TASK_QUEUE_GROUPS); + for (const queue of TASK_QUEUES) { + expect(groups.has(queue.group)).toBe(true); + expect(queue.concurrency).toBeGreaterThan(0); + expect(queue.attempts).toBeGreaterThanOrEqual(1); + expect(queue.backoffDelayMs).toBeGreaterThan(0); + } + }); + + it('分组队列:script 1 / image 2 / video 3 / export 2 / verify 1', () => { + expect(queuesOfGroup('script')).toEqual(['script:generate']); + expect(queuesOfGroup('image')).toEqual(['image:matting', 'image:render']); + expect(queuesOfGroup('video')).toEqual(['video:keyframe', 'video:shot', 'video:compose']); + expect(queuesOfGroup('export')).toEqual(['delivery:zip', 'export:package']); + expect(queuesOfGroup('verify')).toEqual(['media:verify']); + }); + + it('任务类型 → 队列 → 分组闭环', () => { + expect(queueOfTaskType('demo')).toBe('media:verify'); + expect(queueOfTaskType('script')).toBe('script:generate'); + expect(queueOfTaskType('image')).toBe('image:render'); + expect(queueOfTaskType('video')).toBe('video:compose'); + expect(queueOfTaskType('export')).toBe('export:package'); + expect(queueOfTaskType('verify')).toBe('media:verify'); + + expect(queueGroupOf('media:verify')).toBe('verify'); + expect(queueGroupOf('script:generate')).toBe('script'); + expect(queueGroupOf('delivery:zip')).toBe('export'); + }); +}); + +describe('进度与收敛判定(进度落库,重启不丢)', () => { + it('进度 = 成功 + 失败 + 跳过 / 总数,四舍五入取整', () => { + expect(taskProgress({ itemTotal: 10, itemSucceeded: 0, itemFailed: 0, itemSkipped: 0 })).toBe( + 0, + ); + expect(taskProgress({ itemTotal: 3, itemSucceeded: 1, itemFailed: 0, itemSkipped: 0 })).toBe( + 33, + ); + expect(taskProgress({ itemTotal: 3, itemSucceeded: 1, itemFailed: 1, itemSkipped: 0 })).toBe( + 67, + ); + expect(taskProgress({ itemTotal: 4, itemSucceeded: 1, itemFailed: 1, itemSkipped: 2 })).toBe( + 100, + ); + }); + + it('边界:总数 0 进度 0;计数越界不溢出到 100 之外', () => { + expect(taskProgress({ itemTotal: 0, itemSucceeded: 0, itemFailed: 0, itemSkipped: 0 })).toBe(0); + expect(taskProgress({ itemTotal: 2, itemSucceeded: 5, itemFailed: 5, itemSkipped: 5 })).toBe( + 100, + ); + expect(taskProgress({ itemTotal: 2, itemSucceeded: -1, itemFailed: 0, itemSkipped: 0 })).toBe( + 0, + ); + }); + + it('收敛判定:成功 + 失败 + 跳过 = 总数', () => { + expect( + isTaskCountersFinished({ itemTotal: 5, itemSucceeded: 3, itemFailed: 1, itemSkipped: 1 }), + ).toBe(true); + expect( + isTaskCountersFinished({ itemTotal: 5, itemSucceeded: 3, itemFailed: 1, itemSkipped: 0 }), + ).toBe(false); + expect( + isTaskCountersFinished({ itemTotal: 0, itemSucceeded: 0, itemFailed: 0, itemSkipped: 0 }), + ).toBe(true); + }); + + it('收敛状态:取消优先 / 全成功待预览 / 全失败失败 / 其余部分失败', () => { + const base = { itemTotal: 4 }; + expect( + resolveFinishedStatus({ ...base, itemSucceeded: 4, itemFailed: 0, itemSkipped: 0 }, false), + ).toBe('pending_review'); + expect( + resolveFinishedStatus({ ...base, itemSucceeded: 0, itemFailed: 4, itemSkipped: 0 }, false), + ).toBe('failed'); + expect( + resolveFinishedStatus({ ...base, itemSucceeded: 2, itemFailed: 2, itemSkipped: 0 }, false), + ).toBe('partial_failed'); + expect( + resolveFinishedStatus({ ...base, itemSucceeded: 3, itemFailed: 1, itemSkipped: 0 }, true), + ).toBe('canceled'); + // 跳过项不影响「全部成功」判定 + expect( + resolveFinishedStatus({ ...base, itemSucceeded: 3, itemFailed: 0, itemSkipped: 1 }, false), + ).toBe('pending_review'); + }); +}); diff --git a/packages/shared/src/tasks/task-protocol.ts b/packages/shared/src/tasks/task-protocol.ts new file mode 100644 index 0000000..0a25c18 --- /dev/null +++ b/packages/shared/src/tasks/task-protocol.ts @@ -0,0 +1,334 @@ +/** + * 异步任务协议(A6-BE-01,技术架构 §3.6 / §3.7 / §6.4,开发计划 §6.7 / §7.6) + * + * 本文件是「任务类型 / 九态状态机 / 队列映射 / SSE 事件 / Outbox 事件」的**唯一事实源**: + * - `apps/api` 的 media 模块、`apps/worker` 的队列消费者、`apps/web` 的任务中心共用同一套枚举; + * - 队列名与并发 / 重试参数只在这里声明,BullMQ 注册(infra/queue)与 worker 分组都从这里读; + * - 状态迁移只提供纯函数,落库前由服务端校验;M0 只点亮 + * 排队 / 生成中 / 部分失败 / 失败 / 已取消,其余状态由 M1 / B3 点亮。 + * + * 约束:本文件不得依赖任何包(shared 零依赖),不得引入 Node / 浏览器 API。 + */ + +/** 任务类型:M0 仅 `demo` 可提交(演练任务),其余类型为 M1+ 占位 */ +export const TASK_TYPES = ['demo', 'script', 'image', 'video', 'export', 'verify'] as const; + +export type TaskType = (typeof TASK_TYPES)[number]; + +export const TASK_TYPE_LABELS: Record = { + demo: '演练任务', + script: '脚本生成', + image: '图片合成', + video: '视频生成', + export: '导出打包', + verify: '质量校验', +}; + +/** 任务状态:与架构 §3.6 九态严格一致(英文 code 落库,中文名只在前端展示) */ +export const TASK_STATUSES = [ + 'queued', + 'running', + 'partial_failed', + 'pending_review', + 'selected', + 'exported', + 'archived', + 'failed', + 'canceled', +] as const; + +export type TaskStatus = (typeof TASK_STATUSES)[number]; + +export const TASK_STATUS_LABELS: Record = { + queued: '排队', + running: '生成中', + partial_failed: '部分失败', + pending_review: '待预览', + selected: '已勾选', + exported: '已导出', + archived: '已入库', + failed: '失败', + canceled: '已取消', +}; + +/** 任务项状态(架构 §3.6 第二行):失败原因必填(枚举来自字典) */ +export const TASK_ITEM_STATUSES = [ + 'queued', + 'running', + 'pending_review', + 'failed', + 'skipped', +] as const; + +export type TaskItemStatus = (typeof TASK_ITEM_STATUSES)[number]; + +export const TASK_ITEM_STATUS_LABELS: Record = { + queued: '排队', + running: '生成中', + pending_review: '待预览', + failed: '失败', + skipped: '已跳过', +}; + +/** 队列分组:五个组可独立启停;单队列积压不影响其他组(A6-BE-02) */ +export const TASK_QUEUE_GROUPS = ['script', 'image', 'video', 'export', 'verify'] as const; + +export type TaskQueueGroup = (typeof TASK_QUEUE_GROUPS)[number]; + +/** 具体队列名(共 9 个):`<域>:<动作>`,与 BullMQ 注册名一一对应 */ +export type TaskQueueName = + | 'script:generate' + | 'image:matting' + | 'image:render' + | 'video:keyframe' + | 'video:shot' + | 'video:compose' + | 'delivery:zip' + | 'export:package' + | 'media:verify'; + +export interface TaskQueueDefinition { + name: TaskQueueName; + group: TaskQueueGroup; + /** 单队列并发上限(worker 侧消费并发) */ + concurrency: number; + /** 最大尝试次数(含首次) */ + attempts: number; + /** 指数退避基数(毫秒) */ + backoffDelayMs: number; +} + +export const TASK_QUEUES: readonly TaskQueueDefinition[] = [ + { name: 'script:generate', group: 'script', concurrency: 8, attempts: 3, backoffDelayMs: 1000 }, + { name: 'image:matting', group: 'image', concurrency: 2, attempts: 3, backoffDelayMs: 1000 }, + { name: 'image:render', group: 'image', concurrency: 2, attempts: 3, backoffDelayMs: 1000 }, + { name: 'video:keyframe', group: 'video', concurrency: 10, attempts: 3, backoffDelayMs: 1000 }, + { name: 'video:shot', group: 'video', concurrency: 12, attempts: 2, backoffDelayMs: 2000 }, + { name: 'video:compose', group: 'video', concurrency: 4, attempts: 3, backoffDelayMs: 1000 }, + { name: 'delivery:zip', group: 'export', concurrency: 1, attempts: 2, backoffDelayMs: 2000 }, + { name: 'export:package', group: 'export', concurrency: 2, attempts: 2, backoffDelayMs: 2000 }, + { name: 'media:verify', group: 'verify', concurrency: 4, attempts: 2, backoffDelayMs: 1000 }, +]; + +/** + * 任务类型 → 投递队列。 + * M0 演练任务只走 `media:verify`(轻量、无外部依赖);image 默认 render、video 默认 compose, + * 具体子任务拆分由 M1 / M2 的任务项处理器决定。 + */ +export const TASK_TYPE_QUEUE: Record = { + demo: 'media:verify', + script: 'script:generate', + image: 'image:render', + video: 'video:compose', + export: 'export:package', + verify: 'media:verify', +}; + +/** BullMQ job name(M0 仅演练任务可执行;其余类型由 M1+ 注册) */ +export const TASK_JOB_NAMES = { + demo: 'task:demo', +} as const; + +/** 任务项硬上限(业务上限读系统配置 `task.demo.max_items`,R-26) */ +export const TASK_ITEM_HARD_LIMIT = 100; + +/** 任务相关系统配置键(R-26 唯一来源;业务代码禁止再硬编码) */ +export const TASK_CONFIG_KEYS = { + demoMaxItems: 'task.demo.max_items', + demoCostPerItem: 'task.estimate.demo_cost_per_item', + demoSecPerItem: 'task.estimate.demo_seconds_per_item', +} as const; + +/** SSE 事件类型(技术架构 §6.4) */ +export const TASK_EVENT_TYPES = ['item', 'progress', 'done', 'error'] as const; + +export type TaskEventType = (typeof TASK_EVENT_TYPES)[number]; + +/** + * Outbox 领域事件(技术架构 §3.7): + * - `created` / `retry` / `canceled` 由 queue-worker 的 dispatcher 投递队列; + * - `finished` / `failed` 由 media 模块本地消费写审计(两边事件类型互不重叠)。 + */ +export const TASK_OUTBOX_EVENTS = { + created: 'media.task.created', + retry: 'media.task.retry', + canceled: 'media.task.canceled', + finished: 'media.task.finished', + failed: 'media.task.failed', +} as const; + +export type TaskOutboxEvent = (typeof TASK_OUTBOX_EVENTS)[keyof typeof TASK_OUTBOX_EVENTS]; + +/** 由 queue-worker dispatcher 投递的 Outbox 事件 */ +export const TASK_QUEUE_EVENT_TYPES: readonly TaskOutboxEvent[] = [ + TASK_OUTBOX_EVENTS.created, + TASK_OUTBOX_EVENTS.retry, +]; + +/** 由 media 模块本地消费(写审计)的 Outbox 事件 */ +export const TASK_LOCAL_EVENT_TYPES: readonly TaskOutboxEvent[] = [ + TASK_OUTBOX_EVENTS.finished, + TASK_OUTBOX_EVENTS.failed, +]; + +const TASK_TYPE_LABEL_MAP: ReadonlyMap = new Map(Object.entries(TASK_TYPE_LABELS)); +const TASK_STATUS_LABEL_MAP: ReadonlyMap = new Map( + Object.entries(TASK_STATUS_LABELS), +); +const TASK_ITEM_STATUS_LABEL_MAP: ReadonlyMap = new Map( + Object.entries(TASK_ITEM_STATUS_LABELS), +); + +/** 类型中文名;未知类型回退 code(审计 / 日志不因脏数据抛错) */ +export function taskTypeLabel(type: string): string { + return TASK_TYPE_LABEL_MAP.get(type) ?? type; +} + +/** 状态中文名;未知状态回退 code */ +export function taskStatusLabel(status: string): string { + return TASK_STATUS_LABEL_MAP.get(status) ?? status; +} + +/** 任务项状态中文名;未知状态回退 code */ +export function taskItemStatusLabel(status: string): string { + return TASK_ITEM_STATUS_LABEL_MAP.get(status) ?? status; +} + +const QUEUE_BY_NAME: ReadonlyMap = new Map( + TASK_QUEUES.map((queue) => [queue.name, queue]), +); + +/** 任务类型默认投递队列 */ +export function queueOfTaskType(type: TaskType): TaskQueueName { + return TASK_TYPE_QUEUE[type]; +} + +/** 队列所属分组 */ +export function queueGroupOf(queue: TaskQueueName): TaskQueueGroup { + const definition = QUEUE_BY_NAME.get(queue); + if (definition === undefined) { + throw new Error(`未登记的队列:${queue}`); + } + return definition.group; +} + +/** 分组下全部队列(按声明顺序) */ +export function queuesOfGroup(group: TaskQueueGroup): TaskQueueName[] { + return TASK_QUEUES.filter((queue) => queue.group === group).map((queue) => queue.name); +} + +// --------------------------------------------------------------------------- +// 状态机(架构 §3.6) +// --------------------------------------------------------------------------- + +/** + * 允许的状态迁移表(from → to[]): + * - 初始入队由创建动作直接落 `queued`,不属于状态迁移; + * - `running → failed`:全部任务项失败时直接失败(无需先落部分失败); + * - `partial_failed → queued`:A6 重试接口把任务置回排队(架构 §3.6 的概念迁移 + * `部分失败 → 生成中`,M0 实现为 queued → worker 取到后转 running,口径见实施报告); + * - 只有「待预览 / 部分失败」可重试(待预览重试属 B3,本表暂不放行); + * - 已导出不可回退,归档 / 失败 / 已取消为终态。 + */ +const TASK_TRANSITIONS: Record = { + queued: ['running', 'canceled'], + running: ['pending_review', 'partial_failed', 'failed', 'canceled'], + partial_failed: ['running', 'queued', 'failed'], + pending_review: ['selected'], + selected: ['exported'], + exported: ['archived'], + archived: [], + failed: [], + canceled: [], +}; + +const TASK_TRANSITION_SET: ReadonlyMap> = new Map( + TASK_STATUSES.map((status) => [status, new Set(TASK_TRANSITIONS[status])]), +); + +/** from 是否允许迁移到 to(九态完整迁移表,含禁止迁移) */ +export function transitionAllowed(from: TaskStatus, to: TaskStatus): boolean { + return TASK_TRANSITION_SET.get(from)?.has(to) ?? false; +} + +/** 可取消:仅排队 / 生成中(已成功项不受影响;已导出不可回退) */ +export function canCancelTask(status: TaskStatus): boolean { + return status === 'queued' || status === 'running'; +} + +/** 可重试:仅部分失败(架构 §3.6「只有待预览 / 部分失败可重试」,待预览重试属 B3) */ +export function canRetryTask(status: TaskStatus): boolean { + return status === 'partial_failed'; +} + +/** 终态:不再接受人工 / worker 动作(SSE 补发 done 后关闭) */ +export function isTaskTerminal(status: TaskStatus): boolean { + return ( + status === 'pending_review' || + status === 'selected' || + status === 'exported' || + status === 'archived' || + status === 'failed' || + status === 'canceled' + ); +} + +/** 仍在生成中(排队 / 生成中):SSE 连接保持、取消与重试语义都以它为前提 */ +export function isTaskActive(status: TaskStatus): boolean { + return status === 'queued' || status === 'running'; +} + +// --------------------------------------------------------------------------- +// 进度与完成判定(进度落库而非只在内存,进程重启不丢) +// --------------------------------------------------------------------------- + +export interface TaskCounters { + itemTotal: number; + itemSucceeded: number; + itemFailed: number; + itemSkipped: number; +} + +/** 已完成项 = 成功 + 失败 + 跳过;进度为 0–100 整数 */ +export function taskProgress(counters: TaskCounters): number { + const total = Math.max(0, counters.itemTotal); + if (total === 0) return 0; + const done = + Math.max(0, counters.itemSucceeded) + + Math.max(0, counters.itemFailed) + + Math.max(0, counters.itemSkipped); + const percent = Math.round((Math.min(done, total) / total) * 100); + return Math.min(100, Math.max(0, percent)); +} + +/** 是否全部任务项已收敛(成功 + 失败 + 跳过 = 总数) */ +export function isTaskCountersFinished(counters: TaskCounters): boolean { + const total = Math.max(0, counters.itemTotal); + const done = + Math.max(0, counters.itemSucceeded) + + Math.max(0, counters.itemFailed) + + Math.max(0, counters.itemSkipped); + return done >= total; +} + +/** + * 收敛后的任务终态判定(cancelRequested 优先): + * - 用户已请求取消 → canceled(即使已有失败项); + * - 无失败项 → pending_review(全部成功); + * - 无成功项但有失败项 → failed(全部失败); + * - 其余 → partial_failed(部分失败,可重试失败项)。 + * + * 调用方通常先 `isTaskCountersFinished`;未收敛时返回的是「按当前计数」的临时判定, + * 用于 worker 落中间态时保持一致语义(只允许前进,final 由最后一次收敛覆盖)。 + */ +export function resolveFinishedStatus( + counters: TaskCounters, + cancelRequested: boolean, +): 'pending_review' | 'partial_failed' | 'failed' | 'canceled' { + if (cancelRequested) return 'canceled'; + const failed = Math.max(0, counters.itemFailed); + const succeeded = Math.max(0, counters.itemSucceeded); + if (failed === 0) return 'pending_review'; + if (succeeded === 0) return 'failed'; + return 'partial_failed'; +} diff --git a/prisma/migrations/20261104125003_a6_media_tasks/migration.sql b/prisma/migrations/20261104125003_a6_media_tasks/migration.sql new file mode 100644 index 0000000..e240388 --- /dev/null +++ b/prisma/migrations/20261104125003_a6_media_tasks/migration.sql @@ -0,0 +1,93 @@ +-- CreateTable +CREATE TABLE "media_tasks" ( + "id" CHAR(26) NOT NULL, + "type" VARCHAR(16) NOT NULL, + "status" VARCHAR(24) NOT NULL DEFAULT 'queued', + "title" VARCHAR(200) NOT NULL, + "customer_id" CHAR(26) NOT NULL, + "customer_name" VARCHAR(128) NOT NULL, + "created_by" CHAR(26) NOT NULL, + "created_by_name" VARCHAR(64) NOT NULL, + "item_total" INTEGER NOT NULL, + "item_succeeded" INTEGER NOT NULL DEFAULT 0, + "item_failed" INTEGER NOT NULL DEFAULT 0, + "item_skipped" INTEGER NOT NULL DEFAULT 0, + "estimated_items" INTEGER NOT NULL, + "estimated_cost" DECIMAL(14,4) NOT NULL, + "estimated_seconds" INTEGER NOT NULL, + "progress" INTEGER NOT NULL DEFAULT 0, + "payload_json" JSONB NOT NULL, + "cancel_requested_at" TIMESTAMPTZ(3), + "canceled_at" TIMESTAMPTZ(3), + "canceled_by" CHAR(26), + "last_error_code" VARCHAR(32), + "last_error_message" VARCHAR(500), + "started_at" TIMESTAMPTZ(3), + "finished_at" TIMESTAMPTZ(3), + "created_at" TIMESTAMPTZ(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "updated_at" TIMESTAMPTZ(3) NOT NULL, + + CONSTRAINT "media_tasks_pkey" PRIMARY KEY ("id") +); + +-- CreateTable +CREATE TABLE "media_items" ( + "id" CHAR(26) NOT NULL, + "task_id" CHAR(26) NOT NULL, + "seq" INTEGER NOT NULL, + "status" VARCHAR(24) NOT NULL DEFAULT 'queued', + "failure_code" VARCHAR(64), + "failure_message" VARCHAR(500), + "attempts" INTEGER NOT NULL DEFAULT 0, + "started_at" TIMESTAMPTZ(3), + "finished_at" TIMESTAMPTZ(3), + "created_at" TIMESTAMPTZ(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "updated_at" TIMESTAMPTZ(3) NOT NULL, + + CONSTRAINT "media_items_pkey" PRIMARY KEY ("id") +); + +-- CreateTable +CREATE TABLE "task_events" ( + "id" CHAR(26) NOT NULL, + "task_id" CHAR(26) NOT NULL, + "seq" BIGSERIAL NOT NULL, + "event_type" VARCHAR(16) NOT NULL, + "payload_json" JSONB NOT NULL, + "created_at" TIMESTAMPTZ(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + + CONSTRAINT "task_events_pkey" PRIMARY KEY ("id") +); + +-- CreateIndex +CREATE INDEX "media_tasks_customer_id_created_at_idx" ON "media_tasks"("customer_id", "created_at" DESC); + +-- CreateIndex +CREATE INDEX "media_tasks_status_created_at_idx" ON "media_tasks"("status", "created_at" DESC); + +-- CreateIndex +CREATE INDEX "media_tasks_type_created_at_idx" ON "media_tasks"("type", "created_at" DESC); + +-- CreateIndex +CREATE INDEX "media_tasks_created_by_created_at_idx" ON "media_tasks"("created_by", "created_at" DESC); + +-- CreateIndex +CREATE INDEX "media_items_task_id_status_idx" ON "media_items"("task_id", "status"); + +-- CreateIndex +CREATE INDEX "media_items_status_idx" ON "media_items"("status"); + +-- CreateIndex +CREATE UNIQUE INDEX "media_items_task_id_seq_key" ON "media_items"("task_id", "seq"); + +-- CreateIndex +CREATE UNIQUE INDEX "task_events_seq_key" ON "task_events"("seq"); + +-- CreateIndex +CREATE INDEX "task_events_task_id_seq_idx" ON "task_events"("task_id", "seq"); + +-- AddForeignKey +ALTER TABLE "media_items" ADD CONSTRAINT "media_items_task_id_fkey" FOREIGN KEY ("task_id") REFERENCES "media_tasks"("id") ON DELETE CASCADE ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "task_events" ADD CONSTRAINT "task_events_task_id_fkey" FOREIGN KEY ("task_id") REFERENCES "media_tasks"("id") ON DELETE CASCADE ON UPDATE CASCADE; diff --git a/prisma/schema.prisma b/prisma/schema.prisma index 88324b3..b3e376b 100644 --- a/prisma/schema.prisma +++ b/prisma/schema.prisma @@ -518,3 +518,90 @@ model AuditExportJob { @@index([status]) @@map("audit_export_jobs") } + +// --------------------------------------------------------------------------- +// A6 任务中心(media 模块):任务头 / 任务项 / 任务事件三层 +// 状态机见 @lingxi/shared tasks/task-protocol.ts(架构 §3.6); +// 状态用 varchar 存英文 code(与 audit_logs 同风格),翻译只在前端,避免 enum 迁移成本。 +// --------------------------------------------------------------------------- + +/// 任务头:存提交时的参数快照与预估成本 / 时长;进度落库(重启不丢)。 +/// `customer_id` / `customer_name` 为冗余快照(跨模块禁止 join customers)。 +model MediaTask { + id String @id @db.Char(26) + type String @db.VarChar(16) + status String @default("queued") @db.VarChar(24) + title String @db.VarChar(200) + customerId String @map("customer_id") @db.Char(26) + customerName String @map("customer_name") @db.VarChar(128) + createdBy String @map("created_by") @db.Char(26) + createdByName String @map("created_by_name") @db.VarChar(64) + itemTotal Int @map("item_total") + itemSucceeded Int @default(0) @map("item_succeeded") + itemFailed Int @default(0) @map("item_failed") + itemSkipped Int @default(0) @map("item_skipped") + estimatedItems Int @map("estimated_items") + estimatedCost Decimal @map("estimated_cost") @db.Decimal(14, 4) + estimatedSeconds Int @map("estimated_seconds") + progress Int @default(0) + payloadJson Json @map("payload_json") @db.JsonB + cancelRequestedAt DateTime? @map("cancel_requested_at") @db.Timestamptz(3) + canceledAt DateTime? @map("canceled_at") @db.Timestamptz(3) + canceledBy String? @map("canceled_by") @db.Char(26) + lastErrorCode String? @map("last_error_code") @db.VarChar(32) + lastErrorMessage String? @map("last_error_message") @db.VarChar(500) + startedAt DateTime? @map("started_at") @db.Timestamptz(3) + finishedAt DateTime? @map("finished_at") @db.Timestamptz(3) + createdAt DateTime @default(now()) @map("created_at") @db.Timestamptz(3) + updatedAt DateTime @updatedAt @map("updated_at") @db.Timestamptz(3) + + items MediaItem[] + events TaskEvent[] + + @@index([customerId, createdAt(sort: Desc)]) + @@index([status, createdAt(sort: Desc)]) + @@index([type, createdAt(sort: Desc)]) + @@index([createdBy, createdAt(sort: Desc)]) + @@map("media_tasks") +} + +/// 任务项:只记状态与失败原因,产物引用在 M1+ 的产物表 / 对象存储。 +/// 外键选择 onDelete: Cascade —— 本批没有删除任务的对外入口,级联只用于测试夹具清理, +/// 避免残留孤儿任务项;审计留痕由 append-only 的 audit_logs 承担(见实施报告)。 +model MediaItem { + id String @id @db.Char(26) + taskId String @map("task_id") @db.Char(26) + seq Int + status String @default("queued") @db.VarChar(24) + failureCode String? @map("failure_code") @db.VarChar(64) + failureMessage String? @map("failure_message") @db.VarChar(500) + attempts Int @default(0) + startedAt DateTime? @map("started_at") @db.Timestamptz(3) + finishedAt DateTime? @map("finished_at") @db.Timestamptz(3) + createdAt DateTime @default(now()) @map("created_at") @db.Timestamptz(3) + updatedAt DateTime @updatedAt @map("updated_at") @db.Timestamptz(3) + + task MediaTask @relation(fields: [taskId], references: [id], onDelete: Cascade) + + /// (task_id, seq) 唯一约束同时提供按任务顺序扫描的索引,不再重复建普通索引 + @@unique([taskId, seq]) + @@index([taskId, status]) + @@index([status]) + @@map("media_items") +} + +/// 任务事件(SSE 增量源):`seq` 为只增不改的 BigInt,SSE 的 `id:` 直接用它续推。 +/// 事件类型 item / progress / done / error(架构 §6.4),payload 由产物方写入。 +model TaskEvent { + id String @id @db.Char(26) + taskId String @map("task_id") @db.Char(26) + seq BigInt @unique @default(autoincrement()) + eventType String @map("event_type") @db.VarChar(16) + payloadJson Json @map("payload_json") @db.JsonB + createdAt DateTime @default(now()) @map("created_at") @db.Timestamptz(3) + + task MediaTask @relation(fields: [taskId], references: [id], onDelete: Cascade) + + @@index([taskId, seq]) + @@map("task_events") +}