feat: 接入真实登录与成员权限

- auth/members/messages: 新增超级管理员登录、成员权限和密码消息流程
- agent-config/agent: 支持服务端保存 Agent 配置并取消未配置 mock 成功结果
- projects/deploy-runs/settings: 按当前用户权限保护真实接口
- prisma: 新增用户、项目权限和平台消息表结构
This commit is contained in:
湛兮
2026-06-12 00:38:23 +08:00
parent cf1bebf625
commit bc3aa31289
83 changed files with 3844 additions and 378 deletions
+70
View File
@@ -0,0 +1,70 @@
import { ApiProperty } from '@nestjs/swagger';
import { z } from 'zod';
const permissionLevelSchema = z.enum(['none', 'read', 'build']);
export const projectPermissionSchema = z.object({
projectKey: z.string().trim().min(1).max(80),
level: permissionLevelSchema,
});
export const createMemberSchema = z.object({
account: z.string().trim().min(1).max(80),
displayName: z.string().trim().min(1).max(120),
permissions: z.array(projectPermissionSchema).optional().default([]),
});
export type CreateMemberDtoInput = z.infer<typeof createMemberSchema>;
export class ProjectPermissionDto {
@ApiProperty({ example: 'devops-platform-api', description: '项目标识' })
projectKey!: string;
@ApiProperty({
enum: ['none', 'read', 'build'],
description: '项目权限:none 无权限,read 只读,build 构建',
})
level!: 'none' | 'read' | 'build';
}
export class CreateMemberDto {
@ApiProperty({ example: 'member01', description: '成员账号' })
account!: string;
@ApiProperty({ example: '成员一', description: '成员名称' })
displayName!: string;
@ApiProperty({
type: [ProjectPermissionDto],
required: false,
description: '成员项目权限,未传项目默认为无权限',
})
permissions?: ProjectPermissionDto[];
}
export const updateMemberPermissionsSchema = z.object({
permissions: z.array(projectPermissionSchema).default([]),
});
export type UpdateMemberPermissionsInput = z.infer<
typeof updateMemberPermissionsSchema
>;
export class UpdateMemberPermissionsDto {
@ApiProperty({ type: [ProjectPermissionDto], description: '完整项目权限列表' })
permissions!: ProjectPermissionDto[];
}
export const updateMemberStatusSchema = z.object({
status: z.enum(['active', 'disabled']),
});
export type UpdateMemberStatusInput = z.infer<typeof updateMemberStatusSchema>;
export class UpdateMemberStatusDto {
@ApiProperty({
enum: ['active', 'disabled'],
description: '成员状态:active 启用,disabled 禁用',
})
status!: 'active' | 'disabled';
}
+25
View File
@@ -0,0 +1,25 @@
import { AuthRole, AuthStatus, ProjectPermissionLevel } from '../auth/auth.types';
export type MemberProjectPermissionSummary = {
projectId: string;
projectKey: string;
projectName: string;
level: ProjectPermissionLevel;
levelName: string;
};
export type MemberSummary = {
id: string;
account: string;
displayName: string;
role: AuthRole;
roleName: string;
status: AuthStatus;
statusName: string;
mustChangePassword: boolean;
hasPendingPasswordResetRequest: boolean;
lastLoginAt?: string;
createdAt: string;
updatedAt: string;
projectPermissions: MemberProjectPermissionSummary[];
};
+105
View File
@@ -0,0 +1,105 @@
import {
Body,
Controller,
Delete,
Get,
Param,
Patch,
Post,
UseGuards,
} from '@nestjs/common';
import {
ApiBody,
ApiCreatedResponse,
ApiOkResponse,
ApiTags,
} from '@nestjs/swagger';
import { AdminOnlyMessage } from '../auth/admin-only-message.decorator';
import { AuthGuard } from '../auth/auth.guard';
import { AuthenticatedUser } from '../auth/auth.types';
import { CurrentUser } from '../auth/current-user.decorator';
import { PasswordChangeGuard } from '../auth/password-change.guard';
import { SuperAdminGuard } from '../auth/super-admin.guard';
import { ZodValidationPipe } from '../common/pipes/zod-validation.pipe';
import {
CreateMemberDto,
CreateMemberDtoInput,
UpdateMemberPermissionsDto,
UpdateMemberPermissionsInput,
UpdateMemberStatusDto,
UpdateMemberStatusInput,
createMemberSchema,
updateMemberPermissionsSchema,
updateMemberStatusSchema,
} from './member.dto';
import { MemberSummary } from './member.types';
import { MembersService } from './members.service';
@ApiTags('成员管理')
@Controller('members')
@UseGuards(AuthGuard, PasswordChangeGuard, SuperAdminGuard)
@AdminOnlyMessage('无权限访问成员管理')
export class MembersController {
constructor(private readonly membersService: MembersService) {}
@Get()
@ApiOkResponse({ description: '查询成员列表和项目权限。' })
async listMembers(): Promise<MemberSummary[]> {
return this.membersService.listMembers();
}
@Post()
@ApiBody({ type: CreateMemberDto })
@ApiCreatedResponse({ description: '创建普通成员,初始密码固定为 111111。' })
async createMember(
@CurrentUser() user: AuthenticatedUser,
@Body(new ZodValidationPipe(createMemberSchema))
body: CreateMemberDtoInput,
): Promise<MemberSummary> {
return this.membersService.createMember(body, user);
}
@Patch(':id/permissions')
@ApiBody({ type: UpdateMemberPermissionsDto })
@ApiOkResponse({ description: '更新普通成员的项目权限。' })
async updatePermissions(
@Param('id') id: string,
@CurrentUser() user: AuthenticatedUser,
@Body(new ZodValidationPipe(updateMemberPermissionsSchema))
body: UpdateMemberPermissionsInput,
): Promise<MemberSummary> {
return this.membersService.updatePermissions(id, body, user);
}
@Post(':id/reset-password')
@ApiOkResponse({
description: '将普通成员密码重置为 111111,并要求下次登录改密。',
})
async resetPassword(
@Param('id') id: string,
@CurrentUser() user: AuthenticatedUser,
): Promise<MemberSummary> {
return this.membersService.resetPassword(id, user);
}
@Patch(':id/status')
@ApiBody({ type: UpdateMemberStatusDto })
@ApiOkResponse({ description: '启用或禁用普通成员账号。' })
async updateStatus(
@Param('id') id: string,
@CurrentUser() user: AuthenticatedUser,
@Body(new ZodValidationPipe(updateMemberStatusSchema))
body: UpdateMemberStatusInput,
): Promise<MemberSummary> {
return this.membersService.updateStatus(id, body, user);
}
@Delete(':id')
@ApiOkResponse({ description: '删除普通成员账号。' })
async deleteMember(
@Param('id') id: string,
@CurrentUser() user: AuthenticatedUser,
): Promise<{ message: string }> {
return this.membersService.deleteMember(id, user);
}
}
+15
View File
@@ -0,0 +1,15 @@
import { Module } from '@nestjs/common';
import { AuditModule } from '../audit/audit.module';
import { AuthModule } from '../auth/auth.module';
import { MessagesModule } from '../messages/messages.module';
import { ProjectsModule } from '../projects/projects.module';
import { MembersController } from './members.controller';
import { MembersService } from './members.service';
@Module({
imports: [AuditModule, AuthModule, MessagesModule, ProjectsModule],
controllers: [MembersController],
providers: [MembersService],
exports: [MembersService],
})
export class MembersModule {}
+288
View File
@@ -0,0 +1,288 @@
import { Injectable } from '@nestjs/common';
import { AuditService } from '../audit/audit.service';
import { MEMBER_INITIAL_PASSWORD, SUPER_ADMIN_SEED } from '../auth/auth.constants';
import {
AuthenticatedUser,
ProjectPermissionInput,
ProjectPermissionLevel,
UserRecord,
} from '../auth/auth.types';
import { PasswordService } from '../auth/password.service';
import { UserRepository } from '../auth/user.repository';
import { AppError } from '../common/errors/app-error';
import { MessagesService } from '../messages/messages.service';
import { ProjectRepository } from '../projects/project.repository';
import {
CreateMemberDtoInput,
UpdateMemberPermissionsInput,
UpdateMemberStatusInput,
} from './member.dto';
import {
MemberProjectPermissionSummary,
MemberSummary,
} from './member.types';
@Injectable()
export class MembersService {
constructor(
private readonly userRepository: UserRepository,
private readonly passwordService: PasswordService,
private readonly messagesService: MessagesService,
private readonly projectRepository: ProjectRepository,
private readonly auditService: AuditService,
) {}
async listMembers(): Promise<MemberSummary[]> {
const users = await this.userRepository.listUsers();
return Promise.all(users.map((user) => this.toMemberSummary(user)));
}
async createMember(
input: CreateMemberDtoInput,
operator: AuthenticatedUser,
): Promise<MemberSummary> {
const account = input.account.trim();
if (account === SUPER_ADMIN_SEED.account) {
throw new AppError(
'CONFLICT',
'该账号为内置超级管理员账号,不能创建为普通成员',
409,
);
}
const existing = await this.userRepository.findByAccount(account);
if (existing) {
throw new AppError('CONFLICT', '成员账号已存在', 409, { account });
}
const passwordHash = await this.passwordService.hashPassword(
MEMBER_INITIAL_PASSWORD,
);
const user = await this.userRepository.createUser({
account,
displayName: input.displayName,
passwordHash,
role: 'member',
mustChangePassword: true,
createdById: operator.id,
});
const updatedUser = await this.userRepository.replaceProjectPermissions(
user.id,
input.permissions ?? [],
);
await this.auditService.record({
action: 'MEMBER_CREATED',
resourceType: 'user',
resourceId: updatedUser.id,
actorId: operator.id,
actorName: operator.account,
after: {
account: updatedUser.account,
permissions: this.safePermissionDigest(input.permissions ?? []),
mustChangePassword: true,
},
});
return this.toMemberSummary(updatedUser);
}
async updatePermissions(
memberId: string,
input: UpdateMemberPermissionsInput,
operator: AuthenticatedUser,
): Promise<MemberSummary> {
const user = await this.requireMutableMember(memberId);
const updatedUser = await this.userRepository.replaceProjectPermissions(
user.id,
input.permissions,
);
await this.auditService.record({
action: 'MEMBER_PERMISSIONS_UPDATED',
resourceType: 'user',
resourceId: updatedUser.id,
actorId: operator.id,
actorName: operator.account,
before: {
permissions: this.safePermissionDigest(user.projectPermissions),
},
after: {
permissions: this.safePermissionDigest(input.permissions),
},
});
return this.toMemberSummary(updatedUser);
}
async resetPassword(
memberId: string,
operator: AuthenticatedUser,
): Promise<MemberSummary> {
const user = await this.requireMutableMember(memberId);
const passwordHash = await this.passwordService.hashPassword(
MEMBER_INITIAL_PASSWORD,
);
const updatedUser = await this.userRepository.updatePassword(
user.id,
passwordHash,
true,
);
await this.messagesService.markPasswordResetHandledForRequester(
user.id,
operator,
);
await this.auditService.record({
action: 'MEMBER_PASSWORD_RESET',
resourceType: 'user',
resourceId: user.id,
actorId: operator.id,
actorName: operator.account,
after: {
resetToInitialPassword: true,
mustChangePassword: true,
},
});
return this.toMemberSummary(updatedUser);
}
async updateStatus(
memberId: string,
input: UpdateMemberStatusInput,
operator: AuthenticatedUser,
): Promise<MemberSummary> {
const user = await this.requireMutableMember(memberId);
const updatedUser = await this.userRepository.setStatus(
user.id,
input.status,
);
await this.auditService.record({
action: 'MEMBER_STATUS_UPDATED',
resourceType: 'user',
resourceId: user.id,
actorId: operator.id,
actorName: operator.account,
before: {
status: user.status,
},
after: {
status: updatedUser.status,
},
});
return this.toMemberSummary(updatedUser);
}
async deleteMember(
memberId: string,
operator: AuthenticatedUser,
): Promise<{ message: string }> {
const user = await this.requireMutableMember(memberId);
await this.userRepository.deleteUser(user.id);
await this.auditService.record({
action: 'MEMBER_DELETED',
resourceType: 'user',
resourceId: user.id,
actorId: operator.id,
actorName: operator.account,
before: {
account: user.account,
},
});
return {
message: '成员已删除',
};
}
private async requireMutableMember(memberId: string): Promise<UserRecord> {
const user = await this.userRepository.findById(memberId);
if (!user) {
throw new AppError('RESOURCE_NOT_FOUND', '成员不存在', 404, { memberId });
}
if (user.role === 'super_admin') {
throw new AppError(
'FORBIDDEN',
'超级管理员不可删除、不可禁用、不可降级、不可重置为普通成员初始密码',
403,
);
}
return user;
}
private async toMemberSummary(user: UserRecord): Promise<MemberSummary> {
return {
id: user.id,
account: user.account,
displayName: user.displayName,
role: user.role,
roleName: user.role === 'super_admin' ? '超级管理员' : '普通成员',
status: user.status,
statusName: user.status === 'active' ? '启用' : '禁用',
mustChangePassword: user.mustChangePassword,
hasPendingPasswordResetRequest:
await this.messagesService.hasPendingPasswordResetRequest(user.id),
lastLoginAt: user.lastLoginAt,
createdAt: user.createdAt,
updatedAt: user.updatedAt,
projectPermissions: await this.resolveProjectPermissions(user),
};
}
private async resolveProjectPermissions(
user: UserRecord,
): Promise<MemberProjectPermissionSummary[]> {
const projects = await this.projectRepository.findMany();
const permissionByProject = new Map(
user.projectPermissions.map((permission) => [
permission.projectKey,
permission.level,
]),
);
return projects.map((project) => {
const level =
user.role === 'super_admin'
? 'build'
: (permissionByProject.get(project.key) ?? 'none');
return {
projectId: project.id,
projectKey: project.key,
projectName: project.name,
level,
levelName: this.permissionLevelName(level),
};
});
}
private permissionLevelName(level: ProjectPermissionLevel): string {
if (level === 'build') {
return '构建权限';
}
if (level === 'read') {
return '只读权限';
}
return '无权限';
}
private safePermissionDigest(
permissions: Array<ProjectPermissionInput | { projectKey: string; level: string }>,
): Array<{ projectKey: string; level: string }> {
return permissions.map((permission) => ({
projectKey: permission.projectKey,
level: permission.level,
}));
}
}