ARTICLE DETAIL

资讯详情

深耕商务建站与企业官网运营的一线实战洞察。

Spring Boot宠物用品交易平台实战:从设计到部署全解析

Spring Boot宠物用品交易平台实战:从设计到部署全解析 做毕设或练手项目选“在线交易平台”这套命题几乎是绕不开的。原因很简单电商类系统覆盖面广从前台购物车到后台订单管理能完整串起 Java Web 开发的主线知识非常适合作为 Spring Boot 实战项目的切入点。“基于 Spring Boot 的宠物用品交易平台”是这个分类里做得比较细、也不容易跟别人完全撞车的题目。这篇文章我把这个项目的完整思路、技术选型、数据库设计、核心流程实现和踩坑记录都整理出来希望能帮正在做类似项目或准备自己从零写一套系统的同学省下不少弯路。这个项目解决的是宠物用品线上购买场景中的完整交易闭环核心包含用户端和管理端两大部分。用户端有注册登录、商品浏览、分类筛选、购物车、下单支付模拟、订单管理和收货地址管理管理端则是商品管理、分类管理、订单处理、用户管理和基础数据统计。整套系统不仅适合毕业设计展示也特别适合想通过一个完整项目把 Spring Boot 前后端知识串起来的人。简单说做完这个项目你对 SSM 框架整合、RESTful API 设计、关系型数据库建模、拦截器鉴权、文件上传这些核心技能都会有比较扎实的体感。1. 项目整体设计与功能拆解1.1 需求定位与角色权限设计宠物用品交易平台这个题目如果你去看网上现成的成品会发现很多版本做得非常单薄有的只有一个商品列表加一个“购买”按钮点了之后什么反馈都没有有的是把所有管理功能堆到一个页面里用户和管理员完全不分权。这些做法应付中期检查都勉强更别说真正上线或者进答辩被问到业务细节。我做这个项目时第一件事就是把角色权限作为整个系统的基础框架来设计。系统分两种角色普通用户和管理员。用户端通过 JWTJSON Web Token做无状态鉴权管理员接口再单独加一层拦截校验。这样做的直接好处是前后端分离时不用每次请求都传 session ID移动端如果以后要接入小程序这类场景JWT 也能直接复用。管理员权限校验我用的是 Spring Boot 拦截器写一个AdminInterceptor类实现HandlerInterceptor接口在preHandle里判断请求头中的 token解析出用户角色后再决定放行还是拦截。整个系统的功能模块划分我建议按照用户端和管理端两条线来梳理用户端注册、登录、退出登录个人信息查看修改首页轮播图、商品分类导航、商品推荐列表商品列表支持关键词搜索、分类筛选、价格排序、分页展示商品详情页展示多张商品图片、库存、销量、详情描述购物车加入、修改数量、删除、批量勾选结算订单提交订单、在线支付模拟、取消、确认收货收货地址新增、编辑、删除、设置默认地址个人中心我的订单、我的收藏、浏览记录管理端管理员登录鉴权独立于用户端商品管理新增、上下架、修改库存和价格、编辑商品图片分类管理商品分类的增删改查订单管理订单列表、订单详情、发货操作、订单统计用户管理查看用户列表、禁用/启用用户账号数据看板销售额统计、订单量统计、热销商品 Top N1.2 技术选型背后的取舍逻辑这个项目我用的是 Spring Boot 2.7.x MyBatis-Plus 3.5.x MySQL 8.0 这套组合。如果你用的是 Java 8 或 11Spring Boot 2.7 仍然是兼容性和稳定性最稳妥的版本不建议一上来就追 Spring Boot 3因为 Spring Boot 3 强制要求 Java 17且部分第三方中间件的适配还容易踩坑。注意如果你的学校或公司环境强制要求 JDK 8Spring Boot 2.7.x 是最后一代完整兼容 JDK 8 的大版本分支这个信息在选型时很关键。MyBatis-Plus 在这个项目里是真正的效率神器。它内置了BaseMapper单表 CRUD 基本不需要手写 SQL注意是“基本”多表关联查询和复杂统计还是要自己写 XML。我用它的分页插件PaginationInnerInterceptor非常顺手配置十几行代码就能实现标准的分页查询避免了原生 MyBatis 写 PageHelper 依赖时的版本冲突问题。前端我选的方案是 Thymeleaf Bootstrap 5 jQuery没上前后端完全分离的 Vue 脚手架。原因很实际团队只有一个人既要写后端又要写页面如果还要维护 Nginx 静态资源分离和前端构建流程工作量会翻倍。服务端渲染模式下页面直接通过 ModelAndView 渲染HTML 里用th:each循环商品列表对演示和答辩来说已经足够完整。更重要的是Thymeleaf 的语法风格跟原生 HTML 非常接近后续想迁移到 Vue 时后端接口只要保证返回 JSON 格式就行改动成本也不至于伤筋动骨。2. 核心细节解析与实操要点2.1 数据库表结构设计不是堆字段是理清关系数据库设计是这个项目最值得花时间的环节。它决定了后面所有业务逻辑的复杂度上限设计得当代码写起来会非常顺手。宠物用品交易平台我设计了 8 张核心表下面这张表给你做个索引对照表名中文含义核心字段关联关系user用户表id, username, password, nickname, avatar, phone, role与 cart、orders 一对多category商品分类表id, name, sort_order与 product 一对多product商品表id, category_id, name, subtitle, main_image, detail, price, stock, sales, status与 cart、order_item 一对多cart购物车表id, user_id, product_id, quantity, checked不落订单表作缓存型数据orders订单表id, order_no, user_id, total_price, payment_type, status, receiver_name, receiver_phone, receiver_address与 order_item 一对多order_item订单明细表id, order_id, product_id, product_name, product_image, current_price, quantity多对一关联 ordersaddress收货地址表id, user_id, receiver_name, receiver_phone, province, city, district, detail, is_default与 user 多对一banner轮播图表id, image_url, link_url, sort_order, status独立配置表有几个设计细节我说一下都是我实际开发中被坑过之后改出来的第一订单表和订单明细表必须分开设计。刚开始图省事把商品快照直接存在订单表里的 JSON 字段里后来发现一旦要统计“哪个商品卖得最好”就得去解析 JSON数据库层面完全没法做聚合查询。拆成 order_item 表之后热销统计一条 SQL 就能解决。另外注意在订单明细里要冗余一份product_name和product_image因为商品信息是动态的用户下单之后商品改名或删除了订单里仍要保留购买当时的快照。第二密码字段存的不是明文是 BCrypt 加密后的密文。Spring Security 里自带的BCryptPasswordEncoder可以直接用不用为了一个密码加密功能把整个 Spring Security 引入项目单独拿它的编码器工具类即可。第三库存字段的初始化和扣减逻辑要提前约定清楚。添加商品时默认stock为 0上架前必须手动设置库存和价格。扣减库存时我用了乐观锁机制在 product 表增加version字段更新库存的 SQL 里带上WHERE version #{version}避免高并发场景下两个订单同时把同一件商品的库存扣成负数。2.2 Spring Boot 项目结构合理的分层是后期维护的救命稻草很多同学做项目时喜欢把所有的 Controller、Service 全部塞到几个包里表面看文件很少但功能一多根本没法维护。我自己比较推荐的分层结构是com.pet.mall ├── controller // 接口层只做参数接收和响应包装 ├── service // 业务层处理核心逻辑 │ └── impl ├── mapper // MyBatis-Plus 的 Mapper 接口 ├── entity // 数据库实体类 ├── dto // 数据传输对象请求参数 ├── vo // 视图对象响应数据 ├── config // 配置类跨域、拦截器、分页插件 ├── common // 通用类统一返回结果、异常处理、常量 ├── util // 工具类JWT、文件上传 └── interceptor // 登录鉴权拦截器注意区分 DTO 和 VO 的概念这是很少有人在毕设里讲清楚但答辩时很可能被问到的点。DTO 是接收前端传来的参数比如注册时只需要username、password、nickname不需要把整个User实体作为入参VO 是返回给前端的数据比如订单详情里需要包含商品明细列表和订单状态描述而这些字段在实体类中可能并不直接存在。统一返回结果类我也强烈建议写这是公司里非常基础的一项规范。我用的是一个泛型类ResultT包含code、message、data三个字段。成功时 code 为 200失败时 code 为 500 或自定义业务码配合全局异常处理器RestControllerAdvice代码里就基本不用在每个 Controller 里重复写 try-catch 了。2.3 关键配置文件与依赖清单pom.xml里核心依赖我整理了一份参考清单dependencies !-- Web 启动器 -- dependency groupIdorg.springframework.boot/groupId artifactIdspring-boot-starter-web/artifactId /dependency !-- Thymeleaf 模板引擎 -- dependency groupIdorg.springframework.boot/groupId artifactIdspring-boot-starter-thymeleaf/artifactId /dependency !-- MyBatis-Plus Spring Boot 3.x 使用不同的 starter -- dependency groupIdcom.baomidou/groupId artifactIdmybatis-plus-boot-starter/artifactId version3.5.3.1/version /dependency !-- MySQL 驱动 -- dependency groupIdmysql/groupId artifactIdmysql-connector-java/artifactId version8.0.33/version /dependency !-- Lombok 简化实体类 -- dependency groupIdorg.projectlombok/groupId artifactIdlombok/artifactId optionaltrue/optional /dependency !-- JWT 工具 -- dependency groupIdio.jsonwebtoken/groupId artifactIdjjwt-api/artifactId version0.11.5/version /dependency !-- Hutool 工具包生成随机订单号、日期工具等 -- dependency groupIdcn.hutool/groupId artifactIdhutool-all/artifactId version5.8.20/version /dependency /dependenciesapplication.yml的关键配置如下数据库连接池我直接用的 HikariCPSpring Boot 默认server: port: 8080 spring: datasource: driver-class-name: com.mysql.cj.jdbc.Driver url: jdbc:mysql://localhost:3306/pet_mall?useUnicodetruecharacterEncodingutf8serverTimezoneAsia/Shanghai username: root password: yourpassword hikari: maximum-pool-size: 10 minimum-idle: 5 thymeleaf: cache: false suffix: .html prefix: classpath:/templates/ mybatis-plus: configuration: log-impl: org.apache.ibatis.logging.stdout.StdOutImpl map-underscore-to-camel-case: true global-config: db-config: id-type: auto logic-delete-field: deleted logic-delete-value: 1 logic-not-delete-value: 0提示map-underscore-to-camel-case一定要打开否则数据库字段user_id无法自动映射到实体类的userId属性会让你在排查问题时怀疑人生。3. 实操过程与核心环节实现3.1 商品模块从数据库到页面渲染的完整链路商品模块是整个项目最先需要跑通的模块因为后面购物车、订单全都要依托商品数据。实现一个商品列表接口为例从前端请求到数据库查询的完整链路是浏览器访问/product/list?pageNum1pageSize8categoryId3keyword猫粮请求首先被 Spring MVC 的DispatcherServlet接收根据RequestMapping(/product/list)映射到ProductController的list方法。Controller 层把参数封装成ProductQueryDTO调用 Service 层的queryProductPage()方法Service 里通过 MyBatis-Plus 的LambdaQueryWrapper构造查询条件分页插件自动执行 count 和 limit 两条 SQL最终返回一个PageInfo对象给前端 Thymeleaf 渲染。关键代码如下这段代码我实测过很多次直接抄就能用Override public PageResultProductVO queryProductPage(ProductQueryDTO dto) { PageProduct page new Page(dto.getPageNum(), dto.getPageSize()); LambdaQueryWrapperProduct wrapper new LambdaQueryWrapper(); // 关键词搜索 if (StrUtil.isNotBlank(dto.getKeyword())) { wrapper.and(w - w.like(Product::getName, dto.getKeyword()) .or() .like(Product::getSubtitle, dto.getKeyword())); } // 分类筛选 if (dto.getCategoryId() ! null) { wrapper.eq(Product::getCategoryId, dto.getCategoryId()); } // 只查询上架商品 wrapper.eq(Product::getStatus, 1); // 排序按销量、价格、新品 if (sales.equals(dto.getSort())) { wrapper.orderByDesc(Product::getSales); } else if (priceAsc.equals(dto.getSort())) { wrapper.orderByAsc(Product::getPrice); } else if (priceDesc.equals(dto.getSort())) { wrapper.orderByDesc(Product::getPrice); } else { wrapper.orderByDesc(Product::getCreateTime); } productMapper.selectPage(page, wrapper); PageResultProductVO result new PageResult(); result.setPageNum(page.getCurrent()); result.setPageSize(page.getSize()); result.setTotal(page.getTotal()); result.setList(BeanUtil.copyToList(page.getRecords(), ProductVO.class)); return result; }这里有个容易被忽略的细节搜索条件中的keyword用了StrUtil.isNotBlank()判断而不是直接判断! null因为前端如果传一个空字符串过来like条件仍然会拼进 SQL虽然结果集一样但会多一次无效查询。3.2 购物车与订单流程事务边界到底画在哪里购物车和订单流程是电商系统的核心链路也是答辩时最容易追问细节的地方。很多初学者的代码在提交订单时会出现严重问题订单主表插入成功了但订单明细和库存扣减失败了导致数据不一致。这个问题的根本原因是事务边界没有控制好。Spring 的Transactional注解默认只对运行时异常RuntimeException回滚对受检异常如IOException不会回滚。如果代码里 catch 住了异常但没有重新抛出即使加了注解也无法触发回滚。我实现的提交订单逻辑是这样的Transactional(rollbackFor Exception.class) Override public OrderVO submitOrder(OrderCreateDTO dto) { // 1. 参数校验地址不能为空购物车商品不能为空 Address address addressMapper.selectById(dto.getAddressId()); Assert.notNull(address, 收货地址不存在); // 2. 计算订单总价校验库存 ListCartVO cartList cartMapper.selectCartWithProduct(dto.getUserId()); BigDecimal totalPrice BigDecimal.ZERO; for (CartVO cart : cartList) { if (cart.getStock() cart.getQuantity()) { throw new BusinessException(500, 商品【 cart.getProductName() 】库存不足); } totalPrice totalPrice.add(cart.getCurrentPrice().multiply(new BigDecimal(cart.getQuantity()))); } // 3. 生成订单号保存订单主表 String orderNo PM System.currentTimeMillis() RandomUtil.randomNumbers(4); Orders order new Orders(); order.setOrderNo(orderNo); order.setUserId(dto.getUserId()); order.setTotalPrice(totalPrice); order.setStatus(0); // 0-待支付 1-已支付 2-已发货 3-已完成 4-已取消 order.setReceiverName(address.getReceiverName()); order.setReceiverPhone(address.getReceiverPhone()); order.setReceiverAddress(address.getProvince() address.getCity() address.getDistrict() address.getDetail()); ordersMapper.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.setCurrentPrice(cart.getCurrentPrice()); item.setQuantity(cart.getQuantity()); orderItemMapper.insert(item); int rows productMapper.deductStock(cart.getProductId(), cart.getQuantity()); if (rows 0) { throw new BusinessException(500, 商品【 cart.getProductName() 】库存扣减失败); } } // 5. 清空用户购物车中已结算的商品 cartMapper.deleteByUserIdAndProductIds(dto.getUserId(), cartList.stream().map(CartVO::getProductId).collect(Collectors.toList())); return orderToVO(order); }整套流程我用了Transactional(rollbackFor Exception.class)确保任何一步失败前面插入的订单主表、明细表、库存扣减全部回滚。这里最关键的 SQL 是库存扣减语句必须使用带条件的更新而不是先查再更新UPDATE product SET stock stock - #{quantity}, version version 1 WHERE id #{productId} AND stock #{quantity} AND version #{version}这个写法把“库存检查”和“库存扣减”合并成一条原子 SQL避免了两步操作之间可能出现的并发问题。3.3 文件上传与图片访问策略商品图片上传是管理端必须的功能。我这里提供两种方案看你的部署环境怎么选择。方案一本地存储在项目根目录下的upload/images文件夹通过配置静态资源映射来访问。优点是部署简单方式是自定义一个WebMvcConfigurer来添加资源映射Configuration public class WebMvcConfig implements WebMvcConfigurer { Override public void addResourceHandlers(ResourceHandlerRegistry registry) { String uploadPath System.getProperty(user.dir) /upload/; registry.addResourceHandler(/upload/**) .addResourceLocations(file: uploadPath); } }PostMapping(/admin/product/upload) public ResultString uploadImage(RequestParam(file) MultipartFile file) { if (file.isEmpty()) { return Result.error(请选择文件); } String originalFilename file.getOriginalFilename(); String ext originalFilename.substring(originalFilename.lastIndexOf(.)); if (!Arrays.asList(.jpg, .jpeg, .png, .gif).contains(ext.toLowerCase())) { return Result.error(图片格式不支持); } String fileName System.currentTimeMillis() RandomUtil.randomNumbers(6) ext; String datePath DateUtil.format(new Date(), yyyyMMdd); File destDir new File(uploadPath datePath); if (!destDir.exists()) { destDir.mkdirs(); } file.transferTo(new File(destDir, fileName)); String url /upload/ datePath / fileName; return Result.success(url); }方案二把图片上传到某云存储服务商的对象存储桶里返回公网可访问的 URL。这是生产环境的标准做法但需要你在云服务商控制台创建一个 Bucket 并开通权限配置更麻烦一些。对于毕设和本地演示方案一足够用了只要注意在上传时做格式大小校验避免把可执行文件伪装成图片传上来。3.4 JWT 登录鉴权与拦截器配置用户登录成功后后端返回一个 JWT 令牌前端在后续请求时通过Authorization: Bearer token头发送。JWT 的核心结构是 Header.Payload.Signature 三段式我用的生成方式如下public String generateToken(User user) { Date now new Date(); Date expireAt new Date(now.getTime() 7 * 24 * 60 * 60 * 1000); // 7天有效期 return Jwts.builder() .setSubject(String.valueOf(user.getId())) .claim(username, user.getUsername()) .claim(role, user.getRole()) .setIssuedAt(now) .setExpiration(expireAt) .signWith(SignatureAlgorithm.HS256, SECRET_KEY) .compact(); }拦截器里做的事很简单从请求头里取 token解析成功且未过期把 userId 和 role 存到ThreadLocal或HttpServletRequest的 attribute 中供后续 Controller 使用。这里我推荐用HandlerInterceptor而不是Filter因为拦截器能拿到 Spring 管理和容器对象比如直接注入 JWT 工具类而 Filter 在 Bean 获取上会麻烦一些。不过这段拦截期间有个细节需要注意放行login、register、/product/**静态资源等需要匿名访问的路径其他一律经过校验。Override public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) throws Exception { // 放行跨域预检请求 if (OPTIONS.equalsIgnoreCase(request.getMethod())) { return true; } String token request.getHeader(Authorization); if (StrUtil.isBlank(token) || !token.startsWith(Bearer )) { throw new BusinessException(401, 用户未登录); } try { Claims claims JwtUtil.parseToken(token.substring(7)); request.setAttribute(userId, claims.get(userId)); request.setAttribute(role, claims.get(role)); return true; } catch (Exception e) { throw new BusinessException(401, 登录状态已过期请重新登录); } }4. 常见问题与排查技巧实录4.1 数据库中文乱码和时区问题这是新手上路遇到的第一道坎。现象是插入中文数据后查出来变成问号或者控制台报The server time zone value Öйú±ê׼ʱ¼ä is unrecognized的错。归根结底是连接串里没有显式指定编码和时区。解决方式很直接数据库连接 URL 加上三个参数就行jdbc:mysql://localhost:3306/pet_mall?useUnicodetruecharacterEncodingutf8serverTimezoneAsia/Shanghai这里缺characterEncodingutf8中文会乱码缺serverTimezone会报时区异常两个必须同时配置。另一个容易踩的地方是 MySQL 建库时的默认字符集如果建库语句里没指定 UTF8MB4Windows 上默认会是 latin1这个和连接串无关要在 CREATE DATABASE 语句里显式指定CREATE DATABASE IF NOT EXISTS pet_mall DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_general_ci;4.2 MyBatis-Plus 实体映射导致的空指针项目里最常见的报错之一就是查库能查到数据但实体对象某些字段为 null后续代码一用到就报空指针。排查思路分三步先看数据库字段名和实体属性名是否对应再确认map-underscore-to-camel-case是否打开最后看实体类有没有加TableName和TableId注解。MyBatis-Plus 的实体类默认认为数据库主键叫id如果主键字段叫user_id必须加TableId(user_id)注解否则插入和更新会把主键当作普通字段处理导致 SQL 执行失败。这段经验来自我实际调试的真实场景当时 Orders 表主键是order_id实体类里对应字段写的orderId怎么查都查不到数据日志里打印的 SQL 是这样的SELECT ... FROM orders WHERE id ?完全不是想要的语义。4.3 跨域请求被拦截如果是服务端渲染的模式跨域问题一般不明显。一旦你前端页面单独启动在另一个端口比如 Vue 开发服务器运行在 5173Spring Boot 跑在 8080跨域请求就会被浏览器的同源策略拦截。解决方法是写一个全局跨域配置Configuration public class CorsConfig { Bean public CorsFilter corsFilter() { CorsConfiguration config new CorsConfiguration(); config.addAllowedOriginPattern(*); config.addAllowedMethod(*); config.addAllowedHeader(*); config.setAllowCredentials(true); UrlBasedCorsConfigurationSource source new UrlBasedCorsConfigurationSource(); source.registerCorsConfiguration(/**, config); return new CorsFilter(source); } }这串配置允许所有来源、方法和请求头跨域访问但当allowCredentials(true)时不能允许具体的 origin 为*必须使用addAllowedOriginPattern才能配合 credentials 正常工作。这是浏览器规范的限制别在这里浪费太多时间。4.4 订单号并发重复自己实现电商系统不要用时间戳 随机数直接拼订单号并发高一点就会重复。我一开始用System.currentTimeMillis()加四位随机数测试时连续快速下两个订单就撞号了。推荐几种增强方案用数据库的自增 ID 作为订单号的一部分但这个暴露了业务量数据不适合外发给用户用 Hutool 的IdUtil.getSnowflakeNextIdStr()生成雪花算法 ID按时间有序、全局唯一这是通用做法更保险的是在数据库订单表给order_no加唯一索引即使上层并发导致生成的订单号重复数据库也能兜底拒绝重复插入4.5 部署与演示常见问题速查问题现象可能原因解决方案启动直接失败端口被占用8080 被其他进程占用netstat -ano查出 PID结束进程或改server.port页面能打开但样式丢失Thymeleaf 缓存未关闭资源路径引用错spring.thymeleaf.cachefalse检查th:href{/css/style.css}商品图片 404上传目录和静态资源映射不一致检查upload目录是否存在映射路径是否包含日期子目录项目打包后启动报找不到主类打包配置有问题确认spring-boot-maven-plugin的mainClass配置正确页面显示模板解析错误HTML 文件所在目录不对Thymeleaf 默认找templates/目录下的.html子目录要写完整相对路径5. 从这份项目里能拿走的东西做完这个项目最大的体会是“电商系统虽然看似到处都是 CRUD但核心难点其实集中在事务、并发和状态流转三件事上”。我实际开发时为了演示顺畅给订单状态定义了一套完整的状态流转待支付、已支付、已发货、已完成、已取消。每个状态之间的合法跳转我在 Service 层都做了校验比如已取消的订单不能发货已完成的订单不能取消。这个设计在答辩时被问到“系统如何处理异常订单”的时候回答起来非常加分。文档和视频资料的整理也是这个项目容易被低估的环节。运行视频录一遍完整的操作流程代码讲解视频分段录制每段控制在 5 分钟以内重点讲一个功能点的实现思路。写文档时把表结构、接口清单和部署步骤分开写明尤其是数据库初始化脚本一定要在全新的 MySQL 实例上测试过一遍。这些看起来琐碎但后期复盘或者给别人展示项目时价值不比代码本身低。最后再分享一个小技巧项目里放一个README.md把启动命令、数据库初始化方式、默认管理员账号密码都写清楚。以后无论时隔多久回看这个项目或者直接把项目交给任何人运行都能在五分钟内跑起来。这个习惯会为你省下大量不必要的时间损耗。
返回列表
PREV
查看更多资讯
NEXT
返回资讯列表