一、为什么需要查询过滤与分页
之前的 Repository 只有 get_by_id 和 list_all------能查一条、能查全部。但真实业务从来不是"给我所有记录":
- 用户只想看"状态为 success 的记录"
- 搜索框输入"Python",要匹配 JD 原文里含这个词的记录
- 首页只展示"最近 7 天"的分析结果
- 数据库攒了 10000 条,前端一次拉全部会卡死
这四个场景分别对应过滤、搜索、时间范围、分页 。list_all 一个都解决不了。所以得给 Repository 加查询能力------这就是 3.1 要做的事。
二、SQLAlchemy 查询三件套:过滤、排序、分页
核心机制:Query 对象不可变
SQLAlchemy 的 session.query(Model) 返回一个 Query 对象 ,它最大的特点是不可变 ------query.filter(...) 不会修改原对象,而是返回一个新的 Query。
python
query = session.query(JdRecord) # 拿到原始 Query
query.filter(JdRecord.status == "success") # 这行白写!没赋值回去
records = query.all() # 查出来的还是全部记录
必须重新赋值:
python
query = session.query(JdRecord)
query = query.filter(JdRecord.status == "success") # 重新赋值才生效
这个机制的好处是链式安全------你可以在条件分支里逐步叠加过滤条件,不用担心互相污染。
list_with_filter:过滤 + 稳定排序 + 分页
把过滤、排序、分页捏到一个方法里:
python
# app/db/repositories/jd_record_repo.py
def list_with_filter(
self,
session: Session,
status: str | None = None,
keyword: str | None = None,
limit: int = 20,
offset: int = 0,
) -> list[JdRecord]:
"""按状态/关键词筛选,支持分页,默认按创建时间倒序(id 兜底稳定排序)。"""
query = session.query(JdRecord)
if status is not None:
query = query.filter(JdRecord.status == status)
if keyword is not None:
query = query.filter(JdRecord.jd_text.like(f"%{keyword}%"))
return (
query.order_by(
JdRecord.created_at.desc(),
JdRecord.id.desc(), # id 兜底 → 稳定排序
)
.offset(offset)
.limit(limit)
.all()
)
两个关键设计:
-
稳定排序
order_by(created_at.desc(), id.desc()):如果多条记录的created_at完全相同(批量插入时常见),只按created_at排会出现"同一页翻两次看到同一条"或"跳过某条"。加id.desc()兜底,保证排序结果唯一确定,分页才不会乱。 -
页码换算
offset = (page - 1) * size:page 从 1 开始,但 offset 从 0 开始。page=1 → offset=0(第一条开始),page=2 → offset=20(跳过前 20 条)。
count 与 get_recent
python
# app/db/repositories/jd_record_repo.py
def count(self, session: Session, status: str | None = None) -> int:
"""统计符合过滤条件的记录总数(status=None 时统计全部)。"""
query = session.query(JdRecord)
if status is not None:
query = query.filter(JdRecord.status == status)
return query.count()
def get_recent(self, session: Session, days: int = 7) -> list[JdRecord]:
"""按创建时间倒序列出最近 N 天的记录。"""
cutoff = datetime.now() - timedelta(days=days)
return (
session.query(JdRecord)
.filter(JdRecord.created_at >= cutoff)
.order_by(JdRecord.created_at.desc(), JdRecord.id.desc())
.all()
)
count 的过滤逻辑和 list_with_filter 的前半段完全一致------同一个 if status is not None 分支。区别只在最后一步:一个 .all() 返回记录列表,一个 .count() 返回数字。
小结:Query 对象不可变是 SQLAlchemy 查询的核心心智模型。记住"filter 返回新对象,必须赋值回去",能避免 80% 的查询 bug。稳定排序是分页的隐形前提------没有稳定排序,分页就会漏数据或重复数据。
三、从数据库到 API:为什么要分页列表接口
3.1 解决了"数据库怎么查",3.2 解决"怎么把查到的数据通过 API 给前端"。
如果直接 return db.query(JdRecord).all() 给前端,数据库里有 10000 条记录会发生什么?
- 服务器:1 万条记录一把查出来,内存飙、响应耗时炸
- 前端:浏览器渲染 1 万行 DOM,页面卡死
- 用户:面对 1 万条数据,根本找不到想要的那条
分页的本质是把大海捞针变成翻书阅读,一次只看一页。
接口设计
请求:GET /api/v1/jd/records?page=1&size=20&status=success
响应不直接返回数组,而是包一层:
json
{
"items": [
{"id": 1, "job_title": "Python后端", "status": "success", "created_at": "..."},
{"id": 2, "job_title": "AI工程师", "status": "success", "created_at": "..."}
],
"total": 100,
"page": 1,
"size": 20
}
为什么包一层而不是直接 return [...] :total 是给前端算"还有几页"用的。前端拿到 total=100、size=20,就能算出总页数 ceil(100/20)=5,画出分页导航条 [上一页] 1 2 [3] 4 5 [下一页]。如果只返回数组,前端不知道后面还有没有数据,分页控件画不出来。
四、泛型分页包装 + ORM ≠ Schema
PaginatedResponse:写一次管所有数据类型
分页响应的结构(items + total + page + size)是通用的------JD 列表要用,将来用户列表、订单列表也要用。如果每种实体写一个 XxxPaginatedResponse,代码重复到崩溃。
Python 的泛型(Generic)解决这个问题------写一个"模具",往里面填类型:
python
# app/schemas/common.py
from typing import Generic, TypeVar
T = TypeVar("T")
class PaginatedResponse(BaseModel, Generic[T]):
"""通用分页响应包装。T 在使用时替换为具体类型。"""
items: list[T]
total: int
page: int
size: int
T = TypeVar("T")声明一个占位符,"到时候再说是什么类型"Generic[T]告诉 Python "这个类肚子里有个可变类型槽位"items: list[T]的意思:items 装什么取决于 T 填什么
使用时用方括号指定具体类型:PaginatedResponse[JdRecordResponse] → FastAPI 自动理解 items 是 list[JdRecordResponse],生成正确的 API 文档。
JdRecordResponse:只暴露该暴露的字段
python
# app/schemas/common.py
class JdRecordResponse(BaseModel):
id: int
job_title: str | None
status: str
created_at: datetime
model_config = {"from_attributes": True}
ORM 模型 JdRecord 有 9 个字段(含 jd_text、error_message、analysis_result 等内部字段),但 API 只给前端 4 个。这就是 ORM 模型 ≠ Pydantic Schema 的职责边界:
| ORM 模型 (JdRecord) | Pydantic Schema (JdRecordResponse) | |
|---|---|---|
| 职责 | 完整映射数据库表 | 定义 API 输出的形状 |
| 字段策略 | 越全越好,不能丢 | 越少越好,只给需要的 |
| 面向谁 | 后端代码(增删改查) | 前端 / API 消费者 |
直接返回 ORM 对象 = 把后台更衣室全裸推到 T 台上------error_message、将来加的 raw_api_response(可能含敏感信息)全跟着出去。model_config = {"from_attributes": True} 让 Pydantic 能从 ORM 对象上按 Schema 定义的字段逐个取值,不在 Schema 里的自动忽略。
五、路由实现与测试
路由:Query 参数边界 + ORM→Schema 转换
python
# app/api/routes/jd.py
@router.get(
"/records",
response_model=PaginatedResponse[JdRecordResponse],
summary="获取 JD 分析历史记录列表",
)
def list_records(
page: int = Query(1, ge=1, description="页码,从1开始"),
size: int = Query(20, ge=1, le=100, description="每页条数,1-100"),
status: str | None = Query(None, description="按状态过滤: success/failed/pending"),
):
with SessionLocal() as db:
records = repo.list_with_filter(
db, status=status, limit=size, offset=(page - 1) * size
)
total = repo.count(db, status=status)
items = [JdRecordResponse.model_validate(r) for r in records]
return PaginatedResponse[JdRecordResponse](
items=items, total=total, page=page, size=size
)
三个设计要点:
-
Query(1, ge=1)/Query(20, ge=1, le=100):给参数加边界。ge=1(大于等于1)、le=100(小于等于100)。用户传?page=0或?size=999→ FastAPI 自动返回 422,不用手写 if 判断。 -
with SessionLocal() as db:创建 session,用完自动关。这是 3.2 的过渡写法,4.1 会升级为Depends(get_db)依赖注入。 -
[JdRecordResponse.model_validate(r) for r in records]:ORM 对象逐个转 Schema。model_validate配合from_attributes=True,从 ORM 对象上取 Schema 定义的那 4 个字段,其余忽略。
测试:4 场景验证
python
# tests/test_list_records.py
@pytest.fixture(autouse=True)
def clean_jd_records():
"""每个测试前后清空 jd_records 表,避免污染开发库。"""
with SessionLocal() as db:
db.query(JdRecord).delete()
db.commit()
yield
with SessionLocal() as db:
db.query(JdRecord).delete()
db.commit()
# 1. 空数据库 → items=[]、total=0
def test_list_records_empty_returns_empty(client: TestClient):
response = client.get("/api/v1/jd/records")
assert response.status_code == 200
data = response.json()
assert data["items"] == []
assert data["total"] == 0
# 2. 插入 3 条 → 返回 3 条
def test_list_records_with_three_records_returns_all(client: TestClient):
_make_record(status="success")
_make_record(status="success", job_title="AI 工程师")
_make_record(status="failed")
response = client.get("/api/v1/jd/records")
assert data["total"] == 3
assert len(data["items"]) == 3
# 3. 插入 25 条,page=2 size=20 → 返回 5 条(第 21-25)
def test_list_records_pagination_page_2_size_20(client: TestClient):
for i in range(25):
_make_record(job_title=f"JD{i:02d}")
response = client.get("/api/v1/jd/records?page=2&size=20")
assert data["total"] == 25
assert len(data["items"]) == 5
# 4. 非法分页参数 → 422
def test_list_records_invalid_params_returns_422(client: TestClient):
r1 = client.get("/api/v1/jd/records?page=0")
assert r1.status_code == 422
r2 = client.get("/api/v1/jd/records?size=999")
assert r2.status_code == 422
autouse=True 的 fixture 每个测试前后自动清表------这是 3.2 的临时方案,4.1 会升级为内存数据库 + 依赖注入隔离,连开发库都不碰。
这两关把数据从"能查"升级到"查得准、给得对"------3.1 让 Repository 学会过滤/排序/分页,3.2 让这些能力通过 API 有序地交给前端。核心就三件事:Query 对象不可变(filter 要赋值回去)、稳定排序是分页的前提(id 兜底)、ORM 和 Schema 职责分离(别把数据库模型直接丢给前端)。泛型
PaginatedResponse写一次管所有列表接口,是这趟最值的复用。