From 6962db3f1a9d2fc300be8476fd79075e88d568a8 Mon Sep 17 00:00:00 2001 From: wangdl Date: Sun, 21 Jun 2026 11:08:33 +0800 Subject: [PATCH] =?UTF-8?q?fix:=20Admin=20Retry=20=E5=B9=82=E7=AD=89?= =?UTF-8?q?=E9=94=AE=E4=BB=8E=20timestamp=20=E6=94=B9=E4=B8=BA=20operation?= =?UTF-8?q?Id?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - retryJob 新增 operationId 参数(必填,调用方提供稳定操作标识) - 幂等键: admin-retry::(同 operationId → 同 Job) - Admin Controller: operationId 校验(1-128 字符,alphanumeric + hyphen/underscore) - 不同 operationId 允许管理员多次独立重试同一失败 Job Co-Authored-By: Claude --- src/modules/ai-job/ai-job-admin.controller.ts | 12 +++++++++--- src/modules/ai-job/ai-job.service.ts | 11 ++++++----- 2 files changed, 15 insertions(+), 8 deletions(-) diff --git a/src/modules/ai-job/ai-job-admin.controller.ts b/src/modules/ai-job/ai-job-admin.controller.ts index 5a2e446..c549e1f 100644 --- a/src/modules/ai-job/ai-job-admin.controller.ts +++ b/src/modules/ai-job/ai-job-admin.controller.ts @@ -1,4 +1,4 @@ -import { Controller, Get, Post, Param, Query, Body, UseGuards } from '@nestjs/common'; +import { Controller, Get, Post, Param, Query, Body, UseGuards, BadRequestException } from '@nestjs/common'; import { ApiTags, ApiBearerAuth, ApiOperation } from '@nestjs/swagger'; import { InjectQueue } from '@nestjs/bullmq'; import { Queue } from 'bullmq'; @@ -47,8 +47,14 @@ export class AiJobAdminController { @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); + async retry(@Param('jobId') jobId: string, @Query('operationId') operationId: string) { + if (!operationId || operationId.trim().length === 0) { + throw new BadRequestException('operationId is required for Admin retry'); + } + if (operationId.length > 128 || !operationId.match(/^[a-zA-Z0-9_-]+$/)) { + throw new BadRequestException('operationId must be 1-128 alphanumeric chars, hyphens, or underscores'); + } + return this.service.retryJob('admin', jobId, operationId.trim()); } @Get('queues/status') diff --git a/src/modules/ai-job/ai-job.service.ts b/src/modules/ai-job/ai-job.service.ts index 0e5334d..57d88f7 100644 --- a/src/modules/ai-job/ai-job.service.ts +++ b/src/modules/ai-job/ai-job.service.ts @@ -139,7 +139,7 @@ export class AiJobService { }; } - async retryJob(adminUserId: string, jobId: string) { + async retryJob(adminUserId: string, jobId: string, operationId: string) { // 1. Load original Job const original = await this.prisma.aiJob.findUnique({ where: { id: jobId }, @@ -163,9 +163,10 @@ export class AiJobService { throw new ConflictException('Original job missing targetType/targetId — cannot retry'); } - // 5. Idempotency key: allows same admin operation to return same retry job, - // but different retry operations produce different jobs - const retryIdempotencyKey = `admin-retry:${jobId}:${Date.now()}`; + // 5. Stable idempotency key using caller-provided operationId. + // Same (jobId, operationId) → same retry Job (幂等). + // Different operationId → different retry Job (允许管理员多次独立重试). + const retryIdempotencyKey = `admin-retry:${jobId}:${operationId}`; // 6. Delegate to AiJobCreationService (Registry + Snapshot + Outbox + atomic transaction) const newJob = await this.creationService.createJob({ @@ -185,7 +186,7 @@ export class AiJobService { this.logger.log( `Admin retry: original=${jobId} → new=${newJob.id} ` + - `type=${newJob.jobType} parentJobId=${original.id}`, + `operationId=${operationId} parentJobId=${original.id}`, ); return this.toAdminResponse(newJob);