在线支付

This commit is contained in:
liu
2026-08-27 21:17:02 +08:00
parent 65eb8ef594
commit c35dac0055
15 changed files with 1381 additions and 10 deletions
+14
View File
@@ -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, '成功');
}
/**
* 通知应答报文(网关约定格式,timestampyyyyMMddHHmmssSSS
*/
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',
];
+50 -4
View File
@@ -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(逗号分隔字符串 ↔ 数组)
*/
+1
View File
@@ -37,6 +37,7 @@ class StoreModel extends Authenticatable
'phone',
'address',
'payment_cycle_days',
'openid',
'status',
'remark',
];
+310
View File
@@ -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);
}
}
+159
View File
@@ -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.phpenv
*/
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.phpenv
*/
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);
}
}
+46
View File
@@ -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;
}
}
+15
View File
@@ -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', ''), // 加签专用 KeyMD5 加签)
'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('支付记录表(小程序合并付款:线下凭证后台审核 / 旺铺在线支付回调结账)');
});
}
}
+8
View File
@@ -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' => '旺铺报文加签专用 KeySignKey,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' => '下单微信子 appidsub_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([
+235
View File
@@ -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` | 加签专用 KeySignKey |
| `wangpu_sub_appid` | 下单微信子 appid,留空取小程序自身 appid |
| `wangpu_payway_code` | 支付方式代码(默认 `WECHAT_MINI`,以旺铺数据词典为准) |
### .env 备选配置
```dotenv
WECHAT_MINI_APPID= # 小程序 AppIDcode2session 必需)
WECHAT_MINI_SECRET= # 小程序 Secretcode2session 必需)
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`),两种入口均会拒绝重复提交。
+392
View File
@@ -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 testMD5 升序拼接 + 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);
}
}