☰
毕业生信息审核系统实战:Flask状态机与权限控制方案
2026/10/8 13:55:59 网站建设 项目流程

简介:Python毕业生信息审核系统源码是一套面向Python学习者与Web开发初学者的完整项目,聚焦高校毕业生信息的录入、查询、审核与审批全流程,亦可迁移至教育管理或人力资源招聘等需要批量信息审核的场景。项目基于Python Web框架构建,涉及后端业务逻辑、接口测试、学生信息管理模块及中间件设计,能够较为完整地展示信息管理系统从数据存储到审核流转的实现思路。压缩包共54个文件,以45个py源文件为主,辅以xml配置、gitignore、说明文档与项目配置文件,整体约51KB,目录结构清晰,便于按模块拆解学习。目前已有123人学习下载。通过阅读和运行这份源码,读者可以理解manage.py启动、接口调用、数据库建模、权限校验与日志记录等关键环节,也可在Django/Flask框架及SQLite/MySQL等数据库配合使用中提升Web开发实操能力;项目还包含api_test.py等测试入口,便于对照数据流进行调试与二次开发,适合作为课程设计或入门项目的参考资料。

1. 毕业生信息审核系统:先判断它值不值得你投入

每年六月初,教务和学工的老师就开始头疼:几百个毕业生的信息核对表发下去,收回来的Excel五花八门,有些人改了一版再发一版,到底哪一版是最终稿,全靠人工记忆,出了错也没法追溯是谁改的。毕业生信息审核系统源码解决的就是这个场景——把“盖章式审核”变成“流程化线上审核”:学生自己提交个人信息与材料,辅导员初审,院系复审,学校终审,每一步都留操作日志。

对Python入门或正在找课程设计题目的同学来说,这个项目非常合适:它有明确的业务边界、有权限模型、有文件处理,技术栈又是最主流的Flask加SQLAlchemy,能讲到答辩评委听得懂的深度。对高校信息化团队,这套源码也可以作为通用审核流程的参考骨架。下文按一个能跑通的Flask实现来讲,重点落在数据模型、状态机、权限守卫和五个高频踩坑点上。

2. 毕业生信息审核系统的数据模型:先设计表再写代码

2.1 五张核心表把业务拆干净

毕业生审核,业务上其实就三类对象:人、材料、动作。人包括学生和各级审核者,材料是成绩单、就业协议、实习证明这类附件,动作则是谁在什么时间做了什么决定。我一般不建一张大宽表硬塞,而是拆成五张表:users用户表、students学生主表、materials材料表、audit_logs审核日志表、dict_options字典表。dict_options放专业列表、材料类型这类可枚举值,后期学校加专业时不用改代码。

这里给出核心的models.py精简版:

class User(UserMixin, db.Model): __tablename__ = 'users' id = db.Column(db.Integer, primary_key=True) username = db.Column(db.String(60), unique=True, nullable=False, index=True) password_hash = db.Column(db.String(128), nullable=False) role = db.Column(db.String(20), nullable=False) # student/counselor/dean/admin real_name = db.Column(db.String(50), nullable=False) college_id = db.Column(db.Integer, db.ForeignKey('colleges.id')) class Student(db.Model): __tablename__ = 'students' id = db.Column(db.Integer, primary_key=True) user_id = db.Column(db.Integer, db.ForeignKey('users.id')) college_id = db.Column(db.Integer, db.ForeignKey('colleges.id')) student_no = db.Column(db.String(20), unique=True, nullable=False, index=True) name = db.Column(db.String(50), nullable=False) major = db.Column(db.String(80), nullable=False) grade = db.Column(db.String(10), nullable=False) # 如 2025 届 phone = db.Column(db.String(20)) email = db.Column(db.String(120)) status = db.Column(db.String(20), default='draft', index=True) class Material(db.Model): __tablename__ = 'materials' id = db.Column(db.Integer, primary_key=True) student_id = db.Column(db.Integer, db.ForeignKey('students.id'), nullable=False) type = db.Column(db.String(30), nullable=False) # score_sheet/agreement/internship filename = db.Column(db.String(255), nullable=False) stored_path = db.Column(db.String(255), nullable=False) uploaded_at = db.Column(db.DateTime, default=datetime.now) class AuditLog(db.Model): __tablename__ = 'audit_logs' id = db.Column(db.Integer, primary_key=True) student_id = db.Column(db.Integer, nullable=False, index=True) operator_id = db.Column(db.Integer, nullable=False) action = db.Column(db.String(20), nullable=False) # submit/approve/reject/revise comment = db.Column(db.Text) old_status = db.Column(db.String(20)) new_status = db.Column(db.String(20)) created_at = db.Column(db.DateTime, default=datetime.now)

几点经验。student_no用String不用Integer,是因为学号可能含前导零或字母后缀,用数字类型会丢掉这些信息,等数据导进去才发现主键对不上就晚了。status列加索引是给列表筛选用的,毕业生审核列表页最常见的查询就是“按状态过滤”,没有索引时数据量大起来会走全表扫描。password_hash字段存的是werkzeug生成的哈希串,原始密码直接落库是这个系统里最不能碰的写法——源码一旦泄露就是所有账号裸奔。

审核日志表单独建,而不是在students表上加一堆时间字段,原因在于一次审核记录可能被驳回多次再重新提交。用old_status和new_status把变更前后的状态都记下来,比只记新状态好排查得多。出问题时可以精确回放这个学生的完整流转历史,不用去猜“他是不是从草稿直接跳到了通过”。

2.2 审核状态机:为什么不干脆用布尔值

很多第一版实现,审核结果就是一个is_approved布尔字段。当时看着省事,后面全是坑:驳回原因写在哪?学生改了又重新提交怎么记?院系看到的过程和学校看到的过程不一样?更关键的是,布尔字段没法表达“待审核”“草稿”这些中间态。我一般把状态定为六个:draft、pending、approved、rejected、revised、archived。revised表示被驳回后修改重新提交,archived表示毕业归档。

状态机的作用是给流转加约束,把非法跳转挡在写数据库之前。有人觉得这是过度设计,实际上这一小段代码能让你的统计口径从此不再靠猜。

ALLOWED_TRANSITIONS = { 'draft': ['pending'], 'pending': ['approved', 'rejected'], 'rejected': ['revised', 'approved'], # 驳回后修改重交,也允许审核直接通过 'revised': ['approved', 'rejected'], 'approved': ['archived'], # 终态,只允许归档 } def transition(student, new_status): if new_status not in ALLOWED_TRANSITIONS.get(student.status, []): raise ValueError(f'非法状态流转: {student.status} -> {new_status}') old_status = student.status student.status = new_status return old_status

这里我把驳回后的再次提交设成revised而不是直接回到pending。收益是:审核员在列表里能一眼看出“这人是被驳回过又修改交来的”,优先看修改说明,不用点进去和第一次提交的混在一起。如果你觉得状态多,把revised并入pending也可以,但列表页就要靠最近一条audit_logs去推断“是否被驳回过”,查询变复杂且慢。

状态机里的非法流转检查要放在事务里执行,校验、更新、写日志三步要么一起成功要么一起失败,避免出现状态改过去了、日志没写上的半截数据。

3. 跑通审核主流程:三个Flask路由与批量操作

3.1 学生提交与辅导员审核:最小可跑通的两条链路

表结构定了之后,主流程其实就是两个核心动作:学生提交、审核员决定通过或驳回。做成两个JSON接口,前端页面只要在这两个接口上套表单就能跑。

先看学生提交:

@app.route('/api/students/<int:student_id>/submit', methods=['POST']) @login_required def submit_student(student_id): student = Student.query.get_or_404(student_id) if student.user_id != current_user.id: abort(403) try: old = transition(student, 'pending') db.session.add(AuditLog( student_id=student.id, operator_id=current_user.id, action='submit', old_status=old, new_status='pending', comment='学生提交审核', )) db.session.commit() except ValueError as e: db.session.rollback() return {'error': str(e)}, 400 return {'status': student.status}, 200

逻辑说明:先检查归属,再走状态机,最后把“谁在什么时间提交了”写入审计日志。注意abort(403)和返回400的边界:归属错误属于权限问题,用403让前端明确感知“你没有权限操作这条数据”;状态非法属于业务冲突,返回400携带错误信息,前端可以直接把error信息弹给用户。db.session.rollback()很关键,事务失败后如果不回滚,连接上挂着半截事务,下一个请求复用连接时会出现莫名其妙的“改动还在事务里”。

再看辅导员审核:

@app.route('/api/audit/<int:student_id>', methods=['POST']) @login_required @role_required('counselor', 'dean', 'admin') def audit_student(student_id): student = Student.query.get_or_404(student_id) # 数据级权限:审核员只能审本院系学生 if student.college_id != current_user.college_id: abort(403) data = request.get_json() action = data.get('action') # approve / reject comment = data.get('comment', '') if action not in ('approve', 'reject'): return {'error': 'action 只能是 approve 或 reject'}, 400 if action == 'reject' and not comment.strip(): return {'error': '驳回必须填写原因'}, 400 try: new_status = 'approved' if action == 'approve' else 'rejected' old = transition(student, new_status) db.session.add(AuditLog( student_id=student.id, operator_id=current_user.id, action=action, comment=comment, old_status=old, new_status=new_status, )) db.session.commit() except ValueError as e: db.session.rollback() return {'error': str(e)}, 400 return {'status': student.status}, 200

这里有两道检验:角色是辅导员、院系归属一致。很多系统只做了第一道,结果辅导员能审到别的院系去。归属校验拿当前登录用户的college_id和学生college_id比对,是典型的数据级权限,第4章细讲。驳回必填原因这个规则不只是体验问题——驳回不留痕,学生看不懂为什么被打回,第二天会重新提交一模一样的材料,审核循环空转。

3.2 批量审核与Excel导出:毕业季效率的来源

毕业季的审核是按批量算的,一个辅导员手上有几十上百条待审记录,一条条点再提交会点疯掉。常见做法是提供一个批量接口,前端勾选一批学生,统一执行通过或驳回。

@app.route('/api/audit/batch', methods=['POST']) @login_required @role_required('counselor', 'dean', 'admin') def audit_batch(): data = request.get_json() ids = data.get('ids', []) action = data.get('action') comment = data.get('comment', '') if not ids or action not in ('approve', 'reject'): return {'error': '参数不合法'}, 400 done, skipped = [], [] try: with db.session.begin(): for sid in ids: student = Student.query.get(sid) if not student: skipped.append({'id': sid, 'reason': '记录不存在'}) continue if student.college_id != current_user.college_id: skipped.append({'id': sid, 'reason': '非本院系数据'}) continue new_status = 'approved' if action == 'approve' else 'rejected' old = transition(student, new_status) db.session.add(AuditLog( student_id=student.id, operator_id=current_user.id, action=action, comment=comment, old_status=old, new_status=new_status, )) done.append(sid) except ValueError as e: return {'error': str(e)}, 400 return {'done': done, 'skipped': skipped}, 200

db.session.begin()把整个循环包进一个事务,要么全部成功,要么一处非法就整体回滚。这避免了尴尬场景:500条里第37条状态异常,前面36条已经提交,审核员二次点击时发现数据变化了却不知道哪几条成功了。done和skipped分开返回,前端能展示“成功XX条、跳过XX条、跳过原因”,这个反馈很重要——不是黑匣子式的“操作完成”。

导出Excel是审核系统的另一个标配功能:学校层面要的是各专业通过率、未通过名单、材料缺失情况。统计汇总交给pandas很方便:

# 顶部 import pandas as pd from flask import send_file from io import BytesIO @app.route('/api/export/approved', methods=['GET']) @login_required @role_required('dean', 'admin') def export_approved(): students = Student.query.filter_by(status='approved').all() rows = [{ '学号': s.student_no, '姓名': s.name, '专业': s.major, '班级': s.grade, '联系方式': s.phone, } for s in students] df = pd.DataFrame(rows) summary = df.groupby('major').size().reset_index(name='通过人数') output = BytesIO() with pd.ExcelWriter(output, engine='openpyxl') as writer: df.to_excel(writer, sheet_name='通过名单', index=False) summary.to_excel(writer, sheet_name='专业汇总', index=False) output.seek(0) return send_file(output, as_attachment=True, download_name='approved_graduates.xlsx')

pandas的groupby比手写SQL再拼Excel快得多。如果环境里没装,一次性把pandas和numpy装齐——pip install pandas numpy openpyxl。numpy在这个项目里不是主场,但它是pandas的地基,漏装后启动时必报ImportError。导出接口记得挂dean/admin权限,这类文件包含全部学生联系方式,属于敏感数据,给辅导员导出全院的名单在多数高校是不合规的。

4. 三种角色的权限控制:越权是这个系统最容易漏的地方

4.1 角色守卫装饰器:让没权限的人拿不到接口

权限控制第一个层次是接口级控制:哪些角色能调这个接口。最通用的方案是装饰器:

from functools import wraps from flask import session, abort def role_required(*roles): def decorator(fn): @wraps(fn) def wrapper(*args, **kwargs): if session.get('role') not in roles: abort(403) return fn(*args, **kwargs) return wrapper return decorator

用法就是在路由上挂一行:@role_required('counselor', 'dean', 'admin')。好处是权限逻辑集中在路由声明处,读代码时一眼就知道接口的准入边界。session里存的是登录时写入的用户信息,建议把user_id、role、college_id都放进去,避免每个请求都去查一次数据库。配合登录接口:

@app.route('/api/login', methods=['POST']) def login(): data = request.get_json() user = User.query.filter_by(username=data.get('username')).first() if not user or not check_password_hash(user.password_hash, data.get('password')): return {'error': '用户名或密码错误'}, 401 session['uid'] = user.id session['role'] = user.role session['college_id'] = user.college_id return {'role': user.role, 'name': user.real_name}, 200

注意装饰器顺序:@login_required要放在@role_required的上面(外层),因为role_required要读session里的role,得先确保已经登录。顺序反过来,未登录用户会先撞上403而不是401,前端会误判成“已登录但无权限”,跳转逻辑就乱了。这个细节特别容易被忽略,等前后端联调时就会发现登录失效的提示一路通不到登录页。

这里还有个常见误区:有人把admin挂到所有接口上,认为“管理员是超级角色,什么都能干”。在审核系统里,admin应该是配置管理员,不是业务操作员。admin能建账号、配置流程,但具体审核动作应该由辅导员和院系管理员操作,否则审核记录里的operator_id是admin,追溯起来说不清是哪个环节的人做的。

4.2 数据级权限:只有角色还不够的4个场景

角色控制解决“能不能调接口”,数据级权限解决“接口内部能不能操作这条数据”。最典型的四个越权场景。

第一个,学生A修改学生B的信息。路由参数是student_id,实现里没做归属校验,访问/api/students/3/submit就能改别人的档案。校验方式是提交接口里比对student.user_id和current_user.id,或者干脆拒绝一切非draft状态的修改请求。

第二个,辅导员审核本院系以外的学生。这是跨院系越权。必须拿当前用户的college_id和待审学生的college_id做比对,批量接口里也要逐个过滤,而不是只看前端勾选结果。前端不传college_id,后端自己查,这一点很重要。

第三个,管理员篡改已归档记录。毕业归档后students记录应当不可变,只能在archived之后走人工订正流程。这个约束靠状态机兜底,但如果审核接口里没有校验“终态不可修改”,仍然可能被直接改回pending。建议在更新前统一加一个断言:if student.status in ('approved', 'archived'): abort(403)。

第四个,纵向越权,比如辅导员试图直接终审通过自己带的班级。多数高校流程是辅导员初审、院系终审,两级审核不能是同一个人。这靠状态机不够,要按角色限制可流转方向:counselor只能把pending变成rejected或revised后的继续驳回,不能直接变成approved;approved必须由dean角色来掉。

数据级权限的检查位置放在路由函数内部、状态机调用之前,而不是装饰器里。原因很简单:装饰器拿不到具体的资源对象,强行查库会把代码写得很绕。把归属校验写在具体接口里,逻辑顺序一目了然,也方便写单元测试。

5. 避坑指南:这套审核源码最常翻车的5个地方

5.1 坑1:SQLite并发写导致“database is locked”

现象:审核高峰时,多个辅导员同时批量操作,页面不定期报OperationalError: database is locked。

原因:SQLite的写锁是数据库级别的,一次只允许一个写事务,Flask默认连接池在并发写时容易超时报错。毕业季几十个辅导员同时在线操作,这种场景很常见。

解决:课设演示用SQLite没问题,真实部署第一件事就是把数据库换成MySQL或PostgreSQL,改一下连接串,SQLAlchemy的模型代码几乎不用动。如果坚持用SQLite,开WAL模式能缓解,在engine创建时执行PRAGMA journal_mode=WAL,配合短事务能扛住小规模并发。自测方法:开两个终端同时跑批量审核脚本,看哪个先抛锁,就能判断你的并发模型是否安全。

5.2 坑2:日期格式不统一,统计口径全乱

现象:导出Excel后发现部分学生的毕业年份变成数字串或空值,专业汇总里人数对不上。

原因:前端表单里有的传2025-06-30,有的传2025/6/30,还有人传“2025年6月”。入库时SQLAlchemy能解析一部分,解析不了的直接抛错或存成NULL。

解决:前端统一用date input,后端接收时强制解析一次,解析失败直接给400,不放脏数据入库。解析函数不要自己手写正则,用datetime.strptime,白名单里只允许ISO8601一种格式。我见过最离谱的一版,字段类型是String,把“六月底”这种中文都存进去了。审核系统一旦涉及统计报表,日期字段必须是日期类型,字符串存日期是给自己上刑。

5.3 坑3:文件上传不校验类型,路径穿越带来安全隐患

现象:上传材料后,文件名变成一串乱码,下载时文件名不对;严重时有人上传了可执行文件。

原因:信任了客户端传来的filename。直接把filename拼路径,遇到../这类内容就会被路径穿越利用。我在代码评审里见过真实案例,上传接口可以覆盖服务器上任意路径的文件。

解决:不用原始文件名做存储路径,用uuid重命名保存,扩展名用白名单校验。安全片段如下:

import os from uuid import uuid4 ALLOWED_EXTENSIONS = {'pdf', 'jpg', 'jpeg', 'png', 'zip'} file = request.files['file'] ext = os.path.splitext(file.filename)[1].lower().lstrip('.') if ext not in ALLOWED_EXTENSIONS: return {'error': f'不支持的文件类型: {ext}'}, 400 stored_name = f'{uuid4().hex}.{ext}' file.save(os.path.join(UPLOAD_DIR, stored_name))

uuid生成新文件名,原始文件名只留存在数据库的filename字段,下载时按需读取。既防路径穿越,又避免重名覆盖。下载时用send_file取stored_path,download_name取数据库里存的原始filename,不要信任用户新传的参数。exe、py这类文件一概不进白名单,后端校验必须有,前端校验只是体验。

5.4 坑4:批量审核事务边界不清,处理到一半状态已改

现象:批量操作500条记录,第37条状态不合法,接口报错,但前面36条已经改了。

原因:循环里每条commit一次,事务边界切得太碎。

解决:把整个批量循环包进db.session.begin(),任何一条失败就整体回滚。代价是“部分成功”无法保留,但审核操作本来就该保持原子性。如果业务上确实要“部分成功”,必须明确返回done和skipped列表,并且前端明确提示“部分记录未处理”,不能只报一个错误就完事。这个场景里,事务边界不是一个性能问题,是一个数据一致性问题,优先级完全不同。

注意:db.session.begin()块内抛出的异常会让上下文管理器自动回滚,不需要在except里再手动rollback,否则事务状态会混乱。

5.5 坑5:状态流转没有日志,出问题无法回放

现象:发现某个学生审核通过了,但不知道是谁、什么时候、依据什么材料通过的。

原因:只更新students.status,没有写audit_logs;或者写了日志但不记录old_status。

解决:每次状态变更都写一条audit_logs,old_status和new_status都存。这条日志既是审计追溯的依据,也是统计分析的数据源。留存了完整日志后,你可以算出平均驳回次数、驳回原因分布、哪些材料最容易缺失,这些指标对教务优化流程非常有价值。如果没做这一步,系统跑三个月后就是一团谁也说不清楚的黑匣子。

6. 从“能跑通”到“能答辩/能交付”:验证清单与两个进阶方向

6.1 一份可以直接照做的验证清单

交出去之前,按这个清单过一遍。用三个浏览器分别登录学生、辅导员、管理员,验证完整流程:学生提交、辅导员驳回、学生修改再提交、辅导员通过、管理员归档。然后测权限:学生访问审核接口返回403,辅导员审别的院系返回403,未登录用户访问导出文件返回401或302。最后做数据检验:导出Excel后抽查三个人的信息,和数据库里逐一对照,核对统计口径。性能不用过度压测,但至少用1000条测试数据跑一遍列表页,确认状态筛选没有明显卡顿。

6.2 两个进阶方向值得投入

第一个方向是解耦审核规则。把“哪些材料必传、哪些专业需要实名认证、哪些情况必须走人工复核”做成可配置规则,用字典表或JSON配置驱动,而不是硬编码在路由里。学校调整政策时不用改代码,这个点答辩时讲出来会让评委眼前一亮。

第二个方向是换前后端分离架构,Flask只出API,前端用Vue。对毕设来说这是加分项,能让老师看到你对接口设计和前后端协作的理解。注意跨域和登录态要早做设计,不要等前后端联调时再翻车。跨域方案用Flask-CORS能快速解决,登录态继续用session加Cookie,如果走JWT则要处理token刷新逻辑。

做这套系统时我自己栽过的坑,就是一开始省事务、省状态机,用一堆is_开头的布尔字段硬顶,最后统计口径乱成一团,回放审核记录全靠翻聊天记录。后来老老实实把状态机补上,把每步变更写进日志,系统的稳定性才真正立住。如果你也是从这类“看着简单”的项目起步,希望这条路子能让你少走一点弯路,也希望帮到你。

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

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

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

立即咨询