画结构图先别管角色:先把「系统能做什么」画全,再决定「谁能做什么」。 角色是「视图」,不是「结构」——同一片叶子,三个人看到的数据不一样,但它在结构上只有一个。 「录过了」不是错误,是正常的业务状态,它需要「提示 + 确认」,不是「报错」。
20.1 回答「要什么」,20.2 回答「怎么分」。这一节的技术含量不高,但有一个关键判断:
什么时候该写三个页面,什么时候该写一个页面加三个 WHERE。
教材 20.2 分三小节:系统目标、系统功能结构、系统预览。
「系统预览」那一节走了一遍「录成绩」的全流程,
里面有一步是「已经录过了怎么办」——这一步是整章最有业务味的地方。
沙箱没有 Tomcat,所以「页面」用「打印菜单树和列表」的方式复刻,
复刻的是「页面之间怎么跳、数据怎么带」,不是「页面长什么样」。
一、先画一棵全的树,再谈角色
| 一级节点 | 下面挂什么 |
|---|---|
| 登录 | (无子节点) |
| 班级管理 | 班级设置、班级学生名单 |
| 学生管理 | 学生信息维护、批量导入 |
| 成绩管理 | 成绩录入、成绩修改、成绩查询 |
| 系统管理 | 用户与角色、数据导出 |
画结构图的时候先别管角色。 先把「系统能做什么」画全,再决定「谁能做什么」—— 两件事混着画,最后一定漏功能(因为你会下意识只画自己关心的那部分)。 角色是「视图」,不是「结构」。 同一个叶子节点,三种角色看到的不一样,但它在结构上只有一个。 这一点想通了,代码就不会写成三套。
二、三种角色,三套菜单,同一个页面
| 角色 | 一级菜单 | 二级项数 |
|---|---|---|
| 管理员 | 班级/学生/成绩/系统管理 | 9 |
| 教师 | 班级管理(只看自己班)/成绩管理 | 4 |
| 学生 | 成绩查询(只有自己) | 1 |
注意「班级管理」教师也有,但只能看自己带的班。
同一个菜单项,两种数据范围——所以范围限制必须做在数据层,不能做在菜单层。
「成绩查询」这个页面,三种角色看到的是同一个文件。
差别只在 WHERE 那一段:管理员不加条件、教师加 class_id、学生加 stu_id。
为什么不写三个页面? 因为改一个需求要改三处,而且一定会漏。
「一个页面 + 数据范围」是这一章最省事的做法。
三、模块和表:谁读谁写
| 模块 | 主要读 | 主要写 |
|---|---|---|
| 班级设置 | class/student | class |
| 学生信息 | student/class | student |
| 成绩录入 | student/score | score |
| 成绩修改 | score | score |
| 成绩查询 | score/student/class | — |
| 用户角色 | user | user |
tb_score 被三个模块写(录入、修改、批量导入),这是最容易出「成绩被改乱」的地方。
tb_class 只被一个模块写——这种「单写」的表最好管,也是唯一能安全缓存的表。
成绩表绝对不能缓存:一秒前刚改的成绩必须立刻看到。
四、录成绩的全流程:第四步最关键
| 步 | 页面发生什么 | 数据库发生什么 |
|---|---|---|
| 1 | 教师选班级、选科目 | SELECT ... FROM tb_class |
| 2 | 列出该班所有学生 | SELECT ... WHERE class_id=? |
| 3 | 逐个填分,点保存 | 一批 INSERT INTO tb_score |
| 4 | 有重复(之前录过) | UNIQUE 拦住,整批回滚 |
| 5 | 提示「该科已录过,要覆盖吗」 | 确认后改 UPDATE |
| 6 | 刷新列表 | SELECT ... FROM v_score |
第 4 步和第 5 步是这个模块的灵魂。
「录过了」不是错误,是正常的业务状态——
它需要的是「提示 + 确认」,不是「报错 + 让人自己想办法」。
实现上有两条路: 一是先查一遍「这批录过没」,二是直接 INSERT 让约束拦。
选第二条——第一条在两个人同时录的时候会漏,第二条不会。
拦下来之后再查一次「已经有哪些」,提示就准确了。
五、哪些想法先不做
| 想法 | 为什么先不做 |
|---|---|
| 自动算排名并导出 Excel | 导出要用另一个库,先出页面 |
| 成绩趋势折线图 | 先有数据,再谈可视化 |
| 家长端小程序 | 账号体系是另一套 |
| 操作日志审计到字段级 | 先记「谁改了哪张表」,别一步到位 |
判断标准还是那一条:谁维护。 一年改三次以上的规则,别硬编进代码。 还有一条更容易忘: 「能不能不做」本身就是一个选项。 导出 Excel 这件事,教务用「复制粘贴到表格」也能凑合半年—— 半年后需求清楚了再做,比现在猜着做强。
六、动手 Demo:功能树、模块矩阵、三套菜单
这一段把功能树和三套菜单各跑一遍,重点看「一级节点怎么跟着消失」。
再对照第三节那张矩阵,看 tb_score 到底被几个模块写。
MENU = [
('班级管理', ['班级设置', '班级学生名单']),
('学生管理', ['学生信息维护', '批量导入']),
('成绩管理', ['成绩录入', '成绩修改', '成绩查询']),
('系统管理', ['用户与角色', '数据导出']),
]
GRANT = {
'管理员': None,
'教师': ['班级学生名单', '成绩录入', '成绩修改', '成绩查询'],
'学生': ['成绩查询'],
}
def build(role):
ok = GRANT[role]
out = []
for top, subs in MENU:
keep = subs if ok is None else [x for x in subs if x in ok]
if keep:
out.append((top, keep))
return out
for role in ('管理员', '教师', '学生'):
tree = build(role)
print(role, len(tree), sum(len(s) for _, s in tree))
实跑输出:
--------------------------------------------------------------------
系统设计:一棵树,三套菜单
--------------------------------------------------------------------
① 功能结构:不分角色,先画一棵全的
--------------------------------------------------------------------
学生成绩管理系统
├─ 登录
├─ 班级管理
│ ├─ 班级设置
│ └─ 班级学生名单
├─ 学生管理
│ ├─ 学生信息维护
│ └─ 批量导入
├─ 成绩管理
│ ├─ 成绩录入
│ ├─ 成绩修改
│ └─ 成绩查询
└─ 系统管理
├─ 用户与角色
└─ 数据导出
画结构图的时候先别管角色。
先把「系统能做什么」画全,再决定「谁能做什么」——两件事混着画,
最后一定漏功能(因为你会下意识只画自己关心的那部分)。
角色是「视图」,不是「结构」。 同一个叶子节点,三种角色看到的不一样,但它在结构上只有一个。
这一点想通了,代码就不会写成三套。
② 三种角色,三套菜单
--------------------------------------------------------------------
| 角色 | 一级菜单 | 二级项数 |
|---|---|---:|
| 管理员 | 班级管理/学生管理/成绩管理/系统管理 | 9 |
| 教师 | 班级管理(只看自己班)/成绩管理 | 4 |
| 学生 | 成绩查询(只有自己) | 1 |
注意「班级管理」教师也有,但只能看自己带的班。
同一个菜单项,两种数据范围——所以范围限制必须做在数据层,不能做在菜单层。
「学生」只有一个菜单项,这是对的。 不是功能少,是职责就只有这一件事。
反而「管理员」九个二级项,才是真的需要工作台和搜索框的人。
③ 模块和表:谁读谁写
--------------------------------------------------------------------
| 模块 | 主要读 | 主要写 |
|---|---|---|
| 班级设置 | class/student | class |
| 学生信息 | student/class | student |
| 成绩录入 | student/score | score |
| 成绩修改 | score | score |
| 成绩查询 | score/student/class | — |
| 用户角色 | user | user |
tb_score 被三个模块写(录入、修改、还有后面的批量导入),这是最容易出「成绩被改乱」的地方。
tb_class 只被一个模块写——这种「单写」的表最好管。
它也是唯一能安全缓存的一张表(班级不常变),成绩表绝对不能缓存,一秒前刚改的成绩必须立刻看到。
④ 系统预览:一次录成绩走完整条链
--------------------------------------------------------------------
| 步 | 页面发生什么 | 数据库发生什么 |
|---|---|---|
| 1 | 教师选班级、选科目 | SELECT ... FROM tb_class |
| 2 | 列出该班所有学生 | SELECT ... WHERE class_id=? |
| 3 | 逐个填分,点保存 | 一批 INSERT INTO tb_score |
| 4 | 有重复(已录过) | UNIQUE 拦住,整批回滚 |
| 5 | 提示「该科已录过,要覆盖吗」 | 确认后改 UPDATE |
| 6 | 刷新列表 | SELECT ... FROM v_score |
第 4 步和第 5 步是这个模块的灵魂。
「录过了」不是错误,是正常的业务状态——它需要的是「提示 + 确认」,不是「报错 + 让人自己想办法」。
实现上有两条路: 一是先查一遍「这批录过没」,二是直接 INSERT 让约束拦。
选第二条——第一条在两个人同时录的时候会漏,第二条不会。
拦下来之后再查一次「已经有哪些」,提示就准确了。
⑤ 哪些想法先不做
--------------------------------------------------------------------
| 想法 | 为什么先不做 |
|---|---|
| 自动算班级排名并导出 Excel | 导出是另一个库的活,先出页面 |
| 成绩趋势折线图 | 先有数据,再谈可视化 |
| 家长端小程序 | 账号体系是另一套 |
| 操作日志审计到字段级 | 先记「谁改了什么表」,别一步到位 |
判断标准还是那一条:谁维护。 一年改三次以上的规则,别硬编进代码。
「学生」只剩「成绩查询」一项,而这一项对三种角色是同一个页面。
页面不区分角色,WHERE 区分——这就是「一个页面 + 数据范围」的全部含义。
如果按角色写三个页面,改一个「排名规则」要改三处。
改三处的东西,第三处一定会忘。
注意「班级学生名单」给了教师,但「班级设置」没给。
这是刻意的:教师要看名单,但不能改班级本身。
同一个模块下,读和写的权限可以分开给。
30 秒实验
- 给教师加上「批量导入」,看菜单怎么变。再想想:教师能不能批量导入成绩? 能,但要限制在自己班。 所以「加菜单」和「加数据范围」是两件事。
- 把
GRANT['学生']改成['成绩查询', '成绩录入'],看会出什么事。 学生能进录入页,但录谁的分? 这就是「有菜单没数据」的荒谬状态。 - 想一想:如果学校要求「班主任只能看自己班,但年级组长能看整个年级」,怎么表达?
加一个角色「年级组长」+ 一个「管辖年级」字段,
WHERE从class_id=?变成grade=?。
常见误区
- 「按角色写三套页面更清楚」。 清楚一时,改起来三倍。 一个页面 + 数据范围才是正解。
- 「录过了就直接报错」。 这是正常业务状态,不是错误。 要给「覆盖吗」的选择。
- 「功能越多越好」。 先做「不做也能凑合」的判断。 半年后需求清楚了再做,比现在猜着做强。
自测三问(点开对答案)
1. 为什么先画完整的树,再分配角色? —— **混着画会漏功能**——**因为你会下意识只画自己关心的那部分。** **角色是视图,不是结构。** 2. 为什么「成绩查询」只写一个页面? —— **三种角色的差别只在 `WHERE`**——**写三个页面,改需求要改三处,一定会漏。** 3. 「录过了」为什么不该只报错? —— **因为它是正常业务状态。** **用户需要的是「覆盖吗」的选择**,**不是「失败了,自己想」。**下一篇 20.3,把三张表建出来: 重点是成绩表那个「三列联合唯一」——它是挡住「录两次」的唯一防线。
- ☐ (本篇完,下一篇 20.3 数据库设计)