334 lines
15 KiB
PHP
334 lines
15 KiB
PHP
<?php
|
||
// +----------------------------------------------------------------------
|
||
// | 模拟企油通 3.10「发放结果通知」 —— 联调 / 补推用(CLI)
|
||
// +----------------------------------------------------------------------
|
||
// | 按企油通的真实口径推送:multipart/form-data,业务参数放在表单字段 data 里,
|
||
// | 另带一个 sign 字段(我方暂未验签,填什么都行)。
|
||
// |
|
||
// | 用法(在站点根目录执行):
|
||
// | php deploy/qyt_waybill_push.php --fake # 【通链路自检】不需要任何订单
|
||
// | php deploy/qyt_waybill_push.php --order=6 # 该单发放成功
|
||
// | php deploy/qyt_waybill_push.php --order=6 --result=failed # 该单发放失败(会触发原路退款)
|
||
// | php deploy/qyt_waybill_push.php --order=6 --batch-status=5 # 整批处理失败(明细仍写待处理)
|
||
// | php deploy/qyt_waybill_push.php --batch=HMB2026... # 按批次号推,批次内所有单一起推
|
||
// | php deploy/qyt_waybill_push.php --order=6 --dry-run # 只打印报文不发送
|
||
// |
|
||
// | 常用参数:
|
||
// | --fake 自检模式:造一条不存在的运单推过去,只验「入口 -> 解析 ->
|
||
// | 队列 -> 消费者」这条链路通不通,不碰数据库、不改任何订单。
|
||
// | 预期结果是「未匹配=1」—— 那正是自检通过的标志。
|
||
// | --order=订单ID 单推一条(推荐)
|
||
// | --batch=批次号 按 batch_no 把该批次的单全推一遍
|
||
// | --result=success|failed|pending 明细级 handle_status,默认 success
|
||
// | --batch-status=3|4|5 批次级 status,默认按 result 推导(成功=3 失败=5)
|
||
// | --msg=文案 成功时写 remark,失败时写 error_msg
|
||
// | --url=通知地址 默认取 .env 的 QYT.WAYBILL_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';
|
||
}
|
||
}
|
||
$fake = !empty($opt['fake']);
|
||
$orderId = isset($opt['order']) ? (int) $opt['order'] : 0;
|
||
$batchNo = isset($opt['batch']) ? trim((string) $opt['batch']) : '';
|
||
$dryRun = !empty($opt['dry_run']);
|
||
$result = isset($opt['result']) ? strtolower(trim((string) $opt['result'])) : 'success';
|
||
$msg = isset($opt['msg']) ? (string) $opt['msg'] : '';
|
||
|
||
if (!$fake && $orderId <= 0 && $batchNo === '') {
|
||
exit("用法:php deploy/qyt_waybill_push.php --fake # 不需要订单,自检链路\n"
|
||
. " php deploy/qyt_waybill_push.php --order=<订单ID> [--result=success|failed]\n"
|
||
. " php deploy/qyt_waybill_push.php --batch=<批次号>\n"
|
||
. "详细参数见本文件头部注释。\n");
|
||
}
|
||
if (!in_array($result, ['success', 'failed', 'pending'], true)) {
|
||
exit("--result 只能是 success / failed / pending\n");
|
||
}
|
||
|
||
// 明细级 handle_status:1 待处理 / 2 成功 / 3 失败
|
||
$handleStatus = ['success' => 2, 'failed' => 3, 'pending' => 1][$result];
|
||
// 批次级 status:3 全部成功 / 4 部分成功 / 5 处理失败
|
||
$batchStatus = isset($opt['batch_status'])
|
||
? (int) $opt['batch_status']
|
||
: ($result === 'success' ? 3 : ($result === 'failed' ? 5 : 2));
|
||
|
||
// ---------------- 读取 .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['WAYBILL_NOTIFY_URL'] ?? ''));
|
||
if ($url === '') {
|
||
$url = 'http://127.0.0.1/qytwaybill.php';
|
||
}
|
||
|
||
// ---------------- 取订单(自检模式跳过) ----------------
|
||
$pdo = null;
|
||
$orders = [];
|
||
if ($fake) {
|
||
// 造一条绝不会命中任何订单的运单流水号:
|
||
// 服务端应当「收下 -> 入队 -> 消费者判为未匹配」,整条链路都走到,但不动任何数据
|
||
$batchNo === '' && $batchNo = 'SELFCHECK' . date('YmdHis');
|
||
$fakeCode = 'SELFCHECKCODE' . date('YmdHis') . mt_rand(100, 999);
|
||
} else {
|
||
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) {
|
||
exit("连不上数据库:{$e->getMessage()}\n(本脚本需要读订单的 batch_no / waybill_no_code)\n");
|
||
}
|
||
|
||
if ($orderId > 0) {
|
||
$st = $pdo->prepare('SELECT * FROM yoshop_partners_order WHERE order_id = ? LIMIT 1');
|
||
$st->execute([$orderId]);
|
||
$orders = array_filter([$st->fetch() ?: null]);
|
||
if (!$orders) {
|
||
exit("未找到订单 order_id={$orderId}\n");
|
||
}
|
||
} else {
|
||
$st = $pdo->prepare('SELECT * FROM yoshop_partners_order WHERE batch_no = ? ORDER BY order_id');
|
||
$st->execute([$batchNo]);
|
||
$orders = $st->fetchAll();
|
||
if (!$orders) {
|
||
exit("未找到批次 batch_no={$batchNo} 下的订单\n");
|
||
}
|
||
}
|
||
}
|
||
|
||
// ---------------- 组装报文(字段与 3.10 文档一致) ----------------
|
||
$list = [];
|
||
$now = date('Y-m-d H:i:s');
|
||
$i = 0;
|
||
if ($fake) {
|
||
$list[] = [
|
||
'id' => 1,
|
||
'batch_no' => $batchNo,
|
||
'waybill_no' => 'SELFCHECKWB' . date('YmdHis'),
|
||
'waybill_no_code' => $fakeCode,
|
||
'mobile' => '13800000000',
|
||
'id_card' => '110101199001011234',
|
||
'name' => '自检',
|
||
'quota_amount' => 1,
|
||
'voucher_name' => '运单卡',
|
||
'effective_start' => '',
|
||
'effective_end' => '',
|
||
'handle_status' => $handleStatus,
|
||
'remark' => '链路自检,不对应任何真实订单',
|
||
'error_msg' => '',
|
||
'create_time' => $now,
|
||
'update_time' => $now,
|
||
];
|
||
}
|
||
foreach ($orders as $o) {
|
||
if ((string) $o['waybill_no_code'] === '') {
|
||
echo "⚠️ 跳过 order_id={$o['order_id']}:还没有运单流水号,说明 7.7 还没成功受理\n";
|
||
continue;
|
||
}
|
||
$batchNo === '' && $batchNo = (string) $o['batch_no'];
|
||
$list[] = [
|
||
'id' => ++$i,
|
||
'batch_no' => (string) $o['batch_no'],
|
||
'waybill_no' => (string) $o['waybill_no'],
|
||
'waybill_no_code' => (string) $o['waybill_no_code'],
|
||
'mobile' => (string) $o['mobile'],
|
||
'id_card' => (string) $o['id_card'],
|
||
'name' => (string) $o['driver_name'],
|
||
'quota_amount' => (int) round((float) $o['total_price'] * 100),
|
||
'voucher_name' => '运单卡',
|
||
'effective_start' => '',
|
||
'effective_end' => '',
|
||
'handle_status' => $handleStatus,
|
||
'remark' => $result === 'success' ? ($msg !== '' ? $msg : '发放成功') : '',
|
||
'error_msg' => $result === 'failed' ? ($msg !== '' ? $msg : '模拟发放失败') : '',
|
||
'create_time' => $now,
|
||
'update_time' => $now,
|
||
];
|
||
}
|
||
if (!$list) {
|
||
exit("没有可推送的明细\n");
|
||
}
|
||
|
||
$statusText = [3 => '全部成功', 4 => '部分成功', 5 => '处理失败'][$batchStatus] ?? '处理中';
|
||
$data = [
|
||
'appId' => (string) ($qyt['APP_ID'] ?? 'SIMAPPID'),
|
||
'scene' => 'oil',
|
||
'business_license' => '',
|
||
'batch_no' => $batchNo,
|
||
'passageway' => ((string) ($orders[0]['passageway'] ?? 'alipay')) === 'pingan' ? 2 : 1, // 自检模式下固定 1
|
||
'status' => $batchStatus,
|
||
'status_text' => $statusText,
|
||
'remark' => '模拟推送',
|
||
'notify_time' => $now,
|
||
'list' => $list,
|
||
];
|
||
$json = json_encode($data, JSON_UNESCAPED_UNICODE);
|
||
|
||
echo "通知地址:{$url}\n";
|
||
echo "批次:{$batchNo} 批次状态:{$batchStatus}({$statusText}) 明细:" . count($list) . " 条\n";
|
||
if ($fake) {
|
||
echo "自检模式:运单流水号 {$fakeCode}(刻意不对应任何订单,不会改动任何数据)\n";
|
||
}
|
||
foreach ($orders as $o) {
|
||
printf(" order_id=%s 油卡号=%s 金额=%s 当前发放态=%s\n",
|
||
$o['order_id'], $o['oilCardNo'], $o['total_price'], $o['oil_status_zsy']);
|
||
}
|
||
echo "data:" . mb_substr($json, 0, 1500) . (mb_strlen($json) > 1500 ? ' …(截断)' : '') . "\n\n";
|
||
|
||
if ($dryRun) {
|
||
echo "--dry-run:未发送\n";
|
||
exit;
|
||
}
|
||
|
||
// ---------------- 发送:multipart/form-data,data + sign ----------------
|
||
$ch = curl_init();
|
||
curl_setopt_array($ch, [
|
||
CURLOPT_URL => $url,
|
||
CURLOPT_POST => true,
|
||
// 传数组 => cURL 自动用 multipart/form-data 编码,与企油通口径一致
|
||
CURLOPT_POSTFIELDS => ['data' => $json, 'sign' => 'SIMULATED'],
|
||
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";
|
||
// 3.10 的成功判据就是 HTTP 2xx,响应体不做要求
|
||
$ok = $http >= 200 && $http < 300;
|
||
echo $ok ? "接收端已受理(HTTP 2xx)\n" : "⚠️ 接收端未返回 2xx,企油通会重试最多 5 次;请查 runtime/storelog 下当天的 qyt_*.txt\n";
|
||
|
||
if (!$ok) {
|
||
exit;
|
||
}
|
||
|
||
// ---------------- 自检模式:靠日志验证整条链路 ----------------
|
||
if ($fake) {
|
||
// 日志目录:接收端与队列进程现在统一写 runtime/storelog(见 app/common.php 的
|
||
// store_log_path)。为了兼容「代码还没全量更新」和历史日志,这里把多应用模式下的
|
||
// 旧目录 runtime/{api,store}/storelog 也一并扫上。
|
||
$sub = date('Ym') . '/' . date('Ymd');
|
||
$dirs = [
|
||
$root . '/runtime/storelog/' . $sub,
|
||
$root . '/runtime/api/storelog/' . $sub,
|
||
$root . '/runtime/store/storelog/' . $sub,
|
||
];
|
||
$grep = static function (array $dirs, string $pattern, string $needle): bool {
|
||
foreach ($dirs as $dir) {
|
||
foreach ((array) glob($dir . '/' . $pattern) as $f) {
|
||
if (strpos((string) @file_get_contents($f), $needle) !== false) {
|
||
return true;
|
||
}
|
||
}
|
||
}
|
||
return false;
|
||
};
|
||
|
||
echo "\n等待 callback:queue 处理";
|
||
$received = false;
|
||
$consumed = false;
|
||
for ($n = 0; $n < 10; $n++) {
|
||
sleep(1);
|
||
echo '.';
|
||
$received = $received || $grep($dirs, 'qyt_*.txt', $batchNo);
|
||
$consumed = $grep($dirs, '[0-9]*.txt', '3.10 发放结果通知处理完成 batch_no=' . $batchNo);
|
||
if ($consumed) {
|
||
break;
|
||
}
|
||
}
|
||
echo "\n\n自检结果:\n";
|
||
printf(" %s 1/3 入口可达,返回 HTTP %d\n", '✅', $http);
|
||
printf(" %s 2/3 接收端解析到报文(%s 里出现了 batch_no)\n",
|
||
$received ? '✅' : '❌', $dirs[0] . '/qyt_*.txt');
|
||
printf(" %s 3/3 callback:queue 消费并完成判定\n", $consumed ? '✅' : '❌');
|
||
|
||
// 3/3 过了就说明整条链路都走到了(接收端日志受「详细日志」开关控制,没有不代表没收到)
|
||
if ($consumed) {
|
||
echo "\n链路通了。日志里那条「未匹配=1」是预期结果——自检用的运单号本来就不对应任何订单,\n"
|
||
. "说明「入口 -> 表单解析 -> 入队 -> 消费者取出 -> 逐条判定」每一步都走到了。\n"
|
||
. "真实通知到达时,这一条会变成「成功=1」或「失败=1」。\n";
|
||
if (!$received) {
|
||
echo "\n注:2/3 没打钩不影响功能,常见原因是「详细日志」开关关着\n"
|
||
. " (后台 ->「企油通对接设置」->「详细日志」)。联调期建议打开,\n"
|
||
. " 出问题时能看到上游推过来的原文:\n"
|
||
. " tail -n 50 {$dirs[0]}/qyt_*.txt\n";
|
||
}
|
||
exit;
|
||
}
|
||
echo "\n排查方向(按顺序排查):\n";
|
||
if (!$received) {
|
||
echo " 1) 请求可能根本没打到本站点:确认 --url 指向的就是这台机器,\n"
|
||
. " 且 nginx 能访问到 public/qytwaybill.php(伪静态别把它拦掉)。\n"
|
||
. " 先把「详细日志」打开再跑一次,能看到原文就说明收到了。\n";
|
||
}
|
||
echo " 2) callback:queue 进程多半没在跑,确认一下:\n"
|
||
. " /www/server/panel/pyenv/bin/supervisorctl status | grep callback\n"
|
||
. " 也可以先前台手动跑一个看报错:php think callback:queue\n";
|
||
echo " 3) Redis 不通的话入队就失败了,接收端日志里会有异常:redis-cli ping\n";
|
||
exit;
|
||
}
|
||
|
||
// ---------------- 等队列处理并对比结果 ----------------
|
||
echo "\n等待 callback:queue 处理";
|
||
$ids = array_column($orders, 'order_id');
|
||
$in = implode(',', array_map('intval', $ids));
|
||
$before = [];
|
||
foreach ($orders as $o) {
|
||
$before[$o['order_id']] = (int) $o['oil_status_zsy'];
|
||
}
|
||
$changed = false;
|
||
for ($n = 0; $n < 10; $n++) {
|
||
sleep(1);
|
||
echo '.';
|
||
$rows = $pdo->query("SELECT order_id, oil_status, oil_status_zsy, oil_text FROM yoshop_partners_order WHERE order_id IN ({$in})")->fetchAll();
|
||
$changed = true;
|
||
foreach ($rows as $r) {
|
||
if ((int) $r['oil_status_zsy'] === $before[$r['order_id']]) {
|
||
$changed = false;
|
||
}
|
||
}
|
||
if ($changed) {
|
||
break;
|
||
}
|
||
}
|
||
echo "\n";
|
||
|
||
$textOf = [0 => '未提交', 1 => '发放中', 2 => '发放成功', 3 => '发放失败'];
|
||
$rows = $pdo->query("SELECT order_id, oil_status, oil_status_zsy, oil_text FROM yoshop_partners_order WHERE order_id IN ({$in})")->fetchAll();
|
||
foreach ($rows as $r) {
|
||
printf("%s order_id=%s 发放态 %s -> %s %s\n",
|
||
(int) $r['oil_status_zsy'] === $before[$r['order_id']] ? '⚠️' : '✅',
|
||
$r['order_id'],
|
||
$textOf[$before[$r['order_id']]] ?? $before[$r['order_id']],
|
||
$textOf[(int) $r['oil_status_zsy']] ?? $r['oil_status_zsy'],
|
||
$r['oil_text']);
|
||
}
|
||
if (!$changed) {
|
||
echo "\n可能原因:\n"
|
||
. " 1) callback:queue 进程没在跑(supervisorctl status 看一下)\n"
|
||
. " 2) 订单当前不是「发放中」,已经是终态了(状态跃迁只从 1 出发,这是幂等设计)\n"
|
||
. " 3) 处理异常,查 runtime/storelog 下当天的 qyt_" . date('YmdH') . ".txt\n";
|
||
}
|