初始化游戏小组管理系统后端项目
- 基于 NestJS + TypeScript + MySQL + Redis 架构 - 完整的模块化设计(认证、用户、小组、游戏、预约等) - JWT 认证和 RBAC 权限控制系统 - Docker 容器化部署支持 - 添加 CLAUDE.md 项目开发指南 - 配置 .gitignore 忽略文件 Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
This commit is contained in:
84
src/modules/assets/assets.controller.ts
Normal file
84
src/modules/assets/assets.controller.ts
Normal file
@@ -0,0 +1,84 @@
|
||||
import {
|
||||
Controller,
|
||||
Get,
|
||||
Post,
|
||||
Body,
|
||||
Patch,
|
||||
Param,
|
||||
Delete,
|
||||
UseGuards,
|
||||
Query,
|
||||
} from '@nestjs/common';
|
||||
import { ApiTags, ApiOperation, ApiBearerAuth } from '@nestjs/swagger';
|
||||
import { AssetsService } from './assets.service';
|
||||
import { CreateAssetDto, UpdateAssetDto, BorrowAssetDto, ReturnAssetDto } from './dto/asset.dto';
|
||||
import { JwtAuthGuard } from '../../common/guards/jwt-auth.guard';
|
||||
import { CurrentUser } from '../../common/decorators/current-user.decorator';
|
||||
|
||||
@ApiTags('assets')
|
||||
@Controller('assets')
|
||||
@UseGuards(JwtAuthGuard)
|
||||
@ApiBearerAuth()
|
||||
export class AssetsController {
|
||||
constructor(private readonly assetsService: AssetsService) {}
|
||||
|
||||
@Post()
|
||||
@ApiOperation({ summary: '创建资产(管理员)' })
|
||||
create(@CurrentUser() user, @Body() createDto: CreateAssetDto) {
|
||||
return this.assetsService.create(user.id, createDto);
|
||||
}
|
||||
|
||||
@Get('group/:groupId')
|
||||
@ApiOperation({ summary: '查询小组资产列表' })
|
||||
findAll(@Param('groupId') groupId: string) {
|
||||
return this.assetsService.findAll(groupId);
|
||||
}
|
||||
|
||||
@Get(':id')
|
||||
@ApiOperation({ summary: '查询资产详情' })
|
||||
findOne(@CurrentUser() user, @Param('id') id: string) {
|
||||
return this.assetsService.findOne(id, user.id);
|
||||
}
|
||||
|
||||
@Patch(':id')
|
||||
@ApiOperation({ summary: '更新资产(管理员)' })
|
||||
update(
|
||||
@CurrentUser() user,
|
||||
@Param('id') id: string,
|
||||
@Body() updateDto: UpdateAssetDto,
|
||||
) {
|
||||
return this.assetsService.update(user.id, id, updateDto);
|
||||
}
|
||||
|
||||
@Post(':id/borrow')
|
||||
@ApiOperation({ summary: '借用资产' })
|
||||
borrow(
|
||||
@CurrentUser() user,
|
||||
@Param('id') id: string,
|
||||
@Body() borrowDto: BorrowAssetDto,
|
||||
) {
|
||||
return this.assetsService.borrow(user.id, id, borrowDto);
|
||||
}
|
||||
|
||||
@Post(':id/return')
|
||||
@ApiOperation({ summary: '归还资产' })
|
||||
returnAsset(
|
||||
@CurrentUser() user,
|
||||
@Param('id') id: string,
|
||||
@Body() returnDto: ReturnAssetDto,
|
||||
) {
|
||||
return this.assetsService.return(user.id, id, returnDto.note);
|
||||
}
|
||||
|
||||
@Get(':id/logs')
|
||||
@ApiOperation({ summary: '查询资产借还记录' })
|
||||
getLogs(@Param('id') id: string) {
|
||||
return this.assetsService.getLogs(id);
|
||||
}
|
||||
|
||||
@Delete(':id')
|
||||
@ApiOperation({ summary: '删除资产(管理员)' })
|
||||
remove(@CurrentUser() user, @Param('id') id: string) {
|
||||
return this.assetsService.remove(user.id, id);
|
||||
}
|
||||
}
|
||||
16
src/modules/assets/assets.module.ts
Normal file
16
src/modules/assets/assets.module.ts
Normal file
@@ -0,0 +1,16 @@
|
||||
import { Module } from '@nestjs/common';
|
||||
import { TypeOrmModule } from '@nestjs/typeorm';
|
||||
import { AssetsController } from './assets.controller';
|
||||
import { AssetsService } from './assets.service';
|
||||
import { Asset } from '../../entities/asset.entity';
|
||||
import { AssetLog } from '../../entities/asset-log.entity';
|
||||
import { Group } from '../../entities/group.entity';
|
||||
import { GroupMember } from '../../entities/group-member.entity';
|
||||
|
||||
@Module({
|
||||
imports: [TypeOrmModule.forFeature([Asset, AssetLog, Group, GroupMember])],
|
||||
controllers: [AssetsController],
|
||||
providers: [AssetsService],
|
||||
exports: [AssetsService],
|
||||
})
|
||||
export class AssetsModule {}
|
||||
242
src/modules/assets/assets.service.spec.ts
Normal file
242
src/modules/assets/assets.service.spec.ts
Normal file
@@ -0,0 +1,242 @@
|
||||
import { Test, TestingModule } from '@nestjs/testing';
|
||||
import { getRepositoryToken } from '@nestjs/typeorm';
|
||||
import { Repository, DataSource } from 'typeorm';
|
||||
import { AssetsService } from './assets.service';
|
||||
import { Asset } from '../../entities/asset.entity';
|
||||
import { AssetLog } from '../../entities/asset-log.entity';
|
||||
import { Group } from '../../entities/group.entity';
|
||||
import { GroupMember } from '../../entities/group-member.entity';
|
||||
import { AssetType, AssetStatus, GroupMemberRole, AssetLogAction } from '../../common/enums';
|
||||
import { NotFoundException, ForbiddenException, BadRequestException } from '@nestjs/common';
|
||||
|
||||
describe('AssetsService', () => {
|
||||
let service: AssetsService;
|
||||
let assetRepository: Repository<Asset>;
|
||||
let assetLogRepository: Repository<AssetLog>;
|
||||
let groupRepository: Repository<Group>;
|
||||
let groupMemberRepository: Repository<GroupMember>;
|
||||
|
||||
const mockAsset = {
|
||||
id: 'asset-1',
|
||||
groupId: 'group-1',
|
||||
type: AssetType.ACCOUNT,
|
||||
name: '测试账号',
|
||||
description: '测试描述',
|
||||
accountCredentials: 'encrypted-data',
|
||||
quantity: 1,
|
||||
status: AssetStatus.AVAILABLE,
|
||||
currentBorrowerId: null,
|
||||
createdAt: new Date(),
|
||||
updatedAt: new Date(),
|
||||
};
|
||||
|
||||
const mockGroup = {
|
||||
id: 'group-1',
|
||||
name: '测试小组',
|
||||
};
|
||||
|
||||
const mockGroupMember = {
|
||||
id: 'member-1',
|
||||
userId: 'user-1',
|
||||
groupId: 'group-1',
|
||||
role: GroupMemberRole.ADMIN,
|
||||
};
|
||||
|
||||
const mockDataSource = {
|
||||
createQueryRunner: jest.fn().mockReturnValue({
|
||||
connect: jest.fn(),
|
||||
startTransaction: jest.fn(),
|
||||
commitTransaction: jest.fn(),
|
||||
rollbackTransaction: jest.fn(),
|
||||
release: jest.fn(),
|
||||
manager: {
|
||||
findOne: jest.fn(),
|
||||
find: jest.fn(),
|
||||
create: jest.fn(),
|
||||
save: jest.fn(),
|
||||
},
|
||||
}),
|
||||
};
|
||||
|
||||
beforeEach(async () => {
|
||||
const module: TestingModule = await Test.createTestingModule({
|
||||
providers: [
|
||||
AssetsService,
|
||||
{
|
||||
provide: getRepositoryToken(Asset),
|
||||
useValue: {
|
||||
create: jest.fn(),
|
||||
save: jest.fn(),
|
||||
find: jest.fn(),
|
||||
findOne: jest.fn(),
|
||||
remove: jest.fn(),
|
||||
},
|
||||
},
|
||||
{
|
||||
provide: getRepositoryToken(AssetLog),
|
||||
useValue: {
|
||||
create: jest.fn(),
|
||||
save: jest.fn(),
|
||||
find: jest.fn(),
|
||||
},
|
||||
},
|
||||
{
|
||||
provide: getRepositoryToken(Group),
|
||||
useValue: {
|
||||
findOne: jest.fn(),
|
||||
},
|
||||
},
|
||||
{
|
||||
provide: getRepositoryToken(GroupMember),
|
||||
useValue: {
|
||||
findOne: jest.fn(),
|
||||
},
|
||||
},
|
||||
{
|
||||
provide: DataSource,
|
||||
useValue: mockDataSource,
|
||||
},
|
||||
],
|
||||
}).compile();
|
||||
|
||||
service = module.get<AssetsService>(AssetsService);
|
||||
assetRepository = module.get<Repository<Asset>>(getRepositoryToken(Asset));
|
||||
assetLogRepository = module.get<Repository<AssetLog>>(getRepositoryToken(AssetLog));
|
||||
groupRepository = module.get<Repository<Group>>(getRepositoryToken(Group));
|
||||
groupMemberRepository = module.get<Repository<GroupMember>>(getRepositoryToken(GroupMember));
|
||||
});
|
||||
|
||||
it('should be defined', () => {
|
||||
expect(service).toBeDefined();
|
||||
});
|
||||
|
||||
describe('create', () => {
|
||||
it('应该成功创建资产', async () => {
|
||||
const createDto = {
|
||||
groupId: 'group-1',
|
||||
type: AssetType.ACCOUNT,
|
||||
name: '测试账号',
|
||||
description: '测试描述',
|
||||
};
|
||||
|
||||
jest.spyOn(groupRepository, 'findOne').mockResolvedValue(mockGroup as any);
|
||||
jest.spyOn(groupMemberRepository, 'findOne').mockResolvedValue(mockGroupMember as any);
|
||||
jest.spyOn(assetRepository, 'create').mockReturnValue(mockAsset as any);
|
||||
jest.spyOn(assetRepository, 'save').mockResolvedValue(mockAsset as any);
|
||||
jest.spyOn(assetRepository, 'findOne').mockResolvedValue(mockAsset as any);
|
||||
|
||||
const result = await service.create('user-1', createDto);
|
||||
|
||||
expect(result).toBeDefined();
|
||||
expect(groupRepository.findOne).toHaveBeenCalledWith({ where: { id: 'group-1' } });
|
||||
expect(groupMemberRepository.findOne).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('小组不存在时应该抛出异常', async () => {
|
||||
const createDto = {
|
||||
groupId: 'group-1',
|
||||
type: AssetType.ACCOUNT,
|
||||
name: '测试账号',
|
||||
};
|
||||
|
||||
jest.spyOn(groupRepository, 'findOne').mockResolvedValue(null);
|
||||
|
||||
await expect(service.create('user-1', createDto)).rejects.toThrow(NotFoundException);
|
||||
});
|
||||
|
||||
it('无权限时应该抛出异常', async () => {
|
||||
const createDto = {
|
||||
groupId: 'group-1',
|
||||
type: AssetType.ACCOUNT,
|
||||
name: '测试账号',
|
||||
};
|
||||
|
||||
jest.spyOn(groupRepository, 'findOne').mockResolvedValue(mockGroup as any);
|
||||
jest.spyOn(groupMemberRepository, 'findOne').mockResolvedValue({
|
||||
...mockGroupMember,
|
||||
role: GroupMemberRole.MEMBER,
|
||||
} as any);
|
||||
|
||||
await expect(service.create('user-1', createDto)).rejects.toThrow(ForbiddenException);
|
||||
});
|
||||
});
|
||||
|
||||
describe('findAll', () => {
|
||||
it('应该返回资产列表', async () => {
|
||||
jest.spyOn(assetRepository, 'find').mockResolvedValue([mockAsset] as any);
|
||||
|
||||
const result = await service.findAll('group-1');
|
||||
|
||||
expect(result).toHaveLength(1);
|
||||
expect(result[0].accountCredentials).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
describe('borrow', () => {
|
||||
it('应该成功借用资产', async () => {
|
||||
const borrowDto = { reason: '需要使用' };
|
||||
|
||||
jest.spyOn(assetRepository, 'findOne').mockResolvedValue(mockAsset as any);
|
||||
jest.spyOn(groupMemberRepository, 'findOne').mockResolvedValue(mockGroupMember as any);
|
||||
jest.spyOn(assetRepository, 'save').mockResolvedValue({ ...mockAsset, status: AssetStatus.IN_USE } as any);
|
||||
jest.spyOn(assetLogRepository, 'create').mockReturnValue({} as any);
|
||||
jest.spyOn(assetLogRepository, 'save').mockResolvedValue({} as any);
|
||||
|
||||
const result = await service.borrow('user-1', 'asset-1', borrowDto);
|
||||
|
||||
expect(result.message).toBe('借用成功');
|
||||
expect(assetRepository.save).toHaveBeenCalled();
|
||||
expect(assetLogRepository.save).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('资产不可用时应该抛出异常', async () => {
|
||||
const borrowDto = { reason: '需要使用' };
|
||||
|
||||
jest.spyOn(assetRepository, 'findOne').mockResolvedValue({
|
||||
...mockAsset,
|
||||
status: AssetStatus.IN_USE,
|
||||
} as any);
|
||||
|
||||
await expect(service.borrow('user-1', 'asset-1', borrowDto)).rejects.toThrow(BadRequestException);
|
||||
});
|
||||
});
|
||||
|
||||
describe('return', () => {
|
||||
it('应该成功归还资产', async () => {
|
||||
jest.spyOn(assetRepository, 'findOne').mockResolvedValue({
|
||||
...mockAsset,
|
||||
currentBorrowerId: 'user-1',
|
||||
} as any);
|
||||
jest.spyOn(assetRepository, 'save').mockResolvedValue(mockAsset as any);
|
||||
jest.spyOn(assetLogRepository, 'create').mockReturnValue({} as any);
|
||||
jest.spyOn(assetLogRepository, 'save').mockResolvedValue({} as any);
|
||||
|
||||
const result = await service.return('user-1', 'asset-1', '已归还');
|
||||
|
||||
expect(result.message).toBe('归还成功');
|
||||
expect(assetRepository.save).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('非借用人归还时应该抛出异常', async () => {
|
||||
jest.spyOn(assetRepository, 'findOne').mockResolvedValue({
|
||||
...mockAsset,
|
||||
currentBorrowerId: 'user-2',
|
||||
} as any);
|
||||
|
||||
await expect(service.return('user-1', 'asset-1')).rejects.toThrow(ForbiddenException);
|
||||
});
|
||||
});
|
||||
|
||||
describe('remove', () => {
|
||||
it('应该成功删除资产', async () => {
|
||||
jest.spyOn(assetRepository, 'findOne').mockResolvedValue(mockAsset as any);
|
||||
jest.spyOn(groupMemberRepository, 'findOne').mockResolvedValue(mockGroupMember as any);
|
||||
jest.spyOn(assetRepository, 'remove').mockResolvedValue(mockAsset as any);
|
||||
|
||||
const result = await service.remove('user-1', 'asset-1');
|
||||
|
||||
expect(result.message).toBe('删除成功');
|
||||
expect(assetRepository.remove).toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
});
|
||||
355
src/modules/assets/assets.service.ts
Normal file
355
src/modules/assets/assets.service.ts
Normal file
@@ -0,0 +1,355 @@
|
||||
import {
|
||||
Injectable,
|
||||
NotFoundException,
|
||||
ForbiddenException,
|
||||
BadRequestException,
|
||||
} from '@nestjs/common';
|
||||
import { InjectRepository } from '@nestjs/typeorm';
|
||||
import { Repository, DataSource } from 'typeorm';
|
||||
import { Asset } from '../../entities/asset.entity';
|
||||
import { AssetLog } from '../../entities/asset-log.entity';
|
||||
import { Group } from '../../entities/group.entity';
|
||||
import { GroupMember } from '../../entities/group-member.entity';
|
||||
import { CreateAssetDto, UpdateAssetDto, 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()
|
||||
export class AssetsService {
|
||||
private readonly ENCRYPTION_KEY = process.env.ASSET_ENCRYPTION_KEY || 'default-key-change-in-production';
|
||||
|
||||
constructor(
|
||||
@InjectRepository(Asset)
|
||||
private assetRepository: Repository<Asset>,
|
||||
@InjectRepository(AssetLog)
|
||||
private assetLogRepository: Repository<AssetLog>,
|
||||
@InjectRepository(Group)
|
||||
private groupRepository: Repository<Group>,
|
||||
@InjectRepository(GroupMember)
|
||||
private groupMemberRepository: Repository<GroupMember>,
|
||||
private dataSource: DataSource,
|
||||
) {}
|
||||
|
||||
/**
|
||||
* 加密账号凭据
|
||||
*/
|
||||
private encrypt(text: string): string {
|
||||
const iv = crypto.randomBytes(16);
|
||||
const cipher = crypto.createCipheriv('aes-256-cbc', Buffer.from(this.ENCRYPTION_KEY.padEnd(32, '0').slice(0, 32)), iv);
|
||||
let encrypted = cipher.update(text, 'utf8', 'hex');
|
||||
encrypted += cipher.final('hex');
|
||||
return iv.toString('hex') + ':' + encrypted;
|
||||
}
|
||||
|
||||
/**
|
||||
* 解密账号凭据
|
||||
*/
|
||||
private decrypt(encrypted: string): string {
|
||||
const parts = encrypted.split(':');
|
||||
const ivStr = parts.shift();
|
||||
if (!ivStr) throw new Error('Invalid encrypted data');
|
||||
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);
|
||||
let decrypted = decipher.update(encryptedText, 'hex', 'utf8');
|
||||
decrypted += decipher.final('utf8');
|
||||
return decrypted;
|
||||
}
|
||||
|
||||
/**
|
||||
* 创建资产
|
||||
*/
|
||||
async create(userId: string, createDto: CreateAssetDto) {
|
||||
const { groupId, accountCredentials, ...rest } = createDto;
|
||||
|
||||
const group = await this.groupRepository.findOne({ where: { id: groupId } });
|
||||
if (!group) {
|
||||
throw new NotFoundException({
|
||||
code: ErrorCode.GROUP_NOT_FOUND,
|
||||
message: ErrorMessage[ErrorCode.GROUP_NOT_FOUND],
|
||||
});
|
||||
}
|
||||
|
||||
// 验证权限
|
||||
const membership = await this.groupMemberRepository.findOne({
|
||||
where: { groupId, userId },
|
||||
});
|
||||
|
||||
if (!membership || (membership.role !== GroupMemberRole.ADMIN && membership.role !== GroupMemberRole.OWNER)) {
|
||||
throw new ForbiddenException({
|
||||
code: ErrorCode.NO_PERMISSION,
|
||||
message: '需要管理员权限',
|
||||
});
|
||||
}
|
||||
|
||||
const asset = this.assetRepository.create({
|
||||
...rest,
|
||||
groupId,
|
||||
accountCredentials: accountCredentials ? this.encrypt(accountCredentials) : undefined,
|
||||
});
|
||||
|
||||
await this.assetRepository.save(asset);
|
||||
|
||||
return this.findOne(asset.id);
|
||||
}
|
||||
|
||||
/**
|
||||
* 查询资产列表
|
||||
*/
|
||||
async findAll(groupId: string) {
|
||||
const assets = await this.assetRepository.find({
|
||||
where: { groupId },
|
||||
relations: ['group'],
|
||||
order: { createdAt: 'DESC' },
|
||||
});
|
||||
|
||||
return assets.map((asset) => ({
|
||||
...asset,
|
||||
accountCredentials: undefined, // 不返回加密凭据
|
||||
}));
|
||||
}
|
||||
|
||||
/**
|
||||
* 查询单个资产详情(包含解密后的凭据,需管理员权限)
|
||||
*/
|
||||
async findOne(id: string, userId?: string) {
|
||||
const asset = await this.assetRepository.findOne({
|
||||
where: { id },
|
||||
relations: ['group'],
|
||||
});
|
||||
|
||||
if (!asset) {
|
||||
throw new NotFoundException({
|
||||
code: ErrorCode.ASSET_NOT_FOUND,
|
||||
message: '资产不存在',
|
||||
});
|
||||
}
|
||||
|
||||
// 如果提供了userId,验证权限后返回解密凭据
|
||||
if (userId) {
|
||||
const membership = await this.groupMemberRepository.findOne({
|
||||
where: { groupId: asset.groupId, userId },
|
||||
});
|
||||
|
||||
if (membership && (membership.role === GroupMemberRole.ADMIN || membership.role === GroupMemberRole.OWNER)) {
|
||||
return {
|
||||
...asset,
|
||||
accountCredentials: asset.accountCredentials ? this.decrypt(asset.accountCredentials) : null,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
...asset,
|
||||
accountCredentials: undefined,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* 更新资产
|
||||
*/
|
||||
async update(userId: string, id: string, updateDto: UpdateAssetDto) {
|
||||
const asset = await this.assetRepository.findOne({ where: { id } });
|
||||
|
||||
if (!asset) {
|
||||
throw new NotFoundException({
|
||||
code: ErrorCode.ASSET_NOT_FOUND,
|
||||
message: '资产不存在',
|
||||
});
|
||||
}
|
||||
|
||||
// 验证权限
|
||||
const membership = await this.groupMemberRepository.findOne({
|
||||
where: { groupId: asset.groupId, userId },
|
||||
});
|
||||
|
||||
if (!membership || (membership.role !== GroupMemberRole.ADMIN && membership.role !== GroupMemberRole.OWNER)) {
|
||||
throw new ForbiddenException({
|
||||
code: ErrorCode.NO_PERMISSION,
|
||||
message: ErrorMessage[ErrorCode.NO_PERMISSION],
|
||||
});
|
||||
}
|
||||
|
||||
if (updateDto.accountCredentials) {
|
||||
updateDto.accountCredentials = this.encrypt(updateDto.accountCredentials);
|
||||
}
|
||||
|
||||
Object.assign(asset, updateDto);
|
||||
await this.assetRepository.save(asset);
|
||||
|
||||
return this.findOne(id, userId);
|
||||
}
|
||||
|
||||
/**
|
||||
* 借用资产(使用事务和悲观锁防止并发问题)
|
||||
*/
|
||||
async borrow(userId: string, id: string, borrowDto: BorrowAssetDto) {
|
||||
// 使用事务确保数据一致性
|
||||
const queryRunner = this.dataSource.createQueryRunner();
|
||||
await queryRunner.connect();
|
||||
await queryRunner.startTransaction();
|
||||
|
||||
try {
|
||||
// 使用悲观锁防止并发借用
|
||||
const asset = await queryRunner.manager.findOne(Asset, {
|
||||
where: { id },
|
||||
lock: { mode: 'pessimistic_write' },
|
||||
});
|
||||
|
||||
if (!asset) {
|
||||
throw new NotFoundException({
|
||||
code: ErrorCode.ASSET_NOT_FOUND,
|
||||
message: '资产不存在',
|
||||
});
|
||||
}
|
||||
|
||||
if (asset.status !== AssetStatus.AVAILABLE) {
|
||||
throw new BadRequestException({
|
||||
code: ErrorCode.INVALID_OPERATION,
|
||||
message: '资产不可用',
|
||||
});
|
||||
}
|
||||
|
||||
// 验证用户在小组中
|
||||
const membership = await queryRunner.manager.findOne(GroupMember, {
|
||||
where: { groupId: asset.groupId, userId },
|
||||
});
|
||||
|
||||
if (!membership) {
|
||||
throw new ForbiddenException({
|
||||
code: ErrorCode.NOT_IN_GROUP,
|
||||
message: ErrorMessage[ErrorCode.NOT_IN_GROUP],
|
||||
});
|
||||
}
|
||||
|
||||
// 更新资产状态
|
||||
asset.status = AssetStatus.IN_USE;
|
||||
asset.currentBorrowerId = userId;
|
||||
await queryRunner.manager.save(Asset, asset);
|
||||
|
||||
// 记录日志
|
||||
const log = queryRunner.manager.create(AssetLog, {
|
||||
assetId: id,
|
||||
userId,
|
||||
action: AssetLogAction.BORROW,
|
||||
note: borrowDto.reason,
|
||||
});
|
||||
await queryRunner.manager.save(AssetLog, log);
|
||||
|
||||
await queryRunner.commitTransaction();
|
||||
return { message: '借用成功' };
|
||||
} catch (error) {
|
||||
await queryRunner.rollbackTransaction();
|
||||
throw error;
|
||||
} finally {
|
||||
await queryRunner.release();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 归还资产(使用事务确保数据一致性)
|
||||
*/
|
||||
async return(userId: string, id: string, note?: string) {
|
||||
// 使用事务确保数据一致性
|
||||
const queryRunner = this.dataSource.createQueryRunner();
|
||||
await queryRunner.connect();
|
||||
await queryRunner.startTransaction();
|
||||
|
||||
try {
|
||||
// 使用悲观锁防止并发问题
|
||||
const asset = await queryRunner.manager.findOne(Asset, {
|
||||
where: { id },
|
||||
lock: { mode: 'pessimistic_write' },
|
||||
});
|
||||
|
||||
if (!asset) {
|
||||
throw new NotFoundException({
|
||||
code: ErrorCode.ASSET_NOT_FOUND,
|
||||
message: '资产不存在',
|
||||
});
|
||||
}
|
||||
|
||||
if (asset.currentBorrowerId !== userId) {
|
||||
throw new ForbiddenException({
|
||||
code: ErrorCode.NO_PERMISSION,
|
||||
message: '无权归还此资产',
|
||||
});
|
||||
}
|
||||
|
||||
// 更新资产状态
|
||||
asset.status = AssetStatus.AVAILABLE;
|
||||
asset.currentBorrowerId = null;
|
||||
await queryRunner.manager.save(Asset, asset);
|
||||
|
||||
// 记录日志
|
||||
const log = queryRunner.manager.create(AssetLog, {
|
||||
assetId: id,
|
||||
userId,
|
||||
action: AssetLogAction.RETURN,
|
||||
note,
|
||||
});
|
||||
await queryRunner.manager.save(AssetLog, log);
|
||||
|
||||
await queryRunner.commitTransaction();
|
||||
return { message: '归还成功' };
|
||||
} catch (error) {
|
||||
await queryRunner.rollbackTransaction();
|
||||
throw error;
|
||||
} finally {
|
||||
await queryRunner.release();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取资产借还记录
|
||||
*/
|
||||
async getLogs(id: string) {
|
||||
const asset = await this.assetRepository.findOne({ where: { id } });
|
||||
|
||||
if (!asset) {
|
||||
throw new NotFoundException({
|
||||
code: ErrorCode.ASSET_NOT_FOUND,
|
||||
message: '资产不存在',
|
||||
});
|
||||
}
|
||||
|
||||
const logs = await this.assetLogRepository.find({
|
||||
where: { assetId: id },
|
||||
relations: ['user'],
|
||||
order: { createdAt: 'DESC' },
|
||||
});
|
||||
|
||||
return logs;
|
||||
}
|
||||
|
||||
/**
|
||||
* 删除资产
|
||||
*/
|
||||
async remove(userId: string, id: string) {
|
||||
const asset = await this.assetRepository.findOne({ where: { id } });
|
||||
|
||||
if (!asset) {
|
||||
throw new NotFoundException({
|
||||
code: ErrorCode.ASSET_NOT_FOUND,
|
||||
message: '资产不存在',
|
||||
});
|
||||
}
|
||||
|
||||
// 验证权限
|
||||
const membership = await this.groupMemberRepository.findOne({
|
||||
where: { groupId: asset.groupId, userId },
|
||||
});
|
||||
|
||||
if (!membership || (membership.role !== GroupMemberRole.ADMIN && membership.role !== GroupMemberRole.OWNER)) {
|
||||
throw new ForbiddenException({
|
||||
code: ErrorCode.NO_PERMISSION,
|
||||
message: ErrorMessage[ErrorCode.NO_PERMISSION],
|
||||
});
|
||||
}
|
||||
|
||||
await this.assetRepository.remove(asset);
|
||||
|
||||
return { message: '删除成功' };
|
||||
}
|
||||
}
|
||||
84
src/modules/assets/dto/asset.dto.ts
Normal file
84
src/modules/assets/dto/asset.dto.ts
Normal file
@@ -0,0 +1,84 @@
|
||||
import {
|
||||
IsString,
|
||||
IsNotEmpty,
|
||||
IsOptional,
|
||||
IsNumber,
|
||||
IsEnum,
|
||||
Min,
|
||||
} from 'class-validator';
|
||||
import { ApiProperty } from '@nestjs/swagger';
|
||||
import { AssetType, AssetStatus } from '../../../common/enums';
|
||||
|
||||
export class CreateAssetDto {
|
||||
@ApiProperty({ description: '小组ID' })
|
||||
@IsString()
|
||||
@IsNotEmpty({ message: '小组ID不能为空' })
|
||||
groupId: string;
|
||||
|
||||
@ApiProperty({ description: '资产类型', enum: AssetType })
|
||||
@IsEnum(AssetType)
|
||||
type: AssetType;
|
||||
|
||||
@ApiProperty({ description: '资产名称', example: '公用游戏账号' })
|
||||
@IsString()
|
||||
@IsNotEmpty({ message: '名称不能为空' })
|
||||
name: string;
|
||||
|
||||
@ApiProperty({ description: '描述', required: false })
|
||||
@IsString()
|
||||
@IsOptional()
|
||||
description?: string;
|
||||
|
||||
@ApiProperty({ description: '账号凭据(将加密存储)', required: false })
|
||||
@IsString()
|
||||
@IsOptional()
|
||||
accountCredentials?: string;
|
||||
|
||||
@ApiProperty({ description: '数量', example: 1, required: false })
|
||||
@IsNumber()
|
||||
@Min(1)
|
||||
@IsOptional()
|
||||
quantity?: number;
|
||||
}
|
||||
|
||||
export class UpdateAssetDto {
|
||||
@ApiProperty({ description: '资产名称', required: false })
|
||||
@IsString()
|
||||
@IsOptional()
|
||||
name?: string;
|
||||
|
||||
@ApiProperty({ description: '描述', required: false })
|
||||
@IsString()
|
||||
@IsOptional()
|
||||
description?: string;
|
||||
|
||||
@ApiProperty({ description: '账号凭据', required: false })
|
||||
@IsString()
|
||||
@IsOptional()
|
||||
accountCredentials?: string;
|
||||
|
||||
@ApiProperty({ description: '数量', required: false })
|
||||
@IsNumber()
|
||||
@Min(1)
|
||||
@IsOptional()
|
||||
quantity?: number;
|
||||
|
||||
@ApiProperty({ description: '状态', enum: AssetStatus, required: false })
|
||||
@IsEnum(AssetStatus)
|
||||
@IsOptional()
|
||||
status?: AssetStatus;
|
||||
}
|
||||
|
||||
export class BorrowAssetDto {
|
||||
@ApiProperty({ description: '借用理由', required: false })
|
||||
@IsString()
|
||||
@IsOptional()
|
||||
reason?: string;
|
||||
}
|
||||
|
||||
export class ReturnAssetDto {
|
||||
@ApiProperty({ description: '归还备注', required: false })
|
||||
@IsString()
|
||||
@IsOptional()
|
||||
note?: string;
|
||||
}
|
||||
Reference in New Issue
Block a user