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
@@ -94,6 +94,7 @@
<div slot="driver" slot-scope="item">
<p class="mtb-2">{{ item.driver_name || '-' }}</p>
<p class="mtb-2 f-13">{{ item.mobile }}</p>
<p class="mtb-2 f-13">车牌:{{ item.plate_no || '-' }}</p>
</div>
<div slot="qyt" slot-scope="item">
<p class="mtb-2 f-13">批次:{{ item.batch_no || '-' }}</p>
BIN
View File
Binary file not shown.
+278
View File
@@ -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。
+47
View File
@@ -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 用 &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());
}
}
+139
View File
@@ -90,6 +90,8 @@ class OilCard extends Controller
$driverName = trim((string)($data['name'] ?? ''));
// 企油通:司机身份证号。两个支付通道都要求实名,必传
$idCard = strtoupper(trim((string)($data['idCard'] ?? '')));
// 车牌号:下单平台必传,仅本地留存与后台展示,不上送企油通
$plateNo = strtoupper(trim((string)($data['plateNo'] ?? '')));
// 请求参数orgNo与请求头appKey不存在对应关系
if($partners['orgNo'] != $orgNo){
return $this->oilRenderError('12','0014');
@@ -113,6 +115,14 @@ class OilCard extends Controller
if(!preg_match('/^\d{17}[\dX]$/', $idCard)){
return $this->oilRenderError('12','0013', '', 'idCard 身份证号格式不正确(18位)');
}
// 车牌号:必传,兼容普通车牌(7位)与新能源车牌(8位)
if($plateNo === ''){
return $this->oilRenderError('12','0001', '', '缺少必填请求参数 plateNo(车牌号)');
}
// 省份简称 + 城市字母(不含 I/O,避免与 1/0 混淆) + 5~7 位序号(含新能源 8 位车牌)
if(!preg_match('/^[京津沪渝冀豫云辽黑湘皖鲁新苏浙赣鄂桂甘晋蒙陕吉闽贵粤青藏川宁琼使领][A-HJ-NP-Z][A-HJ-NP-Z0-9]{4,6}$/u', $plateNo)){
return $this->oilRenderError('12','0013', '', 'plateNo 车牌号格式不正确');
}
// 金额:由下单平台直接传入(单位:元,最多两位小数)
if($amount === '' || !preg_match('/^\d{1,8}(\.\d{1,2})?$/', (string)$amount) || (float)$amount <= 0){
return $this->oilRenderError('12','0013', '', 'amount 金额格式不正确(单位:元,最多两位小数且大于0)');
@@ -141,6 +151,7 @@ class OilCard extends Controller
'mobile' => $mobile,
'driver_name' => $driverName,
'id_card' => $idCard,
'plate_no' => $plateNo,
// 企业与通道随单固化:之后改绑定或切默认企业,在途单仍按下单时的企业发放
'qyt_enterprise_id' => $enterprise['enterprise_id'],
'passageway' => $enterprise['passageway'],
@@ -266,6 +277,134 @@ class OilCard extends Controller
}
}
/**
* 合作商余额查询
* 查的是我方账户余额(下单时按分油金额实时扣减,发放失败/退款原路回补)
* @return Json
*/
public function balance()
{
$headers = Request::header();
$data = json_decode(Request::instance()->getContent(), true);
$appKey = $headers['appkey'] ?? '';
$requestId = $headers['requestid'] ?? '';
$method = $headers['method'] ?? '';
if($method != 'syb.oil.account.balance'){
return $this->oilRenderError('12', '0005');
}
try {
Db::name('request_log')->insert([
'request_id' => $requestId,
'app_key' => $appKey,
'create_time' => time()
]);
} catch (\think\Exception $e) {
if (strpos($e->getMessage(), 'Duplicate entry') !== false) {
return $this->oilRenderError('12', '0012');
}
return $this->oilRenderError('99', '9999');
}
$partners = PartnersModel::detail(['appKey' => $appKey]);
if(!$partners){
return $this->oilRenderError('02','0004');
}
$crypto = new RsaUtil($partners);
$result = $crypto->processResponse(['header'=>$headers,'encryptData'=>$data]);
if ($result['code'] !== '00') {
return $this->oilRenderError($result['code'], $result['errCode'], '', $result['errMessage'] ?? '');
}
if($partners['orgNo'] != ($result['data']['orgNo'] ?? '')){
return $this->oilRenderError('12','0014');
}
$balance = PartnersBalance::balance((int)$partners['partners_id']);
return $this->oilRenderSuccess([
'orgNo' => $partners['orgNo'],
'balance' => number_format($balance, 2, '.', ''),
'queryTime' => date('Y-m-d H:i:s'),
]);
}
/**
* 分油账户司机可用额度
* 查的是司机在上游(企油通)的油费钱包:按本合作商绑定的企业维度统计
* @return Json
*/
public function driverQuota()
{
$headers = Request::header();
$data = json_decode(Request::instance()->getContent(), true);
$appKey = $headers['appkey'] ?? '';
$requestId = $headers['requestid'] ?? '';
$method = $headers['method'] ?? '';
if($method != 'syb.oil.driver.quota'){
return $this->oilRenderError('12', '0005');
}
try {
Db::name('request_log')->insert([
'request_id' => $requestId,
'app_key' => $appKey,
'create_time' => time()
]);
} catch (\think\Exception $e) {
if (strpos($e->getMessage(), 'Duplicate entry') !== false) {
return $this->oilRenderError('12', '0012');
}
return $this->oilRenderError('99', '9999');
}
$partners = PartnersModel::detail(['appKey' => $appKey]);
if(!$partners){
return $this->oilRenderError('02','0004');
}
$crypto = new RsaUtil($partners);
$result = $crypto->processResponse(['header'=>$headers,'encryptData'=>$data]);
if ($result['code'] !== '00') {
return $this->oilRenderError($result['code'], $result['errCode'], '', $result['errMessage'] ?? '');
}
$body = $result['data'];
if($partners['orgNo'] != ($body['orgNo'] ?? '')){
return $this->oilRenderError('12','0014');
}
$mobile = trim((string)($body['mobile'] ?? ''));
$idCard = strtoupper(trim((string)($body['idCard'] ?? '')));
if(!preg_match('/^1[3-9]\d{9}$/', $mobile)){
return $this->oilRenderError('12','0013', '', '手机号格式不正确');
}
// 上游按「手机号 + 身份证号」定位司机,两个都必传
if($idCard === ''){
return $this->oilRenderError('12','0001', '', '缺少必填请求参数 idCard(司机身份证号)');
}
if(!preg_match('/^\d{17}[\dX]$/', $idCard)){
return $this->oilRenderError('12','0013', '', 'idCard 身份证号格式不正确(18位)');
}
$enterprise = EnterpriseModel::resolve((int)$partners['qyt_enterprise_id']);
if(!$enterprise){
return $this->oilRenderError('01','2052', '', '系统未配置可用企业,请联系平台');
}
$res = QytOil::forEnterprise($enterprise)->driverBalanceQuery($mobile, $idCard, 1, 20);
if($res['code'] !== '00'){
return $this->oilRenderError('01','2052', '', $res['message'] ?: '司机额度查询失败');
}
// 上游金额单位是「分」,统一转成「元」返回
$upstream = (array)$res['data'];
$list = [];
foreach ((array)($upstream['list'] ?? []) as $row) {
$list[] = [
'enterpriseName' => (string)($row['enterprise_name'] ?? ''),
'totalAmount' => QytOil::fenToYuan((int)($row['total_amount'] ?? 0)),
'availableAmount' => QytOil::fenToYuan((int)($row['available_amount'] ?? 0)),
];
}
return $this->oilRenderSuccess([
'mobile' => $mobile,
'totalGrantAmount' => QytOil::fenToYuan((int)($upstream['totalGrantAmount'] ?? 0)),
'availableAmount' => QytOil::fenToYuan((int)($upstream['availableBalance'] ?? 0)),
'list' => $list,
]);
}
public static function createOrderNo(): string
{
// 获取当前时间戳到毫秒(13位)
+199
View File
@@ -0,0 +1,199 @@
<?php
// +----------------------------------------------------------------------
// | 模拟企油通 7.10「运单加油明细推送」 —— 联调 / 补推用(CLI)
// +----------------------------------------------------------------------
// | 按企油通的真实口径推送:multipart/form-data,业务参数放在表单字段 params 里。
// |
// | 用法(在站点根目录执行):
// | php deploy/qyt_consume_push.php --order=6 --amount=100
// | php deploy/qyt_consume_push.php --code=HMCTEST20260914233556192 --amount=100
// | php deploy/qyt_consume_push.php --order=6 --amount=30 --dry-run # 只打印报文不发送
// |
// | 常用参数:
// | --order=订单ID 从数据库取运单号等信息(推荐)
// | --code=运单唯一流水 不查库直接指定 waybill_no_code
// | --amount=金额(元) 本次加油占用金额,默认取订单剩余未加油金额
// | --order-code=消费单号 去重键,默认自动生成;想测【重复推送】就填上次用过的
// | --station=加油站名 默认「模拟加油站」
// | --url=回调地址 默认取 .env 的 QYT.CONSUME_NOTIFY_URL
// | --dry-run 只打印,不实际发送
// |
// | ⚠️ 这会写入真实的加油流水、累计订单已加油金额、并触发给下单平台的加油通知,
// | 只用于联调环境。联调结束请删除本文件。
// +----------------------------------------------------------------------
declare (strict_types=1);
if (PHP_SAPI !== 'cli') {
http_response_code(403);
exit('仅限命令行执行');
}
$root = dirname(__DIR__);
// ---------------- 参数解析 ----------------
$opt = [];
foreach (array_slice($argv, 1) as $arg) {
if (preg_match('/^--([a-zA-Z0-9\-_]+)(?:=(.*))?$/', $arg, $m)) {
$opt[str_replace('-', '_', $m[1])] = $m[2] ?? '1';
}
}
$orderId = isset($opt['order']) ? (int) $opt['order'] : 0;
$code = isset($opt['code']) ? trim((string) $opt['code']) : '';
$dryRun = !empty($opt['dry_run']);
$station = isset($opt['station']) ? (string) $opt['station'] : '模拟加油站';
$orderCode = isset($opt['order_code']) ? trim((string) $opt['order_code']) : '';
if ($orderId <= 0 && $code === '') {
exit("用法:php deploy/qyt_consume_push.php --order=<订单ID> [--amount=金额] [--dry-run]\n"
. " php deploy/qyt_consume_push.php --code=<运单唯一流水> --amount=金额\n"
. "详细参数见本文件头部注释。\n");
}
// ---------------- 读取 .env ----------------
$ini = @parse_ini_file($root . '/.env', true, INI_SCANNER_RAW) ?: [];
$db = $ini['DATABASE'] ?? [];
$qyt = $ini['QYT'] ?? [];
$url = isset($opt['url']) ? (string) $opt['url'] : trim((string) ($qyt['CONSUME_NOTIFY_URL'] ?? ''));
if ($url === '') {
$url = 'http://127.0.0.1/qytconsume.php';
}
// ---------------- 取订单(可选) ----------------
$pdo = null;
$order = null;
try {
$pdo = new PDO(
'mysql:host=' . ($db['HOSTNAME'] ?? '127.0.0.1') . ';port=' . ($db['HOSTPORT'] ?? '3306')
. ';dbname=' . ($db['DATABASE'] ?? 'oil') . ';charset=' . ($db['CHARSET'] ?? 'utf8'),
$db['USERNAME'] ?? 'root', $db['PASSWORD'] ?? '',
[PDO::ATTR_ERRMODE => PDO::ERRMODE_EXCEPTION, PDO::ATTR_DEFAULT_FETCH_MODE => PDO::FETCH_ASSOC]
);
} catch (Throwable $e) {
echo "提示:连不上数据库({$e->getMessage()}),将只发送推送、不做前后对比\n\n";
}
if ($pdo) {
$sql = $orderId > 0
? 'SELECT * FROM yoshop_partners_order WHERE order_id = ? LIMIT 1'
: 'SELECT * FROM yoshop_partners_order WHERE waybill_no_code = ? LIMIT 1';
$st = $pdo->prepare($sql);
$st->execute([$orderId > 0 ? $orderId : $code]);
$order = $st->fetch() ?: null;
if (!$order && $orderId > 0) {
exit("未找到订单 order_id={$orderId}\n");
}
}
if ($order) {
$code = (string) $order['waybill_no_code'];
if ($code === '') {
exit("订单 {$order['order_id']} 还没有运单流水号(waybill_no_code 为空),说明 7.7 还没成功下发,不能模拟加油\n");
}
}
if ($code === '') {
exit("缺少 waybill_no_code:请用 --code 指定,或用 --order 指定订单\n");
}
// ---------------- 金额 ----------------
$remain = $order ? round((float) $order['total_price'] - (float) $order['used_amount'], 2) : 0.0;
$amount = isset($opt['amount']) ? round((float) $opt['amount'], 2) : $remain;
if ($amount <= 0) {
exit("加油金额必须大于 0(订单剩余未加油金额为 " . number_format($remain, 2) . " 元,可用 --amount 指定)\n");
}
if ($order && $amount > $remain + 0.005) {
echo "⚠️ 本次金额 {$amount} 元 超过订单剩余未加油金额 " . number_format($remain, 2) . " 元,"
. "服务端会照常入账并写超额告警日志\n";
}
// ---------------- 组装报文(字段与 7.10 文档一致) ----------------
$fen = (int) round($amount * 100);
$item = [
'waybill_no' => $order['waybill_no'] ?? ('SIMW' . $code),
'order_code' => $orderCode !== '' ? $orderCode : ('SIM' . date('YmdHis') . mt_rand(1000, 9999)),
'waybill_no_code' => $code,
'quota_id' => $order['qyt_order_code'] ?? ('SIMQ' . mt_rand(100000, 999999)),
'passageway' => ($order['passageway'] ?? 'alipay') === 'pingan' ? 2 : 1,
'station_code' => '',
'station_name' => $station,
'pay_fee' => $fen,
'pay_time' => date('Y-m-d H:i:s'),
'consume_amount' => $fen,
'litter' => isset($opt['litter']) ? (string) $opt['litter'] : '',
'oil_name' => isset($opt['oil_name']) ? (string) $opt['oil_name'] : '',
'oil_price' => '',
'actual_oil_price' => '',
'scene' => 'oil',
];
$params = json_encode(['order_list' => [$item]], JSON_UNESCAPED_UNICODE);
echo "回调地址:{$url}\n";
if ($order) {
printf("订单:order_id=%s 油卡号=%s 金额=%s 已加油=%s 剩余=%s 加油次数=%s\n",
$order['order_id'], $order['oilCardNo'], $order['total_price'], $order['used_amount'],
number_format($remain, 2), $order['used_num']);
}
echo "本次加油:{$amount} 元({$fen} 分) 消费单号(去重键):{$item['order_code']}\n";
echo "params:{$params}\n\n";
if ($dryRun) {
echo "--dry-run:未发送\n";
exit;
}
// ---------------- 发送:multipart/form-data,params 放表单字段 ----------------
$ch = curl_init();
curl_setopt_array($ch, [
CURLOPT_URL => $url,
CURLOPT_POST => true,
// 传数组 => cURL 自动用 multipart/form-data 编码,与企油通口径一致
CURLOPT_POSTFIELDS => ['params' => $params],
CURLOPT_RETURNTRANSFER => true,
CURLOPT_TIMEOUT => 30,
CURLOPT_SSL_VERIFYPEER => false,
CURLOPT_SSL_VERIFYHOST => false,
]);
$resp = curl_exec($ch);
$http = (int) curl_getinfo($ch, CURLINFO_HTTP_CODE);
$err = curl_error($ch);
curl_close($ch);
echo "HTTP {$http}\n响应:" . ($resp === false ? "cURL 失败:{$err}" : $resp) . "\n";
$ok = is_string($resp) && strpos($resp, '"100"') !== false;
echo $ok ? "接收端已受理(code=100)\n" : "⚠️ 接收端未返回 code=100,请查 runtime/storelog 下当天的 qyt_*.txt\n";
// ---------------- 等队列处理并对比结果 ----------------
if (!$ok || !$pdo || !$order) {
echo "\n提示:真正的落库与订单累计由 callback:queue 进程完成,请确认该进程在运行。\n";
exit;
}
echo "\n等待 callback:queue 处理";
$after = null;
for ($i = 0; $i < 10; $i++) {
sleep(1);
echo '.';
$st = $pdo->prepare('SELECT used_amount, used_num, use_status, refund_status FROM yoshop_partners_order WHERE order_id = ?');
$st->execute([$order['order_id']]);
$after = $st->fetch();
if ($after && (float) $after['used_amount'] > (float) $order['used_amount']) {
break;
}
}
echo "\n";
if ($after && (float) $after['used_amount'] > (float) $order['used_amount']) {
printf("✅ 已入账:已加油 %s -> %s,加油次数 %s -> %s,加油状态=%s\n",
$order['used_amount'], $after['used_amount'], $order['used_num'], $after['used_num'],
(int) $after['use_status'] === 1 ? '已加油' : '未加油');
$st = $pdo->prepare('SELECT id, order_code, consume_amount, notify_status FROM yoshop_qyt_consume WHERE order_id = ? ORDER BY id DESC LIMIT 3');
$st->execute([$order['order_id']]);
echo "最近消费流水:\n";
foreach ($st->fetchAll() as $r) {
printf(" #%s %s %s元 通知状态=%s\n", $r['id'], $r['order_code'],
number_format($r['consume_amount'] / 100, 2), $r['notify_status'] ? '已通知' : '待通知');
}
} else {
echo "⚠️ 10 秒内订单没有变化,可能原因:\n"
. " 1) callback:queue 进程没在跑(supervisorctl status 看一下)\n"
. " 2) 该消费单号已存在,被唯一键判为重复推送(换一个 --order-code 再试)\n"
. " 3) 处理异常,查 runtime/storelog 下当天的 " . date('YmdH') . ".txt\n";
}
+176
View File
@@ -0,0 +1,176 @@
<?php
// +----------------------------------------------------------------------
// | 补推企油通 7.10 加油明细(3 条真实推送原文,无需任何参数)
// +----------------------------------------------------------------------
// | 用法(在站点根目录执行):
// | php deploy/qyt_consume_replay.php
// |
// | 这 3 条是企油通实际推过、但因解析 bug 被我方静默忽略的报文。
// | 报文内容原样保留(order_code / pay_time / quota_id 都是真实值),
// | 因此重复执行会被 uk_consume 唯一键拦掉,不会重复累计金额。
// |
// | 推送口径与企油通一致:multipart/form-data,业务参数放表单字段 params。
// |
// | ⚠️ 会写入真实加油流水、累计订单已加油金额、并触发给下单平台的加油通知。
// | 执行前请确认:① QytNotify.php 的解析修复已部署 ② callback:queue 进程在跑。
// | 联调结束请删除本文件。
// +----------------------------------------------------------------------
declare (strict_types=1);
if (PHP_SAPI !== 'cli') {
http_response_code(403);
exit('仅限命令行执行');
}
$root = dirname(__DIR__);
// ---------------- 3 条推送原文(原样,未做任何改动) ----------------
$payloads = [];
$payloads[] = <<<'JSON'
{"order_list":[{"waybill_no":"HMWTEST20260914233556192","order_code":"2026091823001406181403169153","waybill_no_code":"HMCTEST20260914233556192","quota_id":"2026091000152611910010552563","passageway":1,"station_code":"","station_name":"中国石油北郊加油站","pay_fee":10000,"pay_time":"2026-09-18 14:25:02","consume_amount":10000,"litter":"","oil_name":"","oil_price":"","actual_oil_price":"","scene":"oil"}]}
JSON;
$payloads[] = <<<'JSON'
{"order_list":[{"waybill_no":"HMWTEST20260917171322418","order_code":"2026091823001490451444496549","waybill_no_code":"HMCTEST20260917171322418","quota_id":"2026091700152611350010049057","passageway":1,"station_code":"","station_name":"中国石化湾边加油站","pay_fee":10000,"pay_time":"2026-09-18 15:32:32","consume_amount":10000,"litter":"","oil_name":"","oil_price":"","actual_oil_price":"","scene":"oil"}]}
JSON;
$payloads[] = <<<'JSON'
{"order_list":[{"waybill_no":"HMWTEST20260917171328296","order_code":"2026091823001490451451416403","waybill_no_code":"HMCTEST20260917171328296","quota_id":"2026091700152611350010049057","passageway":1,"station_code":"","station_name":"中国石化湾边加油站","pay_fee":10000,"pay_time":"2026-09-18 15:32:53","consume_amount":10000,"litter":"","oil_name":"","oil_price":"","actual_oil_price":"","scene":"oil"}]}
JSON;
// ---------------- 回调地址 & 数据库(都取 .env) ----------------
$ini = @parse_ini_file($root . '/.env', true, INI_SCANNER_RAW) ?: [];
$url = trim((string) ($ini['QYT']['CONSUME_NOTIFY_URL'] ?? ''));
if ($url === '') {
$url = 'http://127.0.0.1/qytconsume.php';
}
$pdo = null;
try {
$db = $ini['DATABASE'] ?? [];
$pdo = new PDO(
'mysql:host=' . ($db['HOSTNAME'] ?? '127.0.0.1') . ';port=' . ($db['HOSTPORT'] ?? '3306')
. ';dbname=' . ($db['DATABASE'] ?? 'oil') . ';charset=' . ($db['CHARSET'] ?? 'utf8'),
$db['USERNAME'] ?? 'root', $db['PASSWORD'] ?? '',
[PDO::ATTR_ERRMODE => PDO::ERRMODE_EXCEPTION, PDO::ATTR_DEFAULT_FETCH_MODE => PDO::FETCH_ASSOC]
);
} catch (Throwable $e) {
echo "提示:连不上数据库({$e->getMessage()}),只发送推送、不做前后对比\n";
}
/** 按 waybill_no_code 取订单快照 */
function snap(?PDO $pdo, string $code): ?array
{
if (!$pdo) {
return null;
}
$st = $pdo->prepare('SELECT order_id, oilCardNo, total_price, used_amount, used_num, use_status
FROM yoshop_partners_order WHERE waybill_no_code = ? LIMIT 1');
$st->execute([$code]);
return $st->fetch() ?: null;
}
echo "回调地址:{$url}\n";
echo "待补推:" . count($payloads) . " 条\n";
echo str_repeat('=', 66) . "\n\n";
$okCount = $failCount = 0;
foreach ($payloads as $i => $params) {
$no = $i + 1;
$item = json_decode($params, true)['order_list'][0];
$code = $item['waybill_no_code'];
$yuan = number_format($item['consume_amount'] / 100, 2);
echo "【{$no}/" . count($payloads) . "】运单流水 {$code}\n";
echo " 加油站:{$item['station_name']} 金额:{$yuan} 元 消费单号:{$item['order_code']}\n";
$before = snap($pdo, $code);
if ($pdo && !$before) {
echo " ⚠️ 本地找不到该运单流水对应的订单,推过去也会被判为「未匹配订单」,跳过\n\n";
$failCount++;
continue;
}
if ($before) {
printf(" 订单:order_id=%s 油卡号=%s 金额=%s 已加油=%s(%s 次)\n",
$before['order_id'], $before['oilCardNo'], $before['total_price'],
$before['used_amount'], $before['used_num']);
}
// 与企油通一致:multipart/form-data,params 放表单字段(cURL 传数组即为 multipart)
$ch = curl_init();
curl_setopt_array($ch, [
CURLOPT_URL => $url,
CURLOPT_POST => true,
CURLOPT_POSTFIELDS => ['params' => $params],
CURLOPT_RETURNTRANSFER => true,
CURLOPT_TIMEOUT => 30,
CURLOPT_SSL_VERIFYPEER => false,
CURLOPT_SSL_VERIFYHOST => false,
]);
$resp = curl_exec($ch);
$http = (int) curl_getinfo($ch, CURLINFO_HTTP_CODE);
$err = curl_error($ch);
curl_close($ch);
if ($resp === false) {
echo " ❌ cURL 失败:{$err}\n\n";
$failCount++;
continue;
}
echo " HTTP {$http} 响应:{$resp}\n";
if (strpos($resp, '"100"') === false) {
echo " ⚠️ 未返回 code=100,查 runtime/storelog 下当天的 qyt_*.txt\n\n";
$failCount++;
continue;
}
// 等 callback:queue 落库
if (!$before) {
echo " 已受理(无数据库连接,未做入账校验)\n\n";
$okCount++;
continue;
}
echo ' 等待 callback:queue 处理';
$after = null;
for ($t = 0; $t < 10; $t++) {
sleep(1);
echo '.';
$after = snap($pdo, $code);
if ($after && (float) $after['used_amount'] > (float) $before['used_amount']) {
break;
}
}
echo "\n";
if ($after && (float) $after['used_amount'] > (float) $before['used_amount']) {
printf(" ✅ 已入账:已加油 %s -> %s,次数 %s -> %s,剩余未加油 %s 元\n\n",
$before['used_amount'], $after['used_amount'], $before['used_num'], $after['used_num'],
number_format(max(0, (float) $after['total_price'] - (float) $after['used_amount']), 2));
$okCount++;
} else {
echo " ⚠️ 10 秒内订单没变化。可能原因:\n"
. " 1) callback:queue 进程没在跑(supervisorctl status)\n"
. " 2) 这条之前已经入账过,被唯一键判为重复推送(正常,不会重复累计)\n"
. " 3) 处理异常,查 runtime/storelog 下当天的 " . date('YmdH') . ".txt\n\n";
$failCount++;
}
}
echo str_repeat('=', 66) . "\n";
echo "完成:成功入账 {$okCount} 条,需关注 {$failCount} 条\n";
if ($pdo) {
echo "\n各订单当前状态:\n";
foreach ($payloads as $params) {
$code = json_decode($params, true)['order_list'][0]['waybill_no_code'];
$row = snap($pdo, $code);
if ($row) {
printf(" %s order_id=%s 金额=%s 已加油=%s(%s 次)加油状态=%s\n",
$code, $row['order_id'], $row['total_price'], $row['used_amount'], $row['used_num'],
(int) $row['use_status'] === 1 ? '已加油' : '未加油');
} else {
printf(" %s 本地无对应订单\n", $code);
}
}
}
+152
View File
@@ -0,0 +1,152 @@
<?php
// +----------------------------------------------------------------------
// | RSA 密钥自检 —— 排查下游报 errCode=0007「请求参数解析异常」(CLI)
// +----------------------------------------------------------------------
// | 0007 的实际含义是:我方用【平台私钥】解不开下游加密的 AES 密钥。
// | 报错里的「PEM routines:no start line」是 OpenSSL 错误队列的残留信息,
// | 具有误导性,真正原因几乎都是:下游加密时用的公钥 ≠ 我方平台私钥对应的公钥。
// |
// | 用法(站点根目录执行):
// | php deploy/rsa_check.php 自检密钥对 + 打印要发给下游的公钥
// | php deploy/rsa_check.php <appKey> 再检查该合作商的公钥(用于验签其请求)
// | php deploy/rsa_check.php <appKey> "<key密文>" 用请求里的 key 字段实测能否解开
// |
// | 其中「key密文」是下游请求体 {"key":"...","data":"..."} 里的 key 原值。
// +----------------------------------------------------------------------
declare (strict_types=1);
if (PHP_SAPI !== 'cli') {
http_response_code(403);
exit('仅限命令行执行');
}
$root = dirname(__DIR__);
$appKey = $argv[1] ?? '';
$encKey = $argv[2] ?? '';
function line(): void
{
echo str_repeat('-', 62) . "\n";
}
/** 取密钥指纹,便于和对方核对是不是同一把 */
function fingerprint($keyRes): string
{
$d = openssl_pkey_get_details($keyRes);
return isset($d['rsa']['n']) ? strtoupper(substr(md5($d['rsa']['n']), 0, 16)) : '(取不到)';
}
// ================================================================
// 1. 平台密钥对
// ================================================================
echo "【1】平台密钥对\n";
line();
$privPath = $root . '/rsa/private_key.pem';
$pubPath = $root . '/rsa/public_key.pem';
foreach ([$privPath, $pubPath] as $p) {
printf(" %-28s %s\n", basename($p), is_file($p) ? '存在(' . filesize($p) . ' 字节)' : '❌ 不存在');
}
if (!is_file($privPath) || !is_file($pubPath)) {
exit("\n❌ 密钥文件缺失,下游所有请求都会失败。\n");
}
$priv = openssl_pkey_get_private(file_get_contents($privPath));
$pub = openssl_pkey_get_public(file_get_contents($pubPath));
printf(" 平台私钥加载%s\n", $priv ? '成功' : '❌ 失败:' . openssl_error_string());
printf(" 平台公钥加载%s\n", $pub ? '成功' : '❌ 失败:' . openssl_error_string());
if (!$priv || !$pub) {
exit("\n❌ 密钥文件格式不对,请检查是否为完整 PEM(含 BEGIN/END 行)。\n");
}
$dPriv = openssl_pkey_get_details($priv);
$dPub = openssl_pkey_get_details($pub);
printf(" 位数:私钥 %d / 公钥 %d\n", $dPriv['bits'], $dPub['bits']);
$pairOk = $dPriv['key'] === $dPub['key'];
printf(" 两者是否同一对:%s\n", $pairOk ? '是' : '❌ 否(公私钥不配套,必须换成同一对)');
// 实测闭环
$aes = openssl_random_pseudo_bytes(32);
openssl_public_encrypt($aes, $enc, $pub, OPENSSL_PKCS1_PADDING);
$loopOk = openssl_private_decrypt($enc, $dec, $priv, OPENSSL_PKCS1_PADDING) && $dec === $aes;
printf(" 加密→解密闭环:%s\n", $loopOk ? '通过' : '❌ 失败');
printf(" 平台公钥指纹:%s\n", fingerprint($pub));
// ================================================================
// 2. 发给下游的公钥(他们必须用这一把加密 AES 密钥)
// ================================================================
echo "\n【2】请把下面这把【平台公钥】发给下游,让他们核对\n";
line();
echo trim(file_get_contents($pubPath)) . "\n\n";
echo "一行 base64(Java 可直接用):\n";
echo preg_replace('/\s+/', '', str_replace(['-----BEGIN PUBLIC KEY-----', '-----END PUBLIC KEY-----'], '', file_get_contents($pubPath))) . "\n";
// ================================================================
// 3. 合作商公钥(我方用它验签下游请求)
// ================================================================
if ($appKey !== '') {
echo "\n【3】合作商 {$appKey}\n";
line();
try {
$ini = @parse_ini_file($root . '/.env', true, INI_SCANNER_RAW) ?: [];
$db = $ini['DATABASE'] ?? [];
$pdo = new PDO(
'mysql:host=' . ($db['HOSTNAME'] ?? '127.0.0.1') . ';port=' . ($db['HOSTPORT'] ?? '3306')
. ';dbname=' . ($db['DATABASE'] ?? 'oil') . ';charset=' . ($db['CHARSET'] ?? 'utf8'),
$db['USERNAME'] ?? 'root', $db['PASSWORD'] ?? '',
[PDO::ATTR_ERRMODE => PDO::ERRMODE_EXCEPTION, PDO::ATTR_DEFAULT_FETCH_MODE => PDO::FETCH_ASSOC]
);
$st = $pdo->prepare('SELECT partners_id, name, orgNo, appKey, publicKey, privateKey FROM yoshop_partners WHERE appKey = ? LIMIT 1');
$st->execute([$appKey]);
$p = $st->fetch();
if (!$p) {
echo " ❌ 未找到该 appKey 的合作商\n";
} else {
printf(" 合作商:%s(partners_id=%s,orgNo=%s)\n", $p['name'], $p['partners_id'], $p['orgNo']);
$ppub = openssl_pkey_get_public((string) $p['publicKey']);
printf(" 合作商公钥:%s%s\n", $ppub ? '可加载' : '❌ 无法加载:' . openssl_error_string(),
$ppub ? ' 指纹 ' . fingerprint($ppub) : '');
echo " 用途:我方用它验签下游请求。下游必须用与之配套的私钥加签,否则报 0006。\n";
if (!empty($p['privateKey'])) {
$ppriv = openssl_pkey_get_private((string) $p['privateKey']);
if ($ppub && $ppriv) {
$dd = openssl_pkey_get_details($ppriv);
printf(" 库里另存的合作商私钥与其公钥是否配套:%s\n",
$dd['key'] === openssl_pkey_get_details($ppub)['key'] ? '是' : '❌ 否');
echo " (这把私钥是发给下游用的,请确认下游手里就是它)\n";
}
}
}
} catch (Throwable $e) {
echo ' 查询失败:' . $e->getMessage() . "\n";
}
}
// ================================================================
// 4. 用真实请求里的 key 密文实测
// ================================================================
if ($encKey !== '') {
echo "\n【4】实测解密下游请求里的 key\n";
line();
$raw = base64_decode($encKey, true);
if ($raw === false) {
echo " ❌ 不是合法 base64\n";
} else {
printf(" 密文长度:%d 字节(%d 位密钥应为 %d 字节)\n", strlen($raw), $dPriv['bits'], $dPriv['bits'] / 8);
while (openssl_error_string() !== false) {
// 先清空 OpenSSL 错误队列,避免读到别处残留的 PEM 报错
}
$ok = openssl_private_decrypt($raw, $out, $priv, OPENSSL_PKCS1_PADDING);
if ($ok) {
printf(" ✅ 解密成功,AES 密钥长度 %d 字节(应为 32)\n", strlen($out));
echo " 说明密钥是配套的,0007 的原因在别处,请把完整请求发来再看。\n";
} else {
echo " ❌ 解密失败:" . (openssl_error_string() ?: '无附加信息') . "\n";
echo " 结论:下游加密用的公钥,与我方平台私钥不是一对。\n";
echo " 让下游换成【2】里打印的那把平台公钥即可。\n";
}
}
}
echo "\n";
+25 -2
View File
@@ -321,6 +321,8 @@ function defaultBody($act, $orgNo)
'amount' => isset($_GET['amount']) ? trim($_GET['amount']) : '100.00',
'mobile' => $mobile,
'name' => isset($_GET['name']) ? trim($_GET['name']) : '张三',
// 车牌号:下单平台必传,我方留存展示,不上送企油通
'plateNo' => isset($_GET['plateNo']) ? trim($_GET['plateNo']) : '桂A12345',
// 销售单号留空则自动生成(同一单号重复提交会走幂等返回,便于测重复下单)
'saleOrderNo' => isset($_GET['saleOrderNo']) && trim($_GET['saleOrderNo']) !== '' ? trim($_GET['saleOrderNo']) : $saleOrderNo,
'oilCardType' => 'zsy_ticket',
@@ -362,6 +364,24 @@ function defaultBody($act, $orgNo)
],
];
}
if ($act === 'balance') {
return [
'method' => 'syb.oil.account.balance',
'path' => '/oilCard/balance',
'body' => ['orgNo' => $orgNo],
];
}
if ($act === 'quota') {
return [
'method' => 'syb.oil.driver.quota',
'path' => '/oilCard/driverQuota',
'body' => [
'orgNo' => $orgNo,
'mobile' => $mobile,
'idCard' => isset($_GET['idCard']) ? trim($_GET['idCard']) : '450103199001011238',
],
];
}
if ($act === 'sharelist') {
return [
'method' => 'syb.oil.oilCard.shareList',
@@ -396,7 +416,7 @@ try {
$platformPub = platformPublicKey($ROOT);
$qconf = qytConf($ROOT, $partner);
if (in_array($act, ['share', 'qrcode', 'status', 'sharelist', 'refund'], true)) {
if (in_array($act, ['share', 'qrcode', 'status', 'sharelist', 'refund', 'balance', 'quota'], true)) {
$def = defaultBody($act, $partner['orgNo']);
$env = buildEnvelope($partner, $platformPub, $def['method'], $def['body']);
$url = apiUrl($def['path']);
@@ -429,7 +449,7 @@ if (PHP_SAPI === 'cli') {
";
}
if (!$result) {
echo "用法:php qyt_test.php [share|qrcode|status|sharelist|refund](qrcode/status/refund 需带 oilCardNo,CLI 下请用浏览器方式)\n";
echo "用法:php qyt_test.php [share|qrcode|status|sharelist|refund|balance|quota](qrcode/status/refund 需带 oilCardNo,CLI 下请用浏览器方式)\n";
exit;
}
echo '接口:' . $result['def']['method'] . "\n";
@@ -561,6 +581,7 @@ $curOil = isset($_GET['oilCardNo']) ? $_GET['oilCardNo'] : '';
<label>司机手机号<input name="mobile" value="<?php echo $fv('mobile', '13800000001'); ?>" maxlength="11"></label>
<label>司机姓名<input name="name" value="<?php echo $fv('name', '张三'); ?>"></label>
<label>身份证号<input name="idCard" value="<?php echo $fv('idCard', '450103199001011238'); ?>" maxlength="18" placeholder="留空即不传"></label>
<label>车牌号<input name="plateNo" value="<?php echo $fv('plateNo', '桂A12345'); ?>" maxlength="8"></label>
<label>销售单号<input name="saleOrderNo" value="<?php echo $fv('saleOrderNo', ''); ?>" placeholder="留空自动生成"></label>
</div>
<button type="submit">① 下单分油 (share)</button>
@@ -571,6 +592,8 @@ $curOil = isset($_GET['oilCardNo']) ? $_GET['oilCardNo'] : '';
<a class="p" href="?act=status&amp;oilCardNo=<?php echo urlencode($curOil); ?>">③ 订单状态 (orderStatus)</a>
<a href="?act=sharelist&amp;mobile=<?php echo urlencode($isShare && isset($_GET['mobile']) ? $_GET['mobile'] : '13800000001'); ?>">④ 分油记录 (shareList)</a>
<a class="r" href="?act=refund&amp;oilCardNo=<?php echo urlencode($curOil); ?>">⑥ 退款 (refund)</a>
<a href="?act=balance">⑦ 合作商余额 (balance)</a>
<a href="?act=quota&amp;mobile=<?php echo urlencode($isShare && isset($_GET['mobile']) ? $_GET['mobile'] : '13800000001'); ?>&amp;idCard=<?php echo urlencode($isShare && isset($_GET['idCard']) ? $_GET['idCard'] : '450103199001011238'); ?>">⑧ 司机可用额度 (driverQuota)</a>
</div>
<div class="sub" style="margin:6px 0 0">
②③⑥ 需要 oilCardNo:先下单拿到后,在下方响应区点对应按钮会自动带上。
Binary file not shown.
File diff suppressed because one or more lines are too long
Binary file not shown.
+1 -1
View File
@@ -1 +1 @@
<!DOCTYPE html><html lang="zh-cmn-Hans"><head><meta charset="utf-8"><meta http-equiv="X-UA-Compatible" content="IE=edge"><meta name="viewport" content="width=device-width,initial-scale=1"><title>商家管理中心</title><style>#loading-mask{position:fixed;left:0;top:0;height:100%;width:100%;background:#fff;user-select:none;z-index:9999;overflow:hidden}.loading-wrapper{position:absolute;top:50%;left:50%;transform:translate(-50%,-100%)}.loading-dot{animation:antRotate 1.2s infinite linear;transform:rotate(45deg);position:relative;display:inline-block;font-size:64px;width:64px;height:64px;box-sizing:border-box}.loading-dot i{width:22px;height:22px;position:absolute;display:block;background-color:#1890ff;border-radius:100%;transform:scale(.75);transform-origin:50% 50%;opacity:.3;animation:antSpinMove 1s infinite linear alternate}.loading-dot i:nth-child(1){top:0;left:0}.loading-dot i:nth-child(2){top:0;right:0;-webkit-animation-delay:.4s;animation-delay:.4s}.loading-dot i:nth-child(3){right:0;bottom:0;-webkit-animation-delay:.8s;animation-delay:.8s}.loading-dot i:nth-child(4){bottom:0;left:0;-webkit-animation-delay:1.2s;animation-delay:1.2s}@keyframes antRotate{to{-webkit-transform:rotate(405deg);transform:rotate(405deg)}}@-webkit-keyframes antRotate{to{-webkit-transform:rotate(405deg);transform:rotate(405deg)}}@keyframes antSpinMove{to{opacity:1}}@-webkit-keyframes antSpinMove{to{opacity:1}}</style><link href="css/card.83fb7f11.css" rel="prefetch"><link href="css/card~content~goods~manage~market~order~page~setting~store~user.bd86e290.css" rel="prefetch"><link href="css/card~content~goods~manage~market~page~store~user.06d71c34.css" rel="prefetch"><link href="css/client.6fbe4ba8.css" rel="prefetch"><link href="css/content.c53a1699.css" rel="prefetch"><link href="css/exception.37940980.css" rel="prefetch"><link href="css/goods.5e999d2c.css" rel="prefetch"><link href="css/index.ff363719.css" rel="prefetch"><link href="css/market.cc3678af.css" rel="prefetch"><link href="css/order.6124dbc9.css" rel="prefetch"><link href="css/page.2d2d2056.css" rel="prefetch"><link href="css/passport.68f7ba8b.css" rel="prefetch"><link href="css/setting.37110db2.css" rel="prefetch"><link href="css/store.3d002fc4.css" rel="prefetch"><link href="css/user.21b3449e.css" rel="prefetch"><link href="js/card.c3d0e7b1.js" rel="prefetch"><link href="js/card~content~goods~manage~market~order~page~setting~store~user.193a5813.js" rel="prefetch"><link href="js/card~content~goods~manage~market~page~store~user.a13c007a.js" rel="prefetch"><link href="js/client.58c03cfb.js" rel="prefetch"><link href="js/content.fd60d4aa.js" rel="prefetch"><link href="js/exception.d3b6b1e8.js" rel="prefetch"><link href="js/goods.19f5e8e7.js" rel="prefetch"><link href="js/goods~order.d9a20aa1.js" rel="prefetch"><link href="js/index.874a9e0d.js" rel="prefetch"><link href="js/lang-zh-CN.303b0ef5.js" rel="prefetch"><link href="js/manage.3485dc4a.js" rel="prefetch"><link href="js/market.db25da7c.js" rel="prefetch"><link href="js/order.bc3eac19.js" rel="prefetch"><link href="js/page.24dbd519.js" rel="prefetch"><link href="js/passport.4036de85.js" rel="prefetch"><link href="js/setting.02914954.js" rel="prefetch"><link href="js/store.e6d3d371.js" rel="prefetch"><link href="js/user.dde70d3c.js" rel="prefetch"><link href="css/app.77281994.css" rel="preload" as="style"><link href="css/chunk-vendors.43940245.css" rel="preload" as="style"><link href="js/app.76ad6bca.js" rel="preload" as="script"><link href="js/chunk-vendors.d8af00dc.js" rel="preload" as="script"><link href="css/chunk-vendors.43940245.css" rel="stylesheet"><link href="css/app.77281994.css" rel="stylesheet"></head><body><noscript><strong>We're sorry but vue-antd-pro doesn't work properly without JavaScript enabled. Please enable it to continue.</strong></noscript><div id="app"><div id="loading-mask"><div class="loading-wrapper"><span class="loading-dot loading-dot-spin"><i></i><i></i><i></i><i></i></span></div></div></div><script src="config.js"></script><script src="js/chunk-vendors.d8af00dc.js"></script><script src="js/app.76ad6bca.js"></script></body></html>
<!DOCTYPE html><html lang="zh-cmn-Hans"><head><meta charset="utf-8"><meta http-equiv="X-UA-Compatible" content="IE=edge"><meta name="viewport" content="width=device-width,initial-scale=1"><title>商家管理中心</title><style>#loading-mask{position:fixed;left:0;top:0;height:100%;width:100%;background:#fff;user-select:none;z-index:9999;overflow:hidden}.loading-wrapper{position:absolute;top:50%;left:50%;transform:translate(-50%,-100%)}.loading-dot{animation:antRotate 1.2s infinite linear;transform:rotate(45deg);position:relative;display:inline-block;font-size:64px;width:64px;height:64px;box-sizing:border-box}.loading-dot i{width:22px;height:22px;position:absolute;display:block;background-color:#1890ff;border-radius:100%;transform:scale(.75);transform-origin:50% 50%;opacity:.3;animation:antSpinMove 1s infinite linear alternate}.loading-dot i:nth-child(1){top:0;left:0}.loading-dot i:nth-child(2){top:0;right:0;-webkit-animation-delay:.4s;animation-delay:.4s}.loading-dot i:nth-child(3){right:0;bottom:0;-webkit-animation-delay:.8s;animation-delay:.8s}.loading-dot i:nth-child(4){bottom:0;left:0;-webkit-animation-delay:1.2s;animation-delay:1.2s}@keyframes antRotate{to{-webkit-transform:rotate(405deg);transform:rotate(405deg)}}@-webkit-keyframes antRotate{to{-webkit-transform:rotate(405deg);transform:rotate(405deg)}}@keyframes antSpinMove{to{opacity:1}}@-webkit-keyframes antSpinMove{to{opacity:1}}</style><link href="css/card.83fb7f11.css" rel="prefetch"><link href="css/card~content~goods~manage~market~order~page~setting~store~user.bd86e290.css" rel="prefetch"><link href="css/card~content~goods~manage~market~page~store~user.06d71c34.css" rel="prefetch"><link href="css/client.6fbe4ba8.css" rel="prefetch"><link href="css/content.c53a1699.css" rel="prefetch"><link href="css/exception.37940980.css" rel="prefetch"><link href="css/goods.fa9f8ef6.css" rel="prefetch"><link href="css/index.ff363719.css" rel="prefetch"><link href="css/market.cc3678af.css" rel="prefetch"><link href="css/order.6124dbc9.css" rel="prefetch"><link href="css/page.2d2d2056.css" rel="prefetch"><link href="css/passport.68f7ba8b.css" rel="prefetch"><link href="css/setting.37110db2.css" rel="prefetch"><link href="css/store.3d002fc4.css" rel="prefetch"><link href="css/user.21b3449e.css" rel="prefetch"><link href="js/card.c3d0e7b1.js" rel="prefetch"><link href="js/card~content~goods~manage~market~order~page~setting~store~user.193a5813.js" rel="prefetch"><link href="js/card~content~goods~manage~market~page~store~user.a13c007a.js" rel="prefetch"><link href="js/client.58c03cfb.js" rel="prefetch"><link href="js/content.fd60d4aa.js" rel="prefetch"><link href="js/exception.d3b6b1e8.js" rel="prefetch"><link href="js/goods.8a836fa3.js" rel="prefetch"><link href="js/goods~order.d9a20aa1.js" rel="prefetch"><link href="js/index.874a9e0d.js" rel="prefetch"><link href="js/lang-zh-CN.303b0ef5.js" rel="prefetch"><link href="js/manage.3485dc4a.js" rel="prefetch"><link href="js/market.db25da7c.js" rel="prefetch"><link href="js/order.bc3eac19.js" rel="prefetch"><link href="js/page.24dbd519.js" rel="prefetch"><link href="js/passport.4036de85.js" rel="prefetch"><link href="js/setting.02914954.js" rel="prefetch"><link href="js/store.e6d3d371.js" rel="prefetch"><link href="js/user.dde70d3c.js" rel="prefetch"><link href="css/app.77281994.css" rel="preload" as="style"><link href="css/chunk-vendors.43940245.css" rel="preload" as="style"><link href="js/app.bcc9542b.js" rel="preload" as="script"><link href="js/chunk-vendors.d8af00dc.js" rel="preload" as="script"><link href="css/chunk-vendors.43940245.css" rel="stylesheet"><link href="css/app.77281994.css" rel="stylesheet"></head><body><noscript><strong>We're sorry but vue-antd-pro doesn't work properly without JavaScript enabled. Please enable it to continue.</strong></noscript><div id="app"><div id="loading-mask"><div class="loading-wrapper"><span class="loading-dot loading-dot-spin"><i></i><i></i><i></i><i></i></span></div></div></div><script src="config.js"></script><script src="js/chunk-vendors.d8af00dc.js"></script><script src="js/app.bcc9542b.js"></script></body></html>
Binary file not shown.
File diff suppressed because one or more lines are too long
Binary file not shown.
File diff suppressed because one or more lines are too long
Binary file not shown.
File diff suppressed because one or more lines are too long
Binary file not shown.
+14
View File
@@ -0,0 +1,14 @@
-- ============================================================
-- 下单分油新增「车牌号」
-- 生成:2026-09-20
-- ------------------------------------------------------------
-- 下单平台必传,存本地并在后台订单展示;⚠️ 不上送企油通(7.7 没有这个字段)。
-- 执行顺序:... -> qyt_migration_partial_fuel.sql -> 本文件
-- ============================================================
ALTER TABLE `yoshop_partners_order`
ADD COLUMN `plate_no` varchar(20) NOT NULL DEFAULT '' COMMENT '车牌号(下单平台必传,仅本地留存与展示,不上送企油通)' AFTER `id_card`,
ADD INDEX `idx_plate_no` (`plate_no`);
-- 回滚:
-- ALTER TABLE `yoshop_partners_order` DROP COLUMN `plate_no`, DROP INDEX `idx_plate_no`;
Binary file not shown.
Binary file not shown.
Binary file not shown.