🪟 第20章 Java与SQL Server实现学生成绩管理系统
📄 一句话概括

班级表是整条链的起点,所以它最「安全」——只被一个模块写,改坏了影响一大片。 列表一定要带「这个班多少人」,因为「删不删得掉」取决于人数。 「改成毕业」不等于「能删」——外键还指着它,软删除解决不了引用问题。

📝 本章和前面几章的关系

从这一节开始进业务模块,四个模块(班级、学生、成绩、查询)骨架一样: 列表 → 选中 → 编辑 → 校验 → 保存 → 刷新。这一节把骨架走一遍,后面三节就快了。 教材 20.8 分三小节:概述、技术分析、实现过程。 「实现过程」里的删除部分值得细看——它要处理「班里还有学生」这个情况。 沙箱没有 JSP,所以「页面」用「表格输出 + 提示文案」复刻, 复刻的是「用户点了之后数据怎么变」,不是「按钮长什么样」。

一、列表:两个数比一个数有用

列 从哪来 为什么要有
班名 tb_class.cname 主标识
年级 tb_class.grade 排序和筛选
班主任 tb_class.teacher 可空,新班可能没定
人数 COUNT(tb_student.id) 决定能不能删

列表页永远要带「这个班有多少人」。 因为「删不删得掉」取决于人数—— 管理员点了删除才知道删不掉,体验很差。 这个人数要 LEFT JOIN 数出来,不能用 JOIN。 新班没有学生,JOIN 会让它整行消失—— 管理员刚建完班,列表里就看不到,他一定会再建一遍,于是重名报错。

二、增:一个班级三件事

要做的事 注意什么
班名唯一 UNIQUE 拦住重名,报错要翻译成人话
年级必填 NOT NULL,别指望页面校验
班主任可空 新班可能还没定班主任

「报错要翻译」这件事看着小事,其实是体验的分水岭。 直接抛出 UNIQUE constraint failed 给用户看,他只会觉得系统坏了。 做法是在 DAO 里 catch 住,按错误类型返回人话。 「班级名已存在,请换一个」比一堆英文有用得多。 还有一个更细的点: 重名检查该在页面做还是数据库做? 都做。 页面做是为了「不用等一次往返就知道错了」,数据库做是为了「并发下也不会重」。 两个目的不同,不冲突。

三、改:改班名影响谁

改名后 会怎样
学生表里的 class_id 不受影响(存的是 id)
历史成绩 不受影响(从学生表连过去)
页面上的旧截图、打印件 还是旧名字——这个改不了

这就是「存 id 不存名字」的价值。 改一次班名,一处改完,全站生效。 如果学生表里存的是班名字符串,改名要改所有学生记录,还得担心漏。 但有个例外要记住:历史表要存名字(第 19 章调岗记录那样)。 区别在于「历史要冻结当时的样子」,主数据要「跟着当前状态走」。

四、删:三种情况,三种回答

情况 能不能删 该给什么提示
班里 0 人 能删 确认即可
班里有人 删不掉 「还有 4 人,请先转班」
学生都改成毕业/转出 还是删不掉 「class_id 还指着这个班」

第三种情况是这一节最容易踩的坑。 「软删除」只解决「人还在不在名单里」,不解决「外键引用」。 class_id 还指着这个班,外键照样拦。 真要撤班,学生必须挪到另一个班(或者真删)。 所以界面上该给的提示是「这个班还有 4 人,请先处理」, 而不是「删除失败」——前者告诉他下一步做什么,后者只告诉他不行。

五、动手 Demo:班级的增删改查与三种删除结果

这一段把班级的四个动作各跑一遍,重点在第四节:三种删除情况各是什么结果。 尤其是第三种——学生都改成「转出」了,班级还是删不掉。

# ① 列表带人数(LEFT JOIN 不能换成 JOIN)
sql = '''SELECT c.id, c.cname, c.teacher, COUNT(s.id) AS 人数
           FROM tb_class c
           LEFT JOIN tb_student s ON s.class_id = c.id
          GROUP BY c.id ORDER BY c.id'''

# ② 增:重名被 UNIQUE 拦住
try:
    c.execute('INSERT INTO tb_class (cname, grade, teacher)'
              ' VALUES (?,?,?)', ('2026级计算机1班', '2026', '某人'))
except Exception as e:
    print('重名:', e)      # 要翻译成「班级已存在」

# ③ 删:三种情况
for sql in ('DELETE FROM tb_class WHERE id = ?',    # 空班
            'DELETE FROM tb_class WHERE id = 1'):    # 有人的班
    try:
        print('影响', c.execute(sql, (new_id,)).rowcount, '行')
    except Exception as e:
        print('被拦住:', e)

实跑输出:

--------------------------------------------------------------------
班级信息设置:最基础也最容易漏
--------------------------------------------------------------------


  ① 四件事:查、增、改、删
  --------------------------------------------------------------------


  1> SELECT id, cname, grade, teacher FROM tb_class ORDER BY id
  id  cname            grade  teacher
  -----------------------------------
  1   2026级计算机1班  2026   刘建国
  2   2026级计算机2班  2026   陈丽
  3   2026级软件1班    2026   周海涛
  4   2025级计算机1班  2025   刘建国
  (4 行受影响)
  列表页永远要带「这个班有多少人」。
  因为「删不删得掉」取决于人数——管理员点删除才知道删不掉,体验很差。


  1> SELECT c.id, c.cname, c.teacher, COUNT(s.id) AS 人数
     FROM tb_class c
     LEFT JOIN tb_student s ON s.class_id = c.id
     GROUP BY c.id
     ORDER BY c.id
  id  cname            teacher  人数
  ----------------------------------
  1   2026级计算机1班  刘建国   4
  2   2026级计算机2班  陈丽     3
  3   2026级软件1班    周海涛   3
  4   2025级计算机1班  刘建国   2
  (4 行受影响)
  LEFT JOIN 不能换成 JOIN。
  新班没有学生,JOIN 会让它整行消失——管理员刚建完班,列表里就看不到,他一定会再建一遍,
  于是重名报错。

  ② 增:一个班级三件事
  --------------------------------------------------------------------
| 要做的事 | 注意什么 |
|---|---|
| 班名唯一 | UNIQUE 拦住重名,报错要翻译成「班级已存在」 |
| 年级必填 | NOT NULL,别指望页面校验 |
| 班主任可空 | 新班可能还没定班主任 |


  重复插入 → 被拦住:UNIQUE constraint failed: tb_class.cname
  「报错要翻译」这件事看着小事,其实是体验的分水岭。
  直接抛出 UNIQUE constraint failed 给用户看,他只会觉得系统坏了。
  做法是在 DAO 里 catch 住,按错误类型返回人话:「班级名已存在,请换一个」比一堆英文有用得多。

  ③ 改:改班名要想清楚一件事
  --------------------------------------------------------------------
| 改名后 | 会怎样 |
|---|---|
| 学生表里的 class_id | 不受影响(存的是 id) |
| 历史成绩 | 不受影响(从学生表连过去) |
| 页面上的旧截图、打印件 | 还是旧名字——这个改不了 |

  这就是「存 id 不存名字」的价值。 改一次班名,一处改完,全站生效。
  如果学生表里存的是班名字符串,改名要改所有学生记录,还得担心漏。
  但有个例外要记住:历史表要存名字(第 19 章调岗记录那样)。
  区别在于「历史要冻结当时的样子」,主数据要「跟着当前状态走」。

  ④ 删:三种情况,三种回答
  --------------------------------------------------------------------

  先建一个空班:「2027级试点班」,id = 5,班里 0 人
  甲 删这个空班:
    → 影响 1 行,班级数 5 → 4
  乙 删有人的班(id=1,四人):
    → 被拦住:FOREIGN KEY constraint failed

  丙 先把学生转走,再删空出来的班(id=4,两人):
    转班前,id=4 班人数:2
    把两人转到 id=3 班,再删 id=4 → 影响 1 行,班级数 4 → 3
  乙那条是「正常会发生的操作」——教务要撤一个班,班里还有四个学生。
  正确做法不是「强行删」,是「先把学生转到别的班」,然后班级才删得掉。
  注意「改成毕业/转出」不等于「能删」——class_id 还指着这个班,外键照样拦。
  软删除只解决「人还在不在名单里」,不解决「外键引用」。
  真要撤班,学生必须挪到另一个班(或者真删)。
  所以界面上该给的提示是「这个班还有 4 人,请先处理」,
  而不是「删除失败」——前者告诉他下一步做什么,后者只告诉他不行。

  ⑤ 一个班最多装多少人:要不要做上限
  --------------------------------------------------------------------
| 做法 | 代价 |
|---|---|
| 不做上限 | 数据可能不真实,但没有功能损失 |
| CHECK 拦(需要触发器) | 复杂,且插入时才报错 |
| 业务层校验 | 简单,并发下可能超一两个 |

  教材没做上限,这一版也没做。
  理由是「班级人数不是硬约束」——多一个人不会让数据错,只会让统计不好看。
  判断一条规则要不要硬拦,看「违反了会不会导致数据错」。会导致(成绩 120 分、学号重复)就硬拦;
  不会(班级多一个人)就提示。

列表那一列「人数」不是存在表里的,是每次数出来的。 有人会想在 tb_class 里加一个 cnt 字段,每次增删学生时维护它—— 这是典型的「用空间换时间,但换来了不一致」。 一旦某次漏更新,人数就永远是错的,而且没人知道什么时候错的。 重名那条报错信息是英文的。 真实代码要 catch 住再翻译, 而且要先判断「是唯一键冲突」还是「别的错」——把所有错都翻译成「已存在」,会掩盖真正的问题。 删除那一节里,「有人的班」被外键拦住,班级数没有变化。 这个「拦住了而不是删了一半」的性质,就是外键的价值。

30 秒实验

  1. 把 LEFT JOIN 换成 JOIN,先建一个空班再跑列表,看这个班还在不在。
  2. 把一个班的学生全部改成 state='转出',再删这个班,看能不能删。 删不掉——外键不管状态。
  3. 想一想:如果学校要求「班级可以归档但不能删」,该怎么设计? 加一个 state 字段,列表默认只查「在用」的班,跟学生的软删除一个套路。

常见误区

  1. 「列表里加个 cnt 字段存人数更快」。 漏更新一次就永远错,而且查不出来。 每次数一遍的成本可以忽略。
  2. 「报错直接显示给用户就行」。 英文报错只会让人觉得系统坏了。 要翻译成人话。
  3. 「学生都转出了,班就能删了」。 class_id 还指着它,外键照样拦。 软删除不解决引用。
自测三问(点开对答案) 1. 列表里的人数为什么要 `LEFT JOIN`? —— **新班没有学生,`JOIN` 会让它整行消失**,**管理员会以为班没建上。** 2. 改班名为什么不用改学生表? —— **因为学生表存的是 `class_id`,不是班名。** **这就是「存 id 不存名字」的价值。** 3. 学生都改成「转出」了,班级为什么还删不掉? —— **因为 `class_id` 还指着这个班,外键引用还在。** **软删除只解决「名单」,不解决「引用」。**

下一篇 20.9,学生管理:分页要稳定排序、批量导入要包事务、删除要走软删——三个坑一次讲完。

相关笔记