
简介这份面向微信小程序求职招聘场景的完整项目源码适合计算机专业学生、课程设计或毕业设计开发者以及想练习小程序与Java后台联调的学习者可用于搭建求职者、商家与管理员三角色的校园兼职招聘系统。压缩包约4.07MB共1207个文件涵盖小程序端wxml、wxss、js与json页面文件后台jsp、java、sql及properties配置以及html、css、png、gif等静态与样式资源便于按前端、服务端和数据库模块拆分阅读。已有220人学习说明其作为课程设计参考具有一定认可度。源码包含微信授权登录、个人信息完善、兼职分类浏览、商户认证与岗位发布、面试预约、互评评分和客服指导等流程后端采用SSM结构并预留向Spring Boot升级的空间适合对照理解小程序请求后台、数据库表设计与权限角色控制的实现思路也可作为二次开发的基础模板。1. 需求先立住小程序 SSM 起步、SpringBoot 收口的求职招聘系统带过几届毕设后我发现做「基于微信小程序的求职招聘系统」真正容易被问住的不是页面好不好看而是后端为什么这么选。常见翻车现场是学生一上来就用 SpringBoot 全家桶结果课程要求写 SSM 分层代码交上去对不上或者反过来死守 SSM 的 XML 配置光 web.xml 和四个 spring 配置文件就把时间耗光了。这套方案换个思路——SSM 起手把业务链路跑通分层和接口写规范再平滑升级到 SpringBoot同一份业务代码既能当课程设计交也能撑起一个能演示、能答辩的成品。适合正在做毕设或课设、需要一套完整后端 小程序端结构的读者。2. 总体架构设计小程序端、接口层、业务层与数据层的四段切分先把边界划清楚后面写代码才不会互相打架。整个系统按四段切小程序端只负责采集输入、渲染列表和提交动作接口层Controller只做参数校验和统一响应包装业务层Service承担状态流转和事务数据层MyBatis Mapper只碰 SQL。这个切法看起来朴素但它是 SSM 升级 SpringBoot 时改动量最小的结构——因为升级动的是装配方式不是分层本身。2.1 端到端请求链路与三类角色系统里通常有三类角色权限和可见数据完全不同求职者浏览职位、投递简历、查看投递进度、接收面试邀约。企业发布职位、查看投递、发起面试邀约、标记录用或不合适。运营方审核企业资质、管理职位分类、下架违规职位。一条典型链路是这样走的小程序端wx.request带上自定义 headerAuthorization: Bearer token请求先过拦截器拦截器解析 token 拿到userId和role塞进ThreadLocalController 拿到参数后调 ServiceService 里做业务判断比如「这个职位是否已下架」「这个用户是否重复投递」再调 MapperMapper 走 MyBatis 的 XML 或注解 SQL返回结果最后统一由ResultT包装成{code, msg, data}三段式返回。这里有个容易被忽略的设计点不要把角色判断写进 Controller 的 if-else。我一般用注解 拦截器的方式方法上加RequireRole(COMPANY)拦截器读注解决定放不放行。这样后面加第四个角色比如「猎头」时只改注解不动业务代码。响应格式统一也很关键。小程序端的请求封装写死了一套解析逻辑后端只要有一个接口返回结构不一样前端就得写特判越写越乱。所以约定code0成功code401登录态失效code403无权限其余为业务错误码前端按code分支处理msg直接 toast 出来。2.2 从 SSM 到 SpringBoot 的升级路径这是整套方案里最值得讲清楚的一段。很多人把「可升级」理解成「重写一遍」其实不是。SSM 和 SpringBoot 的差别集中在三处依赖怎么引、配置写在哪、容器怎么起。业务代码Service、Mapper、实体类几乎可以原样搬。第一步改依赖。SSM 的pom.xml里是一堆带版本号的独立依赖SpringBoot 换成 starter版本交给父工程管!-- SSM 阶段的典型依赖版本要自己对齐容易冲突 -- dependency groupIdorg.springframework/groupId artifactIdspring-webmvc/artifactId version5.3.20/version /dependency dependency groupIdorg.mybatis/groupId artifactIdmybatis-spring/artifactId version2.0.7/version /dependency !-- 升级 SpringBoot 后版本由 parent 统一管理 -- parent groupIdorg.springframework.boot/groupId artifactIdspring-boot-starter-parent/artifactId version2.7.18/version /parent 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第二步改配置。SSM 阶段的数据源、事务、MyBatis 的SqlSessionFactory分散在applicationContext-dao.xml、spring-mvc.xml、web.xml里SpringBoot 阶段全部收进application.ymlweb.xml直接删掉换成启动类上的SpringBootApplication。server: port: 8080 spring: datasource: driver-class-name: com.mysql.cj.jdbc.Driver url: jdbc:mysql://127.0.0.1:3306/job_recruit?useUnicodetruecharacterEncodingutf8serverTimezoneAsia/Shanghai username: root password: ${DB_PASSWORD} # 走环境变量别把明文密码提交上去 mybatis: mapper-locations: classpath:mapper/*.xml type-aliases-package: com.example.recruit.entity configuration: map-underscore-to-camel-case: true # 数据库下划线字段自动映射成驼峰属性mapper-locations和type-aliases-package这两行是升级时最容易漏的漏了前者启动不报错但一调就提示Invalid bound statement漏了后者XML 里的resultType得写全限定名漏改一处就抛ClassNotFoundException。map-underscore-to-camel-case建议一开始就开company_id自动映射到companyId能省掉大量resultMap。第三步处理 XML 里那些「容器行为」。SSM 常用web.xml注册CharacterEncodingFilter、HiddenHttpMethodFilterSpringBoot 里这些要么已默认开启要么用Bean注册。拦截器从mvc:interceptors改成实现WebMvcConfigurer#addInterceptors。跨域配置从mvc:cors改成addCorsMappings。改动清单不长但要一项项对我习惯做一个「配置项对照表」贴在旁边改一项划一项比凭记忆靠谱。2.3 数据表设计与索引取舍七张核心表能撑起全部业务用户表、企业表、职位表、简历表、投递表、面试邀约表、职位分类表。字段不用多但几个关键约束必须提前定死否则后面全是脏数据。表名关键字段约束与说明t_userid、openid、role、phone、statusopenid 唯一索引role 取 JOBSEEKER / COMPANY / ADMINt_companyid、user_id、name、license_no、audit_statususer_id 唯一audit_status 0 待审 1 通过 2 驳回t_jobid、company_id、category_id、title、salary_min、salary_max、statusstatus 0 下线 1 在线建 company_id status 联合索引t_resumeid、user_id、real_name、education、expect_cityuser_id 唯一一人一份主简历t_deliveryid、job_id、resume_id、user_id、status、create_timejob_id user_id 唯一索引防重复投递t_interviewid、delivery_id、interview_time、address、statusdelivery_id 关联投递记录t_categoryid、name、sort分类固定几项不做多级职位表的联合索引(company_id, status)是专门为企业端「我的职位列表」准备的列表页默认只查在线职位走这个索引能少扫很多行。投递表的(job_id, user_id)唯一索引更关键——不建这个用户连点两下投递按钮就是两条记录企业端看到重复投递答辩时解释起来很难看。CREATE TABLE t_delivery ( id BIGINT PRIMARY KEY AUTO_INCREMENT, job_id BIGINT NOT NULL COMMENT 职位ID, resume_id BIGINT NOT NULL COMMENT 简历ID, user_id BIGINT NOT NULL COMMENT 求职者ID, status TINYINT NOT NULL DEFAULT 0 COMMENT 0待查看 1已查看 2邀面试 3已录用 4不合适, create_time DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, update_time DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, UNIQUE KEY uk_job_user (job_id, user_id), KEY idx_user_status (user_id, status) ) ENGINEInnoDB DEFAULT CHARSETutf8mb4 COMMENT投递记录表;idx_user_status服务的是求职者端「我的投递」按状态筛选status放第二位是因为查询条件里user_id一定存在。这种「等值列在前、筛选列在后」的顺序不是玄学把最常用的等值条件放最左边索引才能被用满。3. 核心模块实现登录鉴权、职位检索与简历投递的三条主链路三条链路跑通系统就有骨架了。登录决定「你是谁」检索决定「你能看到什么」投递决定「你能做什么」。这三块写扎实剩下的企业端管理、后台审核都是同一套模式的复制。3.1 小程序登录与 token 鉴权落地小程序的登录不能照搬账号密码那套。标准流程是小程序端调wx.login()拿code把code发给后端后端用appidsecretcode请求微信的登录凭证校验接口换回openid和session_key后端拿openid查用户表没查到就自动注册一条查到就取现成的userId最后签发一个自己的 token 返回给小程序端后续请求都带这个 token。// utils/request.js —— 小程序端统一请求封装 const BASE_URL http://127.0.0.1:8080/api 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每次请求带上 Authorization: Bearer (wx.getStorageSync(token) || ) }, success(res) { if (res.data.code 0) { resolve(res.data.data) } else if (res.data.code 401) { // 登录态失效清掉本地 token回登录页重新走 wx.login wx.removeStorageSync(token) wx.reLaunch({ url: /pages/login/login }) reject(res.data) } else { wx.showToast({ title: res.data.msg, icon: none }) reject(res.data) } }, fail: reject }) }) }这段封装里有两个设计决定值得说。一是code 401单独处理并reLaunch回登录页而不是弹个 toast 让用户手动返回——token 过期是常态让用户自己处理体验很差。二是 token 放Authorizationheader 而不是拼在 URL 上避免被日志和分享链接带出去。后端的 token 用 JWT 签发载荷里只放userId和role不放手机号之类的敏感信息。密钥从配置文件读不硬编码。校验放在拦截器里// AuthInterceptor.java —— 统一登录态校验 public class AuthInterceptor implements HandlerInterceptor { Override public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) throws Exception { // 放行预检请求否则小程序端跨域会直接失败 if (OPTIONS.equalsIgnoreCase(request.getMethod())) { return true; } String header request.getHeader(Authorization); if (header null || !header.startsWith(Bearer )) { writeError(response, 401, 未登录); return false; } try { Claims claims JwtUtil.parse(header.substring(7)); // 线程内传递Controller 和 Service 直接取不用层层传参 UserContext.set(new CurrentUser( Long.valueOf(claims.get(userId).toString()), claims.get(role).toString())); return true; } catch (Exception e) { writeError(response, 401, 登录态已过期); return false; } } Override public void afterCompletion(HttpServletRequest req, HttpServletResponse resp, Object handler, Exception ex) { // 必须清理线程池复用会串数据 UserContext.clear(); } }afterCompletion里的UserContext.clear()是血泪经验。拦截器里的ThreadLocal不清线程被 Tomcat 复用时下一个请求可能读到上一个用户的身份表现出来就是「A 登录后偶尔看到 B 的数据」——这种 bug 排查起来像黑匣子因为它是概率性的。角色校验单独抽一层用注解标记Target(ElementType.METHOD) Retention(RetentionPolicy.RUNTIME) public interface RequireRole { String[] value(); // 允许访问的角色如 {COMPANY} }拦截器解析HandlerMethod上的注解比对UserContext.get().getRole()不匹配就返回 403。这样企业端接口加RequireRole(COMPANY)求职者拿自己的 token 调过去会被挡掉不用在每个方法里写 if。3.2 职位检索的分页与筛选实现职位列表是整个系统访问量最大的接口它的写法直接决定演示时卡不卡。条件有四个关键词匹配标题、分类、城市、薪资区间全部可选。做法是用 MyBatis 的动态 SQL 拼条件分页交给 PageHelper。!-- JobMapper.xml —— 职位列表动态查询 -- select idselectByCondition resultTypecom.example.recruit.entity.Job SELECT j.id, j.title, j.salary_min, j.salary_max, j.city, j.create_time, c.name AS companyName, cat.name AS categoryName FROM t_job j LEFT JOIN t_company c ON j.company_id c.id LEFT JOIN t_category cat ON j.category_id cat.id where j.status 1 if testkeyword ! null and keyword ! AND j.title LIKE CONCAT(%, #{keyword}, %) /if if testcategoryId ! null AND j.category_id #{categoryId} /if if testcity ! null and city ! AND j.city #{city} /if if testsalaryMin ! null AND j.salary_max gt; #{salaryMin} /if if testsalaryMax ! null AND j.salary_min lt; #{salaryMax} /if /where ORDER BY j.create_time DESC /select几个细节j.status 1写在where里而不是if里保证下线的职位永远查不出来这条不需要调用方传参。薪资区间判断用的是「区间重叠」逻辑——职位薪资上限大于等于筛选下限且职位薪资下限小于等于筛选上限这样 8k-12k 的职位在筛「10k 以上」时能命中而简单的salary_min #{salaryMin}会漏掉它。LIKE CONCAT(%, #{}, %)用#{}而不是${}防注入这是基础但每年都有人写错。Service 层只做两件事调PageHelper.startPage(pageNum, pageSize)再调 Mapper然后把结果包成PageInfo返回。public PageInfoJobVO list(JobQuery query) { // 页码兜底防止前端传 0 或负数导致 SQL 报错 int pageNum query.getPageNum() null || query.getPageNum() 1 ? 1 : query.getPageNum(); int pageSize query.getPageSize() null ? 10 : Math.min(query.getPageSize(), 50); PageHelper.startPage(pageNum, pageSize); ListJobVO list jobMapper.selectByCondition(query); return new PageInfo(list); }Math.min(pageSize, 50)是个小防线。不设上限的话有人传pageSize10000就能把整张表拉下来接口响应时间直接从几十毫秒涨到几秒演示现场很难看。提示PageHelper.startPage必须紧挨着 Mapper 查询调用中间不能插入别的查询。它靠ThreadLocal传递分页参数中间插一条查询会把参数吃掉表现出来的现象是「分页失效返回全量数据」。3.3 简历投递与状态机流转投递这块最容易写成「一个 update 改所有状态」最后状态乱飞。正确的做法是先把状态定死再写流转规则。状态定义0 待查看、1 已查看、2 邀面试、3 已录用、4 不合适。允许的流转只有这几条0 → 1企业点开简历1 → 2企业发起面试邀约1 → 4企业标记不合适2 → 3企业标记录用2 → 4面试后不合适其他跳转一律拒绝比如 0 直接到 3、3 再回 0。把规则写成常量表Service 里查表校验// 允许的状态流转key 是当前状态value 是可达状态集合 private static final MapInteger, SetInteger FLOW new HashMap(); static { FLOW.put(0, Set.of(1)); FLOW.put(1, Set.of(2, 4)); FLOW.put(2, Set.of(3, 4)); FLOW.put(3, Set.of()); // 终态 FLOW.put(4, Set.of()); // 终态 } Transactional(rollbackFor Exception.class) public void changeStatus(Long deliveryId, Integer target, Long operatorCompanyId) { Delivery d deliveryMapper.selectById(deliveryId); if (d null) { throw new BizException(投递记录不存在); } // 越权校验只能操作投给自己公司的投递 Job job jobMapper.selectById(d.getJobId()); if (!job.getCompanyId().equals(operatorCompanyId)) { throw new BizException(403, 无权操作该投递); } if (!FLOW.getOrDefault(d.getStatus(), Set.of()).contains(target)) { throw new BizException(当前状态不允许该操作); } // 带原状态做条件更新防并发重复流转 int rows deliveryMapper.updateStatus(deliveryId, d.getStatus(), target); if (rows 0) { throw new BizException(状态已变更请刷新后重试); } if (target 2) { interviewService.createByDelivery(d); // 邀面试时同步生成面试记录 } }updateStatus的 SQL 里带AND status #{oldStatus}这是乐观锁的简化写法。两个企业账号同时点「不合适」只有一个能成功另一个拿到 0 行更新提示刷新重试。不加这个条件后一次会覆盖前一次状态可能从「已录用」被打回「不合适」。投递动作本身也要做幂等。插入前先查t_delivery有没有(job_id, user_id)记录有就直接返回「已投递过」不要指望数据库唯一索引报错后捕获异常——异常信息对用户没意义还得额外处理事务回滚标记。3.4 面试邀约与消息提醒企业发起邀约时除了写t_interview还要把投递状态推到 2两件事必须在同一个事务里。面试时间、地点、联系人存成独立字段不要塞进备注里——后面求职者端要按时间排序展示塞备注里就没法查了。求职者端怎么知道有新邀约最省事的方案是轮询小程序在「我的消息」页onShow时拉一次未读数量进入列表页再拉一次详情。不引入 WebSocket也不引入消息队列答辩时不会被追问「为什么要用这个」。接口按user_idis_read 0统计用户点开后批量更新为已读。-- 未读邀约数量配合 idx_user_read 索引 SELECT COUNT(*) FROM t_interview i JOIN t_delivery d ON i.delivery_id d.id WHERE d.user_id #{userId} AND i.is_read 0;面试时间字段用DATETIME存前端展示时按本地时区格式化。不要存字符串也不要存时间戳后前端自己算——时区一错邀约显示成「昨天」用户直接懵。4. 避坑与排查上线前最容易翻车的五个点这一章是我带项目时攒下来的问题清单每条都按「现象 → 原因 → 解决」写。这些坑不涉及高深技术但每一个都能让演示卡住半小时以上。4.1 五个高频故障的现象、原因与处理现象一小程序端所有接口都报「请求失败」但浏览器直接访问接口是通的。原因基本是没配开发工具的「不校验合法域名」或者后端没开 CORS。小程序真机调试时对域名有要求本地127.0.0.1在开发者工具里勾选忽略校验能过真机就不行了。解决开发阶段在开发者工具里勾上忽略校验后端在WebMvcConfigurer里加addCorsMappings允许*来源、GET/POST/PUT/DELETE方法和Authorizationheader。上线前把来源改成实际域名。现象二登录成功但刷新页面又变回未登录。原因通常是小程序端wx.setStorageSync(token, ...)写在了wx.request的 success 回调外层或者后端返回的字段名是data.token而前端取的是data.accessToken。这类问题排查起来最快的办法是console.log打出完整响应体对着字段名一个一个核。解决登录成功后在 success 回调里立刻写 storage并且前后端把返回结构写进接口文档字段名不许临时改。现象三职位列表翻到第二页数据和第一页重复。原因两种一是PageHelper.startPage之后中间插了别的查询分页参数被吃掉二是 SQL 的ORDER BY字段有大量重复值比如只按create_time排同一秒发布的职位顺序不稳定MySQL 分页时同一行可能出现在两页。解决前者调整代码顺序后者在ORDER BY后面补一个唯一列改成ORDER BY j.create_time DESC, j.id DESC。现象四企业端能看到别家公司的投递记录。这是权限校验漏了通常是 Controller 里只校验了「登录」没校验「这条数据是不是我的」。解决所有涉及企业数据的接口Service 层必须拿UserContext里的userId反查t_company拿到companyId再用companyId作为查询条件而不是信任前端传的companyId。前端传的 ID 一律当不可信输入。现象五SSM 升级 SpringBoot 后启动报Invalid bound statement (not found)。原因有四种可能mapper-locations路径写错、Mapper 接口没加Mapper或没配MapperScan、XML 里的namespace和接口全限定名对不上、Maven 打包时 XML 没被放进target/classes。前三种看日志能定位第四种最隐蔽——本地 IDE 跑得好好的打成 jar 就废了。解决pom.xml的build里加resources把src/main/java下的**/*.xml也纳入打包或者干脆把 XML 统一放src/main/resources/mapper下。4.2 出问题时的排查顺序我一般按这个顺序走能省掉大量盲目试错先看后端控制台有没有异常堆栈——有就直接读最下面那行Caused by没有异常就看接口返回的code401 往 token 查403 往角色注解查500 往 Service 查接口通但数据不对就把 Mapper 的 SQL 日志打开logging.level.com.example.recruit.mapperdebug把实际执行的 SQL 复制到客户端跑一遍十有八九能看出条件拼错了。还有一个反直觉的经验先怀疑配置再怀疑代码。配置类问题路径、端口、跨域、时区占了排查时间的一大半而它们的表现往往是「代码看着没问题但就是不生效」。改完配置记得重启热部署对application.yml的改动经常不生效。5. 后台管理与权限设计三角色模型与企业资质审核后台不是可有可无的附属品它是整套系统「看起来完整」的关键。答辩时如果只有小程序端老师会问「企业乱发职位怎么办」有了审核流程这个问题就变成了加分项。5.1 三角色权限模型与拦截器落地角色在t_user.role里存一个字符串取值JOBSEEKER、COMPANY、ADMIN。不用数字因为数字在日志和调试时看不出含义字符串一眼就懂。企业账号和求职者账号共用一张用户表靠role区分企业额外在t_company里挂一条档案。权限的落地分两层。第一层是接口级靠前面说的RequireRole注解 拦截器。第二层是数据级靠 Service 里的归属校验。这两层缺一不可只做接口级企业 A 拿自己的 token 调GET /api/company/delivery?companyId2依然能查到企业 B 的数据只做数据级代码里到处是重复的归属判断写起来累还容易漏。// 数据级校验的通用写法从登录态反查归属不信任前端参数 private Long currentCompanyId() { CurrentUser user UserContext.get(); if (user null || !COMPANY.equals(user.getRole())) { throw new BizException(403, 仅企业账号可操作); } Company company companyMapper.selectByUserId(user.getUserId()); if (company null) { throw new BizException(403, 企业档案不存在); } return company.getId(); }所有企业端接口第一步就调这个方法拿companyId后面所有查询都带上它。这套写法在升级 SpringBoot 时完全不用改因为它只依赖UserContext跟容器怎么装配没关系。后台管理端运营方建议单独做一套页面不复用小程序。用最简单的服务端渲染或者一套独立的静态页面 同一套接口都行关键是ADMIN角色的接口必须全部加RequireRole(ADMIN)并且管理端的登录不走小程序那套openid走账号密码 图形验证码避免管理员账号和小程序用户混在一起。5.2 企业资质审核与职位上下架的状态约束企业注册后audit_status默认 0待审此时能登录、能编辑档案但不能发布职位。发布接口里必须先判断audit_status 1否则抛「企业资质审核中」。这条约束写在 Service不写在 Controller因为后面可能还有别的入口要发布职位。审核动作只有运营方能做通过写 1驳回写 2 并记录驳回原因。驳回后企业可以改资料重新提交audit_status回到 0。这个来回流程要允许不能设计成「驳回即终态」。职位上下架同理。企业发布职位后status默认 1在线可以手动下线改成 0。运营方有权限强制下架但不改company_id只在后台记录一条操作日志。求职者端的所有查询都硬编码status 1这条不开放给前端传参——前面 3.2 的 SQL 里已经这么写了。还有一条容易被忽略企业被驳回或封禁后它已发布的职位要同步下线。做法是在审核接口里把该企业名下所有status 1的职位批量更新为 0跟审核状态变更放在同一个事务里。不做这一步就会出现「企业已被驳回但职位还在列表里挂着」的尴尬情况演示时被点出来很扣分。6. 验收与答辩用一份可复现的清单证明系统真的跑通了验收这件事最忌讳的是「点一遍看着都正常」。我一般让学生准备一份清单逐项打勾每项都要有可复现的操作路径和预期结果。下面这份是这套系统的核心验收项可以直接拿去改验收项操作路径预期结果新用户注册小程序首次进入 → 授权登录t_user 新增一条roleJOBSEEKERtoken 正常返回重复登录同一微信号再次进入不新增用户返回同一 userId企业发职位未审核企业账号 → 发布职位提示「资质审核中」不落库企业发职位已审核后台审核通过后重试落库成功status1职位检索关键词 分类 薪资区间组合筛选结果全部命中条件分页 total 正确重复投递同一用户对同一职位投两次第二次提示「已投递」t_delivery 只有一条越权投递操作企业 A 的 token 操作企业 B 的投递返回 403数据不变状态非法跳转对待查看投递直接标记录用返回业务错误状态不变登录态过期手动改坏本地 token 后请求返回 401小程序跳回登录页企业驳回后职位下线后台驳回某企业该企业名下在线职位全部变为 status0这张表的价值不在于「测了什么」而在于每一条都能当场演。答辩时老师随口问「重复投递怎么处理的」你直接把表翻到那一行跑一遍给他看比口头解释有说服力得多。答辩陈述我建议按「一个问题 → 一个设计 → 一个证据」的三段式讲控制在 8 分钟内。问题讲清楚业务场景求职者和企业信息不对称设计讲清楚技术取舍为什么 SSM 起步、为什么用 JWT 不用 session、为什么状态机要锁流转证据就是上面那张表加上一两个关键代码片段。技术取舍那部分最容易被追问提前想好「如果重做会怎么改」的答案比如「如果并发量上来职位检索会加 Redis 缓存热门分类但当前数据量下没必要」。最后说个习惯。我现在每次交付前都会把数据库清空用一个全新的微信号从头走一遍完整流程注册 → 建企业 → 审核 → 发职位 → 投递 → 邀面试 → 录用。这条链路走不通说明有隐藏的初始化依赖比如某个分类数据是手动插的、某个状态值是测试时改的。清库重跑一遍能把这些隐患全逼出来比看一百遍代码管用。希望这套结构帮到你把毕设做成一份能讲清楚的方案而不只是一堆能跑的页面。本文还有配套的精品资源点击获取