背景:A6-BE-01..05 与 A6-INF-01,打通「提交 → 队列 → SSE 进度 → 完成/失败 → 取消 / 仅重试失败项」闭环,为 B3 / M1 的真实任务提供统一协议。 改动: - API media 模块:POST/GET /media-tasks、详情、SSE(Last-Event-ID 续推 + 15s 心跳 + 800ms 落库轮询)、取消(只影响未开始项)、仅重试失败项;Idempotency-Key 24h(异步提交 202;同 key 同 body 返回同一任务且不重复审计,不同 body 40902);范围外统一 404; - 队列骨架:BullMQ 5 九队列(5 组独立启停)、Outbox dispatcher(Redis 锁 + FOR UPDATE SKIP LOCKED + jobId 幂等 + 指数退避/死信)、worker 按 QUEUE 启停、demo 演练处理器(事务 + 行锁 + 取消/重试/幂等)、优雅退出与心跳; - 监控:GET /api/v1/health/queues、GET /api/v1/metrics(Prometheus 0.0.4)、infra/monitoring 告警规则与接入说明; - 通用:AuditInterceptor 支持幂等重放 skipAudit(默认行为不变)、AppModule 接线、.env.example 的 QUEUE 语义、bullmq 5.81.5 / worker @prisma/client / worker 覆盖率依赖。 影响面:OpenAPI 新增 6 个端点(openapi:diff 无破坏性变更);API 进程新增 outbox dispatcher 与 metrics/health 路由;worker 新增 BullMQ 消费;未改旧审计导出链路与既有接口。 回滚:回滚提交并停止 worker 新队列消费;task/outbox 新表数据不影响旧功能,可按需保留。 验证:api 单测 257、集成 92(连跑 2 次)、worker 27、shared 197;typecheck / lint / format:check / build / openapi:diff 全绿;本地真实 worker 启用 9 队列心跳,/health/queues、/metrics 实测,/media-tasks 未登录 401。
This commit is contained in:
1 parent
4a29cf815e
commit
633765f10f
70 files changed
+8674
-45
No files matched your search
@@ -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",
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,172 @@
|
||||
import {
|
||||
queueOfTaskType,
|
||||
TASK_JOB_NAMES,
|
||||
TASK_LOCAL_EVENT_TYPES,
|
||||
TASK_OUTBOX_EVENTS,
|
||||
TASK_QUEUE_EVENT_TYPES,
|
||||
TASK_QUEUES,
|
||||
TASK_TYPE_QUEUE,
|
||||
type TaskQueueGroup,
|
||||
type TaskQueueName,
|
||||
type TaskType,
|
||||
} from '@lingxi/shared';
|
||||
|
||||
import { OUTBOX_MAX_ATTEMPTS } from './queue.constants';
|
||||
|
||||
/** TASK_QUEUES 的元素类型(重试 / 退避 / 并发上限的唯一事实源) */
|
||||
export type TaskQueueDefinition = (typeof TASK_QUEUES)[number];
|
||||
|
||||
/** dispatcher 认领到的一条 outbox 事件(attempts 已在本轮认领时 +1) */
|
||||
export interface ClaimedOutboxEvent {
|
||||
id: string;
|
||||
aggregate_id: string;
|
||||
event_type: string;
|
||||
payload_json: unknown;
|
||||
attempts: number;
|
||||
}
|
||||
|
||||
/** BullMQ job data 契约:与 worker 侧 `TaskJobData` 保持一致 */
|
||||
export interface TaskJobData {
|
||||
taskId: string;
|
||||
itemId: string;
|
||||
}
|
||||
|
||||
export interface PlannedTaskJob {
|
||||
queue: TaskQueueName;
|
||||
jobName: string;
|
||||
/**
|
||||
* `${eventId}-${itemId}`:重复投递同一事件不会产生重复 job。
|
||||
* 注意 BullMQ 5.x 不允许自定义 jobId 含冒号(`Custom Id cannot contain :`),
|
||||
* 因此用 ULID 之间不冲突的 `-` 分隔。
|
||||
*/
|
||||
jobId: string;
|
||||
data: TaskJobData;
|
||||
}
|
||||
|
||||
export interface OutboxDeliveryPlan {
|
||||
eventId: string;
|
||||
taskId: string;
|
||||
jobs: PlannedTaskJob[];
|
||||
}
|
||||
|
||||
/**
|
||||
* 任务类型 → job 名。M0 只有演练任务有显式映射(demo → task:demo),
|
||||
* 其余类型按 `<type>:run` 占位,后续批次接入真实处理器时再补充映射。
|
||||
*/
|
||||
const JOB_NAME_MAP: ReadonlyMap<string, string> = new Map(Object.entries(TASK_JOB_NAMES));
|
||||
|
||||
export function jobNameOfTaskType(taskType: TaskType): string {
|
||||
return JOB_NAME_MAP.get(taskType) ?? `${taskType}:run`;
|
||||
}
|
||||
|
||||
export function isTaskType(value: string): value is TaskType {
|
||||
return Object.hasOwn(TASK_TYPE_QUEUE, value);
|
||||
}
|
||||
|
||||
export function isQueueEventType(eventType: string): boolean {
|
||||
return TASK_QUEUE_EVENT_TYPES.some((type) => type === eventType);
|
||||
}
|
||||
|
||||
/**
|
||||
* dispatcher 认领的事件类型:
|
||||
* - created / retry → 拆任务项入队;
|
||||
* - canceled → 不产生 job,直接置 published_at(worker 通过 media_tasks 状态感知取消)。
|
||||
* 注意:finished / failed 归 media 模块本地消费者,dispatcher **绝不认领**(认领会抢事件导致审计丢失)。
|
||||
*/
|
||||
export const DISPATCHABLE_OUTBOX_EVENT_TYPES: readonly string[] = [
|
||||
...TASK_QUEUE_EVENT_TYPES,
|
||||
TASK_OUTBOX_EVENTS.canceled,
|
||||
];
|
||||
|
||||
export function isDispatchableOutboxEvent(eventType: string): boolean {
|
||||
return DISPATCHABLE_OUTBOX_EVENT_TYPES.some((type) => type === eventType);
|
||||
}
|
||||
|
||||
export function isLocalEventType(eventType: string): boolean {
|
||||
return TASK_LOCAL_EVENT_TYPES.some((type) => type === eventType);
|
||||
}
|
||||
|
||||
/** 取队列定义(并发 / 重试次数 / 退避);未登记属编码错误,直接抛出 */
|
||||
export function queueDefinitionOf(name: TaskQueueName): TaskQueueDefinition {
|
||||
const definition = TASK_QUEUES.find((item) => item.name === name);
|
||||
if (definition === undefined) {
|
||||
throw new Error(`未登记的队列:${name}`);
|
||||
}
|
||||
return definition;
|
||||
}
|
||||
|
||||
export function queueGroupOfName(name: TaskQueueName): TaskQueueGroup {
|
||||
return queueDefinitionOf(name).group;
|
||||
}
|
||||
|
||||
/** 失败退避:min(2^attempts * 500ms, 60s),与认领 SQL 中的表达式保持一致 */
|
||||
export function outboxBackoffMs(attempts: number): number {
|
||||
return Math.min(2 ** attempts * 500, 60_000);
|
||||
}
|
||||
|
||||
/** 是否已达死信阈值(attempts 为本轮认领后的累计尝试次数) */
|
||||
export function isOutboxDeadLetter(attempts: number): boolean {
|
||||
return attempts >= OUTBOX_MAX_ATTEMPTS;
|
||||
}
|
||||
|
||||
function isRecord(value: unknown): value is Record<string, unknown> {
|
||||
return typeof value === 'object' && value !== null && !Array.isArray(value);
|
||||
}
|
||||
|
||||
interface ParsedTaskPayload {
|
||||
taskType: TaskType;
|
||||
itemIds: string[];
|
||||
}
|
||||
|
||||
/** 解析 outbox payload `{ type, itemIds, ... }`;结构非法抛中文错误,交由 dispatcher 记账重试 */
|
||||
export function parseOutboxTaskPayload(payload: unknown): ParsedTaskPayload {
|
||||
if (!isRecord(payload)) {
|
||||
throw new Error('Outbox 任务事件 payload 不是对象');
|
||||
}
|
||||
const type = payload.type;
|
||||
if (typeof type !== 'string' || !isTaskType(type)) {
|
||||
throw new Error(`Outbox 任务事件 type 非法:${String(type)}`);
|
||||
}
|
||||
const itemIdsRaw = payload.itemIds;
|
||||
if (!Array.isArray(itemIdsRaw)) {
|
||||
throw new Error('Outbox 任务事件缺少 itemIds 数组');
|
||||
}
|
||||
const itemIds = itemIdsRaw.map((itemId: unknown) => {
|
||||
if (typeof itemId !== 'string' || itemId.length === 0) {
|
||||
throw new Error('Outbox 任务事件 itemIds 含非法值');
|
||||
}
|
||||
return itemId;
|
||||
});
|
||||
return { taskType: type, itemIds };
|
||||
}
|
||||
|
||||
/**
|
||||
* 事件 → 待投递 job 计划(纯函数)。
|
||||
* - 只处理 TASK_QUEUE_EVENT_TYPES(media.task.created / media.task.retry);
|
||||
* - `media.task.finished` / `media.task.failed` 由 media 模块本地消费者处理,**绝不入队**;
|
||||
* - `media.task.canceled` 由 worker 通过 DB 状态感知,直接视为无需投递(返回 null 由调用方置 published)。
|
||||
*/
|
||||
export function planOutboxDelivery(event: ClaimedOutboxEvent): OutboxDeliveryPlan | null {
|
||||
if (event.event_type === TASK_OUTBOX_EVENTS.canceled) {
|
||||
return null;
|
||||
}
|
||||
if (isLocalEventType(event.event_type) || !isQueueEventType(event.event_type)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const payload = parseOutboxTaskPayload(event.payload_json);
|
||||
const queue = queueOfTaskType(payload.taskType);
|
||||
const jobName = jobNameOfTaskType(payload.taskType);
|
||||
const taskId = event.aggregate_id;
|
||||
|
||||
return {
|
||||
eventId: event.id,
|
||||
taskId,
|
||||
jobs: payload.itemIds.map((itemId) => ({
|
||||
queue,
|
||||
jobName,
|
||||
jobId: `${event.id}-${itemId}`,
|
||||
data: { taskId, itemId },
|
||||
})),
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,279 @@
|
||||
import { type TaskQueueName, ulid } from '@lingxi/shared';
|
||||
import { Prisma } from '@prisma/client';
|
||||
import { afterAll, afterEach, beforeAll, describe, expect, it } from 'vitest';
|
||||
|
||||
import type { AppConfig } from '../../config/env';
|
||||
import { loadEnv } from '../../config/env';
|
||||
import { loadDotenv } from '../../config/load-dotenv';
|
||||
import { PrismaService } from '../prisma/prisma.service';
|
||||
import { RedisService } from '../redis/redis.service';
|
||||
import { OutboxDispatcherService } from './outbox-dispatcher.service';
|
||||
import { queueDefinitionOf } from './outbox-payload';
|
||||
import { QueueRegistryService } from './queue-registry.service';
|
||||
|
||||
/**
|
||||
* Outbox dispatcher 集成用例(真实 PG + Redis)。
|
||||
*
|
||||
* 覆盖 A6-BE-05 验收:created/retry 事件投递为 BullMQ job、published_at 置位、
|
||||
* 重复运行不重复入队(jobId 幂等)、canceled 直接置已投递不入队、
|
||||
* finished/failed 不被认领、失败退避与 attempts 达阈值后死信。
|
||||
*
|
||||
* 夹具约定:outbox id 统一 `ITQU` 前缀,afterEach 清理 outbox 行与对应 BullMQ job;
|
||||
* 不写入 media_tasks / media_items(dispatcher 只认领 outbox,不依赖任务存在)。
|
||||
*/
|
||||
const FIXTURE_PREFIX = 'ITQU';
|
||||
const QUEUE: TaskQueueName = 'media:verify';
|
||||
const ALL_JOB_STATES = ['waiting', 'active', 'delayed', 'paused', 'completed', 'failed'] as const;
|
||||
|
||||
interface OutboxSnapshot {
|
||||
id: string;
|
||||
event_type: string;
|
||||
attempts: number;
|
||||
last_error: string | null;
|
||||
published_at: Date | null;
|
||||
}
|
||||
|
||||
interface TaskPayload {
|
||||
type: string;
|
||||
itemIds: string[];
|
||||
}
|
||||
|
||||
let config: AppConfig;
|
||||
let prisma: PrismaService;
|
||||
let redis: RedisService;
|
||||
let registry: QueueRegistryService;
|
||||
let dispatcher: OutboxDispatcherService;
|
||||
|
||||
function fixtureId(): string {
|
||||
return `${FIXTURE_PREFIX}${ulid().slice(FIXTURE_PREFIX.length)}`;
|
||||
}
|
||||
|
||||
async function insertOutbox(
|
||||
id: string,
|
||||
eventType: string,
|
||||
payload: TaskPayload,
|
||||
options: { ageSeconds?: number; attempts?: number } = {},
|
||||
): Promise<void> {
|
||||
const ageSeconds = options.ageSeconds ?? 31_536_000;
|
||||
const attempts = options.attempts ?? 0;
|
||||
await prisma.$executeRaw(Prisma.sql`
|
||||
INSERT INTO outbox_events
|
||||
(id, aggregate_type, aggregate_id, event_type, payload_json, occurred_at, attempts)
|
||||
VALUES
|
||||
(${id}, 'media_task', ${id}, ${eventType}, ${JSON.stringify(payload)}::jsonb,
|
||||
now() - interval '1 second' * ${ageSeconds}, ${attempts})
|
||||
`);
|
||||
}
|
||||
|
||||
async function readOutbox(id: string): Promise<OutboxSnapshot | null> {
|
||||
const rows = await prisma.$queryRaw<OutboxSnapshot[]>(Prisma.sql`
|
||||
SELECT id, event_type, attempts, last_error, published_at
|
||||
FROM outbox_events WHERE id = ${id}
|
||||
`);
|
||||
return rows[0] ?? null;
|
||||
}
|
||||
|
||||
async function fixtureJobIds(eventId: string): Promise<string[]> {
|
||||
const jobs = await registry.getQueue(QUEUE).getJobs([...ALL_JOB_STATES], 0, 1000);
|
||||
return jobs.map((job) => job.id).filter((id): id is string => id?.startsWith(eventId) === true);
|
||||
}
|
||||
|
||||
function sleep(ms: number): Promise<void> {
|
||||
return new Promise<void>((resolve) => {
|
||||
setTimeout(resolve, ms);
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* 驱动 dispatcher 直到条件满足。
|
||||
* 本机可能同时运行着真实 API(带 dispatcher):会与本用例竞争 Redis 锁或抢先投递,
|
||||
* 因此不假设「单次 dispatchOnce 一定认领到」,而是轮询到结果收敛(CI 干净环境下第一轮即满足)。
|
||||
*/
|
||||
async function dispatchUntil(
|
||||
check: () => Promise<boolean>,
|
||||
description: string,
|
||||
timeoutMs = 10_000,
|
||||
): Promise<void> {
|
||||
const deadline = Date.now() + timeoutMs;
|
||||
while (Date.now() < deadline) {
|
||||
await dispatcher.dispatchOnce();
|
||||
if (await check()) {
|
||||
return;
|
||||
}
|
||||
await sleep(200);
|
||||
}
|
||||
throw new Error(`等待 dispatcher ${description} 超时(${String(timeoutMs)}ms)`);
|
||||
}
|
||||
|
||||
async function cleanup(): Promise<void> {
|
||||
const queue = registry.getQueue(QUEUE);
|
||||
const jobs = await queue.getJobs([...ALL_JOB_STATES], 0, 1000);
|
||||
await Promise.all(
|
||||
jobs
|
||||
.filter((job) => job.id?.startsWith(FIXTURE_PREFIX) === true)
|
||||
.map(async (job) => {
|
||||
await job.remove().catch(() => undefined);
|
||||
}),
|
||||
);
|
||||
await prisma.$executeRaw(
|
||||
Prisma.sql`DELETE FROM outbox_events WHERE id LIKE ${`${FIXTURE_PREFIX}%`}`,
|
||||
);
|
||||
}
|
||||
|
||||
beforeAll(async () => {
|
||||
loadDotenv();
|
||||
config = loadEnv();
|
||||
prisma = new PrismaService(config);
|
||||
redis = new RedisService(config);
|
||||
registry = new QueueRegistryService(config);
|
||||
dispatcher = new OutboxDispatcherService(prisma, redis, registry);
|
||||
await redis.ping();
|
||||
});
|
||||
|
||||
afterEach(async () => {
|
||||
await cleanup();
|
||||
});
|
||||
|
||||
afterAll(async () => {
|
||||
await registry.onModuleDestroy();
|
||||
redis.onModuleDestroy();
|
||||
await prisma.$disconnect();
|
||||
});
|
||||
|
||||
describe('Outbox dispatcher(真实 PG + Redis)', () => {
|
||||
it('media.task.created → 每项一个 job(jobId=事件:任务项),published_at 置位', async () => {
|
||||
const eventId = fixtureId();
|
||||
const itemA = fixtureId();
|
||||
const itemB = fixtureId();
|
||||
await insertOutbox(eventId, 'media.task.created', { type: 'demo', itemIds: [itemA, itemB] });
|
||||
|
||||
await dispatchUntil(
|
||||
async () => (await readOutbox(eventId))?.published_at !== null,
|
||||
'投递 created 事件',
|
||||
);
|
||||
|
||||
const row = await readOutbox(eventId);
|
||||
expect(row?.published_at).not.toBeNull();
|
||||
expect(row?.last_error).toBeNull();
|
||||
|
||||
const queue = registry.getQueue(QUEUE);
|
||||
const definition = queueDefinitionOf(QUEUE);
|
||||
const job = await queue.getJob(`${eventId}-${itemA}`);
|
||||
expect(job).not.toBeNull();
|
||||
expect(job?.data).toEqual({ taskId: eventId, itemId: itemA });
|
||||
expect(job?.opts.attempts).toBe(definition.attempts);
|
||||
expect(job?.opts.backoff).toEqual({
|
||||
type: 'fixed',
|
||||
delay: definition.backoffDelayMs,
|
||||
});
|
||||
expect(await fixtureJobIds(eventId)).toHaveLength(2);
|
||||
});
|
||||
|
||||
it('media.task.retry → 按新事件 id 重新入队(可重试失败项)', async () => {
|
||||
const eventId = fixtureId();
|
||||
const itemA = fixtureId();
|
||||
await insertOutbox(eventId, 'media.task.retry', { type: 'demo', itemIds: [itemA] });
|
||||
|
||||
await dispatchUntil(
|
||||
async () => (await readOutbox(eventId))?.published_at !== null,
|
||||
'投递 retry 事件',
|
||||
);
|
||||
|
||||
expect((await readOutbox(eventId))?.published_at).not.toBeNull();
|
||||
expect(await fixtureJobIds(eventId)).toEqual([`${eventId}-${itemA}`]);
|
||||
});
|
||||
|
||||
it('重复运行不重复入队:把事件重置为未投递后再跑一轮,jobId 幂等', async () => {
|
||||
const eventId = fixtureId();
|
||||
const itemA = fixtureId();
|
||||
const itemB = fixtureId();
|
||||
await insertOutbox(eventId, 'media.task.created', { type: 'demo', itemIds: [itemA, itemB] });
|
||||
|
||||
await dispatchUntil(
|
||||
async () => (await readOutbox(eventId))?.published_at !== null,
|
||||
'首次投递 created 事件',
|
||||
);
|
||||
expect(await fixtureJobIds(eventId)).toHaveLength(2);
|
||||
|
||||
// 模拟至少一次投递的重复认领:事件回到未投递状态,再投递一轮
|
||||
await prisma.$executeRaw(Prisma.sql`
|
||||
UPDATE outbox_events SET published_at = NULL, attempts = 0 WHERE id = ${eventId}
|
||||
`);
|
||||
await dispatchUntil(
|
||||
async () => (await readOutbox(eventId))?.published_at !== null,
|
||||
'重复投递 created 事件',
|
||||
);
|
||||
|
||||
expect(await fixtureJobIds(eventId)).toHaveLength(2);
|
||||
expect((await readOutbox(eventId))?.published_at).not.toBeNull();
|
||||
});
|
||||
|
||||
it('media.task.canceled → 直接置已投递、不产生 job(worker 靠 DB 状态感知取消)', async () => {
|
||||
const eventId = fixtureId();
|
||||
await insertOutbox(eventId, 'media.task.canceled', { type: 'demo', itemIds: [fixtureId()] });
|
||||
|
||||
await dispatchUntil(
|
||||
async () => (await readOutbox(eventId))?.published_at !== null,
|
||||
'投递 canceled 事件',
|
||||
);
|
||||
|
||||
expect((await readOutbox(eventId))?.published_at).not.toBeNull();
|
||||
expect(await fixtureJobIds(eventId)).toHaveLength(0);
|
||||
});
|
||||
|
||||
it('media.task.finished / failed 不被认领(留给 media 模块本地消费者写审计)', async () => {
|
||||
const finishedId = fixtureId();
|
||||
const failedId = fixtureId();
|
||||
const payload: TaskPayload = { type: 'demo', itemIds: [fixtureId()] };
|
||||
await insertOutbox(finishedId, 'media.task.finished', payload);
|
||||
await insertOutbox(failedId, 'media.task.failed', payload);
|
||||
|
||||
// finished / failed 不在 dispatcher 认领集合内:跑几轮也不应被投递
|
||||
for (let round = 0; round < 3; round += 1) {
|
||||
await dispatcher.dispatchOnce();
|
||||
await sleep(200);
|
||||
}
|
||||
|
||||
// 本机可能同时运行着真实 API:media 模块的结果消费者会认领 finished / failed 并置 published_at,
|
||||
// 因此这里只断言「dispatcher 不会把这两类事件变成队列 job」,published 归属不由本用例独占。
|
||||
const finished = await readOutbox(finishedId);
|
||||
const failed = await readOutbox(failedId);
|
||||
expect(finished?.last_error).toBeNull();
|
||||
expect(failed?.last_error).toBeNull();
|
||||
expect(await fixtureJobIds(finishedId)).toHaveLength(0);
|
||||
expect(await fixtureJobIds(failedId)).toHaveLength(0);
|
||||
});
|
||||
|
||||
it('payload 非法 → 记 last_error 等待退避,attempts 达阈值后标记 dead_lettered', async () => {
|
||||
const brokenId = fixtureId();
|
||||
await insertOutbox(brokenId, 'media.task.created', { type: 'unknown-type', itemIds: [] });
|
||||
|
||||
await dispatchUntil(
|
||||
async () => (await readOutbox(brokenId))?.last_error !== null,
|
||||
'记录 payload 非法事件的 last_error',
|
||||
);
|
||||
|
||||
const broken = await readOutbox(brokenId);
|
||||
expect(broken?.published_at).toBeNull();
|
||||
expect(broken?.last_error).toContain('type 非法');
|
||||
|
||||
// 模拟长期投递失败:attempts=9 且事件足够旧 → 下一轮认领后 attempts=10 触发死信
|
||||
const deadLetterId = fixtureId();
|
||||
await insertOutbox(
|
||||
deadLetterId,
|
||||
'media.task.created',
|
||||
{ type: 'unknown-type', itemIds: [] },
|
||||
{ ageSeconds: 120, attempts: 9 },
|
||||
);
|
||||
await dispatchUntil(
|
||||
async () => (await readOutbox(deadLetterId))?.last_error === 'dead_lettered',
|
||||
'标记死信',
|
||||
);
|
||||
|
||||
const deadLetter = await readOutbox(deadLetterId);
|
||||
expect(deadLetter?.published_at).not.toBeNull();
|
||||
expect(deadLetter?.last_error).toBe('dead_lettered');
|
||||
expect(deadLetter?.attempts).toBe(10);
|
||||
expect(await fixtureJobIds(deadLetterId)).toHaveLength(0);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,53 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
|
||||
import type { QueueMetricsInput } from './prometheus';
|
||||
import { failureRatio, renderPrometheusMetrics } from './prometheus';
|
||||
|
||||
function queueMetrics(overrides: Partial<QueueMetricsInput> = {}): QueueMetricsInput {
|
||||
return {
|
||||
name: 'media:verify',
|
||||
counts: { waiting: 2, active: 1, completed: 8, failed: 2, delayed: 0 },
|
||||
oldestWaitingSeconds: 42,
|
||||
heartbeatAgeSeconds: 5,
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
describe('Prometheus 指标渲染(纯函数)', () => {
|
||||
it('失败率 = failed/(failed+completed),无样本为 0', () => {
|
||||
expect(failureRatio({ waiting: 0, active: 0, completed: 0, failed: 0, delayed: 0 })).toBe(0);
|
||||
expect(failureRatio({ waiting: 0, active: 0, completed: 9, failed: 1, delayed: 0 })).toBe(0.1);
|
||||
expect(failureRatio({ waiting: 0, active: 0, completed: 3, failed: 1, delayed: 0 })).toBe(0.25);
|
||||
});
|
||||
|
||||
it('输出全部 A6-INF-01 指标,心跳缺失输出 -1', () => {
|
||||
const text = renderPrometheusMetrics({
|
||||
queues: [queueMetrics(), queueMetrics({ name: 'image:render', heartbeatAgeSeconds: null })],
|
||||
outbox: { pending: 4, deadLetter: 1 },
|
||||
comments: ['队列 media:verify 指标查询失败:测试'],
|
||||
});
|
||||
|
||||
expect(text).toContain('# 队列 media:verify 指标查询失败:测试');
|
||||
expect(text).toContain('# TYPE lingxi_queue_jobs gauge');
|
||||
expect(text).toContain('lingxi_queue_jobs{queue="media:verify",state="waiting"} 2');
|
||||
expect(text).toContain('lingxi_queue_jobs{queue="image:render",state="failed"} 2');
|
||||
expect(text).toContain('lingxi_queue_oldest_waiting_seconds{queue="media:verify"} 42');
|
||||
expect(text).toContain('lingxi_queue_failure_ratio{queue="media:verify"} 0.2');
|
||||
expect(text).toContain('lingxi_worker_heartbeat_age_seconds{queue="media:verify"} 5');
|
||||
expect(text).toContain('lingxi_worker_heartbeat_age_seconds{queue="image:render"} -1');
|
||||
expect(text).toContain('lingxi_outbox_pending_total 4');
|
||||
expect(text).toContain('lingxi_outbox_dead_letter_total 1');
|
||||
expect(text.endsWith('\n')).toBe(true);
|
||||
});
|
||||
|
||||
it('outbox 采集失败时不输出 outbox 指标,但队列指标仍可用', () => {
|
||||
const text = renderPrometheusMetrics({
|
||||
queues: [queueMetrics()],
|
||||
outbox: null,
|
||||
comments: ['Outbox 指标查询失败:数据库不可用'],
|
||||
});
|
||||
expect(text).toContain('lingxi_queue_jobs{queue="media:verify",state="active"} 1');
|
||||
expect(text).not.toContain('lingxi_outbox_pending_total');
|
||||
expect(text).not.toContain('lingxi_outbox_dead_letter_total');
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,97 @@
|
||||
import type { TaskQueueName } from '@lingxi/shared';
|
||||
|
||||
import type { QueueCountsSnapshot } from './queue-health';
|
||||
|
||||
/** 单个队列的指标快照(已换算成 Prometheus 需要的秒 / 比率口径) */
|
||||
export interface QueueMetricsInput {
|
||||
name: TaskQueueName;
|
||||
counts: QueueCountsSnapshot;
|
||||
oldestWaitingSeconds: number;
|
||||
heartbeatAgeSeconds: number | null;
|
||||
}
|
||||
|
||||
export interface OutboxMetricsInput {
|
||||
pending: number;
|
||||
deadLetter: number;
|
||||
}
|
||||
|
||||
export interface MetricsRenderInput {
|
||||
queues: readonly QueueMetricsInput[];
|
||||
outbox: OutboxMetricsInput | null;
|
||||
/** 采集失败原因等注释行(以 # 开头,Prometheus 会忽略) */
|
||||
comments: readonly string[];
|
||||
}
|
||||
|
||||
const JOB_STATES = ['waiting', 'active', 'completed', 'failed', 'delayed'] as const;
|
||||
|
||||
function round(value: number, digits: number): number {
|
||||
const factor = 10 ** digits;
|
||||
return Math.round(value * factor) / factor;
|
||||
}
|
||||
|
||||
/** 失败率 = failed / (failed + completed);无样本时为 0 */
|
||||
export function failureRatio(counts: QueueCountsSnapshot): number {
|
||||
const denominator = counts.failed + counts.completed;
|
||||
if (denominator === 0) {
|
||||
return 0;
|
||||
}
|
||||
return round(counts.failed / denominator, 4);
|
||||
}
|
||||
|
||||
function toCommentLine(comment: string): string {
|
||||
return `# ${comment.replaceAll('\n', ' ')}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* 渲染 Prometheus 文本格式(version 0.0.4)。
|
||||
* 指标口径见 A6-INF-01:队列长度 / 等待时长 / 失败率 / worker 心跳 / outbox 待投递与死信。
|
||||
*/
|
||||
export function renderPrometheusMetrics(input: MetricsRenderInput): string {
|
||||
const lines: string[] = [];
|
||||
for (const comment of input.comments) {
|
||||
lines.push(toCommentLine(comment));
|
||||
}
|
||||
|
||||
lines.push('# HELP lingxi_queue_jobs 队列内各状态的任务数');
|
||||
lines.push('# TYPE lingxi_queue_jobs gauge');
|
||||
for (const queue of input.queues) {
|
||||
for (const state of JOB_STATES) {
|
||||
lines.push(
|
||||
`lingxi_queue_jobs{queue="${queue.name}",state="${state}"} ${queue.counts[state]}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
lines.push('# HELP lingxi_queue_oldest_waiting_seconds 队首任务已等待秒数(无等待任务为 0)');
|
||||
lines.push('# TYPE lingxi_queue_oldest_waiting_seconds gauge');
|
||||
for (const queue of input.queues) {
|
||||
lines.push(
|
||||
`lingxi_queue_oldest_waiting_seconds{queue="${queue.name}"} ${queue.oldestWaitingSeconds}`,
|
||||
);
|
||||
}
|
||||
|
||||
lines.push('# HELP lingxi_queue_failure_ratio 队列失败率 failed/(failed+completed),无样本为 0');
|
||||
lines.push('# TYPE lingxi_queue_failure_ratio gauge');
|
||||
for (const queue of input.queues) {
|
||||
lines.push(`lingxi_queue_failure_ratio{queue="${queue.name}"} ${failureRatio(queue.counts)}`);
|
||||
}
|
||||
|
||||
lines.push('# HELP lingxi_worker_heartbeat_age_seconds worker 心跳年龄(秒),缺失为 -1');
|
||||
lines.push('# TYPE lingxi_worker_heartbeat_age_seconds gauge');
|
||||
for (const queue of input.queues) {
|
||||
lines.push(
|
||||
`lingxi_worker_heartbeat_age_seconds{queue="${queue.name}"} ${queue.heartbeatAgeSeconds ?? -1}`,
|
||||
);
|
||||
}
|
||||
|
||||
if (input.outbox !== null) {
|
||||
lines.push('# HELP lingxi_outbox_pending_total 未投递的 outbox 事件数');
|
||||
lines.push('# TYPE lingxi_outbox_pending_total gauge');
|
||||
lines.push(`lingxi_outbox_pending_total ${input.outbox.pending}`);
|
||||
lines.push('# HELP lingxi_outbox_dead_letter_total 已标记死信的 outbox 事件数(累计)');
|
||||
lines.push('# TYPE lingxi_outbox_dead_letter_total gauge');
|
||||
lines.push(`lingxi_outbox_dead_letter_total ${input.outbox.deadLetter}`);
|
||||
}
|
||||
|
||||
return `${lines.join('\n')}\n`;
|
||||
}
|
||||
@@ -0,0 +1,122 @@
|
||||
import Redis from 'ioredis';
|
||||
import { afterAll, beforeAll, describe, expect, it } from 'vitest';
|
||||
|
||||
import { createApp, type CreatedApiApp } from '../../bootstrap';
|
||||
import { loadDotenv } from '../../config/load-dotenv';
|
||||
import { workerHeartbeatKey } from './queue-health';
|
||||
|
||||
/**
|
||||
* 队列监控端点集成用例(真实 PG + Redis,进程内 Fastify inject)。
|
||||
* 覆盖:GET /api/v1/health/queues(9 队列快照 + worker 心跳)、GET /api/v1/metrics(Prometheus 文本)。
|
||||
*/
|
||||
const API = '/api/v1';
|
||||
|
||||
interface QueueHealthEntryBody {
|
||||
name: string;
|
||||
group: string;
|
||||
counts: { waiting: number; active: number; completed: number; failed: number; delayed: number };
|
||||
oldestWaitingSeconds: number;
|
||||
workerHeartbeat: { present: boolean; ageSeconds: number | null };
|
||||
}
|
||||
|
||||
interface QueueHealthBody {
|
||||
status: string;
|
||||
redis: string;
|
||||
queues: QueueHealthEntryBody[];
|
||||
}
|
||||
|
||||
let created: CreatedApiApp;
|
||||
let redis: Redis;
|
||||
|
||||
beforeAll(async () => {
|
||||
loadDotenv();
|
||||
// 避免后台 dispatcher 在测试期间认领开发库的 outbox 事件
|
||||
process.env.OUTBOX_DISPATCH_INTERVAL_MS = '600000';
|
||||
created = await createApp();
|
||||
await created.app.init();
|
||||
redis = new Redis(created.config.redisUrl);
|
||||
});
|
||||
|
||||
afterAll(async () => {
|
||||
await redis.del(workerHeartbeatKey('media:verify'));
|
||||
await redis.quit();
|
||||
await created.app.close();
|
||||
delete process.env.OUTBOX_DISPATCH_INTERVAL_MS;
|
||||
});
|
||||
|
||||
describe('队列健康检查端点', () => {
|
||||
it('GET /api/v1/health/queues 返回 9 个队列与心跳快照', async () => {
|
||||
const response = await created.app.inject({
|
||||
method: 'GET',
|
||||
url: `${API}/health/queues`,
|
||||
});
|
||||
expect(response.statusCode).toBe(200);
|
||||
|
||||
const body = response.json<QueueHealthBody>();
|
||||
expect(body.redis).toBe('ok');
|
||||
expect(['ok', 'degraded']).toContain(body.status);
|
||||
expect(body.queues).toHaveLength(9);
|
||||
|
||||
const verify = body.queues.find((queue) => queue.name === 'media:verify');
|
||||
expect(verify).toBeDefined();
|
||||
expect(verify?.group).toBe('verify');
|
||||
for (const state of ['waiting', 'active', 'completed', 'failed', 'delayed']) {
|
||||
expect(typeof verify?.counts[state as keyof QueueHealthEntryBody['counts']]).toBe('number');
|
||||
}
|
||||
expect(typeof verify?.oldestWaitingSeconds).toBe('number');
|
||||
expect(verify?.workerHeartbeat.present).toBe(false);
|
||||
});
|
||||
|
||||
it('worker 写入心跳后健康检查可见 present + ageSeconds', async () => {
|
||||
await redis.set(
|
||||
workerHeartbeatKey('media:verify'),
|
||||
JSON.stringify({
|
||||
workerId: 'it-worker',
|
||||
pid: process.pid,
|
||||
queue: 'media:verify',
|
||||
updatedAt: new Date().toISOString(),
|
||||
}),
|
||||
'EX',
|
||||
30,
|
||||
);
|
||||
|
||||
const response = await created.app.inject({
|
||||
method: 'GET',
|
||||
url: `${API}/health/queues`,
|
||||
});
|
||||
const body = response.json<QueueHealthBody>();
|
||||
const verify = body.queues.find((queue) => queue.name === 'media:verify');
|
||||
expect(verify?.workerHeartbeat.present).toBe(true);
|
||||
expect(verify?.workerHeartbeat.ageSeconds).toBeGreaterThanOrEqual(0);
|
||||
expect(verify?.workerHeartbeat.ageSeconds).toBeLessThan(30);
|
||||
});
|
||||
});
|
||||
|
||||
describe('Prometheus 指标端点', () => {
|
||||
it('GET /api/v1/metrics 返回 text/plain 0.0.4 与全部队列指标', async () => {
|
||||
const response = await created.app.inject({ method: 'GET', url: `${API}/metrics` });
|
||||
|
||||
expect(response.statusCode).toBe(200);
|
||||
expect(response.headers['content-type']).toContain('text/plain');
|
||||
expect(response.headers['content-type']).toContain('version=0.0.4');
|
||||
|
||||
const text = response.body;
|
||||
expect(text).toContain('# TYPE lingxi_queue_jobs gauge');
|
||||
expect(text).toContain('lingxi_queue_jobs{queue="media:verify",state="waiting"}');
|
||||
expect(text).toContain('lingxi_queue_oldest_waiting_seconds{queue="media:verify"}');
|
||||
expect(text).toContain('lingxi_queue_failure_ratio{queue="media:verify"}');
|
||||
expect(text).toContain('lingxi_worker_heartbeat_age_seconds{queue="media:verify"}');
|
||||
// 9 个队列 × 5 个 state + 每队列 3 个单值指标
|
||||
expect(text.match(/lingxi_queue_jobs\{/g)).toHaveLength(9 * 5);
|
||||
expect(text).toContain('lingxi_outbox_pending_total');
|
||||
expect(text).toContain('lingxi_outbox_dead_letter_total');
|
||||
});
|
||||
|
||||
it('GET /api/v1/metrics 心跳缺失队列输出 -1', async () => {
|
||||
await redis.del(workerHeartbeatKey('script:generate'));
|
||||
const response = await created.app.inject({ method: 'GET', url: `${API}/metrics` });
|
||||
expect(response.body).toContain(
|
||||
'lingxi_worker_heartbeat_age_seconds{queue="script:generate"} -1',
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,102 @@
|
||||
import { ErrorCode, TASK_QUEUES } from '@lingxi/shared';
|
||||
import { Controller, Get, Inject, Logger } from '@nestjs/common';
|
||||
import { ApiOkResponse, ApiOperation, ApiResponse, ApiTags } from '@nestjs/swagger';
|
||||
|
||||
import { AppException } from '../../common/errors/app-exception';
|
||||
import { Public } from '../../common/security/decorators';
|
||||
import type { RedisService } from '../redis/redis.service';
|
||||
import { REDIS } from '../tokens';
|
||||
import type { TaskQueueDefinition } from './outbox-payload';
|
||||
import { errorMessage, toSingleLine } from './queue.utils';
|
||||
import {
|
||||
buildQueueHealthEntry,
|
||||
buildQueueHealthReport,
|
||||
type QueueHealthEntry,
|
||||
type QueueHealthReport,
|
||||
workerHeartbeatKey,
|
||||
} from './queue-health';
|
||||
import { QueueHealthReportDto } from './queue-health.types';
|
||||
import { QueueRegistryService } from './queue-registry.service';
|
||||
|
||||
/**
|
||||
* 队列健康检查(A6-INF-01)。
|
||||
* - Redis 不可用 → 503 + 统一错误码 50002;
|
||||
* - 有积压却没有 worker 心跳(或心跳陈旧)→ status=degraded,HTTP 仍 200;
|
||||
* - 心跳 key:`lingxi:worker:heartbeat:<queueName>`,由 apps/worker 每 10s 续写、TTL 30s。
|
||||
*/
|
||||
@ApiTags('platform')
|
||||
@Public()
|
||||
@Controller('health')
|
||||
export class QueueHealthController {
|
||||
private readonly logger = new Logger(QueueHealthController.name);
|
||||
|
||||
constructor(
|
||||
@Inject(QueueRegistryService) private readonly registry: QueueRegistryService,
|
||||
@Inject(REDIS) private readonly redis: RedisService,
|
||||
) {}
|
||||
|
||||
@Get('queues')
|
||||
@ApiOperation({
|
||||
summary: '队列健康检查',
|
||||
description:
|
||||
'返回 9 个队列的计数、队首等待时长与 worker 心跳;Redis 不可用时返回 503(错误码 50002)。',
|
||||
})
|
||||
@ApiOkResponse({ type: QueueHealthReportDto, description: '队列状态快照(可能为 degraded)' })
|
||||
@ApiResponse({ status: 503, description: 'Redis 不可用(错误码 50002)' })
|
||||
async queues(): Promise<QueueHealthReport> {
|
||||
await this.assertRedisAvailable();
|
||||
const nowMs = Date.now();
|
||||
const entries: QueueHealthEntry[] = [];
|
||||
for (const definition of TASK_QUEUES) {
|
||||
entries.push(await this.collectEntry(definition, nowMs));
|
||||
}
|
||||
const report = buildQueueHealthReport(entries);
|
||||
if (report.status === 'degraded') {
|
||||
this.logger.warn('队列健康检查降级:存在积压且 worker 心跳缺失或陈旧');
|
||||
}
|
||||
return report;
|
||||
}
|
||||
|
||||
private async assertRedisAvailable(): Promise<void> {
|
||||
try {
|
||||
await this.redis.ping();
|
||||
} catch (error) {
|
||||
this.logger.error(`队列健康检查失败:Redis 不可用(${toSingleLine(errorMessage(error))})`);
|
||||
throw new AppException({
|
||||
code: ErrorCode.DEPENDENCY_UNAVAILABLE,
|
||||
message: 'Redis 不可用',
|
||||
details: { redis: toSingleLine(errorMessage(error)) },
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
private async collectEntry(
|
||||
definition: TaskQueueDefinition,
|
||||
nowMs: number,
|
||||
): Promise<QueueHealthEntry> {
|
||||
try {
|
||||
const [counts, oldestWaitingTimestampMs, heartbeatRaw] = await Promise.all([
|
||||
this.registry.getCounts(definition.name),
|
||||
this.registry.getOldestWaitingTimestampMs(definition.name),
|
||||
this.redis.getClient().get(workerHeartbeatKey(definition.name)),
|
||||
]);
|
||||
return buildQueueHealthEntry({
|
||||
name: definition.name,
|
||||
group: definition.group,
|
||||
counts,
|
||||
oldestWaitingTimestampMs,
|
||||
heartbeatRaw,
|
||||
nowMs,
|
||||
});
|
||||
} catch (error) {
|
||||
this.logger.error(
|
||||
`队列 ${definition.name} 状态查询失败:${toSingleLine(errorMessage(error))}`,
|
||||
);
|
||||
throw new AppException({
|
||||
code: ErrorCode.DEPENDENCY_UNAVAILABLE,
|
||||
message: '队列状态查询失败',
|
||||
details: { queue: definition.name, error: toSingleLine(errorMessage(error)) },
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,86 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
|
||||
import {
|
||||
buildQueueHealthEntry,
|
||||
buildQueueHealthReport,
|
||||
parseHeartbeatAgeSeconds,
|
||||
workerHeartbeatKey,
|
||||
} from './queue-health';
|
||||
|
||||
describe('队列健康检查(纯函数)', () => {
|
||||
it('心跳 key 统一为 lingxi:worker:heartbeat:<queueName>', () => {
|
||||
expect(workerHeartbeatKey('media:verify')).toBe('lingxi:worker:heartbeat:media:verify');
|
||||
});
|
||||
|
||||
it('心跳年龄解析:缺失 / 非法为 null,未来时间归零', () => {
|
||||
const now = Date.parse('2026-11-04T12:00:00.000Z');
|
||||
expect(parseHeartbeatAgeSeconds(null, now)).toBeNull();
|
||||
expect(parseHeartbeatAgeSeconds('not-json', now)).toBeNull();
|
||||
expect(parseHeartbeatAgeSeconds('{"updatedAt":123}', now)).toBeNull();
|
||||
expect(parseHeartbeatAgeSeconds('{"updatedAt":"2026-11-04T11:59:50.000Z"}', now)).toBe(10);
|
||||
expect(parseHeartbeatAgeSeconds('{"updatedAt":"2026-11-04T12:00:10.000Z"}', now)).toBe(0);
|
||||
});
|
||||
|
||||
it('队首等待时长换算为秒,无等待任务为 0', () => {
|
||||
const nowMs = Date.parse('2026-11-04T12:00:00.000Z');
|
||||
const counts = { waiting: 1, active: 0, completed: 0, failed: 0, delayed: 0 };
|
||||
const entry = buildQueueHealthEntry({
|
||||
name: 'media:verify',
|
||||
group: 'verify',
|
||||
counts,
|
||||
oldestWaitingTimestampMs: nowMs - 12_400,
|
||||
heartbeatRaw: '{"updatedAt":"2026-11-04T11:59:57.000Z"}',
|
||||
nowMs,
|
||||
});
|
||||
expect(entry.oldestWaitingSeconds).toBe(12);
|
||||
expect(entry.workerHeartbeat).toEqual({ present: true, ageSeconds: 3 });
|
||||
expect(
|
||||
buildQueueHealthEntry({
|
||||
name: 'media:verify',
|
||||
group: 'verify',
|
||||
counts,
|
||||
oldestWaitingTimestampMs: null,
|
||||
heartbeatRaw: null,
|
||||
nowMs,
|
||||
}).oldestWaitingSeconds,
|
||||
).toBe(0);
|
||||
});
|
||||
|
||||
it('有积压且无心跳 → degraded;无积压无心跳 → ok', () => {
|
||||
const empty = buildQueueHealthReport([]);
|
||||
expect(empty).toEqual({ status: 'ok', redis: 'ok', queues: [] });
|
||||
|
||||
const backlogNoHeartbeat = buildQueueHealthReport([
|
||||
{
|
||||
name: 'media:verify',
|
||||
group: 'verify',
|
||||
counts: { waiting: 3, active: 0, completed: 0, failed: 0, delayed: 0 },
|
||||
oldestWaitingSeconds: 600,
|
||||
workerHeartbeat: { present: false, ageSeconds: null },
|
||||
},
|
||||
]);
|
||||
expect(backlogNoHeartbeat.status).toBe('degraded');
|
||||
|
||||
const backlogFreshHeartbeat = buildQueueHealthReport([
|
||||
{
|
||||
name: 'media:verify',
|
||||
group: 'verify',
|
||||
counts: { waiting: 3, active: 0, completed: 0, failed: 0, delayed: 0 },
|
||||
oldestWaitingSeconds: 600,
|
||||
workerHeartbeat: { present: true, ageSeconds: 5 },
|
||||
},
|
||||
]);
|
||||
expect(backlogFreshHeartbeat.status).toBe('ok');
|
||||
|
||||
const noBacklog = buildQueueHealthReport([
|
||||
{
|
||||
name: 'media:verify',
|
||||
group: 'verify',
|
||||
counts: { waiting: 0, active: 0, completed: 0, failed: 0, delayed: 0 },
|
||||
oldestWaitingSeconds: 0,
|
||||
workerHeartbeat: { present: false, ageSeconds: null },
|
||||
},
|
||||
]);
|
||||
expect(noBacklog.status).toBe('ok');
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,119 @@
|
||||
import type { TaskQueueGroup, TaskQueueName } from '@lingxi/shared';
|
||||
|
||||
import {
|
||||
WORKER_HEARTBEAT_KEY_PREFIX,
|
||||
WORKER_HEARTBEAT_STALE_MULTIPLIER,
|
||||
WORKER_HEARTBEAT_TTL_SECONDS,
|
||||
} from './queue.constants';
|
||||
|
||||
/** 队列各状态计数(与 BullMQ getJobCounts 对齐) */
|
||||
export interface QueueCountsSnapshot {
|
||||
waiting: number;
|
||||
active: number;
|
||||
completed: number;
|
||||
failed: number;
|
||||
delayed: number;
|
||||
}
|
||||
|
||||
/** worker 心跳快照:present 表示 key 是否存在;ageSeconds 为 null 表示缺失或值非法 */
|
||||
export interface WorkerHeartbeatState {
|
||||
present: boolean;
|
||||
ageSeconds: number | null;
|
||||
}
|
||||
|
||||
export interface QueueHealthEntry {
|
||||
name: TaskQueueName;
|
||||
group: TaskQueueGroup;
|
||||
counts: QueueCountsSnapshot;
|
||||
oldestWaitingSeconds: number;
|
||||
workerHeartbeat: WorkerHeartbeatState;
|
||||
}
|
||||
|
||||
export interface QueueHealthReport {
|
||||
status: 'ok' | 'degraded';
|
||||
redis: 'ok';
|
||||
queues: QueueHealthEntry[];
|
||||
}
|
||||
|
||||
export interface QueueHealthSnapshotInput {
|
||||
name: TaskQueueName;
|
||||
group: TaskQueueGroup;
|
||||
counts: QueueCountsSnapshot;
|
||||
oldestWaitingTimestampMs: number | null;
|
||||
heartbeatRaw: string | null;
|
||||
nowMs: number;
|
||||
}
|
||||
|
||||
/** 心跳 key:`lingxi:worker:heartbeat:<queueName>`(与 apps/worker 保持一致) */
|
||||
export function workerHeartbeatKey(queueName: TaskQueueName): string {
|
||||
return `${WORKER_HEARTBEAT_KEY_PREFIX}${queueName}`;
|
||||
}
|
||||
|
||||
export function isHeartbeatStale(ageSeconds: number | null): boolean {
|
||||
return (
|
||||
ageSeconds !== null &&
|
||||
ageSeconds > WORKER_HEARTBEAT_TTL_SECONDS * WORKER_HEARTBEAT_STALE_MULTIPLIER
|
||||
);
|
||||
}
|
||||
|
||||
function isRecord(value: unknown): value is Record<string, unknown> {
|
||||
return typeof value === 'object' && value !== null && !Array.isArray(value);
|
||||
}
|
||||
|
||||
/** 解析心跳 JSON 中的 updatedAt,得到秒级年龄;缺失 / 非法返回 null */
|
||||
export function parseHeartbeatAgeSeconds(raw: string | null, nowMs: number): number | null {
|
||||
if (raw === null) {
|
||||
return null;
|
||||
}
|
||||
try {
|
||||
const parsed: unknown = JSON.parse(raw);
|
||||
if (!isRecord(parsed)) {
|
||||
return null;
|
||||
}
|
||||
const updatedAt = parsed.updatedAt;
|
||||
if (typeof updatedAt !== 'string') {
|
||||
return null;
|
||||
}
|
||||
const updatedAtMs = Date.parse(updatedAt);
|
||||
if (Number.isNaN(updatedAtMs)) {
|
||||
return null;
|
||||
}
|
||||
return Math.max(0, Math.round((nowMs - updatedAtMs) / 1000));
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
export function buildQueueHealthEntry(input: QueueHealthSnapshotInput): QueueHealthEntry {
|
||||
const ageSeconds = parseHeartbeatAgeSeconds(input.heartbeatRaw, input.nowMs);
|
||||
const oldestWaitingSeconds =
|
||||
input.oldestWaitingTimestampMs === null
|
||||
? 0
|
||||
: Math.max(0, Math.round((input.nowMs - input.oldestWaitingTimestampMs) / 1000));
|
||||
return {
|
||||
name: input.name,
|
||||
group: input.group,
|
||||
counts: input.counts,
|
||||
oldestWaitingSeconds,
|
||||
workerHeartbeat: { present: input.heartbeatRaw !== null, ageSeconds },
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* 汇总健康报告:有积压(waiting + delayed > 0)却没有 worker 心跳时降级;
|
||||
* 心跳存在但陈旧(超过 TTL 倍数)同样降级,提示 worker 可能已崩溃。
|
||||
*/
|
||||
export function buildQueueHealthReport(entries: readonly QueueHealthEntry[]): QueueHealthReport {
|
||||
const degraded = entries.some((entry) => {
|
||||
const backlog = entry.counts.waiting + entry.counts.delayed;
|
||||
if (!entry.workerHeartbeat.present) {
|
||||
return backlog > 0;
|
||||
}
|
||||
return backlog > 0 && isHeartbeatStale(entry.workerHeartbeat.ageSeconds);
|
||||
});
|
||||
return {
|
||||
status: degraded ? 'degraded' : 'ok',
|
||||
redis: 'ok',
|
||||
queues: [...entries],
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,59 @@
|
||||
import { ApiProperty } from '@nestjs/swagger';
|
||||
|
||||
export class QueueCountsDto {
|
||||
@ApiProperty({ type: Number, description: '等待中任务数', example: 0 })
|
||||
waiting!: number;
|
||||
|
||||
@ApiProperty({ type: Number, description: '执行中任务数', example: 0 })
|
||||
active!: number;
|
||||
|
||||
@ApiProperty({ type: Number, description: '已完成任务数(保留窗口内的历史计数)', example: 12 })
|
||||
completed!: number;
|
||||
|
||||
@ApiProperty({ type: Number, description: '失败任务数(保留窗口内的历史计数)', example: 1 })
|
||||
failed!: number;
|
||||
|
||||
@ApiProperty({ type: Number, description: '延迟任务数', example: 0 })
|
||||
delayed!: number;
|
||||
}
|
||||
|
||||
export class WorkerHeartbeatDto {
|
||||
@ApiProperty({ type: Boolean, description: '最近 30s 内是否有 worker 心跳' })
|
||||
present!: boolean;
|
||||
|
||||
@ApiProperty({
|
||||
type: Number,
|
||||
nullable: true,
|
||||
description: '心跳年龄(秒);心跳缺失或值非法时为 null',
|
||||
example: 3,
|
||||
})
|
||||
ageSeconds!: number | null;
|
||||
}
|
||||
|
||||
export class QueueHealthEntryDto {
|
||||
@ApiProperty({ type: String, description: '队列名', example: 'media:verify' })
|
||||
name!: string;
|
||||
|
||||
@ApiProperty({ type: String, description: '队列组', example: 'verify' })
|
||||
group!: string;
|
||||
|
||||
@ApiProperty({ type: QueueCountsDto })
|
||||
counts!: QueueCountsDto;
|
||||
|
||||
@ApiProperty({ type: Number, description: '队首任务等待秒数', example: 0 })
|
||||
oldestWaitingSeconds!: number;
|
||||
|
||||
@ApiProperty({ type: WorkerHeartbeatDto })
|
||||
workerHeartbeat!: WorkerHeartbeatDto;
|
||||
}
|
||||
|
||||
export class QueueHealthReportDto {
|
||||
@ApiProperty({ type: String, enum: ['ok', 'degraded'], example: 'ok' })
|
||||
status!: 'ok' | 'degraded';
|
||||
|
||||
@ApiProperty({ type: String, enum: ['ok'], example: 'ok' })
|
||||
redis!: 'ok';
|
||||
|
||||
@ApiProperty({ type: QueueHealthEntryDto, isArray: true })
|
||||
queues!: QueueHealthEntryDto[];
|
||||
}
|
||||
@@ -0,0 +1,104 @@
|
||||
import { TASK_QUEUES, type TaskQueueName } from '@lingxi/shared';
|
||||
import { Controller, Get, Header, Inject, Logger } from '@nestjs/common';
|
||||
import { ApiExcludeController } from '@nestjs/swagger';
|
||||
import { Prisma } from '@prisma/client';
|
||||
|
||||
import { Public } from '../../common/security/decorators';
|
||||
import type { PrismaService } from '../prisma/prisma.service';
|
||||
import type { RedisService } from '../redis/redis.service';
|
||||
import { PRISMA, REDIS } from '../tokens';
|
||||
import {
|
||||
type OutboxMetricsInput,
|
||||
type QueueMetricsInput,
|
||||
renderPrometheusMetrics,
|
||||
} from './prometheus';
|
||||
import { OUTBOX_DEAD_LETTER_MARKER } from './queue.constants';
|
||||
import { errorMessage, toSingleLine } from './queue.utils';
|
||||
import { parseHeartbeatAgeSeconds, workerHeartbeatKey } from './queue-health';
|
||||
import { QueueRegistryService } from './queue-registry.service';
|
||||
|
||||
interface CountRow {
|
||||
count: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* Prometheus 指标端点(A6-INF-01):`GET /api/v1/metrics`(text/plain; version=0.0.4)。
|
||||
*
|
||||
* 只读:队列计数走 BullMQ(Redis),outbox 待投递 / 死信计数走 outbox_events 只读查询。
|
||||
* 任何一个数据源查询失败都**不返回 500**:写入注释行 + 记日志,保证抓取不中断。
|
||||
*/
|
||||
@ApiExcludeController()
|
||||
@Public()
|
||||
@Controller()
|
||||
export class QueueMetricsController {
|
||||
private readonly logger = new Logger(QueueMetricsController.name);
|
||||
|
||||
constructor(
|
||||
@Inject(QueueRegistryService) private readonly registry: QueueRegistryService,
|
||||
@Inject(REDIS) private readonly redis: RedisService,
|
||||
@Inject(PRISMA) private readonly prisma: PrismaService,
|
||||
) {}
|
||||
|
||||
@Get('metrics')
|
||||
@Header('Content-Type', 'text/plain; version=0.0.4; charset=utf-8')
|
||||
async metrics(): Promise<string> {
|
||||
const nowMs = Date.now();
|
||||
const comments: string[] = [];
|
||||
const queues = await this.collectQueues(nowMs, comments);
|
||||
const outbox = await this.collectOutbox(comments);
|
||||
return renderPrometheusMetrics({ queues, outbox, comments });
|
||||
}
|
||||
|
||||
private async collectQueues(nowMs: number, comments: string[]): Promise<QueueMetricsInput[]> {
|
||||
const result: QueueMetricsInput[] = [];
|
||||
for (const definition of TASK_QUEUES) {
|
||||
try {
|
||||
result.push(await this.collectQueue(definition.name, nowMs));
|
||||
} catch (error) {
|
||||
const message = toSingleLine(errorMessage(error));
|
||||
comments.push(`队列 ${definition.name} 指标查询失败:${message}`);
|
||||
this.logger.warn(`队列 ${definition.name} 指标查询失败:${message}`);
|
||||
}
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
private async collectQueue(name: TaskQueueName, nowMs: number): Promise<QueueMetricsInput> {
|
||||
const [counts, oldestWaitingTimestampMs, heartbeatRaw] = await Promise.all([
|
||||
this.registry.getCounts(name),
|
||||
this.registry.getOldestWaitingTimestampMs(name),
|
||||
this.redis.getClient().get(workerHeartbeatKey(name)),
|
||||
]);
|
||||
return {
|
||||
name,
|
||||
counts,
|
||||
oldestWaitingSeconds:
|
||||
oldestWaitingTimestampMs === null
|
||||
? 0
|
||||
: Math.max(0, Math.round((nowMs - oldestWaitingTimestampMs) / 1000)),
|
||||
heartbeatAgeSeconds: parseHeartbeatAgeSeconds(heartbeatRaw, nowMs),
|
||||
};
|
||||
}
|
||||
|
||||
private async collectOutbox(comments: string[]): Promise<OutboxMetricsInput | null> {
|
||||
try {
|
||||
const [pendingRows, deadLetterRows] = await Promise.all([
|
||||
this.prisma.$queryRaw<CountRow[]>(
|
||||
Prisma.sql`SELECT count(*)::int AS count FROM outbox_events WHERE published_at IS NULL`,
|
||||
),
|
||||
this.prisma.$queryRaw<CountRow[]>(
|
||||
Prisma.sql`SELECT count(*)::int AS count FROM outbox_events WHERE last_error = ${OUTBOX_DEAD_LETTER_MARKER}`,
|
||||
),
|
||||
]);
|
||||
return {
|
||||
pending: pendingRows[0]?.count ?? 0,
|
||||
deadLetter: deadLetterRows[0]?.count ?? 0,
|
||||
};
|
||||
} catch (error) {
|
||||
const message = toSingleLine(errorMessage(error));
|
||||
comments.push(`Outbox 指标查询失败:${message}`);
|
||||
this.logger.warn(`Outbox 指标查询失败:${message}`);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,146 @@
|
||||
import { TASK_QUEUES, type TaskQueueName } from '@lingxi/shared';
|
||||
import { Inject, Injectable, Logger, type OnModuleDestroy } from '@nestjs/common';
|
||||
import { type Job, type JobsOptions, Queue } from 'bullmq';
|
||||
import Redis from 'ioredis';
|
||||
|
||||
import { APP_CONFIG } from '../../config/config.module';
|
||||
import type { AppConfig } from '../../config/env';
|
||||
import type { TaskJobData } from './outbox-payload';
|
||||
import { QUEUE_OPERATION_TIMEOUT_MS } from './queue.constants';
|
||||
import type { QueueCountsSnapshot } from './queue-health';
|
||||
import { withTimeout } from './with-timeout';
|
||||
|
||||
/**
|
||||
* 逻辑队列名 → BullMQ 物理队列名。
|
||||
* BullMQ 5.x 明确禁止队列名含冒号(Queue constructor 会抛 `Queue name cannot contain :`),
|
||||
* 而协议队列名是 `<域>:<动作>`(media:verify 等),因此物理名统一把 `:` 换成 `-`;
|
||||
* 健康检查 / 指标 / 心跳 / 告警一律使用**逻辑名**,只有 BullMQ 内部 key 使用物理名。
|
||||
*/
|
||||
export function toBullQueueName(name: TaskQueueName): string {
|
||||
return name.replaceAll(':', '-');
|
||||
}
|
||||
|
||||
/**
|
||||
* BullMQ 队列注册表(A6-BE-02)。
|
||||
*
|
||||
* - 队列实例**懒创建**:模块初始化阶段不连接 Redis,避免 OpenAPI 导出 / 单测在无 Redis 时失败;
|
||||
* - 9 个队列共享一条 ioredis 连接(BullMQ 要求 maxRetriesPerRequest: null,不复用 RedisService 的请求连接);
|
||||
* - 并发 / 重试 / 退避上限全部来自 shared 的 TASK_QUEUES,不在本文件硬编码;
|
||||
* - OnModuleDestroy 统一 close,防止 dev watch / 测试进程残留连接。
|
||||
*/
|
||||
@Injectable()
|
||||
export class QueueRegistryService implements OnModuleDestroy {
|
||||
private readonly logger = new Logger(QueueRegistryService.name);
|
||||
private readonly queues = new Map<TaskQueueName, Queue<TaskJobData, unknown>>();
|
||||
private connection: Redis | null = null;
|
||||
|
||||
constructor(@Inject(APP_CONFIG) private readonly config: AppConfig) {}
|
||||
|
||||
/** 已登记的全部队列名(顺序与 TASK_QUEUES 一致) */
|
||||
queueNames(): readonly TaskQueueName[] {
|
||||
return TASK_QUEUES.map((definition) => definition.name);
|
||||
}
|
||||
|
||||
getQueue(name: TaskQueueName): Queue<TaskJobData, unknown> {
|
||||
const existing = this.queues.get(name);
|
||||
if (existing !== undefined) {
|
||||
return existing;
|
||||
}
|
||||
const queue = new Queue<TaskJobData, unknown>(toBullQueueName(name), {
|
||||
connection: this.getConnection(),
|
||||
});
|
||||
// 队列连接异常只记录,不能让 EventEmitter 的 'error' 事件把进程打挂
|
||||
queue.on('error', (error: Error) => {
|
||||
this.logger.warn(`队列 ${name} 连接异常:${error.message}`);
|
||||
});
|
||||
this.queues.set(name, queue);
|
||||
return queue;
|
||||
}
|
||||
|
||||
/** 投递一条 job;Redis 不可用时由超时兜底,避免调用方永久挂起 */
|
||||
async enqueue(
|
||||
name: TaskQueueName,
|
||||
jobName: string,
|
||||
data: TaskJobData,
|
||||
options: JobsOptions,
|
||||
): Promise<Job<TaskJobData, unknown>> {
|
||||
const queue = this.getQueue(name);
|
||||
return withTimeout(
|
||||
queue.add(jobName, data, options),
|
||||
QUEUE_OPERATION_TIMEOUT_MS,
|
||||
`队列入队失败:${name}`,
|
||||
);
|
||||
}
|
||||
|
||||
/** 各状态计数;失败抛错由调用方(health / metrics)兜底 */
|
||||
async getCounts(name: TaskQueueName): Promise<QueueCountsSnapshot> {
|
||||
const counts = await withTimeout(
|
||||
this.getQueue(name).getJobCounts('waiting', 'active', 'completed', 'failed', 'delayed'),
|
||||
QUEUE_OPERATION_TIMEOUT_MS,
|
||||
`队列状态查询失败:${name}`,
|
||||
);
|
||||
return {
|
||||
waiting: countOf(counts, 'waiting'),
|
||||
active: countOf(counts, 'active'),
|
||||
completed: countOf(counts, 'completed'),
|
||||
failed: countOf(counts, 'failed'),
|
||||
delayed: countOf(counts, 'delayed'),
|
||||
};
|
||||
}
|
||||
|
||||
/** 队首(最早入队且仍 waiting)job 的时间戳,用于等待时长指标 */
|
||||
async getOldestWaitingTimestampMs(name: TaskQueueName): Promise<number | null> {
|
||||
const jobs = await withTimeout(
|
||||
this.getQueue(name).getJobs(['waiting'], 0, 0),
|
||||
QUEUE_OPERATION_TIMEOUT_MS,
|
||||
`队列等待时长查询失败:${name}`,
|
||||
);
|
||||
const oldest = jobs[0];
|
||||
return oldest === undefined ? null : oldest.timestamp;
|
||||
}
|
||||
|
||||
async onModuleDestroy(): Promise<void> {
|
||||
for (const [name, queue] of this.queues) {
|
||||
try {
|
||||
await queue.close();
|
||||
} catch (error) {
|
||||
this.logger.warn(
|
||||
`关闭队列 ${name} 失败:${error instanceof Error ? error.message : String(error)}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
this.queues.clear();
|
||||
|
||||
const connection = this.connection;
|
||||
this.connection = null;
|
||||
if (connection !== null) {
|
||||
try {
|
||||
if (connection.status === 'wait' || connection.status === 'end') {
|
||||
connection.disconnect();
|
||||
} else {
|
||||
await connection.quit();
|
||||
}
|
||||
} catch {
|
||||
connection.disconnect();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private getConnection(): Redis {
|
||||
if (this.connection === null) {
|
||||
this.connection = new Redis(this.config.redisUrl, {
|
||||
lazyConnect: true,
|
||||
// BullMQ 阻塞命令要求 null;ioredis 连接错误用事件记录,避免未处理异常
|
||||
maxRetriesPerRequest: null,
|
||||
});
|
||||
this.connection.on('error', (error: Error) => {
|
||||
this.logger.warn(`队列 Redis 连接异常:${error.message}`);
|
||||
});
|
||||
}
|
||||
return this.connection;
|
||||
}
|
||||
}
|
||||
|
||||
function countOf(counts: Record<string, number>, key: string): number {
|
||||
return counts[key] ?? 0;
|
||||
}
|
||||
@@ -0,0 +1,40 @@
|
||||
/**
|
||||
* 队列 / Outbox 投递协议常量(技术架构 §2.4 / §3.7,A6-BE-02 / A6-BE-05 / A6-INF-01)。
|
||||
*
|
||||
* key 统一 `lingxi:` 前缀;心跳 key 与 worker 侧(apps/worker/src/heartbeat.ts)保持一致,
|
||||
* 跨 app 契约变更时必须两端同步。
|
||||
*/
|
||||
|
||||
/** Outbox 投递分布式锁:同一时刻只允许一个 dispatcher 抢到本轮投递权 */
|
||||
export const OUTBOX_DISPATCH_LOCK_KEY = 'lingxi:lock:outbox-dispatch';
|
||||
|
||||
/** 锁 TTL:既覆盖单轮投递耗时,又保证实例崩溃后 5s 内可被其他实例接管 */
|
||||
export const OUTBOX_DISPATCH_LOCK_TTL_MS = 5_000;
|
||||
|
||||
/** 单轮最多认领的事件数(避免一轮占满连接 / 内存) */
|
||||
export const OUTBOX_DISPATCH_BATCH_SIZE = 20;
|
||||
|
||||
/** 死信阈值:投递尝试达到该次数后标记 dead_lettered,避免单条事件永久阻塞 outbox 表 */
|
||||
export const OUTBOX_MAX_ATTEMPTS = 10;
|
||||
|
||||
/** 默认调度间隔(可被环境变量 OUTBOX_DISPATCH_INTERVAL_MS 覆盖) */
|
||||
export const OUTBOX_DISPATCH_DEFAULT_INTERVAL_MS = 1_000;
|
||||
|
||||
/** 死信标记:写入 outbox_events.last_error,便于监控与人工排查 */
|
||||
export const OUTBOX_DEAD_LETTER_MARKER = 'dead_lettered';
|
||||
|
||||
/** worker 心跳 key 前缀:`lingxi:worker:heartbeat:<queueName>` */
|
||||
export const WORKER_HEARTBEAT_KEY_PREFIX = 'lingxi:worker:heartbeat:';
|
||||
|
||||
/** worker 心跳 TTL(秒);worker 每 10s 续写一次,超过 TTL 视为心跳缺失 */
|
||||
export const WORKER_HEARTBEAT_TTL_SECONDS = 30;
|
||||
|
||||
/** 心跳年龄超过 TTL 的倍数视为陈旧(用于健康检查降级判定) */
|
||||
export const WORKER_HEARTBEAT_STALE_MULTIPLIER = 2;
|
||||
|
||||
/** 单轮投递 / 单次队列命令的超时,避免 Redis 异常时无限挂起 */
|
||||
export const QUEUE_OPERATION_TIMEOUT_MS = 5_000;
|
||||
|
||||
/** Lua:仅当锁 value 与 token 相等时释放,避免误删其他实例持有的锁 */
|
||||
export const RELEASE_LOCK_SCRIPT =
|
||||
"if redis.call('get', KEYS[1]) == ARGV[1] then return redis.call('del', KEYS[1]) else return 0 end";
|
||||
@@ -0,0 +1,23 @@
|
||||
import { Module } from '@nestjs/common';
|
||||
|
||||
import { OutboxDispatcherService } from './outbox-dispatcher.service';
|
||||
import { QueueHealthController } from './queue-health.controller';
|
||||
import { QueueMetricsController } from './queue-metrics.controller';
|
||||
import { QueueRegistryService } from './queue-registry.service';
|
||||
|
||||
/**
|
||||
* 队列骨架模块(A6-BE-02 / A6-BE-05 / A6-INF-01)。
|
||||
*
|
||||
* 由 AppModule 统一注册(Lead 接线,本模块不反向 import 业务模块):
|
||||
* - QueueRegistryService:9 个 BullMQ 队列的注册、入队与生命周期;
|
||||
* - OutboxDispatcherService:定时认领 outbox_events 并投递队列(Redis 锁 + SKIP LOCKED);
|
||||
* - GET /health/queues、GET /metrics:队列健康检查与 Prometheus 指标。
|
||||
*
|
||||
* 依赖(APP_CONFIG / PRISMA / REDIS)来自全局模块,无需在此重复 imports。
|
||||
*/
|
||||
@Module({
|
||||
controllers: [QueueHealthController, QueueMetricsController],
|
||||
providers: [QueueRegistryService, OutboxDispatcherService],
|
||||
exports: [QueueRegistryService, OutboxDispatcherService],
|
||||
})
|
||||
export class QueueModule {}
|
||||
@@ -0,0 +1,34 @@
|
||||
/** 队列基础设施的通用小工具(纯函数,便于单测) */
|
||||
|
||||
export function errorMessage(error: unknown): string {
|
||||
return error instanceof Error ? error.message : String(error);
|
||||
}
|
||||
|
||||
/** 日志 / 死信原因字段截断,避免超长错误串撑爆 DB / 日志 */
|
||||
export function truncate(value: string, maxLength: number): string {
|
||||
return value.length <= maxLength ? value : `${value.slice(0, maxLength - 1)}…`;
|
||||
}
|
||||
|
||||
/** 把错误信息压缩为单行,Prometheus 注释 / 日志输出前调用 */
|
||||
export function toSingleLine(value: string): string {
|
||||
return value.replaceAll(/\s+/g, ' ').trim();
|
||||
}
|
||||
|
||||
/** 读取布尔开关类环境变量,未设置时返回默认值 */
|
||||
export function readBooleanEnv(name: string, defaultValue: boolean): boolean {
|
||||
const raw = process.env[name];
|
||||
if (raw === undefined || raw === '') {
|
||||
return defaultValue;
|
||||
}
|
||||
return raw === 'true' || raw === '1';
|
||||
}
|
||||
|
||||
/** 读取正整数环境变量,非法 / 缺失时回退默认值(用于调度间隔等运行参数) */
|
||||
export function readPositiveIntEnv(name: string, defaultValue: number, min = 1): number {
|
||||
const raw = process.env[name];
|
||||
if (raw === undefined || raw === '') {
|
||||
return defaultValue;
|
||||
}
|
||||
const parsed = Number(raw);
|
||||
return Number.isInteger(parsed) && parsed >= min ? parsed : defaultValue;
|
||||
}
|
||||
@@ -0,0 +1,27 @@
|
||||
/** 给队列 / Redis 命令加超时,避免依赖不可用时请求或调度循环无限挂起 */
|
||||
export class QueueTimeoutError extends Error {
|
||||
constructor(message: string) {
|
||||
super(message);
|
||||
this.name = 'QueueTimeoutError';
|
||||
}
|
||||
}
|
||||
|
||||
export async function withTimeout<T>(
|
||||
task: Promise<T>,
|
||||
timeoutMs: number,
|
||||
message: string,
|
||||
): Promise<T> {
|
||||
let timer: NodeJS.Timeout | undefined;
|
||||
const timeout = new Promise<never>((_resolve, reject) => {
|
||||
timer = setTimeout(() => {
|
||||
reject(new QueueTimeoutError(`${message}(超时 ${String(timeoutMs)}ms)`));
|
||||
}, timeoutMs);
|
||||
});
|
||||
try {
|
||||
return await Promise.race([task, timeout]);
|
||||
} finally {
|
||||
if (timer !== undefined) {
|
||||
clearTimeout(timer);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,49 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
|
||||
import {
|
||||
decodeItemCursor,
|
||||
decodeMediaTaskCursor,
|
||||
encodeItemCursor,
|
||||
encodeMediaTaskCursor,
|
||||
} from './media-task-cursor';
|
||||
|
||||
const AT = new Date('2026-11-04T12:00:00.000Z');
|
||||
const ID = '01J9Z000000000000000000001';
|
||||
|
||||
describe('任务列表游标(createdAt DESC, id DESC)', () => {
|
||||
it('编码 / 解码往返一致', () => {
|
||||
const encoded = encodeMediaTaskCursor(AT, ID);
|
||||
expect(encoded).not.toContain('=');
|
||||
expect(decodeMediaTaskCursor(encoded)).toEqual({ at: AT, id: ID });
|
||||
});
|
||||
|
||||
it('支持 ISO 字符串入参', () => {
|
||||
const encoded = encodeMediaTaskCursor(AT.toISOString(), ID);
|
||||
expect(decodeMediaTaskCursor(encoded)?.id).toBe(ID);
|
||||
});
|
||||
|
||||
it('非法游标返回 null:空串 / 非法 base64 / 缺分隔符 / 非法 ISO / 非法 ULID', () => {
|
||||
expect(decodeMediaTaskCursor('')).toBeNull();
|
||||
expect(decodeMediaTaskCursor('!!!!')).toBeNull();
|
||||
expect(decodeMediaTaskCursor(Buffer.from('no-separator').toString('base64url'))).toBeNull();
|
||||
expect(decodeMediaTaskCursor(Buffer.from(`not-a-date|${ID}`).toString('base64url'))).toBeNull();
|
||||
expect(
|
||||
decodeMediaTaskCursor(Buffer.from(`${AT.toISOString()}|not-ulid`).toString('base64url')),
|
||||
).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe('任务项续拉游标(seq)', () => {
|
||||
it('编码 / 解码往返一致', () => {
|
||||
expect(decodeItemCursor(encodeItemCursor(1))).toBe(1);
|
||||
expect(decodeItemCursor(encodeItemCursor(100))).toBe(100);
|
||||
});
|
||||
|
||||
it('非法游标返回 null:前缀错误 / 非数字 / 0 / 越界', () => {
|
||||
expect(decodeItemCursor('')).toBeNull();
|
||||
expect(decodeItemCursor(Buffer.from('xxx:1').toString('base64url'))).toBeNull();
|
||||
expect(decodeItemCursor(Buffer.from('item:abc').toString('base64url'))).toBeNull();
|
||||
expect(decodeItemCursor(Buffer.from('item:0').toString('base64url'))).toBeNull();
|
||||
expect(decodeItemCursor(Buffer.from('item:1234567890').toString('base64url'))).toBeNull();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,57 @@
|
||||
/**
|
||||
* 任务中心游标分页编码(A6-BE-04)
|
||||
*
|
||||
* - 任务列表:`base64url(<ISO createdAt>|<ULID id>)`,对应稳定排序 `createdAt DESC, id DESC`;
|
||||
* 用 keyset(`createdAt < cursor.at OR (createdAt = cursor.at AND id < cursor.id)`)翻页,
|
||||
* 不依赖 (createdAt, id) 唯一索引,也不引新依赖;
|
||||
* - 任务项续拉:`base64url(item:<seq>)`,对应 `(task_id, seq)` 唯一索引上的 `seq > cursor`。
|
||||
* 非法游标返回 null,由 Service 映射为 40001(不静默首页,避免用户误以为数据丢失)。
|
||||
*/
|
||||
import { isIsoDateString, ULID_PATTERN } from '@lingxi/shared';
|
||||
|
||||
export interface MediaTaskCursor {
|
||||
at: Date;
|
||||
id: string;
|
||||
}
|
||||
|
||||
export function encodeMediaTaskCursor(at: Date | string, id: string): string {
|
||||
const iso = at instanceof Date ? at.toISOString() : at;
|
||||
return Buffer.from(`${iso}|${id}`, 'utf8').toString('base64url');
|
||||
}
|
||||
|
||||
export function decodeMediaTaskCursor(value: string): MediaTaskCursor | null {
|
||||
let decoded: string;
|
||||
try {
|
||||
decoded = Buffer.from(value, 'base64url').toString('utf8');
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
const separator = decoded.lastIndexOf('|');
|
||||
if (separator <= 0) return null;
|
||||
const iso = decoded.slice(0, separator);
|
||||
const id = decoded.slice(separator + 1);
|
||||
if (!isIsoDateString(iso) || !ULID_PATTERN.test(id)) return null;
|
||||
const at = new Date(iso);
|
||||
if (Number.isNaN(at.getTime())) return null;
|
||||
return { at, id };
|
||||
}
|
||||
|
||||
const ITEM_CURSOR_PREFIX = 'item:';
|
||||
|
||||
export function encodeItemCursor(seq: number): string {
|
||||
return Buffer.from(`${ITEM_CURSOR_PREFIX}${String(seq)}`, 'utf8').toString('base64url');
|
||||
}
|
||||
|
||||
export function decodeItemCursor(value: string): number | null {
|
||||
let decoded: string;
|
||||
try {
|
||||
decoded = Buffer.from(value, 'base64url').toString('utf8');
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
if (!decoded.startsWith(ITEM_CURSOR_PREFIX)) return null;
|
||||
const raw = decoded.slice(ITEM_CURSOR_PREFIX.length);
|
||||
if (!/^\d{1,9}$/.test(raw)) return null;
|
||||
const seq = Number(raw);
|
||||
return Number.isSafeInteger(seq) && seq > 0 ? seq : null;
|
||||
}
|
||||
@@ -0,0 +1,102 @@
|
||||
import { ErrorCode } from '@lingxi/shared';
|
||||
import { describe, expect, it, vi } from 'vitest';
|
||||
|
||||
import { AppException } from '../../common/errors/app-exception';
|
||||
import type { SystemConfigService } from '../platform/system-config/system-config.service';
|
||||
import { estimateTask, MediaTaskEstimator } from './media-task-estimator';
|
||||
|
||||
function expectCode(fn: () => unknown, code: ErrorCode): void {
|
||||
try {
|
||||
fn();
|
||||
} catch (error) {
|
||||
expect(error).toBeInstanceOf(AppException);
|
||||
expect((error as AppException).code).toBe(code);
|
||||
return;
|
||||
}
|
||||
throw new Error('预期抛出 AppException');
|
||||
}
|
||||
|
||||
const CONFIG = { maxItems: 20, costPerItem: 0.02, secondsPerItem: 3 };
|
||||
|
||||
describe('任务预估纯计算(R-26:上限 / 单价来自系统配置)', () => {
|
||||
it('返回条数 / 成本(2 位小数)/ 耗时', () => {
|
||||
expect(estimateTask('demo', 5, CONFIG)).toEqual({
|
||||
estimatedItems: 5,
|
||||
estimatedCost: 0.1,
|
||||
estimatedSeconds: 15,
|
||||
});
|
||||
// 0.02 * 3 = 0.06 不产生浮点尾巴
|
||||
expect(estimateTask('demo', 3, CONFIG).estimatedCost).toBe(0.06);
|
||||
expect(estimateTask('demo', 20, CONFIG).estimatedCost).toBe(0.4);
|
||||
});
|
||||
|
||||
it('项数超过配置上限 → 40001(业务上限,不是 schema 硬上限)', () => {
|
||||
expectCode(() => estimateTask('demo', 21, CONFIG), ErrorCode.PARAM_INVALID);
|
||||
expectCode(
|
||||
() => estimateTask('demo', 101, { ...CONFIG, maxItems: 100 }),
|
||||
ErrorCode.PARAM_INVALID,
|
||||
);
|
||||
});
|
||||
|
||||
it('非法项数 / 非法配置 → 40001', () => {
|
||||
expectCode(() => estimateTask('demo', 0, CONFIG), ErrorCode.PARAM_INVALID);
|
||||
expectCode(() => estimateTask('demo', 2.5, CONFIG), ErrorCode.PARAM_INVALID);
|
||||
expectCode(() => estimateTask('demo', 1, { ...CONFIG, maxItems: 0 }), ErrorCode.PARAM_INVALID);
|
||||
expectCode(
|
||||
() => estimateTask('demo', 1, { ...CONFIG, costPerItem: Number.NaN }),
|
||||
ErrorCode.PARAM_INVALID,
|
||||
);
|
||||
expectCode(
|
||||
() => estimateTask('demo', 1, { ...CONFIG, costPerItem: -1 }),
|
||||
ErrorCode.PARAM_INVALID,
|
||||
);
|
||||
expectCode(
|
||||
() => estimateTask('demo', 1, { ...CONFIG, secondsPerItem: 0 }),
|
||||
ErrorCode.PARAM_INVALID,
|
||||
);
|
||||
expectCode(
|
||||
() => estimateTask('demo', 1, { ...CONFIG, secondsPerItem: Number.POSITIVE_INFINITY }),
|
||||
ErrorCode.PARAM_INVALID,
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe('MediaTaskEstimator(读 SystemConfigService)', () => {
|
||||
function estimatorWith(values: readonly number[]): {
|
||||
estimator: MediaTaskEstimator;
|
||||
getNumber: ReturnType<typeof vi.fn>;
|
||||
} {
|
||||
const getNumber = vi.fn();
|
||||
for (const value of values) getNumber.mockResolvedValueOnce(value);
|
||||
const config = { getNumber } as unknown as SystemConfigService;
|
||||
return { estimator: new MediaTaskEstimator(config), getNumber };
|
||||
}
|
||||
|
||||
it('按配置计算预估', async () => {
|
||||
const { estimator, getNumber } = estimatorWith([20, 0.02, 3]);
|
||||
await expect(estimator.estimate('demo', 5)).resolves.toEqual({
|
||||
estimatedItems: 5,
|
||||
estimatedCost: 0.1,
|
||||
estimatedSeconds: 15,
|
||||
});
|
||||
expect(getNumber).toHaveBeenNthCalledWith(1, 'task.demo.max_items');
|
||||
expect(getNumber).toHaveBeenNthCalledWith(2, 'task.estimate.demo_cost_per_item');
|
||||
expect(getNumber).toHaveBeenNthCalledWith(3, 'task.estimate.demo_seconds_per_item');
|
||||
});
|
||||
|
||||
it('读配置失败(缺预估字段)→ 40001,不允许静默 0', async () => {
|
||||
const getNumber = vi.fn().mockRejectedValue(new Error('system_configs 不可用'));
|
||||
const config = { getNumber } as unknown as SystemConfigService;
|
||||
const estimator = new MediaTaskEstimator(config);
|
||||
await expect(estimator.estimate('demo', 5)).rejects.toMatchObject({
|
||||
code: ErrorCode.PARAM_INVALID,
|
||||
});
|
||||
});
|
||||
|
||||
it('配置值越界(超过上限)→ 40001', async () => {
|
||||
const { estimator } = estimatorWith([4, 0.02, 3]);
|
||||
await expect(estimator.estimate('demo', 5)).rejects.toMatchObject({
|
||||
code: ErrorCode.PARAM_INVALID,
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,111 @@
|
||||
/**
|
||||
* 任务提交预估(A6-BE-01:提交响应必须包含 estimatedItems / estimatedCost / estimatedSeconds)
|
||||
*
|
||||
* R-26 单一来源:上限与单价全部读 `system_configs`(经 SystemConfigService),禁止硬编码;
|
||||
* 任一预估配置缺失 / 非法时**拒绝提交**(40001),不允许静默返回 0 误导用户。
|
||||
*/
|
||||
import {
|
||||
ErrorCode,
|
||||
systemConfigDefinition,
|
||||
TASK_CONFIG_KEYS,
|
||||
type TaskType,
|
||||
taskTypeLabel,
|
||||
} from '@lingxi/shared';
|
||||
import { Inject, Injectable } from '@nestjs/common';
|
||||
|
||||
import { AppException } from '../../common/errors/app-exception';
|
||||
import { SystemConfigService } from '../platform/system-config/system-config.service';
|
||||
|
||||
export interface TaskEstimate {
|
||||
estimatedItems: number;
|
||||
/** 元,保留 2 位小数 */
|
||||
estimatedCost: number;
|
||||
estimatedSeconds: number;
|
||||
}
|
||||
|
||||
export interface TaskEstimateConfig {
|
||||
maxItems: number;
|
||||
costPerItem: number;
|
||||
secondsPerItem: number;
|
||||
}
|
||||
|
||||
const ESTIMATE_CONFIG_KEYS: readonly string[] = [
|
||||
TASK_CONFIG_KEYS.demoMaxItems,
|
||||
TASK_CONFIG_KEYS.demoCostPerItem,
|
||||
TASK_CONFIG_KEYS.demoSecPerItem,
|
||||
];
|
||||
|
||||
function roundCost(value: number): number {
|
||||
return Math.round(value * 100) / 100;
|
||||
}
|
||||
|
||||
function invalidEstimate(message: string): AppException {
|
||||
return new AppException({ code: ErrorCode.PARAM_INVALID, message });
|
||||
}
|
||||
|
||||
/**
|
||||
* 纯计算 + 校验:项数超业务上限(task.demo.max_items)或预估配置非法 → 40001。
|
||||
* 抽成纯函数便于单测覆盖全部拒绝分支。
|
||||
*/
|
||||
export function estimateTask(
|
||||
type: TaskType,
|
||||
itemCount: number,
|
||||
config: TaskEstimateConfig,
|
||||
): TaskEstimate {
|
||||
const label = taskTypeLabel(type);
|
||||
if (!Number.isInteger(itemCount) || itemCount < 1) {
|
||||
throw invalidEstimate(`${label}项数必须为正整数`);
|
||||
}
|
||||
if (!Number.isFinite(config.maxItems) || config.maxItems < 1) {
|
||||
throw invalidEstimate('任务项上限配置无效,请联系管理员检查系统配置');
|
||||
}
|
||||
if (itemCount > config.maxItems) {
|
||||
throw invalidEstimate(`${label}项数超过上限(${String(config.maxItems)} 项)`);
|
||||
}
|
||||
if (!Number.isFinite(config.costPerItem) || config.costPerItem < 0) {
|
||||
throw invalidEstimate('任务预估成本配置无效,请联系管理员检查系统配置');
|
||||
}
|
||||
if (!Number.isFinite(config.secondsPerItem) || config.secondsPerItem <= 0) {
|
||||
throw invalidEstimate('任务预估耗时配置无效,请联系管理员检查系统配置');
|
||||
}
|
||||
return {
|
||||
estimatedItems: itemCount,
|
||||
estimatedCost: roundCost(itemCount * config.costPerItem),
|
||||
estimatedSeconds: Math.round(itemCount * config.secondsPerItem),
|
||||
};
|
||||
}
|
||||
|
||||
@Injectable()
|
||||
export class MediaTaskEstimator {
|
||||
constructor(@Inject(SystemConfigService) private readonly config: SystemConfigService) {}
|
||||
|
||||
async estimate(type: TaskType, itemCount: number): Promise<TaskEstimate> {
|
||||
const estimateConfig = await this.loadConfig();
|
||||
return estimateTask(type, itemCount, estimateConfig);
|
||||
}
|
||||
|
||||
/**
|
||||
* 读取三项预估配置:
|
||||
* 1. 定义缺失(代码未登记 / 类型不是 number)→ 视为配置缺失,拒绝提交;
|
||||
* 2. 读库异常 / 值非法 → 同样拒绝,避免拿 0 或兜底值蒙混。
|
||||
*/
|
||||
private async loadConfig(): Promise<TaskEstimateConfig> {
|
||||
for (const key of ESTIMATE_CONFIG_KEYS) {
|
||||
const definition = systemConfigDefinition(key);
|
||||
if (definition?.type !== 'number') {
|
||||
throw invalidEstimate(`任务预估配置缺失:${key}`);
|
||||
}
|
||||
}
|
||||
let values: readonly number[];
|
||||
try {
|
||||
values = await Promise.all(ESTIMATE_CONFIG_KEYS.map((key) => this.config.getNumber(key)));
|
||||
} catch {
|
||||
throw invalidEstimate('任务预估配置读取失败,请稍后重试或联系管理员');
|
||||
}
|
||||
const [maxItems, costPerItem, secondsPerItem] = values;
|
||||
if (maxItems === undefined || costPerItem === undefined || secondsPerItem === undefined) {
|
||||
throw invalidEstimate('任务预估配置缺失,请联系管理员检查系统配置');
|
||||
}
|
||||
return { maxItems, costPerItem, secondsPerItem };
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,62 @@
|
||||
import { ErrorCode } from '@lingxi/shared';
|
||||
import { describe, expect, it } from 'vitest';
|
||||
|
||||
import { AppException } from '../../common/errors/app-exception';
|
||||
import {
|
||||
hashIdempotencyRequest,
|
||||
parseIdempotencyKey,
|
||||
stableStringify,
|
||||
} from './media-task-idempotency';
|
||||
|
||||
function expectCode(fn: () => unknown, code: ErrorCode): void {
|
||||
try {
|
||||
fn();
|
||||
} catch (error) {
|
||||
expect(error).toBeInstanceOf(AppException);
|
||||
expect((error as AppException).code).toBe(code);
|
||||
return;
|
||||
}
|
||||
throw new Error('预期抛出 AppException');
|
||||
}
|
||||
|
||||
describe('幂等请求指纹(含 actorId)', () => {
|
||||
it('键顺序不影响哈希(稳定序列化)', () => {
|
||||
expect(stableStringify({ b: 2, a: 1 })).toBe('{"a":1,"b":2}');
|
||||
expect(stableStringify({ a: 1, b: { d: 4, c: 3 } })).toBe('{"a":1,"b":{"c":3,"d":4}}');
|
||||
const left = hashIdempotencyRequest('u1', { a: 1, b: [1, 2] });
|
||||
const right = hashIdempotencyRequest('u1', { b: [1, 2], a: 1 });
|
||||
expect(left).toBe(right);
|
||||
expect(left).toHaveLength(64);
|
||||
});
|
||||
|
||||
it('不同操作人 / 不同请求体 → 不同哈希', () => {
|
||||
const base = hashIdempotencyRequest('u1', { itemCount: 5 });
|
||||
expect(hashIdempotencyRequest('u2', { itemCount: 5 })).not.toBe(base);
|
||||
expect(hashIdempotencyRequest('u1', { itemCount: 6 })).not.toBe(base);
|
||||
});
|
||||
|
||||
it('数组顺序 / 原始类型保持语义', () => {
|
||||
expect(stableStringify([3, 1, 2])).toBe('[3,1,2]');
|
||||
expect(stableStringify(null)).toBe('null');
|
||||
expect(stableStringify('text')).toBe('"text"');
|
||||
});
|
||||
});
|
||||
|
||||
describe('Idempotency-Key 请求头校验', () => {
|
||||
it('接受 8–128 位可见 ASCII,数组取第一个', () => {
|
||||
expect(parseIdempotencyKey('idem-key-0001')).toBe('idem-key-0001');
|
||||
expect(parseIdempotencyKey(['idem-key-0002', 'other'])).toBe('idem-key-0002');
|
||||
});
|
||||
|
||||
it('缺失 / 空白 → 40001(缺少请求头)', () => {
|
||||
expectCode(() => parseIdempotencyKey(undefined), ErrorCode.PARAM_INVALID);
|
||||
expectCode(() => parseIdempotencyKey([]), ErrorCode.PARAM_INVALID);
|
||||
expectCode(() => parseIdempotencyKey(' '), ErrorCode.PARAM_INVALID);
|
||||
});
|
||||
|
||||
it('过短 / 过长 / 非可见 ASCII → 40001(格式非法)', () => {
|
||||
expectCode(() => parseIdempotencyKey('short'), ErrorCode.PARAM_INVALID);
|
||||
expectCode(() => parseIdempotencyKey('x'.repeat(129)), ErrorCode.PARAM_INVALID);
|
||||
expectCode(() => parseIdempotencyKey('幂等键-0001'), ErrorCode.PARAM_INVALID);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,71 @@
|
||||
/**
|
||||
* 任务提交幂等(A6-BE-01 / AGENTS §3.13:任务提交必须带 Idempotency-Key)
|
||||
*
|
||||
* 语义(与契约一致):
|
||||
* - 24h 内同 scope + key:
|
||||
* - 请求体哈希(含 actorId)相同 → 返回首次响应(202,不重复建任务 / 审计 / outbox);
|
||||
* - 请求体哈希不同 → 40902;
|
||||
* - 缺失 / 非法 header → 40001;
|
||||
* - 过期行按「无记录」处理并允许覆盖(唯一键是 scope + key,过期行先删除再重建)。
|
||||
*/
|
||||
import { createHash } from 'node:crypto';
|
||||
|
||||
import { ErrorCode, idempotencyKeySchema } from '@lingxi/shared';
|
||||
|
||||
import { AppException } from '../../common/errors/app-exception';
|
||||
|
||||
/** 幂等作用域:不同业务动作互不冲突 */
|
||||
export const MEDIA_TASK_SUBMIT_SCOPE = 'media.tasks.submit';
|
||||
|
||||
/** 幂等键有效期:24 小时(A6-BE-01) */
|
||||
export const IDEMPOTENCY_TTL_MS = 24 * 60 * 60 * 1000;
|
||||
|
||||
function canonicalize(value: unknown): unknown {
|
||||
if (Array.isArray(value)) {
|
||||
return value.map((item) => canonicalize(item));
|
||||
}
|
||||
if (value !== null && typeof value === 'object') {
|
||||
const sorted: Record<string, unknown> = {};
|
||||
for (const [key, item] of Object.entries(value).sort(([left], [right]) =>
|
||||
left < right ? -1 : left > right ? 1 : 0,
|
||||
)) {
|
||||
sorted[key] = canonicalize(item);
|
||||
}
|
||||
return sorted;
|
||||
}
|
||||
return value;
|
||||
}
|
||||
|
||||
/** 稳定序列化(键排序),保证同一请求体在不同字段顺序下哈希一致 */
|
||||
export function stableStringify(value: unknown): string {
|
||||
return JSON.stringify(canonicalize(value));
|
||||
}
|
||||
|
||||
/** 请求指纹 = sha256(actorId + '\n' + 规范请求体):同一 key 不同操作人视为不同请求 */
|
||||
export function hashIdempotencyRequest(actorId: string, body: unknown): string {
|
||||
return createHash('sha256')
|
||||
.update(`${actorId}\n${stableStringify(body)}`)
|
||||
.digest('hex');
|
||||
}
|
||||
|
||||
/**
|
||||
* 读取并校验 `Idempotency-Key` 请求头。
|
||||
* Fastify 对重复 header 返回字符串数组,取第一个(与 HTTP 语义一致)。
|
||||
*/
|
||||
export function parseIdempotencyKey(raw: string | string[] | undefined): string {
|
||||
const value = Array.isArray(raw) ? raw[0] : raw;
|
||||
if (value === undefined || value.trim().length === 0) {
|
||||
throw new AppException({
|
||||
code: ErrorCode.PARAM_INVALID,
|
||||
message: '缺少 Idempotency-Key 请求头',
|
||||
});
|
||||
}
|
||||
const parsed = idempotencyKeySchema.safeParse(value);
|
||||
if (!parsed.success) {
|
||||
throw new AppException({
|
||||
code: ErrorCode.PARAM_INVALID,
|
||||
message: 'Idempotency-Key 需为 8–128 位可见 ASCII 字符',
|
||||
});
|
||||
}
|
||||
return parsed.data;
|
||||
}
|
||||
@@ -0,0 +1,155 @@
|
||||
/**
|
||||
* 任务结果 Outbox 本地消费(A6-BE-05 / 技术架构 §3.7)
|
||||
*
|
||||
* 职责:消费 `media.task.finished` / `media.task.failed` 两类 Outbox 事件,写任务完成 / 失败审计。
|
||||
* 与 queue-worker 的 dispatcher 事件类型**互不重叠**(它只处理 created / retry / canceled)。
|
||||
*
|
||||
* 至少一次投递下的不重复处理:
|
||||
* - 条件 UPDATE 认领(`WHERE id = ? AND published_at IS NULL`),多实例只有一个能成功;
|
||||
* - 成功保持 published_at,失败回滚认领并记 last_error,下轮重试;
|
||||
* - 连续失败达上限后保持已认领(只记 last_error),避免每 2s 热循环。
|
||||
*/
|
||||
import { auditActionMeta, type AuditResult, TASK_OUTBOX_EVENTS } from '@lingxi/shared';
|
||||
import {
|
||||
Inject,
|
||||
Injectable,
|
||||
Logger,
|
||||
type OnModuleDestroy,
|
||||
type OnModuleInit,
|
||||
Optional,
|
||||
} from '@nestjs/common';
|
||||
|
||||
import { AUDIT_SINK, type AuditSink, type AuditWriteInput } from '../../common/security/audit-sink';
|
||||
import { MediaTaskRepository, type PendingResultEvent } from './media-task.repository';
|
||||
|
||||
/** 轮询间隔:与契约一致(2s) */
|
||||
export const RESULT_CONSUMER_INTERVAL_MS = 2000;
|
||||
/** 单轮最多处理事件数 */
|
||||
export const RESULT_CONSUMER_BATCH_SIZE = 10;
|
||||
/** 单事件最大处理次数,超过后只记 last_error 不再重试(避免无限循环) */
|
||||
export const RESULT_CONSUMER_MAX_ATTEMPTS = 5;
|
||||
|
||||
interface ResultAuditMapping {
|
||||
actionCode: string;
|
||||
result: AuditResult;
|
||||
}
|
||||
|
||||
const RESULT_AUDIT_MAPPINGS: ReadonlyMap<string, ResultAuditMapping> = new Map([
|
||||
[TASK_OUTBOX_EVENTS.finished, { actionCode: 'task.finished', result: 'success' }],
|
||||
[TASK_OUTBOX_EVENTS.failed, { actionCode: 'task.failed', result: 'failure' }],
|
||||
]);
|
||||
|
||||
@Injectable()
|
||||
export class MediaTaskResultConsumer implements OnModuleInit, OnModuleDestroy {
|
||||
private readonly logger = new Logger('MediaTaskResultConsumer');
|
||||
private timer: NodeJS.Timeout | null = null;
|
||||
private draining = false;
|
||||
|
||||
constructor(
|
||||
@Inject(MediaTaskRepository) private readonly repository: MediaTaskRepository,
|
||||
@Optional() @Inject(AUDIT_SINK) private readonly sink?: AuditSink,
|
||||
) {}
|
||||
|
||||
onModuleInit(): void {
|
||||
// unref:不阻塞进程退出(单测 / OpenAPI 导出等短生命周期场景)
|
||||
this.timer = setInterval(() => {
|
||||
void this.drain();
|
||||
}, RESULT_CONSUMER_INTERVAL_MS);
|
||||
this.timer.unref();
|
||||
void this.drain();
|
||||
}
|
||||
|
||||
onModuleDestroy(): void {
|
||||
if (this.timer !== null) clearInterval(this.timer);
|
||||
this.timer = null;
|
||||
}
|
||||
|
||||
/** 单轮消费:串行处理,避免同批事件重复写审计时互相覆盖 */
|
||||
async drain(): Promise<void> {
|
||||
if (this.draining) return;
|
||||
this.draining = true;
|
||||
try {
|
||||
const events = await this.repository.findPendingResultEvents(RESULT_CONSUMER_BATCH_SIZE);
|
||||
for (const event of events) {
|
||||
await this.consume(event);
|
||||
}
|
||||
} catch (error) {
|
||||
// 数据库抖动等:只记日志,下轮继续;不影响 API
|
||||
this.logger.error(
|
||||
`任务结果事件拉取失败 error=${error instanceof Error ? error.message : '未知错误'}`,
|
||||
);
|
||||
} finally {
|
||||
this.draining = false;
|
||||
}
|
||||
}
|
||||
|
||||
private async consume(event: PendingResultEvent): Promise<void> {
|
||||
const mapping = RESULT_AUDIT_MAPPINGS.get(event.eventType);
|
||||
if (mapping === undefined) return;
|
||||
// 条件 UPDATE 认领:多实例 / 上一轮残留都只有一个消费者能拿到
|
||||
const claimed = await this.repository.claimResultEvent(event.id);
|
||||
if (!claimed) return;
|
||||
try {
|
||||
const task = await this.repository.findTaskForResult(event.aggregateId);
|
||||
if (task === null) {
|
||||
// 任务不存在(极端数据异常):没有可审计对象,保持已消费避免死循环
|
||||
this.logger.warn(`任务结果事件找不到任务 event=${event.id} task=${event.aggregateId}`);
|
||||
return;
|
||||
}
|
||||
if (this.sink === undefined) return;
|
||||
const meta = auditActionMeta(mapping.actionCode);
|
||||
const input: AuditWriteInput = {
|
||||
occurredAt: new Date(),
|
||||
actorId: task.createdBy,
|
||||
actorName: task.createdByName,
|
||||
actorAccount: null,
|
||||
actionCode: mapping.actionCode,
|
||||
category: meta.category,
|
||||
targetType: 'task',
|
||||
targetId: task.id,
|
||||
targetName: task.title,
|
||||
customerId: task.customerId,
|
||||
customerName: task.customerName,
|
||||
result: mapping.result,
|
||||
httpMethod: null,
|
||||
httpPath: null,
|
||||
// outbox 事件由 worker 写入 trace_id;历史事件可能为空,空串表示未知,不伪造 trace
|
||||
traceId: event.traceId ?? '',
|
||||
ip: null,
|
||||
userAgent: null,
|
||||
before: undefined,
|
||||
after: {
|
||||
type: task.type,
|
||||
status: task.status,
|
||||
progress: task.progress,
|
||||
},
|
||||
changes: undefined,
|
||||
reason: null,
|
||||
errorCode: task.lastErrorCode,
|
||||
errorMessage: task.lastErrorMessage,
|
||||
durationMs: null,
|
||||
};
|
||||
await this.sink.write(input);
|
||||
} catch (error) {
|
||||
await this.handleFailure(event, error);
|
||||
}
|
||||
}
|
||||
|
||||
private async handleFailure(event: PendingResultEvent, error: unknown): Promise<void> {
|
||||
const message = error instanceof Error ? error.message : '未知错误';
|
||||
// 本轮认领已把 attempts +1,用 event.attempts + 1 判断是否达到上限
|
||||
if (event.attempts + 1 >= RESULT_CONSUMER_MAX_ATTEMPTS) {
|
||||
await this.repository.markResultEventExhausted(event.id, message);
|
||||
this.logger.error(
|
||||
`任务结果审计连续失败达上限,停止重试 event=${event.id} attempts=${String(
|
||||
event.attempts + 1,
|
||||
)} error=${message}`,
|
||||
);
|
||||
return;
|
||||
}
|
||||
await this.repository.markResultEventRetry(event.id, message);
|
||||
this.logger.error(
|
||||
`任务结果审计失败,回滚认领 event=${event.id} attempts=${String(event.attempts + 1)} error=${message}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,208 @@
|
||||
import { TASK_OUTBOX_EVENTS } from '@lingxi/shared';
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest';
|
||||
|
||||
import type { PendingResultEvent } from './media-task.repository';
|
||||
import type { MediaTaskRepository } from './media-task.repository';
|
||||
import {
|
||||
MediaTaskResultConsumer,
|
||||
RESULT_CONSUMER_MAX_ATTEMPTS,
|
||||
} from './media-task-result-consumer.service';
|
||||
|
||||
const TASK_ID = '01J9Z000000000000000000001';
|
||||
const CUSTOMER_ID = '01J9Z000000000000000000002';
|
||||
const USER_ID = '01J9Z000000000000000000003';
|
||||
|
||||
function eventFixture(overrides: Partial<PendingResultEvent> = {}): PendingResultEvent {
|
||||
return {
|
||||
id: 'evt-1',
|
||||
eventType: TASK_OUTBOX_EVENTS.finished,
|
||||
aggregateId: TASK_ID,
|
||||
payloadJson: { taskId: TASK_ID, type: 'demo' },
|
||||
attempts: 0,
|
||||
traceId: 'trace-1',
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
function taskFixture(overrides: Record<string, unknown> = {}): Record<string, unknown> {
|
||||
return {
|
||||
id: TASK_ID,
|
||||
type: 'demo',
|
||||
status: 'pending_review',
|
||||
title: '演练任务 · 花间集 · 2 项',
|
||||
customerId: CUSTOMER_ID,
|
||||
customerName: '花间集',
|
||||
createdBy: USER_ID,
|
||||
createdByName: '陈墨',
|
||||
progress: 100,
|
||||
lastErrorCode: null,
|
||||
lastErrorMessage: null,
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
interface Harness {
|
||||
consumer: MediaTaskResultConsumer;
|
||||
repository: {
|
||||
findPendingResultEvents: ReturnType<typeof vi.fn>;
|
||||
claimResultEvent: ReturnType<typeof vi.fn>;
|
||||
findTaskForResult: ReturnType<typeof vi.fn>;
|
||||
markResultEventRetry: ReturnType<typeof vi.fn>;
|
||||
markResultEventExhausted: ReturnType<typeof vi.fn>;
|
||||
};
|
||||
sink: { write: ReturnType<typeof vi.fn> };
|
||||
}
|
||||
|
||||
function harness(sinkEnabled = true): Harness {
|
||||
const repository = {
|
||||
findPendingResultEvents: vi.fn().mockResolvedValue([]),
|
||||
claimResultEvent: vi.fn().mockResolvedValue(true),
|
||||
findTaskForResult: vi.fn().mockResolvedValue(taskFixture()),
|
||||
markResultEventRetry: vi.fn().mockResolvedValue(undefined),
|
||||
markResultEventExhausted: vi.fn().mockResolvedValue(undefined),
|
||||
};
|
||||
const sink = { write: vi.fn().mockResolvedValue(undefined) };
|
||||
return {
|
||||
consumer: new MediaTaskResultConsumer(
|
||||
repository as unknown as MediaTaskRepository,
|
||||
sinkEnabled ? sink : undefined,
|
||||
),
|
||||
repository,
|
||||
sink,
|
||||
};
|
||||
}
|
||||
|
||||
afterEach(() => {
|
||||
vi.useRealTimers();
|
||||
});
|
||||
|
||||
describe('任务结果 Outbox 消费(A6-BE-05)', () => {
|
||||
it('media.task.finished → 写 task.finished 成功审计(actor 为任务创建人,target=task)', async () => {
|
||||
const { consumer, repository, sink } = harness();
|
||||
repository.findPendingResultEvents.mockResolvedValueOnce([eventFixture()]);
|
||||
await consumer.drain();
|
||||
|
||||
expect(repository.claimResultEvent).toHaveBeenCalledWith('evt-1');
|
||||
expect(sink.write).toHaveBeenCalledTimes(1);
|
||||
expect(sink.write.mock.calls[0]?.[0]).toMatchObject({
|
||||
actionCode: 'task.finished',
|
||||
category: 'task',
|
||||
result: 'success',
|
||||
actorId: USER_ID,
|
||||
actorName: '陈墨',
|
||||
targetType: 'task',
|
||||
targetId: TASK_ID,
|
||||
targetName: '演练任务 · 花间集 · 2 项',
|
||||
customerId: CUSTOMER_ID,
|
||||
customerName: '花间集',
|
||||
traceId: 'trace-1',
|
||||
after: { type: 'demo', status: 'pending_review', progress: 100 },
|
||||
});
|
||||
});
|
||||
|
||||
it('media.task.failed → 写 task.failed 失败审计,带最后错误码', async () => {
|
||||
const { consumer, repository, sink } = harness();
|
||||
repository.findTaskForResult.mockResolvedValueOnce(
|
||||
taskFixture({
|
||||
status: 'failed',
|
||||
progress: 100,
|
||||
lastErrorCode: 'DEMO_FAIL',
|
||||
lastErrorMessage: '全部失败',
|
||||
}),
|
||||
);
|
||||
repository.findPendingResultEvents.mockResolvedValueOnce([
|
||||
eventFixture({ eventType: TASK_OUTBOX_EVENTS.failed }),
|
||||
]);
|
||||
await consumer.drain();
|
||||
|
||||
expect(sink.write.mock.calls[0]?.[0]).toMatchObject({
|
||||
actionCode: 'task.failed',
|
||||
result: 'failure',
|
||||
errorCode: 'DEMO_FAIL',
|
||||
errorMessage: '全部失败',
|
||||
});
|
||||
});
|
||||
|
||||
it('条件认领失败(其他实例已处理)→ 不重复写审计', async () => {
|
||||
const { consumer, repository, sink } = harness();
|
||||
repository.findPendingResultEvents.mockResolvedValueOnce([eventFixture()]);
|
||||
repository.claimResultEvent.mockResolvedValueOnce(false);
|
||||
await consumer.drain();
|
||||
expect(sink.write).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('非本地事件类型(created / retry / canceled)不认领', async () => {
|
||||
const { consumer, repository, sink } = harness();
|
||||
repository.findPendingResultEvents.mockResolvedValueOnce([
|
||||
eventFixture({ id: 'evt-2', eventType: TASK_OUTBOX_EVENTS.created }),
|
||||
]);
|
||||
await consumer.drain();
|
||||
expect(repository.claimResultEvent).not.toHaveBeenCalled();
|
||||
expect(sink.write).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('任务不存在 / 未装配审计端口:不抛错、不阻塞后续事件', async () => {
|
||||
const missing = harness();
|
||||
missing.repository.findPendingResultEvents.mockResolvedValueOnce([eventFixture()]);
|
||||
missing.repository.findTaskForResult.mockResolvedValueOnce(null);
|
||||
await expect(missing.consumer.drain()).resolves.toBeUndefined();
|
||||
|
||||
const noSink = harness(false);
|
||||
noSink.repository.findPendingResultEvents.mockResolvedValueOnce([eventFixture()]);
|
||||
await expect(noSink.consumer.drain()).resolves.toBeUndefined();
|
||||
});
|
||||
|
||||
it('审计写失败:回滚认领并记 error,下轮重试', async () => {
|
||||
const { consumer, repository, sink } = harness();
|
||||
repository.findPendingResultEvents.mockResolvedValueOnce([eventFixture({ attempts: 1 })]);
|
||||
sink.write.mockRejectedValueOnce(new Error('db down'));
|
||||
await consumer.drain();
|
||||
expect(repository.markResultEventRetry).toHaveBeenCalledWith('evt-1', 'db down');
|
||||
expect(repository.markResultEventExhausted).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it(`连续失败达上限(${String(RESULT_CONSUMER_MAX_ATTEMPTS)} 次):标记放弃,不再回滚认领`, async () => {
|
||||
const { consumer, repository, sink } = harness();
|
||||
repository.findPendingResultEvents.mockResolvedValueOnce([
|
||||
eventFixture({ attempts: RESULT_CONSUMER_MAX_ATTEMPTS - 1 }),
|
||||
]);
|
||||
sink.write.mockRejectedValueOnce(new Error('boom'));
|
||||
await consumer.drain();
|
||||
expect(repository.markResultEventExhausted).toHaveBeenCalledWith('evt-1', 'boom');
|
||||
expect(repository.markResultEventRetry).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('拉取异常只记日志,不向上抛', async () => {
|
||||
const { consumer, repository } = harness();
|
||||
repository.findPendingResultEvents.mockRejectedValueOnce(new Error('connection reset'));
|
||||
await expect(consumer.drain()).resolves.toBeUndefined();
|
||||
});
|
||||
|
||||
it('单轮防重入:drain 未结束时再次调用直接返回', async () => {
|
||||
const { consumer, repository } = harness();
|
||||
let resolveFetch: (value: PendingResultEvent[]) => void = () => undefined;
|
||||
repository.findPendingResultEvents.mockImplementationOnce(
|
||||
() =>
|
||||
new Promise<PendingResultEvent[]>((resolve) => {
|
||||
resolveFetch = resolve;
|
||||
}),
|
||||
);
|
||||
const first = consumer.drain();
|
||||
await expect(consumer.drain()).resolves.toBeUndefined();
|
||||
resolveFetch([]);
|
||||
await first;
|
||||
expect(repository.findPendingResultEvents).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('onModuleInit 每 2s 轮询,onModuleDestroy 停止定时器', async () => {
|
||||
vi.useFakeTimers();
|
||||
const { consumer, repository } = harness();
|
||||
consumer.onModuleInit();
|
||||
expect(repository.findPendingResultEvents).toHaveBeenCalledTimes(1);
|
||||
await vi.advanceTimersByTimeAsync(2000);
|
||||
expect(repository.findPendingResultEvents).toHaveBeenCalledTimes(2);
|
||||
consumer.onModuleDestroy();
|
||||
await vi.advanceTimersByTimeAsync(10_000);
|
||||
expect(repository.findPendingResultEvents).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,68 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
|
||||
import {
|
||||
formatSseComment,
|
||||
formatSseDone,
|
||||
formatSseEvent,
|
||||
parseTaskEventType,
|
||||
resolveResumeSeq,
|
||||
} from './media-task-sse-format';
|
||||
|
||||
describe('SSE 帧序列化(id 用 task_events.seq)', () => {
|
||||
it('事件帧:id / event / data 三行 + 空行,data 为单行 JSON', () => {
|
||||
const frame = formatSseEvent('12', 'progress', {
|
||||
taskId: 't1',
|
||||
status: 'running',
|
||||
note: '多行\n文本',
|
||||
});
|
||||
expect(frame.startsWith('id: 12\nevent: progress\ndata: ')).toBe(true);
|
||||
expect(frame.endsWith('\n\n')).toBe(true);
|
||||
// JSON.stringify 转义换行,data 仍是单行
|
||||
const dataLine = frame.split('\n')[2] ?? '';
|
||||
expect(dataLine.includes('\\n')).toBe(true);
|
||||
expect(JSON.parse(dataLine.replace('data: ', ''))).toMatchObject({
|
||||
taskId: 't1',
|
||||
status: 'running',
|
||||
});
|
||||
});
|
||||
|
||||
it('心跳用注释帧,不进入事件流', () => {
|
||||
expect(formatSseComment('ping')).toBe(': ping\n\n');
|
||||
});
|
||||
|
||||
it('终态 done 帧复用事件格式,id 为最后消费的 seq', () => {
|
||||
const frame = formatSseDone(42n, { taskId: 't1', status: 'canceled', progress: 100 });
|
||||
expect(frame).toContain('id: 42');
|
||||
expect(frame).toContain('event: done');
|
||||
expect(frame).toContain('"status":"canceled"');
|
||||
});
|
||||
|
||||
it('事件类型只接受 item / progress / done / error', () => {
|
||||
expect(parseTaskEventType('item')).toBe('item');
|
||||
expect(parseTaskEventType('progress')).toBe('progress');
|
||||
expect(parseTaskEventType('done')).toBe('done');
|
||||
expect(parseTaskEventType('error')).toBe('error');
|
||||
expect(() => parseTaskEventType('unknown')).toThrow('任务事件类型非法:unknown');
|
||||
});
|
||||
});
|
||||
|
||||
describe('Last-Event-ID 续推解析', () => {
|
||||
it('缺省 / 空串从 0 开始', () => {
|
||||
expect(resolveResumeSeq(undefined, undefined)).toEqual({ ok: true, seq: 0n });
|
||||
expect(resolveResumeSeq('', undefined)).toEqual({ ok: true, seq: 0n });
|
||||
expect(resolveResumeSeq(undefined, '')).toEqual({ ok: true, seq: 0n });
|
||||
});
|
||||
|
||||
it('header 优先于 query,支持数组取第一个', () => {
|
||||
expect(resolveResumeSeq('7', '3')).toEqual({ ok: true, seq: 7n });
|
||||
expect(resolveResumeSeq(['9', '2'], '3')).toEqual({ ok: true, seq: 9n });
|
||||
expect(resolveResumeSeq(undefined, '3')).toEqual({ ok: true, seq: 3n });
|
||||
});
|
||||
|
||||
it('非纯数字视为非法(40001 由 Service 映射)', () => {
|
||||
expect(resolveResumeSeq('-1', undefined)).toEqual({ ok: false });
|
||||
expect(resolveResumeSeq('abc', undefined)).toEqual({ ok: false });
|
||||
expect(resolveResumeSeq('1.5', undefined)).toEqual({ ok: false });
|
||||
expect(resolveResumeSeq('123456789012345678901', undefined)).toEqual({ ok: false });
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,52 @@
|
||||
/**
|
||||
* SSE 文本帧与断点续推解析(A6-BE-03 / 技术架构 §6.4)
|
||||
*
|
||||
* 帧格式(与 EventSource 规范一致):
|
||||
* id: <task_events.seq>
|
||||
* event: item | progress | done | error
|
||||
* data: <单行 JSON>
|
||||
* <空行>
|
||||
* 心跳用注释帧 `: ping`,不污染事件流。`Last-Event-ID` 请求头优先于 `last_event_id` query。
|
||||
*/
|
||||
import { TASK_EVENT_TYPES, type TaskDoneEventPayload, type TaskEventType } from '@lingxi/shared';
|
||||
|
||||
/** DB 字符串 → SSE 事件类型(未知视为脏数据,由调用方兜底为 error / 跳过) */
|
||||
export function parseTaskEventType(value: string): TaskEventType {
|
||||
const found = TASK_EVENT_TYPES.find((event) => event === value);
|
||||
if (found === undefined) throw new Error(`任务事件类型非法:${value}`);
|
||||
return found;
|
||||
}
|
||||
|
||||
export type SseResume = { ok: true; seq: bigint } | { ok: false };
|
||||
|
||||
/** 解析续推起点:header 优先;缺省 / 空串 = 从 0 开始;非纯数字视为非法(40001) */
|
||||
export function resolveResumeSeq(
|
||||
header: string | string[] | undefined,
|
||||
query: string | undefined,
|
||||
): SseResume {
|
||||
const headerValue = Array.isArray(header) ? header[0] : header;
|
||||
const raw = headerValue === undefined || headerValue.trim().length === 0 ? query : headerValue;
|
||||
if (raw === undefined || raw.trim().length === 0) {
|
||||
return { ok: true, seq: 0n };
|
||||
}
|
||||
const trimmed = raw.trim();
|
||||
if (!/^\d{1,19}$/.test(trimmed)) {
|
||||
return { ok: false };
|
||||
}
|
||||
return { ok: true, seq: BigInt(trimmed) };
|
||||
}
|
||||
|
||||
/** 事件帧:id 用 task_events.seq(BigInt 转字符串),data 单行 JSON */
|
||||
export function formatSseEvent(id: string, event: TaskEventType, data: unknown): string {
|
||||
return `id: ${id}\nevent: ${event}\ndata: ${JSON.stringify(data)}\n\n`;
|
||||
}
|
||||
|
||||
/** 心跳注释帧 */
|
||||
export function formatSseComment(text: string): string {
|
||||
return `: ${text}\n\n`;
|
||||
}
|
||||
|
||||
/** 终态补发 done:id 用最后消费到的 seq,保证重连语义不倒退 */
|
||||
export function formatSseDone(seq: bigint, payload: TaskDoneEventPayload): string {
|
||||
return formatSseEvent(seq.toString(), 'done', payload);
|
||||
}
|
||||
@@ -0,0 +1,201 @@
|
||||
import { ErrorCode } from '@lingxi/shared';
|
||||
import type { FastifyReply } from 'fastify';
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest';
|
||||
|
||||
import type { ScopeContext } from '../../common/security/scope/scope-context';
|
||||
import type { MediaTaskRepository } from './media-task.repository';
|
||||
import { MediaTaskSseService } from './media-task-sse.service';
|
||||
|
||||
const TASK_ID = '01J9Z000000000000000000001';
|
||||
const CUSTOMER_ID = '01J9Z000000000000000000002';
|
||||
const USER_ID = '01J9Z000000000000000000003';
|
||||
|
||||
const SCOPE: ScopeContext = {
|
||||
userId: USER_ID,
|
||||
isSuperAdmin: false,
|
||||
dataScopes: [],
|
||||
customerIds: [CUSTOMER_ID],
|
||||
};
|
||||
|
||||
interface FakeRaw {
|
||||
writableEnded: boolean;
|
||||
writeHead: ReturnType<typeof vi.fn>;
|
||||
write: ReturnType<typeof vi.fn>;
|
||||
end: ReturnType<typeof vi.fn>;
|
||||
on: ReturnType<typeof vi.fn>;
|
||||
}
|
||||
|
||||
interface ReplyHarness {
|
||||
reply: FastifyReply;
|
||||
raw: FakeRaw;
|
||||
hijack: ReturnType<typeof vi.fn>;
|
||||
chunks: string[];
|
||||
close: () => void;
|
||||
}
|
||||
|
||||
function replyHarness(): ReplyHarness {
|
||||
const chunks: string[] = [];
|
||||
const listeners: (() => void)[] = [];
|
||||
const raw: FakeRaw = {
|
||||
writableEnded: false,
|
||||
writeHead: vi.fn(),
|
||||
write: vi.fn((chunk: string) => {
|
||||
chunks.push(chunk);
|
||||
return true;
|
||||
}),
|
||||
end: vi.fn(() => {
|
||||
raw.writableEnded = true;
|
||||
}),
|
||||
on: vi.fn((_event: string, handler: () => void) => {
|
||||
listeners.push(handler);
|
||||
return raw;
|
||||
}),
|
||||
};
|
||||
const hijack = vi.fn();
|
||||
return {
|
||||
reply: { raw, hijack } as unknown as FastifyReply,
|
||||
raw,
|
||||
hijack,
|
||||
chunks,
|
||||
close: () => {
|
||||
for (const listener of listeners) listener();
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
function serviceWith(repository: {
|
||||
findTaskForStream: ReturnType<typeof vi.fn>;
|
||||
findTaskEventsAfter: ReturnType<typeof vi.fn>;
|
||||
findTaskStreamStatus: ReturnType<typeof vi.fn>;
|
||||
}): MediaTaskSseService {
|
||||
return new MediaTaskSseService(repository as unknown as MediaTaskRepository);
|
||||
}
|
||||
|
||||
function repositoryMock(): {
|
||||
findTaskForStream: ReturnType<typeof vi.fn>;
|
||||
findTaskEventsAfter: ReturnType<typeof vi.fn>;
|
||||
findTaskStreamStatus: ReturnType<typeof vi.fn>;
|
||||
} {
|
||||
return {
|
||||
findTaskForStream: vi.fn().mockResolvedValue({ id: TASK_ID, status: 'running', progress: 40 }),
|
||||
findTaskEventsAfter: vi.fn().mockResolvedValue([
|
||||
{
|
||||
seq: 1n,
|
||||
eventType: 'progress',
|
||||
payloadJson: {
|
||||
taskId: TASK_ID,
|
||||
status: 'running',
|
||||
progress: 40,
|
||||
itemTotal: 2,
|
||||
itemSucceeded: 1,
|
||||
itemFailed: 0,
|
||||
itemSkipped: 0,
|
||||
},
|
||||
},
|
||||
]),
|
||||
findTaskStreamStatus: vi
|
||||
.fn()
|
||||
.mockResolvedValue({ id: TASK_ID, status: 'running', progress: 40 }),
|
||||
};
|
||||
}
|
||||
|
||||
afterEach(() => {
|
||||
vi.useRealTimers();
|
||||
});
|
||||
|
||||
describe('SSE 事件流(A6-BE-03)', () => {
|
||||
it('缺少 ScopeContext → 40302;非法 Last-Event-ID → 40001;任务不存在 → 404(均不升级为流)', async () => {
|
||||
const repository = repositoryMock();
|
||||
const service = serviceWith(repository);
|
||||
const { reply, hijack } = replyHarness();
|
||||
|
||||
await expect(service.stream(TASK_ID, {}, null, reply)).rejects.toMatchObject({
|
||||
code: ErrorCode.OUT_OF_SCOPE,
|
||||
});
|
||||
await expect(
|
||||
service.stream(TASK_ID, { lastEventIdHeader: 'not-a-number' }, SCOPE, reply),
|
||||
).rejects.toMatchObject({ code: ErrorCode.PARAM_INVALID });
|
||||
|
||||
repository.findTaskForStream.mockResolvedValueOnce(null);
|
||||
await expect(service.stream(TASK_ID, {}, SCOPE, reply)).rejects.toMatchObject({
|
||||
code: ErrorCode.NOT_FOUND,
|
||||
});
|
||||
expect(hijack).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('生成中:握手头正确、补发增量事件、不结束;心跳 / 继续轮询可用', async () => {
|
||||
vi.useFakeTimers();
|
||||
const repository = repositoryMock();
|
||||
const service = serviceWith(repository);
|
||||
const harness = replyHarness();
|
||||
const done = service.stream(TASK_ID, {}, SCOPE, harness.reply);
|
||||
await done;
|
||||
|
||||
expect(harness.hijack).toHaveBeenCalledTimes(1);
|
||||
expect(harness.raw.writeHead).toHaveBeenCalledWith(
|
||||
200,
|
||||
expect.objectContaining({
|
||||
'Content-Type': 'text/event-stream; charset=utf-8',
|
||||
'Cache-Control': 'no-cache, no-transform',
|
||||
Connection: 'keep-alive',
|
||||
'X-Accel-Buffering': 'no',
|
||||
}),
|
||||
);
|
||||
expect(harness.chunks[0]).toContain('id: 1');
|
||||
expect(harness.chunks[0]).toContain('event: progress');
|
||||
expect(harness.raw.end).not.toHaveBeenCalled();
|
||||
expect(repository.findTaskEventsAfter).toHaveBeenCalledWith(TASK_ID, 0n, 200);
|
||||
|
||||
harness.close();
|
||||
await vi.advanceTimersByTimeAsync(15_000);
|
||||
// 关闭后不再写心跳 / 轮询
|
||||
const chunkCount = harness.chunks.length;
|
||||
await vi.advanceTimersByTimeAsync(20_000);
|
||||
expect(harness.chunks).toHaveLength(chunkCount);
|
||||
});
|
||||
|
||||
it('心跳:15s 发送注释帧,800ms 轮询下一批', async () => {
|
||||
vi.useFakeTimers();
|
||||
const repository = repositoryMock();
|
||||
repository.findTaskEventsAfter.mockResolvedValue([]);
|
||||
const service = serviceWith(repository);
|
||||
const harness = replyHarness();
|
||||
const done = service.stream(TASK_ID, {}, SCOPE, harness.reply);
|
||||
await done;
|
||||
|
||||
await vi.advanceTimersByTimeAsync(800);
|
||||
expect(repository.findTaskEventsAfter.mock.calls.length).toBeGreaterThanOrEqual(2);
|
||||
await vi.advanceTimersByTimeAsync(15_000);
|
||||
expect(harness.chunks.some((chunk) => chunk === ': ping\n\n')).toBe(true);
|
||||
harness.close();
|
||||
});
|
||||
|
||||
it('终态:补发 done 帧并关闭连接(id = 最后消费的 seq)', async () => {
|
||||
const repository = repositoryMock();
|
||||
repository.findTaskStreamStatus.mockResolvedValueOnce({
|
||||
id: TASK_ID,
|
||||
status: 'canceled',
|
||||
progress: 100,
|
||||
});
|
||||
const service = serviceWith(repository);
|
||||
const harness = replyHarness();
|
||||
await service.stream(TASK_ID, { lastEventIdQuery: '3' }, SCOPE, harness.reply);
|
||||
|
||||
expect(repository.findTaskEventsAfter).toHaveBeenCalledWith(TASK_ID, 3n, 200);
|
||||
expect(harness.chunks.some((chunk) => chunk.includes('event: done'))).toBe(true);
|
||||
expect(harness.chunks.some((chunk) => chunk.includes('id: 1'))).toBe(true);
|
||||
expect(harness.raw.end).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('轮询异常:补发 error 事件后关闭(前端降级轮询)', async () => {
|
||||
const repository = repositoryMock();
|
||||
repository.findTaskEventsAfter.mockRejectedValueOnce(new Error('db down'));
|
||||
const service = serviceWith(repository);
|
||||
const harness = replyHarness();
|
||||
await service.stream(TASK_ID, {}, SCOPE, harness.reply);
|
||||
|
||||
expect(harness.chunks.some((chunk) => chunk.includes('event: error'))).toBe(true);
|
||||
expect(harness.chunks.some((chunk) => chunk.includes('"code":50002'))).toBe(true);
|
||||
expect(harness.raw.end).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,158 @@
|
||||
/**
|
||||
* 任务 SSE 推送(A6-BE-03 / 技术架构 §6.4 / §7.3)
|
||||
*
|
||||
* - 认证:走标准 Authorization 头(前端用 fetch 流读取,不依赖原生 EventSource);
|
||||
* - 续推:`Last-Event-ID` 请求头优先,其次 `last_event_id` query,事件 `id` 用 task_events.seq;
|
||||
* - 进度源:每 800ms 轮询 `task_events`(进度落库,进程重启不丢),每 15s 发 `: ping` 心跳;
|
||||
* - 收敛:任务离开「排队 / 生成中」后补发 `done` 并关闭;客户端断开时清理定时器。
|
||||
*/
|
||||
import { ErrorCode, isTaskActive } from '@lingxi/shared';
|
||||
import { Inject, Injectable, Logger } from '@nestjs/common';
|
||||
import type { FastifyReply } from 'fastify';
|
||||
|
||||
import { AppException } from '../../common/errors/app-exception';
|
||||
import type { ScopeContext } from '../../common/security/scope/scope-context';
|
||||
import { parseTaskStatus } from './media-task.mapper';
|
||||
import { MediaTaskRepository } from './media-task.repository';
|
||||
import {
|
||||
formatSseComment,
|
||||
formatSseDone,
|
||||
formatSseEvent,
|
||||
parseTaskEventType,
|
||||
resolveResumeSeq,
|
||||
} from './media-task-sse-format';
|
||||
|
||||
/** 心跳间隔:反向代理通常 60s 超时,15s 留足余量 */
|
||||
export const SSE_HEARTBEAT_INTERVAL_MS = 15_000;
|
||||
/** 轮询间隔:与契约一致(800ms) */
|
||||
export const SSE_POLL_INTERVAL_MS = 800;
|
||||
/** 单轮最多补发事件数,积压时下一轮继续 */
|
||||
export const SSE_EVENT_BATCH_SIZE = 200;
|
||||
|
||||
export interface SseStreamOptions {
|
||||
lastEventIdHeader?: string | string[];
|
||||
lastEventIdQuery?: string;
|
||||
}
|
||||
|
||||
@Injectable()
|
||||
export class MediaTaskSseService {
|
||||
private readonly logger = new Logger('MediaTaskSse');
|
||||
|
||||
constructor(@Inject(MediaTaskRepository) private readonly repository: MediaTaskRepository) {}
|
||||
|
||||
// eslint-disable-next-line max-lines-per-function -- 事件流生命周期(校验 → 握手 → 心跳 / 轮询 → 收敛 / 清理)集中一处,拆散会丢失清理时机
|
||||
async stream(
|
||||
id: string,
|
||||
options: SseStreamOptions,
|
||||
scopeInput: ScopeContext | null,
|
||||
reply: FastifyReply,
|
||||
): Promise<void> {
|
||||
if (scopeInput === null) {
|
||||
throw new AppException({
|
||||
code: ErrorCode.OUT_OF_SCOPE,
|
||||
message: '缺少数据范围上下文,已拒绝访问',
|
||||
});
|
||||
}
|
||||
const resume = resolveResumeSeq(options.lastEventIdHeader, options.lastEventIdQuery);
|
||||
if (!resume.ok) {
|
||||
throw new AppException({
|
||||
code: ErrorCode.PARAM_INVALID,
|
||||
message: 'Last-Event-ID / last_event_id 必须是任务事件序号',
|
||||
});
|
||||
}
|
||||
// 先校验可见性,再切换为流式响应(范围外统一 404,不泄露存在性)
|
||||
const task = await this.repository.findTaskForStream(id, scopeInput);
|
||||
if (task === null) {
|
||||
throw new AppException({ code: ErrorCode.NOT_FOUND, message: '任务不存在或已删除' });
|
||||
}
|
||||
|
||||
const raw = reply.raw;
|
||||
reply.hijack();
|
||||
raw.writeHead(200, {
|
||||
'Content-Type': 'text/event-stream; charset=utf-8',
|
||||
'Cache-Control': 'no-cache, no-transform',
|
||||
Connection: 'keep-alive',
|
||||
'X-Accel-Buffering': 'no',
|
||||
});
|
||||
|
||||
let closed = false;
|
||||
let polling = false;
|
||||
let cursor = resume.seq;
|
||||
let lastEventType: string | null = null;
|
||||
let pollTimer: NodeJS.Timeout | null = null;
|
||||
let heartbeatTimer: NodeJS.Timeout | null = null;
|
||||
const stopTimers = (): void => {
|
||||
if (pollTimer !== null) clearInterval(pollTimer);
|
||||
if (heartbeatTimer !== null) clearInterval(heartbeatTimer);
|
||||
};
|
||||
const write = (chunk: string): void => {
|
||||
if (!closed && !raw.writableEnded) raw.write(chunk);
|
||||
};
|
||||
const finish = (): void => {
|
||||
if (closed) return;
|
||||
closed = true;
|
||||
stopTimers();
|
||||
if (!raw.writableEnded) raw.end();
|
||||
};
|
||||
const poll = async (): Promise<void> => {
|
||||
if (closed || polling) return;
|
||||
polling = true;
|
||||
try {
|
||||
const events = await this.repository.findTaskEventsAfter(id, cursor, SSE_EVENT_BATCH_SIZE);
|
||||
for (const event of events) {
|
||||
write(
|
||||
formatSseEvent(
|
||||
event.seq.toString(),
|
||||
parseTaskEventType(event.eventType),
|
||||
event.payloadJson,
|
||||
),
|
||||
);
|
||||
cursor = event.seq;
|
||||
lastEventType = event.eventType;
|
||||
}
|
||||
// 有积压时下一轮继续补发,避免长时间占用事件循环
|
||||
if (events.length >= SSE_EVENT_BATCH_SIZE) return;
|
||||
const statusRow = await this.repository.findTaskStreamStatus(id);
|
||||
if (statusRow === null) {
|
||||
finish();
|
||||
return;
|
||||
}
|
||||
const status = parseTaskStatus(statusRow.status);
|
||||
if (!isTaskActive(status)) {
|
||||
// 已落库的 done 事件刚补发过则不重复补发(客户端仍可依赖事件 id 去重)
|
||||
if (lastEventType !== 'done') {
|
||||
write(formatSseDone(cursor, { taskId: id, status, progress: statusRow.progress }));
|
||||
}
|
||||
finish();
|
||||
}
|
||||
} catch (error) {
|
||||
this.logger.error(
|
||||
`任务事件流轮询失败 task=${id} error=${error instanceof Error ? error.message : '未知错误'}`,
|
||||
);
|
||||
write(
|
||||
formatSseEvent(cursor.toString(), 'error', {
|
||||
code: ErrorCode.DEPENDENCY_UNAVAILABLE,
|
||||
message: '任务事件流暂时不可用,请降级为轮询任务详情',
|
||||
}),
|
||||
);
|
||||
finish();
|
||||
} finally {
|
||||
polling = false;
|
||||
}
|
||||
};
|
||||
|
||||
// 客户端断开(关闭页面 / 网络中断)时清理定时器,避免泄漏
|
||||
raw.on('close', () => {
|
||||
closed = true;
|
||||
stopTimers();
|
||||
});
|
||||
heartbeatTimer = setInterval(() => {
|
||||
write(formatSseComment('ping'));
|
||||
}, SSE_HEARTBEAT_INTERVAL_MS);
|
||||
pollTimer = setInterval(() => {
|
||||
void poll();
|
||||
}, SSE_POLL_INTERVAL_MS);
|
||||
|
||||
await poll();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,168 @@
|
||||
/**
|
||||
* 任务中心接口(A6-BE-04 / A6-BE-03,开发计划 §7.6)
|
||||
*
|
||||
* | 方法 | 路径 | 权限 | 说明 |
|
||||
* | --- | --- | --- | --- |
|
||||
* | POST | /media-tasks | material:render | 提交(必带 Idempotency-Key) |
|
||||
* | GET | /media-tasks | material:read | 跨类型任务列表(游标分页 + 筛选) |
|
||||
* | GET | /media-tasks/:id | material:read | 详情 + 首 100 条任务项 |
|
||||
* | GET | /media-tasks/:id/events | material:read | SSE 进度(Last-Event-ID 续推) |
|
||||
* | POST | /media-tasks/:id/cancel | material:render | 取消(只影响未开始项) |
|
||||
* | POST | /media-tasks/:id/retry | material:render | 仅重试失败项 |
|
||||
*
|
||||
* 所有查询都经 ScopeContext 注入 customer_id 过滤;范围外单条资源统一 404(不泄露存在性)。
|
||||
*/
|
||||
import {
|
||||
ErrorCode,
|
||||
IDEMPOTENCY_KEY_HEADER,
|
||||
type MediaTaskCancelResponse,
|
||||
type MediaTaskDetail,
|
||||
type MediaTaskDetailQuery,
|
||||
mediaTaskDetailQuerySchema,
|
||||
type MediaTaskListQuery,
|
||||
mediaTaskListQuerySchema,
|
||||
type MediaTaskListResponse,
|
||||
type MediaTaskRetryResponse,
|
||||
type MediaTaskSseQuery,
|
||||
mediaTaskSseQuerySchema,
|
||||
type MediaTaskSubmitParsed,
|
||||
type MediaTaskSubmitResponse,
|
||||
mediaTaskSubmitSchema,
|
||||
Permission,
|
||||
} from '@lingxi/shared';
|
||||
import {
|
||||
Body,
|
||||
Controller,
|
||||
Get,
|
||||
Headers,
|
||||
HttpCode,
|
||||
Inject,
|
||||
Param,
|
||||
Post,
|
||||
Query,
|
||||
Res,
|
||||
} from '@nestjs/common';
|
||||
import { ApiBearerAuth, ApiOperation, ApiProduces, ApiTags } from '@nestjs/swagger';
|
||||
import type { FastifyReply } from 'fastify';
|
||||
|
||||
import { AppException } from '../../common/errors/app-exception';
|
||||
import { ZodValidationPipe } from '../../common/pipes/zod-validation.pipe';
|
||||
import type { AuthContext } from '../../common/security/auth.types';
|
||||
import {
|
||||
Audit,
|
||||
CurrentScope,
|
||||
CurrentUser,
|
||||
RequirePermissions,
|
||||
} from '../../common/security/decorators';
|
||||
import type { ScopeContext } from '../../common/security/scope/scope-context';
|
||||
import { MediaTaskService } from './media-task.service';
|
||||
import { MediaTaskSseService } from './media-task-sse.service';
|
||||
|
||||
function requireAuth(auth: AuthContext | null): AuthContext {
|
||||
if (auth === null) {
|
||||
throw new AppException({ code: ErrorCode.UNAUTHENTICATED });
|
||||
}
|
||||
return auth;
|
||||
}
|
||||
|
||||
@ApiTags('media-tasks')
|
||||
@ApiBearerAuth()
|
||||
@Controller('media-tasks')
|
||||
export class MediaTaskController {
|
||||
constructor(
|
||||
@Inject(MediaTaskService) private readonly tasks: MediaTaskService,
|
||||
@Inject(MediaTaskSseService) private readonly sse: MediaTaskSseService,
|
||||
) {}
|
||||
|
||||
@Post()
|
||||
@HttpCode(202)
|
||||
@RequirePermissions(Permission.MATERIAL_RENDER)
|
||||
@Audit('task.submit')
|
||||
@ApiOperation({
|
||||
summary:
|
||||
'提交任务(异步 202;M0 仅演练任务;必带 Idempotency-Key,同 key 同请求体 24h 内返回同一任务)',
|
||||
})
|
||||
submit(
|
||||
@Body(new ZodValidationPipe(mediaTaskSubmitSchema)) input: MediaTaskSubmitParsed,
|
||||
@Headers(IDEMPOTENCY_KEY_HEADER.toLowerCase()) idempotencyKey: string | string[] | undefined,
|
||||
@CurrentUser() auth: AuthContext | null,
|
||||
@CurrentScope() scope: ScopeContext | null,
|
||||
): Promise<MediaTaskSubmitResponse> {
|
||||
return this.tasks.submit(input, idempotencyKey, requireAuth(auth), scope);
|
||||
}
|
||||
|
||||
@Get()
|
||||
@RequirePermissions(Permission.MATERIAL_READ)
|
||||
@ApiOperation({
|
||||
summary:
|
||||
'任务列表(类型 / 状态 / 客户 / 时间 / 我的 / 关键字筛选;稳定排序 createdAt DESC, id DESC)',
|
||||
})
|
||||
list(
|
||||
@Query(new ZodValidationPipe(mediaTaskListQuerySchema)) query: MediaTaskListQuery,
|
||||
@CurrentUser() auth: AuthContext | null,
|
||||
@CurrentScope() scope: ScopeContext | null,
|
||||
): Promise<MediaTaskListResponse> {
|
||||
return this.tasks.list(query, scope, requireAuth(auth));
|
||||
}
|
||||
|
||||
@Get(':id/events')
|
||||
@RequirePermissions(Permission.MATERIAL_READ)
|
||||
@ApiProduces('text/event-stream')
|
||||
@ApiOperation({
|
||||
summary:
|
||||
'任务 SSE 进度(id 用 task_events.seq;支持 Last-Event-ID / last_event_id 续推,15s 心跳)',
|
||||
})
|
||||
events(
|
||||
@Param('id') id: string,
|
||||
@Headers('last-event-id') lastEventIdHeader: string | string[] | undefined,
|
||||
@Query(new ZodValidationPipe(mediaTaskSseQuerySchema)) query: MediaTaskSseQuery,
|
||||
@CurrentScope() scope: ScopeContext | null,
|
||||
@Res() reply: FastifyReply,
|
||||
): Promise<void> {
|
||||
return this.sse.stream(
|
||||
id,
|
||||
{
|
||||
lastEventIdHeader,
|
||||
...(query.last_event_id === undefined ? {} : { lastEventIdQuery: query.last_event_id }),
|
||||
},
|
||||
scope,
|
||||
reply,
|
||||
);
|
||||
}
|
||||
|
||||
@Get(':id')
|
||||
@RequirePermissions(Permission.MATERIAL_READ)
|
||||
@ApiOperation({ summary: '任务详情(首 100 条任务项 + itemsNextCursor;范围外 404)' })
|
||||
detail(
|
||||
@Param('id') id: string,
|
||||
@Query(new ZodValidationPipe(mediaTaskDetailQuerySchema)) query: MediaTaskDetailQuery,
|
||||
@CurrentScope() scope: ScopeContext | null,
|
||||
): Promise<MediaTaskDetail> {
|
||||
return this.tasks.detail(id, query, scope);
|
||||
}
|
||||
|
||||
@Post(':id/cancel')
|
||||
@RequirePermissions(Permission.MATERIAL_RENDER)
|
||||
@Audit('task.cancel')
|
||||
@ApiOperation({
|
||||
summary: '取消任务(仅排队 / 生成中;只把未开始项置 skipped,已成功项不受影响)',
|
||||
})
|
||||
cancel(
|
||||
@Param('id') id: string,
|
||||
@CurrentUser() auth: AuthContext | null,
|
||||
@CurrentScope() scope: ScopeContext | null,
|
||||
): Promise<MediaTaskCancelResponse> {
|
||||
return this.tasks.cancel(id, scope, requireAuth(auth));
|
||||
}
|
||||
|
||||
@Post(':id/retry')
|
||||
@RequirePermissions(Permission.MATERIAL_RENDER)
|
||||
@Audit('task.retry')
|
||||
@ApiOperation({ summary: '重试任务(仅部分失败;只重置失败项,成功项不动)' })
|
||||
retry(
|
||||
@Param('id') id: string,
|
||||
@CurrentScope() scope: ScopeContext | null,
|
||||
): Promise<MediaTaskRetryResponse> {
|
||||
return this.tasks.retry(id, scope);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,131 @@
|
||||
import { Prisma } from '@prisma/client';
|
||||
import { describe, expect, it } from 'vitest';
|
||||
|
||||
import {
|
||||
type MediaTaskDetailRow,
|
||||
type MediaTaskSummaryRow,
|
||||
parseTaskItemStatus,
|
||||
parseTaskStatus,
|
||||
parseTaskType,
|
||||
toJsonObject,
|
||||
toMediaTaskDetail,
|
||||
toMediaTaskItem,
|
||||
toMediaTaskSummary,
|
||||
} from './media-task.mapper';
|
||||
|
||||
const AT = new Date('2026-11-04T12:00:00.000Z');
|
||||
const ID = '01J9Z000000000000000000001';
|
||||
|
||||
function summaryRow(overrides: Record<string, unknown> = {}): MediaTaskSummaryRow {
|
||||
return {
|
||||
id: ID,
|
||||
type: 'demo',
|
||||
status: 'partial_failed',
|
||||
title: '演练任务 · 花间集 · 5 项',
|
||||
customerId: '01J9Z000000000000000000002',
|
||||
customerName: '花间集',
|
||||
itemTotal: 5,
|
||||
itemSucceeded: 3,
|
||||
itemFailed: 2,
|
||||
itemSkipped: 0,
|
||||
progress: 100,
|
||||
estimatedItems: 5,
|
||||
estimatedCost: new Prisma.Decimal('0.1000'),
|
||||
estimatedSeconds: 15,
|
||||
createdBy: '01J9Z000000000000000000003',
|
||||
createdByName: '陈墨',
|
||||
createdAt: AT,
|
||||
startedAt: null,
|
||||
finishedAt: null,
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
describe('任务行映射(Decimal → number / 可选时间 / code)', () => {
|
||||
it('摘要:金额转 number,空的可选时间不返回', () => {
|
||||
const summary = toMediaTaskSummary(summaryRow());
|
||||
expect(summary.estimatedCost).toBe(0.1);
|
||||
expect(summary.type).toBe('demo');
|
||||
expect(summary.status).toBe('partial_failed');
|
||||
expect(summary.startedAt).toBeUndefined();
|
||||
expect(summary.finishedAt).toBeUndefined();
|
||||
});
|
||||
|
||||
it('摘要:起始 / 结束时间存在时返回 ISO 字符串', () => {
|
||||
const summary = toMediaTaskSummary(summaryRow({ startedAt: AT, finishedAt: AT }));
|
||||
expect(summary.startedAt).toBe(AT.toISOString());
|
||||
expect(summary.finishedAt).toBe(AT.toISOString());
|
||||
});
|
||||
|
||||
it('未知类型 / 状态视为脏数据抛错(不静默降级)', () => {
|
||||
expect(() => toMediaTaskSummary(summaryRow({ type: 'unknown' }))).toThrow('任务类型非法');
|
||||
expect(() => toMediaTaskSummary(summaryRow({ status: 'unknown' }))).toThrow('任务状态非法');
|
||||
expect(() => parseTaskItemStatus('unknown')).toThrow('任务项状态非法');
|
||||
expect(parseTaskType('video')).toBe('video');
|
||||
expect(parseTaskStatus('archived')).toBe('archived');
|
||||
});
|
||||
|
||||
it('任务项:失败原因可选,attempts 保留', () => {
|
||||
const item = toMediaTaskItem({
|
||||
id: ID,
|
||||
seq: 2,
|
||||
status: 'failed',
|
||||
failureCode: 'DEMO_FAIL',
|
||||
failureMessage: '演练失败',
|
||||
attempts: 3,
|
||||
startedAt: AT,
|
||||
finishedAt: AT,
|
||||
});
|
||||
expect(item).toMatchObject({ seq: 2, status: 'failed', attempts: 3, failureCode: 'DEMO_FAIL' });
|
||||
const queued = toMediaTaskItem({
|
||||
id: ID,
|
||||
seq: 1,
|
||||
status: 'queued',
|
||||
failureCode: null,
|
||||
failureMessage: null,
|
||||
attempts: 0,
|
||||
startedAt: null,
|
||||
finishedAt: null,
|
||||
});
|
||||
expect(queued.failureCode).toBeUndefined();
|
||||
expect(queued.failureMessage).toBeUndefined();
|
||||
expect(queued.startedAt).toBeUndefined();
|
||||
});
|
||||
|
||||
it('详情:payload 对象 / 取消请求时间 / 最后一次错误 + items', () => {
|
||||
const detailRow = summaryRow({
|
||||
payloadJson: { mode: 'mixed', note: null },
|
||||
cancelRequestedAt: AT,
|
||||
lastErrorCode: 'ITEM_FAILED',
|
||||
lastErrorMessage: '2 项失败',
|
||||
}) as unknown as MediaTaskDetailRow;
|
||||
const detail = toMediaTaskDetail(
|
||||
detailRow,
|
||||
[
|
||||
{
|
||||
id: ID,
|
||||
seq: 1,
|
||||
status: 'failed',
|
||||
failureCode: 'DEMO_FAIL',
|
||||
failureMessage: '演练失败',
|
||||
attempts: 1,
|
||||
startedAt: null,
|
||||
finishedAt: AT,
|
||||
},
|
||||
],
|
||||
'item-cursor',
|
||||
);
|
||||
expect(detail.payload).toEqual({ mode: 'mixed', note: null });
|
||||
expect(detail.cancelRequestedAt).toBe(AT.toISOString());
|
||||
expect(detail.lastErrorCode).toBe('ITEM_FAILED');
|
||||
expect(detail.items).toHaveLength(1);
|
||||
expect(detail.itemsNextCursor).toBe('item-cursor');
|
||||
});
|
||||
|
||||
it('异常 JsonB 结构回退空对象(详情不 500)', () => {
|
||||
expect(toJsonObject(null)).toEqual({});
|
||||
expect(toJsonObject([1, 2])).toEqual({});
|
||||
expect(toJsonObject('text')).toEqual({});
|
||||
expect(toJsonObject({ a: 1 })).toEqual({ a: 1 });
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,163 @@
|
||||
/**
|
||||
* 任务中心 Prisma 行 → 共享 DTO 映射(A6-BE-04)
|
||||
*
|
||||
* - 列表 / 详情显式 select(DB-10:列表不返回 payload 等大 JSONB);
|
||||
* - 金额 `estimated_cost` 为 Decimal(14,4),出参统一转 number(元,2 位小数展示由前端处理);
|
||||
* - DB 存英文 code,未知 code 视为脏数据直接抛错(由全局过滤器转 500,不静默降级成错误状态)。
|
||||
*/
|
||||
import {
|
||||
type MediaTaskDetail,
|
||||
type MediaTaskItem,
|
||||
type MediaTaskSummary,
|
||||
TASK_ITEM_STATUSES,
|
||||
TASK_STATUSES,
|
||||
TASK_TYPES,
|
||||
type TaskItemStatus,
|
||||
type TaskStatus,
|
||||
type TaskType,
|
||||
} from '@lingxi/shared';
|
||||
import type { Prisma } from '@prisma/client';
|
||||
|
||||
/** 摘要 select:列表与详情共用(详情再追加 payload / 取消 / 错误字段) */
|
||||
export const MEDIA_TASK_SUMMARY_SELECT = {
|
||||
id: true,
|
||||
type: true,
|
||||
status: true,
|
||||
title: true,
|
||||
customerId: true,
|
||||
customerName: true,
|
||||
itemTotal: true,
|
||||
itemSucceeded: true,
|
||||
itemFailed: true,
|
||||
itemSkipped: true,
|
||||
progress: true,
|
||||
estimatedItems: true,
|
||||
estimatedCost: true,
|
||||
estimatedSeconds: true,
|
||||
createdBy: true,
|
||||
createdByName: true,
|
||||
createdAt: true,
|
||||
startedAt: true,
|
||||
finishedAt: true,
|
||||
} satisfies Prisma.MediaTaskSelect;
|
||||
|
||||
export type MediaTaskSummaryRow = Prisma.MediaTaskGetPayload<{
|
||||
select: typeof MEDIA_TASK_SUMMARY_SELECT;
|
||||
}>;
|
||||
|
||||
export const MEDIA_TASK_DETAIL_SELECT = {
|
||||
...MEDIA_TASK_SUMMARY_SELECT,
|
||||
payloadJson: true,
|
||||
cancelRequestedAt: true,
|
||||
lastErrorCode: true,
|
||||
lastErrorMessage: true,
|
||||
} satisfies Prisma.MediaTaskSelect;
|
||||
|
||||
export type MediaTaskDetailRow = Prisma.MediaTaskGetPayload<{
|
||||
select: typeof MEDIA_TASK_DETAIL_SELECT;
|
||||
}>;
|
||||
|
||||
/** 任务项 select:列表 / 详情只给状态与失败原因,不含产物引用 */
|
||||
export const MEDIA_ITEM_SELECT = {
|
||||
id: true,
|
||||
seq: true,
|
||||
status: true,
|
||||
failureCode: true,
|
||||
failureMessage: true,
|
||||
attempts: true,
|
||||
startedAt: true,
|
||||
finishedAt: true,
|
||||
} satisfies Prisma.MediaItemSelect;
|
||||
|
||||
export type MediaItemRow = Prisma.MediaItemGetPayload<{ select: typeof MEDIA_ITEM_SELECT }>;
|
||||
|
||||
/** DB 字符串 → 任务类型(未知视为脏数据) */
|
||||
export function parseTaskType(value: string): TaskType {
|
||||
const found = TASK_TYPES.find((type) => type === value);
|
||||
if (found === undefined) throw new Error(`任务类型非法:${value}`);
|
||||
return found;
|
||||
}
|
||||
|
||||
/** DB 字符串 → 任务状态(未知视为脏数据) */
|
||||
export function parseTaskStatus(value: string): TaskStatus {
|
||||
const found = TASK_STATUSES.find((status) => status === value);
|
||||
if (found === undefined) throw new Error(`任务状态非法:${value}`);
|
||||
return found;
|
||||
}
|
||||
|
||||
/** DB 字符串 → 任务项状态(未知视为脏数据) */
|
||||
export function parseTaskItemStatus(value: string): TaskItemStatus {
|
||||
const found = TASK_ITEM_STATUSES.find((status) => status === value);
|
||||
if (found === undefined) throw new Error(`任务项状态非法:${value}`);
|
||||
return found;
|
||||
}
|
||||
|
||||
function optionalIso(value: Date | null): string | undefined {
|
||||
return value === null ? undefined : value.toISOString();
|
||||
}
|
||||
|
||||
/** JsonB → 普通对象(只接受 object;异常结构回退空对象,详情不因此 500) */
|
||||
export function toJsonObject(value: Prisma.JsonValue): Record<string, unknown> {
|
||||
if (typeof value === 'object' && value !== null && !Array.isArray(value)) {
|
||||
return value;
|
||||
}
|
||||
return {};
|
||||
}
|
||||
|
||||
export function toMediaTaskSummary(row: MediaTaskSummaryRow): MediaTaskSummary {
|
||||
const startedAt = optionalIso(row.startedAt);
|
||||
const finishedAt = optionalIso(row.finishedAt);
|
||||
return {
|
||||
id: row.id,
|
||||
type: parseTaskType(row.type),
|
||||
status: parseTaskStatus(row.status),
|
||||
title: row.title,
|
||||
customerId: row.customerId,
|
||||
customerName: row.customerName,
|
||||
itemTotal: row.itemTotal,
|
||||
itemSucceeded: row.itemSucceeded,
|
||||
itemFailed: row.itemFailed,
|
||||
itemSkipped: row.itemSkipped,
|
||||
progress: row.progress,
|
||||
estimatedItems: row.estimatedItems,
|
||||
estimatedCost: Number(row.estimatedCost),
|
||||
estimatedSeconds: row.estimatedSeconds,
|
||||
createdBy: row.createdBy,
|
||||
createdByName: row.createdByName,
|
||||
createdAt: row.createdAt.toISOString(),
|
||||
...(startedAt === undefined ? {} : { startedAt }),
|
||||
...(finishedAt === undefined ? {} : { finishedAt }),
|
||||
};
|
||||
}
|
||||
|
||||
export function toMediaTaskItem(row: MediaItemRow): MediaTaskItem {
|
||||
const startedAt = optionalIso(row.startedAt);
|
||||
const finishedAt = optionalIso(row.finishedAt);
|
||||
return {
|
||||
id: row.id,
|
||||
seq: row.seq,
|
||||
status: parseTaskItemStatus(row.status),
|
||||
...(row.failureCode === null ? {} : { failureCode: row.failureCode }),
|
||||
...(row.failureMessage === null ? {} : { failureMessage: row.failureMessage }),
|
||||
attempts: row.attempts,
|
||||
...(startedAt === undefined ? {} : { startedAt }),
|
||||
...(finishedAt === undefined ? {} : { finishedAt }),
|
||||
};
|
||||
}
|
||||
|
||||
export function toMediaTaskDetail(
|
||||
row: MediaTaskDetailRow,
|
||||
items: MediaItemRow[],
|
||||
itemsNextCursor: string | null,
|
||||
): MediaTaskDetail {
|
||||
const cancelRequestedAt = optionalIso(row.cancelRequestedAt);
|
||||
return {
|
||||
...toMediaTaskSummary(row),
|
||||
payload: toJsonObject(row.payloadJson),
|
||||
...(cancelRequestedAt === undefined ? {} : { cancelRequestedAt }),
|
||||
...(row.lastErrorCode === null ? {} : { lastErrorCode: row.lastErrorCode }),
|
||||
...(row.lastErrorMessage === null ? {} : { lastErrorMessage: row.lastErrorMessage }),
|
||||
items: items.map((item) => toMediaTaskItem(item)),
|
||||
itemsNextCursor,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,530 @@
|
||||
import { ErrorCode, TASK_OUTBOX_EVENTS } from '@lingxi/shared';
|
||||
import { Prisma } from '@prisma/client';
|
||||
import { describe, expect, it, vi } from 'vitest';
|
||||
|
||||
import { AppException } from '../../common/errors/app-exception';
|
||||
import type { ScopeContext } from '../../common/security/scope/scope-context';
|
||||
import type { PrismaService } from '../../infra/prisma/prisma.service';
|
||||
import type { MediaTaskHeaderRow } from './media-task.repository';
|
||||
import {
|
||||
isUniqueViolation,
|
||||
MediaTaskRepository,
|
||||
type MediaTaskSubmitTransactionInput,
|
||||
} from './media-task.repository';
|
||||
|
||||
const NOW = new Date('2026-11-04T12:00:00.000Z');
|
||||
const TASK_ID = '01J9Z000000000000000000001';
|
||||
const CUSTOMER_ID = '01J9Z000000000000000000002';
|
||||
const USER_ID = '01J9Z000000000000000000003';
|
||||
const ITEM_ID_1 = '01J9Z000000000000000000004';
|
||||
const ITEM_ID_2 = '01J9Z000000000000000000005';
|
||||
|
||||
const SCOPE: ScopeContext = {
|
||||
userId: USER_ID,
|
||||
isSuperAdmin: false,
|
||||
dataScopes: [],
|
||||
customerIds: [CUSTOMER_ID],
|
||||
};
|
||||
|
||||
interface PrismaMock {
|
||||
mediaTask: Record<string, ReturnType<typeof vi.fn>>;
|
||||
mediaItem: Record<string, ReturnType<typeof vi.fn>>;
|
||||
taskEvent: Record<string, ReturnType<typeof vi.fn>>;
|
||||
outboxEvent: Record<string, ReturnType<typeof vi.fn>>;
|
||||
idempotencyKey: Record<string, ReturnType<typeof vi.fn>>;
|
||||
$transaction: ReturnType<typeof vi.fn>;
|
||||
}
|
||||
|
||||
function prismaMock(): PrismaMock {
|
||||
const mock = {
|
||||
mediaTask: {
|
||||
findMany: vi.fn().mockResolvedValue([]),
|
||||
findFirst: vi.fn().mockResolvedValue(null),
|
||||
findUnique: vi.fn().mockResolvedValue(null),
|
||||
create: vi.fn().mockResolvedValue({}),
|
||||
update: vi.fn().mockResolvedValue({}),
|
||||
updateMany: vi.fn().mockResolvedValue({ count: 1 }),
|
||||
groupBy: vi.fn().mockResolvedValue([]),
|
||||
count: vi.fn().mockResolvedValue(0),
|
||||
},
|
||||
mediaItem: {
|
||||
createMany: vi.fn().mockResolvedValue({ count: 0 }),
|
||||
updateMany: vi.fn().mockResolvedValue({ count: 0 }),
|
||||
findMany: vi.fn().mockResolvedValue([]),
|
||||
groupBy: vi.fn().mockResolvedValue([]),
|
||||
count: vi.fn().mockResolvedValue(0),
|
||||
},
|
||||
taskEvent: {
|
||||
create: vi.fn().mockResolvedValue({}),
|
||||
createMany: vi.fn().mockResolvedValue({ count: 0 }),
|
||||
findMany: vi.fn().mockResolvedValue([]),
|
||||
},
|
||||
outboxEvent: {
|
||||
create: vi.fn().mockResolvedValue({}),
|
||||
findMany: vi.fn().mockResolvedValue([]),
|
||||
updateMany: vi.fn().mockResolvedValue({ count: 1 }),
|
||||
},
|
||||
idempotencyKey: {
|
||||
findUnique: vi.fn().mockResolvedValue(null),
|
||||
delete: vi.fn().mockResolvedValue({}),
|
||||
create: vi.fn().mockResolvedValue({}),
|
||||
},
|
||||
$transaction: vi.fn(),
|
||||
};
|
||||
mock.$transaction.mockImplementation(async (callback: unknown): Promise<unknown> => {
|
||||
if (typeof callback === 'function') {
|
||||
return (callback as (client: PrismaMock) => Promise<unknown>)(mock);
|
||||
}
|
||||
return Promise.all(callback as Promise<unknown>[]);
|
||||
});
|
||||
return mock;
|
||||
}
|
||||
|
||||
function harness(): { repository: MediaTaskRepository; prisma: PrismaMock } {
|
||||
const prisma = prismaMock();
|
||||
return {
|
||||
repository: new MediaTaskRepository(prisma as unknown as PrismaService),
|
||||
prisma,
|
||||
};
|
||||
}
|
||||
|
||||
function submitInput(): MediaTaskSubmitTransactionInput {
|
||||
return {
|
||||
task: {
|
||||
id: TASK_ID,
|
||||
type: 'demo',
|
||||
title: '演练任务 · 花间集 · 2 项',
|
||||
customerId: CUSTOMER_ID,
|
||||
customerName: '花间集',
|
||||
createdBy: USER_ID,
|
||||
createdByName: '陈墨',
|
||||
itemTotal: 2,
|
||||
estimatedItems: 2,
|
||||
estimatedCost: 0.04,
|
||||
estimatedSeconds: 6,
|
||||
payload: { type: 'demo', mode: 'mixed', itemCount: 2, note: null },
|
||||
},
|
||||
itemIds: [ITEM_ID_1, ITEM_ID_2],
|
||||
idempotency: {
|
||||
id: 'k1',
|
||||
scope: 'media.tasks.submit',
|
||||
key: 'idem-key-0001',
|
||||
requestHash: 'hash',
|
||||
response: { taskId: TASK_ID, estimatedItems: 2, estimatedCost: 0.04, estimatedSeconds: 6 },
|
||||
expiresAt: new Date(NOW.getTime() + 86_400_000),
|
||||
},
|
||||
traceId: 'trace-1',
|
||||
now: NOW,
|
||||
};
|
||||
}
|
||||
|
||||
function headerRow(overrides: Record<string, unknown> = {}): MediaTaskHeaderRow {
|
||||
return {
|
||||
id: TASK_ID,
|
||||
type: 'demo',
|
||||
status: 'queued',
|
||||
title: '演练任务 · 花间集 · 2 项',
|
||||
customerId: CUSTOMER_ID,
|
||||
customerName: '花间集',
|
||||
createdBy: USER_ID,
|
||||
createdByName: '陈墨',
|
||||
itemTotal: 2,
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
async function expectRejected(promise: Promise<unknown>, code: ErrorCode): Promise<void> {
|
||||
try {
|
||||
await promise;
|
||||
} catch (error) {
|
||||
expect(error).toBeInstanceOf(AppException);
|
||||
expect((error as AppException).code).toBe(code);
|
||||
return;
|
||||
}
|
||||
throw new Error('预期抛出 AppException');
|
||||
}
|
||||
|
||||
describe('任务列表 / 详情查询(ScopeContext 强制注入 customer_id)', () => {
|
||||
it('列表 where 全部下推到 SQL:范围 / 筛选 / 时间 / 关键字 / keyset 游标', async () => {
|
||||
const { repository, prisma } = harness();
|
||||
await repository.listTasks(
|
||||
{
|
||||
type: 'demo',
|
||||
status: 'partial_failed',
|
||||
customer_id: CUSTOMER_ID,
|
||||
created_from: NOW.toISOString(),
|
||||
created_to: NOW.toISOString(),
|
||||
mine: 'true',
|
||||
keyword: '演练',
|
||||
limit: 20,
|
||||
},
|
||||
SCOPE,
|
||||
USER_ID,
|
||||
{ at: NOW, id: TASK_ID },
|
||||
);
|
||||
const args = prisma.mediaTask.findMany?.mock.calls[0]?.[0] as {
|
||||
where: { AND: Record<string, unknown>[] };
|
||||
orderBy: unknown;
|
||||
take: number;
|
||||
};
|
||||
expect(args.take).toBe(21);
|
||||
expect(args.orderBy).toEqual([{ createdAt: 'desc' }, { id: 'desc' }]);
|
||||
expect(args.where.AND).toContainEqual({ customerId: { in: [CUSTOMER_ID] } });
|
||||
expect(args.where.AND).toContainEqual({ type: 'demo' });
|
||||
expect(args.where.AND).toContainEqual({ status: 'partial_failed' });
|
||||
expect(args.where.AND).toContainEqual({ createdBy: USER_ID });
|
||||
expect(args.where.AND).toContainEqual(
|
||||
expect.objectContaining({
|
||||
OR: [{ createdAt: { lt: NOW } }, { createdAt: NOW, id: { lt: TASK_ID } }],
|
||||
}),
|
||||
);
|
||||
expect(JSON.stringify(args.where.AND)).toContain('"mode":"insensitive"');
|
||||
});
|
||||
|
||||
it('全部客户可见时不注入 in 过滤', async () => {
|
||||
const { repository, prisma } = harness();
|
||||
await repository.listTasks({ limit: 20 }, { ...SCOPE, customerIds: null }, USER_ID, null);
|
||||
const args = prisma.mediaTask.findMany?.mock.calls[0]?.[0] as {
|
||||
where: { AND: Record<string, unknown>[] };
|
||||
};
|
||||
expect(args.where.AND[0]).toEqual({});
|
||||
});
|
||||
|
||||
it('详情:任务不存在返回 null;存在时按 seq 续拉并返回 hasMore', async () => {
|
||||
const { repository, prisma } = harness();
|
||||
await expect(repository.findTaskDetail(TASK_ID, SCOPE, null, 2)).resolves.toBeNull();
|
||||
|
||||
prisma.mediaTask.findFirst?.mockResolvedValueOnce({ id: TASK_ID });
|
||||
prisma.mediaItem.findMany?.mockResolvedValueOnce([
|
||||
{ id: ITEM_ID_1, seq: 1 },
|
||||
{ id: ITEM_ID_2, seq: 2 },
|
||||
{ id: '01J9Z000000000000000000006', seq: 3 },
|
||||
]);
|
||||
const found = await repository.findTaskDetail(TASK_ID, SCOPE, 1, 2);
|
||||
expect(found?.items).toHaveLength(2);
|
||||
expect(found?.hasMore).toBe(true);
|
||||
const itemArgs = prisma.mediaItem.findMany?.mock.calls[0]?.[0] as {
|
||||
where: { taskId: string; seq?: { gt: number } };
|
||||
take: number;
|
||||
};
|
||||
expect(itemArgs.take).toBe(3);
|
||||
expect(itemArgs.where).toEqual({ taskId: TASK_ID, seq: { gt: 1 } });
|
||||
});
|
||||
});
|
||||
|
||||
describe('幂等键与提交事务', () => {
|
||||
it('replayResponse:同哈希返回首次响应;不同哈希 / 半成品响应 40902', () => {
|
||||
const { repository } = harness();
|
||||
const existing = {
|
||||
id: 'k1',
|
||||
scope: 'media.tasks.submit',
|
||||
key: 'idem-key-0001',
|
||||
requestHash: 'hash',
|
||||
responseStatus: 202,
|
||||
responseBody: {
|
||||
taskId: TASK_ID,
|
||||
estimatedItems: 2,
|
||||
estimatedCost: 0.04,
|
||||
estimatedSeconds: 6,
|
||||
},
|
||||
createdAt: NOW,
|
||||
expiresAt: new Date(NOW.getTime() + 60_000),
|
||||
};
|
||||
expect(repository.replayResponse(existing, 'hash')).toEqual({
|
||||
taskId: TASK_ID,
|
||||
estimatedItems: 2,
|
||||
estimatedCost: 0.04,
|
||||
estimatedSeconds: 6,
|
||||
});
|
||||
expect(() => repository.replayResponse(existing, 'other-hash')).toThrow(AppException);
|
||||
expect(() => repository.replayResponse({ ...existing, responseBody: null }, 'hash')).toThrow(
|
||||
AppException,
|
||||
);
|
||||
});
|
||||
|
||||
it('提交:无幂等记录时写入任务头 / 任务项 / progress 事件 / created outbox / 幂等键', async () => {
|
||||
const { repository, prisma } = harness();
|
||||
const result = await repository.submitTask(submitInput());
|
||||
expect(result).toEqual({
|
||||
kind: 'created',
|
||||
response: { taskId: TASK_ID, estimatedItems: 2, estimatedCost: 0.04, estimatedSeconds: 6 },
|
||||
});
|
||||
expect(prisma.mediaTask.create).toHaveBeenCalledTimes(1);
|
||||
const taskData = prisma.mediaTask.create?.mock.calls[0]?.[0] as {
|
||||
data: { status: string; estimatedCost: Prisma.Decimal; progress: number };
|
||||
};
|
||||
expect(taskData.data.status).toBe('queued');
|
||||
expect(taskData.data.progress).toBe(0);
|
||||
expect(taskData.data.estimatedCost.toNumber()).toBe(0.04);
|
||||
|
||||
const items = prisma.mediaItem.createMany?.mock.calls[0]?.[0] as {
|
||||
data: { id: string; seq: number; status: string }[];
|
||||
};
|
||||
expect(items.data).toEqual([
|
||||
{ id: ITEM_ID_1, taskId: TASK_ID, seq: 1, status: 'queued' },
|
||||
{ id: ITEM_ID_2, taskId: TASK_ID, seq: 2, status: 'queued' },
|
||||
]);
|
||||
expect(prisma.taskEvent.create).toHaveBeenCalledTimes(1);
|
||||
const event = prisma.taskEvent.create?.mock.calls[0]?.[0] as {
|
||||
data: { eventType: string; payloadJson: Record<string, unknown> };
|
||||
};
|
||||
expect(event.data.eventType).toBe('progress');
|
||||
expect(event.data.payloadJson).toMatchObject({ status: 'queued', progress: 0, itemTotal: 2 });
|
||||
|
||||
const outbox = prisma.outboxEvent.create?.mock.calls[0]?.[0] as {
|
||||
data: { eventType: string; payloadJson: Record<string, unknown> };
|
||||
};
|
||||
expect(outbox.data.eventType).toBe(TASK_OUTBOX_EVENTS.created);
|
||||
expect(outbox.data.payloadJson).toMatchObject({
|
||||
taskId: TASK_ID,
|
||||
type: 'demo',
|
||||
itemIds: [ITEM_ID_1, ITEM_ID_2],
|
||||
});
|
||||
expect(prisma.idempotencyKey.create).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('提交:24h 内已有同 key → 返回重放,不再建任务', async () => {
|
||||
const { repository, prisma } = harness();
|
||||
prisma.idempotencyKey.findUnique?.mockResolvedValueOnce({
|
||||
id: 'k1',
|
||||
requestHash: 'hash',
|
||||
responseBody: {
|
||||
taskId: TASK_ID,
|
||||
estimatedItems: 2,
|
||||
estimatedCost: 0.04,
|
||||
estimatedSeconds: 6,
|
||||
},
|
||||
expiresAt: new Date(NOW.getTime() + 60_000),
|
||||
});
|
||||
const result = await repository.submitTask(submitInput());
|
||||
expect(result.kind).toBe('replayed');
|
||||
expect(prisma.mediaTask.create).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('提交:过期幂等键先删除再按新请求重建', async () => {
|
||||
const { repository, prisma } = harness();
|
||||
prisma.idempotencyKey.findUnique?.mockResolvedValueOnce({
|
||||
id: 'k-old',
|
||||
requestHash: 'old',
|
||||
responseBody: null,
|
||||
expiresAt: new Date(NOW.getTime() - 1),
|
||||
});
|
||||
const result = await repository.submitTask(submitInput());
|
||||
expect(result.kind).toBe('created');
|
||||
expect(prisma.idempotencyKey.delete).toHaveBeenCalledWith({ where: { id: 'k-old' } });
|
||||
});
|
||||
|
||||
it('isUniqueViolation 只识别 Prisma P2002', () => {
|
||||
expect(
|
||||
isUniqueViolation(
|
||||
new Prisma.PrismaClientKnownRequestError('dup', {
|
||||
code: 'P2002',
|
||||
clientVersion: '6.19.3',
|
||||
}),
|
||||
),
|
||||
).toBe(true);
|
||||
expect(
|
||||
isUniqueViolation(
|
||||
new Prisma.PrismaClientKnownRequestError('fk', {
|
||||
code: 'P2003',
|
||||
clientVersion: '6.19.3',
|
||||
}),
|
||||
),
|
||||
).toBe(false);
|
||||
expect(isUniqueViolation(new Error('x'))).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('取消事务(只影响未开始项、无 running 项立即收敛)', () => {
|
||||
it('条件认领失败(并发 / 状态已变)→ 40903', async () => {
|
||||
const { repository, prisma } = harness();
|
||||
prisma.mediaTask.updateMany?.mockResolvedValueOnce({ count: 0 });
|
||||
await expectRejected(
|
||||
repository.cancelTask({ task: headerRow(), actorId: USER_ID, now: NOW }),
|
||||
ErrorCode.STATE_NOT_ALLOWED,
|
||||
);
|
||||
});
|
||||
|
||||
it('排队任务取消:queued 项置 skipped,立即 canceled + done 事件 + canceled outbox', async () => {
|
||||
const { repository, prisma } = harness();
|
||||
prisma.mediaItem.findMany?.mockResolvedValueOnce([
|
||||
{ id: ITEM_ID_1, seq: 1 },
|
||||
{ id: ITEM_ID_2, seq: 2 },
|
||||
]);
|
||||
prisma.mediaItem.groupBy?.mockResolvedValueOnce([{ status: 'skipped', _count: { _all: 2 } }]);
|
||||
prisma.mediaItem.count?.mockResolvedValueOnce(0);
|
||||
|
||||
const result = await repository.cancelTask({
|
||||
task: headerRow(),
|
||||
actorId: USER_ID,
|
||||
now: NOW,
|
||||
});
|
||||
expect(result.status).toBe('canceled');
|
||||
expect(result.skippedItems).toHaveLength(2);
|
||||
|
||||
const taskUpdate = prisma.mediaTask.update?.mock.calls[0]?.[0] as {
|
||||
data: Record<string, unknown>;
|
||||
};
|
||||
expect(taskUpdate.data).toMatchObject({
|
||||
status: 'canceled',
|
||||
itemSkipped: 2,
|
||||
progress: 100,
|
||||
canceledAt: NOW,
|
||||
finishedAt: NOW,
|
||||
});
|
||||
// 2 条 skip item 事件 + 1 条 progress + 1 条 done
|
||||
expect(prisma.taskEvent.createMany).toHaveBeenCalledTimes(1);
|
||||
expect(prisma.taskEvent.create).toHaveBeenCalledTimes(2);
|
||||
const doneEvent = prisma.taskEvent.create?.mock.calls[1]?.[0] as {
|
||||
data: { eventType: string; payloadJson: Record<string, unknown> };
|
||||
};
|
||||
expect(doneEvent.data.eventType).toBe('done');
|
||||
expect(doneEvent.data.payloadJson).toMatchObject({ status: 'canceled', progress: 100 });
|
||||
|
||||
const outbox = prisma.outboxEvent.create?.mock.calls[0]?.[0] as {
|
||||
data: { eventType: string; payloadJson: Record<string, unknown> };
|
||||
};
|
||||
expect(outbox.data.eventType).toBe(TASK_OUTBOX_EVENTS.canceled);
|
||||
expect(outbox.data.payloadJson).toMatchObject({ taskId: TASK_ID, canceledBy: USER_ID });
|
||||
});
|
||||
|
||||
it('有 running 项时不落终态、不写 canceled outbox(等 worker 收敛)', async () => {
|
||||
const { repository, prisma } = harness();
|
||||
prisma.mediaItem.findMany?.mockResolvedValueOnce([]);
|
||||
prisma.mediaItem.groupBy?.mockResolvedValueOnce([
|
||||
{ status: 'running', _count: { _all: 1 } },
|
||||
{ status: 'pending_review', _count: { _all: 1 } },
|
||||
]);
|
||||
prisma.mediaItem.count?.mockResolvedValueOnce(1);
|
||||
|
||||
const result = await repository.cancelTask({
|
||||
task: headerRow({ status: 'running' }),
|
||||
actorId: USER_ID,
|
||||
now: NOW,
|
||||
});
|
||||
expect(result.status).toBe('running');
|
||||
const taskUpdate = prisma.mediaTask.update?.mock.calls[0]?.[0] as {
|
||||
data: Record<string, unknown>;
|
||||
};
|
||||
expect(taskUpdate.data.status).toBeUndefined();
|
||||
expect(prisma.taskEvent.create).toHaveBeenCalledTimes(1);
|
||||
expect(prisma.outboxEvent.create).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe('重试事务(只重置失败项,成功项不动)', () => {
|
||||
it('非部分失败 / 没有失败项 → 40903', async () => {
|
||||
const { repository, prisma } = harness();
|
||||
prisma.mediaTask.updateMany?.mockResolvedValueOnce({ count: 0 });
|
||||
await expectRejected(
|
||||
repository.retryTask(headerRow({ status: 'running' }), NOW),
|
||||
ErrorCode.STATE_NOT_ALLOWED,
|
||||
);
|
||||
|
||||
prisma.mediaTask.updateMany?.mockResolvedValueOnce({ count: 1 });
|
||||
prisma.mediaItem.findMany?.mockResolvedValueOnce([]);
|
||||
await expectRejected(
|
||||
repository.retryTask(headerRow({ status: 'partial_failed' }), NOW),
|
||||
ErrorCode.STATE_NOT_ALLOWED,
|
||||
);
|
||||
});
|
||||
|
||||
it('只把 failed 项重置为 queued(attempts 保留),写 progress 事件 + retry outbox', async () => {
|
||||
const { repository, prisma } = harness();
|
||||
prisma.mediaItem.findMany?.mockResolvedValueOnce([{ id: ITEM_ID_1, seq: 1 }]);
|
||||
prisma.mediaItem.groupBy?.mockResolvedValueOnce([
|
||||
{ status: 'queued', _count: { _all: 1 } },
|
||||
{ status: 'pending_review', _count: { _all: 1 } },
|
||||
]);
|
||||
|
||||
const result = await repository.retryTask(headerRow({ status: 'partial_failed' }), NOW);
|
||||
expect(result).toEqual({ status: 'queued', retriedItems: 1 });
|
||||
|
||||
const taskUpdate = prisma.mediaTask.updateMany?.mock.calls[0]?.[0] as {
|
||||
where: Record<string, unknown>;
|
||||
data: Record<string, unknown>;
|
||||
};
|
||||
expect(taskUpdate.where).toEqual({ id: TASK_ID, status: 'partial_failed' });
|
||||
expect(taskUpdate.data).toMatchObject({ status: 'queued', finishedAt: null });
|
||||
|
||||
const itemReset = prisma.mediaItem.updateMany?.mock.calls[0]?.[0] as {
|
||||
where: Record<string, unknown>;
|
||||
data: Record<string, unknown>;
|
||||
};
|
||||
expect(itemReset.where).toEqual({ taskId: TASK_ID, status: 'failed' });
|
||||
expect(itemReset.data).toMatchObject({ status: 'queued', failureCode: null });
|
||||
// attempts 不在重置字段中(保留尝试次数)
|
||||
expect(itemReset.data).not.toHaveProperty('attempts');
|
||||
|
||||
const outbox = prisma.outboxEvent.create?.mock.calls[0]?.[0] as {
|
||||
data: { eventType: string; payloadJson: Record<string, unknown> };
|
||||
};
|
||||
expect(outbox.data.eventType).toBe(TASK_OUTBOX_EVENTS.retry);
|
||||
expect(outbox.data.payloadJson).toMatchObject({ itemIds: [ITEM_ID_1] });
|
||||
});
|
||||
});
|
||||
|
||||
describe('SSE 查询与 Outbox 结果消费数据访问', () => {
|
||||
it('SSE:任务可见性 / 增量事件 / 状态轮询', async () => {
|
||||
const { repository, prisma } = harness();
|
||||
await repository.findTaskForStream(TASK_ID, SCOPE);
|
||||
expect(prisma.mediaTask.findFirst?.mock.calls[0]?.[0]).toMatchObject({
|
||||
where: { id: TASK_ID, customerId: { in: [CUSTOMER_ID] } },
|
||||
});
|
||||
|
||||
await repository.findTaskEventsAfter(TASK_ID, 5n, 200);
|
||||
expect(prisma.taskEvent.findMany?.mock.calls[0]?.[0]).toMatchObject({
|
||||
where: { taskId: TASK_ID, seq: { gt: 5n } },
|
||||
orderBy: { seq: 'asc' },
|
||||
take: 200,
|
||||
});
|
||||
|
||||
await repository.findTaskStreamStatus(TASK_ID);
|
||||
expect(prisma.mediaTask.findUnique?.mock.calls[0]?.[0]).toMatchObject({
|
||||
where: { id: TASK_ID },
|
||||
});
|
||||
});
|
||||
|
||||
it('结果消费:只查本地事件类型 + 未投递;认领成功 / 失败;回滚与放弃', async () => {
|
||||
const { repository, prisma } = harness();
|
||||
await repository.findPendingResultEvents(10);
|
||||
const findArgs = prisma.outboxEvent.findMany?.mock.calls[0]?.[0] as {
|
||||
where: { eventType: { in: string[] }; publishedAt: null };
|
||||
take: number;
|
||||
};
|
||||
expect(findArgs.take).toBe(10);
|
||||
expect(findArgs.where.publishedAt).toBeNull();
|
||||
expect(findArgs.where.eventType.in).toEqual([
|
||||
TASK_OUTBOX_EVENTS.finished,
|
||||
TASK_OUTBOX_EVENTS.failed,
|
||||
]);
|
||||
|
||||
prisma.outboxEvent.updateMany?.mockResolvedValueOnce({ count: 1 });
|
||||
await expect(repository.claimResultEvent('evt-1')).resolves.toBe(true);
|
||||
expect(prisma.outboxEvent.updateMany?.mock.calls[0]?.[0]).toMatchObject({
|
||||
where: { id: 'evt-1', publishedAt: null },
|
||||
data: { attempts: { increment: 1 } },
|
||||
});
|
||||
|
||||
prisma.outboxEvent.updateMany?.mockResolvedValueOnce({ count: 0 });
|
||||
await expect(repository.claimResultEvent('evt-1')).resolves.toBe(false);
|
||||
|
||||
await repository.markResultEventRetry('evt-1', 'x'.repeat(1200));
|
||||
const retryArgs = prisma.outboxEvent.updateMany?.mock.calls[2]?.[0] as {
|
||||
data: { publishedAt: null; lastError: string };
|
||||
};
|
||||
expect(retryArgs.data.publishedAt).toBeNull();
|
||||
expect(retryArgs.data.lastError).toHaveLength(1000);
|
||||
|
||||
await repository.markResultEventExhausted('evt-1', 'boom');
|
||||
expect(prisma.outboxEvent.updateMany?.mock.calls[3]?.[0]).toMatchObject({
|
||||
where: { id: 'evt-1' },
|
||||
data: { lastError: 'boom' },
|
||||
});
|
||||
|
||||
await repository.findTaskForResult(TASK_ID);
|
||||
expect(prisma.mediaTask.findUnique?.mock.calls[0]?.[0]).toMatchObject({
|
||||
where: { id: TASK_ID },
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,660 @@
|
||||
/* eslint-disable max-lines-per-function -- 任务事务(提交 / 取消 / 重试)必须保持单事务边界,拆散会丢失原子性 */
|
||||
/**
|
||||
* 任务中心数据访问(A6-BE-01 / A6-BE-03 / A6-BE-04)
|
||||
*
|
||||
* 约束(AGENTS §3.5 / DB-05):
|
||||
* - 面向用户的读写方法都接收 `ScopeContext` 并把 `customer_id` 过滤注入 SQL where;
|
||||
* - 禁止「先查全量再内存过滤」;范围外单条读取返回 null,由 Service 统一映射 404(不泄露存在性);
|
||||
* - 写操作(提交 / 取消 / 重试)在单事务内完成「任务头 + 任务项 + 任务事件 + outbox + 幂等键」,
|
||||
* 事务回滚不产生事件(技术架构 §3.7);
|
||||
* - 结果消费者是系统身份(无用户 ScopeContext),按 outbox 事件里的 aggregateId 精确读取,
|
||||
* 只用于写审计,不对外暴露数据。
|
||||
*/
|
||||
import {
|
||||
ErrorCode,
|
||||
type MediaTaskListQuery,
|
||||
type MediaTaskSubmitResponse,
|
||||
mediaTaskSubmitResponseSchema,
|
||||
TASK_LOCAL_EVENT_TYPES,
|
||||
TASK_OUTBOX_EVENTS,
|
||||
type TaskCounters,
|
||||
taskProgress,
|
||||
type TaskStatus,
|
||||
type TaskType,
|
||||
ulid,
|
||||
} from '@lingxi/shared';
|
||||
import { Inject, Injectable } from '@nestjs/common';
|
||||
import { type IdempotencyKey, Prisma } from '@prisma/client';
|
||||
|
||||
import { AppException } from '../../common/errors/app-exception';
|
||||
import { toPrismaJsonValue } from '../../common/security/audit-sanitize';
|
||||
import { customerScopeWhere, type ScopeContext } from '../../common/security/scope/scope-context';
|
||||
import type { PrismaService } from '../../infra/prisma/prisma.service';
|
||||
import { PRISMA } from '../../infra/tokens';
|
||||
import {
|
||||
MEDIA_ITEM_SELECT,
|
||||
MEDIA_TASK_DETAIL_SELECT,
|
||||
MEDIA_TASK_SUMMARY_SELECT,
|
||||
type MediaItemRow,
|
||||
type MediaTaskDetailRow,
|
||||
type MediaTaskSummaryRow,
|
||||
} from './media-task.mapper';
|
||||
import type { MediaTaskCursor } from './media-task-cursor';
|
||||
|
||||
/** Prisma 唯一约束冲突(幂等键并发提交时的兜底分支) */
|
||||
export function isUniqueViolation(error: unknown): boolean {
|
||||
return error instanceof Prisma.PrismaClientKnownRequestError && error.code === 'P2002';
|
||||
}
|
||||
|
||||
/** 取消 / 重试需要的最小任务头(不含 payload 大字段) */
|
||||
export const MEDIA_TASK_HEADER_SELECT = {
|
||||
id: true,
|
||||
type: true,
|
||||
status: true,
|
||||
title: true,
|
||||
customerId: true,
|
||||
customerName: true,
|
||||
createdBy: true,
|
||||
createdByName: true,
|
||||
itemTotal: true,
|
||||
} satisfies Prisma.MediaTaskSelect;
|
||||
|
||||
export type MediaTaskHeaderRow = Prisma.MediaTaskGetPayload<{
|
||||
select: typeof MEDIA_TASK_HEADER_SELECT;
|
||||
}>;
|
||||
|
||||
/** 结果审计需要的最小任务头 */
|
||||
export const MEDIA_TASK_RESULT_SELECT = {
|
||||
id: true,
|
||||
type: true,
|
||||
status: true,
|
||||
title: true,
|
||||
customerId: true,
|
||||
customerName: true,
|
||||
createdBy: true,
|
||||
createdByName: true,
|
||||
progress: true,
|
||||
lastErrorCode: true,
|
||||
lastErrorMessage: true,
|
||||
} satisfies Prisma.MediaTaskSelect;
|
||||
|
||||
export type MediaTaskResultRow = Prisma.MediaTaskGetPayload<{
|
||||
select: typeof MEDIA_TASK_RESULT_SELECT;
|
||||
}>;
|
||||
|
||||
export interface MediaTaskSubmitTransactionInput {
|
||||
task: {
|
||||
id: string;
|
||||
type: TaskType;
|
||||
title: string;
|
||||
customerId: string;
|
||||
customerName: string;
|
||||
createdBy: string;
|
||||
createdByName: string;
|
||||
itemTotal: number;
|
||||
estimatedItems: number;
|
||||
estimatedCost: number;
|
||||
estimatedSeconds: number;
|
||||
payload: Record<string, unknown>;
|
||||
};
|
||||
itemIds: readonly string[];
|
||||
idempotency: {
|
||||
id: string;
|
||||
scope: string;
|
||||
key: string;
|
||||
requestHash: string;
|
||||
response: MediaTaskSubmitResponse;
|
||||
expiresAt: Date;
|
||||
};
|
||||
traceId: string | null;
|
||||
now: Date;
|
||||
}
|
||||
|
||||
export type MediaTaskSubmitTransactionResult =
|
||||
| { kind: 'created'; response: MediaTaskSubmitResponse }
|
||||
| { kind: 'replayed'; response: MediaTaskSubmitResponse };
|
||||
|
||||
export interface CancelTaskInput {
|
||||
task: MediaTaskHeaderRow;
|
||||
actorId: string;
|
||||
now: Date;
|
||||
}
|
||||
|
||||
export interface CancelTaskResult {
|
||||
status: TaskStatus;
|
||||
/** 被置为 skipped 的任务项(用于 SSE item 事件与审计 changes) */
|
||||
skippedItems: readonly { id: string; seq: number }[];
|
||||
}
|
||||
|
||||
export interface RetryTaskResult {
|
||||
status: TaskStatus;
|
||||
retriedItems: number;
|
||||
}
|
||||
|
||||
export interface PendingResultEvent {
|
||||
id: string;
|
||||
eventType: string;
|
||||
aggregateId: string;
|
||||
payloadJson: Prisma.JsonValue;
|
||||
attempts: number;
|
||||
traceId: string | null;
|
||||
}
|
||||
|
||||
export interface StreamTaskRow {
|
||||
id: string;
|
||||
status: string;
|
||||
progress: number;
|
||||
}
|
||||
|
||||
export interface StreamEventRow {
|
||||
seq: bigint;
|
||||
eventType: string;
|
||||
payloadJson: Prisma.JsonValue;
|
||||
}
|
||||
|
||||
const ACTIVE_STATUSES: readonly string[] = ['queued', 'running'];
|
||||
|
||||
function countersFromGroups(
|
||||
groups: readonly { status: string; _count: { _all: number } }[],
|
||||
itemTotal: number,
|
||||
): TaskCounters {
|
||||
let succeeded = 0;
|
||||
let failed = 0;
|
||||
let skipped = 0;
|
||||
for (const group of groups) {
|
||||
if (group.status === 'pending_review') succeeded += group._count._all;
|
||||
else if (group.status === 'failed') failed += group._count._all;
|
||||
else if (group.status === 'skipped') skipped += group._count._all;
|
||||
}
|
||||
return { itemTotal, itemSucceeded: succeeded, itemFailed: failed, itemSkipped: skipped };
|
||||
}
|
||||
|
||||
function keysetWhere(cursor: MediaTaskCursor): Prisma.MediaTaskWhereInput {
|
||||
return {
|
||||
OR: [{ createdAt: { lt: cursor.at } }, { createdAt: cursor.at, id: { lt: cursor.id } }],
|
||||
};
|
||||
}
|
||||
|
||||
function listConditions(
|
||||
query: MediaTaskListQuery,
|
||||
scope: ScopeContext,
|
||||
actorId: string,
|
||||
cursor: MediaTaskCursor | null,
|
||||
): Prisma.MediaTaskWhereInput[] {
|
||||
const conditions: Prisma.MediaTaskWhereInput[] = [{ ...customerScopeWhere(scope) }];
|
||||
if (query.type !== undefined) conditions.push({ type: query.type });
|
||||
if (query.status !== undefined) conditions.push({ status: query.status });
|
||||
if (query.customer_id !== undefined) conditions.push({ customerId: query.customer_id });
|
||||
if (query.mine === 'true') conditions.push({ createdBy: actorId });
|
||||
if (query.keyword !== undefined) {
|
||||
conditions.push({
|
||||
OR: [
|
||||
{ title: { contains: query.keyword, mode: 'insensitive' } },
|
||||
{ customerName: { contains: query.keyword, mode: 'insensitive' } },
|
||||
],
|
||||
});
|
||||
}
|
||||
const createdAt: Prisma.DateTimeFilter = {};
|
||||
if (query.created_from !== undefined) createdAt.gte = new Date(query.created_from);
|
||||
if (query.created_to !== undefined) createdAt.lte = new Date(query.created_to);
|
||||
if (createdAt.gte !== undefined || createdAt.lte !== undefined) {
|
||||
conditions.push({ createdAt });
|
||||
}
|
||||
if (cursor !== null) conditions.push(keysetWhere(cursor));
|
||||
return conditions;
|
||||
}
|
||||
|
||||
@Injectable()
|
||||
export class MediaTaskRepository {
|
||||
constructor(@Inject(PRISMA) private readonly prisma: PrismaService) {}
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
// 列表 / 详情(用户范围)
|
||||
// -------------------------------------------------------------------------
|
||||
|
||||
async listTasks(
|
||||
query: MediaTaskListQuery,
|
||||
scope: ScopeContext,
|
||||
actorId: string,
|
||||
cursor: MediaTaskCursor | null,
|
||||
): Promise<MediaTaskSummaryRow[]> {
|
||||
return this.prisma.mediaTask.findMany({
|
||||
where: { AND: listConditions(query, scope, actorId, cursor) },
|
||||
select: MEDIA_TASK_SUMMARY_SELECT,
|
||||
orderBy: [{ createdAt: 'desc' }, { id: 'desc' }],
|
||||
take: query.limit + 1,
|
||||
});
|
||||
}
|
||||
|
||||
async findTaskDetail(
|
||||
id: string,
|
||||
scope: ScopeContext,
|
||||
itemCursorSeq: number | null,
|
||||
itemLimit: number,
|
||||
): Promise<{ task: MediaTaskDetailRow; items: MediaItemRow[]; hasMore: boolean } | null> {
|
||||
const task = await this.prisma.mediaTask.findFirst({
|
||||
where: { id, ...customerScopeWhere(scope) },
|
||||
select: MEDIA_TASK_DETAIL_SELECT,
|
||||
});
|
||||
if (task === null) return null;
|
||||
const items = await this.prisma.mediaItem.findMany({
|
||||
where: { taskId: id, ...(itemCursorSeq === null ? {} : { seq: { gt: itemCursorSeq } }) },
|
||||
select: MEDIA_ITEM_SELECT,
|
||||
orderBy: { seq: 'asc' },
|
||||
take: itemLimit + 1,
|
||||
});
|
||||
return { task, items: items.slice(0, itemLimit), hasMore: items.length > itemLimit };
|
||||
}
|
||||
|
||||
async findVisibleTaskHeader(id: string, scope: ScopeContext): Promise<MediaTaskHeaderRow | null> {
|
||||
return this.prisma.mediaTask.findFirst({
|
||||
where: { id, ...customerScopeWhere(scope) },
|
||||
select: MEDIA_TASK_HEADER_SELECT,
|
||||
});
|
||||
}
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
// 幂等键(唯一键 scope + key;24h 过期视为无记录)
|
||||
// -------------------------------------------------------------------------
|
||||
|
||||
async findIdempotencyByKey(scope: string, key: string): Promise<IdempotencyKey | null> {
|
||||
return this.prisma.idempotencyKey.findUnique({ where: { scope_key: { scope, key } } });
|
||||
}
|
||||
|
||||
/**
|
||||
* 幂等重放:同 key 同请求体返回首次响应;同 key 不同请求体 40902。
|
||||
* 响应体缺失(异常中断留在半途)时提示重试,不返回半成品。
|
||||
*/
|
||||
replayResponse(existing: IdempotencyKey, requestHash: string): MediaTaskSubmitResponse {
|
||||
if (existing.requestHash !== requestHash) {
|
||||
throw new AppException({
|
||||
code: ErrorCode.DUPLICATE_SUBMIT,
|
||||
message: '同一 Idempotency-Key 的请求内容与首次提交不一致',
|
||||
});
|
||||
}
|
||||
const parsed = mediaTaskSubmitResponseSchema.safeParse(existing.responseBody);
|
||||
if (!parsed.success) {
|
||||
throw new AppException({
|
||||
code: ErrorCode.DUPLICATE_SUBMIT,
|
||||
message: '同一 Idempotency-Key 的请求正在处理中,请稍后重试',
|
||||
});
|
||||
}
|
||||
return parsed.data;
|
||||
}
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
// 提交(任务头 + 任务项 + progress 事件 + outbox + 幂等键,单事务)
|
||||
// -------------------------------------------------------------------------
|
||||
|
||||
async submitTask(
|
||||
input: MediaTaskSubmitTransactionInput,
|
||||
): Promise<MediaTaskSubmitTransactionResult> {
|
||||
const { task, idempotency, now } = input;
|
||||
return this.prisma.$transaction(async (tx) => {
|
||||
const existing = await tx.idempotencyKey.findUnique({
|
||||
where: { scope_key: { scope: idempotency.scope, key: idempotency.key } },
|
||||
});
|
||||
if (existing !== null) {
|
||||
if (existing.expiresAt.getTime() > now.getTime()) {
|
||||
return {
|
||||
kind: 'replayed',
|
||||
response: this.replayResponse(existing, idempotency.requestHash),
|
||||
};
|
||||
}
|
||||
// 过期键:删掉后按新请求重建(唯一约束是 scope + key)
|
||||
await tx.idempotencyKey.delete({ where: { id: existing.id } });
|
||||
}
|
||||
|
||||
await tx.mediaTask.create({
|
||||
data: {
|
||||
id: task.id,
|
||||
type: task.type,
|
||||
status: 'queued',
|
||||
title: task.title,
|
||||
customerId: task.customerId,
|
||||
customerName: task.customerName,
|
||||
createdBy: task.createdBy,
|
||||
createdByName: task.createdByName,
|
||||
itemTotal: task.itemTotal,
|
||||
estimatedItems: task.estimatedItems,
|
||||
estimatedCost: new Prisma.Decimal(task.estimatedCost.toFixed(2)),
|
||||
estimatedSeconds: task.estimatedSeconds,
|
||||
progress: 0,
|
||||
payloadJson: toPrismaJsonValue(task.payload),
|
||||
createdAt: now,
|
||||
updatedAt: now,
|
||||
},
|
||||
});
|
||||
await tx.mediaItem.createMany({
|
||||
data: input.itemIds.map((itemId, index) => ({
|
||||
id: itemId,
|
||||
taskId: task.id,
|
||||
seq: index + 1,
|
||||
status: 'queued',
|
||||
})),
|
||||
});
|
||||
await tx.taskEvent.create({
|
||||
data: {
|
||||
id: ulid(),
|
||||
taskId: task.id,
|
||||
eventType: 'progress',
|
||||
payloadJson: toPrismaJsonValue({
|
||||
taskId: task.id,
|
||||
status: 'queued',
|
||||
progress: 0,
|
||||
itemTotal: task.itemTotal,
|
||||
itemSucceeded: 0,
|
||||
itemFailed: 0,
|
||||
itemSkipped: 0,
|
||||
}),
|
||||
},
|
||||
});
|
||||
await tx.outboxEvent.create({
|
||||
data: {
|
||||
id: ulid(),
|
||||
aggregateType: 'media_task',
|
||||
aggregateId: task.id,
|
||||
eventType: TASK_OUTBOX_EVENTS.created,
|
||||
payloadJson: toPrismaJsonValue({
|
||||
taskId: task.id,
|
||||
type: task.type,
|
||||
itemIds: [...input.itemIds],
|
||||
}),
|
||||
traceId: input.traceId,
|
||||
},
|
||||
});
|
||||
await tx.idempotencyKey.create({
|
||||
data: {
|
||||
id: idempotency.id,
|
||||
scope: idempotency.scope,
|
||||
key: idempotency.key,
|
||||
requestHash: idempotency.requestHash,
|
||||
responseStatus: 202,
|
||||
responseBody: toPrismaJsonValue(idempotency.response),
|
||||
expiresAt: idempotency.expiresAt,
|
||||
},
|
||||
});
|
||||
return { kind: 'created', response: idempotency.response };
|
||||
});
|
||||
}
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
// 取消:只影响未开始项;无 running 项时立即收敛为 canceled
|
||||
// -------------------------------------------------------------------------
|
||||
|
||||
async cancelTask(input: CancelTaskInput): Promise<CancelTaskResult> {
|
||||
const { task, actorId, now } = input;
|
||||
return this.prisma.$transaction(async (tx) => {
|
||||
const claimed = await tx.mediaTask.updateMany({
|
||||
where: { id: task.id, status: { in: [...ACTIVE_STATUSES] }, cancelRequestedAt: null },
|
||||
data: { cancelRequestedAt: now, canceledBy: actorId, updatedAt: now },
|
||||
});
|
||||
if (claimed.count === 0) {
|
||||
throw new AppException({
|
||||
code: ErrorCode.STATE_NOT_ALLOWED,
|
||||
message: '当前状态不允许取消(已取消或已结束)',
|
||||
});
|
||||
}
|
||||
const queuedItems = await tx.mediaItem.findMany({
|
||||
where: { taskId: task.id, status: 'queued' },
|
||||
select: { id: true, seq: true },
|
||||
});
|
||||
if (queuedItems.length > 0) {
|
||||
await tx.mediaItem.updateMany({
|
||||
where: { taskId: task.id, status: 'queued' },
|
||||
data: { status: 'skipped', finishedAt: now, updatedAt: now },
|
||||
});
|
||||
}
|
||||
const groups = await tx.mediaItem.groupBy({
|
||||
by: ['status'],
|
||||
where: { taskId: task.id },
|
||||
_count: { _all: true },
|
||||
});
|
||||
const runningCount = await tx.mediaItem.count({
|
||||
where: { taskId: task.id, status: 'running' },
|
||||
});
|
||||
const counters = countersFromGroups(groups, task.itemTotal);
|
||||
const finished = runningCount === 0;
|
||||
const status: TaskStatus = finished ? 'canceled' : 'running';
|
||||
const progress = taskProgress(counters);
|
||||
await tx.mediaTask.update({
|
||||
where: { id: task.id },
|
||||
data: {
|
||||
itemSucceeded: counters.itemSucceeded,
|
||||
itemFailed: counters.itemFailed,
|
||||
itemSkipped: counters.itemSkipped,
|
||||
progress,
|
||||
...(finished ? { status, canceledAt: now, finishedAt: now } : {}),
|
||||
updatedAt: now,
|
||||
},
|
||||
});
|
||||
if (queuedItems.length > 0) {
|
||||
await tx.taskEvent.createMany({
|
||||
data: queuedItems.map((item) => ({
|
||||
id: ulid(),
|
||||
taskId: task.id,
|
||||
eventType: 'item',
|
||||
payloadJson: toPrismaJsonValue({
|
||||
taskId: task.id,
|
||||
itemId: item.id,
|
||||
seq: item.seq,
|
||||
status: 'skipped',
|
||||
}),
|
||||
})),
|
||||
});
|
||||
}
|
||||
await tx.taskEvent.create({
|
||||
data: {
|
||||
id: ulid(),
|
||||
taskId: task.id,
|
||||
eventType: 'progress',
|
||||
payloadJson: toPrismaJsonValue({
|
||||
taskId: task.id,
|
||||
status,
|
||||
progress,
|
||||
itemTotal: counters.itemTotal,
|
||||
itemSucceeded: counters.itemSucceeded,
|
||||
itemFailed: counters.itemFailed,
|
||||
itemSkipped: counters.itemSkipped,
|
||||
}),
|
||||
},
|
||||
});
|
||||
if (finished) {
|
||||
await tx.taskEvent.create({
|
||||
data: {
|
||||
id: ulid(),
|
||||
taskId: task.id,
|
||||
eventType: 'done',
|
||||
payloadJson: toPrismaJsonValue({ taskId: task.id, status, progress }),
|
||||
},
|
||||
});
|
||||
await tx.outboxEvent.create({
|
||||
data: {
|
||||
id: ulid(),
|
||||
aggregateType: 'media_task',
|
||||
aggregateId: task.id,
|
||||
eventType: TASK_OUTBOX_EVENTS.canceled,
|
||||
payloadJson: toPrismaJsonValue({
|
||||
taskId: task.id,
|
||||
type: task.type,
|
||||
canceledBy: actorId,
|
||||
}),
|
||||
},
|
||||
});
|
||||
}
|
||||
return { status, skippedItems: queuedItems };
|
||||
});
|
||||
}
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
// 重试:只重置失败项(成功项不动、attempts 保留),任务回 queued
|
||||
// -------------------------------------------------------------------------
|
||||
|
||||
async retryTask(task: MediaTaskHeaderRow, now: Date): Promise<RetryTaskResult> {
|
||||
return this.prisma.$transaction(async (tx) => {
|
||||
const claimed = await tx.mediaTask.updateMany({
|
||||
where: { id: task.id, status: 'partial_failed' },
|
||||
data: {
|
||||
status: 'queued',
|
||||
lastErrorCode: null,
|
||||
lastErrorMessage: null,
|
||||
finishedAt: null,
|
||||
updatedAt: now,
|
||||
},
|
||||
});
|
||||
if (claimed.count === 0) {
|
||||
throw new AppException({
|
||||
code: ErrorCode.STATE_NOT_ALLOWED,
|
||||
message: '仅部分失败的任务可重试',
|
||||
});
|
||||
}
|
||||
const failedItems = await tx.mediaItem.findMany({
|
||||
where: { taskId: task.id, status: 'failed' },
|
||||
select: { id: true, seq: true },
|
||||
orderBy: { seq: 'asc' },
|
||||
});
|
||||
if (failedItems.length === 0) {
|
||||
throw new AppException({
|
||||
code: ErrorCode.STATE_NOT_ALLOWED,
|
||||
message: '没有可重试的失败项',
|
||||
});
|
||||
}
|
||||
await tx.mediaItem.updateMany({
|
||||
where: { taskId: task.id, status: 'failed' },
|
||||
data: {
|
||||
status: 'queued',
|
||||
failureCode: null,
|
||||
failureMessage: null,
|
||||
startedAt: null,
|
||||
finishedAt: null,
|
||||
updatedAt: now,
|
||||
},
|
||||
});
|
||||
const groups = await tx.mediaItem.groupBy({
|
||||
by: ['status'],
|
||||
where: { taskId: task.id },
|
||||
_count: { _all: true },
|
||||
});
|
||||
const counters = countersFromGroups(groups, task.itemTotal);
|
||||
const progress = taskProgress(counters);
|
||||
await tx.mediaTask.update({
|
||||
where: { id: task.id },
|
||||
data: {
|
||||
itemSucceeded: counters.itemSucceeded,
|
||||
itemFailed: counters.itemFailed,
|
||||
itemSkipped: counters.itemSkipped,
|
||||
progress,
|
||||
updatedAt: now,
|
||||
},
|
||||
});
|
||||
await tx.taskEvent.create({
|
||||
data: {
|
||||
id: ulid(),
|
||||
taskId: task.id,
|
||||
eventType: 'progress',
|
||||
payloadJson: toPrismaJsonValue({
|
||||
taskId: task.id,
|
||||
status: 'queued',
|
||||
progress,
|
||||
itemTotal: counters.itemTotal,
|
||||
itemSucceeded: counters.itemSucceeded,
|
||||
itemFailed: counters.itemFailed,
|
||||
itemSkipped: counters.itemSkipped,
|
||||
}),
|
||||
},
|
||||
});
|
||||
await tx.outboxEvent.create({
|
||||
data: {
|
||||
id: ulid(),
|
||||
aggregateType: 'media_task',
|
||||
aggregateId: task.id,
|
||||
eventType: TASK_OUTBOX_EVENTS.retry,
|
||||
payloadJson: toPrismaJsonValue({
|
||||
taskId: task.id,
|
||||
type: task.type,
|
||||
itemIds: failedItems.map((item) => item.id),
|
||||
}),
|
||||
},
|
||||
});
|
||||
return { status: 'queued', retriedItems: failedItems.length };
|
||||
});
|
||||
}
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
// SSE:事件增量 + 任务状态(轮询兜底依赖 DB,不依赖内存)
|
||||
// -------------------------------------------------------------------------
|
||||
|
||||
async findTaskForStream(id: string, scope: ScopeContext): Promise<StreamTaskRow | null> {
|
||||
const row = await this.prisma.mediaTask.findFirst({
|
||||
where: { id, ...customerScopeWhere(scope) },
|
||||
select: { id: true, status: true, progress: true },
|
||||
});
|
||||
return row;
|
||||
}
|
||||
|
||||
async findTaskEventsAfter(
|
||||
taskId: string,
|
||||
afterSeq: bigint,
|
||||
take: number,
|
||||
): Promise<StreamEventRow[]> {
|
||||
return this.prisma.taskEvent.findMany({
|
||||
where: { taskId, seq: { gt: afterSeq } },
|
||||
select: { seq: true, eventType: true, payloadJson: true },
|
||||
orderBy: { seq: 'asc' },
|
||||
take,
|
||||
});
|
||||
}
|
||||
|
||||
async findTaskStreamStatus(id: string): Promise<StreamTaskRow | null> {
|
||||
return this.prisma.mediaTask.findUnique({
|
||||
where: { id },
|
||||
select: { id: true, status: true, progress: true },
|
||||
});
|
||||
}
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
// 结果消费者:只认 media.task.finished / failed,条件 UPDATE 认领避免多实例重复
|
||||
// -------------------------------------------------------------------------
|
||||
|
||||
async findPendingResultEvents(limit: number): Promise<PendingResultEvent[]> {
|
||||
return this.prisma.outboxEvent.findMany({
|
||||
where: { eventType: { in: [...TASK_LOCAL_EVENT_TYPES] }, publishedAt: null },
|
||||
select: {
|
||||
id: true,
|
||||
eventType: true,
|
||||
aggregateId: true,
|
||||
payloadJson: true,
|
||||
attempts: true,
|
||||
traceId: true,
|
||||
},
|
||||
orderBy: { occurredAt: 'asc' },
|
||||
take: limit,
|
||||
});
|
||||
}
|
||||
|
||||
async claimResultEvent(id: string): Promise<boolean> {
|
||||
const claimed = await this.prisma.outboxEvent.updateMany({
|
||||
where: { id, publishedAt: null },
|
||||
data: { publishedAt: new Date(), attempts: { increment: 1 } },
|
||||
});
|
||||
return claimed.count === 1;
|
||||
}
|
||||
|
||||
async markResultEventRetry(id: string, errorMessage: string): Promise<void> {
|
||||
await this.prisma.outboxEvent.updateMany({
|
||||
where: { id },
|
||||
data: { publishedAt: null, lastError: errorMessage.slice(0, 1000) },
|
||||
});
|
||||
}
|
||||
|
||||
async markResultEventExhausted(id: string, errorMessage: string): Promise<void> {
|
||||
await this.prisma.outboxEvent.updateMany({
|
||||
where: { id },
|
||||
data: { lastError: errorMessage.slice(0, 1000) },
|
||||
});
|
||||
}
|
||||
|
||||
async findTaskForResult(id: string): Promise<MediaTaskResultRow | null> {
|
||||
return this.prisma.mediaTask.findUnique({ where: { id }, select: MEDIA_TASK_RESULT_SELECT });
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,404 @@
|
||||
import { ErrorCode, type MediaTaskSubmitParsed } from '@lingxi/shared';
|
||||
import { Prisma } from '@prisma/client';
|
||||
import { describe, expect, it, vi } from 'vitest';
|
||||
|
||||
import { AppException } from '../../common/errors/app-exception';
|
||||
import type { AuthContext } from '../../common/security/auth.types';
|
||||
import type { RequestContextService } from '../../common/security/request-context';
|
||||
import type { ScopeContext } from '../../common/security/scope/scope-context';
|
||||
import type { CustomersService } from '../client/customers.service';
|
||||
import type { MediaItemRow, MediaTaskDetailRow, MediaTaskSummaryRow } from './media-task.mapper';
|
||||
import type { MediaTaskRepository } from './media-task.repository';
|
||||
import { MediaTaskService } from './media-task.service';
|
||||
import type { MediaTaskEstimator } from './media-task-estimator';
|
||||
import { hashIdempotencyRequest, MEDIA_TASK_SUBMIT_SCOPE } from './media-task-idempotency';
|
||||
|
||||
const NOW = new Date('2026-11-04T12:00:00.000Z');
|
||||
const TASK_ID = '01J9Z000000000000000000001';
|
||||
const CUSTOMER_ID = '01J9Z000000000000000000002';
|
||||
const USER_ID = '01J9Z000000000000000000003';
|
||||
|
||||
const AUTH: AuthContext = {
|
||||
userId: USER_ID,
|
||||
account: 'chen.mo',
|
||||
name: '陈墨',
|
||||
status: 'active',
|
||||
roles: [],
|
||||
permissions: [],
|
||||
dataScopes: [],
|
||||
departmentIds: [],
|
||||
isSuperAdmin: false,
|
||||
mustChangePassword: false,
|
||||
};
|
||||
|
||||
const SCOPE: ScopeContext = {
|
||||
userId: USER_ID,
|
||||
isSuperAdmin: false,
|
||||
dataScopes: [],
|
||||
customerIds: [CUSTOMER_ID],
|
||||
};
|
||||
|
||||
const SUBMIT_INPUT: MediaTaskSubmitParsed = {
|
||||
type: 'demo',
|
||||
customerId: CUSTOMER_ID,
|
||||
itemCount: 2,
|
||||
mode: 'mixed',
|
||||
};
|
||||
|
||||
const RESPONSE = { taskId: TASK_ID, estimatedItems: 2, estimatedCost: 0.04, estimatedSeconds: 6 };
|
||||
|
||||
function summaryRow(overrides: Record<string, unknown> = {}): MediaTaskSummaryRow {
|
||||
return {
|
||||
id: TASK_ID,
|
||||
type: 'demo',
|
||||
status: 'queued',
|
||||
title: '演练任务 · 花间集 · 2 项',
|
||||
customerId: CUSTOMER_ID,
|
||||
customerName: '花间集',
|
||||
itemTotal: 2,
|
||||
itemSucceeded: 0,
|
||||
itemFailed: 0,
|
||||
itemSkipped: 0,
|
||||
progress: 0,
|
||||
estimatedItems: 2,
|
||||
estimatedCost: new Prisma.Decimal('0.04'),
|
||||
estimatedSeconds: 6,
|
||||
createdBy: USER_ID,
|
||||
createdByName: '陈墨',
|
||||
createdAt: NOW,
|
||||
startedAt: null,
|
||||
finishedAt: null,
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
function headerRow(overrides: Record<string, unknown> = {}): Record<string, unknown> {
|
||||
return {
|
||||
id: TASK_ID,
|
||||
type: 'demo',
|
||||
status: 'queued',
|
||||
title: '演练任务 · 花间集 · 2 项',
|
||||
customerId: CUSTOMER_ID,
|
||||
customerName: '花间集',
|
||||
createdBy: USER_ID,
|
||||
createdByName: '陈墨',
|
||||
itemTotal: 2,
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
interface Harness {
|
||||
service: MediaTaskService;
|
||||
repository: {
|
||||
findIdempotencyByKey: ReturnType<typeof vi.fn>;
|
||||
replayResponse: ReturnType<typeof vi.fn>;
|
||||
submitTask: ReturnType<typeof vi.fn>;
|
||||
listTasks: ReturnType<typeof vi.fn>;
|
||||
findTaskDetail: ReturnType<typeof vi.fn>;
|
||||
findVisibleTaskHeader: ReturnType<typeof vi.fn>;
|
||||
cancelTask: ReturnType<typeof vi.fn>;
|
||||
retryTask: ReturnType<typeof vi.fn>;
|
||||
};
|
||||
estimator: { estimate: ReturnType<typeof vi.fn> };
|
||||
customers: { detail: ReturnType<typeof vi.fn> };
|
||||
requestContext: {
|
||||
patch: ReturnType<typeof vi.fn>;
|
||||
patchAudit: ReturnType<typeof vi.fn>;
|
||||
get: ReturnType<typeof vi.fn>;
|
||||
};
|
||||
}
|
||||
|
||||
function harness(): Harness {
|
||||
const repository = {
|
||||
findIdempotencyByKey: vi.fn().mockResolvedValue(null),
|
||||
replayResponse: vi.fn().mockReturnValue(RESPONSE),
|
||||
submitTask: vi.fn().mockResolvedValue({ kind: 'created', response: RESPONSE }),
|
||||
listTasks: vi.fn().mockResolvedValue([]),
|
||||
findTaskDetail: vi.fn().mockResolvedValue(null),
|
||||
findVisibleTaskHeader: vi.fn().mockResolvedValue(null),
|
||||
cancelTask: vi.fn(),
|
||||
retryTask: vi.fn(),
|
||||
};
|
||||
const estimator = {
|
||||
estimate: vi
|
||||
.fn()
|
||||
.mockResolvedValue({ estimatedItems: 2, estimatedCost: 0.04, estimatedSeconds: 6 }),
|
||||
};
|
||||
const customers = {
|
||||
detail: vi.fn().mockResolvedValue({ id: CUSTOMER_ID, name: '花间集', status: 'active' }),
|
||||
};
|
||||
const requestContext = {
|
||||
patch: vi.fn(),
|
||||
patchAudit: vi.fn(),
|
||||
get: vi.fn().mockReturnValue({ traceId: 'trace-1' }),
|
||||
};
|
||||
return {
|
||||
service: new MediaTaskService(
|
||||
repository as unknown as MediaTaskRepository,
|
||||
estimator as unknown as MediaTaskEstimator,
|
||||
customers as unknown as CustomersService,
|
||||
requestContext as unknown as RequestContextService,
|
||||
),
|
||||
repository,
|
||||
estimator,
|
||||
customers,
|
||||
requestContext,
|
||||
};
|
||||
}
|
||||
|
||||
async function expectRejected(promise: Promise<unknown>, code: ErrorCode): Promise<AppException> {
|
||||
try {
|
||||
await promise;
|
||||
} catch (error) {
|
||||
expect(error).toBeInstanceOf(AppException);
|
||||
const appError = error as AppException;
|
||||
expect(appError.code).toBe(code);
|
||||
return appError;
|
||||
}
|
||||
throw new Error('预期抛出 AppException');
|
||||
}
|
||||
|
||||
describe('任务提交(A6-BE-01:幂等 → 客户 → 预估 → 事务 → 审计)', () => {
|
||||
it('缺 Idempotency-Key → 40001,不触碰数据库', async () => {
|
||||
const { service, repository } = harness();
|
||||
await expectRejected(
|
||||
service.submit(SUBMIT_INPUT, undefined, AUTH, SCOPE),
|
||||
ErrorCode.PARAM_INVALID,
|
||||
);
|
||||
expect(repository.findIdempotencyByKey).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('幂等重放:返回同一响应、跳过审计、不重复建任务', async () => {
|
||||
const { service, repository, requestContext } = harness();
|
||||
repository.findIdempotencyByKey.mockResolvedValueOnce({
|
||||
id: 'k1',
|
||||
scope: MEDIA_TASK_SUBMIT_SCOPE,
|
||||
key: 'idem-key-0001',
|
||||
requestHash: hashIdempotencyRequest(USER_ID, SUBMIT_INPUT),
|
||||
responseStatus: 202,
|
||||
responseBody: RESPONSE,
|
||||
createdAt: NOW,
|
||||
expiresAt: new Date(Date.now() + 60_000),
|
||||
});
|
||||
|
||||
await expect(service.submit(SUBMIT_INPUT, 'idem-key-0001', AUTH, SCOPE)).resolves.toEqual(
|
||||
RESPONSE,
|
||||
);
|
||||
expect(repository.submitTask).not.toHaveBeenCalled();
|
||||
expect(requestContext.patch).toHaveBeenCalledWith({ skipAudit: true });
|
||||
expect(requestContext.patchAudit).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('客户不在数据范围 → 40302(不泄露客户是否存在)', async () => {
|
||||
const { service } = harness();
|
||||
await expectRejected(
|
||||
service.submit(SUBMIT_INPUT, 'idem-key-0001', AUTH, {
|
||||
...SCOPE,
|
||||
customerIds: ['01J9Z000000000000000000009'],
|
||||
}),
|
||||
ErrorCode.OUT_OF_SCOPE,
|
||||
);
|
||||
});
|
||||
|
||||
it('暂停 / 结束客户 → 42201(A3 口径:暂停客户不能提交新任务)', async () => {
|
||||
const { service, customers } = harness();
|
||||
customers.detail.mockResolvedValueOnce({ id: CUSTOMER_ID, name: '花间集', status: 'paused' });
|
||||
await expectRejected(
|
||||
service.submit(SUBMIT_INPUT, 'idem-key-0001', AUTH, SCOPE),
|
||||
ErrorCode.BUSINESS_RULE_VIOLATION,
|
||||
);
|
||||
});
|
||||
|
||||
it('成功提交:单事务写任务,审计明细含目标 / 客户 / 预估', async () => {
|
||||
const { service, repository, requestContext } = harness();
|
||||
// 仓库回显服务端生成的 taskId(真实实现同源)
|
||||
repository.submitTask.mockImplementationOnce(
|
||||
(input: { idempotency: { response: typeof RESPONSE } }) => ({
|
||||
kind: 'created',
|
||||
response: input.idempotency.response,
|
||||
}),
|
||||
);
|
||||
const result = await service.submit(SUBMIT_INPUT, 'idem-key-0001', AUTH, SCOPE);
|
||||
expect(repository.submitTask).toHaveBeenCalledTimes(1);
|
||||
const args = repository.submitTask.mock.calls[0]?.[0] as {
|
||||
task: { id: string; customerId: string; createdBy: string; payload: unknown };
|
||||
itemIds: string[];
|
||||
idempotency: { key: string; scope: string; responseBody: unknown; expiresAt: Date };
|
||||
traceId: string;
|
||||
};
|
||||
// taskId 由服务端生成(mock 仓库回显固定响应),以返回值为准
|
||||
expect(args.task.id).toBe(result.taskId);
|
||||
expect(args.task.createdBy).toBe(USER_ID);
|
||||
expect(args.itemIds).toHaveLength(2);
|
||||
expect(args.idempotency).toMatchObject({
|
||||
key: 'idem-key-0001',
|
||||
scope: MEDIA_TASK_SUBMIT_SCOPE,
|
||||
});
|
||||
expect(args.traceId).toBe('trace-1');
|
||||
expect(args.idempotency.expiresAt.getTime()).toBeGreaterThan(NOW.getTime());
|
||||
expect(requestContext.patchAudit).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
targetType: 'task',
|
||||
targetId: result.taskId,
|
||||
customerId: CUSTOMER_ID,
|
||||
customerName: '花间集',
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it('并发同 key:唯一约束冲突后回读首次响应并跳过审计', async () => {
|
||||
const { service, repository, requestContext } = harness();
|
||||
const uniqueError = new Prisma.PrismaClientKnownRequestError('duplicate', {
|
||||
code: 'P2002',
|
||||
clientVersion: '6.19.3',
|
||||
});
|
||||
repository.submitTask.mockRejectedValueOnce(uniqueError);
|
||||
repository.findIdempotencyByKey.mockResolvedValueOnce(null).mockResolvedValueOnce({
|
||||
id: 'k1',
|
||||
scope: MEDIA_TASK_SUBMIT_SCOPE,
|
||||
key: 'idem-key-0001',
|
||||
requestHash: hashIdempotencyRequest(USER_ID, SUBMIT_INPUT),
|
||||
responseStatus: 202,
|
||||
responseBody: RESPONSE,
|
||||
createdAt: NOW,
|
||||
expiresAt: new Date(Date.now() + 60_000),
|
||||
});
|
||||
|
||||
await expect(service.submit(SUBMIT_INPUT, 'idem-key-0001', AUTH, SCOPE)).resolves.toEqual(
|
||||
RESPONSE,
|
||||
);
|
||||
expect(requestContext.patch).toHaveBeenCalledWith({ skipAudit: true });
|
||||
expect(requestContext.patchAudit).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('非唯一约束错误原样抛出', async () => {
|
||||
const { service, repository } = harness();
|
||||
repository.submitTask.mockRejectedValueOnce(new Error('connection lost'));
|
||||
await expect(service.submit(SUBMIT_INPUT, 'idem-key-0001', AUTH, SCOPE)).rejects.toThrow(
|
||||
'connection lost',
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe('任务列表 / 详情(material:read + ScopeContext)', () => {
|
||||
it('列表:游标非法 40001;正常返回 items 与 next_cursor', async () => {
|
||||
const { service, repository } = harness();
|
||||
await expectRejected(
|
||||
service.list({ limit: 20, cursor: 'bad-cursor' }, SCOPE, AUTH),
|
||||
ErrorCode.PARAM_INVALID,
|
||||
);
|
||||
|
||||
repository.listTasks.mockResolvedValueOnce([
|
||||
summaryRow(),
|
||||
summaryRow({ id: '01J9Z000000000000000000004' }),
|
||||
summaryRow({ id: '01J9Z000000000000000000005' }),
|
||||
]);
|
||||
const result = await service.list({ limit: 2 }, SCOPE, AUTH);
|
||||
expect(result.items).toHaveLength(2);
|
||||
expect(result.next_cursor).not.toBeNull();
|
||||
});
|
||||
|
||||
it('列表:不足一页 next_cursor 为 null;mine 透传 actorId', async () => {
|
||||
const { service, repository } = harness();
|
||||
repository.listTasks.mockResolvedValueOnce([summaryRow()]);
|
||||
const result = await service.list({ limit: 20, mine: 'true' }, SCOPE, AUTH);
|
||||
expect(result.next_cursor).toBeNull();
|
||||
expect(repository.listTasks).toHaveBeenCalledWith(
|
||||
{ limit: 20, mine: 'true' },
|
||||
SCOPE,
|
||||
USER_ID,
|
||||
null,
|
||||
);
|
||||
});
|
||||
|
||||
it('详情:范围外 / 不存在统一 404;任务项游标非法 40001', async () => {
|
||||
const { service } = harness();
|
||||
await expectRejected(
|
||||
service.detail(TASK_ID, { items_cursor: 'bad-cursor' }, SCOPE),
|
||||
ErrorCode.PARAM_INVALID,
|
||||
);
|
||||
await expectRejected(service.detail(TASK_ID, {}, SCOPE), ErrorCode.NOT_FOUND);
|
||||
});
|
||||
|
||||
it('详情:返回 payload / items / itemsNextCursor', async () => {
|
||||
const { service, repository } = harness();
|
||||
const task = summaryRow({
|
||||
payloadJson: { mode: 'mixed' },
|
||||
cancelRequestedAt: null,
|
||||
lastErrorCode: null,
|
||||
lastErrorMessage: null,
|
||||
}) as unknown as MediaTaskDetailRow;
|
||||
repository.findTaskDetail.mockResolvedValueOnce({
|
||||
task,
|
||||
items: [
|
||||
{
|
||||
id: TASK_ID,
|
||||
seq: 1,
|
||||
status: 'queued',
|
||||
failureCode: null,
|
||||
failureMessage: null,
|
||||
attempts: 0,
|
||||
startedAt: null,
|
||||
finishedAt: null,
|
||||
} as unknown as MediaItemRow,
|
||||
],
|
||||
hasMore: true,
|
||||
});
|
||||
const detail = await service.detail(TASK_ID, {}, SCOPE);
|
||||
expect(detail.payload).toEqual({ mode: 'mixed' });
|
||||
expect(detail.items).toHaveLength(1);
|
||||
expect(detail.itemsNextCursor).not.toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe('取消 / 重试(A6-BE-04:取消只影响未开始项,重试只重失败项)', () => {
|
||||
it('取消:不存在 404;状态不允许 40903;排队任务取消返回 canceled', async () => {
|
||||
const { service, repository, requestContext } = harness();
|
||||
await expectRejected(service.cancel(TASK_ID, SCOPE, AUTH), ErrorCode.NOT_FOUND);
|
||||
|
||||
repository.findVisibleTaskHeader.mockResolvedValueOnce(headerRow({ status: 'canceled' }));
|
||||
await expectRejected(service.cancel(TASK_ID, SCOPE, AUTH), ErrorCode.STATE_NOT_ALLOWED);
|
||||
|
||||
repository.findVisibleTaskHeader.mockResolvedValueOnce(headerRow({ status: 'queued' }));
|
||||
repository.cancelTask.mockResolvedValueOnce({
|
||||
status: 'canceled',
|
||||
skippedItems: [
|
||||
{ id: '01J9Z000000000000000000006', seq: 1 },
|
||||
{ id: '01J9Z000000000000000000007', seq: 2 },
|
||||
],
|
||||
});
|
||||
await expect(service.cancel(TASK_ID, SCOPE, AUTH)).resolves.toEqual({
|
||||
taskId: TASK_ID,
|
||||
status: 'canceled',
|
||||
});
|
||||
expect(repository.cancelTask).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ actorId: USER_ID }),
|
||||
);
|
||||
expect(requestContext.patchAudit).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
targetType: 'task',
|
||||
changes: { canceledItems: 2 },
|
||||
after: { status: 'canceled' },
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it('重试:不存在 404;非部分失败 40903;部分失败只重试失败项', async () => {
|
||||
const { service, repository, requestContext } = harness();
|
||||
await expectRejected(service.retry(TASK_ID, SCOPE), ErrorCode.NOT_FOUND);
|
||||
|
||||
repository.findVisibleTaskHeader.mockResolvedValueOnce(headerRow({ status: 'running' }));
|
||||
await expectRejected(service.retry(TASK_ID, SCOPE), ErrorCode.STATE_NOT_ALLOWED);
|
||||
|
||||
repository.findVisibleTaskHeader.mockResolvedValueOnce(headerRow({ status: 'partial_failed' }));
|
||||
repository.retryTask.mockResolvedValueOnce({ status: 'queued', retriedItems: 1 });
|
||||
await expect(service.retry(TASK_ID, SCOPE)).resolves.toEqual({
|
||||
taskId: TASK_ID,
|
||||
status: 'queued',
|
||||
retriedItems: 1,
|
||||
});
|
||||
expect(requestContext.patchAudit).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ changes: { retriedItems: 1 }, after: { status: 'queued' } }),
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,298 @@
|
||||
/* eslint-disable max-lines-per-function -- 提交编排(幂等 → 客户校验 → 预估 → 事务 → 审计)是一次完整业务动作,拆散会丢失步骤顺序 */
|
||||
/**
|
||||
* 任务中心业务编排(A6-BE-01 / A6-BE-03 / A6-BE-04)
|
||||
*
|
||||
* 提交链路(技术架构 §6.4):
|
||||
* 1. 幂等键校验 → 早读幂等(命中直接返回同一响应,不再建任务 / 不写审计);
|
||||
* 2. 客户必须在 ScopeContext 内(40302),暂停 / 结束客户拒绝(42201);
|
||||
* 3. 预估条数 / 成本 / 时长(缺配置拒绝 40001);
|
||||
* 4. 单事务写任务头 + 任务项 + progress 事件 + outbox `media.task.created` + 幂等键;
|
||||
* 5. 并发同 key:唯一约束兜底,回读首次响应或 40902。
|
||||
*/
|
||||
import {
|
||||
canCancelTask,
|
||||
canRetryTask,
|
||||
ErrorCode,
|
||||
type MediaTaskCancelResponse,
|
||||
type MediaTaskDetail,
|
||||
type MediaTaskDetailQuery,
|
||||
type MediaTaskListQuery,
|
||||
type MediaTaskListResponse,
|
||||
type MediaTaskRetryResponse,
|
||||
type MediaTaskSubmitParsed,
|
||||
type MediaTaskSubmitResponse,
|
||||
taskTypeLabel,
|
||||
ulid,
|
||||
} from '@lingxi/shared';
|
||||
import { Inject, Injectable } from '@nestjs/common';
|
||||
|
||||
import { AppException } from '../../common/errors/app-exception';
|
||||
import type { AuthContext } from '../../common/security/auth.types';
|
||||
import { RequestContextService } from '../../common/security/request-context';
|
||||
import {
|
||||
assertCustomerAccess,
|
||||
requireScopeContext,
|
||||
type ScopeContext,
|
||||
} from '../../common/security/scope/scope-context';
|
||||
import { CustomersService } from '../client/customers.service';
|
||||
import { parseTaskStatus, toMediaTaskDetail, toMediaTaskSummary } from './media-task.mapper';
|
||||
import { isUniqueViolation, MediaTaskRepository } from './media-task.repository';
|
||||
import {
|
||||
decodeItemCursor,
|
||||
decodeMediaTaskCursor,
|
||||
encodeItemCursor,
|
||||
encodeMediaTaskCursor,
|
||||
type MediaTaskCursor,
|
||||
} from './media-task-cursor';
|
||||
import { MediaTaskEstimator } from './media-task-estimator';
|
||||
import {
|
||||
hashIdempotencyRequest,
|
||||
IDEMPOTENCY_TTL_MS,
|
||||
MEDIA_TASK_SUBMIT_SCOPE,
|
||||
parseIdempotencyKey,
|
||||
} from './media-task-idempotency';
|
||||
|
||||
/** 详情首屏任务项条数(与契约「首 100 条 items」一致) */
|
||||
export const MEDIA_TASK_DETAIL_ITEMS_PAGE_SIZE = 100;
|
||||
|
||||
function buildTitle(
|
||||
type: MediaTaskSubmitParsed['type'],
|
||||
customerName: string,
|
||||
itemCount: number,
|
||||
): string {
|
||||
const title = `${taskTypeLabel(type)} · ${customerName} · ${String(itemCount)} 项`;
|
||||
return title.length > 200 ? title.slice(0, 200) : title;
|
||||
}
|
||||
|
||||
function invalidCursor(message: string): AppException {
|
||||
return new AppException({ code: ErrorCode.PARAM_INVALID, message });
|
||||
}
|
||||
|
||||
@Injectable()
|
||||
export class MediaTaskService {
|
||||
constructor(
|
||||
@Inject(MediaTaskRepository) private readonly repository: MediaTaskRepository,
|
||||
@Inject(MediaTaskEstimator) private readonly estimator: MediaTaskEstimator,
|
||||
@Inject(CustomersService) private readonly customers: CustomersService,
|
||||
@Inject(RequestContextService) private readonly requestContext: RequestContextService,
|
||||
) {}
|
||||
|
||||
async submit(
|
||||
input: MediaTaskSubmitParsed,
|
||||
rawIdempotencyKey: string | string[] | undefined,
|
||||
auth: AuthContext,
|
||||
scopeInput: ScopeContext | null,
|
||||
): Promise<MediaTaskSubmitResponse> {
|
||||
const scope = requireScopeContext(scopeInput);
|
||||
const idempotencyKey = parseIdempotencyKey(rawIdempotencyKey);
|
||||
const requestHash = hashIdempotencyRequest(auth.userId, input);
|
||||
const now = new Date();
|
||||
|
||||
// 1) 幂等早读:命中 24h 内记录直接返回首次响应(不重复建任务 / 审计 / outbox)
|
||||
const existing = await this.repository.findIdempotencyByKey(
|
||||
MEDIA_TASK_SUBMIT_SCOPE,
|
||||
idempotencyKey,
|
||||
);
|
||||
if (existing !== null && existing.expiresAt.getTime() > now.getTime()) {
|
||||
const replayed = this.repository.replayResponse(existing, requestHash);
|
||||
this.requestContext.patch({ skipAudit: true });
|
||||
return replayed;
|
||||
}
|
||||
|
||||
// 2) 客户必须在数据范围内(40302),暂停 / 结束客户不能提交新任务(42201)
|
||||
assertCustomerAccess(scope, input.customerId);
|
||||
const customer = await this.customers.detail(input.customerId, scope);
|
||||
if (customer.status !== 'active') {
|
||||
throw new AppException({
|
||||
code: ErrorCode.BUSINESS_RULE_VIOLATION,
|
||||
message: '客户已暂停或结束,不能提交新任务',
|
||||
});
|
||||
}
|
||||
|
||||
// 3) 预估:缺配置 / 超上限一律 40001,不允许静默 0
|
||||
const estimate = await this.estimator.estimate(input.type, input.itemCount);
|
||||
|
||||
const taskId = ulid();
|
||||
const response: MediaTaskSubmitResponse = { taskId, ...estimate };
|
||||
const itemIds = Array.from({ length: input.itemCount }, () => ulid());
|
||||
const title = buildTitle(input.type, customer.name, input.itemCount);
|
||||
const traceId = this.requestContext.get()?.traceId ?? null;
|
||||
|
||||
// 4) 单事务写入;并发同 key 由 (scope, key) 唯一约束兜底
|
||||
let result: Awaited<ReturnType<MediaTaskRepository['submitTask']>>;
|
||||
try {
|
||||
result = await this.repository.submitTask({
|
||||
task: {
|
||||
id: taskId,
|
||||
type: input.type,
|
||||
title,
|
||||
customerId: input.customerId,
|
||||
customerName: customer.name,
|
||||
createdBy: auth.userId,
|
||||
createdByName: auth.name,
|
||||
itemTotal: input.itemCount,
|
||||
estimatedItems: estimate.estimatedItems,
|
||||
estimatedCost: estimate.estimatedCost,
|
||||
estimatedSeconds: estimate.estimatedSeconds,
|
||||
payload: {
|
||||
type: input.type,
|
||||
mode: input.mode,
|
||||
itemCount: input.itemCount,
|
||||
note: input.note ?? null,
|
||||
},
|
||||
},
|
||||
itemIds,
|
||||
idempotency: {
|
||||
id: ulid(),
|
||||
scope: MEDIA_TASK_SUBMIT_SCOPE,
|
||||
key: idempotencyKey,
|
||||
requestHash,
|
||||
response,
|
||||
expiresAt: new Date(now.getTime() + IDEMPOTENCY_TTL_MS),
|
||||
},
|
||||
traceId,
|
||||
now,
|
||||
});
|
||||
} catch (error) {
|
||||
if (!isUniqueViolation(error)) throw error;
|
||||
const raced = await this.repository.findIdempotencyByKey(
|
||||
MEDIA_TASK_SUBMIT_SCOPE,
|
||||
idempotencyKey,
|
||||
);
|
||||
if (raced === null) throw error;
|
||||
const replayed = this.repository.replayResponse(raced, requestHash);
|
||||
this.requestContext.patch({ skipAudit: true });
|
||||
return replayed;
|
||||
}
|
||||
|
||||
if (result.kind === 'replayed') {
|
||||
this.requestContext.patch({ skipAudit: true });
|
||||
return result.response;
|
||||
}
|
||||
|
||||
// 5) 审计明细(落库由 AuditInterceptor 统一完成;重放不写)
|
||||
this.requestContext.patchAudit({
|
||||
targetType: 'task',
|
||||
targetId: taskId,
|
||||
targetName: title,
|
||||
customerId: input.customerId,
|
||||
customerName: customer.name,
|
||||
after: {
|
||||
type: input.type,
|
||||
itemCount: input.itemCount,
|
||||
mode: input.mode,
|
||||
...estimate,
|
||||
},
|
||||
});
|
||||
return result.response;
|
||||
}
|
||||
|
||||
async list(
|
||||
query: MediaTaskListQuery,
|
||||
scopeInput: ScopeContext | null,
|
||||
auth: AuthContext,
|
||||
): Promise<MediaTaskListResponse> {
|
||||
const scope = requireScopeContext(scopeInput);
|
||||
let cursor: MediaTaskCursor | null = null;
|
||||
if (query.cursor !== undefined) {
|
||||
cursor = decodeMediaTaskCursor(query.cursor);
|
||||
if (cursor === null) throw invalidCursor('分页游标无效,请刷新后重试');
|
||||
}
|
||||
const rows = await this.repository.listTasks(query, scope, auth.userId, cursor);
|
||||
const page = rows.slice(0, query.limit);
|
||||
const last = page[page.length - 1];
|
||||
return {
|
||||
items: page.map((row) => toMediaTaskSummary(row)),
|
||||
next_cursor:
|
||||
rows.length > query.limit && last !== undefined
|
||||
? encodeMediaTaskCursor(last.createdAt, last.id)
|
||||
: null,
|
||||
};
|
||||
}
|
||||
|
||||
async detail(
|
||||
id: string,
|
||||
query: MediaTaskDetailQuery,
|
||||
scopeInput: ScopeContext | null,
|
||||
): Promise<MediaTaskDetail> {
|
||||
const scope = requireScopeContext(scopeInput);
|
||||
let itemCursor: number | null = null;
|
||||
if (query.items_cursor !== undefined) {
|
||||
itemCursor = decodeItemCursor(query.items_cursor);
|
||||
if (itemCursor === null) throw invalidCursor('任务项游标无效,请刷新后重试');
|
||||
}
|
||||
const found = await this.repository.findTaskDetail(
|
||||
id,
|
||||
scope,
|
||||
itemCursor,
|
||||
MEDIA_TASK_DETAIL_ITEMS_PAGE_SIZE,
|
||||
);
|
||||
// 范围外与不存在统一 404,不泄露任务存在性(R-04)
|
||||
if (found === null) {
|
||||
throw new AppException({ code: ErrorCode.NOT_FOUND, message: '任务不存在或已删除' });
|
||||
}
|
||||
const last = found.items[found.items.length - 1];
|
||||
const itemsNextCursor = found.hasMore && last !== undefined ? encodeItemCursor(last.seq) : null;
|
||||
return toMediaTaskDetail(found.task, found.items, itemsNextCursor);
|
||||
}
|
||||
|
||||
async cancel(
|
||||
id: string,
|
||||
scopeInput: ScopeContext | null,
|
||||
auth: AuthContext,
|
||||
): Promise<MediaTaskCancelResponse> {
|
||||
const scope = requireScopeContext(scopeInput);
|
||||
const task = await this.repository.findVisibleTaskHeader(id, scope);
|
||||
if (task === null) {
|
||||
throw new AppException({ code: ErrorCode.NOT_FOUND, message: '任务不存在或已删除' });
|
||||
}
|
||||
if (!canCancelTask(parseTaskStatus(task.status))) {
|
||||
throw new AppException({
|
||||
code: ErrorCode.STATE_NOT_ALLOWED,
|
||||
message: '当前状态不允许取消(仅排队 / 生成中可取消)',
|
||||
});
|
||||
}
|
||||
const result = await this.repository.cancelTask({
|
||||
task,
|
||||
actorId: auth.userId,
|
||||
now: new Date(),
|
||||
});
|
||||
this.requestContext.patchAudit({
|
||||
targetType: 'task',
|
||||
targetId: id,
|
||||
targetName: task.title,
|
||||
customerId: task.customerId,
|
||||
customerName: task.customerName,
|
||||
before: { status: task.status },
|
||||
after: { status: result.status },
|
||||
changes: { canceledItems: result.skippedItems.length },
|
||||
});
|
||||
return { taskId: id, status: result.status };
|
||||
}
|
||||
|
||||
async retry(id: string, scopeInput: ScopeContext | null): Promise<MediaTaskRetryResponse> {
|
||||
const scope = requireScopeContext(scopeInput);
|
||||
const task = await this.repository.findVisibleTaskHeader(id, scope);
|
||||
if (task === null) {
|
||||
throw new AppException({ code: ErrorCode.NOT_FOUND, message: '任务不存在或已删除' });
|
||||
}
|
||||
if (!canRetryTask(parseTaskStatus(task.status))) {
|
||||
throw new AppException({
|
||||
code: ErrorCode.STATE_NOT_ALLOWED,
|
||||
message: '仅部分失败的任务可重试',
|
||||
});
|
||||
}
|
||||
const result = await this.repository.retryTask(task, new Date());
|
||||
this.requestContext.patchAudit({
|
||||
targetType: 'task',
|
||||
targetId: id,
|
||||
targetName: task.title,
|
||||
customerId: task.customerId,
|
||||
customerName: task.customerName,
|
||||
before: { status: task.status },
|
||||
after: { status: result.status },
|
||||
changes: { retriedItems: result.retriedItems },
|
||||
});
|
||||
return { taskId: id, status: result.status, retriedItems: result.retriedItems };
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,864 @@
|
||||
import type { OutgoingHttpHeaders } from 'node:http';
|
||||
|
||||
import { API_PREFIX, ErrorCode, IDEMPOTENCY_KEY_HEADER, Permission, ulid } from '@lingxi/shared';
|
||||
import { PrismaClient } from '@prisma/client';
|
||||
import { afterAll, beforeAll, describe, expect, it } from 'vitest';
|
||||
|
||||
import { createApp, type CreatedApiApp } from '../../bootstrap';
|
||||
import { loadDotenv } from '../../config/load-dotenv';
|
||||
|
||||
/**
|
||||
* A6 任务中心集成用例(真实 PG / Redis,进程内 Fastify inject)
|
||||
*
|
||||
* 覆盖:提交建三表 + outbox + 幂等键、重复提交同 key 不新增行 / 不重复审计、缺 key 40001、
|
||||
* 同 key 不同 body 40902、范围外客户 40302 / 404、列表筛选与游标、取消只影响未开始项、
|
||||
* 部分失败重试只重置失败项、SSE 按 Last-Event-ID 续推、结果 Outbox 本地消费写审计。
|
||||
*
|
||||
* 说明:`audit_logs` 为 append-only,afterAll 不删除审计数据;通过独立前缀 / taskId 断言隔离。
|
||||
*/
|
||||
|
||||
const PASSWORD_HASH =
|
||||
'$argon2id$v=19$m=65536,p=4,t=3$Osdgg3Fz53AwcoC2kyGiGg$QVbxW7Ok/SlCNbML95go2J/6GMSfE1EG3jBTJgR4zO4';
|
||||
const PASSWORD = 'lingxi2026';
|
||||
const API = API_PREFIX;
|
||||
const PREFIX = 'ITMEDIA-';
|
||||
const ACCOUNT_PREFIX = 'itmedia.';
|
||||
const ROLE_PREFIX = 'ITMEDIA-角色-';
|
||||
const ROLE_CODE_PREFIX = 'itmedia_role_';
|
||||
const INDUSTRY_NAME = `${PREFIX}行业`;
|
||||
const CUSTOMER_PREFIX = `${PREFIX}客户-`;
|
||||
const KEY_PREFIX = 'itmedia-idem-';
|
||||
|
||||
loadDotenv();
|
||||
const prisma = new PrismaClient();
|
||||
|
||||
interface JsonResult<T> {
|
||||
status: number;
|
||||
body: T;
|
||||
headers: OutgoingHttpHeaders;
|
||||
}
|
||||
|
||||
interface ErrorBody {
|
||||
code: number;
|
||||
message: string;
|
||||
}
|
||||
|
||||
interface LoginBody {
|
||||
accessToken: string;
|
||||
refreshToken: string;
|
||||
}
|
||||
|
||||
interface SubmitBody {
|
||||
taskId: string;
|
||||
estimatedItems: number;
|
||||
estimatedCost: number;
|
||||
estimatedSeconds: number;
|
||||
}
|
||||
|
||||
interface SummaryBody {
|
||||
id: string;
|
||||
type: string;
|
||||
status: string;
|
||||
title: string;
|
||||
customerId: string;
|
||||
customerName: string;
|
||||
itemTotal: number;
|
||||
itemSucceeded: number;
|
||||
itemFailed: number;
|
||||
itemSkipped: number;
|
||||
progress: number;
|
||||
estimatedItems: number;
|
||||
estimatedCost: number;
|
||||
estimatedSeconds: number;
|
||||
createdBy: string;
|
||||
createdByName: string;
|
||||
createdAt: string;
|
||||
startedAt?: string;
|
||||
finishedAt?: string;
|
||||
}
|
||||
|
||||
interface ItemBody {
|
||||
id: string;
|
||||
seq: number;
|
||||
status: string;
|
||||
failureCode?: string;
|
||||
failureMessage?: string;
|
||||
attempts: number;
|
||||
}
|
||||
|
||||
interface DetailBody extends SummaryBody {
|
||||
payload: Record<string, unknown>;
|
||||
cancelRequestedAt?: string;
|
||||
lastErrorCode?: string;
|
||||
lastErrorMessage?: string;
|
||||
items: ItemBody[];
|
||||
itemsNextCursor: string | null;
|
||||
}
|
||||
|
||||
interface ListBody {
|
||||
items: SummaryBody[];
|
||||
next_cursor: string | null;
|
||||
}
|
||||
|
||||
let phoneCounter = 0;
|
||||
let app: CreatedApiApp['app'];
|
||||
let adminToken = '';
|
||||
let limitedToken = '';
|
||||
let adminUserId = '';
|
||||
let limitedUserId = '';
|
||||
let industryId = '';
|
||||
let adminCustomerId = '';
|
||||
let limitedCustomerId = '';
|
||||
let pausedCustomerId = '';
|
||||
const createdRoleIds: string[] = [];
|
||||
const createdUserIds: string[] = [];
|
||||
const createdTaskIds: string[] = [];
|
||||
let testStartedAt = new Date();
|
||||
|
||||
function nextPhone(): string {
|
||||
phoneCounter += 1;
|
||||
return `135${String(phoneCounter).padStart(8, '0')}`;
|
||||
}
|
||||
|
||||
function uniqueKey(suffix: string): string {
|
||||
return `${KEY_PREFIX}${suffix}-${String(Date.now())}`;
|
||||
}
|
||||
|
||||
async function createRole(
|
||||
suffix: string,
|
||||
permissions: string[],
|
||||
scopeType: 'all' | 'owned_customers',
|
||||
): Promise<string> {
|
||||
const id = ulid();
|
||||
await prisma.role.create({
|
||||
data: {
|
||||
id,
|
||||
code: `${ROLE_CODE_PREFIX}${suffix}`,
|
||||
name: `${ROLE_PREFIX}${suffix}`,
|
||||
description: 'A6 任务中心集成测试角色',
|
||||
status: 'ACTIVE',
|
||||
permissions: { create: permissions.map((permissionCode) => ({ permissionCode })) },
|
||||
dataScopes: { create: [{ id: ulid(), scopeType, targetId: null }] },
|
||||
},
|
||||
});
|
||||
createdRoleIds.push(id);
|
||||
return id;
|
||||
}
|
||||
|
||||
async function createUser(suffix: string, roleId: string): Promise<string> {
|
||||
const department = await prisma.department.findUniqueOrThrow({ where: { code: 'content-g1' } });
|
||||
const account = `${ACCOUNT_PREFIX}${suffix}`;
|
||||
const id = ulid();
|
||||
await prisma.user.create({
|
||||
data: {
|
||||
id,
|
||||
name: `A6 任务${suffix}`,
|
||||
account,
|
||||
email: `${account}@lingxi.cn`,
|
||||
phone: nextPhone(),
|
||||
passwordHash: PASSWORD_HASH,
|
||||
departmentId: department.id,
|
||||
mustChangePassword: false,
|
||||
roles: { create: [{ roleId }] },
|
||||
},
|
||||
});
|
||||
createdUserIds.push(id);
|
||||
return id;
|
||||
}
|
||||
|
||||
async function createCustomer(
|
||||
name: string,
|
||||
ownerUserId: string,
|
||||
status = 'ACTIVE',
|
||||
): Promise<string> {
|
||||
const id = ulid();
|
||||
await prisma.customer.create({
|
||||
data: {
|
||||
id,
|
||||
industryId,
|
||||
name,
|
||||
shortName: name.slice(-4),
|
||||
status: status === 'PAUSED' ? 'PAUSED' : 'ACTIVE',
|
||||
ownerUserId,
|
||||
remark: 'A6 任务中心集成测试客户',
|
||||
},
|
||||
});
|
||||
return id;
|
||||
}
|
||||
|
||||
async function login(account: string): Promise<LoginBody> {
|
||||
const response = await app.inject({
|
||||
method: 'POST',
|
||||
url: `${API}/auth/login`,
|
||||
payload: { account, password: PASSWORD },
|
||||
});
|
||||
if (response.statusCode !== 200 && response.statusCode !== 201) {
|
||||
throw new Error(`login failed ${String(response.statusCode)}: ${response.body}`);
|
||||
}
|
||||
return response.json<LoginBody>();
|
||||
}
|
||||
|
||||
async function call<T>(
|
||||
method: 'GET' | 'POST',
|
||||
url: string,
|
||||
token: string,
|
||||
payload?: unknown,
|
||||
headers?: Record<string, string>,
|
||||
): Promise<JsonResult<T>> {
|
||||
const response = await app.inject({
|
||||
method,
|
||||
url: `${API}${url}`,
|
||||
headers: { authorization: `Bearer ${token}`, 'user-agent': 'itmedia-vitest', ...headers },
|
||||
...(payload === undefined ? {} : { payload: payload as Record<string, unknown> }),
|
||||
});
|
||||
const body = response.body.length === 0 ? (null as T) : response.json<T>();
|
||||
return { status: response.statusCode, body, headers: response.headers };
|
||||
}
|
||||
|
||||
/** SSE 响应不是 JSON:直接取原始文本(结果流已合流为 string body) */
|
||||
async function callStream(
|
||||
url: string,
|
||||
token: string,
|
||||
headers?: Record<string, string>,
|
||||
): Promise<{ status: number; body: string; headers: OutgoingHttpHeaders }> {
|
||||
const response = await app.inject({
|
||||
method: 'GET',
|
||||
url: `${API}${url}`,
|
||||
headers: { authorization: `Bearer ${token}`, 'user-agent': 'itmedia-vitest', ...headers },
|
||||
});
|
||||
return { status: response.statusCode, body: response.body, headers: response.headers };
|
||||
}
|
||||
|
||||
function submitPayload(
|
||||
customerId: string,
|
||||
itemCount: number,
|
||||
mode: 'success' | 'mixed' | 'fail' = 'success',
|
||||
): Record<string, unknown> {
|
||||
return { type: 'demo', customerId, itemCount, mode, note: 'A6 集成演练' };
|
||||
}
|
||||
|
||||
async function submitTask(
|
||||
token: string,
|
||||
customerId: string,
|
||||
itemCount: number,
|
||||
key: string,
|
||||
mode: 'success' | 'mixed' | 'fail' = 'success',
|
||||
): Promise<JsonResult<SubmitBody>> {
|
||||
const result = await call<SubmitBody>(
|
||||
'POST',
|
||||
'/media-tasks',
|
||||
token,
|
||||
submitPayload(customerId, itemCount, mode),
|
||||
{
|
||||
[IDEMPOTENCY_KEY_HEADER]: key,
|
||||
},
|
||||
);
|
||||
if (result.status === 202) createdTaskIds.push(result.body.taskId);
|
||||
return result;
|
||||
}
|
||||
|
||||
async function taskRow(taskId: string, token: string): Promise<DetailBody> {
|
||||
const result = await call<DetailBody>('GET', `/media-tasks/${taskId}`, token);
|
||||
expect(result.status).toBe(200);
|
||||
return result.body;
|
||||
}
|
||||
|
||||
async function sleep(ms: number): Promise<void> {
|
||||
await new Promise((resolve) => setTimeout(resolve, ms));
|
||||
}
|
||||
|
||||
async function cleanupFixtures(): Promise<void> {
|
||||
await prisma.mediaTask.deleteMany({ where: { customerName: { startsWith: CUSTOMER_PREFIX } } });
|
||||
await prisma.outboxEvent.deleteMany({
|
||||
where: { aggregateType: 'media_task', occurredAt: { gte: testStartedAt } },
|
||||
});
|
||||
await prisma.idempotencyKey.deleteMany({ where: { key: { startsWith: KEY_PREFIX } } });
|
||||
await prisma.customer.deleteMany({ where: { name: { startsWith: CUSTOMER_PREFIX } } });
|
||||
await prisma.user.deleteMany({ where: { account: { startsWith: ACCOUNT_PREFIX } } });
|
||||
if (createdRoleIds.length > 0) {
|
||||
await prisma.role.deleteMany({ where: { id: { in: createdRoleIds } } });
|
||||
}
|
||||
await prisma.industry.deleteMany({ where: { name: INDUSTRY_NAME } });
|
||||
}
|
||||
|
||||
beforeAll(async () => {
|
||||
await cleanupFixtures();
|
||||
createdRoleIds.length = 0;
|
||||
createdUserIds.length = 0;
|
||||
createdTaskIds.length = 0;
|
||||
testStartedAt = new Date();
|
||||
|
||||
const adminRole = await createRole(
|
||||
'admin',
|
||||
[Permission.MATERIAL_READ, Permission.MATERIAL_RENDER, Permission.CUSTOMER_READ],
|
||||
'all',
|
||||
);
|
||||
adminUserId = await createUser('admin', adminRole);
|
||||
const limitedRole = await createRole(
|
||||
'limited',
|
||||
[Permission.MATERIAL_READ, Permission.MATERIAL_RENDER],
|
||||
'owned_customers',
|
||||
);
|
||||
limitedUserId = await createUser('limited', limitedRole);
|
||||
|
||||
const root = await prisma.industry.create({
|
||||
data: { id: ulid(), parentId: null, level: 1, name: INDUSTRY_NAME, sortOrder: 10 },
|
||||
});
|
||||
industryId = root.id;
|
||||
|
||||
adminCustomerId = await createCustomer(`${CUSTOMER_PREFIX}管理员客户`, adminUserId);
|
||||
limitedCustomerId = await createCustomer(`${CUSTOMER_PREFIX}负责人客户`, limitedUserId);
|
||||
pausedCustomerId = await createCustomer(`${CUSTOMER_PREFIX}暂停客户`, adminUserId, 'PAUSED');
|
||||
|
||||
const created = await createApp();
|
||||
app = created.app;
|
||||
await app.init();
|
||||
adminToken = (await login(`${ACCOUNT_PREFIX}admin`)).accessToken;
|
||||
limitedToken = (await login(`${ACCOUNT_PREFIX}limited`)).accessToken;
|
||||
});
|
||||
|
||||
afterAll(async () => {
|
||||
await cleanupFixtures();
|
||||
// eslint-disable-next-line @typescript-eslint/no-unnecessary-condition -- beforeAll 失败时 app 未赋值
|
||||
if (app) await app.close();
|
||||
await prisma.$disconnect();
|
||||
});
|
||||
|
||||
describe('A6 任务提交(A6-BE-01:预估 + 幂等 + 三表/outbox/审计)', () => {
|
||||
it('提交成功:返回预估字段,写任务头 / 任务项 / progress 事件 / created outbox / 幂等键 / 审计', async () => {
|
||||
const key = uniqueKey('create');
|
||||
const result = await submitTask(adminToken, adminCustomerId, 2, key, 'mixed');
|
||||
expect(result.status).toBe(202);
|
||||
expect(result.body).toMatchObject({
|
||||
estimatedItems: 2,
|
||||
estimatedCost: 0.04,
|
||||
estimatedSeconds: 6,
|
||||
});
|
||||
expect(result.body.taskId).toMatch(/^[0-9A-Z]{26}$/);
|
||||
|
||||
const task = await prisma.mediaTask.findUniqueOrThrow({ where: { id: result.body.taskId } });
|
||||
expect(task).toMatchObject({
|
||||
type: 'demo',
|
||||
status: 'queued',
|
||||
customerId: adminCustomerId,
|
||||
customerName: `${CUSTOMER_PREFIX}管理员客户`,
|
||||
itemTotal: 2,
|
||||
itemSucceeded: 0,
|
||||
itemFailed: 0,
|
||||
itemSkipped: 0,
|
||||
progress: 0,
|
||||
estimatedItems: 2,
|
||||
estimatedSeconds: 6,
|
||||
createdBy: adminUserId,
|
||||
createdByName: 'A6 任务admin',
|
||||
});
|
||||
expect(task.estimatedCost.toNumber()).toBe(0.04);
|
||||
expect(task.payloadJson).toMatchObject({ mode: 'mixed', itemCount: 2 });
|
||||
|
||||
const items = await prisma.mediaItem.findMany({
|
||||
where: { taskId: task.id },
|
||||
orderBy: { seq: 'asc' },
|
||||
});
|
||||
expect(items).toHaveLength(2);
|
||||
expect(items.map((item) => [item.seq, item.status])).toEqual([
|
||||
[1, 'queued'],
|
||||
[2, 'queued'],
|
||||
]);
|
||||
|
||||
const events = await prisma.taskEvent.findMany({
|
||||
where: { taskId: task.id },
|
||||
orderBy: { seq: 'asc' },
|
||||
});
|
||||
expect(events).toHaveLength(1);
|
||||
expect(events[0]?.eventType).toBe('progress');
|
||||
expect(events[0]?.payloadJson).toMatchObject({ status: 'queued', progress: 0, itemTotal: 2 });
|
||||
|
||||
const outbox = await prisma.outboxEvent.findFirst({
|
||||
where: { aggregateId: task.id, eventType: 'media.task.created' },
|
||||
});
|
||||
expect(outbox).not.toBeNull();
|
||||
expect(outbox?.payloadJson).toMatchObject({
|
||||
taskId: task.id,
|
||||
type: 'demo',
|
||||
itemIds: items.map((item) => item.id),
|
||||
});
|
||||
|
||||
const idempotency = await prisma.idempotencyKey.findUnique({
|
||||
where: { scope_key: { scope: 'media.tasks.submit', key } },
|
||||
});
|
||||
expect(idempotency).not.toBeNull();
|
||||
expect(idempotency?.requestHash).toHaveLength(64);
|
||||
expect(idempotency?.responseStatus).toBe(202);
|
||||
expect(idempotency?.responseBody).toMatchObject({ taskId: task.id });
|
||||
const ttlMs = (idempotency?.expiresAt.getTime() ?? 0) - Date.now();
|
||||
expect(ttlMs).toBeGreaterThan(23 * 3600 * 1000);
|
||||
expect(ttlMs).toBeLessThanOrEqual(24 * 3600 * 1000 + 60_000);
|
||||
|
||||
const audit = await prisma.auditLog.findFirst({
|
||||
where: { actionCode: 'task.submit', targetId: task.id },
|
||||
});
|
||||
expect(audit?.customerId).toBe(adminCustomerId);
|
||||
expect(audit?.result).toBe('success');
|
||||
});
|
||||
|
||||
it('重复提交同 key 同 body:返回同一 taskId,不新增任务 / 任务项 / outbox / 审计', async () => {
|
||||
const key = uniqueKey('replay');
|
||||
const first = await submitTask(adminToken, adminCustomerId, 3, key);
|
||||
expect(first.status).toBe(202);
|
||||
|
||||
const taskCountBefore = await prisma.mediaTask.count({
|
||||
where: { customerId: adminCustomerId },
|
||||
});
|
||||
const itemCountBefore = await prisma.mediaItem.count({ where: { taskId: first.body.taskId } });
|
||||
const outboxCountBefore = await prisma.outboxEvent.count({
|
||||
where: { aggregateId: first.body.taskId },
|
||||
});
|
||||
const auditCountBefore = await prisma.auditLog.count({
|
||||
where: { actionCode: 'task.submit', targetId: first.body.taskId },
|
||||
});
|
||||
|
||||
const second = await submitTask(adminToken, adminCustomerId, 3, key);
|
||||
expect(second.status).toBe(202);
|
||||
expect(second.body).toEqual(first.body);
|
||||
|
||||
expect(await prisma.mediaTask.count({ where: { customerId: adminCustomerId } })).toBe(
|
||||
taskCountBefore,
|
||||
);
|
||||
expect(await prisma.mediaItem.count({ where: { taskId: first.body.taskId } })).toBe(
|
||||
itemCountBefore,
|
||||
);
|
||||
expect(await prisma.outboxEvent.count({ where: { aggregateId: first.body.taskId } })).toBe(
|
||||
outboxCountBefore,
|
||||
);
|
||||
expect(
|
||||
await prisma.auditLog.count({
|
||||
where: { actionCode: 'task.submit', targetId: first.body.taskId },
|
||||
}),
|
||||
).toBe(auditCountBefore);
|
||||
});
|
||||
|
||||
it('缺 Idempotency-Key → 40001;同 key 不同 body → 40902', async () => {
|
||||
const missing = await call<ErrorBody>(
|
||||
'POST',
|
||||
'/media-tasks',
|
||||
adminToken,
|
||||
submitPayload(adminCustomerId, 1),
|
||||
);
|
||||
expect(missing.status).toBe(400);
|
||||
expect(missing.body.code).toBe(ErrorCode.PARAM_INVALID);
|
||||
expect(missing.body.message).toContain('Idempotency-Key');
|
||||
|
||||
const key = uniqueKey('conflict');
|
||||
const first = await submitTask(adminToken, adminCustomerId, 1, key);
|
||||
expect(first.status).toBe(202);
|
||||
const second = await call<ErrorBody>(
|
||||
'POST',
|
||||
'/media-tasks',
|
||||
adminToken,
|
||||
submitPayload(adminCustomerId, 2),
|
||||
{ [IDEMPOTENCY_KEY_HEADER]: key },
|
||||
);
|
||||
expect(second.status).toBe(409);
|
||||
expect(second.body.code).toBe(ErrorCode.DUPLICATE_SUBMIT);
|
||||
});
|
||||
|
||||
it('客户校验:范围外 40302、暂停客户 42201、非 demo 类型 40001、缺预估配置不适用(默认值兜底)', async () => {
|
||||
const outOfScope = await call<ErrorBody>(
|
||||
'POST',
|
||||
'/media-tasks',
|
||||
limitedToken,
|
||||
submitPayload(adminCustomerId, 1),
|
||||
{ [IDEMPOTENCY_KEY_HEADER]: uniqueKey('scope') },
|
||||
);
|
||||
expect(outOfScope.status).toBe(403);
|
||||
expect(outOfScope.body.code).toBe(ErrorCode.OUT_OF_SCOPE);
|
||||
|
||||
const paused = await call<ErrorBody>(
|
||||
'POST',
|
||||
'/media-tasks',
|
||||
adminToken,
|
||||
submitPayload(pausedCustomerId, 1),
|
||||
{ [IDEMPOTENCY_KEY_HEADER]: uniqueKey('paused') },
|
||||
);
|
||||
expect(paused.status).toBe(422);
|
||||
expect(paused.body.code).toBe(ErrorCode.BUSINESS_RULE_VIOLATION);
|
||||
|
||||
const nonDemo = await call<ErrorBody>(
|
||||
'POST',
|
||||
'/media-tasks',
|
||||
adminToken,
|
||||
{ ...submitPayload(adminCustomerId, 1), type: 'script' },
|
||||
{ [IDEMPOTENCY_KEY_HEADER]: uniqueKey('type') },
|
||||
);
|
||||
expect(nonDemo.status).toBe(400);
|
||||
expect(nonDemo.body.code).toBe(ErrorCode.PARAM_INVALID);
|
||||
expect(JSON.stringify(nonDemo.body)).toContain('M0 仅支持演练任务');
|
||||
|
||||
const overLimit = await call<ErrorBody>(
|
||||
'POST',
|
||||
'/media-tasks',
|
||||
adminToken,
|
||||
submitPayload(adminCustomerId, 21),
|
||||
{ [IDEMPOTENCY_KEY_HEADER]: uniqueKey('limit') },
|
||||
);
|
||||
expect(overLimit.status).toBe(400);
|
||||
expect(overLimit.body.code).toBe(ErrorCode.PARAM_INVALID);
|
||||
});
|
||||
});
|
||||
|
||||
describe('A6 任务列表 / 详情(A6-BE-04 + 客户隔离)', () => {
|
||||
it('列表返回 { items, next_cursor };筛选 type / status / customer_id / mine / keyword', async () => {
|
||||
const created = await submitTask(
|
||||
adminToken,
|
||||
adminCustomerId,
|
||||
2,
|
||||
uniqueKey('list-a'),
|
||||
'success',
|
||||
);
|
||||
const result = await call<ListBody>(
|
||||
'GET',
|
||||
`/media-tasks?type=demo&status=queued&customer_id=${adminCustomerId}&limit=100`,
|
||||
adminToken,
|
||||
);
|
||||
expect(result.status).toBe(200);
|
||||
expect(Array.isArray(result.body.items)).toBe(true);
|
||||
expect(result.body).toHaveProperty('next_cursor');
|
||||
const ids = result.body.items.map((item) => item.id);
|
||||
expect(ids).toContain(created.body.taskId);
|
||||
|
||||
const mine = await call<ListBody>('GET', '/media-tasks?mine=true&limit=100', adminToken);
|
||||
expect(mine.body.items.every((item) => item.createdBy === adminUserId)).toBe(true);
|
||||
|
||||
const keyword = await call<ListBody>(
|
||||
'GET',
|
||||
`/media-tasks?keyword=${encodeURIComponent('演练任务')}&limit=100`,
|
||||
adminToken,
|
||||
);
|
||||
expect(keyword.body.items.length).toBeGreaterThan(0);
|
||||
|
||||
const limited = await call<ListBody>('GET', '/media-tasks?limit=100', limitedToken);
|
||||
expect(limited.body.items.every((item) => item.customerId === limitedCustomerId)).toBe(true);
|
||||
});
|
||||
|
||||
it('游标分页稳定:limit=2 两页不重不漏', async () => {
|
||||
for (let index = 0; index < 3; index += 1) {
|
||||
await submitTask(adminToken, adminCustomerId, 1, uniqueKey(`page-${String(index)}`));
|
||||
}
|
||||
const first = await call<ListBody>('GET', '/media-tasks?limit=2', adminToken);
|
||||
expect(first.body.items).toHaveLength(2);
|
||||
expect(first.body.next_cursor).not.toBeNull();
|
||||
const second = await call<ListBody>(
|
||||
'GET',
|
||||
`/media-tasks?limit=2&cursor=${encodeURIComponent(first.body.next_cursor ?? '')}`,
|
||||
adminToken,
|
||||
);
|
||||
expect(second.status).toBe(200);
|
||||
const ids = [...first.body.items, ...second.body.items].map((item) => item.id);
|
||||
expect(new Set(ids).size).toBe(ids.length);
|
||||
expect(ids.length).toBeGreaterThanOrEqual(3);
|
||||
|
||||
const invalid = await call<ErrorBody>('GET', '/media-tasks?cursor=bad-cursor', adminToken);
|
||||
expect(invalid.status).toBe(400);
|
||||
expect(invalid.body.code).toBe(ErrorCode.PARAM_INVALID);
|
||||
});
|
||||
|
||||
it('详情:首屏返回任务项与 payload;范围外 / 不存在统一 404;items_cursor 非法 40001', async () => {
|
||||
const created = await submitTask(adminToken, adminCustomerId, 2, uniqueKey('detail'));
|
||||
const detail = await taskRow(created.body.taskId, adminToken);
|
||||
expect(detail.items).toHaveLength(2);
|
||||
expect(detail.itemsNextCursor).toBeNull();
|
||||
expect(detail.payload).toMatchObject({ mode: 'success', itemCount: 2 });
|
||||
expect(detail.status).toBe('queued');
|
||||
|
||||
const cross = await call<ErrorBody>('GET', `/media-tasks/${created.body.taskId}`, limitedToken);
|
||||
expect(cross.status).toBe(404);
|
||||
expect(cross.body.code).toBe(ErrorCode.NOT_FOUND);
|
||||
|
||||
const invalidCursor = await call<ErrorBody>(
|
||||
'GET',
|
||||
`/media-tasks/${created.body.taskId}?items_cursor=bad-cursor`,
|
||||
adminToken,
|
||||
);
|
||||
expect(invalidCursor.status).toBe(400);
|
||||
expect(invalidCursor.body.code).toBe(ErrorCode.PARAM_INVALID);
|
||||
});
|
||||
});
|
||||
|
||||
describe('A6 取消(只影响未开始项)', () => {
|
||||
it('排队任务取消:queued 项置 skipped,任务立即 canceled,写 done 事件 + canceled outbox', async () => {
|
||||
const created = await submitTask(adminToken, adminCustomerId, 3, uniqueKey('cancel-queued'));
|
||||
const cancel = await call<{ taskId: string; status: string }>(
|
||||
'POST',
|
||||
`/media-tasks/${created.body.taskId}/cancel`,
|
||||
adminToken,
|
||||
);
|
||||
expect(cancel.status).toBe(201);
|
||||
expect(cancel.body).toEqual({ taskId: created.body.taskId, status: 'canceled' });
|
||||
|
||||
const task = await prisma.mediaTask.findUniqueOrThrow({ where: { id: created.body.taskId } });
|
||||
expect(task.status).toBe('canceled');
|
||||
expect(task.finishedAt).not.toBeNull();
|
||||
expect(task.cancelRequestedAt).not.toBeNull();
|
||||
expect(task.canceledBy).toBe(adminUserId);
|
||||
expect(task.itemSkipped).toBe(3);
|
||||
expect(task.progress).toBe(100);
|
||||
|
||||
const items = await prisma.mediaItem.findMany({ where: { taskId: created.body.taskId } });
|
||||
expect(items.every((item) => item.status === 'skipped')).toBe(true);
|
||||
|
||||
const doneEvent = await prisma.taskEvent.findFirst({
|
||||
where: { taskId: created.body.taskId, eventType: 'done' },
|
||||
});
|
||||
expect(doneEvent).not.toBeNull();
|
||||
const canceledOutbox = await prisma.outboxEvent.findFirst({
|
||||
where: { aggregateId: created.body.taskId, eventType: 'media.task.canceled' },
|
||||
});
|
||||
expect(canceledOutbox).not.toBeNull();
|
||||
|
||||
const again = await call<ErrorBody>(
|
||||
'POST',
|
||||
`/media-tasks/${created.body.taskId}/cancel`,
|
||||
adminToken,
|
||||
);
|
||||
expect(again.status).toBe(409);
|
||||
expect(again.body.code).toBe(ErrorCode.STATE_NOT_ALLOWED);
|
||||
|
||||
const cross = await call<ErrorBody>(
|
||||
'POST',
|
||||
`/media-tasks/${created.body.taskId}/cancel`,
|
||||
limitedToken,
|
||||
);
|
||||
expect(cross.status).toBe(404);
|
||||
});
|
||||
|
||||
it('生成中任务取消:已成功项不变、只跳过未开始项,任务等 worker 收敛', async () => {
|
||||
const created = await submitTask(adminToken, adminCustomerId, 3, uniqueKey('cancel-running'));
|
||||
const taskId = created.body.taskId;
|
||||
const items = await prisma.mediaItem.findMany({
|
||||
where: { taskId },
|
||||
orderBy: { seq: 'asc' },
|
||||
});
|
||||
const [successItem, runningItem, queuedItem] = items;
|
||||
expect(successItem && runningItem && queuedItem).toBeTruthy();
|
||||
if (successItem === undefined || runningItem === undefined || queuedItem === undefined) return;
|
||||
await prisma.mediaTask.update({
|
||||
where: { id: taskId },
|
||||
data: { status: 'running', startedAt: new Date() },
|
||||
});
|
||||
await prisma.mediaItem.update({
|
||||
where: { id: successItem.id },
|
||||
data: { status: 'pending_review' },
|
||||
});
|
||||
await prisma.mediaItem.update({ where: { id: runningItem.id }, data: { status: 'running' } });
|
||||
|
||||
const cancel = await call<{ taskId: string; status: string }>(
|
||||
'POST',
|
||||
`/media-tasks/${taskId}/cancel`,
|
||||
adminToken,
|
||||
);
|
||||
expect(cancel.status).toBe(201);
|
||||
expect(cancel.body.status).toBe('running');
|
||||
|
||||
const after = await prisma.mediaTask.findUniqueOrThrow({ where: { id: taskId } });
|
||||
expect(after.status).toBe('running');
|
||||
expect(after.cancelRequestedAt).not.toBeNull();
|
||||
expect(after.itemSucceeded).toBe(1);
|
||||
expect(after.itemSkipped).toBe(1);
|
||||
|
||||
const itemsAfter = await prisma.mediaItem.findMany({
|
||||
where: { taskId },
|
||||
orderBy: { seq: 'asc' },
|
||||
});
|
||||
expect(itemsAfter.map((item) => item.status)).toEqual(['pending_review', 'running', 'skipped']);
|
||||
});
|
||||
});
|
||||
|
||||
describe('A6 重试(只重失败项)', () => {
|
||||
it('partial_failed 重试:失败项重置 queued(attempts 保留、失败原因清空),成功项不动', async () => {
|
||||
const created = await submitTask(adminToken, adminCustomerId, 3, uniqueKey('retry'), 'mixed');
|
||||
const taskId = created.body.taskId;
|
||||
const items = await prisma.mediaItem.findMany({ where: { taskId }, orderBy: { seq: 'asc' } });
|
||||
const [firstItem, failedItem, thirdItem] = items;
|
||||
expect(firstItem && failedItem && thirdItem).toBeTruthy();
|
||||
if (firstItem === undefined || failedItem === undefined || thirdItem === undefined) return;
|
||||
await prisma.mediaTask.update({
|
||||
where: { id: taskId },
|
||||
data: {
|
||||
status: 'partial_failed',
|
||||
itemSucceeded: 2,
|
||||
itemFailed: 1,
|
||||
progress: 100,
|
||||
finishedAt: new Date(),
|
||||
lastErrorCode: 'DEMO_FAIL',
|
||||
lastErrorMessage: '1 项失败',
|
||||
},
|
||||
});
|
||||
await prisma.mediaItem.update({
|
||||
where: { id: firstItem.id },
|
||||
data: { status: 'pending_review' },
|
||||
});
|
||||
await prisma.mediaItem.update({
|
||||
where: { id: failedItem.id },
|
||||
data: {
|
||||
status: 'failed',
|
||||
failureCode: 'DEMO_FAIL',
|
||||
failureMessage: '演练失败',
|
||||
attempts: 3,
|
||||
finishedAt: new Date(),
|
||||
},
|
||||
});
|
||||
await prisma.mediaItem.update({
|
||||
where: { id: thirdItem.id },
|
||||
data: { status: 'pending_review' },
|
||||
});
|
||||
|
||||
const retry = await call<{ taskId: string; status: string; retriedItems: number }>(
|
||||
'POST',
|
||||
`/media-tasks/${taskId}/retry`,
|
||||
adminToken,
|
||||
);
|
||||
expect(retry.status).toBe(201);
|
||||
expect(retry.body).toEqual({ taskId, status: 'queued', retriedItems: 1 });
|
||||
|
||||
const task = await prisma.mediaTask.findUniqueOrThrow({ where: { id: taskId } });
|
||||
expect(task.status).toBe('queued');
|
||||
expect(task.itemFailed).toBe(0);
|
||||
expect(task.itemSucceeded).toBe(2);
|
||||
expect(task.finishedAt).toBeNull();
|
||||
expect(task.lastErrorCode).toBeNull();
|
||||
|
||||
const itemsAfter = await prisma.mediaItem.findMany({
|
||||
where: { taskId },
|
||||
orderBy: { seq: 'asc' },
|
||||
});
|
||||
const reset = itemsAfter.find((item) => item.id === failedItem.id);
|
||||
expect(reset).toMatchObject({
|
||||
status: 'queued',
|
||||
attempts: 3,
|
||||
failureCode: null,
|
||||
failureMessage: null,
|
||||
});
|
||||
expect(itemsAfter.find((item) => item.id === firstItem.id)?.status).toBe('pending_review');
|
||||
expect(itemsAfter.find((item) => item.id === thirdItem.id)?.status).toBe('pending_review');
|
||||
|
||||
const retryOutbox = await prisma.outboxEvent.findFirst({
|
||||
where: { aggregateId: taskId, eventType: 'media.task.retry' },
|
||||
});
|
||||
expect(retryOutbox?.payloadJson).toMatchObject({
|
||||
taskId,
|
||||
type: 'demo',
|
||||
itemIds: [failedItem.id],
|
||||
});
|
||||
|
||||
const again = await call<ErrorBody>('POST', `/media-tasks/${taskId}/retry`, adminToken);
|
||||
expect(again.status).toBe(409);
|
||||
expect(again.body.code).toBe(ErrorCode.STATE_NOT_ALLOWED);
|
||||
});
|
||||
});
|
||||
|
||||
describe('A6 SSE(Last-Event-ID 续推)', () => {
|
||||
it('终态任务:返回 text/event-stream,续推不重复历史事件并补发 done', async () => {
|
||||
const created = await submitTask(adminToken, adminCustomerId, 2, uniqueKey('sse'));
|
||||
await call('POST', `/media-tasks/${created.body.taskId}/cancel`, adminToken);
|
||||
const events = await prisma.taskEvent.findMany({
|
||||
where: { taskId: created.body.taskId },
|
||||
orderBy: { seq: 'asc' },
|
||||
});
|
||||
expect(events.length).toBeGreaterThanOrEqual(2);
|
||||
// 从「取消写入的 progress 事件」续推:之后只剩 done,历史 item / progress 不应重发
|
||||
const lastProgress = [...events].reverse().find((event) => event.eventType === 'progress');
|
||||
expect(lastProgress).toBeDefined();
|
||||
const resumeSeq = lastProgress?.seq.toString() ?? '0';
|
||||
|
||||
const full = await callStream(`/media-tasks/${created.body.taskId}/events`, adminToken, {
|
||||
'last-event-id': '0',
|
||||
});
|
||||
expect(full.status).toBe(200);
|
||||
expect(String(full.headers['content-type'])).toContain('text/event-stream');
|
||||
expect(full.headers['x-accel-buffering']).toBe('no');
|
||||
expect(full.body).toContain('event: progress');
|
||||
expect(full.body).toContain('event: done');
|
||||
expect(full.body).toContain('id: ');
|
||||
|
||||
const resumed = await callStream(
|
||||
`/media-tasks/${created.body.taskId}/events?last_event_id=${resumeSeq}`,
|
||||
adminToken,
|
||||
);
|
||||
expect(resumed.status).toBe(200);
|
||||
expect(resumed.body).toContain('event: done');
|
||||
expect(resumed.body).not.toContain('event: progress');
|
||||
expect(resumed.body).not.toContain('event: item');
|
||||
|
||||
const cross = await call<ErrorBody>(
|
||||
'GET',
|
||||
`/media-tasks/${created.body.taskId}/events`,
|
||||
limitedToken,
|
||||
);
|
||||
expect(cross.status).toBe(404);
|
||||
});
|
||||
});
|
||||
|
||||
describe('A6 结果 Outbox 本地消费(写审计)', () => {
|
||||
it('media.task.finished → 写 task.finished 审计并置 published_at;created 事件不被本地消费', async () => {
|
||||
const created = await submitTask(
|
||||
adminToken,
|
||||
adminCustomerId,
|
||||
1,
|
||||
uniqueKey('result'),
|
||||
'success',
|
||||
);
|
||||
const taskId = created.body.taskId;
|
||||
|
||||
// created 事件属于 dispatcher(queue-worker),本地消费者不应认领
|
||||
const createdEvent = await prisma.outboxEvent.findFirstOrThrow({
|
||||
where: { aggregateId: taskId, eventType: 'media.task.created' },
|
||||
});
|
||||
|
||||
const finishedId = ulid();
|
||||
await prisma.outboxEvent.create({
|
||||
data: {
|
||||
id: finishedId,
|
||||
aggregateType: 'media_task',
|
||||
aggregateId: taskId,
|
||||
eventType: 'media.task.finished',
|
||||
payloadJson: { taskId, status: 'pending_review' },
|
||||
traceId: 'itmedia-result',
|
||||
},
|
||||
});
|
||||
await prisma.mediaTask.update({
|
||||
where: { id: taskId },
|
||||
data: {
|
||||
status: 'pending_review',
|
||||
itemSucceeded: 1,
|
||||
progress: 100,
|
||||
finishedAt: new Date(),
|
||||
},
|
||||
});
|
||||
|
||||
let finishedEvent = await prisma.outboxEvent.findUniqueOrThrow({ where: { id: finishedId } });
|
||||
let audit = await prisma.auditLog.findFirst({
|
||||
where: { actionCode: 'task.finished', targetId: taskId },
|
||||
});
|
||||
for (let attempt = 0; attempt < 40; attempt += 1) {
|
||||
if (finishedEvent.publishedAt !== null && audit !== null) break;
|
||||
await sleep(150);
|
||||
finishedEvent = await prisma.outboxEvent.findUniqueOrThrow({ where: { id: finishedId } });
|
||||
audit = await prisma.auditLog.findFirst({
|
||||
where: { actionCode: 'task.finished', targetId: taskId },
|
||||
});
|
||||
}
|
||||
expect(finishedEvent.publishedAt).not.toBeNull();
|
||||
expect(audit).not.toBeNull();
|
||||
expect(audit?.actorId).toBe(adminUserId);
|
||||
expect(audit?.targetType).toBe('task');
|
||||
expect(audit?.customerId).toBe(adminCustomerId);
|
||||
expect(audit?.result).toBe('success');
|
||||
|
||||
// created 事件仍归 dispatcher 处理;本地消费者不应把它标记为已消费为审计
|
||||
const createdAudit = await prisma.auditLog.findFirst({
|
||||
where: { actionCode: 'task.created', targetId: taskId },
|
||||
});
|
||||
expect(createdAudit).toBeNull();
|
||||
expect(createdEvent.eventType).toBe('media.task.created');
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,31 @@
|
||||
import { Module } from '@nestjs/common';
|
||||
|
||||
import { MediaTaskController } from './media-task.controller';
|
||||
import { MediaTaskRepository } from './media-task.repository';
|
||||
import { MediaTaskService } from './media-task.service';
|
||||
import { MediaTaskEstimator } from './media-task-estimator';
|
||||
import { MediaTaskResultConsumer } from './media-task-result-consumer.service';
|
||||
import { MediaTaskSseService } from './media-task-sse.service';
|
||||
|
||||
/**
|
||||
* media 模块(A6 任务中心):
|
||||
* - `POST/GET /media-tasks`、详情、SSE、取消、仅重试失败项(A6-BE-01 / 03 / 04);
|
||||
* - 提交只写 `outbox_events`(media.task.created),**不直接入队**(BullMQ 在 infra/queue,由 worker 侧投递);
|
||||
* - 本地消费 `media.task.finished` / `media.task.failed` 写审计(与 dispatcher 事件类型不重叠)。
|
||||
*
|
||||
* 依赖说明:PrismaModule / SecurityModule / PlatformModule(SystemConfigService / AUDIT_SINK)/
|
||||
* ClientModule(CustomersService)均为 @Global,本模块不反向依赖 identity / platform 实现细节。
|
||||
* 由 AppModule 统一接线(Lead 负责)。
|
||||
*/
|
||||
@Module({
|
||||
controllers: [MediaTaskController],
|
||||
providers: [
|
||||
MediaTaskRepository,
|
||||
MediaTaskEstimator,
|
||||
MediaTaskService,
|
||||
MediaTaskSseService,
|
||||
MediaTaskResultConsumer,
|
||||
],
|
||||
exports: [MediaTaskService],
|
||||
})
|
||||
export class MediaModule {}
|
||||
+303
@@ -4,6 +4,26 @@
|
||||
*/
|
||||
|
||||
export interface paths {
|
||||
'/api/v1/health/queues': {
|
||||
parameters: {
|
||||
query?: never;
|
||||
header?: never;
|
||||
path?: never;
|
||||
cookie?: never;
|
||||
};
|
||||
/**
|
||||
* 队列健康检查
|
||||
* @description 返回 9 个队列的计数、队首等待时长与 worker 心跳;Redis 不可用时返回 503(错误码 50002)。
|
||||
*/
|
||||
get: operations['QueueHealthController_queues'];
|
||||
put?: never;
|
||||
post?: never;
|
||||
delete?: never;
|
||||
options?: never;
|
||||
head?: never;
|
||||
patch?: never;
|
||||
trace?: never;
|
||||
};
|
||||
'/api/v1/auth/login': {
|
||||
parameters: {
|
||||
query?: never;
|
||||
@@ -716,6 +736,92 @@ export interface paths {
|
||||
patch: operations['DictsController_updateItem'];
|
||||
trace?: never;
|
||||
};
|
||||
'/api/v1/media-tasks': {
|
||||
parameters: {
|
||||
query?: never;
|
||||
header?: never;
|
||||
path?: never;
|
||||
cookie?: never;
|
||||
};
|
||||
/** 任务列表(类型 / 状态 / 客户 / 时间 / 我的 / 关键字筛选;稳定排序 createdAt DESC, id DESC) */
|
||||
get: operations['MediaTaskController_list'];
|
||||
put?: never;
|
||||
/** 提交任务(异步 202;M0 仅演练任务;必带 Idempotency-Key,同 key 同请求体 24h 内返回同一任务) */
|
||||
post: operations['MediaTaskController_submit'];
|
||||
delete?: never;
|
||||
options?: never;
|
||||
head?: never;
|
||||
patch?: never;
|
||||
trace?: never;
|
||||
};
|
||||
'/api/v1/media-tasks/{id}/events': {
|
||||
parameters: {
|
||||
query?: never;
|
||||
header?: never;
|
||||
path?: never;
|
||||
cookie?: never;
|
||||
};
|
||||
/** 任务 SSE 进度(id 用 task_events.seq;支持 Last-Event-ID / last_event_id 续推,15s 心跳) */
|
||||
get: operations['MediaTaskController_events'];
|
||||
put?: never;
|
||||
post?: never;
|
||||
delete?: never;
|
||||
options?: never;
|
||||
head?: never;
|
||||
patch?: never;
|
||||
trace?: never;
|
||||
};
|
||||
'/api/v1/media-tasks/{id}': {
|
||||
parameters: {
|
||||
query?: never;
|
||||
header?: never;
|
||||
path?: never;
|
||||
cookie?: never;
|
||||
};
|
||||
/** 任务详情(首 100 条任务项 + itemsNextCursor;范围外 404) */
|
||||
get: operations['MediaTaskController_detail'];
|
||||
put?: never;
|
||||
post?: never;
|
||||
delete?: never;
|
||||
options?: never;
|
||||
head?: never;
|
||||
patch?: never;
|
||||
trace?: never;
|
||||
};
|
||||
'/api/v1/media-tasks/{id}/cancel': {
|
||||
parameters: {
|
||||
query?: never;
|
||||
header?: never;
|
||||
path?: never;
|
||||
cookie?: never;
|
||||
};
|
||||
get?: never;
|
||||
put?: never;
|
||||
/** 取消任务(仅排队 / 生成中;只把未开始项置 skipped,已成功项不受影响) */
|
||||
post: operations['MediaTaskController_cancel'];
|
||||
delete?: never;
|
||||
options?: never;
|
||||
head?: never;
|
||||
patch?: never;
|
||||
trace?: never;
|
||||
};
|
||||
'/api/v1/media-tasks/{id}/retry': {
|
||||
parameters: {
|
||||
query?: never;
|
||||
header?: never;
|
||||
path?: never;
|
||||
cookie?: never;
|
||||
};
|
||||
get?: never;
|
||||
put?: never;
|
||||
/** 重试任务(仅部分失败;只重置失败项,成功项不动) */
|
||||
post: operations['MediaTaskController_retry'];
|
||||
delete?: never;
|
||||
options?: never;
|
||||
head?: never;
|
||||
patch?: never;
|
||||
trace?: never;
|
||||
};
|
||||
'/health': {
|
||||
parameters: {
|
||||
query?: never;
|
||||
@@ -911,6 +1017,74 @@ export interface paths {
|
||||
export type webhooks = Record<string, never>;
|
||||
export interface components {
|
||||
schemas: {
|
||||
QueueCountsDto: {
|
||||
/**
|
||||
* @description 等待中任务数
|
||||
* @example 0
|
||||
*/
|
||||
waiting: number;
|
||||
/**
|
||||
* @description 执行中任务数
|
||||
* @example 0
|
||||
*/
|
||||
active: number;
|
||||
/**
|
||||
* @description 已完成任务数(保留窗口内的历史计数)
|
||||
* @example 12
|
||||
*/
|
||||
completed: number;
|
||||
/**
|
||||
* @description 失败任务数(保留窗口内的历史计数)
|
||||
* @example 1
|
||||
*/
|
||||
failed: number;
|
||||
/**
|
||||
* @description 延迟任务数
|
||||
* @example 0
|
||||
*/
|
||||
delayed: number;
|
||||
};
|
||||
WorkerHeartbeatDto: {
|
||||
/** @description 最近 30s 内是否有 worker 心跳 */
|
||||
present: boolean;
|
||||
/**
|
||||
* @description 心跳年龄(秒);心跳缺失或值非法时为 null
|
||||
* @example 3
|
||||
*/
|
||||
ageSeconds: number | null;
|
||||
};
|
||||
QueueHealthEntryDto: {
|
||||
/**
|
||||
* @description 队列名
|
||||
* @example media:verify
|
||||
*/
|
||||
name: string;
|
||||
/**
|
||||
* @description 队列组
|
||||
* @example verify
|
||||
*/
|
||||
group: string;
|
||||
counts: components['schemas']['QueueCountsDto'];
|
||||
/**
|
||||
* @description 队首任务等待秒数
|
||||
* @example 0
|
||||
*/
|
||||
oldestWaitingSeconds: number;
|
||||
workerHeartbeat: components['schemas']['WorkerHeartbeatDto'];
|
||||
};
|
||||
QueueHealthReportDto: {
|
||||
/**
|
||||
* @example ok
|
||||
* @enum {string}
|
||||
*/
|
||||
status: 'ok' | 'degraded';
|
||||
/**
|
||||
* @example ok
|
||||
* @enum {string}
|
||||
*/
|
||||
redis: 'ok';
|
||||
queues: components['schemas']['QueueHealthEntryDto'][];
|
||||
};
|
||||
ComponentHealthDto: {
|
||||
/**
|
||||
* @example up
|
||||
@@ -1018,6 +1192,33 @@ export interface components {
|
||||
}
|
||||
export type $defs = Record<string, never>;
|
||||
export interface operations {
|
||||
QueueHealthController_queues: {
|
||||
parameters: {
|
||||
query?: never;
|
||||
header?: never;
|
||||
path?: never;
|
||||
cookie?: never;
|
||||
};
|
||||
requestBody?: never;
|
||||
responses: {
|
||||
/** @description 队列状态快照(可能为 degraded) */
|
||||
200: {
|
||||
headers: {
|
||||
[name: string]: unknown;
|
||||
};
|
||||
content: {
|
||||
'application/json': components['schemas']['QueueHealthReportDto'];
|
||||
};
|
||||
};
|
||||
/** @description Redis 不可用(错误码 50002) */
|
||||
503: {
|
||||
headers: {
|
||||
[name: string]: unknown;
|
||||
};
|
||||
content?: never;
|
||||
};
|
||||
};
|
||||
};
|
||||
AuthController_login: {
|
||||
parameters: {
|
||||
query?: never;
|
||||
@@ -1972,6 +2173,108 @@ export interface operations {
|
||||
};
|
||||
};
|
||||
};
|
||||
MediaTaskController_list: {
|
||||
parameters: {
|
||||
query?: never;
|
||||
header?: never;
|
||||
path?: never;
|
||||
cookie?: never;
|
||||
};
|
||||
requestBody?: never;
|
||||
responses: {
|
||||
200: {
|
||||
headers: {
|
||||
[name: string]: unknown;
|
||||
};
|
||||
content?: never;
|
||||
};
|
||||
};
|
||||
};
|
||||
MediaTaskController_submit: {
|
||||
parameters: {
|
||||
query?: never;
|
||||
header?: never;
|
||||
path?: never;
|
||||
cookie?: never;
|
||||
};
|
||||
requestBody?: never;
|
||||
responses: {
|
||||
202: {
|
||||
headers: {
|
||||
[name: string]: unknown;
|
||||
};
|
||||
content?: never;
|
||||
};
|
||||
};
|
||||
};
|
||||
MediaTaskController_events: {
|
||||
parameters: {
|
||||
query?: never;
|
||||
header?: never;
|
||||
path?: never;
|
||||
cookie?: never;
|
||||
};
|
||||
requestBody?: never;
|
||||
responses: {
|
||||
200: {
|
||||
headers: {
|
||||
[name: string]: unknown;
|
||||
};
|
||||
content?: never;
|
||||
};
|
||||
};
|
||||
};
|
||||
MediaTaskController_detail: {
|
||||
parameters: {
|
||||
query?: never;
|
||||
header?: never;
|
||||
path?: never;
|
||||
cookie?: never;
|
||||
};
|
||||
requestBody?: never;
|
||||
responses: {
|
||||
200: {
|
||||
headers: {
|
||||
[name: string]: unknown;
|
||||
};
|
||||
content?: never;
|
||||
};
|
||||
};
|
||||
};
|
||||
MediaTaskController_cancel: {
|
||||
parameters: {
|
||||
query?: never;
|
||||
header?: never;
|
||||
path?: never;
|
||||
cookie?: never;
|
||||
};
|
||||
requestBody?: never;
|
||||
responses: {
|
||||
201: {
|
||||
headers: {
|
||||
[name: string]: unknown;
|
||||
};
|
||||
content?: never;
|
||||
};
|
||||
};
|
||||
};
|
||||
MediaTaskController_retry: {
|
||||
parameters: {
|
||||
query?: never;
|
||||
header?: never;
|
||||
path?: never;
|
||||
cookie?: never;
|
||||
};
|
||||
requestBody?: never;
|
||||
responses: {
|
||||
201: {
|
||||
headers: {
|
||||
[name: string]: unknown;
|
||||
};
|
||||
content?: never;
|
||||
};
|
||||
};
|
||||
};
|
||||
HealthController_check: {
|
||||
parameters: {
|
||||
query?: never;
|
||||
|
||||
@@ -6,10 +6,13 @@
|
||||
"dev": "tsx watch src/main.ts",
|
||||
"build": "tsc -p tsconfig.build.json",
|
||||
"start": "node dist/main.js",
|
||||
"test": "vitest run --coverage",
|
||||
"typecheck": "tsc -p tsconfig.json --noEmit"
|
||||
},
|
||||
"dependencies": {
|
||||
"@lingxi/shared": "workspace:*",
|
||||
"@prisma/client": "6.19.3",
|
||||
"bullmq": "5.81.5",
|
||||
"dotenv": "18.0.9",
|
||||
"ioredis": "5.11.1",
|
||||
"pino": "10.4.0",
|
||||
@@ -17,7 +20,9 @@
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/node": "24.19.2",
|
||||
"@vitest/coverage-v8": "3.2.7",
|
||||
"tsx": "4.23.15",
|
||||
"typescript": "5.9.3"
|
||||
"typescript": "5.9.3",
|
||||
"vitest": "3.2.7"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,58 @@
|
||||
import { TASK_QUEUES } from '@lingxi/shared';
|
||||
import { describe, expect, it } from 'vitest';
|
||||
|
||||
import { loadWorkerConfig } from './config';
|
||||
|
||||
const BASE_ENV: NodeJS.ProcessEnv = {
|
||||
REDIS_URL: 'redis://127.0.0.1:6379/1',
|
||||
DATABASE_URL: 'postgresql://postgres:postgres@127.0.0.1:5432/lingxi?schema=public',
|
||||
};
|
||||
|
||||
describe('worker 配置解析', () => {
|
||||
it('QUEUE=all 启动全部 9 个队列,并发取 TASK_QUEUES 定义值', () => {
|
||||
const config = loadWorkerConfig({ ...BASE_ENV, QUEUE: 'all' });
|
||||
expect(config.queues).toHaveLength(TASK_QUEUES.length);
|
||||
expect(config.queues.map((queue) => queue.name)).toEqual(
|
||||
TASK_QUEUES.map((queue) => queue.name),
|
||||
);
|
||||
const verify = config.queues.find((queue) => queue.name === 'media:verify');
|
||||
const defined = TASK_QUEUES.find((queue) => queue.name === 'media:verify');
|
||||
expect(verify?.concurrency).toBe(defined?.concurrency);
|
||||
expect(verify?.attempts).toBe(defined?.attempts);
|
||||
expect(verify?.backoffDelayMs).toBe(defined?.backoffDelayMs);
|
||||
});
|
||||
|
||||
it('QUEUE=verify 只启动 verify 组(M0 精简 worker)', () => {
|
||||
const config = loadWorkerConfig({ ...BASE_ENV, QUEUE: 'verify' });
|
||||
expect(config.queues.map((queue) => queue.name)).toEqual(['media:verify']);
|
||||
});
|
||||
|
||||
it('WORKER_CONCURRENCY 覆盖全部队列并发', () => {
|
||||
const config = loadWorkerConfig({ ...BASE_ENV, QUEUE: 'verify', WORKER_CONCURRENCY: '7' });
|
||||
expect(config.queues[0]?.concurrency).toBe(7);
|
||||
});
|
||||
|
||||
it('QUEUE 非法值给出中文原因(含旧值 default)', () => {
|
||||
expect(() => loadWorkerConfig({ ...BASE_ENV, QUEUE: 'default' })).toThrow(
|
||||
/QUEUE 配置非法.*未知队列/,
|
||||
);
|
||||
expect(() => loadWorkerConfig({ ...BASE_ENV, QUEUE: 'all,verify' })).toThrow(
|
||||
/QUEUE 配置非法.*不能与其他分组/,
|
||||
);
|
||||
});
|
||||
|
||||
it('缺少或非法连接串时中文报错', () => {
|
||||
expect(() => loadWorkerConfig({ QUEUE: 'all' })).toThrow(/REDIS_URL/);
|
||||
expect(() => loadWorkerConfig({ ...BASE_ENV, DATABASE_URL: 'mysql://x' })).toThrow(
|
||||
/DATABASE_URL/,
|
||||
);
|
||||
});
|
||||
|
||||
it('WORKER_ID 默认 hostname-pid,可显式覆盖', () => {
|
||||
const config = loadWorkerConfig({ ...BASE_ENV, QUEUE: 'verify' });
|
||||
expect(config.workerId).toContain(`-${String(process.pid)}`);
|
||||
expect(loadWorkerConfig({ ...BASE_ENV, QUEUE: 'verify', WORKER_ID: 'w-1' }).workerId).toBe(
|
||||
'w-1',
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -1,5 +1,9 @@
|
||||
import os from 'node:os';
|
||||
|
||||
import { z } from 'zod';
|
||||
|
||||
import { buildQueueDefinitions, parseQueueSpec, type WorkerQueueDefinition } from './queues';
|
||||
|
||||
const envSchema = z.object({
|
||||
NODE_ENV: z.enum(['development', 'test', 'production']).default('development'),
|
||||
LOG_LEVEL: z.enum(['fatal', 'error', 'warn', 'info', 'debug', 'trace']).default('info'),
|
||||
@@ -10,18 +14,38 @@ const envSchema = z.object({
|
||||
(value) => value.startsWith('redis://') || value.startsWith('rediss://'),
|
||||
'REDIS_URL 必须是 redis:// 连接串',
|
||||
),
|
||||
QUEUE: z.string().min(1).default('default'),
|
||||
WORKER_CONCURRENCY: z.coerce.number().int().min(1).max(64).default(2),
|
||||
DATABASE_URL: z
|
||||
.string()
|
||||
.min(1)
|
||||
.refine(
|
||||
(value) => value.startsWith('postgresql://') || value.startsWith('postgres://'),
|
||||
'DATABASE_URL 必须是 postgresql:// 连接串',
|
||||
),
|
||||
QUEUE: z.string().min(1).default('all'),
|
||||
WORKER_CONCURRENCY: z.coerce.number().int().min(1).max(64).optional(),
|
||||
WORKER_ID: z.string().min(1).max(128).optional(),
|
||||
});
|
||||
|
||||
export interface WorkerConfig {
|
||||
nodeEnv: 'development' | 'test' | 'production';
|
||||
logLevel: 'fatal' | 'error' | 'warn' | 'info' | 'debug' | 'trace';
|
||||
redisUrl: string;
|
||||
queue: string;
|
||||
concurrency: number;
|
||||
databaseUrl: string;
|
||||
queueSpec: string;
|
||||
queues: WorkerQueueDefinition[];
|
||||
workerId: string;
|
||||
}
|
||||
|
||||
function defaultWorkerId(): string {
|
||||
return `${os.hostname()}-${String(process.pid)}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* 加载并校验 worker 环境变量:
|
||||
* - QUEUE:`all` 或逗号分隔的队列组名(script/image/video/export/verify)/ 队列名(如 media:verify);
|
||||
* - WORKER_CONCURRENCY:覆盖全部队列并发;缺省取 TASK_QUEUES 中每个队列的定义值;
|
||||
* - WORKER_ID:默认 hostname + pid,用于心跳与日志定位。
|
||||
*/
|
||||
export function loadWorkerConfig(source: NodeJS.ProcessEnv = process.env): WorkerConfig {
|
||||
const result = envSchema.safeParse(source);
|
||||
if (!result.success) {
|
||||
@@ -30,11 +54,24 @@ export function loadWorkerConfig(source: NodeJS.ProcessEnv = process.env): Worke
|
||||
.join('; ');
|
||||
throw new Error(`Worker 环境变量校验失败:${details}`);
|
||||
}
|
||||
const env = result.data;
|
||||
|
||||
let queues: WorkerQueueDefinition[];
|
||||
try {
|
||||
queues = buildQueueDefinitions(parseQueueSpec(env.QUEUE), env.WORKER_CONCURRENCY);
|
||||
} catch (error) {
|
||||
throw new Error(
|
||||
`QUEUE 配置非法(当前值 ${env.QUEUE}):${error instanceof Error ? error.message : String(error)}`,
|
||||
);
|
||||
}
|
||||
|
||||
return {
|
||||
nodeEnv: result.data.NODE_ENV,
|
||||
logLevel: result.data.LOG_LEVEL,
|
||||
redisUrl: result.data.REDIS_URL,
|
||||
queue: result.data.QUEUE,
|
||||
concurrency: result.data.WORKER_CONCURRENCY,
|
||||
nodeEnv: env.NODE_ENV,
|
||||
logLevel: env.LOG_LEVEL,
|
||||
redisUrl: env.REDIS_URL,
|
||||
databaseUrl: env.DATABASE_URL,
|
||||
queueSpec: env.QUEUE,
|
||||
queues,
|
||||
workerId: env.WORKER_ID ?? defaultWorkerId(),
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,50 @@
|
||||
import { describe, expect, it, vi } from 'vitest';
|
||||
|
||||
import {
|
||||
buildHeartbeat,
|
||||
clearWorkerHeartbeat,
|
||||
heartbeatKey,
|
||||
serializeHeartbeat,
|
||||
WORKER_HEARTBEAT_INTERVAL_MS,
|
||||
WORKER_HEARTBEAT_TTL_SECONDS,
|
||||
writeWorkerHeartbeat,
|
||||
} from './heartbeat';
|
||||
|
||||
describe('worker 心跳', () => {
|
||||
it('key 统一为 lingxi:worker:heartbeat:<queueName>', () => {
|
||||
expect(heartbeatKey('media:verify')).toBe('lingxi:worker:heartbeat:media:verify');
|
||||
expect(heartbeatKey('script:generate')).toBe('lingxi:worker:heartbeat:script:generate');
|
||||
});
|
||||
|
||||
it('心跳内容含 workerId / pid / queue / updatedAt(ISO 字符串)', () => {
|
||||
const updatedAt = new Date('2026-11-04T12:00:00.000Z');
|
||||
const payload = buildHeartbeat('media:verify', 'w-1', 123, updatedAt);
|
||||
expect(payload).toEqual({
|
||||
workerId: 'w-1',
|
||||
pid: 123,
|
||||
queue: 'media:verify',
|
||||
updatedAt: '2026-11-04T12:00:00.000Z',
|
||||
});
|
||||
expect(JSON.parse(serializeHeartbeat(payload))).toEqual(payload);
|
||||
});
|
||||
|
||||
it('心跳 TTL 30s、续写间隔 10s(与 API 侧读取口径一致)', () => {
|
||||
expect(WORKER_HEARTBEAT_TTL_SECONDS).toBe(30);
|
||||
expect(WORKER_HEARTBEAT_INTERVAL_MS).toBe(10_000);
|
||||
});
|
||||
|
||||
it('写心跳使用 EX 30,退出时删除心跳', async () => {
|
||||
const set = vi.fn().mockResolvedValue('OK');
|
||||
const del = vi.fn().mockResolvedValue(1);
|
||||
const client = { set, del };
|
||||
await writeWorkerHeartbeat(client, 'media:verify', 'w-1');
|
||||
expect(set).toHaveBeenCalledWith(
|
||||
'lingxi:worker:heartbeat:media:verify',
|
||||
expect.stringContaining('"workerId":"w-1"'),
|
||||
'EX',
|
||||
30,
|
||||
);
|
||||
await clearWorkerHeartbeat(client, 'media:verify');
|
||||
expect(del).toHaveBeenCalledWith('lingxi:worker:heartbeat:media:verify');
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,61 @@
|
||||
import type { TaskQueueName } from '@lingxi/shared';
|
||||
/** 心跳 key 前缀(跨 app 契约,与 apps/api/src/infra/queue/queue.constants.ts 保持一致) */
|
||||
export const WORKER_HEARTBEAT_KEY_PREFIX = 'lingxi:worker:heartbeat:';
|
||||
|
||||
/** 心跳 TTL(秒):worker 每 10s 续写一次,API 侧超过 TTL 视为心跳缺失 */
|
||||
export const WORKER_HEARTBEAT_TTL_SECONDS = 30;
|
||||
|
||||
/** 心跳续写间隔(毫秒) */
|
||||
export const WORKER_HEARTBEAT_INTERVAL_MS = 10_000;
|
||||
|
||||
export interface HeartbeatRedis {
|
||||
set(key: string, value: string, mode: 'EX', seconds: number): Promise<unknown>;
|
||||
del(key: string): Promise<unknown>;
|
||||
}
|
||||
|
||||
export interface WorkerHeartbeatPayload {
|
||||
workerId: string;
|
||||
pid: number;
|
||||
queue: TaskQueueName;
|
||||
updatedAt: string;
|
||||
}
|
||||
|
||||
/** `lingxi:worker:heartbeat:<queueName>` */
|
||||
export function heartbeatKey(queue: TaskQueueName): string {
|
||||
return `${WORKER_HEARTBEAT_KEY_PREFIX}${queue}`;
|
||||
}
|
||||
|
||||
export function buildHeartbeat(
|
||||
queue: TaskQueueName,
|
||||
workerId: string,
|
||||
pid: number,
|
||||
updatedAt: Date = new Date(),
|
||||
): WorkerHeartbeatPayload {
|
||||
return { workerId, pid, queue, updatedAt: updatedAt.toISOString() };
|
||||
}
|
||||
|
||||
export function serializeHeartbeat(payload: WorkerHeartbeatPayload): string {
|
||||
return JSON.stringify(payload);
|
||||
}
|
||||
|
||||
export async function writeWorkerHeartbeat(
|
||||
client: HeartbeatRedis,
|
||||
queue: TaskQueueName,
|
||||
workerId: string,
|
||||
now: Date = new Date(),
|
||||
): Promise<void> {
|
||||
const payload = buildHeartbeat(queue, workerId, process.pid, now);
|
||||
await client.set(
|
||||
heartbeatKey(queue),
|
||||
serializeHeartbeat(payload),
|
||||
'EX',
|
||||
WORKER_HEARTBEAT_TTL_SECONDS,
|
||||
);
|
||||
}
|
||||
|
||||
export async function clearWorkerHeartbeat(
|
||||
client: HeartbeatRedis,
|
||||
queue: TaskQueueName,
|
||||
): Promise<void> {
|
||||
await client.del(heartbeatKey(queue));
|
||||
}
|
||||
+187
-22
@@ -1,11 +1,21 @@
|
||||
import { existsSync } from 'node:fs';
|
||||
import { resolve } from 'node:path';
|
||||
|
||||
import { PrismaClient } from '@prisma/client';
|
||||
import { type Job, Worker } from 'bullmq';
|
||||
import { config as loadDotenvFile } from 'dotenv';
|
||||
import Redis from 'ioredis';
|
||||
import pino from 'pino';
|
||||
import pino, { type Logger } from 'pino';
|
||||
|
||||
import { loadWorkerConfig } from './config';
|
||||
import { loadWorkerConfig, type WorkerConfig } from './config';
|
||||
import {
|
||||
clearWorkerHeartbeat,
|
||||
WORKER_HEARTBEAT_INTERVAL_MS,
|
||||
writeWorkerHeartbeat,
|
||||
} from './heartbeat';
|
||||
import { isDemoJob, parseTaskJobData, processDemoJob, type TaskJobData } from './processors';
|
||||
import { toBullQueueName, type WorkerQueueDefinition } from './queues';
|
||||
import { parseRedisOptions, type RedisConnectionOptions } from './redis-options';
|
||||
|
||||
function loadDotenv(): void {
|
||||
for (const file of ['.env.local', '.env']) {
|
||||
@@ -16,34 +26,149 @@ function loadDotenv(): void {
|
||||
}
|
||||
}
|
||||
|
||||
async function main(): Promise<void> {
|
||||
loadDotenv();
|
||||
const config = loadWorkerConfig();
|
||||
const logger = pino({ level: config.logLevel, base: { service: 'lingxi-worker' } });
|
||||
interface WorkerRuntime {
|
||||
config: WorkerConfig;
|
||||
logger: Logger;
|
||||
heartbeatRedis: Redis;
|
||||
prisma: PrismaClient;
|
||||
workers: Worker<TaskJobData, unknown>[];
|
||||
heartbeatTimer: NodeJS.Timeout;
|
||||
}
|
||||
|
||||
const redis = new Redis(config.redisUrl, {
|
||||
function createHeartbeatRedis(config: WorkerConfig, logger: Logger): Redis {
|
||||
const client = new Redis(config.redisUrl, {
|
||||
lazyConnect: true,
|
||||
maxRetriesPerRequest: null,
|
||||
maxRetriesPerRequest: 1,
|
||||
enableOfflineQueue: false,
|
||||
});
|
||||
redis.on('error', (error: Error) => {
|
||||
logger.error({ err: error }, 'redis connection error');
|
||||
client.on('error', (error: Error) => {
|
||||
logger.warn({ err: error.message }, '心跳 Redis 连接异常');
|
||||
});
|
||||
return client;
|
||||
}
|
||||
|
||||
await redis.connect();
|
||||
await redis.ping();
|
||||
logger.info({ queue: config.queue, concurrency: config.concurrency }, 'worker 已启动,等待任务');
|
||||
/**
|
||||
* job 分发:M0 只有 `media:verify` 的 `task:demo`。
|
||||
* 其余队列 / job 在本里程碑没有处理器:记录告警后直接完成,避免无意义重试。
|
||||
*/
|
||||
function createJobHandler(
|
||||
config: WorkerConfig,
|
||||
prisma: PrismaClient,
|
||||
logger: Logger,
|
||||
): (job: Job<TaskJobData, unknown>) => Promise<void> {
|
||||
// BullMQ 的 Job.queueName 是物理队列名(media-verify),按物理名反查队列定义
|
||||
const definitionByPhysicalName = new Map(
|
||||
config.queues.map((definition) => [toBullQueueName(definition.name), definition]),
|
||||
);
|
||||
return async (job) => {
|
||||
const data = parseTaskJobData(job.data);
|
||||
if (isDemoJob(job.queueName, job.name)) {
|
||||
const definition = definitionByPhysicalName.get(job.queueName);
|
||||
const outcome = await processDemoJob(
|
||||
prisma,
|
||||
data,
|
||||
job.attemptsMade,
|
||||
definition?.attempts ?? 1,
|
||||
);
|
||||
logger.debug({ taskId: data.taskId, itemId: data.itemId, outcome }, '演练任务项处理完成');
|
||||
return;
|
||||
}
|
||||
logger.warn(
|
||||
{ queue: job.queueName, job: job.name },
|
||||
'该队列在本里程碑没有处理器,任务项直接完成',
|
||||
);
|
||||
};
|
||||
}
|
||||
|
||||
// A0 只验证进程与依赖可用;BullMQ 消费者在 M1 批次接入(QUEUE 环境变量决定队列)
|
||||
const heartbeat = setInterval(() => {
|
||||
logger.debug({ queue: config.queue }, 'worker heartbeat');
|
||||
}, 30_000);
|
||||
heartbeat.unref();
|
||||
function createWorker(
|
||||
definition: WorkerQueueDefinition,
|
||||
config: WorkerConfig,
|
||||
prisma: PrismaClient,
|
||||
logger: Logger,
|
||||
connection: RedisConnectionOptions,
|
||||
): Worker<TaskJobData, unknown> {
|
||||
const handler = createJobHandler(config, prisma, logger);
|
||||
return new Worker<TaskJobData, unknown>(
|
||||
toBullQueueName(definition.name),
|
||||
async (job) => {
|
||||
await handler(job);
|
||||
},
|
||||
{ connection, concurrency: definition.concurrency },
|
||||
);
|
||||
}
|
||||
|
||||
function attachWorkerLogging(
|
||||
worker: Worker<TaskJobData, unknown>,
|
||||
queue: string,
|
||||
logger: Logger,
|
||||
): void {
|
||||
worker.on('completed', (job) => {
|
||||
logger.debug({ queue, jobId: job.id }, 'job 处理完成');
|
||||
});
|
||||
worker.on('failed', (job, error) => {
|
||||
logger.warn({ queue, jobId: job?.id, err: error.message }, 'job 处理失败');
|
||||
});
|
||||
worker.on('error', (error: Error) => {
|
||||
logger.error({ queue, err: error.message }, 'worker 连接错误');
|
||||
});
|
||||
}
|
||||
|
||||
async function writeAllHeartbeats(
|
||||
client: Redis,
|
||||
config: WorkerConfig,
|
||||
logger: Logger,
|
||||
): Promise<void> {
|
||||
for (const definition of config.queues) {
|
||||
try {
|
||||
await writeWorkerHeartbeat(client, definition.name, config.workerId);
|
||||
} catch (error) {
|
||||
logger.warn(
|
||||
{
|
||||
queue: definition.name,
|
||||
err: error instanceof Error ? error.message : String(error),
|
||||
},
|
||||
'worker 心跳写入失败',
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function registerShutdown(runtime: WorkerRuntime): void {
|
||||
let shuttingDown = false;
|
||||
const shutdown = async (signal: string): Promise<void> => {
|
||||
clearInterval(heartbeat);
|
||||
logger.info({ signal }, 'worker 正在退出');
|
||||
await redis.quit();
|
||||
if (shuttingDown) {
|
||||
return;
|
||||
}
|
||||
shuttingDown = true;
|
||||
runtime.logger.info({ signal }, 'worker 正在退出');
|
||||
clearInterval(runtime.heartbeatTimer);
|
||||
|
||||
await Promise.all(
|
||||
runtime.workers.map(async (worker) => {
|
||||
try {
|
||||
await worker.close();
|
||||
} catch (error) {
|
||||
runtime.logger.warn(
|
||||
{ err: error instanceof Error ? error.message : String(error) },
|
||||
'关闭 worker 失败',
|
||||
);
|
||||
}
|
||||
}),
|
||||
);
|
||||
for (const definition of runtime.config.queues) {
|
||||
try {
|
||||
await clearWorkerHeartbeat(runtime.heartbeatRedis, definition.name);
|
||||
} catch {
|
||||
// Redis 已不可用时无需阻塞退出
|
||||
}
|
||||
}
|
||||
try {
|
||||
await runtime.heartbeatRedis.quit();
|
||||
} catch {
|
||||
runtime.heartbeatRedis.disconnect();
|
||||
}
|
||||
await runtime.prisma.$disconnect();
|
||||
runtime.logger.info('worker 已退出');
|
||||
};
|
||||
|
||||
process.on('SIGINT', () => {
|
||||
@@ -54,7 +179,47 @@ async function main(): Promise<void> {
|
||||
});
|
||||
}
|
||||
|
||||
void main().catch((error: unknown) => {
|
||||
async function bootstrap(): Promise<void> {
|
||||
loadDotenv();
|
||||
const config = loadWorkerConfig();
|
||||
const logger = pino({
|
||||
level: config.logLevel,
|
||||
base: { service: 'lingxi-worker', workerId: config.workerId },
|
||||
});
|
||||
|
||||
const heartbeatRedis = createHeartbeatRedis(config, logger);
|
||||
await heartbeatRedis.connect();
|
||||
await heartbeatRedis.ping();
|
||||
|
||||
const prisma = new PrismaClient({ datasources: { db: { url: config.databaseUrl } } });
|
||||
await prisma.$queryRaw`SELECT 1`;
|
||||
|
||||
const connection = parseRedisOptions(config.redisUrl);
|
||||
const workers = config.queues.map((definition) => {
|
||||
const worker = createWorker(definition, config, prisma, logger, connection);
|
||||
attachWorkerLogging(worker, definition.name, logger);
|
||||
return worker;
|
||||
});
|
||||
|
||||
await writeAllHeartbeats(heartbeatRedis, config, logger);
|
||||
const heartbeatTimer = setInterval(() => {
|
||||
void writeAllHeartbeats(heartbeatRedis, config, logger);
|
||||
}, WORKER_HEARTBEAT_INTERVAL_MS);
|
||||
heartbeatTimer.unref();
|
||||
|
||||
logger.info(
|
||||
{
|
||||
queues: config.queues.map((definition) => definition.name),
|
||||
queueSpec: config.queueSpec,
|
||||
concurrency: config.queues.map((definition) => definition.concurrency),
|
||||
},
|
||||
'worker 已启动,等待任务',
|
||||
);
|
||||
|
||||
registerShutdown({ config, logger, heartbeatRedis, prisma, workers, heartbeatTimer });
|
||||
}
|
||||
|
||||
void bootstrap().catch((error: unknown) => {
|
||||
process.stderr.write(
|
||||
`Worker 启动失败:${error instanceof Error ? (error.stack ?? error.message) : String(error)}\n`,
|
||||
);
|
||||
|
||||
@@ -0,0 +1,72 @@
|
||||
import { isTaskCountersFinished, resolveFinishedStatus, type TaskCounters } from '@lingxi/shared';
|
||||
import { describe, expect, it } from 'vitest';
|
||||
|
||||
import {
|
||||
applyItemResult,
|
||||
decideDemoOutcome,
|
||||
demoDurationMs,
|
||||
normalizeDemoMode,
|
||||
} from './demo.processor';
|
||||
|
||||
const RUNNING_COUNTERS: TaskCounters = {
|
||||
itemTotal: 4,
|
||||
itemSucceeded: 0,
|
||||
itemFailed: 0,
|
||||
itemSkipped: 0,
|
||||
};
|
||||
|
||||
describe('演练任务判定(纯函数)', () => {
|
||||
it('success 模式全部成功;mixed 模式 seq%4===0 失败;fail 模式全失败', () => {
|
||||
expect(decideDemoOutcome('success', 4, 0, 2)).toBe('success');
|
||||
expect(decideDemoOutcome('mixed', 3, 0, 2)).toBe('success');
|
||||
expect(decideDemoOutcome('mixed', 4, 0, 2)).toBe('retry');
|
||||
expect(decideDemoOutcome('mixed', 4, 1, 2)).toBe('failure');
|
||||
expect(decideDemoOutcome('fail', 1, 0, 3)).toBe('retry');
|
||||
expect(decideDemoOutcome('fail', 1, 2, 3)).toBe('failure');
|
||||
expect(decideDemoOutcome('fail', 1, 0, 1)).toBe('failure');
|
||||
});
|
||||
|
||||
it('未知 mode 回退 success(脏数据不阻塞任务收敛)', () => {
|
||||
expect(normalizeDemoMode(undefined)).toBe('success');
|
||||
expect(normalizeDemoMode('weird')).toBe('success');
|
||||
expect(normalizeDemoMode('mixed')).toBe('mixed');
|
||||
});
|
||||
|
||||
it('耗时公式确定性:300 + seq%5*150', () => {
|
||||
expect(demoDurationMs(1)).toBe(450);
|
||||
expect(demoDurationMs(5)).toBe(300);
|
||||
expect(demoDurationMs(4)).toBe(900);
|
||||
});
|
||||
|
||||
it('任务项计数按结果原子累加,不丢其他计数', () => {
|
||||
expect(applyItemResult(RUNNING_COUNTERS, 'succeeded')).toEqual({
|
||||
...RUNNING_COUNTERS,
|
||||
itemSucceeded: 1,
|
||||
});
|
||||
expect(applyItemResult(RUNNING_COUNTERS, 'failed').itemFailed).toBe(1);
|
||||
expect(applyItemResult(RUNNING_COUNTERS, 'skipped').itemSkipped).toBe(1);
|
||||
});
|
||||
|
||||
it('终态判定使用 shared 契约(成功→待预览 / 部分失败 / 全部失败 / 取消优先)', () => {
|
||||
const allSucceeded: TaskCounters = {
|
||||
itemTotal: 2,
|
||||
itemSucceeded: 2,
|
||||
itemFailed: 0,
|
||||
itemSkipped: 0,
|
||||
};
|
||||
const partial: TaskCounters = { itemTotal: 2, itemSucceeded: 1, itemFailed: 1, itemSkipped: 0 };
|
||||
const allFailed: TaskCounters = {
|
||||
itemTotal: 2,
|
||||
itemSucceeded: 0,
|
||||
itemFailed: 2,
|
||||
itemSkipped: 0,
|
||||
};
|
||||
|
||||
expect(isTaskCountersFinished(partial)).toBe(true);
|
||||
expect(isTaskCountersFinished(RUNNING_COUNTERS)).toBe(false);
|
||||
expect(resolveFinishedStatus(allSucceeded, false)).toBe('pending_review');
|
||||
expect(resolveFinishedStatus(partial, false)).toBe('partial_failed');
|
||||
expect(resolveFinishedStatus(allFailed, false)).toBe('failed');
|
||||
expect(resolveFinishedStatus(partial, true)).toBe('canceled');
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,462 @@
|
||||
/**
|
||||
* 演练任务(demo)处理器 —— A6-BE-02 的最小可执行链路。
|
||||
*
|
||||
* 设计要点:
|
||||
* 1. 事务 + 行锁:先 `SELECT ... FOR UPDATE` 锁任务头再锁任务项,保证同一任务的并发项计数不丢;
|
||||
* 2. 幂等:任务项已是终态 / 已被其他 job 抢先执行(条件更新 count=0)→ 直接静默完成;
|
||||
* 3. 取消:任务头 `cancel_requested_at` 非空 → 任务项标 skipped、写事件、按需收敛终态后快速返回;
|
||||
* 4. 重试:非最后一次尝试失败 → 任务项置回 queued 后抛错,由 BullMQ 按队列定义退避重试;
|
||||
* 5. 收敛:全部任务项终态时在同一事务内写 done 事件与 outbox 事件(finished / failed)。
|
||||
*
|
||||
* 纯函数(decideDemoOutcome / applyItemResult / demoDurationMs)单独导出,便于单测。
|
||||
*/
|
||||
import {
|
||||
isTaskCountersFinished,
|
||||
isTaskTerminal,
|
||||
resolveFinishedStatus,
|
||||
TASK_ITEM_STATUSES,
|
||||
TASK_OUTBOX_EVENTS,
|
||||
TASK_STATUSES,
|
||||
type TaskCounters,
|
||||
type TaskDoneEventPayload,
|
||||
type TaskEventType,
|
||||
type TaskItemEventPayload,
|
||||
type TaskItemStatus,
|
||||
taskProgress,
|
||||
type TaskProgressEventPayload,
|
||||
type TaskStatus,
|
||||
ulid,
|
||||
} from '@lingxi/shared';
|
||||
import { Prisma, type PrismaClient } from '@prisma/client';
|
||||
|
||||
export interface DemoJobData {
|
||||
taskId: string;
|
||||
itemId: string;
|
||||
}
|
||||
|
||||
export type DemoMode = 'success' | 'mixed' | 'fail';
|
||||
|
||||
/** 单次尝试的判定结果:成功 / 最后一次尝试失败 / 非最后尝试失败需重试 */
|
||||
export type DemoAttemptOutcome = 'success' | 'failure' | 'retry';
|
||||
|
||||
export type DemoProcessorOutcome = 'succeeded' | 'failed' | 'skipped' | 'canceled' | 'retry';
|
||||
|
||||
/** 任务项结果计数增减(纯函数) */
|
||||
export function applyItemResult(
|
||||
counters: TaskCounters,
|
||||
result: 'succeeded' | 'failed' | 'skipped',
|
||||
): TaskCounters {
|
||||
switch (result) {
|
||||
case 'succeeded':
|
||||
return { ...counters, itemSucceeded: counters.itemSucceeded + 1 };
|
||||
case 'failed':
|
||||
return { ...counters, itemFailed: counters.itemFailed + 1 };
|
||||
case 'skipped':
|
||||
return { ...counters, itemSkipped: counters.itemSkipped + 1 };
|
||||
}
|
||||
}
|
||||
|
||||
/** 确定性耗时(300ms + seq%5*150ms),便于测试与本地演示 */
|
||||
export function demoDurationMs(seq: number): number {
|
||||
return 300 + (seq % 5) * 150;
|
||||
}
|
||||
|
||||
export function normalizeDemoMode(value: unknown): DemoMode {
|
||||
return value === 'mixed' || value === 'fail' ? value : 'success';
|
||||
}
|
||||
|
||||
/**
|
||||
* 判定本次尝试结果(纯函数):
|
||||
* - success:全成功,或 mixed 模式下 seq 不是 4 的倍数;
|
||||
* - failure:最后一次尝试仍失败 → 任务项置 failed;
|
||||
* - retry:非最后一次尝试失败 → 任务项置回 queued 并抛错交给 BullMQ 重试。
|
||||
*/
|
||||
export function decideDemoOutcome(
|
||||
mode: DemoMode,
|
||||
seq: number,
|
||||
attemptsMade: number,
|
||||
maxAttempts: number,
|
||||
): DemoAttemptOutcome {
|
||||
const succeeded = mode === 'success' || (mode === 'mixed' && seq % 4 !== 0);
|
||||
if (succeeded) {
|
||||
return 'success';
|
||||
}
|
||||
return attemptsMade + 1 >= maxAttempts ? 'failure' : 'retry';
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// DB 行类型与只读查询
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
interface TaskRow {
|
||||
id: string;
|
||||
status: string;
|
||||
payload_json: unknown;
|
||||
item_total: number;
|
||||
item_succeeded: number;
|
||||
item_failed: number;
|
||||
item_skipped: number;
|
||||
cancel_requested_at: Date | null;
|
||||
}
|
||||
|
||||
interface ItemRow {
|
||||
id: string;
|
||||
seq: number;
|
||||
status: string;
|
||||
}
|
||||
|
||||
function isTaskStatus(value: string): value is TaskStatus {
|
||||
return TASK_STATUSES.some((status) => status === value);
|
||||
}
|
||||
|
||||
function isItemStatus(value: string): value is TaskItemStatus {
|
||||
return TASK_ITEM_STATUSES.some((status) => status === value);
|
||||
}
|
||||
|
||||
function isRecord(value: unknown): value is Record<string, unknown> {
|
||||
return typeof value === 'object' && value !== null && !Array.isArray(value);
|
||||
}
|
||||
|
||||
function modeOf(payload: unknown): DemoMode {
|
||||
return isRecord(payload) ? normalizeDemoMode(payload.mode) : 'success';
|
||||
}
|
||||
|
||||
function countersOfTask(task: TaskRow): TaskCounters {
|
||||
return {
|
||||
itemTotal: task.item_total,
|
||||
itemSucceeded: task.item_succeeded,
|
||||
itemFailed: task.item_failed,
|
||||
itemSkipped: task.item_skipped,
|
||||
};
|
||||
}
|
||||
|
||||
function isTaskCanceled(task: TaskRow): boolean {
|
||||
return task.cancel_requested_at !== null || task.status === 'canceled';
|
||||
}
|
||||
|
||||
function isItemFinished(status: string): boolean {
|
||||
return status === 'pending_review' || status === 'failed' || status === 'skipped';
|
||||
}
|
||||
|
||||
function sleep(ms: number): Promise<void> {
|
||||
return new Promise<void>((resolve) => {
|
||||
setTimeout(resolve, ms);
|
||||
});
|
||||
}
|
||||
|
||||
async function lockTask(tx: Prisma.TransactionClient, taskId: string): Promise<TaskRow | null> {
|
||||
const rows = await tx.$queryRaw<TaskRow[]>(Prisma.sql`
|
||||
SELECT id, status, payload_json, item_total, item_succeeded, item_failed, item_skipped,
|
||||
cancel_requested_at
|
||||
FROM media_tasks
|
||||
WHERE id = ${taskId}
|
||||
FOR UPDATE
|
||||
`);
|
||||
return rows[0] ?? null;
|
||||
}
|
||||
|
||||
async function lockItem(
|
||||
tx: Prisma.TransactionClient,
|
||||
taskId: string,
|
||||
itemId: string,
|
||||
): Promise<ItemRow | null> {
|
||||
const rows = await tx.$queryRaw<ItemRow[]>(Prisma.sql`
|
||||
SELECT id, seq, status
|
||||
FROM media_items
|
||||
WHERE id = ${itemId} AND task_id = ${taskId}
|
||||
FOR UPDATE
|
||||
`);
|
||||
return rows[0] ?? null;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// 事件与收敛写入
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
interface ItemEventInput {
|
||||
taskId: string;
|
||||
itemId: string;
|
||||
seq: number;
|
||||
status: TaskItemStatus;
|
||||
failureCode?: string;
|
||||
failureMessage?: string;
|
||||
}
|
||||
|
||||
async function appendTaskEvent(
|
||||
tx: Prisma.TransactionClient,
|
||||
taskId: string,
|
||||
eventType: TaskEventType,
|
||||
payload: unknown,
|
||||
): Promise<void> {
|
||||
await tx.$executeRaw(Prisma.sql`
|
||||
INSERT INTO task_events (id, task_id, event_type, payload_json)
|
||||
VALUES (${ulid()}, ${taskId}, ${eventType}, ${JSON.stringify(payload)}::jsonb)
|
||||
`);
|
||||
}
|
||||
|
||||
async function appendItemEvent(tx: Prisma.TransactionClient, input: ItemEventInput): Promise<void> {
|
||||
const payload: TaskItemEventPayload = {
|
||||
taskId: input.taskId,
|
||||
itemId: input.itemId,
|
||||
seq: input.seq,
|
||||
status: input.status,
|
||||
...(input.failureCode === undefined ? {} : { failureCode: input.failureCode }),
|
||||
...(input.failureMessage === undefined ? {} : { failureMessage: input.failureMessage }),
|
||||
};
|
||||
await appendTaskEvent(tx, input.taskId, 'item', payload);
|
||||
}
|
||||
|
||||
async function appendProgressEvent(
|
||||
tx: Prisma.TransactionClient,
|
||||
taskId: string,
|
||||
status: TaskStatus,
|
||||
counters: TaskCounters,
|
||||
): Promise<void> {
|
||||
const payload: TaskProgressEventPayload = {
|
||||
taskId,
|
||||
status,
|
||||
progress: taskProgress(counters),
|
||||
itemTotal: counters.itemTotal,
|
||||
itemSucceeded: counters.itemSucceeded,
|
||||
itemFailed: counters.itemFailed,
|
||||
itemSkipped: counters.itemSkipped,
|
||||
};
|
||||
await appendTaskEvent(tx, taskId, 'progress', payload);
|
||||
}
|
||||
|
||||
async function closeTask(
|
||||
tx: Prisma.TransactionClient,
|
||||
taskId: string,
|
||||
status: TaskStatus,
|
||||
counters: TaskCounters,
|
||||
): Promise<void> {
|
||||
const donePayload: TaskDoneEventPayload = { taskId, status, progress: taskProgress(counters) };
|
||||
await appendTaskEvent(tx, taskId, 'done', donePayload);
|
||||
|
||||
// 取消由 API 侧事件表达,worker 不在取消路径上写 outbox(避免 dispatcher 永不消费的死信)
|
||||
if (status === 'canceled') {
|
||||
return;
|
||||
}
|
||||
const eventType = status === 'failed' ? TASK_OUTBOX_EVENTS.failed : TASK_OUTBOX_EVENTS.finished;
|
||||
await tx.$executeRaw(Prisma.sql`
|
||||
INSERT INTO outbox_events (id, aggregate_type, aggregate_id, event_type, payload_json)
|
||||
VALUES (${ulid()}, 'media_task', ${taskId}, ${eventType}, ${JSON.stringify({
|
||||
taskId,
|
||||
status,
|
||||
counters,
|
||||
})}::jsonb)
|
||||
`);
|
||||
}
|
||||
|
||||
/** 写回任务头计数 / 进度 / 状态;全部任务项收敛时补 done 与 outbox 事件 */
|
||||
async function persistProgress(
|
||||
tx: Prisma.TransactionClient,
|
||||
taskId: string,
|
||||
counters: TaskCounters,
|
||||
cancelRequested: boolean,
|
||||
): Promise<TaskStatus> {
|
||||
const finished = isTaskCountersFinished(counters);
|
||||
const status: TaskStatus = finished
|
||||
? resolveFinishedStatus(counters, cancelRequested)
|
||||
: 'running';
|
||||
await tx.$executeRaw(Prisma.sql`
|
||||
UPDATE media_tasks
|
||||
SET item_succeeded = ${counters.itemSucceeded},
|
||||
item_failed = ${counters.itemFailed},
|
||||
item_skipped = ${counters.itemSkipped},
|
||||
progress = ${taskProgress(counters)},
|
||||
status = ${status},
|
||||
finished_at = CASE WHEN ${finished} THEN now() ELSE finished_at END,
|
||||
updated_at = now()
|
||||
WHERE id = ${taskId}
|
||||
`);
|
||||
await appendProgressEvent(tx, taskId, status, counters);
|
||||
if (finished) {
|
||||
await closeTask(tx, taskId, status, counters);
|
||||
}
|
||||
return status;
|
||||
}
|
||||
|
||||
/** 取消请求:未收敛的任务项标 skipped 后收敛(已终态项不重复计数) */
|
||||
async function skipItemOnCancel(
|
||||
tx: Prisma.TransactionClient,
|
||||
task: TaskRow,
|
||||
item: ItemRow,
|
||||
): Promise<void> {
|
||||
if (isItemFinished(item.status)) {
|
||||
return;
|
||||
}
|
||||
await tx.$executeRaw(Prisma.sql`
|
||||
UPDATE media_items
|
||||
SET status = 'skipped', finished_at = now(), updated_at = now()
|
||||
WHERE id = ${item.id}
|
||||
`);
|
||||
const counters = applyItemResult(countersOfTask(task), 'skipped');
|
||||
await appendItemEvent(tx, {
|
||||
taskId: task.id,
|
||||
itemId: item.id,
|
||||
seq: item.seq,
|
||||
status: 'skipped',
|
||||
});
|
||||
await persistProgress(tx, task.id, counters, true);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// 执行阶段
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
type BeginResult =
|
||||
{ outcome: 'started'; seq: number } | { outcome: 'skipped' } | { outcome: 'canceled' };
|
||||
|
||||
async function beginItem(tx: Prisma.TransactionClient, data: DemoJobData): Promise<BeginResult> {
|
||||
const task = await lockTask(tx, data.taskId);
|
||||
if (task === null || !isTaskStatus(task.status) || isTaskTerminal(task.status)) {
|
||||
return { outcome: 'skipped' };
|
||||
}
|
||||
const item = await lockItem(tx, data.taskId, data.itemId);
|
||||
if (item === null || !isItemStatus(item.status)) {
|
||||
return { outcome: 'skipped' };
|
||||
}
|
||||
if (isTaskCanceled(task)) {
|
||||
await skipItemOnCancel(tx, task, item);
|
||||
return { outcome: 'canceled' };
|
||||
}
|
||||
if (isItemFinished(item.status)) {
|
||||
return { outcome: 'skipped' };
|
||||
}
|
||||
|
||||
const claimed = await tx.$executeRaw(Prisma.sql`
|
||||
UPDATE media_items
|
||||
SET status = 'running', started_at = now(), attempts = attempts + 1, updated_at = now()
|
||||
WHERE id = ${item.id} AND status = 'queued'
|
||||
`);
|
||||
if (claimed === 0) {
|
||||
return { outcome: 'skipped' };
|
||||
}
|
||||
if (task.status === 'queued') {
|
||||
await tx.$executeRaw(Prisma.sql`
|
||||
UPDATE media_tasks SET status = 'running', started_at = now(), updated_at = now()
|
||||
WHERE id = ${task.id}
|
||||
`);
|
||||
}
|
||||
await appendItemEvent(tx, {
|
||||
taskId: task.id,
|
||||
itemId: item.id,
|
||||
seq: item.seq,
|
||||
status: 'running',
|
||||
});
|
||||
return { outcome: 'started', seq: item.seq };
|
||||
}
|
||||
|
||||
const DEMO_FAILURE_CODE = 'demo_simulated_failure';
|
||||
const DEMO_FAILURE_MESSAGE = '演练任务模拟失败';
|
||||
|
||||
async function finishItem(
|
||||
tx: Prisma.TransactionClient,
|
||||
data: DemoJobData,
|
||||
attemptsMade: number,
|
||||
maxAttempts: number,
|
||||
): Promise<DemoProcessorOutcome> {
|
||||
const task = await lockTask(tx, data.taskId);
|
||||
if (task === null || isTaskTerminalTask(task)) {
|
||||
return 'skipped';
|
||||
}
|
||||
const item = await lockItem(tx, data.taskId, data.itemId);
|
||||
if (item === null || isItemFinished(item.status)) {
|
||||
return 'skipped';
|
||||
}
|
||||
if (isTaskCanceled(task)) {
|
||||
await skipItemOnCancel(tx, task, item);
|
||||
return 'canceled';
|
||||
}
|
||||
if (item.status !== 'running') {
|
||||
return 'skipped';
|
||||
}
|
||||
|
||||
const outcome = decideDemoOutcome(modeOf(task.payload_json), item.seq, attemptsMade, maxAttempts);
|
||||
if (outcome === 'retry') {
|
||||
await retryItem(tx, task, item);
|
||||
return 'retry';
|
||||
}
|
||||
const succeeded = outcome === 'success';
|
||||
await recordItemResult(tx, task, item, succeeded);
|
||||
return succeeded ? 'succeeded' : 'failed';
|
||||
}
|
||||
|
||||
/** 非最后一次尝试失败:任务项置回 queued(保留 attempts)并写事件,随后由 BullMQ 退避重试 */
|
||||
async function retryItem(
|
||||
tx: Prisma.TransactionClient,
|
||||
task: TaskRow,
|
||||
item: ItemRow,
|
||||
): Promise<void> {
|
||||
await tx.$executeRaw(Prisma.sql`
|
||||
UPDATE media_items SET status = 'queued', started_at = NULL, updated_at = now()
|
||||
WHERE id = ${item.id}
|
||||
`);
|
||||
await appendItemEvent(tx, {
|
||||
taskId: task.id,
|
||||
itemId: item.id,
|
||||
seq: item.seq,
|
||||
status: 'queued',
|
||||
});
|
||||
await appendProgressEvent(tx, task.id, 'running', countersOfTask(task));
|
||||
}
|
||||
|
||||
/** 任务项收敛:成功 → pending_review;最后一次尝试失败 → failed(失败原因必填) */
|
||||
async function recordItemResult(
|
||||
tx: Prisma.TransactionClient,
|
||||
task: TaskRow,
|
||||
item: ItemRow,
|
||||
succeeded: boolean,
|
||||
): Promise<void> {
|
||||
const itemStatus: TaskItemStatus = succeeded ? 'pending_review' : 'failed';
|
||||
await tx.$executeRaw(Prisma.sql`
|
||||
UPDATE media_items
|
||||
SET status = ${itemStatus},
|
||||
failure_code = ${succeeded ? null : DEMO_FAILURE_CODE},
|
||||
failure_message = ${succeeded ? null : DEMO_FAILURE_MESSAGE},
|
||||
finished_at = now(),
|
||||
updated_at = now()
|
||||
WHERE id = ${item.id}
|
||||
`);
|
||||
const counters = applyItemResult(countersOfTask(task), succeeded ? 'succeeded' : 'failed');
|
||||
await appendItemEvent(tx, {
|
||||
taskId: task.id,
|
||||
itemId: item.id,
|
||||
seq: item.seq,
|
||||
status: itemStatus,
|
||||
...(succeeded ? {} : { failureCode: DEMO_FAILURE_CODE, failureMessage: DEMO_FAILURE_MESSAGE }),
|
||||
});
|
||||
await persistProgress(tx, task.id, counters, false);
|
||||
}
|
||||
|
||||
function isTaskTerminalTask(task: TaskRow): boolean {
|
||||
return isTaskStatus(task.status) && isTaskTerminal(task.status);
|
||||
}
|
||||
|
||||
/**
|
||||
* 处理一个演练任务项。
|
||||
* @param attemptsMade BullMQ 已尝试次数(首次为 0);`attemptsMade + 1 >= maxAttempts` 即最后一次尝试
|
||||
*/
|
||||
export async function processDemoJob(
|
||||
prisma: PrismaClient,
|
||||
data: DemoJobData,
|
||||
attemptsMade: number,
|
||||
maxAttempts: number,
|
||||
): Promise<DemoProcessorOutcome> {
|
||||
const begin = await prisma.$transaction((tx) => beginItem(tx, data));
|
||||
if (begin.outcome !== 'started') {
|
||||
return begin.outcome;
|
||||
}
|
||||
|
||||
// 事务外 sleep,避免长事务占用连接与行锁
|
||||
await sleep(demoDurationMs(begin.seq));
|
||||
|
||||
const outcome = await prisma.$transaction((tx) =>
|
||||
finishItem(tx, data, attemptsMade, maxAttempts),
|
||||
);
|
||||
if (outcome === 'retry') {
|
||||
throw new Error(`演练任务项 ${data.itemId} 本次尝试失败,等待 BullMQ 重试`);
|
||||
}
|
||||
return outcome;
|
||||
}
|
||||
@@ -0,0 +1,18 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
|
||||
import { toBullQueueName } from '../queues';
|
||||
import { DEMO_QUEUE_PHYSICAL, isDemoJob } from './dispatch';
|
||||
|
||||
describe('M0 job 分发判定', () => {
|
||||
it('用物理队列名(不含冒号)判定 media:verify 的 task:demo', () => {
|
||||
expect(DEMO_QUEUE_PHYSICAL).toBe('media-verify');
|
||||
expect(toBullQueueName('media:verify')).toBe(DEMO_QUEUE_PHYSICAL);
|
||||
expect(isDemoJob('media-verify', 'task:demo')).toBe(true);
|
||||
});
|
||||
|
||||
it('逻辑名 / 其他队列 / 其他 job 不命中', () => {
|
||||
expect(isDemoJob('media:verify', 'task:demo')).toBe(false);
|
||||
expect(isDemoJob('image-render', 'task:demo')).toBe(false);
|
||||
expect(isDemoJob('media-verify', 'task:image')).toBe(false);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,18 @@
|
||||
import { toBullQueueName } from '../queues';
|
||||
|
||||
/**
|
||||
* M0 演练任务的 job 分发契约。
|
||||
*
|
||||
* 注意:BullMQ `Job.queueName` 返回的是**物理队列名**(`media-verify`,不含冒号),
|
||||
* 协议里的逻辑名是 `media:verify`;判断时必须用 `toBullQueueName` 映射,不能用逻辑名直接比较。
|
||||
*/
|
||||
export const DEMO_QUEUE_LOGICAL = 'media:verify' as const;
|
||||
|
||||
export const DEMO_QUEUE_PHYSICAL = toBullQueueName(DEMO_QUEUE_LOGICAL);
|
||||
|
||||
export const DEMO_JOB_NAME = 'task:demo' as const;
|
||||
|
||||
/** 当前 job 是否由演练任务处理器处理(M0 唯一实现) */
|
||||
export function isDemoJob(queueName: string, jobName: string): boolean {
|
||||
return queueName === DEMO_QUEUE_PHYSICAL && jobName === DEMO_JOB_NAME;
|
||||
}
|
||||
@@ -0,0 +1,13 @@
|
||||
export {
|
||||
applyItemResult,
|
||||
decideDemoOutcome,
|
||||
type DemoAttemptOutcome,
|
||||
demoDurationMs,
|
||||
type DemoJobData,
|
||||
type DemoMode,
|
||||
type DemoProcessorOutcome,
|
||||
normalizeDemoMode,
|
||||
processDemoJob,
|
||||
} from './demo.processor';
|
||||
export { DEMO_JOB_NAME, DEMO_QUEUE_LOGICAL, DEMO_QUEUE_PHYSICAL, isDemoJob } from './dispatch';
|
||||
export { parseTaskJobData, type TaskJobData } from './job-data';
|
||||
@@ -0,0 +1,20 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
|
||||
import { parseTaskJobData } from './job-data';
|
||||
|
||||
describe('job data 校验', () => {
|
||||
it('合法数据原样返回', () => {
|
||||
expect(parseTaskJobData({ taskId: 'T1', itemId: 'I1', extra: true })).toEqual({
|
||||
taskId: 'T1',
|
||||
itemId: 'I1',
|
||||
});
|
||||
});
|
||||
|
||||
it('非法数据抛中文错误', () => {
|
||||
expect(() => parseTaskJobData(null)).toThrow('任务 job data 不是对象');
|
||||
expect(() => parseTaskJobData({ itemId: 'I1' })).toThrow('任务 job data 缺少 taskId');
|
||||
expect(() => parseTaskJobData({ taskId: 'T1', itemId: '' })).toThrow(
|
||||
'任务 job data 缺少 itemId',
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,25 @@
|
||||
/** BullMQ job data 契约(与 apps/api/src/infra/queue/outbox-payload.ts 的 TaskJobData 一致) */
|
||||
export interface TaskJobData {
|
||||
taskId: string;
|
||||
itemId: string;
|
||||
}
|
||||
|
||||
function isRecord(value: unknown): value is Record<string, unknown> {
|
||||
return typeof value === 'object' && value !== null && !Array.isArray(value);
|
||||
}
|
||||
|
||||
/** 校验并收窄 BullMQ job data;非法数据抛中文错误,以 failed job 形式暴露而不是静默吞掉 */
|
||||
export function parseTaskJobData(data: unknown): TaskJobData {
|
||||
if (!isRecord(data)) {
|
||||
throw new Error('任务 job data 不是对象');
|
||||
}
|
||||
const taskId = data.taskId;
|
||||
const itemId = data.itemId;
|
||||
if (typeof taskId !== 'string' || taskId.length === 0) {
|
||||
throw new Error('任务 job data 缺少 taskId');
|
||||
}
|
||||
if (typeof itemId !== 'string' || itemId.length === 0) {
|
||||
throw new Error('任务 job data 缺少 itemId');
|
||||
}
|
||||
return { taskId, itemId };
|
||||
}
|
||||
@@ -0,0 +1,43 @@
|
||||
import { TASK_QUEUES } from '@lingxi/shared';
|
||||
import { describe, expect, it } from 'vitest';
|
||||
|
||||
import { buildQueueDefinitions, parseQueueSpec, toBullQueueName } from './queues';
|
||||
|
||||
describe('QUEUE 解析与队列选择', () => {
|
||||
it('all → 全部队列(顺序与 TASK_QUEUES 一致)', () => {
|
||||
expect(parseQueueSpec('all')).toEqual(TASK_QUEUES.map((queue) => queue.name));
|
||||
});
|
||||
|
||||
it('分组名展开为组内队列,逗号分隔可混用具体队列名', () => {
|
||||
expect(parseQueueSpec('verify')).toEqual(['media:verify']);
|
||||
expect(parseQueueSpec('image')).toEqual(['image:matting', 'image:render']);
|
||||
expect(parseQueueSpec('verify,media:verify')).toEqual(['media:verify']);
|
||||
expect(parseQueueSpec('image:render, verify')).toEqual(['image:render', 'media:verify']);
|
||||
});
|
||||
|
||||
it('非法值 / all 混用 / 空值给出中文原因', () => {
|
||||
expect(() => parseQueueSpec('')).toThrow('QUEUE 不能为空');
|
||||
expect(() => parseQueueSpec('image,unknown')).toThrow('含未知队列 / 分组:unknown');
|
||||
expect(() => parseQueueSpec('all,verify')).toThrow('QUEUE=all 不能与其他分组 / 队列混用');
|
||||
});
|
||||
|
||||
it('BullMQ 物理队列名不含冒号且 9 条互不冲突', () => {
|
||||
const names = TASK_QUEUES.map((queue) => toBullQueueName(queue.name));
|
||||
expect(names).toContain('media-verify');
|
||||
expect(names.every((name) => !name.includes(':'))).toBe(true);
|
||||
expect(new Set(names).size).toBe(names.length);
|
||||
});
|
||||
|
||||
it('buildQueueDefinitions 使用 TASK_QUEUES 的重试参数,并支持并发覆盖', () => {
|
||||
const definitions = buildQueueDefinitions(['media:verify']);
|
||||
expect(definitions[0]).toMatchObject({
|
||||
name: 'media:verify',
|
||||
group: 'verify',
|
||||
concurrency: 4,
|
||||
attempts: 2,
|
||||
backoffDelayMs: 1000,
|
||||
});
|
||||
expect(buildQueueDefinitions(['media:verify'], 9)[0]?.concurrency).toBe(9);
|
||||
expect(() => buildQueueDefinitions(['not:a-queue'])).toThrow('未登记的队列');
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,100 @@
|
||||
import {
|
||||
TASK_QUEUE_GROUPS,
|
||||
TASK_QUEUES,
|
||||
type TaskQueueGroup,
|
||||
type TaskQueueName,
|
||||
} from '@lingxi/shared';
|
||||
|
||||
/** 单个 worker 被分配到的队列定义(并发 / 重试参数全部来自 shared 的 TASK_QUEUES) */
|
||||
export interface WorkerQueueDefinition {
|
||||
name: TaskQueueName;
|
||||
group: TaskQueueGroup;
|
||||
concurrency: number;
|
||||
attempts: number;
|
||||
backoffDelayMs: number;
|
||||
}
|
||||
|
||||
/** `QUEUE=all`:启动全部队列(本地开发默认) */
|
||||
export const ALL_QUEUE_SELECTOR = 'all';
|
||||
|
||||
/**
|
||||
* 逻辑队列名 → BullMQ 物理队列名(BullMQ 禁止冒号),与 apps/api 的 toBullQueueName 保持一致。
|
||||
* 心跳 / 日志 / QUEUE 环境变量使用逻辑名,只有 BullMQ 实例使用物理名。
|
||||
*/
|
||||
export function toBullQueueName(name: TaskQueueName): string {
|
||||
return name.replaceAll(':', '-');
|
||||
}
|
||||
|
||||
export function isQueueName(value: string): value is TaskQueueName {
|
||||
return TASK_QUEUES.some((definition) => definition.name === value);
|
||||
}
|
||||
|
||||
export function isQueueGroup(value: string): value is TaskQueueGroup {
|
||||
return TASK_QUEUE_GROUPS.some((group) => group === value);
|
||||
}
|
||||
|
||||
/**
|
||||
* 解析 QUEUE 环境变量:`all` / 分组名 / 队列名,逗号分隔(如 `QUEUE=verify`、`QUEUE=image,media:verify`)。
|
||||
* 非法值给出中文原因(含可用取值),避免 worker 静默空转。
|
||||
*/
|
||||
export function parseQueueSpec(spec: string): TaskQueueName[] {
|
||||
const tokens = spec
|
||||
.split(',')
|
||||
.map((token) => token.trim())
|
||||
.filter((token) => token.length > 0);
|
||||
|
||||
if (tokens.length === 0) {
|
||||
throw new Error('QUEUE 不能为空:请填 all、队列组名或队列名(如 media:verify)');
|
||||
}
|
||||
if (tokens.includes(ALL_QUEUE_SELECTOR)) {
|
||||
if (tokens.length > 1) {
|
||||
throw new Error('QUEUE=all 不能与其他分组 / 队列混用');
|
||||
}
|
||||
return TASK_QUEUES.map((definition) => definition.name);
|
||||
}
|
||||
|
||||
const selected: TaskQueueName[] = [];
|
||||
for (const token of tokens) {
|
||||
if (isQueueName(token)) {
|
||||
selected.push(token);
|
||||
continue;
|
||||
}
|
||||
if (isQueueGroup(token)) {
|
||||
const group = token;
|
||||
selected.push(
|
||||
...TASK_QUEUES.filter((definition) => definition.group === group).map(
|
||||
(definition) => definition.name,
|
||||
),
|
||||
);
|
||||
continue;
|
||||
}
|
||||
throw new Error(
|
||||
`含未知队列 / 分组:${token}(可用:all、${TASK_QUEUE_GROUPS.join(' / ')} 或具体队列名)`,
|
||||
);
|
||||
}
|
||||
|
||||
return [...new Set(selected)];
|
||||
}
|
||||
|
||||
/** 把队列名展开为 worker 启动定义;WORKER_CONCURRENCY 覆盖全部队列并发 */
|
||||
export function buildQueueDefinitions(
|
||||
names: readonly string[],
|
||||
concurrencyOverride?: number,
|
||||
): WorkerQueueDefinition[] {
|
||||
return names.map((name) => {
|
||||
if (!isQueueName(name)) {
|
||||
throw new Error(`未登记的队列:${name}`);
|
||||
}
|
||||
const definition = TASK_QUEUES.find((item) => item.name === name);
|
||||
if (definition === undefined) {
|
||||
throw new Error(`未登记的队列:${name}`);
|
||||
}
|
||||
return {
|
||||
name,
|
||||
group: definition.group,
|
||||
concurrency: concurrencyOverride ?? definition.concurrency,
|
||||
attempts: definition.attempts,
|
||||
backoffDelayMs: definition.backoffDelayMs,
|
||||
};
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,31 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
|
||||
import { parseRedisOptions } from './redis-options';
|
||||
|
||||
describe('REDIS_URL → 连接参数', () => {
|
||||
it('解析 host / port / db', () => {
|
||||
expect(parseRedisOptions('redis://127.0.0.1:6379/1')).toEqual({
|
||||
host: '127.0.0.1',
|
||||
port: 6379,
|
||||
db: 1,
|
||||
});
|
||||
});
|
||||
|
||||
it('缺省端口 6379、缺省 db 0', () => {
|
||||
expect(parseRedisOptions('redis://localhost')).toEqual({
|
||||
host: 'localhost',
|
||||
port: 6379,
|
||||
db: 0,
|
||||
});
|
||||
});
|
||||
|
||||
it('支持账号密码(URL 编码)', () => {
|
||||
expect(parseRedisOptions('redis://lingxi:p%40ss@127.0.0.1:6380/2')).toEqual({
|
||||
host: '127.0.0.1',
|
||||
port: 6380,
|
||||
db: 2,
|
||||
username: 'lingxi',
|
||||
password: 'p@ss',
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,26 @@
|
||||
/** 解析 REDIS_URL 为 BullMQ / ioredis 可用的连接参数(支持 db 与账号密码) */
|
||||
export interface RedisConnectionOptions {
|
||||
host: string;
|
||||
port: number;
|
||||
db: number;
|
||||
username?: string;
|
||||
password?: string;
|
||||
}
|
||||
|
||||
export function parseRedisOptions(redisUrl: string): RedisConnectionOptions {
|
||||
const parsed = new URL(redisUrl);
|
||||
const dbPath = parsed.pathname.replace(/^\//, '');
|
||||
const parsedDb = dbPath === '' ? 0 : Number(dbPath);
|
||||
const options: RedisConnectionOptions = {
|
||||
host: parsed.hostname,
|
||||
port: parsed.port === '' ? 6379 : Number(parsed.port),
|
||||
db: Number.isInteger(parsedDb) && parsedDb >= 0 ? parsedDb : 0,
|
||||
};
|
||||
if (parsed.username !== '') {
|
||||
options.username = decodeURIComponent(parsed.username);
|
||||
}
|
||||
if (parsed.password !== '') {
|
||||
options.password = decodeURIComponent(parsed.password);
|
||||
}
|
||||
return options;
|
||||
}
|
||||
@@ -8,5 +8,6 @@
|
||||
"declarationMap": false,
|
||||
"sourceMap": true
|
||||
},
|
||||
"include": ["src"]
|
||||
"include": ["src"],
|
||||
"exclude": ["src/**/*.spec.ts"]
|
||||
}
|
||||
@@ -7,5 +7,5 @@
|
||||
"types": ["node"],
|
||||
"noEmit": true
|
||||
},
|
||||
"include": ["src"]
|
||||
"include": ["src", "vitest.config.ts"]
|
||||
}
|
||||
@@ -0,0 +1,14 @@
|
||||
import { defineConfig } from 'vitest/config';
|
||||
|
||||
export default defineConfig({
|
||||
test: {
|
||||
environment: 'node',
|
||||
include: ['src/**/*.spec.ts'],
|
||||
coverage: {
|
||||
provider: 'v8',
|
||||
reporter: ['text', 'lcov', 'json-summary'],
|
||||
include: ['src/**/*.ts'],
|
||||
exclude: ['src/**/*.spec.ts', 'src/main.ts'],
|
||||
},
|
||||
},
|
||||
});
|
||||
Reference in new issue
Block a user