简介:这份求职招聘系统代码包面向计算机专业毕业设计、课程设计及微信小程序与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 Result<T> { private Integer code; // 200 成功,401 未登录,500 业务失败 private String msg; private T data; public static <T> Result<T> ok(T data) { Result<T> r = new Result<>(); r.code = 200; r.msg = "ok"; r.data = data; return r; } public static <T> Result<T> fail(String msg) { Result<T> 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 PageBean<JobVO> page(Integer page, Integer size, String keyword, String city) { // 紧跟在 startPage 之后的第一个查询才会被分页 PageHelper.startPage(page, size); List<JobVO> list = jobMapper.selectByCondition(keyword, city); PageInfo<JobVO> info = new PageInfo<>(list); PageBean<JobVO> bean = new PageBean<>(); bean.setTotal(info.getTotal()); bean.setRows(info.getList()); return bean; } }配套的 XML 用动态 SQL 拼条件,注意status = 1是硬条件(只查在招的),不能被<if>包住:
<select id="selectByCondition" resultType="com.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 test="keyword != null and keyword != ''"> and (j.title like concat('%', #{keyword}, '%') or c.name like concat('%', #{keyword}, '%')) </if> <if test="city != null and city != ''"> and j.city = #{city} </if> order by j.create_time desc </select>left 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`) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; -- 投递表,唯一索引用于兜底防重复投递 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`) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;idx_city_status这个联合索引很关键。列表页的查询是where status = 1加上可选的city = ?,把区分度高的city放前面、固定的status放后面,走索引的概率更大。等数据量上到几万条,没有这个索引分页会明显变慢。导入初始数据后至少造三条 company、十条 job,不然小程序首页一片空白,看不出效果。
4.2 后端配置与 Tomcat 启动
改jdbc.properties,三个值对不上直接起不来:
jdbc.driver=com.mysql.cj.jdbc.Driver jdbc.url=jdbc:mysql://127.0.0.1:3306/job_recruit?useUnicode=true&characterEncoding=utf8mb4&serverTimezone=Asia/Shanghai&useSSL=false jdbc.username=root jdbc.password=你的密码serverTimezone=Asia/Shanghai这一项不加的话,JDK8 + MySQL 8 的组合下时间字段经常整体偏移八小时,而代码里看不出任何问题。characterEncoding=utf8mb4要跟建库时保持一致,否则中文会变成问号。
web.xml里要确认两处:contextConfigLocation指向classpath:spring/applicationContext.xml,DispatcherServlet的contextConfigLocation指向classpath:spring/spring-mvc.xml,两者的classpath:前缀不要丢。然后用 IDEA 配一个本地 Tomcat,Deployment 里选 war exploded,Application context 填/job-recruit,这个值必须和前端BASE_URL的后缀一致。
启动成功后在浏览器访问http://127.0.0.1:8080/job-recruit/api/job/list?page=1&size=10,能看到 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的<build><resources>里显式包含**/*.xml。如果有,就把namespace和id逐个字符对一遍,IDE 不会帮你检查这个。
5.3 上传的简历头像过一会儿就 404
现象是上传成功、页面也能显示,但重启一次 Tomcat 之后图片全没了。
原因是图片写到了项目部署目录下,比如webapp/upload/,war exploded 每次重启都会被重新解压覆盖,文件跟着一起没了。
解决是把上传目录挪到项目外,在jdbc.properties同级加一个upload.path=D:/data/upload,代码里读这个配置拼绝对路径。同时要在spring-mvc.xml里加一条静态资源映射,把/upload/**指到那个外部目录,否则前端拿不到图。生产环境更规范的做法是直接上对象存储,把返回的 URL 存库,本地只做开发调试用。
5.4 时间字段整体偏移八小时
现象是投递记录里显示的创建时间比实际早了或晚了八小时,数据库里用工具查又是对的。
原因是 JDBC 连接串少了serverTimezone参数,驱动用的是 UTC,Java 的Date序列化成 JSON 时又按 JVM 默认时区转了一遍,两次偏移叠加。
解决是三处对齐:连接串加serverTimezone=Asia/Shanghai,MySQL 服务端的time_zone设成+08:00,实体类上的Date字段加@JsonFormat(pattern = "yyyy-MM-dd HH:mm:ss", timezone = "GMT+8")。第三处最容易被忽略,很多项目就是连接串改对了但接口返回还是差八小时,问题就出在序列化这一层。
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-web+mybatis-spring-boot-starter |
| 启动方式 | 外置 Tomcat 部署 war | 内置 Tomcat,main方法直接跑 |
| 数据源 | jdbc.properties+dataSourceBean | application.yml自动装配 |
| 拦截器 | <mvc:interceptors> | WebMvcConfigurer#addInterceptors |
| Mapper 扫描 | 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?useUnicode=true&characterEncoding=utf8mb4&serverTimezone=Asia/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和时区这两处固定写法抄进配置里,再开始动手——这两个坑加起来浪费的时间,够我把整个首页重写一遍。希望帮到你。
本文还有配套的精品资源,点击获取