认证服务
1. 环境搭建
创建gulimall-auth-server
模块,导依赖,引入login.html
和reg.html
,并把静态资源放到nginx的static目录下
2. 注册功能
(1) 验证码倒计时
javascript">//点击发送验证码按钮触发下面函数
$("#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
进行配置
java">@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 83359fd73fe94948385f570e3c139105headers.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,给别的服务提供远程调用发送验证码的接口
java">@Controller
@RequestMapping(value = "/sms")
public class SmsSendController {@Resourceprivate 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内验证该验证码有效
- 如果调用时以当前
java">@GetMapping("/sms/sendCode")
@ResponseBody
public R sendCode(@RequestParam("phone")String phone) {//接口防刷,在redis中缓存phone-codeValueOperations<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保存信息并在重定向的时候携带过去
java"> @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
会员服务注册逻辑
- 通过异常机制判断当前注册会员名和电话号码是否已经注册,如果已经注册,则抛出对应的自定义异常,并在返回时封装对应的错误信息
- 如果没有注册,则封装传递过来的会员信息,并设置默认的会员等级、创建时间
java"> @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();}
java">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
模块中的主体逻辑
- 通过会员服务远程调用登录接口
- 如果调用成功,重定向至首页
- 如果调用失败,则封装错误信息并携带错误信息重定向至登录页
java">@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返回
用户名或密码错误
java">@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());}
}@Overridepublic 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
失败或远程调用服务失败,则封装错误信息重新转回登录页
java">@Controller
public class OauthController {@Autowiredprivate 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请求换取tokenHttpResponse 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
并将结果返回
java">@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();}
}@Overridepublic 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) 统一存储
redis_542">(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.102session:store-type: redis
添加注解
java">@EnableRedisHttpSession
public class GulimallAuthServerApplication {
2) 自定义配置
-
由于默认使用jdk进行序列化,通过导入
RedisSerializer
修改为json序列化 -
并且通过修改
CookieSerializer
扩大session
的作用域至**.gulimall.com
java">@Configuration
public class GulimallSessionConfig {@Beanpublic RedisSerializer<Object> springSessionDefaultRedisSerializer() {return new GenericJackson2JsonRedisSerializer();}@Beanpublic CookieSerializer cookieSerializer() {DefaultCookieSerializer serializer = new DefaultCookieSerializer();serializer.setCookieName("GULISESSIONID");serializer.setDomainName("gulimall.com");return serializer;}
}
(5) SpringSession核心原理 - 装饰者模式
- 原生的获取
session
时是通过HttpServletRequest
获取的 - 这里对request进行包装,并且重写了包装request的
getSession()
方法
java">@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
java">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
java">public class CartVo {/*** 购物车子项信息*/List