简介:基于Java SSM框架与微信小程序开发的英语学习交流平台完整源码包,面向计算机专业学生的毕业设计、课程设计,以及初学Spring、SpringMVC、MyBatis和小程序开发的开发者。系统包含管理员与用户双角色,后台覆盖用户管理、每日打卡、备忘录、学习计划、学习资源、论坛交流等模块;小程序端提供个人中心、打卡、收藏、论坛等功能,前后台业务闭环完整。资源共1219个文件,压缩包约17.07MB,主要包含java后端代码、vue后台页面、js脚本、json配置、wxml/wxss小程序页面与样式、sql数据库脚本、png图片及安装运行批处理脚本等,覆盖从前端界面到后端接口再到数据库落地的完整技术栈。目前已有70人学习,特别适合希望快速理解SSM框架分层思想和小程序开发流程的读者。解压后按目录结构即可导入数据库、启动后端并运行小程序端,便于二次开发和功能扩展。
1. 英语学习交流平台小程序+SSM,打开了才看清的完整链路
拿到“英语学习交流平台小程序+ssm.zip”这个压缩包的人,通常分两类:一类是课程设计要交差的学生,另一类是工作几年想补一块微信小程序全链路经验的开发。文件后缀里的“ssm”指 Spring、SpringMVC、MyBatis 三件套,它不负责画界面,只负责出接口、管数据;“小程序”是微信小程序,负责页面和交互。两者拼起来就是一个最典型的单体前后端项目:小程序发请求,SSM 返回 JSON,页面渲染数据。这个组合的价值不在业务本身,而在于“用户点了一下屏幕,请求怎么穿过小程序、网关、Controller、Service、Mapper 落到 MySQL,再原路返回”那整条链路,值得拆开看一遍的不只学生,还有那些只写过纯后端接口、没碰过小程序的从业者。
2. 拆解 zip:SSM 后端在小程序项目里怎么分工
2.1 Spring、SpringMVC、MyBatis 三层的职责边界
SSM 在小程序后端里的分工,比在传统后台管理系统里更清晰,因为它不需要渲染页面,只需要吐 JSON。Spring 是容器,负责管理 Controller、Service、Mapper 这些 Bean 的创建和注入;SpringMVC 负责 HTTP 路由,把/api/word/today这样的 URL 映射到某个 Java 方法上;MyBatis 负责把方法调用翻译成 SQL,再把查询结果映射回 Java 对象。
小程序端不关心这三层怎么分,它只认 HTTP 状态码和 JSON 字段。因此 SSM 后端在设计时要遵守一个原则:Controller 层只做参数接收和结果包装,不写业务逻辑;Service 层只做业务编排,不碰 HttpServletRequest;Mapper 层只写数据访问,不回头调 Service。这个约束在单体小项目里看似多余,但 zip 里的项目一旦要加功能,比如从“每日单词”扩成“打卡日历”,层级混乱的代码会最先崩。
2.2 一份典型 zip 目录结构的读法
解压后先别急着跑,先看目录。常见做法是 Maven 工程,后端代码在src/main/java下按 controller、service、mapper 分层,src/main/resources里放着applicationContext.xml、spring-mvc.xml、mybatis-config.xml和 mapper 映射文件;前端小程序代码和 Java 后端混在同一个 zip 里,通常在miniprogram/或pages/目录下,和pom.xml平级。对照关系如下表:
| 目录/文件 | 作用 | 改的时候注意什么 |
|---|---|---|
controller/ | 接收小程序请求,返回 JSON | 路径要和前端wx.request的 url 完全一致 |
service/ | 业务逻辑,事务控制 | @Transactional只能加在 public 方法上 |
mapper/ | MyBatis 接口,和 XML 里的 id 对应 | 方法名不能乱改,XML namespace 要配套 |
resources/mapper/*.xml | SQL 语句 | 改了 SQL 要重新编译打包 |
miniprogram/pages/ | 小程序页面 | 每个页面是.js/.wxml/.wxss/.json四件套 |
miniprogram/utils/request.js | 封装wx.request | 统一在这里加 token、处理错误码 |
sql/或db/ | 建表脚本 | 先跑这个再启动后端,不然启动报错 |
2.3 小程序请求穿透 SSM 三层的完整路径
一个请求从wx.request发出,到数据库再返回,中间经过的标准路径是:小程序网络库 → SpringMVC 的DispatcherServlet→ 对应的@Controller方法 →@Service实现类 →@Mapper接口 → MyBatis 的 XML 里的 SQL → 数据库。下面是一个典型的 Controller 写法:
@RestController @RequestMapping("/api/word") public class WordController { @Autowired private WordService wordService; @GetMapping("/today") public Result<List<Word>> today(@RequestParam Integer userId) { List<Word> words = wordService.getTodayWords(userId); return Result.ok(words); } }这段代码里,@RestController表示这个类的所有方法返回值都会被 Jackson 序列化成 JSON,不需要再手动写@ResponseBody;@RequestMapping("/api/word")是类级前缀,配合方法上的@GetMapping("/today")后,完整路径是/api/word/today,小程序端必须原样拼接;@RequestParam声明参数来自 URL 查询串,小程序发请求时要以?userId=1的形式携带。
Service 层如果加了@Transactional,需要注意它只对 RuntimeException 回滚,对受检异常不生效。MyBatis 的 Mapper 接口和 XML 通过 namespace 绑定,XML 里id必须和接口方法名一致,parameterType和resultType的包路径写全,这三点是启动后报Invalid bound statement最常见的来源。
3. 建表与 MyBatis 映射:英语学习交流平台的核心数据模型
3.1 一张用户表,撑起登录、打卡、发帖三个场景
英语学习交流平台的业务可以拆成两条线:一条是“学习”,包括单词、打卡、学习记录;另一条是“交流”,包括帖子、评论、点赞。两条线都挂在用户底下。用户表的核心字段不是自增 ID,而是微信小程序的openid,因为微信登录返回给你的唯一标识就是它。
CREATE TABLE `user` ( `id` INT NOT NULL AUTO_INCREMENT, `openid` VARCHAR(64) NOT NULL, `nickname` VARCHAR(64) DEFAULT '', `avatar_url` VARCHAR(255) DEFAULT '', `created_at` DATETIME DEFAULT CURRENT_TIMESTAMP, PRIMARY KEY (`id`), UNIQUE KEY `uk_openid` (`openid`) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;openid必须加唯一索引,否则同一个微信号第二次登录时会插入两条记录,导致学习数据和发帖记录全乱。utf8mb4是为了存 Emoji,小程序昵称里经常带表情,utf8会报Incorrect string value。业务表设计时,凡是和用户关联的,都不直接存 openid,而是存user_id,理由很简单:openid 是微信侧的敏感标识,业务表到处引用它,将来要迁移或做数据隔离会非常痛苦。
3.2 学习记录表和帖子表:一对多关系的两种建模方式
学习记录表记录“某用户某天学了哪些词”,帖子表记录“某用户发了什么内容”。前者是典型的事实表,数据只增不改;后者是内容表,要频繁更新和删除。
CREATE TABLE `word_record` ( `id` INT NOT NULL AUTO_INCREMENT, `user_id` INT NOT NULL, `word` VARCHAR(64) NOT NULL, `status` TINYINT DEFAULT 0 COMMENT '0-未掌握 1-已掌握', `learned_at` DATETIME DEFAULT CURRENT_TIMESTAMP, PRIMARY KEY (`id`), KEY `idx_user_date` (`user_id`, `learned_at`) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; CREATE TABLE `post` ( `id` INT NOT NULL AUTO_INCREMENT, `user_id` INT NOT NULL, `title` VARCHAR(128) NOT NULL, `content` TEXT, `like_count` INT DEFAULT 0, `created_at` DATETIME DEFAULT CURRENT_TIMESTAMP, PRIMARY KEY (`id`), KEY `idx_user` (`user_id`) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;word_record的联合索引idx_user_date直接服务“今日打卡”“连续打卡天数”“历史学习曲线”三个查询,没有这个索引,用户学习次数一多,按时间范围查就会全表扫描。post表的like_count是冗余字段,每次点赞直接UPDATE post SET like_count = like_count + 1 WHERE id = ?,不要先查出来再写回去,否则并发点赞会互相覆盖。评论表结构类似post,多一个post_id外键,按帖子 ID 建索引。
3.3 写 MyBatis 映射文件时最容易翻车的两个点
英语学习交流平台的查询条件经常是组合式的,比如“查某用户 7 天内的已掌握单词”,用 MyBatis 动态 SQL 最常见,但也最容易写错:
<select id="listLearnedWords" resultType="com.example.entity.WordRecord"> SELECT * FROM word_record <where> <if test="userId != null"> AND user_id = #{userId} </if> <if test="days != null"> AND learned_at >= DATE_SUB(NOW(), INTERVAL #{days} DAY) </if> <if test="status != null"> AND status = #{status} </if> </where> ORDER BY learned_at DESC </select><where>标签会自动去掉第一个多余的AND,这是它比直接写WHERE 1=1更稳妥的原因。XML 中小于号>必须转义成>,小于号同理,不然 XML 解析直接报错。#{}是预编译占位符,生成?,不会 SQL 注入;${}是字符串拼接,表名、排序字段这种没法用占位符的地方才用它,用户传入的值绝对不能拼进${}。
4. 登录态与接口鉴权
4.1 微信登录的完整数据流与 token 生成
英语学习交流平台这类小程序,登录不能走传统的用户名密码,必须走微信的wx.login。流程是:小程序调用wx.login拿到临时code,把这个code发到自己的后端;后端拿着code加上小程序的appid和secret,请求微信的jscode2session接口换openid和session_key;拿到openid后查用户表,存在就返回登录成功,不存在就先注册再返回。
后端的核心方法可以这样写:
public LoginResult login(String code) { // 1. code 换 openid,正常情况下这里调微信 HTTP 接口 String openid = wechatService.code2Session(code); // 2. 查用户,没有就注册 User user = userMapper.findByOpenid(openid); if (user == null) { user = new User(); user.setOpenid(openid); userMapper.insert(user); } // 3. 生成 token,存入缓存,返回给小程序 String token = UUID.randomUUID().toString().replace("-", ""); redisTemplate.opsForValue().set("token:" + token, user.getId().toString(), 7, TimeUnit.DAYS); return new LoginResult(token, user); }code是一次性的,5 分钟内有效,用后即废,后端不能缓存它,每次登录都要让小程序重新wx.login。session_key不该返回给小程序,它在需要解密手机号、微信运动数据时才用得上,本项目如果没用到就留在后端。token 用 UUID 生成最简单,生产环境建议换成 JWT,但单体小项目用 Redis 存 token 已经足够,登录退出的状态管理更直观。
4.2 小程序端封装 wx.request,统一处理 token 和错误码
小程序不能像浏览器那样自动携带 Cookie,每个请求都要手动在 header 里塞Authorization,所以utils/request.js是必改文件,所有页面发请求都从它走:
const request = (url, method, data) => { return new Promise((resolve, reject) => { wx.request({ url: 'http://localhost:8080' + url, method: method || 'GET', data: data || {}, header: { 'Content-Type': 'application/json', 'Authorization': wx.getStorageSync('token') || '' }, success: (res) => { if (res.statusCode === 401) { wx.removeStorageSync('token'); wx.navigateTo({ url: '/pages/login/login' }); reject(res); return; } if (res.data && res.data.code === 0) { resolve(res.data.data); } else { wx.showToast({ title: res.data.msg || '请求失败', icon: 'none' }); reject(res); } }, fail: (err) => reject(err) }); }); }; module.exports = { request };封装后,页面里只要request('/api/word/today', 'GET', { userId: 1 })就能拿到数据。res.data.code === 0是约定的业务成功码,后端返回结构统一为{ code, msg, data },这样前端只认一个字段,不用每个接口都判断。401 分支专门处理 token 过期,过期的正确姿势是清掉本地 token 跳登录页,而不是弹一个干巴巴的“请重新登录”。
4.3 后端发一个拦截器,让受保护接口不再裸奔
封装请求只是前端做了防御,后端必须用拦截器把不受信任的请求挡在 Controller 之外。SSM 项目里最常见的是实现HandlerInterceptor:
public class AuthInterceptor implements HandlerInterceptor { @Override public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) throws Exception { // 登录接口本身放行 String uri = request.getRequestURI(); if (uri.contains("/api/auth/login")) { return true; } String token = request.getHeader("Authorization"); if (token == null || !tokenService.isValid(token)) { response.setStatus(401); response.setContentType("application/json;charset=UTF-8"); response.getWriter().write("{\"code\":401,\"msg\":\"token无效或已过期\"}"); return false; } // 把当前登录用户 ID 塞进 request,方便 Controller 取 request.setAttribute("userId", tokenService.getUserId(token)); return true; } }拦截器只放行登录接口和微信支付的回调接口,其余接口一律校验 token。校验通过后把userId塞进 request 属性,Controller 里用@RequestAttribute Integer userId就能拿到,省去每个接口手动解析 token 的重复代码。实际项目中遇到的“学生改一下小程序请求包就能看到别人的学习记录”这类漏洞,十有八九是漏了这层校验。
5. 把 zip 跑起来:部署、抓包与一个小程序端技巧
5.1 三个步骤让项目在本地跑通
第一步,用 Navicat 或命令行执行 zip 里的.sql建表脚本;第二步,把spring-mvc.xml或jdbc.properties里的数据库账号密码改成自己的,用 IDEA 打开 Maven 工程,等依赖下载完直接启动 Tomcat;第三步,用微信开发者工具导入miniprogram目录,把request.js里的 baseURL 改成http://localhost:8080。注意小程序默认不允许请求 HTTP 域名,开发时要在开发者工具里勾选“不校验合法域名”,否则网络请求直接被拦截。
5.2 联调排错时抓包看请求,比看代码更快
页面点了没反应,先别查代码,用抓包工具看请求到底发出去没有、返回了什么。小程序开发者工具自带 Network 面板,想看手机真机上的请求,可以用 Charles 或 Burp Suite 配代理抓 HTTPS 流量,核心是把 CA 证书装到手机并信任,然后把微信开发者工具或手机的代理指向抓包工具监听的端口。抓到请求后重点看三个信息:URL 路径是否和后端@RequestMapping拼出来的完全一致,请求头里的Authorization是否带上了,响应的 JSON 结构是不是前端约定的{ code, msg, data }三层。后端 500 就去翻 Tomcat 的catalina.out,报ClassNotFoundException是依赖没打进去,报Invalid bound statement是 mapper XML 没扫描到,报Communications link failure是数据库地址或密码错了。
5.3 动态设置小程序头部标题,一个细节值得单独说
英语类小程序经常会根据当前学习的单元动态改变导航栏文字,比如进入“四级高频词”页面时,标题要变成“高频词 · 第 3 天”。app.json里的navigationBarTitleText是静态配置,改它需要发版本,正确做法是用wx.setNavigationBarTitle:
onLoad(options) { wx.setNavigationBarTitle({ title: '高频词 · 第 ' + options.day + ' 天' }); }这个方法是异步的,官网文档里不保证调用后立即生效,所以最好放在onReady里调用,或者在onLoad调用后用wx.nextTick包一层。要注意options.day来自页面跳转参数,如果用户是直接扫码进入页面,参数不存在,标题会变成undefined,取值时要先判空再拼接。跳转到详情页时,在小程序里用wx.navigateTo传参,目标页onLoad里拿到的options就是 URL 上的 query 参数。
本文还有配套的精品资源,点击获取