Quellcode durchsuchen

删除通道、公司管理

doge vor 5 Tagen
Ursprung
Commit
f85db1c1e6

+ 54 - 0
app/Http/Controllers/admin/PaymentCompany.php

@@ -0,0 +1,54 @@
+<?php
+
+namespace App\Http\Controllers\admin;
+
+use App\Constants\HttpStatus;
+use App\Http\Controllers\Controller;
+use App\Services\Payment\PaymentProviderCatalog;
+use App\Services\Payment\PaymentProviderService;
+use Illuminate\Database\Eloquent\ModelNotFoundException;
+use Illuminate\Validation\Rule;
+use Illuminate\Validation\ValidationException;
+
+class PaymentCompany extends Controller
+{
+    public function list(PaymentProviderService $service)
+    {
+        return $this->run(function () use ($service) {
+            $params = request()->validate([
+                'page' => ['nullable', 'integer', 'min:1'], 'limit' => ['nullable', 'integer', 'min:1', 'max:100'],
+                'payment_company' => ['nullable', Rule::in(PaymentProviderCatalog::codes())],
+                'status' => ['nullable', 'integer', 'in:0,1'], 'data_type' => ['nullable', 'integer', 'in:1,2'],
+            ]);
+            return $service->listing($params);
+        });
+    }
+
+    public function status(PaymentProviderService $service)
+    {
+        return $this->run(function () use ($service) {
+            $params = request()->validate([
+                'payment_company' => ['required', Rule::in(PaymentProviderCatalog::codes())],
+                'status' => ['required', 'integer', 'in:0,1'],
+            ]);
+            $service->setStatus($params['payment_company'], (int)$params['status']);
+            return [];
+        });
+    }
+
+    private function run(callable $callback)
+    {
+        try {
+            return $this->success($callback());
+        } catch (ValidationException $e) {
+            return $this->error(HttpStatus::CUSTOM_ERROR, $e->validator->errors()->first());
+        } catch (ModelNotFoundException $e) {
+            return $this->error(HttpStatus::CUSTOM_ERROR, '支付公司不存在');
+        } catch (\PDOException $e) {
+            report($e);
+            return $this->error(HttpStatus::CUSTOM_ERROR, '支付公司操作失败,请稍后重试');
+        } catch (\InvalidArgumentException | \RuntimeException $e) {
+            return $this->error(HttpStatus::CUSTOM_ERROR, $e->getMessage());
+        }
+    }
+}

+ 2 - 1
app/Http/Controllers/admin/PaymentConfiguration.php

@@ -13,6 +13,7 @@ use App\Models\RechargeChannelGroup;
 use App\Services\OperationAuditService;
 use App\Services\OperationAuditService;
 use App\Services\PaymentChannelLinkService;
 use App\Services\PaymentChannelLinkService;
 use App\Services\Payment\PaymentProviderCatalog;
 use App\Services\Payment\PaymentProviderCatalog;
+use App\Services\Payment\PaymentProviderService;
 use Illuminate\Database\Eloquent\Builder;
 use Illuminate\Database\Eloquent\Builder;
 use Illuminate\Support\Facades\DB;
 use Illuminate\Support\Facades\DB;
 use Illuminate\Validation\Rule;
 use Illuminate\Validation\Rule;
@@ -39,7 +40,7 @@ class PaymentConfiguration extends Controller
                 'payment_method' => ['nullable', 'string', 'max:64'],
                 'payment_method' => ['nullable', 'string', 'max:64'],
                 'collection_method' => ['nullable', 'string', 'max:64'],
                 'collection_method' => ['nullable', 'string', 'max:64'],
             ]);
             ]);
-            return (new PaymentProviderCatalog())->options(
+            return (new PaymentProviderCatalog(PaymentProviderService::statuses()))->options(
                 RechargeChannel::query()->orderBy('data_type')->orderBy('sort')->orderBy('id')->get()->toArray(),
                 RechargeChannel::query()->orderBy('data_type')->orderBy('sort')->orderBy('id')->get()->toArray(),
                 RechargeChannelGroup::query()->orderBy('id')->get()->toArray(),
                 RechargeChannelGroup::query()->orderBy('id')->get()->toArray(),
                 $filters
                 $filters

+ 46 - 24
app/Http/Controllers/admin/RechargeChannel.php

@@ -7,6 +7,8 @@ use App\Models\RechargeChannel as RechargeChannelModel;
 use App\Models\RechargeChannelGroup;
 use App\Models\RechargeChannelGroup;
 use App\Services\Payment\PaymentProviderCatalog;
 use App\Services\Payment\PaymentProviderCatalog;
 use App\Services\Payment\RechargeChannelConfigurationService;
 use App\Services\Payment\RechargeChannelConfigurationService;
+use App\Services\Payment\PaymentProviderService;
+use Illuminate\Support\Facades\DB;
 use Illuminate\Validation\Rule;
 use Illuminate\Validation\Rule;
 use Illuminate\Validation\ValidationException;
 use Illuminate\Validation\ValidationException;
 use Illuminate\Database\Eloquent\ModelNotFoundException;
 use Illuminate\Database\Eloquent\ModelNotFoundException;
@@ -15,6 +17,23 @@ use App\Constants\HttpStatus;
 
 
 class RechargeChannel extends Controller
 class RechargeChannel extends Controller
 {
 {
+    public function delete(RechargeChannelConfigurationService $service)
+    {
+        try {
+            $params = request()->validate(['id' => ['required', 'integer', 'min:1']]);
+            $service->delete((int)$params['id']);
+            return $this->success();
+        } catch (ValidationException $e) {
+            return $this->error(HttpStatus::CUSTOM_ERROR, $e->validator->errors()->first());
+        } catch (ModelNotFoundException $e) {
+            return $this->error(HttpStatus::CUSTOM_ERROR, '通道不存在');
+        } catch (\PDOException $e) {
+            report($e);
+            return $this->error(HttpStatus::CUSTOM_ERROR, '删除通道失败,请稍后重试');
+        } catch (Exception $e) {
+            return $this->error(HttpStatus::CUSTOM_ERROR, $e->getMessage());
+        }
+    }
 
 
     public function options(RechargeChannelConfigurationService $service)
     public function options(RechargeChannelConfigurationService $service)
     {
     {
@@ -78,32 +97,34 @@ class RechargeChannel extends Controller
             $params = request()->validate([
             $params = request()->validate([
                 'id' => ['nullable','integer'],
                 'id' => ['nullable','integer'],
                 'name' => ['required','string'],
                 'name' => ['required','string'],
-                'recharge_type' => ['required','array'],
-                'withdraw_type' => ['required','array'],
+                'recharge_type' => ['present','array'],
+                'withdraw_type' => ['present','array'],
                 'activity_type' => ['nullable','array'],
                 'activity_type' => ['nullable','array'],
             ]);
             ]);
-            foreach (['recharge_type' => 1, 'withdraw_type' => 2, 'activity_type' => 3] as $field => $dataType) {
-                if ($field === 'activity_type' && !request()->exists('activity_type')) {
-                    unset($params['activity_type']);
-                    continue;
+            DB::transaction(function () use ($params) {
+                foreach (['recharge_type' => 1, 'withdraw_type' => 2, 'activity_type' => 3] as $field => $dataType) {
+                    if ($field === 'activity_type' && !request()->exists('activity_type')) {
+                        unset($params['activity_type']);
+                        continue;
+                    }
+                    $types = array_values(array_unique(array_filter(array_map('strval', $params[$field] ?? []))));
+                    if ($types) {
+                        $existingCount = RechargeChannelModel::query()->where('data_type', $dataType)
+                            ->whereIn('type', $types)->distinct()->lockForUpdate()->get(['type'])->count();
+                        if ($existingCount !== count($types)) throw new Exception("{$field} 包含不存在的通道类型");
+                    }
+                    $params[$field] = $types;
                 }
                 }
-                $types = array_values(array_unique(array_filter(array_map('strval', $params[$field] ?? []))));
-                if ($types) {
-                    $existingCount = RechargeChannelModel::query()->where('data_type', $dataType)
-                        ->whereIn('type', $types)->distinct()->count('type');
-                    if ($existingCount !== count($types)) throw new Exception("{$field} 包含不存在的通道类型");
+
+                if (empty($params['id'])) {
+                    RechargeChannelGroup::create($params);
+                } else {
+                    $info = RechargeChannelGroup::where('id', $params['id'])->first();
+                    if (!$info) throw new Exception('数据不存在');
+                    $info->update($params);
+                    $info->save();
                 }
                 }
-                $params[$field] = $types;
-            }
-            
-            if (empty($params['id'])) {
-                RechargeChannelGroup::create($params);
-            } else {
-                $info = RechargeChannelGroup::where('id', $params['id'])->first();
-                if (!$info) throw new Exception('数据不存在');
-                $info->update($params);
-                $info->save();
-            }
+            }, 3);
             
             
             return $this->success();
             return $this->success();
         } catch (Exception $e) {
         } catch (Exception $e) {
@@ -152,13 +173,14 @@ class RechargeChannel extends Controller
                     PaymentProviderCatalog::channelTypesForProvider($params['payment_company'], $direction));
                     PaymentProviderCatalog::channelTypesForProvider($params['payment_company'], $direction));
             }
             }
             $count = $query->count();
             $count = $query->count();
+            $companyStatuses = PaymentProviderService::statuses();
             $list = $query
             $list = $query
                 ->forPage($page, $limit)
                 ->forPage($page, $limit)
                 ->orderBy('sort', 'asc')
                 ->orderBy('sort', 'asc')
                 ->orderBy('id', 'asc')
                 ->orderBy('id', 'asc')
-                ->get()->map(function (RechargeChannelModel $channel) {
+                ->get()->map(function (RechargeChannelModel $channel) use ($companyStatuses) {
                     $row = $channel->toArray();
                     $row = $channel->toArray();
-                    return $row + PaymentProviderCatalog::channelDisplay($row);
+                    return $row + PaymentProviderCatalog::channelDisplay($row, $companyStatuses);
                 });
                 });
         } catch (Exception $e) {
         } catch (Exception $e) {
             return $this->error(HttpStatus::CUSTOM_ERROR,$e->getMessage());
             return $this->error(HttpStatus::CUSTOM_ERROR,$e->getMessage());

+ 11 - 0
app/Models/PaymentProvider.php

@@ -0,0 +1,11 @@
+<?php
+
+namespace App\Models;
+
+class PaymentProvider extends BaseModel
+{
+    protected $table = 'payment_providers';
+    protected $fillable = ['code', 'status'];
+    protected $hidden = [];
+    protected $casts = ['status' => 'integer'];
+}

+ 29 - 6
app/Models/RechargeChannel.php

@@ -3,14 +3,32 @@
 namespace App\Models;
 namespace App\Models;
 
 
 use App\Services\Payment\PaymentProviderCatalog;
 use App\Services\Payment\PaymentProviderCatalog;
+use App\Services\Payment\PaymentProviderService;
+use Illuminate\Database\Eloquent\SoftDeletes;
 
 
 class RechargeChannel extends BaseModel
 class RechargeChannel extends BaseModel
 {
 {
+    use SoftDeletes;
 
 
     protected $table = 'recharge_channel';
     protected $table = 'recharge_channel';
     protected $fillable = ['id', 'data_type', 'from', 'key', 'name', 'type', 'rate', 'min', 'max', 'fixed', 'sort', 'status'];
     protected $fillable = ['id', 'data_type', 'from', 'key', 'name', 'type', 'rate', 'min', 'max', 'fixed', 'sort', 'status'];
     protected $hidden = [];
     protected $hidden = [];
 
 
+    public function scopeAvailable($query)
+    {
+        $query->where('status', 1);
+        $statuses = PaymentProviderService::statuses();
+        foreach (PaymentProviderCatalog::codes() as $code) {
+            if (($statuses[$code] ?? 0) === 1) continue;
+            foreach ([1, 2] as $direction) {
+                $types = PaymentProviderCatalog::channelTypesForProvider($code, $direction);
+                if ($types === []) continue;
+                $query->whereNot(fn ($part) => $part->where('data_type', $direction)->whereIn('type', $types));
+            }
+        }
+        return $query;
+    }
+
     public function getFixedAttribute($value)
     public function getFixedAttribute($value)
     {
     {
         return $value ? explode(',', $value) : null; 
         return $value ? explode(',', $value) : null; 
@@ -22,8 +40,13 @@ class RechargeChannel extends BaseModel
         if ($type) {
         if ($type) {
             $query = $query->whereIn('type', $type);
             $query = $query->whereIn('type', $type);
         }
         }
-        $channel = $query->orderBy('sort')->orderBy('id')->get(['type', 'name', 'from', 'data_type'])
-            ->map(fn ($row) => $row->toArray() + PaymentProviderCatalog::channelDisplay($row->toArray()))->all();
+        $statuses = PaymentProviderService::statuses();
+        $channel = $query->orderBy('sort')->orderBy('id')->get(['type', 'name', 'from', 'data_type', 'status'])
+            ->map(function ($row) use ($statuses) {
+                $data = $row->toArray() + PaymentProviderCatalog::channelDisplay($row->toArray(), $statuses);
+                return $data + ['disabled' => $data['company_status'] === 0,
+                    'disabled_reason' => $data['company_status'] === 0 ? '支付公司已禁用' : ''];
+            })->all();
         $channel = array_column($channel, null, 'type');
         $channel = array_column($channel, null, 'type');
         return array_values($channel);
         return array_values($channel);
     }
     }
@@ -36,14 +59,14 @@ class RechargeChannel extends BaseModel
         if($from){
         if($from){
             $where['from'] = $from;
             $where['from'] = $from;
         }
         }
-        $list = self::where($where)->orderBy('sort', 'asc')->get()->toArray();
+        $list = self::where($where)->available()->orderBy('sort', 'asc')->get()->toArray();
         return array_column($list, null, 'key');
         return array_column($list, null, 'key');
     }
     }
 
 
     public static function getFormatChannel($data_type, $recharge_channel_group_id = 1)
     public static function getFormatChannel($data_type, $recharge_channel_group_id = 1)
     {
     {
         $recharge_channel_group_id = $recharge_channel_group_id > 0 ? $recharge_channel_group_id : 1;
         $recharge_channel_group_id = $recharge_channel_group_id > 0 ? $recharge_channel_group_id : 1;
-        $query = self::where(['status' => 1, 'data_type' => $data_type]);
+        $query = self::where('data_type', $data_type)->available();
         
         
         $field = 'recharge_type'; //充值类型
         $field = 'recharge_type'; //充值类型
         if ($data_type == 2) {
         if ($data_type == 2) {
@@ -108,7 +131,7 @@ class RechargeChannel extends BaseModel
             return false;
             return false;
         }
         }
         return self::query()->where('data_type', 2)->where('type', $type)
         return self::query()->where('data_type', 2)->where('type', $type)
-            ->where('status', 1)->orderBy('sort')->first() ?: false;
+            ->available()->orderBy('sort')->first() ?: false;
     }
     }
     
     
     //校验是否支持此充值方式
     //校验是否支持此充值方式
@@ -120,6 +143,6 @@ class RechargeChannel extends BaseModel
             return false;
             return false;
         }
         }
         return self::query()->where('data_type', 1)->where('type', $type)
         return self::query()->where('data_type', 1)->where('type', $type)
-            ->where('status', 1)->orderBy('sort')->first() ?: false;
+            ->available()->orderBy('sort')->first() ?: false;
     }
     }
 }
 }

+ 40 - 9
app/Services/Payment/PaymentProviderCatalog.php

@@ -7,6 +7,10 @@ use InvalidArgumentException;
 /** 支付商及协议目录。独立于商户配置表;只登记已实现的支付服务。 */
 /** 支付商及协议目录。独立于商户配置表;只登记已实现的支付服务。 */
 class PaymentProviderCatalog
 class PaymentProviderCatalog
 {
 {
+    public function __construct(private ?array $providerStatuses = null)
+    {
+    }
+
     private const PROVIDERS = [
     private const PROVIDERS = [
         'sanjin' => ['label' => '三斤支付', 'directions' => [1], 'signature' => 'MD5'],
         'sanjin' => ['label' => '三斤支付', 'directions' => [1], 'signature' => 'MD5'],
         'qianbao' => ['label' => '钱宝支付', 'directions' => [2], 'signature' => 'MD5'],
         'qianbao' => ['label' => '钱宝支付', 'directions' => [2], 'signature' => 'MD5'],
@@ -46,6 +50,11 @@ class PaymentProviderCatalog
         return array_keys(self::PROVIDERS);
         return array_keys(self::PROVIDERS);
     }
     }
 
 
+    public static function definitions(): array
+    {
+        return self::PROVIDERS;
+    }
+
     public static function label(string $code): string
     public static function label(string $code): string
     {
     {
         return self::PROVIDERS[$code]['label'] ?? $code;
         return self::PROVIDERS[$code]['label'] ?? $code;
@@ -64,19 +73,23 @@ class PaymentProviderCatalog
     }
     }
 
 
     /** Definitions for creating channels, intentionally independent of existing channels/groups. */
     /** Definitions for creating channels, intentionally independent of existing channels/groups. */
-    public static function channelFormOptions(int $dataType): array
+    public static function channelFormOptions(int $dataType, ?array $statuses = null): array
     {
     {
         if (!in_array($dataType, [1, 2, 3], true)) throw new InvalidArgumentException('通道方向错误');
         if (!in_array($dataType, [1, 2, 3], true)) throw new InvalidArgumentException('通道方向错误');
         $companies = [];
         $companies = [];
         $types = [];
         $types = [];
         foreach (self::PROVIDERS as $code => $provider) {
         foreach (self::PROVIDERS as $code => $provider) {
             if (in_array($dataType, $provider['directions'], true)) {
             if (in_array($dataType, $provider['directions'], true)) {
-                $companies[] = ['value' => $code, 'label' => $provider['label']];
+                $enabled = $statuses === null || ($statuses[$code] ?? 0) === 1;
+                $companies[] = ['value' => $code, 'label' => $provider['label'], 'status' => $enabled ? 1 : 0,
+                    'disabled' => !$enabled, 'disabled_reason' => $enabled ? '' : '支付公司已禁用'];
             }
             }
         }
         }
         foreach (self::METHODS[$dataType] ?? [] as $type => [$company, $label]) {
         foreach (self::METHODS[$dataType] ?? [] as $type => [$company, $label]) {
+            $enabled = $statuses === null || ($statuses[$company] ?? 0) === 1;
             $types[] = ['value' => $type, 'label' => $label, 'from' => 1,
             $types[] = ['value' => $type, 'label' => $label, 'from' => 1,
-                'payment_company' => $company, 'payment_company_label' => self::label($company)];
+                'payment_company' => $company, 'payment_company_label' => self::label($company),
+                'disabled' => !$enabled, 'disabled_reason' => $enabled ? '' : '支付公司已禁用'];
         }
         }
         $local = match ($dataType) {
         $local = match ($dataType) {
             1 => ['usdt' => [2, 'USDT充值'], 'rgcz' => [3, '人工充值']],
             1 => ['usdt' => [2, 'USDT充值'], 'rgcz' => [3, '人工充值']],
@@ -85,7 +98,7 @@ class PaymentProviderCatalog
         };
         };
         foreach ($local as $type => [$from, $label]) {
         foreach ($local as $type => [$from, $label]) {
             $types[] = ['value' => $type, 'label' => $label, 'from' => $from,
             $types[] = ['value' => $type, 'label' => $label, 'from' => $from,
-                'payment_company' => null, 'payment_company_label' => ''];
+                'payment_company' => null, 'payment_company_label' => '', 'disabled' => false, 'disabled_reason' => ''];
         }
         }
         return [
         return [
             'data_type' => $dataType,
             'data_type' => $dataType,
@@ -114,14 +127,17 @@ class PaymentProviderCatalog
         return $data;
         return $data;
     }
     }
 
 
-    public static function channelDisplay(array $channel): array
+    public static function channelDisplay(array $channel, ?array $statuses = null): array
     {
     {
         $identity = self::channelIdentity($channel);
         $identity = self::channelIdentity($channel);
         $label = $identity ? self::label($identity['provider_code']) : '';
         $label = $identity ? self::label($identity['provider_code']) : '';
+        $companyStatus = $identity ? ($statuses === null ? 1 : (int)($statuses[$identity['provider_code']] ?? 0)) : null;
         return [
         return [
             'payment_company' => $identity['provider_code'] ?? null,
             'payment_company' => $identity['provider_code'] ?? null,
             'payment_company_label' => $label,
             'payment_company_label' => $label,
             'display_name' => ($label === '' ? '' : $label . ' / ') . (string)$channel['name'],
             'display_name' => ($label === '' ? '' : $label . ' / ') . (string)$channel['name'],
+            'company_status' => $companyStatus,
+            'effective_status' => (int)($channel['status'] ?? 1) === 1 && $companyStatus !== 0 && empty($channel['deleted_at']) ? 1 : 0,
         ];
         ];
     }
     }
 
 
@@ -183,6 +199,7 @@ class PaymentProviderCatalog
             }
             }
             $identity = self::channelIdentity($channel);
             $identity = self::channelIdentity($channel);
             $enabled = (int)$channel['status'] === 1;
             $enabled = (int)$channel['status'] === 1;
+            $companyEnabled = !$identity || $this->companyEnabled($identity['provider_code']);
             $formatted[] = [
             $formatted[] = [
                 'id' => (int)$channel['id'], 'data_type' => $dataType,
                 'id' => (int)$channel['id'], 'data_type' => $dataType,
                 'name' => (string)$channel['name'], 'key' => (string)$channel['key'], 'type' => (string)$channel['type'],
                 'name' => (string)$channel['name'], 'key' => (string)$channel['key'], 'type' => (string)$channel['type'],
@@ -191,12 +208,13 @@ class PaymentProviderCatalog
                 'min_amount' => $channel['min'] ?? null, 'max_amount' => $channel['max'] ?? null,
                 'min_amount' => $channel['min'] ?? null, 'max_amount' => $channel['max'] ?? null,
                 'fixed_amounts' => self::types($channel['fixed'] ?? []),
                 'fixed_amounts' => self::types($channel['fixed'] ?? []),
                 'sort' => (int)$channel['sort'], 'status' => (int)$channel['status'],
                 'sort' => (int)$channel['sort'], 'status' => (int)$channel['status'],
+                'company_status' => $identity ? ($companyEnabled ? 1 : 0) : null,
                 'available_group_ids' => $ids,
                 'available_group_ids' => $ids,
                 'provider_code' => $identity['provider_code'] ?? null,
                 'provider_code' => $identity['provider_code'] ?? null,
                 'payment_method' => $identity['method_code'] ?? null,
                 'payment_method' => $identity['method_code'] ?? null,
                 'payment_method_label' => $identity['method_label'] ?? null,
                 'payment_method_label' => $identity['method_label'] ?? null,
-                'selectable' => $identity && $enabled && $ids !== [],
-                'unavailable_reason' => !$identity ? '不属于第三方支付通道' : (!$enabled ? '通道已停用' : ($ids === [] ? '没有包含此通道的组合' : '')),
+                'selectable' => $identity && $companyEnabled && $enabled && $ids !== [],
+                'unavailable_reason' => !$identity ? '不属于第三方支付通道' : (!$companyEnabled ? '支付公司已禁用' : (!$enabled ? '通道已停用' : ($ids === [] ? '没有包含此通道的组合' : ''))),
             ];
             ];
         }
         }
 
 
@@ -228,7 +246,7 @@ class PaymentProviderCatalog
                 'value' => $code, 'label' => $provider['label'],
                 'value' => $code, 'label' => $provider['label'],
                 'signature_algorithm' => $provider['signature'],
                 'signature_algorithm' => $provider['signature'],
                 'secret_field' => $direction === null ? null : ($direction === 2 ? 'withdrawal_secret' : 'deposit_secret'),
                 'secret_field' => $direction === null ? null : ($direction === 2 ? 'withdrawal_secret' : 'deposit_secret'),
-            ] + $this->availability($matching);
+            ] + $this->companyAvailability($code, $matching);
         }
         }
         return $result;
         return $result;
     }
     }
@@ -247,12 +265,25 @@ class PaymentProviderCatalog
                     'value' => $code, 'label' => $label, 'provider_code' => $company, 'data_type' => $dataType,
                     'value' => $code, 'label' => $label, 'provider_code' => $company, 'data_type' => $dataType,
                     'channel_ids' => array_values(array_column($matching, 'id')),
                     'channel_ids' => array_values(array_column($matching, 'id')),
                     'selectable_channel_ids' => array_values(array_column($selectable, 'id')),
                     'selectable_channel_ids' => array_values(array_column($selectable, 'id')),
-                ] + $this->availability($matching);
+                ] + $this->companyAvailability($company, $matching);
             }
             }
         }
         }
         return $result;
         return $result;
     }
     }
 
 
+    private function companyEnabled(string $code): bool
+    {
+        return $this->providerStatuses === null || ($this->providerStatuses[$code] ?? 0) === 1;
+    }
+
+    private function companyAvailability(string $code, array $channels): array
+    {
+        if (!$this->companyEnabled($code)) {
+            return ['disabled' => true, 'disabled_reason_code' => 'provider_disabled', 'disabled_reason' => '支付公司已禁用'];
+        }
+        return $this->availability($channels);
+    }
+
     private function availability(array $channels): array
     private function availability(array $channels): array
     {
     {
         if (array_filter($channels, static fn ($channel) => $channel['selectable']) !== []) {
         if (array_filter($channels, static fn ($channel) => $channel['selectable']) !== []) {

+ 77 - 0
app/Services/Payment/PaymentProviderService.php

@@ -0,0 +1,77 @@
+<?php
+
+namespace App\Services\Payment;
+
+use App\Models\PaymentProvider;
+use App\Models\RechargeChannel;
+use App\Services\OperationAuditService;
+use Illuminate\Support\Facades\DB;
+use InvalidArgumentException;
+use RuntimeException;
+
+class PaymentProviderService
+{
+    public static function statuses(): array
+    {
+        // No process-wide cache: long-running workers must see the latest switch.
+        return PaymentProvider::query()->pluck('status', 'code')->map(fn ($status) => (int)$status)->all();
+    }
+
+    public static function assertEnabled(string $code): void
+    {
+        if (!in_array($code, PaymentProviderCatalog::codes(), true)) throw new InvalidArgumentException('支付公司不存在');
+        if ((self::statuses()[$code] ?? 0) !== 1) throw new RuntimeException(PaymentProviderCatalog::label($code) . '已禁用');
+    }
+
+    public static function requestEnabled(string $channel, int $direction): bool
+    {
+        // Mirror actual dispatch, including historical aliases accepted by provider services.
+        if (JdPayService::isChannel($channel)) $code = 'jd';
+        elseif ($direction === 1 ? NoPayService::isRechargeChannel($channel) : NoPayService::isWithdrawChannel($channel)) $code = 'no';
+        elseif ($direction === 1 ? ZimuPayService::isRechargeChannel($channel) : ZimuPayService::isPayoutChannel($channel)) $code = 'zimu';
+        else $code = $direction === 1 ? 'sanjin' : 'qianbao';
+        return (self::statuses()[$code] ?? 0) === 1;
+    }
+
+    public function listing(array $params): array
+    {
+        $channels = RechargeChannel::query()->get(['from', 'data_type', 'type', 'status'])->toArray();
+        $definitions = PaymentProviderCatalog::definitions();
+        $rows = PaymentProvider::query()->whereIn('code', array_keys($definitions))->orderBy('id')->get()
+            ->map(function ($provider) use ($definitions, $channels) {
+                $code = $provider->code;
+                $definition = $definitions[$code];
+                $matching = array_filter($channels, fn ($channel) => (PaymentProviderCatalog::channelIdentity($channel)['provider_code'] ?? null) === $code);
+                return [
+                    'id' => (int)$provider->id, 'payment_company' => $code, 'payment_company_label' => $definition['label'],
+                    'data_types' => $definition['directions'], 'signature_algorithm' => $definition['signature'],
+                    'status' => (int)$provider->status, 'status_text' => $provider->status === 1 ? '启用' : '禁用',
+                    'channel_count' => count($matching),
+                    'enabled_channel_count' => count(array_filter($matching, fn ($channel) => (int)$channel['status'] === 1)),
+                    'created_at' => $provider->created_at, 'updated_at' => $provider->updated_at,
+                ];
+            })->filter(function ($row) use ($params) {
+                if (!empty($params['payment_company']) && $row['payment_company'] !== $params['payment_company']) return false;
+                if (isset($params['status']) && $row['status'] !== (int)$params['status']) return false;
+                return empty($params['data_type']) || in_array((int)$params['data_type'], $row['data_types'], true);
+            })->values();
+        $page = (int)($params['page'] ?? 1);
+        $limit = (int)($params['limit'] ?? 20);
+        return ['total' => $rows->count(), 'data' => $rows->forPage($page, $limit)->values()->all()];
+    }
+
+    public function setStatus(string $code, int $status): void
+    {
+        if (!in_array($code, PaymentProviderCatalog::codes(), true) || !in_array($status, [0, 1], true)) {
+            throw new InvalidArgumentException('支付公司或状态错误');
+        }
+        DB::transaction(function () use ($code, $status) {
+            $provider = PaymentProvider::query()->where('code', $code)->lockForUpdate()->firstOrFail();
+            if ($provider->status === $status) return;
+            $before = $provider->toArray();
+            $provider->status = $status;
+            $provider->save();
+            OperationAuditService::record('payment_company', (int)$provider->id, 'update', $before, $provider);
+        }, 3);
+    }
+}

+ 39 - 1
app/Services/Payment/RechargeChannelConfigurationService.php

@@ -3,6 +3,8 @@
 namespace App\Services\Payment;
 namespace App\Services\Payment;
 
 
 use App\Models\RechargeChannel;
 use App\Models\RechargeChannel;
+use App\Models\RechargeChannelGroup;
+use App\Services\OperationAuditService;
 use Illuminate\Support\Facades\DB;
 use Illuminate\Support\Facades\DB;
 use Illuminate\Support\Facades\Schema;
 use Illuminate\Support\Facades\Schema;
 use RuntimeException;
 use RuntimeException;
@@ -11,7 +13,7 @@ class RechargeChannelConfigurationService
 {
 {
     public function options(int $dataType, ?int $id = null): array
     public function options(int $dataType, ?int $id = null): array
     {
     {
-        $options = PaymentProviderCatalog::channelFormOptions($dataType);
+        $options = PaymentProviderCatalog::channelFormOptions($dataType, PaymentProviderService::statuses());
         $reason = '';
         $reason = '';
         if ($id) {
         if ($id) {
             $channel = RechargeChannel::query()->findOrFail($id);
             $channel = RechargeChannel::query()->findOrFail($id);
@@ -31,6 +33,9 @@ class RechargeChannelConfigurationService
                 throw new RuntimeException('不能修改通道所属的充值、提现或活动方向');
                 throw new RuntimeException('不能修改通道所属的充值、提现或活动方向');
             }
             }
             $params = PaymentProviderCatalog::normalizeChannelSelection($params);
             $params = PaymentProviderCatalog::normalizeChannelSelection($params);
+            $identityChanged = !$channel->exists || (int)$channel->from !== $params['from'] || (string)$channel->type !== (string)$params['type'];
+            $identity = PaymentProviderCatalog::channelIdentity($params);
+            if ($identityChanged && $identity) PaymentProviderService::assertEnabled($identity['provider_code']);
             if ($channel->exists && ((int)$channel->from !== $params['from'] || (string)$channel->type !== (string)$params['type'])) {
             if ($channel->exists && ((int)$channel->from !== $params['from'] || (string)$channel->type !== (string)$params['type'])) {
                 $reason = $this->identityLockReason($channel, true);
                 $reason = $this->identityLockReason($channel, true);
                 if ($reason !== '') throw new RuntimeException($reason);
                 if ($reason !== '') throw new RuntimeException($reason);
@@ -51,6 +56,39 @@ class RechargeChannelConfigurationService
         }, 3);
         }, 3);
     }
     }
 
 
+    public function delete(int $id): void
+    {
+        DB::transaction(function () use ($id) {
+            $channel = RechargeChannel::withTrashed()->lockForUpdate()->findOrFail($id);
+            if (!in_array((int)$channel->data_type, [1, 2], true)) throw new RuntimeException('只支持删除充值或提现通道');
+            if ($channel->trashed()) return;
+            if ($this->identityLockReason($channel, true) !== '') {
+                throw new RuntimeException('该通道已被支付配置引用,请先解除关联再删除');
+            }
+            $before = $channel->toArray();
+            $channel->status = 0;
+            $channel->save();
+            $channel->delete();
+            // Groups grant access by type, so retain the grant while another row of that type remains.
+            $remaining = RechargeChannel::query()->where('data_type', $channel->data_type)
+                ->where('type', $channel->type)->lockForUpdate()->first(['id']);
+            $groupChanges = [];
+            if (!$remaining) {
+                $field = (int)$channel->data_type === 1 ? 'recharge_type' : 'withdraw_type';
+                foreach (RechargeChannelGroup::query()->orderBy('id')->lockForUpdate()->get() as $group) {
+                    $types = (array)$group->{$field};
+                    $updated = array_values(array_filter($types, fn ($type) => trim((string)$type) !== (string)$channel->type));
+                    if ($types === $updated) continue;
+                    $groupChanges[] = ['id' => (int)$group->id, 'field' => $field, 'before' => $types, 'after' => $updated];
+                    $group->{$field} = $updated;
+                    $group->save();
+                }
+            }
+            OperationAuditService::record('recharge_channel', $id, 'delete', $before,
+                ['channel' => $channel->toArray(), 'group_changes' => $groupChanges]);
+        }, 3);
+    }
+
     private function identityLockReason(RechargeChannel $channel, bool $lock = false): string
     private function identityLockReason(RechargeChannel $channel, bool $lock = false): string
     {
     {
         foreach (['payment_gateways', 'payment_collection_channels', 'payment_direct_recharges'] as $table) {
         foreach (['payment_gateways', 'payment_collection_channels', 'payment_direct_recharges'] as $table) {

+ 2 - 2
app/Services/Payment/SanJinService.php

@@ -36,10 +36,10 @@ class SanJinService extends BaseService
         if ($type) {
         if ($type) {
             if ($groupId && RechargeChannel::checkRechargeChannel($type, $groupId) === false) return '';
             if ($groupId && RechargeChannel::checkRechargeChannel($type, $groupId) === false) return '';
             $name = RechargeChannel::where('data_type', 1)->where('from', 1)
             $name = RechargeChannel::where('data_type', 1)->where('from', 1)
-                ->where('status', 1)->where('type', $type)->value('name');
+                ->available()->where('type', $type)->value('name');
             return Lang($name);
             return Lang($name);
         } else {
         } else {
-            $query = RechargeChannel::query()->where('status', 1)->where('data_type', 1)->where('from', 1);
+            $query = RechargeChannel::query()->available()->where('data_type', 1)->where('from', 1);
             if ($groupId) {
             if ($groupId) {
                 $group = RechargeChannelGroup::query()->find($groupId);
                 $group = RechargeChannelGroup::query()->find($groupId);
                 $query->whereIn('type', $group ? (array)$group->recharge_type : []);
                 $query->whereIn('type', $group ? (array)$group->recharge_type : []);

+ 6 - 1
app/Services/PaymentChannelLinkService.php

@@ -9,6 +9,7 @@ use Illuminate\Database\Eloquent\Model;
 use Illuminate\Support\Collection;
 use Illuminate\Support\Collection;
 use RuntimeException;
 use RuntimeException;
 use App\Services\Payment\PaymentProviderCatalog;
 use App\Services\Payment\PaymentProviderCatalog;
+use App\Services\Payment\PaymentProviderService;
 
 
 class PaymentChannelLinkService
 class PaymentChannelLinkService
 {
 {
@@ -21,6 +22,8 @@ class PaymentChannelLinkService
         if ((int)$channel->data_type !== $expectedDataType) {
         if ((int)$channel->data_type !== $expectedDataType) {
             throw new RuntimeException($expectedDataType === 2 ? '请选择提现通道' : '请选择充值通道');
             throw new RuntimeException($expectedDataType === 2 ? '请选择提现通道' : '请选择充值通道');
         }
         }
+        $identity = PaymentProviderCatalog::channelIdentity($channel->toArray());
+        if ($identity) PaymentProviderService::assertEnabled($identity['provider_code']);
 
 
         $groupIds = array_values(array_unique(array_filter(array_map('intval', $groupIds))));
         $groupIds = array_values(array_unique(array_filter(array_map('intval', $groupIds))));
         if (!$groupIds) throw new RuntimeException('至少选择一个通道组合');
         if (!$groupIds) throw new RuntimeException('至少选择一个通道组合');
@@ -121,8 +124,10 @@ class PaymentChannelLinkService
         $data['sort'] = $channel ? (int)$channel->sort : null;
         $data['sort'] = $channel ? (int)$channel->sort : null;
         $data['config_status'] = (int)$model->status;
         $data['config_status'] = (int)$model->status;
         $data['channel_status'] = $channel ? (int)$channel->status : 0;
         $data['channel_status'] = $channel ? (int)$channel->status : 0;
+        $companyStatus = $identity ? (int)(PaymentProviderService::statuses()[$identity['provider_code']] ?? 0) : null;
+        $data['company_status'] = $companyStatus;
         $data['runtime_channel_available'] = $channel
         $data['runtime_channel_available'] = $channel
-            && (int)$channel->status === 1 && count($effectiveIds) > 0 ? 1 : 0;
+            && (int)$channel->status === 1 && $companyStatus !== 0 && count($effectiveIds) > 0 ? 1 : 0;
         $data['effective_status'] = (int)$model->status === 1
         $data['effective_status'] = (int)$model->status === 1
             && $data['runtime_channel_available'] === 1 ? 1 : 0;
             && $data['runtime_channel_available'] === 1 ? 1 : 0;
         if (array_key_exists('amount', $data)) {
         if (array_key_exists('amount', $data)) {

+ 16 - 0
app/Services/PaymentOrderService.php

@@ -15,6 +15,7 @@ use App\Services\Payment\NoPayService;
 use App\Services\Payment\QianBaoService;
 use App\Services\Payment\QianBaoService;
 use App\Services\Payment\SanJinService;
 use App\Services\Payment\SanJinService;
 use App\Services\Payment\ZimuPayService;
 use App\Services\Payment\ZimuPayService;
+use App\Services\Payment\PaymentProviderService;
 use App\Services\ConfigService;
 use App\Services\ConfigService;
 
 
 /**
 /**
@@ -200,6 +201,11 @@ class PaymentOrderService extends BaseService
         $result['chat_id'] = $memberId;
         $result['chat_id'] = $memberId;
         $result['code'] = 0;
         $result['code'] = 0;
         $result['url'] = '';
         $result['url'] = '';
+        if (!PaymentProviderService::requestEnabled((string)$paymentType, 1)) {
+            $result['code'] = 20001;
+            $result['text'] = '支付公司已禁用';
+            return $result;
+        }
         $user = User::query()->where('member_id', $memberId)->first();
         $user = User::query()->where('member_id', $memberId)->first();
         $groupId = (int)($user->recharge_channel_group_id ?? 1);
         $groupId = (int)($user->recharge_channel_group_id ?? 1);
         $channelConfig = RechargeChannel::checkRechargeChannel($paymentType, $groupId);
         $channelConfig = RechargeChannel::checkRechargeChannel($paymentType, $groupId);
@@ -752,6 +758,12 @@ class PaymentOrderService extends BaseService
                 ->first();
                 ->first();
             if (!$order) throw new Exception("订单不存在_{$orderId}", HttpStatus::CUSTOM_ERROR);
             if (!$order) throw new Exception("订单不存在_{$orderId}", HttpStatus::CUSTOM_ERROR);
             $amount = $order->amount;
             $amount = $order->amount;
+            if (!PaymentProviderService::requestEnabled((string)$order->channel, 2)) {
+                throw new Exception('支付公司已禁用', HttpStatus::CUSTOM_ERROR);
+            }
+            if (!RechargeChannel::query()->where('data_type', 2)->where('type', $order->channel)->available()->exists()) {
+                throw new Exception('提现通道已停用或删除', HttpStatus::CUSTOM_ERROR);
+            }
             $amount = number_format($amount, 2, '.', '');
             $amount = number_format($amount, 2, '.', '');
             if (NoPayService::isWithdrawChannel($order->channel)) {
             if (NoPayService::isWithdrawChannel($order->channel)) {
                 $ret = NoPayService::withdraw($amount, $order->order_no, (string)$order->member_id, (string)$order->account, (string)$order->card_no);
                 $ret = NoPayService::withdraw($amount, $order->order_no, (string)$order->member_id, (string)$order->account, (string)$order->card_no);
@@ -867,6 +879,10 @@ class PaymentOrderService extends BaseService
         $default_amount = $amount;
         $default_amount = $amount;
         $result = [];
         $result = [];
         $result['chat_id'] = $memberId;
         $result['chat_id'] = $memberId;
+        if (!PaymentProviderService::requestEnabled((string)$channel, 2)) {
+            $result['text'] = '支付公司已禁用';
+            return $result;
+        }
         $user = User::query()->where('member_id', $memberId)->first();
         $user = User::query()->where('member_id', $memberId)->first();
         $groupId = (int)($user->recharge_channel_group_id ?? 1);
         $groupId = (int)($user->recharge_channel_group_id ?? 1);
         $channelConfig = RechargeChannel::checkWithdrawChannel($channel, $groupId);
         $channelConfig = RechargeChannel::checkWithdrawChannel($channel, $groupId);

+ 34 - 0
database/migrations/2026_09_08_160000_add_payment_provider_status_and_channel_soft_deletes.php

@@ -0,0 +1,34 @@
+<?php
+
+use Illuminate\Database\Migrations\Migration;
+use Illuminate\Database\Schema\Blueprint;
+use Illuminate\Support\Facades\DB;
+use Illuminate\Support\Facades\Schema;
+
+return new class extends Migration {
+    public function up(): void
+    {
+        if (!Schema::hasTable('payment_providers')) {
+            Schema::create('payment_providers', function (Blueprint $table) {
+                $table->id();
+                $table->string('code', 32)->unique();
+                $table->unsignedTinyInteger('status')->default(1);
+                $table->timestamps();
+            });
+        }
+        foreach (['sanjin', 'qianbao', 'jd', 'no', 'zimu'] as $code) {
+            DB::table('payment_providers')->insertOrIgnore([
+                'code' => $code, 'status' => 1, 'created_at' => now(), 'updated_at' => now(),
+            ]);
+        }
+        if (Schema::hasTable('recharge_channel') && !Schema::hasColumn('recharge_channel', 'deleted_at')) {
+            Schema::table('recharge_channel', fn (Blueprint $table) => $table->softDeletes());
+        }
+    }
+
+    public function down(): void
+    {
+        // Rolling back would discard company kill switches and expose soft-deleted rows to older code.
+        throw new RuntimeException('此迁移包含支付公司启停和通道删除状态,禁止自动回滚,请先核对业务数据');
+    }
+};

+ 83 - 0
docs/前端接口/支付配置/通道删除与支付公司管理-前端变更说明.md

@@ -0,0 +1,83 @@
+# 通道删除与支付公司管理-前端变更说明
+
+本次在「充值通道」页面增加两处功能:充值/提现列表的删除按钮,以及与「通道管理」「层级设置」同级的「支付公司管理」。原列表、编辑、保存接口不变。所有新接口使用现有管理员 Token。
+
+## 1. 充值/提现通道删除
+
+`POST /admin/rechargeChannel/delete`
+
+```json
+{"id":24}
+```
+
+- 仅在充值、提现列表增加删除按钮,活动列表不加。
+- 点击后确认“是否删除该通道?删除后不可再选择”;用户确认后提交当前行 `id`。
+- 成功返回 `code=0,data=[]`,刷新通道列表和层级组合;删除最后一条同类型通道时,相应组合会移除该类型。
+- 有支付配置引用时返回非零 `code`,展示 `msg`,引导先解除关联;不要前端强制移除该行。
+- 重复提交已删除的 ID 返回成功;未知 ID 返回业务错误。
+
+## 2. 支付公司列表
+
+`GET /admin/paymentCompany/list`
+
+| 参数 | 类型 | 必填 | 说明 |
+|---|---|---|---|
+| `page` | integer | 否 | 默认 1 |
+| `limit` | integer | 否 | 默认 20,最大 100 |
+| `payment_company` | string | 否 | 公司代码 |
+| `status` | integer | 否 | `1` 启用、`0` 禁用 |
+| `data_type` | integer | 否 | `1` 支持充值、`2` 支持提现 |
+
+返回仍为 `code/timestamp/msg/data`,列表取 `data.data`,总数取 `data.total`。单行示例:
+
+```json
+{
+  "id":4,
+  "payment_company":"no",
+  "payment_company_label":"NO支付",
+  "data_types":[1,2],
+  "signature_algorithm":"SHA256",
+  "status":1,
+  "status_text":"启用",
+  "channel_count":3,
+  "enabled_channel_count":3,
+  "created_at":"2026-09-08 16:00:00",
+  "updated_at":"2026-09-08 16:00:00"
+}
+```
+
+建议列:公司代码、支付公司、支持方向、签名算法、通道数、状态开关。名称和算法直接显示返回值。`enabled_channel_count` 是通道自身开关为启用的数量,不代表公司禁用后仍可用。
+
+本页只管理已接入公司的启停,不提供公司新增、删除或任意修改代码按钮。
+
+## 3. 公司启用/禁用
+
+`POST /admin/paymentCompany/status`
+
+```json
+{"payment_company":"no","status":0}
+```
+
+成功返回 `code=0,data=[]`。禁用前提示:“将暂停该公司的新充值及新代付,是否继续?”提交期间禁用开关,失败恢复原值并显示 `msg`。成功后刷新公司列表、通道列表及所有相关下拉缓存。
+
+公司禁用不改变通道自身的启停;重新启用公司不会恢复原本停用或已删除的通道。已发起订单的回调、查询和退款仍可处理。
+
+## 4. 现有页面新增返回字段
+
+| 接口/位置 | 变化及显示方式 |
+|---|---|
+| `/admin/rechargeChannel/list` | 新增 `company_status`、`effective_status`;原 `status` 仍是通道自身开关。`company_status=0` 时显示“公司已禁用”;不把原通道开关改成关闭 |
+| `/admin/rechargeChannel/options` | 公司项新增 `status/disabled/disabled_reason`;类型项新增 `disabled/disabled_reason`。按接口禁用选项并展示原因,编辑旧通道时保留原值 |
+| `/admin/rechargeChannel/getChannel` 及组合展示项 | 新增 `company_status/effective_status/disabled/disabled_reason`。公司禁用的已有选中类型仍回显,不自动清空组合;不允许新勾选禁用项 |
+| `/admin/paymentConfig/options` | `disabled_reason_code` 新增 `provider_disabled`;直接显示 `disabled_reason`。通道项新增 `company_status` |
+| 支付配置列表/保存结果 | 新增 `company_status`,原 `runtime_channel_available/effective_status` 已计入公司开关,直接展示返回结果 |
+
+`company_status`:三方公司为 `0/1`,USDT、人工、活动等无三方公司的通道为 `null`。通道列表的 `effective_status` 只表示公司和通道开关共同允许;具体会员可见性仍由层级组合决定。
+
+`/admin/rechargeChannel/updateGroup` 原字段不变,`recharge_type/withdraw_type` 现在允许空数组,但字段仍需提交;不要因禁用公司而清空已有选择。
+
+## 联调
+
+- 删除一个金额档位,同类型还有其他通道时组合仍保留该类型;删除最后一条后同步刷新组合。
+- 禁用 NO 后,NO 通道仍可在管理列表看到,但显示公司已禁用,新增网关无法选择 NO。
+- 重新启用 NO 后,原本启用的通道恢复可用;原本停用或已删除的通道不恢复。

+ 83 - 0
docs/后端/通道删除与支付公司管理说明.md

@@ -0,0 +1,83 @@
+# 通道删除与支付公司管理-后端说明(2026-09-08)
+
+## 范围
+
+实现充值/提现通道删除和已接入支付公司的统一启停。公司目录仍是三斤、钱宝、JD、NO、808;USDT、人工和活动不伪造为三方支付公司。不提供任意添加未接入公司,也不删除商户、历史订单或资金流水。
+
+前端接口、返回和交互单独见 [前端变更说明](../前端接口/支付配置/通道删除与支付公司管理-前端变更说明.md)。
+
+## 数据与迁移
+
+新增迁移:`2026_09_08_160000_add_payment_provider_status_and_channel_soft_deletes.php`。
+
+- 新建 `payment_providers`:`id`、唯一 `code`、`status`、时间戳;初始化五家公司为启用,保持此前有效行为。名称、方向、签名算法仍来自后端目录,不在两处重复维护。
+- `recharge_channel` 增加 `deleted_at`,模型使用软删除。删除时同时设 `status=0`,避免仍按旧启用字段读取的消费者继续展示。
+- 迁移重复执行不会覆盖已经禁用的公司,也不会恢复已删除通道。
+- 此次与之前仅增加公司展示字段不同,**需要执行迁移**。不需要再插入 JD/NO/808 通道,不自动为所有组合扩展权限。
+
+## 删除规则
+
+入口:`POST /admin/rechargeChannel/delete`;服务:`RechargeChannelConfigurationService::delete`。
+
+1. 事务内锁定通道,仅支持 `data_type=1/2`。重复删除已有软删除记录成功返回。
+2. 仍被未软删除的网关、代收、直充配置引用时拒绝,包括已停用但未解除的配置;不会连带删除这些商户信息。
+3. 将通道停用并软删除。若同方向还有任何未删除的同类型行,保留组合里的类型,即使该行目前停用;避免删除某个金额档位影响其他档位。
+4. 最后一条同类型行删除后,只从对应方向的组合字段移除该类型;其他方向、其他类型、旧未知值不动。
+5. `operation_audits` 记录通道删除前后及每个受影响组合的 before/after。审计失败则事务回滚,最多重试 3 次。
+
+`updateGroup` 的两类数组允许为空,避免删除最后一种通道后组合无法保存;校验/写入在事务内,并锁定验证到的通道,与删除顺序协调。支付配置保存此前已锁通道,删除也会使用同一记录锁。
+
+本次没有恢复接口。软删除行保留原始数据;确需恢复时须人工核对审计、原启停状态和已清理的组合类型,不能只清空 `deleted_at` 就认为全部恢复。
+
+## 公司开关规则
+
+入口:`GET /admin/paymentCompany/list`、`POST /admin/paymentCompany/status`;持久化服务:`PaymentProviderService`。
+
+- 开关是公司级总开关,同时作用于它支持的充值/提现方向。禁用不批量覆盖通道的 `status`,也不清空组合配置。
+- 开关变更和审计在同一事务,重复设置相同状态不重复写审计。
+- 不使用跨请求静态缓存,避免常驻进程长时间继续使用旧开关;目录和列表按当前持久化状态计算。
+- 生产入口显式加载状态;已登记公司若缺少状态行按不可用处理,数据库错误不回退为全部启用。纯目录单元测试可不注入状态,其默认值只用于离线定义测试。
+
+### 已接入的执行路径
+
+- 会员充值/提现通道列表、通道校验、产品选择:`RechargeChannel::available` 叠加通道状态、公司状态和软删除条件。
+- 三斤历史通道入口也复用可用性筛选;不会因为这些旧方法的命名而忽略其他公司的开关。
+- `PaymentOrderService::createPay`、`autoCreatePayout` 在钱包变动/外部请求前检查公司开关;按实际服务分流识别别名,避免只靠 `from` 或前端控制。
+- 待审核订单执行 `createPayout` 时再次检查公司以及对应提现类型是否仍可用。禁用或删除后不继续发起新代付,订单保留待处理,可按既有流程退款/处理。
+- 公司选项、通道创建选项、层级候选项和商户配置最终有效状态也计入开关。删除的通道从正常模型查询中排除。
+
+### 明确保留的行为
+
+- 回调验签、已有订单状态回写、查单和退款不加公司禁用门槛;公司停用不能导致在途订单无法结清。
+- 不强制取消已经开始执行的外部调用,不自动退款,不修改已有订单的商户/通道,不迁移凭据。
+- 商户配置仍按前次实现存储,实际支付凭据仍使用现有环境配置;本功能不切换凭据来源。
+- 本次只修改 `bot-28`。其他项目、其他部署或手动 SQL 若绕开这里直接发起支付,需要同步遵守公司开关;不能据此宣称所有外部系统均已受控。
+
+## 发布顺序
+
+1. 备份相关表,确认既有支付配置和 `operation_audits` 已部署。
+2. 在维护窗口先部署迁移文件并执行新增迁移,再启用依赖新字段/表的应用代码:
+
+```sh
+php artisan migrate --path=database/migrations/2026_09_08_160000_add_payment_provider_status_and_channel_soft_deletes.php
+```
+
+3. 按既有流程更新路由缓存,重启队列、机器人和其他常驻进程。新 API 位于现有管理员鉴权/按钮权限分组;按项目菜单权限配置开放页面和按钮。
+4. 验证公司启停、通道删除、在途订单查询/回调,再开放前端操作。
+
+**回滚注意:** 迁移 `down` 主动拒绝丢弃这些状态;回退旧应用代码也可能绕过公司开关,不能直接用常规 rollback 恢复旧行为。应在维护窗口核对公司/通道状态、审计和旧代码可用性后人工处理。
+
+本次未在服务器执行迁移、删除、公司禁用、真实支付或部署。
+
+## 测试与限制
+
+支付相关数据库回归:26 个测试、184 个断言通过。包含目录/新增编辑原有用例,以及公司列表筛选、重复启停审计、运行时拦截、原通道开关保留、软删除、同类型多档位、组合清理、引用保护、迁移重入、未发起代付拦截、历史回调仍可处理、商户有效状态及空组合保存。另有 81 项离线目录检查通过。
+
+```sh
+php tests/Regression/payment_provider_catalog.php
+php -d error_reporting=24575 vendor/bin/phpunit --bootstrap tests/bootstrap-agent.php tests/Integration/PaymentChannelOptionsTest.php --do-not-cache-result
+```
+
+使用隔离 Laravel 9.52.21、SQLite 内存库和 `bot_` 前缀,不加载业务 `.env`。可用 `AGENT_TEST_AUTOLOAD` 指定临时依赖。PHP 8.4 测试排除旧框架 deprecated 提示,不排除一般异常。
+
+SQLite 不证明 MySQL 行锁/死锁以及跨进程竞争已正确;未进行实际前端、完整鉴权链、三方支付联调。在 MySQL 测试环境应重点验证删除与绑定、删除与组合保存、公司开关与正在执行请求的边界。

+ 6 - 0
routes/admin.php

@@ -315,6 +315,7 @@ Route::middleware(['admin.jwt'])->group(function () {
         });
         });
 
 
         Route::prefix('/rechargeChannel')->group(function () {
         Route::prefix('/rechargeChannel')->group(function () {
+            Route::post('/delete', [RechargeChannel::class, 'delete']);
             Route::get("/options", [RechargeChannel::class, 'options']);
             Route::get("/options", [RechargeChannel::class, 'options']);
             Route::get("/list", [RechargeChannel::class, 'list']);
             Route::get("/list", [RechargeChannel::class, 'list']);
             Route::post("/update", [RechargeChannel::class, 'update']);
             Route::post("/update", [RechargeChannel::class, 'update']);
@@ -324,6 +325,11 @@ Route::middleware(['admin.jwt'])->group(function () {
 
 
         });
         });
 
 
+        Route::prefix('/paymentCompany')->group(function () {
+            Route::get('/list', [\App\Http\Controllers\admin\PaymentCompany::class, 'list']);
+            Route::post('/status', [\App\Http\Controllers\admin\PaymentCompany::class, 'status']);
+        });
+
         Route::prefix('/user')->group(function () {
         Route::prefix('/user')->group(function () {
             Route::get('/', [User::class, 'index']);
             Route::get('/', [User::class, 'index']);
             Route::get('/address', [User::class, 'address']);
             Route::get('/address', [User::class, 'address']);

+ 212 - 0
tests/Integration/PaymentChannelOptionsTest.php

@@ -4,9 +4,13 @@ namespace Tests\Integration;
 
 
 use App\Http\Controllers\admin\PaymentConfiguration;
 use App\Http\Controllers\admin\PaymentConfiguration;
 use App\Http\Controllers\admin\RechargeChannel;
 use App\Http\Controllers\admin\RechargeChannel;
+use App\Http\Controllers\admin\PaymentCompany;
 use App\Services\PaymentChannelLinkService;
 use App\Services\PaymentChannelLinkService;
 use App\Services\Payment\RechargeChannelConfigurationService;
 use App\Services\Payment\RechargeChannelConfigurationService;
 use App\Models\PaymentGateway;
 use App\Models\PaymentGateway;
+use App\Models\RechargeChannel as ChannelModel;
+use App\Services\Payment\PaymentProviderService;
+use App\Services\PaymentOrderService;
 use Illuminate\Config\Repository;
 use Illuminate\Config\Repository;
 use Illuminate\Database\Capsule\Manager;
 use Illuminate\Database\Capsule\Manager;
 use Illuminate\Database\Schema\Blueprint;
 use Illuminate\Database\Schema\Blueprint;
@@ -60,6 +64,12 @@ class PaymentChannelOptionsTest extends TestCase
         $fixture = require dirname(__DIR__) . '/fixtures/payment_provider_channels.php';
         $fixture = require dirname(__DIR__) . '/fixtures/payment_provider_channels.php';
         DB::table('recharge_channel')->insert($fixture['channels']);
         DB::table('recharge_channel')->insert($fixture['channels']);
         DB::table('recharge_channel_group')->insert($fixture['groups']);
         DB::table('recharge_channel_group')->insert($fixture['groups']);
+        (require dirname(__DIR__, 2) . '/database/migrations/2026_09_08_160000_add_payment_provider_status_and_channel_soft_deletes.php')->up();
+        $db->schema()->create('operation_audits', function (Blueprint $t) {
+            $t->id(); $t->string('resource_type'); $t->unsignedBigInteger('resource_id')->nullable();
+            $t->string('action'); $t->json('changes'); $t->unsignedBigInteger('operator_id')->nullable();
+            $t->string('operator_name'); $t->timestamp('created_at')->useCurrent();
+        });
     }
     }
 
 
     protected function tearDown(): void
     protected function tearDown(): void
@@ -284,4 +294,206 @@ class PaymentChannelOptionsTest extends TestCase
         $method->invoke($controller, PaymentGateway::class, ['kind' => 'deposit', 'recharge_channel_id' => 26,
         $method->invoke($controller, PaymentGateway::class, ['kind' => 'deposit', 'recharge_channel_id' => 26,
             'recharge_channel_group_ids' => [1], 'payment_company' => 'no', 'payment_method' => 'NOpay12'], 'payment_gateway');
             'recharge_channel_group_ids' => [1], 'payment_company' => 'no', 'payment_method' => 'NOpay12'], 'payment_gateway');
     }
     }
+
+    private function setCompanyStatus(string $code, int $status): array
+    {
+        $this->app->instance('request', Request::create('/admin/paymentCompany/status', 'POST', ['payment_company' => $code, 'status' => $status]));
+        return (new PaymentCompany())->status(new PaymentProviderService())->getData(true);
+    }
+
+    private function deleteChannel(int $id): array
+    {
+        $this->app->instance('request', Request::create('/admin/rechargeChannel/delete', 'POST', ['id' => $id]));
+        return (new RechargeChannel())->delete(new RechargeChannelConfigurationService())->getData(true);
+    }
+
+    public function test_company_management_lists_registered_providers_and_filters_status(): void
+    {
+        $this->request('/admin/paymentCompany/list', []);
+        $result = (new PaymentCompany())->list(new PaymentProviderService())->getData(true);
+        $this->assertSame(0, $result['code']);
+        $this->assertSame(5, $result['data']['total']);
+        $rows = array_column($result['data']['data'], null, 'payment_company');
+        $this->assertSame('SHA256', $rows['no']['signature_algorithm']);
+        $this->assertSame([1, 2], $rows['no']['data_types']);
+        $this->assertSame(3, $rows['no']['channel_count']);
+        $this->assertSame(0, $this->setCompanyStatus('no', 0)['code']);
+        $this->assertSame(0, $this->setCompanyStatus('no', 0)['code']);
+        $this->assertSame(1, DB::table('operation_audits')->where('resource_type', 'payment_company')->count());
+        $this->request('/admin/paymentCompany/list', ['status' => 0]);
+        $this->assertSame(1, (new PaymentCompany())->list(new PaymentProviderService())->getData(true)['data']['total']);
+        $this->assertSame(-3, $this->setCompanyStatus('unknown', 0)['code']);
+        $this->assertSame(-3, $this->setCompanyStatus('no', 9)['code']);
+    }
+
+    public function test_company_switch_disables_options_and_runtime_without_rewriting_channels(): void
+    {
+        $groups = DB::table('recharge_channel_group')->get()->toJson();
+        $this->assertSame(0, $this->setCompanyStatus('no', 0)['code']);
+        $this->assertFalse(ChannelModel::checkRechargeChannel('NOpay12', 1));
+        $this->assertFalse(ChannelModel::checkWithdrawChannel('NOwithdraw', 1));
+        $this->assertNotFalse(ChannelModel::checkRechargeChannel('JDpay', 1));
+        $this->assertNotFalse(ChannelModel::checkRechargeChannel('usdt', 1));
+        $this->assertArrayNotHasKey('NOpay12', ChannelModel::product(1));
+        $this->assertSame(1, DB::table('recharge_channel')->where('id', 26)->value('status'));
+        $this->assertSame($groups, DB::table('recharge_channel_group')->get()->toJson());
+        $this->request('/admin/paymentConfig/options', ['kind' => 'deposit']);
+        $options = (new PaymentConfiguration(new PaymentChannelLinkService()))->options()->getData(true)['data'];
+        $companies = array_column($options['payment_companies'], null, 'value');
+        $this->assertSame('provider_disabled', $companies['no']['disabled_reason_code']);
+        $this->assertFalse(array_column($options['recharge_channels'], null, 'id')[26]['selectable']);
+        $form = $this->formOptions();
+        $this->assertTrue(array_column($form['payment_companies'], null, 'value')['no']['disabled']);
+        $row = $this->channelList(['data_type' => '1', 'type' => 'NOpay12'])['data'][0];
+        $this->assertSame(1, $row['status']);
+        $this->assertSame(0, $row['company_status']);
+        $this->assertSame(0, $row['effective_status']);
+        $this->assertSame(-3, $this->saveChannel($this->newChannel())['code']);
+        $this->assertSame(0, $this->setCompanyStatus('no', 1)['code']);
+        $this->assertNotFalse(ChannelModel::checkRechargeChannel('NOpay12', 1));
+    }
+
+    public function test_reenable_company_does_not_enable_individually_disabled_channels(): void
+    {
+        $this->setCompanyStatus('zimu', 0);
+        $this->setCompanyStatus('zimu', 1);
+        $this->assertSame(0, DB::table('recharge_channel')->where('id', 31)->value('status'));
+        $this->assertFalse(ChannelModel::checkWithdrawChannel('ZIMUcash', 1));
+        $this->assertNotFalse(ChannelModel::checkWithdrawChannel('ZIMUwithdraw', 1));
+    }
+
+    public function test_disabled_company_blocks_new_external_requests_before_any_wallet_or_network_work(): void
+    {
+        $this->setCompanyStatus('no', 0);
+        $this->assertSame('支付公司已禁用', PaymentOrderService::createPay('test-user', 100, 'NOpay12')['text']);
+        $this->assertSame('支付公司已禁用', PaymentOrderService::autoCreatePayout('test-user', 100, 'NOwithdraw', '', '', '')['text']);
+        $this->assertFalse(PaymentProviderService::requestEnabled('nopay12', 1));
+        $this->assertTrue(PaymentProviderService::requestEnabled('JDpay', 1));
+    }
+
+    public function test_delete_last_deposit_type_soft_deletes_and_cleans_only_its_group_membership(): void
+    {
+        $beforeWithdraw = DB::table('recharge_channel_group')->where('id', 1)->value('withdraw_type');
+        $this->assertSame(0, $this->deleteChannel(24)['code']);
+        $this->assertNull(ChannelModel::query()->find(24));
+        $deleted = ChannelModel::withTrashed()->findOrFail(24);
+        $this->assertTrue($deleted->trashed());
+        $this->assertSame(0, (int)$deleted->status);
+        $this->assertSame('JD钱包', $deleted->name);
+        $types = explode(',', DB::table('recharge_channel_group')->where('id', 1)->value('recharge_type'));
+        $this->assertNotContains('JDpay', $types);
+        $this->assertContains('NOpay12', $types);
+        $this->assertSame($beforeWithdraw, DB::table('recharge_channel_group')->where('id', 1)->value('withdraw_type'));
+        $this->assertNotFalse(ChannelModel::checkWithdrawChannel('JDpay', 1));
+        $this->assertFalse(ChannelModel::checkRechargeChannel('JDpay', 1));
+        $this->assertSame(0, $this->deleteChannel(24)['code']);
+        $this->assertSame(1, DB::table('operation_audits')->where('resource_type', 'recharge_channel')->count());
+        $this->setCompanyStatus('jd', 0); $this->setCompanyStatus('jd', 1);
+        $this->assertNull(ChannelModel::query()->find(24));
+    }
+
+    public function test_delete_one_of_several_same_type_channels_preserves_groups_until_last_removed(): void
+    {
+        $this->assertSame(0, $this->deleteChannel(6)['code']);
+        $this->assertStringContainsString('zfbsm', DB::table('recharge_channel_group')->where('id', 1)->value('recharge_type'));
+        $this->assertSame(3, ChannelModel::query()->where('data_type', 1)->where('type', 'zfbsm')->count());
+        foreach ([7, 8, 9] as $id) $this->assertSame(0, $this->deleteChannel($id)['code']);
+        $this->assertStringNotContainsString('zfbsm', DB::table('recharge_channel_group')->where('id', 1)->value('recharge_type'));
+    }
+
+    public function test_delete_is_blocked_by_payment_config_reference_and_does_not_touch_activity(): void
+    {
+        DB::connection()->getSchemaBuilder()->create('payment_gateways', function (Blueprint $t) {
+            $t->id(); $t->unsignedBigInteger('recharge_channel_id'); $t->softDeletes();
+        });
+        DB::table('payment_gateways')->insert(['recharge_channel_id' => 24]);
+        $this->assertSame(-3, $this->deleteChannel(24)['code']);
+        $this->assertNotNull(ChannelModel::query()->find(24));
+        $this->assertSame(-3, $this->deleteChannel(21)['code']);
+        $this->assertNotNull(ChannelModel::query()->find(21));
+        DB::table('payment_gateways')->update(['deleted_at' => now()]);
+        $this->assertSame(0, $this->deleteChannel(24)['code']);
+    }
+
+    public function test_migration_retry_keeps_disabled_company_and_deleted_channel(): void
+    {
+        $this->setCompanyStatus('no', 0);
+        $this->deleteChannel(24);
+        (require dirname(__DIR__, 2) . '/database/migrations/2026_09_08_160000_add_payment_provider_status_and_channel_soft_deletes.php')->up();
+        $this->assertSame(0, PaymentProviderService::statuses()['no']);
+        $this->assertTrue(ChannelModel::withTrashed()->findOrFail(24)->trashed());
+        $this->assertSame(5, DB::table('payment_providers')->count());
+    }
+
+    private function paymentOrderFixture(int $type, string $channel, int $status): \App\Models\PaymentOrder
+    {
+        DB::connection()->getSchemaBuilder()->create('payment_orders', function (Blueprint $t) {
+            $t->id(); $t->integer('type'); $t->string('channel'); $t->integer('status');
+            $t->string('order_no'); $t->string('member_id'); $t->decimal('amount', 18, 4);
+            $t->string('state')->nullable(); $t->text('callback_data')->nullable(); $t->timestamps();
+        });
+        $this->app->instance('log', new class {
+            public function channel($channel) { return $this; }
+            public function error($message, array $context = []) {}
+        });
+        return \App\Models\PaymentOrder::query()->create(['type' => $type, 'channel' => $channel,
+            'status' => $status, 'order_no' => 'test-order', 'member_id' => 'test-user', 'amount' => 10]);
+    }
+
+    public function test_pending_payout_cannot_be_sent_after_company_disabled(): void
+    {
+        $order = $this->paymentOrderFixture(2, 'NOwithdraw', PaymentOrderService::STATUS_STAY);
+        $this->setCompanyStatus('no', 0);
+        $result = PaymentOrderService::createPayout($order->id);
+        $this->assertSame(-3, $result['code']);
+        $this->assertSame('支付公司已禁用', $result['msg']);
+        $this->assertSame(PaymentOrderService::STATUS_STAY, $order->fresh()->status);
+    }
+
+    public function test_pending_payout_cannot_be_sent_after_last_channel_deleted(): void
+    {
+        $order = $this->paymentOrderFixture(2, 'JDpay', PaymentOrderService::STATUS_STAY);
+        $this->assertSame(0, $this->deleteChannel(25)['code']);
+        $result = PaymentOrderService::createPayout($order->id);
+        $this->assertSame(-3, $result['code']);
+        $this->assertSame('提现通道已停用或删除', $result['msg']);
+        $this->assertSame(PaymentOrderService::STATUS_STAY, $order->fresh()->status);
+    }
+
+    public function test_existing_pay_callback_is_not_blocked_by_company_switch(): void
+    {
+        $order = $this->paymentOrderFixture(1, 'NOpay12', PaymentOrderService::STATUS_PROCESS);
+        $this->setCompanyStatus('no', 0);
+        $method = new \ReflectionMethod(PaymentOrderService::class, 'applyPayCallback');
+        $method->setAccessible(true);
+        $this->assertTrue($method->invoke(null, $order, '10', 'failed', 'success', 'failed', ['status' => 'failed']));
+        $this->assertSame(PaymentOrderService::STATUS_FAIL, $order->fresh()->status);
+        $this->assertNotNull($order->fresh()->callback_data);
+    }
+
+    public function test_company_disabled_updates_saved_gateway_effective_status(): void
+    {
+        $channel = ChannelModel::query()->findOrFail(26);
+        $gateway = new PaymentGateway(['payment_company' => 'no', 'payment_method' => 'NOpay12',
+            'status' => 1, 'recharge_channel_group_ids' => [1]]);
+        $gateway->setRelation('rechargeChannel', $channel);
+        $this->setCompanyStatus('no', 0);
+        $data = (new PaymentChannelLinkService())->formatOne($gateway);
+        $this->assertSame(1, $data['config_status']);
+        $this->assertSame(1, $data['channel_status']);
+        $this->assertSame(0, $data['company_status']);
+        $this->assertSame(0, $data['effective_status']);
+        $this->expectException(\RuntimeException::class);
+        $this->expectExceptionMessage('NO支付已禁用');
+        (new PaymentChannelLinkService())->validate(26, [1], 1);
+    }
+
+    public function test_empty_group_types_can_be_saved_after_last_channel_removed(): void
+    {
+        $this->app->instance('request', Request::create('/admin/rechargeChannel/updateGroup', 'POST',
+            ['id' => 1, 'name' => '全部充值', 'recharge_type' => [], 'withdraw_type' => []]));
+        $this->assertSame(0, (new RechargeChannel())->updateGroup()->getData(true)['code']);
+        $this->assertSame('', DB::table('recharge_channel_group')->where('id', 1)->value('recharge_type'));
+        $this->assertSame('yuebao,old_user,recharge', DB::table('recharge_channel_group')->where('id', 1)->value('activity_type'));
+    }
 }
 }