在线支付
This commit is contained in:
@@ -46,3 +46,17 @@ MAIL_USERNAME=
|
||||
MAIL_PASSWORD=
|
||||
MAIL_FROM_ADDRESS=
|
||||
MAIL_FROM_NAME=
|
||||
|
||||
# 微信小程序(code2session 换取 openid,在线支付必需)
|
||||
WECHAT_MINI_APPID=
|
||||
WECHAT_MINI_SECRET=
|
||||
|
||||
# 旺铺支付网关(也可在后台 系统设置→支付配置 中维护,后台配置优先)
|
||||
WANGPU_BASE_URL=
|
||||
WANGPU_ORGANIZ_NO=
|
||||
WANGPU_MER_NO=
|
||||
WANGPU_MER_CODE=
|
||||
WANGPU_TERM_CODE=
|
||||
WANGPU_SIGN_KEY=
|
||||
WANGPU_SUB_APPID=
|
||||
WANGPU_PAYWAY_CODE=WECHAT_MINI
|
||||
|
||||
File diff suppressed because one or more lines are too long
@@ -0,0 +1,136 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Controllers\Mini;
|
||||
|
||||
use App\Exceptions\RepositoryException;
|
||||
use App\Models\PaymentModel;
|
||||
use App\Services\OnlinePaymentService;
|
||||
use App\Services\WangpuPayService;
|
||||
use Illuminate\Http\JsonResponse;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Support\Facades\Log;
|
||||
use Modules\AnnoRoute\Attribute\GetRoute;
|
||||
use Modules\AnnoRoute\Attribute\PostRoute;
|
||||
use Modules\AnnoRoute\Attribute\RequestAttribute;
|
||||
use Throwable;
|
||||
|
||||
/**
|
||||
* 小程序在线支付(旺铺网关 JSAPI)
|
||||
*
|
||||
* 链路:POST /mini/payment/online 下单(返回调起支付参数)
|
||||
* → 小程序 wx.requestPayment 完成支付
|
||||
* → 网关 POST /mini/payment/notify 后台通知(验签 + 幂等结账)
|
||||
* → 小程序 GET /mini/payment/online/{paymentNo}/query 主动同步支付结果(回调兜底)
|
||||
*/
|
||||
#[RequestAttribute('/mini', 'mini', authGuard: 'users')]
|
||||
class OnlinePaymentController extends BaseMiniController
|
||||
{
|
||||
public function __construct(
|
||||
protected OnlinePaymentService $onlinePayment,
|
||||
protected WangpuPayService $wangpu,
|
||||
) {}
|
||||
|
||||
/**
|
||||
* 发起在线支付:合并选择本店未支付账单 → 旺铺下单 → 返回调起支付参数
|
||||
* @throws Throwable
|
||||
*/
|
||||
#[PostRoute('/payment/online', authorize: true)]
|
||||
public function create(Request $request): JsonResponse
|
||||
{
|
||||
$data = $request->validate([
|
||||
'bill_ids' => 'required|array|min:1',
|
||||
'bill_ids.*' => 'integer|distinct',
|
||||
'code' => 'required|string|max:64',
|
||||
'remark' => 'nullable|string|max:255',
|
||||
], [
|
||||
'bill_ids.required' => '请选择要付款的账单',
|
||||
'bill_ids.min' => '请选择要付款的账单',
|
||||
'code.required' => '微信登录凭证缺失,请重新进入小程序',
|
||||
'remark.max' => '备注超过最大长度',
|
||||
]);
|
||||
|
||||
$store = $this->currentStore($request);
|
||||
|
||||
[$payment, $payParams] = $this->onlinePayment->create(
|
||||
$store,
|
||||
array_map('intval', $data['bill_ids']),
|
||||
(string) $data['code'],
|
||||
(string) ($data['remark'] ?? ''),
|
||||
);
|
||||
|
||||
return $this->success([
|
||||
'id' => $payment->id,
|
||||
'payment_no' => $payment->payment_no,
|
||||
'amount' => $payment->amount,
|
||||
'pay_params' => $payParams,
|
||||
], '下单成功,请调起支付');
|
||||
}
|
||||
|
||||
/**
|
||||
* 查询支付结果:网关回调可能延迟/丢失,小程序完成支付后主动调用同步结账
|
||||
* @throws Throwable
|
||||
*/
|
||||
#[GetRoute('/payment/online/{paymentNo}/query', authorize: true)]
|
||||
public function query(string $paymentNo, Request $request): JsonResponse
|
||||
{
|
||||
$store = $this->currentStore($request);
|
||||
|
||||
$payment = PaymentModel::query()
|
||||
->where('store_id', $store->id)
|
||||
->where('payment_no', $paymentNo)
|
||||
->where('pay_type', PaymentModel::TYPE_ONLINE)
|
||||
->first();
|
||||
if ($payment === null) {
|
||||
throw new RepositoryException('支付记录不存在');
|
||||
}
|
||||
|
||||
$result = $this->onlinePayment->queryAndSettle($payment);
|
||||
$payment = $result['payment'];
|
||||
|
||||
return $this->success([
|
||||
'payment_no' => $payment->payment_no,
|
||||
'status' => $payment->status,
|
||||
'status_name' => PaymentModel::ONLINE_STATUS_NAMES[$payment->status] ?? '待支付',
|
||||
'paid_at' => $payment->paid_at,
|
||||
'trade_no' => $payment->trade_no,
|
||||
], $payment->status === PaymentModel::STATUS_APPROVED ? '支付成功' : '支付结果确认中');
|
||||
}
|
||||
|
||||
/**
|
||||
* 旺铺支付结果后台通知(公开路由,验签后幂等结账)
|
||||
*
|
||||
* 网关约定:应答 {"code":"00"} 视为通知成功,否则按 2^n 分钟重试 7 次;
|
||||
* 重复通知必须幂等(settle 内部行锁 + 状态判断)。
|
||||
*/
|
||||
#[PostRoute('/payment/notify', authorize: false)]
|
||||
public function notify(Request $request): JsonResponse
|
||||
{
|
||||
$params = $request->all();
|
||||
|
||||
if (! $this->wangpu->verifyNotifySign($params)) {
|
||||
Log::warning('旺铺支付通知验签失败', ['params' => $params]);
|
||||
return $this->notifyAck('01', '验签失败');
|
||||
}
|
||||
|
||||
try {
|
||||
$this->onlinePayment->settleByNotify($params);
|
||||
} catch (Throwable $e) {
|
||||
Log::error('旺铺支付通知处理失败', ['params' => $params, 'error' => $e->getMessage()]);
|
||||
return $this->notifyAck('01', $e->getMessage());
|
||||
}
|
||||
|
||||
return $this->notifyAck(WangpuPayService::NOTIFY_ACK_OK, '成功');
|
||||
}
|
||||
|
||||
/**
|
||||
* 通知应答报文(网关约定格式,timestamp:yyyyMMddHHmmssSSS)
|
||||
*/
|
||||
protected function notifyAck(string $code, string $msg): JsonResponse
|
||||
{
|
||||
return response()->json([
|
||||
'code' => $code,
|
||||
'msg' => mb_substr($msg, 0, 100),
|
||||
'timestamp' => now()->format('YmdHisv'),
|
||||
]);
|
||||
}
|
||||
}
|
||||
@@ -24,6 +24,7 @@ class PaymentController extends BaseController
|
||||
protected array $searchField = [
|
||||
'store_id' => '=',
|
||||
'status' => '=',
|
||||
'pay_type' => '=',
|
||||
'pay_method' => '=',
|
||||
'payment_no' => 'like',
|
||||
];
|
||||
|
||||
@@ -11,31 +11,49 @@ use Modules\SystemTool\Models\SysFileModel;
|
||||
use Modules\SystemUser\Models\SysUserModel;
|
||||
|
||||
/**
|
||||
* 支付记录模型(小程序选择门店账单合并付款,提交汇款凭证;后台审核通过后关联账单批量置已支付)
|
||||
* 支付记录模型(小程序选择门店账单合并付款)
|
||||
*
|
||||
* 支付类型 pay_type:
|
||||
* - 1 线下凭证支付:门店提交汇款凭证,后台审核通过后关联账单批量置已支付
|
||||
* - 2 旺铺在线支付:调起微信/支付宝在线支付,网关回调(或主动查询)确认后自动结账
|
||||
*/
|
||||
class PaymentModel extends Model
|
||||
{
|
||||
use HasFactory;
|
||||
|
||||
/** 支付类型:线下凭证支付 */
|
||||
public const int TYPE_OFFLINE = 1;
|
||||
/** 支付类型:旺铺在线支付 */
|
||||
public const int TYPE_ONLINE = 2;
|
||||
|
||||
/** 支付类型中文名 */
|
||||
public const array TYPE_NAMES = [
|
||||
self::TYPE_OFFLINE => '凭证支付',
|
||||
self::TYPE_ONLINE => '在线支付',
|
||||
];
|
||||
|
||||
/** 支付方式:微信 */
|
||||
public const int METHOD_WECHAT = 1;
|
||||
/** 支付方式:支付宝 */
|
||||
public const int METHOD_ALIPAY = 2;
|
||||
/** 支付方式:对公汇款(银行卡) */
|
||||
public const int METHOD_BANK = 3;
|
||||
/** 支付方式:旺铺在线支付(微信/支付宝小程序 JSAPI) */
|
||||
public const int METHOD_WANGPU = 4;
|
||||
|
||||
/** 支付方式中文名 */
|
||||
public const array METHOD_NAMES = [
|
||||
self::METHOD_WECHAT => '微信支付',
|
||||
self::METHOD_ALIPAY => '支付宝',
|
||||
self::METHOD_BANK => '对公汇款',
|
||||
self::METHOD_WANGPU => '旺铺支付',
|
||||
];
|
||||
|
||||
/** 状态:待审核 */
|
||||
/** 状态:待审核(线下凭证)/ 待支付(在线支付) */
|
||||
public const int STATUS_PENDING = 0;
|
||||
/** 状态:已通过 */
|
||||
/** 状态:已通过(线下凭证)/ 支付成功(在线支付) */
|
||||
public const int STATUS_APPROVED = 1;
|
||||
/** 状态:已拒绝 */
|
||||
/** 状态:已拒绝(线下凭证)/ 支付失败(在线支付) */
|
||||
public const int STATUS_REJECTED = 2;
|
||||
|
||||
/** 状态中文名 */
|
||||
@@ -45,6 +63,13 @@ class PaymentModel extends Model
|
||||
self::STATUS_REJECTED => '已拒绝',
|
||||
];
|
||||
|
||||
/** 在线支付状态中文名(pay_type=2 时使用) */
|
||||
public const array ONLINE_STATUS_NAMES = [
|
||||
self::STATUS_PENDING => '待支付',
|
||||
self::STATUS_APPROVED => '支付成功',
|
||||
self::STATUS_REJECTED => '支付失败',
|
||||
];
|
||||
|
||||
protected $table = 'payment';
|
||||
protected $primaryKey = 'id';
|
||||
|
||||
@@ -52,9 +77,15 @@ class PaymentModel extends Model
|
||||
'payment_no',
|
||||
'store_id',
|
||||
'amount',
|
||||
'pay_type',
|
||||
'pay_method',
|
||||
'voucher_ids',
|
||||
'status',
|
||||
'order_id',
|
||||
'trade_no',
|
||||
'openid',
|
||||
'paid_at',
|
||||
'pay_params',
|
||||
'remark',
|
||||
'audited_at',
|
||||
'auditor_id',
|
||||
@@ -64,13 +95,28 @@ class PaymentModel extends Model
|
||||
protected $casts = [
|
||||
'store_id' => 'integer',
|
||||
'amount' => 'decimal:2',
|
||||
'pay_type' => 'integer',
|
||||
'pay_method' => 'integer',
|
||||
'status' => 'integer',
|
||||
'paid_at' => 'datetime:Y-m-d H:i:s',
|
||||
'audited_at' => 'datetime:Y-m-d H:i:s',
|
||||
'auditor_id' => 'integer',
|
||||
'created_at' => 'datetime:Y-m-d H:i:s',
|
||||
];
|
||||
|
||||
/**
|
||||
* 旺铺下单返回的调起支付参数(JSON 字符串 ↔ 数组)
|
||||
*
|
||||
* @return Attribute<array<string, mixed>, string>
|
||||
*/
|
||||
public function payParams(): Attribute
|
||||
{
|
||||
return Attribute::make(
|
||||
get: fn ($value) => $value === '' || $value === null ? [] : (json_decode((string) $value, true) ?: []),
|
||||
set: fn ($value) => is_array($value) ? json_encode($value, JSON_UNESCAPED_UNICODE) : $value,
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* 汇款凭证图片ID(逗号分隔字符串 ↔ 数组)
|
||||
*/
|
||||
|
||||
@@ -37,6 +37,7 @@ class StoreModel extends Authenticatable
|
||||
'phone',
|
||||
'address',
|
||||
'payment_cycle_days',
|
||||
'openid',
|
||||
'status',
|
||||
'remark',
|
||||
];
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -52,4 +52,19 @@ return [
|
||||
'secret' => env('WECHAT_MINI_SECRET', ''),
|
||||
],
|
||||
],
|
||||
|
||||
/*
|
||||
* 旺铺支付网关(统一下单B-JSAPI / 交易查询 / 后台通知)
|
||||
* 优先读取后台「系统设置 → 支付配置」(site_config pay.wangpu_*),为空时回退到这里的 env 配置
|
||||
*/
|
||||
'wangpu' => [
|
||||
'base_url' => env('WANGPU_BASE_URL', ''), // 网关域名,如 https://pay.example.com
|
||||
'organiz_no' => env('WANGPU_ORGANIZ_NO', ''), // 合作机构渠道号
|
||||
'mer_no' => env('WANGPU_MER_NO', ''), // 旺铺内部商户号
|
||||
'mer_code' => env('WANGPU_MER_CODE', ''), // 商户号(进件入网后返回)
|
||||
'term_code' => env('WANGPU_TERM_CODE', ''), // 终端号(进件入网后返回)
|
||||
'sign_key' => env('WANGPU_SIGN_KEY', ''), // 加签专用 Key(MD5 加签)
|
||||
'sub_appid' => env('WANGPU_SUB_APPID', ''), // 下单微信子 appid(默认取小程序 appid)
|
||||
'payway_code' => env('WANGPU_PAYWAY_CODE', 'WECHAT_MINI'), // 支付方式代码(主扫必填)
|
||||
],
|
||||
];
|
||||
|
||||
@@ -42,6 +42,7 @@ return new class extends Migration
|
||||
$table->string('phone', 20)->default('')->comment('联系电话');
|
||||
$table->string('address', 255)->default('')->comment('门店地址');
|
||||
$table->integer('payment_cycle_days')->default(0)->comment('回款周期(天)');
|
||||
$table->string('openid', 64)->default('')->comment('微信小程序 openid(在线支付付款人标识,wx.login 换取后绑定)');
|
||||
$table->decimal('total_purchase_amount', 12, 2)->default(0)->comment('总采购金额(只统计商品金额,账单支付后累加)');
|
||||
$table->integer('status')->default(1)->comment('状态(1正常 0停用)');
|
||||
$table->string('remark', 255)->nullable()->default('')->comment('备注');
|
||||
|
||||
@@ -15,19 +15,26 @@ return new class extends Migration
|
||||
if (! Schema::hasTable('payment')) {
|
||||
Schema::create('payment', function (Blueprint $table) {
|
||||
$table->increments('id')->comment('支付记录ID');
|
||||
$table->string('payment_no', 32)->unique()->comment('支付单号');
|
||||
$table->string('payment_no', 32)->unique()->comment('支付单号(在线支付时作为商户订单号 mer_order_id 上送网关)');
|
||||
$table->integer('store_id')->comment('门店ID(提交门店即支付人)');
|
||||
$table->decimal('amount', 10, 2)->default(0)->comment('支付金额(= 关联账单总金额合计,提交时快照)');
|
||||
$table->integer('pay_method')->comment('支付方式(1微信 2支付宝 3对公汇款)');
|
||||
$table->string('voucher_ids', 255)->default('')->comment('汇款凭证图片ID(逗号分隔)');
|
||||
$table->integer('status')->default(0)->comment('状态(0待审核 1已通过 2已拒绝)');
|
||||
$table->integer('pay_type')->default(1)->comment('支付类型(1线下凭证支付 2旺铺在线支付)');
|
||||
$table->integer('pay_method')->comment('支付方式(1微信 2支付宝 3对公汇款 4旺铺在线支付)');
|
||||
$table->string('voucher_ids', 255)->default('')->comment('汇款凭证图片ID(逗号分隔,线下凭证支付)');
|
||||
$table->integer('status')->default(0)->comment('状态(0待审核/待支付 1已通过/支付成功 2已拒绝/支付失败)');
|
||||
$table->string('order_id', 64)->default('')->comment('旺铺平台订单号(网关返回)');
|
||||
$table->string('trade_no', 64)->default('')->comment('通道交易流水号(支付成功返回)');
|
||||
$table->string('openid', 64)->default('')->comment('付款人微信 openid(小程序在线支付)');
|
||||
$table->timestamp('paid_at')->nullable()->comment('支付成功时间(网关交易时间)');
|
||||
$table->text('pay_params')->nullable()->comment('旺铺下单返回的调起支付参数(JSON 快照)');
|
||||
$table->string('remark', 255)->default('')->comment('门店备注');
|
||||
$table->timestamp('audited_at')->nullable()->comment('审核时间');
|
||||
$table->integer('auditor_id')->default(0)->comment('审核人(后台系统用户ID)');
|
||||
$table->string('audit_remark', 255)->default('')->comment('审核备注(拒绝原因)');
|
||||
$table->timestamps();
|
||||
$table->index(['store_id', 'status'], 'payment_store_status_index');
|
||||
$table->comment('支付记录表(小程序合并付款,后台审核汇款凭证)');
|
||||
$table->index(['pay_type', 'status'], 'payment_type_status_index');
|
||||
$table->comment('支付记录表(小程序合并付款:线下凭证后台审核 / 旺铺在线支付回调结账)');
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@@ -29,6 +29,14 @@ class SysDataSeeder extends Seeder
|
||||
['id' => 9, 'group_id' => 4, 'key' => 'wechat_qrcode', 'title' => '微信收款码', 'describe' => '微信收款码图片地址(在文件管理中上传收款码后复制图片链接,或直接填写文件ID),小程序付款页展示', 'values' => '', 'type' => 'Input','options' => "", 'sort' => 0, 'created_at' => $date, 'updated_at' => $date],
|
||||
['id' => 10, 'group_id' => 4, 'key' => 'alipay_qrcode', 'title' => '支付宝收款码', 'describe' => '支付宝收款码图片地址(在文件管理中上传收款码后复制图片链接,或直接填写文件ID),小程序付款页展示', 'values' => '', 'type' => 'Input','options' => "", 'sort' => 1, 'created_at' => $date, 'updated_at' => $date],
|
||||
['id' => 11, 'group_id' => 4, 'key' => 'bank_info', 'title' => '对公汇款信息', 'describe' => '对公账户汇款信息(户名、账号、开户行等),小程序付款页展示', 'values' => '', 'type' => 'TextArea','options' => "", 'sort' => 2, 'created_at' => $date, 'updated_at' => $date],
|
||||
['id' => 12, 'group_id' => 4, 'key' => 'wangpu_base_url', 'title' => '旺铺网关地址', 'describe' => '旺铺支付网关域名(如 https://pay.example.com),在线支付下单/查询接口前缀', 'values' => '', 'type' => 'Input','options' => "", 'sort' => 3, 'created_at' => $date, 'updated_at' => $date],
|
||||
['id' => 13, 'group_id' => 4, 'key' => 'wangpu_organiz_no', 'title' => '旺铺机构渠道号', 'describe' => '合作机构渠道号 organiz_no(旺铺分配)', 'values' => '', 'type' => 'Input','options' => "", 'sort' => 4, 'created_at' => $date, 'updated_at' => $date],
|
||||
['id' => 14, 'group_id' => 4, 'key' => 'wangpu_mer_no', 'title' => '旺铺内部商户号', 'describe' => '旺铺内部商户号 mer_no(进件入网后返回)', 'values' => '', 'type' => 'Input','options' => "", 'sort' => 5, 'created_at' => $date, 'updated_at' => $date],
|
||||
['id' => 15, 'group_id' => 4, 'key' => 'wangpu_mer_code', 'title' => '旺铺商户号', 'describe' => '商户号 mer_code(进件入网后返回)', 'values' => '', 'type' => 'Input','options' => "", 'sort' => 6, 'created_at' => $date, 'updated_at' => $date],
|
||||
['id' => 16, 'group_id' => 4, 'key' => 'wangpu_term_code', 'title' => '旺铺终端号', 'describe' => '终端号 term_code(进件入网后返回)', 'values' => '', 'type' => 'Input','options' => "", 'sort' => 7, 'created_at' => $date, 'updated_at' => $date],
|
||||
['id' => 17, 'group_id' => 4, 'key' => 'wangpu_sign_key', 'title' => '旺铺加签Key', 'describe' => '旺铺报文加签专用 Key(SignKey,MD5 加签),请勿泄露', 'values' => '', 'type' => 'Input','options' => "", 'sort' => 8, 'created_at' => $date, 'updated_at' => $date],
|
||||
['id' => 18, 'group_id' => 4, 'key' => 'wangpu_sub_appid', 'title' => '旺铺下单子appid', 'describe' => '下单微信子 appid(sub_appid),留空则取小程序自身 appid', 'values' => '', 'type' => 'Input','options' => "", 'sort' => 9, 'created_at' => $date, 'updated_at' => $date],
|
||||
['id' => 19, 'group_id' => 4, 'key' => 'wangpu_payway_code', 'title' => '旺铺支付方式代码', 'describe' => '支付方式代码 payway_code(小程序主扫必填,如 WECHAT_MINI),见旺铺数据词典', 'values' => 'WECHAT_MINI', 'type' => 'Input','options' => "", 'sort' => 10, 'created_at' => $date, 'updated_at' => $date],
|
||||
]);
|
||||
// 字典类型初始数据
|
||||
DB::table('sys_dict')->insert([
|
||||
|
||||
@@ -0,0 +1,235 @@
|
||||
# 小程序接口文档:在线支付(旺铺网关 JSAPI)
|
||||
|
||||
> 门店端在线支付:选择本店未支付账单合并付款,后端经旺铺支付网关(统一下单B-JSAPI)下单,
|
||||
> 小程序调起 `wx.requestPayment` 完成支付;支付结果由**网关后台通知**自动结账,
|
||||
> 小程序端也可**主动查询**同步结果(回调延迟/丢失时的兜底)。
|
||||
>
|
||||
> 结账效果与线下凭证审核一致:关联账单批量置「已支付」、累加门店总采购金额、门店收到支付成功通知。
|
||||
|
||||
## 整体流程
|
||||
|
||||
```
|
||||
小程序 后端 旺铺网关 微信
|
||||
│ wx.login → code │ │ │
|
||||
│ POST /mini/payment/online (bill_ids, code) │ │
|
||||
│────────────────────▶│ code2session 换 openid │──────────────────▶│
|
||||
│ │ 创建支付单+锁定账单 │ │
|
||||
│ │ 统一下单(mer_order_id=支付单号) ──────────▶│
|
||||
│ 返回 pay_params │◀──────── order_id + 调起参数 ────────────│
|
||||
│◀────────────────────│ │ │
|
||||
│ wx.requestPayment(pay_params) ─────────────────────────────────▶│
|
||||
│ │ POST /mini/payment/notify(支付成功通知) │
|
||||
│ │◀───────────────────────│ │
|
||||
│ │ 验签 → 幂等结账 → 应答 {"code":"00"} │
|
||||
│ GET .../query 主动同步(兜底) │ │
|
||||
│────────────────────▶│ 交易查询 → 已支付则结账 │ │
|
||||
```
|
||||
|
||||
## 通用约定
|
||||
|
||||
| 项 | 值 |
|
||||
|---|---|
|
||||
| 鉴权 | 门店 token:`Authorization: Bearer <token>`(登录见 `/mini/auth/login`) |
|
||||
| 响应格式 | `{ "success": true|false, "data": {...}, "msg": "..." }` |
|
||||
| 金额单位 | 元,字符串/数字两位小数(如 `150.50`) |
|
||||
|
||||
---
|
||||
|
||||
## 1. 发起在线支付(合并账单下单)
|
||||
|
||||
| 项 | 值 |
|
||||
|---|---|
|
||||
| 请求方式 | `POST` |
|
||||
| 路径 | `/mini/payment/online` |
|
||||
| 鉴权 | 门店 token |
|
||||
|
||||
### 请求参数
|
||||
|
||||
| 参数 | 类型 | 必填 | 说明 |
|
||||
|---|---|---|---|
|
||||
| `bill_ids` | int[] | 是 | 要合并付款的账单 ID 数组(`GET /mini/bill?payable=1` 返回的 `id`),至少 1 个 |
|
||||
| `code` | string | 是 | 小程序 `wx.login()` 返回的登录凭证(后端用它换付款人 openid) |
|
||||
| `remark` | string | 否 | 付款备注,最长 255 字 |
|
||||
|
||||
### 校验规则(失败均返回 `success:false`)
|
||||
|
||||
- 账单必须全部属于当前门店,且为「未支付」且未被其他支付单锁定;
|
||||
- 任一账单已支付 / 正在支付中(含凭证审核中)→ 拒绝并提示对应账单号;
|
||||
- 合计金额必须大于 0;
|
||||
- 微信 `code` 无效(code2session 失败)→ 报错,不产生支付单;
|
||||
- 网关下单失败 → 支付单自动作废、账单释放,可重新发起。
|
||||
|
||||
### 响应示例
|
||||
|
||||
```json
|
||||
{
|
||||
"success": true,
|
||||
"msg": "下单成功,请调起支付",
|
||||
"data": {
|
||||
"id": 12,
|
||||
"payment_no": "ZF202608270001",
|
||||
"amount": "150.50",
|
||||
"pay_params": {
|
||||
"order_id": "202608271201444525348059",
|
||||
"tradeNo": "2021082722001407831438768160",
|
||||
"user_openid": "oXxx123"
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### 字段说明
|
||||
|
||||
| 字段 | 说明 |
|
||||
|---|---|
|
||||
| `payment_no` | 本系统支付单号(即上送网关的商户订单号 `mer_order_id`),后续查询/对账用 |
|
||||
| `amount` | 应付金额(= 所选账单总额合计,元) |
|
||||
| `pay_params` | 旺铺网关 `data` 节点原样透传。**调起支付所需参数以网关返回为准**(服务商配置后通常包含 `timeStamp`/`nonceStr`/`package`/`signType`/`paySign` 等),直接透传给 `wx.requestPayment` 即可 |
|
||||
|
||||
### 小程序端调用示例
|
||||
|
||||
```js
|
||||
// 1. 获取登录凭证
|
||||
const { code } = await wx.login();
|
||||
|
||||
// 2. 后端下单
|
||||
const res = await request.post('/mini/payment/online', { bill_ids: [101, 102], code });
|
||||
const { payment_no, pay_params } = res.data;
|
||||
|
||||
// 3. 调起微信支付(pay_params 以旺铺网关实际返回的调起参数为准)
|
||||
await wx.requestPayment({
|
||||
timeStamp: pay_params.timeStamp,
|
||||
nonceStr: pay_params.nonceStr,
|
||||
package: pay_params.package,
|
||||
signType: pay_params.signType || 'RSA',
|
||||
paySign: pay_params.paySign,
|
||||
});
|
||||
|
||||
// 4. 支付完成后主动同步结果(回调兜底)
|
||||
const q = await request.get(`/mini/payment/online/${payment_no}/query`);
|
||||
// q.data.status === 1 → 支付成功,刷新账单列表
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 2. 查询支付结果(主动同步)
|
||||
|
||||
| 项 | 值 |
|
||||
|---|---|
|
||||
| 请求方式 | `GET` |
|
||||
| 路径 | `/mini/payment/online/{payment_no}/query` |
|
||||
| 鉴权 | 门店 token(仅可查询本店支付单) |
|
||||
|
||||
### 路径参数
|
||||
|
||||
| 参数 | 说明 |
|
||||
|---|---|
|
||||
| `payment_no` | 下单返回的支付单号(如 `ZF202608270001`) |
|
||||
|
||||
### 行为说明
|
||||
|
||||
- 本地已是「支付成功」→ 直接返回,不再请求网关;
|
||||
- 否则向旺铺网关发起「交易查询」,网关返回已支付(`order_status=1`)则**立即结账**(与后台通知同一幂等逻辑);
|
||||
- 建议在 `wx.requestPayment` 成功回调后、以及付款页 `onShow` 时各调一次;
|
||||
- 用户中途取消支付时本接口返回 `status: 0`,账单仍处于锁定中,可稍后重试或联系客服释放。
|
||||
|
||||
### 响应示例
|
||||
|
||||
```json
|
||||
{
|
||||
"success": true,
|
||||
"msg": "支付成功",
|
||||
"data": {
|
||||
"payment_no": "ZF202608270001",
|
||||
"status": 1,
|
||||
"status_name": "支付成功",
|
||||
"paid_at": "2026-08-27 10:00:00",
|
||||
"trade_no": "2021082722001407831438768160"
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### `status` 取值
|
||||
|
||||
| 值 | 含义 |
|
||||
|---|---|
|
||||
| `0` | 待支付(网关未确认) |
|
||||
| `1` | 支付成功(账单已置已支付) |
|
||||
| `2` | 支付失败(网关下单失败作废,账单已释放,可重新下单) |
|
||||
|
||||
---
|
||||
|
||||
## 3. 支付结果后台通知(服务端对接,无需小程序调用)
|
||||
|
||||
| 项 | 值 |
|
||||
|---|---|
|
||||
| 请求方式 | `POST`(表单) |
|
||||
| 路径 | `/mini/payment/notify` |
|
||||
| 鉴权 | 无(公开路由,以报文 `sign` 验签) |
|
||||
| 来源 | 旺铺网关(下单时上送的 `notifyurl`,由后端自动生成:`{APP_URL}/mini/payment/notify`) |
|
||||
|
||||
### 处理逻辑
|
||||
|
||||
1. 按旺铺加签规则验签(参数 ASCII 升序 `key=value` 以 `&` 拼接 + `&key=SignKey`,MD5 大写);
|
||||
2. 验签失败 → 应答 `{"code":"01"}`;
|
||||
3. 按 `mer_order_id` 定位支付单,校验 `order_status=1` 且 `order_amt` 与支付单金额一致(防篡改);
|
||||
4. **幂等结账**:支付单置成功(记录 `trade_no`/`order_id`/支付时间)→ 关联账单批量置已支付 → 累加门店总采购金额(只统计商品金额)→ 站内通知门店;
|
||||
5. 重复通知直接应答成功,不重复结账。
|
||||
|
||||
### 应答报文(网关约定格式)
|
||||
|
||||
```json
|
||||
{ "code": "00", "msg": "成功", "timestamp": "20260827100000123" }
|
||||
```
|
||||
|
||||
网关收到 `code=00` 才认为通知成功,否则按 2ⁿ 分钟重试 7 次。
|
||||
|
||||
---
|
||||
|
||||
## 后台配置(上线前必配)
|
||||
|
||||
配置优先级:后台「系统设置 → 支付配置」> `.env`。
|
||||
|
||||
### 后台支付配置(site_config `pay` 组,已内置配置项)
|
||||
|
||||
| 配置项 | 说明 |
|
||||
|---|---|
|
||||
| `wangpu_base_url` | 旺铺网关域名(接口地址前缀) |
|
||||
| `wangpu_organiz_no` | 合作机构渠道号 organiz_no |
|
||||
| `wangpu_mer_no` | 旺铺内部商户号 mer_no(进件入网后返回) |
|
||||
| `wangpu_mer_code` | 商户号 mer_code |
|
||||
| `wangpu_term_code` | 终端号 term_code |
|
||||
| `wangpu_sign_key` | 加签专用 Key(SignKey) |
|
||||
| `wangpu_sub_appid` | 下单微信子 appid,留空取小程序自身 appid |
|
||||
| `wangpu_payway_code` | 支付方式代码(默认 `WECHAT_MINI`,以旺铺数据词典为准) |
|
||||
|
||||
### .env 备选配置
|
||||
|
||||
```dotenv
|
||||
WECHAT_MINI_APPID= # 小程序 AppID(code2session 必需)
|
||||
WECHAT_MINI_SECRET= # 小程序 Secret(code2session 必需)
|
||||
WANGPU_BASE_URL=
|
||||
WANGPU_ORGANIZ_NO=
|
||||
WANGPU_MER_NO=
|
||||
WANGPU_MER_CODE=
|
||||
WANGPU_TERM_CODE=
|
||||
WANGPU_SIGN_KEY=
|
||||
WANGPU_SUB_APPID=
|
||||
WANGPU_PAYWAY_CODE=WECHAT_MINI
|
||||
```
|
||||
|
||||
> 注意:`APP_URL` 必须是网关可访问的公网 HTTPS 地址,否则支付成功通知无法送达(此时依赖小程序主动查询兜底结账)。
|
||||
|
||||
## 与线下凭证支付的关系
|
||||
|
||||
两种付款方式并存,同一张账单同一时刻只能处于一条支付链路:
|
||||
|
||||
| | 在线支付(本档) | 线下凭证支付 |
|
||||
|---|---|---|
|
||||
| 入口 | `POST /mini/payment/online` | `POST /mini/payment` |
|
||||
| 支付单 `pay_type` | `2` | `1` |
|
||||
| 支付单 `pay_method` | `4` 旺铺支付 | `1` 微信 / `2` 支付宝 / `3` 对公汇款 |
|
||||
| 结账方式 | 网关通知/主动查询自动结账 | 后台审核通过 |
|
||||
| 失败/拒绝 | 账单自动释放可重新付款 | 审核拒绝后释放 |
|
||||
|
||||
账单被任一支付单锁定期间(`payment_id ≠ 0`),两种入口均会拒绝重复提交。
|
||||
@@ -0,0 +1,392 @@
|
||||
<?php
|
||||
|
||||
namespace Tests\Feature;
|
||||
|
||||
use App\Models\BillModel;
|
||||
use App\Models\NoticeModel;
|
||||
use App\Models\PaymentModel;
|
||||
use App\Models\PurchaseOrderModel;
|
||||
use App\Models\StoreModel;
|
||||
use App\Services\WangpuPayService;
|
||||
use Illuminate\Support\Facades\Http;
|
||||
|
||||
/**
|
||||
* 小程序在线支付(旺铺网关 JSAPI):下单 → 调起支付 → 后台通知/主动查询结账
|
||||
*
|
||||
* - Http::fake 模拟微信 code2session 与旺铺网关,不触网
|
||||
* - 结账幂等:重复通知/查询不重复累加门店总采购金额
|
||||
*/
|
||||
class MiniOnlinePaymentTest extends ProcurementTestCase
|
||||
{
|
||||
private const string SIGN_KEY = 'test-wangpu-sign-key';
|
||||
|
||||
protected function setUp(): void
|
||||
{
|
||||
parent::setUp();
|
||||
|
||||
config([
|
||||
'services.wechat.mini.appid' => 'wx-mini-test',
|
||||
'services.wechat.mini.secret' => 'wx-secret-test',
|
||||
'services.wangpu.base_url' => 'https://wangpu.test',
|
||||
'services.wangpu.organiz_no' => 'org001',
|
||||
'services.wangpu.mer_no' => 'mer001',
|
||||
'services.wangpu.mer_code' => 'code001',
|
||||
'services.wangpu.term_code' => 'term001',
|
||||
'services.wangpu.sign_key' => self::SIGN_KEY,
|
||||
'services.wangpu.payway_code' => 'WECHAT_MINI',
|
||||
]);
|
||||
}
|
||||
|
||||
/** 模拟微信 code2session + 旺铺统一下单均成功 */
|
||||
private function fakeGatewaySuccess(string $openid = 'oOpenidTest001'): void
|
||||
{
|
||||
Http::fake([
|
||||
'https://api.weixin.qq.com/*' => Http::response(['openid' => $openid, 'session_key' => 'sk'], 200),
|
||||
'https://wangpu.test/industrial/payment/order' => Http::response([
|
||||
'code' => '0000',
|
||||
'msg' => '调用成功',
|
||||
'data' => ['order_id' => 'WP202608270001', 'tradeNo' => 'T20260827001', 'user_openid' => $openid],
|
||||
], 200),
|
||||
]);
|
||||
}
|
||||
|
||||
/** 造一张指定金额的未支付账单(总额=商品金额) */
|
||||
private function makeBill(StoreModel $store, string $amount, array $attributes = []): BillModel
|
||||
{
|
||||
return BillModel::create(array_merge([
|
||||
'bill_no' => 'ZD' . random_int(100000000000, 999999999999),
|
||||
'purchase_id' => PurchaseOrderModel::factory()->create()->id,
|
||||
'store_id' => $store->id,
|
||||
'bill_date' => '2026-08-27',
|
||||
'product_amount' => $amount,
|
||||
'delivery_fee' => '0.00',
|
||||
'box_num' => 0,
|
||||
'tray_num' => 0,
|
||||
'box_price' => '0.00',
|
||||
'tray_price' => '0.00',
|
||||
'added_amount' => '0.00',
|
||||
'total_amount' => $amount,
|
||||
'status' => BillModel::STATUS_UNPAID,
|
||||
], $attributes));
|
||||
}
|
||||
|
||||
/** 造一笔待支付的在线支付单并锁定账单 */
|
||||
private function makeOnlinePayment(StoreModel $store, string $amount, BillModel ...$bills): PaymentModel
|
||||
{
|
||||
$payment = PaymentModel::create([
|
||||
'payment_no' => 'ZF' . now()->format('Ymd') . random_int(1000, 9999),
|
||||
'store_id' => $store->id,
|
||||
'amount' => $amount,
|
||||
'pay_type' => PaymentModel::TYPE_ONLINE,
|
||||
'pay_method' => PaymentModel::METHOD_WANGPU,
|
||||
'voucher_ids' => '',
|
||||
'status' => PaymentModel::STATUS_PENDING,
|
||||
'openid' => 'oOpenidTest001',
|
||||
]);
|
||||
foreach ($bills as $bill) {
|
||||
$bill->update(['payment_id' => $payment->id]);
|
||||
}
|
||||
return $payment;
|
||||
}
|
||||
|
||||
/** 构造已加签的支付成功通知报文 */
|
||||
private function signedNotifyParams(PaymentModel $payment, array $overrides = []): array
|
||||
{
|
||||
$params = array_merge([
|
||||
'mer_order_id' => $payment->payment_no,
|
||||
'order_status' => '1',
|
||||
'order_amt' => (string) $payment->amount,
|
||||
'trade_no' => 'T20260827001',
|
||||
'order_id' => 'WP202608270001',
|
||||
'order_time' => '2026-08-27 09:59:00',
|
||||
'trade_time' => '2026-08-27 10:00:00',
|
||||
'payway_code' => 'WECHAT_MINI',
|
||||
'mer_no' => 'mer001',
|
||||
'device_no' => 'dev001',
|
||||
'order_title' => '账单合并付款',
|
||||
], $overrides);
|
||||
$params['sign'] = app(WangpuPayService::class)->sign($params);
|
||||
return $params;
|
||||
}
|
||||
|
||||
/** 旺铺加签算法:按接口文档示例 golden test(MD5 升序拼接 + key) */
|
||||
public function test_sign_matches_document_example(): void
|
||||
{
|
||||
config(['services.wangpu.sign_key' => '07714583f82b4db8b675b32cd5e0969743']);
|
||||
|
||||
$sign = app(WangpuPayService::class)->sign([
|
||||
'mer_order_id' => 'CBC92E5GTL000083202004121010143',
|
||||
'trade_no' => '11420200410120144102483',
|
||||
'mer_no' => '2001071119360E5Riu',
|
||||
'order_amt' => '0.01',
|
||||
'payway_code' => 'QR_WECHAT_BARPAY',
|
||||
'order_id' => '202004101201444525348059',
|
||||
'order_status' => '1',
|
||||
'order_title' => '住宿酒店',
|
||||
'mer_code' => 'W00000000001381',
|
||||
'device_no' => 'CBC92E5GTL000083',
|
||||
'order_time' => '2020-04-10 12:01:44',
|
||||
'trade_time' => '2020-04-10 12:01:47',
|
||||
'gateway_mer_order_id' => '2020041012014445269',
|
||||
]);
|
||||
|
||||
$this->assertSame('A31998F2E0549E0A80B2A4B3A0473784', $sign);
|
||||
}
|
||||
|
||||
/** 发起在线支付:锁定账单、创建支付单、上送网关参数正确、openid 绑定到门店 */
|
||||
public function test_create_online_payment_success(): void
|
||||
{
|
||||
$this->fakeGatewaySuccess();
|
||||
$store = StoreModel::factory()->create();
|
||||
$bill1 = $this->makeBill($store, '100.00');
|
||||
$bill2 = $this->makeBill($store, '50.50');
|
||||
|
||||
$this->actingAsMiniStore($store);
|
||||
$response = $this->postJson('/mini/payment/online', [
|
||||
'bill_ids' => [$bill1->id, $bill2->id],
|
||||
'code' => 'wx-login-code',
|
||||
])->assertJsonPath('success', true);
|
||||
|
||||
$paymentNo = $response->json('data.payment_no');
|
||||
$this->assertSame('150.50', $response->json('data.amount'));
|
||||
$this->assertSame('WP202608270001', $response->json('data.pay_params.order_id'));
|
||||
|
||||
$payment = PaymentModel::where('payment_no', $paymentNo)->first();
|
||||
$this->assertSame(PaymentModel::TYPE_ONLINE, $payment->pay_type);
|
||||
$this->assertSame(PaymentModel::METHOD_WANGPU, $payment->pay_method);
|
||||
$this->assertSame(PaymentModel::STATUS_PENDING, $payment->status);
|
||||
$this->assertSame('oOpenidTest001', $payment->openid);
|
||||
$this->assertSame('WP202608270001', $payment->order_id);
|
||||
|
||||
// 账单锁定 + openid 绑定门店
|
||||
$this->assertSame($payment->id, $bill1->fresh()->payment_id);
|
||||
$this->assertSame($payment->id, $bill2->fresh()->payment_id);
|
||||
$this->assertSame('oOpenidTest001', $store->fresh()->openid);
|
||||
|
||||
// 上送网关的报文:商户订单号=支付单号、金额、openid、带签名
|
||||
Http::assertSent(static function ($request) use ($paymentNo) {
|
||||
$body = $request->data();
|
||||
return str_contains($request->url(), '/industrial/payment/order')
|
||||
&& ($body['mer_order_id'] ?? '') === $paymentNo
|
||||
&& ($body['order_amt'] ?? '') === '150.50'
|
||||
&& ($body['open_id'] ?? '') === 'oOpenidTest001'
|
||||
&& ($body['sub_appid'] ?? '') === 'wx-mini-test'
|
||||
&& ! empty($body['sign'])
|
||||
&& ! empty($body['notifyurl']);
|
||||
});
|
||||
}
|
||||
|
||||
/** 发起支付校验:缺 code / 非本店账单 / 已支付账单 / 锁定中账单 均拒绝 */
|
||||
public function test_create_online_payment_validation(): void
|
||||
{
|
||||
$this->fakeGatewaySuccess();
|
||||
$store = StoreModel::factory()->create();
|
||||
$other = StoreModel::factory()->create();
|
||||
|
||||
$this->actingAsMiniStore($store);
|
||||
// 缺 code
|
||||
$this->postJson('/mini/payment/online', ['bill_ids' => [1]])->assertJsonPath('success', false);
|
||||
|
||||
// 非本店账单
|
||||
$otherBill = $this->makeBill($other, '10.00');
|
||||
$this->postJson('/mini/payment/online', ['bill_ids' => [$otherBill->id], 'code' => 'c'])
|
||||
->assertJsonPath('success', false);
|
||||
|
||||
// 已支付账单
|
||||
$paidBill = $this->makeBill($store, '10.00', ['status' => BillModel::STATUS_PAID]);
|
||||
$this->postJson('/mini/payment/online', ['bill_ids' => [$paidBill->id], 'code' => 'c'])
|
||||
->assertJsonPath('success', false);
|
||||
|
||||
// 锁定中账单(已在其他支付单)
|
||||
$lockedBill = $this->makeBill($store, '10.00');
|
||||
$this->makeOnlinePayment($store, '10.00', $lockedBill);
|
||||
$this->postJson('/mini/payment/online', ['bill_ids' => [$lockedBill->id], 'code' => 'c'])
|
||||
->assertJsonPath('success', false);
|
||||
|
||||
// 均未产生新的待支付在线支付单(除锁定用那笔)
|
||||
$this->assertSame(1, PaymentModel::where('pay_type', PaymentModel::TYPE_ONLINE)->count());
|
||||
}
|
||||
|
||||
/** 微信 code2session 失败:报错且不产生支付单 */
|
||||
public function test_create_fails_when_code2session_fails(): void
|
||||
{
|
||||
Http::fake([
|
||||
'https://api.weixin.qq.com/*' => Http::response(['errcode' => 40029, 'errmsg' => 'invalid code'], 200),
|
||||
]);
|
||||
$store = StoreModel::factory()->create();
|
||||
$bill = $this->makeBill($store, '20.00');
|
||||
|
||||
$this->actingAsMiniStore($store);
|
||||
$this->postJson('/mini/payment/online', ['bill_ids' => [$bill->id], 'code' => 'bad-code'])
|
||||
->assertJsonPath('success', false);
|
||||
|
||||
$this->assertSame(0, PaymentModel::count());
|
||||
$this->assertSame(0, $bill->fresh()->payment_id);
|
||||
}
|
||||
|
||||
/** 网关下单失败:支付单作废(置失败)并释放账单,可重新发起 */
|
||||
public function test_create_gateway_failure_releases_bills(): void
|
||||
{
|
||||
Http::fake([
|
||||
'https://api.weixin.qq.com/*' => Http::response(['openid' => 'oOpenidTest001'], 200),
|
||||
'https://wangpu.test/*' => Http::response(['code' => '9999', 'msg' => '商户号不存在'], 200),
|
||||
]);
|
||||
$store = StoreModel::factory()->create();
|
||||
$bill = $this->makeBill($store, '20.00');
|
||||
|
||||
$this->actingAsMiniStore($store);
|
||||
$this->postJson('/mini/payment/online', ['bill_ids' => [$bill->id], 'code' => 'c'])
|
||||
->assertJsonPath('success', false);
|
||||
|
||||
$payment = PaymentModel::first();
|
||||
$this->assertSame(PaymentModel::STATUS_REJECTED, $payment->status);
|
||||
$this->assertSame(0, $bill->fresh()->payment_id, '账单释放可重新付款');
|
||||
}
|
||||
|
||||
/** 支付成功通知:验签通过 → 幂等结账(账单置已支付 + 累加门店总采购金额 + 通知门店) */
|
||||
public function test_notify_settles_payment(): void
|
||||
{
|
||||
$store = StoreModel::factory()->create();
|
||||
$bill1 = $this->makeBill($store, '100.00');
|
||||
$bill2 = $this->makeBill($store, '50.00');
|
||||
$payment = $this->makeOnlinePayment($store, '150.00', $bill1, $bill2);
|
||||
|
||||
$this->postJson('/mini/payment/notify', $this->signedNotifyParams($payment))
|
||||
->assertJsonPath('code', '00');
|
||||
|
||||
$payment->refresh();
|
||||
$this->assertSame(PaymentModel::STATUS_APPROVED, $payment->status);
|
||||
$this->assertSame('T20260827001', $payment->trade_no);
|
||||
$this->assertSame('WP202608270001', $payment->order_id);
|
||||
$this->assertSame('2026-08-27 10:00:00', (string) $payment->paid_at);
|
||||
|
||||
foreach ([$bill1, $bill2] as $bill) {
|
||||
$bill->refresh();
|
||||
$this->assertSame(BillModel::STATUS_PAID, $bill->status);
|
||||
$this->assertStringContainsString($payment->payment_no, (string) $bill->pay_remark);
|
||||
}
|
||||
$this->assertSame('150.00', (string) $store->fresh()->total_purchase_amount);
|
||||
|
||||
// 门店收到支付成功通知
|
||||
$this->assertTrue(
|
||||
NoticeModel::where('store_id', $store->id)->where('title', '账单支付成功')->exists()
|
||||
);
|
||||
|
||||
// 重复通知幂等:仍应答成功,金额不重复累加
|
||||
$this->postJson('/mini/payment/notify', $this->signedNotifyParams($payment))
|
||||
->assertJsonPath('code', '00');
|
||||
$this->assertSame('150.00', (string) $store->fresh()->total_purchase_amount);
|
||||
$this->assertSame(1, NoticeModel::where('store_id', $store->id)->count());
|
||||
}
|
||||
|
||||
/** 通知验签失败 / 金额不一致 / 订单号不存在 / 非支付成功状态:应答失败且不结账 */
|
||||
public function test_notify_rejects_invalid_messages(): void
|
||||
{
|
||||
$store = StoreModel::factory()->create();
|
||||
$bill = $this->makeBill($store, '100.00');
|
||||
$payment = $this->makeOnlinePayment($store, '100.00', $bill);
|
||||
|
||||
// 验签失败
|
||||
$badSign = $this->signedNotifyParams($payment);
|
||||
$badSign['sign'] = 'INVALIDSIGN';
|
||||
$this->postJson('/mini/payment/notify', $badSign)->assertJsonPath('code', '01');
|
||||
|
||||
// 金额不一致(防篡改)
|
||||
$this->postJson('/mini/payment/notify', $this->signedNotifyParams($payment, ['order_amt' => '99.99']))
|
||||
->assertJsonPath('code', '01');
|
||||
|
||||
// 订单号不存在
|
||||
$this->postJson('/mini/payment/notify', $this->signedNotifyParams($payment, ['mer_order_id' => 'ZF000000000000']))
|
||||
->assertJsonPath('code', '01');
|
||||
|
||||
// 非支付成功状态
|
||||
$this->postJson('/mini/payment/notify', $this->signedNotifyParams($payment, ['order_status' => '0']))
|
||||
->assertJsonPath('code', '01');
|
||||
|
||||
// 均未结账
|
||||
$this->assertSame(PaymentModel::STATUS_PENDING, $payment->fresh()->status);
|
||||
$this->assertSame(BillModel::STATUS_UNPAID, $bill->fresh()->status);
|
||||
$this->assertSame('0.00', (string) $store->fresh()->total_purchase_amount);
|
||||
}
|
||||
|
||||
/** 主动查询:网关已支付则同步结账;未支付保持待支付 */
|
||||
public function test_query_syncs_gateway_status(): void
|
||||
{
|
||||
$store = StoreModel::factory()->create();
|
||||
$bill = $this->makeBill($store, '80.00');
|
||||
$payment = $this->makeOnlinePayment($store, '80.00', $bill);
|
||||
|
||||
// 第 1 次查询网关未支付,第 2 次已支付(fake 回调按调用次数返回,避免重复注册被先注册的 stub 拦截)
|
||||
$queryCount = 0;
|
||||
Http::fake([
|
||||
'https://wangpu.test/industrial/query/order' => function () use (&$queryCount, $payment) {
|
||||
$queryCount++;
|
||||
$data = $queryCount === 1
|
||||
? ['order_status' => 0, 'mer_order_id' => $payment->payment_no, 'order_amt' => '80.00']
|
||||
: [
|
||||
'order_status' => 1,
|
||||
'mer_order_id' => $payment->payment_no,
|
||||
'order_amt' => '80.00',
|
||||
'trade_no' => 'T20260827002',
|
||||
'order_id' => 'WP202608270002',
|
||||
'trade_time' => '2026-08-27 11:00:00',
|
||||
];
|
||||
return Http::response(['code' => '0000', 'msg' => '调用成功', 'data' => $data], 200);
|
||||
},
|
||||
]);
|
||||
|
||||
// 场景一:网关未支付
|
||||
$this->actingAsMiniStore($store);
|
||||
$this->getJson("/mini/payment/online/{$payment->payment_no}/query")
|
||||
->assertJsonPath('success', true)
|
||||
->assertJsonPath('data.status', PaymentModel::STATUS_PENDING);
|
||||
$this->assertSame(BillModel::STATUS_UNPAID, $bill->fresh()->status);
|
||||
|
||||
// 场景二:网关已支付 → 查询即结账
|
||||
$this->getJson("/mini/payment/online/{$payment->payment_no}/query")
|
||||
->assertJsonPath('success', true)
|
||||
->assertJsonPath('data.status', PaymentModel::STATUS_APPROVED)
|
||||
->assertJsonPath('data.trade_no', 'T20260827002');
|
||||
|
||||
$this->assertSame(BillModel::STATUS_PAID, $bill->fresh()->status);
|
||||
$this->assertSame('80.00', (string) $store->fresh()->total_purchase_amount);
|
||||
|
||||
// 已结账后重复查询不再请求网关(本地直接返回)
|
||||
$this->getJson("/mini/payment/online/{$payment->payment_no}/query")
|
||||
->assertJsonPath('success', true)
|
||||
->assertJsonPath('data.status', PaymentModel::STATUS_APPROVED);
|
||||
$this->assertSame(2, $queryCount, '已结账后不再请求网关');
|
||||
$this->assertSame('80.00', (string) $store->fresh()->total_purchase_amount);
|
||||
}
|
||||
|
||||
/** 查询接口隔离:他人支付单不可见 */
|
||||
public function test_query_rejects_other_store_payment(): void
|
||||
{
|
||||
$store = StoreModel::factory()->create();
|
||||
$other = StoreModel::factory()->create();
|
||||
$bill = $this->makeBill($other, '10.00');
|
||||
$payment = $this->makeOnlinePayment($other, '10.00', $bill);
|
||||
|
||||
$this->actingAsMiniStore($store);
|
||||
$this->getJson("/mini/payment/online/{$payment->payment_no}/query")
|
||||
->assertJsonPath('success', false);
|
||||
}
|
||||
|
||||
/** 现有线下凭证支付流程不受影响:默认 pay_type=1 */
|
||||
public function test_offline_payment_flow_unaffected(): void
|
||||
{
|
||||
$store = StoreModel::factory()->create();
|
||||
$bill = $this->makeBill($store, '30.00');
|
||||
|
||||
$this->actingAsMiniStore($store);
|
||||
$this->postJson('/mini/payment', [
|
||||
'bill_ids' => [$bill->id],
|
||||
'pay_method' => PaymentModel::METHOD_BANK,
|
||||
'voucher_ids' => [1],
|
||||
])->assertJsonPath('success', true);
|
||||
|
||||
$payment = PaymentModel::first();
|
||||
$this->assertSame(PaymentModel::TYPE_OFFLINE, $payment->pay_type, '线下凭证支付默认 pay_type=1');
|
||||
$this->assertSame($payment->id, $bill->fresh()->payment_id);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user