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

RFC 131: 项目回收站系统

状态: 待实施
创建时间: 2026-01-21
作者: System
相关文档: 项目管理服务


概述

实现项目回收站机制,支持三层删除流程:用户删除 → 回收站(30天) → 软删除(用户不可见) → 物理删除(仅管理员)。


背景

当前问题

  1. 误删风险:用户删除项目后无法恢复,误操作成本高
  2. 数据安全:删除的项目立即从数据库清除,无法追溯
  3. 用户体验:缺少"后悔药"机制,不符合现代应用标准

业务需求

  • 用户删除项目后进入回收站,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)      │
└──────────────┘

状态转换规则

  1. active/archivedtrashed: 用户删除
  2. trashedactive: 用户恢复
  3. trashedsoft_deleted: 用户永久删除 或 30天自动
  4. 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 个工作日


后续优化

  1. 批量操作:支持批量恢复、批量永久删除
  2. 回收站容量限制:限制回收站最大项目数(如 100 个)
  3. 自定义保留期:允许用户设置回收站保留天数(7/15/30天)
  4. 回收站搜索:支持在回收站中搜索项目
  5. 删除原因记录:记录项目被删除的原因(用户主动/自动清理)

参考资料


RFC 状态: 待审核
下一步: 等待团队评审和批准