1.实现未登录状态的购物车
2.实现登陆状态下的购物车
需求描述:
用户可以在登录状态下将商品添加到购物车 放入数据库mongodb放入redis(采用) 用户可以在未登录状态下将商品添加到购物车 放入localstoragecookiewebSQL 用户可以使用购物车一起结算下单用户可以查询自己的购物车用户可以在购物车中修改购买商品的数量。用户可以在购物车中删除商品。在购物车中展示商品优惠信息提示购物车商品价格变化这幅图主要描述了两个功能:新增商品到购物车、查询购物车。
新增商品:
判断是否登录 是:则添加商品到后台Redis中否:则添加商品到本地的Localstorage无论哪种新增,完成后都需要查询购物车列表:
判断是否登录 否:直接查询localstorage中数据并展示是:已登录,则需要先看本地是否有数据, 有:需要提交到后台添加到redis,合并数据,而后查询否:直接去后台查询redis,而后返回首先分析一下未登录购物车的数据结构。
我们看下页面展示需要什么数据:
因此每一个购物车信息,都是一个对象,包含:
{ skuId:2131241, title:"小米6", image:"", price:190000, num:1, ownSpec:"{"机身颜色":"陶瓷黑尊享版","内存":"6GB","机身存储":"128GB"}" }另外,购物车中不止一条数据,因此最终会是对象的数组。即:
[ {...},{...},{...} ]知道了数据结构,下一个问题,就是如何保存购物车数据。前面我们分析过,可以使用Localstorage来实现。Localstorage是web本地存储的一种,那么,什么是web本地存储呢?
web本地存储主要有两种方式:
LocalStorage:localStorage 方法存储的数据没有时间限制。第二天、第二周或下一年之后,数据依然可用。SessionStorage:sessionStorage 方法针对一个 session 进行数据存储。当用户关闭浏览器窗口后,数据会被删除。语法非常简单:
localStorage.setItem("key","value"); // 存储数据 localStorage.getItem("key"); // 获取数据 localStorage.removeItem("key"); // 删除数据注意:localStorage和SessionStorage都只能保存字符串。
不过,在我们的common.js中,已经对localStorage进行了简单的封装:
示例:
添加购物车需要知道购物的数量,所以我们需要获取数量大小。我们在Vue中定义num,保存数量:
然后将num与页面的input框绑定,同时给+和-的按钮绑定事件:
编写方法:
我们看下商品详情页:
现在点击加入购物车会跳转到购物车成功页面。
不过我们不这么做,我们绑定点击事件,然后实现添加购物车功能。
addCart方法中判断用户的登录状态:
addCart(){ ly.http.get("/auth/verify").then(res=>{ // 已登录发送信息到后台,保存到redis中 }).catch(()=>{ // 未登录保存在浏览器本地的localStorage中 }) }结果:
添加完成后,页面会跳转到购物车结算页面:cart.html
因为会多次校验用户登录状态,因此我们封装一个校验的方法:
在common.js中:
在页面item.html中使用该方法:
页面加载时,就应该去查询购物车。
var cartVm = new Vue({ el: "#cartApp", data: { ly, carts: [],// 购物车数据 }, created() { this.loadCarts(); }, methods: { loadCarts() { // 先判断登录状态 ly.verifyUser().then(() => { // 已登录 }).catch(() => { // 未登录 this.carts = ly.store.get("carts") || []; this.selected = this.carts; }) } } components: { shortcut: () => import("/js/pages/shortcut.js") } })刷新页面,查看控制台Vue实例:
接下来,我们在页面中展示carts的数据:
要注意,价格的展示需要进行格式化,这里使用的是我们在common.js中定义的formatPrice方法
效果:
我们给页面的 + 和 -绑定点击事件,修改num 的值:
两个事件:
increment(c) { c.num++; ly.verifyUser().then(() => { // TODO 已登录,向后台发起请求 }).catch(() => { // 未登录,直接操作本地数据 ly.store.set("carts", this.carts); }) }, decrement(c) { if (c.num <= 1) { return; } c.num--; ly.verifyUser().then(() => { // TODO 已登录,向后台发起请求 }).catch(() => { // 未登录,直接操作本地数据 ly.store.set("carts", this.carts); }) }给删除按钮绑定事件:
点击事件中删除商品:
deleteCart(i){ ly.verifyUser().then(res=>{ // TODO,已登录购物车 }).catch(()=>{ // 未登录购物车 this.carts.splice(i, 1); ly.store.set("carts", this.carts); }) }在页面中,每个购物车商品左侧,都有一个复选框,用户可以选择部分商品进行下单,而不一定是全部:
我们定义一个变量,记录所有被选中的商品:
我们给商品前面的复选框与selected绑定,并且指定其值为当前购物车商品:
我们在加载完成购物车查询后,初始化全选:
然后编写一个计算属性,计算出选中商品总价格:
computed: { totalPrice() { return ly.formatPrice(this.selected.reduce((c1, c2) => c1 + c2.num * c2.price, 0)); } }在页面中展示总价格:
效果:
接下来,我们完成已登录购物车。
在刚才的未登录购物车编写时,我们已经预留好了编写代码的位置,逻辑也基本一致。
购物车系统只负责登录状态的购物车处理,因此需要添加登录校验,我们通过JWT鉴权即可实现。
我们引入之前写的鉴权工具:leyou-auth-common
<dependency> <groupId>com.leyou.auth</groupId> <artifactId>leyou-auth-common</artifactId> <version>1.0.0-SNAPSHOT</version> </dependency>代码:
@ConfigurationProperties(prefix = "leyou.jwt") public class JwtProperties { private String pubKeyPath;// 公钥 private PublicKey publicKey; // 公钥 private String cookieName; private static final Logger logger = LoggerFactory.getLogger(JwtProperties.class); @PostConstruct public void init(){ try { // 获取公钥和私钥 this.publicKey = RsaUtils.getPublicKey(pubKeyPath); } catch (Exception e) { logger.error("初始化公钥失败!", e); throw new RuntimeException(); } } public String getPubKeyPath() { return pubKeyPath; } public void setPubKeyPath(String pubKeyPath) { this.pubKeyPath = pubKeyPath; } public PublicKey getPublicKey() { return publicKey; } public void setPublicKey(PublicKey publicKey) { this.publicKey = publicKey; } public String getCookieName() { return cookieName; } public void setCookieName(String cookieName) { this.cookieName = cookieName; } }因为很多接口都需要进行登录,我们直接编写SpringMVC拦截器,进行统一登录校验。同时,我们还要把解析得到的用户信息保存起来,以便后续的接口可以使用。
代码:
public class LoginInterceptor extends HandlerInterceptorAdapter { private JwtProperties jwtProperties; // 定义一个线程域,存放登录用户 private static final ThreadLocal<UserInfo> tl = new ThreadLocal<>(); public LoginInterceptor(JwtProperties jwtProperties) { this.jwtProperties = jwtProperties; } @Override public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) throws Exception { // 查询token String token = CookieUtils.getCookieValue(request, "LY_TOKEN"); if (StringUtils.isBlank(token)) { // 未登录,返回401 response.setStatus(HttpStatus.UNAUTHORIZED.value()); return false; } // 有token,查询用户信息 try { // 解析成功,证明已经登录 UserInfo user = JwtUtils.getInfoFromToken(token, jwtProperties.getPublicKey()); // 放入线程域 tl.set(user); return true; } catch (Exception e){ // 抛出异常,证明未登录,返回401 response.setStatus(HttpStatus.UNAUTHORIZED.value()); return false; } } @Override public void afterCompletion(HttpServletRequest request, HttpServletResponse response, Object handler, Exception ex) throws Exception { tl.remove(); } public static UserInfo getLoginUser() { return tl.get(); } }注意:
这里我们使用了ThreadLocal来存储查询到的用户信息,线程内共享,因此请求到达Controller后可以共享User并且对外提供了静态的方法:getLoginUser()来获取User信息配置SpringMVC,使过滤器生效:
@Configuration @EnableConfigurationProperties(JwtProperties.class) public class MvcConfig implements WebMvcConfigurer { @Autowired private JwtProperties jwtProperties; @Bean public LoginInterceptor loginInterceptor() { return new LoginInterceptor(jwtProperties); } @Override public void addInterceptors(InterceptorRegistry registry) { registry.addInterceptor(loginInterceptor()).addPathPatterns("/**"); } }当用户登录时,我们需要把购物车数据保存到后台,可以选择保存在数据库。但是购物车是一个读写频率很高的数据。因此我们这里选择读写效率比较高的Redis作为购物车存储。
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,值是购物车数据实体类:
public class Cart { private Long userId;// 用户id private Long skuId;// 商品id private String title;// 标题 private String image;// 图片 private Long price;// 加入购物车时的价格 private Integer num;// 购买数量 private String ownSpec;// 商品规格参数 public Long getUserId() { return userId; } public void setUserId(Long userId) { this.userId = userId; } public Long getSkuId() { return skuId; } public void setSkuId(Long skuId) { this.skuId = skuId; } 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 Long getPrice() { return price; } public void setPrice(Long price) { this.price = price; } public Integer getNum() { return num; } public void setNum(Integer num) { this.num = num; } public String getOwnSpec() { return ownSpec; } public void setOwnSpec(String ownSpec) { this.ownSpec = ownSpec; } }已登录情况下,向后台添加购物车:
ly.http.post("/cart", {skuId: this.sku.id, num: this.num}).then(res=>{ window.location = "http://www.leyou.com/cart.html"; })这里发起的是Json请求。那么我们后台也要以json接收。
先分析一下:
请求方式:新增,肯定是Post请求路径:/cart ,这个其实是Zuul路由的路径,我们可以不管请求参数:Json对象,包含skuId和num属性返回结果:无 @Controller public class CartController { @Autowired private CartService cartService; /** * 添加购物车 * * @return */ @PostMapping public ResponseEntity<Void> addCart(@RequestBody Cart cart) { this.cartService.addCart(cart); return ResponseEntity.ok().build(); } }在leyou-gateway中添加路由配置:
这里我们不访问数据库,而是直接操作Redis。基本思路:
先查询之前的购物车数据判断要添加的商品是否存在 存在:则直接修改数量后写回Redis不存在:新建一条数据,然后写入Redis代码:
@Service public class CartService { @Autowired private StringRedisTemplate redisTemplate; @Autowired private GoodsClient goodsClient; static final String KEY_PREFIX = "leyou:cart:uid:"; static final Logger logger = LoggerFactory.getLogger(CartService.class); public void addCart(Cart cart) { // 获取登录用户 UserInfo user = LoginInterceptor.getLoginUser(); // Redis的key String key = KEY_PREFIX + user.getId(); // 获取hash操作对象 BoundHashOperations<String, Object, Object> hashOps = this.redisTemplate.boundHashOps(key); // 查询是否存在 Long skuId = cart.getSkuId(); Integer num = cart.getNum(); Boolean boo = hashOps.hasKey(skuId.toString()); if (boo) { // 存在,获取购物车数据 String json = hashOps.get(skuId.toString()).toString(); cart = JsonUtils.parse(json, Cart.class); // 修改购物车数量 cart.setNum(cart.getNum() + num); } else { // 不存在,新增购物车数据 cart.setUserId(user.getId()); // 其它商品信息,需要查询商品服务 Sku sku = this.goodsClient.querySkuById(skuId); cart.setImage(StringUtils.isBlank(sku.getImages()) ? "" : StringUtils.split(sku.getImages(), ",")[0]); cart.setPrice(sku.getPrice()); cart.setTitle(sku.getTitle()); cart.setOwnSpec(sku.getOwnSpec()); } // 将购物车数据写入redis hashOps.put(cart.getSkuId().toString(), JsonUtils.serialize(cart)); } }需要引入leyou-item-interface依赖:
<dependency> <groupId>com.leyou.item</groupId> <artifactId>leyou-item-interface</artifactId> <version>1.0.0-SNAPSHOT</version> </dependency>参照搜索工程,添加GoodClient,提供根据id查询sku的接口:
@FeignClient("item-service") public interface GoodsClient extends GoodsApi { }在leyou-item-service中的GoodsController添加方法:
@GetMapping("sku/{id}") public ResponseEntity<Sku> querySkuById(@PathVariable("id")Long id){ Sku sku = this.goodsService.querySkuById(id); if (sku == null){ return new ResponseEntity<>(HttpStatus.NOT_FOUND); } return ResponseEntity.ok(sku); }在leyou-item-service中的GoodsService添加方法:
public Sku querySkuById(Long id) { return this.skuMapper.selectByPrimaryKey(id); }购物车页面:cart.html
Controller
/** * 查询购物车列表 * * @return */ @GetMapping public ResponseEntity<List<Cart>> queryCartList() { List<Cart> carts = this.cartService.queryCartList(); if (carts == null) { return ResponseEntity.status(HttpStatus.NOT_FOUND).body(null); } return ResponseEntity.ok(carts); }Service
public List<Cart> queryCartList() { // 获取登录用户 UserInfo user = LoginInterceptor.getLoginUser(); // 判断是否存在购物车 String key = KEY_PREFIX + user.getId(); if(!this.redisTemplate.hasKey(key)){ // 不存在,直接返回 return null; } BoundHashOperations<String, Object, Object> hashOps = this.redisTemplate.boundHashOps(key); List<Object> carts = hashOps.values(); // 判断是否有数据 if(CollectionUtils.isEmpty(carts)){ return null; } // 查询购物车数据 return carts.stream().map(o -> JsonUtils.parse(o.toString(), Cart.class)).collect(Collectors.toList()); }Controller
@PutMapping public ResponseEntity<Void> updateNum(@RequestBody Cart cart){ this.cartService.updateCarts(cart); return ResponseEntity.noContent().build(); }Service
public void updateCarts(Cart cart) { // 获取登陆信息 UserInfo userInfo = LoginInterceptor.getUserInfo(); String key = LEYOU_CART_KEY + userInfo.getId(); // 获取hash操作对象 BoundHashOperations<String, Object, Object> hashOperations = this.redisTemplate.boundHashOps(key); // 获取购物车信息 String cartJson = hashOperations.get(cart.getSkuId().toString()).toString(); Cart cart1 = JsonUtils.parse(cartJson, Cart.class); // 更新数量 cart1.setNum(cart.getNum()); // 写入购物车 hashOperations.put(cart.getSkuId().toString(), JsonUtils.serialize(cart1)); }注意:后台成功响应后,要把页面的购物车中数据也删除
Controller
@DeleteMapping("{skuId}") public ResponseEntity<Void> deleteCart(@PathVariable("skuId") String skuId) { this.cartService.deleteCart(skuId); return ResponseEntity.ok().build(); }Service
public void deleteCart(String skuId) { // 获取登录用户 UserInfo user = LoginInterceptor.getLoginUser(); String key = KEY_PREFIX + user.getId(); BoundHashOperations<String, Object, Object> hashOps = this.redisTemplate.boundHashOps(key); hashOps.delete(skuId); }当跳转到购物车页面,查询购物车列表前,需要判断用户登录状态,
如果登录: 首先检查用户的LocalStorage中是否有购物车信息,如果有,则提交到后台保存,清空LocalStorage 如果未登录,直接查询即可