286 lines
8.8 KiB
PHP
286 lines
8.8 KiB
PHP
<?php
|
|
|
|
namespace app\support;
|
|
|
|
class ShouqianbaClient
|
|
{
|
|
private const VERSION = '1.0.0';
|
|
private const SIGN_TYPE = 'SHA256';
|
|
private ?ShouqianbaConfigService $configService;
|
|
|
|
public function __construct(?ShouqianbaConfigService $configService = null)
|
|
{
|
|
$this->configService = $configService ?: new ShouqianbaConfigService();
|
|
}
|
|
|
|
public function purchase(array $body): array
|
|
{
|
|
return $this->post('/api/lite-pos/v1/sales/purchase', $body);
|
|
}
|
|
|
|
public function query(array $body): array
|
|
{
|
|
return $this->post('/api/lite-pos/v1/sales/query', $body);
|
|
}
|
|
|
|
public function void(array $body): array
|
|
{
|
|
return $this->post('/api/lite-pos/v1/sales/void', $body);
|
|
}
|
|
|
|
public function decodeNotification(string $rawBody): array
|
|
{
|
|
$payload = json_decode($rawBody, true);
|
|
if (!is_array($payload)) {
|
|
throw new \RuntimeException('收钱吧通知请求体格式错误');
|
|
}
|
|
|
|
if (!$this->verifyEnvelopeRaw($rawBody, $payload, 'request') && !$this->verifyEnvelope($payload, 'request')) {
|
|
throw new \RuntimeException('收钱吧通知验签失败');
|
|
}
|
|
|
|
$body = $payload['request']['body'] ?? null;
|
|
if (!is_array($body)) {
|
|
throw new \RuntimeException('收钱吧通知业务参数为空');
|
|
}
|
|
|
|
return $body;
|
|
}
|
|
|
|
public function signedResponse(string $resultCode = '200', string $bizResultCode = '200'): array
|
|
{
|
|
$config = $this->configService->assertReady();
|
|
$response = [
|
|
'head' => [
|
|
'version' => self::VERSION,
|
|
'sign_type' => self::SIGN_TYPE,
|
|
'appid' => $config['appid'],
|
|
'response_time' => $this->isoTime(),
|
|
],
|
|
'body' => [
|
|
'result_code' => $resultCode,
|
|
'biz_response' => [
|
|
'result_code' => $bizResultCode,
|
|
],
|
|
],
|
|
];
|
|
|
|
return [
|
|
'response' => $response,
|
|
'signature' => $this->sign($response, $config['merchant_private_key']),
|
|
];
|
|
}
|
|
|
|
private function post(string $path, array $body): array
|
|
{
|
|
$config = $this->configService->assertReady(true);
|
|
$request = [
|
|
'head' => [
|
|
'version' => self::VERSION,
|
|
'sign_type' => self::SIGN_TYPE,
|
|
'appid' => $config['appid'],
|
|
'request_time' => $this->isoTime(),
|
|
],
|
|
'body' => $body,
|
|
];
|
|
$payload = [
|
|
'request' => $request,
|
|
'signature' => $this->sign($request, $config['merchant_private_key']),
|
|
];
|
|
|
|
$rawResponse = $this->postJson($config['api_domain'] . $path, $payload);
|
|
$decoded = json_decode($rawResponse, true);
|
|
if (!is_array($decoded)) {
|
|
throw new \RuntimeException('收钱吧接口返回格式异常');
|
|
}
|
|
|
|
if (!$this->verifyEnvelopeRaw($rawResponse, $decoded, 'response') && !$this->verifyEnvelope($decoded, 'response')) {
|
|
throw new \RuntimeException('收钱吧接口返回验签失败');
|
|
}
|
|
|
|
$responseBody = $decoded['response']['body'] ?? [];
|
|
if (!is_array($responseBody)) {
|
|
throw new \RuntimeException('收钱吧接口返回业务体异常');
|
|
}
|
|
|
|
if ((string)($responseBody['result_code'] ?? '') !== '200') {
|
|
throw new \RuntimeException($responseBody['error_message'] ?? '收钱吧接口通信失败');
|
|
}
|
|
|
|
$bizResponse = $responseBody['biz_response'] ?? [];
|
|
if (!is_array($bizResponse) || (string)($bizResponse['result_code'] ?? '') !== '200') {
|
|
throw new \RuntimeException($bizResponse['error_message'] ?? '收钱吧接口业务处理失败');
|
|
}
|
|
|
|
$data = $bizResponse['data'] ?? [];
|
|
return [
|
|
'request' => $request,
|
|
'response' => $decoded,
|
|
'data' => is_array($data) ? $data : [],
|
|
];
|
|
}
|
|
|
|
private function sign(array $payload, string $privateKey): string
|
|
{
|
|
$content = $this->encodeJson($payload);
|
|
$key = openssl_pkey_get_private($privateKey);
|
|
if ($key === false) {
|
|
throw new \RuntimeException('收钱吧商户 RSA 私钥不可用');
|
|
}
|
|
|
|
$signature = '';
|
|
$ok = openssl_sign($content, $signature, $key, OPENSSL_ALGO_SHA256);
|
|
if (!$ok) {
|
|
throw new \RuntimeException('收钱吧请求签名失败');
|
|
}
|
|
|
|
return base64_encode($signature);
|
|
}
|
|
|
|
private function verifyEnvelope(array $payload, string $field): bool
|
|
{
|
|
$content = $payload[$field] ?? null;
|
|
$signature = (string)($payload['signature'] ?? '');
|
|
if (!is_array($content) || $signature === '') {
|
|
return false;
|
|
}
|
|
|
|
return $this->verifyContent($this->encodeJson($content), $signature);
|
|
}
|
|
|
|
private function verifyEnvelopeRaw(string $rawPayload, array $payload, string $field): bool
|
|
{
|
|
$signature = (string)($payload['signature'] ?? '');
|
|
if ($signature === '') {
|
|
return false;
|
|
}
|
|
|
|
$content = $this->extractJsonFieldRaw($rawPayload, $field);
|
|
if ($content === '') {
|
|
return false;
|
|
}
|
|
|
|
return $this->verifyContent($content, $signature);
|
|
}
|
|
|
|
private function verifyContent(string $content, string $signature): bool
|
|
{
|
|
$config = $this->configService->assertReady(true);
|
|
$publicKey = openssl_pkey_get_public($config['shouqianba_public_key']);
|
|
if ($publicKey === false) {
|
|
throw new \RuntimeException('收钱吧 RSA 公钥不可用');
|
|
}
|
|
|
|
$result = openssl_verify(
|
|
$content,
|
|
base64_decode($signature, true) ?: '',
|
|
$publicKey,
|
|
OPENSSL_ALGO_SHA256
|
|
);
|
|
|
|
return $result === 1;
|
|
}
|
|
|
|
private function extractJsonFieldRaw(string $json, string $field): string
|
|
{
|
|
if (!preg_match('/"' . preg_quote($field, '/') . '"\s*:/', $json, $matches, PREG_OFFSET_CAPTURE)) {
|
|
return '';
|
|
}
|
|
|
|
$offset = $matches[0][1] + strlen($matches[0][0]);
|
|
$length = strlen($json);
|
|
while ($offset < $length && ctype_space($json[$offset])) {
|
|
$offset++;
|
|
}
|
|
if ($offset >= $length || !in_array($json[$offset], ['{', '['], true)) {
|
|
return '';
|
|
}
|
|
|
|
$start = $offset;
|
|
$depth = 0;
|
|
$inString = false;
|
|
$escaped = false;
|
|
for ($i = $offset; $i < $length; $i++) {
|
|
$char = $json[$i];
|
|
if ($inString) {
|
|
if ($escaped) {
|
|
$escaped = false;
|
|
continue;
|
|
}
|
|
if ($char === '\\') {
|
|
$escaped = true;
|
|
continue;
|
|
}
|
|
if ($char === '"') {
|
|
$inString = false;
|
|
}
|
|
continue;
|
|
}
|
|
|
|
if ($char === '"') {
|
|
$inString = true;
|
|
continue;
|
|
}
|
|
if ($char === '{' || $char === '[') {
|
|
$depth++;
|
|
continue;
|
|
}
|
|
if ($char === '}' || $char === ']') {
|
|
$depth--;
|
|
if ($depth === 0) {
|
|
return substr($json, $start, $i - $start + 1);
|
|
}
|
|
}
|
|
}
|
|
|
|
return '';
|
|
}
|
|
|
|
private function postJson(string $url, array $payload): string
|
|
{
|
|
$body = $this->encodeJson($payload);
|
|
$ch = curl_init($url);
|
|
curl_setopt_array($ch, [
|
|
CURLOPT_RETURNTRANSFER => true,
|
|
CURLOPT_POST => true,
|
|
CURLOPT_POSTFIELDS => $body,
|
|
CURLOPT_TIMEOUT => 12,
|
|
CURLOPT_CONNECTTIMEOUT => 5,
|
|
CURLOPT_HTTPHEADER => [
|
|
'Content-Type: application/json',
|
|
'Accept: application/json',
|
|
],
|
|
]);
|
|
|
|
$response = curl_exec($ch);
|
|
$errno = curl_errno($ch);
|
|
$error = curl_error($ch);
|
|
$httpStatus = (int)curl_getinfo($ch, CURLINFO_HTTP_CODE);
|
|
curl_close($ch);
|
|
|
|
if ($errno) {
|
|
throw new \RuntimeException('收钱吧请求失败:' . $error);
|
|
}
|
|
if ($httpStatus < 200 || $httpStatus >= 300) {
|
|
throw new \RuntimeException('收钱吧请求 HTTP 状态异常:' . $httpStatus);
|
|
}
|
|
|
|
return is_string($response) ? $response : '';
|
|
}
|
|
|
|
private function encodeJson(array $payload): string
|
|
{
|
|
$json = json_encode($payload, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES);
|
|
if (!is_string($json)) {
|
|
throw new \RuntimeException('收钱吧 JSON 编码失败');
|
|
}
|
|
|
|
return $json;
|
|
}
|
|
|
|
private function isoTime(?int $timestamp = null): string
|
|
{
|
|
return date('c', $timestamp ?? time());
|
|
}
|
|
}
|