SpringBoot校园一卡通饭卡管理系统:余额流水对账与并发幂等实战 简介饭卡管理系统是一套面向软件开发初学者和课程设计学习者的校园应用项目围绕饭卡充值、消费记录查询、余额及用户管理等业务展开可用于练习数据库设计、前后端交互与完整项目开发流程。压缩包共93个文件、约2.57MB包含class编译产物、doc设计文档、vsd流程图、java源码、form窗体、xml配置、properties属性文件及mdb数据库等另附图片与说明文本目录结构便于按模块对照学习。已有442人学习下载可作为软件工程课程设计参考。项目涵盖用户表与交易表设计、充值消费接口实现、事务ACID、密码加密、SQL注入防护、测试调试和文档编写能帮助读者理解从需求分析到编码、测试与维护的完整过程并积累排错与工程化实践思路。1. 从食堂窗口的一次排队说起饭卡系统真正管的是哪三笔账某高校后勤做系统迁移最先被推翻的不是支付接口而是一张对账表。窗口阿姨一句“今天中午多收了三百二”让整套饭卡管理系统的设计推倒重来——很多人以为饭卡管理系统就是“加钱 扣钱”两个按钮真跑起来才发现它是一套围绕余额、流水、对账三者一致性的账务系统。它要保证两个窗口在同一秒扣同一张卡时不会扣成负数要保证补卡之后旧卡立刻刷不动还要能在日终把渠道账单和本地流水逐笔对上。正在做校园一卡通二次开发的团队、想找个真实业务练事务与幂等的后端开发者都可以从这份源码里看到完整的落法。2. 技术栈与工程结构一个 SpringBoot 单体如何撑起全校刷卡这个项目没有上微服务是刻意的。全校两三万张卡、日均几万笔流水峰值集中在中午十一点半到十二点半那一个小时这个量级用一台 4C8G 的应用服务器加主从 MySQL 完全够用拆成微服务只会让分布式事务和对账变得更难查。常见做法是 SpringBoot MyBatis-Plus MySQL 8 Redis Vue3 后台前端只负责管理端窗口刷卡走的是另一套 HTTP 接口和终端机。2.1 分层与模块划分工程拆成三个 Maven 模块边界划得很清楚card-common放统一返回体、业务异常、枚举和日期工具card-server是唯一的后端服务card-admin是 Vue3 后台。真正的业务分层在card-server内部Controller 只做参数校验和结果包装Service 承担业务编排和事务边界Mapper 只碰 SQL。这条线如果守不住后面加补贴规则、加退款流程的时候一定会乱。card-system/ ├── card-common/ # 统一返回体、异常、枚举、工具类 ├── card-server/ # SpringBoot 单体服务 │ ├── src/main/java/com/example/card/ │ │ ├── controller/ # 只做参数校验和结果包装 │ │ ├── service/ # 业务编排Transactional 都落在这一层 │ │ ├── mapper/ # MyBatis 接口 │ │ ├── domain/ # 实体、枚举、状态机 │ │ └── job/ # 日终对账、报表预热 │ └── src/main/resources/ │ ├── mapper/ # XML复杂 SQL 放这里 │ └── application-dev.yml ├── card-admin/ # Vue3 Element Plus 后台 └── sql/ # 01_schema / 02_index / 03_init_data目录里最值得先看的是service和domain。domain下面除了实体还有CardStatus、OrderStatus、TradeType三个枚举这三个枚举串起来就是整个系统的状态地图service下按业务切成AccountService、TradeService、RechargeService、CardService、ReconcileService互相之间只通过接口调用不直接注入对方的 Mapper。这个约束看着麻烦但等你要换支付渠道或者加一个“补助金账户”的时候就知道它省了多少事。另外提醒一句sql目录里的脚本是有执行顺序的02_index.sql里建的是流水表的联合索引如果先灌了初始化数据再建索引十万条流水的导入时间会从几秒变成几分钟——这属于能省则省的等待。2.2 表结构设计余额、流水、订单三张主表怎么摆账务系统的表设计有一条铁律余额可以重算流水不能改。所以t_trade_flow是只增不改的任何冲正都靠插一条反向流水而不是 update 原记录。t_account存当前余额和版本号t_recharge_order存充值订单和渠道回调状态。卡本身的信息单独放t_card因为一张卡可能对应多个账户主账户、补助账户把卡和账户揉在一张表里后面加补助金就得改表。CREATE TABLE t_account ( id BIGINT NOT NULL AUTO_INCREMENT, card_no VARCHAR(32) NOT NULL COMMENT 卡号业务唯一键, balance DECIMAL(12,2) NOT NULL DEFAULT 0.00 COMMENT 余额单位元, frozen DECIMAL(12,2) NOT NULL DEFAULT 0.00 COMMENT 冻结金额挂失时用, status TINYINT NOT NULL DEFAULT 1 COMMENT 1正常 2冻结 3销户, version INT NOT NULL DEFAULT 0 COMMENT 版本号排查并发问题用, update_time DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, PRIMARY KEY (id), UNIQUE KEY uk_card_no (card_no) ) ENGINEInnoDB DEFAULT CHARSETutf8mb4 COMMENT账户余额表; CREATE TABLE t_trade_flow ( id BIGINT NOT NULL AUTO_INCREMENT, flow_no VARCHAR(40) NOT NULL COMMENT 流水号全局唯一, card_no VARCHAR(32) NOT NULL, trade_type TINYINT NOT NULL COMMENT 1消费 2充值 3退款 4补助 5冲正, amount DECIMAL(12,2) NOT NULL COMMENT 有符号消费为负, balance_after DECIMAL(12,2) NOT NULL COMMENT 记账后余额对账关键字段, window_id BIGINT DEFAULT NULL COMMENT 消费窗口充值时为空, request_id VARCHAR(64) DEFAULT NULL COMMENT 终端请求号做幂等, trade_time DATETIME NOT NULL, PRIMARY KEY (id), UNIQUE KEY uk_flow_no (flow_no), UNIQUE KEY uk_request_id (request_id), KEY idx_card_time (card_no, trade_time), KEY idx_window_time (window_id, trade_time) ) ENGINEInnoDB DEFAULT CHARSETutf8mb4 COMMENT交易流水表;两个细节值得单独说。一是amount用 DECIMAL 而不是 DOUBLE钱这个东西只要沾上浮点日终对账必然差几分钱后面第 5 章还会专门讲这个坑。二是balance_after这个冗余字段很多人觉得多余反正能根据流水累加算出来——但真出问题的时候你需要的正是“这条流水记账那一刻余额是多少”靠累加算出来的值在并发场景下根本对不上这是排查时唯一的参照点。uk_request_id是幂等的最后一道防线数据库唯一索引比任何应用层判断都可靠。2.3 本地跑起来依赖、配置与启动顺序环境上需要 JDK 8 或 11、Maven 3.6、MySQL 8、Redis 6、Node 16。启动顺序不能乱先建库建表灌数据再起后端最后起前端因为前端登录要调后端接口拿 token。# 1. 建库并顺序执行脚本02 是索引别和 03 换位置 mysql -uroot -p -e CREATE DATABASE card_system DEFAULT CHARSET utf8mb4; mysql -uroot -p card_system sql/01_schema.sql mysql -uroot -p card_system sql/02_index.sql mysql -uroot -p card_system sql/03_init_data.sql # 2. 后端打包启动dev profile 会连本地库 mvn clean package -DskipTests java -jar card-server/target/card-server.jar --spring.profiles.activedev # 3. 管理后台npm run dev 默认 5173 端口 cd card-admin npm install npm run dev配置文件里有两个参数最容易翻车serverTimezone不写会导致流水时间和渠道账单差 8 小时日终对账直接跨天连接池maximum-pool-size默认 10中午高峰期窗口终端几十台并发连接池打满之后终端会开始报超时本地调试时把它调到 20 就够。server: port: 8080 spring: datasource: url: jdbc:mysql://127.0.0.1:3306/card_system?useUnicodetruecharacterEncodingutf8serverTimezoneAsia/Shanghai username: root password: 123456 hikari: maximum-pool-size: 20 connection-timeout: 3000 redis: host: 127.0.0.1 port: 6379 database: 0初始化数据里给了三个账号管理员、财务、窗口操作员密码统一是加密后的初始串登录后强制改密。还有个mock开关在application-dev.yml里打开之后充值回调不会真的找渠道而是本地模拟成功本地调试充值链路就不用来回改代码了。3. 核心链路拆解充值、消费、挂失三段的代码实现三条链路的难度完全不一样。充值难在幂等和回调时序消费难在并发和事务边界挂失难在状态和缓存的同步。逐个拆。3.1 充值下单订单号生成与回调幂等充值走的是“先下单、再支付、后回调”的异步模式。下单时生成一个业务订单号写进t_recharge_order状态 PENDING渠道回调进来之后先做幂等判断再改状态、加余额、写流水。整个方法在一个事务里任何一步失败全部回滚。Transactional(rollbackFor Exception.class) public void handleCallback(String orderNo, BigDecimal payAmount, String channelTradeNo) { // 1. 幂等闸门先查订单非 PENDING 状态直接返回重复回调不会重复加钱 RechargeOrder order orderMapper.selectByOrderNo(orderNo); if (order null) { throw new BizException(订单不存在: orderNo); } if (!OrderStatus.PENDING.equals(order.getStatus())) { log.warn(重复回调已忽略 orderNo{}, status{}, orderNo, order.getStatus()); return; } // 2. 金额校验渠道回传金额必须和下单金额一分不差 if (order.getAmount().compareTo(payAmount) ! 0) { throw new BizException(回调金额与订单金额不一致, orderNo orderNo); } // 3. 带状态条件的更新靠受影响行数兜底并发 int updated orderMapper.finishOrder(orderNo, channelTradeNo, OrderStatus.PENDING, OrderStatus.SUCCESS); if (updated 0) { log.warn(订单已被其它线程处理 orderNo{}, orderNo); return; } // 4. 改余额 写流水两者必须同事务 accountMapper.increaseBalance(order.getCardNo(), order.getAmount()); flowMapper.insert(TradeFlow.ofRecharge(order)); }finishOrder的 SQL 是update t_recharge_order set status #{to}, channel_trade_no #{tradeNo}, finish_time now() where order_no #{orderNo} and status #{from}where status 0这个条件是整个幂等逻辑的核心——两个回调线程同时进来只有一个能把 PENDING 改成 SUCCESS另一个受影响行数为 0 直接返回。这种方法比分布式锁轻也比在应用层用synchronized可靠因为锁只在单机上生效。参数上要注意payAmount的类型渠道传过来通常是字符串转 BigDecimal 的时候别用new BigDecimal(double)会带出 0.1 变成 0.100000000000000005 的经典问题用new BigDecimal(String)。订单号生成用“日期 卡号后六位 序列”不用 UUID因为对账时人要看得懂UUID 在账单核查时根本没法肉眼比对。3.2 消费扣款一条带条件的 UPDATE 解决并发消费是所有链路里并发最高的。中午一个窗口一分钟几十笔同一张卡理论上不会被两个窗口同时刷但终端超时重发、网络抖动重试都会造成重复请求所以必须先做请求号幂等再做扣款。Transactional(rollbackFor Exception.class) public ConsumeResult consume(String cardNo, Long windowId, BigDecimal amount, String requestId) { // 1. 终端重发防护同一 requestId 只允许扣一次 TradeFlow exist flowMapper.selectByRequestId(requestId); if (exist ! null) { return ConsumeResult.duplicate(exist); } // 2. 一条 SQL 完成 状态校验 余额校验 扣减避免先查后改的竞态 int rows accountMapper.deduct(cardNo, amount); if (rows 0) { throw new BizException(余额不足或账户状态异常, cardNo cardNo); } // 3. 记账流水amount 存负数便于汇总时直接 sum TradeFlow flow TradeFlow.ofConsume(cardNo, windowId, amount.negate(), requestId); flow.setBalanceAfter(accountMapper.selectBalance(cardNo)); flowMapper.insert(flow); return ConsumeResult.success(flow); }对应的 Mapper XMLupdate iddeduct update t_account set balance balance - #{amount}, version version 1, update_time now() where card_no #{cardNo} and status 1 and balance gt; #{amount} /update这条 SQL 是整个系统里最关键的一行。balance #{amount}写在 where 里MySQL 在执行 update 时会先对匹配的行加行锁再判断条件所以哪怕一百个线程同时扣同一张卡也只会串行执行不会出现“查到余额 100、两个线程各扣 80、余额变成 -60”这种情况。这比“先 select 查余额、Java 里判断、再 update”安全得多后者在并发下必翻车。version字段在扣款这里其实用不上因为条件更新本身就是原子的留着它是为了排查——出现余额异常时看 version 涨了多少次就知道这张卡被扣过多少回能快速定位是不是重发了。balance_after单独再查一次余额在事务内是安全的读到的就是刚扣完的值。3.3 挂失与补卡状态机比 if-else 好维护卡的状态有正常、挂失、冻结、注销四种如果按业务随便写 if-else加一个“临时挂失 24 小时自动恢复”的需求就要改十几个地方。项目的做法是用枚举加一张转移表所有状态变更走同一个入口。public enum CardStatus { NORMAL(1), LOST(2), FROZEN(3), CANCELLED(4); private final int code; CardStatus(int code) { this.code code; } public int getCode() { return code; } } // 允许的转移正常-挂失、挂失-正常、正常-冻结、冻结-正常、任意-注销 private static final MapCardStatus, SetCardStatus ALLOWED Map.of( CardStatus.NORMAL, Set.of(CardStatus.LOST, CardStatus.FROZEN, CardStatus.CANCELLED), CardStatus.LOST, Set.of(CardStatus.NORMAL, CardStatus.CANCELLED), CardStatus.FROZEN, Set.of(CardStatus.NORMAL, CardStatus.CANCELLED), CardStatus.CANCELLED, Set.of() ); Transactional(rollbackFor Exception.class) public void changeStatus(String cardNo, CardStatus target, String reason) { Card card cardMapper.selectByCardNo(cardNo); if (card null) throw new BizException(卡不存在); if (!ALLOWED.get(card.getStatus()).contains(target)) { throw new BizException(非法状态转移: card.getStatus() - target); } cardMapper.updateStatus(cardNo, target.getCode(), reason); // 状态变更后立刻清缓存刷卡端下一次读库拿到新状态 redisTemplate.delete(card:status: cardNo); }补卡是挂失的延伸旧卡置为 CANCELLED新卡继承旧卡的card_no关联的账户余额不用搬因为账户和卡本来就是一对多。这里最容易漏的一步是缓存清理刷卡终端为了性能会把卡状态缓存在 Redis 里key 是card:status:{cardNo}TTL 五分钟如果挂失只改库不清缓存最坏情况下旧卡还能再刷五分钟这在校园场景里是实实在在的资金风险。所以changeStatus里改完库必须紧接着删 key两件事放同一个方法不给漏掉的机会。4. 后台管理与日终对账让每一分钱都能落地4.1 流水查询索引与分页的取舍财务最常用的功能是按卡号查某个月的流水其次是按窗口查某天的收款。这两种查询对应两个联合索引idx_card_time(card_no, trade_time)和idx_window_time(window_id, trade_time)。索引顺序不能反trade_time放第二位是因为范围查询放后面才能用上索引反过来写成(trade_time, card_no)就退化成全表扫。分页这里有个坑。后台默认用的是limit offset, size查第一页很快翻到第五百页的时候 MySQL 要扫描前五万行再丢弃接口直接超时。项目的做法是超过一定页数就切换到游标分页用上一页最后一条的 id 做起点。-- 深分页优化用上一页最大 id 做游标避免 offset 扫描 SELECT id, flow_no, card_no, trade_type, amount, balance_after, trade_time FROM t_trade_flow WHERE card_no #{cardNo} AND trade_time #{startTime} AND trade_time #{endTime} AND id #{lastId} ORDER BY id DESC LIMIT #{size};前端也要配合流水列表不做“跳转到第 N 页”只保留上一页下一页财务实际使用中也很少跳页。如果业务方坚持要总数把count(*)单独走一个缓存五分钟更新一次不要和列表查询放一个接口里。4.2 日终对账任务差异表怎么设计对账是这套系统里最能体现水平的地方。每天凌晨一点跑定时任务把当天所有充值订单的金额按渠道汇总再和渠道方给的账单文件逐笔比对对不上的写进t_reconcile_diff第二天财务在后台能看到差异列表并手动处理。Scheduled(cron 0 0 1 * * ?) public void dailyReconcile(LocalDate date) { // 1. 本地侧按渠道汇总当天成功的充值订单 ListChannelSum local orderMapper.sumSuccessByChannel(date); // 2. 渠道侧解析对账文件格式是 csv每行 订单号,金额,渠道流水号 MapString, BigDecimal remote billParser.parse(date); // 3. 逐笔比对状态或金额不一致就落差异表 for (ChannelSum item : local) { BigDecimal remoteAmt remote.get(item.getOrderNo()); if (remoteAmt null) { diffMapper.insert(DiffRecord.missingRemote(item)); } else if (remoteAmt.compareTo(item.getAmount()) ! 0) { diffMapper.insert(DiffRecord.amountMismatch(item, remoteAmt)); } } // 4. 渠道有、本地没有的属于漏记同样落差异 remote.keySet().stream() .filter(no - orderMapper.selectByOrderNo(no) null) .forEach(no - diffMapper.insert(DiffRecord.missingLocal(no, remote.get(no)))); }差异表t_reconcile_diff的核心字段是biz_date、order_no、diff_type本地缺失、渠道缺失、金额不符、handle_status。设计上有个经验差异记录不做删除财务处理完只改状态并记录处理人和备注这样月底复盘时能看出哪家渠道经常出问题。任务本身要做成分片可重跑的biz_date加唯一索引重复跑同一天不会产生重复差异记录。5. 避坑与排查上线后最容易翻车的五个地方5.1 金额字段用了 double对账差一分现象日终对账每天都有一两笔金额差 0.01人工核对发现两边显示的都是同一个数。原因早期为了省事amount建成了DOUBLEJava 侧也用double接收。0.1 0.2 在二进制浮点里等于 0.30000000000000004累加几千笔之后误差被放大。解决全链路换DECIMAL(12,2)加BigDecimal比较用compareTo不用equals后者会比较 scale1.0 和 1.00 不相等。历史数据写个迁移脚本ALTER TABLE ... MODIFY amount DECIMAL(12,2)改完重跑一遍历史对账确认无差异。5.2 并发扣款把余额扣成负数现象压测时同一张卡并发扣款余额出现 -3.50。原因扣款逻辑写成了“先 select 查余额、Java 判断够不够、再 update 减余额”两个线程同时查到余额 5 元都判断够都执行了扣减。解决改成update ... where balance #{amount}的条件更新受影响行数为 0 就抛余额不足或者用select ... for update加行锁。前者性能更好后者适合需要在扣款前做复杂校验的场景。改完用 JMeter 起 50 个线程打同一张卡验证余额必须始终大于等于 0。5.3 支付回调重复到达余额翻倍现象个别用户充值 100 元余额涨了 200。原因渠道在超时后会重发回调服务端只判断了订单是否存在没判断状态两次回调都走了加余额的逻辑。解决三重保险——订单号唯一索引、状态条件更新where status 0、request_id唯一索引。三层里任何一层生效都不会重复加钱。排查的时候直接看t_recharge_order的finish_time和channel_trade_no同一个渠道流水号对应两条订单就是渠道侧重复推送。5.4 补卡后旧卡还能刷缓存没失效现象学生挂失补卡十分钟后有人拿着捡到的旧卡在另一栋楼的窗口刷成功了。原因刷卡终端为了性能缓存了卡状态挂失接口只改了数据库没有清 Redis 里card:status:{cardNo}的 key缓存 TTL 是五分钟叠加终端的本地缓存实际延迟更长。解决状态变更接口里改库和删缓存必须写在同一个方法内删缓存失败要打日志告警同时把终端的本地缓存 TTL 压到 30 秒以内并且在消费接口里对 LOST 状态做二次校验不信任终端传来的状态。5.5 时区不一致日终对账跨了天现象对账任务跑出来每天都有几笔“本地有、渠道无”的差异时间都集中在晚上 23:50 到 00:10 之间。原因JDBC 连接串没写serverTimezone驱动用了 UTC而渠道账单是北京时间跨零点的交易被算到了前一天。解决连接串统一写serverTimezoneAsia/Shanghai容器基础镜像设置TZAsia/Shanghai数据库time_zone参数也在初始化脚本里固定。对账任务的日期参数不要用LocalDate.now()而是显式传入biz_date避免任务重跑时用到错误日期。6. 二次开发换支付渠道、加补贴规则、接门禁拿到源码之后最常见的三个改造方向是换支付渠道、加补助金规则、对接门禁考勤。换渠道的关键是把支付抽象出来项目里已经留了PayChannelService接口只需要实现createOrder、queryOrder、verifySign三个方法然后在配置里注册渠道编码回调入口用策略模式按channelCode路由不用改RechargeService一行代码。public interface PayChannelService { String channelCode(); // 渠道标识如 WECHAT、ALIPAY String createOrder(String orderNo, BigDecimal amount); // 返回支付串或二维码 boolean verifySign(MapString, String callbackParams); // 回调验签必须实现 ChannelOrder queryOrder(String orderNo); // 主动查单兜底回调丢失 }加补助规则要动的是账户模型不是交易逻辑。建议新建一张t_subsidy_rule字段包含适用人群、发放周期、金额上限然后加一个定时任务往补助账户打钱trade_type用 4流水照常写。补助账户和主账户分开学生刷卡时先扣补助再扣本金这个扣减顺序做成配置项不同学校的财务口径不一样。接门禁相对简单门禁终端只要能读到卡号调消费服务的queryStatus接口拿卡状态就行别让它直接连数据库。改造完之后一定要做三件事用历史流水重跑一遍对账确认没算错用 JMeter 压一百并发扣款确认没有负余额把挂失到补卡的完整流程手工走一遍确认旧卡在缓存过期前就刷不动。我有一次图省事跳过第二步上线第二天中午就被窗口的电话打爆从那以后不管改多小的账务逻辑我都强制先把对账和并发这两条压测跑一遍再合并。希望帮到你。本文还有配套的精品资源点击获取