Compare commits
26 Commits
68b06801d3
...
03e70436c6
Author | SHA1 | Date | |
---|---|---|---|
03e70436c6 | |||
2fe89029c3 | |||
8f42803d63 | |||
73e5a897c6 | |||
5a5306b10d | |||
35cc963ca8 | |||
a5b7a9bd5d | |||
fc9b8f7f2a | |||
c74757d692 | |||
1057041738 | |||
92b93e2e89 | |||
cb15a976f1 | |||
2e4997da9c | |||
2afa6bcb4c | |||
e5912600ce | |||
88ba3f5a16 | |||
b2189a8c5f | |||
7b6a5ca24e | |||
a92ad374e1 | |||
4991b83641 | |||
38208a57e8 | |||
3fa9ecc33a | |||
6f581ad383 | |||
3f0a9ca6fb | |||
2c76c94578 | |||
9f88f9c731 |
@ -1,17 +1,17 @@
|
||||
module.exports = {
|
||||
"singleQuote": true,
|
||||
"trailingComma": "all",
|
||||
"printWidth": 100,
|
||||
"proseWrap": "never",
|
||||
"endOfLine": "auto",
|
||||
"semi": true,
|
||||
"tabWidth": 4,
|
||||
"overrides": [
|
||||
{
|
||||
"files": ".prettierrc",
|
||||
"options": {
|
||||
"parser": "json"
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
singleQuote: true,
|
||||
trailingComma: 'all',
|
||||
printWidth: 100,
|
||||
proseWrap: 'never',
|
||||
endOfLine: 'auto',
|
||||
semi: true,
|
||||
tabWidth: 4,
|
||||
overrides: [
|
||||
{
|
||||
files: '.prettierrc',
|
||||
options: {
|
||||
parser: 'json',
|
||||
},
|
||||
},
|
||||
],
|
||||
};
|
||||
|
3
.vscode/extensions.json
vendored
Normal file
3
.vscode/extensions.json
vendored
Normal file
@ -0,0 +1,3 @@
|
||||
{
|
||||
"recommendations": []
|
||||
}
|
12
babel.config.js
Normal file
12
babel.config.js
Normal file
@ -0,0 +1,12 @@
|
||||
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
Normal file
12
env.example
Normal file
@ -0,0 +1,12 @@
|
||||
# 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
Normal file
198
eslint.config.js
Normal file
@ -0,0 +1,198 @@
|
||||
/* 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,6 +5,13 @@
|
||||
"compilerOptions": {
|
||||
"deleteOutDir": true,
|
||||
"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",
|
||||
"scripts": {
|
||||
"prebuild": "rimraf dist",
|
||||
"build": "nest build",
|
||||
"build": "cross-env NODE_ENV=production nest build",
|
||||
"format": "prettier --write \"src/**/*.ts\" \"test/**/*.ts\"",
|
||||
"start": "nest start",
|
||||
"start:dev": "nest start --watch",
|
||||
"start:debug": "nest start --debug --watch",
|
||||
"start:prod": "node dist/main",
|
||||
"start": "cross-env NODE_ENV=development nest start",
|
||||
"start:dev": "cross-env NODE_ENV=development nest start --watch",
|
||||
"start:debug": "cross-env NODE_ENV=development nest start --debug --watch",
|
||||
"start:prod": "cross-env NODE_ENV=production node dist/main",
|
||||
"lint": "eslint \"{src,apps,libs,test}/**/*.ts\" --fix",
|
||||
"test": "jest",
|
||||
"test:watch": "jest --watch",
|
||||
@ -21,56 +21,77 @@
|
||||
"test:e2e": "jest --config ./test/jest-e2e.json"
|
||||
},
|
||||
"dependencies": {
|
||||
"@nestjs/common": "^10.0.3",
|
||||
"@nestjs/core": "^10.0.3",
|
||||
"@nestjs/platform-fastify": "^10.0.3",
|
||||
"@nestjs/swagger": "^7.4.2",
|
||||
"@fastify/static": "^8.2.0",
|
||||
"@nestjs/common": "^11.1.3",
|
||||
"@nestjs/core": "^11.1.3",
|
||||
"@nestjs/platform-fastify": "^11.1.3",
|
||||
"@nestjs/swagger": "^11.2.0",
|
||||
"@nestjs/typeorm": "^11.0.0",
|
||||
"chalk": "^5.4.1",
|
||||
"class-transformer": "^0.5.1",
|
||||
"class-validator": "^0.14.2",
|
||||
"deepmerge": "^4.3.1",
|
||||
"dotenv": "^16.5.0",
|
||||
"find-up": "^7.0.0",
|
||||
"fs-extra": "^11.3.0",
|
||||
"lodash": "^4.17.21",
|
||||
"meilisearch": "^0.50.0",
|
||||
"meilisearch": "^0.51.0",
|
||||
"mysql2": "^3.14.1",
|
||||
"reflect-metadata": "^0.1.13",
|
||||
"rimraf": "^5.0.1",
|
||||
"rxjs": "^7.8.1",
|
||||
"reflect-metadata": "^0.2.2",
|
||||
"rimraf": "^6.0.1",
|
||||
"rxjs": "^7.8.2",
|
||||
"sanitize-html": "^2.17.0",
|
||||
"typeorm": "^0.3.24",
|
||||
"validator": "^13.15.0"
|
||||
"validator": "^13.15.15",
|
||||
"yaml": "^2.8.0"
|
||||
},
|
||||
"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",
|
||||
"@nestjs/cli": "^10.0.3",
|
||||
"@nestjs/schematics": "^10.0.1",
|
||||
"@nestjs/testing": "^10.0.3",
|
||||
"@swc/cli": "^0.1.62",
|
||||
"@swc/core": "^1.3.66",
|
||||
"@types/jest": "29.5.2",
|
||||
"@types/lodash": "^4.17.16",
|
||||
"@types/node": "^20.3.1",
|
||||
"@nestjs/cli": "^11.0.7",
|
||||
"@nestjs/schematics": "^11.0.5",
|
||||
"@nestjs/testing": "^11.1.3",
|
||||
"@swc/cli": "^0.7.7",
|
||||
"@swc/core": "^1.12.1",
|
||||
"@types/eslint": "^9.6.1",
|
||||
"@types/fs-extra": "^11.0.4",
|
||||
"@types/jest": "29.5.14",
|
||||
"@types/lodash": "^4.17.17",
|
||||
"@types/node": "^24.0.1",
|
||||
"@types/sanitize-html": "^2.16.0",
|
||||
"@types/supertest": "^2.0.12",
|
||||
"@types/supertest": "^6.0.3",
|
||||
"@types/validator": "^13.15.1",
|
||||
"@typescript-eslint/eslint-plugin": "^5.60.0",
|
||||
"@typescript-eslint/parser": "^5.60.0",
|
||||
"eslint": "^8.43.0",
|
||||
"@typescript-eslint/eslint-plugin": "^8.34.0",
|
||||
"@typescript-eslint/parser": "^8.34.0",
|
||||
"babel-jest": "^30.0.0",
|
||||
"cross-env": "^7.0.3",
|
||||
"eslint": "^9.29.0",
|
||||
"eslint-config-airbnb-base": "^15.0.0",
|
||||
"eslint-config-airbnb-typescript": "^17.0.0",
|
||||
"eslint-config-prettier": "^8.8.0",
|
||||
"eslint-plugin-import": "^2.27.5",
|
||||
"eslint-plugin-jest": "^27.2.2",
|
||||
"eslint-plugin-prettier": "^4.2.1",
|
||||
"eslint-plugin-unused-imports": "^2.0.0",
|
||||
"jest": "29.5.0",
|
||||
"prettier": "^2.8.8",
|
||||
"eslint-config-airbnb-typescript": "^18.0.0",
|
||||
"eslint-config-prettier": "^10.1.5",
|
||||
"eslint-plugin-import": "^2.31.0",
|
||||
"eslint-plugin-jest": "^28.13.5",
|
||||
"eslint-plugin-prettier": "^5.4.1",
|
||||
"eslint-plugin-unused-imports": "^4.1.4",
|
||||
"globals": "^16.2.0",
|
||||
"jest": "30.0.0",
|
||||
"prettier": "^3.5.3",
|
||||
"source-map-support": "^0.5.21",
|
||||
"supertest": "^6.3.3",
|
||||
"ts-jest": "29.1.0",
|
||||
"ts-loader": "^9.4.3",
|
||||
"ts-node": "^10.9.1",
|
||||
"supertest": "^7.1.1",
|
||||
"ts-babel": "^6.1.7",
|
||||
"ts-jest": "29.4.0",
|
||||
"ts-loader": "^9.5.2",
|
||||
"ts-node": "^10.9.2",
|
||||
"tsconfig-paths": "4.2.0",
|
||||
"typescript": "~5.1.3"
|
||||
"typescript": "~5.8.3"
|
||||
},
|
||||
"jest": {
|
||||
"moduleFileExtensions": [
|
||||
@ -85,12 +106,16 @@
|
||||
"<rootDir>/test/**/*.test.ts"
|
||||
],
|
||||
"transform": {
|
||||
"^.+\\.(t|j)s$": "ts-jest"
|
||||
"^.+\\.(js|jsx)$": "babel-jest",
|
||||
"^.+\\.(ts|tsx)?$": "ts-jest"
|
||||
},
|
||||
"collectCoverageFrom": [
|
||||
"**/*.(t|j)s"
|
||||
],
|
||||
"coverageDirectory": "../coverage",
|
||||
"transformIgnorePatterns": [
|
||||
"node_modules/(?!(chalk))"
|
||||
],
|
||||
"testEnvironment": "node"
|
||||
},
|
||||
"pnpm": {
|
||||
|
6942
pnpm-lock.yaml
6942
pnpm-lock.yaml
File diff suppressed because it is too large
Load Diff
@ -1,41 +0,0 @@
|
||||
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 {}
|
43
src/config/api.config.ts
Normal file
43
src/config/api.config.ts
Normal file
@ -0,0 +1,43 @@
|
||||
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) },
|
||||
}),
|
||||
};
|
8
src/config/app.config.ts
Normal file
8
src/config/app.config.ts
Normal file
@ -0,0 +1,8 @@
|
||||
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,5 +1,6 @@
|
||||
import { ContentConfig } from '@/modules/content/types';
|
||||
import { createContentConfig } from '@/modules/content/config';
|
||||
|
||||
export const content = (): ContentConfig => ({
|
||||
SearchType: 'meili',
|
||||
});
|
||||
export const content = createContentConfig(() => ({
|
||||
searchType: 'meili',
|
||||
htmlEnabled: false,
|
||||
}));
|
||||
|
@ -1,15 +1,17 @@
|
||||
import { TypeOrmModuleOptions } from '@nestjs/typeorm';
|
||||
import { toNumber } from 'lodash';
|
||||
|
||||
export const database = (): TypeOrmModuleOptions => ({
|
||||
charset: 'utf8mb4',
|
||||
logging: ['error'],
|
||||
type: 'mysql',
|
||||
host: '192.168.50.26',
|
||||
port: 3306,
|
||||
username: '3r',
|
||||
password: '12345678',
|
||||
database: '3r',
|
||||
synchronize: true,
|
||||
autoLoadEntities: true,
|
||||
timezone: '+08:00',
|
||||
});
|
||||
import { createDBConfig } from '@/modules/database/config';
|
||||
|
||||
export const database = createDBConfig((configure) => ({
|
||||
common: { synchronize: true },
|
||||
connections: [
|
||||
{
|
||||
type: 'mysql',
|
||||
host: configure.env.get('DB_HOST', '192.168.50.26'),
|
||||
port: configure.env.get<number>('DB_PORT', (v) => toNumber(v), 3306),
|
||||
username: configure.env.get('DB_USERNAME', '3r'),
|
||||
password: configure.env.get('DB_PASSWORD', '12345678'),
|
||||
database: configure.env.get('DB_NAME', '3r'),
|
||||
},
|
||||
],
|
||||
}));
|
||||
|
@ -1,2 +1,5 @@
|
||||
export * from './database.config';
|
||||
export * from './content.config';
|
||||
export * from './app.config';
|
||||
export * from './meili.config';
|
||||
export * from './api.config';
|
||||
|
8
src/config/meili.config.ts
Normal file
8
src/config/meili.config.ts
Normal file
@ -0,0 +1,8 @@
|
||||
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,21 +1,7 @@
|
||||
import { NestFactory } from '@nestjs/core';
|
||||
import 'reflect-metadata';
|
||||
|
||||
import { FastifyAdapter, NestFastifyApplication } from '@nestjs/platform-fastify';
|
||||
import { createApp, startApp } from './modules/core/helpers/app';
|
||||
import { listened } from './modules/restful/utils';
|
||||
import { createOptions } from './options';
|
||||
|
||||
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();
|
||||
startApp(createApp(createOptions), listened);
|
||||
|
53
src/modules/config/ConfigStorage.ts
Normal file
53
src/modules/config/ConfigStorage.ts
Normal file
@ -0,0 +1,53 @@
|
||||
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));
|
||||
}
|
||||
}
|
20
src/modules/config/config.module.ts
Normal file
20
src/modules/config/config.module.ts
Normal file
@ -0,0 +1,20 @@
|
||||
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],
|
||||
};
|
||||
}
|
||||
}
|
144
src/modules/config/configure.ts
Normal file
144
src/modules/config/configure.ts
Normal file
@ -0,0 +1,144 @@
|
||||
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');
|
||||
}
|
||||
}
|
8
src/modules/config/constants.ts
Normal file
8
src/modules/config/constants.ts
Normal file
@ -0,0 +1,8 @@
|
||||
export enum EnvironmentType {
|
||||
DEVELOPMENT = 'development',
|
||||
DEV = 'dev',
|
||||
PRODUCTION = 'production',
|
||||
PROD = 'prod',
|
||||
TEST = 'test',
|
||||
PREVIEW = 'preview',
|
||||
}
|
78
src/modules/config/env.ts
Normal file
78
src/modules/config/env.ts
Normal file
@ -0,0 +1,78 @@
|
||||
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;
|
||||
}
|
||||
}
|
24
src/modules/config/types.ts
Normal file
24
src/modules/config/types.ts
Normal file
@ -0,0 +1,24 @@
|
||||
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>;
|
27
src/modules/config/utils.ts
Normal file
27
src/modules/config/utils.ts
Normal file
@ -0,0 +1,27 @@
|
||||
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];
|
||||
}, []);
|
||||
};
|
12
src/modules/content/config.ts
Normal file
12
src/modules/content/config.ts
Normal file
@ -0,0 +1,12 @@
|
||||
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,7 +2,6 @@ import { DynamicModule, Module, ModuleMetadata } from '@nestjs/common';
|
||||
|
||||
import { TypeOrmModule } from '@nestjs/typeorm';
|
||||
|
||||
import * as controllers from '@/modules/content/controllers';
|
||||
import * as entities from '@/modules/content/entities';
|
||||
import * as repositories from '@/modules/content/repositories';
|
||||
import * as services from '@/modules/content/services';
|
||||
@ -11,19 +10,19 @@ import { SanitizeService } from '@/modules/content/services/SanitizeService';
|
||||
|
||||
import { PostService } from '@/modules/content/services/post.service';
|
||||
import { PostSubscriber } from '@/modules/content/subscribers/post.subscriber';
|
||||
import { ContentConfig } from '@/modules/content/types';
|
||||
import { DatabaseModule } from '@/modules/database/database.module';
|
||||
|
||||
import { Configure } from '../config/configure';
|
||||
|
||||
import { defauleContentConfig } from './config';
|
||||
import { ContentConfig } from './types';
|
||||
|
||||
@Module({})
|
||||
export class ContentModule {
|
||||
static forRoot(configRegister?: () => ContentConfig): DynamicModule {
|
||||
const config: Required<ContentConfig> = {
|
||||
SearchType: 'mysql',
|
||||
...(configRegister ? configRegister() : {}),
|
||||
};
|
||||
static async forRoot(configure: Configure): Promise<DynamicModule> {
|
||||
const config = await configure.get<ContentConfig>('content', defauleContentConfig);
|
||||
const providers: ModuleMetadata['providers'] = [
|
||||
...Object.values(services),
|
||||
SanitizeService,
|
||||
PostSubscriber,
|
||||
{
|
||||
provide: PostService,
|
||||
@ -47,13 +46,23 @@ export class ContentModule {
|
||||
categoryService,
|
||||
tagRepository,
|
||||
searchService,
|
||||
config.SearchType,
|
||||
config.searchType,
|
||||
);
|
||||
},
|
||||
},
|
||||
];
|
||||
if (config.SearchType === 'meili') {
|
||||
const exports: ModuleMetadata['exports'] = [
|
||||
...Object.values(services),
|
||||
PostService,
|
||||
DatabaseModule.forRepository(Object.values(repositories)),
|
||||
];
|
||||
if (config.searchType === 'meili') {
|
||||
providers.push(services.SearchService);
|
||||
exports.push(SearchService);
|
||||
}
|
||||
if (config.htmlEnabled) {
|
||||
providers.push(SanitizeService);
|
||||
exports.push(SanitizeService);
|
||||
}
|
||||
return {
|
||||
module: ContentModule,
|
||||
@ -61,13 +70,8 @@ export class ContentModule {
|
||||
TypeOrmModule.forFeature(Object.values(entities)),
|
||||
DatabaseModule.forRepository(Object.values(repositories)),
|
||||
],
|
||||
controllers: Object.values(controllers),
|
||||
providers,
|
||||
exports: [
|
||||
...Object.values(services),
|
||||
PostService,
|
||||
DatabaseModule.forRepository(Object.values(repositories)),
|
||||
],
|
||||
exports,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
@ -11,19 +11,33 @@ import {
|
||||
SerializeOptions,
|
||||
} 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 { CategoryService } from '../services';
|
||||
|
||||
@ApiTags('Category Operate')
|
||||
@Depends(ContentModule)
|
||||
@Controller('category')
|
||||
export class CategoryController {
|
||||
constructor(protected service: CategoryService) {}
|
||||
|
||||
/**
|
||||
* Search category tree
|
||||
*/
|
||||
@Get('tree')
|
||||
@SerializeOptions({ groups: ['category-tree'] })
|
||||
async tree() {
|
||||
return this.service.findTrees();
|
||||
}
|
||||
|
||||
/**
|
||||
* 分页查询分类列表
|
||||
* @param options
|
||||
*/
|
||||
@Get()
|
||||
@SerializeOptions({ groups: ['category-list'] })
|
||||
async list(
|
||||
@ -33,6 +47,10 @@ export class CategoryController {
|
||||
return this.service.paginate(options);
|
||||
}
|
||||
|
||||
/**
|
||||
* 查询分类明细
|
||||
* @param id
|
||||
*/
|
||||
@Get(':id')
|
||||
@SerializeOptions({ groups: ['category-detail'] })
|
||||
async detail(@Param('id', new ParseUUIDPipe()) id: string) {
|
||||
@ -60,6 +78,6 @@ export class CategoryController {
|
||||
@Delete(':id')
|
||||
@SerializeOptions({ groups: ['category-detail'] })
|
||||
async delete(@Param('id', new ParseUUIDPipe()) id: string) {
|
||||
return this.service.delete(id);
|
||||
return this.service.delete([id]);
|
||||
}
|
||||
}
|
||||
|
@ -1,5 +1,8 @@
|
||||
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 {
|
||||
CreateCommentDto,
|
||||
DeleteCommentDto,
|
||||
@ -8,6 +11,7 @@ import {
|
||||
} from '../dtos/comment.dto';
|
||||
import { CommentService } from '../services';
|
||||
|
||||
@Depends(ContentModule)
|
||||
@Controller('comment')
|
||||
export class CommentController {
|
||||
constructor(protected service: CommentService) {}
|
||||
|
@ -14,8 +14,12 @@ import {
|
||||
import { CreatePostDto, QueryPostDto, UpdatePostDto } from '@/modules/content/dtos/post.dto';
|
||||
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';
|
||||
|
||||
@Depends(ContentModule)
|
||||
@Controller('posts')
|
||||
export class PostController {
|
||||
constructor(private postService: PostService) {}
|
||||
|
@ -13,9 +13,13 @@ import {
|
||||
|
||||
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 { TagService } from '../services';
|
||||
|
||||
@Depends(ContentModule)
|
||||
@Controller('tag')
|
||||
export class TagController {
|
||||
constructor(protected service: TagService) {}
|
||||
|
@ -48,7 +48,7 @@ export class PostEntity extends BaseEntity {
|
||||
type: PostBodyType;
|
||||
|
||||
@Expose()
|
||||
@Column({ comment: '发布时间', type: 'varchar', nullable: true })
|
||||
@Column({ comment: '发布时间', type: 'timestamp', nullable: true })
|
||||
publishedAt?: Date | null;
|
||||
|
||||
@Expose()
|
||||
|
@ -1,21 +1,23 @@
|
||||
import { isNil, pick, unset } from 'lodash';
|
||||
import { FindOptionsUtils, FindTreeOptions, TreeRepository, TreeRepositoryUtils } from 'typeorm';
|
||||
import { isNil, unset } from 'lodash';
|
||||
import { FindOptionsUtils, FindTreeOptions, TreeRepositoryUtils } from 'typeorm';
|
||||
|
||||
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';
|
||||
|
||||
@CustomRepository(CategoryEntity)
|
||||
export class CategoryRepository extends TreeRepository<CategoryEntity> {
|
||||
export class CategoryRepository extends BaseTreeRepository<CategoryEntity> {
|
||||
protected _qbName = 'category';
|
||||
|
||||
protected orderBy = { name: 'customOrder', order: OrderType.ASC };
|
||||
|
||||
protected _childrenResolve = TreeChildrenResolve.UP;
|
||||
|
||||
buildBaseQB() {
|
||||
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[]> {
|
||||
const escape = (val: string) => this.manager.connection.driver.escape(val);
|
||||
const joinColumn = this.metadata.treeParentRelation!.joinColumns[0];
|
||||
@ -39,28 +41,6 @@ export class CategoryRepository extends TreeRepository<CategoryEntity> {
|
||||
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(
|
||||
entity: CategoryEntity,
|
||||
options?: FindTreeOptions,
|
||||
@ -95,23 +75,6 @@ export class CategoryRepository extends TreeRepository<CategoryEntity> {
|
||||
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) {
|
||||
let data: Omit<CategoryEntity, 'children'>[] = [];
|
||||
const category = await this.findAncestorsTree(item);
|
||||
|
@ -1,20 +1,17 @@
|
||||
import { pick, unset } from 'lodash';
|
||||
import {
|
||||
FindOptionsUtils,
|
||||
FindTreeOptions,
|
||||
SelectQueryBuilder,
|
||||
TreeRepository,
|
||||
TreeRepositoryUtils,
|
||||
} from 'typeorm';
|
||||
import { FindOptionsUtils, FindTreeOptions, SelectQueryBuilder } from 'typeorm';
|
||||
|
||||
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 { QueryHook } from '@/modules/database/types';
|
||||
|
||||
type FindCommentTreeOptions = FindTreeOptions & {
|
||||
addQuery?: (query: SelectQueryBuilder<CommentEntity>) => SelectQueryBuilder<CommentEntity>;
|
||||
addQuery?: QueryHook<CommentEntity>;
|
||||
};
|
||||
@CustomRepository(CommentEntity)
|
||||
export class CommentRepository extends TreeRepository<CommentEntity> {
|
||||
export class CommentRepository extends BaseTreeRepository<CommentEntity> {
|
||||
protected _qbName = 'comment';
|
||||
|
||||
buildBaseQB(qb: SelectQueryBuilder<CommentEntity>): SelectQueryBuilder<CommentEntity> {
|
||||
return qb
|
||||
.leftJoinAndSelect(`comment.parent`, 'parent')
|
||||
@ -22,14 +19,7 @@ export class CommentRepository extends TreeRepository<CommentEntity> {
|
||||
.orderBy('comment.createdAt', 'DESC');
|
||||
}
|
||||
|
||||
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[]> {
|
||||
async findRoots(options?: FindCommentTreeOptions): Promise<CommentEntity[]> {
|
||||
const { addQuery, ...rest } = options;
|
||||
const escape = (val: string) => this.manager.connection.driver.escape(val);
|
||||
const joinColumn = this.metadata.treeParentRelation!.joinColumns[0];
|
||||
@ -38,58 +28,19 @@ export class CommentRepository extends TreeRepository<CommentEntity> {
|
||||
let qb = this.buildBaseQB(this.createQueryBuilder('comment'));
|
||||
FindOptionsUtils.applyOptionsToTreeQueryBuilder(qb, rest);
|
||||
qb.where(`${escape('comment')}.${escape(parentPropertyName)} IS NULL`);
|
||||
qb = addQuery ? addQuery(qb) : qb;
|
||||
qb = addQuery ? await addQuery(qb) : qb;
|
||||
return qb.getMany();
|
||||
}
|
||||
|
||||
createDtsQueryBuilder(
|
||||
async createDtsQueryBuilder(
|
||||
closureTable: string,
|
||||
entity: CommentEntity,
|
||||
options: FindCommentTreeOptions = {},
|
||||
): SelectQueryBuilder<CommentEntity> {
|
||||
): Promise<SelectQueryBuilder<CommentEntity>> {
|
||||
const { addQuery } = options;
|
||||
const qb = this.buildBaseQB(
|
||||
super.createDescendantsQueryBuilder('comment', closureTable, entity),
|
||||
);
|
||||
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,29 +2,23 @@ import { Injectable } from '@nestjs/common';
|
||||
import { isNil, omit } from 'lodash';
|
||||
import { EntityNotFoundError } from 'typeorm';
|
||||
|
||||
import {
|
||||
CreateCategoryDto,
|
||||
QueryCategoryDto,
|
||||
UpdateCategoryDto,
|
||||
} from '@/modules/content/dtos/category.dto';
|
||||
import { CreateCategoryDto, UpdateCategoryDto } from '@/modules/content/dtos/category.dto';
|
||||
import { CategoryEntity } from '@/modules/content/entities/category.entity';
|
||||
import { CategoryRepository } from '@/modules/content/repositories/category.repository';
|
||||
import { treePaginate } from '@/modules/database/utils';
|
||||
import { BaseService } from '@/modules/database/base/service';
|
||||
|
||||
@Injectable()
|
||||
export class CategoryService {
|
||||
constructor(protected repository: CategoryRepository) {}
|
||||
export class CategoryService extends BaseService<CategoryEntity, CategoryRepository> {
|
||||
protected enableTrash = true;
|
||||
|
||||
constructor(protected repository: CategoryRepository) {
|
||||
super(repository);
|
||||
}
|
||||
|
||||
async 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) {
|
||||
return this.repository.findOneOrFail({ where: { id }, relations: ['parent', 'children'] });
|
||||
}
|
||||
@ -56,21 +50,6 @@ export class CategoryService {
|
||||
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) {
|
||||
if (current === parentId) {
|
||||
return undefined;
|
||||
|
@ -11,18 +11,21 @@ import {
|
||||
} from '@/modules/content/dtos/comment.dto';
|
||||
import { CommentEntity } from '@/modules/content/entities/comment.entity';
|
||||
import { CommentRepository, PostRepository } from '@/modules/content/repositories';
|
||||
import { BaseService } from '@/modules/database/base/service';
|
||||
import { treePaginate } from '@/modules/database/utils';
|
||||
|
||||
@Injectable()
|
||||
export class CommentService {
|
||||
export class CommentService extends BaseService<CommentEntity, CommentRepository> {
|
||||
constructor(
|
||||
protected repository: CommentRepository,
|
||||
protected postRepository: PostRepository,
|
||||
) {}
|
||||
) {
|
||||
super(repository);
|
||||
}
|
||||
|
||||
async findTrees(options: QueryCommentTreeDto = {}) {
|
||||
return this.repository.findTrees({
|
||||
addQuery: (qb) => {
|
||||
addQuery: async (qb) => {
|
||||
return isNil(options.post) ? qb : qb.where('post.id = :id', { id: options.post });
|
||||
},
|
||||
});
|
||||
@ -30,7 +33,7 @@ export class CommentService {
|
||||
|
||||
async paginate(options: QueryCommentDto) {
|
||||
const { post, ...query } = options;
|
||||
const addQuery = (qb: SelectQueryBuilder<CommentEntity>) => {
|
||||
const addQuery = async (qb: SelectQueryBuilder<CommentEntity>) => {
|
||||
const condition: RecordString = {};
|
||||
if (!isNil(post)) {
|
||||
condition.post = post;
|
||||
@ -91,4 +94,8 @@ export class CommentService {
|
||||
}
|
||||
return parent;
|
||||
}
|
||||
|
||||
update(data: any, ...others: any[]): Promise<CommentEntity> {
|
||||
throw new Error('Method not implemented.');
|
||||
}
|
||||
}
|
||||
|
@ -11,6 +11,7 @@ import { CategoryRepository } from '@/modules/content/repositories';
|
||||
import { PostRepository } from '@/modules/content/repositories/post.repository';
|
||||
import { SearchService } from '@/modules/content/services/search.service';
|
||||
import { SearchType } from '@/modules/content/types';
|
||||
import { BaseService } from '@/modules/database/base/service';
|
||||
import { SelectTrashMode } from '@/modules/database/constants';
|
||||
import { QueryHook } from '@/modules/database/types';
|
||||
import { paginate } from '@/modules/database/utils';
|
||||
@ -24,7 +25,9 @@ type FindParams = {
|
||||
};
|
||||
|
||||
@Injectable()
|
||||
export class PostService {
|
||||
export class PostService extends BaseService<PostEntity, PostRepository, FindParams> {
|
||||
protected enableTrash = true;
|
||||
|
||||
constructor(
|
||||
protected repository: PostRepository,
|
||||
protected categoryRepository: CategoryRepository,
|
||||
@ -32,13 +35,15 @@ export class PostService {
|
||||
protected tagRepository: TagRepository,
|
||||
protected searchService?: SearchService,
|
||||
protected searchType: SearchType = 'mysql',
|
||||
) {}
|
||||
) {
|
||||
super(repository);
|
||||
}
|
||||
|
||||
async paginate(options: QueryPostDto, callback?: QueryHook<PostEntity>) {
|
||||
if (!isNil(this.searchService) && !isNil(options.search) && this.searchType === 'meili') {
|
||||
return this.searchService.search(
|
||||
options.search,
|
||||
pick(options, ['trashed', 'page', 'limit']),
|
||||
pick(options, ['trashed', 'page', 'limit', 'isPublished']),
|
||||
);
|
||||
}
|
||||
const qb = await this.buildListQuery(this.repository.buildBaseQB(), options, callback);
|
||||
|
@ -47,7 +47,7 @@ export class SearchService implements OnModuleInit {
|
||||
return this.client;
|
||||
}
|
||||
|
||||
async search(text: string, param: SearchOption = {}) {
|
||||
async search(text: string, param: SearchOption = {}): Promise<any> {
|
||||
const option = { page: 1, limit: 10, trashed: SelectTrashMode.ONLY, ...param };
|
||||
const limit = isNil(option.limit) || option.limit < 1 ? 1 : option.limit;
|
||||
const page = isNil(option.page) || option.page < 1 ? 1 : option.page;
|
||||
|
@ -1,19 +1,18 @@
|
||||
import { Injectable } from '@nestjs/common';
|
||||
import { omit } from 'lodash';
|
||||
|
||||
import { In } from 'typeorm';
|
||||
|
||||
import { CreateTagDto, QueryTagDto, UpdateTagDto } from '@/modules/content/dtos/tag.dto';
|
||||
import { CreateTagDto, UpdateTagDto } from '@/modules/content/dtos/tag.dto';
|
||||
import { TagRepository } from '@/modules/content/repositories/tag.repository';
|
||||
import { paginate } from '@/modules/database/utils';
|
||||
import { BaseService } from '@/modules/database/base/service';
|
||||
|
||||
import { TagEntity } from '../entities';
|
||||
|
||||
@Injectable()
|
||||
export class TagService {
|
||||
constructor(protected repository: TagRepository) {}
|
||||
export class TagService extends BaseService<TagEntity, TagRepository> {
|
||||
protected enableTrash = true;
|
||||
|
||||
async paginate(options: QueryTagDto) {
|
||||
const qb = this.repository.buildBaseQB();
|
||||
return paginate(qb, options);
|
||||
constructor(protected repository: TagRepository) {
|
||||
super(repository);
|
||||
}
|
||||
|
||||
async detail(id: string) {
|
||||
@ -31,11 +30,4 @@ export class TagService {
|
||||
await this.repository.update(data.id, omit(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,25 +1,32 @@
|
||||
import { DataSource, EventSubscriber } from 'typeorm';
|
||||
import { Optional } from '@nestjs/common';
|
||||
import { isNil } from 'lodash';
|
||||
import { DataSource, EventSubscriber, ObjectType } from 'typeorm';
|
||||
|
||||
import { Configure } from '@/modules/config/configure';
|
||||
import { PostBodyType } from '@/modules/content/constants';
|
||||
import { PostEntity } from '@/modules/content/entities/post.entity';
|
||||
import { PostRepository } from '@/modules/content/repositories/post.repository';
|
||||
import { SanitizeService } from '@/modules/content/services/SanitizeService';
|
||||
import { BaseSubscriber } from '@/modules/database/base/subscriber';
|
||||
|
||||
@EventSubscriber()
|
||||
export class PostSubscriber {
|
||||
export class PostSubscriber extends BaseSubscriber<PostEntity> {
|
||||
protected entity: ObjectType<PostEntity> = PostEntity;
|
||||
constructor(
|
||||
protected dataSource: DataSource,
|
||||
protected sanitizeService: SanitizeService,
|
||||
protected postRepository: PostRepository,
|
||||
protected configure: Configure,
|
||||
@Optional() protected sanitizeService: SanitizeService,
|
||||
) {
|
||||
dataSource.subscribers.push(this);
|
||||
}
|
||||
listenTo() {
|
||||
return PostEntity;
|
||||
super(dataSource);
|
||||
}
|
||||
|
||||
async afterLoad(entity: PostEntity) {
|
||||
if (entity.type === PostBodyType.HTML) {
|
||||
if (
|
||||
(await this.configure.get('content.htmlEnabled')) &&
|
||||
!isNil(this.sanitizeService) &&
|
||||
entity.type === PostBodyType.HTML
|
||||
) {
|
||||
entity.body = this.sanitizeService.sanitize(entity.body);
|
||||
}
|
||||
}
|
||||
|
@ -3,7 +3,8 @@ import { SelectTrashMode } from '@/modules/database/constants';
|
||||
export type SearchType = 'mysql' | 'meili';
|
||||
|
||||
export interface ContentConfig {
|
||||
SearchType?: SearchType;
|
||||
searchType: SearchType;
|
||||
htmlEnabled: boolean;
|
||||
}
|
||||
|
||||
export interface SearchOption {
|
||||
|
30
src/modules/core/config.ts
Normal file
30
src/modules/core/config.ts
Normal file
@ -0,0 +1,30 @@
|
||||
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,5 +1,4 @@
|
||||
import { registerDecorator, ValidationArguments, ValidationOptions } from 'class-validator';
|
||||
// eslint-disable-next-line import/no-extraneous-dependencies
|
||||
import { isMobilePhone, IsMobilePhoneOptions, MobilePhoneLocale } from 'validator';
|
||||
|
||||
export function isMatchPhone(
|
||||
|
@ -1,8 +1,11 @@
|
||||
import { DynamicModule, Module } from '@nestjs/common';
|
||||
|
||||
import { Configure } from '../config/configure';
|
||||
|
||||
@Module({})
|
||||
export class CoreModule {
|
||||
static forRoot(): DynamicModule {
|
||||
static async forRoot(configure: Configure): Promise<DynamicModule> {
|
||||
await configure.store('app.name');
|
||||
return {
|
||||
module: CoreModule,
|
||||
global: true,
|
||||
|
101
src/modules/core/helpers/app.ts
Normal file
101
src/modules/core/helpers/app.ts
Normal file
@ -0,0 +1,101 @@
|
||||
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,6 +1,10 @@
|
||||
import { Module, ModuleMetadata, Type } from '@nestjs/common';
|
||||
import chalk from 'chalk';
|
||||
import deepmerge from 'deepmerge';
|
||||
import { isNil } from 'lodash';
|
||||
|
||||
import { PanicOption } from '../types';
|
||||
|
||||
export function toBoolean(value?: string | boolean): boolean {
|
||||
if (isNil(value)) {
|
||||
return false;
|
||||
@ -32,3 +36,49 @@ export const deepMerge = <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);
|
||||
}
|
||||
}
|
||||
|
63
src/modules/core/types.ts
Normal file
63
src/modules/core/types.ts
Normal file
@ -0,0 +1,63 @@
|
||||
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;
|
||||
}
|
155
src/modules/database/base/service.ts
Normal file
155
src/modules/database/base/service.ts
Normal file
@ -0,0 +1,155 @@
|
||||
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>;
|
||||
}
|
76
src/modules/database/base/subscriber.ts
Normal file
76
src/modules/database/base/subscriber.ts
Normal file
@ -0,0 +1,76 @@
|
||||
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);
|
||||
}
|
||||
}
|
98
src/modules/database/base/tree.repository.ts
Normal file
98
src/modules/database/base/tree.repository.ts
Normal file
@ -0,0 +1,98 @@
|
||||
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[];
|
||||
}
|
||||
}
|
37
src/modules/database/config.ts
Normal file
37
src/modules/database/config.ts
Normal file
@ -0,0 +1,37 @@
|
||||
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,11 +1,18 @@
|
||||
export const CUSTOM_REPOSITORY_METADATA = 'CUSTOM_REPOSITORY_METADATA';
|
||||
|
||||
export enum SelectTrashMode {
|
||||
// ALL: 包含已软删除和未软删除的数据(同时查询正常数据和回收站中的数据)
|
||||
/**
|
||||
* ALL: 包含已软删除和未软删除的数据(同时查询正常数据和回收站中的数据)
|
||||
*/
|
||||
ALL = 'all',
|
||||
// ONLY: 只包含软删除的数据 (只查询回收站中的数据)
|
||||
/**
|
||||
* ONLY: 只包含软删除的数据 (只查询回收站中的数据)
|
||||
*/
|
||||
ONLY = 'only',
|
||||
// NONE: 只包含未软删除的数据 (只查询正常数据)
|
||||
|
||||
/**
|
||||
* NONE: 只包含未软删除的数据 (只查询正常数据)
|
||||
*/
|
||||
NONE = 'none',
|
||||
}
|
||||
|
||||
@ -13,3 +20,9 @@ export enum OrderType {
|
||||
ASC = 'ASC',
|
||||
DESC = 'DESC',
|
||||
}
|
||||
|
||||
export enum TreeChildrenResolve {
|
||||
DELETE = 'delete',
|
||||
UP = 'up',
|
||||
ROOT = 'root',
|
||||
}
|
||||
|
@ -1,10 +1,14 @@
|
||||
import { DynamicModule, Module, Provider, Type } from '@nestjs/common';
|
||||
import { DynamicModule, Module, ModuleMetadata, Provider, Type } from '@nestjs/common';
|
||||
import { getDataSourceToken, TypeOrmModule, TypeOrmModuleOptions } from '@nestjs/typeorm';
|
||||
|
||||
import { DataSource, ObjectType } from 'typeorm';
|
||||
|
||||
import { CUSTOM_REPOSITORY_METADATA } from '@/modules/database/constants';
|
||||
|
||||
import { Configure } from '../config/configure';
|
||||
|
||||
import { panic } from '../core/helpers';
|
||||
|
||||
import {
|
||||
DataExistConstraint,
|
||||
TreeUniqueConstraint,
|
||||
@ -12,21 +16,31 @@ import {
|
||||
UniqueConstraint,
|
||||
UniqueExistConstraint,
|
||||
} from './constraints';
|
||||
import { DBOptions } from './types';
|
||||
|
||||
@Module({})
|
||||
export class DatabaseModule {
|
||||
static forRoot(configRegister: () => TypeOrmModuleOptions): DynamicModule {
|
||||
static async forRoot(configure: Configure): Promise<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 {
|
||||
global: true,
|
||||
module: DatabaseModule,
|
||||
imports: [TypeOrmModule.forRoot(configRegister())],
|
||||
providers: [
|
||||
DataExistConstraint,
|
||||
UniqueConstraint,
|
||||
UniqueExistConstraint,
|
||||
TreeUniqueConstraint,
|
||||
TreeUniqueExistContraint,
|
||||
],
|
||||
imports,
|
||||
providers,
|
||||
};
|
||||
}
|
||||
static forRepository<T extends Type<any>>(
|
||||
|
@ -1,6 +1,16 @@
|
||||
import { ObjectLiteral, SelectQueryBuilder } from 'typeorm';
|
||||
import { TypeOrmModuleOptions } from '@nestjs/typeorm';
|
||||
import {
|
||||
FindTreeOptions,
|
||||
ObjectLiteral,
|
||||
SelectQueryBuilder,
|
||||
Repository,
|
||||
TreeRepository,
|
||||
} from 'typeorm';
|
||||
|
||||
import { OrderType } from '@/modules/database/constants';
|
||||
import { OrderType, SelectTrashMode } from '@/modules/database/constants';
|
||||
|
||||
import { BaseRepository } from './base/repository';
|
||||
import { BaseTreeRepository } from './base/tree.repository';
|
||||
|
||||
export type QueryHook<Entity> = (
|
||||
qb: SelectQueryBuilder<Entity>,
|
||||
@ -28,3 +38,45 @@ export type OrderQueryType =
|
||||
| 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,8 +1,10 @@
|
||||
import { isArray, isNil } from 'lodash';
|
||||
import { ObjectLiteral, SelectQueryBuilder } from 'typeorm';
|
||||
import { DataSource, ObjectLiteral, ObjectType, Repository, SelectQueryBuilder } from 'typeorm';
|
||||
|
||||
import { OrderQueryType, PaginateOptions, PaginateReturn } from '@/modules/database/types';
|
||||
|
||||
import { CUSTOM_REPOSITORY_METADATA } from './constants';
|
||||
|
||||
export const paginate = async <T extends ObjectLiteral>(
|
||||
qb: SelectQueryBuilder<T>,
|
||||
options: PaginateOptions,
|
||||
@ -80,3 +82,18 @@ export const getOrderByQuery = <T extends ObjectLiteral>(
|
||||
}
|
||||
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;
|
||||
};
|
||||
|
11
src/modules/meilisearch/config.ts
Normal file
11
src/modules/meilisearch/config.ts
Normal file
@ -0,0 +1,11 @@
|
||||
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),
|
||||
});
|
@ -1,9 +0,0 @@
|
||||
import { MeiliConfig } from '@/modules/meilisearch/types';
|
||||
|
||||
export const MEILI_CONFIG = (): MeiliConfig => [
|
||||
{
|
||||
name: 'default',
|
||||
host: 'http://localhost:7700',
|
||||
apiKey: 'masterKey',
|
||||
},
|
||||
];
|
@ -1,12 +1,16 @@
|
||||
import { DynamicModule, Module } from '@nestjs/common';
|
||||
|
||||
import { MeiliService } from '@/modules/meilisearch/meili.service';
|
||||
import { MeiliConfig } from '@/modules/meilisearch/types';
|
||||
import { createMeiliOptions } from '@/modules/meilisearch/utils';
|
||||
|
||||
import { Configure } from '../config/configure';
|
||||
import { panic } from '../core/helpers';
|
||||
|
||||
@Module({})
|
||||
export class MeiliModule {
|
||||
static forRoot(configRegister: () => MeiliConfig): DynamicModule {
|
||||
static forRoot(configure: Configure): DynamicModule {
|
||||
if (!configure.has('meili')) {
|
||||
panic({ message: 'MeilliSearch config not exists' });
|
||||
}
|
||||
return {
|
||||
global: true,
|
||||
module: MeiliModule,
|
||||
@ -14,9 +18,7 @@ export class MeiliModule {
|
||||
{
|
||||
provide: MeiliService,
|
||||
useFactory: async () => {
|
||||
const service = new MeiliService(
|
||||
await createMeiliOptions(configRegister()),
|
||||
);
|
||||
const service = new MeiliService(await configure.get('meili'));
|
||||
await service.createClients();
|
||||
return service;
|
||||
},
|
||||
|
@ -1,18 +0,0 @@
|
||||
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;
|
||||
};
|
116
src/modules/restful/base.ts
Normal file
116
src/modules/restful/base.ts
Normal file
@ -0,0 +1,116 @@
|
||||
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
src/modules/restful/constants.ts
Normal file
1
src/modules/restful/constants.ts
Normal file
@ -0,0 +1 @@
|
||||
export const CONTROLLER_DEPENDS = 'controller_depends';
|
5
src/modules/restful/decorators/depend.decorator.ts
Normal file
5
src/modules/restful/decorators/depend.decorator.ts
Normal file
@ -0,0 +1,5 @@
|
||||
import { SetMetadata, Type } from '@nestjs/common';
|
||||
|
||||
import { CONTROLLER_DEPENDS } from '../constants';
|
||||
|
||||
export const Depends = (...depends: Type<any>[]) => SetMetadata(CONTROLLER_DEPENDS, depends ?? []);
|
16
src/modules/restful/dtos/paginate-width-trashed.dto.ts
Normal file
16
src/modules/restful/dtos/paginate-width-trashed.dto.ts
Normal file
@ -0,0 +1,16 @@
|
||||
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;
|
||||
}
|
30
src/modules/restful/dtos/paginate.dto.ts
Normal file
30
src/modules/restful/dtos/paginate.dto.ts
Normal file
@ -0,0 +1,30 @@
|
||||
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;
|
||||
}
|
24
src/modules/restful/restful.module.ts
Normal file
24
src/modules/restful/restful.module.ts
Normal file
@ -0,0 +1,24 @@
|
||||
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],
|
||||
};
|
||||
}
|
||||
}
|
168
src/modules/restful/restful.ts
Normal file
168
src/modules/restful/restful.ts
Normal file
@ -0,0 +1,168 @@
|
||||
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);
|
||||
}
|
45
src/modules/restful/types.ts
Normal file
45
src/modules/restful/types.ts
Normal file
@ -0,0 +1,45 @@
|
||||
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 };
|
||||
}
|
141
src/modules/restful/utils.ts
Normal file
141
src/modules/restful/utils.ts
Normal file
@ -0,0 +1,141 @@
|
||||
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),
|
||||
)}`,
|
||||
);
|
||||
});
|
||||
}
|
||||
}
|
51
src/options.ts
Normal file
51
src/options.ts
Normal file
@ -0,0 +1,51 @@
|
||||
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,13 +1,10 @@
|
||||
import { describe } from 'node:test';
|
||||
|
||||
import { FastifyAdapter, NestFastifyApplication } from '@nestjs/platform-fastify';
|
||||
import { Test, TestingModule } from '@nestjs/testing';
|
||||
import { NestFastifyApplication } from '@nestjs/platform-fastify';
|
||||
|
||||
import { useContainer } from 'class-validator';
|
||||
import { isNil, pick } from 'lodash';
|
||||
import { DataSource } from 'typeorm';
|
||||
|
||||
import { AppModule } from '@/app.module';
|
||||
import { CategoryEntity, CommentEntity, PostEntity, TagEntity } from '@/modules/content/entities';
|
||||
import {
|
||||
CategoryRepository,
|
||||
@ -15,6 +12,11 @@ import {
|
||||
PostRepository,
|
||||
TagRepository,
|
||||
} 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 { categoriesData, commentData, INIT_DATA, postData, tagData } from './test-data';
|
||||
@ -31,25 +33,27 @@ describe('nest app test', () => {
|
||||
let categories: CategoryEntity[];
|
||||
let tags: TagEntity[];
|
||||
let comments: CommentEntity[];
|
||||
let searchService: MeiliService;
|
||||
|
||||
beforeAll(async () => {
|
||||
const module: TestingModule = await Test.createTestingModule({
|
||||
imports: [AppModule],
|
||||
}).compile();
|
||||
app = module.createNestApplication<NestFastifyApplication>(new FastifyAdapter());
|
||||
useContainer(app.select(AppModule), { fallbackOnErrors: true });
|
||||
const appConfig: App = await createApp(createOptions)();
|
||||
app = appConfig.container;
|
||||
await app.init();
|
||||
await app.getHttpAdapter().getInstance().ready();
|
||||
|
||||
categoryRepository = module.get<CategoryRepository>(CategoryRepository);
|
||||
tagRepository = module.get<TagRepository>(TagRepository);
|
||||
postRepository = module.get<PostRepository>(PostRepository);
|
||||
commentRepository = module.get<CommentRepository>(CommentRepository);
|
||||
datasource = module.get<DataSource>(DataSource);
|
||||
categoryRepository = app.get<CategoryRepository>(CategoryRepository);
|
||||
tagRepository = app.get<TagRepository>(TagRepository);
|
||||
postRepository = app.get<PostRepository>(PostRepository);
|
||||
commentRepository = app.get<CommentRepository>(CommentRepository);
|
||||
searchService = app.get<MeiliService>(MeiliService);
|
||||
datasource = app.get<DataSource>(DataSource);
|
||||
if (!datasource.isInitialized) {
|
||||
await datasource.initialize();
|
||||
}
|
||||
if (INIT_DATA) {
|
||||
const client = searchService.getClient();
|
||||
client.deleteIndex('content');
|
||||
|
||||
const queryRunner = datasource.createQueryRunner();
|
||||
try {
|
||||
await queryRunner.query('SET FOREIGN_KEY_CHECKS = 0');
|
||||
@ -71,7 +75,7 @@ describe('nest app test', () => {
|
||||
categories = await addCategory(app, categoriesData);
|
||||
const ids = categories.map((item) => item.id);
|
||||
categories = [];
|
||||
Promise.all(
|
||||
await Promise.all(
|
||||
ids.map(async (id) => {
|
||||
const result = await app.inject({
|
||||
method: 'GET',
|
||||
@ -377,7 +381,10 @@ describe('nest app test', () => {
|
||||
},
|
||||
});
|
||||
expect(result.json()).toEqual({
|
||||
message: ['The format of the parent category ID is incorrect.'],
|
||||
message: [
|
||||
'The format of the parent category ID is incorrect.',
|
||||
'The parent category does not exist',
|
||||
],
|
||||
error: 'Bad Request',
|
||||
statusCode: 400,
|
||||
});
|
||||
@ -592,7 +599,7 @@ describe('nest app test', () => {
|
||||
});
|
||||
|
||||
it('update category with duplicate name in same parent', async () => {
|
||||
const parentCategory = categories.find((c) => c.children && c.children.length > 1);
|
||||
const parentCategory = categories.find((c) => c.children?.length > 1);
|
||||
const [child1, child2] = parentCategory.children;
|
||||
|
||||
const result = await app.inject({
|
||||
@ -1120,8 +1127,8 @@ describe('nest app test', () => {
|
||||
});
|
||||
expect(result.json()).toEqual({
|
||||
message: [
|
||||
'body should not be empty',
|
||||
'body must be shorter than or equal to 1000 characters',
|
||||
'Comment content cannot be empty',
|
||||
'The length of the comment content cannot exceed 1000',
|
||||
],
|
||||
error: 'Bad Request',
|
||||
statusCode: 400,
|
||||
@ -1135,7 +1142,7 @@ describe('nest app test', () => {
|
||||
body: { body: 'Test comment' },
|
||||
});
|
||||
expect(result.json()).toEqual({
|
||||
message: ['The ID must be specified', 'The ID format is incorrect'],
|
||||
message: ['The post ID must be specified', 'The ID format is incorrect'],
|
||||
error: 'Bad Request',
|
||||
statusCode: 400,
|
||||
});
|
||||
@ -1152,7 +1159,7 @@ describe('nest app test', () => {
|
||||
},
|
||||
});
|
||||
expect(result.json()).toEqual({
|
||||
message: ['body must be shorter than or equal to 1000 characters'],
|
||||
message: ['The length of the comment content cannot exceed 1000'],
|
||||
error: 'Bad Request',
|
||||
statusCode: 400,
|
||||
});
|
||||
|
@ -1,24 +1,22 @@
|
||||
import { Test, TestingModule } from '@nestjs/testing';
|
||||
import { INestApplication } from '@nestjs/common';
|
||||
import * as request from 'supertest';
|
||||
import { AppModule } from './../src/app.module';
|
||||
import { Test, TestingModule } from '@nestjs/testing';
|
||||
import request from 'supertest';
|
||||
|
||||
import { CoreModule } from '@/modules/core/core.module';
|
||||
|
||||
describe('AppController (e2e)', () => {
|
||||
let app: INestApplication;
|
||||
let app: INestApplication;
|
||||
|
||||
beforeEach(async () => {
|
||||
const moduleFixture: TestingModule = await Test.createTestingModule({
|
||||
imports: [AppModule],
|
||||
}).compile();
|
||||
beforeEach(async () => {
|
||||
const moduleFixture: TestingModule = await Test.createTestingModule({
|
||||
imports: [CoreModule],
|
||||
}).compile();
|
||||
|
||||
app = moduleFixture.createNestApplication();
|
||||
await app.init();
|
||||
});
|
||||
app = moduleFixture.createNestApplication();
|
||||
await app.init();
|
||||
});
|
||||
|
||||
it('/ (GET)', () => {
|
||||
return request(app.getHttpServer())
|
||||
.get('/')
|
||||
.expect(200)
|
||||
.expect('Hello World!');
|
||||
});
|
||||
it('/ (GET)', () => {
|
||||
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 { 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 RePartial<T> = {
|
||||
[P in keyof T]: T[P] extends (infer U)[] | undefined
|
||||
[P in keyof T]?: T[P] extends (infer U)[] | undefined
|
||||
? RePartial<U>[]
|
||||
: T[P] extends object | undefined
|
||||
? T[P] extends ((...args: any[]) => any) | ClassType<T[P]> | undefined
|
||||
|
Loading…
Reference in New Issue
Block a user