简介:这套饭卡管理系统课程设计源码面向高校软件工程初学者与课程设计学习者,围绕校园饭卡充值、消费记录查询及后台管理等场景,提供一套可运行、可拆解的教学案例。压缩包共93个文件,约2.57MB,以46个class编译文件、7个Java源码、6个form窗体、10个doc文档、9个vsd设计图、4个xml配置及mdb数据库文件为主,兼顾程序源码、设计文档、数据库与界面资源。目前已有442人学习,适合需要完成课程设计或夯实数据库与后端开发基础的学习者参考。读者可从中梳理用户表与交易表设计、前后端交互、充值消费事务的ACID处理、密码加密与SQL注入防护等实现思路,并借助需求分析、设计文档、用户手册和测试用例理解完整开发流程;目录按源码、文档、数据库分层,便于按模块对照学习。
1. 中午十一点五十,三个窗口同时开闸:饭卡管理系统到底在解决什么问题
中午十一点五十,三个窗口同时开闸,两百多号人涌进来,每笔消费从刷卡到抬杆不到两秒。这时候系统只要卡一下,队伍立刻堵到楼梯口。饭卡管理系统要解决的就是这件事:把储值、消费、充值、挂失、对账这几条链路压进两秒内,还得保证一分钱都不错。它不是拿来做高并发炫技的,适合校园、园区、厂区这类固定场所的小额高频支付场景——终端几十台、持卡人几千到几万、每天几万笔流水。我带着人拆过一套 Spring Boot + Vue 的实现,读下去你会发现,真正难的不是扣钱,是扣完钱账还能对得上。
2. 技术栈与目录结构:一套饭卡管理系统是怎么搭起来的
2.1 技术选型:为什么最后落在 Spring Boot + Vue 这套组合上
先看运行环境,这决定了选型。窗口机的现实是:一台几百块的工控小主机,装着 Windows,浏览器内核可能还停在 Chromium 九十多,网线接的是食堂自己的局域网交换机,出口带宽只有一条家用宽带。这种环境下最忌讳微服务和容器编排——服务一多,本地那台机器扛不住,运维也没人看得懂。所以单体 jar 是合理选择:Spring Boot 打成 fat jar,内嵌 Tomcat,丢到食堂本地服务器上java -jar就能起,断外网照样跑局域网。
数据库用 MySQL 8,流水表和账户表放一个库,事务好控。Redis 不是必须的,但只要有日限额、终端心跳、卡状态缓存这三样需求,加上它能省掉大量数据库往返。前端后台管理走 Vue 3 + Element Plus,窗口消费端做成一个纯 Web 页面,用浏览器全屏 + 键盘钩子模拟刷卡枪输入,好处是升级只改服务器不用挨个机器装客户端。鉴权用 JWT,后台管理员一套 token,终端设备一套设备 token,两边不共用同一套权限表。
| 层次 | 技术选型 | 在这套系统里干什么 |
|---|---|---|
| 后端框架 | Spring Boot 2.7 + Spring MVC | 提供 REST 接口、事务边界、统一异常处理 |
| 持久层 | MyBatis-Plus + 手写 XML | 简单 CRUD 走 Wrapper,扣款这类关键 SQL 手写 |
| 数据库 | MySQL 8(InnoDB) | 账户余额、交易流水、充值订单、终端台账 |
| 缓存 | Redis 6 | 日消费限额计数、卡状态缓存、终端在线心跳 |
| 鉴权 | Spring Security + JWT | 后台管理员与消费终端两套身份 |
| 前端 | Vue 3 + Element Plus + Axios | 后台管理台、窗口消费页、报表页 |
| 部署 | Nginx + 单机 jar + systemd | 局域网内网部署,无需外网依赖 |
有一点要提前说清楚:这套系统里最不该省的是数据库事务和唯一索引,最可以省的是各种花哨中间件。我见过有人在食堂本地部署上消息队列,结果机器内存不够,消费接口偶发超时,最后还是拆掉了。
2.2 目录结构:controller / service / mapper 三层各自该管什么
源码拆开看,后端是一个标准的 Maven 多模块单应用,前端独立一个工程。目录大致长这样,这份结构在同类项目里属于常见做法,理解它比记住每个文件名重要:
mealcard/ ├── mealcard-admin/ # 后台管理前端(Vue3 + Vite) │ ├── src/api/ # 按模块拆的接口封装:card.js / trade.js / report.js │ ├── src/views/ # 页面:开户、充值、挂失、流水查询、日结报表 │ └── src/router/ # 路由与按钮级权限 ├── mealcard-terminal/ # 窗口消费端(纯静态页,可嵌 Electron) │ └── src/views/Consume.vue # 刷卡输入框 + 金额键盘 + 结果提示 ├── mealcard-server/ │ ├── src/main/java/com/example/mealcard/ │ │ ├── controller/ # 只做参数校验和返回包装,不写业务 │ │ ├── service/ # 事务边界在这里,@Transactional 加这层 │ │ ├── mapper/ # 接口 + resources/mapper/*.xml │ │ ├── entity/ # 与表一一对应的实体 │ │ ├── dto/ # 入参出参,别直接拿 entity 接请求 │ │ ├── config/ # 拦截器、跨域、MyBatis、Redis 配置 │ │ └── common/ # Result、BizException、全局异常处理 │ └── src/main/resources/ │ ├── mapper/ # 手写 SQL 放这 │ ├── db/ # schema.sql + data.sql │ └── application.yml └── docs/ # 接口文档、对账口径说明改代码时最常见的迷路点是「逻辑写在 controller 里」。判断标准很简单:任何一个带@Transactional的方法都应该在 service 层;controller 里只干三件事——接参、调 service、包Result。另一处是dto和entity混用,导致前端能直接改到balance字段,这类越权改动在充值接口上尤其危险,后面第 5 章会说到。
还有一个细节:窗口消费端单独一个工程,不跟后台管理台混在一起。原因很实际——消费页要全屏、要禁用右键、要吃掉键盘的 F5,这些跟管理台的需求完全冲突,混在一个 SPA 里做条件渲染,最后两边都做不好。
2.3 数据表设计:余额、流水、充值单三张表怎么落
表设计决定了后面能不能对账。三张核心表:t_card存账户当前状态,t_trade存每一笔资金变动,t_recharge存线上充值订单。流水表里冗余一个balance_after字段,很多人第一版会省掉它,等到对账时才发现必须补——没有它,你只能从头把流水累加一遍才知道某一笔之后余额是多少。
CREATE TABLE t_card ( id BIGINT PRIMARY KEY AUTO_INCREMENT, card_no VARCHAR(32) NOT NULL COMMENT '卡号/学工号,物理卡与账户解耦', holder_name VARCHAR(64) NOT NULL, dept VARCHAR(64) DEFAULT NULL, status TINYINT NOT NULL DEFAULT 0 COMMENT '0正常 1挂失 2冻结 3已注销', balance DECIMAL(12,2) NOT NULL DEFAULT 0.00, day_limit DECIMAL(10,2) NOT NULL DEFAULT 50.00 COMMENT '单日消费上限', version INT NOT NULL DEFAULT 0, create_time DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, update_time DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, UNIQUE KEY uk_card_no (card_no), KEY idx_status (status) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; CREATE TABLE t_trade ( id BIGINT PRIMARY KEY AUTO_INCREMENT, trade_no VARCHAR(40) NOT NULL COMMENT '系统流水号', request_no VARCHAR(64) NOT NULL COMMENT '终端请求号,幂等键', card_no VARCHAR(32) NOT NULL, terminal_no VARCHAR(32) DEFAULT NULL, trade_type TINYINT NOT NULL COMMENT '1消费 2充值 3退款 4补助发放', amount DECIMAL(12,2) NOT NULL, balance_after DECIMAL(12,2) NOT NULL, trade_time DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, UNIQUE KEY uk_request_no (request_no), KEY idx_card_time (card_no, trade_time) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; CREATE TABLE t_recharge ( id BIGINT PRIMARY KEY AUTO_INCREMENT, order_no VARCHAR(40) NOT NULL, card_no VARCHAR(32) NOT NULL, amount DECIMAL(12,2) NOT NULL, channel VARCHAR(16) NOT NULL COMMENT 'wx / alipay / cash', status TINYINT NOT NULL DEFAULT 0 COMMENT '0待支付 1已支付 2已关闭', outer_no VARCHAR(64) DEFAULT NULL COMMENT '渠道流水号', create_time DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, UNIQUE KEY uk_order_no (order_no) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;几个字段值得单独说。amount一律用DECIMAL(12,2),不用FLOAT/DOUBLE,也不用INT存分——后者虽然稳,但导出报表和人工核对时到处要除以 100,容易漏。t_trade.request_no上的唯一索引是终端重复提交的最后一道闸,t_recharge.order_no同理。t_card.status用整型不用枚举字符串,是为了在 SQL 里能直接status = 0走索引。
card_no与物理卡号解耦这点,第一版通常看不出价值,等遇到「学生补办卡」就明白了:补卡时把旧卡号置为已注销,新卡号指向同一个账户主体,余额和历史流水都不用迁,只多一张卡记录。如果一开始把物理卡号当主键,补卡就得改一堆历史数据。
3. 核心链路拆解:一笔消费从刷卡到落账要走几道关
3.1 消费扣款:一条 UPDATE 扛住并发和余额校验
窗口排队时最怕的不是慢,是扣错。第一版代码很多人这么写:先select查余额,Java 里判断够不够,再update减去金额。单机单线程没事,一旦同一张卡在两台终端上同时刷(或者家长和学生共用一张卡),两边都读到 100 块,都判断通过,最后余额变成 -12。这不是理论问题,开学季补助发放当天我亲眼见过。
正确做法是把判断和扣减压进同一条 SQL,让 InnoDB 的行锁来保证原子性:
<!-- CardMapper.xml --> <update id="deduct"> UPDATE t_card SET balance = balance - #{amount}, version = version + 1 WHERE card_no = #{cardNo} AND status = 0 AND balance >= #{amount} </update>这条 SQL 的返回行数就是判据:返回 1 表示扣款成功,返回 0 表示余额不足、卡片挂失或冻结,三种情况在业务层统一抛BizException,前端提示「余额不足或卡片状态异常」。不要为了给用户更精确的提示再去查一次库——那一次查询就是新的竞态窗口。
service 层这么组织:
@Service public class TradeService { @Resource private CardMapper cardMapper; @Resource private TradeMapper tradeMapper; @Resource private LimitService limitService; /** * @param dto amount 单位元,requestNo 终端生成,全局唯一 * 事务里只做扣款 + 写流水,风控校验尽量前置到事务外,缩短持锁时间 */ @Transactional(rollbackFor = Exception.class) public TradeVO consume(ConsumeDTO dto) { // 1. 幂等前置判断:同一 requestNo 已经入账就直接返回原结果 TradeVO exist = tradeMapper.findByRequestNo(dto.getRequestNo()); if (exist != null) { return exist; } // 2. 日限额校验:Redis 当日累计 + 本次金额,超限直接拒 limitService.checkDayLimit(dto.getCardNo(), dto.getAmount()); // 3. 一条 UPDATE 完成余额校验与扣减,rows == 0 即失败 int rows = cardMapper.deduct(dto.getCardNo(), dto.getAmount()); if (rows == 0) { throw new BizException("余额不足或卡片状态异常"); } // 4. 回查最新余额写进流水,balance_after 用于日后对账 BigDecimal after = cardMapper.selectBalance(dto.getCardNo()); Trade trade = Trade.of(dto, after); tradeMapper.insert(trade); // 5. 累加日限额计数,失败不影响主流程,用 try 包住 limitService.addTodayAmount(dto.getCardNo(), dto.getAmount()); return TradeVO.success(trade, after); } }requestNo由终端生成,格式建议终端号-yyyyMMddHHmmss-序号,好处是断网重试时能复用同一个号。amount做参数校验,@DecimalMin("0.01")加@Digits(integer = 10, fraction = 2),把 0 元、负数、三位小数挡在 controller 层。
第 1 步的幂等判断是「快路径」,第 4 步插入流水时唯一索引才是「兜底」。为什么两道都要?因为并发下两个相同requestNo的请求可能同时越过第 1 步,此时第二条 insert 会撞唯一索引抛DuplicateKeyException,事务回滚,余额退回,结果依然正确。这种做法在业内很常见,代价是需要捕获那个异常并转成「已处理」的返回。
3.2 充值回调:状态机加唯一索引做幂等
线上充值是整个系统里唯一跟外部渠道交互的环节,也是最容易出对账差异的地方。渠道方的回调策略通常是「不确认就一直重试」,同一个订单回调三五次很正常。如果回调里写的是UPDATE t_card SET balance = balance + amount,用户就白赚了几份。
处理办法是把充值订单当作状态机:0 待支付 → 1 已支付,只有从 0 变更到 1 的那一次才给账户加钱。
@Transactional(rollbackFor = Exception.class) public void onPayCallback(PayCallbackDTO cb) { // 状态机推进:只有 status=0 的记录能被改成 1 int updated = rechargeMapper.markPaid(cb.getOrderNo(), cb.getOuterNo()); if (updated == 0) { // 已处理过、订单不存在或已关闭,直接幂等返回 log.warn("充值回调重复或订单状态异常, orderNo={}", cb.getOrderNo()); return; } // 校验金额,防止渠道回调和下单金额不一致 Recharge order = rechargeMapper.selectByOrderNo(cb.getOrderNo()); if (order.getAmount().compareTo(cb.getAmount()) != 0) { throw new BizException("回调金额与订单金额不一致"); } cardMapper.addBalance(order.getCardNo(), order.getAmount()); BigDecimal after = cardMapper.selectBalance(order.getCardNo()); tradeMapper.insert(Trade.ofRecharge(order, after)); // 事务提交后再清缓存,避免脏读;用 TransactionSynchronization 更稳 cacheService.evictCard(order.getCardNo()); }<update id="markPaid"> UPDATE t_recharge SET status = 1, outer_no = #{outerNo}, pay_time = NOW() WHERE order_no = #{orderNo} AND status = 0 </update>markPaid返回影响行数,这就是最轻量的分布式锁——不用引入 Redis 锁,不用加悲观锁,条件是status = 0,第二次回调必然返回 0。整套逻辑的正确性依赖两点:t_recharge.order_no的唯一索引,以及这条 UPDATE 在同一个事务里跟加余额绑在一起。
有个坑要提醒:清缓存千万别写在事务方法内部直接调,如果事务最后回滚了,缓存已经被清掉,下一次读到的是旧库数据,看上去像「余额少了」。稳妥做法是注册TransactionSynchronization.afterCommit回调,或者干脆给缓存设短 TTL,几十秒内自然失效。
3.3 挂失、解挂与补卡:状态流转不能拍脑袋
挂失看起来最简单,实际上是客服投诉最多的地方。核心是四态流转:正常(0)、挂失(1)、冻结(2)、注销(3)。规则要写死在一个地方,不要散落在各个 service 里:
| 当前状态 | 允许操作 | 目标状态 | 说明 |
|---|---|---|---|
| 正常(0) | 挂失 | 挂失(1) | 立即生效,终端不再允许消费 |
| 挂失(1) | 解挂 | 正常(0) | 需人工核验身份,记操作日志 |
| 正常(0)/挂失(1) | 冻结 | 冻结(2) | 管理员操作,如欠费、异常交易 |
| 冻结(2) | 解冻 | 正常(0) | 清空冻结原因 |
| 挂失(1) | 补卡 | 旧卡注销(3) | 新卡继承余额,旧卡留档 |
public enum CardStatus { NORMAL(0), LOST(1), FROZEN(2), CANCELLED(3); private static final Map<CardStatus, Set<CardStatus>> ALLOWED = Map.of( NORMAL, Set.of(LOST, FROZEN, CANCELLED), LOST, Set.of(NORMAL, FROZEN, CANCELLED), FROZEN, Set.of(NORMAL, CANCELLED), CANCELLED, Set.of() ); public boolean canTransferTo(CardStatus target) { return ALLOWED.getOrDefault(this, Set.of()).contains(target); } }补卡这一步要单独注意:不是「把旧卡余额清零再给新卡加上」,那样一旦中间失败就丢钱。正确做法是在同一个事务里先把旧卡状态改为注销,再查一次旧卡余额作为新卡初始余额插入,然后写一条trade_type = 4(或单独的业务类型)的流水,备注「补卡转余额」。整个过程只有一次余额读取,不需要清零。
挂失的生效延迟是另一个真问题。终端为了扛住网络抖动,往往会在本地缓存卡状态,缓存不失效就会出现「已经挂失还能刷」的情况。解决办法不是取消缓存,而是把挂失做成推送:后台挂失成功后,往所有在线终端的 WebSocket 通道或 Redis 发布订阅发一条失效消息,终端收到后立刻清本地黑名单。终端每 30 秒还会拉一次全量黑名单做对齐,双保险。
4. 本地跑通:环境、初始化脚本与联调验证
4.1 环境准备与几个必须改的配置项
本地跑起来需要的东西不多:JDK 8 或 11(Spring Boot 2.7 对两者都兼容,选 8 更贴近食堂服务器现状)、Maven 3.6+、MySQL 8、Redis 6、Node 16+。Redis 可以关掉,把limitService换成数据库实现,只是并发时会慢一些;但要跑完整流程,建议装上。
application.yml里几个常被改错的点集中列一下:
server: port: 8080 servlet: context-path: /api spring: datasource: url: jdbc:mysql://127.0.0.1:3306/mealcard?useUnicode=true&characterEncoding=utf8&serverTimezone=Asia/Shanghai&useSSL=false&allowPublicKeyRetrieval=true username: root password: change-me hikari: maximum-pool-size: 20 # 食堂并发不高,20 足够,别照抄 200 connection-timeout: 3000 redis: host: 127.0.0.1 port: 6379 database: 0 mealcard: jwt: secret: please-replace-with-a-32-byte-random-string expire-hours: 12 trade: max-amount-per-trade: 200 # 单笔上限,防误输入 day-limit-default: 50 # 默认日限额,开户时写入 t_card terminal: heartbeat-timeout-seconds: 90serverTimezone必须显式指定,不写会跟随 JVM 默认时区,一旦服务器时区是 UTC,流水时间就会整体差 8 小时,日结报表直接对不上。jwt.secret长度不足 32 字节在部分版本会直接抛异常,启动失败但日志不明显,排查时容易抓瞎。连接池大小别照抄网上的 200,食堂场景几十个连接都算多。
4.2 建库与初始化:schema.sql 里必须有的几样东西
建库语句没什么特别,字符集统一utf8mb4,排序规则utf8mb4_general_ci就够。初始化脚本建议拆成两份:schema.sql只建表建索引,data.sql灌测试数据,生产环境只跑第一份。
# 1. 建库 mysql -uroot -p -e "CREATE DATABASE IF NOT EXISTS mealcard DEFAULT CHARSET utf8mb4;" # 2. 建表(脚本里包含了 t_card / t_trade / t_recharge / t_terminal) mysql -uroot -p mealcard < mealcard-server/src/main/resources/db/schema.sql # 3. 灌入测试数据:管理员账号、两台终端、三张测试卡 mysql -uroot -p mealcard < mealcard-server/src/main/resources/db/data.sqldata.sql里至少要准备三张卡,分别覆盖正常、挂失、余额不足三种场景,否则测不出来分支。测试卡的初始余额建议给 100.00 和 5.00 两个档,5 块那张专门用来验证「余额不足」提示。管理员账号的密码存 BCrypt 哈希,不要存明文,第一次登录后强制改密这个逻辑也要在初始化时就带上标记位。
索引方面,t_trade上除了uk_request_no,一定要有idx_card_time (card_no, trade_time)。查某张卡一个月流水时,没有这个复合索引,几万条数据就开始扫全表,报表页转圈十几秒。
4.3 启动验证:从登录到刷出第一笔流水
后端打包启动:
cd mealcard-server mvn clean package -DskipTests java -jar target/mealcard-server-1.0.0.jar --spring.profiles.active=dev前端两个工程分别起:
cd mealcard-admin && npm install && npm run dev # 默认 5173 cd mealcard-terminal && npm install && npm run dev # 默认 5174后台起来后先登录拿 token,再用 curl 直接打消费接口,绕开页面先把后端链路验通:
TOKEN=$(curl -s -X POST http://localhost:8080/api/auth/login \ -H "Content-Type: application/json" \ -d '{"username":"admin","password":"123456"}' | jq -r '.data.token') curl -X POST http://localhost:8080/api/trade/consume \ -H "Content-Type: application/json" \ -H "Authorization: Bearer $TOKEN" \ -d '{"cardNo":"2024010001","terminalNo":"W01","amount":12.50,"requestNo":"W01-20240501120000-001"}'第一次跑通要验证四件事,缺一件都算没跑完。第一,正常消费返回balance_after等于 100 - 12.5 = 87.50。第二,把同一个requestNo再打一遍,返回结果跟第一次一致,t_trade里只有一条记录。第三,拿余额 5 块那张卡消费 12.5,接口返回业务异常而不是 500。第四,去后台流水页能看到刚才那笔,时间跟本机时间一致。
提示:
requestNo每次测试都要换,否则你会以为接口坏了,其实是幂等生效了。我第一次测的时候在这个地方卡了十分钟。
5. 上线前排查:饭卡系统最容易翻车的五个地方
5.1 现象:某张卡余额变成负数
原因:扣款逻辑写成「先查后改」,两条语句之间有窗口,同一张卡在两台终端并发刷卡时都通过了余额判断。还有一种变体是UPDATE ... WHERE balance >= amount写对了,但事务隔离级别被改成了 READ UNCOMMITTED,读到脏数据。
解决:把判断条件塞进 UPDATE 的 WHERE 子句,用返回行数做判据,任何时候都不要在 Java 里判余额。事后补一条巡检 SQL,每天凌晨扫一遍SELECT card_no, balance FROM t_card WHERE balance < 0,出现即告警。同时对t_card加检查约束(MySQL 8.0.16 以上支持CHECK (balance >= 0)),多一层兜底。
5.2 现象:用户投诉充了 50 块,账上多了 100
原因:渠道回调重复到达,而回调处理里没做幂等,直接balance = balance + amount。也可能是订单表没有对order_no建唯一索引,同一订单创建了两次。
解决:回调处理改成「先推进订单状态,影响行数为 0 就直接返回」,状态更新和加余额放同一个事务。t_recharge.order_no加唯一索引,t_trade.request_no也加。上线前拿回调报文手动打十次,确认只有第一次生效。
5.3 现象:学生挂失十分钟后,还有窗口能刷出消费记录
原因:终端本地缓存了卡状态,缓存 TTL 设了 10 分钟,挂失操作只改了数据库,没有通知终端。另一种情况是终端离线消费先记账、联网后补传,补传时已经过了挂失时间。
解决:挂失成功后通过 Redis 发布订阅或 WebSocket 推送失效消息,终端立即清缓存。终端本地缓存 TTL 压到 30 秒以内。离线消费必须记本地队列,恢复联网后逐笔上传,服务端在补传接口里重新校验卡状态和余额,校验失败的那笔单独进异常队列由人工处理,不要静默丢弃。
5.4 现象:日结报表的总金额跟实际差 8 小时,跨天数据全乱
原因:JDBC URL 没写serverTimezone,或者服务器是 UTC 而业务按北京时间切天。跨零点的消费被算到前一天或后一天,日限额也因此算错。
解决:连接串显式写serverTimezone=Asia/Shanghai,JVM 启动参数加-Duser.timezone=Asia/Shanghai,容器时区挂载/etc/localtime。日结的切分不要依赖NOW(),用明确的起止时间参数传入,[昨日 00:00:00, 今日 00:00:00)左闭右开。
5.5 现象:对账时零星差几分钱,怎么查都查不出来
原因:某个字段用了FLOAT或 Java 的double做金额运算,0.1 + 0.2 得到 0.30000000000000004,四舍五入后差一分。还有一种是BigDecimal用了new BigDecimal(0.1)构造,而不是字符串构造。
解决:全链路金额字段统一DECIMAL(12,2)+BigDecimal,BigDecimal一律用new BigDecimal("0.1")或BigDecimal.valueOf()构造。除法运算必须指定精度和舍入模式:amount.divide(count, 2, RoundingMode.HALF_UP)。补一条对账 SQL 每天跑,等式不成立就报警:
SELECT (SELECT IFNULL(SUM(amount), 0) FROM t_trade WHERE trade_type IN (2, 4)) AS 累计入账, (SELECT IFNULL(SUM(amount), 0) FROM t_trade WHERE trade_type = 1) AS 累计消费, (SELECT IFNULL(SUM(balance), 0) FROM t_card WHERE status <> 3) AS 当前余额合计, (SELECT IFNULL(SUM(amount), 0) FROM t_trade WHERE trade_type IN (2, 4)) - (SELECT IFNULL(SUM(amount), 0) FROM t_trade WHERE trade_type = 1) - (SELECT IFNULL(SUM(balance), 0) FROM t_card WHERE status <> 3) AS 差额;差额长期为 0 才算账平。如果不为 0,先按天分组缩小范围,再按card_no定位到具体卡片,通常十分钟内能锁定问题记录。
6. 二次开发:把通用饭卡系统改成你单位能用的样子
接手一套通用实现之后,真正花时间的往往不是改代码,是搞清哪些改动会影响账务正确性。我一般按「动表 → 动接口 → 动页面 → 跑对账」的顺序推,下面这几个改造点是同类项目里最高频的:
| 改造需求 | 涉及的表 | 涉及接口 | 主要风险 |
|---|---|---|---|
| 补贴/餐补按月发放 | 新增t_subsidy_batch,t_trade复用类型 4 | 新增批量发放接口 | 重复发放、发放到已注销卡 |
| 二维码/人脸消费 | t_card增加credential_type、credential_no | 消费接口入参从cardNo改为凭证解析 | 凭证被截图复用、解析超时 |
| 多校区/多食堂隔离 | 各表加org_id | 所有查询增加机构过滤 | 漏加过滤条件导致跨机构查询 |
| 报表导出 | 不改表 | 新增流式导出接口 | 一次性 load 全量导致 OOM |
以补贴发放为例,最容易翻车的是「重复点了一次发放按钮」。做法跟充值完全一样——批次表加唯一键(org_id + period + batch_type),批次状态 0 待发放、1 已发放,推进状态用条件 UPDATE:
@Transactional(rollbackFor = Exception.class) public void grantSubsidy(Long batchId) { // 条件更新做幂等:只有 0 -> 1 的那一次才真正发钱 int locked = batchMapper.markGranting(batchId); if (locked == 0) { throw new BizException("该批次已发放或不存在"); } List<SubsidyItem> items = batchMapper.listItems(batchId); for (SubsidyItem item : items) { // 逐张卡加钱 + 写流水,金额用 DECIMAL,不用 double cardMapper.addBalance(item.getCardNo(), item.getAmount()); BigDecimal after = cardMapper.selectBalance(item.getCardNo()); tradeMapper.insert(Trade.ofSubsidy(item, after)); } batchMapper.markGranted(batchId); }批量发放的坑在「部分成功」。如果循环到第 500 张卡时抛异常,事务回滚,前面 499 张白做;如果为了性能把循环拆成多个事务,又会出现发了一半的情况。我的做法是在毫秒级可控的规模内(几千张卡)放一个事务里,超过这个量就改成「逐条 + 明细状态位」,每条明细记录自己的发放状态,失败可续发。别用异步线程池硬扛,账务操作宁可慢也别乱。
验证方式很朴素但最有效:改完之后跑三件事。第一,跑第 5 章那条对账 SQL,差额必须为 0。第二,用 JMeter 起 200 个并发对同一张卡各扣 1 元,卡里放 100 元,最后余额必须是 0 且失败请求恰好 100 个,绝不能出现负数。第三,把新增接口的入参全用非法值打一遍——负数金额、空卡号、超长字符串、重复 requestNo,看有没有 500 或者脏数据落库。
从那以后我每次动账务相关的代码,哪怕只是加一个字段,都会强制走一遍「对账 + 并发扣款 + 非法参数」这三板斧,宁可多花半小时,也不想半夜被电话叫起来查一分钱去哪了。希望帮到你。
本文还有配套的精品资源,点击获取