多彩编程 多彩编程MZPH · CODE BLOG
ARTICLE DETAIL

文章详情

深耕前端与后端开发技术的一线实战笔记与踩坑复盘。

SpringBoot+Vue校园一卡通系统实战:从数据库设计到部署

SpringBoot+Vue校园一卡通系统实战:从数据库设计到部署 很多人做管理系统第一个想到的成熟命题就是校园一卡通。原因很简单它麻雀虽小五脏俱全把用户体系、账户体系、交易流水、权限控制、报表统计全都串起来了而且业务规则贴近现实生活评审一眼就能看懂你想表达什么。这篇东西我按SpringBootVueMyBatisMySQL这套组合来拆讲清楚一卡通系统到底该怎么建、关键模块怎么设计、哪些坑我实际踩过以及怎么把这套代码部署成真正能用的企业级项目而不是停留在能启动就行的演示阶段。如果你正准备拿它做毕业设计、课程设计或者刚入职想找一个完整业务逻辑参考这篇都很适合按顺序读一遍后面我会把建表思路、扣款并发处理、前端权限控制这些平时文档里不会细讲的部分全部展开。1. 一卡通系统到底在管什么业务场景与功能边界1.1 从一张卡到一个账户模型校园一卡通表面上管的是那张卡本质上管的是人和资金的绑定关系。学生拿卡去食堂打饭去超市买水去图书馆交逾期费刷卡的一瞬间系统在做的事情是验证持卡人身份、检查卡片状态、核对余额是否充足、冻结对应金额、记录交易流水、更新账户余额。这一串动作在数据库里就是几条SQL的事但要在高并发下保证不扣错、不漏记就需要好好设计。业务边界我建议卡死在三块身份认证、资金账户、交易流水。其余像是水电控、门禁对接都属于外部系统联动的范围主系统只要提供标准的余额查询和扣费接口就够了不要把所有设备协议都放进主工程里否则后期维护会很痛苦。1.2 角色划分与权限矩阵管理员、财务、操作员、学生这四类角色基本覆盖了一卡通系统的主要使用者。角色不同能看到的菜单和数据范围也完全不同比如财务能看全量流水和退费审核操作员只能处理本窗口的充值补卡学生只能查看自己账户的消费明细和挂失操作。权限设计建议抛弃硬编码角色判断走RBAC模型。数据库里建角色表和权限表再用关联表把用户—角色—权限串起来。后端接口用一个自定义注解校验权限码前端路由根据登录后返回的权限列表动态生成菜单。这套东西早期多花半天时间建模后期加新功能会非常舒服。1.3 核心业务流程图解虽然不方便画图但可以用文字把主流程捋一遍学生开户录入身份信息→分配账户→制卡发卡→ 充值现金/线上支付入账→ 刷卡消费扣减余额→生成交易流水→ 异常处理挂失→冻结卡片→补办新卡→转移余额→ 期末结算对账报表→余额退还我见过很多人做系统把精力全花在CRUD界面上结果真正难的部分——挂失补卡后账户余额怎么安全转移、当天流水怎么对账——反而一笔带过。这其实是面试和答辩最喜欢追问的点后面我会重点讲。2. 技术选型拆解SpringBootVueMyBatis为什么够用且好用2.1 后端SpringBoot解决的是约定问题SpringBoot最大的贡献不是快而是把Spring家族里面大量最佳实践打包成了默认约定。一卡通这种管理系统本质上就是一堆Controller接收请求、Service处理业务、Mapper访问数据库SpringBoot让你专注写这三层不用纠结复杂的XML配置和bean装配。我在实际项目中用的是SpringBoot 2.7.x搭配JDK8到JDK11都可以稳定跑。如果团队比较新也可以用SpringBoot 3.x但要注意3.x强制要求JDK17MyBatis相关starter也要换成支持Jakarta命名空间的版本。本篇标题写的是常见组合所以下面我按2.7来讲这套在多数高校现有服务器环境里兼容性最好。2.2 前端Vue负责把状态管起来一卡通管理后台有大量数据联动场景选择学号后自动带出姓名、切换日期范围后刷新表格、点击卡片状态时弹窗修改。Vue响应式的数据绑定让这些交互开发量大幅下降比传统jspajax拼接字符串的方式维护起来舒服太多。我推荐用Vue 2.7或Vue 3 Element UI/Ant Design Vue 组件库。Element的表格、表单、分页、弹窗都是直接用就行跟后端接口一拼就是完整页面。组件库里表格的customRender插槽做状态标签渲染非常方便值得熟练。2.3 持久层万能的MyBatis还是要配MyBatis-PlusMyBatis本身是个半自动化ORMSQL由你掌控这是它最大的优点。一卡通业务里有大量复杂的多表联查、条件统计手写SQL反而心里踏实。但纯粹用MyBatis写单表增删改查又太啰嗦所以我很推荐引入MyBatis-Plus它提供通用的BaseMapper和LambdaQueryWrapper单表操作一行代码都不用写XML复杂统计再走自定义XML。标题里只写了MyBatis这没问题MyBatis-Plus只是增强插件依赖包和底层逻辑仍然是MyBatis。如果不想引入额外依赖也可以用tk.mybatis这类通用Mapper但体验不如Plus。2.4 数据库MySQL在什么量级下会出问题MySQL应对一卡通这种规模非常成熟。一个在校生两万人的高校日交易流水量大概在十万到几十万笔这量级对MySQL来说毫无压力。真正要关注的不是选型而是建表和查询习惯。什么时候该换库当流水表超过几千万行、并且需要频繁跨年度统计的时候你可以考虑分表或者引入其他存储做冷热分离。但对大多数项目MySQL合理的索引定时归档就足够了。3. 数据库设计一卡通系统的地基如何打牢3.1 核心表模型与关系一卡通数据库建议至少包含下面这几张核心表表名职责关键字段sys_user用户表包含学生、教职工、管理员id, user_no, name, user_type, statuscard_info卡片表id, card_no, user_id, balance, status, create_timeaccount_flow资金流水表id, card_id, flow_type, amount, balance_after, trade_timecard_transaction消费交易表id, card_id, merchant_id, amount, status, trade_timemerchant_info商户/窗口表id, merchant_name, settlement_cyclesys_role角色表id, role_code, role_namesys_permission权限表id, perm_code, perm_namesys_user_role用户角色关联表id, user_id, role_idsys_role_permission角色权限关联表id, role_id, permission_id关于卡片表和用户表的关系我建议一张用户表对应多张卡片记录一张卡挂失用户可以补办新卡。这种场景用一对多建模更贴合现实每张卡片都独立保存余额补卡时新卡余额从旧卡迁移或直接归零由管理员操作。3.2 别用double存金额要命这几乎是所有新手必踩的坑。Java里的double和float是二进制浮点数0.1这种十进制小数存入后会有误差多个交易累加下来误差会越来越大。用户充了100块钱查余额显示99.99999999财务对账必然炸。所有涉及金额的字段一律使用DECIMAL(10,2)Java实体里用BigDecimal。MyBatis映射时不需要特殊处理JDBC驱动会自动完成转换。我在项目里习惯把金额精度控制在2位BigDecimal运算时统一用setScale(2, RoundingMode.HALF_UP)不管计算中间过程怎么复杂最终落库前都做一次统一舍入。3.3 流水表索引设计交易流水表是整个系统里数据增长最快、查询频率最高的表。学生查本周消费记录、财务查某商户月流水、管理员查某天全站交易量都绕不开这张表。索引设计建议主键索引必须要有这是MySQL默认的。其次trade_time单独建索引几乎必建。card_id和trade_time做联合索引支撑某张卡在某段时间的流水这种最频繁的查询。merchant_id trade_time联合索引支撑商户结算统计。有一种情况要小心如果你用了MyBatis-Plus的分页插件它会自动把count查询和limit查询分开但你自己的XML里如果写了带order by的复杂SQL务必确认order by的字段在索引里否则文件排序会让接口变慢。我在某次优化中把某个报表接口从2.3秒压到0.4秒就是靠合理联合索引去掉select *来的。3.4 初始化数据与菜单权限脚本拿到源码第一件事不要急着改代码先把init.sql里的建表和初始化脚本跑一遍。好的项目都会提供初始化管理员账号、默认角色、基础菜单权限数据。我强烈建议自己再补一段菜单权限表的数据把各角色的可见菜单明确分开这样登录后前端动态路由才会有效果不然所有账号看到全量菜单权限设计就形同虚设了。4. 后端核心实现认证、充值与消费的完整链路4.1 认证授权从Session到Token企业级项目里Session的痛点很明显服务端存储、集群环境需要共享 Session而且前后端分离后浏览器里存的Cookie还要跟着域名走。现在更通用的是JWT Token方案登录成功后后端生成一串签名Token返回给前端前端存储在本地通常用localStorage每次请求在请求头里带Authorization: Bearer token后端拦截器校验签名和有效期。SpringBoot里实现很简单定义一个HandlerInterceptor在preHandle里解析请求头Token把用户ID和角色信息放入ThreadLocal或Request Attribute里Service层就能直接取当前操作人。注意设置好Token的过期时间并且提供一个刷新机制避免用户正在操作时突然被踢下线。4.2 充值流程事务边界要清楚充值接口的完整动作是校验操作员权限→校验卡片状态→入账更新卡余额→写流水→返回新余额。这一串操作必须在同一个数据库事务里任何一步失败都要整体回滚否则就会出现流水没写但钱已到账的脏数据。Spring的Transactional注解用起来虽然方便但它默认只回滚RuntimeException。如果你在业务里手动catch了异常但没有往外抛事务是不会回滚的很多钱没扣但流水记了的问题就是这个原因。我的习惯是Service层不catch异常统一由全局RestControllerAdvice处理把异常转成统一结果集返回给前端。4.3 消费扣款并发场景下的安全措施刷卡的瞬间同一个卡可能同时发起两笔消费。如果代码这么写查余额→判断够不够→扣除→更新那么两个请求同时查到余额100都认为够扣80结果余额变成负数或出现脏数据。解决思路有几个层次。最简单的方案是乐观锁在卡片表加一个version字段更新时update card_info set balance balance - #{amount}, version version 1 where card_id #{cardId} and version #{oldVersion}受影响行数为0说明版本冲突提示用户稍后重试。这种方式代码简单不需要额外加锁一卡通场景下同一张卡并发消费的概率很低乐观锁完全够用。还可以用数据库层面的原子更新SQL直接写balance balance - #{amount}MySQL的行锁会保证同一行的更新串行执行。但要注意光这样还不够你还得提前查一次余额做判断判断和更新之间有间隙安全的做法是把余额判断并进更新条件update card_info set balance balance - #{amount} where card_id #{cardId} and balance #{amount}受影响行数为0说明余额不足或卡不存在。4.4 MyBatis动态SQL别把SQL拼在Java里一卡通系统查询条件往往不固定按卡号、按时间、按状态、按商户任意组合。这时候MyBatis的whereif标签就非常顺手。举个典型代码片段select idlistTransactions resultTypemap select * from account_flow where if testcardId ! null and card_id #{cardId} /if if testmerchantId ! null and merchant_id #{merchantId} /if if teststartTime ! null and trade_time gt; #{startTime} /if if testendTime ! null and trade_time lt; #{endTime} /if if testflowType ! null and flow_type #{flowType} /if /where order by trade_time desc /select注意两点一是在XML里小于号要用lt;否则解析报错二是where标签会自动去掉第一个多余的and比手写where 11更干净。分页不要自己写limit传offset直接用MyBatis-Plus的分页插件它会拦截SQL并自动生成count和limit。4.5 通用返回结果与统一异常处理我每次做项目都会先定一个统一的ResultT类字段至少包含code、message、data。接口一律返回Result.success(data)或Result.error(code, msg)。前端axios响应拦截器里判断code非200统一弹Message提示业务代码里就不用到处写try-catch了。统一异常处理类上加上RestControllerAdvice针对参数校验异常、业务异常、系统异常分别处理。业务异常自己定义一个BusinessException在Service里throw new BusinessException(余额不足)前端拿到这个message直接弹出排查问题效率提升不是一星半点。5. 前端Vue架构管理后台该有的组织方式5.1 目录结构与核心模块划分拿Vue项目为例合理的目录划分能让多人协作不打架src/ api/ # 每个后端模块的接口请求封装 assets/ # 静态资源 components/ # 公共组件上传、富文本、图表 layout/ # 主框架侧边栏、顶栏 router/ # 路由配置 store/ # 全局状态用户信息、权限、菜单 utils/ # 工具函数request封装、校验 views/ # 页面视图api目录按后端Controller模块划分文件比如src/api/card.js专门放卡片相关接口一个文件导出几个方法每个方法就是一个axios请求。不要把所有接口堆在一个文件里更不要在组件里直接写this.$http.get(...)维护时你会被自己坑到。5.2 权限与路由动态菜单的真实现登录成功后后端返回当前用户的角色code列表和权限code列表。前端拿这些信息做两件事一是把权限列表存到Vuex里二是根据路由表里每一条路由的meta.roles或meta.perms字段做过滤计算出当前用户可见的路由动态加到Router实例中。实现起来可以这么写// 路由表里标记需要哪些权限 { path: /card, component: Layout, meta: { title: 卡片管理, perms: [card:list] }, children: [...] } // 动态过滤 function filterRoutes(routes, permissions) { return routes.filter(route { if (route.meta route.meta.perms) { return permissions.some(p route.meta.perms.includes(p)) } return true }) }后端权限也要做校验前端隐藏菜单只是体验上的不能作为安全边界。任何修改类接口后端都必须再校验一次权限码前端绕过只是时间问题。5.3 axios封装拦截器里的大学问axios封装是我接手每个项目必看的第一个文件。合理的封装应该包含请求拦截器里自动附加Token、响应拦截器里统一处理HTTP错误码和业务code、请求超时设置、以及关闭loading的工具函数。几个容易忽略的点一是Token过期后要能跳转登录页并清理本地存储不要留在一个报错死循环里二是文件下载接口返回的是blob不能按json解析响应拦截器要做类型判断三是接口路径统一用import.meta.env.VITE_API_BASE_URL或process.env.VUE_APP_API_BASE_URL这类环境变量配置部署到不同环境时只改环境文件就好。5.4 数据可视化管理后台的加分项一卡通系统很适合加数据可视化页面食堂窗口流量对比、充值金额趋势、卡片异常状态统计。用ECharts可以轻松搞定前端封装一个ChartBox公共组件传入option更新配置组件内部负责初始化实例、监听窗口大小变化、销毁实例。我在实际项目里通常放两个图表页面一个总览Dashboard放充值趋势、消费排行、卡状态占比另一个商户对账图表按月度展示各商户应收实收对比。这类页面做好了无论是答辩展示还是领导汇报都是最直观的加分点。6. 实战避坑并发扣款、金额精度与跨域联调的完整排查链路6.1 并发扣款问题从踩坑到修复的完整过程某次压测中用测试工具模拟一张卡同时发起10次消费请求结果5次成功扣款后余额就扣成负数了。我首先检查的是Service代码果然是最经典的查余额再判断再更新三步走。当时线上的做法是给消费接口加synchronized关键字但压测一上就发现根本没有用——因为接口部署了多实例JVM锁只对单实例有效。这个问题的标准解法就是前面说的乐观锁或者原子更新。我把更新SQL改成了条件里带余额判断的方案同时卡片表加version字段做二次兜底。重新压测10个并发请求有9个返回失败或重试提示但余额始终不会变成负数也不会出现多笔超扣的流水。这个教训很直接凡是资金操作不要让判断和执行之间存在缝隙。6.2 BigDecimal精度问题的复现与规范典型场景是退款计算退货金额是8.1手续费率0.01退款到账金额算出来是8.01还是8.00取决于中间过程怎么舍入。如果中间过程用double运算结果可能变成8.009999999。我处理这种问题的规范是所有涉及金额的运算都通过BigDecimal除法必须指定精度和舍入模式禁止new BigDecimal(double)方式入参统一使用new BigDecimal(String)或BigDecimal.valueOf(double)。还有一个细节数据库表里金额字段是decimal(10,2)但Java实体里如果没写setter的转换逻辑MyBatis默认按数据库精度返回。只要Java侧也用BigDecimal和统一scale策略前后端展示基本不会出现精度乱掉的情况。前端显示时用toFixed(2)格式化存入时确保格式校验通过即可。6.3 前后端联调跨域、代理与接口地址开发环境最常见的报错是CORS跨域。后端在SpringBoot里写一个CorsFilter配置允许前端域名访问或者简单点在Vue的vue.config.js里配devServer代理让前端的/api请求转发到后端的http://localhost:8080这样浏览器看到的是同源请求跨域问题直接消失。生产环境下跨域通常靠Nginx反向代理解决。前端打包后的dist目录由Nginx托管Nginx配置将/api前缀的请求转发到后端服务端口。这样前后端在外界看来就是同一个域名不存在跨域问题也顺带解决了Cookie和Token共享的麻烦。6.4 会话失效与重复提交一卡通管理后台还有一个特别容易忽略的问题用户连续点击保存按钮导致重复提交产生重复流水。解决方案有两种一是前端按钮在提交后置灰并显示loading二是后端给提交接口加幂等性控制。幂等性最简单做法是前端生成一个requestId后端在Redis里判断这个requestId是否处理过没处理过就执行并写入处理过就直接返回成功。我在项目里偏向于前后端双保险前端按钮loading做到位后端对充值、退款这类资金接口再接一层requestId幂等。管理后台虽然并发不高但财务人员或管理员手误连点两次的概率却不低多这一层能避免很多尴尬的对账问题。7. 部署落地从开发机到服务器的关键几步7.1 环境准备与版本对齐部署前先列出环境清单JDK版本、Maven版本、Node版本、MySQL版本。我碰到过的最坑情况是本地用MySQL 8跑得好好的服务器上是MySQL 5.7结果某个日期函数行为不一致导致查询结果不同。后来我养成了习惯项目里pom.xml锁定MySQL驱动版本application.yml里显式指定数据库连接参数和时区并且开发、测试、生产三套配置文件分开避免本地能跑、部署就挂的局面。7.2 构建后端与前端后端构建就两步mvn clean package -DskipTests然后得到一个xxx.jar。放到服务器上执行nohup java -jar xxx.jar --spring.profiles.activeprod 。建议生产环境用systemd服务管理写一个service文件这样开机自启、崩溃自动拉起都更好处理。前端构建是npm run build生成dist目录。dist里的文件全部扔到Nginx的/usr/share/nginx/html下即可。构建之前要注意环境变量NODE_ENVproduction时axios的baseURL要指向Nginx里的后端代理路径。7.3 Nginx配置示例一个典型的配置长这样server { listen 80; server_name your_domain; # 前端静态资源 location / { root /usr/share/nginx/html/dist; index index.html; try_files $uri $uri/ /index.html; } # 后端接口反向代理 location /api/ { proxy_pass http://127.0.0.1:8080/api/; proxy_set_header Host $host; proxy_set_header X-Real-IP $remote_addr; proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; } }try_files那行很关键Vue是单页应用刷新某个路由时Nginx要找对应的html文件找不到就返回router入口否则一刷新就是404。第一次部署时我吃过这个亏后来查资料才想起要加这一句。7.4 初始化数据与权限校验部署完成后第一件事不是演示页面而是做一遍全流程验证用管理员账号登录、创建角色、给角色分配权限、创建新用户、给用户绑卡、充值、消费、查询流水、挂失、补卡、退费。任何一步异常都说明某处配置没到位。我见过很多部署成功但功能全挂的场景最后发现是数据库初始化脚本没跑全或者Nginx没重启导致前端还是旧缓存。最后再分享一个实际体会这类一卡通项目真正决定好坏的地方从来不是某个炫酷的页面组件而是账户模型是否严谨、流水记录是否完整、并发扣款是否安全、权限控制是否闭环。如果你照着这套思路做哪怕代码是自己一行行敲的答辩或演示时别人问起你怎么保证并发下钱不会扣错你可以很踏实地把乐观锁的SQL当场写出来这就是这套系统最大的底气。当然在动手之前先把业务角色想清楚再把表建好代码只是个把设计落地的过程而已。
返回列表