# 实时数据推送第三方平台
# 接口说明
微卡支持交易流水、设备信息等通过数据流的形式发送给接入方,接入方需要按文档规范返回应答。
能力申请:发送邮件到 seasonyuan@tencent.com进行申请(邮件同时也抄送至lydiaxyang@tencent.com , lindayyang@tencent.com , ronniewan@tencent.com),申请时需提供业务方资料和使用场景。
# 通知规则
- 和接入方服务端通知交互时,如果微卡收到接入方的应答不符合规范或超时(超过 5 秒超时),认为通知失败,微卡会根据通知类型通过一定的策略定期重新发起通知,尽可能提高通知的成功率,但微卡不保证通知最终能成功。建议接入方收到通知解密后先保存通知信息并返回响应,异步再处理业务逻辑 (通知频率为:0/15/15/30/180/1800/1800/1800/1800/3600(单位:秒) )
# 通知报文
通知的数据会加密通过post请求到对接方接口中,由于涉及到通知加密和解密,接入方必须先通过微卡商务侧申请通知秘钥 key和通知地址 url,申请好通知秘钥 key 和 通知地址 url 后才能收到通知和解密通知
# 参数解密
下面详细描述对通知数据进行解密的流程:
通知秘钥 key,记为 notify_key;
针对 resource.algorithm 中描述的算法(目前为 AEAD_AES_256_GCM),取得对应的参数 nonce 和 associated_data;
使用 notify_key、nonce 和 associated_data,对数据密文 resource.ciphertext 进行解密,得到 JSON 形式的资源对象(资源对象就是具体的数据内容);
注: AEAD_AES_256_GCM 算法的接口细节,请参考 rfc5116。微卡使用的通知密钥 key 长度为 32 个字节,随机串 nonce 长度小于等于 32 个字节,associated_data 长度小于 16 个字节并可能为空。
# 通知参数
参数 | 是否必填 | 类型 | 实例 | 说明 | |
---|---|---|---|---|---|
id | 是 | String | EV-2018022511223320873 | 通知的唯一ID | |
create_time | 是 | String | 2015-05-20T13:29:35+08:00 | 通知的创建时间通知创建的时间,遵循rfc3339标准格式,格式为YYYY-MM-DDTHH:mm:ss+TIMEZONE,YYYY-MM-DD表示年月日,T出现在字符串中,表示time元素的开头,HH:mm:ss 表示时分秒,TIMEZONE表示时区(+08:00表示东八区时间,领先UTC 8小时,即北京时间)。例如:2015-05-20T13:29:35+08:00表示北京时间2015年05月20日13点29分35秒。 | |
event_type | 是 | String | TRANSACTION.PAY | 通知的类型,比如支付成功通知的类型为TRANSACTION.PAY | |
resource_type | 是 | String | encrypt-resource | 通知的资源数据类型,比如支付成功通知为encrypt-resource | |
resource | algorithm | 是 | String | AEAD_AES_256_GCM | 对通知数据进行加密的加密算法,目前只支持AEAD_AES_256_GCM |
ciphertext | 是 | String | CIPHERTEXT | Base64编码后的开启/停用结果数据密文 | |
nonce | 是 | String | NONCE | 加密使用的随机串 | |
original_type | 是 | String | transaction | 原始通知类型,为transaction | |
associated_data | 是 | String | ASSOCIATED_DATA | 附加数据 | |
summary | 是 | String | 支付成功 | 通知摘要 |
# 交易流水event_type(通知类型)枚举
参数值 | 代表内容 |
---|---|
TRANSACTION.PAY | 支付成功 |
TRANSACTION.ORDER | 下单成功 |
TRANSACTION.PAYDEBT | 补缴成功 |
TRANSACTION.PAYFAIL | 转未结 |
TRANSACTION.REFUND | 退款成功 |
TRANSACTION.CLOSE | 关单 |
# 设备信息event_type(通知类型)枚举
参数值 | 代表内容 |
---|---|
POS.HEARTBEAT | 心跳上报 |
# 结果通知示例:
- 通知原文
{
"id": "EV-2018022511223320873",
"create_time": "2015-05-20T13:29:35+08:00",
"resource_type": "encrypt-resource",
"event_type": "TRANSACTION.PAY",
"resource": {
"algorithm": "AEAD_AES_256_GCM",
"ciphertext": "...",
"nonce": "...",
"original_type": "transaction",
"associated_data": ""
},
"summary": "支付成功"
}
- 交易流水通知 ciphertext 解密
{
"school_code": "1013957946",
"order_no": "087911615258036297",
"user_name": "微信原生码支付用户",
"user_no": "15823211069",
"mch_no": "1941248470",
"mch_name": "测试商户",
"device_no": "JB000749028791",
"order_amount": 1,
"deal_amount": 1,
"deal_time": "2021-03-09 10:47:16",
"pay_time": "2021-03-09 10:47:18",
"pay_channel": "wechat",
"channel_no": "4200000903202103091632039199",
"channel_refund_no": "",
"discount_amount": 0,
"over_amount": 0,
"refund_no": "087xxxxxxxx297",
"refund_amont": 1,
"refund_time": "2021-03-10 10:47:18",
"scene": "faceoffline"
}
参数 | 是否必填 | 类型 | 实例 | 说明 |
---|---|---|---|---|
school_code | 是 | String | 1013957946 | 主体代码 |
order_no | 是 | String | 087911615258036297 | 订单号 |
parent_order_no | 是 | String | 087911615258036297 | 母单 |
user_name | 是 | String | 微信原生码支付用户 | 用户姓名 |
user_no | 是 | String | 15823211069 | 学工/员工号 |
mch_no | 是 | String | 1941248470 | 子商户号 |
mch_name | 是 | String | 测试商户 | 子商户名称 |
device_no | 是 | String | JB000749028791 | 设备编号 |
order_amount | 是 | Int | 1 | 发生金额 |
deal_amount | 是 | Int | 1 | 应收金额 |
deal_time | 是 | String | 2021-03-09 10:47:16 | 交易时间 |
pay_time | 是 | String | 2021-03-09 10:47:18 | 支付时间 |
pay_channel | 是 | String | wecard | 支付渠道(包括:wechat:微信支付、alipay:支付宝支付、uniacct:补贴钱包、mchcoupon:商家券、recharge:余额、wechat_native:微信付款码及公众号支付、face:人脸支付,edupay:轻松付、计次支付) |
channel_no | 是 | String | 4200000903202103091632039199 | 渠道单号 |
channel_refund_no | 是 | String | 渠道退款单号 | |
discount_amount | 是 | Int | 0 | 折扣金额 |
over_amount | 是 | Int | 0 | 溢价金额 |
refund_no | 否 | String | 087xxxxxxxx297 | 退款订单(订单退款事件类型才有返回) |
refund_amont | 否 | Int | 1 | 退款金额(订单退款事件类型才有返回) |
refund_time | 否 | String | 2021-03-10 10:47:18 | 退款时间(订单退款事件类型才有返回) |
scene | 是 | String | card | 场景值(paycode:收款码、authcode:电子码、barcode:原生码、wxpay:非实名公众号支付、mppay:微卡、apppay:应用支付、paydept:补缴、walletcharge:小钱包充值、faceon:人脸在线、faceoff:人脸离线、card:物理卡、syspappay:一分钱验证、apppappay:应用代扣) |
- 设备信息通知 ciphertext 解密
{
"org_code": "1013957946",
"device_id": "JB000749028791",
"merchant_name": "测试商户",
"merchant_address": "南山区食堂一",
"fk_update_time": "2021-03-10 10:47:18",
"offline_order_num": 10,
"state_flag": 1
}
参数 | 是否必填 | 类型 | 实例 | 说明 |
---|---|---|---|---|
org_code | 是 | String | 1013957946 | 主体代码 |
device_id | 是 | String | JB000749028791 | 设备编号 |
merchant_name | 是 | String | 测试商户 | 商户名称 |
merchant_address | 是 | String | 南山区食堂一 | 商户注册地址 |
fk_update_time | 是 | String | 2021-03-10 10:47:18 | 配置更新时间 |
offline_order_num | 是 | Integer | 10 | 未上传的离线订单数量 |
state_flag | 是 | Integer | 1 | 支付功能状态;0:支付功能关闭,1:支付功能开启 |
# 接入方应答
如果接入方侧未返回正确的内容,微卡会多次通知,为了避免给接入方服务器造成过大的压力,请在得到微卡结果通知之后,返回以下内容。
# 注意:
当接入方后台应答失败时,微卡将记录下应答的报文,接入方须按照以下格式返回。
参数名 | 变量 | 类型[长度限制] | 必填 | 描述 |
---|---|---|---|---|
返回状态码 | code | string[1,16] | 是 | SUCCESS:响应成功,FAIL:响应失败 |
返回信息 | message | string[1,128] | 否 | 返回信息,如非空,为错误原因。如:解密失败 等 |
返回示例:
{
"code": "SUCCESS",
"message": "",
}
# 解密示例
JAVA
import java.io.IOException;
import java.security.GeneralSecurityException;
import java.security.InvalidAlgorithmParameterException;
import java.security.InvalidKeyException;
import java.security.NoSuchAlgorithmException;
import java.util.Base64;
import javax.crypto.Cipher;
import javax.crypto.NoSuchPaddingException;
import javax.crypto.spec.GCMParameterSpec;
import javax.crypto.spec.SecretKeySpec;
public class AesUtil {
static final int KEY_LENGTH_BYTE = 32;
static final int TAG_LENGTH_BIT = 128;
private final byte[] aesKey;
public AesUtil(byte[] key) {
if (key.length != KEY_LENGTH_BYTE) {
throw new IllegalArgumentException("无效的ApiV3Key,长度必须为32个字节");
}
this.aesKey = key;
}
public String decryptToString(byte[] associatedData, byte[] nonce, String ciphertext)
throws GeneralSecurityException, IOException {
try {
Cipher cipher = Cipher.getInstance("AES/GCM/NoPadding");
SecretKeySpec key = new SecretKeySpec(aesKey, "AES");
GCMParameterSpec spec = new GCMParameterSpec(TAG_LENGTH_BIT, nonce);
cipher.init(Cipher.DECRYPT_MODE, key, spec);
cipher.updateAAD(associatedData);
return new String(cipher.doFinal(Base64.getDecoder().decode(ciphertext)), "utf-8");
} catch (NoSuchAlgorithmException | NoSuchPaddingException e) {
throw new IllegalStateException(e);
} catch (InvalidKeyException | InvalidAlgorithmParameterException e) {
throw new IllegalArgumentException(e);
}
}
}
PHP
class AesUtil{
/**
* AES key
*
* @var string
*/
private $aesKey;
const KEY_LENGTH_BYTE = 32;
const AUTH_TAG_LENGTH_BYTE = 16;
/**
* Constructor
*/
public function __construct($aesKey)
{
if (strlen($aesKey) != self::KEY_LENGTH_BYTE) {
throw new InvalidArgumentException('无效的ApiV3Key,长度应为32个字节');
}
$this->aesKey = $aesKey;
}
/**
* Decrypt AEAD_AES_256_GCM ciphertext
*
* @param string $associatedData AES GCM additional authentication data
* @param string $nonceStr AES GCM nonce
* @param string $ciphertext AES GCM cipher text
*
* @return string|bool Decrypted string on success or FALSE on failure
*/
public function decryptToString($associatedData, $nonceStr, $ciphertext)
{
$ciphertext = \base64_decode($ciphertext);
if (strlen($ciphertext) <= self::AUTH_TAG_LENGTH_BYTE) {
return false;
}
// ext-sodium (default installed on >= PHP 7.2)
if (function_exists('\sodium_crypto_aead_aes256gcm_is_available') &&
\sodium_crypto_aead_aes256gcm_is_available()) {
return \sodium_crypto_aead_aes256gcm_decrypt($ciphertext, $associatedData, $nonceStr, $this->aesKey);
}
// ext-libsodium (need install libsodium-php 1.x via pecl)
if (function_exists('\Sodium\crypto_aead_aes256gcm_is_available') &&
\Sodium\crypto_aead_aes256gcm_is_available()) {
return \Sodium\crypto_aead_aes256gcm_decrypt($ciphertext, $associatedData, $nonceStr, $this->aesKey);
}
// openssl (PHP >= 7.1 support AEAD)
if (PHP_VERSION_ID >= 70100 && in_array('aes-256-gcm', \openssl_get_cipher_methods())) {
$ctext = substr($ciphertext, 0, -self::AUTH_TAG_LENGTH_BYTE);
$authTag = substr($ciphertext, -self::AUTH_TAG_LENGTH_BYTE);
return \openssl_decrypt($ctext, 'aes-256-gcm', $this->aesKey, \OPENSSL_RAW_DATA, $nonceStr,
$authTag, $associatedData);
}
throw new \RuntimeException('AEAD_AES_256_GCM需要PHP 7.1以上或者安装libsodium-php');
}
}
.NET
public class AesGcm
{
private static string ALGORITHM = "AES/GCM/NoPadding";
private static int TAG_LENGTH_BIT = 128;
private static int NONCE_LENGTH_BYTE = 12;
private static string AES_KEY = "yourkeyhere";
public static string AesGcmDecrypt(string associatedData, string nonce, string ciphertext)
{
GcmBlockCipher gcmBlockCipher = new GcmBlockCipher(new AesEngine());
AeadParameters aeadParameters = new AeadParameters(
new KeyParameter(Encoding.UTF8.GetBytes(AES_KEY)),
128,
Encoding.UTF8.GetBytes(nonce),
Encoding.UTF8.GetBytes(associatedData));
gcmBlockCipher.Init(false, aeadParameters);
byte[] data = Convert.FromBase64String(ciphertext);
byte[] plaintext = new byte[gcmBlockCipher.GetOutputSize(data.Length)];
int length = gcmBlockCipher.ProcessBytes(data, 0, data.Length, plaintext, 0);
gcmBlockCipher.DoFinal(plaintext, length);
return Encoding.UTF8.GetString(plaintext);
}
}
PYTHON
from cryptography.hazmat.primitives.ciphers.aead import AESGCM
import base64
def decrypt(nonce, ciphertext, associated_data):
key = "Your32Apiv3Key"
key_bytes = str.encode(key)
nonce_bytes = str.encode(nonce)
ad_bytes = str.encode(associated_data)
data = base64.b64decode(ciphertext)
aesgcm = AESGCM(key_bytes)
return aesgcm.decrypt(nonce_bytes, data, ad_bytes)
GO
type ReqBody struct {
Id string `json:"id"`
CreateTime string `json:"create_time"`
EventType string `json:"event_type"`
Resource *ReqResource `json:"resource"`
Summary string `json:"summary"`
}
type ReqResource struct {
Algorithm string `json:"algorithm"`
Ciphertext string `json:"ciphertext"`
Nonce string `json:"nonce"`
OriginalType string `json:"original_type"`
AssociatedData string `json:"associated_data"`
}
func Decrypt(key string, body *ReqBody) ([]byte, error) {
block, err := aes.NewCipher([]byte(key))
if err != nil {
return nil, err
}
if body == nil {
return nil, errors.New("nil body")
}
if body.Resource == nil {
return nil, errors.New("nil body.Resource")
}
aesgcm, err := cipher.NewGCM(block)
if err != nil {
return nil, err
}
plainDec, err := base64.StdEncoding.DecodeString(body.Resource.Ciphertext)
if err != nil {
return nil, err
}
plaintext, err := aesgcm.Open(nil, []byte(body.Resource.Nonce), plainDec, []byte(body.Resource.AssociatedData))
if err != nil {
return nil, err
}
return plaintext, nil
}
n := ReqBody{
Id: [推送数据中的id],
CreateTime: [推送数据中的creat_time],
EventType: [推送数据中的event_type],
Summary: [推送数据中的summary],
Resource: &ReqResource{},
}
resource := [推送数据中的summary]
if err := json.Unmarshal([]byte(resource), n.Resource); err != nil {
// do something
}
if order, err := Decrypt([通知秘钥key]], &n); err != nil {
// do something
}
// do something