You can not select more than 25 topics
Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
20 KiB
20 KiB
RFC 131: 项目回收站系统
状态: 待实施
创建时间: 2026-01-21
作者: System
相关文档: 项目管理服务
概述
实现项目回收站机制,支持三层删除流程:用户删除 → 回收站(30天) → 软删除(用户不可见) → 物理删除(仅管理员)。
背景
当前问题
- 误删风险:用户删除项目后无法恢复,误操作成本高
- 数据安全:删除的项目立即从数据库清除,无法追溯
- 用户体验:缺少"后悔药"机制,不符合现代应用标准
业务需求
- 用户删除项目后进入回收站,30天内可恢复
- 用户可在回收站中立即永久删除
- 回收站项目30天后自动转为软删除
- 软删除的项目用户不可见,仅管理后台可查
- 管理员可物理删除项目(真实删除数据库记录)
设计方案
状态枚举设计
class ProjectStatus(IntEnum):
"""项目状态枚举"""
ACTIVE = 0 # 活跃项目
ARCHIVED = 1 # 用户归档
TRASHED = 2 # 回收站(30天内可恢复)
SOFT_DELETED = 3 # 软删除(用户不可见,仅管理后台可查)
命名说明:
ACTIVE: 正常活跃状态ARCHIVED: 用户主动归档(不常用但保留)TRASHED: 回收站状态(可恢复)SOFT_DELETED: 软删除(业界标准术语,区别于物理删除)
数据库字段
-- 状态字段
status SMALLINT NOT NULL DEFAULT 0 CHECK (status IN (0, 1, 2, 3))
-- 时间戳字段
trashed_at TIMESTAMPTZ -- 进入回收站时间(用于计算30天期限)
deleted_at TIMESTAMPTZ -- 软删除时间(从回收站删除或自动过期)
permanently_deleted_at TIMESTAMPTZ -- 物理删除时间(仅管理员操作,预留字段)
状态流转规则
┌─────────┐
│ active │ ◄──────────────┐
│ (0) │ │
└────┬────┘ │
│ │
│ 用户删除 │ 用户恢复
▼ │
┌─────────┐ │
│ trashed │ ───────────────┘
│ (2) │
└────┬────┘
│
│ 用户永久删除 或 30天自动
▼
┌──────────────┐
│ soft_deleted │ ──────► 物理删除(仅管理员)
│ (3) │
└──────────────┘
状态转换规则:
active/archived→trashed: 用户删除trashed→active: 用户恢复trashed→soft_deleted: 用户永久删除 或 30天自动soft_deleted→ 物理删除: 仅管理员操作
索引优化
-- 状态索引(覆盖活跃和归档)
CREATE INDEX idx_projects_status ON projects (status);
-- 回收站时间索引(用于自动清理任务)
CREATE INDEX idx_projects_trashed_at ON projects (trashed_at) WHERE status = 2;
-- 更新其他索引的 WHERE 条件(排除回收站和软删除)
CREATE INDEX idx_projects_owner ON projects (owner_type, owner_id) WHERE status IN (0, 1);
CREATE INDEX idx_projects_folder_id ON projects (folder_id) WHERE status IN (0, 1) AND folder_id IS NOT NULL;
API 接口设计
1. 移至回收站
DELETE /api/v1/projects/{project_id}
行为:
- 设置
status = 2 - 设置
trashed_at = NOW() - 返回成功消息
权限:项目所有者(owner)
2. 从回收站恢复
POST /api/v1/projects/{project_id}/restore
行为:
- 设置
status = 0(active) - 清空
trashed_at - 返回恢复后的项目信息
权限:项目所有者
3. 永久删除
DELETE /api/v1/projects/{project_id}/permanent
行为:
- 设置
status = 3 - 设置
deleted_at = NOW() - 返回成功消息
权限:项目所有者
4. 查看回收站
GET /api/v1/projects/trash?page=1&page_size=20
响应:
{
"items": [
{
"id": "project-123",
"name": "我的项目",
"status": "trashed",
"trashed_at": "2026-01-21T10:00:00Z",
"days_remaining": 25
}
],
"total": 10,
"page": 1,
"page_size": 20
}
权限:当前用户
5. 物理删除(管理员)
DELETE /admin/api/v1/projects/{project_id}
行为:
- 真实删除数据库记录
- 级联删除关联资源
- 记录操作日志
权限:仅管理员
服务层实现
ProjectService 新增方法
async def move_to_trash(
self,
user_id: str,
project_id: str
) -> None:
"""移至回收站"""
project = await self.repository.get_by_id(project_id)
if not project:
raise NotFoundError("项目不存在")
# 检查权限(只有 owner 可以删除)
if not await self.repository.check_user_permission(
user_id, project_id, 'owner'
):
raise PermissionError("只有项目所有者可以删除项目")
await self.repository.update(project_id, {
'status': ProjectStatus.TRASHED,
'trashed_at': datetime.utcnow()
})
async def restore_from_trash(
self,
user_id: str,
project_id: str
) -> Project:
"""从回收站恢复"""
project = await self.repository.get_by_id(project_id)
if not project:
raise NotFoundError("项目不存在")
if project.status != ProjectStatus.TRASHED:
raise ValidationError("项目不在回收站中")
# 检查权限
if not await self.repository.check_user_permission(
user_id, project_id, 'owner'
):
raise PermissionError("只有项目所有者可以恢复项目")
return await self.repository.update(project_id, {
'status': ProjectStatus.ACTIVE,
'trashed_at': None
})
async def permanent_delete(
self,
user_id: str,
project_id: str
) -> None:
"""永久删除(软删除)"""
project = await self.repository.get_by_id(project_id)
if not project:
raise NotFoundError("项目不存在")
if project.status != ProjectStatus.TRASHED:
raise ValidationError("只能永久删除回收站中的项目")
# 检查权限
if not await self.repository.check_user_permission(
user_id, project_id, 'owner'
):
raise PermissionError("只有项目所有者可以永久删除项目")
await self.repository.update(project_id, {
'status': ProjectStatus.SOFT_DELETED,
'deleted_at': datetime.utcnow()
})
async def get_trash_projects(
self,
user_id: str,
page: int = 1,
page_size: int = 20
) -> Dict[str, Any]:
"""获取回收站项目列表"""
projects = await self.repository.get_trashed_projects(
user_id=user_id,
page=page,
page_size=page_size
)
total = await self.repository.count_trashed_projects(user_id)
# 计算剩余天数
items = []
for project in projects:
days_remaining = 30 - (datetime.utcnow() - project.trashed_at).days
items.append({
**project.dict(),
'days_remaining': max(0, days_remaining)
})
return {
'items': items,
'total': total,
'page': page,
'page_size': page_size,
'total_pages': (total + page_size - 1) // page_size
}
Repository 新增方法
async def get_trashed_projects(
self,
user_id: str,
page: int = 1,
page_size: int = 20
) -> List[Project]:
"""获取回收站项目"""
offset = (page - 1) * page_size
query = select(Project).where(
and_(
Project.owner_id == user_id,
Project.status == ProjectStatus.TRASHED
)
).order_by(Project.trashed_at.desc()).offset(offset).limit(page_size)
result = await self.db.execute(query)
return result.scalars().all()
async def count_trashed_projects(self, user_id: str) -> int:
"""统计回收站项目数量"""
query = select(func.count(Project.id)).where(
and_(
Project.owner_id == user_id,
Project.status == ProjectStatus.TRASHED
)
)
result = await self.db.execute(query)
return result.scalar_one()
自动清理机制
定时任务
使用 APScheduler 或 Celery Beat 实现每日自动清理:
# app/tasks/cleanup.py
from datetime import datetime, timedelta
from sqlalchemy import update
from app.models.project import Project, ProjectStatus
async def cleanup_expired_trash():
"""清理过期的回收站项目(30天)"""
expiry_date = datetime.utcnow() - timedelta(days=30)
query = update(Project).where(
and_(
Project.status == ProjectStatus.TRASHED,
Project.trashed_at < expiry_date
)
).values(
status=ProjectStatus.SOFT_DELETED,
deleted_at=datetime.utcnow()
)
result = await db.execute(query)
count = result.rowcount
logger.info(f"自动清理了 {count} 个过期的回收站项目")
return count
任务调度配置
# app/main.py
from apscheduler.schedulers.asyncio import AsyncIOScheduler
from app.tasks.cleanup import cleanup_expired_trash
scheduler = AsyncIOScheduler()
# 每天凌晨 2 点执行清理任务
scheduler.add_job(
cleanup_expired_trash,
'cron',
hour=2,
minute=0,
id='cleanup_expired_trash'
)
@app.on_event("startup")
async def startup_event():
scheduler.start()
@app.on_event("shutdown")
async def shutdown_event():
scheduler.shutdown()
数据库迁移
迁移脚本
# server/app/migrations/005_project_status_enhancement.py
"""
项目状态增强:添加回收站机制
迁移内容:
1. 修改 status 字段类型为 SMALLINT
2. 添加 trashed_at 字段
3. 更新 deleted_at 字段语义
4. 添加 permanently_deleted_at 字段
5. 更新索引
6. 数据迁移(将现有 deleted_at 不为空的项目设置为 soft_deleted)
"""
async def upgrade(db):
# 1. 添加新字段
await db.execute("""
ALTER TABLE projects
ADD COLUMN IF NOT EXISTS trashed_at TIMESTAMPTZ,
ADD COLUMN IF NOT EXISTS permanently_deleted_at TIMESTAMPTZ;
""")
# 2. 修改 status 字段(先添加新列,再迁移数据,最后删除旧列)
await db.execute("""
ALTER TABLE projects ADD COLUMN status_new SMALLINT;
""")
# 3. 数据迁移
await db.execute("""
UPDATE projects SET status_new = CASE
WHEN status = 'active' THEN 0
WHEN status = 'archived' THEN 1
WHEN status = 'deleted' AND deleted_at IS NOT NULL THEN 3
ELSE 0
END;
""")
# 4. 删除旧列,重命名新列
await db.execute("""
ALTER TABLE projects DROP COLUMN status;
ALTER TABLE projects RENAME COLUMN status_new TO status;
ALTER TABLE projects ALTER COLUMN status SET NOT NULL;
ALTER TABLE projects ALTER COLUMN status SET DEFAULT 0;
ALTER TABLE projects ADD CONSTRAINT projects_status_check
CHECK (status IN (0, 1, 2, 3));
""")
# 5. 更新索引
await db.execute("""
DROP INDEX IF EXISTS idx_projects_status;
DROP INDEX IF EXISTS idx_projects_owner;
DROP INDEX IF EXISTS idx_projects_folder_id;
CREATE INDEX idx_projects_status ON projects (status);
CREATE INDEX idx_projects_trashed_at ON projects (trashed_at) WHERE status = 2;
CREATE INDEX idx_projects_owner ON projects (owner_type, owner_id) WHERE status IN (0, 1);
CREATE INDEX idx_projects_folder_id ON projects (folder_id) WHERE status IN (0, 1) AND folder_id IS NOT NULL;
""")
# 6. 添加注释
await db.execute("""
COMMENT ON COLUMN projects.status IS '项目状态:0=active(活跃), 1=archived(归档), 2=trashed(回收站), 3=soft_deleted(软删除)';
COMMENT ON COLUMN projects.trashed_at IS '进入回收站时间,用于计算30天自动清理期限';
COMMENT ON COLUMN projects.deleted_at IS '软删除时间,用户从回收站删除或自动过期后设置';
COMMENT ON COLUMN projects.permanently_deleted_at IS '物理删除时间,仅管理员可操作(预留字段)';
""")
async def downgrade(db):
# 回滚迁移
await db.execute("""
ALTER TABLE projects DROP COLUMN IF EXISTS trashed_at;
ALTER TABLE projects DROP COLUMN IF EXISTS permanently_deleted_at;
-- 恢复旧的 status 字段
ALTER TABLE projects ADD COLUMN status_old TEXT;
UPDATE projects SET status_old = CASE
WHEN status = 0 THEN 'active'
WHEN status = 1 THEN 'archived'
WHEN status IN (2, 3) THEN 'deleted'
ELSE 'active'
END;
ALTER TABLE projects DROP COLUMN status;
ALTER TABLE projects RENAME COLUMN status_old TO status;
ALTER TABLE projects ALTER COLUMN status SET NOT NULL;
ALTER TABLE projects ALTER COLUMN status SET DEFAULT 'active';
ALTER TABLE projects ADD CONSTRAINT projects_status_check
CHECK (status IN ('active', 'archived', 'deleted'));
""")
前端集成
回收站页面
// client/src/pages/TrashPage.tsx
import { useQuery, useMutation } from '@tanstack/react-query';
import { projectApi } from '@/services/api';
export function TrashPage() {
const { data, refetch } = useQuery({
queryKey: ['projects', 'trash'],
queryFn: () => projectApi.getTrash()
});
const restoreMutation = useMutation({
mutationFn: (projectId: string) => projectApi.restore(projectId),
onSuccess: () => refetch()
});
const deleteMutation = useMutation({
mutationFn: (projectId: string) => projectApi.permanentDelete(projectId),
onSuccess: () => refetch()
});
return (
<div>
<h1>回收站</h1>
{data?.items.map(project => (
<div key={project.id}>
<span>{project.name}</span>
<span>{project.days_remaining} 天后自动删除</span>
<button onClick={() => restoreMutation.mutate(project.id)}>
恢复
</button>
<button onClick={() => deleteMutation.mutate(project.id)}>
永久删除
</button>
</div>
))}
</div>
);
}
API 客户端
// client/src/services/api/projects.ts
export const projectApi = {
// 移至回收站
delete: (projectId: string) =>
client.delete(`/projects/${projectId}`),
// 恢复
restore: (projectId: string) =>
client.post(`/projects/${projectId}/restore`),
// 永久删除
permanentDelete: (projectId: string) =>
client.delete(`/projects/${projectId}/permanent`),
// 获取回收站
getTrash: (params?: { page?: number; page_size?: number }) =>
client.get('/projects/trash', { params })
};
测试计划
单元测试
# tests/test_project_trash.py
import pytest
from datetime import datetime, timedelta
async def test_move_to_trash(project_service, test_user, test_project):
"""测试移至回收站"""
await project_service.move_to_trash(test_user.id, test_project.id)
project = await project_service.get_project(test_user.id, test_project.id)
assert project.status == ProjectStatus.TRASHED
assert project.trashed_at is not None
async def test_restore_from_trash(project_service, test_user, trashed_project):
"""测试从回收站恢复"""
restored = await project_service.restore_from_trash(
test_user.id,
trashed_project.id
)
assert restored.status == ProjectStatus.ACTIVE
assert restored.trashed_at is None
async def test_permanent_delete(project_service, test_user, trashed_project):
"""测试永久删除"""
await project_service.permanent_delete(test_user.id, trashed_project.id)
project = await project_service.repository.get_by_id(trashed_project.id)
assert project.status == ProjectStatus.SOFT_DELETED
assert project.deleted_at is not None
async def test_auto_cleanup(project_service, db):
"""测试自动清理过期项目"""
# 创建一个31天前的回收站项目
old_project = await create_test_project(
status=ProjectStatus.TRASHED,
trashed_at=datetime.utcnow() - timedelta(days=31)
)
# 执行清理任务
count = await cleanup_expired_trash()
assert count == 1
# 验证项目已转为软删除
project = await project_service.repository.get_by_id(old_project.id)
assert project.status == ProjectStatus.SOFT_DELETED
集成测试
async def test_trash_workflow(client, test_user):
"""测试完整的回收站流程"""
# 1. 创建项目
response = await client.post('/api/v1/projects', json={
'name': '测试项目',
'type': 'mine'
})
project_id = response.json()['id']
# 2. 删除项目(移至回收站)
response = await client.delete(f'/api/v1/projects/{project_id}')
assert response.status_code == 200
# 3. 查看回收站
response = await client.get('/api/v1/projects/trash')
assert response.status_code == 200
assert len(response.json()['items']) == 1
# 4. 恢复项目
response = await client.post(f'/api/v1/projects/{project_id}/restore')
assert response.status_code == 200
# 5. 再次删除
await client.delete(f'/api/v1/projects/{project_id}')
# 6. 永久删除
response = await client.delete(f'/api/v1/projects/{project_id}/permanent')
assert response.status_code == 200
# 7. 验证回收站为空
response = await client.get('/api/v1/projects/trash')
assert len(response.json()['items']) == 0
风险评估
技术风险
| 风险 | 影响 | 缓解措施 |
|---|---|---|
| 数据迁移失败 | 高 | 充分测试迁移脚本,提供回滚方案 |
| 自动清理任务失败 | 中 | 添加错误日志和告警,手动补偿机制 |
| 性能影响 | 低 | 优化索引,定时任务在低峰期执行 |
业务风险
| 风险 | 影响 | 缓解措施 |
|---|---|---|
| 用户误操作永久删除 | 中 | 添加二次确认弹窗,明确提示不可恢复 |
| 回收站占用存储空间 | 低 | 30天自动清理,管理员可手动清理 |
实施计划
阶段 1:数据库迁移(1天)
- 编写迁移脚本
- 本地测试迁移
- 准备回滚方案
阶段 2:后端实现(2天)
- 更新模型和枚举
- 实现 Service 层方法
- 实现 Repository 层方法
- 添加 API 端点
- 编写单元测试
阶段 3:自动清理任务(1天)
- 实现清理任务
- 配置任务调度
- 添加日志和监控
阶段 4:前端集成(2天)
- 实现回收站页面
- 更新删除确认弹窗
- 添加恢复和永久删除按钮
- 集成测试
阶段 5:测试和部署(1天)
- 集成测试
- 性能测试
- 部署到测试环境
- 部署到生产环境
总计:7 个工作日
后续优化
- 批量操作:支持批量恢复、批量永久删除
- 回收站容量限制:限制回收站最大项目数(如 100 个)
- 自定义保留期:允许用户设置回收站保留天数(7/15/30天)
- 回收站搜索:支持在回收站中搜索项目
- 删除原因记录:记录项目被删除的原因(用户主动/自动清理)
参考资料
RFC 状态: 待审核
下一步: 等待团队评审和批准