一个简单的订单模块业务分析

一个简单的订单模块业务分析,第1张

一、订单概念

订单系统作为中枢将信息流,资金流,物流集合起来,在这个环节上需要多个模块的数据和信息,同时对这些信息进行加工处理后流向下一个环节,这一系列构成了订单的信息流通

1.1 订单中心 1.1.1 订单的构成

1.用户信息
包括用户账号,等级,收货信息,个人资料信息等一系列需要用到的个人信息

2,订单基础信息
订单基础信息是订单流转的核心,包括订单类,父、子订单,订单编号,订单状态,订单流转的时间等
2.1 订单类型包括实体商品订单和虚拟商品订单等,根据实际业务区分
2.2 订单需要做父子订单处理,父子订单为后期进行拆单准备的,例如多商户商场,和不同仓库商品的时候

2.3订单编号,父子订单都需要有订单编号,需要完善的时候可以对订单编号每个字段进行统一的定义和诠释

2.4 订单状态,记录订单每次流转过程,

2.5 订单流转时间,记录下单时间,支付时间,发货时间,结束时间,关闭时间等

3 商品信息

4 优惠信息
记录用户参加的优惠活动,包括优惠促销活动,例如满减,满赠,秒杀等,用户使用的优惠券信息,优惠券满足条件的优惠券需要默认展示出来,以及虚拟币抵扣信息的记录等

优惠信息只是记录用户使用的条目,而支付信息需要加入数据进行计算,因此将优惠信息单独拿出来

5 支付信息
5.1 支付流水单号
该单号是唤起网关支付后支付通道返回给电商业务平台的支付流水号,财务通过订单号和流水单号与支付通道进行对账使用
5.2 支付方式

5.3 商品总金额

6 物流信息

1.1.2 订单状态

1.待付款
用户提交订单后,订单进行预下单,主流网站都会唤起支付
待付款状态下可以对库存进行锁定,锁定库存需要配支付超时时间,超时后自动取消订单,订单变为关闭状态

2.已经付款/待发货
完成订单支,订单系统需要记录支付时间,支付流水单号便于对账,订单下放到WMS系统,仓库进行调拨,配货,分拣,出库等 *** 作

3 待收货/已发货
出库后,订单进入物流环节,订单系统需要同步物流信息,便于用户知道物流状态

4 完成订单
确认收获后订单完成,订单各方面信息都进行了归库

5 已取消
用户取消的订单可以收集信息,用于信息统计,以及连接用户消化系统的等

6 售后中
记录售后规则需要用到的信息

1.2 订单流程


1.订单的创建和支付
订单创建前需要预览订单,选择收货信息
创建时要锁定库存,
创建后超时未支付解锁库存
支付后,进行拆单,根据商品打包方式,所在仓库,物流等进行拆单
支付的每笔流水进行纪录,用于查账
订单创建,支付成功等状态要给MQ发送消息,方便其他系统感知订阅

2.逆向流程
2.1 修改订单,用户未提交,对订单一些信息进行修改,例如配送,优惠等信息,

2.2 订单取消,主动取消或超时未支付都会取消,超时是系统自动关闭订单,在订单支付的响应机制上要做支付的限时处理,另外优惠系统的优惠券等规则,需要正确下放到用户

2.3 退款,按照退款类型,敲定号每个流程

1.3 幂等性处理 什么是幂等性

接口幂等性就是用户对于同一 *** 作发起的一次请求或者多次请求结果是一致的,不会因为多次点击产生副作用

哪些情况需要防止

用户多次点击按钮
用户页面回退再提交
微服务互相调用,由于网络问题导致请求失败,feign触发重试机制
其他业务情况

什么情况下需要幂等

以数据库为例, *** 作的数据有固定的标识,那么就是天然幂等的
例如,查询一个固定Id的数据,更新一个固定id的数据,删除一个固定的id,等,无论指定多少次都是一样的,形成幂等
而多次执行会导致数据一致变化的所有 *** 作,都是不幂等的

幂等解决方案 token机制

1.服务端提供了发送token的接口,存在幂等性问题的业务,在执行业务前,先去获取token,服务器将token保存到redis中
2.调用业务接口请求时,把token携带过去,一般放在请求头部
3.服务器判断token是否存在redis中,存在表示第一次请求,然后删除token,继续执行业务
4.token不存在redis中,就表示是重复 *** 作,直接返回重复标记给client,这样保证了业务代码不被重复执行

危险性
1.先删除token还是后删除
先删除可能导致业务确实没有执行,重试还带上之前的token,由于防重设计导致,请求还是不能执行
后删除可能导致,业务处理成功,但是服务闪断,出现超时,没有删除token,别人继续重试,导致业务被执行两遍

2.token获取,比较和删除必须是原子性
redis.get(token), token.equals,redis.del(token)如果这三个 *** 作不是原子性的,可能导致高并发下,都get到同样的数据,判断都成功,继续业务并发
可以在redis中使用lua脚本完成这个 *** 作

if redis.call('get',KEY[1])==ARVG[1] then return redis.call('del',KEYS[1]) else return 0 end
各种锁机制

数据库悲观锁
select * from xxx where id =1 for update;
悲观锁使用一般伴随事务一起使用,数据锁定时间可能会很长,要根据实际情况使用。另外,id字段一定是主键或唯一索引,不然可能造成锁表,非常麻烦。

数据库乐观锁
适合更新场景,给 *** 作加上版本号,重复 *** 作导致版本号不同,sql不会执行

update t_goods set count=count-1,version=version+1 where good_id=2 and version=1

业务层分布式锁
如果多个机器可能在同一时间同时处理相同的数据,例如多台机器定时任务都拿到了相同的数据处理,就可以加分布式锁,锁定此数据,处理完成后释放锁,获取到锁的必须先判断这个数据是否被处理过

各种唯一约束

数据库唯一约束
插入数据按照唯一索引进行插入,例如订单号,相同的订单不可能有两条记录插入
该机制利用了数据库的主键唯一约束特性,但主键的要求不是自增主键,这样就需要业务生成全局唯一的主键
如果是分库分表下,路由规则要保证相同请求下,落地在同一数据库和同一表中,要不然数据库主键约束就不起效果了,因为是不同的数据库和表主键不相关

redis set 防重
很多数据需要处理,只能被处理一次,比如我们可以计算数据的MD5放入redis的set,每次处理数据,先看这个MD5是否存在,存在就不处理
百度网盘的上传机制也是如此,将文件生成MD5值,再去查看是否已经有这个文件,有的话就不上传了,而是直接将已有的文件共享到你的空间

防重表

全局请求唯一id

二、订单业务 2.1 支持框架

redis,spring-session
主要用于登录账户共享

2.2 订单登录拦截

订单的直接通道,是从购物车结算页,点击结算,跳转到订单页
拦截的流程为,在服务添加拦截器,判断cookie是否保存了用户信息,没有保存代表未登录,重定向到登录页面,保存了就放行到指定页面

2.2.1 拦截之前,配置好redis和session框架依赖

lettuce有内存泄露的风险,换成jedis更好

<dependency>
    <groupId>org.springframework.sessiongroupId>
    <artifactId>spring-session-data-redisartifactId>
dependency>
<dependency>
    <groupId>org.springframework.bootgroupId>
    <artifactId>spring-boot-starter-data-redisartifactId>
    <exclusions>
        <exclusion>
            <groupId>io.lettucegroupId>
            <artifactId>lettuce-coreartifactId>
        exclusion>
    exclusions>
dependency>
<dependency>
    <groupId>redis.clientsgroupId>
    <artifactId>jedisartifactId>
dependency>
<dependency>
2.2.2 配置类

1、创建线程池
每个服务都应该创建自己的线程池

@Configuration
//ThreadPoolConfigProperties写了component就进入了容器,不需要再通过指定配置文件配置
//@EnableConfigurationProperties(ThreadPoolConfigProperties.class)
public class MyThreadConfig {
    @Bean
    public ThreadPoolExecutor threadPoolExecutor(ThreadPoolConfigProperties pool){
        return new ThreadPoolExecutor(pool.getCoreSize(),
        								pool.getMaxSize(),
        								pool.getKeepAliveTime(), 
        								TimeUnit.SECONDS,
										new LinkedBlockingDeque<>(100000), 
										Executors.defaultThreadFactory(),
										new ThreadPoolExecutor.AbortPolicy());
    }
}

线程池配置类

@ConfigurationProperties(prefix ="gulimall.thread")
@Component
@Data
public class ThreadPoolConfigProperties {
    private Integer coreSize;
    private Integer maxSize;
    private Integer keepAliveTime;
}

2、Session配置类

@Configuration
public class GulimallSessionConfig {
    @Bean
    public CookieSerializer cookieSerializer(){
        DefaultCookieSerializer cookieSerializer = new DefaultCookieSerializer();
        //明确指定cookie放在父域
        cookieSerializer.setDomainName("gulimall.com");
        cookieSerializer.setCookieName("GULISESSION");
        return cookieSerializer;
    }
    //session的序列化机制
    @Bean
    public RedisSerializer<Object> springSessionDefaultRedisSerializer(){
        return new GenericJackson2JsonRedisSerializer();
    }
}

3、拦截器配置类

@Configuration
public class OrderWebConfiguration implements WebMvcConfigurer {
    @Autowired
    LoginUserInterceptor loginUserInterceptor;
    @Override
    public void addInterceptors(InterceptorRegistry registry) {
        //将自定义拦截器进行注册
        //路径映射为所有请求都进行拦截
        registry.addInterceptor(loginUserInterceptor).addPathPatterns("/**");
    }
}
2.2.3 yml配置文件
spring.session.store-type=redis
gulimall.thread.core-size=20
gulimall.thread.max-size=200
gulimall.thread.keep-alive-time=10

spring.redis.host=1.12.249.69
spring.redis.password=huaxin1994
2.2.4 自定义拦截器
@Component
public class LoginUserInterceptor implements HandlerInterceptor {
    //目标请求到达之前进行前置拦截
    public static ThreadLocal<MemberResVo> loginUser=new ThreadLocal<>();
    @Override
    public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) throws Exception {
        MemberResVo attribute =(MemberResVo) 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.2.5 controller
@Controller
public class OrderWebController {
    @GetMapping("/toTrade")
    public String toTrade(){
        return "confirm";
    }
}
2.2.6 前端登录页面

若用户没有登录,跳转到登录页面后,可以将传过来的msg消息进行显示

<span style="color: red" th:if="${session.msg!=null}"><br/>[[${session.msg}]]</span>
2.3 订单确认模型抽取 2.3.1 订单确认页数据获取

1.需要数据以及数据来源
一个简单的订单确认页需要的数据有
1.收货地址信息
2.选中的会员购物项信息
3.发票
4.优惠券
5.价格
6.订单放重token
此处主要介绍流程,因此主要获取收获地址信息和会员购物项信息
两者都需要进行远程调用


2. 确认数据流程
controller

@Controller
public class OrderWebController {
    @Autowired
    OrderService orderService;
    @GetMapping("/toTrade")
    public String toTrade(Model model){
        OrderConfirmVo vo= orderService.confirmOrder();
        model.addAttribute("orderConfirmData",vo);
        return "confirm";
    }
}

impl

@Service("orderService")
public class OrderServiceImpl extends ServiceImpl<OrderDao, OrderEntity> implements OrderService {
    @Autowired
    MemberFeignService memberFeignService;
    @Autowired
    CartFeignService cartFeignService;
    @Override
    public OrderConfirmVo confirmOrder() {
        OrderConfirmVo confirmVo = new OrderConfirmVo();
        //远程查询收获地址列表
        //能到这个方法代表肯定登录了,登录信息内就有需要的用户id
        MemberResVo memberResVo = LoginUserInterceptor.loginUser.get();
        List<MemberAddressVo> address = memberFeignService.getAddress(memberResVo.getId());
        confirmVo.setAddress(address);
        //远程查询购物车所有选中的购物项
        List<OrderItemVo> items = cartFeignService.getCurrentUserCartItems();
        confirmVo.setItems(items);
        //查询用户积分
        Integer integration = memberResVo.getIntegration();
        confirmVo.setIntegeration(integration);
        //其他数据自动计算
        //TODO 防重令牌
        return confirmVo;
    }
}

*** 3 查询收货地址的远程调用***
feign

@FeignClient("gulimall-member")
public interface MemberFeignService {
    @GetMapping("/member/memberreceiveaddress/{memberId}/addresses")
    List<MemberAddressVo> getAddress(@PathVariable("memberId") Long memberId);
}

member-controller

@RestController
@RequestMapping("member/memberreceiveaddress")
public class MemberReceiveAddressController {
    @GetMapping("/{memberId}/addresses")
    public List<MemberReceiveAddressEntity> getAddress(@PathVariable("memberId") Long memberId){
        return memberReceiveAddressService.getAddress(memberId);
    }
}

impl

    @Override
    public List<MemberReceiveAddressEntity> getAddress(Long memberId) {
        List<MemberReceiveAddressEntity> member = this.list(new QueryWrapper<MemberReceiveAddressEntity>()
                .eq("member_id", memberId));
        return member;
    }

4 查询购物车所有选中购物项
feign

@FeignClient("gulimall-cart")
public interface CartFeignService {
    @GetMapping("/currentUserCartItems")
    List<OrderItemVo> getCurrentUserCartItems();
}

cart-controller

@Controller
public class CartController {
    @Autowired
    CartService cartService;
    @GetMapping("/currentUserCartItems")
    public List<CartItem> getCurrentUserCartItems(){
        return cartService.getUserCartItems();
    }
}

impl
此处的商品价格,一定要查询最新的价格,而不能用缓存中保存的价格,因此还要再远程调用查询一次,逻辑就是通过getById查询出对象,将里面的最新价格回传

@Override
public List<CartItem> getUserCartItems() {
    UserInfoTo userInfoTo = CartInterceptor.threadLocal.get();
    if (userInfoTo.getUserId()==null){
        return null;
    }else {
        String cartKey=CART_PREFIX+userInfoTo.getUserId();
        List<CartItem> cartItems = getCartItems(cartKey);
        //获取所有被选中的购物项
        List<CartItem> collect = cartItems.stream()
                .filter(item -> item.getCheck())
                .map(item->{
                    //TODO 更新为最新价格
                    //还要注意,价格保存在redis中不一定是最新的价格,一定要获取最新的价格
                    BigDecimal price = productFeignService.getPrice(item.getSkuId());
                    item.setPrice(price);
                    return item;
                })
                .collect(Collectors.toList());
        return collect;
    }
}
2.3.2 feign远程调用丢失请求头

上面的远程调用,存在一个很大的问题,由于获取会员地址信息传递了会员id,因此可以直接获取到数据
但是远程获取购物车选中项的逻辑是,不传递参数,希望通过远程方法直接通过保存在cookie的用户登录id,去查询用户所有选中的商品信息
这个地方存在错误的地方是,feign远程调用,默认状态下会丢失请求头信息,因此保存的cookie通过远程调用,被置空,购物车以为没登录,直接返回null了

原因为
远程调用FeignService是一个feign客户端的代理对象,

首先判断是否为equals等公共方法,不是再进行invoke远程调用流程

进入的invoke方法,真正的执行在excuteAndDecode中

excuteAndDecode
该方法先得到请求request,然后利用客户端client去执行

targetRequest中
该处逻辑为起用所用RequestInterceptor
拿所有的request拦截器进行拦截
拦截完后就直接了
因此feign在远程调用之前要构造请求,会调用很多拦截器


如果没有主动配置拦截器,原生的requestTmplate,请求头的是空的

因此feign远程调用的问题就很好解释了,流程为
1.页面调用order.gulimall.com/toTrade,此时请求头肯定带了cookie
2.toTrade的方法进行feign远程调用,查询订单信息的时候
Feign给我们创建了一个新的请求模板request,请求模板里面什么都没有
这就是feign的远程调用问题,默认下Feign远程调用会丢失请求头

为了解决请求头丢失可以加上feign远程调用的请求拦截器

配置类同步请求头

@Configuration
public class GuliFeignConfig {
    @Bean("requestInterceptor")
    public RequestInterceptor requestInterceptor(){
        return new RequestInterceptor(){
            @Override
            public void apply(RequestTemplate requestTemplate) {
                //RequestContextHolder拿到刚进来的这个请求的数据
                // (实际就是拿到调用这个请求的方法,也是就toTrade的request,这个request保存的cookie)
                ServletRequestAttributes attributes =(ServletRequestAttributes) RequestContextHolder.getRequestAttributes();
                //获取到当前请求
                HttpServletRequest request = attributes.getRequest();//老请求
                //同步请求头数据,cookie
                String cookie = request.getHeader("Cookie");
                //给新请求同步了老请求的cookie
                requestTemplate.header("Cookie",cookie);
            }
        };
    }
}


2.3.3 异步编排解决远程调用耗时

config中已经配置了线程池,对于多个需要远程查询的业务需求,应该通过线程池的方式去查询

@Service("orderService")
public class OrderServiceImpl extends ServiceImpl<OrderDao, OrderEntity> implements OrderService {
    @Autowired
    MemberFeignService memberFeignService;
    @Autowired
    CartFeignService cartFeignService;
    @Autowired
    ThreadPoolExecutor executor;
    @Autowired
    WmsFeignService wmsFeignService;
 @Override
    public OrderConfirmVo confirmOrder() throws ExecutionException, InterruptedException {
        OrderConfirmVo confirmVo = new OrderConfirmVo();
        //远程查询收获地址列表
        //能到这个方法代表肯定登录了,登录信息内就有需要的用户id
        MemberResVo memberResVo = LoginUserInterceptor.loginUser.get();
        //获取之前的请求
        RequestAttributes requestAttributes = RequestContextHolder.getRequestAttributes();

        CompletableFuture<Void> getAddressFuture= CompletableFuture.runAsync(()->{
            //每个线程都要共享之前的请求
            RequestContextHolder.setRequestAttributes(requestAttributes);
            List<MemberAddressVo> address = memberFeignService.getAddress(memberResVo.getId());
            confirmVo.setAddress(address);
        },executor);

        CompletableFuture<Void> cartFuture = CompletableFuture.runAsync(()->{
            //每个线程都要共享之前的请求
            RequestContextHolder.setRequestAttributes(requestAttributes);
            //远程查询购物车所有选中的购物项
            List<OrderItemVo> items = cartFeignService.getCurrentUserCartItems();
            confirmVo.setItems(items);
        },executor).thenRunAsync(()->{
            //再次异步,远程查询商品的库存
            List<OrderItemVo> items = confirmVo.getItems();
            List<Long> collect = items.stream().map(item -> item.getSkuId()).collect(Collectors.toList());
            R hasStock = wmsFeignService.getSkuHasStock(collect);
            List<SkuStockVo> data = hasStock.getData(new TypeReference<List<SkuStockVo>>() {
            });
            if (data!=null){
                Map<Long, Boolean> collect1 = data.stream().collect(Collectors.toMap(SkuStockVo::getSkuId, SkuStockVo::getHasStock));
                confirmVo.setStocks(collect1);
            }
        },executor);

        //查询用户积分
        Integer integration = memberResVo.getIntegration();
        confirmVo.setIntegeration(integration);
        //其他数据自动计算

        //TODO 防重令牌
        //使用线程池要要确保他们都完成 *** 作
        CompletableFuture.allOf(getAddressFuture,cartFuture).get();
        return confirmVo;
    }
}
2.3.4 异步模式下feign会丢失上下文

之前的请求流程是
1.先请求订单服务order
2.调用orderService进行处理
此时请求都是在一个线程进行处理,所以订单的controller和service是同一个线程,也就是在订单服务开始时,threadLocal共享了数据
拦截器中想要获取请求的上下文,是用RequestContextHolder获取的,RequestContextHolder是用ThreadLocal获取的,
Feign共享老请求的信息,相当于是在ThreadLocal中共享的,也就是在一个线程内可以共享数据

但开了异步后,获取地址和订单信息已经不再是同一个线程了,开了新的线程
每个请求有他自己的线程,ThreadLocal在OrderService里面,但请求address和cart的异步方法开辟了新线程,他们的拦截器也在自己的线程内,自然无法拿到ThreadLocal的数据


新开辟的线程运行前,都会调用拦截器,而拦截器也是处于新线程中,无法利用RequestContextHolder拿到request,因为此时就是空的,也就导致了空异常

解决方案
在调用异步方法前,将数据f放置进入异步中,传递过去
关键代码如下,执行了这个步骤的异步方法,即使开辟了新线程,RequestAttributes 已经传递过去了,就可以获取到数据了

 //获取之前的请求
 RequestAttributes requestAttributes = RequestContextHolder.getRequestAttributes();

 CompletableFuture<Void> getAddressFuture= CompletableFuture.runAsync(()->{
     //每个线程都要共享之前的请求
     RequestContextHolder.setRequestAttributes(requestAttributes);
2.4 前端页面渲染 2.4.1 页面渲染需求与前端代码

前端页面渲染除了将基本的信息渲染到指定位置,比较重要的几个问题是
1.地址渲染
地址可能保存了多项,要做的逻辑有
1.展示用户的所有的地址且默认地址标红
2.用户点击那个地址,就进行切换[给包裹地址的div标签添加单机事件实现]
3.同时根据地址的不同,显示出不同的邮费[通过addrId进行ajax请求查询到价格]

2.商品信息展示
需要实现的逻辑有
1.将商品信息展示,遍历到指定位置
2.根据库存信息查询是否有货
3.显示商品的总件数与总金额
4.显示最终价格
应付金额=总金额+物流金额
因此前端需要通过ajax获取到物流金额进行相加,得到最终金额

3.前端逻辑代码

-----------------------商品显示地址--------------------------------------
<div class="top-3 addr-item" th:each="addr:${orderConfirmData.address}">
	<p  th:attr="def=${addr.defaultStatus},addrId=${addr.id}">[[${addr.name}]]</p><span>[[${addr.name}]]	[[${addr.province}]]	[[${addr.detailAddress}]]	[[${addr.phone}]]</span>
</div>
-----------------------购买商品信息--------------------------------------
<div class="yun1" th:each="item:${orderConfirmData.items}">
	<img style="width: 50px;height: 100px" th:src="${item.image}" class="yun"/>
	<div class="mi">
		<p>[[${item.title}]] <span style="color: red;">[[${#numbers.formatDecimal(item.price,1,2)}]]</span> <span> x[[${item.count}]] </span> <span>[[${orderConfirmData.stocks[item.skuId]?"有货":"无货"}]]</span></p>
		<p><span>0.095kg</span></p>
		<p class="tui-1"><img src="/static/order/confirm/img/i_07.png" />支持7天无理由退货</p>
	</div>
</div>
-----------------------商品件数与总价格--------------------------------------
<p class="qian_y">
	<span>[[${orderConfirmData.count}]]</span>
	<span>件商品,总商品金额:</span>
	<span class="rmb">[[${#numbers.formatDecimal(orderConfirmData.total,1,2)}]]</span>
</p>

-----------------------应付总额--------------------------------------
<p class="yfze_a">
	<span class="z">应付总额:</span>
	<span class="hq"><b id="payPriceEle">
		[[${#numbers.formatDecimal(orderConfirmData.payPrice,1,2)}]]
		</b> 
	</span>
</p>

---------------------------------------------------------------------
<script>
//文档加载后调用的方法
$(document).ready(function () {
	----------------------------------------------------
	highlight();
	//默认进入页面就需要查询运费,显示默认选中地址的价格,得出总价
	getFare($(".addr-item p[def='1']").attr("addrId"));
})

//放方法给默认地址标记为红色,其他地址表记为灰色
function highlight(){
	//空格p代表查找子元素P
	$(".addr-item p").css({"border":"2px solid gray"})
	$(".addr-item p[def='1']").css({"border":"2px solid red"})
}
//选择地址的时候,选择的是地址的p标签,因此可直接给标签绑定单机事件
$(".addr-item p").click(function (){
	//给当前点击的切换状态,就是将原来的默认状态修改
	//先将所有的自定义def置为0
	$(".addr-item p").attr("def","0");
	//将当前点击的attr置为1
	$(this).attr("def",1);
	highlight();//重新再运行一下高亮代码

	//选中后获取到当前地址id
	var addrId= $(this).attr("addrId");
	//发送ajax请求获取运费信息
	getFare(addrId);
});
function getFare(addrId){
	$.get("http://gulimall.com/api/ware/wareinfo/fare?addrId="+addrId,function (data){
		console.log(data);
		$("#fareEle").text(data.data);
		var total = [[${orderConfirmData.total}]];
		$("#payPriceEle").text(total*1+data.data*1);
	})
}
</script>
2.4.2 后端获取商品总件数与总价格

总件数和总金额都直接在OrderConfirmVo中定义好即可
只要Vo有getCount方法,就代表有count这个属性,前端js直接可以获取到count

//订单确认页需要数据
public class OrderConfirmVo {
    //收货地址,ums_member_receive_address
    @Setter @Getter
    List<MemberAddressVo> address;
    //所有选中的购物项
    @Setter @Getter
    List<OrderItemVo> items;
    //发票。。。。
    //优惠券。。。
    //会员积分信息
    @Setter @Getter
    Integer integeration;
    @Setter @Getter
    Map<Long,Boolean> stocks;
    //BigDecimal total;//订单总额
    //订单防重令牌
    @Getter @Setter
    String orderToken;
    public BigDecimal getTotal() {
        BigDecimal totalNum=BigDecimal.ZERO;
        if (items!=null){
            for (OrderItemVo item : items) {
                BigDecimal multiply = item.getPrice().multiply(new BigDecimal(item.getCount().toString()));
                totalNum = totalNum.add(multiply);
            }
        }
        return totalNum;
    }
    //BigDecimal payPrice;//应付价格
    public BigDecimal getPayPrice() {
        return getTotal();
    }
    public Integer getCount(){
        Integer i = 0;
        if (items!=null){
            for (OrderItemVo item:items){
                i+=item.getCount();
            }
        }
        return i;
    }
}
2.4.3 订单确认库存

查询库存的异步请求通过再次异步,进行商品库存的远程查询,即可获取数据封装

CompletableFuture<Void> cartFuture = CompletableFuture.runAsync(()->{
------------省略查询购物项代码------------
},executor).thenRunAsync(()->{
    //再次异步,远程查询商品的库存
    List<OrderItemVo> items = confirmVo.getItems();
    List<Long> collect = items.stream().map(item -> item.getSkuId()).collect(Collectors.toList());
    R hasStock = wmsFeignService.getSkuHasStock(collect);
    List<SkuStockVo> data = hasStock.getData(new TypeReference<List<SkuStockVo>>() {
    });
    if (data!=null){
        Map<Long, Boolean> collect1 = data.stream().collect(Collectors.toMap(SkuStockVo::getSkuId, SkuStockVo::getHasStock));
        confirmVo.setStocks(collect1);
    }
},executor);

feignClient

@FeignClient("gulimall-ware")
public interface WmsFeignService {
    @PostMapping("/ware/waresku/hasstock")
    public R getSkuHasStock(@RequestBody List<Long> skuids);
}

远程controller

@RestController
@RequestMapping("ware/waresku")
public class WareSkuController {
    @Autowired
    private WareSkuService wareSkuService;
    //查询sku是否有库存
    @PostMapping("/hasstock")
    public R getSkuHasStock(@RequestBody List<Long> skuids){
       List<SkuHasStockVo> vos= wareSkuService.getSkuHasStock(skuids);
        return R.ok().setData(vos);
    }
}

远程impl

@Override
public List<SkuHasStockVo> getSkuHasStock(List<Long> skuids) {
    List<SkuHasStockVo> collect = skuids.stream().map(sku -> {
        SkuHasStockVo vo = new SkuHasStockVo();
        vo.setSkuId(sku);
        //查询当前库存量
        Long count= baseMapper.getSkuStock(sku);
        vo.setHasStock(count==null?false:count>0);
        return vo;
    }).collect(Collectors.toList());
    return collect;
}
2.4.4 为异步查询邮费提供接口

controller

@RestController
@RequestMapping("ware/wareinfo")
public class WareInfoController {
    @Autowired
    private WareInfoService wareInfoService;
    @GetMapping("/fare")
    public R getFare(@RequestParam("addrId") Long addrId){
        BigDecimal fare= wareInfoService.getFare(addrId);
        return R.ok().setData(fare);
    }
}

impl
此处只是将流程走通,实际业务要调用第三方物流接口得到价格

   //根据收货地址计算运费
@Override
public BigDecimal getFare(Long addrId) {
    R info = memberFeignSerivce.info(addrId);
    MemberAddressVo data = info.getData("memberReceiveAddress",new TypeReference<MemberAddressVo>() {
    });
    if (data!=null){
        //实际运费计算要调用第三方接口(快递物流接口)
        String phone = data.getPhone();
        String substring = phone.substring(phone.length() - 1, phone.length());
        return new BigDecimal(substring);
    }
    return null;
}
2.5 订单确认提交

可以使用令牌机制,来保证提交幂等性

2.5.1 订单确认页逻辑

2.5.3 防重令牌设置

在之前的查询订单信息给前端的功能中,将令牌也设置上,一并传递给前端

 @Override
public OrderConfirmVo confirmOrder(){
	//TODO 防重令牌
	String token = UUID.randomUUID().toString().replace("-", "");
	confirmVo.setOrderToken(token);
    redisTemplate.opsForValue().set(OrderConstant.USER_ORDER_TOKEN_PREFIX+memberResVo.getId(),
	        token,
	        30,
	        TimeUnit.MINUTES);
}
2.5.4 前端页面准备好数据,通过表单提交

给提交订单按钮增加from表单,将需要的数据设置好,直接提交到接口来处理
并且在异步方法中将数据设置进入表单
前端提交订单就将所有重要数据发送到后端接口

<form action="http://order.gulimall.com/submitOrder" method="post">
	<input type="hidden" id="addrIdInput" name="addrId">
	<input type="hidden" id="payPriceInput" name="payPrice">
	<input type="hidden" name="orderToken" th:value="${orderConfirmData.orderToken}"/>
	<button class="tijiao" type="submit">提交订单</button>
</form>
-----------------------------------------------------------------------
function getFare(addrId){
	//给表单回填选择的地址
	$("#addrIdInput").val(addrId);
	$.get("http://gulimall.com/api/ware/wareinfo/fare?addrId="+addrId,function (resp){
		console.log(resp);
		$("#fareEle").text(resp.data.fare);
		var total = [[${orderConfirmData.total}]];
		var payPrice = total*1+resp.data.fare*1;
		//设置运费
		$("#payPriceEle").text(payPrice);
		//应付总额要设置给要提交的表单
		$("#payPriceInput").val(payPrice);
		//设置收货地址
		$("#recieveAddressEle").text(resp.data.address.province+" "+resp.data.address.detailAddress);
		//设置收货人
		$("#recieveEle").text(resp.data.address.name);
	})
}
2.5.5 原子验令牌 封装订单提交的数据

订单提交数据
订单提交,会实时从购物车中去获取一次选中的数据,再算一次金额,因此提交的订单确认页无需提交购买的所有商品,去购物车再取一次就可以了

@Data
public class OrderSubmitVo {
    private Long addrId;
    private Integer payType;
    //无需提交要购买的商品,去购物车再获取一次
    private String orderToken;//防重令牌
    private String note;//订单备注
    //用户相关信息直接去session取出登录用户
	//优惠,发票,暂时没做
}

下单成功后回传数据
回传数据包括订单entity和状态码即可

@Data
public class SubmitOrderResponseVo {
    private OrderEntity order;
    private Integer code;//错误状态码
}
controller
@Controller
public class OrderWebController {
    @PostMapping("/submitOrder")
    public String submitOrder(OrderSubmitVo vo){
        //去创建订单,验令牌,验价格,锁库存...
        SubmitOrderResponseVo responseVo =orderService.submitOrder(vo);
        //下单失败回到订单确认页,重新确认订单
        if (responseVo.getCode()==0){
            //下单成功,来到支付选择页
            return "pay";
        }else{
            return "redirect:http://order.gulimall.com/toTrade";
        }
    }
}
普通令牌校验存在问题

如果使用以下方法验证token,存在的问题是,如果一个用户两次订单提交的方法非常快,两次请求都进来以下方法,拿到的令牌都相同,去redis查询的令牌也就都相同,两个对比会都通过,业务代码都会执行
再去删令牌,还是会出现重复提交的问题
因此验证令牌和通过后要优先删除令牌,即令牌的对比和删除必须保证原子性,使用lua脚本可以实现

//验证令牌
String orderToken =	vo.getOrderToken();
String redisToken = redisTemplate.opsForValue().get(...);
if(orderToken!=null&&orderToken.equals(redisToken){
	//令牌验证通过
	redisTemplate.delete(...);
}
脚本实现原子验证
@Override
public SubmitOrderResponseVo submitOrder(OrderSubmitVo vo) {
    SubmitOrderResponseVo response=new SubmitOrderResponseVo();
    //从拦截器拿到当前登录的用户
    MemberResVo memberResVo = LoginUserInterceptor.loginUser.get();
    //验证令牌是否合法【令牌的对比和删除必须保证原子性】
    //ARGV[1]代表我们传递过来的值
    //该脚本成功返回1,失败返回0
    String script="if redis.call('get',KEYS[1]) == ARGV[1] then return redis.call('del',KEYS[1]) else return 0 end";
    String orderToken = vo.getOrderToken();//页面提交的令牌
    /**
     * 参数1 : 脚本与类型
     * 参数2: 对应脚本中KEYS[1]的值,代表要验证的key(当前用户)
     * 参数3: 代表ARGV[1],即要对比的值(页面传递来的值)
     */
    //原子验证令牌和删除令牌
    Long result = redisTemplate.execute(new DefaultRedisScript<Long>(script, Long.class),
            Arrays.asList(OrderConstant.USER_ORDER_TOKEN_PREFIX + memberResVo.getId()), orderToken);
    if (result==0L){
        //令牌验证失败
        return response;
    }else {
        //令牌验证成功
        //下单,去创建订单,验令牌,验价格,锁库存。。。。
    }
    return response;
}

令牌对比存在的问题是,若两次提交的订单非常块





下单流程

欢迎分享,转载请注明来源:内存溢出

原文地址: http://outofmemory.cn/langs/722454.html

(0)
打赏 微信扫一扫 微信扫一扫 支付宝扫一扫 支付宝扫一扫
上一篇 2022-04-26
下一篇 2022-04-26

发表评论

登录后才能评论

评论列表(0条)

保存