432 lines
16 KiB
PHP
432 lines
16 KiB
PHP
<?php
|
|
|
|
declare(strict_types=1);
|
|
|
|
require dirname(__DIR__) . '/vendor/autoload.php';
|
|
|
|
$dotenv = Dotenv\Dotenv::createImmutable(dirname(__DIR__));
|
|
$dotenv->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;
|
|
}
|
|
|
|
function mockKeyPair(): array
|
|
{
|
|
$key = openssl_pkey_new([
|
|
'private_key_bits' => 2048,
|
|
'private_key_type' => OPENSSL_KEYTYPE_RSA,
|
|
]);
|
|
assertTrue($key !== false, 'mock rsa key generation failed');
|
|
|
|
$privateKey = '';
|
|
assertTrue(openssl_pkey_export($key, $privateKey), 'mock private key export failed');
|
|
$details = openssl_pkey_get_details($key);
|
|
assertTrue(is_array($details) && !empty($details['key']), 'mock public key export failed');
|
|
|
|
return [$privateKey, (string)$details['key']];
|
|
}
|
|
|
|
$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);
|
|
[$mockPrivateKey, $mockPublicKey] = mockKeyPair();
|
|
|
|
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', $mockPrivateKey);
|
|
ensureConfig('payment', 'shouqianba_public_key', $mockPublicKey);
|
|
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);
|
|
}
|