🛢️ 第23章 Python+MySQL实现在线学习笔记
📄 一句话概括

列表要三个条件: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 秒实验

  1. 把 user_id 改成从请求参数里读,然后传一个别人的 id,看笔记作者变成谁。
  2. 在 (user_id, is_deleted) 上建复合索引,重跑第一段,看执行计划变不变。
  3. 改成硬删除后,把中间表的级联去掉,再删一篇笔记,看报什么错。

常见误区

  1. user_id 从表单隐藏域取。隐藏域只是「看不见」,不是「不能改」。 改一下参数就能替别人写笔记。
  2. 编辑时不判断 rowcount。0 行意味着无权或不存在,直接提示「修改成功」就是在骗用户, 而且他不会报 bug。
  3. 软删除不配视图。漏一处条件就出现两套数字。 软删除的代价从来不是那一列,是「每个查询都要记得」。
自测三问(点开对答案) 1. 列表查询的三个条件分别对应什么后果? —— 漏 `user_id` 越权,漏 `is_deleted = 0` 删了还显示,漏 `LIMIT` 全表搬进内存。 三个都不报错,这是笔记模块最难的地方。 2. 为什么「当前用户是谁」不能从表单取? —— 表单里所有字段包括隐藏域都是用户能改的。从表单取就能「替别人写笔记」,而页面上看不出异常。 只能从服务端会话取。 3. `UPDATE` 返回 0 行时程序该做什么? —— 把它翻译成「无权操作」或「笔记不存在」,返回 403 或对应提示。 不判断就会「提示成功但没改」,用户反复试然后放弃。

第 23 章到这里结束。这一章的手法可以一句话概括:把「必须做的事」变成「默认会做的事」。 SQL 收进类、归属进查询、登录挂装饰器、软删配视图——四件事都不是技术难点,是结构设计。 下一篇进入最后一个项目:用 Java 三大框架加 MySQL 做一个网络商城。

相关笔记