解析 -> // | 队列 -> 消费者」这条链路通不通,不碰数据库、不改任何订单。 // | 预期结果是「未匹配=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"; }