简介:本资源是一套完整的Java Web课程设计级项目实践材料,面向计算机专业本科生、Java初学者及Web开发入门者,聚焦超市会员积分管理这一典型业务场景,覆盖需求分析、系统设计到部署运行的全流程。压缩包共5个文件,含项目报告(.doc)、答辩PPT(位于.zip内)、源代码(.zip)、MySQL数据库脚本(.sql)及使用说明(.txt),总大小18.21MB;其中报告详述MVC架构设计与DAO层实现,SQL脚本支持快速建库建表,源码基于Servlet+JSP开发,体现JDBC数据库交互与前后端基础协同。已有360人学习下载,读者可直接复用完整项目结构、理解积分规则建模逻辑、参考答辩材料组织思路,并通过截图与代码对照掌握Java Web典型分层开发范式,是夯实Java面向对象、Web基础与数据库应用能力的高实用性学习包。
1. 为什么一个“超市积分系统”要硬套MVC+Servlet+JSP这套老组合?——给正在赶毕设/课设的你一份能过答辩、能跑通、能改出新功能的完整方案
不是所有Java Web项目都得上Spring Boot。去年帮某高校三个小组做中期检查时发现:80%用Spring Boot的同学卡在拦截器配置和MyBatis-Plus多表关联上,而用传统Servlet+JSP搭的那组,虽然界面朴素,但答辩时现场演示增删改查+积分兑换全流程,导师只问了两个问题就让过了。原因很简单——技术栈透明、模块边界清晰、每行代码都可追溯。这个基于Java的超市积分管理系统,就是为这种“需要快速交付、必须逻辑自洽、答辩要能手敲演示”的场景量身设计的:它不炫技,但覆盖了从会员注册、消费积分、等级升降、商品兑换到后台统计的全业务闭环;它用最基础的JDBC直连MySQL,不封装ORM黑匣子,查SQL慢了你一眼就能定位到DAO层哪条语句没加索引;它把所有Servlet按功能切片(LoginServlet、AddPointServlet、RedeemServlet),答辩时抽一个讲清楚请求怎么流转、数据怎么落库、异常怎么兜底,比背Spring生命周期容易十倍。如果你正被“毕设选题难”“代码跑不通”“答辩怕被问住”三座山压着,这份带报告、PPT、可运行源码、含建库脚本的完整包,就是你最后一周能稳住节奏的底牌。
2. 总体架构设计:三层结构与四个核心模块的职责划分
2.1 系统分层逻辑:为什么坚持“Servlet—Service—DAO”铁三角?
这个系统没有用任何框架,但严格遵循经典三层架构:
- 表现层(View):由JSP页面构成,仅负责数据展示和简单表单提交,禁止在JSP里写Java业务逻辑(如
<% if(user.getPoints()>1000) { ... } %>这类代码全部被我删掉重写成EL表达式+JSTL标签); - 控制层(Controller):每个Servlet对应一个明确业务动作,比如
PointRecordServlet只处理“查询积分流水”,不碰用户信息也不管兑换逻辑; - 业务层(Service):真正做决策的地方,例如
PointService.calculateLevel()方法里封装了“积分满5000升黄金会员,满10000升钻石会员”的规则,且所有Service方法都声明抛出BusinessException,强制上层处理业务异常; - 数据层(DAO):每个DAO类只操作一张表,
MemberDAO管会员表,PointLogDAO管积分日志表,绝不出现跨表JOIN——多表关联逻辑全放在Service层用两次单表查询+内存组装完成,这样调试时断点打在哪一层都一目了然。
提示:很多同学把DAO写成“万能查询类”,一个
BaseDAO.query(String sql, Object... args)方法走天下,结果SQL写错报SQLException时根本不知道是哪个业务调用的。本方案中每个DAO方法名都带业务语义,如PointLogDAO.insertConsumeLog(int memberId, int points, String remark),看到方法名就知道它干啥、参数怎么填、失败会抛什么异常。
2.2 四大核心模块功能边界与交互关系
系统拆解为四个高内聚模块,模块间通过接口或DTO通信,杜绝包级循环依赖:
| 模块名称 | 主要职责 | 关键实现文件 | 数据流向说明 |
|---|---|---|---|
| 会员管理模块 | 会员注册、登录、信息修改、等级查询 | MemberServlet,MemberService,MemberDAO | 登录成功后将Member对象存入Session,后续所有积分操作均通过session.getAttribute("loginUser")获取当前用户ID |
| 积分操作模块 | 消费积分、充值积分、积分冻结/解冻 | AddPointServlet,DeductPointServlet,FreezePointServlet | 所有积分变更操作均先校验余额(if (currentPoints < deductPoints) throw new BusinessException("积分不足")),再执行数据库更新,最后写日志 |
| 积分日志模块 | 记录每一笔积分变动(类型、来源、时间、操作人) | PointLogServlet,PointLogDAO | 日志表point_log含log_type字段(1=消费,2=充值,3=兑换,4=过期),报表统计时直接按此字段GROUP BY |
| 商品兑换模块 | 兑换商品列表、库存扣减、兑换记录生成 | RedeemServlet,ProductDAO,RedeemRecordDAO | 兑换前双重校验:① 用户积分是否足够;② 商品库存stock > 0,任一失败立即回滚,避免出现“积分扣了但商品没兑到”的资损 |
2.3 数据库设计:从ER图到关键约束的落地细节
数据库脚本jf.sql采用MySQL 5.7,共6张表,核心设计原则是“宁可多查一次,不写复杂SQL”:
-- 会员表:主键id,唯一约束mobile,索引level_id加速等级查询 CREATE TABLE member ( id INT PRIMARY KEY AUTO_INCREMENT, mobile VARCHAR(11) NOT NULL UNIQUE, name VARCHAR(20), points INT DEFAULT 0, level_id TINYINT DEFAULT 1, -- 1:普通,2:白银,3:黄金,4:钻石 create_time DATETIME DEFAULT CURRENT_TIMESTAMP, INDEX idx_level (level_id) ); -- 积分日志表:重点!log_type+member_id+create_time三字段联合索引,支撑高频查询 CREATE TABLE point_log ( id BIGINT PRIMARY KEY AUTO_INCREMENT, member_id INT NOT NULL, log_type TINYINT NOT NULL, -- 1消费 2充值 3兑换 4过期 points_change INT NOT NULL, -- 可正可负 remark VARCHAR(100), create_time DATETIME DEFAULT CURRENT_TIMESTAMP, INDEX idx_member_type_time (member_id, log_type, create_time) ); -- 兑换商品表:status字段控制上下架,避免物理删除导致日志关联失效 CREATE TABLE product ( id INT PRIMARY KEY AUTO_INCREMENT, name VARCHAR(50) NOT NULL, points_required INT NOT NULL, stock INT NOT NULL DEFAULT 0, status TINYINT DEFAULT 1 -- 1上架 0下架 );关键设计说明:
- 不设外键:MySQL外键会拖慢批量插入性能,且毕业答辩演示时若因外键约束报错,学生很难快速定位。所有关联逻辑(如“查某会员所有兑换记录”)由Service层用两次查询实现:先查
redeem_record.member_id = ?,再根据product_id查商品信息; - 时间字段统一用DATETIME:不用TIMESTAMP(时区问题易翻车),所有时间值由Java端
new Date()生成,数据库不做默认值计算; - 积分字段全用INT:拒绝FLOAT/DECIMAL,避免浮点数精度问题(比如0.1+0.2≠0.3),积分本质是整数计数单位;
- 状态字段用TINYINT而非VARCHAR:
level_id、log_type、status全用数字编码,JSP页面用JSTL<c:choose>做映射,既节省存储又提升查询速度。
3. 核心模块实现:从登录验证到积分兑换的代码级落地
3.1 登录模块:Session管理与防重复提交的双重保险
LoginServlet是整个系统的入口,其实现必须解决两个真实痛点:一是用户登出后历史页面仍能访问(未清Session),二是网络延迟导致用户狂点登录按钮引发重复插入。解决方案如下:
// LoginServlet.java protected void doPost(HttpServletRequest req, HttpServletResponse resp) throws ServletException, IOException { String mobile = req.getParameter("mobile"); String password = req.getParameter("password"); // 【防重复提交】检查请求头是否有自定义标记(前端表单JS生成) String token = req.getHeader("X-Request-Token"); if (token == null || !token.equals(req.getSession().getAttribute("formToken"))) { req.setAttribute("error", "请勿重复提交"); req.getRequestDispatcher("/login.jsp").forward(req, resp); return; } Member member = memberService.login(mobile, password); if (member != null) { // 【Session安全】清除旧Session,设置新Session属性 HttpSession session = req.getSession(true); session.removeAttribute("loginUser"); // 防止旧Session残留 session.setAttribute("loginUser", member); session.setAttribute("loginTime", new Date()); // 记录登录时间,用于超时判断 // 【Token刷新】生成新Token防止F5刷新重复提交 String newToken = UUID.randomUUID().toString().replace("-", ""); session.setAttribute("formToken", newToken); resp.sendRedirect("main.jsp"); // 重定向避免后退重复登录 } else { req.setAttribute("error", "手机号或密码错误"); req.getRequestDispatcher("/login.jsp").forward(req, resp); } }设计说明:
- Token机制:前端登录表单加载时,用JS读取
session.getAttribute("formToken")并写入隐藏域<input type="hidden" name="token" value="${formToken}">,同时在AJAX请求头中携带X-Request-Token。每次成功登录后生成新Token,确保同一Session下Token单次有效; - Session清理:
removeAttribute("loginUser")不是可选项——曾有学生反馈“登出后点浏览器后退还能进会员中心”,根源就是旧Session未清除; - 重定向跳转:
sendRedirect而非forward,避免用户F5刷新触发doPost重执行,这是Web开发血泪经验。
3.2 积分变动模块:事务边界与幂等性保障
积分增减是资金类操作,必须保证ACID。本系统在PointService中手动控制事务,不依赖容器(因为没用框架):
// PointService.java public void addPoints(int memberId, int points, String remark) throws BusinessException { Connection conn = null; PreparedStatement ps1 = null; PreparedStatement ps2 = null; try { conn = JdbcUtil.getConnection(); // 自定义工具类,从druid连接池取连接 conn.setAutoCommit(false); // 关闭自动提交 // 步骤1:更新会员积分 String sql1 = "UPDATE member SET points = points + ? WHERE id = ?"; ps1 = conn.prepareStatement(sql1); ps1.setInt(1, points); ps1.setInt(2, memberId); int rows1 = ps1.executeUpdate(); if (rows1 != 1) { throw new BusinessException("更新会员积分失败"); } // 步骤2:插入积分日志 String sql2 = "INSERT INTO point_log (member_id, log_type, points_change, remark) VALUES (?, ?, ?, ?)"; ps2 = conn.prepareStatement(sql2); ps2.setInt(1, memberId); ps2.setByte(2, (byte) 2); // 2=充值 ps2.setInt(3, points); ps2.setString(4, remark); ps2.executeUpdate(); conn.commit(); // 手动提交 } catch (SQLException e) { if (conn != null) { try { conn.rollback(); // 出错回滚 } catch (SQLException ex) { throw new BusinessException("事务回滚失败", ex); } } throw new BusinessException("积分充值失败", e); } finally { JdbcUtil.close(ps2, ps1, conn); // 工具类确保资源释放 } }关键设计点:
- Connection复用:两个
PreparedStatement共享同一个Connection,保证在同一线程同一次数据库会话中执行,这是事务生效的前提; - 异常分类处理:
BusinessException用于业务校验失败(如积分不足),SQLException用于数据库层面错误(如主键冲突),答辩时被问“怎么区分异常类型”,直接指这段代码; - JdbcUtil工具类:封装了Druid连接池初始化、
getConnection()、close()方法,避免学生自己写连接池配置——druid.properties已预置在src/main/resources下,只需改数据库地址密码即可运行。
3.3 商品兑换模块:库存扣减与兑换记录的强一致性
兑换流程涉及商品库存减少和用户兑换记录生成,必须原子化。这里采用“先锁库存再写记录”的策略,避免超卖:
// RedeemService.java public void redeemProduct(int memberId, int productId, int quantity) throws BusinessException { Connection conn = null; PreparedStatement ps1 = null; PreparedStatement ps2 = null; ResultSet rs = null; try { conn = JdbcUtil.getConnection(); conn.setAutoCommit(false); // 【关键】SELECT ... FOR UPDATE 锁定商品行,防止并发超卖 String sql1 = "SELECT stock FROM product WHERE id = ? AND status = 1 FOR UPDATE"; ps1 = conn.prepareStatement(sql1); ps1.setInt(1, productId); rs = ps1.executeQuery(); if (!rs.next()) { throw new BusinessException("商品不存在或已下架"); } int currentStock = rs.getInt("stock"); if (currentStock < quantity) { throw new BusinessException("库存不足"); } // 【扣库存】 String sql2 = "UPDATE product SET stock = stock - ? WHERE id = ?"; ps2 = conn.prepareStatement(sql2); ps2.setInt(1, quantity); ps2.setInt(2, productId); if (ps2.executeUpdate() != 1) { throw new BusinessException("库存扣减失败"); } // 【写兑换记录】 String sql3 = "INSERT INTO redeem_record (member_id, product_id, quantity, create_time) VALUES (?, ?, ?, ?)"; PreparedStatement ps3 = conn.prepareStatement(sql3); ps3.setInt(1, memberId); ps3.setInt(2, productId); ps3.setInt(3, quantity); ps3.setTimestamp(4, new Timestamp(System.currentTimeMillis())); ps3.executeUpdate(); conn.commit(); } catch (SQLException e) { if (conn != null) { try { conn.rollback(); } catch (SQLException ex) { throw new BusinessException("事务回滚失败", ex); } } throw new BusinessException("商品兑换失败", e); } finally { JdbcUtil.close(rs, ps2, ps1, conn); } }为什么用SELECT ... FOR UPDATE?
- 场景还原:假设商品A库存剩1件,用户甲、乙同时点击兑换。若不用行锁,可能甲查到
stock=1,乙也查到stock=1,然后甲扣减成功(stock=0),乙也扣减成功(stock=-1),造成超卖; FOR UPDATE让第一个执行SELECT的事务获得该行排他锁,第二个事务会被阻塞,直到第一个事务提交或回滚后才继续执行,天然解决并发问题;- 注意:此锁只在事务内有效,所以必须配合
conn.setAutoCommit(false)使用,否则锁立刻释放。
4. 常见问题排查:这5个坑我替你踩过了,别再重蹈覆辙
4.1 现象:登录成功后跳转到main.jsp,但页面显示“null”或空白
原因:main.jsp中用<%= session.getAttribute("loginUser").getName() %>直接取值,而loginUser为null(Session未正确设置或已被销毁)。
解决:
- 检查
LoginServlet中是否调用了session.setAttribute("loginUser", member); - 在
main.jsp顶部添加空值判断:
<%@ page import="com.example.model.Member" %> <% Member user = (Member) session.getAttribute("loginUser"); if (user == null) { response.sendRedirect("login.jsp"); return; } %>- 检查Tomcat配置:
conf/web.xml中<session-config>的<session-timeout>是否设为-1(永不过期)或足够大(如30分钟),避免演示时Session意外失效。
4.2 现象:积分日志查询页面报SQLException: Column 'log_type' not found
原因:MySQL驱动版本与JDBC URL参数不匹配。本项目使用MySQL 5.7,但部分同学下载了mysql-connector-java-8.x.jar,其默认开启serverTimezone校验。
解决:
- 方案一(推荐):降级驱动,用
mysql-connector-java-5.1.47.jar; - 方案二:修改JDBC URL,在
jdbc:mysql://localhost:3306/jf后添加参数:
# jdbc.properties jdbc.url=jdbc:mysql://localhost:3306/jf?useUnicode=true&characterEncoding=UTF-8&serverTimezone=GMT%2B8- 验证方法:在
JdbcUtil.getConnection()中打印conn.getMetaData().getURL(),确认URL含serverTimezone参数。
4.3 现象:兑换商品时提示“库存不足”,但数据库里product.stock明明大于0
原因:RedeemService.redeemProduct()中SELECT ... FOR UPDATE语句未加AND status = 1条件,导致查到下架商品(status=0)的库存,但后续UPDATE因WHERE id = ? AND status = 1不匹配而影响0行。
解决:
- 严格按本文3.3节代码编写,
SELECT和UPDATE的WHERE条件必须完全一致; - 在
product表增加复合索引:ALTER TABLE product ADD INDEX idx_status_stock (status, stock);加速带状态筛选的库存查询。
4.4 现象:项目报告里“系统测试”章节写“所有功能测试通过”,但答辩时导师让现场演示“会员等级自动升级”,系统没反应
原因:等级升级逻辑写在PointService.addPoints()里,但只在“充值积分”时触发,而消费积分(deductPoints)未同步检查等级变化。
解决:
- 在
addPoints()和deductPoints()方法末尾都调用updateMemberLevel(memberId); updateMemberLevel()方法逻辑:
private void updateMemberLevel(int memberId) throws BusinessException { Member member = memberDAO.findById(memberId); int points = member.getPoints(); byte newLevel = 1; if (points >= 10000) newLevel = 4; else if (points >= 5000) newLevel = 3; else if (points >= 1000) newLevel = 2; if (newLevel != member.getLevelId()) { memberDAO.updateLevel(memberId, newLevel); } }4.5 现象:答辩PPT里画了“MVC三层架构图”,但导师问“Controller层怎么接收参数”,答“用request.getParameter()”,被追问“如果参数是JSON怎么办”,当场卡壳
原因:项目实际只处理表单提交(Content-Type: application/x-www-form-urlencoded),未实现JSON接口,但PPT过度包装。
解决:
- 答辩时诚实说明:“本系统面向超市收银台PC端使用,所有交互通过HTML表单提交,未设计移动端JSON接口。若需扩展,可在Servlet中增加对
application/json类型的判断,用request.getReader()读取JSON字符串,再用Jackson解析”; - 提前准备一句备用回答:“我们预留了接口扩展能力,比如在
BaseServlet中增加parseJsonRequest()方法,解析后转为Map,再传给Service层——这部分代码已写在dev/json-support分支,演示需要我可以现场切过去”。
5. 答辩验收指南:从环境部署到答辩话术的全流程通关策略
5.1 三步极速部署:5分钟让系统在导师电脑上跑起来
别信“一键部署”,毕业答辩现场没时间折腾。按以下顺序操作,确保零失败:
第一步:数据库初始化(2分钟)
- 打开MySQL命令行(或Navicat),执行:
CREATE DATABASE jf CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci; USE jf; SOURCE /path/to/jf.sql; -- 替换为你的jf.sql绝对路径- 验证:
SELECT COUNT(*) FROM member;应返回10(脚本内置10个测试会员)
第二步:Tomcat配置(1分钟)
- 解压
jf.zip到webapps/目录下,重命名为jf; - 修改
jf/WEB-INF/classes/jdbc.properties:
jdbc.driver=com.mysql.jdbc.Driver jdbc.url=jdbc:mysql://localhost:3306/jf?useUnicode=true&characterEncoding=UTF-8 jdbc.username=root jdbc.password=123456 -- 改为你本地MySQL密码- 启动Tomcat:双击
bin/startup.bat(Windows)或./startup.sh(Mac/Linux)
第三步:浏览器验证(1分钟)
- 访问
http://localhost:8080/jf/login.jsp; - 用测试账号登录:手机号
13800138000,密码123456; - 成功进入首页后,点击“积分查询”→“查看流水”,确认显示至少3条日志——此时举手告诉导师:“系统已部署完成,现在可以开始演示”。
注意:全程不要提“IDEA”“Eclipse”,导师电脑大概率没装开发工具。所有操作必须基于Tomcat原生部署,这是答辩基本功。
5.2 答辩演示脚本:聚焦“我能解决什么问题”,而非“我用了什么技术”
导师最关心的是“这系统能不能用”,不是“你写了多少行代码”。按此脚本演示,控制在8分钟内:
| 时间 | 动作 | 话术要点 | 导师关注点 |
|---|---|---|---|
| 0:00-1:30 | 登录+查看会员信息 | “这是张经理,手机13800138000,当前积分2850,等级白银。注意看右上角‘等级说明’,白银会员享95折——这个折扣在收银系统结算时会自动应用。” | 业务真实性、数据准确性 |
| 1:30-3:00 | 模拟消费:输入金额100元,点击“生成积分” | “超市规则是1元=1积分,所以100元产生100积分。您看积分从2850变成2950,同时下方‘积分流水’新增一条记录,类型是‘消费’,备注是‘收银台001’——这对应真实收银员工号。” | 流程闭环、日志可追溯 |
| 3:00-5:00 | 兑换商品:选“定制保温杯”(需500积分),点兑换 | “库存剩8个,兑换后库存变7,用户积分减500。重点看这里:兑换成功后,页面弹出‘兑换成功’提示,同时数据库redeem_record表新增记录,product.stock实时更新——这保证了财务和库存数据强一致。” | 并发安全、数据一致性 |
| 5:00-7:00 | 后台统计:点击“报表中心”→“月度积分TOP10” | “这是管理员视角,按自然月统计消费积分最多的10个会员。SQL是SELECT m.name, SUM(p.points_change) FROM point_log p JOIN member m ON p.member_id=m.id WHERE p.log_type=1 AND p.create_time BETWEEN ? AND ? GROUP BY m.id ORDER BY SUM(p.points_change) DESC LIMIT 10——所有统计逻辑都在Service层,可随时导出Excel。” | 管理价值、可扩展性 |
| 7:00-8:00 | 主动提问:“如果要增加微信扫码登录,技术上怎么改?” | “我会在LoginServlet里增加/wechat-login接口,调用微信OAuth2.0获取openid,再查库匹配手机号。关键改动只有3处:① 新增WeChatLoginServlet;② MemberDAO加findByOpenid方法;③ 前端加微信图标按钮。其他会员、积分逻辑完全复用。” | 技术理解深度、架构延展性 |
5.3 答辩问答应答模板:把“不会”转化成“我这样考虑”
遇到不会的问题,千万别沉默。用这个结构回答,展现工程思维:
问题:“为什么不用Redis缓存积分?数据库查询很慢吧?”
回答:“这是个好问题。我们评估过Redis,但最终选择直连MySQL,主要基于三点现实约束:第一,超市日活用户约200人,峰值QPS不到5,MySQL单机完全扛得住;第二,积分变动必须100%强一致,Redis+MySQL双写有数据不一致风险,而超市财务对资损零容忍;第三,毕设答辩要求代码可读、可调试,Redis引入序列化、连接池、缓存穿透等新概念,反而增加解释成本。如果未来用户量涨到10万,我会在PointService层加Redis缓存会员当前积分,用‘Cache Aside’模式,写DB成功后再删缓存——这部分设计已写在项目报告‘优化方向’章节第3.2小节。”
问题:“DAO层没用泛型,是不是设计缺陷?”
回答:“不是缺陷,是刻意为之。泛型在DAO层会增加学习成本,比如MemberDAO extends BaseDAO<Member>,学生要理解类型擦除、反射实例化等概念。而本项目DAO方法名已带业务语义(如insertMember(Member m)),且所有DAO类都继承BaseDAO复用executeUpdate等通用方法——既保持简洁,又不牺牲可维护性。我们在BaseDAO的JavaDoc里明确写了‘子类必须实现getTableName()方法’,这就是契约,比泛型约束更直观。”
从那以后我每次带学生做答辩,都强制他们提前两天在导师办公室电脑上走一遍部署+演示全流程,哪怕只是打开浏览器输个网址。因为真正的验收不是代码跑通,而是当导师说“你来操作一下”,你能面不改色地敲完所有命令、点完所有按钮、说出每一句该说的话。希望帮到你。
本文还有配套的精品资源,点击获取