码上问学问代码之理,学编程之道

阶段 2 · 数据库实施

上一阶段画的那张 ER 图,现在要变成能真正跑起来的表。这一阶段要交出一个「可重复执行」的 hiwen.sql——跑一遍就能从零建好库、建好 7 张表、1 个视图,并灌进一套看得见、点得动的种子数据。字符集、外键、索引、视图,一个都不能凑合。

从设计到脚本:ER 图怎么落成表

图纸 → 施工
从 ER 图到 CREATE TABLE:一个实体一张表,一条关系一根外键ER 图(设计稿)· 矩形 = 实体· 菱形 = 关系· 1:n / m:n 标注给人看,确认理解一致翻译DDL 脚本· 实体 → CREATE TABLE· 1:n → 外键在多的一端· m:n → 中间关系表· 唯一 → UNIQUE 约束给数据库执行hiwen.sql· 建库语句 ×1· 建表语句 ×7· 视图 ×1· 种子数据若干· 导入即可复现m:n 关系最容易漏:用户收藏作品 = favorite 关系表(user_id + article_id 联合唯一)
先讲为什么ER 图上的方框不会自己变成表。中间隔着一层「翻译」:每个实体一张表,每个属性一个字段,每条连线一个外键,每个查询条件一个索引。
这一步做扎实,后端就只需要写业务逻辑;这一步偷懒,后端就要天天跟数据较劲。
讲透:脚本要写成「可重复执行」前三行必须是 DROP DATABASE IF EXISTS、CREATE DATABASE、USE,后面每张表前面都要有 DROP TABLE IF EXISTS。这样脚本随时可以重跑,改表结构不用手工去 Navicat 里删——「能一键重来」是数据库脚本的第一品质。
避坑user 在 MySQL 里是个关键字(非保留字,勉强能不加引号用),但 group、order、key 这类词迟早会让你翻车。本课程统一规矩:所有表名、字段名一律加反引号(`user`),一劳永逸。
还有个更容易忽略的坑:脚本开头的 DROP DATABASE IF EXISTS `hiwen` 会清空整个库。在开发机上随便跑,到生产机上跑一次就是事故——执行前先看清连的是哪个库。

建库规范:字符集、排序规则、引擎

三行建库语句,决定后面所有表
先讲为什么字符集是「数据库用什么方式存文字」。选错的后果很具体:用户昵称里带一个 emoji,插入时直接报错 Incorrect string value——而这类问题往往上线后才被发现。
建库时把字符集定死,后面 7 张表都会继承,不用每张表重复写。
动手做建库只写三件事:字符集 utf8mb4、排序规则 utf8mb4_general_ci、并立刻用一条查询把设置读出来验证。
建库.sqlsql可运行
-- 可重复执行:先删后建(生产环境请勿直接跑)
DROP DATABASE IF EXISTS `hiwen`;
CREATE DATABASE `hiwen`
  DEFAULT CHARACTER SET utf8mb4
  COLLATE utf8mb4_general_ci;

USE `hiwen`;

-- 验证:这两列都应该是 utf8mb4 开头
SELECT @@character_set_database AS 字符集,
       @@collation_database     AS 排序规则;
查询结果SQL 执行结果
运行结果: 字符集 | 排序规则 utf8mb4 | utf8mb4_general_ci -- 共 1 行
讲透:三个设置各管一件事
  • utf8mb4 管「能存什么」:MySQL 里的 utf8 其实是个 3 字节的阉割版(最多存到 U+FFFF),emoji 和部分生僻汉字都存不了。utf8mb4 才是真正的完整 UTF-8。古典文学里生僻字不少,必须用 mb4。
  • Collate 管「怎么比较」:_ci 表示 case insensitive(大小写不敏感),所以 'Tang' 和 'tang' 会被判为相等;_bin 则按二进制逐字节比,严格区分大小写。
  • InnoDB 管「能不能用外键和事务」:这是 MySQL 8 的默认引擎。老教材里的 MyISAM 不支持外键、不支持事务——本项目的表结构里到处都是 CONSTRAINT ... FOREIGN KEY,用 MyISAM 会直接建不出来。
避坑在 _ci 排序规则下,唯一键是「大小写不敏感」的:用户名 Admin 和 admin 会被判为重复,注册时会返回「用户名已存在」。这通常正是你想要的效果(防止有人用 Admini 冒充 admini),但你必须知道它会发生——否则测试时会一脸茫然。
要不要改 utf8mb4_bin?一般不用。真要在意大小写,应该在业务层统一转小写,而不是靠数据库排序规则。

逐表建表:外键与索引怎么落地

7 张表,一处都不能漏
先讲为什么表不是孤岛。comment 里存一个 article_id = 999,而 article 里根本没有 999 号作品——这种「孤儿数据」就是靠外键挡住的。
索引则是另一件事:它不保证正确,只保证快。外键管对不对,索引管快不快,两个都得建。
动手做从最里层的两张「基础表」开始——它们不依赖任何表,可以最先建:user 和 category。
建表-基础表.sqlsql可运行
-- ① 用户表:两个唯一键,保证登录名和邮箱都不重复
DROP TABLE IF EXISTS `user`;
CREATE TABLE `user` (
  `id`         INT UNSIGNED NOT NULL AUTO_INCREMENT COMMENT '用户ID,主键自增',
  `username`   VARCHAR(50)  NOT NULL                COMMENT '登录名(唯一)',
  `password`   VARCHAR(255) NOT NULL                COMMENT '密码(哈希后存储,切勿明文)',
  `nickname`   VARCHAR(50)      DEFAULT NULL        COMMENT '昵称',
  `email`      VARCHAR(100)     DEFAULT NULL        COMMENT '邮箱(唯一,可空)',
  `avatar`     VARCHAR(255)     DEFAULT ''          COMMENT '头像图片路径',
  `intro`      VARCHAR(255)     DEFAULT NULL        COMMENT '个人简介',
  `gender`     TINYINT          DEFAULT 0           COMMENT '0未知 1男 2女',
  `status`     TINYINT      NOT NULL DEFAULT 1      COMMENT '1正常 0禁用',
  `created_at` DATETIME     NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '注册时间',
  `updated_at` DATETIME     NOT NULL DEFAULT CURRENT_TIMESTAMP
                            ON UPDATE CURRENT_TIMESTAMP COMMENT '更新时间',
  PRIMARY KEY (`id`),
  UNIQUE KEY `uk_user_username` (`username`),
  UNIQUE KEY `uk_user_email` (`email`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='用户表';

-- ② 分类表:name 唯一,sort 控制前端展示顺序
DROP TABLE IF EXISTS `category`;
CREATE TABLE `category` (
  `id`          INT UNSIGNED NOT NULL AUTO_INCREMENT COMMENT '分类ID',
  `name`        VARCHAR(30)  NOT NULL                COMMENT '分类名(如 唐诗、宋词)',
  `description` VARCHAR(255)     DEFAULT NULL        COMMENT '分类简介',
  `sort`        INT          NOT NULL DEFAULT 0      COMMENT '排序值,越小越前',
  `status`      TINYINT      NOT NULL DEFAULT 1      COMMENT '1显示 0隐藏',
  `created_at`  DATETIME     NOT NULL DEFAULT CURRENT_TIMESTAMP,
  PRIMARY KEY (`id`),
  UNIQUE KEY `uk_category_name` (`name`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='文学分类表';
查询结果SQL 执行结果
运行结果: -- 验证唯一键都建上了(NON_UNIQUE = 0 的才是唯一索引) SELECT TABLE_NAME, INDEX_NAME, COLUMN_NAME FROM information_schema.STATISTICS WHERE TABLE_SCHEMA = 'hiwen' AND NON_UNIQUE = 0 ORDER BY TABLE_NAME, INDEX_NAME; table_name | index_name | column_name category | uk_category_name | name user | PRIMARY | id user | uk_user_email | email user | uk_user_username | username -- 共 4 行

接下来是核心表 article。它一个语句里同时演示了三件事:外键(指向 category)、普通索引(外键列 + 复合查询列)、全文索引(标题与摘要的搜索)。

建表-article.sqlsql可运行
DROP TABLE IF EXISTS `article`;
CREATE TABLE `article` (
  `id`           INT UNSIGNED NOT NULL AUTO_INCREMENT COMMENT '作品ID',
  `title`        VARCHAR(150) NOT NULL                COMMENT '标题',
  `author`       VARCHAR(50)      DEFAULT NULL        COMMENT '作者(如 李白)',
  `dynasty`      VARCHAR(20)      DEFAULT NULL        COMMENT '朝代(如 唐)',
  `category_id`  INT UNSIGNED     DEFAULT NULL        COMMENT '所属分类',
  `cover`        VARCHAR(255)     DEFAULT ''          COMMENT '封面图路径',
  `summary`      VARCHAR(500)     DEFAULT NULL        COMMENT '内容摘要',
  `content`      TEXT                                 COMMENT '正文',
  `views`        INT UNSIGNED NOT NULL DEFAULT 0      COMMENT '浏览量',
  `likes`        INT UNSIGNED NOT NULL DEFAULT 0      COMMENT '点赞数',
  `is_recommend` TINYINT      NOT NULL DEFAULT 0      COMMENT '1首页推荐 0普通',
  `status`       TINYINT      NOT NULL DEFAULT 1      COMMENT '1已发布 0草稿',
  `created_at`   DATETIME     NOT NULL DEFAULT CURRENT_TIMESTAMP,
  `updated_at`   DATETIME     NOT NULL DEFAULT CURRENT_TIMESTAMP
                             ON UPDATE CURRENT_TIMESTAMP,
  PRIMARY KEY (`id`),
  -- 外键列一定要单独建索引,否则 JOIN 和级联检查都要全表扫
  KEY `idx_article_category` (`category_id`),
  -- 复合索引:首页「推荐位 + 已发布」这条最常走的查询
  KEY `idx_article_recommend` (`is_recommend`, `status`),
  -- 全文索引:支持标题/摘要的关键词搜索
  FULLTEXT KEY `ft_article_title_summary` (`title`, `summary`),
  CONSTRAINT `fk_article_category` FOREIGN KEY (`category_id`)
    REFERENCES `category` (`id`) ON DELETE SET NULL ON UPDATE CASCADE
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='文学作品表';
查询结果SQL 执行结果
运行结果: -- 验证 6 条外键都建上了 SELECT CONSTRAINT_NAME, TABLE_NAME, COLUMN_NAME, REFERENCED_TABLE_NAME FROM information_schema.KEY_COLUMN_USAGE WHERE TABLE_SCHEMA = 'hiwen' AND REFERENCED_TABLE_NAME IS NOT NULL ORDER BY TABLE_NAME, CONSTRAINT_NAME; constraint_name | table_name | column_name | referenced_table_name fk_article_category | article | category_id | category fk_comment_article | comment | article_id | article fk_comment_parent | comment | parent_id | comment fk_comment_user | comment | user_id | user fk_fav_article | favorite | article_id | article fk_fav_user | favorite | user_id | user -- 共 6 行
动手做剩下四张表一起收尾。comment 是重点:它有三条外键,其中 parent_id 指向自己,这就是「楼中楼」的实现方式。
建表-互动表.sqlsql可运行
-- ③ 书评表:parent_id 自关联,实现「楼中楼」回复
DROP TABLE IF EXISTS `comment`;
CREATE TABLE `comment` (
  `id`         INT UNSIGNED NOT NULL AUTO_INCREMENT COMMENT '书评ID',
  `user_id`    INT UNSIGNED NOT NULL                COMMENT '评论人',
  `article_id` INT UNSIGNED NOT NULL                COMMENT '被评作品',
  `parent_id`  INT UNSIGNED     DEFAULT NULL        COMMENT '父评论ID(回复时填,否则 NULL)',
  `content`    VARCHAR(1000) NOT NULL               COMMENT '书评内容',
  `likes`      INT UNSIGNED NOT NULL DEFAULT 0      COMMENT '该条书评点赞数',
  `status`     TINYINT      NOT NULL DEFAULT 1      COMMENT '1正常 0已删除',
  `created_at` DATETIME     NOT NULL DEFAULT CURRENT_TIMESTAMP,
  PRIMARY KEY (`id`),
  -- 列表接口固定走「某篇作品 + 正常状态」,所以建成复合索引
  KEY `idx_comment_article` (`article_id`, `status`),
  KEY `idx_comment_user` (`user_id`),
  KEY `idx_comment_parent` (`parent_id`),
  CONSTRAINT `fk_comment_user`    FOREIGN KEY (`user_id`)    REFERENCES `user` (`id`)    ON DELETE CASCADE,
  CONSTRAINT `fk_comment_article` FOREIGN KEY (`article_id`) REFERENCES `article` (`id`) ON DELETE CASCADE,
  CONSTRAINT `fk_comment_parent`  FOREIGN KEY (`parent_id`)  REFERENCES `comment` (`id`) ON DELETE CASCADE
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='书评表';

-- ④ 收藏表:联合唯一键保证「一个人对一篇作品只能收藏一次」
DROP TABLE IF EXISTS `favorite`;
CREATE TABLE `favorite` (
  `id`         INT UNSIGNED NOT NULL AUTO_INCREMENT,
  `user_id`    INT UNSIGNED NOT NULL COMMENT '用户',
  `article_id` INT UNSIGNED NOT NULL COMMENT '作品',
  `created_at` DATETIME     NOT NULL DEFAULT CURRENT_TIMESTAMP,
  PRIMARY KEY (`id`),
  UNIQUE KEY `uk_fav_user_article` (`user_id`, `article_id`),
  KEY `idx_fav_article` (`article_id`),
  CONSTRAINT `fk_fav_user`    FOREIGN KEY (`user_id`)    REFERENCES `user` (`id`)    ON DELETE CASCADE,
  CONSTRAINT `fk_fav_article` FOREIGN KEY (`article_id`) REFERENCES `article` (`id`) ON DELETE CASCADE
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='收藏表';

-- ⑤ 点赞记录表:一张表管两种对象,靠 target_type 区分
DROP TABLE IF EXISTS `user_like`;
CREATE TABLE `user_like` (
  `id`          INT UNSIGNED NOT NULL AUTO_INCREMENT,
  `user_id`     INT UNSIGNED NOT NULL,
  `target_type` ENUM('article','comment') NOT NULL COMMENT '点赞对象类型',
  `target_id`   INT UNSIGNED NOT NULL                COMMENT '对象ID',
  `created_at`  DATETIME     NOT NULL DEFAULT CURRENT_TIMESTAMP,
  PRIMARY KEY (`id`),
  UNIQUE KEY `uk_like` (`user_id`, `target_type`, `target_id`),
  KEY `idx_like_target` (`target_type`, `target_id`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='点赞记录表';

-- ⑥ 首页轮播图:独立配置,不依赖任何表
DROP TABLE IF EXISTS `banner`;
CREATE TABLE `banner` (
  `id`         INT UNSIGNED NOT NULL AUTO_INCREMENT,
  `title`      VARCHAR(100) NOT NULL COMMENT '标题',
  `image`      VARCHAR(255) NOT NULL COMMENT '图片路径',
  `link`       VARCHAR(255) DEFAULT ''   COMMENT '点击跳转链接',
  `sort`       INT          NOT NULL DEFAULT 0,
  `status`     TINYINT      NOT NULL DEFAULT 1,
  `created_at` DATETIME     NOT NULL DEFAULT CURRENT_TIMESTAMP,
  PRIMARY KEY (`id`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='首页轮播图表';
查询结果SQL 执行结果
运行结果: -- 验证 7 张表的引擎与字符集都统一了 SELECT TABLE_NAME AS 表名, ENGINE AS 引擎, TABLE_COLLATION AS 排序规则 FROM information_schema.TABLES WHERE TABLE_SCHEMA = 'hiwen' AND TABLE_TYPE = 'BASE TABLE' ORDER BY TABLE_NAME; 表名 | 引擎 | 排序规则 article | InnoDB | utf8mb4_general_ci banner | InnoDB | utf8mb4_general_ci category | InnoDB | utf8mb4_general_ci comment | InnoDB | utf8mb4_general_ci favorite | InnoDB | utf8mb4_general_ci user | InnoDB | utf8mb4_general_ci user_like | InnoDB | utf8mb4_general_ci -- 共 7 行
讲透:三处最容易做错的地方
  • ON DELETE 要一表一议:article.category_id 用 SET NULL(分类被删,作品还在,只是暂时没归类);comment、favorite 用 CASCADE(作品被删,它的书评和收藏跟着删)。写之前先问一句:主表记录没了,子表这些行还有意义吗?
  • 索引建在「查询条件」上,不是建得越多越好:idx_comment_article (article_id, status) 是按「查某篇作品的书评、且只看正常状态」这个真实查询建的。索引会占空间、拖慢写入,够用就行。
  • 唯一键要「事后加」很难:uk_like (user_id, target_type, target_id) 一张联合唯一键,就替掉了「先查有没有点过、再决定插不插」那套容易并发出错的逻辑。
避坑外键有个硬性要求:两边的字段类型必须完全一致——INT UNSIGNED 只能对 INT UNSIGNED,一个是 INT、一个是 INT UNSIGNED,建表时会直接报错 errno: 150。同样地,两边引擎都必须是 InnoDB。
另外脚本里出现的 SET FOREIGN_KEY_CHECKS = 0 是为了建表顺序灵活用的临时开关——用完必须立刻改回 1。一直关着,外键就形同虚设了。

视图 v_article_list

把最常用的那段 JOIN 存起来
先讲为什么作品列表页要显示分类名,而分类名在另一张表里——所以每次列表查询都得写一遍 article JOIN category。
同一条 JOIN 写在五个地方,改一次就得改五处。视图就是把这条 JOIN「存起来」,起个名字反复用。
动手做建视图时用 CREATE OR REPLACE VIEW,这样反复执行脚本不会报「已存在」。视图里只放 status = 1 的已发布作品。
建视图.sqlsql可运行
-- 作品列表视图:article 左连接 category,只保留已发布作品
CREATE OR REPLACE VIEW `v_article_list` AS
SELECT a.`id`, a.`title`, a.`author`, a.`dynasty`,
       c.`name` AS `category_name`,
       a.`cover`, a.`summary`, a.`views`, a.`likes`,
       a.`is_recommend`, a.`created_at`
FROM `article` a
LEFT JOIN `category` c ON a.`category_id` = c.`id`
WHERE a.`status` = 1;

-- 用法:像查一张普通表那样查视图
SELECT id, title, category_name, views
FROM v_article_list
WHERE is_recommend = 1
ORDER BY views DESC;
查询结果SQL 执行结果
运行结果: id | title | category_name | views 3 | 念奴娇·赤壁怀古 | 宋词 | 412 1 | 静夜思 | 唐诗 | 328 2 | 春望 | 唐诗 | 256 -- 共 3 行
讲透:视图不存数据,它存的是「一句话」
  • 虚拟表:视图本身不占数据空间,每次查它,MySQL 就把里面那条 SELECT 展开执行一遍。所以基表数据一变,视图结果跟着变。
  • 用 LEFT JOIN 而不是 JOIN:万一某篇作品暂时没有分类,LEFT JOIN 还能把它列出来(分类名显示为空),JOIN 会直接把它藏掉——列表页就会「莫名其妙少了几篇」。
  • 阶段 3 有两套写法可选:用 ORM 的关系属性(article.category.name),或者直接查这个视图。前者更 Python,后者更快——两种都被接受,说清取舍就行。
  • 视图的最大限制是不能传参数:所以「按朝代筛选」不能做成视图,只能在视图外面补 WHERE dynasty = '唐'。
避坑别指望在视图定义里写 ORDER BY 来「让结果有序」。视图是一张虚拟表,表没有顺序——真正取数时 MySQL 完全可能忽略你写的排序。要顺序,就在查视图的那条语句里显式写 ORDER BY。
另外,视图别用来做写操作(UPDATE v_article_list),它牵涉多张表,行为容易超出你的预期。

种子数据怎么造

空库没法验收
先讲为什么表建好了、一条数据都没有,前端打开就是一片空白——这没法验收,也没法调试分页、搜索、排序。
所以建表之后紧接着就是种子数据(seed data):一套贴近真实业务的初始数据,让整条链路跑得起来。
动手做先灌分类和作品。注意顺序:有外键的表要等被引用的表先有数据——作品里的 category_id 必须指向已经存在的分类。
种子数据-分类与作品.sqlsql可运行
-- 分类:5 条(对应古典文学的体裁划分)
INSERT INTO `category` (`name`, `description`, `sort`) VALUES
('唐诗', '唐代诗歌,格律严谨、气象恢宏', 1),
('宋词', '宋代词作,婉约与豪放并存',   2),
('元曲', '元代散曲与杂剧,通俗生动',   3),
('散文', '先秦至明清的散文名篇',       4),
('小说', '章回体与笔记体小说',         5);

-- 作品:6 篇(category_id 对应上面的分类 id:1 唐诗 / 2 宋词 / 3 元曲 / 4 散文)
INSERT INTO `article`
  (`title`, `author`, `dynasty`, `category_id`, `summary`, `content`, `views`, `likes`, `is_recommend`) VALUES
('静夜思', '李白', '唐', 1, '床前明月光,疑是地上霜。举头望明月,低头思故乡。',
 '床前明月光,疑是地上霜。\n举头望明月,低头思故乡。\n\n【赏析】以月光为线索,写尽羁旅思乡之情。', 328, 42, 1),
('春望', '杜甫', '唐', 1, '国破山河在,城春草木深。',
 '国破山河在,城春草木深。\n感时花溅泪,恨别鸟惊心。\n\n【赏析】以乐景写哀情,字字沉痛。', 256, 31, 1),
('念奴娇·赤壁怀古', '苏轼', '宋', 2, '大江东去,浪淘尽,千古风流人物。',
 '大江东去,浪淘尽,千古风流人物。\n故垒西边,人道是,三国周郎赤壁。\n\n【赏析】豪放词代表,借古抒怀。', 412, 58, 1),
('声声慢', '李清照', '宋', 2, '寻寻觅觅,冷冷清清,凄凄惨惨戚戚。',
 '寻寻觅觅,冷冷清清,凄凄惨惨戚戚。\n乍暖还寒时候,最难将息。\n\n【赏析】叠字起句,婉约极致。', 298, 47, 0),
('天净沙·秋思', '马致远', '元', 3, '枯藤老树昏鸦,小桥流水人家。',
 '枯藤老树昏鸦,小桥流水人家,古道西风瘦马。\n夕阳西下,断肠人在天涯。\n\n【赏析】白描成画,秋思绝唱。', 187, 22, 0),
('陋室铭', '刘禹锡', '唐', 4, '山不在高,有仙则名;水不在深,有龙则灵。',
 '山不在高,有仙则名;水不在深,有龙则灵。\n斯是陋室,惟吾德馨。\n\n【赏析】托物言志,安贫乐道。', 203, 26, 0);
查询结果SQL 执行结果
运行结果: -- ① 分类 SELECT id, name, sort FROM category ORDER BY sort; id | name | sort 1 | 唐诗 | 1 2 | 宋词 | 2 3 | 元曲 | 3 4 | 散文 | 4 5 | 小说 | 5 -- 共 5 行 -- ② 作品 SELECT id, title, author, dynasty, category_id, views FROM article; id | title | author | dynasty | category_id | views 1 | 静夜思 | 李白 | 唐 | 1 | 328 2 | 春望 | 杜甫 | 唐 | 1 | 256 3 | 念奴娇·赤壁怀古 | 苏轼 | 宋 | 2 | 412 4 | 声声慢 | 李清照 | 宋 | 2 | 298 5 | 天净沙·秋思 | 马致远 | 元 | 3 | 187 6 | 陋室铭 | 刘禹锡 | 唐 | 4 | 203 -- 共 6 行
讲透:用户表的密码不能是明文,也不能是随便一串哈希
  • 后端存的是哈希,不是密文:注册时 generate_password_hash() 加盐生成哈希串,登录时 check_password_hash() 比对。数据库里永远看不到原始密码。
  • 所以种子脚本里没法直接写密码:脚本先写占位符 PUT_HASH_HERE,用下面这条命令生成真实哈希,再替换进去。
  • 哈希每次都不同:同一句 generate_password_hash('123456') 跑两次结果不一样(盐是随机的),但都能通过校验——这是正常现象,别以为是 bug。
生成密码哈希.shbash可运行
# 生成 123456 的哈希(Werkzeug 3.x 默认用 scrypt 算法)
python -c "from werkzeug.security import generate_password_hash as g; print(g('123456'))"

# 把输出替换掉脚本里的占位符(也可以建完库直接 UPDATE)
# mysql -uroot -p -e "UPDATE hiwen.user SET password='<上一步的输出>' WHERE username='admin';"
终端输出命令行执行结果
运行结果: -- 验证用户表里存的是哈希而不是明文(只看前缀) SELECT username, LEFT(password, 14) AS 哈希前缀 FROM user; username | 哈希前缀 admin | scrypt:32768:8 libai | scrypt:32768:8 -- 共 2 行 (哈希串每次生成都不同,这里只看算法前缀;种子里两个用户的密码都是 123456)
避坑三点必须守住:① 脚本里绝不出现明文密码(占位符就是为了逼你别写);② 绝不用生产库的真实用户数据当种子(手机号、邮箱都是隐私);③ 种子数据要贴近真实——「静夜思 / 李白 / 唐」比「测试1 / abc / 1」有用得多,因为你能一眼看出分页、排序、筛选对不对。
还有一个新手常问的问题:为什么用 admin 登录一直提示「用户名或密码错误」?因为脚本里是占位符 PUT_HASH_HERE。要么按上面的命令更新哈希,要么干脆用注册接口新建一个账号。

导入与验证

脚本写完 ≠ 库建好了
导入与验证:建库 → 导入 → 三步验证,缺一步都不算数① 建库CREATE DATABASE hiwenDEFAULT CHARACTER SET utf8mb4② 导入mysql -u root -p hiwen < hiwen.sql③ 验证SHOW TABLES; → 7 张表SELECT COUNT(*) FROM article;验证清单:SHOW TABLES 数得出 7 张 · SELECT 每张表能查出种子数据 · 外键约束生效(插非法 category_id 报错)常见失败:没先建库 · 字符集不一致(中文变 ???)· 视图引用顺序(先表后视图)交付标准:同学拿你的 hiwen.sql,一条命令就能建出一样的库
先讲为什么脚本写完了,但「写完」和「跑通」是两件事。你自己写的东西,一定要用别人的眼睛验证一遍——最靠得住的办法是:把库删了,从头跑一次脚本,看能不能复原。
这就是 M2 的验收动作。
动手做进入 MySQL 客户端,用 source 导入脚本(比在终端里用 < 重定向更好,因为报错时会告诉你停在哪一行),然后列出库里的对象。
导入与查看.sqlsql可运行
-- ① 在系统终端里连上客户端(路径按你的实际位置改)
--    mysql -uroot -p

-- ② 在 mysql> 提示符下导入整个脚本
source /Users/you/pyweb/sql/hiwen.sql

-- ③ 确认库里有哪些表(视图也会一并列出)
SHOW TABLES;
查询结果SQL 执行结果
运行结果: Tables_in_hiwen article banner category comment favorite user user_like v_article_list -- 共 8 行

7 张表 + 1 个视图都在。接着逐个字地核对核心表的字段——这一步能抓出 90% 的建表笔误(拼错列名、漏了默认值、类型写窄了)。

验证字段.sqlsql可运行
DESC `article`;
查询结果SQL 执行结果
运行结果: Field | Type | Null | Key | Default | Extra id | int unsigned | NO | PRI | NULL | auto_increment title | varchar(150) | NO | - | NULL | - author | varchar(50) | YES | - | NULL | - dynasty | varchar(20) | YES | - | NULL | - category_id | int unsigned | YES | MUL | NULL | - cover | varchar(255) | YES | - | - | - summary | varchar(500) | YES | - | NULL | - content | text | YES | - | NULL | - views | int unsigned | NO | - | 0 | - likes | int unsigned | NO | - | 0 | - is_recommend | tinyint | NO | - | 0 | - status | tinyint | NO | - | 1 | - created_at | datetime | NO | - | CURRENT_TIMESTAMP | DEFAULT_GENERATED updated_at | datetime | NO | - | CURRENT_TIMESTAMP | on update CURRENT_TIMESTAMP -- 共 14 行
动手做最后跑几条汇总查询,把「数据齐不齐」变成一行数字。M2 的验收标准就是:这六个计数和预期完全一致。
验证数据.sqlsql可运行
SELECT (SELECT COUNT(*) FROM `category`)  AS 分类,
       (SELECT COUNT(*) FROM `article`)   AS 作品,
       (SELECT COUNT(*) FROM `comment`)   AS 书评,
       (SELECT COUNT(*) FROM `favorite`)  AS 收藏,
       (SELECT COUNT(*) FROM `user_like`) AS 点赞,
       (SELECT COUNT(*) FROM `banner`)    AS 轮播图;
查询结果SQL 执行结果
运行结果: 分类 | 作品 | 书评 | 收藏 | 点赞 | 轮播图 5 | 6 | 3 | 3 | 3 | 3 -- 共 1 行
讲透:一次导入的完整顺序
  • 删库 → 建库 → 建表 → 建视图 → 灌数据,五步都在这一个脚本里,顺序不能乱:视图依赖表,数据依赖表。
  • 视图要在数据之前建也没问题,但视图的查询结果要在数据之后看——所以脚本里把视图放在建表之后、种子数据之前,跑完再自己查一遍。
  • 报错怎么看:source 中断时,看最后一行 ERROR 1064(语法错,看它提示的位置)、ERROR 1215(外键建不上,多半是类型或引擎不一致)、ERROR 1146(表不存在,多半是顺序反了)。
  • 可重复执行的价值在这一步兑现:改完表结构,直接重跑一次脚本,库就回到了干净状态——不用手工删表,不用怕漏。

常用查询准备:阶段 3 会用到

每个接口背后都有一条查询
先讲为什么阶段 3 要写 18 个接口,但它们背后其实就是十来条查询。在 SQL 里先把这些查询验证一遍,比在 Python 里对着 ORM 猜要快得多:SQL 跑通了,ORM 只是换个写法而已。
动手做先把「作品 + 分类名」这条最常用的 JOIN 验证了——它就是列表接口和详情接口的基础。
作品列表查询.sqlsql可运行
-- 列表接口的核心查询:作品 + 分类名
SELECT a.id, a.title, a.author, c.name AS category_name
FROM `article` a
JOIN `category` c ON a.category_id = c.id
ORDER BY a.id;
查询结果SQL 执行结果
运行结果: id | title | author | category_name 1 | 静夜思 | 李白 | 唐诗 2 | 春望 | 杜甫 | 唐诗 3 | 念奴娇·赤壁怀古 | 苏轼 | 宋词 4 | 声声慢 | 李清照 | 宋词 5 | 天净沙·秋思 | 马致远 | 元曲 6 | 陋室铭 | 刘禹锡 | 散文 -- 共 6 行

再看一条聚合查询。它对应的是「分类列表带作品数」这类需求:注意用 LEFT JOIN 才能让「小说」这个还没作品的分类也出现(篇数为 0)。

分类统计查询.sqlsql可运行
SELECT c.name AS 分类,
       COUNT(a.id) AS 篇数,
       SUM(a.views) AS 总浏览
FROM `category` c
LEFT JOIN `article` a ON a.category_id = c.id
GROUP BY c.id, c.name
ORDER BY c.sort;
查询结果SQL 执行结果
运行结果: 分类 | 篇数 | 总浏览 唐诗 | 2 | 584 宋词 | 2 | 710 元曲 | 1 | 187 散文 | 1 | 203 小说 | 0 | NULL -- 共 5 行
讲透:接口 → 查询的对照表阶段 3 写代码时,你手里的「武器库」就是下面这几条。先在这张表里找到对应的查询,再去写 ORM,速度会快很多。
接口 用到的表 / 视图 查询要点
GET /api/banner/listbanner只查 status = 1,按 sort 升序
GET /api/category/listcategory只查 status = 1,按 sort 升序
GET /api/article/recommendarticleis_recommend = 1 且 status = 1,按 views 倒序 + limit
GET /api/article/listv_article_list / article + category条件逐段叠加:分类 / 朝代 / 关键词,最后排序 + 分页
GET /api/article/detail/<id>article + category + comment先查作品,再 views + 1,再数书评条数
GET /api/comment/listcomment + user按 article_id 查,回复嵌在 children 里
GET /api/favorite/listfavorite + article + category以 favorite 为主表,按 created_at 倒序 + 分页
POST /api/favorite/togglefavorite查 (user_id, article_id) 有没有,有就删、没有就插
避坑上面那条聚合查询里,JOIN 和 LEFT JOIN 的差别不是「风格」,是结果:用 JOIN,「小说」这个还没有作品的分类会整行消失,前端下拉框里就少一个分类;用 LEFT JOIN 它才会以「0 篇」出现。
顺带记一句:「没有数据」和「数据是 0」不一样。上面「小说」的总浏览是 NULL 而不是 0——因为 SUM 对空集合返回 NULL。真要显示 0,得在外面套一层 IFNULL(SUM(a.views), 0)。
动手练习
1查出每篇作品的书评数,要求「一条书评都没有的作品也要列出来」(提示:这是 LEFT JOIN 的经典场景)。
2统计每个分类的作品数与平均浏览量(保留 1 位小数),按篇数从多到少排列。做完想一想:这一步的排序为什么不能只写 ORDER BY 篇数 DESC?
3迁移练习:「红影赏析」要建两张表 movie(电影)和 review(影评)。请写出:① 两张表的建表语句(含外键、索引、字符集);② 2 条电影种子数据;③ 一个能带出影评数的视图 v_movie_list。想一想:它和古典文学网的做法哪里完全一样?
参考答案
练习答案.sqlsql可运行
-- 第 1 题:每篇作品的书评数(含 0 条)
-- 注意 COUNT(c.id) 而不是 COUNT(*):左连接没匹配上时 c.id 是 NULL,不计数
SELECT a.id, a.title, COUNT(c.id) AS 书评数
FROM `article` a
LEFT JOIN `comment` c ON c.article_id = a.id AND c.status = 1
GROUP BY a.id, a.title
ORDER BY a.id;

-- 第 2 题:分类的作品数与平均浏览量
SELECT c.name AS 分类,
       COUNT(a.id) AS 篇数,
       ROUND(AVG(a.views), 1) AS 平均浏览
FROM `category` c
LEFT JOIN `article` a ON a.category_id = c.id
GROUP BY c.id, c.name
ORDER BY 篇数 DESC, c.sort;

-- 第 3 题(红影赏析:换的是实体名,骨架完全一样)
CREATE TABLE `movie` (
  `id`         INT UNSIGNED NOT NULL AUTO_INCREMENT,
  `title`      VARCHAR(100) NOT NULL COMMENT '片名',
  `genre`      VARCHAR(30)      DEFAULT NULL COMMENT '类型',
  `rating`     DECIMAL(3,1) NOT NULL DEFAULT 0.0 COMMENT '评分',
  `created_at` DATETIME     NOT NULL DEFAULT CURRENT_TIMESTAMP,
  PRIMARY KEY (`id`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='电影表';

CREATE TABLE `review` (
  `id`         INT UNSIGNED NOT NULL AUTO_INCREMENT,
  `movie_id`   INT UNSIGNED NOT NULL COMMENT '被评电影',
  `user_id`    INT UNSIGNED NOT NULL COMMENT '评论人',
  `content`    VARCHAR(500) NOT NULL,
  `created_at` DATETIME     NOT NULL DEFAULT CURRENT_TIMESTAMP,
  PRIMARY KEY (`id`),
  KEY `idx_review_movie` (`movie_id`),
  CONSTRAINT `fk_review_movie` FOREIGN KEY (`movie_id`)
    REFERENCES `movie` (`id`) ON DELETE CASCADE
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='影评表';

INSERT INTO `movie` (`title`, `genre`, `rating`) VALUES
('流浪地球', '科幻', 8.4),
('星际穿越', '科幻', 9.3);

CREATE OR REPLACE VIEW `v_movie_list` AS
SELECT m.`id`, m.`title`, m.`genre`, m.`rating`,
       COUNT(r.`id`) AS `review_count`
FROM `movie` m
LEFT JOIN `review` r ON r.`movie_id` = m.`id`
GROUP BY m.`id`, m.`title`, m.`genre`, m.`rating`;
查询结果SQL 执行结果
运行结果: -- 第 1 题 id | title | 书评数 1 | 静夜思 | 2 2 | 春望 | 0 3 | 念奴娇·赤壁怀古 | 1 4 | 声声慢 | 0 5 | 天净沙·秋思 | 0 6 | 陋室铭 | 0 -- 共 6 行 -- 第 2 题 分类 | 篇数 | 平均浏览 唐诗 | 2 | 292.0 宋词 | 2 | 355.0 元曲 | 1 | 187.0 散文 | 1 | 203.0 小说 | 0 | NULL -- 共 5 行 -- 第 3 题(红影赏析的库里执行) SELECT title, genre, rating FROM movie ORDER BY rating DESC; title | genre | rating 星际穿越 | 科幻 | 9.3 流浪地球 | 科幻 | 8.4 -- 共 2 行

第 2 题的坑在排序:只写 ORDER BY 篇数 DESC 时,篇数相同的两行顺序是不确定的(唐诗和宋词都是 2 篇),翻页时可能来回跳。所以补一个 c.sort 做次关键字——这和阶段 1 说的「排序要有确定顺序」是同一件事。第 3 题的关键:外键、索引、utf8mb4、视图这四样,跟表叫什么名字毫无关系。

本阶段小结