乐忧商城项目总结-5

16.用户注册

16.1 创建用户中心

用户搜索到自己心仪的商品,接下来就要去购买,但是购买必须先登录。所以接下来我们编写用户中心,实现用户的登录和注册功能。

用户中心的提供的服务:

  • 用户的注册
  • 用户登录
  • 用户个人信息管理
  • 用户地址管理
  • 用户收藏管理
  • 我的订单
  • 优惠券管理

这里我们暂时先实现基本的:注册和登录功能。因为用户中心的服务其它微服务也会调用,因此这里我们做聚合。

leyou-user:父工程,包含2个子工程:

  • leyou-user-interface:实体及接口
  • leyou-user-service:业务和服务

16.2 后台功能准备

数据结构

CREATE TABLE `tb_user` (
  `id` bigint(20) NOT NULL AUTO_INCREMENT,
  `username` varchar(50) NOT NULL COMMENT '用户名',
  `password` varchar(32) NOT NULL COMMENT '密码,加密存储',
  `phone` varchar(20) DEFAULT NULL COMMENT '注册手机号',
  `created` datetime NOT NULL COMMENT '创建时间',
  `salt` varchar(32) NOT NULL COMMENT '密码加密的salt值',
  PRIMARY KEY (`id`),
  UNIQUE KEY `username` (`username`) USING BTREE
) ENGINE=InnoDB AUTO_INCREMENT=28 DEFAULT CHARSET=utf8 COMMENT='用户表';

数据结构比较简单,因为根据用户名查询的频率较高,所以我们给用户名创建了索引

实体类:

package com.leyou.user.pojo;

import com.fasterxml.jackson.annotation.JsonIgnore;
import org.hibernate.validator.constraints.Length;

import javax.persistence.GeneratedValue;
import javax.persistence.GenerationType;
import javax.persistence.Id;
import javax.persistence.Table;
import javax.validation.constraints.Pattern;
import java.util.Date;

@Table(name = "tb_user")
public class User {
    @Id
    @GeneratedValue(strategy = GenerationType.IDENTITY)
    private Long id;

    @Length(min = 4, max = 16, message = "用户名长度应该在4到16位之间")
    private String username;// 用户名

    @JsonIgnore
    @Length(min = 4, max = 16, message = "密码长度应该在4到16位之间")
    private String password;// 密码

    @Pattern(regexp = "^1[35678]\\d{9}$", message = "手机号格式不正确")
    private String phone;// 电话

    private Date created;// 创建时间

    @JsonIgnore
    private String salt;// 密码的盐值

    public Long getId() {
        return id;
    }

    public void setId(Long id) {
        this.id = id;
    }

    public String getUsername() {
        return username;
    }

    public void setUsername(String username) {
        this.username = username;
    }

    public String getPassword() {
        return password;
    }

    public void setPassword(String password) {
        this.password = password;
    }

    public String getPhone() {
        return phone;
    }

    public void setPhone(String phone) {
        this.phone = phone;
    }

    public Date getCreated() {
        return created;
    }

    public void setCreated(Date created) {
        this.created = created;
    }

    public String getSalt() {
        return salt;
    }

    public void setSalt(String salt) {
        this.salt = salt;
    }
}

注意:为了安全考虑。这里对password和salt添加了注解@JsonIgnore,这样在json序列化时,就不会把password和salt返回。

16.3 数据验证功能

接口说明
实现用户数据的校验,主要包括对:手机号、用户名的唯一性校验。
接口路径

GET /check/{data}/{type}

参数说明

参数说明是否必须数据类型默认值
data要校验的数据String
type要校验的数据类型:1.用户名 2.手机Integer1

返回结果:

返回布尔类型结果:

  • true:可用
  • false:不可用

状态码:

  • 200:校验成功
  • 400:参数有误
  • 500:服务器内部异常

Controller类:
因为有了接口,我们可以不关心页面,所有需要的东西都一清二楚:

  • 请求方式:GET
  • 请求路径:/check/{param}/{type}
  • 请求参数:param,type
  • 返回结果:true或false
/**
  * 校验数据是否可用
  * @param data
  * @param type
  * @return
  */
@GetMapping("check/{data}/{type}")
public ResponseEntity<Boolean> checkUserData(@PathVariable("data") String data, @PathVariable(value = "type") Integer type) {
    Boolean boo = this.userService.checkData(data, type);
    if (boo == null) {
        return ResponseEntity.status(HttpStatus.BAD_REQUEST).build();
    }
    return ResponseEntity.ok(boo);
}

UserService类:

public Boolean checkData(String data, Integer type) {
    User record = new User();
    switch (type) {
        case 1:
            record.setUsername(data);
            break;
        case 2:
            record.setPhone(data);
            break;
        default:
            return null;
    }
    return this.userMapper.selectCount(record) == 0;
}

16.4 阿里大于短信服务

很遗憾,这部分没法完成,因为申请这个东西现在需要已经备案的网站什么之类的,弄了半天没有申请到,但是这个部分无非是调一个发短信的API,缺少这部分对整体也没啥太大影响。
创建短信微服务
因为系统中不止注册一个地方需要短信发送,因此我们将短信发送抽取为微服务:leyou-sms-service,凡是需要的地方都可以使用。

另外,因为短信发送API调用时长的不确定性,为了提高程序的响应速度,短信发送我们都将采用异步发送方式,即:

  • 短信服务监听MQ消息,收到消息后发送短信。
  • 其它服务要发送短信时,通过MQ通知短信微服务。

1.导入依赖

<?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>leyou</artifactId>
        <groupId>com.leyou.parent</groupId>
        <version>1.0.0-SNAPSHOT</version>
    </parent>
    <modelVersion>4.0.0</modelVersion>

    <groupId>com.leyou.sms</groupId>
    <artifactId>leyou-sms</artifactId>

    <properties>
        <maven.compiler.source>11</maven.compiler.source>
        <maven.compiler.target>11</maven.compiler.target>
    </properties>

    <dependencies>
        <dependency>
            <groupId>org.springframework.boot</groupId>
            <artifactId>spring-boot-starter-web</artifactId>
        </dependency>
        <dependency>
            <groupId>org.slf4j</groupId>
            <artifactId>slf4j-api</artifactId>
        </dependency>
        <dependency>
            <groupId>com.aliyun</groupId>
            <artifactId>aliyun-java-sdk-core</artifactId>
            <version>3.3.1</version>
        </dependency>
        <dependency>
            <groupId>com.aliyun</groupId>
            <artifactId>aliyun-java-sdk-dysmsapi</artifactId>
            <version>1.0.0</version>
        </dependency>
        <dependency>
            <groupId>org.springframework.boot</groupId>
            <artifactId>spring-boot-starter-amqp</artifactId>
        </dependency>
    </dependencies>
</project>

2.添加配置

server:
  port: 8086
spring:
  application:
    name: sms-service
  rabbitmq:
    host: 192.168.124.121
    virtual-host: /leyou
    username: leyou
    password: leyou
leyou:
  sms:
    accessKeyId: LTAItNxtDfk7MJMq # 你自己的accessKeyId 这部分已经没法使用了
    accessKeySecret: HKUIDl21ZM7XoubErXjUFJq84dha8W # 你自己的AccessKeySecret
    signName: 乐优商城 # 签名名称
    verifyCodeTemplate: SMS_143714980 # 模板名称

3.编写启动类

package com.leyou;

import org.springframework.boot.SpringApplication;
import org.springframework.boot.autoconfigure.SpringBootApplication;

@SpringBootApplication
public class LeyouSmsApplication {
    public static void main(String[] args) {
        SpringApplication.run(LeyouSmsApplication.class,args);
    }
}

编写短信工具类
先将属性抽取出来注入到属性类中:

@ConfigurationProperties(prefix = "leyou.sms")
public class SmsProperties {

    String accessKeyId;

    String accessKeySecret;

    String signName;

    String verifyCodeTemplate;

    public String getAccessKeyId() {
        return accessKeyId;
    }

    public void setAccessKeyId(String accessKeyId) {
        this.accessKeyId = accessKeyId;
    }

    public String getAccessKeySecret() {
        return accessKeySecret;
    }

    public void setAccessKeySecret(String accessKeySecret) {
        this.accessKeySecret = accessKeySecret;
    }

    public String getSignName() {
        return signName;
    }

    public void setSignName(String signName) {
        this.signName = signName;
    }

    public String getVerifyCodeTemplate() {
        return verifyCodeTemplate;
    }

    public void setVerifyCodeTemplate(String verifyCodeTemplate) {
        this.verifyCodeTemplate = verifyCodeTemplate;
    }
}

然后把阿里提供的demo进行简化和抽取,封装一个工具类(这个是好几年前的工具类了,现在已经不能这么使用了,但是没办法,反正也弄不了了,就直接把以前的粘贴上来占个位置吧):

package com.leyou.sms.utils;

import com.aliyuncs.DefaultAcsClient;
import com.aliyuncs.IAcsClient;
import com.aliyuncs.dysmsapi.model.v20170525.SendSmsRequest;
import com.aliyuncs.dysmsapi.model.v20170525.SendSmsResponse;
import com.aliyuncs.exceptions.ClientException;
import com.aliyuncs.http.MethodType;
import com.aliyuncs.profile.DefaultProfile;
import com.aliyuncs.profile.IClientProfile;
import com.leyou.sms.config.SmsProperties;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.context.properties.EnableConfigurationProperties;
import org.springframework.stereotype.Component;


@Component
@EnableConfigurationProperties(SmsProperties.class)
public class SmsUtils {

    @Autowired
    private SmsProperties prop;

    //产品名称:云通信短信API产品,开发者无需替换
    static final String product = "Dysmsapi";
    //产品域名,开发者无需替换
    static final String domain = "dysmsapi.aliyuncs.com";

    static final Logger logger = LoggerFactory.getLogger(SmsUtils.class);

    public SendSmsResponse sendSms(String phone, String code, String signName, String template) throws ClientException {

        //可自助调整超时时间
        System.setProperty("sun.net.client.defaultConnectTimeout", "10000");
        System.setProperty("sun.net.client.defaultReadTimeout", "10000");

        //初始化acsClient,暂不支持region化
        IClientProfile profile = DefaultProfile.getProfile("cn-hangzhou",
                prop.getAccessKeyId(), prop.getAccessKeySecret());
        DefaultProfile.addEndpoint("cn-hangzhou", "cn-hangzhou", product, domain);
        IAcsClient acsClient = new DefaultAcsClient(profile);

        //组装请求对象-具体描述见控制台-文档部分内容
        SendSmsRequest request = new SendSmsRequest();
        request.setMethod(MethodType.POST);
        //必填:待发送手机号
        request.setPhoneNumbers(phone);
        //必填:短信签名-可在短信控制台中找到
        request.setSignName(signName);
        //必填:短信模板-可在短信控制台中找到
        request.setTemplateCode(template);
        //可选:模板中的变量替换JSON串,如模板内容为"亲爱的${name},您的验证码为${code}"时,此处的值为
        request.setTemplateParam("{\"code\":\"" + code + "\"}");

        //选填-上行短信扩展码(无特殊需求用户请忽略此字段)
        //request.setSmsUpExtendCode("90997");

        //可选:outId为提供给业务方扩展字段,最终在短信回执消息中将此值带回给调用者
        request.setOutId("123456");

        //hint 此处可能会抛出异常,注意catch
        SendSmsResponse sendSmsResponse = acsClient.getAcsResponse(request);

        logger.info("发送短信状态:{}", sendSmsResponse.getCode());
        logger.info("发送短信消息:{}", sendSmsResponse.getMessage());

        return sendSmsResponse;
    }
}

编写消息监听器
接下来,编写消息监听器,当接收到消息后,我们发送短信。

@Component
@EnableConfigurationProperties(SmsProperties.class)
public class SmsListener {

    @Autowired
    private SmsUtils smsUtils;

    @Autowired
    private SmsProperties prop;

    @RabbitListener(bindings = @QueueBinding(
            value = @Queue(value = "leyou.sms.queue", durable = "true"),
            exchange = @Exchange(value = "leyou.sms.exchange", 
                                 ignoreDeclarationExceptions = "true"),
            key = {"sms.verify.code"}))
    public void listenSms(Map<String, String> msg) throws Exception {
        if (msg == null || msg.size() <= 0) {
            // 放弃处理
            return;
        }
        String phone = msg.get("phone");
        String code = msg.get("code");

        if (StringUtils.isBlank(phone) || StringUtils.isBlank(code)) {
            // 放弃处理
            return;
        }
        // 发送消息
        SendSmsResponse resp = this.smsUtils.sendSms(phone, code, 
                                                     prop.getSignName(),
                                                     prop.getVerifyCodeTemplate());
        
    }
}

我们注意到,消息体是一个Map,里面有两个属性:

  • phone:电话号码
  • code:短信验证码

16.5 发送短信功能

这里的业务逻辑是这样的:

  • 1)我们接收页面发送来的手机号码
  • 2)生成一个随机验证码
  • 3)将验证码保存在服务端
  • 4)发送短信,将验证码发送到用户手机

那么问题来了:验证码保存在哪里呢?
验证码有一定有效期,一般是5分钟,我们可以利用Redis的过期机制来保存。
Spring Data Redis
Spring Data Redis,是Spring Data 家族的一部分。 对Jedis客户端进行了封装,与spring进行了整合。可以非常方便的来实现redis的配置和操作。
RedisTemplate基本操作
Spring Data Redis 提供了一个工具类:RedisTemplate。里面封装了对于Redis的五种数据结构的各种操作,包括:

  • redisTemplate.opsForValue() :操作字符串
  • redisTemplate.opsForHash() :操作hash
  • redisTemplate.opsForList():操作list
  • redisTemplate.opsForSet():操作set
  • redisTemplate.opsForZSet():操作zset

其它一些通用命令,如expire,可以通过redisTemplate.xx()来直接调用

5种结构:

  • String:等同于java中的,Map<String,String>

  • list:等同于java中的Map<String,List>

  • set:等同于java中的Map<String,Set>

  • sort_set:可排序的set

  • hash:等同于java中的:`Map<String,Map<String,String>>
    StringRedisTemplate
    RedisTemplate在创建时,可以指定其泛型类型:

  • K:代表key 的数据类型

  • V: 代表value的数据类型

注意:这里的类型不是Redis中存储的数据类型,而是Java中的数据类型,RedisTemplate会自动将Java类型转为Redis支持的数据类型:字符串、字节、二进制等等。不过RedisTemplate默认会采用JDK自带的序列化(Serialize)来对对象进行转换。生成的数据十分庞大,因此一般我们都会指定key和value为String类型,这样就由我们自己把对象序列化为json字符串来存储即可。

因为大部分情况下,我们都会使用key和value都为String的RedisTemplate,因此Spring就默认提供了这样一个实现:
在这里插入图片描述
具体怎么实现呢?
需要三个步骤:

  • 生成随机验证码
  • 将验证码保存到Redis中,用来在注册的时候验证
  • 发送验证码到leyou-sms-service服务,发送短信

在UserController类中添加方法:

/**
 * 发送手机验证码
 * @param phone
 * @return
 */
@PostMapping("code")
public ResponseEntity<Void> sendVerifyCode(String phone) {
    Boolean boo = this.userService.sendVerifyCode(phone);
    if (boo == null || !boo) {
        return new ResponseEntity<>(HttpStatus.INTERNAL_SERVER_ERROR);
    }
    return new ResponseEntity<>(HttpStatus.CREATED);
}

在UserService中添加如下代码:

@Autowired
private StringRedisTemplate redisTemplate;

@Autowired
private AmqpTemplate amqpTemplate;

static final String KEY_PREFIX = "user:code:phone:";

static final Logger logger = LoggerFactory.getLogger(UserService.class);

public Boolean sendVerifyCode(String phone) {
    // 生成验证码
    String code = NumberUtils.generateCode(6);
    try {
        // 发送短信
        Map<String, String> msg = new HashMap<>();
        msg.put("phone", phone);
        msg.put("code", code);
        this.amqpTemplate.convertAndSend("leyou.sms.exchange", "sms.verify.code", msg);
        // 将code存入redis
        this.redisTemplate.opsForValue().set(KEY_PREFIX + phone, code, 5, TimeUnit.MINUTES);
        return true;
    } catch (Exception e) {
        logger.error("发送短信失败。phone:{}, code:{}", phone, code);
        return false;
    }
}

注意:要设置短信验证码在Redis的缓存时间为5分钟

16.6 注册功能

接口说明
在这里插入图片描述
基本逻辑:

  • 1)校验短信验证码
  • 2)生成盐
  • 3)对密码加密
  • 4)写入数据库
  • 5)删除Redis中的验证码

在UserController类中加入:

/**
 * 注册
 * @param user
 * @param code
 * @return
 */
@PostMapping("register")
public ResponseEntity<Void> register(User user, @RequestParam("code") String code) {
    Boolean boo = this.userService.register(user, code);
    if (boo == null || !boo) {
        return ResponseEntity.status(HttpStatus.BAD_REQUEST).build();
    }
    return new ResponseEntity<>(HttpStatus.CREATED);
}

在UserService中加入:

public Boolean register(User user, String code) {
    // 校验短信验证码
    String cacheCode = this.redisTemplate.opsForValue().get(KEY_PREFIX + user.getPhone());
    if (!StringUtils.equals(code, cacheCode)) {
        return false;
    }

    // 生成盐
    String salt = CodecUtils.generateSalt();
    user.setSalt(salt);

    // 对密码加密
    user.setPassword(CodecUtils.md5Hex(user.getPassword(), salt));

    // 强制设置不能指定的参数为null
    user.setId(null);
    user.setCreated(new Date());
    // 添加到数据库
    boolean b = this.userMapper.insertSelective(user) == 1;

    if(b){
        // 注册成功,删除redis中的记录
        this.redisTemplate.delete(KEY_PREFIX + user.getPhone());
    }
    return b;
}

hibernate-validate
刚才虽然实现了注册,但是服务端并没有进行数据校验,而前端的校验是很容易被有心人绕过的。所以我们必须在后台添加数据校验功能:我们这里会使用Hibernate-Validator框架完成数据校验:
而SpringBoot的web启动器中已经集成了相关依赖:
在这里插入图片描述
Hibernate Validator是Hibernate提供的一个开源框架,使用注解方式非常方便的实现服务端的数据校验。hibernate Validator 是 Bean Validation 的参考实现 。Hibernate Validator 提供了 JSR 303 规范中所有内置 constraint(约束) 的实现,除此之外还有一些附加的 constraint。在日常开发中,Hibernate Validator经常用来验证bean的字段,基于注解,方便快捷高效。
常用的注解如下:
在这里插入图片描述

如何使用呢?
1.引入依赖

<dependency>
    <groupId>org.hibernate.validator</groupId>
    <artifactId>hibernate-validator</artifactId>
</dependency>

2.在User对象的部分属性上添加注解:


@Table(name = "tb_user")
public class User {
    @Id
    @GeneratedValue(strategy = GenerationType.IDENTITY)
    private Long id;
    @Length(min = 4, max = 30, message = "用户名只能在4~30位之间")
    private String username;// 用户名

    @JsonIgnore
    @Length(min = 4, max = 30, message = "密码只能在4~30位之间")
    private String password;// 密码

    @Pattern(regexp = "^1[35678]\\d{9}$", message = "手机号格式不正确")
    private String phone;// 电话

    private Date created;// 创建时间

    @JsonIgnore
    private String salt;// 密码的盐值
}

3.在controller中改造register方法,只需要给User添加 @Valid注解即可。
在这里插入图片描述

16.7 根据用户名和密码查询用户

查询功能,根据参数中的用户名和密码查询指定用户
接口路径

GET /query

参数说明

参数说明是否必须数据类型默认值
username用户名,格式为4~30位字母、数字、下划线String
password用户密码,格式为4~30位字母、数字、下划线String

状态码

  • 200:注册成功
  • 400:用户名或密码错误
  • 500:服务器内部异常,注册失败

在UserController类中加入:

/**
 * 根据用户名和密码查询用户
 * @param username
 * @param password
 * @return
 */
@GetMapping("query")
public ResponseEntity<User> queryUser(
    @RequestParam("username") String username,
    @RequestParam("password") String password
    ) {
        User user = this.userService.queryUser(username, password);
        if (user == null) {
            return ResponseEntity.status(HttpStatus.BAD_REQUEST).build();
        }
        return ResponseEntity.ok(user);
    }

在UserService类中加入:

public User queryUser(String username, String password) {
    // 查询
    User record = new User();
    record.setUsername(username);
    User user = this.userMapper.selectOne(record);
    // 校验用户名
    if (user == null) {
        return null;
    }
    // 校验密码
    if (!user.getPassword().equals(CodecUtils.md5Hex(password, user.getSalt()))) {
        return null;
    }
    // 用户名密码都正确
    return user;
}

要注意,查询时也要对密码进行加密后判断是否一致。

16.8 总结

1.用户名和手机号的校验
判断type的值
1:校验用户名
2:校验手机号
使用selectCount(record)==0

2.发送短信功能
阿里大于
参照demo工程
redis
安装
SDR使用:StringRedisTemplate
搭建了一个微服务:sms-service,监听rabbitmq的队列,获取到消息之后发送短信

1.生成验证码
2.发送消息给rabbitMQ的队列
3.保存验证码到redis中

3.注册功能:
1.校验验证码
2.生成盐
3.加盐加密
4.新增用户

hibernate-validate:对bean Validate(JSR303 规范)的实现
提供了一系列的注解,通过注解就可以校验数据的合法性
@Valid

4.查询用户(用户名和密码)
1.根据用户查询用户
2.判断用户是否存在
3.对用户输入的密码加盐加密
4.对密码进行比较

17.授权中心

17.1 无状态登录原理

什么是有状态?
有状态服务,即服务端需要记录每次会话的客户端信息,从而识别客户端身份,根据用户身份进行请求的处理,典型的设计如tomcat中的session。

例如登录:用户登录后,我们把登录者的信息保存在服务端session中,并且给用户一个cookie值,记录对应的session。然后下次请求,用户携带cookie值来,我们就能识别到对应session,从而找到用户的信息。

缺点是什么?

  • 服务端保存大量数据,增加服务端压力

  • 服务端保存用户状态,无法进行水平扩展

  • 客户端请求依赖服务端,多次请求必须访问同一台服务器
    什么是无状态
    微服务集群中的每个服务,对外提供的都是Rest风格的接口。而Rest风格的一个最重要的规范就是:服务的无状态性,即:

  • 服务端不保存任何客户端请求者信息

  • 客户端的每次请求必须具备自描述信息,通过这些信息识别客户端身份

带来的好处是什么呢?

  • 客户端请求不依赖服务端的信息,任何多次请求不需要必须访问到同一台服务器
  • 服务端的集群和状态对客户端透明
  • 服务端可以任意的迁移和伸缩
  • 减小服务端存储压力

如何实现无状态
无状态登录的流程:

  • 当客户端第一次请求服务时,服务端对用户进行信息认证(登录)
  • 认证通过,将用户信息进行加密形成token,返回给客户端,作为登录凭证
  • 以后每次请求,客户端都携带认证的token
  • 服务端对token进行解密,判断是否有效。

流程图:
在这里插入图片描述
整个登录过程中,最关键的点是什么?当然是token的安全性

token是识别客户端身份的唯一标示,如果加密不够严密,被人伪造那就完蛋了。采用何种方式加密才是安全可靠的呢?我们将采用JWT + RSA非对称加密
JWT
JWT,全称是Json Web Token, 是JSON风格轻量级的授权和身份认证规范,可实现无状态、分布式的Web应用授权;官网:https://jwt.io
数据格式
JWT包含三部分数据:

  • Header:头部,通常头部有两部分信息:
    • 声明类型,这里是JWT
      我们会对头部进行base64编码,得到第一部分数据
  • Payload:载荷,就是有效数据,一般包含下面信息:
    • 用户身份信息(注意,这里因为采用base64编码,可解码,因此不要存放敏感信息)
    • 注册声明:如token的签发时间,过期时间,签发人等
      这部分也会采用base64编码,得到第二部分数据
  • Signature:签名,是整个数据的认证信息。一般根据前两步的数据,再加上服务的的密钥(secret)(不要泄漏,最好周期性更换),通过加密算法生成。用于验证整个数据完整和可靠性

生成的数据格式:token==个人证件 jwt=个人身份证
在这里插入图片描述
可以看到分为3段,每段就是上面的一部分数据
JWT交互流程
流程图:
在这里插入图片描述
步骤翻译:

  • 1、用户登录
  • 2、服务的认证,通过后根据secret生成token
  • 3、将生成的token返回给浏览器
  • 4、用户每次请求携带token
  • 5、服务端利用公钥解读jwt签名,判断签名有效后,从Payload中获取用户信息
  • 6、处理请求,返回响应结果

因为JWT签发的token中已经包含了用户的身份信息,并且每次请求都会携带,这样服务端就无需保存用户信息,甚至无需去数据库查询,完全符合了Rest的无状态规范。

非对称加密
加密技术是对信息进行编码和解码的技术,编码是把原来可读信息(又称明文)译成代码形式(又称密文),其逆过程就是解码(解密),加密技术的要点是加密算法,加密算法可以分为三类:

  • 对称加密,如AES
    • 基本原理:将明文分成N个组,然后使用密钥对各个组进行加密,形成各自的密文,最后把所有的分组密文进行合并,形成最终的密文。
    • 优势:算法公开、计算量小、加密速度快、加密效率高
    • 缺陷:双方都使用同样密钥,安全性得不到保证
  • 非对称加密,如RSA
    • 基本原理:同时生成两把密钥:私钥和公钥,私钥隐秘保存,公钥可以下发给信任客户端
      • 私钥加密,持有私钥或公钥才可以解密
      • 公钥加密,持有私钥才可解密
    • 优点:安全,难以破解
    • 缺点:算法比较耗时
  • 不可逆加密,如MD5,SHA
    • 基本原理:加密过程中不需要使用密钥,输入明文后由系统直接经过加密算法处理成密文,这种加密后的数据是无法被解密的,无法根据密文推算出明文。

RSA算法历史:1977年,三位数学家Rivest、Shamir 和 Adleman 设计了一种算法,可以实现非对称加密。这种算法用他们三个人的名字缩写:RSA

结合Zuul的鉴权流程
我们逐步演进系统架构设计。需要注意的是:secret是签名的关键,因此一定要保密,我们放到鉴权中心保存,其它任何服务中都不能获取secret。
1.没有RSA加密时
在微服务架构中,我们可以把服务的鉴权操作放到网关中,将未通过鉴权的请求直接拦截,如图:
在这里插入图片描述

  • 1、用户请求登录
  • 2、Zuul将请求转发到授权中心,请求授权
  • 3、授权中心校验完成,颁发JWT凭证
  • 4、客户端请求其它功能,携带JWT
  • 5、Zuul将jwt交给授权中心校验,通过后放行
  • 6、用户请求到达微服务
  • 7、微服务将jwt交给鉴权中心,鉴权同时解析用户信息
  • 8、鉴权中心返回用户数据给微服务
  • 9、微服务处理请求,返回响应

这种结构的问题:每次鉴权都需要访问鉴权中心,系统间的网络请求频率过高,效率略差,鉴权中心的压力较大。

2.结合RSA的鉴权
在这里插入图片描述

  • 我们首先利用RSA生成公钥和私钥。私钥保存在授权中心,公钥保存在Zuul和各个信任的微服务
  • 用户请求登录
  • 授权中心校验,通过后用私钥对JWT进行签名加密
  • 返回jwt给用户
  • 用户携带JWT访问
  • Zuul直接通过公钥解密JWT,进行验证,验证通过则放行
  • 请求到达微服务,微服务直接用公钥解析JWT,获取用户信息,无需访问授权中心

**这里我一直没弄明白,RSA 对 JWT 加密,这不是多此一举吗???要么用RSA要么用JWT加密,如果你用RSA把JWT加密了,你解密的时候也只是得到了原来加密过的JWT,然后你怎么直接获取用户信息呢?????*

17.2 授权中心

授权中心的主要职责:

  • 用户鉴权:
    • 接收用户的登录请求,通过用户中心的接口进行校验,通过后生成JWT
    • 使用私钥生成JWT并返回
  • 服务鉴权:微服务间的调用不经过Zuul,会有风险,需要鉴权中心进行认证
    • 原理与用户鉴权类似,但逻辑稍微复杂一些(此处我们不做实现)

因为生成jwt,解析jwt这样的行为以后在其它微服务中也会用到,因此我们会抽取成工具。我们把鉴权中心进行聚合,一个工具module,一个提供服务的module

我们先创建父module,名称为:leyou-auth
然后是授权服务的通用模块:leyou-auth-common
最后是授权服务的服务模块:leyou-auth-service(引入依赖和编写配置文件就省略了)

JWT工具类
我们在leyou-auth-common中导入资料中的工具类:
在这里插入图片描述
需要在leyou-auth-common中引入JWT依赖:

<dependencies>
    <dependency>
        <groupId>org.apache.commons</groupId>
        <artifactId>commons-lang3</artifactId>
    </dependency>
    <dependency>
        <groupId>io.jsonwebtoken</groupId>
        <artifactId>jjwt</artifactId>
        <version>0.9.0</version>
    </dependency>
    <dependency>
        <groupId>joda-time</groupId>
        <artifactId>joda-time</artifactId>
    </dependency>
</dependencies>

编写登录授权接口
接下来,我们需要在leyou-auth-servcice编写一个接口,对外提供登录授权服务。基本流程如下:

  • 客户端携带用户名和密码请求登录
  • 授权中心调用用户中心接口,根据用户名和密码查询用户信息
  • 如果用户名密码正确,能获取用户,否则为空,则登录失败
  • 如果校验成功,则生成JWT并返回

我们需要在授权中心生成真正的公钥和私钥。我们必须有一个生成公钥和私钥的secret,这个可以配置到application.yml中:

leyou:
  jwt:
    secret: leyou@Login(Auth}*^31)&heiMa% # 登录校验的密钥,这个可以随便写,写得越复杂越好
    pubKeyPath: C:\\tmp\\rsa\\rsa.pub # 公钥地址
    priKeyPath: C:\\tmp\\rsa\\rsa.pri # 私钥地址
    expire: 30 # 过期时间,单位分钟(表示该jwt30分钟之后过期)
    cookieName: LY_TOKEN
    cookieMaxAge: 30

然后编写属性类

package com.leyou.config;

import com.leyou.common.utils.RsaUtils;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.boot.context.properties.ConfigurationProperties;

import javax.annotation.PostConstruct;
import java.io.File;
import java.security.PrivateKey;
import java.security.PublicKey;

@ConfigurationProperties(prefix = "leyou.jwt")
public class JwtProperties {

    private String secret; // 密钥

    private String pubKeyPath;// 公钥

    private String priKeyPath;// 私钥

    private int expire;// token过期时间

    private PublicKey publicKey; // 公钥

    private PrivateKey privateKey; // 私钥

    private String cookieName;  //cookie的名字

    private Integer cookieMaxAge; // cookie存活时间,单位为分钟

    private static final Logger logger = LoggerFactory.getLogger(JwtProperties.class);

    public String getCookieName() {
        return cookieName;
    }

    public void setCookieName(String cookieName) {
        this.cookieName = cookieName;
    }

    public Integer getCookieMaxAge() {
        return cookieMaxAge;
    }

    public void setCookieMaxAge(Integer cookieMaxAge) {
        this.cookieMaxAge = cookieMaxAge;
    }

    /**
     * @PostContruct:在构造方法执行之后执行该方法
     * 用于生成公钥和私钥
     */
    @PostConstruct
    public void init(){
        try {
            File pubKey = new File(pubKeyPath);
            File priKey = new File(priKeyPath);
            if (!pubKey.exists() || !priKey.exists()) {
                // 生成公钥和私钥
                RsaUtils.generateKey(pubKeyPath, priKeyPath, secret);
            }
            // 获取公钥和私钥
            this.publicKey = RsaUtils.getPublicKey(pubKeyPath);
            this.privateKey = RsaUtils.getPrivateKey(priKeyPath);
        } catch (Exception e) {
            logger.error("初始化公钥和私钥失败!", e);
            throw new RuntimeException();
        }
    }
    
    // getter setter ...

    public String getSecret() {
        return secret;
    }

    public void setSecret(String secret) {
        this.secret = secret;
    }

    public String getPubKeyPath() {
        return pubKeyPath;
    }

    public void setPubKeyPath(String pubKeyPath) {
        this.pubKeyPath = pubKeyPath;
    }

    public String getPriKeyPath() {
        return priKeyPath;
    }

    public void setPriKeyPath(String priKeyPath) {
        this.priKeyPath = priKeyPath;
    }

    public int getExpire() {
        return expire;
    }

    public void setExpire(int expire) {
        this.expire = expire;
    }

    public PublicKey getPublicKey() {
        return publicKey;
    }

    public void setPublicKey(PublicKey publicKey) {
        this.publicKey = publicKey;
    }

    public PrivateKey getPrivateKey() {
        return privateKey;
    }

    public void setPrivateKey(PrivateKey privateKey) {
        this.privateKey = privateKey;
    }
}

AuthController类:
编写授权接口,我们接收用户名和密码,校验成功后,写入cookie中。

  • 请求方式:post
  • 请求路径:/accredit
  • 请求参数:username和password
  • 返回结果:无
package com.leyou.auth.controller;

import com.leyou.auth.service.AuthService;
import com.leyou.common.pojo.CookieUtils;
import com.leyou.common.pojo.UserInfo;
import com.leyou.common.utils.JwtUtils;
import com.leyou.config.JwtProperties;
import com.leyou.user.pojo.User;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.context.properties.EnableConfigurationProperties;
import org.springframework.http.HttpStatus;
import org.springframework.http.ResponseEntity;
import org.springframework.stereotype.Controller;
import org.springframework.util.StringUtils;
import org.springframework.web.bind.annotation.CookieValue;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestParam;

import javax.servlet.http.HttpServletRequest;
import javax.servlet.http.HttpServletResponse;

@Controller
@EnableConfigurationProperties(JwtProperties.class)
public class AuthController {
    @Autowired
    private AuthService authService;

    @Autowired
    private JwtProperties jwtProperties;

    @PostMapping("/accredit")
    public ResponseEntity<Void> accredit(@RequestParam("username")String username,
                                         @RequestParam("password")String password,
                                         HttpServletRequest request,
                                         HttpServletResponse response){
        String token = this.authService.accredit(username,password);
        if(StringUtils.isEmpty(token)){
            ResponseEntity.status(HttpStatus.UNAUTHORIZED).build();
        }
        CookieUtils.setCookie(request,response,jwtProperties.getCookieName(),token,jwtProperties.getCookieMaxAge() * 60);
        return ResponseEntity.ok(null);
    }

    @GetMapping("/verify")
    public ResponseEntity<UserInfo> verify(@CookieValue("LY_TOKEN")String token,
                                           HttpServletRequest request,
                                           HttpServletResponse response){
        UserInfo user = this.authService.verify(token);
        if(user == null){
            return ResponseEntity.status(HttpStatus.UNAUTHORIZED).build();
        }
        try {
            //重新生成token
            token = JwtUtils.generateToken(user, jwtProperties.getPrivateKey(), jwtProperties.getExpire());
            //重新设置cookie
            CookieUtils.setCookie(request,response,jwtProperties.getCookieName(),token, jwtProperties.getExpire() * 60);

            return ResponseEntity.ok(user);
        } catch (Exception e) {
            e.printStackTrace();
        }
        return ResponseEntity.status(HttpStatus.UNAUTHORIZED).build();
    }
}

AuthService类:

package com.leyou.auth.service;

import com.leyou.auth.client.UserClient;
import com.leyou.common.pojo.CookieUtils;
import com.leyou.common.pojo.UserInfo;
import com.leyou.common.utils.JwtUtils;
import com.leyou.config.JwtProperties;
import com.leyou.user.pojo.User;
import com.netflix.discovery.converters.Auto;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.context.properties.EnableConfigurationProperties;
import org.springframework.stereotype.Service;
import org.springframework.util.StringUtils;

@Service
@EnableConfigurationProperties(JwtProperties.class)
public class AuthService {
    @Autowired
    private JwtProperties jwtProperties;

    @Autowired
    private UserClient userClient;

    /**
     * 根据用户名和密码生成token
     * @param username
     * @param password
     * @return
     */
    public String accredit(String username, String password) {
        // 利用feign组件调用远程接口查询用户是否存在
        User user = this.userClient.query(username, password);

        if(user == null){
            return null;
        }

        //如果用户存在,则生成token
        try {
            return JwtUtils.generateToken(new UserInfo(user.getId(), user.getUsername()), jwtProperties.getPrivateKey(), jwtProperties.getExpire());
        } catch (Exception e) {
            e.printStackTrace();
        }
        return null;
    }

    /**
     * 根据token解析出相应的用户
     * @param token
     * @return
     */
    public UserInfo verify(String token) {
        if(StringUtils.isEmpty(token)){
            return null;
        }
        try {
            UserInfo userInfo = JwtUtils.getInfoFromToken(token, jwtProperties.getPublicKey());
            return userInfo;
        } catch (Exception e) {
            e.printStackTrace();
        }
        return null;
    }
}

解决cookie写入问题
我在之前测试时,清晰的看到了响应头中,有Set-Cookie属性,为什么在这里却什么都没有?

之前复习cors跨域时,讲到过跨域请求cookie生效的条件:

  • 服务的响应头中需要携带Access-Control-Allow-Credentials并且为true。
  • 响应头中的Access-Control-Allow-Origin一定不能为*,必须是指定的域名
  • 浏览器发起ajax需要指定withCredentials 为true

看看我们的服务端cors配置:
在这里插入图片描述
没有任何问题。

再看客户端浏览器的ajax配置,我们在js/common.js中对axios进行了统一配置:
在这里插入图片描述
一切OK。那说明,问题一定出在响应的set-cookie头中。
在这里插入图片描述
我们发现cookie的 domain属性似乎不太对。
说明cookie也是有域 的限制,一个网页,只能操作当前域名下的cookie,但是现在我们看到的地址是0.0.1,而页面是www.leyou.com,域名不匹配,cookie设置肯定失败了!

我们去Debug跟踪CookieUtils,看看到底是怎么回事:我们发现内部有一个方法,用来获取Domain:
在这里插入图片描述
它获取domain是通过服务器的host来计算的,然而我们的地址竟然是:127.0.0.1:8087,因此后续的运算,最终得到的domain就变成了:

在这里插入图片描述

问题找到了:我们请求时的serverName明明是:www.api.leyou.com,现在却被变成了:127.0.0.1,因此计算domain是错误的,从而导致cookie设置失败!
解决host地址的变化
那么问题来了:为什么我们这里的请求serverName变成了:127.0.0.1:8087呢?

这里的server name其实就是请求的时的主机名:Host,之所以改变,有两个原因:

  • 我们使用了nginx反向代理,当监听到api.leyou.com的时候,会自动将请求转发至127.0.0.1:10010,即Zuul。
  • 而后请求到达我们的网关Zuul,Zuul就会根据路径匹配,我们的请求是/api/auth,根据规则被转发到了 127.0.0.1:8087 ,即我们的授权中心。

我们首先去更改nginx配置,让它不要修改我们的host:proxy_set_header Host $host
在这里插入图片描述
这样就解决了nginx这里的问题。但是Zuul还会有一次转发,所以要去修改网关的配置(leyou-gateway工程):
在这里插入图片描述
再次测试,发现依然没有cookie!!
Zuul的敏感头过滤
Zuul内部有默认的过滤器,会对请求和响应头信息进行重组,过滤掉敏感的头信息:
在这里插入图片描述
会发现,这里会通过一个属性为SensitiveHeaders的属性,来获取敏感头列表,然后添加到IgnoredHeaders中,这些头信息就会被忽略。
而这个SensitiveHeaders的默认值就包含了set-cookie:
在这里插入图片描述
解决方案有两种:

全局设置:

  • zuul.sensitive-headers=

指定路由设置:

  • zuul.routes..sensitive-headers=
  • zuul.routes..custom-sensitive-headers=true

思路都是把敏感头设置为null
在这里插入图片描述

17.3 首页判断登录状态

虽然cookie已经成功写入,但是我们首页的顶部,登录状态依然没能判断出用户信息
所以需要向后台发起请求,根据cookie获取当前用户的信息。
在这里插入图片描述

后台实现校验用户接口
我们在leyou-auth-service中定义用户的校验接口,通过cookie获取token,然后校验通过返回用户信息。

  • 请求方式:GET
  • 请求路径:/verify
  • 请求参数:无,不过我们需要从cookie中获取token信息
  • 返回结果:UserInfo,校验成功返回用户信息;校验失败,则返回401

代码如下:

/**
  * 验证用户信息
  * @param token
  * @return
  */
@GetMapping("verify")
public ResponseEntity<UserInfo> verifyUser(@CookieValue("LY_TOKEN")String token){
    try {
        // 从token中解析token信息
        UserInfo userInfo = JwtUtils.getInfoFromToken(token, this.properties.getPublicKey());
        // 解析成功返回用户信息
        return ResponseEntity.ok(userInfo);
    } catch (Exception e) {
        e.printStackTrace();
    }
    // 出现异常则,响应500
    return ResponseEntity.status(HttpStatus.INTERNAL_SERVER_ERROR).build();
}

刷新token
每当用户在页面进行新的操作,都应该刷新token的过期时间,否则30分钟后用户的登录信息就无效了。而刷新其实就是重新生成一份token,然后写入cookie即可。

那么问题来了:我们怎么知道用户有操作呢?

事实上,每当用户来查询其个人信息,就证明他正在浏览网页,此时刷新cookie是比较合适的时机。因此我们可以对刚刚的校验用户登录状态的接口进行改进,加入刷新token的逻辑。

@GetMapping("/verify")
    public ResponseEntity<UserInfo> verify(@CookieValue("LY_TOKEN")String token,
                                           HttpServletRequest request,
                                           HttpServletResponse response){
        UserInfo user = this.authService.verify(token);
        if(user == null){
            return ResponseEntity.status(HttpStatus.UNAUTHORIZED).build();
        }
        try {
            //重新生成token
            token = JwtUtils.generateToken(user, jwtProperties.getPrivateKey(), jwtProperties.getExpire());
            //重新设置cookie
            CookieUtils.setCookie(request,response,jwtProperties.getCookieName(),token, jwtProperties.getExpire() * 60);

            return ResponseEntity.ok(user);
        } catch (Exception e) {
            e.printStackTrace();
        }
        return ResponseEntity.status(HttpStatus.UNAUTHORIZED).build();
    }

17.4 网关的登录拦截器

接下来,我们在Zuul编写拦截器,对用户的token进行校验,如果发现未登录,则进行拦截。
1.引入依赖

<dependency>
    <groupId>com.leyou.common</groupId>
    <artifactId>leyou-common</artifactId>
    <version>1.0.0-SNAPSHOT</version>
</dependency>
<dependency>
    <groupId>com.leyou.auth</groupId>
    <artifactId>leyou-auth-common</artifactId>
    <version>1.0.0-SNAPSHOT</version>
</dependency>

2.修改配置文件

leyou:
  jwt:
    pubKeyPath:  C:\\tmp\\rsa\\rsa.pub # 公钥地址
    cookieName: LY_TOKEN # cookie的名称

3.编写属性类:

@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;
    }
}

4.编写过滤器逻辑
基本逻辑:

  • 获取cookie中的token
  • 通过JWT对token进行校验
  • 通过:则放行;不通过:则重定向到登录页
package com.leyou.gateway.filter;

import com.leyou.common.pojo.CookieUtils;
import com.leyou.common.utils.JwtUtils;
import com.leyou.gateway.config.FilterProperties;
import com.leyou.gateway.config.JwtProperties;
import com.netflix.zuul.ZuulFilter;
import com.netflix.zuul.context.RequestContext;
import com.netflix.zuul.exception.ZuulException;
import org.apache.commons.lang3.StringUtils;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.context.properties.EnableConfigurationProperties;
import org.springframework.http.HttpStatus;
import org.springframework.stereotype.Controller;

import javax.servlet.http.HttpServletRequest;
import java.util.List;

@Controller
@EnableConfigurationProperties({JwtProperties.class, FilterProperties.class})
public class LoginFilter extends ZuulFilter {
    @Autowired
    private JwtProperties jwtProperties;

    @Autowired
    private FilterProperties filterProperties;

    @Override
    public String filterType() {
        return "pre";
    }

    @Override
    public int filterOrder() {
        return 10;
    }

    @Override
    public boolean shouldFilter() {
        // 获取上下文
        RequestContext context = RequestContext.getCurrentContext();

        // 拿到request对象
        HttpServletRequest request = context.getRequest();

        String url = request.getRequestURL().toString();
        List<String> allowPaths = filterProperties.getAllowPaths();

        //白名单直接放行
        for (String allowPath : allowPaths) {
            if(StringUtils.contains(url, allowPath))
                return false;
        }

        return true;
    }

    @Override
    public Object run() throws ZuulException {
        // 获取上下文
        RequestContext context = RequestContext.getCurrentContext();

        // 拿到request对象
        HttpServletRequest request = context.getRequest();

        String token = CookieUtils.getCookieValue(request,jwtProperties.getCookieName());

        try {
            JwtUtils.getInfoFromToken(token,jwtProperties.getPublicKey());
        } catch (Exception e) {
            // 若出现异常则说明解析失败
            context.setSendZuulResponse(false);
            context.setResponseStatusCode(HttpStatus.UNAUTHORIZED.value());
        }
        return null;
    }
}

白名单
要注意,并不是所有的路径我们都需要拦截,例如:

  • 登录校验接口:/auth/**
  • 注册接口:/user/register
  • 数据校验接口:/user/check/**
  • 发送验证码接口:/user/code
  • 搜索接口:/search/**

另外,跟后台管理相关的接口,因为我们没有做登录和权限,因此暂时都放行,但是生产环境中要做登录校验:

  • 后台商品服务:/item/**

所以,我们需要在拦截时,配置一个白名单,如果在名单内,则不进行拦截。

在application.yml中添加规则:

leyou:
  jwt:
    pubKeyPath: C:\\tmp\\rsa\\rsa.pub # 公钥地址
    cookieName: LY_TOKEN
  filter:
    allowPaths:
      - /api/auth
      - /api/search
      - /api/user/register
      - /api/user/check
      - /api/user/code
      - /api/item
      - /api/goods/item

然后编写配置类读取这些属性:

@ConfigurationProperties(prefix = "leyou.filter")
public class FilterProperties {

    private List<String> allowPaths;

    public List<String> getAllowPaths() {
        return allowPaths;
    }

    public void setAllowPaths(List<String> allowPaths) {
        this.allowPaths = allowPaths;
    }
}

最后在过滤器中的shouldFilter方法中添加判断逻辑,代码上面已经粘贴过了。

18.购物车

18.1 搭建购物车微服务

购物车微服务命名为:leyou-cart
1.导入依赖

<?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>leyou</artifactId>
        <groupId>com.leyou.parent</groupId>
        <version>1.0.0-SNAPSHOT</version>
    </parent>
    <modelVersion>4.0.0</modelVersion>

    <groupId>com.leyou.cart</groupId>
    <artifactId>leyou-cart</artifactId>

    <properties>
        <maven.compiler.source>11</maven.compiler.source>
        <maven.compiler.target>11</maven.compiler.target>
    </properties>

    <dependencies>
        <dependency>
            <groupId>org.springframework.cloud</groupId>
            <artifactId>spring-cloud-starter-netflix-eureka-client</artifactId>
        </dependency>
        <dependency>
            <groupId>org.springframework.boot</groupId>
            <artifactId>spring-boot-starter-test</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>
        <dependency>
            <groupId>org.springframework.boot</groupId>
            <artifactId>spring-boot-starter-web</artifactId>
        </dependency>
        <dependency>
            <groupId>com.leyou.item</groupId>
            <artifactId>leyou-item-interface</artifactId>
            <version>1.0.0-SNAPSHOT</version>
        </dependency>
        <dependency>
            <groupId>com.leyou.auth</groupId>
            <artifactId>leyou-auth-common</artifactId>
            <version>1.0.0-SNAPSHOT</version>
        </dependency>
        <dependency>
            <groupId>com.leyou.common</groupId>
            <artifactId>leyou-common</artifactId>
            <version>1.0.0-SNAPSHOT</version>
        </dependency>
        <dependency>
            <groupId>org.apache.commons</groupId>
            <artifactId>commons-lang3</artifactId>
        </dependency>
        <!--        要求使用jdk8,我这里用的是jdk11,需要导入这些Jar包,太坑了-->
        <dependency>
            <groupId>javax.xml.bind</groupId>
            <artifactId>jaxb-api</artifactId>
            <version>2.3.0</version>
        </dependency>
        <dependency>
            <groupId>com.sun.xml.bind</groupId>
            <artifactId>jaxb-impl</artifactId>
            <version>2.3.0</version>
        </dependency>
        <dependency>
            <groupId>com.sun.xml.bind</groupId>
            <artifactId>jaxb-core</artifactId>
            <version>2.3.0</version>
        </dependency>
        <dependency>
            <groupId>javax.activation</groupId>
            <artifactId>activation</artifactId>
            <version>1.1.1</version>
        </dependency>
    </dependencies>

</project>

2.编写配置文件

server:
  port: 8088
spring:
  application:
    name: cart-service
  redis:
    host: 192.168.124.121
eureka:
  client:
    service-url:
      defaultZone: http://127.0.0.1:10086/eureka
    registry-fetch-interval-seconds: 10
  instance:
    lease-expiration-duration-in-seconds: 15
    lease-renewal-interval-in-seconds: 5
leyou:
  jwt:
    pubKeyPath: C:\\tmp\\rsa\\rsa.pub # 公钥地址
    cookieName: LY_TOKEN

3.编写引导类

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 LeyouCartApplication {
    public static void main(String[] args) {
        SpringApplication.run(LeyouCartApplication.class,args);
    }
}

18.2 购物车功能分析

需求描述:

  • 用户可以在登录状态下将商品添加到购物车
    • 放入数据库
    • mongodb
    • 放入redis(采用)
  • 用户可以在未登录状态下将商品添加到购物车
    • 放入localstorage
    • cookie
    • webSQL
  • 用户可以使用购物车一起结算下单
  • 用户可以查询自己的购物车
  • 用户可以在购物车中修改购买商品的数量。
  • 用户可以在购物车中删除商品。
  • 在购物车中展示商品优惠信息
  • 提示购物车商品价格变化

在这里插入图片描述

这幅图主要描述了两个功能:新增商品到购物车、查询购物车。

新增商品:

  • 判断是否登录
    • 是:则添加商品到后台Redis中
    • 否:则添加商品到本地的Localstorage

无论哪种新增,完成后都需要查询购物车列表:

  • 判断是否登录
    • 否:直接查询localstorage中数据并展示
    • 是:已登录,则需要先看本地是否有数据,
      • 有:需要提交到后台添加到redis,合并数据,而后查询
      • 否:直接去后台查询redis,而后返回

18.3 未登录的购物车

web本地存储
知道了数据结构,下一个问题,就是如何保存购物车数据。前面我们分析过,可以使用Localstorage来实现。Localstorage是web本地存储的一种,那么,什么是web本地存储呢?
在这里插入图片描述
web本地存储主要有两种方式:

  • LocalStorage:localStorage 方法存储的数据没有时间限制。第二天、第二周或下一年之后,数据依然可用。
  • SessionStorage:sessionStorage 方法针对一个 session 进行数据存储。当用户关闭浏览器窗口后,数据会被删除。
    LocalStorage的用法
localStorage.setItem("key","value"); // 存储数据
localStorage.getItem("key"); // 获取数据
localStorage.removeItem("key"); // 删除数据

注意:localStorage和SessionStorage都只能保存字符串。
不过,在我们的common.js中,已经对localStorage进行了简单的封装:
在这里插入图片描述
添加购物车

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.ownSpec
            }
            carts.push(cart);
        }
        // 把carts写回localstorage
        ly.store.set("carts", carts);
        // 跳转
        window.location.href = "http://www.leyou.com/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")
    }
})

渲染到页面
在这里插入图片描述
删除商品
给删除按钮绑定事件:
在这里插入图片描述
点击事件中删除商品:

deleteCart(i){
    ly.verifyUser().then(res=>{
        // TODO,已登录购物车
    }).catch(()=>{
        // 未登录购物车
        this.carts.splice(i, 1);
        ly.store.set("carts", this.carts);
    })
}

18.4 已登录购物车

接下来,我们完成已登录购物车。在刚才的未登录购物车编写时,我们已经预留好了编写代码的位置,逻辑也基本一致。
添加登录校验
1.引入jwt相关依赖

<dependency>
    <groupId>com.leyou.auth</groupId>
    <artifactId>leyou-auth-common</artifactId>
    <version>1.0.0-SNAPSHOT</version>
</dependency>

2.配置公钥

leyou:
  jwt:
    pubKeyPath: C:/tmp/rsa/rsa.pub # 公钥地址
    cookieName: LY_TOKEN # cookie的名称

3.编写属性类

@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;
    }
}

4.编写拦截器
因为很多接口都需要进行登录,我们直接编写SpringMVC拦截器,进行统一登录校验。同时,我们还要把解析得到的用户信息保存起来,以便后续的接口可以使用。

package com.leyou.cart.interceptor;

import com.leyou.cart.config.JwtProperties;
import com.leyou.common.pojo.CookieUtils;
import com.leyou.common.pojo.UserInfo;
import com.leyou.common.utils.JwtUtils;
import org.apache.commons.lang3.StringUtils;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.context.properties.EnableConfigurationProperties;
import org.springframework.http.HttpStatus;
import org.springframework.stereotype.Controller;
import org.springframework.web.servlet.HandlerInterceptor;
import org.springframework.web.servlet.handler.HandlerInterceptorAdapter;

import javax.servlet.http.HttpServletRequest;
import javax.servlet.http.HttpServletResponse;

@Controller
@EnableConfigurationProperties(JwtProperties.class)
public class LoginIntercepter extends HandlerInterceptorAdapter {
    //这里使用线程锁来保存登录用户的信息,以防止线程安全问题
    private static final ThreadLocal<UserInfo> THREAD_LOCAL = new ThreadLocal<>();

    @Autowired
    private JwtProperties jwtProperties;
    /**
     * 只实现前置方法,用于从token中解析登录的用户信息
     * @param request
     * @param response
     * @param handler
     * @return
     * @throws Exception
     */
    @Override
    public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler)
            throws Exception {
        String token = CookieUtils.getCookieValue(request, jwtProperties.getCookieName());
        if(StringUtils.isBlank(token)){
            // 未登录,返回401
            response.setStatus(HttpStatus.UNAUTHORIZED.value());
            return false;
        }

        // 有token,查询用户信息
        try {
            // 解析成功,证明已经登录
            UserInfo user = JwtUtils.getInfoFromToken(token, jwtProperties.getPublicKey());
            // 放入线程域
            THREAD_LOCAL.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 {
        THREAD_LOCAL.remove();
    }

    public static UserInfo getUserInfo(){
        return THREAD_LOCAL.get();
    }

}

注意:

  • 这里我们使用了ThreadLocal来存储查询到的用户信息,线程内共享,因此请求到达Controller后可以共享User
  • 并且对外提供了静态的方法:getLoginUser()来获取User信息

(这里我不是很理解,需要对多线程和锁进一步加深理解
5.配置拦截器

package com.leyou.cart.interceptor;

import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.context.annotation.Configuration;
import org.springframework.stereotype.Controller;
import org.springframework.web.servlet.config.annotation.InterceptorRegistry;
import org.springframework.web.servlet.config.annotation.WebMvcConfigurer;

@Configuration
public class LeyouWebMVCIntercepter implements WebMvcConfigurer {
    @Autowired
    private LoginIntercepter loginIntercepter;

    @Override
    public void addInterceptors(InterceptorRegistry registry) {
        registry.addInterceptor(loginIntercepter).addPathPatterns("/**");
    }
}

后台购物车设计
当用户登录时,我们需要把购物车数据保存到后台,可以选择保存在数据库。但是购物车是一个读写频率很高的数据。因此我们这里选择读写效率比较高的Redis作为购物车存储。

Redis有5种不同数据结构,这里选择哪一种比较合适呢?Map<String, List<String>>

  • 首先不同用户应该有独立的购物车,因此购物车应该以用户的作为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;

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;
    }
}

页面发起请求
已登录情况下,向后台添加购物车:
在这里插入图片描述
这里发起的是Json请求。那么我们后台也要以json接收。

Controller类:
先分析一下:

  • 请求方式:新增,肯定是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();
    }
}

CartService类:
这里我们不访问数据库,而是直接操作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));
   }
}

查询购物车
在这里插入图片描述
CartController类:

/**
 * 查询购物车列表
 *
 * @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);
}

CartService类:

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());
}

修改商品数量

incr(cart){
                _this = this;
                cart.num += 1;
                ly.verify().then(
                    () => {
                        // 用户已经登录则更新redis
                        ly.http.put("/cart",{
                            skuId: cart.skuId,
                            num: cart.num
                        }).then();
                    }
                ).catch(res => {
                    // 未登录
                    ly.store.set("LY_CARTS",this.carts);
                });
            },
            decr(cart){
                if(cart.num > 1){
                    cart.num -= 1;
                }
                ly.verify().then(
                    () => {
                        // 用户已经登录则更新redis
                        ly.http.put("/cart",{
                            skuId: cart.skuId,
                            num: cart.num
                        }).then();
                    }
                ).catch(res => {
                    // 未登录
                    ly.store.set("LY_CARTS",this.carts);
                });
            },

删除购物车商品

del(cart){
                //这里只是改变了 this.carts, redis和localStorage并没有发生改变
                let index = this.carts.indexOf(cart);
                this.carts.splice(index,1);
                //this.selected.splice(index,1);
                ly.verify().then(() => {
                    // 用户已经登录则更新redis
                    ly.http.delete("/cart",{
                        skuId: cart.skuId,
                    }).then();
                }).catch(res => {
                    //如果没有登录则更新localStorage
                    ly.store.set("LY_CARTS",this.carts);
                });
            },
            deleteSelected(){
                temp = this.selected;
                this.selected.forEach(cart => {
                    let index = this.carts.indexOf(cart);
                    this.carts.splice(index,1);
                });
                //这里只是改变了 this.carts, redis和localStorage并没有发生改变
                ly.verify().then(() => {
                    //如果已经登录则更新redis
                    //删除所有的被选中的cart
                    temp.forEach(cart => {
                        ly.http.delete("/cart",{
                            skuId: cart.skuId,
                        }).then();
                    })
                }).catch(res => {
                    //如果没有登录则更新localStorage
                    ly.store.set("LY_CARTS",this.carts);
                });
            }

最终的CartController:

package com.leyou.cart.controller;

import com.leyou.cart.client.GoodsClient;
import com.leyou.cart.pojo.Cart;
import com.leyou.cart.service.CartService;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.data.redis.core.StringRedisTemplate;
import org.springframework.http.HttpStatus;
import org.springframework.http.ResponseEntity;
import org.springframework.stereotype.Controller;
import org.springframework.util.CollectionUtils;
import org.springframework.web.bind.annotation.*;

import java.util.List;

@Controller
public class CartController {
    @Autowired
    private CartService cartService;

    /**
     * 根据前端传递过来的cart添加购物车
     * @param cart
     * @return
     */
    @PostMapping
    public ResponseEntity<Void> addCart(@RequestBody Cart cart){
        this.cartService.addCart(cart);
        return ResponseEntity.status(HttpStatus.CREATED).build();
    }

    /**
     * 从redis查询登录用户的所有购物车记录
     * @return
     */
    @GetMapping("/all")
    public ResponseEntity<List<Cart>> queryCarts(){
        List<Cart> carts = this.cartService.queryCarts();
        if(CollectionUtils.isEmpty(carts)){
            return ResponseEntity.notFound().build();
        }
        return ResponseEntity.ok(carts);
    }

    /**
     * 在redis中更新用户的购物车记录
     * @param cart
     * @return
     */
    @PutMapping
    public ResponseEntity<Void> updateNum(@RequestBody Cart cart){
        this.cartService.updateNum(cart);
        return ResponseEntity.status(HttpStatus.CREATED).build();
    }

    /**
     * 在redis中删除用户的购物车记录
     * @param cart
     * @return
     */
    @DeleteMapping
    public ResponseEntity<Void> delete(@RequestBody Cart cart){
        this.cartService.delete(cart);
        return ResponseEntity.status(HttpStatus.CREATED).build();
    }
}

最终的CartService:

package com.leyou.cart.service;

import com.leyou.cart.client.GoodsClient;
import com.leyou.cart.interceptor.LoginIntercepter;
import com.leyou.cart.pojo.Cart;
import com.leyou.common.pojo.JsonUtils;
import com.leyou.common.pojo.UserInfo;
import com.leyou.item.pojo.Sku;
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;

import java.util.List;
import java.util.stream.Collectors;

@Service
public class CartService {
    private static String KEY_PREFIX = "user:cart:";

    @Autowired
    private GoodsClient goodsClient;

    @Autowired
    private StringRedisTemplate redisTemplate;

    /**
     * 将前端发送过来的cart加入到redis中
     * redis结构:<userId,Map<skuId,cart>>
     * cart里应该有如下信息:userId,skuId,image,title,ownSpec,num,price
     * @param cart
     */
    public void addCart(Cart cart) {
        //拿到登录的用户信息
        UserInfo userInfo = LoginIntercepter.getUserInfo();
        cart.setUserId(userInfo.getId());

        //查询redis
        BoundHashOperations<String, Object, Object> hashOperations = redisTemplate.boundHashOps(KEY_PREFIX + cart.getSkuId());
        String key = cart.getSkuId().toString();
        Integer num = cart.getNum();
        if(hashOperations.hasKey(key)){
            // 如果之前已经有这件商品则更新数量
            cart = JsonUtils.parse(hashOperations.get(key).toString(),Cart.class);
            cart.setNum(num + cart.getNum());
        }
        else{
            // 如果之前没有这件商品则新增即可
            //查询sku,并补充cart的信息
            Sku sku = this.goodsClient.querySkuByskuId(cart.getSkuId());
            cart.setTitle(sku.getTitle());
            cart.setOwnSpec(sku.getOwnSpec());
            cart.setPrice(sku.getPrice());
            cart.setImage(StringUtils.isEmpty(sku.getImages())? "":StringUtils.split(sku.getImages(),",")[0]);
        }
        //存入redis之中
        hashOperations.put(key,JsonUtils.serialize(cart));
    }

    /**
     * 从redis查询登录用户的所有购物车记录
     * @return
     */
    public List<Cart> queryCarts() {
        //获取用户信息
        UserInfo userInfo = LoginIntercepter.getUserInfo();

        //获取redis中的购物车记录
        String key = KEY_PREFIX + userInfo.getId();
        BoundHashOperations<String, Object, Object> hashOperations = this.redisTemplate.boundHashOps(key);

        List<Object> values = hashOperations.values();
        //返回List<Cart>
        return values.stream().map(cartJson -> {
            return JsonUtils.parse(cartJson.toString(),Cart.class);
        }).collect(Collectors.toList());
    }

    /**
     * 在redis中更新用户的购物车记录
     * @param cart
     * @return
     */
    public void updateNum(Cart cart) {
        //获取用户信息
        UserInfo userInfo = LoginIntercepter.getUserInfo();

        //获取redis中的购物车记录
        String key = KEY_PREFIX + userInfo.getId();
        BoundHashOperations<String, Object, Object> hashOperations = this.redisTemplate.boundHashOps(key);

        String kk = cart.getSkuId().toString();
        Integer num = cart.getNum();
        if(hashOperations.hasKey(kk)){
            cart = JsonUtils.parse(hashOperations.get(kk).toString(), Cart.class);
            cart.setNum(num);
            hashOperations.put(kk,JsonUtils.serialize(cart));
        }

    }

    /**
     * 在redis中删除用户的购物车记录
     * @param cart
     * @return
     */
    public void delete(Cart cart) {
        //获取用户信息
        UserInfo userInfo = LoginIntercepter.getUserInfo();

        //获取redis中的购物车记录
        BoundHashOperations<String, Object, Object> hashOperations = this.redisTemplate.boundHashOps(KEY_PREFIX + userInfo.getId());

        //删除记录
        String key = cart.getSkuId().toString();
        if(hashOperations.hasKey(key)){
            hashOperations.delete(key);
        }
    }
}

18.5 登录后购物车合并

当跳转到购物车页面,查询购物车列表前,需要判断用户登录状态,

  • 如果登录:
    • 首先检查用户的LocalStorage中是否有购物车信息,
    • 如果有,则提交到后台保存,
    • 清空LocalStorage
  • 如果未登录,直接查询即可

19.下单

最后这部分我并没有完成代码的编写,想编写也并不是很好编写,因为微信支付的API和短信服务的API一样,要申请很麻烦。这里只记录下单方面需要重点注意的几个地方。

19.1 Swagger-UI

随着互联网技术的发展,现在的网站架构基本都由原来的后端渲染,变成了:前端渲染、前后端分离的形态,而且前端技术和后端技术在各自的道路上越走越远。 前端和后端的唯一联系,变成了API接口;API文档变成了前后端开发人员联系的纽带,变得越来越重要。没有API文档工具之前,大家都是手写API文档的,在什么地方书写的都有,而且API文档没有统一规范和格式,每个公司都不一样。这无疑给开发带来了灾难。OpenAPI规范(OpenAPI Specification 简称OAS)是Linux基金会的一个项目,试图通过定义一种用来描述API格式或API定义的语言,来规范RESTful服务开发过程。目前V3.0版本的OpenAPI规范已经发布并开源在github上 。

OpenAPI是一个编写API文档的规范,然而如果手动去编写OpenAPI规范的文档,是非常麻烦的。而Swagger就是一个实现了OpenAPI规范的工具集。

Swagger包含的工具集:

  • Swagger编辑器: Swagger Editor允许您在浏览器中编辑YAML中的OpenAPI规范并实时预览文档。
  • Swagger UI: Swagger UI是HTML,Javascript和CSS资产的集合,可以从符合OAS标准的API动态生成漂亮的文档。
  • Swagger Codegen:允许根据OpenAPI规范自动生成API客户端库(SDK生成),服务器存根和文档。
  • Swagger Parser:用于解析来自Java的OpenAPI定义的独立库
  • Swagger Core:与Java相关的库,用于创建,使用和使用OpenAPI定义
  • Swagger Inspector(免费): API测试工具,可让您验证您的API并从现有API生成OpenAPI定义
  • SwaggerHub(免费和商业): API设计和文档,为使用OpenAPI的团队构建。

SpringBoot已经集成了Swagger,使用简单注解即可生成swagger的API文档。
1.引入依赖

<dependency>
    <groupId>io.springfox</groupId>
    <artifactId>springfox-swagger2</artifactId>
    <version>2.8.0</version>
</dependency>
<dependency>
    <groupId>io.springfox</groupId>
    <artifactId>springfox-swagger-ui</artifactId>
    <version>2.8.0</version>
</dependency>

2.编写配置

<dependency>
    <groupId>io.springfox</groupId>
    <artifactId>springfox-swagger2</artifactId>
    <version>2.8.0</version>
</dependency>
<dependency>
    <groupId>io.springfox</groupId>
    <artifactId>springfox-swagger-ui</artifactId>
    <version>2.8.0</version>
</dependency>

3.接口声明

@RestController
@RequestMapping("order")
@Api("订单服务接口")
public class OrderController {

    @Autowired
    private OrderService orderService;

    @Autowired
    private PayHelper payHelper;

    /**
     * 创建订单
     *
     * @param order 订单对象
     * @return 订单编号
     */
    @PostMapping
    @ApiOperation(value = "创建订单接口,返回订单编号", notes = "创建订单")
    @ApiImplicitParam(name = "order", required = true, value = "订单的json对象,包含订单条目和物流信息")
    public ResponseEntity<Long> createOrder(@RequestBody @Valid Order order) {
        Long id = this.orderService.createOrder(order);
        return new ResponseEntity<>(id, HttpStatus.CREATED);
    }

    /**
     * 分页查询当前用户订单
     *
     * @param status 订单状态
     * @return 分页订单数据
     */
    @GetMapping("list")
    @ApiOperation(value = "分页查询当前用户订单,并且可以根据订单状态过滤", 
                  notes = "分页查询当前用户订单")
    @ApiImplicitParams({
        @ApiImplicitParam(name = "page", value = "当前页", 
                          defaultValue = "1", type = "Integer"),
        @ApiImplicitParam(name = "rows", value = "每页大小", 
                          defaultValue = "5", type = "Integer"),
        @ApiImplicitParam(
            name = "status", 
            value = "订单状态:1未付款,2已付款未发货,3已发货未确认,4已确认未评价,5交易关闭,6交易成功,已评价", type = "Integer"),
    })
    public ResponseEntity<PageResult<Order>> queryUserOrderList(
        @RequestParam(value = "page", defaultValue = "1") Integer page,
        @RequestParam(value = "rows", defaultValue = "5") Integer rows,
        @RequestParam(value = "status", required = false) Integer status) {
        PageResult<Order> result = this.orderService.queryUserOrderList(page, rows, status);
        if (result == null) {
            return new ResponseEntity<>(HttpStatus.NOT_FOUND);
        }
        return ResponseEntity.ok(result);
    }
}

常用注解说明:

/**
 @Api:修饰整个类,描述Controller的作用
 @ApiOperation:描述一个类的一个方法,或者说一个接口
 @ApiParam:单个参数描述
 @ApiModel:用对象来接收参数
 @ApiProperty:用对象接收参数时,描述对象的一个字段
 @ApiResponse:HTTP响应其中1个描述
 @ApiResponses:HTTP响应整体描述
 @ApiIgnore:使用该注解忽略这个API
 @ApiError :发生错误返回的信息
 @ApiImplicitParam:一个请求参数
 @ApiImplicitParams:多个请求参数
 */

4.启动测试
启动服务,然后访问:http://localhost:8089/swagger-ui.html

19.2 生成订单id的方式

订单数据非常庞大,将来一定会做分库分表。那么这种情况下, 要保证id的唯一,就不能靠数据库自增,而是自己来实现算法,生成唯一id。而工具类所采用的生成id算法,是由Twitter公司开源的snowflake(雪花)算法。
简单原理
雪花算法会生成一个64位的二进制数据,为一个Long型。(转换成字符串后长度最多19) ,其基本结构:
在这里插入图片描述
第一位:为未使用

第二部分:41位为毫秒级时间(41位的长度可以使用69年)

第三部分:5位datacenterId和5位workerId(10位的长度最多支持部署1024个节点)

第四部分:最后12位是毫秒内的计数(12位的计数顺序号支持每个节点每毫秒产生4096个ID序号)

snowflake生成的ID整体上按照时间自增排序,并且整个分布式系统内不会产生ID碰撞(由datacenter和workerId作区分),并且效率较高。经测试snowflake每秒能够产生26万个ID。

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值