接入阿里云发送短信
1.开通服务
1.1.注册阿里云
登录阿里云首页:https://account.aliyun.com/
然后注册账号并登录
1.2.开通短信服务
在首页的产品中,找到短信服务:
首次登入会显示:立即开通
,以后会显示管理控制台
:
可以看到控制台页面:
1.3.付费充值
在顶部菜单中选择费用,充值:
充值后即可使用短信服务发送短信了。
2.服务准备
正式开发发送短信前,还有一系列准备工作要做,主要包括以下几个部分:
- 开通子账户,设置AccessKeyId和AccessKeySecret
- 开通子账户短信权限
- 申请短信签名
- 申请短信模板
2.1.开通子账户
出于安全考虑,我们不能直接使用主账号开发,因为主账号具备整个云服务的完整权限,风险比较大。
所以,我们首先需要开通子账户,用于业务开发。
点击右上角的用户头像,可以选择权限控制:
然后在弹出的页面中,选择新建用户:
然后输入用户名,并选择编程访问
:
这样创建的用户就会带上AccessKeyID和AccessKeySecret了。
2.2.开通短信权限
在用户列表页面,选中一个新建的用户:
然后会进入用户设置页面,我们选择权限管理,并添加权限:
在弹出的列表页面中,搜索短信
相关权限:
选中管理短信服务(SMS)的权限
,点击确定即可!
2.3.短信签名
什么是短信签名?
短信签名是短信服务提供的一种快捷、方便的个性化签名方式。当发送短信时,短信平台会根据设置,在短信内容里附加个性签名,再发送给指定手机号码。
例如,企业主体为“阿里巴巴网络技术有限公司”,则可以提交的签名如下:
- 企业全称或简称:【阿里巴巴】、【阿里巴巴网络技术有限公司】。
- 公司旗下产品名称:【淘宝网】、【阿里云】等。
签名会在短信的开头携带,标示短信发送方的身份。
在管理控制台,找到国内短信菜单:
点击添加签名,进入签名申请页面:
填写信息后,点击确定,等待人工审核即可。
2.4.短信模板
什么是短信模板?
阿里肯定不会允许你随意发送短信,因此会要求你提前定义好短信发送的内容。当然,内容中允许出现一些参数变量,但基本内容是固定的,这样的一套定义好的短信内容,就是短信模板。
短信模板由变量和模板内容构成。模板变量以变量形式提供针对不同手机号码的短信定制方式,在模板中设置变量后,发送短信时指定变量的实际值,短信服务会自动用实际值替换模板变量,并发送短信,实现短信的定制化。
例如:
1
| 【阿里云】您正在申请手机注册,验证码为:${code},5分钟内有效!
|
其中:
- 模板内容为:
您正在申请手机注册,验证码为:${code},5分钟内有效!
。
- 模板变量为:
${code}
。
申请模板
在国内短信菜单中,点击模板管理,
再点击添加模板按钮,进入模板申请页面:
填写信息后,点击提交,等待人工审核即可!
3.阿里SDK
3.1.官方文档
发送短信最终要通过java代码,我们可以再阿里找到对应的文档信息:短信发送文档
请求参数:
名称 |
类型 |
是否必选 |
示例值 |
描述 |
PhoneNumbers |
String |
是 |
15900000000 |
接收短信的手机号码。格式:国内短信:11位手机号码,例如15951955195。国际/港澳台消息:国际区号+号码,例如85200000000。支持对多个手机号码发送短信,手机号码之间以英文逗号(,)分隔。上限为1000个手机号码。批量调用相对于单条调用及时性稍有延迟。说明 验证码类型短信,建议使用单独发送的方式。 |
SignName |
String |
是 |
阿里云 |
短信签名名称。请在控制台签名管理页面签名名称一列查看。说明 必须是已添加、并通过审核的短信签名。 |
TemplateCode |
String |
是 |
SMS_153055065 |
短信模板ID。请在控制台模板管理页面模板CODE一列查看。说明 必须是已添加、并通过审核的短信签名;且发送国际/港澳台消息时,请使用国际/港澳台短信模版。 |
AccessKeyId |
String |
否 |
LTAIP00vvvvvvvvv |
主账号AccessKey的ID。 |
Action |
String |
否 |
SendSms |
系统规定参数。取值:SendSms。 |
OutId |
String |
否 |
abcdefgh |
外部流水扩展字段。 |
SmsUpExtendCode |
String |
否 |
90999 |
上行短信扩展码,无特殊需要此字段的用户请忽略此字段。 |
TemplateParam |
String |
否 |
{“code”:”1111”} |
短信模板变量对应的实际值,JSON格式。说明 如果JSON中需要带换行符,请参照标准的JSON协议处理。 |
返回结果:
名称 |
类型 |
示例值 |
描述 |
BizId |
String |
900619746936498440^0 |
发送回执ID,可根据该ID在接口QuerySendDetails中查询具体的发送状态。 |
Code |
String |
OK |
请求状态码。返回OK代表请求成功。其他错误码详见错误码列表。 |
Message |
String |
OK |
状态码的描述。 |
RequestId |
String |
F655A8D5-B967-440B-8683-DAD6FF8DE990 |
请求ID。 |
3.2.官方Demo
阿里提供了在线测试的Demo:https://api.aliyun.com/?spm=a2c4g.11186623.2.14.56ef50a4PQx5ux#/?product=Dysmsapi&api=SendSms
填写基本信息,即可发送并测试。
4.创建短信微服务
在以前,我们都是把发送短信功能抽取一个工具类,任何地方需要,直接调用工具类发送短信即可。这样的方式存在以下缺点:
- 发送短信代码与业务代码耦合
- 短信发送功能会影响业务功能执行
如果在业务代码中,嵌入发送短信的代码,那就是功能的耦合,不方便后期的维护。而且
因为短信发送是调用第三方的云服务,API调用成功与否、执行时长都是不确定的。如果执行发短信时,因为网络问题导致阻塞,那么我们自己的业务也会阻塞。
为了解决上述问题,提高程序的响应速度,短信发送我们都将采用异步发送方式,即:
- 短信服务监听MQ消息
- 收到消息后发送短信,根据消息
routing_key
不同,发送不同类型的短信
- 其它服务要发送短信时,通过MQ通知短信微服务。
4.1.创建module
4.2.pom
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42
| <?xml version="1.0" encoding="UTF-8"?> <project xmlns="http://maven.apache.org/POM/4.0.0" xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 http://maven.apache.org/xsd/maven-4.0.0.xsd"> <parent> <artifactId>leyou</artifactId> <groupId>com.leyou</groupId> <version>1.0.0-SNAPSHOT</version> </parent> <modelVersion>4.0.0</modelVersion>
<artifactId>ly-sms</artifactId>
<dependencies> <dependency> <groupId>org.springframework.boot</groupId> <artifactId>spring-boot-starter-web</artifactId> </dependency> <dependency> <groupId>com.aliyun</groupId> <artifactId>aliyun-java-sdk-core</artifactId> <version>4.1.1</version> </dependency> <dependency> <groupId>org.springframework.boot</groupId> <artifactId>spring-boot-starter-amqp</artifactId> </dependency> <dependency> <groupId>com.leyou</groupId> <artifactId>ly-common</artifactId> <version>1.0.0-SNAPSHOT</version> </dependency> </dependencies> <build> <plugins> <plugin> <groupId>org.springframework.boot</groupId> <artifactId>spring-boot-maven-plugin</artifactId> </plugin> </plugins> </build> </project>
|
4.3.编写启动类
我们在ly-sms
的com.leyou.sms
包下,新建一个启动类:
1 2 3 4 5 6 7 8 9 10 11
| package com.leyou.sms;
import org.springframework.boot.SpringApplication; import org.springframework.boot.autoconfigure.SpringBootApplication;
@SpringBootApplication(scanBasePackages = {"com.leyou.sms", "com.leyou.common.advice"}) public class LySmsApplication { public static void main(String[] args) { SpringApplication.run(LySmsApplication.class, args); } }
|
4.4.编写application.yml
1 2 3 4 5 6 7 8 9 10
| server: port: 8085 spring: application: name: sms-service rabbitmq: host: ly-mq username: leyou password: 123321 virtual-host: /leyou
|
5.编写短信工具类
接下来,我们把刚刚学习的Demo中代码抽取成一个工具,方便后期使用
5.1.属性抽取
我们首先把一些常量抽取到application.yml中:
1 2 3 4 5 6 7 8 9 10
| ly: sms: accessKeyID: LTAIfmmL26haCK0b accessKeySecret: pX3RQns9ZwXs75M6Isae9sMgBLXDfY signName: 乐优商城 verifyCodeTemplate: SMS_143719983 domain: dysmsapi.aliyuncs.com action: SendSMS version: 2017-05-25 regionID: cn-hangzhou
|
然后在com.leyou.sms.config
包下,定义一个类,读取yml文件的属性:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41
| package com.leyou.sms.config;
import lombok.Data; import org.springframework.boot.context.properties.ConfigurationProperties;
@Data @ConfigurationProperties(prefix = "ly.sms") public class SmsProperties {
String accessKeyID;
String accessKeySecret;
String signName;
String verifyCodeTemplate;
String domain;
String version;
String action;
String regionID; }
|
5.2.阿里客户端
然后通过java配置,将发请求需要的客户端注册到Spring容器。
在com.leyou.sms.config
包定义一个配置类,标记为@Configuration
,并通过@Bean
注册阿里客户端到Spring:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21
| package com.leyou.sms.config;
import com.aliyuncs.DefaultAcsClient; import com.aliyuncs.IAcsClient; import com.aliyuncs.profile.DefaultProfile; import org.springframework.boot.context.properties.EnableConfigurationProperties; import org.springframework.context.annotation.Bean; import org.springframework.context.annotation.Configuration;
@Configuration @EnableConfigurationProperties(SmsProperties.class) public class SmsConfiguration {
@Bean public IAcsClient acsClient(SmsProperties prop){ DefaultProfile profile = DefaultProfile.getProfile( prop.getRegionID(), prop.getAccessKeyID(), prop.getAccessKeySecret()); return new DefaultAcsClient(profile); } }
|
5.3.工具类
我们把阿里提供的demo进行简化和抽取,封装一个工具类。
在com.leyou.sms.utils
包下,新建工具类:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86
| package com.leyou.sms.utils;
import com.aliyuncs.CommonRequest; import com.aliyuncs.CommonResponse; import com.aliyuncs.IAcsClient; import com.aliyuncs.exceptions.ClientException; import com.aliyuncs.exceptions.ServerException; import com.aliyuncs.http.MethodType; import com.aliyuncs.http.ProtocolType; import com.leyou.common.utils.JsonUtils; import com.leyou.sms.config.SmsProperties; import lombok.extern.slf4j.Slf4j; import org.apache.commons.lang3.StringUtils; import org.springframework.stereotype.Component;
import java.util.Map;
import static com.leyou.sms.constants.SmsConstants.*;
@Slf4j @Component public class SmsUtils {
private final IAcsClient client;
private final SmsProperties prop;
public SmsUtils(IAcsClient client, SmsProperties prop) { this.client = client; this.prop = prop; }
public void sendVerifyCode(String phone, String code) { String param = String.format(VERIFY_CODE_PARAM_TEMPLATE, code); sendMessage(phone, prop.getSignName(), prop.getVerifyCodeTemplate(), param); }
private void sendMessage(String phone, String signName, String template, String param) { CommonRequest request = new CommonRequest(); request.setProtocol(ProtocolType.HTTPS); request.setMethod(MethodType.POST); request.setDomain(prop.getDomain()); request.setVersion(prop.getVersion()); request.setAction(prop.getAction()); request.putQueryParameter(SMS_PARAM_KEY_PHONE, phone); request.putQueryParameter(SMS_PARAM_KEY_SIGN_NAME, signName); request.putQueryParameter(SMS_PARAM_KEY_TEMPLATE_CODE, template); request.putQueryParameter(SMS_PARAM_KEY_TEMPLATE_PARAM, param);
try { CommonResponse response = client.getCommonResponse(request); if (response.getHttpStatus() >= 300) { log.error("【SMS服务】发送短信失败。响应信息:{}", response.getData()); } Map<String, String> resp = JsonUtils.toMap(response.getData(), String.class, String.class); if (!StringUtils.equals(OK, resp.get(SMS_RESPONSE_KEY_CODE))) { log.error("【SMS服务】发送短信失败,原因{}", resp.get(SMS_RESPONSE_KEY_MESSAGE)); } log.info("【SMS服务】发送短信成功,手机号:{}, 响应:{}", phone, response.getData()); } catch (ServerException e) { log.error("【SMS服务】发送短信失败,服务端异常。", e); } catch (ClientException e) { log.error("【SMS服务】发送短信失败,客户端异常。", e); } } }
|
这里把阿里SDK中会用到的一些参数KEY,响应KEY都定义成了常量,定义在ly-sms
的com.leyou.sms.constants
包下:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28
| package com.leyou.sms.constants;
public final class SmsConstants {
public static final String SMS_PARAM_KEY_PHONE = "PhoneNumbers"; public static final String SMS_PARAM_KEY_SIGN_NAME = "SignName"; public static final String SMS_PARAM_KEY_TEMPLATE_CODE = "TemplateCode"; public static final String SMS_PARAM_KEY_TEMPLATE_PARAM= "TemplateParam";
public static final String SMS_RESPONSE_KEY_CODE = "Code"; public static final String SMS_RESPONSE_KEY_MESSAGE = "Message";
public static final String OK = "OK";
public static final String VERIFY_CODE_PARAM_TEMPLATE = "{\"code\":\"%s\"}"; }
|
如图:
6.编写消息监听器
接下来,在com.leyou.sms.mq
包中编写消息监听器,当接收到消息后,我们发送短信。我们可以通过routing_key
,监听不同类型消息,发送不同类型短信。
本例中,我们监听VERIFY_CODE_KEY
,发送验证码短信。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61
| package com.leyou.sms.mq;
import com.leyou.common.utils.RegexUtils; import com.leyou.sms.utils.SmsUtils; import lombok.extern.slf4j.Slf4j; import org.springframework.amqp.core.ExchangeTypes; import org.springframework.amqp.rabbit.annotation.Exchange; import org.springframework.amqp.rabbit.annotation.Queue; import org.springframework.amqp.rabbit.annotation.QueueBinding; import org.springframework.amqp.rabbit.annotation.RabbitListener; import org.springframework.stereotype.Component; import org.springframework.util.CollectionUtils;
import java.util.Map;
import static com.leyou.common.constants.MQConstants.*;
@Slf4j @Component public class MessageListener {
private final SmsUtils smsUtils;
public MessageListener(SmsUtils smsUtils) { this.smsUtils = smsUtils; }
@RabbitListener(bindings = @QueueBinding( value = @Queue(name = QueueConstants.SMS_VERIFY_CODE_QUEUE, durable = "true"), exchange = @Exchange(name = ExchangeConstants.SMS_EXCHANGE_NAME, type = ExchangeTypes.TOPIC), key = RoutingKeyConstants.VERIFY_CODE_KEY )) public void listenVerifyCodeMessage(Map<String,String> msg){ if(CollectionUtils.isEmpty(msg)){ return; } String phone = msg.get("phone"); if (!RegexUtils.isPhone(phone)) { return; } String code = msg.get("code"); if (!RegexUtils.isCodeValid(code)) { return; } try { } catch (Exception e) { log.error("【SMS服务】短信验证码发送失败", e); } } }
|
我们注意到,消息体是一个Map,里面有两个属性:
7.启动
启动项目,然后查看RabbitMQ控制台,发现交换机已经创建:
8.单元测试
编写一个测试类,尝试发送一条短信消息:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17
| @RunWith(SpringRunner.class) @SpringBootTest public class SmsTest {
@Autowired private AmqpTemplate amqpTemplate;
@Test public void testSendMessage() throws InterruptedException { Map<String,String> map = new HashMap<>(); map.put("phone", "13000000000"); map.put("code", "123321"); amqpTemplate.convertAndSend("ly.sms.exchange", "sms.verify.code", map);
Thread.sleep(5000); } }
|
加盐版MD5密码加密
密码加密使用传统的MD5加密并不安全,这里我们使用的是Spring提供的BCryptPasswordEncoder加密算法,分成加密和验证两个过程:
为了防止有人能根据密文推测出salt,我们需要在使用BCryptPasswordEncoder时配置随即密钥,在com.leyou.user.config
包中创建一个配置类,注册BCryptPasswordEncoder
对象:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27
| package com.leyou.user.config;
import lombok.Data; import org.springframework.boot.context.properties.ConfigurationProperties; import org.springframework.context.annotation.Bean; import org.springframework.context.annotation.Configuration; import org.springframework.security.crypto.bcrypt.BCryptPasswordEncoder;
import java.security.SecureRandom;
@Data @Configuration @ConfigurationProperties(prefix = "ly.encoder.crypt") public class PasswordConfig {
private int strength; private String secret;
@Bean public BCryptPasswordEncoder passwordEncoder(){ SecureRandom secureRandom = new SecureRandom(secret.getBytes()); return new BCryptPasswordEncoder(strength, secureRandom); } }
|
在配置文件中配置属性:
1 2 3 4 5
| ly: encoder: crypt: secret: ${random.uuid} strength: 6
|
然后,在ly-user
的com.leyou.user.service
包中的UserService
接口中添加方法:
1
| void register(User user, String code);
|
在ly-user
的com.leyou.user.service.impl
包中的UserServiceImpl
中添加方法:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15
| @Transactional public void register(User user, String code) { String cacheCode = redisTemplate.opsForValue().get(KEY_PREFIX + user.getPhone()); if (!StringUtils.equals(code, cacheCode)) { throw new LyException(400, "验证码错误"); } user.setPassword(passwordEncoder.encode(user.getPassword()));
save(user); }
|
引入bean:
1 2 3 4 5
| private final BCryptPasswordEncoder passwordEncoder;
public UserServiceImpl(BCryptPasswordEncoder passwordEncoder) { this.passwordEncoder = passwordEncoder; }
|
密码解密:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18
| @Override public UserDTO queryUserByPhoneAndPassword(String username, String password) { User user = getOne(new QueryWrapper<User>().lambda().eq(User::getUsername, username)); if (user == null) { throw new LyException(400, "用户名或密码错误"); }
if(!passwordEncoder.matches(password, user.getPassword())){ throw new LyException(400, "用户名或密码错误"); } return new UserDTO(user); }
|