feat: 切换 PostgreSQL 并实现软删除功能

- 数据库从 SQLite 切换到 PostgreSQL,添加 Docker Compose 配置
- 使用 dotenv-cli 支持 .env 和 .env.local 环境变量加载
- 使用 Prisma $extends 实现底层自动软删除机制
- 新增用户恢复和查询已删除用户的 API 接口
- 更新文档和类型定义
This commit is contained in:
Charile Zhou
2025-12-31 20:24:54 +08:00
parent b5624a664d
commit 3567aaff4d
14 changed files with 456 additions and 27 deletions

View File

@@ -9,6 +9,9 @@ Seclusion 是一个基于 Next.js + NestJS 的 Monorepo 项目模板,使用 pn
## Commands
```bash
# 启动数据库 (PostgreSQL)
docker compose -f deploy/docker-compose.yml up -d
# 开发(同时启动前后端)
pnpm dev
@@ -25,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 # 运行迁移
@@ -46,12 +49,32 @@ cd apps/api && pnpm db:studio # 打开 Prisma Studio
NestJS 采用模块化架构:
- **PrismaModule** - 全局数据库服务,其他模块通过依赖注入使用
- **PrismaModule** - 全局数据库服务,内置软删除扩展
- **AuthModule** - JWT 认证注册、登录、token 验证)
- **UserModule** - 用户 CRUD
认证流程:使用 `@Public()` 装饰器标记公开接口,其他接口需要 JWT Bearer Token。使用 `@CurrentUser()` 装饰器获取当前登录用户信息。
### 软删除机制
PrismaService 使用 `$extends` 实现底层自动软删除:
- **查询自动过滤**: `findMany``findFirst``findUnique``count` 自动添加 `deletedAt: null` 条件
- **删除自动转换**: `delete``deleteMany` 自动转换为设置 `deletedAt` 时间戳
- **绕过过滤**: 显式指定 `deletedAt` 条件可查询已删除数据,如 `where: { deletedAt: { not: null } }`
启用软删除的模型在 `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` - 恢复已删除用户(需认证)
### 共享包使用
```typescript
@@ -61,9 +84,14 @@ import { formatDate, generateId } from '@seclusion/shared';
**注意**: `packages/shared` 中的工具函数应优先使用 lodash-es 实现。
## Environment Variables
后端环境变量文件优先级:`.env.local` > `.env`NestJS ConfigModule 和 Prisma 脚本均支持)
## Key Files
- `apps/api/prisma/schema.prisma` - 数据库模型定义(使用 SQLiteID 使用 cuid2
- `apps/api/.env` - 后端环境变量 (DATABASE_URL, JWT_SECRET)
- `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 任务依赖配置

View File

@@ -1,5 +1,5 @@
# 数据库配置
DATABASE_URL="file:./dev.db"
# 数据库配置 (PostgreSQL)
DATABASE_URL="postgresql://dev:dev@localhost:5432/seclusion"
# JWT 配置
JWT_SECRET="your-super-secret-key-change-in-production"

View File

@@ -12,10 +12,11 @@
"test:watch": "jest --watch",
"test:cov": "jest --coverage",
"clean": "rm -rf dist .turbo node_modules",
"db:generate": "prisma generate",
"db:push": "prisma db push",
"db:migrate": "prisma migrate dev",
"db:studio": "prisma studio"
"db:generate": "dotenv -e .env -e .env.local -- prisma generate",
"db:push": "dotenv -e .env -e .env.local -- prisma db push",
"db:migrate": "dotenv -e .env -e .env.local -- prisma migrate dev",
"db:migrate:reset": "dotenv -e .env -e .env.local -- prisma migrate reset",
"db:studio": "dotenv -e .env -e .env.local -- prisma studio"
},
"dependencies": {
"@nestjs/common": "^10.4.15",
@@ -46,6 +47,7 @@
"@types/jest": "^29.5.14",
"@types/node": "^22.10.2",
"@types/passport-jwt": "^4.0.1",
"dotenv-cli": "^11.0.0",
"eslint": "^9.39.0",
"jest": "^29.7.0",
"prisma": "^6.1.0",

View File

@@ -3,17 +3,20 @@ generator client {
}
datasource db {
provider = "sqlite"
provider = "postgresql"
url = env("DATABASE_URL")
}
model User {
id String @id @default(cuid(2))
email String @unique
id String @id @default(cuid(2))
email String
password String
name String?
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
deletedAt DateTime?
// 复合唯一约束:未删除用户邮箱唯一,已删除用户邮箱可重复
@@unique([email, deletedAt])
@@map("users")
}

View File

@@ -11,7 +11,7 @@ import { UserModule } from './user/user.module';
imports: [
ConfigModule.forRoot({
isGlobal: true,
envFilePath: '.env',
envFilePath: ['.env.local', '.env'],
}),
PrismaModule,
AuthModule,

View File

@@ -14,8 +14,8 @@ export class AuthService {
) {}
async register(dto: RegisterDto) {
// 检查邮箱是否已存在
const existingUser = await this.prisma.user.findUnique({
// 底层自动过滤已删除用户
const existingUser = await this.prisma.user.findFirst({
where: { email: dto.email },
});
@@ -49,8 +49,8 @@ export class AuthService {
}
async login(dto: LoginDto) {
// 查找用户
const user = await this.prisma.user.findUnique({
// 底层自动过滤已删除用户
const user = await this.prisma.user.findFirst({
where: { email: dto.email },
});

View File

@@ -1,13 +1,109 @@
import { Injectable, OnModuleInit, OnModuleDestroy } from '@nestjs/common';
import { PrismaClient } from '@prisma/client';
import { Prisma, PrismaClient } from '@prisma/client';
// 启用软删除的模型列表
const SOFT_DELETE_MODELS: Prisma.ModelName[] = ['User'];
function isSoftDeleteModel(model: string | undefined): boolean {
return !!model && SOFT_DELETE_MODELS.includes(model as Prisma.ModelName);
}
// 创建软删除扩展(需要传入客户端实例)
function createSoftDeleteExtension(client: PrismaClient) {
return Prisma.defineExtension({
name: 'softDelete',
query: {
$allModels: {
async findMany({ model, args, query }) {
if (isSoftDeleteModel(model) && args.where?.deletedAt === undefined) {
args.where = { ...args.where, deletedAt: null };
}
return query(args);
},
async findFirst({ model, args, query }) {
if (isSoftDeleteModel(model) && args.where?.deletedAt === undefined) {
args.where = { ...args.where, deletedAt: null };
}
return query(args);
},
async findUnique({ model, args, query }) {
if (isSoftDeleteModel(model) && (args.where as Record<string, unknown>)?.deletedAt === undefined) {
(args.where as Record<string, unknown>).deletedAt = null;
}
return query(args);
},
async count({ model, args, query }) {
if (isSoftDeleteModel(model) && args.where?.deletedAt === undefined) {
args.where = { ...args.where, deletedAt: null };
}
return query(args);
},
async delete({ model, args }) {
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({
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);
},
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({
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
);
},
},
},
});
}
// 创建扩展后的客户端
function createPrismaClient() {
const client = new PrismaClient();
return client.$extends(createSoftDeleteExtension(client));
}
// 扩展后的客户端类型
export type ExtendedPrismaClient = ReturnType<typeof createPrismaClient>;
@Injectable()
export class PrismaService extends PrismaClient implements OnModuleInit, OnModuleDestroy {
export class PrismaService implements OnModuleInit, OnModuleDestroy {
private readonly _client: ExtendedPrismaClient;
constructor() {
this._client = createPrismaClient();
}
get client(): ExtendedPrismaClient {
return this._client;
}
// 代理到扩展客户端
get user() {
return this._client.user;
}
async onModuleInit() {
await this.$connect();
await this._client.$connect();
}
async onModuleDestroy() {
await this.$disconnect();
await this._client.$disconnect();
}
}

View File

@@ -19,6 +19,12 @@ export class UserController {
return this.userService.findAll();
}
@Get('deleted')
@ApiOperation({ summary: '获取已删除的用户列表' })
findDeleted() {
return this.userService.findDeleted();
}
@Get(':id')
@ApiOperation({ summary: '根据 ID 获取用户' })
findById(@Param('id') id: string) {
@@ -36,4 +42,10 @@ export class UserController {
delete(@Param('id') id: string) {
return this.userService.delete(id);
}
@Patch(':id/restore')
@ApiOperation({ summary: '恢复已删除的用户' })
restore(@Param('id') id: string) {
return this.userService.restore(id);
}
}

View File

@@ -9,6 +9,7 @@ export class UserService {
constructor(private prisma: PrismaService) {}
async findAll() {
// 底层自动过滤已删除记录
return this.prisma.user.findMany({
select: {
id: true,
@@ -21,6 +22,7 @@ export class UserService {
}
async findById(id: string) {
// 底层自动过滤已删除记录
const user = await this.prisma.user.findUnique({
where: { id },
select: {
@@ -66,8 +68,47 @@ export class UserService {
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,
},
});
}
}

20
deploy/docker-compose.yml Normal file
View File

@@ -0,0 +1,20 @@
services:
postgres:
image: postgres:16-alpine
container_name: seclusion-postgres
ports:
- "5432:5432"
environment:
POSTGRES_USER: dev
POSTGRES_PASSWORD: dev
POSTGRES_DB: seclusion
volumes:
- postgres_data:/var/lib/postgresql/data
healthcheck:
test: ["CMD-SHELL", "pg_isready -U dev -d seclusion"]
interval: 5s
timeout: 5s
retries: 5
volumes:
postgres_data:

197
docs/backend/soft-delete.md Normal file
View File

@@ -0,0 +1,197 @@
# 软删除Soft Delete设计文档
## 概述
软删除是一种数据删除策略,不物理删除数据库记录,而是通过标记字段(`deletedAt`)来标识记录已被删除。这样可以保留数据历史,支持数据恢复,同时在业务层面表现为"已删除"。
## 设计目标
- **通用方案**:底层自动处理,业务代码无需关心软删除逻辑
- **可扩展**:新增模型只需加入配置数组即可启用软删除
- **支持恢复**:已删除的数据可以恢复
- **邮箱可复用**:用户软删除后,其邮箱可被新用户注册
## 技术实现
### 1. 数据库层 (Prisma Schema)
`apps/api/prisma/schema.prisma`:
```prisma
model User {
id String @id @default(cuid(2))
email String
password String
name String?
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
deletedAt DateTime? // 软删除标记null 表示未删除
// 复合唯一约束:允许已删除用户的邮箱被重新注册
// email + null 唯一(未删除用户)
// email + deletedAt 唯一(已删除用户,同一邮箱可多次删除)
@@unique([email, deletedAt])
@@map("users")
}
```
### 2. PrismaService 扩展
`apps/api/src/prisma/prisma.service.ts`:
使用 Prisma Client Extensions (`$extends`) 实现底层自动软删除:
```typescript
// 启用软删除的模型列表
const SOFT_DELETE_MODELS: Prisma.ModelName[] = ['User'];
// 扩展拦截以下操作:
const softDeleteExtension = Prisma.defineExtension({
query: {
$allModels: {
// 查询操作:自动添加 deletedAt: null 条件
findMany({ model, args, query }) { ... },
findFirst({ model, args, query }) { ... },
findUnique({ model, args, query }) { ... },
count({ model, args, query }) { ... },
// 删除操作:转换为 update 设置 deletedAt
delete({ model, args }) { ... },
deleteMany({ model, args }) { ... },
},
},
});
```
### 3. 自动处理逻辑
| 操作 | 自动处理 |
|------|---------|
| `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() } })` |
## 使用方式
### 业务代码(无需关心软删除)
```typescript
// 查询 - 自动过滤已删除记录
const users = await prisma.user.findMany();
// 删除 - 自动转换为软删除
await prisma.user.delete({ where: { id } });
// 按 ID 查找 - 自动过滤已删除记录
const user = await prisma.user.findUnique({ where: { id } });
```
### 查询已删除数据
显式指定 `deletedAt` 条件可绕过自动过滤:
```typescript
// 查询已删除的用户
const deletedUsers = await prisma.user.findMany({
where: { deletedAt: { not: null } },
});
// 查询指定 ID 的已删除用户
const deletedUser = await prisma.user.findFirst({
where: { id, deletedAt: { not: null } },
});
```
### 恢复已删除数据
```typescript
// 恢复用户
await prisma.user.update({
where: { id },
data: { deletedAt: null },
});
```
## API 接口
| 接口 | 方法 | 说明 |
|------|------|------|
| `/users` | GET | 获取用户列表(自动排除已删除) |
| `/users/:id` | GET | 获取单个用户(自动排除已删除) |
| `/users/:id` | DELETE | 软删除用户 |
| `/users/deleted` | GET | 获取已删除用户列表 |
| `/users/:id/restore` | PATCH | 恢复已删除用户 |
## 扩展新模型
为新模型启用软删除只需三步:
### 1. 修改 Schema
在模型中添加 `deletedAt` 字段:
```prisma
model Post {
id String @id @default(cuid(2))
title String
content String
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
deletedAt DateTime? // 添加此字段
@@map("posts")
}
```
### 2. 配置启用
`apps/api/src/prisma/prisma.service.ts` 中添加模型名:
```typescript
const SOFT_DELETE_MODELS: Prisma.ModelName[] = ['User', 'Post'];
```
### 3. 添加恢复接口(可选)
如需恢复功能,在 Service 中添加:
```typescript
async findDeleted() {
return this.prisma.post.findMany({
where: { deletedAt: { not: null } },
});
}
async restore(id: string) {
const post = await this.prisma.post.findFirst({
where: { id, deletedAt: { not: null } },
});
if (!post) throw new NotFoundException('已删除的文章不存在');
return this.prisma.post.update({
where: { id },
data: { deletedAt: null },
});
}
```
## 注意事项
1. **复合唯一约束**`@@unique([email, deletedAt])` 使得:
- 未删除用户email + null 唯一
- 已删除用户email + deletedAt 唯一(同一邮箱可多次删除)
2. **update 操作不受影响**:可以更新已删除的记录,这对于恢复功能是必要的
3. **物理删除**:如确需物理删除数据,需要绕过扩展直接使用原始 PrismaClient
4. **性能考虑**:软删除会增加查询条件,建议为 `deletedAt` 字段添加索引
## 相关文件
- `apps/api/prisma/schema.prisma` - 数据库模型定义
- `apps/api/src/prisma/prisma.service.ts` - 软删除扩展实现
- `apps/api/src/user/user.service.ts` - 用户服务(含恢复逻辑)
- `apps/api/src/user/user.controller.ts` - 用户接口(含恢复接口)

View File

@@ -4,9 +4,10 @@
## 文档列表
| 文档 | 说明 | 适用场景 |
| ------------------------ | ------------ | -------------------------------- |
| [design.md](./design.md) | 项目设计文档 | 了解整体架构、技术选型、模块设计 |
| 文档 | 说明 | 适用场景 |
| ------------------------------------------ | ---------------- | -------------------------------- |
| [design.md](./design.md) | 项目设计文档 | 了解整体架构、技术选型、模块设计 |
| [backend/soft-delete.md](./backend/soft-delete.md) | 软删除设计文档 | 了解软删除机制、扩展新模型 |
## 快速链接

View File

@@ -30,6 +30,7 @@ export interface User {
name: string | null;
createdAt: Date;
updatedAt: Date;
deletedAt?: Date | null;
}
// 用户创建请求

28
pnpm-lock.yaml generated
View File

@@ -102,6 +102,9 @@ importers:
'@types/passport-jwt':
specifier: ^4.0.1
version: 4.0.1
dotenv-cli:
specifier: ^11.0.0
version: 11.0.0
eslint:
specifier: ^9.39.0
version: 9.39.2(jiti@2.6.1)
@@ -2177,10 +2180,18 @@ packages:
resolution: {integrity: sha512-35mSku4ZXK0vfCuHEDAwt55dg2jNajHZ1odvF+8SSr82EsZY4QmXfuWso8oEd8zRhVObSN18aM0CjSdoBX7zIw==}
engines: {node: '>=0.10.0'}
dotenv-cli@11.0.0:
resolution: {integrity: sha512-r5pA8idbk7GFWuHEU7trSTflWcdBpQEK+Aw17UrSHjS6CReuhrrPcyC3zcQBPQvhArRHnBo/h6eLH1fkCvNlww==}
hasBin: true
dotenv-expand@10.0.0:
resolution: {integrity: sha512-GopVGCpVS1UKH75VKHGuQFqS1Gusej0z4FyQkPdwjil2gNIv+LNsqBlboOzpJFZKVT95GkCyWJbBSdFEFUWI2A==}
engines: {node: '>=12'}
dotenv-expand@12.0.3:
resolution: {integrity: sha512-uc47g4b+4k/M/SeaW1y4OApx+mtLWl92l5LMPP0GNXctZqELk+YGgOPIIC5elYmUH4OuoK3JLhuRUYegeySiFA==}
engines: {node: '>=12'}
dotenv@16.4.5:
resolution: {integrity: sha512-ZmdL2rui+eB2YwhsWzjInR8LldtZHGDoQ1ugH85ppHKwpUHL7j7rN0Ti9NCnGiQbhaZ11FpR+7ao1dNsmduNUg==}
engines: {node: '>=12'}
@@ -2189,6 +2200,10 @@ packages:
resolution: {integrity: sha512-uBq4egWHTcTt33a72vpSG0z3HnPuIl6NqYcTrKEg2azoEyl2hpW0zqlxysq2pK9HlDIHyHyakeYaYnSAwd8bow==}
engines: {node: '>=12'}
dotenv@17.2.3:
resolution: {integrity: sha512-JVUnt+DUIzu87TABbhPmNfVdBDt18BLOWjMUFJMSi/Qqg7NTYtabbvSNJGOJ7afbRuv9D/lngizHtP7QyLQ+9w==}
engines: {node: '>=12'}
dunder-proto@1.0.1:
resolution: {integrity: sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A==}
engines: {node: '>= 0.4'}
@@ -6629,12 +6644,25 @@ snapshots:
dependencies:
esutils: 2.0.3
dotenv-cli@11.0.0:
dependencies:
cross-spawn: 7.0.6
dotenv: 17.2.3
dotenv-expand: 12.0.3
minimist: 1.2.8
dotenv-expand@10.0.0: {}
dotenv-expand@12.0.3:
dependencies:
dotenv: 16.6.1
dotenv@16.4.5: {}
dotenv@16.6.1: {}
dotenv@17.2.3: {}
dunder-proto@1.0.1:
dependencies:
call-bind-apply-helpers: 1.0.2