前阵子帮某个兄弟院校整理学生信息管理这块业务我接手的还是一套堆了三年多的Excel表格和纸质档案夹。查一个学生的家庭成员信息要翻半个档案柜录入一条奖惩记录得等管理员手动更新汇总表。折腾了一圈之后我决定直接重写一套学院个人信息管理系统技术栈就按现在最主流的前后端分离组合来SpringBoot做后端服务Vue做前端页面MyBatis负责数据库访问MySQL存数据。这套系统做完之后管理员录入一条学生记录从打开页面到保存成功只花十几秒学生自己登录就能维护个人信息学院领导按权限查看汇总数据彻底替代了原来那些来回传文件的流程。这篇博文就是把这套系统从零实现到部署上线的完整过程拆给你看包含数据库设计思路、JWT登录鉴权、Vue项目结构组织、axios封装、打包部署的具体命令和我在实操里踩过的坑。适合正在学SpringBoot和Vue的开发者照着复现也适合毕业设计想找前后端分离项目做参考的同学。1. 个人信息管理系统从需求到架构先搞清楚要管什么、边界在哪1.1 我梳理原始需求时列出的功能清单系统命名是学院个人信息管理系统但个人信息这四个字很含糊实际落地时要拆成很具体的数据维度。我第一步是找学院里真正用这套系统的人聊了一圈最后整理出来的核心需求是四块学生的基本档案学号、姓名、性别、民族、出生日期、政治面貌、联系方式、家庭住址、家庭成员信息姓名、关系、工作单位、联系电话、学业信息辅助字段入学年份、专业、班级、学籍状态以及奖惩记录时间、类型、事由、审批状态。另外每个用户的账号要能登录、能改密码管理员能对学生的全部信息做增删改查和按条件检索。这个需求清单看着不复杂但它是后面所有表结构和接口设计的基础。我在动手写任何代码之前先画了一版简单的模块图用户认证模块、学生信息管理模块、家庭成员子表模块、奖惩记录子表模块和统计数据模块按班级、专业统计人数。这种从需求到模块的拆解方式比直接打开IDE开始建项目要靠谱得多因为后面你写每个Controller的时候都能对号入座知道自己正在实现的是哪个业务闭环。1.2 为什么是SpringBootVueMyBatisMySQL而不是别的组合技术选型这个事很多新手喜欢追新框架但实际在学院、中小型公司这类场景里稳定、生态成熟、招聘市场上容易找到人接手才是第一位的。SpringBoot把配置简化到了极致内嵌Tomcat一个jar包就能跑起来部署成本极低。MyBatis比JPA更直观的原因是SQL完全由自己掌控多表关联和复杂统计查询很容易写出可控的语句而且XML文件维护起来非常透明出了问题直接看SQL就能分析。Vue在这套组合里负责前端页面用Vue CLI或Vite搭出的工程结构清晰组件化开发让个人信息表单、家庭成员列表这类UI可以独立复用。前后端分离之后的接口联调用JSON数据交换Vue的axios库发请求、收响应都很顺手。MySQL则是最保险的选择免费、轻量、容易装学生信息这种规模的数据量用MySQL绰绰有余没必要上PostgreSQL或者Oracle这种偏重的方案。我额外还要说一句很多人纠结要不要用Redis做缓存我实测下来这种管理系统的数据变更频繁、单条数据量小缓存收益有限反而引入缓存会让代码复杂好几个量级。除非你后面要扛高并发查询否则纯MySQL就够了别自己给自己加戏。1.3 前后端分离的边界划分接口、状态、权限各归各管前后端分离不是说后端写接口、前端写页面就完事了真正的边界划分是后端只输出JSON数据和管理鉴权状态前端负责页面渲染、表单校验和路由跳转。比如用户是否已登录这个状态后端用JWT令牌来验证前端用路由守卫配合本地存储来判断页面访问权限两边各管一段。在我这个系统里登录接口由后端提供接收用户名密码后校验并签发token前端拿到token后存入localStorage每次axios请求都带上这个token后端拦截器统一校验token的合法性。信息的新增修改查询前端只是把表单数据组织成JSON发给后端SQL和事务控制完全在后端处理。这种划分的好处是前端可以独立测试页面交互后端可以用接口测试工具单独调接口开发起来不用互相等待。2. 数据库设计与SpringBoot服务端落地三张核心表和一个动态查询2.1 user表、student_info表、family_member表怎么设计字段数据库设计是整个系统的地基。我建库时用utf8mb4字符集原因很简单万一学生信息里出现生僻字或者特殊符号utf8mb4能正常存储utf8会直接变问号。下面是我实际在用的三张核心表结构不是教科书的抽象版本是能直接跑起来的-- 用户账号表 CREATE TABLE sys_user ( id INT NOT NULL AUTO_INCREMENT, username VARCHAR(50) NOT NULL COMMENT 登录账号, password VARCHAR(100) NOT NULL COMMENT BCrypt加密后的密码, real_name VARCHAR(50) DEFAULT NULL COMMENT 真实姓名, role TINYINT DEFAULT 2 COMMENT 1-管理员 2-学生, student_no VARCHAR(20) DEFAULT NULL COMMENT 关联学号学生角色必填, create_time DATETIME DEFAULT CURRENT_TIMESTAMP, PRIMARY KEY (id), UNIQUE KEY uk_username (username) ) ENGINEInnoDB DEFAULT CHARSETutf8mb4 COMMENT系统用户表; -- 学生基本信息表 CREATE TABLE student_info ( id INT NOT NULL AUTO_INCREMENT, student_no VARCHAR(20) NOT NULL COMMENT 学号, name VARCHAR(50) NOT NULL, gender TINYINT DEFAULT 0 COMMENT 0-男 1-女, birth_date DATE DEFAULT NULL, ethnic VARCHAR(20) DEFAULT NULL COMMENT 民族, political_status VARCHAR(20) DEFAULT NULL COMMENT 政治面貌, phone VARCHAR(20) DEFAULT NULL, address VARCHAR(255) DEFAULT NULL, major VARCHAR(50) DEFAULT NULL COMMENT 专业, class_name VARCHAR(50) DEFAULT NULL COMMENT 班级, enroll_year VARCHAR(10) DEFAULT NULL COMMENT 入学年份, status TINYINT DEFAULT 1 COMMENT 1-在读 0-离校, PRIMARY KEY (id), UNIQUE KEY uk_student_no (student_no) ) ENGINEInnoDB DEFAULT CHARSETutf8mb4 COMMENT学生基本信息表;家庭成员表它的核心是外键关联到student_info表用student_no做逻辑关联而不是物理外键。这个是我刻意做的取舍物理外键在删除和更新时容易引发连锁约束而且在数据量上来后会影响插入性能。逻辑关联的意思是表结构里不加FOREIGN KEY关键字由应用层保证关联关系查询时靠join完成。CREATE TABLE family_member ( id INT NOT NULL AUTO_INCREMENT, student_no VARCHAR(20) NOT NULL, member_name VARCHAR(50) NOT NULL, relation VARCHAR(20) NOT NULL COMMENT 父子/母女等, work_unit VARCHAR(100) DEFAULT NULL, phone VARCHAR(20) DEFAULT NULL, PRIMARY KEY (id), KEY idx_student_no (student_no) ) ENGINEInnoDB DEFAULT CHARSETutf8mb4 COMMENT家庭成员表;奖惩记录表的思路类似事件类型和审批状态用int类型存储页面上用字典映射成中文。这种设计比直接存字符串灵活比如审批状态以后要扩展更多状态值时只需要改代码里的字典映射不用改表结构。字段的默认值一定要设置比如审批状态默认0待审核避免后端插入数据时漏传字段导致NullPointerException。2.2 SpringBoot工程里Controller-Service-Mapper三层怎么协同项目结构我给一个经验性的组织方式照着建就行com.example.personinfo ├── controller # 接收前端请求返回Result对象 ├── service # 业务逻辑处理事务控制 ├── mapper # MyBatis接口对应XML文件 ├── entity # 数据库实体类 ├── interceptor # JWT拦截器 ├── config # 跨域配置等 └── common # 统一返回值、异常处理、JWT工具类Controller层只做参数接收和结果封装不写业务逻辑。比如新增学生信息Controller就接收一个StudentInfo对象直接调用service层的insert方法。Service层里做字段校验学号非空、手机号格式和数据插入用Transactional注解保证家庭成员和学生信息一并保存时不会出现部分成功部分失败。Mapper层就是最底层的数据库操作接口方法直接对着SQL执行。统一返回结果类Result 尤其值得说一下。前端每次收到的JSON格式都是固定的{ code: 200, message: 操作成功, data: { } }前端axios拦截器解析这个结构时逻辑就非常统一code为200就取data渲染页面code不是200就弹出message提示。这比让每个接口返回裸JSON前端到处判断要强太多了。很多东西看着是小细节长期维护开发时才知道它的价值。MyBatis的XML文件里我大量用了动态SQL尤其是查询学生列表时那种支持按姓名模糊查询、按班级精确查询、按学号精确查询的三个条件组合场景。这一步如果写死三条SQL分别应对代码会臃肿到没法看动态SQL用 和 完美解决select idselectStudentList resultTypecom.example.personinfo.entity.StudentInfo SELECT * FROM student_info where if testname ! null and name ! AND name LIKE CONCAT(%, #{name}, %) /if if testclassName ! null and className ! AND class_name #{className} /if if teststudentNo ! null and studentNo ! AND student_no #{studentNo} /if /where ORDER BY student_no /select2.3 JWT登录与权限拦截后端必须守住的防线登录逻辑看似简单但用到生产级别需要注意的点很多。密码我存的是BCrypt加密后的哈希值登录校验时用BCrypt的matches方法比对。为什么不存MD5因为彩虹表攻击在MD5面前基本都是秒破BCrypt自带随机盐每次加密结果都不同安全性高一个量级。JWT生成使用jjwt库设置过期时间我用的2小时用户操作超过2小时就要求重新登录时间设定不能太长token泄露后的风险窗口越小越好。拦截器是最容易被忽视的部分。我写了一个LoginInterceptor注册到WebMvcConfigurer里并且用addPathPatterns和excludePathPatterns精确控制哪些接口要拦、哪些不拦。登录接口/register和/login要放行静态资源和错误路径放行其余接口一律要解析请求头里Authorization字段的token。public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) throws Exception { String token request.getHeader(Authorization); if (StringUtils.hasText(token)) { try { Long userId JwtUtil.getUserIdFromToken(token); UserContext.set(userId); return true; } catch (Exception e) { response.setStatus(401); response.setContentType(application/json;charsetUTF-8); response.getWriter().write({\code\:401,\message\:\未登录或token已过期\}); return false; } } response.setStatus(401); response.setContentType(application/json;charsetUTF-8); response.getWriter().write({\code\:401,\message\:\未登录或token已过期\}); return false; }UserContext是我自己写的一个ThreadLocal工具类登录鉴权通过后把当前userId塞进线程变量后面在Service层任何地方都能取到当前操作人是谁。这个设计在记录最后修改人、操作日志时特别方便不需要每个接口都手动传userId参数。还要给管理员和学生分权限。接口层面我用拦截器里解析出的role判断比如删除学生信息只允许role1的管理员调用学生在调用修改接口时只能改自己studentNo对应的数据。拦截器里加一个判断逻辑即可不用引入Spring Security那种重量级框架对这个体量的项目来说过度设计了。3. Vue前端的目录组织与接口交互路由守卫、axios封装和组件复用3.1 前端工程按什么维度拆目录才不会被自己绕晕前端这块我看到太多人把Vue项目写成一个巨大的App.vue加一个巨长的script标签。这种写法短平快但一旦页面超过三个就彻底失控。我的前端目录是按页面-组件-工具三层来组织的页面里只放路由对应的最外层视图公共UI抽成组件请求相关的逻辑全部集中到api目录src ├── api │ ├── request.js # axios实例封装 │ ├── user.js # 登录相关接口 │ └── student.js # 学生信息管理接口 ├── router │ └── index.js # 路由表和守卫 ├── store │ └── user.js # 用户状态 ├── views │ ├── Login.vue # 登录页 │ ├── StudentList.vue # 学生列表页 │ ├── StudentDetail.vue# 学生详情/编辑页 │ └── Dashboard.vue # 首页统计 ├── components │ ├── FamilyMemberTable.vue # 家庭成员表格组件 │ └── RewardRecordList.vue # 奖惩记录列表组件 └── utils └── auth.js # token读写封装api目录里的request.js是整个前端请求的枢纽。我用axios.create生成一个实例baseURL直接设成后端域名前缀在拦截器里统一加上token统一处理错误码这样每个页面里的接口调用只需要写一行代码去拿数据。import axios from axios import { Message } from element-ui import { getToken, clearToken } from /utils/auth const service axios.create({ baseURL: process.env.VUE_APP_API_BASE_URL, timeout: 15000 }) service.interceptors.request.use(config { const token getToken() if (token) { config.headers[Authorization] token } return config }) service.interceptors.response.use( response { const res response.data if (res.code 401) { clearToken() window.location.href /login return Promise.reject(new Error(登录已过期)) } if (res.code ! 200) { Message.error(res.message || 请求出错) return Promise.reject(new Error(res.message)) } return res.data }, error { Message.error(error.response?.data?.message || 网络异常) return Promise.reject(error) } ) export default service3.2 学生信息列表与编辑表单查询、弹窗、提交回显一整套交互学生列表页是基于Element UI的el-table实现的进入页面先在mounted钩子里调一次查询接口加载全部数据搜索区域放学号、姓名、班级三个输入框点查询按钮时把表单条件作为query参数传给后端。这个交互看起来常规但前端在处理那个编辑并保存家庭成员数据的步骤上比想象中复杂。我是把StudentDetail.vue拆成了三个区块基本信息区用一个el-form包裹包含学号管理员可改、学生只读、姓名、性别、出生日期等输入项家庭成员区域用独立的FamilyMemberTable组件它的数据源和主表单分开维护新增一条成员记录时只更新这个组件内部的数组奖惩记录区域类似。保存按钮的handleSubmit方法要做两件事先调用学生信息更新接口再遍历家庭成员数组调用对应的批量保存接口。这两个请求我用async/await串行执行防止并发导致数据错乱。表单校验我用了Element UI原生的rules验证规则手机号用正则匹配11位数字学号必填且最长20位。前端校验的意义不是替代后端校验而是早一步拦截明显错误减少后端请求次数和数据库压力。后端Service层仍然要有同样的校验逻辑两边都管双保险。3.3 路由守卫和本地存储防止未登录用户直接改URL绕过认证前端的路由守卫是登录校验的第一道门。我在router/index.js里给每个路由配置了meta.requiresAuth字段然后在全局前置守卫里判断router.beforeEach((to, from, next) { const token getToken() if (to.meta.requiresAuth !token) { next({ path: /login }) return } if (to.path /login token) { next({ path: / }) return } next() })这段逻辑能拦住没登录的用户用手动改URL的方式跳进首页但注意它只是前端层面的拦截真正防住越权操作仍然靠后端接口的token校验和权限判断。前端守卫的价值在于提升用户体验——一刷新就自动跳转登录页而不是等接口请求返回401之后才弹错误。token存储我放在localStorage而不是cookie原因是纯前端分离架构下后端不需要维护sessioncookie反而容易和跨域配置冲突。比较容易被忽略的坑是改密码或退出登录时务必调用clearToken把localStorage清掉否则下次登录老token会和新token混在一起。我在utils/auth.js里封装了getToken和setToken所有页面统一走util方法避免有些地方直接操作localStorage导致key名不一致。4. 从源码到上线本地联调、服务器部署与高频问题排查4.1 环境准备清单从JDK到Node缺一个后面全是坑整个项目要跑起来需要准备的环境我列一份可以直接对着检查的清单。JDK版本用1.8这是SpringBoot 2.x最兼容的版本别用太高版本的JDK去兜底Maven的pom文件依赖版本可能不支持。Node版本建议14以上npm install时如果报错node-sass不兼容多半是Node和依赖版本对不上换成用sass包就能解决。前端构建我用的npm run build构建后会在dist目录生成纯静态文件后端用Maven打包成jar包目标环境只要装了JDK就能直接java -jar运行。MySQL我是在本地起一个8.0的实例初始化时建好数据库并设置用户权限推荐用utf8mb4前面说过这个编码集处理中文和特殊字符最稳妥。如果你在Windows上开发在Linux服务器上部署最建议的流程是开发期两边都连同一个远程数据库或者在服务器上装好MySQL后用NaviCat这类客户端把sql脚本导入远端的数据库代码打包后部署到服务器只改数据库连接配置即可。4.2 前后端联调阶段最容易卡住的三个点跨域、代理、时间格式前端开发服务器默认跑在8080端口后端跑在8081或9090端口浏览器直接跨域请求时会被同源策略拦截。解决方式我不用前后端都加CORS注解那种零散做法而是在Vue的vue.config.js里配置devServer的proxy代理把/api开头的请求代理到后端地址上module.exports { devServer: { port: 8080, proxy: { /api: { target: http://localhost:8081, changeOrigin: true, pathRewrite: { ^/api: } } } } }这么配置之后前端代码里请求地址写/api/login开发环境实际转发到http://localhost:8081/login前后端联调时全程不会有跨域报错。后端那边我在config包也加了CorsConfig配置allowCredentials(true)并指定允许的域名白名单主要目的是区分开发环境和生产环境开发靠代理生产靠Nginx反向代理解决跨域。时间格式也是联调中的高频坑。前端传2024-09-10这种字符串给后端后端Jackson反序列化需要配置格式匹配否则直接解析失败。我在SpringBoot的application.yml里加了spring: jackson: date-format: yyyy-MM-dd time-zone: GMT8同时实体类里日期字段统一用java.time.LocalDate避免用Date类型带来的时区偏移和格式化问题。这个问题排查起来特别隐蔽接口能通但值不对最后定位到是Jackson序列化格式不匹配。写完配置后记得重新编译后端配置改动不会热生效。4.3 服务器部署完整命令与一键启动脚本服务器部署我这边的做法是一台Linux服务器装JDK8和NginxMySQL如果不用云数据库就装在服务器本机。打包产物前端dist目录的静态文件交给Nginx托管后端boot jar包用java命令启动用systemd把它注册成服务开机自启、崩溃自动重启。操作步骤依次是# 后端打包跳过测试避免测试环境连不上数据库导致打包失败 mvn clean package -DskipTests # 上传jar包到服务器 /opt/personinfo/ scp target/personinfo-0.0.1-SNAPSHOT.jar rootyour_server:/opt/personinfo/ # 编写启动脚本 start.sh #!/bin/bash cd /opt/personinfo nohup java -Xms256m -Xmx512m -jar personinfo-0.0.1-SNAPSHOT.jar --spring.profiles.activeprod app.log 21 生产环境的数据库配置要单独用application-prod.yml管理里面把数据库地址、用户名密码改成服务器上的真实值启动时通过--spring.profiles.activeprod指定。绝对不要把本地数据库密码打包到jar部署上线的。前端静态文件部署到Nginx之后Nginx配置里要做一件事除了API请求反向代理到后端服务还要把非API的路径全部rewrite到index.html。否则Vue Router使用history模式时刷新一个子路径页面会返回404。配置核心部分如下server { listen 80; server_name your_domain; root /opt/personinfo/dist; index index.html; # 静态资源缓存 location /assets/ { expires 7d; } # 前端路由history模式支持所有非静态文件请求都指向index.html location / { try_files $uri $uri/ /index.html; } # API反向代理到后端 location /api/ { proxy_pass http://127.0.0.1:8081; proxy_set_header Host $host; proxy_set_header X-Real-IP $remote_addr; proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; } }我用的是Vue Router的history模式不带#号地址栏看着干净但必须配上面的try_files规则。如果你嫌麻烦用hash模式页面默认没有这个刷新404问题但URL里会带一个#导航到二级页面时观感差一些。我实测中两个模式都跑过从搜索引擎收录和分享链接的角度看history模式绝对更专业。4.4 部署后我遇到的几个高频问题的定位链路这类管理系统上线后最常见的问题按我实际遇到的频率从高到低排每个问题后面附上排查思路你遇到时可以按同样的思路往下找。第一个是MySQL连接失败或者中文乱码。现象是后端起不来日志报Communications link failure或Unknown character set。原因九成是数据库连接URL没设置编码和时区参数。正确写法是spring: datasource: url: jdbc:mysql://localhost:3306/personinfo?useUnicodetruecharacterEncodingutf8useSSLfalseserverTimezoneAsia/Shanghai第二个是Post请求的数据后端收到全是null。排除过程分三步先看前端console有没有400错误有400说明参数类型不匹配再看后端日志有没有JSON解析异常最后检查实体类字段名和前端传参名是否大小写一致。我之前就在这里栽过一次前端传studentNo后端实体类字段写成了studentnoMyBatis映射出来结果就是null这个问题很多新手会忽略一定要逐字段核对驼峰命名。第三个是JWT在服务器上突然失效本地却好的。这是因为生产环境服务器时间不准JWT签发和校验都依赖计算时间差。解决方法是给服务器装ntpdate同步时间或者在JWT校验时允许30秒的时钟偏移。我在JwtUtil的解析方法里加了一句宽容校验不纠结1分钟内的时间差这个坑基本就消失了。第四个是Nginx配置好之后API请求返回504。定位思路先直接用curl -X POST http://127.0.0.1:8081/api/login去请求后端如果通说明Nginx代理配置有问题如果也不通说明后端服务挂了。然后去看app.log的栈信息八成是数据库连接池耗尽或者OOMOOM的话JVM参数里的-Xmx可以调大一点或者优化SQL减少全表扫描。5. 从一个能跑的Demo到一个真正能用的系统我的收尾心得这套系统从前到后加起来大概花了两周多时间完成第一版很简单跑通登录和信息增删改查就花了三天。后面真正耗时间的不是写逻辑而是把边界情况补齐、把部署流程跑顺、把各种奇葩操作下的数据一致性考虑清楚。我在这过程中最有感触的一件事是前后端分离项目的联调成本比单体应用高很多所以接口文档一定要先于代码定下来。我自己用的方式是写一个简单的接口约定文档把每个接口的路径、请求参数、返回结构、错误码提前列好前端照着文档调后端照着文档写两边并行推进时冲突会少很多。你没这个文档的话等前端写完了发现后端接口参数名不一致来回沟通改代码的成本远高于一开始花半小时写文档的成本。给准备抄作业的读者最后的建议是部署之前不要直接从本地连生产数据库测试用一台独立的测试服务器把完整流程跑一遍。我就是因为图省事在本地直接连了生产库结果一个误操作把测试数据插到了正式环境后面清数据的时候才发现自己的SQL写得不够严谨花了半天整理。项目本身不难难的是每一环节都稳扎稳打不留尾巴。这套代码跑到现在已经稳定运行了几个月没再出过让我夜半惊醒的问题。