doge 5 дней назад
Родитель
Сommit
39c68fd550

+ 26 - 26
app/Http/Controllers/admin/RechargeChannel.php

@@ -8,7 +8,6 @@ use App\Models\RechargeChannelGroup;
 use App\Services\Payment\PaymentProviderCatalog;
 use App\Services\Payment\RechargeChannelConfigurationService;
 use App\Services\Payment\PaymentProviderService;
-use Illuminate\Support\Facades\DB;
 use Illuminate\Validation\Rule;
 use Illuminate\Validation\ValidationException;
 use Illuminate\Database\Eloquent\ModelNotFoundException;
@@ -101,34 +100,35 @@ class RechargeChannel extends Controller
                 'withdraw_type' => ['present','array'],
                 'activity_type' => ['nullable','array'],
             ]);
-            DB::transaction(function () use ($params) {
-                foreach (['recharge_type' => 1, 'withdraw_type' => 2, 'activity_type' => 3] as $field => $dataType) {
-                    if ($field === 'activity_type' && !request()->exists('activity_type')) {
-                        unset($params['activity_type']);
-                        continue;
-                    }
-                    $types = array_values(array_unique(array_filter(array_map('strval', $params[$field] ?? []))));
-                    if ($types) {
-                        $existingCount = RechargeChannelModel::query()->where('data_type', $dataType)
-                            ->whereIn('type', $types)->distinct()->lockForUpdate()->get(['type'])->count();
-                        if ($existingCount !== count($types)) throw new Exception("{$field} 包含不存在的通道类型");
-                    }
-                    $params[$field] = $types;
-                }
+            $removed = app(RechargeChannelConfigurationService::class)->saveGroup($params);
+            return $this->success([], $removed === [] ? '' : '保存成功,已清理失效的历史通道类型');
+        } 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());
+        }
+    }
 
-                if (empty($params['id'])) {
-                    RechargeChannelGroup::create($params);
-                } else {
-                    $info = RechargeChannelGroup::where('id', $params['id'])->first();
-                    if (!$info) throw new Exception('数据不存在');
-                    $info->update($params);
-                    $info->save();
-                }
-            }, 3);
-            
+    public function deleteGroup(RechargeChannelConfigurationService $service)
+    {
+        try {
+            $params = request()->validate(['id' => ['required', 'integer', 'min:1']]);
+            $service->deleteGroup((int)$params['id']);
             return $this->success();
+        } catch (ValidationException $e) {
+            return $this->error(HttpStatus::CUSTOM_ERROR, $e->validator->errors()->first());
+        } catch (ModelNotFoundException $e) {
+            return $this->error(HttpStatus::CUSTOM_ERROR, '层级不存在');
+        } catch (\PDOException $e) {
+            report($e);
+            return $this->error(HttpStatus::CUSTOM_ERROR, '删除层级失败,请稍后重试');
         } catch (Exception $e) {
-            return $this->error(HttpStatus::CUSTOM_ERROR,$e->getMessage());
+            return $this->error(HttpStatus::CUSTOM_ERROR, $e->getMessage());
         }
     }
 

+ 5 - 1
app/Http/Controllers/admin/User.php

@@ -618,7 +618,11 @@ class User extends Controller
                 'member_id' => ['required', 'array'],
                 'recharge_channel_group_id' => ['required', 'integer', 'min:1'],
             ]);
-            UserModel::whereIn('member_id', $params['member_id'])->update(['recharge_channel_group_id' => $params['recharge_channel_group_id']]);
+            DB::transaction(function () use ($params) {
+                $group = \App\Models\RechargeChannelGroup::query()->lockForUpdate()->find($params['recharge_channel_group_id']);
+                if (!$group) throw new Exception('层级不存在或已删除');
+                UserModel::whereIn('member_id', $params['member_id'])->update(['recharge_channel_group_id' => $group->id]);
+            }, 3);
         } catch (ValidationException $e) {
             return $this->error(HttpStatus::CUSTOM_ERROR, $e->validator->errors()->first());
         } catch (Exception $e) {

+ 3 - 0
app/Models/RechargeChannelGroup.php

@@ -2,8 +2,11 @@
 
 namespace App\Models;
 
+use Illuminate\Database\Eloquent\SoftDeletes;
+
 class RechargeChannelGroup extends BaseModel
 {
+    use SoftDeletes;
     protected $table = 'recharge_channel_group';
     protected $fillable = ['name', 'recharge_type', 'withdraw_type', 'activity_type'];
     protected $hidden = [];

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

@@ -11,6 +11,85 @@ use RuntimeException;
 
 class RechargeChannelConfigurationService
 {
+    public function saveGroup(array $params): array
+    {
+        return DB::transaction(function () use ($params) {
+            $id = (int)($params['id'] ?? 0);
+            unset($params['id']);
+            $validTypes = [];
+            foreach (['recharge_type' => 1, 'withdraw_type' => 2, 'activity_type' => 3] as $field => $direction) {
+                if (!array_key_exists($field, $params)) continue;
+                $params[$field] = $this->normalizeTypes((array)($params[$field] ?? []));
+                // Match deletion's lock order: channel rows, then the group being edited.
+                $validTypes[$field] = $params[$field] === [] ? [] : RechargeChannel::query()
+                    ->where('data_type', $direction)->whereIn('type', $params[$field])
+                    ->orderBy('id')->lockForUpdate()->get(['type'])->pluck('type')->map(fn ($type) => (string)$type)->all();
+            }
+            $group = $id ? RechargeChannelGroup::query()->lockForUpdate()->findOrFail($id) : new RechargeChannelGroup();
+            $before = $group->exists ? $group->toArray() : [];
+            $removed = [];
+            foreach ($validTypes as $field => $valid) {
+                $invalid = array_values(array_diff($params[$field], $valid));
+                $previous = $group->exists ? $this->normalizeTypes((array)$group->{$field}) : [];
+                $newInvalid = array_values(array_diff($invalid, $previous));
+                if ($newInvalid !== []) {
+                    throw new RuntimeException($field . ' 包含不存在或已删除的通道类型:'
+                        . implode(',', $newInvalid) . ';请提交选项的 type,不是通道 ID 或公司代码');
+                }
+                if ($invalid !== []) $removed[$field] = $invalid;
+                $params[$field] = array_values(array_intersect($params[$field], $valid));
+            }
+            $group->fill($params);
+            $group->save();
+            if ($removed !== []) {
+                OperationAuditService::record('recharge_channel_group', (int)$group->id, 'update', $before,
+                    ['group' => $group->toArray(), 'removed_types' => $removed]);
+            }
+            return $removed;
+        }, 3);
+    }
+
+    public function deleteGroup(int $id): void
+    {
+        if ($id === 1) throw new RuntimeException('默认通道组合不能删除');
+        DB::transaction(function () use ($id) {
+            $group = RechargeChannelGroup::withTrashed()->lockForUpdate()->findOrFail($id);
+            if ($group->trashed()) return;
+            if (Schema::hasTable('users') && Schema::hasColumn('users', 'recharge_channel_group_id')
+                && DB::table('users')->where('recharge_channel_group_id', $id)->lockForUpdate()->first(['id'])) {
+                throw new RuntimeException('该层级仍有会员使用,请先将会员调整到其他层级');
+            }
+            foreach (['payment_gateways', 'payment_collection_channels', 'payment_direct_recharges'] as $table) {
+                if (!Schema::hasTable($table) || !Schema::hasColumn($table, 'recharge_channel_group_ids')) continue;
+                DB::table($table)->whereNull('deleted_at')->whereNotNull('recharge_channel_group_ids')
+                    ->select(['id', 'recharge_channel_group_ids'])
+                    ->orderBy('id')->lockForUpdate()->chunkById(200, function ($rows) use ($id) {
+                        foreach ($rows as $row) {
+                            // Match runtime's integer normalization, including legacy string IDs such as "02".
+                            $ids = array_map('intval', (array)json_decode($row->recharge_channel_group_ids, true));
+                            if (in_array($id, $ids, true)) throw new RuntimeException('该层级仍被支付配置引用,请先解除关联');
+                        }
+                    }, 'id', 'id');
+            }
+            $before = $group->toArray();
+            $group->delete();
+            OperationAuditService::record('recharge_channel_group', $id, 'delete', $before, $group);
+        }, 3);
+    }
+
+    private function normalizeTypes(array $values): array
+    {
+        $types = [];
+        foreach ($values as $value) {
+            if ($value !== null && !is_string($value) && !is_int($value)) {
+                throw new RuntimeException('通道类型应提交 type 字符串数组,不能提交选项对象');
+            }
+            $type = trim((string)$value);
+            if ($type !== '') $types[] = $type;
+        }
+        return array_values(array_unique($types));
+    }
+
     public function options(int $dataType, ?int $id = null): array
     {
         $options = PaymentProviderCatalog::channelFormOptions($dataType, PaymentProviderService::statuses());

+ 3 - 1
app/Services/PaymentChannelLinkService.php

@@ -27,7 +27,9 @@ class PaymentChannelLinkService
 
         $groupIds = array_values(array_unique(array_filter(array_map('intval', $groupIds))));
         if (!$groupIds) throw new RuntimeException('至少选择一个通道组合');
-        $groups = RechargeChannelGroup::query()->whereIn('id', $groupIds)->get();
+        $groupQuery = RechargeChannelGroup::query()->whereIn('id', $groupIds)->orderBy('id');
+        if ($lockChannel) $groupQuery->lockForUpdate();
+        $groups = $groupQuery->get();
         if ($groups->count() !== count($groupIds)) throw new RuntimeException('部分通道组合不存在');
 
         $field = $this->groupField($expectedDataType);

+ 19 - 0
database/migrations/2026_09_08_170000_add_soft_deletes_to_recharge_channel_groups.php

@@ -0,0 +1,19 @@
+<?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::hasTable('recharge_channel_group') && !Schema::hasColumn('recharge_channel_group', 'deleted_at')) {
+            Schema::table('recharge_channel_group', fn (Blueprint $table) => $table->softDeletes());
+        }
+    }
+
+    public function down(): void
+    {
+        throw new RuntimeException('此字段保留层级删除状态,禁止自动回滚,请先核对业务数据');
+    }
+};

+ 35 - 0
docs/前端接口/支付配置/层级保存修复与删除-前端变更说明.md

@@ -0,0 +1,35 @@
+# 层级保存修复与删除-前端变更说明(2026-09-08)
+
+本次只涉及「充值通道 → 层级设置」:修复编辑原样保存报错,并增加层级删除。使用现有管理员 Token;不涉及支付公司或通道管理页面的其他调整。
+
+## 1. 保存行为修复
+
+现有 `POST /admin/rechargeChannel/updateGroup` 的 Path、参数和返回结构不变。
+
+- 继续提交 `recharge_type/withdraw_type/activity_type` 的 `type` 字符串数组,不提交通道 ID、公司代码或整个选项对象。
+- 旧组合原样保存时,已有失效类型不会再导致整次保存失败;清理发生后,成功 `msg` 为“保存成功,已清理失效的历史通道类型”。
+- 新提交的错误类型仍返回业务错误,`msg` 会列出具体值。公司/通道暂时禁用不等于类型失效,不需要清空原选择。
+- 前端按 `code=0` 判断成功,展示 `msg` 并刷新当前组合;不要依赖固定的成功提示文字。
+
+## 2. 新增层级删除按钮
+
+层级设置列表增加删除按钮,用户确认后请求:
+
+`POST /admin/rechargeChannel/deleteGroup`
+
+```json
+{"id":2}
+```
+
+`id` 为当前层级 ID,必填整数。成功仍返回 `code/timestamp/msg/data`,其中 `code=0,data=[]`。
+
+- 成功后重新请求 `GET /admin/rechargeChannel/groupList`,并刷新相关层级下拉缓存。
+- 默认组合、仍有会员使用或支付配置引用的组合不能删除;失败直接展示 `msg`,不要提前从页面移除该行。
+- 删除层级不会删除通道,也不会自动迁移会员。
+- 已删除记录重试返回成功;未知 ID 返回业务错误。
+- 提交期间禁用按钮,避免重复点击。
+
+## 3. 本次验收
+
+- 打开已有层级,数据不动直接保存,应成功;若提示清理,刷新后显示更新后的选择。
+- 删除无引用层级后列表不再显示;有引用层级删除失败并显示原因。

+ 37 - 0
docs/后端/层级保存修复与删除说明.md

@@ -0,0 +1,37 @@
+# 层级保存修复与删除-后端说明(2026-09-08)
+
+本次只记录层级保存修复与新增删除,不包含此前通道删除或公司管理的变更。前端单独看 [前端变更说明](../前端接口/支付配置/层级保存修复与删除-前端变更说明.md)。
+
+## 原样保存报错原因及修复
+
+此前用户提供的组合 1:充值类型中含 `1,2,3`,提现类型中含 `ylk,zfb,szrmb`。这些不是当前相应方向的通道 type。原 `updateGroup` 对整个提交数组做存在性计数,编辑页原样回传旧值也会被拒绝。
+
+现在由 `RechargeChannelConfigurationService::saveGroup` 处理:
+
+1. 对请求类型去空白、去重;拒绝选项对象等非标量数据。
+2. 按方向查询未删除的现有通道类型,不按启停状态过滤,因此不会因为公司暂时禁用而删除组合授权。
+3. 锁定当前组合,再比较请求中的失效值与该组合原本保存的值。原本已存在且现在确实失效的类型在本次保存时清理;新增的未知值仍拒绝,并在错误提示中列出。
+4. 不将数字当作通道 ID 转换,不把旧别名猜测转换成新的有效类型,不从其他组合继承无效值。活动字段未传时保持原值。
+5. 有历史清理时记录 `recharge_channel_group` 审计(含 `removed_types`),成功提示告知清理;失败则全部回滚,不发生一部分字段已清理另一部分失败。
+
+清理仅在用户保存该组合时发生,没有执行全表数据清洗或生产 SQL。新增回归先复现修改前 `code=-3`,修复后相同历史组合保存通过。
+
+## 新增层级删除
+
+`POST /admin/rechargeChannel/deleteGroup`,参数 `id`。默认 ID 1 被运行时作为兜底组合,禁止删除;其他组合需要无会员引用、无未删除支付配置引用才可删除。配置中的数字及字符串组 ID 均按运行时整数规则判断,防止旧值 `"02"` 绕过引用检查。
+
+采用软删除并记录审计;重复删除已删除记录成功返回。层级从正常列表/选项/运行时查询中排除,不自动迁移会员,不删除该层级包含的通道。会员层级分配接口也会在事务内锁定并检查目标层级;商户保存时会锁定选中的组合,防止引用已删除的层级。
+
+新增迁移需在启用这次代码前执行:
+
+```sh
+php artisan migrate --path=database/migrations/2026_09_08_170000_add_soft_deletes_to_recharge_channel_groups.php
+```
+
+这条迁移只增加 `recharge_channel_group.deleted_at`,不迁移会员、不删除组合。此前 `2026_09_08_160000_add_payment_provider_status_and_channel_soft_deletes.php` 公司/通道迁移及已有审计表仍需已完成。为保留删除状态,禁止自动回滚本字段;恢复需人工核对审计和引用。
+
+## 补充验证
+
+当前支付相关回归为 **36 个测试、238 个断言通过**,新增覆盖历史无效类型原样保存、未知新类型原子拒绝、类型规范化、嵌套对象拒绝、软删除类型清理,以及层级软删除/默认组合保护/会员及支付配置引用/重复删除/删除后不能重新绑定。验证仍为隔离 SQLite 环境,不替代 MySQL 并发验证,未操作服务器数据。
+
+发布前执行上述迁移,再按既有流程刷新路由缓存、重载常驻进程。本次未在服务器执行迁移或部署。

+ 1 - 0
routes/admin.php

@@ -321,6 +321,7 @@ Route::middleware(['admin.jwt'])->group(function () {
             Route::post("/update", [RechargeChannel::class, 'update']);
             Route::get("/groupList", [RechargeChannel::class, 'groupList']);
             Route::post("/updateGroup", [RechargeChannel::class, 'updateGroup']);
+            Route::post('/deleteGroup', [RechargeChannel::class, 'deleteGroup']);
             Route::get("/getChannel", [RechargeChannel::class, 'getChannel']);
 
         });

+ 149 - 0
tests/Integration/PaymentChannelOptionsTest.php

@@ -65,6 +65,7 @@ class PaymentChannelOptionsTest extends TestCase
         DB::table('recharge_channel')->insert($fixture['channels']);
         DB::table('recharge_channel_group')->insert($fixture['groups']);
         (require dirname(__DIR__, 2) . '/database/migrations/2026_09_08_160000_add_payment_provider_status_and_channel_soft_deletes.php')->up();
+        (require dirname(__DIR__, 2) . '/database/migrations/2026_09_08_170000_add_soft_deletes_to_recharge_channel_groups.php')->up();
         $db->schema()->create('operation_audits', function (Blueprint $t) {
             $t->id(); $t->string('resource_type'); $t->unsignedBigInteger('resource_id')->nullable();
             $t->string('action'); $t->json('changes'); $t->unsignedBigInteger('operator_id')->nullable();
@@ -496,4 +497,152 @@ class PaymentChannelOptionsTest extends TestCase
         $this->assertSame('', DB::table('recharge_channel_group')->where('id', 1)->value('recharge_type'));
         $this->assertSame('yuebao,old_user,recharge', DB::table('recharge_channel_group')->where('id', 1)->value('activity_type'));
     }
+
+    private function saveGroup(array $params): array
+    {
+        $this->app->instance('request', Request::create('/admin/rechargeChannel/updateGroup', 'POST', $params));
+        return (new RechargeChannel())->updateGroup()->getData(true);
+    }
+
+    private function groupPayload(int $id = 1): array
+    {
+        $group = DB::table('recharge_channel_group')->where('id', $id)->first();
+        return ['id' => $id, 'name' => $group->name, 'recharge_type' => explode(',', $group->recharge_type),
+            'withdraw_type' => explode(',', $group->withdraw_type)];
+    }
+
+    public function test_legacy_group_can_be_saved_and_only_invalid_existing_types_are_cleaned(): void
+    {
+        $payload = $this->groupPayload();
+        $result = $this->saveGroup($payload);
+        $this->assertSame(0, $result['code']);
+        $this->assertSame([], $result['data']);
+        $this->assertStringContainsString('已清理', $result['msg']);
+        $saved = $this->groupPayload();
+        $this->assertSame(array_values(array_diff($payload['recharge_type'], ['1', '2', '3'])), $saved['recharge_type']);
+        $this->assertSame(array_values(array_diff($payload['withdraw_type'], ['ylk', 'zfb', 'szrmb'])), $saved['withdraw_type']);
+        $this->assertSame('yuebao,old_user,recharge', DB::table('recharge_channel_group')->where('id', 1)->value('activity_type'));
+        $audit = DB::table('operation_audits')->where('resource_type', 'recharge_channel_group')->first();
+        $this->assertNotNull($audit);
+        $this->assertSame(['1', '2', '3'], json_decode($audit->changes, true)['after']['removed_types']['recharge_type']);
+    }
+
+    public function test_new_unknown_type_in_existing_group_is_rejected_atomically(): void
+    {
+        $payload = $this->groupPayload();
+        $payload['recharge_type'][] = 'not_a_type';
+        $before = DB::table('recharge_channel_group')->where('id', 1)->first();
+        $result = $this->saveGroup($payload);
+        $this->assertSame(-3, $result['code']);
+        $this->assertStringContainsString('not_a_type', $result['msg']);
+        $this->assertSame($before->recharge_type, DB::table('recharge_channel_group')->where('id', 1)->value('recharge_type'));
+        $this->assertSame(0, DB::table('operation_audits')->count());
+    }
+
+    public function test_new_group_cannot_reuse_invalid_values_from_another_group(): void
+    {
+        $payload = $this->groupPayload();
+        unset($payload['id']);
+        $result = $this->saveGroup($payload);
+        $this->assertSame(-3, $result['code']);
+        $this->assertStringContainsString('1,2,3', $result['msg']);
+        $this->assertSame(2, DB::table('recharge_channel_group')->count());
+    }
+
+    public function test_group_types_trim_deduplicate_and_keep_disabled_company_membership(): void
+    {
+        $this->setCompanyStatus('no', 0);
+        $result = $this->saveGroup(['id' => 1, 'name' => '组合',
+            'recharge_type' => [' JDpay ', 'JDpay', ' NOpay12 '], 'withdraw_type' => [' DF001 ']]);
+        $this->assertSame(0, $result['code']);
+        $this->assertSame('JDpay,NOpay12', DB::table('recharge_channel_group')->where('id', 1)->value('recharge_type'));
+        $this->assertSame('DF001', DB::table('recharge_channel_group')->where('id', 1)->value('withdraw_type'));
+    }
+
+    public function test_nested_option_objects_are_rejected_without_writing(): void
+    {
+        $result = $this->saveGroup(['id' => 1, 'name' => '组合',
+            'recharge_type' => [['type' => 'JDpay', 'name' => 'JD钱包']], 'withdraw_type' => ['DF001']]);
+        $this->assertSame(-3, $result['code']);
+        $this->assertStringContainsString('type', $result['msg']);
+        $this->assertSame(0, DB::table('operation_audits')->count());
+    }
+
+    public function test_existing_deleted_type_is_removed_and_not_restored(): void
+    {
+        DB::table('recharge_channel')->where('id', 24)->update(['deleted_at' => now(), 'status' => 0]);
+        $this->assertSame(0, $this->saveGroup($this->groupPayload())['code']);
+        $saved = $this->groupPayload();
+        $this->assertNotContains('JDpay', $saved['recharge_type']);
+        $this->assertContains('JDpay', $saved['withdraw_type']);
+        $this->assertTrue(ChannelModel::withTrashed()->findOrFail(24)->trashed());
+    }
+
+    private function deleteGroup(int $id): array
+    {
+        $this->app->instance('request', Request::create('/admin/rechargeChannel/deleteGroup', 'POST', ['id' => $id]));
+        return (new RechargeChannel())->deleteGroup(new RechargeChannelConfigurationService())->getData(true);
+    }
+
+    public function test_unreferenced_group_can_be_soft_deleted_without_deleting_channels(): void
+    {
+        $count = ChannelModel::query()->count();
+        $this->assertSame(0, $this->deleteGroup(2)['code']);
+        $this->assertNull(\App\Models\RechargeChannelGroup::query()->find(2));
+        $this->assertTrue(\App\Models\RechargeChannelGroup::withTrashed()->findOrFail(2)->trashed());
+        $this->assertSame($count, ChannelModel::query()->count());
+        $this->assertSame(0, $this->deleteGroup(2)['code']);
+        $this->assertSame(1, DB::table('operation_audits')->where('action', 'delete')->count());
+        $this->request('/admin/rechargeChannel/groupList', []);
+        $this->assertSame(1, (new RechargeChannel())->groupList()->getData(true)['data']['total']);
+        $this->assertSame(-3, $this->saveGroup(['id' => 2, 'name' => '已删除', 'recharge_type' => [], 'withdraw_type' => []])['code']);
+    }
+
+    public function test_default_group_and_member_assigned_group_cannot_be_deleted(): void
+    {
+        $this->assertSame(-3, $this->deleteGroup(1)['code']);
+        $this->assertSame(-3, $this->deleteGroup(999)['code']);
+        DB::connection()->getSchemaBuilder()->create('users', function (Blueprint $t) {
+            $t->id(); $t->string('member_id'); $t->unsignedBigInteger('recharge_channel_group_id')->nullable(); $t->timestamps();
+        });
+        DB::table('users')->insert(['member_id' => 'test', 'recharge_channel_group_id' => 2]);
+        $result = $this->deleteGroup(2);
+        $this->assertSame(-3, $result['code']);
+        $this->assertStringContainsString('会员', $result['msg']);
+        $this->assertNotNull(\App\Models\RechargeChannelGroup::query()->find(2));
+        $this->assertSame(0, DB::table('operation_audits')->count());
+    }
+
+    public function test_numeric_and_string_payment_config_group_references_block_delete(): void
+    {
+        DB::connection()->getSchemaBuilder()->create('payment_collection_channels', function (Blueprint $t) {
+            $t->id(); $t->json('recharge_channel_group_ids'); $t->softDeletes();
+        });
+        foreach (['[2]', '["2"]', '["02"]'] as $json) {
+            DB::table('payment_collection_channels')->delete();
+            DB::table('payment_collection_channels')->insert(['recharge_channel_group_ids' => $json]);
+            $result = $this->deleteGroup(2);
+            $this->assertSame(-3, $result['code']);
+            $this->assertStringContainsString('被支付配置引用', $result['msg']);
+            $this->assertNotNull(\App\Models\RechargeChannelGroup::query()->find(2));
+        }
+        DB::table('payment_collection_channels')->update(['deleted_at' => now()]);
+        $this->assertSame(0, $this->deleteGroup(2)['code']);
+    }
+
+    public function test_deleted_group_cannot_be_assigned_to_members_or_new_payment_config(): void
+    {
+        DB::connection()->getSchemaBuilder()->create('users', function (Blueprint $t) {
+            $t->id(); $t->string('member_id'); $t->unsignedBigInteger('recharge_channel_group_id')->nullable(); $t->timestamps();
+        });
+        DB::table('users')->insert(['member_id' => 'test', 'recharge_channel_group_id' => 1]);
+        $this->assertSame(0, $this->deleteGroup(2)['code']);
+        $this->app->instance('request', Request::create('/admin/user/setRechargeChannelGroup', 'POST',
+            ['member_id' => ['test'], 'recharge_channel_group_id' => 2]));
+        $this->assertSame(-3, (new \App\Http\Controllers\admin\User())->setRechargeChannelGroup()->getData(true)['code']);
+        $this->assertSame(1, DB::table('users')->where('member_id', 'test')->value('recharge_channel_group_id'));
+        $this->expectException(\RuntimeException::class);
+        $this->expectExceptionMessage('部分通道组合不存在');
+        (new PaymentChannelLinkService())->validate(24, [2], 1, true);
+    }
 }