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()); } }