1. 项目核心拆解:一个经典的JavaWeb商城是如何长成的
我最早接触这类项目,是在带学生做课程设计的时候。每年都有大量计算机专业的同学被要求做“数据库课程设计”或者“JavaWeb实训项目”,而鲜花商城恰恰是出现频率最高的选题之一。为什么?因为它的业务边界足够清晰,却又覆盖了JavaWeb阶段几乎所有核心知识点——JSP页面渲染、Servlet请求处理、JDBC数据库操作、Session会话管理、前端Ajax交互。可以说,把这个项目吃透,JavaWeb这条线就算真正入门了。
先看这个技术栈:java + jsp + servlet + javascript + bootstrap + mysql。说白了,这是一套非常“学院派”但绝不落后的组合。JSP负责往浏览器吐HTML,Servlet负责接收请求、处理业务、跳转页面,JDBC负责跟MySQL打交道,Bootstrap负责让页面不至于丑到没法看,JavaScript则是让页面具备基本的交互能力。这里没有用到Spring、MyBatis这类后期框架,但恰恰因为“裸奔”,所有底层机制都暴露在你面前,反而更适合学习和理解Web开发的本质。
这个系统能做什么?按照国内这类项目的通行设计,它至少要具备用户注册登录、鲜花分类浏览、商品详情展示、购物车管理、订单提交、后台管理(商品增删改、订单处理、用户管理)这些标准模块。有些做得深的,还会加上商品搜索、库存扣减、销量统计、会员等级之类的细分功能。核心诉求其实就两点:用户能顺畅地完成从“逛”到“买”的全流程,管理员能高效地维护商品和订单数据。
适合谁去参考?我建议有三类人可以重点看:一是正在做JavaWeb课程设计或毕业设计的学生,直接拿这个项目改改就能交差;二是刚学完JavaSE和数据库基础、想找一个完整案例串联知识点的自学者;三是工作后想快速回忆JavaWeb技术栈的老手,这篇文章里的很多细节能帮你少走弯路。下面,我从项目整体设计开始,把这套系统里里外外拆给你看。
需要模型API调用? 免费领10W Token,多模型网关一键接入 Claude、DeepSeek 等主流模型。
2. 技术栈选型与整体设计思路:为什么这套组合至今仍是经典
2.1 为什么选择JSP+Servlet而非Spring Boot
很多人一上来就会问:现在都2025年了,新项目谁还用JSP+Servlet?这确实是个好问题。但我得说,在“学习Web开发底层原理”这件事上,JSP+Servlet有它不可替代的价值。Spring Boot太好用了,一个注解就把一切搞定,以至于很多人开发了半年,都不知道一个HTTP请求是怎么被容器处理、怎么被路由到Controller、怎么完成响应回写的。而JSP+Servlet把整个过程赤裸裸地暴露出来:请求到达Tomcat,Tomcat根据web.xml里的映射找到对应的Servlet,Servlet调用业务方法操作数据库,然后把结果set到request域里forward到JSP页面,JSP再渲染成HTML返回浏览器。每一步都看得见、摸得着。
另外还有一层现实考量:很多学校和企业内部的老系统仍然基于这套技术栈维护着。你要是能看懂JSP里的脚本片段、能修Servlet里的逻辑漏洞,那你在接手老项目的时候会非常从容。学Spring Boot只能让你找到工作,同时看懂Servlet才能让你真正干好活。
2.2 Bootstrap+JavaScript在其中的角色定位
再说说前端部分。Bootstrap在项目里承担的是“让页面不丑”的底线保障。它的栅格系统能帮我们轻松实现商品列表的多列布局,它的模态框组件可以用来做购物车确认弹窗、登录提示,它的表单样式能让注册和后台管理页面直接可用而不用写太多CSS。JavaScript主要负责两个方面:一是表单的客户端校验,比如注册时检查两次密码是否一致、手机号格式是否正确,这些逻辑放到前端做能显著减少无效请求;二是异步交互,比如加购后不刷新页面直接更新购物车角标、商品详情页面切换缩略图等。
我见过很多学生项目,要么前端做得极其简陋——完全没有JavaScript,所有操作都要提交表单刷新页面;要么过度堆砌——引了一堆jQuery插件、图表库,结果跟后端的交互逻辑对不上,功能跑不通。Bootstrap搭配适度的JavaScript,恰好是一个“够用且稳定”的组合,这也是这套技术栈经久不衰的原因之一。
2.3 MySQL在整个系统中的核心定位
MySQL负责的是所有数据的持久化存储。这里有个很多新手容易忽略的认知:MySQL不只是“存数据”,表结构的设计质量直接决定了后续业务逻辑开发的复杂度上限。拿鲜花商城来说,至少要设计用户表(user)、商品分类表(category)、商品表(product)、订单表(orders)、订单明细表(order_item)和购物车相关的表。这些表之间的关联关系、字段类型的选择、索引的建立时机,都直接影响着系统的整体质量和后期可维护性。
具体到设计过程中,业界通行的一对多关系是这样的:一个分类下有多个商品,所以分类表通过主键id被商品表的外键category_id引用;一个用户可以有多个订单,订单表包含user_id外键;一个订单包含多个商品条目,这是典型的一对多,必须单独拆出一张订单明细表来记录“订单中有哪些商品、数量、价格”。如果这些关系一开始没理清,后面实现购物车结算、订单详情展示的时候就会陷入改表结构的泥潭。我后面会在代码层面把详细建表语句写出来,可以直接照抄。
2.4 项目目录结构与分层思想
项目采用MVC分层思想,这是JavaWeb规范里最核心的架构模式。M(Model模型层)对应JavaBean和DAO类,负责数据封装与数据库访问;V(View视图层)对应JSP页面,负责数据展示;C(Controller控制层)对应Servlet,负责请求接收、业务调度和页面跳转。标准的项目目录是:
src/com/xxx/shop/dao:数据访问对象接口与实现src/com/xxx/shop/entity:实体类,对应数据库表结构src/com/xxx/shop/servlet:控制层Servletsrc/com/xxx/shop/util:工具类,如数据库连接工具、分页工具WebContent/:JSP页面、CSS/JS资源文件、图片WebContent/WEB-INF:web.xml配置文件、第三方jar包
分层的好处不用我多说,最直观的一点是“各司其职、互不干扰”。即使你的项目只有几千行代码,分层也能让你在三个月后重新打开代码时,依然能快速定位问题在哪里——页面出问题去JSP找,逻辑出问题去Servlet找,数据出问题去DAO找。这一点,等你自己尝试着扩展功能(比如加一个优惠券模块)时体会会更深。
3. 核心细节解析与实操要点:从建库到前端,每个环节都不能偷懒
3.1 数据库设计与建表语句
表设计是整套系统的地基。我见过太多项目死在起步阶段——用户表没有唯一约束导致重复注册、商品表没有价格字段(或者价格字段用了int导致0.99元的商品直接变成0)、订单表没有下单时间导致后台排单困难。这些问题看着小,改起来却要全局联动,所以建表时务必一次想清楚。
以我实际项目中的设计为例,核心表的建表语句大致如下:
sql复制CREATE DATABASE IF NOT EXISTS flower_shop DEFAULT CHARACTER SET utf8mb4;
USE flower_shop;
DROP TABLE IF EXISTS order_item;
DROP TABLE IF EXISTS orders;
DROP TABLE IF EXISTS cart;
DROP TABLE IF EXISTS product;
DROP TABLE IF EXISTS category;
DROP TABLE IF EXISTS user;
CREATE TABLE user (
id INT PRIMARY KEY AUTO_INCREMENT COMMENT '用户ID',
username VARCHAR(32) NOT NULL UNIQUE COMMENT '用户名',
password VARCHAR(64) NOT NULL COMMENT '密码(建议MD5加密存储)',
phone VARCHAR(15) COMMENT '手机号',
email VARCHAR(64) COMMENT '邮箱',
address VARCHAR(255) COMMENT '收货地址',
create_time DATETIME DEFAULT CURRENT_TIMESTAMP COMMENT '注册时间'
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='用户表';
CREATE TABLE category (
id INT PRIMARY KEY AUTO_INCREMENT COMMENT '分类ID',
name VARCHAR(32) NOT NULL COMMENT '分类名称',
sort INT DEFAULT 0 COMMENT '排序值,越小越靠前'
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='鲜花分类表';
CREATE TABLE product (
id INT PRIMARY KEY AUTO_INCREMENT COMMENT '商品ID',
category_id INT NOT NULL COMMENT '所属分类ID',
name VARCHAR(64) NOT NULL COMMENT '商品名称',
description VARCHAR(500) COMMENT '商品描述',
price DECIMAL(10,2) NOT NULL COMMENT '单价',
stock INT NOT NULL DEFAULT 0 COMMENT '库存',
image VARCHAR(255) COMMENT '图片路径',
sales INT DEFAULT 0 COMMENT '销量',
status TINYINT DEFAULT 1 COMMENT '1上架 0下架',
create_time DATETIME DEFAULT CURRENT_TIMESTAMP,
KEY idx_category (category_id),
CONSTRAINT fk_product_category FOREIGN KEY (category_id) REFERENCES category(id)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='鲜花商品表';
CREATE TABLE cart (
id INT PRIMARY KEY AUTO_INCREMENT,
user_id INT NOT NULL,
product_id INT NOT NULL,
quantity INT NOT NULL DEFAULT 1 COMMENT '数量',
KEY idx_user (user_id),
CONSTRAINT fk_cart_user FOREIGN KEY (user_id) REFERENCES user(id),
CONSTRAINT fk_cart_product FOREIGN KEY (product_id) REFERENCES product(id)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='购物车表';
CREATE TABLE orders (
id INT PRIMARY KEY AUTO_INCREMENT COMMENT '订单ID',
order_no VARCHAR(32) NOT NULL UNIQUE COMMENT '订单编号',
user_id INT NOT NULL,
total_price DECIMAL(10,2) NOT NULL COMMENT '订单总金额',
status TINYINT DEFAULT 0 COMMENT '0待付款 1已付款 2已发货 3已完成 4已取消',
receiver_name VARCHAR(32) COMMENT '收货人',
receiver_phone VARCHAR(15) COMMENT '收货电话',
receiver_address VARCHAR(255) COMMENT '收货地址',
create_time DATETIME DEFAULT CURRENT_TIMESTAMP COMMENT '下单时间',
KEY idx_user (user_id),
CONSTRAINT fk_orders_user FOREIGN KEY (user_id) REFERENCES user(id)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='订单表';
CREATE TABLE order_item (
id INT PRIMARY KEY AUTO_INCREMENT,
order_id INT NOT NULL COMMENT '所属订单ID',
product_id INT NOT NULL,
product_name VARCHAR(64) COMMENT '商品名称快照',
product_price DECIMAL(10,2) COMMENT '单价快照',
quantity INT NOT NULL COMMENT '购买数量',
CONSTRAINT fk_item_order FOREIGN KEY (order_id) REFERENCES orders(id),
CONSTRAINT fk_item_product FOREIGN KEY (product_id) REFERENCES product(id)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='订单明细表';
这里有几个地方想展开讲一下。price字段使用DECIMAL(10,2)而不是float或double,是因为浮点类型在计算金额时会出现精度丢失问题——0.1+0.2可能等于0.30000000000000004,这在订单结算时是致命的。utf8mb4字符集则是为了兼容表情符号和更多特殊字符,很多老项目用utf8存中文没问题,但存emoji就报错,起步时选utf8mb4能省去后面很多麻烦。外键约束我建议在表结构层面建立,虽然有些开发者为了性能会去掉外键、在应用层保证一致性,但这是学习项目,保留外键能让表关系更清晰、不易错。
另外有个实用技巧:orders表里单独存了一个order_no字段,格式类似20250101120000123(时间戳+随机数),而不是直接用自增id当订单号。这样做的好处是订单号在业务上具备可读性,而且在对接第三方物流查询、售后工单时,不会因为暴露了订单量而泄露运营数据。
3.2 项目分层:实体类、DAO、Servlet与JSP之间的协作关系
有了表结构,接下来就需要把数据库里的记录“翻译”成Java里可以直接操作的对象。这一环节涉及三个核心部分:实体类(Entity)、数据访问对象(DAO)和控制层Servlet。
实体类是表的映射,基本上每个表对应一个JavaBean。拿Product来举例,它包含的属性跟表字段一一对应:id、categoryId、name、description、price、stock、image、sales、status、createTime。类里需要提供无参构造、getter/setter方法,有时候为了方便表单数据回显,建议重写toString方法。实体类的命名有个约定:类名首字母大写,与表名下划线转驼峰命名。table里叫create_time,类属性就写createTime,这个映射关系在手动编写SQL时尤其要注意对齐。
DAO层负责操作数据库。推荐的做法是先定义接口再写实现类,比如说ProductDao接口里定义好findById(int id)、findByCategory(int categoryId)、findAll()、insert(Product p)、update(Product p)、delete(int id)这些方法,然后ProductDaoImpl里用JDBC去执行SQL并封装结果集。这里有个非常实用的封装技巧:数据库连接可以通过工具类复用,ResultSet到实体类的转换封装成一个RowMapper接口,代码会清爽非常多,后面加表的时候也不会东复制一段西复制一段。
Servlet层是请求入口。前端页面提交的每一个操作,都要映射到一个Servlet上。比如:
/login->LoginServlet/register->RegisterServlet/product/list->ProductListServlet/product/detail->ProductDetailServlet/cart/add->CartAddServlet/order/create->OrderCreateServlet/admin/product/add->AdminAddProductServlet
Servlet的doGet和doPost分工也要明确。经验方法是:表单提交走doPost,页面跳转和链接访问走doGet,两个方法内部可以互相转发调用,但入口不能乱。很多新手把查数据的逻辑放在doGet里,把更新数据的逻辑放在doPost里,这是对的,但总有人图省事在doGet里直接处理表单提交,导致刷新页面时重复提交订单,这种事故我处理过不止一次。
JSP页面位于WebContent目录下,页面里通过JSTL标签和EL表达式展示数据:
jsp复制<c:forEach var="p" items="${productList}">
<div class="col-md-3">
<div class="thumbnail">
<img src="${p.image}" alt="${p.name}">
<div class="caption">
<h4>${p.name}</h4>
<p class="text-danger">¥${p.price}</p>
<a href="${pageContext.request.contextPath}/cart/add?productId=${p.id}" class="btn btn-primary">加入购物车</a>
</div>
</div>
</div>
</c:forEach>
顺便说一下,${pageContext.request.contextPath}是很多新手的知识盲区。它代表当前Web应用的根路径,通常形如/flower-shop。如果不写它,直接写/cart/add,本地部署到Tomcat的ROOT路径下也许没问题,但一旦打成war包部署到服务器,应用有了独立上下文路径,所有请求链接会全部失效,页面跳转直接404。这是个非常普遍的坑,建议直接形成习惯。
3.3 前后端联动:从商品列表到订单结算的完整链路里,前端各自负责什么
整个商城主流程可以这样描述:用户访问首页看到商品列表,点击商品进入详情页,点击“加入购物车”触发一次加购请求,购物车页面展示已选商品并允许修改数量或删除,点击“结算”后填写收货信息并提交订单,后台管理员在管理页面看到订单并修改状态。这里前端在每个环节承担的任务并不相同,拆开看会更清晰。
商品列表页主要靠JSP配合JSTL循环输出,前端做的只是样式美化。商品详情页相对复杂一些,通常需要展示商品大图、多张详情图,如果不想引入额外的图片插件,可以用JavaScript简单实现“点击缩略图切换大图”的效果。购物车页面由于涉及数据变更频繁,建议用JavaScript实现“修改数量后小计金额即时更新”的体验,然后通过window.location.href或者Ajax将数量变更提交到后端。
订单结算页是最容易出问题的环节。页面要展示收货信息表单、商品清单和总金额。提交订单前,前端的JavaScript校验要做两层:一是基本格式校验,比如手机号11位、收货人姓名非空;二是提交前禁用按钮加个“正在提交”的文案,防止用户手滑重复点击产生重复订单。关于重复提交,后端也要做一道防线——生成一个隐藏的token字段存到Session里,提交时比对,比对通过后立即清除,这是经典的防重方案,强烈建议在这类项目中加上。
后台管理页面的开发相对简单,因为操作逻辑单一:商品列表(支持分页搜索)、新增商品表单、订单列表(支持按状态筛选)、用户列表。Bootstrap提供的表格样式和表单样式基本能覆盖需求,再加上一个简单的侧边栏导航就能串起整个后台。
3.4 分页实现与搜索功能:细节决定体验
商品数量一多,一次性全查出来渲染页面的方式就暴露出性能问题了。我见过产品表只有几百条数据时无所谓,但数据到几千条、上万个类目时,页面响应时间会急剧上升。所以分页是这类商城系统的必备功能。
分页的核心有两个参数:当前页码(pageNumber)和每页条数(pageSize)。后端需要三个数据:当前页数据列表(用SQL的LIMIT offset, pageSize查询)、总记录数(用COUNT(*)查询)、当前页码。一个典型的分页查询方法长这样:
java复制public PageResult<Product> findPage(int pageNumber, int pageSize, Integer categoryId) {
int total = count(categoryId);
int offset = (pageNumber - 1) * pageSize;
List<Product> list = jdbcTemplate.query(
"SELECT * FROM product WHERE status=1 AND (? IS NULL OR category_id=?) LIMIT ?,?",
productRowMapper, categoryId, categoryId, offset, pageSize
);
return new PageResult<>(list, total, pageNumber, pageSize);
}
分页导航在前端可以这样渲染:总页数 = ceil总记录数 / 每页条数,页面展示“上一页、页码数字、下一页”,点击页码通过链接参数pageNumber=x传给后端。这里有个细节:页码类组件建议做成独立的JSP标签文件,复用到商品列表和管理后台,避免两个页面各写一套分页逻辑,改起来牵一发动全身。
搜索功能也是商城的标配。最简单的办法是在商品列表页放一个搜索框,以keyword参数提交到后端,SQL里用LIKE CONCAT('%', ?, '%')对商品名和描述做模糊匹配。需要注意的是,搜索条件要和分页条件组合使用,而且SQL注意使用PreparedStatement参数占位符而不是字符串拼接,否则一旦用户在搜索框输入' OR '1'='1,你的整个商品表就裸奔了。SQL注入在JSP+Servlet项目里特别常见,因为很多人习惯了直接拼接字符串,这里务必养成参数化查询的习惯。
4. 实操过程与核心环节实现:环境搭建、代码示例与部署验证
4.1 环境准备:JDK、Tomcat、MySQL与IDEA的配置细节
我默认你用的是Windows开发环境,IDE为IDEA社区版或专业版。开发环境版本建议这样搭配:
- JDK 8或11(不要用JDK 17,部分老版本Tomcat对高版本JDK支持有问题)
- Tomcat 8.5或9.0(JSP和Servlet规范支持成熟)
- MySQL 5.7或8.0(你自己装哪个版本都行,但5.7的配置相对省心,8.0要留意驱动类名变化)
- IDEA 2023或更新版本
- Maven 3.6+(如果你用Maven管理依赖,推荐;如果直接往WEB-INF/lib丢jar包也可以,但有Maven更省事)
MySQL的安装过程里,很多同学在最后一步的“Choose a Password”卡住——请记住你的root密码,后面JDBC连接要用。还有一项重要的配置是字符集,安装时如果没选utf8mb4,后面建表还得手动指定,或者在my.ini里加上character-set-server=utf8mb4重启服务。装MySQL 8.0时,如果连接驱动还在用com.mysql.jdbc.Driver,会直接报错,必须换成com.mysql.cj.jdbc.Driver,URL也要加上serverTimezone=Asia/Shanghai参数,否则会因为时区问题拒绝连接。
用IDEA跑JavaWeb项目,步骤说简单也简单、说坑也多。核心动作是:项目里配置Tomcat Server -> Local,在Deployment标签页里把项目以war exploded方式部署,Application context设置为/flower-shop(或者你喜欢的任意名字),然后启动。这里有两个高频问题:一是端口被占,Tomcat默认8080,如果你的电脑上已经跑了一个MySQL占用3306倒没事,但如果别的程序占了8080就要改Tomcat的HTTP端口;二是项目部署后404,多半是Application context配置不对,访问时要带上下文路径。
4.2 数据库连接工具类与JDBC操作封装
数据库连接这块,我推荐直接写一个DBUtil工具类管理连接,核心逻辑代码如下:
java复制public class DBUtil {
private static final String URL = "jdbc:mysql://localhost:3306/flower_shop?useUnicode=true&characterEncoding=utf8mb4&serverTimezone=Asia/Shanghai&useSSL=false";
private static final String USER = "root";
private static final String PASSWORD = "你的密码";
private static final String DRIVER = "com.mysql.cj.jdbc.Driver";
static {
try {
Class.forName(DRIVER);
} catch (ClassNotFoundException e) {
e.printStackTrace();
}
}
public static Connection getConnection() throws SQLException {
return DriverManager.getConnection(URL, USER, PASSWORD);
}
public static void close(Connection conn, PreparedStatement ps, ResultSet rs) {
if (rs != null) { try { rs.close(); } catch (SQLException ignored) {} }
if (ps != null) { try { ps.close(); } catch (SQLException ignored) {} }
if (conn != null) { try { conn.close(); } catch (SQLException ignored) {} }
}
}
这里有几个容易踩的坑我提前说。URL里的useSSL=false必须加,否则MySQL 8.0高版本会提示SSL连接警告;serverTimezone必须指定,不然报The server time zone value '???ú±??? is unrecognized;characterEncoding要写utf8mb4,如果写utf8,虽然中文也能存,但表情符号会报错。
再来讲PreparedStatement用法。核心优势有两个:一是防止SQL注入(参数值不会参与SQL语法解析),二是预编译机制在多次执行同一SQL时性能更好。查询操作的典型写法:
java复制public Product findById(int id) {
String sql = "SELECT * FROM product WHERE id=?";
try (Connection conn = DBUtil.getConnection();
PreparedStatement ps = conn.prepareStatement(sql)) {
ps.setInt(1, id);
try (ResultSet rs = ps.executeQuery()) {
if (rs.next()) {
return mapRow(rs);
}
}
} catch (SQLException e) {
e.printStackTrace();
}
return null;
}
4.3 注册登录模块的实现与密码安全处理
登录注册是所有系统的入口,这个模块写得好不好,直接影响用户对系统质量的直观感受。注册功能的核心逻辑:用户提交用户名、密码、确认密码、手机号,前端JavaScript先做一次本地校验(是否为空、两次密码是否一致、手机号是否11位),通过后提交到RegisterServlet。后端要做的是:检查用户名是否已被占用(查一次数据库)、密码MD5加密后再存入数据库(明文存密码是绝对不允许的行为,哪怕只是学习项目也要养成习惯)、注册成功后重定向到登录页。MD5加密建议再加强,加盐盐值可以是用户名+固定字符串,这样即使两个用户密码相同,密文也不同。
登录功能稍微复杂一点,涉及会话管理。典型实现是:LoginServlet接收用户名和密码,调用userDao查询数据库比对,比对成功就把user对象存进Session,重定向到首页或用户中心;比对失败就返回登录页并带上错误消息。这里要注意:密码比对不要在Java代码里先把数据库存的密文解密(MD5不可逆),而是把用户输入的密码做同样的MD5处理后,跟数据库里的密文比较是否相等。
会话过期和退出登录也别忘了。Session默认有效期是30分钟,可以修改web.xml里的session-timeout参数。退出登录就是session.invalidate()干掉全部会话数据。还有一个安全细节:登录成功后建议顺手request.changeSessionId()一次,防止Session固定攻击,这类细节虽然在这个阶段不会考,但写上不扣分,反而显得专业。
4.4 购物车与订单模块的实现要点
购物车是本系统里牵扯前端交互最深的一个模块。核心数据模型是Cart表,关联用户和商品,但购物车页面展示的时候,通常需要把商品名称、单价、小图一起查出来,所以DAO里的SQL一般是多表关联查询:
sql复制SELECT c.id, c.product_id, c.quantity, p.name, p.price, p.image
FROM cart c
JOIN product p ON c.product_id = p.id
WHERE c.user_id = ?
修改数量按钮的前端交互逻辑建议这样设计:用户点击数量加减按钮,先在前端更新显示的数量和小计金额,然后立刻通过Ajax把productId和新的quantity发送到后端更新数据库。Ajax这一块的实现不复杂,用原生fetch即可:
javascript复制fetch('${pageContext.request.contextPath}/cart/update', {
method: 'POST',
headers: {'Content-Type': 'application/x-www-form-urlencoded'},
body: 'productId=' + productId + '&quantity=' + newQuantity
}).then(res => res.json()).then(data => {
if (data.code === 200) {
// 更新成功,刷新总价
refreshTotalPrice();
}
});
Ajax请求有一个老生常谈的坑:POST请求的Content-Type必须设置成application/x-www-form-urlencoded,否则后端用request.getParameter()拿不到参数。如果你忘了设好多人排查半天,就这一个基础细节。
订单模块的创建流程值得细写。用户从购物车点击“去结算”,后端要做的事情有一串:读取购物车中该用户的所有商品、计算总金额、校验库存、生成订单号和订单记录、把购物车商品逐一写入订单明细表、扣减库存或标记已购买、最后清空购物车。这一系列操作必须放在同一个数据库事务里执行——任何一个步骤失败都要整体回滚。如果不用事务,会出现“订单表生成了主记录,但明细一条都没写入,库存也没扣”这样的脏数据。在JDBC里开事务是在Connection上调setAutoCommit(false),所有SQL执行完毕再commit(),任何异常就rollback()。
库存扣减这里有个细节逻辑:是下单即扣还是支付后扣?学习项目通常建议下单即扣,因为系统没接真实支付(顶多模拟支付),如果你不扣库存,用户反复下单就会超卖。真实的电商系统里会复杂很多,涉及库存预占、超卖控制、消息队列削峰等等,这不属于当前项目讨论的范畴,但你可以留个印象。
4.5 后台管理模块与文件上传
后台管理模块面向管理员,包含商品管理、订单管理、用户管理等子功能。商品管理页面需要支持:分页展示商品列表、新增商品、编辑商品、上架/下架切换、删除商品。这里有一个安全隐患要特别指出:删除商品不建议物理删除。为什么?因为历史订单明细里记录着外键关联的product_id,你一旦物理删除商品,订单查看页面就会因为查不到商品而报错或显示空白。生产环境更不要物理删数据,数据是有价值资产,建议用status字段标记下架(逻辑删除)替代DB删除操作。
新增和编辑商品会涉及图片上传,这是JSP+Servlet项目中一个稍显繁琐但绕不开的功能。实现步骤是:前端<form enctype="multipart/form-data">提交,后端用part = request.getPart("file")获取文件流,然后定义上传目录(比如项目WebContent下建uploads/目录或者服务器上的某个绝对路径),把文件写入然后生成访问URL存到数据库的image字段。注意几个细节:
- 必须给上传目录配置访问权限,确保浏览器能通过URL访问到图片;
- 文件名不要用用户原始文件名,容易重名和产生路径穿越风险,用
UUID.randomUUID()重新生成; - 要限制文件类型和大小,只允许jpg/png/gif,大小控制在2MB以内;
- 上传目录一定要和项目部署路径对应,用
getServletContext().getRealPath("/uploads")获取物理路径,别硬编码本地路径,不然部署到服务器就找不到了。
订单管理模块相对简单,主要就是按订单状态筛选列表、点击订单查看详情(包括收货信息、商品明细列表、总额)、将订单状态从待付款改为已付款、从已付款改为已发货等。订单状态机可以画一张非常清晰的状态跳转图,页面上的操作按钮根据当前状态动态展示——比如订单还没付款,就不该显示“发货”按钮,这些判断在JSP里用<c:if>就能控制。
4.6 项目部署上线:从本地到服务器
本地IDEA跑通只是第一步,项目最终提交或展示时通常需要部署到服务器上。有两种常见方式:把项目打包成war包,丢到Tomcat的webapps目录下自动解压;或者直接把整个项目目录复制到服务器上用Tomcat运行。推荐war包方式,因为方便迁移。
打包war包时,最需要注意的地方是:数据库连接、上传路径、服务器端口这些环境相关的配置,不要写在代码里写死,而是应该提取到配置文件里。在项目里放一个db.properties,用Properties类读取,这样换环境只需要改配置文件,不用重新编译。另外,web.xml里的<welcome-file-list>记得配置好欢迎页,比如index.jsp,这样访问域名根路径就能直接进首页。
部署上线后还有一个高频问题要提醒:你本地MySQL的账号密码可能和服务器不一致,服务器的防火墙和云安全组也要放行Tomcat端口(默认8080)和MySQL端口(3306)。很多项目本地跑得好好的,一上线访问不了,查到最后就是服务器安全组没放行端口,纯低级错误,但真的非常常见。
5. 常见问题与排查技巧实录:把这些坑提前踩一遍
5.1 数据库连接失败,提示Unknown database或Access denied
这个问题几乎每个新手都会遇到。Unknown database说明数据库没建,或者你URL里写的库名跟实际建的不一致;Access denied for user说明账号密码不对,或者root的host限制导致只允许本机访问。排查顺序建议是这样:先mysql -u root -p进命令行手敲一遍SQL,确认账号密码和库名正确;再检查URL里的localhost和端口。最常见的原因是数据库建了但没执行建表脚本,或者密码里包了特殊字符(如@、#)导致URL解析异常——解决办法是URL里对特殊字符做URL编码。
5.2 JSP页面中文乱码
中文乱码有三大来源:页面本身编码不对、请求参数传递编码不对、数据库存取编码不对。排查思路可以按链路走:先看JSP文件头部有没有<%@ page contentType="text/html;charset=UTF-8" %>,页面文件的物理编码是否也是UTF-8;再看Servlet里request.setCharacterEncoding("UTF-8")有没有在读取参数前调用;最后看数据库表字符集是否用了utf8或utf8mb4。很多时候一条链路修好了,另一条又冒出来,所以我建议把所有环境统一到UTF-8:项目文件编码、JSP脚本指令、JDBC URL的characterEncoding、MySQL建库语句的charset,全部一致就不会再乱。
5.3 登录后一直跳回登录页
这背后的机制问题出在Session上。几种可能:登录成功后没有把user对象存进Session——你的LoginServlet里只重定向了但代码没执行session.setAttribute("user", user);或者过滤器的拦截逻辑写反了,把所有请求(包括登录接口本身)都拦截了;再或者Session过期时间太短。我遇到过一个特别奇葩的案例——过滤器里判断“是否登录”用错了Session属性名,存的时候用的user,取的时候写的currentUser,结果永远认为没登录。这种问题调试时先输出Session里的所有属性值看看,往往一眼就能发现。
5.4 分页参数丢失或页码越界
分页参数丢失大多是因为链接只带了pageNumber没带搜索条件和分类ID,解决方法是把筛选条件以隐藏字段或参数拼接方式一起传递。页码越界则是因为用户手动修改URL里的页码数字,比如总共有10页他把pageNumber改成99,后端如果没有对页码做范围限制就会查出一页空的。建议在后端做一次兜底校验:pageNumber = Math.max(1, Math.min(pageNumber, totalPages)),保证不管前端传什么,后端返回的都是合法数据。
5.5 上传图片后页面404无法显示
这个问题跟部署容器的路径映射有关。如果你把图片上传到了IDEA工作区里的某个目录,但IDEA部署项目时用的是target目录,则两者不是同一个目录,自然访问不到。解决办法是统一使用ServletContext.getRealPath()获取的上传路径,部署后Tomcat会在自己的工作目录解压war包并创建上传目录,图片才能被Web容器对外访问。
5.6 表单重复提交产生重复订单
上面提到的防重token方案是最有效的办法。在订单结算页生成一个随机UUID存到Session中,同时作为隐藏字段放进表单;提交订单前,后端比对隐藏字段与Session中存储的是否一致,一致则处理订单然后立即清空Session里的token。用户刷新页面重新提交时,Session里的token已经没了,请求直接被拒绝。这是学习项目里性价比极高的一个功能,代码量不大但能讲出不少东西,答辩时也是亮点。
6. 我踩过的一些坑与扩展思考
说实话,这个项目里最好的老师其实是报错信息。我见过太多初学者一看到Exception就慌,其实大部分异常信息已经把原因说得清清楚楚了。ClassNotFoundException是驱动或jar包缺失,NullPointerException多半是查询结果为空却强行调用方法,NumberFormatException是字符串转数字失败,SQLIntegrityConstraintViolationException是数据库约束冲突。你只需要学会“从上往下读、抓第一行Cause by”,绝大多数问题都能定位。
可以扩展的方向也很多。如果想让这个项目在毕业答辩或面试时更有竞争力,我不建议在原有架构上继续堆功能,而是可以考虑做一次轻量级的架构升级:保留现有业务逻辑,把前端Bootstrap页面升级成Vue或React单页应用,后端Servlet提供JSON格式的API接口;或者引入Redis做缓存和Session共享;又或者用Spring Boot + MyBatis重构后端。这些升级路径会让你的技术栈直接从“课设水平”跳到“工业级项目”的讨论范围。不过要提醒你,架构升级的前提是现有项目的业务逻辑你已经能吃透了——不然你会陷入“旧代码没理解、新框架不会用”的双重困境。
我最初带人做这个项目的时候,总是会反复强调一个观念:做项目不是为了交一份能跑的代码,而是要通过代码把“浏览器与服务器如何对话、数据如何在内存与磁盘间流转、业务逻辑如何从前端流转到后端再落进数据库”这条主线彻底想明白。鲜花商城天然具备完整的业务闭环——从用户逛店到下单到后台管理,几乎覆盖了JavaWeb学习路径上的每一个关键节点。真心建议拿到这个项目的同学,不要急着复制粘贴,按着我上面的设计思路一步一步捋,能自己写的地方尽量自己写一遍,这个过程带给你的收获,远不是一份能交差的代码能比的。
