• 《谷粒商城》开发记录 12:购物车和订单


    一、购物车

    1 购物车业务

    ● 用户可以在登录状态下将商品添加到购物车,也可以查询、修改、删除购物车中的商品。
    ● 用户可以在未登录状态下将商品添加到离线购物车。即使关闭浏览器,离线购物车中的商品也会保留。用户登录后,系统自动将离线购物车中的商品添加到在线购物车中,然后清空离线购物车。
    ● 用户可以只选中购物车中的一部分商品,选中商品的总价格会实时计算。
    ● 用户可以结算选中的商品并下单。

    2 模型设计

    ● 购物车Cart:
        private List items;  // 商品列表
        private Integer countNum;  // 件数
        private Integer countType;  // 种类数
        private BigDecimal totalAmount;  // 总价
        private BigDecimal reduce = new BigDecimal("0.00");  // 减免价格
    ● 商品CartItem:
        private Long skuId;  // sku ID
        private Boolean check = true;  // 是否选中
        private String title;  // 标题
        private String image;  // 图片
        private List skuAttr;  // sku销售属性
        private BigDecimal price;  // 单价
        private Integer count;  // 数量
        private BigDecimal totalPrice;  // 总价
    ● 当前用户CurrentUser:
        private String  userId;  // 用户ID
        private String  userKey;  // 用户临时key
        private boolean hasTempUser = false;  // cookie中是否包含临时用户
    ● 常量Constant:
        public static final String LOGIN_USER_KEY        = "currentUser";
        public static final String TEMP_USER_COOKIE_NAME = "user-key";

    3 拦截器

    3.1 设置拦截器

    1. 创建购物车拦截器类CartInterceptor。代码示例见3.2节。
        1.1 实现HandlerInterceptor接口。
        1.2 重写preHandle方法,该方法将在执行业务方法前执行。return true则放行,否则拦截。
            1.2.1 业务执行前,从session中获取当前用户currentUser:
                ● currentUser不为null,说明用户已登录。
                ● currentUser为null,说明用户未登录,创建一个临时用户。
            1.2.2 无论是否已登录,给当前用户分配一个游客标识user-key:
                ● 如果request携带的cookies中包含了user-key,就把它分配给当前用户。
                ● 如果cookies中没有user-key,就创建一个uuid作为游客标识分配给当前用户。
                ● 这里的布尔值hasTempUser用作记录cookies中是否包含user-key。
            1.2.3 将当前用户放入threadLocal
            1.2.4 放行。
        1.3 重写postHandle方法,该方法将在执行业务方法后执行。
            1.3.1 从threadLocal中获取当前用户。
            1.3.2 创建cookie。
    2. 配置拦截器。
        @Configuration
        public class GulimallWebConfig implements WebMvcConfigurer {
            @Override
            public void addInterceptors(InterceptorRegistry registry) {
                registry.addInterceptor(new CartInterceptor()).addPathPatterns("/**");
            }
        }

    3.2 代码示例

    购物车拦截器类CartInterceptor:
        public class CartInterceptor implements HandlerInterceptor {
            public static ThreadLocal threadLocal = new ThreadLocal<>();
        
            @Override
            public boolean preHandle(HttpServletRequest request, HttpServletResponse response,
                                 Object handler) throws Exception {
                // 从session获取当前用户, 如果为null, 创建新用户
                HttpSession session = request.getSession();
                CurrentUser currentUser = (CurrentUser) session.getAttribute(LOGIN_USER_KEY);
                if (currentUser == null) {
                    currentUser = new CurrentUser();
                }
                // 给当前用户分配游客标识
                Cookie[] cookies = request.getCookies();
                if (cookies != null && cookies.length > 0) {
                    for (Cookie cookie : cookies) {
                        String cookieName = cookie.getName();
                        if (cookieName.equals(TEMP_USER_COOKIE_NAME)) {
                            currentUser.setUserKey(cookie.getValue());
                            currentUser.setHasTempUser(true);
                            break;
                        }
                    }
                }
                if (StringUtils.isBlank(currentUser.getUserKey())) {
                    String uuid = UUID.randomUUID().toString();
                    currentUser.setUserKey(uuid);
                }
                // 向threadLocal中添加当前用户
                threadLocal.set(currentUser);
                // 放行
                return true;
            }
            
            @Override
            public void postHandle(HttpServletRequest request, HttpServletResponse response, Object handler,
                               ModelAndView modelAndView) throws Exception {
                // 从threadLocal中获取当前用户
                CurrentUser currentUser = threadLocal.get();
                // 创建cookie
                if (!currentUser.isHasTempUser()) {
                    Cookie cookie = new Cookie(TEMP_USER_COOKIE_NAME, currentUser.getUserKey());
                    cookie.setDomain("xxx.com");
                    cookie.setMaxAge(86400 * 30);
                    response.addCookie(cookie);
                }
            }
        }

    4 实现购物车功能

    购物车数据保存在Redis中,数据类型为hash:

    使用redisTemplate通过cartKey绑定购物车。这里的operations是对Redis的一组操作(增删改查)的集合。
        private BoundHashOperations getCartOps() {
            CurrentUser currentUser = CartInterceptor.threadLocal.get();
            String cartKey = "";
            if (currentUser.getUserId() != null) {
                // CART_PREFIX = "gulimall:cart:"
                cartKey = CART_PREFIX + userInfoTo.getUserId();
            } else {
                cartKey = CART_PREFIX + userInfoTo.getUserKey();
            }
            BoundHashOperations operations = redisTemplate.boundHashOps(cartKey);
            return operations;
        }

    对购物车中的商品进行增删改查:
        BoundHashOperations cartOps = getCartOps();
        ● 增、改
           cartOps.put(skuId.toString(), JSON.toJSONString(cartItem));
        ● 删
           cartOps.delete(skuId.toString());
        ● 查
           cartOps.get(skuId.toString());

    二、订单

    1 订单业务

    电商系统涉及到3流,分别是信息流、资金流、物流,订单系统作为中枢将三者有机地结合起来。

    1.1 订单构成

    ● 用户信息。包括用户账号、用户等级、收货地址、收货人、收货人手机号等。
    ● 订单基础信息。包括订单编号、订单状态、订单流转时间、订单类型、父/子订单等。
        其中订单流转时间包括下单时间、支付时间、发货时间、关闭时间等,订单类型包括实体商品订单和虚拟商品订单等。
    ● 商品信息。
    ● 优惠信息。
    ● 支付信息。包括支付流水单号、支付方式、商品总金额、运费、优惠金额、实付金额等。
        用户实付金额=商品总金额+运费-优惠金额。
    ● 物流信息。包括物流单号、物流公司、物流状态等。

    1.2 订单状态

    ● 待付款。用户提交订单后,系统创建一个待付款状态的订单。
    ● 已付款/待发货。用户完成订单支付后,订单变更为已付款/待发货状态。
    ● 已发货/待收货。商家将商品出库后,订单进入物流环节,订单变更为已发货/待收货状态。
    ● 已完成。用户确认收货后,订单变更为已完成状态。
    ● 已取消。订单超时未支付,或用户、商家中的一方取消了订单,订单变更为已取消状态。
    ● 售后中。用户在付款后申请退款,或商家发货后用户申请退换等,订单变更为售后中状态。

    1.3 订单流程

    1. 用户下单。在购物车点"去结算",构建订单确认对象OrderConfirmVO(见2.4节 模型设计,下同),进入订单确认页。
        1.1 从session中获取当前用户的基本信息。如果获取不到,跳转到系统登录页。
        1.2 构建订单确认对象OrderConfirmVO。
            1.2.1 调用用户服务,获取当前用户的收货地址、收货人等信息。
            1.2.2 调用购物车服务,获取用户购买的商品的信息。
            1.2.3 调用优惠券服务,获取当前用户可用的优惠券。
            1.2.4 计算用户应付金额。
            1.2.5* 生成防重令牌,保证订单提交的幂等性。(见3.1节 防重令牌)
        1.3 向页面返回订单确认对象OrderConfirmVO。
    2. 订单提交。用户在订单确认页点"提交订单"后,根据页面提交表单生成订单提交对象OrderSubmitVO,提交订单。如果下单成功,系统跳转到支付页,否则返回订单确认页。
        2.1 验证防重令牌。如果验证成功,删除令牌,如果验证失败,则下单失败。
        2.2 构建订单创建对象OrderCreateTO。
            2.2.1 生成订单对象OrderEntity。
                2.2.1.1 调用仓储服务,计算运费。(比较复杂,该项目这里使用了随机数)
                2.2.1.2 设置收货人信息。
                2.2.1.3 设置其他信息(订单状态等)。
            2.2.2 调用购物车服务,获取所有订单项信息。
            2.2.3 验价。比较OrderSubmitVO携带的用户应付金额 和 所有订单项总价格,如果验价失败,则下单失败。
        2.3 保存订单数据。
        2.4 调用仓储服务,
        2.5* 远程锁定库存。为用户预留商品,其他人无法再购买。传参仓储锁库存对象WareSkuLockVO,返回锁库存结果LockStockResult。(见4.1节 远程锁定库存)
            2.5.1 保存任务单信息。
            2.5.2 查询商品在哪些仓库有库存,返回一个仓库ID列表。如果都没有,则下单失败。
            2.5.3 锁定库存。如果锁库存失败,则抛出异常,下单失败。
    3. 订单支付。
        3.1 订单支付失败,释放订单、解锁库存。订单支付失败的场景有:
            ● 用户未支付,订单自动取消。
            ● 用户手动取消。
            ● 锁定库存后,业务调用失败,订单回滚。
        3.2* 调用第三方服务支付,如支付宝支付。(见5.1节 整合支付宝支付)
        3.3 支付成功。
        3.4 拆单、记录支付流水等。
        3.5 订单下库,开始物流。
    4. 物流。商品出库、物流跟踪、订单签收等。不做过多介绍。
    5. 售后。退款申请、退货物流、退货入库、退款等。不做过多介绍。

    2 准备工作

    2.1 整合Redis

    1. 引入依赖。
        groupId: org.springframework.boot
        artifactId: spring-boot-starter-data-redis
    2. 在配置文件中添加配置。
        spring.redis.host=192.168.56.10

    2.2 整合Spring Session

    1. 引入依赖。
        groupId: org.springframework.session
        artifactId: spring-session-data-redis
    2. 在配置文件中添加配置。
        spring.session.store-type=redis
    3. 使用注解开启session服务。
        在启动类上添加注解@EnableRedisHttpSession。
    4. 配置session。
        @Configuration
        public class GulimallSessionConfig{
            @Bean
            public CookieSerializer cookieSerializer(){
                DefaultCookieSerializer cookieSerializer = new DefaultCookieSerializer();
                cookieSerializer.setDomainName("gulimall.com");
                cookieSerializer.setCookieName("GULISESSION");
                return cookieSerializer;
            }
            @Bean
            public RedisSerializer springSessionDefaultRedisSerializer(){
                return new GenericJackson2JsonRedisSerializer();
            }
        }

    2.3 注入线程池

    1. 创建线程池属性类。
        @ConfigurationProperties(prefix="gulimall.thread")
        @Component
        @Getter
        @Setter
        public class ThreadPoolConfigProperties{
            private Integer coreSize;
            private Integer maxSize;
            private Integer keepAliveTime;
            private Integer blockingDequeSize;
        }
    2. 在配置文件中设置线程池参数。
        gulimall.thread.core-size=20
        gulimall.thread.max-size=200
        gulimall.thread.keep-alive-time=10
        gulimall.thread.blocking-deque-size=100000
    3. 注入线程池。
        @Configuration
        public class MyThreadConfig{
            @Bean
            public ThreadPoolExecutor threadPoolExecutor(ThreadPoolConfigProperties properties){
                return new ThreadPoolExecutor(properties.getCoreSize(), properties.getMaxSize(), properties.getKeepAliveTime(), TimeUnit.SECONDS, new LinkedBlockingDeque<>(properties.getBlockingDequeSize()), new ThreadPoolExecutor.AbortPolicy());
            }
        }

    2.4 模型设计

    ● 订单购物项OrderItemVO:
        private Long skuId;  // SKU ID
        private String title;  // 标题
        private String image;  // 图片(地址)
        private List skuAttr;  // SKU属性
        private BigDecimal price;  // 单价
        private Integer count;  // 数量
        private BigDecimal totalPrice;  // 总价=单价*数量,重写get方法
    ● 订单确认对象OrderConfirmVO:
        private List addressList;  // 收货地址列表
        private List itemList;  // 订单购物项列表
        // TODO 优惠券列表
        // TODO public BigDecimal getPayPrice(){ }  // 计算用户应付金额
        private String orderToken;  // 订单令牌
    ● 订单提交对象OrderConfirmVO:
        private Long addrId;  // 收货地址的ID
        private Integer payType;  // 支付类型
        // TODO 使用的优惠券
        private String orderToken;  // 防重令牌
        private BigDecimal payPrice;  // 应付价格,需要验价
        private String note;  // 订单备注
    ● 订单创建对象OrderCreateTO:
        private OrderEntity order;  // 订单
        private List orderItemList;  // 订单项列表
        private BigDecimal payPrice;  // 订单应付价格,用来验价
        private BigDecimal fare;  // 运费
    ● 仓储锁库存对象WareSkuLockVO:
        private String orderSn;  // 订单号
        private List lockList;  // 需要锁的订单项列表
    ● 锁库存结果LockStockResult:
        private Long skuId;  // SKU ID
        private Integer num;  // 锁定数量
        private boolean locked;  // 是否已锁定
    ● 库存锁定对象StockLockedTO:
        private Long taskId;  // 任务ID
        private StockDetailTO stockDetailTO;  // 库存详情
    ● 库存详情对象StockDetailTO:
        private Long id;  // ID
        private Long skuId;  // SKU ID
        private String skuName;  // SKU名称
        private Integer skuNum;  // (购买的)SKU数目
        private Long taskId;  // 任务ID
        private Long wareId;  // 仓库ID
        private Integer lockStatus;  // 锁定状态(SKU已锁定数目)

    2.5 设置拦截器

    1. 创建登录用户拦截器类LoginUserInterceptor。
        @Component
        public class LoginUserInterceptor implements HandlerInterceptor{
            public static ThreadLocal loginUser = new ThreadLocal<>();
            
            @Override
            public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) throws Exception{
                MemberRespVO attribute = (MemberRespVO) request.getSession().getAttribute(AuthServerConstant.LOGIN_USER);
                if(attribute != null){
                    loginUser.set(attribute);
                    return true;
                } else{
                    request.getSession().setAttribute("msg", "请您先进行登录");
                    response.sendRedirect("http://auth.gulimall.com/login.html");
                    return false;
                }
            }
        }
    2. 配置拦截器。
        @Configuration
        public class MyWebConfig implements WebMvcConfigurer {
            @Autowired
            LoginUserInterceptor loginUserInterceptor;
            
            @Override
            public void addInterceptors(InterceptorRegistry registry) {
                registry.addInterceptor(loginUserInterceptor).addPathPatterns("/**");
            }
        }

    2.6 整合RabbitMQ

    1. 引入依赖。
        groupId: org.springframework.boot
        artifactId: spring-boot-starter-amqp
    2. 在配置文件中添加配置。
        spring.rabbitmq.host=192.168.56.10
        spring.rabbitmq.port=5672
        spring.rabbitmq.virtual-host=/
    3. 在服务启动类上添加@EnableRabbit注解。
    4. 注入RabbitMQ组件。
        @Configuration
        public class MyRabbitMQConfig{
            /**
             * 消息转换器(使用JSON格式序列化)
             */
            @Bean
            public MessageConverter messageConverter(){
                return new Jackson2JsonMessageConverter();
            }
            /**
             * 订单事件交换器
             */
            @Bean
            public Exchange orderEventExchange(){
                return new TopicExchange("order-event-exchange", true, false);
            }
            /**
             * 订单解锁队列
             */
            @Bean
            public Queue orderReleaseOrderQueue(){
                return new Queue("order.release.order.queue", true, false, false);
            }
            /**
             * 订单延时队列
             */
            @Bean
            public Queue orderDelayQueue(){
                Map arguments = new HashMap<>();
                arguments.put("x-dead-letter-exchange", "order-event-exchange");
                arguments.put("x-dead-letter-routing-key", "order.release.order");
                arguments.put("x-message-ttl", 60000);
                return new Queue("order.delay.queue", true, false, false, arguments);
            }
            /**
             * 订单创建绑定关系
             */
            @Bean
            public Binding orderCreateOrderBinding(){
                return new Binding("order.delay.queue", Binding.DestinationType.QUEUE, "order-event-exchange", "order.create.order", null);
            }
            /**
             * 订单释放绑定关系
             */
            @Bean
            public Binding orderReleaseOrderBinding(){
                return new Binding("order.release.order.queue", Binding.DestinationType.QUEUE, "order-event-exchange", "order.release.order", null);
            }
        }

    2.7 仓储服务准备工作

    1. 整合Spring Session。
    2. 设置拦截器。
    3. 整合RabbitMQ。
    4. 注入RabbitMQ组件。
        @Configuration
        public class MyRabbitMQConfig{
            /**
             * 消息转换器(使用JSON格式序列化)
             */
            @Bean
            public MessageConverter messageConverter(){
                return new Jackson2JsonMessageConverter();
            }
            /**
             * 仓储事件交换器
             */
            @Bean
            public Exchange stockEventExchange(){
                return new TopicExchange("stock-event-exchange", true, false);
            }
            /**
             * 仓储释放库存队列
             */
            @Bean
            public Queue stockReleaseStockQueue(){
                return new Queue("stock.release.stock.queue", true, false, false);
            }
            /**
             * 仓储延时队列
             */
            @Bean
            public Queue stockDelayQueue(){
                Map arguments = new HashMap<>();
                arguments.put("x-dead-letter-exchange", "stock-event-exchange");
                arguments.put("x-dead-letter-routing-key", "stock.release");
                arguments.put("x-message-ttl", 120000);
                return new Queue("stock.delay.queue", true, false, false, arguments);
            }
            /**
             * 仓储锁定绑定关系
             */
            @Bean
            public Binding stockLockedBinding(){
                return new Binding("stock.delay.queue", Binding.DestinationType.QUEUE, "stock-event-exchange", "stock.locked", null);
            }
            /**
             * 仓储释放绑定关系
             */
            @Bean
            public Binding stockReleaseBinding(){
                return new Binding("stock.release.stock.queue", Binding.DestinationType.QUEUE, "stock-event-exchange", "stock.release.#", null);
            }
        }

    3 用户下单

    3.1 防重令牌

    用户在购物车点"去结算"以后,系统会生成一个防重令牌,存入Redis,并随着订单确认对象OrderConfirmVO返回到页面。
    当用户确认过订单信息 点"提交订单"时,在前端将订单令牌放到订单提交对象OrderConfirmVO的orderToken属性中。
    在后端验证这个订单令牌:与Redis中存的令牌做比对,如果验证失败,则提交订单失败,如果验证成功,删除Redis中的数据,这样,订单服务再一次收到相同的请求时,也会验证失败。这样就保证了请求的幂等性。

    这里需要注意的是,token的获取、比较和删除三个操作必须具备原子性,可以通过在Redis中执行LUA脚本来实现。
    脚本示例:
        if redis.call('get', KEYS[1]) == ARGV[1] then return redis.call('del', KEYS[1]) else return 0 end

    4 订单提交

    4.1 锁定库存

    锁定库存的核心SQL:
        update 'wms_ware_sku'
        set stock_locked = stock_locked + #{num}
        where sku_id = #{skuId}
        and ware_id = #{wareId}
        and stock - stock_locked >= #{num}

    锁定库存的全流程为:
    1. 保存任务单。
    2. 保存任务单详情。
    3. 尝试锁定库存。如果锁定成功,就修改锁定状态,如果锁定失败,回滚前两步操作。
    可以看出,远程锁定库存必须是事务操作。
    ● 本地事务注解:@Transactional
    ● Seata提供的分布式事务注解:@GlobalTransactional

    锁定库存成功后,向RabbitMQ发送一条消息(存入延时队列),一段时间后用这条消息释放订单、解锁库存。
    发送的消息是一个库存锁定对象StockLockedTO,见2.4节 模型设计。

    5 订单支付

    5.1 整合支付宝支付

    进入蚂蚁金服开放平台,按照接入指南接入支付宝支付功能。
        https://opendocs.alipay.com/open/270/105898
    提示:必须保证系统字符集是UTF-8,否则在加密时可能会出错。

    5.2 内网穿透

    支付完成后的跳转页面必须是公网可访问的。如果服务器所在的网络是局域网,就必须使用内网穿透技术,使外网的用户可以访问处于内网的服务器。

    【软件】续断内网穿透:https://www.zhexi.tech/

    三、其他

    1 Feign远程调用时请求头丢失

    浏览器给订单服务发送请求时,请求头自动带了cookie,cookie中可能记录了用户的登录状态。
    当订单服务使用Feign调用远程服务时,会构造一个新的请求发送给远程服务,新请求的请求头中自然是没有原来的cookie的,所以远程服务无法检测到用户的登录状态。

    解决方法是注入一个请求拦截器,这个拦截器的功能是:
    1. 获取浏览器请求的cookie。
    2. 给每一个发出去的请求带上cookie。

    代码示例:
        @Configuration
        public class GulimallRequestInterceptorConfig{
            @Bean("requestInterceptor")
            public RequestInterceptor requestInterceptor(){
                return new RequestInterceptor(){
                    @Override
                    public void apply(RequestTemplate template){
                        ServletRequestAttributes attributes = (ServletRequestAttributes) RequestContextHolder.getRequestAttributes();
                        HttpServletRequest request = attributes.getRequest();
                        String cookie = request.getHeader("Cookie");
                        template.header("Cookie", cookie);
                    }
                }
            }
        }

    2 异步执行时上下文丢失

    创建异步任务时,异步线程无法自动获取原线程ThreadLocal中的信息。

    解决方法是手动从原线程中获取上下文信息,set到异步线程中。
    1. 创建异步任务之前,获取原线程请求属性。
        RequestAttributes requestAttributes = RequestContextHolder.getRequestAttributes();
    2. 创建异步任务时,把原线程请求属性set到异步线程中。
        RequestContextHolder.setRequestAttributes(requestAttributes);

  • 相关阅读:
    代码简洁之道(译)
    奶牛排序问题
    Nodejs安装及其他事项
    MongoDB是什么?非关系型数据库的优点?安装使用教程
    kubernetes自定义hosts域名解析
    《Hierarchical Text-Conditional Image Generation with CLIP Latents》阅读笔记
    中标麒麟国产服务器安装MinIO报错不能读取该二进制文件解决方案
    完整、免费的把pdf转word文档
    入门Docker你不得不读的基础知识
    2023/5/23总结
  • 原文地址:https://blog.csdn.net/qq_42082161/article/details/126150717