# 短信验证码 **Repository Path**: open_sr/sms-verification-code ## Basic Information - **Project Name**: 短信验证码 - **Description**: 短信验证码的发送及用户注册的实现 - **Primary Language**: Java - **License**: Not specified - **Default Branch**: master - **Homepage**: None - **GVP Project**: No ## Statistics - **Stars**: 0 - **Forks**: 1 - **Created**: 2023-08-31 - **Last Updated**: 2023-08-31 ## Categories & Tags **Categories**: Uncategorized **Tags**: None ## README 短信发送验证码的实现: 1>短信发送与注册实现分析 1>点击发送验证码,发起一个请求到后端接口,带取的参数有当前的电话号码 2>请求验证码接口,接收参数电话号码,生成验证码code1,拼接短信信息,发送给手机,返回json格式数据给前端状态 3>用户接收短信,将验证码code2输入,还有各种参数的输入,发起注册请求 4>前端发起注册请求,带各种参数,输入的验证码,后端注册接口接受参数,包括手机号和用户输入的验证码code2 5>将前端传入的验证码code2和后端的验证码code1进行比较,如果一致,进行下一步的逻辑操作,不一致,那么就返回信息 核心问题: 1>如何发送短信 2>发起验证码请求和注册请求之间,如何共享数据? 核心问题的本质: 分析: code1验证码是在点击发送短信请求中的时候创建的,code2是用户发起注册请求时,带取过来的,此时的问题是,我们要进行验证码的验证操作的话,我们就需要获取到发送短信请求时创建的验证码code1,那么就会需要将code1缓存到某个地方,当我们需要的时候就去获取,然后进行验证比较 那么这里涉及到的问题就是:请求之间共享数据的问题 像以往之前我们使用到的就是,登录操作,session共享的问题 解决方法:mysql,session,redis 凡是涉及到技术方案的选择的时候,我们都需要考虑以下方面: 1.根据当前需求(数据特点) 2.时效性(性能问题) mysql:IO操作,读写速度慢,不建议 session:存储在内存中,读写速度快,也具有时效性(默认一般是30分钟失效,也可以去设置存活时间),但是当前项目为前后端分离项目,客户端的话,不仅仅只有pc端,还有app端,小程序端等,app和小程序对于session的支持不太友好,甚至不支持,那么就导致用户无法注册,不建议 redis:是直接在内存中进行操作,读写数据块,具有时效性 所以最终的方案:redis 具体的实现: 发送短信验证请求: 1.用户点击获取验证码,发送短信验证码请求 GET http://localhost:8080/users/sendVerifyCode?phone=13226237958 参数和路径都已经配置好 2.短信请求接口接收请求,获取电话参数,然后生成验证码,拼接短信 3.将短信发送给用户手机 4.将电话作为key ,验证码作为value存入到redis中,注意这里建议放进缓存层中,解耦和方便维护,设置key失效时间,一般是抽取出常量来,一般来说,项目中存在系统常量类,consts 5.返回短信发送提示 发起注册请求: 1.发起注册请求,带取各种参数过来 2.注册接口接受请求,接受参数 3.进行参数的验证,例如不为空,使用到的是断言工具类 4.将验证码比较是否一致,需要从redis中获取,需要创建redis层 5.验证码一致,注册操作,不一致返回错误信息 注意:异常的处理过程 2>同一请求响应对象-JsonResult @Setter @Getter @NoArgsConstructor public class JsonResult { public static final int CODE_SUCCESS = 200; public static final String MSG_SUCCESS = "操作成功"; public static final int CODE_NOLOGIN = 401; public static final String MSG_NOLOGIN = "请先登录"; public static final int CODE_ERROR = 500; public static final String MSG_ERROR = "系统异常,请联系管理员"; public static final int CODE_ERROR_PARAM = 501; //参数异常 private int code; //区分不同结果, 而不再是true或者false private String msg; private T data; //除了操作结果之后, 还行携带数据返回 public JsonResult(int code, String msg, T data){ this.code = code; this.msg = msg; this.data = data; } //操作成功,返回数据data public static JsonResult success(T data){ return new JsonResult(CODE_SUCCESS, MSG_SUCCESS, data); } //操作成功,不返回数据data public static JsonResult success(){ return new JsonResult(CODE_SUCCESS, MSG_SUCCESS, null); } //自定义返回500异常信息 public static JsonResult error(int code, String msg, T data){ return new JsonResult(code, msg, data); } //系统异常,返回500错误,"系统异常,请联系管理员" public static JsonResult defaultError(){ return new JsonResult(CODE_ERROR, MSG_ERROR, null); } //没有登录的,返回401 public static JsonResult noLogin() { return new JsonResult(CODE_NOLOGIN, MSG_NOLOGIN, null); } } 3>项目集成Redis key的设计要唯一,可读性以及失效时间 phone 作为key, 验证码code作为value 1>直接在业务层ServiceImpl层注入StringRedisTemplate对象,存入到Redis 2>建立缓存层,解耦和方便后期维护 //缓存层 @Service public class UserInfoRedisServiceImpl implements IUserInfoRedisService { @Autowired private StringRedisTemplate template; @Override public void sendVerifyCode(String phone, String code) { //将key 和 value 拼接到redis中,注意:设置失效时间 //key 的设计:唯一性,可读性,前缀 String key = "verify_code:" + phone; //参数1:phone ,参数2:验证码code 参数3:失效时间,参数4:时间格式 template.opsForValue().set(key,code, Consts.VERIFY_CODE_VAI_TIME * 60L, TimeUnit.SECONDS); } } //小结: /**对于redis的使用,这里有两种方案 * 1.直接在这里注入对象 StringRedisTemplate 去操作redis * 2.创建一个缓存层,去创建接口和实现类,然后调用方法把数据存入到缓存中 * 这里使用的是:缓存层,因为解耦还有后期维护的问题 */ 4具体实现 //业务层 @Override public void sendVerifyCode(String phone) { //1:接收参数,生成验证码 String code = UUID.randomUUID().toString().replace("-", "").substring(0, 4); //2:将生成的验证码拼接成短信 //为什么使用StringBuilder?因为字长可以变化,而且变化后还是同一个对象,不是创建新的对象 StringBuilder sb = new StringBuilder(80); //字长为80 sb.append("您的短信验证码为").append(code).append(",请在").append(Consts.VERIFY_CODE_VAI_TIME).append("分钟有效"); //3:发送验证码给用户手机 //模拟发送 System.out.println(sb.toString()); //4:将电话作为key,验证码作为value存入到redis中,还有失效时间 TimeUtils.success userInfoRedisService.sendVerifyCode(phone,code); } 用户注册: 1>参数校验不能为空 2>注册实现 @Override public void regist(String phone, String nickname, String password, String rpassword, String verifyCode) { //注意:凡是涉及到前端需要校验的,后端都需要进行校验操作 //1.注册请求,参数校验,使用断言工具类 //参数不能为空 AssertUtil.hasText(phone,"电话号码不能为空"); AssertUtil.hasText(nickname,"用户名不能为空"); AssertUtil.hasText(password,"密码名不能为空"); AssertUtil.hasText(rpassword,"密码不能为空"); AssertUtil.hasText(verifyCode,"验证码不能为空"); //两次输入密码不一致 isEquls AssertUtil.isEquls(password,rpassword,"两次密码不一致"); //手机格式是否正确 --正则表达式 patten AssertUtil.regular(phone,"手机号码格式不正确"); //电话号码是否唯一 if (this.checkPhone(phone)){ throw new LogicException("手机号码已注册"); } //验证码是否一致,从缓存层中去获取 String code = userInfoRedisService.getVerifyCode(phone); //忽略大小写 if (!code.equalsIgnoreCase(verifyCode)){ throw new LogicException("验证码不一致"); } //2.注册实现 UserInfo userInfo = new UserInfo(); userInfo.setPhone(phone); userInfo.setNickname(nickname); userInfo.setPassword(password); userInfo.setHeadImgUrl("/images/default.jpg"); userInfo.setState(UserInfo.STATE_NORMAL); userInfoService.save(userInfo); /**小结:注册功能的实现 * 1.首先用户点击注册按钮,发送请求带取参数到后台 * 2.后端注册接口接受请求和参数 * 3.在业务层进行业务逻辑操作 * 注意:凡是在前端需要校验的操作,在后端都需要进行校验 * 例如:参数不为空,我们这里使用到的是自定义断言工具类 * 还有手机号码唯一,号码格式验证,密码是否一致,验证码是否相等等 * 都需要进校验 * 最后提交注册请求 */ } 3>自定义异常 用户注册会抛出两种异常,一是业务异常(给用户看看),二是系统异常,项目BUG(美化异常) 问题:如何区分业务异常和系统异常呢? 因为系统异常是无穷多的,所以我们换思路:捕获业务异常就可以了 业务异常怎么区分? 自定义异常及时业务异常,在注册方法中,catch两种异常类型,系统异常的话,返回默认系统异常 @PostMapping("/regist") public JsonResult regist(String phone,String nickname,String password,String rpassword,String verifyCode){ try { userInfoService.regist(phone,nickname,password,rpassword,verifyCode); }catch (LogicException e){ //业务异常 e.printStackTrace(); return JsonResult.error(JsonResult.CODE_ERROR,e.getMessage(),null); } catch (Exception e) { //系统异常 e.printStackTrace(); return JsonResult.defaultError(); } /**小结:业务异常和系统异常的区分 * 1.一般业务异常,我们使用自定义异常去代替 * 2.这样的异常处理代码,重复代码太多了,需要通过动态代理的方式去解决 * 3.使用统一异常处理去解决重复代码的问题 */ return JsonResult.success(); } 4>统一异常处理 使用AOP思想,利用动态代理,实现统一异常的处理,减少重复代码 @PostMapping("/regist") public JsonResult regist(String phone,String nickname,String password,String rpassword,String verifyCode){ userInfoService.regist(phone,nickname,password,rpassword,verifyCode); return JsonResult.success(); } /**小结:业务异常和系统异常的区分 * 1.一般业务异常,我们使用自定义异常去代替 * 2.这样的异常处理代码,重复代码太多了,需要通过动态代理的方式去解决 * 3.使用统一异常处理去解决重复代码的问题 */ ------------------------------------------ /** * 通用异常处理类 * ControllerAdvice controller类功能增强注解, 动态代理controller类实现一些额外功能 * * 请求进入controller映射方法之前做功能增强: 经典用法:日期格式化 * 请求进入controller映射方法之后做功能增强: 经典用法:统一异常处理 */ @ControllerAdvice @RestController public class CommonExceptionHandler { //这个方法定义的跟映射方法操作一样 @ExceptionHandler(LogicException.class) public Object logicExp(Exception e, HttpServletResponse resp) { e.printStackTrace(); //可能错误信息中含有中文,所以设置字符编码 resp.setContentType("application/json;charset=utf-8"); return JsonResult.error(JsonResult.CODE_ERROR_PARAM, e.getMessage(), null); } @ExceptionHandler(RuntimeException.class) public Object runTimeExp(Exception e, HttpServletResponse resp) { e.printStackTrace(); //可能错误信息中含有中文,所以设置字符编码 resp.setContentType("application/json;charset=utf-8"); return JsonResult.defaultError(); } } Rides的key的重构设计: 枚举类和普通类的区别在于 1>枚举类的构造器是私有化的,外部无法创建对象 2>枚举类的实例一旦创建好,数量就是固定的 3>枚举类的其他操作和普通类一样 注意:枚举类的setter方法是直接贴在属性上的,因为枚举类的实例对象是固定的,不可更改,贴在类上编译不通过 其实枚举类的对象本质上和类并无区别: 就是简写的一个版本 //枚举 DATA1,DATA2; //普通类 public static final MyData2 DATE1 = new MyData2(); public static final MyData2 DATE2 = new MyData2(); 在RedisKey(管理redis key) 枚举类中,我们约定一个枚举实例对应一个Redis 的Key 枚举类的作用:就是将key的前缀 设置唯一,可读性,以及key失效时间的管理还有,拼接唯一标识 /** * 约定:一个枚举实例对应一个 redis的key * */ @Getter public enum RedisKey { //注册短信验证码 key对象实例 设置前缀和失效时间 REGIST_VERIFY_CODE("regist_verify_code", Consts.VERIFY_CODE_VAI_TIME * 60L); //5分钟 @Setter private String prefix; @Setter private Long time; RedisKey(String prefix, Long time) { this.prefix = prefix; //redis的前缀 this.time = time; //redis的有效时间,单位为s } //redis 完整的拼接 public String join(String ... values){ StringBuilder sb = new StringBuilder(80); //将前缀加进来 ///regist_verify_code:13226237958:yy:zz:xx... sb.append(this.prefix); for (String value : values){ //字符串的拼接 sb.append(":"); sb.append(value); } return sb.toString(); } //测试 public static void main(String[] args) { //regist_verify_code:13226237958 String join = RedisKey.REGIST_VERIFY_CODE.join("13226237958"); System.out.println(join); ///regist_verify_code:13226237958:yy:zz:xx... String join2 = RedisKey.REGIST_VERIFY_CODE.join("13226237958","yy","zz","xx"); System.out.println(join2); } } -------------------------------------------- //缓存层(只需要传入要拼接进 key 的唯一标识即可) @Override public void sendVerifyCode(String phone, String code) { String key = RedisKey.REGIST_VERIFY_CODE.join(phone); //参数1:phone ,参数2:验证码code 参数3:失效时间,参数4:时间格式 template.opsForValue().set(key,code, RedisKey.REGIST_VERIFY_CODE.getTime(), TimeUnit.SECONDS); } /**小结:对于使用枚举类对 Redis 的key进行管理的使用 * 1>需要注意的是:约定好的,必须遵守,一个枚举实例对应一个Redis 的key * 2>然后注意:可拓展性的问题 * 注意:形参中 ... 表示可以多个参数的意思 * 还有失效时间的管理:注意时间格式 */ 短信接口的使用: 在Java代码中如何发起htpp请求? 由spring提供的工具类:RestTemplate template = new RestTemplate (); @Override public void sendVerifyCode(String phone) { //1:接收参数,生成验证码 String code = UUID.randomUUID().toString().replace("-", "").substring(0, 4); //2:将生成的验证码拼接成短信 //为什么使用StringBuilder?因为字长可以变化,而且变化后还是同一个对象,不是创建新的对象 StringBuilder sb = new StringBuilder(80); //字长为80 sb.append("您的短信验证码为").append(code).append(",请在").append(Consts.VERIFY_CODE_VAI_TIME).append("分钟有效"); //3:发送验证码给用户手机 //模拟发送 System.out.println(sb.toString()); //String appkey = "7a1a40b8be1f4cc555cc4e8968e04ee2"; //注意:有些短信接口会强制要求带有 【】的 //String url = "https://way.jd.com/chuangxin/dxjk?mobile={0}&content=【创信】你的验证码是:{1},3分钟内有效!&appkey={2}"; RestTemplate template = new RestTemplate(); //参数1:请求路径,参数2:返回值类型,参数3:url地址占位符填充 String ret = template.getForObject(url, String.class, phone, code, appkey); System.out.println(ret); if (!ret.toUpperCase().contains("SUCCESS")){ //如果短信发送成功,会返回一个SUCCESS ,所以可以从这里判断 throw new LogicException("短信发送失败"); } userInfoRedisService.sendVerifyCode(phone,code); } 记住:学会一个短信接口的使用,其他的接口也要学会举一反三会使用,格局打开 项目与项目之间的交互,服务与服务之间的交互,都是通过HTTP请求去进行交互的 注意: 从spring容器中获取配置文件信息 记得在配置文件中,进行中文转码,转换为unico码,百度在线工具 细节: 1>抽取的问题,像短信接口的路径还有appkey,我们不能写死在代码里,可以抽取到配置文件中,然后在代码中通过@Value注解,获取配置文件中的配置 @Value("${配置文件中的属性名}") @Value("${sms.url}") private String url; @Value("${sms.appkey}") private String appkey; 2>有些短信接口强制要求url路径中,也就是必须有签名【】,硬性要求,请求必须要求有【】,否则无效 3>发送短信,手机没收到,正常,因为是白嫖 传统的登录项目: 1>用户登录时,带取用户名和明码到后端接口进行登录验证,根据传入的用户名和密码去数据库查询user对象,判断user对象是否为空,空表示登录失败,提示错误信息,user对象不为空表示登录成功 2>登录成功后,我们会往session中,存入user对象,username作为key,user对象作为value 3>底层操作: 1.马上创建一个cookie对象,存入sessionId,把sessionId存入到cookie中 2.服务器通过Response响应对象,将cookie对象响应给浏览器 4>浏览器接收服务器响应,解析所有的cookie,并缓存到浏览器中 5>在浏览器下一次在发起请求的时候,会将cookie一并带取过去给服务器 6>服务器会解析浏览器请求中的sessionId对象,根据sessionId,去找对应存在服务器中的session对象,根据session中是否有user对象,进而判断当前用户是否登录过 传统登录操作: 思考:项目二为前后端分离项目,客户端不仅仅有pc客户端,还有APP客户端以及小程序客户端,这些客户端不支持session/cookie,那么此时登录操作怎么办? 解决方法:令牌登录!