目录
- 1.7 认证服务
- 1.7.1 环境搭建
- 1)、创建认证服务微服务
- 2)、引入依赖
- 3)、添加相应的域名
- 4)、动静分离
- 5)、nacos中注册
- 6)、配置网关
- 7)、测试访问登录页面
- 8)、实现各个页面之间跳转
- 1.7.2 验证码功能
- 1)、验证码功能
- 2)、整合验证码
- ①短信远程服务准备
- ②认证服务远程调用短信
- ③ 验证码防刷
- 1.7.3 一步一坑的注册页环境
- 1)、编写 vo封装注册页内容
- 2)、编写 controller接口
- 3)、编写注册页面
- 4)、为Model绑定校验错误信息
- 5)、编写前端页面获取错误信息
- 6)、测试--踩坑
- 1.7.4 异常机制
- 1)、校验验证码
- 2)、会员服务中编写Vo接受数据
- 3)、编写会员服务的用户注册接口
- 4)、异常类的编写
- 1.7.5 [MD5](https://so.csdn.net/so/search?q=MD5&spm=1001.2101.3001.7020)&盐值&BCrypt
- 1.7.6 注册完成
- 1)、在common的exception包下,编写异常枚举
- 2)、进行异常的捕获
- 3)、远程服务接口编写
- 4)、 远程服务调用
- 5)、注册页错误消息提示
- 6)、测试
- 1.7.7 账户密码登录完成
- 1)、编写vo
- 2)、数据绑定
- 3)、编写登录接口
- 4)、MemberLoginVo
- 5)、登录校验功能
- 6)、编写异常枚举
- 7)、远程服务接口编写
- 8)、页面错误消息提示
- 9)、测试
- 1.7.8 社交登录
- 1)、OAuth 2.0
- 2)、微博登陆准备工作
- 3)、微博登陆测试(课件)
- 4)、实际操作(gitee+weibo)
- ① Gitee测试
- ② 微博测试
- ③操作
- 1.7.9 springsession
- 1)、session共享问题
- 2)、SpringSession核心原理
- 1.7.10 页面效果完善
- 1)、完善社交登录的页面效果
- 2)完善账号密码登录
- 3)、设置默认的昵称
- 4)、完善登录逻辑
- 5)、商品详情页面登录完善
- 6)、搜索页面用户昵称显示
- 总结
- 1.7.11 单点登录
- 1)、介绍
- 2)、前置概念
- 3)、xxl单点登录demo
- 4)、单点登录代码实现
- ① 总流程梳理
- ② 环境准备
- ③单点登录流程1:
- ④单点登录流程2:
- ⑤单点登录流程3:
- 1.8 购物车服务
- 1.8.1 环境搭建
- 1)、配置域名:cart.gulimall.com
- 2)、创建微服务
- 3)、动静分离
- 4)、网关配置
- 5)、前端页面调试
- 1.8.2 数据模型分析
- 1)、购物车需求
- 2)、数据结构
- 1.8.3 vo编写
- 1)、购物项的Vo编写:
- 2)、购车Vo
- 1.8.4 ThreadLocal用户身份鉴别
- 1)、数据存储-redis
- 2)、准备cartService和其实现类
- 3)、引入springsession判断用户登录与否
- 4)、cookie中的user-key说明
- 5)、编写To与常量
- 6)、编写拦截器(登录前检查)
- 7)、CartController
- 8)、测试
- 1.8.5 页面环境搭建
- cartList.html
- 1.8.6 添加购物车
- 1)、加入购物车
- 2)、修改加入购物车后的成功页面展示
- ① 简单修改页面
- ②抽取获取购物车(临时用户/登录用户的)方法
- ③远程调用商品服务中查询商品销售属性的方法
- ④去到商品服务中编写要远程调用的方法
- ⑤配置线程池 提高远程服务查询效率
- ⑥异步编排
- ⑦启动测试
- ⑧完善购物车细节
- ⑨前端页面完善
- 1.8.7 获取&合并购物车
- 1)、编写获取购物车的方法
- 2)、编写删除临时用户购物车的方法
- 3)、合并购物车,合并完之后要删除临时购物车
- 4)、完善前端页面
- ① 登录回显
- ② 购物车是否有数据的逻辑判断
- ③ 购物车中商品遍历
- ④ 是否被选中、图片展示、标题展示、销售属性展示、格式化商品单价展示、商品数量展示、商品总价显示
- ⑤购物车总价显示、优惠价格显示
- ⑥ 登录与未登录页面展示效果不同
- ⑦效果展示
- 1.8.8 选中购物项(单选框)
- 1)、 页面修改
- 2)、编写请求
- 1.8.9 改变购物项数量
- 1)、页面修改
- 2)、编写请求
- 1.8.10 删除购物项
- 1)、页面修改
- 2)、编写请求
- 1.9 订单服务
- 1.9.1 环境搭建
- 1)、动静分离
- 2)、修改hosts
- 3)、html页面完善
- 4)、配置网关
- 5)、注册到nacos
- 6)、导入thymeleaf并禁用缓存
- 7)、编写Controller访问订单页面
- 1.9.2 整合SpringSession
- 1)、redis相关配置
- 2) 、配置session
- 3)、线程池配置
- 4)、登录回显
- 1.9.3 订单基本概念
- 1)、订单中心
- 2)、订单状态
- 3)、订单流程
- 4)、幂等性处理
- 1.9.4 订单登录拦截
- 1)、处理去结算请求
- 2)、登录检查
- 1.9.5 订单确认页模型抽取编写
- 1)购物车价格小bug修改
- 2)、订单确认页的数据编写
- 3)、编写vo
- 4)、编写业务代码
- 1.9.6 订单确认页数据获取
- 1)、远程查询会员地址准备
- 2)、远程调用查询会员地址
- 3)、远程查询商品的最新价格准备
- 4)、远程查询商品的最新价格
- 5)、远程查询实时购物车准备
- 6)、远程查询实时购物车
- 7)、动态计算价格
- 1.9.7 订单确认页请求完成
- 1.9.8 Feign远程调用丢失请求头问题
- 1.9.9 Feign异步调用丢失上下文的问题
- 1)、注入线程池
- 2)、使用异步编排
- 3)、问题及解决方案
- 1.9.10 订单确认页渲染
- 1)、收货人信息回显
- 2)、商品项信息回显
- 3)、商品总件数、总金额、应付金额回显
- 1.9.11 订单确认页库存查询
- 1)、查询库存方法
- 2)、远程查询商品的库存
- 3)、编写vo
- 4)、编写异步任务查询库存信息
- 5)、库存信息回显
- 1.9.12 订单确认页模拟运费效果
- 1)、远程查询收货地址
- 2)、获取运费
- 3)、页面优化
- 1.9.13 订单确认页细节显示
- 1)、编写FareVo
- 2)、改良实现代码
- 3)、信息回显
- 1.9.14 接口幂等性
- 1.9.15 订单确认页完成
- 1)、订单服务执行流程
- 2)、防重令牌的编写
- ① 注入StringRedisTemplate
- ② 创建订单服务常量即防重令牌前缀,格式:order:token:userId
- ③ 防重令牌存到redis中
- ④ 提交页面数据Vo的编写
- ⑤ 前端页面提交表单编写
- ⑥ 为input框绑定数据
- ⑦ 编写提交订单数据接口
- 1.9.16 原子验证令牌
- 1)、提交订单返回结果Vo编写
- 2)、下单功能接口完善
- 1.9.17 构造订单数据
- 1) 、订单创建To的编写
- 2) 、创建订单方法编写
- ① 订单状态枚举类的编写
- ② 远程服务调用获取地址和运费信息
- ③ 使用ThreadLocal,实现同一线程共享数据
- ④ 实现
- 1.9.18 构造订单项数据
- 1)、远程服务通过skuId获取spu信息准备
- 2)、远程服务通过skuId获取spu信息
- 3)、设置订单购物项数据
- 4)、实现方法完善
- 1.9.19 订单验价
- 1)、 计算单个购物项的真实价格
- 2)、设置订单的价格
- 3)、订单其它信息设置
- 4)、验价
- 1.9.20 保存订单数据
- 1)、保存订单和订单项数据
- 1.9.21 锁定库存
- 1)、锁库存逻辑
- 2)、远程服务调用锁定库存
- 3)、锁库存实现
- 4)、 远程服务调用
- 5)、接口完善
1.7 认证服务
1.7.1 环境搭建
1)、创建认证服务微服务
我们添加的依赖如上图所示。主要是springbootDevTools + Lombok + spring Web + Thymeleaf + OpenFeign(远程调用情况)
2)、引入依赖
<dependencies>
<dependency>
<groupId>com.atguigu.gulimall</groupId>
<artifactId>gulimall-common</artifactId>
<version>0.0.1-SNAPSHOT</version>
<exclusions>
<exclusion>
<groupId>com.baomidou</groupId>
<artifactId>mybatis-plus-boot-starter</artifactId>
</exclusion>
</exclusions>
</dependency>
3)、添加相应的域名
4)、动静分离
- 我们需要将登陆页面(login.html)和认证页面(reg.html)都放到新创建的认证服务下的templates下。
- 同理在nginx中的静态文件夹下的html中创建两个login文件夹和reg的文件夹。
- 修改login.html和reg.html页面中的静态资源路径。将href=" 改为href=“/static/login/ 和 src=” 改为src="/static/login/ 。同理reg.html页面中我们也要进行相同的修改。
5)、nacos中注册
- application.properties
spring.application.name=gulimall-auth-server
spring.cloud.nacos.discovery.server-addr=127.0.0.1:8848
server.port=50000
- GulimallAuthServerApplication
@EnableFeignClients //加入远程调用
@EnableDiscoveryClient //加入服务注册发现功能
@SpringBootApplication
public class GulimallAuthServerApplication {
http://localhost:8848/nacos/#/login
输入这个网址之后,本机才能打开naocs注册中心地址。否则不行。
6)、配置网关
- id: gulimall_auth_route
uri: lb://gulimall-auth-server
predicates:
- Host=auth.gulimall.com
7)、测试访问登录页面
-
我们暂时将login.html改为index.html页面,这样因为是默认的会被模板引擎解析。但是这个是暂时的。
8)、实现各个页面之间跳转
1、实现登录页面点击”谷粒商城“图标能跳转到首页:
login.html
2、实现首页点击登录和注册能跳转到登录和注册页面:
修改商品服务下的首页index.html
认证服务编写 controller 实现跳转
@Controller
public class LoginController {
@GetMapping("/login.html")
public String loginPage(){
return "login";
}
@GetMapping("/reg.html")
public String regPage(){
return "reg";
}
}
登录页面点击“立即注册”能够跳转到注册页面。
注册页面点击“请登录”能够跳转到登录页面。
[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-QykLGWJ1-1673532968095)(null)]
ps:这里可以稍微修改一下 登录页面的宽度,让页面更好看一点。
1.7.2 验证码功能
1)、验证码功能
①把 reg.html页面中这一处修改为 “发送验证码”
发送验证码,有60秒倒计时:
$(function (){
$("#sendCode").click(function () {
//2、倒计时
if ($(this).hasClass("disabled")){
//正在倒计时。
}else{
//1、给指定手机号码发送验证码
timeoutChangeStyle();
}
});
})
var num = 60;
function timeoutChangeStyle(){
$("#sendCode").attr("class","disabled");
if (num == 0){
$("#sendCode").text("发送验证码");
num = 60;
$("#sendCode").attr("class","");
}else{
var str = num +"s 后再次发送";
$("#sendCode").text(str);
//每隔1s调用timeoutChangeStyle()
setTimeout("timeoutChangeStyle()",1000);
}
num --;
}
效果:
②修改后台代码
如果编写一个接口仅仅是为了跳转页面,没有数据的处理,如果这样的跳转接口多了则可以使用SpringMVC的view Controller(视图控制器)将请求与页面进行绑定.
新建 GulimallWebConfig
@Configuration
public class GulimallWebConfig implements WebMvcConfigurer {
/**
* 视图映射
* @param registry
*/
@Override
public void addViewControllers(ViewControllerRegistry registry) {
/**
* * @GetMapping("/login.html")
* * public String loginPage(){
* *
* * return "login";
* * }
* * @param registry
*/
registry.addViewController("/login.html").setViewName("login");
registry.addViewController("/reg.html").setViewName("reg");
}
}
ps:idea快捷键:实现接口方法
alt + shift + p
以前的 LoginController 里面的 方法就可以注释掉了。(这个地方编写的代码仅仅是帮助我们实现跳转页面的)
@Controller
public class LoginController {
/**
* 发送一个请求直接跳转到一个页面。
* springMVC viewcontroller:将请求和页面映射过来。
*/
// @GetMapping("/login.html")
// public String loginPage(){
//
// return "login";
// }
//
// @GetMapping("/reg.html")
// public String regPage(){
//
// return "reg";
// }
}
- 为了我们内存问题,我们可以将认证微服务的内存修改为只占用100m
2)、整合验证码
- 我们可以去阿里云云市场中购买三网短信接口,使用这个来进行完成我们的短信验证码功能。
- 试试调试功能
- 请求示例
public static void main(String[] args) {
String host = "https://dfsns.market.alicloudapi.com";
String path = "/data/send_sms";
String method = "POST";
String appcode = "你自己的AppCode";
Map<String, String> headers = new HashMap<String, String>();
//最后在header中的格式(中间是英文空格)为Authorization:APPCODE 83359fd73fe94948385f570e3c139105
headers.put("Authorization", "APPCODE " + appcode);
//根据API的要求,定义相对应的Content-Type
headers.put("Content-Type", "application/x-www-form-urlencoded; charset=UTF-8");
Map<String, String> querys = new HashMap<String, String>();
Map<String, String> bodys = new HashMap<String, String>();
bodys.put("content", "code:1234");
bodys.put("phone_number", "156*****140");
bodys.put("template_id", "TPL_0000");
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();
}
}
ps:当我们在页面上点击“发送验证码”,我们不能通过js代码带上我们的APPCODE ,这样就直接将APPCODE 暴露给别人了,然后别人使用它发送大量短信(让短信服务崩溃),这样就有危机了。我们通过后台来发送验证码,这样比较保险。
- 在第三方微服务下进行简单的测试
- 直接将上面的请求示例的代码放到test中,手机号写自己的,测试看看。
- 我们发现需要引入依赖,所以我们
package com.atguigu.gulimall.thirdparty.utils;
这个包下面将请求示例中给我们提供的地址中的HttpUtils这个java代码复制到utils包中。 - 测试,发现发送成功
①短信远程服务准备
- 我们可以进行自定义,不要短信验证码写死。将请求实例中的代码抽取为一个组件 — SmsComponent
package com.atguigu.gulimall.thirdparty.component;
@ConfigurationProperties(prefix = "spring.cloud.alicloud.sms")
@Data
@Component
public class SmsComponent {
private String host;
private String path;
private String appcode;
public void sendSmsCode(String phone, String content) {
String method = "POST";
Map<String, String> headers = new HashMap<String, String>();
headers.put("Authorization", "APPCODE " + appcode);
headers.put("Content-Type", "application/x-www-form-urlencoded; charset=UTF-8");
Map<String, String> querys = new HashMap<String, String>();
Map<String, String> bodys = new HashMap<String, String>();
bodys.put("content", "code: " +content);
bodys.put("phone_number", phone);
bodys.put("template_id", "TPL_0000");
try {
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();
}
}
}
- 在pom.xml文件中将下面的这个依赖引入,这样我们在application.yml文件中编写的时候就有提示了
- application.yml文件中编写我们自定义的。
- 在test中进行测试
@Test
public void testSms(){
smsComponent.sendSmsCode("15642848274","8639");
}
随便写一个,发现测试通过。
- 编写controller,提供给别的服务进行调用。
@RestController
@RequestMapping("/sms")
public class SmsSendController {
@Autowired
SmsComponent smsComponent;
/**
* 提供给别的服务进行调用
*
* @return
*/
@GetMapping("/sendcode")
public R sendCode(@RequestParam("phone") String phone,@RequestParam("content") String content){
smsComponent.sendSmsCode(phone,content);
return R.ok();
}
}
②认证服务远程调用短信
- 在认证微服务中编写一个feign接口(相应的主启动类上要加入
@EnableFeignClients
这个注解)
package com.atguigu.gulimall.auth.feign;
@FeignClient("gulimall-third-party")
public interface ThirdPartFeignService {
@GetMapping("/sms/sendcode")
public R sendCode(@RequestParam("phone") String phone, @RequestParam("content") String content);
}
③ 验证码防刷
-
我们将发送的验证码要按照一定的规则存储到redis中,到时候认证的时候需要根据用户输入的和实际存储的验证码进行对比。而且还要防止其他人利用网页刷新,将原来60s重新设置60s,导致短信服务崩溃。
-
redis保存验证码
注意引入:redis依赖
<dependency> <groupId>org.springframework.boot</groupId> <artifactId>spring-boot-starter-data-redis</artifactId> </dependency>
- 设置redis中保存的前缀
package com.atguigu.common.constant; public class AuthServerConstant { public static final String SMS_CODE_CACHE_PREFIX = "sms:code:"; }
- 如果没有超过60s,设置错误代码
-
修改LoginController
-
@Controller public class LoginController { @Autowired ThirdPartFeignService thirdPartFeignService; @Autowired StringRedisTemplate redisTemplate; /** * 发送一个请求直接跳转到一个页面 * SpringMVC viewcontroller;将请求和页面映射过来 * @return */ @ResponseBody //返回json数据 @GetMapping("/sms/sendcode") public R sendCode(@RequestParam("phone")String phone){ //TODO //1.接口防刷 //相同手机号,即使再次刷新页面,但是手机号是相同的,你再次发送也不会生效 String redisContent = redisTemplate.opsForValue().get(AuthServerConstant.SMS_CODE_CACHE_PREFIX + phone); if(!StringUtils.isEmpty(redisContent)){ long l = Long.parseLong(redisContent.split("_")[1]); if(System.currentTimeMillis() - l < 60000){ //系统当前时间减去保存redis的时间间隔小于60s return R.error(BizCodeEnume.SMS_CODE_EXCEPTION.getCode(), BizCodeEnume.SMS_CODE_EXCEPTION.getMsg()); } } //2.验证码的再次校验。redis.存key-phone,value-code sms:code:17512080612 -> 45678 String content = UUID.randomUUID().toString().substring(0, 5); String substring = content + "_"+System.currentTimeMillis(); //redis缓存验证码,防止同一个phone在60秒内再次发送验证码 redisTemplate.opsForValue().set(AuthServerConstant.SMS_CODE_CACHE_PREFIX+phone,substring,10, TimeUnit.MILLISECONDS); thirdPartFeignService.sendCode(phone,content); return R.ok(); } @PostMapping("/regist") public String regist(@Valid UserRegistVo vo, BindingResult result, Model model){ if(result.hasErrors()){ Map<String, String> errors = result.getFieldErrors().stream().collect((Collectors.toMap(FieldError::getField, FieldError::getDefaultMessage))); model.addAttribute("errors",errors); //校验出错,转发到注册页 return "forward:/reg.html"; } //真正注册,调用远程服务进行注册 //注册成功回到首页,回到登录页 return "redirect:/login.html"; } }
-
为页面设置的输入验证码功能
-
- 回调函数
1.7.3 一步一坑的注册页环境
1)、编写 vo封装注册页内容
package com.atguigu.gulimall.auth.vo;
@Data
public class UserRegistVo {
@NotEmpty(message = "用户名必须提交")
@Length(min = 6,max = 18,message = "用户名必须是6-18位字符")
private String userName;
@NotEmpty(message = "密码必须填写")
@Length(min = 6,max = 18,message = "密码必须是6-18位字符")
private String password;
@NotEmpty(message = "手机号必须填写")
@Pattern(regexp = "^[1]([3-9])[0-9]{9}$",message = "手机号格式不正确")
private String phone;
@NotEmpty(message = "验证码必须填写")
private String content;
}
后端使用jsr303校验。
JSR303校验的结果,被封装到 BindingResult ,再结合 BindingResult.getFieldErrors() 方法获取错误信息, 有错误就重定向至注册页面。
2)、编写 controller接口
使用@Valid注解开启数据校验功能,将校验后的结果封装到BindingResult中。 LoginController
package com.atguigu.gulimall.auth.controller;
@PostMapping("/regist")
public String regist(@Valid UserRegistVo vo, BindingResult result) {
if (result.hasErrors()) {
//校验出错,转发到注册页
return "redirect:http://auth.gulimall.com/reg.html";
}
//注册成功回到首页,回到登录页
return "redirect:/login.html";
}
3)、编写注册页面
为每个input框设置name属性,值需要与Vo的属性名一一对应
点击注册按钮没有发送请求,说明:为注册按钮绑定了单击事件,禁止了默认行为。将绑定的单击事件注释掉
4)、为Model绑定校验错误信息
使用方法引用的方式。
5)、编写前端页面获取错误信息
<form action="/regist" method="post" class="one">0
<div class="register-box">
<label class="username_label">用 户 名
<input name="userName" maxlength="20" type="text" placeholder="您的用户名和登录名">
</label>
<div class="tips" style="color:red" th:text="${errors!=null?(#maps.containsKey(errors, 'userName')?errors.userName:''):''}">
</div>
</div>
<div class="register-box">
<label class="other_label">设 置 密 码
<input name="password" maxlength="20" type="password" placeholder="建议至少使用两种字符组合">
</label>
<div class="tips" style="color:red" th:text="${errors!=null?(#maps.containsKey(errors, 'password')?errors.password:''):''}">
</div>
</div>
<div class="register-box">
<label class="other_label">确 认 密 码
<input maxlength="20" type="password" placeholder="请再次输入密码">
</label>
<div class="tips">
</div>
</div>
<div class="register-box">
<label class="other_label">
<span>中国 0086∨</span>
<input name="phone" class="phone" id="phoneNum" maxlength="20" type="text" placeholder="建议使用常用手机">
</label>
<div class="tips" style="color:red" th:text="${errors!=null?(#maps.containsKey(errors, 'phone')?errors.phone:''):''}">
</div>
</div>
<div class="register-box">
<label class="other_label">验 证 码
<input name="code" maxlength="20" type="text" placeholder="请输入验证码" class="caa">
</label>
<a id="sendCode">发送验证码</a>
<div class="tips" style="color:red" th:text="${errors!=null?(#maps.containsKey(errors, 'code')?errors.code:''):''}">
</div>
6)、测试–踩坑
- 第一个踩坑
- 我的错误却是````Request method ‘GET’ not supported ```````Resolved [org.springframework.web.HttpRequestMethodNotSupportedException: Request method ‘GET’ not supported]```
这个是我百度找到的结果,使用@RequestMapping
- 第二个踩坑
- 刷新页面,会重复提交表单
-
出现问题:分布式下重定向使用session存储数据会出现一些问题(这个后续来解决)
-
完整代码
/**
* //TODO 重定向携带数据,利用session原理。将数据放在session中,只要跳到下一个页面取出这个数据以后,session里面的数据就会删掉
*
*
*
* // TODO 1、分布式下的session问题。
* RedirectAttributes redirectAttributes : 模拟重定向携带数据
* @param vo
* @param result
* @param redirectAttributes
* @return
*/
@PostMapping("/regist")
public String regist(@Valid UserRegistVo vo, BindingResult result, RedirectAttributes redirectAttributes, HttpSession session) {
if (result.hasErrors()) {
/**
* .map(fieldError ->{
* String field = fieldError.getField();
* String defaultMessage = fieldError.getDefaultMessage();
* errors.put(field,defaultMessage);
* return
* })
*
*
*/
Map<String, String> errors = result.getFieldErrors().stream().collect(Collectors.toMap(FieldError::getField, FieldError::getDefaultMessage));
// model.addAttribute("errors", errors);
redirectAttributes.addFlashAttribute("errors",errors);
// Request method 'POST' not supported
//用户注册 -》/regist[post] ---->转发/reg.html(路径映射默认都是get方法访问的。)
//真正注册。调用远程服务进行注册
//校验出错,转发到注册页
return "redirect:http://auth.gulimall.com/reg.html";
}
//注册成功回到首页,回到登录页
return "redirect:/login.html";
}
ps: 以上内容是注册用户
在 gulimall-auth-server服务中编写注册的主体逻辑
- 从redis中确认手机验证码是否正确,一致则删除验证码,(令牌机制)
- 会员服务调用成功后,重定向至登录页(防止表单重复提交),否则封装远程服务返回的错误信息返回至注册页面
- 重定向的请求数据,可以利用RedirectAttributes参数转发
- 但是他是利用的session原理,所以后期我们需要解决分布式的session问题
- 重定向取一次后,session数据就消失了,因为使用的是.addFlashAttribute(
- 重定向时,如果不指定host,就直接显示了注册服务的ip,所以我们重定义写http://…
注: RedirectAttributes可以通过session保存信息并在重定向的时候携带过去
1.7.4 异常机制
1)、校验验证码
@PostMapping("/regist")
public String regist(@Valid UserRegistVo vo, BindingResult result, RedirectAttributes redirectAttributes, HttpSession session) {
if (result.hasErrors()) {
/**
* .map(fieldError ->{
* String field = fieldError.getField();
* String defaultMessage = fieldError.getDefaultMessage();
* errors.put(field,defaultMessage);
* return
* })
*/
Map<String, String> errors = result.getFieldErrors().stream().collect(Collectors.toMap(FieldError::getField, FieldError::getDefaultMessage));
// model.addAttribute("errors", errors);
redirectAttributes.addFlashAttribute("errors",errors);
// Request method 'POST' not supported
//用户注册 -》/regist[post] ---->转发/reg.html(路径映射默认都是get方法访问的。)
//校验出错,重定向到注册页
return "redirect:http://auth.gulimall.com/reg.html";
}
//1、校验验证码
String code = vo.getCode();
String s = redisTemplate.opsForValue().get(AuthServerConstant.SMS_CODE_CACHE_PREFIX + vo.getPhone());
if (!StringUtils.isEmpty(s)){
if (code.equals(s.split("_")[0])){
//删除验证码;令牌机制
redisTemplate.delete(AuthServerConstant.SMS_CODE_CACHE_PREFIX + vo.getPhone());
//验证通过。//真正注册。调用远程服务进行注册。
}else{
Map<String, String> errors = new HashMap<>();
errors.put("code","验证码错误");
redirectAttributes.addFlashAttribute("errors",errors);
return "redirect:http://auth.gulimall.com/reg.html";
}
}else{
Map<String, String> errors = new HashMap<>();
errors.put("code","验证码错误");
redirectAttributes.addFlashAttribute("errors",errors);
return "redirect:http://auth.gulimall.com/reg.html";
}
//注册成功回到首页,回到登录页
return "redirect:/login.html";
}
验证短信验证码通过,下面开始去数据库保存。
-
member远程服务 ------- 通过
gulimall-member
会员服务注册逻辑-
通过异常机制判断当前注册会员名和电话号码是否已经注册,如果已经注册,则抛出对应的自定义异常,并在返回时封装对应的错误信息
-
如果没有注册,则封装传递过来的会员信息,并设置默认的会员等级、创建时间
-
2)、会员服务中编写Vo接受数据
package com.atguigu.gulimall.member.vo;
@Data
public class MemberRegistVo {
/**
* 这个地方就不需要校验了,因为只有正确了,才会进行保存
*/
private String userName;
private String password;
private String phone;
}
3)、编写会员服务的用户注册接口
- MemberController
//因为我们注册会提交很多的东西,所以是 post方式提交
@RequestMapping("/regist")
public R regist(@RequestBody MemberRegistVo vo){
memberService.regist(vo);
return R.ok();
}
- MemberServiceImpl
@Override
public void regist(MemberRegistVo vo) {
MemberDao memberDao = this.baseMapper;
MemberEntity entity = new MemberEntity();
//设置默认等级
MemberLevelEntity levelEntity = memberLevelDao.getDefaultLevel();
entity.setLevelId(levelEntity.getId());
//检查用户名和手机号是否唯一。为了让controller能够感知异常,使用异常机制:一直往上抛
checkPhoneUnique(vo.getPhone());
checkUsernameUnique(vo.getUserName());
entity.setMobile(vo.getPhone());
entity.setUsername(vo.getUserName());
//密码要进行加密存储。
memberDao.insert(entity);
}
-
MemberLevelDao.xml -> :查询会员的默认等级
<select id="getDefaultLevel" resultType="com.atguigu.gulimall.member.entity.MemberLevelEntity"> SELECT * FROM `ums_member_level` WHERE default_status = 1 </select>
4)、异常类的编写
- PhoneExistException
package com.atguigu.gulimall.member.exception;
public class PhoneExistException extends RuntimeException{
public PhoneExistException(){
super("手机号存在");
}
}
- UsernameExistException
public class UsernameExistException extends RuntimeException {
public UsernameExistException(){
super("用户名存在");
}
}
- 检查方法编写->MemberServiceI
void checkPhoneUnique(String phone) throws PhoneExistException;
void checkUsernameUnique(String username) throws UsernameExistException;
- MemberServiceImpl
@Override
public void checkPhoneUnique(String phone) throws PhoneExistException{
MemberDao memberDao = this.baseMapper;
Integer mobile = memberDao.selectCount(new QueryWrapper<MemberEntity>().eq("mobile", phone));
if (mobile > 0){
throw new PhoneExistException();
}
}
@Override
public void checkUsernameUnique(String username) throws UsernameExistException {
MemberDao memberDao = this.baseMapper;
Integer count = memberDao.selectCount(new QueryWrapper<MemberEntity>().eq("username", username));
if (count > 0){
throw new UsernameExistException();
}
}
如果抛出异常,则进行捕获
@Test
public void contextLoads() {
//e10adc3949ba59abbe56e057f20f883e
//抗修改性:彩虹表。 123456 -> xxxx
String s = DigestUtils.md5Hex("123456");
//MD5不能直接进行密码的加密存储:可以被直接暴力破解
// System.out.println(s);
}
1.7.5 MD5&盐值&BCrypt
- 密码的设置,前端传来的密码是明文,存储到数据库中需要进行加密。
[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-R4ovczRY-1673533055339)(null)]
@Test
public void contextLoads() {
//e10adc3949ba59abbe56e057f20f883e
//抗修改性:彩虹表。 123456 -> xxxx
String s = DigestUtils.md5Hex("123456");
//MD5不能直接进行密码的加密存储:可以被直接暴力破解
// System.out.println(s);
}
Apache.common下DigestUtils工具类的md5Hex()方法,将MD5加密后的数据转化为16进制
MD5并安全,很多在线网站都可以破解MD5,通过使用彩虹表,暴力破解。
因此,可以通过使用MD5+盐值进行加密
盐值:随机生成的数
方法1是加默认盐值: 1 1 1xxxxxxxx
方法2是加自定义盐值
//盐值加密:随机值 加盐:$1$+8位字符
//$1$qqqqqqqq$AZofg3QwurbxV3KEOzwuI1
//验证: 123456进行盐值(去数据库查)加密
// String s1 = Md5Crypt.md5Crypt("123456".getBytes(), "$1$qqqqqqqq");
// System.out.println(s1);
这种方法需要在数据库添加一个专门来记录注册时系统时间的字段,此外还需额外在数据库中存储盐值。
可以使用Spring家的BCryptPasswordEncoder,它的encode()方法使用的就是MD5+盐值进行加密,盐值是随机产生的,通过matches()方法进行密码是否一致。
//使用 spring家的
BCryptPasswordEncoder passwordEncoder = new BCryptPasswordEncoder();
//$2a$10$R/VBymW1UA.VzeBedBcspe.iypJIyQiWkka/Ds5SDG7h6r0wQsF6G
String encode = passwordEncoder.encode("123456");
boolean matches = passwordEncoder.matches("123456", "$2a$10$R/VBymW1UA.VzeBedBcspe.iypJIyQiWkka/Ds5SDG7h6r0wQsF6G");
// $2a$10$jLJp4edbLb9pnCg9quGk0u2uvsm4E/6TD5zi1wqHY4jz/f1ydS.LS=>true
System.out.println(encode+"=>"+matches);
用户注册业务中的密码加密
//密码要进行加密存储。
BCryptPasswordEncoder passwordEncoder = new BCryptPasswordEncoder();
String encode = passwordEncoder.encode(vo.getPassword());
entity.setPassword(encode);
1.7.6 注册完成
1)、在common的exception包下,编写异常枚举
USER_EXIST_EXCEPTION(15001,"用户存在"),
PHONE_EXIST_EXCEPTION(15002,"手机号存在"),
2)、进行异常的捕获
- MemberController
package com.atguigu.gulimall.member.controller;
// @PostMapping("/regist")
@RequestMapping("/regist")
public R regist(@RequestBody MemberRegistVo vo ){
try {
memberService.regist(vo);
} catch (PhoneExistException e) {
return R.error(BizCodeEnume.PHONE_EXIST_EXCEPTION.getCode(), BizCodeEnume.PHONE_EXIST_EXCEPTION.getMsg());
}catch (UsernameExistException e){
R.error(BizCodeEnume.USER_EXIST_EXCEPTION.getCode(), BizCodeEnume.USER_EXIST_EXCEPTION.getMsg());
}
return R.ok();
}
3)、远程服务接口编写
- 在 auth 服务下新建 MemberFeignService
@FeignClient("gulimall-member")
public interface MemberFeignService {
@PostMapping("/member/member/regist")
public R regist(@RequestBody UserRegistVo vo);
}
4)、 远程服务调用
package com.atguigu.gulimall.auth.controller;
/**
* //todo 重定向携带数据,利用的是session原理,将数据放在session中,只要跳到下一个页面,取出这个数据以后,session里面的数据就会删掉
* RedirectAttributes redirectAttributes :模拟重定向携带数据
*
* @param vo
* @param result
* @param redirectAttributes
* @return
*/
// @PostMapping("/regist")
@RequestMapping("/regist") //这个地方和老师不一样,写成postmapping的话,
// 会报 Request method 'GET' not supported,百度下改为requestmapping才行
public String regist(@Valid UserRegistVo vo, BindingResult result, RedirectAttributes redirectAttributes,
HttpSession session) {
if (result.hasErrors()) {
Map<String, String> errors = result.getFieldErrors().stream().collect(Collectors.toMap(FieldError::getField, FieldError::getDefaultMessage));
// model.addAttribute("errors",errors);
redirectAttributes.addFlashAttribute("errors", errors);
// Request method 'GET' not supported
// 用户注册 -》 /regist[约定是post表单提交] - 》转发/reg.html(路径映射默认都是get方式访问的)
//校验出错,转发到注册页
return "redirect:http://auth.gulimall.com/reg.html";
// return "forward:/reg.html";
}
//1.校验验证码
String content = vo.getContent();
String s = redisTemplate.opsForValue().get(AuthServerConstant.SMS_CODE_CACHE_PREFIX + vo.getPhone());
if (!StringUtils.isEmpty(s)) {
if (content.equals(s.split("_")[0])) {
//删除验证码 ;令牌机制
redisTemplate.delete(AuthServerConstant.SMS_CODE_CACHE_PREFIX + vo.getPhone());
//验证通过 //真正注册,调用远程服务进行注册
R r = memberFeignService.regist(vo);
if(r.getCode() == 0){
//成功
return "redirect:http://auth.gulimall.com/login.html";
}else{
HashMap<String,String> errors = new HashMap<>();
errors.put("msg",r.getData("msg",new TypeReference<String>(){
}));
redirectAttributes.addFlashAttribute("errors",errors);
return "redirect:http://auth.gulimall.com/reg.html";
}
} else {
Map<String, String> errors = new HashMap<>();
errors.put("content", "验证码错误");
redirectAttributes.addFlashAttribute("errors", errors);
return "redirect:http://auth.gulimall.com/reg.html";
}
} else {
Map<String, String> errors = new HashMap<>();
errors.put("content", "验证码错误");
redirectAttributes.addFlashAttribute("errors", errors);
//校验出错,转发到注册页
return "redirect:http://auth.gulimall.com/reg.html";
}
//真正注册,调用远程服务进行注册
//注册成功回到首页,回到登录页
}
}
5)、注册页错误消息提示
6)、测试
- 注册页面输入之后,跳转到登录页面
- 数据库中也有数据
- 报错:
status 400 reading MemberFeignService#regist(UserRegistVo)
这个地方最开始写成@RequestParam了。
1.7.7 账户密码登录完成
1)、编写vo
package com.atguigu.gulimall.auth.vo;
@Data
public class UserLoginVo {
private String loginacct;
private String password;
}
2)、数据绑定
我们进行输入账号密码进行提交,我们必须要采用表单的方式。
3)、编写登录接口
package com.atguigu.gulimall.auth.controller;
@PostMapping("/login")
public String login(UserLoginVo vo,RedirectAttributes redirectAttributes){
//因为请求第一次过来传过来的是kv键值对(form表单),不是Json,所以不加@requestBody,调用远程服务时将其又转换为了json
//远程登录
R login = memberFeignService.login(vo);
if(login.getCode() == 0){
//成功
return "redirect:http://gulimall.com";
// return "redirect:http//gulimall.com";
}else {
//失败 ,回到登录页
Map<String, String> errors = new HashMap<>();
errors.put("msg",login.getData("msg",new TypeReference<String>(){}));
redirectAttributes.addFlashAttribute("errors",errors);
return "redirect:http://auth.gulimall.com/login.html";
}
}
4)、MemberLoginVo
package com.atguigu.gulimall.member.vo;
@Data
public class MemberLoginVo {
private String loginacct;
private String password;
}
5)、登录校验功能
- MemberController
//login
@PostMapping("/login")
// @RequestMapping("/login")
public R login(@RequestBody MemberLoginVo vo){
MemberEntity entity = memberService.login(vo);
if(entity!=null){
return R.ok();
}else{
return R.error(BizCodeEnume.LOGINACCT_PASSWORD_INVALID_EXCEPTION.getCode(), BizCodeEnume.LOGINACCT_PASSWORD_INVALID_EXCEPTION.getMsg());
}
}
- MemberServiceImpl
package com.atguigu.gulimall.member.service.impl;
@Override
public MemberEntity login(MemberLoginVo vo) {
String loginacct = vo.getLoginacct();
String password = vo.getPassword();
//1.去数据库查询 SELECT * FROM `ums_member` WHERE username=? OR mobile = ?
MemberDao memberDao = this.baseMapper;
MemberEntity entity = memberDao.selectOne(new QueryWrapper<MemberEntity>().eq("username", loginacct)
.or().eq("mobile", loginacct));
if (entity == null){
//登录失败
return null;
}else{
//1.获取到数据库的password
String passwordDb = entity.getPassword();
BCryptPasswordEncoder passwordEncoder = new BCryptPasswordEncoder();
//2.密码匹配
boolean matches = passwordEncoder.matches(password, passwordDb);
if(matches){
return entity;
}else {
return null;
}
}
}
6)、编写异常枚举
7)、远程服务接口编写
认证服务调用会员服务中的登录校验功能进行登录验证
- MemberFeignService
package com.atguigu.gulimall.auth.feign;
@PostMapping("/member/member/login")
R login(@RequestBody UserLoginVo vo);
-
LoginController
@PostMapping("/login") public String login(UserLoginVo vo,RedirectAttributes redirectAttributes){ //因为请求第一次过来传过来的是kv键值对(form表单),不是Json,所以不加@requestBody,调用远程服务时将其又转换为了json //远程登录 R login = memberFeignService.login(vo); if(login.getCode() == 0){ //成功 return "redirect:http://gulimall.com"; // return "redirect:http//gulimall.com"; }else { //失败 ,回到登录页 Map<String, String> errors = new HashMap<>(); errors.put("msg",login.getData("msg",new TypeReference<String>(){})); redirectAttributes.addFlashAttribute("errors",errors); return "redirect:http://auth.gulimall.com/login.html"; } }
8)、页面错误消息提示
- 在 form 表单下面新增一个 div存放错误消息提示
9)、测试
- 专门输入错误的账号密码看看到时会不会有错误消息提示
- 踩坑报错:
报这个错误
404 找不到资源,其实我们来看登录成功之后跳转的页面可看到重定向的网址写错了
1.7.8 社交登录
1)、OAuth 2.0
2)、微博登陆准备工作
[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-5xjVyrAN-1673533054932)(null)]
3)、微博登陆测试(课件)
[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-KgJFdp2M-1673533055880)(null)]
4)、实际操作(gitee+weibo)
- oauth2.0的原理
① Gitee测试
注册地址:https://gitee.com/oauth/applications/
对应文档:Gitee OAuth 文档:https://gitee.com/api/v5/oauth_doc#/
api 文档:https://gitee.com/api/v5/swagger#/getV5User
- 创建一个第三方应用
- oauth2认证基本流程
- 修改认证服务下的login.html页面
https://gitee.com/oauth/authorize?client_id={client_id}&redirect_uri={redirect_uri}&response_type=code
-
这个地方就是填自己的client_id和redirect_uri
-
图片随便找一个,将下方<src=“/static/login/JD_img/gitee.png”>即可。(相应的图标这些需要传到nginx中,根据图片路径来即可)。
- 测试获取code码
-
页面点击gitee图片登录
-
引导到指定页面登录
- http://gulimall.com/success?code=a87db26189f45222eef70f4382d006b57eaefeefb7ea8f38da2f00af71990a26 获得code码
- 利用apifox获取access_token
-
https://gitee.com/oauth/token?grant_type=authorization_code&code={code}&client_id={client_id}&redirect_uri={redirect_uri}&client_secret={client_secret}
- 将上面获得的code码和uri和client_secret和client_id这些都填进去
- 得到access_token
{
"access_token": "289e10c1952bd8a7613d400b87b7b53d",
"token_type":"bearer",
"expires_in": 86400,
"refresh_token": "427b4f1c7bfc5948a07d62879782476bb23bccdc342f3174c8aeb17be83528a5",
"scope": "user_info",
"created_at": 1670673346
}
获取到这个令牌之后,我们就可以获取到公开的所有api。
- 比如说获取授权用户的所有资料
可以看到我们需要发送一个GET请求:https://gitee.com/api/v5/user 带上access_token即可。
② 微博测试
- 创建一个网页应用
2.授权机制说明:https://open.weibo.com/wiki/%E6%8E%88%E6%9D%83%E6%9C%BA%E5%88%B6%E8%AF%B4%E6%98%8E
高级信息:https://open.weibo.com/apps/2475976673/info/advanced
用户普通读取信息:https://open.weibo.com/wiki/2/users/show
- 其他测试我们在老师的课件中可以明确的看到。这里不再过多赘述。
③操作
我们发现获取的code等这些敏感数据都直接被显示到浏览器上,所以我们需要使用后台代码将这些隐藏在后台代码中。
- 首先设置回调地址
回调地址是用户授权后,码云回调到应用,并且回传授权码的地址
比如说qq:用户点击QQ登录跳转到QQ登录页面,登录成功后,应该跳转回网站。回调地址即在这里用来指定跳转回网站的URL。回调地址注册的目的是为了保障第三方APPID帐户的安全,以免被其他恶意网站盗用。
个人理解:我们在csdn中进行社交登录,选择qq,qq登录成功之后,会跳回csdn,微博也一样,登录成功之后,要跳回哪个页面,这个页面就是指的回调地址。
- 这就是我们设置的回调地址,即用户登录成功之后。
- gitee
- 微博
-
相应的我们在认证服务下的login.html的页面中设置的redirect_uri也要改回我们设置的这个回调地址
- gitee:http://auth.gulimall.com/oauth2.0/gitee/success
- 微博:http://auth.gulimall.com/oauth2.0/weibo/success
- 将第三方服务中短信验证码中使用到的HTTPutils这个工具类复制到common服务中,并按照要求引入依赖。----主要是为了后面发送请求方便,而且放在common中也可以让其他微服务能调用
-
依赖地址: https://github.com/aliyun/api-gateway-demo-sign-java/blob/master/pom.xml
将里面的依赖导入到公共服务中
<dependencies>
<dependency>
<groupId>com.alibaba</groupId>
<artifactId>fastjson</artifactId>
<version>1.2.15</version>
</dependency>
<dependency>
<groupId>org.apache.httpcomponents</groupId>
<artifactId>httpclient</artifactId>
<version>4.2.1</version>
</dependency>
<dependency>
<groupId>org.apache.httpcomponents</groupId>
<artifactId>httpcore</artifactId>
<version>4.2.1</version>
</dependency>
<dependency>
<groupId>commons-lang</groupId>
<artifactId>commons-lang</artifactId>
<version>2.6</version>
</dependency>
<dependency>
<groupId>org.eclipse.jetty</groupId>
<artifactId>jetty-util</artifactId>
<version>9.3.7.v20160115</version>
</dependency>
<dependency>
<groupId>junit</groupId>
<artifactId>junit</artifactId>
<version>4.5</version>
<scope>test</scope>
</dependency>
</dependencies>
test依赖暂时不需要。
- 我们需要注意的是gitee和微博登录之后返回的数据是不一样的,具体返回的结果是有一些差异的。
- 认证服务包下新建SocialUser
- 我们使用这个用户普通读取接口来进行获取用户信息
- 这个是返回的数据,我们可以使用Json工具将其转换为java实体类
package com.atguigu.gulimall.auth.vo;
@Data
public class SocialUser {
//gitee
/**
private String access_token;
private Long expires_in;
private String uid;
*/
//微博
private String access_token;
private String remind_in;
private Long expires_in;
private String uid;
private String isRealName;
}
- 修改数据库中
ums_member
表,增加三个属性
- 认证服务下编写OAuth2Controller
package com.atguigu.gulimall.auth.controller;
@Controller
@Slf4j
public class OAuth2Controller {
@Autowired
MemberFeignService memberFeignService;
// @GetMapping("/oauth2.0/gitee/success") ----- gitee
//登录成功之后处理回调
@GetMapping ("/oauth2.0/weibo/success") // ----- 微博
public String gitee(@RequestParam("code")String code) throws Exception {
Map<String, String> map = new HashMap<>();
/** ----- gitee
map.put("grant_type","authorization_code");
map.put("code",code);
map.put("client_id","882f054cba88fb88b67f402b6e458dd7d0938ce2483ebeaea220ddc54b8eebb5");
map.put("client_secret","1f87cb951c4faa8350673b6d7f0a56b6c6a8f442d19de0f2031756737359abe3");
*/
//微博
map.put("client_id", "2475976673");
map.put("client_secret", "a9298e9d9cad1d01d45574ba8a56d6d0");
map.put("grant_type", "authorization_code");
map.put("redirect_uri", "http://auth.gulimall.com/oauth2.0/weibo/success");
map.put("code", code);
Map<String, String> headers = new HashMap<>();
Map<String, String> querys = new HashMap<>();
//1.根据code换取access_token ----- gitee
// HttpResponse response = HttpUtils.doPost("http://gitee.com", "/oauth/token", "post", headers, querys, map);
//获取返回结果
HttpResponse response = HttpUtils.doPost("https://api.weibo.com", "/oauth2/access_token", "post", headers, querys, map);
//2.处理 getStatusLine() :获取响应状态行,响应状态行中有响应状态码
if(response.getStatusLine().getStatusCode() == 200){
//获取到了accessToken
//response.getEntity():获取响应实体,EntityUtils.toString:将httpentityzhaun'h
String json = EntityUtils.toString(response.getEntity());
SocialUser socialUser = JSON.parseObject(json, SocialUser.class);
/** ----- gitee
JSONObject jsonObject = JSON.parseObject(json);
String accessTtoken = jsonObject.getString("access_token");
Long expiresIn = Long.valueOf(jsonObject.getString("expires_in"));
//通过access_token获取用户id
Map<String, String> map1 = new HashMap<>();
map1.put("access_token",accessTtoken);
HttpResponse response1 = HttpUtils.doGet("http://gitee.com", "/api/v5/user", "get", new HashMap<>(), map1);
String json1 = EntityUtils.toString(response1.getEntity());
JSONObject jsonObject1 = JSON.parseObject(json1);
String id = jsonObject1.getString("id");
//将access_token expires_id uid封装到SocialUser
SocialUser socialUser = new SocialUser();
socialUser.setUid(id);
socialUser.setAccess_token(accessTtoken);
socialUser.setExpires_in(expiresIn);
*/
//知道当前是哪个社交用户
//1.当前用户如果是第一次进入网站,自动注册(为当前社交用户生成一个会员信息账号,以后这个社交账号就对应指定的会员账户)
//登录或注册这个社交用户
R oauthLogin = memberFeignService.oauthLogin(socialUser);
if(oauthLogin.getCode() == 0){
MemberRespVo data = oauthLogin.getData("data",new TypeReference<MemberRespVo>(){});
log.info("登录成功:用户信息:{}",data.toString());
//2.登录成功就跳回首页
return "redirect:http://gulimall.com";
}else {
return "redirect:http://auth.gulimall.com/login.html";
}
}else {
return "redirect:http://auth.gulimall.com/login.html";
}
}
}
- 会员服务下编写 处理社交登录的类(认证服务会远程调用会员服务中的社交登录)
- MemberEntity实体类下新增3个字段(因为如果是以前没有登录过得,我们要为其快速创建一个用户)
private String socialUid;
private String accessToken;
private Long expiresIn;
- MemberController
//社交登录
@PostMapping("/oauth2/login")
public R oauthLogin(@RequestBody SocialUser socialUser) throws Exception{
MemberEntity entity = memberService.login(socialUser);
if(entity != null){
//TODO1.登录成功处理
return R.ok().setData(entity);
}else {
return R.error(BizCodeEnume.LOGINACCT_PASSWORD_INVALID_EXCEPTION.getCode(),BizCodeEnume.LOGINACCT_PASSWORD_INVALID_EXCEPTION.getMsg());
}
}
- MemberServiceImpl
@Override
public MemberEntity login(SocialUser socialUser) throws Exception{
//登录和注册合并逻辑
String uid = socialUser.getUid();
//1.判断当前社交用户是否已经登录过系统
MemberDao memberDao = this.baseMapper;
MemberEntity memberEntity = memberDao.selectOne(new QueryWrapper<MemberEntity>().eq("social_uid", uid));
if(memberEntity != null){
//这个用户已经注册
MemberEntity update = new MemberEntity();
update.setId(memberEntity.getId());
update.setAccessToken(socialUser.getAccess_token());
update.setExpiresIn(socialUser.getExpires_in());
memberDao.updateById(update);
memberEntity.setAccessToken(socialUser.getAccess_token());
memberEntity.setExpiresIn(socialUser.getExpires_in());
return memberEntity;
}else{
//2.没有查询到当前社交用户对应的记录我们就需要注册一个
MemberEntity regiset = new MemberEntity();
try {
Map<String, String> query = new HashMap<>();
/**
query.put("access_token", socialUser.getAccess_token());
*/
query.put("access_token", socialUser.getAccess_token());
query.put("uid",socialUser.getUid());
// HttpResponse response = HttpUtils.doGet("http://gitee.com", "/api/v5/user", "get", new HashMap<>(), query); ----- gitee
HttpResponse response = HttpUtils.doGet("https://api.weibo.com", "/2/users/show.json", "get", new HashMap<>(), query);
if(response.getStatusLine().getStatusCode() == 200){
//查询成功
String json = EntityUtils.toString(response.getEntity());
JSONObject jsonObject = JSON.parseObject(json);
/**
//昵称 ----- gitee
String name = jsonObject.getString("name");
regiset.setNickname(name);
*/
String name = jsonObject.getString("name");
String gender = jsonObject.getString("gender");
regiset.setGender("m".equals(gender)?1:0);
regiset.setNickname(name);
}
} catch (Exception e) {
}
regiset.setSocialUid(socialUser.getUid());
regiset.setAccessToken(socialUser.getAccess_token());
regiset.setExpiresIn(socialUser.getExpires_in());
memberDao.insert(regiset) ;
return regiset;
}
}
- SocialUser类复制到member服务的vo包下,因为需要这个类型的数据,我们直接复制即可。
- 远程调用:认证服务调用会员服务
- 认证服务
package com.atguigu.gulimall.auth.feign;
@PostMapping("/member/member/oauth2/login")
public R oauthLogin(@RequestBody SocialUser socialUser) throws Exception;
- 为数据封装方便,将会员服务中的 MemberEntity 复制到认证服务下,重命名为 MemberRespVo
package com.atguigu.gulimall.auth.vo;
import lombok.Data;
import lombok.ToString;
import java.util.Date;
@ToString
@Data
public class MemberRespVo {
private Long id;
/**
* 会员等级id
*/
private Long levelId;
/**
* 用户名
*/
private String username;
/**
* 密码
*/
private String password;
/**
* 昵称
*/
private String nickname;
/**
* 手机号码
*/
private String mobile;
/**
* 邮箱
*/
private String email;
/**
* 头像
*/
private String header;
/**
* 性别
*/
private Integer gender;
/**
* 生日
*/
private Date birth;
/**
* 所在城市
*/
private String city;
/**
* 职业
*/
private String job;
/**
* 个性签名
*/
private String sign;
/**
* 用户来源
*/
private Integer sourceType;
/**
* 积分
*/
private Integer integration;
/**
* 成长值
*/
private Integer growth;
/**
* 启用状态
*/
private Integer status;
/**
* 注册时间
*/
private Date createTime;
private String socialUid;
private String accessToken;
private Long expiresIn;
}
- 测试
- 登录成功-授权–返回首页(数据库中有记录)
1.7.9 springsession
1)、session共享问题
2)、SpringSession核心原理
-
@EnableRedisHttpSession注解导入了RedisHttpSessionConfiguration.class这个配置类
- 在 RedisHttpSessionConfiguration.class这个配置类,为容器中注入了一个组件 sessionRepository -> sessionRedisOperations : redis操作session,实现session的增删改查
- 调用SpringHttpSessionConfiguration中的springSessionRepositoryFilter()方法,获取一个SessionRepositoryFilter对象,调用doFilterInternal()对原生的request和response对象进行封装即装饰者模式, request对象调用getSession()方法就会调用wrapperRequest对象的getSession()方法。
- 网上相关资料:https://blog.csdn.net/m0_46539364/article/details/110533408
1.7.10 页面效果完善
1)、完善社交登录的页面效果
product包下面:index.html
进行简单的判断,如果登录了,就显示昵称;如果没有登录,就显示免费注册。
2)完善账号密码登录
页面登录除了进行扫码登录之外,还有一种方法是通过账号密码登录,这种登录方式的用户我们也需要进行保存到session中。
- 公共服务包
- 会员服务包 — MemberController
以前账号密码登录成功之后,直接是return R.ok.没有做任何操作,现在我们加入一段代码,使之保存到session中。
- 认证服务包 ---- LoginController
3)、设置默认的昵称
- 会员服务包下 ------ MemberServiceImpl
4)、完善登录逻辑
我们如果已经登录了,那么我们在浏览器中重新访问登录页面的话就直接跳转到首页,而不是再次跳转到登录页面了。所以这一块我们需要自定义。
- 认证服务 ------ GulimallWebConfig
这个是以前我们对于只有登录跳转页面这个controller的抽取。使用springmvc抽取的,现在我们需要自定义。
-
自己编写接口实现逻辑
-
认证服务下 LoginController
-
//如果登录成功了之后,访问auth.gulimall.login.html这个页面会自动跳转到首页 @GetMapping("/login.html") public String loginPage(HttpSession session){ Object attribute = session.getAttribute(AuthServerConstant.LOGIN_USER); if(attribute == null){ //没登录 return "login"; }else{ return "redirect:http://gulimall.com"; } }
-
5)、商品详情页面登录完善
- 详情页显示昵称
- 点击详情页面左上角京东图标返回谷粒商城首页
6)、搜索页面用户昵称显示
注意这个是在检索服务包下。
- 导入依赖
因为我们都是将session这些存在redis中,都是对redis的操作。所以需要引入redis相关依赖。
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-data-redis</artifactId>
</dependency>
<!--spring整合session-->
<dependency>
<groupId>org.springframework.session</groupId>
<artifactId>spring-session-data-redis</artifactId>
</dependency>
- 配置
- 检索服务包下的application.properties中配置redis的相关配置
- 开启session共享
- 因为都是使用同一个session。所以我们可以将认证服务中的GulimallSessionConfig复制到检索服务中。
@Configuration
public class GulimallSessionConfig {
//子域共享问题解决
@Bean
public CookieSerializer cookieSerializer(){
DefaultCookieSerializer cookieSerializer = new DefaultCookieSerializer();
//将session作用域设置为不仅仅只限于子域名,而是整个父域名。这样所有子域名都可以获得一样的cookie
cookieSerializer.setDomainName("gulimall.com");
cookieSerializer.setCookieName("GULISESSION");
return cookieSerializer;
}
// 默认使用jdk进行序列化机制,这里我们使用json序列化方式来序列化对象数据到redis中
@Bean
public RedisSerializer<Object> springSessionDefaultRedisSerializer() {
return new GenericJackson2JsonRedisSerializer();
}
}
- item.html页面完善
总结
1.7.11 单点登录
1)、介绍
2)、前置概念
3)、xxl单点登录demo
这里以码云上的[许雪里](https://gitee.com/xuxueli0323) / [xxl-sso](https://gitee.com/xuxueli0323/xxl-sso)单点登录为演示。
- 修改配置
- 配置本机域名
- 修改服务器redis地址
- 修改客户端配置
- xxl-sso-master\xxl-sso-master\xxl-sso-samples\xxl-sso-web-sample-springboot\src\main\resources
- 打包
mvn clean package -Dmaven.skip.test=true
- 启动服务器端和客户端
同样启动一个8081。
- 当访问客户端1,会自动重定向到服务器认证中心,认证中心登录后,客户端2刷新之后就登录上了。
认证中心访问路径:http://ssoserver.com:8080/xxl-sso-server
客户端1访问路径:http://client1.com:8081/xxl-sso-web-sample-springboot
客户端2访问路径:http://client2.com:8082/xxl-sso-web-sample-springboot
实现一处登录,处处登录。一处退出,处处退出。
4)、单点登录代码实现
① 总流程梳理
下图是一个总的流程。单点登录就是单独起一个认证服务,其他服务登录先请求认证服务,认证服务判断自己域名下是否有cookie保存登录信息,如果有直接返回,如果没有就登录并保存cookie重定向到申请地址。
② 环境准备
- 本机配置域名
根据此我们要在idea中创建相应的模块,服务端和客户端。
- 创建模块
- 使用Spring Initializr创建 gulimall-test-sso-server (服务器端)和 gulimall-test-sso-client(客户端)
- 引入的依赖
- spring web
- thymeleaf
- lombok
接下来我们将单点登录流程的全部分为三个部分进行理解和编排。
③单点登录流程1:
客户端:
- 创建一个HelloController
package com.atguigu.gulimall.ssoclient.controller;
@Controller
public class HelloController {
@Value("${sso.server.url}") #取出配置文件中的值
String ssoServerUrl;
/**
* 无需登录即可访问
* @return
*/
@ResponseBody
@GetMapping("/hello")
public String hello(){
return "hello";
}
/**
需要登录才可以访问的
*/
@GetMapping("/employees")
public String employees(Model model, HttpSession session){
//先从session中获取,看是否有登录用户
Object loginUser = session.getAttribute("loginUser");
//进行判断 没有登录
if (loginUser == null){
//如果没登录,让其跳转重定向到登录服务器进行登录,并且在跳转时带上前一个页面,即我们在sso登录之后需要跳回的页面。
//redirect:http://client1.com:8081/employees
return "redirect:"+ssoServerUrl+"?
redirect_url=http://client1.com:8081/employees";
}else{
//登录了,保存。这个地方我们都是写死了,两个用户的。开发中不可能是这样
List<String> emps = new ArrayList<>();
emps.add("张三");
emps.add("李四");
// model 就是个 key-value 集合。Model 对象负责在控制器和展现数据的视图之间传递数据。
model.addAttribute("emps",emps);
return "list";
}
}
}
- 创建一个list.html
<!DOCTYPE html>
<html lang="en" xmlns:th="http://www.thymeleaf.org">
<head>
<meta charset="UTF-8">
<title>员工列表</title>
</head>
<body>
<h1>欢迎:[]</h1>
<ul>
<li th:each="emp:${emps}">姓名:[[${emp}]]</li>
</ul>
</body>
</html>
- application.properties
server.port=8081
sso.server.url=http://sso.com:8080/login.html
服务器端:
- LoginController
@Controller
public class LoginController {
@GetMapping("/login.html")
public String loginPage(@RequestParam("redirect_url")String url){
return "login";
}
@PostMapping("/doLogin")
public String doLogin(){
//登录成功跳转,跳回到之前的页面
return "";
}
}
- login.html
<!DOCTYPE html>
<html lang="en" xmlns:th="http://www.thymeleaf.org">
<head>
<meta charset="UTF-8">
<title>登录页</title>
</head>
<body>
<!--表单提交-->
<form action="/doLogin" method="post">
用户名:<input name="username"/><br/>
密码:<input name="password" type="password"/><br/>
<input type="submit" value="登录"/>
</form>
</body>
</html>
- application.properties
server.port=8080
- 演示:访问客户端直接重定向到 服务端。
④单点登录流程2:
服务器端:
- 加入依赖:(将cookie这些存入到redis中)
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-data-redis</artifactId>
</dependency>
- 配置:
spring.redis.host=192.168.56.10
- login页面带一个隐藏输入框:用于存储调回的url
<!DOCTYPE html>
<html lang="en" xmlns:th="http://www.thymeleaf.org">
<head>
<meta charset="UTF-8">
<title>登录页</title>
</head>
<body>
<form action="/doLogin" method="post">
用户名:<input name="username"/><br/>
密码:<input name="password" type="password"/><br/>
<input type="hidden" name="url" th:value="${url}"/>
<input type="submit" value="登录"/>
</form>
</body>
</html>
- LoginController
- 登录成功保存用户信息并传递token
@Controller
public class LoginController {
@Autowired
private StringRedisTemplate redisTemplate;
@GetMapping("/login.html")
public String loginPage(@RequestParam("redirect_url")String url, Model model){
model.addAttribute("url",url);
return "login";
}
@PostMapping("/doLogin")
public String doLogin(@RequestParam("username") String username,
@RequestParam("password") String password,
@RequestParam("url") String url){
if (!StringUtils.isEmpty(username) && !StringUtils.isEmpty(password)){
//登录成功,跳回之前页面
String uuid = UUID.randomUUID().toString().replace("-","");
//把登录成功的用户保存起来
//登录成功保存用户信息并传递token
redisTemplate.opsForValue().set(uuid,username);
return "redirect:"+url+"?token="+uuid;
}
//登录失败,展示登录页
return "login";
}
客户端:
- 拿到令牌需要去认证中心查询用户的信息,这里只是简单保存了以下并没有模拟
/**
* 能够感知这次是在 ssoserver登录成功跳回来的。
* @param model
* @param session
* @param token 只要去ssoserver登录成功跳回来就会带上
* @return
*/
@GetMapping("/employees")
public String employees(Model model, HttpSession session,
@RequestParam(value = "token",required = false) String token){
if (!StringUtils.isEmpty(token)){
//去ssoserver登录成功跳回来就会带上
//TODO 1、去ssoserver获取当前token真正对应的用户信息
session.setAttribute("loginUser","zhangsan");
}
//先从session中获取,看是否有登录用户
Object loginUser = session.getAttribute("loginUser");
//进行判断 没有登录
if (loginUser == null){
//如果没登录,让其跳转重定向到登录服务器进行登录,并且在跳转时带上前一个页面,即我们在sso登录之后需要跳回的页面。
//redirect:http://client1.com:8081/employees
return "redirect:"+ssoServerUrl+"?
redirect_url=http://client1.com:8081/employees";
}
else{
List<String> emps = new ArrayList<>();
emps.add("张三");
emps.add("李四");
model.addAttribute("emps",emps);
return "list";
}
}
- 测试:客户端1加上了token
⑤单点登录流程3:
- 将客户端复制一份,改为客户端2,修改一些配置
- 添加进项目
- 服务器端:
@Controller
public class LoginController {
@Autowired
private StringRedisTemplate redisTemplate;
//登录之后保存用户token
@ResponseBody
@GetMapping("/userInfo")
public String userInfo(@RequestParam("token") String token){
String s = redisTemplate.opsForValue().get(token);
return s;
}
@GetMapping("/login.html")
public String loginPage(@RequestParam("redirect_url")String url, Model model,
@CookieValue(value = "sso_token",required = false) String sso_token){
if (!StringUtils.isEmpty(sso_token)){
//说明之前有人登录过,浏览器留下了痕迹
return "redirect:"+url+"?token="+sso_token;
}
model.addAttribute("url",url);
return "login";
}
@PostMapping("/doLogin")
public String doLogin(@RequestParam("username") String username,
@RequestParam("password") String password,
@RequestParam("url") String url,
HttpServletResponse response){
if (!StringUtils.isEmpty(username) && !StringUtils.isEmpty(password)){
//登录成功,跳回之前页面
//把登录成功的用户保存起来
// 登录成功保存用户信息并传递token
//UUID 01E4DC67-6F61-4272-07E1-28417A4A4707
String uuid = UUID.randomUUID().toString().replace("-","");
redisTemplate.opsForValue().set(uuid,username);
Cookie sso_token = new Cookie("sso_token", uuid);
response.addCookie(sso_token);
return "redirect:"+url+"?token="+uuid;
}
//登录失败,展示登录页
return "login";
}
}
- 客户端:
@Controller
public class HelloController {
@Value("${sso.server.url}")
String ssoServerUrl;
/**
* 无需登录即可访问
* @return
*/
@ResponseBody
@GetMapping("/hello")
public String hello(){
return "hello";
} /**
* 能够感知这次是在 ssoserver登录成功跳回来的。
* @param model
* @param session
* @param token 只要去ssoserver登录成功跳回来就会带上
* @return
*/
@GetMapping("/employees")
public String employees(Model model, HttpSession session,
@RequestParam(value = "token",required = false) String token){
if (!StringUtils.isEmpty(token)){
//去ssoserver登录成功跳回来就会带上
//TODO 1、去ssoserver获取当前token真正对应的用户信息
RestTemplate restTemplate = new RestTemplate();
ResponseEntity<String> forEntity =
restTemplate.getForEntity("http://sso.com:8080/userInfo?token=" + token, String.class);
String body = forEntity.getBody();
session.setAttribute("loginUser",body);
}
Object loginUser = session.getAttribute("loginUser");
if (loginUser == null){
//没登录,跳转到登录服务器进行登录
//跳转过去以后,使用url上的查询参数标识我们自己是那个页面
//redirect:http://client1.com:8081/employees
return "redirect:"+ssoServerUrl+"?
redirect_url=http://client1.com:8081/employees";
}else{
List<String> emps = new ArrayList<>();
emps.add("张三");
emps.add("李四");
model.addAttribute("emps",emps);
return "list";
}
}
}
<!DOCTYPE html>
<html lang="en" xmlns:th="http://www.thymeleaf.org">
<head>
<meta charset="UTF-8">
<title>员工列表</title>
</head>
<body>
<h1>欢迎:[[${session.loginUser}]]</h1>
<ul>
<li th:each="emp:${emps}">姓名:[[${emp}]]</li>
</ul>
</body>
</html>
- 测试:
1.8 购物车服务
1.8.1 环境搭建
1)、配置域名:cart.gulimall.com
2)、创建微服务
-
gulimall-cart
-
加入的依赖:lombok springweb springbootdevTools thymeleaf openfeign
-
导入公共服务的依赖
<dependency>
<groupId>com.atguigu.gulimall</groupId>
<artifactId>gulimall-common</artifactId>
<version>0.0.1-SNAPSHOT</version>
</dependency>
- application.properties配置
server.port= 15000
spring.application.name=gulimall-cart
spring.cloud.nacos.discovery.server-addr=127.0.0.1:8848
- 主启动类上加入nacos服务注册发现功能,而且由于后面还需要远程调用,所以我们还需要加上相应注解(特别提醒:我们导入了common包,但是没有用到数据库服务,所以我们还需要暂时排除数据库自动配置,否则启动会报错)
@EnableFeignClients
@EnableDiscoveryClient
@SpringBootApplication(exclude = DataSourceAutoConfiguration.class)
3)、动静分离
-
将课件中的静态资源放到nginx中
- 在nginx中,/mydata/nginx/html/static下创建一个cart文件夹。将静态资源放入起重
-
将动态页面放入项目中
-
由于更换了路径,所以一些文件路径和图片路径会发生变化,所以我们需要修改。
4)、网关配置
- id: gulimall_cart_route
uri: lb://gulimall-cart
predicates:
- Host=cart.gulimall.com
5)、前端页面调试
在不编写任何代码的情况下,我们进行测试访问。为了方便,将success.html页面改名为Index.html
-
测试中发现的问题
- 页面中存在th:这个thymeleaf预先写好的代码,导致找不到,进而报错。我们可以先将其替换为空 。
- 页面中存在th:这个thymeleaf预先写好的代码,导致找不到,进而报错。我们可以先将其替换为空 。
-
前端页面点击图标可以返回首页
1.8.2 数据模型分析
1)、购物车需求
2)、数据结构
因此每一个购物项信息,都是一个对象,基本字段包括:
1.8.3 vo编写
1)、购物项的Vo编写:
package com.atguigu.gulimall.cart.vo;
public class CartItem {
private Long skuId;
private Boolean check = true;//是否被选中
private String title;//标题
private String image;//图片
private List<String> skuAttr;//销售属性组合描述
private BigDecimal price;//商品单价
private Integer count;//商品数量
private BigDecimal totalPrice;//总价,总价需要计算
public Long getSkuId() {
return skuId;
}
public void setSkuId(Long skuId) {
this.skuId = skuId;
}
public Boolean getCheck() {
return check;
}
public void setCheck(Boolean check) {
this.check = check;
}
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 List<String> getSkuAttr() {
return skuAttr;
}
public void setSkuAttr(List<String> skuAttr) {
this.skuAttr = skuAttr;
}
public BigDecimal getPrice() {
return price;
}
public void setPrice(BigDecimal price) {
this.price = price;
}
public Integer getCount() {
return count;
}
public void setCount(Integer count) {
this.count = count;
}
/**
* 计算当前项的总价
*
* @return
*/
public BigDecimal getTotalPrice() {
return this.price.multiply(new BigDecimal("" + this.count));
}
public void setTotalPrice(BigDecimal totalPrice) {
this.totalPrice = totalPrice;
}
}
2)、购车Vo
/**
* 整个购物车
* 需要计算的属性,必须重写它的get方法,保证每次获取属性都会进行计算,所以不用使用@Data方法
*/
public class Cart {
//一个购物车有很多个购物项
List<CartItem> items;
private Integer countNum;//商品数量
private Integer countType;//商品类型数量
private BigDecimal totalAmount;//商品总价
private BigDecimal reduce = new BigDecimal("0.00");//减免价格
public List<CartItem> getItems() {
return items;
}
public void setItems(List<CartItem> items) {
this.items = items;
}
public Integer getCountNum() {
int count = 0;
if (items != null && items.size() > 0) {
for (CartItem item : items) {
count += item.getCount();
}
}
return count;
}
public Integer getCountType() {
int count = 0;
if (items != null && items.size() > 0) {
for (CartItem item : items) {
count += 1;
}
}
return count;
}
public BigDecimal getTotalAmount() {
BigDecimal amount = new BigDecimal("0");
//1、计算购物项总价
if (items != null && items.size() > 0) {
for (CartItem item : items) {
BigDecimal totalPrice = item.getTotalPrice();
amount = amount.add(totalPrice);
}
}
//2、减去优惠总价
BigDecimal subtract = amount.subtract(getReduce());
return subtract;
}
public void setTotalAmount(BigDecimal totalAmount) {
this.totalAmount = totalAmount;
}
public BigDecimal getReduce() {
return reduce;
}
public void setReduce(BigDecimal reduce) {
this.reduce = reduce;
}
}
1.8.4 ThreadLocal用户身份鉴别
1)、数据存储-redis
将购物车数据存储至Redis中,因此,需要导入Spring整合Redis的依赖以及Redis的配置。项目上线之后,应该有 一个专门的Redis负责存储购物车的数据不应该使用缓存的Redis
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-data-redis</artifactId>
</dependency>
- 配置redis地址
spring.redis.host=192.168.56.10
2)、准备cartService和其实现类
这个接口是以后我们编写购物车具体服务的
- 创建CartService这个类
- CartServiceImpl
package com.atguigu.gulimall.cart.service.impl;
@Slf4j
@Service
public class CartServiceImpl implements CartService {
@Autowired
private StringRedisTemplate stringRedisTemplate;
}
3)、引入springsession判断用户登录与否
<dependency>
<groupId>org.springframework.session</groupId>
<artifactId>spring-session-data-redis</artifactId>
</dependency>
我们会将登录的用户保存到session中,然后通过判断session中是否有用户的数据来判断登录与否。
-
配置session
-
直接将我们认证服务中的编写好的sessionconfig拿过来
-
GulimallSessionConfig
-
package com.atguigu.gulimall.cart.config; @Configuration public class GulimallSessionConfig { //子域共享问题解决 @Bean public CookieSerializer cookieSerializer(){ DefaultCookieSerializer cookieSerializer = new DefaultCookieSerializer(); //将session作用域设置为不仅仅只限于子域名,而是整个父域名。这样所有子域名都可以获得一样的cookie cookieSerializer.setDomainName("gulimall.com"); cookieSerializer.setCookieName("GULISESSION"); return cookieSerializer; } // 默认使用jdk进行序列化机制,这里我们使用json序列化方式来序列化对象数据到redis中 @Bean public RedisSerializer<Object> springSessionDefaultRedisSerializer() { return new GenericJackson2JsonRedisSerializer(); } }
-
4)、cookie中的user-key说明
第一次访问京东,会给你的cookie中设置user-key标识你的身份,有效期为一个月,浏览器会保存你的user-key, 以后访问都会带上
*浏览器有一个cookie;user-key;标识用户身份,一个月后个过期;
* 如果第一次使用jd的购物车功能,都会给一个临时的用户身份;
* 浏览器以后保存,每次访问都会带上这个cookie;
* 登录:session有
* 没登录:按照cookie里面带来user-key来做。
* 第一次:如果没有临时用户,帮忙创建一个临时用户。
*
5)、编写To与常量
- UserInfoTo
package com.atguigu.gulimall.cart.vo;
@ToString
@Data
public class UserInfoTo {
private Long userId;
private String userKey;
private boolean tempUser = false; //标识临时用户
}
-
公共服务下增加一个有关购物车的常量(设置临时用户的过期时间)
-
package com.atguigu.common.constant; public class CartConstant { public static final String TEMP_USER_COOKIE_NAME = "user-key"; //过期时间为1个月 ,默认单位是秒 临时用户的过期时间(参考京东) public static final int TEMP_USER_COOKIE_TIMEOUT= 60*60*24*30; }
6)、编写拦截器(登录前检查)
拦截器逻辑:业务执行之前,判断是否登录,若登录则封装用户信息,将标识位设置为true,postHandler就不再
设置作用域和有效时间,否则为其创建一个user-key
注意细节:整合SpringSession之后,Session获取数据都是从Redis中获取的
使用ThreadLocal,解决线程共享数据问题,方便同一线程共享UserInfoTo
-
编写拦截器 ---- CartInterceptor
-
package com.atguigu.gulimall.cart.interceptor; /** 在执行目标方法之前,判断用户的登录状态,并封装传递给controller目标请求 * @author hxld * @create 2022-12-15 21:36 */ public class CartInterceptor implements HandlerInterceptor { public static ThreadLocal<UserInfoTo> threadLocal = new ThreadLocal<>(); /** * 目标方法执行之前 * @param request * @param response * @param handler * @return * @throws Exception */ @Override public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) throws Exception { UserInfoTo userInfoTo = new UserInfoTo(); HttpSession session = request.getSession(); MemberRespVo member = (MemberRespVo) session.getAttribute(AuthServerConstant.LOGIN_USER); if(member != null){ //用户登录 userInfoTo.setUserId(member.getId()); } Cookie[] cookies = request.getCookies(); if(cookies!=null && cookies.length>0){ for (Cookie cookie : cookies) { //user-key String name = cookie.getName(); if(name.equals(CartConstant.TEMP_USER_COOKIE_NAME)){ userInfoTo.setUserKey(cookie.getValue()); } } } //如果没有临时用户一定要分配一个临时用户 if(StringUtils.isEmpty(userInfoTo.getUserKey())){ String uuid = UUID.randomUUID().toString(); userInfoTo.setUserKey(uuid); } threadLocal.set(userInfoTo); return true; } /** * 业务执行之后:分配临时用户,让浏览器保存 * @param request * @param response * @param handler * @param modelAndView * @throws Exception */ @Override public void postHandle(HttpServletRequest request, HttpServletResponse response, Object handler, ModelAndView modelAndView) throws Exception { UserInfoTo userInfoTo = threadLocal.get(); //如果没有临时用户一定要保存一个临时用户 if(!userInfoTo.isTempUser()){ //持续的延长临时用户的过期时间 Cookie cookie = new Cookie(CartConstant.TEMP_USER_COOKIE_NAME, userInfoTo.getUserKey()); cookie.setDomain("gulimall.com"); cookie.setMaxAge(CartConstant.TEMP_USER_COOKIE_TIMEOUT); response.addCookie(cookie); } } }
-
-
配置拦截器拦截所有请求
-
package com.atguigu.gulimall.cart.config; @Configuration public class GulimallWebConfig implements WebMvcConfigurer { @Override public void addInterceptors(InterceptorRegistry registry) { //配置catrInterceptor拦截器拦截所有请求 registry.addInterceptor(new CartInterceptor()).addPathPatterns("/**"); } }
-
7)、CartController
@Controller
public class CartController {
/**
*浏览器有一个cookie;user-key;标识用户身份,一个月后个过期;
* 如果第一次使用jd的购物车功能,都会给一个临时的用户身份;
* 浏览器以后保存,每次访问都会带上这个cookie;
*
*
* 登录:session有
* 没登录:按照cookie里面带来user-key来做。
* 第一次:如果没有临时用户,帮忙创建一个临时用户。
*
* @return
*/
@GetMapping("/cart.html")
public String cartListPage(){
//1.快速得到用户信息,id,user-key
UserInfoTo userInfoTo = CartInterceptor.threadLocal.get();
System.out.println(userInfoTo);
return "cartList";
}
}
8)、测试
我们不登录,检查看看session是否有user-key
测试通过。
1.8.5 页面环境搭建
-
商品详情页
-
CartController
-
/** * 添加商品到购物车 */ @GetMapping("/addToCart") public String addToCart(){ return "success"; }
-
-
加入商品成功后,跳转到购物车列表页面
- success.html
- 这里的查看详情,我们先写成查看跳转到1号商品
-
购物车详情页面
-
cartList.html
1.8.6 添加购物车
1)、加入购物车
-
首先我们需要知道的是添加商品到购物车,必须要知道两个属性,商品的id和数量。
- 在CartController中编写一个addToCart方法,这个方法专门来处理该请求。
-
商品详情页面中加入购物车这个也需要绑定一个单击事件
-
为该属性加入单击事件,将这个href的跳转地址改为#(href="#"是一种临时链接的写法,这样写就是说这个链接目前不可用,点击了也不会有作用),并且我们还要通过自定义属性方式获取商品的SKUID,我们在编写的单击事件逻辑中编辑完整功能。
-
-
<div class="box-btns-two"> <a href="#" id="addToCartA" th:attr="skuId=${item.info.skuId}"> 加入购物车 </a> </div>
-
为文本框设置一个id,以供加入购物车单击事件获取商品数量(js中使用id属性为特定元素执行某些任务)
-
单击事件
-
$("#addToCartA").click(function () { var num = $("#numInput").val(); //获取数量 var skuId = $(this).attr("skuId"); //获取商品的skuId location.href = "http://cart.gulimall.com/addToCart?skuId=" + skuId + "&num=" + num; //拼接路径,加入购物车之后要跳转的的页面 return false; //取消默认行为 })
-
-
-
-
2)、修改加入购物车后的成功页面展示
① 简单修改页面
- 加入购物车的商品,我们需要在页面进行展示,将其相关信息等展示出来。
- 默认图片的显示、商品详情页跳转以及标题显示、商品数量显示(注意这里的item是当跳转成功页面之后使用
model.addAttribute("item",item);
放到请求域中的)
- 默认图片的显示、商品详情页跳转以及标题显示、商品数量显示(注意这里的item是当跳转成功页面之后使用
②抽取获取购物车(临时用户/登录用户的)方法
-
依照前面在CatrServiceImpl类中放入StringRedisTemplate的做法,我们为临时用户和登录用户设置一个购物车前缀。(登录用户会显示具体的,临时用户则是uuid)
-
-
private final String CART_PREFIX="gulimall:cart:";//购物车前缀
-
-
-
/** 获取用户购物车数据 * 获取到我们要操作的购物车,购物车存储在redis中,是双层map MAP(key,map<k,v>) * redisTemplate.opsForValue() redisTemplate.opsForHash():redis原生操作 * BoundHashOperations:经过一层包装的 类似于redisTemplate.opsForHash() 操作hash BoundHashOperations在操作单个key中的内容细节方面比原生操作更简单,比如一个键key对应Map类型的数据,这种使用BoundHashOperations来做就很方便了, */ private BoundHashOperations<String,Object,Object> getCartOps(){ UserInfoTo userInfoTo = CartInterceptor.threadLocal.get(); String cartKey = ""; if(userInfoTo.getUserId() != null){ //gulimall:cart:1 ----判断是登录用户 cartKey = CART_PREFIX + userInfoTo.getUserId(); }else { //gulimall:cart:20150b0a-678c-4f2b-1252-d7b148ca3fbc --- 临时用户 cartKey = CART_PREFIX + userInfoTo.getUserKey(); } //redisTemplate.boundHashOps(cartKey); 使用redis来操作cartkey BoundHashOperations<String, Object, Object> operations = redisTemplate.boundHashOps(cartKey); return operations; }
③远程调用商品服务中查询商品销售属性的方法
- 新建一个ProductFeignService(info/{skuId}直接使用以前的即可)
@FeignClient("gulimall-product")
public interface ProductFeignService {
@GetMapping("/product/skusaleattrvalue/stringlist/{skuId}") //获取销售属性 需要我们编写
List<String> getSkuSaleAttrValues(@PathVariable("skuId") Long skuId);
@RequestMapping("/product/skuinfo/info/{skuId}") //直接调用SkuInfoController中的 @RequestMapping("/info/{skuId}") 现成方法
R getSkuInfo(@PathVariable("skuId") Long skuId);
}
记得在主启动类上加入开启远程调用的注解@EnableFeignClients
④去到商品服务中编写要远程调用的方法
-
SkuSaleAttrValueController
-
package com.atguigu.gulimall.product.app; public class SkuSaleAttrValueController { @Autowired private SkuSaleAttrValueService skuSaleAttrValueService; @GetMapping("/stringlist/{skuId}") public List<String> getSkuSaleAttrValues(@PathVariable("skuId") Long skuId){ return skuSaleAttrValueService.getSkuSaleAttrValueAsStringList(skuId); } }
-
-
SkuSaleAttrValueService
-
package com.atguigu.gulimall.product.service; List<String> getSkuSaleAttrValueAsStringList(Long skuId);
-
-
SkuSaleAttrValueServiceImpl
-
package com.atguigu.gulimall.product.service.impl; @Override public List<String> getSkuSaleAttrValueAsStringList(Long skuId) { SkuSaleAttrValueDao dao = this.baseMapper; return dao.getSkuSaleAttrValuesAsStringList(skuId); }
-
-
SkuSaleAttrValueDao
-
List<String> getSkuSaleAttrValuesAsStringList(@Param("skuId")Long skuId);
-
-
SkuSaleAttrValueDao.xml
-
<select id="getSkuSaleAttrValuesAsStringList" resultType="java.lang.String"> select concat(attr_name,":",attr_value) from `pms_sku_sale_attr_value` where sku_id = #{skuId} </select>
-
⑤配置线程池 提高远程服务查询效率
这个我们可以直接复制商品服务写好的即可。
-
MyThreadConfig
-
@Configuration public class MyThreadConfig { @Bean public ThreadPoolExecutor threadPoolExecutor(ThreadPoolConfigProperties pool){ return new ThreadPoolExecutor(pool.getCoreSize(), pool.getMaxSize(),pool.getKeepAliveTime(), TimeUnit.SECONDS,new LinkedBlockingDeque<>(100000), Executors.defaultThreadFactory(), new ThreadPoolExecutor.AbortPolicy()); } }
-
-
ThreadPoolConfigProperties
-
@ConfigurationProperties(prefix = "gulimall.thread") @Component @Data public class ThreadPoolConfigProperties { private Integer coreSize; private Integer maxSize; private Integer keepAliveTime; }
-
-
application.properties
-
gulimall.thread.core-size=20 gulimall.thread.max-size=100 gulimall.thread.keep-alive-time=10
-
⑥异步编排
-
SkuInfoVo(将SkuInfoEntity这个复制过来就行)—因为我们远程调用返回的类型需要这个,所以直接放在购物车微服务中
-
@Data public class SkuInfoVo { /** * skuId */ private Long skuId; /** * spuId */ private Long spuId; /** * sku名称 */ private String skuName; /** * sku介绍描述 */ private String skuDesc; /** * 所属分类id */ private Long catalogId; /** * 品牌id */ private Long brandId; /** * 默认图片 */ private String skuDefaultImg; /** * 标题 */ private String skuTitle; /** * 副标题 */ private String skuSubtitle; /** * 价格 */ private BigDecimal price; /** * 销量 */ private Long saleCount; }
-
-
异步编排
-
CartService
-
//将商品添加到购物车 CartItem addToCart(Long skuId,Integer num) throws ExecutionException,InterruptedException;
-
-
CatrServiceImpl
-
public class CatrServiceImpl implements CartService { @Autowired ThreadPoolExecutor executor; @Override public CartItem addToCart(Long skuId, Integer num) throws ExecutionException, InterruptedException { //获取用户购物车数据 BoundHashOperations<String, Object, Object> cartOps = getCartOps(); // 判断商品是否存在 String res = (String) cartOps.get(skuId.toString()); if(StringUtils.isEmpty(res)){ //购物车中没有此商品 CartItem cartItem = new CartItem(); //异步编排 CompletableFuture<Void> getSkuInfoTask = CompletableFuture.runAsync(() -> { //商品不存在,则添加商品信息 //1.远程调用商品服务查询商品详情 R skuInfo = productFeignService.getSkuInfo(skuId); SkuInfoVo data = skuInfo.getData("skuInfo", new TypeReference<SkuInfoVo>() { }); //2.添加商品到购物车 cartItem.setCheck(true); cartItem.setCount(num); cartItem.setImage(data.getSkuDefaultImg()); cartItem.setTitle(data.getSkuTitle()); cartItem.setSkuId(skuId); cartItem.setPrice(data.getPrice()); }, executor); //3.远程查询sku的组合信息 //同时调用多个远程服务,为了不影响最终的查询速度,我们使用多线程的方式,用我们自定义的线程池提高效率 CompletableFuture<Void> getSkuSaleAttrValues = CompletableFuture.runAsync(() -> { List<String> values = productFeignService.getSkuSaleAttrValues(skuId); cartItem.setSkuAttr(values); }, executor); //等待getSkuSaleAttrValues 和 getSkuInfoTask 都完成,否则会报空指针异常 CompletableFuture.allOf(getSkuInfoTask,getSkuSaleAttrValues).get(); String s = JSON.toJSONString(cartItem); //加入购物车 cartOps.put(key,value); cartOps.put(skuId.toString(),s); return cartItem; } } }
-
⑦启动测试
- 以前编写的逻辑代码出现语法用法问题
⑧完善购物车细节
- 上面的代码中,添加购物车我们默认是购物车中没有此商品,但是如果购物车中有此商品呢?没有是新建,有的话就应该是修改数量了。这个细节我们需要进行修改。
-
我们目前的商城页面还存在一个问题。某个商品下加入购物车这个如果我们使用新标签页打开链接,重复刷新我们会发现,购物车该商品的数量会一直变化。这就导致了一个bug,如果有人拿到这个链接,或者用户自己操作这个链接,本来只想要1个的,操作之后变为2个,用户体验等大大下降。所以我们为了解决这个问题可以参考京东的做法,就是我们通过重定向携带数据方式。
当controller层需要重定向到指定页面时,如何携带数据?
-
传统使用session
-
使用RedirectAttributes. (利用session原理)【提供了addFlashAttribute 等方法.确保数据只能被使用一次后删除】
- 使用:
- 直接在Controller的参数中添加RedirectAttributes.
- addFlashAttribute会在重定向到下一个页面取出这个数据以后,将session里面的数据删除
- addFlashAttribute 方法会将数据存储在session中,访问一次后失效
- addAttribute 方法会将数据拼接在url后(get的形式)
- 使用:
-
RedirectAttributes的addFlashAttribut()方法:将对象存储在Session中且只能使用一次,再次刷新就没有了
RedirectAttributes的addAttribut()方法:将对象拼接在url中。
-
CartController
-
/** * 添加商品到购物车 * 防止用户恶意刷新,可以使用重定向的办法,本方法不跳转页面,只是执行完业务代码后,跳转到别的方法,让那个方法跳转页面 * redirectAttributes.addFlashAttribute() 将数据保存在session里面可以在页面取出,但是只能取一次 * redirectAttributes.addAttribute("skuId", skuId); 将数据放在URL后面 */ @GetMapping("/addToCart") public String addToCart(@RequestParam("skuId") Long skuId,@RequestParam("num") Integer num ,RedirectAttributes ra) throws ExecutionException, InterruptedException { cartService.addToCart(skuId,num); //将数据拼接在url后 ra.addAttribute("skuId",skuId); //重定向到某个页面,由这个页面跳转到成功页面 return "redirect:http://cart.gulimall.com/addToCartSuccess.html"; } /** * 跳转到成功页面 * 获取购物车中某个购物项 */ @GetMapping("/addToCartSuccess.html") public String addToCartSuccessPage(@RequestParam("skuId")Long skuId, Model model){ //重定向到成功页面,再次从Redis查询购物车数据即可 CartItem item = cartService.getCartItem(skuId); //放到请求域中 model.addAttribute("item",item); //跳转到成功页面 return "success"; }
-
-
对于上面的操作,我们让加入购物车之后不是跳转,而变为重定向,通过重定向进行页面再次跳转
-
CartService
-
//获取购物车中某个购物项 CartItem getCartItem(Long skuId);
-
-
CatrServiceImpl
-
/** * 获取购物车中的商品 * @param skuId * @return */ @Override public CartItem getCartItem(Long skuId) { BoundHashOperations<String, Object, Object> cartOps = getCartOps(); String item = (String) cartOps.get(skuId.toString()); CartItem cartItem = JSON.parseObject(item, CartItem.class); return cartItem; }
-
⑨前端页面完善
1.8.7 获取&合并购物车
- 购物车列表展示逻辑:首先判断是否登录,没有登录则展示临时购物车,若登录则展示合并后的购物车,将临时购物 车合并后并清空
1)、编写获取购物车的方法
-
CatrServiceImpl
-
/** * 购物车项展示 */ private List<CartItem> getCartItems(String cartKey){ BoundHashOperations<String, Object, Object> hashOps = redisTemplate.boundHashOps(cartKey); //获取所有商品数据 List<Object> values = hashOps.values(); if(values!=null && values.size() > 0){ List<CartItem> collect = values.stream().map((obj) -> { String str = (String) obj; CartItem cartItem = JSON.parseObject(str, CartItem.class); return cartItem; }).collect(Collectors.toList()); return collect; } return null; }
-
2)、编写删除临时用户购物车的方法
-
CartService
-
//清空购物车数据 void clearCart(String cartKey);
-
-
CatrServiceImpl(直接使用redis的原生delete操作即可)
-
@Override public void clearCart(String cartKey) { redisTemplate.delete(cartKey); }
-
3)、合并购物车,合并完之后要删除临时购物车
-
CartService
-
//获取整个购物车 Cart getCart() throws ExecutionException,InterruptedException;
-
-
CatrServiceImpl
-
/** * 获取整个购物车 * @return * @throws ExecutionException * @throws InterruptedException */ @Override public Cart getCart() throws ExecutionException, InterruptedException { Cart cart = new Cart(); UserInfoTo userInfoTo = CartInterceptor.threadLocal.get(); if(userInfoTo.getUserKey() != null){ //1.登录 String cartKey = CART_PREFIX + userInfoTo.getUserId(); //2.如果临时购物车的数据还没有,进行合并 合并购物车 String temCartKey = CART_PREFIX + userInfoTo.getUserKey(); List<CartItem> temCartItems = getCartItems(temCartKey); if(temCartItems != null){ //临时购物车有数据,需要进行合并 for (CartItem item : temCartItems) { addToCart(item.getSkuId(), item.getCount()); } //清楚临时购物车的数据 clearCart(temCartKey); } //3.获取登录后的购物车的数据(包含合并过来的临时购物车的数据和登录后的购物车的数据) List<CartItem> cartItems = getCartItems(cartKey); cart.setItems(cartItems); }else { //4.没登录 String cartKey = CART_PREFIX + userInfoTo.getUserKey(); //获取临时购物车的所有购物项 List<CartItem> cartItems = getCartItems(cartKey); cart.setItems(cartItems); } return cart; }
-
4)、完善前端页面
① 登录回显
<ul class="header-right">
<li>
<a th:if="${session.loginUser == null }" href="http://auth.gulimall.com/login.html"
class="li_2">你好,请登录</a>
<a th:if="${session.loginUser != null }" style="width: 100px">[[${session.loginUser.nickname}]]</a>
</li>
<li>
<a th:if="${session.loginUser == null }" href="http://auth.gulimall.com/reg.html">免费注册</a>
</li>
<li class="spacer"></li>
<li><a href="">我的订单</a></li>
<li class="spacer"></li>
</ul>
② 购物车是否有数据的逻辑判断
③ 购物车中商品遍历
④ 是否被选中、图片展示、标题展示、销售属性展示、格式化商品单价展示、商品数量展示、商品总价显示
<div class="One_ShopCon">
<h1 th:if="${cart.items == null}">
购物车还没有商品,<a href="http://gulimall.com">去购物</a>
</h1>
<ul th:if="${cart.items != null}">
<li th:each="item : ${cart.items}">
<div>
</div>
<div>
<ol>
<li><input type="checkbox" th:attr="skuId=${item.skuId}" class="itemCheck" th:checked="${item.check}"></li>
<li>
<dt><img th:src="${item.image}" alt=""></dt>
<dd style="width: 300px">
<p>
<span th:text="${item.title}">TCL 55A950C 55英寸32核</span>
<br/>
<span th:each="attr:${item.skuAttr}" th:text="${attr}">尺码: 55时 超薄曲面 人工智能</span>
</p>
</dd>
</li>
<li>
<p class="dj" th:text="'¥'+${#numbers.formatDecimal(item.price,3,2)}">¥4599.00</p>
</li>
<li>
<p th:attr="skuId=${item.skuId}">
<span class="countOpsBtn">-</span>
<span class="countOpsNum" th:text="${item.count}">5</span>
<span class="countOpsBtn">+</span>
</p>
</li>
<li style="font-weight:bold"><p class="zj">¥[[${#numbers.formatDecimal(item.totalPrice,3,2)}]]</p></li>
<li>
<p class="deleteItemBtn" th:attr="skuId=${item.skuId}">删除</p>
</li>
</ol>
</div>
</li>
</ul>
</div>
⑤购物车总价显示、优惠价格显示
<div>
<ol>
<li>总价:<span style="color:#e64346;font-weight:bold;font-size:16px;" class="fnt">¥[[${#numbers.formatDecimal(cart.totalAmount,3,2)}]]</span>
</li>
<li>优惠:[[${#numbers.formatDecimal(cart.reduce,1,2)}]]</li>
</ol>
</div>
⑥ 登录与未登录页面展示效果不同
登录之后不显示“你还没有登录。。。。。” ,没有登录则显示,并且可以跳转去登录页面。
⑦效果展示
1.8.8 选中购物项(单选框)
1)、 页面修改
-
首先为input框设置class方便后续绑定单击事件修改选中状态,自定义属性保存skuId
-
-
单击事件编写 ,选中单选框,类型设置为true,否则为fasle. prop会返回true或false
-
$(".itemCheck").click(function (){ var skuId = $(this).attr("skuId"); var check = $(this).prop("checked");//使用prop获取到的是 true、false;使用 attr获取到的就是checked:这里需要使用prop location.href="http://cart.gulimall.com/checkItem?skuId="+skuId+"&check="+(check?1:0);// check = 1:true;check = 0:false });
-
-
-
2)、编写请求
-
CartController
-
//勾选购物项 @GetMapping("/checkItem") public String checkItem(@RequestParam("skuId")Long skuId, @RequestParam("check")Integer check){ cartService.checkItem(skuId,check); return "redirect:http://cart.gulimall.com/cart.html"; }
-
-
CartService
-
void checkItem(Long skuId, Integer check);
-
-
CatrServiceImpl
-
@Override public void checkItem(Long skuId, Integer check) { BoundHashOperations<String, Object, Object> cartOps = getCartOps(); //获取单个商品 CartItem cartItem = getCartItem(skuId); cartItem.setCheck((check==1?true:false)); String s = JSON.toJSONString(cartItem); //序列化进Redis中 cartOps.put(skuId.toString(),s); }
-
1.8.9 改变购物项数量
1)、页面修改
- 为父标签自定义属性存储skuId,为加减操作设置相同的class,为数量设置class
-
<li> <p th:attr="skuId=${item.skuId}"> <span class="countOpsBtn">-</span> <span class="countOpsNum" th:text="${item.count}">5</span> <span class="countOpsBtn">+</span> </p> </li>
-
编写加减的单击事件
-
$(".countOpsBtn").click(function () { //1、skuId var skuId = $(this).parent().attr("skuId"); var num = $(this).parent().find(".countOpsNum").text(); location.href="http://cart.gulimall.com/countItem?skuId="+skuId+"&num="+num; });
-
2)、编写请求
-
Controller
-
//修改购物车数量 @GetMapping("/countItem") public String countItem(@RequestParam("skuId")Long skuId,@RequestParam("num") Integer num){ cartService.changeItemCount(skuId,num); return "redirect:http://cart.gulimall.com/cart.html"; }
-
-
CartService
-
void changeItemCount(Long skuId, Integer num);
-
-
CatrServiceImpl
-
@Override public void changeItemCount(Long skuId, Integer num) { CartItem cartItem = getCartItem(skuId); cartItem.setCount(num); //根据当前登录状态获取购物车 BoundHashOperations<String, Object, Object> cartOps = getCartOps(); //保存金redis中(需要序列化保存)skuId转为string作为key,商品序列化后的文本作为值 cartOps.put(skuId.toString(),JSON.toJSONString(cartItem)); }
-
1.8.10 删除购物项
1)、页面修改
为图中的删除按钮设置class,绑定单击事件临时保存skuId
本来应该是有一个x号退出删除操作的,这个我们先不做。
-
//全局变量 var deleteId = 0; //删除购物项 function deleteItem(){ location.href = "http://cart.gulimall.com/deleteItem?skuId="+deleteId; }
2)、编写请求
-
CartController
-
//删除购物项 @GetMapping("/deleteItem") public String deleteItem(@RequestParam("skuId") Long skuId){ cartService.deleteItem(skuId); //删除完了之后重新跳转到此页面,相当于刷新获取最新的内容 return "redirect:http://cart.gulimall.com/cart.html"; }
-
-
CartService
-
void deleteItem(Long skuId);
-
-
CatrServiceImpl
-
@Override public void deleteItem(Long skuId) { //根据当前状态获取购物车 BoundHashOperations<String, Object, Object> cartOps = getCartOps(); //利用skuid进行删除 cartOps.delete(skuId.toString()); }
-
1.9 订单服务
1.9.1 环境搭建
1)、动静分离
2)、修改hosts
3)、html页面完善
src="" href=""
这些都加入/static/order/
等各自对应的路径- 有些错误自己进行修改即可
4)、配置网关
- id: gulimall_order_route
uri: lb://gulimall-order
predicates:
- Host=order.gulimall.com
5)、注册到nacos
spring.cloud.nacos.discovery.server-addr=127.0.0.1:8848
spring.application.name=gulimall-order
主启动类上加入@EnableDiscoveryClient
服务注册发现功能注解
6)、导入thymeleaf并禁用缓存
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-thymeleaf</artifactId>
</dependency>
spring.thymeleaf.cache=false
7)、编写Controller访问订单页面
@Controller
public class HelloController {
@GetMapping("/{page}.html")
public String listPage(@PathVariable("page") String page){
return page;
}
}
这个主要是为了测试查看我们的页面是否可以正常访问。
- 测试访问各个页面:
- 出现错误:confirm.html 报 Unfinished block structure
- 解决方案: 将/*删除即可
- 最后能够成功访问各个页面。
1.9.2 整合SpringSession
1)、redis相关配置
- Redis默认使用lettuce作为客户端可能导致内存泄漏,因此需要排除lettuce依赖,使用jedis作为客户端或者使用 高版本的Redis依赖即可解决内存泄漏问题。
<!--引入redis-->
<!-- https://mvnrepository.com/artifact/org.springframework.boot/spring-boot-starter-data-redis -->
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-data-redis</artifactId>
<!--排除使用lettuce ,改为使用jedis-->
<exclusions>
<exclusion>
<groupId>io.lettuce</groupId>
<artifactId>lettuce-core</artifactId>
</exclusion>
</exclusions>
</dependency>
-
spring.redis.host=192.168.56.10 spring.redis.port=6379
2) 、配置session
-
导入session依赖
-
<!--spring整合session--> <dependency> <groupId>org.springframework.session</groupId> <artifactId>spring-session-data-redis</artifactId> </dependency>
-
-
配置session的存储类型
-
spring.session.store-type=redis
-
-
编写session的配置类(我们直接导入以前写过的就行)
GulimallSessionConfig
@Configuration
public class GulimallSessionConfig {
//子域共享问题解决
@Bean
public CookieSerializer cookieSerializer(){
DefaultCookieSerializer cookieSerializer = new DefaultCookieSerializer();
//将session作用域设置为不仅仅只限于子域名,而是整个父域名。这样所有子域名都可以获得一样的cookie
cookieSerializer.setDomainName("gulimall.com");
cookieSerializer.setCookieName("GULISESSION");
return cookieSerializer;
}
//使用json序列化方式来序列化对象数据到redis中
@Bean
public RedisSerializer<Object> springSessionDefaultRedisSerializer(){
return new GenericJackson2JsonRedisSerializer();
}
}
- 主启动类上加入@EnableRedisHttpSession
3)、线程池配置
MyThreadConfig
@Configuration
public class MyThreadConfig {
@Bean
public ThreadPoolExecutor threadPoolExecutor(ThreadPoolConfigProperties pool){
return new ThreadPoolExecutor(pool.getCoreSize(),
pool.getMaxSize(),pool.getKeepAliveTime(),
TimeUnit.SECONDS,new LinkedBlockingDeque<>(100000),
Executors.defaultThreadFactory(),
new ThreadPoolExecutor.AbortPolicy());
}
}
-
线程池配置
-
gulimall.thread.core-size=20 gulimall.thread.max-size=100 gulimall.thread.keep-alive-time=10
-
4)、登录回显
我们在订单等这些新引入的页面中需要将我们的登录状态进行回显,所以我们接下来进行编写。
[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-dweXztUh-1673533218194)(null)]
1.9.3 订单基本概念
1)、订单中心
2)、订单状态
3)、订单流程
4)、幂等性处理
参照幂等性文档。
1.9.4 订单登录拦截
1)、处理去结算请求
- 点击去结算 跳转到订单详情页面进行结算。
- 购物车页面(cartList.html)
修改连接。当我们点击去跳转的时候实际上发送的请求是http://order.gulimall.com/toTrade
所以我们接下来就要编写代码处理这个请求。
- 新建一个OrderWebController,专门来处理订单微服务中的各种请求处理
@Controller
public class OrderWebController {
@Autowired
OrderService orderService;
@GetMapping("/toTrade")
public String toTrade(){
return "confirm";
}
}
2)、登录检查
-
只有登录状态下的用户才可以去结算,因为结算页面涉及到地址等信息,所以我们必须要是登录状态下才行。未登录的用户我们需要编写一个拦截器来拦截,让其登录后来进行结算。
-
LoginUserInterceptor
@Component
public class LoginUserInterceptor implements HandlerInterceptor {
public static ThreadLocal<MemberRespVo> loginUser = new ThreadLocal<>();
@Override
public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) throws Exception {
//获取登录用户
MemberRespVo attribute = (MemberRespVo) request.getSession().getAttribute(AuthServerConstant.LOGIN_USER);
if (attribute != null) {
//登录成功后,将用户信息存储至ThreadLocal中方便其他服务获取用户信息;
//加上ThreadLocal共享数据,是为了登录后把用户放到本地内存,而不是每次都去远程session里面查
loginUser.set(attribute);
return true;
} else {
//没登录就去登录
request.getSession().setAttribute("msg", "请先进行登录");
response.sendRedirect("http://auth.gulimall.com/login.html");
return false;
}
}
}
- 拦截器还需要进行一些配置才可以生效
@Configuration
public class OrderWebConfiguration implements WebMvcConfigurer {
@Autowired
LoginUserInterceptor interceptor;
@Override
public void addInterceptors(InterceptorRegistry registry) {
//配置LoginUserInterceptor拦截器拦截所有请求
registry.addInterceptor(interceptor).addPathPatterns("/**");
}
}
- 未登录消息提醒 (回显)
1.9.5 订单确认页模型抽取编写
1)购物车价格小bug修改
- 购物车计算价格存在小bug,未选中的商品不应该加入总价的计算
- 修改 购物车服务vo 包下的 Cart
加入判断如果被勾选了,才去计算总价格。
2)、订单确认页的数据编写
-
用户地址信息来源于
ums_member_receive_address
这张表 -
订单确认页面商品的价格,我们要获取最新的价格,而不应该是加入购物车时的价格
- 优惠券信息 ,使用京豆的形式增加用户的积分
- 订单总额和应付总额信息
[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-OZ73GaHn-1673533259886)(null)]
3)、编写vo
- MemberAddressVo
package com.atguigu.gulimall.order.vo;
/**
* @author hxld
* @create 2022-12-22 15:45
*/
@Data
public class MemberAddressVo {
/**
* id
*/
private Long id;
/**
* member_id
*/
private Long memberId;
/**
* 收货人姓名
*/
private String name;
/**
* 电话
*/
private String phone;
/**
* 邮政编码
*/
private String postCode;
/**
* 省份/直辖市
*/
private String province;
/**
* 城市
*/
private String city;
/**
* 区
*/
private String region;
/**
* 详细地址(街道)
*/
private String detailAddress;
/**
* 省市区代码
*/
private String areacode;
/**
* 是否默认
*/
private Integer defaultStatus;
}
- OrderItemVo
package com.atguigu.gulimall.order.vo;
/**
* @author hxld
* @create 2022-12-22 15:48
*/
@Data
public class OrderItemVo {
private Long skuId;
private Boolean check; //是否被选中
private String title; //标题
private String image; //图片
private List<String> skuAttr; //销售属性组合描述
private BigDecimal price; //商品单价
private Integer count; //商品数量
private BigDecimal totalPrice; //总价,总价需要计算
private BigDecimal weight; //重量
}
- OrderConfirmVo
//订单确认页需要用的数据
@Data
public class OrderConfirmVo {
// 收货地址,ums_member_receive_address 表
List<MemberAddressVo> address;
//所有选中的购物项
List<OrderItemVo> items;
//发票记录...
//优惠券信息...
Integer integration;
BigDecimal total;
//订单总额
BigDecimal payPrice;
//应付价格
}
4)、编写业务代码
- 完善OrderWebController
@Controller
public class OrderWebController {
@Autowired
OrderService orderService;
@GetMapping("/toTrade")
public String toTrade(Model model, HttpServletRequest request) throws ExecutionException, InterruptedException {
OrderConfirmVo confirmVo = orderService.confirmOrder();
model.addAttribute("orderConfirmData",confirmVo);
//展示订单确认的数据
return "confirm";
}
}
- OrderService
/**
* 订单确认页返回需要用的数据
* @return
*/
OrderConfirmVo confirmOrder();
service实现类的代码我们在后面会详细进行编写,因为涉及到多个远程调用。
1.9.6 订单确认页数据获取
1)、远程查询会员地址准备
会员服务
- MemberReceiveAddressController
/**
* 根据用户id查询用户地址信息
* @param memberId
* @return
*/
@GetMapping("/{memberId}/addresses")
public List<MemberReceiveAddressEntity> getAddress(@PathVariable("memberId") Long memberId){
return memberReceiveAddressService.getAddress(memberId);
}
- MemberReceiveAddressServiceImpl
/**
* 查出对应会员id的地址
* @param memberId
* @return
*/
@Override
public List<MemberReceiveAddressEntity> getAddress(Long memberId) {
return this.list(new QueryWrapper<MemberReceiveAddressEntity>().eq("member_id",memberId));
}
2)、远程调用查询会员地址
订单服务
- MemberFeignService
@FeignClient("gulimall-member")
public interface MemberFeignService {
@GetMapping("/member/memberreceiveaddress/{memberId}/addresses")
List<MemberAddressVo> getAddress(@PathVariable("memberId") Long memberId);
}
- 开启远程调用功能
主启动类上加入注解:@EnableFeignClients
3)、远程查询商品的最新价格准备
商品服务
- SkuInfoController
/**
* 获取商品的最新价格
* @param skuId
* @return
*/
@GetMapping("/{skuId}/price")
public R getPrice(@PathVariable("skuId")Long skuId){
SkuInfoEntity byId = skuInfoService.getById(skuId);
return R.ok().setData(byId.getPrice().toString());
}
4)、远程查询商品的最新价格
购物车服务
- ProductFeignService
@GetMapping("/product/skuinfo/{skuId}/price")
R getPrice(@PathVariable("skuId")Long skuId);
5)、远程查询实时购物车准备
结算页面要获取当前购物车。
购物车服务
- CartController
@GetMapping("/currentUserCartItems")
public List<CartItem> getCurrentUserCartItems(){
return cartService.getCurrentUserCartItems();
}
- CartServiceImpl
@Override
public List<CartItem> getCurrentUserCartItems() {
UserInfoTo userInfoTo = CartInterceptor.threadLocal.get();
if(userInfoTo.getUserId() == null){
return null;
}else {
String cartKey = CART_PREFIX + userInfoTo.getUserId();
List<CartItem> cartItems = getCartItems(cartKey);
//获取所有被选中的购物项
List<CartItem> collect = cartItems.stream().filter(item -> item.getCheck()).map(item -> {
R price = productFeignService.getPrice(item.getSkuId());
//todo 1 更新为最新价格
String data = (String) price.get("data");
item.setPrice(new BigDecimal(data));
return item;
}).collect(Collectors.toList());
return collect;
}
}
6)、远程查询实时购物车
订单服务
- CartFeignService
@FeignClient("gulimall-cart")
public interface CartFeignService {
@GetMapping("/currentUserCartItems")
List<OrderItemVo> getCurrentUserCartItems();
}
7)、动态计算价格
为了防止用户重复提交订单,我们还需要编写一个令牌。
- OrderConfirmVo
//订单确认页需要用的数据
// @Data
public class OrderConfirmVo {
//收货地址
@Setter @Getter
List<MemberAddressVo> address;
//所有选中的购物项
@Setter @Getter
List<OrderItemVo> items;
//发票记录。。。。
//优惠券信息....
@Setter @Getter
Integer integration;
//防止重复令牌(防止因为网络原因,用户多次点击提交订单,造成多次提交)
@Getter @Setter
String orderToken;
// BigDecimal total; //订单总额
public BigDecimal getTotal(){
BigDecimal sum = new BigDecimal("0");
if(items != null){
for (OrderItemVo item : items) {
BigDecimal multiply = item.getPrice().multiply(new BigDecimal(item.getCount().toString()));
sum = sum.add(multiply);
}
}
return sum;
}
// BigDecimal payPrice; //应付价格
public BigDecimal getPayPrice(){
return getTotal();
}
}
1.9.7 订单确认页请求完成
- OrderWebController
@GetMapping("/toTrade")
public String toTrade(Model model, HttpServletRequest request) throws ExecutionException, InterruptedException {
OrderConfirmVo confirmVo = orderService.confirmOrder();
model.addAttribute("orderConfirmData",confirmVo);
//展示订单确认的数据
return "confirm";
}
- OrderServiceImpl
@Override
public OrderConfirmVo confirmOrder() {
OrderConfirmVo confirmVo = new OrderConfirmVo();
MemberRespVo memberRespVo = LoginUserInterceptor.loginUser.get();
//1、远程查询所有的收货地址列表
List<MemberAddressVo> address = memberFeignService.getAddress(memberRespVo.getId());
confirmVo.setAddress(address);
//2、远程查询购物车所有选中的购物项
List<OrderItemVo> items = cartFeignService.getCurrentUserCartItems();
confirmVo.setItems(items);
//3、查询用户积分
Integer integration = memberRespVo.getIntegration();
confirmVo.setIntegration(integration);
//4、其他数据自动计算
//TODO 5、防重令牌(幂等性章节)
return confirmVo;
}
1.9.8 Feign远程调用丢失请求头问题
feign远程调用丢失请求头
- feign源码分析
- targetRequest() 构造出了一个request对象,而最终的response就是这个request请求的执行结果。
- GuliFeignConfig
@Configuration
public class GuliFeignConfig {
@Bean("requestInterceptor")
public RequestInterceptor requestInterceptor(){
return new RequestInterceptor() {
@Override
public void apply(RequestTemplate template) {
// 拦截器和toTrade接口同为一个线程,可以使用threadlocal将请求头信息进行线程共享。
// spring给我们提供了RequestContextHolder请求上下文保持器,底层也是对threadlocal进行封装
//1.RequestContextHolder拿到刚进来的这个请求
ServletRequestAttributes attributes = (ServletRequestAttributes) RequestContextHolder.getRequestAttributes();
HttpServletRequest request = attributes.getRequest(); //老请求 远程调用前的准备工作
// 同步请求数据,新请求远程调用建立的request请求,将老请求中的请求头信息同步到新请求
String cookie = request.getHeader("Cookie");
//给新请求同步了老请求的cookie
template.header("Cookie",cookie);
}
};
}
}
1.9.9 Feign异步调用丢失上下文的问题
1)、注入线程池
2)、使用异步编排
- 各个任务彼此之间互不相关,但是需要等待各个任务处理完成
@Override
public OrderConfirmVo confirmOrder() {
OrderConfirmVo confirmVo = new OrderConfirmVo();
MemberRespVo memberRespVo = LoginUserInterceptor.loginUser.get();
CompletableFuture<Void> getAddressFuture = CompletableFuture.runAsync(() -> {
//1、远程查询所有的收货地址列表
List<MemberAddressVo> address =
memberFeignService.getAddress(memberRespVo.getId());
confirmVo.setAddress(address);
}, executor);
CompletableFuture<Void> cartFuture = CompletableFuture.runAsync(() -> {
//2、远程查询购物车所有选中的购物项
List<OrderItemVo> items = cartFeignService.getCurrentUserCartItems();
confirmVo.setItems(items);
//feign在远程调用之前要构造请求,调用很多的拦截器
//RequestInterceptor interceptor :requestInterceptors
}, executor);
//3、查询用户积分
Integer integration = memberRespVo.getIntegration();
confirmVo.setIntegration(integration);
//4、其他数据自动计算
//TODO 5、防重令牌(幂等性章节)
CompletableFuture.allOf(getAddressFuture,cartFuture).get(); //等待所有结果完成
return confirmVo;
}
3)、问题及解决方案
- 出现问题: 异步任务执行远程调用时会丢失请求上下文,oldRequest会为null
此外:远程获取价格的时候应该用R。
- 购物车服务
CartServiceImpl
- 商品服务
SkuInfoController
1.9.10 订单确认页渲染
1)、收货人信息回显
2)、商品项信息回显
- 添加两项新属性
3)、商品总件数、总金额、应付金额回显
-
OrderConfirmVo :总件数计算
//总件数 public Integer getCount(){ Integer i = 0; if(items != null){ for (OrderItemVo item : items) { i += item.getCount(); } } return i; }
1.9.11 订单确认页库存查询
1)、查询库存方法
-
库存服务中查询库存的方法我们在以前就已经编写好了
-
WareSkuController
/** * 查询sku是否有库存 */ @PostMapping("/hasstock") public R getSkuHasStock(@RequestBody List<Long> skuIds){ //sku_id,stock List<SkuHasStockVo> vos = wareSkuService.getSkuHasStock(skuIds); return R.ok().setData(vos); }
2)、远程查询商品的库存
-
订单服务下
-
创建一个WmsFeignService,专门用于远程调用库存微服务的接口
@FeignClient("gulimall-ware")
public interface WmsFeignService {
//查询sku 是否有库存
@PostMapping("/ware/waresku/hasstock")
R getSkuHasStock(@RequestBody List<Long> skuIds);
3)、编写vo
- SkuStockVo
package com.atguigu.gulimall.order.vo;
@Data
public class SkuStockVo {
private Long skuId;
private Boolean hasStock;
}
4)、编写异步任务查询库存信息
- 在OrderConfirmVo中新增一个map属性用于封装库存信息
public class OrderConfirmVo {
//库存
@Getter @Setter
Map<Long,Boolean> stocks;
}
- 完善 OrderServiceImpl
@Override
public OrderConfirmVo confirmOrder() throws ExecutionException, InterruptedException {
// 封装订单
OrderConfirmVo confirmVo = new OrderConfirmVo();
// 获取用户,用用户信息获取购物车
MemberRespVo memberRespVo = LoginUserInterceptor.loginUser.get();
// System.out.println("主线程..."+Thread.currentThread().getId());
/*** 我们要从request里获取用户数据,但是其他线程是没有这个信息的,
*所以可以手动设置新线程里也能共享当前的request数据
*/
//获取之前的请求
RequestAttributes requestAttributes = RequestContextHolder.getRequestAttributes();
CompletableFuture<Void> getAddressFuture = CompletableFuture.runAsync(() -> {
//1、远程查询所有的收货地址列表
// System.out.println("member线程..."+Thread.currentThread().getId());
//每一个线程都来共享之前的请求数据
//因为异步线程需要新的线程,而新的线程里没有request数据,所以我们自己设置进去
RequestContextHolder.setRequestAttributes(requestAttributes);
List<MemberAddressVo> address =
memberFeignService.getAddress(memberRespVo.getId());
confirmVo.setAddress(address);
}
, executor);
CompletableFuture<Void> cartFuture = CompletableFuture.runAsync(() -> {
//2、远程查询购物车所有选中的购物项
System.out.println("cart线程..."+Thread.currentThread().getId());
//每一个线程都来共享之前的请求数据
RequestContextHolder.setRequestAttributes(requestAttributes);
List<OrderItemVo> items = cartFeignService.getCurrentUserCartItems();
confirmVo.setItems(items);
//feign在远程调用之前要构造请求,调用很多的拦截器
//RequestInterceptor interceptor :requestInterceptors
}
, executor).thenRunAsync(()->{
List<OrderItemVo> items = confirmVo.getItems();
List<long> collect = items.stream().map(item ->
item.getSkuId()).collect(Collectors.toList());
// TODO 一定要启动库存服务,否则库存查不出
R hasStock = wmsFeignService.getSkuHasStock(collect);
List<SkuStockVo> data = hasStock.getData(new TypeReference<List<SkuStockVo>>() {
}
);
if (data != null){
Map<long, Boolean> map =
data.stream().collect(Collectors.toMap(SkuStockVo::getSkuId, SkuStockVo::getHasStock));
confirmVo.setStocks(map);
}
}
,executor);
//3、查询用户积分
Integer integration = memberRespVo.getIntegration();
confirmVo.setIntegration(integration);
//4、其他数据自动计算
//TODO 5、防重令牌(幂等性章节)
CompletableFuture.allOf(getAddressFuture,cartFuture).get();
//等待所有结果完成
return confirmVo;
}
5)、库存信息回显
<span>[[${orderConfirmData.stocks[item.skuId]?"有货":"无货"}]]</span></p>
- 出现空指针异常:无需共享数据就不用做以下操作了
- 测试:
- 注意,想要显示有货,数据库 wms_ware_sku表中这两个字段必须有值才行。
1.9.12 订单确认页模拟运费效果
1)、远程查询收货地址
- 库存微服务下
- 新建一个MemberFeignService接口专门用做查询用户的收货地址信息
package com.atguigu.gulimall.ware.feign;
@FeignClient("gulimall-member")
public interface MemberFeignService {
@RequestMapping("/member/memberreceiveaddress/info/{id}")
R addrInfo(@PathVariable("id") Long id);
}
这个远程获取地址的方法在 会员服务 MemberReceiveAddressController下已经写过了,所以可以直接使用。
2)、获取运费
-
WareInfoController
package com.atguigu.gulimall.ware.controller; /** * 根据用户的收货地址计算运费 * @param addrId * @return */ @GetMapping("/fare") public R getFare(@RequestParam("addrId") Long addrId){ FareVo fare = wareInfoService.getFare(addrId); //这里的代码是下面经过改良之后的,因为目前为止farevo还没有被创建 return R.ok().setData(fare); }
-
WareInfoServiceImpl
@Override public BigDecimal getFare(long addrId) { R r = memberFeignService.addrInfo(addrId); MemberAddressVo data = r.getData("memberReceiveAddress",new TypeReference<MemberAddressVo>() { } ); if (data != null){ String phone = data.getPhone(); // 12345678918 8:截取手机号的最后一位当做运费,实际上应该是接入第三方物流作为接口。这里只是 String substring = phone.substring(phone.length() - 1, phone.length()); return new BigDecimal(substring); 简单模拟一下。 } } return null;
3)、页面优化
地址高亮显示
- 为div绑定class方便被函数方法调用,自定义def属性存储默认地址值,默认地址为1,否则为0;自定义属性存储地址Id
- 函数调用
运费回显
应付总额回显
为p标签绑定单击事件
默认收货地址运费查询回显
效果展示
1.9.13 订单确认页细节显示
- 查询运费时连同地址信息一起返回,也就是选中地址的地址信息回显
1)、编写FareVo
package com.atguigu.gulimall.order.vo;
@Data
public class FareVo {
private MemberAddressVo address;
private BigDecimal fare;
}
2)、改良实现代码
- WareInfoController
/**
* 根据用户的收货地址计算运费
* @param addrId
* @return
*/
@GetMapping("/fare")
public R getFare(@RequestParam("addrId") long addrId){
FareVo fare = wareInfoService.getFare(addrId);
return R.ok().setData(fare);
}
- WareInfoService
/*
* 根据用户的收货地址计算运费
* @param addrId
* @return
*/
FareVo getFare(Long addrId);
- WareInfoServiceImpl
@Override
public FareVo getFare(long addrId) {
FareVo fareVo = new FareVo();
R r = memberFeignService.addrInfo(addrId);
MemberAddressVo data = r.getData("memberReceiveAddress",new
TypeReference<MemberAddressVo>() {
}
);
if (data != null){
String phone = data.getPhone();
// 12345678918 8:截取手机号的最后一位当做运费,实际上应该是接入第三方物流作为接口。这里只是
简单模拟一下。
String substring = phone.substring(phone.length() - 1, phone.length());
BigDecimal bigDecimal = new BigDecimal(substring);
fareVo.setAddress(data);
fareVo.setFare(bigDecimal);
return fareVo;
}
return null;
}
3)、信息回显
效果展示
1.9.14 接口幂等性
- 幂等解决方案(本次商城服务采用以下两种方式)
- Token机制
- 对订单号设置唯一约束
1.9.15 订单确认页完成
- 使用TOKEN机制处理幂等性
1)、订单服务执行流程
2)、防重令牌的编写
① 注入StringRedisTemplate
- OrderServiceImpl
② 创建订单服务常量即防重令牌前缀,格式:order:token:userId
package com.atguigu.gulimall.order.constant;
public class OrderConstant {
//订单放入redis中的防重令牌
public static final String USER_ORDER_TOKEN_PREFIX = "order:token:";
}
③ 防重令牌存到redis中
④ 提交页面数据Vo的编写
-
仿照京东:京东的结算页中的商品信息是实时获取的,结算的时候会去购物车中再去获取一遍,因此,提交页 面的数据Vo没必要提交商品信息
-
OrderSubmitVo
package com.atguigu.gulimall.order.vo;
//封装订单提交的数据
@Data
public class OrderSubmitVo {
private Long addrId; //收货地址id
private Integer payType; //支付方式
//无需提交需要购买的商品,去购物车再获取一遍
//优惠 ,发票
private String orderToken; //防重令牌
private BigDecimal payPrice; //应付价格,验价
private String note; //订单备注
//用户相关信息,直接去session中取出登录的用户即可
}
⑤ 前端页面提交表单编写
- 我们使用隐藏的输入框将我们需要使用的数据带上
⑥ 为input框绑定数据
⑦ 编写提交订单数据接口
- OrderWebController
/**
* 下单功能
* @param vo
* @return
*/
@PostMapping("/submitOrder")
public String submitOrder(OrderSubmitVo vo){
//下单:去创建订单,验令牌,验价格,锁库存...
//下单成功来到支付选择页
//下单失败回到订单确认页重新确认订单信息
return null;
}
1.9.16 原子验证令牌
1)、提交订单返回结果Vo编写
- SubmitOrderResponsevo
package com.atguigu.gulimall.order.vo;
@Data
public class SubmitOrderResponsevo {
private OrderEntity order; //当前订单内容
private Integer code; // 0成功 错误状态码
}
2)、下单功能接口完善
- OrderWebController
/**
* 下单功能
* @param vo
* @return
*/
@PostMapping("/submitOrder")
public String submitOrder(OrderSubmitVo vo){
SubmitOrderResponseVo responseVo = orderService.submitOrder(vo);
if (responseVo.getCode() == 0){
//下单成功来到支付选择页
return "pay";
} else{
//下单失败回到订单确认页重新确认订单信息
return "redirect:http://order.gulimall.com/toTrade";
}
}
-
验证令牌的核心:保证令牌的比较和删除的原子性
-
解决方案:使用脚本
-
if redis.call('get', KEYS[1]) == ARGV[1] then return redis.call('del', KEYS[1]) else return 0 end
-
脚本执行的返回结果:
-
0:代表令牌校验失败
1:代表令牌成功删除即成功
-
-
-
OrderServiceImpl
@Override
public SubmitOrderResponseVo submitOrder(OrderSubmitVo vo) {
SubmitOrderResponseVo response = new SubmitOrderResponseVo();
MemberRespVo memberRespVo = LoginUserInterceptor.loginUser.get();
//得到当前用户
//1、验证令牌【令牌的对比和删除必须保证原子性】
//0 令牌失败 - 1删除成功
// lua脚本实现原子性
String script = "if redis.call('get', KEYS[1]) == ARGV[1] then return
redis.call('del', KEYS[1]) else return 0 end";
String orderToken = vo.getOrderToken();
//原子验证令牌和删除令牌
long result = redisTemplate.execute(new DefaultRedisScript<long>(script,long.class),Arrays.asList(OrderConstant.USER_ORDER_TOKEN_PREFIX + memberRespVo.getId()), orderToken);
if (result == 0L){
//令牌验证失败
return response;
} else {
//令牌验证成功//下单:去创建订单,验令牌,验价格,锁库存...
}
return response;
}
-
execute(arg1,arg2,arg3)参数解释:
arg1:用DefaultRedisScript的构造器封装脚本和返回值类型
arg2:数组,用于存放Redis中token的key
arg3:用于比较的token即浏览器存储的token
1.9.17 构造订单数据
1) 、订单创建To的编写
- OrderCreateTo
package com.atguigu.gulimall.order.to;
@Data
public class OrderCreateTo {
private OrderEntity order; //当前订单内容
private List<OrderItemEntity> orderItems; //订单包含的所有订单项
private BigDecimal payPrice; //订单计算的应付价格
private BigDecimal fare; //运费
}
2) 、创建订单方法编写
① 订单状态枚举类的编写
- OrderStatusEnum
package com.atguigu.gulimall.order.enume;
public enum OrderStatusEnum {
CREATE_NEW(0,"待付款"),
PAYED(1,"已付款"),
SENDED(2,"已发货"),
RECIEVED(3,"已完成"),
CANCLED(4,"已取消"),
SERVICING(5,"售后中"),
SERVICED(6,"售后完成");
private Integer code;
private String msg;
OrderStatusEnum(Integer code, String msg) {
this.code = code;
this.msg = msg;
}
public Integer getCode() {
return code;
}
public String getMsg() {
return msg;
}
}
② 远程服务调用获取地址和运费信息
- WmsFeignService
//获取运费
@GetMapping("/ware/wareinfo/fare")
R getFare(@RequestParam("addrId")Long addrId);
③ 使用ThreadLocal,实现同一线程共享数据
④ 实现
- OrderServiceImpl(4个方法)
public OrderCreateTo createOrderTo(){
OrderCreateTo createTo = new OrderCreateTo();
//1、生成订单号
String orderSn = IdWorker.getTimeId();
//创建订单号
OrderEntity orderEntity = buildOrder(orderSn);
//2、获取到所有的订单项
List<OrderItemEntity> itemEntities = bulidOrderItems();
//3、验价
return createTo;
}
/**
* 构建订单
* @param orderSn
* @return
*/
private OrderEntity buildOrder(String orderSn) {
OrderEntity entity = new OrderEntity();
entity.setOrderSn(orderSn);
OrderSubmitVo submitVo = submitVoThreadLocal.get();
//获取收货地址信息
R fare = wmsFeignService.getFare(submitVo.getAddrId());
FareVo fareResp = fare.getData(new TypeReference<FareVo>() {});
//设置运费信息
entity.setFreightAmount(fareResp.getFare());
//设置收货人信息
entity.setReceiverCity(fareResp.getAddress().getCity());
entity.setReceiverDetailAddress(fareResp.getAddress().getDetailAddress());
entity.setReceiverName(fareResp.getAddress().getName());
entity.setReceiverPhone(fareResp.getAddress().getPhone());
entity.setReceiverPostCode(fareResp.getAddress().getPostCode());
entity.setReceiverProvince(fareResp.getAddress().getProvince());
entity.setReceiverRegion(fareResp.getAddress().getRegion());
return entity;
}
/**
* 构建所有订单项数据
* @return
*/
private List<OrderItemEntity> bulidOrderItems() {
List<OrderItemVo> currentUserCartItems = cartFeignService.getCurrentUserCartItems();
if (currentUserCartItems != null && currentUserCartItems.size() > 0){
currentUserCartItems.stream().map(cartItem ->{
OrderItemEntity itemEntity = bulidOrderItem(cartItem);
return itemEntity;
}
).collect(Collectors.toList());
}
return null;
}
/**
* 构建某一个订单项
* @param cartItem
* @return
*/
private OrderItemEntity bulidOrderItem(OrderItemVo cartItem) {
return null;
}
IDWorker中的getTimeId()生成时间id,不重复,用于充当订单号
1.9.18 构造订单项数据
1)、远程服务通过skuId获取spu信息准备
- 商品服务下 SpuInfoController
/**
* 通过skuId获取Spu信息
* @param skuId
* @return
*/
@GetMapping("/skuId/{id}")
public R getSpuInfoBySkuId(@PathVariable("id") long skuId) {
SpuInfoEntity entity = spuInfoService.getSpuInfoBySkuId(skuId);
return R.ok().setData(entity);
}
-
实现 :SpuInfoServiceImpl
@Override public SpuInfoEntity getSpuInfoBySkuId(long skuId) { SkuInfoEntity byId = skuInfoService.getById(skuId); long spuId = byId.getSpuId(); SpuInfoEntity spuInfoEntity = getById(spuId); return spuInfoEntity; }
2)、远程服务通过skuId获取spu信息
- 订单服务下ProductFeignService
@FeignClient("gulimall-product")
public interface ProductFeignService {
@GetMapping("/product/spuinfo/skuId/{id}")
R getSpuInfoBySkuId(@PathVariable("id") Long skuId);
}
3)、设置订单购物项数据
- SpuInfoVo
@Data
public class SpuInfoVo {
private long id;
/**
* 商品名称
*/
private String spuName;
/**
* 商品描述
*/
private String spuDescription;
/**
* 所属分类id
*/
private long catalogId;
/**
* 品牌id
*/
private long brandId;
private BigDecimal weight;
/**
* 上架状态[0 - 下架,1 - 上架]
*/
private Integer publishStatus;
private Date createTime;
private Date updateTime;
}
4)、实现方法完善
- OrderServiceImpl
@Autowired
ProductFeignService productFeignService;
public OrderCreateTo createOrderTo(){
OrderCreateTo createTo = new OrderCreateTo();
//1、生成订单号
String orderSn = IdWorker.getTimeId();
//创建订单号
OrderEntity orderEntity = buildOrder(orderSn);
//2、获取到所有的订单项
List<OrderItemEntity> itemEntities = bulidOrderItems(orderSn);
//3、验价
return createTo;
}
/**
* 构建订单
* @param orderSn
* @return
*/
private OrderEntity buildOrder(String orderSn) {
OrderEntity entity = new OrderEntity();
entity.setOrderSn(orderSn);
OrderSubmitVo submitVo = submitVoThreadLocal.get();
//获取收货地址信息
R fare = wmsFeignService.getFare(submitVo.getAddrId());
FareVo fareResp = fare.getData(new TypeReference<FareVo>() {
}
);
//设置运费信息
entity.setFreightAmount(fareResp.getFare());
//设置收货人信息
entity.setReceiverCity(fareResp.getAddress().getCity());
entity.setReceiverDetailAddress(fareResp.getAddress().getDetailAddress());
entity.setReceiverName(fareResp.getAddress().getName());
entity.setReceiverPhone(fareResp.getAddress().getPhone());
entity.setReceiverPostCode(fareResp.getAddress().getPostCode());
entity.setReceiverProvince(fareResp.getAddress().getProvince());
entity.setReceiverRegion(fareResp.getAddress().getRegion());
return entity;
}
/**
* 构建所有订单项数据
* @return
*/
private List<OrderItemEntity> bulidOrderItems(String orderSn) {
//最后确定每个购物项的价格
List<OrderItemVo> currentUserCartItems = cartFeignService.getCurrentUserCartItems();
if (currentUserCartItems != null && currentUserCartItems.size() > 0){
List<OrderItemEntity> itemEntities = currentUserCartItems.stream().map(cartItem -> {
OrderItemEntity itemEntity = bulidOrderItem(cartItem);
itemEntity.setOrderSn(orderSn);
return itemEntity;
}
).collect(Collectors.toList());
return itemEntities;
}
return null;
}
/**
* 构建某一个订单项
* @param cartItem
* @return
*/
private OrderItemEntity bulidOrderItem(OrderItemVo cartItem) {
OrderItemEntity itemEntity = new OrderItemEntity();
//1、订单信息:订单号
//2、商品的spu信息
long skuId = cartItem.getSkuId();
R r = productFeignService.getSpuInfoBySkuId(skuId);
SpuInfoVo data = r.getData(new TypeReference<SpuInfoVo>() {});
itemEntity.setSpuId(data.getId());
itemEntity.setSpuBrand(data.getBrandId().toString());
itemEntity.setSpuName(data.getSpuName());
itemEntity.setCategoryId(data.getCatalogId());
//3、商品的sku信息
itemEntity.setSkuId(cartItem.getSkuId());
itemEntity.setSkuName(cartItem.getTitle());
itemEntity.setSkuPic(cartItem.getImage());
itemEntity.setSkuPrice(cartItem.getPrice());
String skuAttr = StringUtils.collectionToDelimitedString(cartItem.getSkuAttr(), ":");
itemEntity.setSkuAttrsVals(skuAttr);
itemEntity.setSkuQuantity(cartItem.getCount());
//4、优惠信息(不做)
//5、积分信息
itemEntity.setGiftGrowth(cartItem.getPrice().intValue());
itemEntity.setGiftIntegration(cartItem.getPrice().intValue());
return itemEntity;
}
1.9.19 订单验价
1)、 计算单个购物项的真实价格
2)、设置订单的价格
- computePrice
private void computePrice(OrderEntity orderEntity, List<OrderItemEntity> itemEntities) {
BigDecimal total = new BigDecimal("0.0");
BigDecimal coupon = new BigDecimal("0.0");
BigDecimal integration = new BigDecimal("0.0");
BigDecimal promotion = new BigDecimal("0.0");
BigDecimal gift = new BigDecimal("0.0");
BigDecimal growth = new BigDecimal("0.0");
//订单的总额,叠加每一个订单项的总额信息
for (OrderItemEntity entity : itemEntities) {
coupon = coupon.add(entity.getCouponAmount());
integration = integration.add(entity.getIntegrationAmount());
promotion = promotion.add(entity.getPromotionAmount());
total = total.add(entity.getRealAmount());
gift = gift.add(new BigDecimal(entity.getGiftIntegration().toString()));
growth = growth.add(new BigDecimal(entity.getGiftGrowth().toString()));
}
//1、订单价格相关
orderEntity.setTotalAmount(total);
//应付总额
orderEntity.setPayAmount(total.add(orderEntity.getFreightAmount()));
orderEntity.setPromotionAmount(promotion);
orderEntity.setIntegrationAmount(integration);
orderEntity.setCouponAmount(coupon);
//设置积分等信息
orderEntity.setIntegration(gift.intValue());
orderEntity.setGrowth(growth.intValue());
orderEntity.setDeleteStatus(0);
//未删除
}
3)、订单其它信息设置
4)、验价
1.9.20 保存订单数据
1)、保存订单和订单项数据
- 保存订单和订单项以及锁库存操作处于事务当中,出现异常需要回滚
-
注入orderItemService
-
保存
- OrderServiceImpl
/**
* 保存订单数据
* @param order
*/
private void saveOrder(OrderCreateTo order){
OrderEntity orderEntity = order.getOrder();
orderEntity.setModifyTime(new Date());
this.save(orderEntity);
//保存订单项
}
1.9.21 锁定库存
1)、锁库存逻辑
2)、远程服务调用锁定库存
-
- 锁库存Vo编写
订单服务下 WareSkuLockVo :
@Data
public class WareSkuLockVo {
//根据订单号判断是否存库存成功
private String orderSn;
//需要锁住的所有库存信息:skuId skuName num
private List<OrderItemVo> locks;
}
-
将订单服务的WareSkuLockVo和OrderItemVo复制到库存服务中
-
- 锁库存响应Vo编写
- 库存服务下
/**商品的库存锁定状态 */ @Data public class LockStockResult { //那个商品 private long skuId; //锁了几件 private Integer num; //锁住了没有,状态 private Boolean locked; }
-
- 锁库存异常类的编写
public class NoStockException extends RuntimeException{ private long skuId; public NoStockException(long skuId){ super("商品id:" + skuId + ";没有足够的库存了"); } public long getSkuId() { return skuId; } public void setSkuId(long skuId) { this.skuId = skuId; } }
-
- 库存不足异常状态码编写
-
- 为库存表的锁库存字段设置默认值:0
-
- 查询库存接口编写
-
WareSkuController
-
/** * 锁定库存 * @param vo * @return */ @PostMapping("/lock/order") public R orderLockStock(@RequestBody WareSkuLockVo vo){ try { Boolean stock = wareSkuService.orderLockStock(vo); return R.ok(); } catch (NoStockException e){ return R.error(BizCodeEnume.NO_STOCK_EXCEPTION.getCode(),BizCodeEnume.NO_STOCK_EXCEPTION.getMsg()); } }
-
实现:
-
指定抛出此异常时一定要回滚,不指定也会回滚默认运行时异常都会回滚
- WareSkuServiceImpl
-
内部类保存商品在哪些仓库有库存以及锁库存数量
@Data
class SkuWareHasStock{
private long skuId;
private Integer num;
private List<long> wareId;
}
3)、锁库存实现
- OrderServiceImpl
/**
* 为某个订单锁定库存
*
*
* (rollbackFor = NoStockException.class)
* 默认只要是运行时异常就会回滚
* @param vo
* @return
*/
@Transactional
@Override
public Boolean orderLockStock(WareSkuLockVo vo) {
//1、按照下单的收货地址,找到一个就近仓库,锁定库存。//暂时不这样做
//1、找到每个商品在哪个仓库都有库存
List<OrderItemVo> locks = vo.getLocks();
List<SkuWareHasStock> collect = locks.stream().map(item -> {
SkuWareHasStock stock = new SkuWareHasStock();
long skuId = item.getSkuId();
stock.setSkuId(skuId);
stock.setNum(item.getCount());
//查询这个商品在哪里有库存
List<long> wareIds = wareSkuDao.listWareIdHasSkuStock(skuId);
stock.setWareId(wareIds);
return stock;
}
).collect(Collectors.toList());
//2、锁定库存
for (SkuWareHasStock hasStock : collect) {
Boolean skuStocked = false;
//标识位
long skuId = hasStock.getSkuId();
List<long> wareIds = hasStock.getWareId();
if (wareIds == null || wareIds.size() == 0){
//没有任何仓库有这个商品的库存
throw new NoStockException(skuId);
}
for (long wareId : wareIds) {
//成功就返回的是1行受影响,否则就是0行受影响
long count = wareSkuDao.lockSkuStock(skuId,wareId,hasStock.getNum());
if (count == 1){
skuStocked = true;
break;
} else {
//当前仓库锁定失败,重试下一个仓库
}
}
if (skuStocked == false){
//当前商品所有仓库都没有锁住
throw new NoStockException(skuId);
}
}
return true;
}
- WareSkuDao
@Mapper
public interface WareSkuDao extends BaseMapper<WareSkuEntity> {
void addStock(@Param("skuId") long skuId, @Param("wareId") long wareId, @Param("skuNum") Integer skuNum);
Long getSkuStock(long skuId);//一个参数的话,可以不用写@Param,多个参数一定要写,方便区分
List<long> listWareIdHasSkuStock(@Param("skuId") long skuId);
long lockSkuStock(@Param("skuId") long skuId, @Param("wareId") long wareId, @Param("num") Integer num);
}
4)、 远程服务调用
- 订单服务下
5)、接口完善
- OrderWebController
@PostMapping("/submitOrder")
public String submitOrder(OrderSubmitVo vo,Model model){
SubmitOrderResponseVo responseVo = orderService.submitOrder(vo);
if (responseVo.getCode() == 0){
//下单成功来到支付选择页
model.addAttribute("submitOrderResp",responseVo);
return "pay";
} else{
//下单失败回到订单确认页重新确认订单信息
return "redirect:http://order.gulimall.com/toTrade";
}
}
- OrderServiceImpl
@Transactional
@Override
public SubmitOrderResponseVo submitOrder(OrderSubmitVo vo) {
submitVoThreadLocal.set(vo);
//放到线程中共享数据
SubmitOrderResponseVo response = new SubmitOrderResponseVo();
MemberRespVo memberRespVo = LoginUserInterceptor.loginUser.get();
//得到当前用户
response.setCode(0);
//1、验证令牌【令牌的对比和删除必须保证原子性】
//0 令牌失败 - 1删除成功
// lua脚本实现原子性
String script = "if redis.call('get', KEYS[1]) == ARGV[1] then return
redis.call('del', KEYS[1]) else return 0 end";
String orderToken = vo.getOrderToken();
//原子验证令牌和删除令牌
long result = redisTemplate.execute(new DefaultRedisScript<long>(script,
long.class),
Arrays.asList(OrderConstant.USER_ORDER_TOKEN_PREFIX + memberRespVo.getId()),
orderToken);
if (result == 0L) {
//令牌验证失败
response.setCode(1);
//设置为失败状态
return response;
} else {
//令牌验证成功
//下单:去创建订单,验令牌,验价格,锁库存...
//1、创建订单、订单项等信息
OrderCreateTo order = createOrderTo();
//2、验价
BigDecimal payAmount = order.getOrder().getPayAmount();
BigDecimal payPrice = vo.getPayPrice();
//页面提交价格与计算的价格相差小于0.01则验价成功
if (Math.abs(payAmount.subtract(payPrice).doubleValue()) < 0.01) {
//金额对比
//.....
//3、保存订单
saveOrder(order);
//4、库存锁定。只要有异常回滚订单数据。
WareSkuLockVo lockVo = new WareSkuLockVo();
lockVo.setOrderSn(order.getOrder().getOrderSn());
List<OrderItemVo> locks = order.getOrderItems().stream().map(item -> {
OrderItemVo itemVo = new OrderItemVo();
itemVo.setSkuId(item.getSkuId());
itemVo.setCount(item.getSkuQuantity());
itemVo.setTitle(item.getSkuName());
return itemVo;
}
).collect(Collectors.toList());
lockVo.setLocks(locks);
// TODO 远程锁库存
R r = wmsFeignService.orderLockStock(lockVo);
if (r.getCode() == 0){
//锁成功了
response.setOrder(order.getOrder());
return response;
} else{
//锁定失败
response.setCode(3);
return response;
}
} else {
response.setCode(2);
return response;
}
}
}