1、合作商余额查询
2、分油账户司机可用额度
This commit is contained in:
@@ -0,0 +1,278 @@
|
||||
# 慧明宝供应链平台 · Java 对接示例
|
||||
|
||||
对接方(下单平台)调用慧明宝接口的完整示例,与平台 PHP 端口径一一对应。
|
||||
|
||||
配套文档:《慧明宝供应链平台接口文档 V1.0.17》。本示例等价于平台自带的联调脚本 `qyt_test.php`。
|
||||
|
||||
---
|
||||
|
||||
## 一、对接链路
|
||||
|
||||
```mermaid
|
||||
flowchart LR
|
||||
subgraph A["你方(下单平台)"]
|
||||
A1["业务系统"]
|
||||
end
|
||||
subgraph B["慧明宝平台"]
|
||||
B1["开放接口<br/>partners.php"]
|
||||
B2["发放队列"]
|
||||
B3["回调队列"]
|
||||
end
|
||||
subgraph C["上游油品平台(企油通)"]
|
||||
C1["运单油费下发"]
|
||||
C2["司机加油"]
|
||||
end
|
||||
|
||||
A1 -->|"① 分油 share<br/>RSA+AES 加密"| B1
|
||||
B1 -->|"② 同步返回 oilCardNo<br/>(仅代表受理)"| A1
|
||||
B1 --> B2
|
||||
B2 -->|"③ 下发油费"| C1
|
||||
C1 -->|"④ 查发放结果"| B2
|
||||
B2 -->|"⑤ 通知 share_oil"| A1
|
||||
C2 -->|"⑥ 加油消费推送"| B3
|
||||
B3 -->|"⑦ 通知 add_oil(每笔一次)"| A1
|
||||
A1 -->|"⑧ 退款 refund(可选)"| B1
|
||||
B1 -->|"⑨ 通知 refund"| A1
|
||||
```
|
||||
|
||||
不看图的话,一句话版本:**分油是异步的**。同步返回只代表平台受理,真正到账要等 `share_oil` 通知,或自己查订单状态。
|
||||
|
||||
```mermaid
|
||||
sequenceDiagram
|
||||
participant Y as 你方系统
|
||||
participant P as 慧明宝平台
|
||||
participant Q as 企油通
|
||||
|
||||
Y->>P: share 分油(金额/司机/身份证/车牌)
|
||||
P-->>Y: code=00 + oilCardNo(受理)
|
||||
P->>Q: 运单油费下发
|
||||
Q-->>P: 受理
|
||||
P->>Q: 查发放结果(轮询)
|
||||
Q-->>P: 发放成功
|
||||
P-->>Y: 通知 optType=share_oil
|
||||
Note over Y,P: 此时 oil_status_zsy=2,油费才真正到账
|
||||
|
||||
Q-->>P: 司机加油(一单可多次)
|
||||
P-->>Y: 通知 optType=add_oil(每笔推一次)
|
||||
|
||||
opt 司机没加完,需要退款
|
||||
Y->>P: refund 退款
|
||||
P->>Q: 调低司机凭证额度
|
||||
P-->>Y: 通知 optType=refund
|
||||
end
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 二、快速开始
|
||||
|
||||
```bash
|
||||
mvn -q compile exec:java -Dexec.mainClass=com.hmb.openapi.demo.ShareDemo
|
||||
```
|
||||
|
||||
运行前改 `ShareDemo.java` 顶部 5 个配置(平台分配):
|
||||
|
||||
| 配置 | 说明 |
|
||||
|---|---|
|
||||
| `BASE_URL` | 接口域名,不含结尾斜杠 |
|
||||
| `APP_KEY` | 平台分配的 appKey |
|
||||
| `ORG_NO` | 公司编号,作为业务参数 `orgNo` |
|
||||
| `PARTNER_PRIVATE_KEY` | **你方**私钥,PKCS#8 一行 base64,用于加签 |
|
||||
| `PLATFORM_PUBLIC_KEY` | **平台**公钥,X.509 一行 base64,用于加密与验签回调 |
|
||||
|
||||
私钥若是 PKCS#1(`-----BEGIN RSA PRIVATE KEY-----`),Java 读不了,先转换:
|
||||
|
||||
```bash
|
||||
openssl pkcs8 -topk8 -inform PEM -in old.pem -outform PEM -nocrypt -out pkcs8.pem
|
||||
```
|
||||
|
||||
### 目录结构
|
||||
|
||||
```
|
||||
src/main/java/com/hmb/openapi/
|
||||
├── HmbConfig.java 配置与地址拼接
|
||||
├── HmbCrypto.java 签名、加解密、PHP 兼容的 urlencode
|
||||
├── JsonPhp.java 请求体序列化(必须与 PHP 口径一致)
|
||||
├── HmbApiClient.java 五个业务接口
|
||||
├── HmbResponse.java 响应封装
|
||||
├── HmbNotifyVerifier.java 接收油卡变动通知(解密 + 验签)
|
||||
└── demo/
|
||||
├── ShareDemo.java 下单 → 查状态 → 取加油地址
|
||||
└── NotifyControllerDemo.java 接收通知的 Spring 示例
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 三、请求地址
|
||||
|
||||
**域名 + `/partners.php` + 接口路径**。平台没配 URL 重写,中间的入口文件不能省。
|
||||
|
||||
| 接口 | method(请求头) | 路径 |
|
||||
|---|---|---|
|
||||
| 用户分油 | `syb.oil.oilCard.share` | `/oilCard/share` |
|
||||
| 订单状态查询 | `syb.oil.oilCard.orderStatus` | `/oilCard/orderStatus` |
|
||||
| 订单退款 | `syb.oil.oilCard.refund` | `/oilCard/refund` |
|
||||
| 付款码申请 | `syb.pay.qrCode.request` | `/oilCard/getPayQrCode` |
|
||||
| 分油记录查询 | `syb.oil.oilCard.shareList` | `/oilCard/shareList` |
|
||||
| 合作商余额查询 | `syb.oil.account.balance` | `/oilCard/balance` |
|
||||
| 司机可用额度 | `syb.oil.driver.quota` | `/oilCard/driverQuota` |
|
||||
|
||||
例:`http://47.104.73.7/partners.php/oilCard/share`
|
||||
|
||||
---
|
||||
|
||||
## 四、报文结构
|
||||
|
||||
### 请求
|
||||
|
||||
请求头(自定义 HTTP 头):
|
||||
|
||||
| 头 | 说明 |
|
||||
|---|---|
|
||||
| `version` | 固定 `1.0.0` |
|
||||
| `appKey` | 平台分配 |
|
||||
| `method` | 接口标识,填错返回 `0005` |
|
||||
| `requestId` | **必须全局唯一**,重复返回 `0012` |
|
||||
| `sign` | 见下方加签步骤 |
|
||||
|
||||
请求体(业务参数不出现在明文里):
|
||||
|
||||
```json
|
||||
{"key": "RSA加密后的AES密钥(base64)", "data": "AES加密后的业务报文(base64)"}
|
||||
```
|
||||
|
||||
### 加签与加密(顺序不能变)
|
||||
|
||||
1. 业务参数序列化成 `bodyJson`(PHP 口径,见「六、常见坑」第 2 条)
|
||||
2. 拼待签串:`http_build_query([version, appKey, method, requestId, body])`
|
||||
3. `SHA256withRSA` 用**你方私钥**签名,base64 → 放进请求头 `sign`
|
||||
4. 随机 32 字节 AES 密钥,`AES-256-ECB/PKCS5Padding` 加密 `bodyJson`,base64 → `data`
|
||||
5. 用**平台公钥** `RSA/ECB/PKCS1Padding` 加密该 AES 密钥,base64 → `key`
|
||||
|
||||
待签串长这样(注意 body 整体被 urlencode 了):
|
||||
|
||||
```
|
||||
version=1.0.0&appKey=abc&method=syb.oil.oilCard.share&requestId=abcXXXX&body=%7B%22orgNo%22%3A%22U230%22%2C%22name%22%3A%22%E5%BC%A0%E4%B8%89%22%7D
|
||||
```
|
||||
|
||||
### 响应
|
||||
|
||||
平台响应是**明文 JSON,不加密**:
|
||||
|
||||
```json
|
||||
{"code":"00","message":"请求成功","errCode":null,"errMessage":null,"data":{"oilCardNo":"OIL1756...","amount":"100.00"}}
|
||||
```
|
||||
|
||||
`code=00` 为成功,否则看 `errCode` / `errMessage`。
|
||||
|
||||
---
|
||||
|
||||
## 五、接口字段
|
||||
|
||||
### 5.1 用户分油 share
|
||||
|
||||
| 参数 | 必填 | 说明 |
|
||||
|---|---|---|
|
||||
| `orgNo` | 是 | 公司编号 |
|
||||
| `amount` | 是 | 分油金额(元),最多两位小数且大于 0 |
|
||||
| `mobile` | 是 | 司机手机号 |
|
||||
| `name` | 是 | 司机姓名,用于上游实名二要素核验 |
|
||||
| `idCard` | 是 | 司机身份证号,18 位 |
|
||||
| `plateNo` | 是 | 车牌号,平台侧留存展示,**不上送上游** |
|
||||
| `saleOrderNo` | 是 | 你方内部销售单号,**同一单号重复提交返回原订单(幂等)** |
|
||||
| `oilCardType` | 是 | 固定 `zsy_ticket` |
|
||||
|
||||
返回 `data`:`oilCardNo`(油卡编号,后续接口都用它)、`amount`。
|
||||
|
||||
### 5.2 订单状态查询 orderStatus
|
||||
|
||||
入参 `orgNo` + `oilCardNo`。关键返回字段:
|
||||
|
||||
| 字段 | 说明 |
|
||||
|---|---|
|
||||
| `oil_status_zsy` | **0 未提交 / 1 发放中 / 2 发放成功 / 3 发放失败**。只有 2 才是真正到账 |
|
||||
| `used_amount` | 已加油金额(一单可多次加油,累计值) |
|
||||
| `used_num` | 加油次数 |
|
||||
| `remain_amount` | 剩余金额 = 分油金额 − 已加油 − 已退款,未退款时即可退金额 |
|
||||
| `refund_status` | 0 未退款 / 1 退款中 / 2 已退款 / 3 退款失败 / 4 退款结果确认中 |
|
||||
| `use_status` | 0 未加油 / 1 有加油记录(可能只加了一部分) |
|
||||
|
||||
### 5.3 订单退款 refund
|
||||
|
||||
入参 `orgNo` + `oilCardNo` + `reason`(选填)。
|
||||
|
||||
- 退款金额**固定为剩余未加油金额**,不能指定,已加油部分不退
|
||||
- 每单只能成功退一次
|
||||
- 返回 `refundStatus=2` 成功;`=4` 表示结果确认中,**此时切勿重复发起**,稍后查订单状态或等 `refund` 通知
|
||||
|
||||
---
|
||||
|
||||
## 六、常见坑
|
||||
|
||||
**1. `http_build_query` 的编码规则和 Java `URLEncoder` 不一样**
|
||||
|
||||
Java 的 `URLEncoder` 不编码 `*`,PHP 会编成 `%2A`;报文里只要出现一个这样的字符,验签就失败。本示例用 `HmbCrypto.phpUrlEncode()` 复刻了 PHP 规则:只有 `A-Z a-z 0-9 - _ .` 原样保留,空格编成 `+`,其余按 UTF-8 编成 `%XX` 大写十六进制。
|
||||
|
||||
**2. 请求体 JSON 里的 `/` 必须转义成 `\/`**
|
||||
|
||||
平台收到后会把 body 反序列化再**重新序列化**去验签,用的是 PHP `json_encode($body, JSON_UNESCAPED_UNICODE)`,它默认转义斜杠,而 Java 的 JSON 库不会。备注里带个日期 `2026/09/29` 就会对不上。所以请求体请用本示例的 `JsonPhp.encode()`,**不要**换成 Jackson/Gson。中文要原样输出,不能转 `\uXXXX`。
|
||||
|
||||
**3. `requestId` 必须全局唯一**
|
||||
|
||||
平台按它拦重复请求,重复返回 `0012`。
|
||||
|
||||
**4. 分油是异步的**
|
||||
|
||||
同步返回只代表受理。真正到账看 `oil_status_zsy=2`,或等 `share_oil` 通知。
|
||||
|
||||
**5. 一张订单可以分多次加油**
|
||||
|
||||
每笔加油单独推一次 `add_oil` 通知,`amount` 是**本次**占用金额,`orderAmount` 才是订单总金额。请按 `shareNo`(企油通消费订单号,每笔不同)做幂等去重。
|
||||
|
||||
**6. 验签回调时,body 用解密出来的明文原串**
|
||||
|
||||
不要反序列化后再序列化 —— 不同语言的 JSON 输出不一定一致。本示例已这样处理。
|
||||
|
||||
---
|
||||
|
||||
## 七、接收油卡变动通知
|
||||
|
||||
把你的接收地址填到平台后台的合作商「回调地址」。报文与请求方向同构,解析步骤:
|
||||
|
||||
1. 用**你方私钥**解出 AES 密钥 → 解出业务报文明文
|
||||
2. 用**平台公钥**验签,待签串是**原样拼接**(不 urlencode):
|
||||
`version=..&appKey=..&method=..&requestId=..&body={解密出的明文}`
|
||||
|
||||
业务字段:
|
||||
|
||||
| 字段 | 说明 |
|
||||
|---|---|
|
||||
| `oilCardNo` | 油卡编号,对应下单返回值 |
|
||||
| `optType` | `share_oil` 分油 / `add_oil` 加油 / `refund` 退款 |
|
||||
| `amount` | 分油=分油金额;加油=**本次**加油占用金额;退款=退款金额 |
|
||||
| `orderAmount` | 订单实付金额(即分油金额),三种场景都推,便于与 `amount` 对照 |
|
||||
| `shareNo` | 分油=分油记录编号;加油=企油通消费订单号(**每笔不同,用于去重**) |
|
||||
| `type` | `add` 增加 / `sub` 减少 |
|
||||
|
||||
处理成功必须返回 `{"code":"00","message":"请求成功"}`,否则平台按 60 秒 / 5 分钟 / 30 分钟退避重试 3 次。
|
||||
|
||||
---
|
||||
|
||||
## 八、错误码
|
||||
|
||||
| errCode | 含义 |
|
||||
|---|---|
|
||||
| 0001 | 缺少必填请求参数(如未传 `idCard` / `plateNo`) |
|
||||
| 0005 | 请求头 method 不正确 |
|
||||
| 0006 | 签名验证不通过 |
|
||||
| 0012 | 请求流水号重复 |
|
||||
| 0013 | 请求参数不正确(格式校验未过) |
|
||||
| 0014 | `orgNo` 与 `appKey` 不匹配 |
|
||||
| 2023 | 销售单号重复且原单发放失败,请换新单号 |
|
||||
| 2028 | 账户余额不足 |
|
||||
| 2032 | 该订单退款正在处理中 |
|
||||
| 2036 | 未找到对应的分油记录 |
|
||||
| 2048 | 退单状态异常(不满足退款前置条件,如已全部加油) |
|
||||
| 2049 | 调用上游退款失败(凭证余额不足或上游拒绝) |
|
||||
|
||||
完整列表见接口文档附录 4.3。
|
||||
@@ -0,0 +1,47 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<project xmlns="http://maven.apache.org/POM/4.0.0"
|
||||
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
|
||||
xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 http://maven.apache.org/xsd/maven-4.0.0.xsd">
|
||||
<modelVersion>4.0.0</modelVersion>
|
||||
|
||||
<groupId>com.hmb</groupId>
|
||||
<artifactId>hmb-openapi-demo</artifactId>
|
||||
<version>1.0.0</version>
|
||||
<packaging>jar</packaging>
|
||||
<name>慧明宝供应链平台 · Java 对接示例</name>
|
||||
|
||||
<properties>
|
||||
<maven.compiler.source>8</maven.compiler.source>
|
||||
<maven.compiler.target>8</maven.compiler.target>
|
||||
<project.build.sourceEncoding>UTF-8</project.build.sourceEncoding>
|
||||
</properties>
|
||||
|
||||
<dependencies>
|
||||
<!-- 只用于解析响应报文;请求体的序列化用 JsonPhp(必须与平台 PHP 口径一致) -->
|
||||
<dependency>
|
||||
<groupId>com.fasterxml.jackson.core</groupId>
|
||||
<artifactId>jackson-databind</artifactId>
|
||||
<version>2.15.2</version>
|
||||
</dependency>
|
||||
|
||||
<!-- 仅 NotifyControllerDemo 用到。你的项目里已有 Spring 就不用再引;
|
||||
不想引入 Spring 时,删掉 demo/NotifyControllerDemo.java 即可,
|
||||
HmbNotifyVerifier 本身不依赖 Spring。 -->
|
||||
<dependency>
|
||||
<groupId>org.springframework</groupId>
|
||||
<artifactId>spring-web</artifactId>
|
||||
<version>5.3.39</version>
|
||||
<scope>provided</scope>
|
||||
</dependency>
|
||||
</dependencies>
|
||||
|
||||
<build>
|
||||
<plugins>
|
||||
<plugin>
|
||||
<groupId>org.codehaus.mojo</groupId>
|
||||
<artifactId>exec-maven-plugin</artifactId>
|
||||
<version>3.1.0</version>
|
||||
</plugin>
|
||||
</plugins>
|
||||
</build>
|
||||
</project>
|
||||
@@ -0,0 +1,232 @@
|
||||
package com.hmb.openapi;
|
||||
|
||||
import com.fasterxml.jackson.databind.JsonNode;
|
||||
import com.fasterxml.jackson.databind.ObjectMapper;
|
||||
|
||||
import java.io.ByteArrayOutputStream;
|
||||
import java.io.InputStream;
|
||||
import java.io.OutputStream;
|
||||
import java.net.HttpURLConnection;
|
||||
import java.net.URL;
|
||||
import java.nio.charset.StandardCharsets;
|
||||
import java.security.PrivateKey;
|
||||
import java.security.PublicKey;
|
||||
import java.util.LinkedHashMap;
|
||||
import java.util.Map;
|
||||
import java.util.UUID;
|
||||
|
||||
/**
|
||||
* 慧明宝开放接口客户端(你方 → 平台)。
|
||||
*
|
||||
* <p>调用流程(与平台 PHP 端一一对应):
|
||||
* <ol>
|
||||
* <li>业务参数序列化成 bodyJson(PHP 口径,见 {@link JsonPhp})</li>
|
||||
* <li>拼待签串 → SHA256withRSA 用<b>你方私钥</b>签名 → base64 放进请求头 sign</li>
|
||||
* <li>随机 32 字节 AES 密钥,AES-256-ECB 加密 bodyJson → base64 = data</li>
|
||||
* <li>用<b>平台公钥</b> RSA 加密该 AES 密钥 → base64 = key</li>
|
||||
* <li>POST {"key": ..., "data": ...},业务参数不出现在明文里</li>
|
||||
* </ol>
|
||||
*
|
||||
* <p>平台的响应是<b>明文 JSON</b>,不加密:
|
||||
* {@code {"code":"00","message":"请求成功","errCode":null,"errMessage":null,"data":{...}}}
|
||||
*/
|
||||
public class HmbApiClient {
|
||||
|
||||
private final HmbConfig config;
|
||||
private final PrivateKey partnerPrivateKey;
|
||||
private final PublicKey platformPublicKey;
|
||||
private final ObjectMapper mapper = new ObjectMapper();
|
||||
|
||||
public HmbApiClient(HmbConfig config) throws Exception {
|
||||
this.config = config;
|
||||
this.partnerPrivateKey = HmbCrypto.loadPrivateKey(config.getPartnerPrivateKey());
|
||||
this.platformPublicKey = HmbCrypto.loadPublicKey(config.getPlatformPublicKey());
|
||||
}
|
||||
|
||||
// ================================================================
|
||||
// 业务接口
|
||||
// ================================================================
|
||||
|
||||
/**
|
||||
* 用户分油:给司机发放油费。
|
||||
*
|
||||
* @param amount 分油金额(元,最多两位小数,须大于 0)
|
||||
* @param mobile 司机手机号
|
||||
* @param name 司机姓名(必填,用于上游实名核验)
|
||||
* @param idCard 司机身份证号(必填,18 位)
|
||||
* @param plateNo 车牌号(必填,平台侧留存展示,不上送上游)
|
||||
* @param saleOrderNo 你方内部销售单号,同一单号重复提交会返回原订单(幂等)
|
||||
*/
|
||||
public HmbResponse share(String amount, String mobile, String name,
|
||||
String idCard, String plateNo, String saleOrderNo) throws Exception {
|
||||
Map<String, String> body = new LinkedHashMap<>();
|
||||
body.put("orgNo", config.getOrgNo());
|
||||
body.put("amount", amount);
|
||||
body.put("mobile", mobile);
|
||||
body.put("name", name);
|
||||
body.put("idCard", idCard);
|
||||
body.put("plateNo", plateNo);
|
||||
body.put("saleOrderNo", saleOrderNo);
|
||||
body.put("oilCardType", "zsy_ticket");
|
||||
return call("syb.oil.oilCard.share", "/oilCard/share", body);
|
||||
}
|
||||
|
||||
/** 订单状态查询:发放状态、加油金额、退款状态等 */
|
||||
public HmbResponse orderStatus(String oilCardNo) throws Exception {
|
||||
Map<String, String> body = new LinkedHashMap<>();
|
||||
body.put("orgNo", config.getOrgNo());
|
||||
body.put("oilCardNo", oilCardNo);
|
||||
return call("syb.oil.oilCard.orderStatus", "/oilCard/orderStatus", body);
|
||||
}
|
||||
|
||||
/** 订单退款:退还剩余未加油金额(已加油部分不退,每单只能成功退一次) */
|
||||
public HmbResponse refund(String oilCardNo, String reason) throws Exception {
|
||||
Map<String, String> body = new LinkedHashMap<>();
|
||||
body.put("orgNo", config.getOrgNo());
|
||||
body.put("oilCardNo", oilCardNo);
|
||||
body.put("reason", reason == null ? "" : reason);
|
||||
return call("syb.oil.oilCard.refund", "/oilCard/refund", body);
|
||||
}
|
||||
|
||||
/** 获取司机免登加油地址(H5,可据此生成二维码) */
|
||||
public HmbResponse payQrCode(String oilCardNo, String mobile) throws Exception {
|
||||
Map<String, String> body = new LinkedHashMap<>();
|
||||
body.put("orgNo", config.getOrgNo());
|
||||
body.put("oilCardNo", oilCardNo);
|
||||
body.put("mobile", mobile);
|
||||
return call("syb.pay.qrCode.request", "/oilCard/getPayQrCode", body);
|
||||
}
|
||||
|
||||
/**
|
||||
* 合作商余额查询:你方在平台的账户余额(下单按分油金额实时扣减,发放失败/退款原路回补)。
|
||||
* 返回 data:orgNo、balance(元)、queryTime
|
||||
*/
|
||||
public HmbResponse accountBalance() throws Exception {
|
||||
Map<String, String> body = new LinkedHashMap<>();
|
||||
body.put("orgNo", config.getOrgNo());
|
||||
return call("syb.oil.account.balance", "/oilCard/balance", body);
|
||||
}
|
||||
|
||||
/**
|
||||
* 分油账户司机可用额度:司机在上游油费钱包里的可用余额。
|
||||
* 返回 data:mobile、totalGrantAmount(累计发放,元)、availableAmount(当前可用,元)、list[]
|
||||
*
|
||||
* @param mobile 司机手机号
|
||||
* @param idCard 司机身份证号(上游按手机号+身份证号定位司机,两个都必传)
|
||||
*/
|
||||
public HmbResponse driverQuota(String mobile, String idCard) throws Exception {
|
||||
Map<String, String> body = new LinkedHashMap<>();
|
||||
body.put("orgNo", config.getOrgNo());
|
||||
body.put("mobile", mobile);
|
||||
body.put("idCard", idCard);
|
||||
return call("syb.oil.driver.quota", "/oilCard/driverQuota", body);
|
||||
}
|
||||
|
||||
/** 分油记录查询。日期格式 yyyyMMdd,区间不超过 365 天 */
|
||||
public HmbResponse shareList(String mobile, String beginDate, String endDate,
|
||||
int pageNum, int pageLimit) throws Exception {
|
||||
Map<String, String> body = new LinkedHashMap<>();
|
||||
body.put("orgNo", config.getOrgNo());
|
||||
body.put("mobile", mobile);
|
||||
body.put("beginDate", beginDate);
|
||||
body.put("endDate", endDate);
|
||||
body.put("pageNum", String.valueOf(pageNum));
|
||||
body.put("pageLimit", String.valueOf(pageLimit));
|
||||
return call("syb.oil.oilCard.shareList", "/oilCard/shareList", body);
|
||||
}
|
||||
|
||||
// ================================================================
|
||||
// 通用调用
|
||||
// ================================================================
|
||||
|
||||
public HmbResponse call(String method, String path, Map<String, String> body) throws Exception {
|
||||
String bodyJson = JsonPhp.encode(body);
|
||||
String requestId = nextRequestId();
|
||||
|
||||
// 1) 加签:用你方私钥
|
||||
String signData = HmbCrypto.buildRequestSignData(
|
||||
config.getVersion(), config.getAppKey(), method, requestId, bodyJson);
|
||||
String sign = HmbCrypto.sign(signData, partnerPrivateKey);
|
||||
|
||||
// 2) 加密:AES 加密报文,RSA(平台公钥)加密 AES 密钥
|
||||
byte[] aesKey = HmbCrypto.randomAesKey();
|
||||
String data = HmbCrypto.aesEncrypt(bodyJson, aesKey);
|
||||
String key = HmbCrypto.rsaEncrypt(aesKey, platformPublicKey);
|
||||
|
||||
Map<String, String> headers = new LinkedHashMap<>();
|
||||
headers.put("version", config.getVersion());
|
||||
headers.put("appKey", config.getAppKey());
|
||||
headers.put("method", method);
|
||||
headers.put("requestId", requestId);
|
||||
headers.put("sign", sign);
|
||||
|
||||
String payload = "{\"key\":\"" + key + "\",\"data\":\"" + data + "\"}";
|
||||
String raw = post(config.url(path), headers, payload);
|
||||
|
||||
return parse(raw, bodyJson, signData);
|
||||
}
|
||||
|
||||
/**
|
||||
* 请求流水号:必须全局唯一,平台按它做重复请求拦截(重复会返回 errCode=0012)。
|
||||
* 这里用 appKey + UUID,够用且不会碰撞。
|
||||
*/
|
||||
private String nextRequestId() {
|
||||
return config.getAppKey() + UUID.randomUUID().toString().replace("-", "").toUpperCase();
|
||||
}
|
||||
|
||||
private HmbResponse parse(String raw, String bodyJson, String signData) throws Exception {
|
||||
HmbResponse resp = new HmbResponse();
|
||||
resp.setRawResponse(raw);
|
||||
resp.setRequestBodyJson(bodyJson);
|
||||
resp.setSignData(signData);
|
||||
JsonNode node = mapper.readTree(raw);
|
||||
resp.setCode(text(node, "code"));
|
||||
resp.setMessage(text(node, "message"));
|
||||
resp.setErrCode(text(node, "errCode"));
|
||||
resp.setErrMessage(text(node, "errMessage"));
|
||||
resp.setData(node.get("data"));
|
||||
return resp;
|
||||
}
|
||||
|
||||
private static String text(JsonNode node, String field) {
|
||||
JsonNode v = node.get(field);
|
||||
return v == null || v.isNull() ? null : v.asText();
|
||||
}
|
||||
|
||||
private String post(String url, Map<String, String> headers, String jsonPayload) throws Exception {
|
||||
HttpURLConnection conn = (HttpURLConnection) new URL(url).openConnection();
|
||||
try {
|
||||
conn.setRequestMethod("POST");
|
||||
conn.setConnectTimeout(config.getConnectTimeout());
|
||||
conn.setReadTimeout(config.getReadTimeout());
|
||||
conn.setDoOutput(true);
|
||||
conn.setRequestProperty("Content-Type", "application/json; charset=UTF-8");
|
||||
conn.setRequestProperty("Charset", "UTF-8");
|
||||
for (Map.Entry<String, String> h : headers.entrySet()) {
|
||||
conn.setRequestProperty(h.getKey(), h.getValue());
|
||||
}
|
||||
try (OutputStream os = conn.getOutputStream()) {
|
||||
os.write(jsonPayload.getBytes(StandardCharsets.UTF_8));
|
||||
}
|
||||
int status = conn.getResponseCode();
|
||||
InputStream is = status >= 400 ? conn.getErrorStream() : conn.getInputStream();
|
||||
return readAll(is);
|
||||
} finally {
|
||||
conn.disconnect();
|
||||
}
|
||||
}
|
||||
|
||||
private static String readAll(InputStream is) throws Exception {
|
||||
if (is == null) {
|
||||
return "";
|
||||
}
|
||||
ByteArrayOutputStream bos = new ByteArrayOutputStream();
|
||||
byte[] buf = new byte[4096];
|
||||
int n;
|
||||
while ((n = is.read(buf)) != -1) {
|
||||
bos.write(buf, 0, n);
|
||||
}
|
||||
is.close();
|
||||
return new String(bos.toByteArray(), StandardCharsets.UTF_8);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,109 @@
|
||||
package com.hmb.openapi;
|
||||
|
||||
/**
|
||||
* 对接配置。
|
||||
*
|
||||
* <p>appKey / orgNo / 你方私钥 / 平台公钥 由慧明宝平台分配,请勿提交到代码仓库,
|
||||
* 生产环境建议放配置中心或环境变量。
|
||||
*/
|
||||
public class HmbConfig {
|
||||
|
||||
/** 接口域名,不含结尾斜杠,例如 http://47.104.73.7 */
|
||||
private String baseUrl;
|
||||
|
||||
/** 平台分配的 appKey */
|
||||
private String appKey;
|
||||
|
||||
/** 平台分配的公司编号,作为业务参数 orgNo 传入 */
|
||||
private String orgNo;
|
||||
|
||||
/**
|
||||
* 你方 RSA 私钥(PKCS#8,一行 base64,无 BEGIN/END 头尾与换行)。
|
||||
* 用于给请求加签;平台用你方公钥验签。
|
||||
*
|
||||
* <p>若拿到的是 PKCS#1(-----BEGIN RSA PRIVATE KEY-----),先转换:
|
||||
* <pre>openssl pkcs8 -topk8 -inform PEM -in old.pem -outform PEM -nocrypt -out pkcs8.pem</pre>
|
||||
*/
|
||||
private String partnerPrivateKey;
|
||||
|
||||
/** 平台公钥(X.509,一行 base64)。用于加密 AES 密钥、以及验签平台推送的回调 */
|
||||
private String platformPublicKey;
|
||||
|
||||
/** 协议版本,固定 1.0.0 */
|
||||
private String version = "1.0.0";
|
||||
|
||||
/** 连接 / 读取超时(毫秒) */
|
||||
private int connectTimeout = 10000;
|
||||
private int readTimeout = 30000;
|
||||
|
||||
public HmbConfig(String baseUrl, String appKey, String orgNo,
|
||||
String partnerPrivateKey, String platformPublicKey) {
|
||||
this.baseUrl = stripTrailingSlash(baseUrl);
|
||||
this.appKey = appKey;
|
||||
this.orgNo = orgNo;
|
||||
this.partnerPrivateKey = partnerPrivateKey;
|
||||
this.platformPublicKey = platformPublicKey;
|
||||
}
|
||||
|
||||
private static String stripTrailingSlash(String s) {
|
||||
if (s == null) {
|
||||
return null;
|
||||
}
|
||||
while (s.endsWith("/")) {
|
||||
s = s.substring(0, s.length() - 1);
|
||||
}
|
||||
return s;
|
||||
}
|
||||
|
||||
/**
|
||||
* 拼接接口地址:域名 + /partners.php + 接口路径。
|
||||
* 平台没有配 URL 重写,中间的入口文件不能省略。
|
||||
*/
|
||||
public String url(String path) {
|
||||
return baseUrl + "/partners.php" + path;
|
||||
}
|
||||
|
||||
public String getBaseUrl() {
|
||||
return baseUrl;
|
||||
}
|
||||
|
||||
public String getAppKey() {
|
||||
return appKey;
|
||||
}
|
||||
|
||||
public String getOrgNo() {
|
||||
return orgNo;
|
||||
}
|
||||
|
||||
public String getPartnerPrivateKey() {
|
||||
return partnerPrivateKey;
|
||||
}
|
||||
|
||||
public String getPlatformPublicKey() {
|
||||
return platformPublicKey;
|
||||
}
|
||||
|
||||
public String getVersion() {
|
||||
return version;
|
||||
}
|
||||
|
||||
public void setVersion(String version) {
|
||||
this.version = version;
|
||||
}
|
||||
|
||||
public int getConnectTimeout() {
|
||||
return connectTimeout;
|
||||
}
|
||||
|
||||
public void setConnectTimeout(int connectTimeout) {
|
||||
this.connectTimeout = connectTimeout;
|
||||
}
|
||||
|
||||
public int getReadTimeout() {
|
||||
return readTimeout;
|
||||
}
|
||||
|
||||
public void setReadTimeout(int readTimeout) {
|
||||
this.readTimeout = readTimeout;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,165 @@
|
||||
package com.hmb.openapi;
|
||||
|
||||
import javax.crypto.Cipher;
|
||||
import javax.crypto.spec.SecretKeySpec;
|
||||
import java.nio.charset.StandardCharsets;
|
||||
import java.security.KeyFactory;
|
||||
import java.security.PrivateKey;
|
||||
import java.security.PublicKey;
|
||||
import java.security.SecureRandom;
|
||||
import java.security.Signature;
|
||||
import java.security.spec.PKCS8EncodedKeySpec;
|
||||
import java.security.spec.X509EncodedKeySpec;
|
||||
import java.util.Base64;
|
||||
|
||||
/**
|
||||
* 签名与加解密工具,口径与平台(PHP openssl)完全一致:
|
||||
*
|
||||
* <ul>
|
||||
* <li>签名:SHA256withRSA,base64</li>
|
||||
* <li>业务报文:AES-256-ECB / PKCS5Padding,密文 base64</li>
|
||||
* <li>AES 密钥:RSA/ECB/PKCS1Padding,密文 base64</li>
|
||||
* </ul>
|
||||
*/
|
||||
public final class HmbCrypto {
|
||||
|
||||
private static final SecureRandom RANDOM = new SecureRandom();
|
||||
|
||||
private HmbCrypto() {
|
||||
}
|
||||
|
||||
// ================================================================
|
||||
// 待签串拼接
|
||||
// ================================================================
|
||||
|
||||
/**
|
||||
* 复刻 PHP 的 urlencode(http_build_query 内部用的就是它)。
|
||||
*
|
||||
* <p>⚠️ 不能直接用 java.net.URLEncoder:它不编码 {@code *},而 PHP 会编成 {@code %2A},
|
||||
* 只要报文里出现一个这样的字符,验签就会失败。
|
||||
*
|
||||
* <p>规则:仅 {@code A-Z a-z 0-9 - _ .} 原样保留,空格编成 {@code +},
|
||||
* 其余字节一律按 UTF-8 编成 {@code %XX}(大写十六进制)。
|
||||
*/
|
||||
public static String phpUrlEncode(String value) {
|
||||
byte[] bytes = value.getBytes(StandardCharsets.UTF_8);
|
||||
StringBuilder sb = new StringBuilder(bytes.length * 3);
|
||||
for (byte b : bytes) {
|
||||
int c = b & 0xFF;
|
||||
boolean unreserved = (c >= 'a' && c <= 'z') || (c >= 'A' && c <= 'Z')
|
||||
|| (c >= '0' && c <= '9') || c == '-' || c == '_' || c == '.';
|
||||
if (unreserved) {
|
||||
sb.append((char) c);
|
||||
} else if (c == ' ') {
|
||||
sb.append('+');
|
||||
} else {
|
||||
sb.append('%').append(String.format("%02X", c));
|
||||
}
|
||||
}
|
||||
return sb.toString();
|
||||
}
|
||||
|
||||
/**
|
||||
* 请求方向的待签串(你方 → 平台):
|
||||
* <pre>http_build_query([version, appKey, method, requestId, body])</pre>
|
||||
* 即 key=value 用 & 连接,key 与 value 都做 urlencode。
|
||||
*/
|
||||
public static String buildRequestSignData(String version, String appKey, String method,
|
||||
String requestId, String bodyJson) {
|
||||
return "version=" + phpUrlEncode(version)
|
||||
+ "&appKey=" + phpUrlEncode(appKey)
|
||||
+ "&method=" + phpUrlEncode(method)
|
||||
+ "&requestId=" + phpUrlEncode(requestId)
|
||||
+ "&body=" + phpUrlEncode(bodyJson);
|
||||
}
|
||||
|
||||
/**
|
||||
* 回调方向的待签串(平台 → 你方):
|
||||
* <pre>version=..&appKey=..&method=..&requestId=..&body={json}</pre>
|
||||
*
|
||||
* <p>⚠️ 注意与请求方向不同:这里是<b>原样拼接、不做 urlencode</b>。
|
||||
* body 直接用解密出来的明文字符串,不要反序列化后再序列化 —— 不同语言的
|
||||
* JSON 输出(斜杠、中文、空格)不一定一致,会导致验签失败。
|
||||
*/
|
||||
public static String buildNotifySignData(String version, String appKey, String method,
|
||||
String requestId, String bodyPlainJson) {
|
||||
return "version=" + version
|
||||
+ "&appKey=" + appKey
|
||||
+ "&method=" + method
|
||||
+ "&requestId=" + requestId
|
||||
+ "&body=" + bodyPlainJson;
|
||||
}
|
||||
|
||||
// ================================================================
|
||||
// 签名 / 验签
|
||||
// ================================================================
|
||||
|
||||
public static String sign(String data, PrivateKey privateKey) throws Exception {
|
||||
Signature signature = Signature.getInstance("SHA256withRSA");
|
||||
signature.initSign(privateKey);
|
||||
signature.update(data.getBytes(StandardCharsets.UTF_8));
|
||||
return Base64.getEncoder().encodeToString(signature.sign());
|
||||
}
|
||||
|
||||
public static boolean verify(String data, String signBase64, PublicKey publicKey) throws Exception {
|
||||
Signature signature = Signature.getInstance("SHA256withRSA");
|
||||
signature.initVerify(publicKey);
|
||||
signature.update(data.getBytes(StandardCharsets.UTF_8));
|
||||
return signature.verify(Base64.getDecoder().decode(signBase64));
|
||||
}
|
||||
|
||||
// ================================================================
|
||||
// AES / RSA
|
||||
// ================================================================
|
||||
|
||||
/** 随机 32 字节 AES 密钥(每次请求都要新生成) */
|
||||
public static byte[] randomAesKey() {
|
||||
byte[] key = new byte[32];
|
||||
RANDOM.nextBytes(key);
|
||||
return key;
|
||||
}
|
||||
|
||||
public static String aesEncrypt(String plain, byte[] aesKey) throws Exception {
|
||||
Cipher cipher = Cipher.getInstance("AES/ECB/PKCS5Padding");
|
||||
cipher.init(Cipher.ENCRYPT_MODE, new SecretKeySpec(aesKey, "AES"));
|
||||
return Base64.getEncoder().encodeToString(cipher.doFinal(plain.getBytes(StandardCharsets.UTF_8)));
|
||||
}
|
||||
|
||||
public static String aesDecrypt(String cipherBase64, byte[] aesKey) throws Exception {
|
||||
Cipher cipher = Cipher.getInstance("AES/ECB/PKCS5Padding");
|
||||
cipher.init(Cipher.DECRYPT_MODE, new SecretKeySpec(aesKey, "AES"));
|
||||
return new String(cipher.doFinal(Base64.getDecoder().decode(cipherBase64)), StandardCharsets.UTF_8);
|
||||
}
|
||||
|
||||
public static String rsaEncrypt(byte[] data, PublicKey publicKey) throws Exception {
|
||||
Cipher cipher = Cipher.getInstance("RSA/ECB/PKCS1Padding");
|
||||
cipher.init(Cipher.ENCRYPT_MODE, publicKey);
|
||||
return Base64.getEncoder().encodeToString(cipher.doFinal(data));
|
||||
}
|
||||
|
||||
public static byte[] rsaDecrypt(String cipherBase64, PrivateKey privateKey) throws Exception {
|
||||
Cipher cipher = Cipher.getInstance("RSA/ECB/PKCS1Padding");
|
||||
cipher.init(Cipher.DECRYPT_MODE, privateKey);
|
||||
return cipher.doFinal(Base64.getDecoder().decode(cipherBase64));
|
||||
}
|
||||
|
||||
// ================================================================
|
||||
// 密钥加载:PEM 头尾与换行可有可无
|
||||
// ================================================================
|
||||
|
||||
public static PrivateKey loadPrivateKey(String pkcs8) throws Exception {
|
||||
byte[] der = Base64.getDecoder().decode(clean(pkcs8));
|
||||
return KeyFactory.getInstance("RSA").generatePrivate(new PKCS8EncodedKeySpec(der));
|
||||
}
|
||||
|
||||
public static PublicKey loadPublicKey(String x509) throws Exception {
|
||||
byte[] der = Base64.getDecoder().decode(clean(x509));
|
||||
return KeyFactory.getInstance("RSA").generatePublic(new X509EncodedKeySpec(der));
|
||||
}
|
||||
|
||||
private static String clean(String key) {
|
||||
return key.replaceAll("-----BEGIN [^-]+-----", "")
|
||||
.replaceAll("-----END [^-]+-----", "")
|
||||
.replaceAll("\\s", "");
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,122 @@
|
||||
package com.hmb.openapi;
|
||||
|
||||
import com.fasterxml.jackson.databind.JsonNode;
|
||||
import com.fasterxml.jackson.databind.ObjectMapper;
|
||||
|
||||
import java.security.PrivateKey;
|
||||
import java.security.PublicKey;
|
||||
import java.util.Map;
|
||||
|
||||
/**
|
||||
* 油卡变动通知接收端(平台 → 你方)。
|
||||
*
|
||||
* <p>平台在三种场景下推送:分油成功、司机加油、订单退款。报文与请求方向同构:
|
||||
* 请求头带 version/appKey/method/requestId/sign,请求体是 {"key": ..., "data": ...}。
|
||||
*
|
||||
* <p>解析步骤:
|
||||
* <ol>
|
||||
* <li>用<b>你方私钥</b> RSA 解出 AES 密钥</li>
|
||||
* <li>用该 AES 密钥解出业务报文明文</li>
|
||||
* <li>用<b>平台公钥</b>验签,待签串是<b>原样拼接</b>(不 urlencode),
|
||||
* body 直接用第 2 步的明文字符串</li>
|
||||
* </ol>
|
||||
*
|
||||
* <p>处理成功后必须返回:{@code {"code":"00","message":"请求成功"}},
|
||||
* 否则平台会按 60 秒 / 5 分钟 / 30 分钟退避重试 3 次。
|
||||
*/
|
||||
public class HmbNotifyVerifier {
|
||||
|
||||
private final PrivateKey partnerPrivateKey;
|
||||
private final PublicKey platformPublicKey;
|
||||
private final ObjectMapper mapper = new ObjectMapper();
|
||||
|
||||
public HmbNotifyVerifier(String partnerPrivateKeyPkcs8, String platformPublicKeyX509) throws Exception {
|
||||
this.partnerPrivateKey = HmbCrypto.loadPrivateKey(partnerPrivateKeyPkcs8);
|
||||
this.platformPublicKey = HmbCrypto.loadPublicKey(platformPublicKeyX509);
|
||||
}
|
||||
|
||||
/**
|
||||
* @param headers 请求头(键名大小写不敏感,内部统一转小写取值)
|
||||
* @param rawBody 原始请求体,形如 {"key":"...","data":"..."}
|
||||
*/
|
||||
public Notify parse(Map<String, String> headers, String rawBody) throws Exception {
|
||||
JsonNode root = mapper.readTree(rawBody);
|
||||
String encKey = textOf(root, "key");
|
||||
String encData = textOf(root, "data");
|
||||
if (encKey == null || encData == null) {
|
||||
throw new IllegalArgumentException("报文缺少 key 或 data");
|
||||
}
|
||||
|
||||
byte[] aesKey = HmbCrypto.rsaDecrypt(encKey, partnerPrivateKey);
|
||||
String plain = HmbCrypto.aesDecrypt(encData, aesKey);
|
||||
|
||||
String signData = HmbCrypto.buildNotifySignData(
|
||||
header(headers, "version"), header(headers, "appKey"),
|
||||
header(headers, "method"), header(headers, "requestId"), plain);
|
||||
boolean verified = HmbCrypto.verify(signData, header(headers, "sign"), platformPublicKey);
|
||||
|
||||
JsonNode body = mapper.readTree(plain);
|
||||
Notify notify = new Notify();
|
||||
notify.verified = verified;
|
||||
notify.plainJson = plain;
|
||||
notify.oilCardNo = textOf(body, "oilCardNo");
|
||||
notify.shareNo = textOf(body, "shareNo");
|
||||
notify.amount = textOf(body, "amount");
|
||||
notify.orderAmount = textOf(body, "orderAmount");
|
||||
notify.type = textOf(body, "type");
|
||||
notify.optType = textOf(body, "optType");
|
||||
return notify;
|
||||
}
|
||||
|
||||
private static String header(Map<String, String> headers, String name) {
|
||||
for (Map.Entry<String, String> e : headers.entrySet()) {
|
||||
if (e.getKey() != null && e.getKey().equalsIgnoreCase(name)) {
|
||||
return e.getValue();
|
||||
}
|
||||
}
|
||||
return "";
|
||||
}
|
||||
|
||||
private static String textOf(JsonNode node, String field) {
|
||||
JsonNode v = node.get(field);
|
||||
return v == null || v.isNull() ? null : v.asText();
|
||||
}
|
||||
|
||||
/** 通知内容 */
|
||||
public static class Notify {
|
||||
/** 验签是否通过。为 false 时不要入账 */
|
||||
public boolean verified;
|
||||
/** 解密出的业务报文明文 */
|
||||
public String plainJson;
|
||||
/** 油卡编号,对应下单时返回的 oilCardNo */
|
||||
public String oilCardNo;
|
||||
/** 分油:分油记录编号;加油:企油通消费订单号(同一订单多次加油每笔不同,可据此去重) */
|
||||
public String shareNo;
|
||||
/** 变动金额:分油=分油金额;加油=本次加油占用金额;退款=退款金额 */
|
||||
public String amount;
|
||||
/** 订单实付金额(即分油金额),三种场景都会推,便于与 amount 对照 */
|
||||
public String orderAmount;
|
||||
/** add 增加 / sub 减少 */
|
||||
public String type;
|
||||
/** share_oil 分油 / add_oil 加油 / refund 退款 */
|
||||
public String optType;
|
||||
|
||||
public boolean isShareOil() {
|
||||
return "share_oil".equals(optType);
|
||||
}
|
||||
|
||||
public boolean isAddOil() {
|
||||
return "add_oil".equals(optType);
|
||||
}
|
||||
|
||||
public boolean isRefund() {
|
||||
return "refund".equals(optType);
|
||||
}
|
||||
|
||||
@Override
|
||||
public String toString() {
|
||||
return "Notify{verified=" + verified + ", optType=" + optType + ", oilCardNo=" + oilCardNo
|
||||
+ ", shareNo=" + shareNo + ", amount=" + amount + ", orderAmount=" + orderAmount + "}";
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,121 @@
|
||||
package com.hmb.openapi;
|
||||
|
||||
import com.fasterxml.jackson.databind.JsonNode;
|
||||
|
||||
/**
|
||||
* 平台响应。
|
||||
*
|
||||
* <p>{@code code=00} 表示成功,其余为失败,失败时看 {@code errCode / errMessage}。
|
||||
* 常见错误码:
|
||||
* <ul>
|
||||
* <li>0001 缺少必填请求参数(如未传 idCard / plateNo)</li>
|
||||
* <li>0012 请求流水号重复(requestId 必须唯一)</li>
|
||||
* <li>0013 请求参数不正确(格式校验未过)</li>
|
||||
* <li>0014 orgNo 与 appKey 不匹配</li>
|
||||
* <li>0006 签名验证不通过</li>
|
||||
* <li>2023 销售平台订单号重复且原单发放失败,请换新单号</li>
|
||||
* <li>2028 账户余额不足</li>
|
||||
* <li>2036 未找到对应的分油记录</li>
|
||||
* <li>2048 退单状态异常(不满足退款前置条件,如已全部加油)</li>
|
||||
* <li>2049 调用上游退款失败</li>
|
||||
* </ul>
|
||||
*/
|
||||
public class HmbResponse {
|
||||
|
||||
private String code;
|
||||
private String message;
|
||||
private String errCode;
|
||||
private String errMessage;
|
||||
private JsonNode data;
|
||||
|
||||
/** 原始响应报文,排错时打印 */
|
||||
private String rawResponse;
|
||||
/** 本次请求实际用于加签与加密的 body,排错时和平台日志比对 */
|
||||
private String requestBodyJson;
|
||||
/** 本次请求的待签串,验签失败时和平台日志逐字符比对 */
|
||||
private String signData;
|
||||
|
||||
public boolean isSuccess() {
|
||||
return "00".equals(code);
|
||||
}
|
||||
|
||||
/** 取 data 下的字符串字段,不存在返回 null */
|
||||
public String dataText(String field) {
|
||||
if (data == null) {
|
||||
return null;
|
||||
}
|
||||
JsonNode v = data.get(field);
|
||||
return v == null || v.isNull() ? null : v.asText();
|
||||
}
|
||||
|
||||
@Override
|
||||
public String toString() {
|
||||
return "HmbResponse{code=" + code + ", message=" + message
|
||||
+ ", errCode=" + errCode + ", errMessage=" + errMessage
|
||||
+ ", data=" + data + "}";
|
||||
}
|
||||
|
||||
public String getCode() {
|
||||
return code;
|
||||
}
|
||||
|
||||
public void setCode(String code) {
|
||||
this.code = code;
|
||||
}
|
||||
|
||||
public String getMessage() {
|
||||
return message;
|
||||
}
|
||||
|
||||
public void setMessage(String message) {
|
||||
this.message = message;
|
||||
}
|
||||
|
||||
public String getErrCode() {
|
||||
return errCode;
|
||||
}
|
||||
|
||||
public void setErrCode(String errCode) {
|
||||
this.errCode = errCode;
|
||||
}
|
||||
|
||||
public String getErrMessage() {
|
||||
return errMessage;
|
||||
}
|
||||
|
||||
public void setErrMessage(String errMessage) {
|
||||
this.errMessage = errMessage;
|
||||
}
|
||||
|
||||
public JsonNode getData() {
|
||||
return data;
|
||||
}
|
||||
|
||||
public void setData(JsonNode data) {
|
||||
this.data = data;
|
||||
}
|
||||
|
||||
public String getRawResponse() {
|
||||
return rawResponse;
|
||||
}
|
||||
|
||||
public void setRawResponse(String rawResponse) {
|
||||
this.rawResponse = rawResponse;
|
||||
}
|
||||
|
||||
public String getRequestBodyJson() {
|
||||
return requestBodyJson;
|
||||
}
|
||||
|
||||
public void setRequestBodyJson(String requestBodyJson) {
|
||||
this.requestBodyJson = requestBodyJson;
|
||||
}
|
||||
|
||||
public String getSignData() {
|
||||
return signData;
|
||||
}
|
||||
|
||||
public void setSignData(String signData) {
|
||||
this.signData = signData;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,83 @@
|
||||
package com.hmb.openapi;
|
||||
|
||||
import java.util.Map;
|
||||
|
||||
/**
|
||||
* 按 PHP {@code json_encode($body, JSON_UNESCAPED_UNICODE)} 的口径序列化请求体。
|
||||
*
|
||||
* <p>为什么不能直接用 Jackson / Gson 生成待签的 body:
|
||||
* <ul>
|
||||
* <li>平台收到请求后会把 body 反序列化再<b>重新序列化</b>去验签,用的是 PHP 的
|
||||
* {@code json_encode($body, JSON_UNESCAPED_UNICODE)};</li>
|
||||
* <li>PHP 默认会把 {@code /} 转义成 {@code \/},而 Java 的 JSON 库不会 ——
|
||||
* 只要 body 里出现一个斜杠(比如回调地址、备注里的日期 2026/01/01),
|
||||
* 两边的字符串就对不上,验签直接失败;</li>
|
||||
* <li>中文必须原样输出(UTF-8),不能转成 \\uXXXX。</li>
|
||||
* </ul>
|
||||
*
|
||||
* <p>本类保证生成的字符串与 PHP 侧一致。字段顺序按传入的 Map 顺序(请用 LinkedHashMap)。
|
||||
* 所有业务字段都是字符串类型,够用且不会有浮点精度问题。
|
||||
*/
|
||||
public final class JsonPhp {
|
||||
|
||||
private JsonPhp() {
|
||||
}
|
||||
|
||||
public static String encode(Map<String, String> data) {
|
||||
StringBuilder sb = new StringBuilder("{");
|
||||
boolean first = true;
|
||||
for (Map.Entry<String, String> e : data.entrySet()) {
|
||||
if (e.getValue() == null) {
|
||||
continue;
|
||||
}
|
||||
if (!first) {
|
||||
sb.append(',');
|
||||
}
|
||||
first = false;
|
||||
sb.append('"').append(escape(e.getKey())).append("\":");
|
||||
sb.append('"').append(escape(e.getValue())).append('"');
|
||||
}
|
||||
return sb.append('}').toString();
|
||||
}
|
||||
|
||||
/** 与 PHP json_encode 的转义规则保持一致(含 / -> \/,中文保持原样) */
|
||||
private static String escape(String s) {
|
||||
StringBuilder sb = new StringBuilder(s.length() + 16);
|
||||
for (int i = 0; i < s.length(); i++) {
|
||||
char c = s.charAt(i);
|
||||
switch (c) {
|
||||
case '"':
|
||||
sb.append("\\\"");
|
||||
break;
|
||||
case '\\':
|
||||
sb.append("\\\\");
|
||||
break;
|
||||
case '/':
|
||||
sb.append("\\/");
|
||||
break;
|
||||
case '\b':
|
||||
sb.append("\\b");
|
||||
break;
|
||||
case '\f':
|
||||
sb.append("\\f");
|
||||
break;
|
||||
case '\n':
|
||||
sb.append("\\n");
|
||||
break;
|
||||
case '\r':
|
||||
sb.append("\\r");
|
||||
break;
|
||||
case '\t':
|
||||
sb.append("\\t");
|
||||
break;
|
||||
default:
|
||||
if (c < 0x20) {
|
||||
sb.append(String.format("\\u%04x", (int) c));
|
||||
} else {
|
||||
sb.append(c);
|
||||
}
|
||||
}
|
||||
}
|
||||
return sb.toString();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,82 @@
|
||||
package com.hmb.openapi.demo;
|
||||
|
||||
import com.hmb.openapi.HmbNotifyVerifier;
|
||||
import org.springframework.http.MediaType;
|
||||
import org.springframework.web.bind.annotation.PostMapping;
|
||||
import org.springframework.web.bind.annotation.RequestBody;
|
||||
import org.springframework.web.bind.annotation.RequestHeader;
|
||||
import org.springframework.web.bind.annotation.RestController;
|
||||
|
||||
import java.util.HashMap;
|
||||
import java.util.Map;
|
||||
|
||||
/**
|
||||
* 接收「油卡变动通知」的 Spring 示例。
|
||||
*
|
||||
* <p>把这个地址(形如 https://你的域名/hmb/notify)填到平台后台的合作商「回调地址」里。
|
||||
*
|
||||
* <p>三点注意:
|
||||
* <ul>
|
||||
* <li>平台推送的是 <b>application/json</b>,body 形如 {"key":"...","data":"..."},
|
||||
* 所以这里用 {@code @RequestBody String} 收原始字符串,不要用对象自动绑定;</li>
|
||||
* <li>验签不通过一律不要入账,直接返回失败即可;</li>
|
||||
* <li>成功必须返回 {@code {"code":"00","message":"请求成功"}},
|
||||
* 否则平台会按 60 秒 / 5 分钟 / 30 分钟退避重试 3 次。</li>
|
||||
* </ul>
|
||||
*/
|
||||
@RestController
|
||||
public class NotifyControllerDemo {
|
||||
|
||||
// 生产环境请从配置中心注入,不要硬编码
|
||||
private static final String PARTNER_PRIVATE_KEY = "MIIEvQIBADANBgkqhkiG9w0BAQEFAASCBKcwggSj...";
|
||||
private static final String PLATFORM_PUBLIC_KEY = "MIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEA...";
|
||||
|
||||
private final HmbNotifyVerifier verifier;
|
||||
|
||||
public NotifyControllerDemo() throws Exception {
|
||||
this.verifier = new HmbNotifyVerifier(PARTNER_PRIVATE_KEY, PLATFORM_PUBLIC_KEY);
|
||||
}
|
||||
|
||||
@PostMapping(value = "/hmb/notify", produces = MediaType.APPLICATION_JSON_VALUE)
|
||||
public Map<String, String> notify(@RequestHeader Map<String, String> headers,
|
||||
@RequestBody String rawBody) {
|
||||
try {
|
||||
HmbNotifyVerifier.Notify n = verifier.parse(headers, rawBody);
|
||||
if (!n.verified) {
|
||||
return fail("验签不通过");
|
||||
}
|
||||
|
||||
if (n.isShareOil()) {
|
||||
// 分油成功:amount = 分油金额,可据此把你方订单标记为已发放
|
||||
// TODO 你的业务处理
|
||||
} else if (n.isAddOil()) {
|
||||
// 司机加油:一张订单可分多次加油,每笔单独推送一次。
|
||||
// amount = 本次加油占用金额,orderAmount = 订单总金额,
|
||||
// shareNo = 企油通消费订单号,同一订单每笔不同 —— 用它做幂等去重。
|
||||
// TODO 你的业务处理(务必按 shareNo 判重,平台重推时不要重复入账)
|
||||
} else if (n.isRefund()) {
|
||||
// 退款:amount = 退款金额(剩余未加油金额)
|
||||
// TODO 你的业务处理
|
||||
}
|
||||
|
||||
return ok();
|
||||
} catch (Exception e) {
|
||||
// 处理异常时返回失败,平台会重试,便于你排查后自动补推
|
||||
return fail(e.getMessage());
|
||||
}
|
||||
}
|
||||
|
||||
private Map<String, String> ok() {
|
||||
Map<String, String> r = new HashMap<>();
|
||||
r.put("code", "00");
|
||||
r.put("message", "请求成功");
|
||||
return r;
|
||||
}
|
||||
|
||||
private Map<String, String> fail(String msg) {
|
||||
Map<String, String> r = new HashMap<>();
|
||||
r.put("code", "99");
|
||||
r.put("message", msg);
|
||||
return r;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,75 @@
|
||||
package com.hmb.openapi.demo;
|
||||
|
||||
import com.hmb.openapi.HmbApiClient;
|
||||
import com.hmb.openapi.HmbConfig;
|
||||
import com.hmb.openapi.HmbResponse;
|
||||
|
||||
/**
|
||||
* 下单分油 + 查状态 的最小可运行示例。
|
||||
*
|
||||
* <p>运行前先把下面 5 个配置换成平台分配给你的值:
|
||||
* <pre>
|
||||
* mvn -q compile exec:java -Dexec.mainClass=com.hmb.openapi.demo.ShareDemo
|
||||
* </pre>
|
||||
*/
|
||||
public class ShareDemo {
|
||||
|
||||
// ====== 改成平台分配给你的值 ======
|
||||
private static final String BASE_URL = "http://47.104.73.7";
|
||||
private static final String APP_KEY = "你的appKey";
|
||||
private static final String ORG_NO = "你的公司编号";
|
||||
/** 你方 RSA 私钥:PKCS#8,一行 base64,去掉 BEGIN/END 与换行 */
|
||||
private static final String PARTNER_PRIVATE_KEY = "MIIEvQIBADANBgkqhkiG9w0BAQEFAASCBKcwggSj...";
|
||||
/** 平台公钥:X.509,一行 base64 */
|
||||
private static final String PLATFORM_PUBLIC_KEY = "MIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEA...";
|
||||
|
||||
public static void main(String[] args) throws Exception {
|
||||
HmbConfig config = new HmbConfig(BASE_URL, APP_KEY, ORG_NO, PARTNER_PRIVATE_KEY, PLATFORM_PUBLIC_KEY);
|
||||
HmbApiClient client = new HmbApiClient(config);
|
||||
|
||||
// 你方内部销售单号:同一单号重复提交会返回原订单(幂等),不会重复发放
|
||||
String saleOrderNo = "TEST" + System.currentTimeMillis();
|
||||
|
||||
// ---------- 1. 下单分油 ----------
|
||||
HmbResponse share = client.share(
|
||||
"100.00", // 分油金额(元)
|
||||
"13800000001", // 司机手机号
|
||||
"张三", // 司机姓名(必填)
|
||||
"450103199001011238", // 司机身份证号(必填,18位)
|
||||
"桂A12345", // 车牌号(必填)
|
||||
saleOrderNo);
|
||||
|
||||
System.out.println("=== 分油 ===");
|
||||
System.out.println("待签串 : " + share.getSignData());
|
||||
System.out.println("请求体 : " + share.getRequestBodyJson());
|
||||
System.out.println("响应 : " + share.getRawResponse());
|
||||
|
||||
if (!share.isSuccess()) {
|
||||
System.out.println("分油失败:" + share.getErrCode() + " " + share.getErrMessage());
|
||||
return;
|
||||
}
|
||||
String oilCardNo = share.dataText("oilCardNo");
|
||||
System.out.println("油卡编号:" + oilCardNo + ",金额:" + share.dataText("amount"));
|
||||
|
||||
// ---------- 2. 查订单状态 ----------
|
||||
// 发放是异步的:分油成功只代表受理,oil_status_zsy=1 表示发放中,=2 才是真正到账。
|
||||
// 结果也会通过「油卡变动通知」推给你,不必轮询太频繁。
|
||||
Thread.sleep(3000);
|
||||
HmbResponse status = client.orderStatus(oilCardNo);
|
||||
System.out.println("\n=== 订单状态 ===");
|
||||
System.out.println(status.getRawResponse());
|
||||
System.out.println("发放状态 oil_status_zsy=" + status.dataText("oil_status_zsy")
|
||||
+ "(0未提交 1发放中 2发放成功 3发放失败)");
|
||||
System.out.println("已加油 " + status.dataText("used_amount")
|
||||
+ " 元,剩余 " + status.dataText("remain_amount") + " 元");
|
||||
|
||||
// ---------- 3. 获取司机免登加油地址(可选) ----------
|
||||
HmbResponse qr = client.payQrCode(oilCardNo, "13800000001");
|
||||
System.out.println("\n=== 加油地址 ===");
|
||||
System.out.println(qr.isSuccess() ? qr.dataText("qrCode") : qr.getRawResponse());
|
||||
|
||||
// ---------- 4. 退款(可选,退剩余未加油金额) ----------
|
||||
// HmbResponse refund = client.refund(oilCardNo, "司机取消运单");
|
||||
// System.out.println(refund.getRawResponse());
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user