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:
charilezhou
2026-01-16 19:17:11 +08:00
parent 1c7e2c3a7c
commit c958271027
61 changed files with 3828 additions and 205 deletions

169
CLAUDE.md
View File

@@ -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 后端 (端口 4000API 文档: /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` - 数据库模型定义(使用 PostgreSQLID 使用 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` - 数据库模型定义PostgreSQLID 使用 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/` - 前端特有类型定义

View File

@@ -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

View File

@@ -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=

View File

@@ -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",

View File

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

View File

@@ -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,
],

View File

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

View File

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

View File

@@ -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('刷新令牌无效或已过期');
}
}
}

View File

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

View File

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

View 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:';

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

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

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

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

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

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

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

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

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

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

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

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

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

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

View File

@@ -0,0 +1,11 @@
import { SetMetadata } from '@nestjs/common';
/** 跳过加密的元数据键 */
export const SKIP_ENCRYPTION_KEY = 'skipEncryption';
/**
* 跳过加密装饰器
* 用于标记不需要加密/解密处理的接口
* 例如:健康检查、文件上传等
*/
export const SkipEncryption = () => SetMetadata(SKIP_ENCRYPTION_KEY, true);

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

View File

@@ -0,0 +1,4 @@
export * from './crypto.module';
export * from './crypto.service';
export * from './encryption.interceptor';
export * from './decorators/skip-encryption.decorator';

View File

@@ -0,0 +1,3 @@
export { REDIS_CLIENT } from './redis.constants';
export { RedisModule } from './redis.module';
export { RedisService } from './redis.service';

View File

@@ -0,0 +1,5 @@
/**
* Redis 客户端注入 Token
* 用于直接注入 ioredis 客户端实例
*/
export const REDIS_CLIENT = Symbol('REDIS_CLIENT');

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

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

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

View File

@@ -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();

View File

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

View File

@@ -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) {}

View File

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

View File

@@ -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
View 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

View File

@@ -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.

View File

@@ -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"

View File

@@ -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')}

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

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

View File

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

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

View File

@@ -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:

View 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
```

View File

@@ -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 | 恢复已删除用户 |
## 扩展新模型

View File

@@ -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 服务 |
## 快速链接

View File

@@ -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": {

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

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

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

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

View File

@@ -3,3 +3,6 @@ export * from './types';
// 导出所有工具函数
export * from './utils';
// 导出加密模块
export * from './crypto';

View File

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

View File

@@ -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
View File

@@ -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: {}