444 lines
18 KiB
PHP
444 lines
18 KiB
PHP
<?php
|
|
|
|
namespace app\common\service;
|
|
|
|
use EasyWeChat\MiniApp\Application as MiniAppApplication;
|
|
use EasyWeChat\Pay\Application as PayApplication;
|
|
use EasyWeChat\Pay\Message as PayMessage;
|
|
use think\Log;
|
|
|
|
/**
|
|
* 微信服务(EasyWeChat 6.x)
|
|
* 文档: https://easywechat.com/6.x/
|
|
* - 小程序: MiniApp\Application
|
|
* - 支付(含回调验签): Pay\Application (微信支付 v3)
|
|
*/
|
|
class EasyWeChatService
|
|
{
|
|
public $config;
|
|
|
|
/** @var MiniAppApplication 小程序实例 */
|
|
public $app;
|
|
|
|
/** @var PayApplication 微信支付 v3 实例 */
|
|
public $payApp;
|
|
|
|
public function __construct()
|
|
{
|
|
$this->config = config('common.wechat');
|
|
|
|
// 小程序
|
|
$this->app = new MiniAppApplication([
|
|
'app_id' => $this->config['app_id'],
|
|
'secret' => $this->config['secret'],
|
|
'http' => ['throw' => false, 'timeout' => 10.0],
|
|
]);
|
|
|
|
// 微信支付 v3
|
|
$this->payApp = new PayApplication([
|
|
'mch_id' => $this->config['mch_id'],
|
|
'secret_key' => $this->config['secret_key'], // APIv3 密钥
|
|
'v2_secret_key' => $this->config['v2_secret_key'], // 旧 v2 密钥(兼容 v2 模式回调)
|
|
'private_key' => $this->config['private_key'], // 商户证书私钥
|
|
'certificate' => $this->config['certificate'], // 商户API证书
|
|
'platform_certs' => $this->config['platform_certs'], // 微信支付公钥/平台证书
|
|
'http' => ['throw' => false, 'timeout' => 10.0],
|
|
]);
|
|
}
|
|
|
|
/**
|
|
* 小程序登录(code2session)
|
|
* @param string $code wx.login 获取的 code
|
|
* @return array {openid, session_key, unionid?}
|
|
*/
|
|
public function login($code)
|
|
{
|
|
$res = $this->app->getUtils()->codeToSession($code);
|
|
Log::info('login-res' . json_encode([$res], JSON_UNESCAPED_UNICODE));
|
|
return $res;
|
|
}
|
|
|
|
/**
|
|
* 解密开放数据(手机号等)
|
|
* @param $sessionKey code2session 返回的 session_key
|
|
* @param $iv
|
|
* @param $encryptedData
|
|
* @return array
|
|
*/
|
|
public function decryData($sessionKey, $iv, $encryptedData)
|
|
{
|
|
return $this->app->getUtils()->decryptSession($sessionKey, $iv, $encryptedData);
|
|
}
|
|
|
|
/**
|
|
* 统一下单(小程序 JSAPI 支付, v3)
|
|
* @param string $body 商品描述
|
|
* @param string $out_trade_no 商户订单号
|
|
* @param float $total_fee 金额(单位:元)
|
|
* @param string $openid 支付用户 openid
|
|
* @param string $notify_url 支付结果通知地址,留空使用配置默认
|
|
* @return array 含 prepay_id;失败时含 message 等错误信息
|
|
*/
|
|
public function unify($body, $out_trade_no, $total_fee, $openid, $notify_url = '')
|
|
{
|
|
$params = [
|
|
'appid' => $this->config['app_id'],
|
|
'mchid' => (string)$this->payApp->getMerchant()->getMerchantId(),
|
|
'description' => $body,
|
|
'out_trade_no' => $out_trade_no,
|
|
'notify_url' => $notify_url ?: $this->config['notify_url'],
|
|
'amount' => ['total' => (int)round($total_fee * 100), 'currency' => 'CNY'], // 转为分
|
|
'payer' => ['openid' => $openid],
|
|
];
|
|
Log::info('unify-params' . json_encode($params, JSON_UNESCAPED_UNICODE));
|
|
$response = $this->payApp->getClient()->postJson('v3/pay/transactions/jsapi', $params);
|
|
$result = $response->toArray(false);
|
|
Log::info('unify-res' . json_encode($result, JSON_UNESCAPED_UNICODE));
|
|
|
|
return $result;
|
|
}
|
|
|
|
/**
|
|
* 小程序调起支付参数(wx.requestPayment)
|
|
* @param string $prepayId
|
|
* @return array {timeStamp, nonceStr, package, signType, paySign}
|
|
*/
|
|
public function sdkConfig($prepayId)
|
|
{
|
|
$config = $this->payApp->getUtils()->buildMiniAppConfig($prepayId, $this->config['app_id']);
|
|
Log::info('buildMiniAppConfig-res' . json_encode($config, JSON_UNESCAPED_UNICODE));
|
|
return $config;
|
|
}
|
|
|
|
/**
|
|
* 发送小程序订阅消息
|
|
* 只要走了订阅消息,就同步发送一条站内消息(失败不影响订阅消息主流程)
|
|
* @param $user_id
|
|
* @param $template_id
|
|
* @param array $msgData
|
|
*/
|
|
public function sendMsg($user_id, $template_id, array $msgData = [])
|
|
{
|
|
$data = SubscribeMessageService::packMsg($user_id, $template_id, $msgData);
|
|
|
|
$response = $this->app->getClient()->postJson('/cgi-bin/message/subscribe/send', $data);
|
|
$res = $response->toArray(false);
|
|
Log::info('发送订阅消息-res' . json_encode(['data' => $data, 'res' => $res], JSON_UNESCAPED_UNICODE));
|
|
|
|
//同步写站内消息(extra_json.page 直接复用订阅消息的跳转页面)
|
|
try {
|
|
MessageService::createBySubscribe($user_id, $template_id, $msgData, $data['page'] ?? '');
|
|
} catch (\Exception $e) {
|
|
Log::error('订阅消息同步站内消息失败:' . json_encode([
|
|
'user_id' => $user_id,
|
|
'template_id' => $template_id,
|
|
'error' => $e->getMessage(),
|
|
], JSON_UNESCAPED_UNICODE));
|
|
}
|
|
}
|
|
|
|
/**
|
|
* 免确认收款转账(商家转账-用户已授权模式)
|
|
* 接口:POST /v3/fund-app/mch-transfer/transfer-bills/transfer
|
|
* 前置:用户需先通过 /v3/fund-app/mch-transfer/user-confirm-authorization 完成授权,
|
|
* 转账时以授权单(out_authorization_no / authorization_id 二选一)定位收款人
|
|
* 文档:https://pay.weixin.qq.com/doc/v3/merchant/4014399371
|
|
* @param string $order_sn 提现单号(out_bill_no, 字母或数字)
|
|
* @param string $name 收款用户姓名(金额>=2000元时必须传,需加密)
|
|
* @param float $amount 金额(单位:元)
|
|
* @param string $out_authorization_no 商户侧授权单号
|
|
* @param string $desc 转账备注(≤32 字符)
|
|
* @return array {out_bill_no, transfer_bill_no, state, transfer_amount, ...};失败时含 message
|
|
*/
|
|
public function mchPay($order_sn, $name, $amount, $out_authorization_no, $desc = '用户提现')
|
|
{
|
|
try {
|
|
$params = [
|
|
'appid' => $this->config['app_id'],
|
|
'out_bill_no' => $order_sn,
|
|
'transfer_scene_id' => $this->config['transfer_scene_id'] ?? '',
|
|
'transfer_amount' => (int)round($amount * 100), // 转为分
|
|
'transfer_remark' => mb_substr($desc, 0, 32), // 最多 32 字符
|
|
//授权转账:以授权单定位收款人,不再传 openid
|
|
'out_authorization_no' => $out_authorization_no,
|
|
//必须传转账场景报备信息,需按商户平台申请的场景准确填写
|
|
'transfer_scene_report_infos' => [
|
|
[
|
|
'info_type' => '活动名称',
|
|
'info_content' => mb_substr($desc ?: '佣金报酬', 0, 32),
|
|
],
|
|
],
|
|
];
|
|
//规则:转账金额 >= 2,000 元时必须传入收款人姓名(加密)
|
|
if ($amount >= 2000 && $name !== '') {
|
|
$params['user_name'] = $this->encryptSensitive($name);
|
|
}
|
|
//含敏感字段加密时须带 Wechatpay-Serial(微信支付公钥ID/平台证书序列号)
|
|
$client = $this->payApp->getClient();
|
|
$platformCerts = $this->config['platform_certs'] ?? [];
|
|
if ($platformCerts) {
|
|
$client = $client->withHeader('Wechatpay-Serial', (string)array_key_first($platformCerts));
|
|
}
|
|
Log::info('商家转账-params' . json_encode($params, JSON_UNESCAPED_UNICODE));
|
|
$response = $client->postJson('v3/fund-app/mch-transfer/transfer-bills/transfer', $params);
|
|
$res = $response->toArray(false);
|
|
Log::info('商家转账-res' . json_encode($res, JSON_UNESCAPED_UNICODE));
|
|
|
|
return $res;
|
|
} catch (\Exception $e) {
|
|
Log::error('商家转账请求异常:' . $e->getMessage());
|
|
return [
|
|
'message' => $e->getMessage(),
|
|
];
|
|
}
|
|
}
|
|
|
|
/**
|
|
* 发起免确认收款授权(用户确认模式的前置授权)
|
|
* 接口:POST /v3/fund-app/mch-transfer/user-confirm-authorization
|
|
* 返回 package_info 供前端 wx.requestMerchantTransfer 拉起授权页
|
|
* 文档:https://pay.weixin.qq.com/doc/v3/merchant/4015901167
|
|
* @param string $out_authorization_no 商户侧授权单号(每用户唯一,未确认单30天后单号可复用)
|
|
* @param string $openid 收款用户 openid
|
|
* @param string $display_name 授权详情展示的开通账号名称(≤32字符)
|
|
* @return array {out_authorization_no, state, create_time, package_info};失败时含 message
|
|
*/
|
|
public function applyTransferAuthorization($out_authorization_no, $openid, $display_name)
|
|
{
|
|
try {
|
|
$params = [
|
|
'appid' => $this->config['app_id'],
|
|
'out_authorization_no' => $out_authorization_no,
|
|
'openid' => $openid,
|
|
'transfer_scene_id' => $this->config['transfer_scene_id'] ?? '',
|
|
'user_display_name' => mb_substr($display_name, 0, 32),
|
|
'authorization_notify_url' => $this->config['transfer_authorization_notify_url'] ?? '',
|
|
];
|
|
Log::info('免确认收款授权-params' . json_encode($params, JSON_UNESCAPED_UNICODE));
|
|
$response = $this->payApp->getClient()->postJson('v3/fund-app/mch-transfer/user-confirm-authorization', $params);
|
|
$res = $response->toArray(false);
|
|
Log::info('免确认收款授权-res' . json_encode($res, JSON_UNESCAPED_UNICODE));
|
|
|
|
return $res;
|
|
} catch (\Exception $e) {
|
|
Log::error('免确认收款授权请求异常:' . $e->getMessage());
|
|
return [
|
|
'message' => $e->getMessage(),
|
|
];
|
|
}
|
|
}
|
|
|
|
/**
|
|
* 处理免确认收款授权结果回调(验签+解密),返回 PSR-7 响应
|
|
* 注意:handlePaid 只处理 TRANSACTION.SUCCESS,授权回调须用通用管道
|
|
* @param \Closure $handler function(PayMessage $message){ 业务处理;异常时抛出则返回失败 }
|
|
* @return \Psr\Http\Message\ResponseInterface
|
|
*/
|
|
public function handleTransferAuthorizationNotify(\Closure $handler)
|
|
{
|
|
$traceId = date('YmdHis') . substr(md5(uniqid('', true)), 0, 6);
|
|
Log::info('免确认收款授权回调[收到请求] trace=' . $traceId . ' ' . json_encode([
|
|
'body' => (string)$this->payApp->getRequest()->getBody(),
|
|
], JSON_UNESCAPED_UNICODE));
|
|
|
|
$server = $this->payApp->getServer();
|
|
$server->with(function (PayMessage $message, \Closure $next) use ($handler, $traceId) {
|
|
Log::info('免确认收款授权回调[解密数据] trace=' . $traceId . ' ' . json_encode($message->toArray(), JSON_UNESCAPED_UNICODE));
|
|
|
|
//回调签名验证(失败抛异常 -> serve 返回失败,微信将重试)
|
|
$this->payApp->getValidator()->validate($this->payApp->getRequest());
|
|
|
|
$handler($message, $traceId);
|
|
|
|
return $next($message);
|
|
});
|
|
|
|
return $server->serve();
|
|
}
|
|
|
|
/**
|
|
* 查询新版商家转账单状态
|
|
* @param string $out_bill_no 商户单号
|
|
* @return array 转账单状态及明细
|
|
*/
|
|
public function queryTransferBill($out_bill_no)
|
|
{
|
|
try {
|
|
$response = $this->payApp->getClient()->get(
|
|
"v3/fund-app/mch-transfer/transfer-bills/out-bill-no/{$out_bill_no}"
|
|
);
|
|
$res = $response->toArray(false);
|
|
Log::info('查询商家转账单-res' . json_encode($res, JSON_UNESCAPED_UNICODE));
|
|
|
|
return $res;
|
|
} catch (\Exception $e) {
|
|
Log::error('查询商家转账单异常:' . $e->getMessage());
|
|
return [
|
|
'message' => $e->getMessage(),
|
|
];
|
|
}
|
|
}
|
|
|
|
/**
|
|
* 敏感信息加密:新版商家转账的收款人姓名需使用微信支付公钥加密(RSA-OAEP)
|
|
* @param string $plaintext
|
|
* @return string base64 密文
|
|
* @throws \Exception
|
|
*/
|
|
public function encryptSensitive(string $plaintext): string
|
|
{
|
|
if ($plaintext === '') {
|
|
return '';
|
|
}
|
|
|
|
$platformCerts = $this->config['platform_certs'] ?? [];
|
|
if (!$platformCerts) {
|
|
throw new \Exception('未配置微信支付公钥(platform_certs),无法加密收款人姓名');
|
|
}
|
|
//取第一个可用公钥(微信支付公钥ID -> 公钥文件)
|
|
$certFile = reset($platformCerts);
|
|
if (!is_file($certFile)) {
|
|
throw new \Exception('微信支付公钥文件不存在:' . $certFile);
|
|
}
|
|
|
|
$publicKey = openssl_pkey_get_public(file_get_contents($certFile));
|
|
if (!$publicKey) {
|
|
throw new \Exception('微信支付公钥解析失败');
|
|
}
|
|
|
|
$encrypted = '';
|
|
if (!openssl_public_encrypt($plaintext, $encrypted, $publicKey, OPENSSL_PKCS1_OAEP_PADDING)) {
|
|
throw new \Exception('收款人姓名加密失败');
|
|
}
|
|
|
|
return base64_encode($encrypted);
|
|
}
|
|
|
|
/**
|
|
* 查询商家转账单状态(提现已改用新版接口,统一走新版查询)
|
|
* @param string $apply_sn 提现单号(out_bill_no)
|
|
* @return array 转账单状态及明细
|
|
*/
|
|
public function queryBalance($apply_sn)
|
|
{
|
|
//提现单已改用新版商家转账接口创建,查询统一走新版,避免查不到单据
|
|
return $this->queryTransferBill($apply_sn);
|
|
}
|
|
|
|
/**
|
|
* 微信支付退款(v3)
|
|
* @param string $order_sn 商户订单号
|
|
* @param string $refund_no 商户退款单号
|
|
* @param float $actual_amount 原订单金额(单位:元)
|
|
* @param float $refund_amount 退款金额(单位:元)
|
|
* @param string $desc 退款原因
|
|
* @return array
|
|
*/
|
|
public function refund($order_sn, $refund_no, $actual_amount, $refund_amount, $desc = '出票失败退款')
|
|
{
|
|
$params = [
|
|
'out_trade_no' => $order_sn,
|
|
'out_refund_no' => $refund_no,
|
|
'reason' => $desc,
|
|
'amount' => [
|
|
'refund' => (int)bcmul($refund_amount, 100), // 转为分
|
|
'total' => (int)bcmul($actual_amount, 100),
|
|
'currency' => 'CNY',
|
|
],
|
|
];
|
|
Log::info('微信订单退款-params' . json_encode($params, JSON_UNESCAPED_UNICODE));
|
|
$response = $this->payApp->getClient()->postJson('v3/refund/domestic/refunds', $params);
|
|
$res = $response->toArray(false);
|
|
Log::info('微信订单退款-res' . json_encode(['param' => func_get_args(), 'res' => $res], JSON_UNESCAPED_UNICODE));
|
|
|
|
return $res;
|
|
}
|
|
|
|
/**
|
|
* 查询退款(v3)
|
|
* @param string $refund_no 商户退款单号
|
|
* @return array
|
|
*/
|
|
public function refundQuery($refund_no)
|
|
{
|
|
$response = $this->payApp->getClient()->get("v3/refund/domestic/refunds/{$refund_no}");
|
|
$res = $response->toArray(false);
|
|
Log::info('微信订单退款查询-res' . json_encode(['param' => func_get_args(), 'res' => $res], JSON_UNESCAPED_UNICODE));
|
|
|
|
return $res;
|
|
}
|
|
|
|
/**
|
|
* 处理微信支付 v3 回调(验签+解密),返回 PSR-7 响应
|
|
* @param \Closure $paidHandler function(PayMessage $message){ 业务处理;异常时抛出则返回失败 }
|
|
* @return \Psr\Http\Message\ResponseInterface
|
|
*/
|
|
public function handlePayNotify(\Closure $paidHandler)
|
|
{
|
|
$request = $this->payApp->getRequest();
|
|
$traceId = date('YmdHis') . substr(md5(uniqid('', true)), 0, 6);
|
|
|
|
// 【日志1】回调原始报文:确认微信有没有真的打过来、打到哪个地址
|
|
Log::info('微信支付回调[收到请求] trace=' . $traceId . ' ' . json_encode([
|
|
'method' => $request->getMethod(),
|
|
'uri' => (string)$request->getUri(),
|
|
'headers' => self::getNotifyHeaders($request),
|
|
'body' => (string)$request->getBody(),
|
|
], JSON_UNESCAPED_UNICODE));
|
|
|
|
$server = $this->payApp->getServer();
|
|
$server->handlePaid(function (PayMessage $message, \Closure $next) use ($paidHandler, $traceId) {
|
|
// 【日志2】解密后的业务数据(含 out_trade_no / trade_state / amount)
|
|
Log::info('微信支付回调[解密数据] trace=' . $traceId . ' ' . json_encode($message->toArray(), JSON_UNESCAPED_UNICODE));
|
|
|
|
// 回调签名验证(官方推荐,验签失败抛异常 -> serve 返回失败,微信将重试)
|
|
try {
|
|
$this->payApp->getValidator()->validate($this->payApp->getRequest());
|
|
Log::info('微信支付回调[验签通过] trace=' . $traceId);
|
|
} catch (\Throwable $e) {
|
|
Log::error('微信支付回调[验签失败] trace=' . $traceId . ' msg=' . $e->getMessage());
|
|
throw $e;
|
|
}
|
|
|
|
try {
|
|
$paidHandler($message, $traceId);
|
|
} catch (\Throwable $e) {
|
|
Log::error('微信支付回调[业务处理异常] trace=' . $traceId . ' msg=' . $e->getMessage()
|
|
. ' file=' . $e->getFile() . ':' . $e->getLine());
|
|
throw $e;
|
|
}
|
|
|
|
Log::info('微信支付回调[业务处理完成] trace=' . $traceId);
|
|
|
|
return $next($message);
|
|
});
|
|
|
|
$response = $server->serve();
|
|
// 注意:先转字符串(内部会 seek(0))并 rewind,保证控制器后续仍能读取 body 回给微信
|
|
$responseBody = (string)$response->getBody();
|
|
$response->getBody()->rewind();
|
|
|
|
// 【日志3】应答给微信的结果:status=200 且 code=SUCCESS 才算回调成功
|
|
Log::info('微信支付回调[响应微信] trace=' . $traceId . ' status=' . $response->getStatusCode() . ' body=' . $responseBody);
|
|
|
|
return $response;
|
|
}
|
|
|
|
/**
|
|
* 提取回调请求中与验签相关的请求头(便于排查签名/证书问题)
|
|
* @param \Psr\Http\Message\ServerRequestInterface $request
|
|
* @return array
|
|
*/
|
|
private static function getNotifyHeaders($request): array
|
|
{
|
|
$headers = [];
|
|
foreach ($request->getHeaders() as $name => $values) {
|
|
if (stripos($name, 'wechatpay') !== false || in_array(strtolower($name), ['content-type', 'user-agent'])) {
|
|
$headers[$name] = implode(', ', $values);
|
|
}
|
|
}
|
|
|
|
return $headers;
|
|
}
|
|
}
|