feat(media): A6 任务中心 API、SSE 进度与队列骨架
ci / 质量门禁 (push) Successful in 6m4s

背景:A6-BE-01..05 与 A6-INF-01,打通「提交 → 队列 → SSE 进度 → 完成/失败 → 取消 / 仅重试失败项」闭环,为 B3 / M1 的真实任务提供统一协议。
改动:
- API media 模块:POST/GET /media-tasks、详情、SSE(Last-Event-ID 续推 + 15s 心跳 + 800ms 落库轮询)、取消(只影响未开始项)、仅重试失败项;Idempotency-Key 24h(异步提交 202;同 key 同 body 返回同一任务且不重复审计,不同 body 40902);范围外统一 404;
- 队列骨架:BullMQ 5 九队列(5 组独立启停)、Outbox dispatcher(Redis 锁 + FOR UPDATE SKIP LOCKED + jobId 幂等 + 指数退避/死信)、worker 按 QUEUE 启停、demo 演练处理器(事务 + 行锁 + 取消/重试/幂等)、优雅退出与心跳;
- 监控:GET /api/v1/health/queues、GET /api/v1/metrics(Prometheus 0.0.4)、infra/monitoring 告警规则与接入说明;
- 通用:AuditInterceptor 支持幂等重放 skipAudit(默认行为不变)、AppModule 接线、.env.example 的 QUEUE 语义、bullmq 5.81.5 / worker @prisma/client / worker 覆盖率依赖。
影响面:OpenAPI 新增 6 个端点(openapi:diff 无破坏性变更);API 进程新增 outbox dispatcher 与 metrics/health 路由;worker 新增 BullMQ 消费;未改旧审计导出链路与既有接口。
回滚:回滚提交并停止 worker 新队列消费;task/outbox 新表数据不影响旧功能,可按需保留。
验证:api 单测 257、集成 92(连跑 2 次)、worker 27、shared 197;typecheck / lint / format:check / build / openapi:diff 全绿;本地真实 worker 启用 9 队列心跳,/health/queues、/metrics 实测,/media-tasks 未登录 401。
This commit is contained in:
Stev_Wang committed 2026-11-04 21:52:35 +08:00
1 parent 4a29cf815e
commit 633765f10f
70 files changed
+8674 -45

No files matched your search

+1
View File
@@ -22,6 +22,7 @@
"@nestjs/swagger": "11.4.7",
"@prisma/client": "6.19.3",
"argon2": "0.45.1",
"bullmq": "5.81.5",
"dotenv": "18.0.9",
"fastify": "5.12.7",
"ioredis": "5.11.1",
+4
View File
@@ -6,10 +6,12 @@ import { SecurityModule } from './common/security/security.module';
import { APP_CONFIG, AppConfigModule } from './config/config.module';
import type { AppConfig } from './config/env';
import { PrismaModule } from './infra/prisma/prisma.module';
import { QueueModule } from './infra/queue/queue.module';
import { RedisModule } from './infra/redis/redis.module';
import { CatalogModule } from './modules/catalog/catalog.module';
import { ClientModule } from './modules/client/client.module';
import { IdentityModule } from './modules/identity/identity.module';
import { MediaModule } from './modules/media/media.module';
import { PlatformModule } from './modules/platform/platform.module';
@Module({
@@ -49,10 +51,12 @@ import { PlatformModule } from './modules/platform/platform.module';
}),
PrismaModule,
RedisModule,
QueueModule,
SecurityModule,
IdentityModule,
ClientModule,
CatalogModule,
MediaModule,
PlatformModule,
],
})
@@ -163,6 +163,22 @@ describe('AuditInterceptor · 审计落库(A5-BE-02)', () => {
).resolves.toBe('ok');
});
it('skipAudit(幂等重放):请求正常返回但不写审计', async () => {
const requestContext = new RequestContextService();
const sink = sinkOf();
const interceptor = interceptorOf(requestContext, sink);
const next = handlerOf(() => {
// 模拟 A6 任务提交识别为幂等重放:返回同一 taskId,但不产生新的业务动作
requestContext.patch({ skipAudit: true });
return of({ taskId: 't1' });
});
await expect(
lastValueFrom(interceptor.intercept(contextOf('task.submit', requestFixture()), next)),
).resolves.toEqual({ taskId: 't1' });
expect(sink.write).not.toHaveBeenCalled();
});
it('未标注 @Audit 的请求不写审计', async () => {
const requestContext = new RequestContextService();
const sink = sinkOf();
@@ -9,7 +9,7 @@ import {
type NestInterceptor,
Optional,
} from '@nestjs/common';
import { from, Observable, throwError } from 'rxjs';
import { from, Observable, of, throwError } from 'rxjs';
import { catchError, concatMap, map, mergeMap } from 'rxjs/operators';
import { AppException } from '../errors/app-exception';
@@ -119,15 +119,18 @@ export class AuditInterceptor implements NestInterceptor {
return next
.handle()
.pipe(
concatMap((value: unknown) =>
from(this.record(ctx, action, 'success', targetId)).pipe(map(() => value)),
),
catchError((error: unknown) =>
concatMap((value: unknown) => {
// skipAudit:handler 内 patch({ skipAudit: true })(如 A6 幂等重放)时不落审计
if (ctx.skipAudit === true) return of(value);
return from(this.record(ctx, action, 'success', targetId)).pipe(map(() => value));
}),
catchError((error: unknown) => {
// record 内部已吞掉异常;这里再兜底一次,保证原错误原样抛回
from(
if (ctx.skipAudit === true) return throwError(() => error);
return from(
this.record(ctx, action, 'failure', targetId, error).catch(() => undefined),
).pipe(mergeMap(() => throwError(() => error))),
),
).pipe(mergeMap(() => throwError(() => error)));
}),
)
.subscribe(subscriber);
}),
@@ -40,6 +40,12 @@ export interface RequestContext {
permissions: string[] | null;
scope: ScopeContext | null;
audit: AuditDetail | null;
/**
* 跳过本次请求的 @Audit 落库(默认 false,行为不变)。
* 目前唯一用途:A6 任务提交幂等重放(同 key 同 body)——不重复建任务、不重复写审计,
* 由 media Service 在识别为重放后 `patch({ skipAudit: true })`。
*/
skipAudit?: boolean;
ip: string | null;
userAgent: string | null;
method: string;
@@ -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);
});
});
+172
View File
@@ -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 },
})),
};
}
+279
View File
@@ -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');
});
});
+97
View File
@@ -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');
});
});
+119
View File
@@ -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";
+23
View File
@@ -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 {}
+34
View File
@@ -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;
}
+27
View File
@@ -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 {}