feat: 优化项目目录结构
This commit is contained in:
18
apps/backend/src/blog/blog.controller.spec.ts
Normal file
18
apps/backend/src/blog/blog.controller.spec.ts
Normal file
@@ -0,0 +1,18 @@
|
||||
import { Test, TestingModule } from '@nestjs/testing';
|
||||
import { BlogController } from './blog.controller';
|
||||
|
||||
describe('BlogController', () => {
|
||||
let controller: BlogController;
|
||||
|
||||
beforeEach(async () => {
|
||||
const module: TestingModule = await Test.createTestingModule({
|
||||
controllers: [BlogController],
|
||||
}).compile();
|
||||
|
||||
controller = module.get<BlogController>(BlogController);
|
||||
});
|
||||
|
||||
it('should be defined', () => {
|
||||
expect(controller).toBeDefined();
|
||||
});
|
||||
});
|
||||
137
apps/backend/src/blog/blog.controller.ts
Normal file
137
apps/backend/src/blog/blog.controller.ts
Normal file
@@ -0,0 +1,137 @@
|
||||
import {
|
||||
BadRequestException,
|
||||
Body,
|
||||
Controller,
|
||||
Get,
|
||||
Param,
|
||||
ParseUUIDPipe,
|
||||
Post,
|
||||
Query,
|
||||
Req,
|
||||
UseGuards,
|
||||
} from '@nestjs/common';
|
||||
import { BlogService } from './blog.service';
|
||||
import { OptionalAuthGuard } from 'src/auth/strategies/OptionalAuthGuard';
|
||||
import { UserService } from 'src/user/user.service';
|
||||
import { createBlogCommentDto } from './dto/create.blogcomment.dto';
|
||||
import { Throttle, ThrottlerGuard } from '@nestjs/throttler';
|
||||
import { BlogPermission } from './blog.permission.enum';
|
||||
|
||||
@Controller('blog')
|
||||
export class BlogController {
|
||||
constructor(
|
||||
private readonly blogService: BlogService,
|
||||
private readonly userService: UserService,
|
||||
) {}
|
||||
|
||||
@Get()
|
||||
getBlogs() {
|
||||
return this.blogService.list();
|
||||
}
|
||||
|
||||
@Get(':id')
|
||||
async getBlog(
|
||||
@Param('id', new ParseUUIDPipe({ version: '4' })) id: string,
|
||||
@Query('p') password?: string,
|
||||
) {
|
||||
const blog = await this.blogService.findById(id);
|
||||
if (!blog) throw new BadRequestException('文章不存在或无权限访问');
|
||||
|
||||
if (!blog.permissions.includes(BlogPermission.Public)) {
|
||||
// 无公开权限,则进一步检查是否有密码保护
|
||||
if (!blog.permissions.includes(BlogPermission.ByPassword)) {
|
||||
throw new BadRequestException('文章不存在或无权限访问');
|
||||
} else {
|
||||
// 判断密码是否正确
|
||||
if (
|
||||
!password ||
|
||||
this.blogService.hashPassword(password) !== blog.password_hash
|
||||
) {
|
||||
throw new BadRequestException('文章不存在或无权限访问');
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const blogDataRes = await fetch(`${blog.contentUrl}`);
|
||||
const blogContent = await blogDataRes.text();
|
||||
|
||||
await this.blogService.incrementViewCount(id);
|
||||
return {
|
||||
id: blog.id,
|
||||
title: blog.title,
|
||||
createdAt: blog.createdAt,
|
||||
content: blogContent,
|
||||
};
|
||||
}
|
||||
|
||||
@Get(':id/comments')
|
||||
async getBlogComments(
|
||||
@Param('id', new ParseUUIDPipe({ version: '4' })) id: string,
|
||||
) {
|
||||
const blog = await this.blogService.findById(id);
|
||||
if (!blog) throw new BadRequestException('文章不存在');
|
||||
|
||||
/** @todo 对文章可读性进行更详细的判定 */
|
||||
|
||||
if (
|
||||
!blog.permissions.includes(BlogPermission.Public) &&
|
||||
!blog.permissions.includes(BlogPermission.ByPassword)
|
||||
) {
|
||||
throw new BadRequestException('文章不存在或未公开');
|
||||
}
|
||||
|
||||
return await this.blogService.getComments(blog);
|
||||
}
|
||||
|
||||
// 该接口允许匿名评论,但仍需验证userId合法性
|
||||
@UseGuards(ThrottlerGuard, OptionalAuthGuard)
|
||||
@Throttle({ default: { limit: 5, ttl: 60000 } })
|
||||
@Post(':id/comment')
|
||||
async createBlogComment(
|
||||
@Param('id', new ParseUUIDPipe({ version: '4' })) id: string,
|
||||
@Body() commentData: createBlogCommentDto,
|
||||
@Req() req,
|
||||
) {
|
||||
const { userId } = req.user || {};
|
||||
const blog = await this.blogService.findById(id);
|
||||
if (!blog) throw new BadRequestException('文章不存在');
|
||||
|
||||
if (!blog.permissions.includes(BlogPermission.AllowComments)) {
|
||||
throw new BadRequestException('作者关闭了该文章的评论功能');
|
||||
}
|
||||
|
||||
const user = userId ? await this.userService.findById(userId) : null;
|
||||
|
||||
const ip = req.headers['x-forwarded-for'] || req.ip;
|
||||
// 获取IP归属地
|
||||
let address = '未知';
|
||||
if (!['::1'].includes(ip)) {
|
||||
const addressRes = await (
|
||||
await fetch(
|
||||
`https://mesh.if.iqiyi.com/aid/ip/info?version=1.1.1&ip=${ip}`,
|
||||
)
|
||||
).json();
|
||||
if (addressRes?.code == 0) {
|
||||
const country: string = addressRes?.data?.countryCN || '未知';
|
||||
const province: string = addressRes?.data?.provinceCN || '中国';
|
||||
if (country !== '中国') {
|
||||
// 非中国,显示国家
|
||||
address = country;
|
||||
} else {
|
||||
// 中国,显示省份
|
||||
address = province;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const comment = {
|
||||
...commentData,
|
||||
blog,
|
||||
user,
|
||||
ip,
|
||||
address,
|
||||
};
|
||||
|
||||
return await this.blogService.createComment(comment);
|
||||
}
|
||||
}
|
||||
20
apps/backend/src/blog/blog.module.ts
Normal file
20
apps/backend/src/blog/blog.module.ts
Normal file
@@ -0,0 +1,20 @@
|
||||
import { Module } from '@nestjs/common';
|
||||
import { BlogController } from './blog.controller';
|
||||
import { BlogService } from './blog.service';
|
||||
import { TypeOrmModule } from '@nestjs/typeorm';
|
||||
import { Blog } from './entity/Blog.entity';
|
||||
import { BlogComment } from './entity/BlogComment.entity';
|
||||
import { AuthModule } from 'src/auth/auth.module';
|
||||
import { UserModule } from 'src/user/user.module';
|
||||
|
||||
@Module({
|
||||
imports: [
|
||||
TypeOrmModule.forFeature([Blog, BlogComment]),
|
||||
AuthModule,
|
||||
UserModule,
|
||||
],
|
||||
controllers: [BlogController],
|
||||
providers: [BlogService],
|
||||
exports: [BlogService],
|
||||
})
|
||||
export class BlogModule {}
|
||||
6
apps/backend/src/blog/blog.permission.enum.ts
Normal file
6
apps/backend/src/blog/blog.permission.enum.ts
Normal file
@@ -0,0 +1,6 @@
|
||||
export enum BlogPermission {
|
||||
Public = 'Public',
|
||||
ByPassword = 'ByPassword',
|
||||
List = 'List',
|
||||
AllowComments = 'AllowComments',
|
||||
}
|
||||
18
apps/backend/src/blog/blog.service.spec.ts
Normal file
18
apps/backend/src/blog/blog.service.spec.ts
Normal file
@@ -0,0 +1,18 @@
|
||||
import { Test, TestingModule } from '@nestjs/testing';
|
||||
import { BlogService } from './blog.service';
|
||||
|
||||
describe('BlogService', () => {
|
||||
let service: BlogService;
|
||||
|
||||
beforeEach(async () => {
|
||||
const module: TestingModule = await Test.createTestingModule({
|
||||
providers: [BlogService],
|
||||
}).compile();
|
||||
|
||||
service = module.get<BlogService>(BlogService);
|
||||
});
|
||||
|
||||
it('should be defined', () => {
|
||||
expect(service).toBeDefined();
|
||||
});
|
||||
});
|
||||
143
apps/backend/src/blog/blog.service.ts
Normal file
143
apps/backend/src/blog/blog.service.ts
Normal file
@@ -0,0 +1,143 @@
|
||||
import { Injectable } from '@nestjs/common';
|
||||
import { InjectRepository } from '@nestjs/typeorm';
|
||||
import { Blog } from './entity/Blog.entity';
|
||||
import { Repository } from 'typeorm';
|
||||
import { BlogComment } from './entity/BlogComment.entity';
|
||||
import { BlogPermission } from './blog.permission.enum';
|
||||
import { createHash } from 'crypto';
|
||||
|
||||
@Injectable()
|
||||
export class BlogService {
|
||||
constructor(
|
||||
@InjectRepository(Blog)
|
||||
private readonly blogRepository: Repository<Blog>,
|
||||
@InjectRepository(BlogComment)
|
||||
private readonly blogCommentRepository: Repository<BlogComment>,
|
||||
) {}
|
||||
|
||||
async list(
|
||||
option: {
|
||||
withAll?: boolean;
|
||||
} = {},
|
||||
) {
|
||||
return (
|
||||
await this.blogRepository.find({
|
||||
order: {
|
||||
createdAt: 'DESC',
|
||||
},
|
||||
})
|
||||
)
|
||||
.filter(
|
||||
(i) => option.withAll || i.permissions.includes(BlogPermission.List),
|
||||
)
|
||||
.map((i) => {
|
||||
if (option.withAll) {
|
||||
return i;
|
||||
}
|
||||
|
||||
const { createdAt, deletedAt, id, title, viewCount } = i;
|
||||
return {
|
||||
createdAt,
|
||||
deletedAt,
|
||||
id,
|
||||
title,
|
||||
viewCount,
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
async create(dto: Partial<Blog> & { password: string }) {
|
||||
const { password, ...blog } = dto;
|
||||
if (blog.permissions.includes(BlogPermission.ByPassword)) {
|
||||
if (password) {
|
||||
blog.password_hash = createHash('sha256')
|
||||
.update(`${password}`)
|
||||
.digest('hex');
|
||||
}
|
||||
}
|
||||
|
||||
const newBlog = this.blogRepository.create(blog);
|
||||
return this.blogRepository.save(newBlog);
|
||||
}
|
||||
|
||||
async setPassword(id: string, password: string) {
|
||||
const blog = await this.findById(id);
|
||||
if (!blog) {
|
||||
throw new Error('博客不存在');
|
||||
}
|
||||
|
||||
return (
|
||||
(
|
||||
await this.blogRepository.update(id, {
|
||||
...blog,
|
||||
password_hash: this.hashPassword(password),
|
||||
})
|
||||
).affected > 0
|
||||
);
|
||||
}
|
||||
|
||||
async update(id: string, blog: Partial<Blog>) {
|
||||
await this.blogRepository.update(id, blog);
|
||||
return this.blogRepository.findOneBy({ id });
|
||||
}
|
||||
|
||||
async remove(id: string) {
|
||||
const blog = await this.blogRepository.findOneBy({ id });
|
||||
if (!blog) return null;
|
||||
return this.blogRepository.softRemove(blog);
|
||||
}
|
||||
|
||||
async findById(id: string) {
|
||||
return await this.blogRepository.findOneBy({ id });
|
||||
}
|
||||
|
||||
async incrementViewCount(id: string) {
|
||||
await this.blogRepository.increment({ id }, 'viewCount', 1);
|
||||
}
|
||||
|
||||
async getComments(blog: Blog) {
|
||||
const comments = await this.blogCommentRepository.find({
|
||||
where: { blog: { id: blog.id } },
|
||||
relations: ['user'],
|
||||
order: {
|
||||
createdAt: 'DESC',
|
||||
},
|
||||
});
|
||||
|
||||
return comments.map((comment) => {
|
||||
const { user, ...rest } = comment;
|
||||
delete rest.blog;
|
||||
return {
|
||||
...rest,
|
||||
user: user
|
||||
? {
|
||||
userId: user.userId,
|
||||
username: user.username,
|
||||
nickname: user.nickname,
|
||||
}
|
||||
: null,
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
async createComment(comment: Partial<BlogComment>) {
|
||||
const newComment = this.blogCommentRepository.create(comment);
|
||||
const savedComment = await this.blogCommentRepository.save(newComment, {});
|
||||
const { user, ...commentWithoutBlog } = savedComment;
|
||||
delete commentWithoutBlog.blog;
|
||||
return {
|
||||
...commentWithoutBlog,
|
||||
user: user
|
||||
? {
|
||||
userId: user.userId,
|
||||
username: user.username,
|
||||
nickname: user.nickname,
|
||||
}
|
||||
: null,
|
||||
};
|
||||
}
|
||||
|
||||
hashPassword(password: string) {
|
||||
return createHash('sha256').update(`${password}`).digest('hex');
|
||||
}
|
||||
}
|
||||
10
apps/backend/src/blog/dto/create.blogcomment.dto.ts
Normal file
10
apps/backend/src/blog/dto/create.blogcomment.dto.ts
Normal file
@@ -0,0 +1,10 @@
|
||||
import { IsOptional, IsString, IsUUID } from 'class-validator';
|
||||
|
||||
export class createBlogCommentDto {
|
||||
@IsString({ message: '评论内容不能为空' })
|
||||
content: string;
|
||||
|
||||
@IsOptional()
|
||||
@IsUUID('4', { message: '父评论ID格式错误' })
|
||||
parentId?: string;
|
||||
}
|
||||
50
apps/backend/src/blog/entity/Blog.entity.ts
Normal file
50
apps/backend/src/blog/entity/Blog.entity.ts
Normal file
@@ -0,0 +1,50 @@
|
||||
import {
|
||||
Column,
|
||||
CreateDateColumn,
|
||||
DeleteDateColumn,
|
||||
Entity,
|
||||
OneToMany,
|
||||
PrimaryGeneratedColumn,
|
||||
UpdateDateColumn,
|
||||
} from 'typeorm';
|
||||
import { BlogComment } from './BlogComment.entity';
|
||||
import { BlogPermission } from '../blog.permission.enum';
|
||||
|
||||
/** @todo 考虑后续将权限的数据类型替换为json,以提高查询效率 */
|
||||
@Entity()
|
||||
export class Blog {
|
||||
@PrimaryGeneratedColumn('uuid')
|
||||
id: string;
|
||||
|
||||
@Column()
|
||||
title: string;
|
||||
|
||||
@Column()
|
||||
description: string;
|
||||
|
||||
@Column()
|
||||
contentUrl: string;
|
||||
|
||||
@Column({ default: 0 })
|
||||
viewCount: number;
|
||||
|
||||
@CreateDateColumn({ precision: 3 })
|
||||
createdAt: Date;
|
||||
|
||||
@UpdateDateColumn({ precision: 3 })
|
||||
updatedAt: Date;
|
||||
|
||||
@DeleteDateColumn({ precision: 3, nullable: true })
|
||||
deletedAt: Date;
|
||||
|
||||
// 权限
|
||||
@Column('simple-array', { default: '' })
|
||||
permissions: BlogPermission[];
|
||||
|
||||
@Column({ nullable: true })
|
||||
password_hash: string | null;
|
||||
|
||||
// 关系
|
||||
@OneToMany(() => BlogComment, (comment) => comment.blog)
|
||||
comments: BlogComment[];
|
||||
}
|
||||
43
apps/backend/src/blog/entity/BlogComment.entity.ts
Normal file
43
apps/backend/src/blog/entity/BlogComment.entity.ts
Normal file
@@ -0,0 +1,43 @@
|
||||
import { User } from 'src/user/entities/user.entity';
|
||||
import {
|
||||
Column,
|
||||
CreateDateColumn,
|
||||
DeleteDateColumn,
|
||||
Entity,
|
||||
JoinColumn,
|
||||
ManyToOne,
|
||||
PrimaryGeneratedColumn,
|
||||
} from 'typeorm';
|
||||
import { Blog } from './Blog.entity';
|
||||
|
||||
@Entity()
|
||||
export class BlogComment {
|
||||
@PrimaryGeneratedColumn('uuid')
|
||||
id: string;
|
||||
|
||||
@Column()
|
||||
content: string;
|
||||
|
||||
@Column()
|
||||
ip: string;
|
||||
|
||||
@Column()
|
||||
address: string;
|
||||
|
||||
@CreateDateColumn({ precision: 3 })
|
||||
createdAt: Date;
|
||||
|
||||
@DeleteDateColumn({ precision: 3, nullable: true })
|
||||
deletedAt: Date;
|
||||
|
||||
@ManyToOne(() => User, { nullable: true })
|
||||
@JoinColumn({ name: 'userId' })
|
||||
user: User | null;
|
||||
|
||||
@ManyToOne(() => Blog)
|
||||
@JoinColumn({ name: 'blogId' })
|
||||
blog: Blog | null;
|
||||
|
||||
@Column({ type: 'uuid', nullable: true })
|
||||
parentId: string | null;
|
||||
}
|
||||
Reference in New Issue
Block a user