Files
anxinyan/server-api/app/support/ShouqianbaClient.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());
}
}