420 lines
22 KiB
PHP
420 lines
22 KiB
PHP
<?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\WechatPayService;
|
||
use EasyWeChat\Kernel\Support\AesGcm;
|
||
use Illuminate\Support\Facades\Http;
|
||
use Symfony\Component\HttpClient\MockHttpClient;
|
||
use Symfony\Component\HttpClient\Response\MockResponse;
|
||
|
||
/**
|
||
* 小程序在线支付(微信官方渠道 APIv3,EasyWeChat):下单 → 调起支付 → 后台通知/主动查询结账
|
||
*
|
||
* - Symfony MockHttpClient 模拟微信支付接口,不触网;商户私钥/证书为测试专用自签名(与生产无关)
|
||
* - 通知报文以 APIv3 密钥 AES-256-GCM 加密构造,走真实解密链路
|
||
* - 配置平台证书时走真实 Wechatpay-Signature 验签链路
|
||
* - 结账幂等:重复通知/查询不重复累加门店总采购金额
|
||
*/
|
||
class MiniWechatPaymentTest extends ProcurementTestCase
|
||
{
|
||
/** 测试专用商户 API 私钥(自签名,仅测试使用) */
|
||
private const string TEST_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 TEST_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 TEST_SECRET_KEY = 'testSecr3tKey0123456789abcdef01';
|
||
|
||
/** 测试专用平台证书序列号 */
|
||
private const string TEST_PLATFORM_SERIAL = 'TESTPUBKEYID0001';
|
||
|
||
protected function setUp(): void
|
||
{
|
||
parent::setUp();
|
||
|
||
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::TEST_PRIVATE_KEY,
|
||
'services.wxpay.certificate' => self::TEST_CERTIFICATE,
|
||
'services.wxpay.secret_key' => self::TEST_SECRET_KEY,
|
||
]);
|
||
}
|
||
|
||
/**
|
||
* 以 MockHttpClient 替换微信支付接口应答,并注入容器(控制器经由容器取同一实例)
|
||
*
|
||
* @param callable|array<int, MockResponse> $responses 应答回调或应答队列
|
||
*/
|
||
private function fakeWechatApi(callable|array $responses): WechatPayService
|
||
{
|
||
$service = app(WechatPayService::class);
|
||
$app = $service->app();
|
||
$app->setHttpClient(new MockHttpClient($responses, 'https://api.mch.weixin.qq.com'));
|
||
$this->app->instance(WechatPayService::class, $service);
|
||
|
||
return $service;
|
||
}
|
||
|
||
/** 模拟微信 code2session 成功 */
|
||
private function fakeCode2session(string $openid = 'oOpenidTest001'): void
|
||
{
|
||
Http::fake([
|
||
'https://api.weixin.qq.com/*' => Http::response(['openid' => $openid, 'session_key' => 'sk'], 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-09-01',
|
||
'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 makeWechatPayment(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_WECHAT,
|
||
'voucher_ids' => '',
|
||
'status' => PaymentModel::STATUS_PENDING,
|
||
'openid' => 'oOpenidTest001',
|
||
]);
|
||
foreach ($bills as $bill) {
|
||
$bill->update(['payment_id' => $payment->id]);
|
||
}
|
||
return $payment;
|
||
}
|
||
|
||
/**
|
||
* 构造微信支付结果通知报文(交易报文以 APIv3 密钥 AES-256-GCM 加密装入 resource)
|
||
*
|
||
* @param array<string, mixed> $resourceOverrides 交易报文覆盖项
|
||
* @return array<string, mixed> 通知报文
|
||
*/
|
||
private function wechatNotifyBody(PaymentModel $payment, array $resourceOverrides = []): array
|
||
{
|
||
$resource = array_merge([
|
||
'mchid' => '1630000001',
|
||
'appid' => 'wx-mini-test',
|
||
'out_trade_no' => $payment->payment_no,
|
||
'transaction_id' => '4200002501202609010001',
|
||
'trade_state' => 'SUCCESS',
|
||
'trade_state_desc' => '支付成功',
|
||
'success_time' => '2026-09-01T14:00:00+08:00',
|
||
'amount' => [
|
||
'total' => (int) bcmul((string) $payment->amount, '100'),
|
||
'payer_total' => (int) bcmul((string) $payment->amount, '100'),
|
||
'currency' => 'CNY',
|
||
],
|
||
], $resourceOverrides);
|
||
|
||
return [
|
||
'id' => 'EV-202609011400000001',
|
||
'create_time' => '2026-09-01T14:00:05+08:00',
|
||
'resource_type' => 'encrypt-resource',
|
||
'event_type' => 'TRANSACTION.SUCCESS',
|
||
'summary' => '支付成功',
|
||
'resource' => [
|
||
'original_type' => 'transaction',
|
||
'algorithm' => 'AEAD_AES_256_GCM',
|
||
'ciphertext' => AesGcm::encrypt((string) json_encode($resource), self::TEST_SECRET_KEY, 'nonce1234567', 'transaction'),
|
||
'associated_data' => 'transaction',
|
||
'nonce' => 'nonce1234567',
|
||
],
|
||
];
|
||
}
|
||
|
||
/** 以原始报文 + 自定义请求头发送微信通知(验签场景需要精确控制 body 参与签名) */
|
||
private function postWechatNotify(array $body, array $headers = []): \Illuminate\Testing\TestResponse
|
||
{
|
||
$server = ['CONTENT_TYPE' => 'application/json'];
|
||
foreach ($headers as $name => $value) {
|
||
$server['HTTP_' . strtoupper(str_replace('-', '_', $name))] = $value;
|
||
}
|
||
return $this->call('POST', '/mini/payment/wechat-notify', [], [], [], $server, (string) json_encode($body));
|
||
}
|
||
|
||
/** 对通知报文按微信规则签名(平台证书验签场景):timestamp\nnonce\nbody\n */
|
||
private function signNotify(string $body, int $timestamp, string $nonce): string
|
||
{
|
||
openssl_sign("{$timestamp}\n{$nonce}\n{$body}\n", $signature, self::TEST_PRIVATE_KEY, 'sha256WithRSAEncryption');
|
||
return base64_encode($signature);
|
||
}
|
||
|
||
/** 发起微信渠道在线支付:锁定账单、创建支付单、上送报文正确、返回标准 wx.requestPayment 调起参数 */
|
||
public function test_create_wechat_payment_success(): void
|
||
{
|
||
$this->fakeCode2session();
|
||
$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' => 'wx202609011200000001']), ['http_code' => 200]);
|
||
});
|
||
|
||
$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'));
|
||
|
||
// 调起参数为标准 wx.requestPayment 五要素,package 含 prepay_id
|
||
$payParams = $response->json('data.pay_params');
|
||
$this->assertSame('wx-mini-test', $payParams['appId'] ?? null);
|
||
$this->assertNotEmpty($payParams['timeStamp']);
|
||
$this->assertNotEmpty($payParams['nonceStr']);
|
||
$this->assertSame('prepay_id=wx202609011200000001', $payParams['package']);
|
||
$this->assertSame('RSA', $payParams['signType']);
|
||
$this->assertNotEmpty($payParams['paySign']);
|
||
|
||
$payment = PaymentModel::where('payment_no', $paymentNo)->first();
|
||
$this->assertSame(PaymentModel::TYPE_ONLINE, $payment->pay_type);
|
||
$this->assertSame(PaymentModel::METHOD_WECHAT, $payment->pay_method);
|
||
$this->assertSame(PaymentModel::STATUS_PENDING, $payment->status);
|
||
$this->assertSame('', (string) $payment->order_id, '微信渠道下单成功后才回传 transaction_id');
|
||
|
||
// 账单锁定 + openid 绑定门店
|
||
$this->assertSame($payment->id, $bill1->fresh()->payment_id);
|
||
$this->assertSame($payment->id, $bill2->fresh()->payment_id);
|
||
$this->assertSame('oOpenidTest001', $store->fresh()->openid);
|
||
|
||
// 上送微信的报文:JSAPI 下单,金额转换为分,通知地址为微信回调路由
|
||
//(MockHttpClient 回调收到的是预处理后的 options:json 已编码进 body,header 名小写且值为数组)
|
||
$this->assertSame('POST', $captured['method']);
|
||
$this->assertStringContainsString('/v3/pay/transactions/jsapi', $captured['url']);
|
||
$json = (array) json_decode((string) ($captured['options']['body'] ?? ''), true);
|
||
$this->assertSame('wx-mini-test', $json['appid']);
|
||
$this->assertSame('1630000001', $json['mchid']);
|
||
$this->assertSame($paymentNo, $json['out_trade_no']);
|
||
$this->assertSame(15050, $json['amount']['total']);
|
||
$this->assertSame('CNY', $json['amount']['currency']);
|
||
$this->assertSame('oOpenidTest001', $json['payer']['openid']);
|
||
$this->assertStringContainsString('/mini/payment/wechat-notify', $json['notify_url']);
|
||
// 请求头携带商户签名(预处理后的 headers 为 "Name: value" 字符串列表)
|
||
$headerLines = implode("\n", array_map('strval', (array) ($captured['options']['headers'] ?? [])));
|
||
$this->assertStringContainsString('Authorization: WECHATPAY2-SHA256-RSA2048', $headerLines);
|
||
}
|
||
|
||
/** 微信下单失败(应答 4xx):支付单作废并释放账单,可重新发起 */
|
||
public function test_create_wechat_payment_gateway_failure_releases_bills(): void
|
||
{
|
||
$this->fakeCode2session();
|
||
$this->fakeWechatApi([
|
||
new MockResponse((string) json_encode(['code' => 'PARAM_ERROR', 'message' => 'appid 与 mchid 不匹配']), ['http_code' => 400]),
|
||
]);
|
||
|
||
$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, '账单释放可重新付款');
|
||
}
|
||
|
||
/** 微信支付成功通知:AES-GCM 解密 → 幂等结账(账单置已支付 + 累加门店总采购金额 + 通知门店) */
|
||
public function test_wechat_notify_settles_payment(): void
|
||
{
|
||
$store = StoreModel::factory()->create();
|
||
$bill1 = $this->makeBill($store, '100.00');
|
||
$bill2 = $this->makeBill($store, '50.00');
|
||
$payment = $this->makeWechatPayment($store, '150.00', $bill1, $bill2);
|
||
|
||
$this->postWechatNotify($this->wechatNotifyBody($payment))
|
||
->assertJsonPath('code', 'SUCCESS');
|
||
|
||
$payment->refresh();
|
||
$this->assertSame(PaymentModel::STATUS_APPROVED, $payment->status);
|
||
$this->assertSame('4200002501202609010001', $payment->trade_no);
|
||
$this->assertSame('2026-09-01 14:00:00', (string) $payment->paid_at);
|
||
|
||
foreach ([$bill1, $bill2] as $bill) {
|
||
$bill->refresh();
|
||
$this->assertSame(BillModel::STATUS_PAID, $bill->status);
|
||
$this->assertStringContainsString('微信支付', (string) $bill->pay_remark);
|
||
$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->postWechatNotify($this->wechatNotifyBody($payment))
|
||
->assertJsonPath('code', 'SUCCESS');
|
||
$this->assertSame('150.00', (string) $store->fresh()->total_purchase_amount);
|
||
$this->assertSame(1, NoticeModel::where('store_id', $store->id)->count());
|
||
}
|
||
|
||
/** 通知解密失败 / 金额不一致 / 订单号不存在 / 非支付成功状态 / 商户号不一致:应答失败且不结账 */
|
||
public function test_wechat_notify_rejects_invalid_messages(): void
|
||
{
|
||
$store = StoreModel::factory()->create();
|
||
$bill = $this->makeBill($store, '100.00');
|
||
$payment = $this->makeWechatPayment($store, '100.00', $bill);
|
||
|
||
// 密文非法(密钥不匹配)→ 解密失败
|
||
$badBody = $this->wechatNotifyBody($payment);
|
||
$badBody['resource']['ciphertext'] = AesGcm::encrypt('{"out_trade_no":"x"}', str_repeat('x', 32), 'nonce1234567', 'transaction');
|
||
$this->postWechatNotify($badBody)->assertJsonPath('code', 'FAIL');
|
||
|
||
// 金额不一致(防篡改)
|
||
$this->postWechatNotify($this->wechatNotifyBody($payment, ['amount' => ['total' => 9999, 'payer_total' => 9999]]))
|
||
->assertJsonPath('code', 'FAIL');
|
||
|
||
// 订单号不存在
|
||
$this->postWechatNotify($this->wechatNotifyBody($payment, ['out_trade_no' => 'ZF000000000000']))
|
||
->assertJsonPath('code', 'FAIL');
|
||
|
||
// 非支付成功状态
|
||
$this->postWechatNotify($this->wechatNotifyBody($payment, ['trade_state' => 'NOTPAY']))
|
||
->assertJsonPath('code', 'FAIL');
|
||
|
||
// 商户号不一致(防串号)
|
||
$this->postWechatNotify($this->wechatNotifyBody($payment, ['mchid' => '9999999999']))
|
||
->assertJsonPath('code', 'FAIL');
|
||
|
||
// 均未结账
|
||
$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_wechat_notify_with_platform_cert_validates_signature(): void
|
||
{
|
||
config([
|
||
'services.wxpay.platform_cert' => self::TEST_CERTIFICATE,
|
||
'services.wxpay.platform_serial' => self::TEST_PLATFORM_SERIAL,
|
||
]);
|
||
|
||
$store = StoreModel::factory()->create();
|
||
$bill = $this->makeBill($store, '66.00');
|
||
$payment = $this->makeWechatPayment($store, '66.00', $bill);
|
||
|
||
$body = (string) json_encode($this->wechatNotifyBody($payment));
|
||
$timestamp = time();
|
||
$nonce = 'notifyNonce001';
|
||
|
||
// 无签名头 → 验签失败(微信请求必带签名头)
|
||
$this->postWechatNotify($this->wechatNotifyBody($payment))->assertJsonPath('code', 'FAIL');
|
||
|
||
// 签名头齐全且合法 → 受理并结账
|
||
$this->postWechatNotify($this->wechatNotifyBody($payment), [
|
||
'Wechatpay-Timestamp' => (string) $timestamp,
|
||
'Wechatpay-Nonce' => $nonce,
|
||
'Wechatpay-Serial' => self::TEST_PLATFORM_SERIAL,
|
||
'Wechatpay-Signature' => $this->signNotify($body, $timestamp, $nonce),
|
||
])->assertJsonPath('code', 'SUCCESS');
|
||
|
||
$this->assertSame(PaymentModel::STATUS_APPROVED, $payment->fresh()->status);
|
||
$this->assertSame(BillModel::STATUS_PAID, $bill->fresh()->status);
|
||
}
|
||
|
||
/** 主动查询:微信已支付则同步结账;未支付保持待支付;已结账后不再请求微信 */
|
||
public function test_wechat_query_syncs_status(): void
|
||
{
|
||
$store = StoreModel::factory()->create();
|
||
$bill = $this->makeBill($store, '80.00');
|
||
$payment = $this->makeWechatPayment($store, '80.00', $bill);
|
||
|
||
$queryCount = 0;
|
||
$this->fakeWechatApi(function (string $method, string $url) use (&$queryCount, $payment) {
|
||
$queryCount++;
|
||
$this->assertSame('GET', $method);
|
||
$this->assertStringContainsString('/v3/pay/transactions/out-trade-no/' . $payment->payment_no, $url);
|
||
$data = $queryCount === 1
|
||
? ['trade_state' => 'NOTPAY', 'out_trade_no' => $payment->payment_no]
|
||
: [
|
||
'trade_state' => 'SUCCESS',
|
||
'out_trade_no' => $payment->payment_no,
|
||
'transaction_id' => '4200002501202609010002',
|
||
'success_time' => '2026-09-01T15:00:00+08:00',
|
||
'amount' => ['total' => 8000, 'payer_total' => 8000],
|
||
];
|
||
return new MockResponse((string) json_encode($data), ['http_code' => 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', '4200002501202609010002');
|
||
|
||
$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);
|
||
}
|
||
|
||
/** 渠道开关为旺铺(默认)时不受影响:仍走旺铺下单(pay_method=旺铺支付,旺铺通道报错而非微信) */
|
||
public function test_wangpu_channel_remains_default(): void
|
||
{
|
||
config(['services.pay.online_channel' => 'wangpu']);
|
||
Http::fake([
|
||
'https://api.weixin.qq.com/*' => Http::response(['openid' => 'oOpenidTest001'], 200),
|
||
]);
|
||
|
||
$store = StoreModel::factory()->create();
|
||
$bill = $this->makeBill($store, '10.00');
|
||
|
||
$this->actingAsMiniStore($store);
|
||
// 未配置旺铺密钥:旺铺通道在本地装信封即报错(证明分发到旺铺而非微信),支付单作废释放账单
|
||
$this->postJson('/mini/payment/online', ['bill_ids' => [$bill->id], 'code' => 'c'])
|
||
->assertJsonPath('success', false);
|
||
|
||
$payment = PaymentModel::first();
|
||
$this->assertSame(PaymentModel::METHOD_WANGPU, $payment->pay_method);
|
||
$this->assertSame(PaymentModel::STATUS_REJECTED, $payment->status);
|
||
$this->assertSame(0, $bill->fresh()->payment_id);
|
||
}
|
||
}
|