doge 5 日 前
コミット
f6edd84ed1

+ 10 - 0
app/Http/Controllers/admin/PaymentConfiguration.php

@@ -250,6 +250,16 @@ class PaymentConfiguration extends Controller
             $id = (int)($params['id'] ?? 0);
             unset($params['id']);
             $params += OperationAuditService::actor();
+            // Serialize config binding with channel identity changes, then recheck the current channel.
+            $direction = $modelClass === PaymentGateway::class && $params['kind'] === 'withdraw' ? 2 : 1;
+            [$channel, $params['recharge_channel_group_ids']] = $this->channelLinks->validate(
+                (int)$params['recharge_channel_id'], $params['recharge_channel_group_ids'], $direction, true
+            );
+            if ($modelClass === PaymentGateway::class) {
+                PaymentProviderCatalog::validateSelection($channel->toArray(), $params['payment_company'], $params['payment_method'], $direction);
+            } elseif ($modelClass === PaymentCollectionChannel::class) {
+                PaymentProviderCatalog::validateSelection($channel->toArray(), $params['provider_name'], $params['collection_method'], $direction);
+            }
             $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()) : [];

+ 35 - 14
app/Http/Controllers/admin/RechargeChannel.php

@@ -6,13 +6,36 @@ use App\Http\Controllers\Controller;
 use App\Models\RechargeChannel as RechargeChannelModel;
 use App\Models\RechargeChannelGroup;
 use App\Services\Payment\PaymentProviderCatalog;
+use App\Services\Payment\RechargeChannelConfigurationService;
 use Illuminate\Validation\Rule;
+use Illuminate\Validation\ValidationException;
+use Illuminate\Database\Eloquent\ModelNotFoundException;
 use Exception;
 use App\Constants\HttpStatus;
 
 class RechargeChannel extends Controller
 {
 
+    public function options(RechargeChannelConfigurationService $service)
+    {
+        try {
+            $params = request()->validate([
+                'data_type' => ['required', 'integer', 'in:1,2,3'],
+                'id' => ['nullable', 'integer', 'min:1'],
+            ]);
+            return $this->success($service->options((int)$params['data_type'], isset($params['id']) ? (int)$params['id'] : null));
+        } 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 getChannel()
     {
@@ -135,10 +158,7 @@ class RechargeChannel extends Controller
                 ->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;
+                    return $row + PaymentProviderCatalog::channelDisplay($row);
                 });
         } catch (Exception $e) {
             return $this->error(HttpStatus::CUSTOM_ERROR,$e->getMessage());
@@ -149,12 +169,13 @@ class RechargeChannel extends Controller
     /**
      * 充值通道管理更新
      */
-    public function update()
+    public function update(RechargeChannelConfigurationService $service)
     {
         try {
             $params = request()->validate([
                 'id' => ['nullable','integer'],
-                'from' => ['nullable','integer'],
+                'from' => ['nullable','integer','in:1,2,3'],
+                'payment_company' => ['nullable', Rule::in(PaymentProviderCatalog::codes())],
                 'key' => ['nullable','string','max:100'],
                 'name' => ['nullable','string','max:100'],
                 'data_type' => ['required','integer','in:1,2,3'],
@@ -167,16 +188,16 @@ class RechargeChannel extends Controller
                 'sort' => ['nullable','integer','min:0'],
             ]);
 
-            if (empty($params['id'])) {
-                RechargeChannelModel::create($params);
-            } else {
-                $info = RechargeChannelModel::where('id', $params['id'])->first();
-                if (!$info) throw new Exception('数据不存在');
-                $info->update($params);
-                $info->save();
-            }
+            $service->save($params);
             
             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());
         }

+ 4 - 1
app/Models/RechargeChannel.php

@@ -2,6 +2,8 @@
 
 namespace App\Models;
 
+use App\Services\Payment\PaymentProviderCatalog;
+
 class RechargeChannel extends BaseModel
 {
 
@@ -20,7 +22,8 @@ class RechargeChannel extends BaseModel
         if ($type) {
             $query = $query->whereIn('type', $type);
         }
-        $channel = $query->select(['type','name'])->get()->toArray();
+        $channel = $query->orderBy('sort')->orderBy('id')->get(['type', 'name', 'from', 'data_type'])
+            ->map(fn ($row) => $row->toArray() + PaymentProviderCatalog::channelDisplay($row->toArray()))->all();
         $channel = array_column($channel, null, 'type');
         return array_values($channel);
     }

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

@@ -63,6 +63,68 @@ class PaymentProviderCatalog
         return array_values(array_unique($types));
     }
 
+    /** Definitions for creating channels, intentionally independent of existing channels/groups. */
+    public static function channelFormOptions(int $dataType): array
+    {
+        if (!in_array($dataType, [1, 2, 3], true)) throw new InvalidArgumentException('通道方向错误');
+        $companies = [];
+        $types = [];
+        foreach (self::PROVIDERS as $code => $provider) {
+            if (in_array($dataType, $provider['directions'], true)) {
+                $companies[] = ['value' => $code, 'label' => $provider['label']];
+            }
+        }
+        foreach (self::METHODS[$dataType] ?? [] as $type => [$company, $label]) {
+            $types[] = ['value' => $type, 'label' => $label, 'from' => 1,
+                'payment_company' => $company, 'payment_company_label' => self::label($company)];
+        }
+        $local = match ($dataType) {
+            1 => ['usdt' => [2, 'USDT充值'], 'rgcz' => [3, '人工充值']],
+            2 => ['usdt' => [2, 'USDT提现'], 'rgtx' => [3, '人工提现']],
+            3 => ['recharge' => [1, '即充即送'], 'old_user' => [1, '老用户回归'], 'yuebao' => [1, '余额宝']],
+        };
+        foreach ($local as $type => [$from, $label]) {
+            $types[] = ['value' => $type, 'label' => $label, 'from' => $from,
+                'payment_company' => null, 'payment_company_label' => ''];
+        }
+        return [
+            'data_type' => $dataType,
+            'from_options' => $dataType === 3 ? [['value' => 1, 'label' => '活动']] : [
+                ['value' => 1, 'label' => '第三方支付'], ['value' => 2, 'label' => 'USDT'], ['value' => 3, 'label' => '人工'],
+            ],
+            'payment_companies' => $companies,
+            'types' => $types,
+        ];
+    }
+
+    public static function normalizeChannelSelection(array $data): array
+    {
+        $definitions = array_column(self::channelFormOptions((int)$data['data_type'])['types'], null, 'value');
+        $definition = $definitions[(string)$data['type']] ?? null;
+        if (!$definition) throw new InvalidArgumentException('请选择已接入的通道类型');
+        $company = (string)($data['payment_company'] ?? '');
+        if ($company !== '' && $company !== $definition['payment_company']) {
+            throw new InvalidArgumentException('支付公司与通道类型不匹配,请重新选择类型');
+        }
+        if (isset($data['from']) && (int)$data['from'] !== $definition['from']) {
+            throw new InvalidArgumentException('通道来源与类型不匹配');
+        }
+        $data['from'] = $definition['from'];
+        unset($data['payment_company']);
+        return $data;
+    }
+
+    public static function channelDisplay(array $channel): array
+    {
+        $identity = self::channelIdentity($channel);
+        $label = $identity ? self::label($identity['provider_code']) : '';
+        return [
+            'payment_company' => $identity['provider_code'] ?? null,
+            'payment_company_label' => $label,
+            'display_name' => ($label === '' ? '' : $label . ' / ') . (string)$channel['name'],
+        ];
+    }
+
     public static function direction(?string $kind): ?int
     {
         return match ($kind) {

+ 64 - 0
app/Services/Payment/RechargeChannelConfigurationService.php

@@ -0,0 +1,64 @@
+<?php
+
+namespace App\Services\Payment;
+
+use App\Models\RechargeChannel;
+use Illuminate\Support\Facades\DB;
+use Illuminate\Support\Facades\Schema;
+use RuntimeException;
+
+class RechargeChannelConfigurationService
+{
+    public function options(int $dataType, ?int $id = null): array
+    {
+        $options = PaymentProviderCatalog::channelFormOptions($dataType);
+        $reason = '';
+        if ($id) {
+            $channel = RechargeChannel::query()->findOrFail($id);
+            if ((int)$channel->data_type !== $dataType) throw new RuntimeException('通道方向与编辑记录不一致');
+            $reason = $this->identityLockReason($channel);
+        }
+        return $options + ['identity_editable' => $reason === '', 'identity_edit_reason' => $reason];
+    }
+
+    public function save(array $params): RechargeChannel
+    {
+        return DB::transaction(function () use ($params) {
+            $id = (int)($params['id'] ?? 0);
+            unset($params['id']);
+            $channel = $id ? RechargeChannel::query()->lockForUpdate()->findOrFail($id) : new RechargeChannel();
+            if ($channel->exists && (int)$channel->data_type !== (int)$params['data_type']) {
+                throw new RuntimeException('不能修改通道所属的充值、提现或活动方向');
+            }
+            $params = PaymentProviderCatalog::normalizeChannelSelection($params);
+            if ($channel->exists && ((int)$channel->from !== $params['from'] || (string)$channel->type !== (string)$params['type'])) {
+                $reason = $this->identityLockReason($channel, true);
+                if ($reason !== '') throw new RuntimeException($reason);
+            }
+            foreach (['name', 'key'] as $field) {
+                if (array_key_exists($field, $params)) $params[$field] = trim((string)$params[$field]);
+                if ((!$channel->exists || array_key_exists($field, $params)) && ($params[$field] ?? '') === '') {
+                    throw new RuntimeException($field === 'name' ? '请填写通道名称' : '请填写通道标识Key');
+                }
+            }
+            foreach (['status', 'sort'] as $field) {
+                if (array_key_exists($field, $params) && $params[$field] === null) unset($params[$field]);
+            }
+            if (!$channel->exists) $params += ['status' => 1, 'sort' => 0];
+            $channel->fill($params);
+            $channel->save();
+            return $channel;
+        }, 3);
+    }
+
+    private function identityLockReason(RechargeChannel $channel, bool $lock = false): string
+    {
+        foreach (['payment_gateways', 'payment_collection_channels', 'payment_direct_recharges'] as $table) {
+            if (!Schema::hasTable($table) || !Schema::hasColumn($table, 'recharge_channel_id')) continue;
+            $query = DB::table($table)->where('recharge_channel_id', $channel->id)->whereNull('deleted_at');
+            if ($lock) $query->lockForUpdate();
+            if ($query->first(['id'])) return '该通道已被支付配置引用,请先解除关联或新增通道,再更换公司或类型';
+        }
+        return '';
+    }
+}

+ 4 - 2
app/Services/PaymentChannelLinkService.php

@@ -12,9 +12,11 @@ use App\Services\Payment\PaymentProviderCatalog;
 
 class PaymentChannelLinkService
 {
-    public function validate(int $channelId, array $groupIds, int $expectedDataType): array
+    public function validate(int $channelId, array $groupIds, int $expectedDataType, bool $lockChannel = false): array
     {
-        $channel = RechargeChannel::query()->find($channelId);
+        $query = RechargeChannel::query();
+        if ($lockChannel) $query->lockForUpdate();
+        $channel = $query->find($channelId);
         if (!$channel) throw new RuntimeException('充值通道不存在');
         if ((int)$channel->data_type !== $expectedDataType) {
             throw new RuntimeException($expectedDataType === 2 ? '请选择提现通道' : '请选择充值通道');

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

@@ -1,5 +1,7 @@
 # 支付配置下拉选项-前端变更说明
 
+「充值通道」菜单新增/编辑时选择公司,见 [充值通道新增编辑说明](充值通道新增编辑-前端变更说明.md)。本文件的 paymentConfig/options 用于商户配置,不用于创建通道的公司候选项。
+
 ## 接口 Path 不变,options 新增联动参数
 
 ```text
@@ -77,7 +79,7 @@ GET /admin/paymentConfig/options?kind=collection_scan&provider_name=no
 | 提现通道 | `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` 数组回显已选项。
+选项取响应 `data.data`,优先显示 `display_name`(未升级时回退 `name`)、勾选值用 `type`(不是 `id`)。编辑时用 `groupList` 的 `recharge_type/withdraw_type/activity_type` 数组回显已选项。
 
 不要用组合已有的 `rechargeTypes/withdrawTypes/activityTypes` 作为全部候选项,也不要用支付网关 options 的 `disabled` 过滤这里的候选项,否则新通道尚未加入组合时就永远无法勾选。
 

+ 83 - 0
docs/前端接口/支付配置/充值通道新增编辑-前端变更说明.md

@@ -0,0 +1,83 @@
+# 充值通道新增/编辑-前端变更说明
+
+适用「充值通道 → 通道管理」的新增、编辑弹窗,**不是新增存款网关弹窗**。列表和保存 Path 不变;新增一个通道表单选项接口。
+
+## 1. 打开弹窗获取选项
+
+```text
+GET /admin/rechargeChannel/options?data_type=1
+GET /admin/rechargeChannel/options?data_type=1&id=24
+```
+
+参数:`data_type` 必填,`1` 充值、`2` 提现、`3` 活动;编辑时追加当前通道 `id`。
+
+`data` 返回字段:
+
+| 字段 | 前端用途 |
+|---|---|
+| `from_options` | 来源 Select,使用 `value/label` |
+| `payment_companies` | 当前方向的支付公司 Select,使用 `value/label` |
+| `types` | 类型 Select;每项含 `value/label/from/payment_company/payment_company_label` |
+| `identity_editable` | 是否可修改来源、公司、类型;新增为 `true` |
+| `identity_edit_reason` | 不可修改时显示的提示;可修改时为空字符串 |
+
+类型选项示例(节选):
+
+```json
+{
+  "value": "NOpay12",
+  "label": "NO扫码支付",
+  "from": 1,
+  "payment_company": "no",
+  "payment_company_label": "NO支付"
+}
+```
+
+该接口提供创建通道所需的目录,即使还没创建 JD/NO/808 通道也能选。**这里不要使用 `/admin/paymentConfig/options` 的禁用状态**。
+
+## 2. 表单改法
+
+- 新增和编辑都增加「通道来源」「支付公司」Select,原「类型 Type」文本框改为 Select;公司代码可只读显示,不另设手填输入框。
+- 来源取 `from_options`;第三方支付时显示公司 Select。USDT、人工、活动不需要选支付公司,清空 `payment_company`。
+- 类型选项按当前来源 `from` 和公司 `payment_company` 过滤;显示 `label`,提交 `value`。公司/来源切换时清空旧 `type`,要求重新选择。
+- 编辑用列表的 `from/payment_company/type` 回显,并带 `id` 获取选项。`identity_editable=false` 时禁用来源、公司、类型三项并显示原因;名称、Key、费率、限额、排序、状态仍按原表单编辑。
+- 编辑不改变所属充值/提现/活动 Tab。历史数据里的未知类型应提示重新选择已支持类型,不能凭名称猜公司。
+- 通道名称与 Key 不能为空;费率仍按原小数提交,例如 `0.03` 表示 `3%`。
+
+USDT 通道公司列显示 `—` 是正常情况;不要给它自动选中三斤。
+
+## 3. 保存接口
+
+仍为 `POST /admin/rechargeChannel/update`,新增可提交字段 `payment_company`;已有字段名称不变。不传 `id` 新增,传 `id` 编辑。
+
+```json
+{
+  "data_type": 1,
+  "from": 1,
+  "payment_company": "no",
+  "name": "NO扫码通道",
+  "key": "NOpay12",
+  "type": "NOpay12",
+  "rate": "0.0300",
+  "min": 10,
+  "max": 1000,
+  "fixed": null,
+  "sort": 98,
+  "status": 1
+}
+```
+
+第三方表单提交选中的公司代码及对应 `type`;非第三方不传公司或传 `null`。`from` 可直接取所选类型项的 `from`。不要提交 `payment_company_label/display_name`。
+
+成功外层结构及 `code=0`、`data=[]` 不变,保存后刷新列表。失败继续展示 `msg`。
+
+更换公司/类型后提醒操作员检查层级设置,原有组合不会自动替换成新类型。已有支付配置引用而无法修改时,显示 `identity_edit_reason`,引导先处理关联或新增一条通道。
+
+## 4. 列表和层级显示
+
+- `/admin/rechargeChannel/list` 保留公司字段,新增 `display_name`。
+- `/admin/rechargeChannel/getChannel` 每项在原 `type/name` 上补充 `from/data_type/payment_company/payment_company_label/display_name`。
+- `/admin/rechargeChannel/groupList` 的 `rechargeTypes/withdrawTypes/activityTypes` 同步补充上述展示信息。
+- 列表仍分列显示公司和通道名称;层级勾选/标签直接显示 `display_name`,例如 `NO支付 / NO扫码通道`。勾选值仍为 `type`,不是名称或公司代码。
+
+联调:新增 NO 通道 → 列表显示 NO 公司;无引用通道改为 JD 并重选 `JDpay` → 同一 ID 显示 JD;USDT 保持无公司;有引用通道显示不可修改原因。

+ 54 - 0
docs/后端/充值通道公司选择与编辑说明.md

@@ -0,0 +1,54 @@
+# 充值通道公司选择与编辑-后端说明(2026-09-08)
+
+## 检查结论
+
+前端反馈基本准确:此前只有列表派生的 `payment_company/payment_company_label`,`update` 不接收公司,也没有独立的“创建通道目录”。将展示字段直接做成只读输入框,不能完成选公司新增/换公司编辑。
+
+截图中的 `usdt` 公司为空正常;`type=12` 不在已实现的通道目录中。不能通过给任意类型填一个公司名就把它变成已接入支付,必须选择公司真正支持的类型。
+
+## 实现与存储
+
+1. 新增 `GET /admin/rechargeChannel/options`。目录由 `PaymentProviderCatalog::channelFormOptions` 提供,不读取已有通道来决定公司可选性,解决“没有通道就不能先选公司创建通道”的循环。
+2. `POST /admin/rechargeChannel/update` 接收 `payment_company`,交由 `RechargeChannelConfigurationService` 在事务中保存。按目录校验方向、公司、类型与来源的一致性;未传 `from` 时由类型推导。
+3. 公司仍由 `from + data_type + type` 唯一推导,不新增公司字段或表。选择 NO 并保存 `NOpay12/from=1` 后,列表自然返回 `payment_company=no`;公司名称取统一后端目录。
+4. 为减少接口破坏,旧调用方不传 `payment_company` 时,已登记类型仍可保存并推导公司。未知类型如 `12` 不再允许创建或继续保存;需要重新选已接入类型。名称/Key 不能为空;原成功结构 `code=0/data=[]` 不变。
+5. `channelDisplay` 统一公司和组合显示名,列表与 `getChannel/groupList` 都返回 `display_name`;原 `name/type` 不改名。USDT、人工与活动不伪造第三方公司。
+
+本次不更改商户号、密钥、实际支付调用、费率单位或真实资金。已有 `from=1` 仍共用于三方支付;非三方来源仍按原数值保存。
+
+## 编辑边界
+
+- 允许未被支付配置引用的通道更换公司/来源/类型,必须同时提供匹配的新类型;保持记录 ID。
+- 已被未软删除的 `payment_gateways`、`payment_collection_channels` 或 `payment_direct_recharges` 引用时,不允许更换来源/类型(也就不能换公司),包括停用但尚未解除的引用。普通名称、Key、费率、限额、排序、状态编辑不受此限制。可先解除/删除引用或新建通道。
+- 编辑不能跨充值/提现/活动方向。必要时在对应方向新建记录。
+- 不自动修改任何组合的 `recharge_type/withdraw_type/activity_type`。换类型后,通道按新类型匹配现有组合,可能不再属于原组合或进入已有的新类型组合;需由操作员确认可见范围,不自动扩展全部会员权限。
+- 不改已生成订单的通道/公司,不回填历史数据。
+
+`options?id=...` 返回 `identity_editable/identity_edit_reason` 供页面说明当前限制;保存时重新检查,不能仅依赖页面的可编辑状态。
+
+## 并发与关联检查
+
+通道更新先锁定通道记录,再检查支付配置引用。商户/代收/直充保存也在实际写入事务内锁同一通道,并重新检查方向、组合和公司类型,避免只依赖事务外读取的旧信息。通道写事务最多重试 3 次。
+
+这不等于覆盖全部外部写入:手动 SQL、其他项目直接写表以及未遵循此锁顺序的代码不在保护范围。MySQL 并发仍需在测试环境验证。
+
+## 发布
+
+**本次无数据库迁移**;两个公司字段与 `display_name` 都是返回字段。发布代码后按现有部署流程刷新路由缓存、重载常驻进程。新增 options 路由位于现有 `admin.jwt/check.button.uri` 分组内;使用按钮权限配置的环境需按既有规则开放该查询接口。
+
+无需重新插入之前已添加的 JD/NO/808 数据,禁止为了本功能覆盖组合。没有执行生产 SQL、迁移、支付请求或部署。
+
+## 验证与前端交付
+
+前端只看 [充值通道新增编辑-前端变更说明](../前端接口/支付配置/充值通道新增编辑-前端变更说明.md),包含 Path、参数、返回和交互;数据库、发布及并发说明只放本文件。
+
+`tests/Integration/PaymentChannelOptionsTest.php`:13 个测试、100 个断言通过。覆盖空库公司目录、方向过滤、新增 NO、同 ID 换 JD、非法组合拒绝、非三方通道、旧参数调用、已引用通道锁定/普通编辑/解除后换公司、组合不被自动改写、层级显示名、商户保存时重新校验。
+
+测试使用隔离 Laravel 9.52.21 / SQLite 内存库及 `bot_` 表前缀,直接调用控制器和真实模型。未加载业务 `.env` 或连接现有数据库。MySQL 行锁/并发、完整鉴权中间件链和实际前端组件未联调。
+
+```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
+```
+
+独立目录依赖可通过 `AGENT_TEST_AUTOLOAD` 指定。PHP 8.4 下测试排除旧框架 deprecated 提示,不排除一般错误或异常。

+ 1 - 0
routes/admin.php

@@ -315,6 +315,7 @@ Route::middleware(['admin.jwt'])->group(function () {
         });
 
         Route::prefix('/rechargeChannel')->group(function () {
+            Route::get("/options", [RechargeChannel::class, 'options']);
             Route::get("/list", [RechargeChannel::class, 'list']);
             Route::post("/update", [RechargeChannel::class, 'update']);
             Route::get("/groupList", [RechargeChannel::class, 'groupList']);

+ 147 - 0
tests/Integration/PaymentChannelOptionsTest.php

@@ -5,6 +5,8 @@ namespace Tests\Integration;
 use App\Http\Controllers\admin\PaymentConfiguration;
 use App\Http\Controllers\admin\RechargeChannel;
 use App\Services\PaymentChannelLinkService;
+use App\Services\Payment\RechargeChannelConfigurationService;
+use App\Models\PaymentGateway;
 use Illuminate\Config\Repository;
 use Illuminate\Database\Capsule\Manager;
 use Illuminate\Database\Schema\Blueprint;
@@ -33,6 +35,7 @@ class PaymentChannelOptionsTest extends TestCase
         $db->setAsGlobal();
         $db->bootEloquent();
         $this->app->instance('db', $db->getDatabaseManager());
+        $this->app->bind('db.schema', fn () => $db->schema());
         $translator = new Translator(new ArrayLoader(), 'zh');
         $this->app->instance('translator', $translator);
         $this->app->instance('validator', new Factory($translator, $this->app));
@@ -137,4 +140,148 @@ class PaymentChannelOptionsTest extends TestCase
         $this->assertArrayNotHasKey('ZIMUcash', $names);
         $this->assertSame('wxsm', DB::table('recharge_channel_group')->value('recharge_type'));
     }
+
+    private function formOptions(int $dataType = 1, ?int $id = null): array
+    {
+        $this->request('/admin/rechargeChannel/options', ['data_type' => $dataType] + ($id ? ['id' => $id] : []));
+        $response = (new RechargeChannel())->options(new RechargeChannelConfigurationService())->getData(true);
+        $this->assertSame(0, $response['code']);
+        return $response['data'];
+    }
+
+    private function saveChannel(array $params): array
+    {
+        $this->app->instance('request', Request::create('/admin/rechargeChannel/update', 'POST', $params));
+        return (new RechargeChannel())->update(new RechargeChannelConfigurationService())->getData(true);
+    }
+
+    private function newChannel(array $overrides = []): array
+    {
+        return $overrides + ['data_type' => 1, 'payment_company' => 'no', 'type' => 'NOpay12',
+            'rate' => '0.0300', 'name' => '测试通道', 'key' => 'test-key', 'min' => 1, 'max' => 100];
+    }
+
+    public function test_creation_catalog_works_without_any_database_channels_or_groups(): void
+    {
+        DB::table('recharge_channel')->delete();
+        DB::table('recharge_channel_group')->delete();
+        $data = $this->formOptions();
+        $this->assertSame(['sanjin', 'jd', 'no', 'zimu'], array_column($data['payment_companies'], 'value'));
+        $this->assertTrue($data['identity_editable']);
+        $this->assertSame('', $data['identity_edit_reason']);
+        $this->assertContains('NOpay12', array_column($data['types'], 'value'));
+        $this->assertSame(['qianbao', 'jd', 'no', 'zimu'], array_column($this->formOptions(2)['payment_companies'], 'value'));
+        $this->assertSame([], $this->formOptions(3)['payment_companies']);
+    }
+
+    public function test_create_selected_company_then_switch_company_and_type_on_same_record(): void
+    {
+        $result = $this->saveChannel($this->newChannel());
+        $this->assertSame(0, $result['code']);
+        $this->assertSame([], $result['data']);
+        $row = DB::table('recharge_channel')->where('key', 'test-key')->first();
+        $this->assertSame(1, $row->from);
+        $this->assertSame(1, $row->status);
+        $this->assertSame(0, $row->sort);
+        $result = $this->saveChannel(['id' => $row->id, 'data_type' => 1,
+            'payment_company' => 'jd', 'type' => 'JDpay', 'rate' => '0.02']);
+        $this->assertSame(0, $result['code']);
+        $data = $this->channelList(['data_type' => '1', 'key' => 'test-key'])['data'][0];
+        $this->assertSame($row->id, $data['id']);
+        $this->assertSame('jd', $data['payment_company']);
+        $this->assertSame('JD支付 / 测试通道', $data['display_name']);
+    }
+
+    public function test_invalid_company_type_or_source_is_rejected_without_writing(): void
+    {
+        $before = DB::table('recharge_channel')->count();
+        foreach ([
+            ['payment_company' => 'jd', 'type' => 'NOpay12'],
+            ['payment_company' => 'no', 'type' => '12'],
+            ['payment_company' => 'no', 'type' => 'usdt'],
+            ['payment_company' => 'no', 'from' => 2],
+            ['payment_company' => 'sanjin', 'type' => 'DF001', 'data_type' => 2],
+        ] as $override) {
+            $this->assertSame(-3, $this->saveChannel($this->newChannel($override))['code']);
+        }
+        $this->assertSame($before, DB::table('recharge_channel')->count());
+    }
+
+    public function test_native_and_activity_channels_do_not_require_or_fabricate_company(): void
+    {
+        foreach ([[1, 'usdt', 2], [1, 'rgcz', 3], [2, 'rgtx', 3], [3, 'recharge', 1]] as [$direction, $type, $from]) {
+            $result = $this->saveChannel($this->newChannel(['payment_company' => null, 'data_type' => $direction,
+                'type' => $type, 'key' => 'local-' . $type]));
+            $this->assertSame(0, $result['code']);
+            $row = $this->channelList(['key' => 'local-' . $type])['data'][0];
+            $this->assertSame($from, $row['from']);
+            $this->assertNull($row['payment_company']);
+            $this->assertSame('测试通道', $row['display_name']);
+        }
+    }
+
+    public function test_existing_valid_clients_can_omit_derived_company_and_from(): void
+    {
+        $params = $this->newChannel();
+        unset($params['payment_company']);
+        $this->assertSame(0, $this->saveChannel($params)['code']);
+        $row = $this->channelList(['key' => 'test-key'])['data'][0];
+        $this->assertSame('no', $row['payment_company']);
+        $this->assertSame(1, $row['from']);
+    }
+
+    public function test_linked_channel_locks_identity_but_keeps_regular_edit_available(): 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]);
+        $options = $this->formOptions(1, 24);
+        $this->assertFalse($options['identity_editable']);
+        $this->assertNotSame('', $options['identity_edit_reason']);
+        $result = $this->saveChannel(['id' => 24, 'data_type' => 1, 'payment_company' => 'no',
+            'type' => 'NOpay12', 'rate' => '0.01']);
+        $this->assertSame(-3, $result['code']);
+        $this->assertSame('JDpay', DB::table('recharge_channel')->where('id', 24)->value('type'));
+        $result = $this->saveChannel(['id' => 24, 'data_type' => 1, 'payment_company' => 'jd',
+            'type' => 'JDpay', 'rate' => '0.01', 'name' => 'JD新名称']);
+        $this->assertSame(0, $result['code']);
+        DB::table('payment_gateways')->update(['deleted_at' => now()]);
+        $this->assertTrue($this->formOptions(1, 24)['identity_editable']);
+        $this->assertSame(0, $this->saveChannel(['id' => 24, 'data_type' => 1,
+            'payment_company' => 'no', 'type' => 'NOpay12', 'rate' => '0.01'])['code']);
+    }
+
+    public function test_switching_company_does_not_rewrite_group_permissions(): void
+    {
+        $groups = DB::table('recharge_channel_group')->orderBy('id')->get()->toJson();
+        $this->assertSame(0, $this->saveChannel(['id' => 24, 'data_type' => 1,
+            'payment_company' => 'no', 'type' => 'NOpay12', 'rate' => '0.01'])['code']);
+        $this->assertSame($groups, DB::table('recharge_channel_group')->orderBy('id')->get()->toJson());
+        $this->assertSame(-3, $this->saveChannel(['id' => 24, 'data_type' => 2,
+            'payment_company' => 'no', 'type' => 'NOwithdraw', 'rate' => '0.01'])['code']);
+    }
+
+    public function test_group_option_names_include_company_without_changing_values(): void
+    {
+        $this->request('/admin/rechargeChannel/getChannel', ['data_type' => 1]);
+        $rows = array_column((new RechargeChannel())->getChannel()->getData(true)['data']['data'], null, 'type');
+        $this->assertSame('JD钱包', $rows['JDpay']['name']);
+        $this->assertSame('jd', $rows['JDpay']['payment_company']);
+        $this->assertSame('JD支付 / JD钱包', $rows['JDpay']['display_name']);
+        $this->assertSame('USDT充值', $rows['usdt']['display_name']);
+    }
+
+    public function test_merchant_save_rechecks_channel_identity_inside_transaction(): void
+    {
+        DB::table('recharge_channel')->where('id', 26)->update(['type' => 'JDpay']);
+        $this->request('/admin/paymentConfig/gateways/save', []);
+        $controller = new PaymentConfiguration(new PaymentChannelLinkService());
+        $method = new \ReflectionMethod($controller, 'saveModel');
+        $method->setAccessible(true);
+        $this->expectException(\InvalidArgumentException::class);
+        $this->expectExceptionMessage('支付公司与所选通道不匹配');
+        $method->invoke($controller, PaymentGateway::class, ['kind' => 'deposit', 'recharge_channel_id' => 26,
+            'recharge_channel_group_ids' => [1], 'payment_company' => 'no', 'payment_method' => 'NOpay12'], 'payment_gateway');
+    }
 }