Spring Boot+Vue校园兼职平台实战:从数据模型到定时任务

发布时间:2026/10/18 18:54:41
Spring Boot+Vue校园兼职平台实战:从数据模型到定时任务 简介这是一套面向高校计算机专业学生与Java全栈开发学习者的校园兼职平台完整项目源码基于Spring Boot与Vue.js前后端分离架构实现可作为毕业设计、课程设计或全栈练手参考。项目围绕学生求职与企业招聘两大场景涵盖兼职信息发布、简历投递、招聘预约、论坛交流等核心业务模块后端负责业务逻辑、数据库交互与RESTful接口前端负责页面渲染与用户交互并引入Spring Security做认证授权、HTTPS保障传输安全。资源包共1470个文件约74.88MB包含129个Java源文件、69个Vue组件、102个HTML页面、101个CSS样式及大量js、图片与class编译文件另附sql建表脚本、yml配置、bat启动脚本与说明文档目录结构清晰便于快速部署与二次开发。目前已有448人学习下载适合希望掌握前后端分离开发流程、理解企业级项目分层设计的读者参考借鉴。1. 校园兼职平台到底在解决什么问题从信息撮合到双向信用校园兼职这件事表面看是「发个帖子、投个简历」的信息撮合真做起来才发现核心矛盾在两端学生怕被坑商家怕被放鸽子。我在某高校帮一个学生团队做过一版基于 Spring Boot Vue 的校园兼职平台最初以为两周能收工结果光「岗位下架后已投递记录怎么处理」这一个问题就返工了三次。这个标题讲的就是用前后端分离的技术栈把校园场景下的兼职发布、投递、录用、评价这条链路做成一个可运营的系统。它适合谁一是想拿一个完整项目练手的后端或全栈方向的同学二是学校里真有一个勤工助学或社团在跑兼职信息、需要把微信群里的散乱消息沉淀成系统的团队。不适合谁不适合想直接做成商业招聘平台的人——校园场景的合规边界、岗位审核、结算方式都和社招平台完全不同硬套会翻车。这篇笔记按「先想清楚数据模型 → 再搭后端 → 再接前端 → 最后处理那些只有真跑起来才会暴露的坑」的顺序讲中间给可抄的代码和参数。2. 先把数据模型和角色权限定死四张核心表与状态机2.1 为什么先画状态机再写代码新手最容易犯的错是先建表再想业务结果job表里塞了一堆status字段投递表又塞一套最后状态对不上。校园兼职的核心实体其实只有四个用户学生/商家/管理员、岗位、投递记录、评价。真正复杂的是投递记录的状态流转我一般会先把它画成状态机再动手待处理(PENDING) → 已查看(VIEWED) → 已录用(ACCEPTED) → 已完成(FINISHED) ↓ ↓ 已拒绝(REJECTED) 已取消(CANCELLED)状态机定死之后后端每个接口只允许做「当前状态 → 合法下一状态」的迁移非法迁移直接抛业务异常。这一步不做后期一定出现「学生已经被拒了还能点确认录用」这种玄学 bug。2.2 四张核心表的字段设计下面是我实际用过的建表 SQLMySQL 8.0字符集utf8mb4。注意application表上的唯一索引它是防重复投递的第一道闸。-- 用户表用 role 区分学生/商家/管理员校园场景不建议拆多表 CREATE TABLE user ( id BIGINT PRIMARY KEY AUTO_INCREMENT, username VARCHAR(32) NOT NULL UNIQUE COMMENT 登录名, password VARCHAR(100) NOT NULL COMMENT BCrypt 加密后, role TINYINT NOT NULL DEFAULT 0 COMMENT 0学生 1商家 2管理员, real_name VARCHAR(32) DEFAULT NULL COMMENT 实名录用前必填, credit_score INT NOT NULL DEFAULT 100 COMMENT 信用分违约扣分, create_time DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ) ENGINEInnoDB DEFAULT CHARSETutf8mb4; -- 岗位表 CREATE TABLE job ( id BIGINT PRIMARY KEY AUTO_INCREMENT, publisher_id BIGINT NOT NULL COMMENT 发布者 user.id, title VARCHAR(64) NOT NULL, salary DECIMAL(10,2) NOT NULL COMMENT 时薪或日薪, salary_type TINYINT NOT NULL DEFAULT 0 COMMENT 0时薪 1日薪 2一次性, work_time VARCHAR(128) COMMENT 如 周一至周五 18:00-21:00, headcount INT NOT NULL DEFAULT 1 COMMENT 招聘人数, status TINYINT NOT NULL DEFAULT 0 COMMENT 0待审核 1已上架 2已下架 3已满员, audit_remark VARCHAR(255) DEFAULT NULL, create_time DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, KEY idx_publisher (publisher_id), KEY idx_status (status) ) ENGINEInnoDB DEFAULT CHARSETutf8mb4; -- 投递记录表唯一索引防重复投递 CREATE TABLE application ( id BIGINT PRIMARY KEY AUTO_INCREMENT, job_id BIGINT NOT NULL, student_id BIGINT NOT NULL, status TINYINT NOT NULL DEFAULT 0 COMMENT 0待处理 1已查看 2已录用 3已拒绝 4已完成 5已取消, apply_remark VARCHAR(255) COMMENT 学生留言, update_time DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, UNIQUE KEY uk_job_student (job_id, student_id), KEY idx_student (student_id) ) ENGINEInnoDB DEFAULT CHARSETutf8mb4; -- 评价表双向评价direction 区分谁评谁 CREATE TABLE review ( id BIGINT PRIMARY KEY AUTO_INCREMENT, application_id BIGINT NOT NULL, from_user_id BIGINT NOT NULL, to_user_id BIGINT NOT NULL, score TINYINT NOT NULL COMMENT 1-5, content VARCHAR(500), create_time DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, UNIQUE KEY uk_app_from (application_id, from_user_id) ) ENGINEInnoDB DEFAULT CHARSETutf8mb4;参数说明几个关键点credit_score默认 100学生被录用后无故取消扣 10 分低于 60 分限制投递这是校园场景里比「封号」更温和的约束手段。application的uk_job_student唯一索引让重复投递在数据库层就被拦掉比在 Service 里查一次再插入可靠得多——并发下查了再插是有窗口的。job.status里我特意留了「待审核」因为校园岗位必须有人工审核环节直接上架等于把风险全甩给学生。2.3 权限控制别用注解一把梭角色权限我见过两种做法一种是在 Controller 上堆PreAuthorize另一种是写一个拦截器统一判。校园兼职平台的权限规则其实很集中——「学生只能操作自己的投递」「商家只能操作自己发布的岗位下的投递」这种基于资源归属的判断用注解表达会很啰嗦。我一般写一个PermissionChecker组件在 Service 层显式调用Component public class PermissionChecker { // 校验当前登录用户是否为该岗位的发布者 public void checkJobOwner(Long jobId, Long currentUserId) { Job job jobMapper.selectById(jobId); if (job null) { throw new BizException(岗位不存在); } if (!job.getPublisherId().equals(currentUserId)) { throw new BizException(403, 无权操作该岗位); } } }逻辑说明把「资源归属校验」从权限框架里拆出来好处是规则可读、可单测出问题时堆栈一眼能看到是哪条业务规则拦的。参数上currentUserId从 JWT 解析后放进ThreadLocal不要在 Controller 里到处传。3. 后端接口怎么落地Spring Boot 分层与三个关键接口3.1 项目分层与依赖选型后端用 Spring Boot 3.x MyBatis-Plus Spring Security JWT。选 MyBatis-Plus 而不是 JPA是因为校园项目里分页查询、条件筛选特别多MyBatis-Plus 的LambdaQueryWrapper写起来更顺手。依赖里必须有的是spring-boot-starter-security、mybatis-plus-boot-starter、jjwt、hutool工具类省事。dependency groupIdcom.baomidou/groupId artifactIdmybatis-plus-boot-starter/artifactId version3.5.5/version /dependency dependency groupIdio.jsonwebtoken/groupId artifactIdjjwt-api/artifactId version0.11.5/version /dependency分层就是标准的controller / service / mapper / entity / dto。这里有个血泪经验DTO 和 Entity 一定要分开。我早期图省事直接用 Entity 接请求参数结果商家提交岗位时把status传成 1直接绕过审核上架了。后来所有写接口都用 DTOEntity 只在 Service 内部流转。3.2 投递接口状态机 唯一索引双保险投递是并发最容易出问题的接口。学生手抖点两下、网络重试都可能触发重复投递。我的做法是Service 层先查状态数据库唯一索引兜底捕获唯一键冲突异常转成友好提示。Service public class ApplicationService { Transactional(rollbackFor Exception.class) public void apply(Long jobId, Long studentId, String remark) { Job job jobMapper.selectById(jobId); // 1. 岗位必须处于已上架状态 if (job null || job.getStatus() ! 1) { throw new BizException(岗位不存在或已下架); } // 2. 不能投递自己发布的岗位商家账号误操作 if (job.getPublisherId().equals(studentId)) { throw new BizException(不能投递自己发布的岗位); } // 3. 信用分门槛 User student userMapper.selectById(studentId); if (student.getCreditScore() 60) { throw new BizException(信用分不足暂无法投递); } Application app new Application(); app.setJobId(jobId); app.setStudentId(studentId); app.setApplyRemark(remark); app.setStatus(0); try { applicationMapper.insert(app); } catch (DuplicateKeyException e) { // 唯一索引 uk_job_student 拦截重复投递 throw new BizException(你已投递过该岗位); } } }逻辑说明三步校验的顺序有讲究——先查岗位状态最可能失败、代价最小再查归属最后查信用分。Transactional保证插入和后续可能的计数更新在同一事务。参数上remark限制 255 字符前端也要同步限制否则超长会直接抛 SQL 异常用户看到的是 500 而不是友好提示。3.3 录用接口状态迁移必须加乐观锁录用是状态机的核心迁移也是最容易并发出错的地方。商家和另一个管理员同时点「录用」可能把两个学生都录成同一岗位。我用 MyBatis-Plus 的乐观锁插件在application表加version字段// 录用只允许从 待处理/已查看 迁移到 已录用 Transactional(rollbackFor Exception.class) public void accept(Long applicationId, Long publisherId) { Application app applicationMapper.selectById(applicationId); PermissionChecker.checkJobOwner(app.getJobId(), publisherId); if (app.getStatus() ! 0 app.getStatus() ! 1) { throw new BizException(当前状态不可录用); } // 用 UpdateWrapper 带状态条件避免并发覆盖 int rows applicationMapper.update(null, new LambdaUpdateWrapperApplication() .eq(Application::getId, applicationId) .in(Application::getStatus, 0, 1) .set(Application::getStatus, 2)); if (rows 0) { throw new BizException(操作失败请刷新后重试); } // 检查岗位是否招满招满则下架 long accepted applicationMapper.selectCount( new LambdaQueryWrapperApplication() .eq(Application::getJobId, app.getJobId()) .eq(Application::getStatus, 2)); Job job jobMapper.selectById(app.getJobId()); if (accepted job.getHeadcount()) { job.setStatus(3); jobMapper.updateById(job); } }逻辑说明关键在update的where条件里带上status in (0,1)这样即使两个请求同时进来数据库行锁也只会让一个成功另一个rows0直接返回失败。这比先查再改可靠得多。参数上headcount是招聘人数招满自动下架避免学生投递一个已经满员的岗位。3.4 岗位列表接口分页 多条件筛选列表接口是前端调用最频繁的性能要盯住。用 MyBatis-Plus 分页插件配合索引。查询条件包括关键词、薪资类型、状态注意status默认只查已上架。public PageJobVO listJobs(JobQueryDTO query) { LambdaQueryWrapperJob wrapper new LambdaQueryWrapper(); wrapper.eq(Job::getStatus, 1); // 只查已上架 if (StrUtil.isNotBlank(query.getKeyword())) { wrapper.like(Job::getTitle, query.getKeyword()); } if (query.getSalaryType() ! null) { wrapper.eq(Job::getSalaryType, query.getSalaryType()); } wrapper.orderByDesc(Job::getCreateTime); PageJob page jobMapper.selectPage( new Page(query.getPageNum(), query.getPageSize()), wrapper); return page.convert(this::toVO); }逻辑说明like查询在数据量大时会走全表扫描校园场景岗位量通常几千条以内没问题但如果要上量title上要加全文索引或改用搜索引擎。pageSize前端传参必须做上限校验我一般限制最大 50防止有人传pageSize100000把内存打爆。4. 前端 Vue 怎么接路由守卫与状态管理4.1 路由与角色守卫前端用 Vue 3 Vue Router Pinia Element Plus。路由守卫是权限控制的第一道门但记住它只是体验层真正的权限在后端。守卫里根据 Pinia 里的userStore.role决定能否进入商家后台。// router/index.js router.beforeEach((to, from, next) { const userStore useUserStore() // 需要登录的路由 if (to.meta.requiresAuth !userStore.token) { next({ path: /login, query: { redirect: to.fullPath } }) return } // 角色校验商家后台只允许 role1 if (to.meta.role ! undefined userStore.role ! to.meta.role) { next(/403) return } next() })逻辑说明to.meta.role在路由配置里声明比如商家后台路由写meta: { requiresAuth: true, role: 1 }。redirect参数让用户登录后能回到原来想去的页面这个细节不做用户体验会明显掉一档。4.2 用 Pinia 管理登录态与请求拦截Token 存 Pinia localStorage请求拦截器统一加Authorization头响应拦截器统一处理 401 和业务异常。// utils/request.js import axios from axios import { useUserStore } from /stores/user import { ElMessage } from element-plus const service axios.create({ baseURL: /api, timeout: 10000 }) service.interceptors.request.use(config { const userStore useUserStore() if (userStore.token) { config.headers.Authorization Bearer ${userStore.token} } return config }) service.interceptors.response.use( res { // 后端统一返回 { code, msg, data } if (res.data.code ! 200) { ElMessage.error(res.data.msg || 请求失败) return Promise.reject(new Error(res.data.msg)) } return res.data.data }, err { if (err.response?.status 401) { useUserStore().logout() location.href /login } return Promise.reject(err) } ) export default service逻辑说明timeout设 10 秒校园网络环境一般够用。响应拦截器里把code ! 200统一弹提示业务代码里就不用每个接口都写错误处理。注意location.href跳转而不是router.push因为拦截器里拿不到 router 实例硬拿会引入循环依赖。4.3 投递按钮的防抖与状态回显投递按钮是前端最容易出问题的地方。用户点一下没反应会连点必须做防抖 loading 态。同时按钮要根据投递状态回显——已投递的岗位显示「已投递」而不是还能点。template el-button :loadingsubmitting :disabledapplied clickhandleApply {{ applied ? 已投递 : 立即投递 }} /el-button /template script setup import { ref } from vue const submitting ref(false) const applied ref(props.job.applied) // 列表接口返回该学生是否已投递 async function handleApply() { if (submitting.value || applied.value) return submitting.value true try { await applyJob(props.job.id) applied.value true ElMessage.success(投递成功) } finally { submitting.value false } } /script逻辑说明applied来自列表接口后端在查岗位时用left join application带上当前学生的投递状态避免前端再发一次请求。submitting防抖只是体验层真正的防重复还是靠后端唯一索引。5. 避坑与排查五个真跑起来才会暴露的问题5.1 岗位下架后已投递记录变成孤儿数据现象商家把岗位下架学生端「我的投递」里点进去 404或者显示空白。原因列表接口只查status1的岗位投递记录关联的岗位被过滤掉了。解决投递记录查询要单独写接口用left join job把岗位信息带出来即使岗位下架也返回快照字段标题、薪资。更彻底的做法是在application表冗余存一份岗位标题和薪资快照岗位删除也不影响历史记录。5.2 信用分扣分逻辑被并发击穿现象学生取消投递信用分偶尔只扣了一次但日志显示扣了两次。原因credit_score credit_score - 10这种读改写在并发下会丢更新。解决改成数据库原子操作UPDATE user SET credit_score credit_score - 10 WHERE id ? AND credit_score 10用where条件保证不会扣成负数同时避免读改写。5.3 JWT 过期后前端一直弹 401 循环现象Token 过期页面疯狂弹「登录已过期」控制台一堆 401。原因响应拦截器里 401 跳登录但登录页本身也在发请求形成循环。解决拦截器里加白名单判断登录、注册接口的 401 不触发跳转同时用一个isRedirecting标志位保证同一时间只跳一次。5.4 商家上传的岗位图片路径在打包后失效现象本地开发图片正常部署到服务器后图片 404。原因图片存到了项目目录下的static打包后目录结构变了。解决图片统一存到项目外的独立目录如/data/upload通过 Nginx 或 Spring 的ResourceHandler映射访问数据库只存相对路径。别把用户上传的文件放进 jar 包里。5.5 分页查询在数据量上来后变慢现象岗位到几千条后列表接口响应从 50ms 涨到 800ms。原因like %关键词%前置通配符导致索引失效加上order by create_time没走索引。解决create_time加索引关键词搜索如果必须前置通配考虑上全文索引或把搜索交给独立服务。校园场景数据量不大加索引基本够用但要知道这个边界在哪。6. 让平台真正跑起来的一个技巧用定时任务做岗位生命周期管理系统能跑通只是及格线真正让一个校园兼职平台「活」起来靠的是岗位的自动生命周期管理。我最后加的一个功能是定时任务它解决了一个很实际的问题商家发完岗位就不管了岗位挂在那里几个月学生投了没人理体验极差。用 Spring 的Scheduled每天凌晨跑一次做三件事把超过 30 天未处理的待审核岗位自动下架把已满员的岗位状态同步为「已满员」给投递超过 7 天仍是「待处理」的记录自动提醒商家。Component public class JobLifecycleTask { Scheduled(cron 0 0 2 * * ?) // 每天凌晨 2 点执行 public void cleanStaleJobs() { // 1. 超过 30 天未审核的岗位自动下架 LocalDateTime deadline LocalDateTime.now().minusDays(30); jobMapper.update(null, new LambdaUpdateWrapperJob() .eq(Job::getStatus, 0) .lt(Job::getCreateTime, deadline) .set(Job::getStatus, 2) .set(Job::getAuditRemark, 超期未审核系统自动下架)); // 2. 提醒商家处理超 7 天未查看的投递 LocalDateTime remindLine LocalDateTime.now().minusDays(7); ListApplication stale applicationMapper.selectList( new LambdaQueryWrapperApplication() .eq(Application::getStatus, 0) .lt(Application::getUpdateTime, remindLine)); for (Application app : stale) { // 这里只做站内信不直接发短信避免打扰 noticeService.send(app.getJobId(), 有投递超过 7 天未处理请及时查看); } } }逻辑说明cron表达式0 0 2 * * ?表示每天 2 点执行避开白天高峰。第一步用UpdateWrapper批量更新比查出来再逐条改高效得多。第二步只发站内信不直接发短信是因为校园场景里短信成本高、打扰大站内信 前端红点足够。参数上 30 天和 7 天都是经验值可以根据学校实际节奏调但别设太短否则商家会觉得系统在催命。这个定时任务上线后最明显的变化是「僵尸岗位」少了学生投递的响应率上来了。我踩过的坑是一开始把提醒逻辑写在cleanStaleJobs里任务一超时整个方法回滚提醒也没发出去。后来把「清理」和「提醒」拆成两个独立方法各自加try-catch一个失败不影响另一个。做这类定时任务永远假设它会失败把副作用隔离开。还有一个习惯我一直在用所有定时任务都加一行日志记录本次处理了多少条出问题时不用猜。日志里带上traceId配合接口日志能串起整条链路。校园项目不大但「能定位问题」比「功能多」重要得多。希望帮到你。本文还有配套的精品资源点击获取

关于本文作者

来自尧图内容编辑团队

尧图内容编辑团队 内容团队

尧图内容编辑团队

本文由尧图网络内容编辑团队执笔。团队由资深项目经理、前端工程师与设计师组成,所有内容均来自亲手交付的真实项目,先讲清问题、再给出可落地的解法。尧图深耕北京网站建设十年,服务过京华建材集团、智造科技等各行业客户,把一线经验沉淀为可复用的行业观察。

  • 十年建站经验,覆盖建材、制造、服务、文创等
  • 项目经理把关选题与事实准确性
  • 工程师与设计师联合撰写专业细节
  • 统一编辑规范,保证文风与排版一致
  • 每月复盘转化数据,迭代选题方向

延伸阅读

相关资讯与近期热门内容

深度阅读推荐

建站决策前值得细读的三篇

网站改版的5个关键决策
2024-08-12

网站改版的5个关键决策

什么时候该改版、改到什么程度、如何避免流量掉光,京华建材集团改版复盘给出答案。

获取专属建站方案

看完文章,把您的行业与预算告诉我们,免费获取一份量身定制的官网建设方案与报价。

立即免费咨询