初始化游戏小组管理系统后端项目

- 基于 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:
UGREEN USER
2026-01-28 10:42:06 +08:00
commit b25aa5b143
134 changed files with 30536 additions and 0 deletions

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

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

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

View 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: '删除成功' };
}
}

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