ARTICLE · INTELLIGENCE

战地情报 · 详情页

来自尧图项目组的一线实战观察与深度解析

Java超市积分管理系统实战:数据库设计与事务处理全解析

Java超市积分管理系统实战:数据库设计与事务处理全解析 简介面向Java Web学习者和高校毕设学生的超市积分管理系统完整项目资料包以会员积分、商品管理等典型业务场景为线索帮助读者掌握从需求分析到编码实现的全流程。压缩包仅18.21MB共5个文件其中sql为数据库脚本、doc为项目报告、两个zip分别存放源代码与项目截图另有txt说明文件结构清晰、即下即用。内容覆盖MVC设计模式、Servlet/JSP交互、JDBC数据库访问及DAO层封装等关键知识点适合作为课程设计或毕业设计的参考范本。资源提供完整源码、数据库建表脚本、项目报告与答辩PPT报告详述了需求分析、系统设计与测试过程PPT则提炼技术选型与功能模块便于梳理答辩思路。目前已有360人学习下载可在短期内快速搭建并二次开发尤其适合用来理解Java Web经典分层架构与数据库操作实践。1. 先别急着解压源码超市积分管理系统到底在解决什么问题很多同学拿到「基于Java的超市积分管理系统设计与实现」这个标题第一反应是解压源代码跑起来。我劝你先停手——这个题目真正的难点不在代码在数据库和规则设计。它要解决的问题很具体顾客办会员卡收银结账按规则累积积分会员拿积分兑换商品后台还要管积分过期和流水对账。说白了就是一个小型积分账本表面是CRUD内里是事务、锁和数据一致性。对于在做Java课程设计、数据库课程设计或刚学完Spring Boot/MyBatis想练手的人正好把「设计→实现→避坑→答辩」完整串一遍。下面直接按我做课设的完整路线讲读完后你能少踩一半的坑。2. 数据库设计先行先把积分账本的五张表落下来代码可以慢慢迭代但表结构错了后面全要返工。超市积分系统的数据流本身很清晰消费进来一笔钱按规则变成积分积分出去变成一件商品。只要把「进来」和「出去」两条链路用表记录下来功能就完成了一半。所以我拿到项目报告的第一件事不是写代码而是把ER图和数据字典画清楚。这一步做扎实了后面的源代码写起来就是照着表搬数据。2.1 角色与核心流程会员、收银员、管理员三条线怎么交汇系统里的角色就三类别急着加权限先把各自要做的事列清楚。收银员在收银台操作负责办卡、结算、现场兑换管理员在后台维护积分规则、查流水、处理异常会员是积分数据的来源每次消费和兑换都会在他的积分账户上留下一笔记录。三条线交汇的地方就是那张积分流水表——谁、在什么时间、因为哪笔业务、积分变了多少、变动后余额是多少。核心流程我用四个字概括办、消、兑、清。办卡时系统生成唯一会员编号初始积分为零消费时按规则计算本单应得积分并写入账户兑换时校验积分余额和商品库存两边同时扣减积分过期时由定时任务批量清零并留痕。这里有一个容易被忽略的设计点积分清零也必须有流水记录。否则会员来投诉说「我积分怎么没了」你连一条证据都拿不出来对账全是糊涂账。把这条流水设计进去答辩时主动讲出来评委一般都会认可这个细节。2.2 建库建表member、product、rule、transaction、exchange一张不少我见过不少课设把积分直接塞进会员表商品表单独一张积分变动完全不留历史最后演示的时候功能倒是能跑但评委一问「怎么证明这笔积分没送重复」就哑火。这五张表是这套系统的地基直接复制到你的初始化SQL里就能用-- 1) 会员表points 用 DECIMAL绝不用 double CREATE TABLE member ( id BIGINT PRIMARY KEY AUTO_INCREMENT, member_no VARCHAR(32) NOT NULL UNIQUE COMMENT 会员编号, name VARCHAR(50) NOT NULL COMMENT 会员姓名, phone VARCHAR(20) DEFAULT NULL COMMENT 手机号, points DECIMAL(12,2) NOT NULL DEFAULT 0.00 COMMENT 当前可用积分, version INT NOT NULL DEFAULT 0 COMMENT 乐观锁版本号, create_time DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, KEY idx_member_phone (phone) ) ENGINEInnoDB DEFAULT CHARSETutf8mb4 COMMENT会员表; -- 2) 商品表现金价格和兑换积分分开存 CREATE TABLE product ( id BIGINT PRIMARY KEY AUTO_INCREMENT, name VARCHAR(100) NOT NULL, price DECIMAL(10,2) NOT NULL COMMENT 现金售价, exchange_points DECIMAL(12,2) NOT NULL COMMENT 兑换所需积分, stock INT NOT NULL DEFAULT 0, status TINYINT NOT NULL DEFAULT 1 COMMENT 1上架 0下架 ) ENGINEInnoDB DEFAULT CHARSETutf8mb4 COMMENT兑换商品表; -- 3) 积分规则表一条规则决定怎么送积分 CREATE TABLE points_rule ( id BIGINT PRIMARY KEY AUTO_INCREMENT, rule_name VARCHAR(50) NOT NULL COMMENT 规则名称, rule_type VARCHAR(20) NOT NULL COMMENT EARN消费送积分 SPEND兑换, min_amount DECIMAL(10,2) DEFAULT 0 COMMENT 消费满多少才积分, points_per_unit DECIMAL(10,4) NOT NULL COMMENT 每单位金额送多少分, valid_days INT DEFAULT 365 COMMENT 积分有效天数, status TINYINT DEFAULT 1 ) ENGINEInnoDB DEFAULT CHARSETutf8mb4 COMMENT积分规则表; -- 4) 积分流水表账本的核心所有积分变动都落这里 CREATE TABLE points_transaction ( id BIGINT PRIMARY KEY AUTO_INCREMENT, trade_no VARCHAR(64) NOT NULL COMMENT 业务单号幂等用, member_id BIGINT NOT NULL, type VARCHAR(20) NOT NULL COMMENT EARN/SPEND/EXPIRE, points_change DECIMAL(12,2) NOT NULL COMMENT 变动值支出为负, balance_after DECIMAL(12,2) NOT NULL COMMENT 变动后的余额快照, ref_no VARCHAR(64) DEFAULT NULL COMMENT 关联订单号/兑换单号, remark VARCHAR(255) DEFAULT NULL, create_time DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, KEY idx_member_time (member_id, create_time), UNIQUE KEY uk_trade_no (trade_no) ) ENGINEInnoDB DEFAULT CHARSETutf8mb4 COMMENT积分流水表; -- 5) 兑换记录表跟流水表互相对应 CREATE TABLE exchange_record ( id BIGINT PRIMARY KEY AUTO_INCREMENT, trade_no VARCHAR(64) NOT NULL, member_id BIGINT NOT NULL, product_id BIGINT NOT NULL, quantity INT NOT NULL DEFAULT 1, spent_points DECIMAL(12,2) NOT NULL, create_time DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, KEY idx_member (member_id) ) ENGINEInnoDB DEFAULT CHARSETutf8mb4 COMMENT积分兑换记录表;这套设计的核心逻辑是「余额可查、流水可溯」。member表只保存当前余额points_transaction表保存每一次变动两个表通过member_id关联。balance_after字段存的是这笔交易完成后会员的剩余积分这相当于给每笔流水拍了张快照以后做对账或数据修复直接看快照就能到推演整条链路。trade_no上建唯一索引是为了防止同一笔订单被重复入账这是积分系统里最隐蔽的坑——收银员手一抖多点一次提交积分就多送了一倍。参数上我特别解释两个地方。第一points用DECIMAL(12,2)而不用double是因为double是二进制浮点算0.10.2会出现0.30000000000000004积分账本里出现这种尾差对账直接崩。DECIMAL的精度由decimal(12,2)里的12和2决定能存到十亿级别课设场景完全够用。第二points_per_unit用DECIMAL(10,4)是因为规则可能是「消费满10元送1积分」换算下来每元送0.1分如果字段只留2位小数0.1还能存但遇到「满15元送2分」这种规则每元0.1333分就存不下了所以留4位小数做中间计算值。提示如果项目报告里要求画ER图别把五张表全画成孤立矩形。member到points_transaction是一对多product到exchange_record是一对多points_rule可以挂在member旁边当配置表。把这三条关系画清楚ER图这页基本就拿满了。2.3 积分过期怎么落地MySQL事件加存储过程比Java定时任务更省事积分都有有效期这是业务常识但很多课设代码里根本没实现答辩时被问到就露馅。我一般用「MySQL事件存储过程」来做过期清理而不是在Java代码里写定时任务。原因是数据库事件不依赖应用服务是否启动代码里少一套调度逻辑答辩时也容易解释就说「过期是数据库层定时任务跟业务代码解耦」。下面是完整脚本-- 开启事件调度器MySQL 8.0 默认是 OFF这条必须执行 SET GLOBAL event_scheduler ON; -- 存储过程把超期未用账户整体清零并写一条 EXPIRE 流水 DELIMITER $$ CREATE PROCEDURE sp_expire_points() BEGIN DECLARE v_id BIGINT; DECLARE v_points DECIMAL(12,2); DECLARE done INT DEFAULT 0; -- 游标取所有创建至今超过 365 天且仍有积分的会员 DECLARE cur CURSOR FOR SELECT id, points FROM member WHERE points 0 AND create_time DATE_SUB(NOW(), INTERVAL 365 DAY); DECLARE CONTINUE HANDLER FOR NOT FOUND SET done 1; OPEN cur; read_loop: LOOP FETCH cur INTO v_id, v_points; IF done 1 THEN LEAVE read_loop; END IF; -- 先落流水再清零方便对账 INSERT INTO points_transaction (trade_no, member_id, type, points_change, balance_after, remark) VALUES (CONCAT(EXP, DATE_FORMAT(NOW(), %Y%m%d%H%i%s), -, v_id), v_id, EXPIRE, -v_points, 0, 积分超期清零); UPDATE member SET points 0 WHERE id v_id; END LOOP; CLOSE cur; END$$ DELIMITER ; -- 每天凌晨 2 点执行一次 CREATE EVENT IF NOT EXISTS evt_expire_points ON SCHEDULE EVERY 1 DAY STARTS CURRENT_TIMESTAMP INTERVAL 1 DAY DO CALL sp_expire_points();这段脚本有几个值得注意的点。游标先查出来再逐条处理是因为MySQL的游标结果集在OPEN时就固定了不会因为循环里的UPDATE而重复读到同一行。先INSERT流水再UPDATE余额保证了「清零」这个动作有据可查哪怕UPDATE失败流水里也能看到系统尝试过这笔操作。trade_no用「EXP日期会员id」拼出来在刚才那张表的唯一索引约束下不会冲突。我要提醒一句边界这里的过期间隔写死为「注册时间起365天」是课设场景的简化方案。真实的超市积分系统是按每笔积分入账时间逐批过期的比如会员今天消费送的积分从今天算起一年内有效而不是整个账户统一过期。答案里你可以这样简化但要在报告里明确写一句「当前为简化方案生产环境需按积分批次过期」这反而显得你懂边界。3. 把源代码里的核心链路写明白消费送积分与积分兑换的实现技术选型我建议用Spring Boot MyBatis Thymeleaf这套组合不要碰SSH那套老古董。Spring Boot负责装配MyBatis把SQL握在自己手里Thymeleaf渲染页面这个组合在Java课程设计里最常见答辩时评委也熟悉不会被追问冷门框架的细节。源代码拿到手后先别急着全部读按层读controller接收请求、service里写事务、mapper写SQL思路立刻清晰。3.1 消费结算与积分入账事务边界要锁住流水一条不能少收银台结账是积分系统的入口也是并发压力最大的操作。一笔消费进来要做两件事给member表加积分给points_transaction表插流水。这两步必须在一个事务里要么都成功要么都回滚。我的核心代码长这样Service public class PointsService { private final MemberMapper memberMapper; private final PointsRuleMapper ruleMapper; private final PointsTransactionMapper txMapper; public PointsService(MemberMapper memberMapper, PointsRuleMapper ruleMapper, PointsTransactionMapper txMapper) { this.memberMapper memberMapper; this.ruleMapper ruleMapper; this.txMapper txMapper; } /** * 收银结算按消费金额计算应得积分并完成入账 */ Transactional(rollbackFor Exception.class) public void settleOrder(SettleRequest request) { // 1. 先读当前会员和当前生效规则 Member member memberMapper.selectById(request.getMemberId()); PointsRule rule ruleMapper.selectByType(EARN); // 2. 不满足最低消费门槛就返回 0 分 if (rule null || request.getAmount().compareTo(rule.getMinAmount()) 0) { return; } // 3. 积分 消费金额 * 每元送几分四舍五入保留 2 位 BigDecimal earned request.getAmount() .multiply(rule.getPointsPerUnit()) .setScale(2, RoundingMode.HALF_UP); if (earned.compareTo(BigDecimal.ZERO) 0) { return; } // 4. 乐观锁更新积分版本号冲突说明数据被改过 int rows memberMapper.addPointsByVersion( member.getId(), earned, member.getVersion()); if (rows 0) { throw new BusinessException(会员信息已变更请重新提交); } // 5. 写流水trade_no 用订单号保证幂等 PointsTransaction tx new PointsTransaction(); tx.setTradeNo(request.getOrderNo()); tx.setMemberId(member.getId()); tx.setType(EARN); tx.setPointsChange(earned); tx.setBalanceAfter(member.getPoints().add(earned)); tx.setRefNo(request.getOrderNo()); txMapper.insert(tx); } }这段代码的关键在Transactional注解和乐观锁的组合。Transactional(rollbackFor Exception.class)把整个方法包成一个事务第4步更新余额和第5步写流水只要有一个抛异常两步全部回滚数据库不会出现「积分加了但流水没记」的中间状态。这里注意rollbackFor必须指定因为Spring默认只在遇到RuntimeException时才回滚如果业务层抛的是自定义受检异常不指定rollbackFor就会导致事务不回滚。乐观锁对应的SQL是这样UPDATE member SET points points #{delta}, version version 1 WHERE id #{id} AND version #{oldVersion}影响行数为0说明version变了也就是这个会员的积分在读取之后被别的请求改过此时直接抛异常让用户重试而不是覆盖更新。这个场景放在收银台很合适——同一时间操作同一个会员的概率很低用version判断足够还省去了数据库行锁的开销。balanceAfter直接取「读到的旧积分新增积分」没有再查一次库是合理的性能取舍。3.2 积分兑换与库存扣减先锁会员行再锁商品行兑换是扣减操作比入账更怕并发问题。两个页面同时提交兑换请求会员积分本来是100分两个请求都读到100分一个扣80一个扣60最后余额变成负40这就是典型的并发翻车现场。兑换场景和消费场景不同兑换的并发量低但冲突概率高所以我在这里用悲观锁直接锁住数据库行/** * 积分兑换扣积分 扣库存任何一步失败全部回滚 */ Transactional(rollbackFor Exception.class) public ExchangeResult exchange(ExchangeRequest req) { // 1. 悲观锁锁定会员行防止并发兑换把积分刷成负数 Member member memberMapper.selectByIdForUpdate(req.getMemberId()); if (member null) { throw new BusinessException(会员不存在); } Product product productMapper.selectByIdForUpdate(req.getProductId()); if (product null || product.getStock() req.getQuantity()) { throw new BusinessException(库存不足); } BigDecimal cost product.getExchangePoints() .multiply(BigDecimal.valueOf(req.getQuantity())) .setScale(2, RoundingMode.HALF_UP); // 2. 积分校验用 compareTo别用 equals if (member.getPoints().compareTo(cost) 0) { throw new BusinessException(积分不足); } // 3. 扣积分、扣库存 memberMapper.addPoints(member.getId(), cost.negate()); productMapper.decreaseStock(product.getId(), req.getQuantity()); // 4. 写一条 SPEND 流水 一条兑换记录 PointsTransaction tx new PointsTransaction(); tx.setTradeNo(req.getExchangeNo()); tx.setMemberId(member.getId()); tx.setType(SPEND); tx.setPointsChange(cost.negate()); tx.setBalanceAfter(member.getPoints().subtract(cost)); tx.setRefNo(req.getExchangeNo()); txMapper.insert(tx); ExchangeRecord record new ExchangeRecord(); record.setTradeNo(req.getExchangeNo()); record.setMemberId(member.getId()); record.setProductId(product.getId()); record.setQuantity(req.getQuantity()); record.setSpentPoints(cost); exchangeMapper.insert(record); return new ExchangeResult(record.getId(), cost); }selectByIdForUpdate对应的是SELECT * FROM member WHERE id #{id} FOR UPDATE这会让数据库锁定这一行直到事务提交或回滚。期间其他事务想读或者改这行都得排队等着。注意加锁顺序我固定为先member后product这是防止死锁的关键。如果兑换A先锁member再锁product兑换B先锁product再锁member两边互相等对方释放就死锁了。统一顺序能从根本上绕开这类问题这个点值得在答辩时主动提一句。积分比较用了compareTo而不是equals这是一个非常容易忽略的细节。BigDecimal的equals方法会比较精度new BigDecimal(1.0)和new BigDecimal(1.00)用equals比较结果是false但它们的数值明明一样。金额计算过程中scale经常变化所以一律用compareTo比较数值大小。项目中凡是涉及金额、积分判断的地方我建议都用compareTo。3.3 答辩时怎么解释这套架构三层结构、事务与连接池的边界很多同学代码跑通了但被问「为什么分三层」就卡壳。这套系统的分层逻辑很清晰Controller层只接收参数和返回结果不写任何业务判断Service层放事务和业务规则比如算积分、校验库存Mapper层只跟SQL打交道。三层各干各的事好处是改动某一层不影响另外两层。比如想把积分规则从「每元送1分」改成「每10元送3分」只需要改points_rule表里的数据Java代码一行不用动这就是规则跟代码解耦的实际价值。连接池参数也值得在报告里写一笔。Spring Boot默认的HikariCP参数在课设环境够用但答辩老师如果问「为什么这么配」你要能答上来。我一般用这张表配置项推荐值说明maximum-pool-size10课设并发量小10个连接足够多了浪费内存minimum-idle2保留2个空闲连接避免冷启动延迟connection-timeout3000030秒拿不到连接就报错防止请求无限等待这里有个容易踩的认知误区连接池不是越大越好。每个连接都对应数据库的一个线程和一份内存你开50个连接数据库就得多分配50份资源反而拖慢性能。课设系统通常只有几个页面和几百条测试数据10个连接完全够用把计算资源留在业务逻辑上才是正解。4. 把课设跑通到能答辩4条避坑硬记录这一章每条都是我实际跑课设时踩过的坑不是玄学。现象、原因、解决三步写清楚你照着排查能省下大量调试时间。这些坑有一个共同特点代码编译期和普通功能测试时完全看不出来只有数据量上来或者并发操作时才会暴露。4.1 积分出现0.30000000000004double的精度坑现象积分余额出现一长串小数尾差比如200积分经过几次累加后变成200.00000000000003会员查询积分时页面显示一串奇怪的数字对账报表怎么都对不上。原因Java的double和数据库的float/double都是二进制浮点数二进制无法精确表示0.1这种十进制小数所以0.10.2的结果是0.30000000000000004。积分账本对精度极其敏感任何尾差都会在汇总统计时被放大。解决数据库字段全部用DECIMALJava代码里所有积分和金额变量用BigDecimal计算时统一setScale(2, RoundingMode.HALF_UP)保留两位小数。我给自己定的代码规范是项目里出现float和double处理金额的直接打回重写。这条规则帮我避掉了后面报表模块的大量返工。4.2 并发兑换把积分扣成负数忘了加锁现象用两个浏览器标签页同时登录同一个会员账号同一秒提交两个兑换请求每个请求都校验通过了最后积分余额变成负数。原因校验积分和扣减积分是两条独立的SQL两个请求同时读取积分余额时都读到100分各自都认为可以扣80分先各自扣完最后数据库里就出现了负数。这就是典型的读-改-写竞态条件问题出在「先查询再更新」的间隙。解决两个办法任选其一。一是用SELECT ... FOR UPDATE悲观锁锁住会员行串行化处理同一个会员的兑换请求二是直接用条件更新SQL让数据库自己判断余额是否足够UPDATE member SET points points - #{cost} WHERE id #{id} AND points #{cost}这条SQL把「检查余额」和「扣减积分」合并成一条原子操作数据库层面保证不会扣成负数影响行数为0就说明余额不足。如果还要同时扣库存记得把这条UPDATE和插流水、扣库存放在同一个事务里。4.3 写好了定时事件却一次都不跑事件调度器没开现象存储过程和事件都创建成功了SHOW EVENTS也能看到记录但积分就是不过期手动调用存储过程又能正常执行。原因MySQL 8.0的event_scheduler参数默认是OFF事件虽然创建了但调度器根本没启动自然不会执行定时任务。很多新手不知道这个开关排错排半天。解决执行SET GLOBAL event_scheduler ON;开启调度器用SHOW VARIABLES LIKE event_scheduler;确认状态是ON。注意这个设置是临时的MySQL重启后失效要在my.cnf的[mysqld]段加一行event_schedulerON永久生效。另外如果一个事件卡住了可以用ALTER EVENT evt_expire_points DISABLE;先停掉再排查不用删掉重建。4.4 本地能跑、答辩机器上连不上时区与连接串的玄学现象项目在本地运行正常传到答辩电脑上启动时报The server time zone value ?й??????? is unrecognized或者中文乱码偶尔还报Public Key Retrieval is not allowed看着像数据库连接串的问题但不知道动哪里。原因MySQL 8.0对时区很敏感服务器默认时区不是中国时区连接串缺了编码参数导致中文乱码allowPublicKeyRetrieval默认关闭MySQL 8.0的caching_sha2_password认证插件会拒绝连接。解决JDBC连接串把参数写全一次到位jdbc:mysql://localhost:3306/pointsdb?useUnicodetruecharacterEncodingutf8serverTimezoneAsia/ShanghaiuseSSLfalseallowPublicKeyRetrievaltrue逐个参数说useUnicodetrue配合characterEncodingutf8保证中文不乱码serverTimezoneAsia/Shanghai解决时区报错useSSLfalse关闭本地开发的SSL握手省去证书配置allowPublicKeyRetrievaltrue是为MySQL 8.0的认证插件准备的不加会报公钥检索错误。这套参数是我踩了两次坑后固定下来的标准模板现在所有课设项目都直接抄这套。5. 把「能跑」变成「会讲」一套演示脚本与三个加分扩展5.1 演示顺序按「办卡→消费→查流水→兑换→过期」五步走答辩演示时功能顺序比功能多少更重要。我的习惯是严格按业务链路走先演示新会员办卡让评委看到member_no自动生成、初始积分为0再模拟一笔真实消费输入金额页面显示本次获得积分数据库里多了一条EARN流水接着演示积分兑换选一个商品确认积分余额和库存同步减少然后切到会员详情页按时间倒序展示积分流水特意指一下balance_after列告诉评委「这里存了每次变动后的余额快照方便对账」最后手动执行CALL sp_expire_points();演示一条EXPIRE流水被写入。每一步操作前先说「我要做什么、预期看到什么」评委跟着你的思路走就不会盯住某个边角功能不放。5.2 三个加分扩展从课设作业变成面试作品如果时间和精力允许我会建议在基础功能之外挑一个扩展点做深。扩展点技术方案答辩怎么讲登录与权限Spring Security BCrypt密码加密管理员和收银员分角色登录积分规则只有管理员能改积分实时查询Redis缓存会员积分余额积分是读多写少的场景缓存能扛住高并发查询写操作再更新缓存数据可视化ECharts 聚合查询SQL按日统计积分发放量和消耗量用折线图展示趋势这三个扩展都不需要改表结构只是在现有代码上加一层。第一个扩展对应权限控制第二个对应性能优化第三个对应数据分析分别覆盖了项目报告里「功能性需求、非功能性需求、系统亮点」三个章节。我当年就是只顾着把demo跑通就冲上去答辩结果被评委一句话问住积分过期你怎么处理那次之后我养成一个习惯动手写代码前先把自己这版方案里最容易被追问的三个边界问题列出来写进报告里。这套思路后来帮我避了不少坑今天整理出来希望帮到你。本文还有配套的精品资源点击获取
RELATED READING

延伸阅读

更多一线实战笔记与深度复盘,助您持续精进