## 第11章-购物车
**学习目标:**
- 能够说出购物车业务需求
- 搭建购物车模块
- 完成新增购物车功能
- 完成购物车合并功能
- 完成购物车商品选中删除购物车功能
# 1、购物车业务简介
![img](assets/day11/wps1.png)
购物车模块要能过存储顾客所选的的商品,记录下所选商品,还要能随时更新,当用户决定购买时,用户可以选择决定购买的商品进入结算页面。
功能要求:
1) 利用**缓存**提高性能。
2) 未登录状态也可以存入购物车,一旦用户登录要进行合并操作。
# 2、购物车模块搭建
购物车添加展示流程:
![img](assets/day11/wps2.jpg)
## 2.1 搭建service-cart服务
选中`gmall-service`父工程,创建子模块:service-cart 。搭建方式如service-item
## 2.2 配置pom.xml
```xml
gmall-service
com.atguigu.gmall
1.0
4.0.0
service-cart
com.atguigu.gmall
service-product-client
1.0
service-cart
org.springframework.boot
spring-boot-maven-plugin
```
## 2.3 启动类
```java
package com.atguigu.gmall;
import org.springframework.boot.SpringApplication;
import org.springframework.boot.autoconfigure.SpringBootApplication;
import org.springframework.boot.autoconfigure.jdbc.DataSourceAutoConfiguration;
import org.springframework.cloud.client.discovery.EnableDiscoveryClient;
import org.springframework.cloud.openfeign.EnableFeignClients;
import org.springframework.context.annotation.ComponentScan;
@EnableFeignClients
@EnableDiscoveryClient
@SpringBootApplication(exclude = DataSourceAutoConfiguration.class)
public class CartApp {
public static void main(String[] args) {
SpringApplication.run(CartApp.class, args);
}
}
```
## 2.3 添加配置文件
在resources目录下创建:bootstrap.properties
```properties
spring.application.name=service-cart
spring.profiles.active=dev
spring.cloud.nacos.discovery.server-addr=192.168.200.128:8848
spring.cloud.nacos.config.server-addr=192.168.200.128:8848
spring.cloud.nacos.config.prefix=${spring.application.name}
spring.cloud.nacos.config.file-extension=yaml
spring.cloud.nacos.config.shared-configs[0].data-id=common.yaml
```
# 3、功能—添加入购物车
## 3.1 功能解析:
1、 商品详情页添加购物车
2、 添加购物车,用户可以不需要登录,如果用户没有登录,则生成临时用户id,购物车商品与临时用户id关联,当用户登录后,将临时用户id的购物车商品与登录用户id的商品合并
3、 商品详情添加购物车时,先判断用户是否登录,如果没登录,再判断是否存在临时用户,如果cookie中也没有临时用户,则生成临时用户
## 3.2 处理临时用户
### 3.2.1 商品详情页
商品详情添加购物车页面方法(/item/index.html):
```js
addToCart() {
// 判断是否登录和是否存在临时用户,如果都没有,添加临时用户
if(!auth.isTokenExist() && !auth.isUserTempIdExist()) {
auth.setUserTempId()
}
window.location.href = 'http://cart.gmall.com/addCart.html?skuId=' + this.skuId + '&skuNum=' + this.skuNum
}
```
### 3.2.2 服务网关处理
思路:既然userId是从服务网关统一传递过来的,那么临时用户id我们也可以从网关传递过来,改造网关
网关中获取临时用户id
在`gmall-gateway` 项目中过滤器`AuthGlobalFilter`添加
```java
/**
* 尝试获取临时用户ID
*
* @param request
* @return
*/
private String getUserTempId(ServerHttpRequest request) {
String userTempId = "";
//1.尝试从cookie中获取
List cookieList = request.getCookies().get("userTempId");
if (!CollectionUtils.isEmpty(cookieList)) {
userTempId = cookieList.get(0).getValue();
return userTempId;
}
//2.尝试从请求头中获取
userTempId = request.getHeaders().getFirst("userTempId");
if(StringUtils.isNotBlank(userTempId)){
return userTempId;
}
return userTempId;
}
```
将userTempId 添加header请求头
```java
//5.将获取到用户ID设置到请求头中,将用户ID传输到目标微服务
if (StringUtils.isNotBlank(userId)) {
request.mutate().header("userId", userId);
}
//将获取到临时用户ID设置到请求头
String userTempId = getUserTempId(request);
if (StringUtils.isNotBlank(userTempId)) {
request.mutate().header("userTempId", userTempId);
}
```
AuthContextHolder类添加公共方法
```java
package com.atguigu.gmall.common.util;
import javax.servlet.http.HttpServletRequest;
/**
* @author: atguigu
* @create: 2023-01-11 10:31
*/
public class AuthContextHolder {
/**
* 从请求对象中获取用户ID
*
* @return
*/
public static String getUserId(HttpServletRequest request) {
String userId = request.getHeader("userId");
return org.apache.commons.lang.StringUtils.isNotBlank(userId) ? userId : "";
}
/**
* 从请求对象中获取临时用户ID
*
* @return
*/
public static String getUserTempId(HttpServletRequest request) {
String userTempId = request.getHeader("userTempId");
return org.apache.commons.lang.StringUtils.isNotBlank(userTempId) ? userTempId : "";
}
}
```
## 3.3 功能开发:
> YAPI接口地址:http://192.168.200.128:3000/project/11/interface/api/683
### 3.3.1 创建实体
```java
package com.atguigu.gmall.model.cart;
import com.atguigu.gmall.model.activity.CouponInfo;
import com.atguigu.gmall.model.base.BaseEntity;
import com.baomidou.mybatisplus.annotation.TableField;
import com.baomidou.mybatisplus.annotation.TableName;
import io.swagger.annotations.ApiModel;
import io.swagger.annotations.ApiModelProperty;
import lombok.Data;
import java.math.BigDecimal;
import java.sql.Timestamp;
import java.util.List;
@Data
@ApiModel(description = "购物车")
//@TableName("cart_info")
public class CartInfo extends BaseEntity {
private static final long serialVersionUID = 1L;
@ApiModelProperty(value = "用户id")
private String userId;
@ApiModelProperty(value = "skuid")
private Long skuId;
@ApiModelProperty(value = "放入购物车时价格")
private BigDecimal cartPrice; // 1999
@ApiModelProperty(value = "数量")
private Integer skuNum;
@ApiModelProperty(value = "图片文件")
private String imgUrl;
// 根据skuId ---> skuInfo 找名称, 减少关联查询,提供检索效率.
@ApiModelProperty(value = "sku名称 (冗余)")
private String skuName;
// 选择状态 默认 1 = 选中 0 = 未选中
@ApiModelProperty(value = "isChecked")
private Integer isChecked = 1;
// 实时价格 skuInfo.price
BigDecimal skuPrice; // 元旦 1888 | 提示 比加入时,降价了,还是涨价了
// 优惠券信息列表
@ApiModelProperty(value = "购物项对应的优惠券信息")
@TableField(exist = false)
private List couponInfoList;
}
```
### 3.3.2 添加购物车控制器
> YAPI接口文档:http://192.168.200.128:3000/project/11/interface/api/683
```java
package com.atguigu.gmall.controller;
import com.atguigu.gmall.service.CartService;
import com.atguigu.gmall.common.result.Result;
import com.atguigu.gmall.common.util.AuthContextHolder;
import org.apache.commons.lang.StringUtils;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PathVariable;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;
import javax.servlet.http.HttpServletRequest;
/**
* @author: atguigu
* @create: 2023-01-11 10:46
*/
@RestController
@RequestMapping("/api/cart")
public class CarApiController {
@Autowired
private CartService cartService;
/**
* 用户将商品加入到购物车
*
* @param skuId 商品SKUID
* @param skuNum 商品加购的数量
* @return
*/
@GetMapping("/addToCart/{skuId}/{skuNum}")
public Result addToCart(HttpServletRequest request, @PathVariable("skuId") Long skuId, @PathVariable("skuNum") Integer skuNum) {
//声明用户ID遍历:可能是登录用户ID也可能是临时用户ID
String userId = "";
userId = AuthContextHolder.getUserId(request);
if (StringUtils.isBlank(userId)) {
userId = AuthContextHolder.getUserTempId(request);
}
cartService.addToCart(userId, skuId, skuNum);
return Result.ok();
}
}
```
### 3.3.3 业务接口
```java
package com.atguigu.gmall.service;
public interface CartService {
/**
* 用户将商品加入到购物车
*
* @param userId 用户ID
* @param skuId 商品SKUID
* @param skuNum 商品加购的数量
* @return
*/
void addToCart(String userId, Long skuId, Integer skuNum);
}
```
### 3.3.4 业务实现类
定义业务需要使用的常量,RedisConst类
```java
public static final String USER_KEY_PREFIX = "user:";
public static final String USER_CART_KEY_SUFFIX = ":cart";
public static final long USER_CART_EXPIRE = 60 * 60 * 24 * 30;
```
```java
package com.atguigu.gmall.service.impl;
import com.atguigu.gmall.common.util.DateUtil;
import com.atguigu.gmall.service.CartService;
import com.atguigu.gmall.cart.model.CartInfo;
import com.atguigu.gmall.common.constant.RedisConst;
import com.atguigu.gmall.product.client.ProductFeignClient;
import com.atguigu.gmall.product.model.SkuInfo;
import org.apache.commons.lang.StringUtils;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.data.redis.core.BoundHashOperations;
import org.springframework.data.redis.core.RedisTemplate;
import org.springframework.stereotype.Service;
import java.util.*;
/**
* @author: atguigu
* @create: 2023-01-11 10:47
*/
@Service
public class CartServiceImpl implements CartService {
@Autowired
private RedisTemplate redisTemplate;
@Autowired
private ProductFeignClient productFeignClient;
/**
* 用户将商品加入到购物车-存储到Redis
*
* @param userId
* @param skuId 商品SKUID
* @param skuNum 商品加购的数量
* @return
*/
@Override
public void addToCart(String userId, Long skuId, Integer skuNum) {
//1.构建购物车结构 redisKey 包含登录用户ID或者临时用户ID
//1.1 登录用户ID作为 形式:user:1:cart
String redisKey = getCartKey(userId);
//1.2 创建hash操作对象 - 决定操作数据都是传入用户购物车数据
//BoundHashOperations
BoundHashOperations hashOps = redisTemplate.boundHashOps(redisKey);
//2.参数中skuID作为hashKey 标识商品 注意:hashKey类型必须是字符串
String hashKey = skuId.toString();
//3.构建hashVal 将商品信息+数量
//3.1 远程调用商品微服务获取商品信息
SkuInfo skuInfo = productFeignClient.getSkuInfo(skuId);
if (skuInfo != null) {
CartInfo cartInfo = null;
if (hashOps.hasKey(skuId.toString())) {
cartInfo = hashOps.get(skuId.toString());
cartInfo.setSkuNum(cartInfo.getSkuNum() + skuNum);
} else {
//3.2 封装商品购物车对象
cartInfo = new CartInfo();
cartInfo.setSkuId(skuId);
cartInfo.setImgUrl(skuInfo.getSkuDefaultImg());
cartInfo.setSkuName(skuInfo.getSkuName());
cartInfo.setSkuNum(skuNum);
cartInfo.setIsChecked(1);
cartInfo.setCreateTime(new Date());
cartInfo.setUpdateTime(new Date());
cartInfo.setUserId(userId);
cartInfo.setCartPrice(productFeignClient.getSkuPrice(skuId));
cartInfo.setSkuPrice(productFeignClient.getSkuPrice(skuId));
}
//3.3 将数据存入Redis
//redisTemplate.opsForHash().put(redisKey, hashKey, cartInfo);
hashOps.put(hashKey, cartInfo);
}
}
/**
* 获取用户对应hash操作的Key
*
* @param userId 用户ID
* @return
*/
private String getCartKey(String userId) {
return RedisConst.USER_KEY_PREFIX + userId + RedisConst.USER_CART_KEY_SUFFIX;
}
}
```
# 4、功能—展示购物车列表
> YAPI接口地址:http://192.168.200.128:3000/project/11/interface/api/675
## 4.1 功能解析
## 4.2 控制器:CartApiController
```java
/**
* 查询用户购物车列表
* 版本1:分别查询未登录购物车列表,以及登录的购物车列表
* 版本2:将两个购物车中商品合并
* @param request
* @return
*/
@GetMapping("/cartList")
public Result> cartList(HttpServletRequest request){
//获取登录用户ID
String userId = AuthContextHolder.getUserId(request);
//获取临时用户ID
String userTempId = AuthContextHolder.getUserTempId(request);
List cartInfoList = cartService.cartList(userId, userTempId);
return Result.ok(cartInfoList);
}
```
## 4.3 购物车列表接口:CartService
```java
/**
* 查询用户购物车列表
* 版本1:分别查询未登录购物车列表,以及登录的购物车列表
* 版本2:将两个购物车中商品合并
* @return
*/
List cartList(String userId, String userTempId);
```
## 4.4 实现类:CartServiceImpl
```java
/**
* 查询用户购物车列表
* 版本1:分别查询未登录购物车列表,以及登录的购物车列表
* 版本2:将两个购物车中商品合并
*
* @return
*/
@Override
public List cartList(String userId, String userTempId) {
//1.查询未登录购物车列表
List cartInfoList = null;
//1.1 判断临时用户有值情况下 redis中获取购物车列表
if (StringUtils.isNotBlank(userTempId)) {
//1.2 构建redisKey 创建hash操作对象
String noLogCartKey = getCartKey(userTempId);
BoundHashOperations noLoginHashOps = redisTemplate.boundHashOps(noLogCartKey);
cartInfoList = noLoginHashOps.values();
}
//3.已登录的购物车列表
//3.1 判断用户有值情况下 redis中获取购物车列表
if (StringUtils.isNotBlank(userId)) {
//3.2 构建redisKey 创建hash操作对象
String loginCartKey = getCartKey(userId);
BoundHashOperations loginHashOps = redisTemplate.boundHashOps(loginCartKey);
cartInfoList = loginHashOps.values();
}
//4.对购物车商品数据进行排序
cartInfoList.sort((o1, o2) -> {
return DateUtil.truncatedCompareTo(o2.getUpdateTime(), o1.getUpdateTime(), Calendar.SECOND);
});
return cartInfoList;
}
```
# 5、功能--合并购物车
功能分析:
1. 当用户登录以后,先判断未登录的时候,用户是否购买了商品。
- 如果用户购买了商品,则找到对应的商品Id,对数量进行合并
- 没有找到的商品,则直接添加到数据
2. 合并完成之后,删除未登录数据。
## 5.1 更改实现类:CartServiceImpl
```java
/**
* 查询用户购物车列表
* 版本2:将两个购物车中商品合并
* 未登录(没有加购商品,加购商品) 登录(没有加购商品,加购商品)
* 情况一:(临时用户)用户未登录:加入商品到购物车 用户没登录 返回临时用户加购商品列表
* 情况二:(临时用户)用户未登录:加入商品到购物车-->用户登录 需要进行合并,删除未登录购物车列表
*
* @return
*/
@Override
public List cartList(String userId, String userTempId) {
//1.(临时用户)用户未登录:加入商品到购物车 用户没登录 返回临时用户加购商品列表
List noLoginCartList = null;
if (StringUtils.isNotBlank(userTempId)) {
//1.1 构建未登录用户redisKey
String noLoginCartKey = getCartKey(userTempId);
//1.2 获取未登录购物车列表
BoundHashOperations noLoginHashOps = redisTemplate.boundHashOps(noLoginCartKey);
noLoginCartList = noLoginHashOps.values();
}
if (StringUtils.isBlank(userId)) {
noLoginCartList.sort((o1, o2) -> {
return DateUtil.truncatedCompareTo(o2.getUpdateTime(), o1.getUpdateTime(), Calendar.SECOND);
});
return noLoginCartList;
}
//2.如果未登录购物车列表中有数据,则需要跟已登录购物车列表进行合并--说明登录了
//2.1 构建已登录购物车KEY
String loginCartKey = getCartKey(userId);
//2.2 构建已登录购物车hash操作对象
BoundHashOperations loginHashOps = redisTemplate.boundHashOps(loginCartKey);
if (!CollectionUtils.isEmpty(noLoginCartList)) {
for (CartInfo cartInfo : noLoginCartList) {
//2.3 判断如果登录购物车中包含商品SKUID 则累加数量 反之则新增
if (loginHashOps.hasKey(cartInfo.getSkuId().toString())) {
CartInfo loginCartInfo = loginHashOps.get(cartInfo.getSkuId().toString());
//将离线购物车商品数量跟在线购物车商品数量累加
loginCartInfo.setSkuNum(loginCartInfo.getSkuNum() + cartInfo.getSkuNum());
//更新登录购物车数据
loginHashOps.put(cartInfo.getSkuId().toString(), loginCartInfo);
} else {
//2.4 如果未登录购物车中商品在已登录购物车中不存在新增
cartInfo.setUserId(userId);
cartInfo.setUpdateTime(new Date());
loginHashOps.put(cartInfo.getSkuId().toString(), cartInfo);
}
}
//3. 删除未登录购物车数据
String noLoginCartKey = getCartKey(userTempId);
redisTemplate.delete(noLoginCartKey);
}
//4.再次查询登录后用户购物车列表
List allCartInfoList = loginHashOps.values();
//4.对购物车商品数据进行排序
allCartInfoList.sort((o1, o2) -> {
return DateUtil.truncatedCompareTo(o2.getUpdateTime(), o1.getUpdateTime(), Calendar.SECOND);
});
return allCartInfoList;
}
```
# 6、选中状态的变更
用户每次勾选购物车的多选框,都要把当前状态保存起来。由于可能会涉及更频繁的操作,所以这个勾选状态不必存储到数据库中。保留在缓存状态即可。
> YAPI接口地址:http://192.168.200.128:3000/project/11/interface/api/667
## 6.1 编写控制器
CartApiController
```java
// 选中状态
@GetMapping("checkCart/{skuId}/{isChecked}")
public Result checkCart(@PathVariable Long skuId,
@PathVariable Integer isChecked,
HttpServletRequest request){
String userId = AuthContextHolder.getUserId(request);
// 判断
if (StringUtils.isEmpty(userId)){
userId = AuthContextHolder.getUserTempId(request);
}
// 调用服务层方法
cartService.checkCart(userId,isChecked,skuId);
return Result.ok();
}
```
## 6.2 编写业务接口与实现
业务接口CartService
```java
/**
* 更新选中状态
*
* @param userId
* @param isChecked
* @param skuId
*/
void checkCart(String userId, Integer isChecked, Long skuId);
```
业务实现类CartServiceImpl
```java
/**
* 变更购物车商品选中状态
*
* @param skuId 商品SKUID
* @param isChecked 状态
* @return
*/
@Override
public void checkCart(String userId, Long skuId, Integer isChecked) {
//获取用户购物车Key
String cartKey = getCartKey(userId);
//获取hash操作对象
BoundHashOperations hashOps = redisTemplate.boundHashOps(cartKey);
if (hashOps.hasKey(skuId.toString())) {
//获取商品信息
CartInfo cartInfo = hashOps.get(skuId.toString());
//修改状态
cartInfo.setIsChecked(isChecked);
//更新购物车商品
hashOps.put(skuId.toString(), cartInfo);
}
}
```
# 7、删除购物车
> YAPI接口地址:http://192.168.200.128:3000/project/11/interface/api/659
## 7.1 编写控制器
```java
/**
* 删除
*
* @param skuId
* @param request
* @return
*/
@DeleteMapping("deleteCart/{skuId}")
public Result deleteCart(@PathVariable("skuId") Long skuId,
HttpServletRequest request) {
// 如何获取userId
String userId = AuthContextHolder.getUserId(request);
if (StringUtils.isEmpty(userId)) {
// 获取临时用户Id
userId = AuthContextHolder.getUserTempId(request);
}
cartService.deleteCart(skuId, userId);
return Result.ok();
}
```
## 7.1 封装业务接口与实现
业务接口CartService
```java
/**
* 删除购物车商品
* @param skuId
* @param userId
*/
void deleteCart(Long skuId, String userId);
```
业务实现类:CartServiceImpl
```java
/**
* 删除购物车商品
* @param userId
* @param skuId
*/
@Override
public void deleteCart(String userId, Long skuId) {
//获取用户购物车Key
String cartKey = getCartKey(userId);
//获取hash操作对象
BoundHashOperations hashOps = redisTemplate.boundHashOps(cartKey);
//删除购物车商品
hashOps.delete(skuId.toString());
}
```
# 8、前端实现
## 8.1 在web-all添加前端实现
### 8.1.1 网关动态路由
在Nacos配置列表找找到`server-gateway-dev.yaml`进行修改:配置购物车域名以及购物车服务的动态路由
```yaml
- id: web-cart
uri: lb://web-all
predicates:
- Host=cart.gmall.com
- id: service-cart
uri: lb://service-cart
predicates:
- Path=/*/cart/**
```
### 8.1.3 购物车Feign接口
1. 在`service-client`模块下创建购物车feign模块:service-cart-client
![image-20221207190600598](assets/day11/image-20221207190600598.png)
2. 在:`service-cart-client`创建获取购物车Feign远程调用API接口CartFeignClient
```java
package com.atguigu.gmall.cart.client;
import com.atguigu.gmall.cart.client.impl.CartDegradeFeignClient;
import com.atguigu.gmall.cart.model.CartInfo;
import org.springframework.cloud.openfeign.FeignClient;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PathVariable;
import java.util.List;
/**
* author:atguigu
* 描述:
**/
@FeignClient(value = "service-cart",fallback = CartDegradeFeignClient.class)
public interface CartFeignClient {
// 根据用户Id 获取到选中购物车列表。
@GetMapping("api/cart/getCartCheckedList/{userId}")
List getCartCheckedList(@PathVariable String userId);
}
```
3. 服务降级类
```java
package com.atguigu.gmall.cart.client.impl;
import com.atguigu.gmall.cart.client.CartFeignClient;
import com.atguigu.gmall.cart.model.CartInfo;
import org.springframework.stereotype.Component;
import java.util.List;
/**
* author:atguigu
* date:2022/11/23 9:35
* 描述:
**/
@Component
public class CartDegradeFeignClient implements CartFeignClient {
@Override
public List getCartCheckedList(String userId) {
return null;
}
}
```
### 8.1.2 controller实现
1. 在`web-all`模块pom.xml中导入依赖
```xml
com.atguigu.gmall
service-cart-client
1.0
```
2. 新增CartController
```java
package com.atguigu.gmall.all.controller;
import com.atguigu.gmall.cart.client.CartFeignClient;
import com.atguigu.gmall.product.model.SkuInfo;
import com.atguigu.gmall.product.client.ProductFeignClient;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.stereotype.Controller;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RequestParam;
import javax.servlet.http.HttpServletRequest;
/**
*
* 购物车页面
*
*
*/
@Controller
public class CartController {
@Autowired
private CartFeignClient cartFeignClient;
@Autowired
private ProductFeignClient productFeignClient;
/**
* 查看购物车
* @return
*/
@RequestMapping("cart.html")
public String index(){
return "cart/index";
}
/**
* 添加购物车
* @param skuId
* @param skuNum
* @param request
* @return
*/
@RequestMapping("addCart.html")
public String addCart(@RequestParam(name = "skuId") Long skuId,
@RequestParam(name = "skuNum") Integer skuNum,
HttpServletRequest request){
SkuInfo skuInfo = productFeignClient.getSkuInfo(skuId);
request.setAttribute("skuInfo",skuInfo);
request.setAttribute("skuNum",skuNum);
return "cart/addCart";
}
}
```