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.
 

19 KiB

ADR 010: Repository 与 Service 层事务管理职责划分

状态: 已采纳
日期: 2026-02-04
决策者: 开发团队
相关问题: Project Repository 测试失败、事务管理混乱

背景

在实施 Project Repository 单元测试时,发现了严重的架构设计问题:

问题 1: Repository 层错误地管理事务

# ❌ 错误:Repository 调用 commit()
class ProjectRepository:
    async def create(self, project: Project) -> Project:
        self.session.add(project)
        await self.session.commit()  # 违反单一职责原则
        await self.session.refresh(project)
        return project

问题

  • 违反单一职责原则(SRP)
  • Repository 应该只负责数据访问,不应该管理事务
  • 导致测试无法通过(与 conftest 的事务回滚机制冲突)
  • 无法在一个事务中调用多个 Repository 方法

问题 2: 测试事务冲突

# ❌ 错误:fixture 中创建数据导致事务冲突
@pytest.fixture
async def sample_project(db_session: AsyncSession):
    project = Project(...)
    db_session.add(project)
    await db_session.commit()  # 与 conftest 的事务管理冲突
    return project

错误信息

InterfaceError: cannot use Connection.transaction() in a manually started transaction
RuntimeError: Task got Future attached to a different loop

问题 3: 职责不清晰

  • Repository、Service、API 层的事务管理职责不明确
  • 不同开发人员采用不同的模式
  • 代码维护困难,容易出错

决策

制定明确的 Repository 和 Service 层职责划分规范,所有代码必须遵守。

架构原则

1. Repository 层职责

定位: 数据访问层(Data Access Layer)

1.1 应该做什么

  1. 数据访问逻辑

    async def get_by_id(self, project_id: str) -> Optional[Project]:
        statement = select(Project).where(Project.id == UUID(project_id))
        result = await self.session.exec(statement)
        return result.first()
    
  2. 查询构建

    async def get_by_user(
        self,
        user_id: str,
        filters: dict,
        page: int,
        page_size: int
    ) -> List[Project]:
        conditions = [Project.owner_id == UUID(user_id)]
        # 构建复杂查询...
        statement = select(Project).where(and_(*conditions))
        result = await self.session.exec(statement)
        return list(result.all())
    
  3. 数据转换

    async def create(self, project: Project) -> Project:
        self.session.add(project)
        await self.session.flush()  # ✅ 使用 flush,不是 commit
        return project
    
  4. 使用 flush() 确保数据可见性

    async def update(self, project_id: str, data: dict) -> Optional[Project]:
        project = await self.get_by_id(project_id)
        if not project:
            return None
    
        for key, value in data.items():
            setattr(project, key, value)
    
        self.session.add(project)
        await self.session.flush()  # ✅ flush 使数据在当前事务内可见
        return project
    

1.2 不应该做什么

  1. 调用 commit() 管理事务

    # ❌ 错误
    await self.session.commit()
    
  2. 调用 refresh() 刷新对象

    # ❌ 错误
    await self.session.refresh(project)
    

    原因: refresh() 会触发数据库查询,应该由调用方决定是否需要

  3. 业务逻辑判断

    # ❌ 错误:业务逻辑应该在 Service 层
    if project.status == ProjectStatus.ARCHIVED:
        raise BusinessException("不能修改已归档的项目")
    
  4. 调用其他 Repository

    # ❌ 错误:跨 Repository 调用应该在 Service 层
    folder = await self.folder_repo.get_by_id(folder_id)
    

1.3 标准模板

class ProjectRepository:
    """项目数据访问层"""
    
    def __init__(self, session: AsyncSession):
        self.session = session
    
    async def create(self, project: Project) -> Project:
        """创建项目"""
        self.session.add(project)
        await self.session.flush()  # ✅ 仅 flush
        return project
    
    async def get_by_id(self, project_id: str) -> Optional[Project]:
        """查询项目"""
        statement = select(Project).where(Project.id == UUID(project_id))
        result = await self.session.exec(statement)
        return result.first()
    
    async def update(self, project_id: str, data: dict) -> Optional[Project]:
        """更新项目"""
        project = await self.get_by_id(project_id)
        if not project:
            return None
        
        for key, value in data.items():
            setattr(project, key, value)
        
        self.session.add(project)
        await self.session.flush()  # ✅ 仅 flush
        return project
    
    async def delete(self, project_id: str) -> bool:
        """删除项目"""
        project = await self.get_by_id(project_id)
        if not project:
            return False
        
        await self.session.delete(project)
        await self.session.flush()  # ✅ 仅 flush
        return True

2. Service 层职责

定位: 业务逻辑层(Business Logic Layer)

2.1 应该做什么

  1. 事务管理

    class ProjectService:
        async def create_project(self, data: ProjectCreate) -> Project:
            try:
                # 业务逻辑
                project = Project(**data.dict())
                created = await self.repo.create(project)
    
                # ✅ Service 层负责提交事务
                await self.session.commit()
    
                return created
            except Exception as e:
                # ✅ Service 层负责回滚
                await self.session.rollback()
                raise
    
  2. 业务逻辑编排

    async def move_project_to_folder(
        self,
        project_id: str,
        folder_id: str,
        user_id: str
    ) -> Project:
        try:
            # 1. 权限检查
            if not await self.check_permission(user_id, project_id):
                raise PermissionDenied()
    
            # 2. 验证文件夹存在
            folder = await self.folder_repo.get_by_id(folder_id)
            if not folder:
                raise FolderNotFound()
    
            # 3. 移动项目
            project = await self.repo.move_to_folder(project_id, folder_id)
    
            # 4. 记录操作日志
            await self.log_repo.create_log(...)
    
            # ✅ 统一提交事务
            await self.session.commit()
    
            return project
        except Exception as e:
            await self.session.rollback()
            raise
    
  3. 调用多个 Repository

    async def clone_project_with_resources(
        self,
        source_id: str,
        new_name: str
    ) -> Project:
        try:
            # 调用多个 Repository
            source = await self.project_repo.get_by_id(source_id)
            new_project = await self.project_repo.clone(source, new_name)
    
            resources = await self.resource_repo.get_by_project(source_id)
            for resource in resources:
                await self.resource_repo.clone(resource, new_project.id)
    
            # ✅ 所有操作在一个事务中
            await self.session.commit()
    
            return new_project
        except Exception as e:
            await self.session.rollback()
            raise
    
  4. 异常处理和转换

    async def update_project(
        self,
        project_id: str,
        data: ProjectUpdate
    ) -> Project:
        try:
            project = await self.repo.update(project_id, data.dict())
            if not project:
                raise ProjectNotFound(project_id)
    
            await self.session.commit()
            return project
        except IntegrityError as e:
            await self.session.rollback()
            raise DuplicateProjectName() from e
        except Exception as e:
            await self.session.rollback()
            raise
    

2.2 标准模板

class ProjectService:
    """项目业务逻辑层"""
    
    def __init__(
        self,
        session: AsyncSession,
        project_repo: ProjectRepository,
        folder_repo: FolderRepository
    ):
        self.session = session
        self.project_repo = project_repo
        self.folder_repo = folder_repo
    
    async def create_project(self, data: ProjectCreate) -> Project:
        """创建项目"""
        try:
            # 1. 业务验证
            if await self.project_repo.exists_by_name(
                data.name,
                data.folder_id,
                data.owner_id
            ):
                raise DuplicateProjectName()
            
            # 2. 创建项目
            project = Project(**data.dict())
            created = await self.project_repo.create(project)
            
            # 3. ✅ 提交事务
            await self.session.commit()
            
            return created
        except Exception as e:
            # 4. ✅ 回滚事务
            await self.session.rollback()
            raise
    
    async def update_project(
        self,
        project_id: str,
        data: ProjectUpdate
    ) -> Project:
        """更新项目"""
        try:
            # 1. 权限检查
            # 2. 业务验证
            # 3. 更新数据
            project = await self.project_repo.update(project_id, data.dict())
            if not project:
                raise ProjectNotFound()
            
            # 4. ✅ 提交事务
            await self.session.commit()
            
            return project
        except Exception as e:
            await self.session.rollback()
            raise
    
    async def delete_project(self, project_id: str) -> bool:
        """删除项目"""
        try:
            # 1. 权限检查
            # 2. 业务验证(是否有关联资源等)
            # 3. 删除数据
            success = await self.project_repo.delete(project_id)
            
            # 4. ✅ 提交事务
            await self.session.commit()
            
            return success
        except Exception as e:
            await self.session.rollback()
            raise

3. API 层职责

定位: 接口层(Presentation Layer)

3.1 应该做什么

  1. 请求验证

    @router.post("/projects", response_model=ProjectResponse)
    async def create_project(
        data: ProjectCreate,  # ✅ Pydantic 自动验证
        current_user: User = Depends(get_current_user),
        service: ProjectService = Depends(get_project_service)
    ):
        # 调用 Service 层
        project = await service.create_project(data)
        return project
    
  2. 依赖注入

    def get_project_service(
        session: AsyncSession = Depends(get_session)
    ) -> ProjectService:
        project_repo = ProjectRepository(session)
        folder_repo = FolderRepository(session)
        return ProjectService(session, project_repo, folder_repo)
    
  3. 响应格式化

    @router.get("/projects/{project_id}", response_model=ProjectResponse)
    async def get_project(
        project_id: str,
        service: ProjectService = Depends(get_project_service)
    ):
        project = await service.get_project(project_id)
        if not project:
            raise HTTPException(status_code=404, detail="项目不存在")
        return project
    

3.2 不应该做什么

  1. 直接调用 Repository

    # ❌ 错误:API 层不应该直接调用 Repository
    @router.post("/projects")
    async def create_project(
        data: ProjectCreate,
        repo: ProjectRepository = Depends(get_repo)
    ):
        project = await repo.create(Project(**data.dict()))
        await repo.session.commit()  # 错误!
        return project
    
  2. 业务逻辑

    # ❌ 错误:业务逻辑应该在 Service 层
    @router.post("/projects")
    async def create_project(data: ProjectCreate, ...):
        if await repo.exists_by_name(data.name):
            raise HTTPException(400, "项目名称已存在")
        # ...
    

4. 测试最佳实践

4.1 Repository 单元测试

@pytest.mark.asyncio
class TestProjectRepository:
    """Repository 单元测试"""
    
    async def test_create_project(self, db_session):
        """测试创建项目"""
        repo = ProjectRepository(db_session)
        
        # ✅ 在测试方法内创建数据,不使用 fixture
        project = Project(
            name="测试项目",
            type=ProjectType.MINE,
            owner_type="user",
            owner_id=UUID("00000000-0000-0000-0000-000000000001"),
            status=ProjectStatus.ACTIVE
        )
        
        created = await repo.create(project)
        
        assert created.id is not None
        assert created.name == "测试项目"
        
        # ✅ 不需要手动 commit,conftest 会自动回滚

关键点

  • 使用 db_session fixture(由 conftest 管理事务)
  • 在测试方法内直接创建数据
  • 依赖 conftest 的自动回滚机制
  • 不在 fixture 中创建需要 flush/commit 的数据
  • 不在测试中手动 commit

4.2 Service 单元测试

@pytest.mark.asyncio
class TestProjectService:
    """Service 单元测试"""
    
    async def test_create_project(self, db_session):
        """测试创建项目"""
        repo = ProjectRepository(db_session)
        service = ProjectService(db_session, repo)
        
        data = ProjectCreate(
            name="测试项目",
            type="mine",
            owner_id="00000000-0000-0000-0000-000000000001"
        )
        
        # ✅ Service 会调用 commit()
        project = await service.create_project(data)
        
        assert project.id is not None
        
        # ✅ conftest 会在测试结束后回滚

4.3 错误的测试模式

# ❌ 错误:在 fixture 中创建数据
@pytest.fixture
async def sample_project(db_session):
    project = Project(...)
    db_session.add(project)
    await db_session.commit()  # 与 conftest 冲突!
    return project

# ❌ 错误:在测试中手动 commit
async def test_create(db_session):
    repo = ProjectRepository(db_session)
    project = await repo.create(Project(...))
    await db_session.commit()  # 不需要!

后果

优点

  1. 职责清晰: Repository、Service、API 各司其职
  2. 易于测试: Repository 测试不需要管理事务
  3. 事务一致性: Service 层统一管理事务,避免部分提交
  4. 代码复用: Repository 方法可以在一个事务中多次调用
  5. 易于维护: 架构清晰,新人容易理解

缺点

  1. ⚠️ 代码量增加: 需要明确的 Service 层
  2. ⚠️ 学习成本: 开发人员需要理解分层架构

风险

  1. 开发人员可能忘记在 Service 层调用 commit()
  2. 可能在 Repository 层添加业务逻辑
  3. 需要在 Code Review 中严格检查

实施计划

阶段 1: 规范制定(已完成)

  • 创建 ADR 文档
  • 定义 Repository 层职责
  • 定义 Service 层职责
  • 定义测试最佳实践

阶段 2: 现有代码修复(进行中)

  • 修复 ProjectRepository(已完成)
  • 修复 ProjectRepository 测试(14/14 通过)
  • 检查 FolderRepository
  • 检查其他 Repository
  • 更新 Service 层添加事务管理

阶段 3: 工具和流程

  • 创建 Repository 模板
  • 创建 Service 模板
  • 添加 pre-commit hook 检查
  • 更新 CI/CD 流程

阶段 4: 团队培训

  • 团队分享会
  • 更新开发文档
  • Code Review Checklist

迁移指南

修复现有 Repository

  1. 查找所有 commit() 调用

    grep -r "await.*session.commit()" server/app/repositories/
    
  2. 替换为 flush()

    # 修改前
    await self.session.commit()
    await self.session.refresh(obj)
    
    # 修改后
    await self.session.flush()
    
  3. 移除 refresh() 调用

    # 修改前
    await self.session.refresh(project)
    return project
    
    # 修改后
    return project  # 调用方决定是否需要 refresh
    

更新 Service 层

  1. 添加事务管理

    async def create_project(self, data: ProjectCreate) -> Project:
        try:
            project = await self.repo.create(Project(**data.dict()))
            await self.session.commit()  # ✅ 添加
            return project
        except Exception as e:
            await self.session.rollback()  # ✅ 添加
            raise
    
  2. 统一异常处理

    try:
        # 业务逻辑
        await self.session.commit()
    except IntegrityError as e:
        await self.session.rollback()
        raise DuplicateError() from e
    except Exception as e:
        await self.session.rollback()
        raise
    

修复测试

  1. 移除 fixture 中的 commit

    # 修改前
    @pytest.fixture
    async def sample_project(db_session):
        project = Project(...)
        db_session.add(project)
        await db_session.commit()  # ❌ 移除
        return project
    
    # 修改后:在测试方法内创建
    async def test_create(self, db_session):
        repo = ProjectRepository(db_session)
        project = Project(...)
        created = await repo.create(project)
        # 不需要 commit
    
  2. 使用字符串比较 UUID

    # 避免对象比较问题
    assert str(found.id) == str(created.id)
    

Code Review Checklist

Repository 层

  • 没有调用 commit()
  • 没有调用 refresh()
  • 使用 flush() 确保数据可见性
  • 没有业务逻辑判断
  • 没有调用其他 Repository

Service 层

  • 所有方法都有 try-except
  • 成功时调用 commit()
  • 异常时调用 rollback()
  • 业务逻辑清晰
  • 异常处理完整

测试

  • 使用 db_session fixture
  • 在测试方法内创建数据
  • 不手动 commit
  • 不在 fixture 中创建需要 flush 的数据

参考资料

相关 ADR

  • ADR 007: 数据库迁移最佳实践

相关文档

  • Changelog: docs/server/changelogs/2026-02-04-project-repository-transaction-management-fix.md
  • 测试指南: server/tests/README.md

变更历史

  • 2026-02-04: 初始版本,基于 Project Repository 测试修复总结