feat: 收紧生产发布确认校验
This commit is contained in:
@@ -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;
|
||||
};
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
|
||||
@@ -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 = {
|
||||
|
||||
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user