Compare commits

...
7 Commits
Author SHA1 Message Date
shuijiangui fac8be4dd0 add mq and redis 2025-08-01 23:08:40 +08:00
shuijiangui de01ffd7c7 add mq and redis 2025-08-01 10:50:03 +08:00
shuijiangui 91b5cce39b add mq and redis 2025-08-01 10:49:50 +08:00
shuijiangui ef6380395c add mq and redis 2025-08-01 10:49:23 +08:00
shuijiangui 24cb8e9843 add mq and redis 2025-07-04 16:24:32 +08:00
shuijiangui 730b77448e modify bugs 2025-07-04 10:26:05 +08:00
shuijiangui 0d11d2bd8a add bullmq and redis 2025-07-03 22:26:18 +08:00
29 changed files with 2617 additions and 44 deletions
+8
View File
@@ -25,6 +25,7 @@
"dependencies": { "dependencies": {
"@casl/ability": "^6.7.3", "@casl/ability": "^6.7.3",
"@fastify/static": "^8.2.0", "@fastify/static": "^8.2.0",
"@nestjs/bullmq": "^11.0.2",
"@nestjs/common": "^11.1.3", "@nestjs/common": "^11.1.3",
"@nestjs/core": "^11.1.3", "@nestjs/core": "^11.1.3",
"@nestjs/jwt": "^11.0.0", "@nestjs/jwt": "^11.0.0",
@@ -33,6 +34,7 @@
"@nestjs/swagger": "^11.2.0", "@nestjs/swagger": "^11.2.0",
"@nestjs/typeorm": "^11.0.0", "@nestjs/typeorm": "^11.0.0",
"bcrypt": "^6.0.0", "bcrypt": "^6.0.0",
"bullmq": "^5.56.0",
"chalk": "^5.4.1", "chalk": "^5.4.1",
"chokidar": "^4.0.3", "chokidar": "^4.0.3",
"class-transformer": "^0.5.1", "class-transformer": "^0.5.1",
@@ -40,13 +42,16 @@
"dayjs": "^1.11.13", "dayjs": "^1.11.13",
"deepmerge": "^4.3.1", "deepmerge": "^4.3.1",
"dotenv": "^16.5.0", "dotenv": "^16.5.0",
"email-templates": "^12.0.3",
"fastify": "^5.4.0", "fastify": "^5.4.0",
"find-up": "^7.0.0", "find-up": "^7.0.0",
"fs-extra": "^11.3.0", "fs-extra": "^11.3.0",
"ioredis": "^5.6.1",
"jsonwebtoken": "^9.0.2", "jsonwebtoken": "^9.0.2",
"lodash": "^4.17.21", "lodash": "^4.17.21",
"meilisearch": "^0.51.0", "meilisearch": "^0.51.0",
"mysql2": "^3.14.1", "mysql2": "^3.14.1",
"nodemailer": "^7.0.4",
"ora": "^8.2.0", "ora": "^8.2.0",
"passport": "^0.7.0", "passport": "^0.7.0",
"passport-jwt": "^4.0.1", "passport-jwt": "^4.0.1",
@@ -56,6 +61,7 @@
"rimraf": "^6.0.1", "rimraf": "^6.0.1",
"rxjs": "^7.8.2", "rxjs": "^7.8.2",
"sanitize-html": "^2.17.0", "sanitize-html": "^2.17.0",
"tencentcloud-sdk-nodejs": "^4.1.67",
"typeorm": "^0.3.24", "typeorm": "^0.3.24",
"uuid": "^11.1.0", "uuid": "^11.1.0",
"validator": "^13.15.15", "validator": "^13.15.15",
@@ -73,12 +79,14 @@
"@swc/cli": "^0.7.7", "@swc/cli": "^0.7.7",
"@swc/core": "^1.12.1", "@swc/core": "^1.12.1",
"@types/bcrypt": "^5.0.2", "@types/bcrypt": "^5.0.2",
"@types/email-templates": "^10.0.4",
"@types/eslint": "^9.6.1", "@types/eslint": "^9.6.1",
"@types/fs-extra": "^11.0.4", "@types/fs-extra": "^11.0.4",
"@types/jest": "29.5.14", "@types/jest": "29.5.14",
"@types/jsonwebtoken": "^9.0.10", "@types/jsonwebtoken": "^9.0.10",
"@types/lodash": "^4.17.17", "@types/lodash": "^4.17.17",
"@types/node": "^24.0.1", "@types/node": "^24.0.1",
"@types/nodemailer": "^6.4.17",
"@types/passport-jwt": "^4.0.1", "@types/passport-jwt": "^4.0.1",
"@types/passport-local": "^1.0.38", "@types/passport-local": "^1.0.38",
"@types/sanitize-html": "^2.16.0", "@types/sanitize-html": "^2.16.0",
+1719 -5
View File
File diff suppressed because it is too large Load Diff
+4
View File
@@ -3,3 +3,7 @@ export * from './content.config';
export * from './app.config'; export * from './app.config';
export * from './meili.config'; export * from './meili.config';
export * from './api.config'; export * from './api.config';
export * from './sms.config';
export * from './smtp.config';
export * from './redis.config';
export * from './queue.config';
+5
View File
@@ -0,0 +1,5 @@
import { QueueOptions } from '@/modules/core/types';
export const queue: () => QueueOptions = () => ({
redis: 'default',
});
+7
View File
@@ -0,0 +1,7 @@
import { RedisOptions } from '@/modules/core/types';
export const redis: () => RedisOptions = () => ({
host: '192.168.50.137',
port: 6379,
password: '123456&Qw',
});
+10
View File
@@ -0,0 +1,10 @@
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', 'liuyi'),
region: configure.env.get('SMS_CLOUD_REGION', 'ap-guangzhou'),
appid: configure.env.get('SMS_CLOUD_APPID', 'app-id'),
secretId: configure.env.get('SMS_CLOUD_ID', 'your-secret-id'),
secretKey: configure.env.get('SMS_CLOUD_KEY', 'your-secret-key'),
});
+15
View File
@@ -0,0 +1,15 @@
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'),
});
+3 -1
View File
@@ -1,3 +1,5 @@
import { createUserConfig } from '@/modules/user/config'; import { createUserConfig } from '@/modules/user/config';
export const user = createUserConfig(() => ({})); export const user = createUserConfig(() => ({
hash: 10,
}));
+62 -3
View File
@@ -1,4 +1,18 @@
import { DynamicModule, Module } from '@nestjs/common'; 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 type {
QueueOptions,
RedisOption,
RedisOptions,
SmsOptions,
SmtpOptions,
} from '@/modules/core/types';
import { createQueueOptions, createRedisOptions } from '@/options';
import { Configure } from '../config/configure'; import { Configure } from '../config/configure';
@@ -6,11 +20,56 @@ import { Configure } from '../config/configure';
export class CoreModule { export class CoreModule {
static async forRoot(configure: Configure): Promise<DynamicModule> { static async forRoot(configure: Configure): Promise<DynamicModule> {
await configure.store('app.name'); await configure.store('app.name');
const providers: ModuleMetadata['providers'] = [];
const exports: ModuleMetadata['exports'] = [];
let imports: ModuleMetadata['imports'] = [];
const redis: RedisOption[] | undefined = 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 { return {
module: CoreModule, module: CoreModule,
global: true, global: true,
providers: [], providers,
exports: [], exports,
imports,
}; };
} }
} }
@@ -1,11 +1,11 @@
import { Paramtype, SetMetadata } from '@nestjs/common'; import { Paramtype, SetMetadata } from '@nestjs/common';
import { ClassTransformOptions } from 'class-transformer'; import { ClassTransformOptions } from '@nestjs/common/interfaces/external/class-transform-options.interface';
import { ValidationOptions } from 'class-validator'; import { ValidatorOptions } from '@nestjs/common/interfaces/external/validator-options.interface';
import { DTO_VALIDATION_OPTIONS } from '../contants'; import { DTO_VALIDATION_OPTIONS } from '../contants';
export const DtoValidation = ( export const DtoValidation = (
options?: ValidationOptions & { transformOptions?: ClassTransformOptions } & { options?: ValidatorOptions & { transformOptions?: ClassTransformOptions } & {
type?: Paramtype; type?: Paramtype;
}, },
) => SetMetadata(DTO_VALIDATION_OPTIONS, options ?? {}); ) => SetMetadata(DTO_VALIDATION_OPTIONS, options ?? {});
+3
View File
@@ -0,0 +1,3 @@
export * from './redis.service';
export * from './sms.service';
export * from './smtp.service';
@@ -0,0 +1,36 @@
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;
}
}
+71
View File
@@ -0,0 +1,71 @@
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));
}
}
+93
View File
@@ -0,0 +1,93 @@
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,
});
}
}
+114
View File
@@ -3,7 +3,12 @@ import { ModuleMetadata, PipeTransform, Type } from '@nestjs/common';
import { IAuthGuard } from '@nestjs/passport'; import { IAuthGuard } from '@nestjs/passport';
import { NestFastifyApplication } from '@nestjs/platform-fastify'; import { NestFastifyApplication } from '@nestjs/platform-fastify';
import { TypeOrmModuleOptions } from '@nestjs/typeorm';
import { QueueOptions as BullMQOptions } from 'bullmq';
import dayjs from 'dayjs'; 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 { Ora } from 'ora';
import { StartOptions } from 'pm2'; import { StartOptions } from 'pm2';
import { ManyToMany, ManyToOne, OneToMany, OneToOne } from 'typeorm'; import { ManyToMany, ManyToOne, OneToMany, OneToOne } from 'typeorm';
@@ -142,3 +147,112 @@ export interface DynamicRelation {
| ReturnType<typeof ManyToMany>; | ReturnType<typeof ManyToMany>;
column: string; column: string;
} }
/**
* 嵌套对象
*/
export type NestedRecord = Record<string, RecordAny>;
/**
* core模块参数选项
*/
export interface CoreOptions {
database?: () => TypeOrmModuleOptions;
sms?: () => SmsOptions;
smtp?: () => SmtpOptions;
redis?: () => RedisOptions;
queue?: () => QueueOptions;
}
/**
* 腾讯云短信驱动配置
*/
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>;
+17
View File
@@ -19,6 +19,23 @@ export function defaultUserConfig(configure: Configure): UserConfig {
3600 * 30, 3600 * 30,
), ),
}, },
captcha: {
sms: {
login: {
template: configure.env.get('SMS_LOGIN_CAPTCHA_CLOUD', 'your-id'),
},
register: {
template: configure.env.get('SMS_REGISTER_CAPTCHA_CLOUD', 'your-id'),
},
'retrieve-password': {
template: configure.env.get('SMS_RETRIEVE_PASSWORD_CAPTCHA_CLOUD', 'your-id'),
},
},
email: {
register: {},
'retrieve-password': {},
},
},
}; };
} }
+65
View File
@@ -40,3 +40,68 @@ export const TokenConst = {
}; };
export const ALLOW_GUEST = 'allowGuest'; export const ALLOW_GUEST = 'allowGuest';
/**
* 验证码发送数据DTO验证组
*/
export enum CaptchaDtoGroups {
// 发送短信登录验证码
PHONE_LOGIN = 'phone-login',
// 发送邮件登录验证码
EMAIL_LOGIN = 'email-login',
// 发送短信注册验证码
PHONE_REGISTER = 'phone-register',
// 发送邮件注册验证码
EMAIL_REGISTER = 'email-register',
// 发送找回密码的短信验证码
PHONE_RETRIEVE_PASSWORD = 'phone-retrieve-password',
// 发送找回密码的邮件验证码
EMAIL_RETRIEVE_PASSWORD = 'email-retrieve-password',
// 发送登录用户密码重置的短信验证码
PHONE_RESET_PASSWORD = 'phone-reset-password',
// 发送登录用户密码重置的邮件验证码
EMAIL_RESET_PASSWORD = 'email-reset-password',
// 发送手机号绑定或更改的短信验证码
BOUND_PHONE = 'bound-phone',
// 发送邮箱地址绑定或更改的邮件验证码
BOUND_EMAIL = 'bound-email',
}
/**
* 验证码操作类别
*/
export enum CaptchaActionType {
// 登录操作
LOGIN = 'login',
// 注册操作
REGISTER = 'register',
// 找回密码操作
RETRIEVE_PASSWORD = 'retrieve-password',
// 重置密码操作
RESET_PASSWORD = 'reset-password',
// 手机号或邮箱地址绑定操作
ACCOUNT_BOUND = 'account-bound',
}
/**
* 验证码类型
*/
export enum CaptchaType {
SMS = 'sms',
EMAIL = 'email',
}
/**
* 发送验证码异步列队名称
*/
export const SEND_CAPTCHA_QUEUE = 'send-captcha-queue';
/**
* 发送短信验证码任务处理名称
*/
export const SMS_CAPTCHA_JOB = 'sms-captcha-job';
/**
* 发送邮件验证码任务处理名称
*/
export const EMAIL_CAPTCHA_JOB = 'mail-captcha-job';
@@ -7,6 +7,7 @@ import {
Request, Request,
SerializeOptions, SerializeOptions,
UseGuards, UseGuards,
UseInterceptors,
} from '@nestjs/common'; } from '@nestjs/common';
import { ApiBearerAuth, ApiTags } from '@nestjs/swagger'; import { ApiBearerAuth, ApiTags } from '@nestjs/swagger';
@@ -14,6 +15,8 @@ import { pick } from 'lodash';
import { Depends } from '@/modules/restful/decorators/depend.decorator'; import { Depends } from '@/modules/restful/decorators/depend.decorator';
import { UserIdInterceptor } from '@/modules/user/interceptors';
import { Guest } from '../decorators/guest.decorator'; import { Guest } from '../decorators/guest.decorator';
import { RequestUser } from '../decorators/user.request.decorator'; import { RequestUser } from '../decorators/user.request.decorator';
import { UpdateAccountDto, UpdatePasswordDto } from '../dtos/account.dto'; import { UpdateAccountDto, UpdatePasswordDto } from '../dtos/account.dto';
@@ -83,8 +86,13 @@ export class AccountController {
*/ */
@Patch() @Patch()
@ApiBearerAuth() @ApiBearerAuth()
@UseInterceptors(UserIdInterceptor)
@SerializeOptions({ groups: ['user-detail'] }) @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']) }); return this.userService.update({ id: user.id, ...pick(data, ['username', 'nickname']) });
} }
+22 -19
View File
@@ -1,38 +1,24 @@
import { PickType } from '@nestjs/swagger'; import { OmitType, PickType } from '@nestjs/swagger';
import { IsDefined, IsUUID, Length } from 'class-validator'; import { Length } from 'class-validator';
import { IsPassword } from '@/modules/core/constraints/password.constraint'; import { IsPassword } from '@/modules/core/constraints/password.constraint';
import { DtoValidation } from '@/modules/core/decorator/dto.validation.decorator'; import { DtoValidation } from '@/modules/core/decorator/dto.validation.decorator';
import { UserCommonDto } from '@/modules/user/dtos/user.common.dto'; import { UserCommonDto } from '@/modules/user/dtos/user.common.dto';
import { UserValidateGroup } from '../constants'; import { CaptchaDtoGroups, UserValidateGroup } from '../constants';
/** /**
* 更新用户信息 * 更新用户信息
*/ */
@DtoValidation({ groups: [UserValidateGroup.ACCOUNT_UPDATE] }) @DtoValidation({ groups: [UserValidateGroup.ACCOUNT_UPDATE], whitelist: false })
export class UpdateAccountDto extends PickType(UserCommonDto, ['username', 'nickname']) { export class UpdateAccountDto extends PickType(UserCommonDto, ['username', 'nickname']) {}
/**
* 待更新的用户ID
*/
@IsUUID(undefined, { message: '用户ID格式不正确', groups: [UserValidateGroup.USER_UPDATE] })
@IsDefined({ groups: ['update'], message: '用户ID必须指定' })
id?: string;
}
/** /**
* 更改用户密码 * 更改用户密码
*/ */
@DtoValidation({ groups: [UserValidateGroup.CHANGE_PASSWORD] }) @DtoValidation({ groups: [UserValidateGroup.CHANGE_PASSWORD] })
export class UpdatePasswordDto extends PickType(UserCommonDto, ['password', 'plainPassword']) { export class UpdatePasswordDto extends PickType(UserCommonDto, ['password', 'plainPassword']) {
/**
* 待更新的用户ID
*/
@IsUUID(undefined, { message: '用户ID格式不正确', groups: [UserValidateGroup.USER_UPDATE] })
@IsDefined({ groups: ['update'], message: '用户ID必须指定' })
id: string;
/** /**
* 旧密码:用户在更改密码时需要输入的原密码 * 旧密码:用户在更改密码时需要输入的原密码
*/ */
@@ -40,3 +26,20 @@ export class UpdatePasswordDto extends PickType(UserCommonDto, ['password', 'pla
@Length(8, 50, { message: '密码长度不得少于$constraint1', always: true }) @Length(8, 50, { message: '密码长度不得少于$constraint1', always: true })
oldPassword: string; oldPassword: string;
} }
/**
* 对手机/邮箱绑定验证码进行验证
*/
export class AccountBoundDto extends PickType(UserCommonDto, ['code', 'phone', 'email']) {}
/**
* 绑定或更改手机号验证
*/
@DtoValidation({ groups: [CaptchaDtoGroups.BOUND_PHONE] })
export class PhoneBoundDto extends OmitType(AccountBoundDto, ['email'] as const) {}
/**
* 绑定或更改邮箱验证
*/
@DtoValidation({ groups: [CaptchaDtoGroups.BOUND_EMAIL] })
export class EmailBoundDto extends OmitType(AccountBoundDto, ['phone'] as const) {}
+57 -1
View File
@@ -1,7 +1,7 @@
import { PickType } from '@nestjs/swagger'; import { PickType } from '@nestjs/swagger';
import { DtoValidation } from '@/modules/core/decorator/dto.validation.decorator'; import { DtoValidation } from '@/modules/core/decorator/dto.validation.decorator';
import { UserValidateGroup } from '@/modules/user/constants'; import { CaptchaDtoGroups, UserValidateGroup } from '@/modules/user/constants';
import { UserCommonDto } from '@/modules/user/dtos/user.common.dto'; import { UserCommonDto } from '@/modules/user/dtos/user.common.dto';
/** /**
@@ -9,6 +9,18 @@ import { UserCommonDto } from '@/modules/user/dtos/user.common.dto';
*/ */
export class CredentialDto extends PickType(UserCommonDto, ['credential', 'password']) {} export class CredentialDto extends PickType(UserCommonDto, ['credential', 'password']) {}
/**
* 通过手机验证码登录
*/
@DtoValidation({ groups: [CaptchaDtoGroups.PHONE_LOGIN] })
export class PhoneLoginDto extends PickType(UserCommonDto, ['phone', 'code'] as const) {}
/**
* 通过邮箱验证码登录
*/
@DtoValidation({ groups: [CaptchaDtoGroups.EMAIL_LOGIN] })
export class EmailLoginDto extends PickType(UserCommonDto, ['email', 'code'] as const) {}
/** /**
* 普通方式注册用户 * 普通方式注册用户
*/ */
@@ -19,3 +31,47 @@ export class RegisterDto extends PickType(UserCommonDto, [
'password', 'password',
'plainPassword', 'plainPassword',
] as const) {} ] as const) {}
/**
* 通过手机验证码注册
*/
@DtoValidation({ groups: [CaptchaDtoGroups.PHONE_REGISTER] })
export class PhoneRegisterDto extends PickType(UserCommonDto, ['phone', 'code'] as const) {}
/**
* 通过邮件验证码注册
*/
@DtoValidation({ groups: [CaptchaDtoGroups.EMAIL_REGISTER] })
export class EmailRegisterDto extends PickType(UserCommonDto, ['email', 'code'] as const) {}
/**
* 通过登录凭证找回密码
*/
export class RetrievePasswordDto extends PickType(UserCommonDto, [
'credential',
'code',
'password',
'plainPassword',
] as const) {}
/**
* 通过手机号找回密码
*/
@DtoValidation({ groups: [CaptchaDtoGroups.EMAIL_RETRIEVE_PASSWORD] })
export class PhoneRetrievePasswordDto extends PickType(UserCommonDto, [
'phone',
'code',
'password',
'plainPassword',
] as const) {}
/**
* 通过邮箱地址找回密码
*/
@DtoValidation({ groups: [CaptchaDtoGroups.EMAIL_RETRIEVE_PASSWORD] })
export class EmailRetrievePasswordDto extends PickType(UserCommonDto, [
'email',
'code',
'password',
'plainPassword',
] as const) {}
+80
View File
@@ -0,0 +1,80 @@
import { PickType } from '@nestjs/swagger';
import { DtoValidation } from '@/modules/core/decorator/dto.validation.decorator';
import { CaptchaDtoGroups } from '../constants';
import { UserCommonDto } from './user.common.dto';
/**
* 发送邮件或短信验证码消息
*/
export class CaptchaMessage extends PickType(UserCommonDto, ['phone', 'email']) {}
/**
* 发送短信验证码DTO类型
*/
export class PhoneCaptchaMessageDto extends PickType(CaptchaMessage, ['phone'] as const) {}
/**
* 发送邮件验证码DTO类型
*/
export class EmailCaptchaMessageDto extends PickType(CaptchaMessage, ['email'] as const) {}
/**
* 通过已登录账户发送验证码消息
*/
export class UserCaptchaMessageDto extends PickType(UserCommonDto, ['type']) {}
/**
* 通过用户凭证发送验证码消息
*/
export class CredentialCaptchaMessageDto extends PickType(UserCommonDto, ['credential']) {}
/**
* 发送登录验证码短信
*/
@DtoValidation({ groups: [CaptchaDtoGroups.PHONE_LOGIN] })
export class LoginPhoneCaptchaDto extends PhoneCaptchaMessageDto {}
/**
* 发送登录验证码邮件
*/
@DtoValidation({ groups: [CaptchaDtoGroups.EMAIL_LOGIN] })
export class LoginEmailCaptchaDto extends EmailCaptchaMessageDto {}
/**
* 发送注册验证码短信
*/
@DtoValidation({ groups: [CaptchaDtoGroups.PHONE_REGISTER] })
export class RegisterPhoneCaptchaDto extends PhoneCaptchaMessageDto {}
/**
* 发送注册验证码邮件
*/
@DtoValidation({ groups: [CaptchaDtoGroups.PHONE_REGISTER] })
export class RegisterEmailCaptchaDto extends EmailCaptchaMessageDto {}
/**
* 发送找回密码短信
*/
@DtoValidation({ groups: [CaptchaDtoGroups.EMAIL_RETRIEVE_PASSWORD] })
export class RetrievePasswordPhoneCaptchaDto extends PhoneCaptchaMessageDto {}
/**
* 发送找回密码邮件
*/
@DtoValidation({ groups: [CaptchaDtoGroups.EMAIL_RETRIEVE_PASSWORD] })
export class RetrievePasswordEmailCaptchaDto extends EmailCaptchaMessageDto {}
/**
* 发送手机绑定短信
*/
@DtoValidation({ groups: [CaptchaDtoGroups.BOUND_PHONE] })
export class BoundPhoneCaptchaDto extends PhoneCaptchaMessageDto {}
/**
* 发送邮箱绑定邮件
*/
@DtoValidation({ groups: [CaptchaDtoGroups.BOUND_EMAIL] })
export class BoundEmailCaptchaDto extends EmailCaptchaMessageDto {}
+9 -2
View File
@@ -1,11 +1,11 @@
import { Injectable } from '@nestjs/common'; import { Injectable } from '@nestjs/common';
import { IsEmail, IsNotEmpty, IsOptional, Length } from 'class-validator'; import { IsEmail, IsEnum, IsNotEmpty, IsNumberString, IsOptional, Length } from 'class-validator';
import { IsMatch } from '@/modules/core/constraints/match.constraint'; import { IsMatch } from '@/modules/core/constraints/match.constraint';
import { IsPassword } from '@/modules/core/constraints/password.constraint'; import { IsPassword } from '@/modules/core/constraints/password.constraint';
import { IsMatchPhone } from '@/modules/core/constraints/phone.number.constraint'; import { IsMatchPhone } from '@/modules/core/constraints/phone.number.constraint';
import { IsUnique, IsUniqueExist } from '@/modules/database/constraints'; import { IsUnique, IsUniqueExist } from '@/modules/database/constraints';
import { UserValidateGroup } from '@/modules/user/constants'; import { CaptchaType, UserValidateGroup } from '@/modules/user/constants';
import { UserEntity } from '@/modules/user/entities/user.entity'; import { UserEntity } from '@/modules/user/entities/user.entity';
/** /**
@@ -111,4 +111,11 @@ export class UserCommonDto {
@IsMatch('password', false, { message: '两次输入密码不同', always: true }) @IsMatch('password', false, { message: '两次输入密码不同', always: true })
@IsNotEmpty({ message: '请再次输入密码以确认', always: true }) @IsNotEmpty({ message: '请再次输入密码以确认', always: true })
plainPassword: string; plainPassword: string;
@IsNumberString(undefined, { message: '验证码必须为数字', always: true })
@Length(6, 6, { message: '验证码长度错误', always: true })
code!: string;
@IsEnum(CaptchaType)
type: CaptchaType;
} }
+11 -1
View File
@@ -1,8 +1,10 @@
import { OmitType, PartialType, PickType } from '@nestjs/swagger'; import { OmitType, PartialType, PickType } from '@nestjs/swagger';
import { IsDefined, IsEnum, IsOptional, IsUUID } from 'class-validator'; import { Transform } from 'class-transformer';
import { IsBoolean, IsDefined, IsEnum, IsOptional, IsUUID } from 'class-validator';
import { DtoValidation } from '@/modules/core/decorator/dto.validation.decorator'; import { DtoValidation } from '@/modules/core/decorator/dto.validation.decorator';
import { toBoolean } from '@/modules/core/helpers';
import { IsDataExist } from '@/modules/database/constraints'; import { IsDataExist } from '@/modules/database/constraints';
import { PermissionEntity, RoleEntity } from '@/modules/rbac/entities'; import { PermissionEntity, RoleEntity } from '@/modules/rbac/entities';
import { PaginateWithTrashedDto } from '@/modules/restful/dtos/paginate-width-trashed.dto'; import { PaginateWithTrashedDto } from '@/modules/restful/dtos/paginate-width-trashed.dto';
@@ -69,6 +71,7 @@ export class UpdateUserDto extends PartialType(CreateUserDto) {
/** /**
* 查询用户列表的Query数据验证 * 查询用户列表的Query数据验证
*/ */
@DtoValidation({ type: 'query', skipMissingProperties: true })
export class QueryUserDto extends PaginateWithTrashedDto { export class QueryUserDto extends PaginateWithTrashedDto {
/** /**
* 角色ID:根据角色来过滤用户 * 角色ID:根据角色来过滤用户
@@ -96,6 +99,13 @@ export class QueryUserDto extends PaginateWithTrashedDto {
@IsEnum(UserOrderType) @IsEnum(UserOrderType)
@IsOptional() @IsOptional()
orderBy?: UserOrderType; orderBy?: UserOrderType;
/**
* 过滤激活状态
*/
@Transform(({ value }) => toBoolean(value))
@IsBoolean()
actived?: boolean;
} }
/** /**
@@ -0,0 +1,45 @@
import {
Column,
CreateDateColumn,
Entity,
PrimaryGeneratedColumn,
UpdateDateColumn,
} from 'typeorm';
import { CaptchaActionType, CaptchaType } from '@/modules/user/constants';
@Entity('user_captcha')
export class CaptchaEntity {
@PrimaryGeneratedColumn('uuid')
id!: string;
@Column({ comment: '验证码' })
code!: string;
@Column({
type: 'enum',
enum: CaptchaActionType,
comment: '验证操作类型',
})
action!: CaptchaActionType;
@Column({
type: 'enum',
enum: CaptchaType,
comment: '验证码类型',
})
type!: CaptchaType;
@Column({ comment: '手机号/邮箱地址' })
value!: string;
@CreateDateColumn({
comment: '创建时间',
})
createdAt!: Date;
@UpdateDateColumn({
comment: '更新时间',
})
updatedAt!: Date;
}
+3
View File
@@ -63,6 +63,9 @@ export class UserEntity {
@Column({ comment: '用户邮箱', length: 256, nullable: true, unique: true }) @Column({ comment: '用户邮箱', length: 256, nullable: true, unique: true })
email?: string; email?: string;
@Column({ comment: '用户状态,是否激活', default: false })
actived?: boolean;
/** /**
* 用户创建时间 * 用户创建时间
*/ */
+79 -1
View File
@@ -1,5 +1,8 @@
import { CaptchaActionType, CaptchaType } from '@/modules/user/constants';
import { CaptchaEntity } from '@/modules/user/entities/captcha.entity';
/** /**
* 用户配置类型 * 自定义用户模块配置
*/ */
export interface UserConfig { export interface UserConfig {
/** /**
@@ -10,6 +13,8 @@ export interface UserConfig {
* jwt token的生成配置 * jwt token的生成配置
*/ */
jwt: JwtConfig; jwt: JwtConfig;
captcha?: CustomCaptchaConfig;
} }
/** /**
@@ -39,3 +44,76 @@ export interface JwtPayload {
*/ */
iat: number; iat: number;
} }
/**
* 默认用户模块配置
*/
export interface DefaultUserConfig {
hash: number;
jwt: Pick<Required<JwtConfig>, 'tokenExpired' | 'refreshTokenExpired'>;
captcha: DefaultCaptchaConfig;
}
/**
* 自定义验证码配置
*/
export interface CustomCaptchaConfig {
[CaptchaType.SMS]?: {
[key in CaptchaActionType]?: Partial<SmsCaptchaOption>;
};
[CaptchaType.EMAIL]?: {
[key in CaptchaActionType]?: Partial<EmailCaptchaOption>;
};
}
/**
* 默认验证码配置
*/
export interface DefaultCaptchaConfig {
[CaptchaType.SMS]: {
[key in CaptchaActionType]: CaptchaOption;
};
[CaptchaType.EMAIL]: {
[key in CaptchaActionType]: Omit<EmailCaptchaOption, 'template'>;
};
}
/**
* 通用验证码选项
*/
export interface CaptchaOption {
limit: number; // 验证码发送间隔时间
expired: number; // 验证码有效时间
}
/**
* 手机验证码选项
*/
export interface SmsCaptchaOption extends CaptchaOption {
template: string; // 云厂商短信推送模板ID
}
/**
* 邮件验证码选项
*/
export interface EmailCaptchaOption extends CaptchaOption {
subject: string; // 邮件主题
template?: string; // 模板路径
}
/**
* 任务传给消费者的数据类型
*/
export interface SendCaptchaQueueJob {
captcha: { [key in keyof CaptchaEntity]: CaptchaEntity[key] };
option: SmsCaptchaOption | EmailCaptchaOption;
otherVars?: RecordAny;
}
/**
* 验证码正确性验证
*/
export type CaptchaValidate<T extends RecordAny = RecordNever> = T & {
value: string;
code: string;
};
+7
View File
@@ -21,3 +21,10 @@ export async function encrypt(configure: Configure, password: string) {
export function decrypt(password: string, hashed: string) { export function decrypt(password: string, hashed: string) {
return bcrypt.compareSync(password, hashed); return bcrypt.compareSync(password, hashed);
} }
/**
* 生成随机验证码
*/
export function generateCaptchaCode() {
return Math.random().toFixed(6).slice(-6);
}
+60 -5
View File
@@ -5,7 +5,7 @@ import { NestFactory } from '@nestjs/core';
import { FastifyAdapter, NestFastifyApplication } from '@nestjs/platform-fastify'; import { FastifyAdapter, NestFastifyApplication } from '@nestjs/platform-fastify';
import { existsSync } from 'fs-extra'; import { existsSync } from 'fs-extra';
import { isNil } from 'lodash'; import { isArray, isNil, omit } from 'lodash';
import { RbacModule } from '@/modules/rbac/rbac.module'; import { RbacModule } from '@/modules/rbac/rbac.module';
import { UserModule } from '@/modules/user/user.module'; import { UserModule } from '@/modules/user/user.module';
@@ -13,7 +13,13 @@ import { UserModule } from '@/modules/user/user.module';
import * as configs from './config'; import * as configs from './config';
import { ContentModule } from './modules/content/content.module'; import { ContentModule } from './modules/content/content.module';
import { GlobalExceptionFilter } from './modules/core/filters/global-exception.filter'; import { GlobalExceptionFilter } from './modules/core/filters/global-exception.filter';
import { CreateOptions } from './modules/core/types'; import {
BullOptions,
CreateOptions,
QueueOptions,
RedisOption,
RedisOptions,
} from './modules/core/types';
import * as dbCommands from './modules/database/commands'; import * as dbCommands from './modules/database/commands';
import { DatabaseModule } from './modules/database/database.module'; import { DatabaseModule } from './modules/database/database.module';
import { MeiliModule } from './modules/meilisearch/meili.module'; import { MeiliModule } from './modules/meilisearch/meili.module';
@@ -52,12 +58,61 @@ export const createOptions: CreateOptions = {
if (existsSync(join(__dirname, 'metadata.js'))) { if (existsSync(join(__dirname, 'metadata.js'))) {
metadata = (await import(join(__dirname, 'metadata.js'))).default; 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); await restful.factoryDocs(container, metadata);
} }
return container; return container;
}, },
}; };
/**
* 生成Redis配置
* @param options
*/
export const createRedisOptions = (options: RedisOptions): RedisOption[] | undefined => {
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')),
};
};
@@ -324,7 +324,6 @@ describe('AccountController (App)', () => {
}, },
}); });
const { token } = loginResult.json(); const { token } = loginResult.json();
console.log(token);
const result = await app.inject({ const result = await app.inject({
method: 'POST', method: 'POST',
url: `${URL_PREFIX}/account/logout`, url: `${URL_PREFIX}/account/logout`,
@@ -449,7 +448,6 @@ describe('AccountController (App)', () => {
username: `updated-account-${randomTag}`, username: `updated-account-${randomTag}`,
}, },
}); });
console.log(result.json());
expect(result.statusCode).toBe(200); expect(result.statusCode).toBe(200);
const updatedUser = result.json(); const updatedUser = result.json();
expect(updatedUser.username).toBe(`updated-account-${randomTag}`); expect(updatedUser.username).toBe(`updated-account-${randomTag}`);