简介:这套饭卡管理系统是面向编程初学者的课程设计项目,定位于校园场景下学生与教职工的饭卡充值、余额查询和消费记录管理,非常适合作为软件工程或数据库课程的结课作业参考。压缩包共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, version |
| t_trade_flow | 交易流水,所有资金变动的明细 | trade_no, card_no, trade_type, amount, balance_after |
| t_recharge_order | 充值订单,处理充值回调幂等 | order_no, card_no, pay_status, amount |
| t_subsidy_batch | 补贴批次,防止补贴重复发放 | batch_no, amount, scope, status |
| t_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) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 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) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 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.1+0.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 就行。但这里有一个时间窗口:如果用户正在窗口刷卡消费,同时另一个窗口在办理挂失,两个请求可能同时读到 status=0,消费请求比挂失请求先提交,结果卡已经挂失了,钱还是被扣了。
@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 status=1 WHERE card_no=?,这条 UPDATE 本身也会加锁,但锁的获取顺序和消费方法不一定一致,仍然可能出现两边同时读到旧状态的情况。统一都用同一行锁,谁先拿到锁谁先执行,问题自然就没了。
还有一种更省事的做法,是让挂失和消费都走同一个 service,内部先锁再分发逻辑,但那样耦合度太高。我一般保留两个方法,但保证锁的是同一行。
4.2 补办卡:余额迁移不能只改卡号
补办卡的典型操作是:旧卡注销,新卡拿到旧卡余额。常见错误是直接把 t_card_account 里的 card_no 改成新卡号,然后 status 改回 0。这样做的后果是历史流水全部跟着变成了新卡号,以后查旧卡的消费记录全都对不上,审计时非常被动。
正确的做法是旧卡行 status 置为 2 注销,新卡新建一行,余额复制过去,再补一条 trade_type=4 的迁移流水,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. 按范围查出所有正常状态的账户 List<CardAccount> 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 或 double,SQL 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_type=2 的退款或调整流水,让余额变化重新回到流水框架内。
我从那以后给自己的规矩是:每周至少跑一次第二组 SQL,把 diff 大于 0.01 的卡全列出来,哪怕是 0.02 元也要查清原因。这个习惯帮我错掉了很多低级错误,也让你在领导问“为什么账对不上”的时候,能直接甩出一张定位到卡号的差异清单。希望帮到你。
本文还有配套的精品资源,点击获取