REQ-2411: 发送短信, 限制发送重复内容

This commit is contained in:
yanglin 2024-04-28 14:40:17 +08:00
parent f3508208e2
commit 507aee4112
11 changed files with 346 additions and 10 deletions

View File

@ -2,6 +2,7 @@ package cn.axzo.msg.center.domain.entity;
import cn.axzo.msg.center.domain.persistence.BaseOwnEntity;
import cn.axzo.trade.datasecurity.core.annotation.CryptField;
import com.alibaba.fastjson.JSON;
import com.baomidou.mybatisplus.annotation.TableField;
import com.baomidou.mybatisplus.annotation.TableName;
import lombok.Data;
@ -129,4 +130,8 @@ public class MNSMessage extends BaseOwnEntity<MNSMessage> {
return this.id;
}
@Override
public String toString() {
return JSON.toJSONString(this);
}
}

View File

@ -2,6 +2,7 @@ package cn.axzo.msg.center.domain.entity;
import cn.axzo.msg.center.domain.enums.YesNoEnum;
import cn.axzo.msg.center.domain.persistence.BaseOwnEntity;
import com.alibaba.fastjson.JSON;
import com.baomidou.mybatisplus.annotation.TableField;
import com.baomidou.mybatisplus.annotation.TableName;
import lombok.Data;
@ -79,6 +80,11 @@ public class MNSMessageTemplate extends BaseOwnEntity<MNSMessageTemplate> {
return this.id;
}
@Override
public String toString() {
return JSON.toJSONString(this);
}
public boolean hasParam() {
return YesNoEnum.YES.getCode().equals(hasParam);
}

View File

@ -184,8 +184,11 @@ public class AliYunSmsClientImpl implements AliYunSmsClient {
String message = responseBody.getMessage();
boolean isRateLimited = message != null && message.contains("触发") && message.contains("流控");
log.warn("AliyunSmsService#checkResponse is fail, error message : {}", message);
if (!isRateLimited)
if (isRateLimited) {
throw new RateLimitException(ReturnCodeEnum.SYSTEM_ERROR, message);
} else {
throw new BizException(ReturnCodeEnum.SYSTEM_ERROR, message);
}
}
return responseBody;
}

View File

@ -0,0 +1,15 @@
package cn.axzo.msg.center.notices.integration.client.impl;
import cn.axzo.msg.center.notices.common.enums.ReturnCodeEnum;
import cn.axzo.msg.center.notices.common.exception.BizException;
/**
* @author yanglin
*/
public class RateLimitException extends BizException {
public RateLimitException(ReturnCodeEnum returnCode, String message) {
super(returnCode, message);
}
}

View File

@ -1,5 +1,6 @@
package cn.axzo.msg.center.notices.manager.api.dto.request;
import com.alibaba.fastjson.JSON;
import lombok.AllArgsConstructor;
import lombok.Builder;
import lombok.Data;
@ -48,4 +49,14 @@ public class MnsRequestDto {
*/
private String expansion;
private Object internalObj;
public <T> T getInternalObj(Class<T> clazz) {
return clazz.isInstance(internalObj) ? clazz.cast(internalObj) : null;
}
@Override
public String toString() {
return JSON.toJSONString(this);
}
}

View File

@ -64,6 +64,7 @@ public class MNSNoticesApiImpl implements MNSNoticesApi {
BeanUtils.copyProperties(request, mnsRequestDto);
log.info("request value={},mnsRequestDto value={}", JSONUtil.toJsonStr(request),JSONUtil.toJsonStr(mnsRequestDto));
try {
mnsRequestDto.setInternalObj(MnsType.BIZ);
messageService.sendMessage(mnsRequestDto);
return CommonResponse.success();
} catch (Exception e) {

View File

@ -1,17 +1,37 @@
package cn.axzo.msg.center.notices.service.impl;
import cn.axzo.msg.center.common.utils.PlaceholderResolver;
import cn.axzo.msg.center.dal.MNSMessageAppDao;
import cn.axzo.msg.center.domain.entity.*;
import cn.axzo.msg.center.domain.entity.MNSBatchMessageRequest;
import cn.axzo.msg.center.domain.entity.MNSChannelMessageTemplate;
import cn.axzo.msg.center.domain.entity.MNSMessage;
import cn.axzo.msg.center.domain.entity.MNSMessageApp;
import cn.axzo.msg.center.domain.entity.MNSMessageChannel;
import cn.axzo.msg.center.domain.entity.MNSMessageRedo;
import cn.axzo.msg.center.domain.entity.MNSMessageTemplate;
import cn.axzo.msg.center.domain.enums.YesNoEnum;
import cn.axzo.msg.center.notices.common.annotation.ApiRequestLog;
import cn.axzo.msg.center.notices.common.constans.CommonConstants;
import cn.axzo.msg.center.notices.common.domain.BatchMessageSendContext;
import cn.axzo.msg.center.notices.common.domain.MessageContext;
import cn.axzo.msg.center.notices.common.enums.*;
import cn.axzo.msg.center.notices.common.enums.AvailableStatusEnum;
import cn.axzo.msg.center.notices.common.enums.BatchMessageRequestStatusEnum;
import cn.axzo.msg.center.notices.common.enums.BatchSendTypeEnum;
import cn.axzo.msg.center.notices.common.enums.MessageStatusEnum;
import cn.axzo.msg.center.notices.common.enums.MessageTypeEnum;
import cn.axzo.msg.center.notices.common.enums.NotifyTypeEnum;
import cn.axzo.msg.center.notices.common.enums.RetryingFlagEnum;
import cn.axzo.msg.center.notices.common.enums.ReturnCodeEnum;
import cn.axzo.msg.center.notices.common.exception.BizException;
import cn.axzo.msg.center.notices.common.utils.DateUtils;
import cn.axzo.msg.center.notices.integration.client.DingDingClient;
import cn.axzo.msg.center.notices.manager.api.*;
import cn.axzo.msg.center.notices.integration.client.impl.RateLimitException;
import cn.axzo.msg.center.notices.manager.api.BatchMessageRequestManger;
import cn.axzo.msg.center.notices.manager.api.MessageChannelRouter;
import cn.axzo.msg.center.notices.manager.api.MessageManager;
import cn.axzo.msg.center.notices.manager.api.MessageTemplateManager;
import cn.axzo.msg.center.notices.manager.api.RetryStrategy;
import cn.axzo.msg.center.notices.manager.api.SmsSendManager;
import cn.axzo.msg.center.notices.manager.api.dto.request.MessageSendRequestDto;
import cn.axzo.msg.center.notices.manager.api.dto.request.MnsRequestDto;
import cn.axzo.msg.center.notices.manager.api.dto.request.SendBatchMessageRequestDto;
@ -30,7 +50,6 @@ import lombok.extern.slf4j.Slf4j;
import org.apache.commons.collections4.CollectionUtils;
import org.apache.commons.collections4.MapUtils;
import org.apache.commons.lang3.ArrayUtils;
import org.apache.commons.lang3.StringUtils;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.cloud.context.config.annotation.RefreshScope;
import org.springframework.context.EnvironmentAware;
@ -38,8 +57,12 @@ import org.springframework.core.env.Environment;
import org.springframework.stereotype.Service;
import javax.annotation.Resource;
import java.util.*;
import java.util.Date;
import java.util.HashMap;
import java.util.List;
import java.util.Map;
import java.util.Map.Entry;
import java.util.Optional;
import java.util.concurrent.ConcurrentHashMap;
import java.util.function.Function;
import java.util.function.Predicate;
@ -84,6 +107,9 @@ public class MessageServiceImpl implements MessageService, EnvironmentAware {
@Resource(name = "userCellphoneProperties")
private UserCellphoneProperties userCellphoneProperties;
@Resource
private MnsLimiter mnsLimiter;
@Value("${prod.profile.string:master}")
private String prodProfileString;
@ -109,6 +135,8 @@ public class MessageServiceImpl implements MessageService, EnvironmentAware {
boolean isLegalPhone = PhoneUtil.isPhone(request.getPhoneNo());
BizException.error(isLegalPhone, ReturnCodeEnum.INVALID_PARAMETER, "非法的手机号:" + request.getPhoneNo());
mnsLimiter.maybeLimitSend(request);
// 查询并校验
MNSMessageTemplate messageTemplate = messageTemplateManager.queryAndCheckInnerTemplate(request.getTemplateNo());
@ -147,12 +175,19 @@ public class MessageServiceImpl implements MessageService, EnvironmentAware {
dto.setRequestChannelNo(message.getMessageOrderNo());
SendSmsCommonResponseDto response = smsSendManagerComposite.sendMessage(dto);
messageManager.updateToProcessing(response.getBizId(), response.getRequestId(), message.getId());
mnsLimiter.setSendSuccess(request);
} catch (RateLimitException e) {
messageManager.updateToFail(message.getId());
mnsLimiter.setSendFail(request, e);
// don't rethrow rate limit exception to let client retry
} catch (BizException e) {
messageManager.updateToFail(message.getId());
mnsLimiter.setSendFail(request, e);
throw e;
}
} catch (Exception e) {
dingDingClient.notifySingleMessage(request.getTemplateNo(), request.getRequestNo(), e.getMessage());
mnsLimiter.setSendFail(request, e);
throw e;
}
}
@ -206,7 +241,15 @@ public class MessageServiceImpl implements MessageService, EnvironmentAware {
message.setChannelName(CommonConstants.MOCK_CHANNEL);
message.updateById();
String messageContent = parse(template.getTemplateContent(), request.getParams());
String messageContent;
try {
messageContent = PlaceholderResolver.tryResolve(template.getTemplateContent(), request.getParams());
} catch (Exception e) {
log.warn("fail to resolve template content, fallback to old method. request={}, template={}, message={}",
request, template, message, e);
//fallback
messageContent = parse(template.getTemplateContent(), request.getParams());
}
dingDingClient.notifyMockMessage(request.getPhoneNo(), template.getTitle(), messageContent);
}

View File

@ -0,0 +1,172 @@
package cn.axzo.msg.center.notices.service.impl;
import cn.axzo.basics.common.exception.ServiceException;
import cn.axzo.msg.center.notices.common.enums.ReturnCodeEnum;
import cn.axzo.msg.center.notices.manager.api.dto.request.MnsRequestDto;
import com.google.common.hash.Hasher;
import com.google.common.hash.Hashing;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.apache.commons.lang3.StringUtils;
import org.joda.time.DateTime;
import org.joda.time.DateTimeZone;
import org.springframework.data.redis.core.StringRedisTemplate;
import org.springframework.stereotype.Component;
import java.nio.charset.StandardCharsets;
import java.text.SimpleDateFormat;
import java.util.Date;
import java.util.Map;
import java.util.TreeMap;
import java.util.concurrent.TimeUnit;
import java.util.function.Consumer;
/**
* @author yanglin
*/
@Slf4j
@Component
@RequiredArgsConstructor
public class MnsLimiter {
private final MnsProperties props;
private final StringRedisTemplate stringRedisTemplate;
/**
* 基于固定窗口, 限制重复内容的发送次数
*/
void maybeLimitSend(MnsRequestDto request) {
if (!props.shouldLimit(request.getTemplateNo())) {
return;
}
// key中包含了固定窗口因子
String key = buildTimeAwareKey(request);
String countStr = stringRedisTemplate.opsForValue().get(key);
if (StringUtils.isBlank(countStr)) {
return;
}
long alreadySendCount = Long.parseLong(countStr);
MnsType msnType = request.getInternalObj(MnsType.class);
if (msnType == null) msnType = MnsType.BIZ;
long limitCount;
String error;
if (msnType == MnsType.BIZ) {
limitCount = props.getBizLimitPerDay();
error = String.format("业务短信: 每天发送给同一个手机号的重复内容不能超过 %s 条。 请勿重试发送!!", limitCount);
} else {
limitCount = props.getVerifyCodeLimitPerWindow();
error = String.format("验证码短信: 每 %s 分钟发送给同一个手机号的重复内容不能超过 %s 条。 请勿重试发送!!",
props.getVerifyCodeLimitWindowMinutes(), limitCount);
}
if (alreadySendCount + 1 > limitCount) {
log.warn("{}, request={}", error, request);
throw new ServiceException(ReturnCodeEnum.FAIL.getCode(), error);
}
}
void setSendSuccess(MnsRequestDto request) {
if (!props.shouldLimit(request.getTemplateNo())) {
return;
}
// key中包含了固定窗口因子
String key = buildTimeAwareKey(request);
stringRedisTemplate.opsForValue().increment(key);
// 这里设置过期时间只是为了删除对应的key, 和限制逻辑本身没有太大的关系
long expireMinutes = getTimeAwareExpireTimeMinutes(request);
// 延迟删除
stringRedisTemplate.expire(key, expireMinutes + 1, TimeUnit.MINUTES);
}
void setSendFail(MnsRequestDto request, Exception e) {
// NOP
}
/**
* 获取key过期时间
*/
private long getTimeAwareExpireTimeMinutes(MnsRequestDto request) {
MnsType msnType = request.getInternalObj(MnsType.class);
if (msnType == null) {
msnType = MnsType.BIZ;
}
return msnType == MnsType.BIZ
? TimeUnit.DAYS.toMinutes(1)
: props.getVerifyCodeLimitWindowMinutes();
}
/**
* 构建包含时间元素的缓存key, key中包含了固定窗口因子. 固定窗口: 限制发送重复的内容
*/
private String buildTimeAwareKey(MnsRequestDto request) {
MnsType msnType = request.getInternalObj(MnsType.class);
if (msnType == null) {
msnType = MnsType.BIZ;
}
StringBuilder buf = new StringBuilder();
buf.append("msg-center:mns_limit");
Consumer<String> appender = value -> {
if (StringUtils.isNotBlank(value))
buf.append(":").append(value);
};
// 通过templateNo和params就可以判断出发送的内容是不是一样的
appender.accept(msnType.name());
appender.accept(request.getTemplateNo());
appender.accept(request.getPhoneNo());
appender.accept(buildTimeAwareSegment(request));
appender.accept(hashParams(request.getParams()));
return buf.toString();
}
private String buildTimeAwareSegment(MnsRequestDto request) {
MnsType mnsType = request.getInternalObj(MnsType.class);
if (mnsType == null) {
mnsType = MnsType.BIZ;
}
Date now = new Date();
SimpleDateFormat sdf = new SimpleDateFormat("yyyyMMdd");
int windowMinutes = props.getVerifyCodeLimitWindowMinutes();
return mnsType == MnsType.BIZ
? sdf.format(now)
: String.format("%s-%d-%d",
sdf.format(now), windowMinutes, getVerifySegmentIndex(now, windowMinutes));
}
public static int getVerifySegmentIndex(Date now, int windowMinutes) {
DateTime dateTime = new DateTime(now, DateTimeZone.forID("Asia/Shanghai"));
// Calculate the total number of intervals in a day
int intervalsPerDay = 24 * 60 / windowMinutes;
// Get the current time in minutes
int currentMinutes = dateTime.getHourOfDay() * 60 + dateTime.getMinuteOfHour();
// Find the index of the interval that the current time falls into
int intervalIndex = currentMinutes / windowMinutes;
// Ensure the index is within the range of the array
if (intervalIndex >= intervalsPerDay) {
intervalIndex = intervalsPerDay - 1;
}
return intervalIndex;
}
@SuppressWarnings("UnstableApiUsage")
private static String hashParams(Map<String, Object> params) {
if (params == null || params.isEmpty()) {
return "";
}
Hasher hasher = Hashing.murmur3_128().newHasher();
Consumer<Object> appender = value -> {
if (value != null) {
hasher.putString(String.valueOf(value), StandardCharsets.UTF_8);
}
};
for (Map.Entry<String, Object> e : new TreeMap<>(params).entrySet()) {
if (e.getKey() == null || e.getValue() == null) {
continue;
}
appender.accept(e.getKey());
appender.accept(e.getValue());
}
return hasher.hash().toString();
}
}

View File

@ -0,0 +1,54 @@
package cn.axzo.msg.center.notices.service.impl;
import lombok.Getter;
import lombok.Setter;
import org.apache.commons.lang3.StringUtils;
import org.springframework.boot.context.properties.ConfigurationProperties;
import org.springframework.cloud.context.config.annotation.RefreshScope;
import org.springframework.context.annotation.Configuration;
/**
* @author yanglin
*/
@Setter
@Getter
@RefreshScope
@Configuration
@ConfigurationProperties(prefix = "user.cellphone")
public class MnsProperties {
/**
* 是否开启重复发送限制
*/
private boolean sendLimitOn = true;
/**
* 不验证重复发送的内部模板编码
*/
private String limitWhitelist;
/**
* 业务上每天发送重复内容的次数限制
*/
private long bizLimitPerDay = 20;
/**
* 验证码15分钟内发送的内容都是一样的
*/
private int verifyCodeLimitWindowMinutes = 15;
/**
* 验证码每15分钟可以重复发送的次数
*/
private long verifyCodeLimitPerWindow = 20;
@SuppressWarnings("BooleanMethodIsAlwaysInverted")
public boolean shouldLimit(String templateNo) {
return sendLimitOn && !isInLimitWhitelist(templateNo);
}
private boolean isInLimitWhitelist(String templateNo) {
return StringUtils.isNotBlank(limitWhitelist)
&& limitWhitelist.contains(templateNo);
}
}

View File

@ -0,0 +1,15 @@
package cn.axzo.msg.center.notices.service.impl;
/**
* @author yanglin
*/
public enum MnsType {
/**
* 业务短信
*/
BIZ,
/**
* 验证码
*/
VERIFY_CODE
}

View File

@ -10,6 +10,7 @@ import cn.axzo.msg.center.notices.common.properties.SmsProperties;
import cn.axzo.msg.center.notices.manager.api.dto.request.MnsRequestDto;
import cn.axzo.msg.center.notices.service.api.MessageService;
import cn.axzo.msg.center.notices.service.gateway.SmsGateway;
import cn.axzo.msg.center.notices.service.impl.MnsType;
import cn.axzo.msg.center.notices.service.request.CaptchaParam;
import cn.axzo.msg.center.notices.service.request.SendCodeV2Req;
import cn.axzo.msg.center.notices.service.response.SmsCodeInfoRes;
@ -31,7 +32,13 @@ import org.springframework.cloud.context.config.annotation.RefreshScope;
import org.springframework.stereotype.Service;
import javax.annotation.Resource;
import java.util.*;
import javax.validation.constraints.NotNull;
import java.util.ArrayList;
import java.util.HashMap;
import java.util.List;
import java.util.Map;
import java.util.Objects;
import java.util.UUID;
/**
* @author zhangPeng
@ -68,7 +75,7 @@ public class SmsManager extends BaseManager implements SmsGateway {
* @return
*/
public int sendSmsCode(SendCodeV2Req req) {
sendMnsCode(req.getPhone(), req.getParam(), req.getAppCode(),req.getTemplateNo());
sendMnsCode(req.getPhone(), req.getParam(), req.getAppCode(),req.getTemplateNo(), req.getCode());
log.info("发送验证码 手机号{} -- 参数 {} -- 应用程序code {} -- 模板编号templateCode {}", req.getPhone(), req.getParam(), req.getAppCode(),req.getTemplateNo());
return req.getCode();
}
@ -176,7 +183,7 @@ public class SmsManager extends BaseManager implements SmsGateway {
}
}
public void sendMnsCode(String phoneNumber, Map<String, Object> param, String appCode,String templateNo) {
public void sendMnsCode(String phoneNumber, Map<String, Object> param, String appCode, String templateNo, Integer code) {
if (!isSendMnsCode) {
return;
}
@ -186,6 +193,10 @@ public class SmsManager extends BaseManager implements SmsGateway {
request.setTemplateNo(templateNo);
request.setParams(param);
request.setRequestNo(UUID.randomUUID().toString());
request.setInternalObj(MnsType.VERIFY_CODE);
HashMap<String, Object> params = new HashMap<>();
params.put("code", code);
request.setParams(params);
messageService.sendMessage(request);
}
}