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;
|
steps: true;
|
||||||
};
|
};
|
||||||
}>;
|
}>;
|
||||||
|
type DeployRunDatabaseClient = PrismaService | Prisma.TransactionClient;
|
||||||
|
|
||||||
type DeployRunMetadata = Prisma.InputJsonObject & {
|
type DeployRunMetadata = Prisma.InputJsonObject & {
|
||||||
jenkinsBuildUrl?: string;
|
jenkinsBuildUrl?: string;
|
||||||
@@ -149,61 +150,7 @@ export class DeployRunRepository {
|
|||||||
return this.createMemoryRun(input, idempotencyKey);
|
return this.createMemoryRun(input, idempotencyKey);
|
||||||
}
|
}
|
||||||
|
|
||||||
const existing = await this.prisma.deployRun.findUnique({
|
const created = await this.createDatabaseRun(input, idempotencyKey);
|
||||||
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;
|
|
||||||
});
|
|
||||||
|
|
||||||
return this.toSummary(created);
|
return this.toSummary(created);
|
||||||
}
|
}
|
||||||
@@ -469,8 +416,92 @@ export class DeployRunRepository {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
private async findTargetProjectEnvironment(input: CreateDeployRunInput) {
|
private async findRecordByIdempotencyKey(
|
||||||
const project = await this.prisma.project.findUnique({
|
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 },
|
where: { key: input.projectKey },
|
||||||
include: {
|
include: {
|
||||||
environments: {
|
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(
|
private createMemoryRun(
|
||||||
input: CreateDeployRunInput,
|
input: CreateDeployRunInput,
|
||||||
idempotencyKey: string,
|
idempotencyKey: string,
|
||||||
|
|||||||
Reference in New Issue
Block a user