news 2026/10/19 6:03:00

Java原生Servlet超市积分系统:MVC实战与压测上线指南

作者头像

张小明

前端开发工程师

1.2k 24
文章封面图
Java原生Servlet超市积分系统:MVC实战与压测上线指南

简介:本资源是一套完整的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链路:

  1. View(JSP):order_confirm.jsp里表单<form action="addPoints" method="post">提交到/addPoints路径;
  2. Controller(Servlet):web.xml中<servlet-mapping>将/addPoints映射到AddPointsServlet;
  3. Controller处理:AddPointsServlet.doPost()中解析request.getParameter("orderId")和request.getParameter("amount");
  4. Model(Service逻辑):调用PointsService.addPointsByOrder(orderId, amount)——注意:项目里Service层是隐式存在的,逻辑写在Servlet里,但DAO层已分离;
  5. DAO操作:PointsRecordDao.insert(record)执行INSERT INTO points_record (...) VALUES (...),同时MemberDao.updatePointsBalance(id, newBalance)更新会员余额;
  6. 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.93

3.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用户:
  1. File → Import → Existing Projects into Workspace → 选中jf/目录;
  2. 右键项目 → Properties → Project Facets → 勾选Dynamic Web Module 3.0、Java 1.8;
  3. Deployment Assembly → Add → Folder → 选WebContent→ Deploy Path填/(不是/WebContent!)。
IntelliJ用户:
  1. File → Open → 选中jf/目录;
  2. File → Project Structure → Project → SDK选JDK 1.8,Language level选8;
  3. Modules → Dependencies → + → JARs or directories → 选WebContent/WEB-INF/lib/mysql-connector-java-5.1.47.jar;
  4. Artifacts → + → Web Application: Exploded → Output Directory选jf/out/artifacts/jf_war_exploded。

3.4 启动与验证:5分钟内看到登录页的终极检查清单

  1. 启动Tomcat(Eclipse右键Servers → Start,IntelliJ用Run Configuration);
  2. 浏览器访问http://localhost:8080/jf/(注意末尾/jf/是项目名,不是/);
  3. 若看到index.jsp重定向到login.jsp,说明Web容器正常;
  4. 用jf.sql里预置的测试账号登录:admin / 123456(管理员)或13800138000 / 123456(会员);
  5. 登录后跳转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'
ServletExchangeGoodsServlet.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项验证清单——哪怕只是改个按钮文字,也要确认事务边界和异常分支。因为超市系统里,一笔积分错,就是真金白银的损失。希望帮到你。

本文还有配套的精品资源,点击获取

版权声明: 本文来自互联网用户投稿,该文观点仅代表作者本人,不代表本站立场。本站仅提供信息存储空间服务,不拥有所有权,不承担相关法律责任。如若内容造成侵权/违法违规/事实不符,请联系邮箱:809451989@qq.com进行投诉反馈,一经查实,立即删除!
网站建设 2026/10/19 6:02:02

图像恢复实战:逆滤波与维纳滤波的Matlab实现与参数调优

做图像恢复的人基本都遇到过这种尴尬&#xff1a;一张看起来只是有点糊的图&#xff0c;直接在频域里除以传递函数&#xff0c;结果不但没变清晰&#xff0c;反而被密密麻麻的噪点淹没&#xff0c;连轮廓都认不出来。这个问题的根源&#xff0c;在于我们把噪声也一并做了除法。…

作者头像 李华
网站建设 2026/10/19 6:01:10

aljcscan安全巡检流水线:从爬虫到实战的分层扫描逻辑

简介&#xff1a;这是一套面向计算机相关专业学生与初阶开发者的安全检测实践工具集&#xff0c;聚焦网页暗链识别、敏感信息泄露扫描及关键字风险监测三大典型Web安全自查场景。资源以Java生态爬虫技术为基础&#xff0c;辅以Python脚本协同调度&#xff0c;适合作为毕业设计、…

作者头像 李华
网站建设 2026/10/19 6:00:20

pyttsx3深度拆解:离线跨平台文本转语音的原理与实践

1. 引擎内核拆解&#xff1a;为什么 pyttsx3 能离线跨平台先说个场景&#xff1a;某次我在内网环境里做一个自动播报项目&#xff0c;服务器没有外网&#xff0c;但要求在工单到达时用语音提醒值班人员。我试了一圈在线 TTS 方案&#xff0c;要么需要注册密钥&#xff0c;要么走…

作者头像 李华
网站建设 2026/10/19 5:59:49

STM32 WiFi远程温控风扇系统:DS18B20与PWM调速完整实战

如果有人问我&#xff0c;嵌入式毕业设计里哪个题目看着最不起眼、做起来却最考验基本功&#xff0c;我一定提名“STM32 WiFi远程温控风扇系统”。光看名字&#xff0c;一个风扇而已&#xff0c;可真正动手去做才会发现&#xff0c;温度采集、PWM调速、WiFi透传、远程指令下发、…

作者头像 李华
网站建设 2026/10/19 5:59:17

基于S7-1200的RS485多从站轮询读写方案与调试实录

接到这个测试台项目的时候&#xff0c;我在地铁上把需求翻了一遍&#xff1a;28台测试仪表&#xff0c;全部走RS485串到同一台西门子S7-1200 PLC里&#xff0c;要求程序连续轮询读写&#xff0c;交付物包括完整的PLC程序和触摸屏程序。第一反应是觉得工作量不大&#xff0c;轮询…

作者头像 李华
网站建设 2026/10/19 5:59:17

图表设计实战:从系统架构图到流程图的全流程指南

diagram-design听起来像是个很宽泛的词&#xff0c;但做技术写作和系统设计的人一看到它就会心一笑&#xff1a;这不就是“画图”吗。可画图这件小事&#xff0c;真正做好的人少之又少。我在团队里见过太多架构图——方框挤在一起、箭头交叉成蜘蛛网、字体忽大忽小、红配绿看得…

作者头像 李华