import { Injectable } from '@nestjs/common'; import { AuthService } from '../auth/auth.service'; import { AuthenticatedUser } from '../auth/auth.types'; import { AppError } from '../common/errors/app-error'; import { redactSensitive } from '../common/security/redact-sensitive'; import { AuditService } from '../audit/audit.service'; import { DeployNotificationEvent, DeployNotificationService, } from '../notifications/wecom/deploy-notification.service'; import { JenkinsCancelResult } from '../integrations/jenkins/jenkins.types'; import { JenkinsClient } from '../integrations/jenkins/jenkins.client'; import { ProjectEnvironmentSummary, ProjectSummary, } from '../projects/project.types'; import { ProjectsService } from '../projects/projects.service'; import { DeployExecutionService } from './deploy-execution.service'; import { DeployRunRepository } from './deploy-run.repository'; import { CreateDeployRunInput, DeployRunJenkinsSyncSummary, DeployRunSummary, } from './deploy-run.types'; @Injectable() export class DeployRunsService { constructor( private readonly deployRunRepository: DeployRunRepository, private readonly projectsService: ProjectsService, private readonly auditService: AuditService, private readonly deployNotificationService: DeployNotificationService, private readonly deployExecutionService: DeployExecutionService, private readonly jenkinsClient: JenkinsClient, private readonly authService: AuthService, ) {} async listRuns(user?: AuthenticatedUser): Promise { const runs = await this.deployRunRepository.findMany(); if (!user || user.role === 'super_admin') { return runs; } const allowedProjectKeys = new Set( await this.authService.listAllowedProjectKeys(user), ); return runs.filter((run) => allowedProjectKeys.has(run.projectKey)); } async getRun( id: string, user?: AuthenticatedUser, ): Promise { const run = await this.deployRunRepository.findById(id); if (!run) { throw new AppError( 'RESOURCE_NOT_FOUND', `发布记录不存在:${id}`, 404, { id, }, ); } if (user) { await this.authService.requireProjectPermission( user, run.projectKey, 'read', ); } return run; } async createRun( input: CreateDeployRunInput, user?: AuthenticatedUser, ): Promise { if (!['test', 'production'].includes(input.environment)) { throw new AppError( 'VALIDATION_FAILED', '发布环境必须为 test 或 production', 400, { environment: input.environment }, ); } if (user) { await this.authService.requireProjectPermission( user, input.projectKey, 'build', ); } const project = await this.projectsService.getProject(input.projectKey); await this.validateReleaseRequest(input, project); const run = await this.deployRunRepository.create(input); await this.auditService.record({ action: 'DEPLOY_REQUESTED', resourceType: 'deploy_run', resourceId: run.id, actorName: input.operator, after: { projectKey: input.projectKey, environment: input.environment, ref: input.ref, trigger: input.trigger ?? 'manual', remark: input.remark, }, }); return this.deployExecutionService.execute(run, project); } async cancelRun( id: string, user?: AuthenticatedUser, ): Promise { const source = await this.getRun(id, user); if (user) { await this.authService.requireProjectPermission( user, source.projectKey, 'build', ); } if (['success', 'failed', 'canceled'].includes(source.status)) { throw new AppError( 'VALIDATION_FAILED', `发布记录 ${id} 已是终态,不能取消`, 400, { id, status: source.status, }, ); } const project = await this.projectsService.getProject(source.projectKey); const jenkinsCancel = await this.cancelJenkinsExecution(source, project); let run = this.requireRun( id, await this.deployRunRepository.updateRun(id, { status: 'canceled', }), ); run = this.requireRun( id, await this.deployRunRepository.updateStep(id, 'jenkins-build', { status: 'canceled', message: undefined, errorSummary: this.cancelSummary(jenkinsCancel), }), ); await this.auditService.record({ action: 'DEPLOY_CANCELED', resourceType: 'deploy_run', resourceId: run.id, actorName: run.operator, after: { status: run.status, jenkinsCancel, }, }); await this.recordNotificationResult(run, 'canceled'); return run; } async retryRun( id: string, user?: AuthenticatedUser, ): Promise { const source = await this.getRun(id, user); if (!['failed', 'canceled'].includes(source.status)) { throw new AppError( 'VALIDATION_FAILED', `发布记录 ${id} 当前状态为 ${source.status},只能重试失败或已取消的发布单`, 400, { id, status: source.status, }, ); } return this.createRun( { projectKey: source.projectKey, environment: source.environment, ref: source.ref, operator: user?.account ?? source.operator, remark: `重试发布记录 ${source.id}`, idempotencyKey: `${source.id}:retry:${new Date().toISOString()}`, trigger: 'retry', }, user, ); } async syncJenkinsRun( id: string, user?: AuthenticatedUser, ): Promise { const run = await this.getRun(id, user); if (user) { await this.authService.requireProjectPermission( user, run.projectKey, 'build', ); } const project = await this.projectsService.getProject(run.projectKey); return this.deployExecutionService.syncJenkinsRun(run, project); } async syncJenkinsRuns( user?: AuthenticatedUser, ): Promise { if (user) { this.authService.assertSuperAdmin(user, '无权限同步全部 Jenkins 状态'); } const runs = await this.listRuns(); const candidates = runs.filter((run) => this.isJenkinsSyncCandidate(run)); const syncedRuns: DeployRunSummary[] = []; const failedRuns: DeployRunJenkinsSyncSummary['failedRuns'] = []; for (const run of candidates) { try { const project = await this.projectsService.getProject(run.projectKey); const syncedRun = await this.deployExecutionService.syncJenkinsRun( run, project, ); syncedRuns.push(syncedRun); } catch (error) { failedRuns.push({ id: run.id, projectKey: run.projectKey, message: this.toSafeSyncError(error), }); } } await this.auditService.record({ action: 'JENKINS_SYNC_SWEEP_COMPLETED', resourceType: 'deploy_run', after: { totalCandidates: candidates.length, syncedCount: syncedRuns.length, failedCount: failedRuns.length, }, }); return { checkedAt: new Date().toISOString(), totalCandidates: candidates.length, syncedRuns, failedRuns, }; } private async recordNotificationResult( run: DeployRunSummary, event: DeployNotificationEvent, ): Promise { const notification = await this.deployNotificationService.sendDeployRunEvent(run, event); await this.auditService.record({ action: `DEPLOY_NOTIFICATION_${notification.status.toUpperCase()}`, resourceType: 'deploy_run', resourceId: run.id, actorName: run.operator, after: { event, channel: notification.channel, reason: notification.reason, }, }); } private isJenkinsSyncCandidate(run: DeployRunSummary): boolean { return ( ['queued', 'running'].includes(run.status) && Boolean(run.jenkinsQueueId || run.jenkinsBuildNumber) ); } private async cancelJenkinsExecution( run: DeployRunSummary, project: ProjectSummary, ): Promise { const environment = this.findEnvironment(project, run); const jenkinsHealth = this.jenkinsClient.healthSummary(); if (jenkinsHealth.status === 'not_configured') { return { requested: false, reason: jenkinsHealth.message ?? 'Jenkins 尚未配置', }; } if (run.jenkinsBuildNumber) { return this.jenkinsClient.stopBuild( environment.jenkinsJobPath, run.jenkinsBuildNumber, ); } if (run.jenkinsQueueId) { return this.jenkinsClient.cancelQueueItem(run.jenkinsQueueId); } return { requested: false, reason: '发布记录缺少 Jenkins 队列编号或构建编号', }; } private findEnvironment( project: ProjectSummary, run: DeployRunSummary, ): ProjectEnvironmentSummary { const environment = project.environments.find( (item) => item.name === run.environment, ); if (!environment) { throw new AppError( 'VALIDATION_FAILED', `项目 ${project.key} 未配置 ${run.environment} 环境`, 400, { projectKey: project.key, environment: run.environment }, ); } return environment; } private cancelSummary( result: JenkinsCancelResult | { requested: false; reason: string }, ): string { if (!result.requested) { return `已在本地记录取消结果:${result.reason}`; } if (result.target === 'build') { return `已请求停止 Jenkins 构建 #${result.buildNumber ?? '-'}。`; } return `已请求取消 Jenkins 队列 ${result.queueId ?? '-'}。`; } private async validateReleaseRequest( input: CreateDeployRunInput, project: ProjectSummary, ): Promise { if (project.status !== 'active') { await this.recordDeployRejected(input, '项目已归档'); throw new AppError( 'VALIDATION_FAILED', `项目 ${project.key} 已归档,不能发布`, 400, { projectKey: project.key }, ); } const environment = project.environments.find( (item) => item.name === input.environment, ); if (!environment) { await this.recordDeployRejected(input, '环境未配置'); throw new AppError( 'VALIDATION_FAILED', `项目 ${project.key} 未配置 ${input.environment} 环境`, 400, { projectKey: project.key, environment: input.environment }, ); } if (!new RegExp(environment.refPattern).test(input.ref)) { await this.recordDeployRejected( input, 'Ref 不符合发布策略', ); throw new AppError( 'VALIDATION_FAILED', `Ref ${input.ref} 不符合 ${environment.releasePolicy} 发布策略`, 400, { projectKey: project.key, environment: input.environment, ref: input.ref, releasePolicy: environment.releasePolicy, refPattern: environment.refPattern, }, ); } return environment; } private async recordDeployRejected( input: CreateDeployRunInput, reason: string, ): Promise { await this.auditService.record({ action: 'DEPLOY_REJECTED', resourceType: 'deploy_run', actorName: input.operator, after: { projectKey: input.projectKey, environment: input.environment, ref: input.ref, reason, }, }); } private toSafeSyncError(error: unknown): string { const message = error instanceof Error ? error.message : 'Jenkins 同步失败'; return redactSensitive({ message }).message; } private requireRun( id: string, run: DeployRunSummary | null, ): DeployRunSummary { if (!run) { throw new AppError( 'RESOURCE_NOT_FOUND', `发布记录不存在:${id}`, 404, { id, }, ); } return run; } }