fix: 加固发布单幂等创建
- src/deploy-runs/deploy-run.repository.ts: 将幂等查重、目标环境读取和 run/steps 创建纳入同一事务 - src/deploy-runs/deploy-run.repository.ts: 并发唯一键冲突时回读已有发布单,避免重复点击冒泡为 500
This commit is contained in:
@@ -48,6 +48,7 @@ type DeployRunRecord = Prisma.DeployRunGetPayload<{
|
||||
steps: true;
|
||||
};
|
||||
}>;
|
||||
type DeployRunDatabaseClient = PrismaService | Prisma.TransactionClient;
|
||||
|
||||
type DeployRunMetadata = Prisma.InputJsonObject & {
|
||||
jenkinsBuildUrl?: string;
|
||||
@@ -149,61 +150,7 @@ export class DeployRunRepository {
|
||||
return this.createMemoryRun(input, idempotencyKey);
|
||||
}
|
||||
|
||||
const existing = await this.prisma.deployRun.findUnique({
|
||||
where: { idempotencyKey },
|
||||
include: {
|
||||
project: true,
|
||||
environment: true,
|
||||
steps: {
|
||||
orderBy: {
|
||||
order: 'asc',
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
if (existing) {
|
||||
return this.toSummary(existing);
|
||||
}
|
||||
|
||||
const target = await this.findTargetProjectEnvironment(input);
|
||||
const initialRun = this.buildInitialRun(input, idempotencyKey);
|
||||
const metadata = this.inputMetadata(input);
|
||||
|
||||
const created = await this.prisma.$transaction(async (tx) => {
|
||||
const run = await tx.deployRun.create({
|
||||
data: {
|
||||
projectId: target.project.id,
|
||||
environmentId: target.environment.id,
|
||||
jobId: target.deployJob?.id,
|
||||
status: PrismaDeployRunStatus.PENDING,
|
||||
trigger: triggerToPrisma[input.trigger ?? 'manual'],
|
||||
ref: input.ref,
|
||||
operator: input.operator,
|
||||
idempotencyKey,
|
||||
metadata,
|
||||
steps: {
|
||||
create: initialRun.steps.map((step) => ({
|
||||
bpmnNodeId: step.bpmnNodeId,
|
||||
name: step.name,
|
||||
status: stepStatusToPrisma[step.status],
|
||||
order: step.order,
|
||||
})),
|
||||
},
|
||||
},
|
||||
include: {
|
||||
project: true,
|
||||
environment: true,
|
||||
steps: {
|
||||
orderBy: {
|
||||
order: 'asc',
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
return run;
|
||||
});
|
||||
const created = await this.createDatabaseRun(input, idempotencyKey);
|
||||
|
||||
return this.toSummary(created);
|
||||
}
|
||||
@@ -469,8 +416,92 @@ export class DeployRunRepository {
|
||||
});
|
||||
}
|
||||
|
||||
private async findTargetProjectEnvironment(input: CreateDeployRunInput) {
|
||||
const project = await this.prisma.project.findUnique({
|
||||
private async findRecordByIdempotencyKey(
|
||||
idempotencyKey: string,
|
||||
client: DeployRunDatabaseClient = this.prisma,
|
||||
): Promise<DeployRunRecord | null> {
|
||||
return client.deployRun.findUnique({
|
||||
where: { idempotencyKey },
|
||||
include: {
|
||||
project: true,
|
||||
environment: true,
|
||||
steps: {
|
||||
orderBy: {
|
||||
order: 'asc',
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
private async createDatabaseRun(
|
||||
input: CreateDeployRunInput,
|
||||
idempotencyKey: string,
|
||||
): Promise<DeployRunRecord> {
|
||||
try {
|
||||
return await this.prisma.$transaction(async (tx) => {
|
||||
const existing = await this.findRecordByIdempotencyKey(
|
||||
idempotencyKey,
|
||||
tx,
|
||||
);
|
||||
|
||||
if (existing) {
|
||||
return existing;
|
||||
}
|
||||
|
||||
const target = await this.findTargetProjectEnvironment(input, tx);
|
||||
const initialRun = this.buildInitialRun(input, idempotencyKey);
|
||||
const metadata = this.inputMetadata(input);
|
||||
|
||||
return tx.deployRun.create({
|
||||
data: {
|
||||
projectId: target.project.id,
|
||||
environmentId: target.environment.id,
|
||||
jobId: target.deployJob?.id,
|
||||
status: PrismaDeployRunStatus.PENDING,
|
||||
trigger: triggerToPrisma[input.trigger ?? 'manual'],
|
||||
ref: input.ref,
|
||||
operator: input.operator,
|
||||
idempotencyKey,
|
||||
metadata,
|
||||
steps: {
|
||||
create: initialRun.steps.map((step) => ({
|
||||
bpmnNodeId: step.bpmnNodeId,
|
||||
name: step.name,
|
||||
status: stepStatusToPrisma[step.status],
|
||||
order: step.order,
|
||||
})),
|
||||
},
|
||||
},
|
||||
include: {
|
||||
project: true,
|
||||
environment: true,
|
||||
steps: {
|
||||
orderBy: {
|
||||
order: 'asc',
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
});
|
||||
} catch (error) {
|
||||
if (this.isIdempotencyUniqueConflict(error)) {
|
||||
const existing = await this.findRecordByIdempotencyKey(idempotencyKey);
|
||||
|
||||
if (existing) {
|
||||
return existing;
|
||||
}
|
||||
}
|
||||
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
private async findTargetProjectEnvironment(
|
||||
input: CreateDeployRunInput,
|
||||
client: DeployRunDatabaseClient = this.prisma,
|
||||
) {
|
||||
const project = await client.project.findUnique({
|
||||
where: { key: input.projectKey },
|
||||
include: {
|
||||
environments: {
|
||||
@@ -503,6 +534,34 @@ export class DeployRunRepository {
|
||||
};
|
||||
}
|
||||
|
||||
private isIdempotencyUniqueConflict(error: unknown): boolean {
|
||||
if (!error || typeof error !== 'object') {
|
||||
return false;
|
||||
}
|
||||
|
||||
const record = error as {
|
||||
code?: unknown;
|
||||
meta?: {
|
||||
target?: unknown;
|
||||
};
|
||||
};
|
||||
|
||||
if (record.code !== 'P2002') {
|
||||
return false;
|
||||
}
|
||||
|
||||
const target = record.meta?.target;
|
||||
|
||||
if (Array.isArray(target)) {
|
||||
return target.includes('idempotencyKey');
|
||||
}
|
||||
|
||||
return (
|
||||
typeof target === 'string' &&
|
||||
target.toLowerCase().includes('idempotencykey')
|
||||
);
|
||||
}
|
||||
|
||||
private createMemoryRun(
|
||||
input: CreateDeployRunInput,
|
||||
idempotencyKey: string,
|
||||
|
||||
Reference in New Issue
Block a user