简介:面向Java后台开发者的微信企业转账到零钱代码包,聚焦企业向员工发放工资、奖金或处理退款时,由服务端调用微信支付企业付款接口完成转账的业务场景。包内共2个Java文件,压缩后约3KB,体量轻便,便于直接查阅与移植;内容围绕转账控制器与签名工具展开,涉及商户号、AppID、API密钥等身份配置,请求参数组装、签名生成、HTTPS证书使用、异步回调确认及异常状态处理等关键环节。已有3089人学习,说明该主题在微信小程序与Java后台对接中具有较高关注度。读者可从中梳理企业付款到零钱的最小实现脉络,理解签名与参数校验的落点,并借鉴日志记录、权限控制和测试环境验证思路,为后续接入真实商户环境、排查转账失败原因提供可复用的参考,对于学习Java后台对接微信支付、理解企业付款接口的签名校验与安全通信也有一定参考价值。
1. 佣金结算这笔钱,最后为什么落在 Java 后台的商家转账接口上
做过分销、任务奖励、内容分成这类业务的都遇到过同一个诉求:用户在页面上点一下“提现”,钱得直接进他的零钱,不能让他去填银行卡。早期大家都用企业付款到零钱,后来接口下线,迁移到商家转账到零钱,链路一下子长了——本地落单、服务端签名、微信侧下单、小程序拉起确认收款、异步回调、对账补偿,任何一环断掉都会变成客服工单。
这份资源给的就是这套 Java 后台的完整落地实现思路:用 API v3 的签名体系、敏感字段加密、批次单与明细单两级结构,把“企业转账到零钱”做进自己的后台系统里,而不是停留在调通一次 demo。适合正在做返佣提现、活动发奖、骑手/兼职结算的后端同学,也适合已经接过支付但没做过出款、想看清对账和幂等怎么设计的开发者。Java 8 起步,Spring Boot 打底,小程序端配一段确认收款的调用就够了。
2. 链路拆解:从业务点提现到微信侧回执的六段式结构
先把整条链路摆平,后面写代码才知道每段该放在哪。这条链路不是“一个 HTTP 请求”那么简单,它是六个阶段的串联,并且每个阶段都可能成为最终状态的判定依据:
第一段,业务侧发起。用户在页面上点提现,业务系统校验余额、风控规则、单日次数,生成一条提现流水。第二段,本地落单预占。在数据库里先插入一条状态为 INIT 的转账单,把余额扣掉(冻结),拿到唯一业务单号。第三段,调微信侧下单接口。服务端签名、加密敏感字段、组装批次和明细,POST 出去,拿到微信返回的批次单号和拉起确认收款需要的参数。第四段,小程序端拉起确认。用户在小程序里看到金额和收款方,点确认。这一步是很多人迁移时漏掉的——商家转账到零钱需要用户主动确认收款,不是调完接口钱就到账。第五段,异步回调。微信把最终状态推到 notify_url,后台解密、验签、改状态、补齐流水。第六段,对账补偿。定时任务扫非终态单子,主动查询或撤销,兜住回调丢失。
这六段拆开之后,模块边界就清楚了:前两段属于业务域,第三、五段属于渠道网关,第四段属于前端,第六段属于运维侧的定时任务。混在一起写是灾难,因为一旦钱的问题出现,你根本不知道是业务算错了、签名错了、还是回调没处理。
2.1 分层与包结构:把渠道相关的东西全部关进 gateway
我一般把这块拆成四层,包名大致长这样:
com.example.payout ├── controller // 对外 HTTP 入口:提现申请、查询进度 ├── service // 业务编排:校验、扣减、落单、状态流转 ├── domain // 实体与状态枚举:TransferBill、TransferState ├── gateway // 渠道适配层,微信相关全部关在这一层 │ ├── wechat │ │ ├── WechatTransferClient.java // 下单/查询/撤销 │ │ ├── WechatSigner.java // 签名与验签 │ │ ├── WechatCrypto.java // 敏感字段加解密 │ │ └── WechatNotifyController.java // 回调入口 └── job // 定时对账与补偿关键在于 gateway 这一层要“厚”。签名串怎么拼、公钥放哪、错误码怎么翻译成业务语义,全都收在这里,service 层只看到transfer(bill)和query(batchNo)两个方法。这样做的直接好处是:将来要接第二个出款渠道,service 一行不动;微信侧换了公钥或者改了参数,改动半径也被限制在一个包内。血泪经验是,我见过把签名逻辑写在 Controller 里的项目,后来换证书序列号,全项目搜字符串搜了半小时。
另一个设计点是 OpenID 的来源。转账必须拿到收款人在该 appid 下的 openid,这个值不能由前端传,必须从登录态里取,否则就是给别人打钱。所以 service 层的第一步永远是“用当前登录用户查出绑定的 openid”,而不是信任入参。
2.2 请求头、签名与证书:三个东西不能搞混
API v3 的鉴权是一套非对称签名,涉及三样东西,经常被混着用,最后排查半天:
| 名称 | 用途 | 存放位置 | 常见错误 |
|---|---|---|---|
| 商户 API 私钥 | 给请求签名 | 服务器本地文件 / 密钥管理服务 | 打进 jar 包里,随代码泄露 |
| 商户证书序列号 | 声明用哪张证书签的 | 配置文件 | 换了证书没更新序列号,401 |
| 微信支付公钥(或平台证书公钥) | 加密敏感字段、验证回调 | 配置文件或定时下载缓存 | 拿商户自己的公钥去加密 |
| APIv3 密钥 | 解密回调 resource | 配置文件 | 和 API 密钥搞混,解密报 tag 校验失败 |
请求头里最关键的是Authorization,格式固定为WECHATPAY2-SHA256-RSA2048开头,后面跟 mchid、nonce_str、signature、timestamp、serial_no 五个键值对。待签名串是五行拼接,最后一行请求体之后必须还有一个换行符——这个末尾换行是最经典的翻车点,少了它签名永远对不上,而报错信息只会告诉你“签名错误”。
还有一点容易被忽略:待签名串里的 URL 部分是“带 query 的路径”,不含协议和域名。比如查询接口是/v3/fund-app/mch-transfer/transfer-bills/out-batch-no/xxx?query=1,签名时就要用这整段,而不是只写到路径。GET 请求没有请求体,但第五行仍然要保留空串加换行。
2.3 依赖与配置:别把密钥写进 yml
工程上最省事的组合是 Spring Boot + Jackson + Apache HttpClient,签名用 JDK 自带的java.security.Signature,加密用javax.crypto.Cipher,不需要额外引第三方加密库。依赖清单很短:
<dependency> <groupId>org.apache.httpcomponents.client5</groupId> <artifactId>httpclient5</artifactId> </dependency> <dependency> <groupId>com.fasterxml.jackson.core</groupId> <artifactId>jackson-databind</artifactId> </dependency>配置项则建议只放“非敏感”的部分,私钥和 APIv3 密钥走环境变量或者配置中心加密项:
wx: pay: app-id: wx0000000000000000 mch-id: 1900000000 merchant-serial-no: 5A3B... # 商户证书序列号 private-key-path: /data/keys/apiclient_key.pem wxpay-public-key-path: /data/keys/wxpay_pub.pem api-v3-key: ${WX_API_V3_KEY} notify-url: https://api.example.com/payout/wechat/notify transfer-scene-id: "1000" # 佣金报酬类场景,按实际报备结果填私钥文件权限设成 600,只给运行用户读。我在项目里还加了一个启动自检:应用起来时校验私钥能否加载、公钥能否解析、APIv3 密钥长度是否为 32 字节,不满足就直接启动失败。宁可起不来,也不要等到第一笔提现失败才发现密钥路径写错了——出款场景的失败成本比启动失败高得多。
3. 核心代码:签名、加密、下单、回调四段实现
代码部分按调用顺序来,从拼 Authorization 头开始,到最后处理回调结束。每段都可以直接抄进项目,改一下包名和配置注入方式即可。
3.1 拼装 Authorization 头:五行拼接一个字符都不能差
public final class WechatSigner { /** * @param method HTTP 方法,必须大写,GET / POST * @param urlPath 带 query 的路径,例如 /v3/fund-app/mch-transfer/transfer-bills?x=1 * @param body 请求体 JSON 字符串;GET 请求传空串 "" */ public static String buildAuthorization(String mchId, String serialNo, PrivateKey privateKey, String method, String urlPath, String body) throws Exception { String nonceStr = UUID.randomUUID().toString().replace("-", ""); long timestamp = System.currentTimeMillis() / 1000; // 五行待签名串,请求体后面必须再跟一个 \n,这是最容易漏的一处 String message = method + "\n" + urlPath + "\n" + timestamp + "\n" + nonceStr + "\n" + body + "\n"; Signature signer = Signature.getInstance("SHA256withRSA"); signer.initSign(privateKey); signer.update(message.getBytes(StandardCharsets.UTF_8)); String signature = Base64.getEncoder().encodeToString(signer.sign()); return "WECHATPAY2-SHA256-RSA2048 " + "mchid=\"" + mchId + "\"," + "nonce_str=\"" + nonceStr + "\"," + "signature=\"" + signature + "\"," + "timestamp=\"" + timestamp + "\"," + "serial_no=\"" + serialNo + "\""; } }逻辑很直白:随机串保证同一秒内的多次请求签名不同,时间戳参与签名防重放,私钥只参与本地计算不外传。参数上最容易错的是urlPath,一定要用“实际发出去的路径”,如果你在 HttpClient 里做了 URL 重写或者加了统一前缀,签名串也要跟着变。body必须是最终发送的字节内容对应的字符串,中间不能二次序列化——先序列化一次拿字符串,签名用它,发送也用它,别签一遍再序列化一遍,字段顺序一变签名就废了。
顺带说下验证回调的对称操作:用微信支付公钥对Wechatpay-Timestamp\nWechatpay-Nonce\n响应体\n做SHA256withRSA验签,公钥来源是响应头里的Wechatpay-Serial对应的那把。验签通过才允许解析业务字段,这个顺序不能反。
3.2 敏感字段加密:user_name 必须用微信支付公钥
转账明细里如果传真实姓名,需要 RSA-OAEP 加密;即使是可选字段,很多场景报备后也要求传,用来做实名一致性校验。
public static String encryptSensitive(String plain, PublicKey wxPayPublicKey) throws Exception { Cipher cipher = Cipher.getInstance("RSA/ECB/OAEPWithSHA-1AndMGF1Padding"); cipher.init(Cipher.ENCRYPT_MODE, wxPayPublicKey); byte[] encrypted = cipher.doFinal(plain.getBytes(StandardCharsets.UTF_8)); return Base64.getEncoder().encodeToString(encrypted); } public static PrivateKey loadPrivateKey(String path) throws Exception { String pem = Files.readString(Path.of(path)) .replace("-----BEGIN PRIVATE KEY-----", "") .replace("-----END PRIVATE KEY-----", "") .replaceAll("\\s", ""); byte[] der = Base64.getDecoder().decode(pem); return KeyFactory.getInstance("RSA") .generatePrivate(new PKCS8EncodedKeySpec(der)); }三个参数说明:填充方式必须严格是OAEPWithSHA-1AndMGF1Padding,写成 SHA-256 会直接报解密失败;公钥必须用微信支付侧提供的那把,不是商户自己的;明文是 UTF-8 字节,中文姓名不需要额外转码。私钥加载这里做了空白字符清理,因为 PEM 文件里通常有换行,直接 Base64 解码会抛异常。
3.3 转账下单:批次 + 明细两级结构
商家转账到零钱的请求体是“一个批次带若干明细”,即使你一次只给一个人发钱,也要按这个结构组装。核心字段如下:
public String createTransfer(TransferCommand cmd) throws Exception { Map<String, Object> detail = new LinkedHashMap<>(); detail.put("out_detail_no", cmd.getOutDetailNo()); // 明细单号,唯一 detail.put("transfer_amount", cmd.getAmountFen()); // 单位:分 detail.put("transfer_remark", cmd.getRemark()); // 用户可见的备注 detail.put("openid", cmd.getOpenid()); // 收款人 openid detail.put("user_name", wechatCrypto.encryptSensitive(cmd.getRealName())); detail.put("transfer_scene_id", props.getTransferSceneId()); detail.put("transfer_scene_report_infos", List.of( Map.of("info_type", "岗位类型", "info_content", cmd.getJobType()), Map.of("info_type", "报酬说明", "info_content", cmd.getRewardDesc()) )); Map<String, Object> body = new LinkedHashMap<>(); body.put("appid", props.getAppId()); body.put("out_batch_no", cmd.getOutBatchNo()); // 批次单号,幂等键 body.put("batch_name", "佣金发放"); body.put("batch_remark", "月度佣金结算"); body.put("total_amount", cmd.getAmountFen()); body.put("total_num", 1); body.put("transfer_detail_list", List.of(detail)); body.put("notify_url", props.getNotifyUrl()); String json = objectMapper.writeValueAsString(body); String auth = WechatSigner.buildAuthorization( props.getMchId(), props.getMerchantSerialNo(), privateKey, "POST", TRANSFER_PATH, json); HttpPost post = new HttpPost("https://api.mch.weixin.qq.com" + TRANSFER_PATH); post.setHeader("Authorization", auth); post.setHeader("Content-Type", "application/json"); post.setHeader("Accept", "application/json"); post.setEntity(new StringEntity(json, StandardCharsets.UTF_8)); return httpClient.execute(post, response -> EntityUtils.toString(response.getEntity(), StandardCharsets.UTF_8)); }out_batch_no是整条链路的幂等键,微信侧对它做唯一约束,重复提交同一个值会直接报“批次单号重复”,这反而是好事——它替你挡住了一次重复出款。transfer_amount和total_amount单位都是分,业务侧如果存的是BigDecimal元,务必在这一层乘 100 并intValueExact(),别用double。transfer_scene_report_infos里的info_type必须和你报备的转账场景一一对应,填错或者漏填会被拒。
返回结果里要重点关注三个字段:batch_id(微信侧批次号,后续查询和回调都用它)、state(下单成功后一般是等待用户确认收款)、以及拉起小程序确认收款需要的那个参数,落库保存,前端要用。
3.4 小程序端确认收款与回调解密
后端下单成功只是第一步,用户得在小程序里点确认。前端拿后端返回的参数调一段:
// 后端把 mchId、appId、packageInfo 一起返回给小程序 wx.requestMerchantTransfer({ mchId: res.data.mchId, appId: res.data.appId, package: res.data.packageInfo, success: (r) => { // 用户点了确认,这里只是前端回调,真正的到账状态以服务端回调为准 wx.showToast({ title: '已确认收款', icon: 'success' }) }, fail: (e) => { console.error('拉起确认收款失败', e) } })注意success不代表钱已到账,它只代表用户点了确认按钮。真正的状态必须等服务端收到异步通知或者主动查询确认,前端不要拿这个回调去改自己的余额展示——这是典型的对账不一致来源。
服务端回调处理的关键是解密resource字段,它用 APIv3 密钥做 AES-256-GCM 加密:
public static String decryptResource(String associatedData, String nonce, String ciphertext, String apiV3Key) throws Exception { Cipher cipher = Cipher.getInstance("AES/GCM/NoPadding"); SecretKeySpec key = new SecretKeySpec(apiV3Key.getBytes(StandardCharsets.UTF_8), "AES"); // GCM tag 长度固定 128 位 GCMParameterSpec spec = new GCMParameterSpec(128, nonce.getBytes(StandardCharsets.UTF_8)); cipher.init(Cipher.DECRYPT_MODE, key, spec); cipher.updateAAD(associatedData.getBytes(StandardCharsets.UTF_8)); byte[] plain = cipher.doFinal(Base64.getDecoder().decode(ciphertext)); return new String(plain, StandardCharsets.UTF_8); }associatedData、nonce、ciphertext三个值都来自回调报文,一个都不能替换。解密出来的 JSON 里有批次单号、明细单号、状态和失败原因。处理顺序是:先验签、再解密、再按明细单号做幂等更新、最后返回成功应答。回调可能重复推送,所以更新语句必须是“只有当当前状态不是终态时才更新”,不能无条件覆盖。
4. 数据与状态:转账单表、幂等键和补偿任务怎么设计
出款系统和普通业务表最大的区别是:任何一次状态错乱都会直接变成资金差错。所以表结构和状态机的设计要按“可追溯、可重放、可对账”三条来。
4.1 表结构:一张主表 + 一张明细表
如果一次只发一个人,主表和明细可以合成一张;但只要业务上有可能批量发放(比如一次结算一个团队),就老老实实拆两张。下面这张是明细粒度最常用的形态:
CREATE TABLE wx_transfer_detail ( id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT, out_batch_no VARCHAR(64) NOT NULL COMMENT '批次单号,业务幂等键', out_detail_no VARCHAR(64) NOT NULL COMMENT '明细单号,微信侧唯一', batch_id VARCHAR(64) DEFAULT NULL COMMENT '微信返回批次号', appid VARCHAR(32) NOT NULL, mch_id VARCHAR(32) NOT NULL, openid VARCHAR(64) NOT NULL, amount_fen INT UNSIGNED NOT NULL COMMENT '金额,单位分', state VARCHAR(24) NOT NULL DEFAULT 'INIT' COMMENT 'INIT/SUBMITTED/WAIT_CONFIRM/TRANSFERING/SUCCESS/FAIL/CANCELLED', package_info VARCHAR(512) DEFAULT NULL COMMENT '拉起确认收款参数', fail_reason VARCHAR(255) DEFAULT NULL, retry_count TINYINT UNSIGNED NOT NULL DEFAULT 0, created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, PRIMARY KEY (id), UNIQUE KEY uk_detail_no (out_detail_no), UNIQUE KEY uk_batch_no (out_batch_no), KEY idx_state_time (state, updated_at) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;uk_batch_no这个唯一索引是整个幂等体系的底座:业务侧即便因为网络重试提交了两次,第二次插入就会因唯一键冲突失败,直接返回“处理中”,不会重复出款。idx_state_time是给补偿任务用的,扫“非终态且超过 N 分钟未更新”的记录,走这个索引能避免全表扫描。
金额一律用分存整型。我见过用DECIMAL(10,2)存元、然后在 Java 侧double转来转去的项目,对账时出现 0.01 的差异,查了两天。
4.2 状态机:先占位再调远程
状态流转必须单向,且每次变更都带条件:
public enum TransferState { INIT, // 本地已落单,未调微信 SUBMITTED, // 已提交微信,等待用户确认 WAIT_CONFIRM, // 用户可拉起确认收款 TRANSFERING, // 用户已确认,微信处理中 SUCCESS, FAIL, CANCELLED; public boolean isFinal() { return this == SUCCESS || this == FAIL || this == CANCELLED; } }调用顺序是“先占位,再调远程”:事务内把状态从 INIT 改成 SUBMITTED,提交事务,然后才发 HTTP 请求。这样即使请求超时、进程被 kill,数据库里也有一条 SUBMITTED 的记录等着补偿任务去查,而不是一条永远查不到的黑匣子。如果同步拿到了微信返回,再更新成 WAIT_CONFIRM 并写入package_info。
更新时必须带状态条件,写成UPDATE ... SET state = ? WHERE out_detail_no = ? AND state = ?,影响行数为 0 就说明状态已经被别的线程改过,直接放弃本次更新。这一条能挡掉绝大多数回调与定时任务并发引起的状态回退。
4.3 补偿与对账:三个定时任务兜底
回调丢了、进程挂了、微信侧超时了,这些都靠定时任务兜。我一般配三个:
// 任务一:扫 SUBMITTED 超过 2 分钟的单子,主动查询同步状态 @Scheduled(fixedDelay = 60_000) public void syncPendingBills() { List<TransferDetail> list = detailMapper.selectByStateOlderThan( List.of("SUBMITTED", "WAIT_CONFIRM", "TRANSFERING"), 120); for (TransferDetail d : list) { try { wechatTransferClient.queryAndSync(d); } catch (Exception e) { log.warn("查询转账单失败 outDetailNo={}", d.getOutDetailNo(), e); } } } // 任务二:WAIT_CONFIRM 超过有效期仍未确认的,主动撤销,释放冻结金额 // 任务三:每天凌晨拉取前一日全部单据,与本地明细逐笔比对,差异落差错表等人工处理任务一要控制单次扫描条数(比如 200 条)和失败重试次数,retry_count超过阈值就转人工,否则一张永远查不到的单子会一直占着锁。任务二的撤销接口用批次单号调用,成功后本地状态改成 CANCELLED 并退回冻结余额。任务三是最后一道防线,对账不通过的单子不要自动改状态,只做告警——自动修数据在对账场景里比不修更危险。
5. 避坑排查:上线后最容易翻车的五类问题
这部分是我和周围人真实踩过的,按“现象 → 原因 → 解决”写,遇到对应症状可以直接对照。
5.1 签名和证书:三个反复出现的坑
坑一,401 签名错误,本地用同样的参数算出来的签名却“看着没问题”。现象是接口返回SIGN_ERROR,日志里签名串打印出来也很正常。原因几乎都是待签名串拼接不对:请求体后面漏了末尾换行、URL 用了不带 query 的路径、或者 JSON 序列化了两次导致字段顺序变化。解决方式是把这个待签名串原样打日志(注意脱敏),和官方文档的示例逐字符比对,尤其是换行符的数量——一次请求最多可能差一个\n。
坑二,换了证书但序列号没换。现象是昨天还好的接口今天全部 401,错误信息里提示证书序列号不匹配。原因是运维更新了apiclient_key.pem和证书,但配置里的merchant-serial-no还是旧的。解决是把序列号也纳入启动自检:用私钥推导公钥,算出证书指纹,和配置的序列号比对,不一致直接启动失败。后悔药不好吃,这种错误在出款链路上代价很高。
坑三,回调验签失败但业务字段明明能解出来。现象是回调一直返回失败,微信持续重推,日志里能看到Wechatpay-Serial头。原因是本地缓存的平台证书或公钥已经轮换,用旧公钥验新签名的报文自然失败。解决是不要硬编码单一公钥,按响应头里的序列号去查本地的密钥表,并保留一个定时刷新机制;同时把“验签失败”和“解密失败”分成两种日志,排查时能一眼看出是哪一步断的。
5.2 参数与额度:两个上线才暴露的问题
坑四,金额放大一百倍。现象是测试环境一路顺畅,上线后第一笔提现直接打出去一万块而不是一百块。原因是业务侧存的是元(BigDecimal),转账接口要的是分,在 DTO 转换时用了double相乘又round,或者干脆忘了乘。解决是在网关层做强制校验:金额必须是整数、必须大于等于最低限额、必须小于等于单笔上限,任何一个条件不满足直接拒绝,不给“先发出去再说”的机会。同时给单笔金额加一条人工复核阈值,超过就走审批流。
坑五,状态一直卡在等待用户确认。现象是用户说“我点了提现但没收到钱”,后台查单据状态停在 WAIT_CONFIRM。原因通常是两类:一是用户根本没在小程序里点确认收款,确认有有效期,过期需要撤销重发;二是小程序端拉起确认收款失败,常见于传入的 appid 和商户号没有绑定关系、或者 package 参数没有原样透传(有人做了 JSON 反序列化再取值,把字符串截断了)。解决是后台提供一个“重新拉起”的入口,把 package 原样存成字符串不动,并在前端失败回调里把错误码上报,别只在控制台打一行console.error。
5.3 排查手段:先把日志和错误码用起来
真出问题时,我一般按这个顺序看:先看本地单据的状态和updated_at,判断是没提交出去、还是提交了没回调;再看batch_id是否存在,存在就说明微信侧已经收到了;然后拿批次单号去主动查询接口拉一次最新状态,这一步能解决 80% 的“状态不动”问题;最后才去翻请求日志看签名和返回体。
日志有两处必须打:一是请求前的out_batch_no+ 金额 + openid 后四位,二是响应体的原始 JSON(敏感字段先脱敏)。但千万不要把完整签名串和 APIv3 密钥打进日志,出款系统的日志本身也是敏感资产。错误码层面,把微信返回的code映射成业务可读的枚举,比如签名类、参数类、余额类、限流类,前端展示和告警分级都靠这个映射表,比在代码里到处if ("SIGN_ERROR".equals(code))强得多。
6. 二次开发:把转账能力抽成通用发放服务
代码能跑通之后,真正有价值的是把它抽出来复用。我的做法是定义一个和渠道无关的发放接口,微信只是其中一个实现:
public interface PayoutChannel { ChannelType type(); // WECHAT / 其他 PayoutResult submit(PayoutCommand cmd); // 发起出款 PayoutResult query(String outBizNo); // 主动查询 void cancel(String outBizNo); // 撤销未确认的单子 }PayoutCommand里只放业务语义字段:收款人标识、金额(分)、业务单号、场景、备注。渠道相关的 openid、加密姓名、场景报备字段,全部由各实现类自己去补。这样加第二个渠道时,service 层只多一个Map<ChannelType, PayoutChannel>的路由,业务代码零改动。状态枚举也放在公共层,把各渠道的状态映射成统一的 INIT / PROCESSING / SUCCESS / FAIL / CANCELLED 五态,对账和报表才能统一口径。
验证方面,我习惯写三层:单元测试里用一个假的PayoutChannel实现验证状态机流转,保证任何状态都不会回退;集成测试里用录制的响应报文回放,覆盖签名失败、重复批次号、回调重复推送这三条路径;小额真实验证则固定用一个内部账号跑一笔最低金额,专门验证证书和密钥在真实环境下没问题。上线前再跑一遍对账脚本,把测试期的单子和渠道侧逐笔比对,确认没有遗留非终态记录。
还有一个具体技巧:把“用户确认收款”做成可重入的。前端可能因为网络问题重复拉起,后端就把package_info存下来,只要状态还是 WAIT_CONFIRM 就允许重复下发,不要每次生成新的批次单。这一条能省掉大量“用户点了两次”的重复工单。
从那以后我每次接出款类需求,都会先画一遍状态机、把幂等键定死、把补偿任务写好,再动手写签名——顺序反过来的项目,最后都在对账上还债。希望帮到你。
本文还有配套的精品资源,点击获取