恒美微站
首页
关于我们
建站服务
主题模板
案例展示
资讯中心
联系我们
SpringBoot+Vue学生证管理系统:从零构建前后端分离与状态机权限设计
首页
资讯中心
/
SpringBoot+Vue学生证管理系统:从零构建前后端分离与状态机权限设计
SpringBoot+Vue学生证管理系统:从零构建前后端分离与状态机权限设计
发布时间:2026/9/30 12:31:18
最近在整理一个用 SpringBoot Vue 做的学生证管理系统。这个名字听起来像是个普通的课程设计但真正动手做一轮就会发现它把前后端分离开发里最典型的问题几乎全踩了一遍多角色权限、审批流状态管理、并发防重复、文件上传、跨域处理、打包部署一样都没落下。项目的业务背景其实很常见——高校里学生证的办理、挂失、补办长期依赖纸质流程学生找辅导员签字再跑学院盖章最后到教务处排队登记运气不好一个证要办两周。我做的这个系统把流程搬到了线上学生提交办证或补办申请辅导员线上审核管理员制证发放全程留痕进度实时可查。这篇文章会按我从 0 到 1 的实际开发顺序来写需求拆解与技术选型、后端接口设计、前端页面落地、高频踩坑与排查方法、部署扩展。不管你是拿它当毕业设计还是想学前后端分离项目怎么从零起步里面都有可以直接抄作业的东西。1. 项目整体设计与需求拆解1.1 先说需求不是“管理”两个字那么简单很多初学者拿到这种题目第一反应就是建一张学生表、一张学生证表然后开始写增删改查。真这么做做到一半就会卡住——因为“学生证管理”背后至少叠着四个核心业务场景新生入学批量办证几十上百个学生同时需要办证不能让人逐个手工录入要考虑批量导入、批量生成申请单。日常零星办证转校生、遗留学生、新生漏办等情况走单个申请流程。挂失补办学生证丢了先挂失作废旧证再提交补办申请这时可能要记录缴费状态。毕业注销毕业离校后批量停用保证系统里的证件状态和实际一致。每个场景对应的操作人不一样。我把角色拆成了四类学生提交申请、查看进度、辅导员审核本班学生申请、学院管理员制证、注销、系统管理员用户管理、数据统计、参数配置。这里有个重要经验需求阶段一定要把“证件状态”和“申请状态”分开建模。前者描述学生当前持有的实体证是什么状况比如正常、已挂失、已注销后者描述某个申请流程走到了哪一步比如待审核、已通过、已驳回。我之前见过有人把这两种状态混在一张表的一个字段里结果申请被驳回后学生证状态不知道怎么回滚日志都救不回来。1.2 技术选型SpringBoot Vue 不是唯一解但确实是最稳的组合后端选 SpringBoot核心理由是它把 Spring 框架那一大堆配置自动装配掉了。你引入spring-boot-starter-web就能直接写 Controller引入spring-boot-starter-data-redis就能直接注入 RedisTemplate不用自己拼 XML 配置文件。对这类中小型业务系统来说开发效率是第一位的SpringBoot 默认约定优于配置的思路非常契合。官网用一句话解释自动装配通过EnableAutoConfiguration启动时读取META-INF/spring.factories里的配置类按条件装配 Bean。你可以不深入源码但至少要理解这一点否则遇到“明明引入了依赖但 Bean 没注入”的问题会一脸懵。版本上我强烈建议用SpringBoot 2.7.x JDK 8/11不要一上来就追 3.x。3.0 开始强制 JDK 17而且把javax.*迁移到了jakarta.*很多老版本的 MyBatis-Plus、Swagger、Activiti 直接不兼容网上搜“springboot版本太高”的求助帖一大片。对业务系统来说稳定的生态比追新更重要。前端选 Vue 的理由更直观组件化开发和响应式数据绑定页面复用率极高。同一个审核列表页学生端、辅导员端、管理员端只是接口和字段略有不同组件稍作抽象就能三端共用。至于选 Vue 2 还是 Vue 3我的建议是直接学 Vue 3 Element Plus。Vue 3 的组合式 API 写业务逻辑更集中而且 2024 年之后 Element Plus 的组件成熟度已经很高了。其他配套选型模块选型理由ORMMyBatis-Plus单表 CRUD 零 SQL复杂查询走自定义 XML数据库MySQL 8.0成本低、生态好InnoDB 支持事务缓存Redis验证码存储、Token 黑名单、并发锁鉴权JWT 拦截器前后端分离下天然合适服务端无状态UIElement Plus表格、表单、步骤条、时间线组件开箱即用1.3 工程结构规划前后端分离的目录怎么摆前后端分离不等于简单开两个文件夹。后端我采用常见的分层包结构student-card-server ├── src/main/java/com/example/studentcard │ ├── controller # 接口层 │ ├── service # 业务逻辑层 │ ├── mapper # MyBatis-Plus 的 Mapper 接口 │ ├── entity # 数据库实体 │ ├── dto # 请求响应对象 │ ├── common # 全局异常、统一返回结果 │ └── config # 跨域、拦截器、Redis 配置前端用 Vue 官方脚手架创建后按业务拆目录student-card-web ├── src │ ├── api # 接口请求封装 │ ├── router # 路由配置 │ ├── store # Pinia 状态管理 │ ├── views # 页面级组件 │ │ ├── student # 学生端页面 │ │ ├── counselor # 辅导员端页面 │ │ └── admin # 管理员端页面 │ ├── components # 公共组件 │ └── utils # 工具函数这个结构的好处是职责单一后端按“层”划分前端按“角色”划分。小团队协作时每个人负责自己熟悉的目录合并冲突的概率会低很多。2. 后端 SpringBoot 核心实现把“办证”变成一组状态流转2.1 数据库设计六张表把业务串起来数据库设计是整个项目的地基我见过太多人一上来就建表结果字段重复、关联混乱。按上面的业务拆解我最终用了六张核心表sys_user用户表统一存学生、辅导员、管理员的账号、密码BCrypt 加密、角色标识。student_info学生信息表存学号、姓名、学院、班级、入学年份与 sys_user 一对一。student_card学生证主表一条记录对应该学生当前持有的实体证字段包括证件编号、发证日期、状态。card_apply申请表办证、补办、注销都走这张表通过 apply_type 区分通过 status 记录流程进度。card_loss_report挂失记录表记录挂失时间、挂失原因、原证件编号。operation_log操作日志表记录谁在什么时间对哪个申请做了什么操作便于审计。我给你看下最关键的 student_card 和 card_apply 的简化结构CREATE TABLE student_card ( id BIGINT PRIMARY KEY AUTO_INCREMENT, student_id BIGINT NOT NULL COMMENT 关联 student_info.id, card_no VARCHAR(32) NOT NULL COMMENT 证件编号, status TINYINT NOT NULL DEFAULT 1 COMMENT 1正常 2已挂失 3已注销 4补办中, issued_at DATETIME COMMENT 发证时间, expired_at DATETIME COMMENT 有效期截止, remark VARCHAR(255), UNIQUE KEY uk_student_active (student_id, status) );CREATE TABLE card_apply ( id BIGINT PRIMARY KEY AUTO_INCREMENT, apply_no VARCHAR(32) NOT NULL COMMENT 申请单号, student_id BIGINT NOT NULL, apply_type TINYINT NOT NULL COMMENT 1办证 2补办 3注销, status TINYINT NOT NULL COMMENT 0待审核 1通过 2驳回 3已完成, reason VARCHAR(255), proof_url VARCHAR(255) COMMENT 缴费凭证/证明材料路径, created_at DATETIME, updated_at DATETIME );这里有两个设计细节值得说第一状态字段我用TINYINT存储后面加上状态枚举对应。有些人喜欢直接存“正常”“挂失”这种中文看着直观但后期做条件查询和统计时会非常痛苦索引效率也低。我的习惯是代码里定义枚举类DataTransfer 层转换成前端可读的文本标签。第二student_card表加了一个业务上的唯一约束(student_id, status)的变体辅助防止并发补办——这个问题后面第 4 章详细展开这里先留个伏笔。2.2 登录鉴权JWT 替代 Session 后怎么维持登录状态前后端分离项目不能依赖 Servlet 容器自带的 Session因为前端域名和后端域名可能都不一样Cookie 跨域携带很麻烦。我用的方案是JWT Redis用户登录成功后端生成 JWT签名密钥放在配置文件中payload 里存 userId、username、role。Token 返回前端前端每次请求在请求头带Authorization: Bearer token。后端拦截器拦截所有/api/**请求解析 Token、校验签名和过期时间把当前用户信息放到ThreadLocal中。不直接把用户角色写死在 Token 里是因为用户角色被修改后需要立刻生效光靠 Token 里的旧信息判断权限会出漏洞。我采取的折中方案Token 里存 userId每次请求放行前从 Redis 拉一下最新的角色虽然多一次内存查询但安全性高很多。核心拦截器的主要逻辑就这三步Override public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) { String token request.getHeader(Authorization); if (token null || !token.startsWith(Bearer )) { throw new AuthException(未登录或登录已过期); } Claims claims JwtUtil.parseToken(token.replace(Bearer , )); String redisKey login:token: claims.get(userId); if (!redisKey.equals(redisTemplate.opsForValue().get(redisKey))) { throw new AuthException(Token已失效请重新登录); } UserContext.set(claims); return true; }注意拦截器里抛异常是不够的还要配合全局异常处理器返回 JSON 格式的 401。否则前端拿到的是默认的 HTML 错误页面拦截器里判断响应状态就不好用了。2.3 核心业务接口办证、挂失、补办的状态机实现这个系统的核心不是 CRUD而是状态机流转。业务规则是这样的学生提交办证申请 → 辅导员审核通过 → 管理员制证 → 标记完成同时生成一条 student_card 记录。学生挂失 → 原 student_card 状态改为“已挂失”生成一条挂失记录。学生补办 → 先提交补办申请 → 审核通过 → 原卡状态改为“已注销”再生成一张新卡。最容易写乱的地方是补办。很多人写代码时会直接把旧的 student_card 更新一下状态就结束了但补办本质上是“旧证作废 新证生成”两个动作必须放在一个事务里Transactional(rollbackFor Exception.class) public void reissueCard(ReissueRequest request) { StudentCard oldCard cardMapper.selectByStudentId(request.getStudentId()); // 校验当前证件状态必须为“已挂失”或“正常”否则不让补办 if (oldCard null || !CardStatus.LOST.equals(oldCard.getStatus())) { throw new BizException(当前没有可补办的证件); } // 校验是否存在进行中的补办申请 Integer pendingCount applyMapper.countPendingApply(request.getStudentId()); if (pendingCount 0) { throw new BizException(已有补办申请在处理中); } // 旧证注销 oldCard.setStatus(CardStatus.INVALID); cardMapper.updateById(oldCard); // 生成新证 StudentCard newCard new StudentCard(); newCard.setStudentId(request.getStudentId()); newCard.setCardNo(generateCardNo(request.getStudentId())); newCard.setStatus(CardStatus.NORMAL); cardMapper.insert(newCard); // 更新申请状态为已完成 applyMapper.updateStatus(request.getApplyId(), ApplyStatus.COMPLETED); }关键在于“校验已有进行中的补办申请”和“事务包裹两个写操作”。没有这两步学生连点两次“补办”就会生成两张有效证件这在现实中是绝对不允许的。2.4 文件上传与全局异常处理前端拿到错误提示很重要学生申请补办往往要上传缴费凭证或丢失证明管理员端也可能需要上传批量导入的 Excel 模板。我实现了两个上传接口单文件上传和批量导入。文件存储先放在本地服务器固定目录路径记录到数据库上线时再换成 MinIO后面扩展章节会讲。上传这里最容易忽略的是大小限制和类型校验。SpringBoot 默认单文件最大 1MB实盘上传超过直接被拒绝且返回的异常不好看。我在配置文件里显式放开并在业务层做类型白名单校验spring: servlet: multipart: max-file-size: 10MB max-request-size: 20MB全局异常处理是后端体验的分水岭。我用RestControllerAdvice统一处理业务异常和系统性异常保证接口出问题时前端能拿到结构一致的 JSON{ code: 500, message: 文件大小超出限制最大支持10MB, data: null }同时定义一个公共 Result 类统一正常返回结构。这样前端 axios 拦截器只需要判断 code 字段就可以统一弹出提示不需要每个接口单独处理错误分支。3. 前端 Vue 实现要点从脚手架到跑通完整流程3.1 工程搭建与依赖版本别在安装这一步卡住我用 Vite 创建 Vue 3 工程npm create vuelatest student-card-web选择包含 Vue Router 和 Pinia后续需要的依赖再手动装npm install element-plus axios piniaElement Plus 建议全量引入对内部管理系统来说按需引入省的那点包体积意义不大反而增加配置复杂度。同时安装element-plus/icons-vue用于图标。vite.config.js里做两件事配置路径别名配置开发环境代理。import { fileURLToPath, URL } from node:url export default defineConfig({ resolve: { alias: { : fileURLToPath(new URL(./src, import.meta.url)) } }, server: { port: 5173, proxy: { /api: { target: http://localhost:8080, changeOrigin: true } } } })开发环境下前端请求/api/user/login会被 Vite 代理到后端http://localhost:8080/api/user/login这样浏览器端不存在跨域问题。生产环境则交给 Nginx 处理这点第 5 章会展开。3.2 路由守卫与角色权限三种角色怎么控制页面访问路由设计我采用“静态基础路由 动态路由”组合。登录页、首页属于静态路由所有角色可访问学生端、辅导员端、管理员端的页面通过动态路由按角色注册。const asyncRoutes { student: [ { path: /student/apply, component: () import(/views/student/Apply.vue) } ], counselor: [ { path: /counselor/audit, component: () import(/views/counselor/Audit.vue) } ], admin: [ { path: /admin/card-list, component: () import(/views/admin/CardList.vue) } ] }配合全局前置守卫每次路由跳转前做三件事判断有没有 Token没有 Token 一律跳登录页有 Token 就根据当前用户角色把对应动态路由加进来注意用next({ ...to, replace: true })防止重复添加。调试这个问题时Vue Devtools 是救命工具。它可以直观看到当前路由表、Vuex/Pinia 里存的用户信息、组件 props 传递链。装好插件后定位“为什么这个页面没权限访问”基本是分钟级的事。3.3 axios 封装请求拦截里统一处理 Token 和 401不封装 axios 的后果是每个接口都要重复写请求头、重复处理错误代码变得又臭又长。我在src/utils/request.js里统一封装import axios from axios import { ElMessage } from element-plus import router from /router const service axios.create({ baseURL: /api, timeout: 15000 }) service.interceptors.request.use(config { const token localStorage.getItem(token) if (token) { config.headers.Authorization Bearer ${token} } return config }) service.interceptors.response.use( response { const res response.data if (res.code ! 200) { ElMessage.error(res.message || 请求失败) return Promise.reject(new Error(res.message)) } return res.data }, error { if (error.response error.response.status 401) { localStorage.clear() router.push(/login) } ElMessage.error(error.message || 网络异常) return Promise.reject(error) } )这里有个容易被忽视的点后端正常返回业务错误时 HTTP 状态码也是 200只有 JSON 里的 code 非 200。所以我封装的响应拦截器中先判断res.code再决定是否提示错误。真正 HTTP 层面的 401、403 则走第二个回调。两种错误分开处理前端交互才不会出现“明明提示了错误还跳去别的页面”这种诡异行为。3.4 核心页面与交互细节状态展示、步骤条、审核时间线前端页面不需要炫技但要贴合业务流程。三个核心页面是最花时间的学生端的办证申请页表单学号、姓名自动带出申请类型选择原因说明材料上传加上 Element Plus 步骤条展示当前进度。步骤条我分为四步提交申请 → 辅导员审核 → 管理员制证 → 完成。每步对应 card_apply 的一个状态。辅导员端的审核列表页用el-table展示本班学生申请关键列有申请人、申请类型、申请时间、状态。状态用el-tag渲染不同颜色待审核是警告色通过是成功色驳回是危险色。这个细节看似小但对审核效率提升明显扫一眼就能知道哪些是待处理的。管理员端的证卡管理页搜索、筛选、分页是标配。分页参数我用page和pageSize后端用 MP 的 Page 对象直接接收逻辑非常清爽。批量操作批量制证、批量注销也是在这一页实现前端把选中行的 id 数组传后端后端循环处理并记录操作日志。4. 开发中高频踩坑与排查实录4.1 时间格式不一致MySQL 与 Jackson 的时区博弈第一个我问遍所有人几乎都会遇到的坑数据库存的时间比实际少了 8 个小时或者前端显示的日期总是“2024-01-01T00:00:00.00000:00”这种带 T 的 UTC 格式。根源有两个MySQL 连接串里的serverTimezone没设对JDBC 驱动和 MySQL 服务器之间时区认知不一致。Jackson 序列化 LocalDateTime 时默认输出 ISO 格式。我最终的配置方案spring: jackson: date-format: yyyy-MM-dd HH:mm:ss time-zone: GMT8数据库连接串加上jdbc:mysql://localhost:3306/student_card?useSSLfalseserverTimezoneAsia/ShanghaicharacterEncodingutf8同时前端拿到时间字符串后统一格式化不要直接 new Date 再拼接容易因为浏览器时区差异显示错一天。4.2 刷新后 404 与登录失效两个典型场景排查前端我用的是 Vue Router 的 history 模式好处是 URL 好看坏处是直接访问/student/apply这个地址时服务器上根本没有这个物理文件如果 Nginx 没配置兜底会返回 404。解决方法是 Nginx 配置加上try_fileslocation / { root /usr/share/nginx/html; index index.html; try_files $uri $uri/ /index.html; }另一个坑是刷新后登录状态丢失。刷新页面不代表 Token 消失但路由守卫在初始化时拿不到用户信息就会误判为未登录而跳转。我的做法是登录成功后把用户信息和 Token 都存到 localStoragePinia 里初始化状态时先读 localStorage保证刷新后能立即恢复登录态。4.3 并发补办怎么防重复从数据库到接口的层层堵漏第 2 章提过补办并发问题这里展开讲排查过程。我当时用 JMeter 并发 200 个线程同时提交同一个学生的补办申请压测后发现生成了多张有效证件。排查步骤如下第一步看代码逻辑。发现少了“校验进行中申请”这一步补上后业务层能挡住一部分但并发窗口仍然存在——两个请求同时通过校验同时进入事务。第二步加数据库约束。在 card_apply 表上对student_id status加唯一索引保证同一时间只能有一条进行中的申请。但 MyBatis-Plus 插入冲突时抛的是 DuplicateKeyException需要捕获转换成友好提示。第三步加 Redis 分布式锁。以lock:reissue:{studentId}为 keysetnx 加过期时间获取锁失败直接提示“操作太频繁”。三个机制叠加之后再压测就稳定了。这个排查过程告诉我防并发从来不是靠单层解决业务判断挡正常操作索引兜底极端情况锁再压住并发窗口。4.4 SpringBoot 版本升级兼容性新版本引发的连锁踩坑有个朋友把项目从 2.x 升级到 SpringBoot 3.2结果启动直接报错原因就是 3.x 把javax.sql迁到了jakarta.sql很多中间件和工具类直接找不到类。当时项目里还用了 springfox-swagger 2.x、旧版 MyBatis-Plus全部不兼容。如果你确实想用 SpringBoot 3.x建议做三件事确认 JDK 17把 swagger 换成 springdoc-openapiMyBatis-Plus 升到 3.5.3 以上并使用mybatis-plus-spring-boot3-starter。但我个人的建议是业务项目别当小白鼠稳定优先。另外构建工具我用 Maven 而不用 Gradle原因是团队里大多数人熟悉 Maven依赖冲突排查手段清晰。如果你只用 IDEA 创建项目直接选 Spring Initializr 可以省去不少配置麻烦。5. 部署上线与后续扩展5.1 前后端分离部署从本地到服务器的一键思路开发完成后的部署流程我建议按下面这样走后端执行mvn clean package得到student-card-server.jar。服务器装 JDK 11、MySQL、Redis、Nginx。上传 jar 后执行nohup java -jar student-card-server.jar --spring.profiles.activeprod app.log 21 。前端执行npm run build把生成的dist目录传到 Nginx 的 HTML 根目录。Nginx 的完整配置里两个核心段缺一不可静态资源兜底和 API 反向代理。server { listen 80; server_name your-domain.com; location /api/ { proxy_pass http://127.0.0.1:8080; proxy_set_header Host $host; proxy_set_header X-Real-IP $remote_addr; proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; } location / { root /usr/share/nginx/html; index index.html; try_files $uri $uri/ /index.html; } }第一次部署后务必检查三件事后端日志有没有数据库连接池报错前端能不能正常登录并调通接口如果服务器开放了 HTTPS把 Nginx 的 80 端口配置改成 443 并用 certbot 自动申请证书否则流量都是明文。5.2 还可以往哪些方向扩展这个系统的骨架具备很强的通用性稍作改造就能复用到其他场景文件存储升级 MinIO现在文件存在本地磁盘将来学生人数多了可以把上传接口改成客户端直传 MinIO 或服务端签名上传静态资源走独立域名减轻应用服务器压力。引入工作流引擎现在审批流是单级审核如果学院多层级审批可以整合 Flowable 或 Activiti把申请流程配置化。消息通知审核通过后自动通知学生可以接入企业微信或邮件服务也可以引入 ActiveMQ 做异步解耦避免接口响应时间过长。电子学生证小程序或 H5 展示二维码闸机扫码验证这个就是另一个完整项目了但底层的证卡状态管理可以直接复用。像这类系统最大的复用价值是“证卡状态机”和“角色权限模型”。以后无论做一卡通管理、图书证系统还是会员卡系统核心逻辑都大同小异。我在实际开发中最大的体会是别小看这种“管理系统”它逼着你在数据表设计和状态流转上多花心思。前期把需求梳理清楚、把状态机画明白后面写代码就是体力活反过来如果急着上手写接口后面大概率要返工。最后分享一个小技巧所有接口在写完后都手动模拟一遍“正常流程 异常流程 并发流程”跑测试尤其要测异常流程——驳回、撤销、过期、重复提交——这些才是生产环境真正会出问题的地方。框架怎么选、页面怎么写大家都能学会拉开差距的往往就是这些细节。