feat: 收紧生产发布确认校验
This commit is contained in:
@@ -20,6 +20,13 @@ export const createDeployRunSchema = z.object({
|
|||||||
operator: optionalTrimmedString(120),
|
operator: optionalTrimmedString(120),
|
||||||
remark: optionalTrimmedString(500),
|
remark: optionalTrimmedString(500),
|
||||||
idempotencyKey: optionalTrimmedString(260),
|
idempotencyKey: optionalTrimmedString(260),
|
||||||
|
productionConfirmation: z
|
||||||
|
.object({
|
||||||
|
confirmed: z.boolean(),
|
||||||
|
confirmedAt: optionalTrimmedString(80),
|
||||||
|
summary: optionalTrimmedString(300),
|
||||||
|
})
|
||||||
|
.optional(),
|
||||||
});
|
});
|
||||||
|
|
||||||
export type CreateDeployRunRequestInput = z.infer<typeof createDeployRunSchema>;
|
export type CreateDeployRunRequestInput = z.infer<typeof createDeployRunSchema>;
|
||||||
@@ -47,4 +54,18 @@ export class CreateDeployRunDto {
|
|||||||
example: 'access-manage:test:develop:2026-06-11T10:00:00Z',
|
example: 'access-manage:test:develop:2026-06-11T10:00:00Z',
|
||||||
})
|
})
|
||||||
idempotencyKey?: string;
|
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 & {
|
type DeployRunMetadata = Prisma.InputJsonObject & {
|
||||||
jenkinsBuildUrl?: string;
|
jenkinsBuildUrl?: string;
|
||||||
remark?: string;
|
remark?: string;
|
||||||
|
productionConfirmation?: Prisma.InputJsonObject;
|
||||||
};
|
};
|
||||||
|
|
||||||
type RunStepMetadata = Prisma.InputJsonObject & {
|
type RunStepMetadata = Prisma.InputJsonObject & {
|
||||||
@@ -650,6 +651,15 @@ export class DeployRunRepository {
|
|||||||
metadata.remark = input.remark;
|
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;
|
return metadata;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -39,6 +39,12 @@ export type DeployRunSummary = {
|
|||||||
steps: DeployRunStepSummary[];
|
steps: DeployRunStepSummary[];
|
||||||
};
|
};
|
||||||
|
|
||||||
|
export type ProductionDeployConfirmation = {
|
||||||
|
confirmed: boolean;
|
||||||
|
confirmedAt?: string;
|
||||||
|
summary?: string;
|
||||||
|
};
|
||||||
|
|
||||||
export type CreateDeployRunInput = {
|
export type CreateDeployRunInput = {
|
||||||
projectKey: string;
|
projectKey: string;
|
||||||
environment: DeployEnvironment;
|
environment: DeployEnvironment;
|
||||||
@@ -47,6 +53,7 @@ export type CreateDeployRunInput = {
|
|||||||
remark?: string;
|
remark?: string;
|
||||||
idempotencyKey?: string;
|
idempotencyKey?: string;
|
||||||
trigger?: DeployRunSummary['trigger'];
|
trigger?: DeployRunSummary['trigger'];
|
||||||
|
productionConfirmation?: ProductionDeployConfirmation;
|
||||||
};
|
};
|
||||||
|
|
||||||
export type DeployRunJenkinsSyncError = {
|
export type DeployRunJenkinsSyncError = {
|
||||||
|
|||||||
@@ -101,6 +101,7 @@ export class DeployRunsService {
|
|||||||
const project = await this.projectsService.getProject(input.projectKey);
|
const project = await this.projectsService.getProject(input.projectKey);
|
||||||
await this.validateReleaseRequest(input, project);
|
await this.validateReleaseRequest(input, project);
|
||||||
const run = await this.deployRunRepository.create(input);
|
const run = await this.deployRunRepository.create(input);
|
||||||
|
const confirmation = this.productionConfirmationDigest(input);
|
||||||
|
|
||||||
await this.auditService.record({
|
await this.auditService.record({
|
||||||
action: 'DEPLOY_REQUESTED',
|
action: 'DEPLOY_REQUESTED',
|
||||||
@@ -113,6 +114,7 @@ export class DeployRunsService {
|
|||||||
ref: input.ref,
|
ref: input.ref,
|
||||||
trigger: input.trigger ?? 'manual',
|
trigger: input.trigger ?? 'manual',
|
||||||
remark: input.remark,
|
remark: input.remark,
|
||||||
|
productionConfirmation: confirmation,
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -204,6 +206,13 @@ export class DeployRunsService {
|
|||||||
remark: `重试发布记录 ${source.id}`,
|
remark: `重试发布记录 ${source.id}`,
|
||||||
idempotencyKey: `${source.id}:retry:${new Date().toISOString()}`,
|
idempotencyKey: `${source.id}:retry:${new Date().toISOString()}`,
|
||||||
trigger: 'retry',
|
trigger: 'retry',
|
||||||
|
productionConfirmation:
|
||||||
|
source.environment === 'production'
|
||||||
|
? {
|
||||||
|
confirmed: true,
|
||||||
|
summary: `重试已确认的生产发布记录 ${source.id}`,
|
||||||
|
}
|
||||||
|
: undefined,
|
||||||
},
|
},
|
||||||
user,
|
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)) {
|
if (!new RegExp(environment.refPattern).test(input.ref)) {
|
||||||
await this.recordDeployRejected(
|
await this.recordDeployRejected(
|
||||||
input,
|
input,
|
||||||
@@ -417,6 +444,21 @@ export class DeployRunsService {
|
|||||||
return environment;
|
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(
|
private async recordDeployRejected(
|
||||||
input: CreateDeployRunInput,
|
input: CreateDeployRunInput,
|
||||||
reason: string,
|
reason: string,
|
||||||
|
|||||||
Reference in New Issue
Block a user