1 背景: 小豆社保半夜短信费用完
小豆社保:是一家一站式人力资源SAAS服务云智慧平台,隶属于北京新琪科技有限公司, 说简单点就是解决工作变动无挂靠单位的人代缴社保的业务。
阿亮: 小豆社保CTO, 原安邦集团系统架构师 。
需求(半夜电话): 最近业务增长迅猛,我们公司预存的短信条数用完, 短信服务商半夜无法充值,怎么办?
是否可以同时对接几家短信公司的解决方案,这样有一个通道费用用完或万一出现故障,可以自动切换到其它通道。
解决方案(回复): 提供企业短信网关,最简单的报文接口即可完成对接,限制IP 调用 ,几行代码搞定, 问题解决。
为何企业短信网关能快速解决问题,以下将对什么叫企业短信网关 。
2 企业短信网关
- 短信网关, 指同时对接移动、联通、电信的大网关, 根据短信号码自动识别属于哪家运营商。
- 企业短信网关, 同一个企业可以同时对接多家短信服务商,比如助通科技、亿美软通、云掌通等.
更多短信服务商参考《2021全网最全短信服务商排名》。
区别: 前者(短信网关)是和移动、联通、电信(类似银行)直接对接,该系统一般由短信服务商建设。
后者(企业短信网关)是和短信服务商(类似第三方支付,如易宝支付)对接,比如助通科技、亿美软通、云掌通等 。该系统由终端用户建设。
2.1企业短信网关架构
参考支付网关《最早的支付网关(滴滴支付)和最新的聚合支付设计架构》
企业只需要简单的配置多个接口的账户密码,
设置每个接口相应的流量比例, 路由功能将自动分配流量到不同的短信通道。
2.2 路由及接口配置 (sms_config.ini)
{
"route": {
"izton": "30",
"ztinfo":"50",
"aliyun":"20",
"emay":"0",
"tzhl":"0",
"monyun":"0"
},
"izton": {
"smsUrl": "http://139.129.107.160:8085/sendsms.php",
"userid": "",
"password": "",
"ext": "2033"
},
"emay": {
"smsUrl": "http://www.btom.cn:8080",
"appId": "",
"secretKey": ""
},
"tzhl": {
"smsUrl": "http://sms.tongzhouhl.com:9885/c123",
"tzId": "",
"tzPwd": ""
},
"aliyun": {
"endpoint": "http://dysmsapi.aliyuncs.com",
"accessKeyId": "",
"accessKeySecret": ""
},
"ztinfo": {
"smsUrl": "http://api.mix2.zthysms.com",
"username": "",
"password": "!"
},
"monyun": {
"smsUrl": "http://api01.monyun.cn:7901",
"userid": "",
"password": ""
}
}
2.3 路由及动态加载接口
package com.newxtc.sms;
import java.util.Map;
import java.util.Set;
import java.util.concurrent.atomic.AtomicLong;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import com.alibaba.fastjson.JSONObject;
import com.newxtc.sms.cache.SpAccCache;
import com.newxtc.sms.entity.SmsRetMsg;
public class SmsProvider {
private final static Logger logger = LoggerFactory.getLogger(SmsProvider.class);
public static SmsApi getSmsApi(String spCode) {
Map<String, String> configMap = SmsInit.getInstance().getConfig(spCode);
return configMap != null ? new SmsClassLoad(configMap, spCode) : null;
}
public static SmsApi getTpSmsApi(String spCode) {
Map<String, String> configMap = SmsInit.getInstance().getConfig(spCode);
return configMap != null ? new SmsClassLoad(configMap, spCode) : null;
}
public static String sendTpSms(String phone, String templateId, String templateJson) {
if (phone == null || templateId == null) {
logger.error("sendTpSms() phone=" + phone + "|templateId=" + templateId);
}
String routeType = "route";
Map<String, String> configMap = SmsInit.getInstance().getConfig(routeType);
Set<String> key = configMap != null ? configMap.keySet() : null;
if (key != null && key.size() > 0) {
// 通道个数
String spCode = null;
SmsRetMsg retMsg = null;
SmsApi smsImpl = null;
try {
boolean isJson = templateJson != null && templateJson.contains("{") && templateJson.contains("}") && templateJson.contains(":");
@SuppressWarnings("unchecked")
Map<String, String> paramMap = isJson ? JSONObject.parseObject(templateJson, Map.class) : null;
for (int i = 0; i <= 1; i++) {
spCode = SmsProvider.getRoute(configMap, null, routeType);
smsImpl = isTpSp(spCode) ? getTpSmsApi(spCode) : getSmsApi(spCode);
retMsg = smsImpl != null ? smsImpl.sendTemplateSms(phone, templateId, paramMap) : null;
Integer ret = (retMsg != null) ? retMsg.getRet() : null;
if (ret != null && ret.equals(0)) {
break;
} else {
if (configMap.size() == 1)
spCode = null;
logger.error("first ret=" + ret);
continue;
}
}
if (retMsg != null) {
JSONObject json = new JSONObject();
json.put("ret", retMsg.getRet());
json.put("msg", retMsg.getMsg());
json.put("spCode", spCode);
String message = json.toJSONString();
return message;
} else {
logger.error("spCode=" + spCode + "|retMsg=" + retMsg);
return null;
}
} catch (Exception e) {
logger.error("spCode=" + spCode + "|e=" + e.toString());
for (StackTraceElement elment : e.getStackTrace()) {
logger.error(elment.toString());
}
return null;
}
} else {
logger.error("no exist smsProvider");
return null;
}
}
private static boolean isTpSp(String spCode) {
Map<String, String> configMap = SmsInit.getInstance().getConfig("route_msg");
return !configMap.containsKey(spCode);
}
public static String sendSms(String phone, String msg) {
String routeType = "route_msg";
Map<String, String> configMap = SmsInit.getInstance().getConfig(routeType);
Set<String> key = configMap != null ? configMap.keySet() : null;
if (key != null && key.size() > 0) {
// 通道个数
String spCode = null;
SmsRetMsg retMsg = null;
SmsApi smsImpl = null;
spCode = SmsProvider.getRoute(configMap, null, routeType);
smsImpl = getSmsApi(spCode);
retMsg = smsImpl != null ? smsImpl.sendSms(phone, msg) : null;
if (retMsg.getRet() != 0) {
logger.error("first retMsg=" + retMsg);
spCode = SmsProvider.getRoute(configMap, spCode, routeType);
smsImpl = getSmsApi(spCode);
retMsg = smsImpl != null ? smsImpl.sendSms(phone, msg) : null;
}
if (retMsg != null) {
JSONObject json = new JSONObject();
json.put("ret", retMsg.getRet());
json.put("msg", retMsg.getMsg());
json.put("spCode", spCode);
String message = json.toJSONString();
return message;
} else {
logger.error("no exist smsProvider");
return null;
}
} else {
logger.error("no exist smsProvider");
return null;
}
}
/**
* 根据当前发送量,和目标比率对比,选择发送的通道
*
* @return
*/
public static String getRoute(Map<String, String> configMap, String exinclude, String smsType) {
// 1 计算的各通道实际发送比率
// 配置分母值 Denominator
long configSum = 0, cacheSum = 0;
// 内存中实际的总署
long cacheCount = 0;
// 第一次遍历计算总数
for (String spCode : configMap.keySet()) {
configSum += Long.parseLong(configMap.get(spCode));
cacheSum += SpAccCache.getInstance().get(spCode);
}
// 第二次遍历计算每个通道的比率
double from, destn;// 目标比率
String retSp = null;// 命中通道
for (String spCode : configMap.keySet()) {
retSp = spCode;
if (exinclude != null && spCode.equals(exinclude))
continue;
long configSp = Long.parseLong(configMap.get(spCode));
cacheCount = SpAccCache.getInstance().get(spCode);
from = (cacheCount * 1.0) / cacheSum;
destn = (configSp * 1.0) / configSum;
if (from < destn) {
logger.debug("getRoute() spCode=" + spCode + "|from=" + from + "|destn=" + destn);
break;
}
}
return retSp;
}
public static void main(String[] args) throws Exception {
SmsInit.getInstance().init(null);
String templateId = "1";
String templateJson = "{\"code\":\"8988788\"}";
for (int i = 0; i < 20; i++) {
SmsProvider.sendTpSms("13718211912", templateId, templateJson);
Thread.sleep(20000);
}
Map<String, AtomicLong> m = SpAccCache.getInstance().getAccMap();
for (String spCode : m.keySet()) {
Long l = m.get(spCode).get();
System.out.println(spCode + "=" + l);
}
}
}
2.4 发送次数计数器
利用原子技术及内存结构, 分别为每一个通道计数
package com.newxtc.sms.cache;
import java.util.Map;
import java.util.concurrent.ConcurrentHashMap;
import java.util.concurrent.atomic.AtomicLong;
public class SpAccCache {
// 内存 key: spCode : value : 计数器
private static Map<String, AtomicLong> accMap = null;
private static SpAccCache statisticsResultCache = null;
public static SpAccCache getInstance() {
if (statisticsResultCache == null) {
statisticsResultCache = new SpAccCache();
accMap = new ConcurrentHashMap<String, AtomicLong>();
}
return statisticsResultCache;
}
public Map<String, AtomicLong> getAccMap() {
return accMap;
}
// 累加器
public long inc(String spCode) {
AtomicLong atomic = accMap.get(spCode);
if (atomic == null) {
atomic = new AtomicLong();
accMap.put(spCode, atomic);
}
return atomic.incrementAndGet();
}
public long get(String spCode) {
AtomicLong atomic = accMap.get(spCode);
return atomic != null ? atomic.get() : 0;
}
}
2.5 发送回执多线程异步获取
短信请求后, 短信服务商的回应只是说这笔请求已经收到,但并不是真正发送的结果,
获取的的方式有2种,
- 查询,一般可以查询最近一个时段的, 查询过的不再返回,
- 有些服务商也会主动通知,但主动通知需要暴露一个互联网端口,为了收取短信回执不值得的, 所以采用的比较少。
多线程延时队列,采用延时队列,可以按指定的时间执行任务。
package com.newxtc.sms.delay;
import java.util.concurrent.BlockingQueue;
import java.util.concurrent.LinkedBlockingQueue;
import java.util.concurrent.RejectedExecutionException;
import java.util.concurrent.ThreadPoolExecutor;
import java.util.concurrent.TimeUnit;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
/**
* @author RAF
*
*/
public class DelayThread {
// log
private final static Logger logger = LoggerFactory.getLogger(DelayThread.class);
private BlockingQueue<Runnable> taskQueue;
private ThreadPoolExecutor pool;
private int dispatchQueueSize = 10000;
private int disatchThreadSize = 4;
private static DelayThread instance = null;
public synchronized static DelayThread getInstance() {
if (instance == null) {
instance = new DelayThread();
System.out.println("DelayThread init ");
}
return instance;
}
/**
*
* @param config_path
* 配置路径
* @param threadName
* 线程名称, 线程的配置文件为:配置路径 + 线程名称.ini
* @param taskEntityCls
* 任务实体类 , 必须实现ScanTaskApi 接口
* @param runCls
* 任务运行类, 必须实现 ThreadRunApi 接口
*/
public DelayThread() {
try {
taskQueue = new LinkedBlockingQueue<Runnable>(dispatchQueueSize);
pool = new ThreadPoolExecutor(disatchThreadSize, disatchThreadSize * 2, 10 * 1000L, TimeUnit.MILLISECONDS, taskQueue, new ThreadPoolExecutor.AbortPolicy());
} catch (Exception e) {
logger.error(e.toString());
}
}
public ThreadPoolExecutor getThreadPoolExecutor() {
return this.pool;
}
public void addTask(DelayTask task) {
try {
pool.execute(task);
} catch (RejectedExecutionException e) {
logger.error(e.toString());
} catch (Throwable e) {
logger.error(e.toString());
}
}
}
执行任务
package com.newxtc.sms.delay;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import com.newxtc.sms.SmsApi;
import com.newxtc.sms.delay.cache.DelayCache;
import com.newxtc.sms.delay.entity.Delay;
public class DelayTask implements Runnable {
private final static Logger logger = LoggerFactory.getLogger(DelayTask.class);
private SmsApi smsApi = null;
private String uniqId = null; // 支持根据具体单号来查询
private int repeat = 0;
public DelayTask(String uniqId, SmsApi smsApi) {
this.uniqId = uniqId;
this.smsApi = smsApi;
}
public String getUniqId() {
return uniqId;
}
@Override
public void run() {
try {
repeat++;
if (smsApi == null) {
logger.error("uniqId=" + uniqId + "|smsApi=" + smsApi);
return;
}
int ret = smsApi.getReport();
// 碰到错误返回,最多重试3次,避免系统性问题导致重复调用
if (ret != 0 && repeat <= 3) {
Delay delay = new Delay();
delay.setTask(this);
delay.setE(System.currentTimeMillis() + 10000);
DelayCache.getInstance().put(delay);
logger.debug("delayTask repeat=" + repeat + "|next time=" + delay.getE());
}
} catch (Throwable e) {
logger.error("run Error", e);
for (StackTraceElement ele : e.getStackTrace())
logger.error(ele.toString());
}
}
}
2.6 短信调用测试
测试类
com.newxtc.sms.SmsProvider
public static void main(String[] args) throws Exception {
SmsInit.getInstance().init(null);
String templateId = "1";
String templateJson = "{\"code\":\"8988788\"}";
for (int i = 0; i < 20; i++) {
SmsProvider.sendTpSms("13718211912", templateId, templateJson);
Thread.sleep(20000);
}
Map<String, AtomicLong> m = SpAccCache.getInstance().getAccMap();
for (String spCode : m.keySet()) {
Long l = m.get(spCode).get();
System.out.println(spCode + "=" + l);
}
}
测试日志:
SmsConfig init success totalWeight=100
|–route={izton=30, ztinfo=50, aliyun=20}
|–key=izton
|–key=emay
|–key=tzhl
|–key=aliyun
|–key=ztinfo
|–key=monyun
|–route_msg={izton=30, ztinfo=50, aliyun=20}
templateSign=新昕科技
template=[1, 2, 3, 4, 5, 6, 7, 8, 9, 10]
|-1->您的验证码是:${code},有效期为1分钟。如非本人操作,可不用理会。
|-2->您购买的商品已支付成功,支付金额 p a y p r i c e 元 , 订 单 号 {pay_price}元,订单号 payprice元,订单号{order_id},感谢您的光临!
|-3->亲爱的用户${nickname},您的商品 s t o r e n a m e , 订 单 号 {store_name},订单号 storename,订单号{order_id}已发货,请注意查收
|-4->亲,您的订单 o r d e r i d , 商 品 {order_id},商品 orderid,商品{store_name}已确认收货,感谢您的光临!
|-5-> a d m i n n a m e 管 理 员 , 您 有 一 笔 已 支 付 的 订 单 待 处 理 , 订 单 号 为 {admin_name}管理员,您有一笔已支付的订单待处理,订单号为 adminname管理员,您有一笔已支付的订单待处理,订单号为{order_id}!
|-6-> a d m i n n a m e 管 理 员 , 您 有 一 笔 支 付 成 功 的 订 单 待 处 理 , 订 单 号 {admin_name}管理员,您有一笔支付成功的订单待处理,订单号 adminname管理员,您有一笔支付成功的订单待处理,订单号{order_id}!
|-7-> a d m i n n a m e 管 理 员 , 您 有 一 笔 退 款 订 单 待 处 理 , 订 单 号 {admin_name}管理员,您有一笔退款订单待处理,订单号 adminname管理员,您有一笔退款订单待处理,订单号{order_id}!
|-8-> a d m i n n a m e 管 理 员 , 您 有 一 笔 订 单 已 经 确 认 收 货 , 订 单 号 {admin_name}管理员,您有一笔订单已经确认收货,订单号 adminname管理员,您有一笔订单已经确认收货,订单号{order_id}!
|-9->您有未付款订单,订单号为: o r d e r i d , 商 品 数 量 有 限 , 请 及 时 付 款 。 ∣ − 10 − > 您 的 订 单 {order_id},商品数量有限,请及时付款。 |-10->您的订单 orderid,商品数量有限,请及时付款。∣−10−>您的订单{order_id},实际支付金额已被修改为${pay_price}
aliyun=[1, 2, 3, 4, 5, 6, 7, 8, 9, 10]
|-1->SMS_193517056
|-2->SMS_193506971
|-3->SMS_193521858
|-4->SMS_193521862
|-5->SMS_193516931
|-6->SMS_193511960
|-7->SMS_193511961
|-8->SMS_193516936
|-9->SMS_193506990
|-10->SMS_193516944
19:14:12.163 [main] ERROR com.newxtc.sms.impl.AliyunSmsImpl - sendTpSms() paramsMap={Format=JSON, SignName=新昕科技, SignatureMethod=HMAC-SHA1, TemplateCode=SMS_193517056, Signature=aBR/qWCZb8CqP709yDWdKEJ1i6Q=, Timestamp=2021-02-07T11:14:11Z, TemplateParam={“code”:“8988788”}, OutId=13718211912-1612696451861, AccessKeyId=, Action=SendSms, RegionId=cn-hangzhou, SignatureNonce=6236648b-937a-4cf1-bd42-2ddac5540d32, SignatureVersion=1.0, Version=2017-05-25, PhoneNumbers=13718211912}|response=
19:14:12.166 [main] ERROR com.newxtc.sms.SmsProvider - first ret=-99
19:14:12.166 [main] DEBUG com.newxtc.sms.SmsProvider - getRoute() spCode=izton|from=0.0|destn=0.3
19:14:12.167 [main] DEBUG com.newxtc.sms.impl.IztonSmsImpl - sendTpSms() {code=8988788}
19:14:12.201 [main] INFO com.newxtc.sms.impl.IztonSmsImpl - sendSms() spCode=izton|phone=13718211912|total=1|msg=【新昕科技】您的验证码是:8988788,有效期为1分钟。如非本人操作,可不用理会。
19:14:12.202 [main] ERROR com.newxtc.sms.SmsProvider - first ret=-1
19:14:32.202 [main] DEBUG com.newxtc.sms.SmsProvider - getRoute() spCode=ztinfo|from=0.0|destn=0.5
19:14:32.203 [main] DEBUG com.newxtc.sms.impl.ZtinfoSmsImpl - sendTpSms() {code=8988788}
19:14:32.335 [main] ERROR com.newxtc.sms.impl.ZtinfoSmsImpl - sendSms() jsonObj={“content”:"【新昕科技】您的验证码是:8988788,有效期为1分钟。如非本人操作,可不用理会。",“username”:"",“tKey”:“1612696472”,“extend”:“13718211912-1612696472”,“password”:“e7e2bc55be19eedae481fcb398c7c891”,“mobile”:“13718211912”}|response={“code”:4001,“msg”:“username wrong”,“msgId”:“161269646780396789761”,“contNum”:0}
19:14:32.336 [main] ERROR com.newxtc.sms.SmsProvider - first ret=4001
代码参考: 下载地址
3 短信接口征集
目前我们的接口数不到10+,计划向短信服务商及技术大牛收集更多的短信接口,
如果您需要对接的短信服务商不在列表中,还请告诉我们哦
我们的目标是: 对接任何一家短信接口,不需要写代码,只需要配置参数
欢迎提供更多的短信接口,如果短信通道被采纳,我们将对评价排前的提供一定的奖品哦
相关阅读:
百家企业短信网关(背景及核心代码)-1-同时对接多家短信公司的开源免费代码
2021全网最全短信服务商排名(100余家短信商户对照)
最早的支付网关(滴滴支付)和最新的聚合支付设计架构
比 REG007 更好用的查询手机注册网站的神器
转载:https://blog.csdn.net/weixin_44549063/article/details/113632029