Files
lingxi/apps/api/src/modules/media/media-task.mapper.ts
T
Stev_Wang 633765f10f
ci / 质量门禁 (push) Successful in 6m4s
feat(media): A6 任务中心 API、SSE 进度与队列骨架
背景: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。
2026-11-04 21:52:35 +08:00

164 lines
5.3 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.
/**
* 任务中心 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,
};
}