safeLoad(); use app\support\ShouqianbaClient; use app\support\ShouqianbaConfigService; use app\support\ShouqianbaPaymentService; use support\think\Db; Db::setConfig(require dirname(__DIR__) . '/config/think-orm.php'); class MockShouqianbaClient extends ShouqianbaClient { public array $queryData = []; public int $voidCalls = 0; public function purchase(array $body): array { return [ 'request' => ['body' => $body], 'response' => ['response' => ['body' => ['biz_response' => ['data' => [ 'order_sn' => 'SQBMOCKREMOTE' . substr((string)$body['check_sn'], -8), 'order_token' => 'mock_token_' . $body['check_sn'], 'cashier_url' => 'https://cashier.mock/' . $body['check_sn'], ]]]]], 'data' => [ 'order_sn' => 'SQBMOCKREMOTE' . substr((string)$body['check_sn'], -8), 'order_token' => 'mock_token_' . $body['check_sn'], 'cashier_url' => 'https://cashier.mock/' . $body['check_sn'], ], ]; } public function query(array $body): array { return [ 'request' => ['body' => $body], 'response' => ['response' => ['body' => ['biz_response' => ['data' => $this->queryData]]]], 'data' => $this->queryData, ]; } public function void(array $body): array { $this->voidCalls++; return [ 'request' => ['body' => $body], 'response' => ['response' => ['body' => ['biz_response' => ['data' => [ 'order_status' => '0', 'check_sn' => (string)($body['original_check_sn'] ?? ''), ]]]]], 'data' => [ 'order_status' => '0', 'check_sn' => (string)($body['original_check_sn'] ?? ''), ], ]; } public function decodeNotification(string $rawBody): array { $data = json_decode($rawBody, true); if (!is_array($data)) { throw new RuntimeException('mock notification json invalid'); } return $data; } } function assertTrue(bool $condition, string $message): void { if (!$condition) { throw new RuntimeException($message); } } function ensureConfig(string $group, string $key, string $value): void { $now = date('Y-m-d H:i:s'); $exists = Db::name('system_configs') ->where('config_group', $group) ->where('config_key', $key) ->find(); $payload = [ 'config_group' => $group, 'config_key' => $key, 'config_value' => $value, 'remark' => '收钱吧支付 mock 测试配置', 'updated_at' => $now, ]; if ($exists) { Db::name('system_configs')->where('id', (int)$exists['id'])->update($payload); return; } $payload['created_at'] = $now; Db::name('system_configs')->insert($payload); } function captureConfigs(array $keys): array { $snapshot = []; foreach ($keys as $key) { [$group, $configKey] = explode('.', $key, 2); $row = Db::name('system_configs') ->where('config_group', $group) ->where('config_key', $configKey) ->find(); $snapshot[$key] = $row ?: null; } return $snapshot; } function restoreConfigs(array $snapshot): void { foreach ($snapshot as $key => $row) { [$group, $configKey] = explode('.', $key, 2); if ($row) { Db::name('system_configs') ->where('config_group', $group) ->where('config_key', $configKey) ->update([ 'config_value' => (string)($row['config_value'] ?? ''), 'remark' => (string)($row['remark'] ?? '后台系统配置'), 'updated_at' => date('Y-m-d H:i:s'), ]); continue; } Db::name('system_configs') ->where('config_group', $group) ->where('config_key', $configKey) ->delete(); } } function cleanupMockData(): void { $orderIds = Db::name('orders')->whereLike('order_no', 'SQBMOCK%')->column('id'); if ($orderIds) { Db::name('shouqianba_payments')->whereIn('order_id', $orderIds)->delete(); Db::name('message_logs')->where('biz_type', 'order')->whereIn('biz_id', $orderIds)->delete(); Db::name('user_messages')->where('biz_type', 'order')->whereIn('biz_id', $orderIds)->delete(); Db::name('appraisal_tasks')->whereIn('order_id', $orderIds)->delete(); Db::name('order_timelines')->whereIn('order_id', $orderIds)->delete(); Db::name('order_shipping_targets')->whereIn('order_id', $orderIds)->delete(); Db::name('order_return_addresses')->whereIn('order_id', $orderIds)->delete(); $uploadItemIds = Db::name('order_upload_items')->whereIn('order_id', $orderIds)->column('id'); if ($uploadItemIds) { Db::name('order_upload_files')->whereIn('order_upload_item_id', $uploadItemIds)->delete(); } Db::name('order_upload_items')->whereIn('order_id', $orderIds)->delete(); Db::name('order_extras')->whereIn('order_id', $orderIds)->delete(); Db::name('order_products')->whereIn('order_id', $orderIds)->delete(); Db::name('orders')->whereIn('id', $orderIds)->delete(); } $userIds = Db::name('users')->whereLike('mobile', '1399919%')->column('id'); if ($userIds) { Db::name('user_auths')->whereIn('user_id', $userIds)->delete(); Db::name('user_addresses')->whereIn('user_id', $userIds)->delete(); Db::name('users')->whereIn('id', $userIds)->delete(); } } function createMockUser(): int { $now = date('Y-m-d H:i:s'); $userId = (int)Db::name('users')->insertGetId([ 'nickname' => '收钱吧支付测试用户', 'avatar' => '', 'mobile' => '13999190001', 'password' => '', 'status' => 'enabled', 'last_login_at' => null, 'created_at' => $now, 'updated_at' => $now, ]); Db::name('user_addresses')->insert([ 'user_id' => $userId, 'consignee' => '测试用户', 'mobile' => '13999190001', 'province' => '广东省', 'city' => '深圳市', 'district' => '南山区', 'detail_address' => '收钱吧测试地址', 'is_default' => 1, 'created_at' => $now, 'updated_at' => $now, ]); return $userId; } function createMockOrder(int $userId, string $suffix): int { $now = date('Y-m-d H:i:s'); $orderId = (int)Db::name('orders')->insertGetId([ 'order_no' => 'SQBMOCK' . $suffix, 'appraisal_no' => 'SQB-MOCK-' . $suffix, 'user_id' => $userId, 'service_mode' => 'physical', 'service_provider' => 'anxinyan', 'payment_status' => 'unpaid', 'order_status' => 'pending_payment', 'display_status' => '待支付', 'estimated_finish_time' => date('Y-m-d H:i:s', strtotime('+48 hours')), 'source_channel' => 'h5', 'source_customer_id' => '', 'pay_amount' => 9.99, 'paid_at' => null, 'created_at' => $now, 'updated_at' => $now, ]); Db::name('order_products')->insert([ 'order_id' => $orderId, 'category_id' => null, 'category_name' => '测试品类', 'brand_id' => null, 'brand_name' => '测试品牌', 'color' => '', 'size_spec' => '', 'serial_no' => '', 'product_name' => '收钱吧支付测试商品', 'product_cover' => '', 'created_at' => $now, 'updated_at' => $now, ]); Db::name('order_extras')->insert([ 'order_id' => $orderId, 'purchase_channel' => '', 'purchase_price' => 0, 'purchase_date' => null, 'usage_status' => '', 'condition_desc' => '', 'has_accessories' => 0, 'accessories_json' => json_encode([], JSON_UNESCAPED_UNICODE), 'remark' => '', 'created_at' => $now, 'updated_at' => $now, ]); Db::name('order_timelines')->insert([ 'order_id' => $orderId, 'node_code' => 'created', 'node_text' => '订单已生成', 'node_desc' => '订单资料已保存,等待用户完成支付。', 'operator_type' => 'system', 'operator_id' => null, 'occurred_at' => $now, 'created_at' => $now, ]); return $orderId; } function latestPayment(int $orderId): array { $payment = Db::name('shouqianba_payments')->where('order_id', $orderId)->order('id', 'desc')->find(); assertTrue((bool)$payment, 'payment row missing'); return $payment; } $configKeys = [ 'payment.enabled', 'payment.api_domain', 'payment.appid', 'payment.brand_code', 'payment.store_sn', 'payment.store_name', 'payment.workstation_sn', 'payment.industry_code', 'payment.order_expire_minutes', 'payment.merchant_private_key', 'payment.shouqianba_public_key', 'payment.notify_url', 'payment.mini_program_plugin_version', 'h5.page_base_url', ]; $snapshot = captureConfigs($configKeys); $client = new MockShouqianbaClient(new ShouqianbaConfigService()); $service = new ShouqianbaPaymentService(null, $client); try { cleanupMockData(); ensureConfig('payment', 'enabled', 'enabled'); ensureConfig('payment', 'api_domain', 'https://mock.shouqianba.test'); ensureConfig('payment', 'appid', 'sqb_mock_appid'); ensureConfig('payment', 'brand_code', 'mock_brand'); ensureConfig('payment', 'store_sn', 'mock_store'); ensureConfig('payment', 'store_name', 'mock store'); ensureConfig('payment', 'workstation_sn', '0'); ensureConfig('payment', 'industry_code', '0'); ensureConfig('payment', 'order_expire_minutes', '1440'); ensureConfig('payment', 'merchant_private_key', "-----BEGIN PRIVATE KEY-----\nmock\n-----END PRIVATE KEY-----"); ensureConfig('payment', 'shouqianba_public_key', "-----BEGIN PUBLIC KEY-----\nmock\n-----END PUBLIC KEY-----"); ensureConfig('payment', 'notify_url', 'https://api.example.com/api/open/shouqianba/payment/notify'); ensureConfig('payment', 'mini_program_plugin_version', '2.3.70'); ensureConfig('h5', 'page_base_url', 'https://m.example.com'); $userId = createMockUser(); $notifyOrderId = createMockOrder($userId, 'NOTIFY'); $launch = $service->createOrReusePayment($notifyOrderId); assertTrue(($launch['status'] ?? '') === 'pending', 'purchase should create pending payment'); assertTrue(($launch['cashier_url'] ?? '') !== '', 'purchase cashier_url missing'); $payment = latestPayment($notifyOrderId); $requestJson = json_decode((string)$payment['request_json'], true); $purchaseBody = is_array($requestJson) ? ($requestJson['body'] ?? []) : []; assertTrue( ($purchaseBody['return_url'] ?? '') === 'https://m.example.com/?sqb_return_order_id=' . $notifyOrderId . '#/pages/order/detail?id=' . $notifyOrderId, 'purchase return_url should include H5 order detail fallback' ); assertTrue(($purchaseBody['back_url'] ?? '') === ($purchaseBody['return_url'] ?? ''), 'purchase back_url should match return_url'); $staleReturnOrderId = createMockOrder($userId, 'STALERETURN'); $service->createOrReusePayment($staleReturnOrderId); $stalePayment = latestPayment($staleReturnOrderId); $staleRequestJson = json_decode((string)$stalePayment['request_json'], true); if (is_array($staleRequestJson)) { $staleRequestJson['body']['return_url'] = 'https://m.example.com/#/pages/order/detail?id=' . $staleReturnOrderId; $staleRequestJson['body']['back_url'] = $staleRequestJson['body']['return_url']; Db::name('shouqianba_payments')->where('id', (int)$stalePayment['id'])->update([ 'request_json' => json_encode($staleRequestJson, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES), ]); } $replacement = $service->createOrReusePayment($staleReturnOrderId); $stalePaymentAfterReplace = Db::name('shouqianba_payments')->where('id', (int)$stalePayment['id'])->find(); $latestReplacement = latestPayment($staleReturnOrderId); assertTrue((string)($stalePaymentAfterReplace['status'] ?? '') === 'replaced', 'stale H5 payment should be marked replaced'); assertTrue((int)$latestReplacement['id'] !== (int)$stalePayment['id'], 'stale H5 payment should be replaced with a new payment row'); assertTrue(($replacement['check_sn'] ?? '') === (string)$latestReplacement['check_sn'], 'replacement launch payload should use the new payment row'); $notifyPayload = [ 'check_sn' => $payment['check_sn'], 'order_status' => '4', 'amount' => (int)$payment['amount'], 'order_sn' => 'SQBMOCKPAID', ]; $service->handleNotification(json_encode($notifyPayload, JSON_UNESCAPED_UNICODE)); $service->handleNotification(json_encode($notifyPayload, JSON_UNESCAPED_UNICODE)); $paidOrder = Db::name('orders')->where('id', $notifyOrderId)->find(); assertTrue(($paidOrder['payment_status'] ?? '') === 'paid', 'notify should mark order paid'); assertTrue(($paidOrder['order_status'] ?? '') === 'pending_shipping', 'notify should move order to pending_shipping'); assertTrue((int)Db::name('appraisal_tasks')->where('order_id', $notifyOrderId)->count() === 1, 'notify should be idempotent for appraisal task'); assertTrue((int)Db::name('order_timelines')->where('order_id', $notifyOrderId)->where('node_code', 'payment_paid')->count() === 1, 'notify should be idempotent for paid timeline'); $queryOrderId = createMockOrder($userId, 'QUERY'); $service->createOrReusePayment($queryOrderId); $queryPayment = latestPayment($queryOrderId); $client->queryData = [ 'check_sn' => $queryPayment['check_sn'], 'order_status' => '4', 'amount' => (int)$queryPayment['amount'], 'order_sn' => 'SQBMOCKQUERYPAID', ]; $sync = $service->syncOrderPaymentStatus($queryOrderId, $userId); assertTrue(($sync['payment_status'] ?? '') === 'paid', 'query sync should mark paid'); $cancelOrderId = createMockOrder($userId, 'CANCEL'); $service->createOrReusePayment($cancelOrderId); $cancelPayment = latestPayment($cancelOrderId); $client->queryData = [ 'check_sn' => $cancelPayment['check_sn'], 'order_status' => '1', 'amount' => (int)$cancelPayment['amount'], 'order_sn' => 'SQBMOCKCANCELPENDING', ]; $cancel = $service->cancelPendingOrder($cancelOrderId, $userId); assertTrue(($cancel['order_status'] ?? '') === 'cancelled', 'cancel should mark order cancelled'); assertTrue($client->voidCalls === 1, 'cancel should call remote void once'); $paidCancelOrderId = createMockOrder($userId, 'PAIDCANCEL'); $service->createOrReusePayment($paidCancelOrderId); $paidCancelPayment = latestPayment($paidCancelOrderId); $client->queryData = [ 'check_sn' => $paidCancelPayment['check_sn'], 'order_status' => '4', 'amount' => (int)$paidCancelPayment['amount'], 'order_sn' => 'SQBMOCKPAIDCANCEL', ]; $thrown = false; try { $service->cancelPendingOrder($paidCancelOrderId, $userId); } catch (RuntimeException $e) { $thrown = str_contains($e->getMessage(), '已支付'); } $paidCancelOrder = Db::name('orders')->where('id', $paidCancelOrderId)->find(); assertTrue($thrown, 'cancel paid remote order should be rejected'); assertTrue(($paidCancelOrder['payment_status'] ?? '') === 'paid', 'cancel paid remote order should sync paid state'); echo "SHOUQIANBA_PAYMENT_MOCK_TEST_OK\n"; } catch (Throwable $e) { fwrite(STDERR, "SHOUQIANBA_PAYMENT_MOCK_TEST_FAIL: " . $e->getMessage() . "\n"); exit(1); } finally { cleanupMockData(); restoreConfigs($snapshot); }