前两章我们能把数据查出来、也能拆成模块,但接口返回的格式还是「一个接口一个样」:有的返回数组、有的返回对象、出错时抛出一个 HTML 错误页。前端同学每个接口都得单独写解析逻辑,苦不堪言。这一章给全站接口立规矩:用 RESTful 风格设计 URL、所有响应统一成 {code,msg,data}、出错也走同一套格式、再顺手解决跨域与分页。学完,你的接口就能直接对接 Vue。
把资源和动作摊成一张矩阵:行是资源(作品/书评/用户),列是 HTTP 方法(GET/POST/PUT/DELETE),交叉格就是一次具体操——这就是 RESTful 接口的「排兵布阵」。
# ✗ 反面写法:动词堆在 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 的书评
{
"code": 200,
"msg": "success",
"data": {}
}
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": "李白"},
})
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
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="注册成功")
不加全局异常处理时,Flask 出错会返回 HTML 错误页,前端无法解析;注册 @app.errorhandler 后,所有异常都转成 {code,msg} 的 JSON,前端用同一套逻辑提示即可。
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
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)
状态码是服务器给的「标准暗号」:2xx 成功、3xx 跳转、4xx 是你(客户端)的问题、5xx 是我(服务器)的问题。统一异常后,Flask 会把 401/403/404/500 也包成 {code,msg,data} 返回。
# 业务状态码:与《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
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="发表成功")
前端 5173 和后端 5000 端口不同,属于「不同源」,浏览器同源策略会拦住响应。后端开启 CORS、在响应里带上 Access-Control-Allow-Origin 头后,浏览器才放行(详见《基础概念图解》)。
# pip install flask-cors
from flask import Flask
from flask_cors import CORS
app = Flask(__name__)
CORS(app) # 等价于对所有路由加 Access-Control-Allow-Origin: *
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
}
},
)
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,
}
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}
把本章的东西合起来,就是接口文档第 8 节「接口设计规范」的八条落地。照着下面这套骨架搭,你的每个接口都会自动拥有统一格式。
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
# 第 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
第 3 题的关键:换的是「资源名和字段」,不变的是「统一响应 + 统一分页」这套包装。movie 换成 article、rating 换成 views,外壳一模一样——能把它迁移过去,说明你真懂了「规范」的意义。