feat: 接入真实登录与成员权限
- auth/members/messages: 新增超级管理员登录、成员权限和密码消息流程 - agent-config/agent: 支持服务端保存 Agent 配置并取消未配置 mock 成功结果 - projects/deploy-runs/settings: 按当前用户权限保护真实接口 - prisma: 新增用户、项目权限和平台消息表结构
This commit is contained in:
@@ -1,4 +1,6 @@
|
||||
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';
|
||||
@@ -30,19 +32,33 @@ export class DeployRunsService {
|
||||
private readonly deployNotificationService: DeployNotificationService,
|
||||
private readonly deployExecutionService: DeployExecutionService,
|
||||
private readonly jenkinsClient: JenkinsClient,
|
||||
private readonly authService: AuthService,
|
||||
) {}
|
||||
|
||||
async listRuns(): Promise<DeployRunSummary[]> {
|
||||
return this.deployRunRepository.findMany();
|
||||
async listRuns(user?: AuthenticatedUser): Promise<DeployRunSummary[]> {
|
||||
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): Promise<DeployRunSummary> {
|
||||
async getRun(
|
||||
id: string,
|
||||
user?: AuthenticatedUser,
|
||||
): Promise<DeployRunSummary> {
|
||||
const run = await this.deployRunRepository.findById(id);
|
||||
|
||||
if (!run) {
|
||||
throw new AppError(
|
||||
'RESOURCE_NOT_FOUND',
|
||||
`Deploy run ${id} not found`,
|
||||
`发布记录不存在:${id}`,
|
||||
404,
|
||||
{
|
||||
id,
|
||||
@@ -50,19 +66,38 @@ export class DeployRunsService {
|
||||
);
|
||||
}
|
||||
|
||||
if (user) {
|
||||
await this.authService.requireProjectPermission(
|
||||
user,
|
||||
run.projectKey,
|
||||
'read',
|
||||
);
|
||||
}
|
||||
|
||||
return run;
|
||||
}
|
||||
|
||||
async createRun(input: CreateDeployRunInput): Promise<DeployRunSummary> {
|
||||
async createRun(
|
||||
input: CreateDeployRunInput,
|
||||
user?: AuthenticatedUser,
|
||||
): Promise<DeployRunSummary> {
|
||||
if (!['test', 'production'].includes(input.environment)) {
|
||||
throw new AppError(
|
||||
'VALIDATION_FAILED',
|
||||
'environment must be test or production',
|
||||
'发布环境必须为 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);
|
||||
@@ -83,13 +118,24 @@ export class DeployRunsService {
|
||||
return this.deployExecutionService.execute(run, project);
|
||||
}
|
||||
|
||||
async cancelRun(id: string): Promise<DeployRunSummary> {
|
||||
const source = await this.getRun(id);
|
||||
async cancelRun(
|
||||
id: string,
|
||||
user?: AuthenticatedUser,
|
||||
): Promise<DeployRunSummary> {
|
||||
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',
|
||||
`Deploy run ${id} is already ${source.status} and cannot be canceled`,
|
||||
`发布记录 ${id} 已是终态,不能取消`,
|
||||
400,
|
||||
{
|
||||
id,
|
||||
@@ -130,27 +176,52 @@ export class DeployRunsService {
|
||||
return run;
|
||||
}
|
||||
|
||||
async retryRun(id: string): Promise<DeployRunSummary> {
|
||||
const source = await this.getRun(id);
|
||||
return this.createRun({
|
||||
projectKey: source.projectKey,
|
||||
environment: source.environment,
|
||||
ref: source.ref,
|
||||
operator: source.operator,
|
||||
remark: `Retry of ${source.id}`,
|
||||
idempotencyKey: `${source.id}:retry:${new Date().toISOString()}`,
|
||||
trigger: 'retry',
|
||||
});
|
||||
async retryRun(
|
||||
id: string,
|
||||
user?: AuthenticatedUser,
|
||||
): Promise<DeployRunSummary> {
|
||||
const source = await this.getRun(id, user);
|
||||
|
||||
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): Promise<DeployRunSummary> {
|
||||
const run = await this.getRun(id);
|
||||
async syncJenkinsRun(
|
||||
id: string,
|
||||
user?: AuthenticatedUser,
|
||||
): Promise<DeployRunSummary> {
|
||||
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(): Promise<DeployRunJenkinsSyncSummary> {
|
||||
async syncJenkinsRuns(
|
||||
user?: AuthenticatedUser,
|
||||
): Promise<DeployRunJenkinsSyncSummary> {
|
||||
if (user) {
|
||||
this.authService.assertSuperAdmin(user, '无权限同步全部 Jenkins 状态');
|
||||
}
|
||||
|
||||
const runs = await this.listRuns();
|
||||
const candidates = runs.filter((run) => this.isJenkinsSyncCandidate(run));
|
||||
const syncedRuns: DeployRunSummary[] = [];
|
||||
@@ -228,7 +299,7 @@ export class DeployRunsService {
|
||||
if (jenkinsHealth.status === 'not_configured') {
|
||||
return {
|
||||
requested: false,
|
||||
reason: jenkinsHealth.message ?? 'Jenkins is not configured',
|
||||
reason: jenkinsHealth.message ?? 'Jenkins 尚未配置',
|
||||
};
|
||||
}
|
||||
|
||||
@@ -245,7 +316,7 @@ export class DeployRunsService {
|
||||
|
||||
return {
|
||||
requested: false,
|
||||
reason: 'Deploy run has no Jenkins queue id or build number',
|
||||
reason: '发布记录缺少 Jenkins 队列编号或构建编号',
|
||||
};
|
||||
}
|
||||
|
||||
@@ -260,7 +331,7 @@ export class DeployRunsService {
|
||||
if (!environment) {
|
||||
throw new AppError(
|
||||
'VALIDATION_FAILED',
|
||||
`Project ${project.key} does not expose ${run.environment} environment`,
|
||||
`项目 ${project.key} 未配置 ${run.environment} 环境`,
|
||||
400,
|
||||
{ projectKey: project.key, environment: run.environment },
|
||||
);
|
||||
@@ -273,14 +344,14 @@ export class DeployRunsService {
|
||||
result: JenkinsCancelResult | { requested: false; reason: string },
|
||||
): string {
|
||||
if (!result.requested) {
|
||||
return `Cancellation recorded locally: ${result.reason}`;
|
||||
return `已在本地记录取消结果:${result.reason}`;
|
||||
}
|
||||
|
||||
if (result.target === 'build') {
|
||||
return `Jenkins build #${result.buildNumber ?? '-'} stop requested.`;
|
||||
return `已请求停止 Jenkins 构建 #${result.buildNumber ?? '-'}。`;
|
||||
}
|
||||
|
||||
return `Jenkins queue ${result.queueId ?? '-'} cancel requested.`;
|
||||
return `已请求取消 Jenkins 队列 ${result.queueId ?? '-'}。`;
|
||||
}
|
||||
|
||||
private async validateReleaseRequest(
|
||||
@@ -288,10 +359,10 @@ export class DeployRunsService {
|
||||
project: ProjectSummary,
|
||||
): Promise<ProjectEnvironmentSummary> {
|
||||
if (project.status !== 'active') {
|
||||
await this.recordDeployRejected(input, 'Project is archived');
|
||||
await this.recordDeployRejected(input, '项目已归档');
|
||||
throw new AppError(
|
||||
'VALIDATION_FAILED',
|
||||
`Project ${project.key} is archived and cannot be deployed`,
|
||||
`项目 ${project.key} 已归档,不能发布`,
|
||||
400,
|
||||
{ projectKey: project.key },
|
||||
);
|
||||
@@ -302,10 +373,10 @@ export class DeployRunsService {
|
||||
);
|
||||
|
||||
if (!environment) {
|
||||
await this.recordDeployRejected(input, 'Environment is not configured');
|
||||
await this.recordDeployRejected(input, '环境未配置');
|
||||
throw new AppError(
|
||||
'VALIDATION_FAILED',
|
||||
`Project ${project.key} does not expose ${input.environment} environment`,
|
||||
`项目 ${project.key} 未配置 ${input.environment} 环境`,
|
||||
400,
|
||||
{ projectKey: project.key, environment: input.environment },
|
||||
);
|
||||
@@ -314,11 +385,11 @@ export class DeployRunsService {
|
||||
if (!new RegExp(environment.refPattern).test(input.ref)) {
|
||||
await this.recordDeployRejected(
|
||||
input,
|
||||
'Ref does not match release policy',
|
||||
'Ref 不符合发布策略',
|
||||
);
|
||||
throw new AppError(
|
||||
'VALIDATION_FAILED',
|
||||
`Ref ${input.ref} does not match ${environment.releasePolicy} release policy`,
|
||||
`Ref ${input.ref} 不符合 ${environment.releasePolicy} 发布策略`,
|
||||
400,
|
||||
{
|
||||
projectKey: project.key,
|
||||
@@ -352,7 +423,7 @@ export class DeployRunsService {
|
||||
|
||||
private toSafeSyncError(error: unknown): string {
|
||||
const message =
|
||||
error instanceof Error ? error.message : 'Jenkins sync failed';
|
||||
error instanceof Error ? error.message : 'Jenkins 同步失败';
|
||||
|
||||
return redactSensitive({ message }).message;
|
||||
}
|
||||
@@ -364,7 +435,7 @@ export class DeployRunsService {
|
||||
if (!run) {
|
||||
throw new AppError(
|
||||
'RESOURCE_NOT_FOUND',
|
||||
`Deploy run ${id} not found`,
|
||||
`发布记录不存在:${id}`,
|
||||
404,
|
||||
{
|
||||
id,
|
||||
|
||||
Reference in New Issue
Block a user