🧪 第5章 建立图片列表页面
📄 一句话概括

UserImage 一共五列,其中 user_id 是指向 user.id 的外键。 外键在数据库层保证「图片一定有个主人」,代码写错也插不进去。 列表页换成真查询之后,模板一个字没改——上一章那版留的接口正好对上。

📝 本章和前面几章的关系

这一篇对应教材 5.1 到 5.3:建立 UserImage 模型、列表页的端点、列表页的模板。 第 4 章把页面和字段定死了,这一篇照着那张表建模型,再把假数据换成真查询。

一、五列怎么来的

class UserImage(db.Model):
    id = db.Column(db.Integer, primary_key=True)
    filename = db.Column(db.String(120), nullable=False)
    uploaded_at = db.Column(db.DateTime, default=now, nullable=False)
    user_id = db.Column(db.Integer,
                        db.ForeignKey("user.id"), nullable=False)

对照第 4 章那张字段表:图片列表要显示 id、文件名、缩略图、侦测件数、上传时间、操作。 这里只落了三个字段。缩略图和侦测件数都不是存下来的——前者靠原图现算,后者去结果表数。 这正是第 4 章那道取舍题的答案:能算出来的不存,避免两份数据对不上。

filename 存的是文件名,不是图片本身。图片放在磁盘的 static/images 下面,库里只记名字。 这个分工是硬性的:把图片二进制塞进数据库,备份、迁移、读取全会变慢,而且数据库会迅速膨胀。

二、外键不是装饰

实测打印出来那一列是这样的:user_id INTEGER nullable=False 外键=['user.id']。 它在数据库层表达「这一行必须指向 user 表里真实存在的一行」。 插一条 user_id=999 的记录,只要外键约束开着,数据库直接拒绝。 所以代码里忘了校验归属,数据库还能兜一层。这是应用层校验替代不了的东西:应用层只在一处生效, 约束对所有入口生效。

注意 SQLite 默认不检查外键,要 PRAGMA foreign_keys=ON 才生效。 换 MySQL、PostgreSQL 就没有这个开关问题。

三、迁移建表

模型写完还是老三步:init 一次、migrate 每次改模型、upgrade 落到库。 实测三条返回码都是 0,upgrade 之后库里有三张表:alembic_version、user、user_image。 migrate 的输出里那句 Detected added table 只报了 user 一张——别被它骗了,脚本里两张表都在, upgrade 之后去库里数才是准的。

这类「日志只说了部分事实」的情况很常见,判断标准永远是最后那个状态,不是中间那句提示。

四、列表端点:从写死到真查询

@bp.route("/")
def index():
    rows = db.session.execute(
        db.select(UserImage).order_by(UserImage.id)).scalars().all()
    return render_template("images/list.html", rows=rows)

对比上一章那版:ROWS 是个写死的列表,这里换成了查询结果。 模板一个字没改。因为两边都是「能遍历的列表」,模板只关心这一点。 .scalars().all() 这两步要分清:execute 返回的是「行」的集合, scalars 把每行里的第一个元素取出来,all 才变成列表。

少了 scalars,拿到的是 Row 对象,模板里 r.filename 直接报未定义。 实测三张图渲染出三行,日期用 strftime('%Y-%m-%d') 截到天。

五、上传时间用什么默认值

教材的写法是 default 加一个函数。这里有个坑:datetime.utcnow() 在 Python 3.12 起被标成过时。 实测调用它会触发一条 DeprecationWarning,说的是「返回的是没有时区的那一个」。 所以默认值写成 datetime.now(timezone.utc):带时区,不会跟着机器时区跑。

但存进 SQLite 之后时区信息还是会丢,读回来是个不带时区的 datetime。 真要做多时区,得自己存时间戳,或者全库统一按 UTC 解释。 这一条现在不用解决,但要知道它在——等你要显示「三小时前」的时候就会撞上。

六、动手 Demo:UserImage 模型与真的列表页

这个 Demo 从模型开始:打印五列和外键,走一遍迁移,塞三条真数据,再打开列表页。 最后一节对比 utcnow 与 now(timezone.utc)。

class UserImage(db.Model):
    id = db.Column(db.Integer, primary_key=True)
    filename = db.Column(db.String(120), nullable=False)
    uploaded_at = db.Column(db.DateTime, default=now, nullable=False)
    user_id = db.Column(db.Integer,
                        db.ForeignKey("user.id"), nullable=False)

@bp.route("/")
def index():
    rows = db.session.execute(
        db.select(UserImage).order_by(UserImage.id)).scalars().all()
    return render_template("images/list.html", rows=rows)

实跑输出:

--------------------------------------------------------------------
UserImage 模型与真的列表页
--------------------------------------------------------------------

  ① 模型:五列,两个约束
  --------------------------------------------------------------------
  表名 -> user_image
    id           INTEGER        nullable=False 外键=—
    filename     VARCHAR(120)   nullable=False 外键=—
    uploaded_at  DATETIME       nullable=False 外键=—
    user_id      INTEGER        nullable=False 外键=['user.id']
  ! user_id 是外键,指向 user.id。它在数据库层保证「图片一定有个主人」,代码写错也插不进去。

  ② 走迁移把表建出来
  --------------------------------------------------------------------
  flask db init -> 0
    Creating directory .\migrations ...  done
  flask db migrate -m 建两张表 -> 0
    INFO  [alembic.autogenerate.compare.tables] Detected added table 'user'
  flask db upgrade -> 0
    INFO  [alembic.runtime.migration] Running upgrade  -> 7b24c1003788, 建两张表
  库里现在有 -> ['alembic_version', 'user', 'user_image']

  ③ 塞三条真数据
  --------------------------------------------------------------------
  图片行数 -> 3

  ④ 列表页读的是真数据
  --------------------------------------------------------------------
  GET /images/ -> 200
  <h2>图片列表(共 3 张)</h2>
  <tr><th>ID</th><th>文件名</th><th>上传时间</th><th>上传者 ID</th></tr>
  <tr><td>1</td><td>cat.jpg</td><td>2026-11-01</td><td>1</td></tr>
  <tr><td>2</td><td>dog.png</td><td>2026-11-02</td><td>1</td></tr>
  <tr><td>3</td><td>street.jpg</td><td>2026-11-03</td><td>1</td></tr>
  ! 和第 4 章那一版比,模板一个字没改——只把写死的列表换成了数据库查询。
  这就是「先做界面后接数据」省下来的时间。

  ⑤ 上传时间用什么默认值
  --------------------------------------------------------------------
  调用 datetime.utcnow() 触发告警 -> 1 条
    datetime.datetime.utcnow() is deprecated and scheduled for removal in a fu
  datetime.now(timezone.utc) -> 2026-11-03T03:54:26.830931
  ! Python 3.12 起 utcnow() 被标成过时,因为它返回的是「没有时区的那一个」。
  新代码写 now(timezone.utc),带时区才不会被本地时区糊弄。
  ! 存进 SQLite 之后时区信息会丢,读回来是个不带时区的 datetime。
  真要做多时区,得自己存时间戳或者统一按 UTC 解释。

第一节那五行里,只有 user_id 带外键,其余四列都是普通列。 第二节 migrate 只说「加了 user 表」,但 upgrade 之后库里两张都在——日志不能当结论。 第四节那三行数据是从库里查出来的,和第 4 章写死的那三行看起来一样,性质不同。

30 秒实验

  1. 把模板里的 rows|length 改成 rows.count(),看报什么错。
  2. 往 user_image 插一条 user_id=999 的记录,打开 sqlite 的外键检查看它会不会被拒。
  3. 想一想:缩略图为什么不存进数据库?

常见误区

  1. 把图片二进制存进数据库:备份变慢、库迅速膨胀,读一张图要拖整个文件。
  2. 忘了 .scalars():拿到 Row 对象,模板里取属性直接失败。
  3. 用 datetime.utcnow() 当默认值:3.12 起告警,而且返回的是无时区时间。
  4. 以为 migrate 的日志就是结果:它可能只说了部分事实,去库里数才准。
自测三问(点开对答案) 1. user_id 上那个外键保证了什么? —— 这一行必须指向 user 表里真实存在的一行,应用层漏校验时数据库兜底。 2. 缩略图与侦测件数为什么不存在表里? —— 它们都能现算,存下来会多一份可能对不上的数据。 3. scalars() 那一步在做什么? —— 把「行的集合」变成「每行第一个元素」的序列,也就是模型对象。

页面有数据了,下一篇补上两张表的关系:JOIN、relationship 与 N+1。

相关笔记