Compare commits
No commits in common. "03e70436c6a0e68d71dc52194caae9b329c79c39" and "68b06801d354538cb0ee05b3104f7fad84421f3f" have entirely different histories.
03e70436c6
...
68b06801d3
@ -1,17 +1,17 @@
|
|||||||
module.exports = {
|
module.exports = {
|
||||||
singleQuote: true,
|
"singleQuote": true,
|
||||||
trailingComma: 'all',
|
"trailingComma": "all",
|
||||||
printWidth: 100,
|
"printWidth": 100,
|
||||||
proseWrap: 'never',
|
"proseWrap": "never",
|
||||||
endOfLine: 'auto',
|
"endOfLine": "auto",
|
||||||
semi: true,
|
"semi": true,
|
||||||
tabWidth: 4,
|
"tabWidth": 4,
|
||||||
overrides: [
|
"overrides": [
|
||||||
{
|
{
|
||||||
files: '.prettierrc',
|
"files": ".prettierrc",
|
||||||
options: {
|
"options": {
|
||||||
parser: 'json',
|
"parser": "json"
|
||||||
},
|
}
|
||||||
},
|
}
|
||||||
],
|
]
|
||||||
};
|
}
|
3
.vscode/extensions.json
vendored
3
.vscode/extensions.json
vendored
@ -1,3 +0,0 @@
|
|||||||
{
|
|
||||||
"recommendations": []
|
|
||||||
}
|
|
@ -1,12 +0,0 @@
|
|||||||
module.exports = {
|
|
||||||
presets: [['@babel/preset-env', { targets: { node: 'current' } }], '@babel/preset-typescript'],
|
|
||||||
plugins: [
|
|
||||||
['@babel/plugin-proposal-decorators', { legacy: true }],
|
|
||||||
[
|
|
||||||
'@babel/plugin-transform-runtime',
|
|
||||||
{
|
|
||||||
regenerator: true,
|
|
||||||
},
|
|
||||||
],
|
|
||||||
],
|
|
||||||
};
|
|
12
env.example
12
env.example
@ -1,12 +0,0 @@
|
|||||||
# APP_NAME=nestapp
|
|
||||||
# APP_HOST=127.0.0.1
|
|
||||||
# APP_PORT=3000
|
|
||||||
# APP_SSL=false
|
|
||||||
# APP_TIMEZONE=Asia/Shanghai
|
|
||||||
# APP_LOCALE=zh_CN
|
|
||||||
# APP_FALLBACK_LOCALE=en
|
|
||||||
# DB_HOST=127.0.0.1
|
|
||||||
# DB_PORT=3306
|
|
||||||
# DB_USERNAME=3r
|
|
||||||
# DB_PASSWORD=12345678
|
|
||||||
# DB_NAME=3r
|
|
198
eslint.config.js
198
eslint.config.js
@ -1,198 +0,0 @@
|
|||||||
/* eslint-disable import/no-unresolved */
|
|
||||||
/* eslint-disable @typescript-eslint/no-require-imports */
|
|
||||||
/* eslint-disable import/no-extraneous-dependencies */
|
|
||||||
const { FlatCompat } = require('@eslint/eslintrc');
|
|
||||||
const js = require('@eslint/js');
|
|
||||||
const typescriptEslint = require('@typescript-eslint/eslint-plugin');
|
|
||||||
const tsParser = require('@typescript-eslint/parser');
|
|
||||||
const { defineConfig, globalIgnores } = require('eslint/config');
|
|
||||||
|
|
||||||
const jest = require('eslint-plugin-jest');
|
|
||||||
const prettier = require('eslint-plugin-prettier');
|
|
||||||
const unusedImports = require('eslint-plugin-unused-imports');
|
|
||||||
const globals = require('globals');
|
|
||||||
|
|
||||||
const compat = new FlatCompat({
|
|
||||||
baseDirectory: __dirname,
|
|
||||||
recommendedConfig: js.configs.recommended,
|
|
||||||
allConfig: js.configs.all,
|
|
||||||
});
|
|
||||||
|
|
||||||
module.exports = defineConfig([
|
|
||||||
{
|
|
||||||
languageOptions: {
|
|
||||||
parser: tsParser,
|
|
||||||
ecmaVersion: 'latest',
|
|
||||||
sourceType: 'module',
|
|
||||||
|
|
||||||
parserOptions: {
|
|
||||||
project: 'tsconfig.json',
|
|
||||||
tsconfigRootDir: __dirname,
|
|
||||||
},
|
|
||||||
|
|
||||||
globals: {
|
|
||||||
...globals.node,
|
|
||||||
...globals.jest,
|
|
||||||
},
|
|
||||||
},
|
|
||||||
|
|
||||||
plugins: {
|
|
||||||
'@typescript-eslint': typescriptEslint,
|
|
||||||
jest,
|
|
||||||
prettier,
|
|
||||||
'unused-imports': unusedImports,
|
|
||||||
},
|
|
||||||
|
|
||||||
extends: compat.extends(
|
|
||||||
'airbnb-base',
|
|
||||||
'airbnb-typescript/base',
|
|
||||||
'plugin:@typescript-eslint/recommended',
|
|
||||||
'plugin:@typescript-eslint/recommended-requiring-type-checking',
|
|
||||||
'plugin:jest/recommended',
|
|
||||||
'prettier',
|
|
||||||
'plugin:prettier/recommended',
|
|
||||||
),
|
|
||||||
|
|
||||||
rules: {
|
|
||||||
'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: ['~'],
|
|
||||||
},
|
|
||||||
],
|
|
||||||
|
|
||||||
'import/no-absolute-path': 0,
|
|
||||||
'import/extensions': 0,
|
|
||||||
'import/no-named-default': 0,
|
|
||||||
'no-restricted-exports': 0,
|
|
||||||
|
|
||||||
'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',
|
|
||||||
},
|
|
||||||
],
|
|
||||||
|
|
||||||
alphabetize: {
|
|
||||||
order: 'asc',
|
|
||||||
caseInsensitive: false,
|
|
||||||
},
|
|
||||||
|
|
||||||
'newlines-between': 'always-and-inside-groups',
|
|
||||||
warnOnUnassignedImports: true,
|
|
||||||
},
|
|
||||||
],
|
|
||||||
|
|
||||||
'unused-imports/no-unused-imports': 1,
|
|
||||||
|
|
||||||
'unused-imports/no-unused-vars': [
|
|
||||||
'warn',
|
|
||||||
{
|
|
||||||
vars: 'all',
|
|
||||||
args: 'none',
|
|
||||||
ignoreRestSiblings: true,
|
|
||||||
},
|
|
||||||
],
|
|
||||||
|
|
||||||
'@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'],
|
|
||||||
},
|
|
||||||
},
|
|
||||||
globalIgnores([
|
|
||||||
'**/dist',
|
|
||||||
'**/back',
|
|
||||||
'**/node_modules',
|
|
||||||
'**/pnpm-lock.yaml',
|
|
||||||
'**/docker',
|
|
||||||
'**/Dockerfile*',
|
|
||||||
'**/LICENSE',
|
|
||||||
'**/yarn-error.log',
|
|
||||||
'**/.history',
|
|
||||||
'**/.vscode',
|
|
||||||
'**/.docusaurus',
|
|
||||||
'**/.dockerignore',
|
|
||||||
'**/.DS_Store',
|
|
||||||
'**/.eslintignore',
|
|
||||||
'**/.editorconfig',
|
|
||||||
'**/.gitignore',
|
|
||||||
'**/.prettierignore',
|
|
||||||
'**/.eslintcache',
|
|
||||||
'**/*.lock',
|
|
||||||
'**/*.svg',
|
|
||||||
'**/*.md',
|
|
||||||
'**/*.ejs',
|
|
||||||
'**/*.html',
|
|
||||||
'**/*.png',
|
|
||||||
'**/*.toml',
|
|
||||||
]),
|
|
||||||
]);
|
|
@ -5,13 +5,6 @@
|
|||||||
"compilerOptions": {
|
"compilerOptions": {
|
||||||
"deleteOutDir": true,
|
"deleteOutDir": true,
|
||||||
"builder": "swc",
|
"builder": "swc",
|
||||||
"typeCheck": true,
|
"typeCheck": true
|
||||||
"plugins": [{
|
|
||||||
"name": "@nestjs/swagger",
|
|
||||||
"options":{
|
|
||||||
"introspectComments": true,
|
|
||||||
"controllerKeyOfComment": "summary"
|
|
||||||
}
|
|
||||||
}]
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
105
package.json
105
package.json
@ -7,12 +7,12 @@
|
|||||||
"license": "UNLICENSED",
|
"license": "UNLICENSED",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"prebuild": "rimraf dist",
|
"prebuild": "rimraf dist",
|
||||||
"build": "cross-env NODE_ENV=production nest build",
|
"build": "nest build",
|
||||||
"format": "prettier --write \"src/**/*.ts\" \"test/**/*.ts\"",
|
"format": "prettier --write \"src/**/*.ts\" \"test/**/*.ts\"",
|
||||||
"start": "cross-env NODE_ENV=development nest start",
|
"start": "nest start",
|
||||||
"start:dev": "cross-env NODE_ENV=development nest start --watch",
|
"start:dev": "nest start --watch",
|
||||||
"start:debug": "cross-env NODE_ENV=development nest start --debug --watch",
|
"start:debug": "nest start --debug --watch",
|
||||||
"start:prod": "cross-env NODE_ENV=production node dist/main",
|
"start:prod": "node dist/main",
|
||||||
"lint": "eslint \"{src,apps,libs,test}/**/*.ts\" --fix",
|
"lint": "eslint \"{src,apps,libs,test}/**/*.ts\" --fix",
|
||||||
"test": "jest",
|
"test": "jest",
|
||||||
"test:watch": "jest --watch",
|
"test:watch": "jest --watch",
|
||||||
@ -21,77 +21,56 @@
|
|||||||
"test:e2e": "jest --config ./test/jest-e2e.json"
|
"test:e2e": "jest --config ./test/jest-e2e.json"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@fastify/static": "^8.2.0",
|
"@nestjs/common": "^10.0.3",
|
||||||
"@nestjs/common": "^11.1.3",
|
"@nestjs/core": "^10.0.3",
|
||||||
"@nestjs/core": "^11.1.3",
|
"@nestjs/platform-fastify": "^10.0.3",
|
||||||
"@nestjs/platform-fastify": "^11.1.3",
|
"@nestjs/swagger": "^7.4.2",
|
||||||
"@nestjs/swagger": "^11.2.0",
|
|
||||||
"@nestjs/typeorm": "^11.0.0",
|
"@nestjs/typeorm": "^11.0.0",
|
||||||
"chalk": "^5.4.1",
|
|
||||||
"class-transformer": "^0.5.1",
|
"class-transformer": "^0.5.1",
|
||||||
"class-validator": "^0.14.2",
|
"class-validator": "^0.14.2",
|
||||||
"deepmerge": "^4.3.1",
|
"deepmerge": "^4.3.1",
|
||||||
"dotenv": "^16.5.0",
|
|
||||||
"find-up": "^7.0.0",
|
|
||||||
"fs-extra": "^11.3.0",
|
|
||||||
"lodash": "^4.17.21",
|
"lodash": "^4.17.21",
|
||||||
"meilisearch": "^0.51.0",
|
"meilisearch": "^0.50.0",
|
||||||
"mysql2": "^3.14.1",
|
"mysql2": "^3.14.1",
|
||||||
"reflect-metadata": "^0.2.2",
|
"reflect-metadata": "^0.1.13",
|
||||||
"rimraf": "^6.0.1",
|
"rimraf": "^5.0.1",
|
||||||
"rxjs": "^7.8.2",
|
"rxjs": "^7.8.1",
|
||||||
"sanitize-html": "^2.17.0",
|
"sanitize-html": "^2.17.0",
|
||||||
"typeorm": "^0.3.24",
|
"typeorm": "^0.3.24",
|
||||||
"validator": "^13.15.15",
|
"validator": "^13.15.0"
|
||||||
"yaml": "^2.8.0"
|
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@babel/core": "^7.27.4",
|
|
||||||
"@babel/plugin-proposal-decorators": "^7.27.1",
|
|
||||||
"@babel/plugin-transform-runtime": "^7.27.4",
|
|
||||||
"@babel/preset-env": "^7.27.2",
|
|
||||||
"@babel/preset-typescript": "^7.27.1",
|
|
||||||
"@babel/runtime": "^7.27.6",
|
|
||||||
"@eslint/compat": "^1.3.0",
|
|
||||||
"@eslint/eslintrc": "^3.3.1",
|
|
||||||
"@eslint/js": "^9.29.0",
|
|
||||||
"@faker-js/faker": "^9.8.0",
|
"@faker-js/faker": "^9.8.0",
|
||||||
"@nestjs/cli": "^11.0.7",
|
"@nestjs/cli": "^10.0.3",
|
||||||
"@nestjs/schematics": "^11.0.5",
|
"@nestjs/schematics": "^10.0.1",
|
||||||
"@nestjs/testing": "^11.1.3",
|
"@nestjs/testing": "^10.0.3",
|
||||||
"@swc/cli": "^0.7.7",
|
"@swc/cli": "^0.1.62",
|
||||||
"@swc/core": "^1.12.1",
|
"@swc/core": "^1.3.66",
|
||||||
"@types/eslint": "^9.6.1",
|
"@types/jest": "29.5.2",
|
||||||
"@types/fs-extra": "^11.0.4",
|
"@types/lodash": "^4.17.16",
|
||||||
"@types/jest": "29.5.14",
|
"@types/node": "^20.3.1",
|
||||||
"@types/lodash": "^4.17.17",
|
|
||||||
"@types/node": "^24.0.1",
|
|
||||||
"@types/sanitize-html": "^2.16.0",
|
"@types/sanitize-html": "^2.16.0",
|
||||||
"@types/supertest": "^6.0.3",
|
"@types/supertest": "^2.0.12",
|
||||||
"@types/validator": "^13.15.1",
|
"@types/validator": "^13.15.1",
|
||||||
"@typescript-eslint/eslint-plugin": "^8.34.0",
|
"@typescript-eslint/eslint-plugin": "^5.60.0",
|
||||||
"@typescript-eslint/parser": "^8.34.0",
|
"@typescript-eslint/parser": "^5.60.0",
|
||||||
"babel-jest": "^30.0.0",
|
"eslint": "^8.43.0",
|
||||||
"cross-env": "^7.0.3",
|
|
||||||
"eslint": "^9.29.0",
|
|
||||||
"eslint-config-airbnb-base": "^15.0.0",
|
"eslint-config-airbnb-base": "^15.0.0",
|
||||||
"eslint-config-airbnb-typescript": "^18.0.0",
|
"eslint-config-airbnb-typescript": "^17.0.0",
|
||||||
"eslint-config-prettier": "^10.1.5",
|
"eslint-config-prettier": "^8.8.0",
|
||||||
"eslint-plugin-import": "^2.31.0",
|
"eslint-plugin-import": "^2.27.5",
|
||||||
"eslint-plugin-jest": "^28.13.5",
|
"eslint-plugin-jest": "^27.2.2",
|
||||||
"eslint-plugin-prettier": "^5.4.1",
|
"eslint-plugin-prettier": "^4.2.1",
|
||||||
"eslint-plugin-unused-imports": "^4.1.4",
|
"eslint-plugin-unused-imports": "^2.0.0",
|
||||||
"globals": "^16.2.0",
|
"jest": "29.5.0",
|
||||||
"jest": "30.0.0",
|
"prettier": "^2.8.8",
|
||||||
"prettier": "^3.5.3",
|
|
||||||
"source-map-support": "^0.5.21",
|
"source-map-support": "^0.5.21",
|
||||||
"supertest": "^7.1.1",
|
"supertest": "^6.3.3",
|
||||||
"ts-babel": "^6.1.7",
|
"ts-jest": "29.1.0",
|
||||||
"ts-jest": "29.4.0",
|
"ts-loader": "^9.4.3",
|
||||||
"ts-loader": "^9.5.2",
|
"ts-node": "^10.9.1",
|
||||||
"ts-node": "^10.9.2",
|
|
||||||
"tsconfig-paths": "4.2.0",
|
"tsconfig-paths": "4.2.0",
|
||||||
"typescript": "~5.8.3"
|
"typescript": "~5.1.3"
|
||||||
},
|
},
|
||||||
"jest": {
|
"jest": {
|
||||||
"moduleFileExtensions": [
|
"moduleFileExtensions": [
|
||||||
@ -106,16 +85,12 @@
|
|||||||
"<rootDir>/test/**/*.test.ts"
|
"<rootDir>/test/**/*.test.ts"
|
||||||
],
|
],
|
||||||
"transform": {
|
"transform": {
|
||||||
"^.+\\.(js|jsx)$": "babel-jest",
|
"^.+\\.(t|j)s$": "ts-jest"
|
||||||
"^.+\\.(ts|tsx)?$": "ts-jest"
|
|
||||||
},
|
},
|
||||||
"collectCoverageFrom": [
|
"collectCoverageFrom": [
|
||||||
"**/*.(t|j)s"
|
"**/*.(t|j)s"
|
||||||
],
|
],
|
||||||
"coverageDirectory": "../coverage",
|
"coverageDirectory": "../coverage",
|
||||||
"transformIgnorePatterns": [
|
|
||||||
"node_modules/(?!(chalk))"
|
|
||||||
],
|
|
||||||
"testEnvironment": "node"
|
"testEnvironment": "node"
|
||||||
},
|
},
|
||||||
"pnpm": {
|
"pnpm": {
|
||||||
|
6808
pnpm-lock.yaml
6808
pnpm-lock.yaml
File diff suppressed because it is too large
Load Diff
41
src/app.module.ts
Normal file
41
src/app.module.ts
Normal file
@ -0,0 +1,41 @@
|
|||||||
|
import { Module } from '@nestjs/common';
|
||||||
|
|
||||||
|
import { APP_FILTER, APP_INTERCEPTOR, APP_PIPE } from '@nestjs/core';
|
||||||
|
|
||||||
|
import { AppInterceptor } from '@/modules/core/providers/app.interceptor';
|
||||||
|
|
||||||
|
import { MEILI_CONFIG } from '@/modules/meilisearch/meili.config';
|
||||||
|
import { MeiliModule } from '@/modules/meilisearch/meili.module';
|
||||||
|
|
||||||
|
import { content, database } from './config';
|
||||||
|
|
||||||
|
import { DEFAULT_VALIDATION_CONFIG } from './modules/content/constants';
|
||||||
|
import { ContentModule } from './modules/content/content.module';
|
||||||
|
import { CoreModule } from './modules/core/core.module';
|
||||||
|
import { AppFilter } from './modules/core/providers/app.filter';
|
||||||
|
import { AppPipe } from './modules/core/providers/app.pipe';
|
||||||
|
import { DatabaseModule } from './modules/database/database.module';
|
||||||
|
|
||||||
|
@Module({
|
||||||
|
imports: [
|
||||||
|
ContentModule.forRoot(content),
|
||||||
|
CoreModule.forRoot(),
|
||||||
|
DatabaseModule.forRoot(database),
|
||||||
|
MeiliModule.forRoot(MEILI_CONFIG),
|
||||||
|
],
|
||||||
|
providers: [
|
||||||
|
{
|
||||||
|
provide: APP_PIPE,
|
||||||
|
useValue: new AppPipe(DEFAULT_VALIDATION_CONFIG),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
provide: APP_INTERCEPTOR,
|
||||||
|
useClass: AppInterceptor,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
provide: APP_FILTER,
|
||||||
|
useClass: AppFilter,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
})
|
||||||
|
export class AppModule {}
|
@ -1,43 +0,0 @@
|
|||||||
import { Configure } from '@/modules/config/configure';
|
|
||||||
import { ConfigureFactory } from '@/modules/config/types';
|
|
||||||
import * as contentControllers from '@/modules/content/controllers';
|
|
||||||
import { ApiConfig, VersionOption } from '@/modules/restful/types';
|
|
||||||
|
|
||||||
export const v1 = async (configure: Configure): Promise<VersionOption> => {
|
|
||||||
return {
|
|
||||||
routes: [
|
|
||||||
{
|
|
||||||
name: 'app',
|
|
||||||
path: '/',
|
|
||||||
controllers: [],
|
|
||||||
doc: {
|
|
||||||
description: 'app name desc',
|
|
||||||
tags: [
|
|
||||||
{ name: '分类操作', description: '对分类进行CRUD操作' },
|
|
||||||
{ name: '标签操作', description: '对标签进行CRUD操作' },
|
|
||||||
{ name: '文章操作', description: '对文章进行CRUD操作' },
|
|
||||||
{ name: '评论操作', description: '对评论进行CRUD操作' },
|
|
||||||
],
|
|
||||||
},
|
|
||||||
children: [
|
|
||||||
{
|
|
||||||
name: 'app.content',
|
|
||||||
path: 'content',
|
|
||||||
controllers: Object.values(contentControllers),
|
|
||||||
},
|
|
||||||
],
|
|
||||||
},
|
|
||||||
],
|
|
||||||
};
|
|
||||||
};
|
|
||||||
|
|
||||||
export const api: ConfigureFactory<ApiConfig> = {
|
|
||||||
register: async (configure: Configure) => ({
|
|
||||||
title: configure.env.get('API_TITLE', `${await configure.get<string>('app.name')} API`),
|
|
||||||
auth: true,
|
|
||||||
docuri: 'api/docs',
|
|
||||||
default: configure.env.get('API_DEFAULT_VERSION', 'v1'),
|
|
||||||
enabled: [],
|
|
||||||
versions: { v1: await v1(configure) },
|
|
||||||
}),
|
|
||||||
};
|
|
@ -1,8 +0,0 @@
|
|||||||
import { toNumber } from 'lodash';
|
|
||||||
|
|
||||||
import { createAppConfig } from '@/modules/core/config';
|
|
||||||
|
|
||||||
export const app = createAppConfig((configure) => ({
|
|
||||||
port: configure.env.get<number>('APP_PORT', (v) => toNumber(v), 3000),
|
|
||||||
prefix: 'api',
|
|
||||||
}));
|
|
@ -1,6 +1,5 @@
|
|||||||
import { createContentConfig } from '@/modules/content/config';
|
import { ContentConfig } from '@/modules/content/types';
|
||||||
|
|
||||||
export const content = createContentConfig(() => ({
|
export const content = (): ContentConfig => ({
|
||||||
searchType: 'meili',
|
SearchType: 'meili',
|
||||||
htmlEnabled: false,
|
});
|
||||||
}));
|
|
||||||
|
@ -1,17 +1,15 @@
|
|||||||
import { toNumber } from 'lodash';
|
import { TypeOrmModuleOptions } from '@nestjs/typeorm';
|
||||||
|
|
||||||
import { createDBConfig } from '@/modules/database/config';
|
export const database = (): TypeOrmModuleOptions => ({
|
||||||
|
charset: 'utf8mb4',
|
||||||
export const database = createDBConfig((configure) => ({
|
logging: ['error'],
|
||||||
common: { synchronize: true },
|
type: 'mysql',
|
||||||
connections: [
|
host: '192.168.50.26',
|
||||||
{
|
port: 3306,
|
||||||
type: 'mysql',
|
username: '3r',
|
||||||
host: configure.env.get('DB_HOST', '192.168.50.26'),
|
password: '12345678',
|
||||||
port: configure.env.get<number>('DB_PORT', (v) => toNumber(v), 3306),
|
database: '3r',
|
||||||
username: configure.env.get('DB_USERNAME', '3r'),
|
synchronize: true,
|
||||||
password: configure.env.get('DB_PASSWORD', '12345678'),
|
autoLoadEntities: true,
|
||||||
database: configure.env.get('DB_NAME', '3r'),
|
timezone: '+08:00',
|
||||||
},
|
});
|
||||||
],
|
|
||||||
}));
|
|
||||||
|
@ -1,5 +1,2 @@
|
|||||||
export * from './database.config';
|
export * from './database.config';
|
||||||
export * from './content.config';
|
export * from './content.config';
|
||||||
export * from './app.config';
|
|
||||||
export * from './meili.config';
|
|
||||||
export * from './api.config';
|
|
||||||
|
@ -1,8 +0,0 @@
|
|||||||
import { createMeiliConfig } from '../modules/meilisearch/config';
|
|
||||||
|
|
||||||
export const meili = createMeiliConfig((configure) => [
|
|
||||||
{
|
|
||||||
name: 'default',
|
|
||||||
host: 'http://192.168.50.26:7700',
|
|
||||||
},
|
|
||||||
]);
|
|
24
src/main.ts
24
src/main.ts
@ -1,7 +1,21 @@
|
|||||||
import 'reflect-metadata';
|
import { NestFactory } from '@nestjs/core';
|
||||||
|
|
||||||
import { createApp, startApp } from './modules/core/helpers/app';
|
import { FastifyAdapter, NestFastifyApplication } from '@nestjs/platform-fastify';
|
||||||
import { listened } from './modules/restful/utils';
|
|
||||||
import { createOptions } from './options';
|
|
||||||
|
|
||||||
startApp(createApp(createOptions), listened);
|
import { useContainer } from 'class-validator';
|
||||||
|
|
||||||
|
import { AppModule } from './app.module';
|
||||||
|
|
||||||
|
async function bootstrap() {
|
||||||
|
const app = await NestFactory.create<NestFastifyApplication>(AppModule, new FastifyAdapter(), {
|
||||||
|
cors: true,
|
||||||
|
logger: ['error', 'warn'],
|
||||||
|
});
|
||||||
|
app.setGlobalPrefix('api');
|
||||||
|
useContainer(app.select(AppModule), { fallbackOnErrors: true });
|
||||||
|
await app.listen(process.env.PORT ?? 3000, () => {
|
||||||
|
console.log('api: http://localhost:3000');
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
bootstrap();
|
||||||
|
@ -1,53 +0,0 @@
|
|||||||
import { resolve } from 'path';
|
|
||||||
|
|
||||||
import { ensureFileSync, readFileSync, writeFileSync } from 'fs-extra';
|
|
||||||
import { has, isNil, omit, set } from 'lodash';
|
|
||||||
import { parse } from 'yaml';
|
|
||||||
|
|
||||||
export class ConfigStorage {
|
|
||||||
protected _enabled = false;
|
|
||||||
|
|
||||||
protected _path = resolve(__dirname, '../../..', 'config.yaml');
|
|
||||||
|
|
||||||
protected _config: RecordAny = {};
|
|
||||||
|
|
||||||
get enabled() {
|
|
||||||
return this._enabled;
|
|
||||||
}
|
|
||||||
|
|
||||||
get path() {
|
|
||||||
return this._path;
|
|
||||||
}
|
|
||||||
|
|
||||||
get config() {
|
|
||||||
return this._config;
|
|
||||||
}
|
|
||||||
|
|
||||||
constructor(enabled?: boolean, filePath?: string) {
|
|
||||||
if (!isNil(enabled)) {
|
|
||||||
this._enabled = enabled;
|
|
||||||
}
|
|
||||||
if (this._enabled) {
|
|
||||||
if (!isNil(filePath)) {
|
|
||||||
this._path = filePath;
|
|
||||||
}
|
|
||||||
ensureFileSync(this._path);
|
|
||||||
const config = parse(readFileSync(this._path, 'utf-8'));
|
|
||||||
this._config = isNil(config) ? {} : config;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
set<T>(key: string, value: T) {
|
|
||||||
ensureFileSync(this.path);
|
|
||||||
set(this._config, key, value);
|
|
||||||
writeFileSync(this.path, JSON.stringify(this._config, null, 4));
|
|
||||||
}
|
|
||||||
|
|
||||||
remove(key: string) {
|
|
||||||
this._config = omit(this._config, [key]);
|
|
||||||
if (has(this._config, key)) {
|
|
||||||
omit(this._config, [key]);
|
|
||||||
}
|
|
||||||
writeFileSync(this.path, JSON.stringify(this._config, null, 4));
|
|
||||||
}
|
|
||||||
}
|
|
@ -1,20 +0,0 @@
|
|||||||
import { DynamicModule, Module } from '@nestjs/common';
|
|
||||||
|
|
||||||
import { Configure } from './configure';
|
|
||||||
|
|
||||||
@Module({})
|
|
||||||
export class ConfigModule {
|
|
||||||
static forRoot(configure: Configure): DynamicModule {
|
|
||||||
return {
|
|
||||||
global: true,
|
|
||||||
module: ConfigModule,
|
|
||||||
providers: [
|
|
||||||
{
|
|
||||||
provide: Configure,
|
|
||||||
useValue: configure,
|
|
||||||
},
|
|
||||||
],
|
|
||||||
exports: [Configure],
|
|
||||||
};
|
|
||||||
}
|
|
||||||
}
|
|
@ -1,144 +0,0 @@
|
|||||||
import { get, has, isArray, isFunction, isNil, isObject, omit, set } from 'lodash';
|
|
||||||
|
|
||||||
import { deepMerge, isAsyncFunction } from '../core/helpers';
|
|
||||||
|
|
||||||
import { ConfigStorage } from './ConfigStorage';
|
|
||||||
import { Env } from './env';
|
|
||||||
import { ConfigStorageOption, ConfigureFactory, ConfigureRegister } from './types';
|
|
||||||
|
|
||||||
interface SetStorageOption {
|
|
||||||
enabled?: boolean;
|
|
||||||
change?: boolean;
|
|
||||||
}
|
|
||||||
|
|
||||||
export class Configure {
|
|
||||||
protected inited = false;
|
|
||||||
|
|
||||||
protected factories: Record<string, ConfigureFactory<RecordAny>> = {};
|
|
||||||
|
|
||||||
protected config: RecordAny = {};
|
|
||||||
|
|
||||||
protected _env: Env;
|
|
||||||
|
|
||||||
protected storage: ConfigStorage;
|
|
||||||
|
|
||||||
get env() {
|
|
||||||
return this._env;
|
|
||||||
}
|
|
||||||
|
|
||||||
all() {
|
|
||||||
return this.config;
|
|
||||||
}
|
|
||||||
|
|
||||||
has(key: string) {
|
|
||||||
return has(this.config, key);
|
|
||||||
}
|
|
||||||
|
|
||||||
async initialize(configs: RecordAny = {}, options: ConfigStorageOption = {}) {
|
|
||||||
if (this.inited) {
|
|
||||||
return this;
|
|
||||||
}
|
|
||||||
this._env = new Env();
|
|
||||||
await this._env.load();
|
|
||||||
const { enable, filePath } = options;
|
|
||||||
this.storage = new ConfigStorage(enable, filePath);
|
|
||||||
for (const key of Object.keys(configs)) {
|
|
||||||
this.add(key, configs[key]);
|
|
||||||
}
|
|
||||||
await this.sync();
|
|
||||||
this.inited = true;
|
|
||||||
return this;
|
|
||||||
}
|
|
||||||
|
|
||||||
async get<T>(key: string, defaultValue?: T): Promise<T> {
|
|
||||||
if (!has(this.config, key) && defaultValue === undefined && has(this.factories, key)) {
|
|
||||||
await this.syncFactory(key);
|
|
||||||
return this.get(key, defaultValue);
|
|
||||||
}
|
|
||||||
return get(this.config, key, defaultValue);
|
|
||||||
}
|
|
||||||
|
|
||||||
set<T>(key: string, value: T, storage: SetStorageOption | boolean = false, append = false) {
|
|
||||||
const storageEnable = typeof storage === 'boolean' ? storage : !!storage.enabled;
|
|
||||||
const storageChange = typeof storage === 'boolean' ? false : !!storage.change;
|
|
||||||
if (storageEnable && this.storage.enabled) {
|
|
||||||
this.changeStorageValue(key, value, storageChange, append);
|
|
||||||
} else {
|
|
||||||
set(this.config, key, value);
|
|
||||||
}
|
|
||||||
return this;
|
|
||||||
}
|
|
||||||
|
|
||||||
async sync(name?: string) {
|
|
||||||
if (isNil(name)) {
|
|
||||||
for (const key in this.factories) {
|
|
||||||
await this.syncFactory(key);
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
await this.syncFactory(name);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
protected async syncFactory(key: string) {
|
|
||||||
if (has(this.config, key) || !has(this.factories, key)) {
|
|
||||||
return this;
|
|
||||||
}
|
|
||||||
const { register, defaultRegister, storage, hook, append } = this.factories[key];
|
|
||||||
let defaultValue = {};
|
|
||||||
let value = isAsyncFunction(register) ? await register(this) : register(this);
|
|
||||||
if (!isNil(defaultRegister)) {
|
|
||||||
defaultValue = isAsyncFunction(defaultRegister)
|
|
||||||
? await defaultRegister(this)
|
|
||||||
: defaultRegister(this);
|
|
||||||
value = deepMerge(defaultValue, value, 'replace');
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!isNil(hook)) {
|
|
||||||
value = isAsyncFunction(hook) ? await hook(this, value) : hook(this, value);
|
|
||||||
}
|
|
||||||
if (this.storage.enabled) {
|
|
||||||
value = deepMerge(value, get(this.storage.config, key, isArray(value) ? [] : {}));
|
|
||||||
}
|
|
||||||
this.set(key, value, storage && isNil(await this.get(key, null)), append);
|
|
||||||
return this;
|
|
||||||
}
|
|
||||||
|
|
||||||
add<T extends RecordAny>(key: string, register: ConfigureFactory<T> | ConfigureRegister<T>) {
|
|
||||||
if (!isFunction(register) && 'register' in register) {
|
|
||||||
this.factories[key] = register as any;
|
|
||||||
} else if (isFunction(register)) {
|
|
||||||
this.factories[key] = { register };
|
|
||||||
}
|
|
||||||
return this;
|
|
||||||
}
|
|
||||||
|
|
||||||
remove(key: string) {
|
|
||||||
if (this.storage.enabled && has(this.storage.config, key)) {
|
|
||||||
this.storage.remove(key);
|
|
||||||
this.config = deepMerge(this.config, this.storage.config, 'replace');
|
|
||||||
} else if (has(this.config, key)) {
|
|
||||||
this.config = omit(this.config, [key]);
|
|
||||||
}
|
|
||||||
return this;
|
|
||||||
}
|
|
||||||
|
|
||||||
async store(key: string, change = false, append = false) {
|
|
||||||
if (!this.storage.enabled) {
|
|
||||||
throw new Error('Must enable storage first');
|
|
||||||
}
|
|
||||||
this.changeStorageValue(key, await this.get(key, null), change, append);
|
|
||||||
return this;
|
|
||||||
}
|
|
||||||
|
|
||||||
protected changeStorageValue<T>(key: string, value: T, change = false, append = false) {
|
|
||||||
if (change || !has(this.storage.config, key)) {
|
|
||||||
this.storage.set(key, value);
|
|
||||||
} else if (isObject(get(this.storage.config, key))) {
|
|
||||||
this.storage.set(
|
|
||||||
key,
|
|
||||||
deepMerge(value, get(this.storage.config, key), append ? 'merge' : 'replace'),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
this.config = deepMerge(this.config, this.storage.config, append ? 'merge' : 'replace');
|
|
||||||
}
|
|
||||||
}
|
|
@ -1,8 +0,0 @@
|
|||||||
export enum EnvironmentType {
|
|
||||||
DEVELOPMENT = 'development',
|
|
||||||
DEV = 'dev',
|
|
||||||
PRODUCTION = 'production',
|
|
||||||
PROD = 'prod',
|
|
||||||
TEST = 'test',
|
|
||||||
PREVIEW = 'preview',
|
|
||||||
}
|
|
@ -1,78 +0,0 @@
|
|||||||
import dotenv from 'dotenv';
|
|
||||||
import { findUpSync } from 'find-up';
|
|
||||||
import { readFileSync } from 'fs-extra';
|
|
||||||
import { isFunction, isNil } from 'lodash';
|
|
||||||
|
|
||||||
import { EnvironmentType } from '@/modules/config/constants';
|
|
||||||
|
|
||||||
export class Env {
|
|
||||||
async load() {
|
|
||||||
if (isNil(process.env.NODE_ENV)) {
|
|
||||||
process.env.NODE_ENV = EnvironmentType.DEVELOPMENT;
|
|
||||||
}
|
|
||||||
const envs = [findUpSync(['.env'])];
|
|
||||||
if (this.isDev()) {
|
|
||||||
envs.push(
|
|
||||||
findUpSync([`.env.${EnvironmentType.DEVELOPMENT}`, `.env.${EnvironmentType.DEV}`]),
|
|
||||||
);
|
|
||||||
} else if (this.isProd()) {
|
|
||||||
envs.push(
|
|
||||||
findUpSync([`.env.${EnvironmentType.PRODUCTION}`, `.env.${EnvironmentType.PROD}`]),
|
|
||||||
);
|
|
||||||
} else {
|
|
||||||
envs.push(findUpSync([`.env.${this.run()}`]));
|
|
||||||
}
|
|
||||||
|
|
||||||
const envFiles = envs.filter((file) => !isNil(file)) as string[];
|
|
||||||
const fileEnvs = envFiles
|
|
||||||
.map((file) => dotenv.parse(readFileSync(file)))
|
|
||||||
.reduce((o, n) => ({ ...o, ...n }), {});
|
|
||||||
const envConfig = { ...process.env, ...fileEnvs };
|
|
||||||
const envKeys = Object.keys(envConfig).filter((key) => !(key in process.env));
|
|
||||||
envKeys.forEach((key) => {
|
|
||||||
process.env[key] = envConfig[key];
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
run() {
|
|
||||||
return process.env.NODE_ENV as EnvironmentType & RecordAny;
|
|
||||||
}
|
|
||||||
|
|
||||||
isProd() {
|
|
||||||
return this.run() === EnvironmentType.PRODUCTION || this.run() === EnvironmentType.PROD;
|
|
||||||
}
|
|
||||||
|
|
||||||
isDev() {
|
|
||||||
return this.run() === EnvironmentType.DEVELOPMENT || this.run() === EnvironmentType.DEV;
|
|
||||||
}
|
|
||||||
|
|
||||||
get(): { [key: string]: string };
|
|
||||||
|
|
||||||
get<T extends BaseType = string>(key: string): T;
|
|
||||||
|
|
||||||
get<T extends BaseType = string>(key: string, parseTo?: ParseType<T>): T;
|
|
||||||
|
|
||||||
get<T extends BaseType = string>(key: string, defaultValue?: T): T;
|
|
||||||
|
|
||||||
get<T extends BaseType = string>(key: string, parseTo?: ParseType<T>, defaultValue?: T): T;
|
|
||||||
|
|
||||||
get<T extends BaseType = string>(key?: string, parseTo?: ParseType<T> | T, defaultValue?: T) {
|
|
||||||
if (!key) {
|
|
||||||
return process.env;
|
|
||||||
}
|
|
||||||
const value = process.env[key];
|
|
||||||
if (value !== undefined) {
|
|
||||||
if (parseTo && isFunction(parseTo)) {
|
|
||||||
return parseTo(value);
|
|
||||||
}
|
|
||||||
return value as T;
|
|
||||||
}
|
|
||||||
if (parseTo === undefined && defaultValue === undefined) {
|
|
||||||
return undefined;
|
|
||||||
}
|
|
||||||
if (parseTo && defaultValue === undefined) {
|
|
||||||
return isFunction(parseTo) ? undefined : parseTo;
|
|
||||||
}
|
|
||||||
return defaultValue! as T;
|
|
||||||
}
|
|
||||||
}
|
|
@ -1,24 +0,0 @@
|
|||||||
import { Configure } from './configure';
|
|
||||||
|
|
||||||
export interface ConfigStorageOption {
|
|
||||||
enable?: boolean;
|
|
||||||
filePath?: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
export type ConfigureRegister<T extends RecordAny> = (configure: Configure) => T | Promise<T>;
|
|
||||||
|
|
||||||
export interface ConfigureFactory<T extends RecordAny, P extends RecordAny = T> {
|
|
||||||
register: ConfigureRegister<RePartial<T>>;
|
|
||||||
|
|
||||||
defaultRegister?: ConfigureRegister<T>;
|
|
||||||
|
|
||||||
storage?: boolean;
|
|
||||||
|
|
||||||
hook?: (configure: Configure, value: T) => P | Promise<P>;
|
|
||||||
|
|
||||||
append?: boolean;
|
|
||||||
}
|
|
||||||
|
|
||||||
export type ConnectionOption<T extends RecordAny> = { name?: string } & T;
|
|
||||||
|
|
||||||
export type ConnectionRst<T extends RecordAny> = Array<{ name?: string } & T>;
|
|
@ -1,27 +0,0 @@
|
|||||||
import { isNil } from 'lodash';
|
|
||||||
|
|
||||||
import { ConnectionOption, ConnectionRst } from './types';
|
|
||||||
|
|
||||||
export const createConnectionOptions = <T extends RecordAny>(
|
|
||||||
config: ConnectionOption<T> | ConnectionOption<T>[],
|
|
||||||
) => {
|
|
||||||
const options = (
|
|
||||||
Array.isArray(config) ? config : [{ ...config, name: 'default' }]
|
|
||||||
) as ConnectionRst<T>;
|
|
||||||
|
|
||||||
if (options.length <= 0) {
|
|
||||||
return undefined;
|
|
||||||
}
|
|
||||||
|
|
||||||
const names = options.map(({ name }) => name);
|
|
||||||
if (!names.includes('default')) {
|
|
||||||
options[0].name = 'default';
|
|
||||||
}
|
|
||||||
|
|
||||||
return options
|
|
||||||
.filter(({ name }) => !isNil(name))
|
|
||||||
.reduce((o, n) => {
|
|
||||||
const oldNames = o.map(({ name }) => name) as string[];
|
|
||||||
return oldNames.includes(n.name) ? o : [...o, n];
|
|
||||||
}, []);
|
|
||||||
};
|
|
@ -1,12 +0,0 @@
|
|||||||
import { ConfigureFactory, ConfigureRegister } from '../config/types';
|
|
||||||
|
|
||||||
import { ContentConfig } from './types';
|
|
||||||
|
|
||||||
export const defauleContentConfig: ContentConfig = { searchType: 'mysql', htmlEnabled: false };
|
|
||||||
|
|
||||||
export const createContentConfig: (
|
|
||||||
register: ConfigureRegister<RePartial<ContentConfig>>,
|
|
||||||
) => ConfigureFactory<ContentConfig> = (register) => ({
|
|
||||||
register,
|
|
||||||
defaultRegister: () => defauleContentConfig,
|
|
||||||
});
|
|
@ -2,6 +2,7 @@ import { DynamicModule, Module, ModuleMetadata } from '@nestjs/common';
|
|||||||
|
|
||||||
import { TypeOrmModule } from '@nestjs/typeorm';
|
import { TypeOrmModule } from '@nestjs/typeorm';
|
||||||
|
|
||||||
|
import * as controllers from '@/modules/content/controllers';
|
||||||
import * as entities from '@/modules/content/entities';
|
import * as entities from '@/modules/content/entities';
|
||||||
import * as repositories from '@/modules/content/repositories';
|
import * as repositories from '@/modules/content/repositories';
|
||||||
import * as services from '@/modules/content/services';
|
import * as services from '@/modules/content/services';
|
||||||
@ -10,19 +11,19 @@ import { SanitizeService } from '@/modules/content/services/SanitizeService';
|
|||||||
|
|
||||||
import { PostService } from '@/modules/content/services/post.service';
|
import { PostService } from '@/modules/content/services/post.service';
|
||||||
import { PostSubscriber } from '@/modules/content/subscribers/post.subscriber';
|
import { PostSubscriber } from '@/modules/content/subscribers/post.subscriber';
|
||||||
|
import { ContentConfig } from '@/modules/content/types';
|
||||||
import { DatabaseModule } from '@/modules/database/database.module';
|
import { DatabaseModule } from '@/modules/database/database.module';
|
||||||
|
|
||||||
import { Configure } from '../config/configure';
|
|
||||||
|
|
||||||
import { defauleContentConfig } from './config';
|
|
||||||
import { ContentConfig } from './types';
|
|
||||||
|
|
||||||
@Module({})
|
@Module({})
|
||||||
export class ContentModule {
|
export class ContentModule {
|
||||||
static async forRoot(configure: Configure): Promise<DynamicModule> {
|
static forRoot(configRegister?: () => ContentConfig): DynamicModule {
|
||||||
const config = await configure.get<ContentConfig>('content', defauleContentConfig);
|
const config: Required<ContentConfig> = {
|
||||||
|
SearchType: 'mysql',
|
||||||
|
...(configRegister ? configRegister() : {}),
|
||||||
|
};
|
||||||
const providers: ModuleMetadata['providers'] = [
|
const providers: ModuleMetadata['providers'] = [
|
||||||
...Object.values(services),
|
...Object.values(services),
|
||||||
|
SanitizeService,
|
||||||
PostSubscriber,
|
PostSubscriber,
|
||||||
{
|
{
|
||||||
provide: PostService,
|
provide: PostService,
|
||||||
@ -46,23 +47,13 @@ export class ContentModule {
|
|||||||
categoryService,
|
categoryService,
|
||||||
tagRepository,
|
tagRepository,
|
||||||
searchService,
|
searchService,
|
||||||
config.searchType,
|
config.SearchType,
|
||||||
);
|
);
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
];
|
];
|
||||||
const exports: ModuleMetadata['exports'] = [
|
if (config.SearchType === 'meili') {
|
||||||
...Object.values(services),
|
|
||||||
PostService,
|
|
||||||
DatabaseModule.forRepository(Object.values(repositories)),
|
|
||||||
];
|
|
||||||
if (config.searchType === 'meili') {
|
|
||||||
providers.push(services.SearchService);
|
providers.push(services.SearchService);
|
||||||
exports.push(SearchService);
|
|
||||||
}
|
|
||||||
if (config.htmlEnabled) {
|
|
||||||
providers.push(SanitizeService);
|
|
||||||
exports.push(SanitizeService);
|
|
||||||
}
|
}
|
||||||
return {
|
return {
|
||||||
module: ContentModule,
|
module: ContentModule,
|
||||||
@ -70,8 +61,13 @@ export class ContentModule {
|
|||||||
TypeOrmModule.forFeature(Object.values(entities)),
|
TypeOrmModule.forFeature(Object.values(entities)),
|
||||||
DatabaseModule.forRepository(Object.values(repositories)),
|
DatabaseModule.forRepository(Object.values(repositories)),
|
||||||
],
|
],
|
||||||
|
controllers: Object.values(controllers),
|
||||||
providers,
|
providers,
|
||||||
exports,
|
exports: [
|
||||||
|
...Object.values(services),
|
||||||
|
PostService,
|
||||||
|
DatabaseModule.forRepository(Object.values(repositories)),
|
||||||
|
],
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@ -11,33 +11,19 @@ import {
|
|||||||
SerializeOptions,
|
SerializeOptions,
|
||||||
} from '@nestjs/common';
|
} from '@nestjs/common';
|
||||||
|
|
||||||
import { ApiTags } from '@nestjs/swagger';
|
|
||||||
|
|
||||||
import { Depends } from '@/modules/restful/decorators/depend.decorator';
|
|
||||||
|
|
||||||
import { ContentModule } from '../content.module';
|
|
||||||
import { CreateCategoryDto, QueryCategoryDto, UpdateCategoryDto } from '../dtos/category.dto';
|
import { CreateCategoryDto, QueryCategoryDto, UpdateCategoryDto } from '../dtos/category.dto';
|
||||||
import { CategoryService } from '../services';
|
import { CategoryService } from '../services';
|
||||||
|
|
||||||
@ApiTags('Category Operate')
|
|
||||||
@Depends(ContentModule)
|
|
||||||
@Controller('category')
|
@Controller('category')
|
||||||
export class CategoryController {
|
export class CategoryController {
|
||||||
constructor(protected service: CategoryService) {}
|
constructor(protected service: CategoryService) {}
|
||||||
|
|
||||||
/**
|
|
||||||
* Search category tree
|
|
||||||
*/
|
|
||||||
@Get('tree')
|
@Get('tree')
|
||||||
@SerializeOptions({ groups: ['category-tree'] })
|
@SerializeOptions({ groups: ['category-tree'] })
|
||||||
async tree() {
|
async tree() {
|
||||||
return this.service.findTrees();
|
return this.service.findTrees();
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* 分页查询分类列表
|
|
||||||
* @param options
|
|
||||||
*/
|
|
||||||
@Get()
|
@Get()
|
||||||
@SerializeOptions({ groups: ['category-list'] })
|
@SerializeOptions({ groups: ['category-list'] })
|
||||||
async list(
|
async list(
|
||||||
@ -47,10 +33,6 @@ export class CategoryController {
|
|||||||
return this.service.paginate(options);
|
return this.service.paginate(options);
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* 查询分类明细
|
|
||||||
* @param id
|
|
||||||
*/
|
|
||||||
@Get(':id')
|
@Get(':id')
|
||||||
@SerializeOptions({ groups: ['category-detail'] })
|
@SerializeOptions({ groups: ['category-detail'] })
|
||||||
async detail(@Param('id', new ParseUUIDPipe()) id: string) {
|
async detail(@Param('id', new ParseUUIDPipe()) id: string) {
|
||||||
@ -78,6 +60,6 @@ export class CategoryController {
|
|||||||
@Delete(':id')
|
@Delete(':id')
|
||||||
@SerializeOptions({ groups: ['category-detail'] })
|
@SerializeOptions({ groups: ['category-detail'] })
|
||||||
async delete(@Param('id', new ParseUUIDPipe()) id: string) {
|
async delete(@Param('id', new ParseUUIDPipe()) id: string) {
|
||||||
return this.service.delete([id]);
|
return this.service.delete(id);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@ -1,8 +1,5 @@
|
|||||||
import { Body, Controller, Delete, Get, Post, Query, SerializeOptions } from '@nestjs/common';
|
import { Body, Controller, Delete, Get, Post, Query, SerializeOptions } from '@nestjs/common';
|
||||||
|
|
||||||
import { Depends } from '@/modules/restful/decorators/depend.decorator';
|
|
||||||
|
|
||||||
import { ContentModule } from '../content.module';
|
|
||||||
import {
|
import {
|
||||||
CreateCommentDto,
|
CreateCommentDto,
|
||||||
DeleteCommentDto,
|
DeleteCommentDto,
|
||||||
@ -11,7 +8,6 @@ import {
|
|||||||
} from '../dtos/comment.dto';
|
} from '../dtos/comment.dto';
|
||||||
import { CommentService } from '../services';
|
import { CommentService } from '../services';
|
||||||
|
|
||||||
@Depends(ContentModule)
|
|
||||||
@Controller('comment')
|
@Controller('comment')
|
||||||
export class CommentController {
|
export class CommentController {
|
||||||
constructor(protected service: CommentService) {}
|
constructor(protected service: CommentService) {}
|
||||||
|
@ -14,12 +14,8 @@ import {
|
|||||||
import { CreatePostDto, QueryPostDto, UpdatePostDto } from '@/modules/content/dtos/post.dto';
|
import { CreatePostDto, QueryPostDto, UpdatePostDto } from '@/modules/content/dtos/post.dto';
|
||||||
import { PostService } from '@/modules/content/services/post.service';
|
import { PostService } from '@/modules/content/services/post.service';
|
||||||
|
|
||||||
import { Depends } from '@/modules/restful/decorators/depend.decorator';
|
|
||||||
|
|
||||||
import { ContentModule } from '../content.module';
|
|
||||||
import { DeleteWithTrashDto, RestoreDto } from '../dtos/delete.with.trash.dto';
|
import { DeleteWithTrashDto, RestoreDto } from '../dtos/delete.with.trash.dto';
|
||||||
|
|
||||||
@Depends(ContentModule)
|
|
||||||
@Controller('posts')
|
@Controller('posts')
|
||||||
export class PostController {
|
export class PostController {
|
||||||
constructor(private postService: PostService) {}
|
constructor(private postService: PostService) {}
|
||||||
|
@ -13,13 +13,9 @@ import {
|
|||||||
|
|
||||||
import { DeleteDto } from '@/modules/content/dtos/delete.dto';
|
import { DeleteDto } from '@/modules/content/dtos/delete.dto';
|
||||||
|
|
||||||
import { Depends } from '@/modules/restful/decorators/depend.decorator';
|
|
||||||
|
|
||||||
import { ContentModule } from '../content.module';
|
|
||||||
import { CreateTagDto, QueryTagDto, UpdateTagDto } from '../dtos/tag.dto';
|
import { CreateTagDto, QueryTagDto, UpdateTagDto } from '../dtos/tag.dto';
|
||||||
import { TagService } from '../services';
|
import { TagService } from '../services';
|
||||||
|
|
||||||
@Depends(ContentModule)
|
|
||||||
@Controller('tag')
|
@Controller('tag')
|
||||||
export class TagController {
|
export class TagController {
|
||||||
constructor(protected service: TagService) {}
|
constructor(protected service: TagService) {}
|
||||||
|
@ -48,7 +48,7 @@ export class PostEntity extends BaseEntity {
|
|||||||
type: PostBodyType;
|
type: PostBodyType;
|
||||||
|
|
||||||
@Expose()
|
@Expose()
|
||||||
@Column({ comment: '发布时间', type: 'timestamp', nullable: true })
|
@Column({ comment: '发布时间', type: 'varchar', nullable: true })
|
||||||
publishedAt?: Date | null;
|
publishedAt?: Date | null;
|
||||||
|
|
||||||
@Expose()
|
@Expose()
|
||||||
|
@ -1,23 +1,21 @@
|
|||||||
import { isNil, unset } from 'lodash';
|
import { isNil, pick, unset } from 'lodash';
|
||||||
import { FindOptionsUtils, FindTreeOptions, TreeRepositoryUtils } from 'typeorm';
|
import { FindOptionsUtils, FindTreeOptions, TreeRepository, TreeRepositoryUtils } from 'typeorm';
|
||||||
|
|
||||||
import { CategoryEntity } from '@/modules/content/entities/category.entity';
|
import { CategoryEntity } from '@/modules/content/entities/category.entity';
|
||||||
import { BaseTreeRepository } from '@/modules/database/base/tree.repository';
|
|
||||||
import { OrderType, TreeChildrenResolve } from '@/modules/database/constants';
|
|
||||||
import { CustomRepository } from '@/modules/database/decorators/repository.decorator';
|
import { CustomRepository } from '@/modules/database/decorators/repository.decorator';
|
||||||
|
|
||||||
@CustomRepository(CategoryEntity)
|
@CustomRepository(CategoryEntity)
|
||||||
export class CategoryRepository extends BaseTreeRepository<CategoryEntity> {
|
export class CategoryRepository extends TreeRepository<CategoryEntity> {
|
||||||
protected _qbName = 'category';
|
|
||||||
|
|
||||||
protected orderBy = { name: 'customOrder', order: OrderType.ASC };
|
|
||||||
|
|
||||||
protected _childrenResolve = TreeChildrenResolve.UP;
|
|
||||||
|
|
||||||
buildBaseQB() {
|
buildBaseQB() {
|
||||||
return this.createQueryBuilder('category').leftJoinAndSelect('category.parent', 'parent');
|
return this.createQueryBuilder('category').leftJoinAndSelect('category.parent', 'parent');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async findTrees(options?: FindTreeOptions) {
|
||||||
|
const roots = await this.findRoots(options);
|
||||||
|
await Promise.all(roots.map((root) => this.findDescendantsTree(root, options)));
|
||||||
|
return roots;
|
||||||
|
}
|
||||||
|
|
||||||
findRoots(options?: FindTreeOptions): Promise<CategoryEntity[]> {
|
findRoots(options?: FindTreeOptions): Promise<CategoryEntity[]> {
|
||||||
const escape = (val: string) => this.manager.connection.driver.escape(val);
|
const escape = (val: string) => this.manager.connection.driver.escape(val);
|
||||||
const joinColumn = this.metadata.treeParentRelation!.joinColumns[0];
|
const joinColumn = this.metadata.treeParentRelation!.joinColumns[0];
|
||||||
@ -41,6 +39,28 @@ export class CategoryRepository extends BaseTreeRepository<CategoryEntity> {
|
|||||||
return qb.getMany();
|
return qb.getMany();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async findDescendantsTree(entity: CategoryEntity, options?: FindTreeOptions) {
|
||||||
|
const qb = this.createDescendantsQueryBuilder('category', 'treeClosure', entity)
|
||||||
|
.leftJoinAndSelect('category.parent', 'parent')
|
||||||
|
.orderBy('category.customOrder', 'ASC');
|
||||||
|
FindOptionsUtils.applyOptionsToTreeQueryBuilder(qb, pick(options, ['relations', 'depth']));
|
||||||
|
const entities = await qb.getRawAndEntities();
|
||||||
|
const relationMaps = TreeRepositoryUtils.createRelationMaps(
|
||||||
|
this.manager,
|
||||||
|
this.metadata,
|
||||||
|
'category',
|
||||||
|
entities.raw,
|
||||||
|
);
|
||||||
|
TreeRepositoryUtils.buildChildrenEntityTree(
|
||||||
|
this.metadata,
|
||||||
|
entity,
|
||||||
|
entities.entities,
|
||||||
|
relationMaps,
|
||||||
|
{ depth: -1, ...pick(options, ['relations']) },
|
||||||
|
);
|
||||||
|
return entity;
|
||||||
|
}
|
||||||
|
|
||||||
async findAncestorsTree(
|
async findAncestorsTree(
|
||||||
entity: CategoryEntity,
|
entity: CategoryEntity,
|
||||||
options?: FindTreeOptions,
|
options?: FindTreeOptions,
|
||||||
@ -75,6 +95,23 @@ export class CategoryRepository extends BaseTreeRepository<CategoryEntity> {
|
|||||||
return qb.getCount();
|
return qb.getCount();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async toFlatTrees(
|
||||||
|
trees: CategoryEntity[],
|
||||||
|
depth = 0,
|
||||||
|
parent: CategoryEntity | null = null,
|
||||||
|
): Promise<CategoryEntity[]> {
|
||||||
|
const data: Omit<CategoryEntity, 'children'>[] = [];
|
||||||
|
for (const item of trees) {
|
||||||
|
item.depth = depth;
|
||||||
|
item.parent = parent;
|
||||||
|
const { children } = item;
|
||||||
|
unset(item, 'children');
|
||||||
|
data.push(item);
|
||||||
|
data.push(...(await this.toFlatTrees(children, depth + 1, item)));
|
||||||
|
}
|
||||||
|
return data as CategoryEntity[];
|
||||||
|
}
|
||||||
|
|
||||||
async flatAncestorsTree(item: CategoryEntity) {
|
async flatAncestorsTree(item: CategoryEntity) {
|
||||||
let data: Omit<CategoryEntity, 'children'>[] = [];
|
let data: Omit<CategoryEntity, 'children'>[] = [];
|
||||||
const category = await this.findAncestorsTree(item);
|
const category = await this.findAncestorsTree(item);
|
||||||
|
@ -1,17 +1,20 @@
|
|||||||
import { FindOptionsUtils, FindTreeOptions, SelectQueryBuilder } from 'typeorm';
|
import { pick, unset } from 'lodash';
|
||||||
|
import {
|
||||||
|
FindOptionsUtils,
|
||||||
|
FindTreeOptions,
|
||||||
|
SelectQueryBuilder,
|
||||||
|
TreeRepository,
|
||||||
|
TreeRepositoryUtils,
|
||||||
|
} from 'typeorm';
|
||||||
|
|
||||||
import { CommentEntity } from '@/modules/content/entities/comment.entity';
|
import { CommentEntity } from '@/modules/content/entities/comment.entity';
|
||||||
import { BaseTreeRepository } from '@/modules/database/base/tree.repository';
|
|
||||||
import { CustomRepository } from '@/modules/database/decorators/repository.decorator';
|
import { CustomRepository } from '@/modules/database/decorators/repository.decorator';
|
||||||
import { QueryHook } from '@/modules/database/types';
|
|
||||||
|
|
||||||
type FindCommentTreeOptions = FindTreeOptions & {
|
type FindCommentTreeOptions = FindTreeOptions & {
|
||||||
addQuery?: QueryHook<CommentEntity>;
|
addQuery?: (query: SelectQueryBuilder<CommentEntity>) => SelectQueryBuilder<CommentEntity>;
|
||||||
};
|
};
|
||||||
@CustomRepository(CommentEntity)
|
@CustomRepository(CommentEntity)
|
||||||
export class CommentRepository extends BaseTreeRepository<CommentEntity> {
|
export class CommentRepository extends TreeRepository<CommentEntity> {
|
||||||
protected _qbName = 'comment';
|
|
||||||
|
|
||||||
buildBaseQB(qb: SelectQueryBuilder<CommentEntity>): SelectQueryBuilder<CommentEntity> {
|
buildBaseQB(qb: SelectQueryBuilder<CommentEntity>): SelectQueryBuilder<CommentEntity> {
|
||||||
return qb
|
return qb
|
||||||
.leftJoinAndSelect(`comment.parent`, 'parent')
|
.leftJoinAndSelect(`comment.parent`, 'parent')
|
||||||
@ -19,7 +22,14 @@ export class CommentRepository extends BaseTreeRepository<CommentEntity> {
|
|||||||
.orderBy('comment.createdAt', 'DESC');
|
.orderBy('comment.createdAt', 'DESC');
|
||||||
}
|
}
|
||||||
|
|
||||||
async findRoots(options?: FindCommentTreeOptions): Promise<CommentEntity[]> {
|
async findTrees(options: FindCommentTreeOptions): Promise<CommentEntity[]> {
|
||||||
|
options.relations = ['parent', 'children'];
|
||||||
|
const roots = await this.findRoots(options);
|
||||||
|
await Promise.all(roots.map((root) => this.findDescendantsTree(root, options)));
|
||||||
|
return roots;
|
||||||
|
}
|
||||||
|
|
||||||
|
findRoots(options?: FindCommentTreeOptions): Promise<CommentEntity[]> {
|
||||||
const { addQuery, ...rest } = options;
|
const { addQuery, ...rest } = options;
|
||||||
const escape = (val: string) => this.manager.connection.driver.escape(val);
|
const escape = (val: string) => this.manager.connection.driver.escape(val);
|
||||||
const joinColumn = this.metadata.treeParentRelation!.joinColumns[0];
|
const joinColumn = this.metadata.treeParentRelation!.joinColumns[0];
|
||||||
@ -28,19 +38,58 @@ export class CommentRepository extends BaseTreeRepository<CommentEntity> {
|
|||||||
let qb = this.buildBaseQB(this.createQueryBuilder('comment'));
|
let qb = this.buildBaseQB(this.createQueryBuilder('comment'));
|
||||||
FindOptionsUtils.applyOptionsToTreeQueryBuilder(qb, rest);
|
FindOptionsUtils.applyOptionsToTreeQueryBuilder(qb, rest);
|
||||||
qb.where(`${escape('comment')}.${escape(parentPropertyName)} IS NULL`);
|
qb.where(`${escape('comment')}.${escape(parentPropertyName)} IS NULL`);
|
||||||
qb = addQuery ? await addQuery(qb) : qb;
|
qb = addQuery ? addQuery(qb) : qb;
|
||||||
return qb.getMany();
|
return qb.getMany();
|
||||||
}
|
}
|
||||||
|
|
||||||
async createDtsQueryBuilder(
|
createDtsQueryBuilder(
|
||||||
closureTable: string,
|
closureTable: string,
|
||||||
entity: CommentEntity,
|
entity: CommentEntity,
|
||||||
options: FindCommentTreeOptions = {},
|
options: FindCommentTreeOptions = {},
|
||||||
): Promise<SelectQueryBuilder<CommentEntity>> {
|
): SelectQueryBuilder<CommentEntity> {
|
||||||
const { addQuery } = options;
|
const { addQuery } = options;
|
||||||
const qb = this.buildBaseQB(
|
const qb = this.buildBaseQB(
|
||||||
super.createDescendantsQueryBuilder('comment', closureTable, entity),
|
super.createDescendantsQueryBuilder('comment', closureTable, entity),
|
||||||
);
|
);
|
||||||
return addQuery ? addQuery(qb) : qb;
|
return addQuery ? addQuery(qb) : qb;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async findDescendantsTree(
|
||||||
|
entity: CommentEntity,
|
||||||
|
options: FindCommentTreeOptions = {},
|
||||||
|
): Promise<CommentEntity> {
|
||||||
|
const qb: SelectQueryBuilder<CommentEntity> = this.createDtsQueryBuilder(
|
||||||
|
'treeClosure',
|
||||||
|
entity,
|
||||||
|
options,
|
||||||
|
);
|
||||||
|
FindOptionsUtils.applyOptionsToTreeQueryBuilder(qb, pick(options, ['relations', 'depth']));
|
||||||
|
const entities = await qb.getRawAndEntities();
|
||||||
|
const relationMaps = TreeRepositoryUtils.createRelationMaps(
|
||||||
|
this.manager,
|
||||||
|
this.metadata,
|
||||||
|
'comment',
|
||||||
|
entities.raw,
|
||||||
|
);
|
||||||
|
TreeRepositoryUtils.buildChildrenEntityTree(
|
||||||
|
this.metadata,
|
||||||
|
entity,
|
||||||
|
entities.entities,
|
||||||
|
relationMaps,
|
||||||
|
{ depth: -1, ...pick(options, ['relations']) },
|
||||||
|
);
|
||||||
|
return entity;
|
||||||
|
}
|
||||||
|
|
||||||
|
async toFlatTrees(trees: CommentEntity[], depth = 0): Promise<CommentEntity[]> {
|
||||||
|
const data: Omit<CommentEntity, 'children'>[] = [];
|
||||||
|
for (const item of trees) {
|
||||||
|
item.depth = depth;
|
||||||
|
const { children } = item;
|
||||||
|
unset(item, 'children');
|
||||||
|
data.push(item);
|
||||||
|
data.push(...(await this.toFlatTrees(children, depth + 1)));
|
||||||
|
}
|
||||||
|
return data as CommentEntity[];
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
@ -2,23 +2,29 @@ import { Injectable } from '@nestjs/common';
|
|||||||
import { isNil, omit } from 'lodash';
|
import { isNil, omit } from 'lodash';
|
||||||
import { EntityNotFoundError } from 'typeorm';
|
import { EntityNotFoundError } from 'typeorm';
|
||||||
|
|
||||||
import { CreateCategoryDto, UpdateCategoryDto } from '@/modules/content/dtos/category.dto';
|
import {
|
||||||
|
CreateCategoryDto,
|
||||||
|
QueryCategoryDto,
|
||||||
|
UpdateCategoryDto,
|
||||||
|
} from '@/modules/content/dtos/category.dto';
|
||||||
import { CategoryEntity } from '@/modules/content/entities/category.entity';
|
import { CategoryEntity } from '@/modules/content/entities/category.entity';
|
||||||
import { CategoryRepository } from '@/modules/content/repositories/category.repository';
|
import { CategoryRepository } from '@/modules/content/repositories/category.repository';
|
||||||
import { BaseService } from '@/modules/database/base/service';
|
import { treePaginate } from '@/modules/database/utils';
|
||||||
|
|
||||||
@Injectable()
|
@Injectable()
|
||||||
export class CategoryService extends BaseService<CategoryEntity, CategoryRepository> {
|
export class CategoryService {
|
||||||
protected enableTrash = true;
|
constructor(protected repository: CategoryRepository) {}
|
||||||
|
|
||||||
constructor(protected repository: CategoryRepository) {
|
|
||||||
super(repository);
|
|
||||||
}
|
|
||||||
|
|
||||||
async findTrees() {
|
async findTrees() {
|
||||||
return this.repository.findTrees();
|
return this.repository.findTrees();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async paginate(options?: QueryCategoryDto) {
|
||||||
|
const tree = await this.findTrees();
|
||||||
|
const data = await this.repository.toFlatTrees(tree);
|
||||||
|
return treePaginate(options, data);
|
||||||
|
}
|
||||||
|
|
||||||
async detail(id: string) {
|
async detail(id: string) {
|
||||||
return this.repository.findOneOrFail({ where: { id }, relations: ['parent', 'children'] });
|
return this.repository.findOneOrFail({ where: { id }, relations: ['parent', 'children'] });
|
||||||
}
|
}
|
||||||
@ -50,6 +56,21 @@ export class CategoryService extends BaseService<CategoryEntity, CategoryReposit
|
|||||||
return item;
|
return item;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async delete(id: string) {
|
||||||
|
const item = await this.repository.findOneOrFail({
|
||||||
|
where: { id },
|
||||||
|
relations: ['parent', 'children'],
|
||||||
|
});
|
||||||
|
if (!isNil(item.children) && item.children.length > 0) {
|
||||||
|
const childrenCategories = [...item.children].map((c) => {
|
||||||
|
c.parent = item.parent;
|
||||||
|
return item;
|
||||||
|
});
|
||||||
|
await this.repository.save(childrenCategories, { reload: true });
|
||||||
|
}
|
||||||
|
return this.repository.remove(item);
|
||||||
|
}
|
||||||
|
|
||||||
async getParent(current?: string, parentId?: string) {
|
async getParent(current?: string, parentId?: string) {
|
||||||
if (current === parentId) {
|
if (current === parentId) {
|
||||||
return undefined;
|
return undefined;
|
||||||
|
@ -11,21 +11,18 @@ import {
|
|||||||
} from '@/modules/content/dtos/comment.dto';
|
} from '@/modules/content/dtos/comment.dto';
|
||||||
import { CommentEntity } from '@/modules/content/entities/comment.entity';
|
import { CommentEntity } from '@/modules/content/entities/comment.entity';
|
||||||
import { CommentRepository, PostRepository } from '@/modules/content/repositories';
|
import { CommentRepository, PostRepository } from '@/modules/content/repositories';
|
||||||
import { BaseService } from '@/modules/database/base/service';
|
|
||||||
import { treePaginate } from '@/modules/database/utils';
|
import { treePaginate } from '@/modules/database/utils';
|
||||||
|
|
||||||
@Injectable()
|
@Injectable()
|
||||||
export class CommentService extends BaseService<CommentEntity, CommentRepository> {
|
export class CommentService {
|
||||||
constructor(
|
constructor(
|
||||||
protected repository: CommentRepository,
|
protected repository: CommentRepository,
|
||||||
protected postRepository: PostRepository,
|
protected postRepository: PostRepository,
|
||||||
) {
|
) {}
|
||||||
super(repository);
|
|
||||||
}
|
|
||||||
|
|
||||||
async findTrees(options: QueryCommentTreeDto = {}) {
|
async findTrees(options: QueryCommentTreeDto = {}) {
|
||||||
return this.repository.findTrees({
|
return this.repository.findTrees({
|
||||||
addQuery: async (qb) => {
|
addQuery: (qb) => {
|
||||||
return isNil(options.post) ? qb : qb.where('post.id = :id', { id: options.post });
|
return isNil(options.post) ? qb : qb.where('post.id = :id', { id: options.post });
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
@ -33,7 +30,7 @@ export class CommentService extends BaseService<CommentEntity, CommentRepository
|
|||||||
|
|
||||||
async paginate(options: QueryCommentDto) {
|
async paginate(options: QueryCommentDto) {
|
||||||
const { post, ...query } = options;
|
const { post, ...query } = options;
|
||||||
const addQuery = async (qb: SelectQueryBuilder<CommentEntity>) => {
|
const addQuery = (qb: SelectQueryBuilder<CommentEntity>) => {
|
||||||
const condition: RecordString = {};
|
const condition: RecordString = {};
|
||||||
if (!isNil(post)) {
|
if (!isNil(post)) {
|
||||||
condition.post = post;
|
condition.post = post;
|
||||||
@ -94,8 +91,4 @@ export class CommentService extends BaseService<CommentEntity, CommentRepository
|
|||||||
}
|
}
|
||||||
return parent;
|
return parent;
|
||||||
}
|
}
|
||||||
|
|
||||||
update(data: any, ...others: any[]): Promise<CommentEntity> {
|
|
||||||
throw new Error('Method not implemented.');
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
@ -11,7 +11,6 @@ import { CategoryRepository } from '@/modules/content/repositories';
|
|||||||
import { PostRepository } from '@/modules/content/repositories/post.repository';
|
import { PostRepository } from '@/modules/content/repositories/post.repository';
|
||||||
import { SearchService } from '@/modules/content/services/search.service';
|
import { SearchService } from '@/modules/content/services/search.service';
|
||||||
import { SearchType } from '@/modules/content/types';
|
import { SearchType } from '@/modules/content/types';
|
||||||
import { BaseService } from '@/modules/database/base/service';
|
|
||||||
import { SelectTrashMode } from '@/modules/database/constants';
|
import { SelectTrashMode } from '@/modules/database/constants';
|
||||||
import { QueryHook } from '@/modules/database/types';
|
import { QueryHook } from '@/modules/database/types';
|
||||||
import { paginate } from '@/modules/database/utils';
|
import { paginate } from '@/modules/database/utils';
|
||||||
@ -25,9 +24,7 @@ type FindParams = {
|
|||||||
};
|
};
|
||||||
|
|
||||||
@Injectable()
|
@Injectable()
|
||||||
export class PostService extends BaseService<PostEntity, PostRepository, FindParams> {
|
export class PostService {
|
||||||
protected enableTrash = true;
|
|
||||||
|
|
||||||
constructor(
|
constructor(
|
||||||
protected repository: PostRepository,
|
protected repository: PostRepository,
|
||||||
protected categoryRepository: CategoryRepository,
|
protected categoryRepository: CategoryRepository,
|
||||||
@ -35,15 +32,13 @@ export class PostService extends BaseService<PostEntity, PostRepository, FindPar
|
|||||||
protected tagRepository: TagRepository,
|
protected tagRepository: TagRepository,
|
||||||
protected searchService?: SearchService,
|
protected searchService?: SearchService,
|
||||||
protected searchType: SearchType = 'mysql',
|
protected searchType: SearchType = 'mysql',
|
||||||
) {
|
) {}
|
||||||
super(repository);
|
|
||||||
}
|
|
||||||
|
|
||||||
async paginate(options: QueryPostDto, callback?: QueryHook<PostEntity>) {
|
async paginate(options: QueryPostDto, callback?: QueryHook<PostEntity>) {
|
||||||
if (!isNil(this.searchService) && !isNil(options.search) && this.searchType === 'meili') {
|
if (!isNil(this.searchService) && !isNil(options.search) && this.searchType === 'meili') {
|
||||||
return this.searchService.search(
|
return this.searchService.search(
|
||||||
options.search,
|
options.search,
|
||||||
pick(options, ['trashed', 'page', 'limit', 'isPublished']),
|
pick(options, ['trashed', 'page', 'limit']),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
const qb = await this.buildListQuery(this.repository.buildBaseQB(), options, callback);
|
const qb = await this.buildListQuery(this.repository.buildBaseQB(), options, callback);
|
||||||
|
@ -47,7 +47,7 @@ export class SearchService implements OnModuleInit {
|
|||||||
return this.client;
|
return this.client;
|
||||||
}
|
}
|
||||||
|
|
||||||
async search(text: string, param: SearchOption = {}): Promise<any> {
|
async search(text: string, param: SearchOption = {}) {
|
||||||
const option = { page: 1, limit: 10, trashed: SelectTrashMode.ONLY, ...param };
|
const option = { page: 1, limit: 10, trashed: SelectTrashMode.ONLY, ...param };
|
||||||
const limit = isNil(option.limit) || option.limit < 1 ? 1 : option.limit;
|
const limit = isNil(option.limit) || option.limit < 1 ? 1 : option.limit;
|
||||||
const page = isNil(option.page) || option.page < 1 ? 1 : option.page;
|
const page = isNil(option.page) || option.page < 1 ? 1 : option.page;
|
||||||
|
@ -1,18 +1,19 @@
|
|||||||
import { Injectable } from '@nestjs/common';
|
import { Injectable } from '@nestjs/common';
|
||||||
import { omit } from 'lodash';
|
import { omit } from 'lodash';
|
||||||
|
|
||||||
import { CreateTagDto, UpdateTagDto } from '@/modules/content/dtos/tag.dto';
|
import { In } from 'typeorm';
|
||||||
import { TagRepository } from '@/modules/content/repositories/tag.repository';
|
|
||||||
import { BaseService } from '@/modules/database/base/service';
|
|
||||||
|
|
||||||
import { TagEntity } from '../entities';
|
import { CreateTagDto, QueryTagDto, UpdateTagDto } from '@/modules/content/dtos/tag.dto';
|
||||||
|
import { TagRepository } from '@/modules/content/repositories/tag.repository';
|
||||||
|
import { paginate } from '@/modules/database/utils';
|
||||||
|
|
||||||
@Injectable()
|
@Injectable()
|
||||||
export class TagService extends BaseService<TagEntity, TagRepository> {
|
export class TagService {
|
||||||
protected enableTrash = true;
|
constructor(protected repository: TagRepository) {}
|
||||||
|
|
||||||
constructor(protected repository: TagRepository) {
|
async paginate(options: QueryTagDto) {
|
||||||
super(repository);
|
const qb = this.repository.buildBaseQB();
|
||||||
|
return paginate(qb, options);
|
||||||
}
|
}
|
||||||
|
|
||||||
async detail(id: string) {
|
async detail(id: string) {
|
||||||
@ -30,4 +31,11 @@ export class TagService extends BaseService<TagEntity, TagRepository> {
|
|||||||
await this.repository.update(data.id, omit(data, ['id']));
|
await this.repository.update(data.id, omit(data, ['id']));
|
||||||
return this.detail(data.id);
|
return this.detail(data.id);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async delete(ids: string[]) {
|
||||||
|
const items = await this.repository.find({
|
||||||
|
where: { id: In(ids) },
|
||||||
|
});
|
||||||
|
return this.repository.remove(items);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
@ -1,32 +1,25 @@
|
|||||||
import { Optional } from '@nestjs/common';
|
import { DataSource, EventSubscriber } from 'typeorm';
|
||||||
import { isNil } from 'lodash';
|
|
||||||
import { DataSource, EventSubscriber, ObjectType } from 'typeorm';
|
|
||||||
|
|
||||||
import { Configure } from '@/modules/config/configure';
|
|
||||||
import { PostBodyType } from '@/modules/content/constants';
|
import { PostBodyType } from '@/modules/content/constants';
|
||||||
import { PostEntity } from '@/modules/content/entities/post.entity';
|
import { PostEntity } from '@/modules/content/entities/post.entity';
|
||||||
import { PostRepository } from '@/modules/content/repositories/post.repository';
|
import { PostRepository } from '@/modules/content/repositories/post.repository';
|
||||||
import { SanitizeService } from '@/modules/content/services/SanitizeService';
|
import { SanitizeService } from '@/modules/content/services/SanitizeService';
|
||||||
import { BaseSubscriber } from '@/modules/database/base/subscriber';
|
|
||||||
|
|
||||||
@EventSubscriber()
|
@EventSubscriber()
|
||||||
export class PostSubscriber extends BaseSubscriber<PostEntity> {
|
export class PostSubscriber {
|
||||||
protected entity: ObjectType<PostEntity> = PostEntity;
|
|
||||||
constructor(
|
constructor(
|
||||||
protected dataSource: DataSource,
|
protected dataSource: DataSource,
|
||||||
|
protected sanitizeService: SanitizeService,
|
||||||
protected postRepository: PostRepository,
|
protected postRepository: PostRepository,
|
||||||
protected configure: Configure,
|
|
||||||
@Optional() protected sanitizeService: SanitizeService,
|
|
||||||
) {
|
) {
|
||||||
super(dataSource);
|
dataSource.subscribers.push(this);
|
||||||
|
}
|
||||||
|
listenTo() {
|
||||||
|
return PostEntity;
|
||||||
}
|
}
|
||||||
|
|
||||||
async afterLoad(entity: PostEntity) {
|
async afterLoad(entity: PostEntity) {
|
||||||
if (
|
if (entity.type === PostBodyType.HTML) {
|
||||||
(await this.configure.get('content.htmlEnabled')) &&
|
|
||||||
!isNil(this.sanitizeService) &&
|
|
||||||
entity.type === PostBodyType.HTML
|
|
||||||
) {
|
|
||||||
entity.body = this.sanitizeService.sanitize(entity.body);
|
entity.body = this.sanitizeService.sanitize(entity.body);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@ -3,8 +3,7 @@ import { SelectTrashMode } from '@/modules/database/constants';
|
|||||||
export type SearchType = 'mysql' | 'meili';
|
export type SearchType = 'mysql' | 'meili';
|
||||||
|
|
||||||
export interface ContentConfig {
|
export interface ContentConfig {
|
||||||
searchType: SearchType;
|
SearchType?: SearchType;
|
||||||
htmlEnabled: boolean;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface SearchOption {
|
export interface SearchOption {
|
||||||
|
@ -1,30 +0,0 @@
|
|||||||
import { isNil, toNumber } from 'lodash';
|
|
||||||
|
|
||||||
import { Configure } from '../config/configure';
|
|
||||||
|
|
||||||
import { ConfigureFactory, ConfigureRegister } from '../config/types';
|
|
||||||
|
|
||||||
import { getRandomString, toBoolean } from './helpers';
|
|
||||||
import { AppConfig } from './types';
|
|
||||||
|
|
||||||
export const getDefaultAppConfig = (configure: Configure) => ({
|
|
||||||
name: configure.env.get('APP_NAME', getRandomString()),
|
|
||||||
host: configure.env.get('APP_HOST', '127.0.0.1'),
|
|
||||||
port: configure.env.get('APP_PORT', (v) => toNumber(v), 3000),
|
|
||||||
https: configure.env.get('APP_SSL', (v) => toBoolean(v), false),
|
|
||||||
locale: configure.env.get('APP_LOCALE', 'zh_CN'),
|
|
||||||
fallbackLocale: configure.env.get('APP_FALLBACK_LOCALE', 'en'),
|
|
||||||
});
|
|
||||||
|
|
||||||
export const createAppConfig: (
|
|
||||||
register: ConfigureRegister<RePartial<AppConfig>>,
|
|
||||||
) => ConfigureFactory<AppConfig> = (register) => ({
|
|
||||||
register,
|
|
||||||
defaultRegister: (configure) => getDefaultAppConfig(configure),
|
|
||||||
hook: (configure: Configure, value) => {
|
|
||||||
if (isNil(value.url)) {
|
|
||||||
value.url = `${value.https ? 'https:' : 'http:'}//${value.host}:${value.port}`;
|
|
||||||
}
|
|
||||||
return value;
|
|
||||||
},
|
|
||||||
});
|
|
@ -1,4 +1,5 @@
|
|||||||
import { registerDecorator, ValidationArguments, ValidationOptions } from 'class-validator';
|
import { registerDecorator, ValidationArguments, ValidationOptions } from 'class-validator';
|
||||||
|
// eslint-disable-next-line import/no-extraneous-dependencies
|
||||||
import { isMobilePhone, IsMobilePhoneOptions, MobilePhoneLocale } from 'validator';
|
import { isMobilePhone, IsMobilePhoneOptions, MobilePhoneLocale } from 'validator';
|
||||||
|
|
||||||
export function isMatchPhone(
|
export function isMatchPhone(
|
||||||
|
@ -1,11 +1,8 @@
|
|||||||
import { DynamicModule, Module } from '@nestjs/common';
|
import { DynamicModule, Module } from '@nestjs/common';
|
||||||
|
|
||||||
import { Configure } from '../config/configure';
|
|
||||||
|
|
||||||
@Module({})
|
@Module({})
|
||||||
export class CoreModule {
|
export class CoreModule {
|
||||||
static async forRoot(configure: Configure): Promise<DynamicModule> {
|
static forRoot(): DynamicModule {
|
||||||
await configure.store('app.name');
|
|
||||||
return {
|
return {
|
||||||
module: CoreModule,
|
module: CoreModule,
|
||||||
global: true,
|
global: true,
|
||||||
|
@ -1,101 +0,0 @@
|
|||||||
import { BadGatewayException, Global, Module, ModuleMetadata, Type } from '@nestjs/common';
|
|
||||||
|
|
||||||
import { APP_FILTER, APP_INTERCEPTOR, APP_PIPE } from '@nestjs/core';
|
|
||||||
import { useContainer } from 'class-validator';
|
|
||||||
|
|
||||||
import { omit } from 'lodash';
|
|
||||||
|
|
||||||
import { ConfigModule } from '@/modules/config/config.module';
|
|
||||||
import { Configure } from '@/modules/config/configure';
|
|
||||||
|
|
||||||
import { DEFAULT_VALIDATION_CONFIG } from '@/modules/content/constants';
|
|
||||||
|
|
||||||
import { CoreModule } from '../core.module';
|
|
||||||
import { AppFilter } from '../providers/app.filter';
|
|
||||||
import { AppInterceptor } from '../providers/app.interceptor';
|
|
||||||
import { AppPipe } from '../providers/app.pipe';
|
|
||||||
import { App, AppConfig, CreateOptions } from '../types';
|
|
||||||
|
|
||||||
import { CreateModule } from './utils';
|
|
||||||
|
|
||||||
export const app: App = { configure: new Configure() };
|
|
||||||
|
|
||||||
export const createApp = (options: CreateOptions) => async (): Promise<App> => {
|
|
||||||
const { config, builder } = options;
|
|
||||||
|
|
||||||
await app.configure.initialize(config.factories, config.storage);
|
|
||||||
|
|
||||||
if (!app.configure.has('app')) {
|
|
||||||
throw new BadGatewayException('App config not exists');
|
|
||||||
}
|
|
||||||
|
|
||||||
const BootModule = await createBootModule(app.configure, options);
|
|
||||||
|
|
||||||
app.container = await builder({ configure: app.configure, BootModule });
|
|
||||||
|
|
||||||
useContainer(app.container.select(BootModule), { fallbackOnErrors: true });
|
|
||||||
return app;
|
|
||||||
};
|
|
||||||
|
|
||||||
export async function createBootModule(
|
|
||||||
configure: Configure,
|
|
||||||
options: Pick<CreateOptions, 'globals' | 'providers' | 'modules'>,
|
|
||||||
): Promise<Type<any>> {
|
|
||||||
const { globals = {}, providers = [] } = options;
|
|
||||||
const modules = await options.modules(configure);
|
|
||||||
const imports: ModuleMetadata['imports'] = (
|
|
||||||
await Promise.all([
|
|
||||||
...modules,
|
|
||||||
ConfigModule.forRoot(configure),
|
|
||||||
await CoreModule.forRoot(configure),
|
|
||||||
])
|
|
||||||
).map((item) => {
|
|
||||||
if ('module' in item) {
|
|
||||||
const meta = omit(item, ['module', 'global']);
|
|
||||||
Module(meta)(item.module);
|
|
||||||
if (item.global) {
|
|
||||||
Global()(item.module);
|
|
||||||
}
|
|
||||||
return item.module;
|
|
||||||
}
|
|
||||||
return item;
|
|
||||||
});
|
|
||||||
|
|
||||||
if (globals.pipe !== null) {
|
|
||||||
const pipe = globals.pipe
|
|
||||||
? globals.pipe(configure)
|
|
||||||
: new AppPipe(DEFAULT_VALIDATION_CONFIG);
|
|
||||||
providers.push({ provide: APP_PIPE, useValue: pipe });
|
|
||||||
}
|
|
||||||
|
|
||||||
if (globals.interceptor !== null) {
|
|
||||||
providers.push({
|
|
||||||
provide: APP_INTERCEPTOR,
|
|
||||||
useClass: globals.interceptor ?? AppInterceptor,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
if (globals.filter !== null) {
|
|
||||||
providers.push({
|
|
||||||
provide: APP_FILTER,
|
|
||||||
useClass: AppFilter,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
return CreateModule('BootModule', () => ({
|
|
||||||
imports,
|
|
||||||
providers,
|
|
||||||
}));
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function startApp(
|
|
||||||
creater: () => Promise<App>,
|
|
||||||
listened: (app: App, startTime: Date) => () => Promise<void>,
|
|
||||||
) {
|
|
||||||
const startTime = new Date();
|
|
||||||
const { container, configure } = await creater();
|
|
||||||
app.container = container;
|
|
||||||
app.configure = configure;
|
|
||||||
const { port, host } = await configure.get<AppConfig>('app');
|
|
||||||
await container.listen(port, host, listened(app, startTime));
|
|
||||||
}
|
|
@ -1,10 +1,6 @@
|
|||||||
import { Module, ModuleMetadata, Type } from '@nestjs/common';
|
|
||||||
import chalk from 'chalk';
|
|
||||||
import deepmerge from 'deepmerge';
|
import deepmerge from 'deepmerge';
|
||||||
import { isNil } from 'lodash';
|
import { isNil } from 'lodash';
|
||||||
|
|
||||||
import { PanicOption } from '../types';
|
|
||||||
|
|
||||||
export function toBoolean(value?: string | boolean): boolean {
|
export function toBoolean(value?: string | boolean): boolean {
|
||||||
if (isNil(value)) {
|
if (isNil(value)) {
|
||||||
return false;
|
return false;
|
||||||
@ -36,49 +32,3 @@ export const deepMerge = <T, P>(
|
|||||||
}
|
}
|
||||||
return deepmerge(x, y, options) as P extends T ? T : T & P;
|
return deepmerge(x, y, options) as P extends T ? T : T & P;
|
||||||
};
|
};
|
||||||
|
|
||||||
export function isAsyncFunction<T, P extends Array<any>>(
|
|
||||||
callback: (...args: P) => T | Promise<T>,
|
|
||||||
): callback is (...args: P) => Promise<T> {
|
|
||||||
const AsyncFunction = (async () => {}).constructor;
|
|
||||||
return callback instanceof AsyncFunction === true;
|
|
||||||
}
|
|
||||||
|
|
||||||
export function CreateModule(
|
|
||||||
target: string | Type<any>,
|
|
||||||
metaSetter: () => ModuleMetadata = () => ({}),
|
|
||||||
): Type<any> {
|
|
||||||
let ModuleClass: Type<any>;
|
|
||||||
if (typeof target === 'string') {
|
|
||||||
ModuleClass = class {};
|
|
||||||
Object.defineProperty(ModuleClass, 'name', { value: target });
|
|
||||||
} else {
|
|
||||||
ModuleClass = target;
|
|
||||||
}
|
|
||||||
Module(metaSetter())(ModuleClass);
|
|
||||||
return ModuleClass;
|
|
||||||
}
|
|
||||||
|
|
||||||
export const getRandomString = (length = 10) => {
|
|
||||||
let result = '';
|
|
||||||
const characters = 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz';
|
|
||||||
const totalLength = characters.length;
|
|
||||||
|
|
||||||
for (let index = 0; index < length; index++) {
|
|
||||||
result += characters.charAt(Math.floor(Math.random() * totalLength));
|
|
||||||
}
|
|
||||||
return result;
|
|
||||||
};
|
|
||||||
|
|
||||||
export async function panic(option: PanicOption | string) {
|
|
||||||
console.log();
|
|
||||||
if (typeof option === 'string') {
|
|
||||||
console.log(chalk.red(`\n❌ ${option}`));
|
|
||||||
process.exit(1);
|
|
||||||
}
|
|
||||||
const { error, message, exit = true } = option;
|
|
||||||
isNil(error) ? console.log(chalk.red(`\n❌ ${message}`)) : console.log(chalk.red(error));
|
|
||||||
if (exit) {
|
|
||||||
process.exit(1);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
@ -1,63 +0,0 @@
|
|||||||
import { ModuleMetadata, PipeTransform, Type } from '@nestjs/common';
|
|
||||||
import { NestFastifyApplication } from '@nestjs/platform-fastify';
|
|
||||||
|
|
||||||
import { Configure } from '../config/configure';
|
|
||||||
import { ConfigStorageOption, ConfigureFactory } from '../config/types';
|
|
||||||
|
|
||||||
export type App = {
|
|
||||||
container?: NestFastifyApplication;
|
|
||||||
|
|
||||||
configure: Configure;
|
|
||||||
};
|
|
||||||
|
|
||||||
export interface CreateOptions {
|
|
||||||
modules: (configure: Configure) => Promise<Required<ModuleMetadata['imports']>>;
|
|
||||||
|
|
||||||
builder: ContainerBuilder;
|
|
||||||
|
|
||||||
globals?: {
|
|
||||||
pipe?: (configure: Configure) => PipeTransform<any> | null;
|
|
||||||
|
|
||||||
interceptor?: Type<any> | null;
|
|
||||||
|
|
||||||
filter?: Type<any> | null;
|
|
||||||
};
|
|
||||||
|
|
||||||
providers?: ModuleMetadata['providers'];
|
|
||||||
|
|
||||||
config: {
|
|
||||||
factories: Record<string, ConfigureFactory<RecordAny>>;
|
|
||||||
|
|
||||||
storage: ConfigStorageOption;
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface ContainerBuilder {
|
|
||||||
(params: { configure: Configure; BootModule: Type<any> }): Promise<NestFastifyApplication>;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface AppConfig {
|
|
||||||
name: string;
|
|
||||||
|
|
||||||
host: string;
|
|
||||||
|
|
||||||
port: number;
|
|
||||||
|
|
||||||
https: boolean;
|
|
||||||
|
|
||||||
locale: string;
|
|
||||||
|
|
||||||
fallbackLocale: string;
|
|
||||||
|
|
||||||
url?: string;
|
|
||||||
|
|
||||||
prefix?: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface PanicOption {
|
|
||||||
message: string;
|
|
||||||
|
|
||||||
error?: any;
|
|
||||||
|
|
||||||
exit?: boolean;
|
|
||||||
}
|
|
@ -1,155 +0,0 @@
|
|||||||
import { ForbiddenException, NotFoundException } from '@nestjs/common';
|
|
||||||
import { isNil } from 'lodash';
|
|
||||||
import { In, ObjectLiteral, SelectQueryBuilder } from 'typeorm';
|
|
||||||
|
|
||||||
import { BaseRepository } from '@/modules/database/base/repository';
|
|
||||||
import { BaseTreeRepository } from '@/modules/database/base/tree.repository';
|
|
||||||
import { SelectTrashMode, TreeChildrenResolve } from '@/modules/database/constants';
|
|
||||||
import {
|
|
||||||
PaginateOptions,
|
|
||||||
PaginateReturn,
|
|
||||||
QueryHook,
|
|
||||||
ServiceListQueryOption,
|
|
||||||
} from '@/modules/database/types';
|
|
||||||
import { paginate, treePaginate } from '@/modules/database/utils';
|
|
||||||
|
|
||||||
export abstract class BaseService<
|
|
||||||
T extends ObjectLiteral,
|
|
||||||
R extends BaseRepository<T> | BaseTreeRepository<T>,
|
|
||||||
P extends ServiceListQueryOption<T> = ServiceListQueryOption<T>,
|
|
||||||
> {
|
|
||||||
protected repository: R;
|
|
||||||
|
|
||||||
protected enableTrash = false;
|
|
||||||
|
|
||||||
protected constructor(repository: R) {
|
|
||||||
this.repository = repository;
|
|
||||||
if (
|
|
||||||
!(
|
|
||||||
this.repository instanceof BaseRepository ||
|
|
||||||
this.repository instanceof BaseTreeRepository
|
|
||||||
)
|
|
||||||
) {
|
|
||||||
throw new Error('Repository init error.');
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
protected async buildItemQB(id: string, qb: SelectQueryBuilder<T>, callback?: QueryHook<T>) {
|
|
||||||
qb.where(`${this.repository.qbName}.id = :id`, { id });
|
|
||||||
if (callback) {
|
|
||||||
return callback(qb);
|
|
||||||
}
|
|
||||||
return qb;
|
|
||||||
}
|
|
||||||
|
|
||||||
protected async buildListQB(qb: SelectQueryBuilder<T>, options?: P, callback?: QueryHook<T>) {
|
|
||||||
const { trashed } = options ?? {};
|
|
||||||
const queryName = this.repository.qbName;
|
|
||||||
if (this.enableTrash && trashed in [SelectTrashMode.ALL, SelectTrashMode.ONLY]) {
|
|
||||||
qb.withDeleted();
|
|
||||||
if (trashed === SelectTrashMode.ONLY) {
|
|
||||||
qb.where(`${queryName}.deletedAt IS NOT NULL`);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if (callback) {
|
|
||||||
return callback(qb);
|
|
||||||
}
|
|
||||||
return qb;
|
|
||||||
}
|
|
||||||
|
|
||||||
async list(options?: P, callback?: QueryHook<T>) {
|
|
||||||
const { trashed: isTrashed = false } = options ?? {};
|
|
||||||
const trashed = isTrashed || SelectTrashMode.NONE;
|
|
||||||
if (this.repository instanceof BaseTreeRepository) {
|
|
||||||
const withTrashed =
|
|
||||||
this.enableTrash && trashed in [SelectTrashMode.ALL, SelectTrashMode.ONLY];
|
|
||||||
const onlyTrashed = this.enableTrash && trashed === SelectTrashMode.ONLY;
|
|
||||||
const tree = await this.repository.findTrees({ ...options, withTrashed, onlyTrashed });
|
|
||||||
return this.repository.toFlatTrees(tree);
|
|
||||||
}
|
|
||||||
const qb = await this.buildListQB(this.repository.buildBaseQB(), options, callback);
|
|
||||||
return qb.getMany();
|
|
||||||
}
|
|
||||||
|
|
||||||
async paginate(options?: PaginateOptions & P, callback?: QueryHook<T>) {
|
|
||||||
const queryOptions = (options ?? {}) as P;
|
|
||||||
if (this.repository instanceof BaseTreeRepository) {
|
|
||||||
const data = await this.list(queryOptions, callback);
|
|
||||||
return treePaginate(options, data) as PaginateReturn<T>;
|
|
||||||
}
|
|
||||||
const qb = await this.buildListQB(this.repository.buildBaseQB(), queryOptions, callback);
|
|
||||||
return paginate(qb, options);
|
|
||||||
}
|
|
||||||
|
|
||||||
async detail(id: string, callback?: QueryHook<T>) {
|
|
||||||
const qb = await this.buildItemQB(id, this.repository.buildBaseQB(), callback);
|
|
||||||
const item = qb.getOne();
|
|
||||||
if (!item) {
|
|
||||||
throw new NotFoundException(`${this.repository.qbName} ${id} NOT FOUND`);
|
|
||||||
}
|
|
||||||
return item;
|
|
||||||
}
|
|
||||||
|
|
||||||
async delete(ids: string[], trash?: boolean) {
|
|
||||||
let items: T[];
|
|
||||||
if (this.repository instanceof BaseTreeRepository) {
|
|
||||||
items = await this.repository.find({
|
|
||||||
where: { id: In(ids) as any },
|
|
||||||
withDeleted: this.enableTrash ? true : undefined,
|
|
||||||
relations: ['parent', 'children'],
|
|
||||||
});
|
|
||||||
if (this.repository.childrenResolve === TreeChildrenResolve.UP) {
|
|
||||||
for (const item of items) {
|
|
||||||
if (isNil(item.children) || item.children.length <= 0) {
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
const children = [...item.children].map((o) => {
|
|
||||||
o.parent = item.parent;
|
|
||||||
return item;
|
|
||||||
});
|
|
||||||
await this.repository.save(children);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
items = await this.repository.find({
|
|
||||||
where: { id: In(ids) as any },
|
|
||||||
withDeleted: this.enableTrash ? true : undefined,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
if (this.enableTrash && trash) {
|
|
||||||
const directs = items.filter((item) => !isNil(item.deletedAt));
|
|
||||||
const softs = items.filter((item) => isNil(item.deletedAt));
|
|
||||||
return [
|
|
||||||
...(await this.repository.remove(directs)),
|
|
||||||
...(await this.repository.softRemove(softs)),
|
|
||||||
];
|
|
||||||
}
|
|
||||||
return this.repository.remove(items);
|
|
||||||
}
|
|
||||||
|
|
||||||
async restore(ids: string[]) {
|
|
||||||
if (!this.enableTrash) {
|
|
||||||
throw new ForbiddenException(
|
|
||||||
`Can not to retore ${this.repository.qbName},because trash not enabled!`,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
const items = await this.repository.find({
|
|
||||||
where: { id: In(ids) as any },
|
|
||||||
withDeleted: true,
|
|
||||||
});
|
|
||||||
const trashIds = items.filter((o) => !isNil(o.deletedAt)).map((o) => o.id);
|
|
||||||
if (trashIds.length < 1) {
|
|
||||||
return [];
|
|
||||||
}
|
|
||||||
await this.repository.restore(trashIds);
|
|
||||||
const qb = await this.buildListQB(this.repository.buildBaseQB(), undefined, async (_) =>
|
|
||||||
_.andWhereInIds(trashIds),
|
|
||||||
);
|
|
||||||
return qb.getMany();
|
|
||||||
}
|
|
||||||
|
|
||||||
abstract create(data: any, ...others: any[]): Promise<T>;
|
|
||||||
|
|
||||||
abstract update(data: any, ...others: any[]): Promise<T>;
|
|
||||||
}
|
|
@ -1,76 +0,0 @@
|
|||||||
import { Optional } from '@nestjs/common';
|
|
||||||
import { isNil } from 'lodash';
|
|
||||||
import {
|
|
||||||
DataSource,
|
|
||||||
EntitySubscriberInterface,
|
|
||||||
EntityTarget,
|
|
||||||
EventSubscriber,
|
|
||||||
InsertEvent,
|
|
||||||
ObjectLiteral,
|
|
||||||
ObjectType,
|
|
||||||
RecoverEvent,
|
|
||||||
RemoveEvent,
|
|
||||||
SoftRemoveEvent,
|
|
||||||
TransactionCommitEvent,
|
|
||||||
TransactionRollbackEvent,
|
|
||||||
TransactionStartEvent,
|
|
||||||
UpdateEvent,
|
|
||||||
} from 'typeorm';
|
|
||||||
|
|
||||||
import { RepositoryType } from '../types';
|
|
||||||
import { getCustomRepository } from '../utils';
|
|
||||||
|
|
||||||
type SubscriberEvent<T extends ObjectLiteral> =
|
|
||||||
| InsertEvent<T>
|
|
||||||
| UpdateEvent<T>
|
|
||||||
| SoftRemoveEvent<T>
|
|
||||||
| RemoveEvent<T>
|
|
||||||
| RecoverEvent<T>
|
|
||||||
| TransactionStartEvent
|
|
||||||
| TransactionCommitEvent
|
|
||||||
| TransactionRollbackEvent;
|
|
||||||
|
|
||||||
@EventSubscriber()
|
|
||||||
export abstract class BaseSubscriber<T extends ObjectLiteral>
|
|
||||||
implements EntitySubscriberInterface<T>
|
|
||||||
{
|
|
||||||
protected abstract entity: ObjectType<T>;
|
|
||||||
|
|
||||||
protected constructor(@Optional() protected dataSource?: DataSource) {
|
|
||||||
if (!isNil(this.dataSource)) {
|
|
||||||
this.dataSource.subscribers.push(this);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
protected getDataSource(event: SubscriberEvent<T>) {
|
|
||||||
return this.dataSource ?? event.connection;
|
|
||||||
}
|
|
||||||
|
|
||||||
protected getManage(event: SubscriberEvent<T>) {
|
|
||||||
return this.dataSource ? this.dataSource.manager : event.manager;
|
|
||||||
}
|
|
||||||
|
|
||||||
listenTo() {
|
|
||||||
return this.entity;
|
|
||||||
}
|
|
||||||
|
|
||||||
async afterLoad(entity: any) {
|
|
||||||
if ('parent' in entity && isNil(entity.depth)) {
|
|
||||||
entity.depth = 0;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
protected getRepository<
|
|
||||||
C extends ClassType<P>,
|
|
||||||
P extends RepositoryType<T>,
|
|
||||||
R extends EntityTarget<ObjectLiteral>,
|
|
||||||
>(event: SubscriberEvent<T>, repository?: C, entity?: R) {
|
|
||||||
return isNil(repository)
|
|
||||||
? this.getDataSource(event).getRepository(entity ?? this.entity)
|
|
||||||
: getCustomRepository<P, T>(this.getDataSource(event), repository);
|
|
||||||
}
|
|
||||||
|
|
||||||
protected isUpdated(column: keyof T, event: UpdateEvent<T>) {
|
|
||||||
return !!event.updatedColumns.find((o) => o.propertyName === column);
|
|
||||||
}
|
|
||||||
}
|
|
@ -1,98 +0,0 @@
|
|||||||
import { isNil, pick, unset } from 'lodash';
|
|
||||||
import {
|
|
||||||
EntityManager,
|
|
||||||
EntityTarget,
|
|
||||||
FindOptionsUtils,
|
|
||||||
FindTreeOptions,
|
|
||||||
ObjectLiteral,
|
|
||||||
QueryRunner,
|
|
||||||
SelectQueryBuilder,
|
|
||||||
TreeRepository,
|
|
||||||
TreeRepositoryUtils,
|
|
||||||
} from 'typeorm';
|
|
||||||
|
|
||||||
import { OrderType, TreeChildrenResolve } from '../constants';
|
|
||||||
import { OrderQueryType, QueryParams } from '../types';
|
|
||||||
import { getOrderByQuery } from '../utils';
|
|
||||||
|
|
||||||
export abstract class BaseTreeRepository<T extends ObjectLiteral> extends TreeRepository<T> {
|
|
||||||
protected abstract _qbName: string;
|
|
||||||
|
|
||||||
protected _childrenResolve?: TreeChildrenResolve;
|
|
||||||
|
|
||||||
protected orderBy?: string | { name: string; order: `${OrderType}` };
|
|
||||||
|
|
||||||
// eslint-disable-next-line @typescript-eslint/no-useless-constructor
|
|
||||||
constructor(target: EntityTarget<T>, manager: EntityManager, queryRunner?: QueryRunner) {
|
|
||||||
super(target, manager, queryRunner);
|
|
||||||
}
|
|
||||||
|
|
||||||
get qbName() {
|
|
||||||
return this._qbName;
|
|
||||||
}
|
|
||||||
|
|
||||||
get childrenResolve() {
|
|
||||||
return this._childrenResolve;
|
|
||||||
}
|
|
||||||
|
|
||||||
buildBaseQB(qb?: SelectQueryBuilder<T>): SelectQueryBuilder<T> {
|
|
||||||
const queryBuilder = qb ?? this.createQueryBuilder(this.qbName);
|
|
||||||
return queryBuilder.leftJoinAndSelect(`${this.qbName}.parent`, 'parent');
|
|
||||||
}
|
|
||||||
|
|
||||||
addOrderByQuery(qb: SelectQueryBuilder<T>, orderBy?: OrderQueryType) {
|
|
||||||
const orderByQuery = orderBy ?? this.orderBy;
|
|
||||||
return isNil(orderByQuery) ? qb : getOrderByQuery(qb, this.qbName, orderByQuery);
|
|
||||||
}
|
|
||||||
|
|
||||||
async findTrees(options?: FindTreeOptions & QueryParams<T>) {
|
|
||||||
const roots = await this.findRoots(options);
|
|
||||||
await Promise.all(roots.map((root) => this.findDescendantsTree(root, options)));
|
|
||||||
return roots;
|
|
||||||
}
|
|
||||||
|
|
||||||
async findDescendantsTree(entity: T, options?: FindTreeOptions & QueryParams<T>) {
|
|
||||||
const { addQuery, orderBy, withTrashed, onlyTrashed } = options ?? {};
|
|
||||||
let qb = this.buildBaseQB(
|
|
||||||
this.createDescendantsQueryBuilder(this.qbName, 'treeClosure', entity),
|
|
||||||
);
|
|
||||||
qb = addQuery
|
|
||||||
? await addQuery(this.addOrderByQuery(qb, orderBy))
|
|
||||||
: this.addOrderByQuery(qb, orderBy);
|
|
||||||
if (withTrashed) {
|
|
||||||
qb.withDeleted();
|
|
||||||
if (onlyTrashed) {
|
|
||||||
qb.where(`${this.qbName}.deletedAt IS NOT NULL`);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
FindOptionsUtils.applyOptionsToTreeQueryBuilder(qb, pick(options, ['relations', 'depth']));
|
|
||||||
const entities = await qb.getRawAndEntities();
|
|
||||||
const relationMaps = TreeRepositoryUtils.createRelationMaps(
|
|
||||||
this.manager,
|
|
||||||
this.metadata,
|
|
||||||
this.qbName,
|
|
||||||
entities.raw,
|
|
||||||
);
|
|
||||||
TreeRepositoryUtils.buildChildrenEntityTree(
|
|
||||||
this.metadata,
|
|
||||||
entity,
|
|
||||||
entities.entities,
|
|
||||||
relationMaps,
|
|
||||||
{ depth: -1, ...pick(options, ['relations']) },
|
|
||||||
);
|
|
||||||
return entity;
|
|
||||||
}
|
|
||||||
|
|
||||||
async toFlatTrees(trees: T[], depth = 0, parent: T | null = null) {
|
|
||||||
const data: Omit<T, 'children'>[] = [];
|
|
||||||
for (const item of trees) {
|
|
||||||
(item as any).depth = depth;
|
|
||||||
(item as any).parent = parent;
|
|
||||||
const { children } = item;
|
|
||||||
unset(item, 'children');
|
|
||||||
data.push(item);
|
|
||||||
data.push(...(await this.toFlatTrees(children, depth + 1, item)));
|
|
||||||
}
|
|
||||||
return data as T[];
|
|
||||||
}
|
|
||||||
}
|
|
@ -1,37 +0,0 @@
|
|||||||
import { ConfigureFactory, ConfigureRegister } from '../config/types';
|
|
||||||
import { createConnectionOptions } from '../config/utils';
|
|
||||||
import { deepMerge } from '../core/helpers';
|
|
||||||
|
|
||||||
import { DBConfig, DBOptions, TypeormOption } from './types';
|
|
||||||
|
|
||||||
export const createDBConfig: (
|
|
||||||
register: ConfigureRegister<RePartial<DBConfig>>,
|
|
||||||
) => ConfigureFactory<DBConfig, DBOptions> = (register) => ({
|
|
||||||
register,
|
|
||||||
hook: (configure, value) => createDBOptions(value),
|
|
||||||
defaultRegister: () => ({
|
|
||||||
common: { charset: 'utf8mb4', logging: ['error'] },
|
|
||||||
connections: [],
|
|
||||||
}),
|
|
||||||
});
|
|
||||||
|
|
||||||
export const createDBOptions = (options: DBConfig) => {
|
|
||||||
const newOptions: DBOptions = {
|
|
||||||
common: deepMerge(
|
|
||||||
{ charset: 'utf8mb4', logging: ['error'] },
|
|
||||||
options.common ?? {},
|
|
||||||
'replace',
|
|
||||||
),
|
|
||||||
connections: createConnectionOptions(options.connections ?? []),
|
|
||||||
};
|
|
||||||
newOptions.connections = newOptions.connections.map((connection) => {
|
|
||||||
const entities = connection.entities ?? [];
|
|
||||||
const newOption = { ...connection, entities };
|
|
||||||
return deepMerge(
|
|
||||||
newOptions.common,
|
|
||||||
{ ...newOption, autoLoadEntities: true } as any,
|
|
||||||
'replace',
|
|
||||||
) as TypeormOption;
|
|
||||||
});
|
|
||||||
return newOptions;
|
|
||||||
};
|
|
@ -1,18 +1,11 @@
|
|||||||
export const CUSTOM_REPOSITORY_METADATA = 'CUSTOM_REPOSITORY_METADATA';
|
export const CUSTOM_REPOSITORY_METADATA = 'CUSTOM_REPOSITORY_METADATA';
|
||||||
|
|
||||||
export enum SelectTrashMode {
|
export enum SelectTrashMode {
|
||||||
/**
|
// ALL: 包含已软删除和未软删除的数据(同时查询正常数据和回收站中的数据)
|
||||||
* ALL: 包含已软删除和未软删除的数据(同时查询正常数据和回收站中的数据)
|
|
||||||
*/
|
|
||||||
ALL = 'all',
|
ALL = 'all',
|
||||||
/**
|
// ONLY: 只包含软删除的数据 (只查询回收站中的数据)
|
||||||
* ONLY: 只包含软删除的数据 (只查询回收站中的数据)
|
|
||||||
*/
|
|
||||||
ONLY = 'only',
|
ONLY = 'only',
|
||||||
|
// NONE: 只包含未软删除的数据 (只查询正常数据)
|
||||||
/**
|
|
||||||
* NONE: 只包含未软删除的数据 (只查询正常数据)
|
|
||||||
*/
|
|
||||||
NONE = 'none',
|
NONE = 'none',
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -20,9 +13,3 @@ export enum OrderType {
|
|||||||
ASC = 'ASC',
|
ASC = 'ASC',
|
||||||
DESC = 'DESC',
|
DESC = 'DESC',
|
||||||
}
|
}
|
||||||
|
|
||||||
export enum TreeChildrenResolve {
|
|
||||||
DELETE = 'delete',
|
|
||||||
UP = 'up',
|
|
||||||
ROOT = 'root',
|
|
||||||
}
|
|
||||||
|
@ -1,14 +1,10 @@
|
|||||||
import { DynamicModule, Module, ModuleMetadata, Provider, Type } from '@nestjs/common';
|
import { DynamicModule, Module, Provider, Type } from '@nestjs/common';
|
||||||
import { getDataSourceToken, TypeOrmModule, TypeOrmModuleOptions } from '@nestjs/typeorm';
|
import { getDataSourceToken, TypeOrmModule, TypeOrmModuleOptions } from '@nestjs/typeorm';
|
||||||
|
|
||||||
import { DataSource, ObjectType } from 'typeorm';
|
import { DataSource, ObjectType } from 'typeorm';
|
||||||
|
|
||||||
import { CUSTOM_REPOSITORY_METADATA } from '@/modules/database/constants';
|
import { CUSTOM_REPOSITORY_METADATA } from '@/modules/database/constants';
|
||||||
|
|
||||||
import { Configure } from '../config/configure';
|
|
||||||
|
|
||||||
import { panic } from '../core/helpers';
|
|
||||||
|
|
||||||
import {
|
import {
|
||||||
DataExistConstraint,
|
DataExistConstraint,
|
||||||
TreeUniqueConstraint,
|
TreeUniqueConstraint,
|
||||||
@ -16,31 +12,21 @@ import {
|
|||||||
UniqueConstraint,
|
UniqueConstraint,
|
||||||
UniqueExistConstraint,
|
UniqueExistConstraint,
|
||||||
} from './constraints';
|
} from './constraints';
|
||||||
import { DBOptions } from './types';
|
|
||||||
|
|
||||||
@Module({})
|
@Module({})
|
||||||
export class DatabaseModule {
|
export class DatabaseModule {
|
||||||
static async forRoot(configure: Configure): Promise<DynamicModule> {
|
static forRoot(configRegister: () => TypeOrmModuleOptions): DynamicModule {
|
||||||
if (!configure.has('database')) {
|
|
||||||
panic({ message: 'Database config not exists' });
|
|
||||||
}
|
|
||||||
const { connections } = await configure.get<DBOptions>('database');
|
|
||||||
const imports: ModuleMetadata['imports'] = [];
|
|
||||||
for (const connection of connections) {
|
|
||||||
imports.push(TypeOrmModule.forRoot(connection as TypeOrmModuleOptions));
|
|
||||||
}
|
|
||||||
const providers: ModuleMetadata['providers'] = [
|
|
||||||
DataExistConstraint,
|
|
||||||
UniqueConstraint,
|
|
||||||
UniqueExistConstraint,
|
|
||||||
TreeUniqueConstraint,
|
|
||||||
TreeUniqueExistContraint,
|
|
||||||
];
|
|
||||||
return {
|
return {
|
||||||
global: true,
|
global: true,
|
||||||
module: DatabaseModule,
|
module: DatabaseModule,
|
||||||
imports,
|
imports: [TypeOrmModule.forRoot(configRegister())],
|
||||||
providers,
|
providers: [
|
||||||
|
DataExistConstraint,
|
||||||
|
UniqueConstraint,
|
||||||
|
UniqueExistConstraint,
|
||||||
|
TreeUniqueConstraint,
|
||||||
|
TreeUniqueExistContraint,
|
||||||
|
],
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
static forRepository<T extends Type<any>>(
|
static forRepository<T extends Type<any>>(
|
||||||
|
@ -1,16 +1,6 @@
|
|||||||
import { TypeOrmModuleOptions } from '@nestjs/typeorm';
|
import { ObjectLiteral, SelectQueryBuilder } from 'typeorm';
|
||||||
import {
|
|
||||||
FindTreeOptions,
|
|
||||||
ObjectLiteral,
|
|
||||||
SelectQueryBuilder,
|
|
||||||
Repository,
|
|
||||||
TreeRepository,
|
|
||||||
} from 'typeorm';
|
|
||||||
|
|
||||||
import { OrderType, SelectTrashMode } from '@/modules/database/constants';
|
import { OrderType } from '@/modules/database/constants';
|
||||||
|
|
||||||
import { BaseRepository } from './base/repository';
|
|
||||||
import { BaseTreeRepository } from './base/tree.repository';
|
|
||||||
|
|
||||||
export type QueryHook<Entity> = (
|
export type QueryHook<Entity> = (
|
||||||
qb: SelectQueryBuilder<Entity>,
|
qb: SelectQueryBuilder<Entity>,
|
||||||
@ -38,45 +28,3 @@ export type OrderQueryType =
|
|||||||
| string
|
| string
|
||||||
| { name: string; order: `${OrderType}` }
|
| { name: string; order: `${OrderType}` }
|
||||||
| Array<string | { name: string; order: `${OrderType}` }>;
|
| Array<string | { name: string; order: `${OrderType}` }>;
|
||||||
|
|
||||||
export interface QueryParams<T extends ObjectLiteral> {
|
|
||||||
addQuery?: QueryHook<T>;
|
|
||||||
|
|
||||||
orderBy?: OrderQueryType;
|
|
||||||
|
|
||||||
withTrashed?: boolean;
|
|
||||||
|
|
||||||
onlyTrashed?: boolean;
|
|
||||||
}
|
|
||||||
|
|
||||||
export type ServiceListQueryOptionWithTrashed<T extends ObjectLiteral> = Omit<
|
|
||||||
FindTreeOptions & QueryParams<T>,
|
|
||||||
'withTrashed'
|
|
||||||
> & { trashed?: `${SelectTrashMode}` } & RecordAny;
|
|
||||||
|
|
||||||
export type ServiceListQueryOptionNotWithTrashed<T extends ObjectLiteral> = Omit<
|
|
||||||
ServiceListQueryOptionWithTrashed<T>,
|
|
||||||
'trashed'
|
|
||||||
>;
|
|
||||||
|
|
||||||
export type ServiceListQueryOption<T extends ObjectLiteral> =
|
|
||||||
| ServiceListQueryOptionNotWithTrashed<T>
|
|
||||||
| ServiceListQueryOptionWithTrashed<T>;
|
|
||||||
|
|
||||||
export type RepositoryType<T extends ObjectLiteral> =
|
|
||||||
| Repository<T>
|
|
||||||
| TreeRepository<T>
|
|
||||||
| BaseRepository<T>
|
|
||||||
| BaseTreeRepository<T>;
|
|
||||||
|
|
||||||
export type DBConfig = {
|
|
||||||
common: RecordAny;
|
|
||||||
connections: Array<TypeOrmModuleOptions & { name?: string }>;
|
|
||||||
};
|
|
||||||
|
|
||||||
export type TypeormOption = Omit<TypeOrmModuleOptions, 'name' | 'migrations'> & { name: string };
|
|
||||||
|
|
||||||
export type DBOptions = RecordAny & {
|
|
||||||
common: RecordAny;
|
|
||||||
connections: TypeormOption[];
|
|
||||||
};
|
|
||||||
|
@ -1,10 +1,8 @@
|
|||||||
import { isArray, isNil } from 'lodash';
|
import { isArray, isNil } from 'lodash';
|
||||||
import { DataSource, ObjectLiteral, ObjectType, Repository, SelectQueryBuilder } from 'typeorm';
|
import { ObjectLiteral, SelectQueryBuilder } from 'typeorm';
|
||||||
|
|
||||||
import { OrderQueryType, PaginateOptions, PaginateReturn } from '@/modules/database/types';
|
import { OrderQueryType, PaginateOptions, PaginateReturn } from '@/modules/database/types';
|
||||||
|
|
||||||
import { CUSTOM_REPOSITORY_METADATA } from './constants';
|
|
||||||
|
|
||||||
export const paginate = async <T extends ObjectLiteral>(
|
export const paginate = async <T extends ObjectLiteral>(
|
||||||
qb: SelectQueryBuilder<T>,
|
qb: SelectQueryBuilder<T>,
|
||||||
options: PaginateOptions,
|
options: PaginateOptions,
|
||||||
@ -82,18 +80,3 @@ export const getOrderByQuery = <T extends ObjectLiteral>(
|
|||||||
}
|
}
|
||||||
return qb.orderBy(`${alias}.${(orderBy as any).name}`, (orderBy as any).order);
|
return qb.orderBy(`${alias}.${(orderBy as any).name}`, (orderBy as any).order);
|
||||||
};
|
};
|
||||||
|
|
||||||
export const getCustomRepository = <P extends Repository<T>, T extends ObjectLiteral>(
|
|
||||||
dataSource: DataSource,
|
|
||||||
Repo: ClassType<P>,
|
|
||||||
): P => {
|
|
||||||
if (isNil(Repo)) {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
const entity = Reflect.getMetadata(CUSTOM_REPOSITORY_METADATA, Repo);
|
|
||||||
if (!entity) {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
const base = dataSource.getRepository<ObjectType<any>>(entity);
|
|
||||||
return new Repo(base.target, base.manager, base.queryRunner) as P;
|
|
||||||
};
|
|
||||||
|
@ -1,11 +0,0 @@
|
|||||||
import { ConfigureFactory, ConfigureRegister } from '../config/types';
|
|
||||||
import { createConnectionOptions } from '../config/utils';
|
|
||||||
|
|
||||||
import { MeiliConfig } from './types';
|
|
||||||
|
|
||||||
export const createMeiliConfig: (
|
|
||||||
registre: ConfigureRegister<RePartial<MeiliConfig>>,
|
|
||||||
) => ConfigureFactory<MeiliConfig, MeiliConfig> = (register) => ({
|
|
||||||
register,
|
|
||||||
hook: (configure, value) => createConnectionOptions(value),
|
|
||||||
});
|
|
9
src/modules/meilisearch/meili.config.ts
Normal file
9
src/modules/meilisearch/meili.config.ts
Normal file
@ -0,0 +1,9 @@
|
|||||||
|
import { MeiliConfig } from '@/modules/meilisearch/types';
|
||||||
|
|
||||||
|
export const MEILI_CONFIG = (): MeiliConfig => [
|
||||||
|
{
|
||||||
|
name: 'default',
|
||||||
|
host: 'http://localhost:7700',
|
||||||
|
apiKey: 'masterKey',
|
||||||
|
},
|
||||||
|
];
|
@ -1,16 +1,12 @@
|
|||||||
import { DynamicModule, Module } from '@nestjs/common';
|
import { DynamicModule, Module } from '@nestjs/common';
|
||||||
|
|
||||||
import { MeiliService } from '@/modules/meilisearch/meili.service';
|
import { MeiliService } from '@/modules/meilisearch/meili.service';
|
||||||
|
import { MeiliConfig } from '@/modules/meilisearch/types';
|
||||||
import { Configure } from '../config/configure';
|
import { createMeiliOptions } from '@/modules/meilisearch/utils';
|
||||||
import { panic } from '../core/helpers';
|
|
||||||
|
|
||||||
@Module({})
|
@Module({})
|
||||||
export class MeiliModule {
|
export class MeiliModule {
|
||||||
static forRoot(configure: Configure): DynamicModule {
|
static forRoot(configRegister: () => MeiliConfig): DynamicModule {
|
||||||
if (!configure.has('meili')) {
|
|
||||||
panic({ message: 'MeilliSearch config not exists' });
|
|
||||||
}
|
|
||||||
return {
|
return {
|
||||||
global: true,
|
global: true,
|
||||||
module: MeiliModule,
|
module: MeiliModule,
|
||||||
@ -18,7 +14,9 @@ export class MeiliModule {
|
|||||||
{
|
{
|
||||||
provide: MeiliService,
|
provide: MeiliService,
|
||||||
useFactory: async () => {
|
useFactory: async () => {
|
||||||
const service = new MeiliService(await configure.get('meili'));
|
const service = new MeiliService(
|
||||||
|
await createMeiliOptions(configRegister()),
|
||||||
|
);
|
||||||
await service.createClients();
|
await service.createClients();
|
||||||
return service;
|
return service;
|
||||||
},
|
},
|
||||||
|
18
src/modules/meilisearch/utils.ts
Normal file
18
src/modules/meilisearch/utils.ts
Normal file
@ -0,0 +1,18 @@
|
|||||||
|
import { MeiliConfig } from '@/modules/meilisearch/types';
|
||||||
|
|
||||||
|
export const createMeiliOptions = async (config: MeiliConfig): Promise<MeiliConfig | undefined> => {
|
||||||
|
if (config.length < 0) {
|
||||||
|
return config;
|
||||||
|
}
|
||||||
|
let options: MeiliConfig = [...config];
|
||||||
|
const names = options.map(({ name }) => name);
|
||||||
|
if (!names.includes('default')) {
|
||||||
|
options[0].name = 'default';
|
||||||
|
} else if (names.filter((name) => name === 'default').length > 0) {
|
||||||
|
options = options.reduce(
|
||||||
|
(o, n) => (o.map(({ name }) => name).includes('default') ? o : [...o, n]),
|
||||||
|
[],
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return options;
|
||||||
|
};
|
@ -1,116 +0,0 @@
|
|||||||
import { Type } from '@nestjs/common';
|
|
||||||
import { Routes } from '@nestjs/core';
|
|
||||||
|
|
||||||
import { pick } from 'lodash';
|
|
||||||
|
|
||||||
import { Configure } from '../config/configure';
|
|
||||||
|
|
||||||
import { ApiConfig, RouteOption } from './types';
|
|
||||||
import { createRouteModuleTree, getCleanRoutes, getRoutePath } from './utils';
|
|
||||||
|
|
||||||
export abstract class BaseRestful {
|
|
||||||
constructor(protected configure: Configure) {}
|
|
||||||
|
|
||||||
abstract create(_config: ApiConfig): void;
|
|
||||||
|
|
||||||
protected config!: ApiConfig;
|
|
||||||
|
|
||||||
protected _routes: Routes = [];
|
|
||||||
|
|
||||||
protected _default!: string;
|
|
||||||
|
|
||||||
protected _versions: string[] = [];
|
|
||||||
|
|
||||||
protected _modules: { [key: string]: Type<any> } = {};
|
|
||||||
|
|
||||||
get routes() {
|
|
||||||
return this._routes;
|
|
||||||
}
|
|
||||||
|
|
||||||
get default() {
|
|
||||||
return this._default;
|
|
||||||
}
|
|
||||||
|
|
||||||
get versions() {
|
|
||||||
return this._versions;
|
|
||||||
}
|
|
||||||
|
|
||||||
get modules() {
|
|
||||||
return this._modules;
|
|
||||||
}
|
|
||||||
|
|
||||||
protected createConfig(config: ApiConfig) {
|
|
||||||
if (!config.default) {
|
|
||||||
throw new Error('default api version name should be config!');
|
|
||||||
}
|
|
||||||
|
|
||||||
const versionMaps = Object.entries(config.versions)
|
|
||||||
.filter(([name]) => (config.default === name ? true : config.enabled.includes(name)))
|
|
||||||
.map(([name, version]) => [
|
|
||||||
name,
|
|
||||||
{
|
|
||||||
...pick(config, ['title', 'description', 'auth']),
|
|
||||||
...version,
|
|
||||||
tags: Array.from(new Set([...(config.tags ?? []), ...(version.tags ?? [])])),
|
|
||||||
routes: getCleanRoutes(version.routes ?? []),
|
|
||||||
},
|
|
||||||
]);
|
|
||||||
config.versions = Object.fromEntries(versionMaps);
|
|
||||||
this._versions = Object.keys(config.versions);
|
|
||||||
this._default = config.default;
|
|
||||||
|
|
||||||
if (!this._versions.includes(this._default)) {
|
|
||||||
throw new Error(`Default api version named ${this._default} not found!`);
|
|
||||||
}
|
|
||||||
this.config = config;
|
|
||||||
}
|
|
||||||
|
|
||||||
protected async createRoutes() {
|
|
||||||
const prefix = await this.configure.get<string>('app.prefix');
|
|
||||||
const versionMaps = Object.entries(this.config.versions);
|
|
||||||
|
|
||||||
this._routes = (
|
|
||||||
await Promise.all(
|
|
||||||
versionMaps.map(async ([name, version]) =>
|
|
||||||
(
|
|
||||||
await createRouteModuleTree(
|
|
||||||
this.configure,
|
|
||||||
this._modules,
|
|
||||||
version.routes ?? [],
|
|
||||||
name,
|
|
||||||
)
|
|
||||||
).map((route) => ({
|
|
||||||
...route,
|
|
||||||
path: getRoutePath(route.path, prefix, name),
|
|
||||||
})),
|
|
||||||
),
|
|
||||||
)
|
|
||||||
).reduce((o, n) => [...o, ...n], []);
|
|
||||||
const defaultVersion = this.config.versions[this._default];
|
|
||||||
this._routes = [
|
|
||||||
...this._routes,
|
|
||||||
...(
|
|
||||||
await createRouteModuleTree(
|
|
||||||
this.configure,
|
|
||||||
this._modules,
|
|
||||||
defaultVersion.routes ?? [],
|
|
||||||
)
|
|
||||||
).map((route) => ({ ...route, path: getRoutePath(route.path, prefix) })),
|
|
||||||
];
|
|
||||||
}
|
|
||||||
|
|
||||||
protected getRouteModules(routes: RouteOption[], parent?: string) {
|
|
||||||
const result = routes
|
|
||||||
.map(({ name, children }) => {
|
|
||||||
const routeName = parent ? `${parent}.${name}` : name;
|
|
||||||
let modules: Type<any>[] = [this._modules[routeName]];
|
|
||||||
if (children) {
|
|
||||||
modules = [...modules, ...this.getRouteModules(children, routeName)];
|
|
||||||
}
|
|
||||||
return modules;
|
|
||||||
})
|
|
||||||
.reduce((o, n) => [...o, ...n], [])
|
|
||||||
.filter((i) => !!i);
|
|
||||||
return result;
|
|
||||||
}
|
|
||||||
}
|
|
@ -1 +0,0 @@
|
|||||||
export const CONTROLLER_DEPENDS = 'controller_depends';
|
|
@ -1,5 +0,0 @@
|
|||||||
import { SetMetadata, Type } from '@nestjs/common';
|
|
||||||
|
|
||||||
import { CONTROLLER_DEPENDS } from '../constants';
|
|
||||||
|
|
||||||
export const Depends = (...depends: Type<any>[]) => SetMetadata(CONTROLLER_DEPENDS, depends ?? []);
|
|
@ -1,16 +0,0 @@
|
|||||||
import { IsEnum, IsOptional } from 'class-validator';
|
|
||||||
|
|
||||||
import { DtoValidation } from '@/modules/core/decorator/dto.validation.decorator';
|
|
||||||
import { SelectTrashMode } from '@/modules/database/constants';
|
|
||||||
|
|
||||||
import { PaginateDto } from './paginate.dto';
|
|
||||||
|
|
||||||
@DtoValidation({ type: 'query' })
|
|
||||||
export class PaginateWithTrashedDto extends PaginateDto {
|
|
||||||
/**
|
|
||||||
* 根据软删除状态查询
|
|
||||||
*/
|
|
||||||
@IsEnum(SelectTrashMode)
|
|
||||||
@IsOptional()
|
|
||||||
trashed?: SelectTrashMode;
|
|
||||||
}
|
|
@ -1,30 +0,0 @@
|
|||||||
import { Transform } from 'class-transformer';
|
|
||||||
import { Min, IsNumber, IsOptional } from 'class-validator';
|
|
||||||
import { toNumber } from 'lodash';
|
|
||||||
|
|
||||||
import { DtoValidation } from '@/modules/core/decorator/dto.validation.decorator';
|
|
||||||
import { PaginateOptions } from '@/modules/database/types';
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 分页数据查询验证
|
|
||||||
*/
|
|
||||||
@DtoValidation({ type: 'query' })
|
|
||||||
export class PaginateDto implements PaginateOptions {
|
|
||||||
/**
|
|
||||||
* 当前页
|
|
||||||
*/
|
|
||||||
@Transform(({ value }) => toNumber(value))
|
|
||||||
@Min(1, { message: '当前页必须大于1' })
|
|
||||||
@IsNumber()
|
|
||||||
@IsOptional()
|
|
||||||
page?: number = 1;
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 每页数据量
|
|
||||||
*/
|
|
||||||
@Transform(({ value }) => toNumber(value))
|
|
||||||
@Min(1, { message: '每页显示数据必须大于1' })
|
|
||||||
@IsNumber()
|
|
||||||
@IsOptional()
|
|
||||||
limit?: number = 10;
|
|
||||||
}
|
|
@ -1,24 +0,0 @@
|
|||||||
import { DynamicModule } from '@nestjs/common';
|
|
||||||
|
|
||||||
import { Configure } from '../config/configure';
|
|
||||||
|
|
||||||
import { Restful } from './restful';
|
|
||||||
|
|
||||||
export class RestfulModule {
|
|
||||||
static async forRoot(configure: Configure): Promise<DynamicModule> {
|
|
||||||
const restful = new Restful(configure);
|
|
||||||
await restful.create(await configure.get('api'));
|
|
||||||
return {
|
|
||||||
module: RestfulModule,
|
|
||||||
global: true,
|
|
||||||
imports: restful.getModuleImports(),
|
|
||||||
providers: [
|
|
||||||
{
|
|
||||||
provide: Restful,
|
|
||||||
useValue: restful,
|
|
||||||
},
|
|
||||||
],
|
|
||||||
exports: [Restful],
|
|
||||||
};
|
|
||||||
}
|
|
||||||
}
|
|
@ -1,168 +0,0 @@
|
|||||||
import { INestApplication, Injectable, Type } from '@nestjs/common';
|
|
||||||
import { RouterModule } from '@nestjs/core';
|
|
||||||
|
|
||||||
import { DocumentBuilder, SwaggerModule } from '@nestjs/swagger';
|
|
||||||
import { isNil, omit, trim } from 'lodash';
|
|
||||||
|
|
||||||
import { BaseRestful } from './base';
|
|
||||||
import {
|
|
||||||
ApiConfig,
|
|
||||||
ApiDocOption,
|
|
||||||
ApiDocSource,
|
|
||||||
RouteOption,
|
|
||||||
SwaggerOption,
|
|
||||||
VersionOption,
|
|
||||||
} from './types';
|
|
||||||
import { trimPath } from './utils';
|
|
||||||
|
|
||||||
@Injectable()
|
|
||||||
export class Restful extends BaseRestful {
|
|
||||||
protected _docs!: { [version: string]: ApiDocOption };
|
|
||||||
|
|
||||||
protected excludeVersionModules: string[] = [];
|
|
||||||
|
|
||||||
get docs() {
|
|
||||||
return this._docs;
|
|
||||||
}
|
|
||||||
|
|
||||||
async create(config: ApiConfig) {
|
|
||||||
this.createConfig(config);
|
|
||||||
await this.createRoutes();
|
|
||||||
this.createDocs();
|
|
||||||
}
|
|
||||||
|
|
||||||
getModuleImports() {
|
|
||||||
return [RouterModule.register(this.routes), ...Object.values(this.modules)];
|
|
||||||
}
|
|
||||||
|
|
||||||
protected getRouteDocs(
|
|
||||||
option: Omit<SwaggerOption, 'include'>,
|
|
||||||
routes: RouteOption[],
|
|
||||||
parent?: string,
|
|
||||||
): { [key: string]: SwaggerOption } {
|
|
||||||
const mergeDoc = (vDoc: Omit<SwaggerOption, 'include'>, route: RouteOption) => ({
|
|
||||||
...vDoc,
|
|
||||||
...route.doc,
|
|
||||||
tags: Array.from(new Set([...(vDoc.tags ?? []), ...(route.doc?.tags ?? [])])),
|
|
||||||
path: genDocPath(route.path, this.config.docuri, parent),
|
|
||||||
include: this.getRouteModules([route], parent),
|
|
||||||
});
|
|
||||||
let routeDocs: { [key: string]: SwaggerOption } = {};
|
|
||||||
const hasAdditional = (doc?: ApiDocSource) =>
|
|
||||||
doc && Object.keys(omit(doc, 'tags')).length > 0;
|
|
||||||
for (const route of routes) {
|
|
||||||
const { name, doc, children } = route;
|
|
||||||
const moduleName = parent ? `${parent}.${name}` : name;
|
|
||||||
|
|
||||||
if (hasAdditional(doc) || parent) {
|
|
||||||
this.excludeVersionModules.push(moduleName);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (hasAdditional(doc)) {
|
|
||||||
routeDocs[moduleName.replace(`${option.version}.`, '')] = mergeDoc(option, route);
|
|
||||||
}
|
|
||||||
if (children) {
|
|
||||||
routeDocs = { ...routeDocs, ...this.getRouteDocs(option, children, moduleName) };
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return routeDocs;
|
|
||||||
}
|
|
||||||
|
|
||||||
protected filterExcludeModules(routeModules: Type<any>[]) {
|
|
||||||
const excludeModules: Type<any>[] = [];
|
|
||||||
const excludeNames = Array.from(new Set(this.excludeVersionModules));
|
|
||||||
for (const [name, module] of Object.entries(this._modules)) {
|
|
||||||
if (excludeNames.includes(name)) {
|
|
||||||
excludeModules.push(module);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return routeModules.filter(
|
|
||||||
(module) => !excludeModules.find((emodule) => emodule === module),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
protected getDocOption(name: string, voption: VersionOption, isDefault = false) {
|
|
||||||
const docConfig: ApiDocOption = {};
|
|
||||||
const defaultDoc = {
|
|
||||||
title: voption.title!,
|
|
||||||
description: voption.description!,
|
|
||||||
tags: voption.tags ?? [],
|
|
||||||
auth: voption.auth ?? false,
|
|
||||||
version: name,
|
|
||||||
path: trim(`${this.config.docuri}${isDefault ? '' : `/${name}`}`, '/'),
|
|
||||||
};
|
|
||||||
|
|
||||||
const routesDoc = isDefault
|
|
||||||
? this.getRouteDocs(defaultDoc, voption.routes ?? [])
|
|
||||||
: this.getRouteDocs(defaultDoc, voption.routes ?? [], name);
|
|
||||||
if (Object.keys(routesDoc).length > 0) {
|
|
||||||
docConfig.routes = routesDoc;
|
|
||||||
}
|
|
||||||
const routeModules = isDefault
|
|
||||||
? this.getRouteModules(voption.routes ?? [])
|
|
||||||
: this.getRouteModules(voption.routes ?? [], name);
|
|
||||||
const include = this.filterExcludeModules(routeModules);
|
|
||||||
if (include.length > 0 || !docConfig.routes) {
|
|
||||||
docConfig.default = { ...defaultDoc, include };
|
|
||||||
}
|
|
||||||
return docConfig;
|
|
||||||
}
|
|
||||||
|
|
||||||
protected createDocs() {
|
|
||||||
const versionMaps = Object.entries(this.config.versions);
|
|
||||||
const vDocs = versionMaps.map(([name, version]) => [
|
|
||||||
name,
|
|
||||||
this.getDocOption(name, version),
|
|
||||||
]);
|
|
||||||
this._docs = Object.fromEntries(vDocs);
|
|
||||||
const defaultVersion = this.config.versions[this._default];
|
|
||||||
this._docs.default = this.getDocOption(this._default, defaultVersion, true);
|
|
||||||
}
|
|
||||||
|
|
||||||
async factoryDocs<T extends INestApplication>(
|
|
||||||
container: T,
|
|
||||||
metadata?: () => Promise<RecordAny>,
|
|
||||||
) {
|
|
||||||
const docs = Object.values(this._docs)
|
|
||||||
.map((doc) => [doc.default, ...Object.values(doc.routes ?? [])])
|
|
||||||
.reduce((o, n) => [...o, ...n], [])
|
|
||||||
.filter((i) => !!i);
|
|
||||||
|
|
||||||
for (const voption of docs) {
|
|
||||||
const { title, description, version, auth, include, tags } = voption!;
|
|
||||||
const builder = new DocumentBuilder();
|
|
||||||
if (title) {
|
|
||||||
builder.setTitle(title);
|
|
||||||
}
|
|
||||||
if (description) {
|
|
||||||
builder.setDescription(description);
|
|
||||||
}
|
|
||||||
if (auth) {
|
|
||||||
builder.addBearerAuth();
|
|
||||||
}
|
|
||||||
if (tags) {
|
|
||||||
tags.forEach((tag) =>
|
|
||||||
typeof tag === 'string'
|
|
||||||
? builder.addTag(tag)
|
|
||||||
: builder.addTag(tag.name, tag.description, tag.externalDocs),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
builder.setVersion(version);
|
|
||||||
|
|
||||||
if (!isNil(metadata)) {
|
|
||||||
await SwaggerModule.loadPluginMetadata(metadata);
|
|
||||||
}
|
|
||||||
|
|
||||||
const document = SwaggerModule.createDocument(container, builder.build(), {
|
|
||||||
include: include.length > 0 ? include : [() => undefined as any],
|
|
||||||
ignoreGlobalPrefix: true,
|
|
||||||
deepScanRoutes: true,
|
|
||||||
});
|
|
||||||
SwaggerModule.setup(voption!.path, container, document);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
export function genDocPath(routePath: string, prefix?: string, version?: string) {
|
|
||||||
return trimPath(`${prefix}${version ? `/${version.toLowerCase()}/` : '/'}${routePath}`, false);
|
|
||||||
}
|
|
@ -1,45 +0,0 @@
|
|||||||
import { Type } from '@nestjs/common';
|
|
||||||
import { ExternalDocumentationObject } from '@nestjs/swagger/dist/interfaces/open-api-spec.interface';
|
|
||||||
|
|
||||||
export interface TagOption {
|
|
||||||
name: string;
|
|
||||||
description?: string;
|
|
||||||
externalDocs?: ExternalDocumentationObject;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface ApiDocSource {
|
|
||||||
title?: string;
|
|
||||||
description?: string;
|
|
||||||
auth?: boolean;
|
|
||||||
tags?: (string | TagOption)[];
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface ApiConfig extends ApiDocSource {
|
|
||||||
docuri?: string;
|
|
||||||
default: string;
|
|
||||||
enabled: string[];
|
|
||||||
versions: Record<string, VersionOption>;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface VersionOption extends ApiDocSource {
|
|
||||||
routes?: RouteOption[];
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface RouteOption {
|
|
||||||
name: string;
|
|
||||||
path: string;
|
|
||||||
controllers: Type<any>[];
|
|
||||||
children?: RouteOption[];
|
|
||||||
doc?: ApiDocSource;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface SwaggerOption extends ApiDocSource {
|
|
||||||
version: string;
|
|
||||||
path: string;
|
|
||||||
include: Type<any>[];
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface ApiDocOption {
|
|
||||||
default?: SwaggerOption;
|
|
||||||
routes?: { [key: string]: SwaggerOption };
|
|
||||||
}
|
|
@ -1,141 +0,0 @@
|
|||||||
import { Type } from '@nestjs/common';
|
|
||||||
import { Routes, RouteTree } from '@nestjs/core';
|
|
||||||
import { NestFastifyApplication } from '@nestjs/platform-fastify';
|
|
||||||
import { ApiTags } from '@nestjs/swagger';
|
|
||||||
import chalk from 'chalk';
|
|
||||||
import { camelCase, isNil, omit, trim, upperFirst } from 'lodash';
|
|
||||||
|
|
||||||
import { Configure } from '../config/configure';
|
|
||||||
|
|
||||||
import { CreateModule } from '../core/helpers';
|
|
||||||
|
|
||||||
import { App } from '../core/types';
|
|
||||||
|
|
||||||
import { CONTROLLER_DEPENDS } from './constants';
|
|
||||||
import { Restful } from './restful';
|
|
||||||
import { ApiDocOption, RouteOption } from './types';
|
|
||||||
|
|
||||||
export const trimPath = (routePath: string, addPrefix = true) =>
|
|
||||||
`${addPrefix ? '/' : ''}${trim(routePath.replace('//', '/'), '/')}`;
|
|
||||||
|
|
||||||
export const getCleanRoutes = (data: RouteOption[]): RouteOption[] =>
|
|
||||||
data.map((option) => {
|
|
||||||
const route: RouteOption = {
|
|
||||||
...omit(option, 'children'),
|
|
||||||
path: trimPath(option.path),
|
|
||||||
};
|
|
||||||
if (option.children && option.children.length > 0) {
|
|
||||||
route.children = getCleanRoutes(option.children);
|
|
||||||
} else {
|
|
||||||
delete route.children;
|
|
||||||
}
|
|
||||||
return route;
|
|
||||||
});
|
|
||||||
|
|
||||||
export function getRoutePath(routePath: string, prefix?: string, version?: string) {
|
|
||||||
const addVersion = `${version ? `/${version.toLowerCase()}/` : '/'}${routePath}`;
|
|
||||||
return isNil(prefix) ? trimPath(addVersion) : trimPath(`${prefix}${addVersion}`);
|
|
||||||
}
|
|
||||||
|
|
||||||
export function createRouteModuleTree(
|
|
||||||
configure: Configure,
|
|
||||||
modules: { [key: string]: Type<any> },
|
|
||||||
routes: RouteOption[],
|
|
||||||
parentModule?: string,
|
|
||||||
): Promise<Routes> {
|
|
||||||
return Promise.all(
|
|
||||||
routes.map(async ({ name, path, children, controllers, doc }) => {
|
|
||||||
const moduleName = parentModule ? `${parentModule}.${name}` : name;
|
|
||||||
if (Object.keys(modules).includes(moduleName)) {
|
|
||||||
throw new Error(
|
|
||||||
`route name must be unique in same level, ${moduleName} has exists`,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
const depends = controllers
|
|
||||||
.map((o) => Reflect.getMetadata(CONTROLLER_DEPENDS, o) || [])
|
|
||||||
.reduce((o: Type<any>[], n) => [...o, ...n], [])
|
|
||||||
.reduce((o: Type<any>[], n: Type<any>) => {
|
|
||||||
if (o.find((i) => i === n)) {
|
|
||||||
return o;
|
|
||||||
}
|
|
||||||
return [...o, n];
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
if (doc?.tags && doc.tags.length > 0) {
|
|
||||||
controllers.forEach((o) => {
|
|
||||||
!Reflect.getMetadata('swagger/apiUseTags', o) &&
|
|
||||||
ApiTags(
|
|
||||||
...doc.tags.map((tag) => (typeof tag === 'string' ? tag : tag.name)),
|
|
||||||
)(o);
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
const module = CreateModule(`${upperFirst(camelCase(name))}RouteModule`, () => ({
|
|
||||||
controllers,
|
|
||||||
imports: depends,
|
|
||||||
}));
|
|
||||||
|
|
||||||
modules[moduleName] = module;
|
|
||||||
const route: RouteTree = { path, module };
|
|
||||||
if (children) {
|
|
||||||
route.children = await createRouteModuleTree(
|
|
||||||
configure,
|
|
||||||
modules,
|
|
||||||
children,
|
|
||||||
moduleName,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
return route;
|
|
||||||
}),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function echoApi(configure: Configure, container: NestFastifyApplication) {
|
|
||||||
const appUrl = await configure.get<string>('app.url');
|
|
||||||
const urlPrefix = await configure.get<string>('api.prefix', undefined);
|
|
||||||
const apiUrl = isNil(urlPrefix)
|
|
||||||
? appUrl
|
|
||||||
: `${appUrl}${urlPrefix.length > 0 ? `/${urlPrefix}` : urlPrefix}`;
|
|
||||||
console.log(`- RestAPI: ${chalk.green.underline(apiUrl)}`);
|
|
||||||
console.log('- RestDocs');
|
|
||||||
const factory = container.get(Restful);
|
|
||||||
const { default: defaultDoc, ...docs } = factory.docs;
|
|
||||||
await echoApiDocs('default', defaultDoc, appUrl);
|
|
||||||
for (const [name, doc] of Object.entries(docs)) {
|
|
||||||
console.log();
|
|
||||||
await echoApiDocs(name, doc, appUrl);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
export const listened: (app: App, startTime: Date) => () => Promise<void> =
|
|
||||||
({ configure, container }, startTime) =>
|
|
||||||
async () => {
|
|
||||||
console.log();
|
|
||||||
await echoApi(configure, container);
|
|
||||||
console.log('used time: ', chalk.cyan(`${new Date().getTime() - startTime.getTime()}`));
|
|
||||||
};
|
|
||||||
|
|
||||||
async function echoApiDocs(name: string, doc: ApiDocOption, appUrl: string) {
|
|
||||||
const getDocPath = (path: string) => `${appUrl}/${path}`;
|
|
||||||
if (!doc.routes && doc.default) {
|
|
||||||
console.log(
|
|
||||||
`[${chalk.blue(name.toUpperCase())}]:${chalk.green.underline(
|
|
||||||
getDocPath(doc.default.path),
|
|
||||||
)}`,
|
|
||||||
);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
console.log(`[${chalk.blue(name.toUpperCase())}]`);
|
|
||||||
if (doc.default) {
|
|
||||||
console.log(`default:${chalk.green.underline(getDocPath(doc.default.path))}`);
|
|
||||||
}
|
|
||||||
if (doc.routes) {
|
|
||||||
Object.entries(doc.routes).forEach(([, docs]) => {
|
|
||||||
console.log(
|
|
||||||
`<${chalk.yellowBright.bold(docs.title)}>: ${chalk.green.underline(
|
|
||||||
getDocPath(docs.path),
|
|
||||||
)}`,
|
|
||||||
);
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
|
@ -1,51 +0,0 @@
|
|||||||
import { join } from 'path';
|
|
||||||
|
|
||||||
import { NestFactory } from '@nestjs/core';
|
|
||||||
|
|
||||||
import { FastifyAdapter, NestFastifyApplication } from '@nestjs/platform-fastify';
|
|
||||||
|
|
||||||
import { existsSync } from 'fs-extra';
|
|
||||||
import { isNil } from 'lodash';
|
|
||||||
|
|
||||||
import * as configs from './config';
|
|
||||||
import { ContentModule } from './modules/content/content.module';
|
|
||||||
import { CreateOptions } from './modules/core/types';
|
|
||||||
import { DatabaseModule } from './modules/database/database.module';
|
|
||||||
import { MeiliModule } from './modules/meilisearch/meili.module';
|
|
||||||
import { Restful } from './modules/restful/restful';
|
|
||||||
import { RestfulModule } from './modules/restful/restful.module';
|
|
||||||
import { ApiConfig } from './modules/restful/types';
|
|
||||||
|
|
||||||
export const createOptions: CreateOptions = {
|
|
||||||
config: { factories: configs as any, storage: { enable: true } },
|
|
||||||
modules: async (configure) => [
|
|
||||||
DatabaseModule.forRoot(configure),
|
|
||||||
MeiliModule.forRoot(configure),
|
|
||||||
RestfulModule.forRoot(configure),
|
|
||||||
ContentModule.forRoot(configure),
|
|
||||||
],
|
|
||||||
globals: {},
|
|
||||||
builder: async ({ configure, BootModule }) => {
|
|
||||||
const container = await NestFactory.create<NestFastifyApplication>(
|
|
||||||
BootModule,
|
|
||||||
new FastifyAdapter(),
|
|
||||||
{
|
|
||||||
cors: true,
|
|
||||||
logger: ['error', 'warn'],
|
|
||||||
},
|
|
||||||
);
|
|
||||||
if (!isNil(await configure.get<ApiConfig>('api', null))) {
|
|
||||||
const restful = container.get(Restful);
|
|
||||||
let metadata: () => Promise<RecordAny>;
|
|
||||||
if (existsSync(join(__dirname, 'metadata.js'))) {
|
|
||||||
metadata = (await import(join(__dirname, 'metadata.js'))).default;
|
|
||||||
}
|
|
||||||
if (existsSync(join(__dirname, 'metadata.ts'))) {
|
|
||||||
metadata = (await import(join(__dirname, 'metadata.ts'))).default;
|
|
||||||
}
|
|
||||||
await restful.factoryDocs(container, metadata);
|
|
||||||
}
|
|
||||||
|
|
||||||
return container;
|
|
||||||
},
|
|
||||||
};
|
|
@ -1,10 +1,13 @@
|
|||||||
import { describe } from 'node:test';
|
import { describe } from 'node:test';
|
||||||
|
|
||||||
import { NestFastifyApplication } from '@nestjs/platform-fastify';
|
import { FastifyAdapter, NestFastifyApplication } from '@nestjs/platform-fastify';
|
||||||
|
import { Test, TestingModule } from '@nestjs/testing';
|
||||||
|
|
||||||
|
import { useContainer } from 'class-validator';
|
||||||
import { isNil, pick } from 'lodash';
|
import { isNil, pick } from 'lodash';
|
||||||
import { DataSource } from 'typeorm';
|
import { DataSource } from 'typeorm';
|
||||||
|
|
||||||
|
import { AppModule } from '@/app.module';
|
||||||
import { CategoryEntity, CommentEntity, PostEntity, TagEntity } from '@/modules/content/entities';
|
import { CategoryEntity, CommentEntity, PostEntity, TagEntity } from '@/modules/content/entities';
|
||||||
import {
|
import {
|
||||||
CategoryRepository,
|
CategoryRepository,
|
||||||
@ -12,11 +15,6 @@ import {
|
|||||||
PostRepository,
|
PostRepository,
|
||||||
TagRepository,
|
TagRepository,
|
||||||
} from '@/modules/content/repositories';
|
} from '@/modules/content/repositories';
|
||||||
import { createApp } from '@/modules/core/helpers/app';
|
|
||||||
import { App } from '@/modules/core/types';
|
|
||||||
import { MeiliService } from '@/modules/meilisearch/meili.service';
|
|
||||||
|
|
||||||
import { createOptions } from '@/options';
|
|
||||||
|
|
||||||
import { generateRandomNumber, generateUniqueRandomNumbers } from './generate-mock-data';
|
import { generateRandomNumber, generateUniqueRandomNumbers } from './generate-mock-data';
|
||||||
import { categoriesData, commentData, INIT_DATA, postData, tagData } from './test-data';
|
import { categoriesData, commentData, INIT_DATA, postData, tagData } from './test-data';
|
||||||
@ -33,27 +31,25 @@ describe('nest app test', () => {
|
|||||||
let categories: CategoryEntity[];
|
let categories: CategoryEntity[];
|
||||||
let tags: TagEntity[];
|
let tags: TagEntity[];
|
||||||
let comments: CommentEntity[];
|
let comments: CommentEntity[];
|
||||||
let searchService: MeiliService;
|
|
||||||
|
|
||||||
beforeAll(async () => {
|
beforeAll(async () => {
|
||||||
const appConfig: App = await createApp(createOptions)();
|
const module: TestingModule = await Test.createTestingModule({
|
||||||
app = appConfig.container;
|
imports: [AppModule],
|
||||||
|
}).compile();
|
||||||
|
app = module.createNestApplication<NestFastifyApplication>(new FastifyAdapter());
|
||||||
|
useContainer(app.select(AppModule), { fallbackOnErrors: true });
|
||||||
await app.init();
|
await app.init();
|
||||||
await app.getHttpAdapter().getInstance().ready();
|
await app.getHttpAdapter().getInstance().ready();
|
||||||
|
|
||||||
categoryRepository = app.get<CategoryRepository>(CategoryRepository);
|
categoryRepository = module.get<CategoryRepository>(CategoryRepository);
|
||||||
tagRepository = app.get<TagRepository>(TagRepository);
|
tagRepository = module.get<TagRepository>(TagRepository);
|
||||||
postRepository = app.get<PostRepository>(PostRepository);
|
postRepository = module.get<PostRepository>(PostRepository);
|
||||||
commentRepository = app.get<CommentRepository>(CommentRepository);
|
commentRepository = module.get<CommentRepository>(CommentRepository);
|
||||||
searchService = app.get<MeiliService>(MeiliService);
|
datasource = module.get<DataSource>(DataSource);
|
||||||
datasource = app.get<DataSource>(DataSource);
|
|
||||||
if (!datasource.isInitialized) {
|
if (!datasource.isInitialized) {
|
||||||
await datasource.initialize();
|
await datasource.initialize();
|
||||||
}
|
}
|
||||||
if (INIT_DATA) {
|
if (INIT_DATA) {
|
||||||
const client = searchService.getClient();
|
|
||||||
client.deleteIndex('content');
|
|
||||||
|
|
||||||
const queryRunner = datasource.createQueryRunner();
|
const queryRunner = datasource.createQueryRunner();
|
||||||
try {
|
try {
|
||||||
await queryRunner.query('SET FOREIGN_KEY_CHECKS = 0');
|
await queryRunner.query('SET FOREIGN_KEY_CHECKS = 0');
|
||||||
@ -75,7 +71,7 @@ describe('nest app test', () => {
|
|||||||
categories = await addCategory(app, categoriesData);
|
categories = await addCategory(app, categoriesData);
|
||||||
const ids = categories.map((item) => item.id);
|
const ids = categories.map((item) => item.id);
|
||||||
categories = [];
|
categories = [];
|
||||||
await Promise.all(
|
Promise.all(
|
||||||
ids.map(async (id) => {
|
ids.map(async (id) => {
|
||||||
const result = await app.inject({
|
const result = await app.inject({
|
||||||
method: 'GET',
|
method: 'GET',
|
||||||
@ -381,10 +377,7 @@ describe('nest app test', () => {
|
|||||||
},
|
},
|
||||||
});
|
});
|
||||||
expect(result.json()).toEqual({
|
expect(result.json()).toEqual({
|
||||||
message: [
|
message: ['The format of the parent category ID is incorrect.'],
|
||||||
'The format of the parent category ID is incorrect.',
|
|
||||||
'The parent category does not exist',
|
|
||||||
],
|
|
||||||
error: 'Bad Request',
|
error: 'Bad Request',
|
||||||
statusCode: 400,
|
statusCode: 400,
|
||||||
});
|
});
|
||||||
@ -599,7 +592,7 @@ describe('nest app test', () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
it('update category with duplicate name in same parent', async () => {
|
it('update category with duplicate name in same parent', async () => {
|
||||||
const parentCategory = categories.find((c) => c.children?.length > 1);
|
const parentCategory = categories.find((c) => c.children && c.children.length > 1);
|
||||||
const [child1, child2] = parentCategory.children;
|
const [child1, child2] = parentCategory.children;
|
||||||
|
|
||||||
const result = await app.inject({
|
const result = await app.inject({
|
||||||
@ -1127,8 +1120,8 @@ describe('nest app test', () => {
|
|||||||
});
|
});
|
||||||
expect(result.json()).toEqual({
|
expect(result.json()).toEqual({
|
||||||
message: [
|
message: [
|
||||||
'Comment content cannot be empty',
|
'body should not be empty',
|
||||||
'The length of the comment content cannot exceed 1000',
|
'body must be shorter than or equal to 1000 characters',
|
||||||
],
|
],
|
||||||
error: 'Bad Request',
|
error: 'Bad Request',
|
||||||
statusCode: 400,
|
statusCode: 400,
|
||||||
@ -1142,7 +1135,7 @@ describe('nest app test', () => {
|
|||||||
body: { body: 'Test comment' },
|
body: { body: 'Test comment' },
|
||||||
});
|
});
|
||||||
expect(result.json()).toEqual({
|
expect(result.json()).toEqual({
|
||||||
message: ['The post ID must be specified', 'The ID format is incorrect'],
|
message: ['The ID must be specified', 'The ID format is incorrect'],
|
||||||
error: 'Bad Request',
|
error: 'Bad Request',
|
||||||
statusCode: 400,
|
statusCode: 400,
|
||||||
});
|
});
|
||||||
@ -1159,7 +1152,7 @@ describe('nest app test', () => {
|
|||||||
},
|
},
|
||||||
});
|
});
|
||||||
expect(result.json()).toEqual({
|
expect(result.json()).toEqual({
|
||||||
message: ['The length of the comment content cannot exceed 1000'],
|
message: ['body must be shorter than or equal to 1000 characters'],
|
||||||
error: 'Bad Request',
|
error: 'Bad Request',
|
||||||
statusCode: 400,
|
statusCode: 400,
|
||||||
});
|
});
|
||||||
|
@ -1,22 +1,24 @@
|
|||||||
import { INestApplication } from '@nestjs/common';
|
|
||||||
import { Test, TestingModule } from '@nestjs/testing';
|
import { Test, TestingModule } from '@nestjs/testing';
|
||||||
import request from 'supertest';
|
import { INestApplication } from '@nestjs/common';
|
||||||
|
import * as request from 'supertest';
|
||||||
import { CoreModule } from '@/modules/core/core.module';
|
import { AppModule } from './../src/app.module';
|
||||||
|
|
||||||
describe('AppController (e2e)', () => {
|
describe('AppController (e2e)', () => {
|
||||||
let app: INestApplication;
|
let app: INestApplication;
|
||||||
|
|
||||||
beforeEach(async () => {
|
beforeEach(async () => {
|
||||||
const moduleFixture: TestingModule = await Test.createTestingModule({
|
const moduleFixture: TestingModule = await Test.createTestingModule({
|
||||||
imports: [CoreModule],
|
imports: [AppModule],
|
||||||
}).compile();
|
}).compile();
|
||||||
|
|
||||||
app = moduleFixture.createNestApplication();
|
app = moduleFixture.createNestApplication();
|
||||||
await app.init();
|
await app.init();
|
||||||
});
|
});
|
||||||
|
|
||||||
it('/ (GET)', () => {
|
it('/ (GET)', () => {
|
||||||
return request(app.getHttpServer()).get('/').expect(200).expect('Hello World!');
|
return request(app.getHttpServer())
|
||||||
});
|
.get('/')
|
||||||
|
.expect(200)
|
||||||
|
.expect('Hello World!');
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
@ -1,4 +1,4 @@
|
|||||||
import { fakerEN } from '@faker-js/faker';
|
import { fakerEN } from '@faker-js/faker/.';
|
||||||
|
|
||||||
import { CreateCommentDto } from '@/modules/content/dtos/comment.dto';
|
import { CreateCommentDto } from '@/modules/content/dtos/comment.dto';
|
||||||
import { CreatePostDto } from '@/modules/content/dtos/post.dto';
|
import { CreatePostDto } from '@/modules/content/dtos/post.dto';
|
||||||
|
2
typings/global.d.ts
vendored
2
typings/global.d.ts
vendored
@ -12,7 +12,7 @@ declare type ClassToPlain<T> = { [key in keyof T]: T[key] };
|
|||||||
declare type ClassType<T> = { new (...args: any[]): T };
|
declare type ClassType<T> = { new (...args: any[]): T };
|
||||||
|
|
||||||
declare type RePartial<T> = {
|
declare type RePartial<T> = {
|
||||||
[P in keyof T]?: T[P] extends (infer U)[] | undefined
|
[P in keyof T]: T[P] extends (infer U)[] | undefined
|
||||||
? RePartial<U>[]
|
? RePartial<U>[]
|
||||||
: T[P] extends object | undefined
|
: T[P] extends object | undefined
|
||||||
? T[P] extends ((...args: any[]) => any) | ClassType<T[P]> | undefined
|
? T[P] extends ((...args: any[]) => any) | ClassType<T[P]> | undefined
|
||||||
|
Loading…
Reference in New Issue
Block a user