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。
53 lines
2.0 KiB
TypeScript
53 lines
2.0 KiB
TypeScript
/**
|
||
* SSE 文本帧与断点续推解析(A6-BE-03 / 技术架构 §6.4)
|
||
*
|
||
* 帧格式(与 EventSource 规范一致):
|
||
* id: <task_events.seq>
|
||
* event: item | progress | done | error
|
||
* data: <单行 JSON>
|
||
* <空行>
|
||
* 心跳用注释帧 `: ping`,不污染事件流。`Last-Event-ID` 请求头优先于 `last_event_id` query。
|
||
*/
|
||
import { TASK_EVENT_TYPES, type TaskDoneEventPayload, type TaskEventType } from '@lingxi/shared';
|
||
|
||
/** DB 字符串 → SSE 事件类型(未知视为脏数据,由调用方兜底为 error / 跳过) */
|
||
export function parseTaskEventType(value: string): TaskEventType {
|
||
const found = TASK_EVENT_TYPES.find((event) => event === value);
|
||
if (found === undefined) throw new Error(`任务事件类型非法:${value}`);
|
||
return found;
|
||
}
|
||
|
||
export type SseResume = { ok: true; seq: bigint } | { ok: false };
|
||
|
||
/** 解析续推起点:header 优先;缺省 / 空串 = 从 0 开始;非纯数字视为非法(40001) */
|
||
export function resolveResumeSeq(
|
||
header: string | string[] | undefined,
|
||
query: string | undefined,
|
||
): SseResume {
|
||
const headerValue = Array.isArray(header) ? header[0] : header;
|
||
const raw = headerValue === undefined || headerValue.trim().length === 0 ? query : headerValue;
|
||
if (raw === undefined || raw.trim().length === 0) {
|
||
return { ok: true, seq: 0n };
|
||
}
|
||
const trimmed = raw.trim();
|
||
if (!/^\d{1,19}$/.test(trimmed)) {
|
||
return { ok: false };
|
||
}
|
||
return { ok: true, seq: BigInt(trimmed) };
|
||
}
|
||
|
||
/** 事件帧:id 用 task_events.seq(BigInt 转字符串),data 单行 JSON */
|
||
export function formatSseEvent(id: string, event: TaskEventType, data: unknown): string {
|
||
return `id: ${id}\nevent: ${event}\ndata: ${JSON.stringify(data)}\n\n`;
|
||
}
|
||
|
||
/** 心跳注释帧 */
|
||
export function formatSseComment(text: string): string {
|
||
return `: ${text}\n\n`;
|
||
}
|
||
|
||
/** 终态补发 done:id 用最后消费到的 seq,保证重连语义不倒退 */
|
||
export function formatSseDone(seq: bigint, payload: TaskDoneEventPayload): string {
|
||
return formatSseEvent(seq.toString(), 'done', payload);
|
||
}
|