简介:面向课程设计、毕业设计与技术练手场景的微信小程序求职招聘系统源码包,采用小程序客户端、Java服务端与MySQL数据库组合,后台为SSM结构并可按需升级SpringBoot。系统划分求职者、商家和管理员三类身份:求职者可微信授权登录、完善个人信息、按分类筛选兼职、联系商户并预约面试;商家经认证后发布岗位、接收面试请求;管理员在Web端维护数据,并配套评分互评与客服答疑模块。包内共1207个文件,约4.07MB,涵盖js、json、wxml、wxss等小程序文件,java、jsp、sql等服务端与数据库脚本,以及html、css、png、gif等页面和静态素材,目录覆盖前后端与资源文件。已有220人学习,适合需要完整业务闭环、角色权限与小程序页面结构参考的读者研读和二次开发。
1. 从一次实际开发说起:这套小程序求职招聘系统到底能解决什么
去年有个朋友做本地招聘,后台需求很朴素:求职者用微信小程序刷职位、投简历,企业端发岗位、看投递,管理员审核信息。他一开始想直接上微服务,我劝住了——这个体量的求职招聘系统,微信小程序配一套 SSM 后端完全跑得动,等真要做大,再从 SSM 升级到 Spring Boot 也就是换一层外壳的事。这个项目就按这个思路搭:小程序负责展示和交互,后端用 Spring + SpringMVC + MyBatis 出接口,MySQL 存数据,Maven 管依赖,打成 war 丢进 Tomcat 就能启动。它适合两类人:一类是要交课程设计或毕设,需要一个链路讲得清楚的完整系统;另一类是手里有类似业务,想拿一套能跑的骨架练手、后面准备迁到 Spring Boot 的开发者。
2. 整体架构与技术选型:小程序端和 SSM 后端各自负责什么
2.1 为什么先上 SSM,而不是一上来就 Spring Boot
先说结论:这套后端选 SSM,不是因为 Spring Boot 不好,而是因为 SSM 的分层更显式,出了问题能顺着配置文件一层层往下找。很多课程设计场景里,答辩时要讲清楚「请求怎么进到 Controller」「事务在哪配的」「Mapper 怎么被扫描到的」,SSM 的web.xml、spring-mvc.xml、applicationContext.xml三件套反而比 Spring Boot 的自动配置更好讲。
具体到依赖,后端就是这几块:
| 层次 | 技术 | 版本说明 |
|---|---|---|
| 容器 | Tomcat | 外部容器,war 包部署 |
| Web 层 | Spring MVC | 注解式 Controller,@RestController |
| 业务层 | Spring | IOC + AOP 声明式事务 |
| 持久层 | MyBatis + PageHelper | XML 写 SQL,插件做分页 |
| 数据库 | MySQL | utf8mb4,InnoDB |
| 缓存/会话 | Redis | 存 token 到 userId 的映射 |
| 构建 | Maven | war 打包 |
小程序端不引入任何框架,原生 WXML/WXSS/JS,只做两件事:调接口、渲染页面。这不是偷懒,小程序包体积和首屏启动时间很敏感,一个 200KB 的 UI 库换来的组件复用,在这个项目里不值。
选型上真正需要注意的是「跨域」和「登录态」这两个交界处。小程序不走浏览器那套 Cookie 同源策略,wx.request里没有 Cookie 自动携带,所以后端不能依赖HttpSession,得改成「登录换 token、请求带 token、拦截器校验」这一套。这个决定会影响后面所有接口的写法,必须在动手写第一个 Controller 之前定下来。
2.2 三层结构怎么切:Controller、Service、Mapper 的边界
很多 SSM 项目写崩,不是因为技术不会,而是三层职责糊了:Controller 里写 SQL、Service 里拼 JSON、Mapper 里做业务判断。这个项目的切法是:
- Controller 只做三件事:接参数、校验、把
Result返回。不碰数据库,不写if业务分支。 - Service 是业务边界:事务开在这一层,一个业务动作对应一个方法,比如「投递简历」包含「校验职位状态 → 校验重复投递 → 插入记录 → 职位投递数 +1」,这四步必须在一个事务里。
- Mapper 只负责 SQL:一个方法对应一条 SQL,复杂查询用 XML 写,别用注解拼字符串。
对应的统一返回体,小程序端只认code / msg / data三个字段:
package com.example.job.common; import java.io.Serializable; /** * 统一返回体 * code: 200 成功 / 401 未登录 / 500 业务异常 * data: 具体业务数据,泛型,列表接口放 PageInfo,详情接口放实体 */ public class Result<T> implements Serializable { private Integer code; 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(Integer code, String msg) { Result<T> r = new Result<>(); r.code = code; r.msg = msg; return r; } // getter / setter 省略 }data用泛型而不是直接返回Object,是为了让小程序端拿到的结构稳定——列表接口永远是{ total, list, pageNum, pageSize },详情接口永远是实体字段。业务异常不抛到前端,统一用code=500+ 中文msg兜住,小程序端拿到msg直接wx.showToast,省掉一层错误码翻译。
Controller 的写法就很薄了:
@RestController @RequestMapping("/api/job") public class JobController { @Autowired private JobService jobService; /** * 职位列表 * @param page 页码,从 1 开始,默认 1 * @param pageSize 每页条数,默认 10,前端下拉刷新时传 10 * @param keyword 搜索关键词,匹配职位名或公司名,可为空 * @param categoryId 分类 ID,可为空 */ @RequestMapping(value = "/list", method = RequestMethod.GET) public Result<PageInfo<JobVO>> list(@RequestParam(defaultValue = "1") Integer page, @RequestParam(defaultValue = "10") Integer pageSize, @RequestParam(required = false) String keyword, @RequestParam(required = false) Integer categoryId) { return Result.ok(jobService.pageQuery(page, pageSize, keyword, categoryId)); } }注意page默认值给了 1 而不是 0,因为 PageHelper 的PageHelper.startPage(pageNum, pageSize)里pageNum是从 1 开始的,如果前端传 0,PageHelper 会当成「不做分页」直接查全表——数据量一上来就是一次慢查询,这是新手特别容易犯的错。
2.3 登录态怎么设计:为什么放弃 Session
小程序端拿到code之后发给后端,后端调微信的jscode2session换openid,这中间没有浏览器,也没有 Cookie。用HttpSession也能跑,但会踩两个坑:一是 App 和小程序如果共用一个后端,Session 不通用;二是 Tomcat 集群部署时 Session 不共享,得额外配广播。
这个项目用的是 Redis + token:登录成功后生成一个 UUID 当 token,login:token:{token}存 userId,有效期 7 天,每次请求在拦截器里校验并续期。好处是 token 天然可跨端、可横向扩容,坏处是多依赖一个 Redis——但对招聘这类需要长期登录态的应用,这个依赖值得。
3. 目录结构与数据库表:动手前先看清骨架
3.1 Maven 工程的目录长什么样
这套后端是标准的 Maven war 工程,没有拆多模块。拆模块在多人协作时有用,但一个人写课设拆成job-api、job-service、job-dao三个模块,改一行代码要mvn install三次,纯粹给自己添堵。目录结构如下:
job-recruit/ ├── pom.xml ├── src/main/java/com/example/job/ │ ├── common/ Result、PageInfo、BizException、常量 │ ├── config/ RedisConfig、WebMvcConfig、MyBatisConfig │ ├── controller/ JobController、UserController、DeliveryController、CompanyController │ ├── service/ 接口 + impl 实现 │ ├── mapper/ MyBatis Mapper 接口 │ ├── entity/ 与表一一对应的实体 │ ├── vo/ 返回给前端的视图对象,如 JobVO │ ├── dto/ 接收前端参数,如 LoginDTO │ └── interceptor/ LoginInterceptor ├── src/main/resources/ │ ├── mapper/ *.xml,与 Mapper 接口同名 │ ├── spring/ applicationContext.xml、spring-mvc.xml │ ├── jdbc.properties │ └── log4j.properties └── src/main/webapp/ ├── WEB-INF/web.xml └── upload/ 简历附件、公司 logo 的本地存储目录entity和vo分开是硬规矩。比如t_job表里只有company_id,但列表页要显示公司名和 logo,如果直接把companyName塞进Job实体,后面做增删改的时候字段对不上表结构,维护起来很难受。用JobVO承载联表结果,实体只对应表字段,这条线一旦划清,后面加字段不用改老代码。
upload目录放在webapp下也是权衡后的选择。放本地磁盘的好处是不用引 OSS SDK、不花钱、调试时能直接看到文件;坏处是 Tomcat 重新部署 war 包会把这个目录清掉。稳妥做法是在catalina.properties里配一个外部路径,或者部署时把upload挂成软链接,这一点后面避坑章节还会提。
3.2 六张核心表与字段取舍
整个系统的数据模型围绕「人、岗、投递」三条线展开,去掉日志和配置表,核心就六张:
| 表名 | 作用 | 关键字段 |
|---|---|---|
t_user | 求职者 | openid、nickname、phone、avatar |
t_company | 企业/HR | account、password、company_name、status |
t_job | 职位 | company_id、title、salary_min/max、status |
t_resume | 简历 | user_id、real_name、education、attachment |
t_delivery | 投递记录 | job_id、user_id、resume_id、status |
t_category | 职位分类 | name、sort |
t_job的建表语句:
CREATE TABLE `t_job` ( `id` bigint(20) NOT NULL AUTO_INCREMENT, `company_id` bigint(20) NOT NULL COMMENT '企业ID,关联 t_company', `title` varchar(64) NOT NULL COMMENT '职位名称', `category_id` int(11) NOT NULL COMMENT '职位分类ID', `salary_min` int(11) DEFAULT NULL COMMENT '月薪下限,单位K', `salary_max` int(11) DEFAULT NULL COMMENT '月薪上限,单位K', `city` varchar(32) DEFAULT NULL COMMENT '工作城市', `education` tinyint(4) DEFAULT '0' COMMENT '0不限 1大专 2本科 3硕士', `description` text COMMENT '职位描述', `apply_count` int(11) DEFAULT '0' COMMENT '投递人数,冗余字段', `status` tinyint(4) DEFAULT '1' COMMENT '1在招 0下线', `create_time` datetime DEFAULT CURRENT_TIMESTAMP, `update_time` datetime DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, PRIMARY KEY (`id`), KEY `idx_category_city` (`category_id`,`city`), KEY `idx_company` (`company_id`) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='职位表';几个刻意的设计:
salary_min/salary_max用两个 int 存「K」为单位,不用字符串存「10-15K」。字符串存薪资,前端筛选「15K 以上」就得在 SQL 里做字符串切割,索引直接失效。拆成两个数值字段,WHERE salary_min >= 15就能走索引。
apply_count是冗余字段,正常应该COUNT(t_delivery)。但列表页每条职位都要显示投递数,十个职位就是十次子查询。冗余一个计数列,投递时 +1,列表直接读,代价是偶尔可能不准——招聘场景对投递数的实时性要求没那么高,这个取舍划算。
t_delivery上必须有唯一索引:
CREATE TABLE `t_delivery` ( `id` bigint(20) NOT NULL AUTO_INCREMENT, `job_id` bigint(20) NOT NULL, `user_id` bigint(20) NOT NULL, `resume_id` bigint(20) NOT NULL, `status` tinyint(4) 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 COMMENT='投递记录表';uk_user_job这个唯一索引是防重复投递的最后一道闸。代码里会先count一次再插入,但这是「查完再写」,两个请求同时进来都能查到 0,然后都插入成功。有唯一索引在,第二次插入直接抛DuplicateKeyException,捕获后返回「已投递过」,这是唯一能真正兜底的方案。
3.3 配置文件的关键项:jdbc.properties 与 spring-mvc.xml
SSM 的配置文件多,但真正容易配错的不超过十行。jdbc.properties:
jdbc.driver=com.mysql.cj.jdbc.Driver jdbc.url=jdbc:mysql://127.0.0.1:3306/job_db?useUnicode=true&characterEncoding=utf8&serverTimezone=Asia/Shanghai&useSSL=false jdbc.username=root jdbc.password=123456serverTimezone=Asia/Shanghai不写会怎样?MySQL 8 的驱动如果服务端时区和 JVM 时区不一致,datetime字段读出来会差 8 小时,投递时间显示成「8 小时前」都算好的,严重的时候日期直接跨天。这是血泪经验,配置里宁可多写一个参数。
useSSL=false是本地开发的省事写法,生产环境要换成true并配证书,否则驱动会打一堆警告。
spring-mvc.xml里最关键的是拦截器放行规则:
<mvc:interceptors> <mvc:interceptor> <!-- 拦截所有 api 请求 --> <mvc:mapping path="/api/**"/> <!-- 这些接口不登录也能访问:登录、职位列表、职位详情、分类 --> <mvc:exclude-mapping path="/api/user/login"/> <mvc:exclude-mapping path="/api/job/list"/> <mvc:exclude-mapping path="/api/job/detail/**"/> <mvc:exclude-mapping path="/api/category/**"/> <bean class="com.example.job.interceptor.LoginInterceptor"/> </mvc:interceptor> </mvc:interceptors>exclude-mapping的顺序有讲究,/api/job/list必须在/api/job/**之前写好,或者干脆只写精确路径。如果写成/api/job/**放行,那/api/job/apply这种需要登录的接口也会被漏掉,等于登录校验形同虚设。这种漏洞在自测时很难发现,因为页面看起来都正常,只有抓包才能看出来。
4. 核心链路拆解:登录、职位列表、简历投递三件事
4.1 小程序登录:从 wx.login 到 token 落库
登录这条链路上有两个容易误解的点,先讲清楚:wx.login拿到的code只能用一次,五分钟过期;openid是用户在这个小程序里的唯一标识,不是微信账号 ID,换个小程序就变了。
小程序端先封装一层request,把 token 和错误处理收敛掉:
// utils/request.js const BASE_URL = 'http://localhost:8080/job-api'; // 上线换成 https 域名,小程序要求 https 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': wx.getStorageSync('token') || '' // 登录后写入本地缓存 }, success(res) { const body = res.data; if (body.code === 200) { resolve(body.data); } else if (body.code === 401) { // token 失效:清掉本地缓存,跳登录页,不要反复弹 toast wx.removeStorageSync('token'); wx.navigateTo({ url: '/pages/login/login' }); reject(body); } else { wx.showToast({ title: body.msg || '请求失败', icon: 'none' }); reject(body); } }, fail(err) { wx.showToast({ title: '网络异常,请稍后重试', icon: 'none' }); reject(err); } }); }); } module.exports = { request };这层封装的作用是:业务页面里只需要request({ url: '/job/list', data: { page: 1 } }),不用每个页面都写一遍 header 和错误分支。401的处理集中在这里,避免十几个页面各写一套跳转逻辑,改起来很痛苦。
后端登录接口:
@RestController @RequestMapping("/api/user") public class UserController { @Value("${wx.appid}") private String appId; @Value("${wx.secret}") private String appSecret; @Autowired private UserService userService; @Autowired private StringRedisTemplate redisTemplate; /** * 小程序登录 * @param dto 只含一个 code 字段,前端 wx.login 拿到的临时凭证 * @return token + userId,token 由前端存本地缓存 */ @PostMapping("/login") public Result<LoginVO> login(@RequestBody LoginDTO dto) { // 1. code 换 openid,appid/secret 从配置文件读,不能写死在代码里 String url = "https://api.weixin.qq.com/sns/jscode2session" + "?appid=" + appId + "&secret=" + appSecret + "&js_code=" + dto.getCode() + "&grant_type=authorization_code"; String resp = HttpUtil.get(url); JSONObject session = JSON.parseObject(resp); String openid = session.getString("openid"); if (openid == null) { // 常见错误码 40029 code 无效、45011 频率限制 return Result.fail(500, "登录失败:" + session.getString("errmsg")); } // 2. openid 落库,没有就注册一个 User user = userService.selectByOpenid(openid); if (user == null) { user = new User(); user.setOpenid(openid); user.setNickname("用户" + openid.substring(openid.length() - 6)); userService.insert(user); } // 3. 生成 token 存 Redis,7 天过期 String token = UUID.randomUUID().toString().replace("-", ""); redisTemplate.opsForValue().set("login:token:" + token, String.valueOf(user.getId()), 7, TimeUnit.DAYS); return Result.ok(new LoginVO(token, user.getId())); } }三个参数含义要记牢:appId和appSecret在小程序后台的「开发管理」里,secret泄露等于别人能冒充你的小程序调接口,所以放jdbc.properties同级的配置文件里,.gitignore里加一条;dto.getCode()只能换一次,如果前端因为网络失败重试,必须重新wx.login拿新 code,不能拿旧的重复调。
拦截器负责校验和续期:
public class LoginInterceptor implements HandlerInterceptor { @Autowired private StringRedisTemplate redisTemplate; @Override public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) throws Exception { String token = request.getHeader("token"); if (StringUtils.isBlank(token)) { writeUnauthorized(response); return false; } String userId = redisTemplate.opsForValue().get("login:token:" + token); if (userId == null) { writeUnauthorized(response); return false; } // 滑动续期:每次请求把过期时间推回 7 天,活跃用户不会中途掉线 redisTemplate.expire("login:token:" + token, 7, TimeUnit.DAYS); UserContext.set(Long.valueOf(userId)); // ThreadLocal,Service 层直接取当前用户 return true; } private void writeUnauthorized(HttpServletResponse response) throws IOException { response.setContentType("application/json;charset=UTF-8"); response.getWriter().write("{\"code\":401,\"msg\":\"请先登录\"}"); } }UserContext用ThreadLocal存当前用户 ID,好处是 Service 层不用每个方法都传userId参数。但要注意两点:afterCompletion里必须UserContext.remove(),否则 Tomcat 线程池复用线程时会串用户——这个 bug 极其隐蔽,表现是 A 用户偶尔看到 B 用户的数据;另外如果用了@Async或线程池,ThreadLocal不会自动传递,得手动传参。
4.2 职位列表:分页、筛选和那个容易失效的索引
列表页是访问量最大的接口,写法直接决定系统能撑多少人。Service 层:
@Service public class JobServiceImpl implements JobService { @Autowired private JobMapper jobMapper; @Override public PageInfo<JobVO> pageQuery(Integer page, Integer pageSize, String keyword, Integer categoryId) { // 关键:startPage 必须紧挨着查询方法,中间不能插别的 SQL PageHelper.startPage(page, pageSize); List<JobVO> list = jobMapper.pageQuery(keyword, categoryId); return new PageInfo<>(list); } }PageHelper.startPage的原理是往当前线程的ThreadLocal里放一个分页参数,MyBatis 执行下一条 SQL 时由拦截器改写成LIMIT。所以它必须紧贴查询语句,中间如果插了一次select count(*)或者别的查询,分页参数会被那次查询消费掉,最终结果就是「第一页正常,第二页还是第一页的数据」。这个坑我踩过不止一次,现在写这段代码都是眼睛盯着这两行。
Mapper XML:
<select id="pageQuery" resultType="com.example.job.vo.JobVO"> SELECT j.id, j.title, j.salary_min, j.salary_max, j.city, j.education, j.apply_count, j.create_time, c.company_name, c.logo FROM t_job j LEFT JOIN t_company c ON c.id = j.company_id WHERE j.status = 1 <if test="keyword != null and keyword != ''"> AND (j.title LIKE CONCAT('%', #{keyword}, '%') OR c.company_name LIKE CONCAT('%', #{keyword}, '%')) </if> <if test="categoryId != null"> AND j.category_id = #{categoryId} </if> ORDER BY j.create_time DESC </select>#{keyword}用#{}不用${},这是防 SQL 注入的基本功。LIKE CONCAT('%', #{keyword}, '%')拼出来的值会被 JDBC 当成参数处理,即使用户输入' OR 1=1 --也只是当成一个字符串去匹配,不会改变 SQL 结构。
代价是LIKE '%xx%'前置通配符用不上索引,数据量到十万级就会全表扫。招聘系统初期数据量不大,能接受;真要优化,常见做法是加一张搜索表或者上全文索引,把title和company_name拆成单独的可搜索字段。这个改造点留到二次开发时再说。
ORDER BY j.create_time DESC同样吃索引。如果t_job上只有idx_category_city,排序时会走 filesort。稳妥的做法是加一个(status, create_time)的联合索引,让「在招 + 按时间倒序」这个最常见的查询条件直接走索引。
4.3 简历投递:事务边界和幂等怎么保证
投递是这套系统里唯一涉及「多张表同时改」的动作,事务写错就是脏数据。Service 实现:
@Service public class DeliveryServiceImpl implements DeliveryService { @Autowired private DeliveryMapper deliveryMapper; @Autowired private JobMapper jobMapper; @Autowired private ResumeMapper resumeMapper; /** * 投递简历 * @param userId 当前登录用户,从 UserContext 取 * @param jobId 目标职位 * @param resumeId 使用的简历,必须属于当前用户 */ @Override @Transactional(rollbackFor = Exception.class) public void deliver(Long userId, Long jobId, Long resumeId) { Job job = jobMapper.selectById(jobId); if (job == null || job.getStatus() != 1) { throw new BizException("该职位已下线"); } // 简历归属校验:防止传别人的 resumeId 投递 Resume resume = resumeMapper.selectById(resumeId); if (resume == null || !resume.getUserId().equals(userId)) { throw new BizException("简历不存在"); } // 先查一次是为了给友好提示,真正兜底靠下面的唯一索引 if (deliveryMapper.countByUserAndJob(userId, jobId) > 0) { throw new BizException("你已经投递过这个职位了"); } try { deliveryMapper.insert(userId, jobId, resumeId); } catch (DuplicateKeyException e) { // 并发下两次请求同时通过了上面的 count 检查 throw new BizException("你已经投递过这个职位了"); } // 和插入在同一事务里,抛异常会一起回滚 jobMapper.incrApplyCount(jobId); } }几个关键点:
@Transactional(rollbackFor = Exception.class)必须显式写rollbackFor。Spring 默认只对RuntimeException回滚,自定义的BizException如果继承的是Exception而不是RuntimeException,抛出去之后事务照常提交,数据就脏了。我习惯让BizException extends RuntimeException,同时注解里也写全,双保险。
简历归属校验这一步不能省。小程序端传resumeId的时候,如果被改成别人的 ID,后端不校验就会把 A 的简历投到 B 的岗位上。这种越权问题在自测阶段完全看不出来,因为正常流程走得很顺。
countByUserAndJob加唯一索引是「乐观 + 兜底」的组合。查一次是为了 99% 的情况给出友好提示,唯一索引是为了剩下 1% 的并发场景不至于插入两条。只靠查一次不行,只靠唯一索引也不行——后者会让用户看到「系统异常」而不是「你已经投递过了」。
incrApplyCount对应的 SQL 是UPDATE t_job SET apply_count = apply_count + 1 WHERE id = #{jobId}。这里必须用数据库自增,不能写成「先 select 出来 +1 再 update」,那样并发下会丢更新。
5. 常见问题排查:从启动报错到数据不对的五个坑
5.1 启动阶段:war 部署和依赖冲突
现象一:Tomcat 启动正常,访问接口返回 404,日志里没有任何报错。
原因通常是web.xml里DispatcherServlet的<url-pattern>写成了/api/*,而 Controller 上已经标了@RequestMapping("/api/job"),实际路径变成/api/api/job/list。或者spring-mvc.xml的组件扫描路径写的是com.example.controller,而实际包名是com.example.job.controller,扫不到任何 Controller。
解决方式:先看 Tomcat 启动日志里有没有Mapped "{[/api/job/list]}"这类输出,没有就是没扫描到;有的话说明路径对不上,用curl直接打一遍,别靠猜。我现在的习惯是web.xml里url-pattern统一写/,路径前缀全部交给@RequestMapping管,少一个变量少一处错。
现象二:java.lang.NoSuchMethodError或NoClassDefFoundError,指向某个 Spring 类。
原因是 Maven 依赖冲突。SSM 项目里常见的是 Spring 4 和 Spring 5 的 jar 同时进了WEB-INF/lib,比如某个工具包传递依赖了老版本 Spring。解决方式是mvn dependency:tree看冲突,用<exclusions>排除掉老的,或者在pom.xml里用<dependencyManagement>统一锁版本。别手动往lib里拷 jar,那样下次打包又乱了。
5.2 运行阶段:跨域、中文乱码、时间字段和上传目录
现象三:小程序真机调试时所有请求都失败,开发者工具里却正常。
原因是BASE_URL写的是http://localhost:8080。开发者工具跑在电脑上,localhost能通;真机上localhost指的是手机自己,自然连不上。同时小程序正式版要求https且域名要在后台配好「服务器域名」白名单。
解决办法分两步:真机调试时把BASE_URL换成电脑的局域网 IP(比如http://192.168.1.100:8080/job-api),并且手机和电脑连同一个 WiFi;上线前换成备案过的 https 域名,在微信后台「开发管理 → 开发设置 → 服务器域名」里加上request 合法域名。这一步不做,正式版一定报「不在以下 request 合法域名列表中」。
现象四:接口返回的中文变成问号,或者前端拿到的 JSON 里中文是\u4e56\u4e56。
问号是编码问题。三个地方要一起改:web.xml里加CharacterEncodingFilter,forceEncoding设true;数据库连接串里带useUnicode=true&characterEncoding=utf8;建表时CHARSET=utf8mb4。三处缺一处都可能出问题。
\uXXXX不是乱码,是 JSON 序列化时把非 ASCII 字符转义了,前端JSON.parse之后是正常中文。如果看着别扭,在spring-mvc.xml的MappingJackson2HttpMessageConverter里加一条WriteMapNullValue和关闭ESCAPE_NON_ASCII就行,但不影响功能。
现象五:投递记录的时间比实际早 8 小时,或者列表排序莫名其妙。
MySQL 8 的驱动com.mysql.cj.jdbc.Driver如果连接串里没有serverTimezone,会用 JVM 默认时区去解释服务端返回的时间。开发机是东八区、服务器是 UTC 的时候,两边数据一混就错位。连接串里加&serverTimezone=Asia/Shanghai,并且保证数据库服务器、JVM、连接串三处时区一致。排序错乱有时候也是时区导致的,ORDER BY create_time DESC排的是被错误解释过的时间。
现象六:上传的公司 logo 和简历附件,重新部署 war 包之后全没了。
因为文件存在webapp/upload目录下,重新部署时 Tomcat 会先删掉整个webapp目录再解压新的 war。解决办法是把存储路径指到应用外部,upload.properties里配upload.path=/data/job/upload,然后通过<mvc:resources>把 URL 映射到file:路径:
<mvc:resources mapping="/upload/**" location="file:/data/job/upload/"/>这样文件在应用外,重新部署不受影响,Nginx 也能直接接管静态资源。这个改动越早做越好,等到上线后再迁移历史文件会多一层麻烦。
6. 二次开发:从 SSM 平滑换到 Spring Boot,顺便把搜索做对
迁移这件事,关键认识是:业务代码几乎不用动,动的是装配方式和打包方式。Controller、Service、Mapper 这三个包一行不改就能跑,因为注解是同一套 Spring 注解。
第一步换pom.xml。父工程从普通 pom 换成spring-boot-starter-parent,packaging从war改jar,把原来散装的spring-webmvc、mybatis、mysql-connector-java换成对应的 starter:
<parent> <groupId>org.springframework.boot</groupId> <artifactId>spring-boot-starter-parent</artifactId> <version>2.7.18</version> </parent> <packaging>jar</packaging> <dependencies> <dependency> <groupId>org.springframework.boot</groupId> <artifactId>spring-boot-starter-web</artifactId> </dependency> <dependency> <groupId>org.mybatis.spring.boot</groupId> <artifactId>mybatis-spring-boot-starter</artifactId> <version>2.3.1</version> </dependency> </dependencies>第二步,删掉web.xml、applicationContext.xml、spring-mvc.xml三个文件,把里面的内容翻译成一个启动类加一份application.yml:
@SpringBootApplication @MapperScan("com.example.job.mapper") public class JobApplication { public static void main(String[] args) { SpringApplication.run(JobApplication.class, args); } }server: port: 8080 servlet: context-path: /job-api spring: datasource: driver-class-name: com.mysql.cj.jdbc.Driver url: jdbc:mysql://127.0.0.1:3306/job_db?useUnicode=true&characterEncoding=utf8&serverTimezone=Asia/Shanghai username: root password: 123456 servlet: multipart: max-file-size: 10MB # 简历附件上限,默认 1MB 太小 mybatis: mapper-locations: classpath:mapper/*.xml type-aliases-package: com.example.job.entitycontext-path一定要保留成/job-api,否则小程序端所有BASE_URL都得改。拦截器则从 XML 搬到WebMvcConfigurer:
@Configuration public class WebMvcConfig implements WebMvcConfigurer { @Autowired private LoginInterceptor loginInterceptor; @Override public void addInterceptors(InterceptorRegistry registry) { registry.addInterceptor(loginInterceptor) .addPathPatterns("/api/**") .excludePathPatterns("/api/user/login", "/api/job/list", "/api/job/detail/**", "/api/category/**"); } @Override public void addResourceHandlers(ResourceHandlerRegistry registry) { // 外置上传目录,重启不丢文件 registry.addResourceHandler("/upload/**") .addResourceLocations("file:/data/job/upload/"); } }注意LoginInterceptor要加@Component,让它进 Spring 容器,否则@Autowired的StringRedisTemplate注入不进来,运行时直接 NPE。这是迁移时最常见的翻车点:XML 时代是<bean class="...">手动 new,改成注解后忘了加@Component。
第三步,把LIKE '%keyword%'换成真正的搜索。小数据量可以用 MySQL 全文索引:
ALTER TABLE t_job ADD FULLTEXT INDEX ft_title_desc (title, description) WITH PARSER ngram;ngram解析器是为中文准备的,不加它中文分词会按空格切,等于没效果。查询改成MATCH(title, description) AGAINST(#{keyword} IN BOOLEAN MODE)。数据量再大就得上独立的搜索引擎,但那是另一个话题了。
验证迁移是否成功,我一般跑三个检查:curl打一遍登录、列表、投递三个接口,对比迁移前后的返回体字段是否完全一致;用同一个 token 连打十次列表接口,看 Redis 里的过期时间有没有被续上;最后把 war 包和 jar 包分别部署一遍,用jmap -histo看类加载数量,jar 包应该明显更少。
从那以后我每次做框架升级,都会先把「迁移前」的接口返回 JSON 存成文件,迁移后逐个 diff,字段少一个、类型变一个都能立刻发现。这套笨办法救过我两次,一次是PageInfo的字段名从pageNum变成了pageNum(看着一样,实际是不同版本的类),一次是日期格式从时间戳变成了字符串。希望这些能帮到你,少走点弯路。
本文还有配套的精品资源,点击获取