简介:《建设银行【善付通】支付结算接入文档》面向需要接入建行善付通的企业技术团队、支付系统开发者与供应链平台实施人员,围绕订单管理、收付款协同、对账及接口联调等场景,梳理从申请接入到正式上线的技术路径。压缩包内为1个docx文件,大小约1.09MB,集中整理了对接流程、数据传输规范、报文格式、数据加解密及各接口定义,便于按目录查阅支付、查询、退款、预付款、会员绑定、日终对账和订单协同等模块。文档对SFT系列接口的请求参数、响应格式与业务用途作了分节说明,并覆盖手机号验证码支付、联机退款、物流协同等较完整的交易生命周期内容,可作为企业设计接口调用、联调测试和排查字段问题的参考。目前已有2472人学习,适合需要理解善付通支付结算接入规范、快速定位接口文档的中高级开发与实施人员。
1. 从一次联调被打回说起:善付通支付结算接入到底要准备什么
第一次拿到善付通支付结算接入文档,我盯的是接口定义——三十来个 SFT 交易码,字段密密麻麻,以为照抄就能通。真正把联调卡了两天的,是文档前四页:数据传输规范、报文格式、DES 加解密。密钥拿错一套、Data 多编了一次码、回调地址写成了内网 IP,随便哪个都能让一笔在线支付在最后一步原地打转。
这套文档的坑不在接口写得多细,而在它是给已有系统的企业做对接用的:你得先有能出公网的服务器、能接异步通知的公网地址、分得清测试密钥和生产密钥的两套配置。适合正在接支付结算的 Java 后端和运维,也适合第一次做移动支付小程序对接、被回调通知折磨过的人。环境没列清,接口看得再熟也白搭。
2. 接入前的环境清单:第三方系统 ID、密钥与回调地址怎么定
接入善付通不是装一个包就完事,它是把自己系统挂到别人平台上的过程。落地第一步永远是"把东西配齐",配不齐后面每一行代码都在猜。我一般会先单独拉一个文档,把需要向对接方索取的凭证、需要自己准备的资源、需要报备的信息三列列出来,全齐了再动代码,比边写边问省事得多。
2.1 三类必填凭证与两套环境的密钥差异
善付通报文里有一个字段叫 ThirdSysID,是"委托单位平台 ID",文档示例里写的是 1004。这个值不是自己编的,是签约时分配给你的唯一标识,所有签名都从它开始拼。跟它配套的还有两把密钥:一把 DES 对称密钥,用来加密 Data 字段;一把 MD5 密钥,用来生成 Auth 签名。测试环境的这两把钥匙文档写死了,生产环境必须换成正式密钥。
我习惯把两套环境做成一张对照表贴在内网 wiki 上,谁改了配置都要同步:
| 配置项 | 测试环境 | 生产环境 | 取自哪里 |
|---|---|---|---|
| ThirdSysID | 文档示例 1004 | 正式分配值 | 对接反馈表 |
| DES 密钥 | TestDesKey | 正式密钥 | 对接反馈表 |
| MD5 密钥 | TestMD5Key | 正式密钥 | 对接反馈表 |
| 联机接口地址 | 测试地址 | 生产地址 | 文档 6.1 接口地址 |
| 回调接收地址 | 自建测试域名 | 自建公网域名 | 文档 6.3 需第三方提供 |
| Hosts 记录 | 可能需手工指定 | 不写死 | 文档 6.2 Hosts 配置 |
注意:文档里有两处容易读错。第一处是加解密章节的小标题写着"公钥加密/解密实现方法",但正文给的示例是 DES 对称密钥,不是非对称那一套,别被标题带偏。第二处是测试密钥只用于联调,上线前如果忘了替换,第一次真实扣款就会失败,而且报错信息往往只告诉你"验签不通过",排查成本极高。
拿到这几样东西之后,先把它们落在项目的配置文件里,不要写死在代码常量中。我见过不止一个项目把 TestMD5Key 直接写在 Java 类里,后来换生产密钥要重新编译发版,赶上发版窗口还拖了一天。
2.2 服务器、域名与网络出口的准备清单
善付通的接口分两类,对网络的要求不一样。联机接口是你主动发起的,服务器只要能出公网、能 POST 就行;网关接口和异步通知反过来,是平台往你这边发,所以你必须准备一个外网能访问到的接收地址。这一点如果前期没规划,最容易在上线前一周才发现回调地址在内网里,被挡在防火墙外面。
我一般按这几项过一遍:
- 一台能访问外网的业务服务器,用于发起联机接口请求,出口 IP 提前报备给对接方,很多平台的频繁调用限额和 IP 白名单是绑在一起的
- 一个公网可达的接收地址,接收支付结果通知、退款结果通知、会员绑定结果通知这类异步报文,必须是 POST 能打通的路径
- 服务器时间同步,装好 chrony 或 ntpdate 并配开机自启。流水号规则是 ThirdSysID + yyyyMMddHHmmss + 5 位顺序号,时间一歪,流水号就可能重复
- 统一 UTF-8,从操作系统 locale 到 JVM 的 file.encoding 到数据库连接串,全链路对齐。文档"数据传输规范"第一条就是字符集统一用 UTF-8
- 日志落盘,请求报文、响应报文、通知报文各留一份原始串,保留时间按财务要求来,我一般至少留 180 天
时间同步这条是被低估的。有一次测试环境的一台机器漂了七分钟,跟另一台同时发起请求,流水号撞在一起,被平台判为重复请求直接拒绝,现象看起来像"接口偶发失败",查了很久。
2.3 参数落在配置文件里:一份可直接抄的配置模板
把凭证和地址从代码里剥出来,落到配置文件,是接入阶段最值钱的一次重构。下面这份是我常用的结构,字段名对应文档里的参数,分环境各写一份:
# application-sft-test.properties 联调用 sft.thirdSysId=1004 sft.desKey=TestDesKey sft.md5Key=TestMD5Key sft.gatewayUrl=https://sft-test.example.com/gateway.do sft.onlineUrl=https://sft-test.example.com/online.do # 我们自己对外暴露的接收地址,必须公网可达 sft.notifyUrl=https://pay-test.example.com/sft/notify # 单笔订单金额上限(分),防止测试时误发真实大额 sft.orderAmountLimit=100# application-sft-prod.properties 上线用 sft.thirdSysId=${SFT_THIRDSYS_ID} sft.desKey=${SFT_DES_KEY} sft.md5Key=${SFT_MD5_KEY} sft.gatewayUrl=https://sft.example.com/gateway.do sft.onlineUrl=https://sft.example.com/online.do sft.notifyUrl=https://pay.example.com/sft/notify sft.orderAmountLimit=5000000两份文件的差别不只是值,还有读取方式。生产环境我坚持从环境变量注入密钥,不把明文密钥提交进仓库。Java 侧统一用一个配置类收口,避免各处散落System.getProperty:
@Component @ConfigurationProperties(prefix = "sft") public class SftConfig { private String thirdSysId; private String desKey; private String md5Key; private String gatewayUrl; private String onlineUrl; private String notifyUrl; private long orderAmountLimit; // getter / setter 省略 }配置类本身没什么技术含量,价值在于把"密钥从哪来"这件事固定成一条路径。之后再接对账、退款、预付款这些接口,加字段只是加属性,不会有人又去new一个常量类出来。启动时加一段校验,密钥为空直接抛异常失败退出,比跑到线上第一次支付才发现要好。
3. 报文封装:DES 加密与 MD5 签名的可执行实现
报文是这套接入里最容易翻车的地方,因为它一次性把编码、加密、签名三件事叠在一起,任何一层错一位,对方只会回你一个"验签失败"。文档第三章把报文拆成五部分,第五章每个接口又只描述 Data 里的内容,中间这层"怎么把业务对象变成 Data"文档不管,得自己写。
3.1 五个字段的报文结构拆解
所有接口的传输数据总体分为五部分,前四个是你发出去的,后两个是平台出错时回来的:
| 字段名 | 含义 | 可空 | 是否加密 |
|---|---|---|---|
| ThirdSysID | 对接系统的 ID,签约时分配 | 否 | 否 |
| TxCode | 交易码,如 SFT10001 | 否 | 否 |
| Data | 该接口所需的具体业务数据 | 否 | 是,DES 加密 |
| Auth | ThirdSysID + TxCode + Data + MD5Key 的 MD5 | 否 | 否 |
| errorCode | 错误码,发送请求时不上送 | 响应报错才有 | 否 |
| errorMsg | 错误信息,发送请求时不上送 | 响应报错才有 | 否 |
看清这张表的关键是理解顺序:先有明文业务数据,再 DES 加密成 Data,再用"未加密的 ThirdSysID + TxCode + 加密后的 Data + MD5Key"拼串做 MD5 得到 Auth。也就是说签名签的是密文,不是明文。很多人第一版把明文拿去签,本地自测完全自洽,一联调就全错。
Data 内部的内容格式,文档给了 dataset 的定义方式,比如 SFT10001 里 OrderInfos 是个可传多行的数据集,里面再嵌 Order_Products。落地上我一般用两级 Java Bean 映射,序列化成平台约定的键值串,而不是直接丢 JSON 过去——文档没有说 Data 明文是 JSON,稳妥做法是按对端确认的格式拼,别自己发明。
3.2 DES 加解密:密钥、填充与编码的三个坑
文档给的示例代码调用了 DESTool.encrypt(desKey, data),示例密文长这样:07B0FE28667D98092D5DC0730A56E784EF68EB3BAD2F5E8C。从这个密文能反推出两个关键信息:输出是十六进制大写字符串,不是 Base64;长度是 48 个字符,对应 24 字节,说明分组模式是常见的 ECB。如果不按这两点来,密文看起来"也是一串乱码",但对方解不出来。
用 JDK 自带能力写一个等效实现,重点在密钥处理和输出编码:
public final class SftDesUtil { private static final String ALGORITHM = "DES"; // ECB + PKCS5Padding,与文档示例密文形态一致 private static final String TRANSFORMATION = "DES/ECB/PKCS5Padding"; public static String encrypt(String key, String plain) throws Exception { Cipher cipher = Cipher.getInstance(TRANSFORMATION); // DES 密钥固定 8 字节,超长部分会被 JDK 拒绝,这里显式截断 byte[] keyBytes = Arrays.copyOf(key.getBytes(StandardCharsets.UTF_8), 8); cipher.init(Cipher.ENCRYPT_MODE, new SecretKeySpec(keyBytes, ALGORITHM)); byte[] out = cipher.doFinal(plain.getBytes(StandardCharsets.UTF_8)); // 输出必须是大写十六进制,与示例 07B0FE... 对齐 return Hex.encodeHexString(out).toUpperCase(); } public static String decrypt(String key, String cipherText) throws Exception { Cipher cipher = Cipher.getInstance(TRANSFORMATION); byte[] keyBytes = Arrays.copyOf(key.getBytes(StandardCharsets.UTF_8), 8); cipher.init(Cipher.DECRYPT_MODE, new SecretKeySpec(keyBytes, ALGORITHM)); byte[] out = cipher.doFinal(Hex.decodeHex(cipherText.toCharArray())); return new String(out, StandardCharsets.UTF_8); } }TRANSFORMATION换成别的模式,密文长度和内容都会变,对端解不开。Arrays.copyOf那行是血泪经验:DES 只认 8 字节密钥,直接key.getBytes()传进去,密钥超过 8 字节会抛InvalidKeyException: Wrong key size,密钥不足 8 字节同样报错。文档示例里的密钥字符串长度并不规整,说明对端工具类内部也做了截断或补齐,跟它对齐最省事。
第三个坑在编码。明文里有中文,比如订单标题、收货方姓名,一定要按 UTF-8 取字节再加密。用默认字符集在本地 Windows 上跑通、部署到 Linux 就乱码,这类问题我踩过不止一次,最后都是在getBytes里显式写死编码才彻底消停。
3.3 MD5 签名的拼接顺序与 UTF-8
签名规则文档写得很直白:auth = MD5.getMD5(thirdSysID + txCode + data + MD5Key, "UTF-8")。示例里 thirdSysID 是 1004,txCode 是 SFT10001,data 是上一步的密文,MD5Key 是 TestMD5Key,算出来 auth=4764da69b2cd9497801d42394562dd3f,32 位小写。
public static String sign(String thirdSysId, String txCode, String data, String md5Key) throws Exception { // 拼接顺序不能变:ID + 交易码 + 密文 + 密钥 String raw = thirdSysId + txCode + data + md5Key; MessageDigest md = MessageDigest.getInstance("MD5"); byte[] digest = md.digest(raw.getBytes(StandardCharsets.UTF_8)); StringBuilder sb = new StringBuilder(); for (byte b : digest) { // 小写、补零,得到 32 位字符串 sb.append(String.format("%02x", b)); } return sb.toString(); }三个细节值得单独说。第一,拼接时不能加任何分隔符,不要好心加|或&;第二,参与签名的 Data 是加密后的密文原串,不能是 URL 编码之后的样子;第三,输出是 32 位小写,String.format("%02x")会自动补前导零,如果用Integer.toHexString手写循环,遇到小于 16 的字节会少一位,签名长度变成 31 位,验签必挂。
还有一个隐蔽的坑:网关接口是表单提交,浏览器或 HttpClient 会对你传的 Data 做一次 URL 编码。如果服务端在验签前先做 URL 解码再拿原始串拼,是对的;如果先拼再解码,就错位了。我的做法是接收侧统一从 request 取到参数后原样保留,先验签,验签通过再解码业务数据,顺序不能反。文档给的接收示例也是这个思路:先取request.getParameter("Data"),再拼ThirdSysID + TxCode + Data + MD5Key算临时签名,跟传进来的 Auth 比对。
3.4 一个能跑通 SFT10001 的最小请求
把前面三块拼起来,就是一次完整的订单支付请求。SFT10001 是网关接口,发起后如果买家没在平台注册,会跳转登录注册页,注册完再跳支付页,最终结果通过 SFT20001 异步通知回来。下面这段只构造请求,不处理跳转,用于验证加密签名链路是否打通:
public String buildPayRequest(SftConfig cfg, Order order) throws Exception { // 1. 组装业务数据,字段名必须与文档大小写完全一致 Map<String, String> biz = new LinkedHashMap<>(); biz.put("TxCode", "SFT10001"); biz.put("TransID", buildTransId(cfg.getThirdSysId())); biz.put("GoPayType", "2"); // 1-PC 2-移动 3-小程序 biz.put("PayType", "01"); // 01-直接支付 00-担保支付 biz.put("OrderIniter", "0"); // 0-买方发起 1-卖方发起 biz.put("BuyerUserID_ThirdSys", order.getBuyerId()); biz.put("BuyerUserName_ThirdSys", order.getBuyerName()); biz.put("SellerUserID_ThirdSys", order.getSellerId()); biz.put("BuyerUserType_ThirdSys", "0"); // 0-个人 1-企业 biz.put("BuyerTrueName_ThirdSys", order.getBuyerTrueName()); biz.put("BuyerCompany_ThirdSys", order.getBuyerCompany()); biz.put("BuyerPhoneNum_ThirdSys", order.getBuyerPhone()); biz.put("BuyerAddress_ThirdSys", order.getBuyerAddress()); biz.put("Pay_Interface", "00"); // 00-收银台 01-个人网银 02-企业网银 04-聚合支付码 biz.put("OrderInfos", buildOrderInfos(order)); // dataset,单笔订单 String plain = toKvString(biz); String data = SftDesUtil.encrypt(cfg.getDesKey(), plain); String auth = SftSignUtil.sign(cfg.getThirdSysId(), "SFT10001", data, cfg.getMd5Key()); // 四个参数按表单字段名提交,注意 ThirdSysID 大小写 return "ThirdSysID=" + cfg.getThirdSysId() + "&TxCode=SFT10001" + "&Data=" + URLEncoder.encode(data, "UTF-8") + "&Auth=" + auth; }buildTransId按文档规则实现:ThirdSysID + yyyyMMddHHmmss + 5 位顺序号,总长不超过 30 位,且一个流水号只能用一次,发送和返回两次跳转都算一次,重复请求无效。顺序号我一般用 Redis 自增,别用随机数,重复概率不为零。OrderInfos里必须带HaveProducts=1,以及Order_No、Order_Money、Order_Time(yyyyMMddHHmmss)、Order_Title、Order_BuyerPhone和收货方四件套,这些少一个都会被拒。
单笔订单金额建议在配置里设个上限,测试阶段把orderAmountLimit调小。文档明确说每个第三方系统每天允许调用接口的次数有限制,具体数值协商确定,超了当天该交易直接拒绝。联调阶段乱刷请求,把当天额度打满,是很常见的低级事故。
4. 联机接口与网关接口:两种调用方式的部署差异
文档把接口分成联机接口和网关接口,这个区分不是学术分类,它直接决定你的服务器要开什么端口、代码要用什么客户端、要不要写页面。很多人把两者混着用,用 HttpClient 去打网关接口,或者拿表单去跳联机接口,结果要么报错要么静默失败。
4.1 联机接口:HttpClient POST 与超时重试
联机接口的特征是"无页面跳转,httpclient 发起 post 请求,处理完原路返回"。SFT10003 订单信息查询、SFT10005 绑定用户关系、SFT10008 日终对账、SFT10009 订单退款、SFT10013 联机退款都属于这一类。它们适合放在服务端做自动化,比如定时对账、批量绑定、异步补偿。
用 HttpClient 发的时候,有三处参数必须显式设:
RequestConfig config = RequestConfig.custom() .setConnectTimeout(3000) // 建连超时,内网调可再小 .setSocketTimeout(15000) // 读超时,查询类接口给足 .setConnectionRequestTimeout(1000) .build(); HttpPost post = new HttpPost(cfg.getOnlineUrl()); // 表单编码必须是 UTF-8,否则 Data 里的中文密文会被二次处理 post.setEntity(new UrlEncodedFormEntity(formParams, StandardCharsets.UTF_8)); post.setConfig(config);超时值不是随便填的。查询类接口返回的是订单列表,SFT10003 一次最多查 10 个订单号,超过 10 个直接被拒,所以批量场景要自己切片。退款类接口一旦超时,结果是不确定的——请求可能已经到了平台并执行成功,只是响应没回来。这种接口不能简单重试,我的做法是超时后立刻用 SFT10003 按订单号查一次真实状态,查到已退款就不再重发,把"重试"变成"查询后补偿"。
返回格式文档也写清了:查询类返回 Json 格式订单列表,里面有Status(1-成功 2-失败)和Remark。注意这个 Status 表示"这次查询请求本身成功与否",不是"订单支付成功与否",订单的支付状态在OrderInfos里的PayStatus。0-付款中 1-付款成功 2-付款失败 3-订单取消 4-已支付未确认付款。我见过有人把外层 Status 当支付结果写进业务库,结果所有订单都成了"成功",对账时才发现金额对不上。
4.2 网关接口:FORM 表单跳转与 GoPayType 的选择
网关接口的要求相反:必须 POST 报文 FORM 表单,以页面跳转形式发起请求,处理结果异步通知。SFT10001 订单支付、SFT10012 手机号验证码支付、SFT10020 预付款支付都是这类。它本质是把用户浏览器"送"到平台页面上完成支付,所以不能由后端 HttpClient 发起。
落地时后端只负责生成四个参数,前端负责渲染一个自动提交的表单:
<form id="sftPayForm" action="https://sft.example.com/gateway.do" method="post"> <input type="hidden" name="ThirdSysID" value="1004"/> <input type="hidden" name="TxCode" value="SFT10001"/> <input type="hidden" name="Data" value="07B0FE28..."/> <input type="hidden" name="Auth" value="4764da69b2cd9497801d42394562dd3f"/> </form> <script>document.getElementById('sftPayForm').submit();</script>GoPayType是这里最关键的参数,决定跳哪个版本的支付页:传 1 跳 PC 版,传 2 跳移动版,传 3 表示走小程序跳支付。同一套后端代码要同时服务 PC、H5 和小程序时,这个值必须由调用方传入而不是写死。小程序场景尤其要注意,只是把 1 改成 3 不够,小程序的跳转链路、支付目录、业务域名都要单独报备,文档专门有一节"小程序对接规范"讲这件事。
还有一个参数Pay_Interface决定支付接入方式:00 收银台、01 个人网银、02 企业网银、04 聚合支付码。传 04 时平台会在响应里带一个Direct_Pay_Url,你需要把这个地址渲染成二维码给用户扫。不传默认 00,走收银台,用户自己选。
OrderIniter这个字段容易被忽略。0 表示作为买方发起,1 表示作为卖方发起。供应链场景里买卖双方身份是相对的,同一家企业既采购也销售,发起方搞反了,后续绑定关系、协同单据的方向全乱。我们项目里是在订单表上加了一列标记,生成报文时按订单归属取,不靠人工判断。
4.3 异步通知接收端:SFT20001 的幂等处理
支付完成后,平台会调用 SFT20001 通知你结果。文档说明:每一笔订单支付完成后无论成功与否都会调用,包括页面通知(带报文的页面跳转)和服务器通知(POST 到指定服务器地址)两种。这意味着你的接收接口会被重复调用——页面跳一次、服务器 POST 一次,用户刷新页面可能再来一次。
接收端必须做的第一件事是验签,第二件事是幂等。
@PostMapping("/sft/notify") public String notify(@RequestParam("ThirdSysID") String thirdSysId, @RequestParam("TxCode") String txCode, @RequestParam("Data") String data, @RequestParam("Auth") String auth) throws Exception { // 1. 先验签,用原始密文拼,不要先解码 String expect = SftSignUtil.sign(thirdSysId, txCode, data, cfg.getMd5Key()); if (!expect.equals(auth)) { log.warn("sft notify sign mismatch, thirdSysId={}", thirdSysId); return "fail"; // 验签失败不要抛异常,避免对端无限重推 } // 2. 解密业务数据 String plain = SftDesUtil.decrypt(cfg.getDesKey(), data); PayNotify notify = parse(plain); // 3. 幂等:以 Order_No + PayStatus 落唯一索引,重复的直接返回 success if (!notifyService.trySave(notify)) { log.info("duplicate notify ignored, orderNo={}", notify.getOrderNo()); } return "success"; }PayStatus有四个值:1 付款成功、2 付款失败、4 已支付未确认付款。最后这个状态在担保交易场景下会出现,意味着钱已经到了但还没确认收货,不能当成功也不能当失败处理。很多系统只判断了 1 和 2,遇到 4 就把订单卡在中间态,用户投诉"付了钱订单没变",其实是状态机没覆盖全。
Total_Money是累计支付金额,Order_Money是本次支付金额。分账、部分支付的场景下这两个值不一样,对账时要用Total_Money,不能用本次金额。Pay_No_CCB是平台付款单号,退款时要用到它,务必落库。Pay_Card和PayNameByCard涉及付款人信息,属于敏感数据,落库前该脱敏的脱敏。
5. 避坑与排查:接入善付通支付结算最常见的五个翻车点
这一章是我自己踩过、也帮别人排查过的记录,按"现象 → 原因 → 解决"写,遇上对得上的直接照方抓药。
5.1 验签一直失败,怎么看都"一模一样"
现象:本地用示例里的 1004 / SFT10001 / 07B0FE28… / TestMD5Key 算出来的 MD5 跟文档对不上;或者线上联调,对方永远回验签失败,日志里两个签名肉眼看着一样长。
原因:绝大多数是拼接顺序或大小写问题。第一,字段名ThirdSysID里 ID 是两个大写字母,很多人写成ThirdSysId,服务端取不到值,拼出来的是空串;第二,MD5 输出是 32 位小写,用Integer.toHexString手写循环会丢前导零,长度变成 31 位;第三,网关场景下 Data 被 URL 编码过一次,验签前如果先解码再拼,拼的就不是原始密文。
解决:拿文档给的示例四件套(1004 / SFT10001 / 07B0FE28667D98092D5DC0730A56E784EF68EB3BAD2F5E8C / TestMD5Key)写一个单元测试,断言结果等于 4764da69b2cd9497801d42394562dd3f。这个用例过不了,别往下写业务,先把签名链路焊死。
5.2 中文订单标题变成问号,对端说数据非法
现象:本地 Windows 上一切正常,部署到 Linux 之后,Order_Title里的中文在平台侧显示成乱码,或者直接报数据校验失败。
原因:String.getBytes()不传字符集时用的是平台默认编码,Windows 上可能是 GBK,Linux 上是 UTF-8,两边加密出来的密文完全不同。文档"数据传输规范"第一条就要求字符集统一用 UTF-8,是全链路的统一。
解决:所有getBytes显式写StandardCharsets.UTF_8,JVM 启动参数加-Dfile.encoding=UTF-8,容器镜像的 locale 设成C.UTF-8,数据库连接串加characterEncoding=utf8。改完用一条带中文的订单完整跑一遍加密、签名、发送、通知接收,全链路验证。
5.3 请求被拒,提示流水号重复
现象:测试环境偶发失败,隔一段时间重试就正常,线上高并发时偶发率明显上升。
原因:流水号规则是 ThirdSysID + yyyyMMddHHmmss + 5 位顺序号,一个流水号只能用一次,发送和返回两次跳转都算。如果顺序号用的是new Random().nextInt(99999),两台机器同秒并发就有概率撞号;如果服务器时间漂移,两台机器的时间戳还可能倒挂。
解决:顺序号改用 Redis 自增,key 带上日期做自然过期;服务器统一配 NTP 并加监控,漂移超过 1 秒告警。重试时不要复用原来的流水号,重试必须重新生成,同时用查询接口确认前一次是否已成功,避免重复支付。
5.4 异步通知一条都没收到
现象:用户在页面上付完款,跳回商户页显示成功,但后台订单状态一直不更新,也没有任何通知日志。
原因:通知是你主动报备的地址,平台从外网 POST 过来。最常见的是报备地址写成了内网 IP 或 localhost;其次是防火墙只开了 443 没开 80,或者路径被网关的鉴权拦截器挡在外面要求登录;还有一种情况是接收接口返回了非约定的成功标识,平台认为投递失败。
解决:先用 curl 从外网机器自测一下报备地址能不能 POST 通,再检查 Nginx 和 Spring Security 的放行规则,把通知路径加进白名单。接收接口处理成功后必须返回明确的成功响应,处理失败也要正常返回而不是抛 500,否则平台会持续重推,把日志刷爆。
5.5 对账文件拿不到,或者拿到的不是当天的
现象:SFT10008 日终对账按日期查询,返回的文本文件内容跟预期日期对不上,或者查询直接报错。
原因:日期参数格式和对账时区没对齐。日终对账是按平台侧的日切时间出文件的,你在凌晨两点查"今天",平台那边可能还没切日。另外对账接口每日调用次数同样有限制,定时任务配得太密会被拒。
解决:把对账任务放到上午跑,查前一天的日期;文件解析用定长或按对端给的分隔符切,不要用split(",")硬来;解析结果跟本地支付流水做双向核对,只比对总额不够,要按订单号逐笔比对,差异单单独出一张表人工跟。对账不平的单子,用 SFT10003 带上Clr_Flag=001去查清算状态,Clr_Status返回 00 未清算、01 清算成功、05 清算成功后有退款,能快速定位是没清算还是退过款。
6. 装完怎么验证跑通:从 SFT10003 查询到日终对账的闭环自检
配置写完、代码提交、部署上线,这时候最容易犯的错是"页面能跳过去就算通了"。真正的验证要走完一条闭环:发起支付、收到通知、主动查询、日终对账,四步都能对上,才算这套接入真的立住了。
我一般的自检顺序是这样:第一步,用测试环境密钥发一笔 SFT10001,GoPayType=1,金额设成 0.01,确认能正常跳转到支付页;第二步,支付完成后看接收端日志里有没有 SFT20001,验签是否通过,PayStatus是不是 1,Order_No_CCB和Pay_No_CCB有没有落库;第三步,隔几分钟用 SFT10003 按订单号查一次,比对查询结果和通知里的金额、状态是否一致;第四步,第二天上午跑 SFT10008 对账,逐笔核。
第三步里有个容易被忽略的参数:Clr_Flag传 001 会带回清算状态和清算时间,不传就只查支付状态。资金真正到账看的是清算,不是支付成功,财务口径和业务口径在这里分叉。我们项目里是把两个状态都落库,业务侧看支付状态,财务侧看清算状态,避免"订单显示已付款但钱没到"的解释成本。
验证阶段还有一张我必看的检查表:
| 检查项 | 通过标准 | 出问题先看哪 |
|---|---|---|
| 加密链路 | 示例四件套算出的签名等于 4764da69b2cd9497801d42394562dd3f | 密钥、拼接顺序、大小写 |
| 网关跳转 | PC 和移动端分别能跳对页面 | GoPayType、Pay_Interface |
| 异步通知 | 日志中有 SFT20001 且验签通过 | 报备地址、防火墙、返回标识 |
| 主动查询 | SFT10003 返回的 PayStatus 与通知一致 | 是否把外层 Status 当支付状态 |
| 幂等 | 同一订单重复通知只处理一次 | 唯一索引、状态机 |
| 对账 | 逐笔核对无差异,差异单可解释 | 日期、时区、清算标识 |
这套流程跑通之后,退款(SFT10009 / SFT10013)、预付款(SFT10020 / SFT10021)、会员绑定(SFT10011)基本是同一套报文骨架换字段,把 Data 里的 dataset 换掉、TxCode 换掉,验签和加解密复用,边际成本很低。真正需要单独设计的是状态机:支付、退款、预付款、协同单据各自的状态流转不一样,尤其"已支付未确认付款"这个中间态,一定要在状态机里留位置。
从那以后我每次接这类支付结算平台,不管文档写得多清楚,都强制先跑一遍"示例签名单元测试 + 0.01 元真实链路 + 次日对账"这三步,少一步都不敢上线。支付这东西没有后悔药,验证阶段多花的两小时,都是从线上事故里省出来的。希望帮到你。
本文还有配套的精品资源,点击获取