🧪 第8章 建立物件侦测功能
📄 一句话概括

列表页遍历 img.tags,每个标签一行字,检测过的图缩略图换成带框的。 删除要三样一起走:记录、原图、带框图,少一样就是脏数据。 权限判断必须在端点里做,模板里那个 if 只是不显示按钮。

📝 本章和前面几章的关系

这一篇对应教材 8.5 到 8.8.5:在图片列表页面显示标记信息与检测按钮、确认运作情况, 以及图片删除功能的表单类别、端点、模板与验收。 第 7 章那个列表页只有缩略图,这一篇让它把标签、检测按钮、删除按钮都长出来。

一、把标签显示出来

<td>{% for t in r.tags %}{{ t.tag }}({{ t.score }})
{% endfor %}</td>

两行。要能用,前提是模型上配了 relationship——上一篇那个 img.tags。 实测列表页渲出来是 spot(0.409),一个标签一行。那张没检测过的图这一列是空的。 空列比空页面好理解:用户一眼能看出「这张还没检测」。要更明确,可以在里面写「未检测」三个字。

二、检测按钮是个纯 POST 表单

<form method="post"
  action="{{ url_for('images.detect', image_id=r.id) }}">
  <button>检测</button></form>

没有输入字段,所以用不上 FlaskForm——但 CSRF 不能少。 真项目里要在这儿放一个隐藏的 csrf_token,或者用 CSRFProtect(app) 兜住全站。 检测这种「点一下就改变数据」的操作必须是 POST。写成 <a href="/images/detect/1"> 的话, 浏览器预加载、爬虫扫一遍,数据就被改了——这类 bug 叫「GET 请求有副作用」。

三、删除:三样一起走

up = current_app.config["UPLOAD_FOLDER"]
for fn in (img.filename, img.box_filename):
    if fn:
        p = os.path.join(up, fn)
        if os.path.exists(p):
            os.remove(p)
db.session.delete(img)

实测算下来:图片 2 张变 1 张、标签 2 条变 0 条、文件 3 个变 1 个。三样同时减。 少删文件 = 磁盘上留垃圾;少删记录 = 列表页全是碎图。前者不报错、慢慢吃满磁盘; 后者每次打开页面都是裂图标。 标签那两条为什么跟着没了?因为配了 cascade="all, delete-orphan"。 没配的话就是 Demo 034 那个样子,留下两条孤儿。

注意 for 那个循环遍历的是两个字段:原图和带框图。 漏掉第二个,磁盘上就留一张没人认领的 _box 图。

四、文件不在了也要能删

实测造一条 lost.png 的记录、磁盘上压根没这个文件,点删除返回 302,不是 500。 靠的就是那个 if os.path.exists(p)。 少了它,os.remove 对不存在的文件抛 FileNotFoundError,请求直接 500。 「记录和文件对不上」在真实项目里天天有:有人 SSH 上去清了盘、备份还原了一半、 迁移脚本跑到中途断了。

这时候用户要的是「把这条删掉」,不是「告诉我文件不见了」。容错要放在这一侧。

五、权限在端点,不在模板

实测用户 1 去删用户 2 的图,返回 403,图片数没变。 模板里可以写 {% if r.user_id == current_user.id %} 来决定显不显示删除按钮。 但那只是「不显示」,不是「不让做」。 绕过浏览器发一个 POST 太容易了:curl 一行、浏览器控制台一行、改一下表单的 action。 服务端要是不判,前端那个 if 等于没有。

这一条跟第 3 章那句「前端校验只是省用户的事」是同一个道理, 只是这条的后果更严重——那是数据格式不对,这是别人的数据没了。

六、这一章补完,应用能干什么

能力 在哪一篇
注册、登入、登出 第 3 章
上传图片,自动防重名与穿越 第 7 章
点一下检测,标签与框落库 第 8 章
列表页看见标签与带框图 第 8 章
删图片,三样一起清 第 8 章

到这里,一个能用的 Web 应用齐了。 但它还只是个「自己用的工具」——跑在自己电脑上,只有自己一个用户。 下一篇开始换目标:把它做成 API,让别的程序也能调。那是第 3 篇的事。

七、动手 Demo:删除:记录、文件、标签三样一起走

这个 Demo 先看列表页把标签显示成什么样,然后删一张自己的图,看三样是不是同时减。 第三节那个「文件不存在也能删」是这一篇最实用的一条——真实项目里这种情况天天有。

<td>{% for t in r.tags %}{{ t.tag }}({{ t.score }})
{% endfor %}</td>
<td><form method="post"
  action="{{ url_for('images.detect', image_id=r.id) }}">
  <button>检测</button></form>
<form method="post"
  action="{{ url_for('images.delete', image_id=r.id) }}">
  <button>删除</button></form></td>

实跑输出:

--------------------------------------------------------------------
删除:记录、文件、标签三样一起走
--------------------------------------------------------------------

  ① 删之前:三样都在
  --------------------------------------------------------------------
  图片 2 张 / 标签 2 条 / 文件 3 个
  目录 -> ['cat.png', 'cat2.png', 'cat_box.png']

  ② 删自己的图
  --------------------------------------------------------------------
  POST /images/delete/1 -> 302
  图片 2 -> 1 / 标签 2 -> 0 / 文件 3 -> 1
  ! 三样一起走才算干净。只删记录不删文件 = 磁盘上留垃圾;只删文件不删记录 = 列表页全是碎图。
  ! 配了 cascade,删图片时那两个标签自动没了。如果没有配,就会像 Demo 034 那样留下两条孤儿记录。

  ③ 文件已经被人手动删掉了
  --------------------------------------------------------------------
  记录里有 lost.png,磁盘上有吗 -> False
  点删除 -> 302(不是 500)
  ! 端点里那个 if os.path.exists(p) 就是为这种情况写的。
  少了它,os.remove 对不存在的文件抛 FileNotFoundError,请求直接 500。
  ! 这种「记录和文件对不上」的状态在真实项目里天天有:有人 SSH 上去清了盘、有备份还原了一半、
  有迁移脚本跑了一半。

  ④ 删别人的图
  --------------------------------------------------------------------
  用户 1 去删用户 2 的图 -> 403
  图片 1 张(没变)

  ⑤ 删完之后列表页
  --------------------------------------------------------------------
  GET /images/ -> 200
  <h2>图片列表(共 1 张)</h2>
  <tr><td>2</td><td>cat2.png</td>
  <td><img src="/images/file/cat2.png" width="80"></td>
  <td></td>
  <td><form method="post"
  ! 列表里还剩别人那张。删除按钮只对自己的图有效——权限判断放在端点里,不能靠模板里那个 if。
  模板的 if 只是不显示按钮,绕过浏览器照样能发 POST。

第二节那三个数一起看:2 变 1、2 变 0、3 变 1。 第三节 302 而不是 500,就是那一句 os.path.exists 的作用。 第四节 403 之后图片数没变——权限判断真的在拦。

30 秒实验

  1. 把删除端点里遍历的那个元组改成只删 img.filename,删一张检测过的图,去目录里找找看。
  2. 把 if img.user_id != current_user.id 那一行注释掉,再用用户 1 删用户 2 的图。
  3. 想一想:为什么检测按钮不能写成 <a href>?

常见误区

  1. 只删记录不删文件:磁盘上留垃圾,不报错,慢慢吃满。
  2. 删了原图忘了带框图:一张没人认领的 _box 图留在目录里。
  3. 靠模板的 if 做权限:绕过浏览器就能发 POST。
  4. 用 GET 做检测或删除:浏览器预加载、爬虫扫一遍就改了数据。
自测三问(点开对答案) 1. 删一张图要删哪三样? —— 数据库记录、原图文件、带框图文件。标签靠 cascade 跟着走。 2. 文件已经不在时点删除会怎样? —— 有 os.path.exists 判断就正常 302,没有就 500。 3. 模板里的 if 能代替端点的权限判断吗? —— 不能。模板只是不显示按钮,绕过浏览器照样能发 POST。

第 8 章到此结束。第 2 篇的物件侦测应用做完了,下一篇换目标:把它做成 API。

相关笔记