Compare commits

..

4 Commits

Author SHA1 Message Date
fac8be4dd0 add mq and redis 2025-08-01 23:08:40 +08:00
de01ffd7c7 add mq and redis 2025-08-01 10:50:03 +08:00
91b5cce39b add mq and redis 2025-08-01 10:49:50 +08:00
ef6380395c add mq and redis 2025-08-01 10:49:23 +08:00
17 changed files with 415 additions and 23 deletions

View File

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

View File

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

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

View File

@ -4,7 +4,13 @@ import { DynamicModule, Module, ModuleMetadata } from '@nestjs/common';
import { isArray, isNil, omit } from 'lodash'; import { isArray, isNil, omit } from 'lodash';
import { RedisService, SmsService, SmtpService } from '@/modules/core/services'; import { RedisService, SmsService, SmtpService } from '@/modules/core/services';
import { QueueOptions, RedisOptions, SmsOptions, SmtpOptions } from '@/modules/core/types'; import type {
QueueOptions,
RedisOption,
RedisOptions,
SmsOptions,
SmtpOptions,
} from '@/modules/core/types';
import { createQueueOptions, createRedisOptions } from '@/options'; import { createQueueOptions, createRedisOptions } from '@/options';
@ -17,7 +23,9 @@ export class CoreModule {
const providers: ModuleMetadata['providers'] = []; const providers: ModuleMetadata['providers'] = [];
const exports: ModuleMetadata['exports'] = []; const exports: ModuleMetadata['exports'] = [];
let imports: ModuleMetadata['imports'] = []; let imports: ModuleMetadata['imports'] = [];
const redis = createRedisOptions(await configure.get<RedisOptions>('redis')); const redis: RedisOption[] | undefined = createRedisOptions(
await configure.get<RedisOptions>('redis'),
);
if (!isNil(redis)) { if (!isNil(redis)) {
providers.push({ providers.push({
provide: RedisService, provide: RedisService,

View File

@ -151,7 +151,7 @@ export interface DynamicRelation {
/** /**
* *
*/ */
export type NestedRecord = Record<string, Record<string, any>>; export type NestedRecord = Record<string, RecordAny>;
/** /**
* core模块参数选项 * core模块参数选项
@ -159,6 +159,9 @@ export type NestedRecord = Record<string, Record<string, any>>;
export interface CoreOptions { export interface CoreOptions {
database?: () => TypeOrmModuleOptions; database?: () => TypeOrmModuleOptions;
sms?: () => SmsOptions; sms?: () => SmsOptions;
smtp?: () => SmtpOptions;
redis?: () => RedisOptions;
queue?: () => QueueOptions;
} }
/** /**
* *

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': {},
},
},
}; };
} }

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

View File

@ -1,12 +1,12 @@
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';
/** /**
* *
@ -19,13 +19,6 @@ export class UpdateAccountDto extends PickType(UserCommonDto, ['username', 'nick
*/ */
@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;
/** /**
* 旧密码:用户在更改密码时需要输入的原密码 * 旧密码:用户在更改密码时需要输入的原密码
*/ */
@ -33,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) {}

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

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

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

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;
} }
/** /**

View File

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

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;
/** /**
* *
*/ */

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

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

View File

@ -69,7 +69,7 @@ export const createOptions: CreateOptions = {
* Redis配置 * Redis配置
* @param options * @param options
*/ */
export const createRedisOptions = (options: RedisOptions) => { export const createRedisOptions = (options: RedisOptions): RedisOption[] | undefined => {
if (isNil(options)) { if (isNil(options)) {
return undefined; return undefined;
} }