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.
 

26 KiB

后端架构规范指南

版本: 1.0
更新时间: 2026-02-04
适用范围: FastAPI + SQLModel + PostgreSQL


📋 目录


架构概览

分层结构

┌─────────────────────────────────────┐
│         API Layer (FastAPI)         │  ← 接口层:请求验证、响应格式化
├─────────────────────────────────────┤
│       Service Layer (Business)      │  ← 业务层:事务管理、业务逻辑
├─────────────────────────────────────┤
│    Repository Layer (Data Access)   │  ← 数据层:数据访问、查询构建
├─────────────────────────────────────┤
│      Model Layer (SQLModel)         │  ← 模型层:数据模型定义
├─────────────────────────────────────┤
│     Database (PostgreSQL 17)        │  ← 数据库
└─────────────────────────────────────┘

核心原则

  1. 单一职责原则 (SRP) - 每层只负责自己的职责
  2. 依赖倒置原则 (DIP) - 高层不依赖低层,都依赖抽象
  3. 开闭原则 (OCP) - 对扩展开放,对修改关闭
  4. 事务一致性 - Service 层统一管理事务
  5. 测试友好 - 每层都可以独立测试

分层架构

1. Model 层

职责: 定义数据模型

位置: server/app/models/

示例:

from sqlmodel import SQLModel, Field
from uuid import UUID
from datetime import datetime
from typing import Optional

class Project(SQLModel, table=True):
    """项目模型"""
    __tablename__ = "projects"
    
    id: UUID = Field(default_factory=uuid7, primary_key=True)
    name: str = Field(max_length=255, index=True)
    description: Optional[str] = None
    owner_id: UUID = Field(foreign_key="users.id", index=True)
    status: int = Field(default=0)  # 使用 SMALLINT 存储枚举
    created_at: datetime = Field(default_factory=lambda: datetime.now(timezone.utc))
    updated_at: datetime = Field(default_factory=lambda: datetime.now(timezone.utc))

规范:

  • 使用 SQLModel 定义表结构
  • 主键使用 UUID v7
  • 枚举使用 SMALLINT 存储
  • 时间戳使用 datetime.timezone.utc
  • 不包含业务逻辑方法

2. Repository 层

职责: 数据访问逻辑

位置: server/app/repositories/

完整示例:

from typing import Optional, List
from uuid import UUID
from sqlmodel import select, and_
from sqlmodel.ext.asyncio.session import AsyncSession

class ProjectRepository:
    """项目数据访问层
    
    职责:
    - 数据访问逻辑(CRUD)
    - 查询构建
    - 数据转换
    
    不负责:
    - 事务管理(不调用 commit)
    - 业务逻辑判断
    - 调用其他 Repository
    """
    
    def __init__(self, session: AsyncSession):
        self.session = session
    
    # ========================================================================
    # 基础 CRUD
    # ========================================================================
    
    async def create(self, project: Project) -> Project:
        """创建项目
        
        注意:
        - 使用 flush() 而非 commit()
        - 不调用 refresh()
        - 返回对象供调用方使用
        """
        self.session.add(project)
        await self.session.flush()  # ✅ 仅 flush,不 commit
        return project
    
    async def get_by_id(self, project_id: str) -> Optional[Project]:
        """通过 ID 查询项目"""
        statement = select(Project).where(
            Project.id == UUID(project_id),
            Project.status != ProjectStatus.SOFT_DELETED
        )
        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():
            if hasattr(project, key):
                setattr(project, key, value)
        
        project.updated_at = datetime.now(timezone.utc)
        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
        
        project.status = ProjectStatus.SOFT_DELETED
        project.deleted_at = datetime.now(timezone.utc)
        self.session.add(project)
        await self.session.flush()  # ✅ 仅 flush
        return True
    
    # ========================================================================
    # 查询方法
    # ========================================================================
    
    async def get_by_user(
        self,
        user_id: str,
        filters: Optional[dict] = None,
        page: int = 1,
        page_size: int = 20
    ) -> List[Project]:
        """获取用户项目列表(支持筛选、分页)"""
        conditions = [
            Project.owner_id == UUID(user_id),
            Project.status != ProjectStatus.SOFT_DELETED
        ]
        
        # 应用筛选条件
        if filters:
            if filters.get('status'):
                conditions.append(Project.status == filters['status'])
            if filters.get('search'):
                search_pattern = f"%{filters['search']}%"
                conditions.append(Project.name.ilike(search_pattern))
        
        # 构建查询
        statement = select(Project).where(and_(*conditions))
        statement = statement.offset((page - 1) * page_size).limit(page_size)
        statement = statement.order_by(Project.updated_at.desc())
        
        result = await self.session.exec(statement)
        return list(result.all())
    
    async def exists_by_name(
        self,
        name: str,
        owner_id: str,
        exclude_id: Optional[str] = None
    ) -> bool:
        """检查项目名称是否存在"""
        conditions = [
            Project.name == name,
            Project.owner_id == UUID(owner_id),
            Project.status != ProjectStatus.SOFT_DELETED
        ]
        
        if exclude_id:
            conditions.append(Project.id != UUID(exclude_id))
        
        statement = select(func.count(Project.id)).where(and_(*conditions))
        result = await self.session.exec(statement)
        count = result.one()
        return count > 0

关键规范:

应该做:

  • 使用 flush() 确保数据在当前事务内可见
  • 返回对象供调用方使用
  • 构建复杂查询
  • 数据转换

不应该做:

  • 调用 commit() - 事务管理是 Service 层的职责
  • 调用 refresh() - 让调用方决定是否需要
  • 业务逻辑判断 - 应该在 Service 层
  • 调用其他 Repository - 应该在 Service 层编排

3. Service 层

职责: 业务逻辑和事务管理

位置: server/app/services/

完整示例:

from typing import Optional
from sqlmodel.ext.asyncio.session import AsyncSession
from sqlalchemy.exc import IntegrityError

class ProjectService:
    """项目业务逻辑层
    
    职责:
    - 事务管理(commit/rollback)
    - 业务逻辑编排
    - 调用多个 Repository
    - 异常处理和转换
    
    不负责:
    - 数据访问细节
    - HTTP 请求处理
    """
    
    def __init__(
        self,
        session: AsyncSession,
        project_repo: ProjectRepository,
        folder_repo: FolderRepository,
        log_repo: LogRepository
    ):
        self.session = session
        self.project_repo = project_repo
        self.folder_repo = folder_repo
        self.log_repo = log_repo
    
    async def create_project(
        self,
        data: ProjectCreate,
        user_id: str
    ) -> Project:
        """创建项目
        
        业务流程:
        1. 验证项目名称唯一性
        2. 验证文件夹存在(如果指定)
        3. 创建项目
        4. 记录操作日志
        5. 提交事务
        """
        try:
            # 1. 业务验证
            if await self.project_repo.exists_by_name(
                data.name,
                user_id
            ):
                raise DuplicateProjectNameError(data.name)
            
            # 2. 验证文件夹
            if data.folder_id:
                folder = await self.folder_repo.get_by_id(data.folder_id)
                if not folder:
                    raise FolderNotFoundError(data.folder_id)
                
                # 检查文件夹权限
                if folder.owner_id != UUID(user_id):
                    raise PermissionDeniedError()
            
            # 3. 创建项目
            project = Project(
                **data.dict(),
                owner_id=UUID(user_id)
            )
            created = await self.project_repo.create(project)
            
            # 4. 记录日志
            await self.log_repo.create_log(
                action="project.created",
                user_id=user_id,
                resource_id=str(created.id)
            )
            
            # 5. ✅ 提交事务
            await self.session.commit()
            
            return created
            
        except (DuplicateProjectNameError, FolderNotFoundError, PermissionDeniedError):
            # 业务异常:回滚并重新抛出
            await self.session.rollback()
            raise
        except IntegrityError as e:
            # 数据库约束错误:转换为业务异常
            await self.session.rollback()
            raise DatabaseConstraintError() from e
        except Exception as e:
            # 未知错误:回滚并重新抛出
            await self.session.rollback()
            logger.error(f"创建项目失败: {e}")
            raise
    
    async def update_project(
        self,
        project_id: str,
        data: ProjectUpdate,
        user_id: str
    ) -> Project:
        """更新项目"""
        try:
            # 1. 权限检查
            if not await self.check_permission(user_id, project_id, 'editor'):
                raise PermissionDeniedError()
            
            # 2. 业务验证
            if data.name:
                if await self.project_repo.exists_by_name(
                    data.name,
                    user_id,
                    exclude_id=project_id
                ):
                    raise DuplicateProjectNameError(data.name)
            
            # 3. 更新数据
            project = await self.project_repo.update(
                project_id,
                data.dict(exclude_unset=True)
            )
            if not project:
                raise ProjectNotFoundError(project_id)
            
            # 4. 记录日志
            await self.log_repo.create_log(
                action="project.updated",
                user_id=user_id,
                resource_id=project_id
            )
            
            # 5. ✅ 提交事务
            await self.session.commit()
            
            return project
            
        except Exception as e:
            await self.session.rollback()
            raise
    
    async def move_to_folder(
        self,
        project_id: str,
        folder_id: Optional[str],
        user_id: str
    ) -> Project:
        """移动项目到文件夹
        
        演示:跨 Repository 调用
        """
        try:
            # 1. 权限检查
            if not await self.check_permission(user_id, project_id, 'editor'):
                raise PermissionDeniedError()
            
            # 2. 验证目标文件夹
            if folder_id:
                folder = await self.folder_repo.get_by_id(folder_id)
                if not folder:
                    raise FolderNotFoundError(folder_id)
                if folder.owner_id != UUID(user_id):
                    raise PermissionDeniedError()
            
            # 3. 移动项目
            project = await self.project_repo.move_to_folder(
                project_id,
                folder_id
            )
            if not project:
                raise ProjectNotFoundError(project_id)
            
            # 4. 记录日志
            await self.log_repo.create_log(
                action="project.moved",
                user_id=user_id,
                resource_id=project_id,
                metadata={"folder_id": folder_id}
            )
            
            # 5. ✅ 所有操作在一个事务中提交
            await self.session.commit()
            
            return project
            
        except Exception as e:
            await self.session.rollback()
            raise
    
    async def check_permission(
        self,
        user_id: str,
        project_id: str,
        required_role: str = 'viewer'
    ) -> bool:
        """检查用户权限(业务逻辑)"""
        project = await self.project_repo.get_by_id(project_id)
        if not project:
            return False
        
        # 所有者拥有所有权限
        if str(project.owner_id) == user_id:
            return True
        
        # 检查成员权限
        # ...
        
        return False

关键规范:

应该做:

  • 所有方法都有 try-except
  • 成功时调用 await self.session.commit()
  • 异常时调用 await self.session.rollback()
  • 编排多个 Repository 调用
  • 业务逻辑验证
  • 异常转换(数据库异常 → 业务异常)

不应该做:

  • 直接构建 SQL 查询
  • 处理 HTTP 请求/响应
  • 直接访问数据库

4. API 层

职责: HTTP 接口处理

位置: server/app/api/v1/

完整示例:

from fastapi import APIRouter, Depends, HTTPException, status
from typing import List

router = APIRouter(prefix="/projects", tags=["projects"])

# ========================================================================
# 依赖注入
# ========================================================================

def get_project_service(
    session: AsyncSession = Depends(get_session)
) -> ProjectService:
    """获取 ProjectService 实例"""
    project_repo = ProjectRepository(session)
    folder_repo = FolderRepository(session)
    log_repo = LogRepository(session)
    return ProjectService(session, project_repo, folder_repo, log_repo)

# ========================================================================
# API 端点
# ========================================================================

@router.post(
    "",
    response_model=ProjectResponse,
    status_code=status.HTTP_201_CREATED,
    summary="创建项目",
    description="创建一个新项目"
)
async def create_project(
    data: ProjectCreate,  # ✅ Pydantic 自动验证
    current_user: User = Depends(get_current_user),
    service: ProjectService = Depends(get_project_service)
):
    """创建项目
    
    - **name**: 项目名称(必填,1-255字符)
    - **description**: 项目描述(可选)
    - **folder_id**: 文件夹ID(可选)
    """
    try:
        project = await service.create_project(data, str(current_user.id))
        return project
    except DuplicateProjectNameError as e:
        raise HTTPException(
            status_code=status.HTTP_400_BAD_REQUEST,
            detail=f"项目名称已存在: {e.name}"
        )
    except FolderNotFoundError as e:
        raise HTTPException(
            status_code=status.HTTP_404_NOT_FOUND,
            detail=f"文件夹不存在: {e.folder_id}"
        )
    except PermissionDeniedError:
        raise HTTPException(
            status_code=status.HTTP_403_FORBIDDEN,
            detail="没有权限访问该文件夹"
        )

@router.get(
    "/{project_id}",
    response_model=ProjectResponse,
    summary="获取项目详情"
)
async def get_project(
    project_id: str,
    current_user: User = Depends(get_current_user),
    service: ProjectService = Depends(get_project_service)
):
    """获取项目详情"""
    # 权限检查
    if not await service.check_permission(
        str(current_user.id),
        project_id,
        'viewer'
    ):
        raise HTTPException(
            status_code=status.HTTP_403_FORBIDDEN,
            detail="没有权限访问该项目"
        )
    
    project = await service.get_project(project_id)
    if not project:
        raise HTTPException(
            status_code=status.HTTP_404_NOT_FOUND,
            detail="项目不存在"
        )
    
    return project

@router.get(
    "",
    response_model=List[ProjectResponse],
    summary="获取项目列表"
)
async def list_projects(
    folder_id: Optional[str] = None,
    search: Optional[str] = None,
    page: int = 1,
    page_size: int = 20,
    current_user: User = Depends(get_current_user),
    service: ProjectService = Depends(get_project_service)
):
    """获取项目列表(支持筛选、搜索、分页)"""
    filters = {}
    if folder_id:
        filters['folder_id'] = folder_id
    if search:
        filters['search'] = search
    
    projects = await service.list_projects(
        str(current_user.id),
        filters,
        page,
        page_size
    )
    
    return projects

@router.put(
    "/{project_id}",
    response_model=ProjectResponse,
    summary="更新项目"
)
async def update_project(
    project_id: str,
    data: ProjectUpdate,
    current_user: User = Depends(get_current_user),
    service: ProjectService = Depends(get_project_service)
):
    """更新项目"""
    try:
        project = await service.update_project(
            project_id,
            data,
            str(current_user.id)
        )
        return project
    except ProjectNotFoundError:
        raise HTTPException(
            status_code=status.HTTP_404_NOT_FOUND,
            detail="项目不存在"
        )
    except PermissionDeniedError:
        raise HTTPException(
            status_code=status.HTTP_403_FORBIDDEN,
            detail="没有权限修改该项目"
        )
    except DuplicateProjectNameError as e:
        raise HTTPException(
            status_code=status.HTTP_400_BAD_REQUEST,
            detail=f"项目名称已存在: {e.name}"
        )

@router.delete(
    "/{project_id}",
    status_code=status.HTTP_204_NO_CONTENT,
    summary="删除项目"
)
async def delete_project(
    project_id: str,
    current_user: User = Depends(get_current_user),
    service: ProjectService = Depends(get_project_service)
):
    """删除项目(软删除)"""
    try:
        await service.delete_project(project_id, str(current_user.id))
    except ProjectNotFoundError:
        raise HTTPException(
            status_code=status.HTTP_404_NOT_FOUND,
            detail="项目不存在"
        )
    except PermissionDeniedError:
        raise HTTPException(
            status_code=status.HTTP_403_FORBIDDEN,
            detail="没有权限删除该项目"
        )

关键规范:

应该做:

  • 使用 Pydantic 模型验证请求
  • 使用依赖注入获取 Service
  • 将业务异常转换为 HTTP 异常
  • 提供清晰的 API 文档
  • 权限检查

不应该做:

  • 直接调用 Repository
  • 包含业务逻辑
  • 直接访问数据库

测试规范

Repository 单元测试

位置: server/tests/unit/repositories/

示例:

import pytest
from uuid import UUID

@pytest.mark.asyncio
class TestProjectRepository:
    """Repository 单元测试"""
    
    async def test_create_project(self, db_session):
        """测试创建项目"""
        repo = ProjectRepository(db_session)
        
        # ✅ 在测试方法内创建数据
        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 会自动回滚
    
    async def test_get_by_id(self, db_session):
        """测试查询项目"""
        repo = ProjectRepository(db_session)
        
        # 创建测试数据
        project = Project(...)
        created = await repo.create(project)
        
        # 查询
        found = await repo.get_by_id(str(created.id))
        
        assert found is not None
        assert str(found.id) == str(created.id)  # ✅ 使用字符串比较

关键规范:

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

Service 单元测试

位置: server/tests/unit/services/

示例:

@pytest.mark.asyncio
class TestProjectService:
    """Service 单元测试"""
    
    async def test_create_project(self, db_session):
        """测试创建项目"""
        # 准备依赖
        project_repo = ProjectRepository(db_session)
        folder_repo = FolderRepository(db_session)
        log_repo = LogRepository(db_session)
        service = ProjectService(db_session, project_repo, folder_repo, log_repo)
        
        # 测试数据
        data = ProjectCreate(
            name="测试项目",
            type="mine",
            owner_id="00000000-0000-0000-0000-000000000001"
        )
        
        # ✅ Service 会调用 commit()
        project = await service.create_project(data, data.owner_id)
        
        assert project.id is not None
        assert project.name == "测试项目"
        
        # ✅ conftest 会在测试结束后回滚
    
    async def test_create_project_duplicate_name(self, db_session):
        """测试创建重复名称的项目"""
        service = ...
        
        # 创建第一个项目
        await service.create_project(data, user_id)
        
        # 尝试创建重复名称的项目
        with pytest.raises(DuplicateProjectNameError):
            await service.create_project(data, user_id)

API 集成测试

位置: server/tests/integration/api/

示例:

@pytest.mark.asyncio
class TestProjectAPI:
    """API 集成测试"""
    
    async def test_create_project(self, async_client, auth_headers):
        """测试创建项目 API"""
        data = {
            "name": "测试项目",
            "type": "mine",
            "description": "项目描述"
        }
        
        response = await async_client.post(
            "/api/v1/projects",
            json=data,
            headers=auth_headers
        )
        
        assert response.status_code == 201
        result = response.json()
        assert result["name"] == "测试项目"
        assert "id" in result

最佳实践

1. 事务管理

正确:

# Service 层统一管理事务
async def create_project(self, data):
    try:
        project = await self.repo.create(Project(**data))
        await self.log_repo.create_log(...)
        await self.session.commit()  # ✅ 统一提交
        return project
    except Exception:
        await self.session.rollback()  # ✅ 统一回滚
        raise

错误:

# Repository 层不应该管理事务
async def create(self, project):
    self.session.add(project)
    await self.session.commit()  # ❌ 错误!
    return project

2. 异常处理

正确:

# Service 层转换异常
try:
    project = await self.repo.create(project)
    await self.session.commit()
except IntegrityError as e:
    await self.session.rollback()
    raise DuplicateProjectNameError() from e  # ✅ 转换为业务异常

3. 依赖注入

正确:

# API 层使用依赖注入
@router.post("/projects")
async def create_project(
    data: ProjectCreate,
    service: ProjectService = Depends(get_project_service)  # ✅
):
    return await service.create_project(data)

错误:

# 不要在 API 层直接创建 Service
@router.post("/projects")
async def create_project(data: ProjectCreate):
    service = ProjectService(...)  # ❌ 错误!
    return await service.create_project(data)

4. 查询优化

正确:

# Repository 层构建高效查询
async def get_by_user(self, user_id, filters):
    conditions = [Project.owner_id == UUID(user_id)]
    
    # 使用索引字段筛选
    if filters.get('status'):
        conditions.append(Project.status == filters['status'])
    
    # 使用 ILIKE 进行模糊搜索
    if filters.get('search'):
        conditions.append(Project.name.ilike(f"%{filters['search']}%"))
    
    statement = select(Project).where(and_(*conditions))
    statement = statement.order_by(Project.updated_at.desc())
    
    result = await self.session.exec(statement)
    return list(result.all())

参考文档


变更历史

  • 2026-02-04: 初始版本,基于 ADR 010 和实际项目经验总结