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

阶段 3 · 后端开发

接口文档已经定死了,现在把它变成能跑的代码。这一阶段的产出是一个「可运行、可自测」的后端服务:统一的响应结构、统一的异常处理、JWT 鉴权、18 个接口一个不少。做完的标志很清楚——Postman 里点一下 Run,从「注册」到「查个人信息」一路绿灯,这就是里程碑 M3。

从接口文档到代码:先搭骨架

18 个接口,别一起写
先讲为什么如果一上来就写第 1 个接口,你会不由自主地把「数据库连接、返回格式、错误处理、鉴权」这些公共的东西,随手写进那个接口里。写到第 5 个接口时,同一套东西已经被抄了 5 遍,而且每份都不一样。
正确做法是先把公共骨架立起来:模型、响应、异常、鉴权各就各位,之后每个接口只操心三件事——查什么、校验什么、返回什么。
讲透:这一阶段的正确顺序
  • ① 初始化项目(虚拟环境 + 依赖 + 应用工厂 + 蓝图注册)。
  • ② 连数据库、写模型——模型字段必须和 hiwen.sql 逐字对应。
  • ③ 统一响应 + 全局异常——这一步做完,后面所有接口的返回格式就自动统一了。
  • ④ 鉴权——注册、登录、@login_required,这是「需登录」那 10 个接口的前置条件。
  • ⑤ 逐个实现 18 个接口,先公开的(首页、列表、详情),再需登录的(点赞、收藏、书评)。
  • ⑥ Postman 自测,跑通 M3 那条链路。
避坑最忌讳的是「一口气写完 18 个接口再统一调试」。一旦起不来,你面对的是一片报错,根本不知道从哪查。
写一个、测一个:写完注册接口就用 curl 打一下,通了再写登录;写完登录再测一次拿 token。这样每个错误都只可能来自刚刚那十行代码。

项目初始化:venv、应用工厂、蓝图

照着 hiflask 骨架走
先讲为什么两个看起来很啰嗦、但都是为你好东西:
虚拟环境解决「依赖打架」——项目 A 要 Flask 2、项目 B 要 Flask 3,装在同一个 Python 里必然有一边挂掉。
应用工厂(create_app() 函数)解决「配置写死」——有了它,你就能创建「开发环境连 MySQL、测试环境用内存库」两个互不干扰的应用实例。
动手做 ①建虚拟环境、装依赖。骨架代码在 pyweb/code/hiflask/,直接复制过来用。
初始化.shbash可运行
# 在 pyweb/code/hiflask/ 目录下执行
python -m venv .venv

# macOS / Linux 激活
source .venv/bin/activate
# Windows(PowerShell):.venv\Scripts\Activate.ps1

pip install -r requirements.txt
pip list | grep -i flask
终端输出命令行执行结果
运行结果: Successfully installed Flask-3.0.3 Flask-Cors-4.0.1 Flask-JWT-Extended-4.6.0 Flask-Migrate-4.0.7 Flask-SQLAlchemy-3.1.1 PyMySQL-1.1.1 cryptography-42.0.8 python-dotenv-1.0.1 blinker-1.8.2 Flask 3.0.3 Flask-Cors 4.0.1 Flask-JWT-Extended 4.6.0 Flask-Migrate 4.0.7 Flask-SQLAlchemy 3.1.1
动手做 ②确认目录结构。一个模块一个文件的好处很直接:改返回字段看 views/,改表结构看 models/,改响应格式看 utils/response.py,改鉴权规则看 utils/auth.py——改动的影响范围是可预测的。
查看项目结构.shbash可运行
tree -L 2 --dirsfirst -I '.venv|__pycache__'
终端输出命令行执行结果
运行结果: . ├── models │ ├── __init__.py │ ├── article.py │ ├── comment.py │ └── user.py ├── utils │ ├── __init__.py │ ├── auth.py │ └── response.py ├── views │ ├── __init__.py │ ├── article.py │ ├── comment.py │ ├── favorite.py │ ├── home.py │ └── user.py ├── app.py ├── config.py ├── extensions.py ├── requirements.txt └── .env.example
动手做 ③写应用工厂。它只做三件事:读配置 → 初始化扩展 → 挂蓝图和错误处理。所有业务代码都不在这,工厂就是装配线。
app.pypython
from flask import Flask

from config import config_map
from extensions import cors, db, jwt, migrate
from models import *  # noqa: F401,F403  —— 让 Flask-Migrate 能发现全部模型
from views import register_blueprints


def create_app(config_name: str = "dev") -> Flask:
    """创建并装配一个 Flask 应用。"""
    app = Flask(__name__)
    app.config.from_object(config_map[config_name])
    # 让 jsonify 直接输出中文,而不是 \uXXXX 转义(Flask 2.3+ 的写法)
    app.json.ensure_ascii = False

    # ① 初始化扩展(实例在 extensions.py 里创建,这里只做绑定)
    db.init_app(app)
    jwt.init_app(app)
    cors.init_app(app, resources={r"/api/*": {"origins": "*"}})
    migrate.init_app(app, db)

    # ② 挂上所有蓝图(18 个接口都在这一步注册)
    register_blueprints(app)

    # ③ 统一 JWT 异常与全局错误处理
    register_jwt_handlers()
    register_error_handlers(app)

    return app


if __name__ == "__main__":
    create_app("dev").run(host="127.0.0.1", port=5000, debug=True)
运行输出Python 运行结果
运行结果: $ python app.py * Serving Flask app 'app' * Debug mode: on WARNING: This is a development server. Do not use it in a production deployment. Use a production WSGI server instead. * Running on http://127.0.0.1:5000 Press CTRL+C to quit
讲透:为什么 db / jwt / cors 要单独放一个 extensions.py
  • 如果 db 定义在 app.py 里,那 models/user.py 就要 from app import db;而 app.py 又得导入模型才能建表——两边互相等对方先加载,Python 直接报 ImportError。
  • 拆出来之后,依赖变成一条直线:extensions → models / utils → views → app,谁都可以安全地导入 db。
  • extensions.py 里只创建实例、不绑定应用;真正的 init_app(app) 统一在工厂里做。这就是「懒绑定」,也是应用工厂能支持多环境的关键。
避坑两个高频事故:① Address already in use——5000 端口被占了。用 lsof -i :5000 找到 PID 后 kill,或换个端口启动。
② 千万别把 debug=True 的实例部署到公网。调试模式会暴露源码和交互式调试器,任何人在浏览器里都能执行任意代码——这是真实存在的漏洞。开发用 python app.py,上线用 Gunicorn(阶段 6 讲)。

数据库连接与 ORM 模型

模型必须和 hiwen.sql 逐字对应
先讲为什么直接用 pymysql 拼 SQL 也能跑,但阶段 2 已经定好了表结构——用 ORM 把它映射成 Python 类,之后写接口就是 Article.query.filter_by(...),不用再背字段名。
更重要的是安全:ORM 底层全走参数化查询,天然挡住 SQL 注入。
动手做 ①把连接串做成一个函数,账号密码从环境变量读,并且用 quote_plus 处理特殊字符。
config.pypython
import os
from urllib.parse import quote_plus

from dotenv import load_dotenv

BASE_DIR = os.path.abspath(os.path.dirname(__file__))
load_dotenv(os.path.join(BASE_DIR, ".env"))


def build_mysql_uri() -> str:
    """按环境变量拼出 MySQL 连接串。

    密码交给 quote_plus 处理:像 @ / # / % 这类字符不会把连接串拼坏。
    """
    user = os.getenv("DB_USER", "root")
    password = os.getenv("DB_PASSWORD", "123456")
    host = os.getenv("DB_HOST", "127.0.0.1")
    port = os.getenv("DB_PORT", "3306")
    name = os.getenv("DB_NAME", "hiwen")
    return (
        f"mysql+pymysql://{user}:{quote_plus(password)}@{host}:{port}/{name}"
        "?charset=utf8mb4"
    )


class Config:
    SECRET_KEY = os.getenv("SECRET_KEY", "dev-secret-key-change-me")
    JWT_SECRET_KEY = os.getenv("JWT_SECRET_KEY", "dev-jwt-secret-change-me")

    SQLALCHEMY_DATABASE_URI = build_mysql_uri()
    SQLALCHEMY_TRACK_MODIFICATIONS = False
    # MySQL 连接空闲 8 小时会被服务端断开,pool_recycle 让连接提前回收
    SQLALCHEMY_ENGINE_OPTIONS = {"pool_recycle": 3600, "pool_pre_ping": True}
运行输出Python 运行结果
运行结果: $ curl -s http://127.0.0.1:5000/api/health | python -m json.tool { "code": 200, "msg": "success", "data": { "service": "hiflask", "status": "up" } } (健康检查里执行了一句 SELECT 1,能返回 up 就说明数据库连通了)
动手做 ②写模型。一张表一个类,__tablename__ 用单数(和阶段 2 的表名一致),字段名一个字母都不能差。
models/user.pypython
from datetime import datetime

from werkzeug.security import check_password_hash, generate_password_hash

from extensions import db


class User(db.Model):
    """用户。表名单数,字段与 hiwen.sql 逐字对应。"""

    __tablename__ = "user"

    id = db.Column(db.Integer, primary_key=True, comment="用户ID")
    username = db.Column(db.String(50), nullable=False, unique=True, comment="登录名")
    password = db.Column(db.String(255), nullable=False, comment="密码哈希(切勿明文)")
    nickname = db.Column(db.String(50), comment="昵称")
    email = db.Column(db.String(100), unique=True, comment="邮箱(唯一,可空)")
    avatar = db.Column(db.String(255), default="", comment="头像路径")
    intro = db.Column(db.String(255), comment="个人简介")
    gender = db.Column(db.SmallInteger, default=0, comment="0未知 1男 2女")
    status = db.Column(db.SmallInteger, nullable=False, default=1, comment="1正常 0禁用")
    created_at = db.Column(db.DateTime, nullable=False, default=datetime.now)
    updated_at = db.Column(
        db.DateTime, nullable=False, default=datetime.now, onupdate=datetime.now
    )

    # ---------- 密码:只存哈希,绝不存明文 ----------
    def set_password(self, raw: str) -> None:
        self.password = generate_password_hash(raw)  # 自动加盐

    def check_password(self, raw: str) -> bool:
        return check_password_hash(self.password, raw)

    # ---------- 序列化:白名单写法,password 永远不会出现在响应里 ----------
    def to_dict(self) -> dict:
        return {
            "id": self.id,
            "username": self.username,
            "nickname": self.nickname,
            "email": self.email,
            "avatar": self.avatar or "",
            "intro": self.intro,
            "gender": self.gender,
            "created_at": self.created_at.strftime("%Y-%m-%d %H:%M:%S"),
        }

    def to_brief(self) -> dict:
        """登录响应里的 user 字段,只要四个字段。"""
        return {
            "id": self.id,
            "username": self.username,
            "nickname": self.nickname,
            "avatar": self.avatar or "",
        }
运行输出Python 运行结果
运行结果: $ python -c "from models.user import User; print(list(User.__table__.columns.keys()))" ['id', 'username', 'password', 'nickname', 'email', 'avatar', 'intro', 'gender', 'status', 'created_at', 'updated_at'] (11 个字段,与 hiwen.sql 的 user 表完全一致)
讲透:模型层有三个「必须」
  • 必须和表结构对齐:长度(VARCHAR(50) → db.String(50))、可空性(nullable)、默认值一个都不能漏。模型和库不一致,改数据时就会报 Data too long 这类莫名其妙的错。
  • 必须用关系属性表达外键:article.category 直接拿到分类对象,comment.children 直接拿到回复列表。lazy="dynamic" 拿到的是一个还能继续 .filter()/.count() 的 Query,适合一对多。
  • 序列化必须用白名单:to_dict() 里手写要返回哪几个字段。别用「遍历 __dict__」这类偷懒写法——那样 password 就会跟着一起返回,这是最典型的安全事故。
避坑三件事:① .env 不要提交到版本库(里面是数据库密码和密钥),仓库里只放 .env.example;② 密码里有特殊字符必须让 quote_plus 处理,否则 p@ss#word 这种密码会把连接串拼断,报一个和密码毫不相干的错;③ 测试环境用 SQLite 时要留意方言差异——SQLite 没有 ENUM、没有 FULLTEXT,在它上面全绿不代表 MySQL 上没问题,关键路径还是要用 Postman 在 MySQL 上验一遍。

统一响应与全局异常

前端只写一套拦截器
先讲为什么如果每个接口返回的结构都不一样,前端的拦截器就得写 18 个分支。
阶段 1 已经定好了契约:{code, msg, data}。现在只需要用一个 success() / fail() 把契约固化进代码——之后没人能「不小心」写歪。
动手做 ①写三个小函数:success()、fail()、page_data()。接口里只准用它们返回,不准手写 jsonify。
utils/response.pypython
from flask import jsonify


def success(data=None, msg: str = "success"):
    """成功响应。"""
    return jsonify(code=200, msg=msg, data=data)


def fail(msg: str = "参数错误", code: int = 400):
    """失败响应。msg 可直接展示给用户。"""
    return jsonify(code=code, msg=msg, data=None)


def page_data(items: list, total: int, page: int, page_size: int) -> dict:
    """分页响应的 data 结构(接口文档 1.5 节)。"""
    return {"list": items, "total": total, "page": page, "page_size": page_size}
运行输出Python 运行结果
运行结果: $ curl -s http://127.0.0.1:5000/api/banner/list | python -m json.tool { "code": 200, "msg": "success", "data": [ { "id": 1, "title": "品读古典文学,涵养中国心灵", "image": "/static/images/banner1.jpg", "link": "/article/list?category_id=1" } ] }
动手做 ②加全局异常处理。它的职责是兜底:任何没被业务逻辑处理的异常,最后都变成一句「服务器内部错误」,而不是一大段 Python 堆栈。
app.py · 异常处理python
from werkzeug.exceptions import HTTPException


def register_error_handlers(app: Flask) -> None:
    """兜底异常处理:绝不把堆栈抛给前端。"""

    @app.errorhandler(404)
    def not_found(error):
        return fail("接口不存在", code=404)

    @app.errorhandler(405)
    def method_not_allowed(error):
        return fail("请求方法不被允许", code=405)

    @app.errorhandler(Exception)
    def internal_error(error):
        # 其余 HTTP 异常(400/415 等)保持原本的语义,不要一律当 500
        if isinstance(error, HTTPException):
            return error
        db.session.rollback()  # 出异常后必须回滚,否则会话一直处于失败状态
        app.logger.exception("未捕获的服务器异常")
        return fail("服务器内部错误", code=500)
运行输出Python 运行结果
运行结果: $ curl -s http://127.0.0.1:5000/api/article/detail/999999 | python -m json.tool { "code": 404, "msg": "作品不存在", "data": null } (不存在的作品由业务代码返回 404,不会被兜底逻辑当成 500)
讲透:为什么 HTTP 状态码统一返回 200
  • 业务成败看 code,不看 HTTP 状态码。这是国内项目最常见的做法:前端拦截器只需要判断 code === 200 就取数据,401 就清 token 跳登录,其余弹 msg——一套逻辑覆盖 18 个接口。
  • 业务状态码表要背下来:200 成功、400 参数错误、401 未登录或 token 失效、403 无权限、404 资源不存在、500 服务器内部错误。
  • db.session.rollback() 那一行不能省。SQLAlchemy 的 session 一旦出错就进入「失败状态」,不回滚的话,同一个请求里后续任何数据库操作都会继续报错——现象是「第一个错还看得懂,后面全是莫名其妙的错」。
避坑别把 @app.errorhandler(Exception) 写成「所有异常都返回 500」。HTTPException 要原样放行:404 本该是 404,400 本该是 400,一律压成 500 会让前端和日志都失去线索。
另外,500 的响应里绝不能带堆栈。堆栈里往往有文件路径、SQL 语句、甚至连接串——那是给攻击者看的礼物。要排查就去服务器日志里看(app.logger.exception 已经记下来了)。

鉴权:注册、登录与 @login_required

JWT 是这一节的主角
先讲为什么服务器怎么知道「这个请求是张三发的」?传统做法是 session:服务端存一份登录状态,浏览器只拿一个 session id。JWT 反过来——把用户信息签个名,直接交给客户端保管,服务端不用存。
好处很实在:服务器可以水平扩展,多个进程、多台机器都不用共享 session。
动手做 ①写注册和登录。注册里最重要的一步是 user.set_password(password)——只存哈希。
views/user.py · 注册与登录python
import re

from flask import Blueprint, g, request
from flask_jwt_extended import create_access_token

from extensions import db
from models.user import User
from utils.auth import login_required
from utils.response import fail, success

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

USERNAME_RE = re.compile(r"^[A-Za-z0-9_]{3,20}$")


@user_bp.post("/register")
def user_register():
    """注册:POST /api/user/register"""
    data = request.get_json(silent=True) or {}
    username = (data.get("username") or "").strip()
    password = data.get("password") or ""

    # ① 参数校验前置:把非法请求挡在数据库之前
    if not USERNAME_RE.match(username):
        return fail("用户名需为 3-20 位字母、数字或下划线")
    if not (6 <= len(password) <= 20):
        return fail("密码长度需为 6-20 位")
    if User.query.filter_by(username=username).first():
        return fail("用户名已存在")

    nickname = (data.get("nickname") or "").strip() or username
    user = User(username=username, nickname=nickname)
    user.set_password(password)  # 只存哈希
    db.session.add(user)
    db.session.commit()
    return success({"user_id": user.id}, "注册成功")


@user_bp.post("/login")
def user_login():
    """登录:POST /api/user/login → 返回 token 与用户简要信息"""
    data = request.get_json(silent=True) or {}
    username = (data.get("username") or "").strip()
    password = data.get("password") or ""

    if not username or not password:
        return fail("缺少参数:username / password")

    user = User.query.filter_by(username=username).first()
    # 用户不存在 / 密码错误,统一提示,避免暴露账号是否存在(用户名枚举漏洞)
    if user is None or not user.check_password(password):
        return fail("用户名或密码错误")
    if user.status != 1:
        return fail("账号已被禁用", code=403)

    # flask-jwt-extended 4.x 要求 identity 必须是字符串
    token = create_access_token(identity=str(user.id))
    return success({"token": token, "user": user.to_brief()}, "登录成功")
运行输出Python 运行结果
运行结果: $ curl -s -X POST http://127.0.0.1:5000/api/user/login \ -H "Content-Type: application/json" \ -d '{"username": "zhangsan", "password": "123456"}' | python -m json.tool { "code": 200, "msg": "登录成功", "data": { "token": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9....", "user": { "id": 3, "username": "zhangsan", "nickname": "张三", "avatar": "" } } }
动手做 ②写 @login_required 装饰器。它做四件事:校验 token → 按 identity 取用户 → 检查状态 → 挂到 g.current_user。
utils/auth.pypython
from functools import wraps

from flask import g
from flask_jwt_extended import get_jwt_identity, verify_jwt_in_request

from extensions import db
from models.user import User
from utils.response import fail


def login_required(view_func):
    """需要登录的视图加上它。

    流程:校验 token → 按 identity 取出用户 → 检查状态 → 挂到 g.current_user。
    token 缺失 / 无效 / 过期时,由 app.py 里注册的 JWT 回调统一返回 401。
    """

    @wraps(view_func)
    def wrapper(*args, **kwargs):
        verify_jwt_in_request()

        identity = get_jwt_identity()
        try:
            user_id = int(identity)
        except (TypeError, ValueError):
            return fail("登录状态无效,请重新登录", code=401)

        user = db.session.get(User, user_id)
        if user is None:
            return fail("请先登录", code=401)
        if user.status != 1:
            return fail("账号已被禁用", code=403)

        g.current_user = user
        return view_func(*args, **kwargs)

    return wrapper


def optional_login():
    """可选登录:带了合法 token 就返回 user_id,没带或失效返回 None。

    给「公开接口但要回填当前用户状态」的场景用,比如作品详情里的
    is_favorite / is_liked —— 没登录也能看,只是状态都是 false。
    """
    try:
        verify_jwt_in_request(optional=True)
    except Exception:
        return None
    identity = get_jwt_identity()
    if not identity:
        return None
    try:
        return int(identity)
    except (TypeError, ValueError):
        return None
运行输出Python 运行结果
运行结果: $ curl -s http://127.0.0.1:5000/api/user/profile | python -m json.tool { "code": 401, "msg": "请先登录", "data": null } (不带 token 访问需登录接口,由 @jwt.unauthorized_loader 统一返回 401)
讲透:三个容易答不上来的面试点
  • 为什么 identity 要转成字符串:flask-jwt-extended 4.x 规定 JWT 的 subject 必须是字符串,直接塞整数会抛错——取出来时自己 int() 转回来。
  • JWT 的「退出登录」退的是什么:服务端删不掉已经签发的 token(它本来就是无状态的),真正的退出由前端清掉本地 token 完成。所以 /api/user/logout 只是记录日志,为将来的黑名单留个位置。
  • optional_login() 为什么必需:作品详情是公开接口,但登录用户要看到「已收藏 / 已点赞」的状态。用 optional=True 校验:带了 token 就回填真实状态,没带就都返回 false。
避坑第一个坑几乎人人都踩过:Authorization 的值是 Bearer + 一个空格 + token,少这个空格服务器解析不出来,直接回 {"code":401,"msg":"请先登录"}。
第二个是安全习惯:用户名不存在和密码错误必须返回同一句话——「用户名或密码错误」。分开提示等于告诉攻击者「这个用户名是存在的」,这叫用户名枚举漏洞。

五个蓝图,18 个接口怎么摆

一个模块一个文件
后端骨架:一个入口装配,五个蓝图分工,横切能力全局生效app.py(入口)create_app() 工厂装配Flask app 实例(注册扩展 + 蓝图)user 蓝图article 蓝图comment 蓝图favorite 蓝图banner 蓝图统一响应 + 全局异常errorhandler 收口@login_requiredJWT 校验装饰器SQLAlchemy 模型7 张表逐字对应 hiwen.sql蓝图 = 模块化的路由包:URL 前缀 /api/user、/api/article……一个模块一个文件,互不搅和
先讲为什么18 个接口全写在一个文件里,那个文件会到 800 行。三个月后要改「收藏」的返回字段,你得先翻五分钟找到它。
蓝图(Blueprint)就是给路由分文件:一个业务模块一个文件,还自带 URL 前缀。
蓝图文件 变量与前缀 接口数 负责的接口
views/home.pyhome_bp · /api4health、banner/list、category/list、article/recommend
views/user.pyuser_bp · /api/user6register、login、profile(GET/PUT)、avatar、logout
views/article.pyarticle_bp · /api/article3list、detail/<id>、like
views/comment.pycomment_bp · /api/comment4list、add、like、delete/<id>
views/favorite.pyfavorite_bp · /api/favorite2toggle、list
合计5 个蓝图1918 个业务接口 + 1 个自测用健康检查
动手做先用一个 views/__init__.py 把蓝图集中注册,app.py 只调一个函数、不关心有几个模块;再照阶段 2 的查询写一个代表作接口。
views/__init__.pypython
"""蓝图集中注册。app.py 只调用 register_blueprints(app),不关心有几个模块。"""

from views.article import article_bp
from views.comment import comment_bp
from views.favorite import favorite_bp
from views.home import home_bp
from views.user import user_bp

ALL_BLUEPRINTS = (home_bp, user_bp, article_bp, comment_bp, favorite_bp)


def register_blueprints(app) -> None:
    for bp in ALL_BLUEPRINTS:
        app.register_blueprint(bp)
运行输出Python 运行结果
运行结果: $ flask --app app routes Endpoint Methods Rule ---------------------------- -------- --------------------------------- article.article_detail GET /api/article/detail/<int:aid> article.article_like POST /api/article/like article.article_list GET /api/article/list comment.comment_add POST /api/comment/add comment.comment_delete DELETE /api/comment/<int:cid> comment.comment_like POST /api/comment/like comment.comment_list GET /api/comment/list favorite.favorite_list GET /api/favorite/list favorite.favorite_toggle POST /api/favorite/toggle home.article_recommend GET /api/article/recommend home.banner_list GET /api/banner/list home.category_list GET /api/category/list home.health GET /api/health static GET /static/<path:filename> user.user_avatar POST /api/user/avatar user.user_login POST /api/user/login user.user_logout POST /api/user/logout user.user_profile GET /api/user/profile user.user_profile_update PUT /api/user/profile user.user_register POST /api/user/register

其中 article/list 是参数最多的一个接口——六个查询参数逐段叠加到同一个 query 上,最后统一排序和分页。把它的写法吃透,剩下的接口都是同一个套路。

views/article.py · 作品列表python
from flask import Blueprint, request

from extensions import db
from models.article import Article
from utils.response import page_data, success

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


def parse_page():
    """分页参数统一夹取,避免 page=0 / page_size=99999 打崩数据库。"""
    page = request.args.get("page", 1, type=int)
    page_size = request.args.get("page_size", 10, type=int)
    return max(page, 1), min(max(page_size, 1), 50)


@article_bp.get("/list")
def article_list():
    """作品列表:支持 page / page_size / category_id / dynasty / keyword / order_by"""
    page, page_size = parse_page()
    category_id = request.args.get("category_id", type=int)
    dynasty = request.args.get("dynasty")
    keyword = request.args.get("keyword")
    order_by = request.args.get("order_by", "latest")

    query = Article.query.filter(Article.status == 1)

    # 条件逐段叠加到同一个 query 上,有几段加几段
    if category_id:
        query = query.filter(Article.category_id == category_id)
    if dynasty:
        query = query.filter(Article.dynasty == dynasty)
    if keyword:
        like = f"%{keyword}%"
        query = query.filter(
            db.or_(Article.title.like(like), Article.summary.like(like))
        )

    # 排序必须带次关键字 id,否则同值记录顺序不保证,翻页会重复或漏条
    if order_by == "hot":
        query = query.order_by(Article.views.desc(), Article.id.desc())
    elif order_by == "likes":
        query = query.order_by(Article.likes.desc(), Article.id.desc())
    else:
        query = query.order_by(Article.created_at.desc(), Article.id.desc())

    page_obj = db.paginate(query, page=page, per_page=page_size, error_out=False)
    return success(
        page_data(
            [a.to_list_dict() for a in page_obj.items],
            page_obj.total,
            page,
            page_size,
        )
    )
运行输出Python 运行结果
运行结果: $ curl -s "http://127.0.0.1:5000/api/article/list?dynasty=%E5%94%90&order_by=hot" \ | python -m json.tool { "code": 200, "msg": "success", "data": { "list": [ { "id": 1, "title": "静夜思", "author": "李白", "dynasty": "唐", "category_name": "唐诗", "cover": "", "summary": "床前明月光,疑是地上霜。举头望明月,低头思故乡。", "views": 328, "likes": 42, "created_at": "2026-10-12 10:00:00" } ], "total": 3, "page": 1, "page_size": 10 } } (唐代作品共 3 篇,total 反映的是筛选后的总数,不是整表行数)
讲透:写接口的三个固定套路
  • 参数校验前置:先 request.get_json(silent=True) or {} 取参数,校验不过立刻 return fail(...)——绝不让脏数据走到数据库。
  • 分页参数一定夹取:page_size 上限 50。不夹取的话,一个 page_size=99999999 就能把服务器内存拖垮,这是最常见的「自己 DDoS 自己」。
  • 列表和详情的返回字段不一样:列表不带 content(正文很长,10 条就是几十 KB),详情才带。所以模型里分了 to_list_dict() 和 to_detail_dict()——省带宽不是抠门,是基本素养。
避坑三个容易翻车的点:① url_prefix 别写重——home_bp 的前缀是 /api,它的 banner/list 才是 /api/banner/list;要是再写成 /api/banner,路径就变成 /api/banner/banner/list 了。
② 点赞 / 收藏这类「重复操作要幂等」的接口,别用「先查再插」的裸写法——两个人同时点,两个请求都查到「没点过」,会同时 INSERT,结果撞上唯一键 uk_like 抛 IntegrityError。正确做法是用 try / except 捕获后 rollback(),当成「已点赞」正常返回。
③ 写完记得对一遍清单:flask --app app routes 的输出要能对上接口文档的 18 行。

Postman 自测:跑通 M3

注册 → 登录 → 拿 token → 查个人信息
Postman 跑通 M3:四步主链路,每步的产出是下一步的输入① 注册POST /api/user/register拿到 id=1 的用户② 登录POST /api/user/login拿到 token(复制)③ 设 tokenAuthorization Bearer粘贴到环境变量④ 查信息GET /api/user/me返回用户信息 ✓M3 达标 = 这条链在 Postman 全绿,且异常分支也对· 重复注册 → 提示「用户名已存在」 · 错误密码 → 401 · 不带 token 查 /me → 401· 过期 token → 401 · 正常 token → 200 用户信息Postman 环境变量保存 token:登录后 Tests 里一行脚本自动写入,后续接口自动携带
先讲为什么「我测过了」这句话在项目里要能拿出证据。curl 适合临时验证,但要留下可重复执行的验收记录,得用 Postman 集合:环境变量帮你管理 token,Tests 脚本自动断言,一键 Run 就能跑完整条链路——这就是里程碑 M3 的验收现场。
动手做 ①导入集合 pyweb/assets/postman/hiwen.postman_collection.json,然后新建环境「古典文学网-本地」,加三个变量。这一步最容易漏的是在右上角下拉框里选中这个环境——没选中,{{base_url}} 会原样发出去,你会收到一堆「无法连接」。
Postman 环境与 Tests.jsjavascript
// ① 环境变量(三个)
//    base_url    = http://127.0.0.1:5000/api
//    token       =            (留空,登录后自动写入)
//    article_id  = 1

// ② 在【登录】请求的 Tests 标签里写断言:既验证结果,又把 token 存下来
pm.test("登录成功并拿到 token", function () {
    const body = pm.response.json();
    pm.expect(body.code).to.eql(200);
    pm.expect(body.data.token).to.be.a("string");
});

// ③ 写入环境变量,后面的请求用 {{token}} 自动带上
pm.environment.set("token", pm.response.json().data.token);

// ④ 需要登录的请求,在 Headers 里加一行:
//    Authorization : Bearer {{token}}      ← Bearer 后面必须有一个空格
页面效果浏览器运行结果
运行结果: Test Results PASS 登录成功并拿到 token 环境变量已更新:token = eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9....
动手做 ②不想开 Postman 的时候,用这四条 curl 也能把 M3 链路跑通——先注册(种子用户的密码是占位符,登不进去),再登录拿 token,最后带着 token 查个人信息。
M3 自测.shbash可运行
BASE=http://127.0.0.1:5000/api

# ① 注册(用户名重复会返回 400,换一个即可)
curl -s -X POST $BASE/user/register \
     -H "Content-Type: application/json" \
     -d '{"username": "zhangsan", "password": "123456", "nickname": "张三"}' \
     | python -m json.tool

# ② 登录,把 token 存进 shell 变量
TOKEN=$(curl -s -X POST $BASE/user/login \
        -H "Content-Type: application/json" \
        -d '{"username": "zhangsan", "password": "123456"}' \
        | python -c "import sys, json; print(json.load(sys.stdin)['data']['token'])")

# ③ 带着 token 查个人信息
curl -s $BASE/user/profile -H "Authorization: Bearer $TOKEN" | python -m json.tool

# ④ 反证:不带 token 应该被挡回来
curl -s $BASE/user/profile | python -m json.tool
终端输出命令行执行结果
运行结果: -- ① 注册 { "code": 200, "msg": "注册成功", "data": { "user_id": 3 } } -- ③ 查个人信息(带 token) { "code": 200, "msg": "success", "data": { "id": 3, "username": "zhangsan", "nickname": "张三", "email": null, "avatar": "", "intro": null, "gender": 0, "created_at": "2026-10-12 10:30:00", "stat": { "comment_count": 0, "favorite_count": 0 } } } -- ④ 反证(不带 token) { "code": 401, "msg": "请先登录", "data": null }
讲透:为什么 M3 专挑这四步
  • 它一次性压到了整条技术链:HTTP 请求解析 → 参数校验 → ORM 写库 → 密码哈希 → JWT 签发 → 请求头解析 → 身份还原 → 序列化输出。这四步过了,说明骨架是好的。
  • 第 ④ 步的「反证」不能省:只测正向路径,永远发现不了「鉴权根本没生效」——因为接口本来就返回 200。带上一条 401 的反例,才叫真的验证过。
  • 为什么先注册再登录:种子脚本里 admin / libai 的密码是占位符 PUT_HASH_HERE,用它们登录必然失败。这是设计成这样的,不是 bug。
  • 集合要提交到仓库:assets/postman/hiwen.postman_collection.json 是阶段 5 测试用例的起点,也是阶段 7 答辩时「我测过」的证据。
避坑Postman 里最常见的三个「不是 bug 的 bug」:① 环境没选中,{{base_url}} 原样发出去,报连接失败;② Body 没选 raw + JSON,后端 get_json(silent=True) 拿到 None,于是提示「缺少参数」;③ 用 GET 请求发 Body,参数被丢掉。
还有一条通用排查法:先看后端的启动日志。请求有没有打进来、报的是哪个异常,日志里写得比 Postman 清楚得多——前端报「网络错误」而后端日志干干净净,那就八成是 CORS 或地址写错,不是业务代码的问题。

后端验收标准清单

逐条打勾,M3 才算过
先讲为什么「后端做完了」是个主观判断,验收标准要写成一张客观的清单。
下面这份清单的每一条都配了「怎么验」——只要能当场演示,就没有争议。这也是阶段 5 写测试用例的底稿。
# 验收项 合格标准 怎么验
1服务能启动无报错,监听 127.0.0.1:5000python app.py 看启动日志
2数据库连通健康检查返回 code 200、status: upGET /api/health
3接口齐全路由表列出 18 个业务接口 + 1 个健康检查flask --app app routes 逐行对接口文档
4响应统一所有接口都是 {code, msg, data} 三个字段Postman 逐个看,或全局搜 jsonify 确认没有漏网的
5注册可用返回 user_id,库里多一条记录,密码是哈希注册后查 SELECT username, password FROM user
6登录可用返回 token 与 user 四字段;密码错时统一提示POST /api/user/login,再用错密码试一次
7鉴权生效不带 token 访问需登录接口返回 401curl /api/user/profile(不带 Authorization)
8越权被拦删别人的书评返回 403,不是删成功用 A 的 token 删 B 的书评
9不泄露敏感字段任何响应里都搜不到 password在 Postman 里全局搜索响应内容
10分页正确total 是筛选后的总数;page_size=99999 被夹到 50/api/article/list?page=2&page_size=2
11浏览量自增连续访问详情两次,views 每次 +1连打两次 /api/article/detail/1
12异常不脏会话连续打错接口后,正常接口依然能用故意传错参数刷几次,再请求列表
13M3 里程碑Postman 集合一键 Run 全绿Run Collection,截图留档
讲透:这份清单后面还有第二次用处
  • 阶段 4 联调时:前端报错先按这张表自查一遍——「接口本身通了没?」能省掉一大半扯皮。
  • 阶段 5 写用例时:清单里的「怎么验」几乎可以原样变成测试用例;每条正向用例再配一条异常用例(缺参数、错 token、越权),用例就齐了。
  • 阶段 7 答辩时:老师问「你怎么证明后端是好的」,你把这张表和 Postman 的运行截图一起拿出来——比说十句「我都测过了」有用得多。
动手练习
1补全 PUT /api/user/profile:只允许改 nickname / intro / gender 三个字段。写完后自问:为什么不能让前端直接改 status 和 password?
2实现收藏接口 POST /api/favorite/toggle:同一个接口既收藏又取消。写完后想一想,「先查再插」在两个人同时点击时会出什么问题,怎么用 utry/except` 兜住。
3迁移练习:「红影赏析」要加一个 GET /api/movie/list 接口(分页 + 类型筛选 + 评分排序)。请照着 article/list 的套路写出视图函数,并说明:哪些地方是「古典文学网」特有的(比如朝代),必须换成电影的字段?
参考答案
练习答案.pypython
# ---------- 第 1 题:只允许改白名单里的字段 ----------
@user_bp.put("/profile")
@login_required
def user_profile_update():
    data = request.get_json(silent=True) or {}

    # 逐个挑,绝不 setattr(user, k, v) 地整包写入
    if "nickname" in data:
        nickname = (data.get("nickname") or "").strip()
        if not (1 <= len(nickname) <= 50):
            return fail("昵称长度需为 1-50 位")
        g.current_user.nickname = nickname
    if "intro" in data:
        g.current_user.intro = (data.get("intro") or "").strip()
    if "gender" in data:
        if data.get("gender") not in (0, 1, 2):
            return fail("性别取值不合法")
        g.current_user.gender = data.get("gender")

    db.session.commit()
    return success(None, "修改成功")


# ---------- 第 2 题:收藏 / 取消收藏(toggle 三步骨架) ----------
@favorite_bp.post("/toggle")
@login_required
def favorite_toggle():
    data = request.get_json(silent=True) or {}
    article_id = data.get("article_id")
    if not article_id:
        return fail("缺少参数:article_id")

    article = db.session.get(Article, article_id)
    if article is None or article.status != 1:
        return fail("作品不存在", code=404)

    uid = g.current_user.id
    row = Favorite.query.filter_by(user_id=uid, article_id=article_id).first()
    try:
        if row:                      # 已收藏 → 取消
            db.session.delete(row)
            favorited = False
        else:                        # 未收藏 → 收藏
            db.session.add(Favorite(user_id=uid, article_id=article_id))
            favorited = True
        db.session.commit()
    except IntegrityError:
        # 并发连点:另一个请求刚好插进去了,唯一键 uk_fav_user_article 报错
        db.session.rollback()
        return success({"favorited": True}, "操作成功")

    return success({"favorited": favorited}, "操作成功")


# ---------- 第 3 题(红影赏析:换字段名,不换骨架) ----------
movie_bp = Blueprint("movie", __name__, url_prefix="/api/movie")


@movie_bp.get("/list")
def movie_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)
    genre = request.args.get("genre")
    order_by = request.args.get("order_by", "latest")

    query = Movie.query
    if genre:                                   # 古典文学网这里是 dynasty
        query = query.filter(Movie.genre == genre)

    if order_by == "rating":                    # 古典文学网这里是 views / likes
        query = query.order_by(Movie.rating.desc(), Movie.id.desc())
    else:
        query = query.order_by(Movie.created_at.desc(), Movie.id.desc())

    page_obj = db.paginate(query, page=page, per_page=page_size, error_out=False)
    return success(
        page_data(
            [m.to_list_dict() for m in page_obj.items],
            page_obj.total, page, page_size,
        )
    )
运行输出Python 运行结果
运行结果: -- 第 2 题:连着调两次 toggle,状态来回切 $ curl -s -X POST http://127.0.0.1:5000/api/favorite/toggle \ -H "Content-Type: application/json" -H "Authorization: Bearer $TOKEN" \ -d '{"article_id": 3}' | python -m json.tool { "code": 200, "msg": "操作成功", "data": { "favorited": true } } $ curl -s -X POST http://127.0.0.1:5000/api/favorite/toggle \ -H "Content-Type: application/json" -H "Authorization: Bearer $TOKEN" \ -d '{"article_id": 3}' | python -m json.tool { "code": 200, "msg": "操作成功", "data": { "favorited": false } }

第 1 题的关键词是白名单:能改的字段一个一个列出来,绝不照着请求体整包写入。否则前端只要加一个 "status": 0,就能把自己禁用掉。第 3 题的关键:换的是字段和业务词(朝代 → 类型、浏览量 → 评分),不变的是「夹取分页 → 逐段叠加查询条件 → 统一排序 → 分页返回」这套骨架。

本阶段小结

📝 本节自测

做完点「检查本节」看解析与得分;做错可重做。