feat(v2.4): 切换根数据领域主写

This commit is contained in:
2026-07-08 12:07:35 +08:00
parent d82e8729af
commit 142800f75b
21 changed files with 977 additions and 73 deletions

View File

@@ -0,0 +1,11 @@
import { IsNotEmpty, IsOptional, IsString } from 'class-validator';
export class CreateProjectDto {
@IsString()
@IsNotEmpty()
name!: string;
@IsString()
@IsOptional()
description?: string;
}

View File

@@ -0,0 +1,4 @@
import { PartialType } from '@nestjs/mapped-types';
import { CreateProjectDto } from './create-project.dto';
export class UpdateProjectDto extends PartialType(CreateProjectDto) {}

View File

@@ -0,0 +1,33 @@
import { Body, Controller, Delete, Get, Param, Patch, Post } from '@nestjs/common';
import { ProjectService } from './project.service';
import { CreateProjectDto } from './dto/create-project.dto';
import { UpdateProjectDto } from './dto/update-project.dto';
@Controller('products/:productId/projects')
export class ProjectController {
constructor(private readonly projectService: ProjectService) {}
@Post()
create(@Param('productId') productId: string, @Body() dto: CreateProjectDto) {
return this.projectService.create(productId, dto);
}
@Get()
findAll(@Param('productId') productId: string) {
return this.projectService.findAll(productId);
}
@Patch(':projectId')
update(
@Param('productId') productId: string,
@Param('projectId') projectId: string,
@Body() dto: UpdateProjectDto,
) {
return this.projectService.update(productId, projectId, dto);
}
@Delete(':projectId')
remove(@Param('productId') productId: string, @Param('projectId') projectId: string) {
return this.projectService.remove(productId, projectId);
}
}

View File

@@ -0,0 +1,9 @@
import { Module } from '@nestjs/common';
import { ProjectController } from './project.controller';
import { ProjectService } from './project.service';
@Module({
controllers: [ProjectController],
providers: [ProjectService],
})
export class ProjectModule {}

View File

@@ -0,0 +1,83 @@
import { NotFoundException } from '@nestjs/common';
import { ProjectService } from './project.service';
describe('ProjectService domain writes', () => {
const makeService = () => {
const prisma = {
product: {
findUnique: jest.fn(),
},
project: {
create: jest.fn(),
delete: jest.fn(),
findFirst: jest.fn(),
findMany: jest.fn(),
update: jest.fn(),
},
};
return {
prisma,
service: new ProjectService(prisma as any),
};
};
it('creates projects directly under a product relation row', async () => {
const { prisma, service } = makeService();
prisma.product.findUnique.mockResolvedValue({ id: 'product-1' });
prisma.project.create.mockResolvedValue({ id: 'project-1', productId: 'product-1' });
await service.create('product-1', { name: 'CRM', description: 'Customer system' });
expect(prisma.project.create).toHaveBeenCalledWith({
data: {
productId: 'product-1',
name: 'CRM',
description: 'Customer system',
},
});
});
it('lists projects by product id', async () => {
const { prisma, service } = makeService();
prisma.project.findMany.mockResolvedValue([]);
await service.findAll('product-1');
expect(prisma.project.findMany).toHaveBeenCalledWith({
where: { productId: 'product-1' },
orderBy: { createdAt: 'desc' },
});
});
it('updates projects only inside their product scope', async () => {
const { prisma, service } = makeService();
prisma.project.findFirst.mockResolvedValue({ id: 'project-1', productId: 'product-1' });
prisma.project.update.mockResolvedValue({ id: 'project-1', name: 'CRM v2' });
await service.update('product-1', 'project-1', { name: 'CRM v2' });
expect(prisma.project.update).toHaveBeenCalledWith({
where: { id: 'project-1' },
data: { name: 'CRM v2' },
});
});
it('throws when updating a project outside the product scope', async () => {
const { prisma, service } = makeService();
prisma.project.findFirst.mockResolvedValue(null);
await expect(service.update('product-1', 'project-404', { name: 'Ghost' })).rejects.toBeInstanceOf(NotFoundException);
expect(prisma.project.update).not.toHaveBeenCalled();
});
it('deletes projects only inside their product scope', async () => {
const { prisma, service } = makeService();
prisma.project.findFirst.mockResolvedValue({ id: 'project-1', productId: 'product-1' });
prisma.project.delete.mockResolvedValue({ id: 'project-1' });
await service.remove('product-1', 'project-1');
expect(prisma.project.delete).toHaveBeenCalledWith({ where: { id: 'project-1' } });
});
});

View File

@@ -0,0 +1,53 @@
import { Injectable, NotFoundException } from '@nestjs/common';
import { PrismaService } from '../../prisma/prisma.service';
import { CreateProjectDto } from './dto/create-project.dto';
import { UpdateProjectDto } from './dto/update-project.dto';
@Injectable()
export class ProjectService {
constructor(private readonly prisma: PrismaService) {}
async create(productId: string, dto: CreateProjectDto) {
await this.ensureProductExists(productId);
return this.prisma.project.create({
data: {
productId,
name: dto.name,
description: dto.description ?? '',
},
});
}
findAll(productId: string) {
return this.prisma.project.findMany({
where: { productId },
orderBy: { createdAt: 'desc' },
});
}
async update(productId: string, projectId: string, dto: UpdateProjectDto) {
await this.ensureProjectInProduct(productId, projectId);
return this.prisma.project.update({
where: { id: projectId },
data: {
...(dto.name !== undefined && { name: dto.name }),
...(dto.description !== undefined && { description: dto.description }),
},
});
}
async remove(productId: string, projectId: string) {
await this.ensureProjectInProduct(productId, projectId);
return this.prisma.project.delete({ where: { id: projectId } });
}
private async ensureProductExists(productId: string) {
const product = await this.prisma.product.findUnique({ where: { id: productId } });
if (!product) throw new NotFoundException('产品不存在');
}
private async ensureProjectInProduct(productId: string, projectId: string) {
const project = await this.prisma.project.findFirst({ where: { id: projectId, productId } });
if (!project) throw new NotFoundException('项目不存在');
}
}