Compare commits

...

2 Commits

Author SHA1 Message Date
040b0b71d5 add user module 2025-06-22 13:10:49 +08:00
92cfd94093 add user module 2025-06-22 10:30:50 +08:00
10 changed files with 319 additions and 2 deletions

View File

@ -21,7 +21,7 @@ export class MatchConstraint implements ValidatorConstraintInterface {
}
}
export function isMatch(
export function IsMatch(
relatedProperty: string,
reverse = false,
validationOptions?: ValidationOptions,

View File

@ -0,0 +1,33 @@
/**
* DTO验证组
*/
export enum UserValidateGroup {
/**
*
*/
USER_CREATE = 'user_create',
/**
*
*/
USER_UPDATE = 'user_update',
/**
*
*/
USER_REGISTER = 'user_register',
/**
*
*/
ACCOUNT_UPDATE = 'account_update',
/**
*
*/
CHANGE_PASSWORD = 'change_password',
}
/**
*
*/
export enum UserOrderType {
CREATED = 'createdAt',
UPDATED = 'updatedAt',
}

View File

@ -0,0 +1,40 @@
import { PickType } from '@nestjs/swagger';
import { Length } from 'class-validator';
import { IsPassword } from '@/modules/core/constraints/password.constraint';
import { DtoValidation } from '@/modules/core/decorator/dto.validation.decorator';
import { UserCommonDto } from '@/modules/user/dtos/user.common.dto';
/**
*
*/
@DtoValidation({ whitelist: false, 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;
}
/**
*
*/
@DtoValidation({ groups: [UserValidateGroup.CHANGE_PASSWORD] })
export class UpdatePasswordDto extends PickType(UserCommonDto, ['password', 'plainPassword']) {
/**
* ID
*/
@IsUUID(undefined, { message: '用户ID格式不正确', groups: [UserValidateGroup.USER_UPDATE] })
@IsDefined({ groups: ['update'], message: '用户ID必须指定' })
id: string;
/**
* 旧密码:用户在更改密码时需要输入的原密码
*/
@IsPassword(5, { message: '密码必须由小写字母,大写字母,数字以及特殊字符组成', always: true })
@Length(8, 50, { message: '密码长度不得少于$constraint1', always: true })
oldPassword: string;
}

View File

@ -0,0 +1,21 @@
import { PickType } from '@nestjs/swagger';
import { DtoValidation } from '@/modules/core/decorator/dto.validation.decorator';
import { UserValidateGroup } from '@/modules/user/constants';
import { UserCommonDto } from '@/modules/user/dtos/user.common.dto';
/**
*
*/
export class CredentialDto extends PickType(UserCommonDto, ['credential', 'password']) {}
/**
*
*/
@DtoValidation({ groups: [UserValidateGroup.USER_REGISTER] })
export class RegisterDto extends PickType(UserCommonDto, [
'username',
'nickname',
'password',
'plainPassword',
] as const) {}

View File

@ -0,0 +1,114 @@
import { Injectable } from '@nestjs/common';
import { IsEmail, IsNotEmpty, IsOptional, Length } from 'class-validator';
import { IsMatch } from '@/modules/core/constraints/match.constraint';
import { IsPassword } from '@/modules/core/constraints/password.constraint';
import { IsMatchPhone } from '@/modules/core/constraints/phone.number.constraint';
import { IsUnique, IsUniqueExist } from '@/modules/database/constraints';
import { UserValidateGroup } from '@/modules/user/constants';
import { UserEntity } from '@/modules/user/entities/UserEntity';
/**
* DTO的通用基础字段
*/
@Injectable()
export class UserCommonDto {
/**
* 登录凭证:可以是用户名,,
*/
@Length(4, 30, { message: '登录凭证长度必须为$constraint1到$constraint2', always: true })
@IsNotEmpty({ message: '登录凭证不得为空', always: true })
credential: string;
/**
*
*/
@IsUnique(
{ entity: UserEntity },
{
groups: [UserValidateGroup.USER_CREATE, UserValidateGroup.USER_REGISTER],
message: '该用户名已被注册',
},
)
@IsUniqueExist(
{ entity: UserEntity, ignore: 'id' },
{ groups: [UserValidateGroup.USER_UPDATE], message: '该用户名已被注册' },
)
@IsUniqueExist(
{ entity: UserEntity, ignore: 'id', ignoreKey: 'userId' },
{ groups: [UserValidateGroup.ACCOUNT_UPDATE], message: '该用户名已被注册' },
)
@Length(4, 50, { always: true, message: '用户名长度必须为$constraint1到$constraint2' })
@IsOptional({ groups: [UserValidateGroup.USER_UPDATE, UserValidateGroup.ACCOUNT_UPDATE] })
username: string;
/**
* 昵称:不设置则为用户名
*/
@Length(3, 20, { message: '昵称必须为$constraint1到$constraint2', always: true })
@IsOptional({ always: true })
nickname: string;
/**
* 手机号:必须是区域开头的,+86.15005255555
*/
@IsUnique(
{ entity: UserEntity },
{
message: '手机号已被注册',
groups: [UserValidateGroup.USER_CREATE, UserValidateGroup.USER_REGISTER],
},
)
@IsMatchPhone(
undefined,
{ strictMode: trus },
{ message: '手机格式错误,示例: +86.15005255555', always: true },
)
@IsOptional({
groups: [
UserValidateGroup.USER_UPDATE,
UserValidateGroup.USER_CREATE,
UserValidateGroup.USER_REGISTER,
],
})
phone: string;
/**
* 邮箱地址:必须符合邮箱地址规则
*/
@IsUnique(
{ entity: UserEntity },
{
message: '邮箱已被注册',
groups: [UserValidateGroup.USER_CREATE, UserValidateGroup.USER_REGISTER],
},
)
@IsEmail(undefined, { message: '邮箱地址格式错误', always: true })
@IsOptional({
groups: [
UserValidateGroup.USER_UPDATE,
UserValidateGroup.USER_CREATE,
UserValidateGroup.USER_REGISTER,
],
})
email: string;
/**
* 用户密码:密码必须由小写字母,,
*/
@IsPassword(5, { message: '密码必须由小写字母,大写字母,数字以及特殊字符组成', always: true })
@Length(8, 50, { message: '密码长度不得少于$constraint1', always: true })
@IsMatch('oldPassword', true, {
message: '新密码与旧密码不得相同',
groups: [UserValidateGroup.CHANGE_PASSWORD],
})
@IsOptional({ groups: [UserValidateGroup.USER_UPDATE] })
password: string;
/**
* 确认密码:必须与用户密码输入相同的字符串
*/
@IsMatch('password', false, { message: '两次输入密码不同', always: true })
@IsNotEmpty({ message: '请再次输入密码以确认', always: true })
plainPassword: string;
}

View File

@ -0,0 +1,44 @@
import { PartialType, PickType } from '@nestjs/swagger';
import { IsDefined, IsEnum, IsUUID } from 'class-validator';
import { DtoValidation } from '@/modules/core/decorator/dto.validation.decorator';
import { PaginateWithTrashedDto } from '@/modules/restful/dtos/paginate-width-trashed.dto';
import { UserOrderType } from '@/modules/user/constants';
import { UserCommonDto } from '@/modules/user/dtos/user.common.dto';
/**
*
*/
@DtoValidation({ groups: [UserValidateGroup.USER_CREATE] })
export class CreateUserDto extends PickType(UserCommonDto, [
'username',
'nickname',
'email',
'password',
'phone',
]) {}
/**
*
*/
@DtoValidation({ groups: [UserValidateGroup.USER_UPDATE] })
export class UpdateUserDto extends PartialType(CreateUserDto) {
/**
* ID
*/
@IsUUID(undefined, { message: '用户ID格式不正确', groups: [UserValidateGroup.USER_UPDATE] })
@IsDefined({ groups: ['update'], message: '用户ID必须指定' })
id: string;
}
/**
* Query数据验证
*/
export class QueryUserDto extends PaginateWithTrashedDto {
/**
* 排序规则:可指定用户列表的排序规则,
*/
@IsEnum(UserOrderType)
orderBy?: UserOrderType;
}

View File

@ -11,6 +11,7 @@ import {
} from 'typeorm';
import { CommentEntity, PostEntity } from '@/modules/content/entities';
import { AccessTokenEntity } from '@/modules/user/entities/access.token.entity';
/**
*
@ -94,4 +95,10 @@ export class UserEntity {
*/
@OneToMany(() => CommentEntity, (comment) => comment.author, { cascade: true })
comments: Relation<CommentEntity>[];
/**
* token
*/
@OneToMany(() => AccessTokenEntity, (token) => token.user, { cascade: true })
accessTokens: Relation<AccessTokenEntity>[];
}

View File

@ -1,5 +1,6 @@
import { Entity, OneToOne } from 'typeorm';
import { Entity, ManyToOne, OneToOne, Relation } from 'typeorm';
import { UserEntity } from '@/modules/user/entities/UserEntity';
import { BaseToken } from '@/modules/user/entities/base.token';
import { RefreshTokenEntity } from '@/modules/user/entities/refresh.token.entity';
@ -13,4 +14,10 @@ export class AccessTokenEntity extends BaseToken {
*/
@OneToOne(() => RefreshTokenEntity, (token) => token.accessToken, { cascade: true })
refreshToken: string;
/**
*
*/
@ManyToOne(() => UserEntity, (user) => user.accessTokens, { onDelete: 'CASCADE' })
user: Relation<UserEntity>;
}

View File

@ -0,0 +1,12 @@
import { BaseRepository } from '@/modules/database/base/repository';
import { CustomRepository } from '@/modules/database/decorators/repository.decorator';
import { UserEntity } from '@/modules/user/entities/UserEntity';
@CustomRepository(UserEntity)
export class UserRepository extends BaseRepository<UserEntity> {
protected _qbName: string = 'user';
buildBaseQuery() {
return this.createQueryBuilder(this.qbName).orderBy(`${this.qbName}.createdAt`, 'DESC');
}
}

View File

@ -0,0 +1,39 @@
import { randomBytes } from 'node:crypto';
import { EventSubscriber, InsertEvent, UpdateEvent } from 'typeorm';
import { BaseSubscriber } from '@/modules/database/base/subscriber';
import { UserEntity } from '@/modules/user/entities/UserEntity';
import { encrypt } from '@/modules/user/utils';
@EventSubscriber()
export class UserSubscriber extends BaseSubscriber<UserEntity> {
protected entity = UserEntity;
/**
*
* @param event
* @protected
*/
protected async generateUserName(event: InsertEvent<UserEntity>): Promise<string> {
const username = `user_${randomBytes(4).toString('hex').slice(0, 8)}`;
const user = await event.manager.findOne(UserEntity, { where: { username } });
return user ? this.generateUserName(event) : username;
}
async beforeInsert(event: InsertEvent<UserEntity>): Promise<void> {
if (!event.entity.username) {
event.entity.username = await this.generateUserName(event);
}
if (!event.entity.password) {
event.entity.password = randomBytes(11).toString('hex').slice(0, 22);
}
event.entity.password = await encrypt(this.configure, event.entity.password);
}
async beforeUpdate(event: UpdateEvent<UserEntity>) {
if (this.isUpdated('password', event)) {
event.entity.password = await encrypt(this.configure, event.entity.password);
}
}
}