doge 6 дней назад
Родитель
Сommit
109968a920

+ 16 - 1
app/Http/Controllers/admin/RechargeChannel.php

@@ -5,6 +5,8 @@ namespace App\Http\Controllers\admin;
 use App\Http\Controllers\Controller;
 use App\Models\RechargeChannel as RechargeChannelModel;
 use App\Models\RechargeChannelGroup;
+use App\Services\Payment\PaymentProviderCatalog;
+use Illuminate\Validation\Rule;
 use Exception;
 use App\Constants\HttpStatus;
 
@@ -100,6 +102,7 @@ class RechargeChannel extends Controller
                 'from' => ['nullable', 'integer'],
                 'key' => ['nullable', 'string'],
                 'name' => ['nullable', 'string'],
+                'payment_company' => ['nullable', Rule::in(PaymentProviderCatalog::codes())],
             ]);
             $page = request()->input('page', 1);
             $limit = request()->input('limit', 15);
@@ -120,11 +123,23 @@ class RechargeChannel extends Controller
             if (!empty($params['name'])) {
                 $query = $query->where('name', 'like', '%'.$params['name'].'%');
             }
+            if (!empty($params['payment_company'])) {
+                $direction = !empty($params['data_type']) ? (int)$params['data_type'] : null;
+                $query = $query->where('from', 1)->whereIn('data_type', [1, 2])->whereIn('type',
+                    PaymentProviderCatalog::channelTypesForProvider($params['payment_company'], $direction));
+            }
             $count = $query->count();
             $list = $query
                 ->forPage($page, $limit)
                 ->orderBy('sort', 'asc')
-                ->get();
+                ->orderBy('id', 'asc')
+                ->get()->map(function (RechargeChannelModel $channel) {
+                    $row = $channel->toArray();
+                    $identity = PaymentProviderCatalog::channelIdentity($row);
+                    $row['payment_company'] = $identity['provider_code'] ?? null;
+                    $row['payment_company_label'] = $identity ? PaymentProviderCatalog::label($identity['provider_code']) : '';
+                    return $row;
+                });
         } catch (Exception $e) {
             return $this->error(HttpStatus::CUSTOM_ERROR,$e->getMessage());
         }

+ 34 - 5
app/Services/Payment/PaymentProviderCatalog.php

@@ -51,6 +51,18 @@ class PaymentProviderCatalog
         return self::PROVIDERS[$code]['label'] ?? $code;
     }
 
+    public static function channelTypesForProvider(string $provider, ?int $direction = null): array
+    {
+        $types = [];
+        foreach (self::METHODS as $dataType => $methods) {
+            if ($direction !== null && $direction !== $dataType) continue;
+            foreach ($methods as $type => [$code]) {
+                if ($code === $provider) $types[] = $type;
+            }
+        }
+        return array_values(array_unique($types));
+    }
+
     public static function direction(?string $kind): ?int
     {
         return match ($kind) {
@@ -149,12 +161,12 @@ class PaymentProviderCatalog
         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']);
+                && ($direction === null || $channel['data_type'] === $direction));
             $result[] = [
-                'value' => $code, 'label' => $provider['label'], 'disabled' => $matching === [],
+                'value' => $code, 'label' => $provider['label'],
                 'signature_algorithm' => $provider['signature'],
                 'secret_field' => $direction === null ? null : ($direction === 2 ? 'withdrawal_secret' : 'deposit_secret'),
-            ];
+            ] + $this->availability($matching);
         }
         return $result;
     }
@@ -173,13 +185,30 @@ class PaymentProviderCatalog
                     '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 === [],
-                ];
+                ] + $this->availability($matching);
             }
         }
         return $result;
     }
 
+    private function availability(array $channels): array
+    {
+        if (array_filter($channels, static fn ($channel) => $channel['selectable']) !== []) {
+            return ['disabled' => false, 'disabled_reason_code' => '', 'disabled_reason' => ''];
+        }
+        if ($channels === []) {
+            $code = 'channel_missing';
+            $reason = '未配置匹配的通道';
+        } elseif (array_filter($channels, static fn ($channel) => $channel['status'] === 1) === []) {
+            $code = 'channel_disabled';
+            $reason = '匹配通道均已停用';
+        } else {
+            $code = 'group_missing';
+            $reason = '已启用通道尚未加入通道组合';
+        }
+        return ['disabled' => true, 'disabled_reason_code' => $code, 'disabled_reason' => $reason];
+    }
+
     private static function types($value): array
     {
         $values = is_array($value) ? $value : explode(',', (string)$value);

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

@@ -58,3 +58,27 @@ GET /admin/paymentConfig/options?kind=collection_scan&provider_name=no
 ## 页面状态显示
 
 `merchant_config_applied=false` 时显示“配置已保存,尚未应用”。通道启停、费率和组合在充值通道菜单管理。
+
+## 置灰原因与充值通道列表(补充)
+
+- options 的四类公司/方式选项新增 `disabled_reason`、`disabled_reason_code`。置灰时直接用 `disabled_reason` 展示提示;可选时两字段均为空字符串。原 `disabled` 含义不变。
+- 存款公司 Select 取 `GET /admin/paymentConfig/options?kind=deposit` 的 `data.payment_companies`,不要用分页的 `/admin/rechargeChannel/list` 结果重新计算禁用状态。
+- `GET /admin/rechargeChannel/list` 列表项新增 `payment_company`、`payment_company_label`。公司名称显示后者,通道名称仍显示原 `name`;非三方通道分别返回 `null`、空字符串。不要把 `from=1` 映射成固定公司名称。
+- 通道列表新增可选筛选参数 `payment_company`(公司代码),原参数不变。例如 `/admin/rechargeChannel/list?data_type=1&payment_company=jd`;存款用 `data_type=1`,提现用 `2`。
+- 通道列表保持分页:`data.total` 是总条数,`data.data` 是当前页,默认 15 条。正确接入分页后可查看后续通道。
+
+## 层级设置新增/编辑组合:选项来源
+
+勾选项使用以下现有接口,均不分页:
+
+| 勾选区域 | 接口 | 提交字段 |
+|---|---|---|
+| 充值通道 | `GET /admin/rechargeChannel/getChannel?data_type=1` | `recharge_type` |
+| 提现通道 | `GET /admin/rechargeChannel/getChannel?data_type=2` | `withdraw_type` |
+| 活动通道 | `GET /admin/rechargeChannel/getChannel?data_type=3` | `activity_type` |
+
+选项取响应 `data.data`,显示 `name`、勾选值用 `type`(不是 `id`)。编辑时用 `groupList` 的 `recharge_type/withdraw_type/activity_type` 数组回显已选项。
+
+不要用组合已有的 `rechargeTypes/withdrawTypes/activityTypes` 作为全部候选项,也不要用支付网关 options 的 `disabled` 过滤这里的候选项,否则新通道尚未加入组合时就永远无法勾选。
+
+按当前提供的通道配置,充值勾选项应包含“JD钱包”“NO快捷充值-扫码支付”“NO快捷充值-余额支付”“808充值”。保存仍使用 `POST /admin/rechargeChannel/updateGroup`,保留其他已选项。

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

@@ -21,7 +21,7 @@
 | `no` | `NOpay12,NOpay13` | `NOwithdraw` |
 | `zimu` | `ZIMUpay` | `ZIMUwithdraw,ZIMUcash` |
 
-三斤与钱宝仅在组合 1;JD、NO、808账户提现在组合 1、2。`ZIMUcash` 已停用且无所属组合。USDT、人工充值/提现、活动不属于第三方公司目录。两个代收类型当前共用存款支付商目录,没有单独线下代收商资料。
+在此前提供的组合快照中,三斤与钱宝仅在组合 1;JD、NO、808账户提现在组合 1、2。`ZIMUcash` 已停用且无所属组合。这不代表已核验截图环境的当前组合配置。USDT、人工充值/提现、活动不属于第三方公司目录。两个代收类型当前共用存款支付商目录,没有单独线下代收商资料。
 
 NO 服务使用 SHA256,其余四家使用 MD5;目录返回 `signature_algorithm` 供前端显示,不由前端维护映射。
 
@@ -43,10 +43,54 @@ php artisan migrate --path=database/migrations/2026_09_07_120000_add_deposit_sec
 
 ## 验证记录
 
-基于用户提供的 31 条通道、2 个组合,离线回归 70 项通过:
+基于用户提供的 31 条通道、2 个组合,离线回归 81 项通过:
 
 ```bash
 php tests/Regression/payment_provider_catalog.php
 ```
 
 PHP 语法、Composer 和文档检查通过。尚未在服务器执行迁移、实际商户保存或真实支付联调。
+
+## JD / NO / 808 置灰排查与数据配置
+
+### from 不需要改
+
+旧方法仍有“三斤”的历史命名,但当前 `from=1` 通道包含多家支付商。`PaymentProviderCatalog` 用 `data_type + type` 识别具体公司;`PaymentOrderService::createPay` 会优先按 JD、NO、808 的类型进入对应服务,不是只看 `from` 就调用三斤。
+
+用户最新提供的 ID 24~31 保持 `from=1`;存款 ID 24、26、27、29 的 `data_type=1`、`status=1` 和 `type` 均符合当前代码。不要改成未经实现的新 from 编号,不要把 `name` 改为支付公司名称,也不要为修下拉启用原本停用的 ID 31。
+
+### 两个可以复现截图的场景
+
+1. **组合未包含新通道。** 保留全部 31 条通道,但从所有组合的 `recharge_type` 去掉 `JDpay,NOpay12,NOpay13,ZIMUpay`,公司选项即变为三斤可选、其余三家禁用。仅新增通道记录不会自动修改会员组合权限。
+2. **前端只使用通道列表第一页。** 存款共 19 条,默认分页 15 条;新建的四条排在后面。公司 Select 应使用不分页的 options 接口,不能以列表第一页是否出现来决定公司是否禁用。
+
+这两个场景均有回归测试;当前服务器具体是哪种原因,仍需同一环境 options 响应确认,不能只凭截图或旧 SQL 快照定论。禁用判断不检查 `.env` 商户密钥,无需提供密钥。
+
+### 本次补充改动
+
+- 公司/方式选项返回禁用原因:`channel_missing` 没有匹配通道、`channel_disabled` 匹配通道全停用、`group_missing` 已启用通道未加入任何组合。
+- 充值通道列表返回公司代码与公司名,添加可选公司筛选;`name/from/type` 原值不变。
+- 不取消实际通道/组合的可选条件,不自动添加组合成员,不修改支付调用。本补充无需新增迁移;前文 `deposit_secret` 迁移只属于此前商户配置改动,已执行的无需重复。
+
+### 在截图对应环境检查(只读)
+
+```sql
+SELECT id, `from`, data_type, `key`, name, type, status, sort
+FROM bot_recharge_channel
+WHERE type IN ('JDpay', 'NOpay12', 'NOpay13', 'NOwithdraw', 'ZIMUpay', 'ZIMUwithdraw', 'ZIMUcash')
+ORDER BY data_type, sort, id;
+
+SELECT id, name, recharge_type, withdraw_type, activity_type
+FROM bot_recharge_channel_group
+ORDER BY id;
+```
+
+若确认为组合缺失:在“充值通道 → 层级设置”中,给**业务希望开放的组合**勾选对应存款类型 `JDpay`、`NOpay12`、`NOpay13`、`ZIMUpay`。提现配置单独按需选择 `JDpay`、`NOwithdraw`、`ZIMUwithdraw`。不要默认向所有组合开放。
+
+保存组合使用 `POST /admin/rechargeChannel/updateGroup`,先读取现有 `id/name/recharge_type/withdraw_type/activity_type`,保留原类型并追加所需类型,再提交完整数组。不得直接覆盖成只有新类型的列表;传的是类型,不是通道 ID。保存后重新请求 options 验证三个公司可选。
+
+### 补充测试
+
+`tests/Integration/PaymentChannelOptionsTest.php`:4 个测试、33 个断言通过;使用隔离 Laravel 9、SQLite 内存库和 `bot_` 前缀,直接调用真实控制器,验证模型数组转换、全量 options、列表分页、公司筛选与显示、组合缺失原因及只读查询不改数据。未访问现网数据库,未验证真实前端组件。
+
+层级设置候选项单独验证:`GET /admin/rechargeChannel/getChannel?data_type=1` 只按方向和启用状态查询,不依赖任何组合,按 type 去重且不分页。即使所有组合均未配置 JD/NO/808,也仍返回这三家已启用的通道。若现场看不到,需对比此接口在同一服务器的响应,不能直接认定该接口缺少支付商或修改 from;也不能用已经选入组合的类型反向限制候选项,形成配置循环。

+ 140 - 0
tests/Integration/PaymentChannelOptionsTest.php

@@ -0,0 +1,140 @@
+<?php
+
+namespace Tests\Integration;
+
+use App\Http\Controllers\admin\PaymentConfiguration;
+use App\Http\Controllers\admin\RechargeChannel;
+use App\Services\PaymentChannelLinkService;
+use Illuminate\Config\Repository;
+use Illuminate\Database\Capsule\Manager;
+use Illuminate\Database\Schema\Blueprint;
+use Illuminate\Foundation\Application;
+use Illuminate\Http\JsonResponse;
+use Illuminate\Http\Request;
+use Illuminate\Support\Facades\DB;
+use Illuminate\Support\Facades\Facade;
+use Illuminate\Translation\ArrayLoader;
+use Illuminate\Translation\Translator;
+use Illuminate\Validation\Factory;
+use PHPUnit\Framework\TestCase;
+
+class PaymentChannelOptionsTest extends TestCase
+{
+    private Application $app;
+
+    protected function setUp(): void
+    {
+        Facade::clearResolvedInstances();
+        $this->app = new Application(dirname(__DIR__, 2));
+        $this->app->instance('config', new Repository(['app' => ['locale' => 'zh']]));
+        Facade::setFacadeApplication($this->app);
+        $db = new Manager($this->app);
+        $db->addConnection(['driver' => 'sqlite', 'database' => ':memory:', 'prefix' => 'bot_']);
+        $db->setAsGlobal();
+        $db->bootEloquent();
+        $this->app->instance('db', $db->getDatabaseManager());
+        $translator = new Translator(new ArrayLoader(), 'zh');
+        $this->app->instance('translator', $translator);
+        $this->app->instance('validator', new Factory($translator, $this->app));
+        $this->app->instance(\Illuminate\Contracts\Routing\ResponseFactory::class, new class {
+            public function json($data, $status = 200, $headers = [], $options = 0) {
+                return new JsonResponse($data, $status, $headers, $options);
+            }
+        });
+        Request::macro('validate', function (array $rules) {
+            return app('validator')->make($this->all(), $rules)->validate();
+        });
+        $db->schema()->create('recharge_channel', function (Blueprint $t) {
+            $t->id(); $t->integer('from'); $t->integer('data_type'); $t->string('key');
+            $t->string('name'); $t->string('type'); $t->decimal('rate', 8, 4);
+            $t->decimal('min')->nullable(); $t->decimal('max')->nullable(); $t->string('fixed')->nullable();
+            $t->integer('status'); $t->integer('sort'); $t->timestamps();
+        });
+        $db->schema()->create('recharge_channel_group', function (Blueprint $t) {
+            $t->id(); $t->string('name'); $t->text('recharge_type')->nullable();
+            $t->text('withdraw_type')->nullable(); $t->text('activity_type')->nullable(); $t->timestamps();
+        });
+        $fixture = require dirname(__DIR__) . '/fixtures/payment_provider_channels.php';
+        DB::table('recharge_channel')->insert($fixture['channels']);
+        DB::table('recharge_channel_group')->insert($fixture['groups']);
+    }
+
+    protected function tearDown(): void
+    {
+        DB::disconnect();
+        Facade::clearResolvedInstances();
+        parent::tearDown();
+    }
+
+    private function request(string $path, array $params): void
+    {
+        $this->app->instance('request', Request::create($path, 'GET', $params));
+    }
+
+    private function channelList(array $params): array
+    {
+        $this->request('/admin/rechargeChannel/list', $params);
+        $response = (new RechargeChannel())->list()->getData(true);
+        $this->assertSame(0, $response['code']);
+        return $response['data'];
+    }
+
+    public function test_company_options_use_all_rows_not_first_page(): void
+    {
+        $page1 = $this->channelList(['data_type' => '1']);
+        $this->assertSame(19, $page1['total']);
+        $this->assertCount(15, $page1['data']);
+        $this->assertNotContains('jd', array_column($page1['data'], 'payment_company'));
+        $page2 = $this->channelList(['data_type' => '1', 'page' => 2]);
+        $this->assertSame(['zimu', 'no', 'no', 'jd'], array_column($page2['data'], 'payment_company'));
+        $this->request('/admin/paymentConfig/options', ['kind' => 'deposit']);
+        $data = (new PaymentConfiguration(new PaymentChannelLinkService()))->options()->getData(true);
+        $this->assertSame(0, $data['code']);
+        $this->assertSame([false, false, false, false], array_column($data['data']['payment_companies'], 'disabled'));
+    }
+
+    public function test_channel_list_displays_provider_separately_from_channel_name(): void
+    {
+        $data = $this->channelList(['data_type' => '1', 'payment_company' => 'jd']);
+        $this->assertSame(1, $data['total']);
+        $this->assertSame('JD钱包', $data['data'][0]['name']);
+        $this->assertSame('JD支付', $data['data'][0]['payment_company_label']);
+        $this->assertSame(1, $data['data'][0]['from']);
+        $this->assertSame(24, $data['data'][0]['id']);
+        $withdraw = $this->channelList(['data_type' => '2', 'payment_company' => 'jd']);
+        $this->assertSame(25, $withdraw['data'][0]['id']);
+        $this->assertSame(0, $this->channelList(['data_type' => '2', 'payment_company' => 'sanjin'])['total']);
+    }
+
+    public function test_missing_group_returns_reason_without_enabling_or_rewriting_config(): void
+    {
+        DB::table('recharge_channel_group')->update(['recharge_type' => 'wxsm,zfbsm']);
+        $this->request('/admin/paymentConfig/options', ['kind' => 'deposit']);
+        $data = (new PaymentConfiguration(new PaymentChannelLinkService()))->options()->getData(true)['data'];
+        $this->assertSame([false, true, true, true], array_column($data['payment_companies'], 'disabled'));
+        $this->assertSame(['', 'group_missing', 'group_missing', 'group_missing'], array_column($data['payment_companies'], 'disabled_reason_code'));
+        $this->assertSame('wxsm,zfbsm', DB::table('recharge_channel_group')->value('recharge_type'));
+        $this->assertSame(0, DB::table('recharge_channel')->where('id', 31)->value('status'));
+    }
+
+    public function test_group_editor_options_include_new_types_even_before_any_group_uses_them(): void
+    {
+        DB::table('recharge_channel_group')->update(['recharge_type' => 'wxsm', 'withdraw_type' => 'DF001']);
+        $this->request('/admin/rechargeChannel/getChannel', ['data_type' => 1]);
+        $response = (new RechargeChannel())->getChannel()->getData(true);
+        $this->assertSame(0, $response['code']);
+        $names = array_column($response['data']['data'], 'name', 'type');
+        $this->assertSame('JD钱包', $names['JDpay']);
+        $this->assertSame('NO快捷充值-扫码支付', $names['NOpay12']);
+        $this->assertSame('NO快捷充值-余额支付', $names['NOpay13']);
+        $this->assertSame('808充值', $names['ZIMUpay']);
+        $this->assertSame(13, $response['data']['total']);
+        $this->request('/admin/rechargeChannel/getChannel', ['data_type' => 2]);
+        $names = array_column((new RechargeChannel())->getChannel()->getData(true)['data']['data'], 'name', 'type');
+        $this->assertSame('JD钱包', $names['JDpay']);
+        $this->assertSame('NO快捷提现', $names['NOwithdraw']);
+        $this->assertSame('808账户提现', $names['ZIMUwithdraw']);
+        $this->assertArrayNotHasKey('ZIMUcash', $names);
+        $this->assertSame('wxsm', DB::table('recharge_channel_group')->value('recharge_type'));
+    }
+}

+ 23 - 0
tests/Regression/payment_provider_catalog.php

@@ -55,6 +55,29 @@ $check(true, array_column($options(['kind' => 'withdraw', 'payment_company' => '
 $empty = $catalog->options([], [], ['kind' => 'deposit']);
 $check(4, count($empty['payment_companies']), '没有通道时目录仍存在');
 $check([true, true, true, true], array_column($empty['payment_companies'], 'disabled'), '没有通道不伪造可用');
+$check(['channel_missing', 'channel_missing', 'channel_missing', 'channel_missing'], array_column($empty['payment_companies'], 'disabled_reason_code'), '缺少通道有明确原因');
+$legacyGroups = $fixture['groups'];
+foreach ($legacyGroups as &$group) {
+    $group['recharge_type'] = implode(',', array_diff(explode(',', $group['recharge_type']), ['JDpay', 'NOpay12', 'NOpay13', 'ZIMUpay']));
+}
+unset($group);
+$missingGroups = $catalog->options($fixture['channels'], $legacyGroups, ['kind' => 'deposit']);
+$check([false, true, true, true], array_column($missingGroups['payment_companies'], 'disabled'), '只新增通道未更新组合可复现截图');
+$check(['', 'group_missing', 'group_missing', 'group_missing'], array_column($missingGroups['payment_companies'], 'disabled_reason_code'), '组合缺失不误报为不支持');
+$check('group_missing', array_column($missingGroups['payment_methods'], null, 'value')['JDpay']['disabled_reason_code'], '方式也返回组合缺失原因');
+$stoppedChannels = $fixture['channels'];
+foreach ($stoppedChannels as &$channel) {
+    if (in_array($channel['type'], ['JDpay', 'NOpay12', 'NOpay13', 'ZIMUpay'], true)) $channel['status'] = 0;
+}
+unset($channel);
+$stopped = $catalog->options($stoppedChannels, $fixture['groups'], ['kind' => 'deposit']);
+$check(['', 'channel_disabled', 'channel_disabled', 'channel_disabled'], array_column($stopped['payment_companies'], 'disabled_reason_code'), '停用和组合缺失区分');
+$check(['NOpay12', 'NOpay13'], PaymentProviderCatalog::channelTypesForProvider('no', 1), 'NO存款筛选');
+$check(['NOwithdraw'], PaymentProviderCatalog::channelTypesForProvider('no', 2), 'NO提现筛选');
+$check([], PaymentProviderCatalog::channelTypesForProvider('sanjin', 2), '三斤无已登记提现方式');
+$check([], PaymentProviderCatalog::channelTypesForProvider('no', 3), '活动不属于NO');
+$check(['JDpay'], PaymentProviderCatalog::channelTypesForProvider('jd'), '跨方向同名type去重');
+$check(['', '', '', ''], array_column($deposit['payment_companies'], 'disabled_reason'), '可选公司无错误提示');
 $unknown = $fixture['channels'][0];
 $unknown['from'] = 1;
 $unknown['type'] = 'unimplemented';