内容相关开发:创建课程 & 标签路由 + CRUD 服务
课程模块是内容管理系统的核心。与附件模块不同,课程创建时需要处理课程标签(CourseTag)的关联逻辑,而标签本身又依赖于字典表(DictCourseTag)和分类表(DictCourseType)。本节将讲解如何设计课程的 CRUD 路由,以及如何将标签创建逻辑封装为可复用的服务方法。
课程标签关联模型
Course (课程)
└── CourseTag[] (课程标签关联 - 一对多)
├── DictCourseTag (字典标签 - 多对一)
└── DictCourseType (字典分类 - 多对一)
text
CourseTag 是关联表,连接课程与字典标签。每条记录同时关联一个标签分类(CourseType)。
DTO 设计
CreateCourseTagDto
// course/dto/create-course-tag.dto.ts
import {
IsInt,
IsOptional,
ValidateNested,
ValidateIf,
IsArray,
} from 'class-validator';
import { Type } from 'class-transformer';
import { CreateDictCourseTagDto } from '../../dict/course-tag/dto/create-dict-course-tag.dto';
export class CreateCourseTagDto {
@IsInt()
@IsOptional()
courseId?: number;
@IsInt()
@IsOptional()
tagId?: number;
@IsOptional()
@ValidateNested({ each: true })
@Type(() => CreateDictCourseTagDto)
@ValidateIf((o) => !o.tagId)
tags?: CreateDictCourseTagDto[];
}
typescript
CreateCourseDto
// course/dto/create-course.dto.ts
import { IsString, IsOptional, ValidateNested, IsArray } from 'class-validator';
import { Type } from 'class-transformer';
import { CreateCourseTagDto } from './create-course-tag.dto';
export class CreateCourseDto {
@IsString()
name: string;
@IsString()
@IsOptional()
desc?: string;
@IsArray()
@IsOptional()
@ValidateNested({ each: true })
@Type(() => CreateCourseTagDto)
tags?: CreateCourseTagDto[];
}
typescript
UpdateCourseTagDto
// course/dto/update-course-tag.dto.ts
import { PartialType } from '@nestjs/mapped-types';
import { CreateCourseTagDto } from './create-course-tag.dto';
export class UpdateCourseTagDto extends PartialType(CreateCourseTagDto) {}
typescript
Controller 路由设计
在课程 Controller 中扩展标签相关的路由:
// course/course.controller.ts
import {
Controller,
Get,
Post,
Body,
Patch,
Param,
Delete,
Query,
} from '@nestjs/common';
import { CourseService } from './course.service';
import { CreateCourseDto } from './dto/create-course.dto';
import { UpdateCourseDto } from './dto/update-course.dto';
import { CreateCourseTagDto } from './dto/create-course-tag.dto';
import { UpdateCourseTagDto } from './dto/update-course-tag.dto';
@Controller('course')
export class CourseController {
constructor(private readonly courseService: CourseService) {}
// === 课程基础 CRUD ===
@Post()
create(@Body() dto: CreateCourseDto) {
return this.courseService.create(dto);
}
@Get()
findAll(
@Query('page') page?: number,
@Query('limit') limit?: number,
) {
return this.courseService.findAll(page, limit);
}
@Get(':id')
findOne(@Param('id') id: string) {
return this.courseService.findOne(+id);
}
@Patch(':id')
update(@Param('id') id: string, @Body() dto: UpdateCourseDto) {
return this.courseService.update(+id, dto);
}
@Delete(':id')
remove(@Param('id') id: string) {
return this.courseService.remove(+id);
}
// === 课程标签 CRUD ===
@Post('tags')
createTag(@Body() dto: CreateCourseTagDto) {
return this.courseService.createTag(dto);
}
@Patch('tags')
updateTag(@Body() dto: UpdateCourseTagDto) {
return this.courseService.updateTag(dto);
}
@Get('tags')
findAllTags(@Query('courseId') courseId?: number) {
return this.courseService.findAllTags(courseId);
}
@Delete('tags/:courseId')
removeTag(
@Param('courseId') courseId: string,
@Query('tagId') tagId?: number,
) {
return this.courseService.removeTag(+courseId, tagId);
}
}
typescript
Service 基础 CRUD
// course/course.service.ts
import { Injectable } from '@nestjs/common';
import { PrismaClient } from '@prisma/client';
import { CreateCourseDto } from './dto/create-course.dto';
import { UpdateCourseDto } from './dto/update-course.dto';
@Injectable()
export class CourseService {
constructor(private prisma: PrismaClient) {}
// 基础 CRUD 方法(标签相关方法在后续章节展开)
createTag(dto: any) { /* 见 9-8 节 */ }
updateTag(dto: any) { /* 见 9-9 节 */ }
findAllTags(courseId?: number) { /* 见下文 */ }
removeTag(courseId: number, tagId?: number) { /* 见下文 */ }
async create(dto: CreateCourseDto) {
// TODO: 处理 tags 嵌套创建(见 9-9 节)
const { tags, ...restData } = dto;
return this.prisma.course.create({ data: restData });
}
async findAll(page?: number, limit?: number) {
const skip = page ? (page - 1) * (limit || 10) : 0;
return this.prisma.course.findMany({
skip,
take: limit || 10,
});
}
async findOne(id: number) {
return this.prisma.course.findUnique({ where: { id } });
}
async update(id: number, dto: UpdateCourseDto) {
return this.prisma.course.update({
where: { id },
data: dto,
});
}
async remove(id: number) {
return this.prisma.course.delete({ where: { id } });
}
// === 标签查询 ===
async findAllTags(courseId?: number) {
return this.prisma.courseTag.findMany({
where: courseId ? { courseId } : undefined,
include: {
tag: true, // DictCourseTag
courseType: true, // DictCourseType
},
});
}
// === 标签删除 ===
async removeTag(courseId: number, tagId?: number) {
if (tagId) {
// 删除单条:使用联合主键(courseId_tagId)
return this.prisma.courseTag.delete({
where: {
courseId_tagId: { courseId, tagId },
},
});
}
// 删除该课程的所有标签关联
return this.prisma.courseTag.deleteMany({
where: { courseId },
});
}
}
typescript
联合主键的删除操作
当 CourseTag 没有独立的 id 字段,而是使用 @@id([courseId, tagId]) 作为联合主键时:
model CourseTag {
courseId Int
tagId Int
course Course @relation(fields: [courseId], references: [id])
tag DictCourseTag @relation(fields: [tagId], references: [id])
@@id([courseId, tagId])
}
prisma
Prisma 会自动生成 courseId_tagId 复合主键字段用于 delete / findUnique 操作:
// 正确:使用复合主键
await prisma.courseTag.delete({
where: { courseId_tagId: { courseId: 1, tagId: 3 } },
});
// 错误:不能单独按 courseId 删除
// await prisma.courseTag.delete({ where: { courseId: 1 } });
typescript
而 deleteMany 不受此限制,可以按单个字段条件批量删除。
方法设计策略
为什么把标签方法放在 CourseService 中
| 方案 | 优势 | 劣势 |
|---|---|---|
| 标签方法在 CourseService 中 | 共享 PrismaClient,事务处理方便 | 课程 Service 代码量增大 |
| 独立 CourseTagService | 职责单一,符合 SOLID 原则 | 需要额外注入 Service,事务需跨 Service 协调 |
本课程选择将标签方法放在 CourseService 中,主要原因是:
- 标签的创建逻辑与课程创建强耦合
- 可以直接使用相同的 Prisma Client 实例进行事务操作
- 减少跨 Service 事务协调的复杂度
小结
| 操作 | 路由 | 说明 |
|---|---|---|
| 创建课程 | POST /course | 可携带 tags 数组嵌套创建 |
| 创建标签 | POST /course/tags | 独立为课程添加标签 |
| 更新标签 | PATCH /course/tags | 先删后建策略 |
| 查询标签 | GET /course/tags?courseId= | 按课程 ID 过滤 |
| 删除标签 | DELETE /course/tags/:courseId?tagId= | 删除单条或全部 |
| 联合主键 | courseId_tagId | Prisma 自动为 @@id 生成的复合主键字段 |
↑