简介:一套基于SpringBoot+Vue前后端分离架构的一卡通消费系统源码,面向Java Web方向毕设与课设学生,覆盖人脸识别、刷码、实体卡等常见校园消费场景。压缩包共748个文件,包含380个Java后端逻辑、92个Vue页面组件、82个JS交互脚本、55个XML配置及少量YML/Properties环境文件,整体仅1.67MB,目录结构清晰,便于快速定位前后端代码。当前已有88人学习浏览,属于中等偏基础难度,源码经本地编译验证,下载后按文档配置环境即可运行。压缩包还提供run.bat、package.bat等启动与打包脚本,适合快速部署体验;借助该资源可系统理解SpringBoot接口开发、Vue前端联调、MyBatis数据库映射等典型环节,也可参照其分层方式改造扩展,作为毕业设计或课程设计的完整参考。 校园里排队打饭的场景大家都经历过:有人掏实体卡,有人亮付款码,还有人直接刷脸。如果把这些方式整合到一套系统里,后台还能实时看到每笔消费记录,这就是今天要聊的项目——基于SpringBoot + Vue前后端分离架构的一卡通消费系统。它同时支持人脸识别、二维码刷码、实体IC卡三种消费方式,覆盖了目前校园、企业园区、食堂等场景下的绝大多数支付需求。
这套系统从技术选型上走的是目前Java全栈最主流的路线:后端SpringBoot负责业务逻辑和接口,前端Vue负责页面交互,前后端通过RESTful API通信。对于正在做毕业设计、或者刚入职想了解企业级项目怎么搭的同学来说,这是一个非常完整的参考案例。它不只是简单的增删改查,而是把硬件设备接入、第三方SDK集成、高并发扣款、账户流水记录这些真实业务中都跑一遍。
1. 项目整体设计与架构思路
1.1 为什么选择前后端分离架构
在动手写代码之前,第一个要回答的问题是:为什么要用前后端分离,而不是传统的服务端渲染模板方案?
一卡通消费系统和普通的后台管理系统不太一样。它的使用场景分为两类:一类是消费终端(食堂窗口的扫码盒子、人脸识别闸机、刷卡POS机),另一类是管理后台(充值、对账、用户管理、消费记录查询)。终端设备需要的是轻量级、响应快的交互界面,而管理后台需要的是复杂的数据展示和操作。如果用Thymeleaf这类服务端渲染方案,每台终端设备都要依赖后端渲染页面,服务器压力大,页面交互也不够流畅。
前后端分离之后,前端静态资源可以部署到Nginx上,后端只提供JSON接口。消费终端通过HTTP请求调用后端接口,不管是扫码枪、人脸识别终端还是普通浏览器,都可以轻松接入。而且前端Vue的组件化开发方式特别适合这种多终端的场景,公共组件如支付弹窗、用户信息展示可以反复复用,开发效率提升明显。
1.2 三种消费方式的场景定位
支持人脸、刷码、实体卡三种方式,不是简单的功能堆砌,而是基于真实场景需求设计的。
实体IC卡是最传统的方案,优点是离线可用、识别速度快,适合食堂高峰期快速扣款。二维码是近几年的主流,用户手机亮码,终端扫描后完成支付,优点是不用带卡,缺点是需要网络支持。人脸识别最方便,用户什么都不用带,走到摄像头前就能完成支付,但对硬件要求高,还需要处理光线、角度等图像质量的问题。
三种方式互补,覆盖了不同用户群体的使用习惯:学生喜欢刷码,教职工习惯刷卡,而人脸识别可以作为忘记带卡和手机时的兜底方案。在设计数据库时,这三种方式统一映射到同一个用户账户上,也就是说无论你用哪种方式消费,扣的都是同一个账户里的钱。
2. 后端SpringBoot核心模块设计
2.1 系统分层与包结构规划
后端采用标准的Controller-Service-Mapper三层架构,包结构如下:
com.campus.card ├── controller # 接口层 ├── service # 业务逻辑层 ├── mapper # 数据访问层 ├── entity # 实体类 ├── dto # 数据传输对象 ├── config # 配置类 ├── common # 通用工具和常量 └── handler # 全局异常处理在实际编码时,我建议把用于接收前端请求的参数对象和数据库实体类分开,避免前端参数结构变化时影响数据库实体。比如消费接口接收的参数包括消费方式、终端编号、消费金额、识别凭证,这些字段需要组合成一个ConsumeRequestDTO,而不是直接操作User实体。
2.2 三种消费方式的统一鉴权流程
无论哪种消费方式,核心流程都是三步:识别身份、校验账户、扣款记账。区别仅仅在于“识别身份”这一步怎么实现。
我把身份识别抽象成了一个接口,三种消费方式分别实现:
public interface IdentityResolver { // 返回用户在系统中的唯一ID Long resolveIdentity(String credential); } @Component public class CardIdentityResolver implements IdentityResolver { @Override public Long resolveIdentity(String credential) { // 实体IC卡刷卡的卡号,直接从卡芯片读取 return cardMapper.getUserIdByCardNo(credential); } } @Component public class QrCodeIdentityResolver implements IdentityResolver { @Override public Long resolveIdentity(String credential) { // 二维码的内容是一串动态令牌,需要解密和校验有效期 String userId = qrCodeService.parseAndVerify(credential); return Long.valueOf(userId); } } @Component public class FaceIdentityResolver implements IdentityResolver { @Override public Long resolveIdentity(String credential) { // credential是前端人脸识别SDK返回的用户特征值或用户ID // 可信终端直接传入userId,非可信终端需要调用人脸比对服务 return faceRecognitionService.verify(credential); } }这样设计的好处是,当需要新增一种消费方式时(比如以后加指纹支付),只需要新增一个IdentityResolver的实现类,不动原有代码,符合开闭原则。
2.3 扣款与流水记录的一致性保障
消费扣款是整个系统的核心,也是最容易出现Bug的地方。很多人一开始容易犯的错误是:先查余额,余额够就扣款,然后插入流水。这段逻辑在并发现场一定会出问题。
想象一下这个场景:用户在1秒内连续刷了两次码,两个请求同时到了后端,同时读到余额是5块,消费金额都是3块,两次都判断余额够,都执行了扣款,最后余额变成了-1块。
解决这个问题有几种方案。第一种是在扣款SQL语句中加入余额条件:
UPDATE user_account SET balance = balance - #{amount} WHERE user_id = #{userId} AND balance >= #{amount}如果影响行数为0,说明余额不足或账户异常,返回扣款失败。这种方式在单库场景下已经足够,不需要引入分布式锁的复杂度。
第二种是使用数据库行锁,在扣款前先通过SELECT ... FOR UPDATE锁定账户行,确保同一时间只有一个请求在操作该账户。两种方式可以结合使用,我自己的实践中是在扣款SQL加余额条件的基础上,用Redis做一个简单的用户维度锁来增加一层防护。
每笔消费都必须生成流水记录,流水表结构如下:
| 字段名 | 类型 | 说明 |
|---|---|---|
| id | bigint | 主键 |
| user_id | bigint | 用户ID |
| order_no | varchar(64) | 订单号(唯一) |
| amount | decimal(10,2) | 消费金额 |
| balance_after | decimal(10,2) | 消费后余额 |
| consume_type | tinyint | 1-实体卡 2-扫码 3-人脸 |
| terminal_no | varchar(32) | 终端编号 |
| create_time | datetime | 消费时间 |
订单号的生成建议使用“日期+终端编号+随机数”的方式,避免使用数据库自增ID作为订单号暴露业务量。
3. 前端Vue实现要点
3.1 项目初始化和环境配置
前端使用Vue CLI创建项目,Node版本建议使用16以上,npm镜像切换到国内源。创建项目的命令很简单:
vue create card-frontend在交互式配置中选择Vue Router和Vuex(或Pinia)作为插件。这里有一个实操建议:如果你的团队成员对Vue 2比较熟悉,就选Vue 2;如果是新项目且有时间学习,直接上Vue 3 + Composition API + Pinia。就我目前的经验来看,Vue 3的组合式API在代码组织和逻辑复用上确实比Vue 2的选项式API更清晰,尤其是消费终端这种需要频繁操作状态的场景。
开发环境的API代理配置是前后端分离项目中必须处理的环节。在vue.config.js中配置devServer代理:
module.exports = { devServer: { proxy: { '/api': { target: 'http://localhost:8080', changeOrigin: true, pathRewrite: { '^/api': '' } } } } }配置后前端请求/api/user/info会被转发到http://localhost:8080/user/info,解决了开发环境的跨域问题。生产环境则用Nginx来做反向代理,前端静态资源交给Nginx托管,/api路径转发到后端服务。
3.2 核心页面和组件拆解
整个系统的前端页面分为三个部分:用户端消费页、管理后台页面、终端设备适配页。
用户端消费页是用户扫码后看到的确认页面,主要包括用户信息、余额展示、消费金额输入。这个页面不是重点,重点在管理后台。
管理后台的页面结构是经典的侧边栏+顶栏布局:左侧菜单包括用户管理、账户管理、消费记录、终端管理、统计报表、系统设置。每个菜单对应的页面组件放在view目录下,公共组件放在components目录下。
这里要说一下组件封装的心得。消费记录查询页有一个日期范围选择器,用户管理和账户管理页面也有类似的筛选条件。如果每个地方都写一遍筛选逻辑,代码会非常冗余。我封装了一个PageWrapper组件,统一处理分页、搜索条件、刷新操作,各页面只需要传入自己的表格列配置和查询接口即可。封装完以后新增一个管理页面的工作量从半天缩短到两小时。
3.3 路由权限与登录状态管理
后台管理系统必须有登录拦截和权限控制。在Vue Router的路由配置中,给需要认证的页面添加meta: { requiresAuth: true }标记,然后在路由守卫中统一校验:
router.beforeEach((to, from, next) => { const token = localStorage.getItem('token') if (to.meta.requiresAuth && !token) { next({ path: '/login' }) } else if (to.path === '/login' && token) { next({ path: '/' }) } else { next() } })Axios请求需要统一拦截器,在请求头中携带Token,在响应中处理Token过期和401状态码。如果后端返回401,说明Token已经失效,需要清除本地登录状态并跳转到登录页面。
登录后的用户信息建议放在Vuex(或Pinia)中,避免每个页面都去LocalStorage读取。需要注意的是,LocalStorage只能存不敏感的信息,用户的手机号、余额等敏感数据必须通过接口获取,不能缓存在前端本地。
4. 三种识别方式的技术实现与选型
4.1 实体IC卡的接入方案
实体IC卡使用的是M1卡或CPU卡,读卡器通过串口或USB接口连接终端设备。后端需要实现的接口是接收读卡器上传的卡号。读卡器通常使用韦根协议或串口协议,底层通信不需要后端直接处理,而是由终端设备(如POS机)读取卡号后调用后端接口。
后端的核心逻辑是维护card表,表中保存卡号和用户ID的绑定关系。卡片还可以设置状态:正常、挂失、注销。挂失的实现很简单,就是在卡表中把状态改为挂失,消费时校验状态即可。
这里要注意一个安全细节:读卡器上传的卡号必须做校验,不能直接信任终端设备传来的数据。典型的做法是终端设备需要向后端申请一个临时Token,然后使用这个Token调用消费接口。也就是说,终端设备在请求头中携带自身设备的Token,后端再去判断这个终端编号是否有权限发起扣款。
4.2 二维码刷码的实现逻辑
二维码消费有两种模式:用户被扫(用户出示二维码,终端扫描)和用户主扫(用户扫终端上的二维码)。一卡通消费系统中常用的是被扫模式,用户打开手机APP或小程序展示一个二维码,终端扫码后上传二维码内容到后端。
二维码的内容不能是简单的用户ID明文,否则二维码被别人拍到后,任何人都能拿着去消费。正确做法是生成一个动态令牌,有效期为60秒或者使用一次即失效:
- 用户点击“生成付款码”时,前端调用后端接口获取token。
- 后端生成一个随机字符串token,将token和用户ID的映射关系存储到Redis中,并设置过期时间为60秒。
- 终端扫码后将token上传到后端。
- 后端从Redis中查询token对应的用户ID,如果不存在,判定为无效码。
用Redis存储token的好处是天然支持过期时间,符合二维码“短时有效”的语义。在并发扣款时,还可以利用Redis的原子性操作确保同一个token只能被使用一次,避免重复扣款。
4.3 人脸识别接入的两种路径
人脸识别是整个系统中最具技术含量的部分,也是踩坑最多的地方。市面上人脸识别的方案主要分为两种:
第一种是离线的SDK方案。终端设备集成人脸识别SDK(如百度离线 SDK、虹软ArcFace),在设备本地完成人脸检测和特征提取,人脸特征值存到后台。消费时,终端摄像头采集人脸,SDK提取特征值并和本地缓存的特征值比对,比对成功后将用户ID上传到后端完成扣款。这种方案的优点是快、不依赖外部网络,缺点是SDK需要购买授权,终端成本高。
第二种是在线API方案。终端设备把人脸图片上传到后端,后端调用云服务商的人脸识别API完成比对。优点是终端硬件要求低,缺点是每笔消费都会产生API调用费用,而且网络延迟会直接影响消费体验,高峰期可能会出现排队等情况。
综合考虑成本、识别速度和离线容错能力,我建议采用离线SDK方案。实际项目中选择哪家SDK,要看设备的算力大小和采购预算。识别阈值参数也非常讲究,阈值设得太高识别率低,用户经常要重复刷脸;设得太低则有可能认错人,造成资金风险。一般建议初始值设为0.7左右,然后根据实际场景微调。光线较暗的窗口需要降低阈值,户外场景则要提高阈值防止误识别。
5. 项目实操过程与核心环节实现
5.1 后端环境搭建过程
后端开发环境是JDK 1.8或11 + MySQL 5.7或8.0 + Redis。使用IDEA创建SpringBoot项目时,有时会遇到下载依赖超时的问题,这通常是因为Maven中央仓库连接不稳定。解决方法是在Maven的settings.xml中配置阿里云镜像:
<mirror> <id>aliyun</id> <mirrorOf>central</mirrorOf> <url>https://maven.aliyun.com/repository/central</url> </mirror>配置文件application.yml中包含数据源、Redis、MyBatis映射路径等核心配置:
server: port: 8080 spring: datasource: driver-class-name: com.mysql.cj.jdbc.Driver url: jdbc:mysql://localhost:3306/card_system?useUnicode=true&characterEncoding=utf8&useSSL=false&serverTimezone=Asia/Shanghai username: root password: 123456 redis: host: localhost port: 6379 mybatis: mapper-locations: classpath:mapper/*.xml type-aliases-package: com.campus.card.entity数据库的初始化脚本包含用户表、账户表、卡片表、消费流水表、终端表、管理员表等核心表结构。在实际开发中,我习惯用数据库迁移工具(如Flyway)来管理表结构的变更,团队成员拿到项目后执行一条命令就能建库建表,效率比传SQL文件高很多。
5.2 消费接口的完整业务流程
一个完整的消费请求链路是这样的:终端设备请求后端消费接口,接口接收请求参数(消费方式、终端编号、识别凭证、金额),进行身份识别、状态校验、锁账户、扣款、记流水、响应结果。
以下是简化后的消费接口核心代码,展示了关键的流程控制逻辑:
@PostMapping("/consume") public ResultVo consume(@RequestBody ConsumeRequestDTO request) { // 1. 校验终端是否有权限发起消费 Terminal terminal = terminalMapper.selectByTerminalNo(request.getTerminalNo()); if (terminal == null || terminal.getStatus() != 1) { return ResultVo.error("终端不合法或已停用"); } // 2. 根据消费方式解析用户身份 IdentityResolver resolver = resolverFactory.getResolver(request.getConsumeType()); Long userId = resolver.resolveIdentity(request.getCredential()); if (userId == null) { return ResultVo.error("身份识别失败"); } // 3. 加锁,防止并发扣款 String lockKey = "consume:lock:" + userId; boolean locked = redisTemplate.opsForValue().setIfAbsent(lockKey, "1", 3, TimeUnit.SECONDS); if (!locked) { return ResultVo.error("操作频繁,请稍后重试"); } try { // 4. 扣款(SQL中带余额条件,确保金额正确) int rows = accountMapper.deductBalance(userId, request.getAmount()); if (rows == 0) { return ResultVo.error("余额不足"); } // 5. 生成消费流水 BigDecimal balanceAfter = accountMapper.selectBalance(userId); consumeRecordMapper.insert(userId, generateOrderNo(), request.getAmount(), balanceAfter, request.getConsumeType(), request.getTerminalNo()); return ResultVo.success(orderNo); } finally { redisTemplate.delete(lockKey); } }这里的几个细节值得展开来说。先校验终端再识别身份,避免无效请求打到识别模块;Redis锁的超时时间设置为3秒,识别和扣款的整体耗时不会超过这个值;扣款SQL带余额条件,是防止超扣的最后一道防线;流水插入放在扣款成功之后,确保账实相符。
5.3 前端页面的实现与接口联调
前端开发时,我习惯按照“登录页 → 布局框架 → 首页仪表盘 → 用户管理 → 消费记录 → 终端管理”的顺序来推进。登录页用Element UI的表单组件,校验规则用async-validator,提交后调用后端登录接口获取Token,并将用户信息存入Pinia。
管理后台的首页仪表盘展示今日消费总额、消费笔数、活跃终端数、账户总数等统计数据。这些数据的计算如果每次实时查数据库,会对数据库造成比较大的压力。我的方案是使用Redis的HyperLogLog统计每日活跃用户数,消费总额则通过定时任务每小时汇总一次存入统计表,页面展示时直接查统计表即可。
接口联调阶段最常遇到的问题就是跨域和字段命名不一致。后端返回的字段用的是驼峰命名(如balanceAfter),如果后端接口的JSON序列化配置没有开启驼峰映射,前端就会拿不到数据。在SpringBoot的application.yml中加入以下配置解决:
spring: jackson: property-naming-strategy: SNAKE_CASE这样后端返回的字段名会自动转为下划线风格(如balance_after),前端代码中统一使用下划线风格访问即可。必须保证前后端开发时先约定好接口文档,我用的是YApi或者Apifox,接口定义确认后再开发,能减少后期联调的大量返工。
6. 常见问题与排查技巧实录
6.1 前端接口请求跨域报错
前后端分离开发中,跨域是最常见的问题。浏览器控制台报Access-Control-Allow-Origin错误时,不要一上来就在后端加@CrossOrigin注解,先分清是开发环境还是生产环境的问题。
开发环境的跨域用代理解决,vue.config.js中配置devServer的proxy即可。但这里有一个容易踩的坑:如果你的页面是通过localhost:8081访问前端,API请求转发到的是http://localhost:8080,那后端的日志中看到的访问来源还是前端服务,因为代理转发是服务端行为,不涉及浏览器跨域。
生产环境的跨域通过Nginx配置解决:
server { listen 80; server_name card.example.com; 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; } }其中try_files配置是为了解决Vue Router的history模式刷新后404的问题,proxy_pass末尾的斜杠表示将/api前缀替换为空。
6.2 扫码消费提示“无效二维码”
这个问题的排查路径比较固定。先看前端是否正常请求了生成二维码接口,再看后端Redis中是否存在对应的token,最后看消费时上传的token和生成的是否一致。
我遇到过一种比较隐蔽的情况:用户同时打开了两个页面,每个页面都生成了一个二维码,但用户第一次展示的是旧页面的二维码,这个token在Redis中已经过期或不存在。排查时发现二维码内容里包含了时间戳,前端在展示二维码时还可以加一个倒计时条,提醒用户二维码即将失效。
还有一种情况是终端扫码到上传之间的网络延迟太长,超过了token的有效期。解决方法是把token有效期从60秒调整为120秒,同时增加一个“窗帘拉取”机制:用户在进入支付页时预生成一个二维码,当二维码即将过期时自动刷新。
6.3 人脸识别在光线不足时识别率下降
这是人脸识别场景中最常见的用户体验问题。光线不足、背光环境下,摄像头采集到的人脸图像质量差,SDK提取的特征值不可靠,导致识别失败率明显上升。
我的解决思路是硬件和软件结合。硬件方面,在人脸识别终端上加装补光灯,尤其是在就餐高峰期和大堂入口处。软件方面,将SDK的图像质量检测功能开启,如果检测到图像过暗,返回一个提示信息给用户,而不是直接进入比对流程。
另外,人脸特征的注册质量也非常关键。很多项目在注册人脸时只是让用户随意拍一张,这样后期识别率很难保证。合理做法是指引用户正对摄像头、保持自然表情、移除眼镜和帽子,连续采集多帧图像,从中选一张质量最高的作为注册底图。实测下来,这样处理后的注册照片能显著提高后续识别的通过率。
6.4 高峰期消费接口响应变慢
一卡通消费系统的高峰期非常集中:食堂的午餐时段,可能全校几千人同时集中在40分钟内消费。这时候最容易成为瓶颈的就是数据库的账户表和流水表。
除了前面提到的Redis锁,数据库层面还有几个优化手段。第一个是给流水表按日期分表,每天一张表(例如consume_record_20250101),查询历史流水时按日期定位到对应分表,避免单表数据量过大。第二个是在流水表的查询字段(user_id、create_time)上建立联合索引,让“查某用户某天的消费记录”这类高频查询走索引。第三个是连接池参数的调整,MySQL默认的max_connections是100,高峰期很容易打满,可以调整为500,但也要注意不要盲目调太高,否则数据库本身可能会被压垮。
7. 部署要点与压测心得
项目开发完成后,部署上线是最后一个环节。整套系统的部署架构是:前端静态文件部署到Nginx,后端SpringBoot打成Jar包部署在服务器,数据库用MySQL,缓存用Redis。
生产环境的配置要和开发环境隔离。SpringBoot项目中创建application-prod.yml配置文件,用spring.profiles.active=prod指定生产配置。密码等敏感信息不要明文写在配置文件中,可以使用Jasypt加密,配置文件中保存密文,启动时通过特定的密钥解密。
上线前要做一波简单的压力测试。用JMeter模拟高并发消费请求,重点观察接口的TPS、响应时间P99、数据库连接池使用情况。我这边实测下来的数据,4核8G的单机部署,加上Redis缓存和数据库连接池优化,消费接口的TPS大约能达到600左右,如果以后用户量增长,可以通过增加后端实例配合Nginx负载均衡来水平扩容。
最后再说一个实操中的小细节:系统的定时任务(比如每日账单结算、过期二维码清理)用SpringBoot自带的@Scheduled注解就可以实现。别一上来就引入xxl-job这类分布式任务调度框架,单机场景下标准JDK就能很好地完成,架构过度设计反而增加了维护成本。等真正到了需要多节点部署,且定时任务又存在重复执行问题时,再迁移到分布式调度不迟。
本文还有配套的精品资源,点击获取