认证服务
1. 环境搭建
创建gulimall-auth-server
模块,导依赖,引入login.html
和reg.html
,并把静态资源放到nginx的static目录下
2. 注册功能
(1) 验证码倒计时
//点击发送验证码按钮触发下面函数
$("#sendCode").click(function () {
//如果有disabled,说明最近已经点过,则什么都不做
if($(this).hasClass("disabled")){
}else {
//调用函数使得当前的文本进行倒计时功能
timeOutChangeStyle();
//发送验证码
var phone=$("#phoneNum").val();
$.get("/sms/sendCode?phone="+phone,function (data){
if (data.code!=0){
alert(data.msg);
}
})
}
})
let time = 60;
function timeOutChangeStyle() {
//开启倒计时后设置标志属性disable,使得该按钮不能再次被点击
$("#sendCode").attr("class", "disabled");
//当时间为0时,说明倒计时完成,则重置
if(time==0){
$("#sendCode").text("点击发送验证码");
time=60;
$("#sendCode").attr("class", "");
}else {
//每秒调用一次当前函数,使得time--
$("#sendCode").text(time+"s后再次发送");
time--;
setTimeout("timeOutChangeStyle()", 1000);
}
}
(2) 整合短信服务
在阿里云网页购买试用的短信服务
在gulimall-third-party
中编写发送短信组件,其中host
、path
、appcode
可以在配置文件中使用前缀spring.cloud.alicloud.sms
进行配置
@Data
@ConfigurationProperties(prefix = "spring.cloud.alicloud.sms")
@Controller
public class SmsComponent {
private String host;
private String path;
private String appcode;
public void sendCode(String phone,String code) {
// String host = "http://dingxin.market.alicloudapi.com";
// String path = "/dx/sendSms";
String method = "POST";
// String appcode = "你自己的AppCode";
Map<String, String> headers = new HashMap<String, String>();
//最后在header中的格式(中间是英文空格)为Authorization:APPCODE 83359fd73fe94948385f570e3c139105
headers.put("Authorization", "APPCODE " + appcode);
Map<String, String> querys = new HashMap<String, String>();
querys.put("mobile",phone);
querys.put("param", "code:"+code);
querys.put("tpl_id", "TP1711063");
Map<String, String> bodys = new HashMap<String, String>();
try {
/**
* 重要提示如下:
* HttpUtils请从
* https://github.com/aliyun/api-gateway-demo-sign-java/blob/master/src/main/java/com/aliyun/api/gateway/demo/util/HttpUtils.java
* 下载
*
* 相应的依赖请参照
* https://github.com/aliyun/api-gateway-demo-sign-java/blob/master/pom.xml
*/
HttpResponse response = HttpUtils.doPost(host, path, method, headers, querys, bodys);
System.out.println(response.toString());
//获取response的body
//System.out.println(EntityUtils.toString(response.getEntity()));
} catch (Exception e) {
e.printStackTrace();
}
}
}
编写controller,给别的服务提供远程调用发送验证码的接口
@Controller
@RequestMapping(value = "/sms")
public class SmsSendController {
@Resource
private SmsComponent smsComponent;
/**
* 提供给别的服务进行调用
* @param phone 电话号码
* @param code 验证码
* @return
*/
@ResponseBody
@GetMapping(value = "/sendCode")
public R sendCode(@RequestParam("phone") String phone, @RequestParam("code") String code) {
//发送验证码
smsComponent.sendCode(phone,code);
System.out.println(phone+code);
return R.ok();
}
}
(3) 接口防刷
由于发送验证码的接口暴露,为了防止恶意攻击,我们不能随意让接口被调用。
- 在redis中以
phone-code
将电话号码和验证码进行存储并将当前时间与code一起存储- 如果调用时以当前
phone
取出的v不为空且当前时间在存储时间的60s以内,说明60s内该号码已经调用过,返回错误信息 - 60s以后再次调用,需要删除之前存储的
phone-code
- code存在一个过期时间,我们设置为10min,10min内验证该验证码有效
- 如果调用时以当前
@GetMapping("/sms/sendCode")
@ResponseBody
public R sendCode(@RequestParam("phone")String phone) {
//接口防刷,在redis中缓存phone-code
ValueOperations<String, String> ops = redisTemplate.opsForValue();
String prePhone = AuthServerConstant.SMS_CODE_CACHE_PREFIX + phone;
String v = ops.get(prePhone);
if (!StringUtils.isEmpty(v)) {
long pre = Long.parseLong(v.split("_")[1]);
//如果存储的时间小于60s,说明60s内发送过验证码
if (System.currentTimeMillis() - pre < 60000) {
return R.error(BizCodeEnum.SMS_CODE_EXCEPTION.getCode(), BizCodeEnum.SMS_CODE_EXCEPTION.getMsg());
}
}
//如果存在的话,删除之前的验证码
redisTemplate.delete(prePhone);
//获取到6位数字的验证码
String code = String.valueOf((int)((Math.random() + 1) * 100000));
//在redis中进行存储并设置过期时间
ops.set(prePhone,code+"_"+System.currentTimeMillis(),10, TimeUnit.MINUTES);
thirdPartFeignService.sendCode(phone, code);
return R.ok();
}
(4) 注册接口编写
在gulimall-auth-server
服务中编写注册的主体逻辑
- 若JSR303校验未通过,则通过
BindingResult
封装错误信息,并重定向至注册页面 - 若通过JSR303校验,则需要从
redis
中取值判断验证码是否正确,正确的话通过会员服务注册 - 会员服务调用成功则重定向至登录页,否则封装远程服务返回的错误信息返回至注册页面
注: RedirectAttributes
可以通过session保存信息并在重定向的时候携带过去
@PostMapping("/register")
public String register(@Valid UserRegisterVo registerVo, BindingResult result, RedirectAttributes attributes) {
//1.判断校验是否通过
Map<String, String> errors = new HashMap<>();
if (result.hasErrors()){
//1.1 如果校验不通过,则封装校验结果
result.getFieldErrors().forEach(item->{
errors.put(item.getField(), item.getDefaultMessage());
//1.2 将错误信息封装到session中
attributes.addFlashAttribute("errors", errors);
});
//1.2 重定向到注册页
return "redirect:http://auth.gulimall.com/reg.html";
}else {
//2.若JSR303校验通过
//判断验证码是否正确
String code = redisTemplate.opsForValue().get(AuthServerConstant.SMS_CODE_CACHE_PREFIX + registerVo.getPhone());
//2.1 如果对应手机的验证码不为空且与提交上的相等-》验证码正确
if (!StringUtils.isEmpty(code) && registerVo.getCode().equals(code.split("_")[0])) {
//2.1.1 使得验证后的验证码失效
redisTemplate.delete(AuthServerConstant.SMS_CODE_CACHE_PREFIX + registerVo.getPhone());
//2.1.2 远程调用会员服务注册
R r = memberFeignService.register(registerVo);
if (r.getCode() == 0) {
//调用成功,重定向登录页
return "redirect:http://auth.gulimall.com/login.html";
}else {
//调用失败,返回注册页并显示错误信息
String msg = (String) r.get("msg");
errors.put("msg", msg);
attributes.addFlashAttribute("errors", errors);
return "redirect:http://auth.gulimall.com/reg.html";
}
}else {
//2.2 验证码错误
errors.put("code", "验证码错误");
attributes.addFlashAttribute("errors", errors);
return "redirect:http://auth.gulimall.com/reg.html";
}
}
}
通过gulimall-member
会员服务注册逻辑
- 通过异常机制判断当前注册会员名和电话号码是否已经注册,如果已经注册,则抛出对应的自定义异常,并在返回时封装对应的错误信息
- 如果没有注册,则封装传递过来的会员信息,并设置默认的会员等级、创建时间
@RequestMapping("/register")
public R register(@RequestBody MemberRegisterVo registerVo) {
try {
memberService.register(registerVo);
//异常机制:通过捕获对应的自定义异常判断出现何种错误并封装错误信息
} catch (UserExistException userException) {
return R.error(BizCodeEnum.USER_EXIST_EXCEPTION.getCode(), BizCodeEnum.USER_EXIST_EXCEPTION.getMsg());
} catch (PhoneNumExistException phoneException) {
return R.error(BizCodeEnum.PHONE_EXIST_EXCEPTION.getCode(), BizCodeEnum.PHONE_EXIST_EXCEPTION.getMsg());
}
return R.ok();
}
public void register(MemberRegisterVo registerVo) {
//1 检查电话号是否唯一
checkPhoneUnique(registerVo.getPhone());
//2 检查用户名是否唯一
checkUserNameUnique(registerVo.getUserName());
//3 该用户信息唯一,进行插入
MemberEntity entity = new MemberEntity();
//3.1 保存基本信息
entity.setUsername(registerVo.getUserName());
entity.setMobile(registerVo.getPhone());
entity.setCreateTime(new Date());
//3.2 使用加密保存密码
BCryptPasswordEncoder passwordEncoder = new BCryptPasswordEncoder();
String encodePassword = passwordEncoder.encode(registerVo.getPassword());
entity.setPassword(encodePassword);
//3.3 设置会员默认等级
//3.3.1 找到会员默认登记
MemberLevelEntity defaultLevel = memberLevelService.getOne(new QueryWrapper<MemberLevelEntity>().eq("default_status", 1));
//3.3.2 设置会员等级为默认
entity.setLevelId(defaultLevel.getId());
// 4 保存用户信息
this.save(entity);
}
private void checkUserNameUnique(String userName) {
Integer count = baseMapper.selectCount(new QueryWrapper<MemberEntity>().eq("username", userName));
if (count > 0) {
throw new UserExistException();
}
}
private void checkPhoneUnique(String phone) {
Integer count = baseMapper.selectCount(new QueryWrapper<MemberEntity>().eq("mobile", phone));
if (count > 0) {
throw new PhoneNumExistException();
}
}
3. 用户名密码登录
在gulimall-auth-server
模块中的主体逻辑
- 通过会员服务远程调用登录接口
- 如果调用成功,重定向至首页
- 如果调用失败,则封装错误信息并携带错误信息重定向至登录页
@RequestMapping("/login")
public String login(UserLoginVo vo,RedirectAttributes attributes){
R r = memberFeignService.login(vo);
if (r.getCode() == 0) {
return "redirect:http://gulimall.com/";
}else {
String msg = (String) r.get("msg");
Map<String, String> errors = new HashMap<>();
errors.put("msg", msg);
attributes.addFlashAttribute("errors", errors);
return "redirect:http://auth.gulimall.com/login.html";
}
}
在gulimall-member
模块中完成登录
- 当数据库中含有以当前登录名为用户名或电话号且密码匹配时,验证通过,返回查询到的实体
- 否则返回null,并在controller返回
用户名或密码错误
@RequestMapping("/login")
public R login(@RequestBody MemberLoginVo loginVo) {
MemberEntity entity=memberService.login(loginVo);
if (entity!=null){
return R.ok();
}else {
return R.error(BizCodeEnum.LOGINACCT_PASSWORD_EXCEPTION.getCode(), BizCodeEnum.LOGINACCT_PASSWORD_EXCEPTION.getMsg());
}
}
@Override
public MemberEntity login(MemberLoginVo loginVo) {
String loginAccount = loginVo.getLoginAccount();
//以用户名或电话号登录的进行查询
MemberEntity entity = this.getOne(new QueryWrapper<MemberEntity>().eq("username", loginAccount).or().eq("mobile", loginAccount));
if (entity!=null){
BCryptPasswordEncoder bCryptPasswordEncoder = new BCryptPasswordEncoder();
boolean matches = bCryptPasswordEncoder.matches(loginVo.getPassword(), entity.getPassword());
if (matches){
entity.setPassword("");
return entity;
}
}
return null;
}
4. 社交登录
(1) oauth2.0
(2) 在微博开放平台创建应用
(3) 在登录页引导用户至授权页
GET
https://api.weibo.com/oauth2/authorize?client_id=YOUR_CLIENT_ID&response_type=code&redirect_uri=YOUR_REGISTERED_REDIRECT_URI
client_id
: 创建网站应用时的app key
YOUR_REGISTERED_REDIRECT_URI
: 认证完成后的跳转链接(需要和平台高级设置一致)
如果用户同意授权,页面跳转至 YOUR_REGISTERED_REDIRECT_URI/?code=CODE
code是我们用来换取令牌的参数
(4) 换取token
POST
https://api.weibo.com/oauth2/access_token?client_id=YOUR_CLIENT_ID&client_secret=YOUR_CLIENT_SECRET&grant_type=authorization_code&redirect_uri=YOUR_REGISTERED_REDIRECT_URI&code=CODE
client_id
: 创建网站应用时的app key
client_secret
: 创建网站应用时的app secret
YOUR_REGISTERED_REDIRECT_URI
: 认证完成后的跳转链接(需要和平台高级设置一致)code
:换取令牌的认证码
返回数据如下
(5) 获取用户信息
https://open.weibo.com/wiki/2/users/show
结果返回json
(6) 代码编写
认证接口
- 通过
HttpUtils
发送请求获取token
,并将token
等信息交给member
服务进行社交登录 - 若获取
token
失败或远程调用服务失败,则封装错误信息重新转回登录页
@Controller
public class OauthController {
@Autowired
private MemberFeignService memberFeignService;
@RequestMapping("/oauth2.0/weibo/success")
public String authorize(String code, RedirectAttributes attributes) throws Exception {
//1. 使用code换取token,换取成功则继续2,否则重定向至登录页
Map<String, String> query = new HashMap<>();
query.put("client_id", "2144***074");
query.put("client_secret", "ff63a0d8d5*****29a19492817316ab");
query.put("grant_type", "authorization_code");
query.put("redirect_uri", "http://auth.gulimall.com/oauth2.0/weibo/success");
query.put("code", code);
//发送post请求换取token
HttpResponse response = HttpUtils.doPost("https://api.weibo.com", "/oauth2/access_token", "post", new HashMap<String, String>(), query, new HashMap<String, String>());
Map<String, String> errors = new HashMap<>();
if (response.getStatusLine().getStatusCode() == 200) {
//2. 调用member远程接口进行oauth登录,登录成功则转发至首页并携带返回用户信息,否则转发至登录页
String json = EntityUtils.toString(response.getEntity());
SocialUser socialUser = JSON.parseObject(json, new TypeReference<SocialUser>() {
});
R login = memberFeignService.login(socialUser);
//2.1 远程调用成功,返回首页并携带用户信息
if (login.getCode() == 0) {
String jsonString = JSON.toJSONString(login.get("memberEntity"));
MemberResponseVo memberResponseVo = JSON.parseObject(jsonString, new TypeReference<MemberResponseVo>() {
});
attributes.addFlashAttribute("user", memberResponseVo);
return "redirect:http://gulimall.com";
}else {
//2.2 否则返回登录页
errors.put("msg", "登录失败,请重试");
attributes.addFlashAttribute("errors", errors);
return "redirect:http://auth.gulimall.com/login.html";
}
}else {
errors.put("msg", "获得第三方授权失败,请重试");
attributes.addFlashAttribute("errors", errors);
return "redirect:http://auth.gulimall.com/login.html";
}
}
登录接口
- 登录包含两种流程,实际上包括了注册和登录
- 如果之前未使用该社交账号登录,则使用
token
调用开放api获取社交账号相关信息,注册并将结果返回 - 如果之前已经使用该社交账号登录,则更新
token
并将结果返回
@RequestMapping("/oauth2/login")
public R login(@RequestBody SocialUser socialUser) {
MemberEntity entity=memberService.login(socialUser);
if (entity!=null){
return R.ok().put("memberEntity",entity);
}else {
return R.error();
}
}
@Override
public MemberEntity login(SocialUser socialUser){
MemberEntity uid = this.getOne(new QueryWrapper<MemberEntity>().eq("uid", socialUser.getUid()));
//1 如果之前未登陆过,则查询其社交信息进行注册
if (uid == null) {
Map<String, String> query = new HashMap<>();
query.put("access_token",socialUser.getAccess_token());
query.put("uid", socialUser.getUid());
//调用微博api接口获取用户信息
String json = null;
try {
HttpResponse response = HttpUtils.doGet("https://api.weibo.com", "/2/users/show.json", "get", new HashMap<>(), query);
json = EntityUtils.toString(response.getEntity());
} catch (Exception e) {
e.printStackTrace();
}
JSONObject jsonObject = JSON.parseObject(json);
//获得昵称,性别,头像
String name = jsonObject.getString("name");
String gender = jsonObject.getString("gender");
String profile_image_url = jsonObject.getString("profile_image_url");
//封装用户信息并保存
uid = new MemberEntity();
MemberLevelEntity defaultLevel = memberLevelService.getOne(new QueryWrapper<MemberLevelEntity>().eq("default_status", 1));
uid.setLevelId(defaultLevel.getId());
uid.setNickname(name);
uid.setGender("m".equals(gender)?0:1);
uid.setHeader(profile_image_url);
uid.setAccessToken(socialUser.getAccess_token());
uid.setUid(socialUser.getUid());
uid.setExpiresIn(socialUser.getExpires_in());
this.save(uid);
}else {
//2 否则更新令牌等信息并返回
uid.setAccessToken(socialUser.getAccess_token());
uid.setUid(socialUser.getUid());
uid.setExpiresIn(socialUser.getExpires_in());
this.updateById(uid);
}
return uid;
}
5. SpringSession
(1) session 原理
jsessionid
相当于银行卡,存在服务器的session
相当于存储的现金,每次通过jsessionid
取出保存的数据
问题:但是正常情况下session
不可跨域,它有自己的作用范围
(2) 分布式下session共享问题
(3) 解决方案
1) session复制
2) 客户端存储
3) hash一致性
4) 统一存储
(4) SpringSession整合redis
通过SpringSession
修改session
的作用域
1) 环境搭建
导入依赖
<dependency>
<groupId>org.springframework.session</groupId>
<artifactId>spring-session-data-redis</artifactId>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-data-redis</artifactId>
</dependency>
修改配置
spring:
redis:
host: 192.168.56.102
session:
store-type: redis
添加注解
@EnableRedisHttpSession
public class GulimallAuthServerApplication {
2) 自定义配置
-
由于默认使用jdk进行序列化,通过导入
RedisSerializer
修改为json序列化 -
并且通过修改
CookieSerializer
扩大session
的作用域至**.gulimall.com
@Configuration
public class GulimallSessionConfig {
@Bean
public RedisSerializer<Object> springSessionDefaultRedisSerializer() {
return new GenericJackson2JsonRedisSerializer();
}
@Bean
public CookieSerializer cookieSerializer() {
DefaultCookieSerializer serializer = new DefaultCookieSerializer();
serializer.setCookieName("GULISESSIONID");
serializer.setDomainName("gulimall.com");
return serializer;
}
}
(5) SpringSession核心原理 - 装饰者模式
- 原生的获取
session
时是通过HttpServletRequest
获取的 - 这里对request进行包装,并且重写了包装request的
getSession()
方法
@Override
protected void doFilterInternal(HttpServletRequest request,
HttpServletResponse response, FilterChain filterChain)
throws ServletException, IOException {
request.setAttribute(SESSION_REPOSITORY_ATTR, this.sessionRepository);
//对原生的request、response进行包装
SessionRepositoryRequestWrapper wrappedRequest = new SessionRepositoryRequestWrapper(
request, response, this.servletContext);
SessionRepositoryResponseWrapper wrappedResponse = new SessionRepositoryResponseWrapper(
wrappedRequest, response);
try {
filterChain.doFilter(wrappedRequest, wrappedResponse);
}
finally {
wrappedRequest.commitSession();
}
}
购物车
1. 数据模型分析
(1) 数据存储
购物车是一个读多写多的场景,因此放入数据库并不合适,但购物车又是需要持久化,因此这里我们选用redis存储购物车数据。
(2) 数据结构
一个购物车是由各个购物项组成的,但是我们用List
进行存储并不合适,因为使用List
查找某个购物项时需要挨个遍历每个购物项,会造成大量时间损耗,为保证查找速度,我们使用hash
进行存储
(3) VO编写
购物项vo
public class CartItemVo {
private Long skuId;
//是否选中
private Boolean check = true;
//标题
private String title;
//图片
private String image;
//商品套餐属性
private List<String> skuAttrValues;
//价格
private BigDecimal price;
//数量
private Integer count;
//总价
private BigDecimal totalPrice;
/**
* 当前购物车项总价等于单价x数量
* @return
*/
public BigDecimal getTotalPrice() {
return price.multiply(new BigDecimal(count));
}
public void setTotalPrice(BigDecimal totalPrice) {
this.totalPrice = totalPrice;
}
购物车vo
public class CartVo {
/**
* 购物车子项信息
*/
List