微信公众号支付
This commit is contained in:
@@ -51,6 +51,10 @@ MAIL_FROM_NAME=
|
|||||||
WECHAT_MINI_APPID=
|
WECHAT_MINI_APPID=
|
||||||
WECHAT_MINI_SECRET=
|
WECHAT_MINI_SECRET=
|
||||||
|
|
||||||
|
# 微信公众号(网页授权换 openid,公众号 H5 JSAPI 支付用;后台 系统设置→微信应用配置 优先)
|
||||||
|
WECHAT_MP_APPID=
|
||||||
|
WECHAT_MP_SECRET=
|
||||||
|
|
||||||
# 在线支付渠道开关:wangpu=旺铺支付网关 / wechat=微信官方支付(后台 系统设置→支付配置 优先)
|
# 在线支付渠道开关:wangpu=旺铺支付网关 / wechat=微信官方支付(后台 系统设置→支付配置 优先)
|
||||||
PAY_ONLINE_CHANNEL=wangpu
|
PAY_ONLINE_CHANNEL=wangpu
|
||||||
|
|
||||||
@@ -64,6 +68,8 @@ WANGPU_PUBLIC_KEY=
|
|||||||
WANGPU_PRIVATE_KEY=
|
WANGPU_PRIVATE_KEY=
|
||||||
WANGPU_SUB_APPID=
|
WANGPU_SUB_APPID=
|
||||||
WANGPU_PAYWAY_CODE=WECHAT_MINI
|
WANGPU_PAYWAY_CODE=WECHAT_MINI
|
||||||
|
# 公众号场景支付方式代码(留空回退 WANGPU_PAYWAY_CODE)
|
||||||
|
WANGPU_MP_PAYWAY_CODE=
|
||||||
|
|
||||||
# 微信官方支付 APIv3(也可在后台 系统设置→微信官方支付 中维护,后台配置优先)
|
# 微信官方支付 APIv3(也可在后台 系统设置→微信官方支付 中维护,后台配置优先)
|
||||||
WXPAY_MCH_ID=
|
WXPAY_MCH_ID=
|
||||||
|
|||||||
File diff suppressed because one or more lines are too long
@@ -16,10 +16,10 @@ use Modules\AnnoRoute\Attribute\RequestAttribute;
|
|||||||
use Throwable;
|
use Throwable;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 小程序在线支付(渠道二选一:旺铺网关 JSAPI / 微信官方支付 APIv3)
|
* 小程序在线支付(渠道二选一:旺铺网关 JSAPI / 微信官方支付 APIv3;场景二选一:小程序 / 公众号 H5)
|
||||||
*
|
*
|
||||||
* 链路:POST /mini/payment/online 下单(返回调起支付参数)
|
* 链路:POST /mini/payment/online 下单(scene=mini 小程序 / scene=mp 公众号,返回调起支付参数)
|
||||||
* → 小程序 wx.requestPayment 完成支付
|
* → 小程序 wx.requestPayment / 公众号 H5 WeixinJSBridge 完成支付
|
||||||
* → 渠道后台通知(旺铺 /mini/payment/notify;微信 /mini/payment/wechat-notify)验签 + 幂等结账
|
* → 渠道后台通知(旺铺 /mini/payment/notify;微信 /mini/payment/wechat-notify)验签 + 幂等结账
|
||||||
* → 小程序 GET /mini/payment/online/{paymentNo}/query 主动同步支付结果(回调兜底)
|
* → 小程序 GET /mini/payment/online/{paymentNo}/query 主动同步支付结果(回调兜底)
|
||||||
*/
|
*/
|
||||||
@@ -33,7 +33,10 @@ class OnlinePaymentController extends BaseMiniController
|
|||||||
) {}
|
) {}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 发起在线支付:合并选择本店未支付账单 → 旺铺下单 → 返回调起支付参数
|
* 发起在线支付:合并选择本店未支付账单 → 渠道下单 → 返回调起支付参数
|
||||||
|
*
|
||||||
|
* scene=mini(默认):小程序 wx.login code 换 openid,wx.requestPayment 调起;
|
||||||
|
* scene=mp:公众号 H5 网页授权 code 换 openid,WeixinJSBridge getBrandWCPayRequest 调起。
|
||||||
* @throws Throwable
|
* @throws Throwable
|
||||||
*/
|
*/
|
||||||
#[PostRoute('/payment/online', authorize: true)]
|
#[PostRoute('/payment/online', authorize: true)]
|
||||||
@@ -43,11 +46,13 @@ class OnlinePaymentController extends BaseMiniController
|
|||||||
'bill_ids' => 'required|array|min:1',
|
'bill_ids' => 'required|array|min:1',
|
||||||
'bill_ids.*' => 'integer|distinct',
|
'bill_ids.*' => 'integer|distinct',
|
||||||
'code' => 'required|string|max:64',
|
'code' => 'required|string|max:64',
|
||||||
|
'scene' => 'nullable|string|in:mini,mp',
|
||||||
'remark' => 'nullable|string|max:255',
|
'remark' => 'nullable|string|max:255',
|
||||||
], [
|
], [
|
||||||
'bill_ids.required' => '请选择要付款的账单',
|
'bill_ids.required' => '请选择要付款的账单',
|
||||||
'bill_ids.min' => '请选择要付款的账单',
|
'bill_ids.min' => '请选择要付款的账单',
|
||||||
'code.required' => '微信登录凭证缺失,请重新进入小程序',
|
'code.required' => '微信登录/授权凭证缺失,请重新进入',
|
||||||
|
'scene.in' => '支付场景不正确',
|
||||||
'remark.max' => '备注超过最大长度',
|
'remark.max' => '备注超过最大长度',
|
||||||
]);
|
]);
|
||||||
|
|
||||||
@@ -58,6 +63,7 @@ class OnlinePaymentController extends BaseMiniController
|
|||||||
array_map('intval', $data['bill_ids']),
|
array_map('intval', $data['bill_ids']),
|
||||||
(string) $data['code'],
|
(string) $data['code'],
|
||||||
(string) ($data['remark'] ?? ''),
|
(string) ($data['remark'] ?? ''),
|
||||||
|
(string) ($data['scene'] ?? OnlinePaymentService::SCENE_MINI),
|
||||||
);
|
);
|
||||||
|
|
||||||
return $this->success([
|
return $this->success([
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ use App\Exceptions\RepositoryException;
|
|||||||
use App\Models\BillModel;
|
use App\Models\BillModel;
|
||||||
use App\Models\PaymentModel;
|
use App\Models\PaymentModel;
|
||||||
use App\Services\BillNumberService;
|
use App\Services\BillNumberService;
|
||||||
|
use App\Services\WechatMpService;
|
||||||
use Illuminate\Http\JsonResponse;
|
use Illuminate\Http\JsonResponse;
|
||||||
use Illuminate\Http\Request;
|
use Illuminate\Http\Request;
|
||||||
use Illuminate\Support\Facades\DB;
|
use Illuminate\Support\Facades\DB;
|
||||||
@@ -48,6 +49,8 @@ class PaymentController extends BaseMiniController
|
|||||||
'wechat_qrcode' => $resolve(site_config('pay.wechat_qrcode', '')),
|
'wechat_qrcode' => $resolve(site_config('pay.wechat_qrcode', '')),
|
||||||
'alipay_qrcode' => $resolve(site_config('pay.alipay_qrcode', '')),
|
'alipay_qrcode' => $resolve(site_config('pay.alipay_qrcode', '')),
|
||||||
'bank_info' => (string) site_config('pay.bank_info', ''),
|
'bank_info' => (string) site_config('pay.bank_info', ''),
|
||||||
|
// 公众号 AppID(H5 在微信内置浏览器拼网页授权链接使用,授权回调 code 传给 /mini/payment/online scene=mp)
|
||||||
|
'mp_appid' => app(WechatMpService::class)->appid(),
|
||||||
]);
|
]);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -38,6 +38,7 @@ class StoreModel extends Authenticatable
|
|||||||
'address',
|
'address',
|
||||||
'payment_cycle_days',
|
'payment_cycle_days',
|
||||||
'openid',
|
'openid',
|
||||||
|
'mp_openid',
|
||||||
'status',
|
'status',
|
||||||
'remark',
|
'remark',
|
||||||
];
|
];
|
||||||
|
|||||||
@@ -32,11 +32,17 @@ class OnlinePaymentService
|
|||||||
/** 在线支付渠道:微信官方支付 */
|
/** 在线支付渠道:微信官方支付 */
|
||||||
public const string CHANNEL_WECHAT = 'wechat';
|
public const string CHANNEL_WECHAT = 'wechat';
|
||||||
|
|
||||||
|
/** 支付场景:微信小程序(wx.login 换 openid,wx.requestPayment 调起) */
|
||||||
|
public const string SCENE_MINI = 'mini';
|
||||||
|
/** 支付场景:公众号 H5(网页授权换 openid,WeixinJSBridge 调起) */
|
||||||
|
public const string SCENE_MP = 'mp';
|
||||||
|
|
||||||
public function __construct(
|
public function __construct(
|
||||||
protected BillNumberService $billNumber,
|
protected BillNumberService $billNumber,
|
||||||
protected WangpuPayService $wangpu,
|
protected WangpuPayService $wangpu,
|
||||||
protected WechatPayService $wxpay,
|
protected WechatPayService $wxpay,
|
||||||
protected WechatMiniService $wechat,
|
protected WechatMiniService $wechat,
|
||||||
|
protected WechatMpService $wechatMp,
|
||||||
) {}
|
) {}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -55,20 +61,29 @@ class OnlinePaymentService
|
|||||||
* 发起在线支付
|
* 发起在线支付
|
||||||
*
|
*
|
||||||
* @param array<int, int> $billIds 合并付款的账单ID
|
* @param array<int, int> $billIds 合并付款的账单ID
|
||||||
* @param string $code 小程序 wx.login() 返回的登录凭证
|
* @param string $code 小程序 wx.login() 登录凭证 / 公众号网页授权 code(按 scene 区分)
|
||||||
|
* @param string $scene 支付场景:mini=小程序(默认) / mp=公众号 H5
|
||||||
* @return array{0: PaymentModel, 1: array<string, mixed>} 支付单与渠道返回的调起支付参数
|
* @return array{0: PaymentModel, 1: array<string, mixed>} 支付单与渠道返回的调起支付参数
|
||||||
* @throws Throwable
|
* @throws Throwable
|
||||||
*/
|
*/
|
||||||
public function create(StoreModel $store, array $billIds, string $code, string $remark = ''): array
|
public function create(StoreModel $store, array $billIds, string $code, string $remark = '', string $scene = self::SCENE_MINI): array
|
||||||
{
|
{
|
||||||
$channel = $this->channel();
|
$channel = $this->channel();
|
||||||
$payMethod = $channel === self::CHANNEL_WECHAT ? PaymentModel::METHOD_WECHAT : PaymentModel::METHOD_WANGPU;
|
$payMethod = $channel === self::CHANNEL_WECHAT ? PaymentModel::METHOD_WECHAT : PaymentModel::METHOD_WANGPU;
|
||||||
|
|
||||||
// 换取付款人 openid 并绑定到门店(下次可直接复用)
|
// 换取付款人 openid 并绑定到门店(小程序/公众号分场景绑定,两者 openid 维度不同不可混用)
|
||||||
$openid = $this->wechat->code2session($code);
|
if ($scene === self::SCENE_MP) {
|
||||||
if ((string) $store->openid !== $openid) {
|
$openid = $this->wechatMp->code2openid($code);
|
||||||
$store->openid = $openid;
|
if ((string) $store->mp_openid !== $openid) {
|
||||||
$store->save();
|
$store->mp_openid = $openid;
|
||||||
|
$store->save();
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
$openid = $this->wechat->code2session($code);
|
||||||
|
if ((string) $store->openid !== $openid) {
|
||||||
|
$store->openid = $openid;
|
||||||
|
$store->save();
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
[$payment, $bills] = DB::transaction(function () use ($store, $billIds, $openid, $remark, $payMethod) {
|
[$payment, $bills] = DB::transaction(function () use ($store, $billIds, $openid, $remark, $payMethod) {
|
||||||
@@ -122,12 +137,14 @@ class OnlinePaymentService
|
|||||||
(string) $payment->amount,
|
(string) $payment->amount,
|
||||||
$openid,
|
$openid,
|
||||||
'账单合并付款-' . $payment->payment_no,
|
'账单合并付款-' . $payment->payment_no,
|
||||||
|
$scene === self::SCENE_MP ? $this->wechatMp->appid() : '',
|
||||||
)
|
)
|
||||||
: $this->wangpu->createOrder([
|
: $this->wangpu->createOrder([
|
||||||
'mer_order_id' => $payment->payment_no,
|
'mer_order_id' => $payment->payment_no,
|
||||||
'order_amt' => (string) $payment->amount,
|
'order_amt' => (string) $payment->amount,
|
||||||
'open_id' => $openid,
|
'open_id' => $openid,
|
||||||
'sub_appid' => $this->subAppid(),
|
'sub_appid' => $this->subAppid($scene),
|
||||||
|
'payway_code' => $this->wangpu->paywayCode($scene),
|
||||||
'order_title' => '账单合并付款-' . $payment->payment_no,
|
'order_title' => '账单合并付款-' . $payment->payment_no,
|
||||||
'notifyurl' => $this->wangpu->notifyUrl(),
|
'notifyurl' => $this->wangpu->notifyUrl(),
|
||||||
]);
|
]);
|
||||||
@@ -391,10 +408,13 @@ class OnlinePaymentService
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 下单微信子 appid(默认取小程序 appid)
|
* 下单微信子 appid(默认取小程序 appid;公众号 H5 场景取公众号 appid)
|
||||||
*/
|
*/
|
||||||
protected function subAppid(): string
|
protected function subAppid(string $scene = self::SCENE_MINI): string
|
||||||
{
|
{
|
||||||
|
if ($scene === self::SCENE_MP) {
|
||||||
|
return $this->wechatMp->appid();
|
||||||
|
}
|
||||||
$subAppid = (string) site_config('wangpu.sub_appid', '');
|
$subAppid = (string) site_config('wangpu.sub_appid', '');
|
||||||
if ($subAppid === '') {
|
if ($subAppid === '') {
|
||||||
$subAppid = (string) config('services.wangpu.sub_appid', '');
|
$subAppid = (string) config('services.wangpu.sub_appid', '');
|
||||||
|
|||||||
@@ -90,6 +90,20 @@ class WangpuPayService
|
|||||||
return rtrim((string) config('app.url'), '/') . '/mini/payment/notify';
|
return rtrim((string) config('app.url'), '/') . '/mini/payment/notify';
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 支付方式代码(公众号 H5 场景优先取 mp_payway_code,留空回退 payway_code)
|
||||||
|
*/
|
||||||
|
public function paywayCode(string $scene = 'mini'): string
|
||||||
|
{
|
||||||
|
if ($scene === 'mp') {
|
||||||
|
$mpPayway = $this->config('mp_payway_code');
|
||||||
|
if ($mpPayway !== '') {
|
||||||
|
return $mpPayway;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return $this->config('payway_code');
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 网关 POST 请求:业务报文加密装信封 → JSON 发送 → 校验应答码 → 解密应答
|
* 网关 POST 请求:业务报文加密装信封 → JSON 发送 → 校验应答码 → 解密应答
|
||||||
*
|
*
|
||||||
|
|||||||
@@ -0,0 +1,72 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Services;
|
||||||
|
|
||||||
|
use App\Exceptions\RepositoryException;
|
||||||
|
use Illuminate\Support\Facades\Http;
|
||||||
|
use Illuminate\Support\Facades\Log;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 微信公众号服务(网页授权 code 换 openid,公众号 JSAPI 支付前置)
|
||||||
|
*
|
||||||
|
* H5 页面在微信内置浏览器打开 → 网页授权(snsapi_base)回调带回 code
|
||||||
|
* → 本服务以 code 换付款人 openid(公众号维度,与小程序 openid 不同,不可混用)
|
||||||
|
*/
|
||||||
|
class WechatMpService
|
||||||
|
{
|
||||||
|
/**
|
||||||
|
* 网页授权 code 换 openid(sns/oauth2/access_token)
|
||||||
|
*
|
||||||
|
* @return string 用户公众号维度 openid
|
||||||
|
* @throws RepositoryException 未配置公众号或授权失败
|
||||||
|
*/
|
||||||
|
public function code2openid(string $code): string
|
||||||
|
{
|
||||||
|
$appid = $this->appid();
|
||||||
|
$secret = $this->config('mp_secret');
|
||||||
|
if ($appid === '' || $secret === '') {
|
||||||
|
throw new RepositoryException('微信公众号未配置 AppID/Secret,请联系管理员');
|
||||||
|
}
|
||||||
|
|
||||||
|
$result = Http::timeout(10)->withOptions([
|
||||||
|
'verify' => false, // 禁用 SSL 证书验证
|
||||||
|
])->get('https://api.weixin.qq.com/sns/oauth2/access_token', [
|
||||||
|
'appid' => $appid,
|
||||||
|
'secret' => $secret,
|
||||||
|
'code' => $code,
|
||||||
|
'grant_type' => 'authorization_code',
|
||||||
|
])->json();
|
||||||
|
|
||||||
|
$openid = is_array($result) ? (string) ($result['openid'] ?? '') : '';
|
||||||
|
if ($openid === '') {
|
||||||
|
Log::warning('微信网页授权失败', ['response' => $result]);
|
||||||
|
throw new RepositoryException('微信网页授权失败:' . ($result['errmsg'] ?? '请重新授权'));
|
||||||
|
}
|
||||||
|
|
||||||
|
return $openid;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 公众号 AppID(公众号 JSAPI 下单 appid、H5 拼网页授权链接使用)
|
||||||
|
*/
|
||||||
|
public function appid(): string
|
||||||
|
{
|
||||||
|
return $this->config('mp_appid');
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 读取应用配置:后台站点配置(微信应用配置分组)优先,为空回退 config/services.php(env)
|
||||||
|
*/
|
||||||
|
protected function config(string $key): string
|
||||||
|
{
|
||||||
|
$value = site_config('wechat.' . $key, '');
|
||||||
|
if ($value === null || $value === '') {
|
||||||
|
$value = match ($key) {
|
||||||
|
'mp_appid' => config('services.wechat.mp.appid', ''),
|
||||||
|
'mp_secret' => config('services.wechat.mp.secret', ''),
|
||||||
|
default => '',
|
||||||
|
};
|
||||||
|
}
|
||||||
|
return trim((string) $value);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -29,19 +29,22 @@ class WechatPayService
|
|||||||
protected ?Application $application = null;
|
protected ?Application $application = null;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 统一下单(小程序 JSAPI)
|
* 统一下单(JSAPI:小程序 / 公众号 H5 通用)
|
||||||
*
|
*
|
||||||
* @param string $paymentNo 商户订单号(本系统支付单号)
|
* @param string $paymentNo 商户订单号(本系统支付单号)
|
||||||
* @param string $amountYuan 金额(元,两位小数)
|
* @param string $amountYuan 金额(元,两位小数)
|
||||||
* @param string $openid 付款人 openid
|
* @param string $openid 付款人 openid(按下单 appid 维度:小程序 openid 或公众号 openid)
|
||||||
* @param string $description 订单标题
|
* @param string $description 订单标题
|
||||||
* @return array<string, mixed> wx.requestPayment 调起参数(timeStamp/nonceStr/package/signType/paySign)
|
* @param string $appid 下单应用 appid(公众号 JSAPI 传公众号 appid;留空取配置的小程序 appid)
|
||||||
|
* @return array<string, mixed> 调起支付参数(appId/timeStamp/nonceStr/package/signType/paySign,
|
||||||
|
* 小程序喂 wx.requestPayment,公众号 H5 喂 WeixinJSBridge getBrandWCPayRequest,结构同构)
|
||||||
* @throws RepositoryException 未配置或下单失败
|
* @throws RepositoryException 未配置或下单失败
|
||||||
*/
|
*/
|
||||||
public function createOrder(string $paymentNo, string $amountYuan, string $openid, string $description): array
|
public function createOrder(string $paymentNo, string $amountYuan, string $openid, string $description, string $appid = ''): array
|
||||||
{
|
{
|
||||||
|
$appid = trim($appid) !== '' ? trim($appid) : $this->appid();
|
||||||
$result = $this->call('POST', '/v3/pay/transactions/jsapi', [
|
$result = $this->call('POST', '/v3/pay/transactions/jsapi', [
|
||||||
'appid' => $this->appid(),
|
'appid' => $appid,
|
||||||
'mchid' => $this->config('mch_id'),
|
'mchid' => $this->config('mch_id'),
|
||||||
'description' => mb_substr($description, 0, 127),
|
'description' => mb_substr($description, 0, 127),
|
||||||
'out_trade_no' => $paymentNo,
|
'out_trade_no' => $paymentNo,
|
||||||
@@ -59,8 +62,9 @@ class WechatPayService
|
|||||||
throw new RepositoryException('微信支付下单失败:应答缺少 prepay_id');
|
throw new RepositoryException('微信支付下单失败:应答缺少 prepay_id');
|
||||||
}
|
}
|
||||||
|
|
||||||
$params = $this->app()->getUtils()->buildMiniAppConfig($prepayId, $this->appid());
|
// buildMiniAppConfig 即 buildBridgeConfig(小程序 wx.requestPayment 与公众号 WeixinJSBridge 参数同构)
|
||||||
Log::driver('pay')->info('微信支付下单成功', ['out_trade_no' => $paymentNo, 'prepay_id' => $prepayId]);
|
$params = $this->app()->getUtils()->buildMiniAppConfig($prepayId, $appid);
|
||||||
|
Log::driver('pay')->info('微信支付下单成功', ['out_trade_no' => $paymentNo, 'prepay_id' => $prepayId, 'appid' => $appid]);
|
||||||
|
|
||||||
return $params;
|
return $params;
|
||||||
}
|
}
|
||||||
|
|||||||
+7
-1
@@ -46,12 +46,17 @@ return [
|
|||||||
/*
|
/*
|
||||||
* 微信小程序(登录 code2Session / 手机号授权),AppID/Secret 需业务方提供
|
* 微信小程序(登录 code2Session / 手机号授权),AppID/Secret 需业务方提供
|
||||||
* 优先读取后台「系统设置 → 微信应用配置」(site_config wechat.mini_appid / wechat.mini_secret),为空时回退到这里的 env 配置
|
* 优先读取后台「系统设置 → 微信应用配置」(site_config wechat.mini_appid / wechat.mini_secret),为空时回退到这里的 env 配置
|
||||||
|
* 公众号(mp):网页授权换 openid、公众号 JSAPI 支付使用,后台 site_config wechat.mp_appid / wechat.mp_secret 优先
|
||||||
*/
|
*/
|
||||||
'wechat' => [
|
'wechat' => [
|
||||||
'mini' => [
|
'mini' => [
|
||||||
'appid' => env('WECHAT_MINI_APPID', ''),
|
'appid' => env('WECHAT_MINI_APPID', ''),
|
||||||
'secret' => env('WECHAT_MINI_SECRET', ''),
|
'secret' => env('WECHAT_MINI_SECRET', ''),
|
||||||
],
|
],
|
||||||
|
'mp' => [
|
||||||
|
'appid' => env('WECHAT_MP_APPID', ''),
|
||||||
|
'secret' => env('WECHAT_MP_SECRET', ''),
|
||||||
|
],
|
||||||
],
|
],
|
||||||
|
|
||||||
/*
|
/*
|
||||||
@@ -75,7 +80,8 @@ return [
|
|||||||
'public_key' => env('WANGPU_PUBLIC_KEY', ''), // 旺铺平台公钥(base64 单行,加密请求报文 AES 密钥)
|
'public_key' => env('WANGPU_PUBLIC_KEY', ''), // 旺铺平台公钥(base64 单行,加密请求报文 AES 密钥)
|
||||||
'private_key' => env('WANGPU_PRIVATE_KEY', ''), // 商户 RSA 私钥(base64 单行,解密网关应答/通知报文)
|
'private_key' => env('WANGPU_PRIVATE_KEY', ''), // 商户 RSA 私钥(base64 单行,解密网关应答/通知报文)
|
||||||
'sub_appid' => env('WANGPU_SUB_APPID', ''), // 下单微信子 appid(默认取小程序 appid)
|
'sub_appid' => env('WANGPU_SUB_APPID', ''), // 下单微信子 appid(默认取小程序 appid)
|
||||||
'payway_code' => env('WANGPU_PAYWAY_CODE', 'WECHAT_MINI'), // 支付方式代码(主扫必填)
|
'payway_code' => env('WANGPU_PAYWAY_CODE', 'WECHAT_MINI'), // 支付方式代码(小程序主扫必填)
|
||||||
|
'mp_payway_code' => env('WANGPU_MP_PAYWAY_CODE', ''), // 公众号场景支付方式代码(留空回退 payway_code)
|
||||||
],
|
],
|
||||||
|
|
||||||
/*
|
/*
|
||||||
|
|||||||
@@ -43,6 +43,7 @@ return new class extends Migration
|
|||||||
$table->string('address', 255)->default('')->comment('门店地址');
|
$table->string('address', 255)->default('')->comment('门店地址');
|
||||||
$table->integer('payment_cycle_days')->default(0)->comment('回款周期(天)');
|
$table->integer('payment_cycle_days')->default(0)->comment('回款周期(天)');
|
||||||
$table->string('openid', 64)->default('')->comment('微信小程序 openid(在线支付付款人标识,wx.login 换取后绑定)');
|
$table->string('openid', 64)->default('')->comment('微信小程序 openid(在线支付付款人标识,wx.login 换取后绑定)');
|
||||||
|
$table->string('mp_openid', 64)->default('')->comment('微信公众号 openid(公众号 JSAPI 支付付款人标识,网页授权换取后绑定)');
|
||||||
$table->decimal('total_purchase_amount', 12, 2)->default(0)->comment('总采购金额(只统计商品金额,账单支付后累加)');
|
$table->decimal('total_purchase_amount', 12, 2)->default(0)->comment('总采购金额(只统计商品金额,账单支付后累加)');
|
||||||
$table->integer('status')->default(1)->comment('状态(1正常 0停用)');
|
$table->integer('status')->default(1)->comment('状态(1正常 0停用)');
|
||||||
$table->string('remark', 255)->nullable()->default('')->comment('备注');
|
$table->string('remark', 255)->nullable()->default('')->comment('备注');
|
||||||
|
|||||||
@@ -44,6 +44,7 @@ class SysDataSeeder extends Seeder
|
|||||||
['id' => 17, 'group_id' => 5, 'key' => 'public_key', 'title' => '旺铺平台公钥', 'describe' => '旺铺平台 RSA 公钥(base64 单行,不含 PEM 头尾),用于加密请求报文 AES 密钥,旺铺提供', 'values' => '', 'type' => 'TextArea','options' => "", 'sort' => 5, 'created_at' => $date, 'updated_at' => $date],
|
['id' => 17, 'group_id' => 5, 'key' => 'public_key', 'title' => '旺铺平台公钥', 'describe' => '旺铺平台 RSA 公钥(base64 单行,不含 PEM 头尾),用于加密请求报文 AES 密钥,旺铺提供', 'values' => '', 'type' => 'TextArea','options' => "", 'sort' => 5, 'created_at' => $date, 'updated_at' => $date],
|
||||||
['id' => 18, 'group_id' => 5, 'key' => 'sub_appid', 'title' => '旺铺下单子appid', 'describe' => '下单微信子 appid(sub_appid),留空则取小程序自身 appid', 'values' => '', 'type' => 'Input','options' => "", 'sort' => 6, 'created_at' => $date, 'updated_at' => $date],
|
['id' => 18, 'group_id' => 5, 'key' => 'sub_appid', 'title' => '旺铺下单子appid', 'describe' => '下单微信子 appid(sub_appid),留空则取小程序自身 appid', 'values' => '', 'type' => 'Input','options' => "", 'sort' => 6, 'created_at' => $date, 'updated_at' => $date],
|
||||||
['id' => 19, 'group_id' => 5, 'key' => 'payway_code', 'title' => '旺铺支付方式代码', 'describe' => '支付方式代码 payway_code(小程序主扫必填,如 WECHAT_MINI),见旺铺数据词典', 'values' => 'WECHAT_MINI', 'type' => 'Input','options' => "", 'sort' => 7, 'created_at' => $date, 'updated_at' => $date],
|
['id' => 19, 'group_id' => 5, 'key' => 'payway_code', 'title' => '旺铺支付方式代码', 'describe' => '支付方式代码 payway_code(小程序主扫必填,如 WECHAT_MINI),见旺铺数据词典', 'values' => 'WECHAT_MINI', 'type' => 'Input','options' => "", 'sort' => 7, 'created_at' => $date, 'updated_at' => $date],
|
||||||
|
['id' => 34, 'group_id' => 5, 'key' => 'mp_payway_code', 'title' => '旺铺公众号支付方式代码', 'describe' => '公众号 H5 场景支付方式代码 payway_code(如 WECHAT_JSPAY),留空则回退小程序支付方式代码', 'values' => 'WECHAT_JSPAY', 'type' => 'Input','options' => "", 'sort' => 9, 'created_at' => $date, 'updated_at' => $date],
|
||||||
['id' => 20, 'group_id' => 5, 'key' => 'private_key', 'title' => '商户RSA私钥', 'describe' => '商户 RSA 私钥(base64 单行,不含 PEM 头尾),用于解密网关应答/支付通知报文,请勿泄露', 'values' => '', 'type' => 'TextArea','options' => "", 'sort' => 8, 'created_at' => $date, 'updated_at' => $date],
|
['id' => 20, 'group_id' => 5, 'key' => 'private_key', 'title' => '商户RSA私钥', 'describe' => '商户 RSA 私钥(base64 单行,不含 PEM 头尾),用于解密网关应答/支付通知报文,请勿泄露', 'values' => '', 'type' => 'TextArea','options' => "", 'sort' => 8, 'created_at' => $date, 'updated_at' => $date],
|
||||||
// 微信官方支付(wxpay):微信支付 APIv3
|
// 微信官方支付(wxpay):微信支付 APIv3
|
||||||
['id' => 22, 'group_id' => 6, 'key' => 'mch_id', 'title' => '微信支付商户号', 'describe' => '微信支付商户号 mch_id(微信商户平台分配)', 'values' => '', 'type' => 'Input','options' => "", 'sort' => 0, 'created_at' => $date, 'updated_at' => $date],
|
['id' => 22, 'group_id' => 6, 'key' => 'mch_id', 'title' => '微信支付商户号', 'describe' => '微信支付商户号 mch_id(微信商户平台分配)', 'values' => '', 'type' => 'Input','options' => "", 'sort' => 0, 'created_at' => $date, 'updated_at' => $date],
|
||||||
|
|||||||
@@ -0,0 +1,336 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace Tests\Feature;
|
||||||
|
|
||||||
|
use App\Models\BillModel;
|
||||||
|
use App\Models\PaymentModel;
|
||||||
|
use App\Models\PurchaseOrderModel;
|
||||||
|
use App\Models\StoreModel;
|
||||||
|
use App\Services\WechatPayService;
|
||||||
|
use Illuminate\Support\Facades\Http;
|
||||||
|
use Illuminate\Support\Str;
|
||||||
|
use Symfony\Component\HttpClient\MockHttpClient;
|
||||||
|
use Symfony\Component\HttpClient\Response\MockResponse;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 公众号 H5 JSAPI 在线支付(scene=mp):网页授权 code 换 openid → 公众号 appid 下单 → 调起参数
|
||||||
|
*
|
||||||
|
* - 微信公众号维度 openid 与小程序 openid 不同:分场景绑定(store.mp_openid / store.openid 互不覆盖)
|
||||||
|
* - 微信官方渠道:JSAPI 下单 appid=公众号 appid,调起参数喂 WeixinJSBridge getBrandWCPayRequest
|
||||||
|
* - 旺铺渠道:sub_appid=公众号 appid、payway_code 取 mp_payway_code(留空回退 payway_code)
|
||||||
|
*/
|
||||||
|
class MiniMpPaymentTest extends ProcurementTestCase
|
||||||
|
{
|
||||||
|
/** 测试专用微信商户 API 私钥(自签名,仅测试使用) */
|
||||||
|
private const string WXPAY_PRIVATE_KEY = "-----BEGIN PRIVATE KEY-----\nMIIEvQIBADANBgkqhkiG9w0BAQEFAASCBKcwggSjAgEAAoIBAQChGoUMvCaiaf97\nXcUExEA3VP4FVwUCPQf/GPvm2cjHZACcPj65rjKNs5nJ0xaKELrJkUCZ3+N5YJJ4\nVGBBhh9GXekrzFckeqUn1sRS9FFbWfNeqAvz3qgps+ohnriPMkTRTw/SONTVRuNV\nesKz8FTYbT5Vl6jSHv71CMMcm6qrhvZ9av1nB03MrUN06v2OMhfXrZTBV5KI5zvI\nOwxetOz0MTAHcfRyrW8cejiJo3baRsRRHXsnDRB7p4H1KIPx+N7yTJKv/L2+tgXY\nmhIjKJRC5hDmrLrLOzievqKlBTsBcG41UWzcLEDG96BcR6Le+dzlJIXXi2taoFXG\nFuDUVt/bAgMBAAECggEAEagPv57kt8AuQ/MO+MAZhExIWtrNWGV1Tua0v6RMIQOQ\nyDmk3kIn6ufYNF/HhJzjcTFThzRxKzMDr98aKD7u2+NxqV8yiHOjTi53mIU2YBtc\n/5l4qzhSmIojYEk+U6i1aiCOzKzLMWTHiELKvAsxrAFXvOxrkDvdbYrm92ey2Nbm\nnvFTzTlPNEVf8HwKIFpbbV/r5RlrqDOqj8np6t5X+PlpL4Kx+oO8XVaaWjjb+2NB\nPX7laZNLa2oJ+sY4qWnwPyCeZQu2glPtBjJQRWX8+eP5p7xMMWylosL056LHasb2\nNC+KDjXSA/4O19NhBwsX8Q7QDbuJrVt/2WMboJxfYQKBgQDU7U64gzdTFezTgJ18\nb4hXqvVyzksireABV2BfKiah25yMH5s4YtgFneusn0flh5yKOh1phbn9EmMGAnob\nyHYfcyiFFR1i+4j3ELblKTkbXeaMIthTqL9QRULc3HwVjKINs3tUAlpSHqdFrzAT\nQb8JcgHfmnDrqSNCNXLJY1cgLwKBgQDBsXj7PDX+URT4m7kMWD4GzTlAbAotLBO5\nGxugKAxczGbAk3G+xmYbwgbuKUHTzBAXX0atfCMjbK9DdowRlyVbI948o8drXqz2\nR2l6u3VyV0ERP+9BV0xfLNbiQGWiPZiYR9hYg+ewbzCmsQEyAUJGisxPZZtNz598\nmqhhQLeEFQKBgQCqTCJp8IiPKzn+7x8GJy4k79bfu57cXbSLXhb2BgBf9AWBDMZY\nkrWzyFp19e7K5WuOImzjuNDIV6xbYh/HmMzg2nnN9tVKFWO2NugQ4KeL+84oxrW+\nM3jP+pU/kBiuI4x46NP6nOcgRuQCF9ubizn/k+9rp1opIV3R1m24JHvKgQKBgFUd\nZtuIHvXtrXh9/bg2ArO8dR8hGuu0xcn+5onfb2dMDw8q+73oszAZeDAqAxpOPvf2\nTzmnJk3H0dCkhHKqZ6kAPwWItvYwuXLT+L8NJ1QikZ5B6SJeeVoNezQbNk4wISEx\n2Rk0hZibk9Z8S9ksgnI7RgLR1IhB54S69ake5kXtAoGANB/pN7MBP9ZeUBKq6eoB\nTHcHZjcD8ZRC/4rsgDcwpHd67xWloALc7FXA1gQfX2VcwFbtf4pl6umQd8kmsmEN\n0AJJ+riyMMrPcHZV0PCo9Kq4ex+6bLFENdRbjUBNk2nrQ/le8BxZLNi0QZPZTOLW\n+XXHpYd3dKJ/BJA/RfiP7sg=\n-----END PRIVATE KEY-----";
|
||||||
|
|
||||||
|
/** 测试专用微信商户 API 证书(自签名 X509,请求签名序列号取自证书) */
|
||||||
|
private const string WXPAY_CERTIFICATE = "-----BEGIN CERTIFICATE-----\nMIIC4TCCAcmgAwIBAgIUIDIEwVtecMJKfY0NCx1l8Fwv7LowDQYJKoZIhvcNAQEL\nBQAwADAeFw0yNjA5MDExMzM5NThaFw0zNjA4MjkxMzM5NThaMAAwggEiMA0GCSqG\nSIb3DQEBAQUAA4IBDwAwggEKAoIBAQChGoUMvCaiaf97XcUExEA3VP4FVwUCPQf/\nGPvm2cjHZACcPj65rjKNs5nJ0xaKELrJkUCZ3+N5YJJ4VGBBhh9GXekrzFckeqUn\n1sRS9FFbWfNeqAvz3qgps+ohnriPMkTRTw/SONTVRuNVesKz8FTYbT5Vl6jSHv71\nCMMcm6qrhvZ9av1nB03MrUN06v2OMhfXrZTBV5KI5zvIOwxetOz0MTAHcfRyrW8c\nejiJo3baRsRRHXsnDRB7p4H1KIPx+N7yTJKv/L2+tgXYmhIjKJRC5hDmrLrLOzie\nvqKlBTsBcG41UWzcLEDG96BcR6Le+dzlJIXXi2taoFXGFuDUVt/bAgMBAAGjUzBR\nMB0GA1UdDgQWBBQ2SD9beEd/0EU6ouWHHSW6Pdh8OjAfBgNVHSMEGDAWgBQ2SD9b\neEd/0EU6ouWHHSW6Pdh8OjAPBgNVHRMBAf8EBTADAQH/MA0GCSqGSIb3DQEBCwUA\nA4IBAQByZckpIfdi5BeWwX4FZK9D5+78hZpOpDi+a8rxyd22BYYbkft5WfWnTjWh\nuXZgwOBPoYwmQ9FtC8rIeXWCadcp/ohGCkVVm9Y2CtkutrTC+uxdl4LnDPRQjXPL\nuRubAzvv3leRrphd1dOmAku32TfE0bK6Xul6pCnQaEgIlFp8zp8UkCrYcm9SPNye\nRt/1vt5iBPB6UiZ9oQL1IDUdqJV2Hl9snzHi0AzuZHy+ZMZY6WEY2lBohN8wvfak\nqrejyeA3nY7+a+QRPCg9fAOhxJlIj/eJXJXUUgJTiwTDHN16JQYcqgX5/AdrqnNj\ndkhBOR8mPPmE9hCoXwcDt017y7YV\n-----END CERTIFICATE-----";
|
||||||
|
|
||||||
|
/** 测试专用微信 APIv3 密钥(32 字节) */
|
||||||
|
private const string WXPAY_SECRET_KEY = 'testSecr3tKey0123456789abcdef01';
|
||||||
|
|
||||||
|
/** 测试专用旺铺 RSA 密钥对(仅测试使用,公私钥同源便于加解密回环) */
|
||||||
|
private const string WANGPU_PUBLIC_KEY = 'MIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEAqZ4z7UatOnbBolcpFhW2q585II7atHK9w/opQ/k8QUqjwkwIVNYzC9qf/SFDoOnMBufZwS2tM8UUPcgNoy0hbmR5QqBsp9ugBaSL1ZHb5jStgpikzWEjFkyVR5WLtxl4nxKv0R5pV/mfq0RXsIpbutZqugnXRvHPYaqbKomXzf0FKlxmbpvPOXsH9L9rYrnOSKQ1t16vAa6UrqUdkgdQBH7+hI4kiDwY0MIZVOhDhB4r0ODrpWMd0U3yrbhwpHZdtF2B3dtn3JSS92F16Yo7Hec8+TUzNKX0Mu1rn07F6yGZeUf+5/egXsBc2Mx1TgZswFlvkmHS96kbH9gqJyGOVwIDAQAB';
|
||||||
|
|
||||||
|
private const string WANGPU_PRIVATE_KEY = 'MIIEvQIBADANBgkqhkiG9w0BAQEFAASCBKcwggSjAgEAAoIBAQCpnjPtRq06dsGiVykWFbarnzkgjtq0cr3D+ilD+TxBSqPCTAhU1jML2p/9IUOg6cwG59nBLa0zxRQ9yA2jLSFuZHlCoGyn26AFpIvVkdvmNK2CmKTNYSMWTJVHlYu3GXifEq/RHmlX+Z+rRFewilu61mq6CddG8c9hqpsqiZfN/QUqXGZum885ewf0v2tiuc5IpDW3Xq8BrpSupR2SB1AEfv6EjiSIPBjQwhlU6EOEHivQ4OulYx3RTfKtuHCkdl20XYHd22fclJL3YXXpijsd5zz5NTM0pfQy7WufTsXrIZl5R/7n96BewFzYzHVOBmzAWW+SYdL3qRsf2ConIY5XAgMBAAECggEAANg+h97uaNFYF9c5XjNeLYU0ULqkeD6SKAApIpgTY+6w6FkBoWIuT1y+q0jNYqKpwN+Ds2+vfizT7GX5Bz1iLoN8etHawBeLCmIus83A75VeAr20gOyy6pWv3Qlty1yvMsczEydqsL3vA0+gdQoB2RW+ib4uKmxl66WNR+xQE0DaGp3GGe117l4PrhqZWsMMugQ2sLJy6ZZ/L6+ILnTPYwz+6NdOor8owoSEYHpKJ03wTcDGdMtRkzanEtfBNklHolymof+aDwXeugw5teGm+/AtaTf8dINeUIGp6AFrUa+g78wyO/piUf+inLNa+e6vgGqck+ErsbRPb6VFQFBsQQKBgQDqkgfXGETdBtE5g9QiV2aY+DDHzcmQZBLgRGDoHFhn2SgRdG2E66Xq6uGEnwIpqLuKsCYe4t2JvNjSVFZ14Ul8DOow6NQfWLPxU6YT3fShAh/gTMgccKb7/Pe3fQqwl+6aEV3oMOKNFErKSGrkSptLh9Nf/ii9khpMFhUARErgjQKBgQC5HRwRb45CIkEeS2Q1Gz2xS4ezXACw9P0h4Qp5bMQmqgn/fjN+t4HJRlHCuDfLTnHZ48Vpihwu7BU1RmK1ozXJswDzQXCYE4ejEXTGVoUNxCHht4/VlAbBVbcnOsIREgvtZRIOHvPaMqUQqeSX9e3HcEAHfIaJRkVAhalQnKMrcwKBgDUtH7viU5Irvnikaw3R9H9PHHffLgeeuCzBM5rK+juong2+8CkG5tknoDJZfbsF9mYNYsbztTdJaXndBrC4ftkxcFHgJl5o1Hor9WVhlth9S86keWUBIMnVYi7lmOvJtZyVvU0q7+D9rarH2fug8i2gQAnt6zx2h6GiC+bAlJztAoGBAI/YxgnqhUJw+ec/sKPwAjW2usGu2b6o8deU153Z3mcpNVG70OpEUW+F3F0S6BBtad1muO41a4cu36AhjO0W4eJV3oQpMwSKEJmwI+1IKGa1JZsQGI5gVAuCvyuV5l57hpc4NhqRBO9m8YwMaV2ItviHCsqGgslDuObVtue0gLtvAoGAWSPjf+POrVy2VJ0yY9zJ6Ks1oGLYzejUTx6Ue/DmjZcOcn6IBGqvgj10D5Gu9vX847GiC8D632A3US4FMLJZki0IxEYWME3d3pg0vhqS24YxZwIo97JvxnrHOqMvd486K+Cyvlft8xgxlZdL79Ez5eec4Lvw7OoQaaoxcdDvf08=';
|
||||||
|
|
||||||
|
protected function setUp(): void
|
||||||
|
{
|
||||||
|
parent::setUp();
|
||||||
|
|
||||||
|
config([
|
||||||
|
'services.wechat.mp.appid' => 'wx-mp-test',
|
||||||
|
'services.wechat.mp.secret' => 'wx-mp-secret',
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 模拟公众号网页授权 code 换 openid 成功 */
|
||||||
|
private function fakeMpOauth(string $openid = 'oMpOpenid001'): void
|
||||||
|
{
|
||||||
|
Http::fake([
|
||||||
|
'https://api.weixin.qq.com/*' => Http::response([
|
||||||
|
'access_token' => 'mp-access-token',
|
||||||
|
'expires_in' => 7200,
|
||||||
|
'refresh_token' => 'mp-refresh-token',
|
||||||
|
'openid' => $openid,
|
||||||
|
'scope' => 'snsapi_base',
|
||||||
|
], 200),
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 以 MockHttpClient 替换微信支付接口应答,并注入容器(控制器经由容器取同一实例) */
|
||||||
|
private function fakeWechatApi(callable $callback): void
|
||||||
|
{
|
||||||
|
$service = app(WechatPayService::class);
|
||||||
|
$service->app()->setHttpClient(new MockHttpClient($callback, 'https://api.mch.weixin.qq.com'));
|
||||||
|
$this->app->instance(WechatPayService::class, $service);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 密钥 PEM 包装(与服务内实现一致) */
|
||||||
|
private function pem(string $body, string $kind): string
|
||||||
|
{
|
||||||
|
return "-----BEGIN {$kind} KEY-----\n" . wordwrap($body, 64, "\n", true) . "\n-----END {$kind} KEY-----";
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 模拟网关加密信封(demo 协议:AES-128-ECB 加密报文 + RSA 公钥加密 AES 密钥)
|
||||||
|
*
|
||||||
|
* @param array<string, mixed> $data 业务报文
|
||||||
|
* @return array<string, string>
|
||||||
|
*/
|
||||||
|
private function gatewayEnvelope(array $data): array
|
||||||
|
{
|
||||||
|
$key = Str::random(16);
|
||||||
|
openssl_public_encrypt($key, $encryptedKey, $this->pem(self::WANGPU_PUBLIC_KEY, 'PUBLIC'));
|
||||||
|
return [
|
||||||
|
'serialNo' => Str::random(32),
|
||||||
|
'version' => '1.0',
|
||||||
|
'timestamp' => now()->format('YmdHis'),
|
||||||
|
'data' => base64_encode((string) openssl_encrypt((string) json_encode($data), 'AES-128-ECB', $key, OPENSSL_RAW_DATA)),
|
||||||
|
'signature' => base64_encode($encryptedKey),
|
||||||
|
'extras' => '',
|
||||||
|
'organizNo' => 'org001',
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 解密我方发往网关的请求信封(断言上送报文用;服务侧 urlencode 需先解码)
|
||||||
|
*
|
||||||
|
* @param array<string, mixed> $body 请求信封
|
||||||
|
* @return array<string, mixed>
|
||||||
|
*/
|
||||||
|
private function decryptRequest(array $body): array
|
||||||
|
{
|
||||||
|
openssl_private_decrypt(
|
||||||
|
(string) base64_decode(urldecode((string) $body['signature'])),
|
||||||
|
$key,
|
||||||
|
$this->pem(self::WANGPU_PRIVATE_KEY, 'PRIVATE')
|
||||||
|
);
|
||||||
|
$plain = openssl_decrypt(
|
||||||
|
(string) base64_decode(urldecode((string) $body['data'])),
|
||||||
|
'AES-128-ECB',
|
||||||
|
(string) $key,
|
||||||
|
OPENSSL_RAW_DATA
|
||||||
|
);
|
||||||
|
return (array) json_decode((string) $plain, true);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 旺铺渠道测试配置 */
|
||||||
|
private function configWangpu(): void
|
||||||
|
{
|
||||||
|
config([
|
||||||
|
'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.public_key' => self::WANGPU_PUBLIC_KEY,
|
||||||
|
'services.wangpu.private_key' => self::WANGPU_PRIVATE_KEY,
|
||||||
|
'services.wangpu.payway_code' => 'WECHAT_MINI',
|
||||||
|
'services.wangpu.mp_payway_code' => 'WECHAT_JSPAY',
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 造一张指定金额的未支付账单(总额=商品金额) */
|
||||||
|
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-09-04',
|
||||||
|
'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));
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 微信官方渠道公众号下单:JSAPI appid=公众号、付款人=公众号 openid、调起参数同构、mp_openid 绑定且不覆盖小程序 openid */
|
||||||
|
public function test_mp_scene_wechat_channel(): void
|
||||||
|
{
|
||||||
|
config([
|
||||||
|
'services.pay.online_channel' => 'wechat',
|
||||||
|
'services.wxpay.mch_id' => '1630000001',
|
||||||
|
'services.wxpay.private_key' => self::WXPAY_PRIVATE_KEY,
|
||||||
|
'services.wxpay.certificate' => self::WXPAY_CERTIFICATE,
|
||||||
|
'services.wxpay.secret_key' => self::WXPAY_SECRET_KEY,
|
||||||
|
]);
|
||||||
|
|
||||||
|
$this->fakeMpOauth();
|
||||||
|
$captured = [];
|
||||||
|
$this->fakeWechatApi(function (string $method, string $url, array $options) use (&$captured) {
|
||||||
|
$captured = compact('method', 'url', 'options');
|
||||||
|
return new MockResponse((string) json_encode(['prepay_id' => 'wx-mp-prepay-001']), ['http_code' => 200]);
|
||||||
|
});
|
||||||
|
|
||||||
|
$store = StoreModel::factory()->create(['openid' => 'oMiniOpenidKeep']);
|
||||||
|
$bill = $this->makeBill($store, '66.00');
|
||||||
|
|
||||||
|
$this->actingAsMiniStore($store);
|
||||||
|
$response = $this->postJson('/mini/payment/online', [
|
||||||
|
'bill_ids' => [$bill->id],
|
||||||
|
'code' => 'mp-oauth-code',
|
||||||
|
'scene' => 'mp',
|
||||||
|
])->assertJsonPath('success', true);
|
||||||
|
|
||||||
|
// 调起参数:appId=公众号 appid,package 含 prepay_id(H5 喂 WeixinJSBridge getBrandWCPayRequest)
|
||||||
|
$payParams = $response->json('data.pay_params');
|
||||||
|
$this->assertSame('wx-mp-test', $payParams['appId']);
|
||||||
|
$this->assertSame('prepay_id=wx-mp-prepay-001', $payParams['package']);
|
||||||
|
$this->assertNotEmpty($payParams['timeStamp']);
|
||||||
|
$this->assertNotEmpty($payParams['nonceStr']);
|
||||||
|
$this->assertSame('RSA', $payParams['signType']);
|
||||||
|
$this->assertNotEmpty($payParams['paySign']);
|
||||||
|
|
||||||
|
// 上送微信报文:appid=公众号 appid、payer=公众号 openid、金额元转分
|
||||||
|
$body = (array) json_decode((string) ($captured['options']['body'] ?? ''), true);
|
||||||
|
$this->assertSame('wx-mp-test', $body['appid'] ?? null);
|
||||||
|
$this->assertSame('1630000001', $body['mchid'] ?? null);
|
||||||
|
$this->assertSame('oMpOpenid001', $body['payer']['openid'] ?? null);
|
||||||
|
$this->assertSame(6600, $body['amount']['total'] ?? null);
|
||||||
|
|
||||||
|
// 支付单与门店绑定:公众号 openid 写 mp_openid,不覆盖小程序 openid
|
||||||
|
$payment = PaymentModel::where('payment_no', $response->json('data.payment_no'))->first();
|
||||||
|
$this->assertSame('oMpOpenid001', $payment->openid);
|
||||||
|
$store->refresh();
|
||||||
|
$this->assertSame('oMpOpenid001', $store->mp_openid);
|
||||||
|
$this->assertSame('oMiniOpenidKeep', $store->openid, '公众号场景不得覆盖小程序 openid');
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 旺铺渠道公众号下单:sub_appid=公众号 appid、payway_code=公众号支付方式代码、open_id=公众号 openid */
|
||||||
|
public function test_mp_scene_wangpu_channel(): void
|
||||||
|
{
|
||||||
|
$this->configWangpu();
|
||||||
|
$this->fakeMpOauth();
|
||||||
|
Http::fake([
|
||||||
|
'https://api.weixin.qq.com/*' => Http::response(['openid' => 'oMpOpenid001', 'access_token' => 'at'], 200),
|
||||||
|
'https://wangpu.test/industrial/payment/order' => Http::response(
|
||||||
|
['code' => '0000', 'msg' => '调用成功'] + $this->gatewayEnvelope([
|
||||||
|
'order_id' => 'WP202609040001',
|
||||||
|
'tradeNo' => 'T20260904001',
|
||||||
|
'user_openid' => 'oMpOpenid001',
|
||||||
|
]),
|
||||||
|
200
|
||||||
|
),
|
||||||
|
]);
|
||||||
|
|
||||||
|
$store = StoreModel::factory()->create();
|
||||||
|
$bill = $this->makeBill($store, '88.00');
|
||||||
|
|
||||||
|
$this->actingAsMiniStore($store);
|
||||||
|
$response = $this->postJson('/mini/payment/online', [
|
||||||
|
'bill_ids' => [$bill->id],
|
||||||
|
'code' => 'mp-oauth-code',
|
||||||
|
'scene' => 'mp',
|
||||||
|
])->assertJsonPath('success', true);
|
||||||
|
|
||||||
|
$paymentNo = $response->json('data.payment_no');
|
||||||
|
$this->assertSame('oMpOpenid001', $store->fresh()->mp_openid);
|
||||||
|
|
||||||
|
// 上送网关的加密信封:解出业务报文校验公众号 sub_appid / 公众号 payway_code / 公众号 openid
|
||||||
|
Http::assertSent(function ($request) use ($paymentNo) {
|
||||||
|
if (! str_contains($request->url(), '/industrial/payment/order')) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
$plain = $this->decryptRequest($request->data());
|
||||||
|
return ($plain['mer_order_id'] ?? '') === $paymentNo
|
||||||
|
&& ($plain['order_amt'] ?? '') === '88.00'
|
||||||
|
&& ($plain['open_id'] ?? '') === 'oMpOpenid001'
|
||||||
|
&& ($plain['sub_appid'] ?? '') === 'wx-mp-test'
|
||||||
|
&& ($plain['payway_code'] ?? '') === 'WECHAT_JSPAY'
|
||||||
|
&& ! empty($plain['notifyurl']);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 公众号网页授权失败:errcode 应答 → 下单拒绝并提示授权失败 */
|
||||||
|
public function test_mp_scene_oauth_failure(): void
|
||||||
|
{
|
||||||
|
Http::fake([
|
||||||
|
'https://api.weixin.qq.com/*' => Http::response(['errcode' => 40029, 'errmsg' => 'invalid code'], 200),
|
||||||
|
]);
|
||||||
|
|
||||||
|
$store = StoreModel::factory()->create();
|
||||||
|
$bill = $this->makeBill($store, '10.00');
|
||||||
|
|
||||||
|
$this->actingAsMiniStore($store);
|
||||||
|
$response = $this->postJson('/mini/payment/online', [
|
||||||
|
'bill_ids' => [$bill->id],
|
||||||
|
'code' => 'bad-code',
|
||||||
|
'scene' => 'mp',
|
||||||
|
])->assertJsonPath('success', false);
|
||||||
|
|
||||||
|
$this->assertStringContainsString('微信网页授权失败', (string) $response->json('msg'));
|
||||||
|
// 授权失败不产生支付单、账单不被锁定
|
||||||
|
$this->assertSame(0, PaymentModel::count());
|
||||||
|
$this->assertSame(0, $bill->fresh()->payment_id);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 公众号未配置 AppID/Secret:明确报错 */
|
||||||
|
public function test_mp_scene_requires_mp_config(): void
|
||||||
|
{
|
||||||
|
config([
|
||||||
|
'services.wechat.mp.appid' => '',
|
||||||
|
'services.wechat.mp.secret' => '',
|
||||||
|
]);
|
||||||
|
|
||||||
|
$store = StoreModel::factory()->create();
|
||||||
|
$bill = $this->makeBill($store, '10.00');
|
||||||
|
|
||||||
|
$this->actingAsMiniStore($store);
|
||||||
|
$response = $this->postJson('/mini/payment/online', [
|
||||||
|
'bill_ids' => [$bill->id],
|
||||||
|
'code' => 'mp-oauth-code',
|
||||||
|
'scene' => 'mp',
|
||||||
|
])->assertJsonPath('success', false);
|
||||||
|
|
||||||
|
$this->assertStringContainsString('微信公众号未配置', (string) $response->json('msg'));
|
||||||
|
$this->assertSame(0, PaymentModel::count());
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 不传 scene 默认走小程序链路(code2session,小程序 appid 下单) */
|
||||||
|
public function test_default_scene_remains_mini(): void
|
||||||
|
{
|
||||||
|
config([
|
||||||
|
'services.wechat.mini.appid' => 'wx-mini-test',
|
||||||
|
'services.wechat.mini.secret' => 'wx-secret-test',
|
||||||
|
'services.pay.online_channel' => 'wechat',
|
||||||
|
'services.wxpay.mch_id' => '1630000001',
|
||||||
|
'services.wxpay.private_key' => self::WXPAY_PRIVATE_KEY,
|
||||||
|
'services.wxpay.certificate' => self::WXPAY_CERTIFICATE,
|
||||||
|
'services.wxpay.secret_key' => self::WXPAY_SECRET_KEY,
|
||||||
|
]);
|
||||||
|
|
||||||
|
Http::fake([
|
||||||
|
'https://api.weixin.qq.com/*' => Http::response(['openid' => 'oMiniOpenid001', 'session_key' => 'sk'], 200),
|
||||||
|
]);
|
||||||
|
$captured = [];
|
||||||
|
$this->fakeWechatApi(function (string $method, string $url, array $options) use (&$captured) {
|
||||||
|
$captured = compact('method', 'url', 'options');
|
||||||
|
return new MockResponse((string) json_encode(['prepay_id' => 'wx-mini-prepay-001']), ['http_code' => 200]);
|
||||||
|
});
|
||||||
|
|
||||||
|
$store = StoreModel::factory()->create();
|
||||||
|
$bill = $this->makeBill($store, '20.00');
|
||||||
|
|
||||||
|
$this->actingAsMiniStore($store);
|
||||||
|
$response = $this->postJson('/mini/payment/online', [
|
||||||
|
'bill_ids' => [$bill->id],
|
||||||
|
'code' => 'wx-login-code',
|
||||||
|
])->assertJsonPath('success', true);
|
||||||
|
|
||||||
|
// 小程序链路:appId=小程序 appid、openid 绑定到 store.openid,mp_openid 保持空
|
||||||
|
$this->assertSame('wx-mini-test', $response->json('data.pay_params.appId'));
|
||||||
|
$body = (array) json_decode((string) ($captured['options']['body'] ?? ''), true);
|
||||||
|
$this->assertSame('wx-mini-test', $body['appid'] ?? null);
|
||||||
|
$store->refresh();
|
||||||
|
$this->assertSame('oMiniOpenid001', $store->openid);
|
||||||
|
$this->assertSame('', (string) $store->mp_openid);
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user