add config module

This commit is contained in:
liuyi 2025-06-08 14:26:04 +08:00
parent 88ba3f5a16
commit e5912600ce
27 changed files with 383 additions and 161 deletions

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

9
src/config/app.config.ts Normal file
View File

@ -0,0 +1,9 @@
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), 3099),
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', '127.0.0.1'),
port: configure.env.get<number>('DB_PORT', (v) => toNumber(v), 3306),
username: configure.env.get('DB_USERNAME', 'root'),
password: configure.env.get('DB_PASSWORD', '12345678'),
database: configure.env.get('DB_NAME', '3r'),
},
],
}));

View File

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

View File

@ -0,0 +1,8 @@
import { createMeiliConfig } from '../modules/meilisearch/config';
export const MEILI_CONFIG = createMeiliConfig((configure) => [
{
name: 'default',
host: 'http://192.168.50.26:7700',
},
]);

View File

@ -1,21 +1,4 @@
import { NestFactory } from '@nestjs/core';
import { createApp, listened, startApp } from './modules/core/helpers/app';
import { createOptions } from './options';
import { FastifyAdapter, NestFastifyApplication } from '@nestjs/platform-fastify';
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

@ -46,6 +46,16 @@ export class Env {
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;

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

@ -11,19 +11,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 +47,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,
@ -63,11 +73,7 @@ export class ContentModule {
],
controllers: Object.values(controllers),
providers,
exports: [
...Object.values(services),
PostService,
DatabaseModule.forRepository(Object.values(repositories)),
],
exports,
};
}
}

View File

@ -1,5 +1,8 @@
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';
@ -11,14 +14,19 @@ 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,
) {
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,9 +1,11 @@
import { BadGatewayException, Global, Module, ModuleMetadata, Type } from '@nestjs/common';
import { APP_FILTER, APP_INTERCEPTOR, APP_PIPE } from '@nestjs/core';
import { NestFastifyApplication } from '@nestjs/platform-fastify';
import chalk from 'chalk';
import { useContainer } from 'class-validator';
import { omit } from 'lodash';
import { isNil, omit } from 'lodash';
import { ConfigModule } from '@/modules/config/config.module';
import { Configure } from '@/modules/config/configure';
@ -14,7 +16,7 @@ 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, CreateOptions } from '../types';
import { App, AppConfig, CreateOptions } from '../types';
import { CreateModule } from './utils';
@ -90,3 +92,32 @@ export async function createBootModule(
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));
}
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)}`);
}
export const listened: (app: App, startyTime: Date) => () => Promise<void> =
({ configure, container }, startTime) =>
async () => {
console.log();
await echoApi(configure, container);
console.log('used time: ', chalk.cyan(`${new Date().getTime() - startTime.getTime()}`));
};

View File

@ -1,7 +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;
@ -55,3 +58,27 @@ export function CreateModule(
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);
}
}

View File

@ -35,3 +35,29 @@ export interface CreateOptions {
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,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,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,3 +1,4 @@
import { TypeOrmModuleOptions } from '@nestjs/typeorm';
import {
FindTreeOptions,
ObjectLiteral,
@ -67,3 +68,15 @@ export type RepositoryType<T extends ObjectLiteral> =
| 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

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

32
src/options.ts Normal file
View File

@ -0,0 +1,32 @@
import { NestFactory } from '@nestjs/core';
import { FastifyAdapter, NestFastifyApplication } from '@nestjs/platform-fastify';
import * as configs from './config';
import { ContentModule } from './modules/content/content.module';
import { CoreModule } from './modules/core/core.module';
import { CreateOptions } from './modules/core/types';
import { DatabaseModule } from './modules/database/database.module';
import { MeiliModule } from './modules/meilisearch/meili.module';
export const createOptions: CreateOptions = {
config: { factories: configs as any, storage: { enable: true } },
modules: async (configure) => [
DatabaseModule.forRoot(configure),
MeiliModule.forRoot(configure),
ContentModule.forRoot(configure),
CoreModule.forRoot(configure),
],
globals: {},
builder: async ({ configure, BootModule }) => {
const container = await NestFactory.create<NestFastifyApplication>(
BootModule,
new FastifyAdapter(),
{
cors: true,
logger: ['error', 'warn'],
},
);
return container;
},
};

View File

@ -7,7 +7,6 @@ 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,
@ -16,6 +15,7 @@ import {
TagRepository,
} from '@/modules/content/repositories';
import { CoreModule } from '@/modules/core/core.module';
import { MeiliService } from '@/modules/meilisearch/meili.service';
import { generateRandomNumber, generateUniqueRandomNumbers } from './generate-mock-data';
@ -37,10 +37,10 @@ describe('nest app test', () => {
beforeAll(async () => {
const module: TestingModule = await Test.createTestingModule({
imports: [AppModule],
imports: [CoreModule],
}).compile();
app = module.createNestApplication<NestFastifyApplication>(new FastifyAdapter());
useContainer(app.select(AppModule), { fallbackOnErrors: true });
useContainer(app.select(CoreModule), { fallbackOnErrors: true });
await app.init();
await app.getHttpAdapter().getInstance().ready();

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