433 lines
18 KiB
PHP
433 lines
18 KiB
PHP
<?php
|
||
/**
|
||
* ============================================================
|
||
* 慧明宝入站接口 · 联调测试脚本(模拟「下单平台」)
|
||
* ------------------------------------------------------------
|
||
* 放在 public/ 下,浏览器访问:http://你的域名/qyt_test.php
|
||
* 也可命令行:php qyt_test.php share
|
||
*
|
||
* 它做四件事:
|
||
* 1) 从数据库读取合作商(appKey / RSA 私钥 / orgNo)
|
||
* 2) 按「慧明宝」规范组装 RSA+AES 信封并调用我方入站接口
|
||
* 3) 展示明文请求 / 待签串 / 密文报文 / 服务端响应
|
||
* 4) act=notify_receiver 作为回调接收端,解密+验签我方推送的
|
||
* syb.notify.oilCard.change,并返回 {"code":"00"}
|
||
*
|
||
* ⚠️ 仅用于联调,正式环境请删除本文件。
|
||
* ============================================================
|
||
*/
|
||
|
||
const TEST_APPKEY = 'qyttest0000000000000000000001'; // 测试合作商 appKey
|
||
const API_VERSION = '1.0.0';
|
||
|
||
date_default_timezone_set('Asia/Shanghai');
|
||
$ROOT = dirname(__DIR__);
|
||
|
||
// ------------------------------------------------------------
|
||
// 基础工具
|
||
// ------------------------------------------------------------
|
||
function envConf($root)
|
||
{
|
||
$file = $root . '/.env';
|
||
if (!is_file($file)) {
|
||
throw new Exception('.env 不存在:' . $file);
|
||
}
|
||
$ini = parse_ini_file($file, true, INI_SCANNER_RAW) ?: [];
|
||
$db = isset($ini['DATABASE']) ? $ini['DATABASE'] : [];
|
||
return [
|
||
'host' => isset($db['HOSTNAME']) ? $db['HOSTNAME'] : '127.0.0.1',
|
||
'port' => isset($db['HOSTPORT']) ? $db['HOSTPORT'] : '3306',
|
||
'name' => isset($db['DATABASE']) ? $db['DATABASE'] : 'oil',
|
||
'user' => isset($db['USERNAME']) ? $db['USERNAME'] : 'root',
|
||
'pass' => isset($db['PASSWORD']) ? $db['PASSWORD'] : '',
|
||
'char' => isset($db['CHARSET']) ? $db['CHARSET'] : 'utf8',
|
||
];
|
||
}
|
||
|
||
function db($root)
|
||
{
|
||
static $pdo = null;
|
||
if ($pdo === null) {
|
||
$c = envConf($root);
|
||
$dsn = 'mysql:host=' . $c['host'] . ';port=' . $c['port'] . ';dbname=' . $c['name'] . ';charset=' . $c['char'];
|
||
$pdo = new PDO($dsn, $c['user'], $c['pass'], [
|
||
PDO::ATTR_ERRMODE => PDO::ERRMODE_EXCEPTION,
|
||
PDO::ATTR_DEFAULT_FETCH_MODE => PDO::FETCH_ASSOC,
|
||
]);
|
||
}
|
||
return $pdo;
|
||
}
|
||
|
||
/** 读取合作商(下单平台账户) */
|
||
function getPartner($root, $appKey)
|
||
{
|
||
$st = db($root)->prepare('SELECT * FROM yoshop_partners WHERE appKey = ? LIMIT 1');
|
||
$st->execute([$appKey]);
|
||
$row = $st->fetch();
|
||
if (!$row) {
|
||
throw new Exception('未找到合作商,appKey=' . $appKey . '(请先执行 deploy/seed_test_partner.sql)');
|
||
}
|
||
return $row;
|
||
}
|
||
|
||
/** 平台公钥:加密 AES 密钥 / 验签我方回调 */
|
||
function platformPublicKey($root)
|
||
{
|
||
$p = $root . '/rsa/public_key.pem';
|
||
if (!is_file($p)) {
|
||
throw new Exception('平台公钥不存在:' . $p);
|
||
}
|
||
return file_get_contents($p);
|
||
}
|
||
|
||
function uniqueRequestId($appKey)
|
||
{
|
||
return $appKey . strtoupper(bin2hex(random_bytes(12)));
|
||
}
|
||
|
||
/**
|
||
* 组装请求信封(口径对齐 app\common\crypto\RsaUtil::processResponse)
|
||
* 待签串:http_build_query([version, appKey, method, requestId, body])
|
||
* body :json_encode($body, JSON_UNESCAPED_UNICODE)
|
||
* 签名 :SHA256withRSA,用【合作商私钥】
|
||
* AES :AES-256-ECB,随机 32 字节密钥;密钥用【平台公钥】RSA 加密
|
||
*/
|
||
function buildEnvelope($partner, $platformPub, $method, array $body)
|
||
{
|
||
$bodyJson = json_encode($body, JSON_UNESCAPED_UNICODE);
|
||
$requestId = uniqueRequestId($partner['appKey']);
|
||
|
||
$signData = http_build_query([
|
||
'version' => API_VERSION,
|
||
'appKey' => $partner['appKey'],
|
||
'method' => $method,
|
||
'requestId' => $requestId,
|
||
'body' => $bodyJson,
|
||
]);
|
||
|
||
$pkey = openssl_pkey_get_private($partner['privateKey']);
|
||
if ($pkey === false) {
|
||
throw new Exception('合作商私钥无效:' . openssl_error_string());
|
||
}
|
||
$signature = '';
|
||
openssl_sign($signData, $signature, $pkey, OPENSSL_ALGO_SHA256);
|
||
|
||
// AES-256-ECB 加密 body(与服务端 encryptData/decryptData 完全一致)
|
||
$aesKey = openssl_random_pseudo_bytes(32);
|
||
$encBody = base64_encode(openssl_encrypt($bodyJson, 'AES-256-ECB', $aesKey, OPENSSL_PKCS1_PADDING));
|
||
|
||
// 平台公钥加密 AES 密钥
|
||
$pub = openssl_pkey_get_public($platformPub);
|
||
if ($pub === false) {
|
||
throw new Exception('平台公钥无效:' . openssl_error_string());
|
||
}
|
||
$encKey = '';
|
||
openssl_public_encrypt($aesKey, $encKey, $pub, OPENSSL_PKCS1_PADDING);
|
||
|
||
return [
|
||
'headers' => [
|
||
'version' => API_VERSION,
|
||
'appKey' => $partner['appKey'],
|
||
'method' => $method,
|
||
'requestId' => $requestId,
|
||
'sign' => base64_encode($signature),
|
||
],
|
||
'body' => ['key' => base64_encode($encKey), 'data' => $encBody],
|
||
'plain' => $body,
|
||
'signData' => $signData,
|
||
];
|
||
}
|
||
|
||
function httpPost($url, array $headers, array $jsonBody)
|
||
{
|
||
$h = ['Content-Type: application/json', 'Charset: UTF-8'];
|
||
foreach ($headers as $k => $v) {
|
||
$h[] = $k . ': ' . $v;
|
||
}
|
||
$ch = curl_init();
|
||
curl_setopt_array($ch, [
|
||
CURLOPT_URL => $url,
|
||
CURLOPT_POST => true,
|
||
CURLOPT_POSTFIELDS => json_encode($jsonBody, JSON_UNESCAPED_UNICODE),
|
||
CURLOPT_HTTPHEADER => $h,
|
||
CURLOPT_RETURNTRANSFER => true,
|
||
CURLOPT_TIMEOUT => 60,
|
||
CURLOPT_SSL_VERIFYPEER => false,
|
||
CURLOPT_SSL_VERIFYHOST => false,
|
||
]);
|
||
$resp = curl_exec($ch);
|
||
$err = curl_error($ch);
|
||
$code = curl_getinfo($ch, CURLINFO_HTTP_CODE);
|
||
curl_close($ch);
|
||
return ['http' => $code, 'body' => $resp, 'error' => $err];
|
||
}
|
||
|
||
/** 入站接口地址:public/partners.php/<控制器>/<方法> */
|
||
function apiUrl($path)
|
||
{
|
||
if (!empty($_GET['api'])) {
|
||
return rtrim($_GET['api'], '/') . '/partners.php' . $path;
|
||
}
|
||
if (PHP_SAPI === 'cli') {
|
||
return 'http://127.0.0.1/partners.php' . $path;
|
||
}
|
||
$scheme = (!empty($_SERVER['HTTPS']) && $_SERVER['HTTPS'] !== 'off') ? 'https' : 'http';
|
||
return $scheme . '://' . $_SERVER['HTTP_HOST'] . '/partners.php' . $path;
|
||
}
|
||
|
||
// ------------------------------------------------------------
|
||
// 回调接收端(模拟下单平台的 callback 地址)
|
||
// 我方 NotifyQueue 用 RsaUtil::prepareRequest 推送,注意它的待签串是
|
||
// 「原样拼接」,与入站的 http_build_query 不同。
|
||
// ------------------------------------------------------------
|
||
function notifyReceiver($root)
|
||
{
|
||
$raw = file_get_contents('php://input');
|
||
$headers = [];
|
||
foreach ($_SERVER as $k => $v) {
|
||
if (strpos($k, 'HTTP_') === 0) {
|
||
$headers[strtolower(str_replace('_', '', substr($k, 5)))] = $v;
|
||
}
|
||
}
|
||
$log = $root . '/runtime/qyt_test_notify.log';
|
||
@file_put_contents($log, date('Y-m-d H:i:s') . " 收到回调\nheaders=" . json_encode($headers, 320) . "\nbody=" . $raw . "\n", FILE_APPEND);
|
||
|
||
try {
|
||
$data = json_decode($raw, true);
|
||
$appKey = isset($headers['appkey']) ? $headers['appkey'] : TEST_APPKEY;
|
||
$partner = getPartner($root, $appKey);
|
||
|
||
// 1) 合作商私钥解出 AES 密钥
|
||
$pkey = openssl_pkey_get_private($partner['privateKey']);
|
||
$aesKey = '';
|
||
openssl_private_decrypt(base64_decode($data['key']), $aesKey, $pkey, OPENSSL_PKCS1_PADDING);
|
||
// 2) 解密 body
|
||
$plain = openssl_decrypt(base64_decode($data['data']), 'AES-256-ECB', $aesKey, OPENSSL_PKCS1_PADDING);
|
||
$body = json_decode($plain, true);
|
||
// 3) 平台公钥验签
|
||
$signData = 'version=' . API_VERSION
|
||
. '&appKey=' . $partner['appKey']
|
||
. '&method=' . (isset($headers['method']) ? $headers['method'] : '')
|
||
. '&requestId=' . (isset($headers['requestid']) ? $headers['requestid'] : '')
|
||
. '&body=' . json_encode($body);
|
||
$sign = isset($headers['sign']) ? $headers['sign'] : '';
|
||
$ok = openssl_verify($signData, base64_decode($sign), openssl_pkey_get_public(platformPublicKey($root)), OPENSSL_ALGO_SHA256);
|
||
|
||
@file_put_contents($log, '解密结果=' . json_encode($body, 320) . ' 验签=' . ($ok === 1 ? 'OK' : 'FAIL') . "\n\n", FILE_APPEND);
|
||
header('Content-Type: application/json');
|
||
echo json_encode(['code' => '00', 'message' => '请求成功'], JSON_UNESCAPED_UNICODE);
|
||
} catch (Throwable $e) {
|
||
@file_put_contents($log, '异常:' . $e->getMessage() . "\n\n", FILE_APPEND);
|
||
header('Content-Type: application/json');
|
||
echo json_encode(['code' => '99', 'message' => $e->getMessage()], JSON_UNESCAPED_UNICODE);
|
||
}
|
||
exit;
|
||
}
|
||
|
||
// ------------------------------------------------------------
|
||
// 各接口默认入参
|
||
// ------------------------------------------------------------
|
||
function defaultBody($act, $orgNo)
|
||
{
|
||
$saleOrderNo = 'TEST' . date('YmdHis') . mt_rand(100, 999);
|
||
$oilCardNo = isset($_GET['oilCardNo']) ? $_GET['oilCardNo'] : '';
|
||
$mobile = isset($_GET['mobile']) ? $_GET['mobile'] : '13800000001';
|
||
|
||
if ($act === 'share') {
|
||
return [
|
||
'method' => 'syb.oil.oilCard.share',
|
||
'path' => '/oilCard/share',
|
||
'body' => [
|
||
'orgNo' => $orgNo,
|
||
'amount' => isset($_GET['amount']) ? $_GET['amount'] : '100.00',
|
||
'mobile' => $mobile,
|
||
'name' => isset($_GET['name']) ? $_GET['name'] : '张三',
|
||
'saleOrderNo' => $saleOrderNo,
|
||
'oilCardType' => 'zsy_ticket',
|
||
],
|
||
];
|
||
}
|
||
if ($act === 'qrcode') {
|
||
return [
|
||
'method' => 'syb.pay.qrCode.request',
|
||
'path' => '/oilCard/getPayQrCode',
|
||
'body' => ['orgNo' => $orgNo, 'oilCardNo' => $oilCardNo, 'mobile' => $mobile],
|
||
];
|
||
}
|
||
if ($act === 'status') {
|
||
return [
|
||
'method' => 'syb.oil.oilCard.orderStatus',
|
||
'path' => '/oilCard/orderStatus',
|
||
'body' => ['orgNo' => $orgNo, 'oilCardNo' => $oilCardNo],
|
||
];
|
||
}
|
||
if ($act === 'sharelist') {
|
||
return [
|
||
'method' => 'syb.oil.oilCard.shareList',
|
||
'path' => '/oilCard/shareList',
|
||
'body' => [
|
||
'orgNo' => $orgNo,
|
||
'mobile' => $mobile,
|
||
'beginDate' => date('Ymd', strtotime('-7 day')),
|
||
'endDate' => date('Ymd'),
|
||
'pageLimit' => '10',
|
||
'pageNum' => '1',
|
||
],
|
||
];
|
||
}
|
||
return null;
|
||
}
|
||
|
||
// ------------------------------------------------------------
|
||
// 主流程
|
||
// ------------------------------------------------------------
|
||
$act = isset($_GET['act']) ? $_GET['act'] : (isset($argv[1]) ? $argv[1] : '');
|
||
$fatal = '';
|
||
$result = null;
|
||
$partner = null;
|
||
|
||
try {
|
||
if ($act === 'notify_receiver') {
|
||
notifyReceiver($ROOT);
|
||
}
|
||
$partner = getPartner($ROOT, TEST_APPKEY);
|
||
$platformPub = platformPublicKey($ROOT);
|
||
|
||
if (in_array($act, ['share', 'qrcode', 'status', 'sharelist'], true)) {
|
||
$def = defaultBody($act, $partner['orgNo']);
|
||
$env = buildEnvelope($partner, $platformPub, $def['method'], $def['body']);
|
||
$url = apiUrl($def['path']);
|
||
$rsp = httpPost($url, $env['headers'], $env['body']);
|
||
$result = ['def' => $def, 'env' => $env, 'url' => $url, 'rsp' => $rsp];
|
||
}
|
||
} catch (Throwable $e) {
|
||
$fatal = $e->getMessage();
|
||
}
|
||
|
||
// ---------- 命令行输出 ----------
|
||
if (PHP_SAPI === 'cli') {
|
||
if ($fatal !== '') {
|
||
echo '错误:' . $fatal . "\n";
|
||
exit(1);
|
||
}
|
||
if (!$result) {
|
||
echo "用法:php qyt_test.php [share|qrcode|status|sharelist]\n";
|
||
exit;
|
||
}
|
||
echo '接口:' . $result['def']['method'] . "\n";
|
||
echo 'URL :' . $result['url'] . "\n";
|
||
echo '明文:' . json_encode($result['env']['plain'], 320) . "\n";
|
||
echo 'HTTP:' . $result['rsp']['http'] . "\n";
|
||
echo '响应:' . $result['rsp']['body'] . "\n";
|
||
if ($result['rsp']['error'] !== '') {
|
||
echo 'cURL:' . $result['rsp']['error'] . "\n";
|
||
}
|
||
exit;
|
||
}
|
||
|
||
$rspArr = $result ? json_decode($result['rsp']['body'], true) : null;
|
||
$okFlag = is_array($rspArr) && isset($rspArr['code']) && $rspArr['code'] === '00';
|
||
$newOil = is_array($rspArr) && isset($rspArr['data']['oilCardNo']) ? $rspArr['data']['oilCardNo'] : '';
|
||
$curOil = isset($_GET['oilCardNo']) ? $_GET['oilCardNo'] : '';
|
||
?><!DOCTYPE html>
|
||
<html lang="zh-CN">
|
||
<head>
|
||
<meta charset="utf-8">
|
||
<meta name="viewport" content="width=device-width,initial-scale=1">
|
||
<title>慧明宝入站接口 · 联调测试</title>
|
||
<style>
|
||
body{font:14px/1.7 -apple-system,"Microsoft YaHei",sans-serif;margin:0;background:#f5f6f8;color:#222}
|
||
.wrap{max-width:1000px;margin:0 auto;padding:24px}
|
||
h1{font-size:20px;margin:0 0 4px}
|
||
.sub{color:#888;font-size:13px;margin-bottom:18px}
|
||
.card{background:#fff;border:1px solid #e6e8eb;border-radius:8px;padding:16px 18px;margin-bottom:16px}
|
||
.card h2{font-size:15px;margin:0 0 12px;padding-bottom:8px;border-bottom:1px solid #eee}
|
||
.btns a{display:inline-block;margin:0 8px 8px 0;padding:7px 16px;background:#1890ff;color:#fff;
|
||
border-radius:4px;text-decoration:none;font-size:13px}
|
||
.btns a.g{background:#52c41a}.btns a.o{background:#faad14}.btns a.p{background:#722ed1}
|
||
table{border-collapse:collapse;width:100%;font-size:13px}
|
||
th,td{border:1px solid #eee;padding:6px 10px;text-align:left;vertical-align:top;word-break:break-all}
|
||
th{background:#fafafa;width:150px;font-weight:600;color:#555}
|
||
pre{background:#282c34;color:#abb2bf;padding:12px;border-radius:6px;overflow:auto;
|
||
font-size:12px;line-height:1.6;margin:0;white-space:pre-wrap;word-break:break-all}
|
||
.ok{color:#52c41a;font-weight:600}.err{color:#ff4d4f;font-weight:600}
|
||
.tip{background:#fffbe6;border:1px solid #ffe58f;padding:10px 14px;border-radius:6px;font-size:13px;margin-bottom:16px}
|
||
.lbl{margin:12px 0 6px;color:#555}
|
||
</style>
|
||
</head>
|
||
<body><div class="wrap">
|
||
<h1>慧明宝入站接口 · 联调测试</h1>
|
||
<div class="sub">模拟「下单平台」调用我方 API(RSA + AES 信封)。仅供联调,正式环境请删除本文件。</div>
|
||
|
||
<?php if ($fatal !== ''): ?>
|
||
<div class="card"><h2>初始化失败</h2><p class="err"><?php echo htmlspecialchars($fatal); ?></p></div>
|
||
<?php else: ?>
|
||
|
||
<div class="card">
|
||
<h2>当前合作商</h2>
|
||
<table>
|
||
<tr><th>partners_id</th><td><?php echo (int)$partner['partners_id']; ?></td></tr>
|
||
<tr><th>名称</th><td><?php echo htmlspecialchars($partner['name']); ?></td></tr>
|
||
<tr><th>orgNo</th><td><?php echo htmlspecialchars($partner['orgNo']); ?></td></tr>
|
||
<tr><th>appKey</th><td><?php echo htmlspecialchars($partner['appKey']); ?></td></tr>
|
||
<tr><th>余额</th><td><b><?php echo htmlspecialchars($partner['balance']); ?></b> 元 (下单会扣减,不足返回 2028)</td></tr>
|
||
<tr><th>回调地址</th><td><?php echo htmlspecialchars($partner['callback']); ?></td></tr>
|
||
</table>
|
||
</div>
|
||
|
||
<div class="tip">
|
||
接口地址默认取当前域名;如需指定,链接后加 <code>&api=http://你的域名</code>。<br>
|
||
<b>下单前提</b>:三个队列进程已启动(partners:queue / callback:queue / notify:queue)、Redis 可用、合作商余额充足。
|
||
</div>
|
||
|
||
<div class="card">
|
||
<h2>发起测试</h2>
|
||
<div class="btns">
|
||
<a class="g" href="?act=share">① 下单分油 (share)</a>
|
||
<a class="o" href="?act=qrcode&oilCardNo=<?php echo urlencode($curOil); ?>">② 获取加油链接 (qrCode)</a>
|
||
<a class="p" href="?act=status&oilCardNo=<?php echo urlencode($curOil); ?>">③ 订单状态 (orderStatus)</a>
|
||
<a href="?act=sharelist">④ 分油记录 (shareList)</a>
|
||
</div>
|
||
<div class="sub" style="margin:6px 0 0">
|
||
②③ 需要 oilCardNo:先点①拿到后会自动带上。<br>
|
||
① 可自定义参数:<code>?act=share&amount=50.00&mobile=13800000002&name=李四</code>
|
||
</div>
|
||
</div>
|
||
|
||
<?php if ($result): ?>
|
||
<div class="card">
|
||
<h2>请求 · <?php echo htmlspecialchars($result['def']['method']); ?></h2>
|
||
<table>
|
||
<tr><th>URL</th><td><?php echo htmlspecialchars($result['url']); ?></td></tr>
|
||
<tr><th>requestId</th><td><?php echo htmlspecialchars($result['env']['headers']['requestId']); ?></td></tr>
|
||
</table>
|
||
<p class="lbl">明文 body</p>
|
||
<pre><?php echo htmlspecialchars(json_encode($result['env']['plain'], JSON_PRETTY_PRINT | JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES)); ?></pre>
|
||
<p class="lbl">待签串(http_build_query)</p>
|
||
<pre><?php echo htmlspecialchars($result['env']['signData']); ?></pre>
|
||
<p class="lbl">实际发送(密文信封)</p>
|
||
<pre><?php echo htmlspecialchars(json_encode($result['env']['body'], JSON_PRETTY_PRINT)); ?></pre>
|
||
</div>
|
||
|
||
<div class="card">
|
||
<h2>响应 <?php echo $okFlag ? '<span class="ok">成功</span>' : '<span class="err">失败</span>'; ?></h2>
|
||
<table>
|
||
<tr><th>HTTP 状态</th><td><?php echo (int)$result['rsp']['http']; ?></td></tr>
|
||
<?php if ($result['rsp']['error'] !== ''): ?>
|
||
<tr><th>cURL 错误</th><td class="err"><?php echo htmlspecialchars($result['rsp']['error']); ?></td></tr>
|
||
<?php endif; ?>
|
||
</table>
|
||
<p class="lbl">响应体</p>
|
||
<pre><?php echo htmlspecialchars(is_array($rspArr) ? json_encode($rspArr, JSON_PRETTY_PRINT | JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES) : $result['rsp']['body']); ?></pre>
|
||
<?php if ($newOil !== ''): ?>
|
||
<div class="btns" style="margin-top:14px">
|
||
<a class="o" href="?act=qrcode&oilCardNo=<?php echo urlencode($newOil); ?>">用 <?php echo htmlspecialchars($newOil); ?> 取加油链接</a>
|
||
<a class="p" href="?act=status&oilCardNo=<?php echo urlencode($newOil); ?>">查该单状态</a>
|
||
</div>
|
||
<?php endif; ?>
|
||
</div>
|
||
<?php endif; ?>
|
||
<?php endif; ?>
|
||
|
||
</div></body></html>
|