From f7295529b6e38bce1297d757f8104f0d4fb9311a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E6=B9=9B=E5=85=AE?= Date: Fri, 12 Jun 2026 01:56:56 +0800 Subject: [PATCH] =?UTF-8?q?feat:=20=E6=94=B6=E7=B4=A7=E7=94=9F=E4=BA=A7?= =?UTF-8?q?=E5=8F=91=E5=B8=83=E7=A1=AE=E8=AE=A4=E6=A0=A1=E9=AA=8C?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/deploy-runs/create-deploy-run.dto.ts | 21 ++++++++++++ src/deploy-runs/deploy-run.repository.ts | 10 ++++++ src/deploy-runs/deploy-run.types.ts | 7 ++++ src/deploy-runs/deploy-runs.service.ts | 42 ++++++++++++++++++++++++ 4 files changed, 80 insertions(+) diff --git a/src/deploy-runs/create-deploy-run.dto.ts b/src/deploy-runs/create-deploy-run.dto.ts index 8323e05..384ecae 100644 --- a/src/deploy-runs/create-deploy-run.dto.ts +++ b/src/deploy-runs/create-deploy-run.dto.ts @@ -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; @@ -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; + }; } diff --git a/src/deploy-runs/deploy-run.repository.ts b/src/deploy-runs/deploy-run.repository.ts index 34a6c02..fd74ee1 100644 --- a/src/deploy-runs/deploy-run.repository.ts +++ b/src/deploy-runs/deploy-run.repository.ts @@ -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; } diff --git a/src/deploy-runs/deploy-run.types.ts b/src/deploy-runs/deploy-run.types.ts index 43f9424..5fab673 100644 --- a/src/deploy-runs/deploy-run.types.ts +++ b/src/deploy-runs/deploy-run.types.ts @@ -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 = { diff --git a/src/deploy-runs/deploy-runs.service.ts b/src/deploy-runs/deploy-runs.service.ts index 066a986..16a6294 100644 --- a/src/deploy-runs/deploy-runs.service.ts +++ b/src/deploy-runs/deploy-runs.service.ts @@ -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,