在线支付
This commit is contained in:
@@ -0,0 +1,310 @@
|
||||
<?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)
|
||||
* → 旺铺统一下单 → 返回调起支付参数 → 小程序 wx.requestPayment
|
||||
* → 网关后台通知 / 小程序主动查询 → settle() 幂等结账:
|
||||
* 支付单置成功 + 关联账单批量置已支付 + 累加门店总采购金额(只统计商品金额)+ 通知门店
|
||||
*
|
||||
* 结账口径与后台「支付审核通过 / 线下收款登记」保持一致。
|
||||
*/
|
||||
class OnlinePaymentService
|
||||
{
|
||||
public function __construct(
|
||||
protected BillNumberService $billNumber,
|
||||
protected WangpuPayService $wangpu,
|
||||
protected WechatMiniService $wechat,
|
||||
) {}
|
||||
|
||||
/**
|
||||
* 发起在线支付
|
||||
*
|
||||
* @param array<int, int> $billIds 合并付款的账单ID
|
||||
* @param string $code 小程序 wx.login() 返回的登录凭证
|
||||
* @return array{0: PaymentModel, 1: array<string, mixed>} 支付单与旺铺返回的调起支付参数
|
||||
* @throws Throwable
|
||||
*/
|
||||
public function create(StoreModel $store, array $billIds, string $code, string $remark = ''): array
|
||||
{
|
||||
// 换取付款人 openid 并绑定到门店(下次可直接复用)
|
||||
$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) {
|
||||
$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' => PaymentModel::METHOD_WANGPU,
|
||||
'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 = $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'] ?? '');
|
||||
$payment->pay_params = $gatewayData;
|
||||
$payment->save();
|
||||
|
||||
return [$payment, $gatewayData];
|
||||
}
|
||||
|
||||
/**
|
||||
* 支付成功后台通知处理:验签由控制器完成,此处按 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'),
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* 主动查询网关订单状态:已支付则结账(回调延迟/丢失时的兜底,小程序支付完成后调用)
|
||||
*
|
||||
* @return array{payment: PaymentModel, order_status: int} 最新支付单与网关订单状态
|
||||
* @throws Throwable
|
||||
*/
|
||||
public function queryAndSettle(PaymentModel $payment): array
|
||||
{
|
||||
if ($payment->status === PaymentModel::STATUS_APPROVED) {
|
||||
return ['payment' => $payment, 'order_status' => WangpuPayService::ORDER_STATUS_PAID];
|
||||
}
|
||||
|
||||
$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];
|
||||
}
|
||||
|
||||
/**
|
||||
* 幂等结账(网关通知与主动查询共用入口,内部事务 + 行锁防重)
|
||||
*
|
||||
* @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[PaymentModel::METHOD_WANGPU] . '(支付单号 ' . $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)
|
||||
*/
|
||||
protected function subAppid(): string
|
||||
{
|
||||
$subAppid = (string) site_config('pay.wangpu_sub_appid', '');
|
||||
if ($subAppid === '') {
|
||||
$subAppid = (string) config('services.wangpu.sub_appid', '');
|
||||
}
|
||||
if ($subAppid === '') {
|
||||
$subAppid = (string) config('services.wechat.mini.appid', '');
|
||||
}
|
||||
return trim($subAppid);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,159 @@
|
||||
<?php
|
||||
|
||||
namespace App\Services;
|
||||
|
||||
use App\Exceptions\RepositoryException;
|
||||
use Illuminate\Support\Facades\Http;
|
||||
use Illuminate\Support\Facades\Log;
|
||||
|
||||
/**
|
||||
* 旺铺支付网关服务(统一下单B-JSAPI / 交易查询 / 后台通知验签)
|
||||
*
|
||||
* 加签规则(请求与回调一致):
|
||||
* 除 sign 外所有非空参数按参数名 ASCII 码升序排列,以 key=value 形式用 & 拼接成 Str,
|
||||
* 在 Str 后拼接 &key={SignKey} 得到 SignStr,对其做 MD5(utf-8)并转大写即为签名值。
|
||||
*
|
||||
* 配置优先级:后台「系统设置 → 支付配置」(site_config pay.wangpu_*) > config/services.php(env)
|
||||
*/
|
||||
class WangpuPayService
|
||||
{
|
||||
/** 网关应答成功码 */
|
||||
public const string CODE_SUCCESS = '0000';
|
||||
|
||||
/** 通知应答码:成功(网关收到 {"code":"00"} 才认为通知成功) */
|
||||
public const string NOTIFY_ACK_OK = '00';
|
||||
|
||||
/** 订单状态:支付成功(交易查询 / 后台通知的 order_status) */
|
||||
public const int ORDER_STATUS_PAID = 1;
|
||||
|
||||
/**
|
||||
* 统一下单B-JSAPI(微信公众号/小程序、支付宝服务窗/生活号、银联二维码)
|
||||
*
|
||||
* @param array<string, mixed> $params 业务参数(mer_order_id/order_amt/open_id/notifyurl 等)
|
||||
* @return array<string, mixed> 网关 data 节点(含 order_id/trade_no/调起支付参数)
|
||||
* @throws RepositoryException 网关应答失败
|
||||
*/
|
||||
public function createOrder(array $params): array
|
||||
{
|
||||
return $this->request('/industrial/payment/order', array_merge([
|
||||
'mer_code' => $this->config('mer_code'),
|
||||
'term_code' => $this->config('term_code'),
|
||||
'payway_code' => $this->config('payway_code'),
|
||||
], $params));
|
||||
}
|
||||
|
||||
/**
|
||||
* 交易查询(单笔订单支付状态同步)
|
||||
*
|
||||
* @param string $merOrderId 商户唯一订单号(本系统 payment_no)
|
||||
* @param string $orderId 旺铺订单号(可选,与 mer_order_id 同时上送时网关以 order_id 为准)
|
||||
* @return array<string, mixed> 网关 data 节点
|
||||
* @throws RepositoryException 网关应答失败
|
||||
*/
|
||||
public function queryOrder(string $merOrderId, string $orderId = ''): array
|
||||
{
|
||||
$params = ['mer_order_id' => $merOrderId];
|
||||
if ($orderId !== '') {
|
||||
$params['order_id'] = $orderId;
|
||||
}
|
||||
return $this->request('/industrial/query/order', $params);
|
||||
}
|
||||
|
||||
/**
|
||||
* 校验后台通知签名(支付成功/退款成功通知通用)
|
||||
*
|
||||
* @param array<string, mixed> $params 通知报文全量参数(含 sign)
|
||||
*/
|
||||
public function verifyNotifySign(array $params): bool
|
||||
{
|
||||
$sign = (string) ($params['sign'] ?? '');
|
||||
if ($sign === '') {
|
||||
return false;
|
||||
}
|
||||
return hash_equals($this->sign($params), strtoupper($sign));
|
||||
}
|
||||
|
||||
/**
|
||||
* MD5 加签:非空参数(sign 除外)按参数名 ASCII 升序 key=value 以 & 拼接,
|
||||
* 末尾拼接 &key={SignKey} 后 MD5 转大写
|
||||
*
|
||||
* @param array<string, mixed> $params
|
||||
*/
|
||||
public function sign(array $params): string
|
||||
{
|
||||
unset($params['sign']);
|
||||
|
||||
$pairs = [];
|
||||
foreach ($params as $key => $value) {
|
||||
if ($value === null || $value === '') {
|
||||
continue;
|
||||
}
|
||||
if (is_array($value) || is_object($value)) {
|
||||
$value = json_encode($value, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES);
|
||||
}
|
||||
$pairs[(string) $key] = (string) $key . '=' . (string) $value;
|
||||
}
|
||||
ksort($pairs, SORT_STRING);
|
||||
|
||||
$signStr = implode('&', $pairs) . '&key=' . $this->config('sign_key');
|
||||
|
||||
return strtoupper(md5($signStr));
|
||||
}
|
||||
|
||||
/**
|
||||
* 支付结果后台通知地址(统一下单时上送的 notifyurl)
|
||||
*/
|
||||
public function notifyUrl(): string
|
||||
{
|
||||
return rtrim((string) config('app.url'), '/') . '/mini/payment/notify';
|
||||
}
|
||||
|
||||
/**
|
||||
* 网关 POST 请求(表单方式):公共参数 + 加签 → 发送 → 校验应答码
|
||||
*
|
||||
* @param array<string, mixed> $params 接口业务参数
|
||||
* @return array<string, mixed> 应答 data 节点
|
||||
* @throws RepositoryException 通讯失败或应答码非成功
|
||||
*/
|
||||
protected function request(string $path, array $params): array
|
||||
{
|
||||
$baseUrl = rtrim($this->config('base_url'), '/');
|
||||
if ($baseUrl === '') {
|
||||
throw new RepositoryException('旺铺支付未配置网关地址,请联系管理员');
|
||||
}
|
||||
|
||||
$body = array_merge([
|
||||
'organiz_no' => $this->config('organiz_no'),
|
||||
'mer_no' => $this->config('mer_no'),
|
||||
], $params);
|
||||
// 空值参数不下送(与网关签名口径一致)
|
||||
$body = array_filter($body, static fn ($value): bool => $value !== null && $value !== '');
|
||||
$body['sign'] = $this->sign($body);
|
||||
|
||||
$response = Http::asForm()->timeout(15)->post($baseUrl . $path, $body);
|
||||
$result = $response->json();
|
||||
if (! is_array($result)) {
|
||||
Log::error('旺铺网关应答异常', ['path' => $path, 'status' => $response->status(), 'body' => $response->body()]);
|
||||
throw new RepositoryException('支付网关通讯异常,请稍后重试');
|
||||
}
|
||||
if (($result['code'] ?? '') !== self::CODE_SUCCESS) {
|
||||
Log::warning('旺铺网关应答失败', ['path' => $path, 'response' => $result]);
|
||||
throw new RepositoryException('支付网关下单失败:' . ($result['msg'] ?? '未知错误'));
|
||||
}
|
||||
|
||||
$data = $result['data'] ?? [];
|
||||
return is_array($data) ? $data : [];
|
||||
}
|
||||
|
||||
/**
|
||||
* 读取网关配置:后台站点配置优先,为空回退 config/services.php(env)
|
||||
*/
|
||||
protected function config(string $key): string
|
||||
{
|
||||
$value = site_config('pay.wangpu_' . $key, '');
|
||||
if ($value === null || $value === '') {
|
||||
$value = config('services.wangpu.' . $key, '');
|
||||
}
|
||||
return trim((string) $value);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,46 @@
|
||||
<?php
|
||||
|
||||
namespace App\Services;
|
||||
|
||||
use App\Exceptions\RepositoryException;
|
||||
use Illuminate\Support\Facades\Http;
|
||||
use Illuminate\Support\Facades\Log;
|
||||
|
||||
/**
|
||||
* 微信小程序服务(wx.login 登录凭证校验 code2session)
|
||||
*
|
||||
* 在线支付前置:用小程序 wx.login() 返回的 code 换取付款人 openid,
|
||||
* 作为旺铺统一下单的 open_id(服务商模式下的微信用户标识)。
|
||||
*/
|
||||
class WechatMiniService
|
||||
{
|
||||
/**
|
||||
* wx.login 的 code 换 openid
|
||||
*
|
||||
* @return string 用户 openid
|
||||
* @throws RepositoryException 未配置小程序或凭证校验失败
|
||||
*/
|
||||
public function code2session(string $code): string
|
||||
{
|
||||
$appid = trim((string) config('services.wechat.mini.appid'));
|
||||
$secret = trim((string) config('services.wechat.mini.secret'));
|
||||
if ($appid === '' || $secret === '') {
|
||||
throw new RepositoryException('微信小程序未配置 AppID/Secret,请联系管理员');
|
||||
}
|
||||
|
||||
$result = Http::timeout(10)->get('https://api.weixin.qq.com/sns/jscode2session', [
|
||||
'appid' => $appid,
|
||||
'secret' => $secret,
|
||||
'js_code' => $code,
|
||||
'grant_type' => 'authorization_code',
|
||||
])->json();
|
||||
|
||||
$openid = is_array($result) ? (string) ($result['openid'] ?? '') : '';
|
||||
if ($openid === '') {
|
||||
Log::warning('微信 code2session 失败', ['response' => $result]);
|
||||
throw new RepositoryException('微信登录凭证校验失败:' . ($result['errmsg'] ?? '请重新进入小程序'));
|
||||
}
|
||||
|
||||
return $openid;
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user