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 秒实验
- 把模板里的
rows|length改成rows.count(),看报什么错。 - 往 user_image 插一条 user_id=999 的记录,打开 sqlite 的外键检查看它会不会被拒。
- 想一想:缩略图为什么不存进数据库?
常见误区
- 把图片二进制存进数据库:备份变慢、库迅速膨胀,读一张图要拖整个文件。
- 忘了 .scalars():拿到 Row 对象,模板里取属性直接失败。
- 用 datetime.utcnow() 当默认值:3.12 起告警,而且返回的是无时区时间。
- 以为 migrate 的日志就是结果:它可能只说了部分事实,去库里数才准。
自测三问(点开对答案)
1. user_id 上那个外键保证了什么? —— 这一行必须指向 user 表里真实存在的一行,应用层漏校验时数据库兜底。 2. 缩略图与侦测件数为什么不存在表里? —— 它们都能现算,存下来会多一份可能对不上的数据。 3. scalars() 那一步在做什么? —— 把「行的集合」变成「每行第一个元素」的序列,也就是模型对象。页面有数据了,下一篇补上两张表的关系:JOIN、relationship 与 N+1。
- ☐ (本篇完,下一篇 5.2 SQLAlchemy 的表连接与关联关系)