Compare commits

..

No commits in common. "24cb8e98431f3ad5b7850ac854c45d1902b52873" and "769973517bea095ee17d0461055dc2408fa4fe64" have entirely different histories.

18 changed files with 28 additions and 2209 deletions

View File

@ -25,7 +25,6 @@
"dependencies": {
"@casl/ability": "^6.7.3",
"@fastify/static": "^8.2.0",
"@nestjs/bullmq": "^11.0.2",
"@nestjs/common": "^11.1.3",
"@nestjs/core": "^11.1.3",
"@nestjs/jwt": "^11.0.0",
@ -34,7 +33,6 @@
"@nestjs/swagger": "^11.2.0",
"@nestjs/typeorm": "^11.0.0",
"bcrypt": "^6.0.0",
"bullmq": "^5.56.0",
"chalk": "^5.4.1",
"chokidar": "^4.0.3",
"class-transformer": "^0.5.1",
@ -42,16 +40,13 @@
"dayjs": "^1.11.13",
"deepmerge": "^4.3.1",
"dotenv": "^16.5.0",
"email-templates": "^12.0.3",
"fastify": "^5.4.0",
"find-up": "^7.0.0",
"fs-extra": "^11.3.0",
"ioredis": "^5.6.1",
"jsonwebtoken": "^9.0.2",
"lodash": "^4.17.21",
"meilisearch": "^0.51.0",
"mysql2": "^3.14.1",
"nodemailer": "^7.0.4",
"ora": "^8.2.0",
"passport": "^0.7.0",
"passport-jwt": "^4.0.1",
@ -61,7 +56,6 @@
"rimraf": "^6.0.1",
"rxjs": "^7.8.2",
"sanitize-html": "^2.17.0",
"tencentcloud-sdk-nodejs": "^4.1.67",
"typeorm": "^0.3.24",
"uuid": "^11.1.0",
"validator": "^13.15.15",
@ -79,14 +73,12 @@
"@swc/cli": "^0.7.7",
"@swc/core": "^1.12.1",
"@types/bcrypt": "^5.0.2",
"@types/email-templates": "^10.0.4",
"@types/eslint": "^9.6.1",
"@types/fs-extra": "^11.0.4",
"@types/jest": "29.5.14",
"@types/jsonwebtoken": "^9.0.10",
"@types/lodash": "^4.17.17",
"@types/node": "^24.0.1",
"@types/nodemailer": "^6.4.17",
"@types/passport-jwt": "^4.0.1",
"@types/passport-local": "^1.0.38",
"@types/sanitize-html": "^2.16.0",

File diff suppressed because it is too large Load Diff

View File

@ -3,7 +3,3 @@ export * from './content.config';
export * from './app.config';
export * from './meili.config';
export * from './api.config';
export * from './sms.config';
export * from './smtp.config';
export * from './redis.config';
export * from './queue.config';

View File

@ -1,5 +0,0 @@
import { QueueOptions } from '@/modules/core/types';
export const queue: () => QueueOptions = () => ({
redis: 'default',
});

View File

@ -1,6 +0,0 @@
import { RedisOptions } from '@/modules/core/types';
export const redis: () => RedisOptions = () => ({
host: '127.0.0.1',
port: 6379,
});

View File

@ -1,10 +0,0 @@
import { Configure } from '@/modules/config/configure';
import { SmsOptions } from '@/modules/core/types';
export const sms: (configure: Configure) => SmsOptions = (configure) => ({
sign: configure.env.get('SMS_CLOUD_SING', '极客科技'),
region: configure.env.get('SMS_CLOUD_REGION', 'ap-guangzhou'),
appid: configure.env.get('SMS_CLOUD_APPID', '1400437232'),
secretId: configure.env.get('SMS_CLOUD_ID', 'your-secret-id'),
secretKey: configure.env.get('SMS_CLOUD_KEY', 'your-secret-key'),
});

View File

@ -1,15 +0,0 @@
import path from 'path';
import { Configure } from '@/modules/config/configure';
import { SmtpOptions } from '@/modules/core/types';
export const smtp: (configure: Configure) => SmtpOptions = (configure) => ({
host: configure.env.get('SMTP_HOST', 'localhost'),
user: configure.env.get('SMTP_USER', 'test'),
password: configure.env.get('SMTP_PASSWORD', ''),
from: configure.env.get('SMTP_FROM', '平克小站<support@localhost>'),
port: configure.env.get('SMTP_PORT', (v) => Number(v), 25),
secure: configure.env.get('SMTP_SSL', (v) => JSON.parse(v), false),
// Email模板路径
resource: path.resolve(__dirname, '../../assets/emails'),
});

View File

@ -1,12 +1,4 @@
import { BullModule } from '@nestjs/bullmq';
import { DynamicModule, Module, ModuleMetadata } from '@nestjs/common';
import { isArray, isNil, omit } from 'lodash';
import { RedisService, SmsService, SmtpService } from '@/modules/core/services';
import { QueueOptions, RedisOptions, SmsOptions, SmtpOptions } from '@/modules/core/types';
import { createQueueOptions, createRedisOptions } from '@/options';
import { DynamicModule, Module } from '@nestjs/common';
import { Configure } from '../config/configure';
@ -14,54 +6,11 @@ import { Configure } from '../config/configure';
export class CoreModule {
static async forRoot(configure: Configure): Promise<DynamicModule> {
await configure.store('app.name');
const providers: ModuleMetadata['providers'] = [];
const exports: ModuleMetadata['exports'] = [];
let imports: ModuleMetadata['imports'] = [];
const redis = createRedisOptions(await configure.get<RedisOptions>('redis'));
if (!isNil(redis)) {
providers.push({
provide: RedisService,
useFactory: () => {
const service = new RedisService(redis);
service.createClients();
return service;
},
});
exports.push(RedisService);
const queues = createQueueOptions(await configure.get<QueueOptions>('queue'), redis);
if (!isNil(queues)) {
if (isArray(queues)) {
imports = queues.map((v) => BullModule.forRoot(v.name, omit(v, 'name')));
} else {
imports.push(BullModule.forRoot(queues));
}
}
}
const sms = await configure.get<SmsOptions>('sms');
if (!isNil(sms)) {
providers.push({
provide: SmsService,
useFactory: () => new SmsService(sms),
});
exports.push(SmsService);
}
const smtp = await configure.get<SmtpOptions>('smtp');
if (!isNil(smtp)) {
providers.push({
provide: SmtpService,
useFactory: () => new SmtpService(smtp),
});
exports.push(SmtpService);
}
return {
module: CoreModule,
global: true,
providers,
exports,
imports,
providers: [],
exports: [],
};
}
}

View File

@ -1,11 +1,11 @@
import { Paramtype, SetMetadata } from '@nestjs/common';
import { ClassTransformOptions } from '@nestjs/common/interfaces/external/class-transform-options.interface';
import { ValidatorOptions } from '@nestjs/common/interfaces/external/validator-options.interface';
import { ClassTransformOptions } from 'class-transformer';
import { ValidationOptions } from 'class-validator';
import { DTO_VALIDATION_OPTIONS } from '../contants';
export const DtoValidation = (
options?: ValidatorOptions & { transformOptions?: ClassTransformOptions } & {
options?: ValidationOptions & { transformOptions?: ClassTransformOptions } & {
type?: Paramtype;
},
) => SetMetadata(DTO_VALIDATION_OPTIONS, options ?? {});

View File

@ -1,3 +0,0 @@
export * from './redis.service';
export * from './sms.service';
export * from './smtp.service';

View File

@ -1,36 +0,0 @@
import { Injectable } from '@nestjs/common';
import Redis from 'ioredis';
import { isNil } from 'lodash';
import { RedisOption } from '@/modules/core/types';
@Injectable()
export class RedisService {
protected options: RedisOption[];
protected clients: Record<string, Redis> = {};
constructor(protected _options: RedisOption[]) {
this.options = _options;
}
async createClients() {
this.options.map(async (option) => {
this.clients[option.name] = new Redis(option);
});
}
getClient(name?: string): Redis {
const key = isNil(name) ? 'default' : name;
if (this.clients[key]) {
return this.clients[key];
}
throw new Error(`Unknown client ${key}`);
}
getClients() {
return this.clients;
}
}

View File

@ -1,71 +0,0 @@
import { Injectable } from '@nestjs/common';
import * as tencentcloud from 'tencentcloud-sdk-nodejs';
import { deepMerge } from '@/modules/core/helpers';
import { SmsOptions, SmsSendParams } from '@/modules/core/types';
const SmsClient = tencentcloud.sms.v20210111.Client;
/**
*
*/
@Injectable()
export class SmsService {
/**
*
* @param options
*/
constructor(protected options: SmsOptions) {}
/**
*
* @param options
*/
protected makeClient(options: SmsOptions) {
const { secretId, secretKey, region, endpoint } = options;
return new SmsClient({
credential: { secretId, secretKey },
region,
profile: {
httpProfile: { endpoint: endpoint ?? 'sms.tencentcloudapi.com' },
},
});
}
/**
*
* @param params
* @param options
*/
protected transSendParams(params: SmsSendParams, options: SmsOptions) {
const { numbers, template, vars, appid, sign, ...others } = params;
let paramSet: RecordAny = {};
if (vars) {
paramSet = Object.fromEntries(
Object.entries(vars).map(([key, value]) => [key, value.toString()]),
);
}
return {
PhoneNumberSet: numbers.map((n) => {
const phone: string[] = n.split('.');
return `${phone[0]}${phone[1]}`;
}),
TemplateId: template,
SmsSdkAppId: appid ?? options.appid,
SignName: sign ?? options.sign,
TemplateParamSet: Object.values(paramSet),
...(others ?? {}),
};
}
/**
*
* @param params
* @param options ()
*/
async send<T>(params: SmsSendParams & T, options?: SmsSendParams) {
const newOptions = deepMerge(this.options, options ?? {}) as SmsOptions;
const client = this.makeClient(newOptions);
return client.SendSms(this.transSendParams(params, newOptions));
}
}

View File

@ -1,93 +0,0 @@
import path from 'path';
import { Injectable } from '@nestjs/common';
import Email from 'email-templates';
import { pick } from 'lodash';
import mailer from 'nodemailer';
import Mail from 'nodemailer/lib/mailer';
import SMTPConnection from 'nodemailer/lib/smtp-connection';
import { deepMerge } from '@/modules/core/helpers';
import { SmtpOptions, SmtpSendParams } from '@/modules/core/types';
/**
* SMTP邮件发送驱动
*/
@Injectable()
export class SmtpService {
/**
*
* @param options
*/
constructor(protected readonly options: SmtpOptions) {}
/**
*
* @param params
* @param options
*/
async send<T>(params: SmtpSendParams & T, options?: SmtpOptions) {
const newOptions = deepMerge(this.options, options ?? {}) as SmtpOptions;
const client = this.makeClient(newOptions);
return this.makeSend(client, params, newOptions);
}
/**
* NodeMailer客户端
* @param options
*/
protected makeClient(options: SmtpOptions) {
const { host, secure, user, password, port } = options;
const clientOptions: SMTPConnection.Options = {
host,
secure: secure ?? false,
auth: {
user,
pass: password,
},
};
if (!clientOptions.secure) {
clientOptions.port = port ?? 25;
}
return mailer.createTransport(clientOptions);
}
/**
* NodeMailer发送参数
* @param client
* @param params
* @param options
*/
protected async makeSend(client: Mail, params: SmtpSendParams, options: SmtpOptions) {
const tplPath = path.resolve(options.resource, params.name ?? 'custom');
const textOnly = !params.html && params.text;
const noHtmlToText = params.html && params.text;
const configd: Email.EmailConfig = {
preview: params.preview ?? false,
send: !params.preview,
message: { from: params.from ?? options.from ?? options.user },
transport: client,
subjectPrefix: params.subjectPrefix,
textOnly,
juiceResources: {
preserveImportant: true,
webResources: {
relativeTo: tplPath,
},
},
};
if (noHtmlToText) {
configd.htmlToText = false;
}
const email = new Email(configd);
const message = {
...pick(params, ['from', 'to', 'reply', 'attachments', 'subject']),
locals: params.vars,
};
return email.send({
template: tplPath,
message,
});
}
}

View File

@ -3,12 +3,7 @@ import { ModuleMetadata, PipeTransform, Type } from '@nestjs/common';
import { IAuthGuard } from '@nestjs/passport';
import { NestFastifyApplication } from '@nestjs/platform-fastify';
import { TypeOrmModuleOptions } from '@nestjs/typeorm';
import { QueueOptions as BullMQOptions } from 'bullmq';
import dayjs from 'dayjs';
import Email from 'email-templates';
import { RedisOptions as IORedisOptions } from 'ioredis';
import { Attachment } from 'nodemailer/lib/mailer';
import { Ora } from 'ora';
import { StartOptions } from 'pm2';
import { ManyToMany, ManyToOne, OneToMany, OneToOne } from 'typeorm';
@ -147,109 +142,3 @@ export interface DynamicRelation {
| ReturnType<typeof ManyToMany>;
column: string;
}
/**
*
*/
export type NestedRecord = Record<string, Record<string, any>>;
/**
* core模块参数选项
*/
export interface CoreOptions {
database?: () => TypeOrmModuleOptions;
sms?: () => SmsOptions;
}
/**
*
*/
export type SmsOptions<T extends NestedRecord = RecordNever> = {
secretId: string;
secretKey: string;
sign: string;
appid: string;
region: string;
endpoint?: string;
} & T;
/**
*
*/
export interface SmsSendParams {
appid?: string;
numbers: string[];
template: string;
sign?: string;
endpoint?: string;
vars?: Record<string, any>;
ExtendCode?: string;
SessionContext?: string;
SenderId?: string;
}
/**
* SMTP邮件发送配置
*/
export type SmtpOptions<T extends NestedRecord = RecordNever> = {
host: string;
user: string;
password: string;
// Email模板总路径
resource: string;
from?: string;
port?: number;
secure?: boolean;
} & T;
/**
*
*/
export interface SmtpSendParams {
// 模板名称
name?: string;
// 发信地址
from?: string;
// 主题
subject?: string;
// 目标地址
to: string | string[];
// 回信地址
reply?: string;
// 是否加载html模板
html?: boolean;
// 是否加载text模板
text?: boolean;
// 模板变量
vars?: Record<string, any>;
// 是否预览
preview?: boolean | Email.PreviewEmailOpts;
// 主题前缀
subjectPrefix?: string;
// 附件
attachments?: Attachment[];
}
/**
* Redis连接配置
*/
export type RedisOption = Omit<IORedisOptions, 'name'> & { name: string };
/**
* Redis配置
*/
export type RedisOptions = IORedisOptions | Array<RedisOption>;
/**
*
*/
export type QueueOption = Omit<BullMQOptions, 'connection'> & { redis?: string };
/**
*
*/
export type QueueOptions = QueueOption | Array<{ name: string } & QueueOption>;
/**
* BullMQ模块注册配置
*/
export type BullOptions = BullMQOptions | Array<{ name: string } & BullMQOptions>;

View File

@ -7,7 +7,6 @@ import {
Request,
SerializeOptions,
UseGuards,
UseInterceptors,
} from '@nestjs/common';
import { ApiBearerAuth, ApiTags } from '@nestjs/swagger';
@ -15,8 +14,6 @@ import { pick } from 'lodash';
import { Depends } from '@/modules/restful/decorators/depend.decorator';
import { UserIdInterceptor } from '@/modules/user/interceptors';
import { Guest } from '../decorators/guest.decorator';
import { RequestUser } from '../decorators/user.request.decorator';
import { UpdateAccountDto, UpdatePasswordDto } from '../dtos/account.dto';
@ -86,13 +83,8 @@ export class AccountController {
*/
@Patch()
@ApiBearerAuth()
@UseInterceptors(UserIdInterceptor)
@SerializeOptions({ groups: ['user-detail'] })
async update(
@RequestUser() user: ClassToPlain<UserEntity>,
@Body()
data: UpdateAccountDto,
) {
async update(@RequestUser() user: ClassToPlain<UserEntity>, @Body() data: UpdateAccountDto) {
return this.userService.update({ id: user.id, ...pick(data, ['username', 'nickname']) });
}

View File

@ -11,8 +11,15 @@ import { UserValidateGroup } from '../constants';
/**
*
*/
@DtoValidation({ groups: [UserValidateGroup.ACCOUNT_UPDATE], whitelist: false })
export class UpdateAccountDto extends PickType(UserCommonDto, ['username', 'nickname']) {}
@DtoValidation({ groups: [UserValidateGroup.ACCOUNT_UPDATE] })
export class UpdateAccountDto extends PickType(UserCommonDto, ['username', 'nickname']) {
/**
* ID
*/
@IsUUID(undefined, { message: '用户ID格式不正确', groups: [UserValidateGroup.USER_UPDATE] })
@IsDefined({ groups: ['update'], message: '用户ID必须指定' })
id?: string;
}
/**
*

View File

@ -5,7 +5,7 @@ import { NestFactory } from '@nestjs/core';
import { FastifyAdapter, NestFastifyApplication } from '@nestjs/platform-fastify';
import { existsSync } from 'fs-extra';
import { isArray, isNil, omit } from 'lodash';
import { isNil } from 'lodash';
import { RbacModule } from '@/modules/rbac/rbac.module';
import { UserModule } from '@/modules/user/user.module';
@ -13,13 +13,7 @@ import { UserModule } from '@/modules/user/user.module';
import * as configs from './config';
import { ContentModule } from './modules/content/content.module';
import { GlobalExceptionFilter } from './modules/core/filters/global-exception.filter';
import {
BullOptions,
CreateOptions,
QueueOptions,
RedisOption,
RedisOptions,
} from './modules/core/types';
import { CreateOptions } from './modules/core/types';
import * as dbCommands from './modules/database/commands';
import { DatabaseModule } from './modules/database/database.module';
import { MeiliModule } from './modules/meilisearch/meili.module';
@ -58,61 +52,12 @@ export const createOptions: CreateOptions = {
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;
},
};
/**
* Redis配置
* @param options
*/
export const createRedisOptions = (options: RedisOptions) => {
if (isNil(options)) {
return undefined;
}
const config: Array<RedisOption> = Array.isArray(options)
? options
: [{ ...options, name: 'default' }];
if (config.length < 1) {
return undefined;
}
if (isNil(config.find(({ name }) => name === 'default'))) {
config[0].name = 'default';
}
return config.reduce<RedisOption[]>((o, n) => {
const names = o.map(({ name }) => name) as string[];
return names.includes(n.name) ? o : [...o, n];
}, []);
};
/**
* BullMQ模块的配置
* @param options
* @param redis
*/
export const createQueueOptions = (
options: QueueOptions,
redis: Array<RedisOption>,
): BullOptions | undefined => {
if (isNil(options) || isNil(redis)) {
return undefined;
}
const names = redis.map(({ name }) => name);
if (names.length < 1 || !names.includes('default')) {
return undefined;
}
if (isArray(options)) {
return options.map((option) => ({
...omit(option, 'redis'),
connection: redis.find(({ name: c }) => c === (option.redis ?? 'default')),
}));
}
return {
...omit(options, 'redis'),
connection: redis.find(({ name: c }) => c === (options.redis ?? 'default')),
};
};

View File

@ -324,6 +324,7 @@ describe('AccountController (App)', () => {
},
});
const { token } = loginResult.json();
console.log(token);
const result = await app.inject({
method: 'POST',
url: `${URL_PREFIX}/account/logout`,
@ -448,6 +449,7 @@ describe('AccountController (App)', () => {
username: `updated-account-${randomTag}`,
},
});
console.log(result.json());
expect(result.statusCode).toBe(200);
const updatedUser = result.json();
expect(updatedUser.username).toBe(`updated-account-${randomTag}`);