☰
基于Spring Boot与Vue的前后端分离校园兼职平台设计与实践
2026/10/14 7:25:54 网站建设 项目流程

1. 为什么校园兼职平台最终选择了“前后端分离”

先说一个很常见的现象:很多同学做毕业设计或者项目练手时,特别喜欢把Spring Boot和Vue放在同一个工程里,页面用Thymeleaf或者JSP渲染,后台接口顺手写在Controller里,看起来“省事”,实际上后期改起来想砸电脑。

我做校园兼职网站这个项目时,第一件事就是决定前后端彻底分离。原因很简单:校园兼职场景天然就有三类使用角色——想找兼职的学生、发布岗位的商家/校内组织、做审核与数据管理的平台管理员。这三类角色的操作路径差异非常大,学生端追求快速筛选和报名,商家端需要发布与管理岗位,管理端则要处理审核、统计和用户管理。如果全部塞在一套服务端渲染的页面里,权限控制、页面复用、接口复用都会变得异常痛苦。

前后端分离之后,后端只负责提供RESTful API,前端用Vue单独维护一套单页应用。这样做有四个立竿见影的好处:

  • 开发可以并行。后端把接口文档定义好,前端就同步开始写页面,不用互相等。
  • 复用特别方便。同一个接口,Web端能调,以后如果要做一个管理用的移动端H5或者小程序,直接复用后端接口就行。
  • 部署灵活。前端打包成静态资源扔到Nginx,后端打包成Jar独立运行,两者互不拖累。
  • 权限模型清晰。前端根据用户角色控制页面可见性,后端通过JWT做统一鉴权,谁该看什么、能操作什么,边界分明。

当然,也有人会问:单体不香吗?单体在超小型项目(比如几百行代码的Demo)里确实更直接,但只要你涉及多角色、多业务模块,迟早会遇到模板渲染逻辑与业务逻辑纠缠不清的窘境。我做项目时有一个原则:复杂度上来了,就要用结构去对抗复杂度,而不是靠记忆力去对抗。

这个项目对外的技术标签是“Spring Boot + Vue”,本质上做的就是一个多角色兼职信息撮合平台。技术选型上我做了如下搭配:

层次选型理由
后端框架Spring Boot 2.7.x生态成熟,资料多,遇到问题容易搜到解决方案
持久层MyBatis-Plus单表CRUD几乎不用写SQL,条件构造器好用,适合快速开发
数据库MySQL 8.0稳定,学生兼职这种数据量级别的项目绰绰有余
权限认证JWT + Spring Security无状态认证,适合前后端分离场景,扩展性好
前端框架Vue 3 + ViteVue 3的组合式API写起来比Vue 2的选项式API更清爽,Vite启动速度快
UI组件库Element Plus表单、表格、弹窗直接套用,后台管理页面效率极高
HTTP工具Axios配合拦截器统一处理token和错误提示,很方便

这个组合不是最炫的,但一定是最稳的。我见过有人为了炫技上了微服务、上了Redis缓存一堆热点数据,结果项目做完连本地启动都要调半天。我的建议是:校园兼职平台这种规模,单机Spring Boot + MySQL足够,别过度设计。核心技术点应该花在“业务逻辑的严谨性”和“权限控制的完整性”上。

2. Spring Boot侧的关键模块:从数据库表到业务接口

2.1 数据库表设计:先想清楚业务关系

写代码之前,我第一件事永远是画数据库表。很多人喜欢上来就写实体类,然后被字段缺漏虐得体无完肤。这个项目我设计了7张核心表,每张表之间关系清晰,互不冗余:

表名核心字段说明
t_userid, username, password, role, phone, nickname, avatar, status用户主表,role区分学生、商家、管理员
t_businessid, user_id, company_name, license_no, contact_name, intro商家扩展信息,与学生角色字段分离
t_jobid, title, type, salary, location, description, status, publisher_id, publish_time兼职信息表,status控制上下架与审核状态
t_applyid, job_id, user_id, apply_time, status, remark报名记录表,一个学生对同一岗位只能报名一次(做唯一索引)
t_favoriteid, job_id, user_id, collect_time收藏表,同样是联合唯一索引
t_noticeid, user_id, content, is_read, create_time站内通知表,报名审核结果推送给学生
t_categoryid, name, sort兼职分类表,比如家教、餐饮、促销、实习

展开说几个容易踩坑的细节。

第一个是密码字段。我存的是BCrypt加密后的字符串,长度要留足60个字符以上,千万别用varchar(20)去存,不然一加密就报Data too long。这个我见太多人踩了。

第二个是唯一索引。t_apply表要在(job_id, user_id)上建联合唯一索引,t_favorite同理。没有这一步,恶意用户可以写个循环脚本疯狂重复报名同一个岗位,把数据库刷出几十条重复记录。唯一约束从数据库层面兜底,比代码里if判断靠谱得多。

第三个是状态字段。t_job的status至少包含四种:0待审核、1已发布、2已下架、3审核不通过。很多新手只做一个上架/下架,但商家发布的兼职信息必须经过平台管理员审核才能公开,这是校园兼职平台最核心的信任机制。没有审核环节,垃圾信息、虚假兼职满天飞,项目基本没有落地价值。

2.2 核心接口设计:别把Controller写成上帝类

接口设计上,我遵循了一个核心原则:一个Controller只负责一类资源的操作。比如JobController只处理兼职信息的增删改查和上下架,ApplyController只处理报名相关,不要搞一个超级Controller把所有逻辑全塞进去。

以兼职模块为例,我定义了这些接口:

方法路径功能权限
GET/api/job/page分页查询兼职列表,支持关键词/分类/薪资范围筛选登录即可
GET/api/job/{id}查看岗位详情登录即可
POST/api/job发布兼职商家/管理员
PUT/api/job/{id}修改自己发布的兼职商家本人/管理员
DELETE/api/job/{id}下线并删除兼职商家本人/管理员
PUT/api/job/{id}/audit审核通过/驳回兼职管理员

这里有一个非常重要的业务规则:学生只能看到status为1(已发布)的岗位,商家只能操作自己发布的岗位,管理员可以审核所有岗位。这些规则不能只在前端做按钮隐藏,后端Service层必须逐一校验。

对于学生端的分页查询,我用的是MyBatis-Plus的LambdaQueryWrapper,动态拼接查询条件,没有任何手写SQL拼接,从源头规避了SQL注入风险:

@Override public IPage<JobVO> pageJobs(JobQueryDTO queryDTO) { Page<Job> page = new Page<>(queryDTO.getCurrent(), queryDTO.getSize()); LambdaQueryWrapper<Job> wrapper = new LambdaQueryWrapper<>(); // 只显示审核通过且上架的岗位 wrapper.eq(Job::getStatus, JobStatusEnum.PUBLISHED.getCode()); // 按分类筛选 if (StringUtils.hasText(queryDTO.getCategory())) { wrapper.eq(Job::getType, queryDTO.getCategory()); } // 按关键词模糊搜索标题或描述 if (StringUtils.hasText(queryDTO.getKeyword())) { wrapper.and(w -> w.like(Job::getTitle, queryDTO.getKeyword()) .or().like(Job::getDescription, queryDTO.getKeyword())); } // 按薪资范围筛选 if (queryDTO.getMinSalary() != null) { wrapper.ge(Job::getSalary, queryDTO.getMinSalary()); } wrapper.orderByDesc(Job::getPublishTime); // ... 分页查询并组装VO }

注意这里加了一个条件:学生端只能查status=已发布的岗位。这个条件必须放在Service层强制设置,而不是等前端传来。有些同学喜欢让前端把所有参数都传过来,接口变成“你说查什么我就查什么”,这相当于把数据库裸奔给用户了。

2.3 报名流程:事务和唯一性校验缺一不可

报名这块业务逻辑虽然简单,但坑特别多。我的核心逻辑如下:

  1. 前端传jobId过来,后端先查岗位是否存在且状态是已发布。
  2. 检查当前登录用户的身份,必须是学生用户,商家不能报名。
  3. 检查是否已报名过(查t_apply表的唯一索引,捕抓DuplicateKeyException兜底)。
  4. 检查是否报名人数已达上限(如果岗位设置了人数限制)。
  5. 插入报名记录,将岗位status更新到报名人数+1(这一步需要乐观锁,防止并发报名超卖)。
  6. 给商家和管理员发送站内通知。

报名人数并发超卖这个点值得展开说说。校园兼职的岗位比如写明“招2人”,如果一个瞬间有5个学生同时点击报名,普通代码“先查人数再更新”在高并发下会全部通过检查,最后招满变超额。我的做法是对t_job表加一个version字段做乐观锁,更新时带上version条件:

int rows = jobMapper.update(null, new LambdaUpdateWrapper<Job>() .eq(Job::getId, jobId) .eq(Job::getVersion, job.getVersion()) .setSql("apply_count = apply_count + 1, version = version + 1")); if (rows == 0) { throw new BizException("报名人数已满,请选择其他岗位"); }

这就是典型的“乐观锁防并发”。对这个项目来说足够用,不必上Redis分布式锁,加了反而徒增复杂度。

2.4 统一响应与全局异常:前后端开发效率的分水岭

前后端分离开发中,最影响联调效率的就是接口返回格式不统一。我在项目里定义了一个统一的响应体Result,结构非常简单:

{ "code": 200, "message": "success", "data": { } }

所有接口返回这个结构,前端在Axios响应拦截器里只需判断code是不是200,不用每个接口单独写try-catch。异常处理统一用@RestControllerAdvice兜底,业务异常返回400+BizException的message,参数校验异常自动返回字段错误信息,系统错误返回500。

这套设计花了不到半天时间,但后面联调省了几倍时间。好项目不是体现在某个接口写得多华丽,而是体现在这些“所有人都在用但没人重视”的公共部分上。

3. Vue侧不是套模板就够了:页面规划、状态管理、跨域调试

3.1 页面结构:一张图讲清楚前端要做什么

网上能下载的Vue校园兼职模板很多,但下载下来的代码质量参差不齐,而且很多是Vue2+webpack的老项目,升级成本高。我更建议自己基于Vue3从零搭建,工作量完全在可控范围内。

前端页面我按角色拆成三大块:

  • 学生端:首页(兼职列表+搜索筛选)、岗位详情页、我的报名、我的收藏、个人中心。
  • 商家端:岗位管理(发布/编辑/下架)、报名管理(查看报名学生、设置录取状态)、数据统计。
  • 管理后台:用户管理、岗位审核、分类管理、平台数据概览。

页面之间用Vue Router做路由导航,所有涉及登录后的页面统一挂在布局组件下,左侧菜单根据角色动态渲染。

3.2 路由守卫与动态菜单:角色控制不能只靠隐藏按钮

这是前端最容易被忽略的安全设计。很多初学者会写“根据角色v-if隐藏某个按钮”,但隐藏按钮只是视觉上的,不代表用户访问不了。路由层面必须做拦截。

我用的方式是在路由meta字段里标记角色:

{ path: '/business/jobs', name: 'BusinessJobs', component: () => import('@/views/business/Jobs.vue'), meta: { roles: ['BUSINESS', 'ADMIN'], requiresAuth: true } }

然后在全局前置守卫里做校验:

router.beforeEach((to, from, next) => { const token = localStorage.getItem('accessToken'); if (to.meta.requiresAuth && !token) { next('/login'); return; } const userInfo = JSON.parse(localStorage.getItem('userInfo') || '{}'); if (to.meta.roles && !to.meta.roles.includes(userInfo.role)) { next('/403'); return; } next(); });

这里有一点必须强调:前端路由守卫只是体验优化,真正的安全边界在后端。就算有人绕过前端直接调接口,后端依然会因为token里没有对应角色而拒绝请求。前端所做的只是让正常用户“看不到”进不去的页面,后端做的才是“就算你硬闯也进不来”。

3.3 Axios封装与请求拦截:把token和错误处理统一管起来

前后端分离项目中,每个请求都要带上token,每个响应都要处理登录失效,如果每个页面单独写一遍就是灾难。我在src/utils/request.js里统一封装了Axios实例:

const service = axios.create({ baseURL: '/api', timeout: 15000 }); // 请求拦截器:自动附带token service.interceptors.request.use(config => { const token = localStorage.getItem('accessToken'); if (token) { config.headers['Authorization'] = 'Bearer ' + token; } return config; }); // 响应拦截器:统一处理业务错误和登录失效 service.interceptors.response.use( response => { const res = response.data; if (res.code === 200) { return res.data; } if (res.code === 401) { localStorage.removeItem('accessToken'); localStorage.removeItem('userInfo'); router.push('/login'); ElMessage.error('登录已过期,请重新登录'); return Promise.reject(new Error(res.message)); } ElMessage.error(res.message || '请求失败'); return Promise.reject(new Error(res.message)); }, error => { ElMessage.error(error.message || '网络异常,请稍后重试'); return Promise.reject(error); } );

封装完之后,业务代码里调接口极度清爽,比如报名接口就一行:

await applyJob(jobId); ElMessage.success('报名成功');

3.4 跨域调试:vite代理配错是新手最常见的问题

本地开发时前端跑在5173端口,后端跑在8080端口,直接请求必然跨域。解决方式有两种:后端加CORS配置,或者前端用代理。我推荐前端代理,因为这样后端不用暴露一组跨域配置,生产环境也用不上。

// vite.config.js export default defineConfig({ plugins: [vue()], server: { port: 5173, proxy: { '/api': { target: 'http://localhost:8080', changeOrigin: true } } } });

这个配置的意思:所有以/api开头的请求,都会转发到http://localhost:8080,并且浏览器看到的请求地址还是localhost:5173,从源头消除跨域。

生产部署时则用Nginx做反向代理,把/api前缀的请求转发到后端端口。后面部署章节我会详细说。

3.5 组件提炼:让页面代码不再又臭又长

写Vue页面最容易犯的错就是把所有逻辑堆在一个.vue文件里,一个文件上千行,后期维护想死。我提炼了几个通用组件:

  • JobCard.vue:兼职信息卡片,学生端首页和收藏页复用。
  • StatusTag.vue:状态标签组件,根据传入的status展示不同颜色和文案。
  • PaginationBar.vue:分页条组件,统一封装el-pagination的配置和事件。
  • EmptyPlaceholder.vue:空状态占位组件,列表为空时展示。

页面组件化之后,最大的感受是“改一个组件,所有页面同步生效”。比如状态标签的文案从“已报名”改成“已投递”,只需要改StatusTag一处,不用满项目搜索替换。

3.6 状态管理:Pinia的用法与token持久化

这个项目的状态管理我用的是Pinia,简单说就是把登录用户信息、角色、token等全局状态统一管理起来,任何组件都能轻松读取和修改。

比较关键的设计是token持久化。刷新页面后,Pinia内存里的状态会丢失,所以要把token和用户信息存一份到localStorage。我封装了一个auth模块,登录成功时调用saveAuth方法同时写入Pinia和localStorage,应用初始化时再读取localStorage回填到Pinia。这样刷新页面后用户不会掉线。

4. 权限与安全:这块不做好,项目一上线就翻车

4.1 角色权限模型:三套权限不是靠if-else硬写

这个项目的核心角色有学生、商家、管理员三类,权限设计上我用了一套简单的RBAC模型。不需要复杂到建三张表去维护角色和权限的关系,因为角色固定、功能固定,直接用表驱动和枚举就能搞定。

操作学生商家管理员
浏览兼职列表允许允许允许
报名兼职允许禁止禁止
发布兼职禁止允许(需审核)允许(直接发布)
审核兼职禁止禁止允许
管理用户禁止禁止允许
修改/下架岗位禁止仅限本人允许一切

后端实现方式是在Controller方法上用Spring Security的@PreAuthorize注解,或者自定义一个@RequireRole注解,标注在方法上。比如报名接口:

@PostMapping("/apply") @RequireRole(RoleEnum.STUDENT.getCode()) public Result<Void> apply(@RequestBody ApplyDTO dto) { applyService.apply(dto.getJobId()); return Result.success(); }

角色校验的逻辑放在Interceptor层统一处理,跟业务代码解耦。这样业务方法里只关注“这个用户到底能不能报这个岗”,不用关心“这个用户是不是学生”。框架一旦搭好,后面写每个接口都很快。

4.2 越权漏洞修复:一段真实的排错经历

这里分享一个让我印象特别深的bug。当时我写完岗位修改接口后,测试同学报告了一个问题:任何商家登录后,把URL里的岗位id改成别人的,就能修改别人的兼职信息。

我排查的过程是这样的:先看Controller,接收了id参数,方法里调用了jobService.updateById(job),完全没有判断这个岗位的publisher_id是不是当前登录用户。这是一个非常典型的IDOR漏洞(不安全的直接对象引用),攻击者不需要任何复杂技巧,改一下URL数字就行了。

修复方案是加一道归属校验:

@Override public void updateJob(JobDTO dto, Long currentUserId) { Job existing = jobMapper.selectById(dto.getId()); if (existing == null) { throw new BizException("岗位不存在"); } if (!existing.getPublisherId().equals(currentUserId) && !isAdmin(currentUserId)) { throw new BizException("无权操作他人岗位"); } // 校验通过后才执行更新 jobMapper.updateById(job); }

这个修复带来的启发是:权限校验不能只停留在“谁能调用这个接口”层面,还要深入到“这个人是否拥有这份资源”层面。前者是角色权限,后者是数据归属权限,两者缺一不可。

4.3 密码加密与敏感信息脱敏:细节决定项目档次

密码加密直接用Spring Security内置的BCryptPasswordEncoder,千万不要用MD5。原因很简单:MD5没有加盐机制,彩虹表一查就能反推出明文密码,而BCrypt每次加密结果都不同,攻击者即使拿到数据库也没法直接破解。

敏感信息脱敏也很重要。兼职列表接口返回的商家联系电话,如果直接返回完整手机号,任何人都能抓包拿到并恶意骚扰。我的做法是VO层做脱敏处理,手机号中间四位用星号代替:

public String getContactPhoneMasked() { if (contactPhone == null || contactPhone.length() != 11) { return contactPhone; } return contactPhone.substring(0, 3) + "****" + contactPhone.substring(7); }

只有报名成功后,学生才能通过“查看联系方式”接口拿到完整号码。这个接口同样有过期时间限制,比如报名后3天内有效,过了时间就失效。

4.4 参数校验:别让用户绕过前端直接打你接口

前端表单做了校验,但前端校验只是辅助,后端必须做二次校验。我在DTO字段上使用了Hibernate Validator注解:

public class JobDTO { @NotBlank(message = "岗位标题不能为空") @Size(max = 50, message = "岗位标题不能超过50个字符") private String title; @NotNull(message = "薪资不能为空") @DecimalMin(value = "1", message = "薪资必须大于0") private BigDecimal salary; @NotBlank(message = "工作地点不能为空") private String location; @NotBlank(message = "岗位描述不能为空") @Size(max = 2000, message = "岗位描述不能超过2000个字符") private String description; }

Controller上加上@Valid注解,参数校验出错时,全局异常处理器会把具体的字段错误信息返回给前端。这样做的好处是:就算有人绕过前端直接Postman打接口,烂数据也进不了数据库。

5. 部署上线与运行期排查:我踩过的最典型的三个坑

5.1 环境准备与打包部署

本地开发环境我用的是:JDK 1.8 + Maven 3.8 + Node 16 + MySQL 8.0。打开项目就能跑,依赖全部在pom.xml和package.json里。

生产部署时,前后端是分开部署的。后端打包命令:

mvn clean package -DskipTests

打出来的jar包直接扔到服务器:

java -jar campus-job-0.0.1-SNAPSHOT.jar --spring.profiles.active=prod

前端打包命令:

npm run build

生成dist目录,里面是纯静态文件,把这些文件丢给Nginx托管,同时配置反向代理转发/api到后端端口。

Nginx配置核心部分长这样:

server { listen 80; server_name 你的服务器IP或域名; # 前端静态资源 location / { root /var/www/campus-job/dist; index index.html; try_files $uri $uri/ /index.html; # Vue Router history模式必须配这个 } # 后端接口反向代理 location /api/ { proxy_pass http://127.0.0.1:8080/api/; proxy_set_header Host $host; proxy_set_header X-Real-IP $remote_addr; } }

这里有个特别容易踩的坑:Vue Router用history模式时,刷新某个子路由页面会报404。原因很简单,Nginx默认找不到对应路径的静态文件。上面配置里的try_files $uri $uri/ /index.html;就是为了解决这个问题,所有找不到的路径都回退到index.html,让前端路由自己处理。

5.2 坑一:MySQL时区引发的数据错乱

我第一次部署上线后,发现发布的兼职信息发布时间比实际时间早了8个小时。排查过程不复杂:MySQL的time_zone默认是系统时区,而系统时区是UTC,跟北京时间差8小时。解决办法是在数据库连接URL上明确指定时区:

jdbc:mysql://localhost:3306/campus_job?useSSL=false&serverTimezone=Asia/Shanghai&characterEncoding=utf8

同时在配置里指定了Jackson的日期格式,保证前后端传输时间格式统一。顺手把MySQL的全局时区也改了:

SET GLOBAL time_zone = '+08:00';

5.3 坑二:本地能跑,服务器上接口全部502

部署完发现,页面能打开,但所有请求接口都报502。排查思路是这样的:

先看Nginx错误日志,发现connect() failed (111: Connection refused)。这通常意味着后端服务没有监听在Nginx所指定的端口上。我用ss -lntp查了一下8080端口,确实没有进程监听。但明明java -jar已经启动了,日志也显示Tomcat started。

后来发现是jar包在启动过程中就崩了。查Spring Boot日志才发现,服务器上的MySQL账号密码跟本地不一样,但application-prod.yml文件被覆盖时用了错误的数据库密码。连接失败后应用启动失败,所以端口没监听。

修复很简单,改对数据库密码重新启动。但这个排查过程特别有代表性:遇到502,大概率不是Nginx配置问题,而是后端服务本身挂了或没起来。排查顺序应该是:先看后端进程是否存活,再看500端口能否访问,最后才怀疑Nginx。

5.4 坑三:线程池与连接池配置不当导致高峰期卡顿

项目在模拟并发测试时发现了一个隐患:当同时有几十个学生访问首页时,接口响应时间明显变慢。排查后发现是Tomcat默认线程池太小,同时数据库连接池的初始连接数太低,每个请求都在等待获取连接。

我的调整方案是:

server: tomcat: threads: max: 200 min-spare: 20 spring: datasource: hikari: maximum-pool-size: 50 minimum-idle: 10

对于校园兼职平台的量级,这套配置足够了。没必要去调什么JVM大内存参数,能把连接池和线程池的默认值优化到合理区间,性能问题就能解决大半。

5.5 日常维护:日志、备份与定期检查

上线之后不是万事大吉。我做的日常维护工作包括:

  • 日志切割:Spring Boot默认logback配置按天切割日志文件,保留最近30天,防止磁盘被日志撑爆。
  • 数据库备份:每天凌晨用crontab定时任务执行mysqldump,备份到指定目录,保留最近7天。
  • 定期检查:每周看一次慢查询日志,找出响应慢的SQL,比如大表上的全表扫描,及时补索引。

这些习惯看起来琐碎,但项目真正运转起来后,你会发现“把系统养好”比“把系统写好”更难,也更见功夫。

写在最后的经验

整个项目从设计到落地,最让我有成就感的事,不是某段华丽的代码,而是把一套包含三类角色、涉及发布-审核-报名-录取全流程的兼职平台真正跑通了。做这类项目,我的心得其实就三条。

第一,结构对了,功能做起来就快。前后端分离、统一响应、统一鉴权、组件化页面,这些基础决定后面所有功能的开发速度和代码质量。别嫌搭建框架浪费时间,框架搭好了,后面一个功能可能就是一小时的事。

第二,权限与安全是面试官最爱问的深水区。很多简历上都写“实现了用户登录和权限管理”,但真正被追问“如何防止越权”“token放哪里”“密码怎么存”时立刻露馅。这个项目里我把BCrypt加密、JWT认证、角色校验、数据归属校验全部做扎实了,每一个都能讲出完整的设计思路。

第三,再小型的项目也要考虑部署与运维。本地跑通只是第一步,能在真实服务器上稳定运行、日志能排查问题、数据库有备份机制,这才算完整闭环。我在部署过程中踩的那几个坑,恰恰是很多人没经历过但一定会遇到的问题。

如果你正在做类似的校园兼职项目,我的建议是:不要急着写代码,先花两天把表结构、接口清单、角色权限矩阵彻底想清楚。再急的开发进度,也抵不过返工带来的时间损耗。后面每踩一个坑,回来重读一遍自己当初的设计文档,你会感谢那个没有偷懒的自己。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询