
各位同行、各位正在为毕业设计或者个人项目发愁的朋友们今天我想和你认真聊聊我最近完整落地的一个项目基于SpringBoot Vue的校园一卡通管理系统。无论你是准备打开IDE开始敲代码还是已经完成了大半、正在为前后端联调抓狂这篇内容都是我从实际开发过程中整理出来的实战总结涵盖了从某张数据库表为什么这么设计到某个请求为什么会一直504掉线的全过程心路。聊这个系统的初衷很简单。校园生活的核心场景里食堂就餐、超市购物、机房上机、图书借阅、宿舍门禁几乎都离不开那张小小的卡片。当我们要用软件把这一整套“卡务 商户 财务”的流程管理起来时它真正锻炼的并不只是CRUD本身而是一个涉及多方角色、多类资金流向、多个物理终端的业务系统该怎么拆结构、怎么定接口、怎么保证数据的准确性。用Java Vue这套组合来做恰好能覆盖从企业级后端到现代前端交互的所有关键环节。先说清楚这套系统到底是做什么的。一句话概括它用一张校园卡作为身份凭证和支付载体将学生、教职工、商户管理员、系统超级管理员这四个角色统一到同一个平台里。学生可以在线充值、查询实时余额、查看每一笔消费明细、随时挂失解挂商户管理员能够处理刷卡消费、管理自己的账目流水管理员则负责发卡、补卡、管理用户权限、查看全系统的统计数据。这看起来是一个“管理系统”但落到技术实现上每一个功能点背后都是权限控制、事务处理和并发经济的考验。我在这篇分享里不会只给你列代码而是把整个实现路径和经验教训都挖出来为什么我选择了SpringBoot 2.7而不是3.x为什么Vue3必须搭配Vite而不是继续用Webpack面对并发消费时数据库表是怎么防止超扣的部署到服务器上以后跨域、History路由、图片404这些问题又是一个一个怎么解决的。这些内容有思路、有代码、有坑拿出来希望给正在做类似项目的朋友一份真正能顺藤摸瓜的参考。整个系统体量其实没有想象中那么大但五脏必须俱全。准备跟着我往下看的你不需要是资深架构师只需要有Java基础、知道SpringBoot写过Controller、对Vue生命周期有基本理解就能完全吃透这篇文章。项目建设过程中的完整源码我也做了归档整理结尾会说怎么拿到手跑起来。接下来我们直接进入正题。1. 系统整体设计为什么是SpringBoot Vue3这套组合拳1.1 后端选型时的关键考量先说说后端。做这种校园内部管理系统最大的特点是业务规则明确、并发量不高但要准、权限层级必须清晰、开发周期短。基于这几点SpringBoot几乎是唯一需要认真考虑的答案。它的自动装配机制能够极大缩短配置时间一个依赖、一个配置文件就能跑起一个可用的Web服务同时它内置的Tomcat容器也让部署方式回归简单——一个Jar包就能分发启动。这里有一个实际的选择值得展开聊聊那就是SpringBoot的版本号。我在技术选型时最终锁定了2.7.x系列而不是博主圈里吹得很火的SpringBoot 3.x。原因非常朴素一是3.x最低要求JDK17而很多学校机房、个人电脑上还跑着JDK8如果为了一个毕设项目去折腾全局的JDK环境成本被不必要地拉高了二是一大批中间件特别是后面要用的MyBatis-Plus和老牌的PageHelper分页插件在低版本上兼容性最稳3.x刚出来时某些依赖会报奇怪的冲突。所以如果你也是做课堂项目或者毕设别追求最新稳定压倒一切。数据库方面我选择的是MySQL 8.0 MyBatis-Plus组合。MySQL 8.0的窗口函数和JSON能力虽然在这个项目里用得不算多但是它的默认字符集utf8mb4能完整支持中文字段和emoji这点在导入学生名单时非常有用。MyBatis-Plus的价值在于它在MyBatis基础上补足了单表CRUD的通用方法一句selectById、updateById就能覆盖大部分基础操作又能保留XML文件去写那些复杂的多表关联报表SQL属于一个兼顾效率和灵活的折中方案。对于一卡通系统里动辄要联查三四张表的统计需求这个组合实测下来非常顺手。1.2 前端选型与工程化的意义前端部分我用的是Vue3 Vite Element Plus Pinia。这个搭配如果经常逛社区的朋友应该不陌生算是2024年往后Vue生态的标准配置了。Vue3的Composition API用起来明显比Options API更适合管理复杂业务逻辑比如同一个页面里既要查询消费记录又要监听余额变动还要处理表单弹窗用setup函数加上ref、reactive、computed这些响应式API代码会变得非常集中逻辑不再散落在data、methods、watch各个角落里。为什么特意强调Vite而不是Vue CLI我的实际体验是在开发一卡通这种页面数量多、组件复用频繁的中后台系统时Vite的按需编译和预构建依赖机制简直是一种享受。传统的Webpack DevServer改一行代码等两秒才能热更新但Vite基于ES Module的处理方式几乎能做到改动即刷新。特别是当项目膨胀到几十个路由组件以后使用Vite那一方等待时间差距非常明显。对于打包速度Vite的优势也很突出生产构建走的是Rollup管线优化到位的话通常是秒级完成。UI组件库我选了Element Plus。说句实话用任何组件库都逃不掉改样式的命但Element Plus的生态成熟度和表单校验能力帮我们省下了不少时间。后文讲前端实操时我会专门展示如何利用它自定义一套与后端校验规则严格对应的表单验证逻辑。提示如果是从零开始的新项目直接使用npm create vitelatest即可获得Vite官方模板选择Vue JavaScript组合即可不需要上手TypeScript避免引入额外心智负担。2. 数据库设计全拆解一卡通系统的表结构经验2.1 角色权限模型的落地方式校园一卡通系统最核心、也最不能搞混的就是角色权限。学生、商户、管理员三者看到的数据范围天差地别学生只能看到自己的卡和流水商户只能处理自己门店的消费和自己的账管理员则要掌控全局。这时候如果直接给用户表加一个role字段后面会有两个隐患一是权限判断时到处写魔法数字代码可读性极差二是以后如果要扩展“辅导员可以查看其管理范围内学生消费情况”之类的需求系统就要伤筋动骨了。所以我在设计时没有嫌麻烦而是建立了一张用户表加上角色字段同时在关键数据表如消费记录上增加归属方字段。再把权限判断收敛到一个拦截器和一个全局用户上下文处理器中。具体来说用户表sys_user:CREATE TABLE sys_user ( id bigint(20) NOT NULL AUTO_INCREMENT, username varchar(50) NOT NULL COMMENT 登录名, password varchar(128) NOT NULL COMMENT BCrypt加密后密码, real_name varchar(50) DEFAULT NULL COMMENT 真实姓名, role tinyint(4) NOT NULL DEFAULT 0 COMMENT 角色 0学生 1商户 2管理员, student_no varchar(20) DEFAULT NULL COMMENT 学号仅学生角色有, card_id bigint(20) DEFAULT NULL COMMENT 绑定的卡ID, status tinyint(4) NOT NULL DEFAULT 1 COMMENT 1正常 0禁用, create_time datetime DEFAULT NULL, PRIMARY KEY (id), KEY idx_card_id (card_id) ) ENGINEInnoDB AUTO_INCREMENT1 DEFAULT CHARSETutf8mb4;这么设计的好处是业务代码里通过一个工具类就能拿到当前登录用户和其角色然后做分支处理。比如商户管理员登录后要查当日流水SQL里强制绑定merchant_id 当前用户ID从数据层面堵住了越权访问的口子而不是把全部数据查出来再在Java里过滤。2.2 卡片与余额的资金安全设计资金相关的表是整个项目的命门也是最容易在答辩时被老师追问的地方。卡表card_info和流水表transaction_log这两张表的设计我经过了几轮迭代才最终定稿。卡表:CREATE TABLE card_info ( id bigint(20) NOT NULL AUTO_INCREMENT, card_no varchar(32) NOT NULL COMMENT 卡号全局唯一, user_id bigint(20) NOT NULL COMMENT 持卡人用户ID, balance decimal(10,2) NOT NULL DEFAULT 0.00 COMMENT 当前余额, status tinyint(4) NOT NULL DEFAULT 1 COMMENT 1正常 2挂失 3注销, lost_time datetime DEFAULT NULL, create_time datetime DEFAULT NULL, PRIMARY KEY (id), UNIQUE KEY uk_card_no (card_no) ) ENGINEInnoDB DEFAULT CHARSETutf8mb4;流水表:CREATE TABLE transaction_log ( id bigint(20) NOT NULL AUTO_INCREMENT, card_id bigint(20) NOT NULL, type tinyint(4) NOT NULL COMMENT 1充值 2消费 3退款, amount decimal(10,2) NOT NULL COMMENT 变动金额消费记负数, balance_after decimal(10,2) NOT NULL COMMENT 变动后余额, merchant_id bigint(20) DEFAULT NULL COMMENT 商户ID消费时必有, remark varchar(255) DEFAULT NULL, create_time datetime NOT NULL, PRIMARY KEY (id), KEY idx_card_id_time (card_id,create_time) ) ENGINEInnoDB DEFAULT CHARSETutf8mb4;这里特别注意三个细节第一balance字段用的是decimal(10,2)而不是double或者float。凡是涉及金额的运算在Java中保持一致地使用BigDecimal。double在做加法时可能产生0.1 0.2 0.30000000000000004这种精度问题放在交易系统里是致命的。所以从建表到代码资金字段我全部走BigDecimal。第二发起消费扣款时千万不能先查出余额再在Java里比较够不够然后执行update。这种“先查后改”的方式在两个请求同时到达时会发生超扣。正确做法是用一条带条件的UPDATE语句把余额的判断放在SQL层面// 正确做法数据库行锁保护防止并发超扣 int rows cardInfoMapper.updateBalanceIfEnough( cardId, new BigDecimal(12.50), // 本次消费金额正数 updateTime ); if (rows 0) { throw new BizException(余额不足或卡状态异常扣款失败); }对应的Mapper SQLupdate idupdateBalanceIfEnough UPDATE card_info SET balance balance - #{payAmount}, update_time #{updateTime} WHERE id #{cardId} AND status 1 AND balance #{payAmount} /update这条语句执行成功后再插入一条流水记录。整个过程用一个Transactional包裹起来就既保证了并发安全又保证了数据一致性。第三流水表写入成功后立即返回不需要在事务里再做多余查询。查询余额走单独的读接口利用MySQL默认的REPEATABLE READ隔离级别读到的都是已提交的数据不会出现脏读问题。2.3 商户和消费场景的表结构商户表merchant_info记录了食堂窗口、超市等消费场所的基本信息以及对应的管理员账号。这里关键的设计是将“商户管理员”和“商户”本身解耦商户管理员是sys_user表中的一种角色他通过merchant_id字段关联到自己管理的商户记录上。这样将来要给某个门店添加多个收银员账号时不需要改动商户表直接再建一个用户并关联相同商户ID即可。消费记录表其实在上面的transaction_log中已经覆盖了核心字段但为了方便商户端按天、按月汇总我还会在业务接口层面直接编写聚合SQL利用DATE_FORMAT函数按天分组统计营业收入。我不推荐为了统计专门建一张冗余的日汇总表除非你的系统预计每天有数十万条流水否则在MySQL里实时聚合足够快而且省去了一大堆同步和一致性维护的成本。3. 后端核心实操从接口设计到安全防护3.1 接口风格与统一返回体前后端分离开发时接口规范直接决定联调效率。我项目里的所有接口统一遵循RESTful风格并且一律返回同一个结构体ResultT。Data public class ResultT implements Serializable { private Integer code; // 200成功500业务失败401未登录 private String message; private T data; public static T ResultT success(T data) { ResultT r new Result(); r.setCode(200); r.setMessage(操作成功); r.setData(data); return r; } public static T ResultT error(String message) { ResultT r new Result(); r.setCode(500); r.setMessage(message); return r; } public static T ResultT unauthorized() { ResultT r new Result(); r.setCode(401); r.setMessage(未登录或登录已过期); return r; } }这样做出一旦约定好前端拿到任何一个响应都先判断code 200再取data进行渲染不需要为每一个接口单独处理错误分支。这是一个极小的约定但为后续联调省下的时间非常可观。3.2 JWT认证与拦截器实现一卡通的系统决定了用户登录后需要持续一段时间内保持会话所以状态管理我用的是比较轻量的JWT方案。用户登录成功以后后端签发一个有效期8小时的Token返回给Vue前端。前端把它存到localStorage并在每个请求的拦截器里通过Authorization请求头带着它。后端再写一个HandlerInterceptor统一处理Token校验并解析出当前用户信息放到ThreadLocal中。关键点在于拦截器要放行登录接口、验证码接口等不需要认证的路径而对其他以/api/**开头的接口统一检查。同时配合CORS跨域配置让前端能够携带自定义请求头Configuration public class WebMvcConfig implements WebMvcConfigurer { Resource private JwtInterceptor jwtInterceptor; Override public void addInterceptors(InterceptorRegistry registry) { registry.addInterceptor(jwtInterceptor) .addPathPatterns(/api/**) .excludePathPatterns(/api/auth/login, /api/auth/captcha); } Override public void addCorsMappings(CorsRegistry registry) { registry.addMapping(/**) .allowedOriginPatterns(*) .allowedMethods(GET, POST, PUT, DELETE, OPTIONS) .allowedHeaders(*) .allowCredentials(true) .maxAge(3600); } }这里有一个非常容易踩的坑如果配置了跨域且设置了allowCredentials(true)那么不能使用allowedOrigins(*)否则浏览器会直接拒绝响应必须改用allowedOriginPatterns(*)。我一开始没有注意结果前端请求在浏览器里一直报CORS错误排查了很久才反应过来。3.3 充值、消费、挂失三个核心模块的实现逻辑充值模块的难点不在CRUD而在事务一致性。学生点击充值后系统先修改卡表余额然后插入一条充值流水如果修改余额成功但插入流水失败事务回滚两者都不生效保障账面真实。在SpringBoot中做这件事非常简单只需要在Service实现类方法上加上Transactional(rollbackFor Exception.class)注解把多张表的写操作放在同一个方法里即可。挂失逻辑的巧妙之处在于它不直接改用户状态而是改卡表状态。卡片状态从1正常变成2挂失之后前面提到的消费扣款SQL里有status 1的硬性条件这意味着即使有人拿着实体卡在消费终端上操作系统也会拒绝扣款。这个设计让我们不需要在每一个消费接口里都重新判断用户状态而是通过一张表的状态统一管控逻辑更清晰。消费模块的实现是商户管理员通过读卡器读卡号然后输入金额提交消费。后端接口接收cardNo和amount两个参数。我先根据卡号查到卡信息再判断卡状态随后走上述的余额扣减逻辑。整个接口的响应时间在未加缓存的情况下大概在30ms以内完全够用。4. 前端页面实现与核心交互4.1 项目初始化和环境配置前端工程搭建我采用的是npm create vitelatest命令。需要注意Node.js版本要求Vite 5要求Node.js 18如果本地还是14甚至12建议先升级Node环境。除了Node版本问题npm安装依赖时容易因为网速问题卡住这时候可以做两件事第一用官方推荐的npmmirror.com镜像源替换默认源第二安装依赖时不要随便中断等待其完成即可。组件库Element Plus使用按需导入。全量引入虽然配置简单、代码量少但打包后体积会大很多。我这里采用的是官方推荐的unplugin-auto-import和unplugin-vue-components插件实现自动按需导入// vite.config.js import AutoImport from unplugin-auto-import/vite import Components from unplugin-vue-components/vite import { ElementPlusResolver } from unplugin-vue-components/resolvers export default defineConfig({ plugins: [ vue(), AutoImport({ resolvers: [ElementPlusResolver()] }), Components({ resolvers: [ElementPlusResolver()] }) ] })这样配置好以后在模板里直接使用el-button组件插件会自动引入对应的样式和JavaScript代码不需要手动import。4.2 路由配置与权限守卫前端路由分为两种一种是公共页面登录页一种是需要通过登录认证的内容页面。我在Vue Router里设置了全局前置守卫每次跳转前检查localStorage里有没有Token如果没有就强制跳转到登录页。router.beforeEach((to, from, next) { const token localStorage.getItem(token) if (to.path /login) { next() return } if (!token) { next(/login) return } next() })这里如果要做得更细还可以在路由元信息中标记允许访问的角色在守卫里再比对一次用户角色实现前端按钮级别的权限控制。但需要注意前端守卫只做体验优化真正的安全防线必须由后端拦截器把关两边要形成共识。4.3 状态管理Pinia管理用户信息与全局数据因为用户信息在多个页面都要用比如顶部导航栏要显示当前用户姓名个人中心要展示余额商户端要显示自己的商户编号因此我用Pinia建立了一个全局Store。import { defineStore } from pinia export const useUserStore defineStore(user, { state: () ({ token: localStorage.getItem(token) || , userInfo: JSON.parse(localStorage.getItem(userInfo) || {}) }), actions: { setToken(token) { this.token token localStorage.setItem(token, token) }, setUserInfo(info) { this.userInfo info localStorage.setItem(userInfo, JSON.stringify(info)) }, logout() { this.token this.userInfo {} localStorage.removeItem(token) localStorage.removeItem(userInfo) } } })在实际开发中我发现不少人喜欢把每次接口返回的字段直接放进Pinia结果造成状态和页面组件数据源不统一改起来非常费劲。我的建议是Store只保存跨页面共享且需要响应式更新的全局数据比如当前用户、未读消息数等页面内部私有的数据不要放进去。4.4 Element Plus的高级用法和自定义校验处理表单时Element Plus的el-form自带校验能力但很多同学只是简单写个required: true就不管了。实际上它的validator回调函数非常强大我们可以把后端校验规则同步到前端这样用户在提交前就有及时反馈减轻服务器压力。以充值金额输入为例要求充值金额在1到5000元之间且最多保留两位小数const validatorAmount (rule, value, callback) { if (value || value null) { callback(new Error(请输入充值金额)) return } const num Number(value) if (isNaN(num) || num 1 || num 5000) { callback(new Error(充值金额必须在1~5000元之间)) return } const decimalPart String(value).split(.)[1] if (decimalPart decimalPart.length 2) { callback(new Error(充值金额最多保留两位小数)) return } callback() }这样做的好处是用户不需要等请求返回才能知道自己填错了交互体验会有一个质的提升。4.5 Axios封装与请求统一管理接口请求统一封装在src/utils/request.js中。封装的核心作用是统一处理Token携带、HTTP状态码异常、业务错误提示和401跳转避免每个页面都重复写一大段拦截逻辑。import axios from axios import { ElMessage } from element-plus import router from /router import { useUserStore } from /stores/user const service axios.create({ baseURL: /api, timeout: 15000 }) service.interceptors.request.use(config { const userStore useUserStore() if (userStore.token) { config.headers.Authorization Bearer ${userStore.token} } return config }) service.interceptors.response.use( response { const res response.data if (res.code 200) { return res.data } if (res.code 401) { const userStore useUserStore() userStore.logout() router.push(/login) return Promise.reject(new Error(登录已过期)) } ElMessage.error(res.message || 请求失败) return Promise.reject(new Error(res.message)) }, error { ElMessage.error(网络异常请稍后重试) return Promise.reject(error) } ) export default service这样封装之后业务代码里调接口就变得非常干净。比如查询余额的调用const balance await getBalance(cardId)不需要关心Token、响应判断这些杂事代码简洁清晰。5. 前后端联调与部署实战5.1 Vite开发代理解决联调跨域在开发阶段前端在localhost:5173后端在localhost:8080两者端口不同天然存在跨域问题。除了在后端配置CORS以外前端更推荐的做法是在Vite的vite.config.js中配置开发代理让/api开头的请求在开发阶段就转发到后端地址这样前端的请求地址始终是相对路径在打包部署后还能利用Nginx配置同样的转发规则。export default defineConfig({ server: { port: 5173, proxy: { /api: { target: http://localhost:8080, changeOrigin: true } } } })注意配置了changeOrigin: true之后后端拿到请求的Host头是localhost:8080不会触发后端一些基于Host的校验逻辑。这样开发起来就像在同一个服务下工作前端写代码时完全不用关心跨域。5.2 打包部署遇到的两个典型问题把前端打包后交给Nginx托管是我推荐的生产部署方式。执行npm run build生成dist目录后将该目录下的文件复制到Nginx的html目录再配置反向代理即可。这里有两个典型坑值得记录。第一个坑是刷新页面404。原因在于Vue Router开启了history模式路由地址如/dashboard/consume在服务端并不存在对应的物理文件刷新时Nginx会去查找这个路径的资源查不到自然就404了。解决办法是在Nginx配置中添加try_files $uri $uri/ /index.html;确保所有未匹配到的路由都回退到前端入口文件location / { root /usr/share/nginx/html; index index.html; try_files $uri $uri/ /index.html; } location /api/ { proxy_pass http://127.0.0.1:8080; proxy_set_header Host $host; proxy_set_header X-Real-IP $remote_addr; }第二个坑是打包后页面样式错乱通常是因为静态资源引用的路径是绝对路径部署在子目录时资源请求不到。解决办法是在vite.config.js里设置base: ./让静态资源路径变成相对路径这样不管部署在哪里都能正常加载。5.3 服务器端的安全组和防火墙配置真的把项目放到云服务器上后还要记得在安全组里放行80或443端口。如果使用Nginx默认端口80浏览器访问时直接输入IP或域名即可。后端服务跑在8080端口可以只让Nginx与后端之间通过内网访问不需要对公网暴露8080端口这是一种最基本的纵深防御。同时项目里的数据库账号密码、Redis密码等敏感配置一定不能写在被Git仓库追踪的配置文件中。我的做法是本地放置application-dev.yml生产环境通过环境变量注入关键配置配置文件放到Git忽略列表中避免密码泄露。6. 业务模块逐一点评与避坑6.1 一卡通的充值退款流程细节充值和退款虽然在代码上是对余额做相反数操作但两者在业务上的处理细节存在明显区别。充值通常是学生自助操作不需要商户介入退款则必须由管理员操作并且必须填写退款原因便于事后审查。因此退款接口在前端需要额外的权限控制只有角色为管理员账号的登录用户才能看到退款按钮。退款操作的数据库事务同样关键。管理员发起的退款如果卡内余额不足要直接拦截并提示这里用到的更新语句跟消费场景一样也是在SQL中判断余额不能小于退款金额。千万不要在Java代码中先select再判断上面讲过的并发超扣风险在这里同样存在。良好的习惯就是让数据库来做一致性的最后一道屏障。6.2 统计分析模块的SQL技巧一卡通系统的统计报表往往需要按天、按周、按月展示食堂消费总额、学生平均消费次数等信息。开发这类聚合SQL时我的经验是优先使用MySQL的日期函数对create_time进行格式化然后分组统计select idsumConsumeByDay resultTypemap SELECT DATE_FORMAT(create_time, %Y-%m-%d) AS day, SUM(amount) AS totalAmount, COUNT(*) AS totalCount FROM transaction_log WHERE type 2 AND create_time gt; #{startTime} AND create_time lt; #{endTime} GROUP BY DATE_FORMAT(create_time, %Y-%m-%d) ORDER BY day /select如果有多个商户需要按商户分组统计只需要在SQL中增加merchant_id或者关联商户表查询出商户名即可。这里要注意如果数据量变大一定要在create_time字段上建索引否则随着系统运行时间变长报表查询会越来越慢。像上面设计表时那样在transaction_log表上建立idx_card_id_time联合索引就是针对这类查询的优化。6.3 代码层面的防御性编程除了技术方案我还想强调一点工程上的习惯或者说是“防御性编程”的意识。在后端接口开发中传参校验不要相信前端。实际开发时我遇到过商户端传入一个负数的消费金额、学生端传入一个超长字符串的备注等脏数据。处理办法是在Controller层使用Validated注解配合javax.validation的约束注解快速拦截明显非法数据。PostMapping(/consume) public ResultString consume(RequestBody ConsumeRequest request) { if (request.getAmount() null || request.getAmount().compareTo(BigDecimal.ZERO) 0 || request.getAmount().compareTo(new BigDecimal(10000)) 0) { return Result.error(消费金额必须在0~10000元之间); } ... }这里之所以不厌其烦地写判断是因为我实实在在看到过学生持卡消费时如果商户误操作输错小数点系统必须有能力拦截异常数据不至于把卡里余额直接刷爆。前端提交数据时同样不要只依赖UI层校验接口层也需要做二次兜底。也就是说在事件处理函数里始终从Store获取最新数据不要在主组件里保存一份很久之前查询的余额后一直引用它以免用户在其他标签页修改了余额后页面展示的还是旧数据。7. 开发中常见问题与排查实录7.1 请求一直401或前端页面一直跳登录这个问题在联调期出现频率极高。绝大多数原因是Token没传或者Token过期后没有正确地清理本地存储。排查路径是打开浏览器开发者工具切换到Network面板看请求头里有没有Authorization字段再看后端日志中JWT解析时的异常信息区分是Token过期、签名不对还是根本没有携带。还有一种隐蔽情况是前端请求被代理到错误的后端尤其是本地同时启动了两个后端服务8080是旧端口、8081是新端口代理配置没有跟着改结果一直带着旧Token访问旧服务自然一直报401。处理这类问题最简单的方法是全局搜索一下代理配置里target的地址核对端口无误即可。7.2 数据库乱码问题MySQL中文乱码通常有两个来源一是数据库表字符集不是utf8mb4二是JDBC连接串没有指定characterEncodingutf8。前者在建表时可以统一指定后者在SpringBoot的application.yml中配置JdbcUrl时需要注意spring: datasource: url: jdbc:mysql://localhost:3306/campus_card?useUnicodetruecharacterEncodingutf8serverTimezoneAsia/ShanghaiuseSSLfalse配置完成后重启服务再测试插入中文数据。只要初始建库字符集正确这条链路基本不会再出问题。如果确实还有乱码最彻底的方法是把数据库字符集整体改为utf8mb4之后重新导入数据。7.3 前端打包文件过大问题初次打包时我遇到过dist资源达到3MB以上的情况在校园内网中加载速度尚可但放到公网演示时就会明显感觉到首屏白屏时间过长。优化方案很直接一是利用路由懒加载让首屏只加载当前页面需要的组件二是通过build.rollupOptions把一些大体积的第三方库单独拆分并配合CDN加速三是检查是否全量引入了Element Plus自动按需导入插件能有效压缩体积。经过这三步优化后我的项目首屏资源从3MB降到了800KB左右加载速度显著提升。7.4 明细查询慢优化方案消费明细分页查询是卡片管理中频率最高的操作。刚开始我把所有流水都查出来再在内存中分页数据量到了几万条后页面明显卡顿。正确的方案是使用MyBatis-Plus的分页功能插件在SQL层面直接LIMITPageTransactionLog page transactionLogMapper.selectPage( new Page(pageNum, pageSize), new LambdaQueryWrapperTransactionLog() .eq(TransactionLog::getCardId, cardId) .eq(TransactionLog::getType, type) .orderByDesc(TransactionLog::getCreateTime) );PageHelper或者MyBatis-Plus内置的物理分页不会把无关数据加载到内存对性能的提升是数量级的。加上刚才提到的索引设计即使流水表积累了数十万条记录分页查询也能稳定在100毫秒以内。8. 项目演示效果与后续扩展方向到这里整个项目的核心内容已经讲得差不多了我再根据自己的实际体验给还在开发或者准备开发这个项目的朋友分享几个后续可以继续深入的方向。第一是引入Redis缓存。目前我在系统里用了简单的数据库查询存储余额和用户信息演示阶段完全没问题。但如果在生产环境里学生用户频繁查询余额、刷卡消费接口频繁读取卡状态这些高频读操作完全可以借助Redis缓存减轻数据库压力。比如在消费接口中先查询Redis中的卡状态若状态正常再走数据库更新更新成功后同步刷新缓存能显著提升并发处理能力。第二是消息通知模块。一卡通系统中充值到账提醒、余额不足提醒、挂失成功提醒这些场景天然适合通过站内信或者短信通知触达学生。技术上可以通过SpringBoot的事件机制在充值、消费、挂失完成后发布一个事件监听器异步处理通知发送避免阻塞主业务流程。第三是引入更细粒度的权限模型。目前我用的是简单的角色判断RBAC模型已经能应对绝大多数场景。但如果你想往更企业级的方向做可以考虑引入Spring Security框架配合自定义注解PreAuthorize做方法级的权限校验权限控制的力度会更细、更规范。第四是数据可视化大屏。如果项目用于毕业设计展示可以基于ECharts增加一个可视化大屏页面展示全系统的实时消费总额分布、各食堂客流趋势、充值金额走势等。这既提升了项目的整体调性也充分展示了你在数据处理和前端交互方面的能力。实测下来这套系统跑通整个流程非常顺手。最后再分享一个写代码之外的小技巧规模稍大的项目建议从一开始就养成每次写完一个完整功能就顺手提交一次Git的习惯提交信息写清楚本次改动的内容。这样哪天不小心改坏了代码可以轻松回退到上一个稳定版本而不需要在整个大版本之间来回翻找能省下大量时间。祝愿你也能顺利把这个系统从想法变成现实如果过程中遇到了什么奇怪的问题欢迎一起交流。