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

一、购物车

1 购物车业务

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

2 模型设计

● 购物车Cart:
    private List<CartItem> 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<String> 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<CurrentUser> 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: <skuId, JSON_cartItem>。

使用redisTemplate通过cartKey绑定购物车。这里的operations是对Redis的一组操作(增删改查)的集合。
    private BoundHashOperations<String, Object, Object> 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<String, Object, Object> operations = redisTemplate.boundHashOps(cartKey);
        return operations;
    }

对购物车中的商品进行增删改查:
    BoundHashOperations<String, Object, Object> 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<Object> 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<String> skuAttr;  // SKU属性
    private BigDecimal price;  // 单价
    private Integer count;  // 数量
    private BigDecimal totalPrice;  // 总价=单价*数量,重写get方法
● 订单确认对象OrderConfirmVO:
    private List<MemberAddressVO> addressList;  // 收货地址列表
    private List<OrderItemVO> 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<OrderItemEntity> orderItemList;  // 订单项列表
    private BigDecimal payPrice;  // 订单应付价格,用来验价
    private BigDecimal fare;  // 运费
● 仓储锁库存对象WareSkuLockVO:
    private String orderSn;  // 订单号
    private List<OrderItemVO> 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<MemberRespVO> 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<String, Object> 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<String, Object> 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);

  • 1
    点赞
  • 3
    收藏
    觉得还不错? 一键收藏
  • 0
    评论
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值