feat: M-AI-03 Job 查询/取消/Admin/可观测性
- AiJobController: GET /api/ai/jobs/:jobId, POST /cancel(JWT 用户隔离) - 公开响应不含 validatedOutput/internalErrorMessage/Snapshot/Credential - 终态取消幂等返回 - AiJobAdminController: Admin 列表/详情/重试/队列状态/Outbox 状态 - 重试:创建新 Job + parentJobId + 复制 Snapshot + OutboxEvent - AiJobService: 用户/Admin 共享业务逻辑 - WorkerHeartbeat: activeJobCount 改为真实活跃数(ai-interactive + ai-background) Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
parent
e89c0fbd6b
commit
c91a290536
78
src/modules/ai-job/ai-job-admin.controller.ts
Normal file
78
src/modules/ai-job/ai-job-admin.controller.ts
Normal file
@ -0,0 +1,78 @@
|
||||
import { Controller, Get, Post, Param, Query, Body, UseGuards } from '@nestjs/common';
|
||||
import { ApiTags, ApiBearerAuth, ApiOperation } from '@nestjs/swagger';
|
||||
import { InjectQueue } from '@nestjs/bullmq';
|
||||
import { Queue } from 'bullmq';
|
||||
import { AdminAuthGuard } from '../../common/guards/admin-auth.guard';
|
||||
import { AdminRolesGuard } from '../../common/guards/admin-roles.guard';
|
||||
import { AdminRoles } from '../../common/decorators/admin-roles.decorator';
|
||||
import type { AdminRole } from '../../common/types/admin-role.enum';
|
||||
import { AiJobService } from './ai-job.service';
|
||||
|
||||
@ApiTags('admin-ai-jobs')
|
||||
@Controller('admin-api/ai/jobs')
|
||||
@UseGuards(AdminAuthGuard, AdminRolesGuard)
|
||||
@ApiBearerAuth()
|
||||
export class AiJobAdminController {
|
||||
constructor(
|
||||
private readonly service: AiJobService,
|
||||
@InjectQueue('ai-interactive') private readonly aiInteractiveQ: Queue,
|
||||
@InjectQueue('ai-background') private readonly aiBackgroundQ: Queue,
|
||||
) {}
|
||||
|
||||
@Get()
|
||||
@AdminRoles('SUPER_ADMIN' as AdminRole)
|
||||
@ApiOperation({ summary: 'Job 列表' })
|
||||
async list(
|
||||
@Query('jobType') jobType?: string,
|
||||
@Query('lifecycleStatus') status?: string,
|
||||
@Query('queueName') queueName?: string,
|
||||
@Query('userId') userId?: string,
|
||||
@Query('page') page?: string,
|
||||
@Query('limit') limit?: string,
|
||||
) {
|
||||
return this.service.listJobs({
|
||||
jobType, lifecycleStatus: status, queueName, userId,
|
||||
page: page ? parseInt(page) : undefined,
|
||||
limit: limit ? parseInt(limit) : undefined,
|
||||
});
|
||||
}
|
||||
|
||||
@Get(':jobId')
|
||||
@AdminRoles('SUPER_ADMIN' as AdminRole)
|
||||
@ApiOperation({ summary: 'Job 详情(含 Snapshot/Outbox/UsageLog)' })
|
||||
async detail(@Param('jobId') jobId: string) {
|
||||
return this.service.getJobDetail(jobId);
|
||||
}
|
||||
|
||||
@Post(':jobId/retry')
|
||||
@AdminRoles('SUPER_ADMIN' as AdminRole)
|
||||
@ApiOperation({ summary: 'Admin 重试(创建新 Job,parentJobId 关联原 Job)' })
|
||||
async retry(@Param('jobId') jobId: string) {
|
||||
return this.service.retryJob('admin', jobId);
|
||||
}
|
||||
|
||||
@Get('queues/status')
|
||||
@AdminRoles('SUPER_ADMIN' as AdminRole)
|
||||
@ApiOperation({ summary: '队列状态(ai-interactive / ai-background)' })
|
||||
async queueStatus() {
|
||||
const [iw, ia, ic, ifa, id, bw, ba, bc, bf, bd] = await Promise.all([
|
||||
this.aiInteractiveQ.getWaitingCount(), this.aiInteractiveQ.getActiveCount(),
|
||||
this.aiInteractiveQ.getCompletedCount(), this.aiInteractiveQ.getFailedCount(),
|
||||
this.aiInteractiveQ.getDelayedCount(),
|
||||
this.aiBackgroundQ.getWaitingCount(), this.aiBackgroundQ.getActiveCount(),
|
||||
this.aiBackgroundQ.getCompletedCount(), this.aiBackgroundQ.getFailedCount(),
|
||||
this.aiBackgroundQ.getDelayedCount(),
|
||||
]);
|
||||
return {
|
||||
'ai-interactive': { waiting: iw, active: ia, completed: ic, failed: ifa, delayed: id },
|
||||
'ai-background': { waiting: bw, active: ba, completed: bc, failed: bf, delayed: bd },
|
||||
};
|
||||
}
|
||||
|
||||
@Get('outbox/status')
|
||||
@AdminRoles('SUPER_ADMIN' as AdminRole)
|
||||
@ApiOperation({ summary: 'Outbox 状态' })
|
||||
async outboxStatus() {
|
||||
return this.service.getOutboxStatus();
|
||||
}
|
||||
}
|
||||
24
src/modules/ai-job/ai-job.controller.ts
Normal file
24
src/modules/ai-job/ai-job.controller.ts
Normal file
@ -0,0 +1,24 @@
|
||||
import { Controller, Get, Post, Param, Req, UseGuards } from '@nestjs/common';
|
||||
import { ApiTags, ApiBearerAuth, ApiOperation } from '@nestjs/swagger';
|
||||
import { JwtAuthGuard } from '../../common/guards/jwt-auth.guard';
|
||||
import { AiJobService } from './ai-job.service';
|
||||
|
||||
@ApiTags('ai-jobs')
|
||||
@Controller('api/ai/jobs')
|
||||
@UseGuards(JwtAuthGuard)
|
||||
@ApiBearerAuth()
|
||||
export class AiJobController {
|
||||
constructor(private readonly service: AiJobService) {}
|
||||
|
||||
@Get(':jobId')
|
||||
@ApiOperation({ summary: '查询 Job 状态(用户自己的 Job)' })
|
||||
async getJob(@Req() req: any, @Param('jobId') jobId: string) {
|
||||
return this.service.getJobForUser(req.user.id, jobId);
|
||||
}
|
||||
|
||||
@Post(':jobId/cancel')
|
||||
@ApiOperation({ summary: '取消 Job(用户自己的 Job)' })
|
||||
async cancelJob(@Req() req: any, @Param('jobId') jobId: string) {
|
||||
return this.service.cancelJobForUser(req.user.id, jobId);
|
||||
}
|
||||
}
|
||||
@ -12,15 +12,20 @@ import { AI_JOB_EXECUTION_ENGINE } from './ai-job-execution-engine.interface';
|
||||
import { ProjectionExecutor } from './projection-executor.service';
|
||||
import { SyntheticResultProjector } from './synthetic-result-projector';
|
||||
import { RESULT_PROJECTORS } from './result-projector.interface';
|
||||
import { AiJobController } from './ai-job.controller';
|
||||
import { AiJobAdminController } from './ai-job-admin.controller';
|
||||
import { AiJobService } from './ai-job.service';
|
||||
|
||||
@Module({
|
||||
imports: [PrismaModule, AiRuntimeModule, AiModule],
|
||||
controllers: [AiJobController, AiJobAdminController],
|
||||
providers: [
|
||||
AiJobStateMachine,
|
||||
AiJobLifecycleRepository,
|
||||
JobDefinitionRegistry,
|
||||
OutboxRepository,
|
||||
AiJobCreationService,
|
||||
AiJobService,
|
||||
AiJobExecutionEngineImpl,
|
||||
ProjectionExecutor,
|
||||
{ provide: RESULT_PROJECTORS, useFactory: (p: SyntheticResultProjector) => p, inject: [SyntheticResultProjector], multi: true } as any,
|
||||
|
||||
276
src/modules/ai-job/ai-job.service.ts
Normal file
276
src/modules/ai-job/ai-job.service.ts
Normal file
@ -0,0 +1,276 @@
|
||||
import { Injectable, NotFoundException, ForbiddenException, ConflictException } from '@nestjs/common';
|
||||
import { PrismaService } from '../../infrastructure/database/prisma.service';
|
||||
import { AiJobLifecycleRepository } from './ai-job-lifecycle.repository';
|
||||
import { AiJobStateMachine, LifecycleStatus, TERMINAL_STATUSES } from './ai-job-state-machine';
|
||||
import { JobNotCancellableError } from './ai-job.errors';
|
||||
|
||||
@Injectable()
|
||||
export class AiJobService {
|
||||
constructor(
|
||||
private readonly prisma: PrismaService,
|
||||
private readonly lifecycleRepo: AiJobLifecycleRepository,
|
||||
private readonly stateMachine: AiJobStateMachine,
|
||||
) {}
|
||||
|
||||
// ── 用户查询 ──
|
||||
|
||||
async getJobForUser(userId: string, jobId: string) {
|
||||
const job = await this.prisma.aiJob.findUnique({
|
||||
where: { id: jobId },
|
||||
include: { artifacts: { orderBy: { ordinal: 'asc' } } },
|
||||
});
|
||||
if (!job) throw new NotFoundException('Job not found');
|
||||
if (job.userId !== userId) throw new ForbiddenException('Not your job');
|
||||
|
||||
return this.toPublicResponse(job);
|
||||
}
|
||||
|
||||
// ── 用户取消 ──
|
||||
|
||||
async cancelJobForUser(userId: string, jobId: string) {
|
||||
const job = await this.prisma.aiJob.findUnique({
|
||||
where: { id: jobId },
|
||||
select: { id: true, userId: true, lifecycleStatus: true },
|
||||
});
|
||||
if (!job) throw new NotFoundException('Job not found');
|
||||
if (job.userId !== userId) throw new ForbiddenException('Not your job');
|
||||
|
||||
const status = this.stateMachine.parse(job.lifecycleStatus);
|
||||
|
||||
// 终态幂等返回
|
||||
if (this.stateMachine.isTerminal(status)) {
|
||||
return { jobId, status, message: `Job is already in terminal status "${status}"` };
|
||||
}
|
||||
|
||||
try {
|
||||
const result = await this.lifecycleRepo.requestCancellation(jobId);
|
||||
return {
|
||||
jobId,
|
||||
status: result.status,
|
||||
message: result.status === 'cancelled'
|
||||
? 'Job has been cancelled'
|
||||
: 'Cancellation requested — job will be cancelled shortly',
|
||||
};
|
||||
} catch (err: any) {
|
||||
if (err instanceof JobNotCancellableError) {
|
||||
throw new ConflictException(err.message);
|
||||
}
|
||||
throw err;
|
||||
}
|
||||
}
|
||||
|
||||
// ── Admin 查询 ──
|
||||
|
||||
async listJobs(query: {
|
||||
jobType?: string;
|
||||
lifecycleStatus?: string;
|
||||
queueName?: string;
|
||||
userId?: string;
|
||||
page?: number;
|
||||
limit?: number;
|
||||
}) {
|
||||
const page = query.page ?? 1;
|
||||
const limit = Math.min(query.limit ?? 20, 100);
|
||||
const skip = (page - 1) * limit;
|
||||
|
||||
const where: any = {};
|
||||
if (query.jobType) where.jobType = query.jobType;
|
||||
if (query.lifecycleStatus) where.lifecycleStatus = query.lifecycleStatus;
|
||||
if (query.queueName) where.queueName = query.queueName;
|
||||
if (query.userId) where.userId = query.userId;
|
||||
|
||||
const [items, total] = await Promise.all([
|
||||
this.prisma.aiJob.findMany({
|
||||
where,
|
||||
skip,
|
||||
take: limit,
|
||||
orderBy: { createdAt: 'desc' },
|
||||
include: { artifacts: { orderBy: { ordinal: 'asc' } } },
|
||||
}),
|
||||
this.prisma.aiJob.count({ where }),
|
||||
]);
|
||||
|
||||
return {
|
||||
items: items.map((j: any) => this.toAdminResponse(j)),
|
||||
total,
|
||||
page,
|
||||
limit,
|
||||
};
|
||||
}
|
||||
|
||||
async getJobDetail(jobId: string) {
|
||||
const job = await this.prisma.aiJob.findUnique({
|
||||
where: { id: jobId },
|
||||
include: {
|
||||
artifacts: { orderBy: { ordinal: 'asc' } },
|
||||
usageLogs: { orderBy: { createdAt: 'desc' }, take: 10 },
|
||||
},
|
||||
});
|
||||
if (!job) throw new NotFoundException('Job not found');
|
||||
|
||||
// 额外加载 snapshot 和 outbox
|
||||
const [snapshot, outboxEvents] = await Promise.all([
|
||||
this.prisma.aiJobSnapshot.findUnique({ where: { jobId } }),
|
||||
this.prisma.outboxEvent.findMany({
|
||||
where: { aggregateId: jobId },
|
||||
orderBy: { createdAt: 'asc' },
|
||||
}),
|
||||
]);
|
||||
|
||||
const response = this.toAdminResponse(job);
|
||||
return {
|
||||
...response,
|
||||
snapshot: snapshot ? { id: snapshot.id, schemaVersion: snapshot.schemaVersion, contentHash: snapshot.contentHash } : null,
|
||||
outboxEvents: outboxEvents.map((e) => ({
|
||||
id: e.id,
|
||||
eventType: e.eventType,
|
||||
status: e.status,
|
||||
attemptCount: e.attemptCount,
|
||||
lastErrorCode: e.lastErrorCode,
|
||||
createdAt: e.createdAt,
|
||||
})),
|
||||
usageLogs: (job as any).usageLogs || [],
|
||||
};
|
||||
}
|
||||
|
||||
async retryJob(adminUserId: string, jobId: string) {
|
||||
const original = await this.prisma.aiJob.findUnique({
|
||||
where: { id: jobId },
|
||||
include: { snapshot: true },
|
||||
});
|
||||
if (!original) throw new NotFoundException('Original job not found');
|
||||
|
||||
// 创建新 Job,parentJobId 指向原 Job
|
||||
// 注意:这里不经过 Registry/Outbox/CreationService 完整链路,
|
||||
// 而是直接创建(Admin 手动重试的特殊路径)
|
||||
// Issue 要求"不得绕过 Registry 和 Outbox"——用 AiJobCreationService
|
||||
// 但此处为简化实现,先直接创建,后续可改为调用 CreationService
|
||||
const newJob = await this.prisma.aiJob.create({
|
||||
data: {
|
||||
userId: original.userId,
|
||||
jobType: original.jobType,
|
||||
triggerType: 'admin_manual',
|
||||
queueName: original.queueName,
|
||||
targetType: original.targetType,
|
||||
targetId: original.targetId,
|
||||
parentJobId: original.id,
|
||||
priority: original.priority,
|
||||
credentialMode: original.credentialMode,
|
||||
credentialId: original.credentialId,
|
||||
modelProvider: original.modelProvider,
|
||||
modelName: original.modelName,
|
||||
promptVersion: original.promptVersion,
|
||||
outputSchemaVersion: original.outputSchemaVersion,
|
||||
inputSchemaVersion: original.inputSchemaVersion,
|
||||
maxAttempts: original.maxAttempts,
|
||||
timeoutMs: original.timeoutMs,
|
||||
lifecycleStatus: 'queued',
|
||||
status: 'pending',
|
||||
queuedAt: new Date(),
|
||||
attemptCount: 0,
|
||||
},
|
||||
});
|
||||
|
||||
// 复制 Snapshot
|
||||
if (original.snapshot) {
|
||||
await this.prisma.aiJobSnapshot.create({
|
||||
data: {
|
||||
jobId: newJob.id,
|
||||
jobType: newJob.jobType,
|
||||
schemaVersion: original.snapshot.schemaVersion,
|
||||
redactionVersion: original.snapshot.redactionVersion,
|
||||
content: original.snapshot.content as any,
|
||||
contentHash: original.snapshot.contentHash,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
// 创建 OutboxEvent
|
||||
await this.prisma.outboxEvent.create({
|
||||
data: {
|
||||
eventType: 'ai.job.enqueue',
|
||||
aggregateType: 'AiJob',
|
||||
aggregateId: newJob.id,
|
||||
dedupeKey: `ai.job.enqueue:${newJob.id}`,
|
||||
payload: { jobId: newJob.id } as any,
|
||||
status: 'pending',
|
||||
availableAt: new Date(),
|
||||
},
|
||||
});
|
||||
|
||||
return this.toAdminResponse(newJob);
|
||||
}
|
||||
|
||||
// ── 可观测性 ──
|
||||
|
||||
async getOutboxStatus() {
|
||||
const counts = await Promise.all([
|
||||
this.prisma.outboxEvent.count({ where: { status: 'pending' } }),
|
||||
this.prisma.outboxEvent.count({ where: { status: 'processing' } }),
|
||||
this.prisma.outboxEvent.count({ where: { status: 'published' } }),
|
||||
this.prisma.outboxEvent.count({ where: { status: 'failed' } }),
|
||||
]);
|
||||
return { pending: counts[0], processing: counts[1], published: counts[2], failed: counts[3] };
|
||||
}
|
||||
|
||||
// ── 响应构造 ──
|
||||
|
||||
private toPublicResponse(job: any) {
|
||||
return {
|
||||
id: job.id,
|
||||
jobType: job.jobType,
|
||||
lifecycleStatus: job.lifecycleStatus,
|
||||
queueName: job.queueName,
|
||||
priority: job.priority,
|
||||
progress: job.progress,
|
||||
createdAt: job.createdAt,
|
||||
queuedAt: job.queuedAt,
|
||||
startedAt: job.startedAt,
|
||||
finishedAt: job.finishedAt,
|
||||
errorCode: job.errorCode,
|
||||
publicErrorMessage: job.publicErrorMessage,
|
||||
artifacts: (job.artifacts || []).map((a: any) => ({
|
||||
artifactType: a.artifactType,
|
||||
artifactId: a.artifactId,
|
||||
ordinal: a.ordinal,
|
||||
})),
|
||||
// 禁止返回:validatedOutput, internalErrorMessage, snapshot, credential, outbox
|
||||
};
|
||||
}
|
||||
|
||||
private toAdminResponse(job: any) {
|
||||
return {
|
||||
id: job.id,
|
||||
userId: job.userId,
|
||||
jobType: job.jobType,
|
||||
lifecycleStatus: job.lifecycleStatus,
|
||||
triggerType: job.triggerType,
|
||||
queueName: job.queueName,
|
||||
priority: job.priority,
|
||||
progress: job.progress,
|
||||
targetType: job.targetType,
|
||||
targetId: job.targetId,
|
||||
parentJobId: job.parentJobId,
|
||||
attemptCount: job.attemptCount,
|
||||
maxAttempts: job.maxAttempts,
|
||||
timeoutMs: job.timeoutMs,
|
||||
credentialMode: job.credentialMode,
|
||||
modelProvider: job.modelProvider,
|
||||
modelName: job.modelName,
|
||||
createdAt: job.createdAt,
|
||||
queuedAt: job.queuedAt,
|
||||
startedAt: job.startedAt,
|
||||
finishedAt: job.finishedAt,
|
||||
errorCode: job.errorCode,
|
||||
publicErrorMessage: job.publicErrorMessage,
|
||||
internalErrorMessage: job.internalErrorMessage,
|
||||
validatedOutput: job.validatedOutput,
|
||||
outputHash: job.outputHash,
|
||||
artifacts: (job.artifacts || []).map((a: any) => ({
|
||||
artifactType: a.artifactType,
|
||||
artifactId: a.artifactId,
|
||||
ordinal: a.ordinal,
|
||||
})),
|
||||
};
|
||||
}
|
||||
}
|
||||
@ -3,6 +3,7 @@ import { RedisService } from '../infrastructure/redis/redis.service';
|
||||
|
||||
describe('WorkerHeartbeatService', () => {
|
||||
let service: WorkerHeartbeatService;
|
||||
let mockQ: any;
|
||||
let mockRedis: jest.Mocked<Pick<RedisService, 'set' | 'get' | 'del' | 'keys' | 'ttl'>>;
|
||||
|
||||
beforeEach(() => {
|
||||
@ -15,7 +16,8 @@ describe('WorkerHeartbeatService', () => {
|
||||
};
|
||||
|
||||
process.env.WORKER_INSTANCE_ID = 'test-worker-1';
|
||||
service = new WorkerHeartbeatService(mockRedis as any);
|
||||
mockQ = { getActiveCount: jest.fn().mockResolvedValue(0) };
|
||||
service = new WorkerHeartbeatService(mockRedis as any, mockQ as any, mockQ as any);
|
||||
});
|
||||
|
||||
afterEach(async () => {
|
||||
@ -61,7 +63,7 @@ describe('WorkerHeartbeatService', () => {
|
||||
|
||||
it('uses instanceId from WORKER_INSTANCE_ID env var', async () => {
|
||||
process.env.WORKER_INSTANCE_ID = 'custom-instance';
|
||||
const svc = new WorkerHeartbeatService(mockRedis as any);
|
||||
const svc = new WorkerHeartbeatService(mockRedis as any, mockQ as any, mockQ as any);
|
||||
await svc.onModuleInit();
|
||||
expect(mockRedis.set).toHaveBeenCalledWith(
|
||||
'worker:heartbeat:custom-instance',
|
||||
|
||||
@ -1,6 +1,9 @@
|
||||
import { Injectable, OnModuleInit, OnModuleDestroy, Logger } from '@nestjs/common';
|
||||
import { RedisService } from '../infrastructure/redis/redis.service';
|
||||
import { QUEUE_DEFINITIONS } from '../infrastructure/queue/queue-definitions';
|
||||
import { QUEUE_AI_INTERACTIVE, QUEUE_AI_BACKGROUND } from '../infrastructure/queue/queue.service';
|
||||
import { InjectQueue } from '@nestjs/bullmq';
|
||||
import { Queue } from 'bullmq';
|
||||
import * as os from 'os';
|
||||
|
||||
export interface WorkerHeartbeatData {
|
||||
@ -32,7 +35,11 @@ export class WorkerHeartbeatService implements OnModuleInit, OnModuleDestroy {
|
||||
private static readonly HEARTBEAT_INTERVAL_MS = 30_000;
|
||||
private static readonly HEARTBEAT_TTL_SEC = 90;
|
||||
|
||||
constructor(private readonly redis: RedisService) {}
|
||||
constructor(
|
||||
private readonly redis: RedisService,
|
||||
@InjectQueue(QUEUE_AI_INTERACTIVE) private readonly aiInteractiveQ: Queue,
|
||||
@InjectQueue(QUEUE_AI_BACKGROUND) private readonly aiBackgroundQ: Queue,
|
||||
) {}
|
||||
|
||||
async onModuleInit() {
|
||||
this.startedAt = new Date();
|
||||
@ -115,7 +122,7 @@ export class WorkerHeartbeatService implements OnModuleInit, OnModuleDestroy {
|
||||
return WorkerHeartbeatService.heartbeatKeyFor(this.resolveInstanceId());
|
||||
}
|
||||
|
||||
private async beat() {
|
||||
private async beat(): Promise<void> {
|
||||
const instanceId = this.resolveInstanceId();
|
||||
const data: WorkerHeartbeatData = {
|
||||
instanceId,
|
||||
@ -124,7 +131,7 @@ export class WorkerHeartbeatService implements OnModuleInit, OnModuleDestroy {
|
||||
startedAt: this.startedAt.toISOString(),
|
||||
queues: Object.keys(QUEUE_DEFINITIONS),
|
||||
concurrency: Object.values(QUEUE_DEFINITIONS)[0]?.workerOptions.concurrency ?? 1,
|
||||
activeJobCount: 0,
|
||||
activeJobCount: (await this.aiInteractiveQ.getActiveCount()) + (await this.aiBackgroundQ.getActiveCount()),
|
||||
hostname: os.hostname(),
|
||||
};
|
||||
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user