尧图网站设计 尧图网站设计YAOTU DESIGN
ARTICLE DETAIL

资讯详情

深耕网站设计与一线实操的经验洞察。

特约商户进件API设计实战:从接口实现到幂等与签名保障

特约商户进件API设计实战:从接口实现到幂等与签名保障 简介在支付系统开发中特约商户进件是连接商户与收单机构的关键环节本质是将商户资质信息通过标准化接口完成线上化提交与自动审核。API设计需基于RESTful与JSON构建通过参数分组、两层校验和状态机流转来保证业务逻辑清晰可靠。同时接口的幂等性设计是防止重复进件的核心依赖唯一索引与请求号机制可实现高并发下的数据一致性而SHA256withRSA签名方案则确保了敏感信息传输的安全。这类接口广泛应用于开放平台、渠道商管理系统及聚合支付场景技术价值在于将复杂的线下审核流程转化为可扩展的线上服务能力。围绕进件提交、进度查询与异步回调等核心动作结合Spring Boot等工程实践即可搭建一套完整的特约商户进件体系支撑支付业务的高效运转。 做支付系统这一行和“特约商户进件”打交道是躲不掉的。特约商户进件就是让一个商户通过资质审核变成线下POS或线上支付通道里的合法特约商户。以前这活儿靠线下填表、邮寄身份证和营业执照现在主流做法是开放平台提供一套进件API让渠道商或商户自己把资料传上来系统自动审核、实时返回结果。这篇文章拿我最近在做的“特约商户进件API 进件、查询等接口 demo”来拆一拆讲清楚一套可用的进件接口到底怎么设计、怎么落地、有哪些坑。不管你是刚接触支付业务的开发还是准备给公司搭一套渠道商进件系统这篇都能给你一个能直接参考的底子。1. 进件API的业务背景与整体设计思路1.1 特约商户进件的完整业务链路先别急着看代码得先明白进件到底在做什么。特约商户通俗讲就是和收单机构签约、能受理银行卡或扫码支付的商户。进件就是把一个原本“非签约”的商户通过提交资质资料变成系统里录入的、可配置支付产品的正式商户。我见过很多刚入行的同学一听到“进件API”就觉得是简单的“提交表单”。实际上进件链路比表面复杂得多。一条比较完整的进件流程是这样的渠道商也叫服务商/代理商收集商户的营业执照、法人身份证、结算银行卡、门店照片等资料。通过API把资料提交到收单机构或支付平台的进件系统。进件系统先做格式校验、必填项校验再调用内部的风控、工商、银行卡鉴权等能力做自动审核。审核通过后系统会自动生成商户号、配置支付通道、开通结算账户然后通知渠道商。如果资料有问题要么直接驳回要么进入人工补充材料流程渠道商补充后再提交。这个demo主要覆盖第2步到第4步的接口部分也就是“提交申请、查询进度、接收结果通知”。这也是渠道商对接时最关心的三个动作。1.2 接口方案选型为什么用RESTful JSON进件API看起来是“上传资料”但本质上是一个业务系统的开放接口。我在做这个demo的时候技术选型上没有走很重的SOAP或者XML方案而是用了RESTful JSON。原因很简单进件接口的调用方大多是渠道商的技术人员JSON的兼容性最好各语言解析都方便。RESTful风格足够表达“提交”、“查询”这两个动作不需要引入额外中间件。HTTP现有状态码就能表达结果比如200表示成功、400表示参数错误、401表示鉴权失败调用方排查问题直觉很多。接口语义上我尽量用资源加动作的方式POST /api/merchant/apply 提交进件申请。POST /api/merchant/apply/query 查询进件进度或者用GET带参数。实际项目里由于查询条件多且参数可能包含签名信息我统一用POST避免URL过长和参数被网关日志打印。1.3 Demo整体技术栈和模块划分既然是demo我没有上微服务也没有搞一套很复杂的容器化编排。整体就是一个标准的Spring Boot工程加上MySQL、MyBatis-Plus做持久化Redis用来做防重和缓存没有Redis也可以用数据库唯一索引代替。工程模块划分也很清晰controller接收HTTP请求做参数校验。service业务逻辑包括进件提交、查询、回调处理。mapper数据库操作。dto接口请求/响应参数。utils签名、日期、脱敏工具。entity数据库实体。config拦截器、Web配置、异步线程池配置。如果你只是复制这个demo跑起来这个结构已经够用了。等真要上生产再在它外面套网关、鉴权、MQ、文件存储思路是能平滑扩展的。2. 进件与查询接口的核心设计2.1 进件提交接口参数分组与两层校验进件接口最怕的就是“一长串平铺参数”。很多对接方第一次看到字段列表就会崩溃因为一个真实进件申请可能有四五十个字段全放平级根本没法维护。我在demo里把参数分成了几个组baseInfo基础信息商户名称、经营类目、省份城市、门头照URL。legalPersonInfo法人信息姓名、身份证号、身份证正反面照片URL。settleAccountInfo结算信息结算类型对公/对私、银行账号、开户行联行号、开户行名称。qualificationInfo资质信息营业执照号、许可证号、行业资质。这样做的好处是第一对接方可以按业务块组装JSON结构清晰第二校验规则能按块去写哪个块有问题提示得准确第三后续扩展字段也方便不会把所有新字段都堆在根上。参数校验一定要做两层。第一层是基础校验处理非空、枚举值、手机号格式、身份证号格式第二层是业务校验比如“对公结算必须有营业执照号”、“法人身份证姓名必须和营业执照法人一致”。这层demo里用简单的if-else去写生产环境可以引入规则引擎但逻辑本身是一样的。注意进件接口一定不能把后端的内部错误直接抛给调用方。统一用错误码加错误信息返回比如40001表示必填参数缺失40002表示身份证格式错误。调用方根据错误码就能快速定位。2.2 进件申请单号与幂等性防止重复进件做任何支付类接口幂等性都是躲不开的话题。进件接口尤其需要因为渠道商很可能会因为超时重试或者用户重复点击把同一份申请提交两遍。如果接口没有做幂等就会出现同一个商户在系统里生成两笔进件申请后续商户号、结算配置全部重复。我在demo里设计了两个编号outRequestNo渠道商自己的请求号由调用方生成。applyNo平台生成的进件申请单号提交成功后返回给调用方。防重的核心就是在数据库层面对outRequestNo加唯一索引同时进件主表还有一个apply_no字段生成后返回。具体流程是调用方提交outRequestNo。进件系统检查该outRequestNo是否已存在。如果不存在插入一条状态为“处理中”的进件记录。如果已存在且状态不是最终态直接返回之前生成的applyNo不重复创建。如果已存在且最终态是成功返回对应的applyNo并提示“该申请已进件成功”。这里有一个坑如果仅仅“先查后插”并发情况下两个请求同时查到不存在就会同时插入还是会造成重复。所以必须依靠数据库唯一索引来兜底插入时捕获DuplicateKeyException捕获后重新查询返回已有记录。这个点我在后面的问题排查章节还会详细说。2.3 进件进度查询接口状态机设计查询接口比提交接口简单但前提是你得把状态机设计清楚。没有状态机约束进件单可以被随意流转后面回溯问题非常痛苦。demo里我设计了这几个状态CREATED草稿状态表示申请单已创建但未提交。SUBMITTED已提交等待审核。REVIEWING审核中可能调用了外部风控接口或者进入人工审核队列。REJECTED已驳回驳回原因会写入驳回记录。SUCCESS进件成功已生成商户号。状态流转规则CREATED - SUBMITTED提交进件。SUBMITTED - REVIEWING系统开始审核。REVIEWING - SUCCESS自动审核通过并开通。REVIEWING - REJECTED审核不通过。REJECTED - SUBMITTED允许渠道商修改资料后重新提交。这些流转在Service层用枚举做限制不合法流转直接抛异常。查询接口我提供了两个维度按applyNo查询单个申请详情。按渠道商、时间范围分页查询申请列表。详情返回里除了商户信息和状态还带一个operationList记录每一步操作比如“提交申请”、“风控审核通过”、“驳回原因”。这样渠道商看到驳回时能直接知道缺什么资料。2.4 列表查询与详情查询的字段脱敏做接口时我习惯把详情和列表使用的返回对象拆开不要一个对象用到黑。列表查询只返回申请单号、商户名称、状态、提交时间、创建时间这些摘要信息详情查询才返回完整资料。这里必须考虑脱敏。进件接口里的身份证号、银行卡号、手机号都是敏感信息不能在日志里完整打印也不能在接口响应里泄漏给无关人员。demo里我写了一个脱敏工具身份证号和银行卡号保留前三位后四位中间用星号替代。渠道商真正需要回显的时候再走单独的解密接口。返回内容上统一包装成resultCode、resultMsg、data的结构。我用的响应格式是{ success: true, code: 0000, message: 成功, data: { applyNo: AP20260612001, merchantNo: M10000001, status: SUCCESS } }code用字符串而不是数字便于扩展类似“A0001”这种业务码。所有接口都用同一套包装调用方解析逻辑就一套省心很多。3. 实操过程把Demo跑起来的完整步骤3.1 环境准备与项目初始化先交代一下这个demo运行需要的东西JDK 8以上我用的JDK 8Spring Boot 2.7。Maven 3.6以上。MySQL 5.7以上本地装一个就好。Redis可选没装就把demo里的缓存逻辑关掉用数据库唯一索引防重。用Spring Initializr生成一个基础工程groupId填com.exampleartifactId填merchant-apply-demo。依赖选Spring WebMyBatis FrameworkMySQL DriverLombokValidation由于MyBatis-Plus不在Initializr默认列表里需要手动在pom.xml加依赖。dependency groupIdcom.baomidou/groupId artifactIdmybatis-plus-boot-starter/artifactId version3.5.3/version /dependency加完后在application.yml里配置数据源、MyBatis-Plus日志以及Jackson的时间格式。server: port: 8080 spring: datasource: url: jdbc:mysql://localhost:3306/merchant_apply?useUnicodetruecharacterEncodingutf8serverTimezoneAsia/Shanghai username: root password: root driver-class-name: com.mysql.cj.jdbc.Driver mybatis-plus: configuration: map-underscore-to-camel-case: true log-impl: org.apache.ibatis.logging.stdout.StdOutImpl这样基础环境就准备好了。3.2 数据库表结构设计进件系统的核心表我通常分为三张。第一张是进件主表存业务数据。CREATE TABLE merchant_apply ( id BIGINT PRIMARY KEY AUTO_INCREMENT, apply_no VARCHAR(32) NOT NULL COMMENT 进件申请单号, out_request_no VARCHAR(64) NOT NULL COMMENT 渠道方请求号, channel_code VARCHAR(32) NOT NULL COMMENT 渠道商编码, merchant_name VARCHAR(128) NOT NULL COMMENT 商户名称, legal_person_name VARCHAR(32) NOT NULL COMMENT 法人姓名, legal_person_id_card VARCHAR(32) NOT NULL COMMENT 法人身份证号, settle_type VARCHAR(8) NOT NULL COMMENT 结算类型: PUBLIC/PRIVATE, settle_account_no VARCHAR(32) NOT NULL COMMENT 结算账号, settle_bank_name VARCHAR(64) COMMENT 开户行名称, status VARCHAR(16) NOT NULL COMMENT 进件状态, reject_reason VARCHAR(512) COMMENT 驳回原因, merchant_no VARCHAR(32) COMMENT 开通后的商户号, create_time DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, update_time DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, UNIQUE KEY uk_out_request_no (channel_code, out_request_no), UNIQUE KEY uk_apply_no (apply_no), KEY idx_status (status), KEY idx_create_time (create_time) ) ENGINEInnoDB DEFAULT CHARSETutf8mb4 COMMENT特约商户进件申请表;第二张是文件表存上传的资质文件URL。CREATE TABLE merchant_apply_file ( id BIGINT PRIMARY KEY AUTO_INCREMENT, apply_no VARCHAR(32) NOT NULL, file_type VARCHAR(32) NOT NULL COMMENT 文件类型: LICENSE/ID_CARD_FRONT/ID_CARD_BACK/STORE_PHOTO, file_url VARCHAR(512) NOT NULL, create_time DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, KEY idx_apply_no (apply_no) ) ENGINEInnoDB DEFAULT CHARSETutf8mb4 COMMENT进件资料文件表;第三张是操作流水表记录每次状态变更。CREATE TABLE merchant_apply_log ( id BIGINT PRIMARY KEY AUTO_INCREMENT, apply_no VARCHAR(32) NOT NULL, action VARCHAR(32) NOT NULL COMMENT 操作类型, from_status VARCHAR(16), to_status VARCHAR(16), remark VARCHAR(512), create_time DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, KEY idx_apply_no (apply_no) ) ENGINEInnoDB DEFAULT CHARSETutf8mb4 COMMENT进件操作流水表;建表的时候我故意把out_request_no的唯一索引和channel_code一起建立因为实际业务里不同渠道商可以使用相同的outRequestNo只有同一个渠道商内部的请求号才能作为幂等依据。这个细节一开始很容易忽略等联调的时候发现不同渠道商相互顶替就麻烦大了。3.3 进件提交接口实现要点进件提交入口我写在MerchantApplyController里路径是/api/merchant/apply。请求进来后先做签名校验然后做参数校验再进Service层。Controller里的代码大致是这样PostMapping(/api/merchant/apply) public ApiResponseApplyResponse apply(RequestBody Valid ApplyRequest request) { // 签名校验在拦截器里做这里只关注业务 ApplyResponse response merchantApplyService.submitApply(request); return ApiResponse.success(response); }Service层是核心。里面我会做几件事将DTO参数转换成实体对象。校验渠道商是否有权限、请求号是否重复。生成applyNo格式类似AP yyyyMMdd 6位随机数。插入进件申请表状态为CREATED或SUBMITTED这里我demo里直接SUBMITTED。保存文件表。插入操作流水。发送异步消息触发风控审核。需要特别注意的是进件提交一定不要做成“同步审核完再返回”。真实业务中风控、工商校验可能耗时几秒甚至几十秒调用方等不起。所以提交接口只需要保证“请求已受理”审核结果通过查询或回调来通知。我在demo里提交成功后立即返回applyNo后台用一个线程池模拟异步审核。线程池配置固定大小避免每个请求都new线程。Bean(auditExecutor) public Executor auditExecutor() { ThreadPoolTaskExecutor executor new ThreadPoolTaskExecutor(); executor.setCorePoolSize(4); executor.setMaxPoolSize(8); executor.setQueueCapacity(1000); executor.setThreadNamePrefix(audit-thread-); executor.initialize(); return executor; }异步审核方法里先睡几秒模拟耗时然后把状态改成REVIEWING再随机或者按规则设置为SUCCESS或REJECTED。demo里模拟成“商户名称包含测试就驳回”这样方便演示回调。3.4 签名与鉴权保证接口安全进件接口涉及商户敏感资料必须做签名。demo里我采用SHA256withRSA渠道商用自己的私钥签名平台用渠道商的公钥验签。签名串生成的规则将请求参数按照key的ASCII码从小到大排序。拼接成key1value1key2value2的形式去掉空值。拼接一个appId字段一起参与签名。对拼接结果用SHA256withRSA进行签名Base64编码。验签工具类核心代码public static boolean verify(String publicKey, String content, String sign) { try { Signature signature Signature.getInstance(SHA256withRSA); signature.initVerify(getPublicKey(publicKey)); signature.update(content.getBytes(StandardCharsets.UTF_8)); return signature.verify(Base64.getDecoder().decode(sign)); } catch (Exception e) { log.error(验签失败, e); return false; } }签名串排序的时候我踩过一个坑如果直接对DTO序列化后的JSON排序字段顺序不可控而且嵌套对象会很麻烦。所以我统一采用“扁平化参数拼接”也就是说把所有参与签名的参数拆成简单的key-value键值对不嵌套。比如baseInfo.merchantName作为key值就拼上。这样对端用Map收集参数排序就非常方便。接口鉴权上demo里采用appId sign的方式。请求头传AppId、Timestamp、Sign三个值。平台根据AppId找到对应的公钥校验Timestamp不能超过5分钟防止重放攻击。拦截器里做完验签后可以把渠道商信息放到ThreadLocal里Service直接取省得每个方法都传一遍。3.5 用Postman验证Demo接口跑起来之后我用Postman做了几组验证。第一组是正常进件。提交一个商户名称为“测试便利店”的请求返回successtrueapplyNo生成成功。过几秒再查详情状态变成SUCCESS说明模拟审核通过。第二组是重复提交。把同一个outRequestNo再提交一次接口不会插入新数据而是直接返回第一次生成的applyNo。第三组是签名错误。手动把sign改成一个随机字符串接口返回“签名验证失败”HTTP状态码保持200业务码是4001。第四组是参数缺失。把法人姓名去掉返回“法人姓名不能为空”这样的错误码。这四组基本覆盖了进件接口最常见的调用场景。自己在本地调的时候建议把MyBatis的SQL日志打开能直观看到插入和查询语句方便排查问题。4. 常见问题与排查技巧实录4.1 HTTP 400还是业务错误统一处理才好排查很多刚开始做开放接口的同学会把参数校验失败直接返回HTTP 400这本身没错但有一个问题很多渠道商的HTTP客户端库会把非200响应直接抛异常导致他们只看得到“HTTP 400”看不到响应体里的具体错误码排障效率极低。所以我在demo里所有业务校验失败都返回HTTP 200但业务码标识失败。只有网关层面的鉴权失败、请求体无法解析这种场景才返回400/401。这样做的好处是渠道商通过业务码就能继续解析而网络层错误则交给基础设施处理。如果你确实希望参数校验走HTTP 400那必须在响应体里带上标准错误结构并且让渠道商读到body。但真实对接下来我发现200业务码的方式对两端都省心。4.2 重复进件问题唯一索引冲突的准确处理我在本地测试时模拟过并发重复提交场景。用JMeter压两个线程同时提交同一个outRequestNo结果有概率插入两条记录。原因就是我前面说的代码里先查后插但没有处理并发窗口。解决办法是必须依赖数据库唯一索引兜底。插入的时候会抛DuplicateKeyException捕获后重新查询已有记录返回给调用方。这里有一个细节捕获异常时一定要判断是不是唯一索引冲突不要把别的数据库异常也吞了。MyBatis-Plus的异常需要拿到根cause判断。伪代码逻辑try { merchantApplyMapper.insert(apply); } catch (DuplicateKeyException e) { Apply exist merchantApplyMapper.selectByOutRequestNo(channelCode, outRequestNo); return exist; }这个处理能保证并发下只生成一条记录另外那条请求拿到的是同一张申请单。4.3 回调通知丢失怎么办真实生产环境渠道商不一定能保证接收回调成功回调接口也可能因为网络波动而超时。如果只靠回调进件状态就会丢。所以我在demo里设计了一套“主动查询兜底”的机制。具体做法是进件状态变更时记录一条回调消息到callback_message表。立刻发起一次回调如果渠道商返回successtrue标记回调成功。如果回调失败启动定时任务每隔1分钟扫描未成功的回调消息最多重试5次。渠道商还可以主动调用查询接口以查询结果为准。这里我特别提醒回调的内容不多但必须带applyNo和status。渠道商收到回调后不要只处理成功状态还要处理驳回状态。我在demo的回调消息体里带了rejectReason这样渠道商可以直接提示用户。4.4 查询接口SQL报错和空指针查询接口看起来简单但容易在两个地方出问题一个是时间范围参数一个是状态枚举转换。如果你把时间范围作为字符串传入SQL一定要统一格式比如yyyy-MM-dd HH:mm:ss否则MySQL比较会出问题。MyBatis里我建议用DateTimeFormat注解把字符串转成LocalDateTime避免手拼SQL。空指针则常用在状态枚举的解析上。如果渠道商传了一个不存在的状态枚举valueOf会抛IllegalArgumentException。我统一写了EnumUtils.parse解析不到时返回null再在业务里给出“状态参数不合法”的错误而不是让异常直接冒出去。4.5 签名不一致的排查思路签名不一致是联调阶段最高频的问题。我的排查步骤一般是打开平台日志查看收到请求时的原始参数和签名串。让渠道商把他们的签名串打印出来。两边逐字符比对很容易发现是排序不一致、编码不对UTF-8被转成了ISO-8859-1、或者参与签名的字段多了一个或少了可能为空的参数。另外绝对不要用JSON序列化结果直接做签名串。因为JSON字段顺序在不同语言里不可控同一个对象Java和PHP序列化出来顺序可能不同。我坚持用排序后的扁平化keyvalue串就是为了避免这种跨语言问题。5. Demo扩展与生产落地建议5.1 从Demo到生产还差哪些环节demo能跑通不代表能上线。我在实际项目中从demo到生产一般还要补这些东西文件上传服务进件资料里的图片和证件照不能直接传业务接口要单独走文件上传接口拿回fileUrl再提交。文件本身建议存OSS或云存储加CDN加速。非对称密钥管理渠道商的公钥要存数据库支持密钥轮换不能写死在代码里。审核后台除了自动审核总要有人工审核界面方便运营查看进件资料、驳回并填写原因。消息队列如果进件量大了异步审核的线程池会不够用这时候把申请提交到MQ由独立服务消费。接口限流进件接口一定要做调用方维度的限流防止渠道商并发太高把系统打挂。数据库分表进件表数据量大了以后按channel_code或按时间分表查询性能才能保证。这些都是生产必备但在demo里没必要全实现先把业务逻辑跑通再逐项加。5.2 进件API的监控与对账进件API上线后日常监控要盯几个核心指标提交成功量、审核通过率、驳回率、平均审核时长、回调成功率、查询接口耗时。我习惯在关键Service方法上做埋点把耗时和结果打到日志和监控系统里。如果某个渠道商的驳回率突然飙升先排查是不是他们传参有bug或者风控规则有变化。还有一个很多人容易忽略的对账点进件成功后生成的商户号必须和支付系统的商户表对齐。否则会出现申请单里显示成功但支付系统里查不到商户号的情况。我在demo里用一个本地事务保证主表和商户表都插入成功如果商户表插入失败进件状态要回滚到创建状态并记录错误日志。5.3 做进件API这段实践我最想提醒你的几件事最后说点个人的实在体会。进件API看着只是几个接口真正难的是把业务的不确定性转成接口可以表达的逻辑。比如同一个商户可能被驳回三次每次修改的资料不一样比如渠道商自己的商户系统可能和你的系统状态不同步再比如法人身份证和营业执照的名字中间有错别字到底算自动驳回还是要走人工。这些业务规则没理清楚接口写得再漂亮生产环境一样会出乱子。所以如果你要基于这个demo改造我强烈建议第一步先拉上业务方把进件状态机确认好然后写一个业务规则矩阵什么情况进件成功、什么情况驳回、什么情况转人工。有了这个矩阵代码只是翻译业务逻辑后面迭代才会越来越顺。这个demo里有一个小小的设计我觉得是值得保留的所有状态变更都记流水。哪怕是demo我也在apply_log表里记录每一步。线上出问题的时候查流水比查什么都管用。即使你公司短期内没有严格的审计要求也建议把这一步养成习惯。本文还有配套的精品资源点击获取
返回列表