From c2afe6e693775c9401fe54f173a9305ac1f52508 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E6=B9=9B=E5=85=AE?= Date: Fri, 12 Jun 2026 10:53:30 +0800 Subject: [PATCH] =?UTF-8?q?feat:=20=E4=BC=98=E5=8C=96=E9=A1=B9=E7=9B=AE?= =?UTF-8?q?=E6=9D=83=E9=99=90=E4=B8=8E=E5=8F=91=E5=B8=83=E9=89=B4=E6=9D=83?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/auth/auth.service.ts | 7 ++++ src/auth/user.repository.ts | 50 +++++++++++++++------------ src/members/member.dto.ts | 10 +++--- src/members/members.service.ts | 37 ++++++++++++++++---- src/projects/project.types.ts | 4 +++ src/projects/projects.service.spec.ts | 5 ++- src/projects/projects.service.ts | 43 +++++++++++++++++++++-- 7 files changed, 118 insertions(+), 38 deletions(-) diff --git a/src/auth/auth.service.ts b/src/auth/auth.service.ts index 3e7ab10..cdd7bc0 100644 --- a/src/auth/auth.service.ts +++ b/src/auth/auth.service.ts @@ -215,6 +215,13 @@ export class AuthService implements OnModuleInit { return this.userRepository.listAllowedProjectKeys(user); } + async getProjectPermissionLevel( + user: AuthenticatedUser, + projectKey: string, + ): Promise { + return this.userRepository.getProjectPermissionLevel(user, projectKey); + } + assertSuperAdmin(user: AuthenticatedUser, message = '无权限访问该功能'): void { if (user.role !== 'super_admin') { throw new AppError('FORBIDDEN', message, 403); diff --git a/src/auth/user.repository.ts b/src/auth/user.repository.ts index 2f9e04f..6d49ecd 100644 --- a/src/auth/user.repository.ts +++ b/src/auth/user.repository.ts @@ -278,20 +278,32 @@ export class UserRepository { userId: string, permissions: ProjectPermissionInput[], ): Promise { - const normalizedPermissions = permissions.filter( - (permission) => permission.level !== 'none', + const normalizedPermissions: Array<{ + projectKey: string; + level: Exclude; + }> = permissions.map((permission) => ({ + projectKey: permission.projectKey, + level: permission.level === 'build' ? 'build' : 'read', + })); + const uniquePermissions = Array.from( + new Map( + normalizedPermissions.map((permission) => [ + permission.projectKey, + permission, + ]), + ).values(), ); if (!this.useDatabase()) { const user = this.requireMemoryUser(userId); - user.projectPermissions = normalizedPermissions.map((permission) => { + user.projectPermissions = uniquePermissions.map((permission) => { const project = this.findSeedProject(permission.projectKey); return { projectId: project.id, projectKey: project.key, projectName: project.name, - level: permission.level as Exclude, + level: permission.level, }; }); user.updatedAt = new Date().toISOString(); @@ -301,11 +313,11 @@ export class UserRepository { const projects = await this.prisma.project.findMany({ where: { key: { - in: normalizedPermissions.map((permission) => permission.projectKey), + in: uniquePermissions.map((permission) => permission.projectKey), }, }, }); - const missingProjectKeys = normalizedPermissions + const missingProjectKeys = uniquePermissions .map((permission) => permission.projectKey) .filter((projectKey) => projects.every((project) => project.key !== projectKey), @@ -325,9 +337,9 @@ export class UserRepository { where: { userId }, }); - if (normalizedPermissions.length > 0) { + if (uniquePermissions.length > 0) { await tx.projectMemberPermission.createMany({ - data: normalizedPermissions.map((permission) => { + data: uniquePermissions.map((permission) => { const project = projects.find( (item) => item.key === permission.projectKey, ); @@ -368,7 +380,7 @@ export class UserRepository { return ( currentUser?.projectPermissions.find( (permission) => permission.projectKey === projectKey, - )?.level ?? 'none' + )?.level ?? 'read' ); } @@ -381,7 +393,7 @@ export class UserRepository { }, }); - return permission ? this.toPermissionLevel(permission.level) : 'none'; + return permission ? this.toPermissionLevel(permission.level) : 'read'; } async listAllowedProjectKeys(user: AuthenticatedUser): Promise { @@ -400,24 +412,16 @@ export class UserRepository { } if (!this.useDatabase()) { - const currentUser = this.memoryUsers.find((item) => item.id === user.id); - return ( - currentUser?.projectPermissions.map( - (permission) => permission.projectKey, - ) ?? [] - ); + return seedProjects.map((project) => project.key); } - const permissions = await this.prisma.projectMemberPermission.findMany({ - where: { - userId: user.id, - }, - include: { - project: true, + const projects = await this.prisma.project.findMany({ + select: { + key: true, }, }); - return permissions.map((permission) => permission.project.key); + return projects.map((project) => project.key); } private requireMemoryUser(userId: string): UserRecord { diff --git a/src/members/member.dto.ts b/src/members/member.dto.ts index b312251..ef994c7 100644 --- a/src/members/member.dto.ts +++ b/src/members/member.dto.ts @@ -1,7 +1,7 @@ import { ApiProperty } from '@nestjs/swagger'; import { z } from 'zod'; -const permissionLevelSchema = z.enum(['none', 'read', 'build']); +const permissionLevelSchema = z.enum(['read', 'build']); export const projectPermissionSchema = z.object({ projectKey: z.string().trim().min(1).max(80), @@ -21,10 +21,10 @@ export class ProjectPermissionDto { projectKey!: string; @ApiProperty({ - enum: ['none', 'read', 'build'], - description: '项目权限:none 无权限,read 只读,build 构建', + enum: ['read', 'build'], + description: '项目权限:read 只读,build 构建', }) - level!: 'none' | 'read' | 'build'; + level!: 'read' | 'build'; } export class CreateMemberDto { @@ -37,7 +37,7 @@ export class CreateMemberDto { @ApiProperty({ type: [ProjectPermissionDto], required: false, - description: '成员项目权限,未传项目默认为无权限', + description: '成员项目权限,未传或漏传的项目默认为只读权限', }) permissions?: ProjectPermissionDto[]; } diff --git a/src/members/members.service.ts b/src/members/members.service.ts index f7ee3f5..5785ba1 100644 --- a/src/members/members.service.ts +++ b/src/members/members.service.ts @@ -69,9 +69,12 @@ export class MembersService { mustChangePassword: true, createdById: operator.id, }); + const permissions = await this.completeProjectPermissions( + input.permissions ?? [], + ); const updatedUser = await this.userRepository.replaceProjectPermissions( user.id, - input.permissions ?? [], + permissions, ); await this.auditService.record({ @@ -82,7 +85,7 @@ export class MembersService { actorName: operator.account, after: { account: updatedUser.account, - permissions: this.safePermissionDigest(input.permissions ?? []), + permissions: this.safePermissionDigest(permissions), mustChangePassword: true, }, }); @@ -96,9 +99,10 @@ export class MembersService { operator: AuthenticatedUser, ): Promise { const user = await this.requireMutableMember(memberId); + const permissions = await this.completeProjectPermissions(input.permissions); const updatedUser = await this.userRepository.replaceProjectPermissions( user.id, - input.permissions, + permissions, ); await this.auditService.record({ @@ -111,7 +115,7 @@ export class MembersService { permissions: this.safePermissionDigest(user.projectPermissions), }, after: { - permissions: this.safePermissionDigest(input.permissions), + permissions: this.safePermissionDigest(permissions), }, }); @@ -253,7 +257,7 @@ export class MembersService { const level = user.role === 'super_admin' ? 'build' - : (permissionByProject.get(project.key) ?? 'none'); + : (permissionByProject.get(project.key) ?? 'read'); return { projectId: project.id, @@ -274,7 +278,28 @@ export class MembersService { return '只读权限'; } - return '无权限'; + return '只读权限'; + } + + private async completeProjectPermissions( + permissions: ProjectPermissionInput[], + ): Promise { + const projects = await this.projectRepository.findMany(); + const defaultLevel: Exclude = 'read'; + const levelByProject = new Map< + string, + Exclude + >( + permissions.map((permission) => [ + permission.projectKey, + permission.level === 'build' ? 'build' : defaultLevel, + ]), + ); + + return projects.map((project) => ({ + projectKey: project.key, + level: levelByProject.get(project.key) ?? defaultLevel, + })); } private safePermissionDigest( diff --git a/src/projects/project.types.ts b/src/projects/project.types.ts index 3236a3f..d06b71a 100644 --- a/src/projects/project.types.ts +++ b/src/projects/project.types.ts @@ -1,6 +1,8 @@ /** * 项目模块对前端暴露的安全模型;第三方 token 只在后端使用,不进入响应。 */ +import { ProjectPermissionLevel } from '../auth/auth.types'; + export type ProjectEnvironmentSummary = { name: 'test' | 'production'; displayName: string; @@ -16,6 +18,8 @@ export type ProjectSummary = { repositoryUrl: string; defaultBranch: string; status: 'active' | 'archived'; + permissionLevel?: Exclude; + permissionLevelName?: string; environments: ProjectEnvironmentSummary[]; }; diff --git a/src/projects/projects.service.spec.ts b/src/projects/projects.service.spec.ts index 986df28..bbd5baa 100644 --- a/src/projects/projects.service.spec.ts +++ b/src/projects/projects.service.spec.ts @@ -71,10 +71,13 @@ describe('ProjectsService', () => { >; const auth = { listAllowedProjectKeys: jest.fn(() => Promise.resolve([project.key])), + getProjectPermissionLevel: jest.fn(() => Promise.resolve('build' as const)), requireProjectPermission: jest.fn(() => Promise.resolve()), } satisfies Pick< AuthService, - 'listAllowedProjectKeys' | 'requireProjectPermission' + | 'listAllowedProjectKeys' + | 'getProjectPermissionLevel' + | 'requireProjectPermission' >; const jenkins = { healthSummary: jest.fn(() => ({ status: 'not_configured' as const })), diff --git a/src/projects/projects.service.ts b/src/projects/projects.service.ts index b2c61d7..4d8f7d1 100644 --- a/src/projects/projects.service.ts +++ b/src/projects/projects.service.ts @@ -4,7 +4,7 @@ import { Injectable } from '@nestjs/common'; import { ConfigService } from '@nestjs/config'; import { AuthService } from '../auth/auth.service'; -import { AuthenticatedUser } from '../auth/auth.types'; +import { AuthenticatedUser, ProjectPermissionLevel } from '../auth/auth.types'; import { AppError } from '../common/errors/app-error'; import { IntegrationRequestError } from '../common/errors/integration-error'; import { EnvConfig } from '../config/env.schema'; @@ -45,15 +45,32 @@ export class ProjectsService { async listProjects(user?: AuthenticatedUser): Promise { const projects = await this.projectRepository.findMany(); - if (!user || user.role === 'super_admin') { + if (!user) { return projects; } + if (user.role === 'super_admin') { + return projects.map((project) => this.withProjectPermission(project, 'build')); + } + const allowedProjectKeys = new Set( await this.authService.listAllowedProjectKeys(user), ); - return projects.filter((project) => allowedProjectKeys.has(project.key)); + const visibleProjects = projects.filter((project) => + allowedProjectKeys.has(project.key), + ); + + return Promise.all( + visibleProjects.map(async (project) => { + const level = await this.authService.getProjectPermissionLevel( + user, + project.key, + ); + + return this.withProjectPermission(project, level); + }), + ); } async getProject( @@ -70,11 +87,31 @@ export class ProjectsService { if (user) { await this.authService.requireProjectPermission(user, key, 'read'); + const level = + user.role === 'super_admin' + ? 'build' + : await this.authService.getProjectPermissionLevel(user, key); + + return this.withProjectPermission(project, level); } return project; } + private withProjectPermission( + project: ProjectSummary, + level: ProjectPermissionLevel, + ): ProjectSummary { + const permissionLevel = level === 'build' ? 'build' : 'read'; + + return { + ...project, + permissionLevel, + permissionLevelName: + permissionLevel === 'build' ? '构建权限' : '只读权限', + }; + } + async listProjectRefs( key: string, user?: AuthenticatedUser,