Files
test/走查与改进记录.md
2026-09-21 19:03:31 +08:00

18 KiB
Raw Permalink Blame History

沃林学生管理系统 · 走查与改进记录

项目位置:wolin_sms/ 运行方式:uvicorn app.main:app --port 8010 数据库:MySQL wolin_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.js 1954 行 + style.css 395 行),没有构建步骤。 好处是打开就能改、没有工具链;代价是页面多了以后 #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    # 清掉验证残留