$billIds 合并付款的账单ID * @param string $code 小程序 wx.login() 登录凭证 / 公众号网页授权 code(按 scene 区分) * @param string $scene 支付场景:mini=小程序(默认) / mp=公众号 H5 * @return array{0: PaymentModel, 1: array} 支付单与渠道返回的调起支付参数 * @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 $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 $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); } }