背景:A6-BE-01..05 与 A6-INF-01,打通「提交 → 队列 → SSE 进度 → 完成/失败 → 取消 / 仅重试失败项」闭环,为 B3 / M1 的真实任务提供统一协议。 改动: - API media 模块:POST/GET /media-tasks、详情、SSE(Last-Event-ID 续推 + 15s 心跳 + 800ms 落库轮询)、取消(只影响未开始项)、仅重试失败项;Idempotency-Key 24h(异步提交 202;同 key 同 body 返回同一任务且不重复审计,不同 body 40902);范围外统一 404; - 队列骨架:BullMQ 5 九队列(5 组独立启停)、Outbox dispatcher(Redis 锁 + FOR UPDATE SKIP LOCKED + jobId 幂等 + 指数退避/死信)、worker 按 QUEUE 启停、demo 演练处理器(事务 + 行锁 + 取消/重试/幂等)、优雅退出与心跳; - 监控:GET /api/v1/health/queues、GET /api/v1/metrics(Prometheus 0.0.4)、infra/monitoring 告警规则与接入说明; - 通用:AuditInterceptor 支持幂等重放 skipAudit(默认行为不变)、AppModule 接线、.env.example 的 QUEUE 语义、bullmq 5.81.5 / worker @prisma/client / worker 覆盖率依赖。 影响面:OpenAPI 新增 6 个端点(openapi:diff 无破坏性变更);API 进程新增 outbox dispatcher 与 metrics/health 路由;worker 新增 BullMQ 消费;未改旧审计导出链路与既有接口。 回滚:回滚提交并停止 worker 新队列消费;task/outbox 新表数据不影响旧功能,可按需保留。 验证:api 单测 257、集成 92(连跑 2 次)、worker 27、shared 197;typecheck / lint / format:check / build / openapi:diff 全绿;本地真实 worker 启用 9 队列心跳,/health/queues、/metrics 实测,/media-tasks 未登录 401。
This commit is contained in:
1 parent
4a29cf815e
commit
633765f10f
70 files changed
+8674
-45
No files matched your search
+15
-3
@@ -40,9 +40,21 @@ HOST=0.0.0.0
|
|||||||
WEB_ORIGIN=http://127.0.0.1:5173,http://localhost:5173
|
WEB_ORIGIN=http://127.0.0.1:5173,http://localhost:5173
|
||||||
MAX_UPLOAD_MB=500
|
MAX_UPLOAD_MB=500
|
||||||
|
|
||||||
# ---------- Worker ----------
|
# ---------- Worker / 队列(BullMQ,技术架构 §2.4 / §3.7) ----------
|
||||||
QUEUE=default
|
# 队列分 5 组共 9 条(script:generate / image:matting / image:render / video:keyframe / video:shot /
|
||||||
WORKER_CONCURRENCY=2
|
# 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 标识,默认 <hostname>-<pid>;写入心跳 lingxi:worker:heartbeat:<queue>(TTL 30s)。
|
||||||
|
# WORKER_ID=dev-worker-01
|
||||||
|
# API 进程内 Outbox dispatcher 的调度间隔(毫秒);未设置默认 1000。
|
||||||
|
# OUTBOX_DISPATCH_INTERVAL_MS=1000
|
||||||
|
|
||||||
# ---------- 前端(只能暴露 VITE_PUBLIC_* 前缀,ENV-07) ----------
|
# ---------- 前端(只能暴露 VITE_PUBLIC_* 前缀,ENV-07) ----------
|
||||||
VITE_PUBLIC_API_BASE_URL=http://127.0.0.1:3000/api/v1
|
VITE_PUBLIC_API_BASE_URL=http://127.0.0.1:3000/api/v1
|
||||||
|
|||||||
@@ -22,6 +22,7 @@
|
|||||||
"@nestjs/swagger": "11.4.7",
|
"@nestjs/swagger": "11.4.7",
|
||||||
"@prisma/client": "6.19.3",
|
"@prisma/client": "6.19.3",
|
||||||
"argon2": "0.45.1",
|
"argon2": "0.45.1",
|
||||||
|
"bullmq": "5.81.5",
|
||||||
"dotenv": "18.0.9",
|
"dotenv": "18.0.9",
|
||||||
"fastify": "5.12.7",
|
"fastify": "5.12.7",
|
||||||
"ioredis": "5.11.1",
|
"ioredis": "5.11.1",
|
||||||
|
|||||||
@@ -6,10 +6,12 @@ import { SecurityModule } from './common/security/security.module';
|
|||||||
import { APP_CONFIG, AppConfigModule } from './config/config.module';
|
import { APP_CONFIG, AppConfigModule } from './config/config.module';
|
||||||
import type { AppConfig } from './config/env';
|
import type { AppConfig } from './config/env';
|
||||||
import { PrismaModule } from './infra/prisma/prisma.module';
|
import { PrismaModule } from './infra/prisma/prisma.module';
|
||||||
|
import { QueueModule } from './infra/queue/queue.module';
|
||||||
import { RedisModule } from './infra/redis/redis.module';
|
import { RedisModule } from './infra/redis/redis.module';
|
||||||
import { CatalogModule } from './modules/catalog/catalog.module';
|
import { CatalogModule } from './modules/catalog/catalog.module';
|
||||||
import { ClientModule } from './modules/client/client.module';
|
import { ClientModule } from './modules/client/client.module';
|
||||||
import { IdentityModule } from './modules/identity/identity.module';
|
import { IdentityModule } from './modules/identity/identity.module';
|
||||||
|
import { MediaModule } from './modules/media/media.module';
|
||||||
import { PlatformModule } from './modules/platform/platform.module';
|
import { PlatformModule } from './modules/platform/platform.module';
|
||||||
|
|
||||||
@Module({
|
@Module({
|
||||||
@@ -49,10 +51,12 @@ import { PlatformModule } from './modules/platform/platform.module';
|
|||||||
}),
|
}),
|
||||||
PrismaModule,
|
PrismaModule,
|
||||||
RedisModule,
|
RedisModule,
|
||||||
|
QueueModule,
|
||||||
SecurityModule,
|
SecurityModule,
|
||||||
IdentityModule,
|
IdentityModule,
|
||||||
ClientModule,
|
ClientModule,
|
||||||
CatalogModule,
|
CatalogModule,
|
||||||
|
MediaModule,
|
||||||
PlatformModule,
|
PlatformModule,
|
||||||
],
|
],
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -163,6 +163,22 @@ describe('AuditInterceptor · 审计落库(A5-BE-02)', () => {
|
|||||||
).resolves.toBe('ok');
|
).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 () => {
|
it('未标注 @Audit 的请求不写审计', async () => {
|
||||||
const requestContext = new RequestContextService();
|
const requestContext = new RequestContextService();
|
||||||
const sink = sinkOf();
|
const sink = sinkOf();
|
||||||
|
|||||||
@@ -9,7 +9,7 @@ import {
|
|||||||
type NestInterceptor,
|
type NestInterceptor,
|
||||||
Optional,
|
Optional,
|
||||||
} from '@nestjs/common';
|
} 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 { catchError, concatMap, map, mergeMap } from 'rxjs/operators';
|
||||||
|
|
||||||
import { AppException } from '../errors/app-exception';
|
import { AppException } from '../errors/app-exception';
|
||||||
@@ -119,15 +119,18 @@ export class AuditInterceptor implements NestInterceptor {
|
|||||||
return next
|
return next
|
||||||
.handle()
|
.handle()
|
||||||
.pipe(
|
.pipe(
|
||||||
concatMap((value: unknown) =>
|
concatMap((value: unknown) => {
|
||||||
from(this.record(ctx, action, 'success', targetId)).pipe(map(() => value)),
|
// skipAudit:handler 内 patch({ skipAudit: true })(如 A6 幂等重放)时不落审计
|
||||||
),
|
if (ctx.skipAudit === true) return of(value);
|
||||||
catchError((error: unknown) =>
|
return from(this.record(ctx, action, 'success', targetId)).pipe(map(() => value));
|
||||||
|
}),
|
||||||
|
catchError((error: unknown) => {
|
||||||
// record 内部已吞掉异常;这里再兜底一次,保证原错误原样抛回
|
// record 内部已吞掉异常;这里再兜底一次,保证原错误原样抛回
|
||||||
from(
|
if (ctx.skipAudit === true) return throwError(() => error);
|
||||||
|
return from(
|
||||||
this.record(ctx, action, 'failure', targetId, error).catch(() => undefined),
|
this.record(ctx, action, 'failure', targetId, error).catch(() => undefined),
|
||||||
).pipe(mergeMap(() => throwError(() => error))),
|
).pipe(mergeMap(() => throwError(() => error)));
|
||||||
),
|
}),
|
||||||
)
|
)
|
||||||
.subscribe(subscriber);
|
.subscribe(subscriber);
|
||||||
}),
|
}),
|
||||||
|
|||||||
@@ -40,6 +40,12 @@ export interface RequestContext {
|
|||||||
permissions: string[] | null;
|
permissions: string[] | null;
|
||||||
scope: ScopeContext | null;
|
scope: ScopeContext | null;
|
||||||
audit: AuditDetail | null;
|
audit: AuditDetail | null;
|
||||||
|
/**
|
||||||
|
* 跳过本次请求的 @Audit 落库(默认 false,行为不变)。
|
||||||
|
* 目前唯一用途:A6 任务提交幂等重放(同 key 同 body)——不重复建任务、不重复写审计,
|
||||||
|
* 由 media Service 在识别为重放后 `patch({ skipAudit: true })`。
|
||||||
|
*/
|
||||||
|
skipAudit?: boolean;
|
||||||
ip: string | null;
|
ip: string | null;
|
||||||
userAgent: string | null;
|
userAgent: string | null;
|
||||||
method: string;
|
method: string;
|
||||||
|
|||||||
@@ -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<OutboxDispatchResult> {
|
||||||
|
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<void> {
|
||||||
|
if (this.running) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
this.running = true;
|
||||||
|
try {
|
||||||
|
await this.dispatchOnce();
|
||||||
|
} catch (error) {
|
||||||
|
this.logThrottled(error);
|
||||||
|
} finally {
|
||||||
|
this.running = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private async acquireLock(): Promise<OutboxLock | null> {
|
||||||
|
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<void> {
|
||||||
|
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<ClaimedOutboxEvent[]> {
|
||||||
|
return this.prisma.$transaction(async (tx) => {
|
||||||
|
const rows = await tx.$queryRaw<ClaimedOutboxEvent[]>(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<boolean> {
|
||||||
|
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<void> {
|
||||||
|
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<void> {
|
||||||
|
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))}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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<typeof vi.fn>;
|
||||||
|
eval: ReturnType<typeof vi.fn>;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface TransactionMock {
|
||||||
|
$queryRaw: ReturnType<typeof vi.fn>;
|
||||||
|
$executeRaw: ReturnType<typeof vi.fn>;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface PrismaMock {
|
||||||
|
$transaction: ReturnType<typeof vi.fn>;
|
||||||
|
$executeRaw: ReturnType<typeof vi.fn>;
|
||||||
|
tx: TransactionMock;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface RegistryMock {
|
||||||
|
enqueue: ReturnType<typeof vi.fn>;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface Harness {
|
||||||
|
dispatcher: OutboxDispatcherService;
|
||||||
|
prisma: PrismaMock;
|
||||||
|
lockClient: LockClientMock;
|
||||||
|
registry: RegistryMock;
|
||||||
|
}
|
||||||
|
|
||||||
|
function eventRow(overrides: Partial<ClaimedOutboxEvent> = {}): 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<Harness> {
|
||||||
|
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<unknown>) =>
|
||||||
|
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();
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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> = {}): 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);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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),
|
||||||
|
* 其余类型按 `<type>:run` 占位,后续批次接入真实处理器时再补充映射。
|
||||||
|
*/
|
||||||
|
const JOB_NAME_MAP: ReadonlyMap<string, string> = 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<string, unknown> {
|
||||||
|
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 },
|
||||||
|
})),
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -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<void> {
|
||||||
|
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<OutboxSnapshot | null> {
|
||||||
|
const rows = await prisma.$queryRaw<OutboxSnapshot[]>(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<string[]> {
|
||||||
|
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<void> {
|
||||||
|
return new Promise<void>((resolve) => {
|
||||||
|
setTimeout(resolve, ms);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 驱动 dispatcher 直到条件满足。
|
||||||
|
* 本机可能同时运行着真实 API(带 dispatcher):会与本用例竞争 Redis 锁或抢先投递,
|
||||||
|
* 因此不假设「单次 dispatchOnce 一定认领到」,而是轮询到结果收敛(CI 干净环境下第一轮即满足)。
|
||||||
|
*/
|
||||||
|
async function dispatchUntil(
|
||||||
|
check: () => Promise<boolean>,
|
||||||
|
description: string,
|
||||||
|
timeoutMs = 10_000,
|
||||||
|
): Promise<void> {
|
||||||
|
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<void> {
|
||||||
|
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);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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> = {}): 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');
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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`;
|
||||||
|
}
|
||||||
@@ -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<QueueHealthBody>();
|
||||||
|
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<QueueHealthBody>();
|
||||||
|
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',
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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:<queueName>`,由 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<QueueHealthReport> {
|
||||||
|
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<void> {
|
||||||
|
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<QueueHealthEntry> {
|
||||||
|
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)) },
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,86 @@
|
|||||||
|
import { describe, expect, it } from 'vitest';
|
||||||
|
|
||||||
|
import {
|
||||||
|
buildQueueHealthEntry,
|
||||||
|
buildQueueHealthReport,
|
||||||
|
parseHeartbeatAgeSeconds,
|
||||||
|
workerHeartbeatKey,
|
||||||
|
} from './queue-health';
|
||||||
|
|
||||||
|
describe('队列健康检查(纯函数)', () => {
|
||||||
|
it('心跳 key 统一为 lingxi:worker:heartbeat:<queueName>', () => {
|
||||||
|
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');
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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:<queueName>`(与 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<string, unknown> {
|
||||||
|
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],
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -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[];
|
||||||
|
}
|
||||||
@@ -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<string> {
|
||||||
|
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<QueueMetricsInput[]> {
|
||||||
|
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<QueueMetricsInput> {
|
||||||
|
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<OutboxMetricsInput | null> {
|
||||||
|
try {
|
||||||
|
const [pendingRows, deadLetterRows] = await Promise.all([
|
||||||
|
this.prisma.$queryRaw<CountRow[]>(
|
||||||
|
Prisma.sql`SELECT count(*)::int AS count FROM outbox_events WHERE published_at IS NULL`,
|
||||||
|
),
|
||||||
|
this.prisma.$queryRaw<CountRow[]>(
|
||||||
|
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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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<TaskQueueName, Queue<TaskJobData, unknown>>();
|
||||||
|
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<TaskJobData, unknown> {
|
||||||
|
const existing = this.queues.get(name);
|
||||||
|
if (existing !== undefined) {
|
||||||
|
return existing;
|
||||||
|
}
|
||||||
|
const queue = new Queue<TaskJobData, unknown>(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<Job<TaskJobData, unknown>> {
|
||||||
|
const queue = this.getQueue(name);
|
||||||
|
return withTimeout(
|
||||||
|
queue.add(jobName, data, options),
|
||||||
|
QUEUE_OPERATION_TIMEOUT_MS,
|
||||||
|
`队列入队失败:${name}`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 各状态计数;失败抛错由调用方(health / metrics)兜底 */
|
||||||
|
async getCounts(name: TaskQueueName): Promise<QueueCountsSnapshot> {
|
||||||
|
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<number | null> {
|
||||||
|
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<void> {
|
||||||
|
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<string, number>, key: string): number {
|
||||||
|
return counts[key] ?? 0;
|
||||||
|
}
|
||||||
@@ -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:<queueName>` */
|
||||||
|
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";
|
||||||
@@ -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 {}
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
@@ -0,0 +1,27 @@
|
|||||||
|
/** 给队列 / Redis 命令加超时,避免依赖不可用时请求或调度循环无限挂起 */
|
||||||
|
export class QueueTimeoutError extends Error {
|
||||||
|
constructor(message: string) {
|
||||||
|
super(message);
|
||||||
|
this.name = 'QueueTimeoutError';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function withTimeout<T>(
|
||||||
|
task: Promise<T>,
|
||||||
|
timeoutMs: number,
|
||||||
|
message: string,
|
||||||
|
): Promise<T> {
|
||||||
|
let timer: NodeJS.Timeout | undefined;
|
||||||
|
const timeout = new Promise<never>((_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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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();
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,57 @@
|
|||||||
|
/**
|
||||||
|
* 任务中心游标分页编码(A6-BE-04)
|
||||||
|
*
|
||||||
|
* - 任务列表:`base64url(<ISO createdAt>|<ULID id>)`,对应稳定排序 `createdAt DESC, id DESC`;
|
||||||
|
* 用 keyset(`createdAt < cursor.at OR (createdAt = cursor.at AND id < cursor.id)`)翻页,
|
||||||
|
* 不依赖 (createdAt, id) 唯一索引,也不引新依赖;
|
||||||
|
* - 任务项续拉:`base64url(item:<seq>)`,对应 `(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;
|
||||||
|
}
|
||||||
@@ -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<typeof vi.fn>;
|
||||||
|
} {
|
||||||
|
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,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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<TaskEstimate> {
|
||||||
|
const estimateConfig = await this.loadConfig();
|
||||||
|
return estimateTask(type, itemCount, estimateConfig);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 读取三项预估配置:
|
||||||
|
* 1. 定义缺失(代码未登记 / 类型不是 number)→ 视为配置缺失,拒绝提交;
|
||||||
|
* 2. 读库异常 / 值非法 → 同样拒绝,避免拿 0 或兜底值蒙混。
|
||||||
|
*/
|
||||||
|
private async loadConfig(): Promise<TaskEstimateConfig> {
|
||||||
|
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 };
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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<string, unknown> = {};
|
||||||
|
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;
|
||||||
|
}
|
||||||
@@ -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<string, ResultAuditMapping> = 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<void> {
|
||||||
|
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<void> {
|
||||||
|
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<void> {
|
||||||
|
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}`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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> = {}): 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<string, unknown> = {}): Record<string, unknown> {
|
||||||
|
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<typeof vi.fn>;
|
||||||
|
claimResultEvent: ReturnType<typeof vi.fn>;
|
||||||
|
findTaskForResult: ReturnType<typeof vi.fn>;
|
||||||
|
markResultEventRetry: ReturnType<typeof vi.fn>;
|
||||||
|
markResultEventExhausted: ReturnType<typeof vi.fn>;
|
||||||
|
};
|
||||||
|
sink: { write: ReturnType<typeof vi.fn> };
|
||||||
|
}
|
||||||
|
|
||||||
|
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<PendingResultEvent[]>((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);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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 });
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,52 @@
|
|||||||
|
/**
|
||||||
|
* SSE 文本帧与断点续推解析(A6-BE-03 / 技术架构 §6.4)
|
||||||
|
*
|
||||||
|
* 帧格式(与 EventSource 规范一致):
|
||||||
|
* id: <task_events.seq>
|
||||||
|
* event: item | progress | done | error
|
||||||
|
* data: <单行 JSON>
|
||||||
|
* <空行>
|
||||||
|
* 心跳用注释帧 `: ping`,不污染事件流。`Last-Event-ID` 请求头优先于 `last_event_id` query。
|
||||||
|
*/
|
||||||
|
import { TASK_EVENT_TYPES, type TaskDoneEventPayload, type TaskEventType } from '@lingxi/shared';
|
||||||
|
|
||||||
|
/** DB 字符串 → SSE 事件类型(未知视为脏数据,由调用方兜底为 error / 跳过) */
|
||||||
|
export function parseTaskEventType(value: string): TaskEventType {
|
||||||
|
const found = TASK_EVENT_TYPES.find((event) => event === value);
|
||||||
|
if (found === undefined) throw new Error(`任务事件类型非法:${value}`);
|
||||||
|
return found;
|
||||||
|
}
|
||||||
|
|
||||||
|
export type SseResume = { ok: true; seq: bigint } | { ok: false };
|
||||||
|
|
||||||
|
/** 解析续推起点:header 优先;缺省 / 空串 = 从 0 开始;非纯数字视为非法(40001) */
|
||||||
|
export function resolveResumeSeq(
|
||||||
|
header: string | string[] | undefined,
|
||||||
|
query: string | undefined,
|
||||||
|
): SseResume {
|
||||||
|
const headerValue = Array.isArray(header) ? header[0] : header;
|
||||||
|
const raw = headerValue === undefined || headerValue.trim().length === 0 ? query : headerValue;
|
||||||
|
if (raw === undefined || raw.trim().length === 0) {
|
||||||
|
return { ok: true, seq: 0n };
|
||||||
|
}
|
||||||
|
const trimmed = raw.trim();
|
||||||
|
if (!/^\d{1,19}$/.test(trimmed)) {
|
||||||
|
return { ok: false };
|
||||||
|
}
|
||||||
|
return { ok: true, seq: BigInt(trimmed) };
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 事件帧:id 用 task_events.seq(BigInt 转字符串),data 单行 JSON */
|
||||||
|
export function formatSseEvent(id: string, event: TaskEventType, data: unknown): string {
|
||||||
|
return `id: ${id}\nevent: ${event}\ndata: ${JSON.stringify(data)}\n\n`;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 心跳注释帧 */
|
||||||
|
export function formatSseComment(text: string): string {
|
||||||
|
return `: ${text}\n\n`;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 终态补发 done:id 用最后消费到的 seq,保证重连语义不倒退 */
|
||||||
|
export function formatSseDone(seq: bigint, payload: TaskDoneEventPayload): string {
|
||||||
|
return formatSseEvent(seq.toString(), 'done', payload);
|
||||||
|
}
|
||||||
@@ -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<typeof vi.fn>;
|
||||||
|
write: ReturnType<typeof vi.fn>;
|
||||||
|
end: ReturnType<typeof vi.fn>;
|
||||||
|
on: ReturnType<typeof vi.fn>;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface ReplyHarness {
|
||||||
|
reply: FastifyReply;
|
||||||
|
raw: FakeRaw;
|
||||||
|
hijack: ReturnType<typeof vi.fn>;
|
||||||
|
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<typeof vi.fn>;
|
||||||
|
findTaskEventsAfter: ReturnType<typeof vi.fn>;
|
||||||
|
findTaskStreamStatus: ReturnType<typeof vi.fn>;
|
||||||
|
}): MediaTaskSseService {
|
||||||
|
return new MediaTaskSseService(repository as unknown as MediaTaskRepository);
|
||||||
|
}
|
||||||
|
|
||||||
|
function repositoryMock(): {
|
||||||
|
findTaskForStream: ReturnType<typeof vi.fn>;
|
||||||
|
findTaskEventsAfter: ReturnType<typeof vi.fn>;
|
||||||
|
findTaskStreamStatus: ReturnType<typeof vi.fn>;
|
||||||
|
} {
|
||||||
|
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);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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<void> {
|
||||||
|
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<void> => {
|
||||||
|
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();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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<MediaTaskSubmitResponse> {
|
||||||
|
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<MediaTaskListResponse> {
|
||||||
|
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<void> {
|
||||||
|
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<MediaTaskDetail> {
|
||||||
|
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<MediaTaskCancelResponse> {
|
||||||
|
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<MediaTaskRetryResponse> {
|
||||||
|
return this.tasks.retry(id, scope);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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<string, unknown> = {}): 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 });
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,163 @@
|
|||||||
|
/**
|
||||||
|
* 任务中心 Prisma 行 → 共享 DTO 映射(A6-BE-04)
|
||||||
|
*
|
||||||
|
* - 列表 / 详情显式 select(DB-10:列表不返回 payload 等大 JSONB);
|
||||||
|
* - 金额 `estimated_cost` 为 Decimal(14,4),出参统一转 number(元,2 位小数展示由前端处理);
|
||||||
|
* - DB 存英文 code,未知 code 视为脏数据直接抛错(由全局过滤器转 500,不静默降级成错误状态)。
|
||||||
|
*/
|
||||||
|
import {
|
||||||
|
type MediaTaskDetail,
|
||||||
|
type MediaTaskItem,
|
||||||
|
type MediaTaskSummary,
|
||||||
|
TASK_ITEM_STATUSES,
|
||||||
|
TASK_STATUSES,
|
||||||
|
TASK_TYPES,
|
||||||
|
type TaskItemStatus,
|
||||||
|
type TaskStatus,
|
||||||
|
type TaskType,
|
||||||
|
} from '@lingxi/shared';
|
||||||
|
import type { Prisma } from '@prisma/client';
|
||||||
|
|
||||||
|
/** 摘要 select:列表与详情共用(详情再追加 payload / 取消 / 错误字段) */
|
||||||
|
export const MEDIA_TASK_SUMMARY_SELECT = {
|
||||||
|
id: true,
|
||||||
|
type: true,
|
||||||
|
status: true,
|
||||||
|
title: true,
|
||||||
|
customerId: true,
|
||||||
|
customerName: true,
|
||||||
|
itemTotal: true,
|
||||||
|
itemSucceeded: true,
|
||||||
|
itemFailed: true,
|
||||||
|
itemSkipped: true,
|
||||||
|
progress: true,
|
||||||
|
estimatedItems: true,
|
||||||
|
estimatedCost: true,
|
||||||
|
estimatedSeconds: true,
|
||||||
|
createdBy: true,
|
||||||
|
createdByName: true,
|
||||||
|
createdAt: true,
|
||||||
|
startedAt: true,
|
||||||
|
finishedAt: true,
|
||||||
|
} satisfies Prisma.MediaTaskSelect;
|
||||||
|
|
||||||
|
export type MediaTaskSummaryRow = Prisma.MediaTaskGetPayload<{
|
||||||
|
select: typeof MEDIA_TASK_SUMMARY_SELECT;
|
||||||
|
}>;
|
||||||
|
|
||||||
|
export const MEDIA_TASK_DETAIL_SELECT = {
|
||||||
|
...MEDIA_TASK_SUMMARY_SELECT,
|
||||||
|
payloadJson: true,
|
||||||
|
cancelRequestedAt: true,
|
||||||
|
lastErrorCode: true,
|
||||||
|
lastErrorMessage: true,
|
||||||
|
} satisfies Prisma.MediaTaskSelect;
|
||||||
|
|
||||||
|
export type MediaTaskDetailRow = Prisma.MediaTaskGetPayload<{
|
||||||
|
select: typeof MEDIA_TASK_DETAIL_SELECT;
|
||||||
|
}>;
|
||||||
|
|
||||||
|
/** 任务项 select:列表 / 详情只给状态与失败原因,不含产物引用 */
|
||||||
|
export const MEDIA_ITEM_SELECT = {
|
||||||
|
id: true,
|
||||||
|
seq: true,
|
||||||
|
status: true,
|
||||||
|
failureCode: true,
|
||||||
|
failureMessage: true,
|
||||||
|
attempts: true,
|
||||||
|
startedAt: true,
|
||||||
|
finishedAt: true,
|
||||||
|
} satisfies Prisma.MediaItemSelect;
|
||||||
|
|
||||||
|
export type MediaItemRow = Prisma.MediaItemGetPayload<{ select: typeof MEDIA_ITEM_SELECT }>;
|
||||||
|
|
||||||
|
/** DB 字符串 → 任务类型(未知视为脏数据) */
|
||||||
|
export function parseTaskType(value: string): TaskType {
|
||||||
|
const found = TASK_TYPES.find((type) => type === value);
|
||||||
|
if (found === undefined) throw new Error(`任务类型非法:${value}`);
|
||||||
|
return found;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** DB 字符串 → 任务状态(未知视为脏数据) */
|
||||||
|
export function parseTaskStatus(value: string): TaskStatus {
|
||||||
|
const found = TASK_STATUSES.find((status) => status === value);
|
||||||
|
if (found === undefined) throw new Error(`任务状态非法:${value}`);
|
||||||
|
return found;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** DB 字符串 → 任务项状态(未知视为脏数据) */
|
||||||
|
export function parseTaskItemStatus(value: string): TaskItemStatus {
|
||||||
|
const found = TASK_ITEM_STATUSES.find((status) => status === value);
|
||||||
|
if (found === undefined) throw new Error(`任务项状态非法:${value}`);
|
||||||
|
return found;
|
||||||
|
}
|
||||||
|
|
||||||
|
function optionalIso(value: Date | null): string | undefined {
|
||||||
|
return value === null ? undefined : value.toISOString();
|
||||||
|
}
|
||||||
|
|
||||||
|
/** JsonB → 普通对象(只接受 object;异常结构回退空对象,详情不因此 500) */
|
||||||
|
export function toJsonObject(value: Prisma.JsonValue): Record<string, unknown> {
|
||||||
|
if (typeof value === 'object' && value !== null && !Array.isArray(value)) {
|
||||||
|
return value;
|
||||||
|
}
|
||||||
|
return {};
|
||||||
|
}
|
||||||
|
|
||||||
|
export function toMediaTaskSummary(row: MediaTaskSummaryRow): MediaTaskSummary {
|
||||||
|
const startedAt = optionalIso(row.startedAt);
|
||||||
|
const finishedAt = optionalIso(row.finishedAt);
|
||||||
|
return {
|
||||||
|
id: row.id,
|
||||||
|
type: parseTaskType(row.type),
|
||||||
|
status: parseTaskStatus(row.status),
|
||||||
|
title: row.title,
|
||||||
|
customerId: row.customerId,
|
||||||
|
customerName: row.customerName,
|
||||||
|
itemTotal: row.itemTotal,
|
||||||
|
itemSucceeded: row.itemSucceeded,
|
||||||
|
itemFailed: row.itemFailed,
|
||||||
|
itemSkipped: row.itemSkipped,
|
||||||
|
progress: row.progress,
|
||||||
|
estimatedItems: row.estimatedItems,
|
||||||
|
estimatedCost: Number(row.estimatedCost),
|
||||||
|
estimatedSeconds: row.estimatedSeconds,
|
||||||
|
createdBy: row.createdBy,
|
||||||
|
createdByName: row.createdByName,
|
||||||
|
createdAt: row.createdAt.toISOString(),
|
||||||
|
...(startedAt === undefined ? {} : { startedAt }),
|
||||||
|
...(finishedAt === undefined ? {} : { finishedAt }),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export function toMediaTaskItem(row: MediaItemRow): MediaTaskItem {
|
||||||
|
const startedAt = optionalIso(row.startedAt);
|
||||||
|
const finishedAt = optionalIso(row.finishedAt);
|
||||||
|
return {
|
||||||
|
id: row.id,
|
||||||
|
seq: row.seq,
|
||||||
|
status: parseTaskItemStatus(row.status),
|
||||||
|
...(row.failureCode === null ? {} : { failureCode: row.failureCode }),
|
||||||
|
...(row.failureMessage === null ? {} : { failureMessage: row.failureMessage }),
|
||||||
|
attempts: row.attempts,
|
||||||
|
...(startedAt === undefined ? {} : { startedAt }),
|
||||||
|
...(finishedAt === undefined ? {} : { finishedAt }),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export function toMediaTaskDetail(
|
||||||
|
row: MediaTaskDetailRow,
|
||||||
|
items: MediaItemRow[],
|
||||||
|
itemsNextCursor: string | null,
|
||||||
|
): MediaTaskDetail {
|
||||||
|
const cancelRequestedAt = optionalIso(row.cancelRequestedAt);
|
||||||
|
return {
|
||||||
|
...toMediaTaskSummary(row),
|
||||||
|
payload: toJsonObject(row.payloadJson),
|
||||||
|
...(cancelRequestedAt === undefined ? {} : { cancelRequestedAt }),
|
||||||
|
...(row.lastErrorCode === null ? {} : { lastErrorCode: row.lastErrorCode }),
|
||||||
|
...(row.lastErrorMessage === null ? {} : { lastErrorMessage: row.lastErrorMessage }),
|
||||||
|
items: items.map((item) => toMediaTaskItem(item)),
|
||||||
|
itemsNextCursor,
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -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<string, ReturnType<typeof vi.fn>>;
|
||||||
|
mediaItem: Record<string, ReturnType<typeof vi.fn>>;
|
||||||
|
taskEvent: Record<string, ReturnType<typeof vi.fn>>;
|
||||||
|
outboxEvent: Record<string, ReturnType<typeof vi.fn>>;
|
||||||
|
idempotencyKey: Record<string, ReturnType<typeof vi.fn>>;
|
||||||
|
$transaction: ReturnType<typeof vi.fn>;
|
||||||
|
}
|
||||||
|
|
||||||
|
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<unknown> => {
|
||||||
|
if (typeof callback === 'function') {
|
||||||
|
return (callback as (client: PrismaMock) => Promise<unknown>)(mock);
|
||||||
|
}
|
||||||
|
return Promise.all(callback as Promise<unknown>[]);
|
||||||
|
});
|
||||||
|
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<string, unknown> = {}): 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<unknown>, code: ErrorCode): Promise<void> {
|
||||||
|
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<string, unknown>[] };
|
||||||
|
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<string, unknown>[] };
|
||||||
|
};
|
||||||
|
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<string, unknown> };
|
||||||
|
};
|
||||||
|
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<string, unknown> };
|
||||||
|
};
|
||||||
|
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<string, unknown>;
|
||||||
|
};
|
||||||
|
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<string, unknown> };
|
||||||
|
};
|
||||||
|
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<string, unknown> };
|
||||||
|
};
|
||||||
|
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<string, unknown>;
|
||||||
|
};
|
||||||
|
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<string, unknown>;
|
||||||
|
data: Record<string, unknown>;
|
||||||
|
};
|
||||||
|
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<string, unknown>;
|
||||||
|
data: Record<string, unknown>;
|
||||||
|
};
|
||||||
|
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<string, unknown> };
|
||||||
|
};
|
||||||
|
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 },
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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<string, unknown>;
|
||||||
|
};
|
||||||
|
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<MediaTaskSummaryRow[]> {
|
||||||
|
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<MediaTaskHeaderRow | null> {
|
||||||
|
return this.prisma.mediaTask.findFirst({
|
||||||
|
where: { id, ...customerScopeWhere(scope) },
|
||||||
|
select: MEDIA_TASK_HEADER_SELECT,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// -------------------------------------------------------------------------
|
||||||
|
// 幂等键(唯一键 scope + key;24h 过期视为无记录)
|
||||||
|
// -------------------------------------------------------------------------
|
||||||
|
|
||||||
|
async findIdempotencyByKey(scope: string, key: string): Promise<IdempotencyKey | null> {
|
||||||
|
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<MediaTaskSubmitTransactionResult> {
|
||||||
|
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<CancelTaskResult> {
|
||||||
|
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<RetryTaskResult> {
|
||||||
|
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<StreamTaskRow | null> {
|
||||||
|
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<StreamEventRow[]> {
|
||||||
|
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<StreamTaskRow | null> {
|
||||||
|
return this.prisma.mediaTask.findUnique({
|
||||||
|
where: { id },
|
||||||
|
select: { id: true, status: true, progress: true },
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// -------------------------------------------------------------------------
|
||||||
|
// 结果消费者:只认 media.task.finished / failed,条件 UPDATE 认领避免多实例重复
|
||||||
|
// -------------------------------------------------------------------------
|
||||||
|
|
||||||
|
async findPendingResultEvents(limit: number): Promise<PendingResultEvent[]> {
|
||||||
|
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<boolean> {
|
||||||
|
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<void> {
|
||||||
|
await this.prisma.outboxEvent.updateMany({
|
||||||
|
where: { id },
|
||||||
|
data: { publishedAt: null, lastError: errorMessage.slice(0, 1000) },
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async markResultEventExhausted(id: string, errorMessage: string): Promise<void> {
|
||||||
|
await this.prisma.outboxEvent.updateMany({
|
||||||
|
where: { id },
|
||||||
|
data: { lastError: errorMessage.slice(0, 1000) },
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async findTaskForResult(id: string): Promise<MediaTaskResultRow | null> {
|
||||||
|
return this.prisma.mediaTask.findUnique({ where: { id }, select: MEDIA_TASK_RESULT_SELECT });
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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<string, unknown> = {}): 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<string, unknown> = {}): Record<string, unknown> {
|
||||||
|
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<typeof vi.fn>;
|
||||||
|
replayResponse: ReturnType<typeof vi.fn>;
|
||||||
|
submitTask: ReturnType<typeof vi.fn>;
|
||||||
|
listTasks: ReturnType<typeof vi.fn>;
|
||||||
|
findTaskDetail: ReturnType<typeof vi.fn>;
|
||||||
|
findVisibleTaskHeader: ReturnType<typeof vi.fn>;
|
||||||
|
cancelTask: ReturnType<typeof vi.fn>;
|
||||||
|
retryTask: ReturnType<typeof vi.fn>;
|
||||||
|
};
|
||||||
|
estimator: { estimate: ReturnType<typeof vi.fn> };
|
||||||
|
customers: { detail: ReturnType<typeof vi.fn> };
|
||||||
|
requestContext: {
|
||||||
|
patch: ReturnType<typeof vi.fn>;
|
||||||
|
patchAudit: ReturnType<typeof vi.fn>;
|
||||||
|
get: ReturnType<typeof vi.fn>;
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
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<unknown>, code: ErrorCode): Promise<AppException> {
|
||||||
|
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' } }),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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<MediaTaskSubmitResponse> {
|
||||||
|
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<ReturnType<MediaTaskRepository['submitTask']>>;
|
||||||
|
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<MediaTaskListResponse> {
|
||||||
|
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<MediaTaskDetail> {
|
||||||
|
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<MediaTaskCancelResponse> {
|
||||||
|
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<MediaTaskRetryResponse> {
|
||||||
|
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 };
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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<T> {
|
||||||
|
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<string, unknown>;
|
||||||
|
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<string> {
|
||||||
|
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<string> {
|
||||||
|
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<string> {
|
||||||
|
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<LoginBody> {
|
||||||
|
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<LoginBody>();
|
||||||
|
}
|
||||||
|
|
||||||
|
async function call<T>(
|
||||||
|
method: 'GET' | 'POST',
|
||||||
|
url: string,
|
||||||
|
token: string,
|
||||||
|
payload?: unknown,
|
||||||
|
headers?: Record<string, string>,
|
||||||
|
): Promise<JsonResult<T>> {
|
||||||
|
const response = await app.inject({
|
||||||
|
method,
|
||||||
|
url: `${API}${url}`,
|
||||||
|
headers: { authorization: `Bearer ${token}`, 'user-agent': 'itmedia-vitest', ...headers },
|
||||||
|
...(payload === undefined ? {} : { payload: payload as Record<string, unknown> }),
|
||||||
|
});
|
||||||
|
const body = response.body.length === 0 ? (null as T) : response.json<T>();
|
||||||
|
return { status: response.statusCode, body, headers: response.headers };
|
||||||
|
}
|
||||||
|
|
||||||
|
/** SSE 响应不是 JSON:直接取原始文本(结果流已合流为 string body) */
|
||||||
|
async function callStream(
|
||||||
|
url: string,
|
||||||
|
token: string,
|
||||||
|
headers?: Record<string, string>,
|
||||||
|
): 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<string, unknown> {
|
||||||
|
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<JsonResult<SubmitBody>> {
|
||||||
|
const result = await call<SubmitBody>(
|
||||||
|
'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<DetailBody> {
|
||||||
|
const result = await call<DetailBody>('GET', `/media-tasks/${taskId}`, token);
|
||||||
|
expect(result.status).toBe(200);
|
||||||
|
return result.body;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function sleep(ms: number): Promise<void> {
|
||||||
|
await new Promise((resolve) => setTimeout(resolve, ms));
|
||||||
|
}
|
||||||
|
|
||||||
|
async function cleanupFixtures(): Promise<void> {
|
||||||
|
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<ErrorBody>(
|
||||||
|
'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<ErrorBody>(
|
||||||
|
'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<ErrorBody>(
|
||||||
|
'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<ErrorBody>(
|
||||||
|
'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<ErrorBody>(
|
||||||
|
'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<ErrorBody>(
|
||||||
|
'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<ListBody>(
|
||||||
|
'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<ListBody>('GET', '/media-tasks?mine=true&limit=100', adminToken);
|
||||||
|
expect(mine.body.items.every((item) => item.createdBy === adminUserId)).toBe(true);
|
||||||
|
|
||||||
|
const keyword = await call<ListBody>(
|
||||||
|
'GET',
|
||||||
|
`/media-tasks?keyword=${encodeURIComponent('演练任务')}&limit=100`,
|
||||||
|
adminToken,
|
||||||
|
);
|
||||||
|
expect(keyword.body.items.length).toBeGreaterThan(0);
|
||||||
|
|
||||||
|
const limited = await call<ListBody>('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<ListBody>('GET', '/media-tasks?limit=2', adminToken);
|
||||||
|
expect(first.body.items).toHaveLength(2);
|
||||||
|
expect(first.body.next_cursor).not.toBeNull();
|
||||||
|
const second = await call<ListBody>(
|
||||||
|
'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<ErrorBody>('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<ErrorBody>('GET', `/media-tasks/${created.body.taskId}`, limitedToken);
|
||||||
|
expect(cross.status).toBe(404);
|
||||||
|
expect(cross.body.code).toBe(ErrorCode.NOT_FOUND);
|
||||||
|
|
||||||
|
const invalidCursor = await call<ErrorBody>(
|
||||||
|
'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<ErrorBody>(
|
||||||
|
'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<ErrorBody>(
|
||||||
|
'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<ErrorBody>('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<ErrorBody>(
|
||||||
|
'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');
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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 {}
|
||||||
+303
@@ -4,6 +4,26 @@
|
|||||||
*/
|
*/
|
||||||
|
|
||||||
export interface paths {
|
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': {
|
'/api/v1/auth/login': {
|
||||||
parameters: {
|
parameters: {
|
||||||
query?: never;
|
query?: never;
|
||||||
@@ -716,6 +736,92 @@ export interface paths {
|
|||||||
patch: operations['DictsController_updateItem'];
|
patch: operations['DictsController_updateItem'];
|
||||||
trace?: never;
|
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': {
|
'/health': {
|
||||||
parameters: {
|
parameters: {
|
||||||
query?: never;
|
query?: never;
|
||||||
@@ -911,6 +1017,74 @@ export interface paths {
|
|||||||
export type webhooks = Record<string, never>;
|
export type webhooks = Record<string, never>;
|
||||||
export interface components {
|
export interface components {
|
||||||
schemas: {
|
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: {
|
ComponentHealthDto: {
|
||||||
/**
|
/**
|
||||||
* @example up
|
* @example up
|
||||||
@@ -1018,6 +1192,33 @@ export interface components {
|
|||||||
}
|
}
|
||||||
export type $defs = Record<string, never>;
|
export type $defs = Record<string, never>;
|
||||||
export interface operations {
|
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: {
|
AuthController_login: {
|
||||||
parameters: {
|
parameters: {
|
||||||
query?: never;
|
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: {
|
HealthController_check: {
|
||||||
parameters: {
|
parameters: {
|
||||||
query?: never;
|
query?: never;
|
||||||
|
|||||||
@@ -6,10 +6,13 @@
|
|||||||
"dev": "tsx watch src/main.ts",
|
"dev": "tsx watch src/main.ts",
|
||||||
"build": "tsc -p tsconfig.build.json",
|
"build": "tsc -p tsconfig.build.json",
|
||||||
"start": "node dist/main.js",
|
"start": "node dist/main.js",
|
||||||
|
"test": "vitest run --coverage",
|
||||||
"typecheck": "tsc -p tsconfig.json --noEmit"
|
"typecheck": "tsc -p tsconfig.json --noEmit"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@lingxi/shared": "workspace:*",
|
"@lingxi/shared": "workspace:*",
|
||||||
|
"@prisma/client": "6.19.3",
|
||||||
|
"bullmq": "5.81.5",
|
||||||
"dotenv": "18.0.9",
|
"dotenv": "18.0.9",
|
||||||
"ioredis": "5.11.1",
|
"ioredis": "5.11.1",
|
||||||
"pino": "10.4.0",
|
"pino": "10.4.0",
|
||||||
@@ -17,7 +20,9 @@
|
|||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@types/node": "24.19.2",
|
"@types/node": "24.19.2",
|
||||||
|
"@vitest/coverage-v8": "3.2.7",
|
||||||
"tsx": "4.23.15",
|
"tsx": "4.23.15",
|
||||||
"typescript": "5.9.3"
|
"typescript": "5.9.3",
|
||||||
|
"vitest": "3.2.7"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -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',
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -1,5 +1,9 @@
|
|||||||
|
import os from 'node:os';
|
||||||
|
|
||||||
import { z } from 'zod';
|
import { z } from 'zod';
|
||||||
|
|
||||||
|
import { buildQueueDefinitions, parseQueueSpec, type WorkerQueueDefinition } from './queues';
|
||||||
|
|
||||||
const envSchema = z.object({
|
const envSchema = z.object({
|
||||||
NODE_ENV: z.enum(['development', 'test', 'production']).default('development'),
|
NODE_ENV: z.enum(['development', 'test', 'production']).default('development'),
|
||||||
LOG_LEVEL: z.enum(['fatal', 'error', 'warn', 'info', 'debug', 'trace']).default('info'),
|
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://'),
|
(value) => value.startsWith('redis://') || value.startsWith('rediss://'),
|
||||||
'REDIS_URL 必须是 redis:// 连接串',
|
'REDIS_URL 必须是 redis:// 连接串',
|
||||||
),
|
),
|
||||||
QUEUE: z.string().min(1).default('default'),
|
DATABASE_URL: z
|
||||||
WORKER_CONCURRENCY: z.coerce.number().int().min(1).max(64).default(2),
|
.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 {
|
export interface WorkerConfig {
|
||||||
nodeEnv: 'development' | 'test' | 'production';
|
nodeEnv: 'development' | 'test' | 'production';
|
||||||
logLevel: 'fatal' | 'error' | 'warn' | 'info' | 'debug' | 'trace';
|
logLevel: 'fatal' | 'error' | 'warn' | 'info' | 'debug' | 'trace';
|
||||||
redisUrl: string;
|
redisUrl: string;
|
||||||
queue: string;
|
databaseUrl: string;
|
||||||
concurrency: number;
|
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 {
|
export function loadWorkerConfig(source: NodeJS.ProcessEnv = process.env): WorkerConfig {
|
||||||
const result = envSchema.safeParse(source);
|
const result = envSchema.safeParse(source);
|
||||||
if (!result.success) {
|
if (!result.success) {
|
||||||
@@ -30,11 +54,24 @@ export function loadWorkerConfig(source: NodeJS.ProcessEnv = process.env): Worke
|
|||||||
.join('; ');
|
.join('; ');
|
||||||
throw new Error(`Worker 环境变量校验失败:${details}`);
|
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 {
|
return {
|
||||||
nodeEnv: result.data.NODE_ENV,
|
nodeEnv: env.NODE_ENV,
|
||||||
logLevel: result.data.LOG_LEVEL,
|
logLevel: env.LOG_LEVEL,
|
||||||
redisUrl: result.data.REDIS_URL,
|
redisUrl: env.REDIS_URL,
|
||||||
queue: result.data.QUEUE,
|
databaseUrl: env.DATABASE_URL,
|
||||||
concurrency: result.data.WORKER_CONCURRENCY,
|
queueSpec: env.QUEUE,
|
||||||
|
queues,
|
||||||
|
workerId: env.WORKER_ID ?? defaultWorkerId(),
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
@@ -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:<queueName>', () => {
|
||||||
|
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');
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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<unknown>;
|
||||||
|
del(key: string): Promise<unknown>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface WorkerHeartbeatPayload {
|
||||||
|
workerId: string;
|
||||||
|
pid: number;
|
||||||
|
queue: TaskQueueName;
|
||||||
|
updatedAt: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** `lingxi:worker:heartbeat:<queueName>` */
|
||||||
|
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<void> {
|
||||||
|
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<void> {
|
||||||
|
await client.del(heartbeatKey(queue));
|
||||||
|
}
|
||||||
+187
-22
@@ -1,11 +1,21 @@
|
|||||||
import { existsSync } from 'node:fs';
|
import { existsSync } from 'node:fs';
|
||||||
import { resolve } from 'node:path';
|
import { resolve } from 'node:path';
|
||||||
|
|
||||||
|
import { PrismaClient } from '@prisma/client';
|
||||||
|
import { type Job, Worker } from 'bullmq';
|
||||||
import { config as loadDotenvFile } from 'dotenv';
|
import { config as loadDotenvFile } from 'dotenv';
|
||||||
import Redis from 'ioredis';
|
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 {
|
function loadDotenv(): void {
|
||||||
for (const file of ['.env.local', '.env']) {
|
for (const file of ['.env.local', '.env']) {
|
||||||
@@ -16,34 +26,149 @@ function loadDotenv(): void {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async function main(): Promise<void> {
|
interface WorkerRuntime {
|
||||||
loadDotenv();
|
config: WorkerConfig;
|
||||||
const config = loadWorkerConfig();
|
logger: Logger;
|
||||||
const logger = pino({ level: config.logLevel, base: { service: 'lingxi-worker' } });
|
heartbeatRedis: Redis;
|
||||||
|
prisma: PrismaClient;
|
||||||
|
workers: Worker<TaskJobData, unknown>[];
|
||||||
|
heartbeatTimer: NodeJS.Timeout;
|
||||||
|
}
|
||||||
|
|
||||||
const redis = new Redis(config.redisUrl, {
|
function createHeartbeatRedis(config: WorkerConfig, logger: Logger): Redis {
|
||||||
|
const client = new Redis(config.redisUrl, {
|
||||||
lazyConnect: true,
|
lazyConnect: true,
|
||||||
maxRetriesPerRequest: null,
|
maxRetriesPerRequest: 1,
|
||||||
enableOfflineQueue: false,
|
enableOfflineQueue: false,
|
||||||
});
|
});
|
||||||
redis.on('error', (error: Error) => {
|
client.on('error', (error: Error) => {
|
||||||
logger.error({ err: error }, 'redis connection error');
|
logger.warn({ err: error.message }, '心跳 Redis 连接异常');
|
||||||
});
|
});
|
||||||
|
return client;
|
||||||
|
}
|
||||||
|
|
||||||
await redis.connect();
|
/**
|
||||||
await redis.ping();
|
* job 分发:M0 只有 `media:verify` 的 `task:demo`。
|
||||||
logger.info({ queue: config.queue, concurrency: config.concurrency }, 'worker 已启动,等待任务');
|
* 其余队列 / job 在本里程碑没有处理器:记录告警后直接完成,避免无意义重试。
|
||||||
|
*/
|
||||||
|
function createJobHandler(
|
||||||
|
config: WorkerConfig,
|
||||||
|
prisma: PrismaClient,
|
||||||
|
logger: Logger,
|
||||||
|
): (job: Job<TaskJobData, unknown>) => Promise<void> {
|
||||||
|
// 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 环境变量决定队列)
|
function createWorker(
|
||||||
const heartbeat = setInterval(() => {
|
definition: WorkerQueueDefinition,
|
||||||
logger.debug({ queue: config.queue }, 'worker heartbeat');
|
config: WorkerConfig,
|
||||||
}, 30_000);
|
prisma: PrismaClient,
|
||||||
heartbeat.unref();
|
logger: Logger,
|
||||||
|
connection: RedisConnectionOptions,
|
||||||
|
): Worker<TaskJobData, unknown> {
|
||||||
|
const handler = createJobHandler(config, prisma, logger);
|
||||||
|
return new Worker<TaskJobData, unknown>(
|
||||||
|
toBullQueueName(definition.name),
|
||||||
|
async (job) => {
|
||||||
|
await handler(job);
|
||||||
|
},
|
||||||
|
{ connection, concurrency: definition.concurrency },
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function attachWorkerLogging(
|
||||||
|
worker: Worker<TaskJobData, unknown>,
|
||||||
|
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<void> {
|
||||||
|
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<void> => {
|
const shutdown = async (signal: string): Promise<void> => {
|
||||||
clearInterval(heartbeat);
|
if (shuttingDown) {
|
||||||
logger.info({ signal }, 'worker 正在退出');
|
return;
|
||||||
await redis.quit();
|
}
|
||||||
|
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', () => {
|
process.on('SIGINT', () => {
|
||||||
@@ -54,7 +179,47 @@ async function main(): Promise<void> {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
void main().catch((error: unknown) => {
|
async function bootstrap(): Promise<void> {
|
||||||
|
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(
|
process.stderr.write(
|
||||||
`Worker 启动失败:${error instanceof Error ? (error.stack ?? error.message) : String(error)}\n`,
|
`Worker 启动失败:${error instanceof Error ? (error.stack ?? error.message) : String(error)}\n`,
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -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');
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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<string, unknown> {
|
||||||
|
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<void> {
|
||||||
|
return new Promise<void>((resolve) => {
|
||||||
|
setTimeout(resolve, ms);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async function lockTask(tx: Prisma.TransactionClient, taskId: string): Promise<TaskRow | null> {
|
||||||
|
const rows = await tx.$queryRaw<TaskRow[]>(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<ItemRow | null> {
|
||||||
|
const rows = await tx.$queryRaw<ItemRow[]>(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<void> {
|
||||||
|
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<void> {
|
||||||
|
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<void> {
|
||||||
|
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<void> {
|
||||||
|
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<TaskStatus> {
|
||||||
|
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<void> {
|
||||||
|
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<BeginResult> {
|
||||||
|
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<DemoProcessorOutcome> {
|
||||||
|
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<void> {
|
||||||
|
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<void> {
|
||||||
|
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<DemoProcessorOutcome> {
|
||||||
|
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;
|
||||||
|
}
|
||||||
@@ -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);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
@@ -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';
|
||||||
@@ -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',
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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<string, unknown> {
|
||||||
|
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 };
|
||||||
|
}
|
||||||
@@ -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('未登记的队列');
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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,
|
||||||
|
};
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -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',
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
@@ -8,5 +8,6 @@
|
|||||||
"declarationMap": false,
|
"declarationMap": false,
|
||||||
"sourceMap": true
|
"sourceMap": true
|
||||||
},
|
},
|
||||||
"include": ["src"]
|
"include": ["src"],
|
||||||
|
"exclude": ["src/**/*.spec.ts"]
|
||||||
}
|
}
|
||||||
@@ -7,5 +7,5 @@
|
|||||||
"types": ["node"],
|
"types": ["node"],
|
||||||
"noEmit": true
|
"noEmit": true
|
||||||
},
|
},
|
||||||
"include": ["src"]
|
"include": ["src", "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'],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
@@ -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:<queueName>`(worker 每 10s 续写,TTL 30s)。
|
||||||
|
|
||||||
|
> 队列名口径:指标 / 告警 / 心跳 / `QUEUE` 环境变量都用**逻辑名**(`media:verify` 这类 `<域>:<动作>`);
|
||||||
|
> BullMQ 5.x 禁止队列名与自定义 jobId 含冒号,因此 BullMQ 内部物理队列名把 `:` 换成 `-`(`media-verify`),
|
||||||
|
> jobId 用 `-` 连接(`<eventId>-<itemId>`)。排查 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` 可读到上述指标、规则文件可加载。
|
||||||
@@ -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 与目标队列配置后人工补投。'
|
||||||
@@ -1,6 +1,32 @@
|
|||||||
{
|
{
|
||||||
"openapi": "3.1.0",
|
"openapi": "3.1.0",
|
||||||
"paths": {
|
"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": {
|
"/api/v1/auth/login": {
|
||||||
"post": {
|
"post": {
|
||||||
"description": "返回 accessToken / refreshToken / expiresIn / me",
|
"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": {
|
"/health": {
|
||||||
"get": {
|
"get": {
|
||||||
"description": "真实检查 PostgreSQL 与 Redis;任一不可用返回 503 与统一错误体。",
|
"description": "真实检查 PostgreSQL 与 Redis;任一不可用返回 503 与统一错误体。",
|
||||||
@@ -1361,6 +1505,126 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"schemas": {
|
"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": {
|
"ComponentHealthDto": {
|
||||||
"type": "object",
|
"type": "object",
|
||||||
"properties": {
|
"properties": {
|
||||||
|
|||||||
Generated
+141
@@ -134,6 +134,9 @@ importers:
|
|||||||
argon2:
|
argon2:
|
||||||
specifier: 0.45.1
|
specifier: 0.45.1
|
||||||
version: 0.45.1
|
version: 0.45.1
|
||||||
|
bullmq:
|
||||||
|
specifier: 5.81.5
|
||||||
|
version: 5.81.5
|
||||||
dotenv:
|
dotenv:
|
||||||
specifier: 18.0.9
|
specifier: 18.0.9
|
||||||
version: 18.0.9
|
version: 18.0.9
|
||||||
@@ -247,6 +250,12 @@ importers:
|
|||||||
'@lingxi/shared':
|
'@lingxi/shared':
|
||||||
specifier: workspace:*
|
specifier: workspace:*
|
||||||
version: link:../../packages/shared
|
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:
|
dotenv:
|
||||||
specifier: 18.0.9
|
specifier: 18.0.9
|
||||||
version: 18.0.9
|
version: 18.0.9
|
||||||
@@ -263,12 +272,18 @@ importers:
|
|||||||
'@types/node':
|
'@types/node':
|
||||||
specifier: 24.19.2
|
specifier: 24.19.2
|
||||||
version: 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:
|
tsx:
|
||||||
specifier: 4.23.15
|
specifier: 4.23.15
|
||||||
version: 4.23.15
|
version: 4.23.15
|
||||||
typescript:
|
typescript:
|
||||||
specifier: 5.9.3
|
specifier: 5.9.3
|
||||||
version: 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:
|
packages/ai-sdk:
|
||||||
devDependencies:
|
devDependencies:
|
||||||
@@ -1272,6 +1287,36 @@ packages:
|
|||||||
'@microsoft/tsdoc@0.16.0':
|
'@microsoft/tsdoc@0.16.0':
|
||||||
resolution: {integrity: sha512-xgAyonlVVS+q7Vc7qLW0UrJU7rSFcETRWsqdXZtjzRU8dF+6CkozTK4V4y1LwOX7j8r/vHphjDeMeGI4tNGeGA==}
|
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':
|
'@napi-rs/lzma-linux-x64-gnu@1.5.1':
|
||||||
resolution: {integrity: sha512-oTXEIha4SsuXdTA4Iyskj0kpdx2yVXdhd75c2v3xGrHFfVMsbhTPZU/nMPL4sWKo4pBHm3aucLaqGlF696dTyQ==}
|
resolution: {integrity: sha512-oTXEIha4SsuXdTA4Iyskj0kpdx2yVXdhd75c2v3xGrHFfVMsbhTPZU/nMPL4sWKo4pBHm3aucLaqGlF696dTyQ==}
|
||||||
engines: {node: ^22.20 || ^24.12 || >=25}
|
engines: {node: ^22.20 || ^24.12 || >=25}
|
||||||
@@ -2306,6 +2351,15 @@ packages:
|
|||||||
buffer-equal-constant-time@1.0.1:
|
buffer-equal-constant-time@1.0.1:
|
||||||
resolution: {integrity: sha512-zRpUiDwd/xk6ADqPMATG8vc9VPrkck7T07OIx0gnjmJAnHnTVXNQG3vfvWNuiZIkwu9KrKdA1iJKfsfTVxE6NA==}
|
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:
|
bundle-require@5.1.0:
|
||||||
resolution: {integrity: sha512-3WrrOuZiyaaZPWiEt4G3+IffISVC9HYlWueJEBWED4ZH4aIAC2PnkdnuRrR94M+w6yGWn4AglWtJtBI8YqvgoA==}
|
resolution: {integrity: sha512-3WrrOuZiyaaZPWiEt4G3+IffISVC9HYlWueJEBWED4ZH4aIAC2PnkdnuRrR94M+w6yGWn4AglWtJtBI8YqvgoA==}
|
||||||
engines: {node: ^12.20.0 || ^14.13.1 || >=16.0.0}
|
engines: {node: ^12.20.0 || ^14.13.1 || >=16.0.0}
|
||||||
@@ -2508,6 +2562,10 @@ packages:
|
|||||||
typescript:
|
typescript:
|
||||||
optional: true
|
optional: true
|
||||||
|
|
||||||
|
cron-parser@4.9.0:
|
||||||
|
resolution: {integrity: sha512-p0SaNjrHOnQeR8/VnfGbmg9te2kfyYSQ7Sc/j/6DtPL3JQvKxmjO9TSjNFpujqV3vEYYBvNNvXSxzyksBWAx1Q==}
|
||||||
|
engines: {node: '>=12.0.0'}
|
||||||
|
|
||||||
cross-env@10.1.0:
|
cross-env@10.1.0:
|
||||||
resolution: {integrity: sha512-GsYosgnACZTADcmEyJctkJIoqAhHjttw7RsFrVoJNXbsWWqaq6Ym+7kZjq6mS45O0jij6vtiReppKQEtqWy6Dw==}
|
resolution: {integrity: sha512-GsYosgnACZTADcmEyJctkJIoqAhHjttw7RsFrVoJNXbsWWqaq6Ym+7kZjq6mS45O0jij6vtiReppKQEtqWy6Dw==}
|
||||||
engines: {node: '>=20'}
|
engines: {node: '>=20'}
|
||||||
@@ -2611,6 +2669,10 @@ packages:
|
|||||||
destr@2.0.5:
|
destr@2.0.5:
|
||||||
resolution: {integrity: sha512-ugFTXCtDZunbzasqBxrK93Ik/DRYsO6S/fedkWEMKqt04xZ4csmnmwGDBAb07QWNaGMAmnTIemsYZCksjATwsA==}
|
resolution: {integrity: sha512-ugFTXCtDZunbzasqBxrK93Ik/DRYsO6S/fedkWEMKqt04xZ4csmnmwGDBAb07QWNaGMAmnTIemsYZCksjATwsA==}
|
||||||
|
|
||||||
|
detect-libc@2.1.2:
|
||||||
|
resolution: {integrity: sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==}
|
||||||
|
engines: {node: '>=8'}
|
||||||
|
|
||||||
devlop@1.1.0:
|
devlop@1.1.0:
|
||||||
resolution: {integrity: sha512-RWmIqhcFf1lRYBvNmr7qTNuyCt/7/ns2jbpp1+PalgE/rDQcBT0fioSMUpJ93irlUhC5hrg4cYqe6U+0ImW0rA==}
|
resolution: {integrity: sha512-RWmIqhcFf1lRYBvNmr7qTNuyCt/7/ns2jbpp1+PalgE/rDQcBT0fioSMUpJ93irlUhC5hrg4cYqe6U+0ImW0rA==}
|
||||||
|
|
||||||
@@ -3560,6 +3622,10 @@ packages:
|
|||||||
lru-cache@5.1.1:
|
lru-cache@5.1.1:
|
||||||
resolution: {integrity: sha512-KpNARQA3Iwv+jTA0utUVVbrh+Jlrr1Fv0e56GGzAFOXN7dk/FviaDW8LHmK52DlcH4WP2n6gI8vN1aesBFgo9w==}
|
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:
|
lz-string@1.5.0:
|
||||||
resolution: {integrity: sha512-h5bgJWpxJNswbU7qCrV0tIKQCaS3blPDrqKWx+QxzuzL1zGUzij9XCWLrSLsJPu5t+eWA/ycetzYAO5IOMcWAQ==}
|
resolution: {integrity: sha512-h5bgJWpxJNswbU7qCrV0tIKQCaS3blPDrqKWx+QxzuzL1zGUzij9XCWLrSLsJPu5t+eWA/ycetzYAO5IOMcWAQ==}
|
||||||
hasBin: true
|
hasBin: true
|
||||||
@@ -3788,6 +3854,13 @@ packages:
|
|||||||
ms@2.1.3:
|
ms@2.1.3:
|
||||||
resolution: {integrity: sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==}
|
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:
|
mz@2.7.0:
|
||||||
resolution: {integrity: sha512-z81GNO7nnYMEhrGh9LeymoE4+Yr0Wn5McHIZMK5cfQCl+NDX08sCZgUc9/6MHni9IWuFLm1Z3HTCXu2z9fN62Q==}
|
resolution: {integrity: sha512-z81GNO7nnYMEhrGh9LeymoE4+Yr0Wn5McHIZMK5cfQCl+NDX08sCZgUc9/6MHni9IWuFLm1Z3HTCXu2z9fN62Q==}
|
||||||
|
|
||||||
@@ -3814,6 +3887,9 @@ packages:
|
|||||||
pino-http: ^11.0.0
|
pino-http: ^11.0.0
|
||||||
rxjs: ^7.1.0
|
rxjs: ^7.1.0
|
||||||
|
|
||||||
|
node-abort-controller@3.1.1:
|
||||||
|
resolution: {integrity: sha512-AGK2yQKIjRuqnc6VkX2Xj5d+QW8xZ87pa1UK6yA6ouUyuxfHuMP6umE5QK7UmTeOAymo+Zx1Fxiuw9rVx8taHQ==}
|
||||||
|
|
||||||
node-addon-api@8.9.2:
|
node-addon-api@8.9.2:
|
||||||
resolution: {integrity: sha512-VijLXbi3UACN69I0JVXJsX4tjACjNoQDgv2gTF6sx2wWEi8tkSg2eX8p5gSIFi8z2+DL3oHmY6OyKce38SDolg==}
|
resolution: {integrity: sha512-VijLXbi3UACN69I0JVXJsX4tjACjNoQDgv2gTF6sx2wWEi8tkSg2eX8p5gSIFi8z2+DL3oHmY6OyKce38SDolg==}
|
||||||
engines: {node: ^18 || ^20 || >= 21}
|
engines: {node: ^18 || ^20 || >= 21}
|
||||||
@@ -3821,6 +3897,10 @@ packages:
|
|||||||
node-fetch-native@1.6.7:
|
node-fetch-native@1.6.7:
|
||||||
resolution: {integrity: sha512-g9yhqoedzIUm0nTnTqAQvueMPVOuIY16bqgAJJC8XOOubYFNwz6IER9qs0Gq2Xd0+CecCKFjtdDTMA4u4xG06Q==}
|
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:
|
node-gyp-build@4.8.4:
|
||||||
resolution: {integrity: sha512-LA4ZjwlnUblHVgq0oBF3Jl/6h/Nvs5fzBLwdEF4nuxnFdsfajde4WfxtJr3CaiH+F6ewcIB/q4jQ4UzPyid+CQ==}
|
resolution: {integrity: sha512-LA4ZjwlnUblHVgq0oBF3Jl/6h/Nvs5fzBLwdEF4nuxnFdsfajde4WfxtJr3CaiH+F6ewcIB/q4jQ4UzPyid+CQ==}
|
||||||
hasBin: true
|
hasBin: true
|
||||||
@@ -5797,6 +5877,24 @@ snapshots:
|
|||||||
|
|
||||||
'@microsoft/tsdoc@0.16.0': {}
|
'@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':
|
'@napi-rs/lzma-linux-x64-gnu@1.5.1':
|
||||||
optional: true
|
optional: true
|
||||||
|
|
||||||
@@ -6766,6 +6864,17 @@ snapshots:
|
|||||||
|
|
||||||
buffer-equal-constant-time@1.0.1: {}
|
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):
|
bundle-require@5.1.0(esbuild@0.27.7):
|
||||||
dependencies:
|
dependencies:
|
||||||
esbuild: 0.27.7
|
esbuild: 0.27.7
|
||||||
@@ -6945,6 +7054,10 @@ snapshots:
|
|||||||
optionalDependencies:
|
optionalDependencies:
|
||||||
typescript: 5.9.3
|
typescript: 5.9.3
|
||||||
|
|
||||||
|
cron-parser@4.9.0:
|
||||||
|
dependencies:
|
||||||
|
luxon: 3.7.2
|
||||||
|
|
||||||
cross-env@10.1.0:
|
cross-env@10.1.0:
|
||||||
dependencies:
|
dependencies:
|
||||||
'@epic-web/invariant': 1.0.0
|
'@epic-web/invariant': 1.0.0
|
||||||
@@ -7047,6 +7160,9 @@ snapshots:
|
|||||||
|
|
||||||
destr@2.0.5: {}
|
destr@2.0.5: {}
|
||||||
|
|
||||||
|
detect-libc@2.1.2:
|
||||||
|
optional: true
|
||||||
|
|
||||||
devlop@1.1.0:
|
devlop@1.1.0:
|
||||||
dependencies:
|
dependencies:
|
||||||
dequal: 2.0.3
|
dequal: 2.0.3
|
||||||
@@ -8249,6 +8365,8 @@ snapshots:
|
|||||||
dependencies:
|
dependencies:
|
||||||
yallist: 3.1.1
|
yallist: 3.1.1
|
||||||
|
|
||||||
|
luxon@3.7.2: {}
|
||||||
|
|
||||||
lz-string@1.5.0: {}
|
lz-string@1.5.0: {}
|
||||||
|
|
||||||
magic-string@0.30.21:
|
magic-string@0.30.21:
|
||||||
@@ -8745,6 +8863,22 @@ snapshots:
|
|||||||
|
|
||||||
ms@2.1.3: {}
|
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:
|
mz@2.7.0:
|
||||||
dependencies:
|
dependencies:
|
||||||
any-promise: 1.3.0
|
any-promise: 1.3.0
|
||||||
@@ -8765,10 +8899,17 @@ snapshots:
|
|||||||
pino-http: 11.0.0
|
pino-http: 11.0.0
|
||||||
rxjs: 7.8.2
|
rxjs: 7.8.2
|
||||||
|
|
||||||
|
node-abort-controller@3.1.1: {}
|
||||||
|
|
||||||
node-addon-api@8.9.2: {}
|
node-addon-api@8.9.2: {}
|
||||||
|
|
||||||
node-fetch-native@1.6.7: {}
|
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-gyp-build@4.8.4: {}
|
||||||
|
|
||||||
node-releases@2.0.63: {}
|
node-releases@2.0.63: {}
|
||||||
|
|||||||
Reference in new issue
Block a user