饭卡管理系统开发实战:数据库设计、并发扣费与每日平账
简介这套饭卡管理系统是面向编程初学者的课程设计项目定位于校园场景下学生与教职工的饭卡充值、余额查询和消费记录管理非常适合作为软件工程或数据库课程的结课作业参考。压缩包共93个文件包括7个Java源文件、46个编译后的class文件、6个表单设计、4个XML配置、3个properties配置、2个MDB数据库文件以及多份doc文档和Visio图表整体仅2.57MB目录结构清晰便于检索。项目覆盖关系型数据库表设计、用户界面搭建、后端接口开发、事务的ACID特性、密码加密与SQL注入防护、单元测试和调试等关键环节并配有软件工程课程设计所需的需求分析文档、设计说明与图表可帮助学习者理解从需求分析、编码实现到测试维护的完整流程。目前已有440人学习下载对于希望动手实践并完成课程报告与答辩的初学者而言兼具代码参考、文档示范与学习指引价值。1. 饭卡管理系统先拆成“卡、账户、流水”三个对象再写代码饭卡管理系统这个名字听起来像是课程设计常客真把它当黑匣子拆一遍会发现难点不在 JSP 页面也不在刷卡机对接而在余额、流水、状态的一致性。业务动作看着就那么几个发卡、充值、消费、挂失、补办、补贴可每一个动作都要在账户余额上留下痕迹还必须在并发请求和重复回调下做到不重不漏。这套系统适合两类人一类是交课程设计或毕业设计的学生另一类是想把食堂手工台账搬到线上的管理人员。下面按我拆过的一个典型 Java MySQL MyBatis 实现来写顺着表结构、扣费事务、挂失补办和每日平账往下走每一处都是能直接抄的细节。2. 数据库设计用五张表把充值、消费、挂失、补贴串起来饭卡系统的表结构设计其实比代码更重要。代码写得烂还能重构表字段缺了、类型选错了后面每次改动都要牵连一堆 SQL 和接口。我看过不少翻车项目问题几乎都出在同一个地方余额被当成普通字段随手改流水只存了个金额没有方向挂失状态没有和消费状态串起来。2.1 先按业务动作定表和谁相关、字段放哪我一般不会按页面去建表而是按业务动作倒推。发卡意味着新增一个卡账户充值意味着余额增加并有一条充值流水消费意味着余额减少并有一条消费流水挂失意味着账户状态变化补贴意味着批量增加若干账户余额。表名职责关键字段t_card_account卡账户余额和状态的唯一出处card_no, balance, status, versiont_trade_flow交易流水所有资金变动的明细trade_no, card_no, trade_type, amount, balance_aftert_recharge_order充值订单处理充值回调幂等order_no, card_no, pay_status, amountt_subsidy_batch补贴批次防止补贴重复发放batch_no, amount, scope, statust_subsidy_detail补贴明细记录每个账户发了多少batch_no, card_no, amount有人会把持卡人信息单独拆成一张 t_user再挂 user_id 到卡账户上。这种做法在人员字段很多的场景下没问题但食堂系统里真正有用的信息就姓名、部门、卡号、余额这几个合并成一张卡账户表反而能少两次 join报表查询也更快。2.2 核心表 DDL字段、索引、类型怎么定两张核心表我给出可以直接执行的建表语句另外三张辅助表在后面说明字段要点。CREATE TABLE t_card_account ( id BIGINT PRIMARY KEY AUTO_INCREMENT, card_no VARCHAR(20) NOT NULL UNIQUE COMMENT 卡号通常取物理卡面编号, user_name VARCHAR(50) NOT NULL COMMENT 持卡人姓名报表冗余字段, dept_name VARCHAR(50) DEFAULT COMMENT 部门/院系用于补贴和统计, balance DECIMAL(10,2) NOT NULL DEFAULT 0.00 COMMENT 当前余额单位元, status TINYINT NOT NULL DEFAULT 0 COMMENT 0正常 1挂失 2注销, version INT NOT NULL DEFAULT 0 COMMENT 乐观锁版本号, create_time DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, update_time DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, KEY idx_dept_status (dept_name, status) ) ENGINEInnoDB DEFAULT CHARSETutf8mb4 COMMENT卡账户表; CREATE TABLE t_trade_flow ( id BIGINT PRIMARY KEY AUTO_INCREMENT, trade_no VARCHAR(32) NOT NULL UNIQUE COMMENT 业务流水号yyyyMMddHHmmss加随机, card_no VARCHAR(20) NOT NULL COMMENT 卡号, trade_type TINYINT NOT NULL COMMENT 1充值 -1消费 2退款 3补贴, amount DECIMAL(10,2) NOT NULL COMMENT 带符号金额正为入账、负为出账, balance_after DECIMAL(10,2) NOT NULL COMMENT 交易后余额用于核对, channel VARCHAR(20) DEFAULT COMMENT 窗口/自助机/线上充值, trade_time DATETIME NOT NULL, remark VARCHAR(255) DEFAULT , KEY idx_card_time (card_no, trade_time), KEY idx_trade_time (trade_time) ) ENGINEInnoDB DEFAULT CHARSETutf8mb4 COMMENT交易流水表;t_recharge_order 的核心字段是 order_no、card_no、pay_status、amount、paid_time其中 pay_status 用 0 待支付、1 支付成功、2 已入账、3 已关闭 四个状态区分t_subsidy_batch 依靠 batch_no 唯一键防止重复执行t_subsidy_detail 则用 batch_no 和 card_no 组成唯一索引避免同一个批次给同一个人发两次。2.3 字段取舍Decimal、带符号金额、status 与 version余额类型必须用 DECIMAL(10,2)不要用 float 或者 double。浮点数在 Java 里做减法会出现 0.10.2 不等于 0.3 的问题对账的时候差几分钱非常难受。如果系统体量大、单表数据过千万也可以把金额改成以分为单位的 BIGINT但在这种规模的饭卡系统里DECIMAL 已经足够。交易流水的 amount 我习惯存带符号金额充值存正数消费存负数补贴存正数退款存正数。这样对账时直接 SUM(amount) 就能得到账户余额变化量不用再根据 trade_type 分支出计算。还有一个反面教训有人把消费金额也存成正数靠 type 去区分方向写报表时每条 SQL 都要 CASE WHEN平账脚本复杂一倍这是得不偿失的。status 必须给默认值 0并且只在代码里通过业务动作修改禁止手工改库。version 字段是给乐观锁用的如果项目全程用行锁version 也可以不维护但保留一个并不吃亏后面做批量补贴导入时会用到。提示t_card_account 的 user_name 属于冗余字段。这个系统里查询报表的场景远多于写数据的场景冗余一个姓名可以避免反复 join代价是修改姓名时多维护一处。3. 扣费与充值链路事务边界、行锁和幂等怎么落地表结构立起来之后下一个要解决的问题是一笔消费到底怎么扣钱才可靠。这里的“可靠”有三个含义并发扣款不会把余额扣成负数挂失之后卡不能再消费每一笔扣款都有流水可查。3.1 扣费这个动作代码要怎么写很多人写饭卡项目时是这样做的先 SELECT 查余额判断够不够再 UPDATE 扣钱。这在单用户、单线程的演示里没问题一旦两个窗口同时刷同一张卡两个请求都读到余额 50都认为可以扣 30最后余额变成 20而不是预期的负数被拦截。解决办法是让扣款变成一个原子动作。Service public class CardService { Resource private CardAccountMapper accountMapper; Resource private TradeFlowMapper flowMapper; /** * 刷卡消费扣款 * param cardNo 卡号 * param amount 消费金额元 * param channel 渠道窗口/自助机 */ Transactional(rollbackFor Exception.class) public TradeFlow consume(String cardNo, BigDecimal amount, String channel) { // 1. 锁定账户行并发请求在这里排队 CardAccount account accountMapper.selectByCardNoForUpdate(cardNo); if (account null) { throw new BizException(卡不存在); } // 2. 状态校验必须在锁内做 if (account.getStatus() 1) { throw new BizException(卡已挂失无法消费); } if (account.getStatus() 2) { throw new BizException(卡已注销); } // 3. 余额比较用 compareTo不要用 if (account.getBalance().compareTo(amount) 0) { throw new BizException(余额不足); } // 4. 条件更新直接扣减 accountMapper.deductBalance(cardNo, amount); // 5. 写一条消费流水方向和余额都要记录 TradeFlow flow new TradeFlow(); flow.setTradeNo(generateTradeNo()); flow.setCardNo(cardNo); flow.setTradeType(-1); flow.setAmount(amount.negate()); flow.setBalanceAfter(account.getBalance().subtract(amount)); flow.setChannel(channel); flow.setTradeTime(new Date()); flowMapper.insert(flow); return flow; } }这段代码里最关键的是第 1 步的selectByCardNoForUpdate。MyBatis 对应的 SQL 是SELECT * FROM t_card_account WHERE card_no #{cardNo} FOR UPDATEFOR UPDATE 会对命中的行加排他锁直到事务提交才释放。两个并发扣款请求同时进来时第二个请求会阻塞等第一个事务提交后再读此时读到的是已经扣减后的余额所以不可能出现负数。第 4 步的deductBalance是条件更新SQL 里也带上了balance amount和status 0两个条件作为锁之外的第二道防线即使锁失效也不会把余额扣成负数。参数上金额一律用 BigDecimal外部传入字符串再转换避免前端浮点精度问题扩散到后端。3.2 锁的选型行锁和乐观锁分别用在哪饭卡系统的扣费场景我基本只用悲观锁也就是 FOR UPDATE。原因是消费是高频小事务锁的持有时间极短冲突概率不高悲观锁逻辑简单代码里也不容易出现并发漏洞。乐观锁适合批量操作的场景比如后面要讲的补贴发放。它的思路是每次 UPDATE 时带上 version 条件更新成功后 version 加一如果更新行数为 0说明版本已被别人改过需要重试或报错。对于批量任务来说悲观锁要一个个锁行慢且容易造成长事务乐观锁直接执行 UPDATE冲突了再来一次性能更好。对比项悲观锁 FOR UPDATE乐观锁 version适用场景高频扣费、窗口消费批量补贴、批量导入事务时长短事务锁持有时间短可以不用长事务冲突处理排队等待更新行数为 0立即感知实现成本一条 SQL 加注解多一个 version 字段如果选择了悲观锁有一点必须注意锁必须放在事务里且事务不能提前提交。Spring 的 Transactional 默认在方法结束后提交所以整个扣款逻辑必须写在一个方法里不能把查询锁、扣款、写流水拆成三个无事务的 service 方法否则锁在第一个方法返回时就释放了等于白锁。3.3 充值链路订单先行、回调幂等充值比消费多一个环节它要跟第三方支付打交道。充值链路如果设计不好最常见的坑是支付回调重复发送。微信和支付宝的支付回调都会重试多次如果每次回调都直接给余额增加金额用户充 100 元可能被加钱加两次。Transactional(rollbackFor Exception.class) public void handlePayCallback(String orderNo, BigDecimal paidAmount) { // 1. 锁订单行防止并发回调同时处理 RechargeOrder order orderMapper.selectByOrderNoForUpdate(orderNo); if (order null) { throw new BizException(订单不存在); } // 2. 已经入账过的订单直接返回保证幂等 if (order.getPayStatus() 1) { return; } if (order.getPayStatus() 3) { throw new BizException(订单已关闭); } // 3. 金额校验支付金额必须和订单金额一致 if (order.getAmount().compareTo(paidAmount) ! 0) { throw new BizException(支付金额与订单金额不一致); } // 4. 更新订单状态增加余额写流水三个动作一个事务 orderMapper.markPaid(orderNo, new Date()); accountMapper.increaseBalance(order.getCardNo(), order.getAmount()); flowMapper.insert(buildRechargeFlow(order)); }这里核心是第 1 步和第 2 步。先对订单行加锁再判断 pay_status。第一个回调进来时状态是 0会正常入账第二个回调进来时要么在锁上等待要么读到状态已经是 1直接 return。很多项目把幂等判断放在 Redis 里做但饭卡这种系统用数据库订单状态就够了少一个中间件就少一个故障点。buildRechargeFlow 方法里需要构造 trade_type 为 1、amount 为正数的流水balance_after 用订单金额加充值前余额计算。4. 挂失、补办与补贴状态流转和时间窗口的坑扣费和充值属于高频主链路大家都会认真写。挂失和补贴这种低频操作反而容易偷懒但正是这些低频操作最容易把数据搞乱。挂失涉及状态流转补贴涉及批量操作两个都有一不留神就埋雷的细节。4.1 挂失与解挂必须和消费用同一把锁挂失的代码看起来很简单把 status 改成 1 就行。但这里有一个时间窗口如果用户正在窗口刷卡消费同时另一个窗口在办理挂失两个请求可能同时读到 status0消费请求比挂失请求先提交结果卡已经挂失了钱还是被扣了。Transactional(rollbackFor Exception.class) public CardAccount freezeCard(String cardNo) { // 和消费方法使用同一把行锁让挂失与消费串行执行 CardAccount account accountMapper.selectByCardNoForUpdate(cardNo); if (account null) { throw new BizException(卡不存在); } if (account.getStatus() ! 0) { throw new BizException(卡当前状态不允许挂失); } accountMapper.updateStatus(cardNo, 1); return account; }关键点是挂失方法里也要执行selectByCardNoForUpdate而不是只执行一条 UPDATE。如果只写UPDATE t_card_account SET status1 WHERE card_no?这条 UPDATE 本身也会加锁但锁的获取顺序和消费方法不一定一致仍然可能出现两边同时读到旧状态的情况。统一都用同一行锁谁先拿到锁谁先执行问题自然就没了。还有一种更省事的做法是让挂失和消费都走同一个 service内部先锁再分发逻辑但那样耦合度太高。我一般保留两个方法但保证锁的是同一行。4.2 补办卡余额迁移不能只改卡号补办卡的典型操作是旧卡注销新卡拿到旧卡余额。常见错误是直接把 t_card_account 里的 card_no 改成新卡号然后 status 改回 0。这样做的后果是历史流水全部跟着变成了新卡号以后查旧卡的消费记录全都对不上审计时非常被动。正确的做法是旧卡行 status 置为 2 注销新卡新建一行余额复制过去再补一条 trade_type4 的迁移流水amount 为旧卡余额balance_after 记新卡当前余额。原卡流水保留原卡号新卡流水从“补办迁移”开始两条路径各自清晰。Transactional(rollbackFor Exception.class) public void replaceCard(String oldCardNo, String newCardNo) { CardAccount oldAccount accountMapper.selectByCardNoForUpdate(oldCardNo); if (oldAccount null || oldAccount.getStatus() ! 0) { throw new BizException(旧卡不存在或状态不允许补办); } // 旧卡注销 accountMapper.updateStatus(oldCardNo, 2); // 新卡开卡余额复制 CardAccount newAccount new CardAccount(); newAccount.setCardNo(newCardNo); newAccount.setUserName(oldAccount.getUserName()); newAccount.setDeptName(oldAccount.getDeptName()); newAccount.setBalance(oldAccount.getBalance()); accountMapper.insert(newAccount); // 迁移流水 flowMapper.insert(buildReplaceFlow(oldAccount, newAccount)); }注意第 1 步锁的是旧卡行而不是新卡行因为判断条件是旧卡的状态和余额锁旧卡才能保证迁移过程中没有人消费旧卡余额。4.3 补贴发放批次幂等和逐笔流水补贴发放是典型的批量操作。最常见的问题是管理员手抖点了两次“发放”结果每人收到双份补贴。为了防止重复我设计了 t_subsidy_batch 和 t_subsidy_detail 两张表批次表靠 batch_no 唯一键拦截重复。Transactional(rollbackFor Exception.class) public void grantSubsidy(String batchNo, BigDecimal amount, String scope) { // 1. 先插入批次batch_no 有唯一索引重复插入会失败 SubsidyBatch batch new SubsidyBatch(); batch.setBatchNo(batchNo); batch.setAmount(amount); batch.setScope(scope); batchMapper.insertIfAbsent(batch); if (batch.getId() null) { throw new BizException(批次已存在请勿重复发放); } // 2. 按范围查出所有正常状态的账户 ListCardAccount accounts accountMapper.selectNormalByScope(scope); for (CardAccount account : accounts) { // 3. 每人增加余额并写一条补贴流水 accountMapper.increaseBalance(account.getCardNo(), amount); flowMapper.insert(buildSubsidyFlow(account, batch)); } // 4. 批次状态置为已执行 batchMapper.markExecuted(batchNo); }这里有一个细节每次循环里 accountMapper.increaseBalance 和 flowMapper.insert 没有单独开启事务它们都被包含在 grantSubsidy 这个方法的大事务里所以不会出现余额加了但流水没写的半截状态。如果补贴人数上万这种写法会撑起一个很大的事务可以改成每处理 500 人提交一次但饭卡场景几千人规模直接一把梭即可。注意markExecuted 这一步不是幂等的关键关键在 batch_no 唯一索引。哪怕程序在发放到一半时崩溃重启后重新提交同一 batchNo也会因为唯一键冲突直接抛异常不会重复发放。5. 避坑指南饭卡系统最常见的翻车现场这部分内容是线下带项目时积累的血泪经验。每个坑我都见过不止一次前三个是并发和重复问题后两个是使用习惯和边界条件问题按现象、原因、解决三个层次写方便你直接对照排查。5.1 并发扣款与挂失竞态现象同一张卡在上午 10 点被两个窗口同时消费扣费后余额变成负数另一类现象是卡已经挂失了系统里却还产生了一笔消费记录。原因消费方法没有加行锁两个请求同时读到相同余额再各自扣减挂失和消费没有统一事务边界消费请求晚于挂失提交但早于挂失读取状态。解决消费和挂失都使用selectByCardNoForUpdate锁同一账户行扣款的 UPDATE 语句加balance #{amount}条件做第二层保险挂失接口校验状态时同样在锁内完成。注意锁必须在同一个事务方法内使用不能跨方法。5.2 重复入账、浮点误差和手工改库现象用户充值 100 元支付回调因为网络抖动重试余额增加了 200 元对账时发现余额和流水总额差几分钱。原因回调没有做幂等判断pay_status 状态没查就重复入账余额字段用了 float 或 doubleSQL SUM 出来的金额和 Java 计算的结果对不上。解决充值回调先锁订单行再查 pay_status已入账直接返回金额类型统一用 DECIMAL(10,2) 或者以分为单位的整数禁止手工执行UPDATE t_card_account SET balance...所有余额变动必须生成对应流水。如果已经发生手工改库只能通过平账流水把差额补平再在日报中留痕。5.3 离线设备与黑名单不同步现象食堂消费机断网期间用户照常刷卡余额没扣或者卡已经挂失但离线机器上还能消费。原因消费机离线时自带本地余额网络恢复后才把流水上传。挂失操作只改了数据库状态没有把黑名单下发到设备或者设备在下发前已经消费成功。解决接入真实消费机时网络恢复后的第一批工作不是直接合流水而是先做黑名单比对把挂失时间早于消费时间的记录单独挑出来处理数据库里给卡账户加一个黑名单时间戳比如挂失时间离线回灌时用挂失时间 消费时间作为有效消费的判断条件。如果只是课设阶段的软键盘模拟消费则不存在这个坑但接口设计上预留一个syncBlacklist方法能省不少事。6. 每日平账用三条 SQL 把余额和流水核对清楚前面所有代码都是在写业务但上线后真正能救你的是平账脚本。饭卡系统运行一段时间后余额、流水、订单三者之间一定会出现差异差异不可怕可怕的是你找不到差异出在哪张卡上。我习惯每天凌晨跑一遍这三组 SQL。第一组核对当天流水总额和账户余额增量SELECT (SELECT IFNULL(SUM(amount),0) FROM t_trade_flow WHERE trade_time 2024-11-01 AND trade_time 2024-11-02) AS today_flow_sum, (SELECT IFNULL(SUM(balance),0) FROM t_card_account WHERE update_time 2024-11-02) - (SELECT IFNULL(SUM(balance),0) FROM t_card_account WHERE update_time 2024-11-01) AS balance_diff;如果 today_flow_sum 不等于 balance_diff说明当天存在没有走流水就直接改余额的操作。注意余额增量统计口径要一致t_card_account 的 update_time 在每次余额变更时自动更新所以截取 2024-11-02表示 11 月 1 日 23:59:59 前的最终状态。第二组直接找出“余额与历史流水对不上”的卡号SELECT c.card_no, c.balance, IFNULL(SUM(f.amount),0) AS flow_balance, c.balance - IFNULL(SUM(f.amount),0) AS diff FROM t_card_account c LEFT JOIN t_trade_flow f ON f.card_no c.card_no GROUP BY c.card_no, c.balance HAVING ABS(diff) 0.01;这个查询的前提是系统上线时所有账户余额从 0 开始且之后每一笔变动都写了流水。它能把手工改余额、漏写流水、补办迁移错误等问题直接定位到具体卡号。如果你在项目里保留过期初余额字段则应该改成expected init_balance SUM(amount)口径更准确。第三组是核对待平账之后的人工兜底操作。对不平的卡逐张查看它的流水详情看 trade_time、channel、amount 三个字段是否与充值订单或消费终端日志一致。大多数差异最终的结论都是“某天手工改过余额”这时候不要直接 UPDATE 余额而是补一条 trade_type2 的退款或调整流水让余额变化重新回到流水框架内。我从那以后给自己的规矩是每周至少跑一次第二组 SQL把 diff 大于 0.01 的卡全列出来哪怕是 0.02 元也要查清原因。这个习惯帮我错掉了很多低级错误也让你在领导问“为什么账对不上”的时候能直接甩出一张定位到卡号的差异清单。希望帮到你。本文还有配套的精品资源点击获取