1. 题外话:这套技术组合是“老”,但它照样能打
先把结论放前面:如果你在课程设计、毕业设计或者个人练手阶段选了“基于 JavaWeb + SpringBoot + SSM + MySQL + JSP + Maven 的商城系统”,这个方向并不落后,反而是最稳妥的一条路。商城类项目是所有 JavaWeb 项目里最典型的综合性场景——它天然牵扯到用户体系、商品展示、购物车、订单流转、库存扣减、后台管理这些模块,几乎把 Java 后端日常开发会碰到的 CRUD、关联查询、事务、会话管理、权限拦截、文件上传全部覆盖了一遍。换句话讲,你做完这一个项目,约等于把 JavaWeb 时代到 SpringBoot 时代的三板斧全部过了一遍。
我见过不少同学一上来就追求 vue3 + springboot 前后端分离,结果前端框架学了大半年,后端连一个完整的订单事务都没写明白。而 JSP + SpringBoot 的方式恰好把重点拉回到服务端:页面渲染交给 JSP,业务逻辑交给 Service 层,数据访问交给 MyBatis,你不需要纠结跨域、token 刷新、前端路由守卫这些额外概念,可以专心把所有精力放在后端模型设计上。
这篇博文我不会只给一个通篇的“看着能跑”的代码粘贴,而是按照我自己从零到一搭这套商城时踩过的坑、用过的取舍、反复调过的配置来写,尽量做到你跟着走一遍,能真正跑起来,并且知道每一步为什么这么干。
需要模型API调用? 免费领10W Token,多模型网关一键接入 Claude、DeepSeek 等主流模型。
2. 冷启动:搭工程骨架最容易翻车的地方
2.1 版本组合是最大的坑:SpringBoot 2.7.x + JSP 兼容性
第一件非常重要的事:如果你打算用 JSP 做视图层,千万别直接用 SpringBoot 3.x。SpringBoot 3.0 全面转向 Jakarta EE,底层包名从 javax.* 变成了 jakarta.*,而传统 JSP 的页面解析、Taglib 依赖大量基于 javax.servlet,哪怕你把依赖版本强行调上去了,本地启动也会报各种 ClassNotFoundException 或者 JSP 编译不过的错。
我自己最开始没注意,直接用了当时最新的 SpringBoot 3.1.2,结果 JSP 页面一直 404,后来切换到 2.7.18 后一切正常。所以建议直接用 SpringBoot 2.7.x,JDK 用 8 或者 11(我用的 1.8),Maven 用 3.6.3 以上版本。这套组合你见过的大部分资料都能完全对上,遇到问题搜一下也能得到靠谱答案。
核心依赖大致如下(只列关键部分):
xml复制<parent>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-parent</artifactId>
<version>2.7.18</version>
<relativePath/>
</parent>
<dependencies>
<!-- Web 启动器,内嵌 Tomcat -->
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-web</artifactId>
</dependency>
<!-- JSP 解析依赖:jasper 负责把 .jsp 编译为 servlet -->
<dependency>
<groupId>org.apache.tomcat.embed</groupId>
<artifactId>tomcat-embed-jasper</artifactId>
<scope>provided</scope>
</dependency>
<!-- JSTL 标签库,页面要用的 c:forEach / c:if -->
<dependency>
<groupId>javax.servlet</groupId>
<artifactId>jstl</artifactId>
<version>1.2</version>
</dependency>
<!-- MyBatis 与 SpringBoot 整合 -->
<dependency>
<groupId>org.mybatis.spring.boot</groupId>
<artifactId>mybatis-spring-boot-starter</artifactId>
<version>2.3.2</version>
</dependency>
<!-- MySQL 驱动 -->
<dependency>
<groupId>mysql</groupId>
<artifactId>mysql-connector-java</artifactId>
<version>8.0.33</version>
</dependency>
<!-- 数据库连接池,内置的 Tomcat JDBC Pool 可以用,但我更推荐 druid -->
<dependency>
<groupId>com.alibaba</groupId>
<artifactId>druid-spring-boot-starter</artifactId>
<version>1.2.20</version>
</dependency>
<!-- 分页插件 -->
<dependency>
<groupId>com.github.pagehelper</groupId>
<artifactId>pagehelper-spring-boot-starter</artifactId>
<version>1.4.7</version>
</dependency>
<!-- Lombok 简化实体类,可有可无 -->
<dependency>
<groupId>org.projectlombok</groupId>
<artifactId>lombok</artifactId>
<optional>true</optional>
</dependency>
</dependencies>
构建配置里还要注意 packaging 必须改成 war,因为 SpringBoot 内置 Tomcat 默认只处理静态资源和 Controller 返回的模板内容,JSP 需要 Servlet 容器支持,打成 war 包后由外部/内嵌 Tomcat 才能正确编译 JSP。同时在 spring-boot-maven-plugin 里加一句 <mainClass> 指向启动类,保证打成 war 包后用 java -jar xxx.war 也能跑:
xml复制<build>
<finalName>cosmetic-mall</finalName>
<plugins>
<plugin>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-maven-plugin</artifactId>
<configuration>
<mainClass>com.cosmetic.mall.CosmeticMallApplication</mainClass>
</configuration>
</plugin>
</plugins>
</build>
2.2 application.yml 配置的三个注意点
第一个注意点是 JSP 视图解析器。SpringBoot 默认支持 SpringMVC 的视图解析规则,但 JSP 前缀后缀并没有像 spring.thymeleaf.prefix 那样有官方自动配置,需要手动在 application.yml 里加:
yaml复制spring:
mvc:
view:
prefix: /WEB-INF/jsp/
suffix: .jsp
第二个注意点是 spring-boot 内嵌 Tomcat 对 JSP 的支持要额外配置静态资源放行。如果你把静态文件放在 src/main/webapp/static 下,默认 SpringBoot 不会把 webapp 目录打包进可执行 jar(这也是为什么打 war 包更稳),建议静态资源直接放在 src/main/resources/static/ 下,然后在 JSP 里用 ${pageContext.request.contextPath} 拼接资源路径,这样本地跑和部署到 Tomcat 都不会丢样式。
第三个注意点是你如果用了 MySQL 8.x,驱动类要写 com.mysql.cj.jdbc.Driver,并且连接串里必须加时区和编码参数:
yaml复制spring:
datasource:
url: jdbc:mysql://localhost:3306/cosmetic_mall?useUnicode=true&characterEncoding=utf8&serverTimezone=Asia/Shanghai&useSSL=false
username: root
password: 你自己的密码
driver-class-name: com.mysql.cj.jdbc.Driver
type: com.alibaba.druid.pool.DruidDataSource
mybatis:
mapper-locations: classpath:mapper/*.xml
type-aliases-package: com.cosmetic.mall.entity
configuration:
map-underscore-to-camel-case: true
pagehelper:
helper-dialect: mysql
reasonable: true
map-underscore-to-camel-case: true 这个配置我单独强调一下:MySQL 里习惯用 create_time、user_name 这种下划线命名,Java 实体类是驼峰 createTime、userName,开启这个配置后 MyBatis 自动映射,能少写大量 resultMap。
2.3 启动类别忘了继承 SpringBootServletInitializer
为了让 war 包能部署到独立 Tomcat,并且被 SpringBoot 的启动器识别,启动类最好继承 SpringBootServletInitializer 并重写 configure:
java复制@SpringBootApplication
@MapperScan("com.cosmetic.mall.mapper")
public class CosmeticMallApplication extends SpringBootServletInitializer {
public static void main(String[] args) {
SpringApplication.run(CosmeticMallApplication.class, args);
}
@Override
protected SpringApplicationBuilder configure(SpringApplicationBuilder builder) {
return builder.sources(CosmeticMallApplication.class);
}
}
这一步不是必须的——如果你只是用 java -jar 本地跑,不写也能启动。但很多同学的评分要求是“打成 war 包丢进 Tomcat 的 webapps 下能跑”,提前把这一步做掉,后面省事。
3. 动手前先想清楚表结构:商城数据的骨架就是这六张表
写商城项目最容易犯的错误是急着写代码。我的经验是,先把表设计出来,再回推实体类和 Mapper,写起代码会顺畅很多。一套麻雀虽小五脏俱全的商城,至少需要这几张表:
- 用户表
t_user - 分类表
t_category - 商品表
t_product - 轮播图表
t_carousel - 购物车表
t_cart - 订单表
t_order - 订单明细表
t_order_item
先看用户表。重点不是字段多,而是把唯一约束做好。用户名和手机号做唯一索引,防止注册时并发插入两条同名的脏数据。密码保存的是 MD5 加盐后的值,一律不要明文存储,即使只是个课设项目,这也是基本素养。角色字段我用 role 区分管理员和普通用户:0 表示后台管理员,1 表示前台用户。商城系统一般会同时包含前台的用户端和后台的管理端,这个字段方便后续做权限拦截。
商品表是核心中的核心。除开基本的名称、价格、描述、图片,我建议一定要单独加 stock 库存字段和 sales 销量字段。这两个字段直接影响商城日常业务:商品列表按销量排序、商品详情判断还能买多少、提交订单时校验库存。图片字段我存的是相对路径 /static/img/product/xxx.jpg,不存完整 URL,因为本地开发环境、部署到服务器环境,IP 和端口都可能变,完整 URL 存库后换环境就废了。
订单表和订单明细表需要分开设计。t_order 记录一次购买,包含订单号、用户ID、总金额、收货人信息、订单状态、创建时间;t_order_item 记录这一次购买里的每一件商品,包含商品ID、商品名称、商品图片、购买单价、购买数量、小计金额。这么设计是为了“快照”——商品价格和商品名称会变,但订单明细里保存的必须是用户下单那一刻的价格和名称,否则日后查订单会出大问题。
订单状态我用整数表示:0 待付款、1 待发货、2 待收货、3 已完成、4 已取消。用整数比用字符串的好处是后续如果你接支付回调,状态流转判断写起来比较清晰;页面上需要展示“待付款”这几个字的时候,在 JSP 里用一个状态映射方法转成中文即可。
下面给出建表 SQL 的核心部分,字段上我做了一些取舍,够用但不过度设计:
sql复制CREATE TABLE `t_user` (
`id` int NOT NULL AUTO_INCREMENT,
`username` varchar(50) NOT NULL COMMENT '登录用户名',
`password` varchar(128) NOT NULL COMMENT 'MD5加密后的密码',
`nickname` varchar(50) DEFAULT NULL,
`phone` varchar(20) DEFAULT NULL,
`email` varchar(100) DEFAULT NULL,
`avatar` varchar(255) DEFAULT NULL,
`role` tinyint NOT NULL DEFAULT '1' COMMENT '0管理员 1普通用户',
`status` tinyint NOT NULL DEFAULT '1' COMMENT '1正常 0禁用',
`create_time` datetime NOT NULL DEFAULT CURRENT_TIMESTAMP,
PRIMARY KEY (`id`),
UNIQUE KEY `uk_username` (`username`)
) ENGINE=InnoDB AUTO_INCREMENT=1 DEFAULT CHARSET=utf8mb4;
CREATE TABLE `t_category` (
`id` int NOT NULL AUTO_INCREMENT,
`name` varchar(50) NOT NULL,
`sort_order` int DEFAULT '0',
`create_time` datetime DEFAULT CURRENT_TIMESTAMP,
PRIMARY KEY (`id`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
CREATE TABLE `t_product` (
`id` int NOT NULL AUTO_INCREMENT,
`category_id` int NOT NULL,
`name` varchar(200) NOT NULL,
`subtitle` varchar(500) DEFAULT NULL COMMENT '副标题/卖点',
`detail` text COMMENT '商品详情',
`main_image` varchar(255) DEFAULT NULL,
`price` decimal(10,2) NOT NULL,
`stock` int NOT NULL DEFAULT '0',
`sales` int NOT NULL DEFAULT '0',
`status` tinyint NOT NULL DEFAULT '1' COMMENT '1上架 0下架',
`create_time` datetime DEFAULT CURRENT_TIMESTAMP,
PRIMARY KEY (`id`),
KEY `idx_category` (`category_id`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
CREATE TABLE `t_cart` (
`id` int NOT NULL AUTO_INCREMENT,
`user_id` int NOT NULL,
`product_id` int NOT NULL,
`quantity` int NOT NULL DEFAULT '1',
`checked` tinyint NOT NULL DEFAULT '1' COMMENT '是否勾选 1选中 0未选中',
`create_time` datetime DEFAULT CURRENT_TIMESTAMP,
`update_time` datetime DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
PRIMARY KEY (`id`),
UNIQUE KEY `uk_user_product` (`user_id`, `product_id`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
CREATE TABLE `t_order` (
`id` int NOT NULL AUTO_INCREMENT,
`order_no` varchar(64) NOT NULL COMMENT '订单号',
`user_id` int NOT NULL,
`total_price` decimal(10,2) NOT NULL,
`receiver_name` varchar(50) NOT NULL,
`receiver_phone` varchar(20) NOT NULL,
`receiver_address` varchar(255) NOT NULL,
`status` tinyint NOT NULL DEFAULT '0' COMMENT '0待付款 1待发货 2待收货 3已完成 4已取消',
`create_time` datetime DEFAULT CURRENT_TIMESTAMP,
`pay_time` datetime DEFAULT NULL,
`send_time` datetime DEFAULT NULL,
`finish_time` datetime DEFAULT NULL,
PRIMARY KEY (`id`),
UNIQUE KEY `uk_order_no` (`order_no`),
KEY `idx_user_id` (`user_id`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
CREATE TABLE `t_order_item` (
`id` int NOT NULL AUTO_INCREMENT,
`order_id` int NOT NULL,
`product_id` int NOT NULL,
`product_name` varchar(200) NOT NULL,
`product_image` varchar(255) DEFAULT NULL,
`current_unit_price` decimal(10,2) NOT NULL COMMENT '下单时商品单价',
`quantity` int NOT NULL,
`total_price` decimal(10,2) NOT NULL COMMENT '小计金额',
PRIMARY KEY (`id`),
KEY `idx_order_id` (`order_id`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
一个容易忽略的小点:购物车表要设置 user_id + product_id 的唯一索引。没有这个唯一索引,代码里每次加购都要先 select 再判断是新增还是更新数量,而且并发请求下可能出现同一用户购物车里出现两条相同商品记录。有了唯一索引,直接用 INSERT ... ON DUPLICATE KEY UPDATE quantity = quantity + 1 一条 SQL 搞定,省一次查询,也避免重复。
4. 后端编码:SSM 三层架构在 SpringBoot 里的落地姿势
4.1 用户注册登录:加密、Session 和拦截器
用户这块的逻辑不算复杂,但有两个细节值得写进代码习惯里。
第一是密码加密。我用的办法是 MD5 + 固定盐:MD5(password + salt)。注意注册时要先查一遍用户名是否已存在,如果存在直接返回“用户名已占用”。MD5 在真实场景里不够安全,但课设和入门项目里已经能体现“不能明文存密码”的意识。如果你有余力,可以直接用 BCryptPasswordEncoder,代码量差不多。
注册的 Service 里大致是:
java复制@Service
public class UserServiceImpl implements UserService {
@Autowired
private UserMapper userMapper;
@Override
public Result register(User user) {
if (userMapper.findByUsername(user.getUsername()) != null) {
return Result.error("用户名已存在");
}
String salt = "cosmetic2024";
String encrypted = DigestUtils.md5DigestAsHex(
(user.getPassword() + salt).getBytes(StandardCharsets.UTF_8));
user.setPassword(encrypted);
user.setRole(1);
user.setStatus(1);
userMapper.insert(user);
return Result.success();
}
}
登录成功的关键操作是把用户信息放进 Session。user 对象里绝对不能放密码,只放 id、username、nickname、role 这些展示需要的信息,我一般单独封装一个 UserVO。然后在 SpringMVC 配置类里注册拦截器,拦截 /user/**、/cart/**、/order/** 这些路径,从 Session 取不到用户就重定向到登录页,同时放行首页、商品列表、商品详情和登录注册接口:
java复制@Configuration
public class WebMvcConfig implements WebMvcConfigurer {
@Autowired
private LoginInterceptor loginInterceptor;
@Override
public void addInterceptors(InterceptorRegistry registry) {
registry.addInterceptor(loginInterceptor)
.addPathPatterns("/user/**", "/cart/**", "/order/**", "/admin/**")
.excludePathPatterns("/login", "/register", "/index", "/product/**", "/static/**", "/carousel/**");
}
}
这种基于 Session 的会话管理在 JSP 项目里最自然,JSP 里需要显示当前登录人时直接 ${sessionScope.user.nickname} 就行,不需要像前后端分离那样每次请求都带 token。
4.2 商品与分类:列表、搜索、详情和 PageHelper 分页
前台首页的商品展示,我分了三块:分类导航、轮播图、热销商品/新品推荐。这样既能把首页撑起来,又能练到多表查询和关联查询的基本功。
分类导航是最简单的,select * from t_category order by sort_order desc 查出来放到 request 域的 categories,然后在 JSP 顶部循环输出。轮播图需要单独建一张表,字段大概就是 id, image_url, target_url, sort_order, status,后台管理员可以维护。热销商品列表用 select * from t_product where status = 1 order by sales desc limit 8,新品列表改成 order by create_time desc limit 8,两次查询一组合就是首页的大致形态。
商品列表页一定要做分页。这里强烈推荐 PageHelper,因为它和 SpringBoot + MyBatis 的配合非常丝滑:只需要在查询前调用 PageHelper.startPage(pageNum, pageSize),紧接着执行的那条 Mapper 查询就会自动被拦截加上 LIMIT 语句。返回的是一个 PageInfo,里面天生带着 total、pageNum、pages、list 这些分页要用的数据。
一个典型的 Controller 方法长这样:
java复制@Controller
public class ProductController {
@Autowired
private ProductService productService;
@GetMapping("/product/list")
public String list(@RequestParam(defaultValue = "1") int pageNum,
@RequestParam(defaultValue = "8") int pageSize,
@RequestParam(required = false) Integer categoryId,
@RequestParam(required = false) String keyword,
Model model) {
PageInfo<ProductVO> pageInfo = productService.queryList(pageNum, pageSize, categoryId, keyword);
model.addAttribute("pageInfo", pageInfo);
model.addAttribute("categoryId", categoryId);
model.addAttribute("keyword", keyword);
return "product-list";
}
}
Mapper XML 里对应的动态 SQL 大概是:
xml复制<select id="queryList" resultType="com.cosmetic.mall.vo.ProductVO">
select p.id, p.name, p.subtitle, p.main_image, p.price, p.sales,
c.name as categoryName
from t_product p
left join t_category c on p.category_id = c.id
where p.status = 1
<if test="categoryId != null">
and p.category_id = #{categoryId}
</if>
<if test="keyword != null and keyword != ''">
and (p.name like concat('%', #{keyword}, '%')
or p.subtitle like concat('%', #{keyword}, '%'))
</if>
order by p.sales desc, p.create_time desc
</select>
注意使用 concat('%', #{keyword}, '%') 而不是 '%${keyword}%'。用 ${} 拼接会有 SQL 注入风险,用 #{} 参数占位符才是安全姿势。
4.3 购物车与订单:核心流程和事务是关键中的关键
购物车模块本身是几张表的增删改查,但有几个细节影响体验:加购时判断库存是否足够;同一商品再次加购时数量累加而不是新增一行;购物车列表里显示商品当前价格,并且小计要由后端重新计算,不能直接用前端传过来的数字。最后一条尤其重要——一个正经系统里,前端传的任何金额都不可信。
订单模块是整个商城的技术重点。从购物车勾选商品生成订单,到支付(这里我用模拟支付)、发货、收货、取消,这一条链路把 Spring 声明式事务体现得淋漓尽致。
下单的 Service 方法大致如下:
java复制@Service
public class OrderServiceImpl implements OrderService {
@Autowired
private OrderMapper orderMapper;
@Autowired
private OrderItemMapper orderItemMapper;
@Autowired
private CartMapper cartMapper;
@Autowired
private ProductMapper productMapper;
@Override
@Transactional(rollbackFor = Exception.class)
public Result createOrder(List<Long> cartIds, Long userId, UserAddress address) {
// 1. 查询勾选的购物车记录
List<CartVO> cartList = cartMapper.selectCheckedCartItems(cartIds, userId);
if (cartList == null || cartList.isEmpty()) {
return Result.error("请选择要结算的商品");
}
// 2. 计算总金额,同时逐条校验库存
BigDecimal totalPrice = new BigDecimal("0");
List<Product> productsToUpdate = new ArrayList<>();
for (CartVO cart : cartList) {
Product product = productMapper.selectById(cart.getProductId());
if (product == null || product.getStatus() != 1) {
return Result.error(cart.getProductName() + "已下架");
}
if (product.getStock() < cart.getQuantity()) {
return Result.error(cart.getProductName() + "库存不足");
}
totalPrice = totalPrice.add(product.getPrice().multiply(new BigDecimal(cart.getQuantity())));
// 记录待扣库存的商品
cart.setCurrentPrice(product.getPrice());
productsToUpdate.add(product);
}
// 3. 生成订单号,插入订单主表
String orderNo = generateOrderNo();
Order order = new Order();
order.setOrderNo(orderNo);
order.setUserId(userId);
order.setTotalPrice(totalPrice);
order.setReceiverName(address.getReceiverName());
order.setReceiverPhone(address.getReceiverPhone());
order.setReceiverAddress(address.getReceiverAddress());
order.setStatus(0);
orderMapper.insert(order);
// 4. 插入订单明细,并扣减库存、增加销量
for (CartVO cart : cartList) {
OrderItem item = new OrderItem();
item.setOrderId(order.getId());
item.setProductId(cart.getProductId());
item.setProductName(cart.getProductName());
item.setProductImage(cart.getProductImage());
item.setCurrentUnitPrice(cart.getCurrentPrice());
item.setQuantity(cart.getQuantity());
item.setTotalPrice(cart.getCurrentPrice().multiply(new BigDecimal(cart.getQuantity())));
orderItemMapper.insert(item);
productMapper.decreaseStock(cart.getProductId(), cart.getQuantity());
productMapper.increaseSales(cart.getProductId(), cart.getQuantity());
}
// 5. 清空已结算的购物车
cartMapper.deleteBatch(cartIds, userId);
return Result.success(orderNo);
}
}
这个方法加了 @Transactional(rollbackFor = Exception.class),等于告诉 Spring:这个方法里任何一个环节抛异常,前面已经执行的数据库操作全部回滚。这是购物车到订单转化过程中最重要的保证,否则可能出现“订单主表写入成功、明细没写进去”或者“库存扣了、订单没建成”这种数据不一致的问题。
rollbackFor = Exception.class 这里必须显式指定。Spring 默认只在遇到 RuntimeException 时回滚,你如果抛的是 Exception 子类,默认策略下事务不会回滚,库存就白白扣掉了。这类细节面试时最爱问,写代码时也最容易忽略。
5. JSP 前端:不用前后端分离也能把页面写干净
5.1 JSP 在这个项目里的价值
很多新手不理解为什么课程设计还要用 JSP,明明 SpringBoot 官方推荐的是 Thymeleaf。原因分两层:一方面许多学校教材、老项目的资料都是 JSP 体系,用 JSP 意味着你能吃透 Java Web 的完整知识链——Servlet 容器、过滤器、标签库、EL 表达式,这些在面试的时候仍然是基本功;另一方面,JSP 方便实时修改预览,改完 .jsp 页面刷新就能看到效果,而不像某些前后端分离项目,改一个按钮要同时端起前端和后端两套服务。
JSP 写商城,几个实践细节分享给大家。
5.2 公共片段一定要抽出来,否则改样式改到哭
商城页面多:首页、商品列表、商品详情、购物车、结算页、订单列表、后台管理……如果每个 JSP 都复制粘贴一大段 header 和 footer,后面改导航栏里的一个链接,你得手动一个一个文件改,改漏了就出现页面风格不统一。所以第一步是把公共部分抽成片段。
我用的是最传统也最稳的方式:<jsp:include>,不需要额外引入任何依赖,JDK 和 JSP 容器天然支持。
header.jsp 里放公共导航栏和登录状态判断:
jsp复制<%@ page contentType="text/html;charset=UTF-8" language="java" %>
<%@ taglib prefix="c" uri="http://java.sun.com/jsp/jstl/core" %>
<%
String ctx = request.getContextPath();
pageContext.setAttribute("ctx", ctx);
%>
<div class="header">
<div class="logo">
<a href="${ctx}/index">化妆品商城</a>
</div>
<div class="nav">
<a href="${ctx}/index">首页</a>
<a href="${ctx}/product/list">全部商品</a>
<a href="${ctx}/cart/list">购物车</a>
<a href="${ctx}/order/list">我的订单</a>
</div>
<div class="user-info">
<c:choose>
<c:when test="${sessionScope.user != null}">
<span>欢迎,${sessionScope.user.nickname}</span>
<a href="${ctx}/user/logout">退出</a>
<c:if test="${sessionScope.user.role == 0}">
<a href="${ctx}/admin/index">后台管理</a>
</c:if>
</c:when>
<c:otherwise>
<a href="${ctx}/user/login">登录</a>
<a href="${ctx}/user/register">注册</a>
</c:otherwise>
</c:choose>
</div>
</div>
注意我在这里通过 request.getContextPath() 存了一个 ctx 变量。这是 JSP 项目里最常见的引静态资源方式:${ctx}/static/css/style.css。项目部署到 Tomcat 之后,ContextPath 可能变成 /cosmetic-mall,如果写死 /static/xxx,换环境就全崩。
页面头部再加一行:
jsp复制<%@ include file="/WEB-INF/jsp/common/header.jsp" %>
这是静态包含,相当于把 header.jsp 的代码原样复制进来,ctx 变量在每个页面都能用。相应地,底部单独一个 footer.jsp,在页面末尾静态包含。
5.3 商品循环、空列表判断、分页链接的 JSP 写法
商品列表页要做三件事:循环展示商品卡片、判断列表是否为空、生成分页链接。
循环用 JSTL:
jsp复制<c:forEach items="${pageInfo.list}" var="product">
<div class="product-card">
<a href="${ctx}/product/detail/${product.id}">
<img src="${ctx}${product.mainImage}" alt="${product.name}">
</a>
<div class="product-name">${product.name}</div>
<div class="product-price">¥${product.price}</div>
<div class="product-sales">已售${product.sales}件</div>
<a class="btn-add-cart" href="${ctx}/cart/add?productId=${product.id}">加入购物车</a>
</div>
</c:forEach>
空列表判断:
jsp复制<c:if test="${pageInfo == null || pageInfo.list.size() == 0}">
<div class="empty-tip">还没有相关商品,换个关键词试试吧。</div>
</c:if>
分页链接是一个比较经典的“小技术大麻烦”:很多人的分页总是丢参数,翻到第 2 页以后分类筛选、关键字就没了。正确做法是把当前查询条件通过隐藏参数一起拼进分页链接。我在 Controller 里把 categoryId、keyword 都放了进 Model,JSP 生成分页时带上:
jsp复制<c:if test="${pageInfo.pages > 1}">
<div class="pagination">
<c:if test="${pageInfo.hasPreviousPage}">
<a href="${ctx}/product/list?pageNum=${pageInfo.prePage}&pageSize=8&categoryId=${categoryId}&keyword=${keyword}">上一页</a>
</c:if>
<span>第 ${pageInfo.pageNum} / ${pageInfo.pages} 页</span>
<c:if test="${pageInfo.hasNextPage}">
<a href="${ctx}/product/list?pageNum=${pageInfo.nextPage}&pageSize=8&categoryId=${categoryId}&keyword=${keyword}">下一页</a>
</c:if>
</div>
</c:if>
这里 categoryId 和 keyword 可能为 null,拼在 URL 里就是 categoryId=null,MyBatis 参数接收后 categoryId != null 判断依然成立,会产生错误查询。所以在前端处理一下:
jsp复制<c:set var="categoryParam" value="${empty categoryId ? '' : categoryId}" />
<c:set var="keywordParam" value="${empty keyword ? '' : keyword}" />
然后在链接里用 categoryId=${categoryParam}&keyword=${keywordParam},避免 null 值乱入。
5.4 商品详情页:数量选择和加入购物车
商品详情页 JSP 的核心是一个带数量选择器的购买表单,数量不能超过库存。这部分我用最原始的 JS 实现:
jsp复制<div class="detail-info">
<h2>${product.name}</h2>
<div class="subtitle">${product.subtitle}</div>
<div class="price">¥${product.price}</div>
<div class="stock">库存:${product.stock} 件</div>
<form action="${ctx}/cart/add" method="post">
<input type="hidden" name="productId" value="${product.id}">
<div>
<span>数量:</span>
<button type="button" onclick="changeQty(-1)">-</button>
<input type="text" id="qty" name="quantity" value="1" readonly style="width:40px;text-align:center;">
<button type="button" onclick="changeQty(1)">+</button>
</div>
<button type="submit">加入购物车</button>
</form>
</div>
<script>
var maxStock = ${product.stock};
function changeQty(delta) {
var current = parseInt(document.getElementById('qty').value);
var newVal = current + delta;
if (newVal < 1) {
newVal = 1;
}
if (newVal > maxStock) {
alert('库存最多 ' + maxStock + ' 件');
newVal = maxStock;
}
document.getElementById('qty').value = newVal;
}
</script>
前端限制数量只是体验层面的保护,真正的库存校验仍然必须发生在后端,这个原则我在前面已经反复强调过,JSP 页面里也一样。
6. 订单流程里的三个硬骨头
6.1 库存扣减怎么做到不超卖
一个商城项目如果只是“能把流程跑通”,那库存扣减随便 update product set stock = stock - 1 就行。但如果你想在面试时讲出点东西,或者项目答辩时经得起老师追问,就必须考虑并发场景:同一件商品,两个用户同时下单,库存只剩 1 件,会不会两个人都下单成功?
我在实现里用了最简单可靠的办法:在扣减库存的 SQL 上加上 stock >= #{quantity} 条件。
xml复制<update id="decreaseStock">
update t_product
set stock = stock - #{quantity}
where id = #{productId}
and stock >= #{quantity}
</update>
这样当库存不足时,update 影响行数为 0,Service 层只需要检查 result == 0 就知道超卖了,直接抛异常回滚事务。比先 select 再判断库存再 update 的方式更安全——因为 select 和 update 之间存在时间差,并发下可能两个请求都读到库存足够,然后都执行了 update,导致超卖。
我的完整下单流程里,校验库存之后实际扣库存时还会再执行一次这个带条件的 update 作为兜底,这是双保险:
java复制int updated = productMapper.decreaseStock(productId, quantity);
if (updated == 0) {
throw new RuntimeException("库存不足");
}
这个方案不是最强的,但对于单机部署的课设和入门项目完全够用,而且能讲清楚“为什么不用悲观锁/乐观锁”——因为这个业务规模下,一条带条件的 update 就完成了原子扣减,代价最小。
6.2 订单状态机的设计
订单从生成到完成,状态不是随便乱跳的。我在系统里维护了下面这张状态流转表:
| 状态 | 值 | 可执行操作 | 下一状态 |
|---|---|---|---|
| 待付款 | 0 | 取消订单 / 模拟支付 | 已取消(4) / 待发货(1) |
| 待发货 | 1 | 管理员发货 | 待收货(2) |
| 待收货 | 2 | 用户确认收货 | 已完成(3) |
| 已完成 | 3 | 无 | 无 |
| 已取消 | 4 | 无 | 无 |
零散状态用一个整数来表示,Service 里写一个校验方法:
java复制private boolean canTransition(Integer currentStatus, Integer targetStatus) {
return (currentStatus == 0 && (targetStatus == 1 || targetStatus == 4))
|| (currentStatus == 1 && targetStatus == 2)
|| (currentStatus == 2 && targetStatus == 3);
}
这个校验看着简单,但在真实项目里价值很大。否则你写的 updateStatus 方法谁都能调用,前端只要改一下请求参数就能把订单从“待发货”直接改成“已完成”,这就是权限漏洞。后台管理端发货、用户确认收货、用户取消订单,都应该走各自独立的 Service 方法,方法内部先查当前状态再做状态校验,而不是暴露一个通用的 updateStatus。
模拟支付这个环节,我在项目里只做了非常简单的一步:把订单状态从 0 改成 1,然后记录 pay_time。真实项目中这里会对接微信/支付宝,需要回调验签、第三方报文解析,整套流程相当考验功底,但核心思路不变:支付成功异步回调,回调里根据订单号把订单状态置为已支付。
6.3 金额计算为什么不能信前端
前端展示购物车总金额、商品价格小计,一切看着都合理,但后端必须重新从数据库查价格再算一遍。为什么?因为前端的数据可以被伪造,任何人打开浏览器开发者工具,都可以把请求体里的价格字段从 100 改成 0.01。
我在生成订单的方法里,价格一律以 productMapper.selectById(productId) 查出来的 price 字段为准,购物车表里存的商品价格字段我都不作为下单价格依据。购物车展示时可以取一个 ProductVO 里的实时价格,但是下单时重新查表取最新价。这个原则在简历上写“订单金额以后端计算为准”绝对是个加分项。
还有一个容易忽略的点:订单明细里保存 current_unit_price 和 product_name 的快照。这是防止商品后来改价、改名导致历史订单对不上账,也是电商系统的行业惯例。以后用户投诉“我买的时候是 50 块,跟我的订单记录对不上”的时候,就能用订单明细里的快照作为证据。这个思路在答辩里讲出来,老师会认为你理解到了实际业务的层次。
7. 打包部署、本地运行和常见问题排查
7.1 打成 war 包还是 jar 包?
两种方式都可以,取决于你怎么评分。如果是老师要求“部署到 Tomcat”,打 war 包丢到 webapps 下即可;如果只是本地跑或者 Docker 部署,用 java -jar 直接运行也完全没问题。
打出 war 包的操作:
bash复制mvn clean package -Dmaven.test.skip=true
执行完,target 目录下会生成 cosmetic-mall.war。把这个文件复制到 Tomcat 的 webapps 目录,启动 Tomcat,访问的时候路径需要加 ContextPath:
code复制http://localhost:8080/cosmetic-mall/index
如果打的是 jar 包,直接把 cosmetic-mall.war 当 jar 跑:
bash复制java -jar cosmetic-mall.war
访问路径就不带项目名,直接:
code复制http://localhost:8080/index
7.2 本地运行最常见的 5 个问题
| 现象 | 大概率原因 | 解决办法 |
|---|---|---|
| JSP 页面 404 | 没加 tomcat-embed-jasper 依赖,或 packaging 不是 war |
检查 pom.xml,加依赖并改为 war |
| 页面能打开但 CSS/JS 全丢 | JSP 里用了绝对路径 /static/...,没有加 ContextPath |
全部改成 ${ctx}/static/... 写法 |
| 数据库连接失败 | MySQL 8 驱动类写错,或连接串没带时区 | 驱动类写 com.mysql.cj.jdbc.Driver,连接串加 serverTimezone=Asia/Shanghai |
| 中文乱码 | 页面编码和数据库编码不一致 | MySQL 表统一 utf8mb4,JSP 顶部加 pageEncoding="UTF-8",连接串加 characterEncoding=utf8 |
Invalid bound statement |
Mapper 接口和 XML 的 namespace 不匹配 | 检查 XML 的 namespace 是否等于接口全限定名,方法 id 是否等于接口方法名 |
最后一个问题在 MyBatis 项目里出现频率极高,几乎每个新手都会碰到。Invalid bound statement 的报错信息会直接告诉你是哪个方法没绑定成功,点开报错日志,十有八九是 Mapper XML 的 namespace 写成了 com.cosmetic.mall.mapper.ProductMapper,但接口实际包名是 com.cosmetic.mall.dao.ProductMapper,或者 XML 里的方法 id 和接口方法名不一致。这类问题通常不是逻辑问题,而是命名问题,所以我的习惯是:接口文件和 XML 文件名保持一致,放同一个包路径下,能避免大部分手误。
7.3 后续还能往哪扩展
这套商城系统已经具备一个完整电商骨架,往后的扩展方向非常清晰。我按优先级列三个方向:
方向一是后台管理功能增强。管理员目前能管理分类、商品和订单,但缺了数据统计报表。可以加一个简单的首页数据面板:今日订单数、总销售额、库存预警列表,SQL 都是聚合查询,学习价值很高。
方向二是支付功能真实化。可以尝试接入沙箱环境,把模拟支付替换成真实的第三方支付下单流程,重点学习回调验签、异步通知处理、掉单补偿。
方向三是管理端从 JSP 改成 Vue 独立部署。这个更适合你已经有前后端分离基础之后再去动,思路是后端仍然使用 SpringBoot,只写 REST API,JSP 页面保留给前台用户端,后台管理用 Vue 写单页应用,通过 Axios 调接口,鉴权方式从 Session 改成 Token。
8. 写在最后:关于这段开发经历,我的一点体会
整个项目从零到一这次做下来,我最大的感触是:这类“老技术栈”项目,真正考验人的不是某一个单独的技术点,而是整体串联能力。配置一个分页插件、写一个事务注解、建几张表,单独拎出来都不难,但要把 JSP 的页面渲染和 SpringBoot 的后端契约、Mapper 的 SQL 和 MySQL 的索引设计、订单状态和购物车清除逻辑全部对上,才能算真正跑通一个系统。
如果你也打算照着这个思路自己搭一套,我建议别贪多,先把最核心的“注册登录 → 浏览商品 → 加购物车 → 下订单 → 模拟支付 → 后台发货 → 确认收货”这条链路完整跑通,再回头补轮播图管理、商品评论、个人中心这些东西。一开始就想着把淘宝所有功能都塞进去,往往是一个模块都做不深。
另外一个很实用的小技巧:开发阶段每次都从首页手动点击操作太费时间,我建议用 IDEA 的 HTTP Client 或者 Postman 把注册、登录、加购、下单这几个关键流程的请求保存下来,每次改完代码跑一遍接口集合,能帮你快速发现某个改动是不是把流程弄断了。这其实就是最原始的接口自动化回归测试意识,成本很低,收获很大。
