背景:A6-BE-01..05 与 A6-INF-01,打通「提交 → 队列 → SSE 进度 → 完成/失败 → 取消 / 仅重试失败项」闭环,为 B3 / M1 的真实任务提供统一协议。 改动: - API media 模块:POST/GET /media-tasks、详情、SSE(Last-Event-ID 续推 + 15s 心跳 + 800ms 落库轮询)、取消(只影响未开始项)、仅重试失败项;Idempotency-Key 24h(异步提交 202;同 key 同 body 返回同一任务且不重复审计,不同 body 40902);范围外统一 404; - 队列骨架:BullMQ 5 九队列(5 组独立启停)、Outbox dispatcher(Redis 锁 + FOR UPDATE SKIP LOCKED + jobId 幂等 + 指数退避/死信)、worker 按 QUEUE 启停、demo 演练处理器(事务 + 行锁 + 取消/重试/幂等)、优雅退出与心跳; - 监控:GET /api/v1/health/queues、GET /api/v1/metrics(Prometheus 0.0.4)、infra/monitoring 告警规则与接入说明; - 通用:AuditInterceptor 支持幂等重放 skipAudit(默认行为不变)、AppModule 接线、.env.example 的 QUEUE 语义、bullmq 5.81.5 / worker @prisma/client / worker 覆盖率依赖。 影响面:OpenAPI 新增 6 个端点(openapi:diff 无破坏性变更);API 进程新增 outbox dispatcher 与 metrics/health 路由;worker 新增 BullMQ 消费;未改旧审计导出链路与既有接口。 回滚:回滚提交并停止 worker 新队列消费;task/outbox 新表数据不影响旧功能,可按需保留。 验证:api 单测 257、集成 92(连跑 2 次)、worker 27、shared 197;typecheck / lint / format:check / build / openapi:diff 全绿;本地真实 worker 启用 9 队列心跳,/health/queues、/metrics 实测,/media-tasks 未登录 401。
This commit is contained in:
1 parent
4a29cf815e
commit
633765f10f
70 files changed
+8674
-45
No files matched your search
@@ -0,0 +1,163 @@
|
||||
/**
|
||||
* 任务中心 Prisma 行 → 共享 DTO 映射(A6-BE-04)
|
||||
*
|
||||
* - 列表 / 详情显式 select(DB-10:列表不返回 payload 等大 JSONB);
|
||||
* - 金额 `estimated_cost` 为 Decimal(14,4),出参统一转 number(元,2 位小数展示由前端处理);
|
||||
* - DB 存英文 code,未知 code 视为脏数据直接抛错(由全局过滤器转 500,不静默降级成错误状态)。
|
||||
*/
|
||||
import {
|
||||
type MediaTaskDetail,
|
||||
type MediaTaskItem,
|
||||
type MediaTaskSummary,
|
||||
TASK_ITEM_STATUSES,
|
||||
TASK_STATUSES,
|
||||
TASK_TYPES,
|
||||
type TaskItemStatus,
|
||||
type TaskStatus,
|
||||
type TaskType,
|
||||
} from '@lingxi/shared';
|
||||
import type { Prisma } from '@prisma/client';
|
||||
|
||||
/** 摘要 select:列表与详情共用(详情再追加 payload / 取消 / 错误字段) */
|
||||
export const MEDIA_TASK_SUMMARY_SELECT = {
|
||||
id: true,
|
||||
type: true,
|
||||
status: true,
|
||||
title: true,
|
||||
customerId: true,
|
||||
customerName: true,
|
||||
itemTotal: true,
|
||||
itemSucceeded: true,
|
||||
itemFailed: true,
|
||||
itemSkipped: true,
|
||||
progress: true,
|
||||
estimatedItems: true,
|
||||
estimatedCost: true,
|
||||
estimatedSeconds: true,
|
||||
createdBy: true,
|
||||
createdByName: true,
|
||||
createdAt: true,
|
||||
startedAt: true,
|
||||
finishedAt: true,
|
||||
} satisfies Prisma.MediaTaskSelect;
|
||||
|
||||
export type MediaTaskSummaryRow = Prisma.MediaTaskGetPayload<{
|
||||
select: typeof MEDIA_TASK_SUMMARY_SELECT;
|
||||
}>;
|
||||
|
||||
export const MEDIA_TASK_DETAIL_SELECT = {
|
||||
...MEDIA_TASK_SUMMARY_SELECT,
|
||||
payloadJson: true,
|
||||
cancelRequestedAt: true,
|
||||
lastErrorCode: true,
|
||||
lastErrorMessage: true,
|
||||
} satisfies Prisma.MediaTaskSelect;
|
||||
|
||||
export type MediaTaskDetailRow = Prisma.MediaTaskGetPayload<{
|
||||
select: typeof MEDIA_TASK_DETAIL_SELECT;
|
||||
}>;
|
||||
|
||||
/** 任务项 select:列表 / 详情只给状态与失败原因,不含产物引用 */
|
||||
export const MEDIA_ITEM_SELECT = {
|
||||
id: true,
|
||||
seq: true,
|
||||
status: true,
|
||||
failureCode: true,
|
||||
failureMessage: true,
|
||||
attempts: true,
|
||||
startedAt: true,
|
||||
finishedAt: true,
|
||||
} satisfies Prisma.MediaItemSelect;
|
||||
|
||||
export type MediaItemRow = Prisma.MediaItemGetPayload<{ select: typeof MEDIA_ITEM_SELECT }>;
|
||||
|
||||
/** DB 字符串 → 任务类型(未知视为脏数据) */
|
||||
export function parseTaskType(value: string): TaskType {
|
||||
const found = TASK_TYPES.find((type) => type === value);
|
||||
if (found === undefined) throw new Error(`任务类型非法:${value}`);
|
||||
return found;
|
||||
}
|
||||
|
||||
/** DB 字符串 → 任务状态(未知视为脏数据) */
|
||||
export function parseTaskStatus(value: string): TaskStatus {
|
||||
const found = TASK_STATUSES.find((status) => status === value);
|
||||
if (found === undefined) throw new Error(`任务状态非法:${value}`);
|
||||
return found;
|
||||
}
|
||||
|
||||
/** DB 字符串 → 任务项状态(未知视为脏数据) */
|
||||
export function parseTaskItemStatus(value: string): TaskItemStatus {
|
||||
const found = TASK_ITEM_STATUSES.find((status) => status === value);
|
||||
if (found === undefined) throw new Error(`任务项状态非法:${value}`);
|
||||
return found;
|
||||
}
|
||||
|
||||
function optionalIso(value: Date | null): string | undefined {
|
||||
return value === null ? undefined : value.toISOString();
|
||||
}
|
||||
|
||||
/** JsonB → 普通对象(只接受 object;异常结构回退空对象,详情不因此 500) */
|
||||
export function toJsonObject(value: Prisma.JsonValue): Record<string, unknown> {
|
||||
if (typeof value === 'object' && value !== null && !Array.isArray(value)) {
|
||||
return value;
|
||||
}
|
||||
return {};
|
||||
}
|
||||
|
||||
export function toMediaTaskSummary(row: MediaTaskSummaryRow): MediaTaskSummary {
|
||||
const startedAt = optionalIso(row.startedAt);
|
||||
const finishedAt = optionalIso(row.finishedAt);
|
||||
return {
|
||||
id: row.id,
|
||||
type: parseTaskType(row.type),
|
||||
status: parseTaskStatus(row.status),
|
||||
title: row.title,
|
||||
customerId: row.customerId,
|
||||
customerName: row.customerName,
|
||||
itemTotal: row.itemTotal,
|
||||
itemSucceeded: row.itemSucceeded,
|
||||
itemFailed: row.itemFailed,
|
||||
itemSkipped: row.itemSkipped,
|
||||
progress: row.progress,
|
||||
estimatedItems: row.estimatedItems,
|
||||
estimatedCost: Number(row.estimatedCost),
|
||||
estimatedSeconds: row.estimatedSeconds,
|
||||
createdBy: row.createdBy,
|
||||
createdByName: row.createdByName,
|
||||
createdAt: row.createdAt.toISOString(),
|
||||
...(startedAt === undefined ? {} : { startedAt }),
|
||||
...(finishedAt === undefined ? {} : { finishedAt }),
|
||||
};
|
||||
}
|
||||
|
||||
export function toMediaTaskItem(row: MediaItemRow): MediaTaskItem {
|
||||
const startedAt = optionalIso(row.startedAt);
|
||||
const finishedAt = optionalIso(row.finishedAt);
|
||||
return {
|
||||
id: row.id,
|
||||
seq: row.seq,
|
||||
status: parseTaskItemStatus(row.status),
|
||||
...(row.failureCode === null ? {} : { failureCode: row.failureCode }),
|
||||
...(row.failureMessage === null ? {} : { failureMessage: row.failureMessage }),
|
||||
attempts: row.attempts,
|
||||
...(startedAt === undefined ? {} : { startedAt }),
|
||||
...(finishedAt === undefined ? {} : { finishedAt }),
|
||||
};
|
||||
}
|
||||
|
||||
export function toMediaTaskDetail(
|
||||
row: MediaTaskDetailRow,
|
||||
items: MediaItemRow[],
|
||||
itemsNextCursor: string | null,
|
||||
): MediaTaskDetail {
|
||||
const cancelRequestedAt = optionalIso(row.cancelRequestedAt);
|
||||
return {
|
||||
...toMediaTaskSummary(row),
|
||||
payload: toJsonObject(row.payloadJson),
|
||||
...(cancelRequestedAt === undefined ? {} : { cancelRequestedAt }),
|
||||
...(row.lastErrorCode === null ? {} : { lastErrorCode: row.lastErrorCode }),
|
||||
...(row.lastErrorMessage === null ? {} : { lastErrorMessage: row.lastErrorMessage }),
|
||||
items: items.map((item) => toMediaTaskItem(item)),
|
||||
itemsNextCursor,
|
||||
};
|
||||
}
|
||||
Reference in new issue
Block a user