Compare commits
25 Commits
d5507d5510
...
d61527709d
Author | SHA1 | Date | |
---|---|---|---|
d61527709d | |||
56f85173cf | |||
afb44f4d0c | |||
149623c073 | |||
06f187b742 | |||
f03b55fec4 | |||
eddf8795df | |||
a198ca62b4 | |||
c2f263ae82 | |||
6386c3f1b3 | |||
34c4ac8f86 | |||
d264b515a1 | |||
4300e851df | |||
64e1602874 | |||
321b7e0b0c | |||
fd6631aba6 | |||
c196b00708 | |||
57c72e010b | |||
9ac2fd8f44 | |||
6059ff4ed2 | |||
65b52d8b6e | |||
2e03eca42d | |||
57c0318bda | |||
840590cad6 | |||
66da3530bf |
262
.eslintrc.js
262
.eslintrc.js
@ -1,144 +1,138 @@
|
||||
module.exports = {
|
||||
parser: '@typescript-eslint/parser',
|
||||
parserOptions: {
|
||||
project: 'tsconfig.json',
|
||||
tsconfigRootDir: __dirname,
|
||||
ecmaVersion: 'latest',
|
||||
sourceType: 'module',
|
||||
},
|
||||
root: true,
|
||||
env: {
|
||||
node: true,
|
||||
jest: true,
|
||||
},
|
||||
plugins: [
|
||||
'@typescript-eslint',
|
||||
'jest',
|
||||
'prettier',
|
||||
'import',
|
||||
'unused-imports',
|
||||
],
|
||||
extends: [
|
||||
// airbnb规范
|
||||
// https://github.com/airbnb/javascript/tree/master/packages/eslint-config-airbnb
|
||||
'airbnb-base',
|
||||
// 兼容typescript的airbnb规范
|
||||
// https://github.com/iamturns/eslint-config-airbnb-typescript
|
||||
'airbnb-typescript/base',
|
||||
parser: '@typescript-eslint/parser',
|
||||
parserOptions: {
|
||||
project: 'tsconfig.json',
|
||||
tsconfigRootDir: __dirname,
|
||||
ecmaVersion: 'latest',
|
||||
sourceType: 'module',
|
||||
},
|
||||
root: true,
|
||||
env: {
|
||||
node: true,
|
||||
jest: true,
|
||||
},
|
||||
plugins: ['@typescript-eslint', 'jest', 'prettier', 'import', 'unused-imports'],
|
||||
extends: [
|
||||
// airbnb规范
|
||||
// https://github.com/airbnb/javascript/tree/master/packages/eslint-config-airbnb
|
||||
'airbnb-base',
|
||||
// 兼容typescript的airbnb规范
|
||||
// https://github.com/iamturns/eslint-config-airbnb-typescript
|
||||
'airbnb-typescript/base',
|
||||
|
||||
// typescript的eslint插件
|
||||
// https://github.com/typescript-eslint/typescript-eslint/blob/master/docs/getting-started/linting/README.md
|
||||
// https://github.com/typescript-eslint/typescript-eslint/tree/master/packages/eslint-plugin
|
||||
'plugin:@typescript-eslint/recommended',
|
||||
'plugin:@typescript-eslint/recommended-requiring-type-checking',
|
||||
// typescript的eslint插件
|
||||
// https://github.com/typescript-eslint/typescript-eslint/blob/master/docs/getting-started/linting/README.md
|
||||
// https://github.com/typescript-eslint/typescript-eslint/tree/master/packages/eslint-plugin
|
||||
'plugin:@typescript-eslint/recommended',
|
||||
'plugin:@typescript-eslint/recommended-requiring-type-checking',
|
||||
|
||||
// 支持jest
|
||||
'plugin:jest/recommended',
|
||||
// 使用prettier格式化代码
|
||||
// https://github.com/prettier/eslint-config-prettier#readme
|
||||
'prettier',
|
||||
// 整合typescript-eslint与prettier
|
||||
// https://github.com/prettier/eslint-plugin-prettier
|
||||
'plugin:prettier/recommended',
|
||||
],
|
||||
rules: {
|
||||
/* ********************************** ES6+ ********************************** */
|
||||
'no-console': 0,
|
||||
'no-var-requires': 0,
|
||||
'no-restricted-syntax': 0,
|
||||
'no-continue': 0,
|
||||
'no-await-in-loop': 0,
|
||||
'no-return-await': 0,
|
||||
'no-unused-vars': 0,
|
||||
'no-multi-assign': 0,
|
||||
'no-param-reassign': [2, { props: false }],
|
||||
'import/prefer-default-export': 0,
|
||||
'import/no-cycle': 0,
|
||||
'import/no-dynamic-require': 0,
|
||||
'max-classes-per-file': 0,
|
||||
'class-methods-use-this': 0,
|
||||
'guard-for-in': 0,
|
||||
'no-underscore-dangle': 0,
|
||||
'no-plusplus': 0,
|
||||
'no-lonely-if': 0,
|
||||
'no-bitwise': ['error', { allow: ['~'] }],
|
||||
// 支持jest
|
||||
'plugin:jest/recommended',
|
||||
// 使用prettier格式化代码
|
||||
// https://github.com/prettier/eslint-config-prettier#readme
|
||||
'prettier',
|
||||
// 整合typescript-eslint与prettier
|
||||
// https://github.com/prettier/eslint-plugin-prettier
|
||||
'plugin:prettier/recommended',
|
||||
],
|
||||
rules: {
|
||||
/* ********************************** ES6+ ********************************** */
|
||||
'no-console': 0,
|
||||
'no-var-requires': 0,
|
||||
'no-restricted-syntax': 0,
|
||||
'no-continue': 0,
|
||||
'no-await-in-loop': 0,
|
||||
'no-return-await': 0,
|
||||
'no-unused-vars': 0,
|
||||
'no-multi-assign': 0,
|
||||
'no-param-reassign': [2, { props: false }],
|
||||
'import/prefer-default-export': 0,
|
||||
'import/no-cycle': 0,
|
||||
'import/no-dynamic-require': 0,
|
||||
'max-classes-per-file': 0,
|
||||
'class-methods-use-this': 0,
|
||||
'guard-for-in': 0,
|
||||
'no-underscore-dangle': 0,
|
||||
'no-plusplus': 0,
|
||||
'no-lonely-if': 0,
|
||||
'no-bitwise': ['error', { allow: ['~'] }],
|
||||
|
||||
/* ********************************** Module Import ********************************** */
|
||||
/* ********************************** Module Import ********************************** */
|
||||
|
||||
'import/no-absolute-path': 0,
|
||||
'import/extensions': 0,
|
||||
'import/no-named-default': 0,
|
||||
'no-restricted-exports': 0,
|
||||
'import/no-absolute-path': 0,
|
||||
'import/extensions': 0,
|
||||
'import/no-named-default': 0,
|
||||
'no-restricted-exports': 0,
|
||||
|
||||
// 一部分文件在导入devDependencies的依赖时不报错
|
||||
'import/no-extraneous-dependencies': [
|
||||
1,
|
||||
{
|
||||
devDependencies: [
|
||||
'**/*.test.{ts,js}',
|
||||
'**/*.spec.{ts,js}',
|
||||
'./test/**.{ts,js}',
|
||||
'./scripts/**/*.{ts,js}',
|
||||
// 一部分文件在导入devDependencies的依赖时不报错
|
||||
'import/no-extraneous-dependencies': [
|
||||
1,
|
||||
{
|
||||
devDependencies: [
|
||||
'**/*.test.{ts,js}',
|
||||
'**/*.spec.{ts,js}',
|
||||
'./test/**.{ts,js}',
|
||||
'./scripts/**/*.{ts,js}',
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
// 模块导入顺序规则
|
||||
'import/order': [
|
||||
1,
|
||||
{
|
||||
pathGroups: [
|
||||
{
|
||||
pattern: '@/**',
|
||||
group: 'external',
|
||||
position: 'after',
|
||||
},
|
||||
// 模块导入顺序规则
|
||||
'import/order': [
|
||||
1,
|
||||
{
|
||||
pathGroups: [
|
||||
{
|
||||
pattern: '@/**',
|
||||
group: 'external',
|
||||
position: 'after',
|
||||
},
|
||||
],
|
||||
alphabetize: { order: 'asc', caseInsensitive: false },
|
||||
'newlines-between': 'always-and-inside-groups',
|
||||
warnOnUnassignedImports: true,
|
||||
},
|
||||
],
|
||||
alphabetize: { order: 'asc', caseInsensitive: false },
|
||||
'newlines-between': 'always-and-inside-groups',
|
||||
warnOnUnassignedImports: true,
|
||||
},
|
||||
],
|
||||
// 自动删除未使用的导入
|
||||
// https://github.com/sweepline/eslint-plugin-unused-imports
|
||||
'unused-imports/no-unused-imports': 1,
|
||||
'unused-imports/no-unused-vars': [
|
||||
'error',
|
||||
{
|
||||
vars: 'all',
|
||||
args: 'none',
|
||||
ignoreRestSiblings: true,
|
||||
},
|
||||
],
|
||||
/* ********************************** Typescript ********************************** */
|
||||
'@typescript-eslint/no-unused-vars': 0,
|
||||
'@typescript-eslint/no-empty-interface': 0,
|
||||
'@typescript-eslint/no-this-alias': 0,
|
||||
'@typescript-eslint/no-var-requires': 0,
|
||||
'@typescript-eslint/no-use-before-define': 0,
|
||||
'@typescript-eslint/explicit-member-accessibility': 0,
|
||||
'@typescript-eslint/no-non-null-assertion': 0,
|
||||
'@typescript-eslint/no-unnecessary-type-assertion': 0,
|
||||
'@typescript-eslint/require-await': 0,
|
||||
'@typescript-eslint/no-for-in-array': 0,
|
||||
'@typescript-eslint/interface-name-prefix': 0,
|
||||
'@typescript-eslint/explicit-function-return-type': 0,
|
||||
'@typescript-eslint/no-explicit-any': 0,
|
||||
'@typescript-eslint/explicit-module-boundary-types': 0,
|
||||
'@typescript-eslint/no-floating-promises': 0,
|
||||
'@typescript-eslint/restrict-template-expressions': 0,
|
||||
'@typescript-eslint/no-unsafe-assignment': 0,
|
||||
'@typescript-eslint/no-unsafe-return': 0,
|
||||
'@typescript-eslint/no-unused-expressions': 0,
|
||||
'@typescript-eslint/no-misused-promises': 0,
|
||||
'@typescript-eslint/no-unsafe-member-access': 0,
|
||||
'@typescript-eslint/no-unsafe-call': 0,
|
||||
'@typescript-eslint/no-unsafe-argument': 0,
|
||||
'@typescript-eslint/ban-ts-comment': 0,
|
||||
'@typescript-eslint/lines-between-class-members': 0,
|
||||
'@typescript-eslint/no-throw-literal': 0,
|
||||
},
|
||||
// 自动删除未使用的导入
|
||||
// https://github.com/sweepline/eslint-plugin-unused-imports
|
||||
'unused-imports/no-unused-imports': 1,
|
||||
'unused-imports/no-unused-vars': [
|
||||
'warn',
|
||||
{
|
||||
vars: 'all',
|
||||
args: 'none',
|
||||
ignoreRestSiblings: true,
|
||||
},
|
||||
],
|
||||
/* ********************************** Typescript ********************************** */
|
||||
'@typescript-eslint/no-unused-vars': 0,
|
||||
'@typescript-eslint/no-empty-interface': 0,
|
||||
'@typescript-eslint/no-this-alias': 0,
|
||||
'@typescript-eslint/no-var-requires': 0,
|
||||
'@typescript-eslint/no-use-before-define': 0,
|
||||
'@typescript-eslint/explicit-member-accessibility': 0,
|
||||
'@typescript-eslint/no-non-null-assertion': 0,
|
||||
'@typescript-eslint/no-unnecessary-type-assertion': 0,
|
||||
'@typescript-eslint/require-await': 0,
|
||||
'@typescript-eslint/no-for-in-array': 0,
|
||||
'@typescript-eslint/interface-name-prefix': 0,
|
||||
'@typescript-eslint/explicit-function-return-type': 0,
|
||||
'@typescript-eslint/no-explicit-any': 0,
|
||||
'@typescript-eslint/explicit-module-boundary-types': 0,
|
||||
'@typescript-eslint/no-floating-promises': 0,
|
||||
'@typescript-eslint/restrict-template-expressions': 0,
|
||||
'@typescript-eslint/no-unsafe-assignment': 0,
|
||||
'@typescript-eslint/no-unsafe-return': 0,
|
||||
'@typescript-eslint/no-unused-expressions': 0,
|
||||
'@typescript-eslint/no-misused-promises': 0,
|
||||
'@typescript-eslint/no-unsafe-member-access': 0,
|
||||
'@typescript-eslint/no-unsafe-call': 0,
|
||||
'@typescript-eslint/no-unsafe-argument': 0,
|
||||
'@typescript-eslint/ban-ts-comment': 0,
|
||||
'@typescript-eslint/lines-between-class-members': 0,
|
||||
'@typescript-eslint/no-throw-literal': 0,
|
||||
},
|
||||
|
||||
settings: {
|
||||
extensions: ['.ts', '.d.ts', '.cts', '.mts', '.js', '.cjs', 'mjs', '.json'],
|
||||
},
|
||||
settings: {
|
||||
extensions: ['.ts', '.d.ts', '.cts', '.mts', '.js', '.cjs', 'mjs', '.json'],
|
||||
},
|
||||
};
|
||||
|
16
package.json
16
package.json
@ -23,10 +23,19 @@
|
||||
"dependencies": {
|
||||
"@nestjs/common": "^10.0.3",
|
||||
"@nestjs/core": "^10.0.3",
|
||||
"@nestjs/platform-express": "^10.0.3",
|
||||
"@nestjs/platform-fastify": "^10.0.3",
|
||||
"@nestjs/swagger": "^7.4.2",
|
||||
"@nestjs/typeorm": "^11.0.0",
|
||||
"better-sqlite3": "^11.10.0",
|
||||
"class-transformer": "^0.5.1",
|
||||
"class-validator": "^0.14.2",
|
||||
"deepmerge": "^4.3.1",
|
||||
"lodash": "^4.17.21",
|
||||
"reflect-metadata": "^0.1.13",
|
||||
"rimraf": "^5.0.1",
|
||||
"rxjs": "^7.8.1"
|
||||
"rxjs": "^7.8.1",
|
||||
"sanitize-html": "^2.17.0",
|
||||
"typeorm": "^0.3.24"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@nestjs/cli": "^10.0.3",
|
||||
@ -34,9 +43,10 @@
|
||||
"@nestjs/testing": "^10.0.3",
|
||||
"@swc/cli": "^0.1.62",
|
||||
"@swc/core": "^1.3.66",
|
||||
"@types/express": "^4.17.17",
|
||||
"@types/jest": "29.5.2",
|
||||
"@types/lodash": "^4.17.16",
|
||||
"@types/node": "^20.3.1",
|
||||
"@types/sanitize-html": "^2.16.0",
|
||||
"@types/supertest": "^2.0.12",
|
||||
"@typescript-eslint/eslint-plugin": "^5.60.0",
|
||||
"@typescript-eslint/parser": "^5.60.0",
|
||||
|
1125
pnpm-lock.yaml
1125
pnpm-lock.yaml
File diff suppressed because it is too large
Load Diff
@ -1,23 +0,0 @@
|
||||
import { Test, TestingModule } from '@nestjs/testing';
|
||||
|
||||
import { AppController } from './app.controller';
|
||||
import { AppService } from './app.service';
|
||||
|
||||
describe('AppController', () => {
|
||||
let appController: AppController;
|
||||
|
||||
beforeEach(async () => {
|
||||
const app: TestingModule = await Test.createTestingModule({
|
||||
controllers: [AppController],
|
||||
providers: [AppService],
|
||||
}).compile();
|
||||
|
||||
appController = app.get<AppController>(AppController);
|
||||
});
|
||||
|
||||
describe('root', () => {
|
||||
it('should return "Hello World!"', () => {
|
||||
expect(appController.getHello()).toBe('Hello World!');
|
||||
});
|
||||
});
|
||||
});
|
@ -1,13 +0,0 @@
|
||||
import { Controller, Get } from '@nestjs/common';
|
||||
|
||||
import { AppService } from './app.service';
|
||||
|
||||
@Controller()
|
||||
export class AppController {
|
||||
constructor(private readonly appService: AppService) {}
|
||||
|
||||
@Get()
|
||||
getHello(): string {
|
||||
return this.appService.getHello();
|
||||
}
|
||||
}
|
@ -1,11 +1,12 @@
|
||||
import { Module } from '@nestjs/common';
|
||||
|
||||
import { AppController } from './app.controller';
|
||||
import { AppService } from './app.service';
|
||||
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';
|
||||
|
||||
@Module({
|
||||
imports: [],
|
||||
controllers: [AppController],
|
||||
providers: [AppService],
|
||||
imports: [ContentModule, CoreModule.forRoot(), DatabaseModule.forRoot(database)],
|
||||
})
|
||||
export class AppModule {}
|
||||
|
@ -1,8 +0,0 @@
|
||||
import { Injectable } from '@nestjs/common';
|
||||
|
||||
@Injectable()
|
||||
export class AppService {
|
||||
getHello(): string {
|
||||
return 'Hello World!';
|
||||
}
|
||||
}
|
14
src/config/database.config.ts
Normal file
14
src/config/database.config.ts
Normal file
@ -0,0 +1,14 @@
|
||||
import { TypeOrmModuleOptions } from '@nestjs/typeorm';
|
||||
|
||||
export const database = (): TypeOrmModuleOptions => ({
|
||||
charset: 'utf8mb4',
|
||||
logging: ['error'],
|
||||
type: 'mysql',
|
||||
host: '192.168.50.26',
|
||||
port: 3306,
|
||||
username: '3r',
|
||||
password: '12345678',
|
||||
database: '3r',
|
||||
synchronize: true,
|
||||
autoLoadEntities: true,
|
||||
});
|
1
src/config/index.ts
Normal file
1
src/config/index.ts
Normal file
@ -0,0 +1 @@
|
||||
export * from './database.config';
|
13
src/main.ts
13
src/main.ts
@ -1,9 +1,18 @@
|
||||
import { NestFactory } from '@nestjs/core';
|
||||
|
||||
import { FastifyAdapter, NestFastifyApplication } from '@nestjs/platform-fastify';
|
||||
|
||||
import { AppModule } from './app.module';
|
||||
|
||||
async function bootstrap() {
|
||||
const app = await NestFactory.create(AppModule);
|
||||
await app.listen(process.env.PORT ?? 3000);
|
||||
const app = await NestFactory.create<NestFastifyApplication>(AppModule, new FastifyAdapter(), {
|
||||
cors: true,
|
||||
logger: ['error', 'warn'],
|
||||
});
|
||||
app.setGlobalPrefix('api');
|
||||
await app.listen(process.env.PORT ?? 3000, () => {
|
||||
console.log('api: http://localhost:3000');
|
||||
});
|
||||
}
|
||||
|
||||
bootstrap();
|
||||
|
11
src/modules/content/constants.ts
Normal file
11
src/modules/content/constants.ts
Normal file
@ -0,0 +1,11 @@
|
||||
export enum PostBodyType {
|
||||
HTML = 'html',
|
||||
MD = 'markdown',
|
||||
}
|
||||
|
||||
export enum PostOrder {
|
||||
CREATED = 'createdAt',
|
||||
UPDATED = 'updatedAt',
|
||||
PUBLISHED = 'publishedAt',
|
||||
CUSTOM = 'custom',
|
||||
}
|
24
src/modules/content/content.module.ts
Normal file
24
src/modules/content/content.module.ts
Normal file
@ -0,0 +1,24 @@
|
||||
import { Module } from '@nestjs/common';
|
||||
|
||||
import { TypeOrmModule } from '@nestjs/typeorm';
|
||||
|
||||
import { PostEntity } from '@/modules/content/entities/post.entity';
|
||||
import { PostRepository } from '@/modules/content/repositories/post.repository';
|
||||
import { SanitizeService } from '@/modules/content/services/SanitizeService';
|
||||
import { PostService } from '@/modules/content/services/post.service';
|
||||
|
||||
import { PostSubscriber } from '@/modules/content/subscribers/post.subscriber';
|
||||
import { DatabaseModule } from '@/modules/database/database.module';
|
||||
|
||||
import { PostController } from './controllers/post.controller';
|
||||
|
||||
@Module({
|
||||
imports: [
|
||||
TypeOrmModule.forFeature([PostEntity]),
|
||||
DatabaseModule.forRepository([PostRepository]),
|
||||
],
|
||||
controllers: [PostController],
|
||||
providers: [PostService, PostSubscriber, SanitizeService],
|
||||
exports: [PostService, DatabaseModule.forRepository([PostRepository])],
|
||||
})
|
||||
export class ContentModule {}
|
50
src/modules/content/controllers/post.controller.ts
Normal file
50
src/modules/content/controllers/post.controller.ts
Normal file
@ -0,0 +1,50 @@
|
||||
import {
|
||||
Body,
|
||||
Controller,
|
||||
Delete,
|
||||
Get,
|
||||
Param,
|
||||
ParseUUIDPipe,
|
||||
Patch,
|
||||
Post,
|
||||
Query,
|
||||
} from '@nestjs/common';
|
||||
|
||||
import { PostService } from '@/modules/content/services/post.service';
|
||||
import { PaginateOptions } from '@/modules/database/types';
|
||||
|
||||
@Controller('posts')
|
||||
export class PostController {
|
||||
constructor(private postService: PostService) {}
|
||||
|
||||
@Get()
|
||||
async list(@Query() options: PaginateOptions) {
|
||||
return this.postService.paginate(options);
|
||||
}
|
||||
|
||||
@Get(':id')
|
||||
async show(@Param('id', new ParseUUIDPipe()) id: string) {
|
||||
return this.postService.detail(id);
|
||||
}
|
||||
|
||||
@Post()
|
||||
async store(
|
||||
@Body()
|
||||
data: RecordAny,
|
||||
) {
|
||||
return this.postService.create(data);
|
||||
}
|
||||
|
||||
@Patch()
|
||||
async update(
|
||||
@Body()
|
||||
data: RecordAny,
|
||||
) {
|
||||
return this.postService.update(data);
|
||||
}
|
||||
|
||||
@Delete(':id')
|
||||
async delete(@Param('id', new ParseUUIDPipe()) id: string) {
|
||||
return this.postService.delete(id);
|
||||
}
|
||||
}
|
94
src/modules/content/dtos/post.dto.ts
Normal file
94
src/modules/content/dtos/post.dto.ts
Normal file
@ -0,0 +1,94 @@
|
||||
import { PartialType } from '@nestjs/swagger';
|
||||
import { Transform } from 'class-transformer';
|
||||
|
||||
import {
|
||||
IsBoolean,
|
||||
IsDefined,
|
||||
IsEnum,
|
||||
IsNotEmpty,
|
||||
IsNumber,
|
||||
IsOptional,
|
||||
IsUUID,
|
||||
MaxLength,
|
||||
Min,
|
||||
ValidateIf,
|
||||
} from 'class-validator';
|
||||
|
||||
import { isNil, toNumber } from 'lodash';
|
||||
|
||||
import { PostOrder } from '@/modules/content/constants';
|
||||
import { toBoolean } from '@/modules/core/helpers';
|
||||
import { PaginateOptions } from '@/modules/database/types';
|
||||
|
||||
export class QueryPostDto implements PaginateOptions {
|
||||
@Transform(({ value }) => toBoolean(value))
|
||||
@IsBoolean()
|
||||
@IsOptional()
|
||||
isPublished?: boolean;
|
||||
|
||||
@IsEnum(PostOrder, { message: `` })
|
||||
@IsOptional()
|
||||
orderBy: PostOrder;
|
||||
|
||||
@Transform(({ value }) => toNumber(value))
|
||||
@Min(1, { message: '' })
|
||||
@IsNumber()
|
||||
@IsOptional()
|
||||
page = 1;
|
||||
|
||||
@Transform(({ value }) => toNumber(value))
|
||||
@Min(1, { message: '' })
|
||||
@IsNumber()
|
||||
@IsOptional()
|
||||
limit = 10;
|
||||
}
|
||||
|
||||
export class CreatePostDto {
|
||||
@MaxLength(255, {
|
||||
always: true,
|
||||
message: 'The maximum length of the article title is $constraint1',
|
||||
})
|
||||
@IsNotEmpty({ groups: ['create'], message: 'The article title must be filled in.' })
|
||||
@IsOptional({ groups: ['update'] })
|
||||
title: string;
|
||||
|
||||
@IsNotEmpty({ groups: ['create'], message: 'The content of the article must be filled in.' })
|
||||
@IsOptional({ groups: ['update'] })
|
||||
body: string;
|
||||
|
||||
@MaxLength(500, {
|
||||
always: true,
|
||||
message: 'The maximum length of the article description is $constraint1',
|
||||
})
|
||||
@IsOptional({ always: true })
|
||||
summary?: string;
|
||||
|
||||
@Transform(({ value }) => toBoolean(value))
|
||||
@IsBoolean({ always: true })
|
||||
@ValidateIf((value) => !isNil(value.publish))
|
||||
@IsOptional({ always: true })
|
||||
publish?: boolean;
|
||||
|
||||
@MaxLength(20, {
|
||||
always: true,
|
||||
each: true,
|
||||
message: 'The maximum length of each keyword is $constraint1',
|
||||
})
|
||||
@IsOptional({ always: true })
|
||||
keywords?: string[];
|
||||
|
||||
@Transform(({ value }) => toNumber(value))
|
||||
@Min(0, { message: 'The sorted value must be greater than 0.' })
|
||||
@IsNumber(undefined, { always: true })
|
||||
@IsOptional({ always: true })
|
||||
customOrder?: number;
|
||||
}
|
||||
|
||||
export class UpdatePostDto extends PartialType(CreatePostDto) {
|
||||
@IsUUID(undefined, {
|
||||
groups: ['update'],
|
||||
message: 'The format of the article ID is incorrect.',
|
||||
})
|
||||
@IsDefined({ groups: ['update'], message: 'The article ID must be specified' })
|
||||
id: string;
|
||||
}
|
38
src/modules/content/entities/post.entity.ts
Normal file
38
src/modules/content/entities/post.entity.ts
Normal file
@ -0,0 +1,38 @@
|
||||
import { Expose } from 'class-transformer';
|
||||
import { BaseEntity, Column, CreateDateColumn, Entity, PrimaryColumn } 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;
|
||||
|
||||
@Expose()
|
||||
@Column({ comment: '关键字', type: 'simple-array', nullable: true })
|
||||
keywords?: [];
|
||||
|
||||
@Column({ comment: '文章类型', type: 'enum', enum: PostBodyType })
|
||||
type: PostBodyType;
|
||||
|
||||
@Column({ comment: '发布时间', type: 'varchar', nullable: true })
|
||||
publishedAt?: Date | null;
|
||||
|
||||
@Column({ comment: '自定义文章排序', default: 0 })
|
||||
customOrder: number;
|
||||
|
||||
@CreateDateColumn({ comment: '创建时间' })
|
||||
createdAt?: Date;
|
||||
|
||||
@Column({ comment: '更新时间', nullable: true })
|
||||
updatedAt?: Date;
|
||||
}
|
11
src/modules/content/repositories/post.repository.ts
Normal file
11
src/modules/content/repositories/post.repository.ts
Normal file
@ -0,0 +1,11 @@
|
||||
import { Repository } from 'typeorm';
|
||||
|
||||
import { PostEntity } from '@/modules/content/entities/post.entity';
|
||||
import { CustomRepository } from '@/modules/database/decorators/repository.decorator';
|
||||
|
||||
@CustomRepository(PostEntity)
|
||||
export class PostRepository extends Repository<PostEntity> {
|
||||
buildBaseQB() {
|
||||
return this.createQueryBuilder('post');
|
||||
}
|
||||
}
|
23
src/modules/content/services/SanitizeService.ts
Normal file
23
src/modules/content/services/SanitizeService.ts
Normal file
@ -0,0 +1,23 @@
|
||||
import sanitizeHtml from 'sanitize-html';
|
||||
|
||||
import { deepMerge } from '@/modules/core/helpers';
|
||||
|
||||
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'));
|
||||
}
|
||||
}
|
84
src/modules/content/services/post.service.ts
Normal file
84
src/modules/content/services/post.service.ts
Normal file
@ -0,0 +1,84 @@
|
||||
import { Injectable } from '@nestjs/common';
|
||||
import { isNil } from '@nestjs/common/utils/shared.utils';
|
||||
|
||||
import { isFunction, omit } from 'lodash';
|
||||
import { EntityNotFoundError, IsNull, Not, SelectQueryBuilder } from 'typeorm';
|
||||
|
||||
import { PostOrder } from '@/modules/content/constants';
|
||||
import { PostEntity } from '@/modules/content/entities/post.entity';
|
||||
import { PostRepository } from '@/modules/content/repositories/post.repository';
|
||||
import { PaginateOptions, QueryHook } from '@/modules/database/types';
|
||||
import { paginate } from '@/modules/database/utils';
|
||||
|
||||
@Injectable()
|
||||
export class PostService {
|
||||
constructor(protected repository: PostRepository) {}
|
||||
|
||||
async paginate(options: PaginateOptions, callback?: QueryHook<PostEntity>) {
|
||||
const qb = await this.buildListQuery(this.repository.buildBaseQB(), options, callback);
|
||||
return paginate(qb, options);
|
||||
}
|
||||
|
||||
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: RecordAny) {
|
||||
const item = await this.repository.save(data);
|
||||
return this.detail(item.id);
|
||||
}
|
||||
|
||||
async update(data: RecordAny) {
|
||||
data.updatedAt = new Date();
|
||||
await this.repository.update(data.id, omit(data, ['id']));
|
||||
return this.detail(data.id);
|
||||
}
|
||||
|
||||
async delete(id: string) {
|
||||
const item = await this.repository.findOneByOrFail({ id });
|
||||
return this.repository.remove(item);
|
||||
}
|
||||
|
||||
protected async buildListQuery(
|
||||
qb: SelectQueryBuilder<PostEntity>,
|
||||
options: RecordAny,
|
||||
callback?: QueryHook<PostEntity>,
|
||||
) {
|
||||
const { orderBy, isPublished } = options;
|
||||
if (typeof isPublished === 'boolean') {
|
||||
isPublished
|
||||
? qb.where({ publishedAt: Not(IsNull) })
|
||||
: qb.where({ publishedAt: IsNull() });
|
||||
}
|
||||
this.queryOrderBy(qb, orderBy);
|
||||
if (callback) {
|
||||
return callback(qb);
|
||||
}
|
||||
return qb;
|
||||
}
|
||||
|
||||
protected queryOrderBy(qb: SelectQueryBuilder<PostEntity>, orderBy?: PostOrder) {
|
||||
switch (orderBy) {
|
||||
case PostOrder.CREATED:
|
||||
return qb.orderBy('post.createdAt', 'DESC');
|
||||
case PostOrder.UPDATED:
|
||||
return qb.orderBy('post.updatedAt', 'DESC');
|
||||
case PostOrder.PUBLISHED:
|
||||
return qb.orderBy('post.publishedAt', 'DESC');
|
||||
case PostOrder.CUSTOM:
|
||||
return qb.orderBy('post.custom', 'DESC');
|
||||
default:
|
||||
return qb
|
||||
.orderBy('post.createdAt', 'DESC')
|
||||
.addOrderBy('post.updatedAt', 'DESC')
|
||||
.addOrderBy('post.publishedAt', 'DESC');
|
||||
}
|
||||
}
|
||||
}
|
26
src/modules/content/subscribers/post.subscriber.ts
Normal file
26
src/modules/content/subscribers/post.subscriber.ts
Normal file
@ -0,0 +1,26 @@
|
||||
import { DataSource, EventSubscriber } from 'typeorm';
|
||||
|
||||
import { PostBodyType } from '@/modules/content/constants';
|
||||
import { PostEntity } from '@/modules/content/entities/post.entity';
|
||||
import { PostRepository } from '@/modules/content/repositories/post.repository';
|
||||
import { SanitizeService } from '@/modules/content/services/SanitizeService';
|
||||
|
||||
@EventSubscriber()
|
||||
export class PostSubscriber {
|
||||
constructor(
|
||||
protected dataSource: DataSource,
|
||||
protected sanitizeService: SanitizeService,
|
||||
protected postRepository: PostRepository,
|
||||
) {
|
||||
dataSource.subscribers.push(this);
|
||||
}
|
||||
listenTo() {
|
||||
return PostEntity;
|
||||
}
|
||||
|
||||
async afterLoad(entity: PostEntity) {
|
||||
if (entity.type === PostBodyType.HTML) {
|
||||
entity.body = this.sanitizeService.sanitize(entity.body);
|
||||
}
|
||||
}
|
||||
}
|
13
src/modules/core/core.module.ts
Normal file
13
src/modules/core/core.module.ts
Normal file
@ -0,0 +1,13 @@
|
||||
import { DynamicModule, Module } from '@nestjs/common';
|
||||
|
||||
@Module({})
|
||||
export class CoreModule {
|
||||
static forRoot(): DynamicModule {
|
||||
return {
|
||||
module: CoreModule,
|
||||
global: true,
|
||||
providers: [],
|
||||
exports: [],
|
||||
};
|
||||
}
|
||||
}
|
1
src/modules/core/helpers/index.ts
Normal file
1
src/modules/core/helpers/index.ts
Normal file
@ -0,0 +1 @@
|
||||
export * from './utils';
|
34
src/modules/core/helpers/utils.ts
Normal file
34
src/modules/core/helpers/utils.ts
Normal file
@ -0,0 +1,34 @@
|
||||
import deepmerge from 'deepmerge';
|
||||
import { isNil } from 'lodash';
|
||||
|
||||
export function 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;
|
||||
}
|
||||
}
|
||||
|
||||
export function toNull(value?: string | null): string | null | undefined {
|
||||
return value === null ? null : value;
|
||||
}
|
||||
|
||||
export const deepMerge = <T, P>(
|
||||
x: Partial<T>,
|
||||
y: Partial<P>,
|
||||
arrayMode: 'replace' | 'merge' = 'merge',
|
||||
) => {
|
||||
const options: deepmerge.Options = {};
|
||||
if (arrayMode === 'replace') {
|
||||
options.arrayMerge = (_d, s, _o) => s;
|
||||
} else if (arrayMode === 'merge') {
|
||||
options.arrayMerge = (_d, s, _o) => Array.from(new Set([..._d, ...s]));
|
||||
}
|
||||
return deepmerge(x, y, options) as P extends T ? T : T & P;
|
||||
};
|
1
src/modules/database/constants.ts
Normal file
1
src/modules/database/constants.ts
Normal file
@ -0,0 +1 @@
|
||||
export const CUSTOM_REPOSITORY_METADATA = 'CUSTOM_REPOSITORY_METADATA';
|
42
src/modules/database/database.module.ts
Normal file
42
src/modules/database/database.module.ts
Normal file
@ -0,0 +1,42 @@
|
||||
import { DynamicModule, Module, Provider, Type } from '@nestjs/common';
|
||||
import { getDataSourceToken, TypeOrmModule, TypeOrmModuleOptions } 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 Repository of repositories) {
|
||||
const entity = Reflect.getMetadata(CUSTOM_REPOSITORY_METADATA, Repository);
|
||||
if (!entity) {
|
||||
continue;
|
||||
}
|
||||
providers.push({
|
||||
inject: [getDataSourceToken(datasourceName)],
|
||||
provide: Repository,
|
||||
useFactory: (datasource: DataSource): InstanceType<typeof Repository> => {
|
||||
const base = datasource.getRepository<ObjectType<any>>(entity);
|
||||
return new Repository(base.target, base.manager, base.queryRunner);
|
||||
},
|
||||
});
|
||||
}
|
||||
return {
|
||||
exports: providers,
|
||||
module: DatabaseModule,
|
||||
providers,
|
||||
};
|
||||
}
|
||||
}
|
7
src/modules/database/decorators/repository.decorator.ts
Normal file
7
src/modules/database/decorators/repository.decorator.ts
Normal file
@ -0,0 +1,7 @@
|
||||
import { SetMetadata } from '@nestjs/common';
|
||||
import { ObjectType } from 'typeorm';
|
||||
|
||||
import { CUSTOM_REPOSITORY_METADATA } from '@/modules/database/constants';
|
||||
|
||||
export const CustomRepository = <T>(entity: ObjectType<T>): ClassDecorator =>
|
||||
SetMetadata(CUSTOM_REPOSITORY_METADATA, entity);
|
23
src/modules/database/types.ts
Normal file
23
src/modules/database/types.ts
Normal file
@ -0,0 +1,23 @@
|
||||
import { ObjectLiteral, SelectQueryBuilder } from 'typeorm';
|
||||
|
||||
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> {
|
||||
meta: PaginateMeta;
|
||||
items: E[];
|
||||
}
|
32
src/modules/database/utils.ts
Normal file
32
src/modules/database/utils.ts
Normal file
@ -0,0 +1,32 @@
|
||||
import { isNil } from 'lodash';
|
||||
import { ObjectLiteral, SelectQueryBuilder } from 'typeorm';
|
||||
|
||||
import { PaginateOptions, PaginateReturn } from '@/modules/database/types';
|
||||
|
||||
export const paginate = async <T extends ObjectLiteral>(
|
||||
qb: SelectQueryBuilder<T>,
|
||||
options: PaginateOptions,
|
||||
): Promise<PaginateReturn<T>> => {
|
||||
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 ? limit : totalItems % limit;
|
||||
const itemCount = page < totalPages ? limit : remainder;
|
||||
return {
|
||||
items,
|
||||
meta: {
|
||||
totalItems,
|
||||
itemCount,
|
||||
perPage: limit,
|
||||
totalPages,
|
||||
currentPage: page,
|
||||
},
|
||||
};
|
||||
};
|
33
typings/global.d.ts
vendored
Normal file
33
typings/global.d.ts
vendored
Normal file
@ -0,0 +1,33 @@
|
||||
declare type RecordAny = Record<string, any>;
|
||||
declare type RecordNever = Record<never, never>;
|
||||
declare type RecordAnyOrNever = RecordAny | RecordNever;
|
||||
|
||||
declare type BaseType = boolean | number | string | undefined | null;
|
||||
|
||||
declare type ParseType<T extends BaseType = string> = (value: string) => T;
|
||||
|
||||
declare type ClassToPlain<T> = { [key in keyof T]: T[key] };
|
||||
|
||||
declare type ClassType<T> = { new (...args: any[]): T };
|
||||
|
||||
declare type RePartial<T> = {
|
||||
[P in keyof T]: T[P] extends (infer U)[] | undefined
|
||||
? RePartial<U>[]
|
||||
: T[P] extends object | undefined
|
||||
? T[P] extends ((...args: any[]) => any) | ClassType<T[P]> | undefined
|
||||
? T[P]
|
||||
: RePartial<T[P]>
|
||||
: T[P];
|
||||
};
|
||||
|
||||
declare type ReRequired<T> = {
|
||||
[P in keyof T]-?: T[P] extends (infer U)[] | undefined
|
||||
? ReRequired<U>[]
|
||||
: T[P] extends object | undefined
|
||||
? T[P] extends ((...args: any[]) => any) | ClassType<T[P]> | undefined
|
||||
? T[P]
|
||||
: ReRequired<T[P]>
|
||||
: T[P];
|
||||
};
|
||||
|
||||
declare type WrapperType<T> = T;
|
Loading…
Reference in New Issue
Block a user