做服装商城项目,最怕的一件事,就是在拿到需求之后直接打开IDEA开始建表。尤其当项目名是“基于Spring Boot的网上服装商城”这种看起来非常标准的教学项目时,很多同学的思路会被“Spring Boot”四个字带跑偏,整天纠结注解和配置,却忽略了商城本质上是“电商系统”这件事。我前后带人做过好几版类似的商城项目,今天这篇就从项目拆解、技术选型、数据库设计、核心接口、并发处理和部署联调这几个维度,完整地把这个项目的底交代清楚。无论你是拿它做毕业设计、简历项目,还是想真正入门企业级开发,这套思路都能直接用。
1. 项目拆解:别急着写代码,先把服装商城当成一个“订单处理系统”
很多人一听到“网上服装商城”,第一反应就是“做个好看的商品列表页、详情页、购物车,然后能下单”。这个理解没有错,但它只看到了C端用户的那一面。真正完整的服装商城,至少需要考虑三种角色:普通用户、运营管理员和系统本身。系统虽然不算角色,但它需要处理订单状态流转、库存扣减、支付回调,这些才是项目真正的难点和亮点。
1.1 用户视角和管理员视角是完全不同的两套需求
从用户端看,典型流程是注册登录、浏览商品、搜索筛选、加入购物车、提交订单、在线支付、查看订单状态。从这个流程能拆出的功能有:用户模块(注册、登录、个人信息、收货地址管理)、商品模块(分类、列表、详情、搜索、尺码/颜色筛选)、购物车模块(增删改查、选中统计)、订单模块(创建订单、订单列表、订单详情、取消订单、确认收货)。
从管理员端看,诉求完全不一样:商品分类管理、商品上下架、库存管理、用户管理、订单管理(发货、查看详情)、销售统计。这里有个很关键的认知:管理端不是“C端页面的数据管理版”,而是一套独立的业务后台。比如商品上架,管理员需要配置SKU(尺码、颜色、库存)、设置轮播图、填写详细描述,这些字段在C端可能只是展示,但在后台是完整的数据录入流程。
1.2 功能模块划分要跟着“业务主线”走
我习惯把商城设计成六条业务线:
- 用户线:注册、登录、地址管理、个人信息。
- 商品线:分类、商品、SKU、上下架。
- 购物车线:加购、改数量、删除、清空。
- 订单线:下单、订单状态、取消、确认收货。
- 支付线:创建支付单、回调处理、订单状态联动。
- 管理线:后台首页统计、商品管理、订单处理。
把每条线单独列出来之后,项目结构就很清晰了,后面建包、建表、写接口都有依据,不会出现“所有Service堆在一起”的情况。
1.3 为什么说这个项目是Spring Boot入门的黄金项目
Spring Boot本身只是一个快速开发框架,它不解决业务问题。服装商城恰好覆盖了Web开发中最常见的能力:CRUD、分页、文件上传、登录鉴权、拦截器、事务、缓存、接口联调、部署上线。同时它又比“图书管理系统”多了一层业务深度,比如库存并发、订单状态机,这些都能在面试的时候成为你和别人拉开差距的谈资。换句话说,这个项目只要不是简单把表建完然后写一堆CRUD,它的含金量足以支撑一个初级Java开发岗位的项目经验。
需要模型API调用? 免费领10W Token,多模型网关一键接入 Claude、DeepSeek 等主流模型。
2. 技术选型:Spring Boot版本不是越高越好,合适才最重要
技术选型这个环节,很多教程直接跳过,但它是整个项目最值得花时间的部分。我在查资料的时候看到“springboot版本太高”“springboot jdk1.8打包到docker desktop”这些热搜词频繁出现,说明大量同学在第一步就被版本问题劝退了。
2.1 第一原则:先定JDK,再定Spring Boot版本
很多人在IDEA里新建Spring项目时,默认选了Spring Boot 3.x,然后发现项目用的JDK还是1.8,一堆依赖拉不下来,启动直接报错。Spring Boot 3.0以上强制要求JDK 17,如果团队或教程还是以JDK 8为主,那么最稳妥的选择是Spring Boot 2.7.18。它是我个人目前最推荐的版本,原因很实际:2.7.18是2.x系列最后一个维护版本,兼容JDK 8,同时支持到了JDK 21(当然最推荐还是JDK 8或11),市面上大部分教程、依赖、中间件的适配文档都是以2.x为准。
我这里给出一个直接能用的版本组合:
| 组件 | 版本建议 | 说明 |
|---|---|---|
| JDK | 1.8(或11) | 稳定、教程多、兼容性好 |
| Spring Boot | 2.7.18 | 2.x最终维护版本,生态成熟 |
| MyBatis Plus | 3.5.3.x | 配合Spring Boot 2.x最稳 |
| MySQL | 5.7或8.0 | 5.7轻量,8.0功能更全 |
| Redis | 6.x/7.x | 用于缓存和验证码等场景 |
| Vue | 2.7或3.2 | 看前端熟练度,2.7更容易上手 |
| Node.js | 16+ | 编译前端项目使用 |
2.2 持久层选型:MyBatis Plus比JPA更适合这种项目
这个项目涉及大量列表查询、分页、多条件筛选,MyBatis Plus的优势非常明显。它内置了BaseMapper,基础的增删改查不用写SQL,复杂查询可以通过LambdaQueryWrapper完成,避免了XML里堆一大堆动态SQL的问题。相比Spring Data JPA,MyBatis Plus更贴近国内开发者的习惯,出现问题也更容易通过SQL日志定位。你如果以后进企业做电商类项目,大概率也是MyBatis这一套体系,所以提前用没有坏处。
2.3 认证方案、缓存和前端框架怎么搭配
登录认证我自己习惯用JWT(JSON Web Token)配合Spring Security。但这里要说明一下,如果只是为了做一个毕业设计或课程项目,直接用拦截器加JWT足矣,完全没必要把Spring Security那套过滤器链全部拉进来,配置成本比业务代码还高。我的建议是:先写一个JwtInterceptor拦截器,校验Token,放行登录接口和商品查询接口,其余接口校验身份,这个方案简单、可控、可解释。
Redis在这个项目里主要用来做两件事:一是存验证码(登录、注册时的短信或邮件验证码),二是缓存首页的热门商品数据。如果觉得引入Redis太重,可以用Spring Cache加Caffeine,但商城项目的简历上写Redis会更吃香。前端部分,Vue加Element UI是经典组合,结构清晰,组件丰富,适合快速搭后台管理页面。
3. 数据库设计:一张商品表撑不起一个商城
数据库是整个项目的灵魂,表设计错了,后面写再多代码都是补窟窿。我见过很多同学把商品表设计成“一个商品一个字段存尺码颜色”,最后查询和下单全都变成了噩梦。商城的核心概念是“商品”和“SKU”分离。商品是展示层概念,SKU是交易层概念。
3.1 核心表结构拆解
我通常把表拆成六个核心部分:
- 用户相关:user、user_address
- 商品相关:category、product、product_sku、product_image
- 购物车相关:cart_item
- 订单相关:orders、order_item
- 支付相关:payment_record
- 管理相关:admin_user、role、permission(如果做权限可以加)
其中user和admin_user分开建表,不要混在一起。普通用户和管理员的字段、权限逻辑完全不同,强行合并只会让表结构变得不伦不类。
3.2 商品、SKU、图片三张表的经典设计
以服装品类为例,一件T恤会有颜色(黑、白)、尺码(M、L、XL)的不同组合,每个组合的库存和价格可能都不一样。商品表存的是公共信息:标题、描述、主图、分类ID、上下架状态。SKU表存的是每个具体变体的信息:
sql复制CREATE TABLE product_sku (
id BIGINT PRIMARY KEY AUTO_INCREMENT,
product_id BIGINT NOT NULL COMMENT '商品ID',
sku_code VARCHAR(64) NOT NULL COMMENT 'SKU编码',
color VARCHAR(32) COMMENT '颜色',
size VARCHAR(32) COMMENT '尺码',
price DECIMAL(10, 2) NOT NULL COMMENT '售价',
stock INT NOT NULL DEFAULT 0 COMMENT '库存',
version INT NOT NULL DEFAULT 0 COMMENT '乐观锁版本号',
status TINYINT NOT NULL DEFAULT 1 COMMENT '1启用 0停用',
create_time DATETIME,
update_time DATETIME
) COMMENT '商品SKU表';
注意这里的version字段,它是后面解决库存超卖问题的关键。商品图片表需要支持一个商品多张图,并且区分主图、详情图和轮播图,可以在product_image表里用image_type字段区分。
3.3 订单表为什么要冗余商品快照
这是很多新手理解不了的点:为什么订单明细里已经有了product_id,还要存一份商品名称、价格、图片?原因很简单——商品信息是可变数据。商家改价、改名、甚至删除商品,都已经下单的用户不能跟着变。所以订单详情里必须把下单那一刻的商品名称、单价、图片、规格信息复制一份存起来,这叫“快照”。你买的时候下单页显示300元,明天商品改成350元,你的订单详情仍然应该是300元。这个设计看似不起眼,却是电商系统的一个基本准则。
4. 项目搭建与核心接口实现:从空目录到跑起来
这一部分我直接按实际操作顺序来讲,包括目录结构、配置文件、登录鉴权、分页查询几个核心环节。只要照着做,基本能在一个小时内把项目骨架跑起来。
4.1 推荐的后端目录结构
我用的是经典分层结构,包名建议用com.example.mall:
code复制com.example.mall
├── config # 配置类,如跨域、拦截器
├── controller # 接口层
├── service # 业务层
├── mapper # 数据访问层
├── entity # 数据库实体
├── dto # 前端入参、出参对象
├── common # 统一返回体、全局异常
├── utils # JWT、日期等工具类
└── interceptor # 登录拦截器
这里有个建议:controller里的方法不要直接接收实体类(entity),而是用dto接收请求参数。比如用户注册接口应该接收RegisterDTO,而不是直接接收User实体。这样做的目的是防止前端传入额外字段覆盖掉不该覆盖的数据,比如注册接口里不允许传入role字段,避免权限越权。
4.2 application.yml配置的关键点
Spring Boot的配置文件虽然是常规操作,但有几个细节特别容易踩坑。首先是时区,连接MySQL时url后面必须加serverTimezone=Asia/Shanghai,否则数据库时间和你本地时间会差8个小时。其次是MyBatis Plus的驼峰映射要打开,不然数据库的create_time映射不到实体的createTime字段。下面是一份可以直接改着用的配置:
yaml复制server:
port: 8080
servlet:
context-path: /api
spring:
datasource:
url: jdbc:mysql://localhost:3306/mall?useUnicode=true&characterEncoding=utf8&useSSL=false&serverTimezone=Asia/Shanghai
username: root
password: root
driver-class-name: com.mysql.cj.jdbc.Driver
redis:
host: localhost
port: 6379
servlet:
multipart:
max-file-size: 10MB
max-request-size: 20MB
mybatis-plus:
configuration:
map-underscore-to-camel-case: true
log-impl: org.apache.ibatis.logging.stdout.StdOutImpl
global-config:
db-config:
logic-delete-field: deleted
logic-delete-value: 1
logic-not-delete-value: 0
配置中的logic-delete是逻辑删除配置,对于商城这种系统,商品、订单不建议物理删除,使用逻辑删除字段更安全,也能保住数据的完整统计口径。
4.3 商品分页查询接口的实现
商品列表是商城最核心的查询接口,需要支持分类筛选、价格排序、关键词搜索。使用MyBatis Plus可以这样写:
java复制@Service
public class ProductServiceImpl implements ProductService {
@Autowired
private ProductMapper productMapper;
@Override
public PageResult<ProductVO> pageQuery(ProductQueryDTO queryDTO) {
Page<Product> page = new Page<>(queryDTO.getPageNum(), queryDTO.getPageSize());
LambdaQueryWrapper<Product> wrapper = new LambdaQueryWrapper<>();
wrapper.eq(Objects.nonNull(queryDTO.getCategoryId()), Product::getCategoryId, queryDTO.getCategoryId())
.and(StringUtils.hasText(queryDTO.getKeyword()), w ->
w.like(Product::getTitle, queryDTO.getKeyword())
.or().like(Product::getSubTitle, queryDTO.getKeyword()))
.eq(Product::getStatus, 1)
.orderByDesc("is_hot".equals(queryDTO.getSort()) ? Product::getIsHot : Product::getCreateTime);
Page<Product> result = productMapper.selectPage(page, wrapper);
// 这里需要再查询每个商品的SKU列表和首图,组装成VO返回
return PageResult.toPage(result.convert(this::toVO));
}
}
这里的关键在于“只查询商品主表是远远不够的”。商品列表页需要展示价格区间和首图,所以要么在SQL里关联查询,要么在内存里批量查SKU。我推荐的做法是:先用商品ID列表批量查询SKU,再按productId分组,组装到VO中,避免N+1查询。这样对数据库的压力小很多,响应速度也快。
4.4 JWT登录认证的完整链路
用户登录成功之后,生成Token返回给前端。前端在后续请求中把Token放在请求头的Authorization里。后端用拦截器拦截除登录、注册、商品查询以外的所有请求。
java复制@Component
public class JwtInterceptor implements HandlerInterceptor {
@Autowired
private JwtUtils jwtUtils;
@Override
public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) throws Exception {
if (!(handler instanceof HandlerMethod)) {
return true;
}
String token = request.getHeader("Authorization");
if (token == null || !token.startsWith("Bearer ")) {
throw new BusinessException(401, "未登录或登录已过期");
}
Long userId = jwtUtils.parseToken(token.replace("Bearer ", ""));
request.setAttribute("userId", userId);
return true;
}
}
这里有几个容易忽略的细节。第一,拦截器放行时记得放行OPTIONS预检请求,否则跨域请求会失败。第二,业务代码中要获取当前登录用户时,不要反复解析Token,而是像上面这样在拦截器里把userId放到request的attribute里,后面可以直接用((ServletRequestAttributes) RequestContextHolder.getRequestAttributes()).getRequest().getAttribute("userId")获取,性能和代码整洁度都好很多。
5. 订单与库存的并发问题:扣库存不能简单“先查再减”
商城项目里最值得拿出来讲的技术点就是订单创建时的并发控制。假设一件商品库存只剩1件,两个用户同时下单,如果代码写成“查到库存为1,然后减1”,很可能两人都看到库存有货,结果卖出去2件,这就是超卖。解决超卖问题,核心原则是:让“扣库存”这个操作在数据库层面原子化,并且在业务上控制并发。
5.1 方案一:乐观锁扣库存
我推荐先使用乐观锁。在product_sku表里增加version字段,更新库存时带上版本号判断,如果版本号不匹配则更新失败:
java复制int rows = productSkuMapper.updateStock(skuId, count, version);
if (rows == 0) {
throw new BusinessException("库存不足或商品已更新,请重试");
}
对应的更新SQL:
sql复制UPDATE product_sku
SET stock = stock - #{count},
version = version + 1
WHERE id = #{skuId}
AND stock >= #{count}
AND version = #{version}
这条SQL同时做了三件事:扣减库存、判断库存充足、校验版本号。受影响行数为1说明扣减成功,为0说明失败。这里不需要先查库存再判断,直接把条件写到where里就好。
5.2 方案二:在事务中执行,但别锁表
如果系统并发量很大,乐观锁的重试机制可能带来大量请求失败,这时可以换悲观锁。但悲观锁不是简单的select for update锁全表,而是只锁那一行SKU记录,同时一定要把事务控制到最短时间。我在这个项目里更推荐“乐观锁加重试”的轻量方案,因为商城项目的并发量在毕业设计和中小公司场景下基本够用,而且代码容易解释清楚,面试官也容易听懂。
5.3 支付回调要保证幂等
订单创建之后,前端跳到模拟支付页面,支付成功后支付平台会回调后端接口。这个回调接口必须处理重复通知的情况,因为支付平台为了保证通知送达,会多次回调同一个地址。处理方式是在payment_record表里增加“支付状态”字段,每次回调先查支付单状态,如果已经是“已支付”,直接返回成功,不再重复更新订单状态。
这一步特别关键,如果不做幂等,重复回调可能会导致订单重复发货、库存重复扣减,数据直接乱掉。
6. 前后端联调与部署:把“能跑的项目”变成“能用的项目”
后台接口写完之后,真正费时间的是和前端联调以及部署上线。很多同学的Spring Boot项目在本地跑得好好的,部署上线后问题一大堆,大多数问题出在跨域、静态资源路径和Docker部署配置上。
6.1 统一返回体和跨域配置
前后端分离项目一定要有统一返回结构。我习惯的格式是:
json复制{
"code": 200,
"message": "success",
"data": { }
}
异常则由全局异常处理器统一处理,并且保证HTTP状态码和业务code分离。业务code主要用于前端判断业务逻辑,HTTP状态码用于网络层判断。跨域问题我用一个CorsConfig解决:
java复制@Configuration
public class CorsConfig implements WebMvcConfigurer {
@Override
public void addCorsMappings(CorsRegistry registry) {
registry.addMapping("/**")
.allowedOriginPatterns("*")
.allowedMethods("GET", "POST", "PUT", "DELETE", "OPTIONS")
.allowedHeaders("*")
.allowCredentials(true)
.maxAge(3600);
}
}
注意allowCredentials(true)时,allowedOrigins不能使用“*”,必须用allowedOriginPatterns(Spring Boot 2.4以后的方式),否则前端发请求时控制台会报跨域错误。
6.2 静态资源上传与访问映射
服装商城涉及商品图片上传,上传后的图片不能直接放在项目源码目录里,因为打包成jar后这个目录就不可写了,而且重启服务文件会丢失。正确做法是把图片上传到服务器/本机的一个固定目录,比如/opt/mall/upload,然后在配置类中做静态资源映射:
java复制registry.addResourceHandler("/upload/**")
.addResourceLocations("file:/opt/mall/upload/");
这样前端访问http://localhost:8080/upload/xxx.jpg就能直接看到图片。这个细节在实际部署时几乎一定会遇到,提前做了就能少走弯路。
6.3 Docker Desktop部署Spring Boot项目
部署这块我强烈建议用Docker。哪怕只是本地学习,也能提前感受一下生产环境的部署方式。Spring Boot项目用Maven打包后生成jar,然后写一个Dockerfile:
dockerfile复制FROM openjdk:8-jdk-alpine
WORKDIR /app
COPY target/mall.jar app.jar
EXPOSE 8080
ENTRYPOINT ["java", "-jar", "app.jar"]
执行下面两条命令可以一键部署:
bash复制mvn clean package -DskipTests
docker build -t mall-server .
docker run -d --name mall-server -p 8080:8080 mall-server
如果使用Docker Desktop,注意Windows环境下文件挂载路径和端口映射要好,MySQL和Redis容器可以用docker-compose一次性启动,然后在Spring Boot的配置文件中把localhost换成容器服务的名称,这样容器间可以通过服务名互相访问。
7. 避坑清单:这些坑我基本都踩过一遍
最后整理一下我做这个项目过程中遇到的高频问题,大部分和框架本身无关,而是项目配置或代码习惯导致的问题。按频率排序如下:
| 问题现象 | 根本原因 | 解决方案 |
|---|---|---|
| 启动报错Invalid bound statement | Mapper接口与XML文件不匹配,或XML没在target里 | 检查namespace、方法名,确保XML放在resources下 |
| 数据库中文乱码 | 连接URL没指定characterEncoding=utf8 | 在url中追加characterEncoding=utf8 |
| 前端传时间格式不对 | 前后端时区不一致 | 在application.yml里配置jackson时区 |
| 事务不生效 | 方法内部自调用,代理未生效 | 把事务方法拆到另一个Service类里调用 |
| 上传文件访问404 | 没有配置资源映射 | 添加WebMvcConfigurer的addResourceHandlers |
| JWT过滤器拦截了登录接口 | 拦截器地址配置错误 | 在注册拦截器时用excludePathPatterns显式放行 |
| Docker容器连不上MySQL | 数据库地址写成了localhost | 容器之间用服务名,宿主机用localhost |
这里单独把“事务不生效”拎出来说一下。很多人在OrderServiceImpl里写了两个方法,一个方法调另一个方法,事务注解加在里面那个方法上,结果发现操作没有回滚。原因是Spring的事务是基于AOP代理实现的,只有通过代理对象调用方法时事务才生效,类内部this方法调用不会经过代理。解决办法是注入自己的代理对象,或者把事务方法放到独立的Service类中。
另一个值得注意的坑是MyBatis Plus的逻辑删除和唯一索引冲突。比如用户表里手机号字段设了唯一索引,但用户注销时只做了逻辑删除,下次再注册同一个手机号会发生主键冲突。解决思路有两个:一是逻辑删除字段改成deleted值存当前时间戳,保证唯一性;二是在注册时先检查是否有逻辑删除的数据,如果有就恢复数据而不是直接插入新记录。
最后再分享一个我个人的习惯:每次写完一个接口,不要急着自测完就过,先看一眼控制台打印的数据库SQL,确认没有“select *”全表扫描,没有N+1查询,再提交代码。这个小习惯帮我挡下了不少线上问题。商城这个项目最适合用来练习从需求到上线全流程的思考方式,做透一遍,Spring Boot的熟练度会有肉眼可见的提升。
