背景: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。
217 lines
8.1 KiB
TypeScript
217 lines
8.1 KiB
TypeScript
/**
|
||
* 任务中心共享契约(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>;
|