V3.php 23 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562
  1. <?php
  2. /**
  3. * Niushop商城系统 - 团队十年电商经验汇集巨献!
  4. * =========================================================
  5. * Copy right 2019-2029 杭州牛之云科技有限公司, 保留所有权利。
  6. * ----------------------------------------------
  7. * 官方网址: https://www.niushop.com
  8. * =========================================================
  9. */
  10. namespace addon\wechatpay\model;
  11. use app\exception\ApiException;
  12. use app\model\BaseModel;
  13. use app\model\system\Cron;
  14. use app\model\system\Pay as PayCommon;
  15. use app\model\upload\Upload;
  16. use think\exception\HttpException;
  17. use think\facade\Cache;
  18. use think\facade\Log;
  19. use WeChatPay\Builder;
  20. use WeChatPay\ClientDecoratorInterface;
  21. use WeChatPay\Crypto\AesGcm;
  22. use WeChatPay\Crypto\Rsa;
  23. use WeChatPay\Formatter;
  24. use WeChatPay\Util\PemUtil;
  25. use GuzzleHttp\Middleware;
  26. use Psr\Http\Message\ResponseInterface;
  27. /**
  28. * 微信支付v3支付
  29. * 版本 1.0.4
  30. */
  31. class V3 extends BaseModel
  32. {
  33. /**
  34. * 应用实例
  35. * @var \WeChatPay\BuilderChainable
  36. */
  37. private $app;
  38. /**
  39. * @var 平台证书实例
  40. */
  41. private $plateform_certificate_instance;
  42. /**
  43. * @var 平台证书序列号
  44. */
  45. private $plateform_certificate_serial;
  46. /**
  47. * 微信支付配置
  48. */
  49. private $config;
  50. public function __construct($config)
  51. {
  52. $this->config = $config;
  53. $merchant_certificate_instance = PemUtil::loadCertificate(realpath($config['apiclient_cert']));
  54. // 证书序列号
  55. $merchant_certificate_serial = PemUtil::parseCertificateSerialNo($merchant_certificate_instance);
  56. // 检测平台证书是否存在
  57. if (empty($config['plateform_cert'])) {
  58. $create_res = $this->certificates();
  59. if ($create_res['code'] != 0) throw new ApiException(-1, "微信支付配置错误");
  60. // 保存平台证书
  61. $this->config['plateform_cert'] = $create_res['data']['cert_path'];
  62. (new Config())->setPayConfig($this->config, $this->config['site_id']);
  63. }
  64. // 加载平台证书
  65. $this->plateform_certificate_instance = PemUtil::loadCertificate(realpath($this->config['plateform_cert']));
  66. // 平台证书序列号
  67. $this->plateform_certificate_serial = PemUtil::parseCertificateSerialNo($this->plateform_certificate_instance);
  68. $this->app = Builder::factory([
  69. // 商户号
  70. 'mchid' => $config['mch_id'],
  71. // 商户证书序列号
  72. 'serial' => $merchant_certificate_serial,
  73. // 商户API私钥
  74. 'privateKey' => PemUtil::loadPrivateKey(realpath($config['apiclient_key'])),
  75. 'certs' => [
  76. $this->plateform_certificate_serial => $this->plateform_certificate_instance
  77. ]
  78. ]);
  79. }
  80. /**
  81. * 生成平台证书
  82. */
  83. private function certificates(){
  84. try {
  85. $merchant_certificate_instance = PemUtil::loadCertificate(realpath($this->config['apiclient_cert']));
  86. // 证书序列号
  87. $merchant_certificate_serial = PemUtil::parseCertificateSerialNo($merchant_certificate_instance);
  88. $certs = ['any' => null];
  89. $app = Builder::factory([
  90. // 商户号
  91. 'mchid' => $this->config['mch_id'],
  92. // 商户证书序列号
  93. 'serial' => $merchant_certificate_serial,
  94. // 商户API私钥
  95. 'privateKey' => PemUtil::loadPrivateKey(realpath($this->config['apiclient_key'])),
  96. 'certs' => &$certs
  97. ]);
  98. $stack = $app->getDriver()->select(ClientDecoratorInterface::JSON_BASED)->getConfig('handler');
  99. $stack->after('verifier', Middleware::mapResponse(self::certsInjector($this->config['v3_pay_signkey'], $certs)), 'injector');
  100. $stack->before('verifier', Middleware::mapResponse(self::certsRecorder((string) dirname($this->config['apiclient_key']), $certs)), 'recorder');
  101. $param = [
  102. 'url' => '/v3/certificates',
  103. 'timestamp' => (string)Formatter::timestamp(),
  104. 'noncestr' => uniqid()
  105. ];
  106. $resp = $app->chain("v3/certificates")
  107. ->get([
  108. 'headers' => [
  109. 'Authorization' => Rsa::sign(
  110. Formatter::joinedByLineFeed(...array_values($param)),
  111. Rsa::from('file://' . realpath($this->config['apiclient_key']))
  112. )
  113. ]
  114. ]);
  115. $result = json_decode($resp->getBody()->getContents(), true);
  116. $file_path = dirname($this->config['apiclient_key']) . '/plateform_cert.pem';
  117. return $this->success(['cert_path' => $file_path]);
  118. } catch (\Exception $e) {
  119. if ($e instanceof \GuzzleHttp\Exception\RequestException && $e->hasResponse()) {
  120. $result = json_decode($e->getResponse()->getBody()->getContents(), true);
  121. return $this->error($result, $result['message']);
  122. } else {
  123. return $this->error([], $e->getMessage());
  124. }
  125. }
  126. }
  127. private static function certsInjector(string $apiv3Key, array &$certs): callable {
  128. return static function(ResponseInterface $response) use ($apiv3Key, &$certs): ResponseInterface {
  129. $body = (string) $response->getBody();
  130. /** @var object{data:array<object{encrypt_certificate:object{serial_no:string,nonce:string,associated_data:string}}>} $json */
  131. $json = \json_decode($body);
  132. $data = \is_object($json) && isset($json->data) && \is_array($json->data) ? $json->data : [];
  133. \array_map(static function($row) use ($apiv3Key, &$certs) {
  134. $cert = $row->encrypt_certificate;
  135. $certs[$row->serial_no] = AesGcm::decrypt($cert->ciphertext, $apiv3Key, $cert->nonce, $cert->associated_data);
  136. }, $data);
  137. return $response;
  138. };
  139. }
  140. private static function certsRecorder(string $outputDir, array &$certs): callable {
  141. return static function(ResponseInterface $response) use ($outputDir, &$certs): ResponseInterface {
  142. $body = (string) $response->getBody();
  143. /** @var object{data:array<object{effective_time:string,expire_time:string:serial_no:string}>} $json */
  144. $json = \json_decode($body);
  145. $data = \is_object($json) && isset($json->data) && \is_array($json->data) ? $json->data : [];
  146. \array_walk($data, static function($row, $index, $certs) use ($outputDir) {
  147. $serialNo = $row->serial_no;
  148. $outpath = $outputDir . \DIRECTORY_SEPARATOR . 'plateform_cert.pem';
  149. \file_put_contents($outpath, $certs[$serialNo]);
  150. }, $certs);
  151. return $response;
  152. };
  153. }
  154. /**
  155. * 支付
  156. * @param array $param
  157. * @return array
  158. */
  159. public function pay(array $param)
  160. {
  161. $self = $this;
  162. $site_id = $param['site_id'];
  163. $data = [
  164. 'json' => [
  165. 'appid' => $this->config['appid'],
  166. 'mchid' => $this->config['mch_id'],
  167. 'description' => str_sub($param["pay_body"], 15),
  168. 'out_trade_no' => $param["out_trade_no"],
  169. 'notify_url' => $param["notify_url"],
  170. 'amount' => [
  171. 'total' => round($param["pay_money"] * 100)
  172. ]
  173. ]
  174. ];
  175. switch ($param["trade_type"]) {
  176. case 'JSAPI':
  177. $data['json']['payer'] = [ 'openid' => $param['openid'] ];
  178. $data['trade_type'] = 'jsapi';
  179. $data['callback'] = function ($result) use ($self) {
  180. return success(0, '', [
  181. "type" => "jsapi",
  182. "data" => $self->jsskdConfig($result['prepay_id'])
  183. ]);
  184. };
  185. break;
  186. case 'APPLET':
  187. $data['json']['payer'] = [ 'openid' => $param['openid'] ];
  188. $data['trade_type'] = 'jsapi';
  189. $data['callback'] = function ($result) use ($self) {
  190. return success(0, '', [
  191. "type" => "jsapi",
  192. "data" => $self->jsskdConfig($result['prepay_id'])
  193. ]);
  194. };
  195. break;
  196. case 'NATIVE':
  197. $data['trade_type'] = 'native';
  198. $data['callback'] = function ($result) use ($site_id) {
  199. $upload_model = new Upload($site_id);
  200. $qrcode_result = $upload_model->qrcode($result['code_url']);
  201. return success(0, '', [
  202. "type" => "qrcode",
  203. "qrcode" => $qrcode_result['data'] ?? ''
  204. ]);
  205. };
  206. break;
  207. case 'MWEB':
  208. $data['trade_type'] = 'h5';
  209. $data['json']['scene_info'] = [
  210. 'payer_client_ip' => request()->ip(),
  211. 'h5_info' => [
  212. 'type' => 'Wap'
  213. ]
  214. ];
  215. $data['callback'] = function ($result){
  216. return success(0, '', [
  217. "type" => "url",
  218. "url" => $result['h5_url']
  219. ]);
  220. };
  221. break;
  222. case 'APP':
  223. $data['trade_type'] = 'app';
  224. $data['callback'] = function ($result) use ($self) {
  225. return success(0, '', [
  226. "type" => "app",
  227. "data" => $self->appConfig($result['prepay_id'])
  228. ]);
  229. };
  230. break;
  231. }
  232. $result = $this->unify($data);
  233. if ($result['code'] != 0) return $result;
  234. $result = $data['callback']($result['data']);
  235. return $result;
  236. }
  237. /**
  238. * 统一下单接口
  239. * @param array $param
  240. */
  241. public function unify(array $param){
  242. try {
  243. $resp = $this->app->chain('v3/pay/transactions/'.$param['trade_type'])->post([
  244. 'json' => $param['json']
  245. ]);
  246. $result = json_decode($resp->getBody()->getContents(), true);
  247. return $this->success($result);
  248. } catch (\Exception $e) {
  249. if ($e instanceof \GuzzleHttp\Exception\RequestException && $e->hasResponse()) {
  250. $result = json_decode($e->getResponse()->getBody()->getContents(), true);
  251. return $this->error($result, $result['message']);
  252. } else {
  253. return $this->error([], $e->getMessage());
  254. }
  255. }
  256. }
  257. /**
  258. * 生成支付配置
  259. * @param string $prepay_id
  260. */
  261. private function jsskdConfig(string $prepay_id)
  262. {
  263. $param = [
  264. 'appId' => $this->config['appid'],
  265. 'timeStamp' => (string)Formatter::timestamp(),
  266. 'nonceStr' => uniqid(),
  267. 'package' => "prepay_id=$prepay_id"
  268. ];
  269. $param += ['paySign' => Rsa::sign(
  270. Formatter::joinedByLineFeed(...array_values($param)),
  271. Rsa::from('file://' . realpath($this->config['apiclient_key']))
  272. ), 'signType' => 'RSA'];
  273. return $param;
  274. }
  275. /**
  276. * 生成支付配置
  277. * @param string $prepay_id
  278. * @return array
  279. */
  280. private function appConfig(string $prepay_id)
  281. {
  282. $param = [
  283. 'appid' => $this->config['appid'],
  284. 'timestamp' => (string)Formatter::timestamp(),
  285. 'noncestr' => uniqid(),
  286. 'prepayid' => $prepay_id
  287. ];
  288. $param += [
  289. 'sign' => Rsa::sign(
  290. Formatter::joinedByLineFeed(...array_values($param)),
  291. Rsa::from('file://' . realpath($this->config['apiclient_key']))
  292. ),
  293. 'package' => 'Sign=WXPay',
  294. 'partnerid' => $this->config['mch_id']
  295. ];
  296. return $param;
  297. }
  298. /**
  299. * 异步回调
  300. */
  301. public function payNotify(){
  302. $inWechatpaySignature = request()->header('Wechatpay-Signature'); // 从请求头中拿到 签名
  303. $inWechatpayTimestamp = request()->header('Wechatpay-Timestamp'); // 从请求头中拿到 时间戳
  304. $inWechatpaySerial = request()->header('Wechatpay-Serial'); // 从请求头中拿到 时间戳
  305. $inWechatpayNonce = request()->header('Wechatpay-Nonce'); // 从请求头中拿到 时间戳
  306. $inBody = file_get_contents('php://input');
  307. $platformPublicKeyInstance = Rsa::from('file://' . realpath($this->config['plateform_cert']), Rsa::KEY_TYPE_PUBLIC);
  308. $timeOffsetStatus = 300 >= abs(Formatter::timestamp() - (int)$inWechatpayTimestamp);
  309. $verifiedStatus = Rsa::verify(
  310. // 构造验签名串
  311. Formatter::joinedByLineFeed($inWechatpayTimestamp, $inWechatpayNonce, file_get_contents('php://input')),
  312. $inWechatpaySignature,
  313. $platformPublicKeyInstance
  314. );
  315. if ($timeOffsetStatus && $verifiedStatus) {
  316. // 转换通知的JSON文本消息为PHP Array数组
  317. $inBodyArray = (array)json_decode($inBody, true);
  318. // 使用PHP7的数据解构语法,从Array中解构并赋值变量
  319. ['resource' => [
  320. 'ciphertext' => $ciphertext,
  321. 'nonce' => $nonce,
  322. 'associated_data' => $aad
  323. ]] = $inBodyArray;
  324. // 加密文本消息解密
  325. $inBodyResource = AesGcm::decrypt($ciphertext, $this->config['v3_pay_signkey'], $nonce, $aad);
  326. // 把解密后的文本转换为PHP Array数组
  327. $message = json_decode($inBodyResource, true);
  328. Log::write('message'.$inBodyResource);
  329. // 交易状态为成功
  330. if (isset($message['trade_state']) && $message['trade_state'] == 'SUCCESS') {
  331. if (isset($message['out_trade_no'])) {
  332. $pay_common = new PayCommon();
  333. $pay_info = $pay_common->getPayInfo($message['out_trade_no'])['data'];
  334. if (empty($pay_info)) return;
  335. if ($message['amount']['total'] != round($pay_info['pay_money'] * 100)) return;
  336. // 用户是否支付成功
  337. $pay_common->onlinePay($message['out_trade_no'], "wechatpay", $message["transaction_id"], "wechatpay");
  338. header('', '', 200);
  339. }
  340. } else {
  341. throw new HttpException(500, '失败', null, [], 'FAIL');
  342. }
  343. } else {
  344. throw new HttpException(500, '失败', null, [], 'FAIL');
  345. }
  346. }
  347. /**
  348. * 支付单据关闭
  349. * @param array $param
  350. */
  351. public function payClose(array $param)
  352. {
  353. try {
  354. $resp = $this->app->chain("v3/pay/transactions/out-trade-no/{$param['out_trade_no']}/close")->post([
  355. 'json' => [
  356. 'mchid' => $this->config['mch_id']
  357. ]
  358. ]);
  359. $result = json_decode($resp->getBody()->getContents(), true);
  360. return $this->success($result);
  361. } catch (\Exception $e) {
  362. if ($e instanceof \GuzzleHttp\Exception\RequestException && $e->hasResponse()) {
  363. $result = json_decode($e->getResponse()->getBody()->getContents(), true);
  364. if (isset($result['code']) && ($result['code'] == 'ORDERPAID' || $result['code'] == 'ORDER_PAID'))
  365. return $this->error(['is_paid' => 1, 'pay_type' => 'wechatpay'], $result['code']);
  366. return $this->error($result, $result['message']);
  367. } else {
  368. return $this->error([], $e->getMessage());
  369. }
  370. }
  371. }
  372. /**
  373. * 申请退款
  374. * @param array $param
  375. */
  376. public function refund(array $param)
  377. {
  378. $pay_info = $param["pay_info"];
  379. try {
  380. $resp = $this->app->chain("v3/refund/domestic/refunds")->post([
  381. 'json' => [
  382. 'out_trade_no' => $pay_info['out_trade_no'],
  383. 'out_refund_no' => $param['refund_no'],
  384. 'notify_url' => addon_url("pay/pay/refundnotify"),
  385. 'amount' => [
  386. 'refund' => round($param['refund_fee'] * 100),
  387. 'total' => round($pay_info['pay_money'] * 100),
  388. 'currency' => $param['currency'] ?? 'CNY'
  389. ]
  390. ]
  391. ]);
  392. $result = json_decode($resp->getBody()->getContents(), true);
  393. if (isset($result['status']) && ($result['status'] == 'SUCCESS' || $result['status'] == 'PROCESSING'))
  394. return $this->success($result);
  395. else return $this->success($result, '退款异常');
  396. } catch (\Exception $e) {
  397. if ($e instanceof \GuzzleHttp\Exception\RequestException && $e->hasResponse()) {
  398. $result = json_decode($e->getResponse()->getBody()->getContents(), true);
  399. return $this->error($result, $result['message']);
  400. } else {
  401. return $this->error([], $e->getMessage());
  402. }
  403. }
  404. }
  405. /**
  406. * 转账
  407. * @param array $param
  408. */
  409. public function transfer(array $param)
  410. {
  411. $data = [
  412. 'appid' => $this->config['appid'],
  413. 'out_batch_no' => $param['out_trade_no'],
  414. 'batch_name' => '客户提现转账',
  415. 'batch_remark' => '客户提现转账提现交易号' . $param['out_trade_no'],
  416. 'total_amount' => round($param['amount'] * 100),
  417. 'total_num' => 1,
  418. 'transfer_detail_list' => [
  419. [
  420. 'out_detail_no' => $param['out_trade_no'],
  421. 'transfer_amount' => $param['amount'] * 100,
  422. 'transfer_remark' => $param['desc'],
  423. 'openid' => $param['account_number'],
  424. 'user_name' => $this->encryptor($param['real_name'])
  425. ]
  426. ]
  427. ];
  428. $this->app->chain('v3/transfer/batches')
  429. ->postAsync([
  430. 'json' => $data,
  431. 'headers' => [
  432. 'Wechatpay-Serial' => $this->plateform_certificate_serial
  433. ]
  434. ])->then(static function($response) use (&$result) {
  435. $result = json_decode($response->getBody()->getContents(), true);
  436. $result = success(0, '', $result);
  437. })->otherwise(static function ($exception) use (&$result) {
  438. if ($exception instanceof \GuzzleHttp\Exception\RequestException && $exception->hasResponse()) {
  439. $result = json_decode($exception->getResponse()->getBody()->getContents(), true);
  440. $result = error(-1, $result['message'], $result);
  441. } else {
  442. $result = error(-1, $exception->getMessage());
  443. }
  444. })->wait();
  445. return $result;
  446. }
  447. /**
  448. * 查询转账明细
  449. * @param string $out_batch_no
  450. * @param string $out_detail_no
  451. * @return array
  452. */
  453. public function transferDetail(string $out_batch_no, string $out_detail_no) : array
  454. {
  455. try {
  456. $resp = $this->app->chain("v3/transfer/batches/out-batch-no/{$out_batch_no}/details/out-detail-no/{$out_detail_no}")
  457. ->get();
  458. $result = json_decode($resp->getBody()->getContents(), true);
  459. return $this->success($result);
  460. } catch (\Exception $e) {
  461. if ($e instanceof \GuzzleHttp\Exception\RequestException && $e->hasResponse()) {
  462. $result = json_decode($e->getResponse()->getBody()->getContents(), true);
  463. return $this->error($result, $result['message']);
  464. } else {
  465. return $this->error([], $e->getMessage());
  466. }
  467. }
  468. }
  469. /**
  470. * 加密数据
  471. * @param string $str
  472. * @return string
  473. */
  474. public function encryptor(string $str){
  475. $publicKey = $this->plateform_certificate_instance;
  476. // 加密方法
  477. $encryptor = function($msg) use ($publicKey) { return Rsa::encrypt($msg, $publicKey); };
  478. return $encryptor($str);
  479. }
  480. /**
  481. * 获取转账结果
  482. * @param $id
  483. * @return array
  484. */
  485. public function getTransferResult($withdraw_info)
  486. {
  487. $result = $this->transferDetail($withdraw_info['withdraw_no'], $withdraw_info['withdraw_no']);
  488. if ($result['code'] != 0 || (isset($result['data']['detail_status']) && $result['data']['detail_status'] == 'PROCESSING')) {
  489. $error_num = Cache::get('get_transfer_result' . $withdraw_info['withdraw_no']) ?: 0;
  490. if (!$error_num || $error_num < 5) {
  491. (new Cron())->addCron(1, 0, "查询转账结果", "TransferResult", (time() + 60), $withdraw_info['id']);
  492. Cache::set('get_transfer_result' . $withdraw_info['withdraw_no'], ($error_num + 1), 600);
  493. }
  494. return $result;
  495. }
  496. if ($result['data']['detail_status'] == 'FAIL') {
  497. $reason = [
  498. 'ACCOUNT_FROZEN' => '账户冻结',
  499. 'REAL_NAME_CHECK_FAIL' => '用户未实名',
  500. 'NAME_NOT_CORRECT' => '用户姓名校验失败',
  501. 'OPENID_INVALID' => 'Openid校验失败',
  502. 'TRANSFER_QUOTA_EXCEED' => '超过用户单笔收款额度',
  503. 'DAY_RECEIVED_QUOTA_EXCEED' => '超过用户单日收款额度',
  504. 'MONTH_RECEIVED_QUOTA_EXCEED' => '超过用户单月收款额度',
  505. 'DAY_RECEIVED_COUNT_EXCEED' => '超过用户单日收款次数',
  506. 'PRODUCT_AUTH_CHECK_FAIL' => '产品权限校验失败',
  507. 'OVERDUE_CLOSE' => '转账关闭',
  508. 'ID_CARD_NOT_CORRECT' => '用户身份证校验失败',
  509. 'ACCOUNT_NOT_EXIST' => '用户账户不存在',
  510. 'TRANSFER_RISK' => '转账存在风险',
  511. 'REALNAME_ACCOUNT_RECEIVED_QUOTA_EXCEED' => '用户账户收款受限,请引导用户在微信支付查看详情',
  512. 'RECEIVE_ACCOUNT_NOT_PERMMIT' => '未配置该用户为转账收款人',
  513. 'PAYER_ACCOUNT_ABNORMAL' => '商户账户付款受限,可前往商户平台-违约记录获取解除功能限制指引',
  514. 'PAYEE_ACCOUNT_ABNORMAL' => '用户账户收款异常,请引导用户完善其在微信支付的身份信息以继续收款',
  515. ];
  516. $fail_reason = '';
  517. if (isset($result['data']['fail_reason'])) $fail_reason = $reason[$result['data']['fail_reason']] ?? '';
  518. model('member_withdraw')->update(['status' => -2, 'status_name' => '转账失败', 'fail_reason' => $fail_reason], [['id', '=', $withdraw_info['id']]]);
  519. } else if ($result['data']['detail_status'] != 'SUCCESS') {
  520. model('member_withdraw')->update(['status' => -2, 'status_name' => '转账失败', 'fail_reason' => '未获取到转账结果'], [['id', '=', $withdraw_info['id']]]);
  521. }
  522. Cache::delete('get_transfer_result' . $withdraw_info['withdraw_no']);
  523. return $this->success();
  524. }
  525. }