十年匠心定制 · 商业建站与技术教学双线并行 咨询热线:400-886-1026 service@lmnt.cn
ARTICLE DETAIL

资讯详情

深耕网站建设与运营推广的一线实战洞察。

特约商户进件API设计实战:签名、幂等与回调机制详解

特约商户进件API设计实战:签名、幂等与回调机制详解 简介在支付与收单系统对接中API接口设计的安全性与可靠性直接影响联调效率与生产稳定性。特约商户进件作为商户准入的核心流程涉及资料提交、资质审核、状态同步等多个环节其接口设计不仅需要统一的通信协议与数据格式更必须涵盖请求签名、幂等控制、异步通知等关键机制。签名机制通过AppId与AppSecret校验请求合法性防止数据篡改幂等设计基于唯一业务单号与联合索引避免重复进件异步回调则解决审核结果主动推送问题降低轮询压力。文章结合实际工程实践从接口规划、安全认证、核心实现到状态流转与问题排查系统梳理特约商户进件API的完整设计思路为涉及支付系统对接或收单平台研发的团队提供可落地的参考方案。 做特约商户进件接口联调这件事其实比很多人想象中要繁琐得多。表面上看就是“传几个参数、查一下结果”但真正落地时签名、幂等、状态流转、异步回调这些问题一个都不能少。我之前因为合作方要接入我们的商户进件系统专门写了一套进件、查询等接口的 demo既是给对方做技术预演也用来内部联调。这篇就结合这个 demo把特约商户进件 API 从设计到实现再到排查的完整链路拆开讲一遍。先说明这个 demo 解决了什么问题特约商户进件说白了就是收单机构把一家线下或线上商户“收编”进系统的过程过去是销售拿纸质表、盖公章、拍照上传现在全部通过接口提交。我们需要提供一个标准化的 HTTP 接口让渠道方把自己发展的商户资料批量提交进来同时提供查询接口让对方随时掌握审核进度。这个 demo 就是围绕这个场景做的最小可用实现适合刚接触支付/收单系统对接的研发同学也适合准备做进件系统但还没想清楚接口怎么设计的团队参考。1. 项目背景与整体设计思路1.1 特约商户进件到底在解决什么问题特约商户在支付行业里指的是通过收单机构审核、可以受理银行卡或扫码支付的合法商户。进件就是“把商户信息录入收单系统”的过程。传统方式里渠道商需要把商户的营业执照、法人身份证、结算银行卡、门店照片等材料打包发给收单机构运营人员运营人员人工录入系统、人工审核周期长不说还容易出错。API 进件把这件事做成了自助化渠道商在自己的系统里收集齐资料调用我们提供的进件接口一次性提交系统自动做格式校验和初步风控再转人工或自动审核。整个进度通过查询接口实时可见。这中间的核心矛盾不是“接口写出来”而是怎么保证资料不错不乱、状态一致、不会重复提交。我在设计 demo 时给自己定了几个目标接口风格统一、签名机制完整、幂等可靠、状态可查、联调方便。很多团队写 demo 就写一个 POST 接口能通就完事但这种 demo 拿到联调阶段根本不够用。真正接进来的时候合作方要看的是一整套“玩法”包括怎么签名、怎么处理失败重试、怎么接收审核结果。1.2 demo 需要覆盖的接口清单这个 demo 里我实现了三类接口基本覆盖了进件业务的主要交互场景进件申请接口渠道方提交商户资料获取受理单号进件结果查询接口根据渠道方自己的单号或平台受理单号查询审核状态和详情异步回调通知审核完成后平台主动通知渠道方审核结果。除了这三个还有一些辅助能力比如查询费率模板、查询银行支行信息等但核心是上面三个。把这三类接口设计好整个进件闭环就通了。为什么查询接口要单独做因为进件审核不是同步的提交之后要经过系统核验、人工审核可能几十分钟甚至一两天。调用方不可能一直阻塞在进件接口里等结果所以必须提供查询能力和异步通知能力。1.3 技术选型为什么是 Spring Bootdemo 用的是 Spring Boot MyBatis-Plus Hutool这是目前做接口类项目很常见的组合。Spring Boot 的理由不用多说内嵌 Tomcat、自动配置、starter 生态成熟一个 main 方法就能把整套服务跑起来。MyBatis-Plus 是为了少写 SQL进件相关的表结构就那么几张用 MyBatis-Plus 的 BaseMapper 足够应付。Hutool 主要用来做签名和 HTTP 调用里面内置了 MD5、SHA-256、SortedMap 排序等工具能省不少样板代码。为什么不用更重的微服务框架因为 demo 的定位是“降低接入门槛”合作方拿到代码要能快速看懂、跑起来、照着改一旦拆成 N 个微服务光链路追踪就能把人绕晕。单体应用在这个场景下是最优解。2. 接口协议与安全设计2.1 认证机制AppId AppSecret 签名方案特约商户进件接口涉及商户的营业执照、身份证号、银行账号这些敏感信息不能像普通 demo 那样裸奔。我采用了支付行业非常常见的 AppId AppSecret 签名方案。具体规则是平台为每个合作渠道方分配一对 appId 和 appSecret。调用方请求时把所有业务参数、时间戳、随机数 nonce 放入一个 Map剔除空值和 sign 本身按 key 的字典序升序排列拼成key1value1key2value2keyappSecret的形式做 MD5 后转大写作为 sign 参数放在请求里。这个方案的优点是简单、成熟、跨语言容易实现。调用方不管用 Java、PHP 还是 Python 都能很快写出来。为什么选用 MD5 而不是 SHA-256其实都可以我选 MD5 纯粹是为了让签名串短一点联调时肉眼排查方便。真要上生产建议至少用 SHA-256 或国密 SM3安全强度更高。核心签名代码大致是这样public class SignUtil { /** * 生成签名 * * param params 业务参数不含 sign * param appSecret 密钥 */ public static String sign(MapString, String params, String appSecret) { SortedMapString, String sortedMap new TreeMap(params); StringBuilder sb new StringBuilder(); for (Map.EntryString, String entry : sortedMap.entrySet()) { String value entry.getValue(); if (StrUtil.isBlank(value) || sign.equals(entry.getKey())) { continue; } sb.append(entry.getKey()).append().append(value).append(); } sb.append(key).append(appSecret); return DigestUtils.md5Hex(sb.toString()).toUpperCase(); } /** * 验签 */ public static boolean verify(MapString, String params, String appSecret) { String sign params.get(sign); if (StrUtil.isBlank(sign)) { return false; } String calcSign sign(params, appSecret); return calcSign.equals(sign); } }时间戳和 nonce 的作用是防重放。时间戳超过 5 分钟直接拒绝nonce 在 Redis 里缓存 5 分钟同样的 nonce 第二次出现就拒绝。这个 demo 里我简化了 nonce 的存储但真实联调时一定要加上。有个合作方一开始没带 nonce导致同样的请求被重复执行了好几次还好进件接口有幂等兜底才没出大问题。2.2 为什么必须设计幂等机制进件接口是最典型的“需要幂等”的场景。调用方发请求网络超时了不知道服务端到底收到没有于是重试。如果服务端没有幂等机制同一份商户资料可能被创建出多笔进件单运营审核时一脸懵怎么同样的营业执照有两笔单子解决思路是让调用方在请求里带上自己的业务单号merchantOrderNo这个单号由渠道方生成全局唯一。服务端在创建进件单时先查这个单号是否已存在如果存在直接返回已有的受理结果不重复创建。同时数据库里对merchant_order_no字段加唯一索引双保险防并发插入。Transactional(rollbackFor Exception.class) public ApplyResult submit(ApplyRequest request) { // 1. 幂等校验同一渠道 同一商户单号直接返回历史记录 MerchantApply existing applyMapper.selectOne(new LambdaQueryWrapperMerchantApply() .eq(MerchantApply::getChannelCode, request.getChannelCode()) .eq(MerchantApply::getMerchantOrderNo, request.getMerchantOrderNo())); if (existing ! null) { return ApplyResult.of(existing); } // 2. 创建进件单 // 3. 落库唯一索引兜底 // 4. 返回受理单号 }这里有个细节幂等检查的结果要分情况处理。如果已存在的记录是“审核中”或“已通过”直接返回成功没问题。但如果已存在的记录是“已驳回”这时候渠道方重新提交同一单号应该允许覆盖原记录重新走审核而不是直接返回旧的驳回结果。这个逻辑很多初版实现会漏掉导致渠道方修正资料后无法重新提交只能换单号数据越攒越乱。2.3 统一响应体别小看这个设计接口返回结构如果不统一联调阶段会非常痛苦。我见过合作方接口一会儿返回{ code: 200 }一会儿返回{ resultCode: SUCCESS }解析代码写得像缝补丁。这个 demo 从一开始就统一了响应体{ code: 000000, message: success, data: { } }code是字符串类型000000表示成功其他为业务错误码message给人看的提示信息data放业务数据。为什么 code 用字符串而不是整数因为业务错误码往往是分层设计的比如A1001表示参数错误、B2003表示商户已存在用字符串可以在码值里带上业务含义扩展性更好。HTTP 状态码只分两类200 表示请求成功处理其余表示系统异常。这样调用方处理逻辑就简单了先看 HTTP 状态码再看业务 code。3. 核心接口实现解析3.1 进件申请接口的完整实现进件接口是整套系统的入口参数最多、校验最复杂。它接收的是商户的完整资料包括基本信息商户名称、经营类型、门店名称、联系人信息资质信息统一社会信用代码、营业执照图片 URL、法人身份证结算信息结算类型对公/对私、开户行、银行账号经营信息经营类目、费率模板编码、门店地址。这些参数我全部用 DTO 封装并加了 JSR 303 参数校验注解比如NotBlank、Pattern等。在实际联调中参数校验错误返回必须足够具体不能只说“参数错误”要告诉对方是哪个字段错、期望什么格式。我在错误码上做了字段级提示比如{code: A1001, message: credit_code格式不正确}这样对方不用翻文档也能知道怎么改。核心处理逻辑是验签通过后先做参数校验再做业务校验比如营业执照号是否合法、费率模板是否存在最后落库。落库成功后接口立即返回受理单号applyId给调用方不代表审核通过只代表“我们收到你的资料了”。PostMapping(/v1/merchant/apply) public ResultApplyResp apply(RequestBody ApplyReq req, HttpServletRequest httpReq) { // 从 header 取 appId 和 sign String appId httpReq.getHeader(appId); String sign httpReq.getHeader(sign); String timestamp httpReq.getHeader(timestamp); // 1. 验签 if (!signService.verify(appId, sign, timestamp, req)) { return Result.error(A0002, 签名错误); } // 2. 参数校验由 Valid 完成 // 3. 幂等处理 落库 ApplyResp resp applyService.submit(req, appId); return Result.success(resp); }有一个坑我必须提一下图片 URL 的问题。进件资料里涉及到营业执照照片、法人身份证照片等这些图片是渠道方先上传到文件服务器再把 URL 传到进件接口。很多渠道方传的是自己的内网地址或临时签名 URL有效期只有几分钟审核人员第二天点开一看图片早就过期了。这个问题的解法是在进件接口里做一次 URL 有效性检查或者干脆要求渠道方把图片传到我们指定的文件服务再提交进件。3.2 查询接口状态与详情查询接口是比较容易被低估的部分实际上它是调用方使用频率最高的接口。因为进件审核是异步的渠道方不知道什么时候出结果只能轮询查。查询接口设计的核心是查询维度要灵活、返回信息要完整。这个 demo 支持两种查询入参渠道方单号merchantOrderNo渠道方自己的单号他们最常用这个查平台受理单号applyId进件成功时返回的单号。返回的内容除了审核状态还包括商户号审核通过后分配、驳回原因、审核时间等。如果审核被驳回驳回原因一定要详细最好把驳回原因编码化。比如REJECT_CREDIT_CODE表示统一社会信用代码有误REJECT_BANK_ACCOUNT_MISMATCH表示结算账户名称与营业执照主体不一致。原因编码化后渠道方可以在自己系统里做映射展示而不是把一段运营人员写的备注直接抛给商户看。GetMapping(/v1/merchant/query) public ResultQueryResp query(RequestParam(required false) String merchantOrderNo, RequestParam(required false) String applyId, HttpServletRequest httpReq) { // 验签逻辑省略 MerchantApply apply applyService.query(merchantOrderNo, applyId); if (apply null) { return Result.error(B1004, 进件单不存在); } return Result.success(QueryResp.of(apply)); }调用方在联调时容易犯的一个错误是进件提交后立刻轮询查询一秒查一次查不到就认为是系统故障。实际上进件从提交到入库有一定延迟建议轮询间隔不要小于 30 秒。我们也会在文档里明确写进件接口返回成功不代表马上能在查询接口查到记录建议 10 秒后再查。3.3 异步回调别让调用方一直轮询虽然有查询接口但如果让渠道方一直轮询对双方服务器压力都不小。所以进件审核状态变化后平台会主动回调渠道方把审核结果推过去。回调通知的地址由渠道方在接入配置时提供我们要求必须是 HTTPS 地址。回调请求里包含商户单号、审核状态、驳回原因、商户号等关键信息并且也要签名防止回调 URL 暴露后被伪造。回调处理的关键是“接收方确认”机制。我们每回调一次都要求渠道方返回固定格式的应答{ code: 000000 }收到这个应答后我们认为回调成功如果应答码不是000000或者超过指定时间比如 10 秒没有响应会按 1 分钟、5 分钟、30 分钟、2 小时、6 小时的间隔递增重试最多重试 5 次。重试 N 次后仍失败这条通知进入人工处理队列由运营人员电话联系渠道方确认。有一个我实际遇到过的坑合作方回调处理逻辑没有做幂等我们重试的第二次对方就把商户状态改了两次导致后续账务出现问题。回调场景的幂等和进件场景一样重要渠道方处理回调时一定要先判断这条通知是否已经处理过。PostMapping(/notify/merchant/result) public ResultVoid handleNotify(RequestBody NotifyReq req) { // 1. 验签 if (!signService.verifyNotify(req)) { return Result.error(A0002, 签名错误); } // 2. 幂等根据 notifyId 判断是否已处理 if (notifyLogService.isProcessed(req.getNotifyId())) { return Result.success(); } // 3. 更新业务状态 merchantService.updateStatusByNotify(req); // 4. 记录处理日志 notifyLogService.markProcessed(req.getNotifyId()); return Result.success(); }4. 数据库设计与状态流转4.1 三张表搞定进件数据demo 的数据库设计我控制在三张表进件申请表、商户主档表、通知记录表。进件申请表是核心存储每次进件的完整资料快照。字段包括主键 id、渠道商编码 channel_code、渠道方单号 merchant_order_no、平台受理单号 apply_id、商户名称、统一社会信用代码、法人姓名、结算信息、状态 status、驳回原因 reject_reason、创建时间等。商户主档表是审核通过后生成的正式商户档案。为什么单独拆一张表因为一个商户可能被多次进件。比如首次进件被驳回后修正资料重新提交就有两条进件记录但最终生成的商户档案只有一条。如果商户信息都在进件表里后续查询商户、修改资料都会很混乱。拆开之后进件表管“过程”商户表管“结果”。通知记录表用来记录每次回调通知的发送情况包括通知 ID、进件单号、回调地址、请求报文、响应报文、重试次数、状态等。这张表在排查“回调丢失”问题时特别重要属于联调阶段的救命稻草。关键索引不要省ALTER TABLE merchant_apply ADD UNIQUE KEY uk_channel_order (channel_code, merchant_order_no); ALTER TABLE merchant_apply ADD KEY idx_apply_id (apply_id);联合唯一索引uk_channel_order就是幂等的那道数据库兜底防线。配合代码里的先查后插能确保极端并发下也不会出现重复进件单。4.2 状态机与审核流转进件单的状态流转是这个系统里最容易理解错的地方。我用几个枚举常量来管理CREATED已受理资料已入库等待审核AUDITING审核中系统初审通过进入人工审核或风控校验APPROVED审核通过商户档案已生成可进行交易结算REJECTED审核驳回附驳回原因渠道方可修改后重新提交。状态流转是单向的CREATED只能走到AUDITINGAUDITING可以到APPROVED或REJECTED。允许从REJECTED回到CREATED吗我采用的是“重新提交”而不是“状态回溯”——渠道方发现自己单号存在且状态是REJECTED时再次调用进件接口传同样的单号服务端会将老记录置为失效创建一条全新的进件单但保留旧记录供审计查询。这样设计的好处是每一次提交都有完整的生命周期记录出了问题可以追溯“第一次提交了什么资料、为什么被驳回、第二次修改了什么”。如果用状态回溯历史记录会被覆盖审计会非常麻烦。在支付行业审计可不是小事。5. 联调过程与常见问题排查5.1 签名不匹配的排查清单签名问题在联调阶段占比最高我总结了一个排查清单基本能覆盖 90% 的情况排序规则是否严格按 key 字典序升序排列TreeMap默认升序没问题但用HashMap遍历拼串就会乱空值处理签名时是否剔除了空字符串和 null客户端和服务端必须保持一致中文编码MD5 之前字符串拼接用的是 UTF-8 还是 GBK两端不一致签名必挂请求头拼接appId、timestamp 是否参与了签名如果参与顺序必须固定对照日志服务端把计算出来的签名串打印出来让对方一比立刻定位。我在 demo 里专门加了一个 logback 配置把所有进件请求的签名串以signContent[xxx]的格式打印出来。联调时让合作方把他们的签名串发过来两边一对比问题当场就清楚了。5.2 重复进件与幂等键冲突联调中常见的一个现象是合作方调一次进件查出来有三条记录。排查后发现他们的重试框架在超时后会自动重试三次但每次重试生成的merchantOrderNo都不一样导致幂等机制完全没生效。这种情况通常不是我们服务端的问题而是调用方的用法问题。我会在联调文档里重点强调merchantOrderNo必须在业务层面生成比如基于商户唯一标识统一社会信用代码、手机号等拼接业务类型不能每次请求都随机生成。否则重试没意义反而制造垃圾数据。服务端的唯一索引也能兜底同一渠道下merchantOrderNo重复时直接抛异常返回“重复进件单号”而不是静默创建两条。这个兜底是确认幂等机制是否生效的最后防线。5.3 回调收不到或回调处理失败回调问题也是重灾区。比较典型的现象是我们日志显示回调已发送但合作方说没收到。排查思路通常分三步先查回调地址配没配对。很多合作方在测试环境填的是localhost或内网地址我们的服务器自然访问不到。这种情况最简单的解法是用内网穿透工具把本地服务暴露出去或者干脆部署到测试环境服务器上再查回调请求是不是被网关拦了。有些合作方的网关对回调请求做了鉴权导致我们发出的回调被 401 拒绝。这个要协调他们给回调 URL 加白名单最后确认回调的签名验证逻辑。合作方收到回调后验签失败会直接丢弃请求但他们自己的日志里没有记录看起来就像“没收到”。另外我会建议合作方在回调处理和响应上下点功夫不管处理成功还是失败都要先返回应答把耗时操作放到异步线程里做。否则回调接口处理时间太长我们这边会判定超时触发重试加重双方服务器负担。5.4 常见问题速查表联调阶段的问题往往高度重复我整理了一张速查表放在文档里方便合作方自测时快速定位现象可能原因排查方向返回签名错误参数排序或编码不一致对比两边签名串内容返回商户单号已存在同一单号重复提交确认是否业务上应重新提交提交成功但查询不到数据未同步或查询维度错误确认查询参数、等待 10 秒以上回调没收到回调地址错误、被网关拦截检查回调地址配置查看我们日志回调验签失败签名规则不一致对比回调签名串和验签规则进件状态一直是审核中人工审核未完成或卡单检查审核任务队列附件 URL 打不开临时链接过期换传文件服务获取长期 URL这张表不是写在文档里就算完我会在联调启动会上带着合作方过一遍特别是签名和幂等这两块提前讲清楚能省掉大量反复沟通的精力。6. demo 之外上生产前还要补哪些课demo 做完能跑通只能说完成了 30% 的工作。真正上线前有几个问题我建议你提前想清楚都是我在实际项目中踩过或看别人踩过的。日志脱敏必须做。进件接口里包含了身份证号、手机号、银行卡号这些敏感字段如果日志直接打印完整信息一旦日志文件泄露或交给第三方排查就是合规事故。我现在的做法是统一在日志输出时对敏感字段做脱敏处理保留前 3 后 4中间打码。这不是技术问题是安全底线问题。限流和并发控制不能省。进件接口是写操作而且是涉及比较重的业务校验的写操作如果不做限流对方一个 for 循环把几万条假数据打进来系统瞬间就被拖垮。我用的是简单的令牌桶限流控制单渠道每秒最多 5 个请求。这个配置按渠道可调不同渠道商技术能力和业务体量不一样要区别对待。接口文档的维护要认真对待。合作方对接时不一定有空看代码文档就是他们唯一的参考。我见过太多接口文档和实际实现不一致的情况参数大小写都对不上合作方照着文档调了一天最后发现是文档写错了。这个 demo 里我坚持用 OpenAPI 3.0 注解生成文档保证文档和代码同步更新避免“文档是文档、代码是代码”的两张皮。最后再提醒一个很容易被忽略的细节进件接口的响应里不能把服务端异常堆栈直接返回给调用方。很多初版实现图省事catch 住异常就把e.getMessage()塞进 message 字段返回这既暴露了系统内部结构又让调用方看到一堆看不懂的英文报错。正确的是统一转成业务错误码详细的堆栈只写在服务端日志里调用方永远只看到“系统繁忙请稍后重试”。这套 demo 从设计到联调我大概花了两周时间跑完最大的感触是进件接口看着不起眼但它连接的上下游非常多渠道方系统、运营审核后台、风控系统、核心商户系统都跟它打交道。任何一个环节没想清楚联调阶段都会变成扯皮现场。把签名、幂等、状态流转、回调这些基础问题提前做好联调的时候就能专注于业务本身。这个设计思路不仅适用于特约商户进件凡是涉及外部系统对接的场景参考价值都是通用的。本文还有配套的精品资源点击获取
返回列表