From 633765f10f2e09325b6118c5a3402794778830e4 Mon Sep 17 00:00:00 2001 From: Stev_Wang <304865932@qq.com> Date: Wed, 4 Nov 2026 21:52:35 +0800 Subject: [PATCH] =?UTF-8?q?feat(media):=20A6=20=E4=BB=BB=E5=8A=A1=E4=B8=AD?= =?UTF-8?q?=E5=BF=83=20API=E3=80=81SSE=20=E8=BF=9B=E5=BA=A6=E4=B8=8E?= =?UTF-8?q?=E9=98=9F=E5=88=97=E9=AA=A8=E6=9E=B6?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 背景: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。 --- .env.example | 18 +- apps/api/package.json | 1 + apps/api/src/app.module.ts | 4 + .../common/security/audit.interceptor.spec.ts | 16 + .../src/common/security/audit.interceptor.ts | 19 +- .../src/common/security/request-context.ts | 6 + .../infra/queue/outbox-dispatcher.service.ts | 238 +++++ .../src/infra/queue/outbox-dispatcher.spec.ts | 182 ++++ .../src/infra/queue/outbox-payload.spec.ts | 103 +++ apps/api/src/infra/queue/outbox-payload.ts | 172 ++++ apps/api/src/infra/queue/outbox.int.spec.ts | 279 ++++++ apps/api/src/infra/queue/prometheus.spec.ts | 53 ++ apps/api/src/infra/queue/prometheus.ts | 97 ++ .../infra/queue/queue-endpoints.int.spec.ts | 122 +++ .../infra/queue/queue-health.controller.ts | 102 +++ apps/api/src/infra/queue/queue-health.spec.ts | 86 ++ apps/api/src/infra/queue/queue-health.ts | 119 +++ .../api/src/infra/queue/queue-health.types.ts | 59 ++ .../infra/queue/queue-metrics.controller.ts | 104 +++ .../src/infra/queue/queue-registry.service.ts | 146 +++ apps/api/src/infra/queue/queue.constants.ts | 40 + apps/api/src/infra/queue/queue.module.ts | 23 + apps/api/src/infra/queue/queue.utils.ts | 34 + apps/api/src/infra/queue/with-timeout.ts | 27 + .../modules/media/media-task-cursor.spec.ts | 49 + .../src/modules/media/media-task-cursor.ts | 57 ++ .../media/media-task-estimator.spec.ts | 102 +++ .../src/modules/media/media-task-estimator.ts | 111 +++ .../media/media-task-idempotency.spec.ts | 62 ++ .../modules/media/media-task-idempotency.ts | 71 ++ .../media-task-result-consumer.service.ts | 155 ++++ .../media/media-task-result-consumer.spec.ts | 208 +++++ .../media/media-task-sse-format.spec.ts | 68 ++ .../modules/media/media-task-sse-format.ts | 52 ++ .../media/media-task-sse.service.spec.ts | 201 ++++ .../modules/media/media-task-sse.service.ts | 158 ++++ .../modules/media/media-task.controller.ts | 168 ++++ .../modules/media/media-task.mapper.spec.ts | 131 +++ .../src/modules/media/media-task.mapper.ts | 163 ++++ .../media/media-task.repository.spec.ts | 530 +++++++++++ .../modules/media/media-task.repository.ts | 660 +++++++++++++ .../modules/media/media-task.service.spec.ts | 404 ++++++++ .../src/modules/media/media-task.service.ts | 298 ++++++ apps/api/src/modules/media/media.int.spec.ts | 864 ++++++++++++++++++ apps/api/src/modules/media/media.module.ts | 31 + apps/web/src/api/generated/openapi.d.ts | 303 ++++++ apps/worker/package.json | 7 +- apps/worker/src/config.spec.ts | 58 ++ apps/worker/src/config.ts | 55 +- apps/worker/src/heartbeat.spec.ts | 50 + apps/worker/src/heartbeat.ts | 61 ++ apps/worker/src/main.ts | 209 ++++- .../src/processors/demo.processor.spec.ts | 72 ++ apps/worker/src/processors/demo.processor.ts | 462 ++++++++++ apps/worker/src/processors/dispatch.spec.ts | 18 + apps/worker/src/processors/dispatch.ts | 18 + apps/worker/src/processors/index.ts | 13 + apps/worker/src/processors/job-data.spec.ts | 20 + apps/worker/src/processors/job-data.ts | 25 + apps/worker/src/queues.spec.ts | 43 + apps/worker/src/queues.ts | 100 ++ apps/worker/src/redis-options.spec.ts | 31 + apps/worker/src/redis-options.ts | 26 + apps/worker/tsconfig.build.json | 3 +- apps/worker/tsconfig.json | 2 +- apps/worker/vitest.config.ts | 14 + infra/monitoring/README.md | 79 ++ infra/monitoring/queue-alerts.yml | 52 ++ openapi/lingxi.openapi.json | 264 ++++++ pnpm-lock.yaml | 141 +++ 70 files changed, 8674 insertions(+), 45 deletions(-) create mode 100644 apps/api/src/infra/queue/outbox-dispatcher.service.ts create mode 100644 apps/api/src/infra/queue/outbox-dispatcher.spec.ts create mode 100644 apps/api/src/infra/queue/outbox-payload.spec.ts create mode 100644 apps/api/src/infra/queue/outbox-payload.ts create mode 100644 apps/api/src/infra/queue/outbox.int.spec.ts create mode 100644 apps/api/src/infra/queue/prometheus.spec.ts create mode 100644 apps/api/src/infra/queue/prometheus.ts create mode 100644 apps/api/src/infra/queue/queue-endpoints.int.spec.ts create mode 100644 apps/api/src/infra/queue/queue-health.controller.ts create mode 100644 apps/api/src/infra/queue/queue-health.spec.ts create mode 100644 apps/api/src/infra/queue/queue-health.ts create mode 100644 apps/api/src/infra/queue/queue-health.types.ts create mode 100644 apps/api/src/infra/queue/queue-metrics.controller.ts create mode 100644 apps/api/src/infra/queue/queue-registry.service.ts create mode 100644 apps/api/src/infra/queue/queue.constants.ts create mode 100644 apps/api/src/infra/queue/queue.module.ts create mode 100644 apps/api/src/infra/queue/queue.utils.ts create mode 100644 apps/api/src/infra/queue/with-timeout.ts create mode 100644 apps/api/src/modules/media/media-task-cursor.spec.ts create mode 100644 apps/api/src/modules/media/media-task-cursor.ts create mode 100644 apps/api/src/modules/media/media-task-estimator.spec.ts create mode 100644 apps/api/src/modules/media/media-task-estimator.ts create mode 100644 apps/api/src/modules/media/media-task-idempotency.spec.ts create mode 100644 apps/api/src/modules/media/media-task-idempotency.ts create mode 100644 apps/api/src/modules/media/media-task-result-consumer.service.ts create mode 100644 apps/api/src/modules/media/media-task-result-consumer.spec.ts create mode 100644 apps/api/src/modules/media/media-task-sse-format.spec.ts create mode 100644 apps/api/src/modules/media/media-task-sse-format.ts create mode 100644 apps/api/src/modules/media/media-task-sse.service.spec.ts create mode 100644 apps/api/src/modules/media/media-task-sse.service.ts create mode 100644 apps/api/src/modules/media/media-task.controller.ts create mode 100644 apps/api/src/modules/media/media-task.mapper.spec.ts create mode 100644 apps/api/src/modules/media/media-task.mapper.ts create mode 100644 apps/api/src/modules/media/media-task.repository.spec.ts create mode 100644 apps/api/src/modules/media/media-task.repository.ts create mode 100644 apps/api/src/modules/media/media-task.service.spec.ts create mode 100644 apps/api/src/modules/media/media-task.service.ts create mode 100644 apps/api/src/modules/media/media.int.spec.ts create mode 100644 apps/api/src/modules/media/media.module.ts create mode 100644 apps/worker/src/config.spec.ts create mode 100644 apps/worker/src/heartbeat.spec.ts create mode 100644 apps/worker/src/heartbeat.ts create mode 100644 apps/worker/src/processors/demo.processor.spec.ts create mode 100644 apps/worker/src/processors/demo.processor.ts create mode 100644 apps/worker/src/processors/dispatch.spec.ts create mode 100644 apps/worker/src/processors/dispatch.ts create mode 100644 apps/worker/src/processors/index.ts create mode 100644 apps/worker/src/processors/job-data.spec.ts create mode 100644 apps/worker/src/processors/job-data.ts create mode 100644 apps/worker/src/queues.spec.ts create mode 100644 apps/worker/src/queues.ts create mode 100644 apps/worker/src/redis-options.spec.ts create mode 100644 apps/worker/src/redis-options.ts create mode 100644 apps/worker/vitest.config.ts create mode 100644 infra/monitoring/README.md create mode 100644 infra/monitoring/queue-alerts.yml diff --git a/.env.example b/.env.example index 8a059dc..1fa0329 100644 --- a/.env.example +++ b/.env.example @@ -40,9 +40,21 @@ HOST=0.0.0.0 WEB_ORIGIN=http://127.0.0.1:5173,http://localhost:5173 MAX_UPLOAD_MB=500 -# ---------- Worker ---------- -QUEUE=default -WORKER_CONCURRENCY=2 +# ---------- Worker / 队列(BullMQ,技术架构 §2.4 / §3.7) ---------- +# 队列分 5 组共 9 条(script:generate / image:matting / image:render / video:keyframe / video:shot / +# video:compose / delivery:zip / export:package / media:verify),名称与并发 / 重试 / 退避参数 +# 的唯一事实源是 packages/shared 的 TASK_QUEUES,这里只决定 worker 启动哪些队列: +# all 启动全部 9 条(本地开发默认) +# 分组名,逗号分隔 script / image / video / export / verify(如 QUEUE=verify) +# 具体队列名 如 QUEUE=media:verify 或 QUEUE=image:render,media:verify +QUEUE=all +# 可选:统一覆盖全部队列的消费并发;留空 / 注释时按 TASK_QUEUES 的每队列定义 +# (如 media:verify=4、script:generate=8、video:shot=12)。 +# WORKER_CONCURRENCY=2 +# 可选:worker 标识,默认 -;写入心跳 lingxi:worker:heartbeat:(TTL 30s)。 +# WORKER_ID=dev-worker-01 +# API 进程内 Outbox dispatcher 的调度间隔(毫秒);未设置默认 1000。 +# OUTBOX_DISPATCH_INTERVAL_MS=1000 # ---------- 前端(只能暴露 VITE_PUBLIC_* 前缀,ENV-07) ---------- VITE_PUBLIC_API_BASE_URL=http://127.0.0.1:3000/api/v1 diff --git a/apps/api/package.json b/apps/api/package.json index e32f4aa..afb9ac1 100644 --- a/apps/api/package.json +++ b/apps/api/package.json @@ -22,6 +22,7 @@ "@nestjs/swagger": "11.4.7", "@prisma/client": "6.19.3", "argon2": "0.45.1", + "bullmq": "5.81.5", "dotenv": "18.0.9", "fastify": "5.12.7", "ioredis": "5.11.1", diff --git a/apps/api/src/app.module.ts b/apps/api/src/app.module.ts index 5e00bb4..7d3ace7 100644 --- a/apps/api/src/app.module.ts +++ b/apps/api/src/app.module.ts @@ -6,10 +6,12 @@ import { SecurityModule } from './common/security/security.module'; import { APP_CONFIG, AppConfigModule } from './config/config.module'; import type { AppConfig } from './config/env'; import { PrismaModule } from './infra/prisma/prisma.module'; +import { QueueModule } from './infra/queue/queue.module'; import { RedisModule } from './infra/redis/redis.module'; import { CatalogModule } from './modules/catalog/catalog.module'; import { ClientModule } from './modules/client/client.module'; import { IdentityModule } from './modules/identity/identity.module'; +import { MediaModule } from './modules/media/media.module'; import { PlatformModule } from './modules/platform/platform.module'; @Module({ @@ -49,10 +51,12 @@ import { PlatformModule } from './modules/platform/platform.module'; }), PrismaModule, RedisModule, + QueueModule, SecurityModule, IdentityModule, ClientModule, CatalogModule, + MediaModule, PlatformModule, ], }) diff --git a/apps/api/src/common/security/audit.interceptor.spec.ts b/apps/api/src/common/security/audit.interceptor.spec.ts index 844fb94..942e510 100644 --- a/apps/api/src/common/security/audit.interceptor.spec.ts +++ b/apps/api/src/common/security/audit.interceptor.spec.ts @@ -163,6 +163,22 @@ describe('AuditInterceptor · 审计落库(A5-BE-02)', () => { ).resolves.toBe('ok'); }); + it('skipAudit(幂等重放):请求正常返回但不写审计', async () => { + const requestContext = new RequestContextService(); + const sink = sinkOf(); + const interceptor = interceptorOf(requestContext, sink); + const next = handlerOf(() => { + // 模拟 A6 任务提交识别为幂等重放:返回同一 taskId,但不产生新的业务动作 + requestContext.patch({ skipAudit: true }); + return of({ taskId: 't1' }); + }); + + await expect( + lastValueFrom(interceptor.intercept(contextOf('task.submit', requestFixture()), next)), + ).resolves.toEqual({ taskId: 't1' }); + expect(sink.write).not.toHaveBeenCalled(); + }); + it('未标注 @Audit 的请求不写审计', async () => { const requestContext = new RequestContextService(); const sink = sinkOf(); diff --git a/apps/api/src/common/security/audit.interceptor.ts b/apps/api/src/common/security/audit.interceptor.ts index a6ec5e0..1e1f46a 100644 --- a/apps/api/src/common/security/audit.interceptor.ts +++ b/apps/api/src/common/security/audit.interceptor.ts @@ -9,7 +9,7 @@ import { type NestInterceptor, Optional, } from '@nestjs/common'; -import { from, Observable, throwError } from 'rxjs'; +import { from, Observable, of, throwError } from 'rxjs'; import { catchError, concatMap, map, mergeMap } from 'rxjs/operators'; import { AppException } from '../errors/app-exception'; @@ -119,15 +119,18 @@ export class AuditInterceptor implements NestInterceptor { return next .handle() .pipe( - concatMap((value: unknown) => - from(this.record(ctx, action, 'success', targetId)).pipe(map(() => value)), - ), - catchError((error: unknown) => + concatMap((value: unknown) => { + // skipAudit:handler 内 patch({ skipAudit: true })(如 A6 幂等重放)时不落审计 + if (ctx.skipAudit === true) return of(value); + return from(this.record(ctx, action, 'success', targetId)).pipe(map(() => value)); + }), + catchError((error: unknown) => { // record 内部已吞掉异常;这里再兜底一次,保证原错误原样抛回 - from( + if (ctx.skipAudit === true) return throwError(() => error); + return from( this.record(ctx, action, 'failure', targetId, error).catch(() => undefined), - ).pipe(mergeMap(() => throwError(() => error))), - ), + ).pipe(mergeMap(() => throwError(() => error))); + }), ) .subscribe(subscriber); }), diff --git a/apps/api/src/common/security/request-context.ts b/apps/api/src/common/security/request-context.ts index 2f69ffe..80bc38e 100644 --- a/apps/api/src/common/security/request-context.ts +++ b/apps/api/src/common/security/request-context.ts @@ -40,6 +40,12 @@ export interface RequestContext { permissions: string[] | null; scope: ScopeContext | null; audit: AuditDetail | null; + /** + * 跳过本次请求的 @Audit 落库(默认 false,行为不变)。 + * 目前唯一用途:A6 任务提交幂等重放(同 key 同 body)——不重复建任务、不重复写审计, + * 由 media Service 在识别为重放后 `patch({ skipAudit: true })`。 + */ + skipAudit?: boolean; ip: string | null; userAgent: string | null; method: string; diff --git a/apps/api/src/infra/queue/outbox-dispatcher.service.ts b/apps/api/src/infra/queue/outbox-dispatcher.service.ts new file mode 100644 index 0000000..f00f575 --- /dev/null +++ b/apps/api/src/infra/queue/outbox-dispatcher.service.ts @@ -0,0 +1,238 @@ +import { ulid } from '@lingxi/shared'; +import { + Inject, + Injectable, + Logger, + type OnModuleDestroy, + type OnModuleInit, +} from '@nestjs/common'; +import { Prisma } from '@prisma/client'; + +import type { PrismaService } from '../prisma/prisma.service'; +import type { RedisService } from '../redis/redis.service'; +import { PRISMA, REDIS } from '../tokens'; +import { + type ClaimedOutboxEvent, + DISPATCHABLE_OUTBOX_EVENT_TYPES, + isOutboxDeadLetter, + planOutboxDelivery, + queueDefinitionOf, +} from './outbox-payload'; +import { + OUTBOX_DEAD_LETTER_MARKER, + OUTBOX_DISPATCH_BATCH_SIZE, + OUTBOX_DISPATCH_DEFAULT_INTERVAL_MS, + OUTBOX_DISPATCH_LOCK_KEY, + OUTBOX_DISPATCH_LOCK_TTL_MS, + RELEASE_LOCK_SCRIPT, +} from './queue.constants'; +import { errorMessage, readPositiveIntEnv, toSingleLine, truncate } from './queue.utils'; +import { QueueRegistryService } from './queue-registry.service'; + +const ERROR_LOG_INTERVAL_MS = 30_000; +const LAST_ERROR_MAX_LENGTH = 1000; + +interface OutboxLock { + token: string; +} + +/** 单轮投递结果(供测试与指标使用) */ +export interface OutboxDispatchResult { + claimed: number; + failed: number; +} + +/** + * Outbox dispatcher(A6-BE-05):定时从 outbox_events 认领待投递事件并写入 BullMQ。 + * + * 语义:至少一次投递 + jobId 幂等(`${eventId}-${itemId}`)+ 失败退避 + 死信兜底。 + * 多实例安全:Redis 分布式锁串行化调度,DB 侧 `FOR UPDATE SKIP LOCKED` + 条件更新保证不重复认领。 + */ +@Injectable() +export class OutboxDispatcherService implements OnModuleInit, OnModuleDestroy { + private readonly logger = new Logger(OutboxDispatcherService.name); + private timer: NodeJS.Timeout | null = null; + private running = false; + private lastErrorLogAt = 0; + + constructor( + @Inject(PRISMA) private readonly prisma: PrismaService, + @Inject(REDIS) private readonly redis: RedisService, + @Inject(QueueRegistryService) private readonly registry: QueueRegistryService, + ) {} + + onModuleInit(): void { + // 单测 / 集成测试的进程内 app 不启动后台调度:避免测试之间互相抢认领 outbox 事件 + // (真实 API 进程与手动调用 dispatchOnce() 都不受影响)。 + if (process.env.VITEST !== undefined) { + this.logger.debug('VITEST 环境:Outbox dispatcher 不启动自动调度'); + return; + } + const intervalMs = readPositiveIntEnv( + 'OUTBOX_DISPATCH_INTERVAL_MS', + OUTBOX_DISPATCH_DEFAULT_INTERVAL_MS, + 50, + ); + this.timer = setInterval(() => { + void this.tick(); + }, intervalMs); + this.timer.unref(); + this.logger.log(`Outbox dispatcher 已启动(间隔 ${String(intervalMs)}ms)`); + } + + onModuleDestroy(): void { + if (this.timer !== null) { + clearInterval(this.timer); + this.timer = null; + } + } + + /** 执行一轮「抢锁 → 认领 → 投递」;抢不到锁直接跳过本轮 */ + async dispatchOnce(): Promise { + const lock = await this.acquireLock(); + if (lock === null) { + return { claimed: 0, failed: 0 }; + } + try { + const events = await this.claimEvents(); + let failed = 0; + for (const event of events) { + const delivered = await this.deliverEvent(event); + if (!delivered) { + failed += 1; + } + } + return { claimed: events.length, failed }; + } finally { + await this.releaseLock(lock.token); + } + } + + private async tick(): Promise { + if (this.running) { + return; + } + this.running = true; + try { + await this.dispatchOnce(); + } catch (error) { + this.logThrottled(error); + } finally { + this.running = false; + } + } + + private async acquireLock(): Promise { + const client = this.redis.getClient(); + const token = ulid(); + const result = await client.set( + OUTBOX_DISPATCH_LOCK_KEY, + token, + 'PX', + OUTBOX_DISPATCH_LOCK_TTL_MS, + 'NX', + ); + return result === 'OK' ? { token } : null; + } + + private async releaseLock(token: string): Promise { + try { + await this.redis.getClient().eval(RELEASE_LOCK_SCRIPT, 1, OUTBOX_DISPATCH_LOCK_KEY, token); + } catch (error) { + this.logThrottled(error); + } + } + + /** + * 事务内认领:`FOR UPDATE SKIP LOCKED` 取一批待投递事件并 attempts+1,提交后再投递。 + * 退避过滤:`occurred_at + min(2^attempts*500ms, 60s) <= now()`,失败事件下一轮再试。 + */ + private async claimEvents(): Promise { + return this.prisma.$transaction(async (tx) => { + const rows = await tx.$queryRaw(Prisma.sql` + SELECT id, aggregate_id, event_type, payload_json, attempts + FROM outbox_events + WHERE published_at IS NULL + AND event_type IN (${Prisma.join([...DISPATCHABLE_OUTBOX_EVENT_TYPES])}) + AND occurred_at <= now() - interval '0.5 second' * LEAST(power(2, attempts), 120) + ORDER BY occurred_at ASC + LIMIT ${OUTBOX_DISPATCH_BATCH_SIZE} + FOR UPDATE SKIP LOCKED + `); + if (rows.length === 0) { + return []; + } + const ids = rows.map((row) => row.id); + await tx.$executeRaw( + Prisma.sql`UPDATE outbox_events SET attempts = attempts + 1 WHERE id IN (${Prisma.join(ids)})`, + ); + return rows.map((row) => ({ ...row, attempts: row.attempts + 1 })); + }); + } + + private async deliverEvent(event: ClaimedOutboxEvent): Promise { + try { + const plan = planOutboxDelivery(event); + if (plan === null) { + await this.markPublished(event.id); + return true; + } + if (plan.jobs.length === 0) { + this.logger.warn(`Outbox 事件 ${event.id}(${event.event_type})无任务项,直接置为已投递`); + await this.markPublished(event.id); + return true; + } + for (const job of plan.jobs) { + const definition = queueDefinitionOf(job.queue); + await this.registry.enqueue(job.queue, job.jobName, job.data, { + jobId: job.jobId, + attempts: definition.attempts, + backoff: { type: 'fixed', delay: definition.backoffDelayMs }, + removeOnComplete: 1000, + removeOnFail: 5000, + }); + } + await this.markPublished(event.id); + return true; + } catch (error) { + await this.recordFailure(event, error); + return false; + } + } + + private async markPublished(id: string): Promise { + await this.prisma.$executeRaw( + Prisma.sql`UPDATE outbox_events SET published_at = now(), last_error = NULL WHERE id = ${id}`, + ); + } + + private async recordFailure(event: ClaimedOutboxEvent, error: unknown): Promise { + const message = truncate(toSingleLine(errorMessage(error)), LAST_ERROR_MAX_LENGTH); + if (isOutboxDeadLetter(event.attempts)) { + this.logger.error( + `Outbox 事件 ${event.id} 投递失败 ${String(event.attempts)} 次,标记为死信:${message}`, + ); + await this.prisma.$executeRaw( + Prisma.sql`UPDATE outbox_events SET published_at = now(), last_error = ${OUTBOX_DEAD_LETTER_MARKER} WHERE id = ${event.id}`, + ); + return; + } + this.logger.warn( + `Outbox 事件 ${event.id} 投递失败(第 ${String(event.attempts)} 次,下轮退避重试):${message}`, + ); + // outbox_events 没有 next_attempt_at 列(A0 表结构已合并):把 occurred_at 重置为本次失败时刻, + // 作为指数退避的起点(认领条件为 occurred_at <= now() - min(2^attempts*500ms, 60s))。 + await this.prisma.$executeRaw( + Prisma.sql`UPDATE outbox_events SET last_error = ${message}, occurred_at = now() WHERE id = ${event.id}`, + ); + } + + private logThrottled(error: unknown): void { + const now = Date.now(); + if (now - this.lastErrorLogAt < ERROR_LOG_INTERVAL_MS) { + return; + } + this.lastErrorLogAt = now; + this.logger.warn(`Outbox dispatcher 本轮跳过:${toSingleLine(errorMessage(error))}`); + } +} diff --git a/apps/api/src/infra/queue/outbox-dispatcher.spec.ts b/apps/api/src/infra/queue/outbox-dispatcher.spec.ts new file mode 100644 index 0000000..1968712 --- /dev/null +++ b/apps/api/src/infra/queue/outbox-dispatcher.spec.ts @@ -0,0 +1,182 @@ +import { queueOfTaskType } from '@lingxi/shared'; +import { Test } from '@nestjs/testing'; +import { describe, expect, it, vi } from 'vitest'; + +import { PRISMA, REDIS } from '../tokens'; +import { OutboxDispatcherService } from './outbox-dispatcher.service'; +import type { ClaimedOutboxEvent } from './outbox-payload'; +import { queueDefinitionOf } from './outbox-payload'; +import { QueueRegistryService } from './queue-registry.service'; + +interface LockClientMock { + set: ReturnType; + eval: ReturnType; +} + +interface TransactionMock { + $queryRaw: ReturnType; + $executeRaw: ReturnType; +} + +interface PrismaMock { + $transaction: ReturnType; + $executeRaw: ReturnType; + tx: TransactionMock; +} + +interface RegistryMock { + enqueue: ReturnType; +} + +interface Harness { + dispatcher: OutboxDispatcherService; + prisma: PrismaMock; + lockClient: LockClientMock; + registry: RegistryMock; +} + +function eventRow(overrides: Partial = {}): ClaimedOutboxEvent { + return { + id: '01JEVENT000000000000000001', + aggregate_id: '01JTASK0000000000000000001', + event_type: 'media.task.created', + payload_json: { type: 'demo', itemIds: ['01JITEM000000000000000001'] }, + attempts: 1, + ...overrides, + }; +} + +async function createHarness(rows: ClaimedOutboxEvent[]): Promise { + const lockClient: LockClientMock = { + set: vi.fn().mockResolvedValue('OK'), + eval: vi.fn().mockResolvedValue(1), + }; + const tx: TransactionMock = { + $queryRaw: vi.fn().mockResolvedValue(rows), + $executeRaw: vi.fn().mockResolvedValue(rows.length), + }; + const prisma: PrismaMock = { + $transaction: vi.fn(async (callback: (client: TransactionMock) => Promise) => + callback(tx), + ), + $executeRaw: vi.fn().mockResolvedValue(1), + tx, + }; + const registry: RegistryMock = { enqueue: vi.fn().mockResolvedValue({ id: 'job-1' }) }; + + const moduleRef = await Test.createTestingModule({ + providers: [ + OutboxDispatcherService, + { provide: PRISMA, useValue: prisma }, + { provide: REDIS, useValue: { getClient: () => lockClient } }, + { provide: QueueRegistryService, useValue: registry }, + ], + }).compile(); + + return { + dispatcher: moduleRef.get(OutboxDispatcherService), + prisma, + lockClient, + registry, + }; +} + +function sqlTextOf(call: unknown): string { + return JSON.stringify(call); +} + +describe('OutboxDispatcherService', () => { + it('Redis 锁被其他实例持有时整轮跳过', async () => { + const harness = await createHarness([eventRow()]); + harness.lockClient.set.mockResolvedValue(null); + + const result = await harness.dispatcher.dispatchOnce(); + + expect(result).toEqual({ claimed: 0, failed: 0 }); + expect(harness.prisma.$transaction).not.toHaveBeenCalled(); + expect(harness.registry.enqueue).not.toHaveBeenCalled(); + }); + + it('created 事件按 itemIds 拆 job,jobId 为「事件:任务项」且重试参数来自 TASK_QUEUES', async () => { + const row = eventRow({ + payload_json: { type: 'demo', itemIds: ['ITEM-A', 'ITEM-B'] }, + }); + const harness = await createHarness([row]); + const queue = queueOfTaskType('demo'); + const definition = queueDefinitionOf(queue); + + const result = await harness.dispatcher.dispatchOnce(); + + expect(result).toEqual({ claimed: 1, failed: 0 }); + expect(harness.registry.enqueue).toHaveBeenCalledTimes(2); + expect(harness.registry.enqueue).toHaveBeenNthCalledWith( + 1, + queue, + 'task:demo', + { taskId: row.aggregate_id, itemId: 'ITEM-A' }, + expect.objectContaining({ + jobId: `${row.id}-ITEM-A`, + attempts: definition.attempts, + backoff: { type: 'fixed', delay: definition.backoffDelayMs }, + }), + ); + expect(harness.registry.enqueue).toHaveBeenNthCalledWith( + 2, + queue, + 'task:demo', + { taskId: row.aggregate_id, itemId: 'ITEM-B' }, + expect.objectContaining({ jobId: `${row.id}-ITEM-B` }), + ); + + const publishSql = harness.prisma.$executeRaw.mock.calls.map((call) => sqlTextOf(call)); + expect(publishSql.some((text) => text.includes('published_at'))).toBe(true); + }); + + it('本地事件(media.task.finished)即使被选中也不入队,直接置为已投递', async () => { + const harness = await createHarness([ + eventRow({ event_type: 'media.task.finished', payload_json: { taskId: 'T1' } }), + ]); + + const result = await harness.dispatcher.dispatchOnce(); + + expect(result).toEqual({ claimed: 1, failed: 0 }); + expect(harness.registry.enqueue).not.toHaveBeenCalled(); + const publishSql = harness.prisma.$executeRaw.mock.calls.map((call) => sqlTextOf(call)); + expect(publishSql.some((text) => text.includes('published_at'))).toBe(true); + }); + + it('投递失败时只记录 last_error,等待下一轮退避重试', async () => { + const harness = await createHarness([eventRow({ attempts: 2 })]); + harness.registry.enqueue.mockRejectedValue(new Error('Redis 连接失败')); + + const result = await harness.dispatcher.dispatchOnce(); + + expect(result).toEqual({ claimed: 1, failed: 1 }); + const sqls = harness.prisma.$executeRaw.mock.calls.map((call) => sqlTextOf(call)); + expect(sqls.some((text) => text.includes('last_error'))).toBe(true); + expect(sqls.some((text) => text.includes('dead_lettered'))).toBe(false); + }); + + it('attempts 达到死信阈值后置 published_at 并标记 dead_lettered', async () => { + const harness = await createHarness([eventRow({ attempts: 10 })]); + harness.registry.enqueue.mockRejectedValue(new Error('队列不可用')); + + const result = await harness.dispatcher.dispatchOnce(); + + expect(result).toEqual({ claimed: 1, failed: 1 }); + const sqls = harness.prisma.$executeRaw.mock.calls.map((call) => sqlTextOf(call)); + const deadLetter = sqls.find((text) => text.includes('dead_lettered')); + expect(deadLetter).toBeDefined(); + expect(deadLetter).toContain('published_at'); + }); + + it('认领时 attempts+1,投递成功后清空 last_error', async () => { + const harness = await createHarness([eventRow({ attempts: 1 })]); + + await harness.dispatcher.dispatchOnce(); + + const claimSqls = harness.prisma.tx.$executeRaw.mock.calls.map((call) => sqlTextOf(call)); + expect(claimSqls.some((text) => text.includes('attempts = attempts + 1'))).toBe(true); + expect(harness.lockClient.eval).toHaveBeenCalled(); + }); +}); diff --git a/apps/api/src/infra/queue/outbox-payload.spec.ts b/apps/api/src/infra/queue/outbox-payload.spec.ts new file mode 100644 index 0000000..6f5c61d --- /dev/null +++ b/apps/api/src/infra/queue/outbox-payload.spec.ts @@ -0,0 +1,103 @@ +import { describe, expect, it } from 'vitest'; + +import type { ClaimedOutboxEvent } from './outbox-payload'; +import { + isDispatchableOutboxEvent, + isLocalEventType, + isOutboxDeadLetter, + isQueueEventType, + jobNameOfTaskType, + outboxBackoffMs, + planOutboxDelivery, + queueDefinitionOf, +} from './outbox-payload'; + +function event(overrides: Partial = {}): ClaimedOutboxEvent { + return { + id: '01JEVENT000000000000000001', + aggregate_id: '01JTASK0000000000000000001', + event_type: 'media.task.created', + payload_json: { type: 'demo', itemIds: ['01JITEM000000000000000001'] }, + attempts: 1, + ...overrides, + }; +} + +describe('outbox 事件 → job 计划(纯函数)', () => { + it('demo 任务映射到 verify 组 media:verify 队列、job 名 task:demo', () => { + expect(jobNameOfTaskType('demo')).toBe('task:demo'); + expect(jobNameOfTaskType('image')).toBe('image:run'); + expect(queueDefinitionOf('media:verify').group).toBe('verify'); + }); + + it('created 事件按 itemIds 拆 job,jobId 使用「事件:任务项」保证重复投递不重复处理', () => { + const plan = planOutboxDelivery( + event({ payload_json: { type: 'demo', itemIds: ['ITEM-A', 'ITEM-B'] } }), + ); + expect(plan).not.toBeNull(); + expect(plan?.taskId).toBe('01JTASK0000000000000000001'); + expect(plan?.jobs).toEqual([ + { + queue: 'media:verify', + jobName: 'task:demo', + jobId: '01JEVENT000000000000000001-ITEM-A', + data: { taskId: '01JTASK0000000000000000001', itemId: 'ITEM-A' }, + }, + { + queue: 'media:verify', + jobName: 'task:demo', + jobId: '01JEVENT000000000000000001-ITEM-B', + data: { taskId: '01JTASK0000000000000000001', itemId: 'ITEM-B' }, + }, + ]); + }); + + it('retry 事件同样投递;重试事件 id 不同 → jobId 不同,可重新入队', () => { + const plan = planOutboxDelivery( + event({ id: '01JRETRY000000000000000001', event_type: 'media.task.retry' }), + ); + expect(plan?.jobs[0]?.jobId).toBe('01JRETRY000000000000000001-01JITEM000000000000000001'); + }); + + it('canceled / finished / failed 事件不入队(取消靠 DB 状态感知,终态由 media 消费者处理)', () => { + expect(planOutboxDelivery(event({ event_type: 'media.task.canceled' }))).toBeNull(); + expect(planOutboxDelivery(event({ event_type: 'media.task.finished' }))).toBeNull(); + expect(planOutboxDelivery(event({ event_type: 'media.task.failed' }))).toBeNull(); + expect(planOutboxDelivery(event({ event_type: 'script.generated' }))).toBeNull(); + }); + + it('payload 非法时抛中文错误,交由 dispatcher 记录 last_error 并退避重试', () => { + expect(() => + planOutboxDelivery(event({ payload_json: { type: 'unknown-type', itemIds: [] } })), + ).toThrow('Outbox 任务事件 type 非法'); + expect(() => planOutboxDelivery(event({ payload_json: { type: 'demo' } }))).toThrow( + 'Outbox 任务事件缺少 itemIds 数组', + ); + expect(() => planOutboxDelivery(event({ payload_json: null }))).toThrow( + 'Outbox 任务事件 payload 不是对象', + ); + }); + + it('事件类型判定与退避 / 死信阈值符合协议', () => { + expect(isQueueEventType('media.task.created')).toBe(true); + expect(isQueueEventType('media.task.retry')).toBe(true); + expect(isQueueEventType('media.task.finished')).toBe(false); + expect(isLocalEventType('media.task.finished')).toBe(true); + expect(isLocalEventType('media.task.created')).toBe(false); + + // dispatcher 只认领 created / retry / canceled,绝不认领 finished / failed + expect(isDispatchableOutboxEvent('media.task.created')).toBe(true); + expect(isDispatchableOutboxEvent('media.task.retry')).toBe(true); + expect(isDispatchableOutboxEvent('media.task.canceled')).toBe(true); + expect(isDispatchableOutboxEvent('media.task.finished')).toBe(false); + expect(isDispatchableOutboxEvent('media.task.failed')).toBe(false); + + expect(outboxBackoffMs(1)).toBe(1_000); + expect(outboxBackoffMs(2)).toBe(2_000); + expect(outboxBackoffMs(7)).toBe(60_000); + expect(outboxBackoffMs(10)).toBe(60_000); + + expect(isOutboxDeadLetter(9)).toBe(false); + expect(isOutboxDeadLetter(10)).toBe(true); + }); +}); diff --git a/apps/api/src/infra/queue/outbox-payload.ts b/apps/api/src/infra/queue/outbox-payload.ts new file mode 100644 index 0000000..e7f1c05 --- /dev/null +++ b/apps/api/src/infra/queue/outbox-payload.ts @@ -0,0 +1,172 @@ +import { + queueOfTaskType, + TASK_JOB_NAMES, + TASK_LOCAL_EVENT_TYPES, + TASK_OUTBOX_EVENTS, + TASK_QUEUE_EVENT_TYPES, + TASK_QUEUES, + TASK_TYPE_QUEUE, + type TaskQueueGroup, + type TaskQueueName, + type TaskType, +} from '@lingxi/shared'; + +import { OUTBOX_MAX_ATTEMPTS } from './queue.constants'; + +/** TASK_QUEUES 的元素类型(重试 / 退避 / 并发上限的唯一事实源) */ +export type TaskQueueDefinition = (typeof TASK_QUEUES)[number]; + +/** dispatcher 认领到的一条 outbox 事件(attempts 已在本轮认领时 +1) */ +export interface ClaimedOutboxEvent { + id: string; + aggregate_id: string; + event_type: string; + payload_json: unknown; + attempts: number; +} + +/** BullMQ job data 契约:与 worker 侧 `TaskJobData` 保持一致 */ +export interface TaskJobData { + taskId: string; + itemId: string; +} + +export interface PlannedTaskJob { + queue: TaskQueueName; + jobName: string; + /** + * `${eventId}-${itemId}`:重复投递同一事件不会产生重复 job。 + * 注意 BullMQ 5.x 不允许自定义 jobId 含冒号(`Custom Id cannot contain :`), + * 因此用 ULID 之间不冲突的 `-` 分隔。 + */ + jobId: string; + data: TaskJobData; +} + +export interface OutboxDeliveryPlan { + eventId: string; + taskId: string; + jobs: PlannedTaskJob[]; +} + +/** + * 任务类型 → job 名。M0 只有演练任务有显式映射(demo → task:demo), + * 其余类型按 `:run` 占位,后续批次接入真实处理器时再补充映射。 + */ +const JOB_NAME_MAP: ReadonlyMap = new Map(Object.entries(TASK_JOB_NAMES)); + +export function jobNameOfTaskType(taskType: TaskType): string { + return JOB_NAME_MAP.get(taskType) ?? `${taskType}:run`; +} + +export function isTaskType(value: string): value is TaskType { + return Object.hasOwn(TASK_TYPE_QUEUE, value); +} + +export function isQueueEventType(eventType: string): boolean { + return TASK_QUEUE_EVENT_TYPES.some((type) => type === eventType); +} + +/** + * dispatcher 认领的事件类型: + * - created / retry → 拆任务项入队; + * - canceled → 不产生 job,直接置 published_at(worker 通过 media_tasks 状态感知取消)。 + * 注意:finished / failed 归 media 模块本地消费者,dispatcher **绝不认领**(认领会抢事件导致审计丢失)。 + */ +export const DISPATCHABLE_OUTBOX_EVENT_TYPES: readonly string[] = [ + ...TASK_QUEUE_EVENT_TYPES, + TASK_OUTBOX_EVENTS.canceled, +]; + +export function isDispatchableOutboxEvent(eventType: string): boolean { + return DISPATCHABLE_OUTBOX_EVENT_TYPES.some((type) => type === eventType); +} + +export function isLocalEventType(eventType: string): boolean { + return TASK_LOCAL_EVENT_TYPES.some((type) => type === eventType); +} + +/** 取队列定义(并发 / 重试次数 / 退避);未登记属编码错误,直接抛出 */ +export function queueDefinitionOf(name: TaskQueueName): TaskQueueDefinition { + const definition = TASK_QUEUES.find((item) => item.name === name); + if (definition === undefined) { + throw new Error(`未登记的队列:${name}`); + } + return definition; +} + +export function queueGroupOfName(name: TaskQueueName): TaskQueueGroup { + return queueDefinitionOf(name).group; +} + +/** 失败退避:min(2^attempts * 500ms, 60s),与认领 SQL 中的表达式保持一致 */ +export function outboxBackoffMs(attempts: number): number { + return Math.min(2 ** attempts * 500, 60_000); +} + +/** 是否已达死信阈值(attempts 为本轮认领后的累计尝试次数) */ +export function isOutboxDeadLetter(attempts: number): boolean { + return attempts >= OUTBOX_MAX_ATTEMPTS; +} + +function isRecord(value: unknown): value is Record { + return typeof value === 'object' && value !== null && !Array.isArray(value); +} + +interface ParsedTaskPayload { + taskType: TaskType; + itemIds: string[]; +} + +/** 解析 outbox payload `{ type, itemIds, ... }`;结构非法抛中文错误,交由 dispatcher 记账重试 */ +export function parseOutboxTaskPayload(payload: unknown): ParsedTaskPayload { + if (!isRecord(payload)) { + throw new Error('Outbox 任务事件 payload 不是对象'); + } + const type = payload.type; + if (typeof type !== 'string' || !isTaskType(type)) { + throw new Error(`Outbox 任务事件 type 非法:${String(type)}`); + } + const itemIdsRaw = payload.itemIds; + if (!Array.isArray(itemIdsRaw)) { + throw new Error('Outbox 任务事件缺少 itemIds 数组'); + } + const itemIds = itemIdsRaw.map((itemId: unknown) => { + if (typeof itemId !== 'string' || itemId.length === 0) { + throw new Error('Outbox 任务事件 itemIds 含非法值'); + } + return itemId; + }); + return { taskType: type, itemIds }; +} + +/** + * 事件 → 待投递 job 计划(纯函数)。 + * - 只处理 TASK_QUEUE_EVENT_TYPES(media.task.created / media.task.retry); + * - `media.task.finished` / `media.task.failed` 由 media 模块本地消费者处理,**绝不入队**; + * - `media.task.canceled` 由 worker 通过 DB 状态感知,直接视为无需投递(返回 null 由调用方置 published)。 + */ +export function planOutboxDelivery(event: ClaimedOutboxEvent): OutboxDeliveryPlan | null { + if (event.event_type === TASK_OUTBOX_EVENTS.canceled) { + return null; + } + if (isLocalEventType(event.event_type) || !isQueueEventType(event.event_type)) { + return null; + } + + const payload = parseOutboxTaskPayload(event.payload_json); + const queue = queueOfTaskType(payload.taskType); + const jobName = jobNameOfTaskType(payload.taskType); + const taskId = event.aggregate_id; + + return { + eventId: event.id, + taskId, + jobs: payload.itemIds.map((itemId) => ({ + queue, + jobName, + jobId: `${event.id}-${itemId}`, + data: { taskId, itemId }, + })), + }; +} diff --git a/apps/api/src/infra/queue/outbox.int.spec.ts b/apps/api/src/infra/queue/outbox.int.spec.ts new file mode 100644 index 0000000..2935f24 --- /dev/null +++ b/apps/api/src/infra/queue/outbox.int.spec.ts @@ -0,0 +1,279 @@ +import { type TaskQueueName, ulid } from '@lingxi/shared'; +import { Prisma } from '@prisma/client'; +import { afterAll, afterEach, beforeAll, describe, expect, it } from 'vitest'; + +import type { AppConfig } from '../../config/env'; +import { loadEnv } from '../../config/env'; +import { loadDotenv } from '../../config/load-dotenv'; +import { PrismaService } from '../prisma/prisma.service'; +import { RedisService } from '../redis/redis.service'; +import { OutboxDispatcherService } from './outbox-dispatcher.service'; +import { queueDefinitionOf } from './outbox-payload'; +import { QueueRegistryService } from './queue-registry.service'; + +/** + * Outbox dispatcher 集成用例(真实 PG + Redis)。 + * + * 覆盖 A6-BE-05 验收:created/retry 事件投递为 BullMQ job、published_at 置位、 + * 重复运行不重复入队(jobId 幂等)、canceled 直接置已投递不入队、 + * finished/failed 不被认领、失败退避与 attempts 达阈值后死信。 + * + * 夹具约定:outbox id 统一 `ITQU` 前缀,afterEach 清理 outbox 行与对应 BullMQ job; + * 不写入 media_tasks / media_items(dispatcher 只认领 outbox,不依赖任务存在)。 + */ +const FIXTURE_PREFIX = 'ITQU'; +const QUEUE: TaskQueueName = 'media:verify'; +const ALL_JOB_STATES = ['waiting', 'active', 'delayed', 'paused', 'completed', 'failed'] as const; + +interface OutboxSnapshot { + id: string; + event_type: string; + attempts: number; + last_error: string | null; + published_at: Date | null; +} + +interface TaskPayload { + type: string; + itemIds: string[]; +} + +let config: AppConfig; +let prisma: PrismaService; +let redis: RedisService; +let registry: QueueRegistryService; +let dispatcher: OutboxDispatcherService; + +function fixtureId(): string { + return `${FIXTURE_PREFIX}${ulid().slice(FIXTURE_PREFIX.length)}`; +} + +async function insertOutbox( + id: string, + eventType: string, + payload: TaskPayload, + options: { ageSeconds?: number; attempts?: number } = {}, +): Promise { + const ageSeconds = options.ageSeconds ?? 31_536_000; + const attempts = options.attempts ?? 0; + await prisma.$executeRaw(Prisma.sql` + INSERT INTO outbox_events + (id, aggregate_type, aggregate_id, event_type, payload_json, occurred_at, attempts) + VALUES + (${id}, 'media_task', ${id}, ${eventType}, ${JSON.stringify(payload)}::jsonb, + now() - interval '1 second' * ${ageSeconds}, ${attempts}) + `); +} + +async function readOutbox(id: string): Promise { + const rows = await prisma.$queryRaw(Prisma.sql` + SELECT id, event_type, attempts, last_error, published_at + FROM outbox_events WHERE id = ${id} + `); + return rows[0] ?? null; +} + +async function fixtureJobIds(eventId: string): Promise { + const jobs = await registry.getQueue(QUEUE).getJobs([...ALL_JOB_STATES], 0, 1000); + return jobs.map((job) => job.id).filter((id): id is string => id?.startsWith(eventId) === true); +} + +function sleep(ms: number): Promise { + return new Promise((resolve) => { + setTimeout(resolve, ms); + }); +} + +/** + * 驱动 dispatcher 直到条件满足。 + * 本机可能同时运行着真实 API(带 dispatcher):会与本用例竞争 Redis 锁或抢先投递, + * 因此不假设「单次 dispatchOnce 一定认领到」,而是轮询到结果收敛(CI 干净环境下第一轮即满足)。 + */ +async function dispatchUntil( + check: () => Promise, + description: string, + timeoutMs = 10_000, +): Promise { + const deadline = Date.now() + timeoutMs; + while (Date.now() < deadline) { + await dispatcher.dispatchOnce(); + if (await check()) { + return; + } + await sleep(200); + } + throw new Error(`等待 dispatcher ${description} 超时(${String(timeoutMs)}ms)`); +} + +async function cleanup(): Promise { + const queue = registry.getQueue(QUEUE); + const jobs = await queue.getJobs([...ALL_JOB_STATES], 0, 1000); + await Promise.all( + jobs + .filter((job) => job.id?.startsWith(FIXTURE_PREFIX) === true) + .map(async (job) => { + await job.remove().catch(() => undefined); + }), + ); + await prisma.$executeRaw( + Prisma.sql`DELETE FROM outbox_events WHERE id LIKE ${`${FIXTURE_PREFIX}%`}`, + ); +} + +beforeAll(async () => { + loadDotenv(); + config = loadEnv(); + prisma = new PrismaService(config); + redis = new RedisService(config); + registry = new QueueRegistryService(config); + dispatcher = new OutboxDispatcherService(prisma, redis, registry); + await redis.ping(); +}); + +afterEach(async () => { + await cleanup(); +}); + +afterAll(async () => { + await registry.onModuleDestroy(); + redis.onModuleDestroy(); + await prisma.$disconnect(); +}); + +describe('Outbox dispatcher(真实 PG + Redis)', () => { + it('media.task.created → 每项一个 job(jobId=事件:任务项),published_at 置位', async () => { + const eventId = fixtureId(); + const itemA = fixtureId(); + const itemB = fixtureId(); + await insertOutbox(eventId, 'media.task.created', { type: 'demo', itemIds: [itemA, itemB] }); + + await dispatchUntil( + async () => (await readOutbox(eventId))?.published_at !== null, + '投递 created 事件', + ); + + const row = await readOutbox(eventId); + expect(row?.published_at).not.toBeNull(); + expect(row?.last_error).toBeNull(); + + const queue = registry.getQueue(QUEUE); + const definition = queueDefinitionOf(QUEUE); + const job = await queue.getJob(`${eventId}-${itemA}`); + expect(job).not.toBeNull(); + expect(job?.data).toEqual({ taskId: eventId, itemId: itemA }); + expect(job?.opts.attempts).toBe(definition.attempts); + expect(job?.opts.backoff).toEqual({ + type: 'fixed', + delay: definition.backoffDelayMs, + }); + expect(await fixtureJobIds(eventId)).toHaveLength(2); + }); + + it('media.task.retry → 按新事件 id 重新入队(可重试失败项)', async () => { + const eventId = fixtureId(); + const itemA = fixtureId(); + await insertOutbox(eventId, 'media.task.retry', { type: 'demo', itemIds: [itemA] }); + + await dispatchUntil( + async () => (await readOutbox(eventId))?.published_at !== null, + '投递 retry 事件', + ); + + expect((await readOutbox(eventId))?.published_at).not.toBeNull(); + expect(await fixtureJobIds(eventId)).toEqual([`${eventId}-${itemA}`]); + }); + + it('重复运行不重复入队:把事件重置为未投递后再跑一轮,jobId 幂等', async () => { + const eventId = fixtureId(); + const itemA = fixtureId(); + const itemB = fixtureId(); + await insertOutbox(eventId, 'media.task.created', { type: 'demo', itemIds: [itemA, itemB] }); + + await dispatchUntil( + async () => (await readOutbox(eventId))?.published_at !== null, + '首次投递 created 事件', + ); + expect(await fixtureJobIds(eventId)).toHaveLength(2); + + // 模拟至少一次投递的重复认领:事件回到未投递状态,再投递一轮 + await prisma.$executeRaw(Prisma.sql` + UPDATE outbox_events SET published_at = NULL, attempts = 0 WHERE id = ${eventId} + `); + await dispatchUntil( + async () => (await readOutbox(eventId))?.published_at !== null, + '重复投递 created 事件', + ); + + expect(await fixtureJobIds(eventId)).toHaveLength(2); + expect((await readOutbox(eventId))?.published_at).not.toBeNull(); + }); + + it('media.task.canceled → 直接置已投递、不产生 job(worker 靠 DB 状态感知取消)', async () => { + const eventId = fixtureId(); + await insertOutbox(eventId, 'media.task.canceled', { type: 'demo', itemIds: [fixtureId()] }); + + await dispatchUntil( + async () => (await readOutbox(eventId))?.published_at !== null, + '投递 canceled 事件', + ); + + expect((await readOutbox(eventId))?.published_at).not.toBeNull(); + expect(await fixtureJobIds(eventId)).toHaveLength(0); + }); + + it('media.task.finished / failed 不被认领(留给 media 模块本地消费者写审计)', async () => { + const finishedId = fixtureId(); + const failedId = fixtureId(); + const payload: TaskPayload = { type: 'demo', itemIds: [fixtureId()] }; + await insertOutbox(finishedId, 'media.task.finished', payload); + await insertOutbox(failedId, 'media.task.failed', payload); + + // finished / failed 不在 dispatcher 认领集合内:跑几轮也不应被投递 + for (let round = 0; round < 3; round += 1) { + await dispatcher.dispatchOnce(); + await sleep(200); + } + + // 本机可能同时运行着真实 API:media 模块的结果消费者会认领 finished / failed 并置 published_at, + // 因此这里只断言「dispatcher 不会把这两类事件变成队列 job」,published 归属不由本用例独占。 + const finished = await readOutbox(finishedId); + const failed = await readOutbox(failedId); + expect(finished?.last_error).toBeNull(); + expect(failed?.last_error).toBeNull(); + expect(await fixtureJobIds(finishedId)).toHaveLength(0); + expect(await fixtureJobIds(failedId)).toHaveLength(0); + }); + + it('payload 非法 → 记 last_error 等待退避,attempts 达阈值后标记 dead_lettered', async () => { + const brokenId = fixtureId(); + await insertOutbox(brokenId, 'media.task.created', { type: 'unknown-type', itemIds: [] }); + + await dispatchUntil( + async () => (await readOutbox(brokenId))?.last_error !== null, + '记录 payload 非法事件的 last_error', + ); + + const broken = await readOutbox(brokenId); + expect(broken?.published_at).toBeNull(); + expect(broken?.last_error).toContain('type 非法'); + + // 模拟长期投递失败:attempts=9 且事件足够旧 → 下一轮认领后 attempts=10 触发死信 + const deadLetterId = fixtureId(); + await insertOutbox( + deadLetterId, + 'media.task.created', + { type: 'unknown-type', itemIds: [] }, + { ageSeconds: 120, attempts: 9 }, + ); + await dispatchUntil( + async () => (await readOutbox(deadLetterId))?.last_error === 'dead_lettered', + '标记死信', + ); + + const deadLetter = await readOutbox(deadLetterId); + expect(deadLetter?.published_at).not.toBeNull(); + expect(deadLetter?.last_error).toBe('dead_lettered'); + expect(deadLetter?.attempts).toBe(10); + expect(await fixtureJobIds(deadLetterId)).toHaveLength(0); + }); +}); diff --git a/apps/api/src/infra/queue/prometheus.spec.ts b/apps/api/src/infra/queue/prometheus.spec.ts new file mode 100644 index 0000000..1e46018 --- /dev/null +++ b/apps/api/src/infra/queue/prometheus.spec.ts @@ -0,0 +1,53 @@ +import { describe, expect, it } from 'vitest'; + +import type { QueueMetricsInput } from './prometheus'; +import { failureRatio, renderPrometheusMetrics } from './prometheus'; + +function queueMetrics(overrides: Partial = {}): QueueMetricsInput { + return { + name: 'media:verify', + counts: { waiting: 2, active: 1, completed: 8, failed: 2, delayed: 0 }, + oldestWaitingSeconds: 42, + heartbeatAgeSeconds: 5, + ...overrides, + }; +} + +describe('Prometheus 指标渲染(纯函数)', () => { + it('失败率 = failed/(failed+completed),无样本为 0', () => { + expect(failureRatio({ waiting: 0, active: 0, completed: 0, failed: 0, delayed: 0 })).toBe(0); + expect(failureRatio({ waiting: 0, active: 0, completed: 9, failed: 1, delayed: 0 })).toBe(0.1); + expect(failureRatio({ waiting: 0, active: 0, completed: 3, failed: 1, delayed: 0 })).toBe(0.25); + }); + + it('输出全部 A6-INF-01 指标,心跳缺失输出 -1', () => { + const text = renderPrometheusMetrics({ + queues: [queueMetrics(), queueMetrics({ name: 'image:render', heartbeatAgeSeconds: null })], + outbox: { pending: 4, deadLetter: 1 }, + comments: ['队列 media:verify 指标查询失败:测试'], + }); + + expect(text).toContain('# 队列 media:verify 指标查询失败:测试'); + expect(text).toContain('# TYPE lingxi_queue_jobs gauge'); + expect(text).toContain('lingxi_queue_jobs{queue="media:verify",state="waiting"} 2'); + expect(text).toContain('lingxi_queue_jobs{queue="image:render",state="failed"} 2'); + expect(text).toContain('lingxi_queue_oldest_waiting_seconds{queue="media:verify"} 42'); + expect(text).toContain('lingxi_queue_failure_ratio{queue="media:verify"} 0.2'); + expect(text).toContain('lingxi_worker_heartbeat_age_seconds{queue="media:verify"} 5'); + expect(text).toContain('lingxi_worker_heartbeat_age_seconds{queue="image:render"} -1'); + expect(text).toContain('lingxi_outbox_pending_total 4'); + expect(text).toContain('lingxi_outbox_dead_letter_total 1'); + expect(text.endsWith('\n')).toBe(true); + }); + + it('outbox 采集失败时不输出 outbox 指标,但队列指标仍可用', () => { + const text = renderPrometheusMetrics({ + queues: [queueMetrics()], + outbox: null, + comments: ['Outbox 指标查询失败:数据库不可用'], + }); + expect(text).toContain('lingxi_queue_jobs{queue="media:verify",state="active"} 1'); + expect(text).not.toContain('lingxi_outbox_pending_total'); + expect(text).not.toContain('lingxi_outbox_dead_letter_total'); + }); +}); diff --git a/apps/api/src/infra/queue/prometheus.ts b/apps/api/src/infra/queue/prometheus.ts new file mode 100644 index 0000000..fb5100f --- /dev/null +++ b/apps/api/src/infra/queue/prometheus.ts @@ -0,0 +1,97 @@ +import type { TaskQueueName } from '@lingxi/shared'; + +import type { QueueCountsSnapshot } from './queue-health'; + +/** 单个队列的指标快照(已换算成 Prometheus 需要的秒 / 比率口径) */ +export interface QueueMetricsInput { + name: TaskQueueName; + counts: QueueCountsSnapshot; + oldestWaitingSeconds: number; + heartbeatAgeSeconds: number | null; +} + +export interface OutboxMetricsInput { + pending: number; + deadLetter: number; +} + +export interface MetricsRenderInput { + queues: readonly QueueMetricsInput[]; + outbox: OutboxMetricsInput | null; + /** 采集失败原因等注释行(以 # 开头,Prometheus 会忽略) */ + comments: readonly string[]; +} + +const JOB_STATES = ['waiting', 'active', 'completed', 'failed', 'delayed'] as const; + +function round(value: number, digits: number): number { + const factor = 10 ** digits; + return Math.round(value * factor) / factor; +} + +/** 失败率 = failed / (failed + completed);无样本时为 0 */ +export function failureRatio(counts: QueueCountsSnapshot): number { + const denominator = counts.failed + counts.completed; + if (denominator === 0) { + return 0; + } + return round(counts.failed / denominator, 4); +} + +function toCommentLine(comment: string): string { + return `# ${comment.replaceAll('\n', ' ')}`; +} + +/** + * 渲染 Prometheus 文本格式(version 0.0.4)。 + * 指标口径见 A6-INF-01:队列长度 / 等待时长 / 失败率 / worker 心跳 / outbox 待投递与死信。 + */ +export function renderPrometheusMetrics(input: MetricsRenderInput): string { + const lines: string[] = []; + for (const comment of input.comments) { + lines.push(toCommentLine(comment)); + } + + lines.push('# HELP lingxi_queue_jobs 队列内各状态的任务数'); + lines.push('# TYPE lingxi_queue_jobs gauge'); + for (const queue of input.queues) { + for (const state of JOB_STATES) { + lines.push( + `lingxi_queue_jobs{queue="${queue.name}",state="${state}"} ${queue.counts[state]}`, + ); + } + } + + lines.push('# HELP lingxi_queue_oldest_waiting_seconds 队首任务已等待秒数(无等待任务为 0)'); + lines.push('# TYPE lingxi_queue_oldest_waiting_seconds gauge'); + for (const queue of input.queues) { + lines.push( + `lingxi_queue_oldest_waiting_seconds{queue="${queue.name}"} ${queue.oldestWaitingSeconds}`, + ); + } + + lines.push('# HELP lingxi_queue_failure_ratio 队列失败率 failed/(failed+completed),无样本为 0'); + lines.push('# TYPE lingxi_queue_failure_ratio gauge'); + for (const queue of input.queues) { + lines.push(`lingxi_queue_failure_ratio{queue="${queue.name}"} ${failureRatio(queue.counts)}`); + } + + lines.push('# HELP lingxi_worker_heartbeat_age_seconds worker 心跳年龄(秒),缺失为 -1'); + lines.push('# TYPE lingxi_worker_heartbeat_age_seconds gauge'); + for (const queue of input.queues) { + lines.push( + `lingxi_worker_heartbeat_age_seconds{queue="${queue.name}"} ${queue.heartbeatAgeSeconds ?? -1}`, + ); + } + + if (input.outbox !== null) { + lines.push('# HELP lingxi_outbox_pending_total 未投递的 outbox 事件数'); + lines.push('# TYPE lingxi_outbox_pending_total gauge'); + lines.push(`lingxi_outbox_pending_total ${input.outbox.pending}`); + lines.push('# HELP lingxi_outbox_dead_letter_total 已标记死信的 outbox 事件数(累计)'); + lines.push('# TYPE lingxi_outbox_dead_letter_total gauge'); + lines.push(`lingxi_outbox_dead_letter_total ${input.outbox.deadLetter}`); + } + + return `${lines.join('\n')}\n`; +} diff --git a/apps/api/src/infra/queue/queue-endpoints.int.spec.ts b/apps/api/src/infra/queue/queue-endpoints.int.spec.ts new file mode 100644 index 0000000..3fd60c1 --- /dev/null +++ b/apps/api/src/infra/queue/queue-endpoints.int.spec.ts @@ -0,0 +1,122 @@ +import Redis from 'ioredis'; +import { afterAll, beforeAll, describe, expect, it } from 'vitest'; + +import { createApp, type CreatedApiApp } from '../../bootstrap'; +import { loadDotenv } from '../../config/load-dotenv'; +import { workerHeartbeatKey } from './queue-health'; + +/** + * 队列监控端点集成用例(真实 PG + Redis,进程内 Fastify inject)。 + * 覆盖:GET /api/v1/health/queues(9 队列快照 + worker 心跳)、GET /api/v1/metrics(Prometheus 文本)。 + */ +const API = '/api/v1'; + +interface QueueHealthEntryBody { + name: string; + group: string; + counts: { waiting: number; active: number; completed: number; failed: number; delayed: number }; + oldestWaitingSeconds: number; + workerHeartbeat: { present: boolean; ageSeconds: number | null }; +} + +interface QueueHealthBody { + status: string; + redis: string; + queues: QueueHealthEntryBody[]; +} + +let created: CreatedApiApp; +let redis: Redis; + +beforeAll(async () => { + loadDotenv(); + // 避免后台 dispatcher 在测试期间认领开发库的 outbox 事件 + process.env.OUTBOX_DISPATCH_INTERVAL_MS = '600000'; + created = await createApp(); + await created.app.init(); + redis = new Redis(created.config.redisUrl); +}); + +afterAll(async () => { + await redis.del(workerHeartbeatKey('media:verify')); + await redis.quit(); + await created.app.close(); + delete process.env.OUTBOX_DISPATCH_INTERVAL_MS; +}); + +describe('队列健康检查端点', () => { + it('GET /api/v1/health/queues 返回 9 个队列与心跳快照', async () => { + const response = await created.app.inject({ + method: 'GET', + url: `${API}/health/queues`, + }); + expect(response.statusCode).toBe(200); + + const body = response.json(); + expect(body.redis).toBe('ok'); + expect(['ok', 'degraded']).toContain(body.status); + expect(body.queues).toHaveLength(9); + + const verify = body.queues.find((queue) => queue.name === 'media:verify'); + expect(verify).toBeDefined(); + expect(verify?.group).toBe('verify'); + for (const state of ['waiting', 'active', 'completed', 'failed', 'delayed']) { + expect(typeof verify?.counts[state as keyof QueueHealthEntryBody['counts']]).toBe('number'); + } + expect(typeof verify?.oldestWaitingSeconds).toBe('number'); + expect(verify?.workerHeartbeat.present).toBe(false); + }); + + it('worker 写入心跳后健康检查可见 present + ageSeconds', async () => { + await redis.set( + workerHeartbeatKey('media:verify'), + JSON.stringify({ + workerId: 'it-worker', + pid: process.pid, + queue: 'media:verify', + updatedAt: new Date().toISOString(), + }), + 'EX', + 30, + ); + + const response = await created.app.inject({ + method: 'GET', + url: `${API}/health/queues`, + }); + const body = response.json(); + const verify = body.queues.find((queue) => queue.name === 'media:verify'); + expect(verify?.workerHeartbeat.present).toBe(true); + expect(verify?.workerHeartbeat.ageSeconds).toBeGreaterThanOrEqual(0); + expect(verify?.workerHeartbeat.ageSeconds).toBeLessThan(30); + }); +}); + +describe('Prometheus 指标端点', () => { + it('GET /api/v1/metrics 返回 text/plain 0.0.4 与全部队列指标', async () => { + const response = await created.app.inject({ method: 'GET', url: `${API}/metrics` }); + + expect(response.statusCode).toBe(200); + expect(response.headers['content-type']).toContain('text/plain'); + expect(response.headers['content-type']).toContain('version=0.0.4'); + + const text = response.body; + expect(text).toContain('# TYPE lingxi_queue_jobs gauge'); + expect(text).toContain('lingxi_queue_jobs{queue="media:verify",state="waiting"}'); + expect(text).toContain('lingxi_queue_oldest_waiting_seconds{queue="media:verify"}'); + expect(text).toContain('lingxi_queue_failure_ratio{queue="media:verify"}'); + expect(text).toContain('lingxi_worker_heartbeat_age_seconds{queue="media:verify"}'); + // 9 个队列 × 5 个 state + 每队列 3 个单值指标 + expect(text.match(/lingxi_queue_jobs\{/g)).toHaveLength(9 * 5); + expect(text).toContain('lingxi_outbox_pending_total'); + expect(text).toContain('lingxi_outbox_dead_letter_total'); + }); + + it('GET /api/v1/metrics 心跳缺失队列输出 -1', async () => { + await redis.del(workerHeartbeatKey('script:generate')); + const response = await created.app.inject({ method: 'GET', url: `${API}/metrics` }); + expect(response.body).toContain( + 'lingxi_worker_heartbeat_age_seconds{queue="script:generate"} -1', + ); + }); +}); diff --git a/apps/api/src/infra/queue/queue-health.controller.ts b/apps/api/src/infra/queue/queue-health.controller.ts new file mode 100644 index 0000000..ae723e0 --- /dev/null +++ b/apps/api/src/infra/queue/queue-health.controller.ts @@ -0,0 +1,102 @@ +import { ErrorCode, TASK_QUEUES } from '@lingxi/shared'; +import { Controller, Get, Inject, Logger } from '@nestjs/common'; +import { ApiOkResponse, ApiOperation, ApiResponse, ApiTags } from '@nestjs/swagger'; + +import { AppException } from '../../common/errors/app-exception'; +import { Public } from '../../common/security/decorators'; +import type { RedisService } from '../redis/redis.service'; +import { REDIS } from '../tokens'; +import type { TaskQueueDefinition } from './outbox-payload'; +import { errorMessage, toSingleLine } from './queue.utils'; +import { + buildQueueHealthEntry, + buildQueueHealthReport, + type QueueHealthEntry, + type QueueHealthReport, + workerHeartbeatKey, +} from './queue-health'; +import { QueueHealthReportDto } from './queue-health.types'; +import { QueueRegistryService } from './queue-registry.service'; + +/** + * 队列健康检查(A6-INF-01)。 + * - Redis 不可用 → 503 + 统一错误码 50002; + * - 有积压却没有 worker 心跳(或心跳陈旧)→ status=degraded,HTTP 仍 200; + * - 心跳 key:`lingxi:worker:heartbeat:`,由 apps/worker 每 10s 续写、TTL 30s。 + */ +@ApiTags('platform') +@Public() +@Controller('health') +export class QueueHealthController { + private readonly logger = new Logger(QueueHealthController.name); + + constructor( + @Inject(QueueRegistryService) private readonly registry: QueueRegistryService, + @Inject(REDIS) private readonly redis: RedisService, + ) {} + + @Get('queues') + @ApiOperation({ + summary: '队列健康检查', + description: + '返回 9 个队列的计数、队首等待时长与 worker 心跳;Redis 不可用时返回 503(错误码 50002)。', + }) + @ApiOkResponse({ type: QueueHealthReportDto, description: '队列状态快照(可能为 degraded)' }) + @ApiResponse({ status: 503, description: 'Redis 不可用(错误码 50002)' }) + async queues(): Promise { + await this.assertRedisAvailable(); + const nowMs = Date.now(); + const entries: QueueHealthEntry[] = []; + for (const definition of TASK_QUEUES) { + entries.push(await this.collectEntry(definition, nowMs)); + } + const report = buildQueueHealthReport(entries); + if (report.status === 'degraded') { + this.logger.warn('队列健康检查降级:存在积压且 worker 心跳缺失或陈旧'); + } + return report; + } + + private async assertRedisAvailable(): Promise { + try { + await this.redis.ping(); + } catch (error) { + this.logger.error(`队列健康检查失败:Redis 不可用(${toSingleLine(errorMessage(error))})`); + throw new AppException({ + code: ErrorCode.DEPENDENCY_UNAVAILABLE, + message: 'Redis 不可用', + details: { redis: toSingleLine(errorMessage(error)) }, + }); + } + } + + private async collectEntry( + definition: TaskQueueDefinition, + nowMs: number, + ): Promise { + try { + const [counts, oldestWaitingTimestampMs, heartbeatRaw] = await Promise.all([ + this.registry.getCounts(definition.name), + this.registry.getOldestWaitingTimestampMs(definition.name), + this.redis.getClient().get(workerHeartbeatKey(definition.name)), + ]); + return buildQueueHealthEntry({ + name: definition.name, + group: definition.group, + counts, + oldestWaitingTimestampMs, + heartbeatRaw, + nowMs, + }); + } catch (error) { + this.logger.error( + `队列 ${definition.name} 状态查询失败:${toSingleLine(errorMessage(error))}`, + ); + throw new AppException({ + code: ErrorCode.DEPENDENCY_UNAVAILABLE, + message: '队列状态查询失败', + details: { queue: definition.name, error: toSingleLine(errorMessage(error)) }, + }); + } + } +} diff --git a/apps/api/src/infra/queue/queue-health.spec.ts b/apps/api/src/infra/queue/queue-health.spec.ts new file mode 100644 index 0000000..3a67db0 --- /dev/null +++ b/apps/api/src/infra/queue/queue-health.spec.ts @@ -0,0 +1,86 @@ +import { describe, expect, it } from 'vitest'; + +import { + buildQueueHealthEntry, + buildQueueHealthReport, + parseHeartbeatAgeSeconds, + workerHeartbeatKey, +} from './queue-health'; + +describe('队列健康检查(纯函数)', () => { + it('心跳 key 统一为 lingxi:worker:heartbeat:', () => { + expect(workerHeartbeatKey('media:verify')).toBe('lingxi:worker:heartbeat:media:verify'); + }); + + it('心跳年龄解析:缺失 / 非法为 null,未来时间归零', () => { + const now = Date.parse('2026-11-04T12:00:00.000Z'); + expect(parseHeartbeatAgeSeconds(null, now)).toBeNull(); + expect(parseHeartbeatAgeSeconds('not-json', now)).toBeNull(); + expect(parseHeartbeatAgeSeconds('{"updatedAt":123}', now)).toBeNull(); + expect(parseHeartbeatAgeSeconds('{"updatedAt":"2026-11-04T11:59:50.000Z"}', now)).toBe(10); + expect(parseHeartbeatAgeSeconds('{"updatedAt":"2026-11-04T12:00:10.000Z"}', now)).toBe(0); + }); + + it('队首等待时长换算为秒,无等待任务为 0', () => { + const nowMs = Date.parse('2026-11-04T12:00:00.000Z'); + const counts = { waiting: 1, active: 0, completed: 0, failed: 0, delayed: 0 }; + const entry = buildQueueHealthEntry({ + name: 'media:verify', + group: 'verify', + counts, + oldestWaitingTimestampMs: nowMs - 12_400, + heartbeatRaw: '{"updatedAt":"2026-11-04T11:59:57.000Z"}', + nowMs, + }); + expect(entry.oldestWaitingSeconds).toBe(12); + expect(entry.workerHeartbeat).toEqual({ present: true, ageSeconds: 3 }); + expect( + buildQueueHealthEntry({ + name: 'media:verify', + group: 'verify', + counts, + oldestWaitingTimestampMs: null, + heartbeatRaw: null, + nowMs, + }).oldestWaitingSeconds, + ).toBe(0); + }); + + it('有积压且无心跳 → degraded;无积压无心跳 → ok', () => { + const empty = buildQueueHealthReport([]); + expect(empty).toEqual({ status: 'ok', redis: 'ok', queues: [] }); + + const backlogNoHeartbeat = buildQueueHealthReport([ + { + name: 'media:verify', + group: 'verify', + counts: { waiting: 3, active: 0, completed: 0, failed: 0, delayed: 0 }, + oldestWaitingSeconds: 600, + workerHeartbeat: { present: false, ageSeconds: null }, + }, + ]); + expect(backlogNoHeartbeat.status).toBe('degraded'); + + const backlogFreshHeartbeat = buildQueueHealthReport([ + { + name: 'media:verify', + group: 'verify', + counts: { waiting: 3, active: 0, completed: 0, failed: 0, delayed: 0 }, + oldestWaitingSeconds: 600, + workerHeartbeat: { present: true, ageSeconds: 5 }, + }, + ]); + expect(backlogFreshHeartbeat.status).toBe('ok'); + + const noBacklog = buildQueueHealthReport([ + { + name: 'media:verify', + group: 'verify', + counts: { waiting: 0, active: 0, completed: 0, failed: 0, delayed: 0 }, + oldestWaitingSeconds: 0, + workerHeartbeat: { present: false, ageSeconds: null }, + }, + ]); + expect(noBacklog.status).toBe('ok'); + }); +}); diff --git a/apps/api/src/infra/queue/queue-health.ts b/apps/api/src/infra/queue/queue-health.ts new file mode 100644 index 0000000..1ebe34c --- /dev/null +++ b/apps/api/src/infra/queue/queue-health.ts @@ -0,0 +1,119 @@ +import type { TaskQueueGroup, TaskQueueName } from '@lingxi/shared'; + +import { + WORKER_HEARTBEAT_KEY_PREFIX, + WORKER_HEARTBEAT_STALE_MULTIPLIER, + WORKER_HEARTBEAT_TTL_SECONDS, +} from './queue.constants'; + +/** 队列各状态计数(与 BullMQ getJobCounts 对齐) */ +export interface QueueCountsSnapshot { + waiting: number; + active: number; + completed: number; + failed: number; + delayed: number; +} + +/** worker 心跳快照:present 表示 key 是否存在;ageSeconds 为 null 表示缺失或值非法 */ +export interface WorkerHeartbeatState { + present: boolean; + ageSeconds: number | null; +} + +export interface QueueHealthEntry { + name: TaskQueueName; + group: TaskQueueGroup; + counts: QueueCountsSnapshot; + oldestWaitingSeconds: number; + workerHeartbeat: WorkerHeartbeatState; +} + +export interface QueueHealthReport { + status: 'ok' | 'degraded'; + redis: 'ok'; + queues: QueueHealthEntry[]; +} + +export interface QueueHealthSnapshotInput { + name: TaskQueueName; + group: TaskQueueGroup; + counts: QueueCountsSnapshot; + oldestWaitingTimestampMs: number | null; + heartbeatRaw: string | null; + nowMs: number; +} + +/** 心跳 key:`lingxi:worker:heartbeat:`(与 apps/worker 保持一致) */ +export function workerHeartbeatKey(queueName: TaskQueueName): string { + return `${WORKER_HEARTBEAT_KEY_PREFIX}${queueName}`; +} + +export function isHeartbeatStale(ageSeconds: number | null): boolean { + return ( + ageSeconds !== null && + ageSeconds > WORKER_HEARTBEAT_TTL_SECONDS * WORKER_HEARTBEAT_STALE_MULTIPLIER + ); +} + +function isRecord(value: unknown): value is Record { + return typeof value === 'object' && value !== null && !Array.isArray(value); +} + +/** 解析心跳 JSON 中的 updatedAt,得到秒级年龄;缺失 / 非法返回 null */ +export function parseHeartbeatAgeSeconds(raw: string | null, nowMs: number): number | null { + if (raw === null) { + return null; + } + try { + const parsed: unknown = JSON.parse(raw); + if (!isRecord(parsed)) { + return null; + } + const updatedAt = parsed.updatedAt; + if (typeof updatedAt !== 'string') { + return null; + } + const updatedAtMs = Date.parse(updatedAt); + if (Number.isNaN(updatedAtMs)) { + return null; + } + return Math.max(0, Math.round((nowMs - updatedAtMs) / 1000)); + } catch { + return null; + } +} + +export function buildQueueHealthEntry(input: QueueHealthSnapshotInput): QueueHealthEntry { + const ageSeconds = parseHeartbeatAgeSeconds(input.heartbeatRaw, input.nowMs); + const oldestWaitingSeconds = + input.oldestWaitingTimestampMs === null + ? 0 + : Math.max(0, Math.round((input.nowMs - input.oldestWaitingTimestampMs) / 1000)); + return { + name: input.name, + group: input.group, + counts: input.counts, + oldestWaitingSeconds, + workerHeartbeat: { present: input.heartbeatRaw !== null, ageSeconds }, + }; +} + +/** + * 汇总健康报告:有积压(waiting + delayed > 0)却没有 worker 心跳时降级; + * 心跳存在但陈旧(超过 TTL 倍数)同样降级,提示 worker 可能已崩溃。 + */ +export function buildQueueHealthReport(entries: readonly QueueHealthEntry[]): QueueHealthReport { + const degraded = entries.some((entry) => { + const backlog = entry.counts.waiting + entry.counts.delayed; + if (!entry.workerHeartbeat.present) { + return backlog > 0; + } + return backlog > 0 && isHeartbeatStale(entry.workerHeartbeat.ageSeconds); + }); + return { + status: degraded ? 'degraded' : 'ok', + redis: 'ok', + queues: [...entries], + }; +} diff --git a/apps/api/src/infra/queue/queue-health.types.ts b/apps/api/src/infra/queue/queue-health.types.ts new file mode 100644 index 0000000..1795758 --- /dev/null +++ b/apps/api/src/infra/queue/queue-health.types.ts @@ -0,0 +1,59 @@ +import { ApiProperty } from '@nestjs/swagger'; + +export class QueueCountsDto { + @ApiProperty({ type: Number, description: '等待中任务数', example: 0 }) + waiting!: number; + + @ApiProperty({ type: Number, description: '执行中任务数', example: 0 }) + active!: number; + + @ApiProperty({ type: Number, description: '已完成任务数(保留窗口内的历史计数)', example: 12 }) + completed!: number; + + @ApiProperty({ type: Number, description: '失败任务数(保留窗口内的历史计数)', example: 1 }) + failed!: number; + + @ApiProperty({ type: Number, description: '延迟任务数', example: 0 }) + delayed!: number; +} + +export class WorkerHeartbeatDto { + @ApiProperty({ type: Boolean, description: '最近 30s 内是否有 worker 心跳' }) + present!: boolean; + + @ApiProperty({ + type: Number, + nullable: true, + description: '心跳年龄(秒);心跳缺失或值非法时为 null', + example: 3, + }) + ageSeconds!: number | null; +} + +export class QueueHealthEntryDto { + @ApiProperty({ type: String, description: '队列名', example: 'media:verify' }) + name!: string; + + @ApiProperty({ type: String, description: '队列组', example: 'verify' }) + group!: string; + + @ApiProperty({ type: QueueCountsDto }) + counts!: QueueCountsDto; + + @ApiProperty({ type: Number, description: '队首任务等待秒数', example: 0 }) + oldestWaitingSeconds!: number; + + @ApiProperty({ type: WorkerHeartbeatDto }) + workerHeartbeat!: WorkerHeartbeatDto; +} + +export class QueueHealthReportDto { + @ApiProperty({ type: String, enum: ['ok', 'degraded'], example: 'ok' }) + status!: 'ok' | 'degraded'; + + @ApiProperty({ type: String, enum: ['ok'], example: 'ok' }) + redis!: 'ok'; + + @ApiProperty({ type: QueueHealthEntryDto, isArray: true }) + queues!: QueueHealthEntryDto[]; +} diff --git a/apps/api/src/infra/queue/queue-metrics.controller.ts b/apps/api/src/infra/queue/queue-metrics.controller.ts new file mode 100644 index 0000000..5279839 --- /dev/null +++ b/apps/api/src/infra/queue/queue-metrics.controller.ts @@ -0,0 +1,104 @@ +import { TASK_QUEUES, type TaskQueueName } from '@lingxi/shared'; +import { Controller, Get, Header, Inject, Logger } from '@nestjs/common'; +import { ApiExcludeController } from '@nestjs/swagger'; +import { Prisma } from '@prisma/client'; + +import { Public } from '../../common/security/decorators'; +import type { PrismaService } from '../prisma/prisma.service'; +import type { RedisService } from '../redis/redis.service'; +import { PRISMA, REDIS } from '../tokens'; +import { + type OutboxMetricsInput, + type QueueMetricsInput, + renderPrometheusMetrics, +} from './prometheus'; +import { OUTBOX_DEAD_LETTER_MARKER } from './queue.constants'; +import { errorMessage, toSingleLine } from './queue.utils'; +import { parseHeartbeatAgeSeconds, workerHeartbeatKey } from './queue-health'; +import { QueueRegistryService } from './queue-registry.service'; + +interface CountRow { + count: number; +} + +/** + * Prometheus 指标端点(A6-INF-01):`GET /api/v1/metrics`(text/plain; version=0.0.4)。 + * + * 只读:队列计数走 BullMQ(Redis),outbox 待投递 / 死信计数走 outbox_events 只读查询。 + * 任何一个数据源查询失败都**不返回 500**:写入注释行 + 记日志,保证抓取不中断。 + */ +@ApiExcludeController() +@Public() +@Controller() +export class QueueMetricsController { + private readonly logger = new Logger(QueueMetricsController.name); + + constructor( + @Inject(QueueRegistryService) private readonly registry: QueueRegistryService, + @Inject(REDIS) private readonly redis: RedisService, + @Inject(PRISMA) private readonly prisma: PrismaService, + ) {} + + @Get('metrics') + @Header('Content-Type', 'text/plain; version=0.0.4; charset=utf-8') + async metrics(): Promise { + const nowMs = Date.now(); + const comments: string[] = []; + const queues = await this.collectQueues(nowMs, comments); + const outbox = await this.collectOutbox(comments); + return renderPrometheusMetrics({ queues, outbox, comments }); + } + + private async collectQueues(nowMs: number, comments: string[]): Promise { + const result: QueueMetricsInput[] = []; + for (const definition of TASK_QUEUES) { + try { + result.push(await this.collectQueue(definition.name, nowMs)); + } catch (error) { + const message = toSingleLine(errorMessage(error)); + comments.push(`队列 ${definition.name} 指标查询失败:${message}`); + this.logger.warn(`队列 ${definition.name} 指标查询失败:${message}`); + } + } + return result; + } + + private async collectQueue(name: TaskQueueName, nowMs: number): Promise { + const [counts, oldestWaitingTimestampMs, heartbeatRaw] = await Promise.all([ + this.registry.getCounts(name), + this.registry.getOldestWaitingTimestampMs(name), + this.redis.getClient().get(workerHeartbeatKey(name)), + ]); + return { + name, + counts, + oldestWaitingSeconds: + oldestWaitingTimestampMs === null + ? 0 + : Math.max(0, Math.round((nowMs - oldestWaitingTimestampMs) / 1000)), + heartbeatAgeSeconds: parseHeartbeatAgeSeconds(heartbeatRaw, nowMs), + }; + } + + private async collectOutbox(comments: string[]): Promise { + try { + const [pendingRows, deadLetterRows] = await Promise.all([ + this.prisma.$queryRaw( + Prisma.sql`SELECT count(*)::int AS count FROM outbox_events WHERE published_at IS NULL`, + ), + this.prisma.$queryRaw( + Prisma.sql`SELECT count(*)::int AS count FROM outbox_events WHERE last_error = ${OUTBOX_DEAD_LETTER_MARKER}`, + ), + ]); + return { + pending: pendingRows[0]?.count ?? 0, + deadLetter: deadLetterRows[0]?.count ?? 0, + }; + } catch (error) { + const message = toSingleLine(errorMessage(error)); + comments.push(`Outbox 指标查询失败:${message}`); + this.logger.warn(`Outbox 指标查询失败:${message}`); + return null; + } + } +} diff --git a/apps/api/src/infra/queue/queue-registry.service.ts b/apps/api/src/infra/queue/queue-registry.service.ts new file mode 100644 index 0000000..e2dd9f0 --- /dev/null +++ b/apps/api/src/infra/queue/queue-registry.service.ts @@ -0,0 +1,146 @@ +import { TASK_QUEUES, type TaskQueueName } from '@lingxi/shared'; +import { Inject, Injectable, Logger, type OnModuleDestroy } from '@nestjs/common'; +import { type Job, type JobsOptions, Queue } from 'bullmq'; +import Redis from 'ioredis'; + +import { APP_CONFIG } from '../../config/config.module'; +import type { AppConfig } from '../../config/env'; +import type { TaskJobData } from './outbox-payload'; +import { QUEUE_OPERATION_TIMEOUT_MS } from './queue.constants'; +import type { QueueCountsSnapshot } from './queue-health'; +import { withTimeout } from './with-timeout'; + +/** + * 逻辑队列名 → BullMQ 物理队列名。 + * BullMQ 5.x 明确禁止队列名含冒号(Queue constructor 会抛 `Queue name cannot contain :`), + * 而协议队列名是 `<域>:<动作>`(media:verify 等),因此物理名统一把 `:` 换成 `-`; + * 健康检查 / 指标 / 心跳 / 告警一律使用**逻辑名**,只有 BullMQ 内部 key 使用物理名。 + */ +export function toBullQueueName(name: TaskQueueName): string { + return name.replaceAll(':', '-'); +} + +/** + * BullMQ 队列注册表(A6-BE-02)。 + * + * - 队列实例**懒创建**:模块初始化阶段不连接 Redis,避免 OpenAPI 导出 / 单测在无 Redis 时失败; + * - 9 个队列共享一条 ioredis 连接(BullMQ 要求 maxRetriesPerRequest: null,不复用 RedisService 的请求连接); + * - 并发 / 重试 / 退避上限全部来自 shared 的 TASK_QUEUES,不在本文件硬编码; + * - OnModuleDestroy 统一 close,防止 dev watch / 测试进程残留连接。 + */ +@Injectable() +export class QueueRegistryService implements OnModuleDestroy { + private readonly logger = new Logger(QueueRegistryService.name); + private readonly queues = new Map>(); + private connection: Redis | null = null; + + constructor(@Inject(APP_CONFIG) private readonly config: AppConfig) {} + + /** 已登记的全部队列名(顺序与 TASK_QUEUES 一致) */ + queueNames(): readonly TaskQueueName[] { + return TASK_QUEUES.map((definition) => definition.name); + } + + getQueue(name: TaskQueueName): Queue { + const existing = this.queues.get(name); + if (existing !== undefined) { + return existing; + } + const queue = new Queue(toBullQueueName(name), { + connection: this.getConnection(), + }); + // 队列连接异常只记录,不能让 EventEmitter 的 'error' 事件把进程打挂 + queue.on('error', (error: Error) => { + this.logger.warn(`队列 ${name} 连接异常:${error.message}`); + }); + this.queues.set(name, queue); + return queue; + } + + /** 投递一条 job;Redis 不可用时由超时兜底,避免调用方永久挂起 */ + async enqueue( + name: TaskQueueName, + jobName: string, + data: TaskJobData, + options: JobsOptions, + ): Promise> { + const queue = this.getQueue(name); + return withTimeout( + queue.add(jobName, data, options), + QUEUE_OPERATION_TIMEOUT_MS, + `队列入队失败:${name}`, + ); + } + + /** 各状态计数;失败抛错由调用方(health / metrics)兜底 */ + async getCounts(name: TaskQueueName): Promise { + const counts = await withTimeout( + this.getQueue(name).getJobCounts('waiting', 'active', 'completed', 'failed', 'delayed'), + QUEUE_OPERATION_TIMEOUT_MS, + `队列状态查询失败:${name}`, + ); + return { + waiting: countOf(counts, 'waiting'), + active: countOf(counts, 'active'), + completed: countOf(counts, 'completed'), + failed: countOf(counts, 'failed'), + delayed: countOf(counts, 'delayed'), + }; + } + + /** 队首(最早入队且仍 waiting)job 的时间戳,用于等待时长指标 */ + async getOldestWaitingTimestampMs(name: TaskQueueName): Promise { + const jobs = await withTimeout( + this.getQueue(name).getJobs(['waiting'], 0, 0), + QUEUE_OPERATION_TIMEOUT_MS, + `队列等待时长查询失败:${name}`, + ); + const oldest = jobs[0]; + return oldest === undefined ? null : oldest.timestamp; + } + + async onModuleDestroy(): Promise { + for (const [name, queue] of this.queues) { + try { + await queue.close(); + } catch (error) { + this.logger.warn( + `关闭队列 ${name} 失败:${error instanceof Error ? error.message : String(error)}`, + ); + } + } + this.queues.clear(); + + const connection = this.connection; + this.connection = null; + if (connection !== null) { + try { + if (connection.status === 'wait' || connection.status === 'end') { + connection.disconnect(); + } else { + await connection.quit(); + } + } catch { + connection.disconnect(); + } + } + } + + private getConnection(): Redis { + if (this.connection === null) { + this.connection = new Redis(this.config.redisUrl, { + lazyConnect: true, + // BullMQ 阻塞命令要求 null;ioredis 连接错误用事件记录,避免未处理异常 + maxRetriesPerRequest: null, + }); + this.connection.on('error', (error: Error) => { + this.logger.warn(`队列 Redis 连接异常:${error.message}`); + }); + } + return this.connection; + } +} + +function countOf(counts: Record, key: string): number { + return counts[key] ?? 0; +} diff --git a/apps/api/src/infra/queue/queue.constants.ts b/apps/api/src/infra/queue/queue.constants.ts new file mode 100644 index 0000000..06626a9 --- /dev/null +++ b/apps/api/src/infra/queue/queue.constants.ts @@ -0,0 +1,40 @@ +/** + * 队列 / Outbox 投递协议常量(技术架构 §2.4 / §3.7,A6-BE-02 / A6-BE-05 / A6-INF-01)。 + * + * key 统一 `lingxi:` 前缀;心跳 key 与 worker 侧(apps/worker/src/heartbeat.ts)保持一致, + * 跨 app 契约变更时必须两端同步。 + */ + +/** Outbox 投递分布式锁:同一时刻只允许一个 dispatcher 抢到本轮投递权 */ +export const OUTBOX_DISPATCH_LOCK_KEY = 'lingxi:lock:outbox-dispatch'; + +/** 锁 TTL:既覆盖单轮投递耗时,又保证实例崩溃后 5s 内可被其他实例接管 */ +export const OUTBOX_DISPATCH_LOCK_TTL_MS = 5_000; + +/** 单轮最多认领的事件数(避免一轮占满连接 / 内存) */ +export const OUTBOX_DISPATCH_BATCH_SIZE = 20; + +/** 死信阈值:投递尝试达到该次数后标记 dead_lettered,避免单条事件永久阻塞 outbox 表 */ +export const OUTBOX_MAX_ATTEMPTS = 10; + +/** 默认调度间隔(可被环境变量 OUTBOX_DISPATCH_INTERVAL_MS 覆盖) */ +export const OUTBOX_DISPATCH_DEFAULT_INTERVAL_MS = 1_000; + +/** 死信标记:写入 outbox_events.last_error,便于监控与人工排查 */ +export const OUTBOX_DEAD_LETTER_MARKER = 'dead_lettered'; + +/** worker 心跳 key 前缀:`lingxi:worker:heartbeat:` */ +export const WORKER_HEARTBEAT_KEY_PREFIX = 'lingxi:worker:heartbeat:'; + +/** worker 心跳 TTL(秒);worker 每 10s 续写一次,超过 TTL 视为心跳缺失 */ +export const WORKER_HEARTBEAT_TTL_SECONDS = 30; + +/** 心跳年龄超过 TTL 的倍数视为陈旧(用于健康检查降级判定) */ +export const WORKER_HEARTBEAT_STALE_MULTIPLIER = 2; + +/** 单轮投递 / 单次队列命令的超时,避免 Redis 异常时无限挂起 */ +export const QUEUE_OPERATION_TIMEOUT_MS = 5_000; + +/** Lua:仅当锁 value 与 token 相等时释放,避免误删其他实例持有的锁 */ +export const RELEASE_LOCK_SCRIPT = + "if redis.call('get', KEYS[1]) == ARGV[1] then return redis.call('del', KEYS[1]) else return 0 end"; diff --git a/apps/api/src/infra/queue/queue.module.ts b/apps/api/src/infra/queue/queue.module.ts new file mode 100644 index 0000000..35d6b4f --- /dev/null +++ b/apps/api/src/infra/queue/queue.module.ts @@ -0,0 +1,23 @@ +import { Module } from '@nestjs/common'; + +import { OutboxDispatcherService } from './outbox-dispatcher.service'; +import { QueueHealthController } from './queue-health.controller'; +import { QueueMetricsController } from './queue-metrics.controller'; +import { QueueRegistryService } from './queue-registry.service'; + +/** + * 队列骨架模块(A6-BE-02 / A6-BE-05 / A6-INF-01)。 + * + * 由 AppModule 统一注册(Lead 接线,本模块不反向 import 业务模块): + * - QueueRegistryService:9 个 BullMQ 队列的注册、入队与生命周期; + * - OutboxDispatcherService:定时认领 outbox_events 并投递队列(Redis 锁 + SKIP LOCKED); + * - GET /health/queues、GET /metrics:队列健康检查与 Prometheus 指标。 + * + * 依赖(APP_CONFIG / PRISMA / REDIS)来自全局模块,无需在此重复 imports。 + */ +@Module({ + controllers: [QueueHealthController, QueueMetricsController], + providers: [QueueRegistryService, OutboxDispatcherService], + exports: [QueueRegistryService, OutboxDispatcherService], +}) +export class QueueModule {} diff --git a/apps/api/src/infra/queue/queue.utils.ts b/apps/api/src/infra/queue/queue.utils.ts new file mode 100644 index 0000000..2e3b3e6 --- /dev/null +++ b/apps/api/src/infra/queue/queue.utils.ts @@ -0,0 +1,34 @@ +/** 队列基础设施的通用小工具(纯函数,便于单测) */ + +export function errorMessage(error: unknown): string { + return error instanceof Error ? error.message : String(error); +} + +/** 日志 / 死信原因字段截断,避免超长错误串撑爆 DB / 日志 */ +export function truncate(value: string, maxLength: number): string { + return value.length <= maxLength ? value : `${value.slice(0, maxLength - 1)}…`; +} + +/** 把错误信息压缩为单行,Prometheus 注释 / 日志输出前调用 */ +export function toSingleLine(value: string): string { + return value.replaceAll(/\s+/g, ' ').trim(); +} + +/** 读取布尔开关类环境变量,未设置时返回默认值 */ +export function readBooleanEnv(name: string, defaultValue: boolean): boolean { + const raw = process.env[name]; + if (raw === undefined || raw === '') { + return defaultValue; + } + return raw === 'true' || raw === '1'; +} + +/** 读取正整数环境变量,非法 / 缺失时回退默认值(用于调度间隔等运行参数) */ +export function readPositiveIntEnv(name: string, defaultValue: number, min = 1): number { + const raw = process.env[name]; + if (raw === undefined || raw === '') { + return defaultValue; + } + const parsed = Number(raw); + return Number.isInteger(parsed) && parsed >= min ? parsed : defaultValue; +} diff --git a/apps/api/src/infra/queue/with-timeout.ts b/apps/api/src/infra/queue/with-timeout.ts new file mode 100644 index 0000000..e58cca9 --- /dev/null +++ b/apps/api/src/infra/queue/with-timeout.ts @@ -0,0 +1,27 @@ +/** 给队列 / Redis 命令加超时,避免依赖不可用时请求或调度循环无限挂起 */ +export class QueueTimeoutError extends Error { + constructor(message: string) { + super(message); + this.name = 'QueueTimeoutError'; + } +} + +export async function withTimeout( + task: Promise, + timeoutMs: number, + message: string, +): Promise { + let timer: NodeJS.Timeout | undefined; + const timeout = new Promise((_resolve, reject) => { + timer = setTimeout(() => { + reject(new QueueTimeoutError(`${message}(超时 ${String(timeoutMs)}ms)`)); + }, timeoutMs); + }); + try { + return await Promise.race([task, timeout]); + } finally { + if (timer !== undefined) { + clearTimeout(timer); + } + } +} diff --git a/apps/api/src/modules/media/media-task-cursor.spec.ts b/apps/api/src/modules/media/media-task-cursor.spec.ts new file mode 100644 index 0000000..4acf903 --- /dev/null +++ b/apps/api/src/modules/media/media-task-cursor.spec.ts @@ -0,0 +1,49 @@ +import { describe, expect, it } from 'vitest'; + +import { + decodeItemCursor, + decodeMediaTaskCursor, + encodeItemCursor, + encodeMediaTaskCursor, +} from './media-task-cursor'; + +const AT = new Date('2026-11-04T12:00:00.000Z'); +const ID = '01J9Z000000000000000000001'; + +describe('任务列表游标(createdAt DESC, id DESC)', () => { + it('编码 / 解码往返一致', () => { + const encoded = encodeMediaTaskCursor(AT, ID); + expect(encoded).not.toContain('='); + expect(decodeMediaTaskCursor(encoded)).toEqual({ at: AT, id: ID }); + }); + + it('支持 ISO 字符串入参', () => { + const encoded = encodeMediaTaskCursor(AT.toISOString(), ID); + expect(decodeMediaTaskCursor(encoded)?.id).toBe(ID); + }); + + it('非法游标返回 null:空串 / 非法 base64 / 缺分隔符 / 非法 ISO / 非法 ULID', () => { + expect(decodeMediaTaskCursor('')).toBeNull(); + expect(decodeMediaTaskCursor('!!!!')).toBeNull(); + expect(decodeMediaTaskCursor(Buffer.from('no-separator').toString('base64url'))).toBeNull(); + expect(decodeMediaTaskCursor(Buffer.from(`not-a-date|${ID}`).toString('base64url'))).toBeNull(); + expect( + decodeMediaTaskCursor(Buffer.from(`${AT.toISOString()}|not-ulid`).toString('base64url')), + ).toBeNull(); + }); +}); + +describe('任务项续拉游标(seq)', () => { + it('编码 / 解码往返一致', () => { + expect(decodeItemCursor(encodeItemCursor(1))).toBe(1); + expect(decodeItemCursor(encodeItemCursor(100))).toBe(100); + }); + + it('非法游标返回 null:前缀错误 / 非数字 / 0 / 越界', () => { + expect(decodeItemCursor('')).toBeNull(); + expect(decodeItemCursor(Buffer.from('xxx:1').toString('base64url'))).toBeNull(); + expect(decodeItemCursor(Buffer.from('item:abc').toString('base64url'))).toBeNull(); + expect(decodeItemCursor(Buffer.from('item:0').toString('base64url'))).toBeNull(); + expect(decodeItemCursor(Buffer.from('item:1234567890').toString('base64url'))).toBeNull(); + }); +}); diff --git a/apps/api/src/modules/media/media-task-cursor.ts b/apps/api/src/modules/media/media-task-cursor.ts new file mode 100644 index 0000000..4776769 --- /dev/null +++ b/apps/api/src/modules/media/media-task-cursor.ts @@ -0,0 +1,57 @@ +/** + * 任务中心游标分页编码(A6-BE-04) + * + * - 任务列表:`base64url(|)`,对应稳定排序 `createdAt DESC, id DESC`; + * 用 keyset(`createdAt < cursor.at OR (createdAt = cursor.at AND id < cursor.id)`)翻页, + * 不依赖 (createdAt, id) 唯一索引,也不引新依赖; + * - 任务项续拉:`base64url(item:)`,对应 `(task_id, seq)` 唯一索引上的 `seq > cursor`。 + * 非法游标返回 null,由 Service 映射为 40001(不静默首页,避免用户误以为数据丢失)。 + */ +import { isIsoDateString, ULID_PATTERN } from '@lingxi/shared'; + +export interface MediaTaskCursor { + at: Date; + id: string; +} + +export function encodeMediaTaskCursor(at: Date | string, id: string): string { + const iso = at instanceof Date ? at.toISOString() : at; + return Buffer.from(`${iso}|${id}`, 'utf8').toString('base64url'); +} + +export function decodeMediaTaskCursor(value: string): MediaTaskCursor | null { + let decoded: string; + try { + decoded = Buffer.from(value, 'base64url').toString('utf8'); + } catch { + return null; + } + const separator = decoded.lastIndexOf('|'); + if (separator <= 0) return null; + const iso = decoded.slice(0, separator); + const id = decoded.slice(separator + 1); + if (!isIsoDateString(iso) || !ULID_PATTERN.test(id)) return null; + const at = new Date(iso); + if (Number.isNaN(at.getTime())) return null; + return { at, id }; +} + +const ITEM_CURSOR_PREFIX = 'item:'; + +export function encodeItemCursor(seq: number): string { + return Buffer.from(`${ITEM_CURSOR_PREFIX}${String(seq)}`, 'utf8').toString('base64url'); +} + +export function decodeItemCursor(value: string): number | null { + let decoded: string; + try { + decoded = Buffer.from(value, 'base64url').toString('utf8'); + } catch { + return null; + } + if (!decoded.startsWith(ITEM_CURSOR_PREFIX)) return null; + const raw = decoded.slice(ITEM_CURSOR_PREFIX.length); + if (!/^\d{1,9}$/.test(raw)) return null; + const seq = Number(raw); + return Number.isSafeInteger(seq) && seq > 0 ? seq : null; +} diff --git a/apps/api/src/modules/media/media-task-estimator.spec.ts b/apps/api/src/modules/media/media-task-estimator.spec.ts new file mode 100644 index 0000000..c1ab9d2 --- /dev/null +++ b/apps/api/src/modules/media/media-task-estimator.spec.ts @@ -0,0 +1,102 @@ +import { ErrorCode } from '@lingxi/shared'; +import { describe, expect, it, vi } from 'vitest'; + +import { AppException } from '../../common/errors/app-exception'; +import type { SystemConfigService } from '../platform/system-config/system-config.service'; +import { estimateTask, MediaTaskEstimator } from './media-task-estimator'; + +function expectCode(fn: () => unknown, code: ErrorCode): void { + try { + fn(); + } catch (error) { + expect(error).toBeInstanceOf(AppException); + expect((error as AppException).code).toBe(code); + return; + } + throw new Error('预期抛出 AppException'); +} + +const CONFIG = { maxItems: 20, costPerItem: 0.02, secondsPerItem: 3 }; + +describe('任务预估纯计算(R-26:上限 / 单价来自系统配置)', () => { + it('返回条数 / 成本(2 位小数)/ 耗时', () => { + expect(estimateTask('demo', 5, CONFIG)).toEqual({ + estimatedItems: 5, + estimatedCost: 0.1, + estimatedSeconds: 15, + }); + // 0.02 * 3 = 0.06 不产生浮点尾巴 + expect(estimateTask('demo', 3, CONFIG).estimatedCost).toBe(0.06); + expect(estimateTask('demo', 20, CONFIG).estimatedCost).toBe(0.4); + }); + + it('项数超过配置上限 → 40001(业务上限,不是 schema 硬上限)', () => { + expectCode(() => estimateTask('demo', 21, CONFIG), ErrorCode.PARAM_INVALID); + expectCode( + () => estimateTask('demo', 101, { ...CONFIG, maxItems: 100 }), + ErrorCode.PARAM_INVALID, + ); + }); + + it('非法项数 / 非法配置 → 40001', () => { + expectCode(() => estimateTask('demo', 0, CONFIG), ErrorCode.PARAM_INVALID); + expectCode(() => estimateTask('demo', 2.5, CONFIG), ErrorCode.PARAM_INVALID); + expectCode(() => estimateTask('demo', 1, { ...CONFIG, maxItems: 0 }), ErrorCode.PARAM_INVALID); + expectCode( + () => estimateTask('demo', 1, { ...CONFIG, costPerItem: Number.NaN }), + ErrorCode.PARAM_INVALID, + ); + expectCode( + () => estimateTask('demo', 1, { ...CONFIG, costPerItem: -1 }), + ErrorCode.PARAM_INVALID, + ); + expectCode( + () => estimateTask('demo', 1, { ...CONFIG, secondsPerItem: 0 }), + ErrorCode.PARAM_INVALID, + ); + expectCode( + () => estimateTask('demo', 1, { ...CONFIG, secondsPerItem: Number.POSITIVE_INFINITY }), + ErrorCode.PARAM_INVALID, + ); + }); +}); + +describe('MediaTaskEstimator(读 SystemConfigService)', () => { + function estimatorWith(values: readonly number[]): { + estimator: MediaTaskEstimator; + getNumber: ReturnType; + } { + const getNumber = vi.fn(); + for (const value of values) getNumber.mockResolvedValueOnce(value); + const config = { getNumber } as unknown as SystemConfigService; + return { estimator: new MediaTaskEstimator(config), getNumber }; + } + + it('按配置计算预估', async () => { + const { estimator, getNumber } = estimatorWith([20, 0.02, 3]); + await expect(estimator.estimate('demo', 5)).resolves.toEqual({ + estimatedItems: 5, + estimatedCost: 0.1, + estimatedSeconds: 15, + }); + expect(getNumber).toHaveBeenNthCalledWith(1, 'task.demo.max_items'); + expect(getNumber).toHaveBeenNthCalledWith(2, 'task.estimate.demo_cost_per_item'); + expect(getNumber).toHaveBeenNthCalledWith(3, 'task.estimate.demo_seconds_per_item'); + }); + + it('读配置失败(缺预估字段)→ 40001,不允许静默 0', async () => { + const getNumber = vi.fn().mockRejectedValue(new Error('system_configs 不可用')); + const config = { getNumber } as unknown as SystemConfigService; + const estimator = new MediaTaskEstimator(config); + await expect(estimator.estimate('demo', 5)).rejects.toMatchObject({ + code: ErrorCode.PARAM_INVALID, + }); + }); + + it('配置值越界(超过上限)→ 40001', async () => { + const { estimator } = estimatorWith([4, 0.02, 3]); + await expect(estimator.estimate('demo', 5)).rejects.toMatchObject({ + code: ErrorCode.PARAM_INVALID, + }); + }); +}); diff --git a/apps/api/src/modules/media/media-task-estimator.ts b/apps/api/src/modules/media/media-task-estimator.ts new file mode 100644 index 0000000..7c48451 --- /dev/null +++ b/apps/api/src/modules/media/media-task-estimator.ts @@ -0,0 +1,111 @@ +/** + * 任务提交预估(A6-BE-01:提交响应必须包含 estimatedItems / estimatedCost / estimatedSeconds) + * + * R-26 单一来源:上限与单价全部读 `system_configs`(经 SystemConfigService),禁止硬编码; + * 任一预估配置缺失 / 非法时**拒绝提交**(40001),不允许静默返回 0 误导用户。 + */ +import { + ErrorCode, + systemConfigDefinition, + TASK_CONFIG_KEYS, + type TaskType, + taskTypeLabel, +} from '@lingxi/shared'; +import { Inject, Injectable } from '@nestjs/common'; + +import { AppException } from '../../common/errors/app-exception'; +import { SystemConfigService } from '../platform/system-config/system-config.service'; + +export interface TaskEstimate { + estimatedItems: number; + /** 元,保留 2 位小数 */ + estimatedCost: number; + estimatedSeconds: number; +} + +export interface TaskEstimateConfig { + maxItems: number; + costPerItem: number; + secondsPerItem: number; +} + +const ESTIMATE_CONFIG_KEYS: readonly string[] = [ + TASK_CONFIG_KEYS.demoMaxItems, + TASK_CONFIG_KEYS.demoCostPerItem, + TASK_CONFIG_KEYS.demoSecPerItem, +]; + +function roundCost(value: number): number { + return Math.round(value * 100) / 100; +} + +function invalidEstimate(message: string): AppException { + return new AppException({ code: ErrorCode.PARAM_INVALID, message }); +} + +/** + * 纯计算 + 校验:项数超业务上限(task.demo.max_items)或预估配置非法 → 40001。 + * 抽成纯函数便于单测覆盖全部拒绝分支。 + */ +export function estimateTask( + type: TaskType, + itemCount: number, + config: TaskEstimateConfig, +): TaskEstimate { + const label = taskTypeLabel(type); + if (!Number.isInteger(itemCount) || itemCount < 1) { + throw invalidEstimate(`${label}项数必须为正整数`); + } + if (!Number.isFinite(config.maxItems) || config.maxItems < 1) { + throw invalidEstimate('任务项上限配置无效,请联系管理员检查系统配置'); + } + if (itemCount > config.maxItems) { + throw invalidEstimate(`${label}项数超过上限(${String(config.maxItems)} 项)`); + } + if (!Number.isFinite(config.costPerItem) || config.costPerItem < 0) { + throw invalidEstimate('任务预估成本配置无效,请联系管理员检查系统配置'); + } + if (!Number.isFinite(config.secondsPerItem) || config.secondsPerItem <= 0) { + throw invalidEstimate('任务预估耗时配置无效,请联系管理员检查系统配置'); + } + return { + estimatedItems: itemCount, + estimatedCost: roundCost(itemCount * config.costPerItem), + estimatedSeconds: Math.round(itemCount * config.secondsPerItem), + }; +} + +@Injectable() +export class MediaTaskEstimator { + constructor(@Inject(SystemConfigService) private readonly config: SystemConfigService) {} + + async estimate(type: TaskType, itemCount: number): Promise { + const estimateConfig = await this.loadConfig(); + return estimateTask(type, itemCount, estimateConfig); + } + + /** + * 读取三项预估配置: + * 1. 定义缺失(代码未登记 / 类型不是 number)→ 视为配置缺失,拒绝提交; + * 2. 读库异常 / 值非法 → 同样拒绝,避免拿 0 或兜底值蒙混。 + */ + private async loadConfig(): Promise { + for (const key of ESTIMATE_CONFIG_KEYS) { + const definition = systemConfigDefinition(key); + if (definition?.type !== 'number') { + throw invalidEstimate(`任务预估配置缺失:${key}`); + } + } + let values: readonly number[]; + try { + values = await Promise.all(ESTIMATE_CONFIG_KEYS.map((key) => this.config.getNumber(key))); + } catch { + throw invalidEstimate('任务预估配置读取失败,请稍后重试或联系管理员'); + } + const [maxItems, costPerItem, secondsPerItem] = values; + if (maxItems === undefined || costPerItem === undefined || secondsPerItem === undefined) { + throw invalidEstimate('任务预估配置缺失,请联系管理员检查系统配置'); + } + return { maxItems, costPerItem, secondsPerItem }; + } +} diff --git a/apps/api/src/modules/media/media-task-idempotency.spec.ts b/apps/api/src/modules/media/media-task-idempotency.spec.ts new file mode 100644 index 0000000..c249120 --- /dev/null +++ b/apps/api/src/modules/media/media-task-idempotency.spec.ts @@ -0,0 +1,62 @@ +import { ErrorCode } from '@lingxi/shared'; +import { describe, expect, it } from 'vitest'; + +import { AppException } from '../../common/errors/app-exception'; +import { + hashIdempotencyRequest, + parseIdempotencyKey, + stableStringify, +} from './media-task-idempotency'; + +function expectCode(fn: () => unknown, code: ErrorCode): void { + try { + fn(); + } catch (error) { + expect(error).toBeInstanceOf(AppException); + expect((error as AppException).code).toBe(code); + return; + } + throw new Error('预期抛出 AppException'); +} + +describe('幂等请求指纹(含 actorId)', () => { + it('键顺序不影响哈希(稳定序列化)', () => { + expect(stableStringify({ b: 2, a: 1 })).toBe('{"a":1,"b":2}'); + expect(stableStringify({ a: 1, b: { d: 4, c: 3 } })).toBe('{"a":1,"b":{"c":3,"d":4}}'); + const left = hashIdempotencyRequest('u1', { a: 1, b: [1, 2] }); + const right = hashIdempotencyRequest('u1', { b: [1, 2], a: 1 }); + expect(left).toBe(right); + expect(left).toHaveLength(64); + }); + + it('不同操作人 / 不同请求体 → 不同哈希', () => { + const base = hashIdempotencyRequest('u1', { itemCount: 5 }); + expect(hashIdempotencyRequest('u2', { itemCount: 5 })).not.toBe(base); + expect(hashIdempotencyRequest('u1', { itemCount: 6 })).not.toBe(base); + }); + + it('数组顺序 / 原始类型保持语义', () => { + expect(stableStringify([3, 1, 2])).toBe('[3,1,2]'); + expect(stableStringify(null)).toBe('null'); + expect(stableStringify('text')).toBe('"text"'); + }); +}); + +describe('Idempotency-Key 请求头校验', () => { + it('接受 8–128 位可见 ASCII,数组取第一个', () => { + expect(parseIdempotencyKey('idem-key-0001')).toBe('idem-key-0001'); + expect(parseIdempotencyKey(['idem-key-0002', 'other'])).toBe('idem-key-0002'); + }); + + it('缺失 / 空白 → 40001(缺少请求头)', () => { + expectCode(() => parseIdempotencyKey(undefined), ErrorCode.PARAM_INVALID); + expectCode(() => parseIdempotencyKey([]), ErrorCode.PARAM_INVALID); + expectCode(() => parseIdempotencyKey(' '), ErrorCode.PARAM_INVALID); + }); + + it('过短 / 过长 / 非可见 ASCII → 40001(格式非法)', () => { + expectCode(() => parseIdempotencyKey('short'), ErrorCode.PARAM_INVALID); + expectCode(() => parseIdempotencyKey('x'.repeat(129)), ErrorCode.PARAM_INVALID); + expectCode(() => parseIdempotencyKey('幂等键-0001'), ErrorCode.PARAM_INVALID); + }); +}); diff --git a/apps/api/src/modules/media/media-task-idempotency.ts b/apps/api/src/modules/media/media-task-idempotency.ts new file mode 100644 index 0000000..ffe0176 --- /dev/null +++ b/apps/api/src/modules/media/media-task-idempotency.ts @@ -0,0 +1,71 @@ +/** + * 任务提交幂等(A6-BE-01 / AGENTS §3.13:任务提交必须带 Idempotency-Key) + * + * 语义(与契约一致): + * - 24h 内同 scope + key: + * - 请求体哈希(含 actorId)相同 → 返回首次响应(202,不重复建任务 / 审计 / outbox); + * - 请求体哈希不同 → 40902; + * - 缺失 / 非法 header → 40001; + * - 过期行按「无记录」处理并允许覆盖(唯一键是 scope + key,过期行先删除再重建)。 + */ +import { createHash } from 'node:crypto'; + +import { ErrorCode, idempotencyKeySchema } from '@lingxi/shared'; + +import { AppException } from '../../common/errors/app-exception'; + +/** 幂等作用域:不同业务动作互不冲突 */ +export const MEDIA_TASK_SUBMIT_SCOPE = 'media.tasks.submit'; + +/** 幂等键有效期:24 小时(A6-BE-01) */ +export const IDEMPOTENCY_TTL_MS = 24 * 60 * 60 * 1000; + +function canonicalize(value: unknown): unknown { + if (Array.isArray(value)) { + return value.map((item) => canonicalize(item)); + } + if (value !== null && typeof value === 'object') { + const sorted: Record = {}; + for (const [key, item] of Object.entries(value).sort(([left], [right]) => + left < right ? -1 : left > right ? 1 : 0, + )) { + sorted[key] = canonicalize(item); + } + return sorted; + } + return value; +} + +/** 稳定序列化(键排序),保证同一请求体在不同字段顺序下哈希一致 */ +export function stableStringify(value: unknown): string { + return JSON.stringify(canonicalize(value)); +} + +/** 请求指纹 = sha256(actorId + '\n' + 规范请求体):同一 key 不同操作人视为不同请求 */ +export function hashIdempotencyRequest(actorId: string, body: unknown): string { + return createHash('sha256') + .update(`${actorId}\n${stableStringify(body)}`) + .digest('hex'); +} + +/** + * 读取并校验 `Idempotency-Key` 请求头。 + * Fastify 对重复 header 返回字符串数组,取第一个(与 HTTP 语义一致)。 + */ +export function parseIdempotencyKey(raw: string | string[] | undefined): string { + const value = Array.isArray(raw) ? raw[0] : raw; + if (value === undefined || value.trim().length === 0) { + throw new AppException({ + code: ErrorCode.PARAM_INVALID, + message: '缺少 Idempotency-Key 请求头', + }); + } + const parsed = idempotencyKeySchema.safeParse(value); + if (!parsed.success) { + throw new AppException({ + code: ErrorCode.PARAM_INVALID, + message: 'Idempotency-Key 需为 8–128 位可见 ASCII 字符', + }); + } + return parsed.data; +} diff --git a/apps/api/src/modules/media/media-task-result-consumer.service.ts b/apps/api/src/modules/media/media-task-result-consumer.service.ts new file mode 100644 index 0000000..6465617 --- /dev/null +++ b/apps/api/src/modules/media/media-task-result-consumer.service.ts @@ -0,0 +1,155 @@ +/** + * 任务结果 Outbox 本地消费(A6-BE-05 / 技术架构 §3.7) + * + * 职责:消费 `media.task.finished` / `media.task.failed` 两类 Outbox 事件,写任务完成 / 失败审计。 + * 与 queue-worker 的 dispatcher 事件类型**互不重叠**(它只处理 created / retry / canceled)。 + * + * 至少一次投递下的不重复处理: + * - 条件 UPDATE 认领(`WHERE id = ? AND published_at IS NULL`),多实例只有一个能成功; + * - 成功保持 published_at,失败回滚认领并记 last_error,下轮重试; + * - 连续失败达上限后保持已认领(只记 last_error),避免每 2s 热循环。 + */ +import { auditActionMeta, type AuditResult, TASK_OUTBOX_EVENTS } from '@lingxi/shared'; +import { + Inject, + Injectable, + Logger, + type OnModuleDestroy, + type OnModuleInit, + Optional, +} from '@nestjs/common'; + +import { AUDIT_SINK, type AuditSink, type AuditWriteInput } from '../../common/security/audit-sink'; +import { MediaTaskRepository, type PendingResultEvent } from './media-task.repository'; + +/** 轮询间隔:与契约一致(2s) */ +export const RESULT_CONSUMER_INTERVAL_MS = 2000; +/** 单轮最多处理事件数 */ +export const RESULT_CONSUMER_BATCH_SIZE = 10; +/** 单事件最大处理次数,超过后只记 last_error 不再重试(避免无限循环) */ +export const RESULT_CONSUMER_MAX_ATTEMPTS = 5; + +interface ResultAuditMapping { + actionCode: string; + result: AuditResult; +} + +const RESULT_AUDIT_MAPPINGS: ReadonlyMap = new Map([ + [TASK_OUTBOX_EVENTS.finished, { actionCode: 'task.finished', result: 'success' }], + [TASK_OUTBOX_EVENTS.failed, { actionCode: 'task.failed', result: 'failure' }], +]); + +@Injectable() +export class MediaTaskResultConsumer implements OnModuleInit, OnModuleDestroy { + private readonly logger = new Logger('MediaTaskResultConsumer'); + private timer: NodeJS.Timeout | null = null; + private draining = false; + + constructor( + @Inject(MediaTaskRepository) private readonly repository: MediaTaskRepository, + @Optional() @Inject(AUDIT_SINK) private readonly sink?: AuditSink, + ) {} + + onModuleInit(): void { + // unref:不阻塞进程退出(单测 / OpenAPI 导出等短生命周期场景) + this.timer = setInterval(() => { + void this.drain(); + }, RESULT_CONSUMER_INTERVAL_MS); + this.timer.unref(); + void this.drain(); + } + + onModuleDestroy(): void { + if (this.timer !== null) clearInterval(this.timer); + this.timer = null; + } + + /** 单轮消费:串行处理,避免同批事件重复写审计时互相覆盖 */ + async drain(): Promise { + if (this.draining) return; + this.draining = true; + try { + const events = await this.repository.findPendingResultEvents(RESULT_CONSUMER_BATCH_SIZE); + for (const event of events) { + await this.consume(event); + } + } catch (error) { + // 数据库抖动等:只记日志,下轮继续;不影响 API + this.logger.error( + `任务结果事件拉取失败 error=${error instanceof Error ? error.message : '未知错误'}`, + ); + } finally { + this.draining = false; + } + } + + private async consume(event: PendingResultEvent): Promise { + const mapping = RESULT_AUDIT_MAPPINGS.get(event.eventType); + if (mapping === undefined) return; + // 条件 UPDATE 认领:多实例 / 上一轮残留都只有一个消费者能拿到 + const claimed = await this.repository.claimResultEvent(event.id); + if (!claimed) return; + try { + const task = await this.repository.findTaskForResult(event.aggregateId); + if (task === null) { + // 任务不存在(极端数据异常):没有可审计对象,保持已消费避免死循环 + this.logger.warn(`任务结果事件找不到任务 event=${event.id} task=${event.aggregateId}`); + return; + } + if (this.sink === undefined) return; + const meta = auditActionMeta(mapping.actionCode); + const input: AuditWriteInput = { + occurredAt: new Date(), + actorId: task.createdBy, + actorName: task.createdByName, + actorAccount: null, + actionCode: mapping.actionCode, + category: meta.category, + targetType: 'task', + targetId: task.id, + targetName: task.title, + customerId: task.customerId, + customerName: task.customerName, + result: mapping.result, + httpMethod: null, + httpPath: null, + // outbox 事件由 worker 写入 trace_id;历史事件可能为空,空串表示未知,不伪造 trace + traceId: event.traceId ?? '', + ip: null, + userAgent: null, + before: undefined, + after: { + type: task.type, + status: task.status, + progress: task.progress, + }, + changes: undefined, + reason: null, + errorCode: task.lastErrorCode, + errorMessage: task.lastErrorMessage, + durationMs: null, + }; + await this.sink.write(input); + } catch (error) { + await this.handleFailure(event, error); + } + } + + private async handleFailure(event: PendingResultEvent, error: unknown): Promise { + const message = error instanceof Error ? error.message : '未知错误'; + // 本轮认领已把 attempts +1,用 event.attempts + 1 判断是否达到上限 + if (event.attempts + 1 >= RESULT_CONSUMER_MAX_ATTEMPTS) { + await this.repository.markResultEventExhausted(event.id, message); + this.logger.error( + `任务结果审计连续失败达上限,停止重试 event=${event.id} attempts=${String( + event.attempts + 1, + )} error=${message}`, + ); + return; + } + await this.repository.markResultEventRetry(event.id, message); + this.logger.error( + `任务结果审计失败,回滚认领 event=${event.id} attempts=${String(event.attempts + 1)} error=${message}`, + ); + } +} diff --git a/apps/api/src/modules/media/media-task-result-consumer.spec.ts b/apps/api/src/modules/media/media-task-result-consumer.spec.ts new file mode 100644 index 0000000..902c99a --- /dev/null +++ b/apps/api/src/modules/media/media-task-result-consumer.spec.ts @@ -0,0 +1,208 @@ +import { TASK_OUTBOX_EVENTS } from '@lingxi/shared'; +import { afterEach, describe, expect, it, vi } from 'vitest'; + +import type { PendingResultEvent } from './media-task.repository'; +import type { MediaTaskRepository } from './media-task.repository'; +import { + MediaTaskResultConsumer, + RESULT_CONSUMER_MAX_ATTEMPTS, +} from './media-task-result-consumer.service'; + +const TASK_ID = '01J9Z000000000000000000001'; +const CUSTOMER_ID = '01J9Z000000000000000000002'; +const USER_ID = '01J9Z000000000000000000003'; + +function eventFixture(overrides: Partial = {}): PendingResultEvent { + return { + id: 'evt-1', + eventType: TASK_OUTBOX_EVENTS.finished, + aggregateId: TASK_ID, + payloadJson: { taskId: TASK_ID, type: 'demo' }, + attempts: 0, + traceId: 'trace-1', + ...overrides, + }; +} + +function taskFixture(overrides: Record = {}): Record { + return { + id: TASK_ID, + type: 'demo', + status: 'pending_review', + title: '演练任务 · 花间集 · 2 项', + customerId: CUSTOMER_ID, + customerName: '花间集', + createdBy: USER_ID, + createdByName: '陈墨', + progress: 100, + lastErrorCode: null, + lastErrorMessage: null, + ...overrides, + }; +} + +interface Harness { + consumer: MediaTaskResultConsumer; + repository: { + findPendingResultEvents: ReturnType; + claimResultEvent: ReturnType; + findTaskForResult: ReturnType; + markResultEventRetry: ReturnType; + markResultEventExhausted: ReturnType; + }; + sink: { write: ReturnType }; +} + +function harness(sinkEnabled = true): Harness { + const repository = { + findPendingResultEvents: vi.fn().mockResolvedValue([]), + claimResultEvent: vi.fn().mockResolvedValue(true), + findTaskForResult: vi.fn().mockResolvedValue(taskFixture()), + markResultEventRetry: vi.fn().mockResolvedValue(undefined), + markResultEventExhausted: vi.fn().mockResolvedValue(undefined), + }; + const sink = { write: vi.fn().mockResolvedValue(undefined) }; + return { + consumer: new MediaTaskResultConsumer( + repository as unknown as MediaTaskRepository, + sinkEnabled ? sink : undefined, + ), + repository, + sink, + }; +} + +afterEach(() => { + vi.useRealTimers(); +}); + +describe('任务结果 Outbox 消费(A6-BE-05)', () => { + it('media.task.finished → 写 task.finished 成功审计(actor 为任务创建人,target=task)', async () => { + const { consumer, repository, sink } = harness(); + repository.findPendingResultEvents.mockResolvedValueOnce([eventFixture()]); + await consumer.drain(); + + expect(repository.claimResultEvent).toHaveBeenCalledWith('evt-1'); + expect(sink.write).toHaveBeenCalledTimes(1); + expect(sink.write.mock.calls[0]?.[0]).toMatchObject({ + actionCode: 'task.finished', + category: 'task', + result: 'success', + actorId: USER_ID, + actorName: '陈墨', + targetType: 'task', + targetId: TASK_ID, + targetName: '演练任务 · 花间集 · 2 项', + customerId: CUSTOMER_ID, + customerName: '花间集', + traceId: 'trace-1', + after: { type: 'demo', status: 'pending_review', progress: 100 }, + }); + }); + + it('media.task.failed → 写 task.failed 失败审计,带最后错误码', async () => { + const { consumer, repository, sink } = harness(); + repository.findTaskForResult.mockResolvedValueOnce( + taskFixture({ + status: 'failed', + progress: 100, + lastErrorCode: 'DEMO_FAIL', + lastErrorMessage: '全部失败', + }), + ); + repository.findPendingResultEvents.mockResolvedValueOnce([ + eventFixture({ eventType: TASK_OUTBOX_EVENTS.failed }), + ]); + await consumer.drain(); + + expect(sink.write.mock.calls[0]?.[0]).toMatchObject({ + actionCode: 'task.failed', + result: 'failure', + errorCode: 'DEMO_FAIL', + errorMessage: '全部失败', + }); + }); + + it('条件认领失败(其他实例已处理)→ 不重复写审计', async () => { + const { consumer, repository, sink } = harness(); + repository.findPendingResultEvents.mockResolvedValueOnce([eventFixture()]); + repository.claimResultEvent.mockResolvedValueOnce(false); + await consumer.drain(); + expect(sink.write).not.toHaveBeenCalled(); + }); + + it('非本地事件类型(created / retry / canceled)不认领', async () => { + const { consumer, repository, sink } = harness(); + repository.findPendingResultEvents.mockResolvedValueOnce([ + eventFixture({ id: 'evt-2', eventType: TASK_OUTBOX_EVENTS.created }), + ]); + await consumer.drain(); + expect(repository.claimResultEvent).not.toHaveBeenCalled(); + expect(sink.write).not.toHaveBeenCalled(); + }); + + it('任务不存在 / 未装配审计端口:不抛错、不阻塞后续事件', async () => { + const missing = harness(); + missing.repository.findPendingResultEvents.mockResolvedValueOnce([eventFixture()]); + missing.repository.findTaskForResult.mockResolvedValueOnce(null); + await expect(missing.consumer.drain()).resolves.toBeUndefined(); + + const noSink = harness(false); + noSink.repository.findPendingResultEvents.mockResolvedValueOnce([eventFixture()]); + await expect(noSink.consumer.drain()).resolves.toBeUndefined(); + }); + + it('审计写失败:回滚认领并记 error,下轮重试', async () => { + const { consumer, repository, sink } = harness(); + repository.findPendingResultEvents.mockResolvedValueOnce([eventFixture({ attempts: 1 })]); + sink.write.mockRejectedValueOnce(new Error('db down')); + await consumer.drain(); + expect(repository.markResultEventRetry).toHaveBeenCalledWith('evt-1', 'db down'); + expect(repository.markResultEventExhausted).not.toHaveBeenCalled(); + }); + + it(`连续失败达上限(${String(RESULT_CONSUMER_MAX_ATTEMPTS)} 次):标记放弃,不再回滚认领`, async () => { + const { consumer, repository, sink } = harness(); + repository.findPendingResultEvents.mockResolvedValueOnce([ + eventFixture({ attempts: RESULT_CONSUMER_MAX_ATTEMPTS - 1 }), + ]); + sink.write.mockRejectedValueOnce(new Error('boom')); + await consumer.drain(); + expect(repository.markResultEventExhausted).toHaveBeenCalledWith('evt-1', 'boom'); + expect(repository.markResultEventRetry).not.toHaveBeenCalled(); + }); + + it('拉取异常只记日志,不向上抛', async () => { + const { consumer, repository } = harness(); + repository.findPendingResultEvents.mockRejectedValueOnce(new Error('connection reset')); + await expect(consumer.drain()).resolves.toBeUndefined(); + }); + + it('单轮防重入:drain 未结束时再次调用直接返回', async () => { + const { consumer, repository } = harness(); + let resolveFetch: (value: PendingResultEvent[]) => void = () => undefined; + repository.findPendingResultEvents.mockImplementationOnce( + () => + new Promise((resolve) => { + resolveFetch = resolve; + }), + ); + const first = consumer.drain(); + await expect(consumer.drain()).resolves.toBeUndefined(); + resolveFetch([]); + await first; + expect(repository.findPendingResultEvents).toHaveBeenCalledTimes(1); + }); + + it('onModuleInit 每 2s 轮询,onModuleDestroy 停止定时器', async () => { + vi.useFakeTimers(); + const { consumer, repository } = harness(); + consumer.onModuleInit(); + expect(repository.findPendingResultEvents).toHaveBeenCalledTimes(1); + await vi.advanceTimersByTimeAsync(2000); + expect(repository.findPendingResultEvents).toHaveBeenCalledTimes(2); + consumer.onModuleDestroy(); + await vi.advanceTimersByTimeAsync(10_000); + expect(repository.findPendingResultEvents).toHaveBeenCalledTimes(2); + }); +}); diff --git a/apps/api/src/modules/media/media-task-sse-format.spec.ts b/apps/api/src/modules/media/media-task-sse-format.spec.ts new file mode 100644 index 0000000..a8583b3 --- /dev/null +++ b/apps/api/src/modules/media/media-task-sse-format.spec.ts @@ -0,0 +1,68 @@ +import { describe, expect, it } from 'vitest'; + +import { + formatSseComment, + formatSseDone, + formatSseEvent, + parseTaskEventType, + resolveResumeSeq, +} from './media-task-sse-format'; + +describe('SSE 帧序列化(id 用 task_events.seq)', () => { + it('事件帧:id / event / data 三行 + 空行,data 为单行 JSON', () => { + const frame = formatSseEvent('12', 'progress', { + taskId: 't1', + status: 'running', + note: '多行\n文本', + }); + expect(frame.startsWith('id: 12\nevent: progress\ndata: ')).toBe(true); + expect(frame.endsWith('\n\n')).toBe(true); + // JSON.stringify 转义换行,data 仍是单行 + const dataLine = frame.split('\n')[2] ?? ''; + expect(dataLine.includes('\\n')).toBe(true); + expect(JSON.parse(dataLine.replace('data: ', ''))).toMatchObject({ + taskId: 't1', + status: 'running', + }); + }); + + it('心跳用注释帧,不进入事件流', () => { + expect(formatSseComment('ping')).toBe(': ping\n\n'); + }); + + it('终态 done 帧复用事件格式,id 为最后消费的 seq', () => { + const frame = formatSseDone(42n, { taskId: 't1', status: 'canceled', progress: 100 }); + expect(frame).toContain('id: 42'); + expect(frame).toContain('event: done'); + expect(frame).toContain('"status":"canceled"'); + }); + + it('事件类型只接受 item / progress / done / error', () => { + expect(parseTaskEventType('item')).toBe('item'); + expect(parseTaskEventType('progress')).toBe('progress'); + expect(parseTaskEventType('done')).toBe('done'); + expect(parseTaskEventType('error')).toBe('error'); + expect(() => parseTaskEventType('unknown')).toThrow('任务事件类型非法:unknown'); + }); +}); + +describe('Last-Event-ID 续推解析', () => { + it('缺省 / 空串从 0 开始', () => { + expect(resolveResumeSeq(undefined, undefined)).toEqual({ ok: true, seq: 0n }); + expect(resolveResumeSeq('', undefined)).toEqual({ ok: true, seq: 0n }); + expect(resolveResumeSeq(undefined, '')).toEqual({ ok: true, seq: 0n }); + }); + + it('header 优先于 query,支持数组取第一个', () => { + expect(resolveResumeSeq('7', '3')).toEqual({ ok: true, seq: 7n }); + expect(resolveResumeSeq(['9', '2'], '3')).toEqual({ ok: true, seq: 9n }); + expect(resolveResumeSeq(undefined, '3')).toEqual({ ok: true, seq: 3n }); + }); + + it('非纯数字视为非法(40001 由 Service 映射)', () => { + expect(resolveResumeSeq('-1', undefined)).toEqual({ ok: false }); + expect(resolveResumeSeq('abc', undefined)).toEqual({ ok: false }); + expect(resolveResumeSeq('1.5', undefined)).toEqual({ ok: false }); + expect(resolveResumeSeq('123456789012345678901', undefined)).toEqual({ ok: false }); + }); +}); diff --git a/apps/api/src/modules/media/media-task-sse-format.ts b/apps/api/src/modules/media/media-task-sse-format.ts new file mode 100644 index 0000000..b97e28f --- /dev/null +++ b/apps/api/src/modules/media/media-task-sse-format.ts @@ -0,0 +1,52 @@ +/** + * SSE 文本帧与断点续推解析(A6-BE-03 / 技术架构 §6.4) + * + * 帧格式(与 EventSource 规范一致): + * id: + * 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); +} diff --git a/apps/api/src/modules/media/media-task-sse.service.spec.ts b/apps/api/src/modules/media/media-task-sse.service.spec.ts new file mode 100644 index 0000000..6c43f30 --- /dev/null +++ b/apps/api/src/modules/media/media-task-sse.service.spec.ts @@ -0,0 +1,201 @@ +import { ErrorCode } from '@lingxi/shared'; +import type { FastifyReply } from 'fastify'; +import { afterEach, describe, expect, it, vi } from 'vitest'; + +import type { ScopeContext } from '../../common/security/scope/scope-context'; +import type { MediaTaskRepository } from './media-task.repository'; +import { MediaTaskSseService } from './media-task-sse.service'; + +const TASK_ID = '01J9Z000000000000000000001'; +const CUSTOMER_ID = '01J9Z000000000000000000002'; +const USER_ID = '01J9Z000000000000000000003'; + +const SCOPE: ScopeContext = { + userId: USER_ID, + isSuperAdmin: false, + dataScopes: [], + customerIds: [CUSTOMER_ID], +}; + +interface FakeRaw { + writableEnded: boolean; + writeHead: ReturnType; + write: ReturnType; + end: ReturnType; + on: ReturnType; +} + +interface ReplyHarness { + reply: FastifyReply; + raw: FakeRaw; + hijack: ReturnType; + chunks: string[]; + close: () => void; +} + +function replyHarness(): ReplyHarness { + const chunks: string[] = []; + const listeners: (() => void)[] = []; + const raw: FakeRaw = { + writableEnded: false, + writeHead: vi.fn(), + write: vi.fn((chunk: string) => { + chunks.push(chunk); + return true; + }), + end: vi.fn(() => { + raw.writableEnded = true; + }), + on: vi.fn((_event: string, handler: () => void) => { + listeners.push(handler); + return raw; + }), + }; + const hijack = vi.fn(); + return { + reply: { raw, hijack } as unknown as FastifyReply, + raw, + hijack, + chunks, + close: () => { + for (const listener of listeners) listener(); + }, + }; +} + +function serviceWith(repository: { + findTaskForStream: ReturnType; + findTaskEventsAfter: ReturnType; + findTaskStreamStatus: ReturnType; +}): MediaTaskSseService { + return new MediaTaskSseService(repository as unknown as MediaTaskRepository); +} + +function repositoryMock(): { + findTaskForStream: ReturnType; + findTaskEventsAfter: ReturnType; + findTaskStreamStatus: ReturnType; +} { + return { + findTaskForStream: vi.fn().mockResolvedValue({ id: TASK_ID, status: 'running', progress: 40 }), + findTaskEventsAfter: vi.fn().mockResolvedValue([ + { + seq: 1n, + eventType: 'progress', + payloadJson: { + taskId: TASK_ID, + status: 'running', + progress: 40, + itemTotal: 2, + itemSucceeded: 1, + itemFailed: 0, + itemSkipped: 0, + }, + }, + ]), + findTaskStreamStatus: vi + .fn() + .mockResolvedValue({ id: TASK_ID, status: 'running', progress: 40 }), + }; +} + +afterEach(() => { + vi.useRealTimers(); +}); + +describe('SSE 事件流(A6-BE-03)', () => { + it('缺少 ScopeContext → 40302;非法 Last-Event-ID → 40001;任务不存在 → 404(均不升级为流)', async () => { + const repository = repositoryMock(); + const service = serviceWith(repository); + const { reply, hijack } = replyHarness(); + + await expect(service.stream(TASK_ID, {}, null, reply)).rejects.toMatchObject({ + code: ErrorCode.OUT_OF_SCOPE, + }); + await expect( + service.stream(TASK_ID, { lastEventIdHeader: 'not-a-number' }, SCOPE, reply), + ).rejects.toMatchObject({ code: ErrorCode.PARAM_INVALID }); + + repository.findTaskForStream.mockResolvedValueOnce(null); + await expect(service.stream(TASK_ID, {}, SCOPE, reply)).rejects.toMatchObject({ + code: ErrorCode.NOT_FOUND, + }); + expect(hijack).not.toHaveBeenCalled(); + }); + + it('生成中:握手头正确、补发增量事件、不结束;心跳 / 继续轮询可用', async () => { + vi.useFakeTimers(); + const repository = repositoryMock(); + const service = serviceWith(repository); + const harness = replyHarness(); + const done = service.stream(TASK_ID, {}, SCOPE, harness.reply); + await done; + + expect(harness.hijack).toHaveBeenCalledTimes(1); + expect(harness.raw.writeHead).toHaveBeenCalledWith( + 200, + expect.objectContaining({ + 'Content-Type': 'text/event-stream; charset=utf-8', + 'Cache-Control': 'no-cache, no-transform', + Connection: 'keep-alive', + 'X-Accel-Buffering': 'no', + }), + ); + expect(harness.chunks[0]).toContain('id: 1'); + expect(harness.chunks[0]).toContain('event: progress'); + expect(harness.raw.end).not.toHaveBeenCalled(); + expect(repository.findTaskEventsAfter).toHaveBeenCalledWith(TASK_ID, 0n, 200); + + harness.close(); + await vi.advanceTimersByTimeAsync(15_000); + // 关闭后不再写心跳 / 轮询 + const chunkCount = harness.chunks.length; + await vi.advanceTimersByTimeAsync(20_000); + expect(harness.chunks).toHaveLength(chunkCount); + }); + + it('心跳:15s 发送注释帧,800ms 轮询下一批', async () => { + vi.useFakeTimers(); + const repository = repositoryMock(); + repository.findTaskEventsAfter.mockResolvedValue([]); + const service = serviceWith(repository); + const harness = replyHarness(); + const done = service.stream(TASK_ID, {}, SCOPE, harness.reply); + await done; + + await vi.advanceTimersByTimeAsync(800); + expect(repository.findTaskEventsAfter.mock.calls.length).toBeGreaterThanOrEqual(2); + await vi.advanceTimersByTimeAsync(15_000); + expect(harness.chunks.some((chunk) => chunk === ': ping\n\n')).toBe(true); + harness.close(); + }); + + it('终态:补发 done 帧并关闭连接(id = 最后消费的 seq)', async () => { + const repository = repositoryMock(); + repository.findTaskStreamStatus.mockResolvedValueOnce({ + id: TASK_ID, + status: 'canceled', + progress: 100, + }); + const service = serviceWith(repository); + const harness = replyHarness(); + await service.stream(TASK_ID, { lastEventIdQuery: '3' }, SCOPE, harness.reply); + + expect(repository.findTaskEventsAfter).toHaveBeenCalledWith(TASK_ID, 3n, 200); + expect(harness.chunks.some((chunk) => chunk.includes('event: done'))).toBe(true); + expect(harness.chunks.some((chunk) => chunk.includes('id: 1'))).toBe(true); + expect(harness.raw.end).toHaveBeenCalledTimes(1); + }); + + it('轮询异常:补发 error 事件后关闭(前端降级轮询)', async () => { + const repository = repositoryMock(); + repository.findTaskEventsAfter.mockRejectedValueOnce(new Error('db down')); + const service = serviceWith(repository); + const harness = replyHarness(); + await service.stream(TASK_ID, {}, SCOPE, harness.reply); + + expect(harness.chunks.some((chunk) => chunk.includes('event: error'))).toBe(true); + expect(harness.chunks.some((chunk) => chunk.includes('"code":50002'))).toBe(true); + expect(harness.raw.end).toHaveBeenCalledTimes(1); + }); +}); diff --git a/apps/api/src/modules/media/media-task-sse.service.ts b/apps/api/src/modules/media/media-task-sse.service.ts new file mode 100644 index 0000000..c1a172b --- /dev/null +++ b/apps/api/src/modules/media/media-task-sse.service.ts @@ -0,0 +1,158 @@ +/** + * 任务 SSE 推送(A6-BE-03 / 技术架构 §6.4 / §7.3) + * + * - 认证:走标准 Authorization 头(前端用 fetch 流读取,不依赖原生 EventSource); + * - 续推:`Last-Event-ID` 请求头优先,其次 `last_event_id` query,事件 `id` 用 task_events.seq; + * - 进度源:每 800ms 轮询 `task_events`(进度落库,进程重启不丢),每 15s 发 `: ping` 心跳; + * - 收敛:任务离开「排队 / 生成中」后补发 `done` 并关闭;客户端断开时清理定时器。 + */ +import { ErrorCode, isTaskActive } from '@lingxi/shared'; +import { Inject, Injectable, Logger } from '@nestjs/common'; +import type { FastifyReply } from 'fastify'; + +import { AppException } from '../../common/errors/app-exception'; +import type { ScopeContext } from '../../common/security/scope/scope-context'; +import { parseTaskStatus } from './media-task.mapper'; +import { MediaTaskRepository } from './media-task.repository'; +import { + formatSseComment, + formatSseDone, + formatSseEvent, + parseTaskEventType, + resolveResumeSeq, +} from './media-task-sse-format'; + +/** 心跳间隔:反向代理通常 60s 超时,15s 留足余量 */ +export const SSE_HEARTBEAT_INTERVAL_MS = 15_000; +/** 轮询间隔:与契约一致(800ms) */ +export const SSE_POLL_INTERVAL_MS = 800; +/** 单轮最多补发事件数,积压时下一轮继续 */ +export const SSE_EVENT_BATCH_SIZE = 200; + +export interface SseStreamOptions { + lastEventIdHeader?: string | string[]; + lastEventIdQuery?: string; +} + +@Injectable() +export class MediaTaskSseService { + private readonly logger = new Logger('MediaTaskSse'); + + constructor(@Inject(MediaTaskRepository) private readonly repository: MediaTaskRepository) {} + + // eslint-disable-next-line max-lines-per-function -- 事件流生命周期(校验 → 握手 → 心跳 / 轮询 → 收敛 / 清理)集中一处,拆散会丢失清理时机 + async stream( + id: string, + options: SseStreamOptions, + scopeInput: ScopeContext | null, + reply: FastifyReply, + ): Promise { + if (scopeInput === null) { + throw new AppException({ + code: ErrorCode.OUT_OF_SCOPE, + message: '缺少数据范围上下文,已拒绝访问', + }); + } + const resume = resolveResumeSeq(options.lastEventIdHeader, options.lastEventIdQuery); + if (!resume.ok) { + throw new AppException({ + code: ErrorCode.PARAM_INVALID, + message: 'Last-Event-ID / last_event_id 必须是任务事件序号', + }); + } + // 先校验可见性,再切换为流式响应(范围外统一 404,不泄露存在性) + const task = await this.repository.findTaskForStream(id, scopeInput); + if (task === null) { + throw new AppException({ code: ErrorCode.NOT_FOUND, message: '任务不存在或已删除' }); + } + + const raw = reply.raw; + reply.hijack(); + raw.writeHead(200, { + 'Content-Type': 'text/event-stream; charset=utf-8', + 'Cache-Control': 'no-cache, no-transform', + Connection: 'keep-alive', + 'X-Accel-Buffering': 'no', + }); + + let closed = false; + let polling = false; + let cursor = resume.seq; + let lastEventType: string | null = null; + let pollTimer: NodeJS.Timeout | null = null; + let heartbeatTimer: NodeJS.Timeout | null = null; + const stopTimers = (): void => { + if (pollTimer !== null) clearInterval(pollTimer); + if (heartbeatTimer !== null) clearInterval(heartbeatTimer); + }; + const write = (chunk: string): void => { + if (!closed && !raw.writableEnded) raw.write(chunk); + }; + const finish = (): void => { + if (closed) return; + closed = true; + stopTimers(); + if (!raw.writableEnded) raw.end(); + }; + const poll = async (): Promise => { + if (closed || polling) return; + polling = true; + try { + const events = await this.repository.findTaskEventsAfter(id, cursor, SSE_EVENT_BATCH_SIZE); + for (const event of events) { + write( + formatSseEvent( + event.seq.toString(), + parseTaskEventType(event.eventType), + event.payloadJson, + ), + ); + cursor = event.seq; + lastEventType = event.eventType; + } + // 有积压时下一轮继续补发,避免长时间占用事件循环 + if (events.length >= SSE_EVENT_BATCH_SIZE) return; + const statusRow = await this.repository.findTaskStreamStatus(id); + if (statusRow === null) { + finish(); + return; + } + const status = parseTaskStatus(statusRow.status); + if (!isTaskActive(status)) { + // 已落库的 done 事件刚补发过则不重复补发(客户端仍可依赖事件 id 去重) + if (lastEventType !== 'done') { + write(formatSseDone(cursor, { taskId: id, status, progress: statusRow.progress })); + } + finish(); + } + } catch (error) { + this.logger.error( + `任务事件流轮询失败 task=${id} error=${error instanceof Error ? error.message : '未知错误'}`, + ); + write( + formatSseEvent(cursor.toString(), 'error', { + code: ErrorCode.DEPENDENCY_UNAVAILABLE, + message: '任务事件流暂时不可用,请降级为轮询任务详情', + }), + ); + finish(); + } finally { + polling = false; + } + }; + + // 客户端断开(关闭页面 / 网络中断)时清理定时器,避免泄漏 + raw.on('close', () => { + closed = true; + stopTimers(); + }); + heartbeatTimer = setInterval(() => { + write(formatSseComment('ping')); + }, SSE_HEARTBEAT_INTERVAL_MS); + pollTimer = setInterval(() => { + void poll(); + }, SSE_POLL_INTERVAL_MS); + + await poll(); + } +} diff --git a/apps/api/src/modules/media/media-task.controller.ts b/apps/api/src/modules/media/media-task.controller.ts new file mode 100644 index 0000000..0bb2b90 --- /dev/null +++ b/apps/api/src/modules/media/media-task.controller.ts @@ -0,0 +1,168 @@ +/** + * 任务中心接口(A6-BE-04 / A6-BE-03,开发计划 §7.6) + * + * | 方法 | 路径 | 权限 | 说明 | + * | --- | --- | --- | --- | + * | POST | /media-tasks | material:render | 提交(必带 Idempotency-Key) | + * | GET | /media-tasks | material:read | 跨类型任务列表(游标分页 + 筛选) | + * | GET | /media-tasks/:id | material:read | 详情 + 首 100 条任务项 | + * | GET | /media-tasks/:id/events | material:read | SSE 进度(Last-Event-ID 续推) | + * | POST | /media-tasks/:id/cancel | material:render | 取消(只影响未开始项) | + * | POST | /media-tasks/:id/retry | material:render | 仅重试失败项 | + * + * 所有查询都经 ScopeContext 注入 customer_id 过滤;范围外单条资源统一 404(不泄露存在性)。 + */ +import { + ErrorCode, + IDEMPOTENCY_KEY_HEADER, + type MediaTaskCancelResponse, + type MediaTaskDetail, + type MediaTaskDetailQuery, + mediaTaskDetailQuerySchema, + type MediaTaskListQuery, + mediaTaskListQuerySchema, + type MediaTaskListResponse, + type MediaTaskRetryResponse, + type MediaTaskSseQuery, + mediaTaskSseQuerySchema, + type MediaTaskSubmitParsed, + type MediaTaskSubmitResponse, + mediaTaskSubmitSchema, + Permission, +} from '@lingxi/shared'; +import { + Body, + Controller, + Get, + Headers, + HttpCode, + Inject, + Param, + Post, + Query, + Res, +} from '@nestjs/common'; +import { ApiBearerAuth, ApiOperation, ApiProduces, ApiTags } from '@nestjs/swagger'; +import type { FastifyReply } from 'fastify'; + +import { AppException } from '../../common/errors/app-exception'; +import { ZodValidationPipe } from '../../common/pipes/zod-validation.pipe'; +import type { AuthContext } from '../../common/security/auth.types'; +import { + Audit, + CurrentScope, + CurrentUser, + RequirePermissions, +} from '../../common/security/decorators'; +import type { ScopeContext } from '../../common/security/scope/scope-context'; +import { MediaTaskService } from './media-task.service'; +import { MediaTaskSseService } from './media-task-sse.service'; + +function requireAuth(auth: AuthContext | null): AuthContext { + if (auth === null) { + throw new AppException({ code: ErrorCode.UNAUTHENTICATED }); + } + return auth; +} + +@ApiTags('media-tasks') +@ApiBearerAuth() +@Controller('media-tasks') +export class MediaTaskController { + constructor( + @Inject(MediaTaskService) private readonly tasks: MediaTaskService, + @Inject(MediaTaskSseService) private readonly sse: MediaTaskSseService, + ) {} + + @Post() + @HttpCode(202) + @RequirePermissions(Permission.MATERIAL_RENDER) + @Audit('task.submit') + @ApiOperation({ + summary: + '提交任务(异步 202;M0 仅演练任务;必带 Idempotency-Key,同 key 同请求体 24h 内返回同一任务)', + }) + submit( + @Body(new ZodValidationPipe(mediaTaskSubmitSchema)) input: MediaTaskSubmitParsed, + @Headers(IDEMPOTENCY_KEY_HEADER.toLowerCase()) idempotencyKey: string | string[] | undefined, + @CurrentUser() auth: AuthContext | null, + @CurrentScope() scope: ScopeContext | null, + ): Promise { + return this.tasks.submit(input, idempotencyKey, requireAuth(auth), scope); + } + + @Get() + @RequirePermissions(Permission.MATERIAL_READ) + @ApiOperation({ + summary: + '任务列表(类型 / 状态 / 客户 / 时间 / 我的 / 关键字筛选;稳定排序 createdAt DESC, id DESC)', + }) + list( + @Query(new ZodValidationPipe(mediaTaskListQuerySchema)) query: MediaTaskListQuery, + @CurrentUser() auth: AuthContext | null, + @CurrentScope() scope: ScopeContext | null, + ): Promise { + return this.tasks.list(query, scope, requireAuth(auth)); + } + + @Get(':id/events') + @RequirePermissions(Permission.MATERIAL_READ) + @ApiProduces('text/event-stream') + @ApiOperation({ + summary: + '任务 SSE 进度(id 用 task_events.seq;支持 Last-Event-ID / last_event_id 续推,15s 心跳)', + }) + events( + @Param('id') id: string, + @Headers('last-event-id') lastEventIdHeader: string | string[] | undefined, + @Query(new ZodValidationPipe(mediaTaskSseQuerySchema)) query: MediaTaskSseQuery, + @CurrentScope() scope: ScopeContext | null, + @Res() reply: FastifyReply, + ): Promise { + return this.sse.stream( + id, + { + lastEventIdHeader, + ...(query.last_event_id === undefined ? {} : { lastEventIdQuery: query.last_event_id }), + }, + scope, + reply, + ); + } + + @Get(':id') + @RequirePermissions(Permission.MATERIAL_READ) + @ApiOperation({ summary: '任务详情(首 100 条任务项 + itemsNextCursor;范围外 404)' }) + detail( + @Param('id') id: string, + @Query(new ZodValidationPipe(mediaTaskDetailQuerySchema)) query: MediaTaskDetailQuery, + @CurrentScope() scope: ScopeContext | null, + ): Promise { + return this.tasks.detail(id, query, scope); + } + + @Post(':id/cancel') + @RequirePermissions(Permission.MATERIAL_RENDER) + @Audit('task.cancel') + @ApiOperation({ + summary: '取消任务(仅排队 / 生成中;只把未开始项置 skipped,已成功项不受影响)', + }) + cancel( + @Param('id') id: string, + @CurrentUser() auth: AuthContext | null, + @CurrentScope() scope: ScopeContext | null, + ): Promise { + return this.tasks.cancel(id, scope, requireAuth(auth)); + } + + @Post(':id/retry') + @RequirePermissions(Permission.MATERIAL_RENDER) + @Audit('task.retry') + @ApiOperation({ summary: '重试任务(仅部分失败;只重置失败项,成功项不动)' }) + retry( + @Param('id') id: string, + @CurrentScope() scope: ScopeContext | null, + ): Promise { + return this.tasks.retry(id, scope); + } +} diff --git a/apps/api/src/modules/media/media-task.mapper.spec.ts b/apps/api/src/modules/media/media-task.mapper.spec.ts new file mode 100644 index 0000000..bda2b84 --- /dev/null +++ b/apps/api/src/modules/media/media-task.mapper.spec.ts @@ -0,0 +1,131 @@ +import { Prisma } from '@prisma/client'; +import { describe, expect, it } from 'vitest'; + +import { + type MediaTaskDetailRow, + type MediaTaskSummaryRow, + parseTaskItemStatus, + parseTaskStatus, + parseTaskType, + toJsonObject, + toMediaTaskDetail, + toMediaTaskItem, + toMediaTaskSummary, +} from './media-task.mapper'; + +const AT = new Date('2026-11-04T12:00:00.000Z'); +const ID = '01J9Z000000000000000000001'; + +function summaryRow(overrides: Record = {}): MediaTaskSummaryRow { + return { + id: ID, + type: 'demo', + status: 'partial_failed', + title: '演练任务 · 花间集 · 5 项', + customerId: '01J9Z000000000000000000002', + customerName: '花间集', + itemTotal: 5, + itemSucceeded: 3, + itemFailed: 2, + itemSkipped: 0, + progress: 100, + estimatedItems: 5, + estimatedCost: new Prisma.Decimal('0.1000'), + estimatedSeconds: 15, + createdBy: '01J9Z000000000000000000003', + createdByName: '陈墨', + createdAt: AT, + startedAt: null, + finishedAt: null, + ...overrides, + }; +} + +describe('任务行映射(Decimal → number / 可选时间 / code)', () => { + it('摘要:金额转 number,空的可选时间不返回', () => { + const summary = toMediaTaskSummary(summaryRow()); + expect(summary.estimatedCost).toBe(0.1); + expect(summary.type).toBe('demo'); + expect(summary.status).toBe('partial_failed'); + expect(summary.startedAt).toBeUndefined(); + expect(summary.finishedAt).toBeUndefined(); + }); + + it('摘要:起始 / 结束时间存在时返回 ISO 字符串', () => { + const summary = toMediaTaskSummary(summaryRow({ startedAt: AT, finishedAt: AT })); + expect(summary.startedAt).toBe(AT.toISOString()); + expect(summary.finishedAt).toBe(AT.toISOString()); + }); + + it('未知类型 / 状态视为脏数据抛错(不静默降级)', () => { + expect(() => toMediaTaskSummary(summaryRow({ type: 'unknown' }))).toThrow('任务类型非法'); + expect(() => toMediaTaskSummary(summaryRow({ status: 'unknown' }))).toThrow('任务状态非法'); + expect(() => parseTaskItemStatus('unknown')).toThrow('任务项状态非法'); + expect(parseTaskType('video')).toBe('video'); + expect(parseTaskStatus('archived')).toBe('archived'); + }); + + it('任务项:失败原因可选,attempts 保留', () => { + const item = toMediaTaskItem({ + id: ID, + seq: 2, + status: 'failed', + failureCode: 'DEMO_FAIL', + failureMessage: '演练失败', + attempts: 3, + startedAt: AT, + finishedAt: AT, + }); + expect(item).toMatchObject({ seq: 2, status: 'failed', attempts: 3, failureCode: 'DEMO_FAIL' }); + const queued = toMediaTaskItem({ + id: ID, + seq: 1, + status: 'queued', + failureCode: null, + failureMessage: null, + attempts: 0, + startedAt: null, + finishedAt: null, + }); + expect(queued.failureCode).toBeUndefined(); + expect(queued.failureMessage).toBeUndefined(); + expect(queued.startedAt).toBeUndefined(); + }); + + it('详情:payload 对象 / 取消请求时间 / 最后一次错误 + items', () => { + const detailRow = summaryRow({ + payloadJson: { mode: 'mixed', note: null }, + cancelRequestedAt: AT, + lastErrorCode: 'ITEM_FAILED', + lastErrorMessage: '2 项失败', + }) as unknown as MediaTaskDetailRow; + const detail = toMediaTaskDetail( + detailRow, + [ + { + id: ID, + seq: 1, + status: 'failed', + failureCode: 'DEMO_FAIL', + failureMessage: '演练失败', + attempts: 1, + startedAt: null, + finishedAt: AT, + }, + ], + 'item-cursor', + ); + expect(detail.payload).toEqual({ mode: 'mixed', note: null }); + expect(detail.cancelRequestedAt).toBe(AT.toISOString()); + expect(detail.lastErrorCode).toBe('ITEM_FAILED'); + expect(detail.items).toHaveLength(1); + expect(detail.itemsNextCursor).toBe('item-cursor'); + }); + + it('异常 JsonB 结构回退空对象(详情不 500)', () => { + expect(toJsonObject(null)).toEqual({}); + expect(toJsonObject([1, 2])).toEqual({}); + expect(toJsonObject('text')).toEqual({}); + expect(toJsonObject({ a: 1 })).toEqual({ a: 1 }); + }); +}); diff --git a/apps/api/src/modules/media/media-task.mapper.ts b/apps/api/src/modules/media/media-task.mapper.ts new file mode 100644 index 0000000..ec0478f --- /dev/null +++ b/apps/api/src/modules/media/media-task.mapper.ts @@ -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 { + 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, + }; +} diff --git a/apps/api/src/modules/media/media-task.repository.spec.ts b/apps/api/src/modules/media/media-task.repository.spec.ts new file mode 100644 index 0000000..74be551 --- /dev/null +++ b/apps/api/src/modules/media/media-task.repository.spec.ts @@ -0,0 +1,530 @@ +import { ErrorCode, TASK_OUTBOX_EVENTS } from '@lingxi/shared'; +import { Prisma } from '@prisma/client'; +import { describe, expect, it, vi } from 'vitest'; + +import { AppException } from '../../common/errors/app-exception'; +import type { ScopeContext } from '../../common/security/scope/scope-context'; +import type { PrismaService } from '../../infra/prisma/prisma.service'; +import type { MediaTaskHeaderRow } from './media-task.repository'; +import { + isUniqueViolation, + MediaTaskRepository, + type MediaTaskSubmitTransactionInput, +} from './media-task.repository'; + +const NOW = new Date('2026-11-04T12:00:00.000Z'); +const TASK_ID = '01J9Z000000000000000000001'; +const CUSTOMER_ID = '01J9Z000000000000000000002'; +const USER_ID = '01J9Z000000000000000000003'; +const ITEM_ID_1 = '01J9Z000000000000000000004'; +const ITEM_ID_2 = '01J9Z000000000000000000005'; + +const SCOPE: ScopeContext = { + userId: USER_ID, + isSuperAdmin: false, + dataScopes: [], + customerIds: [CUSTOMER_ID], +}; + +interface PrismaMock { + mediaTask: Record>; + mediaItem: Record>; + taskEvent: Record>; + outboxEvent: Record>; + idempotencyKey: Record>; + $transaction: ReturnType; +} + +function prismaMock(): PrismaMock { + const mock = { + mediaTask: { + findMany: vi.fn().mockResolvedValue([]), + findFirst: vi.fn().mockResolvedValue(null), + findUnique: vi.fn().mockResolvedValue(null), + create: vi.fn().mockResolvedValue({}), + update: vi.fn().mockResolvedValue({}), + updateMany: vi.fn().mockResolvedValue({ count: 1 }), + groupBy: vi.fn().mockResolvedValue([]), + count: vi.fn().mockResolvedValue(0), + }, + mediaItem: { + createMany: vi.fn().mockResolvedValue({ count: 0 }), + updateMany: vi.fn().mockResolvedValue({ count: 0 }), + findMany: vi.fn().mockResolvedValue([]), + groupBy: vi.fn().mockResolvedValue([]), + count: vi.fn().mockResolvedValue(0), + }, + taskEvent: { + create: vi.fn().mockResolvedValue({}), + createMany: vi.fn().mockResolvedValue({ count: 0 }), + findMany: vi.fn().mockResolvedValue([]), + }, + outboxEvent: { + create: vi.fn().mockResolvedValue({}), + findMany: vi.fn().mockResolvedValue([]), + updateMany: vi.fn().mockResolvedValue({ count: 1 }), + }, + idempotencyKey: { + findUnique: vi.fn().mockResolvedValue(null), + delete: vi.fn().mockResolvedValue({}), + create: vi.fn().mockResolvedValue({}), + }, + $transaction: vi.fn(), + }; + mock.$transaction.mockImplementation(async (callback: unknown): Promise => { + if (typeof callback === 'function') { + return (callback as (client: PrismaMock) => Promise)(mock); + } + return Promise.all(callback as Promise[]); + }); + return mock; +} + +function harness(): { repository: MediaTaskRepository; prisma: PrismaMock } { + const prisma = prismaMock(); + return { + repository: new MediaTaskRepository(prisma as unknown as PrismaService), + prisma, + }; +} + +function submitInput(): MediaTaskSubmitTransactionInput { + return { + task: { + id: TASK_ID, + type: 'demo', + title: '演练任务 · 花间集 · 2 项', + customerId: CUSTOMER_ID, + customerName: '花间集', + createdBy: USER_ID, + createdByName: '陈墨', + itemTotal: 2, + estimatedItems: 2, + estimatedCost: 0.04, + estimatedSeconds: 6, + payload: { type: 'demo', mode: 'mixed', itemCount: 2, note: null }, + }, + itemIds: [ITEM_ID_1, ITEM_ID_2], + idempotency: { + id: 'k1', + scope: 'media.tasks.submit', + key: 'idem-key-0001', + requestHash: 'hash', + response: { taskId: TASK_ID, estimatedItems: 2, estimatedCost: 0.04, estimatedSeconds: 6 }, + expiresAt: new Date(NOW.getTime() + 86_400_000), + }, + traceId: 'trace-1', + now: NOW, + }; +} + +function headerRow(overrides: Record = {}): MediaTaskHeaderRow { + return { + id: TASK_ID, + type: 'demo', + status: 'queued', + title: '演练任务 · 花间集 · 2 项', + customerId: CUSTOMER_ID, + customerName: '花间集', + createdBy: USER_ID, + createdByName: '陈墨', + itemTotal: 2, + ...overrides, + }; +} + +async function expectRejected(promise: Promise, code: ErrorCode): Promise { + try { + await promise; + } catch (error) { + expect(error).toBeInstanceOf(AppException); + expect((error as AppException).code).toBe(code); + return; + } + throw new Error('预期抛出 AppException'); +} + +describe('任务列表 / 详情查询(ScopeContext 强制注入 customer_id)', () => { + it('列表 where 全部下推到 SQL:范围 / 筛选 / 时间 / 关键字 / keyset 游标', async () => { + const { repository, prisma } = harness(); + await repository.listTasks( + { + type: 'demo', + status: 'partial_failed', + customer_id: CUSTOMER_ID, + created_from: NOW.toISOString(), + created_to: NOW.toISOString(), + mine: 'true', + keyword: '演练', + limit: 20, + }, + SCOPE, + USER_ID, + { at: NOW, id: TASK_ID }, + ); + const args = prisma.mediaTask.findMany?.mock.calls[0]?.[0] as { + where: { AND: Record[] }; + orderBy: unknown; + take: number; + }; + expect(args.take).toBe(21); + expect(args.orderBy).toEqual([{ createdAt: 'desc' }, { id: 'desc' }]); + expect(args.where.AND).toContainEqual({ customerId: { in: [CUSTOMER_ID] } }); + expect(args.where.AND).toContainEqual({ type: 'demo' }); + expect(args.where.AND).toContainEqual({ status: 'partial_failed' }); + expect(args.where.AND).toContainEqual({ createdBy: USER_ID }); + expect(args.where.AND).toContainEqual( + expect.objectContaining({ + OR: [{ createdAt: { lt: NOW } }, { createdAt: NOW, id: { lt: TASK_ID } }], + }), + ); + expect(JSON.stringify(args.where.AND)).toContain('"mode":"insensitive"'); + }); + + it('全部客户可见时不注入 in 过滤', async () => { + const { repository, prisma } = harness(); + await repository.listTasks({ limit: 20 }, { ...SCOPE, customerIds: null }, USER_ID, null); + const args = prisma.mediaTask.findMany?.mock.calls[0]?.[0] as { + where: { AND: Record[] }; + }; + expect(args.where.AND[0]).toEqual({}); + }); + + it('详情:任务不存在返回 null;存在时按 seq 续拉并返回 hasMore', async () => { + const { repository, prisma } = harness(); + await expect(repository.findTaskDetail(TASK_ID, SCOPE, null, 2)).resolves.toBeNull(); + + prisma.mediaTask.findFirst?.mockResolvedValueOnce({ id: TASK_ID }); + prisma.mediaItem.findMany?.mockResolvedValueOnce([ + { id: ITEM_ID_1, seq: 1 }, + { id: ITEM_ID_2, seq: 2 }, + { id: '01J9Z000000000000000000006', seq: 3 }, + ]); + const found = await repository.findTaskDetail(TASK_ID, SCOPE, 1, 2); + expect(found?.items).toHaveLength(2); + expect(found?.hasMore).toBe(true); + const itemArgs = prisma.mediaItem.findMany?.mock.calls[0]?.[0] as { + where: { taskId: string; seq?: { gt: number } }; + take: number; + }; + expect(itemArgs.take).toBe(3); + expect(itemArgs.where).toEqual({ taskId: TASK_ID, seq: { gt: 1 } }); + }); +}); + +describe('幂等键与提交事务', () => { + it('replayResponse:同哈希返回首次响应;不同哈希 / 半成品响应 40902', () => { + const { repository } = harness(); + const existing = { + id: 'k1', + scope: 'media.tasks.submit', + key: 'idem-key-0001', + requestHash: 'hash', + responseStatus: 202, + responseBody: { + taskId: TASK_ID, + estimatedItems: 2, + estimatedCost: 0.04, + estimatedSeconds: 6, + }, + createdAt: NOW, + expiresAt: new Date(NOW.getTime() + 60_000), + }; + expect(repository.replayResponse(existing, 'hash')).toEqual({ + taskId: TASK_ID, + estimatedItems: 2, + estimatedCost: 0.04, + estimatedSeconds: 6, + }); + expect(() => repository.replayResponse(existing, 'other-hash')).toThrow(AppException); + expect(() => repository.replayResponse({ ...existing, responseBody: null }, 'hash')).toThrow( + AppException, + ); + }); + + it('提交:无幂等记录时写入任务头 / 任务项 / progress 事件 / created outbox / 幂等键', async () => { + const { repository, prisma } = harness(); + const result = await repository.submitTask(submitInput()); + expect(result).toEqual({ + kind: 'created', + response: { taskId: TASK_ID, estimatedItems: 2, estimatedCost: 0.04, estimatedSeconds: 6 }, + }); + expect(prisma.mediaTask.create).toHaveBeenCalledTimes(1); + const taskData = prisma.mediaTask.create?.mock.calls[0]?.[0] as { + data: { status: string; estimatedCost: Prisma.Decimal; progress: number }; + }; + expect(taskData.data.status).toBe('queued'); + expect(taskData.data.progress).toBe(0); + expect(taskData.data.estimatedCost.toNumber()).toBe(0.04); + + const items = prisma.mediaItem.createMany?.mock.calls[0]?.[0] as { + data: { id: string; seq: number; status: string }[]; + }; + expect(items.data).toEqual([ + { id: ITEM_ID_1, taskId: TASK_ID, seq: 1, status: 'queued' }, + { id: ITEM_ID_2, taskId: TASK_ID, seq: 2, status: 'queued' }, + ]); + expect(prisma.taskEvent.create).toHaveBeenCalledTimes(1); + const event = prisma.taskEvent.create?.mock.calls[0]?.[0] as { + data: { eventType: string; payloadJson: Record }; + }; + expect(event.data.eventType).toBe('progress'); + expect(event.data.payloadJson).toMatchObject({ status: 'queued', progress: 0, itemTotal: 2 }); + + const outbox = prisma.outboxEvent.create?.mock.calls[0]?.[0] as { + data: { eventType: string; payloadJson: Record }; + }; + expect(outbox.data.eventType).toBe(TASK_OUTBOX_EVENTS.created); + expect(outbox.data.payloadJson).toMatchObject({ + taskId: TASK_ID, + type: 'demo', + itemIds: [ITEM_ID_1, ITEM_ID_2], + }); + expect(prisma.idempotencyKey.create).toHaveBeenCalledTimes(1); + }); + + it('提交:24h 内已有同 key → 返回重放,不再建任务', async () => { + const { repository, prisma } = harness(); + prisma.idempotencyKey.findUnique?.mockResolvedValueOnce({ + id: 'k1', + requestHash: 'hash', + responseBody: { + taskId: TASK_ID, + estimatedItems: 2, + estimatedCost: 0.04, + estimatedSeconds: 6, + }, + expiresAt: new Date(NOW.getTime() + 60_000), + }); + const result = await repository.submitTask(submitInput()); + expect(result.kind).toBe('replayed'); + expect(prisma.mediaTask.create).not.toHaveBeenCalled(); + }); + + it('提交:过期幂等键先删除再按新请求重建', async () => { + const { repository, prisma } = harness(); + prisma.idempotencyKey.findUnique?.mockResolvedValueOnce({ + id: 'k-old', + requestHash: 'old', + responseBody: null, + expiresAt: new Date(NOW.getTime() - 1), + }); + const result = await repository.submitTask(submitInput()); + expect(result.kind).toBe('created'); + expect(prisma.idempotencyKey.delete).toHaveBeenCalledWith({ where: { id: 'k-old' } }); + }); + + it('isUniqueViolation 只识别 Prisma P2002', () => { + expect( + isUniqueViolation( + new Prisma.PrismaClientKnownRequestError('dup', { + code: 'P2002', + clientVersion: '6.19.3', + }), + ), + ).toBe(true); + expect( + isUniqueViolation( + new Prisma.PrismaClientKnownRequestError('fk', { + code: 'P2003', + clientVersion: '6.19.3', + }), + ), + ).toBe(false); + expect(isUniqueViolation(new Error('x'))).toBe(false); + }); +}); + +describe('取消事务(只影响未开始项、无 running 项立即收敛)', () => { + it('条件认领失败(并发 / 状态已变)→ 40903', async () => { + const { repository, prisma } = harness(); + prisma.mediaTask.updateMany?.mockResolvedValueOnce({ count: 0 }); + await expectRejected( + repository.cancelTask({ task: headerRow(), actorId: USER_ID, now: NOW }), + ErrorCode.STATE_NOT_ALLOWED, + ); + }); + + it('排队任务取消:queued 项置 skipped,立即 canceled + done 事件 + canceled outbox', async () => { + const { repository, prisma } = harness(); + prisma.mediaItem.findMany?.mockResolvedValueOnce([ + { id: ITEM_ID_1, seq: 1 }, + { id: ITEM_ID_2, seq: 2 }, + ]); + prisma.mediaItem.groupBy?.mockResolvedValueOnce([{ status: 'skipped', _count: { _all: 2 } }]); + prisma.mediaItem.count?.mockResolvedValueOnce(0); + + const result = await repository.cancelTask({ + task: headerRow(), + actorId: USER_ID, + now: NOW, + }); + expect(result.status).toBe('canceled'); + expect(result.skippedItems).toHaveLength(2); + + const taskUpdate = prisma.mediaTask.update?.mock.calls[0]?.[0] as { + data: Record; + }; + expect(taskUpdate.data).toMatchObject({ + status: 'canceled', + itemSkipped: 2, + progress: 100, + canceledAt: NOW, + finishedAt: NOW, + }); + // 2 条 skip item 事件 + 1 条 progress + 1 条 done + expect(prisma.taskEvent.createMany).toHaveBeenCalledTimes(1); + expect(prisma.taskEvent.create).toHaveBeenCalledTimes(2); + const doneEvent = prisma.taskEvent.create?.mock.calls[1]?.[0] as { + data: { eventType: string; payloadJson: Record }; + }; + expect(doneEvent.data.eventType).toBe('done'); + expect(doneEvent.data.payloadJson).toMatchObject({ status: 'canceled', progress: 100 }); + + const outbox = prisma.outboxEvent.create?.mock.calls[0]?.[0] as { + data: { eventType: string; payloadJson: Record }; + }; + expect(outbox.data.eventType).toBe(TASK_OUTBOX_EVENTS.canceled); + expect(outbox.data.payloadJson).toMatchObject({ taskId: TASK_ID, canceledBy: USER_ID }); + }); + + it('有 running 项时不落终态、不写 canceled outbox(等 worker 收敛)', async () => { + const { repository, prisma } = harness(); + prisma.mediaItem.findMany?.mockResolvedValueOnce([]); + prisma.mediaItem.groupBy?.mockResolvedValueOnce([ + { status: 'running', _count: { _all: 1 } }, + { status: 'pending_review', _count: { _all: 1 } }, + ]); + prisma.mediaItem.count?.mockResolvedValueOnce(1); + + const result = await repository.cancelTask({ + task: headerRow({ status: 'running' }), + actorId: USER_ID, + now: NOW, + }); + expect(result.status).toBe('running'); + const taskUpdate = prisma.mediaTask.update?.mock.calls[0]?.[0] as { + data: Record; + }; + expect(taskUpdate.data.status).toBeUndefined(); + expect(prisma.taskEvent.create).toHaveBeenCalledTimes(1); + expect(prisma.outboxEvent.create).not.toHaveBeenCalled(); + }); +}); + +describe('重试事务(只重置失败项,成功项不动)', () => { + it('非部分失败 / 没有失败项 → 40903', async () => { + const { repository, prisma } = harness(); + prisma.mediaTask.updateMany?.mockResolvedValueOnce({ count: 0 }); + await expectRejected( + repository.retryTask(headerRow({ status: 'running' }), NOW), + ErrorCode.STATE_NOT_ALLOWED, + ); + + prisma.mediaTask.updateMany?.mockResolvedValueOnce({ count: 1 }); + prisma.mediaItem.findMany?.mockResolvedValueOnce([]); + await expectRejected( + repository.retryTask(headerRow({ status: 'partial_failed' }), NOW), + ErrorCode.STATE_NOT_ALLOWED, + ); + }); + + it('只把 failed 项重置为 queued(attempts 保留),写 progress 事件 + retry outbox', async () => { + const { repository, prisma } = harness(); + prisma.mediaItem.findMany?.mockResolvedValueOnce([{ id: ITEM_ID_1, seq: 1 }]); + prisma.mediaItem.groupBy?.mockResolvedValueOnce([ + { status: 'queued', _count: { _all: 1 } }, + { status: 'pending_review', _count: { _all: 1 } }, + ]); + + const result = await repository.retryTask(headerRow({ status: 'partial_failed' }), NOW); + expect(result).toEqual({ status: 'queued', retriedItems: 1 }); + + const taskUpdate = prisma.mediaTask.updateMany?.mock.calls[0]?.[0] as { + where: Record; + data: Record; + }; + expect(taskUpdate.where).toEqual({ id: TASK_ID, status: 'partial_failed' }); + expect(taskUpdate.data).toMatchObject({ status: 'queued', finishedAt: null }); + + const itemReset = prisma.mediaItem.updateMany?.mock.calls[0]?.[0] as { + where: Record; + data: Record; + }; + expect(itemReset.where).toEqual({ taskId: TASK_ID, status: 'failed' }); + expect(itemReset.data).toMatchObject({ status: 'queued', failureCode: null }); + // attempts 不在重置字段中(保留尝试次数) + expect(itemReset.data).not.toHaveProperty('attempts'); + + const outbox = prisma.outboxEvent.create?.mock.calls[0]?.[0] as { + data: { eventType: string; payloadJson: Record }; + }; + expect(outbox.data.eventType).toBe(TASK_OUTBOX_EVENTS.retry); + expect(outbox.data.payloadJson).toMatchObject({ itemIds: [ITEM_ID_1] }); + }); +}); + +describe('SSE 查询与 Outbox 结果消费数据访问', () => { + it('SSE:任务可见性 / 增量事件 / 状态轮询', async () => { + const { repository, prisma } = harness(); + await repository.findTaskForStream(TASK_ID, SCOPE); + expect(prisma.mediaTask.findFirst?.mock.calls[0]?.[0]).toMatchObject({ + where: { id: TASK_ID, customerId: { in: [CUSTOMER_ID] } }, + }); + + await repository.findTaskEventsAfter(TASK_ID, 5n, 200); + expect(prisma.taskEvent.findMany?.mock.calls[0]?.[0]).toMatchObject({ + where: { taskId: TASK_ID, seq: { gt: 5n } }, + orderBy: { seq: 'asc' }, + take: 200, + }); + + await repository.findTaskStreamStatus(TASK_ID); + expect(prisma.mediaTask.findUnique?.mock.calls[0]?.[0]).toMatchObject({ + where: { id: TASK_ID }, + }); + }); + + it('结果消费:只查本地事件类型 + 未投递;认领成功 / 失败;回滚与放弃', async () => { + const { repository, prisma } = harness(); + await repository.findPendingResultEvents(10); + const findArgs = prisma.outboxEvent.findMany?.mock.calls[0]?.[0] as { + where: { eventType: { in: string[] }; publishedAt: null }; + take: number; + }; + expect(findArgs.take).toBe(10); + expect(findArgs.where.publishedAt).toBeNull(); + expect(findArgs.where.eventType.in).toEqual([ + TASK_OUTBOX_EVENTS.finished, + TASK_OUTBOX_EVENTS.failed, + ]); + + prisma.outboxEvent.updateMany?.mockResolvedValueOnce({ count: 1 }); + await expect(repository.claimResultEvent('evt-1')).resolves.toBe(true); + expect(prisma.outboxEvent.updateMany?.mock.calls[0]?.[0]).toMatchObject({ + where: { id: 'evt-1', publishedAt: null }, + data: { attempts: { increment: 1 } }, + }); + + prisma.outboxEvent.updateMany?.mockResolvedValueOnce({ count: 0 }); + await expect(repository.claimResultEvent('evt-1')).resolves.toBe(false); + + await repository.markResultEventRetry('evt-1', 'x'.repeat(1200)); + const retryArgs = prisma.outboxEvent.updateMany?.mock.calls[2]?.[0] as { + data: { publishedAt: null; lastError: string }; + }; + expect(retryArgs.data.publishedAt).toBeNull(); + expect(retryArgs.data.lastError).toHaveLength(1000); + + await repository.markResultEventExhausted('evt-1', 'boom'); + expect(prisma.outboxEvent.updateMany?.mock.calls[3]?.[0]).toMatchObject({ + where: { id: 'evt-1' }, + data: { lastError: 'boom' }, + }); + + await repository.findTaskForResult(TASK_ID); + expect(prisma.mediaTask.findUnique?.mock.calls[0]?.[0]).toMatchObject({ + where: { id: TASK_ID }, + }); + }); +}); diff --git a/apps/api/src/modules/media/media-task.repository.ts b/apps/api/src/modules/media/media-task.repository.ts new file mode 100644 index 0000000..7a4659f --- /dev/null +++ b/apps/api/src/modules/media/media-task.repository.ts @@ -0,0 +1,660 @@ +/* eslint-disable max-lines-per-function -- 任务事务(提交 / 取消 / 重试)必须保持单事务边界,拆散会丢失原子性 */ +/** + * 任务中心数据访问(A6-BE-01 / A6-BE-03 / A6-BE-04) + * + * 约束(AGENTS §3.5 / DB-05): + * - 面向用户的读写方法都接收 `ScopeContext` 并把 `customer_id` 过滤注入 SQL where; + * - 禁止「先查全量再内存过滤」;范围外单条读取返回 null,由 Service 统一映射 404(不泄露存在性); + * - 写操作(提交 / 取消 / 重试)在单事务内完成「任务头 + 任务项 + 任务事件 + outbox + 幂等键」, + * 事务回滚不产生事件(技术架构 §3.7); + * - 结果消费者是系统身份(无用户 ScopeContext),按 outbox 事件里的 aggregateId 精确读取, + * 只用于写审计,不对外暴露数据。 + */ +import { + ErrorCode, + type MediaTaskListQuery, + type MediaTaskSubmitResponse, + mediaTaskSubmitResponseSchema, + TASK_LOCAL_EVENT_TYPES, + TASK_OUTBOX_EVENTS, + type TaskCounters, + taskProgress, + type TaskStatus, + type TaskType, + ulid, +} from '@lingxi/shared'; +import { Inject, Injectable } from '@nestjs/common'; +import { type IdempotencyKey, Prisma } from '@prisma/client'; + +import { AppException } from '../../common/errors/app-exception'; +import { toPrismaJsonValue } from '../../common/security/audit-sanitize'; +import { customerScopeWhere, type ScopeContext } from '../../common/security/scope/scope-context'; +import type { PrismaService } from '../../infra/prisma/prisma.service'; +import { PRISMA } from '../../infra/tokens'; +import { + MEDIA_ITEM_SELECT, + MEDIA_TASK_DETAIL_SELECT, + MEDIA_TASK_SUMMARY_SELECT, + type MediaItemRow, + type MediaTaskDetailRow, + type MediaTaskSummaryRow, +} from './media-task.mapper'; +import type { MediaTaskCursor } from './media-task-cursor'; + +/** Prisma 唯一约束冲突(幂等键并发提交时的兜底分支) */ +export function isUniqueViolation(error: unknown): boolean { + return error instanceof Prisma.PrismaClientKnownRequestError && error.code === 'P2002'; +} + +/** 取消 / 重试需要的最小任务头(不含 payload 大字段) */ +export const MEDIA_TASK_HEADER_SELECT = { + id: true, + type: true, + status: true, + title: true, + customerId: true, + customerName: true, + createdBy: true, + createdByName: true, + itemTotal: true, +} satisfies Prisma.MediaTaskSelect; + +export type MediaTaskHeaderRow = Prisma.MediaTaskGetPayload<{ + select: typeof MEDIA_TASK_HEADER_SELECT; +}>; + +/** 结果审计需要的最小任务头 */ +export const MEDIA_TASK_RESULT_SELECT = { + id: true, + type: true, + status: true, + title: true, + customerId: true, + customerName: true, + createdBy: true, + createdByName: true, + progress: true, + lastErrorCode: true, + lastErrorMessage: true, +} satisfies Prisma.MediaTaskSelect; + +export type MediaTaskResultRow = Prisma.MediaTaskGetPayload<{ + select: typeof MEDIA_TASK_RESULT_SELECT; +}>; + +export interface MediaTaskSubmitTransactionInput { + task: { + id: string; + type: TaskType; + title: string; + customerId: string; + customerName: string; + createdBy: string; + createdByName: string; + itemTotal: number; + estimatedItems: number; + estimatedCost: number; + estimatedSeconds: number; + payload: Record; + }; + itemIds: readonly string[]; + idempotency: { + id: string; + scope: string; + key: string; + requestHash: string; + response: MediaTaskSubmitResponse; + expiresAt: Date; + }; + traceId: string | null; + now: Date; +} + +export type MediaTaskSubmitTransactionResult = + | { kind: 'created'; response: MediaTaskSubmitResponse } + | { kind: 'replayed'; response: MediaTaskSubmitResponse }; + +export interface CancelTaskInput { + task: MediaTaskHeaderRow; + actorId: string; + now: Date; +} + +export interface CancelTaskResult { + status: TaskStatus; + /** 被置为 skipped 的任务项(用于 SSE item 事件与审计 changes) */ + skippedItems: readonly { id: string; seq: number }[]; +} + +export interface RetryTaskResult { + status: TaskStatus; + retriedItems: number; +} + +export interface PendingResultEvent { + id: string; + eventType: string; + aggregateId: string; + payloadJson: Prisma.JsonValue; + attempts: number; + traceId: string | null; +} + +export interface StreamTaskRow { + id: string; + status: string; + progress: number; +} + +export interface StreamEventRow { + seq: bigint; + eventType: string; + payloadJson: Prisma.JsonValue; +} + +const ACTIVE_STATUSES: readonly string[] = ['queued', 'running']; + +function countersFromGroups( + groups: readonly { status: string; _count: { _all: number } }[], + itemTotal: number, +): TaskCounters { + let succeeded = 0; + let failed = 0; + let skipped = 0; + for (const group of groups) { + if (group.status === 'pending_review') succeeded += group._count._all; + else if (group.status === 'failed') failed += group._count._all; + else if (group.status === 'skipped') skipped += group._count._all; + } + return { itemTotal, itemSucceeded: succeeded, itemFailed: failed, itemSkipped: skipped }; +} + +function keysetWhere(cursor: MediaTaskCursor): Prisma.MediaTaskWhereInput { + return { + OR: [{ createdAt: { lt: cursor.at } }, { createdAt: cursor.at, id: { lt: cursor.id } }], + }; +} + +function listConditions( + query: MediaTaskListQuery, + scope: ScopeContext, + actorId: string, + cursor: MediaTaskCursor | null, +): Prisma.MediaTaskWhereInput[] { + const conditions: Prisma.MediaTaskWhereInput[] = [{ ...customerScopeWhere(scope) }]; + if (query.type !== undefined) conditions.push({ type: query.type }); + if (query.status !== undefined) conditions.push({ status: query.status }); + if (query.customer_id !== undefined) conditions.push({ customerId: query.customer_id }); + if (query.mine === 'true') conditions.push({ createdBy: actorId }); + if (query.keyword !== undefined) { + conditions.push({ + OR: [ + { title: { contains: query.keyword, mode: 'insensitive' } }, + { customerName: { contains: query.keyword, mode: 'insensitive' } }, + ], + }); + } + const createdAt: Prisma.DateTimeFilter = {}; + if (query.created_from !== undefined) createdAt.gte = new Date(query.created_from); + if (query.created_to !== undefined) createdAt.lte = new Date(query.created_to); + if (createdAt.gte !== undefined || createdAt.lte !== undefined) { + conditions.push({ createdAt }); + } + if (cursor !== null) conditions.push(keysetWhere(cursor)); + return conditions; +} + +@Injectable() +export class MediaTaskRepository { + constructor(@Inject(PRISMA) private readonly prisma: PrismaService) {} + + // ------------------------------------------------------------------------- + // 列表 / 详情(用户范围) + // ------------------------------------------------------------------------- + + async listTasks( + query: MediaTaskListQuery, + scope: ScopeContext, + actorId: string, + cursor: MediaTaskCursor | null, + ): Promise { + return this.prisma.mediaTask.findMany({ + where: { AND: listConditions(query, scope, actorId, cursor) }, + select: MEDIA_TASK_SUMMARY_SELECT, + orderBy: [{ createdAt: 'desc' }, { id: 'desc' }], + take: query.limit + 1, + }); + } + + async findTaskDetail( + id: string, + scope: ScopeContext, + itemCursorSeq: number | null, + itemLimit: number, + ): Promise<{ task: MediaTaskDetailRow; items: MediaItemRow[]; hasMore: boolean } | null> { + const task = await this.prisma.mediaTask.findFirst({ + where: { id, ...customerScopeWhere(scope) }, + select: MEDIA_TASK_DETAIL_SELECT, + }); + if (task === null) return null; + const items = await this.prisma.mediaItem.findMany({ + where: { taskId: id, ...(itemCursorSeq === null ? {} : { seq: { gt: itemCursorSeq } }) }, + select: MEDIA_ITEM_SELECT, + orderBy: { seq: 'asc' }, + take: itemLimit + 1, + }); + return { task, items: items.slice(0, itemLimit), hasMore: items.length > itemLimit }; + } + + async findVisibleTaskHeader(id: string, scope: ScopeContext): Promise { + return this.prisma.mediaTask.findFirst({ + where: { id, ...customerScopeWhere(scope) }, + select: MEDIA_TASK_HEADER_SELECT, + }); + } + + // ------------------------------------------------------------------------- + // 幂等键(唯一键 scope + key;24h 过期视为无记录) + // ------------------------------------------------------------------------- + + async findIdempotencyByKey(scope: string, key: string): Promise { + return this.prisma.idempotencyKey.findUnique({ where: { scope_key: { scope, key } } }); + } + + /** + * 幂等重放:同 key 同请求体返回首次响应;同 key 不同请求体 40902。 + * 响应体缺失(异常中断留在半途)时提示重试,不返回半成品。 + */ + replayResponse(existing: IdempotencyKey, requestHash: string): MediaTaskSubmitResponse { + if (existing.requestHash !== requestHash) { + throw new AppException({ + code: ErrorCode.DUPLICATE_SUBMIT, + message: '同一 Idempotency-Key 的请求内容与首次提交不一致', + }); + } + const parsed = mediaTaskSubmitResponseSchema.safeParse(existing.responseBody); + if (!parsed.success) { + throw new AppException({ + code: ErrorCode.DUPLICATE_SUBMIT, + message: '同一 Idempotency-Key 的请求正在处理中,请稍后重试', + }); + } + return parsed.data; + } + + // ------------------------------------------------------------------------- + // 提交(任务头 + 任务项 + progress 事件 + outbox + 幂等键,单事务) + // ------------------------------------------------------------------------- + + async submitTask( + input: MediaTaskSubmitTransactionInput, + ): Promise { + const { task, idempotency, now } = input; + return this.prisma.$transaction(async (tx) => { + const existing = await tx.idempotencyKey.findUnique({ + where: { scope_key: { scope: idempotency.scope, key: idempotency.key } }, + }); + if (existing !== null) { + if (existing.expiresAt.getTime() > now.getTime()) { + return { + kind: 'replayed', + response: this.replayResponse(existing, idempotency.requestHash), + }; + } + // 过期键:删掉后按新请求重建(唯一约束是 scope + key) + await tx.idempotencyKey.delete({ where: { id: existing.id } }); + } + + await tx.mediaTask.create({ + data: { + id: task.id, + type: task.type, + status: 'queued', + title: task.title, + customerId: task.customerId, + customerName: task.customerName, + createdBy: task.createdBy, + createdByName: task.createdByName, + itemTotal: task.itemTotal, + estimatedItems: task.estimatedItems, + estimatedCost: new Prisma.Decimal(task.estimatedCost.toFixed(2)), + estimatedSeconds: task.estimatedSeconds, + progress: 0, + payloadJson: toPrismaJsonValue(task.payload), + createdAt: now, + updatedAt: now, + }, + }); + await tx.mediaItem.createMany({ + data: input.itemIds.map((itemId, index) => ({ + id: itemId, + taskId: task.id, + seq: index + 1, + status: 'queued', + })), + }); + await tx.taskEvent.create({ + data: { + id: ulid(), + taskId: task.id, + eventType: 'progress', + payloadJson: toPrismaJsonValue({ + taskId: task.id, + status: 'queued', + progress: 0, + itemTotal: task.itemTotal, + itemSucceeded: 0, + itemFailed: 0, + itemSkipped: 0, + }), + }, + }); + await tx.outboxEvent.create({ + data: { + id: ulid(), + aggregateType: 'media_task', + aggregateId: task.id, + eventType: TASK_OUTBOX_EVENTS.created, + payloadJson: toPrismaJsonValue({ + taskId: task.id, + type: task.type, + itemIds: [...input.itemIds], + }), + traceId: input.traceId, + }, + }); + await tx.idempotencyKey.create({ + data: { + id: idempotency.id, + scope: idempotency.scope, + key: idempotency.key, + requestHash: idempotency.requestHash, + responseStatus: 202, + responseBody: toPrismaJsonValue(idempotency.response), + expiresAt: idempotency.expiresAt, + }, + }); + return { kind: 'created', response: idempotency.response }; + }); + } + + // ------------------------------------------------------------------------- + // 取消:只影响未开始项;无 running 项时立即收敛为 canceled + // ------------------------------------------------------------------------- + + async cancelTask(input: CancelTaskInput): Promise { + const { task, actorId, now } = input; + return this.prisma.$transaction(async (tx) => { + const claimed = await tx.mediaTask.updateMany({ + where: { id: task.id, status: { in: [...ACTIVE_STATUSES] }, cancelRequestedAt: null }, + data: { cancelRequestedAt: now, canceledBy: actorId, updatedAt: now }, + }); + if (claimed.count === 0) { + throw new AppException({ + code: ErrorCode.STATE_NOT_ALLOWED, + message: '当前状态不允许取消(已取消或已结束)', + }); + } + const queuedItems = await tx.mediaItem.findMany({ + where: { taskId: task.id, status: 'queued' }, + select: { id: true, seq: true }, + }); + if (queuedItems.length > 0) { + await tx.mediaItem.updateMany({ + where: { taskId: task.id, status: 'queued' }, + data: { status: 'skipped', finishedAt: now, updatedAt: now }, + }); + } + const groups = await tx.mediaItem.groupBy({ + by: ['status'], + where: { taskId: task.id }, + _count: { _all: true }, + }); + const runningCount = await tx.mediaItem.count({ + where: { taskId: task.id, status: 'running' }, + }); + const counters = countersFromGroups(groups, task.itemTotal); + const finished = runningCount === 0; + const status: TaskStatus = finished ? 'canceled' : 'running'; + const progress = taskProgress(counters); + await tx.mediaTask.update({ + where: { id: task.id }, + data: { + itemSucceeded: counters.itemSucceeded, + itemFailed: counters.itemFailed, + itemSkipped: counters.itemSkipped, + progress, + ...(finished ? { status, canceledAt: now, finishedAt: now } : {}), + updatedAt: now, + }, + }); + if (queuedItems.length > 0) { + await tx.taskEvent.createMany({ + data: queuedItems.map((item) => ({ + id: ulid(), + taskId: task.id, + eventType: 'item', + payloadJson: toPrismaJsonValue({ + taskId: task.id, + itemId: item.id, + seq: item.seq, + status: 'skipped', + }), + })), + }); + } + await tx.taskEvent.create({ + data: { + id: ulid(), + taskId: task.id, + eventType: 'progress', + payloadJson: toPrismaJsonValue({ + taskId: task.id, + status, + progress, + itemTotal: counters.itemTotal, + itemSucceeded: counters.itemSucceeded, + itemFailed: counters.itemFailed, + itemSkipped: counters.itemSkipped, + }), + }, + }); + if (finished) { + await tx.taskEvent.create({ + data: { + id: ulid(), + taskId: task.id, + eventType: 'done', + payloadJson: toPrismaJsonValue({ taskId: task.id, status, progress }), + }, + }); + await tx.outboxEvent.create({ + data: { + id: ulid(), + aggregateType: 'media_task', + aggregateId: task.id, + eventType: TASK_OUTBOX_EVENTS.canceled, + payloadJson: toPrismaJsonValue({ + taskId: task.id, + type: task.type, + canceledBy: actorId, + }), + }, + }); + } + return { status, skippedItems: queuedItems }; + }); + } + + // ------------------------------------------------------------------------- + // 重试:只重置失败项(成功项不动、attempts 保留),任务回 queued + // ------------------------------------------------------------------------- + + async retryTask(task: MediaTaskHeaderRow, now: Date): Promise { + return this.prisma.$transaction(async (tx) => { + const claimed = await tx.mediaTask.updateMany({ + where: { id: task.id, status: 'partial_failed' }, + data: { + status: 'queued', + lastErrorCode: null, + lastErrorMessage: null, + finishedAt: null, + updatedAt: now, + }, + }); + if (claimed.count === 0) { + throw new AppException({ + code: ErrorCode.STATE_NOT_ALLOWED, + message: '仅部分失败的任务可重试', + }); + } + const failedItems = await tx.mediaItem.findMany({ + where: { taskId: task.id, status: 'failed' }, + select: { id: true, seq: true }, + orderBy: { seq: 'asc' }, + }); + if (failedItems.length === 0) { + throw new AppException({ + code: ErrorCode.STATE_NOT_ALLOWED, + message: '没有可重试的失败项', + }); + } + await tx.mediaItem.updateMany({ + where: { taskId: task.id, status: 'failed' }, + data: { + status: 'queued', + failureCode: null, + failureMessage: null, + startedAt: null, + finishedAt: null, + updatedAt: now, + }, + }); + const groups = await tx.mediaItem.groupBy({ + by: ['status'], + where: { taskId: task.id }, + _count: { _all: true }, + }); + const counters = countersFromGroups(groups, task.itemTotal); + const progress = taskProgress(counters); + await tx.mediaTask.update({ + where: { id: task.id }, + data: { + itemSucceeded: counters.itemSucceeded, + itemFailed: counters.itemFailed, + itemSkipped: counters.itemSkipped, + progress, + updatedAt: now, + }, + }); + await tx.taskEvent.create({ + data: { + id: ulid(), + taskId: task.id, + eventType: 'progress', + payloadJson: toPrismaJsonValue({ + taskId: task.id, + status: 'queued', + progress, + itemTotal: counters.itemTotal, + itemSucceeded: counters.itemSucceeded, + itemFailed: counters.itemFailed, + itemSkipped: counters.itemSkipped, + }), + }, + }); + await tx.outboxEvent.create({ + data: { + id: ulid(), + aggregateType: 'media_task', + aggregateId: task.id, + eventType: TASK_OUTBOX_EVENTS.retry, + payloadJson: toPrismaJsonValue({ + taskId: task.id, + type: task.type, + itemIds: failedItems.map((item) => item.id), + }), + }, + }); + return { status: 'queued', retriedItems: failedItems.length }; + }); + } + + // ------------------------------------------------------------------------- + // SSE:事件增量 + 任务状态(轮询兜底依赖 DB,不依赖内存) + // ------------------------------------------------------------------------- + + async findTaskForStream(id: string, scope: ScopeContext): Promise { + const row = await this.prisma.mediaTask.findFirst({ + where: { id, ...customerScopeWhere(scope) }, + select: { id: true, status: true, progress: true }, + }); + return row; + } + + async findTaskEventsAfter( + taskId: string, + afterSeq: bigint, + take: number, + ): Promise { + return this.prisma.taskEvent.findMany({ + where: { taskId, seq: { gt: afterSeq } }, + select: { seq: true, eventType: true, payloadJson: true }, + orderBy: { seq: 'asc' }, + take, + }); + } + + async findTaskStreamStatus(id: string): Promise { + return this.prisma.mediaTask.findUnique({ + where: { id }, + select: { id: true, status: true, progress: true }, + }); + } + + // ------------------------------------------------------------------------- + // 结果消费者:只认 media.task.finished / failed,条件 UPDATE 认领避免多实例重复 + // ------------------------------------------------------------------------- + + async findPendingResultEvents(limit: number): Promise { + return this.prisma.outboxEvent.findMany({ + where: { eventType: { in: [...TASK_LOCAL_EVENT_TYPES] }, publishedAt: null }, + select: { + id: true, + eventType: true, + aggregateId: true, + payloadJson: true, + attempts: true, + traceId: true, + }, + orderBy: { occurredAt: 'asc' }, + take: limit, + }); + } + + async claimResultEvent(id: string): Promise { + const claimed = await this.prisma.outboxEvent.updateMany({ + where: { id, publishedAt: null }, + data: { publishedAt: new Date(), attempts: { increment: 1 } }, + }); + return claimed.count === 1; + } + + async markResultEventRetry(id: string, errorMessage: string): Promise { + await this.prisma.outboxEvent.updateMany({ + where: { id }, + data: { publishedAt: null, lastError: errorMessage.slice(0, 1000) }, + }); + } + + async markResultEventExhausted(id: string, errorMessage: string): Promise { + await this.prisma.outboxEvent.updateMany({ + where: { id }, + data: { lastError: errorMessage.slice(0, 1000) }, + }); + } + + async findTaskForResult(id: string): Promise { + return this.prisma.mediaTask.findUnique({ where: { id }, select: MEDIA_TASK_RESULT_SELECT }); + } +} diff --git a/apps/api/src/modules/media/media-task.service.spec.ts b/apps/api/src/modules/media/media-task.service.spec.ts new file mode 100644 index 0000000..1e1c83e --- /dev/null +++ b/apps/api/src/modules/media/media-task.service.spec.ts @@ -0,0 +1,404 @@ +import { ErrorCode, type MediaTaskSubmitParsed } from '@lingxi/shared'; +import { Prisma } from '@prisma/client'; +import { describe, expect, it, vi } from 'vitest'; + +import { AppException } from '../../common/errors/app-exception'; +import type { AuthContext } from '../../common/security/auth.types'; +import type { RequestContextService } from '../../common/security/request-context'; +import type { ScopeContext } from '../../common/security/scope/scope-context'; +import type { CustomersService } from '../client/customers.service'; +import type { MediaItemRow, MediaTaskDetailRow, MediaTaskSummaryRow } from './media-task.mapper'; +import type { MediaTaskRepository } from './media-task.repository'; +import { MediaTaskService } from './media-task.service'; +import type { MediaTaskEstimator } from './media-task-estimator'; +import { hashIdempotencyRequest, MEDIA_TASK_SUBMIT_SCOPE } from './media-task-idempotency'; + +const NOW = new Date('2026-11-04T12:00:00.000Z'); +const TASK_ID = '01J9Z000000000000000000001'; +const CUSTOMER_ID = '01J9Z000000000000000000002'; +const USER_ID = '01J9Z000000000000000000003'; + +const AUTH: AuthContext = { + userId: USER_ID, + account: 'chen.mo', + name: '陈墨', + status: 'active', + roles: [], + permissions: [], + dataScopes: [], + departmentIds: [], + isSuperAdmin: false, + mustChangePassword: false, +}; + +const SCOPE: ScopeContext = { + userId: USER_ID, + isSuperAdmin: false, + dataScopes: [], + customerIds: [CUSTOMER_ID], +}; + +const SUBMIT_INPUT: MediaTaskSubmitParsed = { + type: 'demo', + customerId: CUSTOMER_ID, + itemCount: 2, + mode: 'mixed', +}; + +const RESPONSE = { taskId: TASK_ID, estimatedItems: 2, estimatedCost: 0.04, estimatedSeconds: 6 }; + +function summaryRow(overrides: Record = {}): MediaTaskSummaryRow { + return { + id: TASK_ID, + type: 'demo', + status: 'queued', + title: '演练任务 · 花间集 · 2 项', + customerId: CUSTOMER_ID, + customerName: '花间集', + itemTotal: 2, + itemSucceeded: 0, + itemFailed: 0, + itemSkipped: 0, + progress: 0, + estimatedItems: 2, + estimatedCost: new Prisma.Decimal('0.04'), + estimatedSeconds: 6, + createdBy: USER_ID, + createdByName: '陈墨', + createdAt: NOW, + startedAt: null, + finishedAt: null, + ...overrides, + }; +} + +function headerRow(overrides: Record = {}): Record { + return { + id: TASK_ID, + type: 'demo', + status: 'queued', + title: '演练任务 · 花间集 · 2 项', + customerId: CUSTOMER_ID, + customerName: '花间集', + createdBy: USER_ID, + createdByName: '陈墨', + itemTotal: 2, + ...overrides, + }; +} + +interface Harness { + service: MediaTaskService; + repository: { + findIdempotencyByKey: ReturnType; + replayResponse: ReturnType; + submitTask: ReturnType; + listTasks: ReturnType; + findTaskDetail: ReturnType; + findVisibleTaskHeader: ReturnType; + cancelTask: ReturnType; + retryTask: ReturnType; + }; + estimator: { estimate: ReturnType }; + customers: { detail: ReturnType }; + requestContext: { + patch: ReturnType; + patchAudit: ReturnType; + get: ReturnType; + }; +} + +function harness(): Harness { + const repository = { + findIdempotencyByKey: vi.fn().mockResolvedValue(null), + replayResponse: vi.fn().mockReturnValue(RESPONSE), + submitTask: vi.fn().mockResolvedValue({ kind: 'created', response: RESPONSE }), + listTasks: vi.fn().mockResolvedValue([]), + findTaskDetail: vi.fn().mockResolvedValue(null), + findVisibleTaskHeader: vi.fn().mockResolvedValue(null), + cancelTask: vi.fn(), + retryTask: vi.fn(), + }; + const estimator = { + estimate: vi + .fn() + .mockResolvedValue({ estimatedItems: 2, estimatedCost: 0.04, estimatedSeconds: 6 }), + }; + const customers = { + detail: vi.fn().mockResolvedValue({ id: CUSTOMER_ID, name: '花间集', status: 'active' }), + }; + const requestContext = { + patch: vi.fn(), + patchAudit: vi.fn(), + get: vi.fn().mockReturnValue({ traceId: 'trace-1' }), + }; + return { + service: new MediaTaskService( + repository as unknown as MediaTaskRepository, + estimator as unknown as MediaTaskEstimator, + customers as unknown as CustomersService, + requestContext as unknown as RequestContextService, + ), + repository, + estimator, + customers, + requestContext, + }; +} + +async function expectRejected(promise: Promise, code: ErrorCode): Promise { + try { + await promise; + } catch (error) { + expect(error).toBeInstanceOf(AppException); + const appError = error as AppException; + expect(appError.code).toBe(code); + return appError; + } + throw new Error('预期抛出 AppException'); +} + +describe('任务提交(A6-BE-01:幂等 → 客户 → 预估 → 事务 → 审计)', () => { + it('缺 Idempotency-Key → 40001,不触碰数据库', async () => { + const { service, repository } = harness(); + await expectRejected( + service.submit(SUBMIT_INPUT, undefined, AUTH, SCOPE), + ErrorCode.PARAM_INVALID, + ); + expect(repository.findIdempotencyByKey).not.toHaveBeenCalled(); + }); + + it('幂等重放:返回同一响应、跳过审计、不重复建任务', async () => { + const { service, repository, requestContext } = harness(); + repository.findIdempotencyByKey.mockResolvedValueOnce({ + id: 'k1', + scope: MEDIA_TASK_SUBMIT_SCOPE, + key: 'idem-key-0001', + requestHash: hashIdempotencyRequest(USER_ID, SUBMIT_INPUT), + responseStatus: 202, + responseBody: RESPONSE, + createdAt: NOW, + expiresAt: new Date(Date.now() + 60_000), + }); + + await expect(service.submit(SUBMIT_INPUT, 'idem-key-0001', AUTH, SCOPE)).resolves.toEqual( + RESPONSE, + ); + expect(repository.submitTask).not.toHaveBeenCalled(); + expect(requestContext.patch).toHaveBeenCalledWith({ skipAudit: true }); + expect(requestContext.patchAudit).not.toHaveBeenCalled(); + }); + + it('客户不在数据范围 → 40302(不泄露客户是否存在)', async () => { + const { service } = harness(); + await expectRejected( + service.submit(SUBMIT_INPUT, 'idem-key-0001', AUTH, { + ...SCOPE, + customerIds: ['01J9Z000000000000000000009'], + }), + ErrorCode.OUT_OF_SCOPE, + ); + }); + + it('暂停 / 结束客户 → 42201(A3 口径:暂停客户不能提交新任务)', async () => { + const { service, customers } = harness(); + customers.detail.mockResolvedValueOnce({ id: CUSTOMER_ID, name: '花间集', status: 'paused' }); + await expectRejected( + service.submit(SUBMIT_INPUT, 'idem-key-0001', AUTH, SCOPE), + ErrorCode.BUSINESS_RULE_VIOLATION, + ); + }); + + it('成功提交:单事务写任务,审计明细含目标 / 客户 / 预估', async () => { + const { service, repository, requestContext } = harness(); + // 仓库回显服务端生成的 taskId(真实实现同源) + repository.submitTask.mockImplementationOnce( + (input: { idempotency: { response: typeof RESPONSE } }) => ({ + kind: 'created', + response: input.idempotency.response, + }), + ); + const result = await service.submit(SUBMIT_INPUT, 'idem-key-0001', AUTH, SCOPE); + expect(repository.submitTask).toHaveBeenCalledTimes(1); + const args = repository.submitTask.mock.calls[0]?.[0] as { + task: { id: string; customerId: string; createdBy: string; payload: unknown }; + itemIds: string[]; + idempotency: { key: string; scope: string; responseBody: unknown; expiresAt: Date }; + traceId: string; + }; + // taskId 由服务端生成(mock 仓库回显固定响应),以返回值为准 + expect(args.task.id).toBe(result.taskId); + expect(args.task.createdBy).toBe(USER_ID); + expect(args.itemIds).toHaveLength(2); + expect(args.idempotency).toMatchObject({ + key: 'idem-key-0001', + scope: MEDIA_TASK_SUBMIT_SCOPE, + }); + expect(args.traceId).toBe('trace-1'); + expect(args.idempotency.expiresAt.getTime()).toBeGreaterThan(NOW.getTime()); + expect(requestContext.patchAudit).toHaveBeenCalledWith( + expect.objectContaining({ + targetType: 'task', + targetId: result.taskId, + customerId: CUSTOMER_ID, + customerName: '花间集', + }), + ); + }); + + it('并发同 key:唯一约束冲突后回读首次响应并跳过审计', async () => { + const { service, repository, requestContext } = harness(); + const uniqueError = new Prisma.PrismaClientKnownRequestError('duplicate', { + code: 'P2002', + clientVersion: '6.19.3', + }); + repository.submitTask.mockRejectedValueOnce(uniqueError); + repository.findIdempotencyByKey.mockResolvedValueOnce(null).mockResolvedValueOnce({ + id: 'k1', + scope: MEDIA_TASK_SUBMIT_SCOPE, + key: 'idem-key-0001', + requestHash: hashIdempotencyRequest(USER_ID, SUBMIT_INPUT), + responseStatus: 202, + responseBody: RESPONSE, + createdAt: NOW, + expiresAt: new Date(Date.now() + 60_000), + }); + + await expect(service.submit(SUBMIT_INPUT, 'idem-key-0001', AUTH, SCOPE)).resolves.toEqual( + RESPONSE, + ); + expect(requestContext.patch).toHaveBeenCalledWith({ skipAudit: true }); + expect(requestContext.patchAudit).not.toHaveBeenCalled(); + }); + + it('非唯一约束错误原样抛出', async () => { + const { service, repository } = harness(); + repository.submitTask.mockRejectedValueOnce(new Error('connection lost')); + await expect(service.submit(SUBMIT_INPUT, 'idem-key-0001', AUTH, SCOPE)).rejects.toThrow( + 'connection lost', + ); + }); +}); + +describe('任务列表 / 详情(material:read + ScopeContext)', () => { + it('列表:游标非法 40001;正常返回 items 与 next_cursor', async () => { + const { service, repository } = harness(); + await expectRejected( + service.list({ limit: 20, cursor: 'bad-cursor' }, SCOPE, AUTH), + ErrorCode.PARAM_INVALID, + ); + + repository.listTasks.mockResolvedValueOnce([ + summaryRow(), + summaryRow({ id: '01J9Z000000000000000000004' }), + summaryRow({ id: '01J9Z000000000000000000005' }), + ]); + const result = await service.list({ limit: 2 }, SCOPE, AUTH); + expect(result.items).toHaveLength(2); + expect(result.next_cursor).not.toBeNull(); + }); + + it('列表:不足一页 next_cursor 为 null;mine 透传 actorId', async () => { + const { service, repository } = harness(); + repository.listTasks.mockResolvedValueOnce([summaryRow()]); + const result = await service.list({ limit: 20, mine: 'true' }, SCOPE, AUTH); + expect(result.next_cursor).toBeNull(); + expect(repository.listTasks).toHaveBeenCalledWith( + { limit: 20, mine: 'true' }, + SCOPE, + USER_ID, + null, + ); + }); + + it('详情:范围外 / 不存在统一 404;任务项游标非法 40001', async () => { + const { service } = harness(); + await expectRejected( + service.detail(TASK_ID, { items_cursor: 'bad-cursor' }, SCOPE), + ErrorCode.PARAM_INVALID, + ); + await expectRejected(service.detail(TASK_ID, {}, SCOPE), ErrorCode.NOT_FOUND); + }); + + it('详情:返回 payload / items / itemsNextCursor', async () => { + const { service, repository } = harness(); + const task = summaryRow({ + payloadJson: { mode: 'mixed' }, + cancelRequestedAt: null, + lastErrorCode: null, + lastErrorMessage: null, + }) as unknown as MediaTaskDetailRow; + repository.findTaskDetail.mockResolvedValueOnce({ + task, + items: [ + { + id: TASK_ID, + seq: 1, + status: 'queued', + failureCode: null, + failureMessage: null, + attempts: 0, + startedAt: null, + finishedAt: null, + } as unknown as MediaItemRow, + ], + hasMore: true, + }); + const detail = await service.detail(TASK_ID, {}, SCOPE); + expect(detail.payload).toEqual({ mode: 'mixed' }); + expect(detail.items).toHaveLength(1); + expect(detail.itemsNextCursor).not.toBeNull(); + }); +}); + +describe('取消 / 重试(A6-BE-04:取消只影响未开始项,重试只重失败项)', () => { + it('取消:不存在 404;状态不允许 40903;排队任务取消返回 canceled', async () => { + const { service, repository, requestContext } = harness(); + await expectRejected(service.cancel(TASK_ID, SCOPE, AUTH), ErrorCode.NOT_FOUND); + + repository.findVisibleTaskHeader.mockResolvedValueOnce(headerRow({ status: 'canceled' })); + await expectRejected(service.cancel(TASK_ID, SCOPE, AUTH), ErrorCode.STATE_NOT_ALLOWED); + + repository.findVisibleTaskHeader.mockResolvedValueOnce(headerRow({ status: 'queued' })); + repository.cancelTask.mockResolvedValueOnce({ + status: 'canceled', + skippedItems: [ + { id: '01J9Z000000000000000000006', seq: 1 }, + { id: '01J9Z000000000000000000007', seq: 2 }, + ], + }); + await expect(service.cancel(TASK_ID, SCOPE, AUTH)).resolves.toEqual({ + taskId: TASK_ID, + status: 'canceled', + }); + expect(repository.cancelTask).toHaveBeenCalledWith( + expect.objectContaining({ actorId: USER_ID }), + ); + expect(requestContext.patchAudit).toHaveBeenCalledWith( + expect.objectContaining({ + targetType: 'task', + changes: { canceledItems: 2 }, + after: { status: 'canceled' }, + }), + ); + }); + + it('重试:不存在 404;非部分失败 40903;部分失败只重试失败项', async () => { + const { service, repository, requestContext } = harness(); + await expectRejected(service.retry(TASK_ID, SCOPE), ErrorCode.NOT_FOUND); + + repository.findVisibleTaskHeader.mockResolvedValueOnce(headerRow({ status: 'running' })); + await expectRejected(service.retry(TASK_ID, SCOPE), ErrorCode.STATE_NOT_ALLOWED); + + repository.findVisibleTaskHeader.mockResolvedValueOnce(headerRow({ status: 'partial_failed' })); + repository.retryTask.mockResolvedValueOnce({ status: 'queued', retriedItems: 1 }); + await expect(service.retry(TASK_ID, SCOPE)).resolves.toEqual({ + taskId: TASK_ID, + status: 'queued', + retriedItems: 1, + }); + expect(requestContext.patchAudit).toHaveBeenCalledWith( + expect.objectContaining({ changes: { retriedItems: 1 }, after: { status: 'queued' } }), + ); + }); +}); diff --git a/apps/api/src/modules/media/media-task.service.ts b/apps/api/src/modules/media/media-task.service.ts new file mode 100644 index 0000000..7a94d36 --- /dev/null +++ b/apps/api/src/modules/media/media-task.service.ts @@ -0,0 +1,298 @@ +/* eslint-disable max-lines-per-function -- 提交编排(幂等 → 客户校验 → 预估 → 事务 → 审计)是一次完整业务动作,拆散会丢失步骤顺序 */ +/** + * 任务中心业务编排(A6-BE-01 / A6-BE-03 / A6-BE-04) + * + * 提交链路(技术架构 §6.4): + * 1. 幂等键校验 → 早读幂等(命中直接返回同一响应,不再建任务 / 不写审计); + * 2. 客户必须在 ScopeContext 内(40302),暂停 / 结束客户拒绝(42201); + * 3. 预估条数 / 成本 / 时长(缺配置拒绝 40001); + * 4. 单事务写任务头 + 任务项 + progress 事件 + outbox `media.task.created` + 幂等键; + * 5. 并发同 key:唯一约束兜底,回读首次响应或 40902。 + */ +import { + canCancelTask, + canRetryTask, + ErrorCode, + type MediaTaskCancelResponse, + type MediaTaskDetail, + type MediaTaskDetailQuery, + type MediaTaskListQuery, + type MediaTaskListResponse, + type MediaTaskRetryResponse, + type MediaTaskSubmitParsed, + type MediaTaskSubmitResponse, + taskTypeLabel, + ulid, +} from '@lingxi/shared'; +import { Inject, Injectable } from '@nestjs/common'; + +import { AppException } from '../../common/errors/app-exception'; +import type { AuthContext } from '../../common/security/auth.types'; +import { RequestContextService } from '../../common/security/request-context'; +import { + assertCustomerAccess, + requireScopeContext, + type ScopeContext, +} from '../../common/security/scope/scope-context'; +import { CustomersService } from '../client/customers.service'; +import { parseTaskStatus, toMediaTaskDetail, toMediaTaskSummary } from './media-task.mapper'; +import { isUniqueViolation, MediaTaskRepository } from './media-task.repository'; +import { + decodeItemCursor, + decodeMediaTaskCursor, + encodeItemCursor, + encodeMediaTaskCursor, + type MediaTaskCursor, +} from './media-task-cursor'; +import { MediaTaskEstimator } from './media-task-estimator'; +import { + hashIdempotencyRequest, + IDEMPOTENCY_TTL_MS, + MEDIA_TASK_SUBMIT_SCOPE, + parseIdempotencyKey, +} from './media-task-idempotency'; + +/** 详情首屏任务项条数(与契约「首 100 条 items」一致) */ +export const MEDIA_TASK_DETAIL_ITEMS_PAGE_SIZE = 100; + +function buildTitle( + type: MediaTaskSubmitParsed['type'], + customerName: string, + itemCount: number, +): string { + const title = `${taskTypeLabel(type)} · ${customerName} · ${String(itemCount)} 项`; + return title.length > 200 ? title.slice(0, 200) : title; +} + +function invalidCursor(message: string): AppException { + return new AppException({ code: ErrorCode.PARAM_INVALID, message }); +} + +@Injectable() +export class MediaTaskService { + constructor( + @Inject(MediaTaskRepository) private readonly repository: MediaTaskRepository, + @Inject(MediaTaskEstimator) private readonly estimator: MediaTaskEstimator, + @Inject(CustomersService) private readonly customers: CustomersService, + @Inject(RequestContextService) private readonly requestContext: RequestContextService, + ) {} + + async submit( + input: MediaTaskSubmitParsed, + rawIdempotencyKey: string | string[] | undefined, + auth: AuthContext, + scopeInput: ScopeContext | null, + ): Promise { + const scope = requireScopeContext(scopeInput); + const idempotencyKey = parseIdempotencyKey(rawIdempotencyKey); + const requestHash = hashIdempotencyRequest(auth.userId, input); + const now = new Date(); + + // 1) 幂等早读:命中 24h 内记录直接返回首次响应(不重复建任务 / 审计 / outbox) + const existing = await this.repository.findIdempotencyByKey( + MEDIA_TASK_SUBMIT_SCOPE, + idempotencyKey, + ); + if (existing !== null && existing.expiresAt.getTime() > now.getTime()) { + const replayed = this.repository.replayResponse(existing, requestHash); + this.requestContext.patch({ skipAudit: true }); + return replayed; + } + + // 2) 客户必须在数据范围内(40302),暂停 / 结束客户不能提交新任务(42201) + assertCustomerAccess(scope, input.customerId); + const customer = await this.customers.detail(input.customerId, scope); + if (customer.status !== 'active') { + throw new AppException({ + code: ErrorCode.BUSINESS_RULE_VIOLATION, + message: '客户已暂停或结束,不能提交新任务', + }); + } + + // 3) 预估:缺配置 / 超上限一律 40001,不允许静默 0 + const estimate = await this.estimator.estimate(input.type, input.itemCount); + + const taskId = ulid(); + const response: MediaTaskSubmitResponse = { taskId, ...estimate }; + const itemIds = Array.from({ length: input.itemCount }, () => ulid()); + const title = buildTitle(input.type, customer.name, input.itemCount); + const traceId = this.requestContext.get()?.traceId ?? null; + + // 4) 单事务写入;并发同 key 由 (scope, key) 唯一约束兜底 + let result: Awaited>; + try { + result = await this.repository.submitTask({ + task: { + id: taskId, + type: input.type, + title, + customerId: input.customerId, + customerName: customer.name, + createdBy: auth.userId, + createdByName: auth.name, + itemTotal: input.itemCount, + estimatedItems: estimate.estimatedItems, + estimatedCost: estimate.estimatedCost, + estimatedSeconds: estimate.estimatedSeconds, + payload: { + type: input.type, + mode: input.mode, + itemCount: input.itemCount, + note: input.note ?? null, + }, + }, + itemIds, + idempotency: { + id: ulid(), + scope: MEDIA_TASK_SUBMIT_SCOPE, + key: idempotencyKey, + requestHash, + response, + expiresAt: new Date(now.getTime() + IDEMPOTENCY_TTL_MS), + }, + traceId, + now, + }); + } catch (error) { + if (!isUniqueViolation(error)) throw error; + const raced = await this.repository.findIdempotencyByKey( + MEDIA_TASK_SUBMIT_SCOPE, + idempotencyKey, + ); + if (raced === null) throw error; + const replayed = this.repository.replayResponse(raced, requestHash); + this.requestContext.patch({ skipAudit: true }); + return replayed; + } + + if (result.kind === 'replayed') { + this.requestContext.patch({ skipAudit: true }); + return result.response; + } + + // 5) 审计明细(落库由 AuditInterceptor 统一完成;重放不写) + this.requestContext.patchAudit({ + targetType: 'task', + targetId: taskId, + targetName: title, + customerId: input.customerId, + customerName: customer.name, + after: { + type: input.type, + itemCount: input.itemCount, + mode: input.mode, + ...estimate, + }, + }); + return result.response; + } + + async list( + query: MediaTaskListQuery, + scopeInput: ScopeContext | null, + auth: AuthContext, + ): Promise { + const scope = requireScopeContext(scopeInput); + let cursor: MediaTaskCursor | null = null; + if (query.cursor !== undefined) { + cursor = decodeMediaTaskCursor(query.cursor); + if (cursor === null) throw invalidCursor('分页游标无效,请刷新后重试'); + } + const rows = await this.repository.listTasks(query, scope, auth.userId, cursor); + const page = rows.slice(0, query.limit); + const last = page[page.length - 1]; + return { + items: page.map((row) => toMediaTaskSummary(row)), + next_cursor: + rows.length > query.limit && last !== undefined + ? encodeMediaTaskCursor(last.createdAt, last.id) + : null, + }; + } + + async detail( + id: string, + query: MediaTaskDetailQuery, + scopeInput: ScopeContext | null, + ): Promise { + const scope = requireScopeContext(scopeInput); + let itemCursor: number | null = null; + if (query.items_cursor !== undefined) { + itemCursor = decodeItemCursor(query.items_cursor); + if (itemCursor === null) throw invalidCursor('任务项游标无效,请刷新后重试'); + } + const found = await this.repository.findTaskDetail( + id, + scope, + itemCursor, + MEDIA_TASK_DETAIL_ITEMS_PAGE_SIZE, + ); + // 范围外与不存在统一 404,不泄露任务存在性(R-04) + if (found === null) { + throw new AppException({ code: ErrorCode.NOT_FOUND, message: '任务不存在或已删除' }); + } + const last = found.items[found.items.length - 1]; + const itemsNextCursor = found.hasMore && last !== undefined ? encodeItemCursor(last.seq) : null; + return toMediaTaskDetail(found.task, found.items, itemsNextCursor); + } + + async cancel( + id: string, + scopeInput: ScopeContext | null, + auth: AuthContext, + ): Promise { + const scope = requireScopeContext(scopeInput); + const task = await this.repository.findVisibleTaskHeader(id, scope); + if (task === null) { + throw new AppException({ code: ErrorCode.NOT_FOUND, message: '任务不存在或已删除' }); + } + if (!canCancelTask(parseTaskStatus(task.status))) { + throw new AppException({ + code: ErrorCode.STATE_NOT_ALLOWED, + message: '当前状态不允许取消(仅排队 / 生成中可取消)', + }); + } + const result = await this.repository.cancelTask({ + task, + actorId: auth.userId, + now: new Date(), + }); + this.requestContext.patchAudit({ + targetType: 'task', + targetId: id, + targetName: task.title, + customerId: task.customerId, + customerName: task.customerName, + before: { status: task.status }, + after: { status: result.status }, + changes: { canceledItems: result.skippedItems.length }, + }); + return { taskId: id, status: result.status }; + } + + async retry(id: string, scopeInput: ScopeContext | null): Promise { + const scope = requireScopeContext(scopeInput); + const task = await this.repository.findVisibleTaskHeader(id, scope); + if (task === null) { + throw new AppException({ code: ErrorCode.NOT_FOUND, message: '任务不存在或已删除' }); + } + if (!canRetryTask(parseTaskStatus(task.status))) { + throw new AppException({ + code: ErrorCode.STATE_NOT_ALLOWED, + message: '仅部分失败的任务可重试', + }); + } + const result = await this.repository.retryTask(task, new Date()); + this.requestContext.patchAudit({ + targetType: 'task', + targetId: id, + targetName: task.title, + customerId: task.customerId, + customerName: task.customerName, + before: { status: task.status }, + after: { status: result.status }, + changes: { retriedItems: result.retriedItems }, + }); + return { taskId: id, status: result.status, retriedItems: result.retriedItems }; + } +} diff --git a/apps/api/src/modules/media/media.int.spec.ts b/apps/api/src/modules/media/media.int.spec.ts new file mode 100644 index 0000000..611acc7 --- /dev/null +++ b/apps/api/src/modules/media/media.int.spec.ts @@ -0,0 +1,864 @@ +import type { OutgoingHttpHeaders } from 'node:http'; + +import { API_PREFIX, ErrorCode, IDEMPOTENCY_KEY_HEADER, Permission, ulid } from '@lingxi/shared'; +import { PrismaClient } from '@prisma/client'; +import { afterAll, beforeAll, describe, expect, it } from 'vitest'; + +import { createApp, type CreatedApiApp } from '../../bootstrap'; +import { loadDotenv } from '../../config/load-dotenv'; + +/** + * A6 任务中心集成用例(真实 PG / Redis,进程内 Fastify inject) + * + * 覆盖:提交建三表 + outbox + 幂等键、重复提交同 key 不新增行 / 不重复审计、缺 key 40001、 + * 同 key 不同 body 40902、范围外客户 40302 / 404、列表筛选与游标、取消只影响未开始项、 + * 部分失败重试只重置失败项、SSE 按 Last-Event-ID 续推、结果 Outbox 本地消费写审计。 + * + * 说明:`audit_logs` 为 append-only,afterAll 不删除审计数据;通过独立前缀 / taskId 断言隔离。 + */ + +const PASSWORD_HASH = + '$argon2id$v=19$m=65536,p=4,t=3$Osdgg3Fz53AwcoC2kyGiGg$QVbxW7Ok/SlCNbML95go2J/6GMSfE1EG3jBTJgR4zO4'; +const PASSWORD = 'lingxi2026'; +const API = API_PREFIX; +const PREFIX = 'ITMEDIA-'; +const ACCOUNT_PREFIX = 'itmedia.'; +const ROLE_PREFIX = 'ITMEDIA-角色-'; +const ROLE_CODE_PREFIX = 'itmedia_role_'; +const INDUSTRY_NAME = `${PREFIX}行业`; +const CUSTOMER_PREFIX = `${PREFIX}客户-`; +const KEY_PREFIX = 'itmedia-idem-'; + +loadDotenv(); +const prisma = new PrismaClient(); + +interface JsonResult { + status: number; + body: T; + headers: OutgoingHttpHeaders; +} + +interface ErrorBody { + code: number; + message: string; +} + +interface LoginBody { + accessToken: string; + refreshToken: string; +} + +interface SubmitBody { + taskId: string; + estimatedItems: number; + estimatedCost: number; + estimatedSeconds: number; +} + +interface SummaryBody { + id: string; + type: string; + status: string; + title: string; + customerId: string; + customerName: string; + itemTotal: number; + itemSucceeded: number; + itemFailed: number; + itemSkipped: number; + progress: number; + estimatedItems: number; + estimatedCost: number; + estimatedSeconds: number; + createdBy: string; + createdByName: string; + createdAt: string; + startedAt?: string; + finishedAt?: string; +} + +interface ItemBody { + id: string; + seq: number; + status: string; + failureCode?: string; + failureMessage?: string; + attempts: number; +} + +interface DetailBody extends SummaryBody { + payload: Record; + cancelRequestedAt?: string; + lastErrorCode?: string; + lastErrorMessage?: string; + items: ItemBody[]; + itemsNextCursor: string | null; +} + +interface ListBody { + items: SummaryBody[]; + next_cursor: string | null; +} + +let phoneCounter = 0; +let app: CreatedApiApp['app']; +let adminToken = ''; +let limitedToken = ''; +let adminUserId = ''; +let limitedUserId = ''; +let industryId = ''; +let adminCustomerId = ''; +let limitedCustomerId = ''; +let pausedCustomerId = ''; +const createdRoleIds: string[] = []; +const createdUserIds: string[] = []; +const createdTaskIds: string[] = []; +let testStartedAt = new Date(); + +function nextPhone(): string { + phoneCounter += 1; + return `135${String(phoneCounter).padStart(8, '0')}`; +} + +function uniqueKey(suffix: string): string { + return `${KEY_PREFIX}${suffix}-${String(Date.now())}`; +} + +async function createRole( + suffix: string, + permissions: string[], + scopeType: 'all' | 'owned_customers', +): Promise { + const id = ulid(); + await prisma.role.create({ + data: { + id, + code: `${ROLE_CODE_PREFIX}${suffix}`, + name: `${ROLE_PREFIX}${suffix}`, + description: 'A6 任务中心集成测试角色', + status: 'ACTIVE', + permissions: { create: permissions.map((permissionCode) => ({ permissionCode })) }, + dataScopes: { create: [{ id: ulid(), scopeType, targetId: null }] }, + }, + }); + createdRoleIds.push(id); + return id; +} + +async function createUser(suffix: string, roleId: string): Promise { + const department = await prisma.department.findUniqueOrThrow({ where: { code: 'content-g1' } }); + const account = `${ACCOUNT_PREFIX}${suffix}`; + const id = ulid(); + await prisma.user.create({ + data: { + id, + name: `A6 任务${suffix}`, + account, + email: `${account}@lingxi.cn`, + phone: nextPhone(), + passwordHash: PASSWORD_HASH, + departmentId: department.id, + mustChangePassword: false, + roles: { create: [{ roleId }] }, + }, + }); + createdUserIds.push(id); + return id; +} + +async function createCustomer( + name: string, + ownerUserId: string, + status = 'ACTIVE', +): Promise { + const id = ulid(); + await prisma.customer.create({ + data: { + id, + industryId, + name, + shortName: name.slice(-4), + status: status === 'PAUSED' ? 'PAUSED' : 'ACTIVE', + ownerUserId, + remark: 'A6 任务中心集成测试客户', + }, + }); + return id; +} + +async function login(account: string): Promise { + const response = await app.inject({ + method: 'POST', + url: `${API}/auth/login`, + payload: { account, password: PASSWORD }, + }); + if (response.statusCode !== 200 && response.statusCode !== 201) { + throw new Error(`login failed ${String(response.statusCode)}: ${response.body}`); + } + return response.json(); +} + +async function call( + method: 'GET' | 'POST', + url: string, + token: string, + payload?: unknown, + headers?: Record, +): Promise> { + const response = await app.inject({ + method, + url: `${API}${url}`, + headers: { authorization: `Bearer ${token}`, 'user-agent': 'itmedia-vitest', ...headers }, + ...(payload === undefined ? {} : { payload: payload as Record }), + }); + const body = response.body.length === 0 ? (null as T) : response.json(); + return { status: response.statusCode, body, headers: response.headers }; +} + +/** SSE 响应不是 JSON:直接取原始文本(结果流已合流为 string body) */ +async function callStream( + url: string, + token: string, + headers?: Record, +): Promise<{ status: number; body: string; headers: OutgoingHttpHeaders }> { + const response = await app.inject({ + method: 'GET', + url: `${API}${url}`, + headers: { authorization: `Bearer ${token}`, 'user-agent': 'itmedia-vitest', ...headers }, + }); + return { status: response.statusCode, body: response.body, headers: response.headers }; +} + +function submitPayload( + customerId: string, + itemCount: number, + mode: 'success' | 'mixed' | 'fail' = 'success', +): Record { + return { type: 'demo', customerId, itemCount, mode, note: 'A6 集成演练' }; +} + +async function submitTask( + token: string, + customerId: string, + itemCount: number, + key: string, + mode: 'success' | 'mixed' | 'fail' = 'success', +): Promise> { + const result = await call( + 'POST', + '/media-tasks', + token, + submitPayload(customerId, itemCount, mode), + { + [IDEMPOTENCY_KEY_HEADER]: key, + }, + ); + if (result.status === 202) createdTaskIds.push(result.body.taskId); + return result; +} + +async function taskRow(taskId: string, token: string): Promise { + const result = await call('GET', `/media-tasks/${taskId}`, token); + expect(result.status).toBe(200); + return result.body; +} + +async function sleep(ms: number): Promise { + await new Promise((resolve) => setTimeout(resolve, ms)); +} + +async function cleanupFixtures(): Promise { + await prisma.mediaTask.deleteMany({ where: { customerName: { startsWith: CUSTOMER_PREFIX } } }); + await prisma.outboxEvent.deleteMany({ + where: { aggregateType: 'media_task', occurredAt: { gte: testStartedAt } }, + }); + await prisma.idempotencyKey.deleteMany({ where: { key: { startsWith: KEY_PREFIX } } }); + await prisma.customer.deleteMany({ where: { name: { startsWith: CUSTOMER_PREFIX } } }); + await prisma.user.deleteMany({ where: { account: { startsWith: ACCOUNT_PREFIX } } }); + if (createdRoleIds.length > 0) { + await prisma.role.deleteMany({ where: { id: { in: createdRoleIds } } }); + } + await prisma.industry.deleteMany({ where: { name: INDUSTRY_NAME } }); +} + +beforeAll(async () => { + await cleanupFixtures(); + createdRoleIds.length = 0; + createdUserIds.length = 0; + createdTaskIds.length = 0; + testStartedAt = new Date(); + + const adminRole = await createRole( + 'admin', + [Permission.MATERIAL_READ, Permission.MATERIAL_RENDER, Permission.CUSTOMER_READ], + 'all', + ); + adminUserId = await createUser('admin', adminRole); + const limitedRole = await createRole( + 'limited', + [Permission.MATERIAL_READ, Permission.MATERIAL_RENDER], + 'owned_customers', + ); + limitedUserId = await createUser('limited', limitedRole); + + const root = await prisma.industry.create({ + data: { id: ulid(), parentId: null, level: 1, name: INDUSTRY_NAME, sortOrder: 10 }, + }); + industryId = root.id; + + adminCustomerId = await createCustomer(`${CUSTOMER_PREFIX}管理员客户`, adminUserId); + limitedCustomerId = await createCustomer(`${CUSTOMER_PREFIX}负责人客户`, limitedUserId); + pausedCustomerId = await createCustomer(`${CUSTOMER_PREFIX}暂停客户`, adminUserId, 'PAUSED'); + + const created = await createApp(); + app = created.app; + await app.init(); + adminToken = (await login(`${ACCOUNT_PREFIX}admin`)).accessToken; + limitedToken = (await login(`${ACCOUNT_PREFIX}limited`)).accessToken; +}); + +afterAll(async () => { + await cleanupFixtures(); + // eslint-disable-next-line @typescript-eslint/no-unnecessary-condition -- beforeAll 失败时 app 未赋值 + if (app) await app.close(); + await prisma.$disconnect(); +}); + +describe('A6 任务提交(A6-BE-01:预估 + 幂等 + 三表/outbox/审计)', () => { + it('提交成功:返回预估字段,写任务头 / 任务项 / progress 事件 / created outbox / 幂等键 / 审计', async () => { + const key = uniqueKey('create'); + const result = await submitTask(adminToken, adminCustomerId, 2, key, 'mixed'); + expect(result.status).toBe(202); + expect(result.body).toMatchObject({ + estimatedItems: 2, + estimatedCost: 0.04, + estimatedSeconds: 6, + }); + expect(result.body.taskId).toMatch(/^[0-9A-Z]{26}$/); + + const task = await prisma.mediaTask.findUniqueOrThrow({ where: { id: result.body.taskId } }); + expect(task).toMatchObject({ + type: 'demo', + status: 'queued', + customerId: adminCustomerId, + customerName: `${CUSTOMER_PREFIX}管理员客户`, + itemTotal: 2, + itemSucceeded: 0, + itemFailed: 0, + itemSkipped: 0, + progress: 0, + estimatedItems: 2, + estimatedSeconds: 6, + createdBy: adminUserId, + createdByName: 'A6 任务admin', + }); + expect(task.estimatedCost.toNumber()).toBe(0.04); + expect(task.payloadJson).toMatchObject({ mode: 'mixed', itemCount: 2 }); + + const items = await prisma.mediaItem.findMany({ + where: { taskId: task.id }, + orderBy: { seq: 'asc' }, + }); + expect(items).toHaveLength(2); + expect(items.map((item) => [item.seq, item.status])).toEqual([ + [1, 'queued'], + [2, 'queued'], + ]); + + const events = await prisma.taskEvent.findMany({ + where: { taskId: task.id }, + orderBy: { seq: 'asc' }, + }); + expect(events).toHaveLength(1); + expect(events[0]?.eventType).toBe('progress'); + expect(events[0]?.payloadJson).toMatchObject({ status: 'queued', progress: 0, itemTotal: 2 }); + + const outbox = await prisma.outboxEvent.findFirst({ + where: { aggregateId: task.id, eventType: 'media.task.created' }, + }); + expect(outbox).not.toBeNull(); + expect(outbox?.payloadJson).toMatchObject({ + taskId: task.id, + type: 'demo', + itemIds: items.map((item) => item.id), + }); + + const idempotency = await prisma.idempotencyKey.findUnique({ + where: { scope_key: { scope: 'media.tasks.submit', key } }, + }); + expect(idempotency).not.toBeNull(); + expect(idempotency?.requestHash).toHaveLength(64); + expect(idempotency?.responseStatus).toBe(202); + expect(idempotency?.responseBody).toMatchObject({ taskId: task.id }); + const ttlMs = (idempotency?.expiresAt.getTime() ?? 0) - Date.now(); + expect(ttlMs).toBeGreaterThan(23 * 3600 * 1000); + expect(ttlMs).toBeLessThanOrEqual(24 * 3600 * 1000 + 60_000); + + const audit = await prisma.auditLog.findFirst({ + where: { actionCode: 'task.submit', targetId: task.id }, + }); + expect(audit?.customerId).toBe(adminCustomerId); + expect(audit?.result).toBe('success'); + }); + + it('重复提交同 key 同 body:返回同一 taskId,不新增任务 / 任务项 / outbox / 审计', async () => { + const key = uniqueKey('replay'); + const first = await submitTask(adminToken, adminCustomerId, 3, key); + expect(first.status).toBe(202); + + const taskCountBefore = await prisma.mediaTask.count({ + where: { customerId: adminCustomerId }, + }); + const itemCountBefore = await prisma.mediaItem.count({ where: { taskId: first.body.taskId } }); + const outboxCountBefore = await prisma.outboxEvent.count({ + where: { aggregateId: first.body.taskId }, + }); + const auditCountBefore = await prisma.auditLog.count({ + where: { actionCode: 'task.submit', targetId: first.body.taskId }, + }); + + const second = await submitTask(adminToken, adminCustomerId, 3, key); + expect(second.status).toBe(202); + expect(second.body).toEqual(first.body); + + expect(await prisma.mediaTask.count({ where: { customerId: adminCustomerId } })).toBe( + taskCountBefore, + ); + expect(await prisma.mediaItem.count({ where: { taskId: first.body.taskId } })).toBe( + itemCountBefore, + ); + expect(await prisma.outboxEvent.count({ where: { aggregateId: first.body.taskId } })).toBe( + outboxCountBefore, + ); + expect( + await prisma.auditLog.count({ + where: { actionCode: 'task.submit', targetId: first.body.taskId }, + }), + ).toBe(auditCountBefore); + }); + + it('缺 Idempotency-Key → 40001;同 key 不同 body → 40902', async () => { + const missing = await call( + 'POST', + '/media-tasks', + adminToken, + submitPayload(adminCustomerId, 1), + ); + expect(missing.status).toBe(400); + expect(missing.body.code).toBe(ErrorCode.PARAM_INVALID); + expect(missing.body.message).toContain('Idempotency-Key'); + + const key = uniqueKey('conflict'); + const first = await submitTask(adminToken, adminCustomerId, 1, key); + expect(first.status).toBe(202); + const second = await call( + 'POST', + '/media-tasks', + adminToken, + submitPayload(adminCustomerId, 2), + { [IDEMPOTENCY_KEY_HEADER]: key }, + ); + expect(second.status).toBe(409); + expect(second.body.code).toBe(ErrorCode.DUPLICATE_SUBMIT); + }); + + it('客户校验:范围外 40302、暂停客户 42201、非 demo 类型 40001、缺预估配置不适用(默认值兜底)', async () => { + const outOfScope = await call( + 'POST', + '/media-tasks', + limitedToken, + submitPayload(adminCustomerId, 1), + { [IDEMPOTENCY_KEY_HEADER]: uniqueKey('scope') }, + ); + expect(outOfScope.status).toBe(403); + expect(outOfScope.body.code).toBe(ErrorCode.OUT_OF_SCOPE); + + const paused = await call( + 'POST', + '/media-tasks', + adminToken, + submitPayload(pausedCustomerId, 1), + { [IDEMPOTENCY_KEY_HEADER]: uniqueKey('paused') }, + ); + expect(paused.status).toBe(422); + expect(paused.body.code).toBe(ErrorCode.BUSINESS_RULE_VIOLATION); + + const nonDemo = await call( + 'POST', + '/media-tasks', + adminToken, + { ...submitPayload(adminCustomerId, 1), type: 'script' }, + { [IDEMPOTENCY_KEY_HEADER]: uniqueKey('type') }, + ); + expect(nonDemo.status).toBe(400); + expect(nonDemo.body.code).toBe(ErrorCode.PARAM_INVALID); + expect(JSON.stringify(nonDemo.body)).toContain('M0 仅支持演练任务'); + + const overLimit = await call( + 'POST', + '/media-tasks', + adminToken, + submitPayload(adminCustomerId, 21), + { [IDEMPOTENCY_KEY_HEADER]: uniqueKey('limit') }, + ); + expect(overLimit.status).toBe(400); + expect(overLimit.body.code).toBe(ErrorCode.PARAM_INVALID); + }); +}); + +describe('A6 任务列表 / 详情(A6-BE-04 + 客户隔离)', () => { + it('列表返回 { items, next_cursor };筛选 type / status / customer_id / mine / keyword', async () => { + const created = await submitTask( + adminToken, + adminCustomerId, + 2, + uniqueKey('list-a'), + 'success', + ); + const result = await call( + 'GET', + `/media-tasks?type=demo&status=queued&customer_id=${adminCustomerId}&limit=100`, + adminToken, + ); + expect(result.status).toBe(200); + expect(Array.isArray(result.body.items)).toBe(true); + expect(result.body).toHaveProperty('next_cursor'); + const ids = result.body.items.map((item) => item.id); + expect(ids).toContain(created.body.taskId); + + const mine = await call('GET', '/media-tasks?mine=true&limit=100', adminToken); + expect(mine.body.items.every((item) => item.createdBy === adminUserId)).toBe(true); + + const keyword = await call( + 'GET', + `/media-tasks?keyword=${encodeURIComponent('演练任务')}&limit=100`, + adminToken, + ); + expect(keyword.body.items.length).toBeGreaterThan(0); + + const limited = await call('GET', '/media-tasks?limit=100', limitedToken); + expect(limited.body.items.every((item) => item.customerId === limitedCustomerId)).toBe(true); + }); + + it('游标分页稳定:limit=2 两页不重不漏', async () => { + for (let index = 0; index < 3; index += 1) { + await submitTask(adminToken, adminCustomerId, 1, uniqueKey(`page-${String(index)}`)); + } + const first = await call('GET', '/media-tasks?limit=2', adminToken); + expect(first.body.items).toHaveLength(2); + expect(first.body.next_cursor).not.toBeNull(); + const second = await call( + 'GET', + `/media-tasks?limit=2&cursor=${encodeURIComponent(first.body.next_cursor ?? '')}`, + adminToken, + ); + expect(second.status).toBe(200); + const ids = [...first.body.items, ...second.body.items].map((item) => item.id); + expect(new Set(ids).size).toBe(ids.length); + expect(ids.length).toBeGreaterThanOrEqual(3); + + const invalid = await call('GET', '/media-tasks?cursor=bad-cursor', adminToken); + expect(invalid.status).toBe(400); + expect(invalid.body.code).toBe(ErrorCode.PARAM_INVALID); + }); + + it('详情:首屏返回任务项与 payload;范围外 / 不存在统一 404;items_cursor 非法 40001', async () => { + const created = await submitTask(adminToken, adminCustomerId, 2, uniqueKey('detail')); + const detail = await taskRow(created.body.taskId, adminToken); + expect(detail.items).toHaveLength(2); + expect(detail.itemsNextCursor).toBeNull(); + expect(detail.payload).toMatchObject({ mode: 'success', itemCount: 2 }); + expect(detail.status).toBe('queued'); + + const cross = await call('GET', `/media-tasks/${created.body.taskId}`, limitedToken); + expect(cross.status).toBe(404); + expect(cross.body.code).toBe(ErrorCode.NOT_FOUND); + + const invalidCursor = await call( + 'GET', + `/media-tasks/${created.body.taskId}?items_cursor=bad-cursor`, + adminToken, + ); + expect(invalidCursor.status).toBe(400); + expect(invalidCursor.body.code).toBe(ErrorCode.PARAM_INVALID); + }); +}); + +describe('A6 取消(只影响未开始项)', () => { + it('排队任务取消:queued 项置 skipped,任务立即 canceled,写 done 事件 + canceled outbox', async () => { + const created = await submitTask(adminToken, adminCustomerId, 3, uniqueKey('cancel-queued')); + const cancel = await call<{ taskId: string; status: string }>( + 'POST', + `/media-tasks/${created.body.taskId}/cancel`, + adminToken, + ); + expect(cancel.status).toBe(201); + expect(cancel.body).toEqual({ taskId: created.body.taskId, status: 'canceled' }); + + const task = await prisma.mediaTask.findUniqueOrThrow({ where: { id: created.body.taskId } }); + expect(task.status).toBe('canceled'); + expect(task.finishedAt).not.toBeNull(); + expect(task.cancelRequestedAt).not.toBeNull(); + expect(task.canceledBy).toBe(adminUserId); + expect(task.itemSkipped).toBe(3); + expect(task.progress).toBe(100); + + const items = await prisma.mediaItem.findMany({ where: { taskId: created.body.taskId } }); + expect(items.every((item) => item.status === 'skipped')).toBe(true); + + const doneEvent = await prisma.taskEvent.findFirst({ + where: { taskId: created.body.taskId, eventType: 'done' }, + }); + expect(doneEvent).not.toBeNull(); + const canceledOutbox = await prisma.outboxEvent.findFirst({ + where: { aggregateId: created.body.taskId, eventType: 'media.task.canceled' }, + }); + expect(canceledOutbox).not.toBeNull(); + + const again = await call( + 'POST', + `/media-tasks/${created.body.taskId}/cancel`, + adminToken, + ); + expect(again.status).toBe(409); + expect(again.body.code).toBe(ErrorCode.STATE_NOT_ALLOWED); + + const cross = await call( + 'POST', + `/media-tasks/${created.body.taskId}/cancel`, + limitedToken, + ); + expect(cross.status).toBe(404); + }); + + it('生成中任务取消:已成功项不变、只跳过未开始项,任务等 worker 收敛', async () => { + const created = await submitTask(adminToken, adminCustomerId, 3, uniqueKey('cancel-running')); + const taskId = created.body.taskId; + const items = await prisma.mediaItem.findMany({ + where: { taskId }, + orderBy: { seq: 'asc' }, + }); + const [successItem, runningItem, queuedItem] = items; + expect(successItem && runningItem && queuedItem).toBeTruthy(); + if (successItem === undefined || runningItem === undefined || queuedItem === undefined) return; + await prisma.mediaTask.update({ + where: { id: taskId }, + data: { status: 'running', startedAt: new Date() }, + }); + await prisma.mediaItem.update({ + where: { id: successItem.id }, + data: { status: 'pending_review' }, + }); + await prisma.mediaItem.update({ where: { id: runningItem.id }, data: { status: 'running' } }); + + const cancel = await call<{ taskId: string; status: string }>( + 'POST', + `/media-tasks/${taskId}/cancel`, + adminToken, + ); + expect(cancel.status).toBe(201); + expect(cancel.body.status).toBe('running'); + + const after = await prisma.mediaTask.findUniqueOrThrow({ where: { id: taskId } }); + expect(after.status).toBe('running'); + expect(after.cancelRequestedAt).not.toBeNull(); + expect(after.itemSucceeded).toBe(1); + expect(after.itemSkipped).toBe(1); + + const itemsAfter = await prisma.mediaItem.findMany({ + where: { taskId }, + orderBy: { seq: 'asc' }, + }); + expect(itemsAfter.map((item) => item.status)).toEqual(['pending_review', 'running', 'skipped']); + }); +}); + +describe('A6 重试(只重失败项)', () => { + it('partial_failed 重试:失败项重置 queued(attempts 保留、失败原因清空),成功项不动', async () => { + const created = await submitTask(adminToken, adminCustomerId, 3, uniqueKey('retry'), 'mixed'); + const taskId = created.body.taskId; + const items = await prisma.mediaItem.findMany({ where: { taskId }, orderBy: { seq: 'asc' } }); + const [firstItem, failedItem, thirdItem] = items; + expect(firstItem && failedItem && thirdItem).toBeTruthy(); + if (firstItem === undefined || failedItem === undefined || thirdItem === undefined) return; + await prisma.mediaTask.update({ + where: { id: taskId }, + data: { + status: 'partial_failed', + itemSucceeded: 2, + itemFailed: 1, + progress: 100, + finishedAt: new Date(), + lastErrorCode: 'DEMO_FAIL', + lastErrorMessage: '1 项失败', + }, + }); + await prisma.mediaItem.update({ + where: { id: firstItem.id }, + data: { status: 'pending_review' }, + }); + await prisma.mediaItem.update({ + where: { id: failedItem.id }, + data: { + status: 'failed', + failureCode: 'DEMO_FAIL', + failureMessage: '演练失败', + attempts: 3, + finishedAt: new Date(), + }, + }); + await prisma.mediaItem.update({ + where: { id: thirdItem.id }, + data: { status: 'pending_review' }, + }); + + const retry = await call<{ taskId: string; status: string; retriedItems: number }>( + 'POST', + `/media-tasks/${taskId}/retry`, + adminToken, + ); + expect(retry.status).toBe(201); + expect(retry.body).toEqual({ taskId, status: 'queued', retriedItems: 1 }); + + const task = await prisma.mediaTask.findUniqueOrThrow({ where: { id: taskId } }); + expect(task.status).toBe('queued'); + expect(task.itemFailed).toBe(0); + expect(task.itemSucceeded).toBe(2); + expect(task.finishedAt).toBeNull(); + expect(task.lastErrorCode).toBeNull(); + + const itemsAfter = await prisma.mediaItem.findMany({ + where: { taskId }, + orderBy: { seq: 'asc' }, + }); + const reset = itemsAfter.find((item) => item.id === failedItem.id); + expect(reset).toMatchObject({ + status: 'queued', + attempts: 3, + failureCode: null, + failureMessage: null, + }); + expect(itemsAfter.find((item) => item.id === firstItem.id)?.status).toBe('pending_review'); + expect(itemsAfter.find((item) => item.id === thirdItem.id)?.status).toBe('pending_review'); + + const retryOutbox = await prisma.outboxEvent.findFirst({ + where: { aggregateId: taskId, eventType: 'media.task.retry' }, + }); + expect(retryOutbox?.payloadJson).toMatchObject({ + taskId, + type: 'demo', + itemIds: [failedItem.id], + }); + + const again = await call('POST', `/media-tasks/${taskId}/retry`, adminToken); + expect(again.status).toBe(409); + expect(again.body.code).toBe(ErrorCode.STATE_NOT_ALLOWED); + }); +}); + +describe('A6 SSE(Last-Event-ID 续推)', () => { + it('终态任务:返回 text/event-stream,续推不重复历史事件并补发 done', async () => { + const created = await submitTask(adminToken, adminCustomerId, 2, uniqueKey('sse')); + await call('POST', `/media-tasks/${created.body.taskId}/cancel`, adminToken); + const events = await prisma.taskEvent.findMany({ + where: { taskId: created.body.taskId }, + orderBy: { seq: 'asc' }, + }); + expect(events.length).toBeGreaterThanOrEqual(2); + // 从「取消写入的 progress 事件」续推:之后只剩 done,历史 item / progress 不应重发 + const lastProgress = [...events].reverse().find((event) => event.eventType === 'progress'); + expect(lastProgress).toBeDefined(); + const resumeSeq = lastProgress?.seq.toString() ?? '0'; + + const full = await callStream(`/media-tasks/${created.body.taskId}/events`, adminToken, { + 'last-event-id': '0', + }); + expect(full.status).toBe(200); + expect(String(full.headers['content-type'])).toContain('text/event-stream'); + expect(full.headers['x-accel-buffering']).toBe('no'); + expect(full.body).toContain('event: progress'); + expect(full.body).toContain('event: done'); + expect(full.body).toContain('id: '); + + const resumed = await callStream( + `/media-tasks/${created.body.taskId}/events?last_event_id=${resumeSeq}`, + adminToken, + ); + expect(resumed.status).toBe(200); + expect(resumed.body).toContain('event: done'); + expect(resumed.body).not.toContain('event: progress'); + expect(resumed.body).not.toContain('event: item'); + + const cross = await call( + 'GET', + `/media-tasks/${created.body.taskId}/events`, + limitedToken, + ); + expect(cross.status).toBe(404); + }); +}); + +describe('A6 结果 Outbox 本地消费(写审计)', () => { + it('media.task.finished → 写 task.finished 审计并置 published_at;created 事件不被本地消费', async () => { + const created = await submitTask( + adminToken, + adminCustomerId, + 1, + uniqueKey('result'), + 'success', + ); + const taskId = created.body.taskId; + + // created 事件属于 dispatcher(queue-worker),本地消费者不应认领 + const createdEvent = await prisma.outboxEvent.findFirstOrThrow({ + where: { aggregateId: taskId, eventType: 'media.task.created' }, + }); + + const finishedId = ulid(); + await prisma.outboxEvent.create({ + data: { + id: finishedId, + aggregateType: 'media_task', + aggregateId: taskId, + eventType: 'media.task.finished', + payloadJson: { taskId, status: 'pending_review' }, + traceId: 'itmedia-result', + }, + }); + await prisma.mediaTask.update({ + where: { id: taskId }, + data: { + status: 'pending_review', + itemSucceeded: 1, + progress: 100, + finishedAt: new Date(), + }, + }); + + let finishedEvent = await prisma.outboxEvent.findUniqueOrThrow({ where: { id: finishedId } }); + let audit = await prisma.auditLog.findFirst({ + where: { actionCode: 'task.finished', targetId: taskId }, + }); + for (let attempt = 0; attempt < 40; attempt += 1) { + if (finishedEvent.publishedAt !== null && audit !== null) break; + await sleep(150); + finishedEvent = await prisma.outboxEvent.findUniqueOrThrow({ where: { id: finishedId } }); + audit = await prisma.auditLog.findFirst({ + where: { actionCode: 'task.finished', targetId: taskId }, + }); + } + expect(finishedEvent.publishedAt).not.toBeNull(); + expect(audit).not.toBeNull(); + expect(audit?.actorId).toBe(adminUserId); + expect(audit?.targetType).toBe('task'); + expect(audit?.customerId).toBe(adminCustomerId); + expect(audit?.result).toBe('success'); + + // created 事件仍归 dispatcher 处理;本地消费者不应把它标记为已消费为审计 + const createdAudit = await prisma.auditLog.findFirst({ + where: { actionCode: 'task.created', targetId: taskId }, + }); + expect(createdAudit).toBeNull(); + expect(createdEvent.eventType).toBe('media.task.created'); + }); +}); diff --git a/apps/api/src/modules/media/media.module.ts b/apps/api/src/modules/media/media.module.ts new file mode 100644 index 0000000..184a9a8 --- /dev/null +++ b/apps/api/src/modules/media/media.module.ts @@ -0,0 +1,31 @@ +import { Module } from '@nestjs/common'; + +import { MediaTaskController } from './media-task.controller'; +import { MediaTaskRepository } from './media-task.repository'; +import { MediaTaskService } from './media-task.service'; +import { MediaTaskEstimator } from './media-task-estimator'; +import { MediaTaskResultConsumer } from './media-task-result-consumer.service'; +import { MediaTaskSseService } from './media-task-sse.service'; + +/** + * media 模块(A6 任务中心): + * - `POST/GET /media-tasks`、详情、SSE、取消、仅重试失败项(A6-BE-01 / 03 / 04); + * - 提交只写 `outbox_events`(media.task.created),**不直接入队**(BullMQ 在 infra/queue,由 worker 侧投递); + * - 本地消费 `media.task.finished` / `media.task.failed` 写审计(与 dispatcher 事件类型不重叠)。 + * + * 依赖说明:PrismaModule / SecurityModule / PlatformModule(SystemConfigService / AUDIT_SINK)/ + * ClientModule(CustomersService)均为 @Global,本模块不反向依赖 identity / platform 实现细节。 + * 由 AppModule 统一接线(Lead 负责)。 + */ +@Module({ + controllers: [MediaTaskController], + providers: [ + MediaTaskRepository, + MediaTaskEstimator, + MediaTaskService, + MediaTaskSseService, + MediaTaskResultConsumer, + ], + exports: [MediaTaskService], +}) +export class MediaModule {} diff --git a/apps/web/src/api/generated/openapi.d.ts b/apps/web/src/api/generated/openapi.d.ts index 6a7263f..bd8741b 100644 --- a/apps/web/src/api/generated/openapi.d.ts +++ b/apps/web/src/api/generated/openapi.d.ts @@ -4,6 +4,26 @@ */ export interface paths { + '/api/v1/health/queues': { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + /** + * 队列健康检查 + * @description 返回 9 个队列的计数、队首等待时长与 worker 心跳;Redis 不可用时返回 503(错误码 50002)。 + */ + get: operations['QueueHealthController_queues']; + put?: never; + post?: never; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; '/api/v1/auth/login': { parameters: { query?: never; @@ -716,6 +736,92 @@ export interface paths { patch: operations['DictsController_updateItem']; trace?: never; }; + '/api/v1/media-tasks': { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + /** 任务列表(类型 / 状态 / 客户 / 时间 / 我的 / 关键字筛选;稳定排序 createdAt DESC, id DESC) */ + get: operations['MediaTaskController_list']; + put?: never; + /** 提交任务(异步 202;M0 仅演练任务;必带 Idempotency-Key,同 key 同请求体 24h 内返回同一任务) */ + post: operations['MediaTaskController_submit']; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + '/api/v1/media-tasks/{id}/events': { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + /** 任务 SSE 进度(id 用 task_events.seq;支持 Last-Event-ID / last_event_id 续推,15s 心跳) */ + get: operations['MediaTaskController_events']; + put?: never; + post?: never; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + '/api/v1/media-tasks/{id}': { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + /** 任务详情(首 100 条任务项 + itemsNextCursor;范围外 404) */ + get: operations['MediaTaskController_detail']; + put?: never; + post?: never; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + '/api/v1/media-tasks/{id}/cancel': { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get?: never; + put?: never; + /** 取消任务(仅排队 / 生成中;只把未开始项置 skipped,已成功项不受影响) */ + post: operations['MediaTaskController_cancel']; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + '/api/v1/media-tasks/{id}/retry': { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get?: never; + put?: never; + /** 重试任务(仅部分失败;只重置失败项,成功项不动) */ + post: operations['MediaTaskController_retry']; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; '/health': { parameters: { query?: never; @@ -911,6 +1017,74 @@ export interface paths { export type webhooks = Record; export interface components { schemas: { + QueueCountsDto: { + /** + * @description 等待中任务数 + * @example 0 + */ + waiting: number; + /** + * @description 执行中任务数 + * @example 0 + */ + active: number; + /** + * @description 已完成任务数(保留窗口内的历史计数) + * @example 12 + */ + completed: number; + /** + * @description 失败任务数(保留窗口内的历史计数) + * @example 1 + */ + failed: number; + /** + * @description 延迟任务数 + * @example 0 + */ + delayed: number; + }; + WorkerHeartbeatDto: { + /** @description 最近 30s 内是否有 worker 心跳 */ + present: boolean; + /** + * @description 心跳年龄(秒);心跳缺失或值非法时为 null + * @example 3 + */ + ageSeconds: number | null; + }; + QueueHealthEntryDto: { + /** + * @description 队列名 + * @example media:verify + */ + name: string; + /** + * @description 队列组 + * @example verify + */ + group: string; + counts: components['schemas']['QueueCountsDto']; + /** + * @description 队首任务等待秒数 + * @example 0 + */ + oldestWaitingSeconds: number; + workerHeartbeat: components['schemas']['WorkerHeartbeatDto']; + }; + QueueHealthReportDto: { + /** + * @example ok + * @enum {string} + */ + status: 'ok' | 'degraded'; + /** + * @example ok + * @enum {string} + */ + redis: 'ok'; + queues: components['schemas']['QueueHealthEntryDto'][]; + }; ComponentHealthDto: { /** * @example up @@ -1018,6 +1192,33 @@ export interface components { } export type $defs = Record; export interface operations { + QueueHealthController_queues: { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description 队列状态快照(可能为 degraded) */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + 'application/json': components['schemas']['QueueHealthReportDto']; + }; + }; + /** @description Redis 不可用(错误码 50002) */ + 503: { + headers: { + [name: string]: unknown; + }; + content?: never; + }; + }; + }; AuthController_login: { parameters: { query?: never; @@ -1972,6 +2173,108 @@ export interface operations { }; }; }; + MediaTaskController_list: { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + requestBody?: never; + responses: { + 200: { + headers: { + [name: string]: unknown; + }; + content?: never; + }; + }; + }; + MediaTaskController_submit: { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + requestBody?: never; + responses: { + 202: { + headers: { + [name: string]: unknown; + }; + content?: never; + }; + }; + }; + MediaTaskController_events: { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + requestBody?: never; + responses: { + 200: { + headers: { + [name: string]: unknown; + }; + content?: never; + }; + }; + }; + MediaTaskController_detail: { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + requestBody?: never; + responses: { + 200: { + headers: { + [name: string]: unknown; + }; + content?: never; + }; + }; + }; + MediaTaskController_cancel: { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + requestBody?: never; + responses: { + 201: { + headers: { + [name: string]: unknown; + }; + content?: never; + }; + }; + }; + MediaTaskController_retry: { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + requestBody?: never; + responses: { + 201: { + headers: { + [name: string]: unknown; + }; + content?: never; + }; + }; + }; HealthController_check: { parameters: { query?: never; diff --git a/apps/worker/package.json b/apps/worker/package.json index fb216f0..a7762ff 100644 --- a/apps/worker/package.json +++ b/apps/worker/package.json @@ -6,10 +6,13 @@ "dev": "tsx watch src/main.ts", "build": "tsc -p tsconfig.build.json", "start": "node dist/main.js", + "test": "vitest run --coverage", "typecheck": "tsc -p tsconfig.json --noEmit" }, "dependencies": { "@lingxi/shared": "workspace:*", + "@prisma/client": "6.19.3", + "bullmq": "5.81.5", "dotenv": "18.0.9", "ioredis": "5.11.1", "pino": "10.4.0", @@ -17,7 +20,9 @@ }, "devDependencies": { "@types/node": "24.19.2", + "@vitest/coverage-v8": "3.2.7", "tsx": "4.23.15", - "typescript": "5.9.3" + "typescript": "5.9.3", + "vitest": "3.2.7" } } diff --git a/apps/worker/src/config.spec.ts b/apps/worker/src/config.spec.ts new file mode 100644 index 0000000..0d6c8e4 --- /dev/null +++ b/apps/worker/src/config.spec.ts @@ -0,0 +1,58 @@ +import { TASK_QUEUES } from '@lingxi/shared'; +import { describe, expect, it } from 'vitest'; + +import { loadWorkerConfig } from './config'; + +const BASE_ENV: NodeJS.ProcessEnv = { + REDIS_URL: 'redis://127.0.0.1:6379/1', + DATABASE_URL: 'postgresql://postgres:postgres@127.0.0.1:5432/lingxi?schema=public', +}; + +describe('worker 配置解析', () => { + it('QUEUE=all 启动全部 9 个队列,并发取 TASK_QUEUES 定义值', () => { + const config = loadWorkerConfig({ ...BASE_ENV, QUEUE: 'all' }); + expect(config.queues).toHaveLength(TASK_QUEUES.length); + expect(config.queues.map((queue) => queue.name)).toEqual( + TASK_QUEUES.map((queue) => queue.name), + ); + const verify = config.queues.find((queue) => queue.name === 'media:verify'); + const defined = TASK_QUEUES.find((queue) => queue.name === 'media:verify'); + expect(verify?.concurrency).toBe(defined?.concurrency); + expect(verify?.attempts).toBe(defined?.attempts); + expect(verify?.backoffDelayMs).toBe(defined?.backoffDelayMs); + }); + + it('QUEUE=verify 只启动 verify 组(M0 精简 worker)', () => { + const config = loadWorkerConfig({ ...BASE_ENV, QUEUE: 'verify' }); + expect(config.queues.map((queue) => queue.name)).toEqual(['media:verify']); + }); + + it('WORKER_CONCURRENCY 覆盖全部队列并发', () => { + const config = loadWorkerConfig({ ...BASE_ENV, QUEUE: 'verify', WORKER_CONCURRENCY: '7' }); + expect(config.queues[0]?.concurrency).toBe(7); + }); + + it('QUEUE 非法值给出中文原因(含旧值 default)', () => { + expect(() => loadWorkerConfig({ ...BASE_ENV, QUEUE: 'default' })).toThrow( + /QUEUE 配置非法.*未知队列/, + ); + expect(() => loadWorkerConfig({ ...BASE_ENV, QUEUE: 'all,verify' })).toThrow( + /QUEUE 配置非法.*不能与其他分组/, + ); + }); + + it('缺少或非法连接串时中文报错', () => { + expect(() => loadWorkerConfig({ QUEUE: 'all' })).toThrow(/REDIS_URL/); + expect(() => loadWorkerConfig({ ...BASE_ENV, DATABASE_URL: 'mysql://x' })).toThrow( + /DATABASE_URL/, + ); + }); + + it('WORKER_ID 默认 hostname-pid,可显式覆盖', () => { + const config = loadWorkerConfig({ ...BASE_ENV, QUEUE: 'verify' }); + expect(config.workerId).toContain(`-${String(process.pid)}`); + expect(loadWorkerConfig({ ...BASE_ENV, QUEUE: 'verify', WORKER_ID: 'w-1' }).workerId).toBe( + 'w-1', + ); + }); +}); diff --git a/apps/worker/src/config.ts b/apps/worker/src/config.ts index 9da7a2e..73c4563 100644 --- a/apps/worker/src/config.ts +++ b/apps/worker/src/config.ts @@ -1,5 +1,9 @@ +import os from 'node:os'; + import { z } from 'zod'; +import { buildQueueDefinitions, parseQueueSpec, type WorkerQueueDefinition } from './queues'; + const envSchema = z.object({ NODE_ENV: z.enum(['development', 'test', 'production']).default('development'), LOG_LEVEL: z.enum(['fatal', 'error', 'warn', 'info', 'debug', 'trace']).default('info'), @@ -10,18 +14,38 @@ const envSchema = z.object({ (value) => value.startsWith('redis://') || value.startsWith('rediss://'), 'REDIS_URL 必须是 redis:// 连接串', ), - QUEUE: z.string().min(1).default('default'), - WORKER_CONCURRENCY: z.coerce.number().int().min(1).max(64).default(2), + DATABASE_URL: z + .string() + .min(1) + .refine( + (value) => value.startsWith('postgresql://') || value.startsWith('postgres://'), + 'DATABASE_URL 必须是 postgresql:// 连接串', + ), + QUEUE: z.string().min(1).default('all'), + WORKER_CONCURRENCY: z.coerce.number().int().min(1).max(64).optional(), + WORKER_ID: z.string().min(1).max(128).optional(), }); export interface WorkerConfig { nodeEnv: 'development' | 'test' | 'production'; logLevel: 'fatal' | 'error' | 'warn' | 'info' | 'debug' | 'trace'; redisUrl: string; - queue: string; - concurrency: number; + databaseUrl: string; + queueSpec: string; + queues: WorkerQueueDefinition[]; + workerId: string; } +function defaultWorkerId(): string { + return `${os.hostname()}-${String(process.pid)}`; +} + +/** + * 加载并校验 worker 环境变量: + * - QUEUE:`all` 或逗号分隔的队列组名(script/image/video/export/verify)/ 队列名(如 media:verify); + * - WORKER_CONCURRENCY:覆盖全部队列并发;缺省取 TASK_QUEUES 中每个队列的定义值; + * - WORKER_ID:默认 hostname + pid,用于心跳与日志定位。 + */ export function loadWorkerConfig(source: NodeJS.ProcessEnv = process.env): WorkerConfig { const result = envSchema.safeParse(source); if (!result.success) { @@ -30,11 +54,24 @@ export function loadWorkerConfig(source: NodeJS.ProcessEnv = process.env): Worke .join('; '); throw new Error(`Worker 环境变量校验失败:${details}`); } + const env = result.data; + + let queues: WorkerQueueDefinition[]; + try { + queues = buildQueueDefinitions(parseQueueSpec(env.QUEUE), env.WORKER_CONCURRENCY); + } catch (error) { + throw new Error( + `QUEUE 配置非法(当前值 ${env.QUEUE}):${error instanceof Error ? error.message : String(error)}`, + ); + } + return { - nodeEnv: result.data.NODE_ENV, - logLevel: result.data.LOG_LEVEL, - redisUrl: result.data.REDIS_URL, - queue: result.data.QUEUE, - concurrency: result.data.WORKER_CONCURRENCY, + nodeEnv: env.NODE_ENV, + logLevel: env.LOG_LEVEL, + redisUrl: env.REDIS_URL, + databaseUrl: env.DATABASE_URL, + queueSpec: env.QUEUE, + queues, + workerId: env.WORKER_ID ?? defaultWorkerId(), }; } diff --git a/apps/worker/src/heartbeat.spec.ts b/apps/worker/src/heartbeat.spec.ts new file mode 100644 index 0000000..03eb3ff --- /dev/null +++ b/apps/worker/src/heartbeat.spec.ts @@ -0,0 +1,50 @@ +import { describe, expect, it, vi } from 'vitest'; + +import { + buildHeartbeat, + clearWorkerHeartbeat, + heartbeatKey, + serializeHeartbeat, + WORKER_HEARTBEAT_INTERVAL_MS, + WORKER_HEARTBEAT_TTL_SECONDS, + writeWorkerHeartbeat, +} from './heartbeat'; + +describe('worker 心跳', () => { + it('key 统一为 lingxi:worker:heartbeat:', () => { + expect(heartbeatKey('media:verify')).toBe('lingxi:worker:heartbeat:media:verify'); + expect(heartbeatKey('script:generate')).toBe('lingxi:worker:heartbeat:script:generate'); + }); + + it('心跳内容含 workerId / pid / queue / updatedAt(ISO 字符串)', () => { + const updatedAt = new Date('2026-11-04T12:00:00.000Z'); + const payload = buildHeartbeat('media:verify', 'w-1', 123, updatedAt); + expect(payload).toEqual({ + workerId: 'w-1', + pid: 123, + queue: 'media:verify', + updatedAt: '2026-11-04T12:00:00.000Z', + }); + expect(JSON.parse(serializeHeartbeat(payload))).toEqual(payload); + }); + + it('心跳 TTL 30s、续写间隔 10s(与 API 侧读取口径一致)', () => { + expect(WORKER_HEARTBEAT_TTL_SECONDS).toBe(30); + expect(WORKER_HEARTBEAT_INTERVAL_MS).toBe(10_000); + }); + + it('写心跳使用 EX 30,退出时删除心跳', async () => { + const set = vi.fn().mockResolvedValue('OK'); + const del = vi.fn().mockResolvedValue(1); + const client = { set, del }; + await writeWorkerHeartbeat(client, 'media:verify', 'w-1'); + expect(set).toHaveBeenCalledWith( + 'lingxi:worker:heartbeat:media:verify', + expect.stringContaining('"workerId":"w-1"'), + 'EX', + 30, + ); + await clearWorkerHeartbeat(client, 'media:verify'); + expect(del).toHaveBeenCalledWith('lingxi:worker:heartbeat:media:verify'); + }); +}); diff --git a/apps/worker/src/heartbeat.ts b/apps/worker/src/heartbeat.ts new file mode 100644 index 0000000..d8a8b3a --- /dev/null +++ b/apps/worker/src/heartbeat.ts @@ -0,0 +1,61 @@ +import type { TaskQueueName } from '@lingxi/shared'; +/** 心跳 key 前缀(跨 app 契约,与 apps/api/src/infra/queue/queue.constants.ts 保持一致) */ +export const WORKER_HEARTBEAT_KEY_PREFIX = 'lingxi:worker:heartbeat:'; + +/** 心跳 TTL(秒):worker 每 10s 续写一次,API 侧超过 TTL 视为心跳缺失 */ +export const WORKER_HEARTBEAT_TTL_SECONDS = 30; + +/** 心跳续写间隔(毫秒) */ +export const WORKER_HEARTBEAT_INTERVAL_MS = 10_000; + +export interface HeartbeatRedis { + set(key: string, value: string, mode: 'EX', seconds: number): Promise; + del(key: string): Promise; +} + +export interface WorkerHeartbeatPayload { + workerId: string; + pid: number; + queue: TaskQueueName; + updatedAt: string; +} + +/** `lingxi:worker:heartbeat:` */ +export function heartbeatKey(queue: TaskQueueName): string { + return `${WORKER_HEARTBEAT_KEY_PREFIX}${queue}`; +} + +export function buildHeartbeat( + queue: TaskQueueName, + workerId: string, + pid: number, + updatedAt: Date = new Date(), +): WorkerHeartbeatPayload { + return { workerId, pid, queue, updatedAt: updatedAt.toISOString() }; +} + +export function serializeHeartbeat(payload: WorkerHeartbeatPayload): string { + return JSON.stringify(payload); +} + +export async function writeWorkerHeartbeat( + client: HeartbeatRedis, + queue: TaskQueueName, + workerId: string, + now: Date = new Date(), +): Promise { + const payload = buildHeartbeat(queue, workerId, process.pid, now); + await client.set( + heartbeatKey(queue), + serializeHeartbeat(payload), + 'EX', + WORKER_HEARTBEAT_TTL_SECONDS, + ); +} + +export async function clearWorkerHeartbeat( + client: HeartbeatRedis, + queue: TaskQueueName, +): Promise { + await client.del(heartbeatKey(queue)); +} diff --git a/apps/worker/src/main.ts b/apps/worker/src/main.ts index 064903a..1899086 100644 --- a/apps/worker/src/main.ts +++ b/apps/worker/src/main.ts @@ -1,11 +1,21 @@ import { existsSync } from 'node:fs'; import { resolve } from 'node:path'; +import { PrismaClient } from '@prisma/client'; +import { type Job, Worker } from 'bullmq'; import { config as loadDotenvFile } from 'dotenv'; import Redis from 'ioredis'; -import pino from 'pino'; +import pino, { type Logger } from 'pino'; -import { loadWorkerConfig } from './config'; +import { loadWorkerConfig, type WorkerConfig } from './config'; +import { + clearWorkerHeartbeat, + WORKER_HEARTBEAT_INTERVAL_MS, + writeWorkerHeartbeat, +} from './heartbeat'; +import { isDemoJob, parseTaskJobData, processDemoJob, type TaskJobData } from './processors'; +import { toBullQueueName, type WorkerQueueDefinition } from './queues'; +import { parseRedisOptions, type RedisConnectionOptions } from './redis-options'; function loadDotenv(): void { for (const file of ['.env.local', '.env']) { @@ -16,34 +26,149 @@ function loadDotenv(): void { } } -async function main(): Promise { - loadDotenv(); - const config = loadWorkerConfig(); - const logger = pino({ level: config.logLevel, base: { service: 'lingxi-worker' } }); +interface WorkerRuntime { + config: WorkerConfig; + logger: Logger; + heartbeatRedis: Redis; + prisma: PrismaClient; + workers: Worker[]; + heartbeatTimer: NodeJS.Timeout; +} - const redis = new Redis(config.redisUrl, { +function createHeartbeatRedis(config: WorkerConfig, logger: Logger): Redis { + const client = new Redis(config.redisUrl, { lazyConnect: true, - maxRetriesPerRequest: null, + maxRetriesPerRequest: 1, enableOfflineQueue: false, }); - redis.on('error', (error: Error) => { - logger.error({ err: error }, 'redis connection error'); + client.on('error', (error: Error) => { + logger.warn({ err: error.message }, '心跳 Redis 连接异常'); }); + return client; +} - await redis.connect(); - await redis.ping(); - logger.info({ queue: config.queue, concurrency: config.concurrency }, 'worker 已启动,等待任务'); +/** + * job 分发:M0 只有 `media:verify` 的 `task:demo`。 + * 其余队列 / job 在本里程碑没有处理器:记录告警后直接完成,避免无意义重试。 + */ +function createJobHandler( + config: WorkerConfig, + prisma: PrismaClient, + logger: Logger, +): (job: Job) => Promise { + // BullMQ 的 Job.queueName 是物理队列名(media-verify),按物理名反查队列定义 + const definitionByPhysicalName = new Map( + config.queues.map((definition) => [toBullQueueName(definition.name), definition]), + ); + return async (job) => { + const data = parseTaskJobData(job.data); + if (isDemoJob(job.queueName, job.name)) { + const definition = definitionByPhysicalName.get(job.queueName); + const outcome = await processDemoJob( + prisma, + data, + job.attemptsMade, + definition?.attempts ?? 1, + ); + logger.debug({ taskId: data.taskId, itemId: data.itemId, outcome }, '演练任务项处理完成'); + return; + } + logger.warn( + { queue: job.queueName, job: job.name }, + '该队列在本里程碑没有处理器,任务项直接完成', + ); + }; +} - // A0 只验证进程与依赖可用;BullMQ 消费者在 M1 批次接入(QUEUE 环境变量决定队列) - const heartbeat = setInterval(() => { - logger.debug({ queue: config.queue }, 'worker heartbeat'); - }, 30_000); - heartbeat.unref(); +function createWorker( + definition: WorkerQueueDefinition, + config: WorkerConfig, + prisma: PrismaClient, + logger: Logger, + connection: RedisConnectionOptions, +): Worker { + const handler = createJobHandler(config, prisma, logger); + return new Worker( + toBullQueueName(definition.name), + async (job) => { + await handler(job); + }, + { connection, concurrency: definition.concurrency }, + ); +} +function attachWorkerLogging( + worker: Worker, + queue: string, + logger: Logger, +): void { + worker.on('completed', (job) => { + logger.debug({ queue, jobId: job.id }, 'job 处理完成'); + }); + worker.on('failed', (job, error) => { + logger.warn({ queue, jobId: job?.id, err: error.message }, 'job 处理失败'); + }); + worker.on('error', (error: Error) => { + logger.error({ queue, err: error.message }, 'worker 连接错误'); + }); +} + +async function writeAllHeartbeats( + client: Redis, + config: WorkerConfig, + logger: Logger, +): Promise { + for (const definition of config.queues) { + try { + await writeWorkerHeartbeat(client, definition.name, config.workerId); + } catch (error) { + logger.warn( + { + queue: definition.name, + err: error instanceof Error ? error.message : String(error), + }, + 'worker 心跳写入失败', + ); + } + } +} + +function registerShutdown(runtime: WorkerRuntime): void { + let shuttingDown = false; const shutdown = async (signal: string): Promise => { - clearInterval(heartbeat); - logger.info({ signal }, 'worker 正在退出'); - await redis.quit(); + if (shuttingDown) { + return; + } + shuttingDown = true; + runtime.logger.info({ signal }, 'worker 正在退出'); + clearInterval(runtime.heartbeatTimer); + + await Promise.all( + runtime.workers.map(async (worker) => { + try { + await worker.close(); + } catch (error) { + runtime.logger.warn( + { err: error instanceof Error ? error.message : String(error) }, + '关闭 worker 失败', + ); + } + }), + ); + for (const definition of runtime.config.queues) { + try { + await clearWorkerHeartbeat(runtime.heartbeatRedis, definition.name); + } catch { + // Redis 已不可用时无需阻塞退出 + } + } + try { + await runtime.heartbeatRedis.quit(); + } catch { + runtime.heartbeatRedis.disconnect(); + } + await runtime.prisma.$disconnect(); + runtime.logger.info('worker 已退出'); }; process.on('SIGINT', () => { @@ -54,7 +179,47 @@ async function main(): Promise { }); } -void main().catch((error: unknown) => { +async function bootstrap(): Promise { + loadDotenv(); + const config = loadWorkerConfig(); + const logger = pino({ + level: config.logLevel, + base: { service: 'lingxi-worker', workerId: config.workerId }, + }); + + const heartbeatRedis = createHeartbeatRedis(config, logger); + await heartbeatRedis.connect(); + await heartbeatRedis.ping(); + + const prisma = new PrismaClient({ datasources: { db: { url: config.databaseUrl } } }); + await prisma.$queryRaw`SELECT 1`; + + const connection = parseRedisOptions(config.redisUrl); + const workers = config.queues.map((definition) => { + const worker = createWorker(definition, config, prisma, logger, connection); + attachWorkerLogging(worker, definition.name, logger); + return worker; + }); + + await writeAllHeartbeats(heartbeatRedis, config, logger); + const heartbeatTimer = setInterval(() => { + void writeAllHeartbeats(heartbeatRedis, config, logger); + }, WORKER_HEARTBEAT_INTERVAL_MS); + heartbeatTimer.unref(); + + logger.info( + { + queues: config.queues.map((definition) => definition.name), + queueSpec: config.queueSpec, + concurrency: config.queues.map((definition) => definition.concurrency), + }, + 'worker 已启动,等待任务', + ); + + registerShutdown({ config, logger, heartbeatRedis, prisma, workers, heartbeatTimer }); +} + +void bootstrap().catch((error: unknown) => { process.stderr.write( `Worker 启动失败:${error instanceof Error ? (error.stack ?? error.message) : String(error)}\n`, ); diff --git a/apps/worker/src/processors/demo.processor.spec.ts b/apps/worker/src/processors/demo.processor.spec.ts new file mode 100644 index 0000000..74d5515 --- /dev/null +++ b/apps/worker/src/processors/demo.processor.spec.ts @@ -0,0 +1,72 @@ +import { isTaskCountersFinished, resolveFinishedStatus, type TaskCounters } from '@lingxi/shared'; +import { describe, expect, it } from 'vitest'; + +import { + applyItemResult, + decideDemoOutcome, + demoDurationMs, + normalizeDemoMode, +} from './demo.processor'; + +const RUNNING_COUNTERS: TaskCounters = { + itemTotal: 4, + itemSucceeded: 0, + itemFailed: 0, + itemSkipped: 0, +}; + +describe('演练任务判定(纯函数)', () => { + it('success 模式全部成功;mixed 模式 seq%4===0 失败;fail 模式全失败', () => { + expect(decideDemoOutcome('success', 4, 0, 2)).toBe('success'); + expect(decideDemoOutcome('mixed', 3, 0, 2)).toBe('success'); + expect(decideDemoOutcome('mixed', 4, 0, 2)).toBe('retry'); + expect(decideDemoOutcome('mixed', 4, 1, 2)).toBe('failure'); + expect(decideDemoOutcome('fail', 1, 0, 3)).toBe('retry'); + expect(decideDemoOutcome('fail', 1, 2, 3)).toBe('failure'); + expect(decideDemoOutcome('fail', 1, 0, 1)).toBe('failure'); + }); + + it('未知 mode 回退 success(脏数据不阻塞任务收敛)', () => { + expect(normalizeDemoMode(undefined)).toBe('success'); + expect(normalizeDemoMode('weird')).toBe('success'); + expect(normalizeDemoMode('mixed')).toBe('mixed'); + }); + + it('耗时公式确定性:300 + seq%5*150', () => { + expect(demoDurationMs(1)).toBe(450); + expect(demoDurationMs(5)).toBe(300); + expect(demoDurationMs(4)).toBe(900); + }); + + it('任务项计数按结果原子累加,不丢其他计数', () => { + expect(applyItemResult(RUNNING_COUNTERS, 'succeeded')).toEqual({ + ...RUNNING_COUNTERS, + itemSucceeded: 1, + }); + expect(applyItemResult(RUNNING_COUNTERS, 'failed').itemFailed).toBe(1); + expect(applyItemResult(RUNNING_COUNTERS, 'skipped').itemSkipped).toBe(1); + }); + + it('终态判定使用 shared 契约(成功→待预览 / 部分失败 / 全部失败 / 取消优先)', () => { + const allSucceeded: TaskCounters = { + itemTotal: 2, + itemSucceeded: 2, + itemFailed: 0, + itemSkipped: 0, + }; + const partial: TaskCounters = { itemTotal: 2, itemSucceeded: 1, itemFailed: 1, itemSkipped: 0 }; + const allFailed: TaskCounters = { + itemTotal: 2, + itemSucceeded: 0, + itemFailed: 2, + itemSkipped: 0, + }; + + expect(isTaskCountersFinished(partial)).toBe(true); + expect(isTaskCountersFinished(RUNNING_COUNTERS)).toBe(false); + expect(resolveFinishedStatus(allSucceeded, false)).toBe('pending_review'); + expect(resolveFinishedStatus(partial, false)).toBe('partial_failed'); + expect(resolveFinishedStatus(allFailed, false)).toBe('failed'); + expect(resolveFinishedStatus(partial, true)).toBe('canceled'); + }); +}); diff --git a/apps/worker/src/processors/demo.processor.ts b/apps/worker/src/processors/demo.processor.ts new file mode 100644 index 0000000..ea58644 --- /dev/null +++ b/apps/worker/src/processors/demo.processor.ts @@ -0,0 +1,462 @@ +/** + * 演练任务(demo)处理器 —— A6-BE-02 的最小可执行链路。 + * + * 设计要点: + * 1. 事务 + 行锁:先 `SELECT ... FOR UPDATE` 锁任务头再锁任务项,保证同一任务的并发项计数不丢; + * 2. 幂等:任务项已是终态 / 已被其他 job 抢先执行(条件更新 count=0)→ 直接静默完成; + * 3. 取消:任务头 `cancel_requested_at` 非空 → 任务项标 skipped、写事件、按需收敛终态后快速返回; + * 4. 重试:非最后一次尝试失败 → 任务项置回 queued 后抛错,由 BullMQ 按队列定义退避重试; + * 5. 收敛:全部任务项终态时在同一事务内写 done 事件与 outbox 事件(finished / failed)。 + * + * 纯函数(decideDemoOutcome / applyItemResult / demoDurationMs)单独导出,便于单测。 + */ +import { + isTaskCountersFinished, + isTaskTerminal, + resolveFinishedStatus, + TASK_ITEM_STATUSES, + TASK_OUTBOX_EVENTS, + TASK_STATUSES, + type TaskCounters, + type TaskDoneEventPayload, + type TaskEventType, + type TaskItemEventPayload, + type TaskItemStatus, + taskProgress, + type TaskProgressEventPayload, + type TaskStatus, + ulid, +} from '@lingxi/shared'; +import { Prisma, type PrismaClient } from '@prisma/client'; + +export interface DemoJobData { + taskId: string; + itemId: string; +} + +export type DemoMode = 'success' | 'mixed' | 'fail'; + +/** 单次尝试的判定结果:成功 / 最后一次尝试失败 / 非最后尝试失败需重试 */ +export type DemoAttemptOutcome = 'success' | 'failure' | 'retry'; + +export type DemoProcessorOutcome = 'succeeded' | 'failed' | 'skipped' | 'canceled' | 'retry'; + +/** 任务项结果计数增减(纯函数) */ +export function applyItemResult( + counters: TaskCounters, + result: 'succeeded' | 'failed' | 'skipped', +): TaskCounters { + switch (result) { + case 'succeeded': + return { ...counters, itemSucceeded: counters.itemSucceeded + 1 }; + case 'failed': + return { ...counters, itemFailed: counters.itemFailed + 1 }; + case 'skipped': + return { ...counters, itemSkipped: counters.itemSkipped + 1 }; + } +} + +/** 确定性耗时(300ms + seq%5*150ms),便于测试与本地演示 */ +export function demoDurationMs(seq: number): number { + return 300 + (seq % 5) * 150; +} + +export function normalizeDemoMode(value: unknown): DemoMode { + return value === 'mixed' || value === 'fail' ? value : 'success'; +} + +/** + * 判定本次尝试结果(纯函数): + * - success:全成功,或 mixed 模式下 seq 不是 4 的倍数; + * - failure:最后一次尝试仍失败 → 任务项置 failed; + * - retry:非最后一次尝试失败 → 任务项置回 queued 并抛错交给 BullMQ 重试。 + */ +export function decideDemoOutcome( + mode: DemoMode, + seq: number, + attemptsMade: number, + maxAttempts: number, +): DemoAttemptOutcome { + const succeeded = mode === 'success' || (mode === 'mixed' && seq % 4 !== 0); + if (succeeded) { + return 'success'; + } + return attemptsMade + 1 >= maxAttempts ? 'failure' : 'retry'; +} + +// --------------------------------------------------------------------------- +// DB 行类型与只读查询 +// --------------------------------------------------------------------------- + +interface TaskRow { + id: string; + status: string; + payload_json: unknown; + item_total: number; + item_succeeded: number; + item_failed: number; + item_skipped: number; + cancel_requested_at: Date | null; +} + +interface ItemRow { + id: string; + seq: number; + status: string; +} + +function isTaskStatus(value: string): value is TaskStatus { + return TASK_STATUSES.some((status) => status === value); +} + +function isItemStatus(value: string): value is TaskItemStatus { + return TASK_ITEM_STATUSES.some((status) => status === value); +} + +function isRecord(value: unknown): value is Record { + return typeof value === 'object' && value !== null && !Array.isArray(value); +} + +function modeOf(payload: unknown): DemoMode { + return isRecord(payload) ? normalizeDemoMode(payload.mode) : 'success'; +} + +function countersOfTask(task: TaskRow): TaskCounters { + return { + itemTotal: task.item_total, + itemSucceeded: task.item_succeeded, + itemFailed: task.item_failed, + itemSkipped: task.item_skipped, + }; +} + +function isTaskCanceled(task: TaskRow): boolean { + return task.cancel_requested_at !== null || task.status === 'canceled'; +} + +function isItemFinished(status: string): boolean { + return status === 'pending_review' || status === 'failed' || status === 'skipped'; +} + +function sleep(ms: number): Promise { + return new Promise((resolve) => { + setTimeout(resolve, ms); + }); +} + +async function lockTask(tx: Prisma.TransactionClient, taskId: string): Promise { + const rows = await tx.$queryRaw(Prisma.sql` + SELECT id, status, payload_json, item_total, item_succeeded, item_failed, item_skipped, + cancel_requested_at + FROM media_tasks + WHERE id = ${taskId} + FOR UPDATE + `); + return rows[0] ?? null; +} + +async function lockItem( + tx: Prisma.TransactionClient, + taskId: string, + itemId: string, +): Promise { + const rows = await tx.$queryRaw(Prisma.sql` + SELECT id, seq, status + FROM media_items + WHERE id = ${itemId} AND task_id = ${taskId} + FOR UPDATE + `); + return rows[0] ?? null; +} + +// --------------------------------------------------------------------------- +// 事件与收敛写入 +// --------------------------------------------------------------------------- + +interface ItemEventInput { + taskId: string; + itemId: string; + seq: number; + status: TaskItemStatus; + failureCode?: string; + failureMessage?: string; +} + +async function appendTaskEvent( + tx: Prisma.TransactionClient, + taskId: string, + eventType: TaskEventType, + payload: unknown, +): Promise { + await tx.$executeRaw(Prisma.sql` + INSERT INTO task_events (id, task_id, event_type, payload_json) + VALUES (${ulid()}, ${taskId}, ${eventType}, ${JSON.stringify(payload)}::jsonb) + `); +} + +async function appendItemEvent(tx: Prisma.TransactionClient, input: ItemEventInput): Promise { + const payload: TaskItemEventPayload = { + taskId: input.taskId, + itemId: input.itemId, + seq: input.seq, + status: input.status, + ...(input.failureCode === undefined ? {} : { failureCode: input.failureCode }), + ...(input.failureMessage === undefined ? {} : { failureMessage: input.failureMessage }), + }; + await appendTaskEvent(tx, input.taskId, 'item', payload); +} + +async function appendProgressEvent( + tx: Prisma.TransactionClient, + taskId: string, + status: TaskStatus, + counters: TaskCounters, +): Promise { + const payload: TaskProgressEventPayload = { + taskId, + status, + progress: taskProgress(counters), + itemTotal: counters.itemTotal, + itemSucceeded: counters.itemSucceeded, + itemFailed: counters.itemFailed, + itemSkipped: counters.itemSkipped, + }; + await appendTaskEvent(tx, taskId, 'progress', payload); +} + +async function closeTask( + tx: Prisma.TransactionClient, + taskId: string, + status: TaskStatus, + counters: TaskCounters, +): Promise { + const donePayload: TaskDoneEventPayload = { taskId, status, progress: taskProgress(counters) }; + await appendTaskEvent(tx, taskId, 'done', donePayload); + + // 取消由 API 侧事件表达,worker 不在取消路径上写 outbox(避免 dispatcher 永不消费的死信) + if (status === 'canceled') { + return; + } + const eventType = status === 'failed' ? TASK_OUTBOX_EVENTS.failed : TASK_OUTBOX_EVENTS.finished; + await tx.$executeRaw(Prisma.sql` + INSERT INTO outbox_events (id, aggregate_type, aggregate_id, event_type, payload_json) + VALUES (${ulid()}, 'media_task', ${taskId}, ${eventType}, ${JSON.stringify({ + taskId, + status, + counters, + })}::jsonb) + `); +} + +/** 写回任务头计数 / 进度 / 状态;全部任务项收敛时补 done 与 outbox 事件 */ +async function persistProgress( + tx: Prisma.TransactionClient, + taskId: string, + counters: TaskCounters, + cancelRequested: boolean, +): Promise { + const finished = isTaskCountersFinished(counters); + const status: TaskStatus = finished + ? resolveFinishedStatus(counters, cancelRequested) + : 'running'; + await tx.$executeRaw(Prisma.sql` + UPDATE media_tasks + SET item_succeeded = ${counters.itemSucceeded}, + item_failed = ${counters.itemFailed}, + item_skipped = ${counters.itemSkipped}, + progress = ${taskProgress(counters)}, + status = ${status}, + finished_at = CASE WHEN ${finished} THEN now() ELSE finished_at END, + updated_at = now() + WHERE id = ${taskId} + `); + await appendProgressEvent(tx, taskId, status, counters); + if (finished) { + await closeTask(tx, taskId, status, counters); + } + return status; +} + +/** 取消请求:未收敛的任务项标 skipped 后收敛(已终态项不重复计数) */ +async function skipItemOnCancel( + tx: Prisma.TransactionClient, + task: TaskRow, + item: ItemRow, +): Promise { + if (isItemFinished(item.status)) { + return; + } + await tx.$executeRaw(Prisma.sql` + UPDATE media_items + SET status = 'skipped', finished_at = now(), updated_at = now() + WHERE id = ${item.id} + `); + const counters = applyItemResult(countersOfTask(task), 'skipped'); + await appendItemEvent(tx, { + taskId: task.id, + itemId: item.id, + seq: item.seq, + status: 'skipped', + }); + await persistProgress(tx, task.id, counters, true); +} + +// --------------------------------------------------------------------------- +// 执行阶段 +// --------------------------------------------------------------------------- + +type BeginResult = + { outcome: 'started'; seq: number } | { outcome: 'skipped' } | { outcome: 'canceled' }; + +async function beginItem(tx: Prisma.TransactionClient, data: DemoJobData): Promise { + const task = await lockTask(tx, data.taskId); + if (task === null || !isTaskStatus(task.status) || isTaskTerminal(task.status)) { + return { outcome: 'skipped' }; + } + const item = await lockItem(tx, data.taskId, data.itemId); + if (item === null || !isItemStatus(item.status)) { + return { outcome: 'skipped' }; + } + if (isTaskCanceled(task)) { + await skipItemOnCancel(tx, task, item); + return { outcome: 'canceled' }; + } + if (isItemFinished(item.status)) { + return { outcome: 'skipped' }; + } + + const claimed = await tx.$executeRaw(Prisma.sql` + UPDATE media_items + SET status = 'running', started_at = now(), attempts = attempts + 1, updated_at = now() + WHERE id = ${item.id} AND status = 'queued' + `); + if (claimed === 0) { + return { outcome: 'skipped' }; + } + if (task.status === 'queued') { + await tx.$executeRaw(Prisma.sql` + UPDATE media_tasks SET status = 'running', started_at = now(), updated_at = now() + WHERE id = ${task.id} + `); + } + await appendItemEvent(tx, { + taskId: task.id, + itemId: item.id, + seq: item.seq, + status: 'running', + }); + return { outcome: 'started', seq: item.seq }; +} + +const DEMO_FAILURE_CODE = 'demo_simulated_failure'; +const DEMO_FAILURE_MESSAGE = '演练任务模拟失败'; + +async function finishItem( + tx: Prisma.TransactionClient, + data: DemoJobData, + attemptsMade: number, + maxAttempts: number, +): Promise { + const task = await lockTask(tx, data.taskId); + if (task === null || isTaskTerminalTask(task)) { + return 'skipped'; + } + const item = await lockItem(tx, data.taskId, data.itemId); + if (item === null || isItemFinished(item.status)) { + return 'skipped'; + } + if (isTaskCanceled(task)) { + await skipItemOnCancel(tx, task, item); + return 'canceled'; + } + if (item.status !== 'running') { + return 'skipped'; + } + + const outcome = decideDemoOutcome(modeOf(task.payload_json), item.seq, attemptsMade, maxAttempts); + if (outcome === 'retry') { + await retryItem(tx, task, item); + return 'retry'; + } + const succeeded = outcome === 'success'; + await recordItemResult(tx, task, item, succeeded); + return succeeded ? 'succeeded' : 'failed'; +} + +/** 非最后一次尝试失败:任务项置回 queued(保留 attempts)并写事件,随后由 BullMQ 退避重试 */ +async function retryItem( + tx: Prisma.TransactionClient, + task: TaskRow, + item: ItemRow, +): Promise { + await tx.$executeRaw(Prisma.sql` + UPDATE media_items SET status = 'queued', started_at = NULL, updated_at = now() + WHERE id = ${item.id} + `); + await appendItemEvent(tx, { + taskId: task.id, + itemId: item.id, + seq: item.seq, + status: 'queued', + }); + await appendProgressEvent(tx, task.id, 'running', countersOfTask(task)); +} + +/** 任务项收敛:成功 → pending_review;最后一次尝试失败 → failed(失败原因必填) */ +async function recordItemResult( + tx: Prisma.TransactionClient, + task: TaskRow, + item: ItemRow, + succeeded: boolean, +): Promise { + const itemStatus: TaskItemStatus = succeeded ? 'pending_review' : 'failed'; + await tx.$executeRaw(Prisma.sql` + UPDATE media_items + SET status = ${itemStatus}, + failure_code = ${succeeded ? null : DEMO_FAILURE_CODE}, + failure_message = ${succeeded ? null : DEMO_FAILURE_MESSAGE}, + finished_at = now(), + updated_at = now() + WHERE id = ${item.id} + `); + const counters = applyItemResult(countersOfTask(task), succeeded ? 'succeeded' : 'failed'); + await appendItemEvent(tx, { + taskId: task.id, + itemId: item.id, + seq: item.seq, + status: itemStatus, + ...(succeeded ? {} : { failureCode: DEMO_FAILURE_CODE, failureMessage: DEMO_FAILURE_MESSAGE }), + }); + await persistProgress(tx, task.id, counters, false); +} + +function isTaskTerminalTask(task: TaskRow): boolean { + return isTaskStatus(task.status) && isTaskTerminal(task.status); +} + +/** + * 处理一个演练任务项。 + * @param attemptsMade BullMQ 已尝试次数(首次为 0);`attemptsMade + 1 >= maxAttempts` 即最后一次尝试 + */ +export async function processDemoJob( + prisma: PrismaClient, + data: DemoJobData, + attemptsMade: number, + maxAttempts: number, +): Promise { + const begin = await prisma.$transaction((tx) => beginItem(tx, data)); + if (begin.outcome !== 'started') { + return begin.outcome; + } + + // 事务外 sleep,避免长事务占用连接与行锁 + await sleep(demoDurationMs(begin.seq)); + + const outcome = await prisma.$transaction((tx) => + finishItem(tx, data, attemptsMade, maxAttempts), + ); + if (outcome === 'retry') { + throw new Error(`演练任务项 ${data.itemId} 本次尝试失败,等待 BullMQ 重试`); + } + return outcome; +} diff --git a/apps/worker/src/processors/dispatch.spec.ts b/apps/worker/src/processors/dispatch.spec.ts new file mode 100644 index 0000000..e3fe271 --- /dev/null +++ b/apps/worker/src/processors/dispatch.spec.ts @@ -0,0 +1,18 @@ +import { describe, expect, it } from 'vitest'; + +import { toBullQueueName } from '../queues'; +import { DEMO_QUEUE_PHYSICAL, isDemoJob } from './dispatch'; + +describe('M0 job 分发判定', () => { + it('用物理队列名(不含冒号)判定 media:verify 的 task:demo', () => { + expect(DEMO_QUEUE_PHYSICAL).toBe('media-verify'); + expect(toBullQueueName('media:verify')).toBe(DEMO_QUEUE_PHYSICAL); + expect(isDemoJob('media-verify', 'task:demo')).toBe(true); + }); + + it('逻辑名 / 其他队列 / 其他 job 不命中', () => { + expect(isDemoJob('media:verify', 'task:demo')).toBe(false); + expect(isDemoJob('image-render', 'task:demo')).toBe(false); + expect(isDemoJob('media-verify', 'task:image')).toBe(false); + }); +}); diff --git a/apps/worker/src/processors/dispatch.ts b/apps/worker/src/processors/dispatch.ts new file mode 100644 index 0000000..3d3e3f7 --- /dev/null +++ b/apps/worker/src/processors/dispatch.ts @@ -0,0 +1,18 @@ +import { toBullQueueName } from '../queues'; + +/** + * M0 演练任务的 job 分发契约。 + * + * 注意:BullMQ `Job.queueName` 返回的是**物理队列名**(`media-verify`,不含冒号), + * 协议里的逻辑名是 `media:verify`;判断时必须用 `toBullQueueName` 映射,不能用逻辑名直接比较。 + */ +export const DEMO_QUEUE_LOGICAL = 'media:verify' as const; + +export const DEMO_QUEUE_PHYSICAL = toBullQueueName(DEMO_QUEUE_LOGICAL); + +export const DEMO_JOB_NAME = 'task:demo' as const; + +/** 当前 job 是否由演练任务处理器处理(M0 唯一实现) */ +export function isDemoJob(queueName: string, jobName: string): boolean { + return queueName === DEMO_QUEUE_PHYSICAL && jobName === DEMO_JOB_NAME; +} diff --git a/apps/worker/src/processors/index.ts b/apps/worker/src/processors/index.ts new file mode 100644 index 0000000..fba60f4 --- /dev/null +++ b/apps/worker/src/processors/index.ts @@ -0,0 +1,13 @@ +export { + applyItemResult, + decideDemoOutcome, + type DemoAttemptOutcome, + demoDurationMs, + type DemoJobData, + type DemoMode, + type DemoProcessorOutcome, + normalizeDemoMode, + processDemoJob, +} from './demo.processor'; +export { DEMO_JOB_NAME, DEMO_QUEUE_LOGICAL, DEMO_QUEUE_PHYSICAL, isDemoJob } from './dispatch'; +export { parseTaskJobData, type TaskJobData } from './job-data'; diff --git a/apps/worker/src/processors/job-data.spec.ts b/apps/worker/src/processors/job-data.spec.ts new file mode 100644 index 0000000..8608050 --- /dev/null +++ b/apps/worker/src/processors/job-data.spec.ts @@ -0,0 +1,20 @@ +import { describe, expect, it } from 'vitest'; + +import { parseTaskJobData } from './job-data'; + +describe('job data 校验', () => { + it('合法数据原样返回', () => { + expect(parseTaskJobData({ taskId: 'T1', itemId: 'I1', extra: true })).toEqual({ + taskId: 'T1', + itemId: 'I1', + }); + }); + + it('非法数据抛中文错误', () => { + expect(() => parseTaskJobData(null)).toThrow('任务 job data 不是对象'); + expect(() => parseTaskJobData({ itemId: 'I1' })).toThrow('任务 job data 缺少 taskId'); + expect(() => parseTaskJobData({ taskId: 'T1', itemId: '' })).toThrow( + '任务 job data 缺少 itemId', + ); + }); +}); diff --git a/apps/worker/src/processors/job-data.ts b/apps/worker/src/processors/job-data.ts new file mode 100644 index 0000000..be95b0f --- /dev/null +++ b/apps/worker/src/processors/job-data.ts @@ -0,0 +1,25 @@ +/** BullMQ job data 契约(与 apps/api/src/infra/queue/outbox-payload.ts 的 TaskJobData 一致) */ +export interface TaskJobData { + taskId: string; + itemId: string; +} + +function isRecord(value: unknown): value is Record { + return typeof value === 'object' && value !== null && !Array.isArray(value); +} + +/** 校验并收窄 BullMQ job data;非法数据抛中文错误,以 failed job 形式暴露而不是静默吞掉 */ +export function parseTaskJobData(data: unknown): TaskJobData { + if (!isRecord(data)) { + throw new Error('任务 job data 不是对象'); + } + const taskId = data.taskId; + const itemId = data.itemId; + if (typeof taskId !== 'string' || taskId.length === 0) { + throw new Error('任务 job data 缺少 taskId'); + } + if (typeof itemId !== 'string' || itemId.length === 0) { + throw new Error('任务 job data 缺少 itemId'); + } + return { taskId, itemId }; +} diff --git a/apps/worker/src/queues.spec.ts b/apps/worker/src/queues.spec.ts new file mode 100644 index 0000000..147fc85 --- /dev/null +++ b/apps/worker/src/queues.spec.ts @@ -0,0 +1,43 @@ +import { TASK_QUEUES } from '@lingxi/shared'; +import { describe, expect, it } from 'vitest'; + +import { buildQueueDefinitions, parseQueueSpec, toBullQueueName } from './queues'; + +describe('QUEUE 解析与队列选择', () => { + it('all → 全部队列(顺序与 TASK_QUEUES 一致)', () => { + expect(parseQueueSpec('all')).toEqual(TASK_QUEUES.map((queue) => queue.name)); + }); + + it('分组名展开为组内队列,逗号分隔可混用具体队列名', () => { + expect(parseQueueSpec('verify')).toEqual(['media:verify']); + expect(parseQueueSpec('image')).toEqual(['image:matting', 'image:render']); + expect(parseQueueSpec('verify,media:verify')).toEqual(['media:verify']); + expect(parseQueueSpec('image:render, verify')).toEqual(['image:render', 'media:verify']); + }); + + it('非法值 / all 混用 / 空值给出中文原因', () => { + expect(() => parseQueueSpec('')).toThrow('QUEUE 不能为空'); + expect(() => parseQueueSpec('image,unknown')).toThrow('含未知队列 / 分组:unknown'); + expect(() => parseQueueSpec('all,verify')).toThrow('QUEUE=all 不能与其他分组 / 队列混用'); + }); + + it('BullMQ 物理队列名不含冒号且 9 条互不冲突', () => { + const names = TASK_QUEUES.map((queue) => toBullQueueName(queue.name)); + expect(names).toContain('media-verify'); + expect(names.every((name) => !name.includes(':'))).toBe(true); + expect(new Set(names).size).toBe(names.length); + }); + + it('buildQueueDefinitions 使用 TASK_QUEUES 的重试参数,并支持并发覆盖', () => { + const definitions = buildQueueDefinitions(['media:verify']); + expect(definitions[0]).toMatchObject({ + name: 'media:verify', + group: 'verify', + concurrency: 4, + attempts: 2, + backoffDelayMs: 1000, + }); + expect(buildQueueDefinitions(['media:verify'], 9)[0]?.concurrency).toBe(9); + expect(() => buildQueueDefinitions(['not:a-queue'])).toThrow('未登记的队列'); + }); +}); diff --git a/apps/worker/src/queues.ts b/apps/worker/src/queues.ts new file mode 100644 index 0000000..8d45394 --- /dev/null +++ b/apps/worker/src/queues.ts @@ -0,0 +1,100 @@ +import { + TASK_QUEUE_GROUPS, + TASK_QUEUES, + type TaskQueueGroup, + type TaskQueueName, +} from '@lingxi/shared'; + +/** 单个 worker 被分配到的队列定义(并发 / 重试参数全部来自 shared 的 TASK_QUEUES) */ +export interface WorkerQueueDefinition { + name: TaskQueueName; + group: TaskQueueGroup; + concurrency: number; + attempts: number; + backoffDelayMs: number; +} + +/** `QUEUE=all`:启动全部队列(本地开发默认) */ +export const ALL_QUEUE_SELECTOR = 'all'; + +/** + * 逻辑队列名 → BullMQ 物理队列名(BullMQ 禁止冒号),与 apps/api 的 toBullQueueName 保持一致。 + * 心跳 / 日志 / QUEUE 环境变量使用逻辑名,只有 BullMQ 实例使用物理名。 + */ +export function toBullQueueName(name: TaskQueueName): string { + return name.replaceAll(':', '-'); +} + +export function isQueueName(value: string): value is TaskQueueName { + return TASK_QUEUES.some((definition) => definition.name === value); +} + +export function isQueueGroup(value: string): value is TaskQueueGroup { + return TASK_QUEUE_GROUPS.some((group) => group === value); +} + +/** + * 解析 QUEUE 环境变量:`all` / 分组名 / 队列名,逗号分隔(如 `QUEUE=verify`、`QUEUE=image,media:verify`)。 + * 非法值给出中文原因(含可用取值),避免 worker 静默空转。 + */ +export function parseQueueSpec(spec: string): TaskQueueName[] { + const tokens = spec + .split(',') + .map((token) => token.trim()) + .filter((token) => token.length > 0); + + if (tokens.length === 0) { + throw new Error('QUEUE 不能为空:请填 all、队列组名或队列名(如 media:verify)'); + } + if (tokens.includes(ALL_QUEUE_SELECTOR)) { + if (tokens.length > 1) { + throw new Error('QUEUE=all 不能与其他分组 / 队列混用'); + } + return TASK_QUEUES.map((definition) => definition.name); + } + + const selected: TaskQueueName[] = []; + for (const token of tokens) { + if (isQueueName(token)) { + selected.push(token); + continue; + } + if (isQueueGroup(token)) { + const group = token; + selected.push( + ...TASK_QUEUES.filter((definition) => definition.group === group).map( + (definition) => definition.name, + ), + ); + continue; + } + throw new Error( + `含未知队列 / 分组:${token}(可用:all、${TASK_QUEUE_GROUPS.join(' / ')} 或具体队列名)`, + ); + } + + return [...new Set(selected)]; +} + +/** 把队列名展开为 worker 启动定义;WORKER_CONCURRENCY 覆盖全部队列并发 */ +export function buildQueueDefinitions( + names: readonly string[], + concurrencyOverride?: number, +): WorkerQueueDefinition[] { + return names.map((name) => { + if (!isQueueName(name)) { + throw new Error(`未登记的队列:${name}`); + } + const definition = TASK_QUEUES.find((item) => item.name === name); + if (definition === undefined) { + throw new Error(`未登记的队列:${name}`); + } + return { + name, + group: definition.group, + concurrency: concurrencyOverride ?? definition.concurrency, + attempts: definition.attempts, + backoffDelayMs: definition.backoffDelayMs, + }; + }); +} diff --git a/apps/worker/src/redis-options.spec.ts b/apps/worker/src/redis-options.spec.ts new file mode 100644 index 0000000..bfcb542 --- /dev/null +++ b/apps/worker/src/redis-options.spec.ts @@ -0,0 +1,31 @@ +import { describe, expect, it } from 'vitest'; + +import { parseRedisOptions } from './redis-options'; + +describe('REDIS_URL → 连接参数', () => { + it('解析 host / port / db', () => { + expect(parseRedisOptions('redis://127.0.0.1:6379/1')).toEqual({ + host: '127.0.0.1', + port: 6379, + db: 1, + }); + }); + + it('缺省端口 6379、缺省 db 0', () => { + expect(parseRedisOptions('redis://localhost')).toEqual({ + host: 'localhost', + port: 6379, + db: 0, + }); + }); + + it('支持账号密码(URL 编码)', () => { + expect(parseRedisOptions('redis://lingxi:p%40ss@127.0.0.1:6380/2')).toEqual({ + host: '127.0.0.1', + port: 6380, + db: 2, + username: 'lingxi', + password: 'p@ss', + }); + }); +}); diff --git a/apps/worker/src/redis-options.ts b/apps/worker/src/redis-options.ts new file mode 100644 index 0000000..8fc81e1 --- /dev/null +++ b/apps/worker/src/redis-options.ts @@ -0,0 +1,26 @@ +/** 解析 REDIS_URL 为 BullMQ / ioredis 可用的连接参数(支持 db 与账号密码) */ +export interface RedisConnectionOptions { + host: string; + port: number; + db: number; + username?: string; + password?: string; +} + +export function parseRedisOptions(redisUrl: string): RedisConnectionOptions { + const parsed = new URL(redisUrl); + const dbPath = parsed.pathname.replace(/^\//, ''); + const parsedDb = dbPath === '' ? 0 : Number(dbPath); + const options: RedisConnectionOptions = { + host: parsed.hostname, + port: parsed.port === '' ? 6379 : Number(parsed.port), + db: Number.isInteger(parsedDb) && parsedDb >= 0 ? parsedDb : 0, + }; + if (parsed.username !== '') { + options.username = decodeURIComponent(parsed.username); + } + if (parsed.password !== '') { + options.password = decodeURIComponent(parsed.password); + } + return options; +} diff --git a/apps/worker/tsconfig.build.json b/apps/worker/tsconfig.build.json index 1ea0982..18e878a 100644 --- a/apps/worker/tsconfig.build.json +++ b/apps/worker/tsconfig.build.json @@ -8,5 +8,6 @@ "declarationMap": false, "sourceMap": true }, - "include": ["src"] + "include": ["src"], + "exclude": ["src/**/*.spec.ts"] } diff --git a/apps/worker/tsconfig.json b/apps/worker/tsconfig.json index 4f25ee2..5f2657e 100644 --- a/apps/worker/tsconfig.json +++ b/apps/worker/tsconfig.json @@ -7,5 +7,5 @@ "types": ["node"], "noEmit": true }, - "include": ["src"] + "include": ["src", "vitest.config.ts"] } diff --git a/apps/worker/vitest.config.ts b/apps/worker/vitest.config.ts new file mode 100644 index 0000000..d1f17b0 --- /dev/null +++ b/apps/worker/vitest.config.ts @@ -0,0 +1,14 @@ +import { defineConfig } from 'vitest/config'; + +export default defineConfig({ + test: { + environment: 'node', + include: ['src/**/*.spec.ts'], + coverage: { + provider: 'v8', + reporter: ['text', 'lcov', 'json-summary'], + include: ['src/**/*.ts'], + exclude: ['src/**/*.spec.ts', 'src/main.ts'], + }, + }, +}); diff --git a/infra/monitoring/README.md b/infra/monitoring/README.md new file mode 100644 index 0000000..96a4fcd --- /dev/null +++ b/infra/monitoring/README.md @@ -0,0 +1,79 @@ +# 队列监控接入说明(A6-INF-01) + +本目录提供 BullMQ 队列与 Outbox 投递的 Prometheus 告警规则。M0 阶段**只交付指标端点与规则文件**, +本机尚未部署 Prometheus / Grafana(技术架构 §4 的观测套件在 T1 阶段落地),后续按下面的步骤接入。 + +## 1. 指标端点 + +| 用途 | 地址 | 说明 | +| --------------- | --------------------------- | ---------------------------------------------------------------------------------------------------------- | +| Prometheus 抓取 | `GET /api/v1/metrics` | `text/plain; version=0.0.4; charset=utf-8`,由 `apps/api/src/infra/queue/queue-metrics.controller.ts` 提供 | +| 队列健康检查 | `GET /api/v1/health/queues` | 9 个队列的计数 / 队首等待秒数 / worker 心跳;Redis 不可用返回 503(错误码 50002) | + +`/health/queues` 的 HTTP 状态:依赖异常 503;`status=degraded`(存在积压但 worker 心跳缺失或陈旧)仍返回 200,由告警规则负责通知。 +指标采集失败不会让 `/metrics` 返回 5xx,而是输出 `#` 注释行并记日志,保证抓取链不断。 + +> 注意:`/health`、`/metrics` 均为**只读公开端点**(`@Public()`),部署时应在网关限制为内网访问; +> 进程启动时 `@nestjs/swagger` 会扫描控制器,`/metrics` 已用 `@ApiExcludeController()` 排除出 OpenAPI 契约。 + +## 2. 指标清单 + +| 指标 | 类型 | 标签 | 含义 | +| ------------------------------------- | ----- | ----------------------------------------------------------- | ------------------------------------------------------- | +| `lingxi_queue_jobs` | gauge | `queue`、`state`(waiting/active/completed/failed/delayed) | 队列内各状态任务数 | +| `lingxi_queue_oldest_waiting_seconds` | gauge | `queue` | 队首等待任务的等待秒数(无等待任务为 0) | +| `lingxi_queue_failure_ratio` | gauge | `queue` | `failed/(failed+completed)`,无样本为 0 | +| `lingxi_worker_heartbeat_age_seconds` | gauge | `queue` | worker 心跳年龄(秒);缺失为 `-1` | +| `lingxi_outbox_pending_total` | gauge | — | `outbox_events.published_at IS NULL` 的事件数 | +| `lingxi_outbox_dead_letter_total` | gauge | — | `outbox_events.last_error='dead_lettered'` 的累计事件数 | + +心跳 key:`lingxi:worker:heartbeat:`(worker 每 10s 续写,TTL 30s)。 + +> 队列名口径:指标 / 告警 / 心跳 / `QUEUE` 环境变量都用**逻辑名**(`media:verify` 这类 `<域>:<动作>`); +> BullMQ 5.x 禁止队列名与自定义 jobId 含冒号,因此 BullMQ 内部物理队列名把 `:` 换成 `-`(`media-verify`), +> jobId 用 `-` 连接(`-`)。排查 Redis key 时按物理名找,看指标与告警时按逻辑名。 + +## 3. Prometheus 接入步骤 + +1. 启动 Prometheus(本机示例;生产按运维 Runbook 挂到 Compose): + + ```yaml + # prometheus.yml + scrape_configs: + - job_name: lingxi-api + metrics_path: /api/v1/metrics + static_configs: + - targets: ['127.0.0.1:3000'] + ``` + + ```bash + prometheus --config.file=prometheus.yml \ + --rules.file=infra/monitoring/queue-alerts.yml # 或使用 rule_files 引入 + ``` + + > `rule_files` 更常用:在 `prometheus.yml` 写 `rule_files: ['/etc/prometheus/queue-alerts.yml']`。 + > 本仓库的 CI / 本地校验可用 `promtool check rules infra/monitoring/queue-alerts.yml`。 + +2. 校验规则语法: + + ```bash + promtool check rules infra/monitoring/queue-alerts.yml + ``` + +3. 验证数据:`curl -s http://127.0.0.1:3000/api/v1/metrics | grep lingxi_`, + 再在 Prometheus UI → Graph 查询 `lingxi_queue_oldest_waiting_seconds`。 + +## 4. 告警规则 + +| 告警 | 表达式 | 持续 | 级别 | 处置 | +| ------------------------------ | ------------------------------------------- | ---- | -------- | ----------------------------------------------------------------------------------------------------- | +| `LingxiQueueBacklog` | `lingxi_queue_oldest_waiting_seconds > 600` | 5m | warning | 确认对应队列组的 worker 是否启动 / 并发是否被长任务占满;必要时扩容 worker(`QUEUE=<组名>` 单独扩容) | +| `LingxiQueueHighFailureRatio` | `lingxi_queue_failure_ratio > 0.05` | 10m | warning | 看失败 job 的 `last_error` 与 worker 日志;区分业务失败与依赖故障 | +| `LingxiWorkerHeartbeatMissing` | `heartbeat_age < 0` 或 `> 60` | 2m | critical | 检查 worker 进程 / `QUEUE` 分组 / Redis 连通性 | +| `LingxiOutboxDeadLetter` | `lingxi_outbox_dead_letter_total > 0` | 1m | critical | 查 `outbox_events WHERE last_error='dead_lettered'`,修复 payload / 队列配置后人工补投 | + +## 5. Grafana(后续) + +- 数据源:Prometheus;面板建议:按 `queue` 分组的任务数堆叠图、队首等待时长、 + 失败率、worker 心跳热力图、outbox 待投递 / 死信。 +- 本机暂无 Grafana 实例,M0 验收只要求 `curl /api/v1/metrics` 可读到上述指标、规则文件可加载。 diff --git a/infra/monitoring/queue-alerts.yml b/infra/monitoring/queue-alerts.yml new file mode 100644 index 0000000..7168dd4 --- /dev/null +++ b/infra/monitoring/queue-alerts.yml @@ -0,0 +1,52 @@ +# 灵犀 · 队列监控告警规则(A6-INF-01) +# 指标来源:API 的 Prometheus 端点 GET /api/v1/metrics(text/plain; version=0.0.4) +# 指标定义:lingxi_queue_jobs / lingxi_queue_oldest_waiting_seconds / lingxi_queue_failure_ratio / +# lingxi_worker_heartbeat_age_seconds / lingxi_outbox_pending_total / lingxi_outbox_dead_letter_total +# +# 加载方式见同目录 README.md;阈值调整需走配置评审(R-26 精神:阈值集中管理)。 +groups: + - name: lingxi-queue + rules: + # 队列积压:队首任务等待超过 10 分钟 + - alert: LingxiQueueBacklog + expr: lingxi_queue_oldest_waiting_seconds > 600 + for: 5m + labels: + severity: warning + component: queue + annotations: + summary: '队列 {{ $labels.queue }} 积压' + description: '队列 {{ $labels.queue }} 队首任务已等待 {{ $value | humanizeDuration }}(阈值 600s,持续 5m);请检查 worker 是否在跑、并发是否被占满。' + + # 失败率:failed/(failed+completed) 超过 5% + - alert: LingxiQueueHighFailureRatio + expr: lingxi_queue_failure_ratio > 0.05 + for: 10m + labels: + severity: warning + component: queue + annotations: + summary: '队列 {{ $labels.queue }} 失败率偏高' + description: '队列 {{ $labels.queue }} 失败率 {{ $value | humanizePercentage }}(阈值 5%,持续 10m);请查看失败 job 与 worker 日志。' + + # worker 心跳缺失(-1)或超过 60s 未续写 + - alert: LingxiWorkerHeartbeatMissing + expr: lingxi_worker_heartbeat_age_seconds < 0 or lingxi_worker_heartbeat_age_seconds > 60 + for: 2m + labels: + severity: critical + component: worker + annotations: + summary: '队列 {{ $labels.queue }} 的 worker 心跳缺失' + description: '队列 {{ $labels.queue }} 心跳年龄 {{ $value }}s(缺失为 -1,阈值为 60s,持续 2m);worker 可能已退出或 Redis 不可用。' + + # outbox 死信:出现即需要人工介入(避免事件永久卡住) + - alert: LingxiOutboxDeadLetter + expr: lingxi_outbox_dead_letter_total > 0 + for: 1m + labels: + severity: critical + component: outbox + annotations: + summary: 'Outbox 出现死信事件' + description: '累计死信 {{ $value }} 条(outbox_events.last_error=dead_lettered);请核对 payload 与目标队列配置后人工补投。' diff --git a/openapi/lingxi.openapi.json b/openapi/lingxi.openapi.json index b901eb9..e6dccab 100644 --- a/openapi/lingxi.openapi.json +++ b/openapi/lingxi.openapi.json @@ -1,6 +1,32 @@ { "openapi": "3.1.0", "paths": { + "/api/v1/health/queues": { + "get": { + "description": "返回 9 个队列的计数、队首等待时长与 worker 心跳;Redis 不可用时返回 503(错误码 50002)。", + "operationId": "QueueHealthController_queues", + "parameters": [], + "responses": { + "200": { + "description": "队列状态快照(可能为 degraded)", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/QueueHealthReportDto" + } + } + } + }, + "503": { + "description": "Redis 不可用(错误码 50002)" + } + }, + "summary": "队列健康检查", + "tags": [ + "platform" + ] + } + }, "/api/v1/auth/login": { "post": { "description": "返回 accessToken / refreshToken / expiresIn / me", @@ -1094,6 +1120,124 @@ ] } }, + "/api/v1/media-tasks": { + "post": { + "operationId": "MediaTaskController_submit", + "parameters": [], + "responses": { + "202": { + "description": "" + } + }, + "security": [ + { + "bearer": [] + } + ], + "summary": "提交任务(异步 202;M0 仅演练任务;必带 Idempotency-Key,同 key 同请求体 24h 内返回同一任务)", + "tags": [ + "media-tasks" + ] + }, + "get": { + "operationId": "MediaTaskController_list", + "parameters": [], + "responses": { + "200": { + "description": "" + } + }, + "security": [ + { + "bearer": [] + } + ], + "summary": "任务列表(类型 / 状态 / 客户 / 时间 / 我的 / 关键字筛选;稳定排序 createdAt DESC, id DESC)", + "tags": [ + "media-tasks" + ] + } + }, + "/api/v1/media-tasks/{id}/events": { + "get": { + "operationId": "MediaTaskController_events", + "parameters": [], + "responses": { + "200": { + "description": "" + } + }, + "security": [ + { + "bearer": [] + } + ], + "summary": "任务 SSE 进度(id 用 task_events.seq;支持 Last-Event-ID / last_event_id 续推,15s 心跳)", + "tags": [ + "media-tasks" + ] + } + }, + "/api/v1/media-tasks/{id}": { + "get": { + "operationId": "MediaTaskController_detail", + "parameters": [], + "responses": { + "200": { + "description": "" + } + }, + "security": [ + { + "bearer": [] + } + ], + "summary": "任务详情(首 100 条任务项 + itemsNextCursor;范围外 404)", + "tags": [ + "media-tasks" + ] + } + }, + "/api/v1/media-tasks/{id}/cancel": { + "post": { + "operationId": "MediaTaskController_cancel", + "parameters": [], + "responses": { + "201": { + "description": "" + } + }, + "security": [ + { + "bearer": [] + } + ], + "summary": "取消任务(仅排队 / 生成中;只把未开始项置 skipped,已成功项不受影响)", + "tags": [ + "media-tasks" + ] + } + }, + "/api/v1/media-tasks/{id}/retry": { + "post": { + "operationId": "MediaTaskController_retry", + "parameters": [], + "responses": { + "201": { + "description": "" + } + }, + "security": [ + { + "bearer": [] + } + ], + "summary": "重试任务(仅部分失败;只重置失败项,成功项不动)", + "tags": [ + "media-tasks" + ] + } + }, "/health": { "get": { "description": "真实检查 PostgreSQL 与 Redis;任一不可用返回 503 与统一错误体。", @@ -1361,6 +1505,126 @@ } }, "schemas": { + "QueueCountsDto": { + "type": "object", + "properties": { + "waiting": { + "type": "number", + "description": "等待中任务数", + "example": 0 + }, + "active": { + "type": "number", + "description": "执行中任务数", + "example": 0 + }, + "completed": { + "type": "number", + "description": "已完成任务数(保留窗口内的历史计数)", + "example": 12 + }, + "failed": { + "type": "number", + "description": "失败任务数(保留窗口内的历史计数)", + "example": 1 + }, + "delayed": { + "type": "number", + "description": "延迟任务数", + "example": 0 + } + }, + "required": [ + "waiting", + "active", + "completed", + "failed", + "delayed" + ] + }, + "WorkerHeartbeatDto": { + "type": "object", + "properties": { + "present": { + "type": "boolean", + "description": "最近 30s 内是否有 worker 心跳" + }, + "ageSeconds": { + "type": "number", + "nullable": true, + "description": "心跳年龄(秒);心跳缺失或值非法时为 null", + "example": 3 + } + }, + "required": [ + "present", + "ageSeconds" + ] + }, + "QueueHealthEntryDto": { + "type": "object", + "properties": { + "name": { + "type": "string", + "description": "队列名", + "example": "media:verify" + }, + "group": { + "type": "string", + "description": "队列组", + "example": "verify" + }, + "counts": { + "$ref": "#/components/schemas/QueueCountsDto" + }, + "oldestWaitingSeconds": { + "type": "number", + "description": "队首任务等待秒数", + "example": 0 + }, + "workerHeartbeat": { + "$ref": "#/components/schemas/WorkerHeartbeatDto" + } + }, + "required": [ + "name", + "group", + "counts", + "oldestWaitingSeconds", + "workerHeartbeat" + ] + }, + "QueueHealthReportDto": { + "type": "object", + "properties": { + "status": { + "type": "string", + "enum": [ + "ok", + "degraded" + ], + "example": "ok" + }, + "redis": { + "type": "string", + "enum": [ + "ok" + ], + "example": "ok" + }, + "queues": { + "type": "array", + "items": { + "$ref": "#/components/schemas/QueueHealthEntryDto" + } + } + }, + "required": [ + "status", + "redis", + "queues" + ] + }, "ComponentHealthDto": { "type": "object", "properties": { diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 98e7b38..2712df5 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -134,6 +134,9 @@ importers: argon2: specifier: 0.45.1 version: 0.45.1 + bullmq: + specifier: 5.81.5 + version: 5.81.5 dotenv: specifier: 18.0.9 version: 18.0.9 @@ -247,6 +250,12 @@ importers: '@lingxi/shared': specifier: workspace:* version: link:../../packages/shared + '@prisma/client': + specifier: 6.19.3 + version: 6.19.3(prisma@6.19.3(magicast@0.3.5)(typescript@5.9.3))(typescript@5.9.3) + bullmq: + specifier: 5.81.5 + version: 5.81.5 dotenv: specifier: 18.0.9 version: 18.0.9 @@ -263,12 +272,18 @@ importers: '@types/node': specifier: 24.19.2 version: 24.19.2 + '@vitest/coverage-v8': + specifier: 3.2.7 + version: 3.2.7(vitest@3.2.7(@types/debug@4.1.13)(@types/node@24.19.2)(jiti@2.7.0)(jsdom@28.1.0)(tsx@4.23.15)(yaml@2.9.1)) tsx: specifier: 4.23.15 version: 4.23.15 typescript: specifier: 5.9.3 version: 5.9.3 + vitest: + specifier: 3.2.7 + version: 3.2.7(@types/debug@4.1.13)(@types/node@24.19.2)(jiti@2.7.0)(jsdom@28.1.0)(tsx@4.23.15)(yaml@2.9.1) packages/ai-sdk: devDependencies: @@ -1272,6 +1287,36 @@ packages: '@microsoft/tsdoc@0.16.0': resolution: {integrity: sha512-xgAyonlVVS+q7Vc7qLW0UrJU7rSFcETRWsqdXZtjzRU8dF+6CkozTK4V4y1LwOX7j8r/vHphjDeMeGI4tNGeGA==} + '@msgpackr-extract/msgpackr-extract-darwin-arm64@3.0.6': + resolution: {integrity: sha512-ugMD1XQPQ0usC3F1frfZzZsNs9uf9Xyxe86GO078Mg0dVY+FQA8TlPL8P5EQwavZs0dUe2LjWJnt4TyQl3pUvw==} + cpu: [arm64] + os: [darwin] + + '@msgpackr-extract/msgpackr-extract-darwin-x64@3.0.6': + resolution: {integrity: sha512-9ESac1RNLXH7jPZri+aOSoWOG3YpxFUdxgcrjlcANf2wDbEUf55bY3R+uGQqYkqcFLF91EkoAcDsJPbbGj84Sw==} + cpu: [x64] + os: [darwin] + + '@msgpackr-extract/msgpackr-extract-linux-arm64@3.0.6': + resolution: {integrity: sha512-zHceGhbGw8RQG6oSKK/43ieOjutwb/yVXa5kmdJCfFCLbTdOkd4TGfvbiic70VJf8uomuV4AwOfFxe1i2/ZLmQ==} + cpu: [arm64] + os: [linux] + + '@msgpackr-extract/msgpackr-extract-linux-arm@3.0.6': + resolution: {integrity: sha512-tAObEkjmQ77IgZfg6UEyEUgKP9vLVMkq2KK8WNa5P7hIieevSMHxyKcqM4zCV+TiZu8q7XmWLrqqlSuaZ5Z0zA==} + cpu: [arm] + os: [linux] + + '@msgpackr-extract/msgpackr-extract-linux-x64@3.0.6': + resolution: {integrity: sha512-7zKNTbhIGdVO4gsGFNd39g5Go3jN+fuizvWe8SrbGSck9Pcr8x9I5GbtUX1JzBLMaUuI/tcB0JKEYn8RNjKhFg==} + cpu: [x64] + os: [linux] + + '@msgpackr-extract/msgpackr-extract-win32-x64@3.0.6': + resolution: {integrity: sha512-J1mT1tp4v9aqcRwUtFrdJ94yei2SlIKm243nWK5bCyOPNnnNknaPzjG6a2Aa5OSfXE36rrth0X2OPHpispZTJw==} + cpu: [x64] + os: [win32] + '@napi-rs/lzma-linux-x64-gnu@1.5.1': resolution: {integrity: sha512-oTXEIha4SsuXdTA4Iyskj0kpdx2yVXdhd75c2v3xGrHFfVMsbhTPZU/nMPL4sWKo4pBHm3aucLaqGlF696dTyQ==} engines: {node: ^22.20 || ^24.12 || >=25} @@ -2306,6 +2351,15 @@ packages: buffer-equal-constant-time@1.0.1: resolution: {integrity: sha512-zRpUiDwd/xk6ADqPMATG8vc9VPrkck7T07OIx0gnjmJAnHnTVXNQG3vfvWNuiZIkwu9KrKdA1iJKfsfTVxE6NA==} + bullmq@5.81.5: + resolution: {integrity: sha512-pW7pFc/X+9AEIx91TWTdIpZ1icfbJnA0unsyzD7VICjCUw8a3qedSjej5dxxn6LjfM1kr2jvalOCGbCmoA5Kxg==} + engines: {node: '>=12.22.0'} + peerDependencies: + redis: '>=5.0.0' + peerDependenciesMeta: + redis: + optional: true + bundle-require@5.1.0: resolution: {integrity: sha512-3WrrOuZiyaaZPWiEt4G3+IffISVC9HYlWueJEBWED4ZH4aIAC2PnkdnuRrR94M+w6yGWn4AglWtJtBI8YqvgoA==} engines: {node: ^12.20.0 || ^14.13.1 || >=16.0.0} @@ -2508,6 +2562,10 @@ packages: typescript: optional: true + cron-parser@4.9.0: + resolution: {integrity: sha512-p0SaNjrHOnQeR8/VnfGbmg9te2kfyYSQ7Sc/j/6DtPL3JQvKxmjO9TSjNFpujqV3vEYYBvNNvXSxzyksBWAx1Q==} + engines: {node: '>=12.0.0'} + cross-env@10.1.0: resolution: {integrity: sha512-GsYosgnACZTADcmEyJctkJIoqAhHjttw7RsFrVoJNXbsWWqaq6Ym+7kZjq6mS45O0jij6vtiReppKQEtqWy6Dw==} engines: {node: '>=20'} @@ -2611,6 +2669,10 @@ packages: destr@2.0.5: resolution: {integrity: sha512-ugFTXCtDZunbzasqBxrK93Ik/DRYsO6S/fedkWEMKqt04xZ4csmnmwGDBAb07QWNaGMAmnTIemsYZCksjATwsA==} + detect-libc@2.1.2: + resolution: {integrity: sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==} + engines: {node: '>=8'} + devlop@1.1.0: resolution: {integrity: sha512-RWmIqhcFf1lRYBvNmr7qTNuyCt/7/ns2jbpp1+PalgE/rDQcBT0fioSMUpJ93irlUhC5hrg4cYqe6U+0ImW0rA==} @@ -3560,6 +3622,10 @@ packages: lru-cache@5.1.1: resolution: {integrity: sha512-KpNARQA3Iwv+jTA0utUVVbrh+Jlrr1Fv0e56GGzAFOXN7dk/FviaDW8LHmK52DlcH4WP2n6gI8vN1aesBFgo9w==} + luxon@3.7.2: + resolution: {integrity: sha512-vtEhXh/gNjI9Yg1u4jX/0YVPMvxzHuGgCm6tC5kZyb08yjGWGnqAjGJvcXbqQR2P3MyMEFnRbpcdFS6PBcLqew==} + engines: {node: '>=12'} + lz-string@1.5.0: resolution: {integrity: sha512-h5bgJWpxJNswbU7qCrV0tIKQCaS3blPDrqKWx+QxzuzL1zGUzij9XCWLrSLsJPu5t+eWA/ycetzYAO5IOMcWAQ==} hasBin: true @@ -3788,6 +3854,13 @@ packages: ms@2.1.3: resolution: {integrity: sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==} + msgpackr-extract@3.0.6: + resolution: {integrity: sha512-prUB8+Gc/1rEzaNo+3Rjo0Hdi6gFjQ9Td0mDsvAIkSGUyp6EADdEBiU71ZP5Mj4jCG+vai0qSNV+yisQAMYuSQ==} + hasBin: true + + msgpackr@2.0.5: + resolution: {integrity: sha512-cef05H/dSYpLpqp3sj/qyZh5vhUYCalnaLO7j1yOmpsR0y/XwLVtK7r5gn+U/F7CTEfMowcGhlUQJDLcLf7jcA==} + mz@2.7.0: resolution: {integrity: sha512-z81GNO7nnYMEhrGh9LeymoE4+Yr0Wn5McHIZMK5cfQCl+NDX08sCZgUc9/6MHni9IWuFLm1Z3HTCXu2z9fN62Q==} @@ -3814,6 +3887,9 @@ packages: pino-http: ^11.0.0 rxjs: ^7.1.0 + node-abort-controller@3.1.1: + resolution: {integrity: sha512-AGK2yQKIjRuqnc6VkX2Xj5d+QW8xZ87pa1UK6yA6ouUyuxfHuMP6umE5QK7UmTeOAymo+Zx1Fxiuw9rVx8taHQ==} + node-addon-api@8.9.2: resolution: {integrity: sha512-VijLXbi3UACN69I0JVXJsX4tjACjNoQDgv2gTF6sx2wWEi8tkSg2eX8p5gSIFi8z2+DL3oHmY6OyKce38SDolg==} engines: {node: ^18 || ^20 || >= 21} @@ -3821,6 +3897,10 @@ packages: node-fetch-native@1.6.7: resolution: {integrity: sha512-g9yhqoedzIUm0nTnTqAQvueMPVOuIY16bqgAJJC8XOOubYFNwz6IER9qs0Gq2Xd0+CecCKFjtdDTMA4u4xG06Q==} + node-gyp-build-optional-packages@5.2.2: + resolution: {integrity: sha512-s+w+rBWnpTMwSFbaE0UXsRlg7hU4FjekKU4eyAih5T8nJuNZT1nNsskXpxmeqSK9UzkBl6UgRlnKc8hz8IEqOw==} + hasBin: true + node-gyp-build@4.8.4: resolution: {integrity: sha512-LA4ZjwlnUblHVgq0oBF3Jl/6h/Nvs5fzBLwdEF4nuxnFdsfajde4WfxtJr3CaiH+F6ewcIB/q4jQ4UzPyid+CQ==} hasBin: true @@ -5797,6 +5877,24 @@ snapshots: '@microsoft/tsdoc@0.16.0': {} + '@msgpackr-extract/msgpackr-extract-darwin-arm64@3.0.6': + optional: true + + '@msgpackr-extract/msgpackr-extract-darwin-x64@3.0.6': + optional: true + + '@msgpackr-extract/msgpackr-extract-linux-arm64@3.0.6': + optional: true + + '@msgpackr-extract/msgpackr-extract-linux-arm@3.0.6': + optional: true + + '@msgpackr-extract/msgpackr-extract-linux-x64@3.0.6': + optional: true + + '@msgpackr-extract/msgpackr-extract-win32-x64@3.0.6': + optional: true + '@napi-rs/lzma-linux-x64-gnu@1.5.1': optional: true @@ -6766,6 +6864,17 @@ snapshots: buffer-equal-constant-time@1.0.1: {} + bullmq@5.81.5: + dependencies: + cron-parser: 4.9.0 + ioredis: 5.11.1 + msgpackr: 2.0.5 + node-abort-controller: 3.1.1 + semver: 7.8.5 + tslib: 2.8.1 + transitivePeerDependencies: + - supports-color + bundle-require@5.1.0(esbuild@0.27.7): dependencies: esbuild: 0.27.7 @@ -6945,6 +7054,10 @@ snapshots: optionalDependencies: typescript: 5.9.3 + cron-parser@4.9.0: + dependencies: + luxon: 3.7.2 + cross-env@10.1.0: dependencies: '@epic-web/invariant': 1.0.0 @@ -7047,6 +7160,9 @@ snapshots: destr@2.0.5: {} + detect-libc@2.1.2: + optional: true + devlop@1.1.0: dependencies: dequal: 2.0.3 @@ -8249,6 +8365,8 @@ snapshots: dependencies: yallist: 3.1.1 + luxon@3.7.2: {} + lz-string@1.5.0: {} magic-string@0.30.21: @@ -8745,6 +8863,22 @@ snapshots: ms@2.1.3: {} + msgpackr-extract@3.0.6: + dependencies: + node-gyp-build-optional-packages: 5.2.2 + optionalDependencies: + '@msgpackr-extract/msgpackr-extract-darwin-arm64': 3.0.6 + '@msgpackr-extract/msgpackr-extract-darwin-x64': 3.0.6 + '@msgpackr-extract/msgpackr-extract-linux-arm': 3.0.6 + '@msgpackr-extract/msgpackr-extract-linux-arm64': 3.0.6 + '@msgpackr-extract/msgpackr-extract-linux-x64': 3.0.6 + '@msgpackr-extract/msgpackr-extract-win32-x64': 3.0.6 + optional: true + + msgpackr@2.0.5: + optionalDependencies: + msgpackr-extract: 3.0.6 + mz@2.7.0: dependencies: any-promise: 1.3.0 @@ -8765,10 +8899,17 @@ snapshots: pino-http: 11.0.0 rxjs: 7.8.2 + node-abort-controller@3.1.1: {} + node-addon-api@8.9.2: {} node-fetch-native@1.6.7: {} + node-gyp-build-optional-packages@5.2.2: + dependencies: + detect-libc: 2.1.2 + optional: true + node-gyp-build@4.8.4: {} node-releases@2.0.63: {}