feat(v2.7): 增加项目成员治理接口

This commit is contained in:
2026-07-08 16:15:52 +08:00
parent 95523cd4a1
commit 0f57e75689
6 changed files with 346 additions and 1 deletions

View File

@@ -12,9 +12,10 @@ import { V22QueryModule } from './modules/v22-query/v22-query.module';
import { HealthModule } from './modules/health/health.module';
import { NotificationModule } from './modules/notification/notification.module';
import { CommentModule } from './modules/comment/comment.module';
import { ProjectMemberModule } from './modules/project-member/project-member.module';
@Module({
imports: [PrismaModule, ProductModule, RequirementModule, ConfigModule, DataModule, MigrationModule, V22QueryModule, HealthModule, AiModule, NotificationModule, CommentModule],
imports: [PrismaModule, ProductModule, RequirementModule, ConfigModule, DataModule, MigrationModule, V22QueryModule, HealthModule, AiModule, NotificationModule, CommentModule, ProjectMemberModule],
controllers: [],
providers: [
{

View File

@@ -0,0 +1,29 @@
import { IsIn, IsOptional, IsString } from 'class-validator';
import type { ProjectGovernanceRole } from '../../../common/rbac/rbac.service';
const PROJECT_ROLES = ['owner', 'admin', 'member', 'viewer'] as const;
export class AddProjectMemberDto {
@IsString()
actorId!: string;
@IsString()
userId!: string;
@IsOptional()
@IsIn(PROJECT_ROLES)
role?: ProjectGovernanceRole;
}
export class UpdateProjectMemberRoleDto {
@IsString()
actorId!: string;
@IsIn(PROJECT_ROLES)
role!: ProjectGovernanceRole;
}
export class RemoveProjectMemberDto {
@IsString()
actorId!: string;
}

View File

@@ -0,0 +1,36 @@
import { Body, Controller, Delete, Get, Param, Patch, Post } from '@nestjs/common';
import { AddProjectMemberDto, RemoveProjectMemberDto, UpdateProjectMemberRoleDto } from './dto/project-member.dto';
import { ProjectMemberService } from './project-member.service';
@Controller('projects/:projectId/members')
export class ProjectMemberController {
constructor(private readonly projectMemberService: ProjectMemberService) {}
@Get()
list(@Param('projectId') projectId: string) {
return this.projectMemberService.list(projectId);
}
@Post()
add(@Param('projectId') projectId: string, @Body() dto: AddProjectMemberDto) {
return this.projectMemberService.add({ ...dto, projectId });
}
@Patch(':userId/role')
updateRole(
@Param('projectId') projectId: string,
@Param('userId') userId: string,
@Body() dto: UpdateProjectMemberRoleDto,
) {
return this.projectMemberService.updateRole({ ...dto, projectId, userId });
}
@Delete(':userId')
remove(
@Param('projectId') projectId: string,
@Param('userId') userId: string,
@Body() dto: RemoveProjectMemberDto,
) {
return this.projectMemberService.remove({ ...dto, projectId, userId });
}
}

View File

@@ -0,0 +1,12 @@
import { Module } from '@nestjs/common';
import { CommonDomainModule } from '../../common/common-domain.module';
import { ProjectMemberController } from './project-member.controller';
import { ProjectMemberService } from './project-member.service';
@Module({
imports: [CommonDomainModule],
controllers: [ProjectMemberController],
providers: [ProjectMemberService],
exports: [ProjectMemberService],
})
export class ProjectMemberModule {}

View File

@@ -0,0 +1,138 @@
import { BadRequestException } from '@nestjs/common';
import { AuditService } from '../../common/audit/audit.service';
import { RbacService } from '../../common/rbac/rbac.service';
import { ProjectMemberService } from './project-member.service';
describe('ProjectMemberService', () => {
const makeService = () => {
const prisma = {
projectMember: {
count: jest.fn(),
create: jest.fn(),
delete: jest.fn(),
findMany: jest.fn(),
findUnique: jest.fn(),
update: jest.fn(),
},
};
const rbac = {
assertProjectRole: jest.fn().mockResolvedValue({ role: 'owner' }),
} as unknown as RbacService;
const audit = {
record: jest.fn(),
} as unknown as AuditService;
return {
prisma,
rbac,
audit,
service: new ProjectMemberService(prisma as any, rbac, audit),
};
};
it('lists project members with their user profile', async () => {
const { prisma, service } = makeService();
prisma.projectMember.findMany.mockResolvedValue([{ id: 'pm-1', role: 'owner' }]);
await expect(service.list('project-1')).resolves.toEqual([{ id: 'pm-1', role: 'owner' }]);
expect(prisma.projectMember.findMany).toHaveBeenCalledWith({
where: { projectId: 'project-1' },
include: { user: { select: { id: true, name: true, email: true } } },
orderBy: [{ role: 'asc' }, { createdAt: 'asc' }],
});
});
it('creates a project member after admin-or-owner authorization and writes audit', async () => {
const { prisma, rbac, audit, service } = makeService();
prisma.projectMember.create.mockResolvedValue({ id: 'pm-2', role: 'member' });
await service.add({ actorId: 'm-owner', projectId: 'project-1', userId: 'm-dev', role: 'member' });
expect((rbac.assertProjectRole as jest.Mock)).toHaveBeenCalledWith({
actorId: 'm-owner',
projectId: 'project-1',
allowedRoles: ['admin'],
});
expect(prisma.projectMember.create).toHaveBeenCalledWith({
data: { projectId: 'project-1', userId: 'm-dev', role: 'member' },
});
expect((audit.record as jest.Mock)).toHaveBeenCalledWith(expect.objectContaining({
actorId: 'm-owner',
action: 'project_member.created',
resourceType: 'project_member',
projectId: 'project-1',
}));
});
it('rejects demoting the last project owner', async () => {
const { prisma, service } = makeService();
prisma.projectMember.findUnique.mockResolvedValue({
id: 'pm-owner',
projectId: 'project-1',
userId: 'm-owner',
role: 'owner',
});
prisma.projectMember.count.mockResolvedValue(1);
await expect(service.updateRole({
actorId: 'm-owner',
projectId: 'project-1',
userId: 'm-owner',
role: 'admin',
})).rejects.toBeInstanceOf(BadRequestException);
expect(prisma.projectMember.update).not.toHaveBeenCalled();
});
it('rejects removing the last project owner', async () => {
const { prisma, service } = makeService();
prisma.projectMember.findUnique.mockResolvedValue({
id: 'pm-owner',
projectId: 'project-1',
userId: 'm-owner',
role: 'owner',
});
prisma.projectMember.count.mockResolvedValue(1);
await expect(service.remove({
actorId: 'm-owner',
projectId: 'project-1',
userId: 'm-owner',
})).rejects.toBeInstanceOf(BadRequestException);
expect(prisma.projectMember.delete).not.toHaveBeenCalled();
});
it('updates roles and writes an audit diff', async () => {
const { prisma, audit, service } = makeService();
prisma.projectMember.findUnique.mockResolvedValue({
id: 'pm-dev',
projectId: 'project-1',
userId: 'm-dev',
role: 'member',
});
prisma.projectMember.update.mockResolvedValue({
id: 'pm-dev',
projectId: 'project-1',
userId: 'm-dev',
role: 'admin',
});
await service.updateRole({
actorId: 'm-owner',
projectId: 'project-1',
userId: 'm-dev',
role: 'admin',
});
expect(prisma.projectMember.update).toHaveBeenCalledWith({
where: { projectId_userId: { projectId: 'project-1', userId: 'm-dev' } },
data: { role: 'admin' },
});
expect((audit.record as jest.Mock)).toHaveBeenCalledWith(expect.objectContaining({
action: 'project_member.role_changed',
before: expect.objectContaining({ role: 'member' }),
after: expect.objectContaining({ role: 'admin' }),
}));
});
});

View File

@@ -0,0 +1,129 @@
import { BadRequestException, Injectable, NotFoundException } from '@nestjs/common';
import { AuditService } from '../../common/audit/audit.service';
import { ProjectGovernanceRole, RbacService, normalizeProjectRole } from '../../common/rbac/rbac.service';
import { PrismaService } from '../../prisma/prisma.service';
export interface ProjectMemberMutationInput {
actorId: string;
projectId: string;
userId: string;
role?: ProjectGovernanceRole;
}
@Injectable()
export class ProjectMemberService {
constructor(
private readonly prisma: PrismaService,
private readonly rbacService: RbacService,
private readonly auditService: AuditService,
) {}
list(projectId: string) {
return this.prisma.projectMember.findMany({
where: { projectId: requireText(projectId, 'projectId') },
include: { user: { select: { id: true, name: true, email: true } } },
orderBy: [{ role: 'asc' }, { createdAt: 'asc' }],
});
}
async add(input: ProjectMemberMutationInput) {
const projectId = requireText(input.projectId, 'projectId');
const userId = requireText(input.userId, 'userId');
const actorId = requireText(input.actorId, 'actorId');
const role = normalizeRequiredRole(input.role ?? 'member');
await this.rbacService.assertProjectRole({ actorId, projectId, allowedRoles: ['admin'] });
const created = await this.prisma.projectMember.create({
data: { projectId, userId, role },
});
await this.auditService.record({
actorId,
action: 'project_member.created',
resourceType: 'project_member',
resourceId: created.id,
projectId,
after: created,
});
return created;
}
async updateRole(input: Required<ProjectMemberMutationInput>) {
const projectId = requireText(input.projectId, 'projectId');
const userId = requireText(input.userId, 'userId');
const actorId = requireText(input.actorId, 'actorId');
const role = normalizeRequiredRole(input.role);
await this.rbacService.assertProjectRole({ actorId, projectId, allowedRoles: ['admin'] });
const existing = await this.findMembership(projectId, userId);
if (existing.role === 'owner' && role !== 'owner') {
await this.assertOwnerWillRemain(projectId);
}
const updated = await this.prisma.projectMember.update({
where: { projectId_userId: { projectId, userId } },
data: { role },
});
await this.auditService.record({
actorId,
action: 'project_member.role_changed',
resourceType: 'project_member',
resourceId: updated.id,
projectId,
before: existing,
after: updated,
});
return updated;
}
async remove(input: Omit<ProjectMemberMutationInput, 'role'>) {
const projectId = requireText(input.projectId, 'projectId');
const userId = requireText(input.userId, 'userId');
const actorId = requireText(input.actorId, 'actorId');
await this.rbacService.assertProjectRole({ actorId, projectId, allowedRoles: ['admin'] });
const existing = await this.findMembership(projectId, userId);
if (existing.role === 'owner') {
await this.assertOwnerWillRemain(projectId);
}
const removed = await this.prisma.projectMember.delete({
where: { projectId_userId: { projectId, userId } },
});
await this.auditService.record({
actorId,
action: 'project_member.deleted',
resourceType: 'project_member',
resourceId: removed.id,
projectId,
before: existing,
});
return removed;
}
private async findMembership(projectId: string, userId: string) {
const membership = await this.prisma.projectMember.findUnique({
where: { projectId_userId: { projectId, userId } },
});
if (!membership) throw new NotFoundException('Project member not found');
return membership;
}
private async assertOwnerWillRemain(projectId: string) {
const ownerCount = await this.prisma.projectMember.count({ where: { projectId, role: 'owner' } });
if (ownerCount <= 1) {
throw new BadRequestException('Cannot remove or demote the last project owner');
}
}
}
function normalizeRequiredRole(role: string | undefined): ProjectGovernanceRole {
const normalized = normalizeProjectRole(role);
if (!normalized) throw new BadRequestException(`Unsupported project member role: ${role}`);
return normalized;
}
function requireText(value: string | undefined | null, field: string): string {
const normalized = value?.trim();
if (!normalized) throw new BadRequestException(`${field} is required`);
return normalized;
}