18 KiB
沃林学生管理系统 · 走查与改进记录
项目位置:
wolin_sms/运行方式:uvicorn app.main:app --port 8010数据库:MySQLwolin_sms(与练习用的student_management_system完全隔离) 本文记录的是「按需求文档实现完之后,切到真实用户视角把系统用一遍」的过程与结论。
一、走查是怎么做的
不是「看一眼觉得还行」,而是把自己当成三种真实使用者,各走一遍:
| 角色 | 账号 | 关注点 |
|---|---|---|
| 教务管理员 | admin / admin123 |
增删改查、批量导入、统计口径准不准 |
| 只读访客 | viewer / viewer123 |
登录后到底能看到什么、能做什么 |
| 换人使用者 | 前者用完切后者 | 会话之间会不会串味 |
走查手段分四层,互相不信任:
| 层 | 脚本 | 项数 | 它擅长抓什么 |
|---|---|---|---|
| 后端端到端 | tests/api_check.py |
66 | 业务链路、约束校验、状态流转 |
| 统计口径独立对账 | verify/statistics_crosscheck.py |
64 | 数字算错(用 Python 独立重算,不借被测 SQL) |
| 前端行为 | verify/frontend_check.js |
140 | UI 层 bug、权限体验、会话状态 |
| 真实浏览器视觉 | verify/shots.js |
13 张 | 布局、只读模式是否真的看不见按钮 |
| 权限边界 | verify/perm_check.py |
147 | 两个角色的读写能力是否划对 |
合计 417 项断言 + 13 张浏览器截图,当前全绿。
为什么需要这么多层:同一类 bug 只在某一层现形。 例如「高级筛选永远提交空规则」是纯 UI 层 bug —— 后端 66 项自检直接打 JSON, 绕过了界面,所以一项都没报。反过来「统计口径算错」UI 层也看不出来, 因为数据长得像模像样。少了任何一层都有盲区。
二、走查发现并修复的 8 个真实缺陷
1. 高级筛选:UI 永远提交空规则(最严重)
现象 在界面上设「年龄 > 25」执行,命中 71 条 —— 正好是学生全表条数。 换成需求文档里的嵌套 AND/OR 条件,命中还是 71 条。
定位
用独立 SQL 直接数:SELECT COUNT(*) FROM student WHERE is_del=0 = 71。
正确的答案应该是 13(age>25)和 2(嵌套条件)。
说明条件根本没被提交上去,请求发的是空规则数组。
追到 app/static/app.js 的 readRules():调用方传的是分组元素本身,
而这个元素的第一层 children 是 .rule-group-head 和 div[data-children],
真正的叶子条件在 [data-children] 里面。于是每一轮都从一个没有条件子的节点
上读,读出空数组,一路静默通过。
修复(app/static/app.js)
const container = node && node.dataset && node.dataset.group ? $('[data-children]', node) : node;
if (!container) return [];
验证 修复后 UI 与接口完全一致:单条件 13 条、嵌套条件 2 条,且断言 「加了 AND/OR 后命中数必须比单条件更少」也能过了。
2. 高级筛选把性别代号 1/2 直接摆给用户看
现象
结果表的「性别」列显示 1,不是「男」。
根因
/advanced/query 返回的是数据库代号(gender=1),前端原样输出。
修复
前端按字段元信息还原中文(app/static/app.js 的 advFmtCell):
const ADV_CODE_TEXT = {
gender: { 1: '男', 2: '女' },
status: { 1: '在读', 2: '进入就业', 3: '已就业' },
class_status: { 1: '在读', 2: '已结课', 3: '已解散' },
};
3. 班级平均分「按分数排序」没有生效
现象
选「平均分从高到低」、不限场次,前 5 名是
[80.07, 76.36, 75.14, 69.96, 81.01] —— 81.01 排在最后。
根因 明细读起来是「按场次分块、块内才有序」。需求文档原文要求
统计每次考试每个班级的平均分,并支持按分数从高到低或从低到高动态排序
前端下拉也明确写着「平均分从高到低」,用户期待的是全局排名。
statistics_dao.py 里排序主键写成了 Score.exam_seq.asc(), avg_col...,
exam_seq 抢了主位。
修复(app/dao/statistics_dao.py)
stmt = stmt.order_by(
avg_col.desc() if order.lower() == "desc" else avg_col.asc(),
Score.exam_seq.asc(),
Clazz.id.asc(),
)
验证
修复后前 5 名 [82.05, 81.01, 80.07, 78.6, 78.31],首行就是全局最高分;
前 4 行的场次是 [5, 2, 1, 3] —— 交叉出现,证明均分才是主导键。
4. 成绩趋势标签与数据自相矛盾
现象 界面上出现「张子豪 · 接口 · 上升」,可他的分数是 87.8 → 82.0,明明在跌。
根因 原算法是「前半段均值 vs 后半段均值,差 5 分以上算变化」。 这个阶跃判据在样本分布不均时会翻车:5 次成绩被切成前 2 后 3, 切点两边的均值完全可能反向于首末变化。
还有一个边界问题:斜率正好 1.00 分/场时,接口判「基本持平」, 独立实现对账判「上升」—— 界面上会显示「基本持平 +1.0/场」,自己打自己脸。
修复(app/service/statistics_service.py)
改成对「考核序次」做最小二乘拟合,返回斜率;并且先把斜率定稿到出参精度
(2 位小数),再拿它判方向,这样标签和数字就不会互相矛盾:
trend_slope = _r(cls._trend(values)) or 0.0 # 先定稿精度,再据它判方向
...
"trend": cls._trend_text(trend_slope, len(values)),
"trend_slope": trend_slope,
出参新增 trend_slope,让「标签是否与斜率自洽」变成可断言的不变量。
验证
对账脚本改为断言「标签方向必须与自身斜率同号」,并且用
代数等价式((nΣxy−ΣxΣy)/(nΣx²−(Σx)²))而不是均值形式来独立重算 ——
避免和被测代码用同一个公式,那就不叫独立对账了。
5. 只读账号什么都读不到(功能等于废的)
现象
viewer 登录后,GET /students、/statistics/overview、/advanced/meta
等全部读取接口返回 403 当前为只读账号,不能执行写操作。
而登录卡片上还在向用户宣传:
只读账号:
viewer / viewer123(可读不可写,用来演示权限)
根因
权限依赖只有 require_write / WriteAccount 一个门禁,
所有读取接口图省事全挂在了它上面。权限模型里叫「只读」,
行为上却是「什么都做不了」—— 这个账号等于是废的。
修复(app/core/deps.py)
def require_read(account: CurrentAccount) -> Account:
"""读操作:只要能通过登录校验就行,三种角色都可读。
这里必须单独有一个依赖,不能图省事复用 require_write。
早期版本把列表/详情/统计这些纯读接口全挂在了 WriteAccount 上,
结果「只读账号」登录后连一条数据都看不到。
"""
return account
ReadAccount = Annotated[Account, Depends(require_read)]
然后把 8 个 API 文件里的 37 处纯读接口从 WriteAccount 切到 ReadAccount
(/advanced/query、/advanced/aggregate 是 POST 但语义是读,也一样要放行)。
验证(新建 verify/perm_check.py,147 项)
- viewer 的 38 个读接口 逐个 与 admin 结果比对,必须完全一致;
- viewer 的 25 个写接口 全部 必须是 403(不是 404/405 这种「路由压根不存在」的巧合);
- 管理员专属的
/auth/accounts:viewer 403、admin 放行; - 无 token 访问:401。
设计上的一个讲究:对账脚本里所有业务 ID 都在运行时从列表接口现取, 不写死「1 号」。硬编码 ID 一旦数据库重新播种就全线 404, 届时看起来像权限炸了,实际只是测试自己过期了 —— 这类假警报最耗人。
6. 慢页面会覆盖新页面(登录后点菜单点不动)
现象 登录后立刻点「学生管理」,屏幕上却是概览。 菜单高亮着「学生管理」,内容区显示的是概览 KPI。
根因
goto() 没有任何并发保护,而各页面的 view.innerHTML = ... 都写在
await 之后。于是两次导航重叠时,谁后返回谁说了算。
概览页要串行打两个接口(/statistics/overview + /employment/funnel),
是最慢的一页 —— 它几乎必然最后返回,也就必然把用户想看的那页顶掉。
enterApp() 里那句 goto('overview') 还没有 await,等于主动制造了这次重叠。
修复(app/static/app.js)
引入导航序号 + 影子容器:这一页先渲染进一个游离容器,只有「还是最新一次导航」
才把它搬进 #view。过期的渲染连 #view 都碰不到,自然覆盖不了别人。
let navSeq = 0;
async function goto(key) {
const seq = ++navSeq;
...
const shadow = document.createElement('div');
shadow.innerHTML = '<div class="card"><div class="card-body">加载中…</div></div>';
host.replaceChildren(shadow);
try {
await page.render(shadow);
} catch (err) {
if (seq !== navSeq) return; // 过期导航的异常不打扰用户
...
}
if (seq !== navSeq) return; // 已经有更新的跳页了,这一页作废
host.onclick = shadow.onclick; // 行级点击处理器要一起转交
host.replaceChildren(...Array.from(shadow.childNodes));
}
这个改法把复杂度收在 goto 一个函数里,10 个页面函数一行都没动。
enterApp 里的 goto 也补上了 await。
顺带解释了一个历史谜团
之前用无头浏览器截图时出现过「11 张截图里 9 张完全一样,全是概览」,
当初以为是截图脚本的问题。现在清楚了:同一个 bug。
截图脚本自动跳页,与 enterApp 那次未 await 的 goto('overview') 并发,
概览最后返回、把所有页面都盖成了它自己。
验证(verify/frontend_check.js 新增段落)
测试不能靠「概览恰好更慢」的运气,否则哪天接口变快这条就形同虚设。
做法是临时给概览用到的接口注入固定延时,让「慢页面后返回」成为必然,
再断言「等慢接口全部回完之后,页面仍然是学生页」。
7. 筛选条件跨登出残留(换个账号就一脸问号)
现象
管理员按「自检临时生」筛过学生 → 退出 → viewer 登进来点「学生管理」:
看到一个空列表,而且关键词框里躺着一个自己从没输过的名字。
根因
stuState / scoreState / empState 是模块级变量。
做成模块级是故意的 —— 同一会话里来回切页不该把刚筛好的条件弄丢。
但同一份「故意」也意味着登出时必须手动清,代码里没清。
顺带一提:班级 / 老师 / 顾问三个页面用的是页面内的局部
st, 切页自然重置。同一个项目里两种写法并存,本身就是容易踩坑的信号。
修复(app/static/app.js)
给默认值一个单一来源,新增复位函数,在进入应用时调用:
const STU_DEFAULTS = { page: 1, page_size: 10, keyword: '', class_id: '', ... };
const stuState = { ...STU_DEFAULTS };
function resetSessionState() {
Object.assign(stuState, STU_DEFAULTS);
Object.assign(scoreState, SCORE_DEFAULTS);
Object.assign(empState, EMP_DEFAULTS);
advTab = 'query';
}
在 enterApp() 里调 resetSessionState() —— 「谁登录进来都从干净状态开始」。
8. 高级筛选的标签页也跨登出残留
现象 管理员在高级筛选切到「分组聚合」标签 → 退出 → 下一个登录的人一进高级筛选, 直接停在聚合页。而他是来写筛选规则的。
根因 同上,let advTab = 'query' 也是模块级的。
修复 并入 resetSessionState()。
这两个是同一个病根。第一次撞见(筛选条件)时我只是就地修掉, 直到撞见第二次才意识到该建立统一的会话状态复位,而不是哪里冒出来补哪里。 走查的价值正在这里 —— 单点 bug 往往是一类问题的第一个样本。
三、只读模式的界面处理
后端把门禁修好之后,还有一个体验问题:只读用户能看到一堆点了会报 403 的按钮。 看不见的按钮比点了报错的按钮友好。
做法是 CSS 隐藏,两个挂法各有原因:
.readonly-mode .w-act { display: none !important; }
- 挂在列的
cls上 → 整列(<th>+<td>)一起隐藏。 适用于「操作」列里全是写链接的表:成绩 / 就业 / 班级 / 老师 / 顾问。 - 挂在单个
span上 → 逐链接隐藏。只用于学生表的「操作」列 —— 那列里还有「详情」链接,只读账号需要它,整列藏掉等于把查看功能也砍了。
这里踩过一次坑:一开始图省事把 6 个「操作」列全部整列隐藏, 结果学生表里只读用户唯一能用的「详情」也被藏了。已撤回并改为逐链接标注。
顶栏加了「只读模式」标记,让用户清楚自己现在是什么身份。
index.html 的登录卡片也保留了 viewer / viewer123 的说明 ——
现在这个账号真的是「可读」了,说明和实际对得上了。
验证(frontend_check.js)
断言用的是 getComputedStyle 算出来的 display,不是类名 ——
类名只是手段,「用户看不见」才是目的。而且:
- 断言「藏起来了」时必须先要求元素存在。
一开始写成
displayOf(el) !== 'none',而元素不存在时displayOf返回'MISSING', 于是页面渲染失败反而让「写入口已隐藏」通过了 —— 最该红的时候绿了,已修正。 - 断言了反面:只读账号走遍 9 个页面一次 403 都不该撞到;
同时绕过 UI 直接发写请求,必须仍被后端拒绝(
viewer拿自己的 token POST/students→ 403)。 前者保证没把读能力削掉,后者保证门禁不是只靠 CSS。
四、验证脚本自身的问题也一并修了
走查工具出错会给出假信号,比没有工具更危险。这一轮修掉的:
| 问题 | 后果 | 处理 |
|---|---|---|
displayOf(null) 返回 'MISSING' |
元素不存在被当成「已隐藏」→ 假通过 | 加 hidden() / shown(),先要求元素存在 |
权限对账硬编码 student_id=1 |
库里没有 1 号 → 全线 404,看着像权限炸了 | 改成运行时从列表接口现取 ID |
| 权限对账用了错的路由路径 | 一堆假 FAIL,掩盖真结论 | 从 /openapi.json 导出真实路由表后重写 |
| 把 xlsx 模板当 JSON 解析 | UnicodeDecodeError,报成读取失败 |
单独用二进制通道校验 xlsx 魔数 PK |
| 截图只报告分辨率/大小 | 「11 张全是概览」这种静默失效看不出来 | 对 PNG 做 MD5,哈希重复即判失败 |
| 验证残留不清(逻辑删除堆积) | 演示库里越跑越多 自检临时生/权限对账临时顾问 的尸体 |
新增 verify/purge_selftest.py(默认干跑),perm_check.py 跑完自动物理清理 |
清理工具特意做成默认干跑:先列出「要删哪些、为什么」,确认无误差再 --yes。
演示库的洁净度现在可以随时核对 —— 当前各表有效行数与总行数完全相等,
也就是说连一行逻辑删除的尸体都不剩。
五、当前验证结果
后端端到端自检 tests/api_check.py 66 / 66 ✅
统计口径独立对账 verify/statistics_crosscheck.py 64 / 64 ✅
前端行为验证 verify/frontend_check.js 140 / 140 ✅
权限边界对账 verify/perm_check.py 147 / 147 ✅
─────────
417 项断言全绿
浏览器视觉截图 verify/shots.js 13 / 13 ✅
演示数据基线(跑完所有验证后核对,零污染):
班级 4 老师 5 顾问 3 学生 52 成绩 260 就业 46 账号 2
六、还没做的 / 已知取舍
- 逻辑删除的行会一直留着。这是设计选择(可恢复),但没有任何界面入口
能看到或恢复已删除的记录 ——
POST /students/{id}/restore只有接口没有按钮。 仅当作练手接口保留。 advisor_no/teacher_no/class_no的自动编号依赖当年级别计数, 跨年时若历史数据被物理清理,编号可能重复。当前靠数据库唯一约束兜底(返回 409), 不做自增序列。- 权限只到「三个角色 + 读写」这一层,没有做「顾问只能看自己名下学生」 这种数据行级隔离。需求文档里没提,就没加。
- 前端是零依赖原生 JS(
app.js1954 行 +style.css395 行),没有构建步骤。 好处是打开就能改、没有工具链;代价是页面多了以后#view的 innerHTML 拼接 会越来越难维护,而且每个页面自己view.onclick = ...,新增页面时容易漏。
七、怎么跑起来
cd wolin_sms
# 1) 建表 + 建初始账号(默认不会删任何数据;要重来才加 --drop)
.venv/Scripts/python.exe -m app.scripts.init_db
# 2) 灌演示数据(已有数据会跳过;要重灌加 --reset,它会清空业务数据)
.venv/Scripts/python.exe -m app.scripts.seed_data
# 3) 启动
.venv/Scripts/python.exe -m uvicorn app.main:app --port 8010
# 4) 浏览器打开 http://127.0.0.1:8010 → admin / admin123
# → viewer / viewer123(只读)
演示数据不是随机糊的:固定随机种子(
random.seed(20260916)), 每次灌出来一样,便于对比;成绩按「个人基础 + 波动」生成, 必然产生学霸、多次不及格、大起大落三类人 —— 保证每个统计口径都有东西看。
验证脚本:
.venv/Scripts/python.exe tests/api_check.py --base http://127.0.0.1:8010
.venv/Scripts/python.exe verify/statistics_crosscheck.py
.venv/Scripts/python.exe verify/perm_check.py
node verify/frontend_check.js http://127.0.0.1:8010 # 需要 NODE_PATH 指向 jsdom
node verify/shots.js http://127.0.0.1:8010 # 需要本机 Edge
.venv/Scripts/python.exe verify/purge_selftest.py --yes # 清掉验证残留