接手“在线课堂微信小程序+SSM”这套组合,是很多开发者绕不开的一道坎。小程序端负责跟用户直接打交道,SSM(Spring + SpringMVC + MyBatis)在后台顶住业务逻辑和数据存储,一路从登录、刷课程、看视频到后台管理全链路跑通。做这个项目,既能把微信生态的开发流程摸一遍,又能实打实练到 Java 后端三件套的配合,所以不管你是拿来当课程设计,还是作为毕设底子,这套链路都非常有代表性。
这篇文章我会用实际做一个在线课堂项目的角度,把项目怎么拆、模块怎么定、表怎么建、接口怎么出、代码写到什么程度、踩过哪些坑,全部捋一遍。核心围绕“一个用户怎么通过小程序看到课程并完成学习”这条业务线展开,后端部分则重点讲 SSM 环境下接口和数据库怎么落地。无论你是刚学完 JavaWeb 准备做第一个完整项目,还是已经有过一点开发经验想看看这类项目的完整结构,这篇文章都能给你一套可以直接照着干的路径。
1. 项目整体设计与思路拆解
1.1 这类项目到底在做什么
在线课堂微信小程序,说白了就是一套“课程商城 + 学习平台”的轻量级结合体。用户通过小程序端看到课程分类、课程列表、课程详情,可以报名购买课程,然后在小程序里直接观看视频、记录学习进度。管理员则通过后台管理课程上下架、分类维护、订单查看、用户管理等操作。
放在技术层面,它要处理的并不是某个很难的功能点,而是一条完整且闭环的业务链:微信登录态获取 openid -> 课程数据展示 -> 订单创建 -> 支付/模拟支付 -> 学习记录 -> 后台管理。这条链路把常见的业务场景都覆盖到了,这也是它为什么总被选作教学项目和考核项目。对你来说,做完这一整套东西,相当于把“会调接口”升级成了“能设计并落地一个完整系统”。
这类项目最容易犯的错误,是一上来就堆功能。今天加个评论,明天加个直播,后天再加个积分商城,结果每一个功能都是半成品。我的建议是先跑通“浏览课程 -> 下单 -> 学习记录”这条最小闭环,再把扩展功能当作二期迭代。核心链路稳了,项目就已经立住了一大半。
1.2 技术选型为什么是“小程序 + SSM”
先说小程序端。选择微信小程序而不是传统 Web 页面,原因很直接:对于在线教育类应用,用户最需要的是低门槛入口,微信扫一扫或者搜索就能打开,不需要下载 App,也不需要通过浏览器输入网址。小程序的代码包体积小、加载快,原生框架内置了 video 组件,播放视频类内容非常方便。对于在线课堂这个场景,原生小程序是足够胜任的。
相比之下,如果选 uni-app 这类跨端框架,虽然可以同时输出 H5 和 App,但会多出一层编译和兼容成本,而且样式细节在小程序原生组件上还不如直接写原生来得顺手。除非你有明确的多端发布需求,否则这类项目直接用原生微信小程序就是最稳的方案。
再说后端。SSM 虽然比 Spring Boot 显得“老”,但它是很多学校教学和考核中的标准技术栈,覆盖了 Java 后端最核心的三块:Spring 负责对象管理和解耦,SpringMVC 负责请求路由和参数绑定,MyBatis 负责数据库访问和 SQL 控制。把这三块分开理解再组合起来,你对后端框架的理解深度会比直接上手 Spring Boot 更扎实。
有人可能会问,为什么不直接上 Spring Boot?如果你是用于个人作品展示,我当然推荐 Spring Boot,配置少、起步快。但如果项目背景是课程设计或毕业设计,SSM 往往更符合题目预期,而且写文档的时候可以铺开的点更多。技术本身没有高下,匹配场景才是关键。SSM 的复杂度主要在于配置文件多,但它恰恰能让你理解 Spring 和 SpringMVC 的底层机制。
1.3 系统角色与核心模块
在线课堂项目按角色拆,天然分成两端:
- 用户端(小程序):微信登录、浏览/搜索课程、查看课程详情、报名下单、观看视频、维护个人学习记录。
- 管理端(后台网页,或文档中定义好的管理接口):课程分类管理、课程信息管理、轮播图管理、订单管理、用户管理、学习数据统计。
用户端的核心是“找课”和“学习”,管理端的核心是“商品管理”和“订单查看”。两个角色不交叉,但共享同一套数据库和接口规范。
从模块清单来看,主要包含这些:
| 模块 | 核心功能 | 涉及数据表 |
|---|---|---|
| 登录模块 | 获取 openid、生成 token、维护会话 | user |
| 首页模块 | 轮播图、分类导航、课程推荐 | banner、course_category、course |
| 课程模块 | 课程列表、课程详情、视频信息 | course、course_video |
| 订单模块 | 创建订单、支付/模拟支付、取消订单 | orders |
| 学习模块 | 用户-课程关系、学习进度记录 | user_course、study_record |
| 后台管理 | 课程/分类/订单/用户维护 | 对应各表 |
这样划分之后,开发顺序也就清楚了:先建库建表,再做登录,再做课程展示,然后做订单,最后补学习记录。管理和统计功能可以穿插在最后统一完善,因为它依赖前面所有业务数据。
模块划分的最大价值,是让团队或者你自己在动手写代码之前就知道每个接口该放到哪一层、每个表该服务谁。很多代码写乱,不是因为水平不够,而是因为一开始就没把边界画清楚。
2. 核心功能模块解析与数据库设计
2.1 核心用户流程拆解
用户第一次打开小程序,第一件事一定是登录。这里要注意,微信小程序里用户点“授权登录”,真正到后端能拿到的不是用户手机号,而是一个临时的 code。后端拿着这个 code 去微信接口换 openid 和 session_key,openid 是用户在当前小程序下的唯一标识,这个值就是你这套系统识别用户的凭据。
拿到 openid 之后,后端要做的第一件事是去 user 表里查一下这个用户存不存在,不存在就自动注册一条。这个过程对用户是无感的,用户不需要填手机号、设置密码,打开即用。这也是小程序类项目最大的体验优势。
登录完成后,用户进入首页。首页通常展示轮播图、课程分类和推荐课程列表。这些数据都来自后端接口,小程序端通过 wx.request 发起请求,拿到 JSON 数据后渲染到页面上。用户点击某个分类,可以刷出该分类下的课程列表;点击某个课程卡片,进入课程详情页。
课程详情页是信息密度最高的页面:课程标题、封面、讲师、价格、课程介绍、视频列表、报名按钮。如果用户还没购买,视频列表里可以展示试看片段;购买之后,所有视频解锁,点开任意一集直接播放。这里非常关键的一点是,视频播放权限不能只靠前端隐藏按钮来保证,一定要在后端接口里校验当前用户是否已购买该课程,否则前端可以直接绕过页面发起请求。
用户点击报名后进入订单流程。系统创建一条订单记录,状态为“待支付”。支付环节在真实项目中是接入微信支付统一下单接口,但在课设项目中,为了方便演示和避免繁琐的商户号申请,绝大多数人会做成“模拟支付”:点击支付按钮,前端请求一个模拟支付接口,后端直接把订单状态改成已支付,同时把课程写入用户的“我的课程”。这个做法对系统逻辑没有影响,反而把支付环节的关注点从“申请商户号”转移到了“订单状态流转”上。
支付成功后,用户可以在“我的课程”里看到已购课程,点进去播放视频。播放过程中,前端监听视频进度,隔一段时间向后端上报学习进度。下次再进来,可以直接从上次停下的位置继续看。至此,一条核心业务闭环就完整了。
2.2 数据库表设计要点
数据库设计直接决定后面写 SQL 是舒服还是痛苦。在线课堂项目,核心表大概七到八张,下面是实际项目中我常用的设计方式:
用户表(user)
| 字段 | 类型 | 说明 |
|---|---|---|
| id | int | 自增主键 |
| openid | varchar(64) | 微信 openid,唯一索引 |
| nickname | varchar(64) | 昵称 |
| avatar | varchar(255) | 头像地址 |
| phone | varchar(20) | 手机号(可选) |
| create_time | datetime | 注册时间 |
openid 一定要建唯一索引,因为用户重复进入小程序后,后端判断“是否新用户”就是靠查 openid 是否已存在。如果遇到并发注册,唯一索引能挡住重复数据的产生。
课程分类表(course_category):id、name、sort、status。分类表结构非常简单,但它是课程列表筛选的基础。注意 sort 字段控制显示顺序,前端排序、后台拖拽排序都用得上。
课程表(course)
| 字段 | 类型 | 说明 |
|---|---|---|
| id | int | 主键 |
| category_id | int | 关联分类表 |
| title | varchar(128) | 课程标题 |
| cover | varchar(255) | 封面图 |
| intro | text | 课程介绍 |
| teacher | varchar(32) | 讲师名 |
| price | decimal(10,2) | 价格,单位元 |
| original_price | decimal(10,2) | 原价(划线价) |
| status | tinyint | 1上架 0下架 |
| sales | int | 虚拟销量 |
| create_time | datetime | 创建时间 |
price 用 decimal 而不是 double/float,是因为浮点数在精度上会出问题。比如 19.9 这个价格,用 float 存储和运算很可能得到 19.900000xxx,后续做金额汇总时全是坑。decimal 是精确保存,做课程这类金额字段必须用。
另外建议增加一个 sales 字段作为虚拟销量。为什么?因为真实销量需要从订单表统计,每次查询课程列表都要做 count 聚合,数据量大了以后性能差,而且很多课程刚上线没有销量,页面显得冷清。用虚拟销量字段,后台想调多少调多少,展示效果好,接口也快。
课程视频表(course_video):id、course_id、title、video_url、duration、sort。一个课程对应多集视频,按 sort 排序播放。video_url 建议存完整的可访问地址,不要把相对路径和 IP 拼一半留一半,小程序端的 video 组件需要的是完整 URL,缺了 https 前缀会直接加载失败。
订单表(orders)
| 字段 | 类型 | 说明 |
|---|---|---|
| id | int | 主键 |
| order_no | varchar(32) | 业务订单号,唯一 |
| user_id | int | 用户 id |
| course_id | int | 课程 id |
| amount | decimal(10,2) | 实付金额 |
| status | tinyint | 0待支付 1已支付 2已取消 |
| pay_time | datetime | 支付时间 |
| create_time | datetime | 创建时间 |
订单号不要用自增 id,因为订单号在业务层面会暴露订单量,而且自增序号在更复杂的场景下容易冲突。这里用日期加随机数拼出 order_no,比如 202606011035120001 + 随机四位,基本可保证唯一。
用户课程表(user_course):id、user_id、course_id、buy_time。这张表用来记录用户与已购课程的关系。用户创建订单且支付成功后,后端要做的关键动作就是往这张表插入一条记录。之后用户查看“我的课程”,查询的就是这张表,而不是订单表。
学习记录表(study_record):id、user_id、course_id、video_id、progress、update_time。记录用户看到某节课的哪个时间点,单位可以是秒。用 video_id 精确到单集,这样即使一个课程有多集视频,也能分别记录各自进度。
数据库的关联关系上,我建议只在 Java 代码和 SQL 中体现外键逻辑,不要真的去建外键约束。真实项目中,物理外键会影响插入和删除性能,还会带来看似安全实则僵化的数据约束。课程、订单、用户之间用索引加逻辑关联就够了。
2.3 接口设计与统一返回格式
前后端分离的项目,接口设计做得舒服,联调阶段就能少掉一半头发。在线课堂项目接口不需要多花哨,按 RESTful 风格来,但要做到路径清晰、返回格式统一。
通常我会定义一个 Result 类,所有接口都返回这个结构:
public class Result<T> { private Integer code; // 200 成功,500 失败,401 未登录 private String msg; private T data; public static <T> Result<T> success(T data) { Result<T> r = new Result<>(); r.code = 200; r.msg = "success"; r.data = data; return r; } public static <T> Result<T> error(String msg) { Result<T> r = new Result<>(); r.code = 500; r.msg = msg; return r; } }前端拿到这个结构后,只需要判断 code 是否为 200,再取 data 渲染。如果 code 是其他值,直接弹出 msg 给用户提示。这样比后端直接甩一堆散乱字段要规范得多,排查问题也快。
接口清单大致如下:
| 接口 | 方法 | 功能 |
|---|---|---|
| /api/user/login | POST | 传入 code,获取 openid,返回 token |
| /api/course/list | GET | 课程分页列表,支持分类筛选 |
| /api/course/detail | GET | 课程详情,含视频列表 |
| /api/order/create | POST | 创建订单 |
| /api/order/pay | POST | 模拟支付,支付成功后写入 user_course |
| /api/study/progress | POST | 上报学习进度 |
| /api/study/progress/get | GET | 获取某节课的学习进度 |
| /api/user/courses | GET | 当前用户已购课程列表 |
分页参数统一用 pageNum 和 pageSize,前端传入,后端返回总条数和当前页数据列表。分页这件事看着简单,但如果不统一,前端写多了就会乱。建议响应里返回一个 PageResult 结构:total、list、pageNum、pageSize。这样前端做滚动加载和分页控件时,直接取字段就行。
3. 实操过程与核心环节实现
3.1 搭建 SSM 后端骨架
新建 Maven 项目后,首先要解决依赖。SSM 需要导入的核心依赖包括 spring-webmvc、mybatis、mybatis-spring、druid 连接池、mysql-connector-java、jackson 等。写 pom.xml 的时候,注意锁定版本,特别是 spring 版本和 mybatis-spring 版本,版本不匹配会导致启动时找不到类。
项目结构按分包划分:
com.example.educourse ├── controller // 接口层,接收前端请求 ├── service // 业务逻辑层 │ └── impl ├── mapper // MyBatis 接口 ├── entity // 实体类,对应数据库表 ├── common // 通用类,如 Result、PageResult └── config // 配置类,如拦截器SSM 配置最麻烦,但也最重要。核心有四个配置点:
- jdbc.properties:数据库连接地址、账号密码、连接池参数。
- spring-mvc.xml:开启注解扫描、配置视图解析、JSON 转换器。
- spring.xml:配置 service 层扫描、数据源、MyBatis 的 SqlSessionFactory、事务管理器。
- mybatis-config.xml:配置驼峰映射、日志输出、别名。
其中有一个非常容易踩坑的点,就是 MyBatis 的驼峰映射。数据库字段名习惯用下划线,比如 create_time,Java 实体类习惯用驼峰,比如 createTime。默认情况下 MyBatis 并不知道这两者对应,查询结果里 createTime 就会是 null。解决方法是开启驼峰映射:
<settings> <setting name="mapUnderscoreToCamelCase" value="true"/> </settings>这个配置一开,省掉 80% 的字段映射烦恼。
3.2 用户登录与 openid 获取
登录接口是小程序端的第一个请求,也是所有业务请求的前提。流程是:小程序端调用 wx.login() 拿到 code,然后通过 wx.request 把 code 传到后端。后端拿到 code 后,调用微信的 jscode2session 接口换取 openid 和 session_key。
后端的核心代码大致是这样的:
@Service public class UserServiceImpl implements UserService { @Value("${wx.appid}") private String appid; @Value("${wx.secret}") private String secret; @Override public String wxLogin(String code) { String url = "https://api.weixin.qq.com/sns/jscode2session?appid=" + appid + "&secret=" + secret + "&js_code=" + code + "&grant_type=authorization_code"; String result = HttpUtil.get(url); // 发送 GET 请求 JSONObject json = JSONObject.parseObject(result); String openid = json.getString("openid"); User user = userMapper.findByOpenid(openid); if (user == null) { user = new User(); user.setOpenid(openid); user.setCreateTime(new Date()); userMapper.insert(user); } // 生成自定义 token String token = UUID.randomUUID().toString().replace("-", ""); redis.set(token, user.getId().toString(), 7 * 24 * 60 * 60); // 有效期7天 return token; } }这段代码里要特别注意两点。第一,wx.login 拿到的 code 是一次性的,用一次就失效,而且有效期很短,所以后端必须及时处理,不能把 code 存在数据库里等下回再用。第二,不要拿 session_key 直接当作登录态,更不要把它返回给前端存起来。session_key 是用来解密手机号、获取用户敏感信息的钥匙,用途不同。这里我们自己生成一个 UUID token,再把它和用户 id 关联存起来,前端后续每次请求带上这个 token,后端就认出是谁了。
实际开发中高并发场景建议把 token 存到 Redis 并设置过期时间,如果项目里没引入 Redis,也可以用一个内存 Map 临时管理,但系统重启后登录态会全部失效。如果只是想快速跑通流程,内存方案完全够用。
3.3 课程列表与分类筛选
课程列表接口是首页和分类页统一复用的关键接口。前端传参包括 categoryId、pageNum、pageSize,后端根据参数做条件查询并分页。
在 MyBatis 的 mapper XML 里,用动态 SQL 处理可选条件:
<select id="selectCourseList" resultType="com.example.educourse.entity.Course"> SELECT * FROM course WHERE status = 1 <if test="categoryId != null"> AND category_id = #{categoryId} </if> ORDER BY create_time DESC LIMIT #{offset}, #{pageSize} </select>LIMIT 的 offset 由后端计算,不要依赖前端传 pageNum 后直接拼到 SQL 里。通常在 service 层这样处理:
int offset = (pageNum - 1) * pageSize; List<Course> list = courseMapper.selectCourseList(categoryId, offset, pageSize);同时还要查总条数,用来计算总页数:
<select id="countCourseList" resultType="int"> SELECT COUNT(*) FROM course WHERE status = 1 <if test="categoryId != null"> AND category_id = #{categoryId} </if> </select>这里有个实战建议:把 list 集合的查询和 count 查询分两个 mapper 方法写,不要用一条 SQL 同时又查数据又查数量,那样维护起来麻烦,也不利于后期做缓存优化。
小程序端的渲染逻辑相对简单,用 wx.request 请求接口,拿到 list 后通过 wx:for 渲染课程卡片。滚动到底部时继续请求下一页,并把新数据 append 到当前列表末尾,实现上拉加载更多。每个课程卡片绑定课程 id,点击后跳转到详情页。
需要注意,列表页返回数据不要包含课程介绍这种大字段,只需要 id、标题、封面、价格、销量、讲师名等卡片展示需要的信息。课程详细介绍在详情接口里单独返回,能显著减小数据传输量。
3.4 订单创建与模拟支付
用户点击报名,前端把 courseId 传给后端。后端先校验当前用户登录态,再查课程是否存在且上架,然后判断用户是否已经买过该课程,买过就直接返回提示,不要再生成订单。这个防重复下单的判断非常关键,否则用户连点两次按钮,会生成两条重复订单,甚至出现支付后回了两次“我的课程”的操作。
创建订单的 service 逻辑可以这样组织:
@Transactional public Long createOrder(Long userId, Long courseId) { Course course = courseMapper.selectById(courseId); if (course == null || course.getStatus() != 1) { throw new RuntimeException("课程不存在或已下架"); } int cnt = userCourseMapper.countByUserIdAndCourseId(userId, courseId); if (cnt > 0) { throw new RuntimeException("您已购买过该课程"); } Order order = new Order(); order.setOrderNo(generateOrderNo()); order.setUserId(userId); order.setCourseId(courseId); order.setAmount(course.getPrice()); order.setStatus(0); // 待支付 order.setCreateTime(new Date()); orderMapper.insert(order); return order.getId(); }@Transactional 是必须加的,因为整个流程里存在多步写库操作,任何一步失败都应该回滚,不能出现订单创建了但用户课程写入失败这种脏数据。
订单号生成逻辑,我习惯这样写:
private String generateOrderNo() { SimpleDateFormat sdf = new SimpleDateFormat("yyyyMMddHHmmss"); String time = sdf.format(new Date()); int random = (int) ((Math.random() * 9 + 1) * 1000); return time + random; }日期取到秒,加上四位随机数,基本不会重复。如果担心极端情况,可以在 order_no 字段上建唯一索引,万一真撞上了,再重新生成一次。
模拟支付的接口就简单了,拿到订单号后,把订单状态改成已支付,同时把记录写到 user_course 表:
@Transactional public void pay(String orderNo, Long userId) { Order order = orderMapper.selectByOrderNo(orderNo); if (order == null || !order.getUserId().equals(userId)) { throw new RuntimeException("订单不存在"); } if (order.getStatus() == 1) { return; // 已经支付过,幂等处理 } order.setStatus(1); order.setPayTime(new Date()); orderMapper.updateStatus(order); UserCourse uc = new UserCourse(); uc.setUserId(userId); uc.setCourseId(order.getCourseId()); uc.setBuyTime(new Date()); userCourseMapper.insert(uc); }幂等处理是这里的一个重点。如果用户支付成功后网络抖动,前端重复点击支付按钮,后端要能识别出订单已经支付过了,直接返回成功,而不是再次写入 user_course 导致重复。上面的代码里,订单状态为 1 时直接 return,就是最简单的幂等方案。
如果是接入真实微信支付,后端会多一步:调用微信支付统一下单接口,拿到 prepay_id,返回给小程序端,小程序端再调起支付收银台。支付完成后,微信服务器会回调后端配置的回调地址,后端收到回调后做同样的订单状态修改操作。回调地址必须是公网可访问,本地开发时可以用内网穿透工具,但安全性要注意。整个流程比模拟支付复杂不少,如果不是必须接入真实支付,建议先用模拟支付把业务跑通。
3.5 学习进度记录
用户进入课程播放页后,小程序端的 video 组件持续播放。为了记录进度,前端可以监听视频的 timeupdate 事件,这个事件在播放过程中会频繁触发,如果每次都向后端发请求,会打出大量无意义请求。
实际处理办法是节流:每隔 5 秒或者 10 秒上报一次当前播放时间,或者只在暂停、退出页面时上报。比如:
onVideoTimeUpdate(e) { this.currentTime = e.detail.currentTime; } pauseAndReport() { wx.request({ url: baseUrl + '/api/study/progress', method: 'POST', header: { token: this.token }, data: { courseId: this.courseId, videoId: this.videoId, progress: Math.floor(this.currentTime) } }); }onHide 和 onUnload 生命周期里都调用 pauseAndReport,保证用户退出页面时进度能保存下来。后端收到请求后,按 user_id 和 video_id 去更新进度,记录不存在就插入,存在就更新:
<update id="updateProgress"> UPDATE study_record SET progress = #{progress}, update_time = NOW() WHERE user_id = #{userId} AND video_id = #{videoId} </update>更好的做法是先查后插或者用 INSERT ... ON DUPLICATE KEY UPDATE,但简单项目里先查后插就够了。关键点是进度字段用秒数存储,单位统一。前端拿到秒数后,在页面 load 时直接定位到对应位置:
this.videoContext.seek(progressFromServer);这样用户下次进来就能续播。深入想一层就会发现,学习进度上报其实是“写多读少”的场景,所以上报接口不用太复杂,但一定要在写库时加上唯一索引约束,避免同一个用户同一节课出现多条进度记录。
3.6 后台管理模块
后台管理如果做完整 Web 页面,工程量会大不少。很多课程设计项目里,后台管理只需要提供接口,配合一个简单管理页面即可。核心功能包含:管理员登录、课程列表及上下架操作、课程分类维护、订单查询、用户查询。
管理员登录和用户端登录分开设计。管理员表单独建,账号密码字段直接存数据库。密码不能明文存储,至少用 MD5 加盐,或者用 BCrypt。虽然课设项目里 MD5 常见,但我还是建议用 BCrypt,代码量差别不大,安全性高一个档次。
后台接口要校验管理员身份,可以在拦截器里统一处理。拦截器检查请求头中的 token,判断是否是管理员的 token,不是就直接返回 401。拦截器机制值得花时间理解,因为它是 SSM 项目里做登录态校验的标准姿势。
4. 常见问题与排查技巧实录
4.1 请求发不出去、报域名错误
这是小程序开发遇到最多的第一个坑。小程序为了安全,所有请求域名必须在微信公众平台配置到白名单里。开发阶段,可以在开发者工具里勾选“不校验合法域名”,真机调试则要打开调试模式。但要注意,这只是临时方案,一旦要发布正式版本,必须去后台配置 request 合法域名,域名还必须是 HTTPS,并且 ICP 备案。
排查请求问题时,我建议按以下顺序操作:
- 打开开发者工具的 Network 面板,确认请求是否真的发出,看请求头和响应状态码。
- 看后端控制台有没有收到请求日志。没收到,大概率是域名配置或网络问题;收到了但返回异常,就是后端代码问题。
- 看响应体结构,确认 Result 里的 code 和 msg,前端再决定是弹错误还是正常渲染。
另外一个容易踩的坑是跨域。小程序端请求后端接口,如果后端是本地跑在 8080 端口,小程序开发工具默认允许访问,但上线后域名不同,后端必须开启 CORS 跨域支持,否则请求会被浏览器拦截。SpringMVC 里用 @CrossOrigin 注解或者配置 CorsFilter 都能解决。
4.2 登录态丢失、token 解析失败
用户在使用过程中突然退出登录,或者某些接口返回 “未登录”,大多数情况不是微信登录失效,而是 token 传递或解析除了问题。
常见原因有:
- 前端每次请求没有带上 token,比如写死了某个 header 名,但后端拦截器读的是另一个 header 名。
- token 过期,Redis 里过期时间设太短。一般建议 7 天起步,在线教育类应用用户会频繁回来,太短影响体验。
- 后端服务重启,如果用的是内存 Map 存储 token,重启后所有 token 都失效。这种情况要么使用 Redis,要么接受重启后重新登录。
排查技巧:在后端拦截器里加日志,打印每个请求的 token 和最终解析结果。看到日志后问题基本一目了然。小程序端也要注意 wx.request 的 header 字段名严格一致,大小写都要匹配。
4.3 MyBatis 映射和 SQL 相关报错
SSM 项目里最多的报错集中在 MyBatis 这块,典型的有三类:
- XML 文件没被扫描到:pom.xml 里如果没有加入 resources 配置,src/main/java 下的 XML 文件不会被打包进 target 目录,运行时就报 Invalid bound statement。解决方法是把 mapper XML 放到 resources 目录下,并在 pom.xml 里配置 resource 包含。
- namespace 写错:mapper 接口的全限定名和 XML 的 namespace 必须完全一致,否则启动时会报绑定错误。
- resultType 写错:resultType 要写实体类的全限定名,或者配置 typeAliasesPackage 后才能写类名。查询结果字段对不上时,先检查驼峰映射有没有开启。
遇到这类问题,先不要慌着改代码,第一时间看控制台完整日志。MyBatis 报错一般都会把问题的 SQL 片段和原因一起打出来,顺着提示定位比瞎猜快得多。
4.4 视频加载失败、播放黑屏
在线课堂项目里视频是核心内容,播放失败对用户来说是致命体验问题。视频播放失败常见原因有三类:
- 视频 URL 不是 HTTPS:微信小程序正式环境中,video 组件要求资源地址为 HTTPS。开发时用 HTTP 的本地视频地址可以看,上线前必须替换。
- 防盗链限制:有些视频服务商设置了 Referer 检查,小程序播放器请求视频时 Referer 头不符合要求,就会拒绝返回内容。解决方法是换用支持自定义白名单的视频云服务,或者把视频转存到自己服务器/CDN。
- 压缩包单文件超限:小程序代码包限制主包 2MB,但视频文件不能打进包里。视频必须放在服务端或云存储上,小程序端只放视频地址。
还有一个业务层面的坑:用户没购买课程时,前端把视频按钮置灰,但如果你没有在后端做播放权限校验,懂技术的人完全可以绕过按钮直接请求视频地址。所以核心视频接口上一定要校验 user_course 表里有没有对应记录。
4.5 重复下单和订单状态不一致
用户连点两次“报名”按钮,或者支付回调重试,都会带来重复数据问题。解决方案前面提到过,核心是两步:
- 创建订单前查 user_course,判断用户是否已购。
- 支付接口做幂等,订单已支付就直接返回成功,不再重复写入用户课程关系。
订单状态不一致的问题,往往是事务没加,或者事务配置没生效。Spring 的事务默认只在抛出 RuntimeException 时回滚,如果你的代码里 catch 住了异常但没有重新抛出,事务就不会回滚。排查时可以打印事务管理器日志,确认方法确实被代理了。
5. 这套项目还能往哪些方向延伸
5.1 功能扩展
课程评论和评分。用户学完课程之后可以留言打分,评论关联 user_id 和 course_id,展示在课程详情页。这个功能不难,加一张表加两个接口的事,但对完成度提升很大,尤其答辩时能讲的点更多。
后台管理界面完善。如果只是接口,答辩的时候只能对着 Postman 演示。建议花两天时间做一个简单的 Vue 后台管理页面,课程上下架、订单查看,用现成的后台模板改改就行。哪怕样式普通,也会比纯 API 项目观感好很多。
数据统计。管理端增加一个首页仪表盘,展示注册用户数、课程总数、订单总金额、热门课程 Top10。这些统计用 SQL 聚合就能搞定,但能体现出你对数据可视化有思考。
引入 Redis 缓存。首页数据是典型的读多写少场景,用 Redis 缓存课程列表和轮播图,缓存更新策略可以选“课程增删改时主动清除对应缓存”。面试时聊到缓存,这会让你的项目有明显亮点。
5.2 服务端能力扩展
就算是 SSM 项目,也可以逐步向 Spring Boot 迁移。先保持业务逻辑不变,把 XML 配置改成 Spring Boot 自动配置,你会发现改完之后的代码量少很多,但底层原理你已经在 SSM 阶段掌握了,迁移过程本身也是学习。
学习进度和课程推荐也可以做得更聪明。比如记录用户观看完成率、停留时长,统计用户偏好分类,再在“推荐课程”里优先展示用户喜欢分类下的高销量课程。这一步不需要上复杂算法,一条 SQL 加一个排序规则就能做出效果。
如果未来想部署上线,后端打包成 jar/war 放到云服务器,小程序端在微信公众平台申请账号并配置合法域名,流程并不复杂。不要把部署想得太神秘,一台基础云服务器跑一个 HTTP 服务,再配一个 MySQL,这套项目就能稳定跑起来。
说句实在话,我经手过不少类似的项目,也见很多同学在“微信小程序 + SSM”这个组合上栽过跟头,回头复盘几乎都是同一个原因:前期设计没想清楚,直接上手写代码,结果写到中间发现表和接口对不上,来回改。如果你正在做这个选题,我最大的建议是老老实实先把表设计好、把接口清单列出来,哪怕花上两天都是值得的。流程理顺之后,后面的代码其实都是熟练活儿。
还有一个细节值得单独拿出来说:项目里所有对外暴露的接口,都要考虑“如果用户传入非法参数会怎样”。课程 id 传负数、pageNum 传 0、token 传一个乱码字符串,这些极端情况在你的接口里怎么响应,直接决定了系统健壮性。答辩时考官特别爱抽这种边界问题,与其到时候现想答案,不如写代码时就养成校验的习惯。
最后分享一个小技巧:项目文档里画一张清晰的系统架构图,把“小程序端 -> 后端接口 -> Service 层 -> Mapper 层 -> 数据库”的调用关系标出来,再在每张表旁边写好字段说明。等到答辩或者复盘的时候,这张图就是你介绍项目的骨架,照着它讲,逻辑性和完整度都会比临场发挥强很多。动手做吧,这套项目做完,你对整个前后端链路的感觉会和以前完全不一样。