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:
Stev_Wang committed 2026-11-04 21:52:12 +08:00
1 parent b505dfe5e3
commit 4a29cf815e
8 files changed
+1267

No files matched your search

+216
View File
@@ -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>;