完成乐优商城购物车模块。

购物车功能分析
需求
- 用户可以在登录状态下将商品添加到购物车
- 用户可以在未登录状态下将商品添加到购物车
- 用户可以使用购物车一起结算下单
- 用户可以查询自己的购物车
- 用户可以在购物车中修改购买商品的数量。
- 用户可以在购物车中删除商品。
- 在购物车中展示商品优惠信息
- 提示购物车商品价格变化
流程图

这幅图主要描述了两个功能:新增商品到购物车、查询购物车。
新增商品:
- 判断是否登录
- 是:则添加商品到后台Redis中
- 否:则添加商品到本地的Localstorage
无论哪种新增,完成后都需要查询购物车列表:
- 判断是否登录
- 否:直接查询localstorage中数据并展示
- 是:已登录,则需要先看本地是否有数据,
- 有:需要提交到后台添加到redis,合并数据,而后查询
- 否:直接去后台查询redis,而后返回
未登录购物车
准备工作
购物车的数据结构
首先分析一下未登录购物车的数据结构。
看下页面展示需要什么数据:

因此每一个购物车信息,都是一个对象,包含:
1 2 3 4 5 6 7 8
| { skuId:2131241, title:"小米6", image:"", price:190000, num:1, ownSpec:"{"机身颜色":"陶瓷黑尊享版","内存":"6GB","机身存储":"128GB"}" }
|
另外,购物车中不止一条数据,因此最终会是对象的数组。即:
web本地存储
知道了数据结构,下一个问题,就是如何保存购物车数据。前面我们分析过,可以使用Localstorage来实现。Localstorage是web本地存储的一种,那么,什么是web本地存储呢?
什么是web本地存储?

web本地存储主要有两种方式:
LocalStorage
:localStorage
方法存储的数据没有时间限制。第二天、第二周或下一年之后,数据依然可用。
SessionStorage
:sessionStorage
方法针对一个 session 进行数据存储。当用户关闭浏览器窗口后,数据会被删除。
LocalStorage的用法
语法非常简单:

1 2 3
| localStorage.setItem("key","value"); localStorage.getItem("key"); localStorage.removeItem("key");
|
注意:localStorage和SessionStorage都只能保存字符串。
不过,在common.js
中,已经对localStorage
进行了简单的封装:

示例:

获取num
在ly-page
项目中的item.html
模板中做如下修改:
添加商品到购物车
在ly-page
项目中的item.html
模板中做如下修改:

加入购物车成功。
查询购物车
页面加载获取购物车
购物车页面加载时,就应该去查询购物车。
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
| <script type="text/javascript"> var cartVm = new Vue({ el: "#cartApp", data: { ly, carts: [], }, created() { this.loadCarts(); }, methods: { loadCarts() { ly.verifyUser().then(() => {
}).catch(() => { this.carts = ly.store.get("carts") || []; }) } }, components: { shortcut: () => import("/js/pages/shortcut.js") } }) </script>
|
查看Vue实例中的购物车数据

渲染购物车数据
在页面中展示carts的数据

修改数量
删除购物车项
删除按钮绑定单击事件

编写deleteCart
方法
1 2 3 4 5 6 7 8 9
| deleteCart(i){ ly.verifyUser().then(res=>{ }).catch(()=>{ this.carts.splice(i, 1); ly.store.set("carts", this.carts); }) }
|
选中商品
选中单个
初始化全部选中
修改loadCarts
方法

计算所有商品总价
在Vue加入计算方法
1 2 3 4 5
| computed: { totalPrice() { return ly.formatPrice(this.selected.reduce((c1, c2) => c1 + c2.num * c2.price, 0)); } }
|
页面调用计算总价

效果

登录购物车
完成已登录购物车。
在刚才的未登录购物车编写时,已经预留好了编写代码的位置,逻辑也基本一致。
搭建购物车微服务
创建module
- GroupId:
com.leyou.service
- ArtifactId:
ly-cart
pom.xml
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
| <?xml version="1.0" encoding="UTF-8"?> <project xmlns="http://maven.apache.org/POM/4.0.0" xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 http://maven.apache.org/xsd/maven-4.0.0.xsd"> <parent> <artifactId>parent</artifactId> <groupId>com.leyou</groupId> <version>1.0.0-SNAPSHOT</version> </parent> <modelVersion>4.0.0</modelVersion>
<groupId>com.leyou.service</groupId> <artifactId>ly-cart</artifactId>
<dependencies> <dependency> <groupId>org.springframework.boot</groupId> <artifactId>spring-boot-starter-web</artifactId> </dependency> <dependency> <groupId>org.springframework.cloud</groupId> <artifactId>spring-cloud-starter-netflix-eureka-client</artifactId> </dependency> <dependency> <groupId>org.springframework.cloud</groupId> <artifactId>spring-cloud-starter-openfeign</artifactId> </dependency> <dependency> <groupId>org.springframework.boot</groupId> <artifactId>spring-boot-starter-data-redis</artifactId> </dependency> </dependencies>
</project>
|
启动类
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15
| package com.leyou;
import org.springframework.boot.SpringApplication; import org.springframework.boot.autoconfigure.SpringBootApplication; import org.springframework.cloud.client.discovery.EnableDiscoveryClient; import org.springframework.cloud.openfeign.EnableFeignClients;
@SpringBootApplication @EnableDiscoveryClient @EnableFeignClients public class CartApplication { public static void main(String[] args) { SpringApplication.run(CartApplication.class, args); } }
|
application.yml
1 2 3 4 5 6 7 8 9 10 11 12
| server: port: 8008 spring: application: name: cart-service redis: host: 192.168.136.103 port: 6379 eureka: client: service-url: defaultZone: http://127.0.0.1:9999/eureka
|
用户鉴权
引入依赖
1 2 3 4 5 6 7 8 9 10
| <dependency> <groupId>com.leyou.auth</groupId> <artifactId>ly-auth-common</artifactId> <version>${leyou.latest.version}</version> </dependency> <dependency> <groupId>com.leyou.common</groupId> <artifactId>ly-common</artifactId> <version>${leyou.latest.version}</version> </dependency>
|
配置公钥
在application.yml
中新增公钥配置
1 2 3 4
| ly: jwt: pubKeyPath: c:\\key\\rsa.pub cookieName: LY_TOKEN
|
JwtProperties
从ly-gateway
中复制过来即可。
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
| package com.leyou.cart.config;
import com.leyou.auth.utils.RsaUtils; import lombok.Data; import lombok.extern.slf4j.Slf4j; import org.springframework.boot.context.properties.ConfigurationProperties;
import javax.annotation.PostConstruct; import java.security.PublicKey;
@Data @Slf4j @ConfigurationProperties(prefix = "ly.jwt") public class JwtProperties {
private String pubKeyPath;
private PublicKey publicKey;
private String cookieName;
@PostConstruct public void init(){ try { this.publicKey = RsaUtils.getPublicKey(pubKeyPath); } catch (Exception e) { log.error("初始化公钥失败!", e); throw new RuntimeException(); } }
}
|
编写拦截器
编写连接器对所有的请求进行统一鉴权。
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
| package com.leyou.cart.interceptor;
import com.leyou.auth.entity.UserInfo; import com.leyou.auth.utils.JwtUtils; import com.leyou.cart.config.JwtProperties; import com.leyou.common.util.CookieUtils; import lombok.extern.slf4j.Slf4j; import org.springframework.web.servlet.handler.HandlerInterceptorAdapter;
import javax.servlet.http.HttpServletRequest; import javax.servlet.http.HttpServletResponse;
@Slf4j public class UserInterceptor extends HandlerInterceptorAdapter {
private JwtProperties jwtProperties;
private static ThreadLocal<UserInfo> userInfoThreadLocal = new ThreadLocal<>();
public UserInterceptor(JwtProperties jwtProperties) { this.jwtProperties = jwtProperties; }
@Override public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) throws Exception { try { String token = CookieUtils.getCookieValue(request, jwtProperties.getCookieName()); UserInfo userInfo = JwtUtils.getUserInfo(jwtProperties.getPublicKey(), token); if (userInfo.getId() == null) { log.warn("[购物车服务] 解析用户凭证失败"); return false; } userInfoThreadLocal.set(userInfo);
return true; } catch (Exception e) { log.error("[购物车服务] 用户权发生异常, ", e); return false; } }
@Override public void afterCompletion(HttpServletRequest request, HttpServletResponse response, Object handler, Exception ex) throws Exception { userInfoThreadLocal.remove(); }
public static UserInfo getUserInfo() { return userInfoThreadLocal.get(); } }
|
- 这里我们使用了
ThreadLocal
来存储查询到的用户信息,线程内共享,因此请求到达Controller
后可以共享User。
- 并且对外提供了静态的方法:
getLoginUser()
来获取User信息。
配置过滤器
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
| package com.leyou.cart.config;
import com.leyou.cart.interceptor.UserInterceptor; import org.springframework.beans.factory.annotation.Autowired; import org.springframework.boot.context.properties.EnableConfigurationProperties; import org.springframework.context.annotation.Bean; import org.springframework.context.annotation.Configuration; import org.springframework.web.servlet.config.annotation.InterceptorRegistry; import org.springframework.web.servlet.config.annotation.WebMvcConfigurer;
@Configuration @EnableConfigurationProperties(JwtProperties.class) public class MvcConfiguration implements WebMvcConfigurer {
@Autowired private JwtProperties jwtProperties;
@Bean public UserInterceptor getUserInterceptor() { return new UserInterceptor(jwtProperties); }
@Override public void addInterceptors(InterceptorRegistry registry) { registry.addInterceptor(getUserInterceptor()).addPathPatterns("/**"); } }
|
购物车设计
当用户登录时,需要把购物车数据保存到后台,可以选择保存在数据库。但是购物车是一个读写频率很高的数据。因此这里选择读写效率比较高的Redis
作为购物车存储。
Redis
有5种不同数据结构,这里选择哪一种比较合适呢?
- 首先不同用户应该有独立的购物车,因此购物车应该以用户的作为key来存储,Value是用户的所有购物车信息。这样看来基本的
k-v
结构就可以了。
- 但是,对购物车中的商品进行增、删、改操作,基本都需要根据商品id进行判断,为了方便后期处理,购物车也应该是
k-v
结构,key是商品id,value才是这个商品的购物车信息。
综上所述,购物车结构是一个双层Map:Map<String,Map<String,String>>
- 第一层Map,Key是用户id
- 第二层Map,Key是购物车中商品id,值是购物车数据
购物车实体类
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19
| package com.leyou.cart.pojo;
import lombok.Data;
@Data public class Cart { private Long userId; private Long skuId; private String title; private String image; private Long price; private Integer num; private String ownSpec; }
|
添加商品到购物车
item-service新增查询sku接口
controller
1 2 3 4 5 6 7 8 9 10
|
@GetMapping("/sku/{skuId}") public ResponseEntity<Sku> querySkuById(@PathVariable("skuId") Long skuId) { return ResponseEntity.ok(goodsService.querySkuById(skuId)); }
|
service
1 2 3 4 5 6 7 8 9 10 11 12 13 14
|
public Sku querySkuById(Long skuId) { Sku sku = skuMapper.selectByPrimaryKey(skuId); if (sku == null || sku.getId() == null) { throw new LyException(LyExceptionEnum.SKU_NOT_FOUND); } return sku; }
|
goodsApi
在GoodsApi
对外开放接口
1 2 3 4 5 6 7 8
|
@GetMapping("goods/sku/{skuId}") Sku querySkuById(@PathVariable("skuId") Long skuId);
|
GoodsClient
1 2 3 4 5 6 7 8 9 10 11 12 13
| package com.leyou.cart.client;
import com.leyou.api.GoodsApi; import com.leyou.common.util.LeyouConstants; import org.springframework.cloud.openfeign.FeignClient;
@FeignClient(LeyouConstants.SERVICE_ITEM) public interface GoodsClient extends GoodsApi { }
|
需要引入ly-item-interface
的依赖。
CartController
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
| package com.leyou.cart.controller;
import com.leyou.cart.interceptor.UserInterceptor; import com.leyou.cart.pojo.Cart; import com.leyou.cart.service.CartService; import org.springframework.beans.factory.annotation.Autowired; import org.springframework.http.ResponseEntity; import org.springframework.web.bind.annotation.PostMapping; import org.springframework.web.bind.annotation.RequestBody; import org.springframework.web.bind.annotation.RestController;
@RestController public class CartController {
@Autowired private CartService cartService;
@PostMapping public ResponseEntity<Void> addCart(@RequestBody Cart cart) { cart.setUserId(UserInterceptor.getUserInfo().getId()); cartService.saveCart(cart); return ResponseEntity.ok().build(); } }
|
CartService
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
| package com.leyou.cart.service;
import com.leyou.cart.client.GoodsClient; import com.leyou.cart.pojo.Cart; import com.leyou.common.util.JsonUtils; import com.leyou.pojo.Sku; import lombok.extern.slf4j.Slf4j; import org.apache.commons.lang3.StringUtils; import org.springframework.beans.factory.annotation.Autowired; import org.springframework.data.redis.core.BoundHashOperations; import org.springframework.data.redis.core.StringRedisTemplate; import org.springframework.stereotype.Service;
@Slf4j @Service public class CartService {
@Autowired private GoodsClient goodsClient;
@Autowired private StringRedisTemplate redisTemplate;
static final String KEY_PREFIX = "ly:cart:uid:";
public void saveCart(Cart cart) { String key = KEY_PREFIX + cart.getUserId(); BoundHashOperations<String, Object, Object> userCartData = redisTemplate.boundHashOps(key);
Integer num = cart.getNum(); Long skuId = cart.getSkuId();
if (userCartData.hasKey(skuId.toString())) { String json = userCartData.get(cart.getSkuId()).toString(); cart = JsonUtils.parse(json, Cart.class); cart.setNum(cart.getNum() + num); } else { 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()); }
userCartData.put(cart.getSkuId().toString(), JsonUtils.serialize(cart)); } }
|
测试
前台登录过后,点击加入购物车,前往redis
查询。
这里使用的是redis desktop manager
,一款redis
的GUI客户端。

查询购物车
页面请求
修改cart.html
中的loadCarts
方法。

CartController
新增方法:queryCartList
1 2 3 4 5 6 7 8 9 10 11 12 13
|
@GetMapping public ResponseEntity<List<Cart>> queryCartList() { List<Cart> carts = cartService.queryCartList(); if (carts == null || carts.isEmpty()) { throw new LyException(LyExceptionEnum.CURRENT_USER_CART_NOT_EXIST); } return ResponseEntity.ok(carts); }
|
CartService
新增方法:queryCartList
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21
|
public List<Cart> queryCartList() { UserInfo userInfo = UserInterceptor.getUserInfo(); String key = KEY_PREFIX + userInfo.getId(); if (!redisTemplate.hasKey(key)) { throw new LyException(LyExceptionEnum.CURRENT_USER_CART_NOT_EXIST); } BoundHashOperations<String, Object, Object> userCartData = redisTemplate.boundHashOps(key); List<Object> values = userCartData.values(); if (CollectionUtils.isEmpty(values)) { throw new LyException(LyExceptionEnum.CURRENT_USER_CART_NOT_EXIST); } return values.stream().map(cart -> JsonUtils.parse(cart.toString(), Cart.class)).collect(Collectors.toList()); }
|
测试
查询购物车成功。

修改数量
页面请求
在increment
和decrement
中新增逻辑:

CartController
1 2 3 4 5 6 7 8 9 10 11 12
|
@PutMapping public ResponseEntity<Void> updateNum(@RequestParam("skuId") Long skuId, @RequestParam("num") Integer num) { cartService.updateNum(skuId, num); return ResponseEntity.ok().build(); }
|
CartService
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18
|
public void updateNum(Long skuId, Integer num) { UserInfo user = UserInterceptor.getUserInfo(); String key = KEY_PREFIX + user.getId(); BoundHashOperations<String, Object, Object> hashOps = redisTemplate.boundHashOps(key); String json = hashOps.get(skuId.toString()).toString(); Cart cart = JsonUtils.parse(json, Cart.class); cart.setNum(num); hashOps.put(skuId.toString(), JsonUtils.serialize(cart)); }
|
删除购物车商品
页面请求

CartController
1 2 3 4 5 6 7 8 9 10
|
@DeleteMapping("{skuId}") public ResponseEntity<Void> deleteCart(@PathVariable("skuId") String skuId) { cartService.deleteCart(skuId); return ResponseEntity.ok().build(); }
|
CartService
1 2 3 4 5 6 7 8 9 10 11 12 13
|
public void deleteCart(String skuId) { UserInfo user = UserInterceptor.getUserInfo(); String key = KEY_PREFIX + user.getId(); BoundHashOperations<String, Object, Object> hashOps = redisTemplate.boundHashOps(key); hashOps.delete(skuId); }
|
登录时合并购物车
页面请求

CartController
1 2 3 4 5 6 7 8 9 10
|
@PostMapping("/merge") public ResponseEntity<Void> addCart(List<Cart> carts) { cartService.mergeCarts(carts); return ResponseEntity.ok().build(); }
|
CartService
1 2 3 4 5 6 7 8 9 10 11 12 13 14
|
public void mergeCarts(List<Cart> carts) { UserInfo user = UserInterceptor.getUserInfo(); carts.forEach(cart -> { cart.setUserId(user.getId()); saveCart(cart); }); }
|
写在最后
我自己做的乐优基本上就到这里了,后面的订单
和支付
模块我没做,因为和我之前看的品优购
基本上没什么区别,只是用的Spring Boot
而已,有兴趣做的,可以去看下品优购
的订单
和支付
模块。
个人感觉,黑马的电商项目基本上都差不多了,只是用的技术的区别。