列表要三个条件:user_id、is_deleted = 0、LIMIT。三个都属于「漏了不报错」。
user_id 只能从会话里取,永远不要从表单或隐藏域里取——那些是用户能改的。
rowcount 就是权限检查:改 0 行意味着「无权或不存在」,必须显式判断。
教材 23.7 讲笔记模块:列表、添加、编辑、删除。
四个动作的代码都不长,所以这一篇不讲「怎么写」,只讲「每个动作都要带的那一个条件」。
教材 23.8 是本章小结,并入本篇末尾。
演示里四个动作挨个跑:列表带三个条件、添加时 user_id 从会话取、
编辑和删除用 rowcount 当权限信号、最后对比软删和硬删。
一、列表:三个条件缺一不可
| 条件 | 漏了会怎样 | 报错吗 |
|---|---|---|
user_id = ? |
看见别人的笔记 | 不报 |
is_deleted = 0 |
删掉的又冒出来 | 不报 |
LIMIT |
全表搬进内存 | 数据多了才白屏 |
三行全是「不报错」。这就是笔记模块的难点——不是写不出来,是写漏了看不出来。
第三行的「数据多了才白屏」也算不报错:小数据量时完全正常,上线一段时间后突然开始 500。
所以这三个条件最好写成固定顺序,让「漏没漏」在代码审查时一眼能看出来。
比如统一成 WHERE user_id = ? AND is_deleted = 0 ORDER BY ... LIMIT ...,
谁少了一段就很显眼。
二、添加:归属从会话来,不从表单来
这一条比它看起来重要得多。表单里的所有字段——包括隐藏域——都是用户能改的。
user_id 从哪来 |
用户能改吗 | 后果 |
|---|---|---|
| 会话(服务端) | 不能 | 正确 |
| 表单字段 | 能 | 可以替别人写笔记 |
| URL 参数 | 能,还能分享 | 更糟 |
第二行和第三行的攻击方式极其简单:改一下请求参数就行,不需要任何工具。 而页面上看不出异常——笔记确实写进去了,只是作者不是你。 所以一条铁律:凡是「当前用户是谁」这类信息,只能从服务端会话里取。 表单里可以传「笔记 id」「标题」「正文」,但不能传「作者」。
三、编辑与删除:rowcount 就是权限信号
这两个动作的正确写法都是「一条带条件的 UPDATE/DELETE」:
| 写法 | 别人的笔记 | 返回值 |
|---|---|---|
WHERE id = ? |
真的被改了 | 影响 1 行 |
WHERE id = ? AND user_id = ? |
没被改 | 影响 0 行 |
第二行「没被改」是好事,但它是「静默的好事」——不报错、不异常,只是数字是 0。 所以代码必须读这个数字:
n = db.execute("UPDATE notes SET title=%s WHERE id=%s AND user_id=%s",
(title, note_id, uid))
if n == 0:
return "笔记不存在或无权修改", 403
return "修改成功"
教材原码里没有这个判断,这是需要补上的地方。 少了它,用户会看到「修改成功」但内容没变,再试一次还是成功——最后他不用这个功能了。 而这种「功能通但结果不对」的问题,测试往往测不出来,因为它不报错。
四、删除:软删还是硬删
| 做法 | 好处 | 代价 |
|---|---|---|
| 软删除(改标记) | 误删能恢复 | 每个查询都要带条件 |
| 硬删除(真删) | 表干净 | 删错了找不回来 |
笔记这类「用户自己产出」的内容,软删除几乎是必需的——因为误删一定发生,而且用户会来找你恢复。
但软删除的代价要认清楚:它不是「多一列」,是「以后每个查询都要记得那个条件」。
这个代价和 22 章那个「两个客户数」是同一件事:漏一处就出现两套数字,而且很难发现。
所以软删除一旦上了,就要配一个视图(固定带 is_deleted = 0),代码只查视图。
最后说一句本章的小结(教材 23.8)。整章可以压成三句:
SQL 收进一个类、归属条件进每一条查询、需要登录的页面挂装饰器。
三件事都不是技术难点,全是「有没有把它变成结构」的问题。
这也是从 22 章到 23 章一直没变的那条主线:让「忘记」变得不可能。
五、动手 Demo:笔记的增删改:四个动作,一个都不能漏归属
这一段四个动作挨个跑:列表带三个条件、添加时 user_id 从会话取而不是表单、
编辑和删除用 rowcount 判断权限、最后对比软删除和硬删除对中间表的影响。
import sqlite3
c = sqlite3.connect(u"note.db", isolation_level=None)
c.execute(u"PRAGMA foreign_keys = ON")
c.executescript(u"""
CREATE TABLE notes (id INTEGER PRIMARY KEY, user_id INTEGER NOT NULL,
title TEXT, body TEXT, is_deleted INTEGER DEFAULT 0);
CREATE TABLE note_tag (note_id INTEGER NOT NULL, tag_id INTEGER NOT NULL,
PRIMARY KEY (note_id, tag_id),
FOREIGN KEY (note_id) REFERENCES notes(id) ON DELETE CASCADE);
""")
c.execute(u"BEGIN")
c.executemany(u"INSERT INTO notes (user_id, title) VALUES (?,?)",
[(i % 10 + 1, u"笔记%04d" % i) for i in range(1, 1001)])
c.executemany(u"INSERT INTO note_tag VALUES (?,?)",
[(i, i % 4 + 1) for i in range(1, 1001)])
c.execute(u"COMMIT")
# ① 列表:三个条件
rows = c.execute(u"SELECT id, title FROM notes WHERE user_id = ? "
u"AND is_deleted = 0 ORDER BY id DESC LIMIT 5", (3,)).fetchall()
print(u"列表 %d 条,首条 %r" % (len(rows), rows[0]))
# ② 添加:user_id 从会话取
session_uid = 3
nid = c.execute(u"INSERT INTO notes (user_id, title, body) VALUES (?,?,?)",
(session_uid, u"我的新笔记", u"内容")).lastrowid
print(u"新增 id=%d,作者取自会话" % nid)
# ③ 编辑:rowcount 就是权限信号
other = c.execute(u"SELECT id FROM notes WHERE user_id <> 3 LIMIT 1").fetchone()[0]
n1 = c.execute(u"UPDATE notes SET title='改坏了' WHERE id=?", (other,)).rowcount
n2 = c.execute(u"UPDATE notes SET title='正常' WHERE id=? AND user_id=?",
(other, 3)).rowcount
print(u"不带条件改 %d 行 / 带条件改 %d 行" % (n1, n2))
# ④ 软删 vs 硬删
before = c.execute(u"SELECT COUNT(*) FROM notes WHERE is_deleted=0").fetchone()[0]
c.execute(u"UPDATE notes SET is_deleted=1 WHERE id=? AND user_id=?", (nid, 3))
after = c.execute(u"SELECT COUNT(*) FROM notes WHERE is_deleted=0").fetchone()[0]
print(u"软删:可见 %d -> %d" % (before, after))
c.execute(u"DELETE FROM notes WHERE id=? AND user_id=?", (nid, 3))
print(u"硬删后中间表剩 %d 条" % c.execute(
u"SELECT COUNT(*) FROM note_tag WHERE note_id=?", (nid,)).fetchone()[0])
c.close()
实跑输出:
--------------------------------------------------------------------
笔记的增删改:四个动作,一个都不能漏归属
--------------------------------------------------------------------
**教材 23.7 是笔记模块:列表、添加、编辑、删除。**
**四个动作的代码都不长,重点在于每个动作都要带一个条件——`user_id`。**
**这一节把「带」和「不带」的后果逐个跑一遍。**
① 列表:分页 + 归属 + 软删除
--------------------------------------------------------------------
三个条件 user_id + is_deleted + LIMIT
id title
-------------
992 笔记0992
982 笔记0982
972 笔记0972
962 笔记0962
952 笔记0952
**三个条件缺一不可:漏 `user_id` 就是越权,漏 `is_deleted` 就是删了还显示,**
**漏 `LIMIT` 就是全表搬进内存。**
**三个都属于「漏了不报错」的类型——这也是这一章反复出现的那句话:安全的反面不是「出错」,**
**是「静默地多做了事」。**
② 添加:归属从会话里来,不从表单里来
--------------------------------------------------------------------
正确做法 user_id 取自登录会话,id=1001
错误做法 user_id 取自表单隐藏域,可被改成别人的
**这一条比它看起来重要:表单里的字段全是用户能改的,包括隐藏域。**
**如果把 `user_id` 放在隐藏域里,改一下就能「替别人写笔记」——而页面上看起来完全正常。**
**所以「谁是当前用户」这件事,只能从会话里取,永远不要从请求参数里取。**
③ 编辑与删除:`rowcount` 就是权限检查
--------------------------------------------------------------------
写法 影响行数 结论
------------------------------------------------
不带 `user_id` 1 改到了别人的笔记
带 `user_id` 0 什么都没改(也没报错)
**第二行的 0 就是「权限不足」的信号,但它是「正常返回」而不是异常。**
**所以代码必须显式判断:**
[教材原码 · 本沙箱不执行]
| n = db.execute("UPDATE notes SET title=%s WHERE id=%s AND user_id=%s",
| (title, note_id, uid))
| if n == 0:
| return "笔记不存在或无权修改", 403
| return "修改成功"
教材原码没有这一步判断,这是需要补上的地方
**少了这个判断,就会出现第 101 段那种情况:明明没改到,却提示「修改成功」。**
**用户会以为改了,刷新之后发现没变,再改一次,还是「成功」——最后他不用这个功能了。**
④ 删除:软删还是硬删
--------------------------------------------------------------------
软删除 可见的从 1001 变成 1000,行还在
硬删除 行也没了,中间表跟着级联删
中间表 剩 0 条
**软删除的好处是「误删能恢复」,代价是每个查询都要带 `is_deleted = 0`。**
**笔记这类用户自己产出的内容,软删除几乎是必需的——因为误删一定发生,而且用户会找你恢复。**
**硬删除那一行顺带验证了级联:中间表里对应的两行也跟着没了。**
**如果不写 `ON DELETE CASCADE`,删笔记会被中间表的外键拦住——那时就得先删中间表,再删笔记,**
**两步都不能忘。**
**软删除和硬删除在这里是两种都做了,实际项目只选一种。**
**选软删除就必须建视图,**
**否则每个查询都得记得那个条件——第 99 段那个「两个客户数」就是这么来的。**
第一段那条 SQL 的三个条件都在:user_id、is_deleted、LIMIT。
它们对应的三种后果分别是「越权」「删了还显示」「内存爆掉」,全都不报错。
第二段只有一个细节,但它是整章最容易出错的地方:session_uid 是从会话里来的常量,
不是从请求参数里读的。
如果写成从表单读,改一下参数就能替别人写笔记。
第三段两行的差别是 1 和 0。
1 表示真的改到了别人的笔记,0 表示什么都没改——但两者都「成功执行」。
所以代码必须判断这个 0,把它翻译成「无权操作」或「笔记不存在」。
第四段两个删除方式都做了:软删只影响「可见数」,行还在;硬删会连带中间表一起删。
实际项目只选一种,但选软删就必须配视图,否则每个查询都得记得那个条件。
30 秒实验
- 把
user_id改成从请求参数里读,然后传一个别人的 id,看笔记作者变成谁。 - 在
(user_id, is_deleted)上建复合索引,重跑第一段,看执行计划变不变。 - 改成硬删除后,把中间表的级联去掉,再删一篇笔记,看报什么错。
常见误区
user_id从表单隐藏域取。隐藏域只是「看不见」,不是「不能改」。 改一下参数就能替别人写笔记。- 编辑时不判断
rowcount。0 行意味着无权或不存在,直接提示「修改成功」就是在骗用户, 而且他不会报 bug。 - 软删除不配视图。漏一处条件就出现两套数字。 软删除的代价从来不是那一列,是「每个查询都要记得」。
自测三问(点开对答案)
1. 列表查询的三个条件分别对应什么后果? —— 漏 `user_id` 越权,漏 `is_deleted = 0` 删了还显示,漏 `LIMIT` 全表搬进内存。 三个都不报错,这是笔记模块最难的地方。 2. 为什么「当前用户是谁」不能从表单取? —— 表单里所有字段包括隐藏域都是用户能改的。从表单取就能「替别人写笔记」,而页面上看不出异常。 只能从服务端会话取。 3. `UPDATE` 返回 0 行时程序该做什么? —— 把它翻译成「无权操作」或「笔记不存在」,返回 403 或对应提示。 不判断就会「提示成功但没改」,用户反复试然后放弃。第 23 章到这里结束。这一章的手法可以一句话概括:把「必须做的事」变成「默认会做的事」。 SQL 收进类、归属进查询、登录挂装饰器、软删配视图——四件事都不是技术难点,是结构设计。 下一篇进入最后一个项目:用 Java 三大框架加 MySQL 做一个网络商城。
- ☐ (本篇完,下一篇 24.1 开发背景)