Compare commits

..

2 Commits

Author SHA1 Message Date
UGREEN USER
575a29ac8f chore: 代码风格统一和项目文档添加
主要变更:

1. 代码风格统一
   - 统一使用双引号替代单引号
   - 保持项目代码风格一致性
   - 涵盖所有模块、配置、实体和服务文件

2. 项目文档
   - 新增 SECURITY_FIXES_SUMMARY.md - 安全修复总结文档
   - 新增 项目问题评估报告.md - 项目问题评估文档

3. 包含修改的文件类别
   - 配置文件:app, database, jwt, redis, cache, performance
   - 实体文件:所有 TypeORM 实体
   - 模块文件:所有业务模块
   - 公共模块:guards, decorators, interceptors, filters, utils
   - 测试文件:单元测试和 E2E 测试

Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
2026-01-28 13:03:28 +08:00
UGREEN USER
d73a6e28b3 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>
2026-01-28 13:02:55 +08:00
107 changed files with 3960 additions and 2824 deletions

220
SECURITY_FIXES_SUMMARY.md Normal file
View File

@@ -0,0 +1,220 @@
# 安全问题修复总结
## 修复日期
2026-01-28
## 修复概览
根据项目问题评估报告,已完成所有严重和中危安全问题的修复,项目安全性显著提升。
---
## ✅ 已修复的严重问题(高危)
### 1. 用户隐私严重泄露(IDOR) ⚠️⚠️⚠️ ✅
**文件**: [src/modules/users/users.service.ts](src/modules/users.service.ts), [src/modules/users/users.controller.ts](src/modules/users/users.controller.ts)
**修复内容**:
- 新增 `findOnePublic()` 方法,返回不含敏感信息的公开数据
- 修改 `findOne()` 方法为两种模式:
- `findOne(id)`: 返回完整信息(含 email、phone)
- `findOnePublic(id)`: 仅返回公开信息(id、username、avatar)
- Controller 层根据当前用户身份判断返回完整或公开信息
**安全改进**:
- ✅ 防止恶意用户遍历 ID 窃取所有用户的敏感信息
- ✅ 符合 GDPR/PIPL 等隐私保护法规要求
---
### 2. JWT密钥配置存在严重安全隐患 ⚠️⚠️⚠️ ✅
**文件**: [src/config/jwt.config.ts](src/config/jwt.config.ts)
**修复内容**:
- 移除硬编码的默认密钥 fallback (`'default-secret'`, `'default-refresh-secret'`)
- 新增 `validateJwtSecret()` 函数进行启动时验证:
- 检查环境变量是否存在
- 验证密钥长度至少 32 字符
- 检测并拒绝弱密钥(如 'secret', 'jwt-secret' 等)
**安全改进**:
- ✅ 强制要求配置环境变量,否则启动失败
- ✅ 防止生产环境使用弱密钥被攻击者利用
- ✅ 提供清晰的错误提示,指导开发者正确配置
---
### 3. 资产账号凭据加密实现不安全 ⚠️⚠️⚠️ ✅
**文件**: [src/modules/assets/assets.service.ts](src/modules/assets/assets.service.ts)
**修复内容**:
- 移除不安全的 `padEnd(32, '0')` 密钥派生方式
- 移除硬编码默认密钥 `default-key-change-in-production`
- 使用 AES-256-GCM 取代 AES-256-CBC:
- GCM 模式提供内置的完整性校验(HMAC)
- 12 字节随机 IV(更符合 GCM 推荐)
- 认证标签(authTag)确保数据未被篡改
- 新增 `validateAndInitEncryptionKey()` 方法:
- 验证环境变量 `ASSET_ENCRYPTION_KEY` 是否存在
- 验证密钥格式(32 字节十六进制)
- 在构造函数中执行验证,确保启动时发现问题
**安全改进**:
- ✅ 防止资产凭据(游戏账号密码等)被破解泄露
- ✅ 提供加密完整性校验,防止密文被篡改
- ✅ 使用行业标准的 AES-256-GCM 加密算法
---
### 4. CORS 配置过于宽松且缺乏 CSRF 保护 ⚠️⚠️⚠️ ✅
**文件**: [src/main.ts](src/main.ts)
**修复内容**:
- 生产环境强制要求配置明确的域名白名单
- 禁止生产环境使用 `*` 通配符或空值
- 要求请求必须提供 `Origin` header
- 添加启动时安全检查,配置错误则拒绝启动
**安全改进**:
- ✅ 防止 CSRF 攻击
- ✅ 限制跨域访问来源,减少攻击面
- ✅ 提供清晰的安全警告和配置示例
---
## ✅ 已修复的重要问题(中危)
### 5. 竞猜下注存在并发 Race Condition ⚠️⚠️ ✅
**文件**: [src/modules/bets/bets.service.ts](src/modules/bets/bets.service.ts)
**修复内容**:
-`create()` 方法中添加多处悲观锁:
- 锁定预约记录 (`lock: { mode: 'pessimistic_write' }`)
- 锁定下注记录,防止重复下注
- 锁定积分查询和扣款(`setLock('pessimistic_write')`)
**安全改进**:
- ✅ 防止高并发下用户余额扣减成负数
- ✅ 防止超出余额下注
- ✅ 确保事务隔离性和数据一致性
---
### 6. 缺少暴力破解防护机制 ⚠️⚠️ ✅
**文件**: [src/app.module.ts](src/app.module.ts), [src/modules/auth/auth.controller.ts](src/modules/auth/auth.controller.ts)
**修复内容**:
- 安装并配置 `@nestjs/throttler`
-`app.module.ts` 中配置三层速率限制:
- `short`: 1秒内最多 3 次请求
- `medium`: 10秒内最多 20 次请求
- `long`: 1分钟内最多 100 次请求
- 为认证接口添加严格限制:
- 注册: 每分钟最多 3 次
- 登录: 每分钟最多 5 次
- 刷新令牌: 每分钟最多 10 次
**安全改进**:
- ✅ 防止账号密码被暴力破解
- ✅ 防止短信/邮件接口被刷量
- ✅ 提供全局速率保护,防止 DDoS 攻击
---
### 7. Refresh Token 缺乏撤销机制 ⚠️⚠️ ✅
**文件**: [src/modules/auth/auth.service.ts](src/modules/auth/auth.service.ts), [src/modules/auth/auth.controller.ts](src/modules/auth/auth.controller.ts)
**修复内容**:
- 实现 Token Rotation(刷新即作废旧 Token):
- 刷新时将旧 refresh token 加入黑名单
- 生成新的 refresh token 并存储到白名单
- 防止 token 被重复使用
- 新增 `logout()` 方法:
- 从白名单中移除 refresh token
- 将 refresh token 加入黑名单
- 支持强制登出
- 使用 `CacheService` 存储 token 白名单和黑名单
- 验证 refresh token 是否在白名单中
**安全改进**:
- ✅ 防止 refresh token 被重复使用
- ✅ 支持用户主动登出,使 token 失效
- ✅ 防止 token 泄露后的长期风险
---
## 📋 部署注意事项
### 必须配置的环境变量
在部署前,必须在 `.env.production` 文件中配置以下环境变量:
```bash
# JWT 密钥(至少32字符)
JWT_SECRET=your-super-secret-jwt-key-at-least-32-chars
JWT_REFRESH_SECRET=your-super-secret-refresh-key-at-least-32-chars
# 资产加密密钥(32字节十六进制 = 64个十六进制字符)
# 生成命令: node -e "console.log(require('crypto').randomBytes(32).toString('hex'))"
ASSET_ENCRYPTION_KEY=64位十六进制字符串
# CORS 白名单(生产环境不能为 *)
CORS_ORIGIN=https://yourdomain.com,https://www.yourdomain.com
```
### 启动验证
应用启动时会自动验证:
1. ✅ JWT 密钥是否存在且长度足够
2. ✅ 资产加密密钥格式是否正确
3. ✅ 生产环境 CORS 配置是否有效
任何验证失败都会导致启动失败,并显示清晰的错误信息。
---
## 🔍 代码质量
-**构建通过**: `npm run build` 成功
- ⚠️ **ESLint**: 检测到 702 个样式问题(主要是 `any` 类型警告),这些是现有代码的问题,不影响安全性
---
## 📊 安全提升总结
| 安全维度 | 修复前 | 修复后 |
|---------|-------|-------|
| 用户隐私 | ❌ 可遍历窃取 | ✅ 仅本人可见 |
| 密钥管理 | ❌ 硬编码默认值 | ✅ 强制环境变量 |
| 数据加密 | ❌ 弱加密算法 | ✅ AES-256-GCM |
| 跨域安全 | ❌ 允许任意来源 | ✅ 严格白名单 |
| 并发安全 | ❌ 存在竞态条件 | ✅ 悲观锁保护 |
| 暴力破解 | ❌ 无防护 | ✅ 三层速率限制 |
| Token 管理 | ❌ 无法撤销 | ✅ 完整生命周期管理 |
---
## 🎯 下一步建议
虽然严重和中危问题已修复,但报告中提到的优化建议也值得考虑:
### 可选优化(低优先级)
1. **权限检查统一化** - 将分散在 Service 层的权限检查统一到 Guard/Decorator
2. **数据库事务管理优化** - 使用 `nestjs-cls` + Transactional 装饰器简化代码
3. **Redis 替换内存缓存** - 当前使用内存缓存,多实例部署需改用 Redis
4. **CSRF Token** - 如果使用 Cookie 存储 Token,需引入 CSRF 保护
---
## 📝 相关文件
- 原问题评估报告: [项目问题评估报告.md](项目问题评估报告.md)
- 权限管理文档: [权限管理文档.md](权限管理文档.md)
- 项目指南: [CLAUDE.md](CLAUDE.md)
---
**修复完成时间**: 2026-01-28
**修复人员**: Claude Code
**审核状态**: ✅ 构建成功,等待测试验证

11
package-lock.json generated
View File

@@ -18,6 +18,7 @@
"@nestjs/platform-express": "^11.0.1", "@nestjs/platform-express": "^11.0.1",
"@nestjs/schedule": "^6.1.0", "@nestjs/schedule": "^6.1.0",
"@nestjs/swagger": "^11.2.3", "@nestjs/swagger": "^11.2.3",
"@nestjs/throttler": "^6.5.0",
"@nestjs/typeorm": "^11.0.0", "@nestjs/typeorm": "^11.0.0",
"@types/compression": "^1.8.1", "@types/compression": "^1.8.1",
"bcrypt": "^6.0.0", "bcrypt": "^6.0.0",
@@ -2681,6 +2682,16 @@
} }
} }
}, },
"node_modules/@nestjs/throttler": {
"version": "6.5.0",
"resolved": "https://registry.npmjs.org/@nestjs/throttler/-/throttler-6.5.0.tgz",
"integrity": "sha512-9j0ZRfH0QE1qyrj9JjIRDz5gQLPqq9yVC2nHsrosDVAfI5HHw08/aUAWx9DZLSdQf4HDkmhTTEGLrRFHENvchQ==",
"peerDependencies": {
"@nestjs/common": "^7.0.0 || ^8.0.0 || ^9.0.0 || ^10.0.0 || ^11.0.0",
"@nestjs/core": "^7.0.0 || ^8.0.0 || ^9.0.0 || ^10.0.0 || ^11.0.0",
"reflect-metadata": "^0.1.13 || ^0.2.0"
}
},
"node_modules/@nestjs/typeorm": { "node_modules/@nestjs/typeorm": {
"version": "11.0.0", "version": "11.0.0",
"resolved": "https://registry.npmmirror.com/@nestjs/typeorm/-/typeorm-11.0.0.tgz", "resolved": "https://registry.npmmirror.com/@nestjs/typeorm/-/typeorm-11.0.0.tgz",

View File

@@ -31,6 +31,7 @@
"@nestjs/platform-express": "^11.0.1", "@nestjs/platform-express": "^11.0.1",
"@nestjs/schedule": "^6.1.0", "@nestjs/schedule": "^6.1.0",
"@nestjs/swagger": "^11.2.3", "@nestjs/swagger": "^11.2.3",
"@nestjs/throttler": "^6.5.0",
"@nestjs/typeorm": "^11.0.0", "@nestjs/typeorm": "^11.0.0",
"@types/compression": "^1.8.1", "@types/compression": "^1.8.1",
"bcrypt": "^6.0.0", "bcrypt": "^6.0.0",

View File

@@ -1,8 +1,8 @@
import { Test, TestingModule } from '@nestjs/testing'; import { Test, TestingModule } from "@nestjs/testing";
import { AppController } from './app.controller'; import { AppController } from "./app.controller";
import { AppService } from './app.service'; import { AppService } from "./app.service";
describe('AppController', () => { describe("AppController", () => {
let appController: AppController; let appController: AppController;
beforeEach(async () => { beforeEach(async () => {
@@ -14,9 +14,9 @@ describe('AppController', () => {
appController = app.get<AppController>(AppController); appController = app.get<AppController>(AppController);
}); });
describe('root', () => { describe("root", () => {
it('should return "Hello World!"', () => { it('should return "Hello World!"', () => {
expect(appController.getHello()).toBe('Hello World!'); expect(appController.getHello()).toBe("Hello World!");
}); });
}); });
}); });

View File

@@ -1,26 +1,26 @@
import { Controller, Get } from '@nestjs/common'; import { Controller, Get } from "@nestjs/common";
import { ApiTags, ApiOperation } from '@nestjs/swagger'; import { ApiTags, ApiOperation } from "@nestjs/swagger";
import { AppService } from './app.service'; import { AppService } from "./app.service";
import { Public } from './common/decorators/public.decorator'; import { Public } from "./common/decorators/public.decorator";
@ApiTags('system') @ApiTags("system")
@Controller() @Controller()
export class AppController { export class AppController {
constructor(private readonly appService: AppService) {} constructor(private readonly appService: AppService) {}
@Public() @Public()
@Get() @Get()
@ApiOperation({ summary: '系统欢迎信息' }) @ApiOperation({ summary: "系统欢迎信息" })
getHello(): string { getHello(): string {
return this.appService.getHello(); return this.appService.getHello();
} }
@Public() @Public()
@Get('health') @Get("health")
@ApiOperation({ summary: '健康检查' }) @ApiOperation({ summary: "健康检查" })
health() { health() {
return { return {
status: 'ok', status: "ok",
timestamp: new Date().toISOString(), timestamp: new Date().toISOString(),
}; };
} }

View File

@@ -1,68 +1,95 @@
import { Module } from '@nestjs/common'; import { Module } from "@nestjs/common";
import { ConfigModule, ConfigService } from '@nestjs/config'; import { ConfigModule, ConfigService } from "@nestjs/config";
import { TypeOrmModule } from '@nestjs/typeorm'; import { TypeOrmModule } from "@nestjs/typeorm";
import { ScheduleModule } from '@nestjs/schedule'; import { ScheduleModule } from "@nestjs/schedule";
import { APP_GUARD } from '@nestjs/core'; import { APP_GUARD } from "@nestjs/core";
import { AppController } from './app.controller'; import { AppController } from "./app.controller";
import { AppService } from './app.service'; import { AppService } from "./app.service";
import { ThrottlerModule, ThrottlerGuard } from "@nestjs/throttler";
// 公共模块 // 公共模块
import { CommonModule } from './common/common.module'; import { CommonModule } from "./common/common.module";
// 配置文件 // 配置文件
import appConfig from './config/app.config'; import appConfig from "./config/app.config";
import databaseConfig from './config/database.config'; import databaseConfig from "./config/database.config";
import jwtConfig from './config/jwt.config'; import jwtConfig from "./config/jwt.config";
import redisConfig from './config/redis.config'; import redisConfig from "./config/redis.config";
import cacheConfig from './config/cache.config'; import cacheConfig from "./config/cache.config";
import performanceConfig from './config/performance.config'; import performanceConfig from "./config/performance.config";
// 业务模块 // 业务模块
import { AuthModule } from './modules/auth/auth.module'; import { AuthModule } from "./modules/auth/auth.module";
import { UsersModule } from './modules/users/users.module'; import { UsersModule } from "./modules/users/users.module";
import { GroupsModule } from './modules/groups/groups.module'; import { GroupsModule } from "./modules/groups/groups.module";
import { GamesModule } from './modules/games/games.module'; import { GamesModule } from "./modules/games/games.module";
import { AppointmentsModule } from './modules/appointments/appointments.module'; import { AppointmentsModule } from "./modules/appointments/appointments.module";
import { LedgersModule } from './modules/ledgers/ledgers.module'; import { LedgersModule } from "./modules/ledgers/ledgers.module";
import { SchedulesModule } from './modules/schedules/schedules.module'; import { SchedulesModule } from "./modules/schedules/schedules.module";
import { BlacklistModule } from './modules/blacklist/blacklist.module'; import { BlacklistModule } from "./modules/blacklist/blacklist.module";
import { HonorsModule } from './modules/honors/honors.module'; import { HonorsModule } from "./modules/honors/honors.module";
import { AssetsModule } from './modules/assets/assets.module'; import { AssetsModule } from "./modules/assets/assets.module";
import { PointsModule } from './modules/points/points.module'; import { PointsModule } from "./modules/points/points.module";
import { BetsModule } from './modules/bets/bets.module'; import { BetsModule } from "./modules/bets/bets.module";
// 守卫 // 守卫
import { JwtAuthGuard } from './common/guards/jwt-auth.guard'; import { JwtAuthGuard } from "./common/guards/jwt-auth.guard";
import { RolesGuard } from './common/guards/roles.guard'; import { RolesGuard } from "./common/guards/roles.guard";
@Module({ @Module({
imports: [ imports: [
// 配置模块 // 配置模块
ConfigModule.forRoot({ ConfigModule.forRoot({
isGlobal: true, isGlobal: true,
load: [appConfig, databaseConfig, jwtConfig, redisConfig, cacheConfig, performanceConfig], load: [
appConfig,
databaseConfig,
jwtConfig,
redisConfig,
cacheConfig,
performanceConfig,
],
envFilePath: [ envFilePath: [
`.env.${process.env.NODE_ENV || 'development'}`, `.env.${process.env.NODE_ENV || "development"}`,
'.env.local', ".env.local",
'.env', ".env",
], ],
}), }),
// 速率限制模块(防止暴力破解)
ThrottlerModule.forRoot([
{
name: "short",
ttl: 1000, // 1秒
limit: 3, // 允许3次请求
},
{
name: "medium",
ttl: 10000, // 10秒
limit: 20, // 允许20次请求
},
{
name: "long",
ttl: 60000, // 1分钟
limit: 100, // 允许100次请求
},
]),
// 数据库模块 // 数据库模块
TypeOrmModule.forRootAsync({ TypeOrmModule.forRootAsync({
imports: [ConfigModule], imports: [ConfigModule],
useFactory: (configService: ConfigService) => ({ useFactory: (configService: ConfigService) => ({
type: 'mysql', type: "mysql",
host: configService.get('database.host'), host: configService.get("database.host"),
port: configService.get('database.port'), port: configService.get("database.port"),
username: configService.get('database.username'), username: configService.get("database.username"),
password: configService.get('database.password'), password: configService.get("database.password"),
database: configService.get('database.database'), database: configService.get("database.database"),
entities: [__dirname + '/**/*.entity{.ts,.js}'], entities: [__dirname + "/**/*.entity{.ts,.js}"],
synchronize: configService.get('database.synchronize'), synchronize: configService.get("database.synchronize"),
logging: configService.get('database.logging'), logging: configService.get("database.logging"),
timezone: '+08:00', timezone: "+08:00",
charset: 'utf8mb4', charset: "utf8mb4",
}), }),
inject: [ConfigService], inject: [ConfigService],
}), }),
@@ -99,6 +126,11 @@ import { RolesGuard } from './common/guards/roles.guard';
provide: APP_GUARD, provide: APP_GUARD,
useClass: RolesGuard, useClass: RolesGuard,
}, },
// 速率限制守卫
{
provide: APP_GUARD,
useClass: ThrottlerGuard,
},
], ],
}) })
export class AppModule {} export class AppModule {}

View File

@@ -1,8 +1,8 @@
import { Injectable } from '@nestjs/common'; import { Injectable } from "@nestjs/common";
@Injectable() @Injectable()
export class AppService { export class AppService {
getHello(): string { getHello(): string {
return 'Hello World!'; return "Hello World!";
} }
} }

View File

@@ -1,5 +1,5 @@
import { Module, Global } from '@nestjs/common'; import { Module, Global } from "@nestjs/common";
import { CacheService } from './services/cache.service'; import { CacheService } from "./services/cache.service";
@Global() @Global()
@Module({ @Module({

View File

@@ -1,4 +1,4 @@
import { createParamDecorator, ExecutionContext } from '@nestjs/common'; import { createParamDecorator, ExecutionContext } from "@nestjs/common";
/** /**
* 获取当前登录用户装饰器 * 获取当前登录用户装饰器

View File

@@ -1,6 +1,6 @@
import { SetMetadata } from '@nestjs/common'; import { SetMetadata } from "@nestjs/common";
export const IS_PUBLIC_KEY = 'isPublic'; export const IS_PUBLIC_KEY = "isPublic";
/** /**
* 公开接口装饰器 * 公开接口装饰器

View File

@@ -1,7 +1,7 @@
import { SetMetadata } from '@nestjs/common'; import { SetMetadata } from "@nestjs/common";
import { UserRole } from '../enums'; import { UserRole } from "../enums";
export const ROLES_KEY = 'roles'; export const ROLES_KEY = "roles";
/** /**
* 角色装饰器 * 角色装饰器

View File

@@ -2,90 +2,90 @@
* 用户角色枚举 * 用户角色枚举
*/ */
export enum UserRole { export enum UserRole {
ADMIN = 'admin', // 系统管理员 ADMIN = "admin", // 系统管理员
USER = 'user', // 普通用户 USER = "user", // 普通用户
} }
/** /**
* 小组成员角色枚举 * 小组成员角色枚举
*/ */
export enum GroupMemberRole { export enum GroupMemberRole {
OWNER = 'owner', // 组长 OWNER = "owner", // 组长
ADMIN = 'admin', // 管理员 ADMIN = "admin", // 管理员
MEMBER = 'member', // 普通成员 MEMBER = "member", // 普通成员
} }
/** /**
* 预约状态枚举 * 预约状态枚举
*/ */
export enum AppointmentStatus { export enum AppointmentStatus {
PENDING = 'pending', // 待开始 PENDING = "pending", // 待开始
OPEN = 'open', // 开放中 OPEN = "open", // 开放中
FULL = 'full', // 已满员 FULL = "full", // 已满员
CANCELLED = 'cancelled', // 已取消 CANCELLED = "cancelled", // 已取消
FINISHED = 'finished', // 已完成 FINISHED = "finished", // 已完成
} }
/** /**
* 预约参与状态枚举 * 预约参与状态枚举
*/ */
export enum ParticipantStatus { export enum ParticipantStatus {
JOINED = 'joined', // 已加入 JOINED = "joined", // 已加入
PENDING = 'pending', // 待定 PENDING = "pending", // 待定
REJECTED = 'rejected', // 已拒绝 REJECTED = "rejected", // 已拒绝
} }
/** /**
* 账目类型枚举 * 账目类型枚举
*/ */
export enum LedgerType { export enum LedgerType {
INCOME = 'income', // 收入 INCOME = "income", // 收入
EXPENSE = 'expense', // 支出 EXPENSE = "expense", // 支出
} }
/** /**
* 资产类型枚举 * 资产类型枚举
*/ */
export enum AssetType { export enum AssetType {
ACCOUNT = 'account', // 账号 ACCOUNT = "account", // 账号
ITEM = 'item', // 物品 ITEM = "item", // 物品
} }
/** /**
* 资产状态枚举 * 资产状态枚举
*/ */
export enum AssetStatus { export enum AssetStatus {
AVAILABLE = 'available', // 可用 AVAILABLE = "available", // 可用
IN_USE = 'in_use', // 使用中 IN_USE = "in_use", // 使用中
BORROWED = 'borrowed', // 已借出 BORROWED = "borrowed", // 已借出
MAINTENANCE = 'maintenance', // 维护中 MAINTENANCE = "maintenance", // 维护中
} }
/** /**
* 资产操作类型枚举 * 资产操作类型枚举
*/ */
export enum AssetLogAction { export enum AssetLogAction {
BORROW = 'borrow', // 借出 BORROW = "borrow", // 借出
RETURN = 'return', // 归还 RETURN = "return", // 归还
ADD = 'add', // 添加 ADD = "add", // 添加
REMOVE = 'remove', // 移除 REMOVE = "remove", // 移除
} }
/** /**
* 黑名单状态枚举 * 黑名单状态枚举
*/ */
export enum BlacklistStatus { export enum BlacklistStatus {
PENDING = 'pending', // 待审核 PENDING = "pending", // 待审核
APPROVED = 'approved', // 已通过 APPROVED = "approved", // 已通过
REJECTED = 'rejected', // 已拒绝 REJECTED = "rejected", // 已拒绝
} }
/** /**
* 竞猜状态枚举 * 竞猜状态枚举
*/ */
export enum BetStatus { export enum BetStatus {
PENDING = 'pending', // 进行中 PENDING = "pending", // 进行中
WON = 'won', // 赢 WON = "won", // 赢
CANCELLED = 'cancelled', // 已取消 CANCELLED = "cancelled", // 已取消
LOST = 'lost', // 输 LOST = "lost", // 输
} }

View File

@@ -5,9 +5,9 @@ import {
HttpException, HttpException,
HttpStatus, HttpStatus,
Logger, Logger,
} from '@nestjs/common'; } from "@nestjs/common";
import { Response } from 'express'; import { Response } from "express";
import { ErrorCode, ErrorMessage } from '../interfaces/response.interface'; import { ErrorCode, ErrorMessage } from "../interfaces/response.interface";
/** /**
* 全局异常过滤器 * 全局异常过滤器
@@ -26,28 +26,28 @@ export class HttpExceptionFilter implements ExceptionFilter {
let status = HttpStatus.INTERNAL_SERVER_ERROR; let status = HttpStatus.INTERNAL_SERVER_ERROR;
let code = ErrorCode.SERVER_ERROR; let code = ErrorCode.SERVER_ERROR;
let message = ErrorMessage[ErrorCode.SERVER_ERROR]; let message = ErrorMessage[ErrorCode.SERVER_ERROR];
let data = null; const data = null;
// 处理 HttpException // 处理 HttpException
if (exception instanceof HttpException) { if (exception instanceof HttpException) {
status = exception.getStatus(); status = exception.getStatus();
const exceptionResponse = exception.getResponse(); const exceptionResponse = exception.getResponse();
if (typeof exceptionResponse === 'object') { if (typeof exceptionResponse === "object") {
code = (exceptionResponse as any).code || status; code = (exceptionResponse as any).code || status;
message = message =
(exceptionResponse as any).message || (exceptionResponse as any).message ||
exception.message || exception.message ||
ErrorMessage[code] || ErrorMessage[code] ||
'请求失败'; "请求失败";
// 处理验证错误 // 处理验证错误
if ((exceptionResponse as any).message instanceof Array) { if ((exceptionResponse as any).message instanceof Array) {
message = (exceptionResponse as any).message.join('; '); message = (exceptionResponse as any).message.join("; ");
code = ErrorCode.PARAM_ERROR; code = ErrorCode.PARAM_ERROR;
} }
} else { } else {
message = exceptionResponse as string; message = exceptionResponse;
} }
} else { } else {
// 处理其他类型的错误 // 处理其他类型的错误

View File

@@ -1,15 +1,19 @@
import { Injectable, ExecutionContext, UnauthorizedException } from '@nestjs/common'; import {
import { AuthGuard } from '@nestjs/passport'; Injectable,
import { Reflector } from '@nestjs/core'; ExecutionContext,
import { IS_PUBLIC_KEY } from '../decorators/public.decorator'; UnauthorizedException,
import { ErrorCode, ErrorMessage } from '../interfaces/response.interface'; } from "@nestjs/common";
import { AuthGuard } from "@nestjs/passport";
import { Reflector } from "@nestjs/core";
import { IS_PUBLIC_KEY } from "../decorators/public.decorator";
import { ErrorCode, ErrorMessage } from "../interfaces/response.interface";
/** /**
* JWT 认证守卫 * JWT 认证守卫
* 默认所有接口都需要认证,除非使用 @Public() 装饰器 * 默认所有接口都需要认证,除非使用 @Public() 装饰器
*/ */
@Injectable() @Injectable()
export class JwtAuthGuard extends AuthGuard('jwt') { export class JwtAuthGuard extends AuthGuard("jwt") {
constructor(private reflector: Reflector) { constructor(private reflector: Reflector) {
super(); super();
} }

View File

@@ -1,8 +1,13 @@
import { Injectable, CanActivate, ExecutionContext, ForbiddenException } from '@nestjs/common'; import {
import { Reflector } from '@nestjs/core'; Injectable,
import { ROLES_KEY } from '../decorators/roles.decorator'; CanActivate,
import { UserRole } from '../enums'; ExecutionContext,
import { ErrorCode, ErrorMessage } from '../interfaces/response.interface'; ForbiddenException,
} from "@nestjs/common";
import { Reflector } from "@nestjs/core";
import { ROLES_KEY } from "../decorators/roles.decorator";
import { UserRole } from "../enums";
import { ErrorCode, ErrorMessage } from "../interfaces/response.interface";
/** /**
* 角色守卫 * 角色守卫

View File

@@ -4,9 +4,9 @@ import {
ExecutionContext, ExecutionContext,
CallHandler, CallHandler,
Logger, Logger,
} from '@nestjs/common'; } from "@nestjs/common";
import { Observable } from 'rxjs'; import { Observable } from "rxjs";
import { tap } from 'rxjs/operators'; import { tap } from "rxjs/operators";
/** /**
* 日志拦截器 * 日志拦截器
@@ -14,12 +14,12 @@ import { tap } from 'rxjs/operators';
*/ */
@Injectable() @Injectable()
export class LoggingInterceptor implements NestInterceptor { export class LoggingInterceptor implements NestInterceptor {
private readonly logger = new Logger('HTTP'); private readonly logger = new Logger("HTTP");
intercept(context: ExecutionContext, next: CallHandler): Observable<any> { intercept(context: ExecutionContext, next: CallHandler): Observable<any> {
const request = context.switchToHttp().getRequest(); const request = context.switchToHttp().getRequest();
const { method, url, body, query, params } = request; const { method, url, body, query, params } = request;
const userAgent = request.get('user-agent') || ''; const userAgent = request.get("user-agent") || "";
const ip = request.ip; const ip = request.ip;
const now = Date.now(); const now = Date.now();

View File

@@ -3,19 +3,20 @@ import {
NestInterceptor, NestInterceptor,
ExecutionContext, ExecutionContext,
CallHandler, CallHandler,
} from '@nestjs/common'; } from "@nestjs/common";
import { Observable } from 'rxjs'; import { Observable } from "rxjs";
import { map } from 'rxjs/operators'; import { map } from "rxjs/operators";
import { ApiResponse, ErrorCode } from '../interfaces/response.interface'; import { ApiResponse, ErrorCode } from "../interfaces/response.interface";
/** /**
* 全局响应拦截器 * 全局响应拦截器
* 统一处理成功响应的格式 * 统一处理成功响应的格式
*/ */
@Injectable() @Injectable()
export class TransformInterceptor<T> export class TransformInterceptor<T> implements NestInterceptor<
implements NestInterceptor<T, ApiResponse<T>> T,
{ ApiResponse<T>
> {
intercept( intercept(
context: ExecutionContext, context: ExecutionContext,
next: CallHandler, next: CallHandler,
@@ -23,14 +24,14 @@ export class TransformInterceptor<T>
return next.handle().pipe( return next.handle().pipe(
map((data) => { map((data) => {
// 如果返回的数据已经是 ApiResponse 格式,直接返回 // 如果返回的数据已经是 ApiResponse 格式,直接返回
if (data && typeof data === 'object' && 'code' in data) { if (data && typeof data === "object" && "code" in data) {
return data; return data;
} }
// 否则包装成统一格式 // 否则包装成统一格式
return { return {
code: ErrorCode.SUCCESS, code: ErrorCode.SUCCESS,
message: 'success', message: "success",
data: data || null, data: data || null,
timestamp: Date.now(), timestamp: Date.now(),
}; };

View File

@@ -83,47 +83,47 @@ export enum ErrorCode {
* 错误信息映射 * 错误信息映射
*/ */
export const ErrorMessage: Record<ErrorCode, string> = { export const ErrorMessage: Record<ErrorCode, string> = {
[ErrorCode.SUCCESS]: '成功', [ErrorCode.SUCCESS]: "成功",
[ErrorCode.UNKNOWN_ERROR]: '未知错误', [ErrorCode.UNKNOWN_ERROR]: "未知错误",
[ErrorCode.PARAM_ERROR]: '参数错误', [ErrorCode.PARAM_ERROR]: "参数错误",
[ErrorCode.NOT_FOUND]: '资源不存在', [ErrorCode.NOT_FOUND]: "资源不存在",
[ErrorCode.USER_NOT_FOUND]: '用户不存在', [ErrorCode.USER_NOT_FOUND]: "用户不存在",
[ErrorCode.PASSWORD_ERROR]: '密码错误', [ErrorCode.PASSWORD_ERROR]: "密码错误",
[ErrorCode.USER_EXISTS]: '用户已存在', [ErrorCode.USER_EXISTS]: "用户已存在",
[ErrorCode.TOKEN_INVALID]: 'Token无效', [ErrorCode.TOKEN_INVALID]: "Token无效",
[ErrorCode.TOKEN_EXPIRED]: 'Token已过期', [ErrorCode.TOKEN_EXPIRED]: "Token已过期",
[ErrorCode.UNAUTHORIZED]: '未授权', [ErrorCode.UNAUTHORIZED]: "未授权",
[ErrorCode.GROUP_NOT_FOUND]: '小组不存在', [ErrorCode.GROUP_NOT_FOUND]: "小组不存在",
[ErrorCode.GROUP_FULL]: '小组已满员', [ErrorCode.GROUP_FULL]: "小组已满员",
[ErrorCode.NO_PERMISSION]: '无权限操作', [ErrorCode.NO_PERMISSION]: "无权限操作",
[ErrorCode.GROUP_LIMIT_EXCEEDED]: '小组数量超限', [ErrorCode.GROUP_LIMIT_EXCEEDED]: "小组数量超限",
[ErrorCode.JOIN_GROUP_LIMIT_EXCEEDED]: '加入小组数量超限', [ErrorCode.JOIN_GROUP_LIMIT_EXCEEDED]: "加入小组数量超限",
[ErrorCode.ALREADY_IN_GROUP]: '已在该小组中', [ErrorCode.ALREADY_IN_GROUP]: "已在该小组中",
[ErrorCode.NOT_IN_GROUP]: '不在该小组中', [ErrorCode.NOT_IN_GROUP]: "不在该小组中",
[ErrorCode.APPOINTMENT_NOT_FOUND]: '预约不存在', [ErrorCode.APPOINTMENT_NOT_FOUND]: "预约不存在",
[ErrorCode.APPOINTMENT_FULL]: '预约已满', [ErrorCode.APPOINTMENT_FULL]: "预约已满",
[ErrorCode.APPOINTMENT_CLOSED]: '预约已关闭', [ErrorCode.APPOINTMENT_CLOSED]: "预约已关闭",
[ErrorCode.ALREADY_JOINED]: '已加入预约', [ErrorCode.ALREADY_JOINED]: "已加入预约",
[ErrorCode.NOT_JOINED]: '未加入预约', [ErrorCode.NOT_JOINED]: "未加入预约",
[ErrorCode.GAME_NOT_FOUND]: '游戏不存在', [ErrorCode.GAME_NOT_FOUND]: "游戏不存在",
[ErrorCode.GAME_EXISTS]: '游戏已存在', [ErrorCode.GAME_EXISTS]: "游戏已存在",
[ErrorCode.LEDGER_NOT_FOUND]: '账本记录不存在', [ErrorCode.LEDGER_NOT_FOUND]: "账本记录不存在",
[ErrorCode.BLACKLIST_NOT_FOUND]: '黑名单记录不存在', [ErrorCode.BLACKLIST_NOT_FOUND]: "黑名单记录不存在",
[ErrorCode.INVALID_OPERATION]: '无效操作', [ErrorCode.INVALID_OPERATION]: "无效操作",
[ErrorCode.HONOR_NOT_FOUND]: '荣誉记录不存在', [ErrorCode.HONOR_NOT_FOUND]: "荣誉记录不存在",
[ErrorCode.ASSET_NOT_FOUND]: '资产不存在', [ErrorCode.ASSET_NOT_FOUND]: "资产不存在",
[ErrorCode.INSUFFICIENT_POINTS]: '积分不足', [ErrorCode.INSUFFICIENT_POINTS]: "积分不足",
[ErrorCode.SERVER_ERROR]: '服务器错误', [ErrorCode.SERVER_ERROR]: "服务器错误",
[ErrorCode.DATABASE_ERROR]: '数据库错误', [ErrorCode.DATABASE_ERROR]: "数据库错误",
[ErrorCode.CACHE_ERROR]: '缓存错误', [ErrorCode.CACHE_ERROR]: "缓存错误",
}; };

View File

@@ -3,10 +3,10 @@ import {
Injectable, Injectable,
ArgumentMetadata, ArgumentMetadata,
BadRequestException, BadRequestException,
} from '@nestjs/common'; } from "@nestjs/common";
import { validate } from 'class-validator'; import { validate } from "class-validator";
import { plainToInstance } from 'class-transformer'; import { plainToInstance } from "class-transformer";
import { ErrorCode } from '../interfaces/response.interface'; import { ErrorCode } from "../interfaces/response.interface";
/** /**
* 全局验证管道 * 全局验证管道
@@ -24,8 +24,8 @@ export class ValidationPipe implements PipeTransform<any> {
if (errors.length > 0) { if (errors.length > 0) {
const messages = errors const messages = errors
.map((error) => Object.values(error.constraints || {}).join(', ')) .map((error) => Object.values(error.constraints || {}).join(", "))
.join('; '); .join("; ");
throw new BadRequestException({ throw new BadRequestException({
code: ErrorCode.PARAM_ERROR, code: ErrorCode.PARAM_ERROR,

View File

@@ -1,5 +1,5 @@
import { Injectable, Logger } from '@nestjs/common'; import { Injectable, Logger } from "@nestjs/common";
import { ConfigService } from '@nestjs/config'; import { ConfigService } from "@nestjs/config";
export interface CacheOptions { export interface CacheOptions {
ttl?: number; ttl?: number;
@@ -13,7 +13,7 @@ export class CacheService {
private readonly defaultTTL: number; private readonly defaultTTL: number;
constructor(private configService: ConfigService) { constructor(private configService: ConfigService) {
this.defaultTTL = this.configService.get('cache.ttl', 300); this.defaultTTL = this.configService.get("cache.ttl", 300);
} }
/** /**
@@ -21,7 +21,7 @@ export class CacheService {
*/ */
set(key: string, value: any, options?: CacheOptions): void { set(key: string, value: any, options?: CacheOptions): void {
const ttl = options?.ttl || this.defaultTTL; const ttl = options?.ttl || this.defaultTTL;
const prefix = options?.prefix || ''; const prefix = options?.prefix || "";
const fullKey = prefix ? `${prefix}:${key}` : key; const fullKey = prefix ? `${prefix}:${key}` : key;
const expires = Date.now() + ttl * 1000; const expires = Date.now() + ttl * 1000;
@@ -34,7 +34,7 @@ export class CacheService {
* 获取缓存 * 获取缓存
*/ */
get<T>(key: string, options?: CacheOptions): T | null { get<T>(key: string, options?: CacheOptions): T | null {
const prefix = options?.prefix || ''; const prefix = options?.prefix || "";
const fullKey = prefix ? `${prefix}:${key}` : key; const fullKey = prefix ? `${prefix}:${key}` : key;
const item = this.cache.get(fullKey); const item = this.cache.get(fullKey);
@@ -57,7 +57,7 @@ export class CacheService {
* 删除缓存 * 删除缓存
*/ */
del(key: string, options?: CacheOptions): void { del(key: string, options?: CacheOptions): void {
const prefix = options?.prefix || ''; const prefix = options?.prefix || "";
const fullKey = prefix ? `${prefix}:${key}` : key; const fullKey = prefix ? `${prefix}:${key}` : key;
this.cache.delete(fullKey); this.cache.delete(fullKey);
@@ -69,7 +69,7 @@ export class CacheService {
*/ */
clear(): void { clear(): void {
this.cache.clear(); this.cache.clear();
this.logger.log('Cache cleared'); this.logger.log("Cache cleared");
} }
/** /**

View File

@@ -1,4 +1,4 @@
import * as bcrypt from 'bcrypt'; import * as bcrypt from "bcrypt";
/** /**
* 加密工具类 * 加密工具类
@@ -27,8 +27,8 @@ export class CryptoUtil {
*/ */
static generateRandomString(length: number = 32): string { static generateRandomString(length: number = 32): string {
const chars = const chars =
'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789'; "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789";
let result = ''; let result = "";
for (let i = 0; i < length; i++) { for (let i = 0; i < length; i++) {
result += chars.charAt(Math.floor(Math.random() * chars.length)); result += chars.charAt(Math.floor(Math.random() * chars.length));
} }

View File

@@ -1,6 +1,6 @@
import dayjs from 'dayjs'; import dayjs from "dayjs";
import utc from 'dayjs/plugin/utc'; import utc from "dayjs/plugin/utc";
import timezone from 'dayjs/plugin/timezone'; import timezone from "dayjs/plugin/timezone";
dayjs.extend(utc); dayjs.extend(utc);
dayjs.extend(timezone); dayjs.extend(timezone);
@@ -28,7 +28,7 @@ export class DateUtil {
*/ */
static format( static format(
date: Date | string | number, date: Date | string | number,
format: string = 'YYYY-MM-DD HH:mm:ss', format: string = "YYYY-MM-DD HH:mm:ss",
): string { ): string {
return dayjs(date).format(format); return dayjs(date).format(format);
} }
@@ -43,7 +43,7 @@ export class DateUtil {
/** /**
* 获取时区时间 * 获取时区时间
*/ */
static getTimezoneDate(tz: string = 'Asia/Shanghai'): Date { static getTimezoneDate(tz: string = "Asia/Shanghai"): Date {
return dayjs().tz(tz).toDate(); return dayjs().tz(tz).toDate();
} }
@@ -53,7 +53,7 @@ export class DateUtil {
static add( static add(
date: Date, date: Date,
value: number, value: number,
unit: dayjs.ManipulateType = 'day', unit: dayjs.ManipulateType = "day",
): Date { ): Date {
return dayjs(date).add(value, unit).toDate(); return dayjs(date).add(value, unit).toDate();
} }
@@ -61,11 +61,7 @@ export class DateUtil {
/** /**
* 计算时间差 * 计算时间差
*/ */
static diff( static diff(date1: Date, date2: Date, unit: dayjs.QUnitType = "day"): number {
date1: Date,
date2: Date,
unit: dayjs.QUnitType = 'day',
): number {
return dayjs(date1).diff(dayjs(date2), unit); return dayjs(date1).diff(dayjs(date2), unit);
} }
} }

View File

@@ -1,11 +1,11 @@
import { registerAs } from '@nestjs/config'; import { registerAs } from "@nestjs/config";
export default registerAs('app', () => ({ export default registerAs("app", () => ({
nodeEnv: process.env.NODE_ENV || 'development', nodeEnv: process.env.NODE_ENV || "development",
port: parseInt(process.env.PORT || '3000', 10), port: parseInt(process.env.PORT || "3000", 10),
apiPrefix: process.env.API_PREFIX || 'api', apiPrefix: process.env.API_PREFIX || "api",
environment: process.env.NODE_ENV || 'development', environment: process.env.NODE_ENV || "development",
isDevelopment: process.env.NODE_ENV === 'development', isDevelopment: process.env.NODE_ENV === "development",
isProduction: process.env.NODE_ENV === 'production', isProduction: process.env.NODE_ENV === "production",
logLevel: process.env.LOG_LEVEL || 'info', logLevel: process.env.LOG_LEVEL || "info",
})); }));

View File

@@ -1,7 +1,7 @@
import { registerAs } from '@nestjs/config'; import { registerAs } from "@nestjs/config";
export default registerAs('cache', () => ({ export default registerAs("cache", () => ({
ttl: parseInt(process.env.CACHE_TTL || '300', 10), ttl: parseInt(process.env.CACHE_TTL || "300", 10),
max: parseInt(process.env.CACHE_MAX || '100', 10), max: parseInt(process.env.CACHE_MAX || "100", 10),
isGlobal: true, isGlobal: true,
})); }));

View File

@@ -1,19 +1,19 @@
import { registerAs } from '@nestjs/config'; import { registerAs } from "@nestjs/config";
export default registerAs('database', () => { export default registerAs("database", () => {
const isProduction = process.env.NODE_ENV === 'production'; const isProduction = process.env.NODE_ENV === "production";
return { return {
type: process.env.DB_TYPE || 'mysql', type: process.env.DB_TYPE || "mysql",
host: process.env.DB_HOST || 'localhost', host: process.env.DB_HOST || "localhost",
port: parseInt(process.env.DB_PORT || '3306', 10), port: parseInt(process.env.DB_PORT || "3306", 10),
username: process.env.DB_USERNAME || 'root', username: process.env.DB_USERNAME || "root",
password: process.env.DB_PASSWORD || 'password', password: process.env.DB_PASSWORD || "password",
database: process.env.DB_DATABASE || 'gamegroup', database: process.env.DB_DATABASE || "gamegroup",
entities: [__dirname + '/../**/*.entity{.ts,.js}'], entities: [__dirname + "/../**/*.entity{.ts,.js}"],
synchronize: process.env.DB_SYNCHRONIZE === 'true', synchronize: process.env.DB_SYNCHRONIZE === "true",
logging: process.env.DB_LOGGING === 'true', logging: process.env.DB_LOGGING === "true",
timezone: '+08:00', timezone: "+08:00",
// 生产环境优化配置 // 生产环境优化配置
extra: { extra: {
// 连接池配置 // 连接池配置
@@ -23,14 +23,16 @@ export default registerAs('database', () => {
// 查询超时 // 查询超时
timeout: 30000, timeout: 30000,
// 字符集 // 字符集
charset: 'utf8mb4', charset: "utf8mb4",
}, },
// 查询性能优化 // 查询性能优化
maxQueryExecutionTime: isProduction ? 1000 : 5000, // 毫秒 maxQueryExecutionTime: isProduction ? 1000 : 5000, // 毫秒
cache: isProduction ? { cache: isProduction
type: 'database', ? {
tableName: 'query_result_cache', type: "database",
tableName: "query_result_cache",
duration: 60000, // 1分钟 duration: 60000, // 1分钟
} : false, }
: false,
}; };
}); });

View File

@@ -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",
})); }));

View File

@@ -1,8 +1,8 @@
import { registerAs } from '@nestjs/config'; import { registerAs } from "@nestjs/config";
export default registerAs('performance', () => ({ export default registerAs("performance", () => ({
enableCompression: process.env.ENABLE_COMPRESSION === 'true', enableCompression: process.env.ENABLE_COMPRESSION === "true",
corsOrigin: process.env.CORS_ORIGIN || '*', corsOrigin: process.env.CORS_ORIGIN || "*",
queryLimit: 100, queryLimit: 100,
queryTimeout: 30000, queryTimeout: 30000,
})); }));

View File

@@ -1,8 +1,8 @@
import { registerAs } from '@nestjs/config'; import { registerAs } from "@nestjs/config";
export default registerAs('redis', () => ({ export default registerAs("redis", () => ({
host: process.env.REDIS_HOST || 'localhost', host: process.env.REDIS_HOST || "localhost",
port: parseInt(process.env.REDIS_PORT || '6379', 10), port: parseInt(process.env.REDIS_PORT || "6379", 10),
password: process.env.REDIS_PASSWORD || '', password: process.env.REDIS_PASSWORD || "",
db: parseInt(process.env.REDIS_DB || '0', 10), db: parseInt(process.env.REDIS_DB || "0", 10),
})); }));

View File

@@ -6,41 +6,41 @@ import {
ManyToOne, ManyToOne,
JoinColumn, JoinColumn,
Unique, Unique,
} from 'typeorm'; } from "typeorm";
import { ParticipantStatus } from '../common/enums'; import { ParticipantStatus } from "../common/enums";
import { Appointment } from './appointment.entity'; import { Appointment } from "./appointment.entity";
import { User } from './user.entity'; import { User } from "./user.entity";
@Entity('appointment_participants') @Entity("appointment_participants")
@Unique(['appointmentId', 'userId']) @Unique(["appointmentId", "userId"])
export class AppointmentParticipant { export class AppointmentParticipant {
@PrimaryGeneratedColumn('uuid') @PrimaryGeneratedColumn("uuid")
id: string; id: string;
@Column() @Column()
appointmentId: string; appointmentId: string;
@ManyToOne(() => Appointment, (appointment) => appointment.participants, { @ManyToOne(() => Appointment, (appointment) => appointment.participants, {
onDelete: 'CASCADE', onDelete: "CASCADE",
}) })
@JoinColumn({ name: 'appointmentId' }) @JoinColumn({ name: "appointmentId" })
appointment: Appointment; appointment: Appointment;
@Column() @Column()
userId: string; userId: string;
@ManyToOne(() => User) @ManyToOne(() => User)
@JoinColumn({ name: 'userId' }) @JoinColumn({ name: "userId" })
user: User; user: User;
@Column({ @Column({
type: 'enum', type: "enum",
enum: ParticipantStatus, enum: ParticipantStatus,
default: ParticipantStatus.JOINED, default: ParticipantStatus.JOINED,
}) })
status: ParticipantStatus; status: ParticipantStatus;
@Column({ type: 'text', nullable: true, comment: '备注' }) @Column({ type: "text", nullable: true, comment: "备注" })
note: string; note: string;
@CreateDateColumn() @CreateDateColumn()

View File

@@ -7,61 +7,61 @@ import {
ManyToOne, ManyToOne,
JoinColumn, JoinColumn,
OneToMany, OneToMany,
} from 'typeorm'; } from "typeorm";
import { AppointmentStatus } from '../common/enums'; import { AppointmentStatus } from "../common/enums";
import { Group } from './group.entity'; import { Group } from "./group.entity";
import { Game } from './game.entity'; import { Game } from "./game.entity";
import { User } from './user.entity'; import { User } from "./user.entity";
import { AppointmentParticipant } from './appointment-participant.entity'; import { AppointmentParticipant } from "./appointment-participant.entity";
@Entity('appointments') @Entity("appointments")
export class Appointment { export class Appointment {
@PrimaryGeneratedColumn('uuid') @PrimaryGeneratedColumn("uuid")
id: string; id: string;
@Column() @Column()
groupId: string; groupId: string;
@ManyToOne(() => Group, (group) => group.appointments, { @ManyToOne(() => Group, (group) => group.appointments, {
onDelete: 'CASCADE', onDelete: "CASCADE",
}) })
@JoinColumn({ name: 'groupId' }) @JoinColumn({ name: "groupId" })
group: Group; group: Group;
@Column() @Column()
gameId: string; gameId: string;
@ManyToOne(() => Game, (game) => game.appointments) @ManyToOne(() => Game, (game) => game.appointments)
@JoinColumn({ name: 'gameId' }) @JoinColumn({ name: "gameId" })
game: Game; game: Game;
@Column() @Column()
initiatorId: string; initiatorId: string;
@ManyToOne(() => User, (user) => user.appointments) @ManyToOne(() => User, (user) => user.appointments)
@JoinColumn({ name: 'initiatorId' }) @JoinColumn({ name: "initiatorId" })
initiator: User; initiator: User;
@Column({ type: 'varchar', length: 200, nullable: true }) @Column({ type: "varchar", length: 200, nullable: true })
title: string; title: string;
@Column({ type: 'text', nullable: true }) @Column({ type: "text", nullable: true })
description: string; description: string;
@Column({ type: 'datetime' }) @Column({ type: "datetime" })
startTime: Date; startTime: Date;
@Column({ type: 'datetime', nullable: true }) @Column({ type: "datetime", nullable: true })
endTime: Date; endTime: Date;
@Column({ comment: '最大参与人数' }) @Column({ comment: "最大参与人数" })
maxParticipants: number; maxParticipants: number;
@Column({ default: 0, comment: '当前参与人数' }) @Column({ default: 0, comment: "当前参与人数" })
currentParticipants: number; currentParticipants: number;
@Column({ @Column({
type: 'enum', type: "enum",
enum: AppointmentStatus, enum: AppointmentStatus,
default: AppointmentStatus.OPEN, default: AppointmentStatus.OPEN,
}) })

View File

@@ -5,37 +5,37 @@ import {
CreateDateColumn, CreateDateColumn,
ManyToOne, ManyToOne,
JoinColumn, JoinColumn,
} from 'typeorm'; } from "typeorm";
import { AssetLogAction } from '../common/enums'; import { AssetLogAction } from "../common/enums";
import { Asset } from './asset.entity'; import { Asset } from "./asset.entity";
import { User } from './user.entity'; import { User } from "./user.entity";
@Entity('asset_logs') @Entity("asset_logs")
export class AssetLog { export class AssetLog {
@PrimaryGeneratedColumn('uuid') @PrimaryGeneratedColumn("uuid")
id: string; id: string;
@Column() @Column()
assetId: string; assetId: string;
@ManyToOne(() => Asset, (asset) => asset.logs, { onDelete: 'CASCADE' }) @ManyToOne(() => Asset, (asset) => asset.logs, { onDelete: "CASCADE" })
@JoinColumn({ name: 'assetId' }) @JoinColumn({ name: "assetId" })
asset: Asset; asset: Asset;
@Column() @Column()
userId: string; userId: string;
@ManyToOne(() => User) @ManyToOne(() => User)
@JoinColumn({ name: 'userId' }) @JoinColumn({ name: "userId" })
user: User; user: User;
@Column({ type: 'enum', enum: AssetLogAction }) @Column({ type: "enum", enum: AssetLogAction })
action: AssetLogAction; action: AssetLogAction;
@Column({ default: 1, comment: '数量' }) @Column({ default: 1, comment: "数量" })
quantity: number; quantity: number;
@Column({ type: 'text', nullable: true, comment: '备注' }) @Column({ type: "text", nullable: true, comment: "备注" })
note: string; note: string;
@CreateDateColumn() @CreateDateColumn()

View File

@@ -7,46 +7,46 @@ import {
ManyToOne, ManyToOne,
JoinColumn, JoinColumn,
OneToMany, OneToMany,
} from 'typeorm'; } from "typeorm";
import { AssetType, AssetStatus } from '../common/enums'; import { AssetType, AssetStatus } from "../common/enums";
import { Group } from './group.entity'; import { Group } from "./group.entity";
import { AssetLog } from './asset-log.entity'; import { AssetLog } from "./asset-log.entity";
@Entity('assets') @Entity("assets")
export class Asset { export class Asset {
@PrimaryGeneratedColumn('uuid') @PrimaryGeneratedColumn("uuid")
id: string; id: string;
@Column() @Column()
groupId: string; groupId: string;
@ManyToOne(() => Group, { onDelete: 'CASCADE' }) @ManyToOne(() => Group, { onDelete: "CASCADE" })
@JoinColumn({ name: 'groupId' }) @JoinColumn({ name: "groupId" })
group: Group; group: Group;
@Column({ type: 'enum', enum: AssetType }) @Column({ type: "enum", enum: AssetType })
type: AssetType; type: AssetType;
@Column({ length: 100 }) @Column({ length: 100 })
name: string; name: string;
@Column({ type: 'text', nullable: true, comment: '描述' }) @Column({ type: "text", nullable: true, comment: "描述" })
description: string; description: string;
@Column({ type: 'text', nullable: true, comment: '加密的账号凭据' }) @Column({ type: "text", nullable: true, comment: "加密的账号凭据" })
accountCredentials?: string | null; accountCredentials?: string | null;
@Column({ default: 1, comment: '数量(用于物品)' }) @Column({ default: 1, comment: "数量(用于物品)" })
quantity: number; quantity: number;
@Column({ @Column({
type: 'enum', type: "enum",
enum: AssetStatus, enum: AssetStatus,
default: AssetStatus.AVAILABLE, default: AssetStatus.AVAILABLE,
}) })
status: AssetStatus; status: AssetStatus;
@Column({ type: 'varchar', nullable: true, comment: '当前借用人ID' }) @Column({ type: "varchar", nullable: true, comment: "当前借用人ID" })
currentBorrowerId?: string | null; currentBorrowerId?: string | null;
@CreateDateColumn() @CreateDateColumn()

View File

@@ -6,40 +6,40 @@ import {
UpdateDateColumn, UpdateDateColumn,
ManyToOne, ManyToOne,
JoinColumn, JoinColumn,
} from 'typeorm'; } from "typeorm";
import { BetStatus } from '../common/enums'; import { BetStatus } from "../common/enums";
import { Appointment } from './appointment.entity'; import { Appointment } from "./appointment.entity";
import { User } from './user.entity'; import { User } from "./user.entity";
@Entity('bets') @Entity("bets")
export class Bet { export class Bet {
@PrimaryGeneratedColumn('uuid') @PrimaryGeneratedColumn("uuid")
id: string; id: string;
@Column() @Column()
appointmentId: string; appointmentId: string;
@ManyToOne(() => Appointment, { onDelete: 'CASCADE' }) @ManyToOne(() => Appointment, { onDelete: "CASCADE" })
@JoinColumn({ name: 'appointmentId' }) @JoinColumn({ name: "appointmentId" })
appointment: Appointment; appointment: Appointment;
@Column() @Column()
userId: string; userId: string;
@ManyToOne(() => User, { onDelete: 'CASCADE' }) @ManyToOne(() => User, { onDelete: "CASCADE" })
@JoinColumn({ name: 'userId' }) @JoinColumn({ name: "userId" })
user: User; user: User;
@Column({ length: 100, comment: '下注选项' }) @Column({ length: 100, comment: "下注选项" })
betOption: string; betOption: string;
@Column({ type: 'int', comment: '下注积分' }) @Column({ type: "int", comment: "下注积分" })
amount: number; amount: number;
@Column({ type: 'enum', enum: BetStatus, default: BetStatus.PENDING }) @Column({ type: "enum", enum: BetStatus, default: BetStatus.PENDING })
status: BetStatus; status: BetStatus;
@Column({ type: 'int', default: 0, comment: '赢得的积分' }) @Column({ type: "int", default: 0, comment: "赢得的积分" })
winAmount: number; winAmount: number;
@CreateDateColumn() @CreateDateColumn()

View File

@@ -5,46 +5,46 @@ import {
CreateDateColumn, CreateDateColumn,
ManyToOne, ManyToOne,
JoinColumn, JoinColumn,
} from 'typeorm'; } from "typeorm";
import { BlacklistStatus } from '../common/enums'; import { BlacklistStatus } from "../common/enums";
import { User } from './user.entity'; import { User } from "./user.entity";
@Entity('blacklists') @Entity("blacklists")
export class Blacklist { export class Blacklist {
@PrimaryGeneratedColumn('uuid') @PrimaryGeneratedColumn("uuid")
id: string; id: string;
@Column({ length: 100, comment: '目标游戏ID或用户名' }) @Column({ length: 100, comment: "目标游戏ID或用户名" })
targetGameId: string; targetGameId: string;
@Column({ type: 'text' }) @Column({ type: "text" })
reason: string; reason: string;
@Column() @Column()
reporterId: string; reporterId: string;
@ManyToOne(() => User) @ManyToOne(() => User)
@JoinColumn({ name: 'reporterId' }) @JoinColumn({ name: "reporterId" })
reporter: User; reporter: User;
@Column({ type: 'simple-json', nullable: true, comment: '证据图片' }) @Column({ type: "simple-json", nullable: true, comment: "证据图片" })
proofImages: string[]; proofImages: string[];
@Column({ @Column({
type: 'enum', type: "enum",
enum: BlacklistStatus, enum: BlacklistStatus,
default: BlacklistStatus.PENDING, default: BlacklistStatus.PENDING,
}) })
status: BlacklistStatus; status: BlacklistStatus;
@Column({ nullable: true, comment: '审核人ID' }) @Column({ nullable: true, comment: "审核人ID" })
reviewerId: string; reviewerId: string;
@ManyToOne(() => User, { nullable: true }) @ManyToOne(() => User, { nullable: true })
@JoinColumn({ name: 'reviewerId' }) @JoinColumn({ name: "reviewerId" })
reviewer: User; reviewer: User;
@Column({ type: 'text', nullable: true, comment: '审核意见' }) @Column({ type: "text", nullable: true, comment: "审核意见" })
reviewNote: string; reviewNote: string;
@CreateDateColumn() @CreateDateColumn()

View File

@@ -5,33 +5,33 @@ import {
CreateDateColumn, CreateDateColumn,
UpdateDateColumn, UpdateDateColumn,
OneToMany, OneToMany,
} from 'typeorm'; } from "typeorm";
import { Appointment } from './appointment.entity'; import { Appointment } from "./appointment.entity";
@Entity('games') @Entity("games")
export class Game { export class Game {
@PrimaryGeneratedColumn('uuid') @PrimaryGeneratedColumn("uuid")
id: string; id: string;
@Column({ length: 100 }) @Column({ length: 100 })
name: string; name: string;
@Column({ type: 'varchar', nullable: true, length: 255 }) @Column({ type: "varchar", nullable: true, length: 255 })
coverUrl: string; coverUrl: string;
@Column({ type: 'text', nullable: true }) @Column({ type: "text", nullable: true })
description: string; description: string;
@Column({ comment: '最大玩家数' }) @Column({ comment: "最大玩家数" })
maxPlayers: number; maxPlayers: number;
@Column({ default: 1, comment: '最小玩家数' }) @Column({ default: 1, comment: "最小玩家数" })
minPlayers: number; minPlayers: number;
@Column({ length: 50, nullable: true, comment: '平台' }) @Column({ length: 50, nullable: true, comment: "平台" })
platform: string; platform: string;
@Column({ type: 'simple-array', nullable: true, comment: '游戏标签' }) @Column({ type: "simple-array", nullable: true, comment: "游戏标签" })
tags: string[]; tags: string[];
@Column({ default: true }) @Column({ default: true })

View File

@@ -6,39 +6,39 @@ import {
ManyToOne, ManyToOne,
JoinColumn, JoinColumn,
Unique, Unique,
} from 'typeorm'; } from "typeorm";
import { GroupMemberRole } from '../common/enums'; import { GroupMemberRole } from "../common/enums";
import { User } from './user.entity'; import { User } from "./user.entity";
import { Group } from './group.entity'; import { Group } from "./group.entity";
@Entity('group_members') @Entity("group_members")
@Unique(['groupId', 'userId']) @Unique(["groupId", "userId"])
export class GroupMember { export class GroupMember {
@PrimaryGeneratedColumn('uuid') @PrimaryGeneratedColumn("uuid")
id: string; id: string;
@Column() @Column()
groupId: string; groupId: string;
@ManyToOne(() => Group, (group) => group.members, { onDelete: 'CASCADE' }) @ManyToOne(() => Group, (group) => group.members, { onDelete: "CASCADE" })
@JoinColumn({ name: 'groupId' }) @JoinColumn({ name: "groupId" })
group: Group; group: Group;
@Column() @Column()
userId: string; userId: string;
@ManyToOne(() => User, (user) => user.groupMembers, { onDelete: 'CASCADE' }) @ManyToOne(() => User, (user) => user.groupMembers, { onDelete: "CASCADE" })
@JoinColumn({ name: 'userId' }) @JoinColumn({ name: "userId" })
user: User; user: User;
@Column({ @Column({
type: 'enum', type: "enum",
enum: GroupMemberRole, enum: GroupMemberRole,
default: GroupMemberRole.MEMBER, default: GroupMemberRole.MEMBER,
}) })
role: GroupMemberRole; role: GroupMemberRole;
@Column({ type: 'varchar', nullable: true, length: 50, comment: '组内昵称' }) @Column({ type: "varchar", nullable: true, length: 50, comment: "组内昵称" })
nickname: string; nickname: string;
@Column({ default: true }) @Column({ default: true })

View File

@@ -7,49 +7,49 @@ import {
ManyToOne, ManyToOne,
JoinColumn, JoinColumn,
OneToMany, OneToMany,
} from 'typeorm'; } from "typeorm";
import { User } from './user.entity'; import { User } from "./user.entity";
import { GroupMember } from './group-member.entity'; import { GroupMember } from "./group-member.entity";
import { Appointment } from './appointment.entity'; import { Appointment } from "./appointment.entity";
@Entity('groups') @Entity("groups")
export class Group { export class Group {
@PrimaryGeneratedColumn('uuid') @PrimaryGeneratedColumn("uuid")
id: string; id: string;
@Column({ length: 100 }) @Column({ length: 100 })
name: string; name: string;
@Column({ type: 'text', nullable: true }) @Column({ type: "text", nullable: true })
description: string; description: string;
@Column({ type: 'varchar', nullable: true, length: 255 }) @Column({ type: "varchar", nullable: true, length: 255 })
avatar: string; avatar: string;
@Column() @Column()
ownerId: string; ownerId: string;
@ManyToOne(() => User) @ManyToOne(() => User)
@JoinColumn({ name: 'ownerId' }) @JoinColumn({ name: "ownerId" })
owner: User; owner: User;
@Column({ default: 'normal', length: 20, comment: '类型: normal/guild' }) @Column({ default: "normal", length: 20, comment: "类型: normal/guild" })
type: string; type: string;
@Column({ nullable: true, comment: '父组ID用于子组' }) @Column({ nullable: true, comment: "父组ID用于子组" })
parentId: string; parentId: string;
@ManyToOne(() => Group, { nullable: true }) @ManyToOne(() => Group, { nullable: true })
@JoinColumn({ name: 'parentId' }) @JoinColumn({ name: "parentId" })
parent: Group; parent: Group;
@Column({ type: 'text', nullable: true, comment: '公示信息' }) @Column({ type: "text", nullable: true, comment: "公示信息" })
announcement: string; announcement: string;
@Column({ default: 50, comment: '最大成员数' }) @Column({ default: 50, comment: "最大成员数" })
maxMembers: number; maxMembers: number;
@Column({ default: 1, comment: '当前成员数' }) @Column({ default: 1, comment: "当前成员数" })
currentMembers: number; currentMembers: number;
@Column({ default: true }) @Column({ default: true })

View File

@@ -5,42 +5,42 @@ import {
CreateDateColumn, CreateDateColumn,
ManyToOne, ManyToOne,
JoinColumn, JoinColumn,
} from 'typeorm'; } from "typeorm";
import { Group } from './group.entity'; import { Group } from "./group.entity";
import { User } from './user.entity'; import { User } from "./user.entity";
@Entity('honors') @Entity("honors")
export class Honor { export class Honor {
@PrimaryGeneratedColumn('uuid') @PrimaryGeneratedColumn("uuid")
id: string; id: string;
@Column() @Column()
groupId: string; groupId: string;
@ManyToOne(() => Group, { onDelete: 'CASCADE' }) @ManyToOne(() => Group, { onDelete: "CASCADE" })
@JoinColumn({ name: 'groupId' }) @JoinColumn({ name: "groupId" })
group: Group; group: Group;
@Column({ length: 200 }) @Column({ length: 200 })
title: string; title: string;
@Column({ type: 'text', nullable: true }) @Column({ type: "text", nullable: true })
description: string; description: string;
@Column({ type: 'simple-json', nullable: true, comment: '媒体文件URLs' }) @Column({ type: "simple-json", nullable: true, comment: "媒体文件URLs" })
mediaUrls: string[]; mediaUrls: string[];
@Column({ type: 'date', comment: '事件日期' }) @Column({ type: "date", comment: "事件日期" })
eventDate: Date; eventDate: Date;
@Column({ type: 'simple-json', nullable: true, comment: '参与者ID列表' }) @Column({ type: "simple-json", nullable: true, comment: "参与者ID列表" })
participantIds: string[]; participantIds: string[];
@Column() @Column()
creatorId: string; creatorId: string;
@ManyToOne(() => User) @ManyToOne(() => User)
@JoinColumn({ name: 'creatorId' }) @JoinColumn({ name: "creatorId" })
creator: User; creator: User;
@CreateDateColumn() @CreateDateColumn()

View File

@@ -5,43 +5,43 @@ import {
CreateDateColumn, CreateDateColumn,
ManyToOne, ManyToOne,
JoinColumn, JoinColumn,
} from 'typeorm'; } from "typeorm";
import { LedgerType } from '../common/enums'; import { LedgerType } from "../common/enums";
import { Group } from './group.entity'; import { Group } from "./group.entity";
import { User } from './user.entity'; import { User } from "./user.entity";
@Entity('ledgers') @Entity("ledgers")
export class Ledger { export class Ledger {
@PrimaryGeneratedColumn('uuid') @PrimaryGeneratedColumn("uuid")
id: string; id: string;
@Column() @Column()
groupId: string; groupId: string;
@ManyToOne(() => Group, { onDelete: 'CASCADE' }) @ManyToOne(() => Group, { onDelete: "CASCADE" })
@JoinColumn({ name: 'groupId' }) @JoinColumn({ name: "groupId" })
group: Group; group: Group;
@Column() @Column()
creatorId: string; creatorId: string;
@ManyToOne(() => User) @ManyToOne(() => User)
@JoinColumn({ name: 'creatorId' }) @JoinColumn({ name: "creatorId" })
creator: User; creator: User;
@Column({ type: 'decimal', precision: 10, scale: 2 }) @Column({ type: "decimal", precision: 10, scale: 2 })
amount: number; amount: number;
@Column({ type: 'enum', enum: LedgerType }) @Column({ type: "enum", enum: LedgerType })
type: LedgerType; type: LedgerType;
@Column({ type: 'varchar', length: 50, nullable: true, comment: '分类' }) @Column({ type: "varchar", length: 50, nullable: true, comment: "分类" })
category: string; category: string;
@Column({ type: 'text', nullable: true }) @Column({ type: "text", nullable: true })
description: string; description: string;
@Column({ type: 'simple-json', nullable: true, comment: '凭证图片' }) @Column({ type: "simple-json", nullable: true, comment: "凭证图片" })
proofImages: string[]; proofImages: string[];
@CreateDateColumn() @CreateDateColumn()

View File

@@ -5,39 +5,43 @@ import {
CreateDateColumn, CreateDateColumn,
ManyToOne, ManyToOne,
JoinColumn, JoinColumn,
} from 'typeorm'; } from "typeorm";
import { User } from './user.entity'; import { User } from "./user.entity";
import { Group } from './group.entity'; import { Group } from "./group.entity";
@Entity('points') @Entity("points")
export class Point { export class Point {
@PrimaryGeneratedColumn('uuid') @PrimaryGeneratedColumn("uuid")
id: string; id: string;
@Column() @Column()
userId: string; userId: string;
@ManyToOne(() => User, (user) => user.points, { onDelete: 'CASCADE' }) @ManyToOne(() => User, (user) => user.points, { onDelete: "CASCADE" })
@JoinColumn({ name: 'userId' }) @JoinColumn({ name: "userId" })
user: User; user: User;
@Column() @Column()
groupId: string; groupId: string;
@ManyToOne(() => Group, { onDelete: 'CASCADE' }) @ManyToOne(() => Group, { onDelete: "CASCADE" })
@JoinColumn({ name: 'groupId' }) @JoinColumn({ name: "groupId" })
group: Group; group: Group;
@Column({ type: 'int', comment: '积分变动值,正为增加,负为减少' }) @Column({ type: "int", comment: "积分变动值,正为增加,负为减少" })
amount: number; amount: number;
@Column({ length: 100, comment: '原因' }) @Column({ length: 100, comment: "原因" })
reason: string; reason: string;
@Column({ type: 'text', nullable: true, comment: '详细说明' }) @Column({ type: "text", nullable: true, comment: "详细说明" })
description: string; description: string;
@Column({ type: 'varchar', nullable: true, comment: '关联ID如活动ID、预约ID' }) @Column({
type: "varchar",
nullable: true,
comment: "关联ID如活动ID、预约ID",
})
relatedId: string; relatedId: string;
@CreateDateColumn() @CreateDateColumn()

View File

@@ -6,31 +6,31 @@ import {
UpdateDateColumn, UpdateDateColumn,
ManyToOne, ManyToOne,
JoinColumn, JoinColumn,
} from 'typeorm'; } from "typeorm";
import { User } from './user.entity'; import { User } from "./user.entity";
import { Group } from './group.entity'; import { Group } from "./group.entity";
@Entity('schedules') @Entity("schedules")
export class Schedule { export class Schedule {
@PrimaryGeneratedColumn('uuid') @PrimaryGeneratedColumn("uuid")
id: string; id: string;
@Column() @Column()
userId: string; userId: string;
@ManyToOne(() => User, { onDelete: 'CASCADE' }) @ManyToOne(() => User, { onDelete: "CASCADE" })
@JoinColumn({ name: 'userId' }) @JoinColumn({ name: "userId" })
user: User; user: User;
@Column() @Column()
groupId: string; groupId: string;
@ManyToOne(() => Group, { onDelete: 'CASCADE' }) @ManyToOne(() => Group, { onDelete: "CASCADE" })
@JoinColumn({ name: 'groupId' }) @JoinColumn({ name: "groupId" })
group: Group; group: Group;
@Column({ @Column({
type: 'simple-json', type: "simple-json",
comment: '空闲时间段 JSON: { "mon": ["20:00-23:00"], ... }', comment: '空闲时间段 JSON: { "mon": ["20:00-23:00"], ... }',
}) })
availableSlots: Record<string, string[]>; availableSlots: Record<string, string[]>;

View File

@@ -5,15 +5,15 @@ import {
CreateDateColumn, CreateDateColumn,
UpdateDateColumn, UpdateDateColumn,
OneToMany, OneToMany,
} from 'typeorm'; } from "typeorm";
import { UserRole } from '../common/enums'; import { UserRole } from "../common/enums";
import { GroupMember } from './group-member.entity'; import { GroupMember } from "./group-member.entity";
import { Appointment } from './appointment.entity'; import { Appointment } from "./appointment.entity";
import { Point } from './point.entity'; import { Point } from "./point.entity";
@Entity('users') @Entity("users")
export class User { export class User {
@PrimaryGeneratedColumn('uuid') @PrimaryGeneratedColumn("uuid")
id: string; id: string;
@Column({ unique: true, length: 50 }) @Column({ unique: true, length: 50 })
@@ -31,19 +31,24 @@ export class User {
@Column({ nullable: true, length: 255 }) @Column({ nullable: true, length: 255 })
avatar: string; avatar: string;
@Column({ type: 'enum', enum: UserRole, default: UserRole.USER }) @Column({ type: "enum", enum: UserRole, default: UserRole.USER })
role: UserRole; role: UserRole;
@Column({ default: false, comment: '是否为会员' }) @Column({ default: false, comment: "是否为会员" })
isMember: boolean; isMember: boolean;
@Column({ type: 'datetime', nullable: true, comment: '会员到期时间' }) @Column({ type: "datetime", nullable: true, comment: "会员到期时间" })
memberExpireAt: Date; memberExpireAt: Date;
@Column({ type: 'varchar', nullable: true, length: 50, comment: '最后登录IP' }) @Column({
type: "varchar",
nullable: true,
length: 50,
comment: "最后登录IP",
})
lastLoginIp: string | null; lastLoginIp: string | null;
@Column({ type: 'datetime', nullable: true, comment: '最后登录时间' }) @Column({ type: "datetime", nullable: true, comment: "最后登录时间" })
lastLoginAt: Date; lastLoginAt: Date;
@CreateDateColumn() @CreateDateColumn()

View File

@@ -1,34 +1,48 @@
import { NestFactory } from '@nestjs/core'; import { NestFactory } from "@nestjs/core";
import { ConfigService } from '@nestjs/config'; import { ConfigService } from "@nestjs/config";
import { SwaggerModule, DocumentBuilder } from '@nestjs/swagger'; import { SwaggerModule, DocumentBuilder } from "@nestjs/swagger";
import { AppModule } from './app.module'; import { AppModule } from "./app.module";
import { HttpExceptionFilter } from './common/filters/http-exception.filter'; import { HttpExceptionFilter } from "./common/filters/http-exception.filter";
import { TransformInterceptor } from './common/interceptors/transform.interceptor'; import { TransformInterceptor } from "./common/interceptors/transform.interceptor";
import { LoggingInterceptor } from './common/interceptors/logging.interceptor'; import { LoggingInterceptor } from "./common/interceptors/logging.interceptor";
import { ValidationPipe } from './common/pipes/validation.pipe'; import { ValidationPipe } from "./common/pipes/validation.pipe";
import compression from 'compression'; import compression from "compression";
async function bootstrap() { async function bootstrap() {
const app = await NestFactory.create(AppModule, { const app = await NestFactory.create(AppModule, {
logger: process.env.NODE_ENV === 'production' logger:
? ['error', 'warn', 'log'] process.env.NODE_ENV === "production"
: ['error', 'warn', 'log', 'debug', 'verbose'], ? ["error", "warn", "log"]
: ["error", "warn", "log", "debug", "verbose"],
}); });
const configService = app.get(ConfigService); const configService = app.get(ConfigService);
const isProduction = configService.get('app.isProduction', false); const isProduction = configService.get("app.isProduction", false);
// 启用压缩 // 启用压缩
if (configService.get('performance.enableCompression', true)) { if (configService.get("performance.enableCompression", true)) {
app.use(compression()); app.use(compression());
} }
// 设置全局前缀 // 设置全局前缀
const apiPrefix = configService.get<string>('app.apiPrefix', 'api'); const apiPrefix = configService.get<string>("app.apiPrefix", "api");
app.setGlobalPrefix(apiPrefix); app.setGlobalPrefix(apiPrefix);
// 启用 CORS // 启用 CORS
const corsOrigin = configService.get('performance.corsOrigin', '*'); const corsOrigin = configService.get("performance.corsOrigin", "*");
// 生产环境 CORS 安全检查
if (isProduction) {
if (!corsOrigin || corsOrigin === "*") {
console.error('❌ 安全警告: 生产环境不能设置 CORS_ORIGIN 为 "*" 或空值');
console.error("请在 .env.production 文件中配置明确的域名白名单,例如:");
console.error(
"CORS_ORIGIN=https://yourdomain.com,https://www.yourdomain.com",
);
throw new Error("生产环境必须配置明确的 CORS 白名单域名");
}
}
app.enableCors({ app.enableCors({
origin: (origin, callback) => { origin: (origin, callback) => {
// 开发环境允许所有来源 // 开发环境允许所有来源
@@ -36,30 +50,46 @@ async function bootstrap() {
callback(null, true); callback(null, true);
return; return;
} }
// 生产环境使用配置的来源
if (!origin || corsOrigin === '*') { // 生产环境:必须提供 origin header
callback(null, true); if (!origin) {
} else { callback(new Error("CORS: Origin header is required in production"));
const allowedOrigins = corsOrigin.split(','); return;
}
// 生产环境不允许使用 '*' 通配符
if (corsOrigin === "*") {
callback(
new Error(
"CORS: Wildcard origin (*) is not allowed in production with credentials enabled",
),
);
return;
}
// 检查 origin 是否在白名单中
const allowedOrigins = corsOrigin.split(",").map((o) => o.trim());
if (allowedOrigins.includes(origin)) { if (allowedOrigins.includes(origin)) {
callback(null, true); callback(null, true);
} else { } else {
callback(new Error('Not allowed by CORS')); console.warn(
} `⚠️ CORS: Blocked request from unauthorized origin: ${origin}`,
);
callback(new Error("Not allowed by CORS"));
} }
}, },
credentials: true, credentials: true,
methods: ['GET', 'POST', 'PUT', 'PATCH', 'DELETE', 'OPTIONS', 'HEAD'], methods: ["GET", "POST", "PUT", "PATCH", "DELETE", "OPTIONS", "HEAD"],
allowedHeaders: [ allowedHeaders: [
'Content-Type', "Content-Type",
'Authorization', "Authorization",
'Accept', "Accept",
'X-Requested-With', "X-Requested-With",
'Origin', "Origin",
'Access-Control-Request-Method', "Access-Control-Request-Method",
'Access-Control-Request-Headers', "Access-Control-Request-Headers",
], ],
exposedHeaders: ['Content-Range', 'X-Content-Range'], exposedHeaders: ["Content-Range", "X-Content-Range"],
preflightContinue: false, preflightContinue: false,
optionsSuccessStatus: 204, optionsSuccessStatus: 204,
maxAge: 86400, maxAge: 86400,
@@ -76,33 +106,35 @@ async function bootstrap() {
// Swagger 文档(仅在开发环境) // Swagger 文档(仅在开发环境)
if (!isProduction) { if (!isProduction) {
const config = new DocumentBuilder() const config = new DocumentBuilder()
.setTitle('GameGroup API') .setTitle("GameGroup API")
.setDescription('GameGroup 游戏小组管理系统 API 文档') .setDescription("GameGroup 游戏小组管理系统 API 文档")
.setVersion('1.0') .setVersion("1.0")
.addBearerAuth() .addBearerAuth()
.addTag('auth', '认证相关') .addTag("auth", "认证相关")
.addTag('users', '用户管理') .addTag("users", "用户管理")
.addTag('groups', '小组管理') .addTag("groups", "小组管理")
.addTag('games', '游戏库') .addTag("games", "游戏库")
.addTag('appointments', '预约管理') .addTag("appointments", "预约管理")
.addTag('ledgers', '账目管理') .addTag("ledgers", "账目管理")
.addTag('schedules', '排班管理') .addTag("schedules", "排班管理")
.addTag('blacklist', '黑名单') .addTag("blacklist", "黑名单")
.addTag('honors', '荣誉墙') .addTag("honors", "荣誉墙")
.addTag('assets', '资产管理') .addTag("assets", "资产管理")
.addTag('points', '积分系统') .addTag("points", "积分系统")
.addTag('bets', '竞猜系统') .addTag("bets", "竞猜系统")
.build(); .build();
const document = SwaggerModule.createDocument(app, config); const document = SwaggerModule.createDocument(app, config);
SwaggerModule.setup('docs', app, document); SwaggerModule.setup("docs", app, document);
} }
const port = configService.get<number>('app.port', 3000); const port = configService.get<number>("app.port", 3000);
await app.listen(port); await app.listen(port);
const environment = configService.get('app.environment', 'development'); const environment = configService.get("app.environment", "development");
console.log(`🚀 Application is running on: http://localhost:${port}/${apiPrefix}`); console.log(
`🚀 Application is running on: http://localhost:${port}/${apiPrefix}`,
);
console.log(`🌍 Environment: ${environment}`); console.log(`🌍 Environment: ${environment}`);
if (!isProduction) { if (!isProduction) {

View File

@@ -8,139 +8,124 @@ import {
Param, Param,
Query, Query,
UseGuards, UseGuards,
} from '@nestjs/common'; } from "@nestjs/common";
import { import {
ApiTags, ApiTags,
ApiOperation, ApiOperation,
ApiResponse, ApiResponse,
ApiBearerAuth, ApiBearerAuth,
ApiQuery, ApiQuery,
} from '@nestjs/swagger'; } from "@nestjs/swagger";
import { AppointmentsService } from './appointments.service'; import { AppointmentsService } from "./appointments.service";
import { import {
CreateAppointmentDto, CreateAppointmentDto,
UpdateAppointmentDto, UpdateAppointmentDto,
QueryAppointmentsDto, QueryAppointmentsDto,
JoinAppointmentDto, JoinAppointmentDto,
} from './dto/appointment.dto'; } from "./dto/appointment.dto";
import { JwtAuthGuard } from '../../common/guards/jwt-auth.guard'; import { JwtAuthGuard } from "../../common/guards/jwt-auth.guard";
import { CurrentUser } from '../../common/decorators/current-user.decorator'; import { CurrentUser } from "../../common/decorators/current-user.decorator";
@ApiTags('appointments') @ApiTags("appointments")
@ApiBearerAuth() @ApiBearerAuth()
@UseGuards(JwtAuthGuard) @UseGuards(JwtAuthGuard)
@Controller('appointments') @Controller("appointments")
export class AppointmentsController { export class AppointmentsController {
constructor(private readonly appointmentsService: AppointmentsService) {} constructor(private readonly appointmentsService: AppointmentsService) {}
@Post() @Post()
@ApiOperation({ summary: '创建预约' }) @ApiOperation({ summary: "创建预约" })
@ApiResponse({ status: 201, description: '创建成功' }) @ApiResponse({ status: 201, description: "创建成功" })
async create( async create(
@CurrentUser('id') userId: string, @CurrentUser("id") userId: string,
@Body() createDto: CreateAppointmentDto, @Body() createDto: CreateAppointmentDto,
) { ) {
return this.appointmentsService.create(userId, createDto); return this.appointmentsService.create(userId, createDto);
} }
@Get() @Get()
@ApiOperation({ summary: '获取预约列表' }) @ApiOperation({ summary: "获取预约列表" })
@ApiResponse({ status: 200, description: '获取成功' }) @ApiResponse({ status: 200, description: "获取成功" })
@ApiQuery({ name: 'groupId', required: false, description: '小组ID' }) @ApiQuery({ name: "groupId", required: false, description: "小组ID" })
@ApiQuery({ name: 'gameId', required: false, description: '游戏ID' }) @ApiQuery({ name: "gameId", required: false, description: "游戏ID" })
@ApiQuery({ name: 'status', required: false, description: '状态' }) @ApiQuery({ name: "status", required: false, description: "状态" })
@ApiQuery({ name: 'startTime', required: false, description: '开始时间' }) @ApiQuery({ name: "startTime", required: false, description: "开始时间" })
@ApiQuery({ name: 'endTime', required: false, description: '结束时间' }) @ApiQuery({ name: "endTime", required: false, description: "结束时间" })
@ApiQuery({ name: 'page', required: false, description: '页码' }) @ApiQuery({ name: "page", required: false, description: "页码" })
@ApiQuery({ name: 'limit', required: false, description: '每页数量' }) @ApiQuery({ name: "limit", required: false, description: "每页数量" })
async findAll( async findAll(
@CurrentUser('id') userId: string, @CurrentUser("id") userId: string,
@Query() queryDto: QueryAppointmentsDto, @Query() queryDto: QueryAppointmentsDto,
) { ) {
return this.appointmentsService.findAll(userId, queryDto); return this.appointmentsService.findAll(userId, queryDto);
} }
@Get('my') @Get("my")
@ApiOperation({ summary: '获取我参与的预约' }) @ApiOperation({ summary: "获取我参与的预约" })
@ApiResponse({ status: 200, description: '获取成功' }) @ApiResponse({ status: 200, description: "获取成功" })
@ApiQuery({ name: 'status', required: false, description: '状态' }) @ApiQuery({ name: "status", required: false, description: "状态" })
@ApiQuery({ name: 'page', required: false, description: '页码' }) @ApiQuery({ name: "page", required: false, description: "页码" })
@ApiQuery({ name: 'limit', required: false, description: '每页数量' }) @ApiQuery({ name: "limit", required: false, description: "每页数量" })
async findMyAppointments( async findMyAppointments(
@CurrentUser('id') userId: string, @CurrentUser("id") userId: string,
@Query() queryDto: QueryAppointmentsDto, @Query() queryDto: QueryAppointmentsDto,
) { ) {
return this.appointmentsService.findMyAppointments(userId, queryDto); return this.appointmentsService.findMyAppointments(userId, queryDto);
} }
@Get(':id') @Get(":id")
@ApiOperation({ summary: '获取预约详情' }) @ApiOperation({ summary: "获取预约详情" })
@ApiResponse({ status: 200, description: '获取成功' }) @ApiResponse({ status: 200, description: "获取成功" })
async findOne( async findOne(@CurrentUser("id") userId: string, @Param("id") id: string) {
@CurrentUser('id') userId: string,
@Param('id') id: string,
) {
return this.appointmentsService.findOne(id, userId); return this.appointmentsService.findOne(id, userId);
} }
@Post('join') @Post("join")
@ApiOperation({ summary: '加入预约' }) @ApiOperation({ summary: "加入预约" })
@ApiResponse({ status: 200, description: '加入成功' }) @ApiResponse({ status: 200, description: "加入成功" })
async join( async join(
@CurrentUser('id') userId: string, @CurrentUser("id") userId: string,
@Body() joinDto: JoinAppointmentDto, @Body() joinDto: JoinAppointmentDto,
) { ) {
return this.appointmentsService.join(userId, joinDto.appointmentId); return this.appointmentsService.join(userId, joinDto.appointmentId);
} }
@Delete(':id/leave') @Delete(":id/leave")
@ApiOperation({ summary: '退出预约' }) @ApiOperation({ summary: "退出预约" })
@ApiResponse({ status: 200, description: '退出成功' }) @ApiResponse({ status: 200, description: "退出成功" })
async leave( async leave(@CurrentUser("id") userId: string, @Param("id") id: string) {
@CurrentUser('id') userId: string,
@Param('id') id: string,
) {
return this.appointmentsService.leave(userId, id); return this.appointmentsService.leave(userId, id);
} }
@Put(':id') @Put(":id")
@ApiOperation({ summary: '更新预约' }) @ApiOperation({ summary: "更新预约" })
@ApiResponse({ status: 200, description: '更新成功' }) @ApiResponse({ status: 200, description: "更新成功" })
async update( async update(
@CurrentUser('id') userId: string, @CurrentUser("id") userId: string,
@Param('id') id: string, @Param("id") id: string,
@Body() updateDto: UpdateAppointmentDto, @Body() updateDto: UpdateAppointmentDto,
) { ) {
return this.appointmentsService.update(userId, id, updateDto); return this.appointmentsService.update(userId, id, updateDto);
} }
@Put(':id/confirm') @Put(":id/confirm")
@ApiOperation({ summary: '确认预约' }) @ApiOperation({ summary: "确认预约" })
@ApiResponse({ status: 200, description: '确认成功' }) @ApiResponse({ status: 200, description: "确认成功" })
async confirm( async confirm(@CurrentUser("id") userId: string, @Param("id") id: string) {
@CurrentUser('id') userId: string,
@Param('id') id: string,
) {
return this.appointmentsService.confirm(userId, id); return this.appointmentsService.confirm(userId, id);
} }
@Put(':id/complete') @Put(":id/complete")
@ApiOperation({ summary: '完成预约' }) @ApiOperation({ summary: "完成预约" })
@ApiResponse({ status: 200, description: '完成成功' }) @ApiResponse({ status: 200, description: "完成成功" })
async complete( async complete(@CurrentUser("id") userId: string, @Param("id") id: string) {
@CurrentUser('id') userId: string,
@Param('id') id: string,
) {
return this.appointmentsService.complete(userId, id); return this.appointmentsService.complete(userId, id);
} }
@Delete(':id') @Delete(":id")
@ApiOperation({ summary: '取消预约' }) @ApiOperation({ summary: "取消预约" })
@ApiResponse({ status: 200, description: '取消成功' }) @ApiResponse({ status: 200, description: "取消成功" })
async cancel( async cancel(@CurrentUser("id") userId: string, @Param("id") id: string) {
@CurrentUser('id') userId: string,
@Param('id') id: string,
) {
return this.appointmentsService.cancel(userId, id); return this.appointmentsService.cancel(userId, id);
} }
} }

View File

@@ -1,13 +1,13 @@
import { Module } from '@nestjs/common'; import { Module } from "@nestjs/common";
import { TypeOrmModule } from '@nestjs/typeorm'; import { TypeOrmModule } from "@nestjs/typeorm";
import { AppointmentsService } from './appointments.service'; import { AppointmentsService } from "./appointments.service";
import { AppointmentsController } from './appointments.controller'; import { AppointmentsController } from "./appointments.controller";
import { Appointment } from '../../entities/appointment.entity'; import { Appointment } from "../../entities/appointment.entity";
import { AppointmentParticipant } from '../../entities/appointment-participant.entity'; import { AppointmentParticipant } from "../../entities/appointment-participant.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 { Game } from '../../entities/game.entity'; import { Game } from "../../entities/game.entity";
import { User } from '../../entities/user.entity'; import { User } from "../../entities/user.entity";
@Module({ @Module({
imports: [ imports: [

View File

@@ -1,27 +1,27 @@
import { Test, TestingModule } from '@nestjs/testing'; import { Test, TestingModule } from "@nestjs/testing";
import { getRepositoryToken } from '@nestjs/typeorm'; import { getRepositoryToken } from "@nestjs/typeorm";
import { import {
NotFoundException, NotFoundException,
BadRequestException, BadRequestException,
ForbiddenException, ForbiddenException,
} from '@nestjs/common'; } from "@nestjs/common";
import { AppointmentsService } from './appointments.service'; import { AppointmentsService } from "./appointments.service";
import { Appointment } from '../../entities/appointment.entity'; import { Appointment } from "../../entities/appointment.entity";
import { AppointmentParticipant } from '../../entities/appointment-participant.entity'; import { AppointmentParticipant } from "../../entities/appointment-participant.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 { Game } from '../../entities/game.entity'; import { Game } from "../../entities/game.entity";
import { User } from '../../entities/user.entity'; import { User } from "../../entities/user.entity";
import { CacheService } from '../../common/services/cache.service'; import { CacheService } from "../../common/services/cache.service";
enum AppointmentStatus { enum AppointmentStatus {
PENDING = 'pending', PENDING = "pending",
CONFIRMED = 'confirmed', CONFIRMED = "confirmed",
CANCELLED = 'cancelled', CANCELLED = "cancelled",
COMPLETED = 'completed', COMPLETED = "completed",
} }
describe('AppointmentsService', () => { describe("AppointmentsService", () => {
let service: AppointmentsService; let service: AppointmentsService;
let mockAppointmentRepository: any; let mockAppointmentRepository: any;
let mockParticipantRepository: any; let mockParticipantRepository: any;
@@ -30,26 +30,26 @@ describe('AppointmentsService', () => {
let mockGameRepository: any; let mockGameRepository: any;
let mockUserRepository: any; let mockUserRepository: any;
const mockUser = { id: 'user-1', username: 'testuser' }; const mockUser = { id: "user-1", username: "testuser" };
const mockGroup = { id: 'group-1', name: '测试小组', isActive: true }; const mockGroup = { id: "group-1", name: "测试小组", isActive: true };
const mockGame = { id: 'game-1', name: '测试游戏' }; const mockGame = { id: "game-1", name: "测试游戏" };
const mockMembership = { const mockMembership = {
id: 'member-1', id: "member-1",
userId: 'user-1', userId: "user-1",
groupId: 'group-1', groupId: "group-1",
role: 'member', role: "member",
isActive: true, isActive: true,
}; };
const mockAppointment = { const mockAppointment = {
id: 'appointment-1', id: "appointment-1",
groupId: 'group-1', groupId: "group-1",
gameId: 'game-1', gameId: "game-1",
creatorId: 'user-1', creatorId: "user-1",
title: '周末开黑', title: "周末开黑",
description: '描述', description: "描述",
startTime: new Date('2024-01-20T19:00:00Z'), startTime: new Date("2024-01-20T19:00:00Z"),
endTime: new Date('2024-01-20T23:00:00Z'), endTime: new Date("2024-01-20T23:00:00Z"),
maxParticipants: 5, maxParticipants: 5,
status: AppointmentStatus.PENDING, status: AppointmentStatus.PENDING,
createdAt: new Date(), createdAt: new Date(),
@@ -57,10 +57,10 @@ describe('AppointmentsService', () => {
}; };
const mockParticipant = { const mockParticipant = {
id: 'participant-1', id: "participant-1",
appointmentId: 'appointment-1', appointmentId: "appointment-1",
userId: 'user-1', userId: "user-1",
status: 'accepted', status: "accepted",
joinedAt: new Date(), joinedAt: new Date(),
}; };
@@ -145,8 +145,8 @@ describe('AppointmentsService', () => {
service = module.get<AppointmentsService>(AppointmentsService); service = module.get<AppointmentsService>(AppointmentsService);
}); });
describe('create', () => { describe("create", () => {
it('应该成功创建预约', async () => { it("应该成功创建预约", async () => {
mockGroupRepository.findOne.mockResolvedValue(mockGroup); mockGroupRepository.findOne.mockResolvedValue(mockGroup);
mockGroupMemberRepository.findOne.mockResolvedValue(mockMembership); mockGroupMemberRepository.findOne.mockResolvedValue(mockMembership);
mockGameRepository.findOne.mockResolvedValue(mockGame); mockGameRepository.findOne.mockResolvedValue(mockGame);
@@ -162,68 +162,68 @@ describe('AppointmentsService', () => {
participants: [mockParticipant], participants: [mockParticipant],
}); });
const result = await service.create('user-1', { const result = await service.create("user-1", {
groupId: 'group-1', groupId: "group-1",
gameId: 'game-1', gameId: "game-1",
title: '周末开黑', title: "周末开黑",
startTime: new Date('2024-01-20T19:00:00Z'), startTime: new Date("2024-01-20T19:00:00Z"),
maxParticipants: 5, maxParticipants: 5,
}); });
expect(result).toHaveProperty('id'); expect(result).toHaveProperty("id");
expect(result.title).toBe('周末开黑'); expect(result.title).toBe("周末开黑");
expect(mockAppointmentRepository.save).toHaveBeenCalled(); expect(mockAppointmentRepository.save).toHaveBeenCalled();
expect(mockParticipantRepository.save).toHaveBeenCalled(); expect(mockParticipantRepository.save).toHaveBeenCalled();
}); });
it('应该在小组不存在时抛出异常', async () => { it("应该在小组不存在时抛出异常", async () => {
mockGroupRepository.findOne.mockResolvedValue(null); mockGroupRepository.findOne.mockResolvedValue(null);
await expect( await expect(
service.create('user-1', { service.create("user-1", {
groupId: 'group-1', groupId: "group-1",
gameId: 'game-1', gameId: "game-1",
title: '周末开黑', title: "周末开黑",
startTime: new Date('2024-01-20T19:00:00Z'), startTime: new Date("2024-01-20T19:00:00Z"),
maxParticipants: 5, maxParticipants: 5,
}), }),
).rejects.toThrow(NotFoundException); ).rejects.toThrow(NotFoundException);
}); });
it('应该在用户不在小组中时抛出异常', async () => { it("应该在用户不在小组中时抛出异常", async () => {
mockGroupRepository.findOne.mockResolvedValue(mockGroup); mockGroupRepository.findOne.mockResolvedValue(mockGroup);
mockGroupMemberRepository.findOne.mockResolvedValue(null); mockGroupMemberRepository.findOne.mockResolvedValue(null);
await expect( await expect(
service.create('user-1', { service.create("user-1", {
groupId: 'group-1', groupId: "group-1",
gameId: 'game-1', gameId: "game-1",
title: '周末开黑', title: "周末开黑",
startTime: new Date('2024-01-20T19:00:00Z'), startTime: new Date("2024-01-20T19:00:00Z"),
maxParticipants: 5, maxParticipants: 5,
}), }),
).rejects.toThrow(ForbiddenException); ).rejects.toThrow(ForbiddenException);
}); });
it('应该在游戏不存在时抛出异常', async () => { it("应该在游戏不存在时抛出异常", async () => {
mockGroupRepository.findOne.mockResolvedValue(mockGroup); mockGroupRepository.findOne.mockResolvedValue(mockGroup);
mockGroupMemberRepository.findOne.mockResolvedValue(mockMembership); mockGroupMemberRepository.findOne.mockResolvedValue(mockMembership);
mockGameRepository.findOne.mockResolvedValue(null); mockGameRepository.findOne.mockResolvedValue(null);
await expect( await expect(
service.create('user-1', { service.create("user-1", {
groupId: 'group-1', groupId: "group-1",
gameId: 'game-1', gameId: "game-1",
title: '周末开黑', title: "周末开黑",
startTime: new Date('2024-01-20T19:00:00Z'), startTime: new Date("2024-01-20T19:00:00Z"),
maxParticipants: 5, maxParticipants: 5,
}), }),
).rejects.toThrow(NotFoundException); ).rejects.toThrow(NotFoundException);
}); });
}); });
describe('findAll', () => { describe("findAll", () => {
it('应该成功获取预约列表', async () => { it("应该成功获取预约列表", async () => {
const mockQueryBuilder = { const mockQueryBuilder = {
leftJoinAndSelect: jest.fn().mockReturnThis(), leftJoinAndSelect: jest.fn().mockReturnThis(),
where: jest.fn().mockReturnThis(), where: jest.fn().mockReturnThis(),
@@ -234,23 +234,25 @@ describe('AppointmentsService', () => {
getManyAndCount: jest.fn().mockResolvedValue([[mockAppointment], 1]), getManyAndCount: jest.fn().mockResolvedValue([[mockAppointment], 1]),
}; };
mockAppointmentRepository.createQueryBuilder.mockReturnValue(mockQueryBuilder); mockAppointmentRepository.createQueryBuilder.mockReturnValue(
mockQueryBuilder,
);
mockGroupMemberRepository.findOne.mockResolvedValue(mockMembership); mockGroupMemberRepository.findOne.mockResolvedValue(mockMembership);
const result = await service.findAll('user-1', { const result = await service.findAll("user-1", {
groupId: 'group-1', groupId: "group-1",
page: 1, page: 1,
limit: 10, limit: 10,
}); });
expect(result).toHaveProperty('items'); expect(result).toHaveProperty("items");
expect(result).toHaveProperty('total'); expect(result).toHaveProperty("total");
expect(result.items).toHaveLength(1); expect(result.items).toHaveLength(1);
}); });
}); });
describe('findOne', () => { describe("findOne", () => {
it('应该成功获取预约详情', async () => { it("应该成功获取预约详情", async () => {
mockAppointmentRepository.findOne.mockResolvedValue({ mockAppointmentRepository.findOne.mockResolvedValue({
...mockAppointment, ...mockAppointment,
group: mockGroup, group: mockGroup,
@@ -258,77 +260,77 @@ describe('AppointmentsService', () => {
creator: mockUser, creator: mockUser,
}); });
const result = await service.findOne('appointment-1'); const result = await service.findOne("appointment-1");
expect(result).toHaveProperty('id'); expect(result).toHaveProperty("id");
expect(result.id).toBe('appointment-1'); expect(result.id).toBe("appointment-1");
}); });
it('应该在预约不存在时抛出异常', async () => { it("应该在预约不存在时抛出异常", async () => {
mockAppointmentRepository.findOne.mockResolvedValue(null); mockAppointmentRepository.findOne.mockResolvedValue(null);
await expect(service.findOne('appointment-1')).rejects.toThrow( await expect(service.findOne("appointment-1")).rejects.toThrow(
NotFoundException, NotFoundException,
); );
}); });
}); });
describe('update', () => { describe("update", () => {
it('应该成功更新预约', async () => { it("应该成功更新预约", async () => {
mockAppointmentRepository.findOne mockAppointmentRepository.findOne
.mockResolvedValueOnce(mockAppointment) .mockResolvedValueOnce(mockAppointment)
.mockResolvedValueOnce({ .mockResolvedValueOnce({
...mockAppointment, ...mockAppointment,
title: '更新后的标题', title: "更新后的标题",
group: mockGroup, group: mockGroup,
game: mockGame, game: mockGame,
creator: mockUser, creator: mockUser,
}); });
mockAppointmentRepository.save.mockResolvedValue({ mockAppointmentRepository.save.mockResolvedValue({
...mockAppointment, ...mockAppointment,
title: '更新后的标题', title: "更新后的标题",
}); });
const result = await service.update('user-1', 'appointment-1', { const result = await service.update("user-1", "appointment-1", {
title: '更新后的标题', title: "更新后的标题",
}); });
expect(result.title).toBe('更新后的标题'); expect(result.title).toBe("更新后的标题");
}); });
it('应该在非创建者更新时抛出异常', async () => { it("应该在非创建者更新时抛出异常", async () => {
mockAppointmentRepository.findOne.mockResolvedValue(mockAppointment); mockAppointmentRepository.findOne.mockResolvedValue(mockAppointment);
await expect( await expect(
service.update('user-2', 'appointment-1', { title: '新标题' }), service.update("user-2", "appointment-1", { title: "新标题" }),
).rejects.toThrow(ForbiddenException); ).rejects.toThrow(ForbiddenException);
}); });
}); });
describe('cancel', () => { describe("cancel", () => {
it('应该成功取消预约', async () => { it("应该成功取消预约", async () => {
mockAppointmentRepository.findOne.mockResolvedValue(mockAppointment); mockAppointmentRepository.findOne.mockResolvedValue(mockAppointment);
mockAppointmentRepository.save.mockResolvedValue({ mockAppointmentRepository.save.mockResolvedValue({
...mockAppointment, ...mockAppointment,
status: AppointmentStatus.CANCELLED, status: AppointmentStatus.CANCELLED,
}); });
const result = await service.cancel('user-1', 'appointment-1'); const result = await service.cancel("user-1", "appointment-1");
expect(result).toHaveProperty('message'); expect(result).toHaveProperty("message");
}); });
it('应该在非创建者取消时抛出异常', async () => { it("应该在非创建者取消时抛出异常", async () => {
mockAppointmentRepository.findOne.mockResolvedValue(mockAppointment); mockAppointmentRepository.findOne.mockResolvedValue(mockAppointment);
await expect( await expect(service.cancel("user-2", "appointment-1")).rejects.toThrow(
service.cancel('user-2', 'appointment-1'), ForbiddenException,
).rejects.toThrow(ForbiddenException); );
}); });
}); });
describe('join', () => { describe("join", () => {
it('应该成功加入预约', async () => { it("应该成功加入预约", async () => {
mockAppointmentRepository.findOne.mockResolvedValue(mockAppointment); mockAppointmentRepository.findOne.mockResolvedValue(mockAppointment);
mockGroupMemberRepository.findOne.mockResolvedValue(mockMembership); mockGroupMemberRepository.findOne.mockResolvedValue(mockMembership);
mockParticipantRepository.findOne.mockResolvedValue(null); mockParticipantRepository.findOne.mockResolvedValue(null);
@@ -336,61 +338,61 @@ describe('AppointmentsService', () => {
mockParticipantRepository.create.mockReturnValue(mockParticipant); mockParticipantRepository.create.mockReturnValue(mockParticipant);
mockParticipantRepository.save.mockResolvedValue(mockParticipant); mockParticipantRepository.save.mockResolvedValue(mockParticipant);
const result = await service.join('user-2', 'appointment-1'); const result = await service.join("user-2", "appointment-1");
expect(result).toHaveProperty('message'); expect(result).toHaveProperty("message");
expect(mockParticipantRepository.save).toHaveBeenCalled(); expect(mockParticipantRepository.save).toHaveBeenCalled();
}); });
it('应该在预约已满时抛出异常', async () => { it("应该在预约已满时抛出异常", async () => {
mockAppointmentRepository.findOne.mockResolvedValue(mockAppointment); mockAppointmentRepository.findOne.mockResolvedValue(mockAppointment);
mockGroupMemberRepository.findOne.mockResolvedValue(mockMembership); mockGroupMemberRepository.findOne.mockResolvedValue(mockMembership);
mockParticipantRepository.findOne.mockResolvedValue(null); mockParticipantRepository.findOne.mockResolvedValue(null);
mockParticipantRepository.count.mockResolvedValue(5); mockParticipantRepository.count.mockResolvedValue(5);
await expect( await expect(service.join("user-2", "appointment-1")).rejects.toThrow(
service.join('user-2', 'appointment-1'), BadRequestException,
).rejects.toThrow(BadRequestException); );
}); });
it('应该在已加入时抛出异常', async () => { it("应该在已加入时抛出异常", async () => {
mockAppointmentRepository.findOne.mockResolvedValue(mockAppointment); mockAppointmentRepository.findOne.mockResolvedValue(mockAppointment);
mockGroupMemberRepository.findOne.mockResolvedValue(mockMembership); mockGroupMemberRepository.findOne.mockResolvedValue(mockMembership);
mockParticipantRepository.findOne.mockResolvedValue(mockParticipant); mockParticipantRepository.findOne.mockResolvedValue(mockParticipant);
await expect( await expect(service.join("user-1", "appointment-1")).rejects.toThrow(
service.join('user-1', 'appointment-1'), BadRequestException,
).rejects.toThrow(BadRequestException); );
}); });
}); });
describe('leave', () => { describe("leave", () => {
it('应该成功离开预约', async () => { it("应该成功离开预约", async () => {
mockAppointmentRepository.findOne.mockResolvedValue(mockAppointment); mockAppointmentRepository.findOne.mockResolvedValue(mockAppointment);
mockParticipantRepository.findOne.mockResolvedValue(mockParticipant); mockParticipantRepository.findOne.mockResolvedValue(mockParticipant);
mockParticipantRepository.remove.mockResolvedValue(mockParticipant); mockParticipantRepository.remove.mockResolvedValue(mockParticipant);
const result = await service.leave('user-1', 'appointment-1'); const result = await service.leave("user-1", "appointment-1");
expect(result).toHaveProperty('message'); expect(result).toHaveProperty("message");
expect(mockParticipantRepository.remove).toHaveBeenCalled(); expect(mockParticipantRepository.remove).toHaveBeenCalled();
}); });
it('应该在创建者尝试离开时抛出异常', async () => { it("应该在创建者尝试离开时抛出异常", async () => {
mockAppointmentRepository.findOne.mockResolvedValue(mockAppointment); mockAppointmentRepository.findOne.mockResolvedValue(mockAppointment);
await expect( await expect(service.leave("user-1", "appointment-1")).rejects.toThrow(
service.leave('user-1', 'appointment-1'), BadRequestException,
).rejects.toThrow(BadRequestException); );
}); });
it('应该在未加入时抛出异常', async () => { it("应该在未加入时抛出异常", async () => {
mockAppointmentRepository.findOne.mockResolvedValue(mockAppointment); mockAppointmentRepository.findOne.mockResolvedValue(mockAppointment);
mockParticipantRepository.findOne.mockResolvedValue(null); mockParticipantRepository.findOne.mockResolvedValue(null);
await expect( await expect(service.leave("user-2", "appointment-1")).rejects.toThrow(
service.leave('user-2', 'appointment-1'), BadRequestException,
).rejects.toThrow(BadRequestException); );
}); });
}); });
}); });

View File

@@ -3,28 +3,31 @@ import {
NotFoundException, NotFoundException,
BadRequestException, BadRequestException,
ForbiddenException, ForbiddenException,
} from '@nestjs/common'; } from "@nestjs/common";
import { InjectRepository } from '@nestjs/typeorm'; import { InjectRepository } from "@nestjs/typeorm";
import { Repository, Between, LessThan, MoreThan } from 'typeorm'; import { Repository, Between, LessThan, MoreThan } from "typeorm";
import { Appointment } from '../../entities/appointment.entity'; import { Appointment } from "../../entities/appointment.entity";
import { AppointmentParticipant } from '../../entities/appointment-participant.entity'; import { AppointmentParticipant } from "../../entities/appointment-participant.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 { Game } from '../../entities/game.entity'; import { Game } from "../../entities/game.entity";
import { User } from '../../entities/user.entity'; import { User } from "../../entities/user.entity";
import { import {
CreateAppointmentDto, CreateAppointmentDto,
UpdateAppointmentDto, UpdateAppointmentDto,
QueryAppointmentsDto, QueryAppointmentsDto,
} from './dto/appointment.dto'; } from "./dto/appointment.dto";
import { AppointmentStatus, GroupMemberRole } from '../../common/enums'; import { AppointmentStatus, GroupMemberRole } from "../../common/enums";
import { ErrorCode, ErrorMessage } from '../../common/interfaces/response.interface'; import {
import { PaginationUtil } from '../../common/utils/pagination.util'; ErrorCode,
import { CacheService } from '../../common/services/cache.service'; ErrorMessage,
} from "../../common/interfaces/response.interface";
import { PaginationUtil } from "../../common/utils/pagination.util";
import { CacheService } from "../../common/services/cache.service";
@Injectable() @Injectable()
export class AppointmentsService { export class AppointmentsService {
private readonly CACHE_PREFIX = 'appointment'; private readonly CACHE_PREFIX = "appointment";
private readonly CACHE_TTL = 300; // 5分钟 private readonly CACHE_TTL = 300; // 5分钟
constructor( constructor(
@@ -119,40 +122,45 @@ export class AppointmentsService {
const { offset } = PaginationUtil.formatPaginationParams(page, limit); const { offset } = PaginationUtil.formatPaginationParams(page, limit);
const queryBuilder = this.appointmentRepository const queryBuilder = this.appointmentRepository
.createQueryBuilder('appointment') .createQueryBuilder("appointment")
.leftJoinAndSelect('appointment.group', 'group') .leftJoinAndSelect("appointment.group", "group")
.leftJoinAndSelect('appointment.game', 'game') .leftJoinAndSelect("appointment.game", "game")
.leftJoinAndSelect('appointment.creator', 'creator') .leftJoinAndSelect("appointment.creator", "creator")
.leftJoinAndSelect('appointment.participants', 'participants') .leftJoinAndSelect("appointment.participants", "participants")
.leftJoinAndSelect('participants.user', 'participantUser'); .leftJoinAndSelect("participants.user", "participantUser");
// 筛选条件 // 筛选条件
if (groupId) { if (groupId) {
queryBuilder.andWhere('appointment.groupId = :groupId', { groupId }); queryBuilder.andWhere("appointment.groupId = :groupId", { groupId });
} }
if (gameId) { if (gameId) {
queryBuilder.andWhere('appointment.gameId = :gameId', { gameId }); queryBuilder.andWhere("appointment.gameId = :gameId", { gameId });
} }
if (status) { if (status) {
queryBuilder.andWhere('appointment.status = :status', { status }); queryBuilder.andWhere("appointment.status = :status", { status });
} }
if (startTime && endTime) { if (startTime && endTime) {
queryBuilder.andWhere('appointment.startTime BETWEEN :startTime AND :endTime', { queryBuilder.andWhere(
"appointment.startTime BETWEEN :startTime AND :endTime",
{
startTime, startTime,
endTime, endTime,
}); },
);
} else if (startTime) { } else if (startTime) {
queryBuilder.andWhere('appointment.startTime >= :startTime', { startTime }); queryBuilder.andWhere("appointment.startTime >= :startTime", {
startTime,
});
} else if (endTime) { } else if (endTime) {
queryBuilder.andWhere('appointment.startTime <= :endTime', { endTime }); queryBuilder.andWhere("appointment.startTime <= :endTime", { endTime });
} }
// 分页 // 分页
const [items, total] = await queryBuilder const [items, total] = await queryBuilder
.orderBy('appointment.startTime', 'ASC') .orderBy("appointment.startTime", "ASC")
.skip(offset) .skip(offset)
.take(limit) .take(limit)
.getManyAndCount(); .getManyAndCount();
@@ -174,22 +182,27 @@ export class AppointmentsService {
const { offset } = PaginationUtil.formatPaginationParams(page, limit); const { offset } = PaginationUtil.formatPaginationParams(page, limit);
const queryBuilder = this.appointmentRepository const queryBuilder = this.appointmentRepository
.createQueryBuilder('appointment') .createQueryBuilder("appointment")
.innerJoin('appointment.participants', 'participant', 'participant.userId = :userId', { .innerJoin(
"appointment.participants",
"participant",
"participant.userId = :userId",
{
userId, userId,
}) },
.leftJoinAndSelect('appointment.group', 'group') )
.leftJoinAndSelect('appointment.game', 'game') .leftJoinAndSelect("appointment.group", "group")
.leftJoinAndSelect('appointment.creator', 'creator') .leftJoinAndSelect("appointment.game", "game")
.leftJoinAndSelect('appointment.participants', 'participants') .leftJoinAndSelect("appointment.creator", "creator")
.leftJoinAndSelect('participants.user', 'participantUser'); .leftJoinAndSelect("appointment.participants", "participants")
.leftJoinAndSelect("participants.user", "participantUser");
if (status) { if (status) {
queryBuilder.andWhere('appointment.status = :status', { status }); queryBuilder.andWhere("appointment.status = :status", { status });
} }
const [items, total] = await queryBuilder const [items, total] = await queryBuilder
.orderBy('appointment.startTime', 'ASC') .orderBy("appointment.startTime", "ASC")
.skip(offset) .skip(offset)
.take(limit) .take(limit)
.getManyAndCount(); .getManyAndCount();
@@ -209,14 +222,22 @@ export class AppointmentsService {
async findOne(id: string, userId?: string) { async findOne(id: string, userId?: string) {
// 先查缓存 // 先查缓存
const cacheKey = userId ? `${id}_${userId}` : id; const cacheKey = userId ? `${id}_${userId}` : id;
const cached = this.cacheService.get<any>(cacheKey, { prefix: this.CACHE_PREFIX }); const cached = this.cacheService.get<any>(cacheKey, {
prefix: this.CACHE_PREFIX,
});
if (cached) { if (cached) {
return cached; return cached;
} }
const appointment = await this.appointmentRepository.findOne({ const appointment = await this.appointmentRepository.findOne({
where: { id }, where: { id },
relations: ['group', 'game', 'creator', 'participants', 'participants.user'], relations: [
"group",
"game",
"creator",
"participants",
"participants.user",
],
}); });
if (!appointment) { if (!appointment) {
@@ -256,14 +277,14 @@ export class AppointmentsService {
if (appointment.status === AppointmentStatus.CANCELLED) { if (appointment.status === AppointmentStatus.CANCELLED) {
throw new BadRequestException({ throw new BadRequestException({
code: ErrorCode.APPOINTMENT_CLOSED, code: ErrorCode.APPOINTMENT_CLOSED,
message: '预约已取消', message: "预约已取消",
}); });
} }
if (appointment.status === AppointmentStatus.FINISHED) { if (appointment.status === AppointmentStatus.FINISHED) {
throw new BadRequestException({ throw new BadRequestException({
code: ErrorCode.APPOINTMENT_CLOSED, code: ErrorCode.APPOINTMENT_CLOSED,
message: '预约已完成', message: "预约已完成",
}); });
} }
@@ -294,10 +315,10 @@ export class AppointmentsService {
.createQueryBuilder() .createQueryBuilder()
.update(Appointment) .update(Appointment)
.set({ .set({
currentParticipants: () => 'currentParticipants + 1', currentParticipants: () => "currentParticipants + 1",
}) })
.where('id = :id', { id: appointmentId }) .where("id = :id", { id: appointmentId })
.andWhere('currentParticipants < maxParticipants') .andWhere("currentParticipants < maxParticipants")
.execute(); .execute();
// 如果影响的行数为0说明预约已满 // 如果影响的行数为0说明预约已满
@@ -337,7 +358,7 @@ export class AppointmentsService {
if (appointment.initiatorId === userId) { if (appointment.initiatorId === userId) {
throw new BadRequestException({ throw new BadRequestException({
code: ErrorCode.NO_PERMISSION, code: ErrorCode.NO_PERMISSION,
message: '创建者不能退出预约', message: "创建者不能退出预约",
}); });
} }
@@ -354,7 +375,7 @@ export class AppointmentsService {
await this.participantRepository.remove(participant); await this.participantRepository.remove(participant);
return { message: '已退出预约' }; return { message: "已退出预约" };
} }
/** /**
@@ -373,7 +394,11 @@ export class AppointmentsService {
} }
// 检查权限:创建者或小组管理员 // 检查权限:创建者或小组管理员
await this.checkPermission(userId, appointment.groupId, appointment.initiatorId); await this.checkPermission(
userId,
appointment.groupId,
appointment.initiatorId,
);
Object.assign(appointment, updateDto); Object.assign(appointment, updateDto);
await this.appointmentRepository.save(appointment); await this.appointmentRepository.save(appointment);
@@ -400,12 +425,16 @@ export class AppointmentsService {
} }
// 检查权限:创建者或小组管理员 // 检查权限:创建者或小组管理员
await this.checkPermission(userId, appointment.groupId, appointment.initiatorId); await this.checkPermission(
userId,
appointment.groupId,
appointment.initiatorId,
);
appointment.status = AppointmentStatus.CANCELLED; appointment.status = AppointmentStatus.CANCELLED;
await this.appointmentRepository.save(appointment); await this.appointmentRepository.save(appointment);
return { message: '预约已取消' }; return { message: "预约已取消" };
} }
/** /**
@@ -414,7 +443,7 @@ export class AppointmentsService {
async confirm(userId: string, id: string) { async confirm(userId: string, id: string) {
const appointment = await this.appointmentRepository.findOne({ const appointment = await this.appointmentRepository.findOne({
where: { id }, where: { id },
relations: ['participants'], relations: ["participants"],
}); });
if (!appointment) { if (!appointment) {
@@ -425,7 +454,11 @@ export class AppointmentsService {
} }
// 检查权限:创建者或小组管理员 // 检查权限:创建者或小组管理员
await this.checkPermission(userId, appointment.groupId, appointment.initiatorId); await this.checkPermission(
userId,
appointment.groupId,
appointment.initiatorId,
);
// 检查是否已满员 // 检查是否已满员
if (appointment.participants.length >= appointment.maxParticipants) { if (appointment.participants.length >= appointment.maxParticipants) {
@@ -453,7 +486,11 @@ export class AppointmentsService {
} }
// 检查权限:创建者或小组管理员 // 检查权限:创建者或小组管理员
await this.checkPermission(userId, appointment.groupId, appointment.initiatorId); await this.checkPermission(
userId,
appointment.groupId,
appointment.initiatorId,
);
appointment.status = AppointmentStatus.FINISHED; appointment.status = AppointmentStatus.FINISHED;
await this.appointmentRepository.save(appointment); await this.appointmentRepository.save(appointment);

View File

@@ -8,42 +8,42 @@ import {
IsEnum, IsEnum,
IsArray, IsArray,
ValidateNested, ValidateNested,
} from 'class-validator'; } from "class-validator";
import { ApiProperty } from '@nestjs/swagger'; import { ApiProperty } from "@nestjs/swagger";
import { Type } from 'class-transformer'; import { Type } from "class-transformer";
import { AppointmentStatus } from '../../../common/enums'; import { AppointmentStatus } from "../../../common/enums";
export class CreateAppointmentDto { export class CreateAppointmentDto {
@ApiProperty({ description: '小组ID' }) @ApiProperty({ description: "小组ID" })
@IsString() @IsString()
@IsNotEmpty({ message: '小组ID不能为空' }) @IsNotEmpty({ message: "小组ID不能为空" })
groupId: string; groupId: string;
@ApiProperty({ description: '游戏ID' }) @ApiProperty({ description: "游戏ID" })
@IsString() @IsString()
@IsNotEmpty({ message: '游戏ID不能为空' }) @IsNotEmpty({ message: "游戏ID不能为空" })
gameId: string; gameId: string;
@ApiProperty({ description: '预约标题' }) @ApiProperty({ description: "预约标题" })
@IsString() @IsString()
@IsNotEmpty({ message: '预约标题不能为空' }) @IsNotEmpty({ message: "预约标题不能为空" })
title: string; title: string;
@ApiProperty({ description: '预约描述', required: false }) @ApiProperty({ description: "预约描述", required: false })
@IsString() @IsString()
@IsOptional() @IsOptional()
description?: string; description?: string;
@ApiProperty({ description: '预约开始时间' }) @ApiProperty({ description: "预约开始时间" })
@IsDateString() @IsDateString()
startTime: Date; startTime: Date;
@ApiProperty({ description: '预约结束时间', required: false }) @ApiProperty({ description: "预约结束时间", required: false })
@IsDateString() @IsDateString()
@IsOptional() @IsOptional()
endTime?: Date; endTime?: Date;
@ApiProperty({ description: '最大参与人数', example: 5 }) @ApiProperty({ description: "最大参与人数", example: 5 })
@IsNumber() @IsNumber()
@Min(1) @Min(1)
@Type(() => Number) @Type(() => Number)
@@ -51,80 +51,88 @@ export class CreateAppointmentDto {
} }
export class UpdateAppointmentDto { export class UpdateAppointmentDto {
@ApiProperty({ description: '预约标题', required: false }) @ApiProperty({ description: "预约标题", required: false })
@IsString() @IsString()
@IsOptional() @IsOptional()
title?: string; title?: string;
@ApiProperty({ description: '预约描述', required: false }) @ApiProperty({ description: "预约描述", required: false })
@IsString() @IsString()
@IsOptional() @IsOptional()
description?: string; description?: string;
@ApiProperty({ description: '预约开始时间', required: false }) @ApiProperty({ description: "预约开始时间", required: false })
@IsDateString() @IsDateString()
@IsOptional() @IsOptional()
startTime?: Date; startTime?: Date;
@ApiProperty({ description: '预约结束时间', required: false }) @ApiProperty({ description: "预约结束时间", required: false })
@IsDateString() @IsDateString()
@IsOptional() @IsOptional()
endTime?: Date; endTime?: Date;
@ApiProperty({ description: '最大参与人数', required: false }) @ApiProperty({ description: "最大参与人数", required: false })
@IsNumber() @IsNumber()
@Min(1) @Min(1)
@IsOptional() @IsOptional()
@Type(() => Number) @Type(() => Number)
maxParticipants?: number; maxParticipants?: number;
@ApiProperty({ description: '状态', enum: AppointmentStatus, required: false }) @ApiProperty({
description: "状态",
enum: AppointmentStatus,
required: false,
})
@IsEnum(AppointmentStatus) @IsEnum(AppointmentStatus)
@IsOptional() @IsOptional()
status?: AppointmentStatus; status?: AppointmentStatus;
} }
export class JoinAppointmentDto { export class JoinAppointmentDto {
@ApiProperty({ description: '预约ID' }) @ApiProperty({ description: "预约ID" })
@IsString() @IsString()
@IsNotEmpty({ message: '预约ID不能为空' }) @IsNotEmpty({ message: "预约ID不能为空" })
appointmentId: string; appointmentId: string;
} }
export class QueryAppointmentsDto { export class QueryAppointmentsDto {
@ApiProperty({ description: '小组ID', required: false }) @ApiProperty({ description: "小组ID", required: false })
@IsString() @IsString()
@IsOptional() @IsOptional()
groupId?: string; groupId?: string;
@ApiProperty({ description: '游戏ID', required: false }) @ApiProperty({ description: "游戏ID", required: false })
@IsString() @IsString()
@IsOptional() @IsOptional()
gameId?: string; gameId?: string;
@ApiProperty({ description: '状态', enum: AppointmentStatus, required: false }) @ApiProperty({
description: "状态",
enum: AppointmentStatus,
required: false,
})
@IsEnum(AppointmentStatus) @IsEnum(AppointmentStatus)
@IsOptional() @IsOptional()
status?: AppointmentStatus; status?: AppointmentStatus;
@ApiProperty({ description: '开始时间', required: false }) @ApiProperty({ description: "开始时间", required: false })
@IsDateString() @IsDateString()
@IsOptional() @IsOptional()
startTime?: Date; startTime?: Date;
@ApiProperty({ description: '结束时间', required: false }) @ApiProperty({ description: "结束时间", required: false })
@IsDateString() @IsDateString()
@IsOptional() @IsOptional()
endTime?: Date; endTime?: Date;
@ApiProperty({ description: '页码', example: 1, required: false }) @ApiProperty({ description: "页码", example: 1, required: false })
@IsNumber() @IsNumber()
@Min(1) @Min(1)
@IsOptional() @IsOptional()
@Type(() => Number) @Type(() => Number)
page?: number; page?: number;
@ApiProperty({ description: '每页数量', example: 10, required: false }) @ApiProperty({ description: "每页数量", example: 10, required: false })
@IsNumber() @IsNumber()
@Min(1) @Min(1)
@IsOptional() @IsOptional()
@@ -133,55 +141,55 @@ export class QueryAppointmentsDto {
} }
export class PollOptionDto { export class PollOptionDto {
@ApiProperty({ description: '选项时间' }) @ApiProperty({ description: "选项时间" })
@IsDateString() @IsDateString()
time: Date; time: Date;
@ApiProperty({ description: '选项描述', required: false }) @ApiProperty({ description: "选项描述", required: false })
@IsString() @IsString()
@IsOptional() @IsOptional()
description?: string; description?: string;
} }
export class CreatePollDto { export class CreatePollDto {
@ApiProperty({ description: '小组ID' }) @ApiProperty({ description: "小组ID" })
@IsString() @IsString()
@IsNotEmpty({ message: '小组ID不能为空' }) @IsNotEmpty({ message: "小组ID不能为空" })
groupId: string; groupId: string;
@ApiProperty({ description: '游戏ID' }) @ApiProperty({ description: "游戏ID" })
@IsString() @IsString()
@IsNotEmpty({ message: '游戏ID不能为空' }) @IsNotEmpty({ message: "游戏ID不能为空" })
gameId: string; gameId: string;
@ApiProperty({ description: '投票标题' }) @ApiProperty({ description: "投票标题" })
@IsString() @IsString()
@IsNotEmpty({ message: '投票标题不能为空' }) @IsNotEmpty({ message: "投票标题不能为空" })
title: string; title: string;
@ApiProperty({ description: '投票描述', required: false }) @ApiProperty({ description: "投票描述", required: false })
@IsString() @IsString()
@IsOptional() @IsOptional()
description?: string; description?: string;
@ApiProperty({ description: '投票选项', type: [PollOptionDto] }) @ApiProperty({ description: "投票选项", type: [PollOptionDto] })
@IsArray() @IsArray()
@ValidateNested({ each: true }) @ValidateNested({ each: true })
@Type(() => PollOptionDto) @Type(() => PollOptionDto)
options: PollOptionDto[]; options: PollOptionDto[];
@ApiProperty({ description: '投票截止时间' }) @ApiProperty({ description: "投票截止时间" })
@IsDateString() @IsDateString()
deadline: Date; deadline: Date;
} }
export class VoteDto { export class VoteDto {
@ApiProperty({ description: '投票ID' }) @ApiProperty({ description: "投票ID" })
@IsString() @IsString()
@IsNotEmpty({ message: '投票ID不能为空' }) @IsNotEmpty({ message: "投票ID不能为空" })
pollId: string; pollId: string;
@ApiProperty({ description: '选项索引' }) @ApiProperty({ description: "选项索引" })
@IsNumber() @IsNumber()
@Min(0) @Min(0)
@Type(() => Number) @Type(() => Number)

View File

@@ -8,77 +8,82 @@ import {
Delete, Delete,
UseGuards, UseGuards,
Query, Query,
} from '@nestjs/common'; } from "@nestjs/common";
import { ApiTags, ApiOperation, ApiBearerAuth } from '@nestjs/swagger'; import { ApiTags, ApiOperation, ApiBearerAuth } from "@nestjs/swagger";
import { AssetsService } from './assets.service'; import { AssetsService } from "./assets.service";
import { CreateAssetDto, UpdateAssetDto, BorrowAssetDto, ReturnAssetDto } from './dto/asset.dto'; import {
import { JwtAuthGuard } from '../../common/guards/jwt-auth.guard'; CreateAssetDto,
import { CurrentUser } from '../../common/decorators/current-user.decorator'; 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') @ApiTags("assets")
@Controller('assets') @Controller("assets")
@UseGuards(JwtAuthGuard) @UseGuards(JwtAuthGuard)
@ApiBearerAuth() @ApiBearerAuth()
export class AssetsController { export class AssetsController {
constructor(private readonly assetsService: AssetsService) {} constructor(private readonly assetsService: AssetsService) {}
@Post() @Post()
@ApiOperation({ summary: '创建资产(管理员)' }) @ApiOperation({ summary: "创建资产(管理员)" })
create(@CurrentUser() user, @Body() createDto: CreateAssetDto) { create(@CurrentUser() user, @Body() createDto: CreateAssetDto) {
return this.assetsService.create(user.id, createDto); return this.assetsService.create(user.id, createDto);
} }
@Get('group/:groupId') @Get("group/:groupId")
@ApiOperation({ summary: '查询小组资产列表' }) @ApiOperation({ summary: "查询小组资产列表" })
findAll(@Param('groupId') groupId: string) { findAll(@Param("groupId") groupId: string) {
return this.assetsService.findAll(groupId); return this.assetsService.findAll(groupId);
} }
@Get(':id') @Get(":id")
@ApiOperation({ summary: '查询资产详情' }) @ApiOperation({ summary: "查询资产详情" })
findOne(@CurrentUser() user, @Param('id') id: string) { findOne(@CurrentUser() user, @Param("id") id: string) {
return this.assetsService.findOne(id, user.id); return this.assetsService.findOne(id, user.id);
} }
@Patch(':id') @Patch(":id")
@ApiOperation({ summary: '更新资产(管理员)' }) @ApiOperation({ summary: "更新资产(管理员)" })
update( update(
@CurrentUser() user, @CurrentUser() user,
@Param('id') id: string, @Param("id") id: string,
@Body() updateDto: UpdateAssetDto, @Body() updateDto: UpdateAssetDto,
) { ) {
return this.assetsService.update(user.id, id, updateDto); return this.assetsService.update(user.id, id, updateDto);
} }
@Post(':id/borrow') @Post(":id/borrow")
@ApiOperation({ summary: '借用资产' }) @ApiOperation({ summary: "借用资产" })
borrow( borrow(
@CurrentUser() user, @CurrentUser() user,
@Param('id') id: string, @Param("id") id: string,
@Body() borrowDto: BorrowAssetDto, @Body() borrowDto: BorrowAssetDto,
) { ) {
return this.assetsService.borrow(user.id, id, borrowDto); return this.assetsService.borrow(user.id, id, borrowDto);
} }
@Post(':id/return') @Post(":id/return")
@ApiOperation({ summary: '归还资产' }) @ApiOperation({ summary: "归还资产" })
returnAsset( returnAsset(
@CurrentUser() user, @CurrentUser() user,
@Param('id') id: string, @Param("id") id: string,
@Body() returnDto: ReturnAssetDto, @Body() returnDto: ReturnAssetDto,
) { ) {
return this.assetsService.return(user.id, id, returnDto.note); return this.assetsService.return(user.id, id, returnDto.note);
} }
@Get(':id/logs') @Get(":id/logs")
@ApiOperation({ summary: '查询资产借还记录' }) @ApiOperation({ summary: "查询资产借还记录" })
getLogs(@Param('id') id: string) { getLogs(@Param("id") id: string) {
return this.assetsService.getLogs(id); return this.assetsService.getLogs(id);
} }
@Delete(':id') @Delete(":id")
@ApiOperation({ summary: '删除资产(管理员)' }) @ApiOperation({ summary: "删除资产(管理员)" })
remove(@CurrentUser() user, @Param('id') id: string) { remove(@CurrentUser() user, @Param("id") id: string) {
return this.assetsService.remove(user.id, id); return this.assetsService.remove(user.id, id);
} }
} }

View File

@@ -1,11 +1,11 @@
import { Module } from '@nestjs/common'; import { Module } from "@nestjs/common";
import { TypeOrmModule } from '@nestjs/typeorm'; import { TypeOrmModule } from "@nestjs/typeorm";
import { AssetsController } from './assets.controller'; import { AssetsController } from "./assets.controller";
import { AssetsService } from './assets.service'; import { AssetsService } from "./assets.service";
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";
@Module({ @Module({
imports: [TypeOrmModule.forFeature([Asset, AssetLog, Group, GroupMember])], imports: [TypeOrmModule.forFeature([Asset, AssetLog, Group, GroupMember])],

View File

@@ -1,15 +1,24 @@
import { Test, TestingModule } from '@nestjs/testing'; import { Test, TestingModule } from "@nestjs/testing";
import { getRepositoryToken } from '@nestjs/typeorm'; import { getRepositoryToken } from "@nestjs/typeorm";
import { Repository, DataSource } from 'typeorm'; import { Repository, DataSource } from "typeorm";
import { AssetsService } from './assets.service'; import { AssetsService } from "./assets.service";
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 { AssetType, AssetStatus, GroupMemberRole, AssetLogAction } from '../../common/enums'; import {
import { NotFoundException, ForbiddenException, BadRequestException } from '@nestjs/common'; AssetType,
AssetStatus,
GroupMemberRole,
AssetLogAction,
} from "../../common/enums";
import {
NotFoundException,
ForbiddenException,
BadRequestException,
} from "@nestjs/common";
describe('AssetsService', () => { describe("AssetsService", () => {
let service: AssetsService; let service: AssetsService;
let assetRepository: Repository<Asset>; let assetRepository: Repository<Asset>;
let assetLogRepository: Repository<AssetLog>; let assetLogRepository: Repository<AssetLog>;
@@ -17,12 +26,12 @@ describe('AssetsService', () => {
let groupMemberRepository: Repository<GroupMember>; let groupMemberRepository: Repository<GroupMember>;
const mockAsset = { const mockAsset = {
id: 'asset-1', id: "asset-1",
groupId: 'group-1', groupId: "group-1",
type: AssetType.ACCOUNT, type: AssetType.ACCOUNT,
name: '测试账号', name: "测试账号",
description: '测试描述', description: "测试描述",
accountCredentials: 'encrypted-data', accountCredentials: "encrypted-data",
quantity: 1, quantity: 1,
status: AssetStatus.AVAILABLE, status: AssetStatus.AVAILABLE,
currentBorrowerId: null, currentBorrowerId: null,
@@ -31,14 +40,14 @@ describe('AssetsService', () => {
}; };
const mockGroup = { const mockGroup = {
id: 'group-1', id: "group-1",
name: '测试小组', name: "测试小组",
}; };
const mockGroupMember = { const mockGroupMember = {
id: 'member-1', id: "member-1",
userId: 'user-1', userId: "user-1",
groupId: 'group-1', groupId: "group-1",
role: GroupMemberRole.ADMIN, role: GroupMemberRole.ADMIN,
}; };
@@ -101,141 +110,173 @@ describe('AssetsService', () => {
service = module.get<AssetsService>(AssetsService); service = module.get<AssetsService>(AssetsService);
assetRepository = module.get<Repository<Asset>>(getRepositoryToken(Asset)); assetRepository = module.get<Repository<Asset>>(getRepositoryToken(Asset));
assetLogRepository = module.get<Repository<AssetLog>>(getRepositoryToken(AssetLog)); assetLogRepository = module.get<Repository<AssetLog>>(
getRepositoryToken(AssetLog),
);
groupRepository = module.get<Repository<Group>>(getRepositoryToken(Group)); groupRepository = module.get<Repository<Group>>(getRepositoryToken(Group));
groupMemberRepository = module.get<Repository<GroupMember>>(getRepositoryToken(GroupMember)); groupMemberRepository = module.get<Repository<GroupMember>>(
getRepositoryToken(GroupMember),
);
}); });
it('should be defined', () => { it("should be defined", () => {
expect(service).toBeDefined(); expect(service).toBeDefined();
}); });
describe('create', () => { describe("create", () => {
it('应该成功创建资产', async () => { it("应该成功创建资产", async () => {
const createDto = { const createDto = {
groupId: 'group-1', groupId: "group-1",
type: AssetType.ACCOUNT, type: AssetType.ACCOUNT,
name: '测试账号', name: "测试账号",
description: '测试描述', description: "测试描述",
}; };
jest.spyOn(groupRepository, 'findOne').mockResolvedValue(mockGroup as any); jest
jest.spyOn(groupMemberRepository, 'findOne').mockResolvedValue(mockGroupMember as any); .spyOn(groupRepository, "findOne")
jest.spyOn(assetRepository, 'create').mockReturnValue(mockAsset as any); .mockResolvedValue(mockGroup as any);
jest.spyOn(assetRepository, 'save').mockResolvedValue(mockAsset as any); jest
jest.spyOn(assetRepository, 'findOne').mockResolvedValue(mockAsset as any); .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); const result = await service.create("user-1", createDto);
expect(result).toBeDefined(); expect(result).toBeDefined();
expect(groupRepository.findOne).toHaveBeenCalledWith({ where: { id: 'group-1' } }); expect(groupRepository.findOne).toHaveBeenCalledWith({
where: { id: "group-1" },
});
expect(groupMemberRepository.findOne).toHaveBeenCalled(); expect(groupMemberRepository.findOne).toHaveBeenCalled();
}); });
it('小组不存在时应该抛出异常', async () => { it("小组不存在时应该抛出异常", async () => {
const createDto = { const createDto = {
groupId: 'group-1', groupId: "group-1",
type: AssetType.ACCOUNT, type: AssetType.ACCOUNT,
name: '测试账号', name: "测试账号",
}; };
jest.spyOn(groupRepository, 'findOne').mockResolvedValue(null); jest.spyOn(groupRepository, "findOne").mockResolvedValue(null);
await expect(service.create('user-1', createDto)).rejects.toThrow(NotFoundException); await expect(service.create("user-1", createDto)).rejects.toThrow(
NotFoundException,
);
}); });
it('无权限时应该抛出异常', async () => { it("无权限时应该抛出异常", async () => {
const createDto = { const createDto = {
groupId: 'group-1', groupId: "group-1",
type: AssetType.ACCOUNT, type: AssetType.ACCOUNT,
name: '测试账号', name: "测试账号",
}; };
jest.spyOn(groupRepository, 'findOne').mockResolvedValue(mockGroup as any); jest
jest.spyOn(groupMemberRepository, 'findOne').mockResolvedValue({ .spyOn(groupRepository, "findOne")
.mockResolvedValue(mockGroup as any);
jest.spyOn(groupMemberRepository, "findOne").mockResolvedValue({
...mockGroupMember, ...mockGroupMember,
role: GroupMemberRole.MEMBER, role: GroupMemberRole.MEMBER,
} as any); } as any);
await expect(service.create('user-1', createDto)).rejects.toThrow(ForbiddenException); await expect(service.create("user-1", createDto)).rejects.toThrow(
ForbiddenException,
);
}); });
}); });
describe('findAll', () => { describe("findAll", () => {
it('应该返回资产列表', async () => { it("应该返回资产列表", async () => {
jest.spyOn(assetRepository, 'find').mockResolvedValue([mockAsset] as any); jest.spyOn(assetRepository, "find").mockResolvedValue([mockAsset] as any);
const result = await service.findAll('group-1'); const result = await service.findAll("group-1");
expect(result).toHaveLength(1); expect(result).toHaveLength(1);
expect(result[0].accountCredentials).toBeUndefined(); expect(result[0].accountCredentials).toBeUndefined();
}); });
}); });
describe('borrow', () => { describe("borrow", () => {
it('应该成功借用资产', async () => { it("应该成功借用资产", async () => {
const borrowDto = { reason: '需要使用' }; const borrowDto = { reason: "需要使用" };
jest.spyOn(assetRepository, 'findOne').mockResolvedValue(mockAsset as any); jest
jest.spyOn(groupMemberRepository, 'findOne').mockResolvedValue(mockGroupMember as any); .spyOn(assetRepository, "findOne")
jest.spyOn(assetRepository, 'save').mockResolvedValue({ ...mockAsset, status: AssetStatus.IN_USE } as any); .mockResolvedValue(mockAsset as any);
jest.spyOn(assetLogRepository, 'create').mockReturnValue({} as any); jest
jest.spyOn(assetLogRepository, 'save').mockResolvedValue({} as any); .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); const result = await service.borrow("user-1", "asset-1", borrowDto);
expect(result.message).toBe('借用成功'); expect(result.message).toBe("借用成功");
expect(assetRepository.save).toHaveBeenCalled(); expect(assetRepository.save).toHaveBeenCalled();
expect(assetLogRepository.save).toHaveBeenCalled(); expect(assetLogRepository.save).toHaveBeenCalled();
}); });
it('资产不可用时应该抛出异常', async () => { it("资产不可用时应该抛出异常", async () => {
const borrowDto = { reason: '需要使用' }; const borrowDto = { reason: "需要使用" };
jest.spyOn(assetRepository, 'findOne').mockResolvedValue({ jest.spyOn(assetRepository, "findOne").mockResolvedValue({
...mockAsset, ...mockAsset,
status: AssetStatus.IN_USE, status: AssetStatus.IN_USE,
} as any); } as any);
await expect(service.borrow('user-1', 'asset-1', borrowDto)).rejects.toThrow(BadRequestException); await expect(
service.borrow("user-1", "asset-1", borrowDto),
).rejects.toThrow(BadRequestException);
}); });
}); });
describe('return', () => { describe("return", () => {
it('应该成功归还资产', async () => { it("应该成功归还资产", async () => {
jest.spyOn(assetRepository, 'findOne').mockResolvedValue({ jest.spyOn(assetRepository, "findOne").mockResolvedValue({
...mockAsset, ...mockAsset,
currentBorrowerId: 'user-1', currentBorrowerId: "user-1",
} as any); } as any);
jest.spyOn(assetRepository, 'save').mockResolvedValue(mockAsset as any); jest.spyOn(assetRepository, "save").mockResolvedValue(mockAsset as any);
jest.spyOn(assetLogRepository, 'create').mockReturnValue({} as any); jest.spyOn(assetLogRepository, "create").mockReturnValue({} as any);
jest.spyOn(assetLogRepository, 'save').mockResolvedValue({} as any); jest.spyOn(assetLogRepository, "save").mockResolvedValue({} as any);
const result = await service.return('user-1', 'asset-1', '已归还'); const result = await service.return("user-1", "asset-1", "已归还");
expect(result.message).toBe('归还成功'); expect(result.message).toBe("归还成功");
expect(assetRepository.save).toHaveBeenCalled(); expect(assetRepository.save).toHaveBeenCalled();
}); });
it('非借用人归还时应该抛出异常', async () => { it("非借用人归还时应该抛出异常", async () => {
jest.spyOn(assetRepository, 'findOne').mockResolvedValue({ jest.spyOn(assetRepository, "findOne").mockResolvedValue({
...mockAsset, ...mockAsset,
currentBorrowerId: 'user-2', currentBorrowerId: "user-2",
} as any); } as any);
await expect(service.return('user-1', 'asset-1')).rejects.toThrow(ForbiddenException); await expect(service.return("user-1", "asset-1")).rejects.toThrow(
ForbiddenException,
);
}); });
}); });
describe('remove', () => { describe("remove", () => {
it('应该成功删除资产', async () => { it("应该成功删除资产", async () => {
jest.spyOn(assetRepository, 'findOne').mockResolvedValue(mockAsset as any); jest
jest.spyOn(groupMemberRepository, 'findOne').mockResolvedValue(mockGroupMember as any); .spyOn(assetRepository, "findOne")
jest.spyOn(assetRepository, 'remove').mockResolvedValue(mockAsset as any); .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'); const result = await service.remove("user-1", "asset-1");
expect(result.message).toBe('删除成功'); expect(result.message).toBe("删除成功");
expect(assetRepository.remove).toHaveBeenCalled(); expect(assetRepository.remove).toHaveBeenCalled();
}); });
}); });

View File

@@ -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,
}; };
} }
} }
@@ -155,7 +240,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: "资产不存在",
}); });
} }
@@ -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: "删除成功" };
} }
} }

View File

@@ -5,36 +5,36 @@ import {
IsNumber, IsNumber,
IsEnum, IsEnum,
Min, Min,
} from 'class-validator'; } from "class-validator";
import { ApiProperty } from '@nestjs/swagger'; import { ApiProperty } from "@nestjs/swagger";
import { AssetType, AssetStatus } from '../../../common/enums'; import { AssetType, AssetStatus } from "../../../common/enums";
export class CreateAssetDto { export class CreateAssetDto {
@ApiProperty({ description: '小组ID' }) @ApiProperty({ description: "小组ID" })
@IsString() @IsString()
@IsNotEmpty({ message: '小组ID不能为空' }) @IsNotEmpty({ message: "小组ID不能为空" })
groupId: string; groupId: string;
@ApiProperty({ description: '资产类型', enum: AssetType }) @ApiProperty({ description: "资产类型", enum: AssetType })
@IsEnum(AssetType) @IsEnum(AssetType)
type: AssetType; type: AssetType;
@ApiProperty({ description: '资产名称', example: '公用游戏账号' }) @ApiProperty({ description: "资产名称", example: "公用游戏账号" })
@IsString() @IsString()
@IsNotEmpty({ message: '名称不能为空' }) @IsNotEmpty({ message: "名称不能为空" })
name: string; name: string;
@ApiProperty({ description: '描述', required: false }) @ApiProperty({ description: "描述", required: false })
@IsString() @IsString()
@IsOptional() @IsOptional()
description?: string; description?: string;
@ApiProperty({ description: '账号凭据(将加密存储)', required: false }) @ApiProperty({ description: "账号凭据(将加密存储)", required: false })
@IsString() @IsString()
@IsOptional() @IsOptional()
accountCredentials?: string; accountCredentials?: string;
@ApiProperty({ description: '数量', example: 1, required: false }) @ApiProperty({ description: "数量", example: 1, required: false })
@IsNumber() @IsNumber()
@Min(1) @Min(1)
@IsOptional() @IsOptional()
@@ -42,42 +42,42 @@ export class CreateAssetDto {
} }
export class UpdateAssetDto { export class UpdateAssetDto {
@ApiProperty({ description: '资产名称', required: false }) @ApiProperty({ description: "资产名称", required: false })
@IsString() @IsString()
@IsOptional() @IsOptional()
name?: string; name?: string;
@ApiProperty({ description: '描述', required: false }) @ApiProperty({ description: "描述", required: false })
@IsString() @IsString()
@IsOptional() @IsOptional()
description?: string; description?: string;
@ApiProperty({ description: '账号凭据', required: false }) @ApiProperty({ description: "账号凭据", required: false })
@IsString() @IsString()
@IsOptional() @IsOptional()
accountCredentials?: string; accountCredentials?: string;
@ApiProperty({ description: '数量', required: false }) @ApiProperty({ description: "数量", required: false })
@IsNumber() @IsNumber()
@Min(1) @Min(1)
@IsOptional() @IsOptional()
quantity?: number; quantity?: number;
@ApiProperty({ description: '状态', enum: AssetStatus, required: false }) @ApiProperty({ description: "状态", enum: AssetStatus, required: false })
@IsEnum(AssetStatus) @IsEnum(AssetStatus)
@IsOptional() @IsOptional()
status?: AssetStatus; status?: AssetStatus;
} }
export class BorrowAssetDto { export class BorrowAssetDto {
@ApiProperty({ description: '借用理由', required: false }) @ApiProperty({ description: "借用理由", required: false })
@IsString() @IsString()
@IsOptional() @IsOptional()
reason?: string; reason?: string;
} }
export class ReturnAssetDto { export class ReturnAssetDto {
@ApiProperty({ description: '归还备注', required: false }) @ApiProperty({ description: "归还备注", required: false })
@IsString() @IsString()
@IsOptional() @IsOptional()
note?: string; note?: string;

View File

@@ -1,11 +1,11 @@
import { Test, TestingModule } from '@nestjs/testing'; import { Test, TestingModule } from "@nestjs/testing";
import { INestApplication, ValidationPipe } from '@nestjs/common'; import { INestApplication, ValidationPipe } from "@nestjs/common";
import request from 'supertest'; import request from "supertest";
import { AuthController } from './auth.controller'; import { AuthController } from "./auth.controller";
import { AuthService } from './auth.service'; import { AuthService } from "./auth.service";
import { JwtAuthGuard } from '../../common/guards/jwt-auth.guard'; import { JwtAuthGuard } from "../../common/guards/jwt-auth.guard";
describe('AuthController (e2e)', () => { describe("AuthController (e2e)", () => {
let app: INestApplication; let app: INestApplication;
let authService: AuthService; let authService: AuthService;
@@ -44,96 +44,96 @@ describe('AuthController (e2e)', () => {
jest.clearAllMocks(); jest.clearAllMocks();
}); });
describe('/api/auth/register (POST)', () => { describe("/api/auth/register (POST)", () => {
it('应该成功注册并返回用户信息和Token', () => { it("应该成功注册并返回用户信息和Token", () => {
const registerDto = { const registerDto = {
username: 'testuser', username: "testuser",
password: 'Password123!', password: "Password123!",
email: 'test@example.com', email: "test@example.com",
}; };
const mockResponse = { const mockResponse = {
user: { user: {
id: 'test-id', id: "test-id",
username: 'testuser', username: "testuser",
email: 'test@example.com', email: "test@example.com",
}, },
accessToken: 'access-token', accessToken: "access-token",
refreshToken: 'refresh-token', refreshToken: "refresh-token",
}; };
mockAuthService.register.mockResolvedValue(mockResponse); mockAuthService.register.mockResolvedValue(mockResponse);
return request(app.getHttpServer()) return request(app.getHttpServer())
.post('/auth/register') .post("/auth/register")
.send(registerDto) .send(registerDto)
.expect(201) .expect(201)
.expect((res) => { .expect((res) => {
expect(res.body.data).toHaveProperty('user'); expect(res.body.data).toHaveProperty("user");
expect(res.body.data).toHaveProperty('accessToken'); expect(res.body.data).toHaveProperty("accessToken");
expect(res.body.data).toHaveProperty('refreshToken'); expect(res.body.data).toHaveProperty("refreshToken");
}); });
}); });
it('应该在缺少必填字段时返回400', () => { it("应该在缺少必填字段时返回400", () => {
return request(app.getHttpServer()) return request(app.getHttpServer())
.post('/auth/register') .post("/auth/register")
.send({ .send({
username: 'testuser', username: "testuser",
// 缺少密码 // 缺少密码
}) })
.expect(400); .expect(400);
}); });
}); });
describe('/api/auth/login (POST)', () => { describe("/api/auth/login (POST)", () => {
it('应该成功登录', () => { it("应该成功登录", () => {
const loginDto = { const loginDto = {
username: 'testuser', username: "testuser",
password: 'Password123!', password: "Password123!",
}; };
const mockResponse = { const mockResponse = {
user: { user: {
id: 'test-id', id: "test-id",
username: 'testuser', username: "testuser",
}, },
accessToken: 'access-token', accessToken: "access-token",
refreshToken: 'refresh-token', refreshToken: "refresh-token",
}; };
mockAuthService.login.mockResolvedValue(mockResponse); mockAuthService.login.mockResolvedValue(mockResponse);
return request(app.getHttpServer()) return request(app.getHttpServer())
.post('/auth/login') .post("/auth/login")
.send(loginDto) .send(loginDto)
.expect(200) .expect(200)
.expect((res) => { .expect((res) => {
expect(res.body.data).toHaveProperty('accessToken'); expect(res.body.data).toHaveProperty("accessToken");
}); });
}); });
}); });
describe('/api/auth/refresh (POST)', () => { describe("/api/auth/refresh (POST)", () => {
it('应该成功刷新Token', () => { it("应该成功刷新Token", () => {
const refreshDto = { const refreshDto = {
refreshToken: 'valid-refresh-token', refreshToken: "valid-refresh-token",
}; };
const mockResponse = { const mockResponse = {
accessToken: 'new-access-token', accessToken: "new-access-token",
refreshToken: 'new-refresh-token', refreshToken: "new-refresh-token",
}; };
mockAuthService.refreshToken.mockResolvedValue(mockResponse); mockAuthService.refreshToken.mockResolvedValue(mockResponse);
return request(app.getHttpServer()) return request(app.getHttpServer())
.post('/auth/refresh') .post("/auth/refresh")
.send(refreshDto) .send(refreshDto)
.expect(200) .expect(200)
.expect((res) => { .expect((res) => {
expect(res.body.data).toHaveProperty('accessToken'); expect(res.body.data).toHaveProperty("accessToken");
expect(res.body.data).toHaveProperty('refreshToken'); expect(res.body.data).toHaveProperty("refreshToken");
}); });
}); });
}); });

View File

@@ -1,37 +1,76 @@
import { Controller, Post, Body, HttpCode, HttpStatus, Ip } from '@nestjs/common'; import {
import { ApiTags, ApiOperation, ApiResponse } from '@nestjs/swagger'; Controller,
import { AuthService } from './auth.service'; Post,
import { RegisterDto, LoginDto, RefreshTokenDto } from './dto/auth.dto'; Body,
import { Public } from '../../common/decorators/public.decorator'; HttpCode,
HttpStatus,
Ip,
} from "@nestjs/common";
import { ApiTags, ApiOperation, ApiResponse } from "@nestjs/swagger";
import { Throttle } from "@nestjs/throttler";
import { AuthService } from "./auth.service";
import { RegisterDto, LoginDto, RefreshTokenDto } from "./dto/auth.dto";
import { Public } from "../../common/decorators/public.decorator";
import { CurrentUser } from "../../common/decorators/current-user.decorator";
import { User } from "../../entities/user.entity";
@ApiTags('auth') @ApiTags("auth")
@Controller('auth') @Controller("auth")
export class AuthController { export class AuthController {
constructor(private readonly authService: AuthService) {} constructor(private readonly authService: AuthService) {}
@Public() @Public()
@Post('register') @Post("register")
@ApiOperation({ summary: '用户注册' }) @Throttle({
@ApiResponse({ status: 201, description: '注册成功' }) default: {
limit: 3, // 每分钟最多3次注册请求
ttl: 60000,
},
})
@ApiOperation({ summary: "用户注册" })
@ApiResponse({ status: 201, description: "注册成功" })
async register(@Body() registerDto: RegisterDto) { async register(@Body() registerDto: RegisterDto) {
return this.authService.register(registerDto); return this.authService.register(registerDto);
} }
@Public() @Public()
@Post('login') @Post("login")
@Throttle({
default: {
limit: 5, // 每分钟最多5次登录请求
ttl: 60000,
},
})
@HttpCode(HttpStatus.OK) @HttpCode(HttpStatus.OK)
@ApiOperation({ summary: '用户登录' }) @ApiOperation({ summary: "用户登录" })
@ApiResponse({ status: 200, description: '登录成功' }) @ApiResponse({ status: 200, description: "登录成功" })
async login(@Body() loginDto: LoginDto, @Ip() ip: string) { async login(@Body() loginDto: LoginDto, @Ip() ip: string) {
return this.authService.login(loginDto, ip); return this.authService.login(loginDto, ip);
} }
@Public() @Public()
@Post('refresh') @Post("refresh")
@Throttle({
default: {
limit: 10, // 每分钟最多10次刷新令牌请求
ttl: 60000,
},
})
@HttpCode(HttpStatus.OK) @HttpCode(HttpStatus.OK)
@ApiOperation({ summary: '刷新令牌' }) @ApiOperation({ summary: "刷新令牌" })
@ApiResponse({ status: 200, description: '刷新成功' }) @ApiResponse({ status: 200, description: "刷新成功" })
async refresh(@Body() refreshTokenDto: RefreshTokenDto) { async refresh(@Body() refreshTokenDto: RefreshTokenDto) {
return this.authService.refreshToken(refreshTokenDto.refreshToken); return this.authService.refreshToken(refreshTokenDto.refreshToken);
} }
@Post("logout")
@HttpCode(HttpStatus.OK)
@ApiOperation({ summary: "登出" })
@ApiResponse({ status: 200, description: "登出成功" })
async logout(
@CurrentUser() user: User,
@Body() body: { refreshToken: string },
) {
return this.authService.logout(user.id, body.refreshToken);
}
} }

View File

@@ -1,23 +1,23 @@
import { Module } from '@nestjs/common'; import { Module } from "@nestjs/common";
import { JwtModule } from '@nestjs/jwt'; import { JwtModule } from "@nestjs/jwt";
import { PassportModule } from '@nestjs/passport'; import { PassportModule } from "@nestjs/passport";
import { TypeOrmModule } from '@nestjs/typeorm'; import { TypeOrmModule } from "@nestjs/typeorm";
import { ConfigModule, ConfigService } from '@nestjs/config'; import { ConfigModule, ConfigService } from "@nestjs/config";
import { AuthService } from './auth.service'; import { AuthService } from "./auth.service";
import { AuthController } from './auth.controller'; import { AuthController } from "./auth.controller";
import { JwtStrategy } from './jwt.strategy'; import { JwtStrategy } from "./jwt.strategy";
import { User } from '../../entities/user.entity'; import { User } from "../../entities/user.entity";
@Module({ @Module({
imports: [ imports: [
TypeOrmModule.forFeature([User]), TypeOrmModule.forFeature([User]),
PassportModule.register({ defaultStrategy: 'jwt' }), PassportModule.register({ defaultStrategy: "jwt" }),
JwtModule.registerAsync({ JwtModule.registerAsync({
imports: [ConfigModule], imports: [ConfigModule],
useFactory: (configService: ConfigService) => ({ useFactory: (configService: ConfigService) => ({
secret: configService.get('jwt.secret'), secret: configService.get("jwt.secret"),
signOptions: { signOptions: {
expiresIn: configService.get('jwt.expiresIn'), expiresIn: configService.get("jwt.expiresIn"),
}, },
}), }),
inject: [ConfigService], inject: [ConfigService],

View File

@@ -1,25 +1,25 @@
import { Test, TestingModule } from '@nestjs/testing'; import { Test, TestingModule } from "@nestjs/testing";
import { JwtService } from '@nestjs/jwt'; import { JwtService } from "@nestjs/jwt";
import { ConfigService } from '@nestjs/config'; import { ConfigService } from "@nestjs/config";
import { getRepositoryToken } from '@nestjs/typeorm'; import { getRepositoryToken } from "@nestjs/typeorm";
import { Repository } from 'typeorm'; import { Repository } from "typeorm";
import { BadRequestException, UnauthorizedException } from '@nestjs/common'; import { BadRequestException, UnauthorizedException } from "@nestjs/common";
import { AuthService } from './auth.service'; import { AuthService } from "./auth.service";
import { User } from '../../entities/user.entity'; import { User } from "../../entities/user.entity";
import { CryptoUtil } from '../../common/utils/crypto.util'; import { CryptoUtil } from "../../common/utils/crypto.util";
import { UserRole } from '../../common/enums'; import { UserRole } from "../../common/enums";
describe('AuthService', () => { describe("AuthService", () => {
let service: AuthService; let service: AuthService;
let userRepository: Repository<User>; let userRepository: Repository<User>;
let jwtService: JwtService; let jwtService: JwtService;
const mockUser = { const mockUser = {
id: 'test-user-id', id: "test-user-id",
username: 'testuser', username: "testuser",
email: 'test@example.com', email: "test@example.com",
phone: '13800138000', phone: "13800138000",
password: 'hashedPassword', password: "hashedPassword",
role: UserRole.USER, role: UserRole.USER,
isMember: false, isMember: false,
memberExpiredAt: null, memberExpiredAt: null,
@@ -45,9 +45,9 @@ describe('AuthService', () => {
const mockConfigService = { const mockConfigService = {
get: jest.fn((key: string) => { get: jest.fn((key: string) => {
const config = { const config = {
'jwt.secret': 'test-secret', "jwt.secret": "test-secret",
'jwt.accessExpiresIn': '15m', "jwt.accessExpiresIn": "15m",
'jwt.refreshExpiresIn': '7d', "jwt.refreshExpiresIn": "7d",
}; };
return config[key]; return config[key];
}), }),
@@ -81,13 +81,13 @@ describe('AuthService', () => {
jest.clearAllMocks(); jest.clearAllMocks();
}); });
describe('register', () => { describe("register", () => {
it('应该成功注册新用户', async () => { it("应该成功注册新用户", async () => {
const registerDto = { const registerDto = {
username: 'newuser', username: "newuser",
password: 'Password123!', password: "Password123!",
email: 'new@example.com', email: "new@example.com",
phone: '13900139000', phone: "13900139000",
}; };
mockUserRepository.findOne mockUserRepository.findOne
@@ -96,33 +96,33 @@ describe('AuthService', () => {
mockUserRepository.create.mockReturnValue({ mockUserRepository.create.mockReturnValue({
...registerDto, ...registerDto,
id: 'new-user-id', id: "new-user-id",
password: 'hashedPassword', password: "hashedPassword",
}); });
mockUserRepository.save.mockResolvedValue({ mockUserRepository.save.mockResolvedValue({
...registerDto, ...registerDto,
id: 'new-user-id', id: "new-user-id",
}); });
mockJwtService.signAsync mockJwtService.signAsync
.mockResolvedValueOnce('access-token') .mockResolvedValueOnce("access-token")
.mockResolvedValueOnce('refresh-token'); .mockResolvedValueOnce("refresh-token");
const result = await service.register(registerDto); const result = await service.register(registerDto);
expect(result).toHaveProperty('user'); expect(result).toHaveProperty("user");
expect(result).toHaveProperty('accessToken', 'access-token'); expect(result).toHaveProperty("accessToken", "access-token");
expect(result).toHaveProperty('refreshToken', 'refresh-token'); expect(result).toHaveProperty("refreshToken", "refresh-token");
expect(mockUserRepository.findOne).toHaveBeenCalledTimes(2); expect(mockUserRepository.findOne).toHaveBeenCalledTimes(2);
expect(mockUserRepository.save).toHaveBeenCalled(); expect(mockUserRepository.save).toHaveBeenCalled();
}); });
it('应该在邮箱已存在时抛出异常', async () => { it("应该在邮箱已存在时抛出异常", async () => {
const registerDto = { const registerDto = {
username: 'newuser', username: "newuser",
password: 'Password123!', password: "Password123!",
email: 'existing@example.com', email: "existing@example.com",
}; };
mockUserRepository.findOne.mockResolvedValueOnce(mockUser); mockUserRepository.findOne.mockResolvedValueOnce(mockUser);
@@ -132,11 +132,11 @@ describe('AuthService', () => {
); );
}); });
it('应该在手机号已存在时抛出异常', async () => { it("应该在手机号已存在时抛出异常", async () => {
const registerDto = { const registerDto = {
username: 'newuser', username: "newuser",
password: 'Password123!', password: "Password123!",
phone: '13800138000', phone: "13800138000",
}; };
mockUserRepository.findOne mockUserRepository.findOne
@@ -148,10 +148,10 @@ describe('AuthService', () => {
); );
}); });
it('应该在缺少邮箱和手机号时抛出异常', async () => { it("应该在缺少邮箱和手机号时抛出异常", async () => {
const registerDto = { const registerDto = {
username: 'newuser', username: "newuser",
password: 'Password123!', password: "Password123!",
}; };
await expect(service.register(registerDto)).rejects.toThrow( await expect(service.register(registerDto)).rejects.toThrow(
@@ -160,113 +160,113 @@ describe('AuthService', () => {
}); });
}); });
describe('login', () => { describe("login", () => {
it('应该使用用户名成功登录', async () => { it("应该使用用户名成功登录", async () => {
const loginDto = { const loginDto = {
account: 'testuser', account: "testuser",
password: 'Password123!', password: "Password123!",
}; };
mockUserRepository.findOne.mockResolvedValue({ mockUserRepository.findOne.mockResolvedValue({
...mockUser, ...mockUser,
password: await CryptoUtil.hashPassword('Password123!'), password: await CryptoUtil.hashPassword("Password123!"),
}); });
mockJwtService.signAsync mockJwtService.signAsync
.mockResolvedValueOnce('access-token') .mockResolvedValueOnce("access-token")
.mockResolvedValueOnce('refresh-token'); .mockResolvedValueOnce("refresh-token");
const result = await service.login(loginDto, '127.0.0.1'); const result = await service.login(loginDto, "127.0.0.1");
expect(result).toHaveProperty('user'); expect(result).toHaveProperty("user");
expect(result).toHaveProperty('accessToken', 'access-token'); expect(result).toHaveProperty("accessToken", "access-token");
expect(result).toHaveProperty('refreshToken', 'refresh-token'); expect(result).toHaveProperty("refreshToken", "refresh-token");
expect(mockUserRepository.findOne).toHaveBeenCalledWith({ expect(mockUserRepository.findOne).toHaveBeenCalledWith({
where: { username: loginDto.account }, where: { username: loginDto.account },
}); });
}); });
it('应该使用邮箱成功登录', async () => { it("应该使用邮箱成功登录", async () => {
const loginDto = { const loginDto = {
account: 'test@example.com', account: "test@example.com",
password: 'Password123!', password: "Password123!",
}; };
mockUserRepository.findOne.mockResolvedValue({ mockUserRepository.findOne.mockResolvedValue({
...mockUser, ...mockUser,
password: await CryptoUtil.hashPassword('Password123!'), password: await CryptoUtil.hashPassword("Password123!"),
}); });
mockJwtService.signAsync mockJwtService.signAsync
.mockResolvedValueOnce('access-token') .mockResolvedValueOnce("access-token")
.mockResolvedValueOnce('refresh-token'); .mockResolvedValueOnce("refresh-token");
const result = await service.login(loginDto, '127.0.0.1'); const result = await service.login(loginDto, "127.0.0.1");
expect(result).toHaveProperty('user'); expect(result).toHaveProperty("user");
expect(result).toHaveProperty('accessToken'); expect(result).toHaveProperty("accessToken");
expect(mockUserRepository.findOne).toHaveBeenCalledWith({ expect(mockUserRepository.findOne).toHaveBeenCalledWith({
where: { email: loginDto.account }, where: { email: loginDto.account },
}); });
}); });
it('应该在用户不存在时抛出异常', async () => { it("应该在用户不存在时抛出异常", async () => {
const loginDto = { const loginDto = {
account: 'nonexistent', account: "nonexistent",
password: 'Password123!', password: "Password123!",
}; };
mockUserRepository.findOne.mockResolvedValue(null); mockUserRepository.findOne.mockResolvedValue(null);
await expect(service.login(loginDto, '127.0.0.1')).rejects.toThrow( await expect(service.login(loginDto, "127.0.0.1")).rejects.toThrow(
UnauthorizedException, UnauthorizedException,
); );
}); });
it('应该在密码错误时抛出异常', async () => { it("应该在密码错误时抛出异常", async () => {
const loginDto = { const loginDto = {
account: 'testuser', account: "testuser",
password: 'WrongPassword', password: "WrongPassword",
}; };
mockUserRepository.findOne.mockResolvedValue({ mockUserRepository.findOne.mockResolvedValue({
...mockUser, ...mockUser,
password: await CryptoUtil.hashPassword('CorrectPassword'), password: await CryptoUtil.hashPassword("CorrectPassword"),
}); });
await expect(service.login(loginDto, '127.0.0.1')).rejects.toThrow( await expect(service.login(loginDto, "127.0.0.1")).rejects.toThrow(
UnauthorizedException, UnauthorizedException,
); );
}); });
}); });
describe('refreshToken', () => { describe("refreshToken", () => {
it('应该成功刷新Token', async () => { it("应该成功刷新Token", async () => {
const refreshToken = 'valid-refresh-token'; const refreshToken = "valid-refresh-token";
mockJwtService.verify.mockReturnValue({ mockJwtService.verify.mockReturnValue({
sub: 'test-user-id', sub: "test-user-id",
username: 'testuser', username: "testuser",
}); });
mockUserRepository.findOne.mockResolvedValue(mockUser); mockUserRepository.findOne.mockResolvedValue(mockUser);
mockJwtService.signAsync mockJwtService.signAsync
.mockResolvedValueOnce('new-access-token') .mockResolvedValueOnce("new-access-token")
.mockResolvedValueOnce('new-refresh-token'); .mockResolvedValueOnce("new-refresh-token");
const result = await service.refreshToken(refreshToken); const result = await service.refreshToken(refreshToken);
expect(result).toHaveProperty('accessToken', 'new-access-token'); expect(result).toHaveProperty("accessToken", "new-access-token");
expect(result).toHaveProperty('refreshToken', 'new-refresh-token'); expect(result).toHaveProperty("refreshToken", "new-refresh-token");
expect(mockJwtService.verify).toHaveBeenCalledWith('valid-refresh-token'); expect(mockJwtService.verify).toHaveBeenCalledWith("valid-refresh-token");
}); });
it('应该在Token无效时抛出异常', async () => { it("应该在Token无效时抛出异常", async () => {
const refreshToken = 'invalid-token'; const refreshToken = "invalid-token";
mockJwtService.verify.mockImplementation(() => { mockJwtService.verify.mockImplementation(() => {
throw new Error('Invalid token'); throw new Error("Invalid token");
}); });
await expect(service.refreshToken(refreshToken)).rejects.toThrow( await expect(service.refreshToken(refreshToken)).rejects.toThrow(
@@ -274,12 +274,12 @@ describe('AuthService', () => {
); );
}); });
it('应该在用户不存在时抛出异常', async () => { it("应该在用户不存在时抛出异常", async () => {
const refreshToken = 'valid-refresh-token'; const refreshToken = "valid-refresh-token";
mockJwtService.verify.mockReturnValue({ mockJwtService.verify.mockReturnValue({
sub: 'nonexistent-user-id', sub: "nonexistent-user-id",
username: 'nonexistent', username: "nonexistent",
}); });
mockUserRepository.findOne.mockResolvedValue(null); mockUserRepository.findOne.mockResolvedValue(null);
@@ -290,21 +290,21 @@ describe('AuthService', () => {
}); });
}); });
describe('validateUser', () => { describe("validateUser", () => {
it('应该返回用户信息(排除密码)', async () => { it("应该返回用户信息(排除密码)", async () => {
mockUserRepository.findOne.mockResolvedValue(mockUser); mockUserRepository.findOne.mockResolvedValue(mockUser);
const result = await service.validateUser('test-user-id'); const result = await service.validateUser("test-user-id");
expect(result).toBeDefined(); expect(result).toBeDefined();
expect(result.id).toBe('test-user-id'); expect(result.id).toBe("test-user-id");
expect(result).not.toHaveProperty('password'); expect(result).not.toHaveProperty("password");
}); });
it('应该在用户不存在时返回null', async () => { it("应该在用户不存在时返回null", async () => {
mockUserRepository.findOne.mockResolvedValue(null); mockUserRepository.findOne.mockResolvedValue(null);
const result = await service.validateUser('nonexistent-id'); const result = await service.validateUser("nonexistent-id");
expect(result).toBeNull(); expect(result).toBeNull();
}); });

View File

@@ -1,20 +1,34 @@
import { Injectable, UnauthorizedException, BadRequestException, HttpException } from '@nestjs/common'; import {
import { JwtService } from '@nestjs/jwt'; Injectable,
import { ConfigService } from '@nestjs/config'; UnauthorizedException,
import { InjectRepository } from '@nestjs/typeorm'; BadRequestException,
import { Repository } from 'typeorm'; HttpException,
import { User } from '../../entities/user.entity'; } from "@nestjs/common";
import { RegisterDto, LoginDto } from './dto/auth.dto'; import { JwtService } from "@nestjs/jwt";
import { CryptoUtil } from '../../common/utils/crypto.util'; import { ConfigService } from "@nestjs/config";
import { ErrorCode, ErrorMessage } from '../../common/interfaces/response.interface'; import { InjectRepository } from "@nestjs/typeorm";
import { Repository } from "typeorm";
import { User } from "../../entities/user.entity";
import { RegisterDto, LoginDto } from "./dto/auth.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 AuthService { export class AuthService {
private readonly REFRESH_TOKEN_PREFIX = "refresh_token";
private readonly REFRESH_TOKEN_BLACKLIST_PREFIX = "refresh_token_blacklist";
private readonly REFRESH_TOKEN_TTL = 30 * 24 * 60 * 60; // 30天(与refresh token过期时间一致)
constructor( constructor(
@InjectRepository(User) @InjectRepository(User)
private userRepository: Repository<User>, private userRepository: Repository<User>,
private jwtService: JwtService, private jwtService: JwtService,
private configService: ConfigService, private configService: ConfigService,
private cacheService: CacheService,
) {} ) {}
/** /**
@@ -27,7 +41,7 @@ export class AuthService {
if (!email && !phone) { if (!email && !phone) {
throw new BadRequestException({ throw new BadRequestException({
code: ErrorCode.PARAM_ERROR, code: ErrorCode.PARAM_ERROR,
message: '邮箱和手机号至少填写一个', message: "邮箱和手机号至少填写一个",
}); });
} }
@@ -45,7 +59,7 @@ export class AuthService {
throw new HttpException( throw new HttpException(
{ {
code: ErrorCode.USER_EXISTS, code: ErrorCode.USER_EXISTS,
message: '用户名已存在', message: "用户名已存在",
}, },
400, 400,
); );
@@ -54,7 +68,7 @@ export class AuthService {
throw new HttpException( throw new HttpException(
{ {
code: ErrorCode.USER_EXISTS, code: ErrorCode.USER_EXISTS,
message: '邮箱已被注册', message: "邮箱已被注册",
}, },
400, 400,
); );
@@ -63,7 +77,7 @@ export class AuthService {
throw new HttpException( throw new HttpException(
{ {
code: ErrorCode.USER_EXISTS, code: ErrorCode.USER_EXISTS,
message: '手机号已被注册', message: "手机号已被注册",
}, },
400, 400,
); );
@@ -84,7 +98,7 @@ export class AuthService {
await this.userRepository.save(user); await this.userRepository.save(user);
// 生成 token // 生成 token
const tokens = await this.generateTokens(user); const tokens = await this.generateTokens(user.id);
return { return {
user: { user: {
@@ -107,11 +121,11 @@ export class AuthService {
// 查找用户(支持用户名、邮箱、手机号登录) // 查找用户(支持用户名、邮箱、手机号登录)
const user = await this.userRepository const user = await this.userRepository
.createQueryBuilder('user') .createQueryBuilder("user")
.where('user.username = :account', { account }) .where("user.username = :account", { account })
.orWhere('user.email = :account', { account }) .orWhere("user.email = :account", { account })
.orWhere('user.phone = :account', { account }) .orWhere("user.phone = :account", { account })
.addSelect('user.password') .addSelect("user.password")
.getOne(); .getOne();
if (!user) { if (!user) {
@@ -140,7 +154,7 @@ export class AuthService {
await this.userRepository.save(user); await this.userRepository.save(user);
// 生成 token // 生成 token
const tokens = await this.generateTokens(user); const tokens = await this.generateTokens(user.id);
return { return {
user: { user: {
@@ -158,14 +172,27 @@ export class AuthService {
} }
/** /**
* 刷新 token * 刷新 token (实现 Token Rotation - 刷新后旧 token 立即失效)
*/ */
async refreshToken(refreshToken: string) { async refreshToken(refreshToken: string) {
try { try {
// 验证 refresh token 是否有效
const payload = this.jwtService.verify(refreshToken, { const payload = this.jwtService.verify(refreshToken, {
secret: this.configService.get('jwt.refreshSecret'), secret: this.configService.get("jwt.refreshSecret"),
}); });
// 检查 token 是否在黑名单中(已被使用过)
const isBlacklisted = this.cacheService.get<boolean>(refreshToken, {
prefix: this.REFRESH_TOKEN_BLACKLIST_PREFIX,
});
if (isBlacklisted) {
throw new UnauthorizedException({
code: ErrorCode.TOKEN_INVALID,
message: "Refresh token 已被使用,请重新登录",
});
}
// 验证用户是否存在
const user = await this.userRepository.findOne({ const user = await this.userRepository.findOne({
where: { id: payload.sub }, where: { id: payload.sub },
}); });
@@ -177,8 +204,32 @@ export class AuthService {
}); });
} }
return this.generateTokens(user); // 验证 refresh token 是否存在于白名单中
const storedToken = this.cacheService.get<string>(user.id, {
prefix: this.REFRESH_TOKEN_PREFIX,
});
if (storedToken !== refreshToken) {
throw new UnauthorizedException({
code: ErrorCode.TOKEN_INVALID,
message: "Refresh token 无效",
});
}
// Token Rotation: 将旧 refresh token 加入黑名单
this.cacheService.set(refreshToken, true, {
prefix: this.REFRESH_TOKEN_BLACKLIST_PREFIX,
ttl: this.REFRESH_TOKEN_TTL,
});
// 生成新的 token 对
const tokens = await this.generateTokens(user.id);
return tokens;
} catch (error) { } catch (error) {
if (error instanceof UnauthorizedException) {
throw error;
}
throw new UnauthorizedException({ throw new UnauthorizedException({
code: ErrorCode.TOKEN_INVALID, code: ErrorCode.TOKEN_INVALID,
message: ErrorMessage[ErrorCode.TOKEN_INVALID], message: ErrorMessage[ErrorCode.TOKEN_INVALID],
@@ -186,6 +237,22 @@ export class AuthService {
} }
} }
/**
* 登出 (将 refresh token 加入黑名单)
*/
async logout(userId: string, refreshToken: string) {
// 从白名单中移除 refresh token
this.cacheService.del(userId, { prefix: this.REFRESH_TOKEN_PREFIX });
// 将 refresh token 加入黑名单
this.cacheService.set(refreshToken, true, {
prefix: this.REFRESH_TOKEN_BLACKLIST_PREFIX,
ttl: this.REFRESH_TOKEN_TTL,
});
return { message: "登出成功" };
}
/** /**
* 验证用户 * 验证用户
*/ */
@@ -206,8 +273,18 @@ export class AuthService {
/** /**
* 生成 access token 和 refresh token * 生成 access token 和 refresh token
* 同时将 refresh token 存储到白名单
*/ */
private async generateTokens(user: User) { private async generateTokens(userId: string) {
const user = await this.userRepository.findOne({ where: { id: userId } });
if (!user) {
throw new UnauthorizedException({
code: ErrorCode.USER_NOT_FOUND,
message: ErrorMessage[ErrorCode.USER_NOT_FOUND],
});
}
const payload = { const payload = {
sub: user.id, sub: user.id,
username: user.username, username: user.username,
@@ -216,15 +293,21 @@ export class AuthService {
const [accessToken, refreshToken] = await Promise.all([ const [accessToken, refreshToken] = await Promise.all([
this.jwtService.signAsync(payload, { this.jwtService.signAsync(payload, {
secret: this.configService.get('jwt.secret'), secret: this.configService.get("jwt.secret"),
expiresIn: this.configService.get('jwt.expiresIn'), expiresIn: this.configService.get("jwt.expiresIn"),
}), }),
this.jwtService.signAsync(payload, { this.jwtService.signAsync(payload, {
secret: this.configService.get('jwt.refreshSecret'), secret: this.configService.get("jwt.refreshSecret"),
expiresIn: this.configService.get('jwt.refreshExpiresIn'), expiresIn: this.configService.get("jwt.refreshExpiresIn"),
}), }),
]); ]);
// 将 refresh token 存储到白名单
this.cacheService.set(userId, refreshToken, {
prefix: this.REFRESH_TOKEN_PREFIX,
ttl: this.REFRESH_TOKEN_TTL,
});
return { return {
accessToken, accessToken,
refreshToken, refreshToken,

View File

@@ -1,45 +1,59 @@
import { IsEmail, IsNotEmpty, IsOptional, IsString, MinLength } from 'class-validator'; import {
import { ApiProperty } from '@nestjs/swagger'; IsEmail,
IsNotEmpty,
IsOptional,
IsString,
MinLength,
} from "class-validator";
import { ApiProperty } from "@nestjs/swagger";
export class RegisterDto { export class RegisterDto {
@ApiProperty({ description: '用户名', example: 'john_doe' }) @ApiProperty({ description: "用户名", example: "john_doe" })
@IsString() @IsString()
@IsNotEmpty({ message: '用户名不能为空' }) @IsNotEmpty({ message: "用户名不能为空" })
@MinLength(3, { message: '用户名至少3个字符' }) @MinLength(3, { message: "用户名至少3个字符" })
username: string; username: string;
@ApiProperty({ description: '密码', example: 'Password123!' }) @ApiProperty({ description: "密码", example: "Password123!" })
@IsString() @IsString()
@IsNotEmpty({ message: '密码不能为空' }) @IsNotEmpty({ message: "密码不能为空" })
@MinLength(6, { message: '密码至少6个字符' }) @MinLength(6, { message: "密码至少6个字符" })
password: string; password: string;
@ApiProperty({ description: '邮箱', example: 'john@example.com', required: false }) @ApiProperty({
@IsEmail({}, { message: '邮箱格式不正确' }) description: "邮箱",
example: "john@example.com",
required: false,
})
@IsEmail({}, { message: "邮箱格式不正确" })
@IsOptional() @IsOptional()
email?: string; email?: string;
@ApiProperty({ description: '手机号', example: '13800138000', required: false }) @ApiProperty({
description: "手机号",
example: "13800138000",
required: false,
})
@IsString() @IsString()
@IsOptional() @IsOptional()
phone?: string; phone?: string;
} }
export class LoginDto { export class LoginDto {
@ApiProperty({ description: '用户名/邮箱/手机号', example: 'john_doe' }) @ApiProperty({ description: "用户名/邮箱/手机号", example: "john_doe" })
@IsString() @IsString()
@IsNotEmpty({ message: '账号不能为空' }) @IsNotEmpty({ message: "账号不能为空" })
account: string; account: string;
@ApiProperty({ description: '密码', example: 'Password123!' }) @ApiProperty({ description: "密码", example: "Password123!" })
@IsString() @IsString()
@IsNotEmpty({ message: '密码不能为空' }) @IsNotEmpty({ message: "密码不能为空" })
password: string; password: string;
} }
export class RefreshTokenDto { export class RefreshTokenDto {
@ApiProperty({ description: '刷新令牌' }) @ApiProperty({ description: "刷新令牌" })
@IsString() @IsString()
@IsNotEmpty({ message: '刷新令牌不能为空' }) @IsNotEmpty({ message: "刷新令牌不能为空" })
refreshToken: string; refreshToken: string;
} }

View File

@@ -1,9 +1,12 @@
import { Injectable, UnauthorizedException } from '@nestjs/common'; import { Injectable, UnauthorizedException } from "@nestjs/common";
import { PassportStrategy } from '@nestjs/passport'; import { PassportStrategy } from "@nestjs/passport";
import { ExtractJwt, Strategy } from 'passport-jwt'; import { ExtractJwt, Strategy } from "passport-jwt";
import { ConfigService } from '@nestjs/config'; import { ConfigService } from "@nestjs/config";
import { AuthService } from './auth.service'; import { AuthService } from "./auth.service";
import { ErrorCode, ErrorMessage } from '../../common/interfaces/response.interface'; import {
ErrorCode,
ErrorMessage,
} from "../../common/interfaces/response.interface";
@Injectable() @Injectable()
export class JwtStrategy extends PassportStrategy(Strategy) { export class JwtStrategy extends PassportStrategy(Strategy) {
@@ -14,7 +17,7 @@ export class JwtStrategy extends PassportStrategy(Strategy) {
super({ super({
jwtFromRequest: ExtractJwt.fromAuthHeaderAsBearerToken(), jwtFromRequest: ExtractJwt.fromAuthHeaderAsBearerToken(),
ignoreExpiration: false, ignoreExpiration: false,
secretOrKey: configService.get('jwt.secret') || 'default-secret', secretOrKey: configService.get("jwt.secret") || "default-secret",
}); });
} }

View File

@@ -1,49 +1,42 @@
import { import { Controller, Get, Post, Body, Param, UseGuards } from "@nestjs/common";
Controller, import { ApiTags, ApiOperation, ApiBearerAuth } from "@nestjs/swagger";
Get, import { BetsService } from "./bets.service";
Post, import { CreateBetDto, SettleBetDto } from "./dto/bet.dto";
Body, import { JwtAuthGuard } from "../../common/guards/jwt-auth.guard";
Param, import { CurrentUser } from "../../common/decorators/current-user.decorator";
UseGuards,
} from '@nestjs/common';
import { ApiTags, ApiOperation, ApiBearerAuth } from '@nestjs/swagger';
import { BetsService } from './bets.service';
import { CreateBetDto, SettleBetDto } from './dto/bet.dto';
import { JwtAuthGuard } from '../../common/guards/jwt-auth.guard';
import { CurrentUser } from '../../common/decorators/current-user.decorator';
@ApiTags('bets') @ApiTags("bets")
@Controller('bets') @Controller("bets")
@UseGuards(JwtAuthGuard) @UseGuards(JwtAuthGuard)
@ApiBearerAuth() @ApiBearerAuth()
export class BetsController { export class BetsController {
constructor(private readonly betsService: BetsService) {} constructor(private readonly betsService: BetsService) {}
@Post() @Post()
@ApiOperation({ summary: '创建竞猜下注' }) @ApiOperation({ summary: "创建竞猜下注" })
create(@CurrentUser() user, @Body() createDto: CreateBetDto) { create(@CurrentUser() user, @Body() createDto: CreateBetDto) {
return this.betsService.create(user.id, createDto); return this.betsService.create(user.id, createDto);
} }
@Get('appointment/:appointmentId') @Get("appointment/:appointmentId")
@ApiOperation({ summary: '查询预约的所有竞猜' }) @ApiOperation({ summary: "查询预约的所有竞猜" })
findAll(@Param('appointmentId') appointmentId: string) { findAll(@Param("appointmentId") appointmentId: string) {
return this.betsService.findAll(appointmentId); return this.betsService.findAll(appointmentId);
} }
@Post('appointment/:appointmentId/settle') @Post("appointment/:appointmentId/settle")
@ApiOperation({ summary: '结算竞猜(管理员)' }) @ApiOperation({ summary: "结算竞猜(管理员)" })
settle( settle(
@CurrentUser() user, @CurrentUser() user,
@Param('appointmentId') appointmentId: string, @Param("appointmentId") appointmentId: string,
@Body() settleDto: SettleBetDto, @Body() settleDto: SettleBetDto,
) { ) {
return this.betsService.settle(user.id, appointmentId, settleDto); return this.betsService.settle(user.id, appointmentId, settleDto);
} }
@Post('appointment/:appointmentId/cancel') @Post("appointment/:appointmentId/cancel")
@ApiOperation({ summary: '取消竞猜' }) @ApiOperation({ summary: "取消竞猜" })
cancel(@Param('appointmentId') appointmentId: string) { cancel(@Param("appointmentId") appointmentId: string) {
return this.betsService.cancel(appointmentId); return this.betsService.cancel(appointmentId);
} }
} }

View File

@@ -1,11 +1,11 @@
import { Module } from '@nestjs/common'; import { Module } from "@nestjs/common";
import { TypeOrmModule } from '@nestjs/typeorm'; import { TypeOrmModule } from "@nestjs/typeorm";
import { BetsController } from './bets.controller'; import { BetsController } from "./bets.controller";
import { BetsService } from './bets.service'; import { BetsService } from "./bets.service";
import { Bet } from '../../entities/bet.entity'; import { Bet } from "../../entities/bet.entity";
import { Appointment } from '../../entities/appointment.entity'; import { Appointment } from "../../entities/appointment.entity";
import { Point } from '../../entities/point.entity'; import { Point } from "../../entities/point.entity";
import { GroupMember } from '../../entities/group-member.entity'; import { GroupMember } from "../../entities/group-member.entity";
@Module({ @Module({
imports: [TypeOrmModule.forFeature([Bet, Appointment, Point, GroupMember])], imports: [TypeOrmModule.forFeature([Bet, Appointment, Point, GroupMember])],

View File

@@ -1,15 +1,23 @@
import { Test, TestingModule } from '@nestjs/testing'; import { Test, TestingModule } from "@nestjs/testing";
import { getRepositoryToken } from '@nestjs/typeorm'; import { getRepositoryToken } from "@nestjs/typeorm";
import { Repository, DataSource } from 'typeorm'; import { Repository, DataSource } from "typeorm";
import { BetsService } from './bets.service'; import { BetsService } from "./bets.service";
import { Bet } from '../../entities/bet.entity'; import { Bet } from "../../entities/bet.entity";
import { Appointment } from '../../entities/appointment.entity'; import { Appointment } from "../../entities/appointment.entity";
import { Point } from '../../entities/point.entity'; import { Point } from "../../entities/point.entity";
import { GroupMember } from '../../entities/group-member.entity'; import { GroupMember } from "../../entities/group-member.entity";
import { BetStatus, GroupMemberRole, AppointmentStatus } from '../../common/enums'; import {
import { NotFoundException, BadRequestException, ForbiddenException } from '@nestjs/common'; BetStatus,
GroupMemberRole,
AppointmentStatus,
} from "../../common/enums";
import {
NotFoundException,
BadRequestException,
ForbiddenException,
} from "@nestjs/common";
describe('BetsService', () => { describe("BetsService", () => {
let service: BetsService; let service: BetsService;
let betRepository: Repository<Bet>; let betRepository: Repository<Bet>;
let appointmentRepository: Repository<Appointment>; let appointmentRepository: Repository<Appointment>;
@@ -17,17 +25,17 @@ describe('BetsService', () => {
let groupMemberRepository: Repository<GroupMember>; let groupMemberRepository: Repository<GroupMember>;
const mockAppointment = { const mockAppointment = {
id: 'appointment-1', id: "appointment-1",
groupId: 'group-1', groupId: "group-1",
title: '测试预约', title: "测试预约",
status: AppointmentStatus.PENDING, status: AppointmentStatus.PENDING,
}; };
const mockBet = { const mockBet = {
id: 'bet-1', id: "bet-1",
appointmentId: 'appointment-1', appointmentId: "appointment-1",
userId: 'user-1', userId: "user-1",
betOption: '胜', betOption: "胜",
amount: 10, amount: 10,
status: BetStatus.PENDING, status: BetStatus.PENDING,
winAmount: 0, winAmount: 0,
@@ -35,9 +43,9 @@ describe('BetsService', () => {
}; };
const mockGroupMember = { const mockGroupMember = {
id: 'member-1', id: "member-1",
userId: 'user-1', userId: "user-1",
groupId: 'group-1', groupId: "group-1",
role: GroupMemberRole.ADMIN, role: GroupMemberRole.ADMIN,
}; };
@@ -107,175 +115,205 @@ describe('BetsService', () => {
service = module.get<BetsService>(BetsService); service = module.get<BetsService>(BetsService);
betRepository = module.get<Repository<Bet>>(getRepositoryToken(Bet)); betRepository = module.get<Repository<Bet>>(getRepositoryToken(Bet));
appointmentRepository = module.get<Repository<Appointment>>(getRepositoryToken(Appointment)); appointmentRepository = module.get<Repository<Appointment>>(
getRepositoryToken(Appointment),
);
pointRepository = module.get<Repository<Point>>(getRepositoryToken(Point)); pointRepository = module.get<Repository<Point>>(getRepositoryToken(Point));
groupMemberRepository = module.get<Repository<GroupMember>>(getRepositoryToken(GroupMember)); groupMemberRepository = module.get<Repository<GroupMember>>(
getRepositoryToken(GroupMember),
);
}); });
it('should be defined', () => { it("should be defined", () => {
expect(service).toBeDefined(); expect(service).toBeDefined();
}); });
describe('create', () => { describe("create", () => {
it('应该成功创建竞猜下注', async () => { it("应该成功创建竞猜下注", async () => {
const createDto = { const createDto = {
appointmentId: 'appointment-1', appointmentId: "appointment-1",
betOption: '胜', betOption: "胜",
amount: 10, amount: 10,
}; };
jest.spyOn(appointmentRepository, 'findOne').mockResolvedValue(mockAppointment as any); jest
mockQueryBuilder.getRawOne.mockResolvedValue({ total: '100' }); .spyOn(appointmentRepository, "findOne")
jest.spyOn(betRepository, 'findOne').mockResolvedValue(null); .mockResolvedValue(mockAppointment as any);
jest.spyOn(betRepository, 'create').mockReturnValue(mockBet as any); mockQueryBuilder.getRawOne.mockResolvedValue({ total: "100" });
jest.spyOn(betRepository, 'save').mockResolvedValue(mockBet as any); jest.spyOn(betRepository, "findOne").mockResolvedValue(null);
jest.spyOn(pointRepository, 'create').mockReturnValue({} as any); jest.spyOn(betRepository, "create").mockReturnValue(mockBet as any);
jest.spyOn(pointRepository, 'save').mockResolvedValue({} as any); jest.spyOn(betRepository, "save").mockResolvedValue(mockBet as any);
jest.spyOn(pointRepository, "create").mockReturnValue({} as any);
jest.spyOn(pointRepository, "save").mockResolvedValue({} as any);
const result = await service.create('user-1', createDto); const result = await service.create("user-1", createDto);
expect(result).toBeDefined(); expect(result).toBeDefined();
expect(betRepository.save).toHaveBeenCalled(); expect(betRepository.save).toHaveBeenCalled();
expect(pointRepository.save).toHaveBeenCalled(); expect(pointRepository.save).toHaveBeenCalled();
}); });
it('预约不存在时应该抛出异常', async () => { it("预约不存在时应该抛出异常", async () => {
const createDto = { const createDto = {
appointmentId: 'appointment-1', appointmentId: "appointment-1",
betOption: '胜', betOption: "胜",
amount: 10, amount: 10,
}; };
jest.spyOn(appointmentRepository, 'findOne').mockResolvedValue(null); jest.spyOn(appointmentRepository, "findOne").mockResolvedValue(null);
await expect(service.create('user-1', createDto)).rejects.toThrow(NotFoundException); await expect(service.create("user-1", createDto)).rejects.toThrow(
NotFoundException,
);
}); });
it('预约已结束时应该抛出异常', async () => { it("预约已结束时应该抛出异常", async () => {
const createDto = { const createDto = {
appointmentId: 'appointment-1', appointmentId: "appointment-1",
betOption: '胜', betOption: "胜",
amount: 10, amount: 10,
}; };
jest.spyOn(appointmentRepository, 'findOne').mockResolvedValue({ jest.spyOn(appointmentRepository, "findOne").mockResolvedValue({
...mockAppointment, ...mockAppointment,
status: AppointmentStatus.FINISHED, status: AppointmentStatus.FINISHED,
} as any); } as any);
await expect(service.create('user-1', createDto)).rejects.toThrow(BadRequestException); await expect(service.create("user-1", createDto)).rejects.toThrow(
BadRequestException,
);
}); });
it('积分不足时应该抛出异常', async () => { it("积分不足时应该抛出异常", async () => {
const createDto = { const createDto = {
appointmentId: 'appointment-1', appointmentId: "appointment-1",
betOption: '胜', betOption: "胜",
amount: 100, amount: 100,
}; };
jest.spyOn(appointmentRepository, 'findOne').mockResolvedValue(mockAppointment as any); jest
mockQueryBuilder.getRawOne.mockResolvedValue({ total: '50' }); .spyOn(appointmentRepository, "findOne")
.mockResolvedValue(mockAppointment as any);
mockQueryBuilder.getRawOne.mockResolvedValue({ total: "50" });
await expect(service.create('user-1', createDto)).rejects.toThrow(BadRequestException); await expect(service.create("user-1", createDto)).rejects.toThrow(
BadRequestException,
);
}); });
it('重复下注时应该抛出异常', async () => { it("重复下注时应该抛出异常", async () => {
const createDto = { const createDto = {
appointmentId: 'appointment-1', appointmentId: "appointment-1",
betOption: '胜', betOption: "胜",
amount: 10, amount: 10,
}; };
jest.spyOn(appointmentRepository, 'findOne').mockResolvedValue(mockAppointment as any); jest
mockQueryBuilder.getRawOne.mockResolvedValue({ total: '100' }); .spyOn(appointmentRepository, "findOne")
jest.spyOn(betRepository, 'findOne').mockResolvedValue(mockBet as any); .mockResolvedValue(mockAppointment as any);
mockQueryBuilder.getRawOne.mockResolvedValue({ total: "100" });
jest.spyOn(betRepository, "findOne").mockResolvedValue(mockBet as any);
await expect(service.create('user-1', createDto)).rejects.toThrow(BadRequestException); await expect(service.create("user-1", createDto)).rejects.toThrow(
BadRequestException,
);
}); });
}); });
describe('findAll', () => { describe("findAll", () => {
it('应该返回竞猜列表及统计', async () => { it("应该返回竞猜列表及统计", async () => {
const bets = [ const bets = [
{ ...mockBet, betOption: '胜', amount: 10 }, { ...mockBet, betOption: "胜", amount: 10 },
{ ...mockBet, id: 'bet-2', betOption: '胜', amount: 20 }, { ...mockBet, id: "bet-2", betOption: "胜", amount: 20 },
{ ...mockBet, id: 'bet-3', betOption: '负', amount: 15 }, { ...mockBet, id: "bet-3", betOption: "负", amount: 15 },
]; ];
jest.spyOn(betRepository, 'find').mockResolvedValue(bets as any); jest.spyOn(betRepository, "find").mockResolvedValue(bets as any);
const result = await service.findAll('appointment-1'); const result = await service.findAll("appointment-1");
expect(result.bets).toHaveLength(3); expect(result.bets).toHaveLength(3);
expect(result.totalBets).toBe(3); expect(result.totalBets).toBe(3);
expect(result.totalAmount).toBe(45); expect(result.totalAmount).toBe(45);
expect(result.stats['胜']).toBeDefined(); expect(result.stats["胜"]).toBeDefined();
expect(result.stats['胜'].count).toBe(2); expect(result.stats["胜"].count).toBe(2);
expect(result.stats['胜'].totalAmount).toBe(30); expect(result.stats["胜"].totalAmount).toBe(30);
}); });
}); });
describe('settle', () => { describe("settle", () => {
it('应该成功结算竞猜', async () => { it("应该成功结算竞猜", async () => {
const settleDto = { winningOption: '胜' }; const settleDto = { winningOption: "胜" };
const bets = [ const bets = [
{ ...mockBet, betOption: '胜', amount: 30 }, { ...mockBet, betOption: "胜", amount: 30 },
{ ...mockBet, id: 'bet-2', betOption: '负', amount: 20 }, { ...mockBet, id: "bet-2", betOption: "负", amount: 20 },
]; ];
jest.spyOn(appointmentRepository, 'findOne').mockResolvedValue(mockAppointment as any); jest
jest.spyOn(groupMemberRepository, 'findOne').mockResolvedValue(mockGroupMember as any); .spyOn(appointmentRepository, "findOne")
jest.spyOn(betRepository, 'find').mockResolvedValue(bets as any); .mockResolvedValue(mockAppointment as any);
jest.spyOn(betRepository, 'save').mockResolvedValue({} as any); jest
jest.spyOn(pointRepository, 'create').mockReturnValue({} as any); .spyOn(groupMemberRepository, "findOne")
jest.spyOn(pointRepository, 'save').mockResolvedValue({} as any); .mockResolvedValue(mockGroupMember as any);
jest.spyOn(betRepository, "find").mockResolvedValue(bets as any);
jest.spyOn(betRepository, "save").mockResolvedValue({} as any);
jest.spyOn(pointRepository, "create").mockReturnValue({} as any);
jest.spyOn(pointRepository, "save").mockResolvedValue({} as any);
const result = await service.settle('user-1', 'appointment-1', settleDto); const result = await service.settle("user-1", "appointment-1", settleDto);
expect(result.message).toBe('结算成功'); expect(result.message).toBe("结算成功");
expect(result.winners).toBe(1); expect(result.winners).toBe(1);
}); });
it('无权限时应该抛出异常', async () => { it("无权限时应该抛出异常", async () => {
const settleDto = { winningOption: '胜' }; const settleDto = { winningOption: "胜" };
jest.spyOn(appointmentRepository, 'findOne').mockResolvedValue(mockAppointment as any); jest
jest.spyOn(groupMemberRepository, 'findOne').mockResolvedValue({ .spyOn(appointmentRepository, "findOne")
.mockResolvedValue(mockAppointment as any);
jest.spyOn(groupMemberRepository, "findOne").mockResolvedValue({
...mockGroupMember, ...mockGroupMember,
role: GroupMemberRole.MEMBER, role: GroupMemberRole.MEMBER,
} as any); } as any);
await expect(service.settle('user-1', 'appointment-1', settleDto)).rejects.toThrow(ForbiddenException); await expect(
service.settle("user-1", "appointment-1", settleDto),
).rejects.toThrow(ForbiddenException);
}); });
it('没有人下注该选项时应该抛出异常', async () => { it("没有人下注该选项时应该抛出异常", async () => {
const settleDto = { winningOption: '平' }; const settleDto = { winningOption: "平" };
const bets = [ const bets = [{ ...mockBet, betOption: "胜", amount: 30 }];
{ ...mockBet, betOption: '胜', amount: 30 },
];
jest.spyOn(appointmentRepository, 'findOne').mockResolvedValue(mockAppointment as any); jest
jest.spyOn(groupMemberRepository, 'findOne').mockResolvedValue(mockGroupMember as any); .spyOn(appointmentRepository, "findOne")
jest.spyOn(betRepository, 'find').mockResolvedValue(bets as any); .mockResolvedValue(mockAppointment as any);
jest
.spyOn(groupMemberRepository, "findOne")
.mockResolvedValue(mockGroupMember as any);
jest.spyOn(betRepository, "find").mockResolvedValue(bets as any);
await expect(service.settle('user-1', 'appointment-1', settleDto)).rejects.toThrow(BadRequestException); await expect(
service.settle("user-1", "appointment-1", settleDto),
).rejects.toThrow(BadRequestException);
}); });
}); });
describe('cancel', () => { describe("cancel", () => {
it('应该成功取消竞猜并退还积分', async () => { it("应该成功取消竞猜并退还积分", async () => {
const bets = [ const bets = [
{ ...mockBet, status: BetStatus.PENDING, appointment: mockAppointment }, { ...mockBet, status: BetStatus.PENDING, appointment: mockAppointment },
]; ];
jest.spyOn(betRepository, 'find').mockResolvedValue(bets as any); jest.spyOn(betRepository, "find").mockResolvedValue(bets as any);
jest.spyOn(betRepository, 'save').mockResolvedValue({} as any); jest.spyOn(betRepository, "save").mockResolvedValue({} as any);
jest.spyOn(pointRepository, 'create').mockReturnValue({} as any); jest.spyOn(pointRepository, "create").mockReturnValue({} as any);
jest.spyOn(pointRepository, 'save').mockResolvedValue({} as any); jest.spyOn(pointRepository, "save").mockResolvedValue({} as any);
const result = await service.cancel('appointment-1'); const result = await service.cancel("appointment-1");
expect(result.message).toBe('竞猜已取消,积分已退还'); expect(result.message).toBe("竞猜已取消,积分已退还");
expect(betRepository.save).toHaveBeenCalled(); expect(betRepository.save).toHaveBeenCalled();
expect(pointRepository.save).toHaveBeenCalled(); expect(pointRepository.save).toHaveBeenCalled();
}); });

View File

@@ -3,16 +3,23 @@ import {
NotFoundException, NotFoundException,
BadRequestException, BadRequestException,
ForbiddenException, ForbiddenException,
} 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 { Bet } from '../../entities/bet.entity'; import { Bet } from "../../entities/bet.entity";
import { Appointment } from '../../entities/appointment.entity'; import { Appointment } from "../../entities/appointment.entity";
import { Point } from '../../entities/point.entity'; import { Point } from "../../entities/point.entity";
import { GroupMember } from '../../entities/group-member.entity'; import { GroupMember } from "../../entities/group-member.entity";
import { CreateBetDto, SettleBetDto } from './dto/bet.dto'; import { CreateBetDto, SettleBetDto } from "./dto/bet.dto";
import { BetStatus, GroupMemberRole, AppointmentStatus } from '../../common/enums'; import {
import { ErrorCode, ErrorMessage } from '../../common/interfaces/response.interface'; BetStatus,
GroupMemberRole,
AppointmentStatus,
} from "../../common/enums";
import {
ErrorCode,
ErrorMessage,
} from "../../common/interfaces/response.interface";
@Injectable() @Injectable()
export class BetsService { export class BetsService {
@@ -40,9 +47,10 @@ export class BetsService {
await queryRunner.startTransaction(); await queryRunner.startTransaction();
try { try {
// 验证预约存在 // 使用悲观锁锁定预约记录,防止并发修改
const appointment = await queryRunner.manager.findOne(Appointment, { const appointment = await queryRunner.manager.findOne(Appointment, {
where: { id: appointmentId }, where: { id: appointmentId },
lock: { mode: "pessimistic_write" },
}); });
if (!appointment) { if (!appointment) {
@@ -56,35 +64,37 @@ export class BetsService {
if (appointment.status !== AppointmentStatus.PENDING) { if (appointment.status !== AppointmentStatus.PENDING) {
throw new BadRequestException({ throw new BadRequestException({
code: ErrorCode.INVALID_OPERATION, code: ErrorCode.INVALID_OPERATION,
message: '预约已结束,无法下注', message: "预约已结束,无法下注",
}); });
} }
// 验证用户积分是否足够 // 使用悲观锁检查是否已下注,防止并发重复下注
const balance = await queryRunner.manager
.createQueryBuilder(Point, 'point')
.select('SUM(point.amount)', 'total')
.where('point.userId = :userId', { userId })
.andWhere('point.groupId = :groupId', { groupId: appointment.groupId })
.getRawOne();
const currentBalance = parseInt(balance.total || '0');
if (currentBalance < amount) {
throw new BadRequestException({
code: ErrorCode.INSUFFICIENT_POINTS,
message: '积分不足',
});
}
// 检查是否已下注
const existingBet = await queryRunner.manager.findOne(Bet, { const existingBet = await queryRunner.manager.findOne(Bet, {
where: { appointmentId, userId }, where: { appointmentId, userId },
lock: { mode: "pessimistic_write" },
}); });
if (existingBet) { if (existingBet) {
throw new BadRequestException({ throw new BadRequestException({
code: ErrorCode.INVALID_OPERATION, code: ErrorCode.INVALID_OPERATION,
message: '已下注,不能重复下注', message: "已下注,不能重复下注",
});
}
// 使用悲观锁验证用户积分是否足够(锁定积分记录)
const balance = await queryRunner.manager
.createQueryBuilder(Point, "point")
.setLock("pessimistic_write")
.select("SUM(point.amount)", "total")
.where("point.userId = :userId", { userId })
.andWhere("point.groupId = :groupId", { groupId: appointment.groupId })
.getRawOne();
const currentBalance = parseInt(balance.total || "0");
if (currentBalance < amount) {
throw new BadRequestException({
code: ErrorCode.INSUFFICIENT_POINTS,
message: "积分不足",
}); });
} }
@@ -102,7 +112,7 @@ export class BetsService {
userId, userId,
groupId: appointment.groupId, groupId: appointment.groupId,
amount: -amount, amount: -amount,
reason: '竞猜下注', reason: "竞猜下注",
description: `预约: ${appointment.title}`, description: `预约: ${appointment.title}`,
relatedId: savedBet.id, relatedId: savedBet.id,
}); });
@@ -125,8 +135,8 @@ export class BetsService {
async findAll(appointmentId: string) { async findAll(appointmentId: string) {
const bets = await this.betRepository.find({ const bets = await this.betRepository.find({
where: { appointmentId }, where: { appointmentId },
relations: ['user'], relations: ["user"],
order: { createdAt: 'DESC' }, order: { createdAt: "DESC" },
}); });
// 统计各选项的下注情况 // 统计各选项的下注情况
@@ -170,10 +180,14 @@ export class BetsService {
where: { groupId: appointment.groupId, userId }, where: { groupId: appointment.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: "需要管理员权限",
}); });
} }
@@ -191,12 +205,15 @@ export class BetsService {
// 计算总奖池和赢家总下注 // 计算总奖池和赢家总下注
const totalPool = bets.reduce((sum, bet) => sum + bet.amount, 0); const totalPool = bets.reduce((sum, bet) => sum + bet.amount, 0);
const winningBets = bets.filter((bet) => bet.betOption === winningOption); const winningBets = bets.filter((bet) => bet.betOption === winningOption);
const winningTotal = winningBets.reduce((sum, bet) => sum + bet.amount, 0); const winningTotal = winningBets.reduce(
(sum, bet) => sum + bet.amount,
0,
);
if (winningTotal === 0) { if (winningTotal === 0) {
throw new BadRequestException({ throw new BadRequestException({
code: ErrorCode.INVALID_OPERATION, code: ErrorCode.INVALID_OPERATION,
message: '没有人下注该选项', message: "没有人下注该选项",
}); });
} }
@@ -223,7 +240,7 @@ export class BetsService {
userId: bet.userId, userId: bet.userId,
groupId: appointment.groupId, groupId: appointment.groupId,
amount: winAmount, amount: winAmount,
reason: '竞猜获胜', reason: "竞猜获胜",
description: `预约: ${appointment.title}`, description: `预约: ${appointment.title}`,
relatedId: bet.id, relatedId: bet.id,
}); });
@@ -243,7 +260,7 @@ export class BetsService {
await queryRunner.commitTransaction(); await queryRunner.commitTransaction();
return { return {
message: '结算成功', message: "结算成功",
winningOption, winningOption,
totalPool, totalPool,
winners: winningBets.length, winners: winningBets.length,
@@ -268,7 +285,7 @@ export class BetsService {
try { try {
const bets = await queryRunner.manager.find(Bet, { const bets = await queryRunner.manager.find(Bet, {
where: { appointmentId }, where: { appointmentId },
relations: ['appointment'], relations: ["appointment"],
}); });
for (const bet of bets) { for (const bet of bets) {
@@ -281,7 +298,7 @@ export class BetsService {
userId: bet.userId, userId: bet.userId,
groupId: bet.appointment.groupId, groupId: bet.appointment.groupId,
amount: bet.amount, amount: bet.amount,
reason: '竞猜取消退款', reason: "竞猜取消退款",
description: `预约: ${bet.appointment.title}`, description: `预约: ${bet.appointment.title}`,
relatedId: bet.id, relatedId: bet.id,
}); });
@@ -291,7 +308,7 @@ export class BetsService {
} }
await queryRunner.commitTransaction(); await queryRunner.commitTransaction();
return { message: '竞猜已取消,积分已退还' }; return { message: "竞猜已取消,积分已退还" };
} catch (error) { } catch (error) {
await queryRunner.rollbackTransaction(); await queryRunner.rollbackTransaction();
throw error; throw error;

View File

@@ -1,31 +1,26 @@
import { import { IsString, IsNotEmpty, IsNumber, Min } from "class-validator";
IsString, import { ApiProperty } from "@nestjs/swagger";
IsNotEmpty,
IsNumber,
Min,
} from 'class-validator';
import { ApiProperty } from '@nestjs/swagger';
export class CreateBetDto { export class CreateBetDto {
@ApiProperty({ description: '预约ID' }) @ApiProperty({ description: "预约ID" })
@IsString() @IsString()
@IsNotEmpty({ message: '预约ID不能为空' }) @IsNotEmpty({ message: "预约ID不能为空" })
appointmentId: string; appointmentId: string;
@ApiProperty({ description: '下注选项', example: '胜' }) @ApiProperty({ description: "下注选项", example: "胜" })
@IsString() @IsString()
@IsNotEmpty({ message: '下注选项不能为空' }) @IsNotEmpty({ message: "下注选项不能为空" })
betOption: string; betOption: string;
@ApiProperty({ description: '下注积分', example: 10 }) @ApiProperty({ description: "下注积分", example: 10 })
@IsNumber() @IsNumber()
@Min(1) @Min(1)
amount: number; amount: number;
} }
export class SettleBetDto { export class SettleBetDto {
@ApiProperty({ description: '胜利选项', example: '胜' }) @ApiProperty({ description: "胜利选项", example: "胜" })
@IsString() @IsString()
@IsNotEmpty({ message: '胜利选项不能为空' }) @IsNotEmpty({ message: "胜利选项不能为空" })
winningOption: string; winningOption: string;
} }

View File

@@ -8,61 +8,61 @@ import {
Patch, Patch,
Query, Query,
UseGuards, UseGuards,
} from '@nestjs/common'; } from "@nestjs/common";
import { ApiTags, ApiOperation, ApiBearerAuth } from '@nestjs/swagger'; import { ApiTags, ApiOperation, ApiBearerAuth } from "@nestjs/swagger";
import { BlacklistService } from './blacklist.service'; import { BlacklistService } from "./blacklist.service";
import { import {
CreateBlacklistDto, CreateBlacklistDto,
ReviewBlacklistDto, ReviewBlacklistDto,
QueryBlacklistDto, QueryBlacklistDto,
} from './dto/blacklist.dto'; } from "./dto/blacklist.dto";
import { JwtAuthGuard } from '../../common/guards/jwt-auth.guard'; import { JwtAuthGuard } from "../../common/guards/jwt-auth.guard";
import { CurrentUser } from '../../common/decorators/current-user.decorator'; import { CurrentUser } from "../../common/decorators/current-user.decorator";
@ApiTags('blacklist') @ApiTags("blacklist")
@Controller('blacklist') @Controller("blacklist")
@UseGuards(JwtAuthGuard) @UseGuards(JwtAuthGuard)
@ApiBearerAuth() @ApiBearerAuth()
export class BlacklistController { export class BlacklistController {
constructor(private readonly blacklistService: BlacklistService) {} constructor(private readonly blacklistService: BlacklistService) {}
@Post() @Post()
@ApiOperation({ summary: '提交黑名单举报' }) @ApiOperation({ summary: "提交黑名单举报" })
create(@CurrentUser() user, @Body() createDto: CreateBlacklistDto) { create(@CurrentUser() user, @Body() createDto: CreateBlacklistDto) {
return this.blacklistService.create(user.id, createDto); return this.blacklistService.create(user.id, createDto);
} }
@Get() @Get()
@ApiOperation({ summary: '查询黑名单列表' }) @ApiOperation({ summary: "查询黑名单列表" })
findAll(@Query() query: QueryBlacklistDto) { findAll(@Query() query: QueryBlacklistDto) {
return this.blacklistService.findAll(query); return this.blacklistService.findAll(query);
} }
@Get('check/:targetGameId') @Get("check/:targetGameId")
@ApiOperation({ summary: '检查游戏ID是否在黑名单中' }) @ApiOperation({ summary: "检查游戏ID是否在黑名单中" })
checkBlacklist(@Param('targetGameId') targetGameId: string) { checkBlacklist(@Param("targetGameId") targetGameId: string) {
return this.blacklistService.checkBlacklist(targetGameId); return this.blacklistService.checkBlacklist(targetGameId);
} }
@Get(':id') @Get(":id")
@ApiOperation({ summary: '查询单个黑名单记录' }) @ApiOperation({ summary: "查询单个黑名单记录" })
findOne(@Param('id') id: string) { findOne(@Param("id") id: string) {
return this.blacklistService.findOne(id); return this.blacklistService.findOne(id);
} }
@Patch(':id/review') @Patch(":id/review")
@ApiOperation({ summary: '审核黑名单(管理员)' }) @ApiOperation({ summary: "审核黑名单(管理员)" })
review( review(
@CurrentUser() user, @CurrentUser() user,
@Param('id') id: string, @Param("id") id: string,
@Body() reviewDto: ReviewBlacklistDto, @Body() reviewDto: ReviewBlacklistDto,
) { ) {
return this.blacklistService.review(user.id, id, reviewDto); return this.blacklistService.review(user.id, id, reviewDto);
} }
@Delete(':id') @Delete(":id")
@ApiOperation({ summary: '删除黑名单记录' }) @ApiOperation({ summary: "删除黑名单记录" })
remove(@CurrentUser() user, @Param('id') id: string) { remove(@CurrentUser() user, @Param("id") id: string) {
return this.blacklistService.remove(user.id, id); return this.blacklistService.remove(user.id, id);
} }
} }

View File

@@ -1,9 +1,9 @@
import { Module } from '@nestjs/common'; import { Module } from "@nestjs/common";
import { TypeOrmModule } from '@nestjs/typeorm'; import { TypeOrmModule } from "@nestjs/typeorm";
import { BlacklistController } from './blacklist.controller'; import { BlacklistController } from "./blacklist.controller";
import { BlacklistService } from './blacklist.service'; import { BlacklistService } from "./blacklist.service";
import { Blacklist } from '../../entities/blacklist.entity'; import { Blacklist } from "../../entities/blacklist.entity";
import { User } from '../../entities/user.entity'; import { User } from "../../entities/user.entity";
@Module({ @Module({
imports: [TypeOrmModule.forFeature([Blacklist, User])], imports: [TypeOrmModule.forFeature([Blacklist, User])],

View File

@@ -1,40 +1,40 @@
import { Test, TestingModule } from '@nestjs/testing'; import { Test, TestingModule } from "@nestjs/testing";
import { getRepositoryToken } from '@nestjs/typeorm'; import { getRepositoryToken } from "@nestjs/typeorm";
import { Repository } from 'typeorm'; import { Repository } from "typeorm";
import { BlacklistService } from './blacklist.service'; import { BlacklistService } from "./blacklist.service";
import { Blacklist } from '../../entities/blacklist.entity'; import { Blacklist } from "../../entities/blacklist.entity";
import { User } from '../../entities/user.entity'; import { User } from "../../entities/user.entity";
import { GroupMember } from '../../entities/group-member.entity'; import { GroupMember } from "../../entities/group-member.entity";
import { BlacklistStatus } from '../../common/enums'; import { BlacklistStatus } from "../../common/enums";
import { NotFoundException, ForbiddenException } from '@nestjs/common'; import { NotFoundException, ForbiddenException } from "@nestjs/common";
describe('BlacklistService', () => { describe("BlacklistService", () => {
let service: BlacklistService; let service: BlacklistService;
let blacklistRepository: Repository<Blacklist>; let blacklistRepository: Repository<Blacklist>;
let userRepository: Repository<User>; let userRepository: Repository<User>;
let groupMemberRepository: Repository<GroupMember>; let groupMemberRepository: Repository<GroupMember>;
const mockBlacklist = { const mockBlacklist = {
id: 'blacklist-1', id: "blacklist-1",
reporterId: 'user-1', reporterId: "user-1",
targetGameId: 'game-123', targetGameId: "game-123",
targetNickname: '违规玩家', targetNickname: "违规玩家",
reason: '恶意行为', reason: "恶意行为",
proofImages: ['image1.jpg'], proofImages: ["image1.jpg"],
status: BlacklistStatus.PENDING, status: BlacklistStatus.PENDING,
createdAt: new Date(), createdAt: new Date(),
}; };
const mockUser = { const mockUser = {
id: 'user-1', id: "user-1",
username: '举报人', username: "举报人",
isMember: true, isMember: true,
}; };
const mockGroupMember = { const mockGroupMember = {
id: 'member-1', id: "member-1",
userId: 'user-1', userId: "user-1",
groupId: 'group-1', groupId: "group-1",
}; };
const mockQueryBuilder = { const mockQueryBuilder = {
@@ -76,43 +76,53 @@ describe('BlacklistService', () => {
}).compile(); }).compile();
service = module.get<BlacklistService>(BlacklistService); service = module.get<BlacklistService>(BlacklistService);
blacklistRepository = module.get<Repository<Blacklist>>(getRepositoryToken(Blacklist)); blacklistRepository = module.get<Repository<Blacklist>>(
getRepositoryToken(Blacklist),
);
userRepository = module.get<Repository<User>>(getRepositoryToken(User)); userRepository = module.get<Repository<User>>(getRepositoryToken(User));
groupMemberRepository = module.get<Repository<GroupMember>>(getRepositoryToken(GroupMember)); groupMemberRepository = module.get<Repository<GroupMember>>(
getRepositoryToken(GroupMember),
);
}); });
it('should be defined', () => { it("should be defined", () => {
expect(service).toBeDefined(); expect(service).toBeDefined();
}); });
describe('create', () => { describe("create", () => {
it('应该成功创建黑名单举报', async () => { it("应该成功创建黑名单举报", async () => {
const createDto = { const createDto = {
targetGameId: 'game-123', targetGameId: "game-123",
targetNickname: '违规玩家', targetNickname: "违规玩家",
reason: '恶意行为', reason: "恶意行为",
proofImages: ['image1.jpg'], proofImages: ["image1.jpg"],
}; };
jest.spyOn(userRepository, 'findOne').mockResolvedValue(mockUser as any); jest.spyOn(userRepository, "findOne").mockResolvedValue(mockUser as any);
jest.spyOn(blacklistRepository, 'create').mockReturnValue(mockBlacklist as any); jest
jest.spyOn(blacklistRepository, 'save').mockResolvedValue(mockBlacklist as any); .spyOn(blacklistRepository, "create")
jest.spyOn(blacklistRepository, 'findOne').mockResolvedValue(mockBlacklist as any); .mockReturnValue(mockBlacklist as any);
jest
.spyOn(blacklistRepository, "save")
.mockResolvedValue(mockBlacklist as any);
jest
.spyOn(blacklistRepository, "findOne")
.mockResolvedValue(mockBlacklist as any);
const result = await service.create('user-1', createDto); const result = await service.create("user-1", createDto);
expect(result).toBeDefined(); expect(result).toBeDefined();
expect(blacklistRepository.create).toHaveBeenCalledWith({ expect(blacklistRepository.create).toHaveBeenCalledWith({
...createDto, ...createDto,
reporterId: 'user-1', reporterId: "user-1",
status: BlacklistStatus.PENDING, status: BlacklistStatus.PENDING,
}); });
expect(blacklistRepository.save).toHaveBeenCalled(); expect(blacklistRepository.save).toHaveBeenCalled();
}); });
}); });
describe('findAll', () => { describe("findAll", () => {
it('应该返回黑名单列表', async () => { it("应该返回黑名单列表", async () => {
const query = { status: BlacklistStatus.APPROVED }; const query = { status: BlacklistStatus.APPROVED };
mockQueryBuilder.getMany.mockResolvedValue([mockBlacklist]); mockQueryBuilder.getMany.mockResolvedValue([mockBlacklist]);
@@ -122,7 +132,7 @@ describe('BlacklistService', () => {
expect(blacklistRepository.createQueryBuilder).toHaveBeenCalled(); expect(blacklistRepository.createQueryBuilder).toHaveBeenCalled();
}); });
it('应该支持按状态筛选', async () => { it("应该支持按状态筛选", async () => {
const query = { status: BlacklistStatus.PENDING }; const query = { status: BlacklistStatus.PENDING };
mockQueryBuilder.getMany.mockResolvedValue([mockBlacklist]); mockQueryBuilder.getMany.mockResolvedValue([mockBlacklist]);
@@ -130,143 +140,162 @@ describe('BlacklistService', () => {
await service.findAll(query); await service.findAll(query);
expect(mockQueryBuilder.andWhere).toHaveBeenCalledWith( expect(mockQueryBuilder.andWhere).toHaveBeenCalledWith(
'blacklist.status = :status', "blacklist.status = :status",
{ status: BlacklistStatus.PENDING } { status: BlacklistStatus.PENDING },
); );
}); });
}); });
describe('findOne', () => { describe("findOne", () => {
it('应该返回单个黑名单记录', async () => { it("应该返回单个黑名单记录", async () => {
jest.spyOn(blacklistRepository, 'findOne').mockResolvedValue(mockBlacklist as any); jest
.spyOn(blacklistRepository, "findOne")
.mockResolvedValue(mockBlacklist as any);
const result = await service.findOne('blacklist-1'); const result = await service.findOne("blacklist-1");
expect(result).toBeDefined(); expect(result).toBeDefined();
expect(result.id).toBe('blacklist-1'); expect(result.id).toBe("blacklist-1");
}); });
it('记录不存在时应该抛出异常', async () => { it("记录不存在时应该抛出异常", async () => {
jest.spyOn(blacklistRepository, 'findOne').mockResolvedValue(null); jest.spyOn(blacklistRepository, "findOne").mockResolvedValue(null);
await expect(service.findOne('non-existent')).rejects.toThrow(NotFoundException); await expect(service.findOne("non-existent")).rejects.toThrow(
NotFoundException,
);
}); });
}); });
describe('review', () => { describe("review", () => {
it('应该成功审核黑名单(会员权限)', async () => { it("应该成功审核黑名单(会员权限)", async () => {
const reviewDto = { const reviewDto = {
status: BlacklistStatus.APPROVED, status: BlacklistStatus.APPROVED,
reviewNote: '确认违规', reviewNote: "确认违规",
}; };
const updatedBlacklist = { const updatedBlacklist = {
...mockBlacklist, ...mockBlacklist,
...reviewDto, ...reviewDto,
reviewerId: 'user-1', reviewerId: "user-1",
}; };
jest.spyOn(userRepository, 'findOne').mockResolvedValue(mockUser as any); jest.spyOn(userRepository, "findOne").mockResolvedValue(mockUser as any);
jest.spyOn(blacklistRepository, 'findOne') jest
.spyOn(blacklistRepository, "findOne")
.mockResolvedValueOnce(mockBlacklist as any) // First call in review method .mockResolvedValueOnce(mockBlacklist as any) // First call in review method
.mockResolvedValueOnce(updatedBlacklist as any); // Second call in findOne at the end .mockResolvedValueOnce(updatedBlacklist as any); // Second call in findOne at the end
jest.spyOn(blacklistRepository, 'save').mockResolvedValue(updatedBlacklist as any); jest
.spyOn(blacklistRepository, "save")
.mockResolvedValue(updatedBlacklist as any);
const result = await service.review('user-1', 'blacklist-1', reviewDto); const result = await service.review("user-1", "blacklist-1", reviewDto);
expect(result.status).toBe(BlacklistStatus.APPROVED); expect(result.status).toBe(BlacklistStatus.APPROVED);
expect(blacklistRepository.save).toHaveBeenCalled(); expect(blacklistRepository.save).toHaveBeenCalled();
}); });
it('非会员审核时应该抛出异常', async () => { it("非会员审核时应该抛出异常", async () => {
const reviewDto = { const reviewDto = {
status: BlacklistStatus.APPROVED, status: BlacklistStatus.APPROVED,
}; };
jest.spyOn(userRepository, 'findOne').mockResolvedValue({ jest.spyOn(userRepository, "findOne").mockResolvedValue({
...mockUser, ...mockUser,
isMember: false, isMember: false,
} as any); } as any);
await expect(service.review('user-1', 'blacklist-1', reviewDto)).rejects.toThrow(ForbiddenException); await expect(
service.review("user-1", "blacklist-1", reviewDto),
).rejects.toThrow(ForbiddenException);
}); });
it('用户不存在时应该抛出异常', async () => { it("用户不存在时应该抛出异常", async () => {
const reviewDto = { const reviewDto = {
status: BlacklistStatus.APPROVED, status: BlacklistStatus.APPROVED,
}; };
jest.spyOn(userRepository, 'findOne').mockResolvedValue(null); jest.spyOn(userRepository, "findOne").mockResolvedValue(null);
await expect(service.review('user-1', 'blacklist-1', reviewDto)).rejects.toThrow(ForbiddenException); await expect(
service.review("user-1", "blacklist-1", reviewDto),
).rejects.toThrow(ForbiddenException);
}); });
}); });
describe('checkBlacklist', () => { describe("checkBlacklist", () => {
it('应该正确检查玩家是否在黑名单', async () => { it("应该正确检查玩家是否在黑名单", async () => {
jest.spyOn(blacklistRepository, 'findOne').mockResolvedValue({ jest.spyOn(blacklistRepository, "findOne").mockResolvedValue({
...mockBlacklist, ...mockBlacklist,
status: BlacklistStatus.APPROVED, status: BlacklistStatus.APPROVED,
} as any); } as any);
const result = await service.checkBlacklist('game-123'); const result = await service.checkBlacklist("game-123");
expect(result.isBlacklisted).toBe(true); expect(result.isBlacklisted).toBe(true);
expect(result.blacklist).toBeDefined(); expect(result.blacklist).toBeDefined();
expect(blacklistRepository.findOne).toHaveBeenCalledWith({ expect(blacklistRepository.findOne).toHaveBeenCalledWith({
where: { where: {
targetGameId: 'game-123', targetGameId: "game-123",
status: BlacklistStatus.APPROVED, status: BlacklistStatus.APPROVED,
}, },
}); });
}); });
it('玩家不在黑名单时应该返回false', async () => { it("玩家不在黑名单时应该返回false", async () => {
jest.spyOn(blacklistRepository, 'findOne').mockResolvedValue(null); jest.spyOn(blacklistRepository, "findOne").mockResolvedValue(null);
const result = await service.checkBlacklist('game-123'); const result = await service.checkBlacklist("game-123");
expect(result.isBlacklisted).toBe(false); expect(result.isBlacklisted).toBe(false);
expect(result.blacklist).toBeNull(); expect(result.blacklist).toBeNull();
}); });
}); });
describe('remove', () => { describe("remove", () => {
it('举报人应该可以删除自己的举报', async () => { it("举报人应该可以删除自己的举报", async () => {
jest.spyOn(blacklistRepository, 'findOne').mockResolvedValue(mockBlacklist as any); jest
jest.spyOn(userRepository, 'findOne').mockResolvedValue(mockUser as any); .spyOn(blacklistRepository, "findOne")
jest.spyOn(blacklistRepository, 'remove').mockResolvedValue(mockBlacklist as any); .mockResolvedValue(mockBlacklist as any);
jest.spyOn(userRepository, "findOne").mockResolvedValue(mockUser as any);
jest
.spyOn(blacklistRepository, "remove")
.mockResolvedValue(mockBlacklist as any);
const result = await service.remove('user-1', 'blacklist-1'); const result = await service.remove("user-1", "blacklist-1");
expect(result.message).toBe('删除成功'); expect(result.message).toBe("删除成功");
expect(blacklistRepository.remove).toHaveBeenCalled(); expect(blacklistRepository.remove).toHaveBeenCalled();
}); });
it('会员应该可以删除任何举报', async () => { it("会员应该可以删除任何举报", async () => {
jest.spyOn(blacklistRepository, 'findOne').mockResolvedValue({ jest.spyOn(blacklistRepository, "findOne").mockResolvedValue({
...mockBlacklist, ...mockBlacklist,
reporterId: 'other-user', reporterId: "other-user",
} as any); } as any);
jest.spyOn(userRepository, 'findOne').mockResolvedValue(mockUser as any); jest.spyOn(userRepository, "findOne").mockResolvedValue(mockUser as any);
jest.spyOn(blacklistRepository, 'remove').mockResolvedValue(mockBlacklist as any); jest
.spyOn(blacklistRepository, "remove")
.mockResolvedValue(mockBlacklist as any);
const result = await service.remove('user-1', 'blacklist-1'); const result = await service.remove("user-1", "blacklist-1");
expect(result.message).toBe('删除成功'); expect(result.message).toBe("删除成功");
}); });
it('非举报人且非会员删除时应该抛出异常', async () => { it("非举报人且非会员删除时应该抛出异常", async () => {
jest.spyOn(blacklistRepository, 'findOne').mockResolvedValue({ jest.spyOn(blacklistRepository, "findOne").mockResolvedValue({
...mockBlacklist, ...mockBlacklist,
reporterId: 'other-user', reporterId: "other-user",
} as any); } as any);
jest.spyOn(userRepository, 'findOne').mockResolvedValue({ jest.spyOn(userRepository, "findOne").mockResolvedValue({
...mockUser, ...mockUser,
isMember: false, isMember: false,
} as any); } as any);
await expect(service.remove('user-1', 'blacklist-1')).rejects.toThrow(ForbiddenException); await expect(service.remove("user-1", "blacklist-1")).rejects.toThrow(
ForbiddenException,
);
}); });
}); });
}); });

View File

@@ -2,21 +2,21 @@ import {
Injectable, Injectable,
NotFoundException, NotFoundException,
ForbiddenException, ForbiddenException,
} from '@nestjs/common'; } from "@nestjs/common";
import { InjectRepository } from '@nestjs/typeorm'; import { InjectRepository } from "@nestjs/typeorm";
import { Repository } from 'typeorm'; import { Repository } from "typeorm";
import { Blacklist } from '../../entities/blacklist.entity'; import { Blacklist } from "../../entities/blacklist.entity";
import { User } from '../../entities/user.entity'; import { User } from "../../entities/user.entity";
import { import {
CreateBlacklistDto, CreateBlacklistDto,
ReviewBlacklistDto, ReviewBlacklistDto,
QueryBlacklistDto, QueryBlacklistDto,
} from './dto/blacklist.dto'; } from "./dto/blacklist.dto";
import { BlacklistStatus } from '../../common/enums'; import { BlacklistStatus } from "../../common/enums";
import { import {
ErrorCode, ErrorCode,
ErrorMessage, ErrorMessage,
} from '../../common/interfaces/response.interface'; } from "../../common/interfaces/response.interface";
@Injectable() @Injectable()
export class BlacklistService { export class BlacklistService {
@@ -56,21 +56,21 @@ export class BlacklistService {
*/ */
async findAll(query: QueryBlacklistDto) { async findAll(query: QueryBlacklistDto) {
const qb = this.blacklistRepository const qb = this.blacklistRepository
.createQueryBuilder('blacklist') .createQueryBuilder("blacklist")
.leftJoinAndSelect('blacklist.reporter', 'reporter') .leftJoinAndSelect("blacklist.reporter", "reporter")
.leftJoinAndSelect('blacklist.reviewer', 'reviewer'); .leftJoinAndSelect("blacklist.reviewer", "reviewer");
if (query.targetGameId) { if (query.targetGameId) {
qb.andWhere('blacklist.targetGameId LIKE :targetGameId', { qb.andWhere("blacklist.targetGameId LIKE :targetGameId", {
targetGameId: `%${query.targetGameId}%`, targetGameId: `%${query.targetGameId}%`,
}); });
} }
if (query.status) { if (query.status) {
qb.andWhere('blacklist.status = :status', { status: query.status }); qb.andWhere("blacklist.status = :status", { status: query.status });
} }
qb.orderBy('blacklist.createdAt', 'DESC'); qb.orderBy("blacklist.createdAt", "DESC");
const blacklists = await qb.getMany(); const blacklists = await qb.getMany();
@@ -83,13 +83,13 @@ export class BlacklistService {
async findOne(id: string) { async findOne(id: string) {
const blacklist = await this.blacklistRepository.findOne({ const blacklist = await this.blacklistRepository.findOne({
where: { id }, where: { id },
relations: ['reporter', 'reviewer'], relations: ["reporter", "reviewer"],
}); });
if (!blacklist) { if (!blacklist) {
throw new NotFoundException({ throw new NotFoundException({
code: ErrorCode.BLACKLIST_NOT_FOUND, code: ErrorCode.BLACKLIST_NOT_FOUND,
message: '黑名单记录不存在', message: "黑名单记录不存在",
}); });
} }
@@ -105,7 +105,7 @@ export class BlacklistService {
if (!user || !user.isMember) { if (!user || !user.isMember) {
throw new ForbiddenException({ throw new ForbiddenException({
code: ErrorCode.NO_PERMISSION, code: ErrorCode.NO_PERMISSION,
message: '需要会员权限', message: "需要会员权限",
}); });
} }
@@ -114,7 +114,7 @@ export class BlacklistService {
if (blacklist.status !== BlacklistStatus.PENDING) { if (blacklist.status !== BlacklistStatus.PENDING) {
throw new ForbiddenException({ throw new ForbiddenException({
code: ErrorCode.INVALID_OPERATION, code: ErrorCode.INVALID_OPERATION,
message: '该记录已审核', message: "该记录已审核",
}); });
} }
@@ -170,6 +170,6 @@ export class BlacklistService {
await this.blacklistRepository.remove(blacklist); await this.blacklistRepository.remove(blacklist);
return { message: '删除成功' }; return { message: "删除成功" };
} }
} }

View File

@@ -5,23 +5,23 @@ import {
IsArray, IsArray,
IsEnum, IsEnum,
MaxLength, MaxLength,
} from 'class-validator'; } from "class-validator";
import { ApiProperty } from '@nestjs/swagger'; import { ApiProperty } from "@nestjs/swagger";
import { BlacklistStatus } from '../../../common/enums'; import { BlacklistStatus } from "../../../common/enums";
export class CreateBlacklistDto { export class CreateBlacklistDto {
@ApiProperty({ description: '目标游戏ID或用户名', example: 'PlayerXXX#1234' }) @ApiProperty({ description: "目标游戏ID或用户名", example: "PlayerXXX#1234" })
@IsString() @IsString()
@IsNotEmpty({ message: '目标游戏ID不能为空' }) @IsNotEmpty({ message: "目标游戏ID不能为空" })
@MaxLength(100) @MaxLength(100)
targetGameId: string; targetGameId: string;
@ApiProperty({ description: '举报原因' }) @ApiProperty({ description: "举报原因" })
@IsString() @IsString()
@IsNotEmpty({ message: '举报原因不能为空' }) @IsNotEmpty({ message: "举报原因不能为空" })
reason: string; reason: string;
@ApiProperty({ description: '证据图片URL列表', required: false }) @ApiProperty({ description: "证据图片URL列表", required: false })
@IsArray() @IsArray()
@IsOptional() @IsOptional()
proofImages?: string[]; proofImages?: string[];
@@ -29,27 +29,27 @@ export class CreateBlacklistDto {
export class ReviewBlacklistDto { export class ReviewBlacklistDto {
@ApiProperty({ @ApiProperty({
description: '审核状态', description: "审核状态",
enum: BlacklistStatus, enum: BlacklistStatus,
example: BlacklistStatus.APPROVED, example: BlacklistStatus.APPROVED,
}) })
@IsEnum(BlacklistStatus) @IsEnum(BlacklistStatus)
status: BlacklistStatus; status: BlacklistStatus;
@ApiProperty({ description: '审核意见', required: false }) @ApiProperty({ description: "审核意见", required: false })
@IsString() @IsString()
@IsOptional() @IsOptional()
reviewNote?: string; reviewNote?: string;
} }
export class QueryBlacklistDto { export class QueryBlacklistDto {
@ApiProperty({ description: '目标游戏ID', required: false }) @ApiProperty({ description: "目标游戏ID", required: false })
@IsString() @IsString()
@IsOptional() @IsOptional()
targetGameId?: string; targetGameId?: string;
@ApiProperty({ @ApiProperty({
description: '状态', description: "状态",
enum: BlacklistStatus, enum: BlacklistStatus,
required: false, required: false,
}) })

View File

@@ -1,42 +1,58 @@
import { IsString, IsNotEmpty, IsOptional, IsNumber, Min, IsArray } from 'class-validator'; import {
import { ApiProperty } from '@nestjs/swagger'; IsString,
import { Type } from 'class-transformer'; IsNotEmpty,
IsOptional,
IsNumber,
Min,
IsArray,
} from "class-validator";
import { ApiProperty } from "@nestjs/swagger";
import { Type } from "class-transformer";
export class CreateGameDto { export class CreateGameDto {
@ApiProperty({ description: '游戏名称', example: '王者荣耀' }) @ApiProperty({ description: "游戏名称", example: "王者荣耀" })
@IsString() @IsString()
@IsNotEmpty({ message: '游戏名称不能为空' }) @IsNotEmpty({ message: "游戏名称不能为空" })
name: string; name: string;
@ApiProperty({ description: '游戏封面URL', required: false }) @ApiProperty({ description: "游戏封面URL", required: false })
@IsString() @IsString()
@IsOptional() @IsOptional()
coverUrl?: string; coverUrl?: string;
@ApiProperty({ description: '游戏描述', required: false }) @ApiProperty({ description: "游戏描述", required: false })
@IsString() @IsString()
@IsOptional() @IsOptional()
description?: string; description?: string;
@ApiProperty({ description: '最大玩家数', example: 5 }) @ApiProperty({ description: "最大玩家数", example: 5 })
@IsNumber() @IsNumber()
@Min(1) @Min(1)
@Type(() => Number) @Type(() => Number)
maxPlayers: number; maxPlayers: number;
@ApiProperty({ description: '最小玩家数', example: 1, required: false }) @ApiProperty({ description: "最小玩家数", example: 1, required: false })
@IsNumber() @IsNumber()
@Min(1) @Min(1)
@IsOptional() @IsOptional()
@Type(() => Number) @Type(() => Number)
minPlayers?: number; minPlayers?: number;
@ApiProperty({ description: '游戏平台', example: 'PC/iOS/Android', required: false }) @ApiProperty({
description: "游戏平台",
example: "PC/iOS/Android",
required: false,
})
@IsString() @IsString()
@IsOptional() @IsOptional()
platform?: string; platform?: string;
@ApiProperty({ description: '游戏标签', example: ['MOBA', '5v5'], required: false, type: [String] }) @ApiProperty({
description: "游戏标签",
example: ["MOBA", "5v5"],
required: false,
type: [String],
})
@IsArray() @IsArray()
@IsString({ each: true }) @IsString({ each: true })
@IsOptional() @IsOptional()
@@ -44,41 +60,41 @@ export class CreateGameDto {
} }
export class UpdateGameDto { export class UpdateGameDto {
@ApiProperty({ description: '游戏名称', required: false }) @ApiProperty({ description: "游戏名称", required: false })
@IsString() @IsString()
@IsOptional() @IsOptional()
name?: string; name?: string;
@ApiProperty({ description: '游戏封面URL', required: false }) @ApiProperty({ description: "游戏封面URL", required: false })
@IsString() @IsString()
@IsOptional() @IsOptional()
coverUrl?: string; coverUrl?: string;
@ApiProperty({ description: '游戏描述', required: false }) @ApiProperty({ description: "游戏描述", required: false })
@IsString() @IsString()
@IsOptional() @IsOptional()
description?: string; description?: string;
@ApiProperty({ description: '最大玩家数', required: false }) @ApiProperty({ description: "最大玩家数", required: false })
@IsNumber() @IsNumber()
@Min(1) @Min(1)
@IsOptional() @IsOptional()
@Type(() => Number) @Type(() => Number)
maxPlayers?: number; maxPlayers?: number;
@ApiProperty({ description: '最小玩家数', required: false }) @ApiProperty({ description: "最小玩家数", required: false })
@IsNumber() @IsNumber()
@Min(1) @Min(1)
@IsOptional() @IsOptional()
@Type(() => Number) @Type(() => Number)
minPlayers?: number; minPlayers?: number;
@ApiProperty({ description: '游戏平台', required: false }) @ApiProperty({ description: "游戏平台", required: false })
@IsString() @IsString()
@IsOptional() @IsOptional()
platform?: string; platform?: string;
@ApiProperty({ description: '游戏标签', required: false, type: [String] }) @ApiProperty({ description: "游戏标签", required: false, type: [String] })
@IsArray() @IsArray()
@IsString({ each: true }) @IsString({ each: true })
@IsOptional() @IsOptional()
@@ -86,29 +102,29 @@ export class UpdateGameDto {
} }
export class SearchGameDto { export class SearchGameDto {
@ApiProperty({ description: '搜索关键词', required: false }) @ApiProperty({ description: "搜索关键词", required: false })
@IsString() @IsString()
@IsOptional() @IsOptional()
keyword?: string; keyword?: string;
@ApiProperty({ description: '游戏平台', required: false }) @ApiProperty({ description: "游戏平台", required: false })
@IsString() @IsString()
@IsOptional() @IsOptional()
platform?: string; platform?: string;
@ApiProperty({ description: '游戏标签', required: false }) @ApiProperty({ description: "游戏标签", required: false })
@IsString() @IsString()
@IsOptional() @IsOptional()
tag?: string; tag?: string;
@ApiProperty({ description: '页码', example: 1, required: false }) @ApiProperty({ description: "页码", example: 1, required: false })
@IsNumber() @IsNumber()
@Min(1) @Min(1)
@IsOptional() @IsOptional()
@Type(() => Number) @Type(() => Number)
page?: number; page?: number;
@ApiProperty({ description: '每页数量', example: 10, required: false }) @ApiProperty({ description: "每页数量", example: 10, required: false })
@IsNumber() @IsNumber()
@Min(1) @Min(1)
@IsOptional() @IsOptional()

View File

@@ -8,88 +8,94 @@ import {
Param, Param,
Query, Query,
UseGuards, UseGuards,
} from '@nestjs/common'; } from "@nestjs/common";
import { ApiTags, ApiOperation, ApiResponse, ApiBearerAuth, ApiQuery } from '@nestjs/swagger'; import {
import { GamesService } from './games.service'; ApiTags,
import { CreateGameDto, UpdateGameDto, SearchGameDto } from './dto/game.dto'; ApiOperation,
import { JwtAuthGuard } from '../../common/guards/jwt-auth.guard'; ApiResponse,
import { Public } from '../../common/decorators/public.decorator'; ApiBearerAuth,
ApiQuery,
} from "@nestjs/swagger";
import { GamesService } from "./games.service";
import { CreateGameDto, UpdateGameDto, SearchGameDto } from "./dto/game.dto";
import { JwtAuthGuard } from "../../common/guards/jwt-auth.guard";
import { Public } from "../../common/decorators/public.decorator";
@ApiTags('games') @ApiTags("games")
@Controller('games') @Controller("games")
export class GamesController { export class GamesController {
constructor(private readonly gamesService: GamesService) {} constructor(private readonly gamesService: GamesService) {}
@Public() @Public()
@Get() @Get()
@ApiOperation({ summary: '获取游戏列表' }) @ApiOperation({ summary: "获取游戏列表" })
@ApiResponse({ status: 200, description: '获取成功' }) @ApiResponse({ status: 200, description: "获取成功" })
@ApiQuery({ name: 'keyword', required: false, description: '搜索关键词' }) @ApiQuery({ name: "keyword", required: false, description: "搜索关键词" })
@ApiQuery({ name: 'platform', required: false, description: '游戏平台' }) @ApiQuery({ name: "platform", required: false, description: "游戏平台" })
@ApiQuery({ name: 'tag', required: false, description: '游戏标签' }) @ApiQuery({ name: "tag", required: false, description: "游戏标签" })
@ApiQuery({ name: 'page', required: false, description: '页码' }) @ApiQuery({ name: "page", required: false, description: "页码" })
@ApiQuery({ name: 'limit', required: false, description: '每页数量' }) @ApiQuery({ name: "limit", required: false, description: "每页数量" })
async findAll(@Query() searchDto: SearchGameDto) { async findAll(@Query() searchDto: SearchGameDto) {
return this.gamesService.findAll(searchDto); return this.gamesService.findAll(searchDto);
} }
@Public() @Public()
@Get('popular') @Get("popular")
@ApiOperation({ summary: '获取热门游戏' }) @ApiOperation({ summary: "获取热门游戏" })
@ApiResponse({ status: 200, description: '获取成功' }) @ApiResponse({ status: 200, description: "获取成功" })
@ApiQuery({ name: 'limit', required: false, description: '数量限制' }) @ApiQuery({ name: "limit", required: false, description: "数量限制" })
async findPopular(@Query('limit') limit?: number) { async findPopular(@Query("limit") limit?: number) {
return this.gamesService.findPopular(limit); return this.gamesService.findPopular(limit);
} }
@Public() @Public()
@Get('tags') @Get("tags")
@ApiOperation({ summary: '获取所有游戏标签' }) @ApiOperation({ summary: "获取所有游戏标签" })
@ApiResponse({ status: 200, description: '获取成功' }) @ApiResponse({ status: 200, description: "获取成功" })
async getTags() { async getTags() {
return this.gamesService.getTags(); return this.gamesService.getTags();
} }
@Public() @Public()
@Get('platforms') @Get("platforms")
@ApiOperation({ summary: '获取所有游戏平台' }) @ApiOperation({ summary: "获取所有游戏平台" })
@ApiResponse({ status: 200, description: '获取成功' }) @ApiResponse({ status: 200, description: "获取成功" })
async getPlatforms() { async getPlatforms() {
return this.gamesService.getPlatforms(); return this.gamesService.getPlatforms();
} }
@Public() @Public()
@Get(':id') @Get(":id")
@ApiOperation({ summary: '获取游戏详情' }) @ApiOperation({ summary: "获取游戏详情" })
@ApiResponse({ status: 200, description: '获取成功' }) @ApiResponse({ status: 200, description: "获取成功" })
async findOne(@Param('id') id: string) { async findOne(@Param("id") id: string) {
return this.gamesService.findOne(id); return this.gamesService.findOne(id);
} }
@ApiBearerAuth() @ApiBearerAuth()
@UseGuards(JwtAuthGuard) @UseGuards(JwtAuthGuard)
@Post() @Post()
@ApiOperation({ summary: '创建游戏' }) @ApiOperation({ summary: "创建游戏" })
@ApiResponse({ status: 201, description: '创建成功' }) @ApiResponse({ status: 201, description: "创建成功" })
async create(@Body() createGameDto: CreateGameDto) { async create(@Body() createGameDto: CreateGameDto) {
return this.gamesService.create(createGameDto); return this.gamesService.create(createGameDto);
} }
@ApiBearerAuth() @ApiBearerAuth()
@UseGuards(JwtAuthGuard) @UseGuards(JwtAuthGuard)
@Put(':id') @Put(":id")
@ApiOperation({ summary: '更新游戏信息' }) @ApiOperation({ summary: "更新游戏信息" })
@ApiResponse({ status: 200, description: '更新成功' }) @ApiResponse({ status: 200, description: "更新成功" })
async update(@Param('id') id: string, @Body() updateGameDto: UpdateGameDto) { async update(@Param("id") id: string, @Body() updateGameDto: UpdateGameDto) {
return this.gamesService.update(id, updateGameDto); return this.gamesService.update(id, updateGameDto);
} }
@ApiBearerAuth() @ApiBearerAuth()
@UseGuards(JwtAuthGuard) @UseGuards(JwtAuthGuard)
@Delete(':id') @Delete(":id")
@ApiOperation({ summary: '删除游戏' }) @ApiOperation({ summary: "删除游戏" })
@ApiResponse({ status: 200, description: '删除成功' }) @ApiResponse({ status: 200, description: "删除成功" })
async remove(@Param('id') id: string) { async remove(@Param("id") id: string) {
return this.gamesService.remove(id); return this.gamesService.remove(id);
} }
} }

View File

@@ -1,8 +1,8 @@
import { Module } from '@nestjs/common'; import { Module } from "@nestjs/common";
import { TypeOrmModule } from '@nestjs/typeorm'; import { TypeOrmModule } from "@nestjs/typeorm";
import { GamesService } from './games.service'; import { GamesService } from "./games.service";
import { GamesController } from './games.controller'; import { GamesController } from "./games.controller";
import { Game } from '../../entities/game.entity'; import { Game } from "../../entities/game.entity";
@Module({ @Module({
imports: [TypeOrmModule.forFeature([Game])], imports: [TypeOrmModule.forFeature([Game])],

View File

@@ -1,23 +1,23 @@
import { Test, TestingModule } from '@nestjs/testing'; import { Test, TestingModule } from "@nestjs/testing";
import { getRepositoryToken } from '@nestjs/typeorm'; import { getRepositoryToken } from "@nestjs/typeorm";
import { Repository } from 'typeorm'; import { Repository } from "typeorm";
import { BadRequestException, NotFoundException } from '@nestjs/common'; import { BadRequestException, NotFoundException } from "@nestjs/common";
import { GamesService } from './games.service'; import { GamesService } from "./games.service";
import { Game } from '../../entities/game.entity'; import { Game } from "../../entities/game.entity";
describe('GamesService', () => { describe("GamesService", () => {
let service: GamesService; let service: GamesService;
let repository: Repository<Game>; let repository: Repository<Game>;
const mockGame = { const mockGame = {
id: 'game-id-1', id: "game-id-1",
name: '王者荣耀', name: "王者荣耀",
coverUrl: 'https://example.com/cover.jpg', coverUrl: "https://example.com/cover.jpg",
description: '5v5竞技游戏', description: "5v5竞技游戏",
maxPlayers: 10, maxPlayers: 10,
minPlayers: 1, minPlayers: 1,
platform: 'iOS/Android', platform: "iOS/Android",
tags: ['MOBA', '5v5'], tags: ["MOBA", "5v5"],
isActive: true, isActive: true,
createdAt: new Date(), createdAt: new Date(),
updatedAt: new Date(), updatedAt: new Date(),
@@ -50,34 +50,40 @@ describe('GamesService', () => {
jest.clearAllMocks(); jest.clearAllMocks();
}); });
describe('create', () => { describe("create", () => {
it('应该成功创建游戏', async () => { it("应该成功创建游戏", async () => {
const createDto = { const createDto = {
name: '原神', name: "原神",
coverUrl: 'https://example.com/genshin.jpg', coverUrl: "https://example.com/genshin.jpg",
description: '开放世界冒险游戏', description: "开放世界冒险游戏",
maxPlayers: 4, maxPlayers: 4,
minPlayers: 1, minPlayers: 1,
platform: 'PC/iOS/Android', platform: "PC/iOS/Android",
tags: ['RPG', '开放世界'], tags: ["RPG", "开放世界"],
}; };
mockRepository.findOne.mockResolvedValue(null); // 游戏名称不存在 mockRepository.findOne.mockResolvedValue(null); // 游戏名称不存在
mockRepository.create.mockReturnValue({ ...createDto, id: 'new-game-id' }); mockRepository.create.mockReturnValue({
mockRepository.save.mockResolvedValue({ ...createDto, id: 'new-game-id' }); ...createDto,
id: "new-game-id",
});
mockRepository.save.mockResolvedValue({
...createDto,
id: "new-game-id",
});
const result = await service.create(createDto); const result = await service.create(createDto);
expect(result).toHaveProperty('id', 'new-game-id'); expect(result).toHaveProperty("id", "new-game-id");
expect(result.name).toBe(createDto.name); expect(result.name).toBe(createDto.name);
expect(mockRepository.findOne).toHaveBeenCalledWith({ expect(mockRepository.findOne).toHaveBeenCalledWith({
where: { name: createDto.name }, where: { name: createDto.name },
}); });
}); });
it('应该在游戏名称已存在时抛出异常', async () => { it("应该在游戏名称已存在时抛出异常", async () => {
const createDto = { const createDto = {
name: '王者荣耀', name: "王者荣耀",
maxPlayers: 10, maxPlayers: 10,
}; };
@@ -89,8 +95,8 @@ describe('GamesService', () => {
}); });
}); });
describe('findAll', () => { describe("findAll", () => {
it('应该返回游戏列表', async () => { it("应该返回游戏列表", async () => {
const searchDto = { const searchDto = {
page: 1, page: 1,
limit: 10, limit: 10,
@@ -115,9 +121,9 @@ describe('GamesService', () => {
expect(result.limit).toBe(10); expect(result.limit).toBe(10);
}); });
it('应该支持关键词搜索', async () => { it("应该支持关键词搜索", async () => {
const searchDto = { const searchDto = {
keyword: '王者', keyword: "王者",
page: 1, page: 1,
limit: 10, limit: 10,
}; };
@@ -139,9 +145,9 @@ describe('GamesService', () => {
expect(result.items).toHaveLength(1); expect(result.items).toHaveLength(1);
}); });
it('应该支持平台筛选', async () => { it("应该支持平台筛选", async () => {
const searchDto = { const searchDto = {
platform: 'iOS', platform: "iOS",
page: 1, page: 1,
limit: 10, limit: 10,
}; };
@@ -163,31 +169,31 @@ describe('GamesService', () => {
}); });
}); });
describe('findOne', () => { describe("findOne", () => {
it('应该返回游戏详情', async () => { it("应该返回游戏详情", async () => {
mockRepository.findOne.mockResolvedValue(mockGame); mockRepository.findOne.mockResolvedValue(mockGame);
const result = await service.findOne('game-id-1'); const result = await service.findOne("game-id-1");
expect(result).toEqual(mockGame); expect(result).toEqual(mockGame);
expect(mockRepository.findOne).toHaveBeenCalledWith({ expect(mockRepository.findOne).toHaveBeenCalledWith({
where: { id: 'game-id-1', isActive: true }, where: { id: "game-id-1", isActive: true },
}); });
}); });
it('应该在游戏不存在时抛出异常', async () => { it("应该在游戏不存在时抛出异常", async () => {
mockRepository.findOne.mockResolvedValue(null); mockRepository.findOne.mockResolvedValue(null);
await expect(service.findOne('nonexistent-id')).rejects.toThrow( await expect(service.findOne("nonexistent-id")).rejects.toThrow(
NotFoundException, NotFoundException,
); );
}); });
}); });
describe('update', () => { describe("update", () => {
it('应该成功更新游戏', async () => { it("应该成功更新游戏", async () => {
const updateDto = { const updateDto = {
description: '更新后的描述', description: "更新后的描述",
maxPlayers: 12, maxPlayers: 12,
}; };
@@ -200,52 +206,52 @@ describe('GamesService', () => {
...updateDto, ...updateDto,
}); });
const result = await service.update('game-id-1', updateDto); const result = await service.update("game-id-1", updateDto);
expect(result.description).toBe(updateDto.description); expect(result.description).toBe(updateDto.description);
expect(result.maxPlayers).toBe(updateDto.maxPlayers); expect(result.maxPlayers).toBe(updateDto.maxPlayers);
}); });
it('应该在更新名称时检查重名', async () => { it("应该在更新名称时检查重名", async () => {
const updateDto = { const updateDto = {
name: '已存在的游戏名', name: "已存在的游戏名",
}; };
const anotherGame = { const anotherGame = {
...mockGame, ...mockGame,
id: 'another-game-id', id: "another-game-id",
name: '已存在的游戏名', name: "已存在的游戏名",
}; };
mockRepository.findOne mockRepository.findOne
.mockResolvedValueOnce(mockGame) // 第一次:获取要更新的游戏 .mockResolvedValueOnce(mockGame) // 第一次:获取要更新的游戏
.mockResolvedValueOnce(anotherGame); // 第二次:检查名称是否存在 .mockResolvedValueOnce(anotherGame); // 第二次:检查名称是否存在
await expect( await expect(service.update("game-id-1", updateDto)).rejects.toThrow(
service.update('game-id-1', updateDto), BadRequestException,
).rejects.toThrow(BadRequestException); );
}); });
}); });
describe('remove', () => { describe("remove", () => {
it('应该软删除游戏', async () => { it("应该软删除游戏", async () => {
mockRepository.findOne.mockResolvedValue(mockGame); mockRepository.findOne.mockResolvedValue(mockGame);
mockRepository.save.mockResolvedValue({ mockRepository.save.mockResolvedValue({
...mockGame, ...mockGame,
isActive: false, isActive: false,
}); });
const result = await service.remove('game-id-1'); const result = await service.remove("game-id-1");
expect(result).toHaveProperty('message', '游戏已删除'); expect(result).toHaveProperty("message", "游戏已删除");
expect(mockRepository.save).toHaveBeenCalledWith( expect(mockRepository.save).toHaveBeenCalledWith(
expect.objectContaining({ isActive: false }), expect.objectContaining({ isActive: false }),
); );
}); });
}); });
describe('findPopular', () => { describe("findPopular", () => {
it('应该返回热门游戏列表', async () => { it("应该返回热门游戏列表", async () => {
mockRepository.find.mockResolvedValue([mockGame]); mockRepository.find.mockResolvedValue([mockGame]);
const result = await service.findPopular(5); const result = await service.findPopular(5);
@@ -253,49 +259,46 @@ describe('GamesService', () => {
expect(result).toHaveLength(1); expect(result).toHaveLength(1);
expect(mockRepository.find).toHaveBeenCalledWith({ expect(mockRepository.find).toHaveBeenCalledWith({
where: { isActive: true }, where: { isActive: true },
order: { createdAt: 'DESC' }, order: { createdAt: "DESC" },
take: 5, take: 5,
}); });
}); });
}); });
describe('getTags', () => { describe("getTags", () => {
it('应该返回所有游戏标签', async () => { it("应该返回所有游戏标签", async () => {
const games = [ const games = [
{ ...mockGame, tags: ['MOBA', '5v5'] }, { ...mockGame, tags: ["MOBA", "5v5"] },
{ ...mockGame, tags: ['FPS', 'RPG'] }, { ...mockGame, tags: ["FPS", "RPG"] },
]; ];
mockRepository.find.mockResolvedValue(games); mockRepository.find.mockResolvedValue(games);
const result = await service.getTags(); const result = await service.getTags();
expect(result).toContain('MOBA'); expect(result).toContain("MOBA");
expect(result).toContain('FPS'); expect(result).toContain("FPS");
expect(result.length).toBeGreaterThan(0); expect(result.length).toBeGreaterThan(0);
}); });
}); });
describe('getPlatforms', () => { describe("getPlatforms", () => {
it('应该返回所有游戏平台', async () => { it("应该返回所有游戏平台", async () => {
const mockQueryBuilder = { const mockQueryBuilder = {
select: jest.fn().mockReturnThis(), select: jest.fn().mockReturnThis(),
where: jest.fn().mockReturnThis(), where: jest.fn().mockReturnThis(),
andWhere: jest.fn().mockReturnThis(), andWhere: jest.fn().mockReturnThis(),
getRawMany: jest getRawMany: jest
.fn() .fn()
.mockResolvedValue([ .mockResolvedValue([{ platform: "iOS/Android" }, { platform: "PC" }]),
{ platform: 'iOS/Android' },
{ platform: 'PC' },
]),
}; };
mockRepository.createQueryBuilder.mockReturnValue(mockQueryBuilder); mockRepository.createQueryBuilder.mockReturnValue(mockQueryBuilder);
const result = await service.getPlatforms(); const result = await service.getPlatforms();
expect(result).toContain('iOS/Android'); expect(result).toContain("iOS/Android");
expect(result).toContain('PC'); expect(result).toContain("PC");
}); });
}); });
}); });

View File

@@ -1,10 +1,17 @@
import { Injectable, NotFoundException, BadRequestException } from '@nestjs/common'; import {
import { InjectRepository } from '@nestjs/typeorm'; Injectable,
import { Repository, Like } from 'typeorm'; NotFoundException,
import { Game } from '../../entities/game.entity'; BadRequestException,
import { CreateGameDto, UpdateGameDto, SearchGameDto } from './dto/game.dto'; } from "@nestjs/common";
import { ErrorCode, ErrorMessage } from '../../common/interfaces/response.interface'; import { InjectRepository } from "@nestjs/typeorm";
import { PaginationUtil } from '../../common/utils/pagination.util'; import { Repository, Like } from "typeorm";
import { Game } from "../../entities/game.entity";
import { CreateGameDto, UpdateGameDto, SearchGameDto } from "./dto/game.dto";
import {
ErrorCode,
ErrorMessage,
} from "../../common/interfaces/response.interface";
import { PaginationUtil } from "../../common/utils/pagination.util";
@Injectable() @Injectable()
export class GamesService { export class GamesService {
@@ -47,32 +54,32 @@ export class GamesService {
const { offset } = PaginationUtil.formatPaginationParams(page, limit); const { offset } = PaginationUtil.formatPaginationParams(page, limit);
const queryBuilder = this.gameRepository const queryBuilder = this.gameRepository
.createQueryBuilder('game') .createQueryBuilder("game")
.where('game.isActive = :isActive', { isActive: true }); .where("game.isActive = :isActive", { isActive: true });
// 关键词搜索(游戏名称和描述) // 关键词搜索(游戏名称和描述)
if (keyword) { if (keyword) {
queryBuilder.andWhere( queryBuilder.andWhere(
'(game.name LIKE :keyword OR game.description LIKE :keyword)', "(game.name LIKE :keyword OR game.description LIKE :keyword)",
{ keyword: `%${keyword}%` }, { keyword: `%${keyword}%` },
); );
} }
// 平台筛选 // 平台筛选
if (platform) { if (platform) {
queryBuilder.andWhere('game.platform LIKE :platform', { queryBuilder.andWhere("game.platform LIKE :platform", {
platform: `%${platform}%`, platform: `%${platform}%`,
}); });
} }
// 标签筛选 // 标签筛选
if (tag) { if (tag) {
queryBuilder.andWhere('game.tags LIKE :tag', { tag: `%${tag}%` }); queryBuilder.andWhere("game.tags LIKE :tag", { tag: `%${tag}%` });
} }
// 分页 // 分页
const [items, total] = await queryBuilder const [items, total] = await queryBuilder
.orderBy('game.createdAt', 'DESC') .orderBy("game.createdAt", "DESC")
.skip(offset) .skip(offset)
.take(limit) .take(limit)
.getManyAndCount(); .getManyAndCount();
@@ -119,7 +126,7 @@ export class GamesService {
if (existingGame) { if (existingGame) {
throw new BadRequestException({ throw new BadRequestException({
code: ErrorCode.GAME_EXISTS, code: ErrorCode.GAME_EXISTS,
message: '游戏名称已存在', message: "游戏名称已存在",
}); });
} }
} }
@@ -139,7 +146,7 @@ export class GamesService {
game.isActive = false; game.isActive = false;
await this.gameRepository.save(game); await this.gameRepository.save(game);
return { message: '游戏已删除' }; return { message: "游戏已删除" };
} }
/** /**
@@ -148,7 +155,7 @@ export class GamesService {
async findPopular(limit: number = 10) { async findPopular(limit: number = 10) {
const games = await this.gameRepository.find({ const games = await this.gameRepository.find({
where: { isActive: true }, where: { isActive: true },
order: { createdAt: 'DESC' }, order: { createdAt: "DESC" },
take: limit, take: limit,
}); });
@@ -161,7 +168,7 @@ export class GamesService {
async getTags() { async getTags() {
const games = await this.gameRepository.find({ const games = await this.gameRepository.find({
where: { isActive: true }, where: { isActive: true },
select: ['tags'], select: ["tags"],
}); });
const tagsSet = new Set<string>(); const tagsSet = new Set<string>();
@@ -179,10 +186,10 @@ export class GamesService {
*/ */
async getPlatforms() { async getPlatforms() {
const games = await this.gameRepository const games = await this.gameRepository
.createQueryBuilder('game') .createQueryBuilder("game")
.select('DISTINCT game.platform', 'platform') .select("DISTINCT game.platform", "platform")
.where('game.isActive = :isActive', { isActive: true }) .where("game.isActive = :isActive", { isActive: true })
.andWhere('game.platform IS NOT NULL') .andWhere("game.platform IS NOT NULL")
.getRawMany(); .getRawMany();
return games.map((item) => item.platform); return games.map((item) => item.platform);

View File

@@ -1,34 +1,41 @@
import { IsString, IsNotEmpty, IsOptional, IsNumber, Min, Max } from 'class-validator'; import {
import { ApiProperty } from '@nestjs/swagger'; IsString,
import { Type } from 'class-transformer'; IsNotEmpty,
IsOptional,
IsNumber,
Min,
Max,
} from "class-validator";
import { ApiProperty } from "@nestjs/swagger";
import { Type } from "class-transformer";
export class CreateGroupDto { export class CreateGroupDto {
@ApiProperty({ description: '小组名称', example: '王者荣耀固定队' }) @ApiProperty({ description: "小组名称", example: "王者荣耀固定队" })
@IsString() @IsString()
@IsNotEmpty({ message: '小组名称不能为空' }) @IsNotEmpty({ message: "小组名称不能为空" })
name: string; name: string;
@ApiProperty({ description: '小组描述', required: false }) @ApiProperty({ description: "小组描述", required: false })
@IsString() @IsString()
@IsOptional() @IsOptional()
description?: string; description?: string;
@ApiProperty({ description: '小组头像', required: false }) @ApiProperty({ description: "小组头像", required: false })
@IsString() @IsString()
@IsOptional() @IsOptional()
avatar?: string; avatar?: string;
@ApiProperty({ description: '小组类型', example: 'normal', required: false }) @ApiProperty({ description: "小组类型", example: "normal", required: false })
@IsString() @IsString()
@IsOptional() @IsOptional()
type?: string; type?: string;
@ApiProperty({ description: '父组ID创建子组时使用', required: false }) @ApiProperty({ description: "父组ID创建子组时使用", required: false })
@IsString() @IsString()
@IsOptional() @IsOptional()
parentId?: string; parentId?: string;
@ApiProperty({ description: '最大成员数', example: 50, required: false }) @ApiProperty({ description: "最大成员数", example: 50, required: false })
@IsNumber() @IsNumber()
@Min(2) @Min(2)
@Max(500) @Max(500)
@@ -38,27 +45,27 @@ export class CreateGroupDto {
} }
export class UpdateGroupDto { export class UpdateGroupDto {
@ApiProperty({ description: '小组名称', required: false }) @ApiProperty({ description: "小组名称", required: false })
@IsString() @IsString()
@IsOptional() @IsOptional()
name?: string; name?: string;
@ApiProperty({ description: '小组描述', required: false }) @ApiProperty({ description: "小组描述", required: false })
@IsString() @IsString()
@IsOptional() @IsOptional()
description?: string; description?: string;
@ApiProperty({ description: '小组头像', required: false }) @ApiProperty({ description: "小组头像", required: false })
@IsString() @IsString()
@IsOptional() @IsOptional()
avatar?: string; avatar?: string;
@ApiProperty({ description: '公示信息', required: false }) @ApiProperty({ description: "公示信息", required: false })
@IsString() @IsString()
@IsOptional() @IsOptional()
announcement?: string; announcement?: string;
@ApiProperty({ description: '最大成员数', required: false }) @ApiProperty({ description: "最大成员数", required: false })
@IsNumber() @IsNumber()
@Min(2) @Min(2)
@Max(500) @Max(500)
@@ -68,32 +75,36 @@ export class UpdateGroupDto {
} }
export class JoinGroupDto { export class JoinGroupDto {
@ApiProperty({ description: '小组ID' }) @ApiProperty({ description: "小组ID" })
@IsString() @IsString()
@IsNotEmpty({ message: '小组ID不能为空' }) @IsNotEmpty({ message: "小组ID不能为空" })
groupId: string; groupId: string;
@ApiProperty({ description: '组内昵称', required: false }) @ApiProperty({ description: "组内昵称", required: false })
@IsString() @IsString()
@IsOptional() @IsOptional()
nickname?: string; nickname?: string;
} }
export class UpdateMemberRoleDto { export class UpdateMemberRoleDto {
@ApiProperty({ description: '成员ID' }) @ApiProperty({ description: "成员ID" })
@IsString() @IsString()
@IsNotEmpty({ message: '成员ID不能为空' }) @IsNotEmpty({ message: "成员ID不能为空" })
userId: string; userId: string;
@ApiProperty({ description: '角色', example: 'admin', enum: ['owner', 'admin', 'member'] }) @ApiProperty({
description: "角色",
example: "admin",
enum: ["owner", "admin", "member"],
})
@IsString() @IsString()
@IsNotEmpty({ message: '角色不能为空' }) @IsNotEmpty({ message: "角色不能为空" })
role: string; role: string;
} }
export class KickMemberDto { export class KickMemberDto {
@ApiProperty({ description: '成员ID' }) @ApiProperty({ description: "成员ID" })
@IsString() @IsString()
@IsNotEmpty({ message: '成员ID不能为空' }) @IsNotEmpty({ message: "成员ID不能为空" })
userId: string; userId: string;
} }

View File

@@ -7,79 +7,87 @@ import {
Body, Body,
Param, Param,
UseGuards, UseGuards,
} from '@nestjs/common'; } from "@nestjs/common";
import { ApiTags, ApiOperation, ApiResponse, ApiBearerAuth } from '@nestjs/swagger'; import {
import { GroupsService } from './groups.service'; ApiTags,
ApiOperation,
ApiResponse,
ApiBearerAuth,
} from "@nestjs/swagger";
import { GroupsService } from "./groups.service";
import { import {
CreateGroupDto, CreateGroupDto,
UpdateGroupDto, UpdateGroupDto,
JoinGroupDto, JoinGroupDto,
UpdateMemberRoleDto, UpdateMemberRoleDto,
KickMemberDto, KickMemberDto,
} from './dto/group.dto'; } from "./dto/group.dto";
import { JwtAuthGuard } from '../../common/guards/jwt-auth.guard'; import { JwtAuthGuard } from "../../common/guards/jwt-auth.guard";
import { CurrentUser } from '../../common/decorators/current-user.decorator'; import { CurrentUser } from "../../common/decorators/current-user.decorator";
import { User } from '../../entities/user.entity'; import { User } from "../../entities/user.entity";
@ApiTags('groups') @ApiTags("groups")
@ApiBearerAuth() @ApiBearerAuth()
@UseGuards(JwtAuthGuard) @UseGuards(JwtAuthGuard)
@Controller('groups') @Controller("groups")
export class GroupsController { export class GroupsController {
constructor(private readonly groupsService: GroupsService) {} constructor(private readonly groupsService: GroupsService) {}
@Post() @Post()
@ApiOperation({ summary: '创建小组' }) @ApiOperation({ summary: "创建小组" })
@ApiResponse({ status: 201, description: '创建成功' }) @ApiResponse({ status: 201, description: "创建成功" })
async create(@CurrentUser() user: User, @Body() createGroupDto: CreateGroupDto) { async create(
@CurrentUser() user: User,
@Body() createGroupDto: CreateGroupDto,
) {
return this.groupsService.create(user.id, createGroupDto); return this.groupsService.create(user.id, createGroupDto);
} }
@Post('join') @Post("join")
@ApiOperation({ summary: '加入小组' }) @ApiOperation({ summary: "加入小组" })
@ApiResponse({ status: 200, description: '加入成功' }) @ApiResponse({ status: 200, description: "加入成功" })
async join(@CurrentUser() user: User, @Body() joinGroupDto: JoinGroupDto) { async join(@CurrentUser() user: User, @Body() joinGroupDto: JoinGroupDto) {
return this.groupsService.join(user.id, joinGroupDto); return this.groupsService.join(user.id, joinGroupDto);
} }
@Delete(':id/leave') @Delete(":id/leave")
@ApiOperation({ summary: '退出小组' }) @ApiOperation({ summary: "退出小组" })
@ApiResponse({ status: 200, description: '退出成功' }) @ApiResponse({ status: 200, description: "退出成功" })
async leave(@CurrentUser() user: User, @Param('id') id: string) { async leave(@CurrentUser() user: User, @Param("id") id: string) {
return this.groupsService.leave(user.id, id); return this.groupsService.leave(user.id, id);
} }
@Get('my') @Get("my")
@ApiOperation({ summary: '获取我的小组列表' }) @ApiOperation({ summary: "获取我的小组列表" })
@ApiResponse({ status: 200, description: '获取成功' }) @ApiResponse({ status: 200, description: "获取成功" })
async findMy(@CurrentUser() user: User) { async findMy(@CurrentUser() user: User) {
return this.groupsService.findUserGroups(user.id); return this.groupsService.findUserGroups(user.id);
} }
@Get(':id') @Get(":id")
@ApiOperation({ summary: '获取小组详情' }) @ApiOperation({ summary: "获取小组详情" })
@ApiResponse({ status: 200, description: '获取成功' }) @ApiResponse({ status: 200, description: "获取成功" })
async findOne(@Param('id') id: string) { async findOne(@Param("id") id: string) {
return this.groupsService.findOne(id); return this.groupsService.findOne(id);
} }
@Put(':id') @Put(":id")
@ApiOperation({ summary: '更新小组信息' }) @ApiOperation({ summary: "更新小组信息" })
@ApiResponse({ status: 200, description: '更新成功' }) @ApiResponse({ status: 200, description: "更新成功" })
async update( async update(
@CurrentUser() user: User, @CurrentUser() user: User,
@Param('id') id: string, @Param("id") id: string,
@Body() updateGroupDto: UpdateGroupDto, @Body() updateGroupDto: UpdateGroupDto,
) { ) {
return this.groupsService.update(user.id, id, updateGroupDto); return this.groupsService.update(user.id, id, updateGroupDto);
} }
@Put(':id/members/role') @Put(":id/members/role")
@ApiOperation({ summary: '设置成员角色' }) @ApiOperation({ summary: "设置成员角色" })
@ApiResponse({ status: 200, description: '设置成功' }) @ApiResponse({ status: 200, description: "设置成功" })
async updateMemberRole( async updateMemberRole(
@CurrentUser() user: User, @CurrentUser() user: User,
@Param('id') id: string, @Param("id") id: string,
@Body() updateMemberRoleDto: UpdateMemberRoleDto, @Body() updateMemberRoleDto: UpdateMemberRoleDto,
) { ) {
return this.groupsService.updateMemberRole( return this.groupsService.updateMemberRole(
@@ -90,21 +98,21 @@ export class GroupsController {
); );
} }
@Delete(':id/members') @Delete(":id/members")
@ApiOperation({ summary: '踢出成员' }) @ApiOperation({ summary: "踢出成员" })
@ApiResponse({ status: 200, description: '移除成功' }) @ApiResponse({ status: 200, description: "移除成功" })
async kickMember( async kickMember(
@CurrentUser() user: User, @CurrentUser() user: User,
@Param('id') id: string, @Param("id") id: string,
@Body() kickMemberDto: KickMemberDto, @Body() kickMemberDto: KickMemberDto,
) { ) {
return this.groupsService.kickMember(user.id, id, kickMemberDto.userId); return this.groupsService.kickMember(user.id, id, kickMemberDto.userId);
} }
@Delete(':id') @Delete(":id")
@ApiOperation({ summary: '解散小组' }) @ApiOperation({ summary: "解散小组" })
@ApiResponse({ status: 200, description: '解散成功' }) @ApiResponse({ status: 200, description: "解散成功" })
async disband(@CurrentUser() user: User, @Param('id') id: string) { async disband(@CurrentUser() user: User, @Param("id") id: string) {
return this.groupsService.disband(user.id, id); return this.groupsService.disband(user.id, id);
} }
} }

View File

@@ -1,10 +1,10 @@
import { Module } from '@nestjs/common'; import { Module } from "@nestjs/common";
import { TypeOrmModule } from '@nestjs/typeorm'; import { TypeOrmModule } from "@nestjs/typeorm";
import { GroupsService } from './groups.service'; import { GroupsService } from "./groups.service";
import { GroupsController } from './groups.controller'; import { GroupsController } from "./groups.controller";
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 { User } from '../../entities/user.entity'; import { User } from "../../entities/user.entity";
@Module({ @Module({
imports: [TypeOrmModule.forFeature([Group, GroupMember, User])], imports: [TypeOrmModule.forFeature([Group, GroupMember, User])],

View File

@@ -1,28 +1,28 @@
import { Test, TestingModule } from '@nestjs/testing'; import { Test, TestingModule } from "@nestjs/testing";
import { getRepositoryToken } from '@nestjs/typeorm'; import { getRepositoryToken } from "@nestjs/typeorm";
import { import {
NotFoundException, NotFoundException,
BadRequestException, BadRequestException,
ForbiddenException, ForbiddenException,
} from '@nestjs/common'; } from "@nestjs/common";
import { GroupsService } from './groups.service'; import { GroupsService } from "./groups.service";
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 { User } from '../../entities/user.entity'; import { User } from "../../entities/user.entity";
import { CacheService } from '../../common/services/cache.service'; import { CacheService } from "../../common/services/cache.service";
describe('GroupsService', () => { describe("GroupsService", () => {
let service: GroupsService; let service: GroupsService;
let mockGroupRepository: any; let mockGroupRepository: any;
let mockGroupMemberRepository: any; let mockGroupMemberRepository: any;
let mockUserRepository: any; let mockUserRepository: any;
const mockUser = { id: 'user-1', username: 'testuser' }; const mockUser = { id: "user-1", username: "testuser" };
const mockGroup = { const mockGroup = {
id: 'group-1', id: "group-1",
name: '测试小组', name: "测试小组",
description: '描述', description: "描述",
ownerId: 'user-1', ownerId: "user-1",
maxMembers: 10, maxMembers: 10,
isPublic: true, isPublic: true,
isActive: true, isActive: true,
@@ -31,10 +31,10 @@ describe('GroupsService', () => {
}; };
const mockMember = { const mockMember = {
id: 'member-1', id: "member-1",
userId: 'user-1', userId: "user-1",
groupId: 'group-1', groupId: "group-1",
role: 'owner', role: "owner",
isActive: true, isActive: true,
joinedAt: new Date(), joinedAt: new Date(),
}; };
@@ -97,8 +97,8 @@ describe('GroupsService', () => {
mockUserRepository.findOne.mockResolvedValue(mockUser); mockUserRepository.findOne.mockResolvedValue(mockUser);
}); });
describe('create', () => { describe("create", () => {
it('应该成功创建小组', async () => { it("应该成功创建小组", async () => {
mockGroupRepository.count.mockResolvedValue(2); mockGroupRepository.count.mockResolvedValue(2);
mockGroupRepository.create.mockReturnValue(mockGroup); mockGroupRepository.create.mockReturnValue(mockGroup);
mockGroupRepository.save.mockResolvedValue(mockGroup); mockGroupRepository.save.mockResolvedValue(mockGroup);
@@ -109,85 +109,85 @@ describe('GroupsService', () => {
owner: mockUser, owner: mockUser,
}); });
const result = await service.create('user-1', { const result = await service.create("user-1", {
name: '测试小组', name: "测试小组",
description: '描述', description: "描述",
maxMembers: 10, maxMembers: 10,
}); });
expect(result).toHaveProperty('id'); expect(result).toHaveProperty("id");
expect(result.name).toBe('测试小组'); expect(result.name).toBe("测试小组");
expect(mockGroupRepository.save).toHaveBeenCalled(); expect(mockGroupRepository.save).toHaveBeenCalled();
expect(mockGroupMemberRepository.save).toHaveBeenCalled(); expect(mockGroupMemberRepository.save).toHaveBeenCalled();
}); });
it('应该mock在创建小组数量超限时抛出异常', async () => { it("应该mock在创建小组数量超限时抛出异常", async () => {
mockGroupRepository.count.mockResolvedValue(5); mockGroupRepository.count.mockResolvedValue(5);
mockUserRepository.findOne.mockResolvedValue(mockUser); mockUserRepository.findOne.mockResolvedValue(mockUser);
await expect( await expect(
service.create('user-1', { service.create("user-1", {
name: '测试小组', name: "测试小组",
maxMembers: 10, maxMembers: 10,
}), }),
).rejects.toThrow(BadRequestException); ).rejects.toThrow(BadRequestException);
}); });
}); });
describe('findOne', () => { describe("findOne", () => {
it('应该成功获取小组详情', async () => { it("应该成功获取小组详情", async () => {
mockGroupRepository.findOne.mockResolvedValue({ mockGroupRepository.findOne.mockResolvedValue({
...mockGroup, ...mockGroup,
owner: mockUser, owner: mockUser,
}); });
const result = await service.findOne('group-1'); const result = await service.findOne("group-1");
expect(result).toHaveProperty('id'); expect(result).toHaveProperty("id");
expect(result.id).toBe('group-1'); expect(result.id).toBe("group-1");
}); });
it('应该在小组不存在时抛出异常', async () => { it("应该在小组不存在时抛出异常", async () => {
mockGroupRepository.findOne.mockResolvedValue(null); mockGroupRepository.findOne.mockResolvedValue(null);
await expect(service.findOne('group-1')).rejects.toThrow( await expect(service.findOne("group-1")).rejects.toThrow(
NotFoundException, NotFoundException,
); );
}); });
}); });
describe('update', () => { describe("update", () => {
it('应该成功更新小组', async () => { it("应该成功更新小组", async () => {
mockGroupRepository.findOne mockGroupRepository.findOne
.mockResolvedValueOnce(mockGroup) .mockResolvedValueOnce(mockGroup)
.mockResolvedValueOnce({ .mockResolvedValueOnce({
...mockGroup, ...mockGroup,
name: '更新后的名称', name: "更新后的名称",
owner: mockUser, owner: mockUser,
}); });
mockGroupRepository.save.mockResolvedValue({ mockGroupRepository.save.mockResolvedValue({
...mockGroup, ...mockGroup,
name: '更新后的名称', name: "更新后的名称",
}); });
const result = await service.update('user-1', 'group-1', { const result = await service.update("user-1", "group-1", {
name: '更新后的名称', name: "更新后的名称",
}); });
expect(result.name).toBe('更新后的名称'); expect(result.name).toBe("更新后的名称");
}); });
it('应该在非所有者更新时抛出异常', async () => { it("应该在非所有者更新时抛出异常", async () => {
mockGroupRepository.findOne.mockResolvedValue(mockGroup); mockGroupRepository.findOne.mockResolvedValue(mockGroup);
await expect( await expect(
service.update('user-2', 'group-1', { name: '新名称' }), service.update("user-2", "group-1", { name: "新名称" }),
).rejects.toThrow(ForbiddenException); ).rejects.toThrow(ForbiddenException);
}); });
}); });
describe('join', () => { describe("join", () => {
it('应该成功加入小组', async () => { it("应该成功加入小组", async () => {
mockGroupRepository.findOne.mockResolvedValue(mockGroup); mockGroupRepository.findOne.mockResolvedValue(mockGroup);
mockGroupMemberRepository.findOne.mockResolvedValue(null); mockGroupMemberRepository.findOne.mockResolvedValue(null);
mockGroupMemberRepository.count mockGroupMemberRepository.count
@@ -196,45 +196,45 @@ describe('GroupsService', () => {
mockGroupMemberRepository.create.mockReturnValue(mockMember); mockGroupMemberRepository.create.mockReturnValue(mockMember);
mockGroupMemberRepository.save.mockResolvedValue(mockMember); mockGroupMemberRepository.save.mockResolvedValue(mockMember);
const result = await service.join('user-2', { groupId: 'group-1' }); const result = await service.join("user-2", { groupId: "group-1" });
expect(result).toHaveProperty('message'); expect(result).toHaveProperty("message");
expect(mockGroupMemberRepository.save).toHaveBeenCalled(); expect(mockGroupMemberRepository.save).toHaveBeenCalled();
}); });
it('应该在小组不存在时抛出异常', async () => { it("应该在小组不存在时抛出异常", async () => {
mockGroupRepository.findOne.mockResolvedValue(null); mockGroupRepository.findOne.mockResolvedValue(null);
await expect(service.join('user-2', { groupId: 'group-1' })).rejects.toThrow( await expect(
NotFoundException, service.join("user-2", { groupId: "group-1" }),
); ).rejects.toThrow(NotFoundException);
}); });
it('应该在已加入时抛出异常', async () => { it("应该在已加入时抛出异常", async () => {
mockGroupRepository.findOne.mockResolvedValue(mockGroup); mockGroupRepository.findOne.mockResolvedValue(mockGroup);
mockGroupMemberRepository.findOne.mockResolvedValue(mockMember); mockGroupMemberRepository.findOne.mockResolvedValue(mockMember);
await expect(service.join('user-1', { groupId: 'group-1' })).rejects.toThrow( await expect(
BadRequestException, service.join("user-1", { groupId: "group-1" }),
); ).rejects.toThrow(BadRequestException);
}); });
it('应该在小组已满时抛出异常', async () => { it("应该在小组已满时抛出异常", async () => {
mockGroupRepository.findOne.mockResolvedValue(mockGroup); mockGroupRepository.findOne.mockResolvedValue(mockGroup);
mockGroupMemberRepository.findOne.mockResolvedValue(null); mockGroupMemberRepository.findOne.mockResolvedValue(null);
mockGroupMemberRepository.count mockGroupMemberRepository.count
.mockResolvedValueOnce(3) .mockResolvedValueOnce(3)
.mockResolvedValueOnce(10); .mockResolvedValueOnce(10);
await expect(service.join('user-2', { groupId: 'group-1' })).rejects.toThrow( await expect(
BadRequestException, service.join("user-2", { groupId: "group-1" }),
); ).rejects.toThrow(BadRequestException);
}); });
}); });
describe('leave', () => { describe("leave", () => {
it('应该成功离开小组', async () => { it("应该成功离开小组", async () => {
const memberNotOwner = { ...mockMember, role: 'member' }; const memberNotOwner = { ...mockMember, role: "member" };
mockGroupRepository.findOne.mockResolvedValue(mockGroup); mockGroupRepository.findOne.mockResolvedValue(mockGroup);
mockGroupMemberRepository.findOne.mockResolvedValue(memberNotOwner); mockGroupMemberRepository.findOne.mockResolvedValue(memberNotOwner);
mockGroupMemberRepository.save.mockResolvedValue({ mockGroupMemberRepository.save.mockResolvedValue({
@@ -242,48 +242,48 @@ describe('GroupsService', () => {
isActive: false, isActive: false,
}); });
const result = await service.leave('user-2', 'group-1'); const result = await service.leave("user-2", "group-1");
expect(result).toHaveProperty('message'); expect(result).toHaveProperty("message");
}); });
it('应该在小组所有者尝试离开时抛出异常', async () => { it("应该在小组所有者尝试离开时抛出异常", async () => {
mockGroupRepository.findOne.mockResolvedValue(mockGroup); mockGroupRepository.findOne.mockResolvedValue(mockGroup);
mockGroupMemberRepository.findOne.mockResolvedValue(mockMember); mockGroupMemberRepository.findOne.mockResolvedValue(mockMember);
await expect(service.leave('user-1', 'group-1')).rejects.toThrow( await expect(service.leave("user-1", "group-1")).rejects.toThrow(
BadRequestException, BadRequestException,
); );
}); });
}); });
describe('updateMemberRole', () => { describe("updateMemberRole", () => {
it('应该成功更新成员角色', async () => { it("应该成功更新成员角色", async () => {
mockGroupRepository.findOne.mockResolvedValue(mockGroup); mockGroupRepository.findOne.mockResolvedValue(mockGroup);
mockGroupMemberRepository.findOne.mockResolvedValue({ mockGroupMemberRepository.findOne.mockResolvedValue({
...mockMember, ...mockMember,
role: 'member', role: "member",
}); });
mockGroupMemberRepository.save.mockResolvedValue({ mockGroupMemberRepository.save.mockResolvedValue({
...mockMember, ...mockMember,
role: 'admin', role: "admin",
}); });
const result = await service.updateMemberRole( const result = await service.updateMemberRole(
'user-1', "user-1",
'group-1', "group-1",
'user-2', "user-2",
'admin' as any, "admin" as any,
); );
expect(result).toHaveProperty('message'); expect(result).toHaveProperty("message");
}); });
it('应该在非所有者更新角色时抛出异常', async () => { it("应该在非所有者更新角色时抛出异常", async () => {
mockGroupRepository.findOne.mockResolvedValue(mockGroup); mockGroupRepository.findOne.mockResolvedValue(mockGroup);
await expect( await expect(
service.updateMemberRole('user-2', 'group-1', 'user-3', 'admin' as any), service.updateMemberRole("user-2", "group-1", "user-3", "admin" as any),
).rejects.toThrow(ForbiddenException); ).rejects.toThrow(ForbiddenException);
}); });
}); });

View File

@@ -3,20 +3,23 @@ import {
NotFoundException, NotFoundException,
BadRequestException, BadRequestException,
ForbiddenException, ForbiddenException,
} from '@nestjs/common'; } from "@nestjs/common";
import { InjectRepository } from '@nestjs/typeorm'; import { InjectRepository } from "@nestjs/typeorm";
import { Repository } from 'typeorm'; import { Repository } from "typeorm";
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 { User } from '../../entities/user.entity'; import { User } from "../../entities/user.entity";
import { CreateGroupDto, UpdateGroupDto, JoinGroupDto } from './dto/group.dto'; import { CreateGroupDto, UpdateGroupDto, JoinGroupDto } from "./dto/group.dto";
import { GroupMemberRole } from '../../common/enums'; import { GroupMemberRole } from "../../common/enums";
import { ErrorCode, ErrorMessage } from '../../common/interfaces/response.interface'; import {
import { CacheService } from '../../common/services/cache.service'; ErrorCode,
ErrorMessage,
} from "../../common/interfaces/response.interface";
import { CacheService } from "../../common/services/cache.service";
@Injectable() @Injectable()
export class GroupsService { export class GroupsService {
private readonly CACHE_PREFIX = 'group'; private readonly CACHE_PREFIX = "group";
private readonly CACHE_TTL = 300; // 5 minutes private readonly CACHE_TTL = 300; // 5 minutes
constructor( constructor(
@@ -50,14 +53,14 @@ export class GroupsService {
if (!user.isMember && ownedGroupsCount >= 1) { if (!user.isMember && ownedGroupsCount >= 1) {
throw new BadRequestException({ throw new BadRequestException({
code: ErrorCode.GROUP_LIMIT_EXCEEDED, code: ErrorCode.GROUP_LIMIT_EXCEEDED,
message: '非会员最多只能创建1个小组', message: "非会员最多只能创建1个小组",
}); });
} }
if (user.isMember && ownedGroupsCount >= 10) { if (user.isMember && ownedGroupsCount >= 10) {
throw new BadRequestException({ throw new BadRequestException({
code: ErrorCode.GROUP_LIMIT_EXCEEDED, code: ErrorCode.GROUP_LIMIT_EXCEEDED,
message: '会员最多只能创建10个小组', message: "会员最多只能创建10个小组",
}); });
} }
@@ -66,7 +69,7 @@ export class GroupsService {
if (!user.isMember) { if (!user.isMember) {
throw new ForbiddenException({ throw new ForbiddenException({
code: ErrorCode.NO_PERMISSION, code: ErrorCode.NO_PERMISSION,
message: '非会员不能创建子组', message: "非会员不能创建子组",
}); });
} }
@@ -77,7 +80,7 @@ export class GroupsService {
if (!parentGroup) { if (!parentGroup) {
throw new NotFoundException({ throw new NotFoundException({
code: ErrorCode.GROUP_NOT_FOUND, code: ErrorCode.GROUP_NOT_FOUND,
message: '父组不存在', message: "父组不存在",
}); });
} }
} }
@@ -117,7 +120,9 @@ export class GroupsService {
}); });
} }
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,
@@ -154,10 +159,10 @@ export class GroupsService {
.createQueryBuilder() .createQueryBuilder()
.update(Group) .update(Group)
.set({ .set({
currentMembers: () => 'currentMembers + 1', currentMembers: () => "currentMembers + 1",
}) })
.where('id = :id', { id: groupId }) .where("id = :id", { id: groupId })
.andWhere('currentMembers < maxMembers') .andWhere("currentMembers < maxMembers")
.execute(); .execute();
// 如果影响的行数为0说明小组已满 // 如果影响的行数为0说明小组已满
@@ -200,20 +205,22 @@ export class GroupsService {
if (member.role === GroupMemberRole.OWNER) { if (member.role === GroupMemberRole.OWNER) {
throw new BadRequestException({ throw new BadRequestException({
code: ErrorCode.NO_PERMISSION, code: ErrorCode.NO_PERMISSION,
message: '组长不能退出小组,请先转让组长或解散小组', message: "组长不能退出小组,请先转让组长或解散小组",
}); });
} }
await this.groupMemberRepository.remove(member); await this.groupMemberRepository.remove(member);
// 更新小组成员数 // 更新小组成员数
const group = await this.groupRepository.findOne({ where: { id: groupId } }); const group = await this.groupRepository.findOne({
where: { id: groupId },
});
if (group) { if (group) {
group.currentMembers = Math.max(0, group.currentMembers - 1); group.currentMembers = Math.max(0, group.currentMembers - 1);
await this.groupRepository.save(group); await this.groupRepository.save(group);
} }
return { message: '退出成功' }; return { message: "退出成功" };
} }
/** /**
@@ -231,7 +238,7 @@ export class GroupsService {
const group = await this.groupRepository.findOne({ const group = await this.groupRepository.findOne({
where: { id }, where: { id },
relations: ['owner', 'members', 'members.user'], relations: ["owner", "members", "members.user"],
}); });
if (!group) { if (!group) {
@@ -269,7 +276,7 @@ export class GroupsService {
async findUserGroups(userId: string) { async findUserGroups(userId: string) {
const members = await this.groupMemberRepository.find({ const members = await this.groupMemberRepository.find({
where: { userId }, where: { userId },
relations: ['group', 'group.owner'], relations: ["group", "group.owner"],
}); });
return members.map((member) => ({ return members.map((member) => ({
@@ -282,8 +289,14 @@ export class GroupsService {
/** /**
* 更新小组信息 * 更新小组信息
*/ */
async update(userId: string, groupId: string, updateGroupDto: UpdateGroupDto) { async update(
const group = await this.groupRepository.findOne({ where: { id: groupId } }); userId: string,
groupId: string,
updateGroupDto: UpdateGroupDto,
) {
const group = await this.groupRepository.findOne({
where: { id: groupId },
});
if (!group) { if (!group) {
throw new NotFoundException({ throw new NotFoundException({
@@ -326,7 +339,7 @@ export class GroupsService {
if (!member) { if (!member) {
throw new NotFoundException({ throw new NotFoundException({
code: ErrorCode.NOT_IN_GROUP, code: ErrorCode.NOT_IN_GROUP,
message: '该用户不在小组中', message: "该用户不在小组中",
}); });
} }
@@ -334,14 +347,14 @@ export class GroupsService {
if (member.role === GroupMemberRole.OWNER) { if (member.role === GroupMemberRole.OWNER) {
throw new BadRequestException({ throw new BadRequestException({
code: ErrorCode.NO_PERMISSION, code: ErrorCode.NO_PERMISSION,
message: '不能修改组长角色', message: "不能修改组长角色",
}); });
} }
member.role = role; member.role = role;
await this.groupMemberRepository.save(member); await this.groupMemberRepository.save(member);
return { message: '角色设置成功' }; return { message: "角色设置成功" };
} }
/** /**
@@ -361,7 +374,7 @@ export class GroupsService {
if (!member) { if (!member) {
throw new NotFoundException({ throw new NotFoundException({
code: ErrorCode.NOT_IN_GROUP, code: ErrorCode.NOT_IN_GROUP,
message: '该用户不在小组中', message: "该用户不在小组中",
}); });
} }
@@ -369,27 +382,31 @@ export class GroupsService {
if (member.role === GroupMemberRole.OWNER) { if (member.role === GroupMemberRole.OWNER) {
throw new BadRequestException({ throw new BadRequestException({
code: ErrorCode.NO_PERMISSION, code: ErrorCode.NO_PERMISSION,
message: '不能踢出组长', message: "不能踢出组长",
}); });
} }
await this.groupMemberRepository.remove(member); await this.groupMemberRepository.remove(member);
// 更新小组成员数 // 更新小组成员数
const group = await this.groupRepository.findOne({ where: { id: groupId } }); const group = await this.groupRepository.findOne({
where: { id: groupId },
});
if (group) { if (group) {
group.currentMembers = Math.max(0, group.currentMembers - 1); group.currentMembers = Math.max(0, group.currentMembers - 1);
await this.groupRepository.save(group); await this.groupRepository.save(group);
} }
return { message: '成员已移除' }; return { message: "成员已移除" };
} }
/** /**
* 解散小组 * 解散小组
*/ */
async disband(userId: string, groupId: string) { async disband(userId: string, groupId: string) {
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({
@@ -402,14 +419,14 @@ export class GroupsService {
if (group.ownerId !== userId) { if (group.ownerId !== userId) {
throw new ForbiddenException({ throw new ForbiddenException({
code: ErrorCode.NO_PERMISSION, code: ErrorCode.NO_PERMISSION,
message: '只有组长可以解散小组', message: "只有组长可以解散小组",
}); });
} }
group.isActive = false; group.isActive = false;
await this.groupRepository.save(group); await this.groupRepository.save(group);
return { message: '小组已解散' }; return { message: "小组已解散" };
} }
/** /**

View File

@@ -5,67 +5,67 @@ import {
IsArray, IsArray,
IsDateString, IsDateString,
MaxLength, MaxLength,
} from 'class-validator'; } from "class-validator";
import { ApiProperty } from '@nestjs/swagger'; import { ApiProperty } from "@nestjs/swagger";
export class CreateHonorDto { export class CreateHonorDto {
@ApiProperty({ description: '小组ID' }) @ApiProperty({ description: "小组ID" })
@IsString() @IsString()
@IsNotEmpty({ message: '小组ID不能为空' }) @IsNotEmpty({ message: "小组ID不能为空" })
groupId: string; groupId: string;
@ApiProperty({ description: '荣誉标题', example: '首次五连胜' }) @ApiProperty({ description: "荣誉标题", example: "首次五连胜" })
@IsString() @IsString()
@IsNotEmpty({ message: '标题不能为空' }) @IsNotEmpty({ message: "标题不能为空" })
@MaxLength(100) @MaxLength(100)
title: string; title: string;
@ApiProperty({ description: '荣誉描述', required: false }) @ApiProperty({ description: "荣誉描述", required: false })
@IsString() @IsString()
@IsOptional() @IsOptional()
description?: string; description?: string;
@ApiProperty({ description: '媒体文件URL列表图片/视频)', required: false }) @ApiProperty({ description: "媒体文件URL列表图片/视频)", required: false })
@IsArray() @IsArray()
@IsOptional() @IsOptional()
mediaUrls?: string[]; mediaUrls?: string[];
@ApiProperty({ description: '荣誉获得日期', required: false }) @ApiProperty({ description: "荣誉获得日期", required: false })
@IsDateString() @IsDateString()
@IsOptional() @IsOptional()
achievedDate?: Date; achievedDate?: Date;
} }
export class UpdateHonorDto { export class UpdateHonorDto {
@ApiProperty({ description: '荣誉标题', required: false }) @ApiProperty({ description: "荣誉标题", required: false })
@IsString() @IsString()
@IsOptional() @IsOptional()
@MaxLength(100) @MaxLength(100)
title?: string; title?: string;
@ApiProperty({ description: '荣誉描述', required: false }) @ApiProperty({ description: "荣誉描述", required: false })
@IsString() @IsString()
@IsOptional() @IsOptional()
description?: string; description?: string;
@ApiProperty({ description: '媒体文件URL列表', required: false }) @ApiProperty({ description: "媒体文件URL列表", required: false })
@IsArray() @IsArray()
@IsOptional() @IsOptional()
mediaUrls?: string[]; mediaUrls?: string[];
@ApiProperty({ description: '事件日期', required: false }) @ApiProperty({ description: "事件日期", required: false })
@IsDateString() @IsDateString()
@IsOptional() @IsOptional()
eventDate?: Date; eventDate?: Date;
} }
export class QueryHonorsDto { export class QueryHonorsDto {
@ApiProperty({ description: '小组ID', required: false }) @ApiProperty({ description: "小组ID", required: false })
@IsString() @IsString()
@IsOptional() @IsOptional()
groupId?: string; groupId?: string;
@ApiProperty({ description: '年份筛选', required: false, example: 2024 }) @ApiProperty({ description: "年份筛选", required: false, example: 2024 })
@IsOptional() @IsOptional()
year?: number; year?: number;
} }

View File

@@ -8,57 +8,61 @@ import {
Delete, Delete,
Query, Query,
UseGuards, UseGuards,
} from '@nestjs/common'; } from "@nestjs/common";
import { ApiTags, ApiOperation, ApiBearerAuth } from '@nestjs/swagger'; import { ApiTags, ApiOperation, ApiBearerAuth } from "@nestjs/swagger";
import { HonorsService } from './honors.service'; import { HonorsService } from "./honors.service";
import { CreateHonorDto, UpdateHonorDto, QueryHonorsDto } from './dto/honor.dto'; import {
import { JwtAuthGuard } from '../../common/guards/jwt-auth.guard'; CreateHonorDto,
import { CurrentUser } from '../../common/decorators/current-user.decorator'; UpdateHonorDto,
QueryHonorsDto,
} from "./dto/honor.dto";
import { JwtAuthGuard } from "../../common/guards/jwt-auth.guard";
import { CurrentUser } from "../../common/decorators/current-user.decorator";
@ApiTags('honors') @ApiTags("honors")
@Controller('honors') @Controller("honors")
@UseGuards(JwtAuthGuard) @UseGuards(JwtAuthGuard)
@ApiBearerAuth() @ApiBearerAuth()
export class HonorsController { export class HonorsController {
constructor(private readonly honorsService: HonorsService) {} constructor(private readonly honorsService: HonorsService) {}
@Post() @Post()
@ApiOperation({ summary: '创建荣誉记录' }) @ApiOperation({ summary: "创建荣誉记录" })
create(@CurrentUser() user, @Body() createDto: CreateHonorDto) { create(@CurrentUser() user, @Body() createDto: CreateHonorDto) {
return this.honorsService.create(user.id, createDto); return this.honorsService.create(user.id, createDto);
} }
@Get() @Get()
@ApiOperation({ summary: '查询荣誉列表' }) @ApiOperation({ summary: "查询荣誉列表" })
findAll(@Query() query: QueryHonorsDto) { findAll(@Query() query: QueryHonorsDto) {
return this.honorsService.findAll(query); return this.honorsService.findAll(query);
} }
@Get('timeline/:groupId') @Get("timeline/:groupId")
@ApiOperation({ summary: '获取小组荣誉时间轴' }) @ApiOperation({ summary: "获取小组荣誉时间轴" })
getTimeline(@Param('groupId') groupId: string) { getTimeline(@Param("groupId") groupId: string) {
return this.honorsService.getTimeline(groupId); return this.honorsService.getTimeline(groupId);
} }
@Get(':id') @Get(":id")
@ApiOperation({ summary: '查询单个荣誉记录' }) @ApiOperation({ summary: "查询单个荣誉记录" })
findOne(@Param('id') id: string) { findOne(@Param("id") id: string) {
return this.honorsService.findOne(id); return this.honorsService.findOne(id);
} }
@Patch(':id') @Patch(":id")
@ApiOperation({ summary: '更新荣誉记录' }) @ApiOperation({ summary: "更新荣誉记录" })
update( update(
@CurrentUser() user, @CurrentUser() user,
@Param('id') id: string, @Param("id") id: string,
@Body() updateDto: UpdateHonorDto, @Body() updateDto: UpdateHonorDto,
) { ) {
return this.honorsService.update(user.id, id, updateDto); return this.honorsService.update(user.id, id, updateDto);
} }
@Delete(':id') @Delete(":id")
@ApiOperation({ summary: '删除荣誉记录' }) @ApiOperation({ summary: "删除荣誉记录" })
remove(@CurrentUser() user, @Param('id') id: string) { remove(@CurrentUser() user, @Param("id") id: string) {
return this.honorsService.remove(user.id, id); return this.honorsService.remove(user.id, id);
} }
} }

View File

@@ -1,10 +1,10 @@
import { Module } from '@nestjs/common'; import { Module } from "@nestjs/common";
import { TypeOrmModule } from '@nestjs/typeorm'; import { TypeOrmModule } from "@nestjs/typeorm";
import { HonorsController } from './honors.controller'; import { HonorsController } from "./honors.controller";
import { HonorsService } from './honors.service'; import { HonorsService } from "./honors.service";
import { Honor } from '../../entities/honor.entity'; import { Honor } from "../../entities/honor.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";
@Module({ @Module({
imports: [TypeOrmModule.forFeature([Honor, Group, GroupMember])], imports: [TypeOrmModule.forFeature([Honor, Group, GroupMember])],

View File

@@ -1,40 +1,40 @@
import { Test, TestingModule } from '@nestjs/testing'; import { Test, TestingModule } from "@nestjs/testing";
import { getRepositoryToken } from '@nestjs/typeorm'; import { getRepositoryToken } from "@nestjs/typeorm";
import { Repository } from 'typeorm'; import { Repository } from "typeorm";
import { HonorsService } from './honors.service'; import { HonorsService } from "./honors.service";
import { Honor } from '../../entities/honor.entity'; import { Honor } from "../../entities/honor.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 { GroupMemberRole } from '../../common/enums'; import { GroupMemberRole } from "../../common/enums";
import { NotFoundException, ForbiddenException } from '@nestjs/common'; import { NotFoundException, ForbiddenException } from "@nestjs/common";
describe('HonorsService', () => { describe("HonorsService", () => {
let service: HonorsService; let service: HonorsService;
let honorRepository: Repository<Honor>; let honorRepository: Repository<Honor>;
let groupRepository: Repository<Group>; let groupRepository: Repository<Group>;
let groupMemberRepository: Repository<GroupMember>; let groupMemberRepository: Repository<GroupMember>;
const mockHonor = { const mockHonor = {
id: 'honor-1', id: "honor-1",
groupId: 'group-1', groupId: "group-1",
title: '冠军荣誉', title: "冠军荣誉",
description: '获得比赛冠军', description: "获得比赛冠军",
eventDate: new Date('2025-01-01'), eventDate: new Date("2025-01-01"),
media: ['image1.jpg'], media: ["image1.jpg"],
createdBy: 'user-1', createdBy: "user-1",
createdAt: new Date(), createdAt: new Date(),
}; };
const mockGroup = { const mockGroup = {
id: 'group-1', id: "group-1",
name: '测试小组', name: "测试小组",
ownerId: 'user-1', ownerId: "user-1",
}; };
const mockGroupMember = { const mockGroupMember = {
id: 'member-1', id: "member-1",
userId: 'user-1', userId: "user-1",
groupId: 'group-1', groupId: "group-1",
role: GroupMemberRole.ADMIN, role: GroupMemberRole.ADMIN,
}; };
@@ -78,236 +78,286 @@ describe('HonorsService', () => {
service = module.get<HonorsService>(HonorsService); service = module.get<HonorsService>(HonorsService);
honorRepository = module.get<Repository<Honor>>(getRepositoryToken(Honor)); honorRepository = module.get<Repository<Honor>>(getRepositoryToken(Honor));
groupRepository = module.get<Repository<Group>>(getRepositoryToken(Group)); groupRepository = module.get<Repository<Group>>(getRepositoryToken(Group));
groupMemberRepository = module.get<Repository<GroupMember>>(getRepositoryToken(GroupMember)); groupMemberRepository = module.get<Repository<GroupMember>>(
getRepositoryToken(GroupMember),
);
}); });
it('should be defined', () => { it("should be defined", () => {
expect(service).toBeDefined(); expect(service).toBeDefined();
}); });
describe('create', () => { describe("create", () => {
it('应该成功创建荣誉记录(管理员)', async () => { it("应该成功创建荣誉记录(管理员)", async () => {
const createDto = { const createDto = {
groupId: 'group-1', groupId: "group-1",
title: '冠军荣誉', title: "冠军荣誉",
description: '获得比赛冠军', description: "获得比赛冠军",
eventDate: new Date('2025-01-01'), eventDate: new Date("2025-01-01"),
media: ['image1.jpg'], media: ["image1.jpg"],
}; };
jest.spyOn(groupRepository, 'findOne').mockResolvedValue(mockGroup as any); jest
jest.spyOn(groupMemberRepository, 'findOne').mockResolvedValue(mockGroupMember as any); .spyOn(groupRepository, "findOne")
jest.spyOn(honorRepository, 'create').mockReturnValue(mockHonor as any); .mockResolvedValue(mockGroup as any);
jest.spyOn(honorRepository, 'save').mockResolvedValue(mockHonor as any); jest
jest.spyOn(honorRepository, 'findOne').mockResolvedValue(mockHonor as any); .spyOn(groupMemberRepository, "findOne")
.mockResolvedValue(mockGroupMember as any);
jest.spyOn(honorRepository, "create").mockReturnValue(mockHonor as any);
jest.spyOn(honorRepository, "save").mockResolvedValue(mockHonor as any);
jest
.spyOn(honorRepository, "findOne")
.mockResolvedValue(mockHonor as any);
const result = await service.create('user-1', createDto); const result = await service.create("user-1", createDto);
expect(result).toBeDefined(); expect(result).toBeDefined();
expect(honorRepository.save).toHaveBeenCalled(); expect(honorRepository.save).toHaveBeenCalled();
}); });
it('小组不存在时应该抛出异常', async () => { it("小组不存在时应该抛出异常", async () => {
const createDto = { const createDto = {
groupId: 'group-1', groupId: "group-1",
title: '冠军荣誉', title: "冠军荣誉",
eventDate: new Date('2025-01-01'), eventDate: new Date("2025-01-01"),
}; };
jest.spyOn(groupRepository, 'findOne').mockResolvedValue(null); jest.spyOn(groupRepository, "findOne").mockResolvedValue(null);
await expect(service.create('user-1', createDto)).rejects.toThrow(NotFoundException); await expect(service.create("user-1", createDto)).rejects.toThrow(
NotFoundException,
);
}); });
it('非管理员创建时应该抛出异常', async () => { it("非管理员创建时应该抛出异常", async () => {
const createDto = { const createDto = {
groupId: 'group-1', groupId: "group-1",
title: '冠军荣誉', title: "冠军荣誉",
eventDate: new Date('2025-01-01'), eventDate: new Date("2025-01-01"),
}; };
jest.spyOn(groupRepository, 'findOne').mockResolvedValue(mockGroup as any); jest
jest.spyOn(groupMemberRepository, 'findOne').mockResolvedValue({ .spyOn(groupRepository, "findOne")
.mockResolvedValue(mockGroup as any);
jest.spyOn(groupMemberRepository, "findOne").mockResolvedValue({
...mockGroupMember, ...mockGroupMember,
role: GroupMemberRole.MEMBER, role: GroupMemberRole.MEMBER,
} as any); } as any);
await expect(service.create('user-1', createDto)).rejects.toThrow(ForbiddenException); await expect(service.create("user-1", createDto)).rejects.toThrow(
ForbiddenException,
);
}); });
it('组长应该可以创建荣誉记录', async () => { it("组长应该可以创建荣誉记录", async () => {
const createDto = { const createDto = {
groupId: 'group-1', groupId: "group-1",
title: '冠军荣誉', title: "冠军荣誉",
eventDate: new Date('2025-01-01'), eventDate: new Date("2025-01-01"),
}; };
jest.spyOn(groupRepository, 'findOne').mockResolvedValue(mockGroup as any); jest
jest.spyOn(groupMemberRepository, 'findOne').mockResolvedValue({ .spyOn(groupRepository, "findOne")
.mockResolvedValue(mockGroup as any);
jest.spyOn(groupMemberRepository, "findOne").mockResolvedValue({
...mockGroupMember, ...mockGroupMember,
role: GroupMemberRole.OWNER, role: GroupMemberRole.OWNER,
} as any); } as any);
jest.spyOn(honorRepository, 'create').mockReturnValue(mockHonor as any); jest.spyOn(honorRepository, "create").mockReturnValue(mockHonor as any);
jest.spyOn(honorRepository, 'save').mockResolvedValue(mockHonor as any); jest.spyOn(honorRepository, "save").mockResolvedValue(mockHonor as any);
jest.spyOn(honorRepository, 'findOne').mockResolvedValue(mockHonor as any); jest
.spyOn(honorRepository, "findOne")
.mockResolvedValue(mockHonor as any);
const result = await service.create('user-1', createDto); const result = await service.create("user-1", createDto);
expect(result).toBeDefined(); expect(result).toBeDefined();
}); });
}); });
describe('findAll', () => { describe("findAll", () => {
it('应该返回荣誉列表', async () => { it("应该返回荣誉列表", async () => {
mockQueryBuilder.getMany.mockResolvedValue([mockHonor]); mockQueryBuilder.getMany.mockResolvedValue([mockHonor]);
const result = await service.findAll({ groupId: 'group-1' }); const result = await service.findAll({ groupId: "group-1" });
expect(result).toHaveLength(1); expect(result).toHaveLength(1);
expect(honorRepository.createQueryBuilder).toHaveBeenCalled(); expect(honorRepository.createQueryBuilder).toHaveBeenCalled();
}); });
}); });
describe('getTimeline', () => { describe("getTimeline", () => {
it('应该返回按年份分组的时间轴', async () => { it("应该返回按年份分组的时间轴", async () => {
const mockHonors = [ const mockHonors = [
{ ...mockHonor, eventDate: new Date('2025-01-01') }, { ...mockHonor, eventDate: new Date("2025-01-01") },
{ ...mockHonor, id: 'honor-2', eventDate: new Date('2024-06-01') }, { ...mockHonor, id: "honor-2", eventDate: new Date("2024-06-01") },
]; ];
jest.spyOn(honorRepository, 'find').mockResolvedValue(mockHonors as any); jest.spyOn(honorRepository, "find").mockResolvedValue(mockHonors as any);
const result = await service.getTimeline('group-1'); const result = await service.getTimeline("group-1");
expect(result).toBeDefined(); expect(result).toBeDefined();
expect(result[2025]).toHaveLength(1); expect(result[2025]).toHaveLength(1);
expect(result[2024]).toHaveLength(1); expect(result[2024]).toHaveLength(1);
}); });
it('空荣誉列表应该返回空对象', async () => { it("空荣誉列表应该返回空对象", async () => {
jest.spyOn(honorRepository, 'find').mockResolvedValue([]); jest.spyOn(honorRepository, "find").mockResolvedValue([]);
const result = await service.getTimeline('group-1'); const result = await service.getTimeline("group-1");
expect(result).toEqual({}); expect(result).toEqual({});
}); });
}); });
describe('findOne', () => { describe("findOne", () => {
it('应该返回单个荣誉记录', async () => { it("应该返回单个荣誉记录", async () => {
jest.spyOn(honorRepository, 'findOne').mockResolvedValue(mockHonor as any); jest
.spyOn(honorRepository, "findOne")
.mockResolvedValue(mockHonor as any);
const result = await service.findOne('honor-1'); const result = await service.findOne("honor-1");
expect(result).toBeDefined(); expect(result).toBeDefined();
expect(result.id).toBe('honor-1'); expect(result.id).toBe("honor-1");
}); });
it('记录不存在时应该抛出异常', async () => { it("记录不存在时应该抛出异常", async () => {
jest.spyOn(honorRepository, 'findOne').mockResolvedValue(null); jest.spyOn(honorRepository, "findOne").mockResolvedValue(null);
await expect(service.findOne('non-existent')).rejects.toThrow(NotFoundException); await expect(service.findOne("non-existent")).rejects.toThrow(
NotFoundException,
);
}); });
}); });
describe('update', () => { describe("update", () => {
it('创建者应该可以更新荣誉记录', async () => { it("创建者应该可以更新荣誉记录", async () => {
const updateDto = { const updateDto = {
title: '更新后的标题', title: "更新后的标题",
}; };
jest.spyOn(honorRepository, 'findOne').mockResolvedValue(mockHonor as any); jest
jest.spyOn(groupRepository, 'findOne').mockResolvedValue(mockGroup as any); .spyOn(honorRepository, "findOne")
jest.spyOn(groupMemberRepository, 'findOne').mockResolvedValue(mockGroupMember as any); .mockResolvedValue(mockHonor as any);
jest.spyOn(honorRepository, 'save').mockResolvedValue({ jest
.spyOn(groupRepository, "findOne")
.mockResolvedValue(mockGroup as any);
jest
.spyOn(groupMemberRepository, "findOne")
.mockResolvedValue(mockGroupMember as any);
jest.spyOn(honorRepository, "save").mockResolvedValue({
...mockHonor, ...mockHonor,
...updateDto, ...updateDto,
} as any); } as any);
const result = await service.update('user-1', 'honor-1', updateDto); const result = await service.update("user-1", "honor-1", updateDto);
expect(result.title).toBe('更新后的标题'); expect(result.title).toBe("更新后的标题");
}); });
it('管理员应该可以更新任何荣誉记录', async () => { it("管理员应该可以更新任何荣誉记录", async () => {
const updateDto = { const updateDto = {
title: '更新后的标题', title: "更新后的标题",
}; };
jest.spyOn(honorRepository, 'findOne').mockResolvedValue({ jest.spyOn(honorRepository, "findOne").mockResolvedValue({
...mockHonor, ...mockHonor,
createdBy: 'other-user', createdBy: "other-user",
} as any); } as any);
jest.spyOn(groupRepository, 'findOne').mockResolvedValue(mockGroup as any); jest
jest.spyOn(groupMemberRepository, 'findOne').mockResolvedValue(mockGroupMember as any); .spyOn(groupRepository, "findOne")
jest.spyOn(honorRepository, 'save').mockResolvedValue({ .mockResolvedValue(mockGroup as any);
jest
.spyOn(groupMemberRepository, "findOne")
.mockResolvedValue(mockGroupMember as any);
jest.spyOn(honorRepository, "save").mockResolvedValue({
...mockHonor, ...mockHonor,
...updateDto, ...updateDto,
} as any); } as any);
const result = await service.update('user-1', 'honor-1', updateDto); const result = await service.update("user-1", "honor-1", updateDto);
expect(result).toBeDefined(); expect(result).toBeDefined();
}); });
it('无权限时应该抛出异常', async () => { it("无权限时应该抛出异常", async () => {
const updateDto = { const updateDto = {
title: '更新后的标题', title: "更新后的标题",
}; };
jest.spyOn(honorRepository, 'findOne').mockResolvedValue({ jest.spyOn(honorRepository, "findOne").mockResolvedValue({
...mockHonor, ...mockHonor,
createdBy: 'other-user', createdBy: "other-user",
} as any); } as any);
jest.spyOn(groupRepository, 'findOne').mockResolvedValue(mockGroup as any); jest
jest.spyOn(groupMemberRepository, 'findOne').mockResolvedValue({ .spyOn(groupRepository, "findOne")
.mockResolvedValue(mockGroup as any);
jest.spyOn(groupMemberRepository, "findOne").mockResolvedValue({
...mockGroupMember, ...mockGroupMember,
role: GroupMemberRole.MEMBER, role: GroupMemberRole.MEMBER,
} as any); } as any);
await expect(service.update('user-1', 'honor-1', updateDto)).rejects.toThrow(ForbiddenException); await expect(
service.update("user-1", "honor-1", updateDto),
).rejects.toThrow(ForbiddenException);
}); });
}); });
describe('remove', () => { describe("remove", () => {
it('创建者应该可以删除自己的荣誉记录', async () => { it("创建者应该可以删除自己的荣誉记录", async () => {
jest.spyOn(honorRepository, 'findOne').mockResolvedValue(mockHonor as any); jest
jest.spyOn(groupRepository, 'findOne').mockResolvedValue(mockGroup as any); .spyOn(honorRepository, "findOne")
jest.spyOn(groupMemberRepository, 'findOne').mockResolvedValue(mockGroupMember as any); .mockResolvedValue(mockHonor as any);
jest.spyOn(honorRepository, 'remove').mockResolvedValue(mockHonor as any); jest
.spyOn(groupRepository, "findOne")
.mockResolvedValue(mockGroup as any);
jest
.spyOn(groupMemberRepository, "findOne")
.mockResolvedValue(mockGroupMember as any);
jest.spyOn(honorRepository, "remove").mockResolvedValue(mockHonor as any);
const result = await service.remove('user-1', 'honor-1'); const result = await service.remove("user-1", "honor-1");
expect(result.message).toBe('删除成功'); expect(result.message).toBe("删除成功");
expect(honorRepository.remove).toHaveBeenCalled(); expect(honorRepository.remove).toHaveBeenCalled();
}); });
it('管理员应该可以删除任何荣誉记录', async () => { it("管理员应该可以删除任何荣誉记录", async () => {
jest.spyOn(honorRepository, 'findOne').mockResolvedValue({ jest.spyOn(honorRepository, "findOne").mockResolvedValue({
...mockHonor, ...mockHonor,
createdBy: 'other-user', createdBy: "other-user",
} as any); } as any);
jest.spyOn(groupRepository, 'findOne').mockResolvedValue(mockGroup as any); jest
jest.spyOn(groupMemberRepository, 'findOne').mockResolvedValue(mockGroupMember as any); .spyOn(groupRepository, "findOne")
jest.spyOn(honorRepository, 'remove').mockResolvedValue(mockHonor as any); .mockResolvedValue(mockGroup as any);
jest
.spyOn(groupMemberRepository, "findOne")
.mockResolvedValue(mockGroupMember as any);
jest.spyOn(honorRepository, "remove").mockResolvedValue(mockHonor as any);
const result = await service.remove('user-1', 'honor-1'); const result = await service.remove("user-1", "honor-1");
expect(result.message).toBe('删除成功'); expect(result.message).toBe("删除成功");
}); });
it('无权限时应该抛出异常', async () => { it("无权限时应该抛出异常", async () => {
jest.spyOn(honorRepository, 'findOne').mockResolvedValue({ jest.spyOn(honorRepository, "findOne").mockResolvedValue({
...mockHonor, ...mockHonor,
createdBy: 'other-user', createdBy: "other-user",
} as any); } as any);
jest.spyOn(groupRepository, 'findOne').mockResolvedValue(mockGroup as any); jest
jest.spyOn(groupMemberRepository, 'findOne').mockResolvedValue({ .spyOn(groupRepository, "findOne")
.mockResolvedValue(mockGroup as any);
jest.spyOn(groupMemberRepository, "findOne").mockResolvedValue({
...mockGroupMember, ...mockGroupMember,
role: GroupMemberRole.MEMBER, role: GroupMemberRole.MEMBER,
} as any); } as any);
await expect(service.remove('user-1', 'honor-1')).rejects.toThrow(ForbiddenException); await expect(service.remove("user-1", "honor-1")).rejects.toThrow(
ForbiddenException,
);
}); });
}); });
}); });

View File

@@ -2,18 +2,22 @@ import {
Injectable, Injectable,
NotFoundException, NotFoundException,
ForbiddenException, ForbiddenException,
} from '@nestjs/common'; } from "@nestjs/common";
import { InjectRepository } from '@nestjs/typeorm'; import { InjectRepository } from "@nestjs/typeorm";
import { Repository, Between } from 'typeorm'; import { Repository, Between } from "typeorm";
import { Honor } from '../../entities/honor.entity'; import { Honor } from "../../entities/honor.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 { CreateHonorDto, UpdateHonorDto, QueryHonorsDto } from './dto/honor.dto'; import {
import { GroupMemberRole } from '../../common/enums'; CreateHonorDto,
UpdateHonorDto,
QueryHonorsDto,
} from "./dto/honor.dto";
import { GroupMemberRole } from "../../common/enums";
import { import {
ErrorCode, ErrorCode,
ErrorMessage, ErrorMessage,
} from '../../common/interfaces/response.interface'; } from "../../common/interfaces/response.interface";
@Injectable() @Injectable()
export class HonorsService { export class HonorsService {
@@ -33,7 +37,9 @@ export class HonorsService {
const { groupId, ...rest } = createDto; const { groupId, ...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,
@@ -53,7 +59,7 @@ export class HonorsService {
) { ) {
throw new ForbiddenException({ throw new ForbiddenException({
code: ErrorCode.NO_PERMISSION, code: ErrorCode.NO_PERMISSION,
message: '需要管理员权限', message: "需要管理员权限",
}); });
} }
@@ -73,24 +79,24 @@ export class HonorsService {
*/ */
async findAll(query: QueryHonorsDto) { async findAll(query: QueryHonorsDto) {
const qb = this.honorRepository const qb = this.honorRepository
.createQueryBuilder('honor') .createQueryBuilder("honor")
.leftJoinAndSelect('honor.group', 'group') .leftJoinAndSelect("honor.group", "group")
.leftJoinAndSelect('honor.creator', 'creator'); .leftJoinAndSelect("honor.creator", "creator");
if (query.groupId) { if (query.groupId) {
qb.andWhere('honor.groupId = :groupId', { groupId: query.groupId }); qb.andWhere("honor.groupId = :groupId", { groupId: query.groupId });
} }
if (query.year) { if (query.year) {
const startDate = new Date(`${query.year}-01-01`); const startDate = new Date(`${query.year}-01-01`);
const endDate = new Date(`${query.year}-12-31`); const endDate = new Date(`${query.year}-12-31`);
qb.andWhere('honor.eventDate BETWEEN :startDate AND :endDate', { qb.andWhere("honor.eventDate BETWEEN :startDate AND :endDate", {
startDate, startDate,
endDate, endDate,
}); });
} }
qb.orderBy('honor.eventDate', 'DESC'); qb.orderBy("honor.eventDate", "DESC");
const honors = await qb.getMany(); const honors = await qb.getMany();
@@ -103,8 +109,8 @@ export class HonorsService {
async getTimeline(groupId: string) { async getTimeline(groupId: string) {
const honors = await this.honorRepository.find({ const honors = await this.honorRepository.find({
where: { groupId }, where: { groupId },
relations: ['creator'], relations: ["creator"],
order: { eventDate: 'DESC' }, order: { eventDate: "DESC" },
}); });
// 按年份分组 // 按年份分组
@@ -126,13 +132,13 @@ export class HonorsService {
async findOne(id: string) { async findOne(id: string) {
const honor = await this.honorRepository.findOne({ const honor = await this.honorRepository.findOne({
where: { id }, where: { id },
relations: ['group', 'creator'], relations: ["group", "creator"],
}); });
if (!honor) { if (!honor) {
throw new NotFoundException({ throw new NotFoundException({
code: ErrorCode.HONOR_NOT_FOUND, code: ErrorCode.HONOR_NOT_FOUND,
message: '荣誉记录不存在', message: "荣誉记录不存在",
}); });
} }
@@ -193,6 +199,6 @@ export class HonorsService {
await this.honorRepository.remove(honor); await this.honorRepository.remove(honor);
return { message: '删除成功' }; return { message: "删除成功" };
} }
} }

View File

@@ -6,116 +6,116 @@ import {
Min, Min,
IsDateString, IsDateString,
IsEnum, IsEnum,
} from 'class-validator'; } from "class-validator";
import { ApiProperty } from '@nestjs/swagger'; import { ApiProperty } from "@nestjs/swagger";
import { Type } from 'class-transformer'; import { Type } from "class-transformer";
import { LedgerType } from '../../../common/enums'; import { LedgerType } from "../../../common/enums";
export class CreateLedgerDto { export class CreateLedgerDto {
@ApiProperty({ description: '小组ID' }) @ApiProperty({ description: "小组ID" })
@IsString() @IsString()
@IsNotEmpty({ message: '小组ID不能为空' }) @IsNotEmpty({ message: "小组ID不能为空" })
groupId: string; groupId: string;
@ApiProperty({ description: '账目类型', enum: LedgerType }) @ApiProperty({ description: "账目类型", enum: LedgerType })
@IsEnum(LedgerType) @IsEnum(LedgerType)
type: LedgerType; type: LedgerType;
@ApiProperty({ description: '金额', example: 100.5 }) @ApiProperty({ description: "金额", example: 100.5 })
@IsNumber() @IsNumber()
@Min(0) @Min(0)
@Type(() => Number) @Type(() => Number)
amount: number; amount: number;
@ApiProperty({ description: '账目描述' }) @ApiProperty({ description: "账目描述" })
@IsString() @IsString()
@IsNotEmpty({ message: '账目描述不能为空' }) @IsNotEmpty({ message: "账目描述不能为空" })
description: string; description: string;
@ApiProperty({ description: '分类', required: false }) @ApiProperty({ description: "分类", required: false })
@IsString() @IsString()
@IsOptional() @IsOptional()
category?: string; category?: string;
@ApiProperty({ description: '账目日期', required: false }) @ApiProperty({ description: "账目日期", required: false })
@IsDateString() @IsDateString()
@IsOptional() @IsOptional()
date?: Date; date?: Date;
@ApiProperty({ description: '备注', required: false }) @ApiProperty({ description: "备注", required: false })
@IsString() @IsString()
@IsOptional() @IsOptional()
notes?: string; notes?: string;
} }
export class UpdateLedgerDto { export class UpdateLedgerDto {
@ApiProperty({ description: '账目类型', enum: LedgerType, required: false }) @ApiProperty({ description: "账目类型", enum: LedgerType, required: false })
@IsEnum(LedgerType) @IsEnum(LedgerType)
@IsOptional() @IsOptional()
type?: LedgerType; type?: LedgerType;
@ApiProperty({ description: '金额', required: false }) @ApiProperty({ description: "金额", required: false })
@IsNumber() @IsNumber()
@Min(0) @Min(0)
@IsOptional() @IsOptional()
@Type(() => Number) @Type(() => Number)
amount?: number; amount?: number;
@ApiProperty({ description: '账目描述', required: false }) @ApiProperty({ description: "账目描述", required: false })
@IsString() @IsString()
@IsOptional() @IsOptional()
description?: string; description?: string;
@ApiProperty({ description: '分类', required: false }) @ApiProperty({ description: "分类", required: false })
@IsString() @IsString()
@IsOptional() @IsOptional()
category?: string; category?: string;
@ApiProperty({ description: '账目日期', required: false }) @ApiProperty({ description: "账目日期", required: false })
@IsDateString() @IsDateString()
@IsOptional() @IsOptional()
date?: Date; date?: Date;
@ApiProperty({ description: '备注', required: false }) @ApiProperty({ description: "备注", required: false })
@IsString() @IsString()
@IsOptional() @IsOptional()
notes?: string; notes?: string;
} }
export class QueryLedgersDto { export class QueryLedgersDto {
@ApiProperty({ description: '小组ID', required: false }) @ApiProperty({ description: "小组ID", required: false })
@IsString() @IsString()
@IsOptional() @IsOptional()
groupId?: string; groupId?: string;
@ApiProperty({ description: '账目类型', enum: LedgerType, required: false }) @ApiProperty({ description: "账目类型", enum: LedgerType, required: false })
@IsEnum(LedgerType) @IsEnum(LedgerType)
@IsOptional() @IsOptional()
type?: LedgerType; type?: LedgerType;
@ApiProperty({ description: '分类', required: false }) @ApiProperty({ description: "分类", required: false })
@IsString() @IsString()
@IsOptional() @IsOptional()
category?: string; category?: string;
@ApiProperty({ description: '开始日期', required: false }) @ApiProperty({ description: "开始日期", required: false })
@IsDateString() @IsDateString()
@IsOptional() @IsOptional()
startDate?: Date; startDate?: Date;
@ApiProperty({ description: '结束日期', required: false }) @ApiProperty({ description: "结束日期", required: false })
@IsDateString() @IsDateString()
@IsOptional() @IsOptional()
endDate?: Date; endDate?: Date;
@ApiProperty({ description: '页码', example: 1, required: false }) @ApiProperty({ description: "页码", example: 1, required: false })
@IsNumber() @IsNumber()
@Min(1) @Min(1)
@IsOptional() @IsOptional()
@Type(() => Number) @Type(() => Number)
page?: number; page?: number;
@ApiProperty({ description: '每页数量', example: 10, required: false }) @ApiProperty({ description: "每页数量", example: 10, required: false })
@IsNumber() @IsNumber()
@Min(1) @Min(1)
@IsOptional() @IsOptional()
@@ -124,18 +124,18 @@ export class QueryLedgersDto {
} }
export class MonthlyStatisticsDto { export class MonthlyStatisticsDto {
@ApiProperty({ description: '小组ID' }) @ApiProperty({ description: "小组ID" })
@IsString() @IsString()
@IsNotEmpty({ message: '小组ID不能为空' }) @IsNotEmpty({ message: "小组ID不能为空" })
groupId: string; groupId: string;
@ApiProperty({ description: '年份', example: 2024 }) @ApiProperty({ description: "年份", example: 2024 })
@IsNumber() @IsNumber()
@Min(2000) @Min(2000)
@Type(() => Number) @Type(() => Number)
year: number; year: number;
@ApiProperty({ description: '月份', example: 1 }) @ApiProperty({ description: "月份", example: 1 })
@IsNumber() @IsNumber()
@Min(1) @Min(1)
@Type(() => Number) @Type(() => Number)

View File

@@ -8,103 +8,100 @@ import {
Param, Param,
Query, Query,
UseGuards, UseGuards,
} from '@nestjs/common'; } from "@nestjs/common";
import { import {
ApiTags, ApiTags,
ApiOperation, ApiOperation,
ApiResponse, ApiResponse,
ApiBearerAuth, ApiBearerAuth,
ApiQuery, ApiQuery,
} from '@nestjs/swagger'; } from "@nestjs/swagger";
import { LedgersService } from './ledgers.service'; import { LedgersService } from "./ledgers.service";
import { import {
CreateLedgerDto, CreateLedgerDto,
UpdateLedgerDto, UpdateLedgerDto,
QueryLedgersDto, QueryLedgersDto,
MonthlyStatisticsDto, MonthlyStatisticsDto,
} from './dto/ledger.dto'; } from "./dto/ledger.dto";
import { JwtAuthGuard } from '../../common/guards/jwt-auth.guard'; import { JwtAuthGuard } from "../../common/guards/jwt-auth.guard";
import { CurrentUser } from '../../common/decorators/current-user.decorator'; import { CurrentUser } from "../../common/decorators/current-user.decorator";
@ApiTags('ledgers') @ApiTags("ledgers")
@ApiBearerAuth() @ApiBearerAuth()
@UseGuards(JwtAuthGuard) @UseGuards(JwtAuthGuard)
@Controller('ledgers') @Controller("ledgers")
export class LedgersController { export class LedgersController {
constructor(private readonly ledgersService: LedgersService) {} constructor(private readonly ledgersService: LedgersService) {}
@Post() @Post()
@ApiOperation({ summary: '创建账目' }) @ApiOperation({ summary: "创建账目" })
@ApiResponse({ status: 201, description: '创建成功' }) @ApiResponse({ status: 201, description: "创建成功" })
async create( async create(
@CurrentUser('id') userId: string, @CurrentUser("id") userId: string,
@Body() createDto: CreateLedgerDto, @Body() createDto: CreateLedgerDto,
) { ) {
return this.ledgersService.create(userId, createDto); return this.ledgersService.create(userId, createDto);
} }
@Get() @Get()
@ApiOperation({ summary: '获取账目列表' }) @ApiOperation({ summary: "获取账目列表" })
@ApiResponse({ status: 200, description: '获取成功' }) @ApiResponse({ status: 200, description: "获取成功" })
@ApiQuery({ name: 'groupId', required: false, description: '小组ID' }) @ApiQuery({ name: "groupId", required: false, description: "小组ID" })
@ApiQuery({ name: 'type', required: false, description: '账目类型' }) @ApiQuery({ name: "type", required: false, description: "账目类型" })
@ApiQuery({ name: 'category', required: false, description: '分类' }) @ApiQuery({ name: "category", required: false, description: "分类" })
@ApiQuery({ name: 'startDate', required: false, description: '开始日期' }) @ApiQuery({ name: "startDate", required: false, description: "开始日期" })
@ApiQuery({ name: 'endDate', required: false, description: '结束日期' }) @ApiQuery({ name: "endDate", required: false, description: "结束日期" })
@ApiQuery({ name: 'page', required: false, description: '页码' }) @ApiQuery({ name: "page", required: false, description: "页码" })
@ApiQuery({ name: 'limit', required: false, description: '每页数量' }) @ApiQuery({ name: "limit", required: false, description: "每页数量" })
async findAll( async findAll(
@CurrentUser('id') userId: string, @CurrentUser("id") userId: string,
@Query() queryDto: QueryLedgersDto, @Query() queryDto: QueryLedgersDto,
) { ) {
return this.ledgersService.findAll(userId, queryDto); return this.ledgersService.findAll(userId, queryDto);
} }
@Get('statistics/monthly') @Get("statistics/monthly")
@ApiOperation({ summary: '月度统计' }) @ApiOperation({ summary: "月度统计" })
@ApiResponse({ status: 200, description: '获取成功' }) @ApiResponse({ status: 200, description: "获取成功" })
async getMonthlyStatistics( async getMonthlyStatistics(
@CurrentUser('id') userId: string, @CurrentUser("id") userId: string,
@Query() statsDto: MonthlyStatisticsDto, @Query() statsDto: MonthlyStatisticsDto,
) { ) {
return this.ledgersService.getMonthlyStatistics(userId, statsDto); return this.ledgersService.getMonthlyStatistics(userId, statsDto);
} }
@Get('statistics/hierarchical/:groupId') @Get("statistics/hierarchical/:groupId")
@ApiOperation({ summary: '层级汇总' }) @ApiOperation({ summary: "层级汇总" })
@ApiResponse({ status: 200, description: '获取成功' }) @ApiResponse({ status: 200, description: "获取成功" })
async getHierarchicalSummary( async getHierarchicalSummary(
@CurrentUser('id') userId: string, @CurrentUser("id") userId: string,
@Param('groupId') groupId: string, @Param("groupId") groupId: string,
) { ) {
return this.ledgersService.getHierarchicalSummary(userId, groupId); return this.ledgersService.getHierarchicalSummary(userId, groupId);
} }
@Get(':id') @Get(":id")
@ApiOperation({ summary: '获取账目详情' }) @ApiOperation({ summary: "获取账目详情" })
@ApiResponse({ status: 200, description: '获取成功' }) @ApiResponse({ status: 200, description: "获取成功" })
async findOne(@Param('id') id: string) { async findOne(@Param("id") id: string) {
return this.ledgersService.findOne(id); return this.ledgersService.findOne(id);
} }
@Put(':id') @Put(":id")
@ApiOperation({ summary: '更新账目' }) @ApiOperation({ summary: "更新账目" })
@ApiResponse({ status: 200, description: '更新成功' }) @ApiResponse({ status: 200, description: "更新成功" })
async update( async update(
@CurrentUser('id') userId: string, @CurrentUser("id") userId: string,
@Param('id') id: string, @Param("id") id: string,
@Body() updateDto: UpdateLedgerDto, @Body() updateDto: UpdateLedgerDto,
) { ) {
return this.ledgersService.update(userId, id, updateDto); return this.ledgersService.update(userId, id, updateDto);
} }
@Delete(':id') @Delete(":id")
@ApiOperation({ summary: '删除账目' }) @ApiOperation({ summary: "删除账目" })
@ApiResponse({ status: 200, description: '删除成功' }) @ApiResponse({ status: 200, description: "删除成功" })
async remove( async remove(@CurrentUser("id") userId: string, @Param("id") id: string) {
@CurrentUser('id') userId: string,
@Param('id') id: string,
) {
return this.ledgersService.remove(userId, id); return this.ledgersService.remove(userId, id);
} }
} }

View File

@@ -1,10 +1,10 @@
import { Module } from '@nestjs/common'; import { Module } from "@nestjs/common";
import { TypeOrmModule } from '@nestjs/typeorm'; import { TypeOrmModule } from "@nestjs/typeorm";
import { LedgersService } from './ledgers.service'; import { LedgersService } from "./ledgers.service";
import { LedgersController } from './ledgers.controller'; import { LedgersController } from "./ledgers.controller";
import { Ledger } from '../../entities/ledger.entity'; import { Ledger } from "../../entities/ledger.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";
@Module({ @Module({
imports: [TypeOrmModule.forFeature([Ledger, Group, GroupMember])], imports: [TypeOrmModule.forFeature([Ledger, Group, GroupMember])],

View File

@@ -1,50 +1,50 @@
import { Test, TestingModule } from '@nestjs/testing'; import { Test, TestingModule } from "@nestjs/testing";
import { getRepositoryToken } from '@nestjs/typeorm'; import { getRepositoryToken } from "@nestjs/typeorm";
import { import {
NotFoundException, NotFoundException,
BadRequestException, BadRequestException,
ForbiddenException, ForbiddenException,
} from '@nestjs/common'; } from "@nestjs/common";
import { LedgersService } from './ledgers.service'; import { LedgersService } from "./ledgers.service";
import { Ledger } from '../../entities/ledger.entity'; import { Ledger } from "../../entities/ledger.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";
enum LedgerType { enum LedgerType {
INCOME = 'income', INCOME = "income",
EXPENSE = 'expense', EXPENSE = "expense",
} }
describe('LedgersService', () => { describe("LedgersService", () => {
let service: LedgersService; let service: LedgersService;
let mockLedgerRepository: any; let mockLedgerRepository: any;
let mockGroupRepository: any; let mockGroupRepository: any;
let mockGroupMemberRepository: any; let mockGroupMemberRepository: any;
const mockUser = { id: 'user-1', username: 'testuser' }; const mockUser = { id: "user-1", username: "testuser" };
const mockGroup = { const mockGroup = {
id: 'group-1', id: "group-1",
name: '测试小组', name: "测试小组",
isActive: true, isActive: true,
parentId: null, parentId: null,
}; };
const mockMembership = { const mockMembership = {
id: 'member-1', id: "member-1",
userId: 'user-1', userId: "user-1",
groupId: 'group-1', groupId: "group-1",
role: 'member', role: "member",
isActive: true, isActive: true,
}; };
const mockLedger = { const mockLedger = {
id: 'ledger-1', id: "ledger-1",
groupId: 'group-1', groupId: "group-1",
creatorId: 'user-1', creatorId: "user-1",
type: LedgerType.INCOME, type: LedgerType.INCOME,
amount: 100, amount: 100,
category: '聚餐费用', category: "聚餐费用",
description: '周末聚餐', description: "周末聚餐",
createdAt: new Date('2024-01-20T10:00:00Z'), createdAt: new Date("2024-01-20T10:00:00Z"),
updatedAt: new Date(), updatedAt: new Date(),
}; };
@@ -87,8 +87,8 @@ describe('LedgersService', () => {
service = module.get<LedgersService>(LedgersService); service = module.get<LedgersService>(LedgersService);
}); });
describe('create', () => { describe("create", () => {
it('应该成功创建账目', async () => { it("应该成功创建账目", async () => {
mockGroupRepository.findOne.mockResolvedValue(mockGroup); mockGroupRepository.findOne.mockResolvedValue(mockGroup);
mockGroupMemberRepository.findOne.mockResolvedValue(mockMembership); mockGroupMemberRepository.findOne.mockResolvedValue(mockMembership);
mockLedgerRepository.create.mockReturnValue(mockLedger); mockLedgerRepository.create.mockReturnValue(mockLedger);
@@ -99,66 +99,66 @@ describe('LedgersService', () => {
creator: mockUser, creator: mockUser,
}); });
const result = await service.create('user-1', { const result = await service.create("user-1", {
groupId: 'group-1', groupId: "group-1",
type: LedgerType.INCOME, type: LedgerType.INCOME,
amount: 100, amount: 100,
category: '聚餐费用', category: "聚餐费用",
description: '周末聚餐', description: "周末聚餐",
}); });
expect(result).toHaveProperty('id'); expect(result).toHaveProperty("id");
expect(result.amount).toBe(100); expect(result.amount).toBe(100);
expect(mockLedgerRepository.save).toHaveBeenCalled(); expect(mockLedgerRepository.save).toHaveBeenCalled();
}); });
it('应该在小组不存在时抛出异常', async () => { it("应该在小组不存在时抛出异常", async () => {
mockGroupRepository.findOne.mockResolvedValue(null); mockGroupRepository.findOne.mockResolvedValue(null);
await expect( await expect(
service.create('user-1', { service.create("user-1", {
groupId: 'group-1', groupId: "group-1",
type: LedgerType.INCOME, type: LedgerType.INCOME,
amount: 100, amount: 100,
category: '聚餐费用', category: "聚餐费用",
description: '测试', description: "测试",
}), }),
).rejects.toThrow(NotFoundException); ).rejects.toThrow(NotFoundException);
}); });
it('应该在用户不在小组中时抛出异常', async () => { it("应该在用户不在小组中时抛出异常", async () => {
mockGroupRepository.findOne.mockResolvedValue(mockGroup); mockGroupRepository.findOne.mockResolvedValue(mockGroup);
mockGroupMemberRepository.findOne.mockResolvedValue(null); mockGroupMemberRepository.findOne.mockResolvedValue(null);
await expect( await expect(
service.create('user-1', { service.create("user-1", {
groupId: 'group-1', groupId: "group-1",
type: LedgerType.INCOME, type: LedgerType.INCOME,
amount: 100, amount: 100,
category: '聚餐费用', category: "聚餐费用",
description: '测试', description: "测试",
}), }),
).rejects.toThrow(ForbiddenException); ).rejects.toThrow(ForbiddenException);
}); });
it('应该在金额无效时抛出异常', async () => { it("应该在金额无效时抛出异常", async () => {
mockGroupRepository.findOne.mockResolvedValue(mockGroup); mockGroupRepository.findOne.mockResolvedValue(mockGroup);
mockGroupMemberRepository.findOne.mockResolvedValue(mockMembership); mockGroupMemberRepository.findOne.mockResolvedValue(mockMembership);
await expect( await expect(
service.create('user-1', { service.create("user-1", {
groupId: 'group-1', groupId: "group-1",
type: LedgerType.INCOME, type: LedgerType.INCOME,
amount: -100, amount: -100,
category: '聚餐费用', category: "聚餐费用",
description: '测试', description: "测试",
}), }),
).rejects.toThrow(BadRequestException); ).rejects.toThrow(BadRequestException);
}); });
}); });
describe('findAll', () => { describe("findAll", () => {
it('应该成功获取账目列表', async () => { it("应该成功获取账目列表", async () => {
const mockQueryBuilder = { const mockQueryBuilder = {
leftJoinAndSelect: jest.fn().mockReturnThis(), leftJoinAndSelect: jest.fn().mockReturnThis(),
where: jest.fn().mockReturnThis(), where: jest.fn().mockReturnThis(),
@@ -172,18 +172,18 @@ describe('LedgersService', () => {
mockLedgerRepository.createQueryBuilder.mockReturnValue(mockQueryBuilder); mockLedgerRepository.createQueryBuilder.mockReturnValue(mockQueryBuilder);
mockGroupMemberRepository.findOne.mockResolvedValue(mockMembership); mockGroupMemberRepository.findOne.mockResolvedValue(mockMembership);
const result = await service.findAll('user-1', { const result = await service.findAll("user-1", {
groupId: 'group-1', groupId: "group-1",
page: 1, page: 1,
limit: 10, limit: 10,
}); });
expect(result).toHaveProperty('items'); expect(result).toHaveProperty("items");
expect(result).toHaveProperty('total'); expect(result).toHaveProperty("total");
expect(result.items).toHaveLength(1); expect(result.items).toHaveLength(1);
}); });
it('应该支持按类型筛选', async () => { it("应该支持按类型筛选", async () => {
const mockQueryBuilder = { const mockQueryBuilder = {
leftJoinAndSelect: jest.fn().mockReturnThis(), leftJoinAndSelect: jest.fn().mockReturnThis(),
where: jest.fn().mockReturnThis(), where: jest.fn().mockReturnThis(),
@@ -197,8 +197,8 @@ describe('LedgersService', () => {
mockLedgerRepository.createQueryBuilder.mockReturnValue(mockQueryBuilder); mockLedgerRepository.createQueryBuilder.mockReturnValue(mockQueryBuilder);
mockGroupMemberRepository.findOne.mockResolvedValue(mockMembership); mockGroupMemberRepository.findOne.mockResolvedValue(mockMembership);
const result = await service.findAll('user-1', { const result = await service.findAll("user-1", {
groupId: 'group-1', groupId: "group-1",
type: LedgerType.INCOME, type: LedgerType.INCOME,
page: 1, page: 1,
limit: 10, limit: 10,
@@ -209,31 +209,31 @@ describe('LedgersService', () => {
}); });
}); });
describe('findOne', () => { describe("findOne", () => {
it('应该成功获取账目详情', async () => { it("应该成功获取账目详情", async () => {
mockLedgerRepository.findOne.mockResolvedValue({ mockLedgerRepository.findOne.mockResolvedValue({
...mockLedger, ...mockLedger,
group: mockGroup, group: mockGroup,
creator: mockUser, creator: mockUser,
}); });
const result = await service.findOne('ledger-1'); const result = await service.findOne("ledger-1");
expect(result).toHaveProperty('id'); expect(result).toHaveProperty("id");
expect(result.id).toBe('ledger-1'); expect(result.id).toBe("ledger-1");
}); });
it('应该在账目不存在时抛出异常', async () => { it("应该在账目不存在时抛出异常", async () => {
mockLedgerRepository.findOne.mockResolvedValue(null); mockLedgerRepository.findOne.mockResolvedValue(null);
await expect(service.findOne('ledger-1')).rejects.toThrow( await expect(service.findOne("ledger-1")).rejects.toThrow(
NotFoundException, NotFoundException,
); );
}); });
}); });
describe('update', () => { describe("update", () => {
it('应该成功更新账目', async () => { it("应该成功更新账目", async () => {
mockLedgerRepository.findOne mockLedgerRepository.findOne
.mockResolvedValueOnce(mockLedger) .mockResolvedValueOnce(mockLedger)
.mockResolvedValueOnce({ .mockResolvedValueOnce({
@@ -244,70 +244,70 @@ describe('LedgersService', () => {
}); });
mockGroupMemberRepository.findOne.mockResolvedValue({ mockGroupMemberRepository.findOne.mockResolvedValue({
...mockMembership, ...mockMembership,
role: 'admin', role: "admin",
}); });
mockLedgerRepository.save.mockResolvedValue({ mockLedgerRepository.save.mockResolvedValue({
...mockLedger, ...mockLedger,
amount: 200, amount: 200,
}); });
const result = await service.update('user-1', 'ledger-1', { const result = await service.update("user-1", "ledger-1", {
amount: 200, amount: 200,
}); });
expect(result.amount).toBe(200); expect(result.amount).toBe(200);
}); });
it('应该在账目不存在时抛出异常', async () => { it("应该在账目不存在时抛出异常", async () => {
mockLedgerRepository.findOne.mockResolvedValue(null); mockLedgerRepository.findOne.mockResolvedValue(null);
await expect( await expect(
service.update('user-1', 'ledger-1', { amount: 200 }), service.update("user-1", "ledger-1", { amount: 200 }),
).rejects.toThrow(NotFoundException); ).rejects.toThrow(NotFoundException);
}); });
it('应该在无权限时抛出异常', async () => { it("应该在无权限时抛出异常", async () => {
mockLedgerRepository.findOne.mockResolvedValue(mockLedger); mockLedgerRepository.findOne.mockResolvedValue(mockLedger);
mockGroupMemberRepository.findOne.mockResolvedValue({ mockGroupMemberRepository.findOne.mockResolvedValue({
...mockMembership, ...mockMembership,
role: 'member', role: "member",
}); });
await expect( await expect(
service.update('user-2', 'ledger-1', { amount: 200 }), service.update("user-2", "ledger-1", { amount: 200 }),
).rejects.toThrow(ForbiddenException); ).rejects.toThrow(ForbiddenException);
}); });
}); });
describe('remove', () => { describe("remove", () => {
it('应该成功删除账目', async () => { it("应该成功删除账目", async () => {
mockLedgerRepository.findOne.mockResolvedValue(mockLedger); mockLedgerRepository.findOne.mockResolvedValue(mockLedger);
mockGroupMemberRepository.findOne.mockResolvedValue({ mockGroupMemberRepository.findOne.mockResolvedValue({
...mockMembership, ...mockMembership,
role: 'admin', role: "admin",
}); });
mockLedgerRepository.remove.mockResolvedValue(mockLedger); mockLedgerRepository.remove.mockResolvedValue(mockLedger);
const result = await service.remove('user-1', 'ledger-1'); const result = await service.remove("user-1", "ledger-1");
expect(result).toHaveProperty('message'); expect(result).toHaveProperty("message");
}); });
it('应该在无权限时抛出异常', async () => { it("应该在无权限时抛出异常", async () => {
mockLedgerRepository.findOne.mockResolvedValue(mockLedger); mockLedgerRepository.findOne.mockResolvedValue(mockLedger);
mockGroupMemberRepository.findOne.mockResolvedValue({ mockGroupMemberRepository.findOne.mockResolvedValue({
...mockMembership, ...mockMembership,
role: 'member', role: "member",
}); });
await expect( await expect(service.remove("user-2", "ledger-1")).rejects.toThrow(
service.remove('user-2', 'ledger-1'), ForbiddenException,
).rejects.toThrow(ForbiddenException); );
}); });
}); });
describe('getMonthlyStatistics', () => { describe("getMonthlyStatistics", () => {
it('应该成功获取月度统计', async () => { it("应该成功获取月度统计", async () => {
const mockQueryBuilder = { const mockQueryBuilder = {
where: jest.fn().mockReturnThis(), where: jest.fn().mockReturnThis(),
andWhere: jest.fn().mockReturnThis(), andWhere: jest.fn().mockReturnThis(),
@@ -320,24 +320,24 @@ describe('LedgersService', () => {
mockLedgerRepository.createQueryBuilder.mockReturnValue(mockQueryBuilder); mockLedgerRepository.createQueryBuilder.mockReturnValue(mockQueryBuilder);
mockGroupMemberRepository.findOne.mockResolvedValue(mockMembership); mockGroupMemberRepository.findOne.mockResolvedValue(mockMembership);
const result = await service.getMonthlyStatistics('user-1', { const result = await service.getMonthlyStatistics("user-1", {
groupId: 'group-1', groupId: "group-1",
year: 2024, year: 2024,
month: 1, month: 1,
}); });
expect(result).toHaveProperty('income'); expect(result).toHaveProperty("income");
expect(result).toHaveProperty('expense'); expect(result).toHaveProperty("expense");
expect(result).toHaveProperty('balance'); expect(result).toHaveProperty("balance");
expect(result).toHaveProperty('categories'); expect(result).toHaveProperty("categories");
}); });
it('应该在用户不在小组时抛出异常', async () => { it("应该在用户不在小组时抛出异常", async () => {
mockGroupMemberRepository.findOne.mockResolvedValue(null); mockGroupMemberRepository.findOne.mockResolvedValue(null);
await expect( await expect(
service.getMonthlyStatistics('user-1', { service.getMonthlyStatistics("user-1", {
groupId: 'group-1', groupId: "group-1",
year: 2024, year: 2024,
month: 1, month: 1,
}), }),
@@ -345,9 +345,9 @@ describe('LedgersService', () => {
}); });
}); });
describe('getHierarchicalSummary', () => { describe("getHierarchicalSummary", () => {
it('应该成功获取层级汇总', async () => { it("应该成功获取层级汇总", async () => {
const childGroup = { id: 'group-2', name: '子小组', parentId: 'group-1' }; const childGroup = { id: "group-2", name: "子小组", parentId: "group-1" };
const mockQueryBuilder = { const mockQueryBuilder = {
where: jest.fn().mockReturnThis(), where: jest.fn().mockReturnThis(),
getMany: jest.fn().mockResolvedValue([mockLedger]), getMany: jest.fn().mockResolvedValue([mockLedger]),
@@ -358,12 +358,12 @@ describe('LedgersService', () => {
mockGroupRepository.find.mockResolvedValue([childGroup]); mockGroupRepository.find.mockResolvedValue([childGroup]);
mockLedgerRepository.createQueryBuilder.mockReturnValue(mockQueryBuilder); mockLedgerRepository.createQueryBuilder.mockReturnValue(mockQueryBuilder);
const result = await service.getHierarchicalSummary('user-1', 'group-1'); const result = await service.getHierarchicalSummary("user-1", "group-1");
expect(result).toHaveProperty('groupId'); expect(result).toHaveProperty("groupId");
expect(result).toHaveProperty('income'); expect(result).toHaveProperty("income");
expect(result).toHaveProperty('expense'); expect(result).toHaveProperty("expense");
expect(result).toHaveProperty('balance'); expect(result).toHaveProperty("balance");
}); });
}); });
}); });

View File

@@ -3,21 +3,24 @@ 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, Between } from 'typeorm'; import { Repository, Between } from "typeorm";
import { Ledger } from '../../entities/ledger.entity'; import { Ledger } from "../../entities/ledger.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 { import {
CreateLedgerDto, CreateLedgerDto,
UpdateLedgerDto, UpdateLedgerDto,
QueryLedgersDto, QueryLedgersDto,
MonthlyStatisticsDto, MonthlyStatisticsDto,
} from './dto/ledger.dto'; } from "./dto/ledger.dto";
import { LedgerType, GroupMemberRole } from '../../common/enums'; import { LedgerType, GroupMemberRole } from "../../common/enums";
import { ErrorCode, ErrorMessage } from '../../common/interfaces/response.interface'; import {
import { PaginationUtil } from '../../common/utils/pagination.util'; ErrorCode,
ErrorMessage,
} from "../../common/interfaces/response.interface";
import { PaginationUtil } from "../../common/utils/pagination.util";
@Injectable() @Injectable()
export class LedgersService { export class LedgersService {
@@ -86,20 +89,20 @@ export class LedgersService {
const { offset } = PaginationUtil.formatPaginationParams(page, limit); const { offset } = PaginationUtil.formatPaginationParams(page, limit);
const queryBuilder = this.ledgerRepository const queryBuilder = this.ledgerRepository
.createQueryBuilder('ledger') .createQueryBuilder("ledger")
.leftJoinAndSelect('ledger.group', 'group') .leftJoinAndSelect("ledger.group", "group")
.leftJoinAndSelect('ledger.user', 'user'); .leftJoinAndSelect("ledger.user", "user");
// 筛选条件 // 筛选条件
if (groupId) { if (groupId) {
// 验证用户是否在小组中 // 验证用户是否在小组中
await this.checkGroupMembership(userId, groupId); await this.checkGroupMembership(userId, groupId);
queryBuilder.andWhere('ledger.groupId = :groupId', { groupId }); queryBuilder.andWhere("ledger.groupId = :groupId", { groupId });
} else { } else {
// 如果没有指定小组,只返回用户所在小组的账目 // 如果没有指定小组,只返回用户所在小组的账目
const memberGroups = await this.groupMemberRepository.find({ const memberGroups = await this.groupMemberRepository.find({
where: { userId, isActive: true }, where: { userId, isActive: true },
select: ['groupId'], select: ["groupId"],
}); });
const groupIds = memberGroups.map((m) => m.groupId); const groupIds = memberGroups.map((m) => m.groupId);
if (groupIds.length === 0) { if (groupIds.length === 0) {
@@ -111,35 +114,38 @@ export class LedgersService {
totalPages: 0, totalPages: 0,
}; };
} }
queryBuilder.andWhere('ledger.groupId IN (:...groupIds)', { groupIds }); queryBuilder.andWhere("ledger.groupId IN (:...groupIds)", { groupIds });
} }
if (type) { if (type) {
queryBuilder.andWhere('ledger.type = :type', { type }); queryBuilder.andWhere("ledger.type = :type", { type });
} }
if (category) { if (category) {
queryBuilder.andWhere('ledger.category = :category', { category }); queryBuilder.andWhere("ledger.category = :category", { category });
} }
if (startDate && endDate) { if (startDate && endDate) {
queryBuilder.andWhere('ledger.createdAt BETWEEN :startDate AND :endDate', { queryBuilder.andWhere(
"ledger.createdAt BETWEEN :startDate AND :endDate",
{
startDate: new Date(startDate), startDate: new Date(startDate),
endDate: new Date(endDate), endDate: new Date(endDate),
}); },
);
} else if (startDate) { } else if (startDate) {
queryBuilder.andWhere('ledger.createdAt >= :startDate', { queryBuilder.andWhere("ledger.createdAt >= :startDate", {
startDate: new Date(startDate), startDate: new Date(startDate),
}); });
} else if (endDate) { } else if (endDate) {
queryBuilder.andWhere('ledger.createdAt <= :endDate', { queryBuilder.andWhere("ledger.createdAt <= :endDate", {
endDate: new Date(endDate), endDate: new Date(endDate),
}); });
} }
// 分页 // 分页
const [items, total] = await queryBuilder const [items, total] = await queryBuilder
.orderBy('ledger.createdAt', 'DESC') .orderBy("ledger.createdAt", "DESC")
.skip(offset) .skip(offset)
.take(limit) .take(limit)
.getManyAndCount(); .getManyAndCount();
@@ -159,13 +165,13 @@ export class LedgersService {
async findOne(id: string) { async findOne(id: string) {
const ledger = await this.ledgerRepository.findOne({ const ledger = await this.ledgerRepository.findOne({
where: { id }, where: { id },
relations: ['group', 'user'], relations: ["group", "user"],
}); });
if (!ledger) { if (!ledger) {
throw new NotFoundException({ throw new NotFoundException({
code: ErrorCode.NOT_FOUND, code: ErrorCode.NOT_FOUND,
message: '账目不存在', message: "账目不存在",
}); });
} }
@@ -183,7 +189,7 @@ export class LedgersService {
if (!ledger) { if (!ledger) {
throw new NotFoundException({ throw new NotFoundException({
code: ErrorCode.NOT_FOUND, code: ErrorCode.NOT_FOUND,
message: '账目不存在', message: "账目不存在",
}); });
} }
@@ -207,7 +213,7 @@ export class LedgersService {
if (!ledger) { if (!ledger) {
throw new NotFoundException({ throw new NotFoundException({
code: ErrorCode.NOT_FOUND, code: ErrorCode.NOT_FOUND,
message: '账目不存在', message: "账目不存在",
}); });
} }
@@ -216,7 +222,7 @@ export class LedgersService {
await this.ledgerRepository.remove(ledger); await this.ledgerRepository.remove(ledger);
return { message: '账目已删除' }; return { message: "账目已删除" };
} }
/** /**
@@ -258,7 +264,7 @@ export class LedgersService {
} }
// 分类统计 // 分类统计
const category = ledger.category || '未分类'; const category = ledger.category || "未分类";
if (!categoryStats[category]) { if (!categoryStats[category]) {
categoryStats[category] = { income: 0, expense: 0, count: 0 }; categoryStats[category] = { income: 0, expense: 0, count: 0 };
} }

View File

@@ -4,48 +4,48 @@ import {
IsOptional, IsOptional,
IsNumber, IsNumber,
MaxLength, MaxLength,
} from 'class-validator'; } from "class-validator";
import { ApiProperty } from '@nestjs/swagger'; import { ApiProperty } from "@nestjs/swagger";
export class AddPointDto { export class AddPointDto {
@ApiProperty({ description: '用户ID' }) @ApiProperty({ description: "用户ID" })
@IsString() @IsString()
@IsNotEmpty({ message: '用户ID不能为空' }) @IsNotEmpty({ message: "用户ID不能为空" })
userId: string; userId: string;
@ApiProperty({ description: '小组ID' }) @ApiProperty({ description: "小组ID" })
@IsString() @IsString()
@IsNotEmpty({ message: '小组ID不能为空' }) @IsNotEmpty({ message: "小组ID不能为空" })
groupId: string; groupId: string;
@ApiProperty({ description: '积分数量', example: 10 }) @ApiProperty({ description: "积分数量", example: 10 })
@IsNumber() @IsNumber()
amount: number; amount: number;
@ApiProperty({ description: '原因', example: '参与预约' }) @ApiProperty({ description: "原因", example: "参与预约" })
@IsString() @IsString()
@IsNotEmpty({ message: '原因不能为空' }) @IsNotEmpty({ message: "原因不能为空" })
@MaxLength(100) @MaxLength(100)
reason: string; reason: string;
@ApiProperty({ description: '详细说明', required: false }) @ApiProperty({ description: "详细说明", required: false })
@IsString() @IsString()
@IsOptional() @IsOptional()
description?: string; description?: string;
@ApiProperty({ description: '关联ID', required: false }) @ApiProperty({ description: "关联ID", required: false })
@IsString() @IsString()
@IsOptional() @IsOptional()
relatedId?: string; relatedId?: string;
} }
export class QueryPointsDto { export class QueryPointsDto {
@ApiProperty({ description: '用户ID', required: false }) @ApiProperty({ description: "用户ID", required: false })
@IsString() @IsString()
@IsOptional() @IsOptional()
userId?: string; userId?: string;
@ApiProperty({ description: '小组ID', required: false }) @ApiProperty({ description: "小组ID", required: false })
@IsString() @IsString()
@IsOptional() @IsOptional()
groupId?: string; groupId?: string;

View File

@@ -6,46 +6,46 @@ import {
Query, Query,
Param, Param,
UseGuards, UseGuards,
} from '@nestjs/common'; } from "@nestjs/common";
import { ApiTags, ApiOperation, ApiBearerAuth } from '@nestjs/swagger'; import { ApiTags, ApiOperation, ApiBearerAuth } from "@nestjs/swagger";
import { PointsService } from './points.service'; import { PointsService } from "./points.service";
import { AddPointDto, QueryPointsDto } from './dto/point.dto'; import { AddPointDto, QueryPointsDto } from "./dto/point.dto";
import { JwtAuthGuard } from '../../common/guards/jwt-auth.guard'; import { JwtAuthGuard } from "../../common/guards/jwt-auth.guard";
import { CurrentUser } from '../../common/decorators/current-user.decorator'; import { CurrentUser } from "../../common/decorators/current-user.decorator";
@ApiTags('points') @ApiTags("points")
@Controller('points') @Controller("points")
@UseGuards(JwtAuthGuard) @UseGuards(JwtAuthGuard)
@ApiBearerAuth() @ApiBearerAuth()
export class PointsController { export class PointsController {
constructor(private readonly pointsService: PointsService) {} constructor(private readonly pointsService: PointsService) {}
@Post() @Post()
@ApiOperation({ summary: '添加积分记录(管理员)' }) @ApiOperation({ summary: "添加积分记录(管理员)" })
addPoint(@CurrentUser() user, @Body() addDto: AddPointDto) { addPoint(@CurrentUser() user, @Body() addDto: AddPointDto) {
return this.pointsService.addPoint(user.id, addDto); return this.pointsService.addPoint(user.id, addDto);
} }
@Get() @Get()
@ApiOperation({ summary: '查询积分流水' }) @ApiOperation({ summary: "查询积分流水" })
findAll(@Query() query: QueryPointsDto) { findAll(@Query() query: QueryPointsDto) {
return this.pointsService.findAll(query); return this.pointsService.findAll(query);
} }
@Get('balance/:userId/:groupId') @Get("balance/:userId/:groupId")
@ApiOperation({ summary: '查询用户在小组的积分余额' }) @ApiOperation({ summary: "查询用户在小组的积分余额" })
getUserBalance( getUserBalance(
@Param('userId') userId: string, @Param("userId") userId: string,
@Param('groupId') groupId: string, @Param("groupId") groupId: string,
) { ) {
return this.pointsService.getUserBalance(userId, groupId); return this.pointsService.getUserBalance(userId, groupId);
} }
@Get('ranking/:groupId') @Get("ranking/:groupId")
@ApiOperation({ summary: '获取小组积分排行榜' }) @ApiOperation({ summary: "获取小组积分排行榜" })
getGroupRanking( getGroupRanking(
@Param('groupId') groupId: string, @Param("groupId") groupId: string,
@Query('limit') limit?: number, @Query("limit") limit?: number,
) { ) {
return this.pointsService.getGroupRanking(groupId, limit); return this.pointsService.getGroupRanking(groupId, limit);
} }

View File

@@ -1,11 +1,11 @@
import { Module } from '@nestjs/common'; import { Module } from "@nestjs/common";
import { TypeOrmModule } from '@nestjs/typeorm'; import { TypeOrmModule } from "@nestjs/typeorm";
import { PointsController } from './points.controller'; import { PointsController } from "./points.controller";
import { PointsService } from './points.service'; import { PointsService } from "./points.service";
import { Point } from '../../entities/point.entity'; import { Point } from "../../entities/point.entity";
import { User } from '../../entities/user.entity'; import { User } from "../../entities/user.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";
@Module({ @Module({
imports: [TypeOrmModule.forFeature([Point, User, Group, GroupMember])], imports: [TypeOrmModule.forFeature([Point, User, Group, GroupMember])],

View File

@@ -1,15 +1,15 @@
import { Test, TestingModule } from '@nestjs/testing'; import { Test, TestingModule } from "@nestjs/testing";
import { getRepositoryToken } from '@nestjs/typeorm'; import { getRepositoryToken } from "@nestjs/typeorm";
import { Repository, SelectQueryBuilder } from 'typeorm'; import { Repository, SelectQueryBuilder } from "typeorm";
import { PointsService } from './points.service'; import { PointsService } from "./points.service";
import { Point } from '../../entities/point.entity'; import { Point } from "../../entities/point.entity";
import { User } from '../../entities/user.entity'; import { User } from "../../entities/user.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 { GroupMemberRole } from '../../common/enums'; import { GroupMemberRole } from "../../common/enums";
import { NotFoundException, ForbiddenException } from '@nestjs/common'; import { NotFoundException, ForbiddenException } from "@nestjs/common";
describe('PointsService', () => { describe("PointsService", () => {
let service: PointsService; let service: PointsService;
let pointRepository: Repository<Point>; let pointRepository: Repository<Point>;
let userRepository: Repository<User>; let userRepository: Repository<User>;
@@ -17,29 +17,29 @@ describe('PointsService', () => {
let groupMemberRepository: Repository<GroupMember>; let groupMemberRepository: Repository<GroupMember>;
const mockPoint = { const mockPoint = {
id: 'point-1', id: "point-1",
userId: 'user-1', userId: "user-1",
groupId: 'group-1', groupId: "group-1",
amount: 10, amount: 10,
reason: '参与预约', reason: "参与预约",
description: '测试说明', description: "测试说明",
createdAt: new Date(), createdAt: new Date(),
}; };
const mockUser = { const mockUser = {
id: 'user-1', id: "user-1",
username: '测试用户', username: "测试用户",
}; };
const mockGroup = { const mockGroup = {
id: 'group-1', id: "group-1",
name: '测试小组', name: "测试小组",
}; };
const mockGroupMember = { const mockGroupMember = {
id: 'member-1', id: "member-1",
userId: 'user-1', userId: "user-1",
groupId: 'group-1', groupId: "group-1",
role: GroupMemberRole.ADMIN, role: GroupMemberRole.ADMIN,
}; };
@@ -97,122 +97,140 @@ describe('PointsService', () => {
pointRepository = module.get<Repository<Point>>(getRepositoryToken(Point)); pointRepository = module.get<Repository<Point>>(getRepositoryToken(Point));
userRepository = module.get<Repository<User>>(getRepositoryToken(User)); userRepository = module.get<Repository<User>>(getRepositoryToken(User));
groupRepository = module.get<Repository<Group>>(getRepositoryToken(Group)); groupRepository = module.get<Repository<Group>>(getRepositoryToken(Group));
groupMemberRepository = module.get<Repository<GroupMember>>(getRepositoryToken(GroupMember)); groupMemberRepository = module.get<Repository<GroupMember>>(
getRepositoryToken(GroupMember),
);
}); });
it('should be defined', () => { it("should be defined", () => {
expect(service).toBeDefined(); expect(service).toBeDefined();
}); });
describe('addPoint', () => { describe("addPoint", () => {
it('应该成功添加积分记录', async () => { it("应该成功添加积分记录", async () => {
const addDto = { const addDto = {
userId: 'user-1', userId: "user-1",
groupId: 'group-1', groupId: "group-1",
amount: 10, amount: 10,
reason: '参与预约', reason: "参与预约",
}; };
jest.spyOn(groupRepository, 'findOne').mockResolvedValue(mockGroup as any); jest
jest.spyOn(userRepository, 'findOne').mockResolvedValue(mockUser as any); .spyOn(groupRepository, "findOne")
jest.spyOn(groupMemberRepository, 'findOne').mockResolvedValue(mockGroupMember as any); .mockResolvedValue(mockGroup as any);
jest.spyOn(pointRepository, 'create').mockReturnValue(mockPoint as any); jest.spyOn(userRepository, "findOne").mockResolvedValue(mockUser as any);
jest.spyOn(pointRepository, 'save').mockResolvedValue(mockPoint as any); jest
.spyOn(groupMemberRepository, "findOne")
.mockResolvedValue(mockGroupMember as any);
jest.spyOn(pointRepository, "create").mockReturnValue(mockPoint as any);
jest.spyOn(pointRepository, "save").mockResolvedValue(mockPoint as any);
const result = await service.addPoint('user-1', addDto); const result = await service.addPoint("user-1", addDto);
expect(result).toBeDefined(); expect(result).toBeDefined();
expect(pointRepository.save).toHaveBeenCalled(); expect(pointRepository.save).toHaveBeenCalled();
}); });
it('小组不存在时应该抛出异常', async () => { it("小组不存在时应该抛出异常", async () => {
const addDto = { const addDto = {
userId: 'user-1', userId: "user-1",
groupId: 'group-1', groupId: "group-1",
amount: 10, amount: 10,
reason: '参与预约', reason: "参与预约",
}; };
jest.spyOn(groupRepository, 'findOne').mockResolvedValue(null); jest.spyOn(groupRepository, "findOne").mockResolvedValue(null);
await expect(service.addPoint('user-1', addDto)).rejects.toThrow(NotFoundException); await expect(service.addPoint("user-1", addDto)).rejects.toThrow(
NotFoundException,
);
}); });
it('用户不存在时应该抛出异常', async () => { it("用户不存在时应该抛出异常", async () => {
const addDto = { const addDto = {
userId: 'user-1', userId: "user-1",
groupId: 'group-1', groupId: "group-1",
amount: 10, amount: 10,
reason: '参与预约', reason: "参与预约",
}; };
jest.spyOn(groupRepository, 'findOne').mockResolvedValue(mockGroup as any); jest
jest.spyOn(userRepository, 'findOne').mockResolvedValue(null); .spyOn(groupRepository, "findOne")
.mockResolvedValue(mockGroup as any);
jest.spyOn(userRepository, "findOne").mockResolvedValue(null);
await expect(service.addPoint('user-1', addDto)).rejects.toThrow(NotFoundException); await expect(service.addPoint("user-1", addDto)).rejects.toThrow(
NotFoundException,
);
}); });
it('无权限时应该抛出异常', async () => { it("无权限时应该抛出异常", async () => {
const addDto = { const addDto = {
userId: 'user-1', userId: "user-1",
groupId: 'group-1', groupId: "group-1",
amount: 10, amount: 10,
reason: '参与预约', reason: "参与预约",
}; };
jest.spyOn(groupRepository, 'findOne').mockResolvedValue(mockGroup as any); jest
jest.spyOn(userRepository, 'findOne').mockResolvedValue(mockUser as any); .spyOn(groupRepository, "findOne")
jest.spyOn(groupMemberRepository, 'findOne').mockResolvedValue({ .mockResolvedValue(mockGroup as any);
jest.spyOn(userRepository, "findOne").mockResolvedValue(mockUser as any);
jest.spyOn(groupMemberRepository, "findOne").mockResolvedValue({
...mockGroupMember, ...mockGroupMember,
role: GroupMemberRole.MEMBER, role: GroupMemberRole.MEMBER,
} as any); } as any);
await expect(service.addPoint('user-1', addDto)).rejects.toThrow(ForbiddenException); await expect(service.addPoint("user-1", addDto)).rejects.toThrow(
ForbiddenException,
);
}); });
}); });
describe('findAll', () => { describe("findAll", () => {
it('应该返回积分流水列表', async () => { it("应该返回积分流水列表", async () => {
mockQueryBuilder.getMany.mockResolvedValue([mockPoint]); mockQueryBuilder.getMany.mockResolvedValue([mockPoint]);
const result = await service.findAll({ groupId: 'group-1' }); const result = await service.findAll({ groupId: "group-1" });
expect(result).toHaveLength(1); expect(result).toHaveLength(1);
expect(pointRepository.createQueryBuilder).toHaveBeenCalled(); expect(pointRepository.createQueryBuilder).toHaveBeenCalled();
}); });
}); });
describe('getUserBalance', () => { describe("getUserBalance", () => {
it('应该返回用户积分余额', async () => { it("应该返回用户积分余额", async () => {
mockQueryBuilder.getRawOne.mockResolvedValue({ total: '100' }); mockQueryBuilder.getRawOne.mockResolvedValue({ total: "100" });
const result = await service.getUserBalance('user-1', 'group-1'); const result = await service.getUserBalance("user-1", "group-1");
expect(result.balance).toBe(100); expect(result.balance).toBe(100);
expect(result.userId).toBe('user-1'); expect(result.userId).toBe("user-1");
expect(result.groupId).toBe('group-1'); expect(result.groupId).toBe("group-1");
}); });
it('没有积分记录时应该返回0', async () => { it("没有积分记录时应该返回0", async () => {
mockQueryBuilder.getRawOne.mockResolvedValue({ total: null }); mockQueryBuilder.getRawOne.mockResolvedValue({ total: null });
const result = await service.getUserBalance('user-1', 'group-1'); const result = await service.getUserBalance("user-1", "group-1");
expect(result.balance).toBe(0); expect(result.balance).toBe(0);
}); });
}); });
describe('getGroupRanking', () => { describe("getGroupRanking", () => {
it('应该返回小组积分排行榜', async () => { it("应该返回小组积分排行榜", async () => {
const mockRanking = [ const mockRanking = [
{ userId: 'user-1', username: '用户1', totalPoints: '100' }, { userId: "user-1", username: "用户1", totalPoints: "100" },
{ userId: 'user-2', username: '用户2', totalPoints: '80' }, { userId: "user-2", username: "用户2", totalPoints: "80" },
]; ];
jest.spyOn(groupRepository, 'findOne').mockResolvedValue(mockGroup as any); jest
.spyOn(groupRepository, "findOne")
.mockResolvedValue(mockGroup as any);
mockQueryBuilder.getRawMany.mockResolvedValue(mockRanking); mockQueryBuilder.getRawMany.mockResolvedValue(mockRanking);
const result = await service.getGroupRanking('group-1', 10); const result = await service.getGroupRanking("group-1", 10);
expect(result).toHaveLength(2); expect(result).toHaveLength(2);
expect(result[0].rank).toBe(1); expect(result[0].rank).toBe(1);
@@ -220,10 +238,12 @@ describe('PointsService', () => {
expect(result[1].rank).toBe(2); expect(result[1].rank).toBe(2);
}); });
it('小组不存在时应该抛出异常', async () => { it("小组不存在时应该抛出异常", async () => {
jest.spyOn(groupRepository, 'findOne').mockResolvedValue(null); jest.spyOn(groupRepository, "findOne").mockResolvedValue(null);
await expect(service.getGroupRanking('group-1')).rejects.toThrow(NotFoundException); await expect(service.getGroupRanking("group-1")).rejects.toThrow(
NotFoundException,
);
}); });
}); });
}); });

View File

@@ -2,16 +2,19 @@ import {
Injectable, Injectable,
NotFoundException, NotFoundException,
ForbiddenException, ForbiddenException,
} from '@nestjs/common'; } from "@nestjs/common";
import { InjectRepository } from '@nestjs/typeorm'; import { InjectRepository } from "@nestjs/typeorm";
import { Repository } from 'typeorm'; import { Repository } from "typeorm";
import { Point } from '../../entities/point.entity'; import { Point } from "../../entities/point.entity";
import { User } from '../../entities/user.entity'; import { User } from "../../entities/user.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 { AddPointDto, QueryPointsDto } from './dto/point.dto'; import { AddPointDto, QueryPointsDto } from "./dto/point.dto";
import { GroupMemberRole } from '../../common/enums'; import { GroupMemberRole } from "../../common/enums";
import { ErrorCode, ErrorMessage } from '../../common/interfaces/response.interface'; import {
ErrorCode,
ErrorMessage,
} from "../../common/interfaces/response.interface";
@Injectable() @Injectable()
export class PointsService { export class PointsService {
@@ -33,7 +36,9 @@ export class PointsService {
const { userId, groupId, ...rest } = addDto; const { userId, groupId, ...rest } = addDto;
// 验证小组存在 // 验证小组存在
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,
@@ -55,10 +60,14 @@ export class PointsService {
where: { groupId, userId: operatorId }, where: { groupId, userId: operatorId },
}); });
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: "需要管理员权限",
}); });
} }
@@ -78,19 +87,19 @@ export class PointsService {
*/ */
async findAll(query: QueryPointsDto) { async findAll(query: QueryPointsDto) {
const qb = this.pointRepository const qb = this.pointRepository
.createQueryBuilder('point') .createQueryBuilder("point")
.leftJoinAndSelect('point.user', 'user') .leftJoinAndSelect("point.user", "user")
.leftJoinAndSelect('point.group', 'group'); .leftJoinAndSelect("point.group", "group");
if (query.userId) { if (query.userId) {
qb.andWhere('point.userId = :userId', { userId: query.userId }); qb.andWhere("point.userId = :userId", { userId: query.userId });
} }
if (query.groupId) { if (query.groupId) {
qb.andWhere('point.groupId = :groupId', { groupId: query.groupId }); qb.andWhere("point.groupId = :groupId", { groupId: query.groupId });
} }
qb.orderBy('point.createdAt', 'DESC'); qb.orderBy("point.createdAt", "DESC");
const points = await qb.getMany(); const points = await qb.getMany();
@@ -102,16 +111,16 @@ export class PointsService {
*/ */
async getUserBalance(userId: string, groupId: string) { async getUserBalance(userId: string, groupId: string) {
const result = await this.pointRepository const result = await this.pointRepository
.createQueryBuilder('point') .createQueryBuilder("point")
.select('SUM(point.amount)', 'total') .select("SUM(point.amount)", "total")
.where('point.userId = :userId', { userId }) .where("point.userId = :userId", { userId })
.andWhere('point.groupId = :groupId', { groupId }) .andWhere("point.groupId = :groupId", { groupId })
.getRawOne(); .getRawOne();
return { return {
userId, userId,
groupId, groupId,
balance: parseInt(result.total || '0'), balance: parseInt(result.total || "0"),
}; };
} }
@@ -119,7 +128,9 @@ export class PointsService {
* 获取小组积分排行榜 * 获取小组积分排行榜
*/ */
async getGroupRanking(groupId: string, limit: number = 10) { async getGroupRanking(groupId: string, limit: number = 10) {
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({
@@ -129,14 +140,14 @@ export class PointsService {
} }
const ranking = await this.pointRepository const ranking = await this.pointRepository
.createQueryBuilder('point') .createQueryBuilder("point")
.select('point.userId', 'userId') .select("point.userId", "userId")
.addSelect('SUM(point.amount)', 'totalPoints') .addSelect("SUM(point.amount)", "totalPoints")
.leftJoin('point.user', 'user') .leftJoin("point.user", "user")
.addSelect('user.username', 'username') .addSelect("user.username", "username")
.where('point.groupId = :groupId', { groupId }) .where("point.groupId = :groupId", { groupId })
.groupBy('point.userId') .groupBy("point.userId")
.orderBy('totalPoints', 'DESC') .orderBy("totalPoints", "DESC")
.limit(limit) .limit(limit)
.getRawMany(); .getRawMany();

View File

@@ -7,42 +7,42 @@ import {
IsArray, IsArray,
ValidateNested, ValidateNested,
IsDateString, IsDateString,
} from 'class-validator'; } from "class-validator";
import { ApiProperty } from '@nestjs/swagger'; import { ApiProperty } from "@nestjs/swagger";
import { Type } from 'class-transformer'; import { Type } from "class-transformer";
export class TimeSlotDto { export class TimeSlotDto {
@ApiProperty({ description: '开始时间', example: '2024-01-20T19:00:00Z' }) @ApiProperty({ description: "开始时间", example: "2024-01-20T19:00:00Z" })
@IsDateString() @IsDateString()
startTime: Date; startTime: Date;
@ApiProperty({ description: '结束时间', example: '2024-01-20T23:00:00Z' }) @ApiProperty({ description: "结束时间", example: "2024-01-20T23:00:00Z" })
@IsDateString() @IsDateString()
endTime: Date; endTime: Date;
@ApiProperty({ description: '备注', required: false }) @ApiProperty({ description: "备注", required: false })
@IsString() @IsString()
@IsOptional() @IsOptional()
note?: string; note?: string;
} }
export class CreateScheduleDto { export class CreateScheduleDto {
@ApiProperty({ description: '小组ID' }) @ApiProperty({ description: "小组ID" })
@IsString() @IsString()
@IsNotEmpty({ message: '小组ID不能为空' }) @IsNotEmpty({ message: "小组ID不能为空" })
groupId: string; groupId: string;
@ApiProperty({ description: '标题', example: '本周空闲时间' }) @ApiProperty({ description: "标题", example: "本周空闲时间" })
@IsString() @IsString()
@IsNotEmpty({ message: '标题不能为空' }) @IsNotEmpty({ message: "标题不能为空" })
title: string; title: string;
@ApiProperty({ description: '描述', required: false }) @ApiProperty({ description: "描述", required: false })
@IsString() @IsString()
@IsOptional() @IsOptional()
description?: string; description?: string;
@ApiProperty({ description: '空闲时间段', type: [TimeSlotDto] }) @ApiProperty({ description: "空闲时间段", type: [TimeSlotDto] })
@IsArray() @IsArray()
@ValidateNested({ each: true }) @ValidateNested({ each: true })
@Type(() => TimeSlotDto) @Type(() => TimeSlotDto)
@@ -50,17 +50,21 @@ export class CreateScheduleDto {
} }
export class UpdateScheduleDto { export class UpdateScheduleDto {
@ApiProperty({ description: '标题', required: false }) @ApiProperty({ description: "标题", required: false })
@IsString() @IsString()
@IsOptional() @IsOptional()
title?: string; title?: string;
@ApiProperty({ description: '描述', required: false }) @ApiProperty({ description: "描述", required: false })
@IsString() @IsString()
@IsOptional() @IsOptional()
description?: string; description?: string;
@ApiProperty({ description: '空闲时间段', type: [TimeSlotDto], required: false }) @ApiProperty({
description: "空闲时间段",
type: [TimeSlotDto],
required: false,
})
@IsArray() @IsArray()
@ValidateNested({ each: true }) @ValidateNested({ each: true })
@Type(() => TimeSlotDto) @Type(() => TimeSlotDto)
@@ -69,34 +73,34 @@ export class UpdateScheduleDto {
} }
export class QuerySchedulesDto { export class QuerySchedulesDto {
@ApiProperty({ description: '小组ID', required: false }) @ApiProperty({ description: "小组ID", required: false })
@IsString() @IsString()
@IsOptional() @IsOptional()
groupId?: string; groupId?: string;
@ApiProperty({ description: '用户ID', required: false }) @ApiProperty({ description: "用户ID", required: false })
@IsString() @IsString()
@IsOptional() @IsOptional()
userId?: string; userId?: string;
@ApiProperty({ description: '开始时间', required: false }) @ApiProperty({ description: "开始时间", required: false })
@IsDateString() @IsDateString()
@IsOptional() @IsOptional()
startTime?: Date; startTime?: Date;
@ApiProperty({ description: '结束时间', required: false }) @ApiProperty({ description: "结束时间", required: false })
@IsDateString() @IsDateString()
@IsOptional() @IsOptional()
endTime?: Date; endTime?: Date;
@ApiProperty({ description: '页码', example: 1, required: false }) @ApiProperty({ description: "页码", example: 1, required: false })
@IsNumber() @IsNumber()
@Min(1) @Min(1)
@IsOptional() @IsOptional()
@Type(() => Number) @Type(() => Number)
page?: number; page?: number;
@ApiProperty({ description: '每页数量', example: 10, required: false }) @ApiProperty({ description: "每页数量", example: 10, required: false })
@IsNumber() @IsNumber()
@Min(1) @Min(1)
@IsOptional() @IsOptional()
@@ -105,20 +109,20 @@ export class QuerySchedulesDto {
} }
export class FindCommonSlotsDto { export class FindCommonSlotsDto {
@ApiProperty({ description: '小组ID' }) @ApiProperty({ description: "小组ID" })
@IsString() @IsString()
@IsNotEmpty({ message: '小组ID不能为空' }) @IsNotEmpty({ message: "小组ID不能为空" })
groupId: string; groupId: string;
@ApiProperty({ description: '开始时间' }) @ApiProperty({ description: "开始时间" })
@IsDateString() @IsDateString()
startTime: Date; startTime: Date;
@ApiProperty({ description: '结束时间' }) @ApiProperty({ description: "结束时间" })
@IsDateString() @IsDateString()
endTime: Date; endTime: Date;
@ApiProperty({ description: '最少参与人数', example: 3, required: false }) @ApiProperty({ description: "最少参与人数", example: 3, required: false })
@IsNumber() @IsNumber()
@Min(1) @Min(1)
@IsOptional() @IsOptional()

View File

@@ -8,92 +8,89 @@ import {
Param, Param,
Query, Query,
UseGuards, UseGuards,
} from '@nestjs/common'; } from "@nestjs/common";
import { import {
ApiTags, ApiTags,
ApiOperation, ApiOperation,
ApiResponse, ApiResponse,
ApiBearerAuth, ApiBearerAuth,
ApiQuery, ApiQuery,
} from '@nestjs/swagger'; } from "@nestjs/swagger";
import { SchedulesService } from './schedules.service'; import { SchedulesService } from "./schedules.service";
import { import {
CreateScheduleDto, CreateScheduleDto,
UpdateScheduleDto, UpdateScheduleDto,
QuerySchedulesDto, QuerySchedulesDto,
FindCommonSlotsDto, FindCommonSlotsDto,
} from './dto/schedule.dto'; } from "./dto/schedule.dto";
import { JwtAuthGuard } from '../../common/guards/jwt-auth.guard'; import { JwtAuthGuard } from "../../common/guards/jwt-auth.guard";
import { CurrentUser } from '../../common/decorators/current-user.decorator'; import { CurrentUser } from "../../common/decorators/current-user.decorator";
@ApiTags('schedules') @ApiTags("schedules")
@ApiBearerAuth() @ApiBearerAuth()
@UseGuards(JwtAuthGuard) @UseGuards(JwtAuthGuard)
@Controller('schedules') @Controller("schedules")
export class SchedulesController { export class SchedulesController {
constructor(private readonly schedulesService: SchedulesService) {} constructor(private readonly schedulesService: SchedulesService) {}
@Post() @Post()
@ApiOperation({ summary: '创建排班' }) @ApiOperation({ summary: "创建排班" })
@ApiResponse({ status: 201, description: '创建成功' }) @ApiResponse({ status: 201, description: "创建成功" })
async create( async create(
@CurrentUser('id') userId: string, @CurrentUser("id") userId: string,
@Body() createDto: CreateScheduleDto, @Body() createDto: CreateScheduleDto,
) { ) {
return this.schedulesService.create(userId, createDto); return this.schedulesService.create(userId, createDto);
} }
@Get() @Get()
@ApiOperation({ summary: '获取排班列表' }) @ApiOperation({ summary: "获取排班列表" })
@ApiResponse({ status: 200, description: '获取成功' }) @ApiResponse({ status: 200, description: "获取成功" })
@ApiQuery({ name: 'groupId', required: false, description: '小组ID' }) @ApiQuery({ name: "groupId", required: false, description: "小组ID" })
@ApiQuery({ name: 'userId', required: false, description: '用户ID' }) @ApiQuery({ name: "userId", required: false, description: "用户ID" })
@ApiQuery({ name: 'startTime', required: false, description: '开始时间' }) @ApiQuery({ name: "startTime", required: false, description: "开始时间" })
@ApiQuery({ name: 'endTime', required: false, description: '结束时间' }) @ApiQuery({ name: "endTime", required: false, description: "结束时间" })
@ApiQuery({ name: 'page', required: false, description: '页码' }) @ApiQuery({ name: "page", required: false, description: "页码" })
@ApiQuery({ name: 'limit', required: false, description: '每页数量' }) @ApiQuery({ name: "limit", required: false, description: "每页数量" })
async findAll( async findAll(
@CurrentUser('id') userId: string, @CurrentUser("id") userId: string,
@Query() queryDto: QuerySchedulesDto, @Query() queryDto: QuerySchedulesDto,
) { ) {
return this.schedulesService.findAll(userId, queryDto); return this.schedulesService.findAll(userId, queryDto);
} }
@Post('common-slots') @Post("common-slots")
@ApiOperation({ summary: '查找共同空闲时间' }) @ApiOperation({ summary: "查找共同空闲时间" })
@ApiResponse({ status: 200, description: '查询成功' }) @ApiResponse({ status: 200, description: "查询成功" })
async findCommonSlots( async findCommonSlots(
@CurrentUser('id') userId: string, @CurrentUser("id") userId: string,
@Body() findDto: FindCommonSlotsDto, @Body() findDto: FindCommonSlotsDto,
) { ) {
return this.schedulesService.findCommonSlots(userId, findDto); return this.schedulesService.findCommonSlots(userId, findDto);
} }
@Get(':id') @Get(":id")
@ApiOperation({ summary: '获取排班详情' }) @ApiOperation({ summary: "获取排班详情" })
@ApiResponse({ status: 200, description: '获取成功' }) @ApiResponse({ status: 200, description: "获取成功" })
async findOne(@Param('id') id: string) { async findOne(@Param("id") id: string) {
return this.schedulesService.findOne(id); return this.schedulesService.findOne(id);
} }
@Put(':id') @Put(":id")
@ApiOperation({ summary: '更新排班' }) @ApiOperation({ summary: "更新排班" })
@ApiResponse({ status: 200, description: '更新成功' }) @ApiResponse({ status: 200, description: "更新成功" })
async update( async update(
@CurrentUser('id') userId: string, @CurrentUser("id") userId: string,
@Param('id') id: string, @Param("id") id: string,
@Body() updateDto: UpdateScheduleDto, @Body() updateDto: UpdateScheduleDto,
) { ) {
return this.schedulesService.update(userId, id, updateDto); return this.schedulesService.update(userId, id, updateDto);
} }
@Delete(':id') @Delete(":id")
@ApiOperation({ summary: '删除排班' }) @ApiOperation({ summary: "删除排班" })
@ApiResponse({ status: 200, description: '删除成功' }) @ApiResponse({ status: 200, description: "删除成功" })
async remove( async remove(@CurrentUser("id") userId: string, @Param("id") id: string) {
@CurrentUser('id') userId: string,
@Param('id') id: string,
) {
return this.schedulesService.remove(userId, id); return this.schedulesService.remove(userId, id);
} }
} }

View File

@@ -1,10 +1,10 @@
import { Module } from '@nestjs/common'; import { Module } from "@nestjs/common";
import { TypeOrmModule } from '@nestjs/typeorm'; import { TypeOrmModule } from "@nestjs/typeorm";
import { SchedulesService } from './schedules.service'; import { SchedulesService } from "./schedules.service";
import { SchedulesController } from './schedules.controller'; import { SchedulesController } from "./schedules.controller";
import { Schedule } from '../../entities/schedule.entity'; import { Schedule } from "../../entities/schedule.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";
@Module({ @Module({
imports: [TypeOrmModule.forFeature([Schedule, Group, GroupMember])], imports: [TypeOrmModule.forFeature([Schedule, Group, GroupMember])],

View File

@@ -1,49 +1,49 @@
import { Test, TestingModule } from '@nestjs/testing'; import { Test, TestingModule } from "@nestjs/testing";
import { getRepositoryToken } from '@nestjs/typeorm'; import { getRepositoryToken } from "@nestjs/typeorm";
import { import {
NotFoundException, NotFoundException,
ForbiddenException, ForbiddenException,
BadRequestException, BadRequestException,
} from '@nestjs/common'; } from "@nestjs/common";
import { SchedulesService } from './schedules.service'; import { SchedulesService } from "./schedules.service";
import { Schedule } from '../../entities/schedule.entity'; import { Schedule } from "../../entities/schedule.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 { TimeSlotDto } from './dto/schedule.dto'; import { TimeSlotDto } from "./dto/schedule.dto";
describe('SchedulesService', () => { describe("SchedulesService", () => {
let service: SchedulesService; let service: SchedulesService;
let mockScheduleRepository: any; let mockScheduleRepository: any;
let mockGroupRepository: any; let mockGroupRepository: any;
let mockGroupMemberRepository: any; let mockGroupMemberRepository: any;
const mockUser = { id: 'user-1', username: 'testuser' }; const mockUser = { id: "user-1", username: "testuser" };
const mockGroup = { id: 'group-1', name: '测试小组', isActive: true }; const mockGroup = { id: "group-1", name: "测试小组", isActive: true };
const mockMembership = { const mockMembership = {
id: 'member-1', id: "member-1",
userId: 'user-1', userId: "user-1",
groupId: 'group-1', groupId: "group-1",
role: 'member', role: "member",
isActive: true, isActive: true,
}; };
const mockTimeSlots: TimeSlotDto[] = [ const mockTimeSlots: TimeSlotDto[] = [
{ {
startTime: new Date('2024-01-20T19:00:00Z'), startTime: new Date("2024-01-20T19:00:00Z"),
endTime: new Date('2024-01-20T21:00:00Z'), endTime: new Date("2024-01-20T21:00:00Z"),
note: '晚上空闲', note: "晚上空闲",
}, },
{ {
startTime: new Date('2024-01-21T14:00:00Z'), startTime: new Date("2024-01-21T14:00:00Z"),
endTime: new Date('2024-01-21T17:00:00Z'), endTime: new Date("2024-01-21T17:00:00Z"),
note: '下午空闲', note: "下午空闲",
}, },
]; ];
const mockSchedule = { const mockSchedule = {
id: 'schedule-1', id: "schedule-1",
userId: 'user-1', userId: "user-1",
groupId: 'group-1', groupId: "group-1",
availableSlots: mockTimeSlots, availableSlots: mockTimeSlots,
createdAt: new Date(), createdAt: new Date(),
updatedAt: new Date(), updatedAt: new Date(),
@@ -89,8 +89,8 @@ describe('SchedulesService', () => {
service = module.get<SchedulesService>(SchedulesService); service = module.get<SchedulesService>(SchedulesService);
}); });
describe('create', () => { describe("create", () => {
it('应该成功创建排班', async () => { it("应该成功创建排班", async () => {
mockGroupRepository.findOne.mockResolvedValue(mockGroup); mockGroupRepository.findOne.mockResolvedValue(mockGroup);
mockGroupMemberRepository.findOne.mockResolvedValue(mockMembership); mockGroupMemberRepository.findOne.mockResolvedValue(mockMembership);
mockScheduleRepository.create.mockReturnValue(mockSchedule); mockScheduleRepository.create.mockReturnValue(mockSchedule);
@@ -101,66 +101,66 @@ describe('SchedulesService', () => {
group: mockGroup, group: mockGroup,
}); });
const result = await service.create('user-1', { const result = await service.create("user-1", {
groupId: 'group-1', groupId: "group-1",
title: '测试排班', title: "测试排班",
availableSlots: mockTimeSlots, availableSlots: mockTimeSlots,
}); });
expect(result).toHaveProperty('id'); expect(result).toHaveProperty("id");
expect(mockScheduleRepository.save).toHaveBeenCalled(); expect(mockScheduleRepository.save).toHaveBeenCalled();
}); });
it('应该在小组不存在时抛出异常', async () => { it("应该在小组不存在时抛出异常", async () => {
mockGroupRepository.findOne.mockResolvedValue(null); mockGroupRepository.findOne.mockResolvedValue(null);
await expect( await expect(
service.create('user-1', { service.create("user-1", {
groupId: 'group-1', groupId: "group-1",
title: '测试排班', title: "测试排班",
availableSlots: mockTimeSlots, availableSlots: mockTimeSlots,
}), }),
).rejects.toThrow(NotFoundException); ).rejects.toThrow(NotFoundException);
}); });
it('应该在用户不在小组中时抛出异常', async () => { it("应该在用户不在小组中时抛出异常", async () => {
mockGroupRepository.findOne.mockResolvedValue(mockGroup); mockGroupRepository.findOne.mockResolvedValue(mockGroup);
mockGroupMemberRepository.findOne.mockResolvedValue(null); mockGroupMemberRepository.findOne.mockResolvedValue(null);
await expect( await expect(
service.create('user-1', { service.create("user-1", {
groupId: 'group-1', groupId: "group-1",
title: '测试排班', title: "测试排班",
availableSlots: mockTimeSlots, availableSlots: mockTimeSlots,
}), }),
).rejects.toThrow(ForbiddenException); ).rejects.toThrow(ForbiddenException);
}); });
it('应该在时间段为空时抛出异常', async () => { it("应该在时间段为空时抛出异常", async () => {
mockGroupRepository.findOne.mockResolvedValue(mockGroup); mockGroupRepository.findOne.mockResolvedValue(mockGroup);
mockGroupMemberRepository.findOne.mockResolvedValue(mockMembership); mockGroupMemberRepository.findOne.mockResolvedValue(mockMembership);
await expect( await expect(
service.create('user-1', { service.create("user-1", {
groupId: 'group-1', groupId: "group-1",
title: '测试排班', title: "测试排班",
availableSlots: [], availableSlots: [],
}), }),
).rejects.toThrow(BadRequestException); ).rejects.toThrow(BadRequestException);
}); });
it('应该在时间段无效时抛出异常', async () => { it("应该在时间段无效时抛出异常", async () => {
mockGroupRepository.findOne.mockResolvedValue(mockGroup); mockGroupRepository.findOne.mockResolvedValue(mockGroup);
mockGroupMemberRepository.findOne.mockResolvedValue(mockMembership); mockGroupMemberRepository.findOne.mockResolvedValue(mockMembership);
await expect( await expect(
service.create('user-1', { service.create("user-1", {
groupId: 'group-1', groupId: "group-1",
title: '测试排班', title: "测试排班",
availableSlots: [ availableSlots: [
{ {
startTime: new Date('2024-01-20T21:00:00Z'), startTime: new Date("2024-01-20T21:00:00Z"),
endTime: new Date('2024-01-20T19:00:00Z'), // 结束时间早于开始时间 endTime: new Date("2024-01-20T19:00:00Z"), // 结束时间早于开始时间
}, },
], ],
}), }),
@@ -168,106 +168,106 @@ describe('SchedulesService', () => {
}); });
}); });
describe('findAll', () => { describe("findAll", () => {
it('应该成功获取排班列表', async () => { it("应该成功获取排班列表", async () => {
const mockQueryBuilder = { const mockQueryBuilder = {
leftJoinAndSelect: jest.fn().mockReturnThis(), leftJoinAndSelect: jest.fn().mockReturnThis(),
andWhere: jest.fn().mockReturnThis(), andWhere: jest.fn().mockReturnThis(),
orderBy: jest.fn().mockReturnThis(), orderBy: jest.fn().mockReturnThis(),
skip: jest.fn().mockReturnThis(), skip: jest.fn().mockReturnThis(),
take: jest.fn().mockReturnThis(), take: jest.fn().mockReturnThis(),
getManyAndCount: jest getManyAndCount: jest.fn().mockResolvedValue([[mockSchedule], 1]),
.fn()
.mockResolvedValue([[mockSchedule], 1]),
}; };
mockScheduleRepository.createQueryBuilder.mockReturnValue(mockQueryBuilder); mockScheduleRepository.createQueryBuilder.mockReturnValue(
mockQueryBuilder,
);
mockGroupMemberRepository.findOne.mockResolvedValue(mockMembership); mockGroupMemberRepository.findOne.mockResolvedValue(mockMembership);
const result = await service.findAll('user-1', { const result = await service.findAll("user-1", {
groupId: 'group-1', groupId: "group-1",
page: 1, page: 1,
limit: 10, limit: 10,
}); });
expect(result).toHaveProperty('items'); expect(result).toHaveProperty("items");
expect(result).toHaveProperty('total'); expect(result).toHaveProperty("total");
expect(result.items).toHaveLength(1); expect(result.items).toHaveLength(1);
expect(result.total).toBe(1); expect(result.total).toBe(1);
}); });
it('应该在指定小组且用户不在小组时抛出异常', async () => { it("应该在指定小组且用户不在小组时抛出异常", async () => {
const mockQueryBuilder = { const mockQueryBuilder = {
leftJoinAndSelect: jest.fn().mockReturnThis(), leftJoinAndSelect: jest.fn().mockReturnThis(),
andWhere: jest.fn().mockReturnThis(), andWhere: jest.fn().mockReturnThis(),
orderBy: jest.fn().mockReturnThis(), orderBy: jest.fn().mockReturnThis(),
skip: jest.fn().mockReturnThis(), skip: jest.fn().mockReturnThis(),
take: jest.fn().mockReturnThis(), take: jest.fn().mockReturnThis(),
getManyAndCount: jest getManyAndCount: jest.fn().mockResolvedValue([[mockSchedule], 1]),
.fn()
.mockResolvedValue([[mockSchedule], 1]),
}; };
mockScheduleRepository.createQueryBuilder.mockReturnValue(mockQueryBuilder); mockScheduleRepository.createQueryBuilder.mockReturnValue(
mockQueryBuilder,
);
mockGroupMemberRepository.findOne.mockResolvedValue(null); mockGroupMemberRepository.findOne.mockResolvedValue(null);
await expect( await expect(
service.findAll('user-1', { service.findAll("user-1", {
groupId: 'group-1', groupId: "group-1",
}), }),
).rejects.toThrow(ForbiddenException); ).rejects.toThrow(ForbiddenException);
}); });
it('应该在无小组ID时返回用户所在所有小组的排班', async () => { it("应该在无小组ID时返回用户所在所有小组的排班", async () => {
const mockQueryBuilder = { const mockQueryBuilder = {
leftJoinAndSelect: jest.fn().mockReturnThis(), leftJoinAndSelect: jest.fn().mockReturnThis(),
andWhere: jest.fn().mockReturnThis(), andWhere: jest.fn().mockReturnThis(),
orderBy: jest.fn().mockReturnThis(), orderBy: jest.fn().mockReturnThis(),
skip: jest.fn().mockReturnThis(), skip: jest.fn().mockReturnThis(),
take: jest.fn().mockReturnThis(), take: jest.fn().mockReturnThis(),
getManyAndCount: jest getManyAndCount: jest.fn().mockResolvedValue([[mockSchedule], 1]),
.fn()
.mockResolvedValue([[mockSchedule], 1]),
}; };
mockScheduleRepository.createQueryBuilder.mockReturnValue(mockQueryBuilder); mockScheduleRepository.createQueryBuilder.mockReturnValue(
mockQueryBuilder,
);
mockGroupMemberRepository.find.mockResolvedValue([ mockGroupMemberRepository.find.mockResolvedValue([
{ groupId: 'group-1' }, { groupId: "group-1" },
{ groupId: 'group-2' }, { groupId: "group-2" },
]); ]);
const result = await service.findAll('user-1', {}); const result = await service.findAll("user-1", {});
expect(result.items).toHaveLength(1); expect(result.items).toHaveLength(1);
expect(mockGroupMemberRepository.find).toHaveBeenCalled(); expect(mockGroupMemberRepository.find).toHaveBeenCalled();
}); });
}); });
describe('findOne', () => { describe("findOne", () => {
it('应该成功获取排班详情', async () => { it("应该成功获取排班详情", async () => {
mockScheduleRepository.findOne.mockResolvedValue({ mockScheduleRepository.findOne.mockResolvedValue({
...mockSchedule, ...mockSchedule,
user: mockUser, user: mockUser,
group: mockGroup, group: mockGroup,
}); });
const result = await service.findOne('schedule-1'); const result = await service.findOne("schedule-1");
expect(result).toHaveProperty('id'); expect(result).toHaveProperty("id");
expect(result.id).toBe('schedule-1'); expect(result.id).toBe("schedule-1");
}); });
it('应该在排班不存在时抛出异常', async () => { it("应该在排班不存在时抛出异常", async () => {
mockScheduleRepository.findOne.mockResolvedValue(null); mockScheduleRepository.findOne.mockResolvedValue(null);
await expect(service.findOne('schedule-1')).rejects.toThrow( await expect(service.findOne("schedule-1")).rejects.toThrow(
NotFoundException, NotFoundException,
); );
}); });
}); });
describe('update', () => { describe("update", () => {
it('应该成功更新排班', async () => { it("应该成功更新排班", async () => {
mockScheduleRepository.findOne mockScheduleRepository.findOne
.mockResolvedValueOnce(mockSchedule) .mockResolvedValueOnce(mockSchedule)
.mockResolvedValueOnce({ .mockResolvedValueOnce({
@@ -277,118 +277,122 @@ describe('SchedulesService', () => {
}); });
mockScheduleRepository.save.mockResolvedValue(mockSchedule); mockScheduleRepository.save.mockResolvedValue(mockSchedule);
const result = await service.update('user-1', 'schedule-1', { const result = await service.update("user-1", "schedule-1", {
availableSlots: mockTimeSlots, availableSlots: mockTimeSlots,
}); });
expect(result).toHaveProperty('id'); expect(result).toHaveProperty("id");
expect(mockScheduleRepository.save).toHaveBeenCalled(); expect(mockScheduleRepository.save).toHaveBeenCalled();
}); });
it('应该在排班不存在时抛出异常', async () => { it("应该在排班不存在时抛出异常", async () => {
mockScheduleRepository.findOne.mockResolvedValue(null); mockScheduleRepository.findOne.mockResolvedValue(null);
await expect( await expect(
service.update('user-1', 'schedule-1', { availableSlots: mockTimeSlots }), service.update("user-1", "schedule-1", {
availableSlots: mockTimeSlots,
}),
).rejects.toThrow(NotFoundException); ).rejects.toThrow(NotFoundException);
}); });
it('应该在非创建者更新时抛出异常', async () => { it("应该在非创建者更新时抛出异常", async () => {
mockScheduleRepository.findOne.mockResolvedValue(mockSchedule); mockScheduleRepository.findOne.mockResolvedValue(mockSchedule);
await expect( await expect(
service.update('user-2', 'schedule-1', { availableSlots: mockTimeSlots }), service.update("user-2", "schedule-1", {
availableSlots: mockTimeSlots,
}),
).rejects.toThrow(ForbiddenException); ).rejects.toThrow(ForbiddenException);
}); });
}); });
describe('remove', () => { describe("remove", () => {
it('应该成功删除排班', async () => { it("应该成功删除排班", async () => {
mockScheduleRepository.findOne.mockResolvedValue(mockSchedule); mockScheduleRepository.findOne.mockResolvedValue(mockSchedule);
mockScheduleRepository.remove.mockResolvedValue(mockSchedule); mockScheduleRepository.remove.mockResolvedValue(mockSchedule);
const result = await service.remove('user-1', 'schedule-1'); const result = await service.remove("user-1", "schedule-1");
expect(result).toHaveProperty('message'); expect(result).toHaveProperty("message");
expect(mockScheduleRepository.remove).toHaveBeenCalled(); expect(mockScheduleRepository.remove).toHaveBeenCalled();
}); });
it('应该在排班不存在时抛出异常', async () => { it("应该在排班不存在时抛出异常", async () => {
mockScheduleRepository.findOne.mockResolvedValue(null); mockScheduleRepository.findOne.mockResolvedValue(null);
await expect(service.remove('user-1', 'schedule-1')).rejects.toThrow( await expect(service.remove("user-1", "schedule-1")).rejects.toThrow(
NotFoundException, NotFoundException,
); );
}); });
it('应该在非创建者删除时抛出异常', async () => { it("应该在非创建者删除时抛出异常", async () => {
mockScheduleRepository.findOne.mockResolvedValue(mockSchedule); mockScheduleRepository.findOne.mockResolvedValue(mockSchedule);
await expect(service.remove('user-2', 'schedule-1')).rejects.toThrow( await expect(service.remove("user-2", "schedule-1")).rejects.toThrow(
ForbiddenException, ForbiddenException,
); );
}); });
}); });
describe('findCommonSlots', () => { describe("findCommonSlots", () => {
it('应该成功查找共同空闲时间', async () => { it("应该成功查找共同空闲时间", async () => {
mockGroupMemberRepository.findOne.mockResolvedValue(mockMembership); mockGroupMemberRepository.findOne.mockResolvedValue(mockMembership);
mockScheduleRepository.find.mockResolvedValue([ mockScheduleRepository.find.mockResolvedValue([
{ {
...mockSchedule, ...mockSchedule,
userId: 'user-1', userId: "user-1",
user: { id: 'user-1' }, user: { id: "user-1" },
}, },
{ {
...mockSchedule, ...mockSchedule,
id: 'schedule-2', id: "schedule-2",
userId: 'user-2', userId: "user-2",
user: { id: 'user-2' }, user: { id: "user-2" },
availableSlots: [ availableSlots: [
{ {
startTime: new Date('2024-01-20T19:30:00Z'), startTime: new Date("2024-01-20T19:30:00Z"),
endTime: new Date('2024-01-20T22:00:00Z'), endTime: new Date("2024-01-20T22:00:00Z"),
}, },
], ],
}, },
]); ]);
const result = await service.findCommonSlots('user-1', { const result = await service.findCommonSlots("user-1", {
groupId: 'group-1', groupId: "group-1",
startTime: new Date('2024-01-20T00:00:00Z'), startTime: new Date("2024-01-20T00:00:00Z"),
endTime: new Date('2024-01-22T00:00:00Z'), endTime: new Date("2024-01-22T00:00:00Z"),
minParticipants: 2, minParticipants: 2,
}); });
expect(result).toHaveProperty('commonSlots'); expect(result).toHaveProperty("commonSlots");
expect(result).toHaveProperty('totalParticipants'); expect(result).toHaveProperty("totalParticipants");
expect(result.totalParticipants).toBe(2); expect(result.totalParticipants).toBe(2);
}); });
it('应该在用户不在小组时抛出异常', async () => { it("应该在用户不在小组时抛出异常", async () => {
mockGroupMemberRepository.findOne.mockResolvedValue(null); mockGroupMemberRepository.findOne.mockResolvedValue(null);
await expect( await expect(
service.findCommonSlots('user-1', { service.findCommonSlots("user-1", {
groupId: 'group-1', groupId: "group-1",
startTime: new Date('2024-01-20T00:00:00Z'), startTime: new Date("2024-01-20T00:00:00Z"),
endTime: new Date('2024-01-22T00:00:00Z'), endTime: new Date("2024-01-22T00:00:00Z"),
}), }),
).rejects.toThrow(ForbiddenException); ).rejects.toThrow(ForbiddenException);
}); });
it('应该在没有排班数据时返回空结果', async () => { it("应该在没有排班数据时返回空结果", async () => {
mockGroupMemberRepository.findOne.mockResolvedValue(mockMembership); mockGroupMemberRepository.findOne.mockResolvedValue(mockMembership);
mockScheduleRepository.find.mockResolvedValue([]); mockScheduleRepository.find.mockResolvedValue([]);
const result = await service.findCommonSlots('user-1', { const result = await service.findCommonSlots("user-1", {
groupId: 'group-1', groupId: "group-1",
startTime: new Date('2024-01-20T00:00:00Z'), startTime: new Date("2024-01-20T00:00:00Z"),
endTime: new Date('2024-01-22T00:00:00Z'), endTime: new Date("2024-01-22T00:00:00Z"),
}); });
expect(result.commonSlots).toEqual([]); expect(result.commonSlots).toEqual([]);
expect(result.message).toBe('暂无排班数据'); expect(result.message).toBe("暂无排班数据");
}); });
}); });
}); });

View File

@@ -3,20 +3,23 @@ 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, Between } from 'typeorm'; import { Repository, Between } from "typeorm";
import { Schedule } from '../../entities/schedule.entity'; import { Schedule } from "../../entities/schedule.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 { import {
CreateScheduleDto, CreateScheduleDto,
UpdateScheduleDto, UpdateScheduleDto,
QuerySchedulesDto, QuerySchedulesDto,
FindCommonSlotsDto, FindCommonSlotsDto,
} from './dto/schedule.dto'; } from "./dto/schedule.dto";
import { ErrorCode, ErrorMessage } from '../../common/interfaces/response.interface'; import {
import { PaginationUtil } from '../../common/utils/pagination.util'; ErrorCode,
ErrorMessage,
} from "../../common/interfaces/response.interface";
import { PaginationUtil } from "../../common/utils/pagination.util";
export interface TimeSlot { export interface TimeSlot {
startTime: Date; startTime: Date;
@@ -101,20 +104,20 @@ export class SchedulesService {
const { offset } = PaginationUtil.formatPaginationParams(page, limit); const { offset } = PaginationUtil.formatPaginationParams(page, limit);
const queryBuilder = this.scheduleRepository const queryBuilder = this.scheduleRepository
.createQueryBuilder('schedule') .createQueryBuilder("schedule")
.leftJoinAndSelect('schedule.group', 'group') .leftJoinAndSelect("schedule.group", "group")
.leftJoinAndSelect('schedule.user', 'user'); .leftJoinAndSelect("schedule.user", "user");
// 筛选条件 // 筛选条件
if (groupId) { if (groupId) {
// 验证用户是否在小组中 // 验证用户是否在小组中
await this.checkGroupMembership(userId, groupId); await this.checkGroupMembership(userId, groupId);
queryBuilder.andWhere('schedule.groupId = :groupId', { groupId }); queryBuilder.andWhere("schedule.groupId = :groupId", { groupId });
} else { } else {
// 如果没有指定小组,只返回用户所在小组的排班 // 如果没有指定小组,只返回用户所在小组的排班
const memberGroups = await this.groupMemberRepository.find({ const memberGroups = await this.groupMemberRepository.find({
where: { userId, isActive: true }, where: { userId, isActive: true },
select: ['groupId'], select: ["groupId"],
}); });
const groupIds = memberGroups.map((m) => m.groupId); const groupIds = memberGroups.map((m) => m.groupId);
if (groupIds.length === 0) { if (groupIds.length === 0) {
@@ -126,23 +129,28 @@ export class SchedulesService {
totalPages: 0, totalPages: 0,
}; };
} }
queryBuilder.andWhere('schedule.groupId IN (:...groupIds)', { groupIds }); queryBuilder.andWhere("schedule.groupId IN (:...groupIds)", { groupIds });
} }
if (targetUserId) { if (targetUserId) {
queryBuilder.andWhere('schedule.userId = :userId', { userId: targetUserId }); queryBuilder.andWhere("schedule.userId = :userId", {
userId: targetUserId,
});
} }
if (startTime && endTime) { if (startTime && endTime) {
queryBuilder.andWhere('schedule.createdAt BETWEEN :startTime AND :endTime', { queryBuilder.andWhere(
"schedule.createdAt BETWEEN :startTime AND :endTime",
{
startTime: new Date(startTime), startTime: new Date(startTime),
endTime: new Date(endTime), endTime: new Date(endTime),
}); },
);
} }
// 分页 // 分页
const [items, total] = await queryBuilder const [items, total] = await queryBuilder
.orderBy('schedule.createdAt', 'DESC') .orderBy("schedule.createdAt", "DESC")
.skip(offset) .skip(offset)
.take(limit) .take(limit)
.getManyAndCount(); .getManyAndCount();
@@ -168,13 +176,13 @@ export class SchedulesService {
async findOne(id: string) { async findOne(id: string) {
const schedule = await this.scheduleRepository.findOne({ const schedule = await this.scheduleRepository.findOne({
where: { id }, where: { id },
relations: ['group', 'user'], relations: ["group", "user"],
}); });
if (!schedule) { if (!schedule) {
throw new NotFoundException({ throw new NotFoundException({
code: ErrorCode.NOT_FOUND, code: ErrorCode.NOT_FOUND,
message: '排班不存在', message: "排班不存在",
}); });
} }
@@ -195,7 +203,7 @@ export class SchedulesService {
if (!schedule) { if (!schedule) {
throw new NotFoundException({ throw new NotFoundException({
code: ErrorCode.NOT_FOUND, code: ErrorCode.NOT_FOUND,
message: '排班不存在', message: "排班不存在",
}); });
} }
@@ -229,7 +237,7 @@ export class SchedulesService {
if (!schedule) { if (!schedule) {
throw new NotFoundException({ throw new NotFoundException({
code: ErrorCode.NOT_FOUND, code: ErrorCode.NOT_FOUND,
message: '排班不存在', message: "排班不存在",
}); });
} }
@@ -243,7 +251,7 @@ export class SchedulesService {
await this.scheduleRepository.remove(schedule); await this.scheduleRepository.remove(schedule);
return { message: '排班已删除' }; return { message: "排班已删除" };
} }
/** /**
@@ -258,13 +266,13 @@ export class SchedulesService {
// 获取时间范围内的所有排班 // 获取时间范围内的所有排班
const schedules = await this.scheduleRepository.find({ const schedules = await this.scheduleRepository.find({
where: { groupId }, where: { groupId },
relations: ['user'], relations: ["user"],
}); });
if (schedules.length === 0) { if (schedules.length === 0) {
return { return {
commonSlots: [], commonSlots: [],
message: '暂无排班数据', message: "暂无排班数据",
}; };
} }
@@ -302,7 +310,11 @@ export class SchedulesService {
userSlots: Map<string, TimeSlot[]>, userSlots: Map<string, TimeSlot[]>,
minParticipants: number, minParticipants: number,
): CommonSlot[] { ): CommonSlot[] {
const allSlots: Array<{ time: Date; userId: string; type: 'start' | 'end' }> = []; const allSlots: Array<{
time: Date;
userId: string;
type: "start" | "end";
}> = [];
// 收集所有时间点 // 收集所有时间点
userSlots.forEach((slots, userId) => { userSlots.forEach((slots, userId) => {
@@ -310,12 +322,12 @@ export class SchedulesService {
allSlots.push({ allSlots.push({
time: new Date(slot.startTime), time: new Date(slot.startTime),
userId, userId,
type: 'start', type: "start",
}); });
allSlots.push({ allSlots.push({
time: new Date(slot.endTime), time: new Date(slot.endTime),
userId, userId,
type: 'end', type: "end",
}); });
}); });
}); });
@@ -341,7 +353,7 @@ export class SchedulesService {
} }
} }
if (event.type === 'start') { if (event.type === "start") {
activeUsers.add(event.userId); activeUsers.add(event.userId);
} else { } else {
activeUsers.delete(event.userId); activeUsers.delete(event.userId);
@@ -389,7 +401,7 @@ export class SchedulesService {
if (slots.length === 0) { if (slots.length === 0) {
throw new BadRequestException({ throw new BadRequestException({
code: ErrorCode.PARAM_ERROR, code: ErrorCode.PARAM_ERROR,
message: '至少需要一个时间段', message: "至少需要一个时间段",
}); });
} }

Some files were not shown because too many files have changed in this diff Show More