chore: 首次提交 - 关联 GitHub fnvtk/MBTI_wang

Made-with: Cursor
This commit is contained in:
卡若
2026-03-17 12:39:38 +08:00
commit eb510304c0
259 changed files with 60464 additions and 0 deletions

View File

@@ -0,0 +1,495 @@
<?php
namespace app\common\service;
use Exception;
use think\facade\Log;
/**
* 微信商家转账到零钱封装
*
* 配置从 env / config 中读取,字段示例:
* - WECHAT_MCH_ID
* - WECHAT_APP_ID
* - WECHAT_API_V3_KEY
* - WECHAT_MCH_PRIVATE_KEY (绝对路径 apiclient_key.pem)
* - WECHAT_MCH_CERT_SERIAL
*/
class WechatTransferService
{
// 微信支付API域名
const API_BASE_URL = 'https://api.mch.weixin.qq.com';
const API_BASE_URL_BACKUP = 'https://api2.mch.weixin.qq.com';
// 配置信息
private $mchId; // 商户号
private $appId; // 小程序/公众号AppID
private $apiV3Key; // API v3密钥
private $privateKey; // 商户私钥(用于签名)
private $certSerialNo; // 证书序列号(用于加密敏感信息)
private $publicKey; // 微信支付公钥(用于验证回调)
/**
* 构造函数:直接从 .env 读取配置
*/
public function __construct()
{
// 核心配置来自 mbti/api/.env
$this->mchId = env('MCH_ID', '');
$this->appId = env('WECHAT_APPID', '');
$this->apiV3Key = env('API_KEY', '');
$this->certSerialNo= env('CERT_SERIAL_NO', '');
if (!$this->mchId || !$this->appId || !$this->apiV3Key || !$this->certSerialNo) {
throw new Exception('微信转账配置不完整,请检查 .env 中的 MCH_ID / WECHAT_APPID / API_KEY / CERT_SERIAL_NO');
}
// 私钥支持本地路径、URL 或直接内容
$privateKeyConf = env('PRIVATE_KEY', '');
if ($privateKeyConf) {
if (file_exists($privateKeyConf)) {
$this->privateKey = file_get_contents($privateKeyConf);
} elseif (filter_var($privateKeyConf, FILTER_VALIDATE_URL)) {
$this->privateKey = file_get_contents($privateKeyConf);
if ($this->privateKey === false) {
Log::error('无法从URL加载私钥', ['url' => $privateKeyConf]);
$this->privateKey = '';
}
} else {
$this->privateKey = $privateKeyConf;
}
}
if (empty($this->privateKey)) {
throw new Exception('商户私钥加载失败,请检查 PRIVATE_KEY 配置');
}
// 公钥可选用于后续回调验签支持本地路径、URL 或直接内容
$publicKeyConf = env('WECHAT_PAY_PUB_KEY', '');
$this->publicKey = '';
if ($publicKeyConf) {
if (file_exists($publicKeyConf)) {
$this->publicKey = file_get_contents($publicKeyConf);
} elseif (filter_var($publicKeyConf, FILTER_VALIDATE_URL)) {
$this->publicKey = file_get_contents($publicKeyConf);
if ($this->publicKey === false) {
Log::error('无法从URL加载公钥', ['url' => $publicKeyConf]);
$this->publicKey = '';
}
} else {
$this->publicKey = $publicKeyConf;
}
}
}
/**
* 发起转账
* @param array $params 转账参数
* - out_bill_no: 商户单号(必填)
* - openid: 收款用户OpenID必填
* - transfer_amount: 转账金额,单位:分(必填)
* - transfer_remark: 转账备注(必填)
* - transfer_scene_id: 转账场景ID必填1000现金营销1006企业报销
* - user_name: 收款用户姓名(选填,>=2000元必填
* - transfer_scene_report_infos: 转账场景报备信息(必填)
* - notify_url: 通知地址(选填)
* - user_recv_perception: 用户收款感知(选填)
* @return array
*/
public function createTransfer($params)
{
$url = self::API_BASE_URL . '/v3/fund-app/mch-transfer/transfer-bills';
// 构建请求体
$body = [
'appid' => $this->appId,
'out_bill_no' => $params['out_bill_no'],
'transfer_scene_id' => $params['transfer_scene_id'],
'openid' => $params['openid'],
'transfer_amount' => intval($params['transfer_amount']),
'transfer_remark' => $params['transfer_remark'],
// 场景报备信息(必填):岗位类型 + 报酬说明
'transfer_scene_report_infos' => $params['transfer_scene_report_infos'] ?? [],
];
// 可选参数
if (isset($params['user_name']) && !empty($params['user_name'])) {
// 需要加密
$body['user_name'] = $this->encryptSensitiveData($params['user_name']);
}
if (isset($params['notify_url']) && !empty($params['notify_url'])) {
$body['notify_url'] = $params['notify_url'];
}
// user_recv_perception 暂不传,避免 INVALID_REQUEST“暂不支持展示当前传入的用户收款感知”
$result = $this->request('POST', $url, $body);
return $result;
}
/**
* 查询转账单(通过商户单号)
* @param string $outBillNo 商户单号
* @return array
*/
public function queryByOutBillNo($outBillNo)
{
$url = self::API_BASE_URL . '/v3/fund-app/mch-transfer/transfer-bills/out-bill-no/' . $outBillNo;
return $this->request('GET', $url);
}
/**
* 查询转账单(通过微信单号)
* 参考https://pay.weixin.qq.com/doc/v3/merchant/4012716457
* @param string $transferBillNo 微信转账单号
* @return array
*/
public function queryByTransferBillNo($transferBillNo)
{
$url = self::API_BASE_URL . '/v3/fund-app/mch-transfer/transfer-bills/transfer-bill-no/' . $transferBillNo;
return $this->request('GET', $url);
}
/**
* 撤销转账
* @param string $transferBillNo 微信转账单号
* @return array
*/
public function cancelTransfer($transferBillNo)
{
$url = self::API_BASE_URL . '/v3/fund-app/mch-transfer/transfer-bills/' . $transferBillNo . '/cancel';
return $this->request('POST', $url);
}
/**
* 发送HTTP请求
* @param string $method 请求方法
* @param string $url 请求URL
* @param array $body 请求体POST时使用
* @return array
*/
private function request($method, $url, $body = [])
{
$timestamp = time();
$nonce = $this->generateNonce();
$bodyStr = !empty($body) ? json_encode($body, JSON_UNESCAPED_UNICODE) : '';
// 构建签名
$signature = $this->buildSignature($method, $url, $timestamp, $nonce, $bodyStr);
// 构建请求头
$headers = [
'Content-Type: application/json',
'Accept: application/json',
'User-Agent: WechatPay-APIv3-PHP',
'Authorization: ' . $this->buildAuthorization($method, $url, $timestamp, $nonce, $bodyStr),
];
// 如果有证书序列号,添加到请求头
if (!empty($this->certSerialNo)) {
$headers[] = 'Wechatpay-Serial: ' . $this->certSerialNo;
}
// 发送请求
$ch = curl_init();
curl_setopt($ch, CURLOPT_URL, $url);
curl_setopt($ch, CURLOPT_RETURNTRANSFER, true);
curl_setopt($ch, CURLOPT_SSL_VERIFYPEER, false);
curl_setopt($ch, CURLOPT_SSL_VERIFYHOST, false);
curl_setopt($ch, CURLOPT_HTTPHEADER, $headers);
curl_setopt($ch, CURLOPT_TIMEOUT, 30);
if ($method === 'POST') {
curl_setopt($ch, CURLOPT_POST, true);
curl_setopt($ch, CURLOPT_POSTFIELDS, $bodyStr);
}
$response = curl_exec($ch);
$httpCode = curl_getinfo($ch, CURLINFO_HTTP_CODE);
$error = curl_error($ch);
curl_close($ch);
if ($error) {
// 尝试写入日志,但不影响错误返回
try {
Log::error('微信支付请求失败: ' . $error);
} catch (\Exception $e) {
// 日志写入失败不影响错误返回
}
return ['success' => false, 'error' => ['code' => 'CURL_ERROR', 'message' => $error]];
}
$result = json_decode($response, true);
if ($httpCode === 200) {
return ['success' => true, 'data' => $result];
} else {
// 尝试写入日志,但不影响错误返回
try {
Log::error('微信支付API错误: HTTP ' . $httpCode . ', Response: ' . $response);
} catch (\Exception $e) {
// 日志写入失败不影响错误返回
}
return ['success' => false, 'http_code' => $httpCode, 'error' => $result];
}
}
/**
* 构建签名
* @param string $method 请求方法
* @param string $url 请求URL不包含域名
* @param int $timestamp 时间戳
* @param string $nonce 随机字符串
* @param string $body 请求体
* @return string
*/
private function buildSignature($method, $url, $timestamp, $nonce, $body)
{
$urlParts = parse_url($url);
$urlPath = $urlParts['path'] . (isset($urlParts['query']) ? '?' . $urlParts['query'] : '');
$message = $method . "\n" .
$urlPath . "\n" .
$timestamp . "\n" .
$nonce . "\n" .
$body . "\n";
openssl_sign($message, $signature, $this->privateKey, OPENSSL_ALGO_SHA256);
return base64_encode($signature);
}
/**
* 构建Authorization头
* @param string $method
* @param string $url
* @param int $timestamp
* @param string $nonce
* @param string $body
* @return string
*/
private function buildAuthorization($method, $url, $timestamp, $nonce, $body)
{
$urlParts = parse_url($url);
$urlPath = $urlParts['path'] . (isset($urlParts['query']) ? '?' . $urlParts['query'] : '');
$signature = $this->buildSignature($method, $url, $timestamp, $nonce, $body);
// 获取证书序列号(从私钥中提取,这里简化处理,实际应该从证书中获取)
$serialNo = $this->certSerialNo ?: 'YOUR_CERT_SERIAL_NO';
return sprintf(
'WECHATPAY2-SHA256-RSA2048 mchid="%s",nonce_str="%s",timestamp="%d",serial_no="%s",signature="%s"',
$this->mchId,
$nonce,
$timestamp,
$serialNo,
$signature
);
}
/**
* 加密敏感信息(使用微信支付公钥加密)
* @param string $data 待加密数据
* @return string base64编码的加密数据
*/
private function encryptSensitiveData($data)
{
// 注意:这里需要使用微信支付平台证书公钥加密
// 简化实现,实际应该使用微信支付平台证书
if (empty($this->publicKey)) {
// 如果没有配置公钥,返回原数据(实际生产环境必须加密)
Log::warning('未配置微信支付公钥,敏感数据未加密');
return $data;
}
$encrypted = '';
if (openssl_public_encrypt($data, $encrypted, $this->publicKey, OPENSSL_PKCS1_OAEP_PADDING)) {
return base64_encode($encrypted);
}
Log::error('敏感数据加密失败');
return $data;
}
/**
* 生成随机字符串
* @param int $length 长度
* @return string
*/
private function generateNonce($length = 32)
{
return bin2hex(random_bytes($length / 2));
}
/**
* 验证回调签名
* @param array $headers 请求头
* @param string $body 请求体
* @return bool
*/
public function verifyCallback($headers, $body)
{
if (empty($this->publicKey)) {
// 不记录日志,避免日志错误
return false;
}
// 从请求头中提取签名信息注意HTTP头中的下划线会被转换为中划线
$signature = $headers['Wechatpay-Signature'] ?? $headers['wechatpay-signature'] ?? '';
$timestamp = $headers['Wechatpay-Timestamp'] ?? $headers['wechatpay-timestamp'] ?? '';
$nonce = $headers['Wechatpay-Nonce'] ?? $headers['wechatpay-nonce'] ?? '';
$serial = $headers['Wechatpay-Serial'] ?? $headers['wechatpay-serial'] ?? '';
if (empty($signature) || empty($timestamp) || empty($nonce) || empty($serial)) {
// 不记录日志,避免日志错误
return false;
}
// 构建验证消息(按照微信支付文档格式)
$message = $timestamp . "\n" . $nonce . "\n" . $body . "\n";
// 验证签名
$signatureData = base64_decode($signature);
$result = openssl_verify($message, $signatureData, $this->publicKey, OPENSSL_ALGO_SHA256);
if ($result === 1) {
return true;
} else {
// 不记录日志,避免日志错误
return false;
}
}
/**
* 解密回调通知中的resource数据
* @param array $resource 回调通知中的resource对象
* @return array|null 解密后的数据失败返回null
*/
/**
* 解密回调报文(按照官方文档实现)
* 参考https://pay.weixin.qq.com/doc/v3/merchant/4012071382
*
* @param array $resource 加密的资源对象
* @return array|null 解密后的数据
*/
public function decryptCallbackResource($resource)
{
// 调试信息
$debug = [];
$debug['step'] = '1.检查输入参数';
// 1. 检查必要参数
if (empty($resource['ciphertext']) || empty($resource['nonce']) || !isset($resource['associated_data'])) {
$debug['error'] = '缺少必要参数';
$debug['has_ciphertext'] = !empty($resource['ciphertext']);
$debug['has_nonce'] = !empty($resource['nonce']);
$debug['has_associated_data'] = isset($resource['associated_data']);
return ['_debug' => $debug, 'result' => null];
}
// 2. 检查加密算法
$algorithm = $resource['algorithm'] ?? '';
$debug['step'] = '2.检查加密算法';
$debug['algorithm'] = $algorithm;
if ($algorithm !== 'AEAD_AES_256_GCM') {
$debug['error'] = '不支持的加密算法';
return ['_debug' => $debug, 'result' => null];
}
// 3. 检查APIv3密钥长度必须是32字节
$debug['step'] = '3.检查APIv3密钥';
$debug['api_v3_key_length'] = strlen($this->apiV3Key);
if (strlen($this->apiV3Key) !== 32) {
$debug['error'] = 'APIv3密钥长度必须为32字节';
return ['_debug' => $debug, 'result' => null];
}
// 4. 准备解密参数(按照官方文档)
$debug['step'] = '4.准备解密参数';
// Base64解码密文
$ciphertext = base64_decode($resource['ciphertext']);
$nonce = $resource['nonce'];
$associatedData = $resource['associated_data'];
$debug['ciphertext_base64_length'] = strlen($resource['ciphertext']);
$debug['ciphertext_decoded_length'] = strlen($ciphertext);
$debug['nonce'] = $nonce;
$debug['nonce_length'] = strlen($nonce);
$debug['associated_data'] = $associatedData;
// 5. 检查密文长度必须大于认证标签长度16字节
$AUTH_TAG_LENGTH = 16;
if (strlen($ciphertext) <= $AUTH_TAG_LENGTH) {
$debug['error'] = '密文长度不足,必须大于' . $AUTH_TAG_LENGTH . '字节';
return ['_debug' => $debug, 'result' => null];
}
// 6. 分离密文和认证标签(按照官方文档)
$debug['step'] = '6.分离密文和认证标签';
// 密文主体去掉最后16字节
$ctext = substr($ciphertext, 0, -$AUTH_TAG_LENGTH);
// 认证标签最后16字节
$authTag = substr($ciphertext, -$AUTH_TAG_LENGTH);
$debug['ctext_length'] = strlen($ctext);
$debug['authTag_length'] = strlen($authTag);
// 7. 使用OpenSSL解密按照官方文档
$debug['step'] = '7.OpenSSL解密';
// PHP >= 7.1 支持 AES-256-GCM
if (PHP_VERSION_ID < 70100) {
$debug['error'] = 'PHP版本必须 >= 7.1';
$debug['php_version'] = PHP_VERSION;
return ['_debug' => $debug, 'result' => null];
}
if (!in_array('aes-256-gcm', openssl_get_cipher_methods())) {
$debug['error'] = 'OpenSSL不支持aes-256-gcm算法';
return ['_debug' => $debug, 'result' => null];
}
// 执行解密(参数顺序按照官方文档)
$decrypted = openssl_decrypt(
$ctext, // 密文主体
'aes-256-gcm', // 加密算法
$this->apiV3Key, // API v3密钥
OPENSSL_RAW_DATA, // 原始数据
$nonce, // 随机串
$authTag, // 认证标签
$associatedData // 附加数据
);
$debug['step'] = '8.检查解密结果';
$debug['decrypt_success'] = ($decrypted !== false);
if ($decrypted === false) {
$debug['error'] = 'openssl_decrypt解密失败';
$debug['openssl_error'] = openssl_error_string() ?: '无错误信息';
return ['_debug' => $debug, 'result' => null];
}
$debug['decrypted_length'] = strlen($decrypted);
$debug['decrypted_preview'] = substr($decrypted, 0, 200);
// 8. 解析JSON
$debug['step'] = '9.解析JSON';
$data = json_decode($decrypted, true);
if (json_last_error() !== JSON_ERROR_NONE) {
$debug['error'] = 'JSON解析失败';
$debug['json_error'] = json_last_error_msg();
$debug['decrypted_full'] = $decrypted;
return ['_debug' => $debug, 'result' => null];
}
$debug['success'] = true;
$debug['data_keys'] = array_keys($data);
return ['_debug' => $debug, 'result' => $data];
}
}