简介:基于Spring Boot与Vue前后端分离架构的一卡通消费系统,面向需要完成毕业设计或课程设计的学生,也可供初中级开发者学习前后端联合开发。系统聚焦校园或园区消费场景,同时支持人脸识别、扫码支付和实体卡刷卡三种支付方式,后端采用Java、Servlet等技术,结合MySQL存储数据,较好地展示了真实业务系统的模块划分与接口设计。压缩包共748个文件,大小仅1.67MB,源码主体由380个Java文件、92个Vue组件、82个JavaScript脚本、83个SVG图标与55个XML配置构成,能清晰区分后端逻辑、前端页面、视觉资源与系统配置;少量批处理脚本和环境配置文件则便于读者快速启动和构建工程。目前已有90人学习/下载。项目源码均经过本地编译验证,下载后按照说明文档配置环境即可正常使用;代码难度适中,经助教审定,适合用来完成课程作业、毕业设计,也能帮助初学者理解前后端分离项目从开发到打包的完整流程。
1. 一卡通消费系统:不是简单的增删改查,是三种身份认证的收敛点
做一卡通消费系统,最容易被低估的是“消费”这两个字。实体卡、二维码、人脸三种方式,背后对应的是三套完全不同的身份读取链路:读卡器读的是扇区数据,手机刷的是动态令牌,摄像头给的是特征比对结果。Spring Boot 后端和 Vue 前端要做的,是把这三条链路统一收敛到一个支付动作上,保证账实相符。
这套基于 springboot + vue 前后端分离架构的一卡通消费系统,解决的就是这个收敛问题。它适合两类人:一是学校、园区、企业内部想落地一卡通消费场景的后端开发,二是准备拿完整项目做二次开发的 Java 工程师。后端是 Spring Boot 单体应用,前端是 Vue 单页应用,数据库层包含账户、流水、消费记录等核心表,人脸、刷码、实体卡三种消费方式走的是同一套扣款链路。接下来从架构开始拆,再到三种消费方式的实现、前端调起方式、部署避坑,最后给一套并发验证方法。
2. 前后端分离与数据库设计:先把模块边界和账务表定死
2.1 前后端分离的模块边界,别把 Vue 的 router 和后端 Controller 混为一谈
前后端分离的一卡通系统,最容易在“接口归属”上出问题。有人把页面的路由跳转逻辑写进后端,也有人把后端鉴权逻辑抄进 Vue 的 router.beforeEach,结果两边各管一段,排查问题时互相甩锅。
这套项目里,前端只负责三件事:页面渲染、路由守卫、Token 持有。后端只负责四件事:身份认证、账户余额操作、流水记录、对账查询。前端通过 axios 携带 JWT Token 调用后端 REST 接口,后端通过拦截器校验 Token,再根据注解判断接口需要的角色权限。
// 后端拦截器核心逻辑,Spring Boot 中实现 HandlerInterceptor public class JwtInterceptor implements HandlerInterceptor { @Override public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) { // 放行登录接口和回调接口,避免死循环 String uri = request.getRequestURI(); if (uri.contains("/auth/login") || uri.contains("/face/callback")) { return true; } // 从请求头获取 Token,前端 axios 拦截器统一添加 String token = request.getHeader("Authorization"); if (token == null || !token.startsWith("Bearer ")) { throw new BusinessException(401, "未登录或Token缺失"); } // 解析 JWT,把 userId 存入 request attribute 供后续使用 Claims claims = JwtUtil.parse(token.substring(7)); request.setAttribute("userId", claims.get("userId")); return true; } }这段逻辑的关键在于放行条件。登录接口必须放行,因为用户还没拿到 Token;人脸设备回调接口必须放行,因为人脸识别门禁机回调时不会带你的业务 Token,它用的是设备自己的签名机制。其余接口一律走 JWT 校验,这样前端路由守卫和后端接口鉴权各管一层,不会出现“页面能进但接口全 401”的尴尬。
2.2 技术栈与版本组合:老项目别盲目追新
一卡通消费系统属于典型的内部业务系统,稳定性优先,不建议盲目追新版本。我拆这套项目时,Spring Boot 用的是 2.7.x,对应 JDK 1.8,前端是 Vue 2.6 + Element UI,数据库 MySQL 5.7,缓存 Redis 6.x。这个组合的好处是社区资料多、踩坑记录全,遇到问题搜索时基本都有答案。
| 组件 | 推荐版本 | 选型理由 |
|---|---|---|
| Spring Boot | 2.7.x | 稳定成熟,与 MyBatis 整合资料多 |
| JDK | 1.8 | 企业内部旧服务器兼容性好 |
| Vue | 2.6/2.7 | Element UI 生态成熟,适合后台管理 |
| MySQL | 5.7+ | 事务支持可靠,一卡通账务表用 InnoDB |
| Redis | 6.x | 用于分布式锁和二维码 nonce 缓存 |
不建议上来就用 Spring Boot 3.x,因为部分 MyBatis 相关 starter 对 Jakarta EE 的迁移还没完全跟上。做这类账务系统,版本保守一点不是坏事。
2.3 数据库核心表:交易流水表才是命根子
一卡通系统的数据库设计,重点不在用户表,而在流水表。用户表、卡片表、账户表都是静态数据,真正决定系统对不对账的是消费流水表。
CREATE TABLE `t_transaction` ( `id` bigint(20) NOT NULL AUTO_INCREMENT, `txn_no` varchar(32) NOT NULL COMMENT '交易流水号,全局唯一', `user_id` bigint(20) NOT NULL COMMENT '用户ID', `card_no` varchar(20) DEFAULT NULL COMMENT '实体卡卡号', `trade_type` tinyint(4) NOT NULL COMMENT '1-实体卡 2-刷码 3-人脸', `amount` decimal(10,2) NOT NULL COMMENT '消费金额', `balance_after` decimal(10,2) NOT NULL COMMENT '交易后余额', `status` tinyint(4) NOT NULL DEFAULT '1' COMMENT '1-成功 2-失败 3-冲正', `create_time` datetime NOT NULL, PRIMARY KEY (`id`), UNIQUE KEY `uk_txn_no` (`txn_no`), KEY `idx_user_id` (`user_id`), KEY `idx_create_time` (`create_time`) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='消费流水表';这里有两个字段是关键。第一个是 txn_no 交易流水号,必须加唯一索引,这是防重复扣款的最后一道闸门,后面第五章会专门讲。第二个是 balance_after 交易后余额,很多人设计表时会忽略这个字段,但对账时它是唯一能追溯“当时余额”的证据,比事后去账户表查当前余额要可靠得多。
2.4 初始化数据:别把账户表和卡片表做成两张孤岛
账户表和卡片表之间要有明确的关联关系。建议账户表是主表,卡片表和用户表是一对一关系,卡片状态字段要包含“未激活、正常、挂失、注销”四种状态。人脸特征码不要直接存人脸图片,存的是设备返回的特征值或用户 ID,图片文件落到磁盘或对象存储,数据库只存路径。
CREATE TABLE `t_account` ( `id` bigint(20) NOT NULL AUTO_INCREMENT, `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-冻结', `version` int(11) NOT NULL DEFAULT '0' COMMENT '乐观锁版本号', PRIMARY KEY (`id`), UNIQUE KEY `uk_user_id` (`user_id`) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;version 字段是为乐观锁准备的。消费扣款时,先查出 version 值,再在 update 语句里带上 version 条件,如果影响行数为 0 说明余额已被其他请求修改,需要重试或返回失败。这是比单纯 synchronized 更可靠的并发控制手段,因为 synchronized 只在单机下有效,而这套系统如果后续拆成多实例部署,乐观锁依然有效。
3. Spring Boot 后端三种消费方式的实现:统一扣款链路,隔离认证差异
3.1 三种身份的统一抽象:认证与扣款分离
实体卡、刷码、人脸三种方式的差异,本质上只有“如何确认你是谁”这一步不同。确认完身份后,扣款逻辑完全一样。所以设计上要把认证逻辑和扣款逻辑拆开。我一般会定义一个 PayContext 对象,包含 userId、amount、tradeType、txnNo,扣款服务只认这个对象,不关心你是刷卡还是刷脸。
public class PayContext { private Long userId; private BigDecimal amount; private Integer tradeType; // 1实体卡 2刷码 3人脸 private String txnNo; private String extraInfo; // 卡号、设备编号等 }这样做的好处是以后新增一种消费方式,比如指纹支付,只需要新增一个认证实现类,扣款服务完全不用改。一卡通系统最容易遇到的需求就是“明天要加一种新的支付方式”,统一抽象能把这个改动的时间从一天压缩到一小时。
3.2 实体卡消费:读卡器只有一个卡号,别指望它给你用户信息
实体卡消费的流程是:读卡器读到卡号 → 后端根据卡号查用户和账户 → 执行扣款。这里有个常见的认知偏差,读卡器读到的只是一串卡号,它不会告诉你用户是谁,更不会告诉你余额够不够。所以后端要做的是把卡号转成用户 ID,再走统一扣款链路。
@Service public class CardPayService { @Autowired private AccountMapper accountMapper; @Autowired private TransactionMapper transactionMapper; @Transactional(rollbackFor = Exception.class) public PayResult payByCard(String cardNo, BigDecimal amount, String txnNo) { // 1. 根据卡号查用户,卡号不足10位补零对齐 String normalizedCardNo = normalizeCardNo(cardNo); User user = userMapper.selectByCardNo(normalizedCardNo); if (user == null) { throw new BusinessException("卡号不存在或未激活"); } // 2. 乐观锁扣款,balance >= amount 保证不超扣 int rows = accountMapper.deductBalance(user.getId(), amount); if (rows == 0) { throw new BusinessException("余额不足或账户冻结"); } // 3. 写流水,txn_no 唯一索引兜底防重复 BigDecimal balanceAfter = accountMapper.selectBalance(user.getId()); transactionMapper.insert(buildTransaction(user.getId(), normalizedCardNo, 1, amount, balanceAfter, txnNo)); return PayResult.success(); } }这段代码最关键的是第 2 步的扣款 SQL。常见的翻车写法是先查余额、判断够不够、再 update,但这样在并发场景下必然出问题。正确做法是直接在 SQL 条件里带上 balance >= amount,让数据库帮你判断,影响行数为 0 就是余额不足。normalizeCardNo 方法也是血泪经验,有的读卡器返回 8 位卡号,有的返回 10 位,不做对齐处理就会出现“同一张卡一会儿能刷一会儿不能刷”的玄学问题。
3.3 刷码消费:二维码是一次性的,nonce 校验不能省
刷码消费的流程是:前端展示二维码 → 扫码设备识别 → 后端校验二维码 → 扣款。二维码的内容是一个经过签名的字符串,包含 userId、时间戳、随机数 nonce。校验时看三样:签名对不对、时间戳是否过期、nonce 是否用过了。
@RestController @RequestMapping("/pay/qrcode") public class QrCodePayController { @PostMapping("/verify") public PayResult verifyAndPay(@RequestBody QrCodePayRequest request) { // 1. 验签 boolean valid = SignatureUtil.verify(request.getPayload(), request.getSign()); if (!valid) { return PayResult.fail("二维码签名无效"); } // 2. 解析 payload,检查时间戳是否超过 60 秒 QrCodePayload payload = JsonUtil.parse(request.getPayload(), QrCodePayload.class); if (System.currentTimeMillis() - payload.getTimestamp() > 60_000) { return PayResult.fail("二维码已过期,请刷新"); } // 3. nonce 防重放,同一个 nonce 只能消费一次 Boolean firstUse = redisTemplate.opsForValue() .setIfAbsent("qr:nonce:" + payload.getNonce(), "1", 120, TimeUnit.SECONDS); if (!Boolean.TRUE.equals(firstUse)) { return PayResult.fail("二维码已被使用"); } // 4. 走统一扣款 return payService.payByUserId(payload.getUserId(), request.getAmount(), payload.getTxnNo(), 2); } }nonce 校验很多人会忽略,结果就是有人截图二维码反复使用,或者抓包重放。这里用 Redis 的 setIfAbsent 实现幂等,比查数据库再插入的方式快得多,而且天然支持分布式部署。payload 里的 txnNo 是前端生成还是后端生成?我建议后端生成,因为前端生成无法保证全局唯一,直接用 UUID 也可能出现重复。更稳的做法是 payload 里不包含 txnNo,后端验签通过后自研生成,然后传给统一的扣款服务。
3.4 人脸消费:设备比对在前端,业务扣款在后端
人脸消费的流程和前两种不太一样。摄像头设备(人脸识别门禁机或刷脸平板)自带人脸比对算法,它先完成“这个人是谁”的识别,然后把识别结果回调给后端。后端不直接处理人脸图片,只处理设备回调的身份结果。
@PostMapping("/face/callback") public PayResult faceCallback(@RequestBody FaceCallbackRequest request) { // 1. 校验设备签名,设备注册时分配 appId 和 secret boolean signOk = DeviceAuthUtil.verifySign(request.getAppId(), request.getPayload(), request.getSign()); if (!signOk) { return PayResult.fail("设备签名校验失败"); } // 2. 从 payload 中取出设备识别出的 userId FaceCallbackPayload payload = JsonUtil.parse(request.getPayload(), FaceCallbackPayload.class); if (payload.getUserId() == null || payload.getConfidence() < 80) { return PayResult.fail("人脸识别置信度不足"); } // 3. 扣款前检查该用户是否已开通人脸支付 if (!faceAuthService.checkUserEnabled(payload.getUserId())) { return PayResult.fail("用户未开通人脸支付"); } // 4. 调用统一扣款 return payService.payByUserId(payload.getUserId(), request.getAmount(), request.getTxnNo(), 3); }人脸这条链路最需要注意的是设备回调安全。设备没有你的 JWT Token,所以不能走统一的拦截器鉴权,必须单独放行这个接口,并设计设备级签名机制。签名方式一般是在设备管理后台配置一个 appId 和 secret,设备回调时用 HMAC-SHA256 对 payload 签名,后端用同样的 secret 验签。这个机制不做,任何人只要知道你的回调地址,就能伪造人脸消费请求,后果是账户资金被盗刷。
3.5 事务边界:扣款和流水必须在一个事务里,但回调通知不能在事务里
消费场景下,扣款和写流水必须在一个事务里,不然会出现“钱扣了但没流水”的灾难。但事务提交后的通知动作,比如 WebSocket 推送消费成功消息,绝不能写在事务方法内部,否则事务还没提交,推送已经发出去了,前端看到的余额可能不是最新值。
@Service public class PayService { @Autowired private TransactionTemplate transactionTemplate; @Autowired private WebSocketService webSocketService; public PayResult payByUserId(Long userId, BigDecimal amount, Integer tradeType, String txnNo) { // 事务内只做扣款和写流水 PayResult result = transactionTemplate.execute(status -> { int rows = accountMapper.deductBalance(userId, amount); if (rows == 0) { status.setRollbackOnly(); return PayResult.fail("余额不足"); } BigDecimal balanceAfter = accountMapper.selectBalance(userId); transactionMapper.insert(buildTransaction(userId, tradeType, amount, balanceAfter, txnNo)); return PayResult.success(balanceAfter); }); // 事务提交后再推送 if (result.isSuccess()) { webSocketService.pushPaySuccess(userId, result.getBalanceAfter()); } return result; } }用 TransactionTemplate 编程式事务控制,比 @Transactional 注解更好排查,因为你可以清楚地看到哪一步导致回滚,回滚后返回什么给调用方。常见坑是:把 WebSocket 推送写进事务方法里,结果推送失败导致整个事务回滚,余额又恢复了,但前端已经弹出“支付成功”的提示,两边数据不一致。
4. Vue 前端实现:路由守卫管登录,消费页管三种方式的调起
4.1 路由守卫与 Token 过期处理:401 时静默刷新,别直接踢回登录页
Vue 端的路由守卫负责控制页面访问权限,但很多项目在这里犯一个错:只要接口返回 401,就直接跳登录页,导致用户正在操作时突然被踢下线。一卡通消费场景里,收银员正在给排队的人刷脸,突然跳登录页,体验极差。正确做法是拦截 401 后先尝试用 refreshToken 静默刷新,刷新失败再踢回登录页。
// axios 响应拦截器 service.interceptors.response.use( response => response.data, error => { if (error.response.status === 401) { const originalRequest = error.config; if (!originalRequest._retry) { originalRequest._retry = true; // 调用刷新 Token 接口 return authApi.refreshToken() .then(res => { store.commit('setToken', res.data.token); originalRequest.headers.Authorization = 'Bearer ' + res.data.token; return service(originalRequest); // 重放原请求 }) .catch(() => { router.push('/login'); return Promise.reject(error); }); } } return Promise.reject(error); } );这段代码的关键在 _retry 标记,防止死循环重放。如果刷新 Token 的接口本身也返回 401,就不能再触发刷新逻辑,直接跳登录页。一卡通系统的收银终端通常用触屏机,token 过期时间不能设太短,建议 2 小时过期、7 天可刷新,这样早班收银员基本不用重新登录。
4.2 消费页面调起三种方式:扫码枪是输入框,刷脸是回调,刷卡是串口事件
收银终端的消费页面,实际上要处理三种完全不同的输入来源。实体卡消费,读卡器通常模拟键盘输入,光标停在卡号输入框,刷卡后自动回填卡号并触发查询;刷码消费,扫码枪也是模拟输入,识别二维码后回车提交;人脸消费,页面上没有输入动作,它是等设备回调后端,后端再推 WebSocket 消息到前端。
<template> <div class="pay-page"> <el-input ref="cardInput" v-model="cardNo" placeholder="请刷卡或扫码" @keyup.enter.native="handleScanInput" /> <div class="face-area" @click="startFacePay"> <i class="el-icon-camera"></i> <span>{{ faceStatus }}</span> </div> <div class="balance">{{ currentBalance }}</div> </div> </template> <script> export default { data() { return { cardNo: '', faceStatus: '点击开始刷脸', currentBalance: '0.00' } }, methods: { handleScanInput() { // 卡号和二维码都是通过回车触发 const input = this.cardNo.trim(); if (input.length > 20) { // 长串视为二维码,走扫码头支付 this.payByQrCode(input); } else { // 短串视为卡号,走刷卡支付 this.payByCard(input); } this.cardNo = ''; }, startFacePay() { // 调起人脸设备,设备识别完成后回调后端 this.faceStatus = '刷脸中,请正视摄像头'; // 实际项目中这里会调用设备的开放接口 } }, mounted() { // 页面加载后自动聚焦输入框,方便即刷即用 this.$refs.cardInput.focus(); // 通过 WebSocket 监听人脸消费结果 this.$socket.on('pay_result', data => { this.currentBalance = data.balanceAfter; this.faceStatus = '支付成功'; }); } } </script>这里的核心技巧是用一个输入框同时处理卡号和二维码,靠字符串长度和格式做区分。v-model 是 Vue 的双向绑定,handleScanInput 是扫码枪回车后的统一入口。WebSocket 监听人脸消费结果是必须的,否则刷脸成功后页面没有反馈,收银员不知道是否成功。
4.3 实时账单与余额刷新:别用轮询,WebSocket 推送才是正解
消费成功后,前端需要刷新两个地方:当前余额和最近交易流水。常见错误是每秒钟轮询一次余额接口,这种做法对数据库压力大,而且消费成功后有时延。WebSocket 推送能把时延压到 100 毫秒以内。
// 后端通过 WebSocket 推送消费结果 public class WebSocketService { @Autowired private SimpMessagingTemplate messagingTemplate; public void pushPaySuccess(Long userId, BigDecimal balanceAfter) { // 发送给指定用户的私有通道 messagingTemplate.convertAndSendToUser( userId.toString(), "/queue/pay_result", Map.of("balanceAfter", balanceAfter, "ts", System.currentTimeMillis()) ); } }// 前端 Vue 组件中建立 WebSocket 连接 created() { this.$socket = new WebSocket( `ws://${location.host}/ws?token=${this.$store.state.token}` ); this.$socket.onmessage = event => { const data = JSON.parse(event.data); if (data.balanceAfter !== undefined) { this.currentBalance = data.balanceAfter; } }; }注意 WebSocket 连接要带 Token 参数,因为浏览器 WebSocket API 不支持自定义请求头。后端要解析 URL 上的 Token 并校验,确认连接的用户身份。一旦连接建立,不要频繁断开重连,心跳机制每 30 秒 ping 一次即可。
4.4 打包部署:vue.config.js 里配 publicPath,否则刷新 404
前端构建后生成 dist 目录,部署到 Nginx。这里最容易翻车的是 publicPath 配置。如果项目部署在域名根路径,publicPath 配 '/' 没问题;如果部署在子路径,比如 /card,publicPath 必须配 '/card/',否则静态资源全部 404。
// vue.config.js module.exports = { publicPath: process.env.NODE_ENV === 'production' ? '/' : '/', outputDir: 'dist', devServer: { port: 8081, proxy: { '/api': { target: 'http://localhost:8080', changeOrigin: true }, '/ws': { target: 'http://localhost:8080', ws: true } } } };开发环境配代理解决跨域,生产环境用 Nginx 配反向代理。WebSocket 的代理要单独设置 ws: true,很多人在这一步漏配导致前端连不上 WebSocket,页面看起来像“卡住了”,实际只是推送没通。
5. 部署与开发避坑:五个高频踩坑记录,碰到能省半天时间
5.1 刷脸设备回调收不到:先看网络,再看签名
现象:本地调试时人脸设备识别成功,但后端接口没有任何请求日志,收银台一直停在“刷脸中”。
原因:人脸设备配置的回调地址是公网 IP 或错误的局域网 IP,而开发机的 IP 不在设备可访问范围内;或者设备管理后台配的端口跟后端实际监听端口不一致。
解决:先配置设备管理后台把回调地址改成开发机的局域网 IP,比如 192.168.1.100:8080,确保设备和开发机在同一网段。然后在后端回调接口打日志,确认请求是否到达。我一般会加一个临时过滤器打印所有 POST 请求的 body,排查完再删掉。
5.2 实体卡号前导零丢失:数据库存的是字符串,不是数字
现象:同一张卡在 A 台设备上能刷,在 B 台设备上提示“卡号不存在”。
原因:数据库卡号字段用的是 varchar,但读卡器返回的卡号有的带前导零,有的不带。比如卡号实际是 0012345678,一台设备读到的是 0012345678,另一台设备读到的是 12345678,查库自然查不到。
解决:写一个卡号归一化方法,统一补零到 10 位再查询,并在应用层做,不要在 SQL 里做字符串函数处理,否则索引会失效。
public String normalizeCardNo(String rawCardNo) { // 去掉卡号中的空格和横线,统一补零到10位 String cleaned = rawCardNo.replaceAll("[\\s-]", ""); if (cleaned.length() > 10) { // 超过10位可能是多刷了一次,截取后10位 cleaned = cleaned.substring(cleaned.length() - 10); } return String.format("%010d", Long.parseLong(cleaned)); }5.3 二维码截图被重放:nonce 只防了一次,但时间窗口没守住
现象:用户把二维码截图发给朋友,朋友在 1 分钟后成功消费了。
原因:时间戳过期判断设了 60 秒,但 nonce 缓存时间设了 120 秒,nonce 已经用掉后 Redis 里还有值,但朋友用的是同一个 nonce,被拦截了。问题是如果只校验 nonce 不校验时间戳,重放窗口就无限大。
解决:验签后同时校验时间戳和 nonce,时间戳超 60 秒直接拒绝,nonce 已在 Redis 中也直接拒绝。两个条件缺一不可,nonce 缓存时间要大于时间戳过期时间,建议 nonce 120 秒、时间戳 60 秒。
5.4 高并发重复扣款:前端防抖是纸糊的,唯一索引才是钢闸
现象:用户手速快双击了支付按钮,或者扫码枪在同一秒内触发两次回车,后台出现两条相同金额的流水,余额扣了两次。
原因:前端按钮 disable 只能防正常用户,防不了设备重复触发或网络重放。后端没有做幂等控制,同一个 txnNo 处理了两次。
解决:一是前端在 handleScanInput 方法开头加一个锁,处理完再解锁;二是后端在 t_transaction 表的 txn_no 字段上建唯一索引,插入时如果主键冲突直接捕获异常返回“重复提交”。前端防抖和后端幂等是两层防线,缺一不可。
try { transactionMapper.insert(buildTransaction(...)); } catch (DuplicateKeyException e) { // 同一个 txnNo 重复插入,说明请求重复,直接返回已处理结果 return PayResult.fail("重复提交,请勿多次操作"); }5.5 Vue history 路由刷新 404:Nginx 要配 try_files
现象:打包部署后,用户在 /consumer 页面按 F5 刷新,浏览器报 404。
原因:Vue Router 用的 history 模式,URL 不带 #。刷新时浏览器把 /consumer 当作后端路径请求,但 Nginx 找不到这个路径对应的静态文件。
解决:Nginx 配置里加 try_files,找不到路径时回退到 index.html。
location / { root /usr/share/nginx/html; index index.html; try_files $uri $uri/ /index.html; }前端路由刷新 404 是 history 模式的经典坑,配一次就好,但每次部署都要确认 Nginx 配置没有因为其他改动被覆盖。
6. 并发验证与进阶检查:压测脚本、幂等验证、jar 反编译确认发布物
一卡通消费系统上线前,我习惯强制走一遍验证流程,不是功能点一遍,而是并发场景点一遍。先用 JMeter 模拟高并发消费,验证扣款逻辑和幂等控制。
# JMeter 命令行压测,300 线程并发刷码消费 jmeter -n -t pay_test.jmx -l result.jtl -e -o report/pay_test.jmx 里配置的请求是 POST /api/pay/qrcode/verify,参数中固定同一个 txnNo,预期结果应该是 1 条成功、其余全部提示“重复提交”。如果出现多条成功流水,说明唯一索引没生效或事务边界有问题。这个测试能直接验证数据库链路的可靠性。
压测通过后再做发布物检查。Spring Boot 项目打包成 jar 后,发到生产前我习惯反编译看一眼 class 文件里的配置有没有打进去。特别是 application.yml 里的环境变量占位符,如果打包时没替换,生产环境启动就会报配置缺失。
# 解压 jar 包查看实际打包的配置 jar xf card-pay-system.jar BOOT-INF/classes/application.yml cat BOOT-INF/classes/application.yml这一步能发现很多玄学问题:本地明明能跑,生产就是启动报错,结果一看 jar 里的 application.yml 用的是本地数据库地址。这种问题用反编译检查可以在发布前十分钟内发现,比上生产再看日志快得多。
验证完并发和配置,再检查一遍 WebSocket 断线重连逻辑。模拟收银终端待机一小时后刷新页面,WebSocket 会自动重连吗?如果不会,前端需要加一个心跳检测,断线后 5 秒自动重连。从那以后我每次部署一卡通消费系统,都强制走一遍“并发扣款 → 唯一索引验证 → jar 配置检查 → WebSocket 重连”四个环节,能挡住九成以上的上线事故。这套完整资源里包含的源码、SQL 脚本和部署配置,按这个流程走一遍就能跑通,希望帮到你。
本文还有配套的精品资源,点击获取