1、合作商余额查询
2、分油账户司机可用额度
This commit is contained in:
@@ -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