跳到主要内容

考试系统:答卷微服务

这节回过头来继续写答卷微服务。

首先创建答卷表:

字段名数据类型描述
idINT答卷ID
answererIdINT答题人ID
examIdINT答卷ID
contentTEXT答卷JSON
scoreINT分数
create_timeDATETIME创建时间
update_timeDATETIME更新时间

改下 schema 文件:

image.png


model User {
id Int @id @default(autoincrement())
username String @db.VarChar(50) @unique
password String @db.VarChar(50)
email String @db.VarChar(50)
createTime DateTime @default(now())
updateTime DateTime @updatedAt

exams Exam[]
answers Answer[]
}

model Exam {
id Int @id @default(autoincrement())
name String @db.VarChar(50)
isPublish Boolean @default(false)
isDelete Boolean @default(false)
content String @db.Text
createTime DateTime @default(now())
updateTime DateTime @updatedAt

createUserId Int
createUser User @relation(fields: [createUserId], references: [id])

answers Answer[]
}


model Answer {
id Int @id @default(autoincrement())
content String @db.Text
score Int
createTime DateTime @default(now())
updateTime DateTime @updatedAt

answererId Int
answerer User @relation(fields: [answererId], references: [id])

examId Int
exam Exam @relation(fields: [examId], references: [id])
}

answer 和 user 还有 exam 都是多对一关系。

所以在 User、Exam 的 modal 里都有个 answer 数组。

创建这个表:

npx prisma migrate dev --name answer

image.png

image.png

生成的 sql 没问题。

数据库里也多了这个表:

image.png

然后来实现接口:

有这些接口:

接口路径请求方式描述
/answer/addPOST添加答卷
/answer/listGET答卷列表
/answer/exportGET导出答卷列表为 excel

先实现添加答卷,在 AnswerController 加一个路由:

image.png

@Post('add')
@RequireLogin()
async add(@Body() addDto: AnswerAddDto, @UserInfo('userId') userId: number){
return this.answerService.add(addDto, userId)
}

创建用到的 dto:

dto/answer-add.dto.ts

import { IsNotEmpty, IsString } from "class-validator";

export class AnswerAddDto {
@IsNotEmpty({ message: "答卷内容不能为空" })
@IsString()
content: string;

@IsNotEmpty()
examId: number;
}

然后在 AnswerService 实现下 add 方法:

@Inject(PrismaService)
private prismaService: PrismaService;

async add(dto: AnswerAddDto, userId: number) {
return this.prismaService.answer.create({
data: {
content: dto.content,
score: 0,
answerer: {
connect: {
id: userId
}
},
exam: {
connect: {
id: dto.examId
}
}
},
})
}

这里用到了 PrismaService,并且还需要登录。

我们引入下 PrismaModule 和 CommonModule 模块,注册全局 Guard:

image.png

import { Module } from "@nestjs/common";
import { AnswerController } from "./answer.controller";
import { AnswerService } from "./answer.service";
import { ClientsModule, Transport } from "@nestjs/microservices";
import { PrismaModule } from "@app/prisma";
import { APP_GUARD } from "@nestjs/core";
import { AuthGuard, CommonModule } from "@app/common";

@Module({
imports: [
PrismaModule,
CommonModule,
ClientsModule.register([
{
name: "EXAM_SERVICE",
transport: Transport.TCP,
options: {
port: 8888,
},
},
]),
],
controllers: [AnswerController],
providers: [
AnswerService,
{
provide: APP_GUARD,
useClass: AuthGuard,
},
],
})
export class AnswerModule {}

然后在 main.ts 里启用下 ValidationPipe

image.png

app.enableCors();
app.useGlobalPipes(new ValidationPipe({ transform: true }));

把服务跑起来:

npm run start:dev answer

image.png

测试下:

image.png

image.png

image.png

添加成功。

然后我们实现下 list 和 find 接口:

添加两个路由:

@Get('list')
@RequireLogin()
async list(@Query('examId') examId: string){
if(!examId) {
throw new BadRequestException('examId 不能为空');
}
return this.answerService.list(+examId)
}

@Get('find/:id')
@RequireLogin()
async find(@Param('id') id: string){
return this.answerService.find(+id)
}

实现下 service:

async list(examId: number) {
return this.prismaService.answer.findMany({
where: {
examId
},
include: {
exam: true,
answerer: true
}
})
}

async find(id: number) {
return this.prismaService.answer.findUnique({
where: {
id
},
include: {
exam: true,
answerer: true
}
})
}

这里关联查出 exam 和 answerer 的信息。

测试下:

image.png

image.png

list 和 find 接口都没问题。

然后再实现导出 excel 的接口:

@Get('export')
async export(@Query('examId') examId: string){
if(!examId) {
throw new BadRequestException('examId 不能为空');
}
return this.answerService.export(+examId)
}

这里用到 excel 的功能,创建个 lib:

nest g lib excel

image.png

安装用到的包:

npm install --save exceljs

改下 ExcelService:

import { Injectable, StreamableFile } from "@nestjs/common";
import { Workbook } from "exceljs";
import { PassThrough } from "stream";

@Injectable()
export class ExcelService {
async export() {
const workbook = new Workbook();

const worksheet = workbook.addWorksheet("guang111");

worksheet.columns = [
{ header: "ID", key: "id", width: 20 },
{ header: "姓名", key: "name", width: 30 },
{ header: "出生日期", key: "birthday", width: 30 },
{ header: "手机号", key: "phone", width: 50 },
];

const data = [
{
id: 1,
name: "光光",
birthday: new Date("1994-07-07"),
phone: "13255555555",
},
{
id: 2,
name: "东东",
birthday: new Date("1994-04-14"),
phone: "13222222222",
},
{
id: 3,
name: "小刚",
birthday: new Date("1995-08-08"),
phone: "13211111111",
},
];
worksheet.addRows(data);

const stream = new PassThrough();

await workbook.xlsx.write(stream);

return new StreamableFile(stream, {
type: "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet",
disposition: "attachment; filename=exported-data.xlsx",
});
}
}

这里 stream 是 node 内置模块,new PassThrough 创建一个流。

然后返回 StreamalbeFile,这个处理了 transfer-encoding:chunked,也就是支持流式传输。

在 answer 微服务引入这个模块:

image.png

@Inject(ExcelService)
excelService: ExcelService;

@Get('export')
async export(@Query('examId') examId: string){
if(!examId) {
throw new BadRequestException('examId 不能为空');
}

return this.excelService.export()
}

试一下:

2024-08-27 09.50.27.gif

导出成功。

然后我们传入具体的参数:

import { Injectable, StreamableFile } from "@nestjs/common";
import { Column, Workbook } from "exceljs";
import { PassThrough } from "stream";

@Injectable()
export class ExcelService {
async export(
columns: Partial<Column>[],
data: Array<Record<string, any>>,
filename: string
) {
const workbook = new Workbook();

const worksheet = workbook.addWorksheet("guang111");

worksheet.columns = columns;

worksheet.addRows(data);

const stream = new PassThrough();

await workbook.xlsx.write(stream);

return new StreamableFile(stream, {
type: "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet",
disposition: "attachment; filename=" + filename,
});
}
}

传入 columns、data 和 filename,来动态生成 excel。

调用的地方传下参数:

@Get('export')
async export(@Query('examId') examId: string){
if(!examId) {
throw new BadRequestException('examId 不能为空');
}

const data = await this.answerService.list(+examId);

const columns = [
{ header: 'ID', key: 'id', width: 20 },
{ header: '分数', key: 'score', width: 30 },
{ header: '答题人', key: 'answerer', width: 30 },
{ header: '试卷', key: 'exam', width: 30 },
{ header: '创建时间', key: 'createTime', width: 30},
]

const res = data.map(item => {
return {
id: item.id,
score: item.score,
answerer: item.answerer.username,
exam: item.exam.name,
createTime: item.createTime
}
})
return this.excelService.export(columns, res, 'answers.xlsx')
}

试一下:

导出成功。

案例代码在小册仓库:

后端代码

总结

这节我们实现了答卷微服务,包括创建答卷、答卷列表、答卷详情接口,导出答卷列表 excel。

并且封装了 excel 的 lib。

这样,答卷微服务就完成了。