Compare commits

...

26 Commits

Author SHA1 Message Date
03e70436c6 update test case 2025-06-15 15:48:51 +08:00
2fe89029c3 pnpm update 2025-06-15 14:14:03 +08:00
8f42803d63 pnpm update 2025-06-15 14:13:01 +08:00
73e5a897c6 add swagger 2025-06-14 23:32:15 +08:00
5a5306b10d add swagger 2025-06-14 21:43:29 +08:00
35cc963ca8 add route module 2025-06-14 20:58:41 +08:00
a5b7a9bd5d add route module 2025-06-14 20:57:47 +08:00
fc9b8f7f2a add route module 2025-06-13 23:24:31 +08:00
c74757d692 add route module 2025-06-12 23:45:42 +08:00
1057041738 add route module 2025-06-12 23:32:23 +08:00
92b93e2e89 add config module 2025-06-12 18:12:21 +08:00
cb15a976f1 add config module 2025-06-10 23:08:01 +08:00
2e4997da9c add config module 2025-06-09 14:59:22 +08:00
2afa6bcb4c add config module 2025-06-08 14:27:50 +08:00
e5912600ce add config module 2025-06-08 14:26:04 +08:00
88ba3f5a16 add config module 2025-06-06 13:39:02 +08:00
b2189a8c5f add config module 2025-06-05 22:04:45 +08:00
7b6a5ca24e add config module 2025-06-05 12:45:03 +08:00
a92ad374e1 add base subscriber 2025-06-04 22:50:04 +08:00
4991b83641 add base subscriber 2025-06-04 16:14:13 +08:00
38208a57e8 add base subscriber 2025-06-04 16:13:25 +08:00
3fa9ecc33a add base service 2025-06-04 11:14:19 +08:00
6f581ad383 add base service 2025-06-03 23:11:36 +08:00
3f0a9ca6fb add base repository 2025-06-03 14:29:58 +08:00
2c76c94578 add base repository 2025-06-03 13:09:31 +08:00
9f88f9c731 add base repository 2025-06-03 13:08:26 +08:00
71 changed files with 6490 additions and 3148 deletions

View File

@ -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
View File

@ -0,0 +1,3 @@
{
"recommendations": []
}

12
babel.config.js Normal file
View 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
View 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
View 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',
]),
]);

View File

@ -5,6 +5,13 @@
"compilerOptions": {
"deleteOutDir": true,
"builder": "swc",
"typeCheck": true
"typeCheck": true,
"plugins": [{
"name": "@nestjs/swagger",
"options":{
"introspectComments": true,
"controllerKeyOfComment": "summary"
}
}]
}
}

View File

@ -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": {

File diff suppressed because it is too large Load Diff

View File

@ -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
View 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
View 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',
}));

View File

@ -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,
}));

View File

@ -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'),
},
],
}));

View File

@ -1,2 +1,5 @@
export * from './database.config';
export * from './content.config';
export * from './app.config';
export * from './meili.config';
export * from './api.config';

View 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',
},
]);

View File

@ -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);

View 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));
}
}

View 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],
};
}
}

View 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');
}
}

View 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
View 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;
}
}

View 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>;

View 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];
}, []);
};

View 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,
});

View File

@ -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,
};
}
}

View File

@ -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]);
}
}

View File

@ -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) {}

View File

@ -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) {}

View File

@ -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) {}

View File

@ -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()

View File

@ -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);

View File

@ -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[];
}
}

View File

@ -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;

View File

@ -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.');
}
}

View File

@ -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);

View File

@ -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;

View File

@ -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);
}
}

View File

@ -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);
}
}

View File

@ -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 {

View 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;
},
});

View File

@ -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(

View File

@ -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,

View 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));
}

View File

@ -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
View 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;
}

View 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>;
}

View 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);
}
}

View 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[];
}
}

View 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;
};

View File

@ -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',
}

View File

@ -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>>(

View File

@ -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[];
};

View File

@ -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;
};

View 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),
});

View File

@ -1,9 +0,0 @@
import { MeiliConfig } from '@/modules/meilisearch/types';
export const MEILI_CONFIG = (): MeiliConfig => [
{
name: 'default',
host: 'http://localhost:7700',
apiKey: 'masterKey',
},
];

View File

@ -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;
},

View File

@ -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
View 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;
}
}

View File

@ -0,0 +1 @@
export const CONTROLLER_DEPENDS = 'controller_depends';

View 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 ?? []);

View 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;
}

View 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;
}

View 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],
};
}
}

View 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);
}

View 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 };
}

View 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
View 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;
},
};

View File

@ -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,
});

View File

@ -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!');
});
});

View File

@ -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
View File

@ -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