'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 $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 $resourceOverrides 交易报文覆盖项 * @return array 通知报文 */ 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); } }