最近在整理自己做过的一个小型电商类项目,宠物用品商城系统(PetShop),后端基于Spring Boot 2.7 + JDK 8 + MyBatis Plus + MySQL,完整走了一遍从需求分析、数据库设计、接口开发到Docker部署的闭环。这篇文章把其中的关键设计思路、核心代码,以及开发过程中踩过的坑都整理出来,希望能帮到正在做毕业设计、或者刚接触Spring Boot想上手完整项目的朋友。
这个系统不算复杂,但五脏俱全。前台有宠物用品浏览、关键字搜索、购物车、下单、模拟支付、订单管理;后台有商品维护、类目管理、库存调整、订单处理。用到的技术点也是当前Java后端开发的高频内容:RESTful接口、统一返回结构、JWT登录认证、拦截器、事务管理、分页插件、参数校验、全局异常处理,最后还做了Docker容器化部署。如果你是初学者,想找一条完整的学习路径,或者打算把这类项目写进简历,这篇内容都值得看完。
1. 项目概述与技术选型思路
1.1 这个系统解决什么问题
宠物用品系统的核心业务场景并不复杂:宠物主人在线上购买猫粮、狗粮、宠物玩具、洗护用品、保健药品等。系统要解决的核心问题有两个,一个是给普通用户提供一套流畅的“逛-选-买-查”体验,另一个是给运营人员提供一套好用的商品和订单管理后台。
具体到功能,用户端需要支持账号注册登录、商品分类浏览、关键字搜索、商品详情查看、加入购物车、结算下单、模拟支付、查看个人订单;管理端需要支持商品新增编辑、上下架、库存调整、类目管理、订单发货和状态处理。整体业务链路看起来简单,但每一步都牵扯到数据库设计、接口规划和状态流转,这也是这类项目的价值所在。
1.2 技术栈选型的核心原则
选型这件事,我一直坚持“能稳定落地、社区资料多、团队熟悉”优先。这个项目后端选择了Spring Boot,核心原因就是它的自动配置机制能大幅减少繁琐的XML配置,让开发者把精力集中在业务代码上。
Spring Boot的自动装配原理,简单说就是启动类上的@SpringBootApplication注解,实际上是一个组合注解,包含了@EnableAutoConfiguration。Spring Boot启动时,会通过spring.factories或AutoConfiguration.imports文件加载所有候选的自动配置类,再通过@Conditional系列条件注解按需装配。比如你引入了spring-boot-starter-web且classpath下有ServletWebServerFactory相关类,它就会自动帮你在内嵌Tomcat中配置好DispatcherServlet。这个机制很像你去一家自助餐厅,想吃什么直接从自助台拿,不需要自己从头洗菜做饭。
持久层框架我选了MyBatis Plus,原因很简单:单表CRUD的SQL模板代码量大且没有太多技术含量,MyBatis Plus的BaseMapper可以省掉这部分工作,LambdaQueryWrapper又能优雅地解决动态SQL拼接问题。数据库用的是MySQL 8.0,InnoDB引擎、utf8mb4字符集,这两个选择在2024年基本是标配,文档多、坑少,出问题随便一搜就有答案。
1.3 整体功能模块划分
模块划分上,我没有做微服务,而是采用经典的单体应用加前后端分离结构。单体结构在这个规模下是性价比最高的选择:部署简单、排查问题链路短、事务控制也容易。模块上分成了用户模块、商品模块、购物车模块、订单模块、后台管理模块五块。
用户模块负责注册、登录、JWT签发和用户信息维护;商品模块负责类目树、商品CRUD、商品上下架、库存管理;购物车模块负责加购、修改数量、删除、清空和购物车列表;订单模块负责创建订单、订单明细、支付回调模拟、取消订单和订单状态流转;后台管理模块复用商品和订单的能力,额外加上数据统计和简单的权限控制。数据库表最终设计为六张:用户表、商品表、类目表、购物车表、订单表、订单明细表。
需要模型API调用? 免费领10W Token,多模型网关一键接入 Claude、DeepSeek 等主流模型。
2. 后端核心模块设计与实现
2.1 用户模块:从注册到JWT认证
用户模块的重点不在功能复杂,而在于登录态的安全处理。注册接口需要对用户名做唯一校验,密码不能明文存储。我用的BCrypt加密,每注册一个用户都会生成随机的盐值,即使两个用户密码相同,密文也不同。Spring Security的BCryptPasswordEncoder可以直接拿来用,不要自己写MD5加盐,一方面安全性不如BCrypt,另一方面面试官看到BCrypt也会觉得你更专业。
登录流程是这样的:用户传入用户名和密码,后端查库、matches()校验密码,成功后生成一个有效期为24小时的JWT返回给前端。JWT由三部分组成:Header、Payload、Signature。其中Signature是用服务端密钥对Header和Payload做签名,这样客户端无法伪造Token。实战中我建议过期时间用expire: 7200000这类毫秒值配置在application.yml里,方便调整。
JWT签发用io.jsonwebtoken:jjwt,解析和验签放在一个拦截器里。拦截器从请求头的Authorization字段取出Bearer token,解析成功放行,失败则返回401。这里有个很容易踩的坑:如果你用Swagger调试接口,不把Swagger相关路径放行,登录接口都看不到文档。需要在拦截器配置里放行/swagger-ui/**、/v3/api-docs/**、/doc.html等路径。
2.2 商品与分类模块:数据库设计与接口实现
商品模块是核心中的核心,数据库设计直接决定后面接口好不好写。商品表主表字段包括:id、category_id、name、subtitle、main_image、sub_images、detail、price、stock、status、sales、create_time、update_time、is_deleted。其中is_deleted是逻辑删除标志,别用物理删除,否则用户浏览记录、订单明细里的商品信息会跟着消失,后台也查不到历史数据。
分类表我设计了父子结构:id、parent_id、name、sort_order。一级类目是猫粮、狗粮、玩具、洗护,二级类目再往下细分,比如“猫粮-幼猫粮”。查询某个类目下的商品时,要把它的所有子类目id递归查出来,再去商品表里做IN查询。这种设计比逗号分隔分类字段的方式规范得多,也方便以后扩展。
商品列表的查询条件通常包括:类目id、关键字(搜名称或副标题)、价格区间、上架状态、排序字段(默认综合、价格升序、价格降序、销量优先)。这个动态条件在MyBatis Plus里用LambdaQueryWrapper非常合适。核心的逻辑是调用Service层的lambdaQuery()方法,第一个条件用eq,后面的条件用and拼接,通过StringUtils.hasText()判断参数是否为空,避免拼出无效SQL。分页直接用MyBatis Plus内置的分页插件PaginationInnerInterceptor,在配置类里注册,分页参数用Page<T>对象传入。
2.3 购物车与订单模块:状态机设计
购物车表设计相对简单,核心字段是user_id、product_id、quantity,加上唯一索引uk_user_product(user_id, product_id)。加购接口的判断逻辑是:先查这条记录是否存在,存在就做数量累加,不存在就插入,注意要放在事务里并处理好并发。
订单模块是整个系统的技术重点。订单主表的字段包括:id、order_no、user_id、total_price、status、pay_type、pay_time、consignee、phone、address、create_time、update_time。订单明细表记录每个商品的下单快照:product_id、product_name、product_image、current_price、quantity、total_price。这里有个设计细节:订单明细里必须冗余商品名称、价格和图片,因为商品信息会变,但订单属于历史事实,不能跟着变。
订单状态我定义成有序状态机:待支付(10)、已支付(20)、已发货(30)、已完成(40)、已取消(50)。状态流转只能按合法方向走:待支付可以取消、待支付可以变已支付、已支付可以发货、已发货可以完成。接口层要严格校验当前状态是否允许目标状态,否则会出现订单状态乱跳的问题。
2.4 核心代码示例:商品模块的分页查询
商品列表分页是最典型的查询场景,直接贴核心代码。Controller接收参数,Service做真正的查询逻辑,Mapper层由MyBatis Plus的BaseMapper搞定。
java复制@ApiOperation("分页查询商品列表")
@PostMapping("/product/list")
public Result<Page<ProductVO>> productList(@RequestBody @Valid ProductQueryDTO queryDTO) {
Page<Product> page = productService.pageQuery(queryDTO);
return Result.success(page);
}
Service实现类里这样处理:
java复制public Page<Product> pageQuery(ProductQueryDTO dto) {
Page<Product> page = new Page<>(dto.getPageNum(), dto.getPageSize());
LambdaQueryWrapper<Product> wrapper = new LambdaQueryWrapper<>();
wrapper.eq(StringUtils.hasText(dto.getCategoryId()), Product::getCategoryId, dto.getCategoryId())
.and(StringUtils.hasText(dto.getKeyword()), w -> w
.like(Product::getName, dto.getKeyword())
.or()
.like(Product::getSubtitle, dto.getKeyword()))
.between(dto.getMinPrice() != null && dto.getMaxPrice() != null,
Product::getPrice, dto.getMinPrice(), dto.getMaxPrice())
.eq(Product::getStatus, 1)
.orderByDesc(Product::getSales)
.orderByAsc(Product::getCreateTime);
return productMapper.selectPage(page, wrapper);
}
这段代码里有个值得注意的地方:and方法内部用了w -> w.like(...).or().like(...),这是一个Lambda子查询,生成的SQL外层有括号,避免了or条件把category_id = ?变成“或者”,这是新手最容易写错的动态条件拼接。
3. 关键业务链路实操:从商品浏览到下单支付
3.1 下单流程的完整链路分析
一次完整的下单流程是这样的:用户在商品详情页点击“立即购买”或从购物车进入结算页,前端会提交商品id列表和数量,后端先检查商品是否存在并处于上架状态,再计算总价。这里注意,价格必须以后端查到的数据库实时价格为准,千万不能信任前端传过来的price字段,否则用户可以改价格下单。
校验通过后,生成订单号和订单主记录,状态为待支付。接着把每个商品快照写入订单明细表,然后扣减对应商品库存。这一步扣库存的SQL很有讲究,不能先查库存再判断再更新,并发场景下会超卖。正确写法是直接用数据库的原子操作:
java复制@Update("UPDATE product SET stock = stock - #{quantity} WHERE id = #{productId} AND stock >= #{quantity}")
int decreaseStock(@Param("productId") Long productId, @Param("quantity") Integer quantity);
返回值为1代表扣减成功,返回0说明库存不足或商品不存在。扣减成功后继续生成支付信息,这个模拟支付系统里直接打一个支付接口,传入订单号、金额和支付方式,返回支付成功。最后把订单状态更新为已支付,整个购物流程完成。
3.2 库存扣减与幂等性设计
刚才的扣库存SQL已经解决了超卖问题,但还有另一层隐患:重复下单。用户快速点击“提交订单”两次、或者支付平台回调重复通知,都可能导致库存被扣两次或订单被重复创建。解决办法是幂等性设计。
订单号我用snowflake算法生成,或者更简单地用yyyyMMddHHmmss + 4位随机数再拼上用户id,保证全局唯一。下单接口再加一层校验:如果同一用户、同一时间段存在相同商品组合的待支付订单,就复用旧订单返回给前端,而不是新建一份。这里可以在业务层加一个Redis分布式锁,或者在数据库表里加一个唯一索引兜底。
支付回调同样要幂等。模拟支付系统回调时,会带上订单号和支付流水号。后端处理逻辑里,先根据订单号查询当前状态,如果已经是“已支付”就直接返回成功,不再做任何更新。严谨一点的做法是增加支付回调流水表,记录每次回调的请求摘要,重复回调被过滤掉。
3.3 事务管理与状态流转的代码落地
下单操作涉及多张表操作,必须加上事务。我在Service层关键方法上标注@Transactional(rollbackFor = Exception.class)。这里有一个经验:事务注解不要只写在Controller上,Controller的事务粒度太大且不好控制;也不要忽略rollbackFor,因为这个注解默认只对RuntimeException回滚,而业务代码里很多自定义异常不继承RuntimeException的话,可能不会触发回滚。
事务方法内部,我用了一个枚举类OrderStatusEnum来管理订单状态,状态判断和流转都通过枚举方法实现。实际开发者写了这样的枚举,后续扩展支付方式、售后流程时就不容易出错。
java复制public enum OrderStatusEnum {
WAIT_PAY(10, "待支付"),
PAID(20, "已支付"),
SHIPPED(30, "已发货"),
COMPLETED(40, "已完成"),
CANCELED(50, "已取消");
private final Integer code;
private final String desc;
}
订单模块有个隐藏的陷阱:订单创建成功后,如果最后一步更新状态失败,事务会回滚前面所有操作,包括库存扣减、订单明细插入。所以要确保所有数据库操作都在同一个事务方法内完成,并且不要因为捕获异常后不抛出而让事务误认为“一切成功”。
3.4 统一返回结构与全局异常处理
项目里的每个Controller我们不直接返回实体类,而是统一包一层Result<T>,里面包含code、message、data三个字段。这样做的好处是前端只需要约定一种解析格式,不用每个接口单独适配。同时配合一个全局异常处理器@RestControllerAdvice,把参数校验异常、业务异常、系统异常分别映射成对应的提示信息,这样接口报错时用户看到的是友好文案,而不是一堆堆栈。
在实际开发中,我发现很多人会忽略对MethodArgumentNotValidException的处理。Spring Boot的@Validated在参数校验失败时抛出的是这个异常,如果不捕获,默认返回的是400加一堆英文错误信息。我在全局异常处理器里做了拦截,把每个字段的校验错误消息取出来拼成一条中文提示返回,前端弹窗直接展示即可。
4. 开发过程中的常见问题与排查实录
4.1 Spring Boot版本与JDK兼容性
这个项目最早开发时用的Spring Boot 2.7,JDK 8,稳定运行没有任何问题。如果你用Spring Boot 3.x,就必须换成JDK 17及以上,否则启动直接报错。更需要注意的是包名变化:Spring Boot 3.x把javax.servlet改成了jakarta.servlet,javax.validation变成了jakarta.validation。如果从旧项目升级过来,代码里所有import javax.*都要改,这个工作量很尴尬。
很多人在IDEA里编译时报“源发行版 17 需要目标发行版 17”,本质是项目的Java Compiler级别和当前JDK不一致。建议在pom.xml里明确指定java.version属性,然后在IDEA的Project Structure里统一Project SDK、Modules Language Level和Settings里的Java Compiler。另外,Spring Boot版本不要盲目追新,生产环境稳定优先,2.7+JDK 8的组合在我接触的大量中小企业项目里仍然是主流。
4.2 循环依赖问题的排查与解决
开发过程中有好几次遇到循环依赖:OrderService依赖ProductService,ProductService的某个方法又反向调用了OrderService。Spring Boot 2.6之后默认禁止循环依赖,也就是项目启动时直接报The dependencies of some of the beans in the application context form a cycle。很多人的第一反应是加@Lazy或者把spring.main.allow-circular-references=true打开,但我强烈不建议这样做。循环依赖本身是代码设计不好的信号,加@Lazy只是延迟代理,问题没有消失。
正确的做法是把循环调用的部分拆出去。比如OrderService需要商品名称,那就把扣库存逻辑下沉到ProductService,订单状态变更逻辑下沉到OrderService,两个Service之间的交叉调用通过新增一个独立的OrderProductFacade组件来协调。这样依赖方向清晰,Spring容器也不会有歧义。
4.3 内存溢出与JVM参数调优
项目运行期间遇到过“OutOfMemoryError: Insufficient memory”。一种情况是Maven在IDEA里编译时内存不够,提示java.lang.OutOfMemoryError: GC overhead limit exceeded。解决办法是在Maven的VM options里设置-Xmx1024m,同时检查IDEA的Build process heap size,默认只有700m,调大到1500m或者更高基本能解决。
另一种情况是Docker容器跑Spring Boot时内存不够。比如本机分配了2GB内存的容器,但JVM按默认规则会向宿主机申请1/4的内存作为堆上限,这可能会导致容器直接OOM或被系统杀掉。解决办法是在启动命令里显式配置JVM参数:java -Xms256m -Xmx512m -jar pet-shop.jar。容器环境下还可以配合-XX:MaxRAMPercentage=60.0这类比例参数,让JVM根据容器配额自动调整堆大小。
4.4 前后端联调中的跨域问题
前端页面和后端接口如果不在同一个端口,就一定会有跨域问题。浏览器默认是同源策略,不同端口之间的请求会被拦截。我在后端配置了一个全局CORS过滤器,允许前端地址跨域访问。
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)和allowedOriginPatterns("*")必须配合使用,JDK自带的allowedOrigins在设置allowCredentials时不允许使用通配符;第二,前端请求携带JWT时会自动带上Authorization请求头,所以allowedHeaders必须包含*或明确写出这个头,否则预检请求会失败。
4.5 大文件上传下载与资源映射
宠物用品系统里可能会有商品详情页图片、用户上传的头像等文件。如果文件直接上传到服务端的临时目录,重启后就没了,所以我在配置里自定义了资源映射,把上传的文件保存到磁盘上的/data/pet-shop/upload/目录,然后通过/upload/**这个URL前缀对外访问。
java复制@Override
public void addResourceHandlers(ResourceHandlerRegistry registry) {
String uploadPath = "file:" + System.getProperty("user.dir") + "/upload/";
registry.addResourceHandler("/upload/**").addResourceLocations(uploadPath);
}
上传接口对于大的图片或PDF文件,要注意application.yml里的上传大小限制。默认max-file-size是1MB,小图无所谓,但如果要传宠物疫苗本扫描件、订单导出的Excel,就会一直报MaxUploadSizeExceededException。我把两个参数调成了100MB和200MB,但同时也加了简单的文件类型白名单和大小校验,防止有人恶意上传超大文件把磁盘塞满。
5. 项目部署与后续扩展
5.1 Docker镜像构建与部署
开发完成后我选择用Docker部署,Dockerfile写得很简洁:
dockerfile复制FROM openjdk:8-jre-alpine
LABEL maintainer="yours@example.com"
WORKDIR /app
COPY target/pet-shop.jar /app/app.jar
ENV TZ=Asia/Shanghai
RUN ln -snf /usr/share/zoneinfo/$TZ /etc/localtime && echo $TZ > /etc/timezone
EXPOSE 8080
ENTRYPOINT ["java", "-Xms256m", "-Xmx512m", "-jar", "app.jar"]
构建时用Maven先打出jar包,再用Docker命令构建镜像。这里说明一个很多人关心的问题:“Spring Boot JDK 8打包到Docker Desktop”。如果你的本机是JDK 17、但准备用JDK 8的镜像,那么pom.xml里必须把java.version设置为1.8,并且确保代码没有用到JDK 9以上的新语法(如List.of()、var关键字),否则编译会直接报错。如果本机编译环境和Docker镜像的基础JDK版本不一致,最常见的问题是在高版本JDK编译出的class文件在低版本JRE上运行报UnsupportedClassVersionError,所以构建镜像时最好直接用和编译环境相同版本的JDK镜像。
Docker Desktop在Windows/Mac上内存默认是2GB,如果你同时跑IDE、MySQL容器和Spring Boot容器,内存会非常紧张,容易触发容器级别的OOM。建议在Docker Desktop设置里把内存调到4GB以上。对于数据库这类有状态服务,我用docker-compose来编排,MySQL容器挂载宿主机目录,应用容器通过--link或同一网络访问。
5.2 Swagger接口文档与接口调试
接口文档我用的是Spring Fox 3.0的Swagger 3,启动项目后访问/swagger-ui/index.html就能看到所有接口,配合调试非常方便。但有个版本问题要提醒:Spring Boot 2.6以后,Spring Fox 3.0会有路径匹配策略不兼容的问题,启动会报PathPattern相关的错误。两种解决办法:一是用spring.mvc.pathmatch.matching-strategy=ant_path_matcher,二是直接换成Spring Doc这个库,它的维护更活跃,兼容性更好。
在项目交付时,可以把Swagger文档导出成离线HTML或Markdown,联调时直接发给前端团队。同时我在所有接口的@ApiOperation注解里写清楚入参出参和业务逻辑,这样第三方接入方不用看代码也能搞清楚接口含义。
5.3 后续扩展方向与项目复盘
这个项目做到后面,我发现可以扩展的点其实很多。数据访问层面可以引入Redis缓存热门商品列表和购物车信息,减少MySQL压力;搜索层面可以把商品搜索从LIKE查询升级到Elasticsearch,支持分词和相关性排序;消息推送层面可以在下单成功后用RabbitMQ发送通知,异步处理物流跟踪。支付模块可以接入微信支付或支付宝沙箱环境,把模拟支付替换成真实支付。
如果你是准备把这个项目用在面试里,除了把业务讲清楚,建议把Spring Boot自动装配原理、JWT认证流程、事务隔离级别、MyBatis Plus分页原理这些底层知识点也准备好。面试官很可能会从项目里的某个点出发,问到框架底层实现。比如你做了JWT,就会问Token过期了怎么办、如何实现主动登出;你做了库存扣减,就很可能追问并发超卖怎么解决。
整个项目做完,我自己最大的感悟是:一个中型业务系统,技术选型不需要多花哨,把基础组件用扎实、把核心链路设计清晰,比堆砌一堆看似高级的技术更有价值。Spring Boot本身已经帮我们屏蔽了大部分环境配置的复杂度,剩下真正考验开发能力的,是表结构设计、接口边界定义、事务和并发控制这些基本功。希望这篇复盘能帮你少走一些弯路。
