feat: 收紧生产发布确认校验

This commit is contained in:
湛兮
2026-06-12 01:56:56 +08:00
parent 6a4953e347
commit f7295529b6
4 changed files with 80 additions and 0 deletions
+21
View File
@@ -20,6 +20,13 @@ export const createDeployRunSchema = z.object({
operator: optionalTrimmedString(120),
remark: optionalTrimmedString(500),
idempotencyKey: optionalTrimmedString(260),
productionConfirmation: z
.object({
confirmed: z.boolean(),
confirmedAt: optionalTrimmedString(80),
summary: optionalTrimmedString(300),
})
.optional(),
});
export type CreateDeployRunRequestInput = z.infer<typeof createDeployRunSchema>;
@@ -47,4 +54,18 @@ export class CreateDeployRunDto {
example: 'access-manage:test:develop:2026-06-11T10:00:00Z',
})
idempotencyKey?: string;
@ApiPropertyOptional({
description: '生产环境发布必须提交的人工确认摘要;测试环境忽略。',
example: {
confirmed: true,
confirmedAt: '2026-06-12T02:30:00.000Z',
summary: '已确认 ref、回滚方案、通知对象和业务窗口',
},
})
productionConfirmation?: {
confirmed: boolean;
confirmedAt?: string;
summary?: string;
};
}
+10
View File
@@ -51,6 +51,7 @@ type DeployRunRecord = Prisma.DeployRunGetPayload<{
type DeployRunMetadata = Prisma.InputJsonObject & {
jenkinsBuildUrl?: string;
remark?: string;
productionConfirmation?: Prisma.InputJsonObject;
};
type RunStepMetadata = Prisma.InputJsonObject & {
@@ -650,6 +651,15 @@ export class DeployRunRepository {
metadata.remark = input.remark;
}
if (input.environment === 'production' && input.productionConfirmation) {
metadata.productionConfirmation = {
confirmed: input.productionConfirmation.confirmed,
confirmedAt: input.productionConfirmation.confirmedAt,
summary: input.productionConfirmation.summary,
operator: input.operator,
};
}
return metadata;
}
+7
View File
@@ -39,6 +39,12 @@ export type DeployRunSummary = {
steps: DeployRunStepSummary[];
};
export type ProductionDeployConfirmation = {
confirmed: boolean;
confirmedAt?: string;
summary?: string;
};
export type CreateDeployRunInput = {
projectKey: string;
environment: DeployEnvironment;
@@ -47,6 +53,7 @@ export type CreateDeployRunInput = {
remark?: string;
idempotencyKey?: string;
trigger?: DeployRunSummary['trigger'];
productionConfirmation?: ProductionDeployConfirmation;
};
export type DeployRunJenkinsSyncError = {
+42
View File
@@ -101,6 +101,7 @@ export class DeployRunsService {
const project = await this.projectsService.getProject(input.projectKey);
await this.validateReleaseRequest(input, project);
const run = await this.deployRunRepository.create(input);
const confirmation = this.productionConfirmationDigest(input);
await this.auditService.record({
action: 'DEPLOY_REQUESTED',
@@ -113,6 +114,7 @@ export class DeployRunsService {
ref: input.ref,
trigger: input.trigger ?? 'manual',
remark: input.remark,
productionConfirmation: confirmation,
},
});
@@ -204,6 +206,13 @@ export class DeployRunsService {
remark: `重试发布记录 ${source.id}`,
idempotencyKey: `${source.id}:retry:${new Date().toISOString()}`,
trigger: 'retry',
productionConfirmation:
source.environment === 'production'
? {
confirmed: true,
summary: `重试已确认的生产发布记录 ${source.id}`,
}
: undefined,
},
user,
);
@@ -395,6 +404,24 @@ export class DeployRunsService {
);
}
if (
input.environment === 'production' &&
!input.productionConfirmation?.confirmed
) {
await this.recordDeployRejected(input, '生产发布缺少人工二次确认');
throw new AppError(
'VALIDATION_FAILED',
'生产发布必须完成二次确认后才能提交',
400,
{
projectKey: project.key,
environment: input.environment,
ref: input.ref,
requiredField: 'productionConfirmation.confirmed',
},
);
}
if (!new RegExp(environment.refPattern).test(input.ref)) {
await this.recordDeployRejected(
input,
@@ -417,6 +444,21 @@ export class DeployRunsService {
return environment;
}
private productionConfirmationDigest(input: CreateDeployRunInput) {
if (input.environment !== 'production') {
return undefined;
}
const confirmation = input.productionConfirmation;
return {
confirmed: confirmation?.confirmed === true,
confirmedAt: confirmation?.confirmedAt,
summary: confirmation?.summary,
operator: input.operator,
};
}
private async recordDeployRejected(
input: CreateDeployRunInput,
reason: string,