# 实时数据推送第三方平台

# 接口说明

微卡支持交易流水、设备信息等通过数据流的形式发送给接入方,接入方需要按文档规范返回应答。

能力申请:发送邮件到 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