
简介这是一份面向JavaWeb初学者与课程设计需求者的摩托车商城实战项目源码适合在掌握Servlet、JSP基础后希望串联完整业务逻辑的学习者。项目覆盖后台管理、评论、购物车、登录注册、商品与订单管理、支付及购买记录等模块并大量运用分页、MVC模式与增删改查逻辑是理解SSM与SpringBoot底层机制的优质练手素材。压缩包共559个文件约32.29MB包含61个java源文件、56个jsp页面、122个class编译文件、63个jar依赖以及jpg、webp、gif等图片静态资源、css与js样式脚本、xml配置和一份sql建库脚本导入后修改数据库连接即可部署运行。目前已有895人学习下载。通过研读其分层结构与业务代码读者可快速掌握JavaWeb核心知识点为后续框架学习打下扎实基础。1. 从零到一一个 JavaWeb 摩托车商城到底要写多少行代码很多同学拿到“JavaWeb 摩托车商城”这个课程设计题目时第一反应是打开 IDE 就开始建表、写 Servlet结果写到第三天发现购物车逻辑和订单状态搅在一起改一处崩三处。我带过几届学生的课程设计也帮朋友救过好几个写到一半卡死的项目血泪经验告诉我这类题目的难点从来不是“会不会写 CRUD”而是在动手之前有没有把领域模型和分层边界想清楚。摩托车商城和普通图书商城、生鲜商城的最大区别在于商品有品牌、排量、车型、库存颜色等多个维度同一个车型不同配置的价格和库存都不一样。如果你按“商品表 订单表”这种最简结构去套做到商品详情页就会发现一个摩托车有七八个 SKU价格还各不相同这时候再回头改表结构基本等于重写。所以这篇文章我会按“先立模型、再搭骨架、最后填肉”的顺序把整个项目从数据库设计到订单状态机讲透适合正在做课程设计、想拿一个能跑通且能讲清楚的项目去答辩的同学。2. 数据库设计摩托车商城的表结构为什么不能照搬图书商城2.1 从“一辆车”到“一个 SKU”的建模思路图书商城的商品模型很简单一本书就是一个商品价格固定库存固定。但摩托车不一样。假设你卖的是某款 250cc 街车它可能有标准版、ABS 版、高配版三个配置每个配置又有红、黑、白三种颜色那么实际可售的单元就是 3×39 个。如果你把价格和库存直接挂在商品表上就没法表达“标准版红色比标准版黑色贵 200 块”这种现实情况。常见做法是拆成两张表product商品/车型和product_sku具体规格。product存车型名称、品牌、排量、描述、主图product_sku存颜色、配置版本、价格、库存、所属商品 ID。这样商品列表页查product详情页根据商品 ID 查product_sku列表用户选完规格后下单时锁定的是 SKU 而不是商品。下面是我一般会用的建表 SQL字段名和类型都按 MySQL 8 来写-- 商品表存车型级别的信息 CREATE TABLE product ( id BIGINT PRIMARY KEY AUTO_INCREMENT, name VARCHAR(128) NOT NULL COMMENT 车型名称如 250cc 街车, brand VARCHAR(64) NOT NULL COMMENT 品牌, displacement INT COMMENT 排量单位 cc, category_id BIGINT COMMENT 分类 ID, main_image VARCHAR(255) COMMENT 主图路径, description TEXT COMMENT 车型描述, status TINYINT DEFAULT 1 COMMENT 1 上架 0 下架, created_at DATETIME DEFAULT CURRENT_TIMESTAMP ) ENGINEInnoDB DEFAULT CHARSETutf8mb4; -- SKU 表存具体可售规格 CREATE TABLE product_sku ( id BIGINT PRIMARY KEY AUTO_INCREMENT, product_id BIGINT NOT NULL, color VARCHAR(32) COMMENT 颜色, version VARCHAR(32) COMMENT 配置版本, price DECIMAL(10,2) NOT NULL COMMENT 售价, stock INT NOT NULL DEFAULT 0 COMMENT 库存, sku_code VARCHAR(64) UNIQUE COMMENT SKU 编码, INDEX idx_product (product_id) ) ENGINEInnoDB DEFAULT CHARSETutf8mb4;逻辑说明product和product_sku是一对多关系product_sku.product_id关联到product.id。价格和库存放在 SKU 层是因为同一个车型不同配置的成本和定价本来就不同。sku_code加唯一索引是为了防止重复录入实际项目中这个编码通常由运营手动填或按规则生成。参数说明displacement用 INT 存排量单位 cc方便按排量区间筛选price用 DECIMAL(10,2) 而不是 FLOAT因为金额计算不能有浮点误差status用 TINYINT 做软删除或上下架标记避免物理删除导致订单关联数据丢失。2.2 订单表与订单明细状态字段怎么设计才不翻车订单表最容易踩的坑是把“订单状态”和“支付状态”混成一个字段。比如你只用一个status字段值有“待支付、已支付、已发货、已完成、已取消”那么当用户支付成功但还没发货时你没法区分“已支付待发货”和“已支付已发货”之间的中间态。更麻烦的是退款场景用户申请退款后订单状态该改成什么如果只有一个字段你只能加一个“退款中”但退款中又分“未发货退款”和“已发货退货退款”逻辑会越来越乱。我一般会拆成两个字段order_status订单生命周期和pay_status支付状态。order_status管“待确认、已确认、已发货、已完成、已取消”pay_status管“未支付、已支付、已退款”。这样退款时只改pay_status订单主流程不受影响。CREATE TABLE orders ( id BIGINT PRIMARY KEY AUTO_INCREMENT, order_no VARCHAR(32) UNIQUE NOT NULL COMMENT 订单号, user_id BIGINT NOT NULL, total_amount DECIMAL(10,2) NOT NULL, order_status TINYINT DEFAULT 0 COMMENT 0待确认 1已确认 2已发货 3已完成 4已取消, pay_status TINYINT DEFAULT 0 COMMENT 0未支付 1已支付 2已退款, receiver_name VARCHAR(32), receiver_phone VARCHAR(20), receiver_address VARCHAR(255), created_at DATETIME DEFAULT CURRENT_TIMESTAMP, INDEX idx_user (user_id) ) ENGINEInnoDB DEFAULT CHARSETutf8mb4; CREATE TABLE order_item ( id BIGINT PRIMARY KEY AUTO_INCREMENT, order_id BIGINT NOT NULL, sku_id BIGINT NOT NULL, product_name VARCHAR(128) COMMENT 下单时快照, sku_desc VARCHAR(64) COMMENT 下单时快照, price DECIMAL(10,2) COMMENT 下单时单价, quantity INT NOT NULL, INDEX idx_order (order_id) ) ENGINEInnoDB DEFAULT CHARSETutf8mb4;逻辑说明order_item里冗余了product_name、sku_desc、price这是故意的。因为商品信息会变如果订单明细只存 SKU ID半年后你查历史订单显示的是商品现在的名字和价格而不是用户下单时的信息这在售后和对账时是致命的。这种“快照冗余”在电商系统里是标准做法不要为了所谓“范式”把它拆掉。参数说明order_no用 VARCHAR(32) 而不是自增 ID因为订单号要对外暴露自增 ID 会泄露业务量total_amount在创建订单时计算一次并落库不要每次查询都重新累加避免商品调价后历史订单金额变化。3. 后端分层Servlet 和 JSP 怎么分工才不写成意大利面条3.1 控制层用 Servlet 还是 Spring MVC课程设计题目里写“JavaWeb”通常默认技术栈是 Servlet JSP JDBC但如果你学校允许用 Spring Boot我强烈建议用 Spring Boot MyBatis因为 Servlet 里手动解析请求参数、手动管理数据库连接、手动拼 JSON代码量至少多三倍而且很容易在doGet里写几百行逻辑最后自己都看不懂。但如果你必须用纯 Servlet很多课程设计明确要求不能用框架那至少要做到“一个 Servlet 只处理一个业务动作”。比如ProductListServlet只负责查商品列表ProductDetailServlet只负责查详情CartAddServlet只负责加购物车。不要写一个ProductServlet然后用action参数区分操作那种写法在后期加功能时会让if-else膨胀到无法维护。下面是一个纯 Servlet 查商品列表的最小示例// ProductListServlet.java WebServlet(/product/list) public class ProductListServlet extends HttpServlet { private ProductService productService new ProductServiceImpl(); Override protected void doGet(HttpServletRequest req, HttpServletResponse resp) throws ServletException, IOException { // 1. 解析分页参数默认第 1 页每页 8 条 int page 1; int size 8; String pageStr req.getParameter(page); if (pageStr ! null !pageStr.isEmpty()) { page Integer.parseInt(pageStr); } // 2. 调用 Service 查数据 PageResultProduct result productService.listByPage(page, size); // 3. 数据放入 request 域转发到 JSP req.setAttribute(pageResult, result); req.getRequestDispatcher(/WEB-INF/jsp/product/list.jsp).forward(req, resp); } }逻辑说明doGet里只做三件事——解析参数、调 Service、转发视图。业务逻辑全部在ProductService里Servlet 不碰数据库连接也不写 SQL。这样后面加缓存、加权限校验都只在 Service 层改Servlet 不用动。参数说明page和size是分页参数size我一般写死 8 或 12因为摩托车商品卡片比较大一页放太多会显得拥挤req.setAttribute把结果传给 JSPJSP 里用 JSTL 的c:forEach渲染不要在 JSP 里写 Java 代码。3.2 Service 层的事务边界怎么划课程设计里最容易忽略的是事务。比如下单操作要同时做三件事扣库存、创建订单、创建订单明细。如果扣完库存后创建订单失败库存就白白少了用户没买到车库存却没了。这种问题在答辩时如果被问到答不上来会很扣分。我一般会在 Service 层的方法上加事务用 JDBC 的话就是手动setAutoCommit(false)用 Spring 的话就是Transactional。关键点是事务边界要划在 Service 方法上不要划在 DAO 上。因为一个 Service 方法可能调多个 DAO事务要覆盖所有 DAO 操作。// OrderServiceImpl.java 下单核心逻辑 public Order createOrder(Long userId, ListCartItem items) { Connection conn null; try { conn DBUtil.getConnection(); conn.setAutoCommit(false); // 开启事务 // 1. 校验库存并扣减 for (CartItem item : items) { int affected skuDao.reduceStock(conn, item.getSkuId(), item.getQuantity()); if (affected 0) { throw new BizException(库存不足 item.getSkuId()); } } // 2. 创建订单主记录 Order order orderDao.insert(conn, userId, items); // 3. 创建订单明细 for (CartItem item : items) { orderItemDao.insert(conn, order.getId(), item); } conn.commit(); // 全部成功才提交 return order; } catch (Exception e) { if (conn ! null) conn.rollback(); // 任何一步失败都回滚 throw new BizException(下单失败 e.getMessage()); } finally { DBUtil.close(conn); } }逻辑说明reduceStock的 SQL 要写成UPDATE product_sku SET stock stock - ? WHERE id ? AND stock ?用stock ?做乐观锁返回影响行数为 0 就说明库存不够直接抛异常触发回滚。这样即使并发下单也不会超卖。参数说明conn在所有 DAO 方法间传递保证用的是同一个连接setAutoCommit(false)关闭自动提交后必须显式commit或rollback否则连接关闭时未提交的操作会丢失。4. 避坑与排查课程设计里最容易翻车的五个点4.1 中文乱码现象是页面显示问号原因是编码链没统一现象JSP 页面输入中文商品名保存到数据库变成???或者从数据库查出来在页面显示乱码。原因编码涉及三个环节——JSP 页面编码、请求编码、数据库编码。任何一个环节不是 UTF-8 都会乱。常见的是 JSP 里写了pageEncodingUTF-8但没写contentTypetext/html;charsetUTF-8或者 Servlet 里没设req.setCharacterEncoding(UTF-8)。解决JSP 头部统一写% page contentTypetext/html;charsetUTF-8 pageEncodingUTF-8 %Servlet 里在doPost第一行加req.setCharacterEncoding(UTF-8)数据库连接 URL 加?useUnicodetruecharacterEncodingutf8建表时指定DEFAULT CHARSETutf8mb4。四个地方都对齐基本不会再乱。4.2 购物车数据丢失现象是刷新页面后购物车空了原因是用了 request 域现象用户加购后跳转到购物车页面能看到商品但刷新一下或者点其他页面再回来购物车就空了。原因把购物车数据存在request域里request只在一次请求内有效转发到 JSP 后请求结束数据就没了。或者存在session里但每次加购都新建一个List覆盖了之前的。解决购物车必须存session而且加购时要先判断 session 里有没有购物车对象有就追加没有才新建。如果要做持久化购物车用户下次登录还能看到那就存数据库session 里只存一个标识。4.3 订单状态跳转混乱现象是已取消的订单还能发货原因是状态判断写在了 JSP 里现象后台管理员看到已取消的订单页面上居然还有“发货”按钮点了一下订单状态变成已发货。原因按钮的显示逻辑写在了 JSP 里用c:if判断状态但判断条件写错了或者多个地方各自判断改了一处漏了另一处。解决状态流转的判断统一放在 Service 层提供一个canShip(orderId)方法后台发货前先调这个方法校验不通过就抛异常。JSP 里只负责显示不负责业务规则。这样即使页面按钮显示错了后端也会拦住。4.4 库存扣减为负数现象是库存变成 -1原因是没加乐观锁现象两个用户同时买最后一辆车都下单成功库存变成 -1。原因扣库存的 SQL 写成了UPDATE product_sku SET stock stock - 1 WHERE id ?没有判断当前库存是否大于 0。并发时两个线程同时读到 stock1都执行减一结果变成 -1。解决SQL 改成UPDATE product_sku SET stock stock - ? WHERE id ? AND stock ?用影响行数判断是否扣减成功。返回 0 就说明库存不足抛异常回滚事务。4.5 数据库连接泄漏现象是跑一会儿就报 Too many connections原因是 Connection 没关现象本地测试没问题部署到服务器上跑几分钟就报Too many connections重启后又好了。原因DAO 里每次操作都DriverManager.getConnection()新建连接但用完没close()连接池很快被占满。或者用了连接池但异常路径下没归还连接。解决所有数据库操作统一走DBUtil工具类在finally块里关闭Connection、Statement、ResultSet。如果用连接池如 Druidclose()方法会自动归还连接到池里不会真正关闭物理连接。养成“在哪里打开就在哪里关闭”的习惯异常路径也要关。5. 进阶技巧用状态机管订单用拦截器管登录5.1 把订单状态流转写成枚举而不是散落的 if-else订单状态流转如果散落在各个 Servlet 和 JSP 里后期加一个“退款审核”状态就会改到崩溃。我一般会定义一个枚举把每个状态能做什么、不能做什么写清楚public enum OrderStatus { PENDING(0, 待确认) { Override public boolean canTransferTo(OrderStatus target) { return target CONFIRMED || target CANCELLED; } }, CONFIRMED(1, 已确认) { Override public boolean canTransferTo(OrderStatus target) { return target SHIPPED || target CANCELLED; } }, SHIPPED(2, 已发货) { Override public boolean canTransferTo(OrderStatus target) { return target COMPLETED; } }, COMPLETED(3, 已完成) { Override public boolean canTransferTo(OrderStatus target) { return false; } }, CANCELLED(4, 已取消) { Override public boolean canTransferTo(OrderStatus target) { return false; } }; private final int code; private final String desc; OrderStatus(int code, String desc) { this.code code; this.desc desc; } public abstract boolean canTransferTo(OrderStatus target); public static OrderStatus fromCode(int code) { for (OrderStatus s : values()) { if (s.code code) return s; } throw new IllegalArgumentException(未知订单状态 code); } }逻辑说明每个状态自己决定能转到哪些状态canTransferTo是抽象方法每个枚举常量各自实现。这样加新状态时只需要加一个枚举常量并实现方法不用去改散落各处的if-else。Service 层改状态前先调current.canTransferTo(target)不通过就抛异常。参数说明code是存数据库的值用 int 比字符串省空间且索引快desc是页面显示的中文描述fromCode用于从数据库读出来后转成枚举。5.2 用 Filter 做登录校验别在每个 Servlet 里写一遍课程设计里常见的问题是每个需要登录的 Servlet 开头都写一遍if (session.getAttribute(user) null)代码重复且容易漏。我一般会写一个LoginFilter拦截需要登录的路径统一校验WebFilter(urlPatterns {/order/*, /cart/*, /user/profile}) public class LoginFilter implements Filter { Override public void doFilter(ServletRequest req, ServletResponse resp, FilterChain chain) throws IOException, ServletException { HttpServletRequest request (HttpServletRequest) req; HttpServletResponse response (HttpServletResponse) resp; HttpSession session request.getSession(false); // session 不存在或没有 user 属性说明未登录 if (session null || session.getAttribute(user) null) { // 记住原始请求地址登录后跳回来 String target request.getRequestURI(); response.sendRedirect(request.getContextPath() /login?redirect target); return; } chain.doFilter(req, resp); // 已登录放行 } }逻辑说明urlPatterns只拦截需要登录的路径商品列表和详情页不拦截游客也能看。getSession(false)表示没有 session 就返回 null不新建 session避免给未登录用户创建无用 session。登录成功后从redirect参数取回原始地址跳转用户体验更好。参数说明request.getContextPath()拿到项目部署路径避免硬编码/motorcycle-shop这种上下文chain.doFilter是放行到下一个过滤器或目标 Servlet。5.3 一个验证项目是否跑通的最小检查清单做完之后别急着答辩按这个顺序过一遍先确认数据库能连上且表都建好了用SELECT COUNT(*) FROM product看有没有测试数据然后启动 Tomcat访问商品列表页看能不能正常显示分页接着注册一个普通用户加购两件商品下单看订单表和订单明细表有没有数据再用管理员账号登录后台看能不能发货、改状态最后把库存改成 1用两个浏览器同时下单验证会不会超卖。这五步走完基本功能就稳了。我自己的习惯是每次改完一个模块就手动跑一遍这个流程不要等全部写完再测那时候出问题定位范围太大。课程设计时间紧但返工更费时间。希望帮到你。本文还有配套的精品资源点击获取