'你的商户号', * 'serial_no' => '你的商户公钥序列号', // 「密钥管理」中生成密钥后显示的 40 位序列号 * 'private_key' => '/path/to/merchant_private.pem', // 商户私钥文件路径或 PEM 内容 * 'platform_key_dir' => '/path/to/platform_keys', // 存放 siyun_platform_public_{序列号}.pem 的目录 * 'callback_secret' => '你的回调密钥', // 解密支付、退款通知时需要 * ]); * * try { * $order = $client->createOrder([ * 'out_trade_no' => 'M' . date('YmdHis') . mt_rand(1000, 9999), * 'amount' => 100, * 'subject' => '会员充值', * 'notify_url' => 'https://example.com/pay/notify', * ]); * // 收银台地址:$order['cashier_url'] * } catch (SiyunPayException $e) { * // $e->getErrorCode()、$e->getMessage()、$e->getRequestId() * } * * // 接收通知(notify_url 对应的处理程序):验签并解密,返回的 resource 为解密后的业务数据 * $notify = $client->parseNotify(file_get_contents('php://input')); * http_response_code(200); * * 平台更换签名密钥前会提前发布新公钥。建议每天调用一次 $client->refreshCertificates(), * 新公钥只有在应答由已信任的平台公钥签名时才会被接受,并保存到 platform_key_dir。 */ /** * 接口调用异常。平台返回的错误码见接入文档「错误码」;SDK 自身的错误码: * CONFIG_ERROR 配置不正确、NETWORK_ERROR 网络错误、SIGN_MISSING 应答缺少签名、SIGN_ERROR 验签失败、 * TIMESTAMP_EXPIRED 时间戳超出允许范围、PLATFORM_KEY_MISSING 缺少平台公钥、RESPONSE_INVALID 应答格式不正确、 * NOTIFY_INVALID 通知报文不正确、DECRYPT_ERROR 解密失败。 */ class SiyunPayException extends RuntimeException { /** @var string */ private $errorCode; /** @var int */ private $httpStatus; /** @var string|null */ private $requestId; /** @var array */ private $detail; public function __construct(string $message, string $errorCode, int $httpStatus = 0, ?string $requestId = null, array $detail = []) { parent::__construct($message); $this->errorCode = $errorCode; $this->httpStatus = $httpStatus; $this->requestId = $requestId; $this->detail = $detail; } public function getErrorCode(): string { return $this->errorCode; } /** HTTP 状态码;没有收到应答时为 0 */ public function getHttpStatus(): int { return $this->httpStatus; } /** 平台请求 ID,联系客服排查时提供 */ public function getRequestId(): ?string { return $this->requestId; } /** 参数错误时 detail.fields 按字段列出错误原因 */ public function getDetail(): array { return $this->detail; } /** * 结果未知(网络错误或平台繁忙):下单、退款请使用相同单号重试或先查询,不要更换单号。 */ public function isUncertain(): bool { return $this->errorCode === 'NETWORK_ERROR' || $this->httpStatus >= 500; } } class SiyunPayClient { const VERSION = '1.0.0'; const SCHEME = 'SIYUN-SHA256-RSA'; /** 应答与通知时间戳允许的偏差(秒) */ const TIMESTAMP_TOLERANCE = 300; /** @var string */ private $mchNo; /** @var string */ private $serialNo; /** @var mixed openssl 私钥 */ private $privateKey; /** @var string */ private $apiHost; /** @var string|null */ private $platformKeyDir; /** @var array 序列号 => openssl 公钥 */ private $platformKeys = []; /** @var string|null */ private $callbackSecret; /** @var int */ private $timeout; /** * @param array{mch_no: string, serial_no: string, private_key: string, platform_key_dir?: string, platform_public_keys?: array, callback_secret?: string, api_host?: string, timeout?: int} $config */ public function __construct(array $config) { foreach (['mch_no', 'serial_no', 'private_key'] as $name) { if (! isset($config[$name]) || (string) $config[$name] === '') { throw new SiyunPayException('缺少配置项 ' . $name, 'CONFIG_ERROR'); } } $this->mchNo = (string) $config['mch_no']; $this->serialNo = strtoupper((string) $config['serial_no']); $this->apiHost = rtrim((string) ($config['api_host'] ?? 'https://pay.siyun223.com'), '/'); $this->timeout = max(1, (int) ($config['timeout'] ?? 15)); $this->callbackSecret = isset($config['callback_secret']) ? (string) $config['callback_secret'] : null; $this->platformKeyDir = isset($config['platform_key_dir']) && $config['platform_key_dir'] !== '' ? rtrim((string) $config['platform_key_dir'], '/\\') : null; $pem = (string) $config['private_key']; if (strpos($pem, '-----BEGIN') === false) { if (! is_file($pem) || ! is_readable($pem)) { throw new SiyunPayException('商户私钥文件不存在或不可读:' . $pem, 'CONFIG_ERROR'); } $pem = (string) file_get_contents($pem); } $privateKey = openssl_pkey_get_private($pem); if ($privateKey === false) { throw new SiyunPayException('商户私钥格式不正确', 'CONFIG_ERROR'); } $this->privateKey = $privateKey; foreach ((array) ($config['platform_public_keys'] ?? []) as $serial => $publicKey) { $this->addPlatformKey((string) $serial, (string) $publicKey); } } // ---------------- 商户与平台公钥 ---------------- /** 查询商户信息(可用于连通性测试) */ public function merchant(): array { return $this->request('GET', '/api/v1/merchant'); } /** * 获取全部有效的平台公钥并加入信任列表;配置了 platform_key_dir 时保存为 siyun_platform_public_{序列号}.pem。 * 应答本身必须由已信任的平台公钥签名,因此至少要先配置一把平台公钥。 */ public function refreshCertificates(): array { $result = $this->request('GET', '/api/v1/certificates'); foreach ((array) ($result['data'] ?? []) as $item) { $serial = strtoupper((string) ($item['serial_no'] ?? '')); $publicKey = (string) ($item['public_key'] ?? ''); if (! preg_match('/^[0-9A-F]{40}$/', $serial) || $publicKey === '') { continue; } $this->addPlatformKey($serial, $publicKey); if ($this->platformKeyDir !== null && ! is_file($this->platformKeyFile($serial))) { file_put_contents($this->platformKeyFile($serial), rtrim($publicKey) . "\n", LOCK_EX); } } return $result; } // ---------------- 支付订单 ---------------- /** 下单。参数见接入文档「下单」 */ public function createOrder(array $params): array { return $this->request('POST', '/api/v1/orders', [], $params); } /** 按平台订单号查询 */ public function queryOrder(string $orderNo): array { return $this->request('GET', '/api/v1/orders/' . rawurlencode($orderNo)); } /** 按商户订单号查询 */ public function queryOrderByOutTradeNo(string $outTradeNo): array { return $this->request('GET', '/api/v1/orders/out-trade-no/' . rawurlencode($outTradeNo)); } /** 关闭未支付的订单 */ public function closeOrder(string $outTradeNo): array { return $this->request('POST', '/api/v1/orders/out-trade-no/' . rawurlencode($outTradeNo) . '/close'); } // ---------------- 退款 ---------------- /** 申请退款。参数见接入文档「退款」 */ public function createRefund(array $params): array { return $this->request('POST', '/api/v1/refunds', [], $params); } /** 按平台退款单号查询 */ public function queryRefund(string $refundNo): array { return $this->request('GET', '/api/v1/refunds/' . rawurlencode($refundNo)); } /** 按商户退款单号查询 */ public function queryRefundByOutRefundNo(string $outRefundNo): array { return $this->request('GET', '/api/v1/refunds/out-refund-no/' . rawurlencode($outRefundNo)); } // ---------------- 对账 ---------------- /** 订单对账:date_from、date_to 必填,可选 date_type、status、page、page_size */ public function listOrders(array $query): array { return $this->request('GET', '/api/v1/orders', $query); } /** 退款对账:date_from、date_to 必填,可选 date_type、status、page、page_size */ public function listRefunds(array $query): array { return $this->request('GET', '/api/v1/refunds', $query); } /** 资金流水:date_from、date_to 必填,可选 type、page、page_size */ public function listFundLogs(array $query): array { return $this->request('GET', '/api/v1/fund-logs', $query); } // ---------------- 余额与结算 ---------------- /** 查询平台代收资金余额 */ public function balance(): array { return $this->request('GET', '/api/v1/balance'); } /** 结算单列表:date_from、date_to 必填,可选 status、page、page_size */ public function listSettlements(array $query): array { return $this->request('GET', '/api/v1/settlements', $query); } /** 按结算单号查询 */ public function querySettlement(string $settleNo): array { return $this->request('GET', '/api/v1/settlements/' . rawurlencode($settleNo)); } // ---------------- 通知 ---------------- /** * 验签并解密支付、退款通知。$body 必须是原始请求体(不要先 json_decode 再重新编码); * $headers 为请求头数组(键名不区分大小写,值可以是字符串或数组),不传时从 $_SERVER 读取。 * 返回通知报文,其中 resource 已替换为解密后的业务数据。同一事件可能推送多次,请按订单号或退款单号幂等处理。 */ public function parseNotify(string $body, ?array $headers = null): array { $headers = $headers === null ? $this->serverHeaders() : $this->normalizeHeaders($headers); $this->verifySignature($headers, $body, '通知'); $notify = json_decode($body, true); if (! is_array($notify) || ! isset($notify['event_type'], $notify['resource']) || ! is_array($notify['resource'])) { throw new SiyunPayException('通知报文格式不正确', 'NOTIFY_INVALID'); } $notify['resource'] = $this->decryptResource($notify['resource']); return $notify; } /** 解密通知中的 resource:AEAD_AES_256_GCM,密文末尾 16 字节为认证标签 */ public function decryptResource(array $resource): array { if ($this->callbackSecret === null || $this->callbackSecret === '') { throw new SiyunPayException('未配置回调密钥 callback_secret', 'CONFIG_ERROR'); } if (($resource['algorithm'] ?? '') !== 'AEAD_AES_256_GCM') { throw new SiyunPayException('不支持的加密算法', 'NOTIFY_INVALID'); } $raw = base64_decode((string) ($resource['ciphertext'] ?? ''), true); $nonce = base64_decode((string) ($resource['nonce'] ?? ''), true); if ($raw === false || $nonce === false || strlen($raw) <= 16) { throw new SiyunPayException('通知密文格式不正确', 'NOTIFY_INVALID'); } $plain = openssl_decrypt( substr($raw, 0, -16), 'aes-256-gcm', $this->callbackSecret, OPENSSL_RAW_DATA, $nonce, substr($raw, -16), (string) ($resource['associated_data'] ?? '') ); $data = $plain === false ? null : json_decode($plain, true); if (! is_array($data)) { throw new SiyunPayException('通知解密失败,请检查回调密钥', 'DECRYPT_ERROR'); } return $data; } // ---------------- 通用请求 ---------------- /** * 发起签名请求并验证应答签名。HTTP 2xx 返回应答 JSON,否则抛出 SiyunPayException。 * $path 不含查询串,查询参数放在 $query 中(空值会被忽略)。 */ public function request(string $method, string $path, array $query = [], ?array $body = null): array { $method = strtoupper($method); $query = array_filter($query, function ($value) { return $value !== null && $value !== ''; }); $uri = $path . ($query === [] ? '' : '?' . http_build_query($query, '', '&', PHP_QUERY_RFC3986)); $payload = ''; if ($body !== null) { $payload = json_encode($body, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES); if ($payload === false) { throw new SiyunPayException('请求参数无法编码为 JSON:' . json_last_error_msg(), 'PARAM_ERROR'); } } $ch = curl_init($this->apiHost . $uri); if ($ch === false) { throw new SiyunPayException('无法初始化 curl', 'NETWORK_ERROR'); } $headers = []; $options = [ CURLOPT_CUSTOMREQUEST => $method, CURLOPT_RETURNTRANSFER => true, CURLOPT_FOLLOWLOCATION => false, CURLOPT_CONNECTTIMEOUT => min(5, $this->timeout), CURLOPT_TIMEOUT => $this->timeout, CURLOPT_SSL_VERIFYPEER => true, CURLOPT_SSL_VERIFYHOST => 2, CURLOPT_HTTPHEADER => [ 'Authorization: ' . $this->authorization($method, $uri, $payload), 'Content-Type: application/json', 'Accept: application/json', 'User-Agent: SiyunPay-PHP-SDK/' . self::VERSION, ], CURLOPT_HEADERFUNCTION => function ($handle, $line) use (&$headers) { $position = strpos($line, ':'); if ($position !== false) { $headers[strtolower(trim(substr($line, 0, $position)))] = trim(substr($line, $position + 1)); } elseif (stripos($line, 'HTTP/') === 0) { // 新的状态行(例如 100 Continue 之后的正式应答),丢弃之前的应答头 $headers = []; } return strlen($line); }, ]; if ($payload !== '') { $options[CURLOPT_POSTFIELDS] = $payload; } curl_setopt_array($ch, $options); $responseBody = curl_exec($ch); if (! is_string($responseBody)) { throw new SiyunPayException('请求失败:' . curl_error($ch) . '。下单、退款结果未知时请使用相同单号重试或先查询', 'NETWORK_ERROR'); } $status = (int) curl_getinfo($ch, CURLINFO_RESPONSE_CODE); // 未经平台签名的错误应答(网关错误、接口路径不存在)不读取其内容,只按状态码报错 if (! isset($headers['siyun-signature']) && ($status < 200 || $status >= 300)) { if ($status >= 500) { throw new SiyunPayException('平台系统繁忙(HTTP ' . $status . '),请稍后使用相同参数重试', 'SYSTEM_ERROR', $status); } throw new SiyunPayException('请求失败(HTTP ' . $status . '),请检查接口地址与请求方法', 'HTTP_' . $status, $status); } $this->verifySignature($headers, $responseBody, '应答', $status); $data = json_decode($responseBody, true); if (! is_array($data)) { throw new SiyunPayException('应答不是有效的 JSON(HTTP ' . $status . ')', 'RESPONSE_INVALID', $status); } if ($status < 200 || $status >= 300) { throw new SiyunPayException( (string) ($data['message'] ?? '请求失败'), (string) ($data['code'] ?? 'HTTP_' . $status), $status, isset($data['request_id']) ? (string) $data['request_id'] : null, isset($data['detail']) && is_array($data['detail']) ? $data['detail'] : [] ); } return $data; } // ---------------- 内部方法 ---------------- /** 请求签名串:请求方法\n请求路径(含查询串)\n时间戳\n随机串\n请求体\n */ private function authorization(string $method, string $uri, string $body): string { $timestamp = (string) time(); $nonce = bin2hex(random_bytes(16)); $message = $method . "\n" . $uri . "\n" . $timestamp . "\n" . $nonce . "\n" . $body . "\n"; if (! openssl_sign($message, $signature, $this->privateKey, OPENSSL_ALGO_SHA256)) { throw new SiyunPayException('请求签名失败,请检查商户私钥', 'CONFIG_ERROR'); } return sprintf( '%s mch_no="%s",serial_no="%s",timestamp="%s",nonce_str="%s",signature="%s"', self::SCHEME, $this->mchNo, $this->serialNo, $timestamp, $nonce, base64_encode($signature) ); } /** 验签:应答与通知共用,签名串为「时间戳\n随机串\n报文主体\n」 */ private function verifySignature(array $headers, string $body, string $subject, int $status = 0): void { $timestamp = (string) ($headers['siyun-timestamp'] ?? ''); $nonce = (string) ($headers['siyun-nonce'] ?? ''); $serial = strtoupper((string) ($headers['siyun-serial'] ?? '')); $signature = (string) ($headers['siyun-signature'] ?? ''); if ($timestamp === '' || $nonce === '' || $serial === '' || $signature === '') { throw new SiyunPayException($subject . '缺少平台签名', 'SIGN_MISSING', $status); } if (! ctype_digit($timestamp) || abs(time() - (int) $timestamp) > self::TIMESTAMP_TOLERANCE) { throw new SiyunPayException($subject . '时间戳与本机时间相差超过 5 分钟,请校准服务器时间', 'TIMESTAMP_EXPIRED', $status); } $publicKey = $this->platformKey($serial); $raw = base64_decode($signature, true); $message = $timestamp . "\n" . $nonce . "\n" . $body . "\n"; if ($raw === false || openssl_verify($message, $raw, $publicKey, OPENSSL_ALGO_SHA256) !== 1) { throw new SiyunPayException($subject . '验签失败,内容可能被篡改', 'SIGN_ERROR', $status); } } /** @return mixed openssl 公钥 */ private function platformKey(string $serial) { if (! preg_match('/^[0-9A-F]{40}$/', $serial)) { throw new SiyunPayException('平台公钥序列号格式不正确', 'SIGN_ERROR'); } if (! isset($this->platformKeys[$serial]) && $this->platformKeyDir !== null) { $file = $this->platformKeyFile($serial); if (is_file($file) && is_readable($file)) { $this->addPlatformKey($serial, (string) file_get_contents($file)); } } if (! isset($this->platformKeys[$serial])) { throw new SiyunPayException( '缺少序列号为 ' . $serial . ' 的平台公钥,请在商户控制台「密钥管理」中下载并放入 platform_key_dir,或定期调用 refreshCertificates()', 'PLATFORM_KEY_MISSING' ); } return $this->platformKeys[$serial]; } private function addPlatformKey(string $serial, string $pem): void { $publicKey = openssl_pkey_get_public($pem); if ($publicKey === false) { throw new SiyunPayException('平台公钥格式不正确:' . $serial, 'CONFIG_ERROR'); } $this->platformKeys[strtoupper($serial)] = $publicKey; } private function platformKeyFile(string $serial): string { return $this->platformKeyDir . DIRECTORY_SEPARATOR . 'siyun_platform_public_' . $serial . '.pem'; } private function serverHeaders(): array { $headers = []; foreach ($_SERVER as $name => $value) { if (is_string($value) && strpos((string) $name, 'HTTP_') === 0) { $headers[strtolower(str_replace('_', '-', substr((string) $name, 5)))] = trim($value); } } return $headers; } private function normalizeHeaders(array $headers): array { $result = []; foreach ($headers as $name => $value) { if (is_array($value)) { $value = $value === [] ? '' : reset($value); } $result[strtolower((string) $name)] = trim((string) $value); } return $result; } }