
1. 项目背景与选型思路1.1 少儿培训网站到底需要哪些核心功能前阵子接了个少儿培训机构的项目需求很直接给6到12岁的孩子做一个能在线看课、交作业、参加小测验、同时记录学习进度的网站。对象是孩子但掏钱的、看数据的都是家长和老师所以整个系统不能只做“能看课程”这么简单作业、考试、学习记录三个模块是硬指标。技术栈我定位得很干脆Python Flask SQLite前端用Bootstrap加一点原生JS不做前后端分离老老实实用服务端渲染。这个项目标题里提到的“在线教学课程网站小儿培训系统”拆开看其实就是三件事内容展示、教学互动、数据记录。内容展示是课程列表和章节详情教学互动是作业的发布与提交、考试的在线作答数据记录则是把每一个学生的学习行为沉淀下来让老师和家长能看得见。很多人做类似项目时容易一头扎进“视频播放”和“聊天互动”里但实际对接过培训机构你会发现他们最关心的反而是作业有没有按时交、考试考了多少分、孩子今天学了多久。这也是我后来把学习记录模块单独拎出来做的原因。1.2 技术选型为什么是Flask而不是Django选Flask不是因为它比Django强而是因为这个场景下它更合适。培训机构的在线平台通常用户量不大几百个学生同时在线已经算高峰业务逻辑也相对固定不需要Django自带的Admin后台、ORM、表单、认证那一整套“全家桶”。Flask的核心优势是轻和自由路由自己定义数据库自己选扩展自己装项目结构完全由自己掌控。对一个人开发的小团队来说这种自由度意味着更少的框架约束、更快的开发速度。打个比方Django像一套精装修的公寓拎包入住但改格局很费劲Flask像毛坯房电路水路自己排但装出来的样子完全是你自己的。这个项目里我需要自定义权限角色、自定义考试抽题逻辑、自定义学习记录的数据口径如果用Django还得去迁就它的App划分习惯反而绕远路。另外这个项目用SQLite起步完全够用Flask SQLAlchemy可以平滑切换到MySQL后面用户量上来了再迁移也不迟。2. 数据库设计与项目初始化2.1 核心数据表的设计思路在线教学系统的表结构比普通博客复杂但也没有复杂到需要微服务拆分。我最终定了九张表分别是用户表、课程表、章节表、作业表、作业提交表、题目表、考试表、考试记录表、学习记录表。表名说明主要字段User用户表id, username, password_hash, role, real_name, created_atCourse课程表id, title, description, cover_path, teacher_id, statusLesson章节表id, course_id, title, video_url, content_text, duration, sort_orderHomework作业表id, course_id, title, description, deadline, created_byHomeworkSubmission作业提交表id, homework_id, student_id, submit_time, file_path, score, commentQuestion题目表id, course_id, type, content, options, answer, scoreExam考试表id, title, course_id, duration, question_ids, start_time, end_timeExamRecord考试记录表id, exam_id, student_id, start_time, submit_time, total_score, answers_jsonStudyRecord学习记录表id, student_id, lesson_id, progress, watch_seconds, last_time, created_at这里的几个设计细节值得多说一句。User表里的role字段我直接存字符串取值是student、teacher、admin三种不搞复杂的权限表因为这个系统的角色维度很清晰字符串判断在装饰器里写起来也最直接。Exam表的question_ids我用了逗号分隔的字符串存题目ID列表而不是单独建一张中间表原因是一个考试最多几十道题用中间表反而增加查询复杂度。ExamRecord表里的answers_json用来存学生提交的完整答题记录判分后连同得分一起序列化进去这样老师想看某道题怎么错的直接解析JSON就行。用SQLAlchemy定义的时候模型之间的关系要提前规划好。比如Course和Lesson是一对多Homework和HomeworkSubmission是一对多Exam和ExamRecord是一对多。外键关系建立好后查询就能直接用course.lessons、homework.submissions这种ORM语法避免手写大量连表查询。下面是一段核心模型示例from flask_sqlalchemy import SQLAlchemy db SQLAlchemy() class User(db.Model): __tablename__ user id db.Column(db.Integer, primary_keyTrue) username db.Column(db.String(50), uniqueTrue, nullableFalse) password_hash db.Column(db.String(128), nullableFalse) role db.Column(db.String(10), nullableFalse, defaultstudent) real_name db.Column(db.String(50), nullableFalse) property def password(self): raise AttributeError(密码字段不可读) password.setter def password(self, value): from werkzeug.security import generate_password_hash self.password_hash generate_password_hash(value) def check_password(self, value): from werkzeug.security import check_password_hash return check_password_hash(self.password_hash, value) class Course(db.Model): __tablename__ course id db.Column(db.Integer, primary_keyTrue) title db.Column(db.String(120), nullableFalse) description db.Column(db.Text) cover_path db.Column(db.String(255)) teacher_id db.Column(db.Integer, db.ForeignKey(user.id)) status db.Column(db.String(10), defaultpublished) lessons db.relationship(Lesson, backrefcourse, lazydynamic) class Lesson(db.Model): __tablename__ lesson id db.Column(db.Integer, primary_keyTrue) course_id db.Column(db.Integer, db.ForeignKey(course.id)) title db.Column(db.String(120), nullableFalse) video_url db.Column(db.String(255)) content_text db.Column(db.Text) duration db.Column(db.Integer, default0) # 视频时长单位秒 sort_order db.Column(db.Integer, default0)2.2 三步搞定项目骨架与依赖环境项目骨架我建议按功能模块分文件不要把所有路由写在一个app.py里。我的目录长这样online_school/ ├── run.py # 启动入口 ├── config.py # 配置文件 ├── models.py # 数据库模型 ├── forms.py # 表单登录、作业提交等 ├── views/ │ ├── __init__.py │ ├── auth.py # 登录注册 │ ├── course.py # 课程相关 │ ├── homework.py # 作业相关 │ ├── exam.py # 考试相关 │ └── dashboard.py # 学习记录看板 ├── templates/ │ ├── base.html │ ├── auth/ │ ├── course/ │ ├── homework/ │ ├── exam/ │ └── dashboard/ ├── static/ │ ├── css/ │ ├── js/ │ └── uploads/ # 学生上传的作业文件 └── requirements.txt配置文件里最容易被忽略的是SECRET_KEY和上传文件大小限制这两个一定要写在显眼位置。SECRET_KEY不设置Flask的session直接没法用登录功能就废了上传大小不限制学生传个大视频上来服务器分分钟被拖垮。我通常这样写import os basedir os.path.abspath(os.path.dirname(__file__)) class Config: SECRET_KEY os.environ.get(SECRET_KEY) or dev-secret-key-change-me SQLALCHEMY_DATABASE_URI sqlite:/// os.path.join(basedir, school.db) SQLALCHEMY_TRACK_MODIFICATIONS False UPLOAD_FOLDER os.path.join(basedir, static, uploads) MAX_CONTENT_LENGTH 16 * 1024 * 1024 # 限制16MB ALLOWED_EXTENSIONS {png, jpg, pdf, doc, docx}依赖就装几个常用的Flask、Flask-SQLAlchemy、Flask-Login、Flask-WTF。用虚拟环境隔离依赖requirements.txt锁版本避免换台机器跑不起来。初始化数据库时可以直接在run.py里加一段建表逻辑第一次运行自动创建表from app import app, db app.cli.command(init-db) def init_db(): db.create_all() print(数据库初始化完成)3. 核心功能模块的代码实现3.1 用户登录与权限控制学生、老师、管理员分层这个系统有三种角色权限边界要清晰管理员管理课程和用户老师发布作业和批改学生看课、交作业、考试。Flask-Login帮我把登录状态管理起来了三行代码注册登录视图就够核心是权限控制装饰器。from functools import wraps from flask import abort from flask_login import current_user def role_required(*roles): def decorator(view): wraps(view) def wrapped(*args, **kwargs): if not current_user.is_authenticated or current_user.role not in roles: abort(403) return view(*args, **kwargs) return wrapped return decorator app.route(/teacher/homework/new, methods[GET, POST]) login_required role_required(teacher, admin) def new_homework(): # 只有老师和管理员能访问 ...这个装饰器写一次挂在所有需要权限的路由上。我踩过的一个坑是login_required和role_required的顺序不能反先检查登录状态再检查角色否则未登录用户会直接看到403而不是跳转到登录页。密码处理用Werkzeug自带的哈希函数明文密码绝对不能入库这是老生常谈但每次都要强调。登录注册页面的模板继承base.html表单用Flask-WTF定义CSRF保护默认开启这点对线上环境非常重要。我在表单里加了render_form快速渲染虽然样式丑了点但胜在可靠。3.2 课程展示与学习记录采集课程列表页就是查询所有状态为published的课程展示封面、标题、简介。课程详情页要展示章节列表左侧是视频播放区域右侧是章节导航。少儿培训的课程内容大多数是录播视频所以我直接嵌HTML5的video标签不依赖第三方播放器。学习记录的采集逻辑是这个项目的关键点。我在前端写了一段原生JS监听timeupdate事件每15秒向后端上报一次当前章节的观看进度和累计时长。这样即使学生中途关掉页面已学习的时长也不会丢。app.route(/api/study_record, methods[POST]) login_required role_required(student) def save_study_record(): data request.get_json() lesson_id data.get(lesson_id) watch_seconds int(data.get(watch_seconds, 0)) progress int(data.get(progress, 0)) lesson Lesson.query.get_or_404(lesson_id) record StudyRecord.query.filter_by( student_idcurrent_user.id, lesson_idlesson_id ).first() if not record: record StudyRecord( student_idcurrent_user.id, lesson_idlesson_id, watch_secondswatch_seconds, progressprogress, last_timedatetime.now() ) db.session.add(record) else: record.watch_seconds watch_seconds record.progress max(record.progress, progress) record.last_time datetime.now() db.session.commit() return jsonify({code: 0, message: ok})这里的progress字段存的是视频播放进度百分比我会前端计算后传上来。watch_seconds是增量每次上报都累加到数据库里这样能统计真实的累计学习时长。有一个细节要注意学生反复看同一节课时watch_seconds会一直涨这是合理的因为它反映的是总投入时间不是去重时间。做数据报表时我会再按last_time去重不会直接拿这个数字展示成“学习次数”。3.3 作业模块发布、提交、批改一次讲清作业模块分老师端和学生端。老师端在课程详情页里点“布置作业”填写作业标题、内容描述、截止时间。学生端在课程详情页看到作业列表点进去可以提交作业文件也可以在文本框里写文字答案。我两种都支持文件上传用来交PDF、图片、压缩包文本框用来交代码、作文这类纯文本内容。文件上传的安全处理是最容易翻车的点。我在配置里做了大小限制和扩展名白名单上传时还给文件重命名规则是作业ID_学生ID_时间戳.扩展名避免文件名冲突和路径穿越。这里用uuid更省事import uuid filename request.files[file] if filename and allowed_file(filename.filename): ext filename.filename.rsplit(., 1)[1].lower() new_name f{uuid.uuid4().hex}.{ext} file_path os.path.join(current_app.config[UPLOAD_FOLDER], new_name) filename.save(file_path) submission HomeworkSubmission( homework_idhomework_id, student_idcurrent_user.id, file_pathnew_name, submit_timedatetime.now() ) db.session.add(submission) db.session.commit()批改端我用了Ajax提交老师打分后页面不刷新直接在当前行显示分数和评语。后台路由接收score和comment更新HomeworkSubmission记录。这里的判断逻辑是老师只能批改自己课程下的作业提交记录不然会出现A课程老师改B课程作业的越权操作。3.4 考试模块随机组卷、限时提交、自动判分考试模块是这个项目里最有技术含量的一部分。老师创建考试时选择课程、填写考试时长、勾选题目范围系统后台随机抽题生成试卷。因为是少儿培训题型以单选题和判断题为主方便自动判分也能降低孩子操作门槛。抽题逻辑用random.sample一行就能实现但要注意题目池数量不足时程序会抛异常所以要加判断import random def build_exam_paper(course_id, question_count): questions Question.query.filter_by(course_idcourse_id).all() if len(questions) question_count: raise ValueError(题目数量不足请检查题库配置) selected random.sample(questions, min(question_count, len(questions))) return selected学生进入考试后前端启动一个倒计时时间到了自动提交试卷。判分逻辑在后端完成遍历学生提交的答案列表和题目表里的标准答案比对计算总分。学生答过的每一道题、选的答案、是否正确我都会存进answers_json字段方便后续做错题回顾。def grade_exam(exam_record, answers): exam Exam.query.get(exam_record.exam_id) question_ids [int(qid) for qid in exam.question_ids.split(,)] questions Question.query.filter(Question.id.in_(question_ids)).all() total_score 0 detail_list [] for q in questions: answer answers.get(str(q.id), ) is_correct (answer q.answer) if is_correct: total_score q.score detail_list.append({ question_id: q.id, content: q.content, student_answer: answer, correct_answer: q.answer, is_correct: is_correct, score: q.score }) exam_record.total_score total_score exam_record.answers_json json.dumps(detail_list, ensure_asciiFalse) db.session.commit() return total_score考试记录表里的start_time和submit_time有实际意义如果submit_time - start_time远超考试时长说明学生可能中途离开了老师需要留意。这一块我在后台做了超时提醒实际使用中老师反馈特别好用。4. 学习记录与数据看板4.1 学习进度的可视化展示学习记录如果不做可视化就是一串干巴巴的数字家长看不懂老师也不愿意看。我在看板页面里用Chart.js画了几张图每个学生的学习时长柱状图、章节完成度百分比、近7天学习趋势折线图。数据来源就是StudyRecord表按学生聚合查询。def get_student_progress(student_id): records db.session.query( StudyRecord.lesson_id, func.max(StudyRecord.progress).label(max_progress), func.sum(StudyRecord.watch_seconds).label(total_seconds), func.max(StudyRecord.last_time).label(last_time) ).filter(StudyRecord.student_id student_id ).group_by(StudyRecord.lesson_id).all() return records图表的数据渲染我直接在后端把统计数据序列化成JSON传给前端模板Chart.js读取JSON做图。这种做法的好处是不需要单独写Ajax接口页面加载时数据就绪刷新不闪烁。4.2 班级维度的统计报表光有单个学生的学习记录还不够老师更需要看整体情况。我在看板里加了班级维度的统计全班作业提交率、各次考试平均分、每周人均学习时长。这些可以用一条SQL直接算出来。作业提交率就是一个课程下已提交人数除以应提交人数。考试平均分遍历该课程的所有考试记录求出均值。最高频的查询是“按天统计学习人数”我用了GROUP BY date(last_time)from sqlalchemy import func, cast, Date daily_counts db.session.query( cast(StudyRecord.last_time, Date).label(day), func.count(func.distinct(StudyRecord.student_id)).label(active_students) ).group_by(day).order_by(day).all()这里func.distinct保证同一个学生同一天只计数一次避免重复统计。这个报表可以让老师一眼看出哪些天学习人数明显下降从而调整教学节奏。5. 常见问题与排查技巧5.1 文件上传常见的坑文件上传是这个项目最容易出问题的环节。我遇到过的典型问题有几个上传中文文件名会导致存储后乱码解决办法是一律用uuid重命名上传目录如果不存在save会报错所以要在应用启动时用os.makedirs确保目录存在MAX_CONTENT_LENGTH超出后Flask会直接返回413错误前端要做提示我给模板加了一个error_handler检测到413时跳转到友好提示页面。另外还要注意扩展名白名单只检查文件名后缀是不可靠的攻击者可以伪造后缀上传可执行文件。所以白名单检查只是第一层实际生产环境建议配合file命令检查文件真实类型或者在保存后用Pillow验证图片文件能否正常打开。这个项目虽然后续没有做深度检查但在代码注释里我留了提示方便接手的人注意。5.2 SQLAlchemy会话与SQLite并发问题开发环境下用SQLite很香但上线后出现过一个灵异问题多个学生同时交作业或提交考试答案时偶尔会有请求失败日志里报database is locked。这是SQLite的锁机制导致的同一个时刻只允许一个写操作高并发写入时就会排队超时。临时方案是把SQLite的timeout设大一点在连接参数里加connect_args{timeout: 15}。长期方案还是迁移到MySQL或PostgreSQL代码层面几乎不用改只要改SQLALCHEMY_DATABASE_URI因为SQLAlchemy做了这层屏蔽。另外我习惯在每次请求结束时显式关闭sessionapp.teardown_appcontext def shutdown_session(exceptionNone): db.session.remove()这个习惯能避免开发环境下多线程调试时session被污染导致数据串掉的诡异问题。5.3 从开发到部署的关键动作本地开发时可以开着Flask自带的开发服务器但对外提供服务前必须换掉它。我的部署方案是gunicorn nginxPython主流、资料多、排查方便。server { listen 80; server_name your-domain.com; location / { proxy_pass http://127.0.0.1:8000; proxy_set_header Host $host; proxy_set_header X-Real-IP $remote_addr; } location /static/ { alias /path/to/online_school/static/; } client_max_body_size 20M; }gunicorn启动命令是gunicorn -w 4 -b 127.0.0.1:8000 run:app。四个worker够应付几百人的并发了再多可能会把服务器内存吃满。nginx里client_max_body_size要设置大于Flask的MAX_CONTENT_LENGTH否则学生传一个大文件会在nginx层就被拦下浏览器的报错提示很不友好。关于部署环境我在项目根目录放了一个.env.example文件里面列了所有需要配置的环境变量SECRET_KEY、DATABASE_URI、UPLOAD_FOLDER等。部署时先把.env.example复制成.env逐项填入真实值然后flask init-db建表再启动gunicorn就完事。做完这个项目后我最大的体会是少儿培训系统的开发难点从来不在技术而在你要清楚地知道“学习记录”四个字意味着什么。对机构来说它是续课率的底气对家长来说它是孩子进步的凭证对学生来说它是每天打卡的成就感。与其把精力花在花哨的交互动画上不如老老实实把作业、考试、学习记录这三条数据链路跑通。技术选型上Flask再一次证明了它在中小型业务系统里的高效——不折腾不炫技一套流程走下来从开发到部署不到两周就能上线。