Files
code-bang/backend/application/common/service/EasyWeChatService.php
T
2026-10-10 17:10:38 +08:00

514 lines
22 KiB
PHP
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<?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_id 对应(1005=佣金报酬 需传「岗位类型」「报酬说明」两条)
'transfer_scene_report_infos' => $this->buildTransferSceneReportInfos($desc),
];
//规则:转账金额 >= 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();
}
/**
* 构造转账场景报备信息
* 报备信息必须与 transfer_scene_id 一一对应,且 info_type 不可重复,否则接口会报
* 「未传入完整且对应的转账场景报备信息」:
* 1000 现金营销 -> 活动名称 + 奖励说明
* 1005 佣金报酬 -> 岗位类型 + 报酬说明
* 可通过 extra/common.php 的 transfer_scene_report_infos 覆盖
* @param string $desc 转账备注,用于 info_content 缺省时兜底
* @return array
*/
private function buildTransferSceneReportInfos(string $desc = ''): array
{
$reportInfos = $this->config['transfer_scene_report_infos'] ?? [];
if (!$reportInfos) {
$sceneId = (string)($this->config['transfer_scene_id'] ?? '1005');
$reportInfos = $sceneId === '1000'
? [
['info_type' => '活动名称', 'info_content' => $desc ?: '平台活动'],
['info_type' => '奖励说明', 'info_content' => $desc ?: '活动奖励'],
]
: [
['info_type' => '岗位类型', 'info_content' => '平台接单用户'],
['info_type' => '报酬说明', 'info_content' => $desc ?: '用户提现'],
];
}
//info_type ≤15 字符、info_content ≤32 字符
return array_map(function ($item) use ($desc) {
$infoType = mb_substr((string)($item['info_type'] ?? ''), 0, 15);
$infoContent = mb_substr((string)($item['info_content'] ?? ''), 0, 32);
if ($infoContent === '') {
$infoContent = mb_substr($desc ?: '佣金报酬', 0, 32);
}
return [
'info_type' => $infoType,
'info_content' => $infoContent,
];
}, $reportInfos);
}
/**
* 查询免确认收款授权单(用于同步授权状态:授权回调丢失时以微信侧状态为准)
* 接口:GET /v3/fund-app/mch-transfer/user-confirm-authorization/out-authorization-no/{no}
* 文档:https://pay.weixin.qq.com/doc/v3/merchant/4015901167
* @param string $out_authorization_no 商户侧授权单号
* @param bool $isDisplayAuthorization 是否请求展示授权信息页的 package_info。
* 注意:仅当授权已生效(TAKING_EFFECT)时才有效;
* 待确认/已关闭状态下微信会返回 INVALID_REQUEST 且不返回 state,
* 因此“查状态”必须传 false,拉起授权确认页的 package 应取申请接口的响应
* @return array {out_authorization_no, authorization_id, state, ...};失败时含 message
*/
public function queryTransferAuthorization($out_authorization_no, $isDisplayAuthorization = false)
{
try {
$options = [];
if ($isDisplayAuthorization) {
$options['query'] = ['is_display_authorization' => 'true'];
}
$response = $this->payApp->getClient()->get(
'v3/fund-app/mch-transfer/user-confirm-authorization/out-authorization-no/' . rawurlencode($out_authorization_no),
$options
);
$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(),
];
}
}
/**
* 查询新版商家转账单状态
* @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;
}
}