☰
Flask+SQLite学生信息管理系统源码拆解与二次开发
2026/11/4 8:47:54 网站建设 项目流程

简介:《学生信息管理系统》配套程序使用说明与源码包,面向学习数据库编程和课程设计的初学者及任课教师,帮助理清一个完整增删改查系统从建表到交互的实现过程。压缩包共4个文件,含2份doc使用与配置说明、1个exe可执行程序和1个py源程序,约5.96MB,文档与程序相互对照,源码可直接阅读与二次修改。目前已有143人学习下载。内容覆盖学生信息录入、按学号姓名或班级查找、删除前的确认校验、字段级修改与排序显示,以及总人数统计与全量信息展示,涉及INSERT、SELECT、UPDATE、COUNT等数据库操作与线性查找、排序算法的选用,退出时的流程控制与资源释放也有交代。对照文档与源码,可快速理清模块划分与表结构,复用查询、统计与界面设计思路,用于课程作业或改造为教务小工具。

1. 学生信息管理系统能干什么:从助教手里那 11 版 Excel 说起

去年帮某高校一个实验室收拾课程设计的烂摊子,助教桌面上躺着 11 个表格文件,命名从「学生名单_final」一路排到「学生名单_final_真的最终版」,谁都说不清哪份才是最新的。

学生信息管理系统解决的就是这个具体问题:把学生、班级、课程、成绩四类数据收进一个库里,用增删改查加角色权限,把「谁能改、改了什么」管住。这份带源码的程序搜索时经常被「小程序」三个字带偏,它其实不是微信小程序,而是标准的 Web 管理后台,浏览器打开就能用。

它适合三类人:要交课程设计的学生、想找一个干净 CRUD 模板练手的初级开发、以及在教研室被表格折磨到想自己搭内部工具的助教。功能不花哨,但登录鉴权、分页检索、成绩统计、Excel 导入导出这几条主线是齐的,拿来当骨架改比自己从零敲快得多。

2. 技术栈与目录结构:先把这套源码的骨架摸清楚

拿到一份源码,最忌讳上来就python app.py然后被一堆报错淹没。我习惯先花十分钟把目录和依赖看一遍,搞清楚「请求从哪进来、数据往哪存、页面在哪渲染」,后面出问题才知道往哪个方向 debug。

2.1 技术选型:Flask + SQLite + Jinja2 这套组合为什么够用

学生信息管理系统的业务特征是:表结构稳定、并发极低、查询以简单条件和分页为主、数据量通常在几千到几万条。这个画像决定了它不需要微服务,不需要消息队列,甚至不需要 Redis 缓存。用 Flask 做路由和请求分发,SQLite 做存储,Jinja2 做服务端渲染,Bootstrap 负责让页面看起来不难看,四件套加起来依赖不超过五个包,pip install十几秒就能装完。

我一般会把 SQLite 换成 MySQL 的写法也留一份,因为很多课程设计的环境要求是 MySQL。但两者的 SQL 在这套系统里差异很小,主要是自增主键写法和日期函数不同,业务逻辑层的代码几乎不用动。如果你手上那份源码是 SpringBoot + MyBatis 版本,分层思路是一样的,Controller 对应路由,Service 对应业务函数,Mapper 对应 SQL,对照着看即可。

密码存储这块,合格的写法必须用哈希,werkzeug.security里的generate_password_hash和check_password_hash就够用,不用额外引 bcrypt。会话用 Flask 自带的 session,把用户 id 和角色塞进签名 cookie,不引入 Flask-Login 也能跑,代码量更少,学生看源码时心智负担也低。

2.2 目录结构逐层对照:每个文件夹负责什么

这套源码的目录大致长这样,不同版本文件名可能略有出入,但分层是一致的:

student-mis/ ├── app.py # 应用入口,注册蓝图、配置 session ├── config.py # 数据库路径、SECRET_KEY、分页默认值 ├── requirements.txt # flask / openpyxl 两个核心依赖 ├── models/ │ ├── db.py # 连接管理、query_one / query_all 封装 │ └── guard.py # login_required / role_required 装饰器 ├── routes/ │ ├── auth.py # 登录、登出 │ ├── student.py # 学生增删改查、分页检索 │ ├── course.py # 课程维护 │ └── score.py # 成绩录入、统计报表 ├── templates/ │ ├── base.html # 公共布局、导航栏 │ ├── login.html │ ├── student_list.html │ └── score_report.html ├── static/ │ ├── css/style.css │ └── js/student.js # 表单校验、删除二次确认 ├── sql/ │ └── init.sql # 建表语句 + 初始数据 ├── tools/ │ └── import_students.py # Excel 批量导入脚本 └── data/ └── smis.db # 运行后自动生成

app.py只做三件事:创建 Flask 实例、从config.py读配置、把routes下的四个蓝图注册进来。路由文件按业务域拆,不按技术层拆,这样加一个「宿舍管理」模块时,新建routes/dorm.py再注册一次就行,不用在几百行的单文件里翻找。

models/db.py是整个项目的黑匣子,所有 SQL 都从这里出去。它至少得提供两个函数:query_one返回单行(用sqlite3.Row工厂,能按列名取值),query_all返回列表。参数全部用?占位符传,别用字符串拼接,这是防注入最省事也最有效的一条。

templates里base.html是公共骨架,导航栏、页脚、引入的 CSS 都在这里,其余页面{% extends "base.html" %}继承,改一次导航全站生效。static/js/student.js负责前端交互,比如删除前弹confirm,学号输入框失焦时校验 10 位数字,能挡掉一部分脏数据。

2.3 数据表设计:五张表撑起整个业务

表设计是这套系统的地基,改起来代价最大,所以第一步就要看清楚。核心五张表:用户表、班级表、学生表、课程表、成绩表。

-- sql/init.sql(节选) CREATE TABLE IF NOT EXISTS sys_user ( id INTEGER PRIMARY KEY AUTOINCREMENT, username TEXT NOT NULL UNIQUE, password_hash TEXT NOT NULL, real_name TEXT NOT NULL, role TEXT NOT NULL DEFAULT 'teacher', -- admin / teacher / student created_at TEXT DEFAULT (datetime('now', 'localtime')) ); CREATE TABLE IF NOT EXISTS class_info ( id INTEGER PRIMARY KEY AUTOINCREMENT, class_name TEXT NOT NULL UNIQUE, major TEXT, grade INTEGER ); CREATE TABLE IF NOT EXISTS student ( id INTEGER PRIMARY KEY AUTOINCREMENT, sno TEXT NOT NULL UNIQUE, -- 学号,业务唯一键 name TEXT NOT NULL, gender TEXT CHECK (gender IN ('男','女')), birthday TEXT, class_id INTEGER REFERENCES class_info(id), phone TEXT, email TEXT, status INTEGER DEFAULT 1 -- 1 在读,0 已离校 ); CREATE TABLE IF NOT EXISTS course ( id INTEGER PRIMARY KEY AUTOINCREMENT, cno TEXT NOT NULL UNIQUE, course_name TEXT NOT NULL, credit REAL DEFAULT 2.0, teacher_id INTEGER REFERENCES sys_user(id) ); CREATE TABLE IF NOT EXISTS score ( id INTEGER PRIMARY KEY AUTOINCREMENT, student_id INTEGER NOT NULL REFERENCES student(id) ON DELETE CASCADE, course_id INTEGER NOT NULL REFERENCES course(id), term TEXT NOT NULL, -- 例如 2024-2025-1 score REAL CHECK (score >= 0 AND score <= 100), UNIQUE (student_id, course_id, term) );

先说主键这个点。student表用自增id做主键,sno只做唯一约束,这是有意为之。如果直接拿学号当主键,学生转专业换学号、或者录错了要改学号,所有引用它的成绩记录都得跟着改,一旦漏了就是脏数据。自增id是稳定的内部标识,业务字段怎么变都不影响关联。

score表上的UNIQUE (student_id, course_id, term)是防重复录入的硬约束。教务场景里最常见的事故就是同一个学生同一门课录了两遍成绩,平均值直接失真。让数据库来挡这一刀,比在代码里写一堆if exists判断靠谱。

ON DELETE CASCADE让删除学生时自动清掉他的成绩记录,不留孤儿数据。CHECK约束把成绩锁在 0 到 100,性别锁在两个值,脏数据在入库那一刻就被拒绝,不用等报表算错了再回头排查。

3. 核心模块拆解:登录、学生 CRUD、成绩统计三条主线怎么走

功能列表看得再明白,不落到代码上还是不会改。这一章挑三条最关键的主线:登录鉴权决定「谁能进来」,学生 CRUD 决定「数据怎么进出」,成绩统计决定「报表怎么算出来」。这三块吃透,其余模块都是同一套写法的复制。

3.1 登录与权限:一个装饰器挡住所有越权请求

登录接口做两件事:验证身份、把身份写进 session。很多新手版本只做了第一步,后面所有页面裸奔,改个 URL 就能进后台,这在课程答辩时很容易被问住。

# routes/auth.py from flask import Blueprint, request, session, redirect, url_for, render_template from werkzeug.security import check_password_hash from models.db import query_one auth_bp = Blueprint("auth", __name__) @auth_bp.route("/login", methods=["GET", "POST"]) def login(): if request.method == "GET": return render_template("login.html") username = request.form.get("username", "").strip() password = request.form.get("password", "") user = query_one("SELECT * FROM sys_user WHERE username = ?", (username,)) # 用户不存在和密码错误返回同一个提示,避免被枚举账号 if user is None or not check_password_hash(user["password_hash"], password): return render_template("login.html", error="账号或密码错误"), 401 session.clear() # 登录前清空,防止会话固定攻击 session["uid"] = user["id"] session["role"] = user["role"] session["name"] = user["real_name"] return redirect(url_for("student.list_students"))

username和password都从表单取,strip()去掉首尾空格,用户复制粘贴带空格是高频事故。query_one用占位符传参,username里就算塞进' OR '1'='1也只是一段普通字符串。check_password_hash拿数据库里的哈希做比对,明文密码不落库、不落日志。session.clear()这一行容易被忽略,它保证用户重新登录时旧会话彻底作废,不会出现「切换账号后还能看到上一个人的数据」。

光有登录还不够,每个受保护的视图都得校验。用一个装饰器统一处理,比在每个函数里写if "uid" not in session干净得多:

# models/guard.py from functools import wraps from flask import session, redirect, url_for, abort def login_required(view): @wraps(view) def wrapper(*args, **kwargs): if "uid" not in session: return redirect(url_for("auth.login")) return view(*args, **kwargs) return wrapper def role_required(*roles): def deco(view): @wraps(view) def wrapper(*args, **kwargs): if session.get("role") not in roles: abort(403) # 已登录但角色不够,返回 403 而不是跳登录页 return view(*args, **kwargs) return wrapper return deco

login_required管「有没有登录」,role_required管「角色够不够」。两者分开是有讲究的:未登录跳登录页,已登录但越权返回 403,这两种情况的处理方式不该混在一起,否则排错时会误以为是没登录。使用时叠加在路由上,@login_required放外层,@role_required("admin")放内层,顺序反了会先抛 403 再判断登录,逻辑就乱了。

3.2 学生管理:分页、搜索、增删改的完整链路

学生列表是使用频率最高的页面,也是最容易写崩的地方。数据量一上来,全表SELECT *然后前端渲染,页面能卡到浏览器无响应。合格写法必须带分页和条件过滤:

# routes/student.py from flask import Blueprint, request, render_template, jsonify from models.db import query_one, query_all, execute from models.guard import login_required student_bp = Blueprint("student", __name__) @student_bp.route("/students") @login_required def list_students(): kw = request.args.get("kw", "").strip() page = max(int(request.args.get("page", 1)), 1) # 页码最小为 1 size = min(max(int(request.args.get("size", 20)), 1), 100) # 每页 1~100 where, params = "WHERE s.status = 1", [] if kw: where += " AND (s.sno LIKE ? OR s.name LIKE ?)" params += [f"%{kw}%", f"%{kw}%"] total = query_one(f"SELECT COUNT(*) AS c FROM student s {where}", params)["c"] rows = query_all( f"""SELECT s.id, s.sno, s.name, s.gender, ci.class_name, s.phone FROM student s LEFT JOIN class_info ci ON s.class_id = ci.id {where} ORDER BY s.sno LIMIT ? OFFSET ?""", params + [size, (page - 1) * size], ) return render_template("student_list.html", rows=rows, total=total, page=page, size=size, kw=kw)

逐段说。page用max(..., 1)兜底,用户手改 URL 传个page=0或负数,OFFSET会算出负值,SQLite 直接报错。size用min(max(...), 100)双向夹紧,既防size=0查出空页,也防size=99999把内存打满。这两个夹紧动作是血泪经验,线上被爬虫或者手贱的测试同学传过奇怪参数,服务直接 500。

where用列表拼参数,params数组和 SQL 里的?一一对应,顺序不能错。kw用LIKE双向模糊,学号和姓名都能搜。COUNT(*)单独查一次拿总数,前端才能算出总页数,别想着用len(rows)代替,那只代表当前页。

LEFT JOIN class_info而不是INNER JOIN,因为学生可能还没分配班级,class_id为空。用内连接这类学生会直接从列表里消失,助教找半天找不到人。ORDER BY s.sno保证分页稳定,没有排序的分页在数据变动时会出现同一条记录重复出现或漏掉。

新增和修改走同一个表单,靠id是否为空区分:

@student_bp.route("/students/save", methods=["POST"]) @login_required def save_student(): data = request.get_json() or request.form sno, name = data.get("sno", "").strip(), data.get("name", "").strip() if not sno or not name: return jsonify(code=400, msg="学号和姓名不能为空"), 400 sid = data.get("id") if sid: # 有 id 走更新 execute("""UPDATE student SET name=?, gender=?, class_id=?, phone=?, email=? WHERE id=?""", (name, data["gender"], data["class_id"], data["phone"], data["email"], sid)) else: # 无 id 走新增 execute("""INSERT INTO student (sno, name, gender, class_id, phone, email) VALUES (?,?,?,?,?,?)""", (sno, name, data["gender"], data["class_id"], data["phone"], data["email"])) return jsonify(code=200, msg="保存成功")

注意更新语句里故意没有改sno。学号是业务唯一键,允许随便改会导致成绩关联错乱,真要改学号的场景应该走单独的「学号变更」流程,而不是在编辑表单里顺手改掉。这个取舍在答辩时是个加分点。

3.3 成绩模块:录入校验与班级排名的一次性算完

成绩模块有两个动作:录入和统计。录入的关键是幂等,同一学生同一课程同一学期重复提交,应该更新而不是插一条新记录:

# routes/score.py @score_bp.route("/scores/save", methods=["POST"]) @login_required @role_required("admin", "teacher") def save_score(): data = request.get_json() or request.form try: val = float(data["score"]) except (KeyError, TypeError, ValueError): return jsonify(code=400, msg="成绩必须是数字"), 400 if not 0 <= val <= 100: return jsonify(code=400, msg="成绩需在 0~100 之间"), 400 # SQLite 的 UPSERT:冲突时更新,依赖 score 表上的唯一约束 execute("""INSERT INTO score (student_id, course_id, term, score) VALUES (?,?,?,?) ON CONFLICT(student_id, course_id, term) DO UPDATE SET score = excluded.score""", (data["student_id"], data["course_id"], data["term"], val)) return jsonify(code=200, msg="录入成功")

ON CONFLICT ... DO UPDATE是 SQLite 3.24 之后支持的语法,等价于 MySQL 的ON DUPLICATE KEY UPDATE。它的前提是score表上建了UNIQUE (student_id, course_id, term),没有这个约束,冲突永远不会触发,语句会退化成普通插入。这也是为什么第 2 章要强调那个唯一约束,它不只是防脏数据,还是这段业务逻辑的前提。

统计报表是这套系统里唯一有点算法味道的地方,一次 SQL 把平均分、及格率、排名全算出来:

@score_bp.route("/scores/report") @login_required def score_report(): course_id = request.args.get("course_id", type=int) term = request.args.get("term", "") rows = query_all( """SELECT s.sno, s.name, ci.class_name, ROUND(AVG(sc.score), 2) AS avg_score, SUM(CASE WHEN sc.score >= 60 THEN 1 ELSE 0 END) * 1.0 / COUNT(*) AS pass_rate, MAX(sc.score) AS max_score, RANK() OVER (ORDER BY AVG(sc.score) DESC) AS rk FROM score sc JOIN student s ON s.id = sc.student_id LEFT JOIN class_info ci ON ci.id = s.class_id WHERE sc.course_id = ? AND sc.term = ? GROUP BY s.id ORDER BY avg_score DESC""", (course_id, term), ) return render_template("score_report.html", rows=rows)

AVG算平均分,ROUND(..., 2)保留两位防止浮点尾数吓人。及格率用CASE WHEN数出及格条数再除以总数,乘1.0是为了把整数除法强制转成浮点,不乘的话 SQLite 会直接给你 0。RANK()是窗口函数,并列名次会跳过后续序号(两个并列第一,下一个是第三),如果业务要求连续排名,换成DENSE_RANK()。窗口函数需要 SQLite 3.25 以上,Python 3.8 自带的版本就够,老环境要留意。

GROUP BY s.id而不是GROUP BY s.sno,因为主键分组在 SQLite 里语义明确,name、class_name这类非聚合列也能安全取出来。ORDER BY avg_score DESC用别名排序,SQLite 支持,MySQL 也支持,换库时不用改。

4. 跑起来:从零到登录首页的六个动作

源码看懂了,下一步是让它在自己机器上跑起来。这一章按顺序走一遍,每一步都给出验证方法,跑完能登录、能查数据、能导入一批真实格式的 Excel,才算真正跑通。

4.1 环境准备与依赖隔离

别在系统全局 Python 里装依赖。多版本共存时,pip install flask装到 A 版本,python app.py用的是 B 版本,报ModuleNotFoundError能查半天。用虚拟环境是标准动作:

# 进入项目根目录 cd student-mis # 创建并激活虚拟环境 python -m venv .venv source .venv/bin/activate # Windows 用 .venv\Scripts\activate # 确认解释器路径指向 .venv 内部 which python # Windows 用 where python # 安装依赖 pip install -r requirements.txt

requirements.txt里通常就两行核心依赖,其余是传递依赖:

Flask>=3.0 openpyxl>=3.1

openpyxl只有 Excel 导入导出用得上,如果你的版本没带这个功能,可以不装。装完执行pip list看一眼 Flask 版本,3.x 和 2.x 在部分 API 上有差异(比如jsonify的返回行为),遇到奇怪问题时先确认版本,别急着怀疑自己代码写错了。

4.2 初始化数据库与种子账号

SQLite 的库文件不会自动出现,第一次运行前必须执行初始化脚本建表。源码一般会带一个init_db.py,如果没有,自己照着写一个:

# init_db.py import os, sqlite3 from werkzeug.security import generate_password_hash BASE = os.path.dirname(os.path.abspath(__file__)) DB = os.path.join(BASE, "data", "smis.db") os.makedirs(os.path.dirname(DB), exist_ok=True) conn = sqlite3.connect(DB) conn.execute("PRAGMA foreign_keys = ON") # SQLite 默认不启用外键约束 with open(os.path.join(BASE, "sql", "init.sql"), encoding="utf-8") as f: conn.executescript(f.read()) # 一次执行整个建表脚本 conn.execute( "INSERT OR IGNORE INTO sys_user (username, password_hash, real_name, role) " "VALUES (?,?,?,?)", ("admin", generate_password_hash("Admin@123"), "系统管理员", "admin"), ) # 顺手塞几个班级和课程,方便第一次进去就能看到数据 conn.executemany( "INSERT OR IGNORE INTO class_info (class_name, major, grade) VALUES (?,?,?)", [("计算机2401", "计算机科学与技术", 2024), ("软件2402", "软件工程", 2024)], ) conn.commit() conn.close() print("初始化完成:", DB)

PRAGMA foreign_keys = ON这一行必须加。SQLite 出于兼容历史的原因,外键约束默认是关闭的,不显式打开,ON DELETE CASCADE根本不生效,删学生之后成绩记录还留在库里,统计报表就会出现「有成绩没学生」的幽灵行。这个坑我第一次踩的时候查了快一个小时。

INSERT OR IGNORE保证脚本可以重复执行,第二次跑不会因为主键或唯一键冲突报错,方便反复调试。执行完用sqlite3 data/smis.db ".tables"看一眼,五张表齐了就算成功。

4.3 启动应用与首次登录

# 确保仍处于虚拟环境 python app.py

app.py末尾的启动代码建议写成这样,不要直接app.run():

# app.py(节选) if __name__ == "__main__": app.config["SECRET_KEY"] = os.environ.get("SMIS_SECRET", "dev-only-change-me") app.run(host="127.0.0.1", port=5001, debug=True)

host绑127.0.0.1而不是0.0.0.0,debug=True只在本机开发时开。debug 模式下页面出错会显示交互式调试器,能执行任意 Python 代码,同一局域网里别人打开你的地址就能拿到服务器控制权。答辩演示时如果必须让评委访问,用host="0.0.0.0"并把debug关掉,演示完立刻改回来。

端口用 5001 是因为 5000 在部分系统上被其他服务占用,启动时报Address already in use时先换端口,别急着怀疑代码。启动成功后终端会打印访问地址,浏览器打开http://127.0.0.1:5001/login,用admin / Admin@123登录。登录成功会跳到学生列表页,此时列表是空的,正常。

4.4 用 Excel 批量导入一批数据做验证

手工点几十条数据太慢,用导入脚本一次性灌进去,顺便验证导入功能本身有没有问题:

# tools/import_students.py import sys, openpyxl from models.db import get_conn def import_students(xlsx_path: str, class_id: int): wb = openpyxl.load_workbook(xlsx_path, data_only=True) ws = wb.active ok, skipped = 0, [] with get_conn() as conn: # 上下文管理器负责 commit / rollback for line_no, row in enumerate(ws.iter_rows(min_row=2, values_only=True), start=2): cells = (list(row) + [None] * 5)[:5] # 学号 姓名 性别 生日 电话 sno, name, gender, birthday, phone = cells if not sno or not name: skipped.append((line_no, "学号或姓名为空")) continue try: conn.execute( "INSERT INTO student (sno, name, gender, birthday, phone, class_id)" " VALUES (?,?,?,?,?,?)", (str(sno).strip(), str(name).strip(), gender, birthday, phone, class_id), ) ok += 1 except Exception as e: skipped.append((line_no, str(e))) # 单行失败不影响整体 return ok, skipped if __name__ == "__main__": ok, skipped = import_students(sys.argv[1], int(sys.argv[2])) print(f"成功 {ok} 条,跳过 {len(skipped)} 条") for line, reason in skipped[:10]: print(f" 第 {line} 行: {reason}")

load_workbook(data_only=True)读的是公式的计算结果而不是公式本身,避免读到=A2&B2这种原始字符串。iter_rows(min_row=2)跳过表头,values_only=True直接拿值不用点.value。(list(row) + [None]*5)[:5]这个写法是为了兼容列数不足的行,缺的列补None,多的截掉,防止解包时报「not enough values」。

单行插入用try/except包住,一条脏数据(比如学号重复)不应该让整个导入中断,跳过并记下行号,导完打印出来让人去补。这是导入功能的通用原则:能导多少导多少,失败清单要可见。跑完之后刷新学生列表页,数据应该在,随便搜一个学号验证搜索条件生效。

5. 避坑与排查:五个把新手卡在启动页的问题

源码能跑和能稳定跑是两回事。下面这五条是我在不同机器上反复遇到的,按「现象 → 原因 → 解决」写清楚,遇到了直接对照。

5.1 启动与环境类

现象:命令行敲python app.py,报ModuleNotFoundError: No module named 'flask',但明明刚pip install过。

原因:pip和python指向两个不同的解释器。常见于系统里装了 Python 3.9 和 3.11,pip属于 3.9,python指向 3.11,或者反过来。Windows 上更常见,py启动器、python、python3三个命令可能指向三份安装。

解决:养成用python -m pip install而不是裸pip install的习惯,这样装的一定是当前python对应的 pip。然后用python -c "import flask; print(flask.__version__)"验证,能打印版本号才算装对。

现象:OSError: [Errno 98] Address already in use,或者 Windows 上的[WinError 10048]。

原因:端口被占用。5000 端口在部分系统上被系统服务占用,或者上一次的python app.py没退干净还在后台跑。

解决:先换端口,app.run(port=5001),改一行就能验证是不是端口问题。确认是占用的话,Linux/macOS 用lsof -i :5000找进程号再kill,Windows 用netstat -ano | findstr :5000拿到 PID 去任务管理器结束。别用「重启电脑」绕过去,下次还会遇到。

5.2 数据与逻辑类

现象:页面偶尔卡住,终端报sqlite3.OperationalError: database is locked,刷新一下又好了。

原因:SQLite 是文件级锁,同一时刻只允许一个写事务。Flask 开发服务器默认多线程,两个请求同时写就会撞锁,等待超时(默认 5 秒)后抛异常。导入 Excel 时最容易触发,因为导入是个长事务。

解决:两步。第一,连接时开 WAL 模式,读写不互斥,conn.execute("PRAGMA journal_mode=WAL"),这个设置是持久的,执行一次就行。第二,把写操作的事务粒度改小,导入脚本每 200 行commit一次,别攒到最后。生产环境数据量再大就该换 MySQL 了,但在几千条的规模上,WAL 加小事务足够。

现象:登录页输入正确的账号密码,一直提示「账号或密码错误」,数据库里明明有这条记录。

原因:密码字段的存储格式不匹配。老版本的源码可能存的是明文或者 MD5,而登录逻辑用的是check_password_hash,它期望的是pbkdf2:sha256:...这种带算法前缀的格式,直接比对必然失败。

解决:查一下库里password_hash字段长什么样。如果是 32 位十六进制,那是 MD5;如果是明文,那更明显。写一个一次性迁移脚本:先按老格式校验通过,再用generate_password_hash重新写入。登录逻辑里也可以做兼容,校验成功且发现格式老旧时顺手升级,用户无感知。这类兼容代码留个注释标明「历史数据迁移用,新数据不会走这里」,不然后面接手的人不敢删。

现象:前端传page=0或size=9999,后端 500,日志里是LIMIT -1 OFFSET -20之类的怪值。

原因:request.args.get("page", type=int)拿到什么就信什么,没做边界夹紧。page=0会让(page-1)*size变成负数,SQLite 对负数OFFSET直接报错;size太大则可能把整表拉进内存。

解决:所有来自 URL 的分页参数,进 SQL 之前必须夹紧,page = max(page, 1),size = min(max(size, 1), 100)。这条规则我后来写进了团队的分页工具函数里,任何列表接口都强制走一遍,再没出过这类 500。

6. 二次开发:加一个字段动三处,加一个角色动两处

这套系统真正的价值不在它现在有什么功能,而在于改起来便宜。带源码的资源,如果改一个字段要翻十个文件,那还不如重写。下面是我在这个结构上做过几次改造之后总结的路径,按这个走基本不会漏。

6.1 加字段:建表、后端、模板三处必须同步

假设要给学生在原有字段基础上加「宿舍号」。改动点固定是三个:

改动位置具体文件容易漏的点
数据库sql/init.sql的student表已有库要单独ALTER TABLE,不能只改建表脚本
后端routes/student.py的save_student与列表查询INSERT / UPDATE 的字段列表和参数顺序要一一对应
前端templates/student_list.html表单与表格列编辑时要把旧值回填到输入框

已有数据库的迁移语句单独写一个文件,别直接改init.sql就完事:

-- sql/migrate_20240601_add_dorm.sql ALTER TABLE student ADD COLUMN dorm TEXT DEFAULT '';

ALTER TABLE ... ADD COLUMN在 SQLite 里只能加列,不能改列类型、不能删列,所以字段设计要想清楚再动手。加完之后,save_student里的 INSERT 和 UPDATE 都要带上dorm参数,少写一个,保存时该字段永远是空,而且不报错,排查起来很烦。

6.2 加角色:先改判断,再改入口

假设要加一个「辅导员」角色,只能看本班学生,不能改成绩。改动顺序是先在role_required的调用处加白名单,再改页面上的按钮可见性。

# 原来只允许 admin 和 teacher @role_required("admin", "teacher") # 改成三类角色都能进,但在函数内部按角色过滤数据 @role_required("admin", "teacher", "counselor") def list_students(): ... if session.get("role") == "counselor": where += " AND s.class_id = ?" params.append(session.get("class_id"))

前端模板里控制按钮显示,用 Jinja2 判断就行,不用引入复杂权限框架:

{# templates/student_list.html 节选 #} {% if session.get('role') in ('admin', 'teacher') %} <button class="btn btn-primary" onclick="openEdit()">编辑</button> <button class="btn btn-danger" onclick="removeRow()">删除</button> {% endif %}

注意前端隐藏按钮只是体验优化,不是安全边界。真正的拦截必须在后端,save_student和删除接口上的role_required一个都不能少。我见过只改模板不改后端的版本,把按钮藏了但接口还开着,用 Postman 直接调接口照样能删数据。

6.3 改完怎么验证:一份五分钟的回归清单

改完代码别只看页面能打开就完事。我一般固定跑这几步:用 admin 登录,新增一条学生,编辑它,搜索学号能搜到,删除它,确认成绩表里的关联记录跟着没了(验证级联),换成普通 teacher 登录确认删除按钮不见且直接调接口返回 403。这一套走完大概五分钟,能挡住九成的低级错误。

最后说个教训。早几年我改这类系统,习惯直接在生产库上ALTER TABLE然后改代码,觉得「就加一列能出什么事」。有一次参数顺序对错了位,电话写进了邮箱列,一百多条数据全是错的,最后靠备份才捞回来。从那以后我每次改表结构,都强制先在副本库上跑一遍迁移脚本加回归清单,确认没问题再动真库。这个习惯看着笨,但省下的后悔药钱远比它值。希望帮到你。

本文还有配套的精品资源,点击获取

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询