做了几个管理系统之后,我开始重新思考在线教育项目里的技术选型。标题里的“基于Java+SSM+Flask在线教育系统”乍一看像技术大杂烩,但把在线学习平台的业务拆开看,你会发现这正是很多开发团队实际在用的组合:Java+SSM负责课程、订单、后台用户管理等重业务链路,Flask负责统计报表、学习行为收集这类轻量扩展服务。这篇内容适合三种人看:准备做毕业设计的在校生、想搭建在线课程系统的自学开发者、以及接手类似远程教育系统源码但不知道怎么讲解交付的小团队。我会把架构逻辑、表结构、核心接口、调试文档要点和讲解思路一条条摊开讲,尽量让你拿到标题里的源码包和LW文档后有底气去改、去讲、去演示。
1. 为什么一前一后两套服务,而不是一套框架走到底
1.1 在线教育业务里最容易踩的需求分层
在线教育平台表面看是“课程列表+视频播放+用户中心”,真落到需求文档里会多出一大堆边角料:课程分类要支持多级嵌套,讲师要单独维护主页,订单要处理支付回调、退款、对账,学习进度要记录到每一小节甚至视频的播放位置,后台还要做数据看板和内容审核。
如果把所有功能都堆在一个单体应用里,一开始开发效率确实高,但到后期会明显难受。比如课程列表页和后台管理页可能有多套权限规则,统计模块又需要频繁调整SQL聚合逻辑,改一处就要重新编译整个项目。另一方面,如果拆成太多微服务,对一个小型在线教育系统来说完全是过度设计,连部署都要多买好几台机器。
我的经验是把它分成两层:Java+SSM作为主后端,承担用户、课程、订单、权限这些核心业务;Flask作为辅助服务,承担学习行为日志接收、数据统计接口、简单推荐规则。两套服务之间通过HTTP接口通信,数据库可以共享同一个MySQL,但业务上尽量各写各的表,别在一个Service里混着写。
1.2 SSM与Flask各自适合解决什么问题
先说SSM。Spring MVC负责接口路由,Spring管理对象和事务,MyBatis负责SQL映射。这套组合的特点是“稳”,事务控制、声明式缓存、拦截器这些能力都很成熟,适合处理订单支付这种一不留神就会出数据问题的场景。SSM的缺点是写一个小接口要经过Controller、Service、Mapper三层,代码量大一些,但这也恰好是规范的地方。
再说Flask。它的路由和视图非常轻量,一个Python文件几十行就能跑起来。放在在线教育系统里,非常适合做三类事情:
- 接收前端上报的学习行为数据,比如用户看了哪个视频、看了多久、拖动了多少进度。
- 提供统计报表查询接口,按天输出注册数、课程销量、学习时长等聚合结果。
- 跑简单的推荐算法,根据用户最近课程分类偏好,排序返回候选课程ID。
选择Flask还有一个现实原因:很多在线教育系统的需求说明书里都有“可视化统计”和“智能推荐”模块,用Java写也不是不行,但Python的数据处理库更齐全,写起来更顺手。整个系统对外是一个在线教育平台,内部代码结构却可以像“主服务+辅助插件”一样灵活演进。
2. 核心模块拆解:从课程浏览到视频学习的学习闭环
2.1 课程中心、讲师和视频资源表结构
在线教育系统的数据模型,最先要确定的不是用户表,而是课程表。因为课程结构天然带层级:一门课程下面有多个章节,每个章节下再挂视频或文档,这种树形结构如果设计不好,后面写查询会非常痛苦。
我习惯用这样一组表来组织课程域:
| 表名 | 说明 | 关键字段 |
|---|---|---|
| t_course_category | 课程分类,支持多级 | cat_id, parent_id, cat_name, sort_no |
| t_teacher | 讲师信息 | teacher_id, teacher_name, avatar, intro, status |
| t_course | 课程主表 | course_id, title, cat_id, teacher_id, cover, price, status |
| t_course_section | 课程章节与视频小节 | section_id, course_id, parent_id, video_url, duration, free_flag |
其中t_course_section的parent_id字段很关键,它让同一张表既能表达章,也能表达节。章下面的小节通过parent_id指向章ID,章本身parent_id为0。这样前端在课程详情页按顺序渲染目录时,一条SQL就能查全。
课程状态字段status我建议用整数枚举:0草稿、1待审核、2已上架、3已下架。后台管理员审核后修改状态,前端只查询status=2的课程,避免出现“草稿课程被用户看到”的低级事故。
这里还要考虑课程的免费和收费。free_flag用来标记某个小节是否可免费试看,订单金额则存在课程主表里。视频文件URL一般存相对路径,配合部署环境的文件服务或对象存储地址拼接。本地调试时,直接放在Tomcat的upload目录下也能跑通,但正式交付时要改成可配置的前缀。
2.2 订单、支付回执与学习进度的状态机设计
订单模块最容易写乱,因为支付状态不是只有“已支付”和“未支付”两种。在线教育平台的订单至少要经历:待支付、已支付、已取消、已退款四个状态。有些需求还要加“部分退款”“退款中”,但学习类课程一般是一次性买断,做四种状态就够。
我用一张表记录订单:
CREATE TABLE t_order ( order_id INT PRIMARY KEY AUTO_INCREMENT, order_no VARCHAR(32) NOT NULL, user_id INT NOT NULL, course_id INT NOT NULL, amount DECIMAL(10,2) NOT NULL, pay_state TINYINT NOT NULL DEFAULT 0, create_time DATETIME NOT NULL, pay_time DATETIME, cancel_time DATETIME, refund_state TINYINT DEFAULT 0 ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;pay_state的取值必须写进设计文档:0待支付,1已支付,2已取消,3已退款。false? 我见过不少项目把支付状态设计成布尔值,结果退款需求一来就傻眼。状态迁移规则也提前定好:只有待支付能取消;只有已支付能退款;支付回执到达时,如果订单已经取消,必须拒绝处理而不是强行改成已支付。
学习进度表可以单独设计,不需要和订单表混在一起:
| 字段 | 含义 | 备注 |
|---|---|---|
| user_id | 学生ID | 联合主键一部分 |
| course_id | 课程ID | 联合主键一部分 |
| section_id | 最后学习到的小节 | 每次上报覆盖 |
| last_position | 视频播放位置(秒) | 用于断点续播 |
| total_duration | 已学总时长 | 用于统计报表 |
| update_time | 最近学习时间 | 用于排序推荐 |
学习进度表按用户+课程+小节联合主键设计,防止同一学生重复学习同一小节产生多条垃圾数据。上报接口收到请求后,优先update,无记录再insert。
3. SSM主链路怎么搭:从登录到课程学习的完整请求流
3.1 Controller-Service-Mapper分层在业务上的真实作用
很多同学拿到SSM源码后,只看到Controller里调用Service,Service里调用Mapper,但不知道每层到底该写什么。我以课程详情接口为例,把职责分清楚。
Controller层只做三件事:接收参数、校验基本格式、包装返回结果。它不应该直接拼SQL,也不应该处理业务逻辑。课程详情接口的Controller写法通常是这样的:
@Controller @RequestMapping("/course") public class CourseController { @Autowired private CourseService courseService; @ResponseBody @RequestMapping("/detail") public Result detail(@RequestParam Integer courseId, HttpSession session) { Integer userId = (Integer) session.getAttribute("userId"); CourseDetailVO vo = courseService.getCourseDetail(courseId, userId); return Result.ok(vo); } }Service层承载业务规则。比如课程详情接口,Service需要判断当前用户是否已经购买该课程,这会决定接口返回的章节列表里,哪些小节带播放地址,哪些被隐藏。查询时先取课程基本信息,再查课程章节列表,最后根据用户权限拼装VO对象。如果直接让Mapper去联三张表一次查出来,SQL会很长,而且权限判定逻辑会被SQL掩盖,不利于后续扩展。
Mapper层只负责单表或明确约定的多表查询。MyBatis的XML文件里保持SQL简洁,能拆的拆分。查询课程列表时如果需要join分类表取分类名,可以用嵌套查询或者join,但不要动辄“SELECT *”,把字段写清楚。
3.2 登录鉴权与视频学习拦截器
在线教育系统的权限逻辑,重点不是管理员后台,而是“哪些用户能看哪些课程”。视频URL如果直接经过Nginx或Tomcat静态资源映射暴露出去,别人拿到链接就能下载或播放,所以需要一层拦截器来控制访问。
Spring MVC里我用HandlerInterceptor实现登录验证和课程鉴权,改造点有两个。第一个是登录拦截器:
public class LoginInterceptor implements HandlerInterceptor { @Override public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) throws Exception { HttpSession session = request.getSession(); if (session.getAttribute("userId") == null) { response.sendRedirect("/login"); return false; } return true; } }第二个是视频资源鉴权。更好的做法是视频URL不直接指向静态文件,而是通过一个受保护的接口派发临时播放地址。我的思路是这样的:
- 客户端请求/course/play?sectionId=xxx。
- 后端先判断用户是否登录,再判断该用户是否购买了当前课程。
- 通过后,根据sectionId查视频路径,重定向到实际文件地址。
- 如果未购买,返回JSON错误提示。
有人会说,我直接在前端隐藏播放按钮就行。实际项目中这样根本拦不住,因为接口地址是公开的,只要猜出参数就能直接访问。视频播放鉴权必须放在后端接口层,至少要做到每个播放请求都校验一次订单状态,不能把订单状态存在Session里然后连续放行。
4. Flask辅助服务:统计报表与动态推荐接口怎么设计
4.1 把学习记录上报到独立的Flask服务
为什么学习记录要单独走Flask而不是直接写在SSM里?因为学习记录有两个特点:频率高、关联少。学生每看一个视频小节就会上报一次,中途还会定时上报播放位置;这些数据不参与订单事务,没必要占用Java端的事务连接和时间片。放到Flask里,既能减轻主服务压力,也能用Python快速处理后续的聚合统计。
设计约定如下:前端定时向Flask服务POST /api/study/report,参数包括userId、courseId、sectionId、duration、position、token。Flask端先校验token,再按联合主键更新学习进度。
@app.route('/api/study/report', methods=['POST']) def study_report(): data = request.get_json() user_id = data.get('userId') course_id = data.get('courseId') section_id = data.get('sectionId') duration = data.get('duration') position = data.get('position') if not all([user_id, course_id, section_id]): return jsonify(code=400, msg='参数不完整'), 400 upsert_study_progress(user_id, course_id, section_id, duration, position) return jsonify(code=200, msg='ok')这里有个容易忽视的问题:前端上报要带上唯一请求标识,否则网络重试或用户快速拖动进度条会导致重复上报,统计数据虚高。这个标识可以放在请求头里,Flask端每次处理前检查Redis集合是否已经存在该ID,存在就跳过。做到接口幂等,统计报表才可信。
4.2 Java调用Flask接口时要注意的数据一致性问题
主服务和辅助服务之间是跨系统通信,最容易出现的是数据不一致。比如用户在Java端完成了支付,Flask端立刻查订单统计时,如果订单数据还没从Java数据库同步过来,统计就会漏单。
解决方式不是硬上消息队列,而是约定数据边界。我的习惯是:
- 订单、课程、用户数据只由Java端写库。
- Flask端需要订单维度数据时,优先实时调用Java端提供的查询接口,而不是直接读Java库表。
- 学习进度这种高频数据只由Flask端写,Java端需要展示“最近学过什么”时,调用Flask接口查询。
有人会说,Java端和Flask端都配了同一个MySQL数据源,Flask直接读t_order表不就行了。确实能读,但很危险:Flask项目里的SQL一旦没有事务约束,很容易误写。更稳妥的做法是Flask只读自己服务范围内的表,比如t_study_progress,订单表通过Java接口查询。这样就算Flask服务挂掉,也不会把主业务数据库搞脏。
Java端调用Flask接口,通常在课程详情页加载学习进度时使用。可以写一个简单的RestTemplate调用:
RestTemplate restTemplate = new RestTemplate(); String url = "http://127.0.0.1:5000/api/study/progress?userId=" + userId + "&courseId=" + courseId; StudyProgress progress = restTemplate.getForObject(url, StudyProgress.class);Flask接口返回JSON时,字段命名统一用驼峰风格,Java端直接映射到实体类就方便。因为Flask是Python,Java是强类型语言,字段类型要特别注意,比如时间字段直接返回格式化字符串,不要在JSON里返回一个难解析的datetime对象。
5. 调试文档应该包含的真实问题:从起不来到锁冲突
5.1 环境问题、端口和跨域,十次调试九次先栽在这
做在线教育系统调试,遇到的第一类坑往往是环境而不是代码。我整理一个高频问题对照表,可以直接抄进LW文档的调试记录章节里。
| 现象 | 根本原因 | 处理方式 |
|---|---|---|
| Tomcat启动报端口被占用 | 本地有其他程序占用8080 | 用netstat查看端口PID并释放,或改Tomcat端口 |
| MySQL连接报错Public Key Retrieval is not allowed | MySQL8默认认证插件问题 | JDBC连接串加allowPublicKeyRetrieval=true |
| 中文乱码 | 数据库字符集不是utf8mb4 | 建库时指定utf8mb4,ApplicationContext里面设置characterEncoding |
| 前端请求404 | SpringMVC拦截路径配置不对 | 检查web.xml和DispatcherServlet的url-pattern |
| 前端请求跨域 | 端口不同导致Origin不一致 | 开发环境加CORS配置,Flask端用flask-cors允许指定来源 |
| Flask端口与Java端口冲突 | 两台服务都用了5000段附近端口 | 统一规则:Java用8080,Flask用5000,部署时用nginx反向代理 |
跨域问题在本地开发时几乎必现。原因是Vue或HTML页面跑在某个端口,Java和Flask服务分别跑在另外两个端口,浏览器默认不允许跨端口读数据。SpringMVC端加CorsFilter,Flask端加flask-cors,两者配置的允许来源不要写“*”,生产环境写具体域名,开发环境可以放行localhost。
5.2 一个典型的并发订单排查链路
订单支付接口的调试资料最能体现一个项目有没有做过真实业务。我遇到过的典型问题是并发下单时重复创建订单。两个请求同时进来,都判断用户没有购买当前课程,然后都执行了insert,最终产生两笔待支付订单。
排查链路分四步走:
第一步,看日志。日志里出现两个线程同时打印“当前用户未购买该课程”,说明判断逻辑本身没错,问题是判断和插入之间没有互斥。
第二步,看事务。Service方法加了@Transactional,但如果是MySQL默认的可重复读隔离级别,两个事务在并发插入时会各自看到旧数据,判断结果都是“未购买”。
第三步,定位锁。修复方式不是把隔离级别改成串行化,那样性能太差。常规做法是给用户和课程的组合加唯一索引,insert时捕获DuplicateKeyException,或者用数据库的for update锁住用户课程记录再判断。
第四步,验证。用并发测试脚本同时发起20个下单请求,观察最终t_order表中只有一条有效订单,且只产生一次支付回调响应。
这个排查过程完整写进调试文档,远比只贴一个“问题已解决”有说服力。评审或接手的人能从排查链路里看出你理解并发控制,而不是靠运气调好。
5.3 LW文档里调试记录该写到什么程度
LW文档在不少交付场景里指的是“设计说明文档”,但很多人把它写成了用户手册,通篇只有“点击登录、输入账号”。更合理的内容结构是这样:
- 需求概述:平台解决什么问题,目标用户是谁。
- 总体设计:系统架构图、功能模块划分、技术选型理由。
- 数据库设计:每张表的作用、字段含义、状态枚举、索引设计。
- 接口文档:每个核心接口的请求路径、参数、返回示例。
- 调试问题记录:调试过程中的异常现象、定位过程、解决代码、复现步骤。
- 操作手册:如何初始化数据库、启动服务、部署上线。
重点说一下接口文档和调试记录。接口文档不要贴大段源码,而是给出请求示例和返回示例,哪怕只有五个核心接口,也比罗列十个没用的接口强。调试记录建议用表格,列出时间、现象、原因、解决方案、验证结果五项。这部分内容在讲解时会被反复提问,写清楚就是你做过真实系统的证据。
6. 源码包、LW文档与演示讲解怎么组织才不像临时凑数
6.1 源码与文档的目录规划
交付一个在线教育系统,光有一堆源码文件是不够的。我见过很多项目的源码包打开后,Java代码和Flask代码混在一起,数据库脚本又放在“新建文件夹”里,接手的人根本不知道从哪开始。好的交付包推荐按这样分目录:
online-education-system/ ├── docs/ │ ├── 需求说明.md │ ├── 设计说明.md │ ├── 数据库设计.md │ ├── 接口文档.md │ └── 调试记录.md ├── database/ │ ├── init.sql │ └── test_data.sql ├── backend-ssm/ │ ├── src/ │ ├── pom.xml │ └── README.md ├── service-flask/ │ ├── app.py │ ├── requirements.txt │ └── config.py ├── frontend/ │ └── pages/ └── 部署说明.md前端如果用的是纯HTML+JS,就放到frontend目录;如果用Vue,就单独提一层目录,说明编译方式。init.sql里只放建表和初始化数据,不要和测试数据混在一起。test_data.sql专门放演示用的课程、学生、订单数据,方便答辩时直接演示。
6.2 演示脚本怎么设计:先看角色后看数据流
讲解在线教育系统,顺序比内容更重要。最常见的错误是一上来就讲登录页好看、课程列表好看,讲完连业务逻辑都没碰。
我的建议是按“角色+数据流”来走:
- 用学生账号登录,浏览课程列表,筛选分类,打开一门免费试听课,强调前端页面调用Java端接口获取课程数据。
- 选择一门收费课程,模拟下单。如果本地没有支付环境,就演示管理员后台把订单置为已支付,再从前端刷新看到课程解锁。
- 播放视频,拖动进度条,通过调试工具展示网络请求里有Flask服务上报学习记录的请求。
- 用管理员账号登录后台,查看用户列表、课程审核、统计报表,强调报表数据来自Flask聚合计算。
- 最后展示数据库,在MySQL中查t_order、t_study_progress,验证刚才的操作确实落库。
这个流程讲完,整条业务链路就闭合了。评审或受众能直观看到Java与Flask各干了什么,而不是只听你念PPT。
6.3 交付前必须做掉的检查清单
在整理源码和文档时,我会强制自己过一遍下面的检查清单,避免在演示当场翻车。
| 检查项 | 说明 |
|---|---|
| 数据库脚本能从头执行 | 清空数据库后按顺序跑init.sql和test_data.sql,确保没有报错 |
| 默认账号明确 | README或操作手册里写清楚学生、管理员账号密码,不要让人去找 |
| 文件路径无本机绝对路径 | 代码里不要出现C:/xxx或/home/xxx,改成相对路径或用配置项 |
| Flask依赖固定版本 | requirements.txt写清版本,避免换环境后库版本不一致 |
| Java依赖能离线编译 | 如果交付环境没网,尽量保留maven本地仓库依赖包 |
| 讲解与代码一致 | 项目代码里不要出现与讲解完全不符的功能名称或模块 |
最后再分享一个个人经验:我会把LW文档当成“团队新成员交接手册”来写,而不是只给评审看。写完调试记录时顺手把常见启动步骤也写进去,半年后自己再打开这个项目,不用费劲回忆“当初那个端口是多少”。如果你将来要把这套在线教育系统扩展成正式的在线教学系统,这份文档就是你最值钱的积累。