diff --git a/admin-vue/src/views/partners/order/Index.vue b/admin-vue/src/views/partners/order/Index.vue index 80368f2..3227351 100644 --- a/admin-vue/src/views/partners/order/Index.vue +++ b/admin-vue/src/views/partners/order/Index.vue @@ -94,6 +94,7 @@
{{ item.driver_name || '-' }}
{{ item.mobile }}
+车牌:{{ item.plate_no || '-' }}
批次:{{ item.batch_no || '-' }}
diff --git a/hmb-java-demo.zip b/hmb-java-demo.zip new file mode 100644 index 0000000..3d021d9 Binary files /dev/null and b/hmb-java-demo.zip differ diff --git a/hmb-java-demo/README.md b/hmb-java-demo/README.md new file mode 100644 index 0000000..08c9517 --- /dev/null +++ b/hmb-java-demo/README.md @@ -0,0 +1,278 @@ +# 慧明宝供应链平台 · Java 对接示例 + +对接方(下单平台)调用慧明宝接口的完整示例,与平台 PHP 端口径一一对应。 + +配套文档:《慧明宝供应链平台接口文档 V1.0.17》。本示例等价于平台自带的联调脚本 `qyt_test.php`。 + +--- + +## 一、对接链路 + +```mermaid +flowchart LR + subgraph A["你方(下单平台)"] + A1["业务系统"] + end + subgraph B["慧明宝平台"] + B1["开放接口调用流程(与平台 PHP 端一一对应): + *
平台的响应是明文 JSON,不加密:
+ * {@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 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 头尾与换行)。
+ * 用于给请求加签;平台用你方公钥验签。
+ *
+ * 若拿到的是 PKCS#1(-----BEGIN RSA PRIVATE KEY-----),先转换:
+ * ⚠️ 不能直接用 java.net.URLEncoder:它不编码 {@code *},而 PHP 会编成 {@code %2A},
+ * 只要报文里出现一个这样的字符,验签就会失败。
+ *
+ * 规则:仅 {@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();
+ }
+
+ /**
+ * 请求方向的待签串(你方 → 平台):
+ * ⚠️ 注意与请求方向不同:这里是原样拼接、不做 urlencode。
+ * 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", "");
+ }
+}
diff --git a/hmb-java-demo/src/main/java/com/hmb/openapi/HmbNotifyVerifier.java b/hmb-java-demo/src/main/java/com/hmb/openapi/HmbNotifyVerifier.java
new file mode 100644
index 0000000..2a8ec02
--- /dev/null
+++ b/hmb-java-demo/src/main/java/com/hmb/openapi/HmbNotifyVerifier.java
@@ -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;
+
+/**
+ * 油卡变动通知接收端(平台 → 你方)。
+ *
+ * 平台在三种场景下推送:分油成功、司机加油、订单退款。报文与请求方向同构:
+ * 请求头带 version/appKey/method/requestId/sign,请求体是 {"key": ..., "data": ...}。
+ *
+ * 解析步骤:
+ * 处理成功后必须返回:{@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 {@code code=00} 表示成功,其余为失败,失败时看 {@code errCode / errMessage}。
+ * 常见错误码:
+ * 为什么不能直接用 Jackson / Gson 生成待签的 body:
+ * 本类保证生成的字符串与 PHP 侧一致。字段顺序按传入的 Map 顺序(请用 LinkedHashMap)。
+ * 所有业务字段都是字符串类型,够用且不会有浮点精度问题。
+ */
+public final class JsonPhp {
+
+ private JsonPhp() {
+ }
+
+ public static String encode(Map 把这个地址(形如 https://你的域名/hmb/notify)填到平台后台的合作商「回调地址」里。
+ *
+ * 三点注意:
+ * 运行前先把下面 5 个配置换成平台分配给你的值:
+ * openssl pkcs8 -topk8 -inform PEM -in old.pem -outform PEM -nocrypt -out pkcs8.pem
+ */
+ 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;
+ }
+}
diff --git a/hmb-java-demo/src/main/java/com/hmb/openapi/HmbCrypto.java b/hmb-java-demo/src/main/java/com/hmb/openapi/HmbCrypto.java
new file mode 100644
index 0000000..f25e8a0
--- /dev/null
+++ b/hmb-java-demo/src/main/java/com/hmb/openapi/HmbCrypto.java
@@ -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)完全一致:
+ *
+ *
+ *
+ */
+public final class HmbCrypto {
+
+ private static final SecureRandom RANDOM = new SecureRandom();
+
+ private HmbCrypto() {
+ }
+
+ // ================================================================
+ // 待签串拼接
+ // ================================================================
+
+ /**
+ * 复刻 PHP 的 urlencode(http_build_query 内部用的就是它)。
+ *
+ * http_build_query([version, appKey, method, requestId, body])
+ * 即 key=value 用 & 连接,key 与 value 都做 urlencode。
+ */
+ public static String buildRequestSignData(String version, String appKey, String method,
+ String requestId, String bodyJson) {
+ return "version=" + phpUrlEncode(version)
+ + "&appKey=" + phpUrlEncode(appKey)
+ + "&method=" + phpUrlEncode(method)
+ + "&requestId=" + phpUrlEncode(requestId)
+ + "&body=" + phpUrlEncode(bodyJson);
+ }
+
+ /**
+ * 回调方向的待签串(平台 → 你方):
+ * version=..&appKey=..&method=..&requestId=..&body={json}
+ *
+ *
+ *
+ *
+ *
+ *
+ */
+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;
+ }
+}
diff --git a/hmb-java-demo/src/main/java/com/hmb/openapi/JsonPhp.java b/hmb-java-demo/src/main/java/com/hmb/openapi/JsonPhp.java
new file mode 100644
index 0000000..adfddd5
--- /dev/null
+++ b/hmb-java-demo/src/main/java/com/hmb/openapi/JsonPhp.java
@@ -0,0 +1,83 @@
+package com.hmb.openapi;
+
+import java.util.Map;
+
+/**
+ * 按 PHP {@code json_encode($body, JSON_UNESCAPED_UNICODE)} 的口径序列化请求体。
+ *
+ *
+ *
+ *
+ *
+ *
+ */
+@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
+ * mvn -q compile exec:java -Dexec.mainClass=com.hmb.openapi.demo.ShareDemo
+ *
+ */
+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());
+ }
+}
diff --git a/server-tp6/app/api/controller/OilCard.php b/server-tp6/app/api/controller/OilCard.php
index a97149e..13e4eaf 100644
--- a/server-tp6/app/api/controller/OilCard.php
+++ b/server-tp6/app/api/controller/OilCard.php
@@ -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位)
diff --git a/server-tp6/deploy/qyt_consume_push.php b/server-tp6/deploy/qyt_consume_push.php
new file mode 100644
index 0000000..0774fea
--- /dev/null
+++ b/server-tp6/deploy/qyt_consume_push.php
@@ -0,0 +1,199 @@
+ [--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";
+}
diff --git a/server-tp6/deploy/qyt_consume_replay.php b/server-tp6/deploy/qyt_consume_replay.php
new file mode 100644
index 0000000..5d57ac5
--- /dev/null
+++ b/server-tp6/deploy/qyt_consume_replay.php
@@ -0,0 +1,176 @@
+ 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);
+ }
+ }
+}
diff --git a/server-tp6/deploy/rsa_check.php b/server-tp6/deploy/rsa_check.php
new file mode 100644
index 0000000..ec94f2f
--- /dev/null
+++ b/server-tp6/deploy/rsa_check.php
@@ -0,0 +1,152 @@
+ 再检查该合作商的公钥(用于验签其请求)
+// | php deploy/rsa_check.php