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

第 13 章 · 项目后端实战

这是课程二的收口。前面 12 章学的是零件——路由、请求响应、上下文、蓝图、ORM、JWT、上传、测试。这一章把它们装成一台能跑的机器:按《API 接口文档》实现全部 18 个接口,再用 Postman 走通「注册 → 登录 → token → 个人信息 → 发书评 → 点赞 → 收藏」整条链路。走通它,你就拿到了里程碑 M3。

总览:18 个接口与 M3

实现清单就是接口文档
18 个接口,按模块归位 用户模块6注册/登录/信息/改资料/头像/退出首页模块3轮播/分类/文学推荐作品模块3列表/详情/点赞书评模块4列表(楼中楼)/发表/点赞/删除收藏模块1toggle 收藏/取消系统1健康检查 /api/health6 + 3 + 3 + 4 + 1 + 1 = 18 个接口,恰好是 M3 的实现清单

「古典文学网」后端共 18 个接口,按业务拆成 6 个模块。这张分布图就是 M3 阶段的实现清单,也是后面逐模块开发的地图。

先讲为什么真实项目不是「想到哪写到哪」,而是先和前端把接口谈定,再照着实现。这份谈定的结果就是 《古典文学网 · 后端 API 接口文档》——它规定了每个接口的路径、方法、请求参数、响应结构、错误码。你的任务不是设计接口,而是把文档一条条变成代码。
这么做的好处很实在:接口一定下来,前端就能并行开工(先用 Mock 数据),你只管把后端填满,联调时谁也不用猜。

先把清单摊开。18 个接口按模块分成五组,先实现公开的读接口(首页 + 列表),再实现写接口(注册登录 + 书评收藏),最后用 Postman 串起来——这个顺序能让你每写完一组就有东西可验,不会攒到最后一起爆。

接口清单(按模块归并)text
模块    函数名(蓝图.视图)              方法     路径
------  -----------------------------  -------  ------------------------------
用户    user.user_register             POST     /api/user/register
        user.user_login                POST     /api/user/login
        user.user_profile              GET      /api/user/profile          【需登录】
        user.user_profile_update       PUT      /api/user/profile          【需登录】
        user.user_avatar               POST     /api/user/avatar           【需登录】
        user.user_logout               POST     /api/user/logout           【需登录】
首页    home.banner_list               GET      /api/banner/list
        home.category_list             GET      /api/category/list
        home.article_recommend         GET      /api/article/recommend
作品    article.article_list           GET      /api/article/list
        article.article_detail         GET      /api/article/detail/<int:aid>
        article.article_like           POST     /api/article/like          【需登录】
书评    comment.comment_list           GET      /api/comment/list
        comment.comment_add            POST     /api/comment/add           【需登录】
        comment.comment_like           POST     /api/comment/like          【需登录】
        comment.comment_delete         DELETE   /api/comment/<int:cid>      【需登录】
收藏    favorite.favorite_toggle       POST     /api/favorite/toggle       【需登录】
        favorite.favorite_list         GET      /api/favorite/list         【需登录】
------  -----------------------------  -------  ------------------------------
合计 18 个接口;另有 1 个自己加的 GET /api/health(冒烟自测用)
效果预览运行结果
运行结果: $ flask --app app routes # 18 条业务路由 + 1 条健康检查全部注册成功,没有 404 的「幽灵接口」 # Postman Collection Runner 一键跑完的结果 注册 PASS 登录 PASS → token 已写入环境变量 获取个人信息 PASS 发表书评 PASS → comment_id = 4 点赞作品 PASS → liked=true, likes=43 收藏作品 PASS → favorited=true 我的收藏 PASS → total=1 -- 7 个请求全绿,里程碑 M3 达成
讲透:M3 里程碑为什么是这 7 步
  • 它覆盖了全部技术栈:注册(参数校验 + 密码哈希 + INSERT)、登录(查询 + 校验 + JWT 签发)、个人信息(鉴权 + 聚合统计)、发书评(写库 + 外键)、点赞与收藏(多表 + toggle 逻辑)、我的收藏(多表 JOIN + 分页)。
  • 它是一条真实业务闭环:每一步都依赖上一步的产物(token、comment_id、article_id),任何一环断了后面都走不通。这比单独测 18 个接口有用得多。
  • 它是可演示的:答辩时打开 Postman 点 Runner,30 秒出结果。「能演示」是工程能力的硬指标。
  • 它是前端能直接对接的:这条链路跑通,课程三的 Vue 前端才有服务可连。

工程结构:应用工厂 + 蓝图

第 5 章的成果在这里兑现
工程结构:应用工厂 + 蓝图的分层 hiflask/ app.py · config.py 工厂与配置 models/ 数据模型(表 → 类) views/ 蓝图与路由(接口) utils/ 工具:响应封装 / JWT 这样分层的好处 · 模型只管数据结构 · 视图只管请求与响应 · 工具函数复用,不重复造 · 配置集中,换环境只改一处 · 第 5 章工厂的成果在这里兑现

hiflask 按职责分层:app.py/config.py 管工厂与配置,models/ 放数据模型,views/ 放蓝图与路由,utils/ 放响应封装等工具。改需求时能准确定位到该动哪一层。

先讲为什么如果 18 个接口全写在一个 app.py 里,文件会膨胀到上千行,找接口靠搜索、改一行怕影响全站。蓝图的作用就是把「一个模块的接口」装进一个盒子——用户、首页、作品、书评、收藏各一个盒子。出了问题你只需要打开对应的盒子,而不是翻整个文件。
配套的「应用工厂」解决另一个问题:同一份代码要在开发、测试、生产三种配置下跑,用函数创建应用,配置按参数注入,测试时一行就能换成内存库(第 12 章已经用过这招)。
动手做先看一眼项目骨架的目录。每个文件只干一件事,看到文件名就知道该去哪里改。
hiflask 目录结构text
hiflask/
├── app.py                  # 应用工厂 create_app() + 错误处理器 + 启动入口
├── config.py               # Config / DevConfig / ProdConfig / TestingConfig
├── extensions.py           # db / jwt / cors / migrate 实例(避免循环导入)
├── requirements.txt
├── .env.example            # 环境变量样例(真密码不进版本库)
├── models/                 # 数据层:一张表一个类
│   ├── __init__.py         # 统一导出,方便 from models import User
│   ├── user.py             # User
│   ├── article.py          # Category / Article / Banner
│   └── comment.py          # Comment / Favorite / UserLike
├── views/                  # 接口层:一个模块一个蓝图
│   ├── __init__.py         # register_blueprints(app)
│   ├── user.py             # user_bp    6 个接口
│   ├── home.py             # home_bp    3 个接口 + 健康检查
│   ├── article.py          # article_bp 3 个接口
│   ├── comment.py          # comment_bp 4 个接口
│   └── favorite.py         # favorite_bp 2 个接口
├── utils/
│   ├── response.py         # success() / fail(),统一 {code,msg,data}
│   └── auth.py             # @login_required 装饰器
└── tests/                  # 第 12 章的 pytest 用例
    ├── conftest.py
    ├── test_user_api.py
    └── test_article_api.py
效果预览运行结果
运行结果: # 分层之后,改动的影响范围是明确的: # 改接口的返回字段 → views/ 对应模块 # 改表结构 / 字段 → models/ 对应文件(记得同步改 hiwen.sql) # 改响应格式 / 错误码 → utils/response.py # 改鉴权规则 → utils/auth.py # 这就是「工程化」最直接的好处。
动手做extensions.py 里只放实例、不放 app;app.py 里用 init_app 把它们和 app 绑起来。顺序很重要:先 init 扩展,再注册蓝图——否则视图里用 db 会报「working outside of application context」。
app.pypython
from flask import Flask

from config import config_map
from extensions import cors, db, jwt, migrate
from utils.response import fail
from views import register_blueprints


def create_app(config_name="dev"):
    """应用工厂:按名字取配置,装配出一个完整的 app"""
    app = Flask(__name__)
    app.config.from_object(config_map[config_name])

    # ① 初始化扩展(顺序:db → jwt → cors → migrate)
    db.init_app(app)
    jwt.init_app(app)
    cors.init_app(app, resources={r"/api/*": {"origins": "*"}})
    migrate.init_app(app, db)

    # ② 注册蓝图:18 个接口在这里被挂到 app 上
    register_blueprints(app)

    # ③ 全局错误处理:一律返回统一结构,绝不把堆栈抛给前端
    register_error_handlers(app)
    return app


def register_error_handlers(app):
    # JWT 相关的异常交给 flask-jwt-extended 的回调,这里处理 404 / 405 / 500
    @app.errorhandler(404)
    def not_found(e):
        return fail("接口不存在", code=404)

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

    @app.errorhandler(Exception)
    def server_error(e):
        app.logger.exception("未捕获异常")      # 堆栈写日志,不给前端
        return fail("服务器内部错误", code=500)


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 * Running on http://127.0.0.1:5000 * Debugger is active! Press CTRL+C to quit $ curl -s http://127.0.0.1:5000/api/health { "code": 200, "msg": "success", "data": { "service": "hiflask", "status": "up" } }
讲透:为什么实例要单独放 extensions.py
  • 循环导入的根源:如果 db 定义在 app.py,那 models/user.py 要 from app import db,而 app.py 又要导入模型才能建表——互相等对方先加载,Python 直接报错。
  • 拆出去就断了环:extensions.py 谁都不依赖,模型和视图都从它导入,依赖方向变成一条直线。
  • init_app 的意义:实例是「无 app」的,可以在模块导入时就创建;真正的绑定推迟到 create_app() 里。所以同一份模型代码能被开发、测试、生产三个 app 共用。
  • CORS 那行别漏:前后端分离时前端跑在 5173、后端跑在 5000,属于跨域。漏了它,前端会收到「已被 CORS 策略阻止」——后端日志里却一点错都没有,这是新手最容易被绕进去的坑。

模型层:7 张表映射成 ORM

表名是单数,字段一个都不能错
模型层:7 张表 → 7 个 ORM 类 数据库表(hiwen.sql) ORM 模型类(models/)userUser 用户categoryCategory 分类articleArticle 作品commentComment 书评favoriteFavorite 收藏user_likeUserLike 点赞bannerBanner 轮播图

课程一建的 7 张表,在这里逐个映射成 ORM 模型类。注意表名是单数(user / article / comment),字段要与建表脚本一一对应,一个都不能错。

先讲为什么课程一的 hiwen.sql 已经把表建好了,ORM 模型的作用是给表起一个 Python 名字,让视图层能用对象的方式读写数据,而不是拼 SQL 字符串。
关键原则只有一条:模型必须和数据库里的表逐字对应——__tablename__ 写错一个字母、字段名少一个下划线,运行时就报「Unknown column」,而且报错信息不一定指得准。所以建模的时候,请把 hiwen.sql 和模型文件并排放着看。
动手做先映射最核心的两张:user 和 article。注意 updated_at 用 onupdate=datetime.now ——它对应 SQL 里的 ON UPDATE CURRENT_TIMESTAMP。
models/user.py 与 models/article.py(节选)python
# ---------- models/user.py ----------
from datetime import datetime
from werkzeug.security import check_password_hash, generate_password_hash

from extensions import db


class User(db.Model):
    __tablename__ = "user"          # 表名是单数,别写成 users

    id = db.Column(db.Integer, primary_key=True)
    username = db.Column(db.String(50), nullable=False, unique=True)
    password = db.Column(db.String(255), nullable=False)   # 存哈希,不是明文
    nickname = db.Column(db.String(50))
    email = db.Column(db.String(100), unique=True)
    avatar = db.Column(db.String(255), default="")
    intro = db.Column(db.String(255))
    gender = db.Column(db.SmallInteger, default=0)
    status = db.Column(db.SmallInteger, nullable=False, default=1)
    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)

    # 关系:一个用户有多条书评、多条收藏(lazy="dynamic" 便于再链式查询)
    comments = db.relationship("Comment", back_populates="user", lazy="dynamic")
    favorites = db.relationship("Favorite", back_populates="user", lazy="dynamic")

    def set_password(self, raw):
        self.password = generate_password_hash(raw)

    def check_password(self, raw):
        return check_password_hash(self.password, raw)

    def to_dict(self):
        """对外输出:绝不含 password 字段"""
        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"),
        }


# ---------- models/article.py ----------
class Article(db.Model):
    __tablename__ = "article"

    id = db.Column(db.Integer, primary_key=True)
    title = db.Column(db.String(150), nullable=False)
    author = db.Column(db.String(50))
    dynasty = db.Column(db.String(20))
    category_id = db.Column(db.Integer, db.ForeignKey("category.id"))
    cover = db.Column(db.String(255), default="")
    summary = db.Column(db.String(500))
    content = db.Column(db.Text)
    views = db.Column(db.Integer, nullable=False, default=0)
    likes = db.Column(db.Integer, nullable=False, default=0)
    is_recommend = db.Column(db.SmallInteger, nullable=False, default=0)
    status = db.Column(db.SmallInteger, nullable=False, default=1)
    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)

    category = db.relationship("Category", back_populates="articles")
    comments = db.relationship("Comment", back_populates="article", lazy="dynamic")
    favorited_by = db.relationship("Favorite", back_populates="article", lazy="dynamic")

    def to_list_dict(self):
        """列表用:字段少一点,不含正文 content"""
        return {
            "id": self.id,
            "title": self.title,
            "author": self.author,
            "dynasty": self.dynasty,
            "category_name": self.category.name if self.category else "",
            "cover": self.cover or "",
            "summary": self.summary,
            "views": self.views,
            "likes": self.likes,
            "created_at": self.created_at.strftime("%Y-%m-%d %H:%M:%S"),
        }
运行输出Python 运行结果
运行结果: $ python -c "from app import create_app; from extensions import db; \ app = create_app('dev'); \ print([t for t in db.metadata.tables.keys()])" ['user', 'category', 'article', 'comment', 'favorite', 'user_like', 'banner'] # 表名与 hiwen.sql 完全一致;用 Python 交互式终端核对一遍最省心 $ python -c "from app import create_app; from models import User; \ app = create_app('dev'); app.app_context().push(); \ print(User.query.count())"
动手做(续)剩下三张表一起补上:comment 的自关联(楼中楼)、favorite 的联合唯一键、user_like 的通用点赞。
models/comment.py(节选)python
class Comment(db.Model):
    __tablename__ = "comment"

    id = db.Column(db.Integer, primary_key=True)
    user_id = db.Column(db.Integer, db.ForeignKey("user.id"), nullable=False)
    article_id = db.Column(db.Integer, db.ForeignKey("article.id"), nullable=False)
    parent_id = db.Column(db.Integer, db.ForeignKey("comment.id"))   # 自关联
    content = db.Column(db.String(1000), nullable=False)
    likes = db.Column(db.Integer, nullable=False, default=0)
    status = db.Column(db.SmallInteger, nullable=False, default=1)   # 1 正常 0 已删
    created_at = db.Column(db.DateTime, nullable=False, default=datetime.now)

    user = db.relationship("User", back_populates="comments")
    article = db.relationship("Article", back_populates="comments")

    # 自关联:remote_side 必须指到「一」那一端(父评论的主键)
    parent = db.relationship("Comment", remote_side=[id], back_populates="children")
    children = db.relationship("Comment", back_populates="parent",
                               order_by="Comment.created_at")


class Favorite(db.Model):
    __tablename__ = "favorite"
    __table_args__ = (
        db.UniqueConstraint("user_id", "article_id", name="uk_fav_user_article"),
    )

    id = db.Column(db.Integer, primary_key=True)
    user_id = db.Column(db.Integer, db.ForeignKey("user.id"), nullable=False)
    article_id = db.Column(db.Integer, db.ForeignKey("article.id"), nullable=False)
    created_at = db.Column(db.DateTime, nullable=False, default=datetime.now)

    user = db.relationship("User", back_populates="favorites")
    article = db.relationship("Article", back_populates="favorited_by")


class UserLike(db.Model):
    __tablename__ = "user_like"
    __table_args__ = (
        db.UniqueConstraint("user_id", "target_type", "target_id", name="uk_like"),
    )

    id = db.Column(db.Integer, primary_key=True)
    user_id = db.Column(db.Integer, nullable=False)
    # 通用点赞:通过 target_type 区分点赞的是作品还是书评
    target_type = db.Column(db.Enum("article", "comment", name="like_target_type"),
                            nullable=False)
    target_id = db.Column(db.Integer, nullable=False)
    created_at = db.Column(db.DateTime, nullable=False, default=datetime.now)
运行输出Python 运行结果
运行结果: # 自关联验证:查「某条书评的回复」(种子里暂时没有回复,所以是空列表) >>> c = Comment.query.get(1) >>> c.content '短短二十字,写尽思乡之情。' >>> c.children [] # 想演示楼中楼就先插一条回复(parent_id 指向 1),插入后再查: # INSERT INTO comment (user_id, article_id, parent_id, content) # VALUES (2, 1, 1, '同感!'); >>> Comment.query.get(1).children[0].content '同感!'
讲透:7 张表的分工
  • user / category / article / banner——基础数据,都是「一张表一件事」。
  • comment——书评。parent_id 指向自己这张表的主键,实现「回复的回复」,也就是楼中楼。
  • favorite——收藏。它是典型的中间表:只存「谁收藏了哪篇」,再配一个 (user_id, article_id) 联合唯一键,让同一个人不可能对同一篇收藏两次。
  • user_like——通用点赞。用 target_type 区分作品还是书评,一张表服务两种点赞,避免再建两张几乎一样的表。
  • 视图 v_article_list 不进模型:它只是「作品 + 分类名」的便捷查询。ORM 里我们用 relationship 取 article.category.name 达到同样效果,比再映射一个视图简单。
避坑三个和「表」有关的坑,改到就是灾难:
① 别用 db.create_all() 去动已有数据库。它只负责「建还不存在的表」,不会改字段、不会删列;表结构要变,请用 Flask-Migrate(flask db migrate → flask db upgrade)或者直接改 hiwen.sql 后重建。
② 不写 __tablename__ 会默认用类名小写,UserLike 会变成 user_like(碰巧对),但 Article 会变成 article(也对),可 Comment 变 comment——看着都对,一旦类名改成 ArticleModel 就全错了。显式写出来,别靠巧合。
③ email 是唯一键但允许为 NULL:MySQL 里多个 NULL 不算重复,所以「不填邮箱」可以有很多个,不要用「查不到就是没填」来判断逻辑。

用户模块(6 个接口)

注册 / 登录 / 个人信息 / 改资料 / 头像 / 退出
用户模块(6 个接口) 注册 POST /user/register 登录 POST /user/login 个人信息 GET /user/me 改资料 PUT /user/me 上传头像 POST /user/avatar 退出登录 POST /user/logout

用户模块 6 个接口:注册、登录(签发 JWT)、查看与修改个人信息、上传头像、退出。其中「改资料 / 头像 / 退出」都需要登录态,靠 JWT 鉴权保护。

先讲为什么用户模块是整条业务链的入口:后面所有「需登录」的接口,都依赖这里发的 token。它也是接口设计规范最密集的地方——参数校验前置、密码只存哈希、错误提示统一、敏感字段不外泄。这四件事在注册和登录里全都会出现,写好了,后面的模块照着抄就行。
动手做先写注册和登录这两个关键接口。注意注册里四道校验的顺序:先校验格式,再查重,最后才写库——把无效请求挡在数据库之前。
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.comment import Comment, Favorite
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():
    """① 注册:参数校验前置,密码只存哈希"""
    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("用户名已存在")

    email = (data.get("email") or "").strip() or None
    if email and User.query.filter_by(email=email).first():
        return fail("邮箱已被注册")

    # 昵称不填就用用户名兜底——对应接口文档里 nickname「不填则默认取 username」
    nickname = (data.get("nickname") or "").strip() or username

    user = User(username=username, nickname=nickname, email=email)
    user.set_password(password)
    db.session.add(user)
    db.session.commit()
    return success({"user_id": user.id}, "注册成功")


@user_bp.post("/login")
def user_login():
    """② 登录:签发 JWT,前端后续用 Authorization: Bearer <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": {
            "id": user.id,
            "username": user.username,
            "nickname": user.nickname,
            "avatar": user.avatar or "",
        },
    }, "登录成功")
运行输出Python 运行结果
运行结果: # ① 注册 $ curl -s -X POST http://127.0.0.1:5000/api/user/register \ -H "Content-Type: application/json" \ -d '{"username": "zhangsan", "password": "123456", "nickname": "张三"}' { "code": 200, "msg": "注册成功", "data": { "user_id": 3 } } # 重复注册同一个用户名 { "code": 400, "msg": "用户名已存在", "data": null } # ② 登录 $ curl -s -X POST http://127.0.0.1:5000/api/user/login \ -H "Content-Type: application/json" \ -d '{"username": "zhangsan", "password": "123456"}' { "code": 200, "msg": "登录成功", "data": { "token": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiIzIn0...", "user": { "id": 3, "username": "zhangsan", "nickname": "张三", "avatar": "" } } }
动手做(续)第三个要写的是「获取个人信息」。它比看上去多做一件事:顺便把「书评数 / 收藏数」算出来,让个人中心页不用再发两次请求。
views/user.py(个人信息)python
@user_bp.get("/profile")
@login_required
def user_profile():
    """③ 获取个人信息:@login_required 校验通过后,g.current_user 就是当前用户"""
    user = g.current_user

    data = user.to_dict()
    # stat 是给个人中心首页用的聚合统计,省掉前端两次额外的请求
    data["stat"] = {
        "comment_count": Comment.query.filter_by(user_id=user.id, status=1).count(),
        "favorite_count": Favorite.query.filter_by(user_id=user.id).count(),
    }
    return success(data)
运行输出Python 运行结果
运行结果: $ 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": "", "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 }
讲透:剩下 3 个接口的要点
  • 修改个人信息 PUT /api/user/profile:只更新请求体里传了的字段,所以用字段白名单遍历,而不是把整个 data 往模型上塞(否则前端能顺手改 status、password)。
    for f in ("nickname", "intro", "gender"): if f in data: setattr(g.current_user, f, data[f])
  • 上传头像 POST /api/user/avatar:第 9 章的活。取 request.files["file"] → 校验后缀在 {"jpg","png","gif"} 内 → 校验大小 ≤ 2MB → 用 uuid + 后缀 生成安全文件名(绝不用用户给的文件名)→ 存到 static/upload/avatar/ → 把路径写回 user.avatar → 返回 {"avatar": "/static/upload/avatar/xxx.png"}。
  • 退出登录 POST /api/user/logout:JWT 是无状态的,服务端「删不掉」已签发的 token。真正的退出由前端删掉本地 token 完成;这个接口的存在意义是留个位置记录日志,或者未来做 token 黑名单。别在这里假装清除了服务端会话——那是 Session 时代的做法。
  • 统一用 utils/response.py 的 success/fail:全站响应结构只有一个来源,哪天要加个 timestamp 字段,改一个文件就够。
避坑两个必须记住的雷:
① 密码绝不能明文入库。用 set_password() 里的 generate_password_hash(),它会自动加盐并做多次哈希;校验用 check_password_hash()。顺便提醒:hiwen.sql 里两个种子用户的密码是占位符 PUT_HASH_HERE,用他们是登不进去的——请自己注册一个新账号来跑 M3,或者先执行文档里那条命令生成哈希再 UPDATE user SET password='...'。
② to_dict() 里永远不要放 password。接口文档第八节写得很明确:「不返回敏感字段」。更稳的做法是像本章这样手写白名单字典,而不是 {c.name: getattr(self, c.name) for c in self.__table__.columns}——后者会把哈希一起吐出去,前端 F12 一看就泄漏了。

首页模块(3 个接口)

轮播图 / 分类 / 文学推荐
先讲为什么这三个接口全是公开的只读查询,不需要登录,是首页一打开就要并发发出去的请求。它们的共同特点是:逻辑极简、返回列表、没有分页(数据量天生很小)。
正因为简单,它们最适合用函数视图直接写——第 11 章说过,不要为了「统一风格」给轮播图列表套一个类视图,那是自找麻烦。
动手做三个接口一起写,注意每个都要加 status=1 的过滤条件——后台把轮播图下架、把分类隐藏,靠的就是这个字段。
views/home.pypython
from flask import Blueprint, request

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

home_bp = Blueprint("home", __name__, url_prefix="/api")


@home_bp.get("/health")
def health():
    """健康检查:自己加的,用来判断服务和数据库是否都通"""
    db.session.execute(db.text("SELECT 1"))   # 顺带探一下数据库
    return success({"service": "hiflask", "status": "up"})


@home_bp.get("/banner/list")
def banner_list():
    """① 轮播图:按 sort 升序,sort 相同的按 id"""
    rows = (Banner.query
            .filter_by(status=1)
            .order_by(Banner.sort.asc(), Banner.id.asc())
            .all())
    return success([
        {"id": b.id, "title": b.title, "image": b.image, "link": b.link or ""}
        for b in rows
    ])


@home_bp.get("/category/list")
def category_list():
    """② 分类:同样是 sort 升序,前端拿它渲染筛选下拉框"""
    rows = (Category.query
            .filter_by(status=1)
            .order_by(Category.sort.asc(), Category.id.asc())
            .all())
    return success([
        {"id": c.id, "name": c.name, "description": c.description or ""}
        for c in rows
    ])


@home_bp.get("/article/recommend")
def article_recommend():
    """③ 文学推荐:is_recommend = 1,按浏览量倒序,limit 可控"""
    limit = request.args.get("limit", 6, type=int)
    limit = min(max(limit, 1), 20)          # 兜住非法值,最多 20 条

    rows = (Article.query
            .filter_by(status=1, is_recommend=1)
            .order_by(Article.views.desc(), Article.id.desc())
            .limit(limit)
            .all())
    return success([a.to_list_dict() for a in rows])
运行输出Python 运行结果
运行结果: # 轮播图:种子数据里正好 3 条 $ curl -s http://127.0.0.1:5000/api/banner/list { "code": 200, "msg": "success", "data": [ { "id": 1, "title": "品读古典文学,涵养中国心灵", "image": "/static/images/banner1.jpg", "link": "/article/list?category_id=1" }, { "id": 2, "title": "一场跨越千年的对话", "image": "/static/images/banner2.jpg", "link": "/article/list?category_id=2" }, { "id": 3, "title": "写下你的书评,与同好相遇", "image": "/static/images/banner3.jpg", "link": "/user/center" } ] } # 分类:种子数据里 5 条 $ curl -s http://127.0.0.1:5000/api/category/list { "code": 200, "msg": "success", "data": [ { "id": 1, "name": "唐诗", "description": "唐代诗歌,格律严谨、气象恢宏" }, { "id": 2, "name": "宋词", "description": "宋代词作,婉约与豪放并存" }, { "id": 3, "name": "元曲", "description": "元代散曲与杂剧,通俗生动" }, { "id": 4, "name": "散文", "description": "先秦至明清的散文名篇" }, { "id": 5, "name": "小说", "description": "章回体与笔记体小说" } ] } # 文学推荐:is_recommend=1 的是 1、2、3 号作品,按浏览量倒序 $ curl -s "http://127.0.0.1:5000/api/article/recommend?limit=3" { "code": 200, "msg": "success", "data": [ { "id": 3, "title": "念奴娇·赤壁怀古", "author": "苏轼", "dynasty": "宋", "category_name": "宋词", "cover": "", "summary": "大江东去,浪淘尽,千古风流人物。", "views": 412, "likes": 58, "created_at": "2026-10-12 10:00:00" }, { "id": 1, "title": "静夜思", "author": "李白", "dynasty": "唐", "category_name": "唐诗", "cover": "", "summary": "床前明月光,疑是地上霜。举头望明月,低头思故乡。", "views": 328, "likes": 42, "created_at": "2026-10-12 10:00:00" }, { "id": 2, "title": "春望", "author": "杜甫", "dynasty": "唐", "category_name": "唐诗", "cover": "", "summary": "国破山河在,城春草木深。", "views": 256, "likes": 31, "created_at": "2026-10-12 10:00:00" } ] }
讲透
  • 接口路径与蓝图前缀的拼法:url_prefix="/api" 加上 @home_bp.get("/banner/list"),最终就是 /api/banner/list。前缀只写一次,路径里不要再带 /api,否则会变成 /api/api/...。
  • 一律带上 status=1:这是「软删除/上架下架」的标准做法。数据不真删,只是不可见,出问题还能查回来。
  • order_by 都要带一个次关键字:只用 sort 排序时,若几条数据 sort 相同,数据库返回的顺序不保证。加 .id.asc() 做兜底,结果才稳定可复现。
  • limit 参数必须夹取范围:min(max(limit, 1), 20) 一行挡住「传 0」「传 -1」「传一亿」三种攻击式参数。
  • 这些接口不用登录:所以函数上不挂 @login_required。什么时候该挂、什么时候不该挂,看接口文档「需登录」那一列,别凭感觉。

作品模块(3 个接口)

列表 / 详情 / 点赞
先讲为什么作品列表是全站被调用最多、也最容易写崩的接口。它的 query string 上要同时支持:分页、分类筛选、朝代筛选、关键词搜索、三种排序——参数两两组合都要对。写这类接口的诀窍是:把查询条件一段段往同一个 query 上叠,每个条件只在「参数存在」时才追加,最后统一分页。这样任意组合都不会漏。
动手做列表接口:先过滤 status,再按需追加筛选条件,最后按 order_by 排序 + 分页。排序一定要带次关键字,否则翻页可能重复或漏条。
views/article.py(列表)python
from flask import Blueprint, g, request

from extensions import db
from models.article import Article
from models.comment import Favorite, UserLike
from utils.auth import login_required
from utils.response import fail, 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 = _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)

    # ② 按需叠加筛选条件(参数没传就不加)
    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())

    # ④ 分页(Flask-SQLAlchemy 3.x 的 db.paginate)
    page_obj = db.paginate(query, page=page, per_page=page_size, error_out=False)
    return success({
        "list": [a.to_list_dict() for a in page_obj.items],
        "total": page_obj.total,
        "page": page,
        "page_size": page_size,
    })
运行输出Python 运行结果
运行结果: # ① 默认排序(latest):6 篇作品的 created_at 来自同一次 INSERT,时刻相同, # 所以实际是按 id 倒序兜底 —— 这正是「次关键字」必须存在的理由 $ curl -s "http://127.0.0.1:5000/api/article/list?page=1&page_size=3" { "code": 200, "msg": "success", "data": { "list": [ { "id": 6, "title": "陋室铭", "author": "刘禹锡", "dynasty": "唐", "category_name": "散文", "cover": "", "summary": "山不在高,有仙则名;水不在深,有龙则灵。", "views": 203, "likes": 26, "created_at": "2026-10-12 10:00:00" }, { "id": 5, "title": "天净沙·秋思", "author": "马致远", "dynasty": "元", "category_name": "元曲", "cover": "", "summary": "枯藤老树昏鸦,小桥流水人家。", "views": 187, "likes": 22, "created_at": "2026-10-12 10:00:00" }, { "id": 4, "title": "声声慢", "author": "李清照", "dynasty": "宋", "category_name": "宋词", "cover": "", "summary": "寻寻觅觅,冷冷清清,凄凄惨惨戚戚。", "views": 298, "likes": 47, "created_at": "2026-10-12 10:00:00" } ], "total": 6, "page": 1, "page_size": 3 } } # ② 按分类筛选:category_id=1 是唐诗,共 3 篇 $ curl -s "http://127.0.0.1:5000/api/article/list?category_id=1&order_by=hot" { "code": 200, "msg": "success", "data": { "list": [ { "id": 1, "title": "静夜思", "author": "李白", "dynasty": "唐", "category_name": "唐诗", "views": 328, "likes": 42, "cover": "", "summary": "床前明月光,疑是地上霜。举头望明月,低头思故乡。", "created_at": "2026-10-12 10:00:00" }, { "id": 2, "title": "春望", "author": "杜甫", "dynasty": "唐", "category_name": "唐诗", "views": 256, "likes": 31, "cover": "", "summary": "国破山河在,城春草木深。", "created_at": "2026-10-12 10:00:00" }, { "id": 6, "title": "陋室铭", "author": "刘禹锡", "dynasty": "唐", "category_name": "散文", "views": 203, "likes": 26, "cover": "", "summary": "山不在高,有仙则名;水不在深,有龙则灵。", "created_at": "2026-10-12 10:00:00" } ], "total": 3, "page": 1, "page_size": 10 } } # ③ 关键词搜索:keyword=明月 命中摘要里含「明月」的《静夜思》 $ curl -s "http://127.0.0.1:5000/api/article/list?keyword=明月" { "code": 200, "msg": "success", "data": { "list": [ { "id": 1, "title": "静夜思", "author": "李白", "dynasty": "唐", "category_name": "唐诗", "views": 328, "likes": 42, "cover": "", "summary": "床前明月光,疑是地上霜。举头望明月,低头思故乡。", "created_at": "2026-10-12 10:00:00" } ], "total": 1, "page": 1, "page_size": 10 } }
动手做(续)详情接口比列表多三件事:浏览量 +1、算评论数、回填当前用户的收藏/点赞状态。最后一件要用「可选登录」的写法——没登录也能看,只是状态都是 false。
views/article.py(详情)python
from flask_jwt_extended import get_jwt_identity, verify_jwt_in_request
from models.comment import Comment        # 算评论数要用


def _current_user_id():
    """可选登录:带了合法 token 就返回用户 id,没带或失效就返回 None"""
    try:
        verify_jwt_in_request(optional=True)
        identity = get_jwt_identity()
        return int(identity) if identity else None
    except Exception:
        return None


@article_bp.get("/detail/<int:aid>")
def article_detail(aid):
    """② 作品详情:浏览量 +1,并回填「我有没有收藏 / 点赞」"""
    article = db.session.get(Article, aid)
    if article is None or article.status != 1:
        return fail("作品不存在", code=404)

    # 浏览量自增:这是唯一允许「读接口写库」的地方,业务上必须这么做
    article.views = (article.views or 0) + 1
    db.session.commit()

    uid = _current_user_id()
    is_favorite = is_liked = False
    if uid:
        is_favorite = Favorite.query.filter_by(user_id=uid, article_id=aid).first() is not None
        is_liked = UserLike.query.filter_by(user_id=uid, target_type="article",
                                           target_id=aid).first() is not None

    data = {
        "id": article.id,
        "title": article.title,
        "author": article.author,
        "dynasty": article.dynasty,
        "category_name": article.category.name if article.category else "",
        "content": article.content,
        "views": article.views,
        "likes": article.likes,
        "is_favorite": is_favorite,
        "is_liked": is_liked,
        "comment_count": Comment.query.filter_by(article_id=aid, status=1).count(),
    }
    return success(data)
运行输出Python 运行结果
运行结果: # 种子里 article id=1《静夜思》的 views 是 328,有一条书评(comment 表里 2 条, # 都属于 article_id=1 —— id 1 与 id 2 两条) $ curl -s http://127.0.0.1:5000/api/article/detail/1 \ -H "Authorization: Bearer eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9..." { "code": 200, "msg": "success", "data": { "id": 1, "title": "静夜思", "author": "李白", "dynasty": "唐", "category_name": "唐诗", "content": "床前明月光,疑是地上霜。\n举头望明月,低头思故乡。\n\n【赏析】以月光为线索,写尽羁旅思乡之情。", "views": 329, "likes": 42, "is_favorite": false, "is_liked": false, "comment_count": 2 } } # 再访问一次,views 变成 330 —— 自增生效 # 访问不存在的作品 $ curl -s http://127.0.0.1:5000/api/article/detail/999 { "code": 404, "msg": "作品不存在", "data": null }
避坑列表接口的三个高频 bug:
① LIMIT 不配稳定排序——翻到第 2 页发现有作品和第 1 页重复。原因就是排序字段有重复值、顺序不确定。解法:排序永远带一个唯一的次关键字(这里是 id)。
② 关键词里带 % 或 _——用户搜「100%」会把 % 当成通配符,返回一大堆无关结果,甚至拖慢查询。严格的做法是先转义:keyword.replace("\\", "\\\\").replace("%", "\\%").replace("_", "\\_")。
③ N+1 查询——to_list_dict() 里每访问一次 self.category.name,默认就会多查一次 category 表;20 条作品就是 21 次 SQL。数据量上来后列表会明显变慢。优化方向:Article.query.options(db.joinedload(Article.category)) 一次 JOIN 取回。课程项目数据量小可以先不管,但要知道这个坑存在。

书评模块(4 个接口)

列表(楼中楼)/ 发表 / 点赞 / 删除
书评模块:楼中楼(父评论 + 子回复) 书评 #1(父评论,pid=0) 「这首诗的意境太美了」 └ 回复 #2(pid=1) 「同感,尤其第二句」 └ 回复 #3(pid=1) 「我也最喜欢这句」 接口怎么用 pid? 发表时带上被回复的 id · GET /comment/list 返回树形结构 · POST /comment/add 带 pid 即回复 · POST /comment/<id>/like 点赞 · DELETE /comment/<id> 删自己的 后端把扁平的评论表按 pid 组装成树,前端直接渲染楼中楼

书评用 pid(父评论 id)表达层级:pid=0 是顶层书评,pid=其他 id 是回复。后端把扁平的评论表按 pid 组装成树结构返回,前端直接渲染成楼中楼。

先讲为什么书评是最「像社交」的模块:有楼中楼、有点赞、还涉及「只能删自己的」。
它的列表接口有个必须想清楚的问题:楼中楼怎么返回?两种做法——扁平返回(所有书评一个数组,靠 parent_id 让前端自己拼树)和嵌套返回(顶层书评的 children 里直接放回复)。本项目按接口文档用嵌套:前端拿到就能渲染,不用写拼树逻辑。
动手做书评列表:分页只统计顶层书评(parent_id IS NULL),回复挂在各自的 children 里。这样分页才有意义——否则「第 1 页 10 条」可能全是同一层楼的回复。
views/comment.py(列表)python
from flask import Blueprint, g, request

from extensions import db
from models.article import Article
from models.comment import Comment, UserLike
from utils.auth import login_required
from utils.response import fail, success

comment_bp = Blueprint("comment", __name__, url_prefix="/api/comment")


def _comment_to_dict(comment, with_children=False):
    """把一条书评转成前端要的结构;with_children 控制是否带上回复"""
    data = {
        "id": comment.id,
        "content": comment.content,
        "likes": comment.likes,
        "user": {
            "id": comment.user.id,
            "nickname": comment.user.nickname,
            "avatar": comment.user.avatar or "",
        },
        "created_at": comment.created_at.strftime("%Y-%m-%d %H:%M:%S"),
    }
    if with_children:
        data["children"] = [
            _comment_to_dict(child)
            for child in comment.children if child.status == 1
        ]
    return data


@comment_bp.get("/list")
def comment_list():
    """① 书评列表:分页只算顶层,回复嵌在 children 里"""
    article_id = request.args.get("article_id", type=int)
    if not article_id:
        return fail("缺少参数: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)

    query = (Comment.query
             .filter_by(article_id=article_id, status=1, parent_id=None)
             .order_by(Comment.created_at.desc(), Comment.id.desc()))
    page_obj = db.paginate(query, page=page, per_page=page_size, error_out=False)

    return success({
        "list": [_comment_to_dict(c, with_children=True) for c in page_obj.items],
        "total": page_obj.total,
        "page": page,
        "page_size": page_size,
    })
运行输出Python 运行结果
运行结果: # 种子数据里 article_id=1 有 2 条书评(都还没有回复),所以 children 都是空数组 $ curl -s "http://127.0.0.1:5000/api/comment/list?article_id=1&page=1&page_size=10" { "code": 200, "msg": "success", "data": { "list": [ { "id": 2, "content": "小时候背,只觉得朗朗上口;长大后读,才懂那份乡愁。", "likes": 8, "user": { "id": 2, "nickname": "青莲居士", "avatar": "" }, "created_at": "2026-10-12 10:05:00", "children": [] }, { "id": 1, "content": "短短二十字,写尽思乡之情。", "likes": 5, "user": { "id": 1, "nickname": "管理员", "avatar": "" }, "created_at": "2026-10-12 10:05:00", "children": [] } ], "total": 2, "page": 1, "page_size": 10 } } # 想看到楼中楼效果,先手动插一条回复(parent_id 指向 1)再查: # INSERT INTO comment (user_id, article_id, parent_id, content) VALUES (2, 1, 1, '同感!'); # 之后 id=1 那条的 children 里就会出现: # { "id": 4, "content": "同感!", "user": { "id": 2, "nickname": "青莲居士" } } # 缺少参数时 $ curl -s "http://127.0.0.1:5000/api/comment/list" { "code": 400, "msg": "缺少参数:article_id", "data": null }
动手做(续)发表书评。三个校验缺一不可:作品必须存在(否则外键报错)、内容不能空、parent_id 指向的父书评要存在。
views/comment.py(发表 / 删除)python
@comment_bp.post("/add")
@login_required
def comment_add():
    """② 发表书评:parent_id 不为空就是「回复某条书评」"""
    data = request.get_json(silent=True) or {}
    article_id = data.get("article_id")
    content = (data.get("content") or "").strip()
    parent_id = data.get("parent_id")

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

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

    # 回复时必须确认父书评存在,而且要属于同一篇作品(防止跨作品乱回复)
    if parent_id:
        parent = db.session.get(Comment, parent_id)
        if parent is None or parent.article_id != article_id:
            return fail("被回复的书评不存在", code=404)

    comment = Comment(user_id=g.current_user.id,
                      article_id=article_id,
                      parent_id=parent_id,
                      content=content)
    db.session.add(comment)
    db.session.commit()
    return success({"comment_id": comment.id}, "发表成功")


@comment_bp.delete("/<int:cid>")
@login_required
def comment_delete(cid):
    """④ 删除书评:只能删自己的,别人的返回 403"""
    comment = db.session.get(Comment, cid)
    if comment is None or comment.status != 1:
        return fail("书评不存在", code=404)
    if comment.user_id != g.current_user.id:
        return fail("无权删除他人书评", code=403)

    comment.status = 0        # 软删除,保留数据便于追溯
    db.session.commit()
    return success(None, "删除成功")
运行输出Python 运行结果
运行结果: # ② 发表书评(种子数据里 comment 已有 id 1、2、3,所以新的是 id=4) $ curl -s -X POST http://127.0.0.1:5000/api/comment/add \ -H "Content-Type: application/json" \ -H "Authorization: Bearer eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9..." \ -d '{"article_id": 1, "content": "好诗!", "parent_id": null}' { "code": 200, "msg": "发表成功", "data": { "comment_id": 4 } } # 内容为空 { "code": 400, "msg": "书评内容不能为空", "data": null } # ④ 删除别人的书评(当前登录用户 id=3,而 comment 1 属于用户 1) $ curl -s -X DELETE http://127.0.0.1:5000/api/comment/1 \ -H "Authorization: Bearer eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9..." { "code": 403, "msg": "无权删除他人书评", "data": null } # 删除自己的(comment_id=4 是刚发的) $ curl -s -X DELETE http://127.0.0.1:5000/api/comment/4 \ -H "Authorization: Bearer eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9..." { "code": 200, "msg": "删除成功", "data": null }
讲透:为什么删除用「软删除」
  • 点删除按钮就把行删掉(DELETE FROM comment)看起来干净,实际很危险:楼中楼结构下,删掉父评论,子回复会因为外键 ON DELETE CASCADE 一起消失——用户会问「我的回复怎么也没了」。
  • 软删除(status = 0)保留了数据,列表查询过滤掉就行;将来要「恢复」或者做内容审核,数据都还在。
  • 代价是所有查询都要记得带上 status == 1。忘了带,被删的书评就会冒出来——这是软删除唯一的坑。
  • 「只能删自己的」必须在后端判。前端把删除按钮藏起来只是体验优化,不是安全措施;改一下请求就能删别人的。所以 comment.user_id != g.current_user.id 这行判断不能省。
  • 点赞书评 POST /api/comment/like 的写法和点赞作品完全一样(下一节统一讲 toggle),只是 target_type 换成 "comment",计数器从 article.likes 换成 comment.likes。

收藏模块与 toggle 的设计

一个接口干两件事
先讲为什么「收藏 / 取消收藏」如果做成两个接口,前端就得自己判断当前状态该调哪个——状态不一致时必然出 bug(比如用户连点两次,一次收藏一次取消,最后状态和按钮显示相反)。
更省心的做法是一个 toggle 接口:你只管发请求,后端自己判断该「加」还是「减」,并把结果告诉你。「点赞」也是同一个模式——所以作品点赞、书评点赞、收藏,三个接口共用一个套路。
动手做记住 toggle 的三步骨架:① 先查记录是否存在 → ② 存在就删、不存在就加 → ③ 同步计数器并返回最新状态。
views/favorite.pypython
from flask import Blueprint, g, request

from extensions import db
from models.article import Article
from models.comment import Favorite
from utils.auth import login_required
from utils.response import fail, success

favorite_bp = Blueprint("favorite", __name__, url_prefix="/api/favorite")


@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")
    if db.session.get(Article, article_id) is None:
        return fail("作品不存在", code=404)

    uid = g.current_user.id

    # 第 ① 步:查「这个用户对这篇作品」有没有记录
    row = Favorite.query.filter_by(user_id=uid, article_id=article_id).first()

    if row:
        # 第 ② 步(分支 A):已有 → 取消收藏
        db.session.delete(row)
        favorited = False
    else:
        # 第 ② 步(分支 B):没有 → 新建收藏
        db.session.add(Favorite(user_id=uid, article_id=article_id))
        favorited = True

    # 第 ③ 步:提交,并把最新状态回给前端,前端据此更新按钮
    db.session.commit()
    return success({"favorited": favorited}, "操作成功")


@favorite_bp.get("/list")
@login_required
def favorite_list():
    """② 我的收藏:收藏表 JOIN 作品表,才能拿到标题、作者、分类名"""
    page = max(request.args.get("page", 1, type=int), 1)
    page_size = min(max(request.args.get("page_size", 10, type=int), 1), 50)

    # 以 favorite 为主表:它决定「收藏时间」和分页总数
    query = (Favorite.query
             .filter_by(user_id=g.current_user.id)
             .order_by(Favorite.created_at.desc(), Favorite.id.desc()))
    page_obj = db.paginate(query, page=page, per_page=page_size, error_out=False)

    items = []
    for fav in page_obj.items:
        article = fav.article
        if article is None or article.status != 1:
            continue          # 作品被下架,就不出现在收藏列表里
        items.append({
            "article_id": article.id,
            "title": article.title,
            "author": article.author,
            "category_name": article.category.name if article.category else "",
            "created_at": fav.created_at.strftime("%Y-%m-%d %H:%M:%S"),
        })

    return success({
        "list": items,
        "total": page_obj.total,
        "page": page,
        "page_size": page_size,
    })
运行输出Python 运行结果
运行结果: # ① 收藏作品 3(第一次调用:新建) $ curl -s -X POST http://127.0.0.1:5000/api/favorite/toggle \ -H "Content-Type: application/json" \ -H "Authorization: Bearer eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9..." \ -d '{"article_id": 3}' { "code": 200, "msg": "操作成功", "data": { "favorited": true } } # 再调一次同一个接口(第二次调用:取消)—— 前端不用改任何逻辑 { "code": 200, "msg": "操作成功", "data": { "favorited": false } } # ② 我的收藏:种子数据里 user 2(青莲居士)收藏了作品 1 和 3 $ curl -s "http://127.0.0.1:5000/api/favorite/list?page=1&page_size=10" \ -H "Authorization: Bearer <user 2 的 token>" { "code": 200, "msg": "success", "data": { "list": [ { "article_id": 3, "title": "念奴娇·赤壁怀古", "author": "苏轼", "category_name": "宋词", "created_at": "2026-10-12 11:00:00" }, { "article_id": 1, "title": "静夜思", "author": "李白", "category_name": "唐诗", "created_at": "2026-10-12 11:00:00" } ], "total": 2, "page": 1, "page_size": 10 } }
讲透:同一套 toggle 骨架,三处复用
  • 收藏 /api/favorite/toggle:查 favorite 表 → 有就删、没有就加 → 回 {"favorited": bool}。
  • 点赞作品 /api/article/like:查 user_like(target_type="article")→ 有就删、没有就加 → 同时维护 article.likes 计数器 → 回 {"liked": bool, "likes": int}。
  • 点赞书评 /api/comment/like:和上一条完全一样,只是 target_type="comment"、计数器换成 comment.likes。
  • 为什么要有「计数器」这个冗余字段:没有 article.likes 的话,列表页想显示点赞数就得对 user_like 做 COUNT——20 条作品就是 20 次统计。用冗余字段换性能,代价是要记得同步维护它。
  • 权威数据永远是 user_like / favorite 表。likes 只是缓存出来的数字;万一两者不一致,以记录表为准(可以写个定时任务重算)。
  • 前端怎么用:进入详情页时后端返回 is_favorite / is_liked 决定按钮是高亮还是空心;点击后只认返回值 favorited / liked 来切状态,不要自己猜。
避坑toggle 有个「看起来不会发生、但一定会发生」的问题:并发。用户在弱网下连点两次收藏,两个请求几乎同时到达——两个都查到「没有记录」,于是都执行 INSERT。数据库的联合唯一键 uk_fav_user_article 会拦住第二条并抛 IntegrityError,你的接口返回 500,用户看到「服务器内部错误」。
处理办法二选一:① try / except IntegrityError 捕获后 db.session.rollback(),当成「已经收藏了」正常返回;② 前端给按钮加 loading 禁用,从源头减少连点。**生产环境建议两个都做**。另外别忘了:任何写库异常之后都要 rollback(),否则这个会话会一直处于失败状态,后面的请求全都报错。

走通 M3 与联调排查

注册 → 登录 → 发书评 → 点赞 → 收藏
走通主链路,即为 M3 完成 注册 POST 登录 拿 token 发书评 需登录 点赞 作品 收藏 toggle 登录拿到的 token 同时供给后续每个请求

验证 M3 是否完成,就走一条主链路:注册 → 登录(拿 token) → 发书评 → 点赞 → 收藏。前端把这几个环节逐环调通、数据能对上,M3 即告完成。

先讲为什么18 个接口单独能跑,不代表串起来能跑。真正暴露问题的是这条链路:token 有没有正确带上、article_id 从上一个响应的哪个字段取、发了书评之后评论数有没有变、收藏完「我的收藏」里是不是真的多了一条。
这些跨请求的数据依赖,正是 Postman 环境变量要解决的事。走通它,M3 就达成。
动手做在 Postman 里按顺序建 7 个请求,环境变量用一个集合变量串起来。关键是每个「写」请求的 Tests 里,把下一步要用的 id 存进环境变量。
Postman:M3 七个请求的排列与关键脚本javascript
// M3 链路(在集合里按这个顺序建请求,Runner 会依次执行)
// 1. POST {{base_url}}/user/register     注册
// 2. POST {{base_url}}/user/login        登录   → 写 token
// 3. GET  {{base_url}}/user/profile      个人信息
// 4. POST {{base_url}}/comment/add       发表书评 → 写 comment_id
// 5. POST {{base_url}}/article/like      点赞作品
// 6. POST {{base_url}}/favorite/toggle   收藏
// 7. GET  {{base_url}}/favorite/list     我的收藏

// ---------- 请求 1「注册」的 Tests ----------
// 每次跑都换一个用户名,避免第二次「用户名已存在」导致整条链断掉
const suffix = Date.now().toString().slice(-6);
pm.environment.set("username", "stu" + suffix);
pm.test("注册成功", function () {
    pm.expect(pm.response.json().code).to.eql(200);
});

// ---------- 请求 2「登录」的 Tests ----------
const loginBody = pm.response.json();
pm.test("登录成功并拿到 token", function () {
    pm.expect(loginBody.code).to.eql(200);
    pm.expect(loginBody.data.token).to.be.a("string");
});
pm.environment.set("token", loginBody.data.token);

// ---------- 请求 4「发表书评」的 Tests ----------
// 把新书评 id 存下来,供后续删除用例复用
const commentBody = pm.response.json();
pm.test("发表成功", function () {
    pm.expect(commentBody.code).to.eql(200);
});
pm.environment.set("comment_id", commentBody.data.comment_id);

// ---------- 请求 5「点赞作品」的 Tests ----------
pm.test("点赞状态被切换", function () {
    const d = pm.response.json().data;
    pm.expect(d).to.have.property("liked");
    pm.expect(d).to.have.property("likes");
});

// ---------- 请求 7「我的收藏」的 Tests ----------
// 链条闭环的判定:刚才收藏的作品,必须出现在我的收藏里
pm.test("收藏的作品出现在我的收藏里", function () {
    const data = pm.response.json().data;
    const ids = data.list.map(function (item) { return item.article_id; });
    pm.expect(ids).to.include(Number(pm.environment.get("article_id")));
});
页面效果浏览器运行结果
运行结果: # Postman Collection Runner 跑完 7 个请求(共 11 条断言) 请求 方法 状态 断言 1 注册 POST 200 PASS 注册成功 2 登录 POST 200 PASS 登录成功并拿到 token 3 获取个人信息 GET 200 PASS code=200、username 正确 4 发表书评 POST 200 PASS 发表成功 5 点赞作品 POST 200 PASS 点赞状态被切换 6 收藏 / 取消收藏 POST 200 PASS 操作成功 7 我的收藏 GET 200 PASS 收藏的作品出现在我的收藏里 -- 7 passed / 0 failed,里程碑 M3 达成 -- 环境变量里 token 与 comment_id 已被自动写入,可在 Environment 面板查看
讲透:经典联调问题速查
  • 401 请先登录:① 请求头漏了 Authorization;② Bearer 后面少了一个空格(写成 Bearerxxx);③ token 过期(默认 7 天,测试时可以先把它设长);④ create_access_token(identity=3) 传了整数——flask-jwt-extended 4.x 要求字符串。
  • CORS 报错但后端日志干净:跨域被浏览器拦了,不是后端逻辑问题。检查 cors.init_app(app, resources={r"/api/*": ...}) 有没有写、/api/* 的通配有没有漏掉自己的路径。另外自测阶段优先用 Postman / curl,它们不受同源策略限制。
  • 404 接口不存在:把 flask --app app routes 的输出和接口文档对一遍。最常见的是蓝图前缀重复(/api/api/user/login)或者漏注册某个蓝图。
  • 405 方法不被允许:方法写错了(取消收藏用了 GET 而不是 POST),或者类视图的 methods 没开放这个方法。
  • 参数生效不了:分清楚参数该放哪——GET 用 query string(?page=1),POST/PUT/DELETE 用 JSON body。文章列表的筛选参数塞进 body,或者 article_id 拼在 URL 上,后端都取不到。
  • 非法 JSON / 400 参数错误:Postman 里 Body 选了 raw + JSON,但手写时多了个逗号或少了引号。养成习惯:写完请求体先切到 Pretty 看一眼有没有报错。
  • 编码乱码:MySQL 建库时必须是 utf8mb4(hiwen.sql 里已经设好了);连接串要带 charset=utf8mb4,否则中文会变成 ???。
  • 服务器起不来 / 端口被占:Address already in use——上一个进程还在。macOS / Linux 用 lsof -i :5000 找到 PID 再 kill;或者干脆换端口 run(port=5001)。
避坑调试请用这三个顺序性动作,别一上来就改代码:① 看后端终端(Flask 会把每条请求的方法、路径、状态码打出来,还会打印异常堆栈);② 用 curl 复现(绕开 Postman 和浏览器,确认到底是后端还是前端的问题);③ 用 print / app.logger.info 打出关键变量(比如 g.current_user.id、article_id 到底是什么)。
另外提醒一句:别开着 debug=True 部署到公网。它会暴露源码和交互式调试器,任何人都能在你的服务器上执行代码。开发用 debug,上线必须关。
动手练习
1把「点赞作品」接口 POST /api/article/like 完整实现出来(用 user_like 表 + 同步维护 article.likes),并用 Postman 连点两次验证 liked 在 true / false 之间正确切换、likes 数字也跟着变回去。
2给作品列表接口加上 dynasty 参数,实现「按朝代筛选」,并让 category_id、dynasty、keyword 三个条件可以同时生效。自测:dynasty=唐&category_id=1 应该返回 2 条(静夜思、春望)。
3迁移练习:「红影赏析」要做「电影点赞」和「我的片单(收藏)」。请仿照本章设计:写出接口路径、请求体、响应结构,并说清 movie_like / movie_favorite 两张表的字段与唯一键。然后回答:这套设计里,有哪些地方是可以直接照搬古典文学网的?哪些必须改?
参考答案
练习答案.py / .sql / .mdmixed
# ============ 第 1 题:点赞作品的实现 ============
@article_bp.post("/like")
@login_required
def article_like():
    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:
        return fail("作品不存在", code=404)

    uid = g.current_user.id
    row = UserLike.query.filter_by(user_id=uid,
                                  target_type="article",
                                  target_id=article_id).first()
    try:
        if row:
            db.session.delete(row)
            article.likes = max((article.likes or 0) - 1, 0)
            liked = False
        else:
            db.session.add(UserLike(user_id=uid, target_type="article",
                                    target_id=article_id))
            article.likes = (article.likes or 0) + 1
            liked = True
        db.session.commit()
    except IntegrityError:
        # 并发连点:唯一键 uk_like 拦住了重复 INSERT,当成已点赞处理
        db.session.rollback()
        return success({"liked": True, "likes": article.likes}, "操作成功")

    return success({"liked": liked, "likes": article.likes}, "操作成功")

# 顶部记得补上:from sqlalchemy.exc import IntegrityError
# from models.comment import UserLike

# Postman 连点两次的预期结果(种子数据里 article 1 的 likes 是 42,
# 且 demo 用户此前没点过赞)
# 第 1 次:{"liked": true,  "likes": 43}
# 第 2 次:{"liked": false, "likes": 42}   ← 数字回到原值,说明计数器同步正确


# ============ 第 2 题:三条件叠加(本章列表接口已天然支持)============
# 因为每个条件都写在独立的 if 里、逐段叠加到同一个 query 上,
# 所以任意组合都能自动生效,不用为组合写额外代码。
$ curl -s "http://127.0.0.1:5000/api/article/list?dynasty=唐&category_id=1"
{
  "code": 200,
  "msg": "success",
  "data": {
    "list": [
      { "id": 1, "title": "静夜思", "author": "李白", "dynasty": "唐",
        "category_name": "唐诗", "views": 328, "likes": 42 },
      { "id": 2, "title": "春望", "author": "杜甫", "dynasty": "唐",
        "category_name": "唐诗", "views": 256, "likes": 31 }
    ],
    "total": 2,
    "page": 1,
    "page_size": 10
  }
}
# 注意:《陋室铭》虽然也是「唐」,但它的 category_id 是 4(散文),
# 所以被 category_id=1 的条件过滤掉了 —— 两个条件是 AND 关系。


# ============ 第 3 题:红影赏析的点赞与片单设计 ============
-- 表设计(思路照搬,表名与字段名按电影业务改)
CREATE TABLE `movie_like` (
  `id`         INT UNSIGNED NOT NULL AUTO_INCREMENT,
  `user_id`    INT UNSIGNED NOT NULL,
  `movie_id`   INT UNSIGNED NOT NULL,
  `created_at` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
  PRIMARY KEY (`id`),
  UNIQUE KEY `uk_movie_like` (`user_id`, `movie_id`),   -- 天然防重复
  KEY `idx_movie_like_movie` (`movie_id`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;

CREATE TABLE `movie_favorite` (
  `id`         INT UNSIGNED NOT NULL AUTO_INCREMENT,
  `user_id`    INT UNSIGNED NOT NULL,
  `movie_id`   INT UNSIGNED NOT NULL,
  `created_at` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
  PRIMARY KEY (`id`),
  UNIQUE KEY `uk_fav_user_movie` (`user_id`, `movie_id`),
  KEY `idx_fav_movie` (`movie_id`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;

-- 接口设计(照搬 toggle 模式)
POST /api/movie/like       body: {"movie_id": 5}  → {"code":200,"data":{"liked":true,"likes":128}}
POST /api/favorite/toggle  body: {"movie_id": 5}  → {"code":200,"data":{"favorited":true}}
GET  /api/favorite/list?page=1&page_size=10       → 我收藏的电影列表

-- 可以直接照搬的:
-- ① toggle 的三步骨架(查 → 有就删没有就加 → 回最新状态)
-- ② 统一响应 {code,msg,data} 与 utils/response.py
-- ③ @login_required 鉴权装饰器与 g.current_user
-- ④ 分页参数夹取、软删除、联合唯一键防重复
-- ⑤ 蓝图 + 应用工厂的工程结构

-- 必须改的:
-- ① 表名与字段:movie 而不是 article,没有 category_id 但有 genre/director
-- ② 业务字段:电影没有 dynasty,但可能有 rating(评分)、duration(片长)
-- ③ 业务规则:电影点赞前可能要先「看过」,书评有 parent_id 楼中楼,电影短评是否也要?
-- ④ 能不能复用 user_like?能 —— 把 target_type 扩展成 ENUM('article','comment','movie')
--    这样一张通用点赞表服务三个业务,比新建 movie_like 更划算(表名要跟着改)
效果预览运行结果
运行结果: # 第 1 题:Postman 连点两次 第 1 次 {"code": 200, "msg": "操作成功", "data": {"liked": true, "likes": 43}} 第 2 次 {"code": 200, "msg": "操作成功", "data": {"liked": false, "likes": 42}} PASS 点赞状态被切换 # 第 2 题 $ curl -s "http://127.0.0.1:5000/api/article/list?dynasty=唐&category_id=1" total = 2 (静夜思、春望) # 第 3 题 -- 迁移的关键判据:换了「资源」(article → movie), -- 但「谁对什么做了什么」这种关系型逻辑是通用的,骨架可以整体搬走。

三题的共同点:换资源、不换骨架。第 1 题的 try / except IntegrityError 和第 3 题的 user_like 扩展建议,是「能用」和「能上线」之间的差距——能把这两点想明白,这门课就学到手了。

本章小结