'wx-mini-test', 'services.wechat.mini.secret' => 'wx-secret-test', '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::TEST_PUBLIC_KEY, 'services.wangpu.private_key' => self::TEST_PRIVATE_KEY, 'services.wangpu.payway_code' => 'WECHAT_MINI', 'services.wangpu.sign_key' => 'notify-test-key', ]); } /** 密钥 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::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 $body 请求信封 * @return array */ 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' => '调用成功'] + $this->gatewayEnvelope([ 'order_id' => 'WP202608270001', 'tradeNo' => 'T20260827001', 'user_openid' => $openid, 'wxjsapistr' => (string) json_encode([ 'appId' => 'wx-mini-test', 'timeStamp' => '1724745600', 'nonceStr' => 'nonce001', 'package' => 'prepay_id=wp-prepay-001', 'signType' => 'RSA', 'paySign' => 'sign001', ]), ]), 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-08-27', '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 makeOnlinePayment(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_WANGPU, 'voucher_ids' => '', 'status' => PaymentModel::STATUS_PENDING, 'openid' => 'oOpenidTest001', ]); foreach ($bills as $bill) { $bill->update(['payment_id' => $payment->id]); } return $payment; } /** 构造带 MD5 签名的支付成功通知表单报文(加签算法与官方文档一致,测试侧独立实现) */ private function signedNotifyParams(PaymentModel $payment, array $overrides = []): array { $params = array_merge([ 'mer_order_id' => $payment->payment_no, 'order_status' => '1', 'order_amt' => (string) $payment->amount, 'trade_no' => 'T20260827001', 'order_id' => 'WP202608270001', 'order_time' => '2026-08-27 09:59:00', 'trade_time' => '2026-08-27 10:00:00', 'payway_code' => 'WECHAT_MINI', 'mer_no' => 'mer001', 'device_no' => 'dev001', 'order_title' => '账单合并付款', ], $overrides); $signParams = array_filter($params, static fn ($value): bool => $value !== null && $value !== ''); ksort($signParams, SORT_STRING); $str = implode('&', array_map(static fn ($k, $v) => $k . '=' . $v, array_keys($signParams), $signParams)); $params['sign'] = strtoupper(md5($str . '&key=' . config('services.wangpu.sign_key'))); return $params; } /** AES-128-ECB 加密 golden test:与官方示例 demo/functions.php encryption 算法输出一致 */ public function test_aes_encryption_matches_demo_golden(): void { $key = 'AbCdEfGh12345678'; // 16 字节密钥 $data = ['mer_order_id' => 'ZF202608270001', 'order_amt' => '0.01', 'organiz_no' => '100005']; $service = app(WangpuPayService::class); $method = new ReflectionMethod($service, 'aesEncrypt'); $cipher = $method->invoke($service, $data, $key); // demo 算法直算的固定密文(算法/填充/编码任何偏差都会改变该值) $this->assertSame( 'jl7iWrSNXWQ4D5CCOCENDYG/3JBxiTYWB2XJSMCFGZjfDr5ned8ZLvfXMC+05bVvbBu5aYc6/245g+wuHU0Mo7zUvmMTrPOvgEfHzsnxHa0=', $cipher ); } /** 通知 MD5 验签 golden test:官方文档示例报文 + SignKey 直算签名一致(含中文、空值剔除) */ public function test_notify_sign_matches_doc_golden(): void { config(['services.wangpu.sign_key' => '07714583f82b4db8b675b32cd5e0969743']); $params = [ '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', 'fee' => '', // 空值不参与签名(平台不下发空值数据元) 'sign' => 'A31998F2E0549E0A80B2A4B3A0473784', // 文档示例签名值 ]; $verified = app(WangpuPayService::class)->verifyNotify($params); $this->assertSame($params, $verified); } /** 通知验签失败(签名缺失/错误)抛异常 */ public function test_notify_verify_rejects_bad_sign(): void { $service = app(WangpuPayService::class); $this->expectException(RepositoryException::class); $service->verifyNotify(['mer_order_id' => 'ZF202608270001', 'sign' => 'INVALIDSIGN']); } /** 密钥配置错误:公钥栏误填私钥时给出明确中文报错(而非 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(); $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')); // 调起支付参数取自网关应答 wxjsapistr(小程序 wx.requestPayment 直接透传) $this->assertSame('wx-mini-test', $response->json('data.pay_params.appId')); $this->assertSame('prepay_id=wp-prepay-001', $response->json('data.pay_params.package')); $payment = PaymentModel::where('payment_no', $paymentNo)->first(); $this->assertSame(PaymentModel::TYPE_ONLINE, $payment->pay_type); $this->assertSame(PaymentModel::METHOD_WANGPU, $payment->pay_method); $this->assertSame(PaymentModel::STATUS_PENDING, $payment->status); $this->assertSame('oOpenidTest001', $payment->openid); $this->assertSame('WP202608270001', $payment->order_id); // 账单锁定 + openid 绑定门店 $this->assertSame($payment->id, $bill1->fresh()->payment_id); $this->assertSame($payment->id, $bill2->fresh()->payment_id); $this->assertSame('oOpenidTest001', $store->fresh()->openid); // 上送网关的加密信封:解出业务报文校验商户订单号/金额/openid/商户信息 Http::assertSent(function ($request) use ($paymentNo) { if (! str_contains($request->url(), '/industrial/payment/order')) { return false; } $body = $request->data(); 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']); }); } /** 发起支付校验:缺 code / 非本店账单 / 已支付账单 / 锁定中账单 均拒绝 */ public function test_create_online_payment_validation(): void { $this->fakeGatewaySuccess(); $store = StoreModel::factory()->create(); $other = StoreModel::factory()->create(); $this->actingAsMiniStore($store); // 缺 code $this->postJson('/mini/payment/online', ['bill_ids' => [1]])->assertJsonPath('success', false); // 非本店账单 $otherBill = $this->makeBill($other, '10.00'); $this->postJson('/mini/payment/online', ['bill_ids' => [$otherBill->id], 'code' => 'c']) ->assertJsonPath('success', false); // 已支付账单 $paidBill = $this->makeBill($store, '10.00', ['status' => BillModel::STATUS_PAID]); $this->postJson('/mini/payment/online', ['bill_ids' => [$paidBill->id], 'code' => 'c']) ->assertJsonPath('success', false); // 锁定中账单(已在其他支付单) $lockedBill = $this->makeBill($store, '10.00'); $this->makeOnlinePayment($store, '10.00', $lockedBill); $this->postJson('/mini/payment/online', ['bill_ids' => [$lockedBill->id], 'code' => 'c']) ->assertJsonPath('success', false); // 均未产生新的待支付在线支付单(除锁定用那笔) $this->assertSame(1, PaymentModel::where('pay_type', PaymentModel::TYPE_ONLINE)->count()); } /** 微信 code2session 失败:报错且不产生支付单 */ public function test_create_fails_when_code2session_fails(): void { Http::fake([ 'https://api.weixin.qq.com/*' => Http::response(['errcode' => 40029, 'errmsg' => 'invalid code'], 200), ]); $store = StoreModel::factory()->create(); $bill = $this->makeBill($store, '20.00'); $this->actingAsMiniStore($store); $this->postJson('/mini/payment/online', ['bill_ids' => [$bill->id], 'code' => 'bad-code']) ->assertJsonPath('success', false); $this->assertSame(0, PaymentModel::count()); $this->assertSame(0, $bill->fresh()->payment_id); } /** 网关下单失败:支付单作废(置失败)并释放账单,可重新发起 */ public function test_create_gateway_failure_releases_bills(): void { Http::fake([ 'https://api.weixin.qq.com/*' => Http::response(['openid' => 'oOpenidTest001'], 200), 'https://wangpu.test/*' => Http::response(['code' => '9999', 'msg' => '商户号不存在'], 200), ]); $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, '账单释放可重新付款'); } /** 支付成功通知:验签 → 幂等结账(账单置已支付 + 累加门店总采购金额 + 通知门店) */ public function test_notify_settles_payment(): void { $store = StoreModel::factory()->create(); $bill1 = $this->makeBill($store, '100.00'); $bill2 = $this->makeBill($store, '50.00'); $payment = $this->makeOnlinePayment($store, '150.00', $bill1, $bill2); $this->postJson('/mini/payment/notify', $this->signedNotifyParams($payment)) ->assertJsonPath('code', '00'); $payment->refresh(); $this->assertSame(PaymentModel::STATUS_APPROVED, $payment->status); $this->assertSame('T20260827001', $payment->trade_no); $this->assertSame('WP202608270001', $payment->order_id); $this->assertSame('2026-08-27 10:00:00', (string) $payment->paid_at); foreach ([$bill1, $bill2] as $bill) { $bill->refresh(); $this->assertSame(BillModel::STATUS_PAID, $bill->status); $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->postJson('/mini/payment/notify', $this->signedNotifyParams($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'); // 篡改金额后未重签 → 验签失败 $tampered = $this->signedNotifyParams($payment); $tampered['order_amt'] = '99.99'; $this->postJson('/mini/payment/notify', $tampered)->assertJsonPath('code', '01'); // 金额不一致(防篡改,签名正确但金额与支付单不符) $this->postJson('/mini/payment/notify', $this->signedNotifyParams($payment, ['order_amt' => '99.99'])) ->assertJsonPath('code', '01'); // 订单号不存在 $this->postJson('/mini/payment/notify', $this->signedNotifyParams($payment, ['mer_order_id' => 'ZF000000000000'])) ->assertJsonPath('code', '01'); // 非支付成功状态 $this->postJson('/mini/payment/notify', $this->signedNotifyParams($payment, ['order_status' => '0'])) ->assertJsonPath('code', '01'); // 均未结账 $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_query_syncs_gateway_status(): void { $store = StoreModel::factory()->create(); $bill = $this->makeBill($store, '80.00'); $payment = $this->makeOnlinePayment($store, '80.00', $bill); // 第 1 次查询网关未支付,第 2 次已支付(fake 回调按调用次数返回,避免重复注册被先注册的 stub 拦截) $queryCount = 0; Http::fake([ 'https://wangpu.test/industrial/query/order' => function () use (&$queryCount, $payment) { $queryCount++; $data = $queryCount === 1 ? ['order_status' => 0, 'mer_order_id' => $payment->payment_no, 'order_amt' => '80.00'] : [ 'order_status' => 1, 'mer_order_id' => $payment->payment_no, 'order_amt' => '80.00', 'trade_no' => 'T20260827002', 'order_id' => 'WP202608270002', 'trade_time' => '2026-08-27 11:00:00', ]; return Http::response(['code' => '0000', 'msg' => '调用成功'] + $this->gatewayEnvelope($data), 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', 'T20260827002'); $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); } /** 查询接口隔离:他人支付单不可见 */ public function test_query_rejects_other_store_payment(): void { $store = StoreModel::factory()->create(); $other = StoreModel::factory()->create(); $bill = $this->makeBill($other, '10.00'); $payment = $this->makeOnlinePayment($other, '10.00', $bill); $this->actingAsMiniStore($store); $this->getJson("/mini/payment/online/{$payment->payment_no}/query") ->assertJsonPath('success', false); } /** 现有线下凭证支付流程不受影响:默认 pay_type=1 */ public function test_offline_payment_flow_unaffected(): void { $store = StoreModel::factory()->create(); $bill = $this->makeBill($store, '30.00'); $this->actingAsMiniStore($store); $this->postJson('/mini/payment', [ 'bill_ids' => [$bill->id], 'pay_method' => PaymentModel::METHOD_BANK, 'voucher_ids' => [1], ])->assertJsonPath('success', true); $payment = PaymentModel::first(); $this->assertSame(PaymentModel::TYPE_OFFLINE, $payment->pay_type, '线下凭证支付默认 pay_type=1'); $this->assertSame($payment->id, $bill->fresh()->payment_id); } }