注意:本文档中模型的写法是NiuShop的写法,NiuShop开发文档:https://www.kancloud.cn/niucloud/niushop_b2c_v5_dev/3049012
通过一个日志表来记录商户转账API相关所有请求日志,有助于开发调试。
数据库表 SQL:
CREATE TABLE `merchant_transfer_api_log` (
`id` bigint NOT NULL AUTO_INCREMENT,
`site_id` int NOT NULL DEFAULT '0' COMMENT '站点id',
`merchant_id` int NOT NULL DEFAULT '0' COMMENT '商户id',
`related_type` varchar(80) CHARACTER SET utf8 COLLATE utf8_general_ci NOT NULL DEFAULT '' COMMENT '关联类型:merchant-member-withdraw=商户会员提现,member-withdraw=普通会员提现',
`related_id` int NOT NULL DEFAULT '0' COMMENT '关联类型对应的记录id',
`log_type` varchar(80) CHARACTER SET utf8 COLLATE utf8_general_ci NOT NULL DEFAULT '' COMMENT '日志类型:api-request=创建类请求,api-query=查询类请求,api-notify=异步通知',
`operator_identity` varchar(80) CHARACTER SET utf8 COLLATE utf8_general_ci NOT NULL DEFAULT '' COMMENT '关联操作人身份:system=系统,platform-admin=平台管理员,merchant-admin=商户管理员,merchant-member=商户绑定会员,member=普通会员',
`operator_id` int NOT NULL DEFAULT '0' COMMENT '关联操作人id,操作人身份为system时该字段的值为0,其他根据场景为操作人id',
`api_desc` varchar(255) CHARACTER SET utf8 COLLATE utf8_general_ci NOT NULL DEFAULT '' COMMENT 'api简介',
`request_data` longtext CHARACTER SET utf8 COLLATE utf8_general_ci COMMENT '请求数据(JSON)',
`response_data` longtext CHARACTER SET utf8 COLLATE utf8_general_ci COMMENT '响应数据(JSON)',
`create_time` int NOT NULL DEFAULT '0' COMMENT '创建时间',
PRIMARY KEY (`id`) USING BTREE
) ENGINE=InnoDB AUTO_INCREMENT=62 DEFAULT CHARSET=utf8mb3 COMMENT='商户转账api请求日志表';
创建模型:
// file: app/model/merchant/MerchantTransferApiLog.php
<?php
/**
*
*/
namespace app\model\merchant;
use app\model\BaseModel;
use Exception;
use think\facade\Cache;
/**
* 转账请求日志
*/
class MerchantTransferApiLog extends BaseModel
{
public string $table_name = 'merchant_transfer_api_log';
public string $table_pk = 'id';
/**
* 关联类型 related_type :商户会员提现
*/
const RELATED_TYPE_MCH_MEM_WITHDRAW = 'merchant-member-withdraw';
/**
* 关联类型 related_type :普通会员提现
*/
const RELATED_TYPE_MEM_WITHDRAW = 'member-withdraw';
const RELATED_TYPES = [
'merchant-member-withdraw' => '商户会员提现',
'member-withdraw' => '普通会员提现',
];
/**
* 日志类型 log_type :创建类请求
*/
const LOG_TYPE_API_REQUEST = 'api-request';
/**
* 日志类型 log_type :查询类请求
*/
const LOG_TYPE_API_QUERY = 'api-query';
/**
* 日志类型 log_type :异步通知
*/
const LOG_TYPE_API_NOTIFY = 'api-notify';
const LOG_TYPES = [
'api-request' => '创建类请求',
'api-query' => '查询类请求',
'api-notify' => '异步通知'
];
/**
* 关联操作人身份 operator_identity :系统
*/
const OPERATOR_IDENTITY_SYSTEM = 'system';
/**
* 关联操作人身份 operator_identity :平台管理员
*/
const OPERATOR_IDENTITY_PLATFORM_ADMIN = 'platform-admin';
/**
* 关联操作人身份 operator_identity :商户管理员
*/
const OPERATOR_IDENTITY_MERCHANT_ADMIN = 'merchant-admin';
/**
* 关联操作人身份 operator_identity :商户绑定会员
*/
const OPERATOR_IDENTITY_MERCHANT_MEMBER = 'merchant-member';
/**
* 关联操作人身份 operator_identity :普通会员
*/
const OPERATOR_IDENTITY_MEMBER = 'member';
const OPERATOR_IDENTITIES = [
'system' => '系统',
'platform-admin' => '平台管理员',
'merchant-admin' => '商户管理员',
'merchant-member' => '商户绑定会员',
'member' => '普通会员',
];
public function addRecord($data)
{
$data['create_time'] = time();
return model($this->table_name)->add($data);
}
public function update(array $data, array $condition): int
{
return model($this->table_name)->update($data, $condition);
}
}
数据库表 SQL:
CREATE TABLE `merchant_withdraw` (
`id` int NOT NULL AUTO_INCREMENT,
`site_id` int NOT NULL DEFAULT '0' COMMENT '站点id',
`merchant_id` int NOT NULL DEFAULT '0' COMMENT '商户id',
`withdraw_no` varchar(50) CHARACTER SET utf8 COLLATE utf8_general_ci NOT NULL DEFAULT '' COMMENT '提现交易号',
`member_id` int NOT NULL DEFAULT '0' COMMENT '会员id(操作提现的会员id,商家会绑定一个或多个会员)',
`transfer_type` varchar(20) CHARACTER SET utf8 COLLATE utf8_general_ci NOT NULL DEFAULT '' COMMENT '转账提现类型:wechat-wallet=微信零钱,bank-card=银行卡',
`realname` varchar(50) CHARACTER SET utf8 COLLATE utf8_general_ci NOT NULL DEFAULT '' COMMENT '真实姓名',
`apply_amount` decimal(10,2) NOT NULL COMMENT '提现申请金额',
`rate` decimal(10,2) NOT NULL COMMENT '提现手续费比率',
`service_money` decimal(10,2) NOT NULL COMMENT '提现手续费',
`actual_amount` decimal(10,2) NOT NULL COMMENT '提现到账金额',
`apply_time` int NOT NULL DEFAULT '0' COMMENT '申请时间',
`audit_time` int NOT NULL DEFAULT '0' COMMENT '审核时间',
`payment_time` int NOT NULL DEFAULT '0' COMMENT '转账时间',
`transfer_bill_no` varchar(100) NOT NULL DEFAULT '' COMMENT '转账成功返回的微信单号',
`status` int NOT NULL DEFAULT '0' COMMENT '状态:0=待审核,1=待转账,2=已转账,-1=拒绝,-2=转账失败',
`memo` varchar(100) CHARACTER SET utf8 COLLATE utf8_general_ci NOT NULL DEFAULT '' COMMENT '备注',
`refuse_reason` varchar(100) CHARACTER SET utf8 COLLATE utf8_general_ci NOT NULL DEFAULT '' COMMENT '拒绝理由',
`bank_name` varchar(255) CHARACTER SET utf8 COLLATE utf8_general_ci NOT NULL DEFAULT '' COMMENT '银行名称',
`account_number` varchar(255) CHARACTER SET utf8 COLLATE utf8_general_ci NOT NULL DEFAULT '' COMMENT '收款账号',
`mobile` varchar(11) CHARACTER SET utf8 COLLATE utf8_general_ci NOT NULL DEFAULT '' COMMENT '手机号',
`certificate` varchar(255) CHARACTER SET utf8 COLLATE utf8_general_ci NOT NULL DEFAULT '' COMMENT '凭证',
`certificate_remark` varchar(255) CHARACTER SET utf8 COLLATE utf8_general_ci NOT NULL DEFAULT '' COMMENT '凭证说明',
`fail_reason` varchar(255) CHARACTER SET utf8 COLLATE utf8_general_ci NOT NULL DEFAULT '' COMMENT '失败原因',
PRIMARY KEY (`id`) USING BTREE
) ENGINE=InnoDB AUTO_INCREMENT=37 DEFAULT CHARSET=utf8mb3 COMMENT='商圈商户提现记录表';
创建模型:
<?php
namespace app\model\merchant;
use app\model\BaseModel;
use Exception;
use think\facade\Cache;
/**
* 商家提现
*/
class MerchantWithdraw extends BaseModel
{
public string $table_name = 'merchant_withdraw';
public string $table_pk = 'id';
/**
* 提现类型:微信零钱
*/
const TRANS_TYPE_WECHAT_WALLET = 'wechat-wallet';
/**
* 提现类型:银行卡
*/
const TRANS_TYPE_BANK_CARD = 'bank-card';
const TRANS_TYPES = [
'wechat-wallet' => '微信零钱',
'bank-card' => '银行卡'
];
/**
* 提现状态:待审核
*/
const TRANS_STATUS_APPLIED = 0;
/**
* 提现状态:待转账
*/
const TRANS_STATUS_PENDING = 1;
/**
* 提现状态:已转账
*/
const TRANS_STATUS_SUCCESS = 2;
/**
* 提现状态:拒绝
*/
const TRANS_STATUS_REFUSE = -1;
/**
* 提现状态:转账失败
*/
const TRANS_STATUS_FAIL = -2;
const TRANS_STATUS = [
0 => '待审核',
1 => '待提现',
2 => '已提现',
-1 => '拒绝',
-2 => '转账失败',
];
public function addRecord($data)
{
$data['apply_time'] = time();
return model($this->table_name)->add($data);
}
public function updateRecord(array $condition, array $data)
{
$res = model($this->table_name)->update($data, $condition);
return $this->success($res);
}
public function getInfo(array $condition, string $fields = '*', string $alias = 'a', $join = null, $data = null)
{
$info = model($this->table_name)->getInfo($condition, $fields, $alias, $join, $data);
if ($info) {
$info['apply_time'] = date('Y-m-d H:i:s', $info['apply_time']);
$info['audit_time'] = !empty($info['audit_time']) ? date('Y-m-d H:i:s', $info['audit_time']) : '-';
$info['payment_time'] = !empty($info['payment_time']) ? date('Y-m-d H:i:s', $info['payment_time']) : '-';
$info['transfer_type_text'] = self::TRANS_TYPES[$info['transfer_type']];
$info['status_text'] = self::TRANS_STATUS[$info['status']];
}
return $this->success($info);
}
/**
* 获取订单分页列表
* @param array $condition
* @param int $page
* @param int $page_size
* @param string $order
* @param string $field
* @param string $alias
* @param array $join
* @param string $group
* @return array
*/
public function getPageList(
array $condition = [],
int $page = 1,
int $page_size = PAGE_LIST_ROWS,
string $order = 'a.apply_time desc',
string $field = '*',
string $alias = 'a',
array $join = [],
string $group = ''): array
{
try {
$res = model($this->table_name)->pageList($condition, $field, $order, $page, $page_size, $alias, $join, $group);
foreach ($res['list'] as $k => &$v) {
$v['apply_time'] = date('Y-m-d H:i:s', $v['apply_time']);
$v['audit_time'] = !empty($v['audit_time']) ? date('Y-m-d H:i:s', $v['audit_time']) : '-';
$v['payment_time'] = !empty($v['payment_time']) ? date('Y-m-d H:i:s', $v['payment_time']) : '-';
$v['transfer_type_text'] = self::TRANS_TYPES[$v['transfer_type']];
$v['status_text'] = self::TRANS_STATUS[$v['status']];
}
return $this->success($res);
} catch (Exception $e) {
return $this->error($e->getMessage());
}
}
/**
* 修改状态
* @param int $id
* @param int $status
* @return array
*/
public function modifyMerchantOrderStatus(int $id, int $status): array
{
// todo 此处需要增加根据实际业务增加检查状态更改逻辑及限制
$res = model($this->table_name)->update(
['status' => $status],
[[$this->table_pk, '=', $id]]
);
return $this->success($res);
}
/**
* 提现流水号
*/
public function createWithdrawNo()
{
$cache = Cache::get('merchant_withdraw_no' . time());
if (empty($cache)) {
Cache::set('niutk' . time(), 1000);
$cache = Cache::get('merchant_withdraw_no' . time());
} else {
$cache = $cache + 1;
Cache::set('merchant_withdraw_no' . time(), $cache);
}
$no = date('Ymdhis', time()) . rand(1000, 9999) . $cache;
return $no;
}
}
该类与业务无关,便于移植、高效复用,纯工具类库。(建议在TP生态下使用,其他框架请根据实际情况调整)
// file: extend/WechatPayV3Lib.php
<?php
namespace extend;
use Exception;
use GuzzleHttp\Exception\RequestException;
use think\facade\Env;
use WeChatPay\Builder;
use WeChatPay\BuilderChainable;
use WeChatPay\Crypto\Rsa;
use WeChatPay\Crypto\AesGcm;
use WeChatPay\Formatter;
/**
* 微信支付 APIv3
* @date 20251104
* @author moyuy77 (微信号)
*/
class WechatPayV3Lib
{
/**
* @var BuilderChainable
*/
public BuilderChainable $instance;
public array $config = [];
public function __construct(array $config)
{
// 商户号,如:1711028017
$merchantId = $config['mch_id'];
// 从本地文件中加载「商户API私钥」,「商户API私钥」会用来生成请求的签名
// 如:E:\www\www.51blog.xyz\upload\wechatpay\cert\20250421112046174520564623813.pem
$merchantPrivateKeyFilePath = $config['mch_pri_key_filepath'];
$merchantPrivateKeyInstance = Rsa::from(file_get_contents($merchantPrivateKeyFilePath), Rsa::KEY_TYPE_PRIVATE);
// 「商户API证书」的「证书序列号」,如:7988C8B2DD95C7D6DA23674FCEA1FF5B9C56F47C
$merchantCertificateSerial = $config['mch_api_cert_serial'];
// 从本地文件中加载「微信支付平台证书」,可由内置CLI工具下载到,用来验证微信支付应答的签名
// 如:E:\www\www.51blog.xyz\upload\wechatpay\cert\wechatpay_1325FC2753504A95BF8D957AA781F2AB84F130AB.pem
$platformCertificateFilePath = $config['platform_cert_filepath'];
$onePlatformPublicKeyInstance = Rsa::from(file_get_contents($platformCertificateFilePath), Rsa::KEY_TYPE_PUBLIC);
// 「微信支付平台证书」的「平台证书序列号」
// 可以从「微信支付平台证书」文件解析,也可以在 商户平台 -> 账户中心 -> API安全 查询到
// 如:1325FC2753504A95BF8D281AA781F2AB84F130AB
$platformCertificateSerial = $config['platform_cert_serial'];
// 从本地文件中加载「微信支付公钥」,用来验证微信支付应答的签名
// 如:E:\www\www.51blog.xyz\upload\wechatpay\cert\pub_key.pem
$platformPublicKeyFilePath = $config['platform_pub_key_filepath'];
$twoPlatformPublicKeyInstance = Rsa::from(file_get_contents($platformPublicKeyFilePath), Rsa::KEY_TYPE_PUBLIC);
// 「微信支付公钥」的「微信支付公钥ID」
// 需要在 商户平台 -> 账户中心 -> API安全 查询
// 如:PUB_KEY_ID_0117110272172025110300211591772205
$platformPublicKeyId = $config['platform_pub_key_id'];
// 构造一个 APIv3 客户端实例
$this->instance = Builder::factory([
'mchid' => $merchantId,
'serial' => $merchantCertificateSerial,
'privateKey' => $merchantPrivateKeyInstance,
'certs' => [
$platformCertificateSerial => $onePlatformPublicKeyInstance,
$platformPublicKeyId => $twoPlatformPublicKeyInstance,
],
]);
$this->config = $config;
}
/**
* 下载微信支付平台证书
* @link https://pay.weixin.qq.com/doc/v3/merchant/4012551764
* @return array
* @throws Exception
*/
public function downloadPlatformCertificate(): array
{
try {
$resp = $this->instance
->chain('v3/certificates')
->get();
return json_decode((string)$resp->getBody(), true);
} catch (Exception $e) {
$errMsg = $e->getMessage();
if (Env::get('app_debug') && $e instanceof RequestException && $e->hasResponse()) {
$r = $e->getResponse();
$errMsg = $r->getStatusCode() . ' ' . $r->getReasonPhrase();
$errMsg .= (string)$r->getBody();
$errMsg .= $e->getTraceAsString();
}
throw new Exception($errMsg);
}
}
/**
* 商家转账 - 发起转账
* @link https://pay.weixin.qq.com/doc/v3/merchant/4012716434
* @param array $params
* @return array
* @throws Exception
*/
public function initiateMchTransfer(array $params): array
{
try {
$body = [
// 必填
'appid' => $params['appid'],
// 必填 【商户单号】 商户系统内部的商家单号,要求此参数只能由数字、大小写字母组成,在商户系统内部唯一
'out_bill_no' => $params['out_bill_no'],
// 必填 【转账场景ID】 该笔转账使用的转账场景,可前往“商户平台-产品中心-商家转账”中申请。如:1000(现金营销),1006(企业报销)等
'transfer_scene_id' => $params['transfer_scene_id'],
// 必填 【收款用户OpenID】 用户在商户appid下的唯一标识。
'openid' => $params['openid'],
// 【收款用户姓名】 收款方真实姓名。若传入收款用户姓名,微信支付会校验收款用户与输入姓名是否一致。转账金额>=2,000元时,必须传入该值。该字段需要加密传入
// 'user_name' => $this->encryptor($params['user_name']),
// 必填 【转账金额】 转账金额,单位为“分”,金额小于0.3元的不支持传user_name(收款用户姓名)字段,转账金额>=2,000元时,必须传入该值。
'transfer_amount' => $params['transfer_amount'],
// 必填 【转账备注】 转账备注,用户收款时可见该备注信息,UTF8编码,最多允许32个字符
'transfer_remark' => $params['transfer_remark'],
// 【通知地址】 异步接收微信支付结果通知的回调地址,通知url必须为公网可访问的URL,必须为HTTPS,不能携带参数。
// 'notify_url' => $params['notify_url'],
// 【用户收款感知】 用户收款时感知的收款原因。不填或填空,将展示转账场景的默认内容。
'user_recv_perception' => $params['user_recv_perception'],
// 【转账场景报备信息】 需按转账场景准确填写报备信息
/**
* [
* // 必填 【信息类型】 不能超过15个字符,商户所属转账场景下的信息类型,此字段内容为固定值,需严格按照转账场景报备信息字段说明传参。
* 'info_type' => $params['transfer_scene_report_infos']['info_type'],
* // 必填 【信息内容】 不能超过32个字符,商户所属转账场景下的信息内容,商户可按实际业务场景自定义传参,需严格按照转账场景报备信息字段说明传参。
* 'info_content' => $params['transfer_scene_report_infos']['info_content'],
* ]
*/
'transfer_scene_report_infos' => $params['transfer_scene_report_infos']
];
// 转账金额>=2,000元时,user_name必须传入该值
if (intval($params['transfer_amount']) >= 200000) {
$body['user_name'] = $this->encryptor($params['user_name']);
}
if (!empty($params['notify_url'])) {
$body['notify_url'] = $params['notify_url'];
}
$resp = $this->instance
->chain('/v3/fund-app/mch-transfer/transfer-bills')
->post([
'json' => $body,
'headers' => [
'Wechatpay-Serial' => $this->config['platform_cert_serial'],
],
]);
return json_decode((string)$resp->getBody(), true);
} catch (Exception $e) {
$errMsg = $e->getMessage();
if (Env::get('app_debug') && $e instanceof RequestException && $e->hasResponse()) {
$r = $e->getResponse();
$errMsg = $r->getStatusCode() . ' ' . $r->getReasonPhrase();
$errMsg .= (string)$r->getBody();
$errMsg .= $e->getTraceAsString();
}
throw new Exception($errMsg);
}
}
/**
* 商户单号查询转账单
* @link https://pay.weixin.qq.com/doc/v3/merchant/4012716437
* @param string $out_bill_no
* @return array
* @throws Exception
*/
public function queryMchTransferBillByOutBillNo(string $out_bill_no): array
{
try {
$resp = $this->instance
->chain('/v3/fund-app/mch-transfer/transfer-bills/out-bill-no/' . $out_bill_no)
->get();
return json_decode((string)$resp->getBody(), true);
} catch (Exception $e) {
$errMsg = $e->getMessage();
if (Env::get('app_debug') && $e instanceof RequestException && $e->hasResponse()) {
$r = $e->getResponse();
$errMsg = $r->getStatusCode() . ' ' . $r->getReasonPhrase();
$errMsg .= (string)$r->getBody();
$errMsg .= $e->getTraceAsString();
}
throw new Exception($errMsg);
}
}
/**
* 加密敏感数据(根据微信官方API文档说明来决定要加密的字段)
* @param string $content
* @return string
*/
public function encryptor(string $content): string
{
$thing = file_get_contents($this->config['platform_cert_filepath']);
$platformPublicKeyInstance = Rsa::from($thing, Rsa::KEY_TYPE_PUBLIC);
return Rsa::encrypt($content, $platformPublicKeyInstance);
}
/**
* 解密回调通知数据
* @param array $headers
* @param string $body
* @return array
* @throws Exception
*/
public function decryptMchTransferNotifyData(array $headers, string $body): array
{
$inWechatpaySignature = $headers['wechatpay-signature'];
$inWechatpayTimestamp = $headers['wechatpay-timestamp'];
$inWechatpaySerial = $headers['wechatpay-serial'];
$inWechatpayNonce = $headers['wechatpay-nonce'];
$apiv3Key = $this->config['api_v3_key'];// 在商户平台上设置的APIv3密钥
// 根据通知的平台证书序列号,查询本地平台证书文件,
$platformCertificateFilePath = $this->config['platform_cert_filepath'];
$platformPublicKeyInstance = Rsa::from(file_get_contents($platformCertificateFilePath), Rsa::KEY_TYPE_PUBLIC);
// 检查通知时间偏移量,允许5分钟之内的偏移
$timeOffsetStatus = 300 >= abs(Formatter::timestamp() - (int)$inWechatpayTimestamp);
$timeOffsetStatus = true;
// 验签
$verifiedStatus = Rsa::verify(
// 构造验签名串
Formatter::joinedByLineFeed($inWechatpayTimestamp, $inWechatpayNonce, $body),
$inWechatpaySignature,
$platformPublicKeyInstance
);
if ($timeOffsetStatus && $verifiedStatus) {
// 使用PHP7的数据解构语法,从Array中解构并赋值变量
$inBodyArray = (array)json_decode($body, true);
['resource' => [
'ciphertext' => $ciphertext,
'nonce' => $nonce,
'associated_data' => $aad
]] = $inBodyArray;
// 加密文本消息解密
$inBodyResource = AesGcm::decrypt($ciphertext, $apiv3Key, $nonce, $aad);
// 把解密后的文本转换为PHP Array数组
return (array)json_decode($inBodyResource, true);
} else {
throw new Exception('通知数据解析失败!');
}
}
}
创建一个专门管理微信生态相关的配置文件,来统一管理配置信息。
当然,如果也可以把配置信息保存到数据库中,使用时借助缓存也可高效读取。
// file: config/wechat.php
<?php
// +----------------------------------------------------------------------
// | 微信支付设置
// +----------------------------------------------------------------------
return [
// 公众号
'mp' => [
'appid' => '',
'appsecret' => ''
],
// 小程序
'mini' => [
'appid' => '',
'appsecret' => ''
],
// 微信支付
'pay' => [
// 商户号
'mch_id' => '',
// 商户API私钥文件绝对路径
'mch_pri_key_filepath' => '',
// 商户API证书的证书序列号
'mch_api_cert_serial' => '7988C8B2DD95C7GHJDA24974FCEA1FF5B9C56F47C',
// 微信支付平台证书的文件绝对路径
'platform_cert_filepath' => 'upload/wechat/cert/wechatpay_1325FC2763804A95BF8D957AA781F2AB84F130AB.pem',
// 微信支付平台证书的证书序列号
'platform_cert_serial' => '1325FC2753504A95BF8D957AT671F2AB84F130AB',
// 微信支付公钥文件绝对路径
'platform_pub_key_filepath' => 'upload/wechat/cert/pub_key.pem',
// 微信支付公钥ID
'platform_pub_key_id' => 'PUB_KEY_ID_0117110272172025110336911591002205',
// APIv3 Key
'api_v3_key' => 'fdc79fb823e916dc4521b30f2a37260b',
],
];
上面配置文件中相关开发必要参数的配置及获取,请仔细阅读该文档:https://pay.weixin.qq.com/doc/v3/merchant/4013070756。
Service 类会轻度涉及到业务场景,便于在业务层复用。
比如,下面的 Service 类中就增加记录API请求日志到具体项目中,而上面的工具类就是纯纯对接微信API,可以拿到 任何其他项目中使用。
Service 类中还提供两种获取配置信息的方法,一种是读取配置文件,一种是从数据库中获取。
// file: app/service/WechatPayService.php
<?php
namespace app\service;
use app\model\system\Config as SystemConfigModel;
use Exception;
use extend\WechatPayV3Lib;
use think\facade\Config;
use think\facade\Route;
use app\model\merchant\MerchantTransferApiLog as MerchantTransferApiLogModel;
class WechatPayService
{
public int $site_id;
public string $app_module;
public array $config;
/**
* @var WechatPayV3Lib
*/
public WechatPayV3Lib $handler;
public function __construct(int $site_id = 1, string $app_module = 'shop')
{
$payDbConfig = $this->getPayConfigFromDb($site_id, $app_module);
$payFileConfig = $this->getPayConfigFromFile();
$config = [
// 商户号
'mch_id' => $payDbConfig['mch_id'],
// 商户API私钥文件绝对路径
'mch_pri_key_filepath' => realpath($payDbConfig['apiclient_key']),
// 商户API证书的证书序列号
'mch_api_cert_serial' => $payFileConfig['mch_api_cert_serial'],
// 微信支付平台证书的文件绝对路径
'platform_cert_filepath' => realpath($payFileConfig['platform_cert_filepath']),
// 微信支付平台证书的证书序列号
'platform_cert_serial' => $payFileConfig['platform_cert_serial'],
// 微信支付公钥文件绝对路径
'platform_pub_key_filepath' => realpath($payFileConfig['platform_pub_key_filepath']),
// 微信支付公钥ID
'platform_pub_key_id' => $payFileConfig['platform_pub_key_id'],
// APIv3密钥
'api_v3_key' => $payFileConfig['api_v3_key'],
];
$this->handler = new WechatPayV3Lib($config);
$this->site_id = $site_id;
$this->app_module = $app_module;
$this->config = $config;
}
/**
* 商家转账 - 发起转账
* @param array $params
* @param array $log_data
* @return void
* @throws Exception
*/
public function initiateMchTransfer(array $params, array $log_data = []): array
{
$miniDbConfig = $this->getMiniConfigFromDb($this->site_id, $this->app_module);
$initiate_params = [
'appid' => $miniDbConfig['appid'],
'out_bill_no' => $params['out_trade_no'],
'transfer_scene_id' => $params['transfer_scene_id'],
'openid' => $params['openid'],
'user_name' => $params['user_name'],
'transfer_amount' => $params['transfer_amount'],
'transfer_remark' => $params['transfer_remark'],
'notify_url' => $params['notify_url'],
'user_recv_perception' => $params['user_recv_perception'],
'transfer_scene_report_infos' => $params['transfer_scene_report_infos'],
];
$result = $this->handler->initiateMchTransfer($initiate_params);
// 记录API请求日志
(new MerchantTransferApiLogModel())->addRecord(array_merge(
$log_data,
[
'log_type' => MerchantTransferApiLogModel::LOG_TYPE_API_REQUEST,
'request_data' => json_encode([
'config' => $this->config,
'request_params' => $initiate_params
], JSON_UNESCAPED_UNICODE),
'response_data' => json_encode($result, JSON_UNESCAPED_UNICODE)
]
));
$result['mch_id'] = $this->config['mch_id'];
return $result;
}
/**
* 商户单号查询转账单
* @param string $out_bill_no
* @param array $log_data
* @return array
* @throws Exception
*/
public function queryMchTransferBillByOutBillNo(string $out_bill_no, array $log_data = []): array
{
$result = $this->handler->queryMchTransferBillByOutBillNo($out_bill_no);
// 记录API请求日志
(new MerchantTransferApiLogModel())->addRecord(array_merge(
$log_data,
[
'log_type' => MerchantTransferApiLogModel::LOG_TYPE_API_QUERY,
'request_data' => json_encode([
'config' => $this->config,
'request_params' => compact('out_bill_no')
]),
'response_data' => json_encode($result)
]
));
return $result;
}
/**
* 处理商家转账回调通知
* @param array $params
* @param array $log_data
* @return array
* @throws Exception
*/
public function handleInitiateMchTransferNotify(array $params, array $log_data = []): array
{
debug_log('mch-transfer-notify.log', str_repeat('##', 10));
debug_log('mch-transfer-notify.log', $params);
// 记录API请求日志
$merchant_transfer_api_log_model = new MerchantTransferApiLogModel();
$log_id = $merchant_transfer_api_log_model->addRecord(array_merge(
$log_data,
[
'site_id' => $this->site_id,
'related_type' => MerchantTransferApiLogModel::RELATED_TYPE_MCH_MEM_WITHDRAW,
'log_type' => MerchantTransferApiLogModel::LOG_TYPE_API_NOTIFY,
'operator_identity' => MerchantTransferApiLogModel::OPERATOR_IDENTITY_SYSTEM,
'request_data' => json_encode([
'config' => $this->config,
'request_params' => $params
]),
'response_data' => json_encode([])
]
));
$result = $this->handler->decryptMchTransferNotifyData($params['headers'], $params['body']);
return [$result, $log_id];
}
/**
* 查询获取数据库微信支付配置
* @param int $site_id
* @param string $app_module
* @return array
* @throws Exception
*/
public function getPayConfigFromDb(int $site_id = 1, string $app_module = 'shop'): array
{
$condition = [
['site_id', '=', $site_id],
['app_module', '=', $app_module],
['config_key', '=', 'WECHAT_PAY_CONFIG']
];
$res = (new SystemConfigModel())->getConfig($condition);
if (empty($res['data']['value'])) {
throw new Exception('数据库微信支付配置为空,请检查后重试!');
}
return $res['data']['value'];
}
/**
* 获取配置文件中的微信支付配置
* @return array
* @throws Exception
*/
public function getPayConfigFromFile(): array
{
$config = Config::get('wechat.pay');
if (empty($config)) {
throw new Exception('配置文件的微信支付配置为空,请检查后重试!');
}
return $config;
}
/**
* 查询获取数据库微信小程序配置
* @param int $site_id
* @param string $app_module
* @return array
* @throws Exception
*/
public function getMiniConfigFromDb(int $site_id = 1, string $app_module = 'shop'): array
{
$condition = [
['site_id', '=', $site_id],
['app_module', '=', $app_module],
['config_key', '=', 'WEAPP_CONFIG']
];
$res = (new SystemConfigModel())->getConfig($condition);
if (empty($res['data']['value'])) {
throw new Exception('数据库微信小程序配置为空,请检查后重试!');
}
return $res['data']['value'];
}
/**
* 获取配置文件中的微信小程序配置
* @return array
* @throws Exception
*/
public function getMiniConfigFromFile(): array
{
$config = Config::get('wechat.mini');
if (empty($config)) {
throw new Exception('配置文件的微信小程序配置为空,请检查后重试!');
}
return $config;
}
}
控制器中直接调用 Service 即可,无需调用扩展工具类。
// file: app/api/controller/Merchant.php
<?php
namespace app\api\controller;
use app\api\controller\BaseApi;
use app\model\merchant\MerchantWithdraw as MerchantWithdrawModel;
use app\model\merchant\MerchantTransferApiLog as MerchantTransferApiLogModel;
use app\service\WechatPayService;
use think\Exception;
use think\facade\Cache;
use think\facade\Db;
use think\facade\Route;
use Throwable;
class Merchant extends BaseApi
{
/**
* 余额提现(商家中心)
*/
public function withdraw()
{
$token_check_res = $this->checkToken();
if ($token_check_res['code'] < 0) {
return $this->jsonResponse($this->error($token_check_res['message']));
}
Db::startTrans();
try {
if (empty($this->merchant_info)) {
throw new Exception('无操作权限');
}
$withdraw_amount = input('amount/f', 0);
$realname = input('realname/s');
if ($withdraw_amount <= 0) {
throw new \Exception('请输入提现金额');
}
if ($withdraw_amount > $this->merchant_info['balance']) {
throw new \Exception('请输入提现金额不能大于可提现余额');
}
if (empty($realname)) {
throw new \Exception('请输入真实姓名');
}
// 创建商户提现记录
$merchant_withdraw_model = new MerchantWithdrawModel();
$create_data = [
'site_id' => $this->site_id,
'merchant_id' => $this->merchant_id,
'member_id' => $this->member_id,
'withdraw_no' => $merchant_withdraw_model->createWithdrawNo(),
'transfer_type' => MerchantWithdrawModel::TRANS_TYPE_WECHAT_WALLET,
'realname' => $realname,
'apply_amount' => $withdraw_amount,
'status' => MerchantWithdrawModel::TRANS_STATUS_PENDING,
];
$ins_id = $merchant_withdraw_model->addRecord($create_data);
$params = array(
'out_trade_no' => $create_data['withdraw_no'],
'transfer_scene_id' => '1000',
'openid' => $this->member_info['weapp_openid'],
'user_name' => $realname,
'transfer_amount' => intval($withdraw_amount * 100),
'transfer_remark' => '商圈商户余额提现',
'user_recv_perception' => '现金奖励',
'transfer_scene_report_infos' => [
[
'info_type' => '活动名称',
'info_content' => '会员消费返现',
],
[
'info_type' => '奖励说明',
'info_content' => '会员余额提现',
]
],
'notify_url' => (string)Route::buildUrl('pay/MchTransfer/notify')->domain(true) . '/biz/mchwithdraw'
);
$log_data = [
'site_id' => $this->site_id,
'merchant_id' => $this->merchant_id,
'related_type' => MerchantTransferApiLogModel::RELATED_TYPE_MCH_MEM_WITHDRAW,
'related_id' => $ins_id,
'operator_identity' => MerchantTransferApiLogModel::OPERATOR_IDENTITY_MERCHANT_MEMBER,
'operator_id' => $this->member_id,
'api_desc' => '商户会员提现'
];
//调用在线转账借口
$result = (new WechatPayService($this->site_id))->initiateMchTransfer($params, $log_data);
$response_data = $this->success($result);
Db::commit();
} catch (Throwable $e) {
Db::rollback();
$response_data = $this->error($e->getMessage() ?? '请求失败', $e->getCode());
}
return $this->jsonResponse($response_data);
}
}
官方商家转账回调通知开发文档地址:https://pay.weixin.qq.com/doc/v3/merchant/4012712115
什么时候发送异步通知?
如何正确的对回调通知应答:
{ "code": "FAIL", "message": "失败" }。注意事项:
通知频率:
// file: app/pay/controller/MchTransfer.php
<?php
namespace app\pay\controller;
use app\Controller;
use app\service\WechatPayService;
use think\facade\Request;
use Throwable;
use Exception;
use think\facade\Db;
use app\model\merchant\MerchantWithdraw as MerchantWithdrawModel;
use app\model\merchant\MerchantTransferApiLog as MerchantTransferApiLogModel;
class MchTransfer extends Controller
{
public function notify()
{
$params = [
'headers' => Request::header(),
// 注意:body 一定要通过 php://input 的方式来获取,使用其他方式获取会导致验签失败
'body' => file_get_contents('php://input'),
];
Db::startTrans();
try {
// 此处返回解析得到的异步通知参数的同时,还返回记录id请求日志,便于下面更新日志关联的业务数据
[$result, $api_log_id] = (new WechatPayService())->handleInitiateMchTransferNotify($params);
$withdraw_model = new MerchantWithdrawModel();
// 获取业务模型数据
$withdraw_row = $withdraw_model->getInfo([['withdraw_no', '=', $result['out_bill_no']]], '*');
if (empty($withdraw_row)) {
throw new Exception('out_bill_no 无效');
}
// 回填日志数据
$log_fill_data = ['related_id' => $withdraw_row['id']];
(new MerchantTransferApiLogModel())->update($log_fill_data, [['id', '=', $api_log_id]]);
if ($result['state'] == 'SUCCESS') {
$withdraw_row_updata = [
'actual_amount' => bcdiv($result['transfer_amount'], 100, 2), // 微信返回的实际转账金额
'status' => 'success',
'payment_time' => strtotime($result['update_time']), // 微信返回的转账成功时间
'transfer_bill_no' => $result['transfer_bill_no'], // 微信返回的转账交易单号
];
} elseif ($result['state'] == 'FAIL') {
$withdraw_row_updata = [
'status' => 'fail',
'fail_reason' => $result['fail_reason'],
];
} else {
throw new Exception('非单据终态,需要继续通知直到终态!');
}
// 更新转账记录状态
$transfer_model->update($withdraw_row_updata, [['id', '=', $withdraw_row['id']]]);
// todo: 其他业务代码
Db::commit();
} catch (Throwable $e) {
Db::rollback();
debug_log('mch-transfer-notify.log', $e);
return json(['code' => 'FAIL', 'message' => $e->getMessage()], 509);
}
}
}
开发调试使用。如果长期使用,建议进行日志切割,如根据创建年月日层级目录,否则会导致单个日志文件体积越来越大。
/**
* 快捷记录日志(开发调试工具函数)
* @param string $file 日志文件名称
* @param $data
*/
function debug_log(string $file, $data)
{
if (empty($file)) return;
file_put_contents($file, date('Y-m-d H:i:s') . ' ' . (is_array($data) ? json_encode($data, JSON_UNESCAPED_UNICODE) : $data) . PHP_EOL, 8);
}
🔥BuildAdmin是一个永久免费开源,无需授权即可商业使用,且使用了流行技术栈快速创建商业级后台管理系统。