简介:《建设银行【善付通】支付结算接入文档》面向需要对接建行善付通支付结算服务的开发、测试与财务信息化人员,帮助梳理从申请接入、系统开发、联调测试到正式上线的完整路径,并解决订单协同、收付款、对账与退款等环节的接口落地问题。压缩包内为1个docx文档,约1.09MB,内容按对接流程、数据传输规范、报文格式、数据加解密和接口定义组织,便于快速定位技术要点。文档覆盖订单支付、支付结果通知、订单查询、预付款、日终对账、退款、买家会员绑定、联机订单保存及物流协同等接口,并给出功能说明、请求参数与响应格式,同时涉及采购销售订单、商品、供应商、客户、员工与结算管理等业务模块的背景。已有2472人学习,适合作为接口联调、参数核对与交易全生命周期流程设计的参考手册。
1. 一份把 30 个接口码塞进五字段报文的支付接入文档
接手供应链平台的在线支付改造时,最容易被低估的就是这类文档:目录排到第八十几页,接口码从 SFT10001 一直列到 SFT10029,乍看像本字典,翻两页才发现它对每个字段的类型、长度、是否可空都写死了。这份某银行供应链支付结算平台的接入文档就是典型——网关跳转、移动支付、小程序支付、预付款、退款、日终对账、上下游绑定协同全在一个体系里,但所有接口共用同一套五字段报文和同一套 DES 加 MD5 的签名链路。换句话说,你只要把传输层那六十行工具代码写对,剩下三十个接口就是填空题。它适合正在做 B 端收付款、对账自动化、多级供应商结算的后端和对接工程师,也适合被"对账工作量大、出错率高"折磨过的财务系统开发者。下面按我实际联调的顺序拆:先报文和签名,再接口分流,再对账闭环,最后是那几个能让你多熬两个通宵的坑。
2. 报文五字段与签名链路:DES 加密加 MD5 校验怎么写成可复用工具类
这套接口的传输层设计得非常"老派"但极其统一:不管你是发起支付、查订单还是拉对账单,出去和进来的数据永远是 ThirdSysID、TxCode、Data、Auth 四个字段(响应报错时多两个 errorCode、errorMsg)。统一的好处是拦截器、日志脱敏、重试策略只需写一份;坏处是任何一处拼串顺序错了,三十个接口会一起报"签名校验失败",你会怀疑人生。所以我不建议一上来就写业务,先把这一层抽成独立模块,用 main 方法自测通过再往下走。
2.1 ThirdSysID、TxCode、Data、Auth 的职责边界
先把四个字段的语义钉死,后面所有问题都好定位。ThirdSysID 是对接系统 ID,由平台方分配,测试环境和生产环境通常不同,它同时参与签名,所以配置错了不会报"ID 不存在",而是直接签名不通过。TxCode 是交易码,八位字符,决定平台把 Data 解密后往哪个业务处理器丢。Data 是业务数据的 DES 密文,注意是密文,不是明文 JSON。Auth 是 MD5 摘要,参与计算的原文是ThirdSysID + TxCode + Data + MD5Key四段直接拼接,中间不加任何分隔符。
这里有个反直觉的点:签名签的是密文,不是明文。很多同行的习惯做法是先对业务明文签名再加密,那在这套体系里必然失败。另一个点是字段名大小写敏感,ThirdSysID写成thirdSysId,平台侧取不到值,返回的 errorMsg 往往只写"参数错误",排查成本很高。我一般会在网关层做一次字段名规范化,把外部传入的键统一按白名单映射,避免前端或上游系统随手改大小写。
字符集统一 UTF-8,这条也参与签名结果——同一串中文,用 GBK 编码算出来的 MD5 和 UTF-8 完全不同。如果你的系统里有历史模块默认 GBK,务必在签名方法里显式指定字符集,不要依赖String.getBytes()的默认值。
2.2 DES 加解密的密钥管理与填充模式
文档只说了"采用 DES 对称加密/解密算法",给了密钥变量和一对示例明文密文,但没写工作模式和填充方式。这类文档的惯例是 DES/ECB/PKCS5Padding,输出十六进制大写字符串,示例里那串 48 位密文也符合这个特征(明文长度补齐到 8 的倍数后翻倍成 hex)。我的做法是先按 ECB/PKCS5Padding 实现,用文档给的示例向量跑一遍,能对上就锁定,对不上再试 CBC 加零 IV。这一步必须先做,别等业务写完才发现加密算法猜错。
密钥管理是另一回事。测试环境用文档里那对固定密钥没问题,生产环境换成对接方通过反馈表下发的那一对,而且这对密钥绝对不能进 Git。我的习惯是放在配置中心或者环境变量里,代码里只读SFT_DES_KEY、SFT_MD5_KEY两个变量,启动时校验非空且长度合法,缺失直接让应用起不来——这比运行到第一笔支付才发现密钥是空的要好得多。
import javax.crypto.Cipher; import javax.crypto.spec.SecretKeySpec; import java.security.MessageDigest; import org.apache.commons.codec.binary.Hex; public final class SftCodec { private static final String CHARSET = "UTF-8"; private final String desKey; // 8 字节,测试/生产分开配置 private final String md5Key; // 参与签名,长度不限 public SftCodec(String desKey, String md5Key) { if (desKey == null || desKey.length() != 8) { throw new IllegalArgumentException("DES 密钥必须是 8 字节"); } this.desKey = desKey; this.md5Key = md5Key; } /** 明文 -> 十六进制大写密文 */ public String encrypt(String plain) throws Exception { Cipher cipher = Cipher.getInstance("DES/ECB/PKCS5Padding"); cipher.init(Cipher.ENCRYPT_MODE, new SecretKeySpec(desKey.getBytes(CHARSET), "DES")); byte[] out = cipher.doFinal(plain.getBytes(CHARSET)); return Hex.encodeHexString(out).toUpperCase(); } /** 十六进制密文 -> 明文 */ public String decrypt(String cipherHex) throws Exception { Cipher cipher = Cipher.getInstance("DES/ECB/PKCS5Padding"); cipher.init(Cipher.DECRYPT_MODE, new SecretKeySpec(desKey.getBytes(CHARSET), "DES")); byte[] out = cipher.doFinal(Hex.decodeHex(cipherHex.toCharArray())); return new String(out, CHARSET); } /** 签名:注意 data 传密文,不是明文 */ public String sign(String thirdSysId, String txCode, String data) throws Exception { String raw = thirdSysId + txCode + data + md5Key; MessageDigest md = MessageDigest.getInstance("MD5"); byte[] digest = md.digest(raw.getBytes(CHARSET)); return Hex.encodeHexString(digest).toLowerCase(); } /** 验签:用常量时间比较,避免被时序侧信道慢慢试出来 */ public boolean verify(String thirdSysId, String txCode, String data, String auth) throws Exception { String expect = sign(thirdSysId, txCode, data); return MessageDigest.isEqual(expect.getBytes(CHARSET), auth.getBytes(CHARSET)); } }代码里三处关键注释对应三个最容易翻车的参数:desKey必须是 8 字节,多一位少一位都会在SecretKeySpec处抛异常;data传的是密文,签明文是无效的;MD5 结果转小写十六进制,文档示例里给的就是小写,如果你的实现输出大写,平台比对会失败。encrypt返回大写 hex,是因为示例密文全大写——这一点也建议照抄,别自作主张改小写,虽然解密端通常兼容,但联调期任何非必要差异都会变成排查噪音。
2.3 流水号生成器该写成什么样
文档对流水号的要求一句话:ThirdSysID + yyyyMMddHHmmss + 5 位顺序号,由发送方生成,长度不超过 30 位,一个流水号只能用一次,重复请求无效。这条约束看着简单,实际藏着两个硬边界。第一,ThirdSysID 不能超过 11 位,因为 11 + 14 + 5 = 30,如果你的系统 ID 被分配了 12 位,规则直接撑爆长度上限,这时候要么和平台方确认能否裁剪,要么在拼接时截断,但截断后的 ID 在日志里就不可追溯了——我倾向于先找平台方确认,别自己动刀。
第二,"只能用一次"意味着重试必须换号。很多团队的处理是:支付请求超时,拿同一个 TransID 再发一次,结果平台返回"重复请求"。正确做法是业务层维护"业务单号 → 请求流水号"的映射,重试时生成新流水号,但业务单号(Order_No)保持不变,靠订单号去查真实状态。这个思路和后面讲幂等是同一套逻辑,先把概念埋下。
import java.time.LocalDateTime; import java.time.format.DateTimeFormatter; import java.util.concurrent.atomic.AtomicInteger; public class TransIdGenerator { private static final DateTimeFormatter FMT = DateTimeFormatter.ofPattern("yyyyMMddHHmmss"); private final String thirdSysId; // 长度必须 <= 11 private final AtomicInteger seq = new AtomicInteger(0); private volatile String currentSecond = ""; public TransIdGenerator(String thirdSysId) { if (thirdSysId.length() > 11) { throw new IllegalArgumentException("ThirdSysID 超过 11 位,拼出的流水号会超长"); } this.thirdSysId = thirdSysId; } public synchronized String next() { String now = LocalDateTime.now().format(FMT); // 跨秒重置序号,保证同秒内 5 位以内递增 if (!now.equals(currentSecond)) { currentSecond = now; seq.set(0); } int n = seq.incrementAndGet(); if (n > 99999) { throw new IllegalStateException("同一秒内请求超过 99999 笔,需要降速或改为毫秒级分段"); } return thirdSysId + now + String.format("%05d", n); } }这段的重点在构造函数的长度校验和next()的跨秒重置。没有长度校验,问题会在压测那一天以"流水号超长"的形式集中爆发;没有跨秒重置,序号会一路涨到六位数,%05d格式化后长度变成 6 位,总长度仍然合规但语义已经偏了,平台侧对顺序号是否严格五位没有明说,保守起见还是重置。synchronized在单机够用,多实例部署时冲突概率不低,常见做法是把序号换成 Redis 的秒级自增 key,key 里带日期和秒,过期时间设两秒,成本很低。
3. 网关接口与联机接口的分流:订单支付、查询、退款三条主链路
把这套接口分成两类,后面所有设计决策都顺了。网关接口是要跳页面的:你 POST 一个 FORM 表单到平台地址,用户浏览器跟着跳过去,在平台侧完成登录、选卡、输密码,结果通过异步通知回来。联机接口没有页面,你用 httpclient 直接 POST 过去,平台处理完原路返回 JSON。文档里明确写了这个区分,但没说清楚哪些接口属于哪类,我整理出来是这样:SFT10001 订单支付、SFT10020 预付款支付、SFT10012 手机号验证码支付属于网关侧;SFT10003 查询、SFT10009 退款、SFT10008 对账、SFT10005 绑定属于联机侧;SFT20001、SFT20002、SFT20003 这些 20000 段的全是平台发给你的异步通知。
3.1 网关接口的表单跳转与异步回填
网关接口的实现要点不在服务端,而在于你要生成一个能自动提交的 HTML 表单,把五个字段塞进 hidden input,然后让浏览器提交。注意这里是 FORM 表单提交,不是 ajax,也不是 302 重定向带 query 参数——文档写的是"必须 POST 报文 FORM 表单以页面跳转形式发起请求",用重定向把 Data 拼在 URL 上,长度会超,密文里的特殊字符还要转义,得不偿失。
跳转之后你的服务端就跟用户失联了,结果只能等异步通知。所以网关接口的完整链路是:本地创建订单并落库为"待支付"→ 生成表单跳转 → 用户在平台侧完成支付 → 平台 POST 通知到你的回调地址 → 你验签、更新订单状态、返回成功。这中间任何一步断掉,订单就悬着,必须有定时补偿查询兜底,这一点在第 5 章会具体说。
异步通知有个细节容易忽略:文档说"包括页面通知(带支付通知报文页面跳转第三方指定地址),服务器(POST 通知报文到第三方指定服务器通知地址)"。也就是说同一次支付,你的回调地址可能被访问两次,一次是用户浏览器跳回来,一次是平台服务器 POST 过来。如果你的回调逻辑没做幂等,订单状态会被更新两遍,库存会被扣两次。判断来源的办法看请求头里的 User-Agent 和 Referer,但更稳妥的还是靠 TransID 唯一约束,谁先到谁生效。
3.2 联机接口的 HttpClient POST 与同步返回
联机接口相对简单,但也有两个固定动作:一是 Content-Type 用application/x-www-form-urlencoded,参数放 body 不放 URL;二是超时时间要设够,查询类接口 5 秒够用,退款和对账类给到 15 到 30 秒,因为平台侧可能要跨系统调用。超时设太短,你的系统会认为失败,但平台侧其实已经处理成功,资金状态和订单状态就对不上了。
import java.util.*; import org.apache.http.NameValuePair; import org.apache.http.client.entity.UrlEncodedFormEntity; import org.apache.http.client.methods.*; import org.apache.http.impl.client.*; import org.apache.http.message.BasicNameValuePair; import org.apache.http.util.EntityUtils; public class SftOnlineClient { private final String endpoint; // 联机接口地址,来自文档"相关配置信息"章节 private final SftCodec codec; private final String thirdSysId; public SftOnlineClient(String endpoint, SftCodec codec, String thirdSysId) { this.endpoint = endpoint; this.codec = codec; this.thirdSysId = thirdSysId; } public String call(String txCode, String plainJson) throws Exception { String data = codec.encrypt(plainJson); // 先加密 String auth = codec.sign(thirdSysId, txCode, data); // 再对密文签名 List<NameValuePair> form = new ArrayList<>(); form.add(new BasicNameValuePair("ThirdSysID", thirdSysId)); form.add(new BasicNameValuePair("TxCode", txCode)); form.add(new BasicNameValuePair("Data", data)); form.add(new BasicNameValuePair("Auth", auth)); HttpPost post = new HttpPost(endpoint); post.setEntity(new UrlEncodedFormEntity(form, "UTF-8")); try (CloseableHttpClient client = HttpClients.custom() .setDefaultRequestConfig( org.apache.http.client.config.RequestConfig.custom() .setConnectTimeout(5000) .setSocketTimeout(30000) .build()) .build()) { try (CloseableHttpResponse resp = client.execute(post)) { return EntityUtils.toString(resp.getEntity(), "UTF-8"); } } } }这段的调用顺序就是全部要点:加密、签名、组表单、发请求。setSocketTimeout(30000)给的是读超时,联机接口里对账和退款这类耗时会明显高于查询,统一 5 秒会让你在月底对账时收到一堆假失败。返回体拿到后别急着解析,先看里面有没有 errorCode 字段,有就是平台侧报错,errorMsg 里通常能直接看出是签名问题还是业务参数问题。
3.3 SFT10001 订单支付的必填字段与子订单结构
这个接口字段多,但真正会拦住你的是三个枚举和一个嵌套结构。GoPayType 控制支付形态,1 是 PC 支付、2 是移动支付、3 是小程序支付,传错会导致页面在错误端打开,用户在手机上看到 PC 收银台,转化率直接腰斩。PayType 是支付方式,01 直接支付、00 担保支付,不传默认 01——做 B 端供应链场景时这个字段很关键,担保支付意味着资金先进中间态,等你确认收货或确认付款才真正结算给卖方,对应后面 SFT10023 确认付款接口。OrderIniter 表示订单由买方还是卖方发起,这个字段影响平台侧的审批流走向,传反了订单会卡在对方待确认里。
嵌套结构主要是 OrderInfos 下的 dataset,再往里是 Order_Products 的商品明细。文档写"只支持单笔订单",意味着一次请求只能带一个 Order_No,但商品明细可以多行。Order_Money 是订单金额,字符串类型,两位小数,别用 double 算完直接拼字符串,0.1 + 0.2的经典问题会导致金额变成0.30000000000000004,平台侧校验不过。我的习惯是全程用 BigDecimal,出参统一setScale(2, RoundingMode.HALF_UP).toPlainString()。
还有一个容易漏的字段:BuyerUserName_ThirdSys注明"仅支持英文或者数字"。如果你的系统里买家昵称是中文,直接传会被拒。常见做法是做一次映射,中文名走BuyerTrueName_ThirdSys字段,登录名位置传 ID 或拼音。这类隐性约束文档写在备注里,不逐字读完很容易踩。
3.4 SFT10003 查询与 SFT10009 退款的状态对齐
支付状态和退款状态是两套枚举,必须建立映射表,否则你的订单状态机迟早乱。SFT10003 返回的 PayStatus 有五个值:0 付款中、1 付款成功、2 付款失败、3 订单取消、4 已支付未确认付款。SFT20001 通知里的 PayStatus 只有 1、2、4 三个值,没有 0 和 3。这意味着如果你的系统只依赖异步通知,订单会永远停在"待支付"——因为通知根本不会告诉你"用户放弃了"。
所以查询接口不是可选项,是必需品。我的做法是:支付发起后写入"待支付",同时投递一条延迟 2 分钟的补偿任务,调 SFT10003 查真实状态;如果还是 0,延迟 10 分钟再查一次;两次都是 0 或返回 3,本地置为关闭。查询接口每次最多 10 个订单号,超过直接拒绝,批量补偿时要自己切片。
Clr_Flag传 001 会额外返回清算状态 Clr_Status 和清算时间 Clr_Time,做资金归集或账务系统的团队会用到,普通电商场景不传即可,传了会增加平台侧开销。退款侧 SFT10009 是联机接口,同步返回受理结果,但受理成功不等于退款成功,最终结果还是要靠 SFT20003 通知或者再查一次订单。这三者的时序关系建议画在纸上贴在工位,比看文档快。
4. 对账、预付款与协同接口:把结算闭环补齐
支付和退款只解决了"钱进来、钱出去",真正让财务愿意用这套系统的是对账和协同。这部分接口在文档里排在后面,但业务价值不低,而且坑的形态和前两类完全不同——前两类错在代码,这一类多半错在数据和时间。
4.1 SFT10008 日终对账单的落地解析
日终对账接口按日期查询,返回的是对账单文本文件,不是 JSON 结构体。文档给的信息量不大,只说了"按日期查询返回对账单文本文件",实际落地时你要自己解决四个问题:文件编码、字段分隔符、金额单位、差异比对口径。
编码上,全链路统一 UTF-8,文件大概率也是 UTF-8,但保险起见读的时候显式指定,不要用平台默认。分隔符常见是竖线或者制表符,第一次接入时我建议先落原始文件到本地,人工打开看一眼再写解析器,别凭想象写 split。金额单位要特别注意:报文里的金额都是"元"的字符串带两位小数,但对账文件里有可能给的是"分"的整数,一旦搞错,你的差异表会全红。判断方法很简单,拿一笔已知金额的订单去比对,差 100 倍就是分。
import csv from decimal import Decimal from datetime import datetime def parse_settle_file(path: str): """解析日终对账单,输出订单号 -> 结算金额 的映射""" result = {} with open(path, "r", encoding="utf-8") as f: reader = csv.reader(f, delimiter="|") # 分隔符按实际文件调整 header = next(reader) idx = {name.strip(): i for i, name in enumerate(header)} for row in reader: if not row or row[0].startswith("#"): continue order_no = row[idx["订单号"]].strip() # 金额统一走 Decimal,避免浮点误差 amount = Decimal(row[idx["结算金额"]].strip()) pay_time = datetime.strptime(row[idx["支付时间"]].strip(), "%Y%m%d%H%M%S") result[order_no] = {"amount": amount, "pay_time": pay_time} return result解析函数只是第一步,真正的工作量在比对。我的口径是:以本地订单表为主表,左连对账单结果,分出四类差异——本地有平台无(大概率是 T+1 时间差或者订单号传错)、平台有本地无(漏单,最危险)、金额不一致(分账或手续费没算对)、状态不一致(本地成功平台退款)。四类分别落表,前两类每天人工看一眼,后两类必须进工单。
对账时间点也有讲究。文档写"日终",但具体几点出文件没写,我的经验是次日早上八点后再拉,太早拉可能拿到空文件或者不完整文件。拉取失败要重试三次,间隔递增,三次都失败告警到人,不要让对账任务静默失败——这是血泪经验,静默失败一个月才发现,补数据能补到崩溃。
4.2 预付款三段式的字段对应
预付款是供应链场景的刚需,接口拆成三个:SFT10020 发起预付款支付、SFT10021 按预付款单号查询、SFT10022 按日期查状态。三者对应同一笔业务的不同粒度,字段上要保证预付款单号在三处一致,否则查出来的东西对不上。
发起预付款和普通订单支付的最大区别在于资金性质:预付款是先付后核销,平台侧通常要求收款方账户已经完成实名绑定,所以 SFT10025 实名认证接口往往是前置条件。如果跳过实名直接发预付款,返回的错误信息一般是账户状态异常,看起来像支付失败,实际是资质问题,排查方向完全不同。
按日期查状态(SFT10022)适合做批量核对,一次拿一天的预付款单列表,和本地表做全量比对。这个接口的返回是列表,注意分页或者条数上限,文档里没有明确写上限值,稳妥做法是按天切分,别一次拉一个月。
4.3 协同类接口的绑定关系与单据匹配
协同类接口数量最多:SFT10016 订单协同、SFT10017 上下游绑定解绑确认、SFT10018 退款协同、SFT10019 物流协同、SFT10024 预付款协同。它们的共同点是"多一方参与",不再是买卖双方直接对接,中间可能还有第三方系统。
以 SFT10005 绑定用户关系为例,dataset 里要同时给出卖方和买方在双方系统的四个标识:供应商第三方用户名、供应商第三方唯一 ID、供应商平台用户名;采购商对应的三个字段。这套映射关系一旦建立,后续所有订单的 BuyerUserID_ThirdSys 都能对上号。绑定发起方 BindIniter 传 0 或 1,决定了平台侧推给谁确认。
实际踩的坑在于:绑定关系是双向的,A 绑 B 和 B 绑 A 在数据上是两条记录,如果你的系统用单条记录存,做解绑时会只解一半。我的做法是建一张关系表,主键用(seller_id, buyer_id),同时维护反向索引,解绑时两边一起置为失效,并在日志里记录操作来源。
5. 联调避坑:五类高频翻车现场的现象与定位
这一章是整篇里最值钱的部分。前面四章的代码你照着写基本能跑通,但联调阶段真正耗时间的是那些"看起来像 A 问题实际是 B 问题"的场景。下面五条都是我在类似项目里真实遇到过的,按现象、原因、解决三段写,你对着排就行。
5.1 加解密与签名类问题
第一条:加密结果和文档示例对不上。现象是你用同一段明文、同一个密钥,加密出来的 hex 串和文档给的示例完全不同,但长度一致。原因通常是工作模式或填充方式猜错,ECB 猜成 CBC,或者 PKCS5Padding 写成 NoPadding。解决办法是先用文档给的示例向量做单元测试,把模式、填充、大小写三种组合枚举一遍,能对上就锁定,锁不定就问对接方要一段他们自己的加密实现片段,比反复试快得多。
第二条:签名通过但平台返回"参数错误"。现象是 Auth 校验过了,说明加密和签名链路没问题,但业务字段被拒。原因大概率是字段名大小写或者字段长度超限,比如 BuyerUserName_ThirdSys 传了中文,或者 Remark 超过 100 字符被截断后导致后续字段错位。解决办法是在组装 Data 之前做一次强校验,按文档的字段类型和长度逐项 assert,宁可本地抛异常也不要发出去被平台拒。
第三条:回调验签一直失败。现象是你自己发的请求平台能收,平台发回来的通知你验不过。原因有两个常见项:一是回调里的 Data 是密文,你拿明文去算签名;二是回调参数通过request.getParameter取出来之后,某些容器会对+号做 URL 解码变成空格,而签名时用的是原始密文,两者不一致。解决办法是取原始请求体自己解析,或者对参数做一次反转义再验签,验签前先打印长度和首尾字符,肉眼能看出是不是被改了。
5.2 业务时序与字段类问题
第四条:订单状态永远停在待支付。现象是用户明明付了钱,本地订单还是待支付,异步通知没来。原因是网关接口的通知地址配置错了,或者你的回调接口返回了非预期内容导致平台重试耗尽,又或者用户在平台页面直接关掉了浏览器,压根没触发页面通知,服务器通知也可能因为网络问题丢失。解决办法是把 SFT10003 主动查询做成标配补偿,支付发起后延迟查询两次,同时把回调地址在测试环境用外网可访问的域名验证一遍。这一条我见过太多团队栽在里面,本质是把异步通知当成了可靠消息。
第五条:金额对不上,差几分钱。现象是对账时大量订单金额差 0.01。原因几乎必然是浮点运算,double累加或者float转换。解决办法是整条链路用 BigDecimal,入库用 DECIMAL(18,2),出参统一 HALF_UP 保留两位,禁止在 Java 里出现Double.parseDouble(money)这种写法。顺带提一句,分账场景下Rcvb_Hdcg_Val是每笔固定金额、用竖线分割,所有明细加手续费之和必须小于等于支付金额,这个校验也要在本地做,否则平台侧会整单拒绝。
6. 上线前的自检脚本与幂等落库:把回调重复挡在业务层外
联调最后一周,我一般会停下来不写新功能,专门做两件事:一是把回调的幂等和验签压到最底层,二是写一个能一键跑完的自检脚本。这两件事看起来不产出业务价值,但它们决定了你上线后半夜能不能睡着。
幂等的核心是唯一键,不是状态判断。很多人写回调处理是先查订单状态,如果是待支付就更新为已支付,这个逻辑在并发下会双写,因为两个请求可能同时查到待支付。正确做法是先落一张通知流水表,主键用(trans_id, tx_code),插入成功才继续处理业务,插入冲突直接返回成功。
CREATE TABLE sft_notify_log ( trans_id VARCHAR(30) NOT NULL COMMENT '平台通知流水号', tx_code VARCHAR(8) NOT NULL COMMENT '交易码,如 SFT20001', order_no VARCHAR(30) NOT NULL COMMENT '第三方订单号', pay_status CHAR(2) NOT NULL COMMENT '1成功 2失败 4已支付未确认', total_money DECIMAL(18,2) COMMENT '累计支付金额', raw_data TEXT COMMENT '解密后的原始报文,出事时救命', create_time DATETIME DEFAULT CURRENT_TIMESTAMP, PRIMARY KEY (trans_id, tx_code), KEY idx_order (order_no) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;这张表有两个作用。主键保证同一条通知只被处理一次,重复投递在 insert 阶段就被挡住,业务代码里不需要再判断。raw_data存解密后的完整报文,出事的时候不用去翻应用日志,直接按订单号查这张表就能看到平台到底发了什么,这就是我说的"后悔药"。idx_order是给排查用的,用户投诉时按订单号反查最快。
处理流程我固定成五步:验签 → 解密 → 插流水表(冲突即返回成功)→ 更新订单状态 → 返回成功。注意第三步和第四步之间如果抛异常,流水表已经有了记录,重试会被当成重复而跳过,订单状态就永远不更新。解决办法是把这两步放进同一个本地事务,插流水和改订单要么都成功要么都回滚,回滚后平台重试还能进来。这个细节比幂等本身更容易被忽略。
自检脚本我习惯用 Python 写,几十行,作用是给联调期的每一次手工测试生成正确的签名参数,避免用错密钥或者拼错顺序。
import hashlib from pyDes import des, ECB, PAD_PKCS5 THIRD_SYS_ID = "1004" # 对接系统 ID DES_KEY = b"********" # 测试环境密钥,生产走配置中心 MD5_KEY = "********" def build(tx_code: str, plain: str): cipher = des(DES_KEY, ECB, padmode=PAD_PKCS5) data = cipher.encrypt(plain.encode("utf-8")).hex().upper() # 密文大写 raw = f"{THIRD_SYS_ID}{tx_code}{data}{MD5_KEY}" auth = hashlib.md5(raw.encode("utf-8")).hexdigest() # 签名小写 return {"ThirdSysID": THIRD_SYS_ID, "TxCode": tx_code, "Data": data, "Auth": auth} if __name__ == "__main__": print(build("SFT10003", '{"OrderInfos":[{"Order_No":"T20240101001"}]}'))这段脚本唯一的价值是"和线上代码走不同实现路径"。如果 Java 侧算出来的 Auth 和 Python 侧一致,说明算法本身没问题,剩下的差异只可能在字符集或者参数传递环节。联调期我一般会让前后端各跑一遍,两边结果对上再发请求,能省掉大量"到底是谁算错了"的扯皮。
从那以后我每次接这类银行侧支付文档,都强制走一遍固定顺序:先用示例向量验证加解密,再写流水号生成器并压测长度边界,然后把回调幂等表建好,最后才动业务接口。顺序反过来,你会在第三十个接口上被第一个接口的签名问题拖住。希望帮到你。
本文还有配套的精品资源,点击获取