支付优化
This commit is contained in:
@@ -7,18 +7,27 @@ use App\Models\NoticeModel;
|
||||
use App\Models\PaymentModel;
|
||||
use App\Models\PurchaseOrderModel;
|
||||
use App\Models\StoreModel;
|
||||
use App\Exceptions\RepositoryException;
|
||||
use App\Services\WangpuPayService;
|
||||
use Illuminate\Support\Facades\Http;
|
||||
use Illuminate\Support\Str;
|
||||
use ReflectionMethod;
|
||||
|
||||
/**
|
||||
* 小程序在线支付(旺铺网关 JSAPI):下单 → 调起支付 → 后台通知/主动查询结账
|
||||
* 小程序在线支付(旺铺网关行业版):下单 → 调起支付 → 后台通知/主动查询结账
|
||||
*
|
||||
* 通讯协议与官方示例 demo/IndexController.php 一致:
|
||||
* 业务报文 AES-128-ECB 加密(随机 16 位密钥)+ RSA 公钥加密 AES 密钥,JSON 信封传输。
|
||||
*
|
||||
* - Http::fake 模拟微信 code2session 与旺铺网关,不触网
|
||||
* - 结账幂等:重复通知/查询不重复累加门店总采购金额
|
||||
*/
|
||||
class MiniOnlinePaymentTest extends ProcurementTestCase
|
||||
{
|
||||
private const string SIGN_KEY = 'test-wangpu-sign-key';
|
||||
/** 测试专用 RSA 密钥对(仅测试使用,与生产密钥无关;公私钥同源便于加解密回环) */
|
||||
private const string TEST_PUBLIC_KEY = 'MIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEAqZ4z7UatOnbBolcpFhW2q585II7atHK9w/opQ/k8QUqjwkwIVNYzC9qf/SFDoOnMBufZwS2tM8UUPcgNoy0hbmR5QqBsp9ugBaSL1ZHb5jStgpikzWEjFkyVR5WLtxl4nxKv0R5pV/mfq0RXsIpbutZqugnXRvHPYaqbKomXzf0FKlxmbpvPOXsH9L9rYrnOSKQ1t16vAa6UrqUdkgdQBH7+hI4kiDwY0MIZVOhDhB4r0ODrpWMd0U3yrbhwpHZdtF2B3dtn3JSS92F16Yo7Hec8+TUzNKX0Mu1rn07F6yGZeUf+5/egXsBc2Mx1TgZswFlvkmHS96kbH9gqJyGOVwIDAQAB';
|
||||
|
||||
private const string TEST_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
|
||||
{
|
||||
@@ -32,21 +41,74 @@ class MiniOnlinePaymentTest extends ProcurementTestCase
|
||||
'services.wangpu.mer_no' => 'mer001',
|
||||
'services.wangpu.mer_code' => 'code001',
|
||||
'services.wangpu.term_code' => 'term001',
|
||||
'services.wangpu.sign_key' => self::SIGN_KEY,
|
||||
'services.wangpu.public_key' => self::TEST_PUBLIC_KEY,
|
||||
'services.wangpu.private_key' => self::TEST_PRIVATE_KEY,
|
||||
'services.wangpu.payway_code' => 'WECHAT_MINI',
|
||||
]);
|
||||
}
|
||||
|
||||
/** 密钥 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::TEST_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::TEST_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);
|
||||
}
|
||||
|
||||
/** 模拟微信 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),
|
||||
'https://wangpu.test/industrial/payment/order' => Http::response(
|
||||
['code' => '0000', 'msg' => '调用成功'] + $this->gatewayEnvelope([
|
||||
'order_id' => 'WP202608270001',
|
||||
'tradeNo' => 'T20260827001',
|
||||
'user_openid' => $openid,
|
||||
]),
|
||||
200
|
||||
),
|
||||
]);
|
||||
}
|
||||
|
||||
@@ -89,10 +151,10 @@ class MiniOnlinePaymentTest extends ProcurementTestCase
|
||||
return $payment;
|
||||
}
|
||||
|
||||
/** 构造已加签的支付成功通知报文 */
|
||||
private function signedNotifyParams(PaymentModel $payment, array $overrides = []): array
|
||||
/** 构造加密的支付成功通知信封 */
|
||||
private function encryptedNotifyParams(PaymentModel $payment, array $overrides = []): array
|
||||
{
|
||||
$params = array_merge([
|
||||
return $this->gatewayEnvelope(array_merge([
|
||||
'mer_order_id' => $payment->payment_no,
|
||||
'order_status' => '1',
|
||||
'order_amt' => (string) $payment->amount,
|
||||
@@ -104,36 +166,53 @@ class MiniOnlinePaymentTest extends ProcurementTestCase
|
||||
'mer_no' => 'mer001',
|
||||
'device_no' => 'dev001',
|
||||
'order_title' => '账单合并付款',
|
||||
], $overrides);
|
||||
$params['sign'] = app(WangpuPayService::class)->sign($params);
|
||||
return $params;
|
||||
], $overrides));
|
||||
}
|
||||
|
||||
/** 旺铺加签算法:按接口文档示例 golden test(MD5 升序拼接 + key) */
|
||||
public function test_sign_matches_document_example(): void
|
||||
/** AES-128-ECB 加密 golden test:与官方示例 demo/functions.php encryption 算法输出一致 */
|
||||
public function test_aes_encryption_matches_demo_golden(): void
|
||||
{
|
||||
config(['services.wangpu.sign_key' => '07714583f82b4db8b675b32cd5e0969743']);
|
||||
$key = 'AbCdEfGh12345678'; // 16 字节密钥
|
||||
$data = ['mer_order_id' => 'ZF202608270001', 'order_amt' => '0.01', 'organiz_no' => '100005'];
|
||||
|
||||
$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',
|
||||
]);
|
||||
$service = app(WangpuPayService::class);
|
||||
$method = new ReflectionMethod($service, 'aesEncrypt');
|
||||
$cipher = $method->invoke($service, $data, $key);
|
||||
|
||||
$this->assertSame('A31998F2E0549E0A80B2A4B3A0473784', $sign);
|
||||
// demo 算法直算的固定密文(算法/填充/编码任何偏差都会改变该值)
|
||||
$this->assertSame(
|
||||
'jl7iWrSNXWQ4D5CCOCENDYG/3JBxiTYWB2XJSMCFGZjfDr5ned8ZLvfXMC+05bVvbBu5aYc6/245g+wuHU0Mo7zUvmMTrPOvgEfHzsnxHa0=',
|
||||
$cipher
|
||||
);
|
||||
}
|
||||
|
||||
/** 发起在线支付:锁定账单、创建支付单、上送网关参数正确、openid 绑定到门店 */
|
||||
/** 信封加解密回环:网关侧加密(demo 算法)→ 服务解密还原业务报文 */
|
||||
public function test_notify_envelope_round_trip(): void
|
||||
{
|
||||
$params = [
|
||||
'mer_order_id' => 'ZF202608270001',
|
||||
'order_status' => '1',
|
||||
'order_amt' => '150.50',
|
||||
'order_title' => '账单合并付款', // 中文报文
|
||||
];
|
||||
|
||||
$decrypted = app(WangpuPayService::class)->decryptNotify($this->gatewayEnvelope($params));
|
||||
|
||||
$this->assertSame($params, $decrypted);
|
||||
}
|
||||
|
||||
/** 密钥配置错误:公钥栏误填私钥时给出明确中文报错(而非 openssl 警告) */
|
||||
public function test_swapped_key_config_fails_with_clear_message(): void
|
||||
{
|
||||
config(['services.wangpu.public_key' => self::TEST_PRIVATE_KEY]); // 公钥栏误填私钥
|
||||
|
||||
$this->expectException(RepositoryException::class);
|
||||
$this->expectExceptionMessage('旺铺平台公钥配置错误(当前内容是私钥,请检查是否填反)');
|
||||
|
||||
app(WangpuPayService::class)->createOrder(['mer_order_id' => 'ZF202609010001', 'order_amt' => '0.01']);
|
||||
}
|
||||
|
||||
/** 发起在线支付:锁定账单、创建支付单、上送网关报文正确、openid 绑定到门店 */
|
||||
public function test_create_online_payment_success(): void
|
||||
{
|
||||
$this->fakeGatewaySuccess();
|
||||
@@ -163,16 +242,30 @@ class MiniOnlinePaymentTest extends ProcurementTestCase
|
||||
$this->assertSame($payment->id, $bill2->fresh()->payment_id);
|
||||
$this->assertSame('oOpenidTest001', $store->fresh()->openid);
|
||||
|
||||
// 上送网关的报文:商户订单号=支付单号、金额、openid、带签名
|
||||
Http::assertSent(static function ($request) use ($paymentNo) {
|
||||
// 上送网关的加密信封:解出业务报文校验商户订单号/金额/openid/商户信息
|
||||
Http::assertSent(function ($request) use ($paymentNo) {
|
||||
if (! str_contains($request->url(), '/industrial/payment/order')) {
|
||||
return false;
|
||||
}
|
||||
$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']);
|
||||
if (($body['organizNo'] ?? '') !== 'org001'
|
||||
|| empty($body['serialNo'])
|
||||
|| ($body['version'] ?? '') !== '1.0'
|
||||
|| empty($body['timestamp'])
|
||||
) {
|
||||
return false;
|
||||
}
|
||||
$plain = $this->decryptRequest($body);
|
||||
return ($plain['mer_order_id'] ?? '') === $paymentNo
|
||||
&& ($plain['order_amt'] ?? '') === '150.50'
|
||||
&& ($plain['open_id'] ?? '') === 'oOpenidTest001'
|
||||
&& ($plain['sub_appid'] ?? '') === 'wx-mini-test'
|
||||
&& ($plain['mer_no'] ?? '') === 'mer001'
|
||||
&& ($plain['mer_code'] ?? '') === 'code001'
|
||||
&& ($plain['term_code'] ?? '') === 'term001'
|
||||
&& ($plain['payway_code'] ?? '') === 'WECHAT_MINI'
|
||||
&& ($plain['organiz_no'] ?? '') === 'org001'
|
||||
&& ! empty($plain['notifyurl']);
|
||||
});
|
||||
}
|
||||
|
||||
@@ -243,7 +336,7 @@ class MiniOnlinePaymentTest extends ProcurementTestCase
|
||||
$this->assertSame(0, $bill->fresh()->payment_id, '账单释放可重新付款');
|
||||
}
|
||||
|
||||
/** 支付成功通知:验签通过 → 幂等结账(账单置已支付 + 累加门店总采购金额 + 通知门店) */
|
||||
/** 支付成功通知:解密信封 → 幂等结账(账单置已支付 + 累加门店总采购金额 + 通知门店) */
|
||||
public function test_notify_settles_payment(): void
|
||||
{
|
||||
$store = StoreModel::factory()->create();
|
||||
@@ -251,7 +344,7 @@ class MiniOnlinePaymentTest extends ProcurementTestCase
|
||||
$bill2 = $this->makeBill($store, '50.00');
|
||||
$payment = $this->makeOnlinePayment($store, '150.00', $bill1, $bill2);
|
||||
|
||||
$this->postJson('/mini/payment/notify', $this->signedNotifyParams($payment))
|
||||
$this->postJson('/mini/payment/notify', $this->encryptedNotifyParams($payment))
|
||||
->assertJsonPath('code', '00');
|
||||
|
||||
$payment->refresh();
|
||||
@@ -273,34 +366,34 @@ class MiniOnlinePaymentTest extends ProcurementTestCase
|
||||
);
|
||||
|
||||
// 重复通知幂等:仍应答成功,金额不重复累加
|
||||
$this->postJson('/mini/payment/notify', $this->signedNotifyParams($payment))
|
||||
$this->postJson('/mini/payment/notify', $this->encryptedNotifyParams($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');
|
||||
// 信封 signature 非法 → 解密失败
|
||||
$badEnvelope = $this->encryptedNotifyParams($payment);
|
||||
$badEnvelope['signature'] = 'INVALIDSIGN';
|
||||
$this->postJson('/mini/payment/notify', $badEnvelope)->assertJsonPath('code', '01');
|
||||
|
||||
// 金额不一致(防篡改)
|
||||
$this->postJson('/mini/payment/notify', $this->signedNotifyParams($payment, ['order_amt' => '99.99']))
|
||||
$this->postJson('/mini/payment/notify', $this->encryptedNotifyParams($payment, ['order_amt' => '99.99']))
|
||||
->assertJsonPath('code', '01');
|
||||
|
||||
// 订单号不存在
|
||||
$this->postJson('/mini/payment/notify', $this->signedNotifyParams($payment, ['mer_order_id' => 'ZF000000000000']))
|
||||
$this->postJson('/mini/payment/notify', $this->encryptedNotifyParams($payment, ['mer_order_id' => 'ZF000000000000']))
|
||||
->assertJsonPath('code', '01');
|
||||
|
||||
// 非支付成功状态
|
||||
$this->postJson('/mini/payment/notify', $this->signedNotifyParams($payment, ['order_status' => '0']))
|
||||
$this->postJson('/mini/payment/notify', $this->encryptedNotifyParams($payment, ['order_status' => '0']))
|
||||
->assertJsonPath('code', '01');
|
||||
|
||||
// 均未结账
|
||||
@@ -331,7 +424,7 @@ class MiniOnlinePaymentTest extends ProcurementTestCase
|
||||
'order_id' => 'WP202608270002',
|
||||
'trade_time' => '2026-08-27 11:00:00',
|
||||
];
|
||||
return Http::response(['code' => '0000', 'msg' => '调用成功', 'data' => $data], 200);
|
||||
return Http::response(['code' => '0000', 'msg' => '调用成功'] + $this->gatewayEnvelope($data), 200);
|
||||
},
|
||||
]);
|
||||
|
||||
|
||||
Reference in New Issue
Block a user