2026/10/30 2:48:25

SSM招聘微信小程序全链路:投递联调与SpringBoot迁移

SSM招聘微信小程序全链路:投递联调与SpringBoot迁移 简介这份求职招聘系统代码包面向计算机专业毕业设计、课程设计及微信小程序与Java后端学习者围绕校园兼职场景给出小程序客户端、Java服务器端与MySQL数据库的完整实现。系统区分求职者、商家和管理员三类身份求职者可微信授权登录、完善院系班级等信息、按分类筛选兼职、联系商户并预约面试商家经学校认证后发布岗位、处理面试请求双方还可互评评分后台另设客服与管理员功能。压缩包共1222个文件约4.09MB包含html、js、css、png、gif、json等前端与素材文件java、jsp、sql、properties、xml等后端与数据库相关文件以及wxss、wxml等小程序页面文件目录覆盖多端页面、样式、脚本和资源。已有312人学习适合按SSM结构阅读并参考其向SpringBoot升级的改造思路对需要完成同类题目的读者可据此梳理数据库实体、接口与页面联动关系减少从零搭建成本。1. 一套 SSM 后端的求职招聘小程序它到底能跑出什么业务上周有个做外包的朋友找我说客户要一个招聘类微信小程序预算不高但要求能演示、能上线、还得留出后续扩展的口子。他找来的这套项目正好对上前端是原生小程序后端是 Spring SpringMVC MyBatis 的 SSM 组合数据库 MySQL并且给出了升级 SpringBoot 的路径。核心业务不复杂——企业发职位、求职者维护简历、双方完成投递和查看但链路完整微信授权登录、职位分页检索、简历编辑、投递状态流转、企业端收件箱。适合两类人一类是要交课程设计或毕设、需要一套能跑通全流程的另一类是接手了老 SSM 项目、准备往 SpringBoot 上迁的。我把这套东西从建库到真机调试完整跑了一遍下面按能照着抄的顺序拆。2. 技术选型与工程结构SSM 分层到小程序页面的一张对照表2.1 为什么这套项目用 SSM 而不是一上来就 SpringBoot先说清楚一件事这不是技术落后是场景决定的。这类项目大多源自教学或早期外包运行环境往往是 JDK8 外置 Tomcat 7/8服务器上已经跑着别的 war 包不能随便动。SSM 那套 XML 装配虽然啰嗦但每一个 Bean、每一个数据源、每一个事务切面都写在明面上出了问题能一眼看到在哪配的。常见做法是三个配置文件分工applicationContext.xml管 Service 和事务、数据源spring-mvc.xml管 Controller 扫描、注解驱动、拦截器、视图解析mybatis-config.xml管别名、驼峰映射、分页插件。启动时web.xml里配ContextLoaderListener加载父容器DispatcherServlet加载子容器。这套结构最大的好处是可读——你顺着web.xml一路往下读整个应用的初始化顺序就全清楚了。代价也很明显加一个依赖要改 pom、加一段配置要改 XML、换个环境要改 properties重启一次 Tomcat 动辄二三十秒。所以项目标题里那句「可升级 SpringBoot」不是噱头是实际开发到中后期必然要走的一步。好消息是业务代码几乎不用动——Service、Autowired、Transactional、Mapper 接口这些在 SpringBoot 下原样能用要换的只是装配方式。这也是为什么我在第 6 章单独拿一节讲迁移因为迁移的成本几乎全在配置层业务层是白捡的。有一点要提前想明白如果你拿这套项目是为了快速上线一个真实产品那建议直接按第 6 章的路线先升到 SpringBoot 再开发业务省得后面一边写功能一边改配置。如果是为了学习或者交付演示SSM 原样跑更省事配置清晰出问题好定位。2.2 后端目录分层controller / service / mapper 各自管什么拿到源码先别急着跑把目录结构看一遍后面改代码才知道往哪放。这套项目的后端结构是典型的 Maven war 工程job-recruit/ ├── pom.xml └── src/main/ ├── java/com/demo/ │ ├── controller/ # 对外接口只做参数接收和结果包装 │ │ ├── UserController.java │ │ ├── JobController.java │ │ ├── ResumeController.java │ │ └── DeliveryController.java │ ├── service/ # 接口 impl业务规则都在 impl 里 │ ├── mapper/ # MyBatis 接口方法名必须和 XML 的 id 对上 │ ├── entity/ # 与数据库表一一对应 │ ├── vo/ # 联表查询结果比如 JobVO 带公司名 │ ├── dto/ # 前端传进来的参数对象 │ ├── util/ # Result、PageBean、WechatUtil、TokenUtil │ └── interceptor/ # 登录态拦截器 └── resources/ ├── mapper/ # 每个 Mapper 接口对应一个 XML ├── spring/ # applicationContext.xml、spring-mvc.xml ├── jdbc.properties └── log4j.properties分层职责要拎清楚不然改起来会乱。Controller 只干三件事接参数、调 Service、用统一格式返回绝对不要在 Controller 里写 if-else 业务判断。Service 层承担全部业务规则比如「职位已下架不能投递」「同一职位不能重复投递」并且这里是加Transactional的地方。Mapper 层只管 SQL复杂的联表查询写成 VO 接收别硬塞进 entity 里搞一堆Transient字段。统一返回体我一般这么写前后端约定好 code 语义前端拦截器统一处理public class ResultT { private Integer code; // 200 成功401 未登录500 业务失败 private String msg; private T data; public static T ResultT ok(T data) { ResultT r new Result(); r.code 200; r.msg ok; r.data data; return r; } public static T ResultT fail(String msg) { ResultT r new Result(); r.code 500; r.msg msg; return r; } // getter / setter 省略 }code的取值不要随手定200 和 401 要固定下来因为小程序端的请求封装会靠这两个值决定是弹提示还是跳登录页。这一点后面第 5 章会踩到。2.3 小程序端页面组织从首页职位流到个人中心小程序端是原生写法没有用 uni-app 之类的框架好处是体积小、调试直接、不依赖构建工具。页面按角色分成两套入口求职者和企业在「我的」页面切换身份。miniprogram/ ├── app.js # 全局存 token、openid、role ├── app.json # 页面注册、tabBar 配置 ├── utils/request.js # wx.request 统一封装 └── pages/ ├── index/ # 首页职位推荐流 ├── job/list/ # 搜索 筛选 ├── job/detail/ # 职位详情 投递按钮 ├── resume/edit/ # 简历编辑 ├── delivery/list/ # 投递记录 └── company/publish/ # 企业发职位请求封装是整套小程序里最值得先看的一个文件因为所有页面都依赖它// utils/request.js const BASE_URL http://127.0.0.1:8080/job-recruit; function request(options) { return new Promise((resolve, reject) { wx.request({ url: BASE_URL options.url, method: options.method || GET, data: options.data || {}, header: { content-type: application/json, // 登录后把 token 存在 storage每次请求带上 token: wx.getStorageSync(token) || }, success(res) { if (res.data.code 200) { resolve(res.data.data); } else if (res.data.code 401) { // 登录态失效清掉本地缓存重新授权 wx.removeStorageSync(token); wx.navigateTo({ url: /pages/login/login }); reject(res.data); } else { wx.showToast({ title: res.data.msg, icon: none }); reject(res.data); } }, fail: reject }); }); } module.exports { request };BASE_URL里的/job-recruit是 war 包名很多人第一次跑会在这一点上翻车——以为后端没起来其实是路径少了这一段。用微信开发者工具打开时还要在「详情 → 本地设置」里勾上「不校验合法域名、web-view业务域名、TLS 版本以及 HTTPS 证书」否则127.0.0.1这种地址直接会被拦掉。3. 核心链路拆解一次「投递简历」从点击到落库3.1 微信登录code 换 openid 只做一次小程序端调wx.login()拿到一个临时code后端拿code去换openid和session_key这是整套系统的身份基础。很多人会把session_key也返回给前端这是不合适的——它只应该留在服务端前端只需要一个自己签发的 token。Service public class UserServiceImpl implements UserService { Autowired private UserMapper userMapper; Override public String loginByCode(String code) { // 1. 用 code 换取 openid规避把会话密钥下发到前端 String resp WechatUtil.code2Session(code); JSONObject json JSONObject.parseObject(resp); String openid json.getString(openid); if (openid null) { throw new BizException(微信登录失败); } // 2. 首次登录自动注册后续直接查 User user userMapper.selectByOpenid(openid); if (user null) { user new User(); user.setOpenid(openid); user.setRole(1); // 1 求职者2 企业 user.setCreateTime(new Date()); userMapper.insert(user); } // 3. 自己签发 token存 userId 过期时间 return TokenUtil.create(user.getId()); } }这里有两个参数值得注意。role字段决定了后续所有权限判断企业身份需要额外补company_id所以注册企业时其实是「先建 user 再建 company回写 user.company_id」这两步要在同一个事务里。TokenUtil.create()我一般用 userId 时间戳拼起来做个签名放进 Redis 或者直接放进一个token表简单点也可以上 JWT但要注意别把openid塞进 payload泄露了等于把身份送人。3.2 职位列表分页PageHelper 动态 SQL职位列表是整个系统访问量最高的接口写法直接决定后面好不好扩展。项目里用的是 PageHelper 分页插件Service 层一行startPage就够了Mapper 只写查询条件Service public class JobServiceImpl implements JobService { Autowired private JobMapper jobMapper; Override public PageBeanJobVO page(Integer page, Integer size, String keyword, String city) { // 紧跟在 startPage 之后的第一个查询才会被分页 PageHelper.startPage(page, size); ListJobVO list jobMapper.selectByCondition(keyword, city); PageInfoJobVO info new PageInfo(list); PageBeanJobVO bean new PageBean(); bean.setTotal(info.getTotal()); bean.setRows(info.getList()); return bean; } }配套的 XML 用动态 SQL 拼条件注意status 1是硬条件只查在招的不能被if包住select idselectByCondition resultTypecom.demo.vo.JobVO select j.id, j.title, j.city, j.salary_min as salaryMin, j.salary_max as salaryMax, j.education, j.experience, j.create_time as createTime, c.name as companyName, c.logo as companyLogo from job j left join company c on j.company_id c.id where j.status 1 if testkeyword ! null and keyword ! and (j.title like concat(%, #{keyword}, %) or c.name like concat(%, #{keyword}, %)) /if if testcity ! null and city ! and j.city #{city} /if order by j.create_time desc /selectleft join而不是inner join是刻意的职位所属公司信息万一缺失职位本身还得能展示只是公司名显示为空。用inner join会导致数据不全时职位直接消失这种问题上线后排查起来非常费劲。另外like concat(%, #{keyword}, %)一定要用#{}不用${}${}是字符串直接拼接能注入。提示PageHelper 的startPage只对紧随其后的第一次查询生效。如果中间插了一句别的查询比如先查一下企业信息分页会失效total会是错误的值。血泪经验排查时先看这个。3.3 投递事务三个校验 一条记录投递是这套系统里唯一涉及状态判断的写操作链路是「校验职位状态 → 校验是否重复投递 → 校验简历是否完整 → 写投递记录」前三个任一失败都要整体回滚。Service public class DeliveryServiceImpl implements DeliveryService { Autowired private JobMapper jobMapper; Autowired private ResumeMapper resumeMapper; Autowired private DeliveryMapper deliveryMapper; Override Transactional(rollbackFor Exception.class) public void apply(Integer userId, Integer jobId, Integer resumeId) { // 1. 职位必须存在且在招 Job job jobMapper.selectById(jobId); if (job null || job.getStatus() ! 1) { throw new BizException(该职位已下架); } // 2. 同一用户对同一职位只能投一次 int cnt deliveryMapper.countByUserAndJob(userId, jobId); if (cnt 0) { throw new BizException(你已经投递过该职位); } // 3. 简历必须存在且属于当前用户 Resume resume resumeMapper.selectById(resumeId); if (resume null || !resume.getUserId().equals(userId)) { throw new BizException(简历不存在); } // 4. 落库状态 0 表示待查看 Delivery d new Delivery(); d.setUserId(userId); d.setJobId(jobId); d.setResumeId(resumeId); d.setStatus(0); d.setCreateTime(new Date()); deliveryMapper.insert(d); } }rollbackFor Exception.class不能省。Spring 默认只对RuntimeException和Error回滚如果哪天抛了个受检异常事务会静默提交数据就脏了。另外那个「重复投递」判断其实有更稳的做法在delivery表上加(user_id, job_id)唯一索引把并发下的漏网之鱼交给数据库兜底代码里的查询只是给用户一个友好提示。4. 本地跑通数据库、后端、小程序三件套的启动顺序4.1 建库建表与初始数据先建库字符集统一用utf8mb4不然用户昵称里的表情符号会直接报错。核心表不多把关联关系理清就行CREATE DATABASE IF NOT EXISTS job_recruit DEFAULT CHARSET utf8mb4; -- 职位表city status 是列表页最常用的组合条件 CREATE TABLE job ( id int NOT NULL AUTO_INCREMENT, company_id int NOT NULL, title varchar(64) NOT NULL, city varchar(32) DEFAULT NULL, salary_min int DEFAULT NULL, salary_max int DEFAULT NULL, education tinyint DEFAULT 1 COMMENT 1不限 2大专 3本科 4硕士, experience tinyint DEFAULT 1 COMMENT 1不限 2一年内 3一到三年 4三年以上, description text, status tinyint DEFAULT 1 COMMENT 0下架 1在招, create_time datetime DEFAULT CURRENT_TIMESTAMP, PRIMARY KEY (id), KEY idx_city_status (city, status) ) ENGINEInnoDB DEFAULT CHARSETutf8mb4; -- 投递表唯一索引用于兜底防重复投递 CREATE TABLE delivery ( id int NOT NULL AUTO_INCREMENT, user_id int NOT NULL, job_id int NOT NULL, resume_id int NOT NULL, status tinyint DEFAULT 0 COMMENT 0待查看 1已查看 2邀面试 3不合适, create_time datetime DEFAULT CURRENT_TIMESTAMP, PRIMARY KEY (id), UNIQUE KEY uk_user_job (user_id, job_id) ) ENGINEInnoDB DEFAULT CHARSETutf8mb4;idx_city_status这个联合索引很关键。列表页的查询是where status 1加上可选的city ?把区分度高的city放前面、固定的status放后面走索引的概率更大。等数据量上到几万条没有这个索引分页会明显变慢。导入初始数据后至少造三条 company、十条 job不然小程序首页一片空白看不出效果。4.2 后端配置与 Tomcat 启动改jdbc.properties三个值对不上直接起不来jdbc.drivercom.mysql.cj.jdbc.Driver jdbc.urljdbc:mysql://127.0.0.1:3306/job_recruit?useUnicodetruecharacterEncodingutf8mb4serverTimezoneAsia/ShanghaiuseSSLfalse jdbc.usernameroot jdbc.password你的密码serverTimezoneAsia/Shanghai这一项不加的话JDK8 MySQL 8 的组合下时间字段经常整体偏移八小时而代码里看不出任何问题。characterEncodingutf8mb4要跟建库时保持一致否则中文会变成问号。web.xml里要确认两处contextConfigLocation指向classpath:spring/applicationContext.xmlDispatcherServlet的contextConfigLocation指向classpath:spring/spring-mvc.xml两者的classpath:前缀不要丢。然后用 IDEA 配一个本地 TomcatDeployment 里选 war explodedApplication context 填/job-recruit这个值必须和前端BASE_URL的后缀一致。启动成功后在浏览器访问http://127.0.0.1:8080/job-recruit/api/job/list?page1size10能看到 JSON 就说明后端这条链路通了。如果报 404先看日志里 DispatcherServlet 有没有初始化成功如果报 500 且堆栈里是 SQL 相关多半是mapper-locations没扫到 XML。4.3 小程序端改配置、过域名校验改完utils/request.js里的BASE_URL还要打开project.config.json确认appid是你自己的或者用测试号不然后续wx.login拿不到code。真机调试时手机和电脑必须在同一局域网127.0.0.1要换成电脑的局域网 IP比如192.168.1.x同时电脑防火墙要放行 8080。调试顺序建议这样走先在开发者工具里跑通首页列表再点进详情页确认数据能传过去然后走一遍登录和投递最后再看个人中心。别一上来就测投递因为投递链路依赖登录态和简历前面任何一环没通都会卡在这里排查范围一下扩大三倍。5. 联调排查这套结构最容易翻车的五个地方5.1 请求报「不在以下 request 合法域名列表中」现象是小程序里所有请求直接 fail控制台打出一条关于合法域名的提示但接口用浏览器访问是正常的。原因是微信客户端对wx.request的域名有强制校验只认备案过的 HTTPS 域名http://和 IP 地址一律不放行——这跟后端有没有开 CORS 完全是两回事很多人会先去后端加跨域头白折腾。解决分两步开发阶段在微信开发者工具「详情 → 本地设置」里勾上「不校验合法域名」同时把BASE_URL改成局域网 IP上线阶段必须在后台上报域名、配好证书并且是 443 端口。真机预览时「不校验」这个开关也可能不生效最稳的做法是把手机连上和电脑相同的 Wi-Fi然后用调试基础库的「开发版」模式跑。5.2 启动报 Invalid bound statement (not found)现象是项目能起来但一调某个接口就抛这个异常堆栈指向 Mapper 代理对象。原因几乎全是 Mapper 接口和 XML 没对上具体有三种spring-mvc.xml或applicationContext.xml里的mapperLocations路径写的是classpath:mapper/*.xml但文件实际放在别处接口的全限定名和 XML 里的namespace不匹配方法名和select id对不上或者漏写了resultType。解决方式是先确认编译后target/classes/mapper/目录下有没有对应的 XML。如果没有说明 Maven 没把它打进 resources要在pom.xml的buildresources里显式包含**/*.xml。如果有就把namespace和id逐个字符对一遍IDE 不会帮你检查这个。5.3 上传的简历头像过一会儿就 404现象是上传成功、页面也能显示但重启一次 Tomcat 之后图片全没了。原因是图片写到了项目部署目录下比如webapp/upload/war exploded 每次重启都会被重新解压覆盖文件跟着一起没了。解决是把上传目录挪到项目外在jdbc.properties同级加一个upload.pathD:/data/upload代码里读这个配置拼绝对路径。同时要在spring-mvc.xml里加一条静态资源映射把/upload/**指到那个外部目录否则前端拿不到图。生产环境更规范的做法是直接上对象存储把返回的 URL 存库本地只做开发调试用。5.4 时间字段整体偏移八小时现象是投递记录里显示的创建时间比实际早了或晚了八小时数据库里用工具查又是对的。原因是 JDBC 连接串少了serverTimezone参数驱动用的是 UTCJava 的Date序列化成 JSON 时又按 JVM 默认时区转了一遍两次偏移叠加。解决是三处对齐连接串加serverTimezoneAsia/ShanghaiMySQL 服务端的time_zone设成08:00实体类上的Date字段加JsonFormat(pattern yyyy-MM-dd HH:mm:ss, timezone GMT8)。第三处最容易被忽略很多项目就是连接串改对了但接口返回还是差八小时问题就出在序列化这一层。5.5 登录接口也被拦截器拦了一直返回 401现象是小程序点登录没反应或者一进首页就跳登录页但账号密码明明是对的。原因是拦截器addPathPatterns(/api/**)把登录接口也圈进去了而登录接口本身没有 token自然过不了校验形成死循环。解决方法是在spring-mvc.xml的mvc:exclude-mapping里把/api/user/login、/api/job/list、/api/job/detail/**这几个白名单路径排掉。这个坑的隐蔽之处在于拦截器里打的日志和正常失败长得一模一样不打印请求 URI 根本看不出来。我一般会在拦截器里把request.getRequestURI()直接打进日志出问题一眼定位。6. 二次开发把 SSM 平滑搬到 SpringBoot 的改造清单迁移这件事听着吓人实际改动集中在四个文件。我把最小改造集列成表照着做基本半小时能跑起来改造点SSM 原状SpringBoot 之后依赖spring-webmvc、mybatis、druid 逐个声明spring-boot-starter-webmybatis-spring-boot-starter启动方式外置 Tomcat 部署 war内置 Tomcatmain方法直接跑数据源jdbc.propertiesdataSourceBeanapplication.yml自动装配拦截器mvc:interceptorsWebMvcConfigurer#addInterceptorsMapper 扫描MapperScannerConfigurer主类加MapperScan主类就三行MapperScan一定要加否则所有 Mapper 都注入不进去SpringBootApplication MapperScan(com.demo.mapper) public class Application { public static void main(String[] args) { SpringApplication.run(Application.class, args); } }application.yml里把原来的 properties 平移过来注意mapper-locations的路径前缀从classpath:改成classpath*:war 转 jar 之后资源加载方式变了前者会扫不到spring: datasource: driver-class-name: com.mysql.cj.jdbc.Driver url: jdbc:mysql://127.0.0.1:3306/job_recruit?useUnicodetruecharacterEncodingutf8mb4serverTimezoneAsia/Shanghai username: root password: 你的密码 mybatis: mapper-locations: classpath*:mapper/*.xml type-aliases-package: com.demo.entity configuration: map-underscore-to-camel-case: true验证方式很简单先启动主类看日志里有没有Tomcat started on port(s): 8080再用原地址访问一遍/api/job/list返回结构不变就说明 Controller 和 Service 那层没受影响最后把小程序BASE_URL后面的 war 包名去掉走一遍完整投递流程。这套顺序验证下来业务代码基本零改动。从那以后我每次接这类老项目第一件事不是写功能而是先把mapper-locations和时区这两处固定写法抄进配置里再开始动手——这两个坑加起来浪费的时间够我把整个首页重写一遍。希望帮到你。本文还有配套的精品资源点击获取