doge пре 2 часа
родитељ
комит
bd60fb1650

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

@@ -220,9 +220,9 @@ class RechargeChannel extends Controller
                 'data_type' => ['required','integer','in:1,2,3'],
                 'type' => ['required','string','max:100'],
                 'rate' => ['required','numeric','min:0'],
-                'min' => ['nullable','numeric','min:0'],
-                'max' => ['nullable','numeric','gte:min'],
-                'fixed' => ['nullable','string'],
+                'min' => ['exclude_if:type,zfbge','nullable','numeric','min:0'],
+                'max' => ['exclude_if:type,zfbge','nullable','numeric','gte:min'],
+                'fixed' => ['nullable'],
                 'status' => ['nullable','integer','in:0,1'],
                 'sort' => ['nullable','integer','min:0'],
                 'logo' => ['nullable', 'string', 'max:2048'],

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

@@ -113,6 +113,7 @@ class RechargeChannelConfigurationService
                 throw new RuntimeException('不能修改通道所属的充值、提现或活动方向');
             }
             $params = PaymentProviderCatalog::normalizeChannelSelection($params);
+            $params = $this->amountConfiguration($params, $channel);
             $identityChanged = !$channel->exists || (int)$channel->from !== $params['from'] || (string)$channel->type !== (string)$params['type'];
             $identity = PaymentProviderCatalog::channelIdentity($params);
             if ($identityChanged && $identity) PaymentProviderService::assertEnabled($identity['provider_code']);
@@ -144,6 +145,44 @@ class RechargeChannelConfigurationService
         }, 3);
     }
 
+    private function amountConfiguration(array $params, RechargeChannel $channel): array
+    {
+        $fixedMode = (int)$params['data_type'] === 1 && $params['type'] === 'zfbge';
+        if ($fixedMode || array_key_exists('fixed', $params)) {
+            $value = array_key_exists('fixed', $params) ? $params['fixed'] : ($channel->type === 'zfbge' ? $channel->getRawOriginal('fixed') : null);
+            if (is_string($value)) $values = explode(',', str_replace(',', ',', $value));
+            elseif (is_array($value) && array_values($value) === $value) $values = $value;
+            elseif ($value === null) $values = [];
+            else throw \Illuminate\Validation\ValidationException::withMessages(['fixed' => '固定金额请提交金额数组或逗号分隔字符串']);
+            $amounts = [];
+            foreach ($values as $amount) {
+                if (!is_string($amount) && !is_int($amount) && !is_float($amount)) throw \Illuminate\Validation\ValidationException::withMessages(['fixed' => '固定金额格式错误']);
+                $amount = trim((string)$amount);
+                if ($amount === '') continue;
+                if (!preg_match('/^\d{1,12}(\.\d{1,2}0*)?$/D', $amount) || bccomp($amount, '0', 2) <= 0) {
+                    throw \Illuminate\Validation\ValidationException::withMessages(['fixed' => '固定金额必须大于0,最多两位小数']);
+                }
+                $amount = bcadd($amount, '0', 2);
+                if (str_ends_with($amount, '.00')) $amount = substr($amount, 0, -3);
+                if (!in_array($amount, $amounts, true)) $amounts[] = $amount;
+            }
+            if ($fixedMode && !$amounts) throw \Illuminate\Validation\ValidationException::withMessages(['fixed' => '请设置固定充值金额']);
+            $params['fixed'] = $amounts ? implode(',', $amounts) : null;
+        } elseif ($channel->exists && $channel->type !== $params['type']) {
+            $params['fixed'] = null;
+        }
+        if ($fixedMode) {
+            $params['min'] = $params['max'] = null;
+        } else {
+            $range = ['min' => array_key_exists('min', $params) ? $params['min'] : $channel->min,
+                'max' => array_key_exists('max', $params) ? $params['max'] : $channel->max];
+            $rules = ['min' => ['nullable', 'numeric', 'min:0'], 'max' => ['nullable', 'numeric', 'min:0']];
+            if ($range['min'] !== null && $range['max'] !== null) $rules['max'][] = 'gte:min';
+            \Illuminate\Support\Facades\Validator::make($range, $rules)->validate();
+        }
+        return $params;
+    }
+
     public function delete(int $id): void
     {
         DB::transaction(function () use ($id) {

+ 1 - 1
app/Services/PaymentOrderService.php

@@ -231,7 +231,7 @@ class PaymentOrderService extends BaseService
                         $channel = $k;
 
                     } else {
-                        $geText .= "❌ 此充值通道固定充值金额为" . implode(',', $v['fixed']) . "请务必输入区间金额!";
+                        $geText .= "❌ 此充值通道固定充值金额为" . implode(',', $v['fixed']) . "请选择上述固定金额!";
                     }
                 } else {
                     if ($amount >= $v['min'] && $amount <= $v['max']) {

+ 2 - 2
app/Support/ActivityCatalog.php

@@ -4,7 +4,7 @@ namespace App\Support;
 
 use App\Services\Sharing\ShareDefaults;
 
-/** Reuse commission game categories; "other" is an activity-only classification. */
+/** Activity display categories include hot/other in addition to the shared game categories. */
 final class ActivityCatalog
 {
     public static function activityTypes(): array
@@ -14,7 +14,7 @@ final class ActivityCatalog
 
     public static function gameTypes(): array
     {
-        return ShareDefaults::categories() + ['other' => '其他'];
+        return ['hot' => '热门'] + ShareDefaults::categories() + ['other' => '其他'];
     }
 
     public static function options(): array

+ 36 - 0
database/migrations/2026_10_28_180000_restore_esports_platform_relations.php

@@ -0,0 +1,36 @@
+<?php
+
+use Illuminate\Database\Migrations\Migration;
+use Illuminate\Support\Facades\DB;
+use Illuminate\Support\Facades\Schema;
+
+return new class extends Migration {
+    public function up(): void
+    {
+        if (!Schema::hasTable('egame_items')) {
+            return;
+        }
+
+        // The legacy image whitelist omitted esports and disabled these lobby
+        // relations. Repair them once; subsequent admin switches remain authoritative.
+        DB::table('egame_items')
+            ->where('item_type', 'platform')
+            ->where('game_type', 5)
+            ->where('game_code', '')
+            ->whereIn('plat_type', ['db5', 'esb', 'tf'])
+            ->where(function ($query) {
+                $query->where('status', '<>', 1)->orWhere('lobby_enabled', '<>', 1);
+            })
+            ->update([
+                'status' => 1,
+                'lobby_enabled' => 1,
+                'updated_at' => date('Y-m-d H:i:s'),
+            ]);
+    }
+
+    public function down(): void
+    {
+        // The previous switches may include both the legacy bug and admin edits.
+        // Do not disable working entrances or overwrite later admin decisions.
+    }
+};

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

@@ -81,3 +81,5 @@ USDT 通道公司列显示 `—` 是正常情况;不要给它自动选中三
 - 列表仍分列显示公司和通道名称;层级勾选/标签直接显示 `display_name`,例如 `NO支付 / NO扫码通道`。勾选值仍为 `type`,不是名称或公司代码。
 
 联调:新增 NO 通道 → 列表显示 NO 公司;无引用通道改为 JD 并重选 `JDpay` → 同一 ID 显示 JD;USDT 保持无公司;有引用通道显示不可修改原因。
+
+三斤支付宝固额(`type=zfbge`)只使用固定金额,不需要最大/最小金额;`fixed` 支持数组或逗号分隔字符串,见[固额金额说明](支付宝固额金额-前端接入.md)。

+ 15 - 0
docs/前端接口/支付配置/支付宝固额金额-前端接入.md

@@ -0,0 +1,15 @@
+# 三斤支付:支付宝固额
+
+当充值通道为 `type=zfbge` 时:
+
+- 只显示「固定充值金额」输入项,固定金额必填。
+- 隐藏「最小金额」「最大金额」,不校验、不提交 `min/max`。
+- 列表返回的 `fixed` 是数组,可以直接回填并保存。
+
+原保存接口 `POST /admin/rechargeChannel/update`,其他字段照常提交,固定金额示例:
+
+```json
+{"fixed":[945,988,990]}
+```
+
+也兼容字符串 `"945,988,990"`。金额必须大于0,最多两位有效小数;接口自动去重。其他通道的金额范围设置沿用原逻辑。

+ 19 - 0
docs/前端接口/活动/热门分类-后台接入.md

@@ -0,0 +1,19 @@
+# 活动分类:热门
+
+后台活动分类新增:`hot`,名称「热门」。
+
+`GET /admin/ActivityReward/options` 的 `data.game_types` 已包含:
+
+```json
+{"value":"hot","label":"热门"}
+```
+
+新增、编辑继续使用 `POST /admin/ActivityReward/submit`,其他字段照常提交,分类字段为:
+
+```json
+{"game_category":"hot"}
+```
+
+后台列表筛选使用 `GET /admin/ActivityReward?game_category=hot&page=1&limit=20`。
+
+分类使用 `game_category`,活动原业务类型 `type` 不变。

+ 14 - 0
docs/后端/电竞分类前台显示修正.md

@@ -0,0 +1,14 @@
+# 电竞分类前台显示修正
+
+电竞分类为 `game_type=5`,平台为 `db5`(DB电竞)、`esb`(电竞牛)、`tf`(雷火电竞)。旧展示白名单遗漏这三组关联,旧目录迁移会将它们设为关闭,用户侧 `/api/egame/platforms` 随后隐藏空分类。
+
+## 发布
+
+1. 发布 `melbet_sport-api/config/game.php`,电竞展示白名单补充 `5 => ['db5', 'esb', 'tf']`。该配置用于旧目录展示和新目录初始化;已有目录以数据库为准。
+2. 在 `bot-28` 执行 `php artisan migrate --force`,应用 `2026_10_28_180000_restore_esports_platform_relations.php`。迁移只恢复上述三条电竞平台分类关联的 `status=1`、`lobby_enabled=1`,不修改名称、图片、排序、单个游戏、其他平台和分类,也不修改分类及平台总开关。
+3. 如前台服务缓存了配置,按现有发布流程重新加载配置或重启常驻进程。
+4. 请求 `GET /api/egame/platforms?device=pc`、`device=mobile` 及不传 `device` 的列表,确认 `data.list` 返回 `gameType=5`。已启用的平台可在无子游戏时通过大厅显示;请求 `GET /api/egame/gameCodes?platType=db5&gameType=5` 可核对大厅入口。
+
+迁移为一次性修正,正常同步不会再次开启运营后来关闭的入口。如果电竞分类或某平台总开关已由运营关闭,仍按其开关过滤,需在后台核对。恢复入口后继续验证关闭分类、平台总开关、平台分类关联能立即使下一次列表请求隐藏相应项。
+
+这是一项目录数据修正,回滚不自动关闭入口,以免覆盖运营后续操作;需要关闭时使用后台开关。发布后的线上验证需要在实际服务及数据库中完成。

+ 17 - 4
tests/Integration/ActivityCatalogTest.php

@@ -134,10 +134,11 @@ class ActivityCatalogTest extends TestCase
         $this->assertSame($financialCategories, ShareDefaults::categories());
         $this->assertCount(9, ShareDefaults::categories());
         $this->assertArrayNotHasKey('other', ShareDefaults::categories());
-        $this->assertSame($financialCategories + ['other' => '其他'], ActivityCatalog::gameTypes());
+        $this->assertArrayNotHasKey('hot', ShareDefaults::categories());
+        $this->assertSame(['hot' => '热门'] + $financialCategories + ['other' => '其他'], ActivityCatalog::gameTypes());
         $expectedGames = [];
-        foreach ($financialCategories + ['other' => '其他'] as $value => $label) $expectedGames[] = compact('value', 'label');
-        $this->assertCount(10, $expectedGames);
+        foreach (['hot' => '热门'] + $financialCategories + ['other' => '其他'] as $value => $label) $expectedGames[] = compact('value', 'label');
+        $this->assertCount(11, $expectedGames);
         $this->assertSame($expectedGames, $body['data']['game_types']);
         $this->assertNotContains('', array_column($body['data']['game_types'], 'value'));
         $this->assertNotContains('wallet', array_column($body['data']['game_types'], 'value'));
@@ -269,6 +270,18 @@ class ActivityCatalogTest extends TestCase
         }
     }
 
+    public function test_hot_category_is_saved_filtered_and_preserved_when_editing_other_fields(): void
+    {
+        $this->assertSame(HttpStatus::OK, $this->save(['game_category' => 'hot'])['code']);
+        $id = (int) DB::table('activity_rewards')->value('id');
+        $this->assertSame(HttpStatus::OK, $this->save(['id' => $id, 'title' => '热门活动'])['code']);
+        $row = $this->listing(['game_category' => 'hot']);
+        $this->assertSame(1, $row['total']);
+        $this->assertSame('hot', $row['data'][0]['game_category']);
+        $this->assertSame('热门', $row['data'][0]['game_category_name']);
+        $this->assertSame(0, $this->listing(['game_category' => 'other'])['total']);
+    }
+
     public function test_new_omission_is_null_edit_omission_preserves_category_and_explicit_values_clear_it(): void
     {
         $result = $this->save();
@@ -312,7 +325,7 @@ class ActivityCatalogTest extends TestCase
                 $this->assertSame($label, $row['game_category_name']);
             }
         }
-        $this->assertSame(20, $this->listing()['total']);
+        $this->assertSame(22, $this->listing()['total']);
     }
 
     public function test_other_category_is_preserved_on_omitted_edit_and_can_be_cleared_for_type_two_and_three(): void

+ 103 - 0
tests/Integration/EgameEsportsMigrationTest.php

@@ -0,0 +1,103 @@
+<?php
+
+namespace Tests\Integration;
+
+use Illuminate\Config\Repository;
+use Illuminate\Database\Capsule\Manager;
+use Illuminate\Database\Schema\Blueprint;
+use Illuminate\Foundation\Application;
+use Illuminate\Support\Facades\DB;
+use Illuminate\Support\Facades\Facade;
+use PHPUnit\Framework\TestCase;
+
+class EgameEsportsMigrationTest extends TestCase
+{
+    protected function setUp(): void
+    {
+        Facade::clearResolvedInstances();
+        $app = new Application(dirname(__DIR__, 2));
+        $app->instance('config', new Repository());
+        Facade::setFacadeApplication($app);
+        $db = new Manager($app);
+        $db->addConnection(['driver' => 'sqlite', 'database' => ':memory:', 'prefix' => 'bot_']);
+        $db->setAsGlobal();
+        $app->instance('db', $db->getDatabaseManager());
+        $app->bind('db.schema', fn () => $db->schema());
+        $db->schema()->create('egame_items', function (Blueprint $table) {
+            $table->id();
+            $table->string('item_type');
+            $table->string('plat_type')->default('');
+            $table->unsignedTinyInteger('game_type');
+            $table->string('game_code')->default('');
+            $table->string('name')->default('');
+            $table->string('logo')->default('');
+            $table->text('logo_langs')->nullable();
+            $table->string('ingress')->default('3');
+            $table->integer('status')->default(1);
+            $table->integer('sort')->default(0);
+            $table->timestamps();
+            $table->unique(['item_type', 'plat_type', 'game_type', 'game_code']);
+        });
+        (require dirname(__DIR__, 2) . '/database/migrations/2026_07_21_120000_add_catalog_management_to_egame_items_table.php')->up();
+    }
+
+    protected function tearDown(): void
+    {
+        DB::disconnect();
+        Facade::clearResolvedInstances();
+        parent::tearDown();
+    }
+
+    private function migration()
+    {
+        return require dirname(__DIR__, 2) . '/database/migrations/2026_10_28_180000_restore_esports_platform_relations.php';
+    }
+
+    public function test_upgrades_the_legacy_catalog_without_changing_other_rows_or_display_fields(): void
+    {
+        $targets = DB::table('egame_items')->where('item_type', 'platform')->where('game_type', 5)->get();
+        $this->assertCount(3, $targets);
+        foreach ($targets as $row) {
+            $this->assertSame(0, (int) $row->status, 'Reproduce the legacy whitelist omission');
+        }
+        DB::table('egame_items')->where('plat_type', 'db5')->where('game_type', 5)->update([
+            'name' => '自定义电竞名称', 'logo' => '/custom.png', 'sort' => 99, 'lobby_enabled' => 0,
+        ]);
+        // Explicit category/global closures remain effective after the repair.
+        DB::table('egame_items')->where('item_type', 'category')->where('game_type', 5)->update(['status' => 0]);
+        DB::table('egame_items')->where('plat_type', 'tf')->where('game_type', 0)->update(['status' => 0]);
+        DB::table('egame_items')->insert([
+            ['item_type' => 'game', 'plat_type' => 'db5', 'game_type' => 5, 'game_code' => 'closed-game', 'status' => 0],
+            ['item_type' => 'platform', 'plat_type' => 'custom', 'game_type' => 5, 'game_code' => '', 'status' => 0],
+        ]);
+        $before = DB::table('egame_items')->orderBy('id')->get();
+        $this->migration()->up();
+        $this->assertSame($before->count(), DB::table('egame_items')->count());
+        foreach ($before as $row) {
+            $after = DB::table('egame_items')->where('id', $row->id)->first();
+            if ($row->item_type === 'platform' && (int) $row->game_type === 5
+                && in_array($row->plat_type, ['db5', 'esb', 'tf'], true) && $row->game_code === '') {
+                $this->assertSame(1, (int) $after->status);
+                $this->assertSame(1, (int) $after->lobby_enabled);
+                foreach (['status', 'lobby_enabled', 'updated_at'] as $field) {
+                    unset($row->{$field}, $after->{$field});
+                }
+            }
+            $this->assertEquals($row, $after);
+        }
+    }
+
+    public function test_repeated_up_is_unchanged_and_rollback_preserves_later_admin_changes(): void
+    {
+        $migration = $this->migration();
+        $migration->up();
+        DB::table('egame_items')->update(['updated_at' => '2026-01-01 00:00:00']);
+        $before = DB::table('egame_items')->orderBy('id')->get();
+        $migration->up();
+        $this->assertEquals($before, DB::table('egame_items')->orderBy('id')->get());
+        DB::table('egame_items')->where('plat_type', 'esb')->where('game_type', 5)->update(['status' => 0]);
+        $before = DB::table('egame_items')->orderBy('id')->get();
+        $migration->down();
+        $this->assertEquals($before, DB::table('egame_items')->orderBy('id')->get());
+    }
+}

+ 37 - 0
tests/Integration/PaymentChannelOptionsTest.php

@@ -95,6 +95,43 @@ class PaymentChannelOptionsTest extends TestCase
         return $response['data'];
     }
 
+    public function test_sanjin_fixed_amounts_accept_list_roundtrip_without_any_minimum_or_maximum(): void
+    {
+        $params = ['id' => 10, 'data_type' => 1, 'payment_company' => 'sanjin', 'type' => 'zfbge', 'rate' => '0.027'];
+        $result = $this->saveChannel($params + ['fixed' => [945, '988.00000000', 990, 945], 'min' => 'not applicable', 'max' => ['unused']]);
+        $this->assertSame(0, $result['code'], $result['msg']);
+        $row = ChannelModel::find(10);
+        $this->assertSame(['945', '988', '990'], $row->fixed);
+        $this->assertNull($row->min); $this->assertNull($row->max);
+        $result = $this->saveChannel($params + ['fixed' => $row->fixed]);
+        $this->assertSame(0, $result['code'], $result['msg']);
+        $this->assertSame(0, $this->saveChannel($params + ['fixed' => '100, 200.50'])['code']);
+        $this->assertSame(['100', '200.50'], ChannelModel::find(10)->fixed);
+        $this->assertSame(0, $this->saveChannel($params)['code']);
+        $this->assertSame(['100', '200.50'], ChannelModel::find(10)->fixed);
+        $created = $this->saveChannel($this->newChannel(['payment_company' => 'sanjin', 'type' => 'zfbge', 'key' => 'fixed-test', 'fixed' => ['300', '500']]));
+        $this->assertSame(0, $created['code'], $created['msg']);
+        $new = ChannelModel::where('key', 'fixed-test')->first();
+        $this->assertNull($new->min); $this->assertNull($new->max);
+        $this->assertSame(['300', '500'], $new->fixed);
+    }
+
+    public function test_fixed_amount_validation_and_other_channel_range_validation_remain_independent(): void
+    {
+        $params = ['id' => 10, 'data_type' => 1, 'payment_company' => 'sanjin', 'type' => 'zfbge', 'rate' => '0.027'];
+        $before = DB::table('recharge_channel')->where('id', 10)->first();
+        foreach ([null, [], '', [0], [-1], ['1.234'], [['invalid']], ['key' => 100], 'abc'] as $fixed) {
+            $this->assertSame(-3, $this->saveChannel($params + ['fixed' => $fixed])['code']);
+            $this->assertEquals($before, DB::table('recharge_channel')->where('id', 10)->first());
+        }
+        $badRange = $this->saveChannel(['id' => 2, 'data_type' => 1, 'payment_company' => 'sanjin', 'type' => 'wxsm', 'rate' => '0.01', 'min' => 100, 'max' => 50]);
+        $this->assertSame(-3, $badRange['code']);
+        try {
+            (new RechargeChannelConfigurationService())->save($params + ['fixed' => [], 'min' => 1, 'max' => 2]);
+            $this->fail('Invalid direct-service fixed amounts accepted');
+        } catch (\Illuminate\Validation\ValidationException $e) { $this->assertArrayHasKey('fixed', $e->errors()); }
+    }
+
     public function test_logo_options_include_every_payment_method_even_without_enabled_or_existing_channels(): void
     {
         $this->request('/admin/rechargeChannel/saveLogo', []);