生活缴费中台 业务通知接口文档(物业 SaaS 对接版)
通知方: 银盛支付 生活缴费中台 被通知方: 物业 SaaS 协议: HTTP
POSTContent-Type:application/json; charset=utf-8文档版本: V1.1 更新日期: 2026-10-26
1. 概述
生活缴费中台在以下四类业务节点向物业 SaaS 发起业务通知。银盛主动调用物业 SaaS 提供的通知地址,SaaS 需同步返回处理结果。
| 序号 | 通知类型 | 触发时机 | 通知地址配置项 |
|---|---|---|---|
| 1 | 主动支付核销通知 | 用户在微信侧完成主动缴费、中台完成销账后 | 账单核销通知URL |
| 2 | 代扣结果通知 | 自动缴费(代扣)扣费完成后 | 代扣结果通知URL |
| 3 | 代扣签约通知 | 用户代扣签约成功后 | 代扣签约URL |
| 4 | 代扣解约通知 | 用户代扣解约成功后 | 解约签约URL |
2. 接入准备
2.1 物业 SaaS 需提供的信息
请物业 SaaS 在联调前提供以下信息,由银盛配置:
| 配置项 | 说明 | 示例 |
|---|---|---|
| 主动支付核销通知 | 接收主动支付核销通知的地址 | https://saas.example.com/notify/writeoff |
| 代扣结果通知URL | 接收代扣扣费结果通知的地址 | https://saas.example.com/notify/deduct |
| 代扣签约URL | 接收代扣签约结果通知的地址 | https://saas.example.com/notify/sign |
| 解约签约URL | 接收代扣解约结果通知的地址 | https://saas.example.com/notify/unsign |
| 发起方商户号 | 银盛分配给该物业的商户号,决定加签方式与验签公钥 | 826551253990363 |
| 商户公钥 | 用于银盛验证 SaaS 响应报文签名 | RSA / SM2 公钥 |
注意
- 四个 URL 必须是 HTTPS,且可从银盛生产网络访问。
- 四个 URL 可复用同一地址,通过报文内容区分业务类型;建议按业务分设便于运维。
- URL 变更需提前联系银盛运营配置,配置生效后旧地址立即失效。
3. 通知报文格式
银盛发给物业 SaaS 的 HTTP 请求 Body 为如下 JSON:
{
"bizContent": "{\"merchantNo\":\"826551253990363\",\"billKey\":\"BK2026100001\"}",
"charset": "UTF-8",
"reqId": "NTF20261026101530000001",
"src": "life",
"timeStamp": "2026-10-26 10:15:30",
"sign": "MEUCIQDx...Base64编码的平台签名..."
}
| 字段 | 类型 | 说明 |
|---|---|---|
bizContent |
String | 业务通知内容,JSON 字符串(四类通知的具体字段见第 4 章) |
charset |
String | 编码,固定 UTF-8 |
reqId |
String | 本次通知请求ID,全局唯一,可用于幂等与问题追踪 |
src |
String | 通知来源,暂定固定值 life(生活缴费),后续如有调整另行通知 |
timeStamp |
String | 通知时间戳,格式 yyyy-MM-dd HH:mm:ss |
sign |
String | 平台签名(Base64),SaaS 必须验签,规则见 3.1 |
⚠️
bizContent是字符串,不是 JSON 对象。解析时需先取出字符串,再二次反序列化为业务对象。
3.1 通知验签规则(SaaS 验银盛的签名)
| 项 | 规则 |
|---|---|
| 验签原文 | 按字段名字典序拼接 key=value&key=value... |
| 参与字段 | bizContent、charset、reqId、src、timeStamp(排除 sign 字段,跳过 null 值) |
| 拼接示例 | bizContent={...}&charset=UTF-8&reqId=NTF...&src=life&timeStamp=2026-10-26 10:15:30 |
| 编码 | UTF-8 |
| 签名算法 | 发起方商户号配置的类型:SHA256withRSA 或 SM3withSM2 |
| 验签公钥 | 平台公钥(银盛提供,RSA 为 X.509 证书,SM2 为 .cer 证书) |
| 签名值编码 | Base64 |
验签失败应直接返回 HTTP 4xx,银盛会按通知失败处理并重试。
4. 通知业务内容(bizContent)
4.1 主动支付核销通知
用户在微信生活缴费平台完成主动缴费支付后,微信通知银盛生活中台核销账单,银盛通物业 SaaS 进行账单核销。
4.1.1 请求字段
| 名称 | 类型 | 必填 | 说明 |
|---|---|---|---|
merchantNo |
String | 是 | 商户号(微信生活缴费商户号,对应物业公司) |
companyId |
String | 是 | 项目id(微信生活缴费项目id) |
initSideMerchantNo |
String | 是 | 发起方商户号 |
billKey |
String | 是 | 户号(房屋编号) |
orderNo |
String | 是 | 银盛支付流水号 |
outTradeNo |
String | 是 | 商户订单号 |
transactionId |
String | 是 | 微信支付订单号 |
payDate |
String | 是 | 缴费单支付日期,格式 yyyyMMddHHmmss |
customerName |
String | 否 | 客户姓名 |
payAmount |
Long | 是 | 缴费金额,单位:分 |
userId |
String | 否 | 用户ID |
feeItems |
Array | 是 | 费用主项列表,见下表 |
feeItems 元素结构
| 名称 | 类型 | 必填 | 说明 |
|---|---|---|---|
feeCycle |
String | 是 | 费用周期,格式 yyyy.MM.dd-yyyy.MM.dd |
feeCode |
String | 是 | 费用编号,物业公司业务系统定义的唯一编码 |
feeName |
String | 是 | 费用名称 |
feeAmount |
Long | 是 | 费用金额,单位:分 |
4.1.2 bizContent 示例
{
"merchantNo": "826551253990363",
"companyId": "wx_project_001",
"initSideMerchantNo": "826551253990363",
"billKey": "BK2026100001",
"orderNo": "P2026102600000001",
"outTradeNo": "WXLP7f3a9c2e1b4d",
"transactionId": "4200002026102612345678901",
"payDate": "20261026101530",
"customerName": "张三",
"payAmount": 12000,
"userId": "oUpF8uMuAJO_M2pxb1Q9zNjWeS6o",
"feeItems": [
{
"feeCycle": "2026.01.01-2026.12.31",
"feeCode": "FEE001",
"feeName": "物业管理费",
"feeAmount": 12000
}
]
}
4.1.3 响应业务字段(SaaS → 银盛)
SaaS 在响应信封的 businessData 中返回以下内容:
| 名称 | 类型 | 必填 | 说明 |
|---|---|---|---|
retCode |
String | 是 | 核销结果码,见下表 |
errMsg |
String | 否 | 错误描述 |
retCode 取值约定(非常重要,直接决定后续资金动作)
| 取值 | 含义 | 银盛后续动作 |
|---|---|---|
SUCCESS |
核销成功 | 向微信回传销账成功 |
DUPLICATE |
重复核销(该账单已核销过) | 按成功处理,向微信回传销账成功 |
FAIL |
业务失败(如参数不匹配、账单状态异常) | 回传销账成功并进入重试(差错补偿) |
ERROR |
用户已完成缴费(该账单此前已被他人缴清) | 触发退款:向微信发起退款申请。返回此应答码,银盛生活缴费平台会自动出发退款 |
⚠️
ERROR与FAIL语义完全不同,请勿混用。
- 若账单此前已缴费,必须返回
ERROR,银盛会为用户退款。- 若仅是暂时性处理失败(如数据库繁忙、下游超时),返回
FAIL,银盛会重试。- 返回未知码值时,银盛按「核销处理中」处理,不视为成功。
4.1.4 businessData 示例
{
"retCode": "SUCCESS",
"errMsg": ""
}
4.2 代扣结果通知
自动缴费(代扣)扣费完成后通知物业 SaaS。
4.2.1 请求字段
| 名称 | 类型 | 必填 | 说明 |
|---|---|---|---|
signRecordNo |
String | 是 | 签约记录号(银盛内部签约流水号) |
merchantNo |
String | 是 | 商户号 |
billKey |
String | 是 | 房屋编号(户号) |
deductStatus |
String | 是 | 扣费结果:SUCCESS / FAIL |
payAmount |
Long | 是 | 扣费金额,单位:分 |
payDate |
String | 否 | 扣费完成时间,格式 yyyyMMddHHmmss(失败场景可能为空) |
orderNo |
String | 否 | 银盛支付流水号(失败场景可能为空) |
failReason |
String | 否 | 失败原因(deductStatus=FAIL 时有值) |
feeCode |
String | 是 | 费用编号 |
outRequestNo |
String | 否 | 商户订单号(银盛条码侧订单号) |
channelBillNo为历史遗留字段,已废弃,不再下发,请勿依赖。
4.2.2 bizContent 示例(扣费成功)
{
"signRecordNo": "SR7f3a9c2e1b4d5f6a",
"merchantNo": "826551253990363",
"billKey": "BK2026100001",
"deductStatus": "SUCCESS",
"payAmount": 12000,
"payDate": "20261026093015",
"orderNo": "P2026102600000001",
"failReason": null,
"feeCode": "FEE001",
"outRequestNo": "LP20261026093015001"
}
4.2.3 bizContent 示例(扣费失败)
{
"signRecordNo": "SR7f3a9c2e1b4d5f6a",
"merchantNo": "826551253990363",
"billKey": "BK2026100001",
"deductStatus": "FAIL",
"payAmount": 12000,
"payDate": null,
"orderNo": null,
"failReason": "余额不足",
"feeCode": "FEE001",
"outRequestNo": "LP20261026093015001"
}
4.2.4 响应业务字段(SaaS → 银盛)
| 名称 | 类型 | 必填 | 说明 |
|---|---|---|---|
retCode |
String | 是 | SUCCESS / FAIL |
errMsg |
String | 否 | 错误描述 |
| 取值 | 含义 | 银盛后续动作 |
|---|---|---|
SUCCESS |
SaaS 处理成功 | 通知流程结束 |
FAIL |
SaaS 处理失败 | 落差错任务重试(按退避策略自动重试,超上限转人工) |
4.2.5 businessData 示例
{
"retCode": "SUCCESS",
"errMsg": ""
}
4.3 申请代扣签约通知
用户在微信生活缴费平台完成签约后,微信生活缴费平台通知银盛生活缴费中台,银盛调用物业SaaS平台的申请签约通知接口,物业SaaS平台响应签约结果
4.3.1 请求字段
| 名称 | 类型 | 必填 | 说明 |
|---|---|---|---|
signRecordNo |
String | 是 | 签约记录号(银盛内部签约流水号) |
merchantNo |
String | 是 | 商户号(物业公司) |
billKey |
String | 是 | 房屋编号(户号) |
changeType |
String | 是 | 变更类型:ADD=签约 / DELETE=解约 |
openid |
String | 是 | 用户 openid |
accountType |
String | 否 | 账户类型: postpaid(后付费) |
deductDate |
String | 否 | 扣费日(每月固定扣费日期),物业缴费场景默认为空 |
rechargeLimit |
Long | 否 | 充值/扣费上限,单位:分,物业缴费场景默认为空 |
rechargeAmount |
Long | 否 | 单次充值/扣费金额,单位:分,物业缴费场景默认为空 |
mobile |
String | 否 | 用户手机号(明文,已解密) |
idCard |
String | 否 | 用户身份证号(明文,已解密) |
userName |
String | 否 | 用户姓名(明文,已解密) |
cancelType |
String | 否 | 解约类型(changeType=DELETE 时有值) |
extend1 |
String | 否 | 扩展字段 1 |
extend2 |
String | 否 | 扩展字段 2 |
⚠️ 敏感信息提示:
mobile/idCard/userName为明文传输。物业 SaaS 收到后须按个人信息保护要求加密存储,不得落明文日志。
4.3.2 bizContent 示例
{
"signRecordNo": "SR7f3a9c2e1b4d5f6a",
"merchantNo": "826551253990363",
"billKey": "BK2026100001",
"changeType": "ADD",
"openid": "oUpF8uMuAJO_M2pxb1Q9zNjWeS6o",
"accountType": "postpaid",
"deductDate": "10",
"rechargeLimit": 500000,
"rechargeAmount": 12000,
"mobile": "13800138000",
"idCard": "440101199001011234",
"userName": "张三",
"cancelType": null,
"extend1": null,
"extend2": null
}
4.3.3 响应业务字段(SaaS → 银盛)
| 名称 | 类型 | 必填 | 说明 |
|---|---|---|---|
retCode |
String | 是 | SUCCESS / FAIL |
errMsg |
String | 否 | 错误描述 |
| 取值 | 含义 |
|---|---|
SUCCESS |
SaaS 处理成功,用户签约或解约成功 |
FAIL |
SaaS 处理失败,用户签约或解约失败 |
4.3.4 businessData 示例
{
"retCode": "SUCCESS",
"errMsg": ""
}
4.4 申请代扣解约通知
报文结构与「申请代扣签约通知」完全一致,仅以下差异:
| 差异项 | 取值 |
|---|---|
| 通知地址 | 使用「解约签约URL」 |
changeType |
固定为 DELETE |
cancelType |
携带解约类型 |
4.4.1 bizContent 示例
{
"signRecordNo": "SR7f3a9c2e1b4d5f6a",
"merchantNo": "826551253990363",
"billKey": "BK2026100001",
"changeType": "DELETE",
"openid": "oUpF8uMuAJO_M2pxb1Q9zNjWeS6o",
"accountType": "postpaid",
"deductDate": "10",
"rechargeLimit": 500000,
"rechargeAmount": 12000,
"mobile": "13800138000",
"idCard": "440101199001011234",
"userName": "张三",
"cancelType": "01",
"extend1": null,
"extend2": null
}
4.4.2 响应业务字段
与 4.3.3 / 4.3.4 完全一致。
5. 响应规范(SaaS → 银盛)
5.1 响应结构
物业 SaaS 必须在 HTTP 200 响应体中返回如下 JSON 信封:
{
"businessData": "{\"retCode\":\"SUCCESS\",\"errMsg\":\"\"}",
"sign": "MEUCIQDx...Base64编码的商户签名..."
}
| 字段 | 类型 | 必填 | 说明 |
|---|---|---|---|
businessData |
String | 是 | 业务响应内容,JSON 序列化后的字符串 |
sign |
String | 是 | 对 businessData 的签名,规则见 5.2 |
⚠️
businessData必须是字符串(内部 JSON 需转义),不能直接返回 JSON 对象。这是最常见的联调失败原因。
5.2 响应签名规则(SaaS 加签)
| 项 | 规则 |
|---|---|
| 签名原文 | 按字典序拼接 businessData=<值>(含 businessData= 前缀,值原样拼接,不加引号) |
| 签名算法 | 与发起方商户号配置一致:SHA256withRSA 或 SM3withSM2 |
| 签名私钥 | 物业 SaaS 自己的商户私钥 |
| 签名值编码 | Base64 |
| 验签方 | 银盛用物业 SaaS 提供的商户公钥验签,不通过则按通知失败重试 |
6. 示例代码(Java)
以下为物业 SaaS 侧参考实现,覆盖:接收通知 → 验平台签名 → 解析业务内容 → 组装响应 → 商户加签返回。
6.1 通知报文对象
import java.util.LinkedHashMap;
import java.util.Map;
/**
* 网关业务通知请求报文
*/
public class NotifyPayloadDTO {
/** 业务参数(JSON 字符串,由平台序列化) */
private String bizContent;
/** 编码,固定 UTF-8 */
private String charset;
/** 通知请求ID */
private String reqId;
/** 通知来源,固定 life */
private String src;
/** 通知时间戳,格式 yyyy-MM-dd HH:mm:ss */
private String timeStamp;
/** 平台签名(Base64) */
private String sign;
/** 构建验签数据 Map(不含 sign,与平台约定一致) */
public Map<String, Object> toSignMap() {
Map<String, Object> map = new LinkedHashMap<>();
map.put("bizContent", bizContent);
map.put("charset", charset);
map.put("reqId", reqId);
map.put("src", src);
map.put("timeStamp", timeStamp);
return map;
}
// getter / setter 省略
}
6.2 响应报文对象
import java.util.LinkedHashMap;
import java.util.Map;
/**
* 被通知方响应报文
*/
public class NotifyResponseDTO {
/** 业务参数(自行序列化的不透明字符串) */
private String businessData;
/** 商户对响应报文的签名(Base64) */
private String sign;
/** 构建验签数据 Map(不含 sign,与平台约定一致) */
public Map<String, Object> toSignMap() {
Map<String, Object> map = new LinkedHashMap<>();
map.put("businessData", businessData);
return map;
}
// getter / setter 省略
}
6.3 加验签原文构建工具
import java.nio.charset.StandardCharsets;
import java.util.ArrayList;
import java.util.Collections;
import java.util.List;
import java.util.Map;
/**
* 加验签原文构建工具
* 与平台约定一致:按 key 字典序拼接 key1=value1&key2=value2...,
* 跳过 sign 字段和 null 值,UTF-8 编码。
*/
public class SignUtils {
public static byte[] getSignDataBytes(Map<String, ?> map) {
return getSignDataStr(map).getBytes(StandardCharsets.UTF_8);
}
private static String getSignDataStr(Map<String, ?> map) {
List<String> keys = new ArrayList<>(map.keySet());
Collections.sort(keys);
StringBuilder sb = new StringBuilder();
for (String key : keys) {
if (map.get(key) == null || "sign".equals(key)) {
continue;
}
sb.append(key).append("=").append(map.get(key)).append("&");
}
if (sb.length() > 0) {
sb.setLength(sb.length() - 1);
}
return sb.toString();
}
}
6.4 商户加验签服务
import cfca.sadk.algorithm.common.Mechanism;
import cfca.sadk.algorithm.sm2.SM2PublicKey;
import cfca.sadk.lib.crypto.JCrypto;
import cfca.sadk.lib.crypto.Session;
import cfca.sadk.util.KeyUtil;
import cfca.sadk.util.Signature;
import cfca.sadk.x509.certificate.X509Cert;
import org.bouncycastle.jce.provider.BouncyCastleProvider;
import javax.annotation.PostConstruct;
import java.io.InputStream;
import java.nio.charset.StandardCharsets;
import java.security.KeyStore;
import java.security.PrivateKey;
import java.security.PublicKey;
import java.security.Security;
import java.security.cert.CertificateFactory;
import java.util.Base64;
/**
* 商户侧加验签服务
* <ul>
* <li>SM2:CFCA p1SignMessage / p1VerifyMessage(Mechanism.SM3_SM2),Base64-DER</li>
* <li>RSA:SHA256withRSA,Base64</li>
* </ul>
*/
public class MercSignService {
private PrivateKey sm2PrivateKey; // 商户 SM2 私钥(.sm2 文件)
private SM2PublicKey platformSm2PublicKey; // 平台 SM2 公钥证书(验平台通知)
private PrivateKey rsaPrivateKey; // 商户 RSA 私钥(PKCS12 .pfx)
private PublicKey platformRsaPublicKey; // 平台 RSA 公钥证书(验平台通知)
@PostConstruct
public void init() throws Exception {
if (Security.getProvider(BouncyCastleProvider.PROVIDER_NAME) == null) {
Security.addProvider(new BouncyCastleProvider());
}
// 商户 SM2 私钥
this.sm2PrivateKey = KeyUtil.getPrivateKeyFromSM2(readResource("cert/商户私钥.sm2"), "私钥密码");
// 平台 SM2 公钥证书
this.platformSm2PublicKey = (SM2PublicKey) new X509Cert(readResource("cert/平台sm2.cer")).getPublicKey();
// 商户 RSA 私钥(PKCS12)
KeyStore keyStore = KeyStore.getInstance("PKCS12");
try (InputStream is = openResource("cert/商户私钥.pfx")) {
keyStore.load(is, "私钥密码".toCharArray());
}
String alias = keyStore.aliases().nextElement();
this.rsaPrivateKey = (PrivateKey) keyStore.getKey(alias, "私钥密码".toCharArray());
// 平台 RSA 公钥证书
this.platformRsaPublicKey = CertificateFactory.getInstance("X.509")
.generateCertificate(openResource("cert/平台rsa.cer")).getPublicKey();
}
/** SM2 加签(SM3withSM2),返回 Base64-DER */
public String signSm2(byte[] sourceData) throws Exception {
Session session = JCrypto.getInstance().openSession(JCrypto.JSOFT_LIB);
Signature sig = new Signature(true);
byte[] signed = sig.p1SignMessage(Mechanism.SM3_SM2, sourceData, sm2PrivateKey, session);
// p1SignMessage 返回 Base64-DER,trim 去掉可能的空格填充
return new String(signed, StandardCharsets.UTF_8).trim();
}
/** SM2 验签(平台公钥,SM3withSM2) */
public boolean verifySm2(byte[] sourceData, String sign) throws Exception {
Session session = JCrypto.getInstance().openSession(JCrypto.JSOFT_LIB);
Signature sig = new Signature(true);
return sig.p1VerifyMessage(Mechanism.SM3_SM2, sourceData,
Base64.getDecoder().decode(sign), platformSm2PublicKey, session);
}
/** RSA 加签(SHA256withRSA),返回 Base64 */
public String signRsa(byte[] sourceData) throws Exception {
java.security.Signature sig = java.security.Signature.getInstance("SHA256withRSA");
sig.initSign(rsaPrivateKey);
sig.update(sourceData);
return Base64.getEncoder().encodeToString(sig.sign());
}
/** RSA 验签(平台公钥,SHA256withRSA) */
public boolean verifyRsa(byte[] sourceData, String sign) throws Exception {
java.security.Signature sig = java.security.Signature.getInstance("SHA256withRSA");
sig.initVerify(platformRsaPublicKey);
sig.update(sourceData);
return sig.verify(Base64.getDecoder().decode(sign));
}
private InputStream openResource(String path) {
// 实际实现按 classpath / 文件系统读取
return getClass().getClassLoader().getResourceAsStream(path);
}
private byte[] readResource(String path) throws Exception {
try (InputStream is = openResource(path);
java.io.ByteArrayOutputStream bos = new java.io.ByteArrayOutputStream()) {
byte[] buf = new byte[4096];
int n;
while ((n = is.read(buf)) != -1) {
bos.write(buf, 0, n);
}
return bos.toByteArray();
}
}
}
依赖:
cfca-sadk(SM2 加验签)、bcprov-jdk15on(BouncyCastle)、JDK 自带SHA256withRSA。
6.5 通知接收端点(完整实现)
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.http.HttpStatus;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestBody;
import org.springframework.web.bind.annotation.RestController;
@RestController
public class NotifyReceiverController {
private static final Logger log = LoggerFactory.getLogger(NotifyReceiverController.class);
private static final String SIGN_TYPE_SM = "SM";
private static final String SIGN_TYPE_RSA = "RSA";
private final MercSignService mercSignService;
public NotifyReceiverController(MercSignService mercSignService) {
this.mercSignService = mercSignService;
}
/**
* 接收业务通知
* 生产建议按业务分设端点:/notify/writeoff、/notify/deduct、/notify/sign、/notify/unsign
*/
@PostMapping("/notify/lifepay")
public ResponseEntity<?> notify(@RequestBody NotifyPayloadDTO payload) throws Exception {
log.info("<<< 收到通知: reqId={}, src={}, timeStamp={}",
payload.getReqId(), payload.getSrc(), payload.getTimeStamp());
// 1. 验平台签名(必须)
String signType = SIGN_TYPE_SM; // 按商户配置的签名类型传入 SM 或 RSA
if (!verifyPlatformSign(payload.toSignMap(), payload.getSign(), signType)) {
log.error("平台通知验签不通过: reqId={}, payload={}", payload.getReqId(), payload);
return ResponseEntity.status(HttpStatus.BAD_REQUEST).body("平台通知验签不通过");
}
// 2. 解析业务内容
String bizContent = payload.getBizContent();
if (bizContent == null || bizContent.isEmpty()) {
return ResponseEntity.status(HttpStatus.BAD_REQUEST).body("bizContent 为空");
}
// 3. 业务处理(示例直接回显,真实场景替换为处理结果)
// 核销场景应返回 {"retCode":"SUCCESS","errMsg":""}
// 代扣/签约场景应返回 {"retCode":"SUCCESS","errMsg":""}
String businessData = bizContent;
// 4. 组装响应信封,用商户私钥对 businessData 加签
NotifyResponseDTO response = new NotifyResponseDTO();
response.setBusinessData(businessData);
byte[] sourceData = SignUtils.getSignDataBytes(response.toSignMap());
response.setSign(mercSignService.signSm2(sourceData));
log.info(">>> 响应: reqId={}, response={}", payload.getReqId(), response);
return ResponseEntity.ok(response);
}
private boolean verifyPlatformSign(Map<String, ?> signSource, String sign, String signType) {
if (sign == null || sign.isEmpty()) {
return false;
}
try {
byte[] sourceData = SignUtils.getSignDataBytes(signSource);
return SIGN_TYPE_SM.equals(signType)
? mercSignService.verifySm2(sourceData, sign)
: mercSignService.verifyRsa(sourceData, sign);
} catch (Exception e) {
// 签名无法解码或格式非法同样视为验签不通过
log.warn("平台通知验签异常(按不通过处理): signType={}, reason={}", signType, e.getMessage());
return false;
}
}
}
6.6 关键点说明
| 要点 | 说明 |
|---|---|
toSignMap() 顺序无关 |
SignUtils 内部按 key 字典序重排,Map 放入顺序不影响结果 |
sign 不参与签名 |
SignUtils 自动跳过 sign 字段 |
null 值跳过 |
字段为 null 不参与拼接 |
| RSA 与 SM2 互斥 | 实际用哪种取决于发起方商户号配置的签名类型,不要两种都试 |
| SM2 签名值 | CFCA p1SignMessage 返回 Base64-DER,需 trim() 去掉空格填充 |
| 时间戳格式 | timeStamp 为 yyyy-MM-dd HH:mm:ss(含空格),参与签名时保持原样 |
7. 错误码
SaaS 响应不合法会导致通知失败,银盛按失败处理并重试。常见原因与责任划分:
| 现象 | 可能原因 | 责任方 |
|---|---|---|
| 请求未到达 SaaS | URL 不可访问、网络不通、防火墙拦截 | SaaS |
| 通知发送超时 | SaaS 响应超过约定的超时时间(待定) | SaaS |
| 响应报文解析失败 | 响应体不是合法 JSON 信封;businessData 写成了对象而非字符串 |
SaaS |
| 响应验签不通过 | 签名原文、算法、私钥与提供公钥不匹配 | SaaS |
| 响应验签异常 | 签名格式非法(未 Base64、乱码) | SaaS |
| 通知处理异常 | SaaS 服务内部异常(5xx) | SaaS |
| 发起方商户不存在或未配置签名类型 / 商户证书不存在 / 通知加签失败 | 银盛侧配置缺失 | 银盛(联系运营) |
7.1 超时与重试约定
| 项 | 约定 |
|---|---|
| 超时时间 | 待定(联调前由银盛确认后补充) |
| 重试机制 | 核销、代扣结果、签约通知失败后,银盛落差错任务,由定时任务按退避策略自动重试 |
| 重试上限 | 默认 5 次(可配置),超过后转人工介入 |
| 幂等要求 | SaaS 必须实现幂等。同一业务主键重复通知时,须返回与首次一致的 retCode,不得重复记账 |
判重键建议
| 通知类型 | 判重键 |
|---|---|
| 主动支付核销 | orderNo + billKey |
| 代扣结果 | outRequestNo(或 signRecordNo + payDate) |
| 代扣签约 / 解约 | signRecordNo |
| 通用 | reqId |
8. 联调检查清单
- [ ] 四个通知 URL 已提供并配置到银盛
- [ ] 发起方商户号已配置,且已配置签名类型(RSA / SM2)
- [ ] 平台公钥已获取(验平台通知签名用)
- [ ] 商户公钥已提供给银盛(验 SaaS 响应签名用)
- [ ] 通知接收接口能正确解析
bizContent,四类通知字段均能取到 - [ ] 已实现平台通知验签,验签失败返回 4xx
- [ ] 响应体为
{"businessData":"...","sign":"..."}格式,businessData是字符串 - [ ] 响应签名原文为
businessData=<值>,算法与配置一致,Base64 编码 - [ ] 响应耗时在约定超时时间内(超时时间待定)
- [ ] 已实现幂等(按业务主键或
reqId去重) - [ ] 核销通知的
retCode四值(SUCCESS/DUPLICATE/FAIL/ERROR)语义正确,尤其ERROR触发退款 - [ ] 敏感字段(手机号/身份证/姓名)已加密存储,未落明文日志
9. 常见问题
Q1:为什么我的响应总是报「响应报文解析失败」?
A:最常见是把 businessData 直接写成了 JSON 对象。必须是 JSON 字符串:
// ❌ 错误
{ "businessData": {"retCode":"SUCCESS"}, "sign":"..." }
// ✅ 正确
{ "businessData": "{\"retCode\":\"SUCCESS\"}", "sign":"..." }
Q2:验签总是不通过? A:逐项核对:
- 验平台通知时,原文是否为
bizContent=...&charset=UTF-8&reqId=...&src=life&timeStamp=...(字典序、&分隔、不含sign); - 对响应加签时,原文是否为
businessData=+ 值(连等号一起签); - 算法是
SHA256withRSA还是SM3withSM2,是否与商户配置一致; - 私钥与提供给银盛的公钥是否同一对;
- 签名结果是否做了 Base64。
Q3:核销通知返回 ERROR 后发生了什么?
A:银盛会立即向微信发起退款申请,把用户缴的钱退回。因此只有确认该账单此前已被缴清时才可返回 ERROR,否则会造成错误退款。
Q4:同一笔账单收到多次核销通知怎么办?
A:说明前次通知可能超时或被判定失败。SaaS 应按 orderNo + billKey 判重,已处理过的直接返回 DUPLICATE。
Q5:代扣结果通知里 orderNo 为空是正常的吗?
A:正常。扣费失败场景下银盛可能未生成支付流水号,此时 deductStatus=FAIL、orderNo 为空、failReason 有值。
Q6:bizContent 需要 URL 解码吗?
A:不需要。请求体是 JSON,bizContent 是标准 JSON 字符串,HTTP 层不会额外编码。
附录 A:枚举值汇总
核销结果码 retCode
| 值 | 含义 | 银盛动作 |
|---|---|---|
SUCCESS |
核销成功 | 回传微信成功 |
DUPLICATE |
重复核销 | 回传微信成功 |
FAIL |
业务失败 | 回传成功 + 重试 |
ERROR |
用户已完成缴费,自动执行退款请求 | 发起退款 |
代扣/签约响应码 retCode
| 值 | 含义 | 银盛动作 |
|---|---|---|
SUCCESS |
处理成功 | 流程结束 |
FAIL |
处理失败 | 落差错重试 |
代扣状态 deductStatus
| 值 | 含义 |
|---|---|
SUCCESS |
扣费成功 |
FAIL |
扣费失败 |
变更类型 changeType
| 值 | 含义 |
|---|---|
ADD |
签约 |
DELETE |
解约 |
签名类型(发起方商户号配置)
| 值 | 算法 |
|---|---|
RSA |
SHA256withRSA |
SM |
SM3withSM2 |