358 lines
11 KiB
TypeScript
358 lines
11 KiB
TypeScript
import {
|
||
Injectable,
|
||
NotFoundException,
|
||
ConflictException,
|
||
BadRequestException,
|
||
Logger,
|
||
} from '@nestjs/common';
|
||
import { InjectRepository } from '@nestjs/typeorm';
|
||
import { Repository, FindOptionsWhere } from 'typeorm';
|
||
import * as bcrypt from 'bcrypt';
|
||
import { User } from './user.entity';
|
||
import { CreateUserDto } from './dto/create-user.dto';
|
||
import { UpdateUserDto } from './dto/update-user.dto';
|
||
import { QueryUserDto } from './dto/query-user.dto';
|
||
import { ChangePasswordDto } from './dto/change-password.dto';
|
||
import { PaginationInfo } from '@/common/dto/pagination.dto';
|
||
import { StorageService } from '../storage/storage.service';
|
||
|
||
@Injectable()
|
||
export class UserService {
|
||
private readonly logger = new Logger(UserService.name);
|
||
|
||
constructor(
|
||
@InjectRepository(User)
|
||
private readonly userRepository: Repository<User>,
|
||
private readonly storageService: StorageService,
|
||
) {}
|
||
|
||
/**
|
||
* 注册用户
|
||
*/
|
||
async create(createUserDto: CreateUserDto): Promise<User> {
|
||
// 检查用户名是否已存在
|
||
const existingByUsername = await this.userRepository.findOne({
|
||
where: { username: createUserDto.username },
|
||
});
|
||
|
||
if (existingByUsername) {
|
||
throw new ConflictException(
|
||
`用户名 "${createUserDto.username}" 已存在`,
|
||
);
|
||
}
|
||
|
||
// 检查邮箱是否已存在
|
||
const existingByEmail = await this.userRepository.findOne({
|
||
where: { email: createUserDto.email },
|
||
});
|
||
|
||
if (existingByEmail) {
|
||
throw new ConflictException(`邮箱 "${createUserDto.email}" 已存在`);
|
||
}
|
||
|
||
// 检查 phone 是否已存在(如果提供了)
|
||
/* if (createUserDto.phone) {
|
||
const existingByPhone = await this.userRepository.findOne({
|
||
where: { phone: createUserDto.phone },
|
||
});
|
||
|
||
if (existingByPhone) {
|
||
throw new ConflictException(
|
||
`Phone "${createUserDto.phone}" already exists`,
|
||
);
|
||
}
|
||
}
|
||
|
||
// 检查 openId 是否已存在(如果提供了)
|
||
if (createUserDto.openId) {
|
||
const existingByOpenId = await this.userRepository.findOne({
|
||
where: { openId: createUserDto.openId },
|
||
});
|
||
|
||
if (existingByOpenId) {
|
||
throw new ConflictException(
|
||
`OpenId "${createUserDto.openId}" already exists`,
|
||
);
|
||
}
|
||
}
|
||
|
||
// 检查 unionId 是否已存在(如果提供了)
|
||
if (createUserDto.unionId) {
|
||
const existingByUnionId = await this.userRepository.findOne({
|
||
where: { unionId: createUserDto.unionId },
|
||
});
|
||
|
||
if (existingByUnionId) {
|
||
throw new ConflictException(
|
||
`UnionId "${createUserDto.unionId}" already exists`,
|
||
);
|
||
}
|
||
} */
|
||
|
||
// 使用 bcrypt 加密密码
|
||
const saltRounds = 10;
|
||
const passwordHash = await bcrypt.hash(
|
||
createUserDto.password,
|
||
saltRounds,
|
||
);
|
||
|
||
// 创建用户
|
||
// 注意:所有注册用户的 role 固定为 'user',不允许通过注册接口设置其他角色
|
||
const user = this.userRepository.create({
|
||
username: createUserDto.username,
|
||
passwordHash,
|
||
email: createUserDto.email,
|
||
nickname: createUserDto.nickname,
|
||
avatarUrl: createUserDto.avatarUrl,
|
||
phone: createUserDto.phone,
|
||
openId: createUserDto.openId,
|
||
unionId: createUserDto.unionId,
|
||
status: 'active',
|
||
role: 'user', // 固定为普通用户,不允许通过注册接口修改
|
||
});
|
||
|
||
return this.userRepository.save(user);
|
||
}
|
||
|
||
/**
|
||
* 查询所有用户
|
||
*/
|
||
async findAll(): Promise<User[]> {
|
||
return this.userRepository.find({
|
||
order: {
|
||
createdAt: 'DESC',
|
||
},
|
||
});
|
||
}
|
||
|
||
/**
|
||
* 查询用户(支持多种查询条件和分页)
|
||
*/
|
||
async findAllPaginated(queryDto: QueryUserDto): Promise<{
|
||
list: User[];
|
||
pagination: PaginationInfo;
|
||
}> {
|
||
const where: FindOptionsWhere<User> = {};
|
||
|
||
if (queryDto.username) {
|
||
where.username = queryDto.username;
|
||
}
|
||
|
||
if (queryDto.nickname) {
|
||
where.nickname = queryDto.nickname;
|
||
}
|
||
|
||
if (queryDto.email) {
|
||
where.email = queryDto.email;
|
||
}
|
||
|
||
if (queryDto.phone) {
|
||
where.phone = queryDto.phone;
|
||
}
|
||
|
||
if (queryDto.role) {
|
||
where.role = queryDto.role;
|
||
}
|
||
|
||
if (queryDto.status) {
|
||
where.status = queryDto.status;
|
||
}
|
||
|
||
// 分页参数
|
||
const page = queryDto.page || 1;
|
||
const limit = queryDto.limit || 10;
|
||
const skip = (page - 1) * limit;
|
||
|
||
// 排序字段映射
|
||
const sortBy = queryDto.sortBy || 'createdAt';
|
||
const sortOrder = queryDto.sortOrder || 'DESC';
|
||
|
||
// 构建排序对象
|
||
const order: Record<string, 'ASC' | 'DESC'> = {};
|
||
if (sortBy === 'createdAt') {
|
||
order.createdAt = sortOrder;
|
||
} else if (sortBy === 'updatedAt') {
|
||
order.updatedAt = sortOrder;
|
||
} else if (sortBy === 'lastLoginAt') {
|
||
order.lastLoginAt = sortOrder;
|
||
} else {
|
||
order.createdAt = 'DESC';
|
||
}
|
||
|
||
// 添加默认排序
|
||
order.userId = 'ASC';
|
||
|
||
// 查询总数
|
||
const total = await this.userRepository.count({ where });
|
||
|
||
// 查询分页数据
|
||
const list = await this.userRepository.find({
|
||
where,
|
||
order,
|
||
skip,
|
||
take: limit,
|
||
});
|
||
|
||
// 移除 passwordHash 字段
|
||
const listWithoutPassword = list.map((user) => {
|
||
// eslint-disable-next-line @typescript-eslint/no-unused-vars
|
||
const { passwordHash, ...userWithoutPassword } = user;
|
||
return userWithoutPassword as User;
|
||
});
|
||
|
||
// 计算总页数
|
||
const total_page = Math.ceil(total / limit);
|
||
|
||
return {
|
||
list: listWithoutPassword,
|
||
pagination: {
|
||
total,
|
||
total_page,
|
||
page_size: limit,
|
||
current_page: page,
|
||
},
|
||
};
|
||
}
|
||
|
||
/**
|
||
* 根据 username 或 email 查询单个用户
|
||
*/
|
||
async findOne(queryDto: QueryUserDto): Promise<User> {
|
||
if (!queryDto.username && !queryDto.email) {
|
||
throw new BadRequestException('必须提供用户名或邮箱');
|
||
}
|
||
|
||
const where: { username?: string; email?: string } = {};
|
||
if (queryDto.username) {
|
||
where.username = queryDto.username;
|
||
}
|
||
if (queryDto.email) {
|
||
where.email = queryDto.email;
|
||
}
|
||
|
||
const user = await this.userRepository.findOne({ where });
|
||
|
||
if (!user) {
|
||
const identifier = queryDto.username || queryDto.email;
|
||
throw new NotFoundException(
|
||
`未找到${queryDto.username ? '用户名' : '邮箱'}为 "${identifier}" 的用户`,
|
||
);
|
||
}
|
||
|
||
return user;
|
||
}
|
||
|
||
/**
|
||
* 根据 ID 查询单个用户
|
||
*/
|
||
async findOneById(id: number): Promise<User> {
|
||
const user = await this.userRepository.findOne({
|
||
where: { userId: id },
|
||
});
|
||
|
||
if (!user) {
|
||
throw new NotFoundException(`未找到ID为 ${id} 的用户`);
|
||
}
|
||
|
||
// 移除 passwordHash 字段
|
||
// eslint-disable-next-line @typescript-eslint/no-unused-vars
|
||
const { passwordHash, ...userWithoutPassword } = user;
|
||
return userWithoutPassword as User;
|
||
}
|
||
|
||
/**
|
||
* 更新用户信息(不允许修改 username、openId、unionId)
|
||
*/
|
||
async update(id: number, updateUserDto: UpdateUserDto): Promise<User> {
|
||
const user = await this.findOneById(id);
|
||
|
||
// 如果更新邮箱,检查是否与其他用户冲突
|
||
if (updateUserDto.email && updateUserDto.email !== user.email) {
|
||
const existingByEmail = await this.userRepository.findOne({
|
||
where: { email: updateUserDto.email },
|
||
});
|
||
|
||
if (existingByEmail) {
|
||
throw new ConflictException(
|
||
`邮箱 "${updateUserDto.email}" 已存在`,
|
||
);
|
||
}
|
||
}
|
||
|
||
// 如果更新 phone,检查是否与其他用户冲突
|
||
if (updateUserDto.phone && updateUserDto.phone !== user.phone) {
|
||
const existingByPhone = await this.userRepository.findOne({
|
||
where: { phone: updateUserDto.phone },
|
||
});
|
||
|
||
if (existingByPhone) {
|
||
throw new ConflictException(
|
||
`手机号 "${updateUserDto.phone}" 已存在`,
|
||
);
|
||
}
|
||
}
|
||
|
||
// 更新用户信息
|
||
Object.assign(user, updateUserDto);
|
||
return this.userRepository.save(user);
|
||
}
|
||
|
||
/**
|
||
* 修改密码(需要先验证旧密码)
|
||
*/
|
||
async changePassword(
|
||
id: number,
|
||
changePasswordDto: ChangePasswordDto,
|
||
): Promise<void> {
|
||
const user = await this.findOneById(id);
|
||
|
||
// 验证旧密码
|
||
const isOldPasswordValid = await bcrypt.compare(
|
||
changePasswordDto.oldPassword,
|
||
user.passwordHash,
|
||
);
|
||
|
||
if (!isOldPasswordValid) {
|
||
throw new BadRequestException('旧密码错误');
|
||
}
|
||
|
||
// 加密新密码
|
||
const saltRounds = 10;
|
||
const newPasswordHash = await bcrypt.hash(
|
||
changePasswordDto.newPassword,
|
||
saltRounds,
|
||
);
|
||
|
||
// 更新密码
|
||
user.passwordHash = newPasswordHash;
|
||
await this.userRepository.save(user);
|
||
}
|
||
|
||
/**
|
||
* 删除用户(软删除,更新状态为 deleted)
|
||
* 同时删除用户头像图片
|
||
*/
|
||
async remove(id: number): Promise<void> {
|
||
const user = await this.findOneById(id);
|
||
|
||
// 删除用户头像图片
|
||
if (user.avatarUrl) {
|
||
try {
|
||
const imagePath = this.storageService.extractStoragePath(
|
||
user.avatarUrl,
|
||
);
|
||
if (imagePath) {
|
||
await this.storageService.delete(imagePath);
|
||
this.logger.log(`已删除用户头像: ${imagePath}`);
|
||
}
|
||
} catch (error) {
|
||
// 图片删除失败不影响用户删除操作
|
||
this.logger.warn(`删除用户头像失败: ${user.avatarUrl}`, error);
|
||
}
|
||
}
|
||
|
||
user.status = 'deleted';
|
||
await this.userRepository.save(user);
|
||
}
|
||
}
|