P13第 1 / 1 课时未开始内容版本 1.0.0 · 核验于 2026-10-26

分页、检索与错误结构

分页、检索与错误结构 · 阅读后完成下方实验与检查题,再用「标记完成」记录进度。

学习目标

这一课把「返回一堆数据」和「报一堆错」变成有设计的行为:稳定分页、可控规模、统一错误结构。目标完成后,前端可以只写一套列表渲染和一套错误处理就吃下你所有接口。

问题场景

课程库涨到 180 门,GET /courses 一次性全量返回,前端渲染开始卡;更糟的是某个调用方传了 page=0、limit=99999,本机服务直接被拖死。而各接口报错五花八门:有的返回 {"msg": ...},有的抛 500——前端没法统一提示。这课用一套约定同时解决两件事。

核心解释

分页的稳定三件套:page(从 1 起)+ limit(带上限)+ total/items 外壳。

from fastapi import FastAPI, Query, Request
from fastapi.responses import JSONResponse

app = FastAPI()

@app.exception_handler(AppError)
async def app_error_handler(request: Request, exc: AppError):
    return JSONResponse(
        status_code=exc.status,
        content={"error": {"category": exc.category, "message": exc.message}},
    )

class AppError(Exception):
    def __init__(self, status: int, category: str, message: str):
        self.status, self.category, self.message = status, category, message

@app.get("/courses")
def list_courses(
    page: int = Query(default=1, ge=1),                 # 页码从 1 起
    limit: int = Query(default=20, ge=1, le=100),       # le=100 是规模上限
    keyword: str | None = Query(default=None, max_length=50),
):
    rows = COURSES                                      # 实际来自数据库
    if keyword:
        rows = [c for c in rows if keyword.lower() in c["title"].lower()]
    start = (page - 1) * limit
    items = rows[start:start + limit]
    return {"total": len(rows), "page": page, "limit": limit, "items": items}

设计决策逐条钉住:

  1. le=100 限制规模。没有上限的分页参数等于邀请任何人全量拉库;le 让超限请求直接 422,本站 /api/run 对输出截断、P06 对脚本限时,是同一个「限制请求规模」的思想。
  2. total + items 外壳全站一致。P07 埋的这个伏笔在此定型:前端分页组件拿到 total 就能算总页数,翻页、筛选、搜索共用一个外壳。
  3. 错误结构对齐本站契约。{error: {category, message}},category 取稳定枚举(invalid_param / not_found / conflict / internal),message 给人读。前端拿到 category 能分支处理,拿到 message 能直接展示。Pydantic 校验出的 422 会在 exception_handler 里被转换成同一结构。
  4. 越界分页不报错。page=99 超出范围返回空 items 加真实 total,而不是 404——「查无此页」是正常业务状态,不是资源不存在。

示例拆解

四种请求覆盖行为矩阵:

curl "http://127.0.0.1:8100/courses?page=1&limit=2"
# {"total":180,"page":1,"limit":2,"items":[...2 条...]}   外壳完整

curl "http://127.0.0.1:8100/courses?page=1&limit=500"
# 422,limit ≤ 100 被拦

curl "http://127.0.0.1:8100/courses?page=1&limit=2&keyword=fastapi"
# total 变小,items 只含命中标题

curl "http://127.0.0.1:8100/courses?page=999"
# {"total":180,"page":999,"items":[]}   空页不是错误

预期:外壳字段在每种情况下都齐全,前端逻辑不需要为任何一种情况写特例。搜索命中数变化只体现在 total 和 items 里,翻页状态由 page/limit 原样回显——这三个字段凑成自描述的分页协议。

动手任务

  1. 给 /courses 加 track 筛选参数(限定枚举值),验证非法值返回 422 且错误结构统一。
  2. 写一个 AppError 子类 NotFound,在 /courses/{code} 查无课程时抛出,确认响应体是 {error:{category:"not_found",...}} 而不是 FastAPI 默认格式。
  3. 用本站前端思路写一个翻页请求:读 total 算总页数,验证最后一页 items 长度不足 limit 时渲染正常。

常见错误

  • page 从 0 起:前端按习惯传 0,ge=1 直接 422;翻页起点两边必须白纸黑字一致。
  • limit 无上限:le 漏写,一次请求拉全库,本机服务内存和带宽一起遭殃。
  • 错误格式随接口漂移:有的 {error:...} 有的 {message:...},前端写两套 if;全走 exception_handler 统一出口。
  • 把空结果当错误:筛选无命中返回 404,前端只能报「出错了」;空列表是 200 加空 items。

检查与复盘

自查清单:

  • 分页参数有默认值、下限和上限,且行为在 /docs 里可验证
  • 所有可预期错误都走统一结构,category 是有限枚举
  • 空页、无命中、超限三种边界行为说得清并演示过

参考资料

检查题共 2 题,提交后立即显示解析
答案由服务端判定;答错的题会自动进入复习计划。
Q1读代码较难

阅读代码(FastAPI,Python 3.11+)。库里共 45 条课程记录,请求 GET /courses?page=5(size 默认 20),接口返回什么?这个分页实现对吗?

@app.get('/courses')
def list_courses(
    page: int = Query(default=1, ge=1),
    size: int = Query(default=20, le=100),
):
    offset = page * size          # ← 注意这里
    rows = db.execute(
        'SELECT * FROM courses LIMIT ? OFFSET ?', (size, offset)
    ).fetchall()
    return {'items': rows, 'page': page}
Q2多选中等(多选,全部选对才算正确)

团队约定所有 API 错误统一返回 {'error': {'code': 'COURSE_NOT_FOUND', 'message': '课程不存在'}}。下列实现要点正确的有哪些?(多选,FastAPI + Python 3.11+)

读完这一课了?

标记完成后,工作台会安排接下来的学习。

Command Palette

Search for a command to run...