fix: Admin Retry 幂等键从 timestamp 改为 operationId
All checks were successful
Deploy API Server / build-and-unit (push) Successful in 34s
Deploy API Server / current-integration (push) Successful in 30s
Deploy API Server / backward-compat (push) Successful in 0s
Deploy API Server / m-ai-03-synthetic-e2e (push) Successful in 9s
Deploy API Server / deploy (push) Successful in 1m1s

- retryJob 新增 operationId 参数(必填,调用方提供稳定操作标识)
- 幂等键: admin-retry:<jobId>:<operationId>(同 operationId → 同 Job)
- Admin Controller: operationId 校验(1-128 字符,alphanumeric + hyphen/underscore)
- 不同 operationId 允许管理员多次独立重试同一失败 Job

Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
wangdl 2026-06-21 11:08:33 +08:00
parent d0f75531ab
commit 6962db3f1a
2 changed files with 15 additions and 8 deletions

View File

@ -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 重试(创建新 JobparentJobId 关联原 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')

View File

@ -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);