# RFC 131: 项目回收站系统 > **状态**: 待实施 > **创建时间**: 2026-01-21 > **作者**: System > **相关文档**: [项目管理服务](../../requirements/backend/04-services/project/project-service.md) --- ## 概述 实现项目回收站机制,支持三层删除流程:用户删除 → 回收站(30天) → 软删除(用户不可见) → 物理删除(仅管理员)。 --- ## 背景 ### 当前问题 1. **误删风险**:用户删除项目后无法恢复,误操作成本高 2. **数据安全**:删除的项目立即从数据库清除,无法追溯 3. **用户体验**:缺少"后悔药"机制,不符合现代应用标准 ### 业务需求 - 用户删除项目后进入回收站,30天内可恢复 - 用户可在回收站中立即永久删除 - 回收站项目30天后自动转为软删除 - 软删除的项目用户不可见,仅管理后台可查 - 管理员可物理删除项目(真实删除数据库记录) --- ## 设计方案 ### 状态枚举设计 ```python class ProjectStatus(IntEnum): """项目状态枚举""" ACTIVE = 0 # 活跃项目 ARCHIVED = 1 # 用户归档 TRASHED = 2 # 回收站(30天内可恢复) SOFT_DELETED = 3 # 软删除(用户不可见,仅管理后台可查) ``` **命名说明**: - `ACTIVE`: 正常活跃状态 - `ARCHIVED`: 用户主动归档(不常用但保留) - `TRASHED`: 回收站状态(可恢复) - `SOFT_DELETED`: 软删除(业界标准术语,区别于物理删除) ### 数据库字段 ```sql -- 状态字段 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) │ └──────────────┘ ``` **状态转换规则**: 1. `active/archived` → `trashed`: 用户删除 2. `trashed` → `active`: 用户恢复 3. `trashed` → `soft_deleted`: 用户永久删除 或 30天自动 4. `soft_deleted` → 物理删除: 仅管理员操作 ### 索引优化 ```sql -- 状态索引(覆盖活跃和归档) 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. 移至回收站 ```http DELETE /api/v1/projects/{project_id} ``` **行为**: - 设置 `status = 2` - 设置 `trashed_at = NOW()` - 返回成功消息 **权限**:项目所有者(owner) ### 2. 从回收站恢复 ```http POST /api/v1/projects/{project_id}/restore ``` **行为**: - 设置 `status = 0` (active) - 清空 `trashed_at` - 返回恢复后的项目信息 **权限**:项目所有者 ### 3. 永久删除 ```http DELETE /api/v1/projects/{project_id}/permanent ``` **行为**: - 设置 `status = 3` - 设置 `deleted_at = NOW()` - 返回成功消息 **权限**:项目所有者 ### 4. 查看回收站 ```http GET /api/v1/projects/trash?page=1&page_size=20 ``` **响应**: ```json { "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. 物理删除(管理员) ```http DELETE /admin/api/v1/projects/{project_id} ``` **行为**: - 真实删除数据库记录 - 级联删除关联资源 - 记录操作日志 **权限**:仅管理员 --- ## 服务层实现 ### ProjectService 新增方法 ```python 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 新增方法 ```python 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 实现每日自动清理: ```python # 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 ``` ### 任务调度配置 ```python # 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() ``` --- ## 数据库迁移 ### 迁移脚本 ```python # 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')); """) ``` --- ## 前端集成 ### 回收站页面 ```typescript // 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 (

回收站

{data?.items.map(project => (
{project.name} {project.days_remaining} 天后自动删除
))}
); } ``` ### API 客户端 ```typescript // 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 }) }; ``` --- ## 测试计划 ### 单元测试 ```python # 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 ``` ### 集成测试 ```python 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 个工作日 --- ## 后续优化 1. **批量操作**:支持批量恢复、批量永久删除 2. **回收站容量限制**:限制回收站最大项目数(如 100 个) 3. **自定义保留期**:允许用户设置回收站保留天数(7/15/30天) 4. **回收站搜索**:支持在回收站中搜索项目 5. **删除原因记录**:记录项目被删除的原因(用户主动/自动清理) --- ## 参考资料 - [软删除最佳实践](https://en.wikipedia.org/wiki/Soft_deletion) - [PostgreSQL 时间戳索引优化](https://www.postgresql.org/docs/current/indexes-partial.html) - [APScheduler 文档](https://apscheduler.readthedocs.io/) --- **RFC 状态**: 待审核 **下一步**: 等待团队评审和批准