180 lines
6.9 KiB
PHP
Executable File
180 lines
6.9 KiB
PHP
Executable File
<?php
|
|
|
|
declare(strict_types=1);
|
|
|
|
namespace app\common\service\gancao;
|
|
|
|
/**
|
|
* 甘草开放平台网关传输(AES-128-ECB + HTTP 头),对齐官方 GcOpenApi.php。
|
|
*/
|
|
final class GancaoOpenApiTransport
|
|
{
|
|
private string $url;
|
|
|
|
private string $ak;
|
|
|
|
private string $sk;
|
|
|
|
private string $userAgent;
|
|
|
|
public function __construct(string $url, string $ak, string $sk, string $userAgent = 'zyt-admin/1.0')
|
|
{
|
|
$this->url = rtrim($url, '/');
|
|
$this->ak = $ak;
|
|
$this->sk = $sk;
|
|
$this->userAgent = $userAgent;
|
|
}
|
|
|
|
/**
|
|
* @param array<string,mixed> $payload 已含 package、class 及业务字段
|
|
* @return array{state:int,msg:string,body?:array<string,mixed>,response?:string}
|
|
*/
|
|
public function post(array $payload): array
|
|
{
|
|
// 与甘草网关规范一致:签名/头里的时间戳须与 body 内业务字段 timestamp(若有)一致,避免跨秒不一致导致签名校验失败
|
|
$sigTs = time();
|
|
if (isset($payload['timestamp']) && is_numeric($payload['timestamp'])) {
|
|
$sigTs = (int) $payload['timestamp'];
|
|
}
|
|
if ($sigTs <= 0) {
|
|
$sigTs = time();
|
|
}
|
|
$json = json_encode($payload, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES);
|
|
if ($json === false) {
|
|
return ['state' => 0, 'msg' => 'json_encode 失败'];
|
|
}
|
|
$noise = self::randStr(8);
|
|
$signature = sha1($json . $sigTs . $noise . $this->sk);
|
|
$cipher = self::encrypt($json, $this->sk);
|
|
if ($cipher === '') {
|
|
return ['state' => 0, 'msg' => 'AES 加密失败'];
|
|
}
|
|
|
|
$headers = [
|
|
'Connection: close',
|
|
'Content-Type: application/json; charset=utf-8',
|
|
'Content-length: ' . strlen($cipher),
|
|
'Cache-Control: no-cache',
|
|
'AK: ' . $this->ak,
|
|
'Signature: ' . $signature,
|
|
'UTC-Timestamp: ' . $sigTs,
|
|
'NOISE: ' . $noise,
|
|
'Expect:',
|
|
];
|
|
|
|
// cURL 常量在部分精简构建里可能未注册,统一用 defined() 做优雅降级,值取自官方枚举
|
|
$httpVer11 = defined('CURL_HTTP_VERSION_1_1') ? CURL_HTTP_VERSION_1_1 : 2;
|
|
$ipv4Only = defined('CURL_IPRESOLVE_V4') ? CURL_IPRESOLVE_V4 : 1;
|
|
|
|
$ch = curl_init($this->url);
|
|
curl_setopt($ch, CURLOPT_HTTP_VERSION, $httpVer11); // 改用 HTTP/1.1
|
|
curl_setopt($ch, CURLOPT_HTTPHEADER, $headers);
|
|
curl_setopt($ch, CURLOPT_USERAGENT, $this->userAgent);
|
|
curl_setopt($ch, CURLOPT_RETURNTRANSFER, true);
|
|
curl_setopt($ch, CURLOPT_POST, true);
|
|
curl_setopt($ch, CURLOPT_ENCODING, 'gzip');
|
|
curl_setopt($ch, CURLOPT_IPRESOLVE, $ipv4Only);
|
|
curl_setopt($ch, CURLOPT_CONNECTTIMEOUT, 10); // 增加连接超时
|
|
curl_setopt($ch, CURLOPT_TIMEOUT, 120);
|
|
curl_setopt($ch, CURLOPT_POSTFIELDS, $cipher);
|
|
curl_setopt($ch, CURLOPT_HEADER, true);
|
|
if (str_starts_with($this->url, 'https:')) {
|
|
curl_setopt($ch, CURLOPT_SSL_VERIFYPEER, false);
|
|
curl_setopt($ch, CURLOPT_SSL_VERIFYHOST, 0); // 改为 0,完全禁用主机验证
|
|
// 强制 TLS 1.2;常量值为 6,但部分 PHP/cURL 构建未注册该常量,运行时未定义时退化为默认 TLS
|
|
$tls12 = defined('CURL_SSLVERSION_TLSv1_2') ? CURL_SSLVERSION_TLSv1_2 : 6;
|
|
curl_setopt($ch, CURLOPT_SSLVERSION, $tls12);
|
|
// 可选 cipher list。默认不设(交给 cURL 自带默认,兼容性最好)。
|
|
// 需要兼容弱加密服务器时,在 config/gancao_scm.php 或 .env 里设:
|
|
// GANCAO_SCM_TLS_CIPHERS="DEFAULT@SECLEVEL=1" // 仅 OpenSSL 构建的 cURL 支持此语法
|
|
// GANCAO_SCM_TLS_CIPHERS="DEFAULT:!aNULL:!eNULL" // 通用写法
|
|
// cURL 是 LibreSSL/NSS/GnuTLS/BoringSSL 时,@SECLEVEL= 会直接报 CURLE_SSL_CIPHER(59)。
|
|
$cipherList = trim((string) \think\facade\Config::get('gancao_scm.tls_ciphers', ''));
|
|
if ($cipherList !== '') {
|
|
curl_setopt($ch, CURLOPT_SSL_CIPHER_LIST, $cipherList);
|
|
}
|
|
}
|
|
// 添加 TCP keepalive(部分旧 libcurl 可能没注册 CURLOPT_TCP_KEEPALIVE,守一下)
|
|
if (defined('CURLOPT_TCP_KEEPALIVE')) {
|
|
curl_setopt($ch, CURLOPT_TCP_KEEPALIVE, 1);
|
|
curl_setopt($ch, CURLOPT_TCP_KEEPIDLE, 120);
|
|
curl_setopt($ch, CURLOPT_TCP_KEEPINTVL, 60);
|
|
}
|
|
|
|
$raw = curl_exec($ch);
|
|
$info = curl_getinfo($ch);
|
|
$curlErr = curl_errno($ch);
|
|
$curlMsg = curl_error($ch);
|
|
curl_close($ch);
|
|
|
|
if ($raw === false || (int) ($info['http_code'] ?? 0) !== 200) {
|
|
$http = (int) ($info['http_code'] ?? 0);
|
|
$err = is_string($raw) ? $raw : '';
|
|
|
|
return [
|
|
'state' => 0,
|
|
'msg' => '通信失败:HTTP ' . $http . ($curlErr !== 0 ? ' curl#' . $curlErr . ' ' . $curlMsg : ''),
|
|
'response' => $err,
|
|
];
|
|
}
|
|
|
|
$bodyRaw = substr($raw, strpos($raw, "\r\n\r\n") + 4);
|
|
$plain = self::decrypt($bodyRaw, $this->sk);
|
|
if ($plain === '') {
|
|
$maybeJson = json_decode($bodyRaw, true);
|
|
if (is_array($maybeJson) && isset($maybeJson['status'])) {
|
|
return ['state' => 1, 'msg' => '成功(明文)', 'body' => $maybeJson];
|
|
}
|
|
|
|
return ['state' => -1, 'msg' => '解密失败(请核对网关 SK 是否为 16 位且与 AK 匹配)', 'response' => mb_substr($bodyRaw, 0, 500)];
|
|
}
|
|
|
|
$decoded = json_decode($plain, true);
|
|
|
|
return ['state' => 1, 'msg' => '成功', 'body' => is_array($decoded) ? $decoded : []];
|
|
}
|
|
|
|
private static function encrypt(string $string, string $key): string
|
|
{
|
|
$out = openssl_encrypt($string, 'AES-128-ECB', $key, OPENSSL_RAW_DATA);
|
|
|
|
return $out !== false ? base64_encode($out) : '';
|
|
}
|
|
|
|
private static function decrypt(string $string, string $key): string
|
|
{
|
|
$bin = base64_decode($string, true);
|
|
if ($bin === false) {
|
|
return '';
|
|
}
|
|
$out = openssl_decrypt($bin, 'AES-128-ECB', $key, OPENSSL_RAW_DATA);
|
|
|
|
return $out !== false ? $out : '';
|
|
}
|
|
|
|
private static function randStr(int $length = 8): string
|
|
{
|
|
$chars = 'ABCDEFGHIJKLMNPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz1234567890';
|
|
$chars = str_shuffle($chars);
|
|
$end = strlen($chars) - 1;
|
|
$buf = [];
|
|
while (true) {
|
|
$c = $chars[random_int(0, $end)];
|
|
if ($c !== '0') {
|
|
$buf[] = $c;
|
|
break;
|
|
}
|
|
}
|
|
$n = 1;
|
|
while ($n < $length) {
|
|
$r = $chars[random_int(0, $end)];
|
|
if ($r !== $buf[count($buf) - 1]) {
|
|
$buf[] = $r;
|
|
++$n;
|
|
}
|
|
}
|
|
|
|
return implode('', $buf);
|
|
}
|
|
}
|