API
事件订阅说明
更新时间: 2021-06-29 17:56:36 访问次数:1221

简介

为了给卖家/开发者提供更及时的业务消息,提高业务流转的效率,winit新增了Webhook的消息订阅功能。

以下是API和Webhook的介绍,API为开发者主动发起数据请求,Winit响应数据和回复;Webhook为Winit主打发起消息广播,再由开发者监听和解析。


事件订阅时序图


首次订阅:

Winit校验开发的URL是否合法,是否有效,若无效则无法订阅

日常消息:

订单状态/库存变化后立即回传消息给订阅的开发者

 

事件订阅页面操作

登录开发者账号

登录Developer.winit.com.cn

查看应用,点击配置按钮

订阅事件

(1)订阅ISP的订单状态变更


(2)库存变化事件订阅


勾选订阅的事件,并填入URL链接,并点击订阅。

取消订阅


取消勾选,则取消订阅成功

 

签名和解密

背景信息

HTTP/HTTPS类型的事件目标通常需要暴露公网Endpoint以接收事件总线推送的事件内容。为了保证数据安全,HTTP/HTTPS类型的事件目标需要识别接受的请求是否是事件总线发送的请求。

回调说明:

Endpoint要求:通信协议http/https(推荐使用https),请求方法POST,内容类型application/json,编码格式UTF-8,返回值:接收成功返回字符串success,失败返回字符串fail。

Java endpoint示例代码片段:

/**

 * http method = post, content-type = application/json , charset=UTF-8

* @param data 加密后的请求体,是一个普通字符串,解密出来是json

 * @param request

 * @return

 */

@RequestMapping(value = "/mock", method = RequestMethod.POST)

public String mock(@RequestBody String data, HttpServletRequest request) {

    try {

        logger.info("消息体密文:{}", data);

logger.info("密文解密:{}", EventBridgeUtils.decryptToJson(data, secret, request));

 

String dateTime = request.getHeader("x-event-signature-timestamp");

long timestamp = LocalDateTime.parse(dateTime, DateTimeFormatter.ofPattern("yyyy-MM-dd'T'HH:mm:ssZ")).toInstant(ZoneOffset.of("+8")).toEpochMilli();

if (System.currentTimeMillis() - timestamp > 60000) {

    // 请求签名时间与接收到请求当前时间相差 60秒,请示为非法请求,丢弃该请求

    return "success";

}

// 回调地址,按实际配置

String endpoint = "127.0.0.1:8080/mock";

// 签名验证

if (EventBridgeUtils.signatureValidate(data, secret, endpoint, request)) {

    logger.info("签名验证通过");

    // 签名验证通过, 则处理业务

} else {

    // 签名验证失败,丢弃该请求

    return "fail";

}

    } catch (Exception e) {

        e.printStackTrace();

        return "fail";

    }

    return "success";

}

EventBridgeUtils详见附录A,或者开发者示例程序包com.winit.api.utils.EventBridgeUtils

生成签名

  1. 事件总线的 secret为开发者平台应用的clientSecret
  2. 事件总线生成待签字符串。

更多待签字符串生成规范,请参见待签名字符串生成规范

  1. 事件总线默认使用HMAC-SHA1算法通过clientSecret对待签数据进行加密生成加密值,并将签名算法写入header:x-event-signature-method。
  2. 事件总线将当前时间戳写入header:x-event-signature-timestamp。
  3. 事件总线将卖家用户名经过base64编码后写入header: x-event-appkey。
  4. 事件总线对待签数据加密生成的HMAC-SHA1加密值用Base64编码,将该值写入header:x-event-signature。

算法如下所示。

Signature = Base64( HMAC-SHA1(clientSecret, UTF-8-Encoding-Of(StringToSign)) )

校验签名

  1. 事件目标根据x-event-signature-timestamp获取服务端发送请求的时间戳。
    • 如果超过60s,事件目标认为该请求已经失效,防止重放攻击。
    • 如果未超过60s,继续进行以下校验。
  2. 事件目标生成待签字符串。

更多待签字符串生成规范,请参见待签名字符串生成规范

  1. 事件目标根据x-event-signature-method默认使用HMAC-SHA1算法通过临时指定的签名算法进行加密生成加密值。
  2. 事件目标对待签数据生成的HMAC-SHA1加密值用Base64编码生成signature。
  3. 事件总线生成签名中生成的header:x-event-signature与4中生成的signature进行比较。
    • 如果两个签名相同,表示该请求是从事件总线发送的,事件目标接收并处理该事件。
    • 如果两个签名不同,表示该请求不是从事件总线发送的,事件目标不接受该事件。

字符串生成规范

StringToSign = webhook目标地址 + "\n" + "x-event-signature-timestamp=" + "header实际值" + "\n" + "x-event-signature-method=" + "header实际值" + "\n" + "x-event-signature-version=" + "header实际值" + "\n" + " x-event-appkey =" + "header实际值" + "\n" + body

官方固定header参数解释如下(代签名字符串生成顺序不能变):

  • x-event-signature-timestamp:请求发送的时间戳。当发送时间和接收时间的间隔超过60s,该时间戳被认为失效,生成签名失败以防止重放攻击。
  • x-event-signature-method:签名算法。默认为HMAC-SHA1算法。
  • x-event-signature-version:签名版本。默认为0。
  • x-event-appkey编码后的卖家用户名经过base64编码。

示例代码片段:

/**

 * 签名算法

 * @param data 请求报文原始数据

 * @param secret 开发者秘钥

 * @param endpoints webhook地址

 * @param request {@link HttpServletRequest}

 * @return 生成的签名

 * @throws Exception

 */

public String signature(String data, String secret, String endpoints, HttpServletRequest request) throws Exception{

   String s = endpoints + "\n"

         + "x-event-signature-timestamp=" + request.getHeader("x-event-signature-timestamp") + "\n"

         + "x-event-signature-method=" + request.getHeader("x-event-signature-method") + "\n"

         + "x-event-signature-version=" + request.getHeader("x-event-signature-version") + "\n"

         + "x-event-appkey=" + request.getHeader("x-event-appkey") + "\n"

         + data;

   byte[] bytes = EncryptUtils.HmacSHA1Encrypt(s, secret);

   return EncryptUtils.base64Encoder(bytes);

}

详见附录A,或者开发者示例程序包com.winit.api.utils.EventBridgeUtils

其中EncryptUtils详见附录C,或者开发者示例程序包com.winit.api.utils.EncryptUtils

数据解密

算法:AES

模式:ECB

填充:PKCS5Padding

Key:开发者secret+卖家授权token(根据请求头参数x-event-appkey获取开发者维护的授权token)字符串经过MD5散列后的字符数组;

编码:UTF-8

示例:

1、开发者秘钥:clientSecret

2、卖家token:userToken

3、密文:C20CA2B2DD3224BB3E53B9AB1382AC6A

4、解密代码片段(详见附录A,或者开发者示例程序包com.winit.api.utils.EventBridgeUtils):

String clientSecret = "clientSecret";

String userToken = "userToken";

AESUtils.decrypt("C20CA2B2DD3224BB3E53B9AB1382AC6A", DigestUtils.md5(clientSecret + userToken))

5、明文:winit

6、说明:

AESUtils详见附录B,或者开发者示例程序包com.winit.api.utils.AESUtils

DigestUtils 详见:org.apache.commons.codec.digest. DigestUtils

Maven依赖:

         commons-codec

         commons-codec

1.11

 

 

例子A

public class EventBridgeUtils {

 

/**

 * 校验签名。用请求头里面的签名和自己生成的签名比较

 * @param data 请求报文原始数据

 * @param secret 开发者秘钥

 * @param endpoints webhook地址

 * @param request {@link HttpServletRequest}

 * @return 校验通过返回true,

 * @throws Exception

 */

public static boolean signatureValidate(String data, String secret, String endpoints, HttpServletRequest request) throws Exception {

    String source = request.getHeader("x-event-signature");

    String generated = signature(data, secret, endpoints, request);

    if (StringUtils.isEmpty(source) || StringUtils.isEmpty(generated)) {

        return false;

    }

    return source.equals(generated);

}

 

/**

 * 解密请求体

 * @param data 请求报文原始数据

 * @param clientSecret 开发者秘钥

 * @param request {@link HttpServletRequest}

 * @return 解密后的JSON对象

 */

public static JSONObject decryptToJson(String data, String clientSecret, HttpServletRequest request) {

    String appKey = decoderAppkey(request);

    String userToken = queryUserToken(appKey);

    String decryptData = decrypt(data, clientSecret, userToken);

    return JSONObject.parseObject(decryptData);

}

    /**

     * 签名算法

     * @param data 请求报文原始数据

     * @param secret 开发者秘钥

     * @param endpoints webhook地址

     * @param request {@link HttpServletRequest}

     * @return 生成的签名

     * @throws Exception

     */

    public static String signature(String data, String secret, String endpoints, HttpServletRequest request) throws Exception{

        String s = endpoints + "\n"

                + "x-event-signature-timestamp=" + request.getHeader("x-event-signature-timestamp") + "\n"

                + "x-event-signature-method=" + request.getHeader("x-event-signature-method") + "\n"

                + "x-event-signature-version=" + request.getHeader("x-event-signature-version") + "\n"

                + "x-event-appkey=" + request.getHeader("x-event-appkey") + "\n"

                + data;

        byte[] bytes = EncryptUtils.HmacSHA1Encrypt(s, secret);

        return EncryptUtils.base64Encoder(bytes);

    }

 

    /**

     * 从请求头参数获取卖家用户名

     * @param request {@link HttpServletRequest}

     * @return base64解码后的参数

     */

    public static String decoderAppkey(HttpServletRequest request) {

        String appKey = request.getHeader("x-event-appkey");

        appKey = StringUtils.isNotEmpty(appKey) ? appKey : "";

        return EncryptUtils.base64Decoder(appKey.getBytes(StandardCharsets.UTF_8));

    }

 

    /**

     * 解密事件推送请求报文

     * @param data 加密报文

     * @param clientSecret 开发者秘钥

     * @param userToken 卖家授权token

     * @return 解密后明文

     */

    public static String decrypt(String data, String clientSecret, String userToken) {

        return AESUtils.decrypt(data, DigestUtils.md5(clientSecret+userToken));

    }

}

 

 

例子B

public class AESUtils {

    /**算法/模式/填充*/

    private static final String CIPHER_MODE = "AES/ECB/PKCS5Padding";

 

    /**

     * 通过byte[]类型的密钥 解密String类型的密文

     * @param content

     * @param key

     * @return

     */

    public static String decrypt(String content, byte[] key) {

        try {

            Cipher cipher = Cipher.getInstance(CIPHER_MODE);

            cipher.init(Cipher.DECRYPT_MODE, new SecretKeySpec(key, "AES"));

            byte[] data = cipher.doFinal(hex2byte(content));

            return new String(data, StandardCharsets.UTF_8);

        } catch (Exception e) {

            e.printStackTrace();

        }

        return null;

    }

 

    /**

     * 将hex字符串转换成字节数组

     * @param inputString

     * @return

     */

    private static byte[] hex2byte(String inputString) {

        if (inputString == null || inputString.length() < 2) {

            return new byte[0];

        }

        inputString = inputString.toLowerCase();

        int l = inputString.length() / 2;

        byte[] result = new byte[l];

        for (int i = 0; i < l; ++i) {

            String tmp = inputString.substring(2 * i, 2 * i + 2);

            result[i] = (byte) (Integer.parseInt(tmp, 16) & 0xFF);

        }

        return result;

    }

}

 

 

例子C

public class EncryptUtils {

       private static final String MAC_NAME = "HmacSHA1";

       private static final String ENCODING = "UTF-8";

       private static final Base64.Encoder ENCODER = Base64.getEncoder();

 

       public static byte[] HmacSHA1Encrypt(String encryptText, String encryptKey)

                     throws UnsupportedEncodingException, NoSuchAlgorithmException, InvalidKeyException {

              byte[] data = encryptKey.getBytes(ENCODING);

              // 根据给定的字节数组构造一个密钥,第二参数指定一个密钥算法的名称

              SecretKey secretKey = new SecretKeySpec(data, MAC_NAME);

              // 生成一个指定 Mac 算法 的 Mac 对象

              Mac mac = Mac.getInstance(MAC_NAME);

              // 用给定密钥初始化 Mac 对象

              mac.init(secretKey);

              byte[] text = encryptText.getBytes(ENCODING);

              // 完成 Mac 操作

              return mac.doFinal(text);

       }

 

       public static String base64Encoder(byte[] bytes) {

              return ENCODER.encodeToString(bytes);

       }

}

 

常见问题

  • 卖家使用多个开发者(卖家使用多个ERP),怎么办?

Winit会记录下单开发者的应用,根据来源和回退一致的原则,把不同开发者的订单推送给对应的开发者

  • 开发者没有接受到消息怎么办?

由于网络波动或者其他原因导致开发者无法接收到当前的消息,winit将在24小时内将当前消息重推8次,8次后依然没有接收成功,将会被遗弃。

  • 开发者的URL是有效的,为什么订阅失败?

URL的有效性校验了该URL接收消息是否成功,需要开发人员部署好具体功能再进行订阅

  • 开发者收到多个未知仓库的库存信息,怎么办?

Winit推送授权给开发者的所有实体仓库和商品的库存变化,若卖家未在ERP中绑定全部仓库,请及时与卖家沟通。

关键词