From b41c9c902f2b8ce4276577c3d151a8b03f33d060 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E6=B9=9B=E5=85=AE?= Date: Fri, 12 Jun 2026 02:37:07 +0800 Subject: [PATCH] =?UTF-8?q?feat:=20=E5=BC=80=E6=94=BE=20Jenkins=20?= =?UTF-8?q?=E6=9E=84=E5=BB=BA=E6=97=A5=E5=BF=97=E6=8E=A5=E5=8F=A3?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/deploy-runs/deploy-run.types.ts | 11 ++++ src/deploy-runs/deploy-runs.controller.ts | 21 +++++- src/deploy-runs/deploy-runs.service.ts | 79 +++++++++++++++++++++++ 3 files changed, 110 insertions(+), 1 deletion(-) diff --git a/src/deploy-runs/deploy-run.types.ts b/src/deploy-runs/deploy-run.types.ts index 5fab673..f28bb7c 100644 --- a/src/deploy-runs/deploy-run.types.ts +++ b/src/deploy-runs/deploy-run.types.ts @@ -68,3 +68,14 @@ export type DeployRunJenkinsSyncSummary = { syncedRuns: DeployRunSummary[]; failedRuns: DeployRunJenkinsSyncError[]; }; + +export type DeployRunJenkinsLog = { + runId: string; + jobPath: string; + buildNumber: number; + start: number; + nextStart: number; + hasMore: boolean; + text: string; + fetchedAt: string; +}; diff --git a/src/deploy-runs/deploy-runs.controller.ts b/src/deploy-runs/deploy-runs.controller.ts index 5bcd04f..cd78ea4 100644 --- a/src/deploy-runs/deploy-runs.controller.ts +++ b/src/deploy-runs/deploy-runs.controller.ts @@ -1,4 +1,12 @@ -import { Body, Controller, Get, Param, Post, UseGuards } from '@nestjs/common'; +import { + Body, + Controller, + Get, + Param, + Post, + Query, + UseGuards, +} from '@nestjs/common'; import { ApiBody, ApiCreatedResponse, @@ -18,6 +26,7 @@ import { CurrentUser } from '../auth/current-user.decorator'; import { PasswordChangeGuard } from '../auth/password-change.guard'; import { SuperAdminGuard } from '../auth/super-admin.guard'; import { + DeployRunJenkinsLog, DeployRunJenkinsSyncSummary, DeployRunSummary, } from './deploy-run.types'; @@ -46,6 +55,16 @@ export class DeployRunsController { return this.deployRunsService.getRun(id, user); } + @Get(':id/jenkins-log') + @ApiOkResponse({ description: '读取发布单对应 Jenkins build 日志。' }) + async getJenkinsLog( + @Param('id') id: string, + @Query('start') start: string | undefined, + @CurrentUser() user: AuthenticatedUser, + ): Promise { + return this.deployRunsService.getJenkinsLog(id, start, user); + } + @Post() @ApiBody({ type: CreateDeployRunDto }) @ApiCreatedResponse({ description: '创建发布单并触发构建。' }) diff --git a/src/deploy-runs/deploy-runs.service.ts b/src/deploy-runs/deploy-runs.service.ts index 0ff4f56..13faff0 100644 --- a/src/deploy-runs/deploy-runs.service.ts +++ b/src/deploy-runs/deploy-runs.service.ts @@ -19,6 +19,7 @@ import { DeployExecutionService } from './deploy-execution.service'; import { DeployRunRepository } from './deploy-run.repository'; import { CreateDeployRunInput, + DeployRunJenkinsLog, DeployRunJenkinsSyncSummary, DeployRunSummary, } from './deploy-run.types'; @@ -237,6 +238,63 @@ export class DeployRunsService { return this.deployExecutionService.syncJenkinsRun(run, project); } + async getJenkinsLog( + id: string, + start: string | undefined, + user?: AuthenticatedUser, + ): Promise { + const run = await this.getRun(id, user); + + if (run.jenkinsBuildNumber === undefined || run.jenkinsBuildNumber === null) { + throw new AppError( + 'VALIDATION_FAILED', + `发布记录 ${id} 尚未关联 Jenkins build,无法读取日志`, + 400, + { + id, + jenkinsQueueId: run.jenkinsQueueId, + }, + ); + } + + const project = await this.projectsService.getProject(run.projectKey); + const environment = this.findEnvironment(project, run); + const normalizedStart = this.normalizeLogStart(start); + const log = await this.jenkinsClient.getProgressiveText( + environment.jenkinsJobPath, + run.jenkinsBuildNumber, + normalizedStart, + ); + const text = redactSensitive({ text: log.text }).text; + + await this.auditService.record({ + action: 'JENKINS_LOG_VIEWED', + resourceType: 'deploy_run', + resourceId: run.id, + actorName: user?.account ?? run.operator, + after: { + projectKey: run.projectKey, + environment: run.environment, + jenkinsBuildNumber: run.jenkinsBuildNumber, + start: normalizedStart, + nextStart: log.nextStart, + hasMore: log.hasMore, + textLength: text.length, + }, + }); + + return { + runId: run.id, + jobPath: environment.jenkinsJobPath, + buildNumber: run.jenkinsBuildNumber, + start: normalizedStart, + nextStart: log.nextStart, + hasMore: log.hasMore, + text, + fetchedAt: new Date().toISOString(), + }; + } + async syncJenkinsRuns( user?: AuthenticatedUser, ): Promise { @@ -311,6 +369,27 @@ export class DeployRunsService { ); } + private normalizeLogStart(start: string | undefined): number { + if (start === undefined || start === '') { + return 0; + } + + const value = Number(start); + + if (!Number.isSafeInteger(value) || value < 0) { + throw new AppError( + 'VALIDATION_FAILED', + 'Jenkins 日志 start 必须是非负整数', + 400, + { + start, + }, + ); + } + + return value; + } + private async cancelJenkinsExecution( run: DeployRunSummary, project: ProjectSummary,