乐忧商城

  • 16.用户注册
    • 16.1 创建用户中心
    • 16.2 后台功能准备
    • 16.3 数据验证功能
    • 16.4 阿里大于短信服务
    • 16.5 发送短信功能
    • 16.6 注册功能
    • 16.7 根据用户名和密码查询用户
    • 16.8 总结
  • 17.授权中心
    • 17.1 无状态登录原理
    • 17.2 授权中心
    • 17.3 首页判断登录状态
    • 17.4 网关的登录拦截器
  • 18.购物车
    • 18.1 搭建购物车微服务
    • 18.2 购物车功能分析
    • 18.3 未登录的购物车
    • 18.4 已登录购物车
    • 18.5 登录后购物车合并
  • 19.下单
    • 19.1 Swagger-UI
    • 19.2 生成订单id的方式

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;// 创建时间@JsonIgnoreprivate 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.手机 Integer 1

返回结果:

返回布尔类型结果:

  • 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-servicerabbitmq:host: 192.168.124.121virtual-host: /leyouusername: leyoupassword: leyou
leyou:sms:accessKeyId: LTAItNxtDfk7MJMq # 你自己的accessKeyId 这部分已经没法使用了accessKeySecret: HKUIDl21ZM7XoubErXjUFJq84dha8W # 你自己的AccessKeySecretsignName: 乐优商城 # 签名名称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 {@Autowiredprivate 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 此处可能会抛出异常,注意catchSendSmsResponse sendSmsResponse = acsClient.getAcsResponse(request);logger.info("发送短信状态:{}", sendSmsResponse.getCode());logger.info("发送短信消息:{}", sendSmsResponse.getMessage());return sendSmsResponse;}
}

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

@Component
@EnableConfigurationProperties(SmsProperties.class)
public class SmsListener {@Autowiredprivate SmsUtils smsUtils;@Autowiredprivate 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存入redisthis.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));// 强制设置不能指定的参数为nulluser.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;// 创建时间@JsonIgnoreprivate 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_TOKENcookieMaxAge: 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:在构造方法执行之后执行该方法* 用于生成公钥和私钥*/@PostConstructpublic 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 {@Autowiredprivate AuthService authService;@Autowiredprivate 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 {//重新生成tokentoken = JwtUtils.generateToken(user, jwtProperties.getPrivateKey(), jwtProperties.getExpire());//重新设置cookieCookieUtils.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 {@Autowiredprivate JwtProperties jwtProperties;@Autowiredprivate 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;}//如果用户存在,则生成tokentry {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();}// 出现异常则,响应500return 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 {//重新生成tokentoken = JwtUtils.generateToken(user, jwtProperties.getPrivateKey(), jwtProperties.getExpire());//重新设置cookieCookieUtils.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);@PostConstructpublic 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 {@Autowiredprivate JwtProperties jwtProperties;@Autowiredprivate FilterProperties filterProperties;@Overridepublic String filterType() {return "pre";}@Overridepublic int filterOrder() {return 10;}@Overridepublic 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;}@Overridepublic 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_TOKENfilter: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-serviceredis:host: 192.168.124.121
eureka:client:service-url:defaultZone: http://127.0.0.1:10086/eurekaregistry-fetch-interval-seconds: 10instance:lease-expiration-duration-in-seconds: 15lease-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写回localstoragely.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);@PostConstructpublic 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<>();@Autowiredprivate JwtProperties jwtProperties;/*** 只实现前置方法,用于从token中解析登录的用户信息* @param request* @param response* @param handler* @return* @throws Exception*/@Overridepublic boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler)throws Exception {String token = CookieUtils.getCookieValue(request, jwtProperties.getCookieName());if(StringUtils.isBlank(token)){// 未登录,返回401response.setStatus(HttpStatus.UNAUTHORIZED.value());return false;}// 有token,查询用户信息try {// 解析成功,证明已经登录UserInfo user = JwtUtils.getInfoFromToken(token, jwtProperties.getPublicKey());// 放入线程域THREAD_LOCAL.set(user);return true;} catch (Exception e){// 抛出异常,证明未登录,返回401response.setStatus(HttpStatus.UNAUTHORIZED.value());return false;}}@Overridepublic 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 {@Autowiredprivate LoginIntercepter loginIntercepter;@Overridepublic 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;// 用户idprivate Long skuId;// 商品idprivate 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 {@Autowiredprivate CartService cartService;/*** 添加购物车** @return*/@PostMappingpublic ResponseEntity<Void> addCart(@RequestBody Cart cart) {this.cartService.addCart(cart);return ResponseEntity.ok().build();}
}

CartService类:
这里我们不访问数据库,而是直接操作Redis。基本思路:

  • 先查询之前的购物车数据
  • 判断要添加的商品是否存在
    • 存在:则直接修改数量后写回Redis
    • 不存在:新建一条数据,然后写入Redis
@Service
public class CartService {@Autowiredprivate StringRedisTemplate redisTemplate;@Autowiredprivate 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的keyString 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());}// 将购物车数据写入redishashOps.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(() => {// 用户已经登录则更新redisly.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(() => {// 用户已经登录则更新redisly.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(() => {// 用户已经登录则更新redisly.http.delete("/cart",{skuId: cart.skuId,}).then();}).catch(res => {//如果没有登录则更新localStoragely.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//删除所有的被选中的carttemp.forEach(cart => {ly.http.delete("/cart",{skuId: cart.skuId,}).then();})}).catch(res => {//如果没有登录则更新localStoragely.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 {@Autowiredprivate CartService cartService;/*** 根据前端传递过来的cart添加购物车* @param cart* @return*/@PostMappingpublic 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*/@PutMappingpublic ResponseEntity<Void> updateNum(@RequestBody Cart cart){this.cartService.updateNum(cart);return ResponseEntity.status(HttpStatus.CREATED).build();}/*** 在redis中删除用户的购物车记录* @param cart* @return*/@DeleteMappingpublic 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:";@Autowiredprivate GoodsClient goodsClient;@Autowiredprivate 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());//查询redisBoundHashOperations<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 {@Autowiredprivate OrderService orderService;@Autowiredprivate 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。

乐忧商城项目总结-5相关推荐

  1. 乐忧商城项目总结-1

    乐忧商城 1.springboot 1.1 springboot基本介绍 1.2 springboot快速入门 1.3 默认配置的原理 小结 1.4 springboot整合常用模块 整合spring ...

  2. 乐忧商城项目总结-4

    乐忧商城 13.搜索过滤 13.1 生成分类和品牌过滤 13.3 生成规格参数过滤 13.4 过滤条件的筛选 13.5 页面展示选择的过滤项 13.6 取消过滤项 13.7 优化 14.thymele ...

  3. 乐忧商城项目总结-2

    乐忧商城 6.商品分类 6.1 搭建后台管理的前端页面 6.2 Vuetify框架 6.3 使用域名访问本地项目 6.4 实现商品分类查询 7.品牌查询 8.品牌新增及fastDFS 8.1 品牌新增 ...

  4. 乐忧商城项目总结-3

    乐忧商城 10.商品管理 10.1 商品新增 10.2 商品修改 10.3 搭建前台系统 11.elasticsearch 11.1 elasticsearch介绍及其安装 11.2 操作索引 11. ...

  5. java学习day58(乐友商城)乐友商城项目搭建、SE6语法使用

    复习springCloud总结: 今日内容: 了解电商行业 了解乐优商城项目结构 能独立搭建项目基本框架 能参考使用ES6的新语法 1.了解电商行业 学习电商项目,自然要先了解这个行业,所以我们首先来 ...

  6. 商城项目介绍以及ES6的新语法

    0.学习目标 了解电商行业 了解乐优商城项目结构 能独立搭建项目基本框架 能参考使用ES6的新语法 1.了解电商行业 学习电商项目,自然要先了解这个行业,所以我们首先来聊聊电商行业 1.1.项目分类 ...

  7. day04-乐优商城项目搭建

    0.学习目标 了解电商行业 了解乐优商城项目结构 能独立搭建项目基本框架 能参考使用ES6的新语法 1.了解电商行业 学习电商项目,自然要先了解这个行业,所以我们首先来聊聊电商行业 1.1.项目分类 ...

  8. day01-乐优商城项目搭建

    0.学习目标 了解电商行业 了解乐优商城项目结构 能独立搭建项目基本框架 能参考使用ES6的新语法 1.了解电商行业 学习电商项目,自然要先了解这个行业,所以我们首先来聊聊电商行业 1.1.项目分类 ...

  9. 【javaWeb微服务架构项目——乐优商城day15】——会调用订单系统接口,实现订单结算功能,实现微信支付功能

    0.学习目标 会调用订单系统接口 实现订单结算功能 实现微信支付功能 源码笔记及资料: 链接:https://pan.baidu.com/s/1_opfL63P1pzH3rzLnbFiNw 提取码:v ...

  10. 乐优商城之项目搭建(四)

    文章目录 (一)项目分类 (二)电商行业 (三)专业术语 (四)项目介绍 (五)技术选型 (六)开发环境 (七)搭建后台环境:父工程 (八)搭建后台环境:eureka (九)搭建后台环境:zuul ( ...

最新文章

  1. k均值聚类算法考试例题_一文读懂K-means聚类算法
  2. 清华大学:确保博士生每年最低资助标准不低于5.1万元
  3. flask项目中无法更改端口号
  4. dom复制cloneNode节点与插入节点appendChild()
  5. c语言课程描述英文版,c语言学生选课系统(国外英文资料).doc
  6. Java:银行账户类
  7. python实现文本编辑器_Python-tkinter实现简单的文本编辑器
  8. linux root权限不够_Linux基础篇之用户管理
  9. 20191004:包装类Integer,int,String类的相互转换
  10. 为什么说Java中要慎重使用继承
  11. 常见的电子商务模式理解
  12. angularJs完成分页
  13. 5y计算机应用2010综合测评答案,计算机二级习题答案
  14. ueditor修改默认图片保存路径,ueditor根据楼盘ID保存图片路径
  15. DOS编写脚本常用命令整理
  16. gp3688写频线制作_摩托罗拉GP3688对讲机写频软件(摩托罗拉对讲机写频工具) 官方版...
  17. mini-pcie接口(EC20和N720)
  18. linux网络工程师简历 项目经验,参加Linux面试简历中的项目经验该怎么写?
  19. 瘟疫模拟——技术预演与方案设计(Python技术预演)
  20. ps文字工具学习笔记

热门文章

  1. pool.map()爬取美文网标题内容
  2. EXCEL 基础函数大全
  3. GPS经纬度坐标转UTM坐标(c++)
  4. java编写智能合约_区块链实现JAVA语言智能合约的设计方案简介
  5. php下载功能的实现,laravel实现下载文件
  6. 如何关闭电脑弹窗(2种方法)
  7. mysql多主集群_MYSQL-galera多主集群
  8. Matlab中进行高斯滤波-学习笔记
  9. 分享188个JS图片效果JS代码,总有一款适合你
  10. C语言图形库函数easyx下载