“作业111”这几个字,最初只是代码仓库里一个随手起的目录名。但就是这样一个看起来毫不起眼的代号,最后长成了一个能支撑几十人同时提交作业、教师在线批改、自动汇总成绩的小系统。前前后后我改了四版,踩了大大小小十几个坑,也把当初完全不搭边的零散代码彻底重写了一遍。现在回过头看,这个项目最值得聊的并不是功能本身,而是“如何把一个看起来像随便占位符的作业,做成一份能真正落地运行、能被别人使用的完整作品”。如果你也接过类似的课程大作业、团队实训项目,或者只是想看看一个真实的Web小系统从零到一是怎么搭起来的,这篇复盘应该能给你一些可复用的思路。
这个“作业111”可以做什么?一句话概括:它是一个基于Flask + SQLite的轻量级作业提交与成绩管理系统。学生登录后可以上传作业文件、填写提交说明;教师登录后可以创建作业批次、在线下载学生作业、批改打分,并把所有成绩一键导出成CSV表格;管理员负责维护用户账号。整个系统不依赖复杂的前端框架,不用Docker,不搞微服务,装好Python环境就能跑,非常适合作为课程设计、实训项目或者个人练手项目。如果你是刚接触Web开发的学生,或者被安排做“XX管理系统”这类题目但不知道从哪下手的开发者,本文的每一步都值得对着敲一遍。
1. 项目整体设计与技术选型
1.1 需求拆解:这个项目到底要解决什么问题
做任何项目之前,先别急着写代码。我拿到“作业111”这个题目时,第一反应是把它当成一个普通的“课程作业管理系统”来做。但后来和需求方一聊,发现真正的痛点有三个:
第一,学生交作业的方式太乱。有人发邮箱,有人传网盘,有人直接在群里丢压缩包,老师收集的时候要一个个点开下载、手动改名,非常痛苦。所以系统的第一个核心功能必须是“统一的作业提交入口”。
第二,批改结果不透明。学生交了作业之后,不知道自己到底交没交上、老师看了没有、成绩是多少。所以系统必须要有状态跟踪——已提交、已批改、已退回,这些状态要在界面上清楚展示。
第三,成绩汇总费劲。一个班几十人,助教手动整理Excel表格,列对不齐、人名对不上,每次都是灾难。所以系统必须能按作业批次导出标准格式的成绩表,最好直接兼容常见的表格软件。
基于这三点,我把“作业111”定义为:一个面向教师、学生、管理员三种角色的轻量级作业流转工具。它不是要做成大而全的教学平台,而是抓住“提交—批改—统计”这条主线,把每个环节做到顺手、直观、不容易出错。
1.2 技术选型:为什么是Flask + SQLite + 原生JS
技术选型是这次项目里争论最多的环节。有人提议用Django,说自带Admin后台,省事;有人提议用Spring Boot,说以后好扩展;还有人提议前端用Vue + Element UI,说界面好看。这些方案我都考虑过,但最终选择了Flask + SQLite + 原生JavaScript,原因很实际:
- 部署成本低。一个课程项目的生命周期可能只有一学期,不会有人愿意为它专门配一台服务器、装一堆依赖。Flask单文件就能起服务,SQLite连数据库都不用单独安装,一个文件就是整个库,复制走就能备份。
- 学习曲线平缓。Flask的路由和视图函数很直观,初学者看两段代码就能明白“URL怎么映射到Python函数”。Django的迁移机制、Admin配置、应用拆分,虽然功能强,但对小项目来说属于过度设计。
- 前端不用花哨框架。这个系统的主要操作是表单提交、列表展示、状态更新,原生JavaScript配合少量CSS就够用。引入Vue这种框架,反而要处理打包配置、跨域问题,对项目周期来说不划算。
当然,这个选型也做了取舍。SQLite在并发写入上天生弱于MySQL,如果同一秒有上百人同时交作业,可能出现“database is locked”。我针对这个问题做了处理,具体方法在第4章会讲。总之,如果你的项目规模在几十人到几百人之间,这个组合是最稳的起步方案。
1.3 目录结构与模块划分
“作业111”的代码目录起初是乱的,utils、models、static、templates全都堆在一起,改一个功能要来回跳转。后来我按功能做了拆分,最终结构如下:
homework111/ ├── app.py # 应用入口、路由注册 ├── config.py # 配置项:上传目录、密钥、允许的扩展名 ├── models.py # 数据库模型定义 ├── utils.py # 通用工具函数:文件名校验、CSV导出 ├── requirements.txt # 依赖清单 ├── uploads/ # 学生上传的作业文件目录 ├── static/ # CSS、JS、图片等静态资源 ├── templates/ # Jinja2模板 │ ├── base.html │ ├── login.html │ ├── student_dashboard.html │ ├── teacher_dashboard.html │ └── ... └── homework.db # SQLite数据库文件(首次运行后生成)模块划分的原则是“按职责分,不按文件数量分”。app.py只管路由和请求处理,models.py只管数据库表结构的定义,utils.py放那些和HTTP无关的纯函数,比如生成安全的文件名、格式化日期、导出CSV。这样做的好处是,后边要加功能时,我只需要知道“这个功能属于哪个模块”,不会出现到处找代码的情况。
2. 数据模型与核心接口设计
2.1 数据库表结构设计
我用SQLite作为存储层,表结构设计直接决定后期功能好不好加。最初版本只建了一张表,把所有字段塞一起,结果加角色权限的时候改得焦头烂额。第二版我重新设计了四张表:
-- 用户表 CREATE TABLE users ( id INTEGER PRIMARY KEY AUTOINCREMENT, username TEXT UNIQUE NOT NULL, password_hash TEXT NOT NULL, role TEXT NOT NULL CHECK(role IN ('student', 'teacher', 'admin')), real_name TEXT NOT NULL, created_at TEXT DEFAULT (datetime('now', 'localtime')) ); -- 作业批次表 CREATE TABLE homeworks ( id INTEGER PRIMARY KEY AUTOINCREMENT, title TEXT NOT NULL, description TEXT, deadline TEXT NOT NULL, created_by INTEGER NOT NULL, created_at TEXT DEFAULT (datetime('now', 'localtime')), FOREIGN KEY (created_by) REFERENCES users(id) ); -- 提交记录表 CREATE TABLE submissions ( id INTEGER PRIMARY KEY AUTOINCREMENT, homework_id INTEGER NOT NULL, student_id INTEGER NOT NULL, filename TEXT NOT NULL, stored_name TEXT NOT NULL, submit_note TEXT, status TEXT DEFAULT 'submitted' CHECK(status IN ('submitted', 'graded', 'returned')), score REAL, comment TEXT, submitted_at TEXT DEFAULT (datetime('now', 'localtime')), graded_at TEXT, FOREIGN KEY (homework_id) REFERENCES homeworks(id), FOREIGN KEY (student_id) REFERENCES users(id) ); -- 可选:系统操作日志表(后来加的) CREATE TABLE logs ( id INTEGER PRIMARY KEY AUTOINCREMENT, user_id INTEGER NOT NULL, action TEXT NOT NULL, detail TEXT, created_at TEXT DEFAULT (datetime('now', 'localtime')) );为什么submissions表要单独建,而不是把成绩直接挂在homeworks上?因为一个学生可以多次提交同一份作业(老师退回后重新提交),如果每次提交都覆盖同一条记录,就没法追溯历史版本。单独建表后,每次提交都是新记录,状态字段负责标识当前处于哪个环节。成绩字段score直接放在提交记录上,这样同一份作业即使被多次批改,历史记录也保留着。
2.2 REST接口设计与关键约定
前后端交互的接口我尽量遵循REST风格,虽然这是个服务端渲染为主的项目,但接口设计得清晰一点,将来加小程序端或者移动端时可以直接复用。核心接口如下:
| 方法 | 路径 | 功能说明 | 角色限制 |
|---|---|---|---|
| POST | /api/login | 登录并创建会话 | 公开 |
| POST | /api/logout | 注销登录 | 登录用户 |
| GET | /api/homeworks/active | 获取当前可提交的作业列表 | 学生 |
| POST | /api/homeworks/ /submit | 提交作业文件 | 学生 |
| POST | /api/homeworks/ /return | 退回作业,允许重新提交 | 教师 |
| POST | /api/homeworks/ /grade | 提交批改分数和评语 | 教师 |
| GET | /api/homeworks/ /submissions | 获取某作业所有提交记录 | 教师 |
| GET | /api/homeworks/ /export | 导出该作业成绩CSV | 教师 |
| POST | /api/admin/users | 创建新用户账号 | 管理员 |
接口设计的两个关键约定:
- 文件上传用POST multipart/form-data,字段名统一叫file,额外参数用submit_note传递。不要用JSON包base64传文件,那样既浪费内存又没必要。
- 所有需要权限的接口统一校验请求头里的会话Cookie。我在前端没做复杂的token管理,沿用Flask自带的session即可。小项目最忌讳为了“显得高级”引入一套完全用不上的鉴权体系。
2.3 文件存储与命名规则
作业文件的存储方式是很多类似项目忽略的细节。直接拿用户上传的原始文件名存盘一定会出事,因为有人的文件名是“最终版(1).docx”,有人是“新建文档.docx”,还有人是“作业(2)(2).docx”,重名和特殊字符问题能让人崩溃。
我的做法是:服务器端生成的唯一文件名保存,原始文件名单独记到数据库里。生成规则用时间戳 + 随机字符串:
import uuid def generate_stored_name(original_filename: str) -> str: ext = os.path.splitext(original_filename)[1].lower() return f"{uuid.uuid4().hex}{ext}"这样存储文件名形如1f9b0c4d4c2e4a4f8f1e0e3b2d5c6a71.docx,完全避免了重名问题。下载的时候再把数据库里的filename字段取出来,通过Content-Disposition响应头还原成用户可读的文件名。这个方案虽然多了一次数据库查询,但换来了存储层的绝对干净。
3. 核心功能实操与代码实现
3.1 环境准备与项目初始化
“作业111”的环境要求非常低。我在Windows和Linux上都跑过,只需要Python 3.9及以上版本。建议用虚拟环境隔离依赖,避免和系统全局Python包冲突:
python -m venv venv source venv/bin/activate # Windows下是 venv\Scripts\activate pip install flask==3.0.* pip install Werkzeug==3.0.*Flask 3.0版本对Python版本有要求,如果你用的是Python 3.8,建议装Flask 2.3版本。不要盲目追求最新版本,稳定兼容更重要。装完依赖后,在项目根目录创建config.py:
import os class Config: SECRET_KEY = os.environ.get('SECRET_KEY', 'dev-secret-key-change-me') UPLOAD_FOLDER = os.path.join(os.path.dirname(__file__), 'uploads') MAX_CONTENT_LENGTH = 50 * 1024 * 1024 # 单个文件大小上限:50MB ALLOWED_EXTENSIONS = {'zip', 'rar', '7z', 'pdf', 'doc', 'docx', 'py', 'c', 'cpp', 'java', 'txt'} DATABASE = os.path.join(os.path.dirname(__file__), 'homework.db')SECRET_KEY必须改掉默认值。这个小东西看似不起眼,但如果密钥泄露,攻击者可以伪造会话Cookie,等于拿到了任意账号的登录权限。生产运行时最好通过环境变量注入,不要硬编码在代码里。
3.2 登录认证与角色权限
登录功能我用Flask的session实现,密码不存明文,而是用Werkzeug自带的密码哈希函数处理。这里有个很多初学者容易踩的坑:不要自己设计“加盐哈希”,除非你是密码学专家,否则几乎一定出错。直接用现成的加密算法库最靠谱。
from flask import Flask, request, session, redirect, url_for, render_template, flash from werkzeug.security import check_password_hash, generate_password_hash from models import get_user_by_username app = Flask(__name__) app.config.from_object(Config) @app.route('/login', methods=['GET', 'POST']) def login(): if request.method == 'POST': username = request.form.get('username', '').strip() password = request.form.get('password', '') user = get_user_by_username(username) if user and check_password_hash(user['password_hash'], password): session['user_id'] = user['id'] session['username'] = user['username'] session['role'] = user['role'] return redirect(role_home(user['role'])) flash('用户名或密码错误') return render_template('login.html')权限控制用一个装饰器统一处理,不写在每个视图函数里:
from functools import wraps from flask import abort def require_role(*roles): def decorator(fn): @wraps(fn) def wrapper(*args, **kwargs): if 'user_id' not in session: return redirect(url_for('login')) if session.get('role') not in roles: abort(403) return fn(*args, **kwargs) return wrapper return decorator @app.route('/teacher/dashboard') @require_role('teacher', 'admin') def teacher_dashboard(): ...这样写的好处是,以后要加“助教”角色,只需要在装饰器参数里加入对应的角色名,不用动视图函数内部的逻辑。我在第二版重构时就吃过这个亏,当时权限判断分散在五六个视图里,每次加角色都要翻遍全部代码,改多了总会漏一个。
3.3 文件上传与作业提交模块
文件上传是整个系统里最容易出问题的环节。除了常规的检查项目类别、检查大小,还有一个隐蔽的坑:检查文件扩展名的时候,必须用os.path.splitext取到的后缀转成小写再比较,否则有人上传“.ZIP”这种大写扩展名,会被直接拒绝。
我的提交处理函数大致如下:
import os from werkzeug.utils import secure_filename def allowed_file(filename): return '.' in filename and \ filename.rsplit('.', 1)[1].lower() in app.config['ALLOWED_EXTENSIONS'] @app.route('/api/homeworks/<int:homework_id>/submit', methods=['POST']) @require_role('student') def submit_homework(homework_id): upload = request.files.get('file') if not upload or upload.filename == '': flash('请选择要上传的文件') return redirect(request.referrer or url_for('student_dashboard')) if not allowed_file(upload.filename): flash('不支持的文件格式') return redirect(request.referrer or url_for('student_dashboard')) try: stored_name = generate_stored_name(upload.filename) save_path = os.path.join(app.config['UPLOAD_FOLDER'], stored_name) upload.save(save_path) create_submission( homework_id=homework_id, student_id=session['user_id'], filename=upload.filename, stored_name=stored_name, submit_note=request.form.get('submit_note', '') ) flash('提交成功') except Exception as e: # 记录日志,但不要向前端输出内部错误 app.logger.error(f"upload failed: {e}") flash('提交失败,请检查文件后重试') return redirect(url_for('student_dashboard'))需要特别注意的是save_path的拼接。建议用os.path.join而不是手写字符串拼接,因为Windows和Linux的路径分隔符不一样。另外,提交成功后记得重定向,不要直接渲染模板。这符合Post/Redirect/Get模式,能防止用户按F5刷新时重复提交表单。
3.4 成绩批量导入与CSV导出
CSV导出功能是教师最常用的功能之一,也是看起来简单、实际坑很多的模块。直接用Python内置的csv模块写一个文件下载响应即可:
import csv import io @app.route('/api/homeworks/<int:homework_id>/export') @require_role('teacher', 'admin') def export_grades(homework_id): homework = get_homework(homework_id) submissions = get_submissions(homework_id) output = io.StringIO() writer = csv.writer(output) writer.writerow(['学号', '姓名', '文件名', '状态', '分数', '评语', '提交时间', '批改时间']) for sub in submissions: writer.writerow([ sub['student_no'], sub['real_name'], sub['filename'], sub['status'], sub['score'] if sub['score'] is not None else '', sub['comment'] or '', sub['submitted_at'], sub['graded_at'] or '' ]) output.seek(0) from flask import Response headers = { 'Content-Disposition': f'attachment; filename="grades_{homework_id}.csv"' } return Response(output.getvalue(), mimetype='text/csv; charset=utf-8', headers=headers)三个容易踩的坑:
- 中文乱码。Excel默认用GBK编码打开CSV文件,UTF-8的CSV在Excel里会显示乱码。最简单的办法是导出时加BOM头:
output.write('\ufeff'),这个字符在Excel里不可见,但能强制让Excel用UTF-8解码。 - 路径注入。文件名如果直接用作业标题拼接,标题里的特殊字符可能会被浏览器解析成奇怪的东西。稳妥的做法是固定文件名格式,只保留作业ID:
grades_{homework_id}.csv。 - 空值处理。score和graded_at可能为None,直接写入CSV会显示Python的None字符串。要用条件表达式转换成空字符串。
4. 常见问题与排查技巧
4.1 问题速查表
项目跑起来之后,我陆陆续续收到了各种使用反馈。下面这些问题,每一个都是真实发生过的,我整理成了一份速查表:
| 症状 | 可能原因 | 解决办法 |
|---|---|---|
| 上传大文件时报413错误 | 超出Flask的MAX_CONTENT_LENGTH | 调整config.py中的限制,或分块上传 |
| 同一个学生重复提交,成绩被覆盖 | 没按submissions表建模,或用upsert覆盖了记录 | 每次提交都insert新记录,用最新记录作为当前版本 |
| 教师导出CSV后用Excel打开是乱码 | CSV没有带UTF-8 BOM | 在csv内容前加\ufeff |
| 学生登录后看不到作业列表 | 作业批次未关联到该学生所在的班级 | 检查homeworks表是否需要加class_id字段 |
| 服务器重启后上传的文件消失了 | uploads目录放在临时目录中 | 用绝对路径配置UPLOAD_FOLDER,不要相对路径 |
| 手机端上传文件无效 | 部分浏览器对multipart表单兼容性差 | 后端打印request.files逐个调试,确认字段名一致 |
| SQLite提示database is locked | 多个请求同时写入,触发了SQLite的并发锁 | 开启WAL模式并设置busy_timeout |
4.2 上传中文文件名乱码排查
第一次测试时,我用了“课程设计_张三_final.docx”这个文件名,结果在服务器端打印出来全是乱码。查了一圈发现是浏览器默认编码的问题。
解决方法是两步走:第一,在接收文件后,强制用UTF-8重新编码:upload.filename.encode('latin-1', 'ignore').decode('utf-8', 'ignore')。但这招在有些浏览器上不生效。第二,更稳妥的做法是前端在提交前用FormData包装文件时,手动指定文件名:formData.append('file', file, encodeURIComponent(file.name))。后端再用urllib.parse.unquote还原。我在项目里最终采取了第二种方案,实测兼容性最好。
如果你不想在前端做这么多处理,也有一个偷懒的办法:后端完全忽略上传的原始文件名,下载时统一改用“学号-姓名-作业名”拼接。缺点是丢失了原始信息,但对大多数课程作业场景来说够用。
4.3 SQLite并发写入锁的坑
系统真正面向全班学生使用时,SQLite的并发问题才暴露出来。多个学生同时提交作业,偶尔会出现database is locked错误。查了官方文档才知道,SQLite在默认的journal mode下,同一时刻只允许一个写事务。
我的解决方法是开启WAL模式,并设置忙碌超时:
def init_db(): conn = sqlite3.connect(Config.DATABASE) conn.execute('PRAGMA journal_mode=WAL;') conn.execute('PRAGMA busy_timeout=5000;') conn.close()WAL模式(Write-Ahead Logging)允许多个读操作和一个写操作并发执行,写操作不阻塞读操作,这对“一边有人交作业,一边有人查成绩”的场景非常合适。busy_timeout让并发的写请求等待而不是直接报错。这两条PRAGMA语句能在不换数据库的情况下解决九成以上的并发问题。
不过也要诚实地说,如果项目规模真的到了几百人同时交作业的程度,SQLite还是不够用的。那时应该把存储层换掉,Flask的SQLAlchemy封装层可以平滑切换,但文件存储、缓存策略都得跟着改。
4.4 部署细节:从本地跑通到局域网可用
课程项目的交付现场一般不会给你配好公网服务器,很多时候是在实验室的局域网里演示。最便捷的启动方式是:
flask --app app.py run --host=0.0.0.0 --port=8000这里的--host=0.0.0.0很关键。如果不加,Flask默认只监听127.0.0.1,别人在局域网里访问不到你的服务。但这只是开发服务器的跑法,只适合临时演示。
如果你需要一台真正稳定的服务进程,我推荐用gunicorn(Linux/macOS)或者waitress(Windows,pip install waitress):
waitress-serve --listen=0.0.0.0:8000 app:app不需要配Nginx,对一个小系统来说,直接用waitress做生产服务器就够了。Nginx反向代理、HTTPS证书这些,属于“以后可能用得上,但现在先不要加”的东西。我见过太多项目因为过度配置,最后连启动都失败了。
5. 做完这个项目后的几点体会
“作业111”这个项目,从名字看像是个临时交差的作业,但做到最后,我反而觉得它是阶段内收获最大的一件事。它逼着我走完了一个Web系统从设计到上线的完整流程:需求分析、数据库设计、接口定义、前端页面、权限控制、异常排查、打包演示。
有几条经验我想单独拎出来说。
第一,数据库表设计直接决定项目后期的心智负担。第一版我把所有字段塞在一张表里,学生提交记录、作业信息、成绩信息混在一起,写查询的时候各种join自己跟自己。第二版拆表之后,整个代码逻辑瞬间清晰了,后来加功能也没再返工。设计表之前,先试着用一句话描述“系统里最关键的业务事件是什么”,从这个事件出发设计核心表,比从界面反推要靠谱得多。
第二,不要把“敏感操作”的异常处理做成抛出堆栈。用户上传文件失败、数据库写入失败,这些场景前端应该看到友好提示,而不是一长串Python traceback。真正的错误细节记到日志文件里就行,用户界面上永远只显示“操作失败,请稍后重试”。这不是为了隐藏问题,而是为了让错误信息不暴露系统内部的路径和结构。
第三,项目的可用性比“技术先进”重要得多。有人建议我给系统加个Redis做缓存,有人建议用消息队列处理作业提交,还有人建议把前端完全React化。我都谢绝了。对这样一个场景和用户量,最稳妥的架构就是最简单、最容易排查的架构,先进技术应该是解决特定问题的手段,而不应该是刻意炫技的工具。系统能用、稳定、好改,这就是最大的优点。
最后再分享一个小技巧:做这种管理类系统时,多花十分钟在“导出功能”上是值得的。因为用户不一定会夸你的界面漂亮,但一定能立刻感受到“导出Excel比手动整理方便太多了”。把导出做稳、做兼容,是低成本换取高好评的典型路径。如果你也开始动手写一个类似的项目,希望这篇复盘能帮你少走那些我已经替你踩过的坑。