SpringBoot 购物车设计与实现完整方案

68 阅读7分钟

SpringBoot 购物车设计与实现完整方案

一、购物车设计方案对比

1.1 常见实现方式

实现方式优点缺点适用场景
基于Session实现简单,无需额外存储无法跨设备,服务重启数据丢失单服务器部署,临时购物车
基于Redis性能高,支持分布式需要额外维护Redis服务分布式系统,高并发场景
基于数据库数据持久化,可靠性能较差,并发能力弱数据安全性要求极高的场景
Redis+数据库兼顾性能与可靠性实现复杂度高综合场景,推荐方案

二、技术选型

  • 框架:Spring Boot 2.7.x
  • 缓存:Redis 6.x
  • ORM:MyBatis-Plus 3.5.x
  • 数据库:MySQL 8.0
  • 前端:可与Vue/React等框架配合

三、完整实现方案

3.1 依赖配置

<dependencies>
    <!-- Spring Boot Web -->
    <dependency>
        <groupId>org.springframework.boot</groupId>
        <artifactId>spring-boot-starter-web</artifactId>
    </dependency>
    
    <!-- Redis -->
    <dependency>
        <groupId>org.springframework.boot</groupId>
        <artifactId>spring-boot-starter-data-redis</artifactId>
    </dependency>
    
    <!-- MyBatis-Plus -->
    <dependency>
        <groupId>com.baomidou</groupId>
        <artifactId>mybatis-plus-boot-starter</artifactId>
        <version>3.5.3.1</version>
    </dependency>
    
    <!-- MySQL 驱动 -->
    <dependency>
        <groupId>mysql</groupId>
        <artifactId>mysql-connector-java</artifactId>
        <scope>runtime</scope>
    </dependency>
    
    <!-- Lombok -->
    <dependency>
        <groupId>org.projectlombok</groupId>
        <artifactId>lombok</artifactId>
        <optional>true</optional>
    </dependency>
</dependencies>

3.2 配置文件

spring:
  datasource:
    url: jdbc:mysql://localhost:3306/shop?useSSL=false&useUnicode=true&characterEncoding=utf8&serverTimezone=Asia/Shanghai
    username: root
    password: root
    driver-class-name: com.mysql.cj.jdbc.Driver
  
  redis:
    host: 127.0.0.1
    port: 6379
    password: 
    database: 0
    timeout: 3000
    jedis:
      pool:
        max-active: 8
        max-wait: -1
        max-idle: 8
        min-idle: 0

3.3 Redis配置类

package com.example.shop.config;

import com.fasterxml.jackson.annotation.JsonAutoDetect;
import com.fasterxml.jackson.annotation.PropertyAccessor;
import com.fasterxml.jackson.databind.ObjectMapper;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.data.redis.connection.RedisConnectionFactory;
import org.springframework.data.redis.core.RedisTemplate;
import org.springframework.data.redis.serializer.Jackson2JsonRedisSerializer;
import org.springframework.data.redis.serializer.StringRedisSerializer;

@Configuration
public class RedisConfig {
    
    @Bean
    public RedisTemplate<String, Object> redisTemplate(RedisConnectionFactory factory) {
        RedisTemplate<String, Object> redisTemplate = new RedisTemplate<>();
        redisTemplate.setConnectionFactory(factory);
        
        // Key序列化
        redisTemplate.setKeySerializer(new StringRedisSerializer());
        redisTemplate.setHashKeySerializer(new StringRedisSerializer());
        
        // Value序列化
        Jackson2JsonRedisSerializer<Object> jackson2JsonRedisSerializer = new Jackson2JsonRedisSerializer<>(Object.class);
        ObjectMapper objectMapper = new ObjectMapper();
        objectMapper.setVisibility(PropertyAccessor.ALL, JsonAutoDetect.Visibility.ANY);
        objectMapper.enableDefaultTyping(ObjectMapper.DefaultTyping.NON_FINAL);
        jackson2JsonRedisSerializer.setObjectMapper(objectMapper);
        
        redisTemplate.setValueSerializer(jackson2JsonRedisSerializer);
        redisTemplate.setHashValueSerializer(jackson2JsonRedisSerializer);
        
        redisTemplate.afterPropertiesSet();
        return redisTemplate;
    }
}

3.4 实体类设计

package com.example.shop.entity;

import lombok.Data;
import java.io.Serializable;
import java.math.BigDecimal;

@Data
public class CartItem implements Serializable {
    private static final long serialVersionUID = 1L;
    
    // 商品ID
    private Long productId;
    // 商品名称
    private String productName;
    // 商品价格
    private BigDecimal price;
    // 购买数量
    private Integer quantity;
    // 商品图片
    private String image;
    // 是否选中
    private Boolean selected = true;
    // 商品总价
    public BigDecimal getTotalPrice() {
        return price.multiply(new BigDecimal(quantity));
    }
}
package com.example.shop.entity;

import com.baomidou.mybatisplus.annotation.IdType;
import com.baomidou.mybatisplus.annotation.TableId;
import com.baomidou.mybatisplus.annotation.TableName;
import lombok.Data;
import java.math.BigDecimal;
import java.util.Date;

@Data
@TableName("product")
public class Product {
    @TableId(type = IdType.AUTO)
    private Long id;
    private String name;
    private String description;
    private BigDecimal price;
    private String image;
    private Integer stock;
    private Integer status;
    private Date createTime;
    private Date updateTime;
}

3.5 购物车服务接口

package com.example.shop.service;

import com.example.shop.entity.CartItem;
import java.util.List;
import java.util.Map;

public interface CartService {
    
    /**
     * 添加商品到购物车
     */
    void addToCart(String cartKey, Long productId, Integer quantity);
    
    /**
     * 从购物车删除商品
     */
    void removeFromCart(String cartKey, Long productId);
    
    /**
     * 更新购物车商品数量
     */
    void updateCartItemQuantity(String cartKey, Long productId, Integer quantity);
    
    /**
     * 更新购物车商品选中状态
     */
    void updateCartItemSelected(String cartKey, Long productId, Boolean selected);
    
    /**
     * 获取购物车所有商品
     */
    List<CartItem> getCartItems(String cartKey);
    
    /**
     * 获取购物车商品总数
     */
    Integer getCartItemCount(String cartKey);
    
    /**
     * 获取购物车总价
     */
    Double getCartTotalPrice(String cartKey);
    
    /**
     * 清空购物车
     */
    void clearCart(String cartKey);
    
    /**
     * 合并购物车(登录时)
     */
    void mergeCart(String tempCartKey, String userCartKey);
    
    /**
     * 生成购物车key
     */
    String generateCartKey(Long userId, String sessionId);
}

3.6 购物车服务实现

package com.example.shop.service.impl;

import com.example.shop.entity.CartItem;
import com.example.shop.entity.Product;
import com.example.shop.mapper.ProductMapper;
import com.example.shop.service.CartService;
import com.fasterxml.jackson.databind.ObjectMapper;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.data.redis.core.RedisTemplate;
import org.springframework.stereotype.Service;
import org.springframework.util.StringUtils;

import java.util.ArrayList;
import java.util.List;
import java.util.Set;
import java.util.concurrent.TimeUnit;

@Service
public class CartServiceImpl implements CartService {
    
    private static final String CART_PREFIX = "cart:";
    private static final long CART_EXPIRE_DAYS = 7;
    
    @Autowired
    private RedisTemplate<String, Object> redisTemplate;
    
    @Autowired
    private ProductMapper productMapper;
    
    @Autowired
    private ObjectMapper objectMapper;
    
    @Override
    public void addToCart(String cartKey, Long productId, Integer quantity) {
        // 查询商品信息
        Product product = productMapper.selectById(productId);
        if (product == null) {
            throw new RuntimeException("商品不存在");
        }
        
        // 构造购物车项
        CartItem cartItem = new CartItem();
        cartItem.setProductId(product.getId());
        cartItem.setProductName(product.getName());
        cartItem.setPrice(product.getPrice());
        cartItem.setImage(product.getImage());
        
        // 检查是否已存在该商品
        String itemKey = cartKey + ":" + productId;
        CartItem existingItem = (CartItem) redisTemplate.opsForValue().get(itemKey);
        
        if (existingItem != null) {
            // 已存在则增加数量
            cartItem.setQuantity(existingItem.getQuantity() + quantity);
            cartItem.setSelected(existingItem.getSelected());
        } else {
            cartItem.setQuantity(quantity);
        }
        
        // 保存到Redis
        redisTemplate.opsForValue().set(itemKey, cartItem, CART_EXPIRE_DAYS, TimeUnit.DAYS);
        
        // 将商品ID添加到购物车集合中,用于快速获取购物车中的所有商品
        redisTemplate.opsForSet().add(cartKey + ":ids", productId.toString());
        redisTemplate.expire(cartKey + ":ids", CART_EXPIRE_DAYS, TimeUnit.DAYS);
    }
    
    @Override
    public void removeFromCart(String cartKey, Long productId) {
        String itemKey = cartKey + ":" + productId;
        redisTemplate.delete(itemKey);
        redisTemplate.opsForSet().remove(cartKey + ":ids", productId.toString());
    }
    
    @Override
    public void updateCartItemQuantity(String cartKey, Long productId, Integer quantity) {
        String itemKey = cartKey + ":" + productId;
        CartItem cartItem = (CartItem) redisTemplate.opsForValue().get(itemKey);
        if (cartItem != null) {
            cartItem.setQuantity(quantity);
            redisTemplate.opsForValue().set(itemKey, cartItem, CART_EXPIRE_DAYS, TimeUnit.DAYS);
        }
    }
    
    @Override
    public void updateCartItemSelected(String cartKey, Long productId, Boolean selected) {
        String itemKey = cartKey + ":" + productId;
        CartItem cartItem = (CartItem) redisTemplate.opsForValue().get(itemKey);
        if (cartItem != null) {
            cartItem.setSelected(selected);
            redisTemplate.opsForValue().set(itemKey, cartItem, CART_EXPIRE_DAYS, TimeUnit.DAYS);
        }
    }
    
    @Override
    public List<CartItem> getCartItems(String cartKey) {
        List<CartItem> cartItems = new ArrayList<>();
        
        Set<Object> productIds = redisTemplate.opsForSet().members(cartKey + ":ids");
        if (productIds != null) {
            for (Object id : productIds) {
                String itemKey = cartKey + ":" + id;
                CartItem cartItem = (CartItem) redisTemplate.opsForValue().get(itemKey);
                if (cartItem != null) {
                    cartItems.add(cartItem);
                }
            }
        }
        
        return cartItems;
    }
    
    @Override
    public Integer getCartItemCount(String cartKey) {
        Integer count = 0;
        List<CartItem> cartItems = getCartItems(cartKey);
        for (CartItem item : cartItems) {
            count += item.getQuantity();
        }
        return count;
    }
    
    @Override
    public Double getCartTotalPrice(String cartKey) {
        Double totalPrice = 0.0;
        List<CartItem> cartItems = getCartItems(cartKey);
        for (CartItem item : cartItems) {
            if (item.getSelected()) {
                totalPrice += item.getTotalPrice().doubleValue();
            }
        }
        return totalPrice;
    }
    
    @Override
    public void clearCart(String cartKey) {
        Set<Object> productIds = redisTemplate.opsForSet().members(cartKey + ":ids");
        if (productIds != null) {
            for (Object id : productIds) {
                String itemKey = cartKey + ":" + id;
                redisTemplate.delete(itemKey);
            }
        }
        redisTemplate.delete(cartKey + ":ids");
    }
    
    @Override
    public void mergeCart(String tempCartKey, String userCartKey) {
        List<CartItem> tempCartItems = getCartItems(tempCartKey);
        if (!tempCartItems.isEmpty()) {
            for (CartItem item : tempCartItems) {
                // 将临时购物车商品添加到用户购物车
                addToCart(userCartKey, item.getProductId(), item.getQuantity());
                // 更新选中状态
                updateCartItemSelected(userCartKey, item.getProductId(), item.getSelected());
            }
            // 清空临时购物车
            clearCart(tempCartKey);
        }
    }
    
    @Override
    public String generateCartKey(Long userId, String sessionId) {
        if (userId != null) {
            return CART_PREFIX + "user:" + userId;
        } else if (!StringUtils.isEmpty(sessionId)) {
            return CART_PREFIX + "session:" + sessionId;
        }
        throw new RuntimeException("无法生成购物车key");
    }
}

3.7 购物车控制器

package com.example.shop.controller;

import com.example.shop.entity.CartItem;
import com.example.shop.service.CartService;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.stereotype.Controller;
import org.springframework.ui.Model;
import org.springframework.web.bind.annotation.*;

import javax.servlet.http.HttpSession;
import java.util.HashMap;
import java.util.List;
import java.util.Map;

@Controller
@RequestMapping("/cart")
public class CartController {
    
    @Autowired
    private CartService cartService;
    
    /**
     * 获取当前登录用户ID(实际项目中应从Session或Token中获取)
     */
    private Long getCurrentUserId(HttpSession session) {
        // 实际项目中这里应该从Session或JWT Token中获取用户ID
        return (Long) session.getAttribute("userId");
    }
    
    /**
     * 获取购物车Key
     */
    private String getCartKey(HttpSession session) {
        Long userId = getCurrentUserId(session);
        String sessionId = session.getId();
        return cartService.generateCartKey(userId, sessionId);
    }
    
    /**
     * 查看购物车
     */
    @GetMapping("/list")
    public String cartList(Model model, HttpSession session) {
        String cartKey = getCartKey(session);
        List<CartItem> cartItems = cartService.getCartItems(cartKey);
        model.addAttribute("cartItems", cartItems);
        model.addAttribute("totalCount", cartService.getCartItemCount(cartKey));
        model.addAttribute("totalPrice", cartService.getCartTotalPrice(cartKey));
        return "cart/list";
    }
    
    /**
     * 添加商品到购物车
     */
    @PostMapping("/add")
    @ResponseBody
    public Map<String, Object> addToCart(@RequestParam Long productId, 
                                        @RequestParam Integer quantity,
                                        HttpSession session) {
        Map<String, Object> result = new HashMap<>();
        try {
            String cartKey = getCartKey(session);
            cartService.addToCart(cartKey, productId, quantity);
            result.put("success", true);
            result.put("totalCount", cartService.getCartItemCount(cartKey));
        } catch (Exception e) {
            result.put("success", false);
            result.put("message", e.getMessage());
        }
        return result;
    }
    
    /**
     * 更新购物车商品数量
     */
    @PostMapping("/update/quantity")
    @ResponseBody
    public Map<String, Object> updateQuantity(@RequestParam Long productId,
                                            @RequestParam Integer quantity,
                                            HttpSession session) {
        Map<String, Object> result = new HashMap<>();
        try {
            String cartKey = getCartKey(session);
            cartService.updateCartItemQuantity(cartKey, productId, quantity);
            result.put("success", true);
            result.put("totalPrice", cartService.getCartTotalPrice(cartKey));
        } catch (Exception e) {
            result.put("success", false);
            result.put("message", e.getMessage());
        }
        return result;
    }
    
    /**
     * 更新商品选中状态
     */
    @PostMapping("/update/selected")
    @ResponseBody
    public Map<String, Object> updateSelected(@RequestParam Long productId,
                                             @RequestParam Boolean selected,
                                             HttpSession session) {
        Map<String, Object> result = new HashMap<>();
        try {
            String cartKey = getCartKey(session);
            cartService.updateCartItemSelected(cartKey, productId, selected);
            result.put("success", true);
            result.put("totalPrice", cartService.getCartTotalPrice(cartKey));
        } catch (Exception e) {
            result.put("success", false);
            result.put("message", e.getMessage());
        }
        return result;
    }
    
    /**
     * 删除购物车商品
     */
    @PostMapping("/delete")
    @ResponseBody
    public Map<String, Object> deleteItem(@RequestParam Long productId,
                                         HttpSession session) {
        Map<String, Object> result = new HashMap<>();
        try {
            String cartKey = getCartKey(session);
            cartService.removeFromCart(cartKey, productId);
            result.put("success", true);
            result.put("totalCount", cartService.getCartItemCount(cartKey));
        } catch (Exception e) {
            result.put("success", false);
            result.put("message", e.getMessage());
        }
        return result;
    }
    
    /**
     * 清空购物车
     */
    @PostMapping("/clear")
    @ResponseBody
    public Map<String, Object> clearCart(HttpSession session) {
        Map<String, Object> result = new HashMap<>();
        try {
            String cartKey = getCartKey(session);
            cartService.clearCart(cartKey);
            result.put("success", true);
        } catch (Exception e) {
            result.put("success", false);
            result.put("message", e.getMessage());
        }
        return result;
    }
}

3.8 登录拦截器(用于购物车合并)

package com.example.shop.interceptor;

import com.example.shop.service.CartService;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.web.servlet.HandlerInterceptor;

import javax.servlet.http.HttpServletRequest;
import javax.servlet.http.HttpServletResponse;
import javax.servlet.http.HttpSession;

public class LoginInterceptor implements HandlerInterceptor {
    
    @Autowired
    private CartService cartService;
    
    @Override
    public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) throws Exception {
        HttpSession session = request.getSession();
        Long userId = (Long) session.getAttribute("userId");
        
        // 用户已登录,需要检查是否有临时购物车需要合并
        if (userId != null) {
            String tempCartKey = cartService.generateCartKey(null, session.getId());
            String userCartKey = cartService.generateCartKey(userId, null);
            
            // 合并临时购物车到用户购物车
            cartService.mergeCart(tempCartKey, userCartKey);
        }
        
        return true;
    }
}

3.9 拦截器配置

package com.example.shop.config;

import com.example.shop.interceptor.LoginInterceptor;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.web.servlet.config.annotation.InterceptorRegistry;
import org.springframework.web.servlet.config.annotation.WebMvcConfigurer;

@Configuration
public class WebConfig implements WebMvcConfigurer {
    
    @Bean
    public LoginInterceptor loginInterceptor() {
        return new LoginInterceptor();
    }
    
    @Override
    public void addInterceptors(InterceptorRegistry registry) {
        // 排除登录和注册接口
        registry.addInterceptor(loginInterceptor())
                .excludePathPatterns("/user/login", "/user/register")
                .addPathPatterns("/**");
    }
}

四、进阶功能

4.1 数据库持久化(可选)

对于需要持久化购物车数据的场景,可以在用户登录或定期将Redis购物车同步到数据库。

package com.example.shop.entity;

import com.baomidou.mybatisplus.annotation.IdType;
import com.baomidou.mybatisplus.annotation.TableId;
import com.baomidou.mybatisplus.annotation.TableName;
import lombok.Data;
import java.math.BigDecimal;
import java.util.Date;

@Data
@TableName("user_cart")
public class UserCart {
    @TableId(type = IdType.AUTO)
    private Long id;
    private Long userId;
    private Long productId;
    private Integer quantity;
    private Boolean selected;
    private Date createTime;
    private Date updateTime;
}

4.2 分布式锁处理并发

对于高并发场景,可以使用Redis分布式锁来处理购物车的并发操作:

package com.example.shop.utils;

import org.springframework.data.redis.core.RedisTemplate;
import org.springframework.stereotype.Component;

import java.util.concurrent.TimeUnit;
import java.util.concurrent.locks.Condition;
import java.util.concurrent.locks.Lock;

@Component
public class RedisLockUtil {
    
    @Autowired
    private RedisTemplate<String, Object> redisTemplate;
    
    /**
     * 加锁
     */
    public boolean lock(String key, String value, long expireTime) {
        return redisTemplate.opsForValue().setIfAbsent(key, value, expireTime, TimeUnit.SECONDS);
    }
    
    /**
     * 解锁
     */
    public void unlock(String key, String value) {
        String currentValue = (String) redisTemplate.opsForValue().get(key);
        if (value.equals(currentValue)) {
            redisTemplate.delete(key);
        }
    }
}

4.3 购物车预热

对于活跃用户,可以在系统启动时或定时任务中预热购物车数据,提高用户体验。

五、性能优化建议

  1. 使用Redis Pipeline:批量操作Redis命令,减少网络往返时间
  2. 设置合理的过期时间:临时购物车可设置较短过期时间
  3. 异步处理:购物车合并等操作可考虑异步处理
  4. 压缩数据:对大型购物车数据考虑压缩存储
  5. 使用本地缓存:对热门商品信息使用本地缓存

六、安全考虑

  1. 防止恶意修改:验证购物车数据的合法性
  2. 防止越权访问:确保用户只能访问自己的购物车
  3. 防止缓存穿透:对不存在的商品进行缓存处理
  4. 敏感信息加密:确保商品价格等敏感信息在传输过程中加密

通过以上设计,可以实现一个功能完整、性能良好、安全可靠的SpringBoot购物车系统,满足大多数电商平台的需求。