Эх сурвалжийг харах

在线充值:新增超时状态,处理中超过30分钟自动标记超时

- payment_orders 新增状态 7超时;新增 payment:pay-timeout 每分钟把在线充值处理中超过30分钟的订单改为超时
- 支付回调与后台查单接受处理中/超时两种状态,迟到的支付仍可入账
- 三斤账单、三方充值报表补充超时文案;修改支付公司密钥时不再被超时订单阻挡
- 迁移更新 status 字段注释,新增测试

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
doge 11 цаг өмнө
parent
commit
9083ebd7a6

+ 32 - 0
app/Console/Commands/PaymentPayTimeout.php

@@ -0,0 +1,32 @@
+<?php
+
+namespace App\Console\Commands;
+
+use App\Models\PaymentOrder;
+use App\Services\AdminRealtime\AdminRealtime;
+use App\Services\PaymentOrderService;
+use Illuminate\Console\Command;
+
+class PaymentPayTimeout extends Command
+{
+    protected $signature = 'payment:pay-timeout';
+
+    protected $description = '在线充值订单处理中超过30分钟未支付的标记为超时(迟到的支付回调仍可入账)';
+
+    public function handle()
+    {
+        $timeoutAt = date('Y-m-d H:i:s', strtotime('-' . PaymentOrderService::PAY_TIMEOUT_MINUTES . ' minutes'));
+        do {
+            $ids = PaymentOrder::where('type', PaymentOrderService::TYPE_PAY)
+                ->where('status', PaymentOrderService::STATUS_PROCESS)
+                ->where('created_at', '<', $timeoutAt)
+                ->limit(500)->pluck('id')->all();
+            if (!$ids) break;
+            // 带状态条件更新,避免覆盖同一时刻回调已改成成功/失败的订单
+            PaymentOrder::whereIn('id', $ids)->where('status', PaymentOrderService::STATUS_PROCESS)
+                ->update(['status' => PaymentOrderService::STATUS_TIMEOUT]);
+            AdminRealtime::record('payment_orders', $ids);
+        } while (count($ids) === 500);
+        return 0;
+    }
+}

+ 1 - 0
app/Console/Kernel.php

@@ -40,6 +40,7 @@ class Kernel extends ConsoleKernel
         $schedule->command('rewards:sync-bases')->everyMinute()->withoutOverlapping(30);
         $schedule->command('share:settle --reconcile')->everyFiveMinutes()->withoutOverlapping(120);
         $schedule->command('share:settle')->timezone('Asia/Shanghai')->dailyAt('00:10')->withoutOverlapping(180);
+        $schedule->command('payment:pay-timeout')->everyMinute()->withoutOverlapping(10);
         $schedule->command('admin-realtime:prune')->hourly()->withoutOverlapping(30);
     }
 

+ 1 - 1
app/Http/Controllers/admin/PaymentOrder.php

@@ -189,7 +189,7 @@ class PaymentOrder extends Controller
      * @apiParam {int=0,1,2} type 类型 0全部 1=代收,2=代付
      * @apiParam {String} order_no 订单号
      * @apiPaaram {String} member_id 会员ID
-     * @apiParam {int=0,1,2,3} [status] 状态:0待处理 1处理中 2成功 3失败
+     * @apiParam {int=1,2,3,7} [status] 状态:1处理中 2成功 3失败 7超时(处理中超过30分钟由 payment:pay-timeout 标记)
      *
      */
     public function index()

+ 1 - 1
app/Services/Payment/PaymentProviderConfig.php

@@ -250,7 +250,7 @@ class PaymentProviderConfig
     private static function assertNoPendingOrders(string $code): void
     {
         $orders = PaymentOrder::query()->whereIn('type', [PaymentOrderService::TYPE_PAY, PaymentOrderService::TYPE_PAYOUT])
-            ->whereNotIn('status', [PaymentOrderService::STATUS_SUCCESS, PaymentOrderService::STATUS_FAIL, PaymentOrderService::STATUS_CANCEL])
+            ->whereNotIn('status', [PaymentOrderService::STATUS_SUCCESS, PaymentOrderService::STATUS_FAIL, PaymentOrderService::STATUS_CANCEL, PaymentOrderService::STATUS_TIMEOUT])
             ->lockForUpdate()->get(['type', 'channel']);
         foreach ($orders as $order) {
             if (PaymentProviderService::providerForRequest((string)$order->channel, (int)$order->type) === $code) {

+ 10 - 6
app/Services/PaymentOrderService.php

@@ -36,6 +36,10 @@ class PaymentOrderService extends BaseService
     const STATUS_USER = 4; // 待用户提交凭证
     const STATUS_AUDIT = 5; //待人工审核
     const STATUS_CANCEL = 6; //已撤回
+    const STATUS_TIMEOUT = 7; //超时(在线充值处理中超过 PAY_TIMEOUT_MINUTES 未支付,仍可接收迟到的回调)
+
+    const PAY_TIMEOUT_MINUTES = 30;
+    const PAY_OPEN_STATUSES = [self::STATUS_PROCESS, self::STATUS_TIMEOUT]; // 在线充值可接收回调/查询入账的状态
 
     public static string $MODEL = PaymentOrder::class;
 
@@ -500,7 +504,7 @@ class PaymentOrderService extends BaseService
                 ]);
                 return false;
             }
-            if ($info->status != self::STATUS_PROCESS) {
+            if (!in_array((int)$info->status, self::PAY_OPEN_STATUSES, true)) {
                 return true;
             }
 
@@ -545,7 +549,7 @@ class PaymentOrderService extends BaseService
                 ]);
                 return false;
             }
-            if ($info->status != self::STATUS_PROCESS) {
+            if (!in_array((int)$info->status, self::PAY_OPEN_STATUSES, true)) {
                 return true;
             }
 
@@ -575,7 +579,7 @@ class PaymentOrderService extends BaseService
             if (bccomp(JdPayService::amount($info->amount), JdPayService::amount($params['amount'] ?? 0), 2) !== 0) {
                 return false;
             }
-            if ($info->status != self::STATUS_PROCESS) {
+            if (!in_array((int)$info->status, self::PAY_OPEN_STATUSES, true)) {
                 return true;
             }
 
@@ -616,7 +620,7 @@ class PaymentOrderService extends BaseService
                     return false;
                 }
 
-                if ($info->status != self::STATUS_PROCESS) {
+                if (!in_array((int)$info->status, self::PAY_OPEN_STATUSES, true)) {
                     return false;
                 }
 
@@ -665,7 +669,7 @@ class PaymentOrderService extends BaseService
     {
         return DB::transaction(function () use ($info, $payAmount, $status, $successStatus, $failStatus, $params) {
             $order = PaymentOrder::where('id', $info->id)->lockForUpdate()->first();
-            if (!$order || (int)$order->type !== self::TYPE_PAY || $order->status != self::STATUS_PROCESS) {
+            if (!$order || (int)$order->type !== self::TYPE_PAY || !in_array((int)$order->status, self::PAY_OPEN_STATUSES, true)) {
                 return false;
             }
 
@@ -1508,7 +1512,7 @@ class PaymentOrderService extends BaseService
         $msg = [];
         $msg['code'] = self::NOT;
         $info = self::findOne(['id' => $id]);
-        if ($info && $info->status == self::STATUS_PROCESS) {
+        if ($info && (int)$info->type === self::TYPE_PAY && in_array((int)$info->status, self::PAY_OPEN_STATUSES, true)) {
             if (NoPayService::isRechargeChannel($info->channel)) {
                 $ret = NoPayService::queryPayOrder($info->order_no, (string)$info->member_id);
                 Log::channel('payment')->info('NO支付查询订单', $ret);

+ 2 - 8
app/Services/SanJinRechargeService.php

@@ -181,14 +181,8 @@ class SanJinRechargeService extends BaseService
                 $text .= lang("充值类型").":".lang($item->bank_name)."\n";
             }
 
-            $status = [lang('待处理'), lang('处理中'), lang('成功'), lang('失败')];
-            
-            if($item->type == 1 && $item->created_at < date('Y-m-d H:i:s',time() - 600) && $item->status == 1){
-                $text .= lang('状态').":".lang("已超时")."\n";
-            }else{
-                $text .= lang('状态').":{$status[$item->status]}\n";
-
-            }
+            $status = [lang('待处理'), lang('处理中'), lang('成功'), lang('失败'), 7 => lang('已超时')];
+            $text .= lang('状态').":".($status[$item->status] ?? '')."\n";
             if ($item->remark) {
                 $text .= lang('说明').":{$item->remark}\n";
             }

+ 1 - 0
app/Services/ThirdPartyDepositService.php

@@ -29,6 +29,7 @@ class ThirdPartyDepositService
         PaymentOrderService::STATUS_PROCESS => '处理中',
         PaymentOrderService::STATUS_SUCCESS => '成功',
         PaymentOrderService::STATUS_FAIL => '失败',
+        PaymentOrderService::STATUS_TIMEOUT => '超时',
     ];
 
     private static ?bool $hasPlatform = null;

+ 25 - 0
database/migrations/2026_11_03_180000_add_timeout_status_to_payment_orders.php

@@ -0,0 +1,25 @@
+<?php
+
+use Illuminate\Database\Migrations\Migration;
+use Illuminate\Database\Schema\Blueprint;
+use Illuminate\Support\Facades\Schema;
+
+return new class extends Migration
+{
+    public function up(): void
+    {
+        // 历史上一直挂在“处理中”的在线充值订单由 payment:pay-timeout 首次运行时批量转为 7超时。
+        Schema::table('payment_orders', function (Blueprint $table) {
+            $table->tinyInteger('status')->default(0)
+                ->comment('状态:0待处理 1处理中 2成功 3失败 4待用户提交凭证 5待人工审核 6已撤回 7超时')->change();
+        });
+    }
+
+    public function down(): void
+    {
+        Schema::table('payment_orders', function (Blueprint $table) {
+            $table->tinyInteger('status')->default(0)
+                ->comment('状态:0待处理 1处理中 2成功 3失败 4待用户提交凭证 5待人工审核 6已撤回')->change();
+        });
+    }
+};

+ 73 - 0
tests/Integration/PaymentPayTimeoutTest.php

@@ -0,0 +1,73 @@
+<?php
+
+namespace Tests\Integration;
+
+use App\Console\Commands\PaymentPayTimeout;
+use App\Models\PaymentOrder;
+use App\Services\PaymentOrderService;
+use Illuminate\Config\Repository;
+use Illuminate\Database\Capsule\Manager;
+use Illuminate\Database\Schema\Blueprint;
+use Illuminate\Foundation\Application;
+use Illuminate\Support\Facades\DB;
+use Illuminate\Support\Facades\Facade;
+use PHPUnit\Framework\TestCase;
+
+/** 在线充值超时:定时任务把处理中超过30分钟的订单标记为7超时,迟到的回调仍会处理。 */
+class PaymentPayTimeoutTest extends TestCase
+{
+    protected function setUp(): void
+    {
+        parent::setUp();
+        Facade::clearResolvedInstances();
+        $app = new Application(dirname(__DIR__, 2));
+        $app->instance('config', new Repository(['app' => ['timezone' => 'Asia/Shanghai'], 'admin_realtime' => ['enabled' => false]]));
+        Facade::setFacadeApplication($app);
+        $db = new Manager($app);
+        $db->addConnection(['driver' => 'sqlite', 'database' => ':memory:', 'prefix' => 'bot_']);
+        $db->setAsGlobal();
+        $db->bootEloquent();
+        $app->instance('db', $db->getDatabaseManager());
+        $db->schema()->create('payment_orders', function (Blueprint $t) {
+            $t->id(); $t->string('member_id'); $t->integer('type'); $t->integer('status'); $t->string('state')->nullable();
+            $t->text('callback_data')->nullable(); $t->decimal('amount', 24, 10); $t->timestamps();
+        });
+        $old = date('Y-m-d H:i:s', strtotime('-2 hours'));
+        $fresh = date('Y-m-d H:i:s', strtotime('-5 minutes'));
+        foreach ([
+            [1, 1, $old],    // 1 → 超时
+            [1, 1, $fresh],  // 2 未到时限,仍处理中
+            [1, 2, $old],    // 3 已成功不动
+            [3, 1, $old],    // 4 人工充值不受影响
+        ] as [$type, $status, $at]) {
+            DB::table('payment_orders')->insert(['member_id' => 'm1', 'type' => $type, 'status' => $status,
+                'amount' => 10, 'created_at' => $at, 'updated_at' => $at]);
+        }
+    }
+
+    protected function tearDown(): void
+    {
+        DB::disconnect();
+        Facade::clearResolvedInstances();
+        parent::tearDown();
+    }
+
+    public function test_command_marks_only_overdue_online_pay_orders_as_timeout(): void
+    {
+        (new PaymentPayTimeout())->handle();
+
+        $this->assertSame([1 => 7, 2 => 1, 3 => 2, 4 => 1],
+            DB::table('payment_orders')->orderBy('id')->pluck('status', 'id')->map(fn($s) => (int)$s)->all());
+    }
+
+    public function test_late_callback_is_still_applied_to_timeout_order(): void
+    {
+        (new PaymentPayTimeout())->handle();
+        $apply = new \ReflectionMethod(PaymentOrderService::class, 'applyPayCallback');
+
+        $processed = $apply->invoke(null, PaymentOrder::find(1), '10', '2', '3', '2', []);
+
+        $this->assertTrue($processed);
+        $this->assertSame(PaymentOrderService::STATUS_FAIL, (int)DB::table('payment_orders')->where('id', 1)->value('status'));
+    }
+}