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