尧图网络 高端网站定制 · 原创设计
免费咨询热线
400-888-6620
免费获取方案
微信小程序求职招聘系统:SSM后端与Spring Boot迁移实践
简介面向课程设计、毕业设计与技术练手场景的微信小程序求职招聘系统源码包采用小程序客户端、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注解式 ControllerRestController业务层SpringIOC AOP 声明式事务持久层MyBatis PageHelperXML 写 SQL插件做分页数据库MySQLutf8mb4InnoDB缓存/会话Redis存 token 到 userId 的映射构建Mavenwar 打包小程序端不引入任何框架原生 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 ResultT implements Serializable { private Integer code; 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(Integer code, String msg) { ResultT r new Result(); r.code code; r.msg msg; return r; } // getter / setter 省略 }data用泛型而不是直接返回Object是为了让小程序端拿到的结构稳定——列表接口永远是{ total, list, pageNum, pageSize }详情接口永远是实体字段。业务异常不抛到前端统一用code500 中文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 ResultPageInfoJobVO 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 开始的如果前端传 0PageHelper 会当成「不做分页」直接查全表——数据量一上来就是一次慢查询这是新手特别容易犯的错。2.3 登录态怎么设计为什么放弃 Session小程序端拿到code之后发给后端后端调微信的jscode2session换openid这中间没有浏览器也没有 Cookie。用HttpSession也能跑但会踩两个坑一是 App 和小程序如果共用一个后端Session 不通用二是 Tomcat 集群部署时 Session 不共享得额外配广播。这个项目用的是 Redis token登录成功后生成一个 UUID 当 tokenlogin: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、avatart_company企业/HRaccount、password、company_name、statust_job职位company_id、title、salary_min/max、statust_resume简历user_id、real_name、education、attachmentt_delivery投递记录job_id、user_id、resume_id、statust_category职位分类name、sortt_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) ) ENGINEInnoDB DEFAULT CHARSETutf8mb4 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) ) ENGINEInnoDB DEFAULT CHARSETutf8mb4 COMMENT投递记录表;uk_user_job这个唯一索引是防重复投递的最后一道闸。代码里会先count一次再插入但这是「查完再写」两个请求同时进来都能查到 0然后都插入成功。有唯一索引在第二次插入直接抛DuplicateKeyException捕获后返回「已投递过」这是唯一能真正兜底的方案。3.3 配置文件的关键项jdbc.properties 与 spring-mvc.xmlSSM 的配置文件多但真正容易配错的不超过十行。jdbc.propertiesjdbc.drivercom.mysql.cj.jdbc.Driver jdbc.urljdbc:mysql://127.0.0.1:3306/job_db?useUnicodetruecharacterEncodingutf8serverTimezoneAsia/ShanghaiuseSSLfalse jdbc.usernameroot jdbc.password123456serverTimezoneAsia/Shanghai不写会怎样MySQL 8 的驱动如果服务端时区和 JVM 时区不一致datetime字段读出来会差 8 小时投递时间显示成「8 小时前」都算好的严重的时候日期直接跨天。这是血泪经验配置里宁可多写一个参数。useSSLfalse是本地开发的省事写法生产环境要换成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 classcom.example.job.interceptor.LoginInterceptor/ /mvc:interceptor /mvc:interceptorsexclude-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 userIdtoken 由前端存本地缓存 */ PostMapping(/login) public ResultLoginVO login(RequestBody LoginDTO dto) { // 1. code 换 openidappid/secret 从配置文件读不能写死在代码里 String url https://api.weixin.qq.com/sns/jscode2session ?appid appId secret appSecret js_code dto.getCode() grant_typeauthorization_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 存 Redis7 天过期 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)); // ThreadLocalService 层直接取当前用户 return true; } private void writeUnauthorized(HttpServletResponse response) throws IOException { response.setContentType(application/json;charsetUTF-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 PageInfoJobVO pageQuery(Integer page, Integer pageSize, String keyword, Integer categoryId) { // 关键startPage 必须紧挨着查询方法中间不能插别的 SQL PageHelper.startPage(page, pageSize); ListJobVO list jobMapper.pageQuery(keyword, categoryId); return new PageInfo(list); } }PageHelper.startPage的原理是往当前线程的ThreadLocal里放一个分页参数MyBatis 执行下一条 SQL 时由拦截器改写成LIMIT。所以它必须紧贴查询语句中间如果插了一次select count(*)或者别的查询分页参数会被那次查询消费掉最终结果就是「第一页正常第二页还是第一页的数据」。这个坑我踩过不止一次现在写这段代码都是眼睛盯着这两行。Mapper XMLselect idpageQuery resultTypecom.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 testkeyword ! null and keyword ! AND (j.title LIKE CONCAT(%, #{keyword}, %) OR c.company_name LIKE CONCAT(%, #{keyword}, %)) /if if testcategoryId ! null AND j.category_id #{categoryId} /if ORDER BY j.create_time DESC /select#{keyword}用#{}不用${}这是防 SQL 注入的基本功。LIKE CONCAT(%, #{keyword}, %)拼出来的值会被 JDBC 当成参数处理即使用户输入 OR 11 --也只是当成一个字符串去匹配不会改变 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里加CharacterEncodingFilterforceEncoding设true数据库连接串里带useUnicodetruecharacterEncodingutf8建表时CHARSETutf8mb4。三处缺一处都可能出问题。\uXXXX不是乱码是 JSON 序列化时把非 ASCII 字符转义了前端JSON.parse之后是正常中文。如果看着别扭在spring-mvc.xml的MappingJackson2HttpMessageConverter里加一条WriteMapNullValue和关闭ESCAPE_NON_ASCII就行但不影响功能。现象五投递记录的时间比实际早 8 小时或者列表排序莫名其妙。MySQL 8 的驱动com.mysql.cj.jdbc.Driver如果连接串里没有serverTimezone会用 JVM 默认时区去解释服务端返回的时间。开发机是东八区、服务器是 UTC 的时候两边数据一混就错位。连接串里加serverTimezoneAsia/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/** locationfile:/data/job/upload//这样文件在应用外重新部署不受影响Nginx 也能直接接管静态资源。这个改动越早做越好等到上线后再迁移历史文件会多一层麻烦。6. 二次开发从 SSM 平滑换到 Spring Boot顺便把搜索做对迁移这件事关键认识是业务代码几乎不用动动的是装配方式和打包方式。Controller、Service、Mapper 这三个包一行不改就能跑因为注解是同一套 Spring 注解。第一步换pom.xml。父工程从普通 pom 换成spring-boot-starter-parentpackaging从war改jar把原来散装的spring-webmvc、mybatis、mysql-connector-java换成对应的 starterparent groupIdorg.springframework.boot/groupId artifactIdspring-boot-starter-parent/artifactId version2.7.18/version /parent packagingjar/packaging dependencies dependency groupIdorg.springframework.boot/groupId artifactIdspring-boot-starter-web/artifactId /dependency dependency groupIdorg.mybatis.spring.boot/groupId artifactIdmybatis-spring-boot-starter/artifactId version2.3.1/version /dependency /dependencies第二步删掉web.xml、applicationContext.xml、spring-mvc.xml三个文件把里面的内容翻译成一个启动类加一份application.ymlSpringBootApplication 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?useUnicodetruecharacterEncodingutf8serverTimezoneAsia/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 搬到WebMvcConfigurerConfiguration 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看着一样实际是不同版本的类一次是日期格式从时间戳变成了字符串。希望这些能帮到你少走点弯路。本文还有配套的精品资源点击获取
RELATED

相关推荐

Java Tess4J OCR实战:从环境配置到置信度质检的完整闭环

Java Tess4J OCR实战:从环境配置到置信度质检的完整闭环

简介:这份资源面向需要在 Java 应用中集成 OCR 能力的开发者,提供一套可直接运行的 Tess4J 示例工程,帮助解决图像文字自动识别、转可编辑文本的入门与调试问题。压缩包共 80 个文件,约 21.21MB,以 44 个 class 与 14 …

📅 2026/11/5 2:11:00
电磁波实验考点全解析:从反射折射到微波参数测量

电磁波实验考点全解析:从反射折射到微波参数测量

简介:面向电磁场与微波技术课程学习者及高校实验教学场景,这份资源围绕湖南大学电磁波四个实验及配套实验报告展开,覆盖PEC与PMC边界条件模拟、线性极化波合成圆极化与椭圆极化波、矩形波导中TE/TM模式传播等核心知识点,可帮助读者…

📅 2026/11/5 2:11:00
Java Tess4J OCR实战:从依赖配置到批量识别与避坑指南

Java Tess4J OCR实战:从依赖配置到批量识别与避坑指南

简介:这份资源面向需要在 Java 应用中集成 OCR 能力的开发者,提供一套可直接运行的 Tess4J 示例工程,帮助解决图像文字自动识别、转换为可编辑文本的实际问题,适合具备一定 Java 基础、希望快速上手 Tesseract 引擎的中级开发者参…

📅 2026/11/5 2:11:00
MORE NEWS

更多资讯

📰

GitHub仓库如何助力大模型学习与工程实践

1. 项目概述:为什么GitHub仓库能成为大模型学习的关键资源?在人工智能领域,GitHub早已超越了单纯的代码托管平台角色,成为了技术前沿的晴雨表。特别是对于大模型这种快速迭代的技术方向,GitHub上的优质仓库往往包含了最…

📰

Sobol全局灵敏度分析MATLAB实现:方差分解与采样估计

简介:面向从事全局敏感性分析与不确定性量化的科研人员和工程技术人员,这套MATLAB代码演示了基于蒙特卡罗抽样与Sobol序列的Sobol灵敏度分析方法,可用于识别非线性数学模型中各输入参数对输出结果的影响程度,适合具备一定MATLAB基…

📰

Delta机器人正逆解:从几何约束到MATLAB实现与避坑

简介:这份资源面向学习并联机器人运动学的学生、研究人员与工程调试人员,聚焦Delta机器人运动学中的正解与逆解问题。正解由三个驱动关节角度推算末端执行器在笛卡尔空间中的位置,逆解则根据目标位姿反求关节角度,二者是理解Delta…

📰

Docker Compose 下载与更新全攻略:在线镜像加速与离线安装指南

Docker Compose 这个工具,但凡你碰过容器化部署,基本绕不开它。可真正让人卡住的往往不是写 YAML,而是第一步——把它弄到机器上,还得是对应的版本。尤其在国内网络环境下,直接从官方源拉取二进制文件,十次…

📰

幽默化设计:让家务变娱乐的5大策略

1. 项目背景与核心创意解析去年夏天的一次家庭聚会上,我意外发现一个有趣现象:当我把洗碗过程配上夸张的肢体动作和即兴喜剧台词时,原本抱怨洗碗的亲友们竟然主动加入了这个"表演"。这个偶然发现让我意识到,日常家务完全…

📰

技术人如何培养产品思维:从实现到洞察的转型

1. 从技术到产品的思维跃迁十年前当我第一次翻开《人人都是产品经理》这本书时,正处在职业生涯的十字路口。作为一名全栈工程师,我习惯用代码解决问题,却常常困惑为什么自己开发的功能用户并不买账。这本书彻底改变了我看待技术价值的视角——…

TODAY

今日更新

THIS WEEK

本周精选

THIS MONTH

本月热门

读完文章,想聊聊您的网站?

告诉我们您的行业与需求,资深顾问一对一梳理方案与报价,全程免费。

📞 💬