Files
anxinyan/server-api/tools/shouqianba_payment_mock_test.php
2026-06-04 16:12:59 +08:00

415 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;
}
$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);
}