仅涉及后端,全部目录看顶部专栏,代码、文档、接口路径在:
【Lilishop商城】记录一下B2B2C商城系统学习笔记~_清晨敲代码的博客-CSDN博客
全篇会结合业务介绍重点设计逻辑,其中重点包括接口类、业务类,具体的结合源代码分析,源码读起来也不复杂~
谨慎:源代码中有一些注释是错误的,有的注释意思完全相反,有的注释对不上号,我在阅读过程中就顺手更新了,并且在我不会的地方添加了新的注释,所以在读源代码过程中一定要谨慎啊!
目录
A1.会员登录模块
B1.会员controller
C1.平台注册会员接口开发
业务逻辑:
代码逻辑:
C2.用户名密码登录接口开发
业务逻辑:
代码逻辑:
C3.短信登录接口开发
业务逻辑:
代码逻辑:
C4.手机App/小程序扫码二维码登录接口开发⭐
业务逻辑:
代码逻辑:
A1.会员登录模块
在 No2-2.确定软件架构搭建 里的 A5.安全框架(springsecurity) 文章中,我们已经了解了账号授权及认证的开发架构了。
- 账号登录成功后,会接收到后端返回的 Token 数据,包括 accesstoken 和 refreshtoken ;
- 账号携带 accesstoken 访问后端接口,会先被 filter 拦截到 accesstoken 拿到帐号信息,判断是有权限,进而执行接口。
所以此处的会员登录模块只涉及到上方的 1. ,最终都一定会拿到 Token ~
至于 2. 可以去各个端的api代码里查看继承了 BasicAuthenticationFilter 的 filter ,逻辑就是 No2-2 里的 A5 ,这里就不重复说明了~
B1.会员controller
C1.平台注册会员接口开发
平台注册会员就一个接口,只需要有入参 用户名、密码、手机号码、短信验证码就可以创建会员。并且接口会直接返回账号登陆的 Token ,但是要注意PC端的前端并没有在注册成功后使用 Token ~用户注册成功后还需要手动登录
业务逻辑:
在介绍业务逻辑时,会涉及到一些其他代码结构,有需要说明的就用绿色底纹标注,然后在后面的代码逻辑里面详细介绍。
controller类:MemberBuyerController
- 接受到入参,先校验短信验证码【校验也是通过公共的 SmsUtil 操作】
- 若验证码校验有问题就抛出 ServiceException 异常,异常类型为:短信验证码错误,请重新校验。【改异常类是自定义的,需要在全局异常处理类中捕获到并返回结果。具体会在 No2-* 软件架构 里面补充,具体代码可见:GlobalControllerExceptionHandler】
- 若校验没问题,则调用会员业务类的注册方法,拿到 token 并返回响应值ResultMessage。
service类:仅使用的 mybatis-plus 的,没有自定义mapper
- 先检测会员信息中用户名和手机号码是否已存在,已存在则抛出 ServiceException 异常,异常类型为:该用户名或手机号已被注册
- 将参数转为用户实体类 Member,使用hutool工具包中的雪花算法设置 id,再调用 IService 自带 save 方法保存用户到数据库;
- 处理会员注册的小事件:新会员赠送积分、新会员赠送优惠券、新会员赠送经验等等。【此处的逻辑使用的 SpringEvent 和 RocketMQ 处理的,SpringEvent 用于发布消息,作用在于程序解耦,RocketMQ 是拿到消息后处理具体的业务】
- 最后生成 MemberTokenGenerate 生成 token ,并返回值;【MemberTokenGenerate见No2-2 A5】
代码逻辑:
//cn.lili.controller.passport.MemberBuyerController
@Slf4j
@RestController
@Api(tags = "买家端,会员接口")
@RequestMapping("/buyer/passport/member")
public class MemberBuyerController {
@Autowired
private MemberService memberService;
@Autowired
private SmsUtil smsUtil;
@ApiOperation(value = "注册用户")
@PostMapping("/register")
public ResultMessage<Object> register(@NotNull(message = "用户名不能为空") @RequestParam String username,
@NotNull(message = "密码不能为空") @RequestParam String password,
@NotNull(message = "手机号为空") @RequestParam String mobilePhone,
@RequestHeader String uuid,
@NotNull(message = "验证码不能为空") @RequestParam String code) {
if (smsUtil.verifyCode(mobilePhone, VerificationEnums.REGISTER, uuid, code)) {
return ResultUtil.data(memberService.register(username, password, mobilePhone));
} else {
throw new ServiceException(ResultCode.VERIFICATION_SMS_CHECKED_ERROR);
}
}
...
}
//cn.lili.modules.member.serviceimpl.MemberServiceImpl
@Service
public class MemberServiceImpl extends ServiceImpl<MemberMapper, Member> implements MemberService {
/**
* 会员token
*/
@Autowired
private MemberTokenGenerate memberTokenGenerate;
@Autowired
private ApplicationEventPublisher applicationEventPublisher;
@Override
@Transactional
public Token register(String userName, String password, String mobilePhone) {
//检测会员信息
checkMember(userName, mobilePhone);
//设置会员信息
Member member = new Member(userName, new BCryptPasswordEncoder().encode(password), mobilePhone);
//进行用户注册处理。抽象出一个方法
this.registerHandler(member);
return memberTokenGenerate.createToken(member, false);
}
/**
* 注册方法抽象出来:会员注册、第三方授权自动注册、员工账号注册登都需要改逻辑~
*
* @param member
*/
@Transactional
public void registerHandler(Member member) {
//hutool工具包 中的雪花算法
member.setId(SnowFlake.getIdStr());
//保存会员
this.save(member);
//处理会员保存成功后的小事件:监听event,发送mp消息。最终在 mq 里面处理小事件
applicationEventPublisher.publishEvent(new TransactionCommitSendMQEvent("new member register", rocketmqCustomProperties.getMemberTopic(), MemberTagsEnum.MEMBER_REGISTER.name(), member));
}
/**
* 检测会员
*
* @param userName 会员名称
* @param mobilePhone 手机号
*/
private void checkMember(String userName, String mobilePhone) {
//判断手机号是否存在
if (this.findMember(mobilePhone, userName) > 0) {
throw new ServiceException(ResultCode.USER_EXIST);
}
}
。。。
}
1.公共的 SmsUtil 操作
详见:cn.lili.modules.sms.SmsUtil,里面包括发送短信验证码、校验短信验证码方法,都是公共的。
在发送短信验证码的方法中,发送给手机号码后,最终会将验证码存到redis里面的。所以校验短信验证码的方法里面,也从redis里面拿到该手机号码的注册Enums的校验码,然后进行对比校验。
短信发送逻辑是使用的第三方(阿里云)的,按照阿里云提供的工具使用。
还有一点,由于 SmsUtil 是公用的,所以有不同种使用类型,例如:会员注册、登录、找回密码等等,不同的类型发送短信验证码模板是不一样的(阿里云里面也是需要模板code进行区分的),所以需要不同的类型进行区分。
所以就有了 VerificationEnums 枚举类来标注类型,并且也使用了 application.yml 来配置模板信息
//cn.lili.modules.verification.entity.enums.VerificationEnums
public enum VerificationEnums {
/**
* 登录
* 注册
* 找回用户
* 修改密码
* 支付钱包密码
*/
LOGIN,
REGISTER,
FIND_USER,
UPDATE_PASSWORD,
WALLET_PASSWORD;
}
//cn.lili.modules.sms.impl.SmsUtilAliImplService
@Component
@Slf4j
public class SmsUtilAliImplService implements SmsUtil, AliSmsUtil {
@Autowired
private Cache cache;
@Autowired
private SettingService settingService;
@Autowired
private MemberService memberService;
@Autowired
private SmsTemplateProperties smsTemplateProperties;
@Autowired
private SystemSettingProperties systemSettingProperties;
@Override
public void sendSmsCode(String mobile, VerificationEnums verificationEnums, String uuid) {
。。。
//缓存中写入要验证的信息
cache.put(cacheKey(verificationEnums, mobile, uuid), code, 300L);
}
@Override
public boolean verifyCode(String mobile, VerificationEnums verificationEnums, String uuid, String code) {
Object result = cache.get(cacheKey(verificationEnums, mobile, uuid));
if (code.equals(result) || code.equals("0")) {
//校验之后,删除
cache.remove(cacheKey(verificationEnums, mobile, uuid));
return true;
} else {
return false;
}
}
/**
* 生成缓存key
*
* @param verificationEnums 验证场景
* @param mobile 手机号码
* @param uuid 用户标识 uuid
* @return
*/
static String cacheKey(VerificationEnums verificationEnums, String mobile, String uuid) {
return CachePrefix.SMS_CODE.getPrefix() + verificationEnums.name() + uuid + mobile;
}
。。。
}
# /lilishop-master/common-api/src/main/resources/application.yml
lili:
#短信模版配置
sms:
#登录
LOGIN: SMS_205755300
#注册
REGISTER: SMS_205755298
#找回密码
FIND_USER: SMS_205755301
#设置密码
UPDATE_PASSWORD: SMS_205755297
#支付密码
WALLET_PASSWORD: SMS_205755301
//使用例子
smsUtil.verifyCode(mobilePhone, VerificationEnums.REGISTER, uuid, code)
2.ServiceException 异常、异常类型为、全局异常处理类
ServiceException是全局业务异常类,大部分的异常都是他,又由于异常类型多,所以需要有个异常类型枚举类,来说明不同类型的异常信息,信息里包含code、message(有的系统是用的是国际化Message,但本系统没有使用)。
最终抛出的ServiceException异常由 GlobalControllerExceptionHandler 捕获到并拿到code、message进行处理。
注:异常类型存放到枚举类里面,不如放到配置文件里面方便,如果要修改异常类型信息就需要修改代码并重新启动。
//cn.lili.common.enums.ResultCode
/**
* 返回状态码
* 第一位 1:商品;2:用户;3:交易,4:促销,5:店铺,6:页面,7:设置,8:其他
*
* @author Chopper
* @since 2020/4/8 1:36 下午
*/
public enum ResultCode {
/**
* 成功状态码
*/
SUCCESS(200, "成功"),
/**
* 失败返回码
*/
ERROR(400, "服务器繁忙,请稍后重试"),
。。。
private final Integer code;
private final String message;
ResultCode(Integer code, String message) {
this.code = code;
this.message = message;
}
public Integer code() {
return this.code;
}
public String message() {
return this.message;
}
}
//cn.lili.common.exception.ServiceException
/**
* 全局业务异常类
*
* @author Chopper
*/
@EqualsAndHashCode(callSuper = true)
@Data
public class ServiceException extends RuntimeException {
private static final long serialVersionUID = 3447728300174142127L;
public static final String DEFAULT_MESSAGE = "网络错误,请稍后重试!";
/**
* 异常消息
*/
private String msg = DEFAULT_MESSAGE;
/**
* 错误码
*/
private ResultCode resultCode;
public ServiceException(String msg) {
this.resultCode = ResultCode.ERROR;
this.msg = msg;
}
public ServiceException() {
super();
}
public ServiceException(ResultCode resultCode) {
this.resultCode = resultCode;
}
public ServiceException(ResultCode resultCode, String message) {
this.resultCode = resultCode;
this.msg = message;
}
}
//cn.lili.common.exception.GlobalControllerExceptionHandler
/**
* 异常处理
*
* @author Chopper
*/
@RestControllerAdvice
@Slf4j
public class GlobalControllerExceptionHandler {
/**
* 如果超过长度,则前后段交互体验不佳,使用默认错误消息
*/
static Integer MAX_LENGTH = 200;
/**
* 自定义异常
*
* @param e
* @return
*/
@ExceptionHandler(ServiceException.class)
//设置响应状态码code
@ResponseStatus(value = HttpStatus.BAD_REQUEST)
public ResultMessage<Object> handleServiceException(HttpServletRequest request, final Exception e, HttpServletResponse response) {
//如果是自定义异常,则获取异常,返回自定义错误消息
if (e instanceof ServiceException) {
ServiceException serviceException = ((ServiceException) e);
ResultCode resultCode = serviceException.getResultCode();
Integer code = null;
String message = null;
if (resultCode != null) {
code = resultCode.code();
message = resultCode.message();
}
//如果有扩展消息,则输出异常中,跟随补充异常
if (!serviceException.getMsg().equals(ServiceException.DEFAULT_MESSAGE)) {
message += ":" + serviceException.getMsg();
}
log.error("全局异常[ServiceException]:{}-{}", serviceException.getResultCode().code(), serviceException.getResultCode().message(), e);
return ResultUtil.error(code, message);
} else {
log.error("全局异常[ServiceException]:", e);
}
//默认错误消息
String errorMsg = "服务器异常,请稍后重试";
if (e != null && e.getMessage() != null && e.getMessage().length() < MAX_LENGTH) {
errorMsg = e.getMessage();
}
return ResultUtil.error(ResultCode.ERROR.code(), errorMsg);
}
。。。
}
//使用例子
throw new ServiceException(ResultCode.VERIFICATION_SMS_CHECKED_ERROR);
3.hutool工具包中的雪花算法设置 id
shop平台使用的使 hutool 工具类的 SnowFlake,具体的分布式学习可以看这篇文章分布式全局唯一ID(学习总结---从入门到深化)-CSDN博客
//cn.lili.common.utils.SnowFlake
/**
* 雪花分布式id获取
*
* @author Chopper
*/
@Slf4j
public class SnowFlake {
//静态
private static Snowflake snowflake;
/**
* 初始化配置
*
* @param workerId
* @param datacenterId
*/
public static void initialize(long workerId, long datacenterId) {
snowflake = IdUtil.getSnowflake(workerId, datacenterId);
}
public static long getId() {
return snowflake.nextId();
}
/**
* 生成字符,带有前缀的id。例如,订单编号 O202103301376882313039708161
*
* @param prefix
* @return
*/
public static String createStr(String prefix) {
return prefix + DateUtil.toString(new Date(), "yyyyMMdd") + SnowFlake.getId();
}
public static String getIdStr() {
return snowflake.nextId() + "";
}
}
//cn.lili.common.utils.SnowflakeInitiator
@Component
@Slf4j
public class SnowflakeInitiator {
/**
* 缓存前缀
*/
private static final String KEY = "{Snowflake}";
@Autowired
private Cache cache;
/**
* 尝试初始化
*
* @return
*/
//Java自带的注解,在方法上加该注解会在项目启动的时候执行该方法
@PostConstruct
public void init() {
//从 redis 里面获取到自增长的主键
Long num = cache.incr(KEY);
long dataCenter = num / 32;
long workedId = num % 32;
//如果数据中心大于32,则抹除缓存,从头开始
if (dataCenter >= 32) {
cache.remove(KEY);
num = cache.incr(KEY);
dataCenter = num / 32;
workedId = num % 32;
}
//初始化
SnowFlake.initialize(workedId, dataCenter);
}
public static void main(String[] args) {
SnowFlake.initialize(0, 8);
System.out.println(SnowFlake.getId());
}
}
//使用例子
member.setId(SnowFlake.getIdStr());
order.setSn(SnowFlake.createStr("G"));
4.SpringEvent、RocketMQ
service里面会员注册成功后,后面需要给该会员发放优惠券、积分等业务操作,为了避免程序耦合,就是使用了 SpringEvent 的方式,也就是 TransactionCommitSendMQListener事件监听器和TransactionCommitSendMQEvent事件。然后在事件监听器里面又调用了rocketMQTemplate发送消息,最终在rocket监听器里面处理的哦~
说白了就是将SpringEvent、RocketMQ结合使用了,我是没搞懂为啥结合使用,因为直接使用RocketMQ也不费事,SpringEvent本身最重要的作用就是业务剥离、程序解耦,这些也是RocketMQ的作用。【其他模块就有直接使用RocketMQ的】
直到我看到 SpringEvent 的类名字及注释:事务提交后发生mq事件、事务提交监听器,并且使用了@TransactionalEventListener注释,我想那这部分类只是专门用来处理事务提交后相关的业务的。
//cn.lili.common.event.TransactionCommitSendMQEvent
/**
* 事务提交后发生mq事件
*
* @author paulG
* @since 2022/1/19
**/
public class TransactionCommitSendMQEvent extends ApplicationEvent {
private static final long serialVersionUID = 5885956821347953071L;
@Getter
private final String topic;
@Getter
private final String tag;
@Getter
private final Object message;
public TransactionCommitSendMQEvent(Object source, String topic, String tag, Object message) {
super(source);
this.topic = topic;
this.tag = tag;
this.message = message;
}
}
//cn.lili.common.listener.TransactionCommitSendMQListener
/**
* 事务提交监听器
*
* @author paulG
* @since 2022/1/19
**/
@Component
@Slf4j
public class TransactionCommitSendMQListener {
/**
* rocketMq
*/
@Autowired
private RocketMQTemplate rocketMQTemplate;
//在事务提交后再触发某一事件
@TransactionalEventListener(phase = TransactionPhase.AFTER_COMMIT)
public void send(TransactionCommitSendMQEvent event) {
log.info("事务提交,发送mq信息!{}", event);
String destination = event.getTopic() + ":" + event.getTag();
//发送订单变更mq消息
rocketMQTemplate.asyncSend(destination, event.getMessage(), RocketmqSendCallbackBuilder.commonCallback());
}
}
//使用例子:
//处理会员保存成功后的小事件:监听event,发送mp消息。最终在 mq 里面处理小事件
applicationEventPublisher.publishEvent(new TransactionCommitSendMQEvent("new member register", rocketmqCustomProperties.getMemberTopic(), MemberTagsEnum.MEMBER_REGISTER.name(), member));
C2.用户名密码登录接口开发
这个也只有一个接口,最终是根据用户名和密码,拿到用户登录的 Token 的。
业务逻辑:
在介绍业务逻辑时,会涉及到一些其他代码结构,有需要说明的就用绿色底纹标注,然后在后面的代码逻辑里面详细介绍。
controller类:MemberBuyerController
- 接受到入参,先校验图片验证码【校验是通过 VerificationService 操作】
- 若验证码校验有问题就抛出 ServiceException 异常,异常类型为:验证码已失效,请重新校验。
- 若校验没问题,则调用会员业务类的用户名密码登录方法,拿到 token 并返回响应值ResultMessage。
service类:仅使用的 mybatis-plus 的,没有自定义mapper
- 先获取用户名或手机号码对应的帐号信息,不存在就抛出 ServiceException 异常,异常类型为:用户不存在。
- 若用户存在则判断密码是否输入正确,不正确就抛出 ServiceException 异常【因为注册时密码使用的BCryptPasswordEncoder保存的,解密时也自然是用他】
- 根据拿到的会员账号信息,生成token,并返回;
代码逻辑:
//cn.lili.controller.passport.MemberBuyerController
@Slf4j
@RestController
@Api(tags = "买家端,会员接口")
@RequestMapping("/buyer/passport/member")
public class MemberBuyerController {
@Autowired
private MemberService memberService;
@Autowired
private VerificationService verificationService;
@ApiOperation(value = "用户名密码登录接口")
@PostMapping("/userLogin")
public ResultMessage<Object> userLogin(@NotNull(message = "用户名不能为空") @RequestParam String username,
@NotNull(message = "密码不能为空") @RequestParam String password,
@RequestHeader String uuid) {
verificationService.check(uuid, VerificationEnums.LOGIN);
return ResultUtil.data(this.memberService.usernameLogin(username, password));
}
。。。
}
//cn.lili.modules.member.serviceimpl.MemberServiceImpl
/**
* 会员接口业务层实现
*
* @author Chopper
* @since 2021-03-29 14:10:16
*/
@Service
public class MemberServiceImpl extends ServiceImpl<MemberMapper, Member> implements MemberService {
/**
* 会员token
*/
@Autowired
private MemberTokenGenerate memberTokenGenerate;
@Override
public Token usernameLogin(String username, String password) {
//获取用户名或手机号码对应的帐号信息
Member member = this.findMember(username);
//判断用户是否存在
if (member == null || !member.getDisabled()) {
throw new ServiceException(ResultCode.USER_NOT_EXIST);
}
//判断密码是否输入正确
if (!new BCryptPasswordEncoder().matches(password, member.getPassword())) {
throw new ServiceException(ResultCode.USER_PASSWORD_ERROR);
}
//成功登录,则检测cookie中的信息,进行会员绑定。但是我发现前端并没有操作对应的cookies,所以暂时是没有用的
this.loginBindUser(member);
//根据会员账号信息,生成token
return memberTokenGenerate.createToken(member, false);
}
...
}
1. VerificationService 操作
【Lilishop商城】No3-2.模块详细设计 A4 滑块验证码图片的详细设计-CSDN博客 里面描述过用户使用滑块验证码登录时的流程,这里在描述一下:
滑块验证流程:
1.后端将底图、滑块图转化成base64并返回,同时将正确的阴影X轴位置存储到redis(key里面包含前端传过来的uuid,以便于后面getkey校验),然后返回给前端展示。
2.前端拿到base64转化成图片展示,并且实现滑动的动态效果。用户看到后滑动滑块到某个位置,此时的滑块位置为入参,松手后调用校验滑块接口,从redis里面拿到正确X轴位置与此时滑块位置作比较,比较通过后,再次缓存校验成功true(key里面也包含刚才的uuid),然后返回success。
3.前端发现滑块校验成功后,就调用登录接口,在登录接口里面会先从缓存中获取校验成功,如果是校验成功则进行登录。
上方三步骤都会对应一个验证码模块的接口,也都挺好理解的,就是要记住需要往 redis 里存储两种,缓存需要验证的内容、缓存验证的结果。
//cn.lili.modules.verification.service.impl.VerificationServiceImpl
/**
* 验证码认证处理类
*
* @author Chopper
* @version v1.0
* 2020-11-17 14:59
*/
@Slf4j
@Component
public class VerificationServiceImpl implements VerificationService {
@Autowired
private VerificationSourceService verificationSourceService;
@Autowired
private VerificationCodeProperties verificationCodeProperties;
@Autowired
private Cache cache;
/**
* 创建校验
* @param uuid 前端传过来的的标识
* @return 验证码参数
*/
@Override
public Map<String, Object> createVerification(VerificationEnums verificationEnums, String uuid) {
if (uuid == null) {
throw new ServiceException(ResultCode.ILLEGAL_REQUEST_ERROR);
}
。。。
try {
。。。
//⭐重点,生成验证码数据
Map<String, Object> resultMap = SliderImageUtil.pictureTemplatesCut(
sliderFile, interfereSliderFile, originalFile,
verificationCodeProperties.getWatermark(), verificationCodeProperties.getInterfereNum());
//生成验证参数 有效时间 默认600秒,可以自行配置,存储到redis
cache.put(cacheKey(verificationEnums, uuid), resultMap.get("randomX"), verificationCodeProperties.getEffectiveTime());
resultMap.put("key", cacheKey(verificationEnums, uuid));
resultMap.put("effectiveTime", verificationCodeProperties.getEffectiveTime());
//移除横坐标移动距离,不能返回给用户哦
resultMap.remove("randomX");
return resultMap;
} catch (ServiceException e) {
throw e;
} catch (Exception e) {
log.error("生成验证码失败", e);
throw new ServiceException(ResultCode.ERROR);
}
}
/**
* 根据网络地址,获取源文件
* 这里简单说一下,这里是将不可序列化的inputstream序列化对象,存入redis缓存
*
* @param originalResource
* @return
*/
private SerializableStream getInputStream(String originalResource) throws Exception {
Object object = cache.get(CachePrefix.VERIFICATION_IMAGE.getPrefix() + originalResource);
if (object != null) {
return (SerializableStream) object;
}
if (StringUtils.isNotEmpty(originalResource)) {
URL url = new URL(originalResource);
InputStream inputStream = url.openStream();
SerializableStream serializableStream = new SerializableStream(inputStream);
cache.put(CachePrefix.VERIFICATION_IMAGE.getPrefix() + originalResource, serializableStream);
return serializableStream;
}
return null;
}
/**
* 预校验图片 用于前端回显
*
* @param xPos X轴移动距离
* @param verificationEnums 验证key
* @return 验证是否成功
*/
@Override
public boolean preCheck(Integer xPos, String uuid, VerificationEnums verificationEnums) {
Integer randomX = (Integer) cache.get(cacheKey(verificationEnums, uuid));
if (randomX == null) {
throw new ServiceException(ResultCode.VERIFICATION_CODE_INVALID);
}
log.debug("{}{}", randomX, xPos);
//验证结果正确 && 删除标记成功
if (Math.abs(randomX - xPos) < verificationCodeProperties.getFaultTolerant() && cache.remove(cacheKey(verificationEnums, uuid))) {
//验证成功,则记录验证结果 验证有效时间与验证码创建有效时间一致
cache.put(cacheResult(verificationEnums, uuid), true, verificationCodeProperties.getEffectiveTime());
return true;
}
throw new ServiceException(ResultCode.VERIFICATION_ERROR);
}
/**
* 验证码校验
*
* @param uuid 用户标识
* @param verificationEnums 验证key
* @return 验证是否成功
*/
@Override
public boolean check(String uuid, VerificationEnums verificationEnums) {
//如果有校验标记,则返回校验结果
if (Boolean.TRUE.equals(cache.remove(this.cacheResult(verificationEnums, uuid)))) {
return true;
}
throw new ServiceException(ResultCode.VERIFICATION_CODE_INVALID);
}
/**
* 生成缓存key 记录缓存需要验证的内容
*
* @param verificationEnums 验证码枚举
* @param uuid 用户uuid
* @return 缓存key
*/
public static String cacheKey(VerificationEnums verificationEnums, String uuid) {
return CachePrefix.VERIFICATION_KEY.getPrefix() + verificationEnums.name() + uuid;
}
/**
* 生成缓存key 记录缓存验证的结果
*
* @param verificationEnums 验证码枚举
* @param uuid 用户uuid
* @return 缓存key
*/
public static String cacheResult(VerificationEnums verificationEnums, String uuid) {
return CachePrefix.VERIFICATION_RESULT.getPrefix() + verificationEnums.name() + uuid;
}
}
//使用例子
verificationService.createVerification(verificationEnums, uuid)
verificationService.preCheck(xPos, uuid, verificationEnums)
verificationService.check(uuid, VerificationEnums.LOGIN)
C3.短信登录接口开发
业务逻辑:
在介绍业务逻辑时,会涉及到一些其他代码结构,有需要说明的就用绿色底纹标注,然后在后面的代码逻辑里面详细介绍。
controller类:MemberBuyerController
- 接收到入参,先校验短信验证码
- 若验证码校验有问题就抛出 ServiceException 异常
- 若校验没问题,则调用会员业务类的手机号验证码登录方法,拿到 token 并返回响应值ResultMessage。
service类:仅使用的 mybatis-plus 的,没有自定义mapper
- 根据手机号码获取用户
- 如果手机号不存在则自动注册用户,使用注册抽象出来的方法this.registerHandler(member)
- 根据会员账号信息,生成token,并返回
代码逻辑:
这一块儿没有复杂的逻辑,只是记住一点,根据手机号码注册的账号,用户名就是手机号码!
注意:直接放具体使用的方法啦,毕竟还是要对照着源码学习的,而且在这里贴代码,看着也不方便,重点在思想、逻辑啊~
//cn.lili.controller.passport.MemberBuyerController#smsLogin
@PostMapping("/smsLogin")
public ResultMessage<Object> smsLogin(@NotNull(message = "手机号为空") @RequestParam String mobile,
@NotNull(message = "验证码为空") @RequestParam String code,
@RequestHeader String uuid) {
if (smsUtil.verifyCode(mobile, VerificationEnums.LOGIN, uuid, code)) {
return ResultUtil.data(memberService.mobilePhoneLogin(mobile));
} else {
throw new ServiceException(ResultCode.VERIFICATION_SMS_CHECKED_ERROR);
}
}
//cn.lili.modules.member.serviceimpl.MemberServiceImpl#mobilePhoneLogin
@Override
@Transactional
public Token mobilePhoneLogin(String mobilePhone) {
QueryWrapper<Member> queryWrapper = new QueryWrapper<>();
queryWrapper.eq("mobile", mobilePhone);
//根据手机号码获取用户。疑问,不是有 findMember(String userNameOrMobile) 方法吗?为啥不用呢?因为有可能会有用户名为该手机号码的,所以不可以使用哦
Member member = this.baseMapper.selectOne(queryWrapper);
//如果手机号不存在则自动注册用户
if (member == null) {
member = new Member(mobilePhone, UuidUtils.getUUID(), mobilePhone);
//使用注册抽象出来的方法
this.registerHandler(member);
}
this.loginBindUser(member);
//根据会员账号信息,生成token
return memberTokenGenerate.createToken(member, false);
}
C4.手机App/小程序扫码二维码登录接口开发
这个有第四个接口,两个是PC端的,两个是手机小程序/APP端的。
重点在于第四个长轮训校验二维码接口,他贯穿了整个过程。
- 首先,用户点击PC前端的扫码登录,会调用后端获取二维码接口,后端会返回二维码的信息,二维码信息包括token、过期时限等信息,前端拿到后生成二维码展示;
- 用户使用App/小程序进行扫码,扫码之后会拿到二维码中的 token ,然后调用后端扫码接口,并将 token 作为入参,然后根据token拿到缓存的二维码信息,并修改状态为已经扫码,并重新缓存二维码信息,然后返回此时的已经扫码的二维码状态;
- 前端拿到已经扫码的状态后,会打开授权确认页面,点击授权确认/拒绝按钮,都会调用后端的二维码登录确认接口,并将 token 和授权状态作为入参,然后根据token拿到缓存的二维码信息,并修改状态为确认/拒绝扫码,如果是确认会再将userid设置为当前登录用户,并重新缓存二维码信息,然后返回成功;
- 在 1. 中拿到二维码信息后,前端以 token 和 等待扫描状态 为入参,调用后端长轮训校验二维码接口,接口中按照二维码的有效期每隔一秒循环判断是否能够返回连接结果。若用户调用了 2. 的扫码接口,则此轮询接口就返回状态是已经扫码的二维码信息。然后前端接收到已经扫码状态的响应后,会再次以 token 和 已经扫描状态 为入参,调用后端长轮训校验二维码接口。若用户调用了 3. 的确认/拒绝接口,则此轮询接口就返回状态是确认/拒绝的二维码信息,返回的如果是确认状态则会包含登录的Token信息。前端拿到确认状态的响应就按照 Token 执行登录成功方法,如果拿到的是拒绝状态的响应就调用刷新二维码的方法。
业务逻辑:
业务逻辑就看上面,都描述的很清楚了~
重点就是 4. 接口里面的轮询判断,根据业务可以得出此方法的返回值中的二维码登录结果信息中的状态可以是 1:已经扫码、2:同意、3:拒绝、4:过期。
返回 1 是因为需要在前端展示该二维码已经扫码的的状态。
方法返回的状态是 1 后,前端就需要再次调用此长轮询方法,因为还要拿到2:同意/3:拒绝状态,但是针对当前token来说,就不能再次返回状态 1 了,不然前端就会在未授权期间不断调用此接口了!!!需要再添加对应的判断!!!
于是,我们在该方法的入参中添加了 beforeSessionStatus 参数,用来表示上次记录的session状态,前端第一次调用时传值为 0:等待扫码,当后端返回 1:已经扫码后,就将新的 1:已经扫码赋值给 beforeSessionStatus 传参,然后后端经过判断后会返回最终的授权结果~~~
那后端是怎样判断的呢?看下方的代码逻辑~~~
代码逻辑:
//cn.lili.controller.passport.MemberBuyerController
/**
* 买家端,会员接口
*
* @author Chopper
* @since 2020/11/16 10:07 下午
*/
@Slf4j
@RestController
@Api(tags = "买家端,会员接口")
@RequestMapping("/buyer/passport/member")
public class MemberBuyerController {
@Autowired
private MemberService memberService;
@ApiOperation(value = "web-获取手机App/小程序登录二维码")
@PostMapping(value = "/pc_session", produces = "application/json;charset=UTF-8")
public ResultMessage<Object> createPcSession() {
return ResultUtil.data(memberService.createPcSession());
}
/**
* 长轮询:参考nacos
*
* 此方法的返回值中的二维码登录结果信息的状态可以是 1,2,3,4,返回 1 是因为需要在前端展示该二维码已经扫码的的状态,
* 返回 1 然后前端会再次调用此长轮询方法,并且之后(针对当前token来说)就不能再次返回 1 了,不然前端就会在未授权期间不断调用此接口了!
* 所以为了增加token状态的判断,我们在入参中添加了 beforeSessionStatus 参数,表示上次记录的session状态
*
* @param token
* @param beforeSessionStatus 上次记录的session状态,前端只可能传递 0 或 1
* @return
*/
@ApiOperation(value = "web-二维码长轮训校验登录")
@PostMapping(value = "/session_login/{token}", produces = "application/json;charset=UTF-8")
public Object loginWithSession(@PathVariable("token") String token, Integer beforeSessionStatus) {
log.info("receive login with session key {}", token);
//ResponseEntity继承了HttpEntity类,HttpEntity代表一个http请求或者响应实体
ResponseEntity<ResultMessage<Object>> timeoutResponseEntity =
new ResponseEntity<>(ResultUtil.error(ResultCode.ERROR), HttpStatus.OK);
int timeoutSecond = 20;
//建立一次连接,让他们等待尽可能长的时间。这样同时如果有新的数据到达服务器,服务器可以直接返回响应
DeferredResult<ResponseEntity<Object>> deferredResult = new DeferredResult<>(timeoutSecond * 1000L, timeoutResponseEntity);
//异步执行
CompletableFuture.runAsync(() -> {
try {
int i = 0;
while (i < timeoutSecond) {
//根据二维码 token 获取二维码登录结果信息
QRLoginResultVo queryResult = memberService.loginWithSession(token);
int status = queryResult.getStatus();
//为了满足接口调用,此处借助于 beforeSessionStatus 来判断。
//但是源代码里面写的是下面这个逻辑,我觉得不太好理解,于是按照此方法的使用流程写了自己的思考(其实就是将他的判断反转了一下,但是这个思维更好理解点,我觉得好理解了)
// if (status == beforeSessionStatus
// && (QRCodeLoginSessionStatusEnum.WAIT_SCANNING.getCode() == status
// || QRCodeLoginSessionStatusEnum.SCANNING.getCode() == status)) {
//如果status是等待扫描, 并且 beforeSessionStatus 是等待扫描,则(true || false && true) = true
//如果status是已经扫描/同意/拒绝/过期,并且 beforeSessionStatus 是等待扫描,则( false || T/F && false) = false
//如果status是已经扫描, 并且 beforeSessionStatus 是已经扫描,则( false || true && true) = true
//如果status是同意/拒绝/过期, 并且 beforeSessionStatus 是已经扫描,则( false || false && false) = false
if (QRCodeLoginSessionStatusEnum.WAIT_SCANNING.getCode() == status
|| (QRCodeLoginSessionStatusEnum.SCANNING.getCode() == status)
&& status == beforeSessionStatus) {
//睡眠一秒种,继续等待结果
TimeUnit.SECONDS.sleep(1);
} else {
//设置长轮询的返回值
deferredResult.setResult(new ResponseEntity<>(ResultUtil.data(queryResult), HttpStatus.OK));
break;
}
i++;
}
} catch (Exception e) {
log.error("获取登录状态异常,", e);
deferredResult.setResult(new ResponseEntity<>(ResultUtil.error(ResultCode.ERROR), HttpStatus.OK));
Thread.currentThread().interrupt();
}
}, Executors.newCachedThreadPool());
//返回长轮询
return deferredResult;
}
@ApiOperation(value = "App/小程序扫码")
@PostMapping(value = "/app_scanner", produces = "application/json;charset=UTF-8")
public ResultMessage<Object> appScanner(String token) {
return ResultUtil.data(memberService.appScanner(token));
}
@ApiOperation(value = "app扫码-登录确认:同意/拒绝")
@ApiImplicitParams({
@ApiImplicitParam(name = "token", value = "sessionToken", required = true, paramType = "query"),
@ApiImplicitParam(name = "code", value = "操作:0拒绝登录,1同意登录", required = true, paramType = "query")
})
@PostMapping(value = "/app_confirm", produces = "application/json;charset=UTF-8")
public ResultMessage<Object> appSConfirm(String token, Integer code) {
boolean flag = memberService.appSConfirm(token, code);
return flag ? ResultUtil.success() : ResultUtil.error(ResultCode.ERROR);
}
...
}
//cn.lili.modules.member.serviceimpl.MemberServiceImpl
@Service
public class MemberServiceImpl extends ServiceImpl<MemberMapper, Member> implements MemberService {
@Override
public QRCodeLoginSessionVo createPcSession() {
//创建二维码信息
QRCodeLoginSessionVo session = new QRCodeLoginSessionVo();
//设置二维码状态:等待扫码
session.setStatus(QRCodeLoginSessionStatusEnum.WAIT_SCANNING.getCode());
//过期时间,20s
Long duration = 20 * 1000L;
session.setDuration(duration);
String token = CachePrefix.QR_CODE_LOGIN_SESSION.name() + SnowFlake.getIdStr();
session.setToken(token);
//将二维码信息缓存起来
cache.put(token, session, duration, TimeUnit.MILLISECONDS);
return session;
}
@Override
public Object appScanner(String token) {
//获取当前登录用户。这里也没用到,其实可以去掉,或者先存到二维码结果里面
AuthUser tokenUser = UserContext.getCurrentUser();
if (tokenUser == null) {
throw new ServiceException(ResultCode.USER_NOT_LOGIN);
}
//根据token获取二维码信息
QRCodeLoginSessionVo session = (QRCodeLoginSessionVo) cache.get(token);
if (session == null) {
//没有二维码或者二维码已过期,则返回二维码不存在/或者已经过期状态的二维码信息
return QRCodeLoginSessionStatusEnum.NO_EXIST.getCode();
}
//将拿到的二维码状态修改:已经扫码
session.setStatus(QRCodeLoginSessionStatusEnum.SCANNING.getCode());
//然后重新缓存二维码信息
cache.put(token, session, session.getDuration(), TimeUnit.MILLISECONDS);
//返回二维码状态
return QRCodeLoginSessionStatusEnum.SCANNING.getCode();
}
@Override
public boolean appSConfirm(String token, Integer code) {
//获取当前登录用户。
AuthUser tokenUser = UserContext.getCurrentUser();
if (tokenUser == null) {
throw new ServiceException(ResultCode.USER_NOT_LOGIN);
}
//根据 token 获取二维码信息
QRCodeLoginSessionVo session = (QRCodeLoginSessionVo) cache.get(token);
if (session == null) {
//没有二维码或者二维码已过期,则返回二维码不存在/或者已经过期状态的二维码信息
return false;
}
if (code == 1) {
//若登录状态是同意,则修改状态:确认登录
session.setStatus(QRCodeLoginSessionStatusEnum.VERIFIED.getCode());
//并且设置用户id
session.setUserId(Long.parseLong(tokenUser.getId()));
} else {
//若登录状态是拒绝,则修改状态:取消登录
session.setStatus(QRCodeLoginSessionStatusEnum.CANCELED.getCode());
}
//然后重新缓存二维码信息
cache.put(token, session, session.getDuration(), TimeUnit.MILLISECONDS);
return true;
}
@Override
public QRLoginResultVo loginWithSession(String sessionToken) {
//创建二维码登录结果对象
QRLoginResultVo result = new QRLoginResultVo();
result.setStatus(QRCodeLoginSessionStatusEnum.NO_EXIST.getCode());
//获取根据token获取缓存里的二维码信息
QRCodeLoginSessionVo session = (QRCodeLoginSessionVo) cache.get(sessionToken);
if (session == null) {
//没有二维码或者二维码已过期,则返回二维码不存在/或者已经过期状态的二维码信息
return result;
}
result.setStatus(session.getStatus());
//若存在二维码,则校验状态是否是:确认登录,是的话会修改二维码登录结果状态
if (QRCodeLoginSessionStatusEnum.VERIFIED.getCode().equals(session.getStatus())) {
//若是,则根据二维码里面的会员id拿到帐号信息
Member member = this.getById(session.getUserId());
if (member == null) {
throw new ServiceException(ResultCode.USER_NOT_EXIST);
} else {
//拿到帐号信息后,生成token
Token token = memberTokenGenerate.createToken(member, false);
//将token添加到二维码登录结果
result.setToken(token);
//删除缓存里面的二维码信息
cache.vagueDel(sessionToken);
}
}
//返回二维码登录结果
return result;
}
...
}
前端的部分代码 /lilishop-ui-master/buyer/src/pages/Login.vue
//调用web-二维码长轮训校验登录
async qrLogin() {
if(!this.qrSessionToken) return;
sCLogin(this.qrSessionToken,{beforeSessionStatus:this.scannerCodeLoginStatus}).then(response=>{
if (response.success) {
//拿到响应里面的二维码结果状态,并设置给 scannerCodeLoginStatus ,再下次调用此方法时会传递
this.scannerCodeLoginStatus = response.result.status;
switch (response.result.status) {
case 0:
case 1:
//已经扫码状态,继续调用web-二维码长轮训校验登录接口
this.qrLogin();break;
case 2:
//已经授权状态,调用登录成功方法
this.loginSuccess(response.result.token.accessToken,response.result.token.refreshToken);
break;
case 3:
//拒绝授权状态,调用刷新二维码方法
this.createPCLoginSession();
break;
default:
this.clearQRLoginInfo();
break
}
} else{
this.clearQRLoginInfo();
}
});
},