doge 6 dni temu
rodzic
commit
dee7820219

+ 42 - 66
app/Http/Controllers/admin/PaymentConfiguration.php

@@ -12,6 +12,7 @@ use App\Models\RechargeChannel;
 use App\Models\RechargeChannelGroup;
 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 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;
@@ -31,52 +32,18 @@ class PaymentConfiguration extends Controller
     public function options()
     public function options()
     {
     {
         return $this->run(function () {
         return $this->run(function () {
-            $groups = RechargeChannelGroup::query()->orderBy('id')->get()->map(fn ($group) => [
-                'id' => (int)$group->id,
-                'name' => (string)$group->name,
-                'recharge_type' => (array)$group->recharge_type,
-                'withdraw_type' => (array)$group->withdraw_type,
-                'activity_type' => (array)$group->activity_type,
+            $filters = request()->validate([
+                'kind' => ['nullable', Rule::in(['deposit', 'withdraw', 'collection_scan', 'collection_third_party'])],
+                'payment_company' => ['nullable', Rule::in(PaymentProviderCatalog::codes())],
+                'provider_name' => ['nullable', Rule::in(PaymentProviderCatalog::codes())],
+                'payment_method' => ['nullable', 'string', 'max:64'],
+                'collection_method' => ['nullable', 'string', 'max:64'],
             ]);
             ]);
-            $channels = RechargeChannel::query()->orderBy('data_type')->orderBy('sort')->orderBy('id')
-                ->get()->map(function ($channel) use ($groups) {
-                    $groupField = match ((int)$channel->data_type) {
-                        2 => 'withdraw_type',
-                        3 => 'activity_type',
-                        default => 'recharge_type',
-                    };
-                    return [
-                        'id' => (int)$channel->id,
-                        'data_type' => (int)$channel->data_type,
-                        'name' => (string)$channel->name,
-                        'key' => (string)$channel->key,
-                        'type' => (string)$channel->type,
-                        'rate' => number_format((float)$channel->rate, 4, '.', ''),
-                        'fee_rate' => bcmul((string)$channel->rate, '100', 4),
-                        'min_amount' => $channel->min,
-                        'max_amount' => $channel->max,
-                        'fixed_amounts' => $channel->fixed ?: [],
-                        'sort' => (int)$channel->sort,
-                        'status' => (int)$channel->status,
-                        'available_group_ids' => $groups->filter(fn ($group) => in_array(
-                            (string)$channel->type,
-                            (array)$group[$groupField],
-                            true
-                        ))->pluck('id')->values(),
-                    ];
-                });
-            return [
-                'recharge_channels' => $channels,
-                'channel_groups' => $groups,
-                'levels' => $groups->map(fn ($group) => [
-                    'value' => $group['id'],
-                    'label' => $group['name'],
-                ])->values(),
-                'payment_companies' => PaymentGateway::query()->distinct()->orderBy('payment_company')->pluck('payment_company')->filter()->values(),
-                'payment_methods' => PaymentGateway::query()->distinct()->orderBy('payment_method')->pluck('payment_method')->filter()->values(),
-                'collection_providers' => PaymentCollectionChannel::query()->distinct()->orderBy('provider_name')->pluck('provider_name')->filter()->values(),
-                'collection_methods' => PaymentCollectionChannel::query()->distinct()->orderBy('collection_method')->pluck('collection_method')->filter()->values(),
-            ];
+            return (new PaymentProviderCatalog())->options(
+                RechargeChannel::query()->orderBy('data_type')->orderBy('sort')->orderBy('id')->get()->toArray(),
+                RechargeChannelGroup::query()->orderBy('id')->get()->toArray(),
+                $filters
+            );
         });
         });
     }
     }
 
 
@@ -166,36 +133,22 @@ class PaymentConfiguration extends Controller
                 'recharge_channel_group_ids.*' => ['integer'],
                 'recharge_channel_group_ids.*' => ['integer'],
                 'kind' => ['required', Rule::in(['deposit', 'withdraw'])],
                 'kind' => ['required', Rule::in(['deposit', 'withdraw'])],
                 'currency' => ['nullable', 'string', 'max:16'],
                 'currency' => ['nullable', 'string', 'max:16'],
-                'payment_company' => ['required', 'string', 'max:100'],
+                'payment_company' => ['required', Rule::in(PaymentProviderCatalog::codes())],
                 'merchant_no' => ['required', 'string', 'max:128'],
                 'merchant_no' => ['required', 'string', 'max:128'],
-                'payment_method' => ['nullable', 'string', 'max:64'],
+                'payment_method' => ['required', 'string', 'max:64'],
                 'channel_code' => ['nullable', 'string', 'max:64'],
                 'channel_code' => ['nullable', 'string', 'max:64'],
                 'channel_name' => ['nullable', 'string', 'max:100'],
                 'channel_name' => ['nullable', 'string', 'max:100'],
                 'withdrawal_secret' => ['nullable', 'string', 'max:10000'],
                 'withdrawal_secret' => ['nullable', 'string', 'max:10000'],
+                'deposit_secret' => ['nullable', 'string', 'max:10000'],
                 'status' => ['nullable', Rule::in([0, 1])],
                 'status' => ['nullable', Rule::in([0, 1])],
             ]);
             ]);
             $expectedDataType = $params['kind'] === 'withdraw' ? 2 : 1;
             $expectedDataType = $params['kind'] === 'withdraw' ? 2 : 1;
-            [, $params['recharge_channel_group_ids']] = $this->channelLinks->validate(
+            [$channel, $params['recharge_channel_group_ids']] = $this->channelLinks->validate(
                 (int)$params['recharge_channel_id'],
                 (int)$params['recharge_channel_id'],
                 $params['recharge_channel_group_ids'],
                 $params['recharge_channel_group_ids'],
                 $expectedDataType
                 $expectedDataType
             );
             );
-            $existing = !empty($params['id']) ? PaymentGateway::query()->findOrFail($params['id']) : null;
-            if ($existing && $existing->kind !== $params['kind']) {
-                throw ValidationException::withMessages(['kind' => '网关类型创建后不能修改']);
-            }
-            if ($params['kind'] === 'deposit' && empty($params['payment_method'])) {
-                throw ValidationException::withMessages(['payment_method' => '存款网关必须填写支付方式']);
-            }
-            $hasSavedSecret = $existing ? $existing->has_withdrawal_secret : false;
-            if ($params['kind'] === 'withdraw' && empty($params['withdrawal_secret']) && !$hasSavedSecret) {
-                throw ValidationException::withMessages(['withdrawal_secret' => '出款网关必须填写出款密钥']);
-            }
-            if ($params['kind'] === 'deposit') {
-                $params['withdrawal_secret'] = null;
-            } elseif (empty($params['withdrawal_secret'])) {
-                unset($params['withdrawal_secret']);
-            }
+            PaymentProviderCatalog::validateSelection($channel->toArray(), $params['payment_company'], $params['payment_method'], $expectedDataType);
             return $this->saveModel(PaymentGateway::class, $params, 'payment_gateway');
             return $this->saveModel(PaymentGateway::class, $params, 'payment_gateway');
         });
         });
     }
     }
@@ -242,18 +195,19 @@ class PaymentConfiguration extends Controller
                 'recharge_channel_group_ids.*' => ['integer'],
                 'recharge_channel_group_ids.*' => ['integer'],
                 'kind' => ['required', Rule::in(['collection_scan', 'collection_third_party'])],
                 'kind' => ['required', Rule::in(['collection_scan', 'collection_third_party'])],
                 'currency' => ['nullable', 'string', 'max:16'],
                 'currency' => ['nullable', 'string', 'max:16'],
-                'provider_name' => ['required', 'string', 'max:100'],
+                'provider_name' => ['required', Rule::in(PaymentProviderCatalog::codes())],
                 'group_name' => ['required', 'string', 'max:100'],
                 'group_name' => ['required', 'string', 'max:100'],
                 'collection_method' => ['required', 'string', 'max:64'],
                 'collection_method' => ['required', 'string', 'max:64'],
                 'channel_code' => ['required', 'string', 'max:64'],
                 'channel_code' => ['required', 'string', 'max:64'],
                 'channel_name' => ['required', 'string', 'max:100'],
                 'channel_name' => ['required', 'string', 'max:100'],
                 'status' => ['nullable', Rule::in([0, 1])],
                 'status' => ['nullable', Rule::in([0, 1])],
             ]);
             ]);
-            [, $params['recharge_channel_group_ids']] = $this->channelLinks->validate(
+            [$channel, $params['recharge_channel_group_ids']] = $this->channelLinks->validate(
                 (int)$params['recharge_channel_id'],
                 (int)$params['recharge_channel_id'],
                 $params['recharge_channel_group_ids'],
                 $params['recharge_channel_group_ids'],
                 1
                 1
             );
             );
+            PaymentProviderCatalog::validateSelection($channel->toArray(), $params['provider_name'], $params['collection_method'], 1);
             return $this->saveModel(PaymentCollectionChannel::class, $params, 'payment_collection_channel');
             return $this->saveModel(PaymentCollectionChannel::class, $params, 'payment_collection_channel');
         });
         });
     }
     }
@@ -297,6 +251,7 @@ class PaymentConfiguration extends Controller
             unset($params['id']);
             unset($params['id']);
             $params += OperationAuditService::actor();
             $params += OperationAuditService::actor();
             $model = $id ? $modelClass::query()->lockForUpdate()->findOrFail($id) : new $modelClass();
             $model = $id ? $modelClass::query()->lockForUpdate()->findOrFail($id) : new $modelClass();
+            if ($model instanceof PaymentGateway) $params = $this->gatewayCredentials($model, $params);
             $before = $model->exists ? $model->replicate()->setRawAttributes($model->getRawOriginal()) : [];
             $before = $model->exists ? $model->replicate()->setRawAttributes($model->getRawOriginal()) : [];
             $model->fill($params);
             $model->fill($params);
             $model->save();
             $model->save();
@@ -319,6 +274,27 @@ class PaymentConfiguration extends Controller
         });
         });
     }
     }
 
 
+    private function gatewayCredentials(PaymentGateway $model, array $params): array
+    {
+        if ($model->exists && $model->kind !== $params['kind']) {
+            throw ValidationException::withMessages(['kind' => '网关类型创建后不能修改']);
+        }
+        $secretField = $params['kind'] === 'deposit' ? 'deposit_secret' : 'withdrawal_secret';
+        $otherField = $params['kind'] === 'deposit' ? 'withdrawal_secret' : 'deposit_secret';
+        $hasSecret = !empty($model->getRawOriginal($secretField));
+        $identityChanged = $model->exists && ($model->payment_company !== $params['payment_company']
+            || $model->merchant_no !== $params['merchant_no']);
+        $newSecret = $params[$secretField] ?? null;
+        if ($newSecret === null || $newSecret === '') {
+            if (!$hasSecret || $identityChanged) {
+                throw ValidationException::withMessages([$secretField => '新增商户或切换公司/商户号时必须填写签名密钥']);
+            }
+            unset($params[$secretField]);
+        }
+        $params[$otherField] = null;
+        return $params;
+    }
+
     private function deleteModel(string $modelClass, string $resourceType)
     private function deleteModel(string $modelClass, string $resourceType)
     {
     {
         return $this->run(function () use ($modelClass, $resourceType) {
         return $this->run(function () use ($modelClass, $resourceType) {

+ 8 - 2
app/Models/PaymentGateway.php

@@ -10,13 +10,19 @@ class PaymentGateway extends BaseModel
 
 
     protected $table = 'payment_gateways';
     protected $table = 'payment_gateways';
     protected $guarded = [];
     protected $guarded = [];
-    protected $hidden = ['withdrawal_secret', 'deleted_at'];
+    protected $hidden = ['withdrawal_secret', 'deposit_secret', 'deleted_at'];
     protected $casts = [
     protected $casts = [
         'withdrawal_secret' => 'encrypted',
         'withdrawal_secret' => 'encrypted',
+        'deposit_secret' => 'encrypted',
         'recharge_channel_group_ids' => 'array',
         'recharge_channel_group_ids' => 'array',
         'status' => 'integer',
         'status' => 'integer',
     ];
     ];
-    protected $appends = ['has_withdrawal_secret'];
+    protected $appends = ['has_withdrawal_secret', 'has_deposit_secret'];
+
+    public function getHasDepositSecretAttribute(): bool
+    {
+        return !empty($this->attributes['deposit_secret'] ?? null);
+    }
 
 
     public function getHasWithdrawalSecretAttribute(): bool
     public function getHasWithdrawalSecretAttribute(): bool
     {
     {

+ 188 - 0
app/Services/Payment/PaymentProviderCatalog.php

@@ -0,0 +1,188 @@
+<?php
+
+namespace App\Services\Payment;
+
+use InvalidArgumentException;
+
+/** 支付商及协议目录。独立于商户配置表;只登记已实现的支付服务。 */
+class PaymentProviderCatalog
+{
+    private const PROVIDERS = [
+        'sanjin' => ['label' => '三斤支付', 'directions' => [1], 'signature' => 'MD5'],
+        'qianbao' => ['label' => '钱宝支付', 'directions' => [2], 'signature' => 'MD5'],
+        'jd' => ['label' => 'JD支付', 'directions' => [1, 2], 'signature' => 'MD5'],
+        'no' => ['label' => 'NO支付', 'directions' => [1, 2], 'signature' => 'SHA256'],
+        'zimu' => ['label' => '808支付', 'directions' => [1, 2], 'signature' => 'MD5'],
+    ];
+
+    // data_type => 通道 type => [支付商代码, 支付方式名称]
+    private const METHODS = [
+        1 => [
+            'wxsm' => ['sanjin', '微信扫码'],
+            'ylsm' => ['sanjin', '银联扫码'],
+            'szrmb' => ['sanjin', '数字人民币'],
+            'zfbsm' => ['sanjin', '支付宝扫码'],
+            'zfbge' => ['sanjin', '支付宝固额'],
+            'zfbzk' => ['sanjin', '支付宝转卡'],
+            'sdjt' => ['sanjin', '手动金条'],
+            'JDpay' => ['jd', 'JD钱包'],
+            'NOpay12' => ['no', 'NO扫码支付'],
+            'NOpay13' => ['no', 'NO余额支付'],
+            'ZIMUpay' => ['zimu', '808充值'],
+        ],
+        2 => [
+            'DF001' => ['qianbao', '银行卡'],
+            'DF002' => ['qianbao', '支付宝'],
+            'DF005' => ['qianbao', '数字人民币'],
+            'JDpay' => ['jd', 'JD钱包'],
+            'NOwithdraw' => ['no', 'NO快捷提现'],
+            'ZIMUwithdraw' => ['zimu', '808账户提现'],
+            'ZIMUcash' => ['zimu', '808人民币代付'],
+        ],
+    ];
+
+    public static function codes(): array
+    {
+        return array_keys(self::PROVIDERS);
+    }
+
+    public static function label(string $code): string
+    {
+        return self::PROVIDERS[$code]['label'] ?? $code;
+    }
+
+    public static function direction(?string $kind): ?int
+    {
+        return match ($kind) {
+            null, '' => null,
+            'deposit', 'collection_scan', 'collection_third_party' => 1,
+            'withdraw' => 2,
+            default => throw new InvalidArgumentException('不支持的支付配置类型'),
+        };
+    }
+
+    public static function channelIdentity(array $channel): ?array
+    {
+        if ((int)($channel['from'] ?? 0) !== 1) return null;
+        $type = (string)($channel['type'] ?? '');
+        $method = self::METHODS[(int)($channel['data_type'] ?? 0)][$type] ?? null;
+        if (!$method) return null;
+        return ['provider_code' => $method[0], 'method_code' => $type, 'method_label' => $method[1]];
+    }
+
+    public static function validateSelection(array $channel, string $provider, string $method, int $direction): void
+    {
+        $identity = self::channelIdentity($channel);
+        if ((int)($channel['data_type'] ?? 0) !== $direction || !$identity) {
+            throw new InvalidArgumentException('该通道不属于已接入的第三方支付通道');
+        }
+        if ($identity['provider_code'] !== $provider) {
+            throw new InvalidArgumentException('支付公司与所选通道不匹配');
+        }
+        if ($identity['method_code'] !== $method) {
+            throw new InvalidArgumentException('支付方式与所选通道不匹配');
+        }
+    }
+
+    public function options(array $channels, array $groups, array $filters = []): array
+    {
+        $direction = self::direction($filters['kind'] ?? null);
+        $isCollection = in_array($filters['kind'] ?? '', ['collection_scan', 'collection_third_party'], true);
+        $company = (string)($filters['payment_company'] ?? '');
+        $collector = (string)($filters['provider_name'] ?? '');
+        $selectedProvider = $isCollection ? $collector : $company;
+        $selectedMethod = (string)($isCollection ? ($filters['collection_method'] ?? '') : ($filters['payment_method'] ?? ''));
+        $groups = array_map(function ($group) {
+            foreach (['recharge_type', 'withdraw_type', 'activity_type'] as $field) {
+                $group[$field] = self::types($group[$field] ?? []);
+            }
+            return ['id' => (int)$group['id'], 'name' => (string)$group['name']] + $group;
+        }, $groups);
+
+        $formatted = [];
+        foreach ($channels as $channel) {
+            $dataType = (int)$channel['data_type'];
+            $groupField = [1 => 'recharge_type', 2 => 'withdraw_type', 3 => 'activity_type'][$dataType] ?? null;
+            $ids = [];
+            foreach ($groups as $group) {
+                if ($groupField && in_array((string)$channel['type'], $group[$groupField], true)) $ids[] = $group['id'];
+            }
+            $identity = self::channelIdentity($channel);
+            $enabled = (int)$channel['status'] === 1;
+            $formatted[] = [
+                'id' => (int)$channel['id'], 'data_type' => $dataType,
+                'name' => (string)$channel['name'], 'key' => (string)$channel['key'], 'type' => (string)$channel['type'],
+                'rate' => bcadd((string)$channel['rate'], '0', 4),
+                'fee_rate' => bcmul((string)$channel['rate'], '100', 4),
+                'min_amount' => $channel['min'] ?? null, 'max_amount' => $channel['max'] ?? null,
+                'fixed_amounts' => self::types($channel['fixed'] ?? []),
+                'sort' => (int)$channel['sort'], 'status' => (int)$channel['status'],
+                'available_group_ids' => $ids,
+                'provider_code' => $identity['provider_code'] ?? null,
+                'payment_method' => $identity['method_code'] ?? null,
+                'payment_method_label' => $identity['method_label'] ?? null,
+                'selectable' => $identity && $enabled && $ids !== [],
+                'unavailable_reason' => !$identity ? '不属于第三方支付通道' : (!$enabled ? '通道已停用' : ($ids === [] ? '没有包含此通道的组合' : '')),
+            ];
+        }
+
+        $selectedChannels = array_values(array_filter($formatted, static function ($channel) use ($direction, $selectedProvider, $selectedMethod) {
+            if ($direction !== null && ($channel['data_type'] !== $direction || !$channel['provider_code'])) return false;
+            if ($selectedProvider !== '' && $channel['provider_code'] !== $selectedProvider) return false;
+            return $selectedMethod === '' || $channel['payment_method'] === $selectedMethod;
+        }));
+        return [
+            'payment_companies' => $this->companies($formatted, $direction),
+            'payment_methods' => $this->methods($formatted, $direction, $company),
+            'collection_providers' => $this->companies($formatted, 1),
+            'collection_methods' => $this->methods($formatted, 1, $collector),
+            'recharge_channels' => $selectedChannels,
+            'channel_groups' => $groups,
+            'levels' => array_map(static fn ($group) => ['value' => $group['id'], 'label' => $group['name']], $groups),
+            'credential_source' => 'environment',
+        ];
+    }
+
+    private function companies(array $channels, ?int $direction): array
+    {
+        $result = [];
+        foreach (self::PROVIDERS as $code => $provider) {
+            if ($direction !== null && !in_array($direction, $provider['directions'], true)) continue;
+            $matching = array_filter($channels, static fn ($channel) => $channel['provider_code'] === $code
+                && ($direction === null || $channel['data_type'] === $direction) && $channel['selectable']);
+            $result[] = [
+                'value' => $code, 'label' => $provider['label'], 'disabled' => $matching === [],
+                'signature_algorithm' => $provider['signature'],
+                'secret_field' => $direction === null ? null : ($direction === 2 ? 'withdrawal_secret' : 'deposit_secret'),
+            ];
+        }
+        return $result;
+    }
+
+    private function methods(array $channels, ?int $direction, string $provider): array
+    {
+        $result = [];
+        foreach (self::METHODS as $dataType => $methods) {
+            if ($direction !== null && $direction !== $dataType) continue;
+            foreach ($methods as $code => [$company, $label]) {
+                if ($provider !== '' && $provider !== $company) continue;
+                $matching = array_filter($channels, static fn ($channel) => $channel['provider_code'] === $company
+                    && $channel['data_type'] === $dataType && $channel['payment_method'] === $code);
+                $selectable = array_filter($matching, static fn ($channel) => $channel['selectable']);
+                $result[] = [
+                    'value' => $code, 'label' => $label, 'provider_code' => $company, 'data_type' => $dataType,
+                    'channel_ids' => array_values(array_column($matching, 'id')),
+                    'selectable_channel_ids' => array_values(array_column($selectable, 'id')),
+                    'disabled' => $selectable === [],
+                ];
+            }
+        }
+        return $result;
+    }
+
+    private static function types($value): array
+    {
+        $values = is_array($value) ? $value : explode(',', (string)$value);
+        return array_values(array_filter(array_map(static fn ($item) => trim((string)$item), $values), static fn ($item) => $item !== ''));
+    }
+}

+ 16 - 0
app/Services/PaymentChannelLinkService.php

@@ -8,6 +8,7 @@ use Illuminate\Database\Eloquent\Collection as EloquentCollection;
 use Illuminate\Database\Eloquent\Model;
 use Illuminate\Database\Eloquent\Model;
 use Illuminate\Support\Collection;
 use Illuminate\Support\Collection;
 use RuntimeException;
 use RuntimeException;
+use App\Services\Payment\PaymentProviderCatalog;
 
 
 class PaymentChannelLinkService
 class PaymentChannelLinkService
 {
 {
@@ -79,6 +80,21 @@ class PaymentChannelLinkService
         }
         }
 
 
         $data = $model->toArray();
         $data = $model->toArray();
+        $identity = $channel ? PaymentProviderCatalog::channelIdentity($channel->toArray()) : null;
+        if (array_key_exists('payment_company', $data)) {
+            $data['payment_company_label'] = PaymentProviderCatalog::label((string)$data['payment_company']);
+            $data['payment_method_label'] = $identity && $identity['method_code'] === $data['payment_method']
+                ? $identity['method_label'] : (string)$data['payment_method'];
+        }
+        if (array_key_exists('provider_name', $data)) {
+            $data['provider_label'] = PaymentProviderCatalog::label((string)$data['provider_name']);
+            $data['collection_method_label'] = $identity && $identity['method_code'] === $data['collection_method']
+                ? $identity['method_label'] : (string)$data['collection_method'];
+        }
+        if (array_key_exists('payment_company', $data) || array_key_exists('provider_name', $data)) {
+            $data['credential_source'] = 'environment';
+            $data['merchant_config_applied'] = false;
+        }
         $data['recharge_channel_group_ids'] = $assignedIds;
         $data['recharge_channel_group_ids'] = $assignedIds;
         $data['effective_group_ids'] = $effectiveIds;
         $data['effective_group_ids'] = $effectiveIds;
         $data['unavailable_group_ids'] = $unavailableIds;
         $data['unavailable_group_ids'] = $unavailableIds;

+ 26 - 0
database/migrations/2026_09_07_120000_add_deposit_secret_to_payment_gateways.php

@@ -0,0 +1,26 @@
+<?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
+    {
+        if (!Schema::hasColumn('payment_gateways', 'deposit_secret')) {
+            Schema::table('payment_gateways', function (Blueprint $table) {
+                $table->text('deposit_secret')->nullable()->comment('存款签名密钥,加密存储');
+            });
+        }
+    }
+
+    public function down(): void
+    {
+        if (Schema::hasColumn('payment_gateways', 'deposit_secret')) {
+            Schema::table('payment_gateways', function (Blueprint $table) {
+                $table->dropColumn('deposit_secret');
+            });
+        }
+    }
+};

+ 60 - 0
docs/前端接口/支付配置/下拉选项-前端变更说明.md

@@ -0,0 +1,60 @@
+# 支付配置下拉选项-前端变更说明
+
+## 接口 Path 不变,options 新增联动参数
+
+```text
+GET /admin/paymentConfig/options?kind=deposit
+GET /admin/paymentConfig/options?kind=deposit&payment_company=sanjin
+GET /admin/paymentConfig/options?kind=deposit&payment_company=sanjin&payment_method=zfbsm
+GET /admin/paymentConfig/options?kind=withdraw&payment_company=jd
+GET /admin/paymentConfig/options?kind=collection_scan&provider_name=no
+```
+
+`kind`:`deposit` 存款、`withdraw` 出款、`collection_scan` 代收扫描、`collection_third_party` 代收三方。
+
+代收按 `provider_name`、`collection_method` 联动;
+网关按 `payment_company`、`payment_method` 联动。
+
+## 返回变化
+
+以下四个数组从“文字数组”改为 Select 对象数组,统一使用 `value` 提交、`label` 显示、`disabled` 禁用:
+
+| 字段 | 用途 |
+|---|---|
+| `payment_companies` | 支付公司 |
+| `payment_methods` | 所选公司的支付方式 |
+| `collection_providers` | 代收三方(已接入的存款支付商) |
+| `collection_methods` | 所选代收商的收款方式 |
+
+示例(部分字段):
+
+```json
+{
+  "payment_companies": [
+    {"value":"sanjin","label":"三斤支付","disabled":false,"signature_algorithm":"MD5","secret_field":"deposit_secret"}
+  ],
+  "payment_methods": [
+    {"value":"zfbsm","label":"支付宝扫码","provider_code":"sanjin","data_type":1,"channel_ids":[6,7,8,9],"selectable_channel_ids":[6,7,8,9],"disabled":false}
+  ]
+}
+```
+
+公司选项已返回 `signature_algorithm`(签名算法)和 `secret_field`(密钥提交字段名)。前端直接使用当前选中公司对象的字段:标签显示 `${selectedCompany.signature_algorithm} 签名密钥`,密钥提交到 `selectedCompany.secret_field`,无需按公司名称做映射。网关取 `data.payment_companies[]`;代收公司也通过 `data.collection_providers[]` 返回签名算法。
+
+这里是支付签名算法的展示信息,前端不要对输入的密钥自行做 MD5/SHA256 处理,也不要提交或修改 `signature_algorithm`。网关表单请求 options 时必须传 `kind=deposit` 或 `kind=withdraw`,以获得对应的 `secret_field`。
+
+`recharge_channels` 新增 `provider_code`、`payment_method`、`payment_method_label`、`selectable`、`unavailable_reason`;原费率、限额、固定金额及 `available_group_ids` 保留。通道和组合 ID 从接口取,不要写死。
+
+前端顺序:公司 → 支付方式 → 充值/提现通道 → 通道组合。切换上游选项时清空下游选择并重新请求。选择通道后用 `available_group_ids` 过滤 `channel_groups`。列表筛选项均可留空,不影响打开新增弹窗。
+
+## 保存参数、列表返回变化
+
+- `POST /admin/paymentConfig/gateways/save`:`payment_company` 提交公司代码;`payment_method` 提交方式代码,存款和出款均必填。
+- 存款新增必填 `deposit_secret`;出款填写 `withdrawal_secret`,字段名直接取所选公司返回的 `secret_field`。编辑同一商户不换密钥时不传或传空字符串;更换公司/商户号必须重新填密钥。签名算法直接渲染接口返回的 `signature_algorithm`。
+- `POST /admin/paymentConfig/collections/save`:`provider_name` 提交公司代码,`collection_method` 提交方式代码;`group_name` 仍是手填群名称。
+- 网关列表/保存结果新增 `payment_company_label`、`payment_method_label`、`has_deposit_secret`;代收新增 `provider_label`、`collection_method_label`。公司和方式的原字段返回代码。
+- 两种密钥不回传明文。列表使用 `has_deposit_secret/has_withdrawal_secret` 表示已配置。
+
+## 页面状态显示
+
+`merchant_config_applied=false` 时显示“配置已保存,尚未应用”。通道启停、费率和组合在充值通道菜单管理。

+ 5 - 21
docs/前端接口/支付配置/与充值通道关联说明.md

@@ -1,17 +1,6 @@
 # 支付配置与充值通道的关联
 # 支付配置与充值通道的关联
 
 
-## 数据职责
-
-支付相关页面必须共用现有充值通道体系,不能分别维护两份费率和限额。
-
-```text
-会员.recharge_channel_group_id
-  -> recharge_channel_group(充值/提现/活动 type 组合)
-  -> recharge_channel(名称、Key、type、费率、限额、固定金额、排序、状态)
-  -> payment_*(商户号、密钥、支付商通道编号、展示名称等扩展配置)
-```
-
-权威字段:
+## 页面字段归属
 
 
 | 字段 | 唯一维护位置 |
 | 字段 | 唯一维护位置 |
 |---|---|
 |---|---|
@@ -19,7 +8,8 @@
 | 费率、最小/最大金额、固定金额 | 充值通道 / 通道管理 |
 | 费率、最小/最大金额、固定金额 | 充值通道 / 通道管理 |
 | 排序、实际通道启停 | 充值通道 / 通道管理 |
 | 排序、实际通道启停 | 充值通道 / 通道管理 |
 | 哪些会员组合可见 | 充值通道 / 层级设置 |
 | 哪些会员组合可见 | 充值通道 / 层级设置 |
-| 支付公司、商户号、出款密钥、第三方通道编号 | 支付配置 |
+| 支付公司及其支持的支付方式 | 从 options 接口选择 |
+| 商户号、存款/出款密钥、第三方通道编号 | 支付配置 |
 | 扫描直充金额档位和名称 | 支付配置 / 扫描直充 |
 | 扫描直充金额档位和名称 | 支付配置 / 扫描直充 |
 
 
 充值通道现有接口保持不变:
 充值通道现有接口保持不变:
@@ -31,7 +21,7 @@
 
 
 ## 前端页面调整
 ## 前端页面调整
 
 
-1. 新增/编辑支付配置时先请求 `GET /admin/paymentConfig/options`。
+1. 新增/编辑支付配置时先请求 `GET /admin/paymentConfig/options`,网关/代收须传对应 `kind`,按公司→支付方式→通道逐级联动;Select 返回格式及参数见 [前端变更说明](下拉选项-前端变更说明.md)。扫描直充仍可不传 `kind` 取通道
 2. 根据 Tab 过滤 `recharge_channels`:
 2. 根据 Tab 过滤 `recharge_channels`:
    - 存款网关、代收、扫描直充:`data_type=1`;
    - 存款网关、代收、扫描直充:`data_type=1`;
    - 出款网关:`data_type=2`。
    - 出款网关:`data_type=2`。
@@ -41,12 +31,6 @@
    - `config_status=1`;
    - `config_status=1`;
    - `channel_status=1`;
    - `channel_status=1`;
    - 至少一个所选组合仍包含该通道。
    - 至少一个所选组合仍包含该通道。
-   `runtime_channel_available` 只表示现有充值/提现接口实际允许该通道;当前支付商仍使用环境变量时,`config_status` 不会单独关闭线上通道,实际启停必须修改充值通道的 `channel_status`
+   `runtime_channel_available` 表示通道可用状态;`config_status` 是当前配置的开关。实际通道启停在充值通道菜单管理
 6. 若返回 `unavailable_group_ids`,说明层级设置后来移除了该通道,前端应显示“组合已失效”并要求重新选择。
 6. 若返回 `unavailable_group_ids`,说明层级设置后来移除了该通道,前端应显示“组合已失效”并要求重新选择。
 7. 旧支付配置若尚未关联通道,会返回 `recharge_channel=null`、`effective_status=0`;必须编辑并选择通道及组合后才能视为有效配置。
 7. 旧支付配置若尚未关联通道,会返回 `recharge_channel=null`、`effective_status=0`;必须编辑并选择通道及组合后才能视为有效配置。
-
-## 与现有用户接口的关系
-
-现有 `GET /api/wallet/channel`、`GET /api/wallet/withdrawChannel`、创建充值和提现接口仍以 `RechargeChannel::getFormatChannel/checkRechargeChannel/checkWithdrawChannel` 为准。此次同时补强了服务端校验:被停用或不属于会员组合的通道不能通过直接请求绕过。
-
-支付商真实下单和回调仍由现有 JD、NO、808、三斤、钱宝服务完成;支付配置的商户扩展字段在各支付商协议接入数据库配置前,不会取代现有环境变量。

+ 9 - 9
docs/前端接口/支付配置/代收代付/接口.md

@@ -2,7 +2,7 @@
 
 
 后台接口前缀:`/admin`。请求头:`Authorization: Bearer <admin_token>`。
 后台接口前缀:`/admin`。请求头:`Authorization: Bearer <admin_token>`。
 
 
-下拉选项使用 `GET /admin/paymentConfig/options`。通道选择使用 `recharge_channels` 中 `data_type=1` 的记录;层级使用 `channel_groups`,兼容字段 `levels` 同样表示通道组合
+下拉选项使用 `GET /admin/paymentConfig/options?kind=collection_scan` 或 `collection_third_party`。代收三方来自已接入的存款支付商目录;Select 显示 `label`、提交 `value`。追加 `provider_name`、`collection_method` 联动获取方式和通道。详见 [前端变更说明](../下拉选项-前端变更说明.md)
 
 
 ## 列表
 ## 列表
 
 
@@ -11,8 +11,8 @@
 | 参数 | 类型 | 必填 | 中文含义 |
 | 参数 | 类型 | 必填 | 中文含义 |
 |---|---|---|---|
 |---|---|---|---|
 | `kind` | string | 是 | `collection_scan` 代收扫描;`collection_third_party` 代收三方 |
 | `kind` | string | 是 | `collection_scan` 代收扫描;`collection_third_party` 代收三方 |
-| `provider_name` | string | 否 | 代收三方名称 |
-| `collection_method` | string | 否 | 收款方式,如微信、支付宝、UPI |
+| `provider_name` | string | 否 | 代收公司代码:sanjin/jd/no/zimu |
+| `collection_method` | string | 否 | 收款方式代码,取 options 的 value |
 | `recharge_channel_id` | int | 否 | 关联充值通道 ID |
 | `recharge_channel_id` | int | 否 | 关联充值通道 ID |
 | `group_id` | int | 否 | 关联通道组合 ID |
 | `group_id` | int | 否 | 关联通道组合 ID |
 | `status` | int | 否 | `1` 启用,`0` 停用 |
 | `status` | int | 否 | `1` 启用,`0` 停用 |
@@ -22,9 +22,9 @@
 
 
 | 字段 | 中文含义 |
 | 字段 | 中文含义 |
 |---|---|
 |---|---|
-| `provider_name` | 代收三方 |
+| `provider_name/provider_label` | 代收公司代码/显示名称 |
 | `group_name` | 群名称 |
 | `group_name` | 群名称 |
-| `collection_method` | 收款方式 |
+| `collection_method/collection_method_label` | 收款方式代码/显示名称 |
 | `channel_code/channel_name` | 通道编号/通道名称 |
 | `channel_code/channel_name` | 通道编号/通道名称 |
 | `recharge_channel` | 充值通道菜单中的权威通道配置 |
 | `recharge_channel` | 充值通道菜单中的权威通道配置 |
 | `fee_rate/min_amount/max_amount/sort` | 从关联通道实时读取,只读 |
 | `fee_rate/min_amount/max_amount/sort` | 从关联通道实时读取,只读 |
@@ -40,13 +40,13 @@
 {
 {
   "id": 1,
   "id": 1,
   "recharge_channel_id": 2,
   "recharge_channel_id": 2,
-  "recharge_channel_group_ids": [1, 2],
+  "recharge_channel_group_ids": [1],
   "kind": "collection_third_party",
   "kind": "collection_third_party",
   "currency": "RMB",
   "currency": "RMB",
-  "provider_name": "富基代收",
+  "provider_name": "sanjin",
   "group_name": "AA支付",
   "group_name": "AA支付",
-  "collection_method": "微信",
-  "channel_code": "90",
+  "collection_method": "wxsm",
+  "channel_code": "WX002",
   "channel_name": "支付1",
   "channel_name": "支付1",
   "status": 1
   "status": 1
 }
 }

+ 16 - 13
docs/前端接口/支付配置/网关支付/接口.md

@@ -4,7 +4,7 @@
 
 
 统一成功返回:`{"code":0,"msg":"OK","data":...}`;分页数据为 `data.total`、`data.data`。
 统一成功返回:`{"code":0,"msg":"OK","data":...}`;分页数据为 `data.total`、`data.data`。
 
 
-下拉选项:`GET /admin/paymentConfig/options`。`recharge_channels` 来自充值通道菜单;`channel_groups` 来自层级设置;兼容字段 `levels` 也是这些通道组合,不再是会员等级。接口还返回 `payment_companies`、`payment_methods`、`collection_providers`、`collection_methods`
+下拉选项:`GET /admin/paymentConfig/options?kind=deposit`(出款用 `withdraw`)。公司来自独立预置目录,返回 `{value,label,disabled}` 对象数组;选择公司后追加 `payment_company=<value>` 获取方式,选择方式后追加 `payment_method=<value>` 获取通道。详见 [前端变更说明](../下拉选项-前端变更说明.md)
 
 
 ## 列表
 ## 列表
 
 
@@ -13,8 +13,8 @@
 | 参数 | 类型 | 必填 | 中文含义 |
 | 参数 | 类型 | 必填 | 中文含义 |
 |---|---|---|---|
 |---|---|---|---|
 | `kind` | string | 是 | `deposit` 存款网关;`withdraw` 出款网关 |
 | `kind` | string | 是 | `deposit` 存款网关;`withdraw` 出款网关 |
-| `payment_company` | string | 否 | 支付公司 |
-| `payment_method` | string | 否 | 支付方式 |
+| `payment_company` | string | 否 | 支付公司代码:sanjin/qianbao/jd/no/zimu |
+| `payment_method` | string | 否 | 支付方式代码,取 options 的 value |
 | `recharge_channel_id` | int | 否 | 关联的充值/提现通道 ID |
 | `recharge_channel_id` | int | 否 | 关联的充值/提现通道 ID |
 | `group_id` | int | 否 | 通道组合 ID |
 | `group_id` | int | 否 | 通道组合 ID |
 | `status` | int | 否 | `1` 启用,`0` 停用 |
 | `status` | int | 否 | `1` 启用,`0` 停用 |
@@ -26,18 +26,20 @@
 |---|---|
 |---|---|
 | `kind` | 存款或出款网关 |
 | `kind` | 存款或出款网关 |
 | `currency` | 币种,默认 `RMB` |
 | `currency` | 币种,默认 `RMB` |
-| `payment_company` | 支付公司 |
+| `payment_company/payment_company_label` | 支付公司代码/显示名称 |
 | `merchant_no` | 商户号 |
 | `merchant_no` | 商户号 |
-| `payment_method` | 支付方式;出款网关可为空 |
+| `payment_method/payment_method_label` | 支付方式代码/显示名称 |
 | `channel_code/channel_name` | 通道编号/通道名称 |
 | `channel_code/channel_name` | 通道编号/通道名称 |
 | `recharge_channel` | 充值通道菜单中的权威配置 |
 | `recharge_channel` | 充值通道菜单中的权威配置 |
 | `fee_rate/min_amount/max_amount/sort` | 从关联通道实时读取,只读 |
 | `fee_rate/min_amount/max_amount/sort` | 从关联通道实时读取,只读 |
 | `groups` | 关联组合及当前是否仍有效 |
 | `groups` | 关联组合及当前是否仍有效 |
 | `config_status/channel_status/runtime_channel_available/effective_status` | 支付商配置状态/通道状态/线上通道可用/后台配置最终状态 |
 | `config_status/channel_status/runtime_channel_available/effective_status` | 支付商配置状态/通道状态/线上通道可用/后台配置最终状态 |
 | `has_withdrawal_secret` | 是否已保存出款密钥 |
 | `has_withdrawal_secret` | 是否已保存出款密钥 |
+| `has_deposit_secret` | 是否已保存存款密钥 |
+| `credential_source/merchant_config_applied` | 当前实际凭据来源/后台凭据是否已用于支付 |
 | `operator_id/operator_name` | 最近操作人 |
 | `operator_id/operator_name` | 最近操作人 |
 
 
-`withdrawal_secret` 永远不会在列表和保存响应中回传。
+`withdrawal_secret`、`deposit_secret` 永远不会在列表和保存响应中回传。
 
 
 ## 新增或编辑
 ## 新增或编辑
 
 
@@ -49,12 +51,13 @@
 {
 {
   "kind": "deposit",
   "kind": "deposit",
   "recharge_channel_id": 2,
   "recharge_channel_id": 2,
-  "recharge_channel_group_ids": [1, 2],
+  "recharge_channel_group_ids": [1],
   "currency": "RMB",
   "currency": "RMB",
-  "payment_company": "188pay",
-  "merchant_no": "f6f087b56a",
-  "payment_method": "UPI",
-  "channel_code": "90",
+  "payment_company": "sanjin",
+  "merchant_no": "你的商户号",
+  "payment_method": "wxsm",
+  "deposit_secret": "商户后台提供的签名密钥",
+  "channel_code": "WX002",
   "channel_name": "支付1",
   "channel_name": "支付1",
   "status": 1
   "status": 1
 }
 }
@@ -62,7 +65,7 @@
 
 
 存款网关必须选择 `data_type=1` 的充值通道;出款网关必须选择 `data_type=2` 的提现通道。所选组合必须包含通道 `type`。费率、限额、排序、通道状态请在“充值通道”菜单修改。
 存款网关必须选择 `data_type=1` 的充值通道;出款网关必须选择 `data_type=2` 的提现通道。所选组合必须包含通道 `type`。费率、限额、排序、通道状态请在“充值通道”菜单修改。
 
 
-出款网关使用 `kind=withdraw`,且必须存在 `withdrawal_secret`。编辑时传 `id`;不修改密钥时不要传该字段或传空字符串。`kind` 创建后不可在存款/出款之间修改。
+公司和方式均使用 options 的代码且必须与通道匹配。存款需要 `deposit_secret`,出款需要 `withdrawal_secret`,两者都必须提交 `payment_method`。编辑时传 `id`;同一商户不修改密钥时不要传密钥字段或传空字符串,更换公司/商户号必须重新填写。`kind` 创建后不可修改。
 
 
 ## 状态、删除、操作记录
 ## 状态、删除、操作记录
 
 
@@ -70,4 +73,4 @@
 - `POST /admin/paymentConfig/gateways/delete`:`{"id":1}`。
 - `POST /admin/paymentConfig/gateways/delete`:`{"id":1}`。
 - `GET /admin/paymentConfig/logs?resource_type=payment_gateway&resource_id=1&page=1&limit=20`
 - `GET /admin/paymentConfig/logs?resource_type=payment_gateway&resource_id=1&page=1&limit=20`
 
 
-密钥在数据库中加密保存,操作记录仅显示 `******`
+密钥不回传明文,已配置状态使用 `has_deposit_secret/has_withdrawal_secret` 展示

+ 52 - 0
docs/后端/支付配置-下拉选项与商户配置.md

@@ -0,0 +1,52 @@
+# 支付配置-后端说明(2026-09-07)
+
+## 本次改动
+
+- `PaymentProviderCatalog` 维护三斤、钱宝、JD、NO、808 目录及支付方式,不再从商户记录反查下拉选项。
+- `PaymentConfiguration::options()` 读取充值通道和组合生成可选项;保存时验证公司、方式、通道匹配。
+- `PaymentGateway` 新增加密字段 `deposit_secret`;两类密钥均隐藏,仅返回是否配置。相同商户编辑时空值保留密钥,更换公司/商户号必须重新填写。
+- 配置及审计仍在同一事务内保存。
+
+## 数据来源与职责
+
+`users.recharge_channel_group_id` 关联 `recharge_channel_group`。组合按 `recharge_type/withdraw_type/activity_type` 包含通道 `type`。`recharge_channel` 提供费率、限额、固定金额、排序和状态;支付配置表保存商户扩展信息。
+
+按用户 2026-09-07 提供的服务器数据核对:
+
+| 公司代码 | 充值通道 type | 提现通道 type |
+|---|---|---|
+| `sanjin` | `wxsm,ylsm,szrmb,zfbsm,zfbge,zfbzk,sdjt` | 无 |
+| `qianbao` | 无 | `DF001,DF002,DF005` |
+| `jd` | `JDpay` | `JDpay` |
+| `no` | `NOpay12,NOpay13` | `NOwithdraw` |
+| `zimu` | `ZIMUpay` | `ZIMUwithdraw,ZIMUcash` |
+
+三斤与钱宝仅在组合 1;JD、NO、808账户提现在组合 1、2。`ZIMUcash` 已停用且无所属组合。USDT、人工充值/提现、活动不属于第三方公司目录。两个代收类型当前共用存款支付商目录,没有单独线下代收商资料。
+
+NO 服务使用 SHA256,其余四家使用 MD5;目录返回 `signature_algorithm` 供前端显示,不由前端维护映射。
+
+## 发布步骤
+
+目标项目:`bot-28`,需已完成之前支付配置的建表与通道关联迁移。备份后,发布本次代码并执行新增迁移:
+
+```bash
+php artisan migrate --path=database/migrations/2026_09_07_120000_add_deposit_secret_to_payment_gateways.php
+```
+
+新迁移只增加 `payment_gateways.deposit_secret`,不会插入或覆盖服务器通道、组合、商户数据;无需导入用户提供的 INSERT。加密字段依赖现有 `APP_KEY`,发布时保持原值。
+
+## 实际支付生效范围
+
+真实下单、查单、回调仍使用现有环境变量凭据;本次补齐目录与商户配置保存,未切换支付调用。接口返回 `credential_source=environment`、`merchant_config_applied=false`。
+
+下一步接入后台凭据时,需要在订单中记录实际使用的商户配置,并保留旧订单使用的凭据版本,保证改配置后查单及验签仍正确。`config_status` 当前不会关闭线上通道;实际启停仍由充值通道菜单控制。
+
+## 验证记录
+
+基于用户提供的 31 条通道、2 个组合,离线回归 70 项通过:
+
+```bash
+php tests/Regression/payment_provider_catalog.php
+```
+
+PHP 语法、Composer 和文档检查通过。尚未在服务器执行迁移、实际商户保存或真实支付联调。

+ 6 - 3
docs/前端接口/实现差异与未完成项.md → docs/后端/运营配置-实现差异与未完成项.md

@@ -1,4 +1,6 @@
-# 实现差异与未完成项
+# 运营配置-后端实现差异与未完成项
+
+本文供后端开发和部署人员查看。支付配置本次发布步骤见 [支付配置后端说明](支付配置-下拉选项与商户配置.md)。
 
 
 ## 已按截图实现
 ## 已按截图实现
 
 
@@ -7,6 +9,7 @@
 - 分享赚钱:按现有会员邀请码 `user_code -> agent_user_code` 统计注册关系,提供推广会员列表、注册详情、推广汇总、推广设置、禁止返佣类目和会员端分享链接。
 - 分享赚钱:按现有会员邀请码 `user_code -> agent_user_code` 统计注册关系,提供推广会员列表、注册详情、推广汇总、推广设置、禁止返佣类目和会员端分享链接。
 - 所有支付密钥字段只允许写入,使用 Laravel 加密字段保存;列表、保存响应、操作记录都不返回明文。
 - 所有支付密钥字段只允许写入,使用 Laravel 加密字段保存;列表、保存响应、操作记录都不返回明文。
 - 支付配置已关联现有 `recharge_channel` 和 `recharge_channel_group`;费率、限额、排序、通道启停和会员组合不再由支付配置重复维护。
 - 支付配置已关联现有 `recharge_channel` 和 `recharge_channel_group`;费率、限额、排序、通道启停和会员组合不再由支付配置重复维护。
+- 支付公司/方式改为已接入服务的预置目录,独立于商户列表;存款密钥通过 `deposit_secret` 加密保存。前端联动、参数和返回变化见 [支付下拉选项变更](../前端接口/支付配置/下拉选项-前端变更说明.md)。
 
 
 ## 与截图有意调整
 ## 与截图有意调整
 
 
@@ -21,9 +24,9 @@
 
 
 ### 1. 支付商真实下单/回调
 ### 1. 支付商真实下单/回调
 
 
-现有系统的 JD、NO、808 等支付服务各自使用不同网关地址、签名字段、回调验签和错误码。截图只有通用表单,没有提供每家支付商的请求协议,因此本次完成的是后台配置和审计 API,**没有让通用配置表直接替换现有线上支付服务**
+现有系统的三斤、钱宝、JD、NO、808 支付服务已有对应协议实现;公司/方式目录已据此补齐。后台商户配置还没有被真实下单、查单和回调读取,目前仍使用环境变量。需要按订单记录实际使用的商户配置,保证配置修改后旧订单仍用原凭据查单及验签,不能仅替换下单时的密钥读取
 
 
-要完成动态切换,需要每个支付公司的:
+若后续接入上述五家之外的支付商,需提供该公司的:
 
 
 - 代收、代付创建订单地址;
 - 代收、代付创建订单地址;
 - 商户号、密钥和签名算法字段映射;
 - 商户号、密钥和签名算法字段映射;

+ 71 - 0
tests/Regression/payment_provider_catalog.php

@@ -0,0 +1,71 @@
+<?php
+
+// 无需 Laravel 或数据库:php tests/Regression/payment_provider_catalog.php
+require_once __DIR__ . '/../../app/Services/Payment/PaymentProviderCatalog.php';
+
+use App\Services\Payment\PaymentProviderCatalog;
+
+$fixture = require __DIR__ . '/../fixtures/payment_provider_channels.php';
+$catalog = new PaymentProviderCatalog();
+$checks = 0;
+$check = static function ($expected, $actual, string $scenario) use (&$checks): void {
+    if ($expected !== $actual) throw new RuntimeException($scenario . ': ' . json_encode($actual, JSON_UNESCAPED_UNICODE));
+    $checks++;
+};
+$options = static fn (array $filters = []) => $catalog->options($fixture['channels'], $fixture['groups'], $filters);
+
+$deposit = $options(['kind' => 'deposit']);
+$withdraw = $options(['kind' => 'withdraw']);
+$check(['sanjin', 'jd', 'no', 'zimu'], array_column($deposit['payment_companies'], 'value'), '空商户表也有四家存款公司');
+$check(['qianbao', 'jd', 'no', 'zimu'], array_column($withdraw['payment_companies'], 'value'), '提现公司按已实现方向返回');
+$check([false, false, false, false], array_column($deposit['payment_companies'], 'disabled'), '实际数据四家均有可选通道');
+$check([false, false, false, false], array_column($withdraw['payment_companies'], 'disabled'), '实际数据四家提现均有可选通道');
+$check('environment', $deposit['credential_source'], '不把保存配置冒充运行时凭据');
+
+$allChannels = array_column($options()['recharge_channels'], null, 'id');
+foreach ([1, 15, 16, 20, 21, 22, 23] as $id) {
+    $check(null, $allChannels[$id]['provider_code'], 'USDT/人工/活动不归入第三方公司 ' . $id);
+}
+foreach ([2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14] as $id) {
+    $check('sanjin', $allChannels[$id]['provider_code'], '三斤通道映射 ' . $id);
+    $check([1], $allChannels[$id]['available_group_ids'], '三斤不属于线下组合 ' . $id);
+}
+foreach ([24, 25, 26, 27, 28, 29, 30] as $id) {
+    $check([1, 2], $allChannels[$id]['available_group_ids'], 'JD/NO/808支持两个组合 ' . $id);
+}
+foreach ([17, 18, 19] as $id) {
+    $check('qianbao', $allChannels[$id]['provider_code'], '钱宝提现映射 ' . $id);
+    $check([1], $allChannels[$id]['available_group_ids'], '钱宝提现不属于线下组合 ' . $id);
+}
+$check(false, $allChannels[31]['selectable'], 'ZIMUcash停用不可选');
+$check([], $allChannels[31]['available_group_ids'], 'ZIMUcash无所属组合');
+$check('0.2000', $allChannels[25]['fee_rate'], 'JD提现原始费率转换为百分比');
+$check('9.5000', $allChannels[2]['fee_rate'], '微信费率转换为百分比');
+$check(['945', '988', '990'], $allChannels[10]['fixed_amounts'], '固额金额保留');
+
+$filtered = $options(['kind' => 'deposit', 'payment_company' => 'sanjin', 'payment_method' => 'zfbsm']);
+$check([6, 7, 8, 9], array_column($filtered['recharge_channels'], 'id'), '支付宝扫码保留全部四个额度通道');
+$check(7, count($filtered['payment_methods']), '选方式不截断公司方式下拉');
+$no = $options(['kind' => 'collection_scan', 'provider_name' => 'no']);
+$check(['NOpay12', 'NOpay13'], array_column($no['collection_methods'], 'value'), '代收NO方式联动');
+$check([26, 27], array_column($no['recharge_channels'], 'id'), '代收NO通道联动');
+$check('SHA256', array_column($deposit['payment_companies'], null, 'value')['no']['signature_algorithm'], 'NO不标成MD5');
+$check([25], array_column($options(['kind' => 'withdraw', 'payment_company' => 'jd'])['recharge_channels'], 'id'), '同名JD type按充值提现分开');
+$check(true, array_column($options(['kind' => 'withdraw', 'payment_company' => 'zimu'])['payment_methods'], null, 'value')['ZIMUcash']['disabled'], '禁用方式仍可见但不能选择');
+$empty = $catalog->options([], [], ['kind' => 'deposit']);
+$check(4, count($empty['payment_companies']), '没有通道时目录仍存在');
+$check([true, true, true, true], array_column($empty['payment_companies'], 'disabled'), '没有通道不伪造可用');
+$unknown = $fixture['channels'][0];
+$unknown['from'] = 1;
+$unknown['type'] = 'unimplemented';
+$check(null, PaymentProviderCatalog::channelIdentity($unknown), '未知通道不默认发给三斤');
+
+$raw = array_column($fixture['channels'], null, 'id');
+PaymentProviderCatalog::validateSelection($raw[2], 'sanjin', 'wxsm', 1);
+foreach ([[$raw[2], 'jd', 'wxsm', 1], [$raw[2], 'sanjin', 'zfbsm', 1], [$raw[24], 'jd', 'JDpay', 2], [$raw[15], 'sanjin', 'rgcz', 1]] as $args) {
+    $rejected = false;
+    try { PaymentProviderCatalog::validateSelection(...$args); } catch (InvalidArgumentException $e) { $rejected = true; }
+    $check(true, $rejected, '保存拒绝跨公司/跨方向/手填方式/人工充值');
+}
+
+echo "Payment provider catalog: {$checks} checks passed.\n";

+ 49 - 0
tests/fixtures/payment_provider_channels.php

@@ -0,0 +1,49 @@
+<?php
+
+// 2026-09-07 用户提供的通道/组合快照,仅用于离线回归,不写入数据库。
+$rows = [
+    [1, 1, 2, 'USDT', 'USDT充值', 'usdt', '0.0000', 1, 1000000, null, 1, 1],
+    [2, 1, 1, 'WX002', '微信扫码', 'wxsm', '0.0950', 100, 3000, null, 10, 1],
+    [3, 1, 1, 'YL001', '银联扫码', 'ylsm', '0.0500', 50, 500, null, 20, 1],
+    [5, 1, 1, 'SZ001', '数字人民币', 'szrmb', '0.0480', 100, 5000, null, 25, 1],
+    [4, 1, 1, 'SZ002', '数字人民币', 'szrmb', '0.0500', 10, 100, null, 30, 1],
+    [6, 1, 1, 'ZFB001', '支付宝扫码', 'zfbsm', '0.0850', 100, 200, null, 40, 1],
+    [7, 1, 1, 'ZFB002', '支付宝扫码', 'zfbsm', '0.0570', 200, 1000, null, 41, 1],
+    [8, 1, 1, 'ZFB003', '支付宝扫码', 'zfbsm', '0.0520', 1000, 3000, null, 42, 1],
+    [9, 1, 1, 'ZFB004', '支付宝扫码', 'zfbsm', '0.0420', 3000, 20000, null, 43, 1],
+    [10, 1, 1, 'ZFB005', '支付宝固额', 'zfbge', '0.0270', null, null, '945,988,990', 44, 1],
+    [11, 1, 1, 'ZFBZK001', '支付宝转卡', 'zfbzk', '0.0500', 200, 2000, null, 45, 1],
+    [12, 1, 1, 'JT000', '手动金条', 'sdjt', '0.0800', 100, 300, null, 50, 1],
+    [13, 1, 1, 'JT001', '手动金条', 'sdjt', '0.0800', 300, 3000, null, 51, 1],
+    [14, 1, 1, 'JT002', '手动金条', 'sdjt', '0.0800', 500, 5000, null, 52, 1],
+    [15, 1, 3, 'rgcz', '人工充值', 'rgcz', '0.0000', 10, 500000, null, 52, 1],
+    [29, 1, 1, 'ZIMUpay', '808充值', 'ZIMUpay', '0.0000', 10, 49999, '', 97, 1],
+    [26, 1, 1, 'NOpay12', 'NO快捷充值-扫码支付', 'NOpay12', '0.0000', 10, 49999, '', 98, 1],
+    [27, 1, 1, 'NOpay13', 'NO快捷充值-余额支付', 'NOpay13', '0.0000', 10, 49999, '', 98, 1],
+    [24, 1, 1, 'JDpay', 'JD钱包', 'JDpay', '0.0000', 1, 49999, '', 99, 1],
+    [16, 2, 2, 'USDT', 'USDT提现', 'usdt', '0.0000', 10, 10000, null, 0, 1],
+    [17, 2, 1, 'DF001', '银行卡', 'DF001', '0.0000', 100, 50000, null, 0, 1],
+    [18, 2, 1, 'DF002', '支付宝', 'DF002', '0.0000', 100, 50000, null, 0, 1],
+    [19, 2, 1, 'DF005', '数字人民币', 'DF005', '0.0000', 100, 50000, null, 0, 1],
+    [20, 2, 3, 'rgtx', '人工提现', 'rgtx', '0.0000', 100, 50000, null, 0, 1],
+    [30, 2, 1, 'ZIMUwithdraw', '808账户提现', 'ZIMUwithdraw', '0.0000', 10, 10000, '', 97, 1],
+    [31, 2, 1, 'ZIMUcash', '808人民币代付', 'ZIMUcash', '0.0000', 100, 49999, '', 97, 0],
+    [28, 2, 1, 'NOwithdraw', 'NO快捷提现', 'NOwithdraw', '0.0000', 10, 49999, '', 98, 1],
+    [25, 2, 1, 'JDpay', 'JD钱包', 'JDpay', '0.0020', 1, 49999, '', 99, 1],
+    [21, 3, 1, 'recharge', '即充即送', 'recharge', '0.0000', null, null, null, 0, 1],
+    [22, 3, 1, 'old_user', '老用户回归', 'old_user', '0.0000', null, null, null, 0, 1],
+    [23, 3, 1, 'yuebao', '余额宝', 'yuebao', '0.0000', null, null, null, 0, 1],
+];
+$fields = ['id', 'data_type', 'from', 'key', 'name', 'type', 'rate', 'min', 'max', 'fixed', 'sort', 'status'];
+return [
+    'channels' => array_map(static fn ($row) => array_combine($fields, $row), $rows),
+    'groups' => [
+        ['id' => 1, 'name' => '全部充值',
+            'recharge_type' => '1,2,3,usdt,szrmb,zfbsm,sdjt,wxsm,zfbge,zfbzk,ylsm,JDpay,NOpay12,NOpay13,ZIMUpay',
+            'withdraw_type' => 'usdt,ylk,zfb,szrmb,DF001,DF002,DF005,JDpay,NOwithdraw,ZIMUwithdraw',
+            'activity_type' => 'yuebao,old_user,recharge'],
+        ['id' => 2, 'name' => '层级一层(线下)',
+            'recharge_type' => 'usdt,rgcz,JDpay,NOpay12,NOpay13,ZIMUpay',
+            'withdraw_type' => 'rgtx,JDpay,NOwithdraw,ZIMUwithdraw', 'activity_type' => null],
+    ],
+];