简介:这是一套面向高校计算机专业学生与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 四张核心表的字段设计
下面是我实际用过的建表 SQL,MySQL 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 ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; -- 岗位表 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`) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; -- 投递记录表:唯一索引防重复投递 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`) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; -- 评价表:双向评价,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`) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;参数说明几个关键点: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> <groupId>com.baomidou</groupId> <artifactId>mybatis-plus-boot-starter</artifactId> <version>3.5.5</version> </dependency> <dependency> <groupId>io.jsonwebtoken</groupId> <artifactId>jjwt-api</artifactId> <version>0.11.5</version> </dependency>分层就是标准的controller / service / mapper / entity / dto。这里有个血泪经验:DTO 和 Entity 一定要分开。我早期图省事直接用 Entity 接请求参数,结果商家提交岗位时把status传成 1,直接绕过审核上架了。后来所有写接口都用 DTO,Entity 只在 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 LambdaUpdateWrapper<Application>() .eq(Application::getId, applicationId) .in(Application::getStatus, 0, 1) .set(Application::getStatus, 2)); if (rows == 0) { throw new BizException("操作失败,请刷新后重试"); } // 检查岗位是否招满,招满则下架 long accepted = applicationMapper.selectCount( new LambdaQueryWrapper<Application>() .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),这样即使两个请求同时进来,数据库行锁也只会让一个成功,另一个rows=0直接返回失败。这比先查再改可靠得多。参数上headcount是招聘人数,招满自动下架,避免学生投递一个已经满员的岗位。
3.4 岗位列表接口:分页 + 多条件筛选
列表接口是前端调用最频繁的,性能要盯住。用 MyBatis-Plus 分页插件,配合索引。查询条件包括关键词、薪资类型、状态,注意status默认只查已上架。
public Page<JobVO> listJobs(JobQueryDTO query) { LambdaQueryWrapper<Job> 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); Page<Job> page = jobMapper.selectPage( new Page<>(query.getPageNum(), query.getPageSize()), wrapper); return page.convert(this::toVO); }逻辑说明:like查询在数据量大时会走全表扫描,校园场景岗位量通常几千条以内没问题,但如果要上量,title上要加全文索引或改用搜索引擎。pageSize前端传参必须做上限校验,我一般限制最大 50,防止有人传pageSize=100000把内存打爆。
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 } // 角色校验:商家后台只允许 role=1 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 :loading="submitting" :disabled="applied" @click="handleApply"> {{ 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,或者显示空白。原因:列表接口只查status=1的岗位,投递记录关联的岗位被过滤掉了。解决:投递记录查询要单独写接口,用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 LambdaUpdateWrapper<Job>() .eq(Job::getStatus, 0) .lt(Job::getCreateTime, deadline) .set(Job::getStatus, 2) .set(Job::getAuditRemark, "超期未审核,系统自动下架")); // 2. 提醒商家处理超 7 天未查看的投递 LocalDateTime remindLine = LocalDateTime.now().minusDays(7); List<Application> stale = applicationMapper.selectList( new LambdaQueryWrapper<Application>() .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,配合接口日志能串起整条链路。校园项目不大,但「能定位问题」比「功能多」重要得多。希望帮到你。
本文还有配套的精品资源,点击获取