简介:这是一套基于JavaWeb实现的必胜客在线订餐系统项目包,面向正在学习JavaWeb、准备课程设计或毕业设计的学生与初级开发者,围绕用户注册登录、菜单浏览、购物车、下单支付及后台菜品与订单管理等典型餐饮业务场景展开,可帮助读者打通从需求分析、数据库设计到前后端交互的完整链路。压缩包共1307个文件,约66.88MB,以png、jpg等页面图片素材,java源码、class编译文件、jsp与html视图、css与js脚本,以及jar依赖、xml与properties配置和sql建表脚本为主,基本覆盖开发、部署与数据初始化所需内容。目前已有2518人学习。项目采用MVC思路组织Servlet、JSP与JavaBean,配合数据库访问层完成业务逻辑,读者可重点研读请求处理、数据校验、订单状态流转与页面异步交互,也能从配置和目录结构中理解Tomcat部署方式,适合作为课程设计模板或JavaWeb综合实践参考。
1. 拿到一个 JavaWeb 订餐系统源码包,先分清它是 JSP+Servlet 还是 SSM
把压缩包解开之后,第一件事不是打开 IDE 新建工程,而是先ls一遍目录。JavaWeb 这个词在课程设计资源里覆盖的跨度极大:一种是最经典的 JSP + Servlet + JDBC 直连 MySQL 的三层结构,另一种是 Spring + SpringMVC + MyBatis 的整合工程,两者的启动方式、依赖管理,甚至配置文件的后缀都不一样。这个在线订餐系统属于哪一类,决定了你接下来半小时是顺风顺水还是连续翻车。它真正值钱的地方在于提供了一条完整的点餐链路:菜单浏览、加入购物车、提交订单、后台改单,四段都能跑通,而不是一堆互不相干的 CRUD 页面拼起来的样子货。适合两类人:赶课程设计需要一套能演示、能讲清逻辑的骨架;以及刚学完 Servlet 想找一个有业务味道的练手项目,而不是继续在图书管理系统的增删改查里打转。下面按「先判断、再跑通、后拆解、最后避坑」的顺序走,每一步都落到你能直接抄的命令和参数上。
2. 环境对齐:JDK、Tomcat、MySQL 三件套版本核对
这类包最容易出问题的地方不在代码,而在版本。课程设计资源普遍有年份,包里的mysql-connector-java-5.1.x.jar配 MySQL 8 会直接报驱动类找不到,javax.servlet与jakarta.servlet混用则会在 Tomcat 10 上整站 404。我一般会先把版本关系钉死,再谈别的。
2.1 看目录判断技术栈,用一次 find 摸清家底
传统工程和 Maven 工程的区分非常明显,看有没有pom.xml就够了。但光看这个还不够,得顺手把 jar 包、SQL 脚本、配置文件全捞出来,心里有张地图,后面排错才不会靠猜。
| 你看到的目录/文件 | 大概率是什么 | 后续动作 |
|---|---|---|
WEB-INF/web.xml+WEB-INF/lib/*.jar+src | 传统 JSP+Servlet 工程 | 按普通 Web 工程导入,jar 手动挂到 classpath |
pom.xml+src/main/java+src/main/webapp | Maven 工程,多为 SSM | 先mvn dependency:tree,看依赖有没有冲突 |
applicationContext.xml/spring-mvc.xml | Spring 系整合 | 检查包扫描路径和数据源 Bean |
*.sql(常见名order_db.sql、db.sql) | 建库建表脚本 | 先导库,再改连接参数 |
jdbc.properties/db.properties | 连接参数外置 | 优先改这里,别动 Java 源码里的硬编码 |
# 解压后先做一次结构扫描,别急着导 IDE unzip -q online-order.zip -d order-src cd order-src # 1. 看清目录层级,判断是 webapp 结构还是标准 Maven 结构 find . -maxdepth 3 -type d | sort # 2. 把所有 jar 列出来,重点看 mysql 驱动和 servlet-api 的版本 find . -name "*.jar" -exec basename {} \; | sort -u # 3. 定位三个关键文件:web.xml、pom.xml、建库脚本 find . \( -name "web.xml" -o -name "pom.xml" -o -name "*.sql" -o -name "jdbc.properties" \) -print这三条命令里,第一条决定导入方式,第二条决定版本兼容性,第三条决定你改配置时该打开哪个文件。第二条尤其关键:如果看到mysql-connector-java-5.1.47.jar,而你本机是 MySQL 8,就要么换成mysql-connector-java-8.0.x,要么把驱动的类名从com.mysql.jdbc.Driver改成com.mysql.cj.jdbc.Driver,两者只能选一个,混着来必报ClassNotFoundException。servlet-api 的 jar 如果出现在WEB-INF/lib里,部署前记得删掉,容器自己会提供,重复了会引发类加载冲突。
2.2 数据库先跑通:建表、导数据、改四个连接参数
代码可以慢慢看,数据库必须先活。绝大多数「页面空白、后台报 500」的根因,都是连接串里缺参数或者库名对不上。
-- 建库时字符集一次到位,别用默认的 latin1 再回头改 CREATE DATABASE IF NOT EXISTS `order_db` DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_general_ci; USE `order_db`; -- 菜品表:status 控制上下架,stock 用于下单时扣减 CREATE TABLE `dish` ( `id` INT PRIMARY KEY AUTO_INCREMENT, `name` VARCHAR(64) NOT NULL, `price` DECIMAL(10,2) NOT NULL, `stock` INT NOT NULL DEFAULT 0, `img` VARCHAR(255) DEFAULT NULL, -- 只存相对路径,如 /upload/dish/1.jpg `status` TINYINT NOT NULL DEFAULT 1 ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; -- 订单主表:total 是下单瞬间的金额快照 CREATE TABLE `orders` ( `id` BIGINT PRIMARY KEY AUTO_INCREMENT, `order_no` VARCHAR(32) NOT NULL UNIQUE, `user_id` INT NOT NULL, `total` DECIMAL(10,2) NOT NULL, `status` TINYINT NOT NULL DEFAULT 0, -- 0待支付 1已支付 2配送中 3已完成 4已取消 `create_time` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, KEY `idx_user` (`user_id`) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;连接参数建议单独放一个 properties 文件,改的时候不用重新编译:
# src/main/resources/jdbc.properties jdbc.driver=com.mysql.cj.jdbc.Driver jdbc.url=jdbc:mysql://127.0.0.1:3306/order_db?useUnicode=true&characterEncoding=utf8&serverTimezone=Asia/Shanghai&useSSL=false&allowPublicKeyRetrieval=true jdbc.username=root jdbc.password=改成你自己的密码连接串里的四个参数各自管一件事,缺一个就会以很奇怪的方式失败:characterEncoding=utf8决定中文能不能正常入库;serverTimezone=Asia/Shanghai不加,MySQL 8 会报时区异常,订单时间直接是 null;useSSL=false是为了本地开发省掉证书握手;allowPublicKeyRetrieval=true是 MySQL 8 默认认证插件下必须开的,否则第一次连接就被拒绝。密码不要留在代码里,也不要提交到任何仓库,这是血泪经验。
// 读取配置并统一管理连接,避免每个 DAO 里都写一遍 DriverManager public class DBUtil { private static final Properties PROPS = new Properties(); static { try (InputStream in = DBUtil.class.getClassLoader() .getResourceAsStream("jdbc.properties")) { PROPS.load(in); Class.forName(PROPS.getProperty("jdbc.driver")); // 老驱动这里会抛异常 } catch (Exception e) { throw new ExceptionInInitializerError("数据源初始化失败: " + e.getMessage()); } } public static Connection getConnection() throws SQLException { return DriverManager.getConnection( PROPS.getProperty("jdbc.url"), PROPS.getProperty("jdbc.username"), PROPS.getProperty("jdbc.password")); } }这段代码的关键在静态块:Class.forName一旦失败,整个类初始化就崩,报错会非常明确地指向驱动问题,比运行时在某个 DAO 里抛NullPointerException好排查得多。getResourceAsStream用的是 classloader 路径,所以jdbc.properties必须放在src/main/resources或者WEB-INF/classes下,放错位置读出来就是 null。
2.3 编译、打包、部署:war 包怎么放,context path 怎么定
Maven 工程一条命令解决,传统工程走 IDEA 的 Artifacts 配置,两条路都要清楚。
# Maven 工程:跳过测试直接打包 mvn -DskipTests clean package # 产物一般在 target 下,确认 war 包名字 ls -lh target/*.war # 丢进 Tomcat 的 webapps,war 名就是 context path cp target/order.war "$CATALINA_HOME"/webapps/ # 启动并观察日志,重点看有没有 SEVERE 和数据库连接异常 "$CATALINA_HOME"/bin/startup.sh tail -f "$CATALINA_HOME"/logs/catalina.out如果 war 包叫order.war,访问地址就是http://localhost:8080/order/,末尾的斜杠不能省,省了在部分容器版本上会 404。非 Maven 工程则要把WEB-INF/lib下所有 jar 一起打进 artifact 的WEB-INF/lib,漏一个就是NoClassDefFoundError。启动日志里如果出现Communications link failure,说明 MySQL 没起来或端口不通;出现Unknown database 'order_db',说明建库脚本没执行或者库名改了没同步;这两类错误占首次部署失败的八成以上,先看日志再改代码。
3. 订单主链路:从加购物车到落库的四个关键节点
点餐系统的业务复杂度几乎全压在订单上。菜单和用户是静态数据,订单是有状态、有并发、有金额的,写得好不好在这里拉开差距。这一段拆的是包里最值得看的部分,也是二次改造时最容易改出 bug 的地方。
3.1 购物车放 Session 还是放数据库
课程设计级项目基本都是 Session,简单直接,但也埋着坑。
// 购物车结构:dishId -> {数量, 下单时的单价快照} // 不直接缓存 Dish 实体,避免菜品改价后购物车金额跟着漂 public class CartItem implements Serializable { private Integer dishId; private String name; private BigDecimal price; // 加入购物车时锁定的价格 private int qty; // getter / setter 省略 } // 加购:已存在就累加数量,不存在就新建 @WebServlet("/cart/add") public class CartAddServlet extends HttpServlet { protected void doPost(HttpServletRequest req, HttpServletResponse resp) throws ServletException, IOException { int dishId = Integer.parseInt(req.getParameter("dishId")); int qty = Integer.parseInt(req.getParameter("qty")); HttpSession session = req.getSession(); Map<Integer, CartItem> cart = (Map<Integer, CartItem>) session.getAttribute("cart"); if (cart == null) { cart = new LinkedHashMap<>(); // 保持加入顺序,页面显示更稳定 session.setAttribute("cart", cart); } CartItem item = cart.get(dishId); if (item == null) { Dish dish = dishService.findById(dishId); // 查一次库拿名称和价格 item = new CartItem(dishId, dish.getName(), dish.getPrice(), 0); cart.put(dishId, item); } item.setQty(item.getQty() + qty); resp.sendRedirect(req.getContextPath() + "/cart/list"); } }用LinkedHashMap而不是HashMap,是为了让购物车列表按用户加购顺序展示,HashMap的遍历顺序在不同 JDK 版本上并不一致,偶尔会出现「加了两道菜顺序乱跳」的玄学现象。价格在加购时锁进CartItem,是为了给下面的事务做铺垫——下单时必须用锁定的价,而不是下单瞬间重新查库的价。
Session 方案的天花板也很清楚:换浏览器购物车就没了,多实例部署时不共享。要往生产方向走,就得把购物车落到cart表,用user_id + dish_id做唯一键,INSERT ... ON DUPLICATE KEY UPDATE qty = qty + ?一条 SQL 搞定累加。这一步改造量不大,但讲清楚为什么改,比直接堆一个 Redis 更有说服力。
3.2 下单事务:金额快照、明细落库、库存扣减必须在一个事务里
这是整个项目最该逐行读的方法。写错的话,线上表现就是「订单成功了但库存没动」,或者「扣了库存订单没生成」。
public String submitOrder(int userId, Map<Integer, CartItem> cart) throws SQLException { Connection conn = null; try { conn = DBUtil.getConnection(); conn.setAutoCommit(false); // 关闭自动提交,事务从这里开始 // 1. 先算总额并生成订单号(简化版,生产建议用雪花或号段) String orderNo = "OD" + System.currentTimeMillis(); BigDecimal total = BigDecimal.ZERO; for (CartItem it : cart.values()) { total = total.add(it.getPrice().multiply(BigDecimal.valueOf(it.getQty()))); } // 2. 主表落库,status=0 待支付 long orderId = orderDao.insert(conn, orderNo, userId, total, 0); for (CartItem it : cart.values()) { // 3. 明细表存价格快照,历史订单不受后续改价影响 orderItemDao.insert(conn, orderId, it.getDishId(), it.getPrice(), it.getQty()); // 4. 带条件的库存扣减:影响行数为 0 说明库存不够 int affected = dishDao.reduceStock(conn, it.getDishId(), it.getQty()); if (affected == 0) { throw new SQLException("库存不足, dishId=" + it.getDishId()); } } conn.commit(); return orderNo; } catch (SQLException e) { if (conn != null) conn.rollback(); // 任何一步失败整体回滚 throw e; } finally { if (conn != null) { conn.setAutoCommit(true); conn.close(); } } }配套的扣减 SQL 必须是带条件的更新,不能先 select 再 update:
-- 用一条语句完成「判断 + 扣减」,把并发判断交给数据库 UPDATE `dish` SET `stock` = `stock` - #{qty} WHERE `id` = #{dishId} AND `stock` >= #{qty};AND stock >= qty这个条件就是并发防线。两个用户同时抢最后一份,MySQL 的行锁会让第二条更新看到扣减后的值,条件不成立,返回影响行数 0,Java 层据此抛异常回滚。如果改成先SELECT stock判断够不够、再UPDATE,中间那段窗口期足够插进另一个事务,超卖就发生了。同一个Connection贯穿所有 DAO 也是必须的,很多人图省事在每个 DAO 里各自getConnection(),那种写法下setAutoCommit(false)完全失效,回滚只回滚了个寂寞。
3.3 订单状态机:别让后台能随便改状态
状态字段用数字没问题,问题在于没有约束。后台管理页面上一个「修改状态」的下拉框,如果直接UPDATE orders SET status = ?,运营手一抖就能把已完成的订单改回待支付。
-- 只允许合法跃迁:待支付(0) -> 已支付(1) -> 配送中(2) -> 已完成(3) -- 取消只允许从 0 或 1 发起 UPDATE `orders` SET `status` = #{to} WHERE `id` = #{orderId} AND `status` = #{from};执行后检查影响行数,为 0 就提示「订单状态已变化,请刷新后重试」。这一招顺带解决了重复提交:用户连点两次「确认收货」,第二次的from已经不是 2 了,更新自然失败,不会重复触发积分或通知逻辑。状态跃迁表建议直接写死在 Service 里,用一个Map<Integer, Set<Integer>>存允许的下一状态,比散落在各个 if 里安全得多。这里不需要上状态机框架,但要有一张明确的表,至少自己排错时能一眼看出哪条路径是非法的。
3.4 分页与菜品图片:两个小功能,两个大坑
分页别用LIMIT offset, size配全表COUNT(*)就完事,菜品表数据量小的时候看不出问题,做二次开发一造几万条数据就慢了。常规写法是两条 SQL:一条SELECT ... ORDER BY id DESC LIMIT ?, ?,一条SELECT COUNT(*) FROM dish WHERE status = 1,两条共用同一套 where 条件,条件拼错就会导致「总数 100 条但翻到第 3 页就空了」。
// 分页参数做边界收敛,防止 page=0 或 size=10000 这类脏输入 int page = Math.max(1, Integer.parseInt(req.getParameter("page") == null ? "1" : req.getParameter("page"))); int size = 8; // 每页固定 8 道菜,前端三列布局刚好 int offset = (page - 1) * size; List<Dish> list = dishDao.findByPage(offset, size); int total = dishDao.countOnSale(); int pages = (total + size - 1) / size; // 向上取整,别用整除图片上传这块,包里常见的做法是把文件存到webapp/upload/下,数据库里存相对路径/upload/dish/xxx.jpg。存相对路径而不是D:/tomcat/webapps/...这种绝对路径,是能不能换机器部署的分水岭。文件名一定要重命名成 UUID 或时间戳,别用原始文件名,中文名加特殊字符在部分容器上会导致 404;上传目录也别放在WEB-INF里面,那个目录浏览器根本访问不到。
4. 登录、权限与后台管理:过滤器与参数绑定
后台管理是这类项目里最容易被忽略的部分,因为它「能跑就行」。但恰恰是后台页面,越权访问、SQL 拼接、明文密码三个问题经常同时出现。
4.1 编码过滤器与登录拦截,两个 Filter 各管一件事
编码问题统一在入口解决,别在每个 Servlet 里写setCharacterEncoding。
@WebFilter("/*") // 拦截所有请求,顺序上要排在登录过滤器之前 public class EncodingFilter implements Filter { public void doFilter(ServletRequest req, ServletResponse resp, FilterChain chain) throws IOException, ServletException { req.setCharacterEncoding("UTF-8"); // 只管 POST 体 resp.setCharacterEncoding("UTF-8"); resp.setContentType("text/html;charset=UTF-8"); chain.doFilter(req, resp); } } @WebFilter(urlPatterns = {"/order/*", "/cart/*", "/admin/*"}) public class LoginFilter implements Filter { public void doFilter(ServletRequest req, ServletResponse resp, FilterChain chain) throws IOException, ServletException { HttpServletRequest r = (HttpServletRequest) req; HttpSession session = r.getSession(false); // 注意:false,别顺手创建新会话 if (session == null || session.getAttribute("loginUser") == null) { ((HttpServletResponse) resp).sendRedirect(r.getContextPath() + "/login.jsp"); return; // 不往下走,请求到此为止 } chain.doFilter(req, resp); } }getSession(false)这个参数是重点。写成getSession()或getSession(true),每个未登录请求都会创建一个空会话,内存里堆一堆无用的 Session 对象,压测时看着内存曲线往上走还找不到原因。两个过滤器的执行顺序由web.xml里的<filter-mapping>声明顺序决定,或者在注解场景下靠过滤器名字母序,所以正式项目里建议统一写在web.xml里,顺序看得见。
4.2 后台越权:前端隐藏菜单不叫权限控制
后台页面常见做法是登录后按角色渲染菜单,管理员看得到「菜品管理」,普通用户看不到。但菜单只是<a>标签,地址栏直接敲/admin/dish/list照样能进。真正的拦截必须在服务端做。
// 后台拦截:角色不匹配直接打回首页,别只靠隐藏链接 @WebFilter("/admin/*") public class AdminFilter implements Filter { public void doFilter(ServletRequest req, ServletResponse resp, FilterChain chain) throws IOException, ServletException { HttpServletRequest r = (HttpServletRequest) req; User user = (User) r.getSession().getAttribute("loginUser"); if (user == null || user.getRole() != 1) { // 1 = 管理员 ((HttpServletResponse) resp).sendError(403); return; } chain.doFilter(req, resp); } }sendError(403)比sendRedirect更合适,越权访问不该被静默重定向成「正常」流程,日志里也更容易定位。角色字段用整数没错,但要定义成常量或者枚举,别在十个地方散写role == 1,后期加一个「店长」角色就得全局搜索替换。
4.3 密码存储与 SQL 注入:两道必须自己守的线
用户表里存明文密码,是这类课程设计包里最常见的减分项。改造成本其实很低。
// 注册与登录都用占位符,字符串拼接一律不许出现 public User login(String username, String rawPwd) throws SQLException { String sql = "SELECT id, username, pwd_hash, salt, role FROM `user` WHERE username = ?"; try (Connection conn = DBUtil.getConnection(); PreparedStatement ps = conn.prepareStatement(sql)) { ps.setString(1, username); try (ResultSet rs = ps.executeQuery()) { if (!rs.next()) return null; String salt = rs.getString("salt"); String expect = rs.getString("pwd_hash"); // 加盐摘要:同一密码在不同用户下哈希值不同,彩虹表失效 String actual = DigestUtils.sha256Hex(salt + rawPwd); return expect.equalsIgnoreCase(actual) ? mapRow(rs) : null; } } }PreparedStatement的占位符不只是防注入,还能让数据库复用执行计划,username = 'a' OR '1'='1这种输入会被当成普通字符串处理。至于密码,最低要求是加盐哈希,别用 MD5 裸哈希;要更稳一点就换 BCrypt,自带盐值和计算强度,代价是登录时多花几十毫秒。这里还有一个细节:登录失败时不要区分「用户不存在」和「密码错误」,统一提示「用户名或密码错误」,否则等于免费提供账号枚举接口。
5. 避坑与排查:五条踩过的记录,按现象对号入座
以下五条是我拆这类包时按出现频率排的,基本覆盖了从解压到跑通的全过程。遇到问题先按「现象」对照,比漫无目的地翻代码快得多。
5.1 启动与部署类问题
记录一:访问首页 404,日志里没有任何异常
现象:Tomcat 启动成功,localhost:8080能出猫页,但访问项目路径就是 404,catalina.out干干净净。
原因:war 包没有真正解压,或者 context path 和你想的不一样。常见于 war 包名带版本号(如order-1.0.war),实际路径是/order-1.0/;也可能是web.xml里<url-pattern>写成了/index,而首页文件叫index.jsp,容器没配欢迎页。
解决:先ls $CATALINA_HOME/webapps/确认解压出来的目录名,访问地址严格按目录名来;检查web.xml里有没有<welcome-file-list>,没有就手动访问/index.jsp验证;最后看server.xml里 Connector 的端口是不是被改过。
记录二:启动报 ClassNotFoundException: com.mysql.cj.jdbc.Driver
现象:应用能起来,但一碰数据库就抛异常,堆栈里明确写着驱动类找不到。
原因:三种可能——jar 没打进WEB-INF/lib;打进去了但是 5.1.x 版本,类名应该是com.mysql.jdbc.Driver;或者容器本身带了一个老版本驱动,类加载时先命中了它。
解决:find . -name "mysql-connector*.jar"确认驱动文件存在;MySQL 8 用com.mysql.cj.jdbc.Driver,5.7 用com.mysql.jdbc.Driver,两者不能混;如果容器lib目录下也有同名驱动,删掉容器里的那份,让应用自带的生效。
5.2 数据与编码类问题
记录三:菜品名称入库变成问号,页面显示乱码
现象:后台新增一道菜,数据库里查出来是???,或者页面标题上出现一串菱形方块。
原因:链路上有三处字符集,任何一处是 latin1 都会断。数据库/表没设utf8mb4;连接串缺characterEncoding=utf8;JSP 页面没写<%@ page contentType="text/html;charset=UTF-8" %>。
解决:先SHOW CREATE TABLE dish;看表字符集,不对就ALTER TABLE dish CONVERT TO CHARACTER SET utf8mb4;;再核对连接串的编码参数;最后统一在过滤器和 JSP 头部各设一次,三处齐了才不会漏。已经写成问号的数据改不回来,只能重录,所以建库那一步别偷懒。
5.3 业务逻辑类问题
记录四:下单提示成功,但库存数字纹丝不动
现象:订单列表里有新订单,明细也对,可菜品表里stock一直是原值。
原因:库存扣减的 SQL 放在了事务之外,或者 DAO 各自开了连接,主事务回滚了它也不知道;还有一种情况是扣减语句写成了SET stock = ?(直接赋值)而不是stock = stock - ?,看着执行成功其实把库存改成了固定值。
解决:确认所有 DAO 方法接收同一个Connection参数并且没有内部getConnection();确认扣减用的是stock = stock - #{qty}并且带AND stock >= #{qty};最后在commit()之前打断点,看每一步的影响行数是不是都在预期内。这类问题最典型的特征是「部分成功」,一旦见到部分成功,先怀疑事务边界。
5.4 静态资源类问题
记录五:菜品图片上传成功,页面上一片裂图
现象:上传接口返回成功,数据库里img字段也有值,但<img src="...">显示不出来,浏览器控制台 404。
原因:存的是绝对路径(如D:\upload\1.jpg),浏览器没法访问;或者文件写到了WEB-INF/upload下,这个目录对外不可见;再或者img字段存的是upload/1.jpg而页面在/dish/list这种二级路径下,相对路径解析错了。
解决:统一存以/开头的应用内相对路径,页面渲染时拼上${pageContext.request.contextPath};上传目录放在webapp下而非WEB-INF下;如果用到 SpringMVC,检查<mvc:resources>有没有把/upload/**映射到实际目录,漏了配置就算文件真实存在也是 404。
6. 二次改造:让这套骨架从「能演示」变成「能讲清楚」
跑通只是及格线。这套代码真正的价值在于它的结构足够干净,改起来不牵一发动全身。我一般的改造顺序是先补数据一致性,再补交互,最后才是加功能,因为前两步决定了你改动之后还敢不敢继续动。
| 改造点 | 具体做法 | 验证方式 |
|---|---|---|
| 购物车持久化 | 新建cart表,user_id+dish_id唯一键,用ON DUPLICATE KEY UPDATE做累加 | 换浏览器登录,购物车内容还在 |
| 订单号生成 | 时间戳换成「日期 + 库自增序列」或雪花算法 | 并发压 50 单,order_no 无重复 |
| 重复提交防护 | 订单表加token字段,页面渲染时下发,提交时校验并删除 | 连点提交按钮,只生成一单 |
| 后台权限细化 | 角色常量抽成枚举,AdminFilter按 URI 前缀分级 | 普通账号直敲后台地址返回 403 |
| 慢查询治理 | 给orders(user_id, create_time)加联合索引 | EXPLAIN看 type 从 ALL 变 ref |
改造完怎么确认没改坏?我的习惯是准备一份最小回归清单,每次动完代码照着走一遍,比凭记忆点页面靠谱得多:
# 用 curl 做一轮冒烟,比手点快,也方便比对返回 BASE=http://localhost:8080/order # 1. 登录拿会话(把 cookie 存下来,后续请求带上) curl -s -c /tmp/ck.txt -d "username=test&password=123456" "$BASE/login" -o /dev/null -w "login:%{http_code}\n" # 2. 加购两道菜,看是否 302 到购物车 curl -s -b /tmp/ck.txt -d "dishId=1&qty=2" "$BASE/cart/add" -o /dev/null -w "add:%{http_code}\n" # 3. 提交订单,返回体里应带订单号 curl -s -b /tmp/ck.txt -X POST "$BASE/order/submit" | grep -o "OD[0-9]*" # 4. 直接查库核对库存是否真的减了 2 mysql -uroot -p -e "SELECT id,name,stock FROM order_db.dish WHERE id=1;"这套脚本的意义在于把「页面看起来正常」换成「数据确实对上了」。第 3 步拿到的订单号要和第 4 步的库存变化对得上,才算真的通过;只看到跳转到成功页就收工,很容易漏掉事务没生效的情况。
还有一个容易被忽略的点:改完之后记得把web.xml里的欢迎页、过滤器顺序、jdbc.properties三者再对一遍。我吃过一次亏,把数据库从本地换到另一台机器,只改了 properties 忘了改连接池的初始化参数,结果应用启动正常,第一次下单才报错,排查了半天才想起是两个配置源。从那以后我每次动配置,都强制先把「启动 → 登录 → 加购 → 下单 → 后台改状态」这条链路空跑一遍再改代码,虽然多花五分钟,但省下的往往是半个下午。这套订餐系统的包就放在那里,按上面的顺序拆一遍,比自己从零搭一个骨架要快得多,希望帮到你。
本文还有配套的精品资源,点击获取