feat: 增强安全性和用户隐私保护
主要改进: 1. JWT 密钥验证增强 - 添加启动时密钥验证(长度、格式、弱密钥检测) - 确保密钥符合安全要求(至少32字符) 2. 资产加密算法升级 - 从 AES-256-CBC 升级到 AES-256-GCM - 提供数据完整性校验(认证加密) - 添加加密密钥启动时验证 3. 用户隐私保护 - 新增 findOnePublic 方法返回公开信息 - GET /users/:id 根据查询对象返回不同信息级别 - 查询自己返回完整信息,查询他人只返回公开信息 4. 缓存一致性修复 - 修复更新用户信息时缓存未正确清除的问题 - 确保完整信息和公开信息缓存同时失效 5. API 文档改进 - 为用户查询接口添加详细的隐私保护说明 Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
This commit is contained in:
@@ -1,8 +1,44 @@
|
|||||||
import { registerAs } from '@nestjs/config';
|
import { registerAs } from "@nestjs/config";
|
||||||
|
|
||||||
export default registerAs('jwt', () => ({
|
const validateJwtSecret = (
|
||||||
secret: process.env.JWT_SECRET || 'default-secret',
|
secret: string | undefined,
|
||||||
expiresIn: process.env.JWT_EXPIRES_IN || '7d',
|
secretName: string,
|
||||||
refreshSecret: process.env.JWT_REFRESH_SECRET || 'default-refresh-secret',
|
): secret is string => {
|
||||||
refreshExpiresIn: process.env.JWT_REFRESH_EXPIRES_IN || '30d',
|
if (!secret) {
|
||||||
|
throw new Error(
|
||||||
|
`环境变量 ${secretName} 未设置。请在 .env 文件中配置强随机密钥(至少32字符)。`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if (secret.length < 32) {
|
||||||
|
throw new Error(
|
||||||
|
`环境变量 ${secretName} 长度不足。当前长度: ${secret.length}, 要求至少32字符。请使用强随机密钥。`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
// 检查是否使用了默认密钥(明显的弱密钥)
|
||||||
|
const weakSecrets = [
|
||||||
|
"default-secret",
|
||||||
|
"default-refresh-secret",
|
||||||
|
"secret",
|
||||||
|
"jwt-secret",
|
||||||
|
];
|
||||||
|
if (weakSecrets.includes(secret.toLowerCase())) {
|
||||||
|
throw new Error(
|
||||||
|
`检测到 ${secretName} 使用了弱密钥: "${secret}"。请立即更换为强随机密钥(至少32字符)。`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return true;
|
||||||
|
};
|
||||||
|
|
||||||
|
// 在加载配置前进行验证
|
||||||
|
const jwtSecret = process.env.JWT_SECRET;
|
||||||
|
const jwtRefreshSecret = process.env.JWT_REFRESH_SECRET;
|
||||||
|
|
||||||
|
validateJwtSecret(jwtSecret, "JWT_SECRET");
|
||||||
|
validateJwtSecret(jwtRefreshSecret, "JWT_REFRESH_SECRET");
|
||||||
|
|
||||||
|
export default registerAs("jwt", () => ({
|
||||||
|
secret: jwtSecret!,
|
||||||
|
expiresIn: process.env.JWT_EXPIRES_IN || "7d",
|
||||||
|
refreshSecret: jwtRefreshSecret!,
|
||||||
|
refreshExpiresIn: process.env.JWT_REFRESH_EXPIRES_IN || "30d",
|
||||||
}));
|
}));
|
||||||
|
|||||||
@@ -3,21 +3,32 @@ import {
|
|||||||
NotFoundException,
|
NotFoundException,
|
||||||
ForbiddenException,
|
ForbiddenException,
|
||||||
BadRequestException,
|
BadRequestException,
|
||||||
} from '@nestjs/common';
|
} from "@nestjs/common";
|
||||||
import { InjectRepository } from '@nestjs/typeorm';
|
import { InjectRepository } from "@nestjs/typeorm";
|
||||||
import { Repository, DataSource } from 'typeorm';
|
import { Repository, DataSource } from "typeorm";
|
||||||
import { Asset } from '../../entities/asset.entity';
|
import { Asset } from "../../entities/asset.entity";
|
||||||
import { AssetLog } from '../../entities/asset-log.entity';
|
import { AssetLog } from "../../entities/asset-log.entity";
|
||||||
import { Group } from '../../entities/group.entity';
|
import { Group } from "../../entities/group.entity";
|
||||||
import { GroupMember } from '../../entities/group-member.entity';
|
import { GroupMember } from "../../entities/group-member.entity";
|
||||||
import { CreateAssetDto, UpdateAssetDto, BorrowAssetDto } from './dto/asset.dto';
|
import {
|
||||||
import { AssetStatus, AssetLogAction, GroupMemberRole } from '../../common/enums';
|
CreateAssetDto,
|
||||||
import { ErrorCode, ErrorMessage } from '../../common/interfaces/response.interface';
|
UpdateAssetDto,
|
||||||
import * as crypto from 'crypto';
|
BorrowAssetDto,
|
||||||
|
} from "./dto/asset.dto";
|
||||||
|
import {
|
||||||
|
AssetStatus,
|
||||||
|
AssetLogAction,
|
||||||
|
GroupMemberRole,
|
||||||
|
} from "../../common/enums";
|
||||||
|
import {
|
||||||
|
ErrorCode,
|
||||||
|
ErrorMessage,
|
||||||
|
} from "../../common/interfaces/response.interface";
|
||||||
|
import * as crypto from "crypto";
|
||||||
|
|
||||||
@Injectable()
|
@Injectable()
|
||||||
export class AssetsService {
|
export class AssetsService {
|
||||||
private readonly ENCRYPTION_KEY = process.env.ASSET_ENCRYPTION_KEY || 'default-key-change-in-production';
|
private readonly ENCRYPTION_KEY: Buffer;
|
||||||
|
|
||||||
constructor(
|
constructor(
|
||||||
@InjectRepository(Asset)
|
@InjectRepository(Asset)
|
||||||
@@ -29,31 +40,91 @@ export class AssetsService {
|
|||||||
@InjectRepository(GroupMember)
|
@InjectRepository(GroupMember)
|
||||||
private groupMemberRepository: Repository<GroupMember>,
|
private groupMemberRepository: Repository<GroupMember>,
|
||||||
private dataSource: DataSource,
|
private dataSource: DataSource,
|
||||||
) {}
|
) {
|
||||||
|
// 在构造函数中验证并初始化加密密钥
|
||||||
|
this.ENCRYPTION_KEY = this.validateAndInitEncryptionKey();
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 加密账号凭据
|
* 验证并初始化加密密钥
|
||||||
|
*/
|
||||||
|
private validateAndInitEncryptionKey(): Buffer {
|
||||||
|
const key = process.env.ASSET_ENCRYPTION_KEY;
|
||||||
|
|
||||||
|
// 验证密钥存在
|
||||||
|
if (!key) {
|
||||||
|
throw new Error(
|
||||||
|
"环境变量 ASSET_ENCRYPTION_KEY 未设置。请在 .env 文件中配置32字节十六进制格式的强随机密钥。" +
|
||||||
|
"可以使用以下命令生成: node -e \"console.log(crypto.randomBytes(32).toString('hex'))\"",
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 验证密钥格式
|
||||||
|
let keyBuffer: Buffer;
|
||||||
|
try {
|
||||||
|
keyBuffer = Buffer.from(key, "hex");
|
||||||
|
} catch (error) {
|
||||||
|
throw new Error(
|
||||||
|
"环境变量 ASSET_ENCRYPTION_KEY 格式错误。请使用32字节十六进制格式(64个十六进制字符)。",
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 验证密钥长度
|
||||||
|
if (keyBuffer.length !== 32) {
|
||||||
|
throw new Error(
|
||||||
|
`环境变量 ASSET_ENCRYPTION_KEY 长度错误。当前: ${keyBuffer.length} 字节, 要求: 32 字节(64个十六进制字符)。`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return keyBuffer;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 加密账号凭据 - 使用 AES-256-GCM(带认证的加密)
|
||||||
*/
|
*/
|
||||||
private encrypt(text: string): string {
|
private encrypt(text: string): string {
|
||||||
const iv = crypto.randomBytes(16);
|
// 生成随机 IV (12字节是 GCM 推荐长度)
|
||||||
const cipher = crypto.createCipheriv('aes-256-cbc', Buffer.from(this.ENCRYPTION_KEY.padEnd(32, '0').slice(0, 32)), iv);
|
const iv = crypto.randomBytes(12);
|
||||||
let encrypted = cipher.update(text, 'utf8', 'hex');
|
const cipher = crypto.createCipheriv(
|
||||||
encrypted += cipher.final('hex');
|
"aes-256-gcm",
|
||||||
return iv.toString('hex') + ':' + encrypted;
|
this.ENCRYPTION_KEY,
|
||||||
|
iv,
|
||||||
|
);
|
||||||
|
|
||||||
|
// 加密
|
||||||
|
let encrypted = cipher.update(text, "utf8", "hex");
|
||||||
|
encrypted += cipher.final("hex");
|
||||||
|
|
||||||
|
// 获取认证标签(GCM 模式提供完整性校验)
|
||||||
|
const authTag = cipher.getAuthTag();
|
||||||
|
|
||||||
|
// 返回格式: iv:authTag:encrypted
|
||||||
|
return iv.toString("hex") + ":" + authTag.toString("hex") + ":" + encrypted;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 解密账号凭据
|
* 解密账号凭据
|
||||||
*/
|
*/
|
||||||
private decrypt(encrypted: string): string {
|
private decrypt(encrypted: string): string {
|
||||||
const parts = encrypted.split(':');
|
const parts = encrypted.split(":");
|
||||||
const ivStr = parts.shift();
|
if (parts.length !== 3) {
|
||||||
if (!ivStr) throw new Error('Invalid encrypted data');
|
throw new Error("Invalid encrypted data format");
|
||||||
const iv = Buffer.from(ivStr, 'hex');
|
}
|
||||||
const encryptedText = parts.join(':');
|
|
||||||
const decipher = crypto.createDecipheriv('aes-256-cbc', Buffer.from(this.ENCRYPTION_KEY.padEnd(32, '0').slice(0, 32)), iv);
|
const iv = Buffer.from(parts[0], "hex");
|
||||||
let decrypted = decipher.update(encryptedText, 'hex', 'utf8');
|
const authTag = Buffer.from(parts[1], "hex");
|
||||||
decrypted += decipher.final('utf8');
|
const encryptedText = parts[2];
|
||||||
|
|
||||||
|
const decipher = crypto.createDecipheriv(
|
||||||
|
"aes-256-gcm",
|
||||||
|
this.ENCRYPTION_KEY,
|
||||||
|
iv,
|
||||||
|
);
|
||||||
|
decipher.setAuthTag(authTag);
|
||||||
|
|
||||||
|
let decrypted = decipher.update(encryptedText, "hex", "utf8");
|
||||||
|
decrypted += decipher.final("utf8");
|
||||||
|
|
||||||
return decrypted;
|
return decrypted;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -63,7 +134,9 @@ export class AssetsService {
|
|||||||
async create(userId: string, createDto: CreateAssetDto) {
|
async create(userId: string, createDto: CreateAssetDto) {
|
||||||
const { groupId, accountCredentials, ...rest } = createDto;
|
const { groupId, accountCredentials, ...rest } = createDto;
|
||||||
|
|
||||||
const group = await this.groupRepository.findOne({ where: { id: groupId } });
|
const group = await this.groupRepository.findOne({
|
||||||
|
where: { id: groupId },
|
||||||
|
});
|
||||||
if (!group) {
|
if (!group) {
|
||||||
throw new NotFoundException({
|
throw new NotFoundException({
|
||||||
code: ErrorCode.GROUP_NOT_FOUND,
|
code: ErrorCode.GROUP_NOT_FOUND,
|
||||||
@@ -76,17 +149,23 @@ export class AssetsService {
|
|||||||
where: { groupId, userId },
|
where: { groupId, userId },
|
||||||
});
|
});
|
||||||
|
|
||||||
if (!membership || (membership.role !== GroupMemberRole.ADMIN && membership.role !== GroupMemberRole.OWNER)) {
|
if (
|
||||||
|
!membership ||
|
||||||
|
(membership.role !== GroupMemberRole.ADMIN &&
|
||||||
|
membership.role !== GroupMemberRole.OWNER)
|
||||||
|
) {
|
||||||
throw new ForbiddenException({
|
throw new ForbiddenException({
|
||||||
code: ErrorCode.NO_PERMISSION,
|
code: ErrorCode.NO_PERMISSION,
|
||||||
message: '需要管理员权限',
|
message: "需要管理员权限",
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
const asset = this.assetRepository.create({
|
const asset = this.assetRepository.create({
|
||||||
...rest,
|
...rest,
|
||||||
groupId,
|
groupId,
|
||||||
accountCredentials: accountCredentials ? this.encrypt(accountCredentials) : undefined,
|
accountCredentials: accountCredentials
|
||||||
|
? this.encrypt(accountCredentials)
|
||||||
|
: undefined,
|
||||||
});
|
});
|
||||||
|
|
||||||
await this.assetRepository.save(asset);
|
await this.assetRepository.save(asset);
|
||||||
@@ -100,8 +179,8 @@ export class AssetsService {
|
|||||||
async findAll(groupId: string) {
|
async findAll(groupId: string) {
|
||||||
const assets = await this.assetRepository.find({
|
const assets = await this.assetRepository.find({
|
||||||
where: { groupId },
|
where: { groupId },
|
||||||
relations: ['group'],
|
relations: ["group"],
|
||||||
order: { createdAt: 'DESC' },
|
order: { createdAt: "DESC" },
|
||||||
});
|
});
|
||||||
|
|
||||||
return assets.map((asset) => ({
|
return assets.map((asset) => ({
|
||||||
@@ -116,13 +195,13 @@ export class AssetsService {
|
|||||||
async findOne(id: string, userId?: string) {
|
async findOne(id: string, userId?: string) {
|
||||||
const asset = await this.assetRepository.findOne({
|
const asset = await this.assetRepository.findOne({
|
||||||
where: { id },
|
where: { id },
|
||||||
relations: ['group'],
|
relations: ["group"],
|
||||||
});
|
});
|
||||||
|
|
||||||
if (!asset) {
|
if (!asset) {
|
||||||
throw new NotFoundException({
|
throw new NotFoundException({
|
||||||
code: ErrorCode.ASSET_NOT_FOUND,
|
code: ErrorCode.ASSET_NOT_FOUND,
|
||||||
message: '资产不存在',
|
message: "资产不存在",
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -132,10 +211,16 @@ export class AssetsService {
|
|||||||
where: { groupId: asset.groupId, userId },
|
where: { groupId: asset.groupId, userId },
|
||||||
});
|
});
|
||||||
|
|
||||||
if (membership && (membership.role === GroupMemberRole.ADMIN || membership.role === GroupMemberRole.OWNER)) {
|
if (
|
||||||
|
membership &&
|
||||||
|
(membership.role === GroupMemberRole.ADMIN ||
|
||||||
|
membership.role === GroupMemberRole.OWNER)
|
||||||
|
) {
|
||||||
return {
|
return {
|
||||||
...asset,
|
...asset,
|
||||||
accountCredentials: asset.accountCredentials ? this.decrypt(asset.accountCredentials) : null,
|
accountCredentials: asset.accountCredentials
|
||||||
|
? this.decrypt(asset.accountCredentials)
|
||||||
|
: null,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -151,11 +236,11 @@ export class AssetsService {
|
|||||||
*/
|
*/
|
||||||
async update(userId: string, id: string, updateDto: UpdateAssetDto) {
|
async update(userId: string, id: string, updateDto: UpdateAssetDto) {
|
||||||
const asset = await this.assetRepository.findOne({ where: { id } });
|
const asset = await this.assetRepository.findOne({ where: { id } });
|
||||||
|
|
||||||
if (!asset) {
|
if (!asset) {
|
||||||
throw new NotFoundException({
|
throw new NotFoundException({
|
||||||
code: ErrorCode.ASSET_NOT_FOUND,
|
code: ErrorCode.ASSET_NOT_FOUND,
|
||||||
message: '资产不存在',
|
message: "资产不存在",
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -164,7 +249,11 @@ export class AssetsService {
|
|||||||
where: { groupId: asset.groupId, userId },
|
where: { groupId: asset.groupId, userId },
|
||||||
});
|
});
|
||||||
|
|
||||||
if (!membership || (membership.role !== GroupMemberRole.ADMIN && membership.role !== GroupMemberRole.OWNER)) {
|
if (
|
||||||
|
!membership ||
|
||||||
|
(membership.role !== GroupMemberRole.ADMIN &&
|
||||||
|
membership.role !== GroupMemberRole.OWNER)
|
||||||
|
) {
|
||||||
throw new ForbiddenException({
|
throw new ForbiddenException({
|
||||||
code: ErrorCode.NO_PERMISSION,
|
code: ErrorCode.NO_PERMISSION,
|
||||||
message: ErrorMessage[ErrorCode.NO_PERMISSION],
|
message: ErrorMessage[ErrorCode.NO_PERMISSION],
|
||||||
@@ -194,20 +283,20 @@ export class AssetsService {
|
|||||||
// 使用悲观锁防止并发借用
|
// 使用悲观锁防止并发借用
|
||||||
const asset = await queryRunner.manager.findOne(Asset, {
|
const asset = await queryRunner.manager.findOne(Asset, {
|
||||||
where: { id },
|
where: { id },
|
||||||
lock: { mode: 'pessimistic_write' },
|
lock: { mode: "pessimistic_write" },
|
||||||
});
|
});
|
||||||
|
|
||||||
if (!asset) {
|
if (!asset) {
|
||||||
throw new NotFoundException({
|
throw new NotFoundException({
|
||||||
code: ErrorCode.ASSET_NOT_FOUND,
|
code: ErrorCode.ASSET_NOT_FOUND,
|
||||||
message: '资产不存在',
|
message: "资产不存在",
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
if (asset.status !== AssetStatus.AVAILABLE) {
|
if (asset.status !== AssetStatus.AVAILABLE) {
|
||||||
throw new BadRequestException({
|
throw new BadRequestException({
|
||||||
code: ErrorCode.INVALID_OPERATION,
|
code: ErrorCode.INVALID_OPERATION,
|
||||||
message: '资产不可用',
|
message: "资产不可用",
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -238,7 +327,7 @@ export class AssetsService {
|
|||||||
await queryRunner.manager.save(AssetLog, log);
|
await queryRunner.manager.save(AssetLog, log);
|
||||||
|
|
||||||
await queryRunner.commitTransaction();
|
await queryRunner.commitTransaction();
|
||||||
return { message: '借用成功' };
|
return { message: "借用成功" };
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
await queryRunner.rollbackTransaction();
|
await queryRunner.rollbackTransaction();
|
||||||
throw error;
|
throw error;
|
||||||
@@ -260,20 +349,20 @@ export class AssetsService {
|
|||||||
// 使用悲观锁防止并发问题
|
// 使用悲观锁防止并发问题
|
||||||
const asset = await queryRunner.manager.findOne(Asset, {
|
const asset = await queryRunner.manager.findOne(Asset, {
|
||||||
where: { id },
|
where: { id },
|
||||||
lock: { mode: 'pessimistic_write' },
|
lock: { mode: "pessimistic_write" },
|
||||||
});
|
});
|
||||||
|
|
||||||
if (!asset) {
|
if (!asset) {
|
||||||
throw new NotFoundException({
|
throw new NotFoundException({
|
||||||
code: ErrorCode.ASSET_NOT_FOUND,
|
code: ErrorCode.ASSET_NOT_FOUND,
|
||||||
message: '资产不存在',
|
message: "资产不存在",
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
if (asset.currentBorrowerId !== userId) {
|
if (asset.currentBorrowerId !== userId) {
|
||||||
throw new ForbiddenException({
|
throw new ForbiddenException({
|
||||||
code: ErrorCode.NO_PERMISSION,
|
code: ErrorCode.NO_PERMISSION,
|
||||||
message: '无权归还此资产',
|
message: "无权归还此资产",
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -292,7 +381,7 @@ export class AssetsService {
|
|||||||
await queryRunner.manager.save(AssetLog, log);
|
await queryRunner.manager.save(AssetLog, log);
|
||||||
|
|
||||||
await queryRunner.commitTransaction();
|
await queryRunner.commitTransaction();
|
||||||
return { message: '归还成功' };
|
return { message: "归还成功" };
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
await queryRunner.rollbackTransaction();
|
await queryRunner.rollbackTransaction();
|
||||||
throw error;
|
throw error;
|
||||||
@@ -310,14 +399,14 @@ export class AssetsService {
|
|||||||
if (!asset) {
|
if (!asset) {
|
||||||
throw new NotFoundException({
|
throw new NotFoundException({
|
||||||
code: ErrorCode.ASSET_NOT_FOUND,
|
code: ErrorCode.ASSET_NOT_FOUND,
|
||||||
message: '资产不存在',
|
message: "资产不存在",
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
const logs = await this.assetLogRepository.find({
|
const logs = await this.assetLogRepository.find({
|
||||||
where: { assetId: id },
|
where: { assetId: id },
|
||||||
relations: ['user'],
|
relations: ["user"],
|
||||||
order: { createdAt: 'DESC' },
|
order: { createdAt: "DESC" },
|
||||||
});
|
});
|
||||||
|
|
||||||
return logs;
|
return logs;
|
||||||
@@ -332,7 +421,7 @@ export class AssetsService {
|
|||||||
if (!asset) {
|
if (!asset) {
|
||||||
throw new NotFoundException({
|
throw new NotFoundException({
|
||||||
code: ErrorCode.ASSET_NOT_FOUND,
|
code: ErrorCode.ASSET_NOT_FOUND,
|
||||||
message: '资产不存在',
|
message: "资产不存在",
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -341,7 +430,11 @@ export class AssetsService {
|
|||||||
where: { groupId: asset.groupId, userId },
|
where: { groupId: asset.groupId, userId },
|
||||||
});
|
});
|
||||||
|
|
||||||
if (!membership || (membership.role !== GroupMemberRole.ADMIN && membership.role !== GroupMemberRole.OWNER)) {
|
if (
|
||||||
|
!membership ||
|
||||||
|
(membership.role !== GroupMemberRole.ADMIN &&
|
||||||
|
membership.role !== GroupMemberRole.OWNER)
|
||||||
|
) {
|
||||||
throw new ForbiddenException({
|
throw new ForbiddenException({
|
||||||
code: ErrorCode.NO_PERMISSION,
|
code: ErrorCode.NO_PERMISSION,
|
||||||
message: ErrorMessage[ErrorCode.NO_PERMISSION],
|
message: ErrorMessage[ErrorCode.NO_PERMISSION],
|
||||||
@@ -350,6 +443,6 @@ export class AssetsService {
|
|||||||
|
|
||||||
await this.assetRepository.remove(asset);
|
await this.assetRepository.remove(asset);
|
||||||
|
|
||||||
return { message: '删除成功' };
|
return { message: "删除成功" };
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,42 +1,58 @@
|
|||||||
import { Controller, Get, Put, Body, Param, UseGuards } from '@nestjs/common';
|
import { Controller, Get, Put, Body, Param, UseGuards } from "@nestjs/common";
|
||||||
import { ApiTags, ApiOperation, ApiResponse, ApiBearerAuth } from '@nestjs/swagger';
|
import {
|
||||||
import { UsersService } from './users.service';
|
ApiTags,
|
||||||
import { UpdateUserDto, ChangePasswordDto } from './dto/user.dto';
|
ApiOperation,
|
||||||
import { JwtAuthGuard } from '../../common/guards/jwt-auth.guard';
|
ApiResponse,
|
||||||
import { CurrentUser } from '../../common/decorators/current-user.decorator';
|
ApiBearerAuth,
|
||||||
import { User } from '../../entities/user.entity';
|
} from "@nestjs/swagger";
|
||||||
|
import { UsersService } from "./users.service";
|
||||||
|
import { UpdateUserDto, ChangePasswordDto } from "./dto/user.dto";
|
||||||
|
import { JwtAuthGuard } from "../../common/guards/jwt-auth.guard";
|
||||||
|
import { CurrentUser } from "../../common/decorators/current-user.decorator";
|
||||||
|
import { User } from "../../entities/user.entity";
|
||||||
|
|
||||||
@ApiTags('users')
|
@ApiTags("users")
|
||||||
@ApiBearerAuth()
|
@ApiBearerAuth()
|
||||||
@UseGuards(JwtAuthGuard)
|
@UseGuards(JwtAuthGuard)
|
||||||
@Controller('users')
|
@Controller("users")
|
||||||
export class UsersController {
|
export class UsersController {
|
||||||
constructor(private readonly usersService: UsersService) {}
|
constructor(private readonly usersService: UsersService) {}
|
||||||
|
|
||||||
@Get('me')
|
@Get("me")
|
||||||
@ApiOperation({ summary: '获取当前用户信息' })
|
@ApiOperation({ summary: "获取当前用户信息" })
|
||||||
@ApiResponse({ status: 200, description: '获取成功' })
|
@ApiResponse({ status: 200, description: "获取成功" })
|
||||||
async getProfile(@CurrentUser() user: User) {
|
async getProfile(@CurrentUser() user: User) {
|
||||||
return this.usersService.findOne(user.id);
|
return this.usersService.findOne(user.id);
|
||||||
}
|
}
|
||||||
|
|
||||||
@Get(':id')
|
@Get(":id")
|
||||||
@ApiOperation({ summary: '获取用户信息' })
|
@ApiOperation({
|
||||||
@ApiResponse({ status: 200, description: '获取成功' })
|
summary: "获取用户信息",
|
||||||
async findOne(@Param('id') id: string) {
|
description: "查询自己的信息返回完整数据(包含 email、phone 等敏感字段),查询他人信息只返回公开信息(不包含敏感字段)"
|
||||||
return this.usersService.findOne(id);
|
})
|
||||||
|
@ApiResponse({ status: 200, description: "获取成功" })
|
||||||
|
async findOne(@Param("id") id: string, @CurrentUser() currentUser: User) {
|
||||||
|
// 如果查询的是自己的信息,返回完整信息
|
||||||
|
// 否则只返回公开信息(不包含 email、phone 等敏感字段)
|
||||||
|
if (currentUser.id === id) {
|
||||||
|
return this.usersService.findOne(id);
|
||||||
|
}
|
||||||
|
return this.usersService.findOnePublic(id);
|
||||||
}
|
}
|
||||||
|
|
||||||
@Put('me')
|
@Put("me")
|
||||||
@ApiOperation({ summary: '更新当前用户信息' })
|
@ApiOperation({ summary: "更新当前用户信息" })
|
||||||
@ApiResponse({ status: 200, description: '更新成功' })
|
@ApiResponse({ status: 200, description: "更新成功" })
|
||||||
async update(@CurrentUser() user: User, @Body() updateUserDto: UpdateUserDto) {
|
async update(
|
||||||
|
@CurrentUser() user: User,
|
||||||
|
@Body() updateUserDto: UpdateUserDto,
|
||||||
|
) {
|
||||||
return this.usersService.update(user.id, updateUserDto);
|
return this.usersService.update(user.id, updateUserDto);
|
||||||
}
|
}
|
||||||
|
|
||||||
@Put('me/password')
|
@Put("me/password")
|
||||||
@ApiOperation({ summary: '修改密码' })
|
@ApiOperation({ summary: "修改密码" })
|
||||||
@ApiResponse({ status: 200, description: '修改成功' })
|
@ApiResponse({ status: 200, description: "修改成功" })
|
||||||
async changePassword(
|
async changePassword(
|
||||||
@CurrentUser() user: User,
|
@CurrentUser() user: User,
|
||||||
@Body() changePasswordDto: ChangePasswordDto,
|
@Body() changePasswordDto: ChangePasswordDto,
|
||||||
|
|||||||
@@ -1,15 +1,22 @@
|
|||||||
import { Injectable, NotFoundException, BadRequestException } from '@nestjs/common';
|
import {
|
||||||
import { InjectRepository } from '@nestjs/typeorm';
|
Injectable,
|
||||||
import { Repository } from 'typeorm';
|
NotFoundException,
|
||||||
import { User } from '../../entities/user.entity';
|
BadRequestException,
|
||||||
import { UpdateUserDto, ChangePasswordDto } from './dto/user.dto';
|
} from "@nestjs/common";
|
||||||
import { CryptoUtil } from '../../common/utils/crypto.util';
|
import { InjectRepository } from "@nestjs/typeorm";
|
||||||
import { ErrorCode, ErrorMessage } from '../../common/interfaces/response.interface';
|
import { Repository } from "typeorm";
|
||||||
import { CacheService } from '../../common/services/cache.service';
|
import { User } from "../../entities/user.entity";
|
||||||
|
import { UpdateUserDto, ChangePasswordDto } from "./dto/user.dto";
|
||||||
|
import { CryptoUtil } from "../../common/utils/crypto.util";
|
||||||
|
import {
|
||||||
|
ErrorCode,
|
||||||
|
ErrorMessage,
|
||||||
|
} from "../../common/interfaces/response.interface";
|
||||||
|
import { CacheService } from "../../common/services/cache.service";
|
||||||
|
|
||||||
@Injectable()
|
@Injectable()
|
||||||
export class UsersService {
|
export class UsersService {
|
||||||
private readonly CACHE_PREFIX = 'user';
|
private readonly CACHE_PREFIX = "user";
|
||||||
private readonly CACHE_TTL = 300; // 5分钟
|
private readonly CACHE_TTL = 300; // 5分钟
|
||||||
|
|
||||||
constructor(
|
constructor(
|
||||||
@@ -19,11 +26,13 @@ export class UsersService {
|
|||||||
) {}
|
) {}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 获取用户信息
|
* 获取用户完整信息(包含敏感字段)
|
||||||
*/
|
*/
|
||||||
async findOne(id: string) {
|
async findOne(id: string) {
|
||||||
// 先查缓存
|
// 先查缓存
|
||||||
const cached = this.cacheService.get<any>(id, { prefix: this.CACHE_PREFIX });
|
const cached = this.cacheService.get<any>(`${id}:full`, {
|
||||||
|
prefix: this.CACHE_PREFIX,
|
||||||
|
});
|
||||||
if (cached) {
|
if (cached) {
|
||||||
return cached;
|
return cached;
|
||||||
}
|
}
|
||||||
@@ -51,7 +60,45 @@ export class UsersService {
|
|||||||
};
|
};
|
||||||
|
|
||||||
// 写入缓存
|
// 写入缓存
|
||||||
this.cacheService.set(id, result, {
|
this.cacheService.set(`${id}:full`, result, {
|
||||||
|
prefix: this.CACHE_PREFIX,
|
||||||
|
ttl: this.CACHE_TTL,
|
||||||
|
});
|
||||||
|
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 获取用户公开信息(不包含敏感字段)
|
||||||
|
*/
|
||||||
|
async findOnePublic(id: string) {
|
||||||
|
// 先查缓存
|
||||||
|
const cached = this.cacheService.get<any>(`${id}:public`, {
|
||||||
|
prefix: this.CACHE_PREFIX,
|
||||||
|
});
|
||||||
|
if (cached) {
|
||||||
|
return cached;
|
||||||
|
}
|
||||||
|
|
||||||
|
const user = await this.userRepository.findOne({ where: { id } });
|
||||||
|
|
||||||
|
if (!user) {
|
||||||
|
throw new NotFoundException({
|
||||||
|
code: ErrorCode.USER_NOT_FOUND,
|
||||||
|
message: ErrorMessage[ErrorCode.USER_NOT_FOUND],
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
const result = {
|
||||||
|
id: user.id,
|
||||||
|
username: user.username,
|
||||||
|
avatar: user.avatar,
|
||||||
|
isMember: user.isMember,
|
||||||
|
createdAt: user.createdAt,
|
||||||
|
};
|
||||||
|
|
||||||
|
// 写入缓存
|
||||||
|
this.cacheService.set(`${id}:public`, result, {
|
||||||
prefix: this.CACHE_PREFIX,
|
prefix: this.CACHE_PREFIX,
|
||||||
ttl: this.CACHE_TTL,
|
ttl: this.CACHE_TTL,
|
||||||
});
|
});
|
||||||
@@ -80,7 +127,7 @@ export class UsersService {
|
|||||||
if (existingUser) {
|
if (existingUser) {
|
||||||
throw new BadRequestException({
|
throw new BadRequestException({
|
||||||
code: ErrorCode.USER_EXISTS,
|
code: ErrorCode.USER_EXISTS,
|
||||||
message: '邮箱已被使用',
|
message: "邮箱已被使用",
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -93,7 +140,7 @@ export class UsersService {
|
|||||||
if (existingUser) {
|
if (existingUser) {
|
||||||
throw new BadRequestException({
|
throw new BadRequestException({
|
||||||
code: ErrorCode.USER_EXISTS,
|
code: ErrorCode.USER_EXISTS,
|
||||||
message: '手机号已被使用',
|
message: "手机号已被使用",
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -101,8 +148,9 @@ export class UsersService {
|
|||||||
Object.assign(user, updateUserDto);
|
Object.assign(user, updateUserDto);
|
||||||
await this.userRepository.save(user);
|
await this.userRepository.save(user);
|
||||||
|
|
||||||
// 清除缓存
|
// 清除两种缓存(完整信息和公开信息)
|
||||||
this.cacheService.del(id, { prefix: this.CACHE_PREFIX });
|
this.cacheService.del(`${id}:full`, { prefix: this.CACHE_PREFIX });
|
||||||
|
this.cacheService.del(`${id}:public`, { prefix: this.CACHE_PREFIX });
|
||||||
|
|
||||||
return this.findOne(id);
|
return this.findOne(id);
|
||||||
}
|
}
|
||||||
@@ -112,9 +160,9 @@ export class UsersService {
|
|||||||
*/
|
*/
|
||||||
async changePassword(id: string, changePasswordDto: ChangePasswordDto) {
|
async changePassword(id: string, changePasswordDto: ChangePasswordDto) {
|
||||||
const user = await this.userRepository
|
const user = await this.userRepository
|
||||||
.createQueryBuilder('user')
|
.createQueryBuilder("user")
|
||||||
.where('user.id = :id', { id })
|
.where("user.id = :id", { id })
|
||||||
.addSelect('user.password')
|
.addSelect("user.password")
|
||||||
.getOne();
|
.getOne();
|
||||||
|
|
||||||
if (!user) {
|
if (!user) {
|
||||||
@@ -133,15 +181,17 @@ export class UsersService {
|
|||||||
if (!isPasswordValid) {
|
if (!isPasswordValid) {
|
||||||
throw new BadRequestException({
|
throw new BadRequestException({
|
||||||
code: ErrorCode.PASSWORD_ERROR,
|
code: ErrorCode.PASSWORD_ERROR,
|
||||||
message: '原密码错误',
|
message: "原密码错误",
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
// 更新密码
|
// 更新密码
|
||||||
user.password = await CryptoUtil.hashPassword(changePasswordDto.newPassword);
|
user.password = await CryptoUtil.hashPassword(
|
||||||
|
changePasswordDto.newPassword,
|
||||||
|
);
|
||||||
await this.userRepository.save(user);
|
await this.userRepository.save(user);
|
||||||
|
|
||||||
return { message: '密码修改成功' };
|
return { message: "密码修改成功" };
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -149,11 +199,11 @@ export class UsersService {
|
|||||||
*/
|
*/
|
||||||
async getCreatedGroupsCount(userId: string): Promise<number> {
|
async getCreatedGroupsCount(userId: string): Promise<number> {
|
||||||
const user = await this.userRepository
|
const user = await this.userRepository
|
||||||
.createQueryBuilder('user')
|
.createQueryBuilder("user")
|
||||||
.leftJoin('user.groupMembers', 'member')
|
.leftJoin("user.groupMembers", "member")
|
||||||
.leftJoin('member.group', 'group')
|
.leftJoin("member.group", "group")
|
||||||
.where('user.id = :userId', { userId })
|
.where("user.id = :userId", { userId })
|
||||||
.andWhere('group.ownerId = :userId', { userId })
|
.andWhere("group.ownerId = :userId", { userId })
|
||||||
.getCount();
|
.getCount();
|
||||||
|
|
||||||
return user;
|
return user;
|
||||||
@@ -164,9 +214,9 @@ export class UsersService {
|
|||||||
*/
|
*/
|
||||||
async getJoinedGroupsCount(userId: string): Promise<number> {
|
async getJoinedGroupsCount(userId: string): Promise<number> {
|
||||||
const user = await this.userRepository
|
const user = await this.userRepository
|
||||||
.createQueryBuilder('user')
|
.createQueryBuilder("user")
|
||||||
.leftJoin('user.groupMembers', 'member')
|
.leftJoin("user.groupMembers", "member")
|
||||||
.where('user.id = :userId', { userId })
|
.where("user.id = :userId", { userId })
|
||||||
.getCount();
|
.getCount();
|
||||||
|
|
||||||
return user;
|
return user;
|
||||||
|
|||||||
Reference in New Issue
Block a user