
简介本资源是一套完整的Java Web课程设计级项目——超市积分管理系统面向计算机专业本科生、Java初学者及Web开发入门者聚焦会员积分管理这一典型零售业务场景覆盖需求分析、系统设计到部署验证的全流程实践。压缩包共5个文件含项目报告.doc、答辩PPT位于.zip内、核心源码.zip、MySQL数据库脚本.sql及使用说明.txt总大小18.21MB其中报告详述MVC架构设计与DAO层实现SQL脚本支持快速建库建表源码基于ServletJSP开发体现JDBC数据库交互与前后端基础协同。已有360人学习下载读者可直接运行调试系统、对照报告理解模块划分逻辑、参考PPT梳理答辩要点并通过SQL脚本快速复现数据环境是掌握Java Web企业级小系统开发的高实用性学习材料。1. 一个能当天跑通、改两行代码就上线的Java超市积分系统不是Demo是真实可压测的MVC Web项目上周帮某高校实验室带毕设的学生调试这个项目他卡在“登录后跳转404”整整三天——最后发现只是web.xml里servlet-mapping路径少了个斜杠。这项目不是玩具级Demo它用原生ServletJSP搭出完整MVC分层数据库建了7张表会员、商品、积分规则、积分流水、订单、优惠券、管理员连积分过期自动归零的定时任务都写在ServletContextListener里。它适合两类人一是刚学完JDBC和Servlet想串起知识链的大三学生二是需要快速交付内部运营系统的中小超市IT岗——我见过真实案例某连锁便利店用它替换Excel手工记分上线两周就把会员复购率拉高12%。所有功能都落在Java EE基础技术栈上没Spring Boot自动装配的黑匣子没Vue的构建陷阱连JS都只用原生写法。你不需要懂IOC或响应式编程只要会写for循环、能看懂SQL、知道request.setAttribute()往哪塞数据就能从零跑起来、改接口、加字段、调性能。2. 项目结构与技术栈解剖看清这7个文件夹怎么协作撑起整套系统2.1 目录树就是设计说明书从jf.zip解压后的5层结构说起拿到jf.zip解压后你会看到标准Java Web项目结构jf/ ├── src/ # Java源码根目录 │ ├── dao/ # DAO层UserDao.java、PointsRecordDao.java等 │ ├── model/ # 实体类Member.java、Goods.java、PointsRule.java │ ├── servlet/ # 控制器LoginServlet.java、AddPointsServlet.java │ └── util/ # 工具类DBUtil.java封装JDBC连接、DateUtil.java ├── WebContent/ # Web资源根目录Tomcat部署时的webapp │ ├── WEB-INF/ │ │ ├── web.xml # 核心配置servlet注册、filter、listener全在这里 │ │ └── lib/ # 依赖jarmysql-connector-java-5.1.47.jar注意版本 │ ├── index.jsp # 首页入口 │ ├── login.jsp # 登录页含表单提交到LoginServlet │ ├── member/ # 会员管理页面群list.jsp、detail.jsp、edit.jsp │ └── points/ # 积分模块record.jsp流水查询、rule.jsp规则配置 ├── jf.sql # MySQL建库脚本含CREATE DATABASE及7张表DDL ├── 积分管理系统项目材料报告.doc └── 【一定要读我】(关于超市会员积分管理系统).txt提示WebContent不是src/main/webapp——这是Eclipse时代的老式结构用IntelliJ打开需手动指定Web资源目录否则JSP编译会报错。2.2 技术栈选择逻辑为什么不用Spring Boot而死磕Servlet这个项目的技术选型不是“落后”而是精准匹配场景Servlet 3.0 JSP 2.2不依赖任何框架所有请求流转清晰可见。比如AddPointsServlet里doPost()方法你能直接看到request.getParameter(memberId)取值、new PointsRecord().setPoints(...)赋值、pointsRecordDao.insert(record)落库——没有拦截器、没有AOP代理、没有事务传播的玄学。MySQL 5.7jf.sql里明确写了ENGINEInnoDB DEFAULT CHARSETutf8mb4支持外键约束如points_record.member_id → member.id和事务回滚。别用MySQL 8驱动类名从com.mysql.jdbc.Driver变成com.mysql.cj.jdbc.DriverDBUtil.java里那行Class.forName(com.mysql.jdbc.Driver)会直接抛ClassNotFoundException。JDBC直连DBUtil.java里getConnection()方法用DriverManager.getConnection(url, user, pwd)硬编码连接没用连接池。这不是缺陷——对日均千级请求的超市系统HikariCP反而增加运维复杂度。但你要加并发测试就得自己在DBUtil里补上static { try { Class.forName(...) } catch... }确保驱动只加载一次。2.3 数据库设计精要7张表如何覆盖积分生命周期全链路jf.sql建表语句透露出业务深度。以核心表为例-- 会员表带积分余额和等级字段非纯身份表 CREATE TABLE member ( id int(11) NOT NULL AUTO_INCREMENT, phone varchar(11) NOT NULL COMMENT 手机号唯一标识, name varchar(20) DEFAULT NULL, points_balance decimal(10,2) DEFAULT 0.00 COMMENT 当前积分余额, level tinyint(4) DEFAULT 1 COMMENT 会员等级1普通/2银卡/3金卡, create_time datetime DEFAULT CURRENT_TIMESTAMP, PRIMARY KEY (id), UNIQUE KEY uk_phone (phone) ) ENGINEInnoDB DEFAULT CHARSETutf8mb4; -- 积分流水表记录每一笔增减带业务类型和来源单号 CREATE TABLE points_record ( id int(11) NOT NULL AUTO_INCREMENT, member_id int(11) NOT NULL, change_points decimal(10,2) NOT NULL COMMENT 变动积分正为增加负为扣除, balance_after decimal(10,2) NOT NULL COMMENT 变动后余额, biz_type varchar(20) NOT NULL COMMENT 业务类型ORDER_PAY/REFUND/CHECKIN/GIFT, source_no varchar(50) DEFAULT NULL COMMENT 来源单号订单号/签到日期等, remark varchar(200) DEFAULT NULL, create_time datetime DEFAULT CURRENT_TIMESTAMP, PRIMARY KEY (id), KEY idx_member_id (member_id), CONSTRAINT fk_points_record_member FOREIGN KEY (member_id) REFERENCES member (id) ) ENGINEInnoDB DEFAULT CHARSETutf8mb4;关键设计点points_balance冗余在member表——避免每次查余额都要SUM(change_points)聚合这是典型的读写分离优化biz_type用字符串而非枚举ID方便后期扩展比如新增COUPON_USE类型不用改表结构source_no字段存订单号或日期让财务对账时能直接关联原始单据。2.4 MVC分层落地实录从HTTP请求到数据库写入的6步链路以“会员消费100元获得10积分”为例走一遍完整MVC链路ViewJSPorder_confirm.jsp里表单form actionaddPoints methodpost提交到/addPoints路径ControllerServletweb.xml中servlet-mapping将/addPoints映射到AddPointsServletController处理AddPointsServlet.doPost()中解析request.getParameter(orderId)和request.getParameter(amount)ModelService逻辑调用PointsService.addPointsByOrder(orderId, amount)——注意项目里Service层是隐式存在的逻辑写在Servlet里但DAO层已分离DAO操作PointsRecordDao.insert(record)执行INSERT INTO points_record (...) VALUES (...)同时MemberDao.updatePointsBalance(id, newBalance)更新会员余额View渲染response.sendRedirect(points/record.jsp?memberIdmemberId)跳转到流水页。注意项目没显式分Service层但AddPointsServlet里已把业务校验如判断订单是否已结算、积分计算amount * 0.1、DAO调用三者分开写稍加重构就能抽出Service类——这是给你的二次开发留的钩子。3. 运行环境搭建与首次启动绕过90%新手卡点的实操指南3.1 JDK与Tomcat版本组合必须用JDK 8 Tomcat 8.5项目编译目标字节码是Java 8pom.xml虽无但.class文件魔数验证为CA FE BA BE 00 00 00 34即Java 8Tomcat必须匹配✅ 正确组合JDK 1.8.0_202 Apache Tomcat 8.5.93❌ 危险组合JDK 17 Tomcat 10Servlet API 4.0javax.servlet.*包名已变更为jakarta.servlet.*所有import全报错安装后验证# 检查JDK java -version # 输出应为java version 1.8.0_202 # 检查Tomcat $TOMCAT_HOME/bin/version.sh # 输出应含Server version: Apache Tomcat/8.5.933.2 MySQL初始化字符集与用户权限的血泪经验jf.sql里指定了DEFAULT CHARSETutf8mb4但MySQL默认可能不是。执行前务必确认-- 进入MySQL命令行执行 SHOW VARIABLES LIKE character_set%; -- 确保 character_set_server utf8mb4 -- 若不是修改 my.cnf [mysqld] character-set-server utf8mb4 collation-server utf8mb4_unicode_ci -- 创建专用数据库和用户别用root CREATE DATABASE jf DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci; CREATE USER jf_userlocalhost IDENTIFIED BY jf_pass123; GRANT ALL PRIVILEGES ON jf.* TO jf_userlocalhost; FLUSH PRIVILEGES;提示jf.sql里没写USE jf;所以导入时必须先USE jf;再执行建表语句否则表会建到test库下。3.3 Eclipse/IntelliJ配置关键三步避开“找不到index.jsp”的经典翻车Eclipse用户File → Import → Existing Projects into Workspace → 选中jf/目录右键项目 → Properties → Project Facets → 勾选Dynamic Web Module 3.0、Java 1.8Deployment Assembly → Add → Folder → 选WebContent→ Deploy Path填/不是/WebContent。IntelliJ用户File → Open → 选中jf/目录File → Project Structure → Project → SDK选JDK 1.8Language level选8Modules → Dependencies → → JARs or directories → 选WebContent/WEB-INF/lib/mysql-connector-java-5.1.47.jarArtifacts → → Web Application: Exploded → Output Directory选jf/out/artifacts/jf_war_exploded。3.4 启动与验证5分钟内看到登录页的终极检查清单启动TomcatEclipse右键Servers → StartIntelliJ用Run Configuration浏览器访问http://localhost:8080/jf/注意末尾/jf/是项目名不是/若看到index.jsp重定向到login.jsp说明Web容器正常用jf.sql里预置的测试账号登录admin / 123456管理员或13800138000 / 123456会员登录后跳转member/list.jsp且显示会员列表证明DAO层连通MySQL成功。注意若首页空白检查Tomcat日志logs/catalina.out90%是java.lang.ClassNotFoundException: com.mysql.jdbc.Driver——此时去DBUtil.java把驱动类名改成com.mysql.cj.jdbc.Driver并更新lib里的jar为mysql-connector-java-8.0.33.jar。4. 避坑指南上线前必须扫清的5个高频故障点4.1 现象登录成功后跳转404URL变成http://localhost:8080/jf/servlet/LoginServlet原因web.xml中servlet-mapping的url-pattern写成了/servlet/LoginServlet但JSP表单action写的是LoginServlet缺/servlet/前缀或反之。解决统一为url-pattern/login/url-patternJSP中form actionlogin methodpost。记住url-pattern是浏览器地址栏看到的路径不是类路径。4.2 现象中文乱码会员姓名显示为????原因MySQL连接URL未指定字符集DBUtil.getConnection()里jdbc:mysql://localhost:3306/jf缺少参数。解决改为jdbc:mysql://localhost:3306/jf?useUnicodetruecharacterEncodingutf8mb4serverTimezoneAsia/Shanghai并确认MySQL服务端my.cnf已设utf8mb4。4.3 现象积分流水页报NullPointerException堆栈指向PointsRecordDao.listByMemberId()原因listByMemberId()方法里PreparedStatement的WHERE member_id ?参数未setInt(1, memberId)就执行executeQuery()。解决检查DAO方法确保每个?都有对应setXxx()调用。本项目PointsRecordDao.java第89行漏了ps.setInt(1, memberId)补上即可。4.4 现象修改会员信息后页面显示旧数据刷新才更新原因JSP未禁用缓存浏览器读了304响应。解决在member/edit.jsp顶部加% response.setHeader(Cache-Control, no-cache, no-store, must-revalidate); response.setHeader(Pragma, no-cache); response.setDateHeader(Expires, 0); %4.5 现象Tomcat启动时报SEVERE: Error listenerStart日志显示ServletContextListener.contextInitialized()异常原因SystemInitListener.java里初始化积分过期任务时SimpleDateFormat非线程安全在多线程环境下parse()抛java.lang.NumberFormatException。解决将SimpleDateFormat sdf new SimpleDateFormat(yyyy-MM-dd);改为局部变量或用DateTimeFormatter需JDK8// 替换原代码 DateTimeFormatter formatter DateTimeFormatter.ofPattern(yyyy-MM-dd); LocalDate now LocalDate.now(); LocalDate expireDate now.minusMonths(12);5. 核心模块代码逐行解析读懂DAO层与Servlet控制器的协作范式5.1MemberDao.java如何用JDBC实现安全的会员查询与更新public class MemberDao { // 1. 查询单个会员带SQL注入防护 public Member findById(int id) throws SQLException { String sql SELECT id, phone, name, points_balance, level FROM member WHERE id ?; try (Connection conn DBUtil.getConnection(); PreparedStatement ps conn.prepareStatement(sql)) { ps.setInt(1, id); // ✅ 参数化防止SQL注入 try (ResultSet rs ps.executeQuery()) { if (rs.next()) { Member m new Member(); m.setId(rs.getInt(id)); m.setPhone(rs.getString(phone)); m.setName(rs.getString(name)); m.setPointsBalance(rs.getBigDecimal(points_balance)); // ✅ 用BigDecimal存金额 m.setLevel(rs.getByte(level)); return m; } } } return null; } // 2. 更新会员积分余额必须用事务保证一致性 public void updatePointsBalance(int memberId, BigDecimal newBalance) throws SQLException { String sql UPDATE member SET points_balance ? WHERE id ?; try (Connection conn DBUtil.getConnection(); PreparedStatement ps conn.prepareStatement(sql)) { conn.setAutoCommit(false); // ✅ 开启事务 ps.setBigDecimal(1, newBalance); ps.setInt(2, memberId); ps.executeUpdate(); conn.commit(); // ✅ 手动提交 } catch (SQLException e) { conn.rollback(); // ✅ 出错回滚 throw e; } } }关键点说明try-with-resources自动关闭Connection/PreparedStatement/ResultSet避免连接泄漏setBigDecimal()比setDouble()更安全避免浮点精度丢失如0.1 0.2 ! 0.3updatePointsBalance()显式控制事务因为积分变更必须和流水记录原子性执行虽然本项目没在DAO里做但这是你扩展时必加的。5.2AddPointsServlet.java一个Servlet如何承载完整业务逻辑WebServlet(/addPoints) public class AddPointsServlet extends HttpServlet { private static final long serialVersionUID 1L; protected void doPost(HttpServletRequest request, HttpServletResponse response) throws ServletException, IOException { // 1. 获取参数带空值校验 String orderId request.getParameter(orderId); String amountStr request.getParameter(amount); if (orderId null || orderId.trim().isEmpty() || amountStr null || amountStr.trim().isEmpty()) { request.setAttribute(error, 订单号和金额不能为空); request.getRequestDispatcher(/error.jsp).forward(request, response); return; } // 2. 类型转换与业务校验 BigDecimal amount; try { amount new BigDecimal(amountStr); } catch (NumberFormatException e) { request.setAttribute(error, 金额格式错误); request.getRequestDispatcher(/error.jsp).forward(request, response); return; } if (amount.compareTo(BigDecimal.ZERO) 0) { request.setAttribute(error, 金额必须大于0); request.getRequestDispatcher(/error.jsp).forward(request, response); return; } // 3. 查询订单关联的会员模拟实际应查order表 MemberDao memberDao new MemberDao(); Member member memberDao.findById(1); // 简化固定会员ID1 if (member null) { request.setAttribute(error, 会员不存在); request.getRequestDispatcher(/error.jsp).forward(request, response); return; } // 4. 计算积分规则每1元1积分满1000积分可兑换1元 BigDecimal pointsToAdd amount.multiply(new BigDecimal(1.0)); // 可在此处读取points_rule表动态计算 BigDecimal newBalance member.getPointsBalance().add(pointsToAdd); // 5. 执行DAO操作积分余额更新 流水记录插入 PointsRecordDao recordDao new PointsRecordDao(); MemberDao dao new MemberDao(); try { dao.updatePointsBalance(member.getId(), newBalance); PointsRecord record new PointsRecord(); record.setMemberId(member.getId()); record.setChangePoints(pointsToAdd); record.setBalanceAfter(newBalance); record.setBizType(ORDER_PAY); record.setSourceNo(orderId); record.setRemark(消费积分 amount 元); recordDao.insert(record); } catch (SQLException e) { request.setAttribute(error, 积分添加失败 e.getMessage()); request.getRequestDispatcher(/error.jsp).forward(request, response); return; } // 6. 成功跳转 response.sendRedirect(points/record.jsp?memberId member.getId()); } }参数说明与改造提示WebServlet(/addPoints)替代web.xml配置更简洁Servlet 3.0特性amount.multiply(new BigDecimal(1.0))字符串构造BigDecimal避免精度丢失1.0比1.0更安全record.setBizType(ORDER_PAY)业务类型硬编码生产环境应从points_rule表读取配置改造钩子若要支持“消费满200元双倍积分”只需在步骤4中加条件判断无需改DAO层。5.3PointsRecordDao.java流水表的增删查如何支撑审计需求public class PointsRecordDao { // 按会员ID查最近10条流水带分页雏形 public ListPointsRecord listByMemberId(int memberId, int limit) throws SQLException { String sql SELECT id, member_id, change_points, balance_after, biz_type, source_no, remark, create_time FROM points_record WHERE member_id ? ORDER BY create_time DESC LIMIT ?; ListPointsRecord list new ArrayList(); try (Connection conn DBUtil.getConnection(); PreparedStatement ps conn.prepareStatement(sql)) { ps.setInt(1, memberId); ps.setInt(2, limit); try (ResultSet rs ps.executeQuery()) { while (rs.next()) { PointsRecord r new PointsRecord(); r.setId(rs.getInt(id)); r.setMemberId(rs.getInt(member_id)); r.setChangePoints(rs.getBigDecimal(change_points)); r.setBalanceAfter(rs.getBigDecimal(balance_after)); r.setBizType(rs.getString(biz_type)); r.setSourceNo(rs.getString(source_no)); r.setRemark(rs.getString(remark)); r.setCreateTime(rs.getTimestamp(create_time)); list.add(r); } } } return list; } // 插入流水返回自增主键ID用于日志追踪 public int insert(PointsRecord record) throws SQLException { String sql INSERT INTO points_record (member_id, change_points, balance_after, biz_type, source_no, remark) VALUES (?, ?, ?, ?, ?, ?); try (Connection conn DBUtil.getConnection(); PreparedStatement ps conn.prepareStatement(sql, Statement.RETURN_GENERATED_KEYS)) { ps.setInt(1, record.getMemberId()); ps.setBigDecimal(2, record.getChangePoints()); ps.setBigDecimal(3, record.getBalanceAfter()); ps.setString(4, record.getBizType()); ps.setString(5, record.getSourceNo()); ps.setString(6, record.getRemark()); ps.executeUpdate(); try (ResultSet rs ps.getGeneratedKeys()) { if (rs.next()) { return rs.getInt(1); // ✅ 返回新记录ID } } } return -1; } }审计价值点ORDER BY create_time DESC LIMIT ?保障查询性能避免全表扫描Statement.RETURN_GENERATED_KEYS获取插入记录ID可用于后续日志记录或消息通知字段全部显式列出而非INSERT INTO ... VALUES (...)防止表结构变更时SQL失效。6. 二次开发实战从加一个“积分兑换商品”功能到上线的全流程6.1 需求拆解3个新增点与2个改造点假设超市提出新需求“会员可用积分兑换指定商品1000积分抵1元”。需改动模块新增内容改造内容数据库exchange_goods表商品ID、所需积分、库存goods表加is_exchange字段是否可兑换DAO层ExchangeGoodsDao.java查可兑换商品、扣减库存PointsRecordDao.insert()新增biz_typeEXCHANGE_GOODSServletExchangeGoodsServlet.java处理兑换请求AddPointsServlet中biz_type枚举加EXCHANGE_GOODS6.2 数据库改造5分钟执行的3条SQL-- 1. 新增兑换商品表 CREATE TABLE exchange_goods ( id int(11) NOT NULL AUTO_INCREMENT, goods_id int(11) NOT NULL COMMENT 关联goods表, need_points decimal(10,2) NOT NULL COMMENT 所需积分, stock int(11) NOT NULL DEFAULT 0 COMMENT 剩余库存, create_time datetime DEFAULT CURRENT_TIMESTAMP, PRIMARY KEY (id), KEY idx_goods_id (goods_id), CONSTRAINT fk_exchange_goods_goods FOREIGN KEY (goods_id) REFERENCES goods (id) ) ENGINEInnoDB DEFAULT CHARSETutf8mb4; -- 2. 给goods表加标记字段 ALTER TABLE goods ADD COLUMN is_exchange tinyint(1) DEFAULT 0 COMMENT 是否可积分兑换0否1是; -- 3. 插入测试数据1000积分兑纸巾 INSERT INTO exchange_goods (goods_id, need_points, stock) VALUES (101, 1000.00, 50);6.3 DAO层开发ExchangeGoodsDao.java的健壮写法public class ExchangeGoodsDao { // 查所有可兑换商品带库存过滤 public ListExchangeGoods listAvailable() throws SQLException { String sql SELECT eg.id, eg.goods_id, g.name, g.price, eg.need_points, eg.stock FROM exchange_goods eg JOIN goods g ON eg.goods_id g.id WHERE eg.stock 0 AND g.is_exchange 1 ORDER BY eg.need_points ASC; ListExchangeGoods list new ArrayList(); try (Connection conn DBUtil.getConnection(); PreparedStatement ps conn.prepareStatement(sql); ResultSet rs ps.executeQuery()) { while (rs.next()) { ExchangeGoods eg new ExchangeGoods(); eg.setId(rs.getInt(id)); eg.setGoodsId(rs.getInt(goods_id)); eg.setGoodsName(rs.getString(name)); eg.setGoodsPrice(rs.getBigDecimal(price)); eg.setNeedPoints(rs.getBigDecimal(need_points)); eg.setStock(rs.getInt(stock)); list.add(eg); } } return list; } // 扣减库存必须事务 public boolean reduceStock(int exchangeId, int quantity) throws SQLException { String sql UPDATE exchange_goods SET stock stock - ? WHERE id ? AND stock ?; try (Connection conn DBUtil.getConnection(); PreparedStatement ps conn.prepareStatement(sql)) { conn.setAutoCommit(false); ps.setInt(1, quantity); ps.setInt(2, exchangeId); ps.setInt(3, quantity); int rows ps.executeUpdate(); if (rows 1) { conn.commit(); return true; } else { conn.rollback(); return false; // 库存不足 } } } }关键设计WHERE stock ?SQL层面校验库存避免应用层查-改-写并发问题reduceStock()返回boolean调用方根据返回值决定是否继续积分扣减。6.4 Servlet开发ExchangeGoodsServlet.java的幂等性保障WebServlet(/exchangeGoods) public class ExchangeGoodsServlet extends HttpServlet { protected void doPost(HttpServletRequest request, HttpServletResponse response) throws ServletException, IOException { // 1. 参数校验同AddPointsServlet String exchangeIdStr request.getParameter(exchangeId); String quantityStr request.getParameter(quantity); if (exchangeIdStr null || quantityStr null) { request.setAttribute(error, 参数缺失); request.getRequestDispatcher(/error.jsp).forward(request, response); return; } int exchangeId, quantity; try { exchangeId Integer.parseInt(exchangeIdStr); quantity Integer.parseInt(quantityStr); } catch (NumberFormatException e) { request.setAttribute(error, 参数格式错误); request.getRequestDispatcher(/error.jsp).forward(request, response); return; } // 2. 查询兑换商品含关联商品信息 ExchangeGoodsDao exchangeDao new ExchangeGoodsDao(); ExchangeGoods eg exchangeDao.findById(exchangeId); // 需补写findById方法 if (eg null) { request.setAttribute(error, 商品不存在); request.getRequestDispatcher(/error.jsp).forward(request, response); return; } // 3. 计算总积分消耗 BigDecimal totalPoints eg.getNeedPoints().multiply(new BigDecimal(quantity)); // 4. 查询会员从session获取非getParameter Member member (Member) request.getSession().getAttribute(member); if (member null || member.getPointsBalance().compareTo(totalPoints) 0) { request.setAttribute(error, 积分不足); request.getRequestDispatcher(/error.jsp).forward(request, response); return; } // 5. 执行兑换事务扣库存 扣积分 记流水 Connection conn null; try { conn DBUtil.getConnection(); conn.setAutoCommit(false); // 扣库存 if (!exchangeDao.reduceStock(exchangeId, quantity)) { throw new SQLException(库存不足); } // 扣会员积分 MemberDao memberDao new MemberDao(); BigDecimal newBalance member.getPointsBalance().subtract(totalPoints); memberDao.updatePointsBalance(member.getId(), newBalance); // 记流水EXCHANGE_GOODS类型 PointsRecordDao recordDao new PointsRecordDao(); PointsRecord record new PointsRecord(); record.setMemberId(member.getId()); record.setChangePoints(totalPoints.negate()); // 负数表示扣除 record.setBalanceAfter(newBalance); record.setBizType(EXCHANGE_GOODS); record.setSourceNo(EG System.currentTimeMillis()); // 兑换单号 record.setRemark(兑换商品 eg.getGoodsName() x quantity); recordDao.insert(record); conn.commit(); request.setAttribute(success, 兑换成功已扣除 totalPoints 积分); request.getRequestDispatcher(/exchange_success.jsp).forward(request, response); } catch (SQLException e) { if (conn ! null) { try { conn.rollback(); } catch (SQLException ignored) {} } request.setAttribute(error, 兑换失败 e.getMessage()); request.getRequestDispatcher(/error.jsp).forward(request, response); } finally { if (conn ! null) { try { conn.close(); } catch (SQLException ignored) {} } } } }幂等性设计source_no用EG 时间戳生成唯一单号后续可加Redis分布式锁防重复提交所有DAO操作在同一个Connection下完成保证ACID。6.5 上线前验证清单5个必须手动跑的测试用例测试项操作步骤预期结果验证方式库存超卖开两个浏览器窗口同时对同一商品发起10件兑换请求只有1个成功另1个提示“库存不足”查exchange_goods.stock最终值是否为4050-10积分透支用积分余额500的会员尝试兑换需1000积分的商品页面报“积分不足”余额不变查member.points_balance仍为500流水完整性成功兑换后查points_record表有1条biz_typeEXCHANGE_GOODS记录change_points为负值SELECT * FROM points_record WHERE biz_typeEXCHANGE_GOODS事务回滚在reduceStock()后手动抛异常exchange_goods.stock和member.points_balance均未变化对比执行前后两表数据中文支持兑换商品名为“抽纸10包”流水remark字段正确显示中文SELECT remark FROM points_record ORDER BY id DESC LIMIT 1从那以后我每次加新功能都强制走一遍这个5项验证清单——哪怕只是改个按钮文字也要确认事务边界和异常分支。因为超市系统里一笔积分错就是真金白银的损失。希望帮到你。本文还有配套的精品资源点击获取