购物车功能分析
需求
- 用户可以在登录状态下将商品添加到购物车
- 放入数据库
- 放入redis(采用)
- 用户可以在未登录状态下将商品添加到购物车
- 放入localstorage
- 用户可以使用购物车一起结算下单
- 用户可以查询自己的购物车
- 用户可以在购物车中修改购买商品的数量。
- 用户可以在购物车中删除商品。
- 在购物车中展示商品优惠信息
- 提示购物车商品价格变化
流程图
这幅图主要描述了两个功能:新增商品到购物车、查询购物车。
新增商品:
- 判断是否登录
- 是:则添加商品到后台Redis中
- 否:则添加商品到本地的Localstorage
无论哪种新增,完成后都需要查询购物车列表:
- 判断是否登录
- 否:直接查询localstorage中数据并展示
- 是:已登录,则需要先看本地是否有数据,
- 有:需要提交到后台添加到redis,合并数据,而后查询
- 否:直接去后台查询redis,而后返回
未登录购物车
准备工作
购物车的数据结构
首先分析一下未登录购物车的数据结构。
看下页面展示需要什么数据:
因此每一个购物车信息,都是一个对象,包含:
{
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的用法
语法非常简单:
localStorage.setItem("key","value"); // 存储数据
localStorage.getItem("key"); // 获取数据
localStorage.removeItem("key"); // 删除数据
注意:localStorage和SessionStorage都只能保存字符串。
不过,在common.js
中,已经对localStorage
进行了简单的封装:
示例:
获取num
在
ly-page
项目中的item.html
模板中做如下修改:
-
添加购物车需要知道购物的数量,所以我们需要获取数量大小。我们在Vue中定义num,保存数量
-
编写方法,用于数量的增加和减少
methods: { incrment() { this.num++ }, decrment() { if (this.num > 1) { this.num-- } } }
-
将num与页面的input框绑定,同时给
+
和-
的按钮绑定事件
添加商品到购物车
在
ly-page
项目中的item.html
模板中做如下修改:
-
为
加入购物车
按钮添加单击事件 -
编写
addCart
方法addCart(){ ly.verifyUser().then(res=>{ // 已登录发送信息到后台,保存到redis中 }).catch(()=>{ // 未登录保存在浏览器本地的localStorage中 // 1、查询本地购物车 let carts = ly.store.get("carts") || []; let cart = carts.find(c=>c.skuId===this.sku.id); // 2、判断是否存在 if (cart) { // 3、存在更新数量 cart.num += this.num; } else { // 4、不存在,新增 cart = { skuId: this.sku.id, title: this.sku.title, price: this.sku.price, image: this.sku.images, num: this.num, ownSpec: this.sku.ownSpec } carts.push(cart); } // 把carts写回localstorage ly.store.set("carts", carts); // 跳转 window.location.href = "http://www.leyou.com/cart.html"; }); }
-
在
common.js
中加入verifyUser()
方法 -
测试
加入购物车成功。
查询购物车
页面加载获取购物车
-
购物车页面加载时,就应该去查询购物车。
<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的数据
修改数量
-
给页面的
+
和-
绑定点击事件,修改num 的值 -
编写
incrment
和decrment
方法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
方法deleteCart(i){ ly.verifyUser().then(res=>{ // TODO,已登录购物车 }).catch(()=>{ // 未登录购物车 this.carts.splice(i, 1); ly.store.set("carts", this.carts); }) }
选中商品
选中单个
-
在Vue中定义变量,记录被选中的购物车项
-
将
selected
与页面的选择框绑定,值为当前购物车项
初始化全部选中
-
修改
loadCarts
方法
计算所有商品总价
-
在Vue加入计算方法
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
<?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>
启动类
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
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
用户鉴权
引入依赖
<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
中新增公钥配置
ly:
jwt:
pubKeyPath: c:\\key\\rsa.pub # 公钥地址
cookieName: LY_TOKEN # cookie的名称
JwtProperties
从
ly-gateway
中复制过来即可。
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();
}
}
}
编写拦截器
编写连接器对所有的请求进行统一鉴权。
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;
}
/**
* 前置拦截, 从cookie中获取User信息
*
* @param request http请求
* @param response http响应
* @param handler 响应的处理器, 可以自定义controller处理响应
* @return boolean 获取到User信息返回true, 否则返回false
*/
@Override
public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) throws Exception {
try {
// 从cookie中获取token
String token = CookieUtils.getCookieValue(request, jwtProperties.getCookieName());
// 从token中解析User信息
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();
}
/**
* 获取用户信息
*
* @return UserInfo 用户信息
*/
public static UserInfo getUserInfo() {
return userInfoThreadLocal.get();
}
}
- 这里我们使用了
ThreadLocal
来存储查询到的用户信息,线程内共享,因此请求到达Controller
后可以共享User。 - 并且对外提供了静态的方法:
getLoginUser()
来获取User信息。
配置过滤器
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,值是购物车数据
购物车实体类
package com.leyou.cart.pojo;
import lombok.Data;
/**
* 购物车实体类
*/
@Data
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;// 商品规格参数
}
添加商品到购物车
item-service新增查询sku接口
-
controller
/** * 查询sku信息 * * @param skuId skuId * @return Sku 商品sku信息 */ @GetMapping("/sku/{skuId}") public ResponseEntity<Sku> querySkuById(@PathVariable("skuId") Long skuId) { return ResponseEntity.ok(goodsService.querySkuById(skuId)); }
-
service
/** * 查询sku信息 * * @param skuId skuId * @return Sku 商品sku信息 */ 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
对外开放接口/** * 查询sku信息 * * @param skuId skuId * @return Sku 商品sku信息 */ @GetMapping("goods/sku/{skuId}") Sku querySkuById(@PathVariable("skuId") Long skuId);
GoodsClient
package com.leyou.cart.client;
import com.leyou.api.GoodsApi;
import com.leyou.common.util.LeyouConstants;
import org.springframework.cloud.openfeign.FeignClient;
/**
* GoodsClient
*/
@FeignClient(LeyouConstants.SERVICE_ITEM)
public interface GoodsClient extends GoodsApi {
}
需要引入
ly-item-interface
的依赖。
CartController
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) {
// 将userid放入cart
cart.setUserId(UserInterceptor.getUserInfo().getId());
cartService.saveCart(cart);
return ResponseEntity.ok().build();
}
}
CartService
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:";
/**
* 保存购物车
* @param cart 购物车数据
*/
public void saveCart(Cart cart) {
String key = KEY_PREFIX + cart.getUserId();
// 获取当前用户购物车信息
BoundHashOperations<String, Object, Object> userCartData = redisTemplate.boundHashOps(key);
// 拿出cart中的关键数据
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());
}
// 将购物车信息存入redis
userCartData.put(cart.getSkuId().toString(), JsonUtils.serialize(cart));
}
}
测试
前台登录过后,点击加入购物车,前往redis
查询。
这里使用的是redis desktop manager
,一款redis
的GUI客户端。
查询购物车
页面请求
修改cart.html
中的loadCarts
方法。
CartController
新增方法:queryCartList
/**
* 查询当前用户购物车
*
* @return List 购物车商品列表
*/
@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
/**
* 查询当前用户购物车
*
* @return List 购物车商品列表
*/
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
/**
* 更新购物车中指定商品数量
*
* @param skuId 商品ID
* @param num 数量
*/
@PutMapping
public ResponseEntity<Void> updateNum(@RequestParam("skuId") Long skuId,
@RequestParam("num") Integer num) {
cartService.updateNum(skuId, num);
return ResponseEntity.ok().build();
}
CartService
/**
* 更新购物车中指定商品数量
*
* @param skuId 商品ID
* @param num 数量
*/
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
/**
* 删除购物车中的指定商品
*
* @param skuId 商品ID
*/
@DeleteMapping("{skuId}")
public ResponseEntity<Void> deleteCart(@PathVariable("skuId") String skuId) {
cartService.deleteCart(skuId);
return ResponseEntity.ok().build();
}
CartService
/**
* 删除购物车中的指定商品
*
* @param skuId 商品ID
*/
public void deleteCart(String skuId) {
// 获取登录用户
UserInfo user = UserInterceptor.getUserInfo();
String key = KEY_PREFIX + user.getId();
BoundHashOperations<String, Object, Object> hashOps = redisTemplate.boundHashOps(key);
// 删除该hashKey
hashOps.delete(skuId);
}
登录时合并购物车
页面请求
CartController
/**
* 新增本地购物车商品到登陆用户购物车中
*
* @param carts 商品数据
*/
@PostMapping("/merge")
public ResponseEntity<Void> addCart(List<Cart> carts) {
cartService.mergeCarts(carts);
return ResponseEntity.ok().build();
}
CartService
/**
* 新增本地购物车商品到登陆用户购物车中
*
* @param carts 商品数据
*/
public void mergeCarts(List<Cart> carts) {
// 获取当前用户信息
UserInfo user = UserInterceptor.getUserInfo();
// 遍历购物车并保存
carts.forEach(cart -> {
cart.setUserId(user.getId());
saveCart(cart);
});
}
写在最后
我自己做的乐优基本上就到这里了,后面的订单
和支付
模块我没做,因为和我之前看的品优购
基本上没什么区别,只是用的Spring Boot
而已,有兴趣做的,可以去看下品优购
的订单
和支付
模块。
个人感觉,黑马的电商项目基本上都差不多了,只是用的技术的区别。