'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 $data 业务报文 * @return array */ 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 $body 请求信封 * @return array */ 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); } }