feat: 完善认证系统和前端 Demo 页面
- 添加图形验证码模块(登录/注册需验证码) - 添加 refresh token 机制和 API 接口 - 认证响应返回 token 有效期 - 添加 Redis 模块支持验证码存储 - 添加前端验证码组件和用户管理 Demo 页面 - 添加 CRUD 基类和分页响应 DTO mixin - 添加请求/响应加密模块(AES-256-GCM) - 完善共享类型定义和前后端类型一致性 - 更新 CLAUDE.md 文档 Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
169
CLAUDE.md
169
CLAUDE.md
@@ -9,7 +9,7 @@ Seclusion 是一个基于 Next.js + NestJS 的 Monorepo 项目模板,使用 pn
|
||||
## Commands
|
||||
|
||||
```bash
|
||||
# 启动数据库 (PostgreSQL)
|
||||
# 启动数据库 (PostgreSQL + Redis)
|
||||
docker compose -f deploy/docker-compose.yml up -d
|
||||
|
||||
# 开发(同时启动前后端)
|
||||
@@ -28,7 +28,7 @@ pnpm test
|
||||
cd apps/api && pnpm test:watch # 单个测试文件监听
|
||||
cd apps/api && pnpm test:cov # 测试覆盖率报告
|
||||
|
||||
# 数据库(使用 dotenv-cli 加载 .env 和 .env.local)
|
||||
# 数据库
|
||||
pnpm db:generate # 生成 Prisma Client
|
||||
pnpm db:push # 推送 schema 到数据库
|
||||
pnpm db:migrate # 运行迁移
|
||||
@@ -41,7 +41,7 @@ cd apps/api && pnpm db:studio # 打开 Prisma Studio
|
||||
|
||||
- **apps/web** - Next.js 16 前端 (端口 3000),使用 React 19
|
||||
- **apps/api** - NestJS 10 后端 (端口 4000,API 文档: /api/docs)
|
||||
- **packages/shared** - 共享类型定义和工具函数(使用 lodash-es)
|
||||
- **packages/shared** - 共享类型定义、工具函数和加密模块
|
||||
- **packages/eslint-config** - 共享 ESLint 9 flat config 配置
|
||||
- **packages/typescript-config** - 共享 TypeScript 配置
|
||||
|
||||
@@ -50,10 +50,12 @@ cd apps/api && pnpm db:studio # 打开 Prisma Studio
|
||||
NestJS 采用模块化架构:
|
||||
|
||||
- **PrismaModule** - 全局数据库服务,内置软删除扩展
|
||||
- **CryptoModule** - 全局加密服务,AES-256-GCM 请求/响应加密
|
||||
- **AuthModule** - JWT 认证(注册、登录、token 验证)
|
||||
- **UserModule** - 用户 CRUD
|
||||
- **UserModule** - 用户 CRUD,继承 CrudService 基类
|
||||
- **CaptchaModule** - 验证码服务,支持多场景验证
|
||||
|
||||
认证流程:使用 `@Public()` 装饰器标记公开接口,其他接口需要 JWT Bearer Token。使用 `@CurrentUser()` 装饰器获取当前登录用户信息。
|
||||
认证流程:使用 `@Public()` 装饰器标记公开接口,其他接口需要 JWT Bearer Token。使用 `@CurrentUser()` 装饰器获取当前登录用户信息(类型为 `AuthUser`)。
|
||||
|
||||
### 软删除机制
|
||||
|
||||
@@ -65,33 +67,160 @@ PrismaService 使用 `$extends` 实现底层自动软删除:
|
||||
|
||||
启用软删除的模型在 `apps/api/src/prisma/prisma.service.ts` 的 `SOFT_DELETE_MODELS` 数组中配置。
|
||||
|
||||
### API 接口
|
||||
### 通信加密
|
||||
|
||||
- `POST /auth/register` - 用户注册
|
||||
- `POST /auth/login` - 用户登录
|
||||
- `GET /auth/me` - 获取当前用户(需认证)
|
||||
- `GET /users` / `GET /users/:id` - 获取用户(需认证)
|
||||
- `GET /users/deleted` - 获取已删除用户列表(需认证)
|
||||
- `PATCH /users/:id` / `DELETE /users/:id` - 更新/删除用户(需认证)
|
||||
- `PATCH /users/:id/restore` - 恢复已删除用户(需认证)
|
||||
基于 AES-256-GCM 的请求/响应 Body 加密,通过 `ENABLE_ENCRYPTION` 环境变量控制:
|
||||
|
||||
- **后端**: `CryptoModule` 提供 `CryptoService` 和 `EncryptionInterceptor`
|
||||
- **前端**: `apps/web/src/lib/crypto.ts` 封装加密客户端
|
||||
- **共享**: `packages/shared/src/crypto/` 提供跨平台加密实现
|
||||
- **跳过加密**: 使用 `@SkipEncryption()` 装饰器标记不需要加密的接口
|
||||
|
||||
### CRUD 基类
|
||||
|
||||
`apps/api/src/common/crud/` 提供通用 CRUD 服务和 DTO:
|
||||
|
||||
- `CrudService<T>` - 泛型基类,提供分页查询、软删除、恢复等方法
|
||||
- `PaginationQueryDto` - 分页查询参数 DTO
|
||||
- `createPaginatedResponseDto(ItemDto)` - 分页响应 DTO 工厂函数
|
||||
|
||||
分页响应 DTO 使用示例:
|
||||
```typescript
|
||||
import { createPaginatedResponseDto } from '@/common/crud';
|
||||
|
||||
export class UserResponseDto { ... }
|
||||
export class PaginatedUserResponseDto extends createPaginatedResponseDto(UserResponseDto) {}
|
||||
```
|
||||
|
||||
### Swagger 文档规范
|
||||
|
||||
所有 API 接口必须完整定义 Swagger 文档:
|
||||
|
||||
**Controller 装饰器要求:**
|
||||
- `@ApiTags()` - 接口分组标签
|
||||
- `@ApiOperation()` - 接口描述
|
||||
- `@ApiOkResponse()` / `@ApiCreatedResponse()` - 成功响应类型
|
||||
- `@ApiBearerAuth()` - 需要认证的接口(非 `@Public()` 接口)
|
||||
|
||||
**DTO 定义要求:**
|
||||
- 所有请求和响应都必须定义对应的 DTO class
|
||||
- 使用 `@ApiProperty()` / `@ApiPropertyOptional()` 装饰每个字段
|
||||
- 必须包含 `example` 和 `description` 属性
|
||||
|
||||
**示例:**
|
||||
```typescript
|
||||
// dto/example.dto.ts
|
||||
export class ExampleResponseDto implements ExampleResponse {
|
||||
@ApiProperty({ example: 'clxxx', description: '记录 ID' })
|
||||
id: string;
|
||||
}
|
||||
|
||||
// example.controller.ts
|
||||
@ApiTags('示例')
|
||||
@ApiBearerAuth()
|
||||
@Controller('example')
|
||||
export class ExampleController {
|
||||
@Get(':id')
|
||||
@ApiOperation({ summary: '获取示例详情' })
|
||||
@ApiOkResponse({ type: ExampleResponseDto, description: '示例详情' })
|
||||
findOne(@Param('id') id: string): Promise<ExampleResponseDto> {
|
||||
return this.service.findOne(id);
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### 共享包使用
|
||||
|
||||
```typescript
|
||||
import type { User, ApiResponse } from '@seclusion/shared';
|
||||
// 类型导入
|
||||
import type { User, AuthUser, UserResponse, TokenPayload } from '@seclusion/shared';
|
||||
import type { ApiResponse, PaginatedResponse } from '@seclusion/shared';
|
||||
|
||||
// 工具函数
|
||||
import { formatDate, generateId } from '@seclusion/shared';
|
||||
|
||||
// 加密模块
|
||||
import { createBrowserCrypto, createNodeCrypto } from '@seclusion/shared/crypto';
|
||||
|
||||
// 常量(使用 const + type 模式)
|
||||
import { CaptchaScene } from '@seclusion/shared';
|
||||
```
|
||||
|
||||
**注意**: `packages/shared` 中的工具函数应优先使用 lodash-es 实现。
|
||||
|
||||
### 前后端共享类型设计规范
|
||||
|
||||
为避免类型重复定义和前后端不一致,遵循以下设计原则:
|
||||
|
||||
**类型定义位置**
|
||||
|
||||
| 类型 | 定义位置 | 说明 |
|
||||
|------|----------|------|
|
||||
| 枚举/常量 | `packages/shared` | 使用 `const + type` 模式 |
|
||||
| 接口类型 | `packages/shared` | 纯类型定义,无装饰器 |
|
||||
| 后端 DTO | `apps/api` | 实现共享接口,添加 Swagger/验证装饰器 |
|
||||
| 前端特有类型 | `apps/web/src/types/` | 组件 Props、表单状态等 |
|
||||
|
||||
**枚举定义方式(使用 const + type 替代 enum)**
|
||||
```typescript
|
||||
// packages/shared/src/types/index.ts
|
||||
export const CaptchaScene = {
|
||||
LOGIN: 'login',
|
||||
REGISTER: 'register',
|
||||
} as const;
|
||||
export type CaptchaScene = (typeof CaptchaScene)[keyof typeof CaptchaScene];
|
||||
```
|
||||
|
||||
**后端 DTO 实现共享接口**
|
||||
```typescript
|
||||
// apps/api/src/xxx/dto/example.dto.ts
|
||||
import type { ExampleResponse } from '@seclusion/shared';
|
||||
|
||||
export class ExampleResponseDto implements ExampleResponse {
|
||||
@ApiProperty({ description: '示例字段', example: 'value' })
|
||||
field: string;
|
||||
}
|
||||
```
|
||||
|
||||
**新增共享类型检查清单**
|
||||
- [ ] 类型定义在 `packages/shared/src/types/` 中
|
||||
- [ ] 枚举使用 `const + type` 模式
|
||||
- [ ] 后端 DTO 使用 `implements` 实现共享接口
|
||||
- [ ] 前端直接从 `@seclusion/shared` 导入使用
|
||||
|
||||
## Environment Variables
|
||||
|
||||
后端环境变量文件优先级:`.env.local` > `.env`(NestJS ConfigModule 和 Prisma 脚本均支持)
|
||||
环境变量在各应用目录下独立配置:
|
||||
|
||||
| 位置 | 文件 | 用途 | 是否提交 |
|
||||
|------|------|------|---------|
|
||||
| apps/api | `.env.example` | 后端配置模板 | ✅ |
|
||||
| apps/api | `.env` | 后端默认配置 | ✅ |
|
||||
| apps/api | `.env.local` | 后端本地覆盖(敏感信息) | ❌ |
|
||||
| apps/web | `.env` | 前端默认配置 | ✅ |
|
||||
| apps/web | `.env.local` | 前端本地覆盖 | ❌ |
|
||||
|
||||
### 加密相关配置
|
||||
|
||||
```bash
|
||||
# apps/api/.env
|
||||
ENABLE_ENCRYPTION=false
|
||||
ENCRYPTION_KEY=<32字节Base64密钥>
|
||||
|
||||
# apps/web/.env
|
||||
NEXT_PUBLIC_ENABLE_ENCRYPTION=false
|
||||
NEXT_PUBLIC_ENCRYPTION_KEY=<与后端相同的密钥>
|
||||
```
|
||||
|
||||
生成密钥: `openssl rand -base64 32`
|
||||
|
||||
## Key Files
|
||||
|
||||
- `deploy/docker-compose.yml` - PostgreSQL 数据库容器配置
|
||||
- `apps/api/prisma/schema.prisma` - 数据库模型定义(使用 PostgreSQL,ID 使用 cuid2)
|
||||
- `apps/api/.env.example` - 后端环境变量模板
|
||||
- `apps/web/.env.local` - 前端环境变量 (NEXT_PUBLIC_API_URL)
|
||||
- `turbo.json` - Turborepo 任务依赖配置
|
||||
- `deploy/docker-compose.yml` - PostgreSQL + Redis 容器配置
|
||||
- `apps/api/prisma/schema.prisma` - 数据库模型定义(PostgreSQL,ID 使用 cuid2)
|
||||
- `apps/api/src/prisma/prisma.service.ts` - Prisma 服务,含软删除扩展
|
||||
- `apps/api/src/common/crud/` - CRUD 基类和分页 DTO
|
||||
- `apps/api/src/common/crypto/` - 后端加密模块
|
||||
- `packages/shared/src/types/` - 共享类型定义
|
||||
- `packages/shared/src/crypto/` - 跨平台加密实现
|
||||
- `apps/web/src/types/` - 前端特有类型定义
|
||||
|
||||
@@ -1,7 +1,17 @@
|
||||
DATABASE_URL="postgresql://dev:dev@localhost:5432/seclusion"
|
||||
|
||||
# ----- Redis 配置 -----
|
||||
REDIS_URL="redis://localhost:6379"
|
||||
|
||||
JWT_SECRET="your-super-secret-key-change-in-production"
|
||||
JWT_EXPIRES_IN="7d"
|
||||
|
||||
PORT=4000
|
||||
NODE_ENV=development
|
||||
|
||||
# ----- 加密配置 -----
|
||||
# 是否启用通信加密
|
||||
ENABLE_ENCRYPTION=false
|
||||
# AES-256-GCM 加密密钥 (32 字节 Base64 编码)
|
||||
# 开发环境默认密钥 (仅用于开发测试,生产环境必须更换)
|
||||
ENCRYPTION_KEY=dGhpc2lzYXRlc3RrZXlmb3JkZXZlbG9wbWVudG9ubHk
|
||||
|
||||
@@ -11,6 +11,12 @@
|
||||
# 本地开发使用 docker compose 启动: docker compose -f deploy/docker-compose.yml up -d
|
||||
DATABASE_URL="postgresql://dev:dev@localhost:5432/seclusion"
|
||||
|
||||
# ----- Redis 配置 -----
|
||||
# Redis 连接字符串
|
||||
# 格式: redis://[用户名:密码@]主机:端口[/数据库号]
|
||||
# 本地开发使用 docker compose 启动: docker compose -f deploy/docker-compose.yml up -d
|
||||
REDIS_URL="redis://localhost:6379"
|
||||
|
||||
# ----- JWT 配置 -----
|
||||
# JWT 签名密钥(生产环境必须修改为强随机字符串)
|
||||
JWT_SECRET="your-super-secret-key-change-in-production"
|
||||
@@ -22,3 +28,11 @@ JWT_EXPIRES_IN="7d"
|
||||
PORT=4000
|
||||
# 运行环境: development | production | test
|
||||
NODE_ENV=development
|
||||
|
||||
# ----- 加密配置 -----
|
||||
# 是否启用通信加密 (true/false)
|
||||
ENABLE_ENCRYPTION=false
|
||||
# AES-256-GCM 加密密钥 (32 字节 Base64 编码)
|
||||
# 生成方式: openssl rand -base64 32
|
||||
# 注意: 生产环境必须使用强随机密钥,且前后端保持一致
|
||||
ENCRYPTION_KEY=
|
||||
|
||||
@@ -31,10 +31,13 @@
|
||||
"bcrypt": "^5.1.1",
|
||||
"class-transformer": "^0.5.1",
|
||||
"class-validator": "^0.14.1",
|
||||
"ioredis": "^5.9.2",
|
||||
"nanoid": "^5.1.6",
|
||||
"passport": "^0.7.0",
|
||||
"passport-jwt": "^4.0.1",
|
||||
"reflect-metadata": "^0.2.2",
|
||||
"rxjs": "^7.8.1"
|
||||
"rxjs": "^7.8.1",
|
||||
"svg-captcha": "^1.4.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@nestjs/cli": "^10.4.9",
|
||||
|
||||
@@ -1,7 +1,8 @@
|
||||
import { Controller, Get } from '@nestjs/common';
|
||||
import { ApiTags, ApiOperation } from '@nestjs/swagger';
|
||||
import { ApiOkResponse, ApiOperation, ApiTags } from '@nestjs/swagger';
|
||||
|
||||
import { AppService } from './app.service';
|
||||
import { HealthCheckResponseDto } from './health/dto/health.dto';
|
||||
|
||||
@ApiTags('健康检查')
|
||||
@Controller()
|
||||
@@ -10,7 +11,8 @@ export class AppController {
|
||||
|
||||
@Get('health')
|
||||
@ApiOperation({ summary: '健康检查' })
|
||||
healthCheck() {
|
||||
@ApiOkResponse({ type: HealthCheckResponseDto, description: '健康检查结果' })
|
||||
healthCheck(): Promise<HealthCheckResponseDto> {
|
||||
return this.appService.healthCheck();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -4,6 +4,9 @@ import { ConfigModule } from '@nestjs/config';
|
||||
import { AppController } from './app.controller';
|
||||
import { AppService } from './app.service';
|
||||
import { AuthModule } from './auth/auth.module';
|
||||
import { CaptchaModule } from './common/captcha';
|
||||
import { CryptoModule } from './common/crypto';
|
||||
import { RedisModule } from './common/redis';
|
||||
import { PrismaModule } from './prisma/prisma.module';
|
||||
import { UserModule } from './user/user.module';
|
||||
|
||||
@@ -11,9 +14,13 @@ import { UserModule } from './user/user.module';
|
||||
imports: [
|
||||
ConfigModule.forRoot({
|
||||
isGlobal: true,
|
||||
// 优先级:本地 > 应用级
|
||||
envFilePath: ['.env.local', '.env'],
|
||||
}),
|
||||
PrismaModule,
|
||||
RedisModule,
|
||||
CryptoModule,
|
||||
CaptchaModule,
|
||||
AuthModule,
|
||||
UserModule,
|
||||
],
|
||||
|
||||
@@ -1,11 +1,36 @@
|
||||
import { Injectable } from '@nestjs/common';
|
||||
|
||||
import { RedisService } from './common/redis';
|
||||
import { HealthCheckResponseDto } from './health/dto/health.dto';
|
||||
import { PrismaService } from './prisma/prisma.service';
|
||||
|
||||
@Injectable()
|
||||
export class AppService {
|
||||
healthCheck() {
|
||||
constructor(
|
||||
private readonly prismaService: PrismaService,
|
||||
private readonly redisService: RedisService
|
||||
) {}
|
||||
|
||||
async healthCheck(): Promise<HealthCheckResponseDto> {
|
||||
const [dbHealthy, redisHealthy] = await Promise.all([
|
||||
this.prismaService.healthCheck(),
|
||||
this.redisService.healthCheck(),
|
||||
]);
|
||||
|
||||
const allHealthy = dbHealthy && redisHealthy;
|
||||
const allDown = !dbHealthy && !redisHealthy;
|
||||
|
||||
return {
|
||||
status: 'ok',
|
||||
status: allHealthy ? 'ok' : allDown ? 'error' : 'degraded',
|
||||
timestamp: new Date().toISOString(),
|
||||
services: {
|
||||
database: {
|
||||
status: dbHealthy ? 'ok' : 'error',
|
||||
},
|
||||
redis: {
|
||||
status: redisHealthy ? 'ok' : 'error',
|
||||
},
|
||||
},
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,10 +1,11 @@
|
||||
import { Controller, Post, Body, Get, UseGuards } from '@nestjs/common';
|
||||
import { ApiTags, ApiOperation, ApiBearerAuth, ApiBody } from '@nestjs/swagger';
|
||||
import { ApiTags, ApiOperation, ApiBearerAuth, ApiBody, ApiCreatedResponse, ApiOkResponse } from '@nestjs/swagger';
|
||||
import type { AuthUser } from '@seclusion/shared';
|
||||
|
||||
import { AuthService } from './auth.service';
|
||||
import { CurrentUser } from './decorators/current-user.decorator';
|
||||
import { Public } from './decorators/public.decorator';
|
||||
import { RegisterDto, LoginDto } from './dto/auth.dto';
|
||||
import { RegisterDto, LoginDto, RefreshTokenDto, AuthResponseDto, AuthUserDto, RefreshTokenResponseDto } from './dto/auth.dto';
|
||||
import { JwtAuthGuard } from './guards/jwt-auth.guard';
|
||||
|
||||
@ApiTags('认证')
|
||||
@@ -16,6 +17,7 @@ export class AuthController {
|
||||
@Public()
|
||||
@ApiOperation({ summary: '用户注册' })
|
||||
@ApiBody({ type: RegisterDto })
|
||||
@ApiCreatedResponse({ type: AuthResponseDto, description: '注册成功' })
|
||||
register(@Body() dto: RegisterDto) {
|
||||
return this.authService.register(dto);
|
||||
}
|
||||
@@ -24,15 +26,26 @@ export class AuthController {
|
||||
@Public()
|
||||
@ApiOperation({ summary: '用户登录' })
|
||||
@ApiBody({ type: LoginDto })
|
||||
@ApiOkResponse({ type: AuthResponseDto, description: '登录成功' })
|
||||
login(@Body() dto: LoginDto) {
|
||||
return this.authService.login(dto);
|
||||
}
|
||||
|
||||
@Post('refresh')
|
||||
@Public()
|
||||
@ApiOperation({ summary: '刷新访问令牌' })
|
||||
@ApiBody({ type: RefreshTokenDto })
|
||||
@ApiOkResponse({ type: RefreshTokenResponseDto, description: '刷新成功' })
|
||||
refresh(@Body() dto: RefreshTokenDto) {
|
||||
return this.authService.refreshToken(dto);
|
||||
}
|
||||
|
||||
@Get('me')
|
||||
@UseGuards(JwtAuthGuard)
|
||||
@ApiBearerAuth()
|
||||
@ApiOperation({ summary: '获取当前用户信息' })
|
||||
getProfile(@CurrentUser() user: { id: string; email: string; name: string }) {
|
||||
@ApiOkResponse({ type: AuthUserDto, description: '当前用户信息' })
|
||||
getProfile(@CurrentUser() user: AuthUser) {
|
||||
return user;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,19 +1,34 @@
|
||||
import { Injectable, UnauthorizedException, ConflictException } from '@nestjs/common';
|
||||
import { JwtService } from '@nestjs/jwt';
|
||||
import type { TokenPayload, RefreshTokenResponse } from '@seclusion/shared';
|
||||
import * as bcrypt from 'bcrypt';
|
||||
|
||||
import { PrismaService } from '../prisma/prisma.service';
|
||||
import { CaptchaScene, CaptchaService } from '../common/captcha';
|
||||
|
||||
import { RegisterDto, LoginDto } from './dto/auth.dto';
|
||||
import { RegisterDto, LoginDto, RefreshTokenDto } from './dto/auth.dto';
|
||||
|
||||
import { PrismaService } from '@/prisma/prisma.service';
|
||||
|
||||
// Token 有效期配置(秒)
|
||||
const ACCESS_TOKEN_EXPIRES_IN = 5 * 60; // 5 分钟
|
||||
const REFRESH_TOKEN_EXPIRES_IN = 30 * 24 * 60 * 60; // 30 天
|
||||
|
||||
@Injectable()
|
||||
export class AuthService {
|
||||
constructor(
|
||||
private prisma: PrismaService,
|
||||
private jwtService: JwtService
|
||||
private jwtService: JwtService,
|
||||
private captchaService: CaptchaService
|
||||
) {}
|
||||
|
||||
async register(dto: RegisterDto) {
|
||||
// 验证验证码
|
||||
await this.captchaService.verifyOrThrow({
|
||||
captchaId: dto.captchaId,
|
||||
code: dto.captchaCode,
|
||||
scene: CaptchaScene.REGISTER,
|
||||
});
|
||||
|
||||
// 底层自动过滤已删除用户
|
||||
const existingUser = await this.prisma.user.findFirst({
|
||||
where: { email: dto.email },
|
||||
@@ -49,6 +64,13 @@ export class AuthService {
|
||||
}
|
||||
|
||||
async login(dto: LoginDto) {
|
||||
// 验证验证码
|
||||
await this.captchaService.verifyOrThrow({
|
||||
captchaId: dto.captchaId,
|
||||
code: dto.captchaCode,
|
||||
scene: CaptchaScene.LOGIN,
|
||||
});
|
||||
|
||||
// 底层自动过滤已删除用户
|
||||
const user = await this.prisma.user.findFirst({
|
||||
where: { email: dto.email },
|
||||
@@ -79,17 +101,45 @@ export class AuthService {
|
||||
}
|
||||
|
||||
private async generateTokens(userId: string, email: string) {
|
||||
const payload = { sub: userId, email };
|
||||
const payload: Omit<TokenPayload, 'iat' | 'exp'> = { sub: userId, email };
|
||||
|
||||
const accessToken = await this.jwtService.signAsync(payload);
|
||||
const accessToken = await this.jwtService.signAsync(payload, {
|
||||
expiresIn: ACCESS_TOKEN_EXPIRES_IN,
|
||||
});
|
||||
|
||||
const refreshToken = await this.jwtService.signAsync(payload, {
|
||||
expiresIn: '30d',
|
||||
expiresIn: REFRESH_TOKEN_EXPIRES_IN,
|
||||
});
|
||||
|
||||
return {
|
||||
accessToken,
|
||||
refreshToken,
|
||||
accessTokenExpiresIn: ACCESS_TOKEN_EXPIRES_IN,
|
||||
refreshTokenExpiresIn: REFRESH_TOKEN_EXPIRES_IN,
|
||||
};
|
||||
}
|
||||
|
||||
async refreshToken(dto: RefreshTokenDto): Promise<RefreshTokenResponse> {
|
||||
try {
|
||||
// 验证 refresh token
|
||||
const payload = await this.jwtService.verifyAsync<TokenPayload>(dto.refreshToken);
|
||||
|
||||
// 检查用户是否存在
|
||||
const user = await this.prisma.user.findUnique({
|
||||
where: { id: payload.sub },
|
||||
});
|
||||
|
||||
if (!user) {
|
||||
throw new UnauthorizedException('用户不存在');
|
||||
}
|
||||
|
||||
// 生成新的 token 对
|
||||
return this.generateTokens(user.id, user.email);
|
||||
} catch (error) {
|
||||
if (error instanceof UnauthorizedException) {
|
||||
throw error;
|
||||
}
|
||||
throw new UnauthorizedException('刷新令牌无效或已过期');
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,7 +1,15 @@
|
||||
import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger';
|
||||
import { IsEmail, IsString, MinLength, IsOptional } from 'class-validator';
|
||||
import type {
|
||||
RegisterDto as IRegisterDto,
|
||||
LoginDto as ILoginDto,
|
||||
RefreshTokenDto as IRefreshTokenDto,
|
||||
RefreshTokenResponse as IRefreshTokenResponse,
|
||||
AuthUser,
|
||||
AuthResponse,
|
||||
} from '@seclusion/shared';
|
||||
import { IsEmail, IsString, MinLength, IsOptional, IsNotEmpty } from 'class-validator';
|
||||
|
||||
export class RegisterDto {
|
||||
export class RegisterDto implements IRegisterDto {
|
||||
@ApiProperty({ example: 'user@example.com', description: '用户邮箱' })
|
||||
@IsEmail()
|
||||
email: string;
|
||||
@@ -15,9 +23,19 @@ export class RegisterDto {
|
||||
@IsString()
|
||||
@IsOptional()
|
||||
name?: string;
|
||||
|
||||
@ApiProperty({ example: 'cap_abc123xyz456def', description: '验证码 ID' })
|
||||
@IsString()
|
||||
@IsNotEmpty()
|
||||
captchaId: string;
|
||||
|
||||
@ApiProperty({ example: 'ab12', description: '用户输入的验证码' })
|
||||
@IsString()
|
||||
@IsNotEmpty()
|
||||
captchaCode: string;
|
||||
}
|
||||
|
||||
export class LoginDto {
|
||||
export class LoginDto implements ILoginDto {
|
||||
@ApiProperty({ example: 'user@example.com', description: '用户邮箱' })
|
||||
@IsEmail()
|
||||
email: string;
|
||||
@@ -25,4 +43,67 @@ export class LoginDto {
|
||||
@ApiProperty({ example: 'password123', description: '密码' })
|
||||
@IsString()
|
||||
password: string;
|
||||
|
||||
@ApiProperty({ example: 'cap_abc123xyz456def', description: '验证码 ID' })
|
||||
@IsString()
|
||||
@IsNotEmpty()
|
||||
captchaId: string;
|
||||
|
||||
@ApiProperty({ example: 'ab12', description: '用户输入的验证码' })
|
||||
@IsString()
|
||||
@IsNotEmpty()
|
||||
captchaCode: string;
|
||||
}
|
||||
|
||||
/** 认证用户信息 DTO */
|
||||
export class AuthUserDto implements AuthUser {
|
||||
@ApiProperty({ example: 'clxxx123', description: '用户 ID' })
|
||||
id: string;
|
||||
|
||||
@ApiProperty({ example: 'user@example.com', description: '用户邮箱' })
|
||||
email: string;
|
||||
|
||||
@ApiProperty({ example: '张三', description: '用户名称', nullable: true })
|
||||
name: string | null;
|
||||
}
|
||||
|
||||
/** 认证响应 DTO */
|
||||
export class AuthResponseDto implements AuthResponse {
|
||||
@ApiProperty({ example: 'eyJhbGciOiJIUzI1NiIs...', description: '访问令牌' })
|
||||
accessToken: string;
|
||||
|
||||
@ApiProperty({ example: 'eyJhbGciOiJIUzI1NiIs...', description: '刷新令牌' })
|
||||
refreshToken: string;
|
||||
|
||||
@ApiProperty({ example: 300, description: '访问令牌有效期(秒)' })
|
||||
accessTokenExpiresIn: number;
|
||||
|
||||
@ApiProperty({ example: 2592000, description: '刷新令牌有效期(秒)' })
|
||||
refreshTokenExpiresIn: number;
|
||||
|
||||
@ApiProperty({ type: AuthUserDto, description: '用户信息' })
|
||||
user: AuthUserDto;
|
||||
}
|
||||
|
||||
/** 刷新 Token 请求 DTO */
|
||||
export class RefreshTokenDto implements IRefreshTokenDto {
|
||||
@ApiProperty({ example: 'eyJhbGciOiJIUzI1NiIs...', description: '刷新令牌' })
|
||||
@IsString()
|
||||
@IsNotEmpty()
|
||||
refreshToken: string;
|
||||
}
|
||||
|
||||
/** 刷新 Token 响应 DTO */
|
||||
export class RefreshTokenResponseDto implements IRefreshTokenResponse {
|
||||
@ApiProperty({ example: 'eyJhbGciOiJIUzI1NiIs...', description: '新的访问令牌' })
|
||||
accessToken: string;
|
||||
|
||||
@ApiProperty({ example: 'eyJhbGciOiJIUzI1NiIs...', description: '新的刷新令牌' })
|
||||
refreshToken: string;
|
||||
|
||||
@ApiProperty({ example: 300, description: '访问令牌有效期(秒)' })
|
||||
accessTokenExpiresIn: number;
|
||||
|
||||
@ApiProperty({ example: 2592000, description: '刷新令牌有效期(秒)' })
|
||||
refreshTokenExpiresIn: number;
|
||||
}
|
||||
|
||||
@@ -1,15 +1,11 @@
|
||||
import { Injectable, UnauthorizedException } from '@nestjs/common';
|
||||
import { ConfigService } from '@nestjs/config';
|
||||
import { PassportStrategy } from '@nestjs/passport';
|
||||
import type { TokenPayload } from '@seclusion/shared';
|
||||
import { ExtractJwt, Strategy } from 'passport-jwt';
|
||||
|
||||
import { PrismaService } from '../../prisma/prisma.service';
|
||||
|
||||
interface JwtPayload {
|
||||
sub: string;
|
||||
email: string;
|
||||
}
|
||||
|
||||
@Injectable()
|
||||
export class JwtStrategy extends PassportStrategy(Strategy) {
|
||||
constructor(
|
||||
@@ -23,7 +19,7 @@ export class JwtStrategy extends PassportStrategy(Strategy) {
|
||||
});
|
||||
}
|
||||
|
||||
async validate(payload: JwtPayload) {
|
||||
async validate(payload: TokenPayload) {
|
||||
const user = await this.prisma.user.findUnique({
|
||||
where: { id: payload.sub },
|
||||
});
|
||||
|
||||
27
apps/api/src/common/captcha/captcha.constants.ts
Normal file
27
apps/api/src/common/captcha/captcha.constants.ts
Normal file
@@ -0,0 +1,27 @@
|
||||
// 从 shared 包导入,保持前后端类型一致
|
||||
export { CaptchaScene } from '@seclusion/shared';
|
||||
|
||||
/**
|
||||
* 验证码配置
|
||||
*/
|
||||
export const CAPTCHA_CONFIG = {
|
||||
/** 验证码长度 */
|
||||
size: 4,
|
||||
/** 有效期(秒) */
|
||||
ttl: 300,
|
||||
/** 忽略大小写 */
|
||||
ignoreCase: true,
|
||||
/** 噪点数量 */
|
||||
noise: 3,
|
||||
/** 图片宽度 */
|
||||
width: 120,
|
||||
/** 图片高度 */
|
||||
height: 40,
|
||||
/** 字符集(排除易混淆字符 0/o/O/1/l/I) */
|
||||
charPreset: '23456789abcdefghjkmnpqrstuvwxyzABCDEFGHJKMNPQRSTUVWXYZ',
|
||||
} as const;
|
||||
|
||||
/**
|
||||
* Redis 键前缀
|
||||
*/
|
||||
export const CAPTCHA_REDIS_PREFIX = 'captcha:';
|
||||
32
apps/api/src/common/captcha/captcha.controller.ts
Normal file
32
apps/api/src/common/captcha/captcha.controller.ts
Normal file
@@ -0,0 +1,32 @@
|
||||
import { Controller, Get, Query } from '@nestjs/common';
|
||||
import { ApiTags, ApiOperation, ApiQuery, ApiResponse } from '@nestjs/swagger';
|
||||
import { CaptchaScene } from '@seclusion/shared';
|
||||
|
||||
import { CaptchaService } from './captcha.service';
|
||||
import { CaptchaResponseDto } from './dto/captcha-response.dto';
|
||||
|
||||
import { Public } from '@/auth/decorators/public.decorator';
|
||||
|
||||
@ApiTags('验证码')
|
||||
@Controller('captcha')
|
||||
export class CaptchaController {
|
||||
constructor(private readonly captchaService: CaptchaService) {}
|
||||
|
||||
@Get()
|
||||
@Public()
|
||||
@ApiOperation({ summary: '获取图形验证码' })
|
||||
@ApiQuery({
|
||||
name: 'scene',
|
||||
enum: CaptchaScene,
|
||||
description: '验证码使用场景',
|
||||
example: CaptchaScene.LOGIN,
|
||||
})
|
||||
@ApiResponse({
|
||||
status: 200,
|
||||
description: '验证码生成成功',
|
||||
type: CaptchaResponseDto,
|
||||
})
|
||||
async generate(@Query('scene') scene: CaptchaScene): Promise<CaptchaResponseDto> {
|
||||
return this.captchaService.generate({ scene });
|
||||
}
|
||||
}
|
||||
45
apps/api/src/common/captcha/captcha.interface.ts
Normal file
45
apps/api/src/common/captcha/captcha.interface.ts
Normal file
@@ -0,0 +1,45 @@
|
||||
import { CaptchaScene } from '@seclusion/shared';
|
||||
|
||||
/**
|
||||
* 验证码生成选项
|
||||
*/
|
||||
export interface CaptchaGenerateOptions {
|
||||
/** 验证码场景 */
|
||||
scene: CaptchaScene;
|
||||
}
|
||||
|
||||
/**
|
||||
* 验证码验证选项
|
||||
*/
|
||||
export interface CaptchaVerifyOptions {
|
||||
/** 验证码 ID */
|
||||
captchaId: string;
|
||||
/** 用户输入的验证码 */
|
||||
code: string;
|
||||
/** 验证码场景(可选,用于校验场景是否匹配) */
|
||||
scene?: CaptchaScene;
|
||||
}
|
||||
|
||||
/**
|
||||
* 验证码生成结果
|
||||
*/
|
||||
export interface CaptchaGenerateResult {
|
||||
/** 验证码 ID */
|
||||
captchaId: string;
|
||||
/** Base64 编码的 SVG 图片 */
|
||||
image: string;
|
||||
/** 过期时间(秒) */
|
||||
expiresIn: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* Redis 中存储的验证码数据
|
||||
*/
|
||||
export interface CaptchaStoredData {
|
||||
/** 验证码文本(小写存储) */
|
||||
code: string;
|
||||
/** 验证码场景 */
|
||||
scene: CaptchaScene;
|
||||
/** 创建时间戳 */
|
||||
createdAt: number;
|
||||
}
|
||||
12
apps/api/src/common/captcha/captcha.module.ts
Normal file
12
apps/api/src/common/captcha/captcha.module.ts
Normal file
@@ -0,0 +1,12 @@
|
||||
import { Module, Global } from '@nestjs/common';
|
||||
|
||||
import { CaptchaController } from './captcha.controller';
|
||||
import { CaptchaService } from './captcha.service';
|
||||
|
||||
@Global()
|
||||
@Module({
|
||||
controllers: [CaptchaController],
|
||||
providers: [CaptchaService],
|
||||
exports: [CaptchaService],
|
||||
})
|
||||
export class CaptchaModule {}
|
||||
133
apps/api/src/common/captcha/captcha.service.ts
Normal file
133
apps/api/src/common/captcha/captcha.service.ts
Normal file
@@ -0,0 +1,133 @@
|
||||
import { Injectable, BadRequestException, Logger } from '@nestjs/common';
|
||||
import { customAlphabet } from 'nanoid';
|
||||
import * as svgCaptcha from 'svg-captcha';
|
||||
|
||||
import { RedisService } from '../redis';
|
||||
|
||||
import { CAPTCHA_CONFIG, CAPTCHA_REDIS_PREFIX } from './captcha.constants';
|
||||
import {
|
||||
CaptchaGenerateOptions,
|
||||
CaptchaGenerateResult,
|
||||
CaptchaStoredData,
|
||||
CaptchaVerifyOptions,
|
||||
} from './captcha.interface';
|
||||
|
||||
// 生成验证码 ID 的函数(使用 nanoid)
|
||||
const generateCaptchaId = customAlphabet('0123456789abcdefghijklmnopqrstuvwxyz', 16);
|
||||
|
||||
@Injectable()
|
||||
export class CaptchaService {
|
||||
private readonly logger = new Logger(CaptchaService.name);
|
||||
|
||||
constructor(private readonly redisService: RedisService) {}
|
||||
|
||||
/**
|
||||
* 生成验证码
|
||||
* @param options 生成选项
|
||||
* @returns 验证码 ID 和 Base64 图片
|
||||
*/
|
||||
async generate(options: CaptchaGenerateOptions): Promise<CaptchaGenerateResult> {
|
||||
const { scene } = options;
|
||||
|
||||
// 生成 SVG 验证码
|
||||
const captcha = svgCaptcha.create({
|
||||
size: CAPTCHA_CONFIG.size,
|
||||
noise: CAPTCHA_CONFIG.noise,
|
||||
width: CAPTCHA_CONFIG.width,
|
||||
height: CAPTCHA_CONFIG.height,
|
||||
charPreset: CAPTCHA_CONFIG.charPreset,
|
||||
ignoreChars: '0oO1lI', // 额外排除易混淆字符
|
||||
});
|
||||
|
||||
// 生成唯一 ID
|
||||
const captchaId = `cap_${generateCaptchaId()}`;
|
||||
|
||||
// 存储到 Redis
|
||||
const storedData: CaptchaStoredData = {
|
||||
code: CAPTCHA_CONFIG.ignoreCase ? captcha.text.toLowerCase() : captcha.text,
|
||||
scene,
|
||||
createdAt: Date.now(),
|
||||
};
|
||||
|
||||
const redisKey = this.getRedisKey(captchaId);
|
||||
await this.redisService.setJson(redisKey, storedData, CAPTCHA_CONFIG.ttl);
|
||||
|
||||
this.logger.debug(`验证码已生成: ${captchaId}, 场景: ${scene}`);
|
||||
|
||||
// 转换为 Base64
|
||||
const base64Image = `data:image/svg+xml;base64,${Buffer.from(captcha.data).toString('base64')}`;
|
||||
|
||||
return {
|
||||
captchaId,
|
||||
image: base64Image,
|
||||
expiresIn: CAPTCHA_CONFIG.ttl,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* 验证验证码
|
||||
* @param options 验证选项
|
||||
* @returns 验证是否成功
|
||||
*/
|
||||
async verify(options: CaptchaVerifyOptions): Promise<boolean> {
|
||||
const { captchaId, code, scene } = options;
|
||||
|
||||
const redisKey = this.getRedisKey(captchaId);
|
||||
const storedData = await this.redisService.getJson<CaptchaStoredData>(redisKey);
|
||||
|
||||
// 验证码不存在或已过期
|
||||
if (!storedData) {
|
||||
this.logger.debug(`验证码不存在或已过期: ${captchaId}`);
|
||||
return false;
|
||||
}
|
||||
|
||||
// 验证场景是否匹配(如果指定了场景)
|
||||
if (scene && storedData.scene !== scene) {
|
||||
this.logger.debug(
|
||||
`验证码场景不匹配: ${captchaId}, 期望: ${scene}, 实际: ${storedData.scene}`
|
||||
);
|
||||
return false;
|
||||
}
|
||||
|
||||
// 比对验证码
|
||||
const inputCode = CAPTCHA_CONFIG.ignoreCase ? code.toLowerCase() : code;
|
||||
const isValid = inputCode === storedData.code;
|
||||
|
||||
// 无论验证成功与否,都删除验证码(一次性使用)
|
||||
await this.invalidate(captchaId);
|
||||
|
||||
this.logger.debug(`验证码验证${isValid ? '成功' : '失败'}: ${captchaId}`);
|
||||
|
||||
return isValid;
|
||||
}
|
||||
|
||||
/**
|
||||
* 验证验证码,失败时抛出异常
|
||||
* @param options 验证选项
|
||||
* @throws BadRequestException 验证失败时抛出
|
||||
*/
|
||||
async verifyOrThrow(options: CaptchaVerifyOptions): Promise<void> {
|
||||
const isValid = await this.verify(options);
|
||||
|
||||
if (!isValid) {
|
||||
throw new BadRequestException('验证码错误或已过期');
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 使验证码失效
|
||||
* @param captchaId 验证码 ID
|
||||
*/
|
||||
async invalidate(captchaId: string): Promise<void> {
|
||||
const redisKey = this.getRedisKey(captchaId);
|
||||
await this.redisService.del(redisKey);
|
||||
this.logger.debug(`验证码已失效: ${captchaId}`);
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取 Redis 键
|
||||
*/
|
||||
private getRedisKey(captchaId: string): string {
|
||||
return `${CAPTCHA_REDIS_PREFIX}${captchaId}`;
|
||||
}
|
||||
}
|
||||
26
apps/api/src/common/captcha/dto/captcha-response.dto.ts
Normal file
26
apps/api/src/common/captcha/dto/captcha-response.dto.ts
Normal file
@@ -0,0 +1,26 @@
|
||||
import { ApiProperty } from '@nestjs/swagger';
|
||||
import type { CaptchaResponse } from '@seclusion/shared';
|
||||
|
||||
/**
|
||||
* 验证码响应 DTO
|
||||
* 实现共享类型接口,确保前后端类型一致
|
||||
*/
|
||||
export class CaptchaResponseDto implements CaptchaResponse {
|
||||
@ApiProperty({
|
||||
description: '验证码 ID',
|
||||
example: 'cap_abc123xyz456def',
|
||||
})
|
||||
captchaId: string;
|
||||
|
||||
@ApiProperty({
|
||||
description: 'Base64 编码的 SVG 图片',
|
||||
example: 'data:image/svg+xml;base64,PHN2ZyB4bWxucz0i...',
|
||||
})
|
||||
image: string;
|
||||
|
||||
@ApiProperty({
|
||||
description: '过期时间(秒)',
|
||||
example: 300,
|
||||
})
|
||||
expiresIn: number;
|
||||
}
|
||||
5
apps/api/src/common/captcha/index.ts
Normal file
5
apps/api/src/common/captcha/index.ts
Normal file
@@ -0,0 +1,5 @@
|
||||
export * from './captcha.module';
|
||||
export * from './captcha.service';
|
||||
export * from './captcha.constants';
|
||||
export * from './captcha.interface';
|
||||
export * from './dto/captcha-response.dto';
|
||||
43
apps/api/src/common/crud/crud.decorator.ts
Normal file
43
apps/api/src/common/crud/crud.decorator.ts
Normal file
@@ -0,0 +1,43 @@
|
||||
import { SetMetadata } from '@nestjs/common';
|
||||
|
||||
import { CrudServiceOptions, DEFAULT_CRUD_OPTIONS } from './crud.types';
|
||||
|
||||
// Metadata key
|
||||
export const CRUD_OPTIONS_KEY = 'crud:options';
|
||||
|
||||
/**
|
||||
* CRUD 服务配置装饰器
|
||||
*
|
||||
* @example
|
||||
* // 基础用法 - 使用默认配置
|
||||
* @CrudOptions()
|
||||
* class UserService extends CrudService<User, CreateUserDto, UpdateUserDto> {}
|
||||
*
|
||||
* @example
|
||||
* // 启用软删除
|
||||
* @CrudOptions({ softDelete: true })
|
||||
* class UserService extends CrudService<User, CreateUserDto, UpdateUserDto> {}
|
||||
*
|
||||
* @example
|
||||
* // 自定义分页配置
|
||||
* @CrudOptions({
|
||||
* softDelete: true,
|
||||
* defaultPageSize: 10,
|
||||
* maxPageSize: 50,
|
||||
* defaultSortBy: 'name',
|
||||
* defaultSortOrder: 'asc',
|
||||
* })
|
||||
* class ProductService extends CrudService<Product, CreateProductDto, UpdateProductDto> {}
|
||||
*/
|
||||
export function CrudOptions(options: CrudServiceOptions = {}): ClassDecorator {
|
||||
const mergedOptions = { ...DEFAULT_CRUD_OPTIONS, ...options };
|
||||
return SetMetadata(CRUD_OPTIONS_KEY, mergedOptions);
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取 CRUD 配置的辅助函数
|
||||
*/
|
||||
export function getCrudOptions(target: object): Required<CrudServiceOptions> {
|
||||
const options = Reflect.getMetadata(CRUD_OPTIONS_KEY, target.constructor);
|
||||
return options || DEFAULT_CRUD_OPTIONS;
|
||||
}
|
||||
338
apps/api/src/common/crud/crud.service.ts
Normal file
338
apps/api/src/common/crud/crud.service.ts
Normal file
@@ -0,0 +1,338 @@
|
||||
import { NotFoundException } from '@nestjs/common';
|
||||
import { PaginatedResponse } from '@seclusion/shared';
|
||||
|
||||
import { getCrudOptions } from './crud.decorator';
|
||||
import { CrudServiceOptions, FindAllParams, PrismaDelegate, SoftDeletable } from './crud.types';
|
||||
|
||||
import { PrismaService } from '@/prisma/prisma.service';
|
||||
|
||||
/**
|
||||
* CRUD 泛型基类
|
||||
*
|
||||
* @typeParam Entity - 实体类型(Prisma 生成的模型类型)
|
||||
* @typeParam CreateDto - 创建 DTO 类型
|
||||
* @typeParam UpdateDto - 更新 DTO 类型
|
||||
* @typeParam WhereInput - Prisma Where 输入类型(可选)
|
||||
* @typeParam WhereUniqueInput - Prisma WhereUnique 输入类型(可选)
|
||||
*
|
||||
* @example
|
||||
* import { User, Prisma } from '@prisma/client';
|
||||
*
|
||||
* @Injectable()
|
||||
* @CrudOptions({ softDelete: true })
|
||||
* export class UserService extends CrudService<
|
||||
* User,
|
||||
* Prisma.UserCreateInput,
|
||||
* Prisma.UserUpdateInput,
|
||||
* Prisma.UserWhereInput,
|
||||
* Prisma.UserWhereUniqueInput
|
||||
* > {
|
||||
* constructor(prisma: PrismaService) {
|
||||
* super(prisma, 'user');
|
||||
* }
|
||||
* }
|
||||
*/
|
||||
export abstract class CrudService<
|
||||
Entity extends { id: string },
|
||||
CreateDto,
|
||||
UpdateDto,
|
||||
WhereInput = Record<string, unknown>,
|
||||
WhereUniqueInput = { id: string },
|
||||
> {
|
||||
protected readonly options: Required<CrudServiceOptions>;
|
||||
|
||||
protected constructor(
|
||||
protected readonly prisma: PrismaService,
|
||||
protected readonly modelName: string
|
||||
) {
|
||||
// 从装饰器获取配置,如果没有则使用默认配置
|
||||
this.options = getCrudOptions(this);
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取 Prisma 模型委托
|
||||
*/
|
||||
protected get model(): PrismaDelegate<
|
||||
Entity,
|
||||
CreateDto,
|
||||
UpdateDto,
|
||||
WhereInput,
|
||||
WhereUniqueInput
|
||||
> {
|
||||
return (this.prisma as unknown as Record<string, unknown>)[this.modelName] as PrismaDelegate<
|
||||
Entity,
|
||||
CreateDto,
|
||||
UpdateDto,
|
||||
WhereInput,
|
||||
WhereUniqueInput
|
||||
>;
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取默认 select 配置
|
||||
* 子类可覆盖此方法自定义返回字段
|
||||
*/
|
||||
protected getDefaultSelect(): Record<string, boolean> | undefined {
|
||||
return Object.keys(this.options.defaultSelect).length > 0
|
||||
? this.options.defaultSelect
|
||||
: undefined;
|
||||
}
|
||||
|
||||
/**
|
||||
* 解析多字段排序
|
||||
* @param sortBy 排序字段(逗号分隔)
|
||||
* @param sortOrder 排序方向(逗号分隔)
|
||||
* @returns Prisma orderBy 数组
|
||||
*/
|
||||
protected parseOrderBy(sortBy: string, sortOrder: string): Record<string, 'asc' | 'desc'>[] {
|
||||
const fields = sortBy.split(',').map((s) => s.trim());
|
||||
const orders = sortOrder.split(',').map((s) => s.trim() as 'asc' | 'desc');
|
||||
|
||||
return fields.map((field, i) => ({
|
||||
[field]: orders[i] || orders[0] || 'desc',
|
||||
}));
|
||||
}
|
||||
|
||||
/**
|
||||
* 判断是否为非分页查询
|
||||
*/
|
||||
protected isNoPagination(pageSize: number): boolean {
|
||||
return pageSize <= 0;
|
||||
}
|
||||
|
||||
/**
|
||||
* 分页查询所有记录
|
||||
* pageSize <= 0 时返回所有数据(不分页)
|
||||
*/
|
||||
async findAll(params: FindAllParams<WhereInput> = {}): Promise<PaginatedResponse<Entity>> {
|
||||
const {
|
||||
page = 1,
|
||||
pageSize = this.options.defaultPageSize,
|
||||
sortBy = this.options.defaultSortBy,
|
||||
sortOrder = this.options.defaultSortOrder,
|
||||
where,
|
||||
} = params;
|
||||
|
||||
// 解析多字段排序
|
||||
const orderBy = this.parseOrderBy(sortBy, sortOrder);
|
||||
|
||||
// 非分页查询
|
||||
if (this.isNoPagination(pageSize)) {
|
||||
const items = await this.model.findMany({
|
||||
where: where as WhereInput,
|
||||
select: this.getDefaultSelect(),
|
||||
orderBy,
|
||||
});
|
||||
|
||||
return {
|
||||
items,
|
||||
total: items.length,
|
||||
page: 1,
|
||||
pageSize: items.length,
|
||||
totalPages: 1,
|
||||
};
|
||||
}
|
||||
|
||||
// 分页查询
|
||||
const limitedPageSize = Math.min(pageSize, this.options.maxPageSize);
|
||||
const skip = (page - 1) * limitedPageSize;
|
||||
|
||||
// 并行执行查询和计数
|
||||
const [items, total] = await Promise.all([
|
||||
this.model.findMany({
|
||||
where: where as WhereInput,
|
||||
select: this.getDefaultSelect(),
|
||||
skip,
|
||||
take: limitedPageSize,
|
||||
orderBy,
|
||||
}),
|
||||
this.model.count({ where: where as WhereInput }),
|
||||
]);
|
||||
|
||||
return {
|
||||
items,
|
||||
total,
|
||||
page,
|
||||
pageSize: limitedPageSize,
|
||||
totalPages: Math.ceil(total / limitedPageSize),
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* 根据 ID 查询单条记录
|
||||
* @throws NotFoundException 当记录不存在时
|
||||
*/
|
||||
async findById(id: string): Promise<Entity> {
|
||||
const entity = await this.model.findUnique({
|
||||
where: { id } as unknown as WhereUniqueInput,
|
||||
select: this.getDefaultSelect(),
|
||||
});
|
||||
|
||||
if (!entity) {
|
||||
throw new NotFoundException(this.getNotFoundMessage(id));
|
||||
}
|
||||
|
||||
return entity;
|
||||
}
|
||||
|
||||
/**
|
||||
* 创建记录
|
||||
*/
|
||||
async create(dto: CreateDto): Promise<Entity> {
|
||||
return this.model.create({
|
||||
data: dto,
|
||||
select: this.getDefaultSelect(),
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* 更新记录
|
||||
* @throws NotFoundException 当记录不存在时
|
||||
*/
|
||||
async update(id: string, dto: UpdateDto): Promise<Entity> {
|
||||
// 先检查记录是否存在
|
||||
await this.findById(id);
|
||||
|
||||
return this.model.update({
|
||||
where: { id } as unknown as WhereUniqueInput,
|
||||
data: dto,
|
||||
select: this.getDefaultSelect(),
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* 删除记录
|
||||
* 如果启用软删除,底层 PrismaService 会自动转换为软删除
|
||||
* @throws NotFoundException 当记录不存在时
|
||||
*/
|
||||
async delete(id: string): Promise<{ message: string }> {
|
||||
// 先检查记录是否存在
|
||||
await this.findById(id);
|
||||
|
||||
await this.model.delete({
|
||||
where: { id } as unknown as WhereUniqueInput,
|
||||
});
|
||||
|
||||
return { message: this.getDeletedMessage(id) };
|
||||
}
|
||||
|
||||
// ============ 软删除相关方法(仅当 softDelete: true 时可用)============
|
||||
|
||||
/**
|
||||
* 查询已删除的记录
|
||||
* 仅当 softDelete: true 时有效
|
||||
* pageSize <= 0 时返回所有数据(不分页)
|
||||
*/
|
||||
async findDeleted(
|
||||
params: FindAllParams<WhereInput> = {}
|
||||
): Promise<PaginatedResponse<Entity & SoftDeletable>> {
|
||||
if (!this.options.softDelete) {
|
||||
throw new Error('此服务未启用软删除功能');
|
||||
}
|
||||
|
||||
const {
|
||||
page = 1,
|
||||
pageSize = this.options.defaultPageSize,
|
||||
sortBy = 'deletedAt',
|
||||
sortOrder = 'desc',
|
||||
where = {} as WhereInput,
|
||||
} = params;
|
||||
|
||||
// 显式指定 deletedAt 条件,绕过自动过滤
|
||||
const deletedWhere = {
|
||||
...where,
|
||||
deletedAt: { not: null },
|
||||
} as WhereInput;
|
||||
|
||||
// 解析多字段排序
|
||||
const orderBy = this.parseOrderBy(sortBy, sortOrder);
|
||||
|
||||
// 非分页查询
|
||||
if (this.isNoPagination(pageSize)) {
|
||||
const items = await this.model.findMany({
|
||||
where: deletedWhere,
|
||||
orderBy,
|
||||
});
|
||||
|
||||
return {
|
||||
items: items as (Entity & SoftDeletable)[],
|
||||
total: items.length,
|
||||
page: 1,
|
||||
pageSize: items.length,
|
||||
totalPages: 1,
|
||||
};
|
||||
}
|
||||
|
||||
// 分页查询
|
||||
const limitedPageSize = Math.min(pageSize, this.options.maxPageSize);
|
||||
const skip = (page - 1) * limitedPageSize;
|
||||
|
||||
const [items, total] = await Promise.all([
|
||||
this.model.findMany({
|
||||
where: deletedWhere,
|
||||
skip,
|
||||
take: limitedPageSize,
|
||||
orderBy,
|
||||
}),
|
||||
this.model.count({ where: deletedWhere }),
|
||||
]);
|
||||
|
||||
return {
|
||||
items: items as (Entity & SoftDeletable)[],
|
||||
total,
|
||||
page,
|
||||
pageSize: limitedPageSize,
|
||||
totalPages: Math.ceil(total / limitedPageSize),
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* 恢复已删除的记录
|
||||
* 仅当 softDelete: true 时有效
|
||||
* @throws NotFoundException 当已删除的记录不存在时
|
||||
*/
|
||||
async restore(id: string): Promise<Entity> {
|
||||
if (!this.options.softDelete) {
|
||||
throw new Error('此服务未启用软删除功能');
|
||||
}
|
||||
|
||||
// 查询已删除的记录
|
||||
const entity = await this.model.findFirst({
|
||||
where: { id, deletedAt: { not: null } } as unknown as WhereInput,
|
||||
});
|
||||
|
||||
if (!entity) {
|
||||
throw new NotFoundException(this.getDeletedNotFoundMessage(id));
|
||||
}
|
||||
|
||||
// 恢复记录
|
||||
return this.model.update({
|
||||
where: { id } as unknown as WhereUniqueInput,
|
||||
data: { deletedAt: null } as unknown as UpdateDto,
|
||||
select: this.getDefaultSelect(),
|
||||
});
|
||||
}
|
||||
|
||||
// ============ 可覆盖的消息方法 ============
|
||||
|
||||
/**
|
||||
* 获取记录不存在的错误消息
|
||||
*/
|
||||
protected getNotFoundMessage(id: string): string {
|
||||
return `记录不存在: ${id}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取删除成功的消息
|
||||
*/
|
||||
protected getDeletedMessage(_id: string): string {
|
||||
return '记录已删除';
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取已删除记录不存在的错误消息
|
||||
*/
|
||||
protected getDeletedNotFoundMessage(id: string): string {
|
||||
return `已删除的记录不存在: ${id}`;
|
||||
}
|
||||
}
|
||||
76
apps/api/src/common/crud/crud.types.ts
Normal file
76
apps/api/src/common/crud/crud.types.ts
Normal file
@@ -0,0 +1,76 @@
|
||||
import { PaginationParams } from '@seclusion/shared';
|
||||
|
||||
/**
|
||||
* CRUD 服务配置选项
|
||||
*/
|
||||
export interface CrudServiceOptions {
|
||||
/** 是否启用软删除(默认 false) */
|
||||
softDelete?: boolean;
|
||||
/** 默认分页大小 */
|
||||
defaultPageSize?: number;
|
||||
/** 最大分页大小 */
|
||||
maxPageSize?: number;
|
||||
/** 默认排序字段 */
|
||||
defaultSortBy?: string;
|
||||
/** 默认排序方向 */
|
||||
defaultSortOrder?: 'asc' | 'desc';
|
||||
/** 默认 select 字段(排除敏感字段如 password) */
|
||||
defaultSelect?: Record<string, boolean>;
|
||||
}
|
||||
|
||||
/**
|
||||
* 默认配置
|
||||
*/
|
||||
export const DEFAULT_CRUD_OPTIONS: Required<CrudServiceOptions> = {
|
||||
softDelete: false,
|
||||
defaultPageSize: 20,
|
||||
maxPageSize: 100,
|
||||
defaultSortBy: 'createdAt',
|
||||
defaultSortOrder: 'desc',
|
||||
defaultSelect: {},
|
||||
};
|
||||
|
||||
/**
|
||||
* 分页查询参数(扩展 shared 包的类型,添加过滤条件)
|
||||
*/
|
||||
export interface FindAllParams<WhereInput = Record<string, unknown>> extends PaginationParams {
|
||||
where?: WhereInput;
|
||||
}
|
||||
|
||||
/**
|
||||
* 软删除实体接口 - 启用软删除的模型必须包含 deletedAt 字段
|
||||
*/
|
||||
export interface SoftDeletable {
|
||||
deletedAt?: Date | null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Prisma 排序类型
|
||||
*/
|
||||
export type OrderByItem = Record<string, 'asc' | 'desc'>;
|
||||
|
||||
/**
|
||||
* Prisma 模型委托类型 - 用于类型安全的模型访问
|
||||
*/
|
||||
export interface PrismaDelegate<T, CreateInput, UpdateInput, WhereInput, WhereUniqueInput> {
|
||||
findMany(args?: {
|
||||
where?: WhereInput;
|
||||
select?: Record<string, boolean>;
|
||||
skip?: number;
|
||||
take?: number;
|
||||
orderBy?: OrderByItem | OrderByItem[];
|
||||
}): Promise<T[]>;
|
||||
findUnique(args: {
|
||||
where: WhereUniqueInput;
|
||||
select?: Record<string, boolean>;
|
||||
}): Promise<T | null>;
|
||||
findFirst(args?: { where?: WhereInput; select?: Record<string, boolean> }): Promise<T | null>;
|
||||
create(args: { data: CreateInput; select?: Record<string, boolean> }): Promise<T>;
|
||||
update(args: {
|
||||
where: WhereUniqueInput;
|
||||
data: UpdateInput;
|
||||
select?: Record<string, boolean>;
|
||||
}): Promise<T>;
|
||||
delete(args: { where: WhereUniqueInput }): Promise<T>;
|
||||
count(args?: { where?: WhereInput }): Promise<number>;
|
||||
}
|
||||
44
apps/api/src/common/crud/dto/paginated-response.dto.ts
Normal file
44
apps/api/src/common/crud/dto/paginated-response.dto.ts
Normal file
@@ -0,0 +1,44 @@
|
||||
import type { Type } from '@nestjs/common';
|
||||
import { ApiProperty } from '@nestjs/swagger';
|
||||
|
||||
/**
|
||||
* 分页响应 DTO 基类
|
||||
* 包含分页元数据字段
|
||||
*/
|
||||
export class PaginationMetaDto {
|
||||
@ApiProperty({ example: 100, description: '总数' })
|
||||
total: number;
|
||||
|
||||
@ApiProperty({ example: 1, description: '当前页' })
|
||||
page: number;
|
||||
|
||||
@ApiProperty({ example: 10, description: '每页数量' })
|
||||
pageSize: number;
|
||||
|
||||
@ApiProperty({ example: 10, description: '总页数' })
|
||||
totalPages: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* 创建分页响应 DTO 的工厂函数
|
||||
* 使用 Mixin 模式生成带有正确 Swagger 类型的分页响应 DTO
|
||||
*
|
||||
* @example
|
||||
* ```typescript
|
||||
* class UserResponseDto { ... }
|
||||
* export class PaginatedUserResponseDto extends createPaginatedResponseDto(UserResponseDto) {}
|
||||
* ```
|
||||
*/
|
||||
export function createPaginatedResponseDto<T>(ItemDto: Type<T>) {
|
||||
class PaginatedResponseDto extends PaginationMetaDto {
|
||||
@ApiProperty({ type: [ItemDto], description: '数据列表' })
|
||||
items: T[];
|
||||
}
|
||||
|
||||
// 设置类名,方便 Swagger 文档显示
|
||||
Object.defineProperty(PaginatedResponseDto, 'name', {
|
||||
value: `Paginated${ItemDto.name}`,
|
||||
});
|
||||
|
||||
return PaginatedResponseDto;
|
||||
}
|
||||
63
apps/api/src/common/crud/dto/pagination.dto.ts
Normal file
63
apps/api/src/common/crud/dto/pagination.dto.ts
Normal file
@@ -0,0 +1,63 @@
|
||||
import { ApiPropertyOptional } from '@nestjs/swagger';
|
||||
import { PaginationParams } from '@seclusion/shared';
|
||||
import { Type } from 'class-transformer';
|
||||
import { IsInt, IsOptional, IsString, Matches } from 'class-validator';
|
||||
|
||||
/**
|
||||
* 分页查询 DTO - 用于 Controller 层参数验证
|
||||
* 复用 shared 包的 PaginationParams 接口
|
||||
*
|
||||
* 支持多字段排序:
|
||||
* - 单字段:sortBy=createdAt&sortOrder=desc
|
||||
* - 多字段:sortBy=status,createdAt&sortOrder=asc,desc
|
||||
*
|
||||
* 支持非分页查询:
|
||||
* - pageSize=-1 或 pageSize=0 返回所有数据
|
||||
*/
|
||||
export class PaginationQueryDto implements PaginationParams {
|
||||
@ApiPropertyOptional({ description: '页码', default: 1, minimum: 1 })
|
||||
@Type(() => Number)
|
||||
@IsInt()
|
||||
@IsOptional()
|
||||
page?: number = 1;
|
||||
|
||||
@ApiPropertyOptional({
|
||||
description: '每页数量(-1 或 0 表示不分页,返回所有数据)',
|
||||
default: 20,
|
||||
examples: {
|
||||
default: { value: 20, summary: '默认分页' },
|
||||
noPagination: { value: -1, summary: '不分页,返回所有数据' },
|
||||
},
|
||||
})
|
||||
@Type(() => Number)
|
||||
@IsInt()
|
||||
@IsOptional()
|
||||
pageSize?: number = 20;
|
||||
|
||||
@ApiPropertyOptional({
|
||||
description: '排序字段(逗号分隔支持多字段)',
|
||||
example: 'createdAt',
|
||||
examples: {
|
||||
single: { value: 'createdAt', summary: '单字段排序' },
|
||||
multiple: { value: 'status,createdAt', summary: '多字段排序' },
|
||||
},
|
||||
})
|
||||
@IsString()
|
||||
@IsOptional()
|
||||
sortBy?: string;
|
||||
|
||||
@ApiPropertyOptional({
|
||||
description: '排序方向(逗号分隔,与 sortBy 对应)',
|
||||
example: 'desc',
|
||||
examples: {
|
||||
single: { value: 'desc', summary: '单字段排序' },
|
||||
multiple: { value: 'asc,desc', summary: '多字段排序' },
|
||||
},
|
||||
})
|
||||
@IsString()
|
||||
@Matches(/^(asc|desc)(,(asc|desc))*$/, {
|
||||
message: 'sortOrder 必须是 asc 或 desc,多个用逗号分隔',
|
||||
})
|
||||
@IsOptional()
|
||||
sortOrder?: string = 'desc';
|
||||
}
|
||||
12
apps/api/src/common/crud/index.ts
Normal file
12
apps/api/src/common/crud/index.ts
Normal file
@@ -0,0 +1,12 @@
|
||||
// 类型导出
|
||||
export * from './crud.types';
|
||||
|
||||
// 装饰器导出
|
||||
export * from './crud.decorator';
|
||||
|
||||
// 基类导出
|
||||
export { CrudService } from './crud.service';
|
||||
|
||||
// DTO 导出
|
||||
export { PaginationQueryDto } from './dto/pagination.dto';
|
||||
export { PaginationMetaDto, createPaginatedResponseDto } from './dto/paginated-response.dto';
|
||||
11
apps/api/src/common/crypto/crypto.module.ts
Normal file
11
apps/api/src/common/crypto/crypto.module.ts
Normal file
@@ -0,0 +1,11 @@
|
||||
import { Global, Module } from '@nestjs/common';
|
||||
|
||||
import { CryptoService } from './crypto.service';
|
||||
import { EncryptionInterceptor } from './encryption.interceptor';
|
||||
|
||||
@Global()
|
||||
@Module({
|
||||
providers: [CryptoService, EncryptionInterceptor],
|
||||
exports: [CryptoService, EncryptionInterceptor],
|
||||
})
|
||||
export class CryptoModule {}
|
||||
108
apps/api/src/common/crypto/crypto.service.ts
Normal file
108
apps/api/src/common/crypto/crypto.service.ts
Normal file
@@ -0,0 +1,108 @@
|
||||
import { Injectable, Logger } from '@nestjs/common';
|
||||
import { ConfigService } from '@nestjs/config';
|
||||
import { createNodeCrypto, CryptoError, isEncryptedPayload } from '@seclusion/shared';
|
||||
import type { AesGcmCrypto, EncryptedPayload } from '@seclusion/shared';
|
||||
|
||||
@Injectable()
|
||||
export class CryptoService {
|
||||
private readonly logger = new Logger(CryptoService.name);
|
||||
private readonly crypto: AesGcmCrypto | null = null;
|
||||
private readonly enabled: boolean;
|
||||
|
||||
constructor(private readonly configService: ConfigService) {
|
||||
this.enabled = this.configService.get<string>('ENABLE_ENCRYPTION') === 'true';
|
||||
|
||||
if (this.enabled) {
|
||||
const key = this.configService.get<string>('ENCRYPTION_KEY');
|
||||
if (!key) {
|
||||
this.logger.error('ENABLE_ENCRYPTION=true 但未配置 ENCRYPTION_KEY');
|
||||
throw new Error('加密已启用但未配置密钥');
|
||||
}
|
||||
|
||||
try {
|
||||
this.crypto = createNodeCrypto(key);
|
||||
this.logger.log('AES-256-GCM 加密已启用');
|
||||
} catch (error) {
|
||||
this.logger.error('初始化加密服务失败', error);
|
||||
throw error;
|
||||
}
|
||||
} else {
|
||||
this.logger.log('加密功能已禁用');
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 是否启用加密
|
||||
*/
|
||||
isEnabled(): boolean {
|
||||
return this.enabled;
|
||||
}
|
||||
|
||||
/**
|
||||
* 加密数据
|
||||
* @param data 要加密的数据(将被 JSON 序列化)
|
||||
*/
|
||||
async encrypt(data: unknown): Promise<EncryptedPayload> {
|
||||
if (!this.crypto) {
|
||||
throw new Error('加密服务未启用');
|
||||
}
|
||||
|
||||
const plaintext = JSON.stringify(data);
|
||||
return this.crypto.encrypt(plaintext);
|
||||
}
|
||||
|
||||
/**
|
||||
* 解密数据
|
||||
* @param payload 加密载荷
|
||||
*/
|
||||
async decrypt<T = unknown>(payload: EncryptedPayload): Promise<T> {
|
||||
if (!this.crypto) {
|
||||
throw new Error('加密服务未启用');
|
||||
}
|
||||
|
||||
const plaintext = await this.crypto.decrypt(payload);
|
||||
return JSON.parse(plaintext) as T;
|
||||
}
|
||||
|
||||
/**
|
||||
* 尝试解密请求体
|
||||
* 如果是加密载荷则解密,否则原样返回
|
||||
*/
|
||||
async tryDecrypt<T = unknown>(body: unknown): Promise<T> {
|
||||
if (!this.enabled || !this.crypto) {
|
||||
return body as T;
|
||||
}
|
||||
|
||||
if (isEncryptedPayload(body)) {
|
||||
try {
|
||||
return await this.decrypt<T>(body);
|
||||
} catch (error) {
|
||||
if (error instanceof CryptoError) {
|
||||
this.logger.warn(`解密失败: ${error.message} (${error.code})`);
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
return body as T;
|
||||
}
|
||||
|
||||
/**
|
||||
* 尝试加密响应体
|
||||
* 如果加密已启用则加密,否则原样返回
|
||||
*/
|
||||
async tryEncrypt(data: unknown): Promise<unknown> {
|
||||
if (!this.enabled || !this.crypto) {
|
||||
return data;
|
||||
}
|
||||
|
||||
try {
|
||||
return await this.encrypt(data);
|
||||
} catch (error) {
|
||||
if (error instanceof CryptoError) {
|
||||
this.logger.warn(`加密失败: ${error.message} (${error.code})`);
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
import { SetMetadata } from '@nestjs/common';
|
||||
|
||||
/** 跳过加密的元数据键 */
|
||||
export const SKIP_ENCRYPTION_KEY = 'skipEncryption';
|
||||
|
||||
/**
|
||||
* 跳过加密装饰器
|
||||
* 用于标记不需要加密/解密处理的接口
|
||||
* 例如:健康检查、文件上传等
|
||||
*/
|
||||
export const SkipEncryption = () => SetMetadata(SKIP_ENCRYPTION_KEY, true);
|
||||
94
apps/api/src/common/crypto/encryption.interceptor.ts
Normal file
94
apps/api/src/common/crypto/encryption.interceptor.ts
Normal file
@@ -0,0 +1,94 @@
|
||||
import {
|
||||
CallHandler,
|
||||
ExecutionContext,
|
||||
Injectable,
|
||||
NestInterceptor,
|
||||
BadRequestException,
|
||||
Logger,
|
||||
} from '@nestjs/common';
|
||||
import { Reflector } from '@nestjs/core';
|
||||
import { CryptoError, isEncryptedPayload } from '@seclusion/shared';
|
||||
import type { Request, Response } from 'express';
|
||||
import { Observable, from, switchMap } from 'rxjs';
|
||||
|
||||
import { CryptoService } from './crypto.service';
|
||||
import { SKIP_ENCRYPTION_KEY } from './decorators/skip-encryption.decorator';
|
||||
|
||||
@Injectable()
|
||||
export class EncryptionInterceptor implements NestInterceptor {
|
||||
private readonly logger = new Logger(EncryptionInterceptor.name);
|
||||
|
||||
constructor(
|
||||
private readonly cryptoService: CryptoService,
|
||||
private readonly reflector: Reflector
|
||||
) {}
|
||||
|
||||
intercept(context: ExecutionContext, next: CallHandler): Observable<unknown> {
|
||||
// 检查是否跳过加密
|
||||
const skipEncryption = this.reflector.getAllAndOverride<boolean>(SKIP_ENCRYPTION_KEY, [
|
||||
context.getHandler(),
|
||||
context.getClass(),
|
||||
]);
|
||||
|
||||
if (skipEncryption || !this.cryptoService.isEnabled()) {
|
||||
return next.handle();
|
||||
}
|
||||
|
||||
const ctx = context.switchToHttp();
|
||||
const request = ctx.getRequest<Request>();
|
||||
const response = ctx.getResponse<Response>();
|
||||
|
||||
// 处理请求解密
|
||||
return from(this.decryptRequest(request)).pipe(
|
||||
switchMap(() => next.handle()),
|
||||
// 处理响应加密
|
||||
switchMap((data) => from(this.encryptResponse(data, response)))
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* 解密请求体
|
||||
*/
|
||||
private async decryptRequest(request: Request): Promise<void> {
|
||||
if (!request.body || typeof request.body !== 'object') {
|
||||
return;
|
||||
}
|
||||
|
||||
// 检查是否为加密载荷
|
||||
if (isEncryptedPayload(request.body)) {
|
||||
try {
|
||||
const decrypted = await this.cryptoService.decrypt(request.body);
|
||||
request.body = decrypted;
|
||||
this.logger.debug('请求体已解密');
|
||||
} catch (error) {
|
||||
if (error instanceof CryptoError) {
|
||||
throw new BadRequestException(`解密失败: ${error.message}`);
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 加密响应体
|
||||
*/
|
||||
private async encryptResponse(data: unknown, response: Response): Promise<unknown> {
|
||||
// 跳过空响应
|
||||
if (data === undefined || data === null) {
|
||||
return data;
|
||||
}
|
||||
|
||||
try {
|
||||
const encrypted = await this.cryptoService.encrypt(data);
|
||||
// 设置响应头标识
|
||||
response.setHeader('X-Encrypted', 'true');
|
||||
this.logger.debug('响应体已加密');
|
||||
return encrypted;
|
||||
} catch (error) {
|
||||
if (error instanceof CryptoError) {
|
||||
this.logger.error(`加密响应失败: ${error.message}`);
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
}
|
||||
4
apps/api/src/common/crypto/index.ts
Normal file
4
apps/api/src/common/crypto/index.ts
Normal file
@@ -0,0 +1,4 @@
|
||||
export * from './crypto.module';
|
||||
export * from './crypto.service';
|
||||
export * from './encryption.interceptor';
|
||||
export * from './decorators/skip-encryption.decorator';
|
||||
3
apps/api/src/common/redis/index.ts
Normal file
3
apps/api/src/common/redis/index.ts
Normal file
@@ -0,0 +1,3 @@
|
||||
export { REDIS_CLIENT } from './redis.constants';
|
||||
export { RedisModule } from './redis.module';
|
||||
export { RedisService } from './redis.service';
|
||||
5
apps/api/src/common/redis/redis.constants.ts
Normal file
5
apps/api/src/common/redis/redis.constants.ts
Normal file
@@ -0,0 +1,5 @@
|
||||
/**
|
||||
* Redis 客户端注入 Token
|
||||
* 用于直接注入 ioredis 客户端实例
|
||||
*/
|
||||
export const REDIS_CLIENT = Symbol('REDIS_CLIENT');
|
||||
44
apps/api/src/common/redis/redis.module.ts
Normal file
44
apps/api/src/common/redis/redis.module.ts
Normal file
@@ -0,0 +1,44 @@
|
||||
import { Global, Module } from '@nestjs/common';
|
||||
import { ConfigService } from '@nestjs/config';
|
||||
import Redis from 'ioredis';
|
||||
|
||||
import { REDIS_CLIENT } from './redis.constants';
|
||||
import { RedisService } from './redis.service';
|
||||
|
||||
/**
|
||||
* Redis 模块
|
||||
* 全局模块,提供 Redis 连接和服务
|
||||
*/
|
||||
@Global()
|
||||
@Module({
|
||||
providers: [
|
||||
{
|
||||
provide: REDIS_CLIENT,
|
||||
useFactory: (configService: ConfigService) => {
|
||||
const url = configService.get<string>('REDIS_URL', 'redis://localhost:6379');
|
||||
return new Redis(url, {
|
||||
// 自动重连配置
|
||||
retryStrategy: (times) => {
|
||||
// 最多重试 10 次,每次间隔递增
|
||||
if (times > 10) {
|
||||
return null;
|
||||
}
|
||||
return Math.min(times * 100, 3000);
|
||||
},
|
||||
// 连接超时
|
||||
connectTimeout: 10000,
|
||||
// 命令超时
|
||||
commandTimeout: 5000,
|
||||
// 启用离线队列
|
||||
enableOfflineQueue: true,
|
||||
// 最大重连次数
|
||||
maxRetriesPerRequest: 3,
|
||||
});
|
||||
},
|
||||
inject: [ConfigService],
|
||||
},
|
||||
RedisService,
|
||||
],
|
||||
exports: [REDIS_CLIENT, RedisService],
|
||||
})
|
||||
export class RedisModule {}
|
||||
269
apps/api/src/common/redis/redis.service.ts
Normal file
269
apps/api/src/common/redis/redis.service.ts
Normal file
@@ -0,0 +1,269 @@
|
||||
import { Inject, Injectable, Logger, OnModuleDestroy, OnModuleInit } from '@nestjs/common';
|
||||
import Redis from 'ioredis';
|
||||
|
||||
import { REDIS_CLIENT } from './redis.constants';
|
||||
|
||||
/**
|
||||
* Redis 服务
|
||||
* 提供常用 Redis 操作的封装
|
||||
*/
|
||||
@Injectable()
|
||||
export class RedisService implements OnModuleInit, OnModuleDestroy {
|
||||
private readonly logger = new Logger(RedisService.name);
|
||||
private connected = false;
|
||||
|
||||
constructor(@Inject(REDIS_CLIENT) private readonly redis: Redis) {}
|
||||
|
||||
async onModuleInit() {
|
||||
// 监听连接事件
|
||||
this.redis.on('connect', () => {
|
||||
this.connected = true;
|
||||
this.logger.log('Redis 连接成功');
|
||||
});
|
||||
|
||||
this.redis.on('error', (error) => {
|
||||
this.connected = false;
|
||||
this.logger.error('Redis 连接错误', error);
|
||||
});
|
||||
|
||||
this.redis.on('close', () => {
|
||||
this.connected = false;
|
||||
this.logger.warn('Redis 连接已关闭');
|
||||
});
|
||||
|
||||
// 执行健康检查
|
||||
await this.healthCheck();
|
||||
}
|
||||
|
||||
async onModuleDestroy() {
|
||||
await this.redis.quit();
|
||||
this.logger.log('Redis 连接已断开');
|
||||
}
|
||||
|
||||
/**
|
||||
* 健康检查
|
||||
*/
|
||||
async healthCheck(): Promise<boolean> {
|
||||
try {
|
||||
const result = await this.redis.ping();
|
||||
if (result === 'PONG') {
|
||||
this.logger.log('Redis 健康检查通过');
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
} catch (error) {
|
||||
this.logger.error('Redis 健康检查失败', error);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 检查连接状态
|
||||
*/
|
||||
isConnected(): boolean {
|
||||
return this.connected && this.redis.status === 'ready';
|
||||
}
|
||||
|
||||
// ==================== 基础操作 ====================
|
||||
|
||||
/**
|
||||
* 获取值
|
||||
*/
|
||||
async get(key: string): Promise<string | null> {
|
||||
return this.redis.get(key);
|
||||
}
|
||||
|
||||
/**
|
||||
* 设置值
|
||||
* @param key 键
|
||||
* @param value 值
|
||||
* @param ttl 过期时间(秒),可选
|
||||
*/
|
||||
async set(key: string, value: string, ttl?: number): Promise<'OK'> {
|
||||
if (ttl) {
|
||||
return this.redis.set(key, value, 'EX', ttl);
|
||||
}
|
||||
return this.redis.set(key, value);
|
||||
}
|
||||
|
||||
/**
|
||||
* 删除键
|
||||
*/
|
||||
async del(...keys: string[]): Promise<number> {
|
||||
return this.redis.del(...keys);
|
||||
}
|
||||
|
||||
/**
|
||||
* 检查键是否存在
|
||||
*/
|
||||
async exists(...keys: string[]): Promise<number> {
|
||||
return this.redis.exists(...keys);
|
||||
}
|
||||
|
||||
/**
|
||||
* 设置过期时间
|
||||
*/
|
||||
async expire(key: string, seconds: number): Promise<number> {
|
||||
return this.redis.expire(key, seconds);
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取剩余过期时间
|
||||
*/
|
||||
async ttl(key: string): Promise<number> {
|
||||
return this.redis.ttl(key);
|
||||
}
|
||||
|
||||
// ==================== JSON 操作 ====================
|
||||
|
||||
/**
|
||||
* 获取 JSON 值
|
||||
*/
|
||||
async getJson<T>(key: string): Promise<T | null> {
|
||||
const value = await this.redis.get(key);
|
||||
if (!value) return null;
|
||||
try {
|
||||
return JSON.parse(value) as T;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 设置 JSON 值
|
||||
*/
|
||||
async setJson<T>(key: string, value: T, ttl?: number): Promise<'OK'> {
|
||||
const json = JSON.stringify(value);
|
||||
return this.set(key, json, ttl);
|
||||
}
|
||||
|
||||
// ==================== 计数器操作 ====================
|
||||
|
||||
/**
|
||||
* 自增
|
||||
*/
|
||||
async incr(key: string): Promise<number> {
|
||||
return this.redis.incr(key);
|
||||
}
|
||||
|
||||
/**
|
||||
* 自增指定值
|
||||
*/
|
||||
async incrBy(key: string, increment: number): Promise<number> {
|
||||
return this.redis.incrby(key, increment);
|
||||
}
|
||||
|
||||
/**
|
||||
* 自减
|
||||
*/
|
||||
async decr(key: string): Promise<number> {
|
||||
return this.redis.decr(key);
|
||||
}
|
||||
|
||||
// ==================== Hash 操作 ====================
|
||||
|
||||
/**
|
||||
* 设置 Hash 字段
|
||||
*/
|
||||
async hset(key: string, field: string, value: string | number): Promise<number> {
|
||||
return this.redis.hset(key, field, value);
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取 Hash 字段
|
||||
*/
|
||||
async hget(key: string, field: string): Promise<string | null> {
|
||||
return this.redis.hget(key, field);
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取所有 Hash 字段
|
||||
*/
|
||||
async hgetall(key: string): Promise<Record<string, string>> {
|
||||
return this.redis.hgetall(key);
|
||||
}
|
||||
|
||||
/**
|
||||
* 删除 Hash 字段
|
||||
*/
|
||||
async hdel(key: string, ...fields: string[]): Promise<number> {
|
||||
return this.redis.hdel(key, ...fields);
|
||||
}
|
||||
|
||||
// ==================== List 操作 ====================
|
||||
|
||||
/**
|
||||
* 从左侧插入
|
||||
*/
|
||||
async lpush(key: string, ...values: (string | number)[]): Promise<number> {
|
||||
return this.redis.lpush(key, ...values);
|
||||
}
|
||||
|
||||
/**
|
||||
* 从右侧插入
|
||||
*/
|
||||
async rpush(key: string, ...values: (string | number)[]): Promise<number> {
|
||||
return this.redis.rpush(key, ...values);
|
||||
}
|
||||
|
||||
/**
|
||||
* 从左侧弹出
|
||||
*/
|
||||
async lpop(key: string): Promise<string | null> {
|
||||
return this.redis.lpop(key);
|
||||
}
|
||||
|
||||
/**
|
||||
* 从右侧弹出
|
||||
*/
|
||||
async rpop(key: string): Promise<string | null> {
|
||||
return this.redis.rpop(key);
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取列表范围
|
||||
*/
|
||||
async lrange(key: string, start: number, stop: number): Promise<string[]> {
|
||||
return this.redis.lrange(key, start, stop);
|
||||
}
|
||||
|
||||
// ==================== Set 操作 ====================
|
||||
|
||||
/**
|
||||
* 添加集合成员
|
||||
*/
|
||||
async sadd(key: string, ...members: (string | number)[]): Promise<number> {
|
||||
return this.redis.sadd(key, ...members);
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取所有集合成员
|
||||
*/
|
||||
async smembers(key: string): Promise<string[]> {
|
||||
return this.redis.smembers(key);
|
||||
}
|
||||
|
||||
/**
|
||||
* 检查是否为集合成员
|
||||
*/
|
||||
async sismember(key: string, member: string | number): Promise<number> {
|
||||
return this.redis.sismember(key, member);
|
||||
}
|
||||
|
||||
/**
|
||||
* 移除集合成员
|
||||
*/
|
||||
async srem(key: string, ...members: (string | number)[]): Promise<number> {
|
||||
return this.redis.srem(key, ...members);
|
||||
}
|
||||
|
||||
// ==================== 原始客户端 ====================
|
||||
|
||||
/**
|
||||
* 获取原始 Redis 客户端
|
||||
* 用于执行未封装的操作
|
||||
*/
|
||||
getClient(): Redis {
|
||||
return this.redis;
|
||||
}
|
||||
}
|
||||
43
apps/api/src/health/dto/health.dto.ts
Normal file
43
apps/api/src/health/dto/health.dto.ts
Normal file
@@ -0,0 +1,43 @@
|
||||
import { ApiProperty } from '@nestjs/swagger';
|
||||
import type {
|
||||
ServiceHealth,
|
||||
ServicesHealth,
|
||||
HealthCheckResponse,
|
||||
ServiceHealthStatus,
|
||||
OverallHealthStatus,
|
||||
} from '@seclusion/shared';
|
||||
|
||||
export class ServiceHealthDto implements ServiceHealth {
|
||||
@ApiProperty({
|
||||
enum: ['ok', 'error'],
|
||||
example: 'ok',
|
||||
description: '服务状态',
|
||||
})
|
||||
status: ServiceHealthStatus;
|
||||
}
|
||||
|
||||
export class ServicesHealthDto implements ServicesHealth {
|
||||
@ApiProperty({ type: ServiceHealthDto, description: '数据库健康状态' })
|
||||
database: ServiceHealthDto;
|
||||
|
||||
@ApiProperty({ type: ServiceHealthDto, description: 'Redis 健康状态' })
|
||||
redis: ServiceHealthDto;
|
||||
}
|
||||
|
||||
export class HealthCheckResponseDto implements HealthCheckResponse {
|
||||
@ApiProperty({
|
||||
enum: ['ok', 'degraded', 'error'],
|
||||
example: 'ok',
|
||||
description: '整体健康状态:ok-全部正常,degraded-部分异常,error-全部异常',
|
||||
})
|
||||
status: OverallHealthStatus;
|
||||
|
||||
@ApiProperty({
|
||||
example: '2026-01-16T10:00:00.000Z',
|
||||
description: '检查时间戳',
|
||||
})
|
||||
timestamp: string;
|
||||
|
||||
@ApiProperty({ type: ServicesHealthDto, description: '各服务健康状态' })
|
||||
services: ServicesHealthDto;
|
||||
}
|
||||
@@ -4,12 +4,21 @@ import { NestFactory } from '@nestjs/core';
|
||||
import { SwaggerModule, DocumentBuilder } from '@nestjs/swagger';
|
||||
|
||||
import { AppModule } from './app.module';
|
||||
import { EncryptionInterceptor } from './common/crypto';
|
||||
|
||||
async function bootstrap() {
|
||||
const app = await NestFactory.create(AppModule);
|
||||
const app = await NestFactory.create(AppModule, {
|
||||
// 日志级别: 'log' | 'error' | 'warn' | 'debug' | 'verbose'
|
||||
// 开发环境显示所有日志,生产环境只显示 error/warn/log
|
||||
logger:
|
||||
process.env.NODE_ENV === 'production'
|
||||
? ['error', 'warn', 'log']
|
||||
: ['error', 'warn', 'log', 'debug', 'verbose'],
|
||||
});
|
||||
|
||||
const configService = app.get(ConfigService);
|
||||
const port = configService.get<number>('PORT', 4000);
|
||||
const enableEncryption = configService.get<string>('ENABLE_ENCRYPTION') === 'true';
|
||||
|
||||
// 全局验证管道
|
||||
app.useGlobalPipes(
|
||||
@@ -20,10 +29,17 @@ async function bootstrap() {
|
||||
})
|
||||
);
|
||||
|
||||
// 注册全局加密拦截器
|
||||
if (enableEncryption) {
|
||||
const encryptionInterceptor = app.get(EncryptionInterceptor);
|
||||
app.useGlobalInterceptors(encryptionInterceptor);
|
||||
}
|
||||
|
||||
// CORS 配置
|
||||
app.enableCors({
|
||||
origin: ['http://localhost:3000'],
|
||||
credentials: true,
|
||||
exposedHeaders: ['X-Encrypted'],
|
||||
});
|
||||
|
||||
// Swagger 配置
|
||||
@@ -39,6 +55,7 @@ async function bootstrap() {
|
||||
await app.listen(port);
|
||||
console.log(`🚀 Application is running on: http://localhost:${port}`);
|
||||
console.log(`📚 Swagger docs: http://localhost:${port}/api/docs`);
|
||||
console.log(`🔐 Encryption: ${enableEncryption ? 'enabled' : 'disabled'}`);
|
||||
}
|
||||
|
||||
bootstrap();
|
||||
|
||||
@@ -27,7 +27,10 @@ function createSoftDeleteExtension(client: PrismaClient) {
|
||||
return query(args);
|
||||
},
|
||||
async findUnique({ model, args, query }) {
|
||||
if (isSoftDeleteModel(model) && (args.where as Record<string, unknown>)?.deletedAt === undefined) {
|
||||
if (
|
||||
isSoftDeleteModel(model) &&
|
||||
(args.where as Record<string, unknown>)?.deletedAt === undefined
|
||||
) {
|
||||
(args.where as Record<string, unknown>).deletedAt = null;
|
||||
}
|
||||
return query(args);
|
||||
@@ -42,31 +45,35 @@ function createSoftDeleteExtension(client: PrismaClient) {
|
||||
if (isSoftDeleteModel(model)) {
|
||||
// 软删除:使用原始客户端执行 update
|
||||
const modelName = model!.charAt(0).toLowerCase() + model!.slice(1);
|
||||
return (client as unknown as Record<string, { update: (args: unknown) => unknown }>)[modelName].update({
|
||||
return (client as unknown as Record<string, { update: (args: unknown) => unknown }>)[
|
||||
modelName
|
||||
].update({
|
||||
where: args.where,
|
||||
data: { deletedAt: new Date() },
|
||||
});
|
||||
}
|
||||
// 非软删除模型,执行真正删除
|
||||
const modelName = model!.charAt(0).toLowerCase() + model!.slice(1);
|
||||
return (client as unknown as Record<string, { delete: (args: unknown) => unknown }>)[modelName].delete(args);
|
||||
return (client as unknown as Record<string, { delete: (args: unknown) => unknown }>)[
|
||||
modelName
|
||||
].delete(args);
|
||||
},
|
||||
async deleteMany({ model, args }) {
|
||||
if (isSoftDeleteModel(model)) {
|
||||
// 软删除:使用原始客户端执行 updateMany
|
||||
const modelName = model!.charAt(0).toLowerCase() + model!.slice(1);
|
||||
return (client as unknown as Record<string, { updateMany: (args: unknown) => unknown }>)[
|
||||
modelName
|
||||
].updateMany({
|
||||
return (
|
||||
client as unknown as Record<string, { updateMany: (args: unknown) => unknown }>
|
||||
)[modelName].updateMany({
|
||||
where: args.where,
|
||||
data: { deletedAt: new Date() },
|
||||
});
|
||||
}
|
||||
// 非软删除模型,执行真正删除
|
||||
const modelName = model!.charAt(0).toLowerCase() + model!.slice(1);
|
||||
return (client as unknown as Record<string, { deleteMany: (args: unknown) => unknown }>)[modelName].deleteMany(
|
||||
args
|
||||
);
|
||||
return (client as unknown as Record<string, { deleteMany: (args: unknown) => unknown }>)[
|
||||
modelName
|
||||
].deleteMany(args);
|
||||
},
|
||||
},
|
||||
},
|
||||
@@ -106,4 +113,17 @@ export class PrismaService implements OnModuleInit, OnModuleDestroy {
|
||||
async onModuleDestroy() {
|
||||
await this._client.$disconnect();
|
||||
}
|
||||
|
||||
/**
|
||||
* 健康检查
|
||||
* 执行简单查询验证数据库连接
|
||||
*/
|
||||
async healthCheck(): Promise<boolean> {
|
||||
try {
|
||||
await this._client.$queryRaw`SELECT 1`;
|
||||
return true;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,9 +1,36 @@
|
||||
import { ApiPropertyOptional } from '@nestjs/swagger';
|
||||
import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger';
|
||||
import type { UpdateUserDto as IUpdateUserDto, UserResponse } from '@seclusion/shared';
|
||||
import { IsString, IsOptional } from 'class-validator';
|
||||
|
||||
export class UpdateUserDto {
|
||||
import { createPaginatedResponseDto } from '@/common/crud';
|
||||
|
||||
export class UpdateUserDto implements IUpdateUserDto {
|
||||
@ApiPropertyOptional({ example: '张三', description: '用户名称' })
|
||||
@IsString()
|
||||
@IsOptional()
|
||||
name?: string;
|
||||
}
|
||||
|
||||
/** 用户响应 DTO */
|
||||
export class UserResponseDto implements UserResponse {
|
||||
@ApiProperty({ example: 'clxxx123', description: '用户 ID' })
|
||||
id: string;
|
||||
|
||||
@ApiProperty({ example: 'user@example.com', description: '用户邮箱' })
|
||||
email: string;
|
||||
|
||||
@ApiProperty({ example: '张三', description: '用户名称', nullable: true })
|
||||
name: string | null;
|
||||
|
||||
@ApiProperty({ example: '2026-01-16T10:00:00.000Z', description: '创建时间' })
|
||||
createdAt: string;
|
||||
|
||||
@ApiProperty({ example: '2026-01-16T10:00:00.000Z', description: '更新时间' })
|
||||
updatedAt: string;
|
||||
|
||||
@ApiPropertyOptional({ example: '2026-01-16T10:00:00.000Z', description: '删除时间', nullable: true })
|
||||
deletedAt?: string | null;
|
||||
}
|
||||
|
||||
/** 分页用户响应 DTO */
|
||||
export class PaginatedUserResponseDto extends createPaginatedResponseDto(UserResponseDto) {}
|
||||
|
||||
@@ -1,11 +1,12 @@
|
||||
import { Controller, Get, Patch, Delete, Param, Body, UseGuards } from '@nestjs/common';
|
||||
import { ApiTags, ApiOperation, ApiBearerAuth } from '@nestjs/swagger';
|
||||
import { Controller, Get, Patch, Delete, Param, Body, Query, UseGuards } from '@nestjs/common';
|
||||
import { ApiTags, ApiOperation, ApiBearerAuth, ApiOkResponse } from '@nestjs/swagger';
|
||||
|
||||
import { JwtAuthGuard } from '../auth/guards/jwt-auth.guard';
|
||||
|
||||
import { UpdateUserDto } from './dto/user.dto';
|
||||
import { UpdateUserDto, UserResponseDto, PaginatedUserResponseDto } from './dto/user.dto';
|
||||
import { UserService } from './user.service';
|
||||
|
||||
import { JwtAuthGuard } from '@/auth/guards/jwt-auth.guard';
|
||||
import { PaginationQueryDto } from '@/common/crud';
|
||||
|
||||
@ApiTags('用户')
|
||||
@Controller('users')
|
||||
@UseGuards(JwtAuthGuard)
|
||||
@@ -14,37 +15,43 @@ export class UserController {
|
||||
constructor(private readonly userService: UserService) {}
|
||||
|
||||
@Get()
|
||||
@ApiOperation({ summary: '获取所有用户' })
|
||||
findAll() {
|
||||
return this.userService.findAll();
|
||||
@ApiOperation({ summary: '获取所有用户(分页)' })
|
||||
@ApiOkResponse({ type: PaginatedUserResponseDto, description: '用户列表' })
|
||||
findAll(@Query() query: PaginationQueryDto) {
|
||||
return this.userService.findAll(query);
|
||||
}
|
||||
|
||||
@Get('deleted')
|
||||
@ApiOperation({ summary: '获取已删除的用户列表' })
|
||||
findDeleted() {
|
||||
return this.userService.findDeleted();
|
||||
@ApiOperation({ summary: '获取已删除的用户列表(分页)' })
|
||||
@ApiOkResponse({ type: PaginatedUserResponseDto, description: '已删除用户列表' })
|
||||
findDeleted(@Query() query: PaginationQueryDto) {
|
||||
return this.userService.findDeleted(query);
|
||||
}
|
||||
|
||||
@Get(':id')
|
||||
@ApiOperation({ summary: '根据 ID 获取用户' })
|
||||
@ApiOkResponse({ type: UserResponseDto, description: '用户详情' })
|
||||
findById(@Param('id') id: string) {
|
||||
return this.userService.findById(id);
|
||||
}
|
||||
|
||||
@Patch(':id')
|
||||
@ApiOperation({ summary: '更新用户信息' })
|
||||
@ApiOkResponse({ type: UserResponseDto, description: '更新后的用户信息' })
|
||||
update(@Param('id') id: string, @Body() dto: UpdateUserDto) {
|
||||
return this.userService.update(id, dto);
|
||||
}
|
||||
|
||||
@Delete(':id')
|
||||
@ApiOperation({ summary: '删除用户' })
|
||||
@ApiOkResponse({ type: UserResponseDto, description: '被删除的用户信息' })
|
||||
delete(@Param('id') id: string) {
|
||||
return this.userService.delete(id);
|
||||
}
|
||||
|
||||
@Patch(':id/restore')
|
||||
@ApiOperation({ summary: '恢复已删除的用户' })
|
||||
@ApiOkResponse({ type: UserResponseDto, description: '恢复后的用户信息' })
|
||||
restore(@Param('id') id: string) {
|
||||
return this.userService.restore(id);
|
||||
}
|
||||
|
||||
@@ -1,114 +1,46 @@
|
||||
import { Injectable, NotFoundException } from '@nestjs/common';
|
||||
|
||||
import { PrismaService } from '../prisma/prisma.service';
|
||||
import { Injectable } from '@nestjs/common';
|
||||
import { Prisma, User } from '@prisma/client';
|
||||
|
||||
import { UpdateUserDto } from './dto/user.dto';
|
||||
|
||||
import { CrudOptions, CrudService } from '@/common/crud';
|
||||
import { PrismaService } from '@/prisma/prisma.service';
|
||||
|
||||
@Injectable()
|
||||
export class UserService {
|
||||
constructor(private prisma: PrismaService) {}
|
||||
|
||||
async findAll() {
|
||||
// 底层自动过滤已删除记录
|
||||
return this.prisma.user.findMany({
|
||||
select: {
|
||||
id: true,
|
||||
email: true,
|
||||
name: true,
|
||||
createdAt: true,
|
||||
updatedAt: true,
|
||||
},
|
||||
});
|
||||
@CrudOptions({
|
||||
softDelete: true,
|
||||
defaultPageSize: 20,
|
||||
maxPageSize: 100,
|
||||
defaultSortBy: 'createdAt',
|
||||
defaultSortOrder: 'desc',
|
||||
defaultSelect: {
|
||||
id: true,
|
||||
email: true,
|
||||
name: true,
|
||||
createdAt: true,
|
||||
updatedAt: true,
|
||||
},
|
||||
})
|
||||
export class UserService extends CrudService<
|
||||
User,
|
||||
Prisma.UserCreateInput,
|
||||
UpdateUserDto,
|
||||
Prisma.UserWhereInput,
|
||||
Prisma.UserWhereUniqueInput
|
||||
> {
|
||||
constructor(prisma: PrismaService) {
|
||||
super(prisma, 'user');
|
||||
}
|
||||
|
||||
async findById(id: string) {
|
||||
// 底层自动过滤已删除记录
|
||||
const user = await this.prisma.user.findUnique({
|
||||
where: { id },
|
||||
select: {
|
||||
id: true,
|
||||
email: true,
|
||||
name: true,
|
||||
createdAt: true,
|
||||
updatedAt: true,
|
||||
},
|
||||
});
|
||||
|
||||
if (!user) {
|
||||
throw new NotFoundException('用户不存在');
|
||||
}
|
||||
|
||||
return user;
|
||||
protected getNotFoundMessage(): string {
|
||||
return '用户不存在';
|
||||
}
|
||||
|
||||
async update(id: string, dto: UpdateUserDto) {
|
||||
const user = await this.prisma.user.findUnique({ where: { id } });
|
||||
|
||||
if (!user) {
|
||||
throw new NotFoundException('用户不存在');
|
||||
}
|
||||
|
||||
return this.prisma.user.update({
|
||||
where: { id },
|
||||
data: dto,
|
||||
select: {
|
||||
id: true,
|
||||
email: true,
|
||||
name: true,
|
||||
createdAt: true,
|
||||
updatedAt: true,
|
||||
},
|
||||
});
|
||||
protected getDeletedMessage(): string {
|
||||
return '用户已删除';
|
||||
}
|
||||
|
||||
async delete(id: string) {
|
||||
const user = await this.prisma.user.findUnique({ where: { id } });
|
||||
|
||||
if (!user) {
|
||||
throw new NotFoundException('用户不存在');
|
||||
}
|
||||
|
||||
// 底层自动转换为软删除
|
||||
await this.prisma.user.delete({ where: { id } });
|
||||
|
||||
return { message: '用户已删除' };
|
||||
}
|
||||
|
||||
async findDeleted() {
|
||||
// 显式指定 deletedAt 条件,绕过自动过滤
|
||||
return this.prisma.user.findMany({
|
||||
where: { deletedAt: { not: null } },
|
||||
select: {
|
||||
id: true,
|
||||
email: true,
|
||||
name: true,
|
||||
createdAt: true,
|
||||
updatedAt: true,
|
||||
deletedAt: true,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
async restore(id: string) {
|
||||
// 显式指定 deletedAt 条件,查询已删除的用户
|
||||
const user = await this.prisma.user.findFirst({
|
||||
where: { id, deletedAt: { not: null } },
|
||||
});
|
||||
|
||||
if (!user) {
|
||||
throw new NotFoundException('已删除的用户不存在');
|
||||
}
|
||||
|
||||
return this.prisma.user.update({
|
||||
where: { id },
|
||||
data: { deletedAt: null },
|
||||
select: {
|
||||
id: true,
|
||||
email: true,
|
||||
name: true,
|
||||
createdAt: true,
|
||||
updatedAt: true,
|
||||
},
|
||||
});
|
||||
protected getDeletedNotFoundMessage(): string {
|
||||
return '已删除的用户不存在';
|
||||
}
|
||||
}
|
||||
|
||||
9
apps/web/.env
Normal file
9
apps/web/.env
Normal file
@@ -0,0 +1,9 @@
|
||||
# API 配置
|
||||
NEXT_PUBLIC_API_URL=http://localhost:4000
|
||||
|
||||
# 加密配置
|
||||
# 是否启用加密 (需与后端 ENABLE_ENCRYPTION 保持一致)
|
||||
NEXT_PUBLIC_ENABLE_ENCRYPTION=false
|
||||
# 加密密钥 (需与后端 ENCRYPTION_KEY 保持一致)
|
||||
# 开发环境默认密钥 (仅用于开发测试,生产环境必须更换)
|
||||
NEXT_PUBLIC_ENCRYPTION_KEY=dGhpc2lzYXRlc3RrZXlmb3JkZXZlbG9wbWVudG9ubHk
|
||||
2
apps/web/next-env.d.ts
vendored
2
apps/web/next-env.d.ts
vendored
@@ -1,6 +1,6 @@
|
||||
/// <reference types="next" />
|
||||
/// <reference types="next/image-types/global" />
|
||||
import "./.next/dev/types/routes.d.ts";
|
||||
import "./.next/types/routes.d.ts";
|
||||
|
||||
// NOTE: This file should not be edited
|
||||
// see https://nextjs.org/docs/app/api-reference/config/typescript for more information.
|
||||
|
||||
@@ -3,9 +3,9 @@
|
||||
"version": "0.0.1",
|
||||
"private": true,
|
||||
"scripts": {
|
||||
"dev": "next dev --port 3000",
|
||||
"build": "next build",
|
||||
"start": "next start",
|
||||
"dev": "dotenv -e .env -e .env.local -- next dev --port 3000",
|
||||
"build": "dotenv -e .env -e .env.local -- next build",
|
||||
"start": "dotenv -e .env -e .env.local -- next start",
|
||||
"lint": "eslint src",
|
||||
"clean": "rm -rf .next .turbo node_modules"
|
||||
},
|
||||
@@ -21,6 +21,7 @@
|
||||
"@types/node": "^22.10.2",
|
||||
"@types/react": "^19.0.2",
|
||||
"@types/react-dom": "^19.0.2",
|
||||
"dotenv-cli": "^11.0.0",
|
||||
"eslint": "^9.39.0",
|
||||
"eslint-config-next": "^16.1.1",
|
||||
"typescript": "^5.7.2"
|
||||
|
||||
@@ -6,7 +6,7 @@ export default function Home() {
|
||||
<div className="text-center">
|
||||
<h1 className="text-4xl font-bold mb-4">Seclusion</h1>
|
||||
<p className="text-lg text-gray-600 mb-8">A monorepo template with Next.js + NestJS</p>
|
||||
<div className="flex gap-4 justify-center">
|
||||
<div className="flex gap-4 justify-center flex-wrap">
|
||||
<a
|
||||
href="http://localhost:4000/health"
|
||||
className="px-6 py-3 bg-blue-600 text-white rounded-lg hover:bg-blue-700 transition"
|
||||
@@ -21,6 +21,12 @@ export default function Home() {
|
||||
>
|
||||
API Docs
|
||||
</a>
|
||||
<a
|
||||
href="/test/users"
|
||||
className="px-6 py-3 bg-green-600 text-white rounded-lg hover:bg-green-700 transition"
|
||||
>
|
||||
用户管理测试
|
||||
</a>
|
||||
</div>
|
||||
<p className="mt-8 text-sm text-gray-400">
|
||||
Generated at: {formatDate(new Date(), 'YYYY-MM-DD HH:mm:ss')}
|
||||
|
||||
606
apps/web/src/app/test/users/page.tsx
Normal file
606
apps/web/src/app/test/users/page.tsx
Normal file
@@ -0,0 +1,606 @@
|
||||
'use client';
|
||||
|
||||
import { useState, useEffect, useCallback, useRef } from 'react';
|
||||
|
||||
import { Captcha } from '@/components/Captcha';
|
||||
import { apiFetch } from '@/lib/api';
|
||||
import { isEncryptionEnabled } from '@/lib/crypto';
|
||||
import type {
|
||||
AuthResponse,
|
||||
AuthUser,
|
||||
UserResponse,
|
||||
PaginatedResponse,
|
||||
MessageState,
|
||||
RegisterFormState,
|
||||
LoginFormState,
|
||||
UpdateUserFormState,
|
||||
RefreshTokenResponse,
|
||||
} from '@/types';
|
||||
import { CaptchaScene } from '@/types';
|
||||
|
||||
export default function UserManagementPage() {
|
||||
// 认证状态
|
||||
const [token, setToken] = useState<string>('');
|
||||
const [refreshToken, setRefreshToken] = useState<string>('');
|
||||
const [currentUser, setCurrentUser] = useState<AuthUser | null>(null);
|
||||
|
||||
// 用于存储 refreshToken 的 ref,避免 useCallback 依赖问题
|
||||
const refreshTokenRef = useRef(refreshToken);
|
||||
refreshTokenRef.current = refreshToken;
|
||||
|
||||
// 用户列表
|
||||
const [users, setUsers] = useState<UserResponse[]>([]);
|
||||
const [deletedUsers, setDeletedUsers] = useState<UserResponse[]>([]);
|
||||
|
||||
// 表单状态
|
||||
const [registerForm, setRegisterForm] = useState<RegisterFormState>({
|
||||
email: '',
|
||||
password: '',
|
||||
name: '',
|
||||
captchaId: '',
|
||||
captchaCode: '',
|
||||
});
|
||||
const [loginForm, setLoginForm] = useState<LoginFormState>({
|
||||
email: '',
|
||||
password: '',
|
||||
captchaId: '',
|
||||
captchaCode: '',
|
||||
});
|
||||
const [updateForm, setUpdateForm] = useState<UpdateUserFormState>({ id: '', name: '' });
|
||||
|
||||
// 验证码刷新 key(用于重置验证码组件)
|
||||
const [registerCaptchaKey, setRegisterCaptchaKey] = useState(0);
|
||||
const [loginCaptchaKey, setLoginCaptchaKey] = useState(0);
|
||||
|
||||
// UI 状态
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [message, setMessage] = useState<MessageState | null>(null);
|
||||
const [activeTab, setActiveTab] = useState<'auth' | 'users' | 'deleted'>('auth');
|
||||
|
||||
// 显示消息
|
||||
const showMessage = (type: 'success' | 'error', text: string) => {
|
||||
setMessage({ type, text });
|
||||
setTimeout(() => setMessage(null), 5000);
|
||||
};
|
||||
|
||||
// 刷新 Token
|
||||
const handleRefreshToken = useCallback(async (): Promise<boolean> => {
|
||||
const currentRefreshToken = refreshTokenRef.current;
|
||||
if (!currentRefreshToken) return false;
|
||||
|
||||
try {
|
||||
const response = await apiFetch<RefreshTokenResponse>('/auth/refresh', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({ refreshToken: currentRefreshToken }),
|
||||
});
|
||||
setToken(response.accessToken);
|
||||
setRefreshToken(response.refreshToken);
|
||||
return true;
|
||||
} catch {
|
||||
// 刷新失败,清除认证状态
|
||||
setToken('');
|
||||
setRefreshToken('');
|
||||
setCurrentUser(null);
|
||||
return false;
|
||||
}
|
||||
}, []);
|
||||
|
||||
// 注册
|
||||
const handleRegister = async (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
setLoading(true);
|
||||
try {
|
||||
const response = await apiFetch<AuthResponse>('/auth/register', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify(registerForm),
|
||||
});
|
||||
setToken(response.accessToken);
|
||||
setRefreshToken(response.refreshToken);
|
||||
setCurrentUser(response.user);
|
||||
showMessage('success', '注册成功');
|
||||
setRegisterForm({ email: '', password: '', name: '', captchaId: '', captchaCode: '' });
|
||||
setRegisterCaptchaKey((k) => k + 1); // 刷新验证码
|
||||
} catch (error) {
|
||||
showMessage('error', error instanceof Error ? error.message : '注册失败');
|
||||
setRegisterCaptchaKey((k) => k + 1); // 失败后刷新验证码
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
// 登录
|
||||
const handleLogin = async (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
setLoading(true);
|
||||
try {
|
||||
const response = await apiFetch<AuthResponse>('/auth/login', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify(loginForm),
|
||||
});
|
||||
setToken(response.accessToken);
|
||||
setRefreshToken(response.refreshToken);
|
||||
setCurrentUser(response.user);
|
||||
showMessage('success', '登录成功');
|
||||
setLoginForm({ email: '', password: '', captchaId: '', captchaCode: '' });
|
||||
setLoginCaptchaKey((k) => k + 1); // 刷新验证码
|
||||
} catch (error) {
|
||||
showMessage('error', error instanceof Error ? error.message : '登录失败');
|
||||
setLoginCaptchaKey((k) => k + 1); // 失败后刷新验证码
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
// 获取当前用户
|
||||
const fetchCurrentUser = useCallback(async () => {
|
||||
if (!token) return;
|
||||
try {
|
||||
const user = await apiFetch<AuthUser>('/auth/me', { token });
|
||||
setCurrentUser(user);
|
||||
} catch (error) {
|
||||
showMessage('error', error instanceof Error ? error.message : '获取用户信息失败');
|
||||
}
|
||||
}, [token]);
|
||||
|
||||
// 获取用户列表
|
||||
const fetchUsers = useCallback(async () => {
|
||||
if (!token) return;
|
||||
setLoading(true);
|
||||
try {
|
||||
const response = await apiFetch<PaginatedResponse<UserResponse>>('/users', { token });
|
||||
setUsers(response.items);
|
||||
} catch (error) {
|
||||
showMessage('error', error instanceof Error ? error.message : '获取用户列表失败');
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, [token]);
|
||||
|
||||
// 获取已删除用户列表
|
||||
const fetchDeletedUsers = useCallback(async () => {
|
||||
if (!token) return;
|
||||
setLoading(true);
|
||||
try {
|
||||
const response = await apiFetch<PaginatedResponse<UserResponse>>('/users/deleted', {
|
||||
token,
|
||||
});
|
||||
setDeletedUsers(response.items);
|
||||
} catch (error) {
|
||||
showMessage('error', error instanceof Error ? error.message : '获取已删除用户列表失败');
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, [token]);
|
||||
|
||||
// 更新用户
|
||||
const handleUpdateUser = async (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
if (!updateForm.id) return;
|
||||
setLoading(true);
|
||||
try {
|
||||
await apiFetch(`/users/${updateForm.id}`, {
|
||||
method: 'PATCH',
|
||||
body: JSON.stringify({ name: updateForm.name }),
|
||||
token,
|
||||
});
|
||||
showMessage('success', '更新成功');
|
||||
setUpdateForm({ id: '', name: '' });
|
||||
fetchUsers();
|
||||
} catch (error) {
|
||||
showMessage('error', error instanceof Error ? error.message : '更新失败');
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
// 删除用户
|
||||
const handleDeleteUser = async (id: string) => {
|
||||
if (!confirm('确定要删除该用户吗?')) return;
|
||||
setLoading(true);
|
||||
try {
|
||||
await apiFetch(`/users/${id}`, { method: 'DELETE', token });
|
||||
showMessage('success', '删除成功');
|
||||
fetchUsers();
|
||||
fetchDeletedUsers();
|
||||
} catch (error) {
|
||||
showMessage('error', error instanceof Error ? error.message : '删除失败');
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
// 恢复用户
|
||||
const handleRestoreUser = async (id: string) => {
|
||||
setLoading(true);
|
||||
try {
|
||||
await apiFetch(`/users/${id}/restore`, { method: 'PATCH', token });
|
||||
showMessage('success', '恢复成功');
|
||||
fetchUsers();
|
||||
fetchDeletedUsers();
|
||||
} catch (error) {
|
||||
showMessage('error', error instanceof Error ? error.message : '恢复失败');
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
// 登出
|
||||
const handleLogout = () => {
|
||||
setToken('');
|
||||
setRefreshToken('');
|
||||
setCurrentUser(null);
|
||||
setUsers([]);
|
||||
setDeletedUsers([]);
|
||||
showMessage('success', '已登出');
|
||||
};
|
||||
|
||||
// 登录后自动获取数据
|
||||
useEffect(() => {
|
||||
if (token) {
|
||||
fetchCurrentUser();
|
||||
fetchUsers();
|
||||
fetchDeletedUsers();
|
||||
}
|
||||
}, [token, fetchCurrentUser, fetchUsers, fetchDeletedUsers]);
|
||||
|
||||
return (
|
||||
<main className="min-h-screen bg-gray-50 p-8">
|
||||
<div className="max-w-4xl mx-auto">
|
||||
{/* 标题 */}
|
||||
<div className="mb-8">
|
||||
<h1 className="text-3xl font-bold text-gray-900">用户管理测试</h1>
|
||||
<p className="text-gray-600 mt-2">
|
||||
加密状态:{' '}
|
||||
{isEncryptionEnabled() ? (
|
||||
<span className="text-green-600 font-medium">已启用</span>
|
||||
) : (
|
||||
<span className="text-gray-400">未启用</span>
|
||||
)}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* 消息提示 */}
|
||||
{message && (
|
||||
<div
|
||||
className={`mb-4 p-4 rounded-lg ${
|
||||
message.type === 'success' ? 'bg-green-100 text-green-800' : 'bg-red-100 text-red-800'
|
||||
}`}
|
||||
>
|
||||
{message.text}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 当前用户信息 */}
|
||||
{currentUser && (
|
||||
<div className="mb-6 p-4 bg-blue-50 rounded-lg flex justify-between items-center">
|
||||
<div>
|
||||
<span className="text-blue-800">当前用户: </span>
|
||||
<span className="font-medium">{currentUser.name || currentUser.email}</span>
|
||||
<span className="text-gray-500 ml-2">({currentUser.email})</span>
|
||||
</div>
|
||||
<div className="flex gap-2">
|
||||
<button
|
||||
onClick={async () => {
|
||||
const success = await handleRefreshToken();
|
||||
if (success) {
|
||||
showMessage('success', 'Token 刷新成功');
|
||||
} else {
|
||||
showMessage('error', 'Token 刷新失败,请重新登录');
|
||||
}
|
||||
}}
|
||||
disabled={loading || !refreshToken}
|
||||
className="px-4 py-2 bg-blue-100 text-blue-700 rounded hover:bg-blue-200 disabled:opacity-50"
|
||||
>
|
||||
刷新 Token
|
||||
</button>
|
||||
<button
|
||||
onClick={handleLogout}
|
||||
className="px-4 py-2 bg-gray-200 text-gray-700 rounded hover:bg-gray-300"
|
||||
>
|
||||
登出
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 标签页 */}
|
||||
<div className="flex border-b mb-6">
|
||||
{(['auth', 'users', 'deleted'] as const).map((tab) => (
|
||||
<button
|
||||
key={tab}
|
||||
onClick={() => setActiveTab(tab)}
|
||||
className={`px-6 py-3 font-medium ${
|
||||
activeTab === tab
|
||||
? 'border-b-2 border-blue-600 text-blue-600'
|
||||
: 'text-gray-500 hover:text-gray-700'
|
||||
}`}
|
||||
>
|
||||
{tab === 'auth' ? '认证' : tab === 'users' ? '用户列表' : '已删除用户'}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* 认证标签页 */}
|
||||
{activeTab === 'auth' && (
|
||||
<div className="grid md:grid-cols-2 gap-6">
|
||||
{/* 注册表单 */}
|
||||
<div className="bg-white p-6 rounded-lg shadow">
|
||||
<h2 className="text-xl font-semibold mb-4">注册</h2>
|
||||
<form onSubmit={handleRegister} className="space-y-4">
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">邮箱</label>
|
||||
<input
|
||||
type="email"
|
||||
value={registerForm.email}
|
||||
onChange={(e) => setRegisterForm({ ...registerForm, email: e.target.value })}
|
||||
className="w-full px-3 py-2 border rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-blue-500"
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">密码</label>
|
||||
<input
|
||||
type="password"
|
||||
value={registerForm.password}
|
||||
onChange={(e) => setRegisterForm({ ...registerForm, password: e.target.value })}
|
||||
className="w-full px-3 py-2 border rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-blue-500"
|
||||
required
|
||||
minLength={6}
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">
|
||||
姓名 (可选)
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
value={registerForm.name}
|
||||
onChange={(e) => setRegisterForm({ ...registerForm, name: e.target.value })}
|
||||
className="w-full px-3 py-2 border rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-blue-500"
|
||||
/>
|
||||
</div>
|
||||
<Captcha
|
||||
key={registerCaptchaKey}
|
||||
scene={CaptchaScene.REGISTER}
|
||||
value={registerForm.captchaCode}
|
||||
onChange={(captchaId, captchaCode) =>
|
||||
setRegisterForm({ ...registerForm, captchaId, captchaCode })
|
||||
}
|
||||
disabled={loading}
|
||||
/>
|
||||
<button
|
||||
type="submit"
|
||||
disabled={loading}
|
||||
className="w-full py-2 bg-blue-600 text-white rounded-lg hover:bg-blue-700 disabled:opacity-50"
|
||||
>
|
||||
{loading ? '处理中...' : '注册'}
|
||||
</button>
|
||||
</form>
|
||||
</div>
|
||||
|
||||
{/* 登录表单 */}
|
||||
<div className="bg-white p-6 rounded-lg shadow">
|
||||
<h2 className="text-xl font-semibold mb-4">登录</h2>
|
||||
<form onSubmit={handleLogin} className="space-y-4">
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">邮箱</label>
|
||||
<input
|
||||
type="email"
|
||||
value={loginForm.email}
|
||||
onChange={(e) => setLoginForm({ ...loginForm, email: e.target.value })}
|
||||
className="w-full px-3 py-2 border rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-blue-500"
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">密码</label>
|
||||
<input
|
||||
type="password"
|
||||
value={loginForm.password}
|
||||
onChange={(e) => setLoginForm({ ...loginForm, password: e.target.value })}
|
||||
className="w-full px-3 py-2 border rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-blue-500"
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
<Captcha
|
||||
key={loginCaptchaKey}
|
||||
scene={CaptchaScene.LOGIN}
|
||||
value={loginForm.captchaCode}
|
||||
onChange={(captchaId, captchaCode) =>
|
||||
setLoginForm({ ...loginForm, captchaId, captchaCode })
|
||||
}
|
||||
disabled={loading}
|
||||
/>
|
||||
<button
|
||||
type="submit"
|
||||
disabled={loading}
|
||||
className="w-full py-2 bg-green-600 text-white rounded-lg hover:bg-green-700 disabled:opacity-50"
|
||||
>
|
||||
{loading ? '处理中...' : '登录'}
|
||||
</button>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 用户列表标签页 */}
|
||||
{activeTab === 'users' && (
|
||||
<div className="space-y-6">
|
||||
{/* 更新用户表单 */}
|
||||
<div className="bg-white p-6 rounded-lg shadow">
|
||||
<h2 className="text-xl font-semibold mb-4">更新用户</h2>
|
||||
<form onSubmit={handleUpdateUser} className="flex gap-4">
|
||||
<select
|
||||
value={updateForm.id}
|
||||
onChange={(e) => {
|
||||
const user = users.find((u) => u.id === e.target.value);
|
||||
setUpdateForm({ id: e.target.value, name: user?.name || '' });
|
||||
}}
|
||||
className="flex-1 px-3 py-2 border rounded-lg focus:ring-2 focus:ring-blue-500"
|
||||
>
|
||||
<option value="">选择用户</option>
|
||||
{users.map((user) => (
|
||||
<option key={user.id} value={user.id}>
|
||||
{user.name || user.email}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
<input
|
||||
type="text"
|
||||
value={updateForm.name}
|
||||
onChange={(e) => setUpdateForm({ ...updateForm, name: e.target.value })}
|
||||
placeholder="新姓名"
|
||||
className="flex-1 px-3 py-2 border rounded-lg focus:ring-2 focus:ring-blue-500"
|
||||
/>
|
||||
<button
|
||||
type="submit"
|
||||
disabled={loading || !updateForm.id}
|
||||
className="px-6 py-2 bg-yellow-600 text-white rounded-lg hover:bg-yellow-700 disabled:opacity-50"
|
||||
>
|
||||
更新
|
||||
</button>
|
||||
</form>
|
||||
</div>
|
||||
|
||||
{/* 用户列表 */}
|
||||
<div className="bg-white rounded-lg shadow overflow-hidden">
|
||||
<div className="px-6 py-4 border-b flex justify-between items-center">
|
||||
<h2 className="text-xl font-semibold">用户列表</h2>
|
||||
<button
|
||||
onClick={fetchUsers}
|
||||
disabled={loading || !token}
|
||||
className="px-4 py-2 bg-gray-100 text-gray-700 rounded hover:bg-gray-200 disabled:opacity-50"
|
||||
>
|
||||
刷新
|
||||
</button>
|
||||
</div>
|
||||
{!token ? (
|
||||
<div className="p-6 text-center text-gray-500">请先登录</div>
|
||||
) : users.length === 0 ? (
|
||||
<div className="p-6 text-center text-gray-500">暂无用户</div>
|
||||
) : (
|
||||
<table className="w-full">
|
||||
<thead className="bg-gray-50">
|
||||
<tr>
|
||||
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase">
|
||||
ID
|
||||
</th>
|
||||
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase">
|
||||
邮箱
|
||||
</th>
|
||||
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase">
|
||||
姓名
|
||||
</th>
|
||||
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase">
|
||||
创建时间
|
||||
</th>
|
||||
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase">
|
||||
操作
|
||||
</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="divide-y divide-gray-200">
|
||||
{users.map((user) => (
|
||||
<tr key={user.id}>
|
||||
<td className="px-6 py-4 text-sm text-gray-500 font-mono">
|
||||
{user.id.slice(0, 8)}...
|
||||
</td>
|
||||
<td className="px-6 py-4 text-sm text-gray-900">{user.email}</td>
|
||||
<td className="px-6 py-4 text-sm text-gray-900">{user.name || '-'}</td>
|
||||
<td className="px-6 py-4 text-sm text-gray-500">
|
||||
{new Date(user.createdAt).toLocaleString('zh-CN')}
|
||||
</td>
|
||||
<td className="px-6 py-4 text-sm">
|
||||
<button
|
||||
onClick={() => handleDeleteUser(user.id)}
|
||||
disabled={loading}
|
||||
className="text-red-600 hover:text-red-800 disabled:opacity-50"
|
||||
>
|
||||
删除
|
||||
</button>
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 已删除用户标签页 */}
|
||||
{activeTab === 'deleted' && (
|
||||
<div className="bg-white rounded-lg shadow overflow-hidden">
|
||||
<div className="px-6 py-4 border-b flex justify-between items-center">
|
||||
<h2 className="text-xl font-semibold">已删除用户</h2>
|
||||
<button
|
||||
onClick={fetchDeletedUsers}
|
||||
disabled={loading || !token}
|
||||
className="px-4 py-2 bg-gray-100 text-gray-700 rounded hover:bg-gray-200 disabled:opacity-50"
|
||||
>
|
||||
刷新
|
||||
</button>
|
||||
</div>
|
||||
{!token ? (
|
||||
<div className="p-6 text-center text-gray-500">请先登录</div>
|
||||
) : deletedUsers.length === 0 ? (
|
||||
<div className="p-6 text-center text-gray-500">暂无已删除用户</div>
|
||||
) : (
|
||||
<table className="w-full">
|
||||
<thead className="bg-gray-50">
|
||||
<tr>
|
||||
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase">
|
||||
ID
|
||||
</th>
|
||||
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase">
|
||||
邮箱
|
||||
</th>
|
||||
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase">
|
||||
姓名
|
||||
</th>
|
||||
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase">
|
||||
删除时间
|
||||
</th>
|
||||
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase">
|
||||
操作
|
||||
</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="divide-y divide-gray-200">
|
||||
{deletedUsers.map((user) => (
|
||||
<tr key={user.id} className="bg-gray-50">
|
||||
<td className="px-6 py-4 text-sm text-gray-500 font-mono">
|
||||
{user.id.slice(0, 8)}...
|
||||
</td>
|
||||
<td className="px-6 py-4 text-sm text-gray-500">{user.email}</td>
|
||||
<td className="px-6 py-4 text-sm text-gray-500">{user.name || '-'}</td>
|
||||
<td className="px-6 py-4 text-sm text-gray-500">
|
||||
{user.deletedAt ? new Date(user.deletedAt).toLocaleString('zh-CN') : '-'}
|
||||
</td>
|
||||
<td className="px-6 py-4 text-sm">
|
||||
<button
|
||||
onClick={() => handleRestoreUser(user.id)}
|
||||
disabled={loading}
|
||||
className="text-green-600 hover:text-green-800 disabled:opacity-50"
|
||||
>
|
||||
恢复
|
||||
</button>
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 返回首页 */}
|
||||
<div className="mt-8 text-center">
|
||||
<a href="/" className="text-blue-600 hover:text-blue-800">
|
||||
返回首页
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
</main>
|
||||
);
|
||||
}
|
||||
97
apps/web/src/components/Captcha.tsx
Normal file
97
apps/web/src/components/Captcha.tsx
Normal file
@@ -0,0 +1,97 @@
|
||||
'use client';
|
||||
|
||||
import { useState, useEffect, useRef, useCallback } from 'react';
|
||||
|
||||
import { apiFetch } from '@/lib/api';
|
||||
import type { CaptchaResponse } from '@/types';
|
||||
import { CaptchaScene } from '@/types';
|
||||
|
||||
export interface CaptchaProps {
|
||||
/** 验证码使用场景 */
|
||||
scene: (typeof CaptchaScene)[keyof typeof CaptchaScene];
|
||||
/** 验证码值变化时的回调 */
|
||||
onChange: (captchaId: string, captchaCode: string) => void;
|
||||
/** 验证码输入值 */
|
||||
value: string;
|
||||
/** 是否禁用 */
|
||||
disabled?: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* 图形验证码组件
|
||||
* 包含验证码图片显示、刷新按钮和输入框
|
||||
*/
|
||||
export function Captcha({ scene, onChange, value, disabled = false }: CaptchaProps) {
|
||||
const [captchaId, setCaptchaId] = useState<string>('');
|
||||
const [captchaImage, setCaptchaImage] = useState<string>('');
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
// 使用 ref 保存 onChange,避免作为 useEffect 依赖导致无限循环
|
||||
const onChangeRef = useRef(onChange);
|
||||
onChangeRef.current = onChange;
|
||||
|
||||
// 获取验证码
|
||||
const fetchCaptcha = useCallback(async () => {
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
try {
|
||||
const response = await apiFetch<CaptchaResponse>(`/captcha?scene=${scene}`);
|
||||
setCaptchaId(response.captchaId);
|
||||
setCaptchaImage(response.image);
|
||||
// 清空之前的输入,通知父组件新的 captchaId
|
||||
onChangeRef.current(response.captchaId, '');
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : '获取验证码失败');
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, [scene]);
|
||||
|
||||
// 组件挂载时获取验证码
|
||||
useEffect(() => {
|
||||
fetchCaptcha();
|
||||
}, [fetchCaptcha]);
|
||||
|
||||
// 处理输入变化
|
||||
const handleInputChange = (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
const code = e.target.value;
|
||||
onChangeRef.current(captchaId, code);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="space-y-2">
|
||||
<label className="block text-sm font-medium text-gray-700">验证码</label>
|
||||
<div className="flex gap-2 items-center">
|
||||
{/* 验证码输入框 */}
|
||||
<input
|
||||
type="text"
|
||||
value={value}
|
||||
onChange={handleInputChange}
|
||||
placeholder="请输入验证码"
|
||||
disabled={disabled || loading}
|
||||
maxLength={6}
|
||||
className="flex-1 px-3 py-2 border rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-blue-500 disabled:opacity-50"
|
||||
required
|
||||
/>
|
||||
{/* 验证码图片 */}
|
||||
<div
|
||||
className="h-10 min-w-[100px] flex items-center justify-center bg-gray-100 rounded cursor-pointer hover:opacity-80 transition-opacity"
|
||||
onClick={!loading && !disabled ? fetchCaptcha : undefined}
|
||||
title="点击刷新验证码"
|
||||
>
|
||||
{loading ? (
|
||||
<span className="text-xs text-gray-500">加载中...</span>
|
||||
) : error ? (
|
||||
<span className="text-xs text-red-500 px-2">{error}</span>
|
||||
) : captchaImage ? (
|
||||
<img src={captchaImage} alt="验证码" className="h-full object-contain" />
|
||||
) : (
|
||||
<span className="text-xs text-gray-500">点击获取</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<p className="text-xs text-gray-500">点击图片可刷新验证码</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,11 +1,20 @@
|
||||
import { encryptRequest, decryptResponse, isEncryptionEnabled } from './crypto';
|
||||
|
||||
import type { FetchOptions } from '@/types';
|
||||
|
||||
const API_BASE_URL = process.env.NEXT_PUBLIC_API_URL || 'http://localhost:4000';
|
||||
|
||||
interface FetchOptions extends RequestInit {
|
||||
token?: string;
|
||||
}
|
||||
|
||||
export async function apiFetch<T>(endpoint: string, options: FetchOptions = {}): Promise<T> {
|
||||
const { token, headers, ...rest } = options;
|
||||
const { token, headers, body, skipEncryption, ...rest } = options;
|
||||
|
||||
// 处理请求体加密
|
||||
let processedBody = body;
|
||||
if (body && !skipEncryption && isEncryptionEnabled()) {
|
||||
// 如果 body 是字符串,先解析为对象
|
||||
const bodyData = typeof body === 'string' ? JSON.parse(body) : body;
|
||||
const encryptedBody = await encryptRequest(bodyData);
|
||||
processedBody = JSON.stringify(encryptedBody);
|
||||
}
|
||||
|
||||
const response = await fetch(`${API_BASE_URL}${endpoint}`, {
|
||||
headers: {
|
||||
@@ -13,13 +22,24 @@ export async function apiFetch<T>(endpoint: string, options: FetchOptions = {}):
|
||||
...(token && { Authorization: `Bearer ${token}` }),
|
||||
...headers,
|
||||
},
|
||||
body: processedBody,
|
||||
...rest,
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
const error = await response.json().catch(() => ({}));
|
||||
// 错误响应也可能被加密
|
||||
const isEncrypted = response.headers.get('X-Encrypted') === 'true';
|
||||
const errorData = await response.json().catch(() => ({}));
|
||||
const error = isEncrypted
|
||||
? await decryptResponse(errorData, true).catch(() => errorData)
|
||||
: errorData;
|
||||
throw new Error(error.message || `HTTP error! status: ${response.status}`);
|
||||
}
|
||||
|
||||
return response.json();
|
||||
// 检查响应是否被加密
|
||||
const isEncrypted = response.headers.get('X-Encrypted') === 'true';
|
||||
const data = await response.json();
|
||||
|
||||
// 解密响应
|
||||
return decryptResponse<T>(data, isEncrypted);
|
||||
}
|
||||
|
||||
126
apps/web/src/lib/crypto.ts
Normal file
126
apps/web/src/lib/crypto.ts
Normal file
@@ -0,0 +1,126 @@
|
||||
/**
|
||||
* 前端加密客户端封装
|
||||
* 使用 Web Crypto API 实现 AES-256-GCM 加密
|
||||
*/
|
||||
import { createBrowserCrypto, isEncryptedPayload } from '@seclusion/shared/crypto';
|
||||
import type { AesGcmCrypto, EncryptedPayload } from '@seclusion/shared/crypto';
|
||||
|
||||
import type { CryptoClientConfig } from '@/types';
|
||||
|
||||
/** 加密客户端单例 */
|
||||
let cryptoInstance: AesGcmCrypto | null = null;
|
||||
let cryptoConfig: CryptoClientConfig | null = null;
|
||||
|
||||
/**
|
||||
* 获取加密配置
|
||||
*/
|
||||
function getConfig(): CryptoClientConfig {
|
||||
if (cryptoConfig) {
|
||||
return cryptoConfig;
|
||||
}
|
||||
|
||||
cryptoConfig = {
|
||||
enabled: process.env.NEXT_PUBLIC_ENABLE_ENCRYPTION === 'true',
|
||||
key: process.env.NEXT_PUBLIC_ENCRYPTION_KEY || '',
|
||||
};
|
||||
|
||||
return cryptoConfig;
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取加密器实例
|
||||
*/
|
||||
async function getCrypto(): Promise<AesGcmCrypto | null> {
|
||||
const config = getConfig();
|
||||
|
||||
if (!config.enabled) {
|
||||
return null;
|
||||
}
|
||||
|
||||
if (cryptoInstance) {
|
||||
return cryptoInstance;
|
||||
}
|
||||
|
||||
if (!config.key) {
|
||||
console.error('加密已启用但未配置 NEXT_PUBLIC_ENCRYPTION_KEY');
|
||||
return null;
|
||||
}
|
||||
|
||||
try {
|
||||
cryptoInstance = await createBrowserCrypto(config.key);
|
||||
return cryptoInstance;
|
||||
} catch (error) {
|
||||
console.error('初始化加密客户端失败:', error);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 检查加密是否启用
|
||||
*/
|
||||
export function isEncryptionEnabled(): boolean {
|
||||
return getConfig().enabled;
|
||||
}
|
||||
|
||||
/**
|
||||
* 加密请求体
|
||||
* @param data 要加密的数据
|
||||
* @returns 加密后的载荷,如果加密未启用则返回原数据
|
||||
*/
|
||||
export async function encryptRequest(data: unknown): Promise<unknown> {
|
||||
const crypto = await getCrypto();
|
||||
|
||||
if (!crypto) {
|
||||
return data;
|
||||
}
|
||||
|
||||
try {
|
||||
const plaintext = JSON.stringify(data);
|
||||
return await crypto.encrypt(plaintext);
|
||||
} catch (error) {
|
||||
console.error('加密请求失败:', error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 解密响应体
|
||||
* @param data 响应数据
|
||||
* @param isEncrypted 响应是否被加密(通过 X-Encrypted header 判断)
|
||||
* @returns 解密后的数据
|
||||
*/
|
||||
export async function decryptResponse<T = unknown>(
|
||||
data: unknown,
|
||||
isEncrypted: boolean
|
||||
): Promise<T> {
|
||||
// 如果响应未加密,直接返回
|
||||
if (!isEncrypted) {
|
||||
return data as T;
|
||||
}
|
||||
|
||||
const crypto = await getCrypto();
|
||||
|
||||
// 如果客户端未启用加密但收到加密响应,抛出错误
|
||||
if (!crypto) {
|
||||
throw new Error('收到加密响应但客户端未启用加密');
|
||||
}
|
||||
|
||||
// 验证载荷格式
|
||||
if (!isEncryptedPayload(data)) {
|
||||
throw new Error('无效的加密响应格式');
|
||||
}
|
||||
|
||||
try {
|
||||
const plaintext = await crypto.decrypt(data);
|
||||
return JSON.parse(plaintext) as T;
|
||||
} catch (error) {
|
||||
console.error('解密响应失败:', error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 重新导出类型检查函数
|
||||
*/
|
||||
export { isEncryptedPayload };
|
||||
export type { EncryptedPayload };
|
||||
78
apps/web/src/types/index.ts
Normal file
78
apps/web/src/types/index.ts
Normal file
@@ -0,0 +1,78 @@
|
||||
/**
|
||||
* 前端特有类型定义
|
||||
* 组件 Props、表单状态等前端特有类型在此定义
|
||||
* 共享类型直接从 @seclusion/shared 导入
|
||||
*/
|
||||
|
||||
// ==================== 重导出共享类型 ====================
|
||||
// 前端常用的共享类型,方便统一导入
|
||||
export type {
|
||||
// 通用类型
|
||||
ApiResponse,
|
||||
PaginatedResponse,
|
||||
// 用户类型
|
||||
User,
|
||||
UserResponse,
|
||||
AuthUser,
|
||||
AuthResponse,
|
||||
// Token 类型
|
||||
TokenPayload,
|
||||
RefreshTokenDto,
|
||||
RefreshTokenResponse,
|
||||
// 验证码类型
|
||||
CaptchaResponse,
|
||||
} from '@seclusion/shared';
|
||||
|
||||
// 重导出验证码场景常量
|
||||
export { CaptchaScene } from '@seclusion/shared';
|
||||
|
||||
// ==================== API 相关类型 ====================
|
||||
|
||||
/** API 请求配置 */
|
||||
export interface FetchOptions extends RequestInit {
|
||||
/** JWT Token */
|
||||
token?: string;
|
||||
/** 跳过加密处理(用于特殊接口如文件上传) */
|
||||
skipEncryption?: boolean;
|
||||
}
|
||||
|
||||
// ==================== 加密相关类型 ====================
|
||||
|
||||
/** 加密配置 */
|
||||
export interface CryptoClientConfig {
|
||||
/** 是否启用加密 */
|
||||
enabled: boolean;
|
||||
/** Base64 编码的加密密钥 */
|
||||
key: string;
|
||||
}
|
||||
|
||||
// ==================== 表单状态类型 ====================
|
||||
|
||||
/** 通用消息状态 */
|
||||
export interface MessageState {
|
||||
type: 'success' | 'error';
|
||||
text: string;
|
||||
}
|
||||
|
||||
/** 注册表单状态 */
|
||||
export interface RegisterFormState {
|
||||
email: string;
|
||||
password: string;
|
||||
name: string;
|
||||
captchaId: string;
|
||||
captchaCode: string;
|
||||
}
|
||||
|
||||
/** 登录表单状态 */
|
||||
export interface LoginFormState {
|
||||
email: string;
|
||||
password: string;
|
||||
captchaId: string;
|
||||
captchaCode: string;
|
||||
}
|
||||
|
||||
/** 更新用户表单状态 */
|
||||
export interface UpdateUserFormState {
|
||||
id: string;
|
||||
name: string;
|
||||
}
|
||||
@@ -16,5 +16,20 @@ services:
|
||||
timeout: 5s
|
||||
retries: 5
|
||||
|
||||
redis:
|
||||
image: redis:7-alpine
|
||||
container_name: seclusion-redis
|
||||
ports:
|
||||
- "6379:6379"
|
||||
volumes:
|
||||
- redis_data:/data
|
||||
command: redis-server --appendonly yes
|
||||
healthcheck:
|
||||
test: ["CMD", "redis-cli", "ping"]
|
||||
interval: 5s
|
||||
timeout: 5s
|
||||
retries: 5
|
||||
|
||||
volumes:
|
||||
postgres_data:
|
||||
redis_data:
|
||||
|
||||
317
docs/backend/crud-service.md
Normal file
317
docs/backend/crud-service.md
Normal file
@@ -0,0 +1,317 @@
|
||||
# CRUD Service 模板
|
||||
|
||||
本文档介绍 `CrudService` 泛型基类的使用方法,用于快速创建标准 CRUD 服务。
|
||||
|
||||
## 概述
|
||||
|
||||
`CrudService` 是一个泛型抽象基类,提供:
|
||||
|
||||
- 标准 CRUD 操作(创建、查询、更新、删除)
|
||||
- 强制分页查询
|
||||
- 可选软删除支持(查询已删除、恢复)
|
||||
- 通过装饰器配置行为
|
||||
|
||||
## 快速开始
|
||||
|
||||
### 1. 创建 Service
|
||||
|
||||
```typescript
|
||||
import { Injectable } from '@nestjs/common';
|
||||
import { Prisma, User } from '@prisma/client';
|
||||
|
||||
import { CrudOptions, CrudService } from '@/common/crud';
|
||||
import { PrismaService } from '@/prisma/prisma.service';
|
||||
|
||||
import { UpdateUserDto } from './dto/user.dto';
|
||||
|
||||
@Injectable()
|
||||
@CrudOptions({
|
||||
softDelete: true,
|
||||
defaultSelect: {
|
||||
id: true,
|
||||
email: true,
|
||||
name: true,
|
||||
createdAt: true,
|
||||
updatedAt: true,
|
||||
},
|
||||
})
|
||||
export class UserService extends CrudService<
|
||||
User, // 实体类型
|
||||
Prisma.UserCreateInput, // 创建 DTO
|
||||
UpdateUserDto, // 更新 DTO
|
||||
Prisma.UserWhereInput, // Where 条件类型
|
||||
Prisma.UserWhereUniqueInput // WhereUnique 条件类型
|
||||
> {
|
||||
constructor(prisma: PrismaService) {
|
||||
super(prisma, 'user'); // 'user' 对应 prisma.user
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### 2. 创建 Controller
|
||||
|
||||
```typescript
|
||||
import { Controller, Get, Post, Patch, Delete, Param, Body, Query } from '@nestjs/common';
|
||||
|
||||
import { PaginationQueryDto } from '@/common/crud';
|
||||
|
||||
import { CreateUserDto, UpdateUserDto } from './dto/user.dto';
|
||||
import { UserService } from './user.service';
|
||||
|
||||
@Controller('users')
|
||||
export class UserController {
|
||||
constructor(private readonly userService: UserService) {}
|
||||
|
||||
@Get()
|
||||
findAll(@Query() query: PaginationQueryDto) {
|
||||
return this.userService.findAll(query);
|
||||
}
|
||||
|
||||
@Get(':id')
|
||||
findById(@Param('id') id: string) {
|
||||
return this.userService.findById(id);
|
||||
}
|
||||
|
||||
@Post()
|
||||
create(@Body() dto: CreateUserDto) {
|
||||
return this.userService.create(dto);
|
||||
}
|
||||
|
||||
@Patch(':id')
|
||||
update(@Param('id') id: string, @Body() dto: UpdateUserDto) {
|
||||
return this.userService.update(id, dto);
|
||||
}
|
||||
|
||||
@Delete(':id')
|
||||
delete(@Param('id') id: string) {
|
||||
return this.userService.delete(id);
|
||||
}
|
||||
|
||||
// 软删除相关(需要 softDelete: true)
|
||||
@Get('deleted')
|
||||
findDeleted(@Query() query: PaginationQueryDto) {
|
||||
return this.userService.findDeleted(query);
|
||||
}
|
||||
|
||||
@Patch(':id/restore')
|
||||
restore(@Param('id') id: string) {
|
||||
return this.userService.restore(id);
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## 配置选项
|
||||
|
||||
通过 `@CrudOptions()` 装饰器配置:
|
||||
|
||||
| 选项 | 类型 | 默认值 | 说明 |
|
||||
| ------------------ | ------------------------- | ------------- | ---------------------------- |
|
||||
| `softDelete` | `boolean` | `false` | 是否启用软删除功能 |
|
||||
| `defaultPageSize` | `number` | `20` | 默认分页大小 |
|
||||
| `maxPageSize` | `number` | `100` | 最大分页大小 |
|
||||
| `defaultSortBy` | `string` | `'createdAt'` | 默认排序字段 |
|
||||
| `defaultSortOrder` | `'asc' \| 'desc'` | `'desc'` | 默认排序方向 |
|
||||
| `defaultSelect` | `Record<string, boolean>` | `{}` | 默认返回字段(排除敏感字段) |
|
||||
|
||||
### 配置示例
|
||||
|
||||
```typescript
|
||||
@CrudOptions({
|
||||
softDelete: true,
|
||||
defaultPageSize: 10,
|
||||
maxPageSize: 50,
|
||||
defaultSortBy: 'name',
|
||||
defaultSortOrder: 'asc',
|
||||
defaultSelect: {
|
||||
id: true,
|
||||
name: true,
|
||||
email: true,
|
||||
createdAt: true,
|
||||
// password: false - 不返回密码
|
||||
},
|
||||
})
|
||||
```
|
||||
|
||||
## 基类方法
|
||||
|
||||
### 标准 CRUD
|
||||
|
||||
| 方法 | 说明 | 返回类型 |
|
||||
| ------------------ | ------------ | --------------------------- |
|
||||
| `findAll(params?)` | 分页查询 | `PaginatedResponse<Entity>` |
|
||||
| `findById(id)` | 根据 ID 查询 | `Entity` |
|
||||
| `create(dto)` | 创建记录 | `Entity` |
|
||||
| `update(id, dto)` | 更新记录 | `Entity` |
|
||||
| `delete(id)` | 删除记录 | `{ message: string }` |
|
||||
|
||||
### 软删除(需要 `softDelete: true`)
|
||||
|
||||
| 方法 | 说明 | 返回类型 |
|
||||
| ---------------------- | -------------- | --------------------------- |
|
||||
| `findDeleted(params?)` | 查询已删除记录 | `PaginatedResponse<Entity>` |
|
||||
| `restore(id)` | 恢复已删除记录 | `Entity` |
|
||||
|
||||
### 分页参数
|
||||
|
||||
`findAll` 和 `findDeleted` 支持以下参数:
|
||||
|
||||
```typescript
|
||||
interface FindAllParams<WhereInput> {
|
||||
page?: number; // 页码,默认 1
|
||||
pageSize?: number; // 每页数量,默认 20(-1 或 0 表示不分页)
|
||||
sortBy?: string; // 排序字段(逗号分隔支持多字段)
|
||||
sortOrder?: string; // 排序方向(逗号分隔,与 sortBy 对应)
|
||||
where?: WhereInput; // 过滤条件
|
||||
}
|
||||
```
|
||||
|
||||
### 非分页查询
|
||||
|
||||
当 `pageSize <= 0`(如 `-1` 或 `0`)时,返回所有匹配数据:
|
||||
|
||||
```bash
|
||||
# 返回所有用户(不分页)
|
||||
GET /users?pageSize=-1
|
||||
|
||||
# 或
|
||||
GET /users?pageSize=0
|
||||
```
|
||||
|
||||
非分页响应格式保持一致,`page=1`,`totalPages=1`,`pageSize` 和 `total` 等于实际返回数量。
|
||||
|
||||
### 多字段排序
|
||||
|
||||
支持通过逗号分隔实现多字段排序:
|
||||
|
||||
```bash
|
||||
# 单字段排序
|
||||
GET /users?sortBy=createdAt&sortOrder=desc
|
||||
|
||||
# 多字段排序:先按 status 升序,再按 createdAt 降序
|
||||
GET /users?sortBy=status,createdAt&sortOrder=asc,desc
|
||||
```
|
||||
|
||||
排序规则:
|
||||
|
||||
- `sortBy` 和 `sortOrder` 按位置一一对应
|
||||
- 如果 `sortOrder` 数量少于 `sortBy`,后续字段使用第一个排序方向
|
||||
- 示例:`sortBy=a,b,c&sortOrder=asc` 等同于 `sortBy=a,b,c&sortOrder=asc,asc,asc`
|
||||
|
||||
### 分页响应
|
||||
|
||||
```typescript
|
||||
interface PaginatedResponse<T> {
|
||||
items: T[]; // 数据列表
|
||||
total: number; // 总记录数
|
||||
page: number; // 当前页码
|
||||
pageSize: number; // 每页数量
|
||||
totalPages: number; // 总页数
|
||||
}
|
||||
```
|
||||
|
||||
## 自定义扩展
|
||||
|
||||
### 覆盖错误消息
|
||||
|
||||
```typescript
|
||||
export class UserService extends CrudService<...> {
|
||||
protected getNotFoundMessage(id: string): string {
|
||||
return '用户不存在';
|
||||
}
|
||||
|
||||
protected getDeletedMessage(id: string): string {
|
||||
return '用户已删除';
|
||||
}
|
||||
|
||||
protected getDeletedNotFoundMessage(id: string): string {
|
||||
return '已删除的用户不存在';
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### 覆盖默认 Select
|
||||
|
||||
```typescript
|
||||
export class UserService extends CrudService<...> {
|
||||
protected getDefaultSelect(): Record<string, boolean> {
|
||||
return {
|
||||
id: true,
|
||||
email: true,
|
||||
name: true,
|
||||
profile: true, // 包含关联
|
||||
};
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### 添加业务方法
|
||||
|
||||
```typescript
|
||||
export class UserService extends CrudService<...> {
|
||||
async findByEmail(email: string): Promise<User | null> {
|
||||
return this.model.findFirst({
|
||||
where: { email },
|
||||
});
|
||||
}
|
||||
|
||||
async findWithPosts(id: string): Promise<User> {
|
||||
const user = await this.prisma.user.findUnique({
|
||||
where: { id },
|
||||
include: { posts: true },
|
||||
});
|
||||
if (!user) throw new NotFoundException('用户不存在');
|
||||
return user;
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### 带过滤条件的查询
|
||||
|
||||
```typescript
|
||||
// Controller
|
||||
@Get()
|
||||
findAll(
|
||||
@Query() query: PaginationQueryDto,
|
||||
@Query('status') status?: string,
|
||||
) {
|
||||
return this.userService.findAll({
|
||||
...query,
|
||||
where: status ? { status } : undefined,
|
||||
});
|
||||
}
|
||||
```
|
||||
|
||||
## 泛型参数说明
|
||||
|
||||
```typescript
|
||||
CrudService<Entity, CreateDto, UpdateDto, WhereInput, WhereUniqueInput>;
|
||||
```
|
||||
|
||||
| 参数 | 说明 | 来源 |
|
||||
| ------------------ | ------------ | ------------------------------------ |
|
||||
| `Entity` | 实体类型 | `@prisma/client` 导出的模型类型 |
|
||||
| `CreateDto` | 创建数据类型 | `Prisma.XxxCreateInput` 或自定义 DTO |
|
||||
| `UpdateDto` | 更新数据类型 | `Prisma.XxxUpdateInput` 或自定义 DTO |
|
||||
| `WhereInput` | 查询条件类型 | `Prisma.XxxWhereInput`(可选) |
|
||||
| `WhereUniqueInput` | 唯一查询条件 | `Prisma.XxxWhereUniqueInput`(可选) |
|
||||
|
||||
## 与软删除的配合
|
||||
|
||||
`CrudService` 的 `softDelete` 配置需要与 `PrismaService` 的软删除扩展配合使用:
|
||||
|
||||
1. 在 `prisma.service.ts` 的 `SOFT_DELETE_MODELS` 数组中添加模型名
|
||||
2. 在 Service 的 `@CrudOptions` 中设置 `softDelete: true`
|
||||
|
||||
详见 [软删除设计文档](./soft-delete.md)。
|
||||
|
||||
## 文件结构
|
||||
|
||||
```
|
||||
apps/api/src/common/crud/
|
||||
├── index.ts # 统一导出
|
||||
├── crud.types.ts # 类型定义
|
||||
├── crud.decorator.ts # @CrudOptions 装饰器
|
||||
├── crud.service.ts # CrudService 基类
|
||||
└── dto/
|
||||
└── pagination.dto.ts # PaginationQueryDto
|
||||
```
|
||||
@@ -65,13 +65,13 @@ const softDeleteExtension = Prisma.defineExtension({
|
||||
|
||||
### 3. 自动处理逻辑
|
||||
|
||||
| 操作 | 自动处理 |
|
||||
|------|---------|
|
||||
| `findMany` | 自动添加 `where: { deletedAt: null }` |
|
||||
| `findFirst` | 自动添加 `where: { deletedAt: null }` |
|
||||
| `findUnique` | 自动添加 `where: { deletedAt: null }` |
|
||||
| `count` | 自动添加 `where: { deletedAt: null }` |
|
||||
| `delete` | 转换为 `update({ data: { deletedAt: new Date() } })` |
|
||||
| 操作 | 自动处理 |
|
||||
| ------------ | -------------------------------------------------------- |
|
||||
| `findMany` | 自动添加 `where: { deletedAt: null }` |
|
||||
| `findFirst` | 自动添加 `where: { deletedAt: null }` |
|
||||
| `findUnique` | 自动添加 `where: { deletedAt: null }` |
|
||||
| `count` | 自动添加 `where: { deletedAt: null }` |
|
||||
| `delete` | 转换为 `update({ data: { deletedAt: new Date() } })` |
|
||||
| `deleteMany` | 转换为 `updateMany({ data: { deletedAt: new Date() } })` |
|
||||
|
||||
## 使用方式
|
||||
@@ -117,13 +117,13 @@ await prisma.user.update({
|
||||
|
||||
## API 接口
|
||||
|
||||
| 接口 | 方法 | 说明 |
|
||||
|------|------|------|
|
||||
| `/users` | GET | 获取用户列表(自动排除已删除) |
|
||||
| `/users/:id` | GET | 获取单个用户(自动排除已删除) |
|
||||
| `/users/:id` | DELETE | 软删除用户 |
|
||||
| `/users/deleted` | GET | 获取已删除用户列表 |
|
||||
| `/users/:id/restore` | PATCH | 恢复已删除用户 |
|
||||
| 接口 | 方法 | 说明 |
|
||||
| -------------------- | ------ | ------------------------------ |
|
||||
| `/users` | GET | 获取用户列表(自动排除已删除) |
|
||||
| `/users/:id` | GET | 获取单个用户(自动排除已删除) |
|
||||
| `/users/:id` | DELETE | 软删除用户 |
|
||||
| `/users/deleted` | GET | 获取已删除用户列表 |
|
||||
| `/users/:id/restore` | PATCH | 恢复已删除用户 |
|
||||
|
||||
## 扩展新模型
|
||||
|
||||
|
||||
@@ -4,10 +4,11 @@
|
||||
|
||||
## 文档列表
|
||||
|
||||
| 文档 | 说明 | 适用场景 |
|
||||
| ------------------------------------------ | ---------------- | -------------------------------- |
|
||||
| [design.md](./design.md) | 项目设计文档 | 了解整体架构、技术选型、模块设计 |
|
||||
| [backend/soft-delete.md](./backend/soft-delete.md) | 软删除设计文档 | 了解软删除机制、扩展新模型 |
|
||||
| 文档 | 说明 | 适用场景 |
|
||||
| ---------------------------------------------------- | ----------------- | -------------------------------- |
|
||||
| [design.md](./design.md) | 项目设计文档 | 了解整体架构、技术选型、模块设计 |
|
||||
| [backend/soft-delete.md](./backend/soft-delete.md) | 软删除设计文档 | 了解软删除机制、扩展新模型 |
|
||||
| [backend/crud-service.md](./backend/crud-service.md) | CRUD Service 模板 | 快速创建标准 CRUD 服务 |
|
||||
|
||||
## 快速链接
|
||||
|
||||
|
||||
@@ -20,6 +20,11 @@
|
||||
"types": "./dist/utils/index.d.ts",
|
||||
"import": "./dist/utils/index.mjs",
|
||||
"require": "./dist/utils/index.js"
|
||||
},
|
||||
"./crypto": {
|
||||
"types": "./dist/crypto/index.d.ts",
|
||||
"import": "./dist/crypto/index.mjs",
|
||||
"require": "./dist/crypto/index.js"
|
||||
}
|
||||
},
|
||||
"scripts": {
|
||||
|
||||
110
packages/shared/src/crypto/aes-gcm-node.ts
Normal file
110
packages/shared/src/crypto/aes-gcm-node.ts
Normal file
@@ -0,0 +1,110 @@
|
||||
/**
|
||||
* Node.js 端 AES-256-GCM 加密实现
|
||||
* 使用 Node.js crypto 模块
|
||||
*/
|
||||
import * as crypto from 'crypto';
|
||||
|
||||
import type { AesGcmCrypto, EncryptedPayload } from './types';
|
||||
import { CryptoError, CryptoErrorCode } from './types';
|
||||
|
||||
/** 算法名称 */
|
||||
const ALGORITHM = 'aes-256-gcm';
|
||||
/** IV 长度 (12 字节,GCM 推荐值) */
|
||||
const IV_LENGTH = 12;
|
||||
/** 认证标签长度 (16 字节,128 位) */
|
||||
const AUTH_TAG_LENGTH = 16;
|
||||
|
||||
/**
|
||||
* 创建 Node.js 端 AES-GCM 加密器
|
||||
* @param keyBase64 Base64 编码的 AES-256 密钥
|
||||
*/
|
||||
export function createNodeCrypto(keyBase64: string): AesGcmCrypto {
|
||||
// 解码并验证密钥
|
||||
let keyBuffer: Buffer;
|
||||
try {
|
||||
keyBuffer = Buffer.from(keyBase64, 'base64');
|
||||
} catch {
|
||||
throw new CryptoError('无效的 Base64 密钥格式', CryptoErrorCode.INVALID_KEY);
|
||||
}
|
||||
|
||||
if (keyBuffer.length !== 32) {
|
||||
throw new CryptoError(
|
||||
`密钥长度必须为 32 字节,当前为 ${keyBuffer.length} 字节`,
|
||||
CryptoErrorCode.INVALID_KEY
|
||||
);
|
||||
}
|
||||
|
||||
return {
|
||||
async encrypt(plaintext: string): Promise<EncryptedPayload> {
|
||||
try {
|
||||
// 生成随机 IV
|
||||
const iv = crypto.randomBytes(IV_LENGTH);
|
||||
|
||||
// 创建加密器
|
||||
const cipher = crypto.createCipheriv(ALGORITHM, keyBuffer, iv);
|
||||
|
||||
// 加密
|
||||
const encrypted = Buffer.concat([cipher.update(plaintext, 'utf8'), cipher.final()]);
|
||||
|
||||
// 获取认证标签
|
||||
const authTag = cipher.getAuthTag();
|
||||
|
||||
// 组合 IV + Ciphertext + AuthTag
|
||||
const combined = Buffer.concat([iv, encrypted, authTag]);
|
||||
|
||||
return {
|
||||
encrypted: true,
|
||||
data: combined.toString('base64'),
|
||||
};
|
||||
} catch (error) {
|
||||
if (error instanceof CryptoError) throw error;
|
||||
throw new CryptoError(
|
||||
`加密失败: ${error instanceof Error ? error.message : '未知错误'}`,
|
||||
CryptoErrorCode.ENCRYPTION_FAILED
|
||||
);
|
||||
}
|
||||
},
|
||||
|
||||
async decrypt(payload: EncryptedPayload): Promise<string> {
|
||||
try {
|
||||
// 解码 Base64 数据
|
||||
const combined = Buffer.from(payload.data, 'base64');
|
||||
|
||||
// 最小长度检查: IV + AuthTag
|
||||
if (combined.length < IV_LENGTH + AUTH_TAG_LENGTH) {
|
||||
throw new CryptoError('加密数据长度不足', CryptoErrorCode.INVALID_PAYLOAD);
|
||||
}
|
||||
|
||||
// 提取 IV、密文和认证标签
|
||||
const iv = combined.subarray(0, IV_LENGTH);
|
||||
const authTag = combined.subarray(combined.length - AUTH_TAG_LENGTH);
|
||||
const ciphertext = combined.subarray(IV_LENGTH, combined.length - AUTH_TAG_LENGTH);
|
||||
|
||||
// 创建解密器
|
||||
const decipher = crypto.createDecipheriv(ALGORITHM, keyBuffer, iv);
|
||||
decipher.setAuthTag(authTag);
|
||||
|
||||
// 解密
|
||||
const decrypted = Buffer.concat([decipher.update(ciphertext), decipher.final()]);
|
||||
|
||||
return decrypted.toString('utf8');
|
||||
} catch (error) {
|
||||
if (error instanceof CryptoError) throw error;
|
||||
// Node.js crypto 在认证失败时会抛出特定错误
|
||||
if (
|
||||
error instanceof Error &&
|
||||
error.message.includes('Unsupported state or unable to authenticate data')
|
||||
) {
|
||||
throw new CryptoError(
|
||||
'认证标签验证失败,数据可能被篡改',
|
||||
CryptoErrorCode.AUTH_TAG_MISMATCH
|
||||
);
|
||||
}
|
||||
throw new CryptoError(
|
||||
`解密失败: ${error instanceof Error ? error.message : '未知错误'}`,
|
||||
CryptoErrorCode.DECRYPTION_FAILED
|
||||
);
|
||||
}
|
||||
},
|
||||
};
|
||||
}
|
||||
134
packages/shared/src/crypto/aes-gcm.browser.ts
Normal file
134
packages/shared/src/crypto/aes-gcm.browser.ts
Normal file
@@ -0,0 +1,134 @@
|
||||
/**
|
||||
* 浏览器端 AES-256-GCM 加密实现
|
||||
* 使用 Web Crypto API
|
||||
*/
|
||||
import type { AesGcmCrypto, EncryptedPayload } from './types';
|
||||
import { CryptoError, CryptoErrorCode } from './types';
|
||||
|
||||
/** IV 长度 (12 字节,GCM 推荐值) */
|
||||
const IV_LENGTH = 12;
|
||||
/** 认证标签长度 (16 字节,128 位) */
|
||||
const AUTH_TAG_LENGTH = 16;
|
||||
|
||||
/**
|
||||
* 创建浏览器端 AES-GCM 加密器
|
||||
* @param keyBase64 Base64 编码的 AES-256 密钥
|
||||
*/
|
||||
export async function createBrowserCrypto(keyBase64: string): Promise<AesGcmCrypto> {
|
||||
// 解码并验证密钥
|
||||
let keyBytes: Uint8Array;
|
||||
try {
|
||||
keyBytes = base64ToBytes(keyBase64);
|
||||
} catch {
|
||||
throw new CryptoError('无效的 Base64 密钥格式', CryptoErrorCode.INVALID_KEY);
|
||||
}
|
||||
|
||||
if (keyBytes.length !== 32) {
|
||||
throw new CryptoError(
|
||||
`密钥长度必须为 32 字节,当前为 ${keyBytes.length} 字节`,
|
||||
CryptoErrorCode.INVALID_KEY
|
||||
);
|
||||
}
|
||||
|
||||
// 导入密钥
|
||||
const cryptoKey = await crypto.subtle.importKey('raw', keyBytes, { name: 'AES-GCM' }, false, [
|
||||
'encrypt',
|
||||
'decrypt',
|
||||
]);
|
||||
|
||||
return {
|
||||
async encrypt(plaintext: string): Promise<EncryptedPayload> {
|
||||
try {
|
||||
// 生成随机 IV
|
||||
const iv = crypto.getRandomValues(new Uint8Array(IV_LENGTH));
|
||||
|
||||
// 将明文转换为字节
|
||||
const plaintextBytes = new TextEncoder().encode(plaintext);
|
||||
|
||||
// 加密
|
||||
const ciphertext = await crypto.subtle.encrypt(
|
||||
{ name: 'AES-GCM', iv, tagLength: AUTH_TAG_LENGTH * 8 },
|
||||
cryptoKey,
|
||||
plaintextBytes
|
||||
);
|
||||
|
||||
// 组合 IV + Ciphertext (Web Crypto API 的 ciphertext 已包含 AuthTag)
|
||||
const combined = new Uint8Array(IV_LENGTH + ciphertext.byteLength);
|
||||
combined.set(iv, 0);
|
||||
combined.set(new Uint8Array(ciphertext), IV_LENGTH);
|
||||
|
||||
return {
|
||||
encrypted: true,
|
||||
data: bytesToBase64(combined),
|
||||
};
|
||||
} catch (error) {
|
||||
if (error instanceof CryptoError) throw error;
|
||||
throw new CryptoError(
|
||||
`加密失败: ${error instanceof Error ? error.message : '未知错误'}`,
|
||||
CryptoErrorCode.ENCRYPTION_FAILED
|
||||
);
|
||||
}
|
||||
},
|
||||
|
||||
async decrypt(payload: EncryptedPayload): Promise<string> {
|
||||
try {
|
||||
// 解码 Base64 数据
|
||||
const combined = base64ToBytes(payload.data);
|
||||
|
||||
// 最小长度检查: IV + AuthTag
|
||||
if (combined.length < IV_LENGTH + AUTH_TAG_LENGTH) {
|
||||
throw new CryptoError('加密数据长度不足', CryptoErrorCode.INVALID_PAYLOAD);
|
||||
}
|
||||
|
||||
// 提取 IV 和密文 (包含 AuthTag)
|
||||
const iv = combined.slice(0, IV_LENGTH);
|
||||
const ciphertext = combined.slice(IV_LENGTH);
|
||||
|
||||
// 解密
|
||||
const plaintextBuffer = await crypto.subtle.decrypt(
|
||||
{ name: 'AES-GCM', iv, tagLength: AUTH_TAG_LENGTH * 8 },
|
||||
cryptoKey,
|
||||
ciphertext
|
||||
);
|
||||
|
||||
return new TextDecoder().decode(plaintextBuffer);
|
||||
} catch (error) {
|
||||
if (error instanceof CryptoError) throw error;
|
||||
// Web Crypto API 在认证失败时会抛出 OperationError
|
||||
if (error instanceof DOMException && error.name === 'OperationError') {
|
||||
throw new CryptoError(
|
||||
'认证标签验证失败,数据可能被篡改',
|
||||
CryptoErrorCode.AUTH_TAG_MISMATCH
|
||||
);
|
||||
}
|
||||
throw new CryptoError(
|
||||
`解密失败: ${error instanceof Error ? error.message : '未知错误'}`,
|
||||
CryptoErrorCode.DECRYPTION_FAILED
|
||||
);
|
||||
}
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Base64 字符串转字节数组
|
||||
*/
|
||||
function base64ToBytes(base64: string): Uint8Array {
|
||||
const binaryString = atob(base64);
|
||||
const bytes = new Uint8Array(binaryString.length);
|
||||
for (let i = 0; i < binaryString.length; i++) {
|
||||
bytes[i] = binaryString.charCodeAt(i);
|
||||
}
|
||||
return bytes;
|
||||
}
|
||||
|
||||
/**
|
||||
* 字节数组转 Base64 字符串
|
||||
*/
|
||||
function bytesToBase64(bytes: Uint8Array): string {
|
||||
let binaryString = '';
|
||||
for (let i = 0; i < bytes.length; i++) {
|
||||
binaryString += String.fromCharCode(bytes[i]);
|
||||
}
|
||||
return btoa(binaryString);
|
||||
}
|
||||
15
packages/shared/src/crypto/index.ts
Normal file
15
packages/shared/src/crypto/index.ts
Normal file
@@ -0,0 +1,15 @@
|
||||
/**
|
||||
* AES-256-GCM 加密模块
|
||||
*
|
||||
* 提供跨平台的加密/解密功能:
|
||||
* - 浏览器端使用 Web Crypto API
|
||||
* - Node.js 端使用 crypto 模块
|
||||
*/
|
||||
|
||||
// 导出类型
|
||||
export type { AesGcmCrypto, CryptoConfig, EncryptedPayload } from './types';
|
||||
export { CryptoError, CryptoErrorCode, isEncryptedPayload } from './types';
|
||||
|
||||
// 导出平台特定实现
|
||||
export { createBrowserCrypto } from './aes-gcm.browser';
|
||||
export { createNodeCrypto } from './aes-gcm-node';
|
||||
80
packages/shared/src/crypto/types.ts
Normal file
80
packages/shared/src/crypto/types.ts
Normal file
@@ -0,0 +1,80 @@
|
||||
/**
|
||||
* 加密后的数据载荷格式
|
||||
*/
|
||||
export interface EncryptedPayload {
|
||||
encrypted: true;
|
||||
/** Base64 编码的加密数据 (IV + Ciphertext + AuthTag) */
|
||||
data: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* 加密配置
|
||||
*/
|
||||
export interface CryptoConfig {
|
||||
/** Base64 编码的 AES-256 密钥 (32 字节) */
|
||||
key: string;
|
||||
/** 是否启用加密 */
|
||||
enabled: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* 加密错误类型
|
||||
*/
|
||||
export class CryptoError extends Error {
|
||||
constructor(
|
||||
message: string,
|
||||
public readonly code: CryptoErrorCode
|
||||
) {
|
||||
super(message);
|
||||
this.name = 'CryptoError';
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 加密错误码
|
||||
*/
|
||||
export enum CryptoErrorCode {
|
||||
/** 无效的密钥格式 */
|
||||
INVALID_KEY = 'INVALID_KEY',
|
||||
/** 加密失败 */
|
||||
ENCRYPTION_FAILED = 'ENCRYPTION_FAILED',
|
||||
/** 解密失败 */
|
||||
DECRYPTION_FAILED = 'DECRYPTION_FAILED',
|
||||
/** 无效的加密数据格式 */
|
||||
INVALID_PAYLOAD = 'INVALID_PAYLOAD',
|
||||
/** 认证标签验证失败 */
|
||||
AUTH_TAG_MISMATCH = 'AUTH_TAG_MISMATCH',
|
||||
}
|
||||
|
||||
/**
|
||||
* AES-GCM 加密器接口
|
||||
*/
|
||||
export interface AesGcmCrypto {
|
||||
/**
|
||||
* 加密数据
|
||||
* @param plaintext 明文字符串
|
||||
* @returns 加密后的载荷
|
||||
*/
|
||||
encrypt(plaintext: string): Promise<EncryptedPayload>;
|
||||
|
||||
/**
|
||||
* 解密数据
|
||||
* @param payload 加密载荷
|
||||
* @returns 解密后的明文字符串
|
||||
*/
|
||||
decrypt(payload: EncryptedPayload): Promise<string>;
|
||||
}
|
||||
|
||||
/**
|
||||
* 判断是否为加密载荷
|
||||
*/
|
||||
export function isEncryptedPayload(data: unknown): data is EncryptedPayload {
|
||||
return (
|
||||
typeof data === 'object' &&
|
||||
data !== null &&
|
||||
'encrypted' in data &&
|
||||
(data as EncryptedPayload).encrypted === true &&
|
||||
'data' in data &&
|
||||
typeof (data as EncryptedPayload).data === 'string'
|
||||
);
|
||||
}
|
||||
@@ -3,3 +3,6 @@ export * from './types';
|
||||
|
||||
// 导出所有工具函数
|
||||
export * from './utils';
|
||||
|
||||
// 导出加密模块
|
||||
export * from './crypto';
|
||||
|
||||
@@ -1,4 +1,6 @@
|
||||
// 通用 API 响应类型
|
||||
// ==================== 通用类型 ====================
|
||||
|
||||
/** 通用 API 响应类型(用于统一响应格式场景) */
|
||||
export interface ApiResponse<T = unknown> {
|
||||
success: boolean;
|
||||
data?: T;
|
||||
@@ -10,8 +12,10 @@ export interface ApiResponse<T = unknown> {
|
||||
export interface PaginationParams {
|
||||
page?: number;
|
||||
pageSize?: number;
|
||||
/** 排序字段(逗号分隔支持多字段,如 "status,createdAt") */
|
||||
sortBy?: string;
|
||||
sortOrder?: 'asc' | 'desc';
|
||||
/** 排序方向(逗号分隔,与 sortBy 对应,如 "asc,desc") */
|
||||
sortOrder?: string;
|
||||
}
|
||||
|
||||
// 分页响应
|
||||
@@ -33,10 +37,18 @@ export interface User {
|
||||
deletedAt?: Date | null;
|
||||
}
|
||||
|
||||
// 用户创建请求
|
||||
export interface CreateUserDto {
|
||||
/** 用户响应(API 返回的用户信息,不含密码) */
|
||||
export interface UserResponse {
|
||||
id: string;
|
||||
email: string;
|
||||
password: string;
|
||||
name: string | null;
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
deletedAt?: string | null;
|
||||
}
|
||||
|
||||
// 用户更新请求
|
||||
export interface UpdateUserDto {
|
||||
name?: string;
|
||||
}
|
||||
|
||||
@@ -44,19 +56,121 @@ export interface CreateUserDto {
|
||||
export interface LoginDto {
|
||||
email: string;
|
||||
password: string;
|
||||
captchaId: string;
|
||||
captchaCode: string;
|
||||
}
|
||||
|
||||
// 用户注册请求
|
||||
export interface RegisterDto {
|
||||
email: string;
|
||||
password: string;
|
||||
name?: string;
|
||||
captchaId: string;
|
||||
captchaCode: string;
|
||||
}
|
||||
|
||||
// 认证响应中的用户信息(不含敏感字段和时间戳)
|
||||
export interface AuthUser {
|
||||
id: string;
|
||||
email: string;
|
||||
name: string | null;
|
||||
}
|
||||
|
||||
// 认证响应
|
||||
export interface AuthResponse {
|
||||
accessToken: string;
|
||||
refreshToken: string;
|
||||
user: Omit<User, 'createdAt' | 'updatedAt'>;
|
||||
/** accessToken 有效期(秒) */
|
||||
accessTokenExpiresIn: number;
|
||||
/** refreshToken 有效期(秒) */
|
||||
refreshTokenExpiresIn: number;
|
||||
user: AuthUser;
|
||||
}
|
||||
|
||||
// Token 载荷
|
||||
// 刷新 Token 请求
|
||||
export interface RefreshTokenDto {
|
||||
refreshToken: string;
|
||||
}
|
||||
|
||||
// 刷新 Token 响应
|
||||
export interface RefreshTokenResponse {
|
||||
accessToken: string;
|
||||
refreshToken: string;
|
||||
/** accessToken 有效期(秒) */
|
||||
accessTokenExpiresIn: number;
|
||||
/** refreshToken 有效期(秒) */
|
||||
refreshTokenExpiresIn: number;
|
||||
}
|
||||
|
||||
/** JWT Token 载荷 */
|
||||
export interface TokenPayload {
|
||||
/** 用户 ID */
|
||||
sub: string;
|
||||
/** 用户邮箱 */
|
||||
email: string;
|
||||
/** 签发时间 */
|
||||
iat?: number;
|
||||
/** 过期时间 */
|
||||
exp?: number;
|
||||
}
|
||||
|
||||
// ==================== 验证码相关类型 ====================
|
||||
|
||||
/**
|
||||
* 验证码场景枚举值
|
||||
* 使用 const 对象 + type 的方式,兼容前后端使用
|
||||
*/
|
||||
export const CaptchaScene = {
|
||||
/** 登录 */
|
||||
LOGIN: 'login',
|
||||
/** 注册 */
|
||||
REGISTER: 'register',
|
||||
/** 重置密码 */
|
||||
RESET_PASSWORD: 'reset_password',
|
||||
} as const;
|
||||
|
||||
/**
|
||||
* 验证码场景类型
|
||||
*/
|
||||
export type CaptchaScene = (typeof CaptchaScene)[keyof typeof CaptchaScene];
|
||||
|
||||
/**
|
||||
* 验证码响应
|
||||
*/
|
||||
export interface CaptchaResponse {
|
||||
/** 验证码 ID */
|
||||
captchaId: string;
|
||||
/** Base64 编码的 SVG 图片 */
|
||||
image: string;
|
||||
/** 过期时间(秒) */
|
||||
expiresIn: number;
|
||||
}
|
||||
|
||||
// ==================== 健康检查相关类型 ====================
|
||||
|
||||
/** 服务健康状态 */
|
||||
export type ServiceHealthStatus = 'ok' | 'error';
|
||||
|
||||
/** 整体健康状态 */
|
||||
export type OverallHealthStatus = 'ok' | 'degraded' | 'error';
|
||||
|
||||
/** 单个服务健康状态 */
|
||||
export interface ServiceHealth {
|
||||
status: ServiceHealthStatus;
|
||||
}
|
||||
|
||||
/** 各服务健康状态 */
|
||||
export interface ServicesHealth {
|
||||
database: ServiceHealth;
|
||||
redis: ServiceHealth;
|
||||
}
|
||||
|
||||
/** 健康检查响应 */
|
||||
export interface HealthCheckResponse {
|
||||
/** 整体健康状态:ok-全部正常,degraded-部分异常,error-全部异常 */
|
||||
status: OverallHealthStatus;
|
||||
/** 检查时间戳 */
|
||||
timestamp: string;
|
||||
/** 各服务健康状态 */
|
||||
services: ServicesHealth;
|
||||
}
|
||||
|
||||
@@ -5,6 +5,7 @@ export default defineConfig({
|
||||
index: 'src/index.ts',
|
||||
'types/index': 'src/types/index.ts',
|
||||
'utils/index': 'src/utils/index.ts',
|
||||
'crypto/index': 'src/crypto/index.ts',
|
||||
},
|
||||
format: ['cjs', 'esm'],
|
||||
dts: true,
|
||||
|
||||
104
pnpm-lock.yaml
generated
104
pnpm-lock.yaml
generated
@@ -59,6 +59,12 @@ importers:
|
||||
class-validator:
|
||||
specifier: ^0.14.1
|
||||
version: 0.14.3
|
||||
ioredis:
|
||||
specifier: ^5.9.2
|
||||
version: 5.9.2
|
||||
nanoid:
|
||||
specifier: ^5.1.6
|
||||
version: 5.1.6
|
||||
passport:
|
||||
specifier: ^0.7.0
|
||||
version: 0.7.0
|
||||
@@ -71,6 +77,9 @@ importers:
|
||||
rxjs:
|
||||
specifier: ^7.8.1
|
||||
version: 7.8.2
|
||||
svg-captcha:
|
||||
specifier: ^1.4.0
|
||||
version: 1.4.0
|
||||
devDependencies:
|
||||
'@nestjs/cli':
|
||||
specifier: ^10.4.9
|
||||
@@ -160,6 +169,9 @@ importers:
|
||||
'@types/react-dom':
|
||||
specifier: ^19.0.2
|
||||
version: 19.2.3(@types/react@19.2.7)
|
||||
dotenv-cli:
|
||||
specifier: ^11.0.0
|
||||
version: 11.0.0
|
||||
eslint:
|
||||
specifier: ^9.39.0
|
||||
version: 9.39.2(jiti@2.6.1)
|
||||
@@ -774,6 +786,9 @@ packages:
|
||||
cpu: [x64]
|
||||
os: [win32]
|
||||
|
||||
'@ioredis/commands@1.5.0':
|
||||
resolution: {integrity: sha512-eUgLqrMf8nJkZxT24JvVRrQya1vZkQh8BBeYNwGDqa5I0VUi8ACx7uFvAaLxintokpTenkK6DASvo/bvNbBGow==}
|
||||
|
||||
'@isaacs/cliui@8.0.2':
|
||||
resolution: {integrity: sha512-O8jcjabXaleOG9DQ0+ARXWZBTfnP4WNAqzuiJK7ll44AmxGKv/J2M4TPjxjY3znBCfvBXFzucm1twdyFybFqEA==}
|
||||
engines: {node: '>=12'}
|
||||
@@ -1972,6 +1987,10 @@ packages:
|
||||
resolution: {integrity: sha512-JQHZ2QMW6l3aH/j6xCqQThY/9OH4D/9ls34cgkUBiEeocRTU04tHfKPBsUK1PqZCUQM7GiA0IIXJSuXHI64Kbg==}
|
||||
engines: {node: '>=0.8'}
|
||||
|
||||
cluster-key-slot@1.1.2:
|
||||
resolution: {integrity: sha512-RMr0FhtfXemyinomL4hrWcYJxmX6deFdCxpJzhDttxgO1+bcCnkk+9drydLVDmAMG7NE6aN/fl4F7ucU/90gAA==}
|
||||
engines: {node: '>=0.10.0'}
|
||||
|
||||
co@4.6.0:
|
||||
resolution: {integrity: sha512-QVb0dM5HvG+uaxitm8wONl7jltx8dqhfU33DcqtOZcLSVIKSDDLDi7+0LbAKiyI8hD9u42m2YxXSkMGWThaecQ==}
|
||||
engines: {iojs: '>= 1.0.0', node: '>= 0.12.0'}
|
||||
@@ -2149,6 +2168,10 @@ packages:
|
||||
delegates@1.0.0:
|
||||
resolution: {integrity: sha512-bd2L678uiWATM6m5Z1VzNCErI3jiGzt6HGY8OVICs40JQq/HALfbyNJmp0UDakEY4pMMaN0Ly5om/B1VI/+xfQ==}
|
||||
|
||||
denque@2.1.0:
|
||||
resolution: {integrity: sha512-HVQE3AAb/pxF8fQAoiqpvg9i3evqug3hoiwakOyZAwJm+6vZehbkYXZ0l4JxS+I3QxM97v5aaRNhj8v5oBhekw==}
|
||||
engines: {node: '>=0.10'}
|
||||
|
||||
depd@2.0.0:
|
||||
resolution: {integrity: sha512-g7nH6P6dyDioJogAAGprGpCtVImJhpPk/roCzdb3fIh61/s/nPsfR6onyMwkCAR/OlC3yBC0lESvUoQEAssIrw==}
|
||||
engines: {node: '>= 0.8'}
|
||||
@@ -2797,6 +2820,10 @@ packages:
|
||||
resolution: {integrity: sha512-4gd7VpWNQNB4UKKCFFVcp1AVv+FMOgs9NKzjHKusc8jTMhd5eL1NqQqOpE0KzMds804/yHlglp3uxgluOqAPLw==}
|
||||
engines: {node: '>= 0.4'}
|
||||
|
||||
ioredis@5.9.2:
|
||||
resolution: {integrity: sha512-tAAg/72/VxOUW7RQSX1pIxJVucYKcjFjfvj60L57jrZpYCHC3XN0WCQ3sNYL4Gmvv+7GPvTAjc+KSdeNuE8oWQ==}
|
||||
engines: {node: '>=12.22.0'}
|
||||
|
||||
ipaddr.js@1.9.1:
|
||||
resolution: {integrity: sha512-0KI/607xoxSToH7GjN1FfSbLoU0+btTicjsQSWQlh/hZykN8KpmMf7uYwPW3R+akZ6R/w18ZlXSHBYXiYUPO3g==}
|
||||
engines: {node: '>= 0.10'}
|
||||
@@ -3241,9 +3268,15 @@ packages:
|
||||
lodash-es@4.17.22:
|
||||
resolution: {integrity: sha512-XEawp1t0gxSi9x01glktRZ5HDy0HXqrM0x5pXQM98EaI0NxO6jVM7omDOxsuEo5UIASAnm2bRp1Jt/e0a2XU8Q==}
|
||||
|
||||
lodash.defaults@4.2.0:
|
||||
resolution: {integrity: sha512-qjxPLHd3r5DnsdGacqOMU6pb/avJzdh9tFX2ymgoZE27BmjXrNy/y4LoaiTeAb+O3gL8AfpJGtqfX/ae2leYYQ==}
|
||||
|
||||
lodash.includes@4.3.0:
|
||||
resolution: {integrity: sha512-W3Bx6mdkRTGtlJISOvVD/lbqjTlPPUDTMnlXZFnVwi9NKJ6tiAk6LVdlhZMm17VZisqhKcgzpO5Wz91PCt5b0w==}
|
||||
|
||||
lodash.isarguments@3.1.0:
|
||||
resolution: {integrity: sha512-chi4NHZlZqZD18a0imDHnZPrDeBbTtVN7GXMwuGdRH9qotxAjYs3aVLKc7zNOG9eddR5Ksd8rvFEBc9SsggPpg==}
|
||||
|
||||
lodash.isboolean@3.0.3:
|
||||
resolution: {integrity: sha512-Bz5mupy2SVbPHURB98VAcw+aHh4vRV5IPNhILUCsOzRmsTmSQ17jIuqopAentWoehktxGd9e/hbIXq980/1QJg==}
|
||||
|
||||
@@ -3416,6 +3449,11 @@ packages:
|
||||
engines: {node: ^10 || ^12 || ^13.7 || ^14 || >=15.0.1}
|
||||
hasBin: true
|
||||
|
||||
nanoid@5.1.6:
|
||||
resolution: {integrity: sha512-c7+7RQ+dMB5dPwwCp4ee1/iV/q2P6aK1mTZcfr1BTuVlyW9hJYiMPybJCcnBlQtuSmTIWNeazm/zqNoZSSElBg==}
|
||||
engines: {node: ^18 || >=20}
|
||||
hasBin: true
|
||||
|
||||
napi-postinstall@0.3.4:
|
||||
resolution: {integrity: sha512-PHI5f1O0EP5xJ9gQmFGMS6IZcrVvTjpXjz7Na41gTE7eE2hK11lg04CECCYEEjdc17EV4DO+fkGEtt7TpTaTiQ==}
|
||||
engines: {node: ^12.20.0 || ^14.18.0 || >=16.0.0}
|
||||
@@ -3547,6 +3585,10 @@ packages:
|
||||
resolution: {integrity: sha512-kbpaSSGJTWdAY5KPVeMOKXSrPtr8C8C7wodJbcsd51jRnmD+GZu8Y0VoU6Dm5Z4vWr0Ig/1NKuWRKf7j5aaYSg==}
|
||||
engines: {node: '>=6'}
|
||||
|
||||
opentype.js@0.7.3:
|
||||
resolution: {integrity: sha512-Veui5vl2bLonFJ/SjX/WRWJT3SncgiZNnKUyahmXCc2sa1xXW15u3R/3TN5+JFiP7RsjK5ER4HA5eWaEmV9deA==}
|
||||
hasBin: true
|
||||
|
||||
optionator@0.9.4:
|
||||
resolution: {integrity: sha512-6IpQ7mKUxRcZNLIObR0hz7lxsapSSIYNZJwXPGeF0mTVqGKFIXj1DQcMoT22S3ROcLyY/rz0PWaWZ9ayWmad9g==}
|
||||
engines: {node: '>= 0.8.0'}
|
||||
@@ -3795,6 +3837,14 @@ packages:
|
||||
resolution: {integrity: sha512-GDhwkLfywWL2s6vEjyhri+eXmfH6j1L7JE27WhqLeYzoh/A3DBaYGEj2H/HFZCn/kMfim73FXxEJTw06WtxQwg==}
|
||||
engines: {node: '>= 14.18.0'}
|
||||
|
||||
redis-errors@1.2.0:
|
||||
resolution: {integrity: sha512-1qny3OExCf0UvUV/5wpYKf2YwPcOqXzkwKKSmKHiE6ZMQs5heeE/c8eXK+PNllPvmjgAbfnsbpkGZWy8cBpn9w==}
|
||||
engines: {node: '>=4'}
|
||||
|
||||
redis-parser@3.0.0:
|
||||
resolution: {integrity: sha512-DJnGAeenTdpMEH6uAJRK/uiyEIH9WVsUmoLwzudwGJUwZPp80PDBWPHXSAGNPwNvIXAbe7MSUB1zQFugFml66A==}
|
||||
engines: {node: '>=4'}
|
||||
|
||||
reflect-metadata@0.2.2:
|
||||
resolution: {integrity: sha512-urBwgfrvVP/eAyXx4hluJivBKzuEbSQs9rKWCrCkbSxNv8mxPcUZKeuoF3Uy4mJl3Lwprp6yy5/39VWigZ4K6Q==}
|
||||
|
||||
@@ -4022,6 +4072,9 @@ packages:
|
||||
resolution: {integrity: sha512-XlkWvfIm6RmsWtNJx+uqtKLS8eqFbxUg0ZzLXqY0caEy9l7hruX8IpiDnjsLavoBgqCCR71TqWO8MaXYheJ3RQ==}
|
||||
engines: {node: '>=10'}
|
||||
|
||||
standard-as-callback@2.1.0:
|
||||
resolution: {integrity: sha512-qoRRSyROncaz1z0mvYqIE4lCd9p2R90i6GxW3uZv5ucSu8tU7B5HXUP1gG8pVZsYNVaXjk8ClXHPttLyxAL48A==}
|
||||
|
||||
statuses@2.0.1:
|
||||
resolution: {integrity: sha512-RwNA9Z/7PrK06rYLIzFMlaF+l73iwpzsqRIFgbMLbTcLD6cOao82TaWefPXQvB2fOC4AjuYSEndS7N/mTCbkdQ==}
|
||||
engines: {node: '>= 0.8'}
|
||||
@@ -4130,6 +4183,10 @@ packages:
|
||||
resolution: {integrity: sha512-ot0WnXS9fgdkgIcePe6RHNk1WA8+muPa6cSjeR3V8K27q9BB1rTE3R1p7Hv0z1ZyAc8s6Vvv8DIyWf681MAt0w==}
|
||||
engines: {node: '>= 0.4'}
|
||||
|
||||
svg-captcha@1.4.0:
|
||||
resolution: {integrity: sha512-/fkkhavXPE57zRRCjNqAP3txRCSncpMx3NnNZL7iEoyAtYwUjPhJxW6FQTQPG5UPEmCrbFoXS10C3YdJlW7PDg==}
|
||||
engines: {node: '>=4.x'}
|
||||
|
||||
swagger-ui-dist@5.18.2:
|
||||
resolution: {integrity: sha512-J+y4mCw/zXh1FOj5wGJvnAajq6XgHOyywsa9yITmwxIlJbMqITq3gYRZHaeqLVH/eV/HOPphE6NjF+nbSNC5Zw==}
|
||||
|
||||
@@ -4180,6 +4237,9 @@ packages:
|
||||
through@2.3.8:
|
||||
resolution: {integrity: sha512-w89qg7PI8wAdvX60bMDP+bFoD5Dvhm9oLheFp5O4a2QF0cSBGsBX4qZmadPMvVqlLJBBci+WqGGOAPvcDeNSVg==}
|
||||
|
||||
tiny-inflate@1.0.3:
|
||||
resolution: {integrity: sha512-pkY1fj1cKHb2seWDy0B16HeWyczlJA9/WW3u3c4z/NiWDsO3DOU5D7nhTLE9CF0yXv/QZFY7sEJmj24dK+Rrqw==}
|
||||
|
||||
tinyexec@0.3.2:
|
||||
resolution: {integrity: sha512-KQQR9yN7R5+OSwaK0XQoj22pwHoTlgYqmUscPYoknOoWCWfj/5/ABTMRi69FrKU5ffPVh5QcFikpWJI/P1ocHA==}
|
||||
|
||||
@@ -5065,6 +5125,8 @@ snapshots:
|
||||
'@img/sharp-win32-x64@0.34.5':
|
||||
optional: true
|
||||
|
||||
'@ioredis/commands@1.5.0': {}
|
||||
|
||||
'@isaacs/cliui@8.0.2':
|
||||
dependencies:
|
||||
string-width: 5.1.2
|
||||
@@ -6470,6 +6532,8 @@ snapshots:
|
||||
|
||||
clone@1.0.4: {}
|
||||
|
||||
cluster-key-slot@1.1.2: {}
|
||||
|
||||
co@4.6.0: {}
|
||||
|
||||
collect-v8-coverage@1.0.3: {}
|
||||
@@ -6626,6 +6690,8 @@ snapshots:
|
||||
|
||||
delegates@1.0.0: {}
|
||||
|
||||
denque@2.1.0: {}
|
||||
|
||||
depd@2.0.0: {}
|
||||
|
||||
destr@2.0.5: {}
|
||||
@@ -7552,6 +7618,20 @@ snapshots:
|
||||
hasown: 2.0.2
|
||||
side-channel: 1.1.0
|
||||
|
||||
ioredis@5.9.2:
|
||||
dependencies:
|
||||
'@ioredis/commands': 1.5.0
|
||||
cluster-key-slot: 1.1.2
|
||||
debug: 4.4.3
|
||||
denque: 2.1.0
|
||||
lodash.defaults: 4.2.0
|
||||
lodash.isarguments: 3.1.0
|
||||
redis-errors: 1.2.0
|
||||
redis-parser: 3.0.0
|
||||
standard-as-callback: 2.1.0
|
||||
transitivePeerDependencies:
|
||||
- supports-color
|
||||
|
||||
ipaddr.js@1.9.1: {}
|
||||
|
||||
is-array-buffer@3.0.5:
|
||||
@@ -8200,8 +8280,12 @@ snapshots:
|
||||
|
||||
lodash-es@4.17.22: {}
|
||||
|
||||
lodash.defaults@4.2.0: {}
|
||||
|
||||
lodash.includes@4.3.0: {}
|
||||
|
||||
lodash.isarguments@3.1.0: {}
|
||||
|
||||
lodash.isboolean@3.0.3: {}
|
||||
|
||||
lodash.isinteger@4.0.4: {}
|
||||
@@ -8350,6 +8434,8 @@ snapshots:
|
||||
|
||||
nanoid@3.3.11: {}
|
||||
|
||||
nanoid@5.1.6: {}
|
||||
|
||||
napi-postinstall@0.3.4: {}
|
||||
|
||||
natural-compare@1.4.0: {}
|
||||
@@ -8481,6 +8567,10 @@ snapshots:
|
||||
dependencies:
|
||||
mimic-fn: 2.1.0
|
||||
|
||||
opentype.js@0.7.3:
|
||||
dependencies:
|
||||
tiny-inflate: 1.0.3
|
||||
|
||||
optionator@0.9.4:
|
||||
dependencies:
|
||||
deep-is: 0.1.4
|
||||
@@ -8710,6 +8800,12 @@ snapshots:
|
||||
|
||||
readdirp@4.1.2: {}
|
||||
|
||||
redis-errors@1.2.0: {}
|
||||
|
||||
redis-parser@3.0.0:
|
||||
dependencies:
|
||||
redis-errors: 1.2.0
|
||||
|
||||
reflect-metadata@0.2.2: {}
|
||||
|
||||
reflect.getprototypeof@1.0.10:
|
||||
@@ -9016,6 +9112,8 @@ snapshots:
|
||||
dependencies:
|
||||
escape-string-regexp: 2.0.0
|
||||
|
||||
standard-as-callback@2.1.0: {}
|
||||
|
||||
statuses@2.0.1: {}
|
||||
|
||||
stop-iteration-iterator@1.1.0:
|
||||
@@ -9143,6 +9241,10 @@ snapshots:
|
||||
|
||||
supports-preserve-symlinks-flag@1.0.0: {}
|
||||
|
||||
svg-captcha@1.4.0:
|
||||
dependencies:
|
||||
opentype.js: 0.7.3
|
||||
|
||||
swagger-ui-dist@5.18.2:
|
||||
dependencies:
|
||||
'@scarf/scarf': 1.4.0
|
||||
@@ -9192,6 +9294,8 @@ snapshots:
|
||||
|
||||
through@2.3.8: {}
|
||||
|
||||
tiny-inflate@1.0.3: {}
|
||||
|
||||
tinyexec@0.3.2: {}
|
||||
|
||||
tinyexec@1.0.2: {}
|
||||
|
||||
Reference in New Issue
Block a user