Files
xin-procurement/app/Services/OnlinePaymentService.php
T
2026-09-04 23:30:13 +08:00

445 lines
18 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\Services;
use App\Exceptions\RepositoryException;
use App\Models\BillModel;
use App\Models\NoticeModel;
use App\Models\PaymentModel;
use App\Models\StoreModel;
use Illuminate\Support\Carbon;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Log;
use Throwable;
/**
* 在线支付编排服务(渠道二选一:旺铺网关 / 微信官方支付)
*
* 链路:门店小程序选择账单合并付款
* → code2session 换 openid → 校验并锁定账单 → 创建支付单(pay_type=2
* → 按当前渠道(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';
/** 支付场景:微信小程序(wx.login 换 openidwx.requestPayment 调起) */
public const string SCENE_MINI = 'mini';
/** 支付场景:公众号 H5(网页授权换 openidWeixinJSBridge 调起) */
public const string SCENE_MP = 'mp';
public function __construct(
protected BillNumberService $billNumber,
protected WangpuPayService $wangpu,
protected WechatPayService $wxpay,
protected WechatMiniService $wechat,
protected WechatMpService $wechatMp,
) {}
/**
* 当前在线支付渠道(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() 登录凭证 / 公众号网页授权 code(按 scene 区分)
* @param string $scene 支付场景:mini=小程序(默认) / mp=公众号 H5
* @return array{0: PaymentModel, 1: array<string, mixed>} 支付单与渠道返回的调起支付参数
* @throws Throwable
*/
public function create(StoreModel $store, array $billIds, string $code, string $remark = '', string $scene = self::SCENE_MINI): array
{
$channel = $this->channel();
$payMethod = $channel === self::CHANNEL_WECHAT ? PaymentModel::METHOD_WECHAT : PaymentModel::METHOD_WANGPU;
// 换取付款人 openid 并绑定到门店(小程序/公众号分场景绑定,两者 openid 维度不同不可混用)
if ($scene === self::SCENE_MP) {
$openid = $this->wechatMp->code2openid($code);
if ((string) $store->mp_openid !== $openid) {
$store->mp_openid = $openid;
$store->save();
}
} else {
$openid = $this->wechat->code2session($code);
if ((string) $store->openid !== $openid) {
$store->openid = $openid;
$store->save();
}
}
[$payment, $bills] = DB::transaction(function () use ($store, $billIds, $openid, $remark, $payMethod) {
$bills = BillModel::query()
->where('store_id', $store->id)
->whereIn('id', $billIds)
->lockForUpdate()
->get();
if ($bills->count() !== count($billIds)) {
throw new RepositoryException('包含不属于本店的账单,请刷新后重试');
}
foreach ($bills as $bill) {
if ($bill->status === BillModel::STATUS_PAID) {
throw new RepositoryException('账单 ' . $bill->bill_no . ' 已支付,请刷新后重试');
}
if ((int) $bill->payment_id !== 0) {
throw new RepositoryException('账单 ' . $bill->bill_no . ' 正在支付中,请勿重复提交');
}
}
$amount = $bills->reduce(
static fn (string $carry, BillModel $bill): string => bcadd($carry, (string) $bill->total_amount, 2),
'0'
);
if (bccomp($amount, '0', 2) <= 0) {
throw new RepositoryException('支付金额必须大于 0');
}
$payment = PaymentModel::create([
'payment_no' => $this->billNumber->make('ZF'),
'store_id' => $store->id,
'amount' => $amount,
'pay_type' => PaymentModel::TYPE_ONLINE,
'pay_method' => $payMethod,
'voucher_ids' => '',
'status' => PaymentModel::STATUS_PENDING,
'openid' => $openid,
'remark' => $remark,
]);
// 锁定账单到本支付记录(支付失败/取消后释放,可重新付款)
BillModel::query()->whereIn('id', $bills->pluck('id'))->update(['payment_id' => $payment->id]);
return [$payment, $bills];
});
try {
$gatewayData = $channel === self::CHANNEL_WECHAT
? $this->wxpay->createOrder(
$payment->payment_no,
(string) $payment->amount,
$openid,
'账单合并付款-' . $payment->payment_no,
$scene === self::SCENE_MP ? $this->wechatMp->appid() : '',
)
: $this->wangpu->createOrder([
'mer_order_id' => $payment->payment_no,
'order_amt' => (string) $payment->amount,
'open_id' => $openid,
'sub_appid' => $this->subAppid($scene),
'payway_code' => $this->wangpu->paywayCode($scene),
'order_title' => '账单合并付款-' . $payment->payment_no,
'notifyurl' => $this->wangpu->notifyUrl(),
]);
} catch (Throwable $e) {
// 网关下单失败:整笔作废并释放账单,门店可重新发起
$this->discard($payment, $e->getMessage());
throw $e;
}
if ($channel === self::CHANNEL_WANGPU) {
$payment->order_id = (string) ($gatewayData['order_id'] ?? '');
}
$payment->pay_params = $gatewayData;
$payment->save();
// 调起支付参数:旺铺渠道在应答 wxjsapistr(JSON 串)中;微信渠道下单应答本身即调起参数
$wxjsapiData = $channel === self::CHANNEL_WANGPU
? json_decode((string) ($gatewayData['wxjsapistr'] ?? '{}'))
: $gatewayData;
return [$payment, $wxjsapiData];
}
/**
* 旺铺支付成功后台通知处理:验签由控制器完成,此处按 mer_order_id 定位支付单并幂等结账
*
* @param array<string, mixed> $params 通知报文(已验签)
* @return bool 本次是否执行了结账(false = 重复通知)
* @throws Throwable
*/
public function settleByNotify(array $params): bool
{
$merOrderId = (string) ($params['mer_order_id'] ?? '');
$payment = PaymentModel::query()
->where('payment_no', $merOrderId)
->where('pay_type', PaymentModel::TYPE_ONLINE)
->first();
if ($payment === null) {
throw new RepositoryException('支付记录不存在:' . $merOrderId);
}
if ((int) ($params['order_status'] ?? -1) !== WangpuPayService::ORDER_STATUS_PAID) {
throw new RepositoryException('订单未支付成功(order_status=' . ($params['order_status'] ?? '空') . '');
}
return $this->settle(
$payment,
(string) ($params['trade_no'] ?? ''),
(string) ($params['order_id'] ?? ''),
(string) ($params['trade_time'] ?? ''),
(string) ($params['order_amt'] ?? '0'),
);
}
/**
* 微信支付成功后台通知处理:验签/解密由 WechatPayService 完成,此处按 out_trade_no 定位支付单并幂等结账
*
* @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
{
if ($payment->status === PaymentModel::STATUS_APPROVED) {
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);
if ($orderStatus === WangpuPayService::ORDER_STATUS_PAID) {
$this->settle(
$payment,
(string) ($data['trade_no'] ?? ''),
(string) ($data['order_id'] ?? ''),
(string) ($data['trade_time'] ?? ''),
(string) ($data['order_amt'] ?? '0'),
);
}
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];
}
/**
* 幂等结账(网关通知与主动查询共用入口,内部事务 + 行锁防重)
*
* @return bool 本次是否执行了结账(false = 已结账,直接吞掉重复通知)
* @throws Throwable
*/
public function settle(PaymentModel $payment, string $tradeNo, string $orderId, string $tradeTime, string $paidAmount): bool
{
$settled = DB::transaction(function () use ($payment, $tradeNo, $orderId, $tradeTime, $paidAmount) {
$payment = PaymentModel::query()->lockForUpdate()->find($payment->id);
if ($payment === null) {
throw new RepositoryException('支付记录不存在');
}
if ($payment->status === PaymentModel::STATUS_APPROVED) {
return false; // 幂等:重复通知/查询直接成功
}
if ($payment->pay_type !== PaymentModel::TYPE_ONLINE || $payment->status !== PaymentModel::STATUS_PENDING) {
throw new RepositoryException('支付记录状态异常,无法结账');
}
// 金额一致性校验,防通知篡改
if (bccomp($paidAmount, (string) $payment->amount, 2) !== 0) {
throw new RepositoryException('支付金额与订单金额不一致(通知 ' . $paidAmount . ' / 订单 ' . $payment->amount . '');
}
$bills = $payment->bills()->lockForUpdate()->get();
// 任一账单已通过其他方式收款(如线下登记)则中止,避免重复收款,需人工核实
$paid = $bills->where('status', BillModel::STATUS_PAID);
if ($paid->isNotEmpty()) {
throw new RepositoryException('账单 ' . $paid->pluck('bill_no')->implode('、') . ' 已通过其他方式收款,请人工核实');
}
$paidAt = $this->parseTradeTime($tradeTime);
BillModel::query()->whereIn('id', $bills->pluck('id'))->update([
'status' => BillModel::STATUS_PAID,
'paid_at' => $paidAt,
'paid_operator_id' => 0,
'pay_remark' => (PaymentModel::METHOD_NAMES[$payment->pay_method] ?? '在线支付') . '(支付单号 ' . $payment->payment_no . '',
]);
// 按门店累加总采购金额(只统计商品金额,不含配送费/附加金额)
$store = StoreModel::query()->lockForUpdate()->find($payment->store_id);
if ($store !== null) {
$amount = '0';
foreach ($bills as $bill) {
$amount = bcadd($amount, (string) $bill->product_amount, 2);
}
$store->total_purchase_amount = bcadd((string) $store->total_purchase_amount, $amount, 2);
$store->save();
}
$payment->status = PaymentModel::STATUS_APPROVED;
$payment->trade_no = $tradeNo;
if ($orderId !== '') {
$payment->order_id = $orderId;
}
$payment->paid_at = $paidAt;
$payment->save();
return true;
});
if ($settled) {
$this->notifyStore($payment->fresh());
}
return $settled;
}
/**
* 网关下单失败后作废支付单:置失败并释放账单(保留记录便于排查)
*/
protected function discard(PaymentModel $payment, string $reason): void
{
try {
DB::transaction(function () use ($payment, $reason) {
BillModel::query()
->where('payment_id', $payment->id)
->where('status', BillModel::STATUS_UNPAID)
->update(['payment_id' => 0]);
PaymentModel::query()->where('id', $payment->id)->update([
'status' => PaymentModel::STATUS_REJECTED,
'audit_remark' => mb_substr('网关下单失败:' . $reason, 0, 255),
]);
});
} catch (Throwable $e) {
Log::error('在线支付作废失败', ['payment_id' => $payment->id, 'error' => $e->getMessage()]);
}
}
/**
* 结账成功后通知门店
*/
protected function notifyStore(PaymentModel $payment): void
{
try {
$billsCount = $payment->bills()->count();
NoticeModel::create([
'store_id' => $payment->store_id,
'type' => NoticeModel::TYPE_SYSTEM,
'title' => '账单支付成功',
'content' => mb_substr("您的 {$billsCount} 张账单已通过在线支付完成付款,金额 {$payment->amount} 元(支付单号 {$payment->payment_no}", 0, 500),
'data' => ['payment_id' => $payment->id],
'is_read' => NoticeModel::UNREAD,
]);
} catch (Throwable $e) {
Log::warning('支付成功通知门店失败', ['payment_id' => $payment->id, 'error' => $e->getMessage()]);
}
}
/**
* 网关交易时间解析(格式 yyyy-MM-dd HH:mm:ss,异常回退当前时间)
*/
protected function parseTradeTime(string $tradeTime): Carbon
{
try {
return $tradeTime !== '' ? Carbon::parse($tradeTime) : now();
} catch (Throwable) {
return now();
}
}
/**
* 下单微信子 appid(默认取小程序 appid;公众号 H5 场景取公众号 appid)
*/
protected function subAppid(string $scene = self::SCENE_MINI): string
{
if ($scene === self::SCENE_MP) {
return $this->wechatMp->appid();
}
$subAppid = (string) site_config('wangpu.sub_appid', '');
if ($subAppid === '') {
$subAppid = (string) config('services.wangpu.sub_appid', '');
}
if ($subAppid === '') {
$subAppid = $this->wechat->appid();
}
return trim($subAppid);
}
/**
* 微信渠道商户号(用于通知商户号一致性校验)
*/
protected function wxpayMchId(): string
{
$mchId = (string) site_config('wxpay.mch_id', '');
if ($mchId === '') {
$mchId = (string) config('services.wxpay.mch_id', '');
}
return trim($mchId);
}
}