feat(shared): A6 任务协议契约、任务数据模型与系统配置
背景:A6 任务中心需要统一任务协议(九态状态机 / 队列映射 / SSE 事件)与任务落库模型,供 API、worker 与前端共用,避免各端自建枚举。
改动:
- 新增 packages/shared/src/tasks/task-protocol.ts:任务类型、架构 §3.6 九态与允许迁移、5 组 9 队列定义、Outbox/SSE 事件、进度与收敛纯函数;
- 新增 packages/shared/src/schemas/tasks.ts:提交/列表/详情/取消/重试/SSE 的 Zod DTO(body camelCase、query snake_case,列表 { items, next_cursor });
- system_configs 新增 task 分组(演练上限 20 项 / 单条成本 0.02 元 / 单条时长 3 秒),seed 按定义幂等;
- Prisma 新增 media_tasks / media_items / task_events 三表与迁移 20261104125003_a6_media_tasks(expand-only,不改旧表)。
影响面:仅新增 shared 导出、配置项与三张新表,旧接口与旧表不受影响。
回滚:撤销提交,并对三张新表执行 prisma migrate resolve/删除(无业务数据)。
验证:shared 197 tests 全绿;typecheck / lint / format:check 全绿;prisma migrate status up to date。
This commit is contained in:
1 parent
b505dfe5e3
commit
4a29cf815e
8 files changed
+1267
No files matched your search
@@ -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',
|
||||
|
||||
@@ -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';
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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<typeof mediaTaskDemoModeSchema>;
|
||||
|
||||
/**
|
||||
* 提交任务入参(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<typeof mediaTaskSubmitSchema>;
|
||||
export type MediaTaskSubmitParsed = z.output<typeof mediaTaskSubmitSchema>;
|
||||
|
||||
/** 提交响应:预估条数 / 成本 / 时长缺一不可(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<typeof mediaTaskSubmitResponseSchema>;
|
||||
|
||||
/** 任务摘要(列表 / 详情共用;任务头不返回 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<typeof mediaTaskSummarySchema>;
|
||||
|
||||
/** 任务项:失败行含 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<typeof mediaTaskItemSchema>;
|
||||
|
||||
/**
|
||||
* 任务详情:摘要 + 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<typeof mediaTaskDetailSchema>;
|
||||
|
||||
/** 详情 query:items 续拉游标(首屏不传) */
|
||||
export const mediaTaskDetailQuerySchema = z.object({
|
||||
items_cursor: z.string().min(1).max(512).optional(),
|
||||
});
|
||||
|
||||
export type MediaTaskDetailQuery = z.infer<typeof mediaTaskDetailQuerySchema>;
|
||||
|
||||
/** 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<typeof mediaTaskSseQuerySchema>;
|
||||
|
||||
/** 列表筛选 + 游标分页(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<typeof mediaTaskListQuerySchema>;
|
||||
|
||||
export const mediaTaskListResponseSchema = z.object({
|
||||
items: z.array(mediaTaskSummarySchema),
|
||||
next_cursor: z.string().nullable(),
|
||||
});
|
||||
|
||||
export type MediaTaskListResponse = z.infer<typeof mediaTaskListResponseSchema>;
|
||||
|
||||
export const mediaTaskCancelResponseSchema = z.object({
|
||||
taskId: ulidSchema,
|
||||
status: taskStatusSchema,
|
||||
});
|
||||
|
||||
export type MediaTaskCancelResponse = z.infer<typeof mediaTaskCancelResponseSchema>;
|
||||
|
||||
export const mediaTaskRetryResponseSchema = z.object({
|
||||
taskId: ulidSchema,
|
||||
status: taskStatusSchema,
|
||||
retriedItems: z.number().int().nonnegative(),
|
||||
});
|
||||
|
||||
export type MediaTaskRetryResponse = z.infer<typeof mediaTaskRetryResponseSchema>;
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// 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<typeof taskItemEventPayloadSchema>;
|
||||
|
||||
/** `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<typeof taskProgressEventPayloadSchema>;
|
||||
|
||||
/** `done`:任务收敛(待预览 / 部分失败 / 失败 / 已取消),SSE 随后关闭 */
|
||||
export const taskDoneEventPayloadSchema = z.object({
|
||||
taskId: ulidSchema,
|
||||
status: taskStatusSchema,
|
||||
progress: z.number().int().min(0).max(100),
|
||||
});
|
||||
|
||||
export type TaskDoneEventPayload = z.infer<typeof taskDoneEventPayloadSchema>;
|
||||
|
||||
/** `error`:任务级错误(错误码与统一错误码同源) */
|
||||
export const taskErrorEventPayloadSchema = z.object({
|
||||
code: z.number().int(),
|
||||
message: z.string().min(1).max(500),
|
||||
});
|
||||
|
||||
export type TaskErrorEventPayload = z.infer<typeof taskErrorEventPayloadSchema>;
|
||||
|
||||
/** SSE 事件元素(序列化单测与前端契约共用) */
|
||||
export const taskSseEventSchema = z.object({
|
||||
id: z.string().min(1),
|
||||
event: taskEventTypeSchema,
|
||||
data: z.unknown(),
|
||||
});
|
||||
|
||||
export type TaskSseEvent = z.infer<typeof taskSseEventSchema>;
|
||||
@@ -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<TaskStatus, readonly TaskStatus[]> = {
|
||||
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<string>(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');
|
||||
});
|
||||
});
|
||||
@@ -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<TaskType, string> = {
|
||||
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<TaskStatus, string> = {
|
||||
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<TaskItemStatus, string> = {
|
||||
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<TaskType, TaskQueueName> = {
|
||||
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<string, string> = new Map(Object.entries(TASK_TYPE_LABELS));
|
||||
const TASK_STATUS_LABEL_MAP: ReadonlyMap<string, string> = new Map(
|
||||
Object.entries(TASK_STATUS_LABELS),
|
||||
);
|
||||
const TASK_ITEM_STATUS_LABEL_MAP: ReadonlyMap<string, string> = 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<string, TaskQueueDefinition> = 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<TaskStatus, readonly TaskStatus[]> = {
|
||||
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<TaskStatus, ReadonlySet<string>> = new Map(
|
||||
TASK_STATUSES.map((status) => [status, new Set<string>(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';
|
||||
}
|
||||
@@ -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;
|
||||
@@ -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")
|
||||
}
|
||||
Reference in new issue
Block a user