乐优商城项目总结day(16)
短信注册
因为系统中不止注册一个地方需要短信发送,因此我们将短信发送抽取为微服务,凡是需要的地方都可以使用。
另外,因为短信发送API调用时长的不确定性,为了提高程序的响应速度,短信发送我们都将采用异步发送方式,即:
- 短信服务监听MQ消息,收到消息后发送短信。
- 其它服务要发送短信时,通过MQ通知短信微服务。
用户微服务发送短信
controller:
/**
* 发送短信验证码
* @param phone
* @return
*/
@PostMapping(value = "/code")
public ResponseEntity<Void> sendCode(@RequestParam("phone") String phone) {
userService.sendCode(phone);
return ResponseEntity.status(HttpStatus.NO_CONTENT).build();
}
service:
public void sendCode(String phone) {
// 校验手机号码格式
if(!checkPhoneNumber(phone)) {
throw new LyException(ExceptionEnum.INVALID_PHONE_NUMBER);
}
// 生成key
String key = KEY_PREFIX + phone;
// 生成验证码
String code = NumberUtils.generateCode(6);
long saveTime = 5; // 验证码的有效时间
// 生成mq消息
String param = StringUtils.join(Arrays.asList(code, String.valueOf(saveTime)), ",");
Map<String, String> msg = new HashMap<>();
msg.put("mobile", phone);
msg.put("param", param);
// 发送验证码
amqpTemplate.convertAndSend("sms.verify.code", msg);
// 保存验证码
redisTemplate.opsForValue().set(key, code, saveTime, TimeUnit.MINUTES);
}
/**
* 校验手机号码格式
* @param phone
* @return
*/
private boolean checkPhoneNumber(String phone) {
String regex = "^(13[0-9]|14[579]|15[0-3,5-9]|16[6]|17[0135678]|18[0-9]|19[89])\\d{8}$";
Pattern pattern = Pattern.compile(regex);
Matcher matcher = pattern.matcher(phone);
return matcher.matches();
}
将手机号、验证码和短信有效时间封装成map发送给mq,同时将手机号和验证码存入redis并指定短信的时长。
短信微服务接受消息
@Slf4j
@Component
@EnableConfigurationProperties(SmsProperties.class)
public class SmsListener {
@Autowired
private SmsProperties smsProperties;
@Autowired
private SmsUtils smsUtils;
@RabbitListener(bindings = @QueueBinding(
value = @Queue(name = "sms.verify.code.queue", durable = "true"),
exchange = @Exchange(name = "leyou.sms.exchange", type = ExchangeTypes.TOPIC),
key = "sms.verify.code"
))
public void listenSmsVerifyCode(Map<String, String> msg) {
if(CollectionUtils.isEmpty(msg)) {
return;
}
String mobile = msg.get("mobile");
String param = msg.get("param");
if(StringUtils.isBlank(mobile) || StringUtils.isBlank(param)) {
return;
}
// 处理消息,发送验证码
smsUtils.sendSms(mobile, smsProperties.getTemplateid(), param);
}
}
SmsUtils
实现短信的发送,本项目用的是云之讯
提供的短信服务,个人也能进行注册认证并发送短信,默认会提供一定次数的免费短信,十分方便。
云之讯:官网
@Slf4j
@Component
@EnableConfigurationProperties(SmsProperties.class)
public class SmsUtils {
@Autowired
private SmsProperties smsProperties;
@Autowired
private RestTemplate restTemplate;
@Autowired
private StringRedisTemplate redisTemplate;
private static final String KEY_PREFIX = "sms:verifyCode:mobile:";
private static final long SMS_MIN_INTERVAL_IN_MILLIS = 60000;
public SmsResponseVo sendSms(String mobile, String templateid, String param) {
// 对同一个手机号码进行限流
String key = KEY_PREFIX + mobile;
// 读取上一次发送的时间
String lastSendTime = redisTemplate.opsForValue().get(key);
if(StringUtils.isNotBlank(lastSendTime)) {
Long last = Long.valueOf(lastSendTime);
if(System.currentTimeMillis() - last < SMS_MIN_INTERVAL_IN_MILLIS) {
log.info("【短信服务】 发送短信过于频繁被拦截, 拦截手机号:{}", mobile);
return null;
}
}
try {
// 短信接口请求地址
String baseUrl = smsProperties.getBaseUrl();
String sid = smsProperties.getSid();
String token = smsProperties.getToken();
String appid = smsProperties.getAppid();
HttpHeaders httpHeaders = new HttpHeaders();
httpHeaders.setContentType(MediaType.APPLICATION_JSON_UTF8);
Map<String, String> map = new HashMap<>();
map.put("sid", sid);
map.put("token", token);
map.put("appid", appid);
map.put("templateid", templateid);
map.put("param", param);
map.put("mobile", mobile);
HttpEntity<Map<String, String>> request = new HttpEntity<>(map, httpHeaders);
ResponseEntity<SmsResponseVo> response = restTemplate.postForEntity(baseUrl, request, SmsResponseVo.class);
SmsResponseVo responseBody = response.getBody();
if(!"000000".equals(responseBody.getCode())) {
log.info("【短信服务】 发送短信失败, mobile:{}, 原因:{}", mobile, responseBody.getMsg());
} else {
log.info("【短信服务】 发送短信成功, mobile:{}, 验证码:{}", mobile, param);
// 发送成功后写入redis,指定生存时间为1分钟
redisTemplate.opsForValue().set(key, String.valueOf(System.currentTimeMillis()), 1, TimeUnit.MINUTES);
}
return responseBody;
} catch (Exception e) {
log.error("【短信服务】 发送短信异常, mobile:{}", mobile, e);
return null;
}
}
}
发送短信时可以对同一个手机号在一定时间内进行限流,防止恶意发送,可以通过redis实现。
以下为发送短信时需要的配置信息,如果在官网认证成功并创建短信模板和应用后,可以在云之讯的控制台进行查看。
@Component
@Data
@ConfigurationProperties(prefix = "leyou.sms")
public class SmsProperties {
// 指定模板单发请求地址
private String baseUrl;
// 用户的账号唯一标识“Account Sid”,在开发者控制台获取
private String sid;
// 用户密钥“Auth Token”,在开发者控制台获取
private String token;
// 创建应用时系统分配的唯一标示
private String appid;
// 可在后台短信产品→选择接入的应用→短信模板-模板ID,查看该模板ID
private String templateid;
}
用户微服务注册
/**
* 注册
* @param user
* @param code
* @return
*/
@PostMapping(value = "/register")
public ResponseEntity<Void> register(@Valid User user, @RequestParam("code") String code) {
userService.register(user, code);
return ResponseEntity.status(HttpStatus.CREATED).build();
}
通过@Valid
对前端传过来的数据进行校验,使用的是Hibernate Validator。
Hibernate Validator是Hibernate提供的一个开源框架,使用注解方式非常方便的实现服务端的数据校验。
在日常开发中,Hibernate Validator经常用来验证bean的字段,基于注解,方便快捷高效。
简单用法如下:
@Data
@Table(name = "tb_user")
public class User {
@Id
@KeySql(useGeneratedKeys = true)
private Long id;
@NotEmpty(message = "用户名不能为空")
@Length(min = 4, max = 32, message = "用户名长度必须在4~32位")
private String username;// 用户名
@NotEmpty(message = "密码不能为空")
@Length(min = 4, max = 32, message = "密码长度必须在4~32位")
@JsonIgnore
private String password;// 密码
@NotEmpty(message = "手机号不能为空")
@Pattern(regexp = "^(13[0-9]|14[579]|15[0-3,5-9]|16[6]|17[0135678]|18[0-9]|19[89])\\d{8}$", message = "无效的手机号码")
private String phone;// 电话
private Date created;// 创建时间
@JsonIgnore
private String salt;// 密码的盐值
}
常用注解如下:
Constraint | 详细信息 |
---|---|
@Valid | 被注释的元素是一个对象,需要检查此对象的所有字段值 |
@Null | 被注释的元素必须为 null |
@NotNull | 被注释的元素必须不为 null |
@AssertTrue | 被注释的元素必须为 true |
@AssertFalse | 被注释的元素必须为 false |
@Min(value) | 被注释的元素必须是一个数字,其值必须大于等于指定的最小值 |
@Max(value) | 被注释的元素必须是一个数字,其值必须小于等于指定的最大值 |
@DecimalMin(value) | 被注释的元素必须是一个数字,其值必须大于等于指定的最小值 |
@DecimalMax(value) | 被注释的元素必须是一个数字,其值必须小于等于指定的最大值 |
@Size(max, min) | 被注释的元素的大小必须在指定的范围内 |
@Digits (integer, fraction) | 被注释的元素必须是一个数字,其值必须在可接受的范围内 |
@Past | 被注释的元素必须是一个过去的日期 |
@Future | 被注释的元素必须是一个将来的日期 |
@Pattern(value) | 被注释的元素必须符合指定的正则表达式 |
被注释的元素必须是电子邮箱地址 | |
@Length | 被注释的字符串的大小必须在指定的范围内 |
@NotEmpty | 被注释的字符串的必须非空 |
@Range | 被注释的元素必须在合适的范围内 |
@NotBlank | 被注释的字符串的必须非空 |
@URL(protocol=,host=, port=,regexp=, flags=) | 被注释的字符串必须是一个有效的url |
@CreditCardNumber | 被注释的字符串必须通过Luhn校验算法,银行卡,信用卡等号码一般都用Luhn计算合法性 |
最后进行注册:
public void register(User user, String code) {
String phone = user.getPhone();
String username = user.getUsername();
if(!checkData(username, 1) || !checkData(phone, 2)) {
throw new LyException(ExceptionEnum.INVALID_USER_DATA_TYPE);
}
// 校验短信验证码是否正确
String key = KEY_PREFIX + phone;
String cacheCode = redisTemplate.opsForValue().get(key);
if(!StringUtils.equals(code, cacheCode)) {
throw new LyException(ExceptionEnum.INVALID_VERIFY_CODE);
}
// 密码加密
String salt = CodecUtils.generateSalt();
user.setSalt(salt);
user.setPassword(CodecUtils.md5Hex(user.getPassword(), salt));
user.setCreated(new Date());
int count = userDao.insert(user);
if(count != 1) {
throw new LyException(ExceptionEnum.USER_SAVE_ERROR);
}
}
密码加密时需要用你自定义的盐进行二次加密,保证密码的安全性。