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

第 11 章 · 类视图

上一章我们用钩子把「每个请求都要做的事」抽了出来,这一章换个方向:把同一个资源上的多个动作收进一个类。学完你能用 MethodView 写出「一个类管一个资源」的 RESTful 接口,也能判断出哪些地方该用类视图、哪些地方函数视图反而更清爽——不为了「高级」而高级。

函数视图写多了,问题在哪

先看痛点
资源动作多?用类视图收进一个类 函数视图 def list_articles(): ... def detail_article(id): ... def create_article(): ... def update_article(id): ... def delete_article(id): ... 一个资源写 5 个函数,路由注册 5 遍 类视图(MethodView) class ArticleView(MethodView): def get(self): # 列表/详情 def post(self): # 新建 def put(self): # 更新 def delete(self): # 删除 一个类管一个资源的全部动作 as_view()

当某个资源的增删改查动作很多,函数视图会写出一堆零散函数、路由注册很多遍。类视图(MethodView)把同一资源的全部动作收进一个类,方法名直接对应 HTTP 方法。

先讲为什么你整理书桌的时候,会把「同一类东西」放在一起:笔放笔筒、本子叠一摞。代码也一样。第 2 章我们学的函数视图,是「一个 URL 对应一个函数」——写三五个接口时很爽,可到了「古典文学网」这种规模,同一个作品资源要同时处理查列表、看详情、新增、修改、删除,五个函数散在文件里,它们的参数校验、权限判断、响应格式几乎一模一样。
类视图要解决的就是这件事:把「一个资源」的多个动作收进一个类,共用一份逻辑。
动手做先用函数视图把作品资源的五个动作写出来,感受一下它的样子。
views/article.pypython
from flask import Blueprint, request
from utils.response import success, fail

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


# ① 列表
@article_bp.get("/list")
def article_list():
    page = request.args.get("page", 1, type=int)
    return success({"page": page, "list": []})


# ② 新增
@article_bp.post("/")
def article_create():
    return success(None, "新增成功")


# ③ 详情
@article_bp.get("/detail/<int:aid>")
def article_detail(aid):
    return success({"id": aid})


# ④ 修改
@article_bp.put("/<int:aid>")
def article_update(aid):
    return success(None, "修改成功")


# ⑤ 删除
@article_bp.delete("/<int:aid>")
def article_delete(aid):
    return success(None, "删除成功")
运行输出Python 运行结果
运行结果: $ flask --app app routes Endpoint Methods Rule -------------------- --------- ------------------------- article.article_list GET /api/article/list article.article_create POST /api/article/ article.article_detail GET /api/article/detail/<int:aid> article.article_update PUT /api/article/<int:aid> article.article_delete DELETE /api/article/<int:aid>
讲透
  • 五个函数其实是「一个资源」的五种操作,可代码里它们互相看不见:共用一份校验、一份权限、一份字段白名单,只能靠复制粘贴。
  • 路由写法不统一:列表是 /list,详情是 /detail/<int:aid>,修改删除却是 /<int:aid>。路径风格一乱,前端就很难记。
  • 函数名容易撞车:换个模块再来一个 list()、detail(),Flask 的 endpoint 就重名了。
  • 类视图不是新语法,而是换一种组织方式:动作变成类的方法,共用的东西放类里,路由由框架一次性注册。

MethodView:第一个类视图

把函数搬进类里
先讲为什么Flask 提供了两种类视图:flask.views.View(最原始,只有一个 dispatch_request(),得自己写 if request.method == "POST")和 flask.views.MethodView。做 RESTful 接口,99% 的情况用后者——它按 HTTP 方法自动找同名方法,你不用写一堆 if。
动手做把「健康检查」接口改写成类视图:继承 MethodView,把原来的函数体原封不动放进 get() 方法里,第一个参数写 self。
app.pypython
from flask import Flask
from flask.views import MethodView

from utils.response import success

app = Flask(__name__)


class HealthView(MethodView):
    """健康检查:只有查,没有增删改,所以只实现 get()"""

    def get(self):
        return success({"service": "hiflask", "status": "up"})


# as_view 把类「转换」成视图函数,再交给 add_url_rule 注册路由
app.add_url_rule("/api/health", view_func=HealthView.as_view("health"))
运行输出Python 运行结果
运行结果: $ curl -s http://127.0.0.1:5000/api/health { "code": 200, "msg": "success", "data": { "service": "hiflask", "status": "up" } }
讲透
  • 方法名就是 HTTP 方法名的小写:get() 接 GET,post() 接 POST,put()、patch()、delete() 同理。名字写错(比如 Get 首字母大写)就等于没实现这个方法。
  • 第一个参数是 self,URL 变量跟在后面,如 def get(self, aid)。这一点和函数视图只差一个 self。
  • 没定义的方法自动 405:Flask 2.2 起,MethodView 会扫描类里定义了哪些方法,自动生成 methods。上面这个类只有 get,所以 POST 过来直接被拒。
  • 类视图也是视图函数:注册之后,它和普通函数视图在路由表里没有区别,一样能吃装饰器、一样能用 url_for。
避坑别在类里定义 __init__ 去存请求数据。默认情况下每次请求都会新建一个实例,看起来安全,但如果你把 self.current_user = xxx 当成「登录状态」来用,一旦有人把 init_every_request = False 改成单例复用,用户 A 的登录状态就会漏给用户 B。跨请求的数据一律放 flask.g(第 4 章讲过),不要挂在 self 上。

as_view():把类变成视图函数

类不能直接注册,得先转换
as_view():把类变成视图函数 类 ArticleView (MethodView 子类) as_view('article') 转换成视图函数 add_url_rule 注册进路由表 为什么不能直接注册类? 路由表要求的是「可调用的函数」,类本身不是函数——as_view 负责造出一个函数,请求来时再交给类处理

路由表要的是可调用的函数,类本身不是函数。as_view() 把类转成一个视图函数并指定端点名,之后才能用 add_url_rule 注册。

动手做类视图注册必须走 as_view("名字") 这一步。括号里的名字就是这个视图的 endpoint(终结点名),url_for 要用它来反向生成 URL。
注册与反向取地址.pypython
from flask import Flask, url_for
from flask.views import MethodView

app = Flask(__name__)


class ArticleDetailView(MethodView):
    def get(self, aid):
        return {"code": 200, "msg": "success", "data": {"id": aid}}


# 关键:as_view() 返回的是「视图函数」,add_url_rule 只认函数
app.add_url_rule("/api/article/detail/<int:aid>",
                 view_func=ArticleDetailView.as_view("article_detail"))


with app.test_request_context():
    # url_for 的第一个参数就是 as_view 里写的名字
    print(url_for("article_detail", aid=3))
运行输出Python 运行结果
运行结果: /api/article/detail/3
讲透
  • as_view() 干了两件事:给这个视图定一个 endpoint 名字;返回一个可以注册的视图函数。所以它必须在使用前调用。
  • endpoint 在整个应用内唯一(蓝图内则要求在蓝图内唯一)。名字重复时 Flask 会直接报 View function mapping is overwriting an existing endpoint。
  • 名字要成体系:本课程统一用 资源名_动作,比如 article_list、article_detail、favorite_toggle。这样 url_for("article_detail", aid=3) 一眼就知道指向哪。
  • as_view 可以传构造函数参数:ArticleView.as_view("article", Article),类里的 __init__(self, model) 就能收到——这招适合写「同一套逻辑服务多个模型」的通用视图,本项目暂时用不上。

自动按 HTTP 方法分派

方法名对上,框架替你判断
框架按 HTTP 方法自动选类里的方法 进来 GET POST PUT DELETE MethodView 同一个类 自动分派 不用你写 if method==... get() post() put() delete() 方法名对上 HTTP 方法,框架替你判断该调哪个——少写一堆 if request.method == ...

注册类视图后,Flask 会按请求的实际 HTTP 方法自动调用类里同名的方法(GET→get、POST→post…)。你不再手写「if request.method == …」分支。

先讲为什么函数视图要用 @app.route("/x", methods=["GET", "POST"]) 声明能接哪些方法,然后在函数体里写 if request.method == "POST": 去分辨。方法一多,这段 if 就会变成一坨。类视图把这层判断交给了框架:来的是 GET,就调 get();来的是 DELETE,就调 delete()。你只负责写「每种方法该干什么」。
动手做给 /api/health 只加一个 get,然后用 DELETE 访问它,看看框架怎么处理「没实现的方法」。
终端.curlbash
# 先看这个类视图支持哪些方法
$ flask --app app routes
Endpoint        Methods            Rule
--------------  -----------------  -----------
health          GET, HEAD, OPTIONS  /api/health

# 类里没写 delete(),DELETE 请求会被 Werkzeug 直接挡掉
$ curl -i -X DELETE http://127.0.0.1:5000/api/health
终端输出命令行执行结果
运行结果: HTTP/1.1 405 METHOD NOT ALLOWED Allow: GET, HEAD, OPTIONS Content-Type: text/html; charset=utf-8 # 响应体是 Werkzeug 的默认 HTML 错误页,这里省略 # 真正项目里我们会用全局异常处理器把它变成 {"code": 405, "msg": "..."}
讲透
  • 分派逻辑很简单:getattr(self, request.method.lower(), None)。取到就调用,取不到就 405。HEAD 请求若没单独实现,会自动回退到 get()。
  • 方法列表是自动推导的:Flask 2.2 起,MethodView 通过 __init_subclass__ 扫描类里定义了哪些 HTTP 方法名,自动拼出 methods。所以上面路由表里只有 GET / HEAD / OPTIONS。
  • 需要精确控制时可以显式指定:app.add_url_rule("/x", view_func=V.as_view("x"), methods=["GET"])。传了 methods 就以你的为准,自动推导结果被覆盖。
  • 继承也能生效:父类里写了 get(),子类加一个 post(),子类的方法列表就是 {GET, POST}。

实战:用类视图重写作品资源

列表 + 详情两个类,刚好覆盖 5 个动作
两个类,刚好覆盖资源的 5 个动作 ArticleListView 挂在 /articles def get(self) → 作品列表 def post(self) → 新建作品 (集合:查全部 / 增一个) ArticleDetailView 挂在 /articles/<int:id> def get(self) → 作品详情 def put(self) → 更新作品 def delete(self) → 删除作品 (单个:查 / 改 / 删) 规律:一个资源拆成「列表类」和「详情类」,方法名直接对应 HTTP 方法,写起来最省心

一个资源通常拆成两个类:列表类挂在 /articles 负责 GET 列表与 POST 新建,详情类挂在 /articles/ 负责 GET 详情、PUT 更新、DELETE 删除。方法名与 HTTP 方法一一对应。

先讲为什么RESTful 里,作品这个资源有两种「视角」:集合(所有作品,路径 /api/article,能 GET 列表、POST 新增)和单个成员(某一篇作品,路径 /api/article/<id>,能 GET 详情、PUT 修改、DELETE 删除)。
视角分清楚了,两个类就自然出来了:ArticleListView 管集合,ArticleDetailView 管成员。
动手做把本节开头那 5 个函数,改写成下面这 2 个类。列表的筛选逻辑用的是第 6 章的 db.paginate(),思路和之前完全一样,只是搬进了方法里。
views/article.pypython
from flask import Blueprint, request
from flask.views import MethodView

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


class ArticleListView(MethodView):
    """集合视角:/api/article —— GET 取列表、POST 新增"""

    def get(self):
        page = request.args.get("page", 1, type=int)
        page_size = request.args.get("page_size", 10, type=int)
        category_id = request.args.get("category_id", type=int)
        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)))
        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({
            "list": [a.to_list_dict() for a in page_obj.items],
            "total": page_obj.total,
            "page": page,
            "page_size": page_size,
        })

    def post(self):
        data = request.get_json(silent=True) or {}
        title = (data.get("title") or "").strip()
        if not title:
            return fail("缺少参数:title")
        article = Article(title=title,
                          author=data.get("author"),
                          dynasty=data.get("dynasty"),
                          category_id=data.get("category_id"),
                          summary=data.get("summary"),
                          content=data.get("content"),
                          status=1)
        db.session.add(article)
        db.session.commit()
        return success({"id": article.id}, "新增成功")


class ArticleDetailView(MethodView):
    """成员视角:/api/article/<aid> —— GET 详情、PUT 修改、DELETE 删除"""

    def get(self, aid):
        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()
        return success(article.to_detail_dict())

    def put(self, aid):
        article = db.session.get(Article, aid)
        if article is None:
            return fail("作品不存在", code=404)
        data = request.get_json(silent=True) or {}
        for field in ("title", "author", "dynasty",
                      "category_id", "summary", "content"):
            if field in data:
                setattr(article, field, data[field])
        db.session.commit()
        return success(None, "修改成功")

    def delete(self, aid):
        article = db.session.get(Article, aid)
        if article is None:
            return fail("作品不存在", code=404)
        article.status = 0          # 软删除:数据留着,前端查不到了
        db.session.commit()
        return success(None, "删除成功")
运行输出Python 运行结果
运行结果: # 两个类,五条路由,全部收在一个文件里 $ curl -s "http://127.0.0.1:5000/api/article/list?page=1&page_size=2&order_by=hot" { "code": 200, "msg": "success", "data": { "list": [ { "id": 3, "title": "念奴娇·赤壁怀古", "author": "苏轼", "dynasty": "宋", "category_name": "宋词", "views": 412, "likes": 58 }, { "id": 1, "title": "静夜思", "author": "李白", "dynasty": "唐", "category_name": "唐诗", "views": 328, "likes": 42 } ], "total": 6, "page": 1, "page_size": 2 } }
动手做(续)类写完了,还要把两个类注册到路由上。注意:这里给每条规则显式写了 methods,让「集合」只开放 GET/POST。
views/article.py(注册部分)python
article_bp = Blueprint("article", __name__, url_prefix="/api/article")

# 集合:GET /api/article/list 查列表,POST /api/article 新增
article_bp.add_url_rule("/list",
                        view_func=ArticleListView.as_view("article_list"),
                        methods=["GET"])
article_bp.add_url_rule("",
                        view_func=ArticleListView.as_view("article_create"),
                        methods=["POST"])

# 成员:GET 详情、PUT 修改、DELETE 删除,共用同一个实例逻辑
article_bp.add_url_rule("/detail/<int:aid>",
                        view_func=ArticleDetailView.as_view("article_detail"),
                        methods=["GET"])
article_bp.add_url_rule("/<int:aid>",
                        view_func=ArticleDetailView.as_view("article_item"),
                        methods=["PUT", "DELETE"])
运行输出Python 运行结果
运行结果: $ flask --app app routes Endpoint Methods Rule ------------------------ --------- --------------------------- article.article_list GET /api/article/list article.article_create POST /api/article article.article_detail GET /api/article/detail/<int:aid> article.article_item PUT /api/article/<int:aid> article.article_item DELETE /api/article/<int:aid>
讲透
  • 一个类就是「一个资源视角的说明书」。想知道作品支持哪些操作,看这个类就够了,不用在文件里翻五个函数。
  • 显式 methods= 覆盖自动推导:不传时 ArticleListView 两个路由都会变成 {GET, POST},也就是 PUT /api/article/list 也能进到 post 里;传了就精确了。这是类视图最容易忽略的一处细节。
  • 路由风格统一了:集合走 /api/article,成员走 /api/article/<id>,前端只要记住「有 id 就是操作某一篇」。
  • 本项目的《API 接口文档》为了兼容前端约定,列表用的是 /api/article/list、详情是 /api/article/detail/<id>——路径以文档为准,类视图只是实现方式,别改了对外约定。
避坑Flask 官方文档里有一个「一个类同时管列表和详情」的写法:URL 规则用 defaults={"aid": None} 把两种情况塞进同一个 get(),再 if aid is None 判断。新手很容易在这里翻车:因为方法列表是整类推导的,两条规则会同时接受 PUT、DELETE,导致 DELETE /api/article(不带 id)也匹配进来,进到方法里 aid 是 None,稍不注意就误删。本课程统一用「集合类 + 成员类」拆开写,逻辑更清楚,也不容易出事故。

类视图结合蓝图与装饰器

书评操作都要登录
先讲为什么「发表书评、点赞书评、删除书评」这三个动作,第 8 章的 @login_required 一个都不能少。写在每个方法上太啰嗦,还容易漏。类视图有个属性叫 decorators,把装饰器写在列表里,框架会自动给这个类生成的视图函数套上——一次管全类。
动手做用类视图 + 蓝图实现「发表书评 / 点赞书评」,把 login_required 挂在 decorators 上。
views/comment.pypython
from flask import Blueprint, g, request
from flask.views import MethodView

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

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


class CommentActionView(MethodView):
    """书评的写操作:整类都要登录,写在 decorators 里一次搞定"""

    decorators = [login_required]

    def post(self):
        data = request.get_json(silent=True) or {}
        article_id = data.get("article_id")
        content = (data.get("content") or "").strip()
        if not article_id:
            return fail("缺少参数:article_id")
        if not content:
            return fail("书评内容不能为空")
        if db.session.get(Article, article_id) is None:
            return fail("作品不存在", code=404)

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

    def delete(self, cid):
        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, "删除成功")


comment_bp.add_url_rule("/add",
                        view_func=CommentActionView.as_view("comment_add"),
                        methods=["POST"])
comment_bp.add_url_rule("/<int:cid>",
                        view_func=CommentActionView.as_view("comment_delete"),
                        methods=["DELETE"])
运行输出Python 运行结果
运行结果: # 不带 token 直接发书评 $ curl -s -X POST http://127.0.0.1:5000/api/comment/add \ -H "Content-Type: application/json" \ -d '{"article_id": 1, "content": "好诗!"}' { "code": 401, "msg": "请先登录", "data": null } # 带上登录拿到的 token 再发 $ 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": "好诗!"}' { "code": 200, "msg": "发表成功", "data": { "comment_id": 9 } }
讲透
  • 用蓝图的 add_url_rule,不要用 app.add_url_rule。蓝图版会把 url_prefix 自动拼上,注册时再 app.register_blueprint(comment_bp) 即可。
  • endpoint 只在蓝图内唯一:所以 comment_add 这个名字不会和别的蓝图撞车,url_for 时要写全名 url_for("comment.comment_add")。
  • decorators 是列表,顺序从里到外:decorators = [login_required, cache] 等价于 @login_required 套在 @cache 外面。先鉴权再缓存,顺序反了会把别人的数据缓存给当前用户,很危险。
  • 登录用户怎么拿:@login_required 校验通过后会往 g.current_user 上放当前用户对象(第 8 章写的),类视图里直接用 g.current_user.id 就行。
  • 想让某个实例全局复用(比如 __init__ 里加载了很重的配置),可以设 init_every_request = False;但记住这时候 self 上不能存请求相关的数据。

什么时候用类视图

不要为了「高级」而用
讲透:它只是一种组织方式,不是「更高级」
  • 用类视图:同一个资源上有 3 个以上 HTTP 动作(典型是 RESTful 的增删改查);多个动作要共用一份逻辑(权限、字段白名单、分页参数解析);你需要一个「按资源」的结构让人一眼看懂。
  • 用函数视图:只有一个动作(健康检查、轮播图列表这种纯查询);动作之间几乎没关系;逻辑本来就短,包一层类反而多绕一步。本项目的 /api/banner/list、/api/category/list 就是典型例子——写函数视图更直接。
  • 两者可以混用,也推荐混用。类视图和函数视图在同一份路由表里完全平等,按资源特点挑就行,不必强求统一。
  • 团队一致性比「用哪个」更重要:一个项目里作品用类视图、书评用函数视图,比一半人用类一半人用函数更好维护。
避坑改造成类视图时,别顺手改了对外路径。前端和 Postman 集合认的是 /api/article/list、/api/comment/add 这些约定好的地址,你只换了内部实现,路径就必须一模一样。改完请务必跑一次 flask --app app routes,把路由表和《API 接口文档》逐行对一遍——这一步能省掉后面一整天的联调时间。
动手练习
1把 /api/banner/list 改写成类视图 BannerListView,只实现 get(),并用 app.add_url_rule(或蓝图)注册。然后回答:flask --app app routes 里它的 Methods 显示什么?为什么?
2写一个 FavoriteView,同时提供 post()(收藏/取消收藏)和 get()(我的收藏),整类要求登录。要求:get 的路径是 /api/favorite/list,post 的路径是 /api/favorite/toggle。
3迁移练习:「红影赏析」有个电影资源 movie(字段 id、title、genre、rating)。请用类视图实现它的 RESTful 接口:GET /api/movie/list 列表、POST /api/movie 新增、GET /api/movie/<id> 详情、DELETE /api/movie/<id> 删除。想一想:这和作品资源是不是同一套骨架?
参考答案
练习答案.pypython
# ---------- 第 1 题 ----------
from flask.views import MethodView
from models.article import Banner        # 轮播图模型
from utils.response import success


class BannerListView(MethodView):
    def get(self):
        rows = (Banner.query
                .filter_by(status=1)
                .order_by(Banner.sort.asc())
                .all())
        return success([b.to_dict() for b in rows])


app.add_url_rule("/api/banner/list",
                 view_func=BannerListView.as_view("banner_list"))

# Methods 会显示 GET, HEAD, OPTIONS。
# 因为类里只定义了 get(),MethodView 自动推导出的方法列表就只有 GET;
# HEAD 与 OPTIONS 是 Werkzeug 给每条规则自动加上的。


# ---------- 第 2 题 ----------
from flask import Blueprint, g, request
from flask.views import MethodView
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")


class FavoriteView(MethodView):
    decorators = [login_required]

    def get(self):
        page = request.args.get("page", 1, type=int)
        page_size = request.args.get("page_size", 10, type=int)
        query = Favorite.query.filter_by(user_id=g.current_user.id) \
                              .order_by(Favorite.created_at.desc())
        page_obj = db.paginate(query, page=page, per_page=page_size, error_out=False)
        return success({
            "list": [f.to_dict() for f in page_obj.items],
            "total": page_obj.total,
            "page": page,
            "page_size": page_size,
        })

    def post(self):
        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)
        row = Favorite.query.filter_by(user_id=g.current_user.id,
                                      article_id=article_id).first()
        if row:                     # 已收藏 → 取消
            db.session.delete(row)
            favorited = False
        else:                      # 未收藏 → 收藏
            db.session.add(Favorite(user_id=g.current_user.id, article_id=article_id))
            favorited = True
        db.session.commit()
        return success({"favorited": favorited}, "操作成功")


favorite_bp.add_url_rule("/list",
                         view_func=FavoriteView.as_view("favorite_list"),
                         methods=["GET"])
favorite_bp.add_url_rule("/toggle",
                         view_func=FavoriteView.as_view("favorite_toggle"),
                         methods=["POST"])


# ---------- 第 3 题(红影赏析,换的是模型,骨架完全一致)----------
class MovieListView(MethodView):
    def get(self):
        movies = Movie.query.order_by(Movie.rating.desc()).all()
        return success([m.to_dict() for m in movies])

    def post(self):
        data = request.get_json(silent=True) or {}
        if not (data.get("title") or "").strip():
            return fail("缺少参数:title")
        movie = Movie(title=data["title"],
                      genre=data.get("genre"),
                      rating=data.get("rating"))
        db.session.add(movie)
        db.session.commit()
        return success({"id": movie.id}, "新增成功")


class MovieDetailView(MethodView):
    def get(self, mid):
        movie = db.session.get(Movie, mid)
        if movie is None:
            return fail("电影不存在", code=404)
        return success(movie.to_dict())

    def delete(self, mid):
        movie = db.session.get(Movie, mid)
        if movie is None:
            return fail("电影不存在", code=404)
        db.session.delete(movie)
        db.session.commit()
        return success(None, "删除成功")
运行输出Python 运行结果
运行结果: # 第 1 题 $ flask --app app routes Endpoint Methods Rule ---------------- ----------------- ---------------- banner_list GET, HEAD, OPTIONS /api/banner/list # 第 2 题 $ 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 } } # 第 3 题 $ flask --app app routes | grep movie movie.movie_list GET /api/movie/list movie.movie_create POST /api/movie movie.movie_detail GET /api/movie/<int:mid> movie.movie_item DELETE /api/movie/<int:mid>

第 3 题的关键:换掉的是「模型名和字段名」,不变的是「集合类管 list/create、成员类管 detail/update/delete」这套骨架。第 2 题里的 toggle 逻辑(先查存在与否 → 存在就删、不存在就加)是下一章的主角,先记住这个套路。

本章小结