学生管理系统源代码实战:实现成绩录入、查询与统计功能

资讯22小时前发布 jdkjadmin
0 00
学生管理系统源代码实战:实现成绩录入、查询与统计功能

学生管理系统源代码实战:实现成绩录入、查询与统计功能

学生成绩管理看起来只是“存一条分数、查几条记录、算个平均值”,实际开发时还要处理重复录入、非法分数、查询条件组合、数据一致性和隐私保护等问题。本文用 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 执行筛选,聚合查询生成统计结果。后续无论增加网页界面、批量导入还是权限管理,都应沿用这些边界原则,并先明确业务规则,再扩展数据模型与接口。

© 版权声明

相关文章