
一个儿童培训机构想搭建自己的在线学习平台功能上绕不开这几件事课程展示、作业布置、在线考试、学习记录。用 Python 的 Flask 框架来做这套系统我觉得是目前最稳妥、也是开发效率最高的一条路。这篇文章就围绕“基于 Flask 的在线教学课程网站小儿培训系统”展开把课程管理、作业考试、学习记录这几块从设计到代码一步步拆开讲清楚适合正在学 Flask 的开发者、做毕业设计的同学以及教育机构里需要快速落地内部系统的技术人员参考。先说个背景。我之前帮朋友的一个小机构做内部教学平台规模不大学员以低龄儿童为主课程主要是编程启蒙和数学思维。机构老师的诉求很简单能发布课程视频和图文讲义、能布置作业和在线批改、能组织随堂考试并自动判分、能记录每个孩子的学习进度。当时第一反应是直接用现成的 SaaS 教学系统但要么贵要么功能冗余要么数据不在自己手里。最后决定自己用 Flask 撸一个轻量级的教学后台前后端加起来差不多四千行代码跑了两三个月稳定性和易用性都够用。这套东西放到今天来看依然很适合作为 Flask 综合实战项目来复现。1. 系统整体设计与思路拆解1.1 为什么用 Flask 而不是重型全栈框架做这类内部教学系统最大的误区是一上来就上重型框架。Django 当然好自带的 Admin、ORM、迁移工具都很成熟但对于一个可能要二次开发、要上课时临时改需求的小系统来说反而是 Flask 这种微框架更顺手。Flask 的优势体现在三个地方。第一路由和视图函数非常直观一个课程列表页就是一个装饰器加一个函数改起来没有心智负担。第二扩展机制自由你不需要在项目启动时就把所有能力都绑死用到什么装什么比如 Flask-SQLAlchemy 处理数据库、Flask-WTF 处理表单、Flask-Login 管登录会话都是按需引入。第三模板系统 Jinja2 和 Flask 深度绑定服务端渲染页面时可以直接在 HTML 里写循环、判断、继承对课程展示这类内容型页面来说特别合适。另外从部署角度看Flask 应用就是标准的 WSGI 应用本地开发跑python app.py生产环境换 gunicorn 加 nginx 反向代理迁移成本非常低。如果你只是给机构内部用甚至直接跑在内网一台小主机上都行。1.2 角色模型与权限设计学生、教师、管理员的三角关系教学系统绕不开权限模型。我的做法是设计三种角色学生、教师、管理员。三种角色共用一张用户表通过role字段区分。学生能看课程、看章节内容、提交作业、参加考试、查看自己的学习记录和成绩。教师能创建课程、维护章节内容、布置作业、批改作业、录入试题、查看学员学习进度。管理员拥有教师全部权限还能管理用户账号、重置密码、查看系统全局数据。实现上我用一个自定义装饰器控制权限核心代码大概是这样的from functools import wraps from flask import session, abort, redirect, url_for def login_required(roleNone): def decorator(f): wraps(f) def wrapped(*args, **kwargs): if user_id not in session: return redirect(url_for(auth.login)) if role and session.get(role) ! role: abort(403) return f(*args, **kwargs) return wrapped return decorator用法很简单给视图函数加装饰器就行app.route(/teacher/homework/new, methods[GET, POST]) login_required(roleteacher) def create_homework(): ...注意一点session里存的role在用户登录时写入但如果管理员改了用户的角色用户得重新登录才能生效。这个问题不大但你在设计后台时最好有个“强制下线”或“刷新权限”的逻辑不然容易出现权限变更不生效的困惑。1.3 功能模块与数据库表结构规划我一开始就把表结构分成四大块后面所有功能都是在这四块上长出来的。用户与认证相关users表加上登录会话逻辑。课程内容相关courses、chapters两张表。作业与考试相关homeworks、homework_submissions、exams、exam_questions、exam_results。学习行为相关learning_records表。设计表结构时有一条经验作业和考试建议分开建表因为它们的判分逻辑完全不同。作业更多是主观题需要教师人工批改考试以客观题为主可以自动判分。如果你把两者混成一张“任务表”后面加逻辑时很容易把自己绕晕。from flask_sqlalchemy import SQLAlchemy from datetime import datetime db SQLAlchemy() class User(db.Model): __tablename__ users id db.Column(db.Integer, primary_keyTrue) username db.Column(db.String(64), uniqueTrue, nullableFalse) password_hash db.Column(db.String(255), nullableFalse) role db.Column(db.String(16), defaultstudent) real_name db.Column(db.String(32)) created_at db.Column(db.DateTime, defaultdatetime.now) class Course(db.Model): __tablename__ courses id db.Column(db.Integer, primary_keyTrue) title db.Column(db.String(128), nullableFalse) description db.Column(db.Text) cover_url db.Column(db.String(255)) teacher_id db.Column(db.Integer, db.ForeignKey(users.id)) created_at db.Column(db.DateTime, defaultdatetime.now) class Chapter(db.Model): __tablename__ chapters id db.Column(db.Integer, primary_keyTrue) course_id db.Column(db.Integer, db.ForeignKey(courses.id)) title db.Column(db.String(128), nullableFalse) content db.Column(db.Text) video_url db.Column(db.String(255)) sort_order db.Column(db.Integer, default0) class Homework(db.Model): __tablename__ homeworks id db.Column(db.Integer, primary_keyTrue) course_id db.Column(db.Integer, db.ForeignKey(courses.id)) chapter_id db.Column(db.Integer, db.ForeignKey(chapters.id)) title db.Column(db.String(128), nullableFalse) content db.Column(db.Text) deadline db.Column(db.DateTime) teacher_id db.Column(db.Integer, db.ForeignKey(users.id)) created_at db.Column(db.DateTime, defaultdatetime.now) class HomeworkSubmission(db.Model): __tablename__ homework_submissions id db.Column(db.Integer, primary_keyTrue) homework_id db.Column(db.Integer, db.ForeignKey(homeworks.id)) student_id db.Column(db.Integer, db.ForeignKey(users.id)) content db.Column(db.Text) file_url db.Column(db.String(255)) score db.Column(db.Integer) feedback db.Column(db.Text) submitted_at db.Column(db.DateTime, defaultdatetime.now) class Exam(db.Model): __tablename__ exams id db.Column(db.Integer, primary_keyTrue) course_id db.Column(db.Integer, db.ForeignKey(courses.id)) title db.Column(db.String(128), nullableFalse) duration_minutes db.Column(db.Integer, default30) created_at db.Column(db.DateTime, defaultdatetime.now) class ExamQuestion(db.Model): __tablename__ exam_questions id db.Column(db.Integer, primary_keyTrue) exam_id db.Column(db.Integer, db.ForeignKey(exams.id)) question_type db.Column(db.String(16)) content db.Column(db.Text) options db.Column(db.Text) correct_answer db.Column(db.String(255)) score db.Column(db.Integer, default5) class ExamResult(db.Model): __tablename__ exam_results id db.Column(db.Integer, primary_keyTrue) exam_id db.Column(db.Integer, db.ForeignKey(exams.id)) student_id db.Column(db.Integer, db.ForeignKey(users.id)) score db.Column(db.Integer) answers db.Column(db.Text) submit_time db.Column(db.DateTime, defaultdatetime.now) class LearningRecord(db.Model): __tablename__ learning_records id db.Column(db.Integer, primary_keyTrue) student_id db.Column(db.Integer, db.ForeignKey(users.id)) chapter_id db.Column(db.Integer, db.ForeignKey(chapters.id)) progress db.Column(db.Integer, default0) updated_at db.Column(db.DateTime, defaultdatetime.now)这里我刻意把所有外键都定义成普通整数列而不是直接用 SQLAlchemy 的relationship。原因很简单教学系统的数据关系虽然清晰但页面展示时经常要跨多张表查数据直接查外键再拼接字典比操作 ORM 关系对象更直观也更好调试。relationship会让模板里的对象嵌套很深反而增加理解成本。2. 核心功能模块的落地实现2.1 用户注册登录与会话管理别把密码存明文注册登录这个模块看起来简单其实是整个系统安全性的基石。我见过很多学生项目直接把密码明文存进数据库这个习惯一定要改。用 Flask 的话最省事的方式是借助 Werkzeug 自带的密码哈希函数from werkzeug.security import generate_password_hash, check_password_hash # 注册时 user User( usernamerequest.form[username], password_hashgenerate_password_hash(request.form[password]), rolerequest.form.get(role, student) ) db.session.add(user) db.session.commit() # 登录时 user User.query.filter_by(usernameusername).first() if user and check_password_hash(user.password_hash, password): session[user_id] user.id session[role] user.role session[real_name] user.real_name return redirect(url_for(index))密码哈希的作用是即使数据库被拖走攻击者也拿不到原始密码只能拿到一串不可逆的哈希值。这里有个细节要注意generate_password_hash默认使用pbkdf2:sha256算法对小型项目足够安全但如果你追求更高强度可以指定methodscrypt。会话管理方面Flask 的session是签名加密的 cookie默认情况下可以防止篡改但你要做两件事一是设置一个足够复杂的SECRET_KEY二是生产环境记得开启 HTTPS。儿童培训系统涉及家长手机号、孩子学习记录等隐私数据传输层必须加密。另外给所有页面统一做登录检查时我建议用before_request钩子而不是在每个视图函数里手写判断app.before_request def check_login(): allowed_routes [auth.login, auth.register, static] if request.endpoint in allowed_routes: return if user_id not in session: return redirect(url_for(auth.login))这样新增页面时不用重复写权限判断也减少漏掉检查的情况。2.2 课程与章节管理从数据模型到服务端渲染课程管理模块是内容型系统的核心逻辑本身不难但有几个体验细节容易被忽略。课程列表页我按卡片式布局展示封面图、课程标题、课程简介、章节数和学习人数。封面图我直接放在/static/covers/目录下不走数据库存二进制。cover_url字段只存文件名模板里用url_for(static, filenamecovers/ course.cover_url)生成完整 URL。章节内容我用富文本编辑器录入但存储时只允许白名单标签防止 XSS 攻击。这里有个比较隐蔽的坑低龄儿童培训的课程内容经常是图文混排教师后台如果直接粘贴 Word 内容编辑器会产生大量垃圾样式标签。我在保存章节时用bleach库过滤了一遍script、iframe、事件属性还顺便清理了style里的危险属性。这个操作必须做因为教师端账号如果被社会工程学攻击恶意脚本会直接作用在学生端浏览器上。章节排序花了我一点时间。最简单的方案是给Chapter加一个sort_order字段新增章节时取当前课程最大排序值加一。但老师经常要调整顺序所以后来我在教师后台加了“上移”“下移”两个按钮对应的视图函数就是交换两条记录的sort_orderapp.route(/teacher/chapter/int:chapter_id/move/direction, methods[POST]) login_required(roleteacher) def move_chapter(chapter_id, direction): chapter Chapter.query.get_or_404(chapter_id) if direction up: sibling Chapter.query.filter( Chapter.course_id chapter.course_id, Chapter.sort_order chapter.sort_order ).order_by(Chapter.sort_order.desc()).first() else: sibling Chapter.query.filter( Chapter.course_id chapter.course_id, Chapter.sort_order chapter.sort_order ).order_by(Chapter.sort_order.asc()).first() if sibling: chapter.sort_order, sibling.sort_order sibling.sort_order, chapter.sort_order db.session.commit() return redirect(url_for(course_detail, course_idchapter.course_id))2.3 作业附件上传文件类型白名单必须写死作业模块涉及文件上传这是我踩坑最多的地方。学生提交的作业可能是图片、PDF、小型压缩包也可能有调皮孩子上传脚本文件。出于安全考虑我做了三层约束。第一层是扩展名白名单只允许.jpg、.png、.pdf、.zip这几种。第二层是使用secure_filename重命名文件防止路径穿越和 Unicode 文件名造成的问题。第三层是最重要的一个细节通过读取文件头判断真实文件类型不能只信扩展名。def allowed_file(filename): return . in filename and filename.rsplit(., 1)[1].lower() in {jpg, png, pdf, zip} app.route(/student/homework/int:homework_id/submit, methods[POST]) login_required(rolestudent) def submit_homework(homework_id): homework Homework.query.get_or_404(homework_id) file request.files.get(file) if file and allowed_file(file.filename): filename secure_filename(file.filename) # 加上时间戳避免同名覆盖 save_name f{datetime.now().strftime(%Y%m%d%H%M%S)}_{filename} file.save(os.path.join(app.config[UPLOAD_FOLDER], save_name)) sub HomeworkSubmission( homework_idhomework_id, student_idsession[user_id], contentrequest.form.get(content, ), file_urlsave_name ) db.session.add(sub) db.session.commit() flash(作业已提交) return redirect(url_for(homework_detail, homework_idhomework_id))同时我设置了app.config[MAX_CONTENT_LENGTH] 32 * 1024 * 1024超过 32MB 直接返回 413 错误。很多做毕设的同学会漏掉这个限制大文件上传会直接把请求缓冲区打爆。3. 作业、考试与学习记录系统的核心价值3.1 在线考试模块先解决“谁在考”和“什么时间考”在线考试比作业复杂得多核心难点有三个题目管理、考试计时、自动判分。题目管理我用的是“考试包含多个题目”的一对多关系。ExamQuestion表支持单选、多选、判断三种题型。options字段存 JSON 字符串前端用 JSON.parse 解析成数组渲染。判断题就简化为两个选项“正确”和“错误”。自动判分逻辑非常简单逐题比对答案单选判断完全一致得分多选必须选项集合完全一致才给分。考试计时这套逻辑我的方案是“前端显示倒计时后端服务端判超时”。前端 JS 倒计时到期后自动提交表单但如果学生关了页面再打开或者直接改了前端代码倒计时就不可靠了。所以我在后端记录考生开始考试的时间提交时用服务器当前时间减去开考时间超过duration_minutes就强制判为超时只计算超时前已作答的部分。app.route(/exam/int:exam_id/start, methods[POST]) login_required(rolestudent) def start_exam(exam_id): exam Exam.query.get_or_404(exam_id) start_time datetime.now() session[fexam_start_{exam_id}] start_time.isoformat() return render_template(exam_paper.html, examexam, questionsexam.questions) app.route(/exam/int:exam_id/submit, methods[POST]) login_required(rolestudent) def submit_exam(exam_id): exam Exam.query.get_or_404(exam_id) start_time datetime.fromisoformat(session.get(fexam_start_{exam_id}, datetime.now().isoformat())) elapsed (datetime.now() - start_time).total_seconds() / 60 if elapsed exam.duration_minutes: flash(考试时间已到系统自动交卷) answers {} for q in exam.questions: answers[str(q.id)] request.form.get(fquestion_{q.id}, ) # 判分逻辑 total_score 0 for q in exam.questions: if q.correct_answer answers[str(q.id)]: total_score q.score result ExamResult(exam_idexam.id, student_idsession[user_id], scoretotal_score, answersjson.dumps(answers, ensure_asciiFalse)) db.session.add(result) db.session.commit() session.pop(fexam_start_{exam_id}, None) return redirect(url_for(exam_result, result_idresult.id))这里有两个特别容易踩的坑。第一考试时间判断必须用服务器时间不能用前端传来的时间戳因为客户端时间不可信。第二选项值的存储必须统一。我在题目录入时把选项编号定为 A、B、C、D判定时直接比对字符串但多选题目一定要先把选项排序再拼接比较否则选择顺序不同会误判。比如学生选的是“A,C”代码里存成“C,A”字符串对比就是错的。3.2 作业批改与反馈客观记录加人工点评作业模块我设计了提交和批改两条线。学生端看到作业列表和提交状态教师端看到待批改列表。批改页面显示学生提交的文字内容和附件预览链接教师录入分数和评语。有个体验细节值得提一下作业截止时间。我在Homework表里有deadline字段学生提交时判断一下当前时间是否超过截止时间。超时后仍允许补交但提交表单上会标注“已逾期”。这种设计比直接禁止提交更人性化因为低龄学员经常会因为家长忘了拍作业照片而错过截止时间。批改完成后的数据流向也比较简单把score和feedback更新到homework_submissions记录上学生在自己的作业列表页能看到已批改状态和分数。这里我不建议自动汇总平均分给所有学生看低龄儿童的激励机制还是以个体反馈为主公开排名容易造成负面情绪。3.3 学习记录模块埋点和进度回执的实践学习记录是这个系统比较有特色的模块。最初甲方要求的是“能看到每个孩子学到哪了、花了多长时间”这不是简单上个在线视频就能解决的因为视频播放平台的数据不归你管。我的实现方案是把学习记录拆成两层。第一层是“进入章节即记录”。学生打开某个章节详情页时前端立即发送一条记录给后端写入learning_records表记录学生、章节、当前进度。第二层是“定时心跳”。如果章节内容里嵌入了视频我在播放器上挂了timeupdate事件每 15 秒向后端上报一次观看位置和观看时长。这里要注意低龄儿童的浏览行为非常跳跃经常看着看着就点去别的页面。你如果只在页面关闭时上报一次进度数据很容易丢失。用navigator.sendBeacon最合适它在页面卸载时也能可靠地发出请求且不阻塞页面跳转。window.addEventListener(beforeunload, function() { var data new Blob([JSON.stringify({ chapter_id: chapterId, progress: currentProgress, duration: accumulatedTime })], {type: application/json}); navigator.sendBeacon(/api/learning_progress, data); });后端接收这个请求的逻辑也不复杂upsert操作有记录就更新 progress 和 duration没有就插入新记录app.route(/api/learning_progress, methods[POST]) login_required(rolestudent) def learning_progress(): data request.get_json() record LearningRecord.query.filter_by( student_idsession[user_id], chapter_iddata.get(chapter_id) ).first() if record: record.progress data.get(progress, record.progress) record.updated_at datetime.now() else: record LearningRecord( student_idsession[user_id], chapter_iddata.get(chapter_id), progressdata.get(progress, 0) ) db.session.add(record) db.session.commit() return {status: ok}教师端的学习进度汇总页面我直接对learning_records表做聚合查询统计每个学生的已完成章节比例。这里有个细节章节总数要动态计算学生学了一门课的全部章节才算 100%而不是把记录表里的记录数当分母否则新增章节后旧学生的学习进度会不正确。4. 前后端联调、部署与常见问题排查4.1 路由组织与模板继承项目代码量上来之后把所有路由都堆在app.py里会非常痛苦。我按照功能拆成了蓝图auth.py管登录注册、course.py管课程章节、homework.py管作业、exam.py管考试、teacher.py管教师后台。每个蓝图有自己的url_prefix比如作业相关路由统一挂在/homework下考试统一挂在/exam下。模板这块我用了三层继承。基础模板base.html定义整体布局、导航栏、侧边栏和消息提示区后台模板admin_base.html继承基础模板再加入数据表格、卡片等公共样式具体页面再继承后台模板。这样做的好处是改导航栏或者加统计卡片样式时只动一处。模板渲染时经常要判断当前用户身份来显示不同入口我在app.py里注册了一个全局函数app.context_processor def inject_user(): return dict(current_usersession.get(real_name), current_rolesession.get(role))这样所有模板都能直接用current_role teacher来判断不用每个视图函数都往模板传一遍用户信息。4.2 部署方案gunicorn 和 nginx 的搭配本地开发时python app.py足够但真正要让机构里面的人稳定使用必须上 gunicorn 加 nginx。部署步骤我在本地 Ubuntu 主机上完整跑通过第一步安装依赖。如果环境还没装好先把 Python 装上再用 pip 安装 Flask、Flask-SQLAlchemy、gunicorn 等依赖最好用虚拟环境隔离。python3 -m venv venv source venv/bin/activate pip install flask flask-sqlalchemy gunicorn第二步用 gunicorn 启动应用。生产环境不要用 app.run 的调试服务器它会串请求并发一高就卡死。正确的启动方式是gunicorn -w 4 -b 127.0.0.1:8000 app:app-w 4表示开 4 个 worker 进程。对于这种教学系统4 个 worker 足够支撑几十个学生同时在线。第三步nginx 做反向代理和静态文件服务。静态文件和上传目录直接交给 nginxFlask 只处理动态请求。如果不做这步上传的作业附件会全部经过 Flask速度慢不说还白白占用 worker 资源。server { listen 80; server_name your-domain.com; client_max_body_size 32m; location /static/ { alias /opt/myedu/static/; } location /uploads/ { alias /opt/myedu/uploads/; } location / { proxy_pass http://127.0.0.1:8000; proxy_set_header Host $host; proxy_set_header X-Real-IP $remote_addr; } }这里有一个容易被初学者忽略的点proxy_set_header X-Real-IP $remote_addr一定要配否则 Flask 里获取所有用户 IP 都是 127.0.0.1后面想做访问频率限制、异常登录提醒时会发现根本定位不到人。4.3 常见问题速查表我把在这套系统开发和运行过程中实际遇到过的问题整理成了表格给正要动手的同学一份参考。问题现象可能原因解决方案中文内容显示乱码数据库连接字符串未指定字符集SQLite 建立时用charsetutf8mb4MySQL 连接串加?charsetutf8mb4提交作业时提示413 Request Entity Too LargeMAX_CONTENT_LENGTH未设置或过小设置在合理范围nginx 端也要同步调client_max_body_size数据库报database is lockedSQLite 在高并发写入时锁冲突生产环境切 MySQL或者给 SQLite 开 WAL 模式考试倒计时作弊刷新前端倒计时被修改后端按服务器时间强制判超时上传的脚本文件被执行未做文件类型白名单白名单校验加读取文件头判断真实类型学生的中断学习进度丢失页面关闭时未上报使用navigator.sendBeacon在beforeunload中上报部署后静态资源 404nginx 的 alias 路径错误检查静态目录权限和路径末尾斜杠作业附件文件重名覆盖直接使用原始文件名保存在文件名前加时间戳或 UUID课程修改后学生看到的还是旧内容浏览器缓存静态资源加版本号如style.css?v20230101这个表格里的前五条是我认为最关键的。尤其是考试倒计时千万别只依赖前端哪怕老师跟你保证“我们的学生不会作弊”也一定要在后端兜底。儿童培训系统虽然面向的是低龄用户但家长可能替孩子操作逻辑上默认所有人都会想尽办法拿到高分这既不涉及信任问题也和未成年人无关纯粹是系统设计的防御底线。5. 安全加固与性能优化的几个细节5.1 表单校验和 XSS 防护儿童培训系统的内容是给学生看的在内容安全上要比普通企业站更严格。教师端虽然可信但如果账号被盗恶意脚本就能污染所有学生的浏览器环境。我的做法是所有富文本内容在保存前用 bleach 白名单过滤掉危险标签和属性展示时再开启 Jinja2 的默认转义。Jinja2 默认用{{ }}输出时会转义 HTML 特殊字符这能挡住大部分 XSS。但注意|safe过滤器会关闭转义我全项目里只在教师录入的富文本区域使用而且是过滤后的内容才允许用|safe。表单校验方面我除了用 Flask-WTF 的validate_on_submit还加了统一的输入清洗函数去掉首尾空格、过滤空字符串、限制最大长度。不要相信任何前端校验浏览器里随便改一下 HTML 就能绕过后端校验才是真正的门槛。5.2 查询性能优化按需加载和索引教学系统数据量不大但有些页面会因为查询写法不当而卡顿。最常见的问题是列表页 N1 查询展示 20 个课程时又去查每个课程的章节数量、教师姓名、最近更新时间总共会执行几十条 SQL。我的优化方案分两步。第一步列表页能合并的查询都合并用join或直接查询出所有需要的字段拼到字典里。第二步给经常用于条件查询的字段建立数据库索引比如Homework.submission的homework_id、LearningRecord的student_id和chapter_id。对 SQLite 来说db.session.execute(text(CREATE INDEX IF NOT EXISTS ...))就能建成本极低。5.3 文件管理和定期清理上传目录会随着使用越来越大。我写了一个简单的管理命令定期把超过 60 天未被访问的临时附件清理掉。注意清理时要确认对应作业已经批改或已过截止日期避免误删还在批改流程中的数据。另外我推荐把作业附件按uploads/2025/03/这样的年月目录存放而不是全部堆在一个目录下面。文件系统在单目录文件过多时性能会下降而且在备份和排查时按时间分目录也方便得多。6. 这个系统还能怎么扩展当前这套 Flask 教学系统已经能覆盖“课程展示、作业、考试、学习记录”四个核心环节但我实操下来有几个方向是实际运营中很快会提出的需求。第一个是消息通知。孩子提交作业后教师需要知道有新作业要批改作业批改完成家长需要收到通知。在 Flask 里实现一个轻量的通知中心并不难多建一张notifications表用户登录后在导航栏右上角显示未读数量就行。再进一步可以接企业微信机器人或邮件通知但要注意低频和隐私边界。第二个是家长端。儿童培训的买单人是家长所以一个家长绑定多个孩子的模式很常见。实现思路是在users表加一个parent_id字段家长登录后可以在子账号之间切换查看每个孩子的学习记录和成绩。这个功能做好之后机构的续费率和家长满意度都会明显提升。第三个是课程数据导出和报表。校长和老师最关心的是“多少孩子学完了课程”“考试平均分多少”。用 Flask 做一个简单的数据汇总页再提供 CSV 导出按钮实用性立刻上一个台阶。在我在实际开发中的体会是用 Flask 做这类系统最大的优势不是“快”而是“可控”。每一段逻辑都在自己手里遇到特殊需求可以随时改不会被迫迁就平台的功能边界。当然代价就是安全、部署、性能这些问题都要自己扛一遍。但这恰恰是锻炼全栈能力最有效的路径。你按这篇文章的思路把系统搭出来之后可以顺手再给它加上以上说的通知、家长端和导出功能一套完整的在线教育平台就真正立起来了。