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
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) │ ← 数据库
└─────────────────────────────────────┘
核心原则
- 单一职责原则 (SRP) - 每层只负责自己的职责
- 依赖倒置原则 (DIP) - 高层不依赖低层,都依赖抽象
- 开闭原则 (OCP) - 对扩展开放,对修改关闭
- 事务一致性 - Service 层统一管理事务
- 测试友好 - 每层都可以独立测试
分层架构
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_sessionfixture - ✅ 在测试方法内创建数据
- ✅ 依赖 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 和实际项目经验总结