
学生管理系统源代码实战:实现成绩录入、查询与统计功能
学生成绩管理看起来只是“存一条分数、查几条记录、算个平均值”,实际开发时还要处理重复录入、非法分数、查询条件组合、数据一致性和隐私保护等问题。本文用 Python、Flask 和 SQLite 编写一个可运行的轻量级学生管理 API,完成学生建档、成绩录入、成绩查询和统计汇总,并说明示例适合什么场景、有哪些边界,以及如何安全地扩展。
一、适用场景与功能边界
这个示例适合课程作业、原型验证、个人学习项目,或者人数不多、由单个服务实例提供服务的内部小工具。SQLite 无须单独部署数据库,Flask 负责接收 HTTP 请求,便于用浏览器开发者工具、curl 或 Postman 测试。
示例提供以下接口:
POST /students:创建学生。GET /students:列出学生。POST /scores:录入或更新某学生某课程、某学期的成绩。GET /scores:按学生、课程、学期筛选成绩。GET /stats:按课程和学期统计成绩数量、平均分、最高分、最低分。
它不是完整的教务系统:没有用户登录、角色权限、成绩审核、操作日志、批量导入、分页和数据备份流程。代码中的 API 不做身份认证,任何能访问服务的人都可能读写数据,因此只能在本机或受控测试环境运行。不要把它直接暴露到公网,也不要将其描述为已具备生产环境所需的安全能力。
二、数据模型与分数表示
系统分成两张表:students 保存学生基本信息,scores 保存成绩记录。成绩表通过外键关联学生,并对“学生、课程、学期”设置唯一约束。同一学生在同一课程、同一学期只能有一条成绩记录;再次录入时更新原记录,而不是插入重复数据。
分数允许 0 到 100,最多两位小数。代码将分数转换为整数“百分之一分”存储,例如 87.25 分保存为 8725。这样可避免使用二进制浮点数存储小数时出现的精度误差。API 返回时再除以 100,恢复为常见分数形式。
三、准备运行环境
需要 Python 3.9 或更高版本。创建项目目录后安装 Flask:
python -m venv .venv
# macOS / Linux
source .venv/bin/activate
# Windows PowerShell
.venv\Scripts\Activate.ps1
pip install Flask
新建 app.py,写入以下完整示例。默认数据库文件为当前目录下的 school.db;第一次启动时会自动创建数据库和表。
import os
import sqlite3
from contextlib import contextmanager
from decimal import Decimal, InvalidOperation
from flask import Flask, jsonify, request
app = Flask(__name__)
DATABASE = os.environ.get("SCHOOL_DB", "school.db")
@contextmanager
def database():
"""每个请求单独打开连接;事务成功提交,异常时回滚,最后关闭连接。"""
conn = sqlite3.connect(DATABASE)
conn.row_factory = sqlite3.Row
conn.execute("PRAGMA foreign_keys = ON")
try:
with conn:
yield conn
finally:
conn.close()
def init_db():
with database() as db:
db.executescript("""
CREATE TABLE IF NOT EXISTS students (
id INTEGER PRIMARY KEY AUTOINCREMENT,
student_no TEXT NOT NULL UNIQUE,
name TEXT NOT NULL,
created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP
);
CREATE TABLE IF NOT EXISTS scores (
id INTEGER PRIMARY KEY AUTOINCREMENT,
student_id INTEGER NOT NULL,
course TEXT NOT NULL,
term TEXT NOT NULL,
score_x100 INTEGER NOT NULL
CHECK (score_x100 >= 0 AND score_x100 <= 10000),
updated_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP,
FOREIGN KEY (student_id) REFERENCES students(id),
UNIQUE (student_id, course, term)
);
""")
def json_body():
data = request.get_json(silent=True)
if not isinstance(data, dict):
return None, (jsonify(error="请求体必须是 JSON 对象"), 400)
return data, None
def required_text(data, key, max_length):
value = data.get(key)
if not isinstance(value, str):
return None, f"{key} 必须是字符串"
value = value.strip()
if not value:
return None, f"{key} 不能为空"
if len(value) > max_length:
return None, f"{key} 长度不能超过 {max_length} 个字符"
return value, None
def parse_score(value):
# 不接受布尔值、空值和非数字字符串。
if isinstance(value, bool) or value is None:
return None, "score 必须是 0 到 100 之间的数字"
try:
score = Decimal(str(value))
except (InvalidOperation, ValueError):
return None, "score 必须是有效数字"
if not score.is_finite() or score < 0 or score > 100:
return None, "score 必须在 0 到 100 之间"
if score != score.quantize(Decimal("0.01")):
return None, "score 最多允许两位小数"
return int(score * 100), None
def score_json(row):
result = dict(row)
result["score"] = result.pop("score_x100") / 100
return result
@app.post("/students")
def create_student():
data, error = json_body()
if error:
return error
student_no, problem = required_text(data, "student_no", 40)
if problem:
return jsonify(error=problem), 400
name, problem = required_text(data, "name", 80)
if problem:
return jsonify(error=problem), 400
try:
with database() as db:
cursor = db.execute(
"INSERT INTO students (student_no, name) VALUES (?, ?)",
(student_no, name),
)
student_id = cursor.lastrowid
except sqlite3.IntegrityError:
return jsonify(error="学号已存在"), 409
return jsonify(id=student_id, student_no=student_no, name=name), 201
@app.get("/students")
def list_students():
with database() as db:
rows = db.execute(
"SELECT id, student_no, name, created_at "
"FROM students ORDER BY student_no"
).fetchall()
return jsonify(students=[dict(row) for row in rows])
@app.post("/scores")
def save_score():
data, error = json_body()
if error:
return error
student_id = data.get("student_id")
# bool 在 Python 中是 int 的子类,需单独排除。
if isinstance(student_id, bool) or not isinstance(student_id, int) or student_id <= 0:
return jsonify(error="student_id 必须是正整数"), 400
course, problem = required_text(data, "course", 100)
if problem:
return jsonify(error=problem), 400
term, problem = required_text(data, "term", 40)
if problem:
return jsonify(error=problem), 400
score_x100, problem = parse_score(data.get("score"))
if problem:
return jsonify(error=problem), 400
try:
with database() as db:
# 唯一键冲突时更新成绩,实现同一学生、课程、学期的覆盖录入。
db.execute("""
INSERT INTO scores (student_id, course, term, score_x100)
VALUES (?, ?, ?, ?)
ON CONFLICT(student_id, course, term)
DO UPDATE SET
score_x100 = excluded.score_x100,
updated_at = CURRENT_TIMESTAMP
""", (student_id, course, term, score_x100))
row = db.execute("""
SELECT s.id, s.student_no, s.name, sc.course, sc.term,
sc.score_x100, sc.updated_at
FROM scores AS sc
JOIN students AS s ON s.id = sc.student_id
WHERE sc.student_id = ? AND sc.course = ? AND sc.term = ?
""", (student_id, course, term)).fetchone()
except sqlite3.IntegrityError:
return jsonify(error="学生不存在,无法录入成绩"), 404
return jsonify(score_json(row)), 200
@app.get("/scores")
def query_scores():
clauses = []
params = []
student_id = request.args.get("student_id")
if student_id is not None:
try:
parsed_id = int(student_id)
if parsed_id <= 0:
raise ValueError
except ValueError:
return jsonify(error="student_id 必须是正整数"), 400
clauses.append("s.id = ?")
params.append(parsed_id)
for key, column, max_length in (
("course", "sc.course", 100),
("term", "sc.term", 40),
):
value = request.args.get(key)
if value is not None:
value = value.strip()
if not value or len(value) > max_length:
return jsonify(error=f"{key} 不能为空且不能超过 {max_length} 个字符"), 400
clauses.append(f"{column} = ?")
params.append(value)
sql = """
SELECT s.id AS student_id, s.student_no, s.name,
sc.course, sc.term, sc.score_x100, sc.updated_at
FROM scores AS sc
JOIN students AS s ON s.id = sc.student_id
"""
if clauses:
sql += " WHERE " + " AND ".join(clauses)
sql += " ORDER BY s.student_no, sc.term, sc.course"
with database() as db:
rows = db.execute(sql, params).fetchall()
return jsonify(scores=[score_json(row) for row in rows])
@app.get("/stats")
def score_stats():
course = request.args.get("course")
term = request.args.get("term")
clauses = []
params = []
for key, value, column, max_length in (
("course", course, "course", 100),
("term", term, "term", 40),
):
if value is not None:
value = value.strip()
if not value or len(value) > max_length:
return jsonify(error=f"{key} 不能为空且不能超过 {max_length} 个字符"), 400
clauses.append(f"{column} = ?")
params.append(value)
sql = """
SELECT course, term, COUNT(*) AS count,
AVG(score_x100) AS average_x100,
MIN(score_x100) AS min_x100,
MAX(score_x100) AS max_x100
FROM scores
"""
if clauses:
sql += " WHERE " + " AND ".join(clauses)
sql += " GROUP BY course, term ORDER BY term, course"
with database() as db:
rows = db.execute(sql, params).fetchall()
stats = []
for row in rows:
stats.append({
"course": row["course"],
"term": row["term"],
"count": row["count"],
"average": round(row["average_x100"] / 100, 2),
"min": row["min_x100"] / 100,
"max": row["max_x100"] / 100,
})
return jsonify(stats=stats)
init_db()
if __name__ == "__main__":
# 仅供本机开发测试。不要用 debug 模式部署到生产环境。
app.run(host="127.0.0.1", port=5000, debug=True)
四、启动并验证接口
运行服务:
python app.py
另开一个终端,先创建学生。接口使用 JSON 请求体,成功创建时返回 HTTP 201:
curl -X POST http://127.0.0.1:5000/students \
-H "Content-Type: application/json" \
-d '{"student_no":"S2025001","name":"林同学"}'
curl -X POST http://127.0.0.1:5000/students \
-H "Content-Type: application/json" \
-d '{"student_no":"S2025002","name":"周同学"}'
录入成绩时,student_id 是创建学生接口返回的数据库 ID。下面假设林同学的 ID 为 1:
curl -X POST http://127.0.0.1:5000/scores \
-H "Content-Type: application/json" \
-d '{"student_id":1,"course":"数学","term":"2025-春","score":87.25}'
curl -X POST http://127.0.0.1:5000/scores \
-H "Content-Type: application/json" \
-d '{"student_id":1,"course":"英语","term":"2025-春","score":93}'
如果对同一学生、课程和学期再次提交成绩,接口会更新原记录。这是明确设定的覆盖策略,不是追加历史记录。如果学校要求保留每次修改的痕迹,需要增加成绩版本表或操作审计表,不能只依赖当前成绩字段。
查询可以单独使用任意筛选条件,也可以组合使用:
curl "http://127.0.0.1:5000/scores"
curl "http://127.0.0.1:5000/scores?student_id=1"
curl "http://127.0.0.1:5000/scores?course=%E6%95%B0%E5%AD%A6&term=2025-%E6%98%A5"
统计结果按课程和学期分组。传入课程或学期参数时,只统计匹配范围;不传参数时统计全部已有成绩:
curl "http://127.0.0.1:5000/stats"
curl "http://127.0.0.1:5000/stats?term=2025-%E6%98%A5"
curl "http://127.0.0.1:5000/stats?course=%E6%95%B0%E5%AD%A6&term=2025-%E6%98%A5"
AVG 计算的是数据库中该分组已有记录的算术平均值,不会把缺考、未录入或退课学生自动按零分计入。业务上如果需要按班级花名册计算“全员平均”,应先定义未录入成绩的处理规则,再用学生名单与成绩表做关联统计。
五、关键实现与边界说明
1. 参数校验不能只依赖前端
接口检查学生编号、姓名、课程、学期是否为空以及长度是否超限,并限制成绩范围和小数位数。前端表单校验主要改善交互体验,调用 API 的客户端可以绕过前端,因此服务端仍须独立校验。
代码中的长度限制按 Python 字符串长度执行,适用于基础输入约束;它不等同于数据库字符存储字节数限制,也不包含学校对学号格式、课程编码或学期名称的业务规则。实际使用时应根据已有数据规范调整校验,而不是随意猜测格式。
2. SQL 参数化防止把输入当作 SQL 代码
查询中的用户输入通过 SQLite 的 ? 占位符传入,不能把输入值直接拼接到 SQL 字符串中。代码拼接的筛选条件列名来自固定的服务端常量,筛选值仍通过参数绑定。因此,用户输入课程名称或学期名称时不会被当作 SQL 语句执行。
参数化查询是防止 SQL 注入的重要措施,但它不替代权限控制、输入校验或错误处理。也不要为了动态查询方便,将用户提交的列名、排序表达式直接拼进 SQL。
3. 数据库约束是最后一道一致性防线
学生学号唯一、成绩记录组合唯一、分数范围以及学生外键都由数据库约束保护。即使后续增加其他接口,或出现并发请求,数据库仍会拒绝不满足规则的数据。代码在每个连接上开启 SQLite 外键检查;SQLite 的外键校验需要显式开启,不能只写出 FOREIGN KEY 就假设约束一定生效。
当前外键没有设置级联删除,删除学生时若仍有关联成绩,数据库会阻止删除。这是较保守的默认策略,可避免意外删除成绩。若要支持退学、毕业等业务,通常应考虑保留档案并改变状态,而不是直接删除关联数据。
4. 状态码与错误响应
示例对格式或范围错误返回 HTTP 400,对重复学号返回 HTTP 409,对不存在的学生返回 HTTP 404。成功创建学生返回 201,成功查询或录入成绩返回 200。调用方应结合状态码和 JSON 中的 error 字段处理错误,不要把任何非 200 响应都当成数据库故障。
六、常见错误排查
- 请求返回“请求体必须是 JSON 对象”:确认请求头包含
Content-Type: application/json,且请求体是对象形式,不是空内容、数组或格式错误的 JSON。 - 录入成绩返回学生不存在:先调用
GET /students检查 ID;不要把学号字符串误当成数据库整数 ID。 - 分数被拒绝:范围为 0 至 100,最多两位小数。
100.01、-1、87.256都会被拒绝。整数和一位小数均可。 - 重复录入后看不到两条记录:这是唯一约束和更新策略的预期行为。同一学生、课程、学期只保留最新成绩。
- 统计结果为空:检查课程和学期的拼写、空格及大小写。当前查询是精确匹配,没有模糊搜索,也不会自动规范学期命名。
- 修改了代码但数据仍在:默认数据库文件会持久保存。若仅在本地练习,停止服务后删除
school.db可重新初始化;删除前先确认其中没有需要保留的数据。
七、从示例走向实际使用
数据量增大或需要多人同时使用时,应评估 SQLite 的并发写入与部署方式,并考虑迁移到 PostgreSQL 或 MySQL。还应补上登录认证、基于角色的访问控制、分页、请求限流、日志、备份恢复、迁移脚本和监控。学生成绩属于敏感信息,日志中不要随意记录完整请求体,数据传输和存储也应遵循所在组织的隐私与保留规定。
开发测试时可以使用 debug=True,但 Flask 调试器不适合对外提供服务。生产部署应关闭调试模式,使用合适的 WSGI 服务和反向代理,并设置访问控制与 HTTPS。SQLite 数据库文件还需要纳入备份方案;把数据库文件放在临时目录或容器可丢弃层中,可能导致重启后数据丢失。
这份实现将成绩录入、查询和统计串成了一个可测试的最小闭环:服务端校验请求,数据库约束保证基本一致性,参数化 SQL 执行筛选,聚合查询生成统计结果。后续无论增加网页界面、批量导入还是权限管理,都应沿用这些边界原则,并先明确业务规则,再扩展数据模型与接口。









