2.6 购物车服务
2.6.1 环境搭建
①域名配置
②创建 微服务
暂时需要的插件
- 此外,导入 公共包的依赖
<dependency>
<groupId>com.atguigu.gulimall</groupId>
<artifactId>gulimall-common</artifactId>
<version>0.0.1-SNAPSHOT</version>
</dependency>
- application.properties配置
server.port=40000
spring.application.name=gulimall-cart
spring.cloud.nacos.discovery.server-addr=127.0.0.1:8848
- 主启动类加上nacos服务发现注册注解,以及后面需要用到的远程服务注解,并且因为导入了common包,需要暂时排除数据库自动配置
@EnableFeignClients
@EnableDiscoveryClient
@SpringBootApplication(exclude = DataSourceAutoConfiguration.class)
③动静分离
- 静态资源放到 Nginx中
- 动态页面放到 templates下。
- 页面前缀替换:
- 网关配置
- id: gulimall_cart_route
uri: lb://gulimall-cart
predicates:
- Host=cart.gulimall.com
⑦测试,为了方便,将success页面重命名为index页面
出现问题,替换:将 th:替换成空字符串
前端页面跳转
点击 图标等能返回首页。
2.6.2 数据模型分析
1、购物车需求
1)、需求描述:
- 用户可以在登录状态下将商品添加到购物车【用户购物车/在线购物车】
- 放入数据库
- mongodb
- 放入redis(采用)
登录以后,会将临时购物车的数据全部合并过来,并清空临时购物车;
- 用户可以在未登录状态下将商品添加到购物车【游客购物车/离线购物车/临时购物车】
- 放入localstorage(客户端存储,后台不存)
- cookie
- WebSQL
- 放入redis(采用)
浏览器即使关闭,下次进入,临时购物车数据都在
- 用户可以使用购物车一起结算下单
- 给购物车添加商品
- 用户可以查询自己的购物车
- 用户可以在购物车中修改购买商品的数量。
- 用户可以在购物车中删除商品。
- 选中不选中商品
- 在购物车中展示商品优惠信息
- 提示购物车商品价格变化
2)、数据结构
因此每一个购物项信息,都是一个对象,基本字段包括:
{
skuId: 2131241,
check: true,
title: "Apple iphone.....",
defaultImage: "...",
price: 4999,
count: 1,
totalPrice: 4999,
skuSaleVO: {...}
}
另外,购物车中不止一条数据,因此最终会是对象的数组。即:
[
{...},{...},{...}
]
Redis 有5 种不同数据结构,这里选择哪一种比较合适呢?Map<String, List>
- 首先不同用户应该有独立的购物车,因此购物车应该以用户的作为key 来存储,Value 是
用户的所有购物车信息。这样看来基本的k-v
结构就可以了。 - 但是,我们对购物车中的商品进行增、删、改操作,基本都需要根据商品id 进行判断,
为了方便后期处理,我们的购物车也应该是k-v
结构,key 是商品id,value 才是这个商品的
购物车信息。
综上所述,我们的购物车结构是一个双层Map:Map<String,Map<String,String>>
- 第一层Map,Key 是用户id
- 第二层Map,Key 是购物车中商品id,值是购物项数据
将购物车中的购物项存为list类型的话,修改起来太麻烦要从头到尾遍历。可以使用hash来存储购物车中的购物项
2.6.3 vo编写
购物项的Vo编写
/**购物项内容
*/
public class CartItem {
private Long skuId;
private Boolean check = true;//是否被选中
private String title;//标题
private String image;//图片
private List<String> skuAttr;//销售属性组合描述
private BigDecimal price;//商品单价
private Integer count;//商品数量
private BigDecimal totalPrice;//总价,总价需要计算
public Long getSkuId() {
return skuId;
}
public void setSkuId(Long skuId) {
this.skuId = skuId;
}
public Boolean getCheck() {
return check;
}
public void setCheck(Boolean check) {
this.check = check;
}
public String getTitle() {
return title;
}
public void setTitle(String title) {
this.title = title;
}
public String getImage() {
return image;
}
public void setImage(String image) {
this.image = image;
}
public List<String> getSkuAttr() {
return skuAttr;
}
public void setSkuAttr(List<String> skuAttr) {
this.skuAttr = skuAttr;
}
public BigDecimal getPrice() {
return price;
}
public void setPrice(BigDecimal price) {
this.price = price;
}
public Integer getCount() {
return count;
}
public void setCount(Integer count) {
this.count = count;
}
/**
* 计算当前项的总价
* @return
*/
public BigDecimal getTotalPrice() {
return this.price.multiply(new BigDecimal("" + this.count));
}
public void setTotalPrice(BigDecimal totalPrice) {
this.totalPrice = totalPrice;
}
}
编写购车Vo
/**整个购物车
* 需要计算的属性,必须重写它的get方法,保证每次获取属性都会进行计算
*/
public class Cart {
List<CartItem> items;
private Integer countNum;//商品数量
private Integer countType;//商品类型数量
private BigDecimal totalAmount;//商品总价
private BigDecimal reduce = new BigDecimal("0.00");//减免价格
public List<CartItem> getItems() {
return items;
}
public void setItems(List<CartItem> items) {
this.items = items;
}
public Integer getCountNum() {
int count = 0;
if (items !=null && items.size()>0){
for (CartItem item : items) {
count += item.getCount();
}
}
return count;
}
public Integer getCountType() {
int count = 0;
if (items !=null && items.size()>0){
for (CartItem item : items) {
count += 1;
}
}
return count;
}
public BigDecimal getTotalAmount() {
BigDecimal amount = new BigDecimal("0");
//1、计算购物项总价
if (items !=null && items.size()>0){
for (CartItem item : items) {
BigDecimal totalPrice = item.getTotalPrice();
amount = amount.add(totalPrice);
}
}
//2、减去优惠总价
BigDecimal subtract = amount.subtract(getReduce());
return subtract;
}
public void setTotalAmount(BigDecimal totalAmount) {
this.totalAmount = totalAmount;
}
public BigDecimal getReduce() {
return reduce;
}
public void setReduce(BigDecimal reduce) {
this.reduce = reduce;
}
}
2.6.4 ThreadLocal用户身份鉴别
1.将购物车数据存储至Redis中,因此,需要导入Spring整合Redis的依赖以及Redis的配置。项目上线之后,应该有一个专门的Redis负责存储购物车的数据不应该使用缓存的Redis
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-data-redis</artifactId>
</dependency>
配置redis地址
spring.redis.host=192.168.56.10
2.编写服务层
@Slf4j
@Service
public class CartServiceImpl implements CartService {
@Autowired
private StringRedisTemplate stringRedisTemplate;
}
3.判断用户是否登录则通过判断Session中是否有用户的数据,因此,导入SpringSession的依赖
<dependency>
<groupId>org.springframework.session</groupId>
<artifactId>spring-session-data-redis</artifactId>
</dependency>
配置Session
/**自定义SpringSession完成子域session共享
* @author wystart
* @create 2022-12-06 21:48
*/
@EnableRedisHttpSession
@Configuration
public class GulimallSessionConfig {
//子域共享问题解决
@Bean
public CookieSerializer cookieSerializer() {
DefaultCookieSerializer cookieSerializer = new DefaultCookieSerializer();
cookieSerializer.setDomainName("gulimall.com");// 扩大session作用域,也就是cookie的有效域
cookieSerializer.setCookieName("GULISESSION");
return cookieSerializer;
}
// 默认使用jdk进行序列化机制,这里我们使用json序列化方式来序列化对象数据到redis中
@Bean
public RedisSerializer<Object> springSessionDefaultRedisSerializer() {
return new GenericJackson2JsonRedisSerializer();
}
}
4. cookie中的user-key说明
第一次访问京东,会给你的cookie中设置user-key标识你的身份,有效期为一个月,浏览器会保存你的user-key,以后访问都会带上
*浏览器有一个cookie;user-key;标识用户身份,一个月后个过期;
* 如果第一次使用jd的购物车功能,都会给一个临时的用户身份;
* 浏览器以后保存,每次访问都会带上这个cookie;
*
*
* 登录:session有
* 没登录:按照cookie里面带来user-key来做。
* 第一次:如果没有临时用户,帮忙创建一个临时用户。
*
5.编写To与常量
购物车服务下
@ToString
@Data
public class UserInfoTo {
private Long userId;
private String userKey;//一定要封装
private boolean tempUser = false;//标识位
}
公共服务下:新建 CartConstant
public class CartConstant {
public static final String TEMP_USER_COOKIE_NAME = "user-key";
//过期时间为1一个月
public static final int TEMP_USER_COOKIE_TIMEOUT = 60*60*24*30;//临时用户的过期时间
}
6.编写拦截器
拦截器逻辑:业务执行之前,判断是否登录,若登录则封装用户信息,将标识位设置为true,postHandler就不再设置作用域和有效时间,否则为其创建一个user-key
注意细节:整合SpringSession之后,Session获取数据都是从Redis中获取的
使用ThreadLocal,解决线程共享数据问题,方便同一线程共享UserInfoTo
编写拦截器实现类
/**在执行目标方法之前,判断用户的登录状态,并封装传递给controller目标请求
* @author wystart
* @create 2022-12-08 20:58
*/
public class CartInterceptor implements HandlerInterceptor {
public static ThreadLocal<UserInfoTo> threadLocal = new ThreadLocal<>();
/**
* 目标方法执行之前
* @param request
* @param response
* @param handler
* @return
* @throws Exception
*/
@Override
public boolean preHandle(HttpServletRequest request, HttpServletResponse response,
Object handler) throws Exception {
UserInfoTo userInfoTo = new UserInfoTo();
HttpSession session = request.getSession();
MemberRespVo member = (MemberRespVo) session.getAttribute(AuthServerConstant.LOGIN_USER);
if (member != null){
//用户登录
userInfoTo.setUserId(member.getId());
}
Cookie[] cookies = request.getCookies();
if (cookies != null && cookies.length > 0){
for (Cookie cookie : cookies) {
//user-key
String name = cookie.getName();
if (name.equals(CartConstant.TEMP_USER_COOKIE_NAME)){
userInfoTo.setUserKey(cookie.getValue());
}
}
}
//如果没有临时用户一定分配一个临时用户
if (StringUtils.isEmpty(userInfoTo.getUserKey())){
String uuid = UUID.randomUUID().toString();
userInfoTo.setUserKey(uuid);
}
//目标方法执行之前
threadLocal.set(userInfoTo);
return true;
}
/**
* 业务执行之后;分配临时用户,让浏览器保存
* @param request
* @param response
* @param handler
* @param modelAndView
* @throws Exception
*/
@Override
public void postHandle(HttpServletRequest request, HttpServletResponse response,
Object handler, ModelAndView modelAndView) throws Exception {
UserInfoTo userInfoTo = threadLocal.get();
//如果没有临时用户一定保存一个临时用户
if (!userInfoTo.isTempUser()){
//持续的延长临时用户的过期时间
Cookie cookie = new Cookie(CartConstant.TEMP_USER_COOKIE_NAME, userInfoTo.getUserKey());
cookie.setDomain("gulimall.com");
cookie.setMaxAge(CartConstant.TEMP_USER_COOKIE_TIMEOUT);
response.addCookie(cookie);
}
}
}
配置拦截器,否则拦截器不生效
@Configuration
public class GulimallWebConfig implements WebMvcConfigurer {
@Override
public void addInterceptors(InterceptorRegistry registry) {
//配置CartInterceptor拦截器拦截所有请求
registry.addInterceptor(new CartInterceptor()).addPathPatterns("/**");
}
}
@Controller
public class CartController {
/**
*浏览器有一个cookie;user-key;标识用户身份,一个月后个过期;
* 如果第一次使用jd的购物车功能,都会给一个临时的用户身份;
* 浏览器以后保存,每次访问都会带上这个cookie;
*
*
* 登录:session有
* 没登录:按照cookie里面带来user-key来做。
* 第一次:如果没有临时用户,帮忙创建一个临时用户。
*
* @return
*/
@GetMapping("/cart.html")
public String cartListPage(){
//1、快速得到用户信息,id,user-key
UserInfoTo userInfoTo = CartInterceptor.threadLocal.get();
System.out.println(userInfoTo);
return "cartList";
}
}
Debug测试UserInfoTo中是否有数据
user-key也有
2.6.5 页面环境搭建
- 首页点击购物车去购物车页面
index.html
- 检索页面点击 我的购物车去购物车页面
list.html
- 商品详情页修改
item.html
CartController
/**
* 添加商品到购物车
* @return
*/
@GetMapping("/addToCart")
public String addToCart(){
return "success";
}
- 加入商品成功后,跳转到购物车列表页面
success.html
这里“查看商品详情暂时写死了”!!
- 购物车详情页面
cartList.html
2.6.6 添加购物车
编写添加商品进入购物车的请求方法,需要知道商品的SkuId和数量
为加入购物车绑定单击事件,url改为#避免跳转并且设置id;为超链接自定义属性,用于存储skuId
<a href="#" id="addToCartA" th:attr="skuId=${item.info.skuId}">
加入购物车
</a>
为文本框设置id
编写单击事件 ,$(this)指当前实例,return false : 禁止默认行为
$("#addToCartA").click(function (){
var num = $("#numInput").val();//获取数量
var skuId = $(this).attr("skuId");//获取商品的skuId
location.href = "http://cart.gulimall.com/addToCart?skuId="+skuId+"&num="+num;//拼接路径
return false;//取消默认行为
})
修改加入购物车的成功页面的显示
①默认图片的显示、商品详情页跳转以及标题显示、商品数量显示
购物车前缀
boundHashOps()方法:所有的增删改查操作只针对这个key
将其抽取成方法
选中->右击->Refactor->Extract Method
/**
* 获取到我们要操作的购物车
* @return
*/
private BoundHashOperations<String, Object, Object> getCartOps() {
UserInfoTo userInfoTo = CartInterceptor.threadLocal.get();
String cartKey = "";
if (userInfoTo.getUserId() != null){
//gulimall:cart:1 ----登录用户
cartKey = CART_PREFIX + userInfoTo.getUserId();
}else{
//gulimall:cart:xxxxx ----临时用户
cartKey = CART_PREFIX + userInfoTo.getUserKey();
}
//redisTemplate.boundHashOps(cartKey):以后关于这个cartKey就都绑定到redis中操作了
BoundHashOperations<String, Object, Object> operations = redisTemplate.boundHashOps(cartKey);
return operations;
}
远程调用product查询sku详情
记得主启动类要加上@EnableFeignClients:开启远程调用注解
远程调用product服务查询销售属性
①编写product服务中的查询销售属性AsString的接口
SkuSaleAttrValueController
@GetMapping("/stringlist/{skuId}")
public List<String> getSkuSaleAttrValues(@PathVariable("skuId") Long skuId){
return skuSaleAttrValueService.getSkuSaleAttrValuesAsStringList(skuId);
}
SkuSaleAttrValueServiceImpl
@Override
public List<String> getSkuSaleAttrValuesAsStringList(Long skuId) {
SkuSaleAttrValueDao dao = this.baseMapper;
return dao.getSkuSaleAttrValuesAsStringList(skuId);
}
数据库查询SQL语句
select concat(attr_name,":",attr_value)
from `pms_sku_sale_attr_value`
where sku_id = 21
SkuSaleAttrValueDao.xml
<select id="getSkuSaleAttrValuesAsStringList" resultType="java.lang.String">
select concat(attr_name,":",attr_value)
from `pms_sku_sale_attr_value`
where sku_id = #{skuId}
</select>
购物车服务远程调用接口
@FeignClient("gulimall-product")
public interface ProductFeignService {
@RequestMapping("/product/skuinfo/info/{skuId}")
//@RequiresPermissions("product:skuinfo:info")
R getSkuInfo(@PathVariable("skuId") Long skuId);
@GetMapping("/product/skusaleattrvalue/stringlist/{skuId}")
List<String> getSkuSaleAttrValues(@PathVariable("skuId") Long skuId);
}
配置线程池提高查询效率:因为多次调用远程服务
MyThreadConfig(直接复制之前商品服务的)
@Configuration
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());
}
}
ThreadPoolConfigProperties
@ConfigurationProperties(prefix = "gulimall.thread")
@Component
@Data
public class ThreadPoolConfigProperties {
private Integer coreSize;
private Integer maxSize;
private Integer keepAliveTime;
}
application.properties配置
gulimall.thread.core-size=20
gulimall.thread.max-size=200
gulimall.thread.keep-alive-time=10
使用异步编排
①编写vo,属性从SkuInfoEntity中copy
@Data
public class SkuInfoVo {
private Long skuId;
/**
* spuId
*/
private Long spuId;
/**
* sku名称
*/
private String skuName;
/**
* sku介绍描述
*/
private String skuDesc;
/**
* 所属分类id
*/
private Long catalogId;
/**
* 品牌id
*/
private Long brandId;
/**
* 默认图片
*/
private String skuDefaultImg;
/**
* 标题
*/
private String skuTitle;
/**
* 副标题
*/
private String skuSubtitle;
/**
* 价格
*/
private BigDecimal price;
/**
* 销量
*/
private Long saleCount;
}
②异步编排
@Autowired
ThreadPoolExecutor executor;
@Override
public CartItem addToCart(Long skuId, Integer num) throws ExecutionException, InterruptedException {
BoundHashOperations<String, Object, Object> cartOps = getCartOps();
CartItem cartItem = new CartItem();
//异步编排
CompletableFuture<Void> getSkuInfoTask = CompletableFuture.runAsync(() -> {
//1、远程调用商品服务查询商品详情
R skuInfo = productFeignService.getSkuInfo(skuId);
SkuInfoVo data = skuInfo.getData("skuInfo", new TypeReference<SkuInfoVo>() {
});
//2、商品添加 到购物车
cartItem.setCheck(true);
cartItem.setCount(num);
cartItem.setImage(data.getSkuDefaultImg());
cartItem.setTitle(data.getSkuTitle());
cartItem.setSkuId(skuId);
cartItem.setPrice(data.getPrice());
}, executor);
//3、远程查询sku的组合信息
//同时调用多个远程服务,为了不影响最终的查询速度,我们可以使用多线程的方式,使用自定义的线程池提高效率
CompletableFuture<Void> getSkuoSaleAttrValues = CompletableFuture.runAsync(() -> {
List<String> values = productFeignService.getSkuSaleAttrValues(skuId);
cartItem.setSkuAttr(values);
}, executor);
CompletableFuture.allOf(getSkuInfoTask,getSkuoSaleAttrValues).get();
String s = JSON.toJSONString(cartItem);
cartOps.put(skuId.toString(),s);
return cartItem;
}
测试:
出现问题:
不能使用th:else,改为 th:if
item.html
list.html
未登录状态
已登录状态
redis中存储:cart:3代表已登录状态;cart:xxxxx代表临时用户,没有登录状态。
完善购物车添加细节①:之前是都认为购物车中没有要添加的此商品存在,现在要判断购物车中是否有我们要添加的此商品。也即是:上面的操作是针对添加新商品进购物车,若购物车里已存在此商品则是一个数量的叠加
@Override
public CartItem addToCart(Long skuId, Integer num) throws ExecutionException, InterruptedException {
BoundHashOperations<String, Object, Object> cartOps = getCartOps();
String res = (String) cartOps.get(skuId.toString());
if (StringUtils.isEmpty(res)){//购物车此时没有此商品
CartItem cartItem = new CartItem();
//异步编排
CompletableFuture<Void> getSkuInfoTask = CompletableFuture.runAsync(() -> {
//1、远程调用商品服务查询商品详情
R skuInfo = productFeignService.getSkuInfo(skuId);
SkuInfoVo data = skuInfo.getData("skuInfo", new TypeReference<SkuInfoVo>() {
});
//2、新商品添加到购物车
cartItem.setCheck(true);
cartItem.setCount(num);
cartItem.setImage(data.getSkuDefaultImg());
cartItem.setTitle(data.getSkuTitle());
cartItem.setSkuId(skuId);
cartItem.setPrice(data.getPrice());
}, executor);
//3、远程查询sku的组合信息
//同时调用多个远程服务,为了不影响最终的查询速度,我们可以使用多线程的方式,使用自定义的线程池提高效率
CompletableFuture<Void> getSkuoSaleAttrValues = CompletableFuture.runAsync(() -> {
List<String> values = productFeignService.getSkuSaleAttrValues(skuId);
cartItem.setSkuAttr(values);
}, executor);
CompletableFuture.allOf(getSkuInfoTask,getSkuoSaleAttrValues).get();
String s = JSON.toJSONString(cartItem);
cartOps.put(skuId.toString(),s);
return cartItem;
}else{
//购物车有此商品,修改数量
CartItem cartItem = JSON.parseObject(res, CartItem.class);
cartItem.setCount(cartItem.getCount() + num);
cartOps.put(skuId.toString(),JSON.toJSONString(cartItem));
return cartItem;
}
}
测试:
完善购物车添加细节②:为了避免用户一直刷新页面,重复提交数据,通过重定向的方式获取购物车内容
RedirectAttributes的addFlashAttribut()方法:将对象存储在Session中且只能使用一次,再次刷新就没有了
RedirectAttributes的addAttribut()方法:将对象拼接在url中
CartController
/**
* 添加商品到购物车
*
* RedirectAttributes ra
* ra.addFlashAttribute():将数据放在session里面可以在页面取出,但是只能取一次
* ra.addAttribute("skuId", skuId);将数据放在url后面
* @return
*/
@GetMapping("/addToCart")
public String addToCart(@RequestParam("skuId") Long skuId,
@RequestParam("num") Integer num,
RedirectAttributes ra) throws ExecutionException, InterruptedException {
cartService.addToCart(skuId, num);
// model.addAttribute("item",cartItem);
ra.addAttribute("skuId", skuId);
return "redirect:http://cart.gulimall.com/addToCartSuccess.html";
}
/**
* 跳转到成功页
* @param skuId
* @param model
* @return
*/
@GetMapping("/addToCartSuccess.html")
public String addToCartSuccess(@RequestParam("skuId") Long skuId, Model model) {
//重定向到成功页面,再次查询购物车数据即可
CartItem item = cartService.getCartItem(skuId);
model.addAttribute("item",item);
return "success";
}
CartServiceImpl
/**
* 获取购物车中的商品
* @param skuId
* @return
*/
@Override
public CartItem getCartItem(Long skuId) {
BoundHashOperations<String, Object, Object> cartOps = getCartOps();
String item = (String) cartOps.get(skuId.toString());
CartItem cartItem = JSON.parseObject(item, CartItem.class);
return cartItem;
}
前端页面修改,进行简单的逻辑判断
success.html
测试:查询没有的商品
不停刷新页面数量不增加
2.6.7 获取&合并购物车
购物车列表展示逻辑:首先判断是否登录,没有登录则展示临时购物车,若登录则展示合并后的购物车,将临时购物车合并后并清空
1.编写获取购物车的方法
CartServiceImpl
/**
* 获取购物车
* @param cartKey
* @return
*/
private List<CartItem> getCartItems(String cartKey){
BoundHashOperations<String, Object, Object> hashOps = redisTemplate.boundHashOps(cartKey);
//获取所有商品数据
List<Object> values = hashOps.values();
if (values != null && values.size() > 0){
List<CartItem> collect = values.stream().map((obj) -> {
String str = (String) obj;
CartItem cartItem = JSON.parseObject(str, CartItem.class);
return cartItem;
}).collect(Collectors.toList());
return collect;
}
return null;
}
2. 编写删除购物车的方法
CartService
/**
* 清空购物车数据
* @param cartKey
*/
void clearCart(String cartKey);
CartServiceImpl
@Override
public void clearCart(String cartKey) {
redisTemplate.delete(cartKey);
}
3. 合并购物车,合并完之后要删除临时购物车
/**
* 购物车展示
* @return
* @throws ExecutionException
* @throws InterruptedException
*/
@Override
public Cart getCart() throws ExecutionException, InterruptedException {
Cart cart = new Cart();
UserInfoTo userInfoTo = CartInterceptor.threadLocal.get();
if (userInfoTo.getUserKey() != null){
//1、登录
String cartKey = CART_PREFIX + userInfoTo.getUserId();
//2、如果临时购物车的数据还没有,进行合并【合并购物车】
String temCartKey = CART_PREFIX + userInfoTo.getUserKey();
List<CartItem> temCartItems = getCartItems(temCartKey);
if (temCartItems != null){
//临时购物车有数据,需要合并
for (CartItem item : temCartItems) {
addToCart(item.getSkuId(), item.getCount());
}
//清除临时购物车的数据
clearCart(temCartKey);
}
//3、获取登录后的购物车的数据【包含合并过来的临时购物车的数据,和登录后的购物车的数据】
List<CartItem> cartItems = getCartItems(cartKey);
cart.setItems(cartItems);
}else {
//4、没登录
String cartKey = CART_PREFIX + userInfoTo.getUserKey();
//获取临时购物车的所有购物项
List<CartItem> cartItems = getCartItems(cartKey);
cart.setItems(cartItems);
}
return cart;
}
前端页面编写
1.登录回显
<li>
<a th:if="${session.loginUser == null }" href="http://auth.gulimall.com/login.html"
class="li_2">你好,请登录</a>
<a th:if="${session.loginUser != null }" style="width: 100px">[[${session.loginUser.nickname}]]</a>
</li>
<li>
<a th:if="${session.loginUser == null }" href="http://auth.gulimall.com/reg.html">免费注册</a>
</li>
2. 逻辑判断
3. 购物车中商品遍历
4.是否被选中、图片展示、标题展示、销售属性展示、格式化商品单价展示、商品数量展示、商品总价显示
<div class="One_ShopCon">
<h1 th:if="${cart.items == null}">
购物车还没有商品,<a href="http://gulimall.com">去购物</a>
</h1>
<ul th:if="${cart.items != null}">
<li th:each="item : ${cart.items}">
<div>
</div>
<div>
<ol>
<li><input type="checkbox" class="check" th:checked="${item.check}"></li>
<li>
<dt><img th:src="${item.image}" alt=""></dt>
<dd style="width: 300px">
<p>
<span th:text="${item.title}">TCL 55A950C 55英寸32核</span>
<br/>
<span th:each="attr:${item.skuAttr}" th:text="${attr}">尺码: 55时 超薄曲面 人工智能</span>
</p>
</dd>
</li>
<li>
<p class="dj" th:text="'¥'+${#numbers.formatDecimal(item.price,3,2)}">¥4599.00</p>
</li>
<li>
<p>
<span>-</span>
<span th:text="${item.count}">5</span>
<span>+</span>
</p>
</li>
<li style="font-weight:bold"><p class="zj">¥[[${#numbers.formatDecimal(item.totalPrice,3,2)}]]</p></li>
<li>
<p>删除</p>
</li>
</ol>
</div>
</li>
</ul>
</div>
5.购物车总价显示、优惠价格显示
<li>总价:<span style="color:#e64346;font-weight:bold;font-size:16px;" class="fnt">¥[[${#numbers.formatDecimal(cart.totalAmount,3,2)}]]</span>
</li>
<li>优惠:[[${#numbers.formatDecimal(cart.reduce,1,2)}]]</li>
6.登录之后不显示“你还没有登录。。。。。” ,没有登录则显示,并且可以跳转去登录页面。
效果:
临时购物车删除,并合并到登录用户购物车中。
2.6.8 选中购物项
为input框设置class方便后续绑定单击事件修改选中状态,自定义属性保存skuId
单击事件编写 ,prop会返回true或false
$(".itemCheck").click(function (){
var skuId = $(this).attr("skuId");
var check = $(this).prop("checked");//使用prop获取到的是 true、false;使用 attr获取到的就是checked:这里需要使用prop
location.href="http://cart.gulimall.com/checkItem?skuId="+skuId+"&check="+(check?1:0);// check = 1:true;check = 0:false
})
编写Controller处理请求
CartController
/**
* 勾选购物项
* @param skuId
* @param check
* @return
*/
@GetMapping("/checkItem")
public String checkItem(@RequestParam("skuId") Long skuId,
@RequestParam("check") Integer check){
cartService.checkItem(skuId,check);
return "redirect:http://cart.gulimall.com/cart.html";
}
CartServiceImpl
@Override
public void checkItem(Long skuId, Integer check) {
BoundHashOperations<String, Object, Object> cartOps = getCartOps();
//获取单个商品
CartItem cartItem = getCartItem(skuId);
cartItem.setCheck((check==1?true:false));
String s = JSON.toJSONString(cartItem);//序列化存进Redis中
cartOps.put(skuId.toString(),s);
}
测试:不勾选,变false
勾选,变 true
2.6.9 改变购物项数量
为父标签自定义属性存储skuId,为加减操作设置相同的class,为数量设置class
<p th:attr="skuId=${item.skuId}">
<span class="countOpsBtn">-</span>
<span class="countOpsNum" th:text="${item.count}">5</span>
<span class="countOpsBtn">+</span>
</p>
编写加减的单击事件
$(".countOpsBtn").click(function () {
//1、skuId
var skuId = $(this).parent().attr("skuId");
var num = $(this).parent().find(".countOpsNum").text();
location.href="http://cart.gulimall.com/countItem?skuId="+skuId+"&num="+num;
})
编写Controller
/**
* 修改购物项数量
* @param skuId
* @param num
* @return
*/
@GetMapping("/countItem")
public String countItem(@RequestParam("skuId") Long skuId,
@RequestParam("num") Integer num){
cartService.changeItemCount(skuId,num);
return "redirect:http://cart.gulimall.com/cart.html";
}
CartServiceImpl
@Override
public void changeItemCount(Long skuId, Integer num) {
CartItem cartItem = getCartItem(skuId);
cartItem.setCount(num);
BoundHashOperations<String, Object, Object> cartOps = getCartOps();//根据当前登录状态获取购物车
cartOps.put(skuId.toString(),JSON.toJSONString(cartItem));//保存进redis中(需要序列化保存):skuId转为string作为key,商品序列化后的文本作为值
}
2.6.10 删除购物项
为图中的删除按钮设置class,绑定单击事件临时保存skuId
//全局变量
var deleteId = 0;
//删除购物项
function deleteItem(){
location.href = "http://cart.gulimall.com/deleteItem?skuId="+deleteId;
}
$(".deleteItemBtn").click(function () {
deleteId = $(this).attr("skuId");
})
编写Controller
CartController
/**
* 删除购物项
* @param skuId
* @return
*/
@GetMapping("/deleteItem")
public String deleteItem(@RequestParam("skuId") Long skuId){
cartService.deleteItem(skuId);
return "redirect:http://cart.gulimall.com/cart.html";//删除完了之后重新跳转到此页面,相当于刷新获得最新的内容
}
CartServiceImpl
@Override
public void deleteItem(Long skuId) {
BoundHashOperations<String, Object, Object> cartOps = getCartOps();//根据当前状态获取购物车
cartOps.delete(skuId.toString());//利用skuId进行删除
}
测试:删除所有商品
重要!!!!
关于 第6章 分布式事务 和 第7章RabbitMQ 单独写在另外一篇文档:谷粒商城之高级篇知识补充。
2.7 订单服务
2.7.1 环境搭建
1、实现动静分离:
课件中等待付款是订单详情页;订单页是用户订单列表;结算页是订单确认页;收银页是支付页
在nginx中新建目录order
-
放到IDEA-order项目中
-
order/detail中放入【等待付款】的静态资源。index.html重命名为 detail.html
-
order/list中放入【订单页】的静态资源。index.html重命名为 list.html
-
order/confirm中放入【结算页】的静态资源。index.html重命名为 confirm.html
-
order/pay中放入【收银页】的静态资源。index.html重命名为 pay.html
-
修改HOSTS, 192.168.56.10 order.gulimall.com
-
nginx中已经配置过转发
-
在gateway中新增order路由
- id: gulimall_order_route
uri: lb://gulimall-order
predicates:
- Host=order.gulimall.com
- 修改html中的路径/static前缀。比如/static/order/confirm
此外每个页面都需要加入 thymeleaf名称空间。
其他三个页面都需要进行上述操作。
pay.html页面还需要:
2、相应配置
①将订单服务注册到注册中心去,并且给微服务起一个名字
spring.cloud.nacos.discovery.server-addr=127.0.0.1:8848
spring.application.name=gulimall-order
②导入thymeleaf依赖并在开发期间禁用缓存
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-thymeleaf</artifactId>
</dependency>
spring.thymeleaf.cache=false
③主启动类加上注解 @EnableDiscoveryClient
3、编写Controller访问订单页面
@Controller
public class HelloController {
@GetMapping("/{page}.html")
public String listPage(@PathVariable("page") String page){
return page;
}
}
测试访问各个页面:
出现错误:confirm.html 报 Unfinished block structure
解决方案: 将/*删除即可
最后能够成功访问各个页面。
2.7.2 整合SpringSession
1.Redis默认使用lettuce作为客户端可能导致内存泄漏,因此需要排除lettuce依赖,使用jedis作为客户端或者使用高版本的Redis依赖即可解决内存泄漏问题
引入依赖
<!--导入SpringBoot整合Redis的依赖-->
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-data-redis</artifactId>
<exclusions>
<exclusion>
<groupId>io.lettuce</groupId>
<artifactId>lettuce-core</artifactId>
</exclusion>
</exclusions>
</dependency>
<!--导入Jedis的依赖-->
<dependency>
<groupId>redis.clients</groupId>
<artifactId>jedis</artifactId>
</dependency>
配置 Redis
spring.redis.host=192.168.56.10
spring.redis.port=6379
2. 导入Session依赖
<dependency>
<groupId>org.springframework.session</groupId>
<artifactId>spring-session-data-redis</artifactId>
</dependency>
配置 Session的存储类型
spring.session.store-type=redis
编写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();
}
}
编写线程池配置
@Configuration
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;
}
配置
gulimall.thread.core-size=20
gulimall.thread.max-size=200
gulimall.thread.keep-alive-time=10
主启动类使用@EnableRedisHttpSession让Session启作用
3. 登录回显
首页:我的订单路径跳转
首页:
订单详情页面(detail.html):显示用户名
订单确认页(confirm.html):用户名显示
订单支付页面(pay.html):用户名回显
2.7.3 订单基本概念
1、订单中心:
电商系统涉及到3流,分别是信息流,资金流,物流,而订单系统作为中枢将三者有机的集合起来。
订单模块是电商系统的枢纽,在订单这个环节上需求获取多个模块的数据和信息,同时对这些信息进行加工处理后流向下个环节,这一系列就构成了订单的信息流通。
- 订单构成
1、用户信息
用户信息包括用户账号、用户等级、用户的收货地址、收货人、收货人电话等组成,用户账户需要绑定手机号码,但是用户绑定的手机号码不一定是收货信息上的电话。用户可以添加多个收货信息,用户等级信息可以用来和促销系统进行匹配,获取商品折扣,同时用户等级还可以获取积分的奖励等。
2、订单基础信息
订单基础信息是订单流转的核心,其包括订单类型、父/子订单、订单编号、订单状态、订单流转的时间等。
(1)订单类型包括实体商品订单和虚拟订单商品等,这个根据商城商品和服务类型进行区分。
(2)同时订单都需要做父子订单处理,之前在初创公司一直只有一个订单,没有做父子订单处理后期需要进行拆单的时候就比较麻烦,尤其是多商户商场,和不同仓库商品的时候,父子订单就是为后期做拆单准备的。
(3)订单编号不多说了,需要强调的一点是父子订单都需要有订单编号,需要完善的时候可以对订单编号的每个字段进行统一定义和诠释。
(4)订单状态记录订单每次流转过程,后面会对订单状态进行单独的说明。
(5)订单流转时间需要记录下单时间,支付时间,发货时间,结束时间/关闭时间等等
3、商品信息
商品信息从商品库中获取商品的SKU 信息、图片、名称、属性规格、商品单价、商户信息等,从用户下单行为记录的用户下单数量,商品合计价格等。
4、优惠信息
优惠信息记录用户参与的优惠活动,包括优惠促销活动,比如满减、满赠、秒杀等,用户使用的优惠券信息,优惠券满足条件的优惠券需要默认展示出来,具体方式已在之前的优惠券篇章做过详细介绍,另外还虚拟币抵扣信息等进行记录。
为什么把优惠信息单独拿出来而不放在支付信息里面呢?
因为优惠信息只是记录用户使用的条目,而支付信息需要加入数据进行计算,所以做为区分。
5、支付信息
(1)支付流水单号,这个流水单号是在唤起网关支付后支付通道返回给电商业务平台的支付流水号,财务通过订单号和流水单号与支付通道进行对账使用。
(2)支付方式用户使用的支付方式,比如微信支付、支付宝支付、钱包支付、快捷支付等。支付方式有时候可能有两个——余额支付+第三方支付。
(3)商品总金额,每个商品加总后的金额;运费,物流产生的费用;优惠总金额,包括促销活动的优惠金额,优惠券优惠金额,虚拟积分或者虚拟币抵扣的金额,会员折扣的金额等之和;实付金额,用户实际需要付款的金额。
用户实付金额=商品总金额+运费-优惠总金额
6、物流信息
物流信息包括配送方式,物流公司,物流单号,物流状态,物流状态可以通过第三方接口来获取和向用户展示物流每个状态节点。
- 订单状态
1、待付款
用户提交订单后,订单进行预下单,目前主流电商网站都会唤起支付,便于用户快速完成支付,需要注意的是待付款状态下可以对库存进行锁定,锁定库存需要配置支付超时时间,超时后将自动取消订单,订单变更关闭状态。
2、已付款/待发货
用户完成订单支付,订单系统需要记录支付时间,支付流水单号便于对账,订单下放到WMS系统,仓库进行调拨,配货,分拣,出库等操作。
3、待收货/已发货
仓储将商品出库后,订单进入物流环节,订单系统需要同步物流信息,便于用户实时知悉物品物流状态
4、已完成
用户确认收货后,订单交易完成。后续支付侧进行结算,如果订单存在问题进入售后状态
5、已取消
付款之前取消订单。包括超时未付款或用户商户取消订单都会产生这种订单状态。
6、售后中
用户在付款后申请退款,或商家发货后用户申请退换货。
售后也同样存在各种状态,当发起售后申请后生成售后订单,售后订单状态为待审核,等待商家审核,商家审核通过后订单状态变更为待退货,等待用户将商品寄回,商家收货后订单状态更新为待退款状态,退款到用户原账户后订单状态更新为售后成功。
2、订单流程
订单流程是指从订单产生到完成整个流转的过程,从而行程了一套标准流程规则。而不同的产品类型或业务类型在系统中的流程会千差万别,比如上面提到的线上实物订单和虚拟订单的流程,线上实物订单与O2O 订单等,所以需要根据不同的类型进行构建订单流程。
不管类型如何订单都包括正向流程和逆向流程,对应的场景就是购买商品和退换货流程,正向流程就是一个正常的网购步骤:订单生成–>支付订单–>卖家发货–>确认收货–>交易成功。
而每个步骤的背后,订单是如何在多系统之间交互流转的,可概括如下图
1、订单创建与支付
- (1) 、订单创建前需要预览订单,选择收货信息等
- (2) 、订单创建需要锁定库存,库存有才可创建,否则不能创建
- (3) 、订单创建后超时未支付需要解锁库存
- (4) 、支付成功后,需要进行拆单,根据商品打包方式,所在仓库,物流等进行拆单
- (5) 、支付的每笔流水都需要记录,以待查账
- (6) 、订单创建,支付成功等状态都需要给MQ 发送消息,方便其他系统感知订阅
2、逆向流程
- (1) 、修改订单,用户没有提交订单,可以对订单一些信息进行修改,比如配送信息,优惠信息,及其他一些订单可修改范围的内容,此时只需对数据进行变更即可。
- (2) 、订单取消,用户主动取消订单和用户超时未支付,两种情况下订单都会取消订单,而超时情况是系统自动关闭订单,所以在订单支付的响应机制上面要做支付的限时处理,尤其是在前面说的下单减库存的情形下面,可以保证快速的释放库存。
另外需要需要处理的是促销优惠中使用的优惠券,权益等视平台规则,进行相应补回给用户。 - (3) 、退款,在待发货订单状态下取消订单时,分为缺货退款和用户申请退款。如果是全部退款则订单更新为关闭状态,若只是做部分退款则订单仍需进行进行,同时生成一条退款的售后订单,走退款流程。退款金额需原路返回用户的账户。
- (4) 、发货后的退款,发生在仓储货物配送,在配送过程中商品遗失,用户拒收,用户收货后对商品不满意,这样情况下用户发起退款的售后诉求后,需要商户进行退款的审核,双方达成一致后,系统更新退款状态,对订单进行退款操作,金额原路返回用户的账户,同时关闭原订单数据。仅退款情况下暂不考虑仓库系统变化。如果发生双方协调不一致情况下,可以申请平台客服介入。在退款订单商户不处理的情况下,系统需要做限期判断,比如5 天商户不处理,退款单自动变更同意退款。
3、幂等性处理
参照幂等性文档。
2.7.4 订单登录拦截
点击 去结算 跳转到 订单详情页面:
购物车页面(cartList.html):
编写controller接口,实现跳转。
@Controller
public class OrderWebController {
@GetMapping("/toTrade")
public String toTrade(){
return "confirm";
}
}
这里我们需要有判断,如果是未登录用户,需要拦截,让其去登录才可以点击结算:即只要能结算就是登录状态,因此,需要编写一个拦截器。
因为订单系统必然涉及到用户信息,因此进入订单系统的请求必须是已经登录的,所以我们需要通过拦截器对未登录订单请求进行拦截
拦截器
@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) {
//登录成功后,将用户信息存储至ThreadLocal中方便其他服务获取用户信息:
//加上ThreadLocal共享数据,是为了登录后把用户放到本地内存,而不是每次都去远程session里查
loginUser.set(attribute);
return true;
} else {
//没登录就去登录
request.getSession().setAttribute("msg", "请先进行登录");
response.sendRedirect("http://auth.gulimall.com/login.html");
return false;
}
}
}
需要配置以下配置类拦截器才会生效:
@Configuration
public class OrderWebConfiguration implements WebMvcConfigurer {
@Autowired
LoginUserInterceptor interceptor;
@Override
public void addInterceptors(InterceptorRegistry registry) {
// 配置 LoginUserInterceptor拦截器拦截所有请求
registry.addInterceptor(interceptor).addPathPatterns("/**");
}
}
未登录消息提醒 回显
login.html
测试没有登录点击 去结算:
跳转到登录页面登录并提示错误消息。
2.7.5 订单确认页模型抽取
1.购物车计算价格存在小bug,未选中的商品不应该加入总价的计算
修改 购物车服务下的 Cart
2. 订单确认页的数据编写
①用户地址信息,数据来源:ums_member_receive_address
② 商品项信息,之前编写CartItem
获取的是最新的价格,而不是加入购物车时的价格。
③ 优惠券信息,使用京豆的形式增加用户的积分
④ 订单总额和应付总额信息
动态计算出来。
3. 编写Vo
OrderConfirmVo
//订单确认页需要用的数据
@Data
public class OrderConfirmVo {
// 收货地址,ums_member_receive_address 表
List<MemberAddressVo> address;
//所有选中的购物项
List<OrderItemVo> items;
//发票记录...
//优惠券信息...
Integer integration;
BigDecimal total;//订单总额
BigDecimal payPrice;//应付价格
}
MemberAddressVo
@Data
public class MemberAddressVo {
private Long id;
/**
* member_id
*/
private Long memberId;
/**
* 收货人姓名
*/
private String name;
/**
* 电话
*/
private String phone;
/**
* 邮政编码
*/
private String postCode;
/**
* 省份/直辖市
*/
private String province;
/**
* 城市
*/
private String city;
/**
* 区
*/
private String region;
/**
* 详细地址(街道)
*/
private String detailAddress;
/**
* 省市区代码
*/
private String areacode;
/**
* 是否默认
*/
private Integer defaultStatus;
}
OrderItemVo
@Data
public class OrderItemVo {
private Long skuId;
private Boolean check ;//是否被选中
private String title;//标题
private String image;//图片
private List<String> skuAttr;//销售属性组合描述
private BigDecimal price;//商品单价
private Integer count;//商品数量
private BigDecimal totalPrice;//总价,总价需要计算
}
4. 编写业务代码
OrderWebController
@Controller
public class OrderWebController {
@Autowired
OrderService orderService;
@GetMapping("/toTrade")
public String toTrade(Model model){
OrderConfirmVo confirmVo = orderService.confirmOrder();
model.addAttribute("OrderConfirmData",confirmVo);
//展示订单确认的数据
return "confirm";
}
}
OrderService
/**
* 订单确认页返回需要用的数据
* @return
*/
OrderConfirmVo confirmOrder();
2.7.6 订单确认页数据获取
1.创建出返回会员地址列表的方法,方便后续的远程服务调用
会员服务下:MemberReceiveAddressController
/**
* 查询用户地址信息
* @param memberId
* @return
*/
@GetMapping("/{memberId}/addresses")
public List<MemberReceiveAddressEntity> getAddress(@PathVariable("memberId") Long memberId){
return memberReceiveAddressService.getAddress(memberId);
}
实现:MemberReceiveAddressServiceImpl
/**
*
* 查出对应会员id的地址
* @param memberId
* @return
*/
@Override
public List<MemberReceiveAddressEntity> getAddress(Long memberId) {
return this.list(new QueryWrapper<MemberReceiveAddressEntity>().eq("member_id",memberId));
}
2.远程调用会员服务查询收货地址
订单服务下新建 MemberFeignService
@FeignClient("gulimall-member")
public interface MemberFeignService {
@GetMapping("/member/memberreceiveaddress/{memberId}/addresses")
List<MemberAddressVo> getAddress(@PathVariable("memberId") Long memberId);
}
开启远程服务调用功能
3. 查询购物车时,需要查询实时的商品价格,因此,编写通过skuId查询商品价格的接口
商品服务下:SkuInfoController
/**
* 获取商品的最新价格
*/
@GetMapping("/{skuId}/price")
public BigDecimal getPrice(@PathVariable("skuId") Long skuId){
SkuInfoEntity byId = skuInfoService.getById(skuId);
return byId.getPrice();
}
4. 远程调用商品服务,查询商品的实时价格
购物车服务下 ProductFeignService
@GetMapping("/product/skuinfo/{skuId}/price")
BigDecimal getPrice(@PathVariable("skuId") Long skuId);
5. 查询购物车接口编写
注意细节:①需要过滤选中的商品②Redis中的购物车商品的价格可能是很久之前的需要实时查询商品的价格
CartController
@GetMapping("/currentUserCartItems")
public List<CartItem> getCurrentUserCartItems(){
return cartService.getCurrentUserCartItems();
}
实现:CartServiceImpl
@Override
public List<CartItem> getCurrentUserCartItems() {
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 -> {
BigDecimal price = productFeignService.getPrice(item.getSkuId());
// TODO 1、更新为最新价格
item.setPrice(price);
return item;
}).collect(Collectors.toList());
return collect;
}
}
6. 远程调用购物车服务,查询购物车中的商品列表
CartFeignService
@FeignClient("gulimall-cart")
public interface CartFeignService {
@GetMapping("/currentUserCartItems")
List<OrderItemVo> getCurrentUserCartItems();
}
7. 价格获取方法编写(动态计算),此外为了防止用户重复提交提订单,需要编写一个令牌(Token)
//订单确认页需要用的数据
// @Data
public class OrderConfirmVo {
// 收货地址,ums_member_receive_address 表
@Setter @Getter
List<MemberAddressVo> address;
//所有选中的购物项
@Setter @Getter
List<OrderItemVo> items;
//发票记录...
//优惠券信息...
@Setter @Getter
Integer integration;
//防重令牌(防重复提交令牌):防止因为网络原因,用户多次点击提交订单,造成多次提交
@Setter @Getter
String orderToken;
// BigDecimal total;//订单总额
public BigDecimal getTotal() {
BigDecimal sum = new BigDecimal("0");
if (items != null){
for (OrderItemVo item : items) {
BigDecimal multiply = item.getPrice().multiply(new BigDecimal(item.getCount().toString()));
sum = sum.add(multiply);
}
}
return sum;
}
// BigDecimal payPrice;//应付价格
public BigDecimal getPayPrice() {
return getTotal();
}
}
8.订单确认页返回需要用的数据
OrderWebController
@GetMapping("/toTrade")
public String toTrade(Model model){
OrderConfirmVo confirmVo = orderService.confirmOrder();
model.addAttribute("OrderConfirmData",confirmVo);
//展示订单确认的数据
return "confirm";
}
实现:
@Override
public OrderConfirmVo confirmOrder() {
OrderConfirmVo confirmVo = new OrderConfirmVo();
MemberRespVo memberRespVo = LoginUserInterceptor.loginUser.get();
//1、远程查询所有的收货地址列表
List<MemberAddressVo> address = memberFeignService.getAddress(memberRespVo.getId());
confirmVo.setAddress(address);
//2、远程查询购物车所有选中的购物项
List<OrderItemVo> items = cartFeignService.getCurrentUserCartItems();
confirmVo.setItems(items);
//3、查询用户积分
Integer integration = memberRespVo.getIntegration();
confirmVo.setIntegration(integration);
//4、其他数据自动计算
//TODO 5、防重令牌(幂等性章节)
return confirmVo;
}
2.7.7 Feign远程调用丢失请求头问题
出现问题:远程调用购物车服务,购物车认为未登录
出现问题的原因:feign构建的新请求未把老请求头给带过来
解决方案:feign在创建RequestTemplate之前会调用很多RequestInterceptor,可以利用RequestInterceptor将老请求头给加上
配置类:
@Configuration
public class GuliFeignConfig {
@Bean("requestInterceptor")
public RequestInterceptor requestInterceptor(){
return new RequestInterceptor() {
@Override
public void apply(RequestTemplate template) {
//1、RequestContextHolder拿到刚进来的这个请求
ServletRequestAttributes attributes = (ServletRequestAttributes) RequestContextHolder.getRequestAttributes();
HttpServletRequest request = attributes.getRequest();//老请求
//同步请求头数据,Cookie
String cookie = request.getHeader("Cookie");
//给新请求同步了老请求的cookie
template.header("Cookie",cookie);
}
};
}
}
1、新线程没有用户数据的问题RequestContextHolder
RequestContextHolder可以解决的问题:
- 正常来说在service层是没有request和response的,然而直接从controlller传过来的话解决方法太粗暴。解决方法是SpringMVC提供的RequestContextHolder
- 用线程池执行任务时非主线程是没有请求数据的,可以通过该方法设置线程中的request数据,原理还是用的threadlocal
RequestContextHolder推荐阅读:https://blog.csdn.net/asdfsadfasdfsa/article/details/79158459
在spring mvc中,为了随时都能取到当前请求的request对象,可以通过RequestContextHolder的静态方法getRequestAttributes()获取Request相关的变量,如request, response等
RequestContextHolder顾名思义,持有上下文的Request容器.使用是很简单的,具体使用如下:
//两个方法在没有使用JSF的项目中是没有区别的 RequestAttributes requestAttributes = RequestContextHolder.currentRequestAttributes(); // RequestContextHolder.getRequestAttributes(); //从session里面获取对应的值 String str = (String) requestAttributes.getAttribute("name",RequestAttributes.SCOPE_SESSION); HttpServletRequest request = ((ServletRequestAttributes)requestAttributes).getRequest(); HttpServletResponse response = ((ServletRequestAttributes)requestAttributes).getResponse();
什么时候把request和response设置进去的:mvc的service()方法里有processRequest(request, response);,每个请求来了都会执行,
- 获取上一个请求的参数
- 重新建立新的参数
- 设置到XXContextHolder
- 父类的service()处理请求
- 恢复request
- 发布事件
2、远程调用丢失用户信息
feign
远程调用的请求头中没有含有JSESSIONID
的cookie
,所以也就不能得到服务端的session
数据,也就没有用户数据,cart认为没登录,获取不了用户信息我们追踪远程调用的源码,可以在SynchronousMethodHandler.targetRequest()方法中看到他会遍历容器中的
RequestInterceptor
进行封装Request targetRequest(RequestTemplate template) { for (RequestInterceptor interceptor : requestInterceptors) { interceptor.apply(template); } return target.apply(template); }
根据追踪源码,我们可以知道我们可以通过给容器中注入RequestInterceptor,从而给远程调用转发时带上cookie。
但是在feign的调用过程中,会使用容器中的RequestInterceptor对RequestTemplate进行处理,因此我们可以通过向容器中导入定制的RequestInterceptor为请求加上cookie。
注意:上面在封装cookie的时候要拿到原来请求的cookie,设置到新的请求中
RequestContextHolder为SpingMVC中共享request数据的上下文,底层由ThreadLocal实现,也就是说该请求只对当前访问线程有效,如果new了新线程就找不到原来request了。
2.7.8 Feign异步调用丢失上下文的问题
1.注入线程池
2.使用异步编排,各个任务彼此之间互不相关,但是需要等待各个任务处理完成
@Override
public OrderConfirmVo confirmOrder() {
OrderConfirmVo confirmVo = new OrderConfirmVo();
MemberRespVo memberRespVo = LoginUserInterceptor.loginUser.get();
CompletableFuture<Void> getAddressFuture = CompletableFuture.runAsync(() -> {
//1、远程查询所有的收货地址列表
List<MemberAddressVo> address = memberFeignService.getAddress(memberRespVo.getId());
confirmVo.setAddress(address);
}, executor);
CompletableFuture<Void> cartFuture = CompletableFuture.runAsync(() -> {
//2、远程查询购物车所有选中的购物项
List<OrderItemVo> items = cartFeignService.getCurrentUserCartItems();
confirmVo.setItems(items);
//feign在远程调用之前要构造请求,调用很多的拦截器
//RequestInterceptor interceptor :requestInterceptors
}, executor);
//3、查询用户积分
Integer integration = memberRespVo.getIntegration();
confirmVo.setIntegration(integration);
//4、其他数据自动计算
//TODO 5、防重令牌(幂等性章节)
CompletableFuture.allOf(getAddressFuture,cartFuture).get();//等待所有结果完成
return confirmVo;
}
出现问题: 异步任务执行远程调用时会丢失请求上下文,oldRequest会为null
出现问题的原因: 当我们不使用异步编排的时候也就是单线程执行的时候,请求上下文持有器即:RequestContextHolder采用的是ThreadLocal存储请求对象。当我们采用异步编排时,而是多个线程去执行,新建的线程会丢失请求对象。
解决方案: 每个新建的线程都去添加之前请求的数据
@Override
public OrderConfirmVo confirmOrder() {
OrderConfirmVo confirmVo = new OrderConfirmVo();
MemberRespVo memberRespVo = LoginUserInterceptor.loginUser.get();
System.out.println("主线程..."+Thread.currentThread().getId());
//获取之前的请求
RequestAttributes requestAttributes = RequestContextHolder.getRequestAttributes();
CompletableFuture<Void> getAddressFuture = CompletableFuture.runAsync(() -> {
//1、远程查询所有的收货地址列表
System.out.println("member线程..."+Thread.currentThread().getId());
//每一个线程都来共享之前的请求数据
RequestContextHolder.setRequestAttributes(requestAttributes);
List<MemberAddressVo> address = memberFeignService.getAddress(memberRespVo.getId());
confirmVo.setAddress(address);
}, executor);
CompletableFuture<Void> cartFuture = CompletableFuture.runAsync(() -> {
//2、远程查询购物车所有选中的购物项
System.out.println("cart线程..."+Thread.currentThread().getId());
//每一个线程都来共享之前的请求数据
RequestContextHolder.setRequestAttributes(requestAttributes);
List<OrderItemVo> items = cartFeignService.getCurrentUserCartItems();
confirmVo.setItems(items);
//feign在远程调用之前要构造请求,调用很多的拦截器
//RequestInterceptor interceptor :requestInterceptors
}, executor);
//3、查询用户积分
Integer integration = memberRespVo.getIntegration();
confirmVo.setIntegration(integration);
//4、其他数据自动计算
//TODO 5、防重令牌(幂等性章节)
CompletableFuture.allOf(getAddressFuture,cartFuture);
return confirmVo;
}
ps:
因为异步编排的原因,他会丢掉
ThreadLocal
中原来线程的数据,从而获取不到loginUser
,这种情况下我们可以在方法内的局部变量中先保存原来线程的信息,在异步编排的新线程中拿着局部变量的值重新设置到新线程中即可。
- 由于
RequestContextHolder
使用ThreadLocal
共享数据,所以在开启异步时获取不到老请求的信息,自然也就无法共享cookie
了在这种情况下,我们需要在开启异步的时候将老请求的
RequestContextHolder
的数据设置进去。
bug修改:
出现问题:
- org.thymeleaf.exceptions.TemplateInputException: Error resolving template [user/cart], template might not exist or might not be accessible by any of the configured Template Resolvers
解决方案:
此外:远程获取价格的时候应该用R。
购物车服务修改
CartServiceImpl
ProductFeignService
商品服务修改:
SkuInfoController
2.7.9 订单确认页渲染
1.收货人信息回显
<div class="top-3" th:each="addr:${orderConfirmData.address}">
<p>[[${addr.name}]]</p><span>[[${addr.name}]] [[${addr.province}]] [[${addr.detailAddress}]] [[${addr.phone}]]</span>
</div>
2. 商品信息回显
添加两项新属性
<div class="yun1" th:each="item:${orderConfirmData.items}">
<img 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>[[${item.hasStock?"有货":"无货"}]]</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>
3. 商品总件数、总金额、应付金额回显
**总件数计算:**OrderConfirmVo
//总件数
public Integer getCount(){
Integer i = 0;
if (items != null){
for (OrderItemVo item : items) {
i += item.getCount();
}
}
return i;
}
2.7.10 订单确认页库存查询
1.库存服务中查询库存的方法之前已经编写好了
WareSkuController
//查询sku 是否有库存
@PostMapping("/hasstock")
public R getSkuHasStock(@RequestBody List<Long> skuIds){
//sku_id,stock
List<SkuHasStockVo> vos = wareSkuService.getSkuHasStock(skuIds);
return R.ok().setData(vos);
}
2. 远程服务接口调用编写
订单服务下
@FeignClient("gulimall-ware")
public interface WmsFeignService {
//查询sku 是否有库存
@PostMapping("/ware/waresku/hasstock")
R getSkuHasStock(@RequestBody List<Long> skuIds);
}
3. Vo编写
@Data
public class SkuStockVo {
private Long skuId;
private Boolean hasStock;
}
4. 编写异步任务查询库存信息
编写Map用于封装库存信息
@Override
public OrderConfirmVo confirmOrder() throws ExecutionException, InterruptedException {
// 封装订单
OrderConfirmVo confirmVo = new OrderConfirmVo();
// 获取用户,用用户信息获取购物车
MemberRespVo memberRespVo = LoginUserInterceptor.loginUser.get();
// System.out.println("主线程..."+Thread.currentThread().getId());
/**
* 我们要从request里获取用户数据,但是其他线程是没有这个信息的,
*所以可以手动设置新线程里也能共享当前的request数据
*/
//获取之前的请求
RequestAttributes requestAttributes = RequestContextHolder.getRequestAttributes();
CompletableFuture<Void> getAddressFuture = CompletableFuture.runAsync(() -> {
//1、远程查询所有的收货地址列表
// System.out.println("member线程..."+Thread.currentThread().getId());
//每一个线程都来共享之前的请求数据
//因为异步线程需要新的线程,而新的线程里没有request数据,所以我们自己设置进去
RequestContextHolder.setRequestAttributes(requestAttributes);
List<MemberAddressVo> address = memberFeignService.getAddress(memberRespVo.getId());
confirmVo.setAddress(address);
}, executor);
CompletableFuture<Void> cartFuture = CompletableFuture.runAsync(() -> {
//2、远程查询购物车所有选中的购物项
System.out.println("cart线程..."+Thread.currentThread().getId());
//每一个线程都来共享之前的请求数据
RequestContextHolder.setRequestAttributes(requestAttributes);
List<OrderItemVo> items = cartFeignService.getCurrentUserCartItems();
confirmVo.setItems(items);
//feign在远程调用之前要构造请求,调用很多的拦截器
//RequestInterceptor interceptor :requestInterceptors
}, executor).thenRunAsync(()->{
List<OrderItemVo> items = confirmVo.getItems();
List<Long> collect = items.stream().map(item -> item.getSkuId()).collect(Collectors.toList());
// TODO 一定要启动库存服务,否则库存查不出
R hasStock = wmsFeignService.getSkuHasStock(collect);
List<SkuStockVo> data = hasStock.getData(new TypeReference<List<SkuStockVo>>() {
});
if (data != null){
Map<Long, Boolean> map = data.stream().collect(Collectors.toMap(SkuStockVo::getSkuId, SkuStockVo::getHasStock));
confirmVo.setStocks(map);
}
},executor);
//3、查询用户积分
Integer integration = memberRespVo.getIntegration();
confirmVo.setIntegration(integration);
//4、其他数据自动计算
//TODO 5、防重令牌(幂等性章节)
CompletableFuture.allOf(getAddressFuture,cartFuture).get();//等待所有结果完成
return confirmVo;
}
库存信息回显
<span>[[${orderConfirmData.stocks[item.skuId]?"有货":"无货"}]]</span></p>
出现空指针异常:无需共享数据就不用做以下操作了
测试:
注意,想要显示有货,数据库 wms_ware_sku表中这两个字段必须有值才行。
2.7.11 订单确认页模拟运费效果
1.远程服务调用查询地址接口编写
库存服务下编写
@FeignClient("gulimall-member")
public interface MemberFeignService {
@RequestMapping("/member/memberreceiveaddress/info/{id}")
R addrInfo(@PathVariable("id") Long id);
}
这个远程获取地址的方法在 会员服务 MemberReceiveAddressController下已经写过了,所以可以直接使用。
2.编写获取邮费的接口
WareInfoController
/**
* 根据用户的收货地址计算运费
* @param addrId
* @return
*/
@GetMapping("/fare")
public R getFare(@RequestParam("addrId") Long addrId){
BigDecimal fare = wareInfoService.getFare(addrId);
return R.ok().setData(fare);
}
WareInfoServiceImpl
@Override
public BigDecimal getFare(Long addrId) {
R r = memberFeignService.addrInfo(addrId);
MemberAddressVo data = r.getData("memberReceiveAddress",new TypeReference<MemberAddressVo>() {
});
if (data != null){
String phone = data.getPhone();
// 12345678918 8:截取手机号的最后一位当做运费,实际上应该是接入第三方物流作为接口。这里只是简单模拟一下。
String substring = phone.substring(phone.length() - 1, phone.length());
return new BigDecimal(substring);
}
return null;
}
3. 地址高亮显示
为div绑定class方便找到,自定义def属性存储默认地址值,默认地址为1,否则为0;自定义属性存储地址Id
空格代表子元素
函数调用
为运费定义一个id,用于运费的回显
为应付总额定义一个id,用于计算应付总额的回显
为p标签绑定单击事件
默认地址的邮费查询
function highlight(){
$(".addr-item p").css({"border":"2px solid gray"})
$(".addr-item p[def='1']").css({"border":"2px solid red"})
}
$(".addr-item p").click(function () {
$(".addr-item p").attr("def","0")
$(this).attr("def","1");
highlight();
//获取到当前的地址id
var addrId = $(this).attr("addrId");
//发送Ajax获取运费信息
getFare(addrId);
});
//发送Ajax获取运费信息
function getFare(addrId) {
$.get("http://gulimall.com/api/ware/wareinfo/fare?addrId="+addrId,function (data) {
console.log(data);
//fareEle
$("#fareEle").text(data.data);
var total = [[${orderConfirmData.total}]];
$("#payPriceEle").text(total*1 + data.data*1);
})
}
效果展示:
2.7.12 订单确认页细节显示
查询运费时连同地址信息一起返回,也就是选中地址的地址信息回显
1.编写vo
@Data
public class FareVo {
private MemberAddressVo address;
private BigDecimal fare;
}
2.改写实现类
WareInfoServiceImpl
@Override
public FareVo getFare(Long addrId) {
FareVo fareVo = new FareVo();
R r = memberFeignService.addrInfo(addrId);
MemberAddressVo data = r.getData("memberReceiveAddress",new TypeReference<MemberAddressVo>() {
});
if (data != null){
String phone = data.getPhone();
// 12345678918 8:截取手机号的最后一位当做运费,实际上应该是接入第三方物流作为接口。这里只是简单模拟一下。
String substring = phone.substring(phone.length() - 1, phone.length());
BigDecimal bigDecimal = new BigDecimal(substring);
fareVo.setAddress(data);
fareVo.setFare(bigDecimal);
return fareVo;
}
return null;
}
WareInfoService
/**
* 根据用户的收货地址计算运费
* @param addrId
* @return
*/
FareVo getFare(Long addrId);
WareInfoController
/**
* 根据用户的收货地址计算运费
* @param addrId
* @return
*/
@GetMapping("/fare")
public R getFare(@RequestParam("addrId") Long addrId){
FareVo fare = wareInfoService.getFare(addrId);
return R.ok().setData(fare);
}
3. 信息回显
//发送Ajax获取运费信息
function getFare(addrId) {
$.get("http://gulimall.com/api/ware/wareinfo/fare?addrId="+addrId,function (resp) {
console.log(resp);
//fareEle
$("#fareEle").text(resp.data.fare);
var total = [[${orderConfirmData.total}]];
//设置运费等
$("#payPriceEle").text(total*1 + resp.data.fare*1);
//设置收货人信息
$("#receiveAddressEle").text(resp.data.address.province+" "+resp.data.address.detailAddress);
$("#receiverEle").text(resp.data.address.name);
})
}
效果展示
2.7.13 接口幂等性讨论
一、什么是幂等性
假设网络很慢,用户多次点击提交订单,有可能会导致数据库中插入了多条订单记录,为了避免订单的重复提交,用专业的术语就称之为接口幂等性,通俗点讲就是用户提交一次和用户提交一百次的结果是一样的,数据库中只会有一条订单记录。
接口幂等性就是用户对于同一操作发起的一次请求或者多次请求的结果是一致的,不会因为多次点击而产生了副作用;比如说支付场景,用户购买了商品支付扣款成功,但是返回结果的时候网络异常,此时钱已经扣了,用户再次点击按钮,此时会进行第二次扣款,返回结果成功,用户查询余额返发现多扣钱了,流水记录也变成了两条...,这就没有保证接口的幂等性。
二、哪些情况需要防止
用户多次点击按钮
用户页面回退再次提交
微服务互相调用,由于网络问题,导致请求失败。feign 触发重试机制
其他业务情况
三、什么情况下需要幂等
以SQL 为例,有些操作是天然幂等的。
SELECT * FROM table WHER id=?,无论执行多少次都不会改变状态,是天然的幂等。
UPDATE tab1 SET col1=1 WHERE col2=2,无论执行成功多少次状态都是一致的,也是幂等操作。
delete from user where userid=1,多次操作,结果一样,具备幂等性
insert into user(userid,name) values(1,‘a’) 如userid 为唯一主键,即重复操作上面的业务,只
会插入一条用户数据,具备幂等性。
UPDATE tab1 SET col1=col1+1 WHERE col2=2,每次执行的结果都会发生变化,不是幂等的。
insert into user(userid,name) values(1,‘a’) 如userid 不是主键,可以重复,那上面业务多次操
作,数据都会新增多条,不具备幂等性。
四、幂等解决方案
1.Token机制
1、服务端提供了发送token 的接口。我们在分析业务的时候,哪些业务是存在幂等问题的,
就必须在执行业务前,先去获取token,服务器会把token 保存到redis 中。
2、然后调用业务接口请求时,把token 携带过去,一般放在请求头部。
3、服务器判断token 是否存在redis 中,存在表示第一次请求,然后删除token,继续执行业
务。
4、如果判断token 不存在redis 中,就表示是重复操作,直接返回重复标记给client,这样
就保证了业务代码,不被重复执行。
危险性:
1、先删除token 还是后删除token;
- (1) 先删除可能导致,业务确实没有执行,重试还带上之前token,由于防重设计导致,
请求还是不能执行。 - (2) 后删除可能导致,业务处理成功,但是服务闪断,出现超时,没有删除token,别
人继续重试,导致业务被执行两边 - (3) 我们最好设计为先删除token,如果业务调用失败,就重新获取token 再次请求。
2、Token 获取、比较和删除必须是原子性
- (1) redis.get(token) 、token.equals、redis.del(token)如果这两个操作不是原子,可能导
致,高并发下,都get 到同样的数据,判断都成功,继续业务并发执行 - (2) 可以在redis 使用lua 脚本完成这个操作
if redis.call(‘get’, KEYS[1]) == ARGV[1] then return redis.call(‘del’, KEYS[1]) else return 0 end
2、各种锁机制
1、数据库悲观锁
select * from xxxx where id = 1 for update;
悲观锁使用时一般伴随事务一起使用,数据锁定时间可能会很长,需要根据实际情况选用。
另外要注意的是,id 字段一定是主键或者唯一索引,不然可能造成锁表的结果,处理起来会
非常麻烦。
数据库悲观锁的使用场景:当我们查询库存信息时可以使用悲观锁锁住这条记录确保别人拿不到。
2、数据库乐观锁
这种方法适合在更新的场景中,
update t_goods set count = count -1 , version = version + 1 where good_id=2 and version = 1
根据version 版本,也就是在操作库存前先获取当前商品的version 版本号,然后操作的时候
带上此version 号。我们梳理下,我们第一次操作库存时,得到version 为1,调用库存服务
version 变成了2;但返回给订单服务出现了问题,订单服务又一次发起调用库存服务,当订
单服务传如的version 还是1,再执行上面的sql 语句时,就不会执行;因为version 已经变
为2 了,where 条件就不成立。这样就保证了不管调用几次,只会真正的处理一次。
乐观锁主要使用于处理读多写少的问题。
数据库乐观锁的使用场景:当我们减库存操作时,带上version=1执行成功此时version=2,但是由于网络原因没有返回执行成功标识,下一次请求过来还是带上的是version=1就无法对库存进行操作。
3、业务层分布式锁
如果多个机器可能在同一时间同时处理相同的数据,比如多台机器定时任务都拿到了相同数
据处理,我们就可以加分布式锁,锁定此数据,处理完成后释放锁。获取到锁的必须先判断
这个数据是否被处理过。
3、各种唯一约束
1、数据库唯一约束
插入数据,应该按照唯一索引进行插入,比如订单号,相同的订单就不可能有两条记录插入。
对订单号设置唯一约束
我们在数据库层面防止重复。
这个机制是利用了数据库的主键唯一约束的特性,解决了在insert 场景时幂等问题。但主键
的要求不是自增的主键,这样就需要业务生成全局唯一的主键。
如果是分库分表场景下,路由规则要保证相同请求下,落地在同一个数据库和同一表中,要
不然数据库主键约束就不起效果了,因为是不同的数据库和表主键不相关。
2、redis set 防重
很多数据需要处理,只能被处理一次,比如我们可以计算数据的MD5 将其放入redis 的set,
每次处理数据,先看这个MD5 是否已经存在,存在就不处理。
Redis set的防重场景:每个数据的MD5加密后的值唯一,网盘就可以根据上传的数据进行MD5加密,将加密后的数据存储至Redis的set里,下次你上传同样的东西时先会去set进行判断是否存在,存在就不处理。
4、防重表
使用订单号orderNo 做为去重表的唯一索引,把唯一索引插入去重表,再进行业务操作,且
他们在同一个事务中。这个保证了重复请求时,因为去重表有唯一约束,导致请求失败,避
免了幂等问题。这里要注意的是,去重表和业务表应该在同一库中,这样就保证了在同一个
事务,即使业务操作失败了,也会把去重表的数据回滚。这个很好的保证了数据一致性。
之前说的redis 防重也算
防重表的应用场景:当我们去解库存的时候,先去防重表里插入一条数据,当请求再次过来的时候,先去防重表里插入数据,只有当插入成功才能进行下一步操作。
5、全局请求唯一id
调用接口时,生成一个唯一id,redis 将数据保存到集合中(去重),存在即处理过。
可以使用nginx 设置每一个请求的唯一id;
proxy_set_header X-Request-Id $request_id;
Nginx为每一个请求设置唯一id可以用作链路追踪,看这个请求请求了那些服务
2.7.14 订单确认页完成
我们这里用token令牌机制解决幂等性。
1.订单服务的执行流程如下图所示
2. 防重令牌的编写
①注入StringRedisTemplate
OrderServiceImpl中
② 编写订单服务常量即防重令牌前缀,格式:order:token:userId
public class OrderConstant {
//订单放入redis中的防重令牌
public static final String USER_ORDER_TOKEN_PREFIX = "order:token:";
}
③ 防重令牌存储
3. 提交页面数据Vo的编写
仿照京东:京东的结算页中的商品信息是实时获取的,结算的时候会去购物车中再去获取一遍,因此,提交页面的数据Vo没必要提交商品信息
/**封装订单提交的数据
*/
@Data
public class OrderSubmitVo {
private Long addrId;//收货地址id
private Integer payType;//支付方式
//无需提交需要购买的商品,去购物车再获取一遍
//优惠,发票
private String orderToken;//防重令牌
private BigDecimal payPrice;//应付价格,验价
private String note;//订单备注
//用户相关信息,直接去session中取出登录的用户
}
4. 前端页面提交表单编写
<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">提交订单</button>
</form>
5. 为input框绑定数据
//发送Ajax获取运费信息
function getFare(addrId) {
//给表单回填选择的地址
$("#addrIdInput").val(addrId);
$.get("http://gulimall.com/api/ware/wareinfo/fare?addrId="+addrId,function (resp) {
console.log(resp);
//fareEle
$("#fareEle").text(resp.data.fare);
var total = [[${orderConfirmData.total}]];
//设置运费等
var payPrice = total*1 + resp.data.fare*1;
$("#payPriceEle").text(payPrice);
$("#payPriceInput").val(payPrice);
//设置收货人信息
$("#receiveAddressEle").text(resp.data.address.province+" "+resp.data.address.detailAddress);
$("#receiverEle").text(resp.data.address.name);
})
}
6.编写提交订单数据接口
OrderWebController
/**
* 下单功能
* @param vo
* @return
*/
@PostMapping("/submitOrder")
public String submitOrder(OrderSubmitVo vo){
//下单:去创建订单,验令牌,验价格,锁库存...
//下单成功来到支付选择页
//下单失败回到订单确认页重新确认订单信息
return null;
}
2.7.15 原子验证令牌
1.提交订单返回结果Vo编写
@Data
public class SubmitOrderResponseVo {
private OrderEntity order;//当前订单内容
private Integer code;// 0成功 错误状态码
}
2. 接口编写
OrderWebController
/**
* 下单功能
* @param vo
* @return
*/
@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";
}
}
验证令牌的核心:保证令牌的比较和删除的原子性
解决方案:使用脚本
if redis.call('get', KEYS[1]) == ARGV[1] then return redis.call('del', KEYS[1]) else return 0 end
脚本执行的返回结果:
0:代表令牌校验失败
1:代表令牌成功删除即成功
execute(arg1,arg2,arg3)参数解释:
arg1:用DefaultRedisScript的构造器封装脚本和返回值类型
arg2:数组,用于存放Redis中token的key
arg3:用于比较的token即浏览器存储的token
T:返回值的类型
@Override
public SubmitOrderResponseVo submitOrder(OrderSubmitVo vo) {
SubmitOrderResponseVo response = new SubmitOrderResponseVo();
MemberRespVo memberRespVo = LoginUserInterceptor.loginUser.get();//得到当前用户
//1、验证令牌【令牌的对比和删除必须保证原子性】
//0 令牌失败 - 1删除成功
// lua脚本实现原子性
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();
//原子验证令牌和删除令牌
Long result = redisTemplate.execute(new DefaultRedisScript<Long>(script, Long.class),
Arrays.asList(OrderConstant.USER_ORDER_TOKEN_PREFIX + memberRespVo.getId()),
orderToken);
if (result == 0L){
//令牌验证失败
return response;
}else {
//令牌验证成功
//下单:去创建订单,验令牌,验价格,锁库存...
}
return response;
}
2.7.16 构造订单数据
1.订单创建To的编写
@Data
public class OrderCreateTo {
private OrderEntity order;//当前订单内容
private List<OrderItemEntity> orderItems;//订单包含的所有订单项
private BigDecimal payPrice;//订单计算的应付价格
private BigDecimal fare;//运费
}
2. 创建订单方法编写
①订单状态枚举类的编写
直接从课件中复制过来即可。
public enum OrderStatusEnum {
CREATE_NEW(0,"待付款"),
PAYED(1,"已付款"),
SENDED(2,"已发货"),
RECIEVED(3,"已完成"),
CANCLED(4,"已取消"),
SERVICING(5,"售后中"),
SERVICED(6,"售后完成");
private Integer code;
private String msg;
OrderStatusEnum(Integer code, String msg) {
this.code = code;
this.msg = msg;
}
public Integer getCode() {
return code;
}
public String getMsg() {
return msg;
}
}
② IDWorker中的getTimeId()生成时间id,不重复,用于充当订单号
Vo的编写
@Data
public class FareVo {
//地址信息
private MemberAddressVo address;
//运费信息
private BigDecimal fare;
}
④ 远程服务调用获取地址和运费信息
WmsFeignService
//获取运费
@GetMapping("/ware/wareinfo/fare")
R getFare(@RequestParam("addrId") Long addrId);
⑤ 使用ThreadLocal,实现同一线程共享数据
⑥ 实现方法编写
OrderServiceImpl
public OrderCreateTo createOrderTo(){
OrderCreateTo createTo = new OrderCreateTo();
//1、生成订单号
String orderSn = IdWorker.getTimeId();
//创建订单号
OrderEntity orderEntity = buildOrder(orderSn);
//2、获取到所有的订单项
List<OrderItemEntity> itemEntities = bulidOrderItems();
//3、验价
return createTo;
}
/**
* 构建订单
* @param orderSn
* @return
*/
private OrderEntity buildOrder(String orderSn) {
OrderEntity entity = new OrderEntity();
entity.setOrderSn(orderSn);
OrderSubmitVo submitVo = submitVoThreadLocal.get();
//获取收货地址信息
R fare = wmsFeignService.getFare(submitVo.getAddrId());
FareVo fareResp = fare.getData(new TypeReference<FareVo>() {
});
//设置运费信息
entity.setFreightAmount(fareResp.getFare());
//设置收货人信息
entity.setReceiverCity(fareResp.getAddress().getCity());
entity.setReceiverDetailAddress(fareResp.getAddress().getDetailAddress());
entity.setReceiverName(fareResp.getAddress().getName());
entity.setReceiverPhone(fareResp.getAddress().getPhone());
entity.setReceiverPostCode(fareResp.getAddress().getPostCode());
entity.setReceiverProvince(fareResp.getAddress().getProvince());
entity.setReceiverRegion(fareResp.getAddress().getRegion());
return entity;
}
/**
* 构建所有订单项数据
* @return
*/
private List<OrderItemEntity> bulidOrderItems() {
List<OrderItemVo> currentUserCartItems = cartFeignService.getCurrentUserCartItems();
if (currentUserCartItems != null && currentUserCartItems.size() > 0){
currentUserCartItems.stream().map(cartItem ->{
OrderItemEntity itemEntity = bulidOrderItem(cartItem);
return itemEntity;
}).collect(Collectors.toList());
}
return null;
}
/**
* 构建某一个订单项
* @param cartItem
* @return
*/
private OrderItemEntity bulidOrderItem(OrderItemVo cartItem) {
return null;
}
2.7.17 构造订单项数据
1.远程服务调用,通过skuId获取Spu信息
商品服务下的SpuInfoController
/**
* 通过skuId获取Spu信息
* @param skuId
* @return
*/
@GetMapping("/skuId/{id}")
public R getSpuInfoBySkuId(@PathVariable("id") Long skuId) {
SpuInfoEntity entity = spuInfoService.getSpuInfoBySkuId(skuId);
return R.ok().setData(entity);
}
实现:SpuInfoServiceImpl
@Override
public SpuInfoEntity getSpuInfoBySkuId(Long skuId) {
SkuInfoEntity byId = skuInfoService.getById(skuId);
Long spuId = byId.getSpuId();
SpuInfoEntity spuInfoEntity = getById(spuId);
return spuInfoEntity;
}
订单服务下ProductFeignService
@FeignClient("gulimall-product")
public interface ProductFeignService {
@GetMapping("/product/spuinfo/skuId/{id}")
R getSpuInfoBySkuId(@PathVariable("id") Long skuId);
}
2. 设置订单购物项数据
编写vo
@Data
public class SpuInfoVo {
private Long id;
/**
* 商品名称
*/
private String spuName;
/**
* 商品描述
*/
private String spuDescription;
/**
* 所属分类id
*/
private Long catalogId;
/**
* 品牌id
*/
private Long brandId;
/**
*
*/
private BigDecimal weight;
/**
* 上架状态[0 - 下架,1 - 上架]
*/
private Integer publishStatus;
/**
*
*/
private Date createTime;
/**
*
*/
private Date updateTime;
}
实现方法完善
@Autowired
ProductFeignService productFeignService;
public OrderCreateTo createOrderTo(){
OrderCreateTo createTo = new OrderCreateTo();
//1、生成订单号
String orderSn = IdWorker.getTimeId();
//创建订单号
OrderEntity orderEntity = buildOrder(orderSn);
//2、获取到所有的订单项
List<OrderItemEntity> itemEntities = bulidOrderItems(orderSn);
//3、验价
return createTo;
}
/**
* 构建订单
* @param orderSn
* @return
*/
private OrderEntity buildOrder(String orderSn) {
OrderEntity entity = new OrderEntity();
entity.setOrderSn(orderSn);
OrderSubmitVo submitVo = submitVoThreadLocal.get();
//获取收货地址信息
R fare = wmsFeignService.getFare(submitVo.getAddrId());
FareVo fareResp = fare.getData(new TypeReference<FareVo>() {
});
//设置运费信息
entity.setFreightAmount(fareResp.getFare());
//设置收货人信息
entity.setReceiverCity(fareResp.getAddress().getCity());
entity.setReceiverDetailAddress(fareResp.getAddress().getDetailAddress());
entity.setReceiverName(fareResp.getAddress().getName());
entity.setReceiverPhone(fareResp.getAddress().getPhone());
entity.setReceiverPostCode(fareResp.getAddress().getPostCode());
entity.setReceiverProvince(fareResp.getAddress().getProvince());
entity.setReceiverRegion(fareResp.getAddress().getRegion());
return entity;
}
/**
* 构建所有订单项数据
* @return
*/
private List<OrderItemEntity> bulidOrderItems(String orderSn) {
//最后确定每个购物项的价格
List<OrderItemVo> currentUserCartItems = cartFeignService.getCurrentUserCartItems();
if (currentUserCartItems != null && currentUserCartItems.size() > 0){
List<OrderItemEntity> itemEntities = currentUserCartItems.stream().map(cartItem -> {
OrderItemEntity itemEntity = bulidOrderItem(cartItem);
itemEntity.setOrderSn(orderSn);
return itemEntity;
}).collect(Collectors.toList());
return itemEntities;
}
return null;
}
/**
* 构建某一个订单项
* @param cartItem
* @return
*/
private OrderItemEntity bulidOrderItem(OrderItemVo cartItem) {
OrderItemEntity itemEntity = new OrderItemEntity();
//1、订单信息:订单号
//2、商品的spu信息
Long skuId = cartItem.getSkuId();
R r = productFeignService.getSpuInfoBySkuId(skuId);
SpuInfoVo data = r.getData(new TypeReference<SpuInfoVo>() {
});
itemEntity.setSpuId(data.getId());
itemEntity.setSpuBrand(data.getBrandId().toString());
itemEntity.setSpuName(data.getSpuName());
itemEntity.setCategoryId(data.getCatalogId());
//3、商品的sku信息
itemEntity.setSkuId(cartItem.getSkuId());
itemEntity.setSkuName(cartItem.getTitle());
itemEntity.setSkuPic(cartItem.getImage());
itemEntity.setSkuPrice(cartItem.getPrice());
String skuAttr = StringUtils.collectionToDelimitedString(cartItem.getSkuAttr(), ";");
itemEntity.setSkuAttrsVals(skuAttr);
itemEntity.setSkuQuantity(cartItem.getCount());
//4、优惠信息(不做)
//5、积分信息
itemEntity.setGiftGrowth(cartItem.getPrice().intValue());
itemEntity.setGiftIntegration(cartItem.getPrice().intValue());
return itemEntity;
}
2.7.18 订单验价
1.计算单个购物项的真实价格
2.设置订单的价格
private void computePrice(OrderEntity orderEntity, List<OrderItemEntity> itemEntities) {
BigDecimal total = new BigDecimal("0.0");
BigDecimal coupon = new BigDecimal("0.0");
BigDecimal integration = new BigDecimal("0.0");
BigDecimal promotion = new BigDecimal("0.0");
BigDecimal gift = new BigDecimal("0.0");
BigDecimal growth = new BigDecimal("0.0");
//订单的总额,叠加每一个订单项的总额信息
for (OrderItemEntity entity : itemEntities) {
coupon = coupon.add(entity.getCouponAmount());
integration = integration.add(entity.getIntegrationAmount());
promotion = promotion.add(entity.getPromotionAmount());
total = total.add(entity.getRealAmount());
gift = gift.add(new BigDecimal(entity.getGiftIntegration().toString()));
growth = growth.add(new BigDecimal(entity.getGiftGrowth().toString()));
}
//1、订单价格相关
orderEntity.setTotalAmount(total);
//应付总额
orderEntity.setPayAmount(total.add(orderEntity.getFreightAmount()));
orderEntity.setPromotionAmount(promotion);
orderEntity.setIntegrationAmount(integration);
orderEntity.setCouponAmount(coupon);
//设置积分等信息
orderEntity.setIntegration(gift.intValue());
orderEntity.setGrowth(growth.intValue());
orderEntity.setDeleteStatus(0);//未删除
}
3.订单其它信息设置
4. 验价
2.7.19 保存订单数据
1.保存订单和订单项数据
①保存订单和订单项以及锁库存操作处于事务当中,出现异常需要回滚
②注入orderItemService
③保存
/**
* 保存订单数据
* @param order
*/
private void saveOrder(OrderCreateTo order){
OrderEntity orderEntity = order.getOrder();
orderEntity.setModifyTime(new Date());
this.save(orderEntity);
//保存订单项
List<OrderItemEntity> orderItems = order.getOrderItems();
orderItemService.saveBatch(orderItems);
}
2.7.20 锁定库存
锁库存逻辑
远程服务调用锁定库存
1.锁库存Vo编写
订单服务下
@Data
public class WareSkuLockVo {
private String orderSn;//根据订单号判断是否存库存成功
private List<OrderItemVo> locks;//需要锁住的所有库存信息:skuId skuName num
}
将订单服务的WareSkuLockVo和OrderItemVo复制到库存服务中
2. 锁库存响应Vo编写
库存服务下
/**商品的库存锁定状态
*/
@Data
public class LockStockResult {
private Long skuId;//那个商品
private Integer num;//锁了几件
private Boolean locked;//锁住了没有,状态
}
3. 锁库存异常类的编写
public class NoStockException extends RuntimeException{
private Long skuId;
public NoStockException(Long skuId){
super("商品id:" + skuId + ";没有足够的库存了");
}
public Long getSkuId() {
return skuId;
}
public void setSkuId(Long skuId) {
this.skuId = skuId;
}
}
4. 库存不足异常状态码编写
5.为库存表的锁库存字段设置默认值:0
6. 查询库存接口编写
WareSkuController
/**
* 锁定库存
* @param vo
* @return
*/
@PostMapping("/lock/order")
public R orderLockStock(@RequestBody WareSkuLockVo vo){
try {
Boolean stock = wareSkuService.orderLockStock(vo);
return R.ok();
}catch (NoStockException e){
return R.error(BizCodeEnume.NO_STOCK_EXCEPTION.getCode(),BizCodeEnume.NO_STOCK_EXCEPTION.getMsg());
}
}
实现:
指定抛出此异常时一定要回滚,不指定也会回滚默认运行时异常都会回滚
WareSkuServiceImpl
内部类保存商品在那些仓库有库存以及锁库存数量
@Data
class SkuWareHasStock{
private Long skuId;
private Integer num;
private List<Long> wareId;
}
锁库存实现
/**
* 为某个订单锁定库存
*
*
* (rollbackFor = NoStockException.class)
* 默认只要是运行时异常就会回滚
* @param vo
* @return
*/
@Transactional
@Override
public Boolean orderLockStock(WareSkuLockVo vo) {
//1、按照下单的收货地址,找到一个就近仓库,锁定库存。//暂时不这样做
//1、找到每个商品在哪个仓库都有库存
List<OrderItemVo> locks = vo.getLocks();
List<SkuWareHasStock> collect = locks.stream().map(item -> {
SkuWareHasStock stock = new SkuWareHasStock();
Long skuId = item.getSkuId();
stock.setSkuId(skuId);
stock.setNum(item.getCount());
//查询这个商品在哪里有库存
List<Long> wareIds = wareSkuDao.listWareIdHasSkuStock(skuId);
stock.setWareId(wareIds);
return stock;
}).collect(Collectors.toList());
//2、锁定库存
for (SkuWareHasStock hasStock : collect) {
Boolean skuStocked = false;//标识位
Long skuId = hasStock.getSkuId();
List<Long> wareIds = hasStock.getWareId();
if (wareIds == null || wareIds.size() == 0){
//没有任何仓库有这个商品的库存
throw new NoStockException(skuId);
}
for (Long wareId : wareIds) {
//成功就返回的是1行受影响,否则就是0行受影响
Long count = wareSkuDao.lockSkuStock(skuId,wareId,hasStock.getNum());
if (count == 1){
skuStocked = true;
break;
}else {
//当前仓库锁定失败,重试下一个仓库
}
}
if (skuStocked == false){
//当前商品所有仓库都没有锁住
throw new NoStockException(skuId);
}
}
return true;
}
WareSkuDao
@Mapper
public interface WareSkuDao extends BaseMapper<WareSkuEntity> {
void addStock(@Param("skuId") Long skuId, @Param("wareId") Long wareId, @Param("skuNum") Integer skuNum);
Long getSkuStock(Long skuId);//一个参数的话,可以不用写@Param,多个参数一定要写,方便区分
List<Long> listWareIdHasSkuStock(@Param("skuId") Long skuId);
Long lockSkuStock(@Param("skuId") Long skuId, @Param("wareId") Long wareId, @Param("num") Integer num);
}
相应SQL语句的由来:
SELECT ware_id FROM `wms_ware_sku` WHERE sku_id = 1 AND stock - stock_locked > 0
UPDATE `wms_ware_sku` SET stock_locked = stock_locked + 1
WHERE sku_id = 1 AND ware_id = 1 AND stock-stock_locked >= 1
<update id="lockSkuStock">
UPDATE `wms_ware_sku` SET stock_locked = stock_locked + #{num}
WHERE sku_id = #{skuId} AND ware_id = #{wareId} AND stock-stock_locked >= ${num}
</update>
<select id="listWareIdHasSkuStock" resultType="java.lang.Long">
SELECT ware_id FROM `wms_ware_sku` WHERE sku_id = #{skuId} AND stock - stock_locked > 0
</select>
7. 远程服务调用
订单服务下
8、接口完善
OrderWebController
/**
* 下单功能
* @param vo
* @return
*/
@PostMapping("/submitOrder")
public String submitOrder(OrderSubmitVo vo,Model model){
SubmitOrderResponseVo responseVo = orderService.submitOrder(vo);
if (responseVo.getCode() == 0){
//下单成功来到支付选择页
model.addAttribute("submitOrderResp",responseVo);
return "pay";
}else{
//下单失败回到订单确认页重新确认订单信息
return "redirect:http://order.gulimall.com/toTrade";
}
}
OrderServiceImpl
@Transactional
@Override
public SubmitOrderResponseVo submitOrder(OrderSubmitVo vo) {
submitVoThreadLocal.set(vo);//放到线程中共享数据
SubmitOrderResponseVo response = new SubmitOrderResponseVo();
MemberRespVo memberRespVo = LoginUserInterceptor.loginUser.get();//得到当前用户
response.setCode(0);
//1、验证令牌【令牌的对比和删除必须保证原子性】
//0 令牌失败 - 1删除成功
// lua脚本实现原子性
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();
//原子验证令牌和删除令牌
Long result = redisTemplate.execute(new DefaultRedisScript<Long>(script, Long.class),
Arrays.asList(OrderConstant.USER_ORDER_TOKEN_PREFIX + memberRespVo.getId()),
orderToken);
if (result == 0L) {
//令牌验证失败
response.setCode(1);//设置为失败状态
return response;
} else {
//令牌验证成功
//下单:去创建订单,验令牌,验价格,锁库存...
//1、创建订单、订单项等信息
OrderCreateTo order = createOrderTo();
//2、验价
BigDecimal payAmount = order.getOrder().getPayAmount();
BigDecimal payPrice = vo.getPayPrice();
//页面提交价格与计算的价格相差小于0.01则验价成功
if (Math.abs(payAmount.subtract(payPrice).doubleValue()) < 0.01) {
//金额对比
//.....
//3、保存订单
saveOrder(order);
//4、库存锁定。只要有异常回滚订单数据。
WareSkuLockVo lockVo = new WareSkuLockVo();
lockVo.setOrderSn(order.getOrder().getOrderSn());
List<OrderItemVo> locks = order.getOrderItems().stream().map(item -> {
OrderItemVo itemVo = new OrderItemVo();
itemVo.setSkuId(item.getSkuId());
itemVo.setCount(item.getSkuQuantity());
itemVo.setTitle(item.getSkuName());
return itemVo;
}).collect(Collectors.toList());
lockVo.setLocks(locks);
// TODO 远程锁库存
R r = wmsFeignService.orderLockStock(lockVo);
if (r.getCode() == 0){
//锁成功了
response.setOrder(order.getOrder());
return response;
}else{
//锁定失败
response.setCode(3);
return response;
}
} else {
response.setCode(2);
return response;
}
}
}
2.7.21 提交订单的问题
1.订单号显示、应付金额回显
ps:之前代码忘记创建订单之后相应数据,这个方法应该补充以下两项。
出现问题:orderSn长度过长
解决方案:数据库的表中的对应字段长度增大
2.提交订单消息回显
confirm.html
3. 为了确保锁库存失败后,订单和订单项也能回滚,需要抛出异常
修改 NoStockException,将库存下的这个异常类直接放到公共服务下。并添加一个构造器。
orderServiceImpl
OrderWebController