小程序支付
This commit is contained in:
@@ -13,34 +13,57 @@ use Illuminate\Support\Facades\Log;
|
||||
use Throwable;
|
||||
|
||||
/**
|
||||
* 在线支付编排服务(旺铺网关)
|
||||
* 在线支付编排服务(渠道二选一:旺铺网关 / 微信官方支付)
|
||||
*
|
||||
* 链路:门店小程序选择账单合并付款
|
||||
* → code2session 换 openid → 校验并锁定账单 → 创建支付单(pay_type=2)
|
||||
* → 旺铺统一下单 → 返回调起支付参数 → 小程序 wx.requestPayment
|
||||
* → 按当前渠道(site_config pay.online_channel)走旺铺统一下单或微信 JSAPI 下单
|
||||
* → 返回调起支付参数 → 小程序 wx.requestPayment
|
||||
* → 网关后台通知 / 小程序主动查询 → settle() 幂等结账:
|
||||
* 支付单置成功 + 关联账单批量置已支付 + 累加门店总采购金额(只统计商品金额)+ 通知门店
|
||||
*
|
||||
* 已创建的支付单按自身 pay_method 固定在原渠道查询/结账(渠道切换不影响进行中的支付单)。
|
||||
* 结账口径与后台「支付审核通过 / 线下收款登记」保持一致。
|
||||
*/
|
||||
class OnlinePaymentService
|
||||
{
|
||||
/** 在线支付渠道:旺铺支付网关 */
|
||||
public const string CHANNEL_WANGPU = 'wangpu';
|
||||
/** 在线支付渠道:微信官方支付 */
|
||||
public const string CHANNEL_WECHAT = 'wechat';
|
||||
|
||||
public function __construct(
|
||||
protected BillNumberService $billNumber,
|
||||
protected WangpuPayService $wangpu,
|
||||
protected WechatPayService $wxpay,
|
||||
protected WechatMiniService $wechat,
|
||||
) {}
|
||||
|
||||
/**
|
||||
* 当前在线支付渠道(site_config pay.online_channel 优先,回退 env PAY_ONLINE_CHANNEL,默认旺铺)
|
||||
*/
|
||||
public function channel(): string
|
||||
{
|
||||
$channel = (string) site_config('pay.online_channel', '');
|
||||
if ($channel === '') {
|
||||
$channel = (string) config('services.pay.online_channel', self::CHANNEL_WANGPU);
|
||||
}
|
||||
return $channel === self::CHANNEL_WECHAT ? self::CHANNEL_WECHAT : self::CHANNEL_WANGPU;
|
||||
}
|
||||
|
||||
/**
|
||||
* 发起在线支付
|
||||
*
|
||||
* @param array<int, int> $billIds 合并付款的账单ID
|
||||
* @param string $code 小程序 wx.login() 返回的登录凭证
|
||||
* @return array{0: PaymentModel, 1: array<string, mixed>} 支付单与旺铺返回的调起支付参数
|
||||
* @return array{0: PaymentModel, 1: array<string, mixed>} 支付单与渠道返回的调起支付参数
|
||||
* @throws Throwable
|
||||
*/
|
||||
public function create(StoreModel $store, array $billIds, string $code, string $remark = ''): array
|
||||
{
|
||||
$channel = $this->channel();
|
||||
$payMethod = $channel === self::CHANNEL_WECHAT ? PaymentModel::METHOD_WECHAT : PaymentModel::METHOD_WANGPU;
|
||||
|
||||
// 换取付款人 openid 并绑定到门店(下次可直接复用)
|
||||
$openid = $this->wechat->code2session($code);
|
||||
if ((string) $store->openid !== $openid) {
|
||||
@@ -48,7 +71,7 @@ class OnlinePaymentService
|
||||
$store->save();
|
||||
}
|
||||
|
||||
[$payment, $bills] = DB::transaction(function () use ($store, $billIds, $openid, $remark) {
|
||||
[$payment, $bills] = DB::transaction(function () use ($store, $billIds, $openid, $remark, $payMethod) {
|
||||
$bills = BillModel::query()
|
||||
->where('store_id', $store->id)
|
||||
->whereIn('id', $billIds)
|
||||
@@ -79,7 +102,7 @@ class OnlinePaymentService
|
||||
'store_id' => $store->id,
|
||||
'amount' => $amount,
|
||||
'pay_type' => PaymentModel::TYPE_ONLINE,
|
||||
'pay_method' => PaymentModel::METHOD_WANGPU,
|
||||
'pay_method' => $payMethod,
|
||||
'voucher_ids' => '',
|
||||
'status' => PaymentModel::STATUS_PENDING,
|
||||
'openid' => $openid,
|
||||
@@ -93,21 +116,30 @@ class OnlinePaymentService
|
||||
});
|
||||
|
||||
try {
|
||||
$gatewayData = $this->wangpu->createOrder([
|
||||
'mer_order_id' => $payment->payment_no,
|
||||
'order_amt' => (string) $payment->amount,
|
||||
'open_id' => $openid,
|
||||
'sub_appid' => $this->subAppid(),
|
||||
'order_title' => '账单合并付款-' . $payment->payment_no,
|
||||
'notifyurl' => $this->wangpu->notifyUrl(),
|
||||
]);
|
||||
$gatewayData = $channel === self::CHANNEL_WECHAT
|
||||
? $this->wxpay->createOrder(
|
||||
$payment->payment_no,
|
||||
(string) $payment->amount,
|
||||
$openid,
|
||||
'账单合并付款-' . $payment->payment_no,
|
||||
)
|
||||
: $this->wangpu->createOrder([
|
||||
'mer_order_id' => $payment->payment_no,
|
||||
'order_amt' => (string) $payment->amount,
|
||||
'open_id' => $openid,
|
||||
'sub_appid' => $this->subAppid(),
|
||||
'order_title' => '账单合并付款-' . $payment->payment_no,
|
||||
'notifyurl' => $this->wangpu->notifyUrl(),
|
||||
]);
|
||||
} catch (Throwable $e) {
|
||||
// 网关下单失败:整笔作废并释放账单,门店可重新发起
|
||||
$this->discard($payment, $e->getMessage());
|
||||
throw $e;
|
||||
}
|
||||
|
||||
$payment->order_id = (string) ($gatewayData['order_id'] ?? '');
|
||||
if ($channel === self::CHANNEL_WANGPU) {
|
||||
$payment->order_id = (string) ($gatewayData['order_id'] ?? '');
|
||||
}
|
||||
$payment->pay_params = $gatewayData;
|
||||
$payment->save();
|
||||
|
||||
@@ -115,7 +147,7 @@ class OnlinePaymentService
|
||||
}
|
||||
|
||||
/**
|
||||
* 支付成功后台通知处理:验签由控制器完成,此处按 mer_order_id 定位支付单并幂等结账
|
||||
* 旺铺支付成功后台通知处理:验签由控制器完成,此处按 mer_order_id 定位支付单并幂等结账
|
||||
*
|
||||
* @param array<string, mixed> $params 通知报文(已验签)
|
||||
* @return bool 本次是否执行了结账(false = 重复通知)
|
||||
@@ -145,9 +177,46 @@ class OnlinePaymentService
|
||||
}
|
||||
|
||||
/**
|
||||
* 主动查询网关订单状态:已支付则结账(回调延迟/丢失时的兜底,小程序支付完成后调用)
|
||||
* 微信支付成功后台通知处理:验签/解密由 WechatPayService 完成,此处按 out_trade_no 定位支付单并幂等结账
|
||||
*
|
||||
* @return array{payment: PaymentModel, order_status: int} 最新支付单与网关订单状态
|
||||
* @param array<string, mixed> $params 解密后的交易报文(out_trade_no/trade_state/transaction_id/success_time/amount)
|
||||
* @return bool 本次是否执行了结账(false = 重复通知)
|
||||
* @throws Throwable
|
||||
*/
|
||||
public function settleByWechatNotify(array $params): bool
|
||||
{
|
||||
$outTradeNo = (string) ($params['out_trade_no'] ?? '');
|
||||
$payment = PaymentModel::query()
|
||||
->where('payment_no', $outTradeNo)
|
||||
->where('pay_type', PaymentModel::TYPE_ONLINE)
|
||||
->first();
|
||||
if ($payment === null) {
|
||||
throw new RepositoryException('支付记录不存在:' . $outTradeNo);
|
||||
}
|
||||
if (($params['trade_state'] ?? '') !== WechatPayService::TRADE_STATE_SUCCESS) {
|
||||
throw new RepositoryException('订单未支付成功(trade_state=' . ($params['trade_state'] ?? '空') . ')');
|
||||
}
|
||||
// 商户号一致性校验,防串号
|
||||
$mchId = (string) ($params['mchid'] ?? '');
|
||||
if ($mchId !== '' && $mchId !== $this->wxpayMchId()) {
|
||||
throw new RepositoryException('通知商户号与配置不一致');
|
||||
}
|
||||
|
||||
return $this->settle(
|
||||
$payment,
|
||||
(string) ($params['transaction_id'] ?? ''),
|
||||
'',
|
||||
(string) ($params['success_time'] ?? ''),
|
||||
bcdiv((string) (int) ($params['amount']['total'] ?? 0), '100', 2),
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* 主动查询渠道订单状态:已支付则结账(回调延迟/丢失时的兜底,小程序支付完成后调用)
|
||||
*
|
||||
* 按支付单的 pay_method 固定在原渠道查询,渠道切换不影响进行中的支付单
|
||||
*
|
||||
* @return array{payment: PaymentModel, order_status: int} 最新支付单与渠道订单状态(1=已支付)
|
||||
* @throws Throwable
|
||||
*/
|
||||
public function queryAndSettle(PaymentModel $payment): array
|
||||
@@ -156,6 +225,10 @@ class OnlinePaymentService
|
||||
return ['payment' => $payment, 'order_status' => WangpuPayService::ORDER_STATUS_PAID];
|
||||
}
|
||||
|
||||
if ($payment->pay_method === PaymentModel::METHOD_WECHAT) {
|
||||
return $this->queryWechatAndSettle($payment);
|
||||
}
|
||||
|
||||
$data = $this->wangpu->queryOrder($payment->payment_no, (string) $payment->order_id);
|
||||
$orderStatus = (int) ($data['order_status'] ?? -1);
|
||||
|
||||
@@ -172,6 +245,30 @@ class OnlinePaymentService
|
||||
return ['payment' => $payment->fresh(), 'order_status' => $orderStatus];
|
||||
}
|
||||
|
||||
/**
|
||||
* 微信渠道主动查询:trade_state=SUCCESS 则结账
|
||||
*
|
||||
* @return array{payment: PaymentModel, order_status: int}
|
||||
* @throws Throwable
|
||||
*/
|
||||
protected function queryWechatAndSettle(PaymentModel $payment): array
|
||||
{
|
||||
$data = $this->wxpay->queryOrder($payment->payment_no);
|
||||
$paid = ($data['trade_state'] ?? '') === WechatPayService::TRADE_STATE_SUCCESS;
|
||||
|
||||
if ($paid) {
|
||||
$this->settle(
|
||||
$payment,
|
||||
(string) ($data['transaction_id'] ?? ''),
|
||||
'',
|
||||
(string) ($data['success_time'] ?? ''),
|
||||
bcdiv((string) (int) ($data['amount']['total'] ?? 0), '100', 2),
|
||||
);
|
||||
}
|
||||
|
||||
return ['payment' => $payment->fresh(), 'order_status' => $paid ? 1 : 0];
|
||||
}
|
||||
|
||||
/**
|
||||
* 幂等结账(网关通知与主动查询共用入口,内部事务 + 行锁防重)
|
||||
*
|
||||
@@ -208,7 +305,7 @@ class OnlinePaymentService
|
||||
'status' => BillModel::STATUS_PAID,
|
||||
'paid_at' => $paidAt,
|
||||
'paid_operator_id' => 0,
|
||||
'pay_remark' => PaymentModel::METHOD_NAMES[PaymentModel::METHOD_WANGPU] . '(支付单号 ' . $payment->payment_no . ')',
|
||||
'pay_remark' => (PaymentModel::METHOD_NAMES[$payment->pay_method] ?? '在线支付') . '(支付单号 ' . $payment->payment_no . ')',
|
||||
]);
|
||||
|
||||
// 按门店累加总采购金额(只统计商品金额,不含配送费/附加金额)
|
||||
@@ -307,4 +404,16 @@ class OnlinePaymentService
|
||||
}
|
||||
return trim($subAppid);
|
||||
}
|
||||
|
||||
/**
|
||||
* 微信渠道商户号(用于通知商户号一致性校验)
|
||||
*/
|
||||
protected function wxpayMchId(): string
|
||||
{
|
||||
$mchId = (string) site_config('pay.wxpay_mch_id', '');
|
||||
if ($mchId === '') {
|
||||
$mchId = (string) config('services.wxpay.mch_id', '');
|
||||
}
|
||||
return trim($mchId);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,255 @@
|
||||
<?php
|
||||
|
||||
namespace App\Services;
|
||||
|
||||
use App\Exceptions\RepositoryException;
|
||||
use EasyWeChat\Pay\Application;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Support\Facades\Log;
|
||||
use Throwable;
|
||||
|
||||
/**
|
||||
* 微信官方支付服务(APIv3 小程序 JSAPI,基于 EasyWeChat Pay)
|
||||
*
|
||||
* - 统一下单:POST /v3/pay/transactions/jsapi → prepay_id → 商户私钥签名生成 wx.requestPayment 调起参数
|
||||
* - 交易查询:GET /v3/pay/transactions/out-trade-no/{商户订单号}
|
||||
* - 支付通知:Wechatpay-Signature 验签(配置平台证书/公钥时)+ APIv3 密钥 AES-256-GCM 解密报文
|
||||
*
|
||||
* 配置优先级:后台「系统设置 → 支付配置」(site_config pay.wxpay_*) > config/services.php(env)
|
||||
*/
|
||||
class WechatPayService
|
||||
{
|
||||
/** 通知应答码:成功 / 失败(微信收到 SUCCESS 才停止重推) */
|
||||
public const string NOTIFY_ACK_OK = 'SUCCESS';
|
||||
public const string NOTIFY_ACK_FAIL = 'FAIL';
|
||||
|
||||
/** 交易状态:支付成功(交易查询 / 支付通知的 trade_state) */
|
||||
public const string TRADE_STATE_SUCCESS = 'SUCCESS';
|
||||
|
||||
protected ?Application $application = null;
|
||||
|
||||
/**
|
||||
* 统一下单(小程序 JSAPI)
|
||||
*
|
||||
* @param string $paymentNo 商户订单号(本系统支付单号)
|
||||
* @param string $amountYuan 金额(元,两位小数)
|
||||
* @param string $openid 付款人 openid
|
||||
* @param string $description 订单标题
|
||||
* @return array<string, mixed> wx.requestPayment 调起参数(timeStamp/nonceStr/package/signType/paySign)
|
||||
* @throws RepositoryException 未配置或下单失败
|
||||
*/
|
||||
public function createOrder(string $paymentNo, string $amountYuan, string $openid, string $description): array
|
||||
{
|
||||
$result = $this->call('POST', '/v3/pay/transactions/jsapi', [
|
||||
'appid' => $this->appid(),
|
||||
'mchid' => $this->config('mch_id'),
|
||||
'description' => mb_substr($description, 0, 127),
|
||||
'out_trade_no' => $paymentNo,
|
||||
'notify_url' => $this->notifyUrl(),
|
||||
'amount' => [
|
||||
'total' => (int) bcmul($amountYuan, '100'), // APIv3 金额单位:分
|
||||
'currency' => 'CNY',
|
||||
],
|
||||
'payer' => ['openid' => $openid],
|
||||
], '下单');
|
||||
|
||||
$prepayId = (string) ($result['prepay_id'] ?? '');
|
||||
if ($prepayId === '') {
|
||||
Log::driver('pay')->error('微信支付下单应答缺少 prepay_id', ['response' => $result]);
|
||||
throw new RepositoryException('微信支付下单失败:应答缺少 prepay_id');
|
||||
}
|
||||
|
||||
$params = $this->app()->getUtils()->buildMiniAppConfig($prepayId, $this->appid());
|
||||
Log::driver('pay')->info('微信支付下单成功', ['out_trade_no' => $paymentNo, 'prepay_id' => $prepayId]);
|
||||
|
||||
return $params;
|
||||
}
|
||||
|
||||
/**
|
||||
* 交易查询(按商户订单号)
|
||||
*
|
||||
* @param string $paymentNo 商户订单号(本系统支付单号)
|
||||
* @return array<string, mixed> 微信交易报文(trade_state/transaction_id/success_time/amount.total 等)
|
||||
* @throws RepositoryException 查询失败
|
||||
*/
|
||||
public function queryOrder(string $paymentNo): array
|
||||
{
|
||||
return $this->call('GET', '/v3/pay/transactions/out-trade-no/' . $paymentNo, [
|
||||
'mchid' => $this->config('mch_id'),
|
||||
], '查询');
|
||||
}
|
||||
|
||||
/**
|
||||
* 解密支付结果后台通知(配置平台证书/公钥时先验签,再以 APIv3 密钥 AES-256-GCM 解密)
|
||||
*
|
||||
* @return array<string, mixed> 解密后的交易报文(out_trade_no/trade_state/transaction_id/success_time/amount 等)
|
||||
* @throws RepositoryException 验签或解密失败
|
||||
*/
|
||||
public function decryptNotify(Request $request): array
|
||||
{
|
||||
Log::driver('pay')->info('微信支付通知接收', ['body' => $request->getContent()]);
|
||||
|
||||
$app = $this->app();
|
||||
$server = $app->getServer();
|
||||
$server->setRequestFromSymfonyRequest($request);
|
||||
|
||||
// 配置平台证书/公钥时校验 Wechatpay-Signature;未配置时依赖 AES-GCM 认证解密(APIv3 密钥仅微信与商户持有)
|
||||
if ($this->config('platform_cert') !== '' && $this->config('platform_serial') !== '') {
|
||||
try {
|
||||
$app->getValidator()->validate($server->getRequest());
|
||||
} catch (Throwable $e) {
|
||||
throw new RepositoryException('微信支付通知验签失败:' . $e->getMessage());
|
||||
}
|
||||
}
|
||||
|
||||
try {
|
||||
$message = $server->getRequestMessage();
|
||||
} catch (Throwable $e) {
|
||||
throw new RepositoryException('微信支付通知解密失败:' . $e->getMessage());
|
||||
}
|
||||
|
||||
$params = $message->toArray();
|
||||
Log::driver('pay')->info('微信支付通知解密', ['params' => $params]);
|
||||
return $params;
|
||||
}
|
||||
|
||||
/**
|
||||
* 支付结果后台通知地址(统一下单时上送的 notify_url)
|
||||
*/
|
||||
public function notifyUrl(): string
|
||||
{
|
||||
return rtrim((string) config('app.url'), '/') . '/mini/payment/wechat-notify';
|
||||
}
|
||||
|
||||
/**
|
||||
* 注入 EasyWeChat Application(测试 mock 用)
|
||||
*/
|
||||
public function setApplication(Application $application): static
|
||||
{
|
||||
$this->application = $application;
|
||||
return $this;
|
||||
}
|
||||
|
||||
/**
|
||||
* EasyWeChat Pay Application(按当前配置懒加载构建)
|
||||
*
|
||||
* @throws RepositoryException 必填配置缺失或密钥无法解析
|
||||
*/
|
||||
public function app(): Application
|
||||
{
|
||||
if ($this->application !== null) {
|
||||
return $this->application;
|
||||
}
|
||||
|
||||
$mchId = $this->config('mch_id');
|
||||
if ($mchId === '') {
|
||||
throw new RepositoryException('微信支付未配置商户号,请联系管理员');
|
||||
}
|
||||
|
||||
$platformCerts = [];
|
||||
$platformCert = $this->config('platform_cert');
|
||||
$platformSerial = $this->config('platform_serial');
|
||||
if ($platformCert !== '' && $platformSerial !== '') {
|
||||
$platformCerts[$platformSerial] = $this->pemContent($platformCert, 'CERTIFICATE');
|
||||
}
|
||||
|
||||
try {
|
||||
$this->application = new Application([
|
||||
'mch_id' => $mchId,
|
||||
'private_key' => $this->pemContent($this->config('private_key'), 'PRIVATE'),
|
||||
'certificate' => $this->pemContent($this->config('certificate'), 'CERTIFICATE'),
|
||||
'secret_key' => $this->config('secret_key'),
|
||||
'platform_certs' => $platformCerts,
|
||||
]);
|
||||
} catch (RepositoryException $e) {
|
||||
throw $e;
|
||||
} catch (Throwable $e) {
|
||||
throw new RepositoryException('微信支付配置错误:' . $e->getMessage());
|
||||
}
|
||||
|
||||
return $this->application;
|
||||
}
|
||||
|
||||
/**
|
||||
* 调起微信 v3 接口并处理应答(通讯/业务失败统一抛 RepositoryException)
|
||||
*
|
||||
* @param array<string, mixed> $payload POST 时为 JSON 报文,GET 时为 query 参数
|
||||
* @return array<string, mixed> 应答报文
|
||||
* @throws RepositoryException
|
||||
*/
|
||||
protected function call(string $method, string $uri, array $payload, string $action): array
|
||||
{
|
||||
try {
|
||||
$response = $method === 'GET'
|
||||
? $this->app()->getClient()->get($uri, ['query' => $payload])
|
||||
: $this->app()->getClient()->postJson($uri, $payload);
|
||||
} catch (RepositoryException $e) {
|
||||
throw $e;
|
||||
} catch (Throwable $e) {
|
||||
Log::driver('pay')->error('微信支付' . $action . '通讯异常', ['uri' => $uri, 'error' => $e->getMessage()]);
|
||||
throw new RepositoryException('微信支付' . $action . '通讯异常,请稍后重试');
|
||||
}
|
||||
|
||||
$body = $response->getContent(false);
|
||||
Log::driver('pay')->info('微信支付' . $action . '应答', [
|
||||
'uri' => $uri,
|
||||
'status' => $response->getStatusCode(),
|
||||
'body' => $body,
|
||||
]);
|
||||
|
||||
$result = json_decode($body, true);
|
||||
if ($response->isFailed()) {
|
||||
$message = is_array($result) ? (string) ($result['message'] ?? $result['code'] ?? '') : '';
|
||||
throw new RepositoryException('微信支付' . $action . '失败:' . ($message !== '' ? $message : '未知错误'));
|
||||
}
|
||||
|
||||
return is_array($result) ? $result : [];
|
||||
}
|
||||
|
||||
/**
|
||||
* 下单小程序 appid(默认取小程序自身 appid)
|
||||
*/
|
||||
protected function appid(): string
|
||||
{
|
||||
$appid = $this->config('appid');
|
||||
if ($appid === '') {
|
||||
$appid = (string) config('services.wechat.mini.appid', '');
|
||||
}
|
||||
return $appid;
|
||||
}
|
||||
|
||||
/**
|
||||
* 密钥/证书内容归一化为 PEM:支持完整 PEM、base64 单行、file:// 路径或本地文件路径
|
||||
*
|
||||
* @throws RepositoryException 未配置
|
||||
*/
|
||||
protected function pemContent(string $value, string $kind): string
|
||||
{
|
||||
$value = trim($value);
|
||||
if ($value === '') {
|
||||
$name = $kind === 'PRIVATE' ? '商户API私钥' : '证书';
|
||||
throw new RepositoryException('微信支付未配置' . $name . ',请联系管理员');
|
||||
}
|
||||
if (str_starts_with($value, 'file://') || str_contains($value, '-----BEGIN')) {
|
||||
return $value;
|
||||
}
|
||||
if (is_file($value)) {
|
||||
return 'file://' . $value;
|
||||
}
|
||||
// base64 单行内容包装为 PEM
|
||||
$header = $kind === 'PRIVATE' ? 'PRIVATE' : 'CERTIFICATE';
|
||||
return "-----BEGIN {$header}-----\n" . wordwrap($value, 64, "\n", true) . "\n-----END {$header}-----";
|
||||
}
|
||||
|
||||
/**
|
||||
* 读取支付配置:后台站点配置优先,为空回退 config/services.php(env)
|
||||
*/
|
||||
protected function config(string $key): string
|
||||
{
|
||||
$value = site_config('pay.wxpay_' . $key, '');
|
||||
if ($value === null || $value === '') {
|
||||
$value = config('services.wxpay.' . $key, '');
|
||||
}
|
||||
return trim((string) $value);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user