尧图网络科技YAOTU DIGITAL 获取报价
获取报价
首页 / 资讯中心 / 文章详情

Spring Boot+Vue一卡通消费系统:三种支付方式与并发扣款实战

发布时间:2026/9/26 11:58:03

资讯中心
01
ARTICLE

Spring Boot+Vue一卡通消费系统:三种支付方式与并发扣款实战

Spring Boot+Vue一卡通消费系统:三种支付方式与并发扣款实战
简介基于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 Boot2.7.x稳定成熟与 MyBatis 整合资料多JDK1.8企业内部旧服务器兼容性好Vue2.6/2.7Element UI 生态成熟适合后台管理MySQL5.7事务支持可靠一卡通账务表用 InnoDBRedis6.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) ) ENGINEInnoDB DEFAULT CHARSETutf8mb4 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) ) ENGINEInnoDB DEFAULT CHARSETutf8mb4;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 classpay-page el-input refcardInput v-modelcardNo placeholder请刷卡或扫码 keyup.enter.nativehandleScanInput / div classface-area clickstartFacePay i classel-icon-camera/i span{{ faceStatus }}/span /div div classbalance{{ 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 配 / 没问题如果部署在子路径比如 /cardpublicPath 必须配 /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 路由刷新 404Nginx 要配 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 脚本和部署配置按这个流程走一遍就能跑通希望帮到你。本文还有配套的精品资源点击获取
02
RELATED NEWS

相关资讯

更多网站建设与数字化升级内容

03
WHY YAOTU

想打造同款高转化官网?

懂行业、懂生意,从建站到增长一站式陪跑

◈

场景化定制

不做模板站,围绕你的业务场景量身设计,小众不撞款。

◐

营销型架构

以转化目标组织内容与路径,让官网真正带来询盘。

▲

全周期服务

设计、开发、运营、运维一体,上线只是开始。

免费获取你的建站方案

留下需求,专属顾问 24 小时内为你输出方案建议。