
简介基于SSM框架Spring、Spring MVC、MyBatis并结合Spring Boot开发的微信小程序实习生管理系统项目源码面向学习Java Web和小程序开发的高校学生、课程设计及毕业设计开发者也可供企业管理人员参考业务流程。前端采用微信小程序支持实习生信息登记、任务分配与跟踪、考勤时间管理、表现评价、实习报告提交以及实习生与导师/HR的在线互动后端通过SSMSpring Boot提供数据分析报告和通知公告发布功能形成完整的管理闭环。资源包约59.35MB包含Java后端工程、小程序前端代码及相关配置文件可直接导入开发工具二次学习与扩展。目前已有94人学习下载对于想掌握前后端分离架构、理解企业级实习生管理流程的开发者是一份具有实践参考价值的完整项目案例。1. 实习生管理系统为什么选 SSM 微信小程序 组合实习生管理系统在中小企业里是典型的内部业务系统需要管理岗位申请、考勤记录、导师评语和周报提交角色分成管理员、导师和实习生。选择微信小程序因为实习生和管理者都不用安装额外 App扫码就能进入微信里的消息提醒还能把待审事项直接推过去。后端选择 SSMSpring SpringMVC MyBatis则是一个偏稳的决策社区资料多、招人容易事务和依赖注入交给 SpringHTTP 接口由 SpringMVC 负责SQL 用 MyBatis 写在 XML 里审计起来直截了当。一个打包成 bootpf.zip 的项目打开后通常直接就是这套三层结构先别急着解压运行看清楚它的分层和配置再决定怎么改。2. SSM 后端骨架从数据库表设计到 REST 接口2.1 实习生管理系统需要哪些核心表主流程跑的是三张表用户表、实习期记录表、周报表。用户表里既存管理员也存导师和实习生角色用role字段区分不拆多张表因为业务逻辑简单拆了反而增加联查成本。实习期记录表存放岗位名称、部门、起止时间和状态关联用户表的主键。周报表则每天或者每周插入一条关联实习生用户和当次实习记录。用户表建表语句如下注意openid上建唯一索引CREATE TABLE user ( id bigint NOT NULL AUTO_INCREMENT, name varchar(50) NOT NULL DEFAULT , openid varchar(64) NOT NULL DEFAULT COMMENT 小程序登录后拿到的唯一标识, role tinyint NOT NULL DEFAULT 3 COMMENT 1管理员 2导师 3实习生, phone varchar(20) DEFAULT , create_time datetime DEFAULT CURRENT_TIMESTAMP, PRIMARY KEY (id), UNIQUE KEY uk_openid (openid) ) ENGINEInnoDB DEFAULT CHARSETutf8mb4;openid是微信小程序里识别用户身份的字段一个用户对着一个小程序只有唯一一个 openid所以做唯一键能防止重复注册。role用tinyint比字符串省空间但代码里要定义常量比如1 ADMIN, 2 MENTOR, 3 INTERN否则三个月后再接手的同事会在if (role 1)这种代码里迷路。create_time设置了默认值但更新操作不会自动维护后面我会在第 5 章里补一个 MyBatis 拦截器来统一处理。实习期记录表稍微复杂一点核心字段是实习生user_id、导师mentor_id、岗位信息、开始和结束日期、当前状态。字段名类型说明idbigint自增主键user_idbigint实习生用户ID外键指向 user.idmentor_idbigint导师用户ID外键指向 user.idpositionvarchar实习岗位比如 Java 开发start_datedate实习开始日期end_datedate实习结束日期默认空表示在职statustinyint1进行中 2已结束 3提前终止注意外键只是逻辑上存在实际建表时如果数据量大、插入频繁建议不加物理外键各表之间通过索引保证查询性能。这只是内部管理系统数据量通常几千条加不加都无所谓但从规范上讲不用物理外键更灵活。2.2 SpringMVC Controller 怎么写才能方便小程序调用小程序端大多数情况下只认 JSON所以 Controller 层不能像做网页服务端渲染那样返回 ModelAndView而是统一返回一个 Result 结构体里面包含 code、msg、data 三个字段。这样不管查询成功、参数错误还是 token 过期小程序端都能用同一段逻辑处理。RestController RequestMapping(/api/user) public class UserController { GetMapping(/list) public Result list(RequestParam(required false) String keyword, RequestParam(defaultValue 1) int page, RequestParam(defaultValue 10) int size) { PageResultUser result userService.search(keyword, page, size); return Result.ok(result); } }RestController表示这个类所有方法返回值都会被转成 JSON。keyword、page、size都是小程序端通过 URL 参数传过来的。required false的意思是keyword可以不传比如用户没有输入搜索词时直接请求第一页。defaultValue则保证了 page 和 size 一定有值避免后端出现空参数异常。Result 类里 code 字段的规则要提前约定好否则小程序端没法判断业务状态code含义0成功1001token 失效需要重新登录2001参数错误5000服务器内部异常这个状态码表不是固定的可以根据项目需求增删但一定要在项目文档里写清楚。常见错误是后端把 HTTP 状态码和业务状态码混在一起导致小程序端既要判断 HTTP 200又要判断业务 code容易漏掉分支。2.3 MyBatis 的 Mapper 层查询和动态 SQLController 调 ServiceService 调 Mapper。Mapper 接口只写方法签名真正的 SQL 写在 XML 文件里。以用户列表的模糊查询为例select idsearch resultTypecom.example.intern.entity.User SELECT id, name, phone, role, openid FROM user where if testkeyword ! null and keyword ! AND (name LIKE CONCAT(%, #{keyword}, %) OR phone LIKE CONCAT(%, #{keyword}, %)) /if /where ORDER BY id DESC LIMIT #{offset}, #{size} /selectwhere标签会自动处理掉第一个条件前面的AND避免出现WHERE AND name LIKE这种语法错误。LIKE查询使用CONCAT(%, #{keyword}, %)而不是在 Java 层拼好再传进来是为了防止 SQL 注入。LIMIT后面是offset和size注意这里不能直接用page * size要在 Service 层算好(page - 1) * size。MyBatis 的动态 SQL 是它比 JPA 更灵活的地方但也不要什么条件都往里塞。实习生管理系统里常用的动态标签就where、if、set、foreach这几个够用了。写 XML 时我给每条 SQL 都加上业务注释比如根据实习生姓名或手机号搜索用户方便后期对照页面需求。2.4 Spring 的事务注解和跨域配置Spring 的事务管理在 SSM 项目里用得很直白方法上标注Transactional即可。注意rollbackFor一定要显式声明否则遇到受检异常不会回滚。Transactional(rollbackFor Exception.class) public void assignInternship(InternshipRecord record) { internshipMapper.insert(record); // 这里做其他写操作比如发通知、更新实习生状态 userMapper.updateStatus(record.getUserId(), 1); }rollbackFor Exception.class的作用是告诉 Spring不管运行时异常还是受检异常只要 throws 出来就回滚。默认情况下 Spring 只对RuntimeException生效不加这个属性像IOException这类异常导致数据不一致时很难排查。配置跨域在小程序开发阶段也绕不开。微信开发者工具里可以勾选“不校验合法域名”但真机预览时仍然可能会被安全策略拦截。最省事的方案是在 SpringMVC 配置类里加一个跨域过滤器Configuration public class WebConfig implements WebMvcConfigurer { Override public void addCorsMappings(CorsRegistry registry) { registry.addMapping(/**) .allowedOriginPatterns(*) .allowedMethods(GET, POST, PUT, DELETE, OPTIONS) .allowedHeaders(*) .allowCredentials(true); } }记住allowCredentials(true)和allowedOriginPatterns(*)一起用时不能用allowedOrigins(*)否则浏览器会拒绝携带 cookie 的跨域请求。不过小程序本身不走 cookie 体系这个配置主要是为了以后如果有 H5 管理端也能直接调用同一套接口。3. 微信小程序端从登录到业务页面3.1 小程序目录结构怎么摆一个 SSM 后端对应的小程序页面数量通常不会太多常见的目录结构是├── app.js ├── app.json ├── utils/ │ ├── request.js │ └── util.js ├── pages/ │ ├── login/ │ ├── index/ // 实习记录列表 │ ├── profile/ // 个人中心 │ └── report/ // 周报提交app.js里放全局配置比如后端地址和用户信息。utils/request.js是统一封装的网络请求模块。每个页面一个文件夹微信小程序的页面结构固定是.wxml、.wxss、.js、.json四个文件。页面少可以不用components目录但如果多个页面都要用到顶部搜索框抽成组件是更合理的选择。3.2 wx.request 的封装统一处理 token 和错误码小程序原生网络请求写起来很冗余而且每个页面单独处理错误会非常啰嗦。我一般会封装一个 request 函数把 token 注入和错误提示统一处理掉// utils/request.js const API_BASE_URL http://localhost:8080/api; function request(path, method GET, data {}) { return new Promise((resolve, reject) { wx.request({ url: API_BASE_URL path, method, data, header: { Content-Type: application/json, Authorization: wx.getStorageSync(token) }, success: (res) { if (res.statusCode 200 res.data.code 0) { resolve(res.data.data); } else { wx.showToast({ title: res.data.msg || 请求失败, icon: none }); reject(new Error(res.data.msg || error)); } }, fail: (err) { reject(err); } }); }); } module.exports { request };这里有几个设计点token 从wx.getStorageSync读取如果用户未登录这个字段为空后端拦截器会返回 1001小程序端跳转到登录页。success回调里先检查状态码再检查业务 code网络层和业务层分开处理。如果只是网络断线fail会被触发这时候可以用wx.showToast提示网络异常。参数类型说明pathstring接口路径不需要带域名methodstringGET/POST/PUT/DELETEdataobject请求体GET 时会自动拼到 query 上3.3 登录流程wx.login 换 openid 的完整链路实习生系统的登录不需要手机号验证只要用户打开小程序先用wx.login拿到临时 code再把 code 发给后端后端用 code 换openid。这个流程在小程序端代码很短// app.js onLaunch() { wx.login({ success: (res) { const code res.code; wx.request({ url: http://localhost:8080/api/user/login, method: POST, data: { code }, success: (loginRes) { const token loginRes.data.data; wx.setStorageSync(token, token); } }); } }); }注意wx.login拿到的 code 五分钟内有效而且只能使用一次。后端拿到 code 后调用微信的jscode2session接口代码片段如下// 请求微信接口获取 openid String url https://api.weixin.qq.com/sns/jscode2session?appid appId secret appSecret js_code code grant_typeauthorization_code; String response restTemplate.getForObject(url, String.class); // 解析 JSON 得到 openid String openid jsonUtil.getField(response, openid);拿到 openid 后先查用户表如果已经存在就更新登录时间不存在就插入一条新用户然后生成一个自定义 token比如 JWT返回给小程序。自定义 token 只是为了让后端做登录态校验不需要直接用微信的 session_key。如果后续要用到解密手机号或者获取用户信息才需要保存 session_key。3.4 列表页的滚动加载小程序端如何做分页实习记录列表页是最常见的需求之一小程序自带的onReachBottom可以触发上拉加载下一页。分页逻辑要处理loading和finished两个状态避免重复请求// pages/index/index.js Page({ data: { list: [], page: 1, size: 10, loading: false, finished: false }, onLoad() { this.loadList(true); }, onReachBottom() { if (this.data.finished || this.data.loading) return; this.loadList(false); }, loadList(reset) { const page reset ? 1 : this.data.page 1; this.setData({ loading: true }); request(/internship/list?page${page}size${this.data.size}) .then(data { const newList reset ? data.list : this.data.list.concat(data.list); this.setData({ list: newList, page, loading: false, finished: page data.totalPage }); }) .catch(() this.setData({ loading: false })); } });reset等于true时是下拉刷新直接替换整个列表reset为false时则是追加下一页。finished用来标记是否已经没有了更多数据避免在最后一页反复请求。后端返回的totalPage字段必须由 Mapper 层用COUNT(*)算出来否则前端不知道什么时候到底。4. 打包与部署bootpf.zip 怎么跑起来4.1 war 包构建和 Tomcat 部署步骤bootpf.zip 解压后是一个标准的 Maven 工程。用 IntelliJ 打开后执行 Maven 打包命令跳过测试可以节省时间mvn clean package -DskipTests构建成功的标志是target目录下生成了xxx.war文件。传统的 SSM 项目一般打成 war 包丢给 Tomcat而不是用 Spring Boot 内嵌容器。将 war 复制到 Tomcat 安装目录的webapps/下然后启动bin/startup.shTomcat 启动后会自动解压 war 包并且以 war 的文件名作为访问路径。比如 war 包叫intern-manager.war那么访问地址是http://localhost:8080/intern-manager/如果不想要这个上下文路径直接把 war 改名为ROOT.war覆盖掉默认应用可以通过根路径访问但这会挤掉 Tomcat 自带的管理页面生产环境可以根据需求选择。部署完成后先用curl验证接口是否通别直接拿小程序调试curl http://localhost:8080/intern-manager/api/user/list?page1size10看到 JSON 返回就说明后端已经正常运作了。4.2 小程序端环境的配置小程序开发中最大的麻烦是环境切换。开发时用本机 localhost测试时用测试服务器上线用正式域名。这三种环境在后端地址上不一样写死在某个页面里就是灾难。常见的做法是在小程序根目录加一个config.js// config.js const config { development: { baseUrl: http://localhost:8080/intern-manager }, production: { baseUrl: https://api.example.com/intern-manager } }; module.exports config;然后在utils/request.js里根据小程序的运行环境自动选择const env wx.getAccountInfoSync().miniProgram.envVersion; const baseUrl env release ? config.production.baseUrl : config.development.baseUrl;envVersion的取值是develop、trial和release对应开发版、体验版和正式版。这样联调的时候不用改代码直接切换开发者工具右上角的版本就行。4.3 部署中容易踩的 3 个坑第一个坑是端口被占用。本机如果跑着别的服务Tomcat 默认的 8080 可能会冲突。修改 Tomcat 的conf/server.xmlConnector port8088 protocolHTTP/1.1 connectionTimeout20000 redirectPort8443 /改完必须重启 Tomcat 才会生效而且小程序端的baseUrl也要跟着改否则还是连不上。第二个坑是跨域问题。小程序请求本身不触发浏览器的跨域检查但在微信开发者工具中如果你勾选了“校验合法域名”本地访问 localhost 会报域名不合法。这个只在开发模式下开启真机正式版必须用备案的 HTTPS 域名并且在小程序后台配置 request 合法域名。第三个坑是 token 校验没有生效。如果后端用拦截器检查请求头里的 token而小程序端没有在wx.request里设置Authorization就会出现“请求成功但拿不到数据”。排查时打开开发者工具的 Network 面板确认请求头里有没有带上 token。5. 进阶两个提升开发效率的细节技巧5.1 用 MyBatis 拦截器自动填充创建时间和更新时间实习生管理系统的业务表基本都有create_time和update_time字段每次写插入和更新 SQL 都手工传这两个字段冗余且容易漏。MyBatis 提供了Interceptor机制可以统一处理。在src/main/java下新建一个拦截器Intercepts({ Signature(type Executor.class, method update, args {MappedStatement.class, Object.class}) }) public class TimeInterceptor implements Interceptor { Override public Object intercept(Invocation invocation) throws Throwable { Object parameter invocation.getArgs()[1]; if (parameter ! null) { Field[] fields parameter.getClass().getDeclaredFields(); for (Field field : fields) { if (createTime.equals(field.getName()) || updateTime.equals(field.getName())) { field.setAccessible(true); if (createTime.equals(field.getName())) { field.set(parameter, new Date()); } else { field.set(parameter, new Date()); } } } } return invocation.proceed(); } }然后在 MyBatis 配置里注册这个拦截器plugins plugin interceptorcom.example.intern.config.TimeInterceptor/ /plugins注意这里只处理实体对象如果参数是Map或者Integer就会直接跳过避免 ClassCast 异常。这个技巧对新人来说可能看起来有点 hack但它把公共逻辑收敛到一处后期如果要加“修改人”字段改这一个类就够了。5.2 微信小程序顶部导航栏高度适配很多实习生管理系统会在页面上方做自定义导航栏放一个标题和返回按钮。但不同机型的顶部状态栏高度不同iPhone 的刘海屏和普通安卓机差距很大。用wx.getWindowInfo()可以拿到状态栏高度动态计算导航栏高度const windowInfo wx.getWindowInfo(); const statusBarHeight windowInfo.statusBarHeight; const navBarHeight statusBarHeight 44; // 顶部文字高度在app.js里把这两个值存到globalDataApp({ globalData: { statusBarHeight: 0, navBarHeight: 0 }, onLaunch() { const info wx.getWindowInfo(); this.globalData.statusBarHeight info.statusBarHeight; this.globalData.navBarHeight info.statusBarHeight 44; } });页面在自定义导航栏时直接取app.globalData.navBarHeight设置占位高度就行。记住 44 这个值对应的是导航文字到内容区域的标准间距不同设计稿可能不一样最好用变量定义方便统一调整。5.3 验证接口耗时和 SQL 日志最后再提一个验证技巧。调接口慢先看是不是 SQL 问题。在 MyBatis 配置文件里开启 SQL 日志setting namelogImpl valueSLF4J/然后在log4j.properties里把 mapper 包的日志级别设为 DEBUGlog4j.logger.com.example.intern.mapperDEBUG这样每次请求都会打印 SQL 语句和执行时长。如果某个接口的 SQL 执行时间超过 200ms优先检查有没有为 where 条件里的字段建索引。实习生管理系统的核心查询都围绕user_id和role字段给这两个字段加上索引性能就能稳定不少。本文还有配套的精品资源点击获取