ARTICLE DETAIL

资讯详情

深耕网站建设与运营推广的一线实战洞察。

基于Java的超市积分管理系统:从表结构到并发防扣的实现指南

基于Java的超市积分管理系统:从表结构到并发防扣的实现指南 简介一份面向Java Web学习者的超市积分管理系统完整项目包覆盖需求分析、系统设计、编码实现到答辩展示全流程。包内共5个文件包含两个zip分别存放项目源码与界面截图、数据库脚本sql、项目材料报告doc及必读说明txt压缩包整体18.21MB结构清晰便于按模块查阅。系统基于Servlet/JSP和JDBC技术构建遵循MVC分层与DAO模式利用JDBC完成数据库连接通过DAO封装增删改查操作实现会员、商品、积分等核心数据的持久化与业务处理从数据库表设计到数据访问封装均有可参考的示范项目报告详细记录了背景、需求、设计与测试过程答辩PPT则提炼出技术选型与系统架构要点。目前已有360人学习浏览适合毕业设计、课程实训或自学Java Web开发时参考源代码和数据库脚本可直接导入运行也可在此基础上扩展功能。1. 超市积分管理系统到底在做什么先想清楚业务再动手“基于 Java 的超市积分管理系统”这个标题乍一看就是个普通课程设计实际上它把一个很常见的真实需求压在了里面顾客购物后按消费金额获得积分积分可以抵现或兑换商品后台需要记录每一笔出入明细。严格来说它比纯增删改查多了一层“钱”的属性因为积分一旦能当钱用就必须有流水、有对账、有防超扣。这个项目适合两类人一类是正在做 Java 课程设计或毕业设计的学生需要凑齐源代码、数据库和答辩 PPT另一类是给自家小超市或门店做会员系统想先跑通一个最小闭环。下面顺着这个标题把数据表、业务代码、规则配置和答辩交付一次讲透。2. 核心表结构把会员、积分流水和商品兑换拆成三张表2.1 数据库选型与字段设计建议直接用 MySQL 8.0原因很简单学校机房和云服务器上最不缺的就是 MySQL 环境Java 生态里 JDBC、MyBatis 对它的支持也最稳。项目规模不大没必要引入 NoSQL积分系统是强一致场景一张会员表、一张流水表、一张兑换记录表就能覆盖 90% 的需求。会员表存的是静态信息和当前可用积分流水表存的是每一笔积分变化的明细兑换表存的是积分换商品的单据。很多新手图省事只建一张 member 表加一个 total_points 字段发积分就update member set total_points total_points 10。这种做法短期内能跑但月底对账时你会发现根本说不清这 1000 分是怎么来的。积分本质上是一种资产必须像账本一样留痕。字段设计上要注意几个点手机号要加唯一索引因为超市积分基本以手机号作为账号total_points 用 INT 而不是 VARCHAR排序和计算都依赖它金额字段用 DECIMAL(10,2)不要用 FLOAT避免精度问题。流水表里一定要有 balance_after 字段记录变动后的余额这样即使业务代码有 bug也能靠流水回看是谁把账改坏了。2.2 建表 SQL 与初始化数据这里给出可直接执行的建表脚本。字符集统一用 utf8mb4否则存 emoji 或生僻字时容易乱码。CREATE DATABASE IF NOT EXISTS supermarket_points DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_general_ci; USE supermarket_points; CREATE TABLE member ( id BIGINT UNSIGNED AUTO_INCREMENT PRIMARY KEY, phone VARCHAR(11) NOT NULL COMMENT 手机号作为登录账号, name VARCHAR(50) NOT NULL COMMENT 会员姓名, total_points INT NOT NULL DEFAULT 0 COMMENT 当前可用积分, level TINYINT NOT NULL DEFAULT 1 COMMENT 1普通 2银卡 3金卡, created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, UNIQUE KEY uk_phone (phone) ) ENGINEInnoDB COMMENT会员表; CREATE TABLE points_flow ( id BIGINT UNSIGNED AUTO_INCREMENT PRIMARY KEY, member_id BIGINT UNSIGNED NOT NULL COMMENT 会员ID, change_type TINYINT NOT NULL COMMENT 1发放 2消费 3过期 4退款, change_value INT NOT NULL COMMENT 正数增加 负数扣减, balance_after INT NOT NULL COMMENT 本次变动后的余额, source_no VARCHAR(64) DEFAULT NULL COMMENT 关联订单号/兑换单号, remark VARCHAR(255) DEFAULT NULL, created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, KEY idx_member_time (member_id, created_at) ) ENGINEInnoDB COMMENT积分流水表; CREATE TABLE exchange_order ( id BIGINT UNSIGNED AUTO_INCREMENT PRIMARY KEY, member_id BIGINT UNSIGNED NOT NULL, product_name VARCHAR(100) NOT NULL, points_cost INT NOT NULL COMMENT 本次兑换消耗积分, status TINYINT NOT NULL DEFAULT 0 COMMENT 0待领取 1已领取 2已取消, created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, KEY idx_member (member_id) ) ENGINEInnoDB COMMENT兑换记录表;说明member_id 上建普通索引而不是唯一索引因为一个会员会有多条流水查询高频场景是“某会员最近的积分明细”所以索引组合为 (member_id, created_at)。所有表都用 InnoDB因为积分扣减需要行级锁MyISAM 在并发扣积分时会变成全表锁这个差异在答辩时值得提一句。这三张表建好后系统的地基就稳了。2.3 为什么不直接改 total_points这个问题值得展开因为很多 Java 课程设计都栽在这里。直接改余额的写法是用户消费后update member set total_points total_points 10 where id ?。看起来没问题但今天上线、明天运营就会来问昨天发出去的 50000 分到底发给哪些人了如果只有一张表你只能看到每个人的余额看不到这批积分的来源。有了流水表每一笔发放都带 source_no、remark 和 change_type运营可以对账开发能定位 bug用户也能在页面或小票上查询明细。更重要的是“过期”和“退款”这两个动作必须靠流水实现过期不是把余额改小而是插入一条 change_type3 的负流水退款也不是直接把单删掉而是插入相反方向的流水。这是积分系统的“后悔药”没有流水就等于没有后悔药。3. 用 Spring Boot MyBatis 把积分增删改查跑通DAO 到 Service 的落地代码3.1 项目骨架与依赖怎么选这个标题没有限定框架常见做法是 Java Web 课程里教的 Servlet JSP但一般建议直接用 Spring Boot MyBatis。理由不是追赶热门技术而是它把连接池、事务、JSON 序列化这些容易踩坑的杂活都管好了让你把精力放在积分业务上。Spring Boot 2.7 MyBatis 2.x 是目前资料最多、最不容易出兼容问题的组合。源代码里最值得抄的部分不是 Controller而是 Service 层因为积分规则和事务都堆在这里。pom.xml 里核心依赖就四个dependency groupIdorg.springframework.boot/groupId artifactIdspring-boot-starter-web/artifactId /dependency dependency groupIdorg.mybatis.spring.boot/groupId artifactIdmybatis-spring-boot-starter/artifactId version2.3.1/version /dependency dependency groupIdcom.mysql/groupId artifactIdmysql-connector-j/artifactId scoperuntime/scope /dependency dependency groupIdorg.projectlombok/groupId artifactIdlombok/artifactId optionaltrue/optional /dependencyapplication.yml 里的数据源配置是另一个高频翻车点尤其是 MySQL 8.0 的时区校验spring: datasource: url: jdbc:mysql://localhost:3306/supermarket_points?useUnicodetruecharacterEncodingutf8serverTimezoneAsia/ShanghaiuseSSLfalseallowPublicKeyRetrievaltrue username: root password: your_password driver-class-name: com.mysql.cj.jdbc.Driver第一个坑是连接串不带 serverTimezoneMySQL 8.0 会直接报时区错误第二个坑是 allowPublicKeyRetrieval 不配某些 MySQL 8.0 客户端会拒绝连接。这两个参数属于血泪经验答辩前先跑一遍。3.2 积分发放先写流水还是先改余额积分发放的 Service 是整套系统的核心要求是“余额和流水必须同时成功或者同时失败”。做法是把方法用 Transactional 包起来先查会员再按规则计算积分然后插入流水最后更新余额。顺序上建议先插流水再改余额因为流水表插入失败时余额不应该变化。Service public class PointsService { private final MemberMapper memberMapper; private final PointsFlowMapper pointsFlowMapper; private final RuleConfigMapper ruleConfigMapper; public PointsService(MemberMapper memberMapper, PointsFlowMapper pointsFlowMapper, RuleConfigMapper ruleConfigMapper) { this.memberMapper memberMapper; this.pointsFlowMapper pointsFlowMapper; this.ruleConfigMapper ruleConfigMapper; } Transactional public void earnPoints(Long memberId, Integer orderAmount, String orderNo) { Member member memberMapper.selectById(memberId); if (member null) { throw new BusinessException(会员不存在); } // 按规则换算积分比如每消费1元积1分 int earned ruleConfigMapper.selectCurrentRule().calcPoints(orderAmount); if (earned 0) { return; } int before member.getTotalPoints(); int after before earned; PointsFlow flow new PointsFlow(); flow.setMemberId(memberId); flow.setChangeType(1); flow.setChangeValue(earned); flow.setBalanceAfter(after); flow.setSourceNo(orderNo); flow.setRemark(消费发放); pointsFlowMapper.insert(flow); member.setTotalPoints(after); memberMapper.updateById(member); } }这段代码有几个关键点要说清楚。第一earned 0时直接 return不要插入 0 分流水避免流水表膨胀第二先插入流水后更新余额二者在同一事务里任何一步抛异常都会回滚第三source_no传的是订单号这是后续做幂等判断的依据。如果你用 MyBatis 的Insert注解insert 后会自动把自增主键回填到 flow 对象里便于打印日志。3.3 积分消费条件更新防超扣扣积分比发积分风险高。发积分多发一点最多是运营成本增加扣积分多扣了用户会直接投诉。先给一个教科书式写法再给一个生产可用的写法。教科书式写法是先select看余额够不够够则update这个写法的隐患在于并发——两个收银台同时用同一会员的积分结账都查到余额够都执行扣减最后余额变成负数。生产可用的写法是把判断和扣减合并成一条 SQL让数据库来保证原子性public boolean consumePoints(Long memberId, Integer cost, String orderNo) { // 条件更新只有余额充足时才真正扣减 int rows memberMapper.deductPoints(memberId, cost); if (rows 0) { throw new BusinessException(积分不足或会员不存在); } Member member memberMapper.selectById(memberId); PointsFlow flow new PointsFlow(); flow.setMemberId(memberId); flow.setChangeType(2); flow.setChangeValue(-cost); flow.setBalanceAfter(member.getTotalPoints()); flow.setSourceNo(orderNo); flow.setRemark(积分消费); pointsFlowMapper.insert(flow); return true; }对应的 MemberMapper 里写一条动态 SQLupdate iddeductPoints UPDATE member SET total_points total_points - #{cost} WHERE id #{memberId} AND total_points #{cost} /update关键在WHERE total_points #{cost}这条条件让数据库在行锁内完成判断不会出现两个事务同时通过检查。rows 0就说明要么会员不存在要么余额不足业务层统一抛异常即可。注意这里扣减后没有重新用member.getTotalPoints()再校验一次直接用事务内的值记录流水避免多一次查询。4. 积分规则怎么定兑换比例、过期任务和防刷门槛4.1 发放规则做成配置表而不是硬编码很多同学把积分规则写死在 Java 代码里比如earned orderAmount / 10。这样做第一版没问题但运营想改成“会员日双倍积分”时你得改代码、重新部署。更合理的做法是把规则放到数据库表里做成一个可切换的开关。CREATE TABLE rule_config ( id INT PRIMARY KEY AUTO_INCREMENT, rule_name VARCHAR(50) NOT NULL, points_per_yuan INT NOT NULL DEFAULT 1 COMMENT 每消费1元发多少积分, start_at DATETIME DEFAULT NULL, end_at DATETIME DEFAULT NULL, status TINYINT NOT NULL DEFAULT 1 COMMENT 1启用 0停用 ) ENGINEInnoDB COMMENT积分规则配置; INSERT INTO rule_config (rule_name, points_per_yuan, status) VALUES (默认规则, 1, 1);代码里读当前启用的规则时要注意 end_at 为 null 表示永久有效不能直接BETWEEN判断。计算积分的逻辑放到 RuleConfig 实体里对外提供一个calcPoints方法这样规则实体自己负责算法Service 不用管细节。积分计算需要考虑单位如果订单金额是 99.8 元按 1 元积 1 分的结果是 99 分建议明确向下取整并在规则表注释里写清楚。4.2 积分有效期与定时过期任务积分不过期对用户体验好但对超市是资产负债表的无底洞。常见做法是 12 个月有效期按月滚动清零。实现上不是每天把“过期积分”从 total_points 里减掉而是把每笔流水标记上“所属批次”再用定时任务找到已过期的正向流水逐笔插入负流水抵消。一般用 Spring 自带的 Scheduled 来跑任务类里加EnableSchedulingComponent public class PointsExpireTask { private final PointsFlowMapper pointsFlowMapper; private final MemberMapper memberMapper; Scheduled(cron 0 30 3 * * ?) // 每天凌晨 3:30 执行 Transactional public void expirePoints() { ListPointsFlow expiredFlows pointsFlowMapper.selectExpiredFlows(); for (PointsFlow flow : expiredFlows) { // 找到正数积分插入一条负数过期流水并同步余额 memberMapper.addPoints(flow.getMemberId(), -flow.getChangeValue()); PointsFlow expireFlow new PointsFlow(); expireFlow.setMemberId(flow.getMemberId()); expireFlow.setChangeType(3); expireFlow.setChangeValue(-flow.getChangeValue()); expireFlow.setBalanceAfter(0); expireFlow.setSourceNo(EXPIRE- flow.getId()); expireFlow.setRemark(积分过期); pointsFlowMapper.insert(expireFlow); } } }这里最容易翻车的是“同一笔过期流水被执行两次”。所以source_no必须带上原流水 id并且在 points_flow 上给source_no加唯一索引。任务跑之前可以先select ... where change_type 1 and created_at date_sub(now(), interval 12 month)一次性查出所有该过期的正向流水但要注意分页数据量大时别一次全加载进内存。selectExpiredFlows 对应查询未过期的正向流水addPoints 对应一条update member set total_points total_points #{delta} where id #{memberId}加入事务后数据库会自动对涉及的行加锁。4.3 防刷与对账让积分系统不像个玩具课程设计阶段可以不管防刷但你要是真给门店用三天就会被薅羊毛。常见手段有三个同一手机号每天发放积分上限、同一订单号不允许重复发放、兑换商品设置单人限购。前两个都依赖 source_no 或 member_id 的唯一约束第三个需要一张限购配置表。这里给一条实用的对账 SQLSELECT member_id, SUM(change_value) AS total FROM points_flow WHERE change_type 1 AND created_at CURRENT_DATE() GROUP BY member_id HAVING total 1000;这条 SQL 查出昨天单个会员积分发放超过 1000 的记录运营拿来和收银小票比对基本能定位异常。对账方面每天凌晨用一个定时任务比对“昨日发放积分总数”和“流水表 change_type1 且 change_value0 之和”两者不一致就告警。这个逻辑不复杂但能体现出你把数据当钱看的态度。答辩时如果老师问“怎么保证系统可靠”把这一段讲清楚比背代码有效得多。5. 从源码到能答辩5 个最容易翻车的坑与排查方法5.1 数据库时区报错与加密连接问题现象Spring Boot 启动时抛The server time zone value й׼ʱ is unrecognized或者Public Key Retrieval is not allowed。 原因MySQL 8.0 默认使用 UTC 时区本地却是东八区同时 caching_sha2_password 认证方式需要客户端在非 SSL 情况下显式允许取公钥。 解决连接串固定带上serverTimezoneAsia/ShanghaiuseSSLfalseallowPublicKeyRetrievaltrue。这三个参数少一个都可能让程序起不来。这是 Java 连接 MySQL 8.0 最常见的老朋友遇到先查连接串。5.2 积分被扣成负数Java 里先判断后更新是典型的并发翻车点现象压力测试或者两个收银台同时操作时会员余额变成 -5。 原因两个线程都先查余额都认为够扣然后各自执行update total_points total_points - 10。 解决把校验挪进 SQL 的 WHERE 条件写成update ... where total_points cost用受影响行数判断是否成功。不要依赖 Java 层的 if 判断数据库行锁才是可信的临界区。顺带一提如果用 Redis 缓存余额做判断也要考虑缓存和数据库不一致的问题课程设计阶段直接用条件更新最简单。5.3 定时任务重复执行导致过期流水翻倍现象第二天发现流水表里同一笔过期记录出现两次会员余额被多扣了一倍。 原因任务没有幂等控制上一次执行过程中产生的新流水又被下一次扫描命中。 解决给 points_flow.source_no 建唯一索引过期流水用EXPIRE-{原流水ID}作为 source_no重复插入时数据库直接报错同时任务里用SELECT ... FOR UPDATE锁定正在处理的批次。课程设计里用唯一索引这一招就够了分布式定时锁属于后续进阶不必在答辩中展开。5.4 中文乱码和 Excel 导出乱码现象页面和 SQL 脚本里中文正常但导入数据后查出来是乱码或导出 Excel 打开是问号。 原因连接串没带 characterEncoding或建库时用了默认 latin1。 解决建库语句显式写DEFAULT CHARACTER SET utf8mb4连接串带characterEncodingutf8。检查时在 MySQL 客户端执行SHOW CREATE DATABASE supermarket_points;和SHOW FULL COLUMNS FROM member;确认两边的字符集都是 utf8mb4 再往下查。Excel 导出乱码还可能是没有使用 UTF-8 BOM 头导出时在文件头写\uFEFF即可。5.5 答辩电脑上项目跑不起来JDK 版本和端口占用现象在自己电脑上好好的到答辩教室双击 jar 包起不来或者 8080 端口被别的程序占用。 原因换了 JDK 版本导致编译不兼容或者教室机器上某个服务占了端口。 解决项目带上 README写清楚 JDK 1.8、Maven 3.6、MySQL 8.0 的版本要求启动器里用mvn spring-boot:run -Dspring-boot.run.arguments--server.port8081换端口避免被占。答辩前在目标机器上完整跑一遍别只在自己电脑上演示这是避坑手册里最老但最实用的一条。6. 交付前最后一步把项目报告、答辩 PPT 和数据库整理成能直接用到这一步系统能跑了但标题里还有两份交付物项目报告和答辩 PPT。我的习惯是先写报告再把报告每章浓缩成 PPT 的一页别反过来。报告建议按“需求分析 → 数据库设计 → 系统实现 → 测试”四章来写数据库设计那一章放关键表结构和字段说明系统实现放积分发放、消费、过期三个核心流程和少量关键代码测试写 5 条以上用例包括正常发放、余额不足、重复兑换这三种边界。答辩 PPT 控制在 8 到 12 页页数少不是缺点讲得完才是重点。第 1 页放系统架构图浏览器 → Controller → Service → DAO → MySQL第 2 页放积分流程时序图中间 4 页放会员管理、积分发放、积分消费、兑换记录的功能截图倒数两页放测试结果和踩坑总结。所有图都要用实际运行截的图别贴网图老师一眼就能看出来。数据库交付不要只给一个 .sql 文件。应该分两个schema.sql 只含建库建表语句init-data.sql 含预置测试账号和规则配置。给一个一键执行的命令mysql -u root -p schema.sql mysql -u root -p init-data.sql再在 README 里写清楚默认管理员账号和预置会员的手机号方便答辩现场直接演示。如果你用的是 MySQL 8.0导出的 SQL 文件开头会有SET SESSION.SQL_MODE之类的语句这是正常的不要手动删但要注意导出的字符集是否是 utf8mb4。最后说一个我的习惯每次交付前都会在干净的虚拟机上按 README 从零跑一遍把“缺依赖、端口被占、数据库没初始化”这类问题提前暴露掉。这个动作花不了半小时但能避免答辩现场最尴尬的“在我电脑上明明能运行”。希望这些细节能帮到你也让你在对账和并发这些看不见的地方少走点弯路。本文还有配套的精品资源点击获取
返回列表