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

第 7 章 · API 设计规范与统一响应

前两章我们能把数据查出来、也能拆成模块,但接口返回的格式还是「一个接口一个样」:有的返回数组、有的返回对象、出错时抛出一个 HTML 错误页。前端同学每个接口都得单独写解析逻辑,苦不堪言。这一章给全站接口立规矩:用 RESTful 风格设计 URL、所有响应统一成 {code,msg,data}、出错也走同一套格式、再顺手解决跨域与分页。学完,你的接口就能直接对接 Vue。

概念不熟?如果你还不清楚 CORS、Cookie、Session、JWT 是什么,建议先读《Flask 基础概念图解》。看懂这四个类比,本章的 CORS 与下一章的 JWT 会轻松很多。

什么是 RESTful

URL 表示资源,方法表示动作
RESTful:URL 表示资源,方法表示动作 资源GETPOSTPUTDELETE作品 /articles列表新建更新—作品 /articles/<id>详情—更新删除书评 /comments列表发表—删除用户 /user信息注册改资料—横看是资源,竖看是动作;「资源 + 方法」就唯一确定一次操作

把资源和动作摊成一张矩阵:行是资源(作品/书评/用户),列是 HTTP 方法(GET/POST/PUT/DELETE),交叉格就是一次具体操——这就是 RESTful 接口的「排兵布阵」。

先讲为什么你给同事指路,会说「去财务室」还是说「走去财务室的那个动作」?正常人都说地点。RESTful 就是这条常识在网络接口上的应用:URL 只负责「指出是哪个资源」(名词),动作由 HTTP 方法(GET/POST/PUT/DELETE)来表达。
反面写法是 /api/getArticleList、/api/deleteComment——动词堆在 URL 里。接口一多,命名越来越随意,前端根本猜不出下一个接口长什么样。RESTful 的价值就是:让任何一个人看到资源名,就能推断出全部四个动作的地址。
动手做把「书评」这个资源的两套写法摆在一起对比,感受一下差别。
接口设计对照text
# ✗ 反面写法:动词堆在 URL 里,靠不住
GET    /api/getCommentList          查书评列表
POST   /api/addComment              发表书评
POST   /api/deleteComment?id=5      删除书评
POST   /api/updateComment           修改书评

# ✓ RESTful:资源用名词,动作交给 HTTP 方法
GET    /api/comment/list            查书评列表
POST   /api/comment/add             发表书评
DELETE /api/comment/5               删除 id=5 的书评
PUT    /api/comment/5               修改 id=5 的书评
效果预览运行结果
运行结果: # 看到 /api/comment 这一个资源名,就能推出四个动作: # GET 查、POST 增、PUT 改、DELETE 删 # 新增「点赞书评」时也自然落成: # POST /api/comment/like # 命名有迹可循,前端不用猜
讲透:四个方法各管什么
  • GET:查询,不改变服务器状态,可以放心刷新、可以被浏览器缓存。/api/article/list、/api/article/detail/1。
  • POST:新增,或执行一个「非增删改查」的动作(如登录、点赞、收藏切换)。/api/comment/add。
  • PUT:整体修改一个已有资源。PUT /api/user/profile 改个人信息。
  • DELETE:删除资源。DELETE /api/comment/5。
  • 把动作放 URL 里不行吗:能跑,但会失控——同一个「删除」在不同人手里可能叫 deleteComment、removeComment、commentDel。用方法统一,是团队协作的省心约定。
  • 真实项目里「点赞 / 收藏切换」这类动作不好映射成增删改查,用 POST + 名词(/api/article/like)也是官方文档允许的常规做法。
避坑别把「筛选、分页、排序」塞进 URL 路径。查询参数走 ?,路径只留资源。写成 /api/article/list/page/2/size/10/dynasty/唐 是错得离谱;正确的是 /api/article/list?page=2&page_size=10&dynasty=唐。同样,鉴权信息(token)必须放请求头 Authorization,绝不能拼进 URL——URL 会被服务器日志、浏览器历史、代理完整记录,等于把令牌公开了。

统一响应格式 code/msg/data

前端一套拦截逻辑搞定所有接口
先讲为什么假设你一天要接 18 个接口:有的直接返回数组,有的返回对象,成功失败全靠猜 HTTP 状态码。前端每个接口都得写一套「这次成功了吗、数据在哪一层」的判断,代码会烂成一锅粥。
解决办法是「统一包装」:所有接口都返回同一个三层结构,成功失败都长一样。这样前端只写一个拦截器(interceptor):看到 code === 200 就取 data,否则弹 msg。18 个接口,一套逻辑。
动手做看这个约定的标准长相——它和接口文档 1.3 / 1.4 节完全一致。
统一响应格式.jsonjson
{
  "code": 200,
  "msg": "success",
  "data": {}
}
响应内容接口返回数据
运行结果: # 三个字段各司其职 # code 业务状态码(int) 200 成功 / 400 / 401 / 403 / 404 / 500 # msg 提示信息(string) 失败时可直接展示给用户 # data 业务数据(任意) 对象 / 数组 / null # 成功:查作品列表 { "code": 200, "msg": "success", "data": [{ "id": 1, "title": "静夜思", "author": "李白" }] } # 失败:参数错误 { "code": 400, "msg": "缺少参数:article_id", "data": null }
动手做用 Flask 的 jsonify 直接返回这个字典即可。注意一处关键约定:HTTP 状态码统一返回 200,成功与否只看 code 字段。
app/api/article.py(手动拼响应)python
from flask import Blueprint, jsonify

article_bp = Blueprint("article", __name__, url_prefix="/api/article")

@article_bp.route("/detail/<int:id>", methods=["GET"])
def article_detail(id):
    if id <= 0:
        return jsonify({"code": 400, "msg": "参数错误", "data": None})
    return jsonify({
        "code": 200,
        "msg": "success",
        "data": {"id": id, "title": "静夜思", "author": "李白"},
    })
运行输出Python 运行结果
运行结果: $ curl http://127.0.0.1:5000/api/article/detail/1 HTTP/1.1 200 OK Content-Type: application/json {"code": 200, "msg": "success", "data": {"id": 1, "title": "静夜思", "author": "李白"}}
讲透:为什么 HTTP 状态码要统一 200
  • 前端拦截器更好写:axios 默认会把「4xx/5xx」当成网络错误抛到 catch 里,而同一次业务失败(如「用户名已存在」)和真正的网络异常混在一起,判断起来很别扭。统一 200 后,一切都从 response.data.code 里读。
  • 不丢失 HTTP 语义的场合:如果是对外开放 API 或要接 CDN、网关,仍建议同时给出正确的 HTTP 状态码。本课程按国内最常见的做法(HTTP 200 + 业务 code),前后端都好写。
  • msg 要能直接给用户看:写「用户名已存在」而不是「Duplicate entry 'zhangsan' for key 'uk_user_username'」——后者暴露了数据库细节。
  • 失败时 data 固定为 null,成功时才有内容,前端少一个判空分支。
避坑永远不要把整个模型对象(或 user.__dict__)直接 jsonify 出去。从第 6 章可知,User 模型里带着 password 字段——一旦整对象返回,密码哈希就泄露了。响应必须「手动挑字段」,把 password 永远排除在外(接口文档第 8 节第 8 条就是这么规定的)。同理,msg 里也不要暴露堆栈或 SQL 语句。

写 success() 与 fail() 工具函数

别在每个接口里手拼字典
先讲为什么上一节每个接口都手写 {"code": 200, "msg": "success", "data": ...}。18 个接口抄 18 遍,哪天想把 msg 改成 "ok",你得改 18 处,还一定漏。把这两个字典封成 success() 和 fail() 两个函数,全站只留一处「格式定义」——这就是「统一」二字的落地方式。
动手做在 app/utils.py 里写下这两个函数,作为全站响应的唯一出口。
app/utils.pypython
from flask import jsonify

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

def fail(msg="操作失败", code=400, data=None, http_status=200):
    """失败响应:HTTP 状态码默认仍为 200,业务成败看 code"""
    return jsonify({"code": code, "msg": msg, "data": data}), http_status
运行输出Python 运行结果
运行结果: # success() 返回 (响应对象, HTTP 状态码) 的元组,Flask 会自动拆开 >>> from app.utils import success, fail >>> success({"id": 1}).get_json() {'code': 200, 'msg': 'success', 'data': {'id': 1}} >>> fail("用户名已存在", 400).get_json() {'code': 400, 'msg': '用户名已存在', 'data': None}
动手做在接口里调用它们,代码立刻短一截,而且格式绝对一致。
app/api/user.py(用上工具函数)python
from flask import Blueprint, request
from app.utils import success, fail
from app.models import User

user_bp = Blueprint("user", __name__, url_prefix="/api/user")

@user_bp.route("/register", methods=["POST"])
def register():
    data = request.get_json() or {}
    username = data.get("username")

    if not username or not (3 <= len(username) <= 20):
        return fail("用户名需为 3–20 位", 400)

    if User.query.filter_by(username=username).first():
        return fail("用户名已存在", 400)

    # …… 创建用户、commit(第 6 章)……
    return success({"user_id": 3}, msg="注册成功")
运行输出Python 运行结果
运行结果: # 注册成功 {"code": 200, "msg": "注册成功", "data": {"user_id": 3}} # 用户名重复 {"code": 400, "msg": "用户名已存在", "data": null} # 用户名太短 {"code": 400, "msg": "用户名需为 3–20 位", "data": null}
讲透
  • 参数默认值就是「约定」:success() 的 code 永远是 200;fail() 默认 code=400。调用时只传必要的,其余交给默认值。
  • 返回元组是 Flask 的规矩:视图函数返回 (响应, 状态码),Flask 会自动拆开成 HTTP 响应。
  • 为什么留 http_status 参数:平时统一 200;万一某个接口需要对外强调状态(比如给第三方回调),可以按需覆盖。
  • 工具函数是「单一出口」:以后想加字段(如统一加个 timestamp),只改 utils.py 一处,全站生效。

全局异常处理

出错也要返回 JSON
全局异常处理:出错也要返回 JSON 视图函数 raise APIException 或抛出未捕获异常 @app.errorhandler 全局兜底捕获 统一转成响应 JSON 错误响应 { code:401, msg:"未登录" } 前端照常解析 ✗ 不加全局处理 Flask 返回 HTML 错误页 前端 axios 解析失败,只看到一串报错 ✓ 加了全局处理 所有错误都变 {code,msg} 前端一套逻辑统一提示

不加全局异常处理时,Flask 出错会返回 HTML 错误页,前端无法解析;注册 @app.errorhandler 后,所有异常都转成 {code,msg} 的 JSON,前端用同一套逻辑提示即可。

先讲为什么用户访问了一个不存在的地址,Flask 默认返回一个 HTML 错误页(那页写着 Not Found)。可前端拿到的是 JSON 解析器——它一看到 HTML 就崩了,报「Unexpected token <」。
统一响应的前提是:出错时也得是 JSON。Flask 提供了 @app.errorhandler(状态码或异常类) 装饰器,专门用来「接住」错误,改成我们自己的格式。
动手做注册 404 和 500 两个错误处理器。500 里别忘了 db.session.rollback()——万一异常发生在写库中途,得把事务回滚干净。
app/__init__.py(注册错误处理器)python
from flask import Flask
from app.utils import fail
from app.extensions import db

def create_app(env="development"):
    app = Flask(__name__)
    # ……

    @app.errorhandler(404)
    def handle_404(e):
        # 访问不存在的地址 / 不存在的资源
        return fail("资源不存在", 404)

    @app.errorhandler(500)
    def handle_500(e):
        # 回滚可能中途失败的事务,避免 session 卡在异常态
        db.session.rollback()
        return fail("服务器内部错误", 500)

    return app
运行输出Python 运行结果
运行结果: # 访问一个不存在的接口 $ curl http://127.0.0.1:5000/api/not-exist {"code": 404, "msg": "资源不存在", "data": null} # 不再是 HTML 错误页,前端 JSON 解析不再崩 # 后端抛出未捕获异常 {"code": 500, "msg": "服务器内部错误", "data": null}
动手做再加一个「兜底」处理器,接住所有没被单独处理的异常。注意:要拿到完整堆栈去排查,得先把它打印出来,但不能返回给前端。
兜底异常.pypython
from werkzeug.exceptions import HTTPException
import logging

@app.errorhandler(Exception)
def handle_all_exception(e):
    # 如果是 404 这类 HTTP 异常,交给对应的处理器
    if isinstance(e, HTTPException):
        return fail(e.description, e.code)
    # 真正的代码异常:服务端记日志,客户端只看到统一提示
    logging.exception("未捕获异常: %s", e)
    db.session.rollback()
    return fail("服务器内部错误", 500)
运行输出Python 运行结果
运行结果: # 服务端日志(只给自己看) ERROR:app:未捕获异常: division by zero Traceback (most recent call last): File "/Users/liuqingjie/hiflask/app/api/article.py", line 28, in article_list avg = total / count ZeroDivisionError: division by zero # 客户端收到(堆栈绝不外泄) {"code": 500, "msg": "服务器内部错误", "data": null}
讲透
  • @app.errorhandler(404) 接的是「状态码」,@app.errorhandler(SomeException) 接的是「异常类」,两者写法一样、用途不同。
  • 兜底的 Exception 处理器要放在最后,而且内部要先把 HTTPException 区分出来——否则连 404 都会被它「吞掉」,丢失原本的状态码。
  • 堆栈只进日志,不进响应:接口文档第 8 节第 4 条明确要求「绝不把堆栈抛给前端」。logging.exception 把它写到服务端日志,运维能查、攻击者看不到。
  • errorhandler 也能写在蓝图里:@user_bp.errorhandler(...) 只作用于该蓝图下的请求,适合模块级的特殊处理;全局规则还是放在 create_app 里。

统一业务异常码与 APIException

400 / 401 / 403 / 404 / 500
HTTP 状态码:出问题看它一眼就懂 2xx 成功 200 OK · 201 Created —— 一切正常,拿走结果 3xx 重定向 301 永久 / 302 临时 —— 资源挪地方了,去新地址 4xx 客户端错误(你的问题) 400 参数错 · 401 未登录 · 403 无权限 · 404 没这资源 5xx 服务端错误(我的问题) 500 服务器内部炸了 · 502/504 网关/超时 业务结果走 {code};HTTP 状态码走这套标准,二者各管一摊

状态码是服务器给的「标准暗号」:2xx 成功、3xx 跳转、4xx 是你(客户端)的问题、5xx 是我(服务器)的问题。统一异常后,Flask 会把 401/403/404/500 也包成 {code,msg,data} 返回。

先讲为什么业务代码里到处 return fail("作品不存在", 404) 有个坏处:校验逻辑和返回逻辑缠在一起,函数里全是 if-return,主干看不清。而且深层函数(比如「工具函数里发现参数错了」)没法直接 return 一个 HTTP 响应。
更优雅的方式是抛出一个自定义异常 APIException,由全局处理器统一转成响应——业务代码只管「这里不对」,怎么返回交给框架。
动手做业务状态码先对齐接口文档 1.4 节,再定义一个携带 code / msg 的异常类。
app/utils.py(业务状态码 + 异常类)python
# 业务状态码:与《API 接口文档》1.4 节一一对应
class Code:
    SUCCESS = 200        # 成功
    PARAM_ERROR = 400    # 参数错误
    UNAUTHORIZED = 401   # 未登录 / Token 失效
    FORBIDDEN = 403      # 无权限
    NOT_FOUND = 404      # 资源不存在
    SERVER_ERROR = 500   # 服务器内部错误


class APIException(Exception):
    """业务异常:抛出后由全局 errorhandler 统一转成响应"""
    def __init__(self, msg="服务器内部错误", code=Code.SERVER_ERROR, http_status=200):
        super().__init__(msg)
        self.msg = msg
        self.code = code
        self.http_status = http_status
运行输出Python 运行结果
运行结果: >>> raise APIException("作品不存在", Code.NOT_FOUND) APIException: 作品不存在 >>> e.msg '作品不存在' >>> e.code 404
动手做把它注册进全局处理器,并在接口里直接用 raise 抛出——主干代码立刻清爽。
app/__init__.py(注册 APIException 处理器)python
from app.utils import APIException, fail

@app.errorhandler(APIException)
def handle_api_exception(e):
    return fail(e.msg, e.code, http_status=e.http_status)


# 接口里:校验不通过就抛,不用关心怎么返回
# app/api/comment.py
from app.utils import APIException, Code, success

@comment_bp.route("/add", methods=["POST"])
def add_comment():
    data = request.get_json() or {}
    article_id = data.get("article_id")
    if not article_id:
        raise APIException("缺少参数:article_id", Code.PARAM_ERROR)

    article = db.session.get(Article, article_id)
    if article is None:
        raise APIException("作品不存在", Code.NOT_FOUND)

    return success({"comment_id": 9}, msg="发表成功")
运行输出Python 运行结果
运行结果: # 缺参数 {"code": 400, "msg": "缺少参数:article_id", "data": null} # 作品不存在 {"code": 404, "msg": "作品不存在", "data": null} # 权限不足(如删除他人书评) {"code": 403, "msg": "无权删除他人书评", "data": null} # 未登录 {"code": 401, "msg": "请先登录", "data": null} # 发表成功 {"code": 200, "msg": "发表成功", "data": {"comment_id": 9}}
讲透:六个码什么时候用
  • 200 成功:正常返回,取 data。
  • 400 参数错误:缺参数、格式不对、用户名已存在这类「用户输入问题」。错误信息要可读,能直接展示。
  • 401 未登录 / Token 失效:没带 token 或 token 过期。前端收到 401 的约定动作是清本地 token → 跳登录页。
  • 403 无权限:登录了,但动的是别人的东西(如删他人书评)。401 是「你是谁」,403 是「你不能」,别混用。
  • 404 资源不存在:查的作品 / 书评 id 不存在。
  • 500 服务器内部错误:代码异常,提示统一写「服务异常,请稍后再试」,不暴露细节。
  • 用 Code 常量而不是裸写数字,是为了「改一处、全站生效」,也避免把 404 手滑写成 403。
避坑别用 raise Exception("作品不存在") 来当业务异常——它会被兜底的 500 处理器接住,返回 code: 500,用户看到「服务器内部错误」,可你明明只是想提示「不存在」。业务上的「可预期失败」一律用 APIException 并带上恰当的 code;只有真正不该发生的意想不到(除零、连不上库)才留给 500。另外异常类名不要起成 Error 之类,容易和内置冲突。

CORS 跨域

为什么前端调不通后端
CORS 跨域:为什么前端调不通后端 前端 http://localhost:5173 (Vue 开发服务器) 后端 http://localhost:5000 (Flask 接口) 端口不同 = 不同源 ✗ 浏览器同源策略拦截:No 'Access-Control-Allow-Origin' header ✓ 后端 CORS 放行:响应带上 Access-Control-Allow-Origin 等头

前端 5173 和后端 5000 端口不同,属于「不同源」,浏览器同源策略会拦住响应。后端开启 CORS、在响应里带上 Access-Control-Allow-Origin 头后,浏览器才放行(详见《基础概念图解》)。

先讲为什么你用 Python 的 requests 调接口一切正常,可一放到浏览器的 Vue 页面里就报 CORS policy: No 'Access-Control-Allow-Origin' header。这不是你代码写错了,是浏览器的一条安全规则:只要「来源」和「目标」的协议、域名、端口三者有一个不同,就算跨域,浏览器默认禁止前端读取响应。
开发时 Vue 跑在 5173 端口、Flask 在 5000,端口不同 → 跨域。解决办法是让后端在响应里明确「允许某来源访问」,这就是 CORS(跨域资源共享)头。
动手做开发阶段图省事,可以用 Flask-CORS 的 CORS(app) 一行全放开——但这只是开发期的临时方案,详见下面两段。
开发期:全放开python
# pip install flask-cors
from flask import Flask
from flask_cors import CORS

app = Flask(__name__)
CORS(app)      # 等价于对所有路由加 Access-Control-Allow-Origin: *
运行输出Python 运行结果
运行结果: $ curl -i -H "Origin: http://localhost:5173" \ http://127.0.0.1:5000/api/article/list HTTP/1.1 200 OK Access-Control-Allow-Origin: * Content-Type: application/json {"code": 200, "msg": "success", "data": {"list": [], "total": 0}}
动手做上线前换成细粒度配置:只放行自己的前端域名、只作用于 /api/ 路径、只放行需要的请求头。
生产期:细粒度 resourcespython
from flask_cors import CORS

CORS(
    app,
    resources={
        r"/api/*": {                       # 只对 /api/ 开头的接口生效
            "origins": ["http://localhost:5173", "https://hiwen.example.com"],
            "methods": ["GET", "POST", "PUT", "DELETE", "OPTIONS"],
            "allow_headers": ["Content-Type", "Authorization"],
            "supports_credentials": False,  # 用了 JWT 头,一般不需要带 cookie
        }
    },
)
运行输出Python 运行结果
运行结果: # 来自白名单里的前端 $ curl -i -H "Origin: http://localhost:5173" http://127.0.0.1:5000/api/article/list Access-Control-Allow-Origin: http://localhost:5173 # 来自陌生站点(不在白名单) $ curl -i -H "Origin: http://evil.example.com" http://127.0.0.1:5000/api/article/list # 响应里不会出现 Access-Control-Allow-Origin # 浏览器据此拒绝把数据交给该页面
讲透
  • 「跨域」是浏览器的限制,不是后端的:curl、requests 不受限制,只有浏览器会拦。所以接口「在 Postman 里好的、在页面上不行」是典型症状。
  • 同源 = 协议 + 域名 + 端口三者全同:http://localhost:5173 与 http://localhost:5000 端口不同,就是跨域。
  • 复杂请求会先发「预检」OPTIONS:带自定义头(如 Authorization)或 Content-Type: application/json 的请求,浏览器会先发一个 OPTIONS 探路,后端必须正确响应它——Flask-CORS 会自动处理,不用你手写。
  • 另一条常见路线是「开发代理」:Vue 的 vite.config.js 里配 server.proxy,把 /api 代理到 5000,浏览器视角就「同源」了。生产环境通常用 Nginx 反向代理,让前后端同域。
  • 两种手段可以并用:开发用代理最干净,上线用 Nginx 同域;但把 CORS 配好是最通用、最省心的做法。
避坑绝对不要把 Access-Control-Allow-Origin: * 直接带到生产环境,尤其是同时开了 supports_credentials=True——前者意味着任何网站都能拿到你用户的接口数据,后者更会因为「* 与带凭证不能共存」而直接报错。正确做法是配白名单 origins,一台机器一个域名地列出来。另外:CORS(app) 的「全放开」只应该在本地开发时图省事用一下,别顺手提交上线。

分页的统一写法

page / page_size → list / total
先讲为什么第 6 章我们用上了 paginate(),但每个列表接口都要重复写一遍「读参数 → 分页 → 拼 list/total/page/page_size」,既啰嗦又容易各写各的。既然接口文档 1.5 节已经规定了分页的请求参数和响应结构,那就把它也封成一个工具函数。这样「作品列表」「书评列表」「我的收藏」三个接口共用一套分页逻辑,前端也只用一套分页组件。
动手做在 utils.py 里加一个 paginate_query():它负责读 page/page_size,调用 ORM 的 paginate,再按约定结构返回。
app/utils.py(分页工具)python
from flask import request

def paginate_query(query, serializer, page_size_default=10):
    """通用分页:query 是 SQLAlchemy 查询,serializer 把对象转成 dict"""
    # ① 读参数:page 从 1 开始,page_size 默认 10
    page = request.args.get("page", 1, type=int)
    page_size = request.args.get("page_size", page_size_default, type=int)

    # ② 兜底:非法值一律回退到默认,避免前端传 -1 直接崩
    if page < 1:
        page = 1
    if page_size < 1 or page_size > 100:
        page_size = page_size_default

    # ③ 分页查询;error_out=False 让越界返回空列表而非 404
    pager = query.paginate(page=page, per_page=page_size, error_out=False)

    # ④ 严格按接口文档 1.5 节的结构返回
    return {
        "list": [serializer(obj) for obj in pager.items],
        "total": pager.total,
        "page": pager.page,
        "page_size": pager.per_page,
    }
运行输出Python 运行结果
运行结果: # GET /api/article/list?page=1&page_size=3 { "list": [ {"id": 3, "title": "念奴娇·赤壁怀古", "author": "苏轼", "views": 412}, {"id": 1, "title": "静夜思", "author": "李白", "views": 328}, {"id": 4, "title": "声声慢", "author": "李清照", "views": 298} ], "total": 6, "page": 1, "page_size": 3 }
动手做在接口里用上它——注意这里只管「拼数据」,外面还要套一层 success(),两层合起来才是完整的统一响应。
app/api/article.py(列表接口)python
from app.utils import success, paginate_query
from app.models import Article

def article_to_dict(a):
    return {"id": a.id, "title": a.title, "author": a.author,
            "dynasty": a.dynasty, "views": a.views, "likes": a.likes}

@article_bp.route("/list", methods=["GET"])
def article_list():
    query = Article.query.filter_by(status=1).order_by(Article.views.desc())
    page_data = paginate_query(query, article_to_dict)
    return success(page_data)      # 外层仍是统一响应 {code,msg,data}
运行输出Python 运行结果
运行结果: { "code": 200, "msg": "success", "data": { "list": [ {"id": 3, "title": "念奴娇·赤壁怀古", "author": "苏轼", "dynasty": "宋", "views": 412, "likes": 58}, {"id": 1, "title": "静夜思", "author": "李白", "dynasty": "唐", "views": 328, "likes": 42}, {"id": 4, "title": "声声慢", "author": "李清照", "dynasty": "宋", "views": 298, "likes": 47} ], "total": 6, "page": 1, "page_size": 3 } }
讲透
  • 两层包装别搞混:内层 {list,total,page,page_size} 是「分页数据」,外层 {code,msg,data} 是「统一响应」。分页结构永远挂在 data 里面,这正是接口文档 1.5 节的样子。
  • serializer 参数的作用:把 ORM 对象转成「只含该露的字段」的字典——这是防「password 泄露」的关口。不同接口传不同 serializer,各取所需。
  • request.args.get(..., type=int):Flask 会自动把字符串转 int,转不了就返回默认值 1,比手动 int() 更稳(不会因 page=abc 抛异常)。
  • 给 page_size 设上限(这里 100):否则有人传 page_size=999999 就能一次拖走整张表,属于常见的接口性能与安全漏洞。
  • 排序必须在调用前定好:paginate 只是套 LIMIT/OFFSET,不给 ORDER BY 翻页就会重复漏项(第 2 章的老朋友)。

综合案例:把规范落到项目里

18 个接口,一套规矩

把本章的东西合起来,就是接口文档第 8 节「接口设计规范」的八条落地。照着下面这套骨架搭,你的每个接口都会自动拥有统一格式。

app/__init__.py(规范全部就位)python
from flask import Flask
from flask_cors import CORS
from app.utils import APIException, fail
from app.extensions import db, migrate

def create_app(env: str = "development") -> Flask:
    app = Flask(__name__)
    app.config.from_object(config_map[env])

    db.init_app(app)
    migrate.init_app(app, db)

    # ① 跨域:只放行自己的前端、只作用于 /api/*
    CORS(app, resources={
        r"/api/*": {"origins": ["http://localhost:5173"], "allow_headers": ["Content-Type", "Authorization"]}
    })

    # ② 业务异常 → 统一响应
    @app.errorhandler(APIException)
    def handle_api_exception(e):
        return fail(e.msg, e.code, http_status=e.http_status)

    # ③ 404 / 500 也绝不返回 HTML
    @app.errorhandler(404)
    def handle_404(e):
        return fail("资源不存在", 404)

    @app.errorhandler(500)
    def handle_500(e):
        db.session.rollback()
        return fail("服务器内部错误", 500)

    # ④ 注册蓝图
    from app.api.user import user_bp
    from app.api.article import article_bp
    app.register_blueprint(user_bp)
    app.register_blueprint(article_bp)

    return app
运行输出Python 运行结果
运行结果: # 同一套规则下,所有接口的返回都长这样: # 成功 {"code": 200, "msg": "...", "data": {...}} # 参数错误 {"code": 400, "msg": "...", "data": null} # 未登录 {"code": 401, "msg": "请先登录", "data": null} # 无权限 {"code": 403, "msg": "...", "data": null} # 不存在 {"code": 404, "msg": "资源不存在", "data": null} # 服务器异常 {"code": 500, "msg": "服务器内部错误", "data": null} # 前端一个拦截器即可处理全部 18 个接口
讲透:规范的价值在于「可预测」用名词设计 URL、用方法表达动作、用 code/msg/data 统一包装、用全局处理器兜住异常、用工具函数统一分页——这五条的共同目标,是让前端「不用看文档也能猜对」。做到这一步,前后端协作的成本会大幅下降。下一章的 JWT 登录鉴权,就会直接用到这里的 401 与 APIException。
动手练习
1用 RESTful 风格写出「收藏」资源的四个接口(查我的收藏、新增收藏、取消收藏、按 id 查收藏),标出各自该用的 HTTP 方法;并指出接口文档里 /api/favorite/toggle 这样写是否符合规范、为什么。
2给 APIException 加一个 code=401 的用法场景:写一段「未登录时访问需鉴权接口」的代码,最终返回 {"code":401,"msg":"请先登录","data":null}。
3迁移练习:把本章规范搬到「红影赏析」(电影主题)。请写出:① 电影资源 movie 的 RESTful 接口表(列表 / 详情 / 新增 / 修改 / 删除);② 影评列表接口的分页响应 JSON(给 1 条示例数据,字段含 id、title、rating);③ 一句话说明「统一响应格式」和「电影网站」有没有关系。
参考答案
练习答案.pypython
# 第 1 题:收藏资源
# GET    /api/favorite/list       查我的收藏(当前用户的)
# POST   /api/favorite/toggle     收藏 / 取消收藏切换
# DELETE /api/favorite/3          取消对 id=3 作品的收藏
# GET    /api/favorite/3          按 id 查收藏
#
# /api/favorite/toggle 符合本课程的规范:
# 「收藏」是「有则取消、无则添加」的切换动作,不属于纯粹的增或删,
# 接口文档把它归为 POST + 名词的常见做法,允许。
# 若要更严格,可拆成 POST /api/favorite 与 DELETE /api/favorite/<id>。

# 第 2 题:未登录 → 401
from app.utils import APIException, Code, success

def login_required(f):
    """极简登录校验装饰器(第 8 章会换成正式版)"""
    def wrapper(*args, **kwargs):
        token = request.headers.get("Authorization")
        if not token:
            raise APIException("请先登录", Code.UNAUTHORIZED)   # 401
        return f(*args, **kwargs)
    return wrapper

@user_bp.route("/profile", methods=["GET"])
@login_required
def get_profile():
    return success({"id": 3, "nickname": "张三"})

# 第 3 题:红影赏析(电影主题)
# ① movie 资源的 RESTful 接口
# GET    /api/movie/list           电影列表(分页/筛选)
# GET    /api/movie/detail/<id>    电影详情
# POST   /api/movie/add           新增电影
# PUT    /api/movie/<id>          修改电影
# DELETE /api/movie/<id>          删除电影
#
# ② 影评列表分页响应
# GET /api/review/list?movie_id=1&page=1&page_size=10
运行输出Python 运行结果
运行结果: # 第 1 题 DELETE /api/favorite/3 {"code": 200, "msg": "操作成功", "data": {"favorited": false}} # 第 2 题:未带 token 访问 {"code": 401, "msg": "请先登录", "data": null} # 第 3 题 ②:影评列表分页响应 { "code": 200, "msg": "success", "data": { "list": [ {"id": 1, "title": "流浪地球", "rating": 8.4, "content": "硬核科幻,格局很大。"} ], "total": 1, "page": 1, "page_size": 10 } }

第 3 题的关键:换的是「资源名和字段」,不变的是「统一响应 + 统一分页」这套包装。movie 换成 article、rating 换成 views,外壳一模一样——能把它迁移过去,说明你真懂了「规范」的意义。

本章小结