转至元数据结尾
转至元数据起始

§. 短信平台(聚合系统)的回调-业务说明

我司短信平台聚合了朗宇、漫道、华信等多家短信服务商通道,并输出统一的接口能力供业务系统使用。

本文以短信平台(sms)为例。来说一下各短信通道回调sms的代码实现。

注:下文提到的”短信服务商“、”短信通道“、”通道“表示相同概念。



接收到短信发送结果的回调通知,我们sms做的事情包括:

  1. 从request对象里获取到请求报文
  2. 数据安全处理,如验签/解密
  3. 解析请求报文,获取消息id发送状态等数据属性
  4. 根据`消息id`判断是否在系统库里存在发送记录
  5. 将通道侧发送状态转换为系统内的短信发送状态
  6. 持久保存通知结果数据
  7. 持久更新短信发送记录的发送状态
  8. 其他相关业务处理(例如:如果通道返回“黑名单”、“风控拦截”等特殊情况,触发系统告警)
  9. 回写消息,响应给短信服务商。


现在sms系统为每个短信通道提供了不同的回调接口,对应各自的处理方法。如

短信通道接收回调通知的API
朗宇
com.sms.restapi.LangyuSmsNotify#onNotify


@RequestMapping("/SmsReport/LangyuSmsReport")
public void onNotify(HttpServletRequest)
漫道
com.sms.restapi.MDSmsNotify#onNotify


@RequestMapping("/SmsReport/MDSmsReport")
public void onNotify(HttpServletRequest)
华信
com.sms.restapi.HuaXinSmsNotify#onNotify


@RequestMapping("/SmsReport/Huaxin")
public void onNotify(HttpServletRequest)


§. 避免烟囱式代码堆砌

如果在每个 onNotify 方法里都编写上面的实现代码,显然,这就是在重复竖烟囱。未来对接新的短信通道时,如果沿用这种实现方式,必然就又会多出一个个的烟囱。


§. 那么,当如何进行程序设计呢?


我们来看看下面的good-practice。


先分析上面处理回调的那9个步骤。我们看哪些是通道独有的,哪些是可以共用的。


step程序逻辑通道独有逻辑公共逻辑
1从request对象里获取到请求报文
2数据安全处理,如验签/解密
3解析请求报文,获取消息id发送状态等数据属性
4根据`消息id`判断是否在系统库里存在发送记录
5将通道侧发送状态转换为系统内的短信发送状态
6持久保存通知结果数据
7持久更新短信发送记录的发送状态
8其他相关业务处理(例如:如果通道返回“黑名单”、“风控拦截”等特殊情况,触发系统告警)
9回写消息,响应给短信服务商。


前5步和第9步是通道独有的,中间6、7、8三步是公共的。为了方便阅读,下文把这2个集合表示为 “{1~5,9}” 和“{6,7,8}”。


首先,我们先将 {6,7,8} 这步封装起来实现复用。

我们业务逻辑层新建SmsService#handSmsNotify 方法,职责是处理短信通道的回调结果,包括 {6,7,8} 步中的数据持久化和其他业务处理。

public boolean handSmsNotify(***){
	6
	7
	8
}

这样一来,各个通道处理回调的接口逻辑简化为:

@RequestMapping("/SmsReport/***")
public void onNotify(HttpServletRequest){
	1
	2
	3
	4
	5
	smsService.handSmsNotify;
	9	
}


接下来,我们说说 {1~5,9}步,这些通道独有的逻辑代码,应该在Web控制器层吗?

非也!

按照系统模块职责,这些通道独有的逻辑,应该放置在通道层,并提供api给Web控制层来调用。

这样的话,怎么来组织我们的代码呢?

达芬奇密码就是面向接口编程。抽象出来公共的接口行为,基于OOP的多态性,由具体的通道实现类封装各自的不同点。这些通道的不同点包括从request获取数据的方式、数据安全校验、数据的解析,等等。

SMS程序中,通道类实现的interface是SmsSDK,我们在这个interface里新建一个接口方法:handleNotify。方法签名为:

SupplierNotifyResultVO handleNotify(HttpServletRequest);

方法返参`SupplierNotifyResultVO`的结构是什么呢?这是程序设计上的一个重点。`SupplierNotifyResultVO`包括 是否成功受理、回写的消息内容、通道侧消息msgId、通道侧发送状态码、通道侧发送状态描述、对应的sms平台的发送状态。

SupplierNotifyResultVO 结构
/**
 * 短信发送结果通知,通道层解析数据后使用这个模型
 */
@Getter public class SupplierNotifyResultVO {
    /**
     * 是否受理成功。成功才有{@link #reportList}
     */
    private boolean isSuccess;
    /**
     * 受理失败时的错误描述
     */
    private String errorMsg;
    /**
     * 业务执行完成后需要回写的消息内容
     */
    private String writeBackText = "";
    /**
     * 通知给我方的发送结果数据(每次回调会通知1条或多条发送结果,所以使用集合)
     */
    private List<Report> reportList;


    @Data public static class Report {
        private SmsStatusEnum sysSmsStatus; // 对应的sms平台的发送状态
        private String msgId; // 通道侧消息msgId
        private String statusCode; // 通道侧发送状态码
        private String statusDesc; // 通道侧发送状态描述
        private String mobile; // 接收短信的用户手机号
        private Date receiveTime; // 用户手机收到短信的时间
    }

    public static SupplierNotifyResultVO success(List<Report> reportList, String writeBackText) { ... }

    public static SupplierNotifyResultVO success(Report report, String writeBackText) { ... }

    public static SupplierNotifyResultVO error(String errorMsg) { ... }
}

这样一来,各个通道处理回调的接口逻辑进一步简化为:

@RequestMapping("/SmsReport/***")
public void onNotify(HttpServletRequest){
	SmsSDK smsSDK = SmsSDKFactory.get(**);
	SupplierNotifyResultVO supplierNotifyResultVO = smsSDK.handleNotify(request);
	
	smsService.handSmsNotify(SupplierNotifyResultVO);
	
	回写 supplierNotifyResultVO.responseMsg;
}


附华信通道实现类的回调代码:

@Override
public SupplierNotifyResultVO handleNotify(HttpServletRequest request) {
    String xml = IoUtil.read(request.getInputStream(), StandardCharsets.UTF_8);
    if (StringUtils.isBlank(xml)) {
        log.error("华信短信通知报文为空");
        return SupplierNotifyResultVO.error("no data");
    }
    log.info("华信的推送报告报文={}", xml);

    HuaxinSmsNotifyModel smsReturn = JAXBXmlUtils.parseXml(xml, HuaxinSmsNotifyModel.class);
    if (null == smsReturn || CollectionUtil.isEmpty(smsReturn.getReports())) {
        log.info("华信 解析回执为空");
        return SupplierNotifyResultVO.error("no data");
    }

    List<HuaxinSmsNotifyModel.HuaxinSmsReport> reports = smsReturn.getReports();
    List<SupplierNotifyResultVO.Report> reportList = new ArrayList<>();
    for (HuaxinSmsNotifyModel.HuaxinSmsReport reportModel : reports) {
        SupplierNotifyResultVO.Report report = new SupplierNotifyResultVO.Report();
        report.setMobile(StringUtils.defaultString(reportModel.getMobile()).trim());
        report.setMsgId(StringUtils.defaultString(reportModel.getTaskId()).trim());
        report.setStatusCode(StringUtils.defaultString(reportModel.getErrorCode()).trim());
        report.setReceiveTime(DateUtil.parseDateTime(reportModel.getReceiveTime()));
        report.setStatusDesc(ReportCodeMapper.getDesc(report.getStatusCode()));
        report.setSysSmsStatus(getStatus(report.getStatusCode()));
        reportList.add(report);
    }
    return SupplierNotifyResultVO.success(reportList, "1");
}


至此,我们的程序设计已经比较完美了。各个通道在处理短信回调时,Web控制层、业务层、通道层各司其职,也没有了烟囱式的代码堆砌。程序扩展方面,当需要对接新通道时,开发者只需要关注Web控制层和通道层,其中Web控制层的开发工作是提供简单的restapi接口,通道层的开发工作则是通道对接相关的代码。


尽管如此,我们有必要再提起一个事情。那就是,Web控制层为各通道暴露的回调接口。

我们不难发现,各个通道的回调入口的逻辑是相似的。

so,我们把这些回调接口统一成一个,岂不更香!

这时,需要注意的是就是程序怎么识别出来特定的通道标识。easy~ SpringMVC为我们提供了 @PathVariable 注解。

@RequestMapping("/SmsReport/{supplierCode}")
public void onNotify(@PathVariable("supplierCode") String supplierCode, HttpServletRequest){
	assert StringUtils.notBlank(supplierCode);
	SmsSupplierEnum supplier = SmsSupplierEnum.valueOf(supplierCode);
	assert supplier != null;

	SmsSDK smsSDK = SmsSDKFactory.get(supplier);
	SupplierNotifyResultVO SupplierNotifyResultVO = smsSDK.handleNotify(request);
	
	smsService.handSmsNotify(SupplierNotifyResultVO);
	
	回写 SupplierNotifyResultVO.responseMsg;
}




§. 附:sms项目分层结构

com.sms




.restapi




.SmsSendController
为公司内业务系统提供的短信发送聚合接口


.SupplierNotifyController
暴露给短信服务商的回调接口(*本文用到)


...


.bizservice




.SmsService
短信发送业务服务类



#sendSms发送短信



#handSmsNotify处理短信发送通知结果


...


modules

单表操作的Manager及DAO


...


.supplier




.vo
POJO模型类



.SupplierNotifyResultVO

短信发送结果通知,通道层解析数据后使用这个模型

(*本文用到)



.SmsSDK
interface -短信服务商接口能力



#sendSms调用短信服务商API,并解析响应报文



#handleNotify

解析短信回调报文(*本文用到)



.huaxin
华信服务商对接package



.HuaXinSmsSDK

华信短信接口能力

implemented SmsSDK



.langyu
朗宇服务商对接package



.LangYuSmsSDK

朗宇短信接口能力

implemented SmsSDK



...


【EOF.】




下面为草稿


















设计思路

正常短信通道回调流程:

  1. 从request对象里获取到请求数据
  2. 数据安全处理,如验签/解密
  3. 数据解析,获取消息id、发送状态等属性值
  4. 根据消息id判断是否在系统库里存在发送记录
  5. 将通道侧发送状态转换为系统内的发送状态
  6. 保存发送结果数据记录
  7. 更新短信发送记录的发送状态
  8. 其他相关业务处理(例如:如果通道返回“黑名单”、“风控拦截”等特殊情况,触发系统告警)
  9. 回写消息。

目前实际业务短信通道回调流程:

  1. 通道职责
    1. 从request对象里获取到请求数据
    2. 数据安全处理,如验签/解密
    3. 数据解析,获取消息id、发送状态等属性值【回调的消息id】
    4. 将通道信息转换为系统使用字段,如:手机号、消息id、消息状态转换为系统状态
    5. 回写返回通道消息
  2. 短信聚合系统职责
    1. 根据消息id判断是否在系统库里存在发送记录
    2. 保存发送结果数据记录
    3. 更新短信发送记录的发送状态
  3. 统一返回通道消息。


为了防止对接新的短信通道而重复竖烟囱,可以把所有的回调入口汇聚一个,然后封装短信通道回调处理的共同点和不同点。 

从上面可以看出通道职责的都是不同点,而对于短信聚合系统需要操作的流程都是一样的

我们可以把通道职责抽象出来,让各通道自己实现

示例:

统一通道通知Controller:

  1. 接到回调通知后首先通过短信通道的地址标识【supplierCode】确认指定通道
  2. 然后获取对应的通道SDK实现,并且调用各个短信通道封装的不同点实现,并封装返回统一格式数据
  3. 执行短信聚合系统流程
	@RequestMapping("/SmsReport/{supplierCode}")
    public String onNotify(@PathVariable("supplierCode") String supplierCode, HttpServletRequest request) {
        log.info("短信服务商通知开始, supplierCode:{}", supplierCode);

        SmsSupplierEnum supplierEnum = SmsSupplierEnum.getByNotifyPath(supplierCode);
        if(supplierEnum == null){
            log.error("短信服务商通知,没有找到对应的供应商");
            throw new ResponseStatusException(HttpStatus.NOT_FOUND);
        }

        String supplierName = supplierEnum.getName();
        log.info("短信服务商通知,找到对应的供应商:{}", supplierName);
        ISmsSDK supplierApi = createSmsModel.findSupplierApi(supplierName);
        Result<List<SmsReport>> notifyResult = supplierApi.handleNotify(request);
        if(notifyResult.isSuccess()){
            List<SmsReport> smsReports = notifyResult.getResult();
            Integer count = smsReportManager.insertBatch(smsReports);
            log.info("{}短信通知,新增回调记录,准备新增数量:{},实际新增数量:{}", supplierName, smsReports.size(), count);
        }
        log.info("{}短信通知结束", supplierName);
        return notifyResult.getMessage();
    }	


ISmsSDK接口:

public interface ISmsSDK {

    /**
     * 处理回调
     * @param request
     * @return 返回统一数据
     */
    Result<List<SmsReport>> handleNotify(HttpServletRequest request);
}


通道SDK回调功能:【以朗宇为例】

@Override
    public Result<List<SmsReport>> handleNotify(HttpServletRequest request) {
        log.info("朗宇短信通知开始");

		//获取朗宇传参
        String mobile = request.getParameter("mobile");
        String msgid = request.getParameter("msgid");
        String reportTime = request.getParameter("reportTime");
        String status = request.getParameter("status");
        log.info("朗宇短信通知参数:mobile={}, msgid={}, reportTime={}, status={}", mobile, msgid, reportTime, status);
        if (StringUtils.isEmpty(msgid)) {
            log.error("朗宇短信通知参数msgid为空");
            return Result.error("msgid is empty");
        }

		//返回统一格式数据
        SmsReport report = new SmsReport();
		...
        return Result.success(Arrays.asList(report), "1");// 如果成功接收状态或上行,回写”1”字符串
    }


本封装架构的设计初衷并非追求普适性,而是致力于针对特定业务需求提供精准、高效的解决方案。






references:

如何封装通道对接代码(以短信平台为例)

短信平台技术栈升级(.net→java)

支付系统场景设计-支付状态处理

设计文稿物料:https://www.processon.com/view/link/6579736c3fb4b0188b2d5c09


编写评论...