feat(media): A6 任务中心 API、SSE 进度与队列骨架
ci / 质量门禁 (push) Successful in 6m4s

背景: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:
Stev_Wang committed 2026-11-04 21:52:35 +08:00
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,
};
}