简介
为了给卖家/开发者提供更及时的业务消息,提高业务流转的效率,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
生成签名
- 事件总线的 secret为开发者平台应用的clientSecret
- 事件总线生成待签字符串。
更多待签字符串生成规范,请参见待签名字符串生成规范。
- 事件总线默认使用HMAC-SHA1算法通过clientSecret对待签数据进行加密生成加密值,并将签名算法写入header:x-event-signature-method。
- 事件总线将当前时间戳写入header:x-event-signature-timestamp。
- 事件总线将卖家用户名经过base64编码后写入header: x-event-appkey。
- 事件总线对待签数据加密生成的HMAC-SHA1加密值用Base64编码,将该值写入header:x-event-signature。
算法如下所示。
Signature = Base64( HMAC-SHA1(clientSecret, UTF-8-Encoding-Of(StringToSign)) )
校验签名
- 事件目标根据x-event-signature-timestamp获取服务端发送请求的时间戳。
- 如果超过60s,事件目标认为该请求已经失效,防止重放攻击。
- 如果未超过60s,继续进行以下校验。
- 事件目标生成待签字符串。
更多待签字符串生成规范,请参见待签名字符串生成规范。
- 事件目标根据x-event-signature-method默认使用HMAC-SHA1算法通过临时指定的签名算法进行加密生成加密值。
- 事件目标对待签数据生成的HMAC-SHA1加密值用Base64编码生成signature。
- 将事件总线生成签名中生成的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依赖:
例子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中绑定全部仓库,请及时与卖家沟通。
- 开发者消息推送失败重试机制
消息推送失败会重试,重试8次(间隔时间为:4s、16 s、64 s、256 s、17 MIN、68 MIN、4.5 HOUR、18 HOUR)。第三次、第六次、第七次重试失败,以及最后的重试失败会触发短信通知,通知会发送至开发者账号的手机号,请及时关注异常通知。