feat(leaning):完成NestJS整合TypeORM实现CRUD

- 增加CORE模块中的工具函数
- 增加数据库模块和数据库配置SQLite
- 增加数据分页处理函数
- 文章模块更新提供者、实体、控制器、存储库替换为TypeORM操作
- 数据库模块增加自定义存储库动态模块
This commit is contained in:
2023-11-20 16:35:17 +08:00
parent 9ac17e43e3
commit db1fcf05bc
37 changed files with 1230 additions and 471 deletions
+1
View File
@@ -0,0 +1 @@
{"_type":"export","__export_format":4,"__export_date":"2023-09-23T09:15:41.007Z","__export_source":"insomnia.desktop.app:v2023.5.8","resources":[{"_id":"req_54139b7550f643b6845c1e051a602807","parentId":"wrk_cf1f617499f54fd2b42f9875c942610d","modified":1692768329669,"created":1673005787757,"url":"{{base_url}}/posts","name":"文章分页列表","description":"","method":"GET","body":{},"parameters":[{"id":"pair_a9c03e97e1cb404fa4502f1db7b84188","name":"page","value":"1","description":""},{"id":"pair_13e90b118d0044239ea632ef7b89c541","name":"limit","value":"3","description":"","disabled":false},{"id":"pair_909eeb5e367e4c84bfc9c1ca521a229a","name":"orderBy","value":"custom","description":"","disabled":false},{"id":"pair_16d24f39e4e8465f9d4e7047d8260455","name":"isPublished","value":"false","description":"","disabled":true}],"headers":[],"authentication":{},"metaSortKey":-1673006024968,"isPrivate":false,"settingStoreCookies":true,"settingSendCookies":true,"settingDisableRenderRequestBody":false,"settingEncodeUrl":true,"settingRebuildPath":true,"settingFollowRedirects":"global","_type":"request"},{"_id":"wrk_cf1f617499f54fd2b42f9875c942610d","parentId":null,"modified":1695460454680,"created":1692767531537,"name":"nestapp-4","description":"","scope":"collection","_type":"workspace"},{"_id":"req_b2ca3725cf924206ae265fe04aed466e","parentId":"wrk_cf1f617499f54fd2b42f9875c942610d","modified":1695460410755,"created":1673122921310,"url":"{{base_url}}/posts/2065c805-1a6b-4e81-9dd4-80f457334b26","name":"文章详情","description":"","method":"GET","body":{},"parameters":[],"headers":[],"authentication":{},"metaSortKey":-1673006024955.5,"isPrivate":false,"settingStoreCookies":true,"settingSendCookies":true,"settingDisableRenderRequestBody":false,"settingEncodeUrl":true,"settingRebuildPath":true,"settingFollowRedirects":"global","_type":"request"},{"_id":"req_54208d3f295d4bf7a6ebb6837197d409","parentId":"wrk_cf1f617499f54fd2b42f9875c942610d","modified":1695460385905,"created":1673005854149,"url":"{{base_url}}/posts","name":"新增文章","description":"","method":"POST","body":{"mimeType":"application/json","text":"{\n\t\"title\": \"测试文章7\",\n\t\"body\": \"这是第7篇文章\"\n}"},"parameters":[],"headers":[{"name":"Content-Type","value":"application/json"}],"authentication":{},"metaSortKey":-1673006024943,"isPrivate":false,"settingStoreCookies":true,"settingSendCookies":true,"settingDisableRenderRequestBody":false,"settingEncodeUrl":true,"settingRebuildPath":true,"settingFollowRedirects":"global","_type":"request"},{"_id":"req_c38a22de0a384cd8a98891de582c30f8","parentId":"wrk_cf1f617499f54fd2b42f9875c942610d","modified":1695460418492,"created":1673006024918,"url":"{{base_url}}/posts","name":"更新文章","description":"","method":"PATCH","body":{"mimeType":"application/json","text":"{\n\t\"id\": \"2065c805-1a6b-4e81-9dd4-80f457334b26\",\n\t\"customOrder\": 1,\n\t\"publishedAt\": \"{% customTimestamp 'specific', '', '', '', '', '', '', '', 'iso-8601', '', '' %}\"\n}"},"parameters":[],"headers":[{"id":"pair_d70cc4fe9f534354a3617ff43378bfdd","name":"Content-Type","value":"application/json","description":""}],"authentication":{},"metaSortKey":-1673006024918,"isPrivate":false,"settingStoreCookies":true,"settingSendCookies":true,"settingDisableRenderRequestBody":false,"settingEncodeUrl":true,"settingRebuildPath":true,"settingFollowRedirects":"global","_type":"request"},{"_id":"req_39bc515120874ad6a5e0c9eb5a88479c","parentId":"wrk_cf1f617499f54fd2b42f9875c942610d","modified":1695460431300,"created":1673006224369,"url":"{{base_url}}/posts/2065c805-1a6b-4e81-9dd4-80f457334b26","name":"删除文章","description":"","method":"DELETE","body":{},"parameters":[],"headers":[],"authentication":{},"metaSortKey":-1673006024868,"isPrivate":false,"settingStoreCookies":true,"settingSendCookies":true,"settingDisableRenderRequestBody":false,"settingEncodeUrl":true,"settingRebuildPath":true,"settingFollowRedirects":"global","_type":"request"},{"_id":"env_b7e12d26efb6436f9f989e6a7eeb8bec","parentId":"wrk_cf1f617499f54fd2b42f9875c942610d","modified":1673005797444,"created":1673005758611,"name":"Base Environment","data":{},"dataPropertyOrder":{},"color":null,"isPrivate":false,"metaSortKey":1673005758611,"_type":"environment"},{"_id":"jar_8716af200d694ecf9bdf74803381db0c","parentId":"wrk_cf1f617499f54fd2b42f9875c942610d","modified":1673005758611,"created":1673005758611,"name":"Default Jar","cookies":[],"_type":"cookie_jar"},{"_id":"env_5e79c99a87e54a9f915296ecb8c05856","parentId":"env_b7e12d26efb6436f9f989e6a7eeb8bec","modified":1692767608192,"created":1673005798804,"name":"dev","data":{"host":"127.0.0.1:3100","base_url":"{{host}}/api"},"dataPropertyOrder":{"&":["host","base_url"]},"color":null,"isPrivate":false,"metaSortKey":1673005798804,"_type":"environment"}]}
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
BIN
View File
Binary file not shown.
BIN
View File
Binary file not shown.
BIN
View File
Binary file not shown.
+85 -79
View File
@@ -1,81 +1,87 @@
{
"name": "ink-nestjs-api",
"version": "0.0.1",
"description": "",
"author": "",
"private": true,
"license": "UNLICENSED",
"scripts": {
"build": "nest build",
"format": "prettier --write \"src/**/*.ts\" \"test/**/*.ts\"",
"start": "nest start",
"start:dev": "nest start --watch",
"start:debug": "nest start --debug --watch",
"start:prod": "node dist/main",
"lint": "eslint \"{src,apps,libs,test}/**/*.ts\" --fix",
"test": "jest",
"test:watch": "jest --watch",
"test:cov": "jest --coverage",
"test:debug": "node --inspect-brk -r tsconfig-paths/register -r ts-node/register node_modules/.bin/jest --runInBand",
"test:e2e": "jest --config ./test/jest-e2e.json"
},
"dependencies": {
"@nestjs/common": "^10.2.9",
"@nestjs/core": "^10.2.9",
"@nestjs/platform-fastify": "^10.2.9",
"@nestjs/swagger": "^7.1.16",
"class-transformer": "^0.5.1",
"class-validator": "^0.14.0",
"fastify": "^4.24.3",
"lodash": "^4.17.21",
"reflect-metadata": "^0.1.13",
"rxjs": "^7.8.1"
},
"devDependencies": {
"@nestjs/cli": "^10.2.1",
"@nestjs/schematics": "^10.0.3",
"@nestjs/testing": "^10.2.9",
"@swc/cli": "^0.1.63",
"@swc/core": "^1.3.96",
"@types/jest": "^29.5.8",
"@types/lodash": "^4.14.201",
"@types/node": "^20.9.1",
"@types/supertest": "^2.0.16",
"@typescript-eslint/eslint-plugin": "^6.11.0",
"@typescript-eslint/parser": "^6.11.0",
"eslint": "^8.53.0",
"eslint-config-airbnb-base": "^15.0.0",
"eslint-config-airbnb-typescript": "^17.1.0",
"eslint-config-prettier": "^9.0.0",
"eslint-plugin-import": "^2.29.0",
"eslint-plugin-jest": "^27.6.0",
"eslint-plugin-prettier": "^5.0.1",
"eslint-plugin-unused-imports": "^3.0.0",
"jest": "^29.7.0",
"prettier": "^3.1.0",
"source-map-support": "^0.5.21",
"supertest": "^6.3.3",
"ts-jest": "^29.1.1",
"ts-loader": "^9.5.1",
"ts-node": "^10.9.1",
"tsconfig-paths": "^4.2.0",
"typescript": "^5.2.2"
},
"jest": {
"moduleFileExtensions": [
"js",
"json",
"ts"
],
"rootDir": "src",
"testRegex": ".*\\.spec\\.ts$",
"transform": {
"^.+\\.(t|j)s$": "ts-jest"
"name": "ink-nestjs-api",
"version": "0.0.1",
"description": "",
"author": "",
"private": true,
"license": "UNLICENSED",
"scripts": {
"build": "nest build",
"format": "prettier --write \"src/**/*.ts\" \"test/**/*.ts\"",
"start": "nest start",
"start:dev": "nest start --watch",
"start:debug": "nest start --debug --watch",
"start:prod": "node dist/main",
"lint": "eslint \"{src,apps,libs,test}/**/*.ts\" --fix",
"test": "jest",
"test:watch": "jest --watch",
"test:cov": "jest --coverage",
"test:debug": "node --inspect-brk -r tsconfig-paths/register -r ts-node/register node_modules/.bin/jest --runInBand",
"test:e2e": "jest --config ./test/jest-e2e.json"
},
"collectCoverageFrom": [
"**/*.(t|j)s"
],
"coverageDirectory": "../coverage",
"testEnvironment": "node"
}
}
"dependencies": {
"@nestjs/common": "^10.2.9",
"@nestjs/core": "^10.2.9",
"@nestjs/platform-fastify": "^10.2.9",
"@nestjs/swagger": "^7.1.16",
"@nestjs/typeorm": "^10.0.1",
"better-sqlite3": "^9.0.0",
"class-transformer": "^0.5.1",
"class-validator": "^0.14.0",
"deepmerge": "^4.3.1",
"fastify": "^4.24.3",
"lodash": "^4.17.21",
"reflect-metadata": "^0.1.13",
"rxjs": "^7.8.1",
"sanitize-html": "^2.11.0",
"typeorm": "^0.3.17"
},
"devDependencies": {
"@nestjs/cli": "^10.2.1",
"@nestjs/schematics": "^10.0.3",
"@nestjs/testing": "^10.2.9",
"@swc/cli": "^0.1.63",
"@swc/core": "^1.3.96",
"@types/jest": "^29.5.8",
"@types/lodash": "^4.14.201",
"@types/node": "^20.9.2",
"@types/sanitize-html": "^2.9.4",
"@types/supertest": "^2.0.16",
"@typescript-eslint/eslint-plugin": "^6.11.0",
"@typescript-eslint/parser": "^6.11.0",
"eslint": "^8.54.0",
"eslint-config-airbnb-base": "^15.0.0",
"eslint-config-airbnb-typescript": "^17.1.0",
"eslint-config-prettier": "^9.0.0",
"eslint-plugin-import": "^2.29.0",
"eslint-plugin-jest": "^27.6.0",
"eslint-plugin-prettier": "^5.0.1",
"eslint-plugin-unused-imports": "^3.0.0",
"jest": "^29.7.0",
"prettier": "^3.1.0",
"source-map-support": "^0.5.21",
"supertest": "^6.3.3",
"ts-jest": "^29.1.1",
"ts-loader": "^9.5.1",
"ts-node": "^10.9.1",
"tsconfig-paths": "^4.2.0",
"typescript": "^5.2.2"
},
"jest": {
"moduleFileExtensions": [
"js",
"json",
"ts"
],
"rootDir": "src",
"testRegex": ".*\\.spec\\.ts$",
"transform": {
"^.+\\.(t|j)s$": "ts-jest"
},
"collectCoverageFrom": [
"**/*.(t|j)s"
],
"coverageDirectory": "../coverage",
"testEnvironment": "node"
}
}
+607 -187
View File
File diff suppressed because it is too large Load Diff
+4 -11
View File
@@ -1,20 +1,13 @@
import { Module } from '@nestjs/common';
import { database } from '@/config';
import { ContentModule } from '@/modules/content/content.module';
import { CoreModule } from '@/modules/core/core.module';
import { DatabaseModule } from '@/modules/database/database.module';
import { WelcomeModule } from '@/modules/welcome/welcome.module';
import { CoreModule } from './modules/core/core.module';
@Module({
imports: [
ContentModule,
WelcomeModule,
CoreModule.forRoot({
config: {
name: '欢迎访问 Ink NestJS API !',
},
}),
],
imports: [DatabaseModule.forRoot(database), ContentModule, WelcomeModule, CoreModule.forRoot()],
controllers: [],
providers: [],
})
+23
View File
@@ -0,0 +1,23 @@
import { resolve } from 'path';
import { TypeOrmModuleOptions } from '@nestjs/typeorm';
/**
* 数据库配置
*/
export const database = (): TypeOrmModuleOptions => ({
// 以下为mysql配置
// charset: 'utf8mb4',
// logging: ['error'],
// type: 'mysql',
// host: '127.0.0.1',
// port: 3306,
// username: 'root',
// password: '123456789',
// database: 'ink_apps',
// 以下为sqlite配置
type: 'better-sqlite3',
database: resolve(__dirname, '../../back/database4.db'),
synchronize: true,
autoLoadEntities: true,
});
+1
View File
@@ -0,0 +1 @@
export * from './database.config';
+3 -1
View File
@@ -9,8 +9,10 @@ const bootstrap = async () => {
logger: ['error', 'warn'],
});
app.setGlobalPrefix('api');
await app.listen(2333, () => {
console.log('api: http://localhost:2333');
console.log('api: http://localhost:2333/api');
});
};
+17
View File
@@ -0,0 +1,17 @@
/**
* 文章内容类型
*/
export enum PostBodyType {
HTML = 'html',
MD = 'markdown',
}
/**
* 文章排序类型
*/
export enum PostOrderType {
CREATED = 'createdAt',
UPDATED = 'updatedAt',
PUBLISHED = 'publishedAt',
CUSTOM = 'custom',
}
+15 -4
View File
@@ -1,11 +1,22 @@
import { Module } from '@nestjs/common';
import { PostController } from './controllers';
import { PostService } from './services';
import { TypeOrmModule } from '@nestjs/typeorm';
import { PostController } from '@/modules/content/controllers';
import { PostEntity } from '@/modules/content/entities';
import { PostRepository } from '@/modules/content/repositories';
import { PostService, SanitizeService } from '@/modules/content/services';
import { PostSubscriber } from '@/modules/content/subscribers';
import { DatabaseModule } from '@/modules/database/database.module';
@Module({
imports: [
TypeOrmModule.forFeature([PostEntity]),
DatabaseModule.forRepository([PostRepository]),
],
controllers: [PostController],
providers: [PostService],
exports: [PostService],
providers: [PostService, PostSubscriber, SanitizeService],
exports: [PostService, DatabaseModule.forRepository([PostRepository])],
})
export class ContentModule {}
@@ -1,24 +1,38 @@
import { Body, Controller, Delete, Get, Param, Patch, Post, ValidationPipe } from '@nestjs/common';
import {
Body,
Controller,
Delete,
Get,
Param,
ParseUUIDPipe,
Patch,
Post,
Query,
ValidationPipe,
} from '@nestjs/common';
import { CreatePostDto, UpdatePostDto } from '../dtos';
import { PostService } from '../services';
import { PostService } from '@/modules/content/services';
import { PaginateOptions } from '@/modules/database/types';
/**
* 文章控制器
* 负责处理与文章相关的请求,如获取文章列表、创建新文章等。
*/
@Controller('post')
@Controller('posts')
export class PostController {
constructor(private postService: PostService) {}
@Get()
async index() {
return this.postService.findAll();
async list(
@Query()
options: PaginateOptions,
) {
return this.postService.paginate(options);
}
@Get(':id')
async show(@Param('id') id: number) {
return this.postService.findOne(id);
async detail(@Param('id', new ParseUUIDPipe()) id: string) {
return this.postService.detail(id);
}
@Post()
@@ -32,7 +46,7 @@ export class PostController {
groups: ['create'],
}),
)
data: CreatePostDto,
data: RecordAny,
) {
return this.postService.create(data);
}
@@ -48,13 +62,13 @@ export class PostController {
groups: ['update'],
}),
)
data: UpdatePostDto,
data: RecordAny,
) {
return this.postService.update(data);
}
@Delete(':id')
async delete(@Param('id') id: number) {
async delete(@Param('id', new ParseUUIDPipe()) id: string) {
return this.postService.delete(id);
}
}
+1
View File
@@ -0,0 +1 @@
export * from './post.entity';
@@ -0,0 +1,43 @@
import {
BaseEntity,
Column,
CreateDateColumn,
Entity,
PrimaryColumn,
UpdateDateColumn,
} from 'typeorm';
import { PostBodyType } from '@/modules/content/constants';
@Entity('content_posts')
export class PostEntity extends BaseEntity {
@PrimaryColumn({ type: 'varchar', generated: 'uuid', length: '36' })
id: string;
@Column({ comment: '文章标题' })
title: string;
@Column({ comment: '文章内容', type: 'text' })
body: string;
@Column({ comment: '文章摘要', nullable: true })
summary: string;
@Column({ comment: '关键字', type: 'simple-array', nullable: true })
keywords?: string[];
@Column({ comment: '文章类型', type: 'varchar', default: PostBodyType.MD })
type: PostBodyType;
@Column({ comment: '发布时间', type: 'varchar', nullable: true })
publishedAt?: Date | null;
@CreateDateColumn({ comment: '创建时间' })
createdAt: Date;
@UpdateDateColumn({ comment: '更新时间' })
updatedAt: Date;
@Column({ comment: '文章自定义排序', default: 0 })
customOrder: number;
}
@@ -0,0 +1 @@
export * from './post.repository';
@@ -0,0 +1,11 @@
import { Repository } from 'typeorm';
import { PostEntity } from '@/modules/content/entities';
import { CustomRepository } from '@/modules/database/decorators';
@CustomRepository(PostEntity)
export class PostRepository extends Repository<PostEntity> {
buildBaseQB() {
return this.createQueryBuilder('post');
}
}
+1
View File
@@ -1 +1,2 @@
export * from './post.service';
export * from './sanitize.service';
+99 -44
View File
@@ -1,64 +1,119 @@
import { Injectable, NotFoundException } from '@nestjs/common';
import { Injectable } from '@nestjs/common';
import { isNil } from 'lodash';
import { isFunction, isNil, omit } from 'lodash';
import { CreatePostDto, UpdatePostDto } from '../dtos';
import { EntityNotFoundError, IsNull, Not, SelectQueryBuilder } from 'typeorm';
import { PostEntity } from '../types';
import { PostOrderType } from '@/modules/content/constants';
import { PostEntity } from '@/modules/content/entities';
import { PostRepository } from '@/modules/content/repositories';
import { paginate } from '@/modules/database/helpers';
import { PaginateOptions, QueryHook } from '@/modules/database/types';
@Injectable()
export class PostService {
protected posts: PostEntity[] = [
{ title: '第一篇文章标题', body: '第一篇文章内容' },
{ title: '第二篇文章标题', body: '第二篇文章内容' },
{ title: '第三篇文章标题', body: '第三篇文章内容' },
{ title: '第四篇文章标题', body: '第四篇文章内容' },
{ title: '第五篇文章标题', body: '第五篇文章内容' },
{ title: '第六篇文章标题', body: '第六篇文章内容' },
].map((v, id) => ({ ...v, id }));
constructor(protected repository: PostRepository) {}
async findAll() {
return this.posts;
/**
* 获取分页数据
* @param options 分页选项
* @param callback 添加额外的查询
*/
async paginate(options: PaginateOptions, callback?: QueryHook<PostEntity>) {
const qb = await this.buildListQuery(this.repository.buildBaseQB(), options, callback);
return paginate(qb, options);
}
async findOne(id: number) {
const post = this.posts.find((item) => item.id === id);
if (isNil(post)) throw new NotFoundException(`id: ${id} 文章不存在`);
return post;
/**
* 查询单篇文章
* @param id
* @param callback 添加额外的查询
*/
async detail(id: string, callback?: QueryHook<PostEntity>) {
let qb = this.repository.buildBaseQB();
qb.where(`post.id = :id`, { id });
qb = !isNil(callback) && isFunction(callback) ? await callback(qb) : qb;
const item = await qb.getOne();
if (!item) throw new EntityNotFoundError(PostEntity, `The post ${id} not exists!`);
return item;
}
async create(data: CreatePostDto) {
const newPost: PostEntity = {
id: Math.max(...this.posts.map(({ id }) => id + 1)),
...data,
};
/**
* 创建文章
* @param data
*/
async create(data: Record<string, any>) {
const item = await this.repository.save(data);
this.posts.push(newPost);
return newPost;
return this.detail(item.id);
}
async update(data: UpdatePostDto) {
let toUpdate = this.posts.find((item) => item.id === data.id);
if (isNil(toUpdate)) throw new NotFoundException(`id: ${data.id} 文章不存在`);
toUpdate = { ...toUpdate, ...data };
this.posts = this.posts.map((item) => (item.id === data.id ? toUpdate : item));
return toUpdate;
/**
* 更新文章
* @param data
*/
async update(data: Record<string, any>) {
await this.repository.update(data.id, omit(data, ['id']));
return this.detail(data.id);
}
async delete(id: number) {
const toDelete = this.posts.find((item) => item.id === id);
/**
* 删除文章
* @param id
*/
async delete(id: string) {
const item = await this.repository.findOneByOrFail({ id });
return this.repository.remove(item);
}
if (isNil(toDelete)) throw new NotFoundException(`id: ${id} 文章不存在`);
/**
* 构建文章列表查询器
* @param qb 初始查询构造器
* @param options 排查分页选项后的查询选项
* @param callback 添加额外的查询
*/
protected async buildListQuery(
qb: SelectQueryBuilder<PostEntity>,
options: Record<string, any>,
callback?: QueryHook<PostEntity>,
) {
const { orderBy, isPublished } = options;
let newQb = qb;
if (typeof isPublished === 'boolean') {
newQb = isPublished
? newQb.where({
publishedAt: Not(IsNull()),
})
: newQb.where({
publishedAt: IsNull(),
});
}
newQb = this.queryOrderBy(newQb, orderBy);
if (callback) return callback(newQb);
return newQb;
}
this.posts = this.posts.filter((item) => item.id !== id);
return toDelete;
/**
* 对文章进行排序的Query构建
* @param qb
* @param orderBy 排序方式
*/
protected queryOrderBy(qb: SelectQueryBuilder<PostEntity>, orderBy?: PostOrderType) {
switch (orderBy) {
case PostOrderType.CREATED:
return qb.orderBy('post.createdAt', 'DESC');
case PostOrderType.UPDATED:
return qb.orderBy('post.updatedAt', 'DESC');
case PostOrderType.PUBLISHED:
return qb.orderBy('post.publishedAt', 'DESC');
case PostOrderType.CUSTOM:
return qb.orderBy('customOrder', 'DESC');
default:
return qb
.orderBy('post.createdAt', 'DESC')
.addOrderBy('post.updatedAt', 'DESC')
.addOrderBy('post.publishedAt', 'DESC');
}
}
}
@@ -0,0 +1,26 @@
import { Injectable } from '@nestjs/common';
import sanitizeHtml from 'sanitize-html';
import { deepMerge } from '@/modules/core/helpers';
@Injectable()
export class SanitizeService {
protected config: sanitizeHtml.IOptions = {};
constructor() {
this.config = {
allowedTags: sanitizeHtml.defaults.allowedTags.concat(['img', 'code']),
allowedAttributes: {
...sanitizeHtml.defaults.allowedAttributes,
'*': ['class', 'style', 'height', 'width'],
},
parser: {
lowerCaseTags: true,
},
};
}
sanitize(body: string, options?: sanitizeHtml.IOptions) {
return sanitizeHtml(body, deepMerge(this.config, options ?? {}, 'replace'));
}
}
+1
View File
@@ -0,0 +1 @@
export * from './post.subscriber';
@@ -0,0 +1,28 @@
import { DataSource, EventSubscriber } from 'typeorm';
import { PostBodyType } from '@/modules/content/constants';
import { PostEntity } from '@/modules/content/entities';
import { PostRepository } from '@/modules/content/repositories';
import { SanitizeService } from '@/modules/content/services';
@EventSubscriber()
export class PostSubscriber {
constructor(
protected dataSource: DataSource,
protected sanitizeService: SanitizeService,
protected postRepository: PostRepository,
) {}
listenTo() {
return PostEntity;
}
/**
* 加载文章数据的处理
*/
async afterLoad(entity: PostEntity) {
if (entity.type === PostBodyType.HTML) {
entity.body = this.sanitizeService.sanitize(entity.body);
}
}
}
-6
View File
@@ -1,6 +0,0 @@
export interface PostEntity {
id: number;
title: string;
summary?: string;
body: string;
}
+2 -14
View File
@@ -1,23 +1,11 @@
import { DynamicModule, Global, Module } from '@nestjs/common';
import { DynamicModule, Module } from '@nestjs/common';
import { ConfigService } from './services/config.service';
@Global()
@Module({})
export class CoreModule {
static forRoot(options: { config: RecordAny }): DynamicModule {
static forRoot(): DynamicModule {
return {
module: CoreModule,
global: true,
providers: [
{
provide: ConfigService,
useFactory() {
return new ConfigService(options.config);
},
},
],
exports: [ConfigService],
};
}
}
+1
View File
@@ -0,0 +1 @@
export * from './utils';
+50
View File
@@ -0,0 +1,50 @@
import deepmerge from 'deepmerge';
import { isNil } from 'lodash';
/**
* 用于请求验证中的boolean数据转义
* @param value
*/
export const toBoolean = (value?: string | boolean): boolean => {
if (isNil(value)) return false;
if (typeof value === 'boolean') return value;
try {
return JSON.parse(value.toLowerCase());
} catch (error) {
return value as unknown as boolean;
}
};
/**
* 用于请求验证中转义null
* @param value
*/
export const toNull = (value?: string | null): string | null | undefined => {
return value === 'null' ? null : value;
};
/**
* 深度合并对象
* @param x 初始值
* @param y 新值
* @param arrayMode 对于数组采取的策略,`replace`为直接替换,`merge`为合并数组
*/
export const deepMerge = <T1, T2>(
x: Partial<T1>,
y: Partial<T2>,
arrayMode: 'replace' | 'merge' = 'merge',
) => {
const options: deepmerge.Options = {};
if (arrayMode === 'replace') {
options.arrayMerge = (target, source, _options) => source;
} else if (arrayMode === 'merge') {
options.arrayMerge = (target, source, _options) =>
Array.from(new Set({ ...target, ...source }));
}
return deepmerge(x, y, options) as T2 extends T1 ? T1 : T1 & T2;
};
@@ -1,15 +0,0 @@
import { Injectable } from '@nestjs/common';
import { get } from 'lodash';
@Injectable()
export class ConfigService {
protected config: RecordAny = {};
constructor(data: RecordAny) {
this.config = data;
}
get<T>(key: string, defaultValue?: T): T | undefined {
return get(this.config, key, defaultValue);
}
}
+4
View File
@@ -0,0 +1,4 @@
/**
* 自定义 Repository 元数据
*/
export const CUSTOM_REPOSITORY_METADATA = 'CUSTOM_REPOSITORY_METADATA';
+47
View File
@@ -0,0 +1,47 @@
import { DynamicModule, Module, Provider, Type } from '@nestjs/common';
import { TypeOrmModule, TypeOrmModuleOptions, getDataSourceToken } from '@nestjs/typeorm';
import { DataSource, ObjectType } from 'typeorm';
import { CUSTOM_REPOSITORY_METADATA } from '@/modules/database/constants';
@Module({})
export class DatabaseModule {
static forRoot(configRegister: () => TypeOrmModuleOptions): DynamicModule {
return {
global: true,
module: DatabaseModule,
imports: [TypeOrmModule.forRoot(configRegister())],
};
}
static forRepository<T extends Type<any>>(
repositories: T[],
dataSourceName?: string,
): DynamicModule {
const providers: Provider[] = [];
for (const Repo of repositories) {
const entity = Reflect.getMetadata(CUSTOM_REPOSITORY_METADATA, Repo);
if (!entity) {
continue;
}
providers.push({
inject: [getDataSourceToken(dataSourceName)],
provide: Repo,
useFactory: (dataSource: DataSource): InstanceType<typeof Repo> => {
const base = dataSource.getRepository<ObjectType<any>>(entity);
return new Repo(base.target, base.manager, base.queryRunner);
},
});
}
return {
exports: providers,
module: DatabaseModule,
providers,
};
}
}
+1
View File
@@ -0,0 +1 @@
export * from './repository.decorator';
@@ -0,0 +1,13 @@
import { SetMetadata } from '@nestjs/common';
import { ObjectType } from 'typeorm';
import { CUSTOM_REPOSITORY_METADATA } from '@/modules/database/constants';
/**
* 自定义存储库装饰器,用于将一个TypeORM实体类关联到一个自定义存储库类。
* @param {ObjectType<T>} entity - TypeORM实体类,将被关联到自定义存储库。
* @returns {ClassDecorator} - 一个类装饰器,将元数据关联到目标存储库类。
*/
export const CustomRepository = <T>(entity: ObjectType<T>): ClassDecorator =>
SetMetadata(CUSTOM_REPOSITORY_METADATA, entity);
+46
View File
@@ -0,0 +1,46 @@
import { isNil } from 'lodash';
import { ObjectLiteral, SelectQueryBuilder } from 'typeorm';
import { PaginateOptions, PaginateReturn } from '@/modules/database/types';
/**
* 分页函数
* @params qb queryBuilder实例
* @params options 分页选项
*/
export const paginate = async <E extends ObjectLiteral>(
qb: SelectQueryBuilder<E>,
options: PaginateOptions,
): Promise<PaginateReturn<E>> => {
const limit = isNil(options.limit) || options.limit < 1 ? 1 : options.limit;
const page = isNil(options.page) || options.page < 1 ? 1 : options.page;
const start = page >= 1 ? page - 1 : 0;
const totalItems = await qb.getCount();
qb.take(limit).skip(start * limit);
const items = await qb.getMany();
const totalPages =
totalItems % limit === 0
? Math.floor(totalItems / limit)
: Math.floor(totalItems / limit) + 1;
const remainder = totalItems % limit !== 0 ? totalItems % limit : limit;
const itemCount = page < totalPages ? limit : remainder;
return {
items,
meta: {
totalItems,
totalPages,
itemCount,
perPage: limit,
currentPage: page,
},
};
};
+70
View File
@@ -0,0 +1,70 @@
import { ObjectLiteral, SelectQueryBuilder } from 'typeorm';
/**
* 一个查询钩子,用于修改或增强给定的查询构建器。
* @param {SelectQueryBuilder<Entity>} qb - TypeORM的查询构建器实例。
* @returns {Promise<SelectQueryBuilder<Entity>>} - 经过修改或增强的查询构建器实例。
*/
export type QueryHook<Entity> = (
qb: SelectQueryBuilder<Entity>,
) => Promise<SelectQueryBuilder<Entity>>;
/**
* 分页原数据
*/
export interface PaginateMeta {
/**
* 当前页项目数量
*/
itemCount: number;
/**
* 项目总数量
*/
totalItems?: number;
/**
* 每页显示数量
*/
perPage: number;
/**
* 总页数
*/
totalPages?: number;
/**
* 当前页数
*/
currentPage: number;
}
/**
* 分页选项
*/
export interface PaginateOptions {
/**
* 当前页数
*/
page: number;
/**
* 每页显示数量
*/
limit: number;
}
/**
* 分页数据返回
*/
export interface PaginateReturn<E extends ObjectLiteral> {
/**
* 项目列表
*/
items: E[];
/**
* 分页信息
*/
meta: PaginateMeta;
}
+2 -49
View File
@@ -1,59 +1,12 @@
import { Controller, Get, Inject } from '@nestjs/common';
import { ConfigService } from '../core/services/config.service';
import { FifthService } from './services/fifth.service';
import { FirstService } from './services/first.service';
import { FourthService } from './services/fourth.service';
import { SecondService } from './services/second.service';
import { Controller, Get } from '@nestjs/common';
/**
* 欢迎访问 Ink NestJS API
*/
@Controller()
export class WelcomeController {
constructor(
private configService: ConfigService,
private first: FirstService,
@Inject('ID-WELCOME') private idExp: FirstService,
@Inject('FACTORY-WELCOME') private ftExp: FourthService,
@Inject('ALIAS-WELCOME') private asExp: FirstService,
@Inject('ASYNC-WELCOME') private acExp: SecondService,
private fifth: FifthService,
) {}
@Get()
getMessage(): string {
return this.configService.get('name');
}
@Get('value')
async useValue() {
return this.first.useValue();
}
@Get('id')
async useId() {
return this.idExp.useId();
}
@Get('factory')
async useFactory() {
return this.ftExp.getContent();
}
@Get('alias')
async useAlias() {
return this.asExp.useAlias();
}
@Get('async')
async useAsync() {
return this.acExp.useAsync();
}
@Get('circular')
async useCircular() {
return this.fifth.circular();
return '欢迎访问 Ink NestJS API';
}
}
-50
View File
@@ -1,58 +1,8 @@
import { Module } from '@nestjs/common';
import { FirstService } from './services/first.service';
import { FourthService } from './services/fourth.service';
import { SecondService } from './services/second.service';
import { ThirdService } from './services/third.service';
import { WelcomeController } from './welcome.controller';
import { FifthService } from './services/fifth.service';
import { SixthService } from './services/sixth.service';
const firstObject = {
useValue: () => 'firstObject useValue 提供者',
useAlias: () => 'firstObject 别名提供者',
};
const firstInstance = new FirstService();
@Module({
controllers: [WelcomeController],
providers: [
{
provide: FirstService,
useValue: firstObject,
},
{
provide: 'ID-WELCOME',
useValue: firstInstance,
},
{
provide: SecondService,
useClass: ThirdService,
},
{
provide: 'FACTORY-WELCOME',
useFactory(second: SecondService) {
const factory = new FourthService(second);
return factory;
},
inject: [SecondService],
},
{
provide: 'ALIAS-WELCOME',
useExisting: FirstService,
},
{
provide: 'ASYNC-WELCOME',
useFactory: async () => {
const factory = new FourthService(new SecondService());
return factory.getPromise();
},
},
FifthService,
SixthService,
],
})
export class WelcomeModule {}