1、合作商余额查询

2、分油账户司机可用额度
This commit is contained in:
2026-10-08 23:12:33 +08:00
parent ae94c1dc00
commit 45452c1799
32 changed files with 2024 additions and 6 deletions
@@ -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 用 &amp; 连接,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=..&amp;appKey=..&amp;method=..&amp;requestId=..&amp;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 的转义规则保持一致(含 / -&gt; \/,中文保持原样) */
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());
}
}