
又到毕业设计选题的旺季每年这时候都会看到不少人在论坛求 SpringBoot 管理系统的源码甚至想直接拿别人的项目应付。但真正经历过一遍从零开发的人都知道拿一套源码跑起来只是第一步答辩被抽问时答不出设计理由才最难受。今天花时间复盘一个完整的“基于 SpringBoot 的勤工俭学系统”设计与实现项目这是我做过的一套计算机毕业设计项目源码、数据库脚本、文档都整理过。如果你正在准备 SpringBoot 方向的毕设或者想快速理解一个真实业务系统是怎么从需求落到代码的这篇文章应该比零散教程更能帮你建立全局观。这套系统解决的是高校勤工俭学管理长期靠手工的痛点岗位信息靠电子表格传阅学生报名靠纸质表递交工时统计靠 Excel 来回汇总流程长、错漏多。系统把岗位发布、学生申请、教师审核、工时录入、薪资核算整条线搬到线上从而让流程可追溯、数据可统计。后面我会拆解技术选型、数据库设计、核心代码、部署流程和典型踩坑尽量给到可以直接参考的细节。1. 项目整体定位与设计思路1.1 勤工俭学系统的核心业务闭环勤工俭学管理不是简单“发布岗位 提交申请”背后涉及人事审核和财务核算。完整的业务闭环应该是管理员维护基础数据包括学生、教师、部门、岗位类别各用工部门老师发布勤工助学岗位写明人数、工时要求、薪资标准、申请截止时间学生浏览在招岗位提交申请并附带个人简介或证明材料岗位负责人审核申请通过后学生可以填报工时工时经过确认后系统按单价汇总月薪管理员复核后生成发放记录。这个闭环如果只做单模块课题太单薄全部做出来又需要合理安排角色权限。所以我将用户分成三类学生、用工部门教师、学工处管理员。每种角色看到的页面和操作完全不一样课题既完整又能体现工作量。为什么建议不要只做纯 CRUD因为毕业设计的核心是展示工程能力流程流转、权限控制、数据统计才是真正的加分点。如果只是对一张表增删改查答辩老师很难找到切入点提问你自己也讲不出亮点。1.2 技术选型为什么偏偏选 SpringBoot选型是答辩必问的问题。我当时后端用的是 SpringBoot 2.3.4.RELEASE持久层用 MyBatis-Plus配合 MySQL 5.7前端用 Vue 2 ElementUI。看到这你可能会想这套技术是不是有点老但对毕设来说稳定、资料多、容易复现远比“新”重要。SpringBoot 去掉大量 XML 配置内嵌 Tomcat一个 jar 就能跑起来非常适合单人开发。为什么不用 Spring Cloud毕设规模没到微服务硬上分布式只会给自己挖坑。为什么不用 JPA因为管理系统里查询条件多SQL 可读性重要MyBatis-Plus 的 QueryWrapper 可以快速拼接条件又保留手写 SQL 的能力。前端选 Vue 是因为前后端分离可以单独启动和演示不用为环境变化争吵。认证方案我用 JWT相比传统 Session接口无状态前端跨域时不用操心 Cookie 问题。层次选型用途后端框架SpringBoot 2.3.x提供 REST 接口、自动配置持久层MyBatis-Plus 3.4.x单表 CRUD 与条件构造器数据库MySQL 5.7存储业务数据认证JWT BCrypt无状态令牌与密码加密前端Vue 2 ElementUI管理界面与交互1.3 模块规划与角色权限的拆分我按角色拆模块管理员端有用户管理、岗位管理、申请审核、工时审核、薪资管理、公告管理教师端有岗位发布、申请审批、工时录入学生端有岗位浏览、在线申请、工时填报、薪资查看。系统管理里还有菜单和角色配置但为了不让工作量失控角色先固定为三类不做动态授权。权限方面我采用 RBAC 模型也就是用户—角色—权限三层。接口层面用 Spring Security 的PreAuthorize注解比如PreAuthorize(hasRole(ADMIN))能拦住未授权访问。在小项目里这么做足够清晰也方便答辩解释“权限是怎么控制的”。还有一种做法是写拦截器判断路径前缀但角色一多就容易混乱不推荐在模块稍多时使用。权限这块的常见误区是在 Controller 里手动判断if(user.getType()1)。刚开始写很爽后面每个接口都要加一遍漏一个就是漏洞。用注解声明式控制代码更干净也更容易被老师认可。2. 数据库设计这是一切的地基2.1 从业务到实体表结构落位业务流程理清后表结构就顺理成章了。我用逻辑外键代替物理外键避免删除数据时被约束阻碍每张表都有 id 主键、create_time、update_time 字段。核心表有九张用户表、角色表、用户角色关联表、岗位表、申请记录表、工时记录表、月薪汇总表、公告表、文件附件表。表之间的关键关系用户和岗位通过申请记录产生多对多关系岗位通过责任老师关联用户工时记录关联学生和岗位月薪汇总通过学生、岗位、月份维度关联。整个模型可以概括成“用户-角色-岗位-申请-工时-薪资”一条主干线。表名主要字段说明t_userid, username, password, real_name, user_type, student_no, dept_id, phoneuser_type 区分学生/教师/管理员t_roleid, role_code, role_nameADMIN/TEACHER/STUDENTt_user_roleid, user_id, role_id中间表解耦多对多t_postid, title, type_id, duty_teacher, headcount, salary_per_hour, apply_deadline, status, description岗位基础信息t_applyid, user_id, post_id, apply_time, status, audit_remark申请记录状态t_work_hourid, user_id, post_id, work_date, hours, status, entry_user工时录入与审核t_salaryid, user_id, post_id, month, total_hours, amount, status月度薪资汇总t_noticeid, title, content, create_by, publish_time公告信息t_fileid, biz_type, biz_id, file_name, file_url, file_size附件上传记录2.2 两张核心业务表的字段设计要点岗位表和申请记录表是最容易出问题的两张表。先说岗位表状态字段我用了 tinyint 而不是 varchar因为数字类型占空间少、索引更快0 表示未发布1 表示招聘中2 表示已截止3 表示下架。如果同学问你“为什么不用枚举字符串”可以答“数据库层面使用数字字典前端再转换成文本展示数据维护方便”。申请记录表最重要的防重复设计我在表上建了唯一索引uk_user_post (user_id, post_id)数据库层面就保证同一个学生对同一个岗位只能申请一次。代码里即使忘记先查询数据库也会抛异常兜底。CREATE TABLE t_apply ( id bigint(20) NOT NULL AUTO_INCREMENT, user_id bigint(20) NOT NULL COMMENT 学生用户ID, post_id bigint(20) NOT NULL COMMENT 岗位ID, apply_time datetime DEFAULT CURRENT_TIMESTAMP, status tinyint(4) DEFAULT 0 COMMENT 0已提交 1通过 2不通过 3已录用, audit_remark varchar(255) DEFAULT NULL, create_time datetime DEFAULT CURRENT_TIMESTAMP, update_time datetime DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, PRIMARY KEY (id), UNIQUE KEY uk_user_post (user_id, post_id) ) ENGINEInnoDB DEFAULT CHARSETutf8mb4;这里有个经验凡是需要“一个用户对一条业务数据只能操作一次”的场景一定要用唯一索引代码判断只能治标不治本。之前我在另一个项目里只写了代码判断结果高并发下插入两条重复记录数据库直接暴露从那以后再也不敢省唯一索引。2.3 权限表设计为什么不要用字符串角色很多同学偷懒在用户表里加一个 role 字段存“admin”或“student”。这样确实能跑但后续想加“部门负责人”“财务专员”就得改代码而且角色和功能的映射会越来越难维护。我做的是最标准的三张表 RBACt_user、t_role、t_user_role。用户关联角色角色通过 role_code 标识。接口编码时我在登录成功时拿到用户角色标识放到 JWT 的 claims 里请求进入 Spring Security 后从中读取。前端则根据登录接口返回的 userType 渲染不同菜单。删除用户时不要直接物理删除而是用状态字段标记停用保留关联记录否则历史申请单里的关联用户会断层排查问题时特别难受。另外角色表里除了 role_code我会加一个 role_name 用于前端展示。不要直接在页面里判断 roleCode 等于 ADMIN 就显示“管理员”因为如果以后角色改名前端又要跟着动。用 roleName 字段做展示roleCode 只做权限判断关注点分离。3. 核心功能实现与代码拆解3.1 登录认证与 JWT 无状态会话登录接口是最先要做的。流程是用户提交用户名密码后台查用户用 BCrypt 匹配匹配成功后生成 JWT返回给前端前端在 axios 拦截器里把 token 放到请求头后端自定义拦截器校验 token。为什么用 BCrypt因为它内置盐值即使两个用户密码相同生成的哈希也不同防彩虹表。JWT 生成代码Component public class JwtUtil { Value(${jwt.secret}) private String secret; public String generateToken(String username, String userType) { return Jwts.builder() .setSubject(username) .claim(userType, userType) .setIssuedAt(new Date()) .setExpiration(new Date(System.currentTimeMillis() 1000L * 60 * 60 * 24 * 7)) .signWith(SignatureAlgorithm.HS256, secret) .compact(); } public Claims parseToken(String token) { return Jwts.parser().setSigningKey(secret).parseClaimsJws(token).getBody(); } }这套写法能跑但要注意密钥一定不能写在代码里写在 application.yml 中用${jwt.secret}注入避免密钥硬编码。token 有效期我设置成七天毕设演示足够如果觉得要更规范可以加入 refresh token 机制但不是重点。用 JWT 后会遇到一个真实痛点用户退出后 token 还在有效期无法立刻失效。解决思路是在 Redis 里维护黑名单但毕设可以接受不处理只要答辩时能诚实说明原因即可。如果强行引入 Redis又会增加部署复杂度不划算。3.2 申请与审核的状态流转控制申请岗位这部分有三个坑无效岗位判断、重复申请判断、状态更新一致性。核心 Service 方法大致是Transactional public boolean applyPost(Long userId, Long postId) { Post post postMapper.selectById(postId); if (post null || post.getStatus() ! 1) { throw new BusinessException(岗位不存在或不在招聘中); } if (DateUtil.isAfter(now, post.getApplyDeadline())) { throw new BusinessException(申请已截止); } Integer count applyMapper.selectCount(new QueryWrapperApply() .eq(user_id, userId).eq(post_id, postId)); if (count 0) { throw new BusinessException(你已经申请过该岗位); } Apply apply new Apply(); apply.setUserId(userId); apply.setPostId(postId); apply.setStatus(0); applyMapper.insert(apply); return true; }Transactional必须加上因为如果后续还有“同时把岗位已申请人数加一”的操作要么一起成功要么一起回滚。审核接口同理审核通过后要改岗位的已用人数或状态不能只更新申请状态。状态值我统一用常量类维护不散落在代码里例如ApplyStatus.PASSED 1。事务失效的一个隐藏坑如果同一个类里一个方法调用另一个Transactional方法Spring 默认不经过代理事务可能不生效。所以事务方法最好写在 Service 实现类里从 Controller 直接调用保证事务入口是代理对象。写代码时别把业务逻辑堆在 Controller否则后面想加事务很难受。3.3 文件上传与 XSS 过滤容易被忽略的安全点这个系统里学生申请岗位可以上传“个人简历”或“困难证明”教师端也可以上传附件所以文件上传不能不做安全防护。我的做法是文件类型白名单限制不能只靠后缀判断还要读取文件的 Content-Type 和实际文件头文件大小限制在 10MB保存路径用 UUID 重新命名路径中不保留原始文件名不允许上传 jsp、html、exe 等可执行文件所有上传文件统一放到服务器的一个 upload 目录通过后台下载接口按 ID 拉取不直接暴露静态路径。另一个容易被忽略的是 XSS 过滤。因为系统所有请求都走 SpringBoot 接口我实现了一个全局过滤器对请求参数里的、script等字符做 HTML 转义防止恶意脚本入库后在前端被执行。注意这个过滤器不要处理 multipart 文件内容否则会把正常的 PDF 二进制搞坏。所以过滤时机要卡在获取参数阶段文件流走单独的处理逻辑。代码里可以用 Spring 自带的HtmlUtils.htmlEscape做清洗也可以用自定义过滤器包装 HttpServletRequest重写 getParameter。实际开发中我建议全局过滤器用来过滤普通表单参数富文本编辑器单独做白名单校验不能一棍子全转义。比如公告内容里可能含有合法 HTML 标签如果直接转义样式就丢了所以要先区分场景再过滤。3.4 工时录入与薪资报表的 SQL 聚合工时表和薪资表分开设计。学生每次打工结束填报当天工时教师审核通过后月底统一汇总。汇总逻辑用 SQL 来做比 Java 内存里循环高效SELECT user_id, post_id, DATE_FORMAT(work_date, %Y-%m) AS month, SUM(hours) AS total_hours FROM t_work_hour WHERE status 1 GROUP BY user_id, post_id, month;得到总工时后再根据岗位的 salary_per_hour 计算应发金额写入 t_salary 表。如果还要做“月薪排行”就再按 amount 排序。报表接口返回的是 Map 列表前端用 ECharts 画柱状图或饼图。数据量不大时后端一次查全量没问题如果岗位多就加 month 参数做条件查询。关于薪资精确度金额全部用 BigDecimal不能用 double。工时单价和总工时相乘时要注意 scale 的设定统一保留两位小数避免出现 19.999999 这种结果。答辩时可以补充一句“金额计算使用 BigDecimal 是为了避免浮点误差”这也是面试官喜欢听到的小细节。4. 部署、联调与上线4.1 本地环境准备与初始化数据先把本地环境对齐避免“在我电脑上能跑”的尴尬。JDK 1.8 以上、Maven 3.6、MySQL 5.7/8.0、Node 14。导入项目的流程在 IDEA 中 File - New - Project from Existing Sources选择 pom.xml等待 Maven 自动下载依赖如果下载慢就换阿里云镜像在资源目录下修改 application.yml把数据库账号密码改成自己本地的先用 SQL 脚本建库建表再在项目里用 CommandLineRunner 或手动执行初始化语句插入三个角色的测试账号启动 SpringBoot 主类确认控制台没有报错。application.yml 关键配置server: port: 8080 spring: datasource: driver-class-name: com.mysql.cj.jdbc.Driver url: jdbc:mysql://localhost:3306/work_study?useUnicodetruecharacterEncodingutf8serverTimezoneAsia/Shanghai username: root password: 123456 servlet: multipart: max-file-size: 10MB max-request-size: 100MB jwt: secret: your-secret-key-change-in-production这里 driver-class-name 是 MySQL 8 写法如果用 MySQL 5.7可以写成 com.mysql.jdbc.Driver但推荐都用 cj 版本。注意 url 里必须带 serverTimezone否则本地时间会和数据库差 8 小时。4.2 后端打包与服务器部署本地跑通后打包部署也不复杂。在项目根目录执行mvn clean package -DskipTests打包后 target 目录下会生成一个 work-study-0.0.1-SNAPSHOT.jar。用java -jar work-study-0.0.1-SNAPSHOT.jar就能启动。服务器上为了让进程后台运行我习惯用 nohupnohup java -jar work-study-0.0.1-SNAPSHOT.jar --spring.profiles.activeprod app.log 21 生产环境的数据库配置可以通过启动参数覆盖避免把密码写在 jar 里。如果想做得稍微工程化一点可以写一个 DockerfileFROM openjdk:8-jdk-alpine COPY work-study-0.0.1-SNAPSHOT.jar app.jar ENTRYPOINT [java, -jar, /app.jar]但毕设阶段不推荐为了 Docker 而 Docker因为很多同学的服务器只有 1C1GMySQL 放容器里容易内存不足。直接把 jar 扔在服务器上跑反而更稳。前端打包执行npm run build把 dist 目录用 Nginx 托管同时配置反向代理把 /api 转发到 8080。4.3 前后端跨域与代理联调细节前端开发时用vue-cli-service serve启动默认端口 8081后端是 8080必然遇到跨域。前端开发环境直接用代理解决在 vue.config.js 里module.exports { devServer: { port: 8081, proxy: { /api: { target: http://localhost:8080, changeOrigin: true, pathRewrite: { ^/api: } } } } }这样前端请求 /api/login 会被代理到 http://localhost:8080/login浏览器感知不到跨域。如果后端也配了 CORS 全局策略切记把自定义 JWT 拦截器对 OPTIONS 请求放行否则第一次请求会挂在预检上。这是联调最常遇到的坑。生产部署时Nginx 配置里也建议location /api/ { proxy_pass http://localhost:8080/; }要让路径正确注意/api/和proxy_pass末尾的斜杠一个加错就 404。我踩过好多次后来养成了“先不带斜杠试一次再带斜杠试一次”的惯性。5. 常见问题排查与避坑清单5.1 数据库连接与版本差异问题现象原因解决启动报 ClassNotFoundException: com.mysql.jdbc.Driver驱动类名写错或依赖缺失改为 com.mysql.cj.jdbc.Driver检查 pom 中 mysql-connector-java 版本连接 8 小时后断线MySQL 默认 wait_timeout空闲连接被断开url 加 autoReconnecttrue或使用 HikariCP 的 maxLifetime 小于数据库超时中文乱码数据库编码不是 utf8mb4建库时指定 CHARACTER SET utf8mb4url 加 useUnicodetrue日期显示相差 8 小时时区不一致url 加 serverTimezoneAsia/ShanghaiJVM 时区同步这里最值得提醒的是MySQL 8 和旧项目的连接方式不完全兼容如果你下载到别人的旧源码优先把 datasource 改成上面的新写法否则光连接问题就能卡半天。5.2 拦截器放行与 CORS 优先级自定义 JWT 拦截器时登录接口、验证码接口、静态资源都要放行。我在 WebConfig 里注册拦截器的同时把路径列表写成常量后续再加白名单就在一处改。但有一个细节放行路由只对后端路径有效前端请求带不带 token 是自己控制跨域请求出现 401 时第一反应是检查 options 请求有没有被拦截器拦掉。Spring Security 也有自己的拦截链优先级比自定义拦截器高必须统一配置 permitAll。处理方案用 CorsFilter 注册全局 CORS 比在 Controller 加CrossOrigin干净。同时 Spring Security 配置中http.cors().and().csrf().disable()否则预检请求 403。另外如果前端用了 withCredentials后端 allowedOrigin 不能写*必须写具体域名否则浏览器也会拒绝。5.3 JSON 序列化循环引用与懒加载系统里的用户关联岗位岗位又关联用户用 MyBatis-Plus 的多表查询时如果直接返回实体JSON 序列化经常报 “could not initialize proxy - no Session” 或出现 A 包含 B、B 包含 A 的无限递归。我的处理是不直接返回实体而是定义 VO/DTO。比如申请记录列表页后端查出来是一个 Map 或者申请 VO里面包含学生姓名、岗位标题、岗位单价等等不直接扔实体给前端。这样可以一是避免循环引用二是不用把密码等敏感字段暴露出去三是前端拿到的字段名完全可控。如果非要用实体也需要在关联字段上打JsonIgnore但这样会丢失信息不如 DTO 灵活。答辩时如果你能说出“用 DTO 做数据脱敏和字段裁剪”已经是加分项了。5.4 从毕设答辩视角看这个项目毕设项目有两个使命完成功能 展示工程能力。答辩老师经常问三类问题项目结构怎么分层要答出 controller/service/mapper 分层以及为什么这么分权限是怎么实现的答 JWT Spring Security RBAC并简单说明 token 的生成和校验如果数据量大怎么办答数据库索引、分页查询、Redis 缓存预热。不需要真的实现但你能说出思路。还有一个容易被问但很少人准备的点为什么选择 SpringBoot 而不是 SSM可以答 SpringBoot 的自动装配解决了传统 Spring 的配置繁琐问题内嵌容器让应用独立运行同时保留了 Spring 的生态。把“自动装配原理”的大致机制说出来比如SpringBootApplication由EnableAutoConfiguration开启自动配置配合 spring.factories 上的配置类按条件生效老师会认为你是真的理解而不是只会调用。最后说点我个人的体会。这类管理系统看着不难真正从头写一遍才发现最耗时间的不是某个算法而是把业务状态流转想清楚。比如“申请是否截止”“工时是否被审核”“薪资是否已发放”这些状态的组合稍微漏一个演示时就会卡壳。如果你时间紧凑建议先把最核心的申请-审核-工时-薪资闭环跑通再去补公告、Excel 导入导出、图表统计这些加分项数据库表结构尽量一次设计到位后面改表付出的代价远比你以为的大。这套勤工俭学系统的源码和文档我现在还保留着每次回头看都会觉得毕业设计的意义不在于做得天花乱坠而是让你完整经历一次“从需求到上线”的过程这个经历在面试时比简历上任何一行字都值钱。