Files
lingxi/packages/shared/src/schemas/tasks.ts
T
Stev_Wang 4a29cf815e 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。
2026-11-04 21:52:12 +08:00

217 lines
8.1 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/**
* 任务中心共享契约(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>;