├── .editorconfig ├── README.md ├── composer.json ├── config └── wechatpay-v3.php └── src ├── .gitkeep ├── Auth ├── AccessToken.php └── ServiceProvider.php ├── Exception └── Exception.php ├── Facades └── WeChatPay.php ├── Factory.php ├── Kernel ├── BaseClient.php ├── Certificate.php ├── Exceptions │ ├── DecryptException.php │ ├── Exception.php │ ├── HashException.php │ ├── HttpException.php │ ├── InvalidArgumentException.php │ ├── RuntimeException.php │ └── SignInvalidException.php ├── ServiceContainer.php ├── Traits │ ├── HasHttpRequests.php │ ├── ResponseCastable.php │ ├── RestfulMethods.php │ └── SignatureGenerator.php └── Utils │ ├── AesUtil.php │ └── RsaUtil.php ├── Service ├── Application.php ├── Apply4Sub │ └── SubMerchant │ │ ├── Client.php │ │ └── ServiceProvider.php ├── Bill │ ├── Client.php │ └── ServiceProvider.php ├── Certificate │ ├── Client.php │ └── ServiceProvider.php ├── CombineTransaction │ ├── Client.php │ └── ServiceProvider.php ├── Ecommerce │ ├── Applyment │ │ ├── Client.php │ │ └── ServiceProvider.php │ ├── Fund │ │ ├── Balance │ │ │ ├── Client.php │ │ │ └── ServiceProvider.php │ │ └── Withdraw │ │ │ ├── Client.php │ │ │ └── ServiceProvider.php │ ├── ProfitSharing │ │ ├── FinishOrder │ │ │ ├── Client.php │ │ │ └── ServiceProvider.php │ │ ├── Order │ │ │ ├── Client.php │ │ │ └── ServiceProvider.php │ │ └── ReturnOrder │ │ │ ├── Client.php │ │ │ └── ServiceProvider.php │ ├── Refund │ │ ├── Client.php │ │ └── ServiceProvider.php │ └── Subsidy │ │ ├── Client.php │ │ └── ServiceProvider.php ├── Merchant │ └── Media │ │ ├── Client.php │ │ └── ServiceProvider.php └── Notify │ ├── Client.php │ └── ServiceProvider.php └── ServiceProvider.php /.editorconfig: -------------------------------------------------------------------------------- 1 | root = true 2 | 3 | [*] 4 | indent_style = space 5 | indent_size = 4 6 | end_of_line = lf 7 | charset = utf-8 8 | trim_trailing_whitespace = true 9 | insert_final_newline = false 10 | 11 | [*.{vue,js,scss}] 12 | charset = utf-8 13 | indent_style = space 14 | indent_size = 2 15 | end_of_line = lf 16 | insert_final_newline = true 17 | trim_trailing_whitespace = true 18 | 19 | [*.md] 20 | trim_trailing_whitespace = false -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # laravel-wechatpay-v3 2 | 3 | 用于 Laravel/Lumen 框架的微信支付 V3 的 API 4 | 5 | ## 安装 6 | 不低于 Laravel 5.7 7 | 8 | ```shell 9 | $ composer require mucts/laravel-wechatpay-v3:^1.0 10 | ``` 11 | 12 | ## Laravel 配置方法 13 | 14 | 由于设置了 Laravel providers 自动加载,所以不需要额外操作。 15 | 16 | ## Lumen 配置方法 17 | 18 | 在 `bootstrap/app.php` 中增加: 19 | ```php 20 | $app->register(MuCTS\Laravel\WeChatPayV3\ServiceProvider::class); 21 | ``` 22 | 23 | ## 使用 24 | ### API 列表 25 | ```php 26 | use MuCTS\Laravel\WeChatPayV3\Facades\WeChatPay; 27 | 28 | $weChatPay = WeChatPay::app(); 29 | 30 | // 证书目录 31 | $weChatPay->certificate->all($query, $options); 32 | 33 | // 解析异步通知 34 | $weChatPay->notify->parseResponse($response); 35 | 36 | // 上传媒体文件 37 | $weChatPay->media->upload($fileName, $content, $mimeType, $options); 38 | 39 | // 子商户入驻(申请) 40 | $weChatPay->applyment->create($params, $options); 41 | 42 | // 子商户入驻(查询) 43 | $weChatPay->applyment->retrieve($id, $query, $options); 44 | 45 | // 合单支付(app) 46 | $weChatPay->combineTransaction->createByApp($params, $options); 47 | 48 | // 合单支付(jsApi) 49 | $weChatPay->combineTransaction->createByJsApi($params, $options); 50 | 51 | // 合单支付查询 52 | $weChatPay->combineTransaction->retrieveByOutTradeNo($outTradeNo, $query, $options); // 使用商户订单号 53 | 54 | // 合单支付关闭 55 | $weChatPay->combineTransaction->closeByOutTradeNo($outTradeNo, $query, $options); // 使用商户订单号 56 | 57 | // 退款(发起) 58 | $weChatPay->refund->create($params, $options); 59 | 60 | // 退款(查询) 61 | $weChatPay->refund->retrieveByOutRefundNo($id, $query, $options); // 使用商户退款单号 62 | $weChatPay->refund->retrieve($id, $query, $options); // 使用微信退款单号 63 | 64 | // 分账(请求分账) 65 | $weChatPay->profitSharingOrder->create($params, $options); 66 | 67 | // 分账(查询分账) 68 | $weChatPay->profitSharingOrder->retrieve($id, $query, $options); 69 | 70 | // 分账(请求分账回退) 71 | $weChatPay->profitSharingReturnOrder->create($params, $options); 72 | 73 | // 分账(查询分账回退) 74 | $weChatPay->profitSharingReturnOrder->retrieve($id, $query, $options); 75 | 76 | // 分账(完结分账) 77 | $weChatPay->profitSharingFinishOrder->create($params, $options); 78 | 79 | // 提现(发起) 80 | $weChatPay->withdraw->create($params, $options); 81 | 82 | // 提现(查询) 83 | $weChatPay->withdraw->retrieve($id, $query, $options); 84 | 85 | // 查询余额 86 | $weChatPay->balance->retrieve($subMerchantId, $query, $options); 87 | 88 | // 申请交易账单 89 | $weChatPay->bill->retrieveTradeBill($query, $options); 90 | 91 | // 申请资金账单 92 | $weChatPay->bill->retrieveFundFlowBill($query, $options); 93 | 94 | // 账单文件下载 95 | $weChatPay->bill->download($body); // $body 使用申请交易账单或申请资金账单接口返回的数据 96 | ``` 97 | 98 | ### 敏感参数加解密 99 | 在设置请求的参数($query 或 $params)时,无需手动对敏感参数进行加解密。仅需要在 $options 参数中申明需要加解密的参数(支持点运算符)即可。 100 | 例如: 101 | ```php 102 | $options = [ 103 | // 加密 104 | 'encode_params' => [ 105 | 'id_card_info.id_card_name', 106 | 'id_card_info.id_card_number', 107 | 'account_info.account_name', 108 | 'account_info.account_number', 109 | 'contact_info.contact_name', 110 | 'contact_info.contact_id_card_number', 111 | 'contact_info.mobile_phone', 112 | 'contact_info.contact_email', 113 | ], 114 | // 解密 115 | 'decode_params' => [ 116 | 'account_validation.account_name', 117 | 'account_validation.pay_amount', 118 | ] 119 | ]; 120 | 121 | ``` -------------------------------------------------------------------------------- /composer.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "mucts\/laravel-wechatpay-v3", 3 | "description": "用于 Laravel/Lumen 框架的微信支付 API v3 组件", 4 | "authors": [ 5 | { 6 | "name": "Herry", 7 | "email": "yuandeng@aliyun.com" 8 | } 9 | ], 10 | "require": { 11 | "ext-curl": "^7.2", 12 | "ext-json": "^7.2", 13 | "ext-openssl": "^7.2", 14 | "php": "^7.2", 15 | "illuminate/support": "^7.5", 16 | "illuminate/container": "^7.5", 17 | "illuminate/translation": "^7.5", 18 | "guzzlehttp/guzzle": "^6.5", 19 | "pimple/pimple": "^3.3" 20 | }, 21 | "require-dev": { 22 | "phpunit/phpunit": "^9.1", 23 | "orchestra/testbench": "^5.1" 24 | }, 25 | "autoload": { 26 | "psr-4": { 27 | "MuCTS\\Laravel\\WeChatPayV3\\": "src" 28 | } 29 | }, 30 | "autoload-dev": { 31 | "psr-4": { 32 | "MuCTS\\Laravel\\WeChatPayV3\\Test\\": "tests" 33 | } 34 | }, 35 | "extra": { 36 | "laravel": { 37 | "providers": [ 38 | "MuCTS\\Laravel\\WeChatPayV3\\ServiceProvider" 39 | ], 40 | "aliases": { 41 | "WeChatPayV3": "MuCTS\\Laravel\\WeChatPayV3\\Facades\\WeChatPayV3" 42 | } 43 | } 44 | }, 45 | "repositories": { 46 | "packagist": { 47 | "type": "composer", 48 | "url": "https://mirrors.aliyun.com/composer/" 49 | } 50 | } 51 | } 52 | -------------------------------------------------------------------------------- /config/wechatpay-v3.php: -------------------------------------------------------------------------------- 1 | env('WECHATPAY_V3_APPID'), 5 | 'aes_key' => env('WECHATPAY_V3_AES_KEY'), 6 | 'private_key' => env('WECHATPAY_V3_PRIVATE_KEY'), 7 | 'serial_no' => env('WECHATPAY_V3_SERIAL_NO'), 8 | ]; 9 | -------------------------------------------------------------------------------- /src/.gitkeep: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/mucts/laravel-wechatpay-v3/04d1c8ec3132343b527769844ab49c224e7a6751/src/.gitkeep -------------------------------------------------------------------------------- /src/Auth/AccessToken.php: -------------------------------------------------------------------------------- 1 | app = $app; 17 | } 18 | 19 | /** 20 | * {@inheritdoc} 21 | */ 22 | protected function getCredentials(): array 23 | { 24 | return [ 25 | 'grant_type' => 'client_credential', 26 | 'appid' => $this->app['config']['app_id'], 27 | 'secret' => $this->app['config']['secret'], 28 | ]; 29 | } 30 | } 31 | -------------------------------------------------------------------------------- /src/Auth/ServiceProvider.php: -------------------------------------------------------------------------------- 1 | app = $app; 34 | } 35 | 36 | /** 37 | * verify the response with certificate 38 | * 39 | * @return \Closure 40 | */ 41 | protected function certificateMiddleware() 42 | { 43 | return Middleware::tap(null, function (RequestInterface $request, $options, ResponseInterface $response) { 44 | 45 | }); 46 | } 47 | 48 | /** 49 | * @param string $url 50 | * @param string $method 51 | * @param array $options 52 | * 53 | * @return mixed|\Psr\Http\Message\ResponseInterface 54 | * 55 | * @throws \GuzzleHttp\Exception\GuzzleException 56 | * @throws \Throwable 57 | */ 58 | protected function request(string $method, string $url, array $options = []) 59 | { 60 | $response = $this->requestRaw($method, $url, $options); 61 | 62 | return $this->castResponse($response); 63 | } 64 | 65 | /** 66 | * @param string $method 67 | * @param string $url 68 | * @param array $options 69 | * @return ResponseInterface 70 | * @throws \GuzzleHttp\Exception\GuzzleException 71 | */ 72 | protected function requestRaw(string $method, string $url, array $options = []) 73 | { 74 | if (empty($this->middlewares)) { 75 | $this->registerHttpMiddleware(); 76 | } 77 | 78 | return $this->performRequest($url, $method, $options); 79 | } 80 | 81 | protected function registerHttpMiddleware() 82 | { 83 | // sensitive param 84 | $this->pushMiddleware($this->sensitiveParamMiddleware(), 'sensitive_param'); 85 | 86 | // auth 87 | $this->pushMiddleware($this->authMiddleware(), 'auth'); 88 | 89 | // verify sign 90 | $this->pushMiddleware($this->verifySignMiddleware(), 'verify_sign'); 91 | 92 | // retry 93 | $this->pushMiddleware($this->retryMiddleware(), 'retry'); 94 | } 95 | 96 | /** 97 | * Encrypt/Decrypt sensitive param 98 | * 99 | * @return \Closure 100 | */ 101 | protected function sensitiveParamMiddleware() 102 | { 103 | return function (callable $handler) { 104 | return function ( 105 | RequestInterface $request, 106 | array $options 107 | ) use ($handler) { 108 | $encodeParams = Arr::get($options, 'encode_params', []); 109 | if (!empty($encodeParams)) { 110 | $body = $request->getBody()->getContents(); 111 | $request->getBody()->rewind(); 112 | $params = json_decode($body, true); 113 | if (!empty($params)) { 114 | $certificate = (new Certificate($this->app)); 115 | $serialNo = $certificate->getAvailableSerialNo(); 116 | foreach ($encodeParams as $encodeParam) { 117 | $value = Arr::get($params, $encodeParam); 118 | if (!is_null($value)) { 119 | $encrypted = RsaUtil::publicEncrypt( 120 | $value, 121 | $certificate->getPublicKey($serialNo) 122 | ); 123 | Arr::set($params, $encodeParam, $encrypted); 124 | } 125 | } 126 | $request = $request->withBody(Psr7\stream_for(json_encode($params))); 127 | } 128 | $request = $request->withHeader('WeChatPay-Serial', $serialNo); 129 | } 130 | 131 | /** @var ResponseInterface $response */ 132 | $response = $handler($request, $options); 133 | $decodeParams = Arr::get($options, 'decode_params', []); 134 | if (!empty($decodeParams)) { 135 | $body = $response->getBody()->getContents(); 136 | $response->getBody()->rewind(); 137 | $params = json_decode($body, true); 138 | if (!empty($params)) { 139 | foreach ($decodeParams as $decodeParam) { 140 | $value = Arr::get($params, $decodeParam); 141 | if (!is_null($value)) { 142 | $decryptedValue = RsaUtil::privateDecrypt( 143 | $value, 144 | Config::get('wechatpay-v3.private_key') 145 | ); 146 | 147 | Arr::set($params, $decodeParam, $decryptedValue); 148 | } 149 | } 150 | $response = $response->withBody(Psr7\stream_for(json_encode($params))); 151 | } 152 | } 153 | 154 | return $response; 155 | }; 156 | }; 157 | } 158 | 159 | /** 160 | * Attache auth to the request header. 161 | * 162 | * @return \Closure 163 | */ 164 | protected function authMiddleware() 165 | { 166 | return function (callable $handler) { 167 | return function ( 168 | RequestInterface $request, 169 | array $options 170 | ) use ($handler) { 171 | $request = $request->withHeader('Accept', 'application/json'); 172 | $auth = $this->authHeader($request, $options); 173 | $request = $request->withHeader('Authorization', $auth); 174 | 175 | return $handler($request, $options); 176 | }; 177 | }; 178 | } 179 | 180 | /** 181 | * Attache auth to the request header. 182 | * 183 | * @return \Closure 184 | */ 185 | protected function verifySignMiddleware() 186 | { 187 | return function (callable $handler) { 188 | return function ( 189 | RequestInterface $request, 190 | array $options 191 | ) use ($handler) { 192 | /** @var Promise $promise */ 193 | $promise = $handler($request, $options); 194 | 195 | return $promise->then( 196 | function (ResponseInterface $response) { 197 | if (!$this->isResponseSignValid($response)) { 198 | throw new SignInvalidException('响应验签失败'); 199 | } 200 | 201 | return $response; 202 | } 203 | ); 204 | }; 205 | }; 206 | } 207 | 208 | /** 209 | * Return retry middleware. 210 | * 211 | * @return \Closure 212 | */ 213 | protected function retryMiddleware() 214 | { 215 | return Middleware::retry(function ( 216 | $retries, 217 | RequestInterface $request, 218 | ResponseInterface $response = null 219 | ) { 220 | if ($retries >= Config::get('http.max_retries', 1)) { 221 | return false; 222 | } 223 | 224 | if (is_null($response) || !in_array($response->getStatusCode(), [429, 500, 502, 503])) { 225 | return false; 226 | } 227 | 228 | return true; 229 | }, function ($retries, ResponseInterface $response) { 230 | if ($response->getStatusCode() == 429) { 231 | // too many request (FREQUENCY_LIMITED) 232 | return $retries * 5000; 233 | } 234 | 235 | return abs(Config::get('http.retry_delay', 500)); 236 | }); 237 | } 238 | } -------------------------------------------------------------------------------- /src/Kernel/Certificate.php: -------------------------------------------------------------------------------- 1 | app = $app; 34 | } 35 | 36 | /** 37 | * @return mixed 38 | */ 39 | public function getAvailableSerialNo() 40 | { 41 | $ttl = Carbon::now()->addHours(12); 42 | 43 | return Cache::remember(self::SERIAL_NUMBER_CACHE, $ttl, function () use ($ttl) { 44 | /** @var Client $certificateClient */ 45 | $certificateClient = $this->app['certificate']; 46 | $certificates = collect(Arr::get($certificateClient->all(), 'data')); 47 | if ($certificates->isEmpty()) { 48 | throw new SignInvalidException('没有可用的平台证书列表'); 49 | } 50 | $certificate = $certificates->reduce(function ($carry, $certificate) { 51 | if (empty($carryExpireTime = Arr::get($carry, 'expire_time'))) { 52 | return $certificate; 53 | } 54 | $carryExpireTime = Carbon::createFromTimeString($carryExpireTime); 55 | $expireTime = Carbon::createFromTimeString(Arr::get($certificate, 'expire_time')); 56 | 57 | return $carryExpireTime->gt($expireTime) ? $carryExpireTime : $expireTime; 58 | }); 59 | if (!$certificate) { 60 | throw new SignInvalidException('没有可用的平台证书'); 61 | } 62 | $serialNo = Arr::get($certificate, 'serial_no'); 63 | $aesKey = Config::get('wechatpay-v3.aes_key', ''); 64 | $publicKey = $this->decryptCertificate(Arr::get($certificate, 'encrypt_certificate'), $aesKey); 65 | Cache::put($this->getPublicKeyCacheKey($serialNo), $publicKey, $ttl); 66 | 67 | return $serialNo; 68 | }); 69 | } 70 | 71 | /** 72 | * @param $encryptCertificate 73 | * @param $aesKey 74 | * @return bool|string 75 | * @throws InvalidArgumentException 76 | * @throws RuntimeException 77 | * @throws DecryptException 78 | */ 79 | private function decryptCertificate($encryptCertificate, $aesKey) 80 | { 81 | $associatedData = Arr::get($encryptCertificate, 'associated_data'); 82 | $nonceStr = Arr::get($encryptCertificate, 'nonce'); 83 | $cipherText = Arr::get($encryptCertificate, 'ciphertext'); 84 | $publicKey = (new AesUtil($aesKey))->decryptAES256GCM($associatedData, $nonceStr, $cipherText); 85 | if (!$publicKey) { 86 | throw new DecryptException('解密证书失败'); 87 | } 88 | 89 | return $publicKey; 90 | } 91 | 92 | /** 93 | * @param $serialNo 94 | * @return string 95 | */ 96 | private function getPublicKeyCacheKey($serialNo) 97 | { 98 | return self::CERTIFICATE_CACHE_PREFIX.$serialNo; 99 | } 100 | 101 | /** 102 | * @param $serialNo 103 | * @return mixed 104 | * @throws SignInvalidException 105 | */ 106 | public function getPublicKey($serialNo) 107 | { 108 | $ttl = Carbon::now()->addHours(12); 109 | 110 | return Cache::remember($this->getPublicKeyCacheKey($serialNo), $ttl, function () use ($serialNo, $ttl) { 111 | /** @var Client $certificateClient */ 112 | $certificateClient = $this->app['certificate']; 113 | $certificates = collect(Arr::get($certificateClient->all(), 'data')); 114 | $certificate = $certificates->firstWhere('serial_no', '=', $serialNo); 115 | if (empty($certificate)) { 116 | throw new SignInvalidException('证书序列号不存在于可用的证书列表中'); 117 | } 118 | $aesKey = Config::get('wechatpay-v3.aes_key', ''); 119 | $publicKey = $this->decryptCertificate(Arr::get($certificate, 'encrypt_certificate'), $aesKey); 120 | Cache::put(self::SERIAL_NUMBER_CACHE, $serialNo, $ttl); 121 | 122 | return $publicKey; 123 | }); 124 | } 125 | 126 | } -------------------------------------------------------------------------------- /src/Kernel/Exceptions/DecryptException.php: -------------------------------------------------------------------------------- 1 | response = $response; 32 | $this->formattedResponse = $formattedResponse; 33 | 34 | if ($response) { 35 | $response->getBody()->rewind(); 36 | } 37 | } 38 | } 39 | -------------------------------------------------------------------------------- /src/Kernel/Exceptions/InvalidArgumentException.php: -------------------------------------------------------------------------------- 1 | registerProviders($this->getProviders()); 20 | 21 | parent::__construct($values); 22 | } 23 | 24 | /** 25 | * @param array $providers 26 | */ 27 | public function registerProviders(array $providers) 28 | { 29 | foreach ($providers as $provider) { 30 | parent::register(new $provider()); 31 | } 32 | } 33 | 34 | /** 35 | * Return all providers. 36 | * 37 | * @return array 38 | */ 39 | public function getProviders() 40 | { 41 | return array_merge([ 42 | // 43 | ], $this->providers); 44 | } 45 | 46 | /** 47 | * Magic get access. 48 | * 49 | * @param string $id 50 | * 51 | * @return mixed 52 | */ 53 | public function __get($name) 54 | { 55 | return $this->offsetGet($name); 56 | } 57 | 58 | /** 59 | * Magic set access. 60 | * 61 | * @param string $id 62 | * @param mixed $value 63 | */ 64 | public function __set($id, $value) 65 | { 66 | $this->offsetSet($id, $value); 67 | } 68 | 69 | } -------------------------------------------------------------------------------- /src/Kernel/Traits/HasHttpRequests.php: -------------------------------------------------------------------------------- 1 | [ 21 | CURLOPT_IPRESOLVE => CURL_IPRESOLVE_V4, 22 | ], 23 | ]; 24 | /** 25 | * @var \GuzzleHttp\ClientInterface 26 | */ 27 | protected $httpClient; 28 | /** 29 | * @var array 30 | */ 31 | protected $middlewares = []; 32 | /** 33 | * @var \GuzzleHttp\HandlerStack 34 | */ 35 | protected $handlerStack; 36 | 37 | /** 38 | * Set guzzle default settings. 39 | * 40 | * @param array $defaults 41 | */ 42 | public static function setDefaultOptions($defaults = []) 43 | { 44 | self::$defaults = $defaults; 45 | } 46 | 47 | /** 48 | * Return current guzzle default settings. 49 | * 50 | * @return array 51 | */ 52 | public static function getDefaultOptions(): array 53 | { 54 | return self::$defaults; 55 | } 56 | 57 | /** 58 | * Add a middleware. 59 | * 60 | * @param callable $middleware 61 | * @param string|null $name 62 | * 63 | * @return $this 64 | */ 65 | public function pushMiddleware(callable $middleware, string $name = null) 66 | { 67 | if (!is_null($name)) { 68 | $this->middlewares[$name] = $middleware; 69 | } else { 70 | array_push($this->middlewares, $middleware); 71 | } 72 | 73 | return $this; 74 | } 75 | 76 | /** 77 | * Return all middlewares. 78 | * 79 | * @return array 80 | */ 81 | public function getMiddlewares(): array 82 | { 83 | return $this->middlewares; 84 | } 85 | 86 | /** 87 | * Make a request. 88 | * 89 | * @param string $url 90 | * @param string $method 91 | * @param array $options 92 | * 93 | * @return ResponseInterface 94 | * @throws \GuzzleHttp\Exception\GuzzleException 95 | */ 96 | public function request($url, $method = 'GET', $options = []): ResponseInterface 97 | { 98 | $method = strtoupper($method); 99 | 100 | $options = array_merge(self::$defaults, $options, ['handler' => $this->getHandlerStack()]); 101 | 102 | $options = $this->fixJsonIssue($options); 103 | 104 | if (property_exists($this, 'baseUri') && !is_null($this->baseUri)) { 105 | $options['base_uri'] = $this->baseUri; 106 | } 107 | 108 | $response = $this->getHttpClient()->request($method, $url, $options); 109 | $response->getBody()->rewind(); 110 | 111 | return $response; 112 | } 113 | 114 | /** 115 | * Build a handler stack. 116 | * 117 | * @return \GuzzleHttp\HandlerStack 118 | */ 119 | public function getHandlerStack(): HandlerStack 120 | { 121 | if ($this->handlerStack) { 122 | return $this->handlerStack; 123 | } 124 | 125 | $this->handlerStack = HandlerStack::create($this->getGuzzleHandler()); 126 | 127 | foreach ($this->middlewares as $name => $middleware) { 128 | $this->handlerStack->push($middleware, $name); 129 | } 130 | 131 | return $this->handlerStack; 132 | } 133 | 134 | /** 135 | * @param \GuzzleHttp\HandlerStack $handlerStack 136 | * 137 | * @return $this 138 | */ 139 | public function setHandlerStack(HandlerStack $handlerStack) 140 | { 141 | $this->handlerStack = $handlerStack; 142 | 143 | return $this; 144 | } 145 | 146 | /** 147 | * Get guzzle handler. 148 | * 149 | * @return callable 150 | */ 151 | protected function getGuzzleHandler() 152 | { 153 | $handler = Arr::get($this->getHttpClient()->getConfig(), 'handler'); 154 | if ($handler instanceof HandlerStack) { 155 | return $handler; 156 | } 157 | 158 | if (property_exists($this, 'app') && isset($this->app['guzzle_handler'])) { 159 | return is_string($handler = $this->app->raw('guzzle_handler')) 160 | ? new $handler() 161 | : $handler; 162 | } 163 | 164 | return \GuzzleHttp\choose_handler(); 165 | } 166 | 167 | /** 168 | * Return GuzzleHttp\ClientInterface instance. 169 | * 170 | * @return ClientInterface 171 | */ 172 | public function getHttpClient(): ClientInterface 173 | { 174 | if ($this->httpClient instanceof ClientInterface) { 175 | return $this->httpClient; 176 | } 177 | 178 | if (Container::getInstance()->has('guzzle')) { 179 | $this->httpClient = App::make('guzzle'); 180 | } else { 181 | $this->httpClient = new Client(); 182 | } 183 | 184 | return $this->httpClient; 185 | } 186 | 187 | /** 188 | * Set GuzzleHttp\Client. 189 | * 190 | * @param \GuzzleHttp\ClientInterface $httpClient 191 | * 192 | * @return $this 193 | */ 194 | public function setHttpClient(ClientInterface $httpClient) 195 | { 196 | $this->httpClient = $httpClient; 197 | 198 | return $this; 199 | } 200 | 201 | /** 202 | * @param array $options 203 | * 204 | * @return array 205 | */ 206 | protected function fixJsonIssue(array $options): array 207 | { 208 | if (isset($options['json']) && is_array($options['json'])) { 209 | $options['headers'] = array_merge($options['headers'] ?? [], ['Content-Type' => 'application/json']); 210 | 211 | if (empty($options['json'])) { 212 | $options['body'] = \GuzzleHttp\json_encode($options['json'], JSON_FORCE_OBJECT); 213 | } else { 214 | $options['body'] = \GuzzleHttp\json_encode($options['json'], JSON_UNESCAPED_UNICODE); 215 | } 216 | 217 | unset($options['json']); 218 | } 219 | 220 | return $options; 221 | } 222 | } 223 | -------------------------------------------------------------------------------- /src/Kernel/Traits/ResponseCastable.php: -------------------------------------------------------------------------------- 1 | getBody()->rewind(); 17 | $contents = $response->getBody()->getContents(); 18 | $response->getBody()->rewind(); 19 | 20 | $array = json_decode($contents, true, 512, JSON_BIGINT_AS_STRING); 21 | 22 | if (JSON_ERROR_NONE === json_last_error()) { 23 | return (array)$array; 24 | } 25 | 26 | return []; 27 | } 28 | } -------------------------------------------------------------------------------- /src/Kernel/Traits/RestfulMethods.php: -------------------------------------------------------------------------------- 1 | $query]; 20 | 21 | return $this->request('GET', $url, $opts); 22 | } 23 | 24 | public static function classUrl() 25 | { 26 | return '/v3/'.static::className().'/'; 27 | } 28 | 29 | public static function className() 30 | { 31 | $className = get_called_class(); 32 | $classes = explode('\\', $className); 33 | $classes = array_slice($classes, 2, count($classes) - 3); 34 | foreach ($classes as $key => $val) { 35 | $classes[$key] = $key == count($classes) - 1 ? Str::plural(Str::snake($val)) : strtolower($val); 36 | }; 37 | 38 | return implode('/', $classes); 39 | } 40 | 41 | /** 42 | * @param string $id 43 | * @param string $query 44 | * @param array $options 45 | * @return mixed|\Psr\Http\Message\ResponseInterface 46 | * @throws \GuzzleHttp\Exception\GuzzleException 47 | * @throws \Throwable 48 | */ 49 | protected function retrieve(string $id, $query = null, array $options = []) 50 | { 51 | $url = $this->instanceUrl($id); 52 | $opts = $options + ['query' => $query]; 53 | 54 | return $this->request('GET', $url, $opts); 55 | } 56 | 57 | public function instanceUrl($id) 58 | { 59 | return self::classUrl().$id; 60 | } 61 | 62 | /** 63 | * @param array $params 64 | * @param array $options 65 | * @return mixed|\Psr\Http\Message\ResponseInterface 66 | * @throws \GuzzleHttp\Exception\GuzzleException 67 | * @throws \Throwable 68 | */ 69 | protected function create(array $params, array $options = []) 70 | { 71 | $url = self::classUrl(); 72 | $opts = $options + ['json' => $params]; 73 | 74 | return $this->request('POST', $url, $opts); 75 | } 76 | 77 | /** 78 | * @param string $id 79 | * @param array $params 80 | * @param array $options 81 | * @return mixed|\Psr\Http\Message\ResponseInterface 82 | * @throws \GuzzleHttp\Exception\GuzzleException 83 | * @throws \Throwable 84 | */ 85 | protected function update(string $id, array $params, array $options = []) 86 | { 87 | $url = self::instanceUrl($id); 88 | $opts = $options + ['json' => $params]; 89 | 90 | return $this->request('PUT', $url, $opts); 91 | } 92 | 93 | /** 94 | * @param string $id 95 | * @param string $query 96 | * @param array $options 97 | * @return mixed|\Psr\Http\Message\ResponseInterface 98 | * @throws \GuzzleHttp\Exception\GuzzleException 99 | * @throws \Throwable 100 | */ 101 | protected function destroy(string $id, string $query, array $options = []) 102 | { 103 | $url = self::instanceUrl($id); 104 | $opts = $options + ['query' => $query]; 105 | 106 | return $this->request('DELETE', $url, $opts); 107 | } 108 | } 109 | -------------------------------------------------------------------------------- /src/Kernel/Traits/SignatureGenerator.php: -------------------------------------------------------------------------------- 1 | getUri()->getPath(); 26 | $request->getUri()->getQuery() && $uri .= ('?'.$request->getUri()->getQuery()); 27 | $payload = [ 28 | 'method' => strtoupper($request->getMethod()), 29 | 'uri' => $uri, 30 | 'timestamp' => time(), 31 | 'nonce_str' => strtoupper(Str::random(32)), 32 | 'body' => Arr::get($options, 'sign_payload', function () use ($request) { 33 | $body = $request->getBody()->getContents(); 34 | $request->getBody()->rewind(); 35 | 36 | return $body; 37 | }), 38 | ]; 39 | $authFormat = '%s mchid="%s",serial_no="%s",nonce_str="%s",timestamp="%s",signature="%s"'; 40 | 41 | return sprintf($authFormat, ...[ 42 | $this->authType, 43 | Config::get('wechatpay-v3.app_id'), 44 | Config::get('wechatpay-v3.serial_no'), 45 | $payload['nonce_str'], 46 | $payload['timestamp'], 47 | $this->sign($payload), 48 | ]); 49 | } 50 | 51 | /** 52 | * 根据商户私钥生成签名 53 | * 54 | * @param array $payload 55 | * @return string 56 | */ 57 | public function sign(array $payload) 58 | { 59 | $signData = implode("\n", $payload)."\n"; 60 | $clientKey = Config::get('wechatpay-v3.private_key'); 61 | openssl_sign($signData, $sign, $clientKey, OPENSSL_ALGO_SHA256); 62 | 63 | return base64_encode($sign); 64 | } 65 | 66 | /** 67 | * 验证响应的签名 68 | * 69 | * @param ResponseInterface $response 70 | * @return bool 71 | * @throws SignInvalidException 72 | */ 73 | protected function isResponseSignValid(ResponseInterface $response) 74 | { 75 | $headers = $response->getHeaders(); 76 | $payload = [ 77 | 'timestamp' => Arr::get($headers, 'Wechatpay-Timestamp.0'), 78 | 'nonce_str' => Arr::get($headers, 'Wechatpay-Nonce.0'), 79 | 'body' => $response->getBody()->getContents(), 80 | ]; 81 | $response->getBody()->rewind(); 82 | 83 | $signData = implode("\n", $payload)."\n"; 84 | $responseSign = base64_decode(Arr::get($headers, 'Wechatpay-Signature.0')); 85 | $serialNo = Arr::get($headers, 'Wechatpay-Serial.0'); 86 | if (empty($serialNo)) { 87 | if (substr(strval($response->getStatusCode()), 0, 1) == '2') { 88 | throw new SignInvalidException('响应中不存在证书序列号'); 89 | } 90 | 91 | return true; 92 | } 93 | $publicKey = (new Certificate($this->app))->getPublicKey($serialNo); 94 | 95 | return boolval(openssl_verify( 96 | $signData, 97 | $responseSign, 98 | $publicKey, 99 | OPENSSL_ALGO_SHA256 100 | )); 101 | } 102 | } 103 | -------------------------------------------------------------------------------- /src/Kernel/Utils/AesUtil.php: -------------------------------------------------------------------------------- 1 | aesKey = $aesKey; 31 | } 32 | 33 | /** 34 | * @param $associatedData 35 | * @param $nonceStr 36 | * @param $cipherText 37 | * @return bool|string 38 | * @throws RuntimeException 39 | */ 40 | public function decryptAES256GCM($associatedData, $nonceStr, $cipherText) 41 | { 42 | $cipherText = \base64_decode($cipherText); 43 | if (strlen($cipherText) <= self::AUTH_TAG_LENGTH_BYTE) { 44 | return false; 45 | } 46 | 47 | // ext-sodium (default installed on >= PHP 7.2) 48 | if (function_exists('\sodium_crypto_aead_aes256gcm_is_available') && 49 | \sodium_crypto_aead_aes256gcm_is_available()) { 50 | return \sodium_crypto_aead_aes256gcm_decrypt($cipherText, $associatedData, $nonceStr, $this->aesKey); 51 | } 52 | 53 | // ext-libsodium (need install libsodium-php 1.x via pecl) 54 | if (function_exists('\Sodium\crypto_aead_aes256gcm_is_available') && 55 | \Sodium\crypto_aead_aes256gcm_is_available()) { 56 | return \Sodium\crypto_aead_aes256gcm_decrypt($cipherText, $associatedData, $nonceStr, $this->aesKey); 57 | } 58 | 59 | // openssl (PHP >= 7.1 support AEAD) 60 | if (PHP_VERSION_ID >= 70100 && in_array('aes-256-gcm', \openssl_get_cipher_methods())) { 61 | $ctext = substr($cipherText, 0, -self::AUTH_TAG_LENGTH_BYTE); 62 | $authTag = substr($cipherText, -self::AUTH_TAG_LENGTH_BYTE); 63 | 64 | return \openssl_decrypt($ctext, 'aes-256-gcm', $this->aesKey, \OPENSSL_RAW_DATA, $nonceStr, 65 | $authTag, $associatedData); 66 | } 67 | 68 | throw new RuntimeException('AEAD_AES_256_GCM需要PHP 7.1以上或者安装libsodium-php'); 69 | } 70 | } -------------------------------------------------------------------------------- /src/Kernel/Utils/RsaUtil.php: -------------------------------------------------------------------------------- 1 | $query]; 25 | 26 | return $this->request('GET', $url, $opts); 27 | } 28 | 29 | /** 30 | * @param string $subMerchantId 31 | * @param array $params 32 | * @param array $options 33 | * @return mixed|\Psr\Http\Message\ResponseInterface 34 | * @throws \GuzzleHttp\Exception\GuzzleException 35 | * @throws \Throwable 36 | */ 37 | public function updateSettlement(string $subMerchantId, array $params, array $options = []) 38 | { 39 | $url = self::classUrl().$subMerchantId.'/modify-settlement'; 40 | $opts = $options + ['json' => $params]; 41 | 42 | return $this->request('POST', $url, $opts); 43 | } 44 | } 45 | -------------------------------------------------------------------------------- /src/Service/Apply4Sub/SubMerchant/ServiceProvider.php: -------------------------------------------------------------------------------- 1 | $query]; 31 | 32 | return $this->request('GET', $url, $opts); 33 | } 34 | 35 | /** 36 | * @param string $id 37 | * @param string|array|null $query 38 | * @param array $options 39 | * @return mixed|\Psr\Http\Message\ResponseInterface 40 | * @throws \GuzzleHttp\Exception\GuzzleException 41 | * @throws \Throwable 42 | */ 43 | public function retrieveFundFlowBill($query = null, array $options = []) 44 | { 45 | $url = self::classUrl().'fundflowbill'; 46 | $opts = $options + ['query' => $query]; 47 | 48 | return $this->request('GET', $url, $opts); 49 | } 50 | 51 | /** 52 | * @param $body 53 | * @return mixed|\Psr\Http\Message\ResponseInterface 54 | * @throws \GuzzleHttp\Exception\GuzzleException 55 | * @throws \Throwable 56 | */ 57 | public function download($body) 58 | { 59 | $response = $this->requestRaw('GET', Arr::get($body, 'download_url')); 60 | $fileStream = $response->getBody()->getContents(); 61 | $response->getBody()->rewind(); 62 | 63 | $hashValue = hash(Arr::get($body, 'hash_type'), $fileStream); 64 | if ($hashValue != Arr::get($body, 'hash_value')) { 65 | throw new HashException('账单文件哈希值错误,请尝试重新下载'); 66 | } 67 | 68 | return $fileStream; 69 | } 70 | } 71 | -------------------------------------------------------------------------------- /src/Service/Bill/ServiceProvider.php: -------------------------------------------------------------------------------- 1 | pushMiddleware($this->authMiddleware(), 'auth'); 21 | 22 | // retry 23 | $this->pushMiddleware($this->retryMiddleware(), 'retry'); 24 | 25 | } 26 | } 27 | -------------------------------------------------------------------------------- /src/Service/Certificate/ServiceProvider.php: -------------------------------------------------------------------------------- 1 | createByChannel('app', $params, $options); 29 | } 30 | 31 | /** 32 | * @param string $channel 值仅可为 app 或 jsapi 33 | * @param array $params 34 | * @param array $options 35 | * @return mixed|\Psr\Http\Message\ResponseInterface 36 | * @throws \GuzzleHttp\Exception\GuzzleException 37 | * @throws \Throwable 38 | */ 39 | public function createByChannel(string $channel, array $params, array $options = []) 40 | { 41 | $url = self::classUrl().$channel; 42 | $opts = $options + ['json' => $params]; 43 | 44 | return $this->request('POST', $url, $opts); 45 | } 46 | 47 | /** 48 | * @param array $params 49 | * @param array $options 50 | * @return mixed|\Psr\Http\Message\ResponseInterface 51 | * @throws \GuzzleHttp\Exception\GuzzleException 52 | * @throws \Throwable 53 | */ 54 | public function createByJsApi(array $params, array $options = []) 55 | { 56 | return $this->createByChannel('jsapi', $params, $options); 57 | } 58 | 59 | /** 60 | * @param string $outRefundNo 61 | * @param string|array|null $query 62 | * @param array $options 63 | * @return mixed|\Psr\Http\Message\ResponseInterface 64 | * @throws \GuzzleHttp\Exception\GuzzleException 65 | * @throws \Throwable 66 | */ 67 | public function retrieveByOutTradeNo(string $outTradeNo, $query = null, array $options = []) 68 | { 69 | $url = self::classUrl().'out-trade-no/'.$outTradeNo; 70 | $opts = $options + ['query' => $query]; 71 | 72 | return $this->request('GET', $url, $opts); 73 | } 74 | 75 | /** 76 | * @param string $outTradeNo 77 | * @param string|array|null $query 78 | * @param array $options 79 | * @return mixed|\Psr\Http\Message\ResponseInterface 80 | * @throws \GuzzleHttp\Exception\GuzzleException 81 | * @throws \Throwable 82 | */ 83 | public function closeByOutTradeNo(string $outTradeNo, $query = null, array $options = []) 84 | { 85 | $url = self::classUrl().'out-trade-no/'.$outTradeNo.'/close'; 86 | $opts = $options + ['query' => $query]; 87 | 88 | return $this->request('POST', $url, $opts); 89 | } 90 | 91 | /** 92 | * @param $appId 93 | * @param $timestamp 94 | * @param $prepayId 95 | */ 96 | public function generateAppPayInfo($appId, $timestamp, $prepayId, $subMerchantId) 97 | { 98 | $payload = [ 99 | 'appId' => $appId, 100 | 'timeStamp' => strval($timestamp), 101 | 'nonceStr' => Str::random(32), 102 | 'prepayId' => $prepayId, 103 | ]; 104 | $payload += [ 105 | 'partnerId' => $subMerchantId, 106 | 'packageValue' => 'Sign=WXPay', 107 | 'paySign' => $this->sign($payload), 108 | ]; 109 | 110 | return $payload; 111 | } 112 | 113 | /** 114 | * @param $appId 115 | * @param $timestamp 116 | * @param $prepayId 117 | */ 118 | public function generateJsApiPayInfo($appId, $timestamp, $prepayId) 119 | { 120 | $payload = [ 121 | 'appId' => $appId, 122 | 'timeStamp' => strval($timestamp), 123 | 'nonceStr' => Str::random(32), 124 | 'package' => 'prepay_id='.$prepayId, 125 | ]; 126 | $payload += [ 127 | 'signType' => 'RSA', 128 | 'paySign' => $this->sign($payload), 129 | ]; 130 | 131 | return $payload; 132 | } 133 | } 134 | -------------------------------------------------------------------------------- /src/Service/CombineTransaction/ServiceProvider.php: -------------------------------------------------------------------------------- 1 | $query]; 23 | 24 | return $this->request('GET', $url, $opts); 25 | } 26 | 27 | /** 28 | * @param array $params 29 | * @param array $options 30 | * @return mixed|\Psr\Http\Message\ResponseInterface 31 | * @throws \GuzzleHttp\Exception\GuzzleException 32 | * @throws \Throwable 33 | */ 34 | public function create(array $params, array $options = []) 35 | { 36 | return parent::create($params, $options); 37 | } 38 | } 39 | -------------------------------------------------------------------------------- /src/Service/Ecommerce/ProfitSharing/Order/ServiceProvider.php: -------------------------------------------------------------------------------- 1 | $query]; 29 | 30 | return $this->request('GET', $url, $opts); 31 | } 32 | 33 | /** 34 | * @param array $params 35 | * @param array $options 36 | * @return mixed|\Psr\Http\Message\ResponseInterface 37 | * @throws \GuzzleHttp\Exception\GuzzleException 38 | * @throws \Throwable 39 | */ 40 | public function create(array $params, array $options = []) 41 | { 42 | return parent::create($params, $options); 43 | } 44 | } 45 | -------------------------------------------------------------------------------- /src/Service/Ecommerce/ProfitSharing/ReturnOrder/ServiceProvider.php: -------------------------------------------------------------------------------- 1 | $params]; 23 | 24 | return $this->request('POST', $url, $opts); 25 | } 26 | 27 | /** 28 | * @param string $outRefundNo 29 | * @param string|array|null $query 30 | * @param array $options 31 | * @return mixed|\Psr\Http\Message\ResponseInterface 32 | * @throws \GuzzleHttp\Exception\GuzzleException 33 | * @throws \Throwable 34 | */ 35 | public function retrieveByOutRefundNo(string $outRefundNo, $query = null, array $options = []) 36 | { 37 | $url = self::classUrl().'out-refund-no/'.$outRefundNo;; 38 | $opts = $options + ['query' => $query]; 39 | 40 | return $this->request('GET', $url, $opts); 41 | } 42 | 43 | /** 44 | * @param string $id 45 | * @param string|array|null $query 46 | * @param array $options 47 | * @return mixed|\Psr\Http\Message\ResponseInterface 48 | * @throws \GuzzleHttp\Exception\GuzzleException 49 | * @throws \Throwable 50 | */ 51 | public function retrieve(string $id, $query = null, array $options = []) 52 | { 53 | $url = self::classUrl().'id/'.$id;; 54 | $opts = $options + ['query' => $query]; 55 | 56 | return $this->request('GET', $url, $opts); 57 | } 58 | } 59 | -------------------------------------------------------------------------------- /src/Service/Ecommerce/Refund/ServiceProvider.php: -------------------------------------------------------------------------------- 1 | $params]; 24 | 25 | return $this->request('POST', $url, $opts); 26 | } 27 | 28 | /** 29 | * @param array $params 30 | * @param array $options 31 | * @return mixed|\Psr\Http\Message\ResponseInterface 32 | * @throws \GuzzleHttp\Exception\GuzzleException 33 | * @throws \Throwable 34 | */ 35 | public function return(array $params, array $options = []) 36 | { 37 | $url = self::classUrl().'return'; 38 | $opts = $options + ['json' => $params]; 39 | 40 | return $this->request('POST', $url, $opts); 41 | } 42 | 43 | /** 44 | * @param array $params 45 | * @param array $options 46 | * @return mixed|\Psr\Http\Message\ResponseInterface 47 | * @throws \GuzzleHttp\Exception\GuzzleException 48 | * @throws \Throwable 49 | */ 50 | public function cancel(array $params, array $options = []) 51 | { 52 | $url = self::classUrl().'cancel'; 53 | $opts = $options + ['json' => $params]; 54 | 55 | return $this->request('POST', $url, $opts); 56 | } 57 | } 58 | -------------------------------------------------------------------------------- /src/Service/Ecommerce/Subsidy/ServiceProvider.php: -------------------------------------------------------------------------------- 1 | $fileName, 25 | 'sha256' => hash('sha256', $content), 26 | ]); 27 | 28 | $multipart = [ 29 | [ 30 | 'name' => 'meta', 31 | 'contents' => $signPayload, 32 | 'headers' => [ 33 | 'Content-Type' => 'application/json', 34 | ], 35 | ], 36 | [ 37 | 'name' => 'file', 38 | 'filename' => $fileName, 39 | 'contents' => $content, 40 | 'headers' => [ 41 | 'Content-Type' => $mimeType, 42 | ], 43 | ], 44 | ]; 45 | 46 | $url = self::classUrl().'upload'; 47 | $opts = $options + ['multipart' => $multipart, 'sign_payload' => $signPayload]; 48 | 49 | return $this->request('POST', $url, $opts); 50 | } 51 | 52 | } 53 | -------------------------------------------------------------------------------- /src/Service/Merchant/Media/ServiceProvider.php: -------------------------------------------------------------------------------- 1 | decryptAES256GCM($associatedData, $nonceStr, $cipherText); 29 | 30 | return \json_decode($data, true); 31 | } 32 | } 33 | -------------------------------------------------------------------------------- /src/Service/Notify/ServiceProvider.php: -------------------------------------------------------------------------------- 1 | setupConfig(); 25 | 26 | $this->app->singleton("wechatpay-v3", function () { 27 | return new Factory(); 28 | }); 29 | } 30 | 31 | /** 32 | * Setup the config. 33 | */ 34 | protected function setupConfig() 35 | { 36 | $source = realpath(__DIR__.'/../config/wechatpay-v3.php'); 37 | 38 | if ($this->app instanceof LaravelApplication && $this->app->runningInConsole()) { 39 | $this->publishes([$source => config_path('wechatpay-v3.php')], 'wechatpay-v3'); 40 | } elseif ($this->app instanceof LumenApplication) { 41 | $this->app->configure('wechatpay-v3'); 42 | } 43 | 44 | $this->mergeConfigFrom($source, 'wechatpay-v3'); 45 | } 46 | } --------------------------------------------------------------------------------