跳到主要内容

结果通知机制说明

为保证数据的一致性,在交易处理完毕后PingPong系统会按照合作方调用预下单请求时所传入的异步通知地址参数notifyUrl或订阅的统一异步通知地址,通过POST请求的形式将业务处理结果以application/json格式通知到合作方系统。

通常情况下,只有业务处理到终态(成功或失败)的订单才会触发异步通知,特殊情况会在具体的异步通知文档中提及。

以下注意事项合作方务必要明晰:

  1. “幂等性”:同样的通知可能会多次发送给合作方系统,合作方系统必须能够正确处理重复的通知。

    推荐的做法是:当商户系统收到通知进行处理时,先检查对应业务数据的状态并判断该通知是否已经处理。如果未处理,则进行处理;如果已处理,则直接返回结果成功({"code":200,"message":"SUCCESS"} )。在对业务数据进行状态检查和处理之前,要采用数据锁进行并发控制,以避免函数重入造成的数据混乱。

  2. “时效性”:在极少情况下可能发生异步通知无法正常通知到您服务器的情况,请务必对接主动查单接口。

    根据业务场景设置合理的请求间隔,比如在支付后1小时还没有异步通知时可以发起主动查单请求。 频繁调用主动查询接口可能触发限流控制等规则导致查询失败,可适当降低查询频率或联系销售、技术支持获得帮助。

  3. “防抵赖”:商户系统对异步通知的内容一定要做签名验证,并校验通知的信息是否与商户侧的信息一致,防止数据泄漏导致出现“假通知”,造成资金损失。

结果通知地址

一般每个异步接口中会提供异步通知地址参数,通常为 notifyUrl。

合作方也可以在开发者中心或通知PingPong技术人员配置异步通知回调地址。当请求api时没有传递回调地址参数同时需要异步通知时,PingPong会采用该默认的回调地址进行异步通知处理。

结果通知频率

如果未按正常处理做出应答({"code":200,"message":"SUCCESS"}),PingPong将认为该次通知是失败的,间隔一定时间后会尝试再次通知。 一般情况下,最多重发15次(通知的间隔频率一般是:10s,30s,1m,2m,5m,10m,20m,40m,60m,2h,4h,8h,12h,18h,1d)。

警告
  1. PingPong可能根据系统负载和业务情况,在不通知商户的情况下调整异步通知的时间间隔和次数,请勿根据该时间间隔和次数做业务处理。
  2. 异步通知是最大可能交付,但不排除失败的情况。请务必对接主动查询接口,在合理的时间间隔后主动请求对应的查询接口(频繁调用可能受到配额限制)。
  3. PingPong对合作方侧的连接和读取超时时间均为10秒,请尽可能在此时间内返回结果,否则可能会导致延迟通知或重复通知。

结果通知报文

请求方式:POST 报文体:application/json格式

消息内容解析

消息结构体

字段类型是否必填描述
msgStringY通知的消息体,具体参数请看本页中事件列表对应的消息结构文档
msg_IdStringY消息唯一标识,不可作为业务幂等的条件。具体业务幂等的唯一判断需要根据msg里的单号进行判断
event_typeenumY事件类型,见具体通知事件定义
send_countNumberN重发的次数,默认失败最多重发16次
versionlongN消息版本号,此处使用时间戳, 标识推送时间

消息解析步骤

  1. 判断消息是否伪造 —> 解析 sign,验证sign正确性。(签名请参见下面的Demo)
  2. 判断消息的事件类型 —> 解析 event_type
  3. 处理消息体 —> 根据event_type,将msg反序列化成对应的消息结构体
  4. 保存并返回接收成功标识 {"code":200,"message":"SUCCESS"}
  5. 根据消息体异步处理自己的业务逻辑

最佳实践

针对一个合作方应用部署一套异步通知接收网关,由该网关根据异步通知报文交由不同的处理器处理具体业务逻辑。

合作方接收结果通知代码示例

import com.alibaba.fastjson.JSONObject;
import com.alibaba.fastjson.PropertyNamingStrategy;
import com.alibaba.fastjson.serializer.SerializeConfig;
import com.alibaba.fastjson.serializer.SerializerFeature;
import com.pingpongx.settlement.vacenter.biz.model.MsgPushEntity;
import com.pingpongx.settlement.vacenter.biz.util.SignatureUtil;
import java.util.Arrays;
import java.util.HashMap;
import java.util.List;
import java.util.Map;
import javax.annotation.Resource;
import javax.servlet.http.HttpServletRequest;
import lombok.extern.slf4j.Slf4j;
import org.apache.commons.collections4.CollectionUtils;
import org.apache.commons.lang3.StringUtils;
import org.springframework.web.bind.annotation.RequestBody;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RequestMethod;
import org.springframework.web.bind.annotation.ResponseBody;
import org.springframework.web.bind.annotation.RestController;
import org.tomitribe.auth.signatures.Signature;

/**
* @author jicp
* @version 1.0.0
* @date 2023/08/2023/8/24 2:20 PM
*/
@Slf4j
@RestController
@RequestMapping("/notify")
public class ReceiveNotifyController {

private static final String HEADER_SIGNATURE = "Signature";
private static final String HEADER_DIGEST = "Digest";
private static final List<String> SIGNATURE_HEADERS = Arrays.asList("Host", "Date", "Digest");
private static final String APP_ID = "{your app id}";
private static final String APP_SECRET = "{your app secret}";

@Resource
private HttpServletRequest httpServletRequest;

@RequestMapping(value = "/receive", method = RequestMethod.POST, produces = "application/json;charset=utf-8")
@ResponseBody
public Object test(@RequestBody MsgPushEntity entity) throws Exception {
log.info("receive notify msg:{}", entity);
JSONObject res = new JSONObject();
res.put("code", 200);
res.put("message", "SUCCESS");

// 验证签名
boolean validSign = validSignature(entity);
if (!validSign) {
res.put("code", 400);
res.put("message", "sign not right");
return res;
}
/**
* 接下来是一些业务处理
* 判断当前消息的类型 比如账户申请结果
*/
if ("OPEN_APPLY_VA_RESULT".equals(entity.getEventType())) {
//参考文档对应的业务消息对象 进行JSON解码 业务处理等
// XXXX xxx = JSONObject.parseObject(entity.getMsg(), XXXX.class);
}
return res;
}

private boolean validSignature(MsgPushEntity entity) {

try {
// 取签名计算所需的 header
Map<String, String> headers = new HashMap<>();
for (String header : SIGNATURE_HEADERS) {
headers.put(header, httpServletRequest.getHeader(header));
}
String uri = httpServletRequest.getRequestURI();
log.info("headerMaps:{}, uri:{}", JSONObject.toJSONString(headers), uri);
String provideDigest = httpServletRequest.getHeader(HEADER_DIGEST);
String provideSign = httpServletRequest.getHeader(HEADER_SIGNATURE);
// 请求体转化为下划线格式
SerializeConfig serializeConfig = new SerializeConfig();
serializeConfig.setPropertyNamingStrategy(PropertyNamingStrategy.SnakeCase);
String payload = JSONObject.toJSONString(entity, serializeConfig, SerializerFeature.WriteMapNullValue);
String digest = SignatureUtil.generateDigest(payload);
if (!StringUtils.equals(digest, provideDigest)) {
log.error("[Signature] msgId:{}, digest not equal, provide digest is {}, calculate digest is {}",
entity.getMsgId(), provideDigest, digest);
return false;
}
// 校验签名
String signature = SignatureUtil.generateSignature(APP_ID, APP_SECRET, "POST", uri, headers);
Signature provideSignature = Signature.fromString(provideSign);
Signature calculateSignature = Signature.fromString(signature);
log.debug("[Signature] msgId:{}, calculate signature is {}", entity.getMsgId(), signature);

provideSignature.verifySignatureValidityDates();
return StringUtils.equals(calculateSignature.getKeyId(), provideSignature.getKeyId())
&& StringUtils.equals(calculateSignature.getSignature(), provideSignature.getSignature())
&& calculateSignature.getAlgorithm().equals(provideSignature.getAlgorithm())
&& CollectionUtils.isEqualCollection(calculateSignature.getHeaders(), provideSignature.getHeaders());
} catch (Exception ex) {
log.error("[Signature] msgId:{}, calculate signature fail.", entity.getMsgId(), ex);
return false;
}
}
}

其中,签名方式及SignatureUtil工具类请参见 鉴权认证机制

消息接收通用类


import com.fasterxml.jackson.annotation.JsonProperty;
import java.io.Serializable;
import lombok.AllArgsConstructor;
import lombok.Builder;
import lombok.Data;
import lombok.NoArgsConstructor;

/**
* @author jicp
* @version 1.0.0
* @date 2023/08/2023/8/24 9:06 PM
*/
@Builder
@AllArgsConstructor
@NoArgsConstructor
@Data
public class MsgPushEntity implements Serializable {

private static final long serialVersionUID = -4741130826925196952L;

/**
* 通知事件类型
*/
@JsonProperty("event_type")
private String eventType;

/**
* 事件消息体
*/
@JsonProperty("msg")
private String msg;

/**
* 当前通知次数
*/
@JsonProperty("send_count")
private int sendCount;

/**
* 消息id
*/
@JsonProperty("msg_id")
private String msgId;

/**
* 消息版本 时间戳 通知一次生成一个时间戳
*/
@JsonProperty("version")
private long version;
}