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
+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.