ARTICLE DETAIL

资讯详情

深耕网站建设与运营推广的一线实战洞察。

Python Flask实战:手把手搭建成绩管理系统

Python Flask实战:手把手搭建成绩管理系统 简介码蚁成绩管理系统是一套面向学校及教学管理场景的Web成绩管理程序围绕成绩录入、统计与查询三大核心环节展开。系统覆盖单位与类别管理、学期/班级/班主任/学科设置、管理员与角色权限控制、学生信息建档、考试参数配置、满分及优秀/良好/及格分数线自定义支持在线录入、表格批量录入和扫码枪录入并能生成考试号、试卷标签和成绩采集表。统计端提供成绩表格、柱形/折线/雷达/箱体/仪表图、成绩报告以及学生历次成绩趋势对比教师与学生按不同身份登录数据权限可依据职务和任务分工灵活控制。压缩包共2000个文件以PHP源码为主另含HTML页面、JavaScript、CSS样式、JSON配置及Markdown/文本说明文档整体大小8.03MB便于本地部署或二次开发。目前已有427人浏览/学习适合需要快速搭建成绩管理系统的开发人员或教学管理人员直接参考使用。1. 码蚁成绩管理系统从录入到统计查询一套能真正落地的教务工具我见过太多学校还在用Excel传成绩一个年级十几个班班主任各自填表教务处汇总后格式五花八门求和公式被误删、姓名带空格导致匹配不上、排名算错还得返工。码蚁成绩管理系统要解决的就是这摊子事——把成绩录入、统计、查询三个环节串成一条流水线让教务老师从表哥表妹变成真正管数据的人。这套系统适合中小学教务处、培训机构教务岗也适合想用Python快速搭建内部工具的开发者。它不依赖商业平台本地部署即可运行数据自主可控报表输出直接可用。我按自己做过的一套方案拆给你看从表结构设计到统计脚本从查询接口到部署避坑全部给到可复现的代码和参数。2. 成绩系统先立数据模型为什么表结构决定统计效率2.1 三张核心表的设计思路与字段选型做成绩系统最容易犯的错是把一次考试当成一张表来建比如exam_2024_期中。这种设计短期看着直观等下学期再考一次就得新建表统计跨学期成绩时SQL写得像拼积木。我一般用三张表学生表、考试表、成绩明细表。学生表存学号、姓名、班级学号用字符串类型而不是整数因为学号可能以0开头整数会丢前导零。考试表存考试名称、类型期中/期末/月考、考试日期、满分值。成绩明细表是核心存学号、考试ID、科目、得分一行一条。成绩明细表的主键要设计成联合主键学号考试ID科目这样能从根本上防止同一个人同一科被录两遍。得分字段用DECIMAL(5,2)最大支持999.99分覆盖绝大多数考试场景用FLOAT会出现0.10.2不等于0.3的精度问题。班级字段冗余在成绩明细表里虽然违反了第三范式但查询时能少一次JOIN在数据量几万条的规模下性能差异明显。CREATE TABLE students ( student_no VARCHAR(20) PRIMARY KEY, student_name VARCHAR(50) NOT NULL, class_name VARCHAR(30) NOT NULL ); CREATE TABLE exams ( exam_id INT AUTO_INCREMENT PRIMARY KEY, exam_name VARCHAR(100) NOT NULL, exam_type ENUM(midterm,final,monthly) NOT NULL, exam_date DATE NOT NULL, full_score DECIMAL(5,2) DEFAULT 100 ); CREATE TABLE scores ( student_no VARCHAR(20), exam_id INT, subject VARCHAR(20), score DECIMAL(5,2), class_name VARCHAR(30), PRIMARY KEY (student_no, exam_id, subject), FOREIGN KEY (student_no) REFERENCES students(student_no), FOREIGN KEY (exam_id) REFERENCES exams(exam_id) );2.2 为什么选择Python Flask而不是其他框架数据模型定了之后做的事就是拿一个轻量框架把它包成Web服务。我用Flask因为成绩录入和查询这个场景不需要重型框架。Django自带Admin后台确实能少写代码但它的ORM对联合主键支持不够灵活自定义统计查询时要绕过默认的Model层写原始SQL反而多一道坎。FastAPI性能更好但团队里如果还有不熟悉异步语法的老师要维护代码上手成本高。Flask配SQLAlchemy就够了。值得注意的一点SQLAlchemy里定义联合主键时要在Column声明里带上primary_keyTrue不能用UniqueConstraint替代否则查询性能和ORM映射行为会有差异。下面是我项目里实际用的模型定义from flask_sqlalchemy import SQLAlchemy db SQLAlchemy() class Score(db.Model): __tablename__ scores student_no db.Column(db.String(20), primary_keyTrue) exam_id db.Column(db.Integer, primary_keyTrue) subject db.Column(db.String(20), primary_keyTrue) score db.Column(db.Numeric(5, 2)) class_name db.Column(db.String(30)) def to_dict(self): return { student_no: self.student_no, exam_id: self.exam_id, subject: self.subject, score: float(self.score), class_name: self.class_name }这段代码的要点在于分数用Numeric(5,2)而不是Float避免浮点精度异常to_dict()方法把Decimal转成float这样后面JSON序列化不会报TypeError: Object of type Decimal is not JSON serializable。如果你在别的项目里踩过这个坑就知道这个转换有多重要。联合主键在ORM里就靠三个primary_keyTrue撑起来插入重复数据时数据库会直接拒绝不用在业务代码里做存在性判断。3. 成绩录入的三种落地方式表格导入、页面录入、接口对接3.1 Excel导入模块用pandas做数据清洗和格式校验成绩录入最现实的场景是班主任手里有一张Excel表列名可能是姓名 语文 数学 英语也可能是学号 姓名 班级 语文 数学 英语。直接读库会翻车第一件事是标准化。我用pandas读取后统一重命名列然后按学号或姓名匹配学生表匹配不上的单独拉出来记录到错误日志里不直接丢弃。导入接口的代码套路如下import pandas as pd from flask import request, jsonify def import_scores(exam_id): file request.files[file] df pd.read_excel(file, dtype{学号: str}) # 列名标准化 column_map {姓名: student_name, 语文: chinese, 数学: math, 英语: english} df df.rename(columnscolumn_map) # 检查空值 if df[[student_name, chinese, math]].isnull().any().any(): error_rows df[df.isnull().any(axis1)].to_dict(records) return jsonify({code: 1, msg: 存在空值行, data: error_rows}), 400 # 分数范围校验 for subject in [chinese, math, english]: invalid df[(df[subject] 0) | (df[subject] 100)] if not invalid.empty: return jsonify({code: 1, msg: f{subject}超出范围, data: invalid.to_dict(records)}), 400 # 逐行写入成绩明细 for _, row in df.iterrows(): score Score( student_norow[学号], exam_idexam_id, subject语文, scorerow[chinese], class_namerow[班级] ) db.session.add(score) db.session.commit() return jsonify({code: 0, msg: f成功导入{len(df)}条记录})这段代码有几个关键参数值得细说dtype{学号: str}是必须的Excel里的学号如果被识别成数字pandas会把它读成int64后面和数据库里的VARCHAR匹配时全查不到。分数范围校验用的阈值按满分动态传入不要写死100因为考试表里full_score字段就是干这个用的。空值检测我放在最前面因为后面分数范围判断里df[df[subject] 0]会把NaN比较的结果变成False空值行被静默跳过等你发现时数据已经录进去了。3.2 单条录入的页面表单实现要点Excel导入适合批量场景但个别学生补考、缓考、成绩更正时录入界面得支持单条操作。表单提交的关键是把学科和分数做成动态字段不要写死三个科目。有些学校的考试科目是语文、数学、英语有些是物理、化学、生物、政治、历史、地理前端动态生成表单后端遍历提交的键值对即可。select idstudent-select !-- 动态渲染学生列表 -- /select select idsubject-select option value请选择科目/option /select input typenumber idscore-input step0.01 min0 max100 button onclicksubmitScore()保存/button// 动态科目加载 async function loadSubjects(examId) { const resp await fetch(/api/exams/${examId}/subjects); const data await resp.json(); const sel document.getElementById(subject-select); sel.innerHTML data.map(s option value${s}${s}/option).join(); } async function submitScore() { const payload { student_no: document.getElementById(student-select).value, exam_id: examId, subject: document.getElementById(subject-select).value, score: parseFloat(document.getElementById(score-input).value) }; const resp await fetch(/api/score, { method: POST, headers: {Content-Type: application/json}, body: JSON.stringify(payload) }); const result await resp.json(); if (result.code 0) { // 刷新当前成绩列表 } }前端step0.01和min/max约束只解决正常输入恶意请求照样能提交负数或超出满分的值所以后端要再校验一次。我通常在后端用一个字典维护每个科目允许的满分值比如SUBJECT_SCORE_LIMIT {语文: 150, 数学: 150, 英语: 150}校验时score limit[subject]直接拒绝。前端校验是给用户友好提示的后端校验才是真防线。3.3 接口对接给其他系统开放成绩写入如果学校已经有OA或教务系统成绩数据在那边录入后会推到这边来这时要提供一个HTTP接口。我把接口设计成接收JSON数组一次批量写入而不是单条推多次这样对接方调用简单数据库事务也容易保证一致性。app.route(/api/scores/batch, methods[POST]) def batch_scores(): data request.get_json() if not isinstance(data, list) or len(data) 500: return jsonify({code: 1, msg: 数据必须是列表且不超过500条}), 400 try: for item in data: score Score( student_noitem[student_no], exam_iditem[exam_id], subjectitem[subject], scoreitem[score], class_nameitem.get(class_name, ) ) db.session.add(score) db.session.commit() return jsonify({code: 0, msg: f批量写入{len(data)}条}) except Exception as e: db.session.rollback() return jsonify({code: 1, msg: str(e)}), 500批量接口要设上限我压到500条是因为一次事务太多会导致锁竞争。db.session.rollback()在异常时必须调用否则SQLAlchemy的session进入脏状态后续查询会全部报错——这个玄学坑我遇到过不止一次。对接方如果传了重复主键数据库会抛IntegrityError此时整个批量事务回滚比逐条跳过更安全因为对接方需要知道数据有问题而不是被静默修正。4. 成绩统计模块从总分排名到班级横向对比4.1 总分与排名的SQL实现及边界处理统计是成绩系统的重头戏。教务老师最常问的是三件事年级排名、班级排名、各科平均分。排名用窗口函数比Python排序更靠谱因为SQL层面直接输出rank值不用把几万条记录拉到内存里。MySQL 8.0及以上支持ROW_NUMBER()和DENSE_RANK()注意区分并列名次需要跳过还是连续决定了用哪个函数。SELECT s.student_no, s.student_name, s.class_name, ROUND(SUM(sc.score), 2) AS total_score, DENSE_RANK() OVER (ORDER BY SUM(sc.score) DESC) AS grade_rank FROM scores sc JOIN students s ON sc.student_no s.student_no WHERE sc.exam_id %(exam_id)s GROUP BY s.student_no, s.student_name, s.class_name ORDER BY total_score DESC;这里有几个容易翻车的边界缺考学生没成绩记录SUM会返回NULL而不是0排名时这些学生排到末尾但总分显示NULL解决办法是COALESCE(SUM(sc.score), 0)。同一学生在成绩明细表里如果存在重复记录比如补录时没走联合主键校验SUM会把分数翻倍排名直接失真。所以我在统计查询前会先跑一条去重检查SQL把重复的学号考试科目找出来。班级排名比年级排名多一个PARTITION BY sc.class_name用法是把OVER (ORDER BY SUM(...))改成OVER (PARTITION BY sc.class_name ORDER BY SUM(...) DESC)这样每个班级内部独立排名。注意班级排名前的PARTITION BY用班级字段一定要用学生表里的当前班级不能用成绩明细表里冗余的班级因为学生可能中途转班历史成绩的班级字段保留的是考试当时的班级。4.2 基于pandas的统计报表生成透视表与Excel导出数据库算排名后前端展示用图表更直观导出Excel给老师们发通知也是刚需。我用pandas做数据加工然后用openpyxl生成带格式的Excel文件。因为pandas的groupby语法对教务老师更友好他们后期要临时添加统计维度改Python代码比改SQL门槛低。def generate_report(exam_id): # 从数据库读取成绩数据 rows db.session.query(Score).filter(Score.exam_id exam_id).all() df pd.DataFrame([r.to_dict() for r in rows]) # 透视表行学生列科目值分数 pivot df.pivot_table( index[student_no, student_name, class_name], columnssubject, valuesscore, aggfuncfirst ).reset_index() # 计算总分和排名 pivot[总分] pivot.select_dtypes(includefloat64).sum(axis1) pivot[班级排名] pivot.groupby(class_name)[总分].rank(ascendingFalse, methodmin) pivot[年级排名] pivot[总分].rank(ascendingFalse, methodmin) # 导出Excel writer pd.ExcelWriter(report.xlsx, engineopenpyxl) pivot.to_excel(writer, sheet_name成绩明细, indexFalse) # 生成班级统计表 class_stats pivot.groupby(class_name)[总分].agg([mean, max, min, count]) class_stats.to_excel(writer, sheet_name班级统计) writer.close()这段代码里aggfuncfirst是刻意选的。正常情况下一个学生在同一考试同一科目只有一条记录用first和sum结果一致但如果存在脏数据sum会把分数翻倍first只会取第一条。务实角度说first更安全因为成绩系统里宁可显示一次成绩也不能翻倍。排名用methodmin和SQL的DENSE_RANK语义一致并列名次不占用后续名次。4.3 统计结果缓存避免每次查询都重算成绩统计的查询频率高但计算结果只在录入新成绩后才会变化。如果每次打开报表页面都跑一遍全量聚合数据量到几万条时响应会变慢。我加了一层缓存以exam_id 统计类型作为key结果存到Redis里成绩增删改操作时主动失效对应缓存。import redis, json r redis.Redis(hostlocalhost, port6379, db1) def get_cached_report(exam_id, report_type): cache_key freport:{exam_id}:{report_type} cached r.get(cache_key) if cached: return json.loads(cached) # 实际计算逻辑 result compute_report(exam_id, report_type) r.setex(cache_key, 3600, json.dumps(result)) return result缓存失效时机需要想清楚。成绩录入和修改必须调r.delete(cache_key)不然老师录完成绩刷新页面发现还是旧数据就会怀疑系统坏了。缓存时间我设3600秒作为兜底防止哪天逻辑漏了删除导致永远展示旧数据。Redis可以换成内存字典单机部署时区别不大但换Redis的原因是想让缓存能跨进程共享——如果用gunicorn起了4个worker进程进程内缓存会导致同一个用户两次请求打到不同进程得到不同结果这个坑挺隐蔽。5. 成绩查询与权限控制查得到、查得快、查得安全5.1 多维度查询接口设计按考试、班级、学生、科目组合筛选查询页面是教务老师每天面对最多的界面。筛选条件组合多某个考试某个班的成绩、某个学生历次考试的趋势、某个科目全年级的分数分布。我把查询接口设计成动态拼接WHERE子句而不是为每个组合写独立接口。app.route(/api/scores/query, methods[GET]) def query_scores(): exam_id request.args.get(exam_id, typeint) class_name request.args.get(class_name) student_no request.args.get(student_no) subject request.args.get(subject) query Score.query if exam_id: query query.filter(Score.exam_id exam_id) if class_name: query query.filter(Score.class_name class_name) if student_no: query query.filter(Score.student_no student_no) if subject: query query.filter(Score.subject subject) scores query.all() return jsonify([s.to_dict() for s in scores])动态拼接在数据量大的时候要小心没有索引的列做filter会全表扫描。我给score表的exam_id和student_no建联合索引给class_name建普通索引这样四种常见组合都能走索引。subject字段基数值很小一般就七八种建索引收益不大反而浪费空间。组合查询的最坏情况是只传subject不带exam_id会扫全表所以在代码里加了个判断没有时间或考试范围限制的查询返回前先告警。分页是另一个必须做的设计。一个年级六七百学生一次查询返回几百行数据没问题但接口就得支持page和page_size参数。我用query.paginate(pagepage, per_pageper_page, error_outFalse)注意error_outFalse这个参数如果传的页码超出范围默认会抛404关了之后返回空列表前端代码更省事。5.2 学生自助查询按学号姓名校验学生或家长要查成绩不能让他们看到全年级数据。自助查询接口只接受学号和姓名两个参数匹配通过后返回当前学生所有考试的成绩记录。这个接口不需要登录用学号姓名做双重校验学号是半公开的加上姓名才够。如果学校要求更严格就加一个查询密码字段在学生表里。密码不能明文存用werkzeug的generate_password_hash和check_password_hash处理。app.route(/api/student/scores, methods[GET]) def student_scores(): student_no request.args.get(student_no) student_name request.args.get(student_name) password request.args.get(password, ) student Student.query.filter_by(student_nostudent_no, student_namestudent_name).first() if not student: return jsonify({code: 1, msg: 学号或姓名不匹配}), 401 if not check_password_hash(student.query_password, password): return jsonify({code: 1, msg: 密码错误}), 401 records db.session.query(Score).filter(Score.student_no student_no).all() return jsonify([r.to_dict() for r in records])5.3 教师权限分级班主任只能看本班教务能看全年级权限控制不能只在界面上隐藏入口后端每个接口都要做校验。我用装饰器实现角色校验三个角色学生、班主任、教务处。班主任的权限校验逻辑是先解析当前登录用户绑定的班级再检查请求参数里的class_name是否匹配。from functools import wraps from flask import session, abort def require_role(role): def decorator(f): wraps(f) def wrapper(*args, **kwargs): user_role session.get(role) if user_role ! role: abort(403) return f(*args, **kwargs) return wrapper return decorator app.route(/api/class/scores, methods[GET]) require_role(teacher) def class_scores(): teacher_class session.get(class_name) class_name request.args.get(class_name) if class_name ! teacher_class and session.get(role) ! admin: abort(403) # 正常返回本班成绩这里的关键是前端按钮的显隐只是体验优化真正拦住越权行为的是后端的abort(403)和Session里的角色信息。Session默认存在客户端Cookie里如果学校对安全要求高就把Session换成服务端存储——Flask-Session插件配Redis做session后端这样老师改角色权限时立即生效不用等Cookie过期。6. 成绩系统避坑指南这8个坑我帮你踩过了6.1 Excel导入乱码与列名匹配现象导入的Excel文件中文列名正常但数据读出来全是乱码。原因read_excel默认用的是openpyxl引擎如果文件是.xls格式需要用xlrd引擎而xlrd 2.0之后不再支持.xls以外的格式另外Excel文件里如果有合并单元格pandas读出来会有大量NaN。解决统一要求上传.xlsx格式文件读取前校验扩展名列名匹配用df.columns.str.strip()去空格很多老师会在语文后面多打个空格匹配不上就报列不存在。6.2 学号前导零丢失现象学号001234导入数据库后变成1234学生查询时查不到记录。原因读取Excel时pandas把学号列识别成数字。解决两个位置要设dtype一是pd.read_excel传入dtype{学号: str}二是数据库表结构设计时用VARCHAR(20)而不是INT。如果数据已经丢了前导零就用LPAD(student_no, 6, 0)补齐但要确认学号固定位数有些学校学号长度不同LPAD会出错。6.3 统计时小数精度异常现象单次考试成绩总分显示249.99999999999997明明手算结果是250。原因数据库字段用的FLOATFLOAT是近似值存储累加时误差累积。解决数据库和ORM里的分数类型统一用DECIMAL/Numeric(5,2)计算时数据库做Sum用的是精确十进制运算。历史数据如果已经用FLOAT存了清洗时先UPDATE scores SET score ROUND(score, 2)再改字段类型。SQLAlchemy的Numeric类型在Python侧拿到的是Decimal对象to_dict里转float也会丢掉精度建议保留Decimal或转成字符串输出。6.4 并发导入导致数据重复现象两个教务老师同时导入同一个考试的成绩成绩明细表出现大量重复记录。原因导入代码先查重再写入两个事务同时查到不存在然后同时插入。解决依赖数据库主键而不是应用层查重联合主键在插入时有唯一性约束第二条插入直接抛IntegrityError事务回滚。如果学校用的是MySQL旧版本MyISAM引擎不约束外键也不支持事务需要迁移到InnoDB同时给成绩明细表ALTER TABLE scores ADD UNIQUE KEY unique_score (student_no, exam_id, subject)。6.5 排名结果与Excel手动排名不一致现象系统排名显示张三是第1名李四也是第1名但王五显示第3名Excel里手动排名觉得应该是第2名。原因排名函数选错了。Excel的RANK默认是跳过并列的DENSE_RANK不跳很多老师手动用的是普通排名。解决统一口径明确排名规则。如果学校要求并列名次保留空位用RANK()如果要求并列后连续排名用DENSE_RANK()。两种排名各有场景不要混用。注意我一般会在排名规则设置里加一个开关默认跟Excel的RANK函数一致。6.6 查询接口慢全表扫描与索引失效现象年级成绩查询从几百毫秒变成几秒钟数据库CPU飙升。原因查询条件里用了LIKE %张三%或者WHERE YEAR(create_time) 2024函数包裹索引列导致索引失效。解决查询条件要写成范围查询WHERE exam_date 2024-01-01 AND exam_date 2025-01-01。学号姓名查询用精确匹配不要用模糊查询。如果必须要模糊搜姓名给姓名列建全文索引或者排到后面做二次过滤。6.7 缓存导致成绩更新后查询仍是旧数据现象录入了新成绩查询报表还是旧数据刷新多次都没变化。原因缓存key没在写入时清掉或者清的是别的key。解决统一的缓存失效策略所有写操作都走同一个invalidate_cache(exam_id)函数而不是在每个写入接口里手动删除。这个函数维护一个集合记录该考试涉及的所有缓存key全部删掉。6.8 时区与日期问题现象成绩查询按考试日期筛选时时间边界上的数据查不到。原因数据库存储UTC时间前端传本地时间转换时差处理错误。解决不要在代码里手动拼08:00使用pytz或zoneinfo库统一设置。Flask应用配置app.config[TIMEZONE] Asia/Shanghai数据入库前统一转UTC。注意考试日期用DATE类型不带时分秒查询时用范围匹配而不是因为日期字段在ORM里可能自动带00:00:00后缀。7. 从能用到好用让成绩管理系统再往前一步录入、统计、查询跑通之后还能往三个方向优化我按优先级排一下。第一个是成绩单PDF导出不加这一步老师还是要把Excel里的排名复制到Word里做成绩单图表白做了。用weasyprint把HTML模板转PDF样式用CSS控制班级名称、考试名称、学生信息都从数据库动态取一次生成一个班的成绩单压缩包。模板里注意设置字体为宋体或微软雅黑不然PDF里中文显示成方块。第二个方向是成绩趋势分析。只算单次考试的排名意义有限把学期内三次月考的排名变化画成折线图能一眼看出谁在退步谁在进步。这需要把成绩明细表的数据按学号科目做时间序列聚合。我通常用ECharts前端画图后端接口只返回[{exam_date, score}]这种结构。做这个功能的教训是考试日期排序不能ORDER BY exam_id因为月考和期中考试的ID顺序不一定和时间顺序一致要用exam_date排序。第三个方向是预警功能。设定分数线语文低于60分自动标记按班级汇总生成预警名单。实现不复杂在统计接口里加一个filter条件或者用SQL的CASE WHEN在查询时生成标记字段。这背后的意义在于成绩系统不只是记录历史更要做教学改进的依据。最后说一个我被问过很多次的问题这个系统值不值得从零写如果学校预算充足买商业产品省心得多但如果你需要的只是一个内部工具、不想被厂商绑定、数据要完全自主可控那就用Flask加这套三表模型自己搭。从我的经验看一个开发两天就能跑通核心流程后面都是按学校需求迭代。我自己的习惯是每学期期末导出全部成绩到本地备份存一份CSV一份Excel。不是信不过数据库而是怕哪天服务器硬盘挂了备份是最后的后悔药。希望这套方案帮你在自己学校或项目里少踩几个坑把时间花在真正分析教学数据上。本文还有配套的精品资源点击获取
返回列表