443 lines
18 KiB
PHP
443 lines
18 KiB
PHP
<?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 换 openid,wx.requestPayment 调起) */
|
||
public const string SCENE_MINI = 'mini';
|
||
/** 支付场景:公众号 H5(网页授权换 openid,WeixinJSBridge 调起) */
|
||
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 = $gatewayData['wxjsapistr'] ?? '{}';
|
||
$wxjsapiData = json_decode($wxjsapistr);
|
||
|
||
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);
|
||
}
|
||
}
|