本文将详细介绍云商城的购物车服务。云商城完整项目介绍见文章 【Project】云商城
购物车服务
购物车服务 mall-cart
负责将用户挑选好的 SKU 添加到购物车中。添加后的效果如下:
购物车服务需要实现的功能:
登录用户可以添加购物车,并且在结算购物车前该信息一直保留
临时用户(未登录用户)也可以添加购物车,并且该信息可以保留 30 天
临时用户在 30 天内再次访问本网站仍然能看到之前添加过的购物车信息
临时用户一旦登录,就会把其之前添加的商品一起合并 到自己登录用户的购物车里
实现思路:
为实现购物车信息一直保留,需要将购物车的信息一直存放在 Redis 中 ,并且开启 Reids 的持久化 。
为实现临时用户功能,需要使用拦截器 ,判断每个访问本网站的用户是否已登录(Redis 中的 Session 是否存储了 loginUser
数据),如果没登录过就要为其设置一个唯一标识 user-key
并且以 Cookie 的形式保存在浏览器中 30 天 。这样下次临时用户登录时仍然能获取其购物车数据
购物车前端页面
前端页面结构:
配置过程不再赘述,可参考其他服务的配置方法。
数据模型设计
购物车模型 CartVo
一个购物车 CartVo
对象中保存着多个购物项 CartItemVo
,这些购物项以 List 的形式保存在购物车对象中:
1 2 3 4 5 6 7 8 public class CartVo { List<CartItemVo> items; }
同时,购物车对象中还需要保存商品数量 、商品类型数量 、商品总价 、减免价格 信息。完整的购物车 CartVo
类定义:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 public class CartVo { List<CartItemVo> items; private Integer countNum; private Integer countType; private BigDecimal totalAmount; private BigDecimal reduce = new BigDecimal("0.00" ); public List<CartItemVo> getItems () { return items; } public void setItems (List<CartItemVo> items) { this .items = items; } public Integer getCountNum () { int count=0 ; if (items != null && items.size() > 0 ) { for (CartItemVo item : items) { count += item.getCount(); } } return count; } public void setCountNum (Integer countNum) { this .countNum = countNum; } public Integer getCountType () { int count = 0 ; if (items != null && items.size() > 0 ) { for (CartItemVo item : items) { count += 1 ; } } return count; } public void setCountType (Integer countType) { this .countType = countType; } public BigDecimal getTotalAmount () { BigDecimal total = new BigDecimal(0 ); if (items != null && items.size() > 0 ) { for (CartItemVo item : items) { total = total.add(item.getTotalPrice()); } } total = total.subtract(reduce); return total; } public void setTotalAmount (BigDecimal totalAmount) { this .totalAmount = totalAmount; } public BigDecimal getReduce () { return reduce; } public void setReduce (BigDecimal reduce) { this .reduce = reduce; } }
注意 :购物车中的总价不应该人为设置,而应该自动计算:该对象被转换成 JSON 格式时,该方法就会被调用 ,从而更新购物车总价。
购物项模型 CartItemVo
购物项 CartItemVo
以 SKU 为单位,包含了 skuId
、是否选中 、标题 、图片、SKU 销售属性 、价格、数量、总价 等信息。完整定义:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 public class CartItemVo { private Long skuId; private Boolean check = true ; private String title; private String image; private List<String> skuAttrValues; 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> getSkuAttrValues () { return skuAttrValues; } public void setSkuAttrValues (List<String> skuAttrValues) { this .skuAttrValues = skuAttrValues; } 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; } public BigDecimal getTotalPrice () { return price.multiply(new BigDecimal(count)); } public void setTotalPrice (BigDecimal totalPrice) { this .totalPrice = totalPrice; } }
注意 :购物项总价的计算同样是在 get 方法中,从在转 JSON 时重新计算总价。
购物车在 Redis 中的数据结构
购物车和购物项信息只保存到 Redis 中,不存储在数据库里
根据前面的定义,一个购物车包含了多个购物项。那么我们就可以采用 Redis 中的 HASH
结构保存购物车和购物项。这样做的好处是可以使用哈希的特性快速增删改查购物车中的某个购物项 。
在 Redis 中的保存结构可抽象为:Map<String, Map<String, Cart>>
。
最外层的 key 为用户 id ,因为每个用户拥有自己唯一的购物车,所有可以使用用户 id 区分不同的购物车。
内层的 key 为该用户的购物车中保存的购物项 id 。一个购物车中可以有多个购物项,使用 HASH 保存起来
该结构在 Redis 中具体为:userId : Hash(cartItemId : cartItem)
key
:用户 id
value
:HASH 结构(key : 购物车项 id,value:购物车项信息)
图示:
在 Redis 中的实际保存效果如下:
共有两个购物车,分别为在线购物车(登录用户)和离线购物车(临时用户)
key
:用户 id
value
:HASH 结构(key : 购物车项 id,value:购物车项信息)
临时用户身份识别
为了给临时用户也开通购物车功能 ,需要先能判断出当前请求是否已登陆过。如果没登录过就要为其设置一个唯一标识 user-key
并且以 Cookie 的形式保存在浏览器中 30 天 。这样该临时用户在关闭浏览器后再次访问时,本网站仍然能识别出其身份,然后显示出该临时用户之前添加过的购物车信息。
首先需要完成临时用户身份识别 功能。为了完成该功能,我们可以使用 Spring 的拦截器机制 实现身份认证与业务代码的解耦合。
添加拦截器。在执行 Controller 层的方法之前,先进行拦截,判断当前请求是否已登录:
如果已登录,则设置 userInfoTo.userId
为用户 id
如果是临时用户,先判断 Cookie 中是否有 user-key
如果有,代表该临时用户之前访问过本网站,则设置 userInfoTo.userKey
为 user-key
对应的值
如果没有,代表该临时用户是第一次访问本网站,则创建一个 Cookie: user-key : uuid
,并返回给浏览器令其保存
同时,需要将 userInfoTo
保存到 ThreadLocal
里,在当前线程内共享,以便 Service 层能获取到该数据。拦截器的完整代码:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 public class CartInterceptor implements HandlerInterceptor { public static ThreadLocal<UserInfoTo> threadLocal = new ThreadLocal<>(); @Override public boolean preHandle (HttpServletRequest request, HttpServletResponse response, Object handler) throws Exception { HttpSession session = request.getSession(); MemberResponseVo memberResponseVo = (MemberResponseVo) session.getAttribute(AuthServerConstant.LOGIN_USER); UserInfoTo userInfoTo = new UserInfoTo(); if (memberResponseVo != null ){ userInfoTo.setUserId(memberResponseVo.getId()); } Cookie[] cookies = request.getCookies(); if (cookies != null && cookies.length > 0 ) { for (Cookie cookie : cookies) { if (cookie.getName().equals(CartConstant.TEMP_USER_COOKIE_NAME)) { userInfoTo.setUserKey(cookie.getValue()); userInfoTo.setTempUser(true ); } } } if (StringUtils.isEmpty(userInfoTo.getUserKey())) { String uuid = UUID.randomUUID().toString(); userInfoTo.setUserKey(uuid); } threadLocal.set(userInfoTo); return true ; } @Override public void postHandle (HttpServletRequest request, HttpServletResponse response, Object handler, ModelAndView modelAndView) throws Exception { UserInfoTo userInfoTo = threadLocal.get(); if (!userInfoTo.getTempUser()) { Cookie cookie = new Cookie(CartConstant.TEMP_USER_COOKIE_NAME, userInfoTo.getUserKey()); cookie.setDomain("yunmall.com" ); cookie.setMaxAge(CartConstant.TEMP_USER_COOKIE_TIMEOUT); response.addCookie(cookie); } threadLocal.remove(userInfoTo); } }
注册该拦截器,设置对所有请求拦截:
1 2 3 4 5 6 7 @Configuration public class MallWebConfig implements WebMvcConfigurer { @Override public void addInterceptors (InterceptorRegistry registry) { registry.addInterceptor(new CartInterceptor()).addPathPatterns("/**" ); } }
这样就会在每个请求处理前先进行拦截,对请求进行判断其登录状态,并设置 userInfoTo
对象到 ThreadLocal
中,以便 Service 层能获取到该数据
UserInfoTo
类用户保存用户的身份信息。已登录用户使用 userId
识别;临时用户使用 userKey
识别:
1 2 3 4 5 6 7 8 9 @Data public class UserInfoTo { private Long userId; private String userKey; private Boolean tempUser = false ; }
在 Cookie 中的保存效果:
YUNSESSIONID
:登录用户的 Cookie
user-key
:临时用户的 Cookie(第一次访问时为其创建,在 30 天内将一直保存在浏览器中)
添加购物项
在商品详情页点击【加入购物车】按钮后,将发出请求 /addCartItem
访问购物车服务,在 URL 中传入要加入的 skuId
以及数量 num
。
Controller 层的 addCartItem()
方法会响应该请求:
首先将该 skuId
对应的信息查出后封装成购物项 CartItemVo
,并存放到 Redis 里该用户的购物车中
添加完毕后,需要重定向 到添加成功页 success.html
。否则如果直接转发到 success.html
的话,重复刷新页面后该方法会重复执行 ,导致购物项的数量一直增加。因此必须要重定向到 success.html
页面,并且在 URL 中带上一个 skuId
参数 。具体做法是再添加一个 addCartItemSuccess()
方法,该方法将根据 URL 中的 skuId
重新去 Redis 中查一遍购物项信息并存到请求域中,然后转发到 success.html
页面。相当于 addCartItemSuccess()
方法做了一个中转,防止重复提交购物项。
这样即使重复刷新浏览器页面,也不会多次执行 addCartItem()
方法,而是会多次执行 addCartItemSuccess()
方法(只是重复查询而已),从而保证 addCartItem()
方法只会执行一遍,避免了重复添加购物项。
因为转发时,浏览器的 URL 不会改变,仍然是转发前的 URL,因此多次刷新就会多次执行这个旧的 URL,导致重复执行。但重定向后,浏览器的 URL 已经发生了改变,此时再次刷新就只会重复执行这个新的 URL,不会再执行旧的 URL。因此只需要将新的 URL 设置为幂等性的查询操作即可。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 @RequestMapping("/addCartItem") public String addCartItem (@RequestParam("skuId") Long skuId, @RequestParam("num") Integer num, RedirectAttributes attributes) { cartService.addCartItem(skuId, num); attributes.addAttribute("skuId" , skuId); return "redirect:http://cart.yunmall.com/addCartItemSuccess" ; } @RequestMapping("/addCartItemSuccess") public String addCartItemSuccess (@RequestParam("skuId") Long skuId, Model model) { CartItemVo cartItemVo = cartService.getCartItem(skuId); model.addAttribute("cartItem" , cartItemVo); return "success" ; }
其中:
RedirectAttributes.addFlashAttribute()
:将数据放在 Session 中,但是只能取一次,使用 session.xxx
获取值
RedirectAttributes.addAttribute()
:将数据拼接在 URL 后面,使用 @RequestParam("skuId")
获取值
Service 层:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 @Override public CartItemVo addCartItem (Long skuId, Integer num) { BoundHashOperations<String, Object, Object> ops = getCartItemOps(); String cartJson = (String) ops.get(skuId.toString()); if (!StringUtils.isEmpty(cartJson)) { CartItemVo cartItemVo = JSON.parseObject(cartJson, CartItemVo.class); cartItemVo.setCount(cartItemVo.getCount() + num); String jsonString = JSON.toJSONString(cartItemVo); ops.put(skuId.toString(), jsonString); return cartItemVo; } else { CartItemVo cartItemVo = new CartItemVo(); CompletableFuture<Void> future1 = CompletableFuture.runAsync(() -> { R r = productFeignService.info(skuId); System.out.println(r.getClass()); String json = JSONObject.toJSONString(r.get("skuInfo" )); SkuInfoVo skuInfo = JSONObject.parseObject(json, new TypeReference<SkuInfoVo>() { }); cartItemVo.setCheck(true ); cartItemVo.setCount(num); cartItemVo.setImage(skuInfo.getSkuDefaultImg()); cartItemVo.setPrice(skuInfo.getPrice()); cartItemVo.setSkuId(skuId); cartItemVo.setTitle(skuInfo.getSkuTitle()); }, executor); CompletableFuture<Void> future2 = CompletableFuture.runAsync(() -> { List<String> attrValuesAsString = productFeignService.getSkuSaleAttrValuesAsString(skuId); cartItemVo.setSkuAttrValues(attrValuesAsString); }, executor); try { CompletableFuture.allOf(future1, future2).get(); } catch (Exception e) { e.printStackTrace(); } String toJSONString = JSON.toJSONString(cartItemVo); ops.put(skuId.toString(), toJSONString); return cartItemVo; } } @Override public CartItemVo getCartItem (Long skuId) { BoundHashOperations<String, Object, Object> cartItemOps = getCartItemOps(); String s = (String) cartItemOps.get(skuId.toString()); CartItemVo cartItemVo = JSON.parseObject(s, CartItemVo.class); return cartItemVo; } private BoundHashOperations<String, Object, Object> getCartItemOps () { UserInfoTo userInfoTo = CartInterceptor.threadLocal.get(); if (!StringUtils.isEmpty(userInfoTo.getUserId())) { return redisTemplate.boundHashOps(CartConstant.CART_PREFIX + userInfoTo.getUserId()); } else { return redisTemplate.boundHashOps(CartConstant.CART_PREFIX + userInfoTo.getUserKey()); } }
存储在 Redis 中的购物车不设置过期时间,只有在合并时才会删除临时购物车
跳转到 success.html
页面后:
购物项在 Redis 中的实际保存效果如下:
购物车和购物项信息只保存到 Redis 中,不存储在数据库里
展示购物车
在 success.html
页面点击【去购物车结算】后将跳转到 cartList.html
展示购物车中所有购物项的信息。其中,需要实现合并在线购物车和临时购物车 的功能:
如果浏览器中存在 Cookie user-key
,代表当前用户之前以临时用户身份登陆过本网站,其可能添加了购物车(存储在 Reids 中)。那么当该用户登录时,在查询购物车信息时,需要将其之前存储在 Redis 中的离线购物车信息合并到登录用户的在线购物车中
如果浏览器中不存在 Cookie user-key
,则不需要进行合并操作,直接展示登录用户的在线购物车信息即可
Controller 层:
1 2 3 4 5 6 7 8 9 10 11 @GetMapping("/cart.html") public String cartListPage (Model model) { CartVo cart = cartService.getCart(); model.addAttribute("cart" , cart); return "cartList" ; }
Service 层需要额外判断是否需要合并:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 @Override public CartVo getCart () { CartVo cartVo = new CartVo(); UserInfoTo userInfoTo = CartInterceptor.threadLocal.get(); List<CartItemVo> tempCart = getCartByKey(userInfoTo.getUserKey()); if (StringUtils.isEmpty(userInfoTo.getUserId())) { List<CartItemVo> cartItemVos = tempCart; cartVo.setItems(cartItemVos); }else { List<CartItemVo> userCart = getCartByKey(userInfoTo.getUserId().toString()); if (tempCart!= null && tempCart.size() > 0 ){ BoundHashOperations<String, Object, Object> ops = redisTemplate.boundHashOps(CartConstant.CART_PREFIX + userInfoTo.getUserId()); for (CartItemVo cartItemVo : tempCart) { userCart.add(cartItemVo); addCartItem(cartItemVo.getSkuId(), cartItemVo.getCount()); } } cartVo.setItems(userCart); redisTemplate.delete(CartConstant.CART_PREFIX + userInfoTo.getUserKey()); } return cartVo; }
cartList.html
购物车展示效果:
购物项勾选、增加和删除
在购物车页面中,还需要实现购物项的勾选、增加和删除功能。
Controller 层:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 @RequestMapping("/checkCart") public String checkCart (@RequestParam("isChecked") Integer isChecked,@RequestParam("skuId") Long skuId) { cartService.checkCart(skuId, isChecked); return "redirect:http://cart.yunmall.com/cart.html" ; } @RequestMapping("/countItem") public String changeItemCount (@RequestParam("skuId") Long skuId, @RequestParam("num") Integer num) { cartService.changeItemCount(skuId, num); return "redirect:http://cart.yunmall.com/cart.html" ; } @RequestMapping("/deleteItem") public String deleteItem (@RequestParam("skuId") Long skuId) { cartService.deleteItem(skuId); return "redirect:http://cart.yunmall.com/cart.html" ; } @ResponseBody @RequestMapping("/getCheckedItems") public List<CartItemVo> getCheckedItems () { return cartService.getCheckedItems(); }
Service 层:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 @Override public void checkCart (Long skuId, Integer isChecked) { BoundHashOperations<String, Object, Object> ops = getCartItemOps(); String cartJson = (String) ops.get(skuId.toString()); CartItemVo cartItemVo = JSON.parseObject(cartJson, CartItemVo.class); cartItemVo.setCheck(isChecked==1 ); ops.put(skuId.toString(),JSON.toJSONString(cartItemVo)); } @Override public void changeItemCount (Long skuId, Integer num) { BoundHashOperations<String, Object, Object> ops = getCartItemOps(); String cartJson = (String) ops.get(skuId.toString()); CartItemVo cartItemVo = JSON.parseObject(cartJson, CartItemVo.class); cartItemVo.setCount(num); ops.put(skuId.toString(),JSON.toJSONString(cartItemVo)); } @Override public void deleteItem (Long skuId) { BoundHashOperations<String, Object, Object> ops = getCartItemOps(); ops.delete(skuId.toString()); } @Override public List<CartItemVo> getCheckedItems () { UserInfoTo userInfoTo = CartInterceptor.threadLocal.get(); List<CartItemVo> cartByKey = getCartByKey(userInfoTo.getUserId().toString()); return cartByKey.stream().filter(CartItemVo::getCheck).collect(Collectors.toList()); } private List<CartItemVo> getCartByKey (String userKey) { BoundHashOperations<String, Object, Object> ops = redisTemplate.boundHashOps(CartConstant.CART_PREFIX+userKey); List<Object> values = ops.values(); if (values != null && values.size() > 0 ) { List<CartItemVo> cartItemVos = values.stream().map(obj -> { String json = (String) obj; return JSON.parseObject(json, CartItemVo.class); }).collect(Collectors.toList()); return cartItemVos; } return null ; }