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 = {
|
module.exports = {
|
||||||
parser: '@typescript-eslint/parser',
|
parser: '@typescript-eslint/parser',
|
||||||
parserOptions: {
|
parserOptions: {
|
||||||
project: 'tsconfig.json',
|
project: 'tsconfig.json',
|
||||||
tsconfigRootDir: __dirname,
|
tsconfigRootDir: __dirname,
|
||||||
ecmaVersion: 'latest',
|
ecmaVersion: 'latest',
|
||||||
sourceType: 'module',
|
sourceType: 'module',
|
||||||
},
|
},
|
||||||
root: true,
|
root: true,
|
||||||
env: {
|
env: {
|
||||||
node: true,
|
node: true,
|
||||||
jest: true,
|
jest: true,
|
||||||
},
|
},
|
||||||
plugins: [
|
plugins: ['@typescript-eslint', 'jest', 'prettier', 'import', 'unused-imports'],
|
||||||
'@typescript-eslint',
|
extends: [
|
||||||
'jest',
|
// airbnb规范
|
||||||
'prettier',
|
// https://github.com/airbnb/javascript/tree/master/packages/eslint-config-airbnb
|
||||||
'import',
|
'airbnb-base',
|
||||||
'unused-imports',
|
// 兼容typescript的airbnb规范
|
||||||
],
|
// https://github.com/iamturns/eslint-config-airbnb-typescript
|
||||||
extends: [
|
'airbnb-typescript/base',
|
||||||
// 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插件
|
// typescript的eslint插件
|
||||||
// https://github.com/typescript-eslint/typescript-eslint/blob/master/docs/getting-started/linting/README.md
|
// 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
|
// https://github.com/typescript-eslint/typescript-eslint/tree/master/packages/eslint-plugin
|
||||||
'plugin:@typescript-eslint/recommended',
|
'plugin:@typescript-eslint/recommended',
|
||||||
'plugin:@typescript-eslint/recommended-requiring-type-checking',
|
'plugin:@typescript-eslint/recommended-requiring-type-checking',
|
||||||
|
|
||||||
// 支持jest
|
// 支持jest
|
||||||
'plugin:jest/recommended',
|
'plugin:jest/recommended',
|
||||||
// 使用prettier格式化代码
|
// 使用prettier格式化代码
|
||||||
// https://github.com/prettier/eslint-config-prettier#readme
|
// https://github.com/prettier/eslint-config-prettier#readme
|
||||||
'prettier',
|
'prettier',
|
||||||
// 整合typescript-eslint与prettier
|
// 整合typescript-eslint与prettier
|
||||||
// https://github.com/prettier/eslint-plugin-prettier
|
// https://github.com/prettier/eslint-plugin-prettier
|
||||||
'plugin:prettier/recommended',
|
'plugin:prettier/recommended',
|
||||||
],
|
],
|
||||||
rules: {
|
rules: {
|
||||||
/* ********************************** ES6+ ********************************** */
|
/* ********************************** ES6+ ********************************** */
|
||||||
'no-console': 0,
|
'no-console': 0,
|
||||||
'no-var-requires': 0,
|
'no-var-requires': 0,
|
||||||
'no-restricted-syntax': 0,
|
'no-restricted-syntax': 0,
|
||||||
'no-continue': 0,
|
'no-continue': 0,
|
||||||
'no-await-in-loop': 0,
|
'no-await-in-loop': 0,
|
||||||
'no-return-await': 0,
|
'no-return-await': 0,
|
||||||
'no-unused-vars': 0,
|
'no-unused-vars': 0,
|
||||||
'no-multi-assign': 0,
|
'no-multi-assign': 0,
|
||||||
'no-param-reassign': [2, { props: false }],
|
'no-param-reassign': [2, { props: false }],
|
||||||
'import/prefer-default-export': 0,
|
'import/prefer-default-export': 0,
|
||||||
'import/no-cycle': 0,
|
'import/no-cycle': 0,
|
||||||
'import/no-dynamic-require': 0,
|
'import/no-dynamic-require': 0,
|
||||||
'max-classes-per-file': 0,
|
'max-classes-per-file': 0,
|
||||||
'class-methods-use-this': 0,
|
'class-methods-use-this': 0,
|
||||||
'guard-for-in': 0,
|
'guard-for-in': 0,
|
||||||
'no-underscore-dangle': 0,
|
'no-underscore-dangle': 0,
|
||||||
'no-plusplus': 0,
|
'no-plusplus': 0,
|
||||||
'no-lonely-if': 0,
|
'no-lonely-if': 0,
|
||||||
'no-bitwise': ['error', { allow: ['~'] }],
|
'no-bitwise': ['error', { allow: ['~'] }],
|
||||||
|
|
||||||
/* ********************************** Module Import ********************************** */
|
/* ********************************** Module Import ********************************** */
|
||||||
|
|
||||||
'import/no-absolute-path': 0,
|
'import/no-absolute-path': 0,
|
||||||
'import/extensions': 0,
|
'import/extensions': 0,
|
||||||
'import/no-named-default': 0,
|
'import/no-named-default': 0,
|
||||||
'no-restricted-exports': 0,
|
'no-restricted-exports': 0,
|
||||||
|
|
||||||
// 一部分文件在导入devDependencies的依赖时不报错
|
// 一部分文件在导入devDependencies的依赖时不报错
|
||||||
'import/no-extraneous-dependencies': [
|
'import/no-extraneous-dependencies': [
|
||||||
1,
|
1,
|
||||||
{
|
{
|
||||||
devDependencies: [
|
devDependencies: [
|
||||||
'**/*.test.{ts,js}',
|
'**/*.test.{ts,js}',
|
||||||
'**/*.spec.{ts,js}',
|
'**/*.spec.{ts,js}',
|
||||||
'./test/**.{ts,js}',
|
'./test/**.{ts,js}',
|
||||||
'./scripts/**/*.{ts,js}',
|
'./scripts/**/*.{ts,js}',
|
||||||
|
],
|
||||||
|
},
|
||||||
],
|
],
|
||||||
},
|
// 模块导入顺序规则
|
||||||
],
|
'import/order': [
|
||||||
// 模块导入顺序规则
|
1,
|
||||||
'import/order': [
|
{
|
||||||
1,
|
pathGroups: [
|
||||||
{
|
{
|
||||||
pathGroups: [
|
pattern: '@/**',
|
||||||
{
|
group: 'external',
|
||||||
pattern: '@/**',
|
position: 'after',
|
||||||
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',
|
// https://github.com/sweepline/eslint-plugin-unused-imports
|
||||||
warnOnUnassignedImports: true,
|
'unused-imports/no-unused-imports': 1,
|
||||||
},
|
'unused-imports/no-unused-vars': [
|
||||||
],
|
'warn',
|
||||||
// 自动删除未使用的导入
|
{
|
||||||
// https://github.com/sweepline/eslint-plugin-unused-imports
|
vars: 'all',
|
||||||
'unused-imports/no-unused-imports': 1,
|
args: 'none',
|
||||||
'unused-imports/no-unused-vars': [
|
ignoreRestSiblings: true,
|
||||||
'error',
|
},
|
||||||
{
|
],
|
||||||
vars: 'all',
|
/* ********************************** Typescript ********************************** */
|
||||||
args: 'none',
|
'@typescript-eslint/no-unused-vars': 0,
|
||||||
ignoreRestSiblings: true,
|
'@typescript-eslint/no-empty-interface': 0,
|
||||||
},
|
'@typescript-eslint/no-this-alias': 0,
|
||||||
],
|
'@typescript-eslint/no-var-requires': 0,
|
||||||
/* ********************************** Typescript ********************************** */
|
'@typescript-eslint/no-use-before-define': 0,
|
||||||
'@typescript-eslint/no-unused-vars': 0,
|
'@typescript-eslint/explicit-member-accessibility': 0,
|
||||||
'@typescript-eslint/no-empty-interface': 0,
|
'@typescript-eslint/no-non-null-assertion': 0,
|
||||||
'@typescript-eslint/no-this-alias': 0,
|
'@typescript-eslint/no-unnecessary-type-assertion': 0,
|
||||||
'@typescript-eslint/no-var-requires': 0,
|
'@typescript-eslint/require-await': 0,
|
||||||
'@typescript-eslint/no-use-before-define': 0,
|
'@typescript-eslint/no-for-in-array': 0,
|
||||||
'@typescript-eslint/explicit-member-accessibility': 0,
|
'@typescript-eslint/interface-name-prefix': 0,
|
||||||
'@typescript-eslint/no-non-null-assertion': 0,
|
'@typescript-eslint/explicit-function-return-type': 0,
|
||||||
'@typescript-eslint/no-unnecessary-type-assertion': 0,
|
'@typescript-eslint/no-explicit-any': 0,
|
||||||
'@typescript-eslint/require-await': 0,
|
'@typescript-eslint/explicit-module-boundary-types': 0,
|
||||||
'@typescript-eslint/no-for-in-array': 0,
|
'@typescript-eslint/no-floating-promises': 0,
|
||||||
'@typescript-eslint/interface-name-prefix': 0,
|
'@typescript-eslint/restrict-template-expressions': 0,
|
||||||
'@typescript-eslint/explicit-function-return-type': 0,
|
'@typescript-eslint/no-unsafe-assignment': 0,
|
||||||
'@typescript-eslint/no-explicit-any': 0,
|
'@typescript-eslint/no-unsafe-return': 0,
|
||||||
'@typescript-eslint/explicit-module-boundary-types': 0,
|
'@typescript-eslint/no-unused-expressions': 0,
|
||||||
'@typescript-eslint/no-floating-promises': 0,
|
'@typescript-eslint/no-misused-promises': 0,
|
||||||
'@typescript-eslint/restrict-template-expressions': 0,
|
'@typescript-eslint/no-unsafe-member-access': 0,
|
||||||
'@typescript-eslint/no-unsafe-assignment': 0,
|
'@typescript-eslint/no-unsafe-call': 0,
|
||||||
'@typescript-eslint/no-unsafe-return': 0,
|
'@typescript-eslint/no-unsafe-argument': 0,
|
||||||
'@typescript-eslint/no-unused-expressions': 0,
|
'@typescript-eslint/ban-ts-comment': 0,
|
||||||
'@typescript-eslint/no-misused-promises': 0,
|
'@typescript-eslint/lines-between-class-members': 0,
|
||||||
'@typescript-eslint/no-unsafe-member-access': 0,
|
'@typescript-eslint/no-throw-literal': 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: {
|
settings: {
|
||||||
extensions: ['.ts', '.d.ts', '.cts', '.mts', '.js', '.cjs', 'mjs', '.json'],
|
extensions: ['.ts', '.d.ts', '.cts', '.mts', '.js', '.cjs', 'mjs', '.json'],
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
16
package.json
16
package.json
@ -23,10 +23,19 @@
|
|||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@nestjs/common": "^10.0.3",
|
"@nestjs/common": "^10.0.3",
|
||||||
"@nestjs/core": "^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",
|
"reflect-metadata": "^0.1.13",
|
||||||
"rimraf": "^5.0.1",
|
"rimraf": "^5.0.1",
|
||||||
"rxjs": "^7.8.1"
|
"rxjs": "^7.8.1",
|
||||||
|
"sanitize-html": "^2.17.0",
|
||||||
|
"typeorm": "^0.3.24"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@nestjs/cli": "^10.0.3",
|
"@nestjs/cli": "^10.0.3",
|
||||||
@ -34,9 +43,10 @@
|
|||||||
"@nestjs/testing": "^10.0.3",
|
"@nestjs/testing": "^10.0.3",
|
||||||
"@swc/cli": "^0.1.62",
|
"@swc/cli": "^0.1.62",
|
||||||
"@swc/core": "^1.3.66",
|
"@swc/core": "^1.3.66",
|
||||||
"@types/express": "^4.17.17",
|
|
||||||
"@types/jest": "29.5.2",
|
"@types/jest": "29.5.2",
|
||||||
|
"@types/lodash": "^4.17.16",
|
||||||
"@types/node": "^20.3.1",
|
"@types/node": "^20.3.1",
|
||||||
|
"@types/sanitize-html": "^2.16.0",
|
||||||
"@types/supertest": "^2.0.12",
|
"@types/supertest": "^2.0.12",
|
||||||
"@typescript-eslint/eslint-plugin": "^5.60.0",
|
"@typescript-eslint/eslint-plugin": "^5.60.0",
|
||||||
"@typescript-eslint/parser": "^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 { Module } from '@nestjs/common';
|
||||||
|
|
||||||
import { AppController } from './app.controller';
|
import { database } from './config';
|
||||||
import { AppService } from './app.service';
|
|
||||||
|
import { ContentModule } from './modules/content/content.module';
|
||||||
|
import { CoreModule } from './modules/core/core.module';
|
||||||
|
import { DatabaseModule } from './modules/database/database.module';
|
||||||
|
|
||||||
@Module({
|
@Module({
|
||||||
imports: [],
|
imports: [ContentModule, CoreModule.forRoot(), DatabaseModule.forRoot(database)],
|
||||||
controllers: [AppController],
|
|
||||||
providers: [AppService],
|
|
||||||
})
|
})
|
||||||
export class AppModule {}
|
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 { NestFactory } from '@nestjs/core';
|
||||||
|
|
||||||
|
import { FastifyAdapter, NestFastifyApplication } from '@nestjs/platform-fastify';
|
||||||
|
|
||||||
import { AppModule } from './app.module';
|
import { AppModule } from './app.module';
|
||||||
|
|
||||||
async function bootstrap() {
|
async function bootstrap() {
|
||||||
const app = await NestFactory.create(AppModule);
|
const app = await NestFactory.create<NestFastifyApplication>(AppModule, new FastifyAdapter(), {
|
||||||
await app.listen(process.env.PORT ?? 3000);
|
cors: true,
|
||||||
|
logger: ['error', 'warn'],
|
||||||
|
});
|
||||||
|
app.setGlobalPrefix('api');
|
||||||
|
await app.listen(process.env.PORT ?? 3000, () => {
|
||||||
|
console.log('api: http://localhost:3000');
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
bootstrap();
|
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