码上问学问代码之理,学编程之道

第 3 章 · 请求与响应

路由回答了「谁来接单」,这一章解决「单子上写了什么、该怎么回话」。你要学会从请求里取出前端传来的参数(分页、筛选、表单、JSON 体、token),再用统一格式把结果交回去。学完这章,接口文档里那些 page、keyword、Authorization,你就都能一一对应到代码上了。

请求从哪来:一次 HTTP 交互

报文、方法、头、体
一次 HTTP 请求 = 请求行 + 头 + 体 请求(客户端 → 服务器) 请求行 POST /api/login HTTP/1.1 请求头 Host: 127.0.0.1:5000 Content-Type: application/json 请求体 { "username":"li", "pwd":"***" } 响应(服务器 → 客户端) 状态行 200 OK 响应头 Content-Type 响应体 { "code":200, "msg":"ok", "data":{...} }

每次请求都打包成三段:请求行(方法+路径+协议)、请求头(元信息)、请求体(POST/PUT 才有的数据);服务器回的响应也是同样的三段结构。

先讲为什么前端点了「唐诗」这个筛选按钮,数据不会自己飞过来——浏览器会真的发一段文本给后端,这段文本叫 HTTP 请求报文。你不需要背协议,但必须知道它由四部分组成,因为你写的每一行取参数的代码,都在从这四部分里捞东西:
① 请求方法(我要读还是写)② 路径 + 查询字符串(我要哪份数据)③ 请求头(我是谁、我接受什么格式)④ 请求体(要提交的内容)。
后端的工作,说到底就是:读报文 → 干活 → 写响应。
动手做先看一眼真实报文长什么样。前端提交一条书评,浏览器发出去的原文就是下面这样——上半部分是头,空行后面是体。
请求报文.httphttp
POST /api/comment/add?from=detail HTTP/1.1
Host: 127.0.0.1:5000
Content-Type: application/json
Authorization: Bearer eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9....
User-Agent: Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7)

{"article_id": 1, "content": "好诗!", "parent_id": null}
报文内容HTTP 报文
运行结果: # 服务器终端里 Flask 打印的访问日志(每来一个请求一行) 127.0.0.1 - - [12/Oct/2026 10:20:31] "POST /api/comment/add?from=detail HTTP/1.1" 200 - # 上面这段报文对应的拆解 方法:POST → request.method 路径:/api/comment/add → request.path 查询字符串:from=detail → request.args 请求头:Content-Type / Authorization… → request.headers 请求体:{"article_id": 1, ...} → request.get_json()
动手做写一个「回显」接口,把收到的请求信息原样打印出来。这是你以后调试接口最常用的工具——搞不清前端到底发了什么,先 echo 一下。
echo.pypython
from flask import Flask, request

app = Flask(__name__)


@app.route("/api/debug/echo", methods=["GET", "POST"])
def echo():
    print("方法:", request.method)
    print("路径:", request.path)
    print("查询参数:", request.args.to_dict())
    print("客户端:", request.remote_addr)
    print("浏览器:", request.headers.get("User-Agent"))
    print("内容类型:", request.content_type)
    return "ok"
运行输出Python 运行结果
运行结果: $ curl -s -X POST "http://127.0.0.1:5000/api/debug/echo?from=detail" \ -H "Content-Type: application/json" -d '{"article_id": 1}' # 跑着 Flask 的那个终端同时打印出: 方法: POST 路径: /api/debug/echo 查询参数: {'from': 'detail'} 客户端: 127.0.0.1 浏览器: curl/8.7.1 内容类型: application/json 127.0.0.1 - - [12/Oct/2026 10:20:31] "POST /api/debug/echo?from=detail HTTP/1.1" 200 -
讲透
  • 参数放在哪,是有讲究的:查询字符串(?page=1&page_size=10)适合「筛选、分页、排序」这类不影响数据的条件;请求体适合「要提交的内容」(新增书评、登录账号);请求头适合「身份和格式」(token、Content-Type);路径适合「你要哪个资源」(/api/article/detail/3)。
  • 接口文档就是照这个分的:GET /api/comment/list?article_id=1&page=1 用查询参数;POST /api/comment/add 用请求体;Authorization: Bearer <token> 用请求头。
  • 查询字符串天生是字符串:?page=1 里的 1 传到后端是 "1"。要拿整数,得显式转换(下面一节就讲 Flask 帮你转的写法)。
  • 中文和特殊字符会被编码:keyword=月 在网络里其实是 keyword=%E6%9C%88。这是正常现象,Flask 会自动帮你解码,你在 request.args 里看到的就是原文「月」。

request.args:查字符串里的参数

分页、筛选、搜索全靠它
request.args:从 URL 的 ? 后面取参数 GET /api/article/list?page=1&page_size=2&keyword=唐 ? 之后是查询字符串 page = 1 第几页(分页) page_size = 2 每页几条 keyword = 唐 搜索关键词 代码里用 request.args.get('page', 1, type=int) 取值 —— 取出来是字符串,要指定 type 转类型 分页、筛选、搜索三件事,全都靠它把参数从 URL 带进后端

URL 里 ? 后面的查询字符串会被 Flask 解析进 request.args。分页的 page、每页条数、搜索关键词都从这里取;取出来默认是字符串,记得用 type 转类型。

先讲为什么作品列表页上,用户会点分类、输入关键词、翻到第 3 页——同一个接口,参数不同,返回的数据就得不同。这些参数不能塞进路径(路径要稳定、要和接口文档一致),于是全放在 ? 后面的查询字符串里。
接口文档给 /api/article/list 定了五个参数:page、page_size、category_id、keyword、order_by。你的任务就是每个都取出来,并给没传的参数一个合理默认值——这就是「分页」和「筛选」的全部秘密。
动手做用 request.args.get(名字, 默认值, type=类型) 取参数。第三个参数 type 是关键:它顺手帮你做类型转换,转不了就用默认值,还不用你写 try/except。先把手写数据的列表接口写出来,第 6 章再换成真 SQL。
article_list.pypython
from flask import Flask, jsonify, request

app = Flask(__name__)
app.json.ensure_ascii = False


@app.route("/api/article/list", methods=["GET"])
def article_list():
    # 取参数:名字对接口文档,没传就用默认值
    page = request.args.get("page", 1, type=int)
    page_size = request.args.get("page_size", 10, type=int)
    category_id = request.args.get("category_id", type=int)   # 不传就是 None
    keyword = request.args.get("keyword", "").strip()
    order_by = request.args.get("order_by", "latest")

    # 顺手做一层保护:防止前端传 page=0 或 page_size=99999
    page = max(page, 1)
    page_size = min(max(page_size, 1), 50)

    return jsonify({
        "code": 200,
        "msg": "success",
        "data": {
            "list": [],
            "total": 0,
            "page": page,
            "page_size": page_size,
            "category_id": category_id,
            "keyword": keyword,
            "order_by": order_by,
        },
    })
运行输出Python 运行结果
运行结果: $ curl -s "http://127.0.0.1:5000/api/article/list?page=2&page_size=5&category_id=1&keyword=月" | python -m json.tool --indent 2 { "code": 200, "msg": "success", "data": { "list": [], "total": 0, "page": 2, "page_size": 5, "category_id": 1, "keyword": "月", "order_by": "latest" } } # 什么都不传:全部走默认值,接口照样能响应 $ curl -s "http://127.0.0.1:5000/api/article/list" | python -m json.tool --indent 2 { "code": 200, "msg": "success", "data": { "list": [], "total": 0, "page": 1, "page_size": 10, "category_id": null, "keyword": "", "order_by": "latest" } } # 传了脏数据 page=abc:type=int 转不了,自动退回默认值 1 $ curl -s "http://127.0.0.1:5000/api/article/list?page=abc" | python -m json.tool --indent 2 { "code": 200, "msg": "success", "data": { "list": [], "total": 0, "page": 1, "page_size": 10, "category_id": null, "keyword": "", "order_by": "latest" } }
讲透
  • request.args 是「查询字符串」的专用字典,它只看得见 ? 后面的东西,看不到请求体。用 request.args["page"] 也能取,但参数不存在时会抛 BadRequestKeyError(400),所以一律用 .get()。
  • type=int 是最省事的一层校验:?page=abc 不会报错,直接给你默认值 1。注意它是「默默兜底」,不会告诉前端你传错了——真要提示用户,还得自己判断(参考第 7 章的参数校验)。
  • 默认值要跟接口文档一致:文档写「默认 1 / 10」,代码里就必须是 1 和 10。前端不传参数时靠的就是这两个默认值。
  • 别忘了给它加上限:page_size=99999 会让数据库一次吐出全表,把服务器拖死。上面那句 min(..., 50) 就是很简单的一道闸门,真实项目里必须有。
  • page / page_size 最终要变成 SQL 的 LIMIT:课程一学过 LIMIT s OFFSET (p-1)*s,这里的 p 和 s 就是你取出来的这两个数——前后两门课到这一行终于接上了。
避坑① 把参数写进路由变量:@app.route("/api/article/list/<int:page>")——分页参数每变一次路径就变一次,缓存、日志全乱。接口文档明确要求「分页 / 排序 / 筛选走查询参数」。② 用 request.args.get("keyword", None) or "" 这类绕圈写法,然后忘了 .strip(),用户输入一个空格就能搜到奇怪的结果。③ 在 request.args 里找请求体:POST 的 JSON 体里没有 page,你在这里取永远是 None——这一节和下一节的区别,就是「? 后面」和「空行后面」的区别。

request.form 与 get_json():读请求体

表单一套、JSON 一套
读请求体:表单一套,JSON 一套 表单提交 Content-Type: application/x-www-form-urlencoded JSON 提交(前后端分离常用) Content-Type: application/json { "username":"li", "pwd":"123" } request.form['x'] 按表单字段取 request.get_json() 整段 JSON 转成字典 用错会取到 None:表单用 form,JSON 用 get_json(),看请求头里的 Content-Type 决定

请求体有两种常见格式:表单用 request.form 按字段取,JSON 用 get_json() 整段转字典。看请求头 Content-Type 决定该用哪个,用错会取到 None。

先讲为什么用户点了「发表书评」,浏览器要把这条内容送给后端。查询字符串能塞短参数,但塞一条书评就别扭了,何况还有上传文件。要提交的内容该放在请求体里。
请求体有两种常见格式:表单(application/x-www-form-urlencoded,老式网页表单和文件上传用)和 JSON(application/json,前后端分离的主流)。「古典文学网」的接口文档写明请求头用 Content-Type: application/json,所以主用 JSON;但表单那套也得认识,因为上传头像用的是 multipart/form-data。
动手做写发表书评的接口:先用 request.get_json(silent=True) 拿到 JSON 体,再逐项校验。校验顺序就是:格式对不对 → 必填有没有 → 内容合法吗,每失败一步就返回一个可读的 400。
comment_add.pypython
from flask import Flask, jsonify, request

app = Flask(__name__)
app.json.ensure_ascii = False


@app.route("/api/comment/add", methods=["POST"])
def comment_add():
    # ① 读 JSON 体。silent=True 表示:体不是合法 JSON 时返回 None,而不是直接抛 415
    data = request.get_json(silent=True)
    if not data:
        return jsonify({"code": 400, "msg": "请求体必须是合法 JSON", "data": None})

    # ② 取字段。用 .get(),不要用 data["xxx"],缺字段时不会崩
    article_id = data.get("article_id")
    content = (data.get("content") or "").strip()
    parent_id = data.get("parent_id")          # 为空表示直接评论作品

    # ③ 校验必填
    if not article_id:
        return jsonify({"code": 400, "msg": "缺少参数:article_id", "data": None})
    if not content:
        return jsonify({"code": 400, "msg": "书评内容不能为空", "data": None})
    if len(content) > 1000:
        return jsonify({"code": 400, "msg": "书评最多 1000 字", "data": None})

    # ④ 校验过了才进业务(第 6 章这里会插入 comment 表)
    return jsonify({
        "code": 200,
        "msg": "发表成功",
        "data": {"comment_id": 9, "article_id": article_id,
                 "content": content, "parent_id": parent_id},
    })


# 表单格式(含文件上传)用 request.form / request.files,例如第 9 章的上传头像
@app.route("/api/user/avatar", methods=["POST"])
def upload_avatar():
    nickname = request.form.get("nickname", "")
    file = request.files.get("file")           # 文件对象
    if file is None:
        return jsonify({"code": 400, "msg": "请选择要上传的图片", "data": None})
    return jsonify({"code": 200, "msg": "上传成功",
                    "data": {"filename": file.filename, "nickname": nickname}})
运行输出Python 运行结果
运行结果: # 正常提交 $ curl -s -X POST http://127.0.0.1:5000/api/comment/add \ -H "Content-Type: application/json" \ -d '{"article_id": 1, "content": "好诗!", "parent_id": null}' | python -m json.tool --indent 2 { "code": 200, "msg": "发表成功", "data": { "comment_id": 9, "article_id": 1, "content": "好诗!", "parent_id": null } } # 少传必填参数 $ curl -s -X POST http://127.0.0.1:5000/api/comment/add \ -H "Content-Type: application/json" -d '{"content": "好诗!"}' | python -m json.tool --indent 2 { "code": 400, "msg": "缺少参数:article_id", "data": null } # 内容为空(空格也会被 strip 掉) $ curl -s -X POST http://127.0.0.1:5000/api/comment/add \ -H "Content-Type: application/json" -d '{"article_id": 1, "content": " "}' | python -m json.tool --indent 2 { "code": 400, "msg": "书评内容不能为空", "data": null } # 表单方式上传(-F 表示 multipart/form-data) $ curl -s -X POST http://127.0.0.1:5000/api/user/avatar -F "nickname=张三" -F "file=@/Users/me/avatar.png" {"code":200,"msg":"上传成功","data":{"filename":"avatar.png","nickname":"张三"}}
讲透
  • request.form 读表单体(?a=1&b=2 那种格式,只是放在了空行后面);request.get_json() 读 JSON 体;request.files 读上传的文件。三者互不通用——用错一个,取出来永远是 None。
  • Flask 靠 Content-Type 判断该用哪一个:请求头写 application/json,Flask 才会去解析 JSON 体。前端要是忘了设这个头,get_json() 就会失败——这是联调时最常见的「我明明发了啊」。
  • request.json 和 get_json() 是同一件事:request.json 是个便捷属性,解析失败会直接抛 415;get_json(silent=True) 失败时返回 None,能由你自己决定怎么提示前端,所以接口里推荐用后者。
  • 一定要用 data.get("xxx")。前端少传一个字段,data["xxx"] 会抛 KeyError,变成 500 错误;.get() 得到 None,你就能干净地回一个 400。
  • 校验要「先严后宽」:格式 → 必填 → 长度/范围,一层层挡。能在这里挡住的错误,就不会跑到数据库那一层变成难查的问题。
  • 别忘了 .strip() 和长度上限:库里 comment.content 是 VARCHAR(1000),不加长度校验,超长内容会被数据库截断或报错。
避坑用 curl 测 POST 接口时,-H "Content-Type: application/json" 这行不能省。省了之后 Flask 认为你发的不是 JSON,get_json(silent=True) 返回 None,于是你的接口回一句「请求体必须是合法 JSON」——看起来像代码写错了,其实是请求少了个头。用 Postman 时对应的是 Body → raw → JSON 这个选项,选错成 form-data 也是同一个坑。

method、headers 与其他常用属性

身份藏在这里
先讲为什么「我的收藏」这个接口,前端只发了路径,什么参数都没带——后端凭什么知道「我」是谁?答案就在请求头里。接口文档规定:需要登录的接口,请求头必须带 Authorization: Bearer <token>。后端从请求头里取出这个 token,解出用户 id,才知道该查谁的收藏。
这也是为什么鉴权信息要放请求头而不是 URL:URL 会被服务器日志、浏览器历史、代理服务器原样记下来,token 写在 URL 里等于到处留复印件。
动手做写个人信息接口:先从请求头里把 token 抠出来,没有就直接回 401;本章先把「有没有带、格式对不对」这一步做掉,真正验签、取 user_id 在第 8 章。
user_profile.pypython
from flask import Flask, jsonify, request

app = Flask(__name__)
app.json.ensure_ascii = False


@app.route("/api/user/profile", methods=["GET", "PUT"])
def user_profile():
    # ① 从请求头取 Authorization,形如 "Bearer eyJhbGciOi..."
    auth = request.headers.get("Authorization", "")
    if not auth.startswith("Bearer "):
        # 业务码 401:前端拦截器看到它会清 token 并跳登录页
        return jsonify({"code": 401, "msg": "请先登录", "data": None})

    token = auth[7:]          # 去掉 "Bearer " 这 7 个字符,剩下的才是 token
    # 第 8 章:这里会用 PyJWT 验签并解出 user_id,本章先不校验

    if request.method == "PUT":
        data = request.get_json(silent=True) or {}
        nickname = (data.get("nickname") or "").strip()
        if not nickname:
            return jsonify({"code": 400, "msg": "昵称不能为空", "data": None})
        return jsonify({"code": 200, "msg": "修改成功", "data": {"nickname": nickname}})

    # GET:返回个人信息
    return jsonify({
        "code": 200,
        "msg": "success",
        "data": {
            "id": 3,
            "username": "zhangsan",
            "nickname": "张三",
            "email": "zhangsan@example.com",
            "avatar": "/static/upload/avatar/3.png",
            "intro": "爱读唐诗",
            "gender": 1,
            "created_at": "2026-10-12 10:30:00",
        },
    })
运行输出Python 运行结果
运行结果: # 没带 token $ curl -s http://127.0.0.1:5000/api/user/profile | python -m json.tool --indent 2 { "code": 401, "msg": "请先登录", "data": null } # 带了 token(本章只检查格式,第 8 章才真正验签) $ curl -s http://127.0.0.1:5000/api/user/profile \ -H "Authorization: Bearer eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9...." | python -m json.tool --indent 2 { "code": 200, "msg": "success", "data": { "id": 3, "username": "zhangsan", "nickname": "张三", "email": "zhangsan@example.com", "avatar": "/static/upload/avatar/3.png", "intro": "爱读唐诗", "gender": 1, "created_at": "2026-10-12 10:30:00" } } # PUT 改昵称 $ curl -s -X PUT http://127.0.0.1:5000/api/user/profile \ -H "Authorization: Bearer eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9...." \ -H "Content-Type: application/json" -d '{"nickname": "张三丰"}' {"code":200,"msg":"修改成功","data":{"nickname":"张三丰"}}
讲透:request 上最常用的几个属性
  • request.method:本次请求的方法。大小写是全大写字符串,比较时写 == "POST",别写 == "post"。
  • request.headers:请求头字典,取值不区分大小写(request.headers.get("authorization") 也能取到),所以不用担心前端写的是哪种大小写。
  • request.path / request.url / request.full_path:路径、完整 URL、带查询串的路径。写日志时很好用。
  • request.remote_addr:客户端的 IP。做访问统计、限流、记操作日志时用得上。
  • request.args / form / json / files:对应四类不同的数据来源,上面几节分别讲过。此外还有 request.values(args + form 的合并),一般不用,容易搞乱来源。
  • 取头的正确姿势是 .get() 带默认值:request.headers.get("Authorization", "")。否则前端没带这个头时,直接取会报错。

响应的四种写法

字符串、JSON、元组、make_response
先讲为什么响应不只有「内容」这一件事:还要能设置状态码(告诉客户端成没成)、响应头(内容类型、缓存策略),有时还要写 Cookie。返回的内容越复杂,写法就越「重」——从最简单的返回字符串,一路到 make_response。知道有这四级,遇到新需求时你就知道该往上加哪一级,而不是瞎试。
动手做四种写法一起看,注意每一级比上一级多控制了什么。
responses.pypython
from flask import Flask, jsonify, make_response

app = Flask(__name__)
app.json.ensure_ascii = False


# ① 返回字符串:最原始,Content-Type 是 text/html
@app.route("/ping")
def ping():
    return "pong"


# ② 返回 JSON:接口的主力写法
@app.route("/api/version")
def version():
    return jsonify({"code": 200, "msg": "success", "data": {"version": "1.0"}})


# ③ 返回元组:(内容, 状态码) 或 (内容, 状态码, 响应头)
@app.route("/api/banner/detail")
def banner_detail():
    # 记录不存在时返回 HTTP 404;业务码和 HTTP 码不必一样(见下一节说明)
    return jsonify({"code": 404, "msg": "轮播图不存在", "data": None}), 404, {"X-Tip": "banner"}


# ④ make_response:需要精调响应对象时用
@app.route("/api/health")
def health():
    resp = make_response(jsonify({"code": 200, "msg": "success",
                                  "data": {"status": "ok"}}))
    resp.status_code = 200
    resp.headers["X-Server"] = "hiflask"
    resp.set_cookie("visited", "1", max_age=3600)   # 写 Cookie
    return resp
运行输出Python 运行结果
运行结果: $ curl -i http://127.0.0.1:5000/ping HTTP/1.1 200 OK Content-Type: text/html; charset=utf-8 pong $ curl -s http://127.0.0.1:5000/api/version {"code":200,"msg":"success","data":{"version":"1.0"}} $ curl -i http://127.0.0.1:5000/api/banner/detail HTTP/1.1 404 NOT FOUND Content-Type: application/json X-Tip: banner {"code":404,"msg":"轮播图不存在","data":null} $ curl -i http://127.0.0.1:5000/api/health HTTP/1.1 200 OK Content-Type: application/json X-Server: hiflask Set-Cookie: visited=1; Max-Age=3600; Path=/ Server: Werkzeug/3.0.6 Python/3.11.6 {"code":200,"msg":"success","data":{"status":"ok"}}
讲透
  • 返回字符串会用 text/html。前端 axios 拿到它是个字符串,res.data.code 直接是 undefined——所以接口一律返回 JSON,包括「成功」这种没有数据的场景。
  • 元组写法灵活:return 内容, 状态码 或 return 内容, 状态码, 响应头字典。Flask 允许你在 return 时顺手改 HTTP 状态。
  • jsonify 已经帮你设好了 Content-Type: application/json。手写 json.dumps() 再 return 字符串,就得自己设头,还容易漏——别绕这一圈。
  • make_response 是「想清楚再交卷」:它把返回值变成可编辑的响应对象,然后你逐项设置状态码、响应头、Cookie。跨域、缓存、下载文件名这些需求都从这里走。
  • 返回值必须是「能被变成响应」的东西:字符串、dict/list、元组、Response 对象都行;返回一个自定义对象则会报 TypeError: The view function did not return a valid response。
避坑「HTTP 状态码」和「业务状态码 code」是两回事,别混着用。接口文档约定:HTTP 状态码统一 200,业务成败看响应体里的 code。于是有些接口你写成 return jsonify({...}), 404,浏览器/axios 会直接进 catch 分支,前端那套「看 code 判断」的拦截器根本没机会执行,报错提示也就丢了。要么统一 HTTP 200,要么两边约定清楚——本课程统一按文档来:HTTP 200、业务看 code。例外是那些客户端必须按 HTTP 语义处理的场景(如 401 触发重新登录、下载失败)。

统一响应格式 {code, msg, data}

前端只写一套判断逻辑
统一响应信封:后端永远回 { code, msg, data } 前端 Flask { } 信封 code 业务码 msg 提示语 data 数据 请求 统一信封 前端只写一套判断逻辑:看 code 决定 success/fail,从 data 取数据

无论成功失败,所有接口都包成同一个信封 {code, msg, data}。前端不用为每个接口写一套解析,只看 code 判断结果、从 data 取数据即可。

先讲为什么如果不统一格式,会是什么样:有的接口返回 {"list": [...]},有的返回 {"data": {...}},出错时有的返回 {"error": "xxx"},有的直接抛一段 HTML 堆栈。前端每调一个接口都要写一套专属解析,还得猜哪种算失败。
接口文档定了统一格式 {code, msg, data},好处就一句话:前端在拦截器里写一次判断,全站通用——code === 200 就取 data,否则把 msg 弹出来;code === 401 就清 token 跳登录。以后新增一百个接口,前端一行都不用改。
动手做别在每个接口里手抄三行字典——把成功和失败包成两个小函数,一个项目里只有这一处定义格式。改格式时改这里就够了。
utils/response.pypython
from flask import jsonify


def ok(data=None, msg="success"):
    """成功:code 固定 200"""
    return jsonify({"code": 200, "msg": msg, "data": data})


def fail(code=400, msg="参数错误"):
    """失败:data 固定为 None,msg 直接给用户看"""
    return jsonify({"code": code, "msg": msg, "data": None})
运行输出Python 运行结果
运行结果: $ cat > /dev/null # 定义好了,接下来在接口里用 # 用起来是这样: $ curl -s http://127.0.0.1:5000/api/category/list | python -m json.tool --indent 2 { "code": 200, "msg": "success", "data": [ { "id": 1, "name": "唐诗", "description": "唐代诗歌,格律严谨、气象恢宏" } ] } $ curl -s http://127.0.0.1:5000/api/comment/add -X POST | python -m json.tool --indent 2 { "code": 400, "msg": "缺少参数:article_id", "data": null }
动手做回到发表书评的接口,用 ok() / fail() 重写一遍。对比一下:判断逻辑没变,但每一处返回一眼就能看出是成功还是失败。
用 ok / fail 重写python
from flask import Flask, request
from utils.response import ok, fail      # 上面那两个小函数

app = Flask(__name__)
app.json.ensure_ascii = False


@app.route("/api/comment/add", methods=["POST"])
def comment_add():
    data = request.get_json(silent=True)
    if not data:
        return fail(400, "请求体必须是合法 JSON")

    article_id = data.get("article_id")
    content = (data.get("content") or "").strip()

    if not article_id:
        return fail(400, "缺少参数:article_id")
    if not content:
        return fail(400, "书评内容不能为空")
    if len(content) > 1000:
        return fail(400, "书评最多 1000 字")

    # 第 6 章这里会插入 comment 表,拿到真实的自增主键
    return ok({"comment_id": 9}, "发表成功")


@app.route("/api/comment/<int:cid>", methods=["DELETE"])
def comment_delete(cid):
    # 未登录 / token 失效 → 401,前端拦截器收到就跳登录页
    return fail(401, "登录已过期,请重新登录")
运行输出Python 运行结果
运行结果: $ curl -s -X POST http://127.0.0.1:5000/api/comment/add \ -H "Content-Type: application/json" -d '{"article_id": 1, "content": "好诗!"}' | python -m json.tool --indent 2 { "code": 200, "msg": "发表成功", "data": { "comment_id": 9 } } $ curl -s -X DELETE http://127.0.0.1:5000/api/comment/1 | python -m json.tool --indent 2 { "code": 401, "msg": "登录已过期,请重新登录", "data": null }
讲透
  • 三个字段各司其职:code 给程序判断,msg 给用户看(失败时可直接提示),data 装业务数据。所以 msg 要写成人话(「书评内容不能为空」),不要写成「ERR_NULL_CONTENT」。
  • 业务码照文档的六档:200 成功、400 参数错误、401 未登录/失效、403 无权限、404 不存在、500 服务器内部错误。不要自己发明 1001、2003 这种码,除非你把它们写进接口文档——前端拦截器只认约定好的那几个。
  • data 的形状也要统一:列表类接口固定返回 {list, total, page, page_size},详情类直接返回对象,无数据的写操作返回 null。前端的分页组件、列表组件就都能复用。
  • 服务器出错了也要返回 JSON:绝不能把 Python 堆栈直接抛给前端,既不安全、前端也解析不了。做法是用全局异常处理器兜底,统一返回 {"code": 500, "msg": "服务器内部错误"}——这是第 7 章的内容。
  • 永远不返回 password:user 表里的密码字段,任何接口的响应里都不允许出现。
  • 这层包装就是「接口的对外面孔」:第 7 章会把它升级成完整的 API 设计规范,Vue 那门课会照着它写 axios 拦截器。

用 curl / Postman 调接口

把本章串起来
动手做把本章写好的三个接口按「前端会怎么调」走一遍:查询参数、JSON 体、请求头 token各来一次。这三招覆盖了接口文档里所有接口的调用方式。
完整调用流程bash
# ① 查询参数:作品列表,第 1 页、每页 2 条、只看唐诗
curl -s "http://127.0.0.1:5000/api/article/list?page=1&page_size=2&category_id=1" \
  | python -m json.tool --indent 2

# ② JSON 体:发表一条书评(POST 一定要指定 Content-Type)
curl -s -X POST http://127.0.0.1:5000/api/comment/add \
  -H "Content-Type: application/json" \
  -d '{"article_id": 1, "content": "短短二十字,写尽思乡之情。"}' \
  | python -m json.tool --indent 2

# ③ 请求头 token:个人信息(需要登录)
curl -s http://127.0.0.1:5000/api/user/profile \
  -H "Authorization: Bearer eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9...." \
  | python -m json.tool --indent 2
终端输出命令行执行结果
运行结果: ① $ curl -s "http://127.0.0.1:5000/api/article/list?page=1&page_size=2&category_id=1" | python -m json.tool --indent 2 { "code": 200, "msg": "success", "data": { "list": [], "total": 0, "page": 1, "page_size": 2, "category_id": 1, "keyword": "", "order_by": "latest" } } ② $ curl -s -X POST http://127.0.0.1:5000/api/comment/add -H "Content-Type: application/json" -d '{"article_id": 1, "content": "短短二十字,写尽思乡之情。"}' { "code": 200, "msg": "发表成功", "data": { "comment_id": 9, "article_id": 1, "content": "短短二十字,写尽思乡之情。", "parent_id": null } } ③ $ curl -s http://127.0.0.1:5000/api/user/profile -H "Authorization: Bearer eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9...." { "code": 200, "msg": "success", "data": { "id": 3, "username": "zhangsan", "nickname": "张三", "email": "zhangsan@example.com", "avatar": "/static/upload/avatar/3.png", "intro": "爱读唐诗", "gender": 1, "created_at": "2026-10-12 10:30:00" } }
讲透:Postman 怎么对应
  • 方法 + 地址:左边下拉框选 GET / POST / PUT / DELETE,右边填 http://127.0.0.1:5000/api/...。别漏了 /api 前缀。
  • 查询参数:切到 Params 标签页,填 page = 1、page_size = 10,它会自动拼成 ?page=1&page_size=10,比手敲省事,也不容易打错 &。
  • 请求体:切到 Body → 选 raw → 右边格式选 JSON,然后写 JSON。选错成 form-data,就是上一节那个坑。
  • 鉴权:切到 Headers,加一行 Authorization = Bearer <token>。登录后可以把 token 存成集合变量,后面所有接口自动带上。
  • Postman 的响应区会自动格式化 JSON,还能直接看状态码、响应头和耗时;命令行下想格式化就用 python -m json.tool --indent 2(本章一直在这么干)。
  • 顺手把接口存进集合:每写完一个接口,就在 Postman 里存一条请求。第 12 章要交付「接口测试 + 接口文档」,你现在攒的就是那时候的素材。
避坑curl 里带 & 的 URL 必须加引号:写 curl http://...:5000/api/article/list?page=1&page_size=5,shell 会把 & 当成「放到后台运行」,于是 curl 实际只请求了 ?page=1,剩下半截变成了另一条命令——你会以为参数没传进去,其实是 shell 干的。正确写法是给整个 URL 加双引号:curl "http://...:5000/api/article/list?page=1&page_size=5"。
动手练习
1给 /api/article/list 增加 order_by 参数的处理:只接受 latest、hot、likes 三个值,传了别的(比如 order_by=abc)就退回 latest,并在返回的 data 里带上最终生效的 order_by。
2书评列表接口 GET /api/comment/list 需要 article_id:没传就返回 400(提示「缺少参数:article_id」),传了就返回 {list, total, page, page_size} 结构。请用 ok() / fail() 写出来,并说明为什么这里不能像 page 那样给个默认值。
3迁移练习:「红影赏析」要给电影打分,接口是 POST /api/movie/rate,请求体 {"movie_id": 1, "score": 8.5}。请写出这个接口:movie_id 必填、score 必须是 0–10 之间的数字,用统一响应格式返回,并用 curl 验证。
参考答案
练习答案.pypython
from flask import Flask, jsonify, request
from utils.response import ok, fail

app = Flask(__name__)
app.json.ensure_ascii = False

ORDER_BY_WHITELIST = {"latest", "hot", "likes"}


# 第 1 题
@app.route("/api/article/list", methods=["GET"])
def article_list():
    page = max(request.args.get("page", 1, type=int), 1)
    page_size = min(max(request.args.get("page_size", 10, type=int), 1), 50)
    keyword = request.args.get("keyword", "").strip()

    order_by = request.args.get("order_by", "latest")
    if order_by not in ORDER_BY_WHITELIST:      # 白名单校验:只认这三个
        order_by = "latest"

    return ok({"list": [], "total": 0, "page": page,
               "page_size": page_size, "keyword": keyword,
               "order_by": order_by})


# 第 2 题
@app.route("/api/comment/list", methods=["GET"])
def comment_list():
    article_id = request.args.get("article_id", type=int)
    if not article_id:
        return fail(400, "缺少参数:article_id")

    page = max(request.args.get("page", 1, type=int), 1)
    page_size = min(max(request.args.get("page_size", 10, type=int), 1), 50)
    return ok({"list": [], "total": 0, "page": page, "page_size": page_size})


# 第 3 题(红影赏析)
@app.route("/api/movie/rate", methods=["POST"])
def movie_rate():
    data = request.get_json(silent=True)
    if not data:
        return fail(400, "请求体必须是合法 JSON")

    movie_id = data.get("movie_id")
    score = data.get("score")

    if not movie_id:
        return fail(400, "缺少参数:movie_id")
    if not isinstance(score, (int, float)) or not 0 <= score <= 10:
        return fail(400, "score 必须是 0 到 10 之间的数字")

    return ok({"movie_id": movie_id, "score": round(float(score), 1)}, "打分成功")
运行输出Python 运行结果
运行结果: $ curl -s "http://127.0.0.1:5000/api/article/list?order_by=abc" | python -m json.tool --indent 2 { "code": 200, "msg": "success", "data": { "list": [], "total": 0, "page": 1, "page_size": 10, "keyword": "", "order_by": "latest" } } $ curl -s "http://127.0.0.1:5000/api/comment/list" | python -m json.tool --indent 2 { "code": 400, "msg": "缺少参数:article_id", "data": null } $ curl -s "http://127.0.0.1:5000/api/comment/list?article_id=1&page=1&page_size=10" | python -m json.tool --indent 2 { "code": 200, "msg": "success", "data": { "list": [], "total": 0, "page": 1, "page_size": 10 } } $ curl -s -X POST http://127.0.0.1:5000/api/movie/rate \ -H "Content-Type: application/json" -d '{"movie_id": 1, "score": 8.5}' | python -m json.tool --indent 2 { "code": 200, "msg": "打分成功", "data": { "movie_id": 1, "score": 8.5 } } $ curl -s -X POST http://127.0.0.1:5000/api/movie/rate \ -H "Content-Type: application/json" -d '{"movie_id": 1, "score": 11}' | python -m json.tool --indent 2 { "code": 400, "msg": "score 必须是 0 到 10 之间的数字", "data": null }

第 2 题的答案:page 是可以合理地默认的(不传就当第 1 页),而 article_id 缺失意味着「你根本没告诉我要看哪篇作品的书评」——这没法默认,只能报 400。判断标准是:这个参数缺了以后,接口还能给出一个用户想要的合理解释吗?

本章小结