简介:本资源是一套完整的Java Web课程设计级项目——超市积分管理系统,面向计算机专业本科生、Java初学者及Web开发入门者,聚焦会员积分管理这一典型零售业务场景,覆盖需求分析、系统设计到部署验证的全流程实践。压缩包共5个文件,含项目报告(.doc)、答辩PPT(位于.zip内)、核心源码(.zip)、MySQL数据库脚本(.sql)及使用说明(.txt),总大小18.21MB;其中报告详述MVC架构设计与DAO层实现,SQL脚本支持快速建库建表,源码基于Servlet+JSP开发,体现JDBC数据库交互与前后端基础协同。已有360人学习下载,读者可直接运行调试系统、对照报告理解模块划分逻辑、参考PPT梳理答辩要点,并通过SQL脚本快速复现数据环境,是掌握Java Web企业级小系统开发的高实用性学习材料。
1. 一个能当天跑通、改两行代码就上线的Java超市积分系统:不是Demo,是真实可压测的MVC Web项目
上周帮某高校实验室带毕设的学生调试这个项目,他卡在“登录后跳转404”整整三天——最后发现只是web.xml里servlet-mapping路径少了个斜杠。这项目不是玩具级Demo,它用原生Servlet+JSP搭出完整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/ # 依赖jar:mysql-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.7:
jf.sql里明确写了ENGINE=InnoDB DEFAULT CHARSET=utf8mb4,支持外键约束(如points_record.member_id → member.id)和事务回滚。别用MySQL 8+,驱动类名从com.mysql.jdbc.Driver变成com.mysql.cj.jdbc.Driver,DBUtil.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`) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; -- 积分流水表:记录每一笔增减,带业务类型和来源单号 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`) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;关键设计点:
points_balance冗余在member表——避免每次查余额都要SUM(change_points)聚合,这是典型的读写分离优化;biz_type用字符串而非枚举ID,方便后期扩展(比如新增COUPON_USE类型不用改表结构);source_no字段存订单号或日期,让财务对账时能直接关联原始单据。
2.4 MVC分层落地实录:从HTTP请求到数据库写入的6步链路
以“会员消费100元获得10积分”为例,走一遍完整MVC链路:
- View(JSP):
order_confirm.jsp里表单<form action="addPoints" method="post">提交到/addPoints路径; - Controller(Servlet):
web.xml中<servlet-mapping>将/addPoints映射到AddPointsServlet; - Controller处理:
AddPointsServlet.doPost()中解析request.getParameter("orderId")和request.getParameter("amount"); - Model(Service逻辑):调用
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?memberId="+memberId)跳转到流水页。
注意:项目没显式分Service层,但
AddPointsServlet里已把业务校验(如判断订单是否已结算)、积分计算(amount * 0.1)、DAO调用三者分开写,稍加重构就能抽出Service类——这是给你的二次开发留的钩子。
3. 运行环境搭建与首次启动:绕过90%新手卡点的实操指南
3.1 JDK与Tomcat版本组合:必须用JDK 8 + Tomcat 8.5
项目编译目标字节码是Java 8(pom.xml虽无,但.class文件魔数验证为CA FE BA BE 00 00 00 34即Java 8),Tomcat必须匹配:
- ✅ 正确组合:JDK 1.8.0_202 + Apache Tomcat 8.5.93
- ❌ 危险组合:JDK 17 + Tomcat 10(Servlet API 4.0,
javax.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 CHARSET=utf8mb4,但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_user'@'localhost' IDENTIFIED BY 'jf_pass123'; GRANT ALL PRIVILEGES ON jf.* TO 'jf_user'@'localhost'; 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.8;
- Deployment Assembly → Add → Folder → 选
WebContent→ Deploy Path填/(不是/WebContent!)。
IntelliJ用户:
- File → Open → 选中
jf/目录; - File → Project Structure → Project → SDK选JDK 1.8,Language level选8;
- Modules → Dependencies → + → JARs or directories → 选
WebContent/WEB-INF/lib/mysql-connector-java-5.1.47.jar; - Artifacts → + → Web Application: Exploded → Output Directory选
jf/out/artifacts/jf_war_exploded。
3.4 启动与验证:5分钟内看到登录页的终极检查清单
- 启动Tomcat(Eclipse右键Servers → Start,IntelliJ用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.out,90%是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 现象:登录成功后跳转404,URL变成http://localhost:8080/jf/servlet/LoginServlet
原因:web.xml中servlet-mapping的url-pattern写成了/servlet/LoginServlet,但JSP表单action写的是LoginServlet(缺/servlet/前缀)或反之。
解决:统一为<url-pattern>/login</url-pattern>,JSP中<form action="login" method="post">。记住:url-pattern是浏览器地址栏看到的路径,不是类路径。
4.2 现象:中文乱码,会员姓名显示为????
原因:MySQL连接URL未指定字符集,DBUtil.getConnection()里jdbc:mysql://localhost:3306/jf缺少参数。
解决:改为jdbc:mysql://localhost:3306/jf?useUnicode=true&characterEncoding=utf8mb4&serverTimezone=Asia/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.3);updatePointsBalance()显式控制事务,因为积分变更必须和流水记录原子性执行(虽然本项目没在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); // 简化:固定会员ID=1 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 List<PointsRecord> 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 ?"; List<PointsRecord> 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_type='EXCHANGE_GOODS' |
| Servlet | ExchangeGoodsServlet.java(处理兑换请求) | AddPointsServlet中biz_type枚举加EXCHANGE_GOODS |
6.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`) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; -- 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 List<ExchangeGoods> 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"; List<ExchangeGoods> 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最终值是否为40(50-10) |
| 积分透支 | 用积分余额500的会员尝试兑换需1000积分的商品 | 页面报“积分不足”,余额不变 | 查member.points_balance仍为500 |
| 流水完整性 | 成功兑换后,查points_record表 | 有1条biz_type='EXCHANGE_GOODS'记录,change_points为负值 | SELECT * FROM points_record WHERE biz_type='EXCHANGE_GOODS' |
| 事务回滚 | 在reduceStock()后手动抛异常 | exchange_goods.stock和member.points_balance均未变化 | 对比执行前后两表数据 |
| 中文支持 | 兑换商品名为“抽纸(10包)” | 流水remark字段正确显示中文 | SELECT remark FROM points_record ORDER BY id DESC LIMIT 1 |
从那以后我每次加新功能,都强制走一遍这个5项验证清单——哪怕只是改个按钮文字,也要确认事务边界和异常分支。因为超市系统里,一笔积分错,就是真金白银的损失。希望帮到你。
本文还有配套的精品资源,点击获取