ARTICLE · INTELLIGENCE

战地情报 · 详情页

来自尧图项目组的一线实战观察与深度解析

微信小程序求职招聘系统:SSM起步到SpringBoot收口实战

微信小程序求职招聘系统:SSM起步到SpringBoot收口实战

简介:这份面向微信小程序求职招聘场景的完整项目源码,适合计算机专业学生、课程设计或毕业设计开发者,以及想练习小程序与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,塞进ThreadLocal;Controller 拿到参数后调 Service;Service 里做业务判断(比如「这个职位是否已下架」「这个用户是否重复投递」),再调 Mapper;Mapper 走 MyBatis 的 XML 或注解 SQL,返回结果;最后统一由Result<T>包装成{code, msg, data}三段式返回。

这里有个容易被忽略的设计点:不要把角色判断写进 Controller 的 if-else。我一般用注解 + 拦截器的方式,方法上加@RequireRole("COMPANY"),拦截器读注解决定放不放行。这样后面加第四个角色(比如「猎头」)时,只改注解,不动业务代码。

响应格式统一也很关键。小程序端的请求封装写死了一套解析逻辑,后端只要有一个接口返回结构不一样,前端就得写特判,越写越乱。所以约定:code=0成功,code=401登录态失效,code=403无权限,其余为业务错误码,前端按code分支处理,msg直接 toast 出来。

2.2 从 SSM 到 SpringBoot 的升级路径

这是整套方案里最值得讲清楚的一段。很多人把「可升级」理解成「重写一遍」,其实不是。SSM 和 SpringBoot 的差别集中在三处:依赖怎么引、配置写在哪、容器怎么起。业务代码(Service、Mapper、实体类)几乎可以原样搬。

第一步改依赖。SSM 的pom.xml里是一堆带版本号的独立依赖,SpringBoot 换成 starter,版本交给父工程管:

<!-- SSM 阶段的典型依赖:版本要自己对齐,容易冲突 --> <dependency> <groupId>org.springframework</groupId> <artifactId>spring-webmvc</artifactId> <version>5.3.20</version> </dependency> <dependency> <groupId>org.mybatis</groupId> <artifactId>mybatis-spring</artifactId> <version>2.0.7</version> </dependency> <!-- 升级 SpringBoot 后:版本由 parent 统一管理 --> <parent> <groupId>org.springframework.boot</groupId> <artifactId>spring-boot-starter-parent</artifactId> <version>2.7.18</version> </parent> <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>

第二步改配置。SSM 阶段的数据源、事务、MyBatis 的SqlSessionFactory分散在applicationContext-dao.xml、spring-mvc.xml、web.xml里;SpringBoot 阶段全部收进application.yml,web.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?useUnicode=true&characterEncoding=utf8&serverTimezone=Asia/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、HiddenHttpMethodFilter;SpringBoot 里这些要么已默认开启,要么用@Bean注册。拦截器从<mvc:interceptors>改成实现WebMvcConfigurer#addInterceptors。跨域配置从<mvc:cors>改成addCorsMappings。改动清单不长,但要一项项对,我习惯做一个「配置项对照表」贴在旁边,改一项划一项,比凭记忆靠谱。

2.3 数据表设计与索引取舍

七张核心表能撑起全部业务:用户表、企业表、职位表、简历表、投递表、面试邀约表、职位分类表。字段不用多,但几个关键约束必须提前定死,否则后面全是脏数据。

表名关键字段约束与说明
t_userid、openid、role、phone、statusopenid 唯一索引;role 取 JOBSEEKER / COMPANY / ADMIN
t_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) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='投递记录表';

idx_user_status服务的是求职者端「我的投递」按状态筛选,status放第二位是因为查询条件里user_id一定存在。这种「等值列在前、筛选列在后」的顺序不是玄学,把最常用的等值条件放最左边,索引才能被用满。

3. 核心模块实现:登录鉴权、职位检索与简历投递的三条主链路

三条链路跑通,系统就有骨架了。登录决定「你是谁」,检索决定「你能看到什么」,投递决定「你能做什么」。这三块写扎实,剩下的企业端管理、后台审核都是同一套模式的复制。

3.1 小程序登录与 token 鉴权落地

小程序的登录不能照搬账号密码那套。标准流程是:小程序端调wx.login()拿code,把code发给后端;后端用appid+secret+code请求微信的登录凭证校验接口,换回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 id="selectByCondition" resultType="com.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 test="keyword != null and keyword != ''"> AND j.title LIKE CONCAT('%', #{keyword}, '%') </if> <if test="categoryId != null"> AND j.category_id = #{categoryId} </if> <if test="city != null and city != ''"> AND j.city = #{city} </if> <if test="salaryMin != null"> AND j.salary_max &gt;= #{salaryMin} </if> <if test="salaryMax != 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 PageInfo<JobVO> 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); List<JobVO> list = jobMapper.selectByCondition(query); return new PageInfo<>(list); }

Math.min(pageSize, 50)是个小防线。不设上限的话,有人传pageSize=10000就能把整张表拉下来,接口响应时间直接从几十毫秒涨到几秒,演示现场很难看。

提示: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 Map<Integer, Set<Integer>> 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_id+is_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;没有异常就看接口返回的code,401 往 token 查,403 往角色注解查,500 往 Service 查;接口通但数据不对,就把 Mapper 的 SQL 日志打开(logging.level.com.example.recruit.mapper=debug),把实际执行的 SQL 复制到客户端跑一遍,十有八九能看出条件拼错了。

还有一个反直觉的经验:先怀疑配置,再怀疑代码。配置类问题(路径、端口、跨域、时区)占了排查时间的一大半,而它们的表现往往是「代码看着没问题但就是不生效」。改完配置记得重启,热部署对application.yml的改动经常不生效。

5. 后台管理与权限设计:三角色模型与企业资质审核

后台不是可有可无的附属品,它是整套系统「看起来完整」的关键。答辩时如果只有小程序端,老师会问「企业乱发职位怎么办」;有了审核流程,这个问题就变成了加分项。

5.1 三角色权限模型与拦截器落地

角色在t_user.role里存一个字符串,取值JOBSEEKER、COMPANY、ADMIN。不用数字,因为数字在日志和调试时看不出含义,字符串一眼就懂。企业账号和求职者账号共用一张用户表,靠role区分,企业额外在t_company里挂一条档案。

权限的落地分两层。第一层是接口级,靠前面说的@RequireRole注解 + 拦截器。第二层是数据级,靠 Service 里的归属校验。这两层缺一不可:只做接口级,企业 A 拿自己的 token 调GET /api/company/delivery?companyId=2依然能查到企业 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 新增一条,role=JOBSEEKER,token 正常返回
重复登录同一微信号再次进入不新增用户,返回同一 userId
企业发职位(未审核)企业账号 → 发布职位提示「资质审核中」,不落库
企业发职位(已审核)后台审核通过后重试落库成功,status=1
职位检索关键词 + 分类 + 薪资区间组合筛选结果全部命中条件,分页 total 正确
重复投递同一用户对同一职位投两次第二次提示「已投递」,t_delivery 只有一条
越权投递操作企业 A 的 token 操作企业 B 的投递返回 403,数据不变
状态非法跳转对待查看投递直接标记录用返回业务错误,状态不变
登录态过期手动改坏本地 token 后请求返回 401,小程序跳回登录页
企业驳回后职位下线后台驳回某企业该企业名下在线职位全部变为 status=0

这张表的价值不在于「测了什么」,而在于每一条都能当场演。答辩时老师随口问「重复投递怎么处理的」,你直接把表翻到那一行,跑一遍给他看,比口头解释有说服力得多。

答辩陈述我建议按「一个问题 → 一个设计 → 一个证据」的三段式讲,控制在 8 分钟内。问题讲清楚业务场景(求职者和企业信息不对称),设计讲清楚技术取舍(为什么 SSM 起步、为什么用 JWT 不用 session、为什么状态机要锁流转),证据就是上面那张表加上一两个关键代码片段。技术取舍那部分最容易被追问,提前想好「如果重做会怎么改」的答案,比如「如果并发量上来,职位检索会加 Redis 缓存热门分类,但当前数据量下没必要」。

最后说个习惯。我现在每次交付前,都会把数据库清空,用一个全新的微信号从头走一遍完整流程:注册 → 建企业 → 审核 → 发职位 → 投递 → 邀面试 → 录用。这条链路走不通,说明有隐藏的初始化依赖,比如某个分类数据是手动插的、某个状态值是测试时改的。清库重跑一遍能把这些隐患全逼出来,比看一百遍代码管用。希望这套结构帮到你,把毕设做成一份能讲清楚的方案,而不只是一堆能跑的页面。

本文还有配套的精品资源,点击获取

RELATED READING

延伸阅读

更多一线实战笔记与深度复盘,助您持续精进