doge 5 ساعت پیش
والد
کامیت
0c91f7d437

+ 7 - 2
app/Http/Controllers/admin/Menu.php

@@ -72,6 +72,7 @@ class Menu extends Controller
      * @apiParam {string} [sort] 排序值(越小越靠前)
      * @apiParam {string} [status] 状态 1-显示 2-隐藏
      * @apiParam {string} [type] 类型 1-菜单 2-按钮
+     * @apiParam {string} [icon] 上传图标返回的图片地址;空字符串清除图标
      */
     public function store()
     {
@@ -81,7 +82,11 @@ class Menu extends Controller
                 'id' => ['nullable', 'integer', 'min:1'],
                 'title' => 'required|string|max:100',
                 'status' => 'required|nullable|integer',
-                'icon' => 'nullable|string',
+                'icon' => ['bail', 'nullable', 'string', 'max:500', function ($attribute, $value, $fail) {
+                    if (!\App\Models\Menu::isImageIcon($value)) {
+                        $fail('菜单图标请提交上传后的图片地址');
+                    }
+                }],
                 'sort' => 'required|nullable|integer',
             ];
 //            if (empty($id)) {
@@ -214,4 +219,4 @@ class Menu extends Controller
         }
     }
 
-}
+}

+ 29 - 1
app/Models/Menu.php

@@ -1,6 +1,8 @@
 <?php
 namespace App\Models;
 
+use App\Constants\Util;
+
 class Menu extends BaseModel
 {
     protected $table = 'menus';
@@ -12,6 +14,32 @@ class Menu extends BaseModel
     const STATUS_SHOW = 1;
     const STATUS_HIDE = 2;
 
+    public static function isImageIcon(string $value): bool
+    {
+        $value = trim($value);
+        if ($value === '') return true;
+        if (preg_match('#^https?://#i', $value)) {
+            return filter_var($value, FILTER_VALIDATE_URL) !== false;
+        }
+        return preg_match('#^/(?!/)\S+$#u', $value) === 1;
+    }
+
+    protected function getIconAttribute($value): string
+    {
+        $value = trim((string) $value);
+        return self::isImageIcon($value) ? (string) Util::ensureUrl($value) : '';
+    }
+
+    protected function setIconAttribute($value): void
+    {
+        $value = trim((string) $value);
+        $baseUrl = rtrim((string) config('app.url', ''), '/');
+        if ($baseUrl !== '' && str_starts_with($value, $baseUrl . '/')) {
+            $value = substr($value, strlen($baseUrl));
+        }
+        $this->attributes['icon'] = $value;
+    }
+
     public function children()
     {
         return $this->hasMany(Menu::class, 'parent_id');
@@ -21,4 +49,4 @@ class Menu extends BaseModel
     {
         return $this->belongsTo(Menu::class, 'parent_id');
     }
-}
+}

+ 1 - 1
app/Services/BalanceLogService.php

@@ -214,7 +214,7 @@ class BalanceLogService extends BaseService
                         if (!empty($agent_member_id)) {
                             DB::transaction(function () use ($agent_member_id, $add_amount, $related_id, $memberId, $remark_amount, $user, $amount, $rate) {
                                 $wallet = Wallet::where('member_id', $agent_member_id)->lockForUpdate()->first();
-                                if (!$wallet) throw new \RuntimeException('推广人钱包不存在');
+                                if (!$wallet) throw new \RuntimeException('推广人钱包不存在(推广人会员ID:' . $agent_member_id . ')');
                                 $before = (string)$wallet->available_balance;
                                 $wallet->available_balance = bcadd($before, (string)$add_amount, 10);
                                 $wallet->save();

+ 31 - 0
database/migrations/2026_10_29_120000_expand_menu_icon_for_uploaded_images.php

@@ -0,0 +1,31 @@
+<?php
+
+use Illuminate\Database\Migrations\Migration;
+use Illuminate\Database\Schema\Blueprint;
+use Illuminate\Support\Facades\DB;
+use Illuminate\Support\Facades\Schema;
+
+return new class extends Migration {
+    public function up(): void
+    {
+        if (!Schema::hasTable('menus')) return;
+        if (!Schema::hasColumn('menus', 'icon')) {
+            Schema::table('menus', function (Blueprint $table) {
+                $table->string('icon', 500)->nullable()->comment('菜单图标图片地址');
+            });
+            return;
+        }
+
+        // SQLite has no VARCHAR length limit. Production uses MySQL; avoid a
+        // Doctrine dependency for this column-only widening.
+        if (DB::connection()->getDriverName() === 'mysql') {
+            $table = str_replace('`', '``', DB::getTablePrefix() . 'menus');
+            DB::statement("ALTER TABLE `{$table}` MODIFY COLUMN `icon` VARCHAR(500) NULL COMMENT '菜单图标图片地址'");
+        }
+    }
+
+    public function down(): void
+    {
+        // Keep uploaded paths intact instead of truncating them back to 50 characters.
+    }
+};

+ 33 - 0
docs/前端接口/菜单图标上传-前端接入.md

@@ -0,0 +1,33 @@
+# 菜单图标上传-前端接入
+
+沿用后台登录凭证,成功响应为 `code=0`。
+
+## 1. 上传图片
+
+`POST /admin/upload/uploadFile`
+
+使用 `multipart/form-data`,文件字段为 `image`。支持 JPG、JPEG、PNG、WebP,最大 10MB。上传成功后取 `data.path`。
+
+## 2. 保存菜单
+
+`POST /admin/menu/submit`
+
+把上传返回的 `data.path` 作为 `icon` 提交,其他菜单字段沿用:
+
+```json
+{"id":12,"parent_id":0,"title":"会员列表","uri":"/users","type":1,"status":1,"sort":0,"icon":"https://admin.example.com/storage/images/menu.png"}
+```
+
+- 新增菜单不传 `id`。
+- 不传 `icon` 保留原图;传 `""` 或 `null` 清除图标。图片地址最多 500 个字符。
+- `icon` 提交图片地址,不再提交 JS/CSS 图标 class。
+
+## 3. 显示菜单
+
+`GET /admin/menu/all` 返回的每级菜单(含 `children`)都有 `icon`,可直接作为图片 `src`。
+
+```json
+{"code":0,"data":[{"id":12,"title":"会员列表","icon":"https://admin.example.com/storage/images/menu.png","children":[]}]}
+```
+
+`/admin/menu/mineMenu` 同样返回图片地址。`icon` 为空时隐藏图片;原 class 图标需重新上传图片替换。

+ 148 - 0
tests/Integration/MenuIconTest.php

@@ -0,0 +1,148 @@
+<?php
+
+namespace Tests\Integration;
+
+use App\Http\Controllers\admin\Menu as MenuController;
+use App\Models\Menu;
+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 MenuIconTest extends TestCase
+{
+    private Application $app;
+
+    protected function setUp(): void
+    {
+        Facade::clearResolvedInstances();
+        require_once dirname(__DIR__, 2) . '/app/Helpers/helpers.php';
+        $this->app = new Application(dirname(__DIR__, 2));
+        $this->app->instance('config', new Repository(['app' => ['url' => 'https://admin.example.test', '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());
+        $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));
+        $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('menus', function (Blueprint $t) {
+            $t->id(); $t->unsignedBigInteger('parent_id')->default(0); $t->string('title');
+            $t->string('icon', 50)->nullable(); $t->string('uri'); $t->string('permission_name')->nullable();
+            $t->integer('sort')->default(0); $t->integer('status')->default(1); $t->integer('type')->default(1); $t->timestamps();
+        });
+        (require dirname(__DIR__, 2) . '/database/migrations/2026_10_29_120000_expand_menu_icon_for_uploaded_images.php')->up();
+        foreach (['roles' => ['name', 'display_name', 'description'], 'role_user' => ['user_id', 'role_id'], 'role_menu' => ['menu_id', 'role_id']] as $table => $fields) {
+            $db->schema()->create($table, function (Blueprint $t) use ($fields) {
+                $t->id();
+                foreach ($fields as $field) $t->string($field)->nullable();
+                $t->timestamps();
+            });
+        }
+        $this->request('/admin/menu/all');
+    }
+
+    protected function tearDown(): void
+    {
+        DB::disconnect();
+        Facade::clearResolvedInstances();
+        parent::tearDown();
+    }
+
+    private function request(string $path, array $params = [], string $method = 'GET'): void
+    {
+        $this->app->instance('request', Request::create($path, $method, $params));
+    }
+
+    private function save(array $params = []): array
+    {
+        $this->request('/admin/menu/submit', $params + ['title' => '报表', 'uri' => '/reports', 'parent_id' => 0, 'status' => 1, 'sort' => 0, 'type' => 1], 'POST');
+        return (new MenuController())->store()->getData(true);
+    }
+
+    private function allMenus(): array
+    {
+        $this->request('/admin/menu/all');
+        $body = (new MenuController())->all()->getData(true);
+        $this->assertSame(0, $body['code']);
+        return $body['data'];
+    }
+
+    public function test_uploaded_image_roundtrips_in_parent_and_child_menu_nodes(): void
+    {
+        $path = '/storage/images/' . str_repeat('12345678', 8) . '.png';
+        $url = 'https://admin.example.test' . $path;
+        $this->assertSame(0, $this->save(['icon' => $url])['code']);
+        $parent = (int) DB::table('menus')->value('id');
+        $this->assertSame($path, DB::table('menus')->where('id', $parent)->value('icon'));
+        $this->assertSame(0, $this->save(['title' => '子菜单', 'uri' => '/reports/daily', 'parent_id' => $parent, 'icon' => '/storage/images/child.webp'])['code']);
+        $tree = $this->allMenus();
+        $this->assertSame($url, $tree[0]['icon']);
+        $this->assertSame('https://admin.example.test/storage/images/child.webp', $tree[0]['children'][0]['icon']);
+        $this->assertSame('/reports/daily', $tree[0]['children'][0]['uri']);
+        $this->assertSame(0, $this->save(['id' => $parent, 'icon' => $tree[0]['icon']])['code']);
+        $this->assertSame($path, DB::table('menus')->where('id', $parent)->value('icon'));
+    }
+
+    public function test_icon_can_be_retained_replaced_with_cdn_url_and_cleared(): void
+    {
+        $this->assertSame(0, $this->save(['icon' => '/storage/images/menu.png'])['code']);
+        $id = (int) DB::table('menus')->value('id');
+        $this->assertSame(0, $this->save(['id' => $id, 'title' => '改名'])['code']);
+        $this->assertSame('https://admin.example.test/storage/images/menu.png', $this->allMenus()[0]['icon']);
+        $url = 'https://cdn.example.test/menu.png?version=2';
+        $this->assertSame(0, $this->save(['id' => $id, 'icon' => $url])['code']);
+        $this->assertSame($url, $this->allMenus()[0]['icon']);
+        foreach ([null, ''] as $empty) {
+            $this->assertSame(0, $this->save(['id' => $id, 'icon' => '/storage/images/menu.png'])['code']);
+            $this->assertSame(0, $this->save(['id' => $id, 'icon' => $empty])['code']);
+            $this->assertSame('', $this->allMenus()[0]['icon']);
+        }
+    }
+
+    public function test_class_names_invalid_values_and_oversized_icons_are_rejected_without_writes(): void
+    {
+        foreach (['ri-home-line', 'fa fa-home', 'javascript:alert(1)', '//other.example.test/icon.png', ['icon'], 'https://cdn.example.test/' . str_repeat('x', 500)] as $value) {
+            $this->assertNotSame(0, $this->save(['icon' => $value])['code']);
+            $this->assertSame(0, DB::table('menus')->count());
+        }
+    }
+
+    public function test_existing_class_icons_are_empty_and_role_menus_use_uploaded_images(): void
+    {
+        DB::table('menus')->insert(['id' => 1, 'parent_id' => 0, 'title' => '旧菜单', 'uri' => '/legacy', 'icon' => 'ri-home-line']);
+        $this->assertSame(0, $this->save(['icon' => '/storage/images/allowed.png'])['code']);
+        $id = (int) DB::table('menus')->where('uri', '/reports')->value('id');
+        $this->assertSame('', $this->allMenus()[0]['icon']);
+        $this->assertSame('ri-home-line', DB::table('menus')->where('id', 1)->value('icon'));
+        DB::table('roles')->insert(['id' => 2, 'name' => 'viewer']);
+        DB::table('role_user')->insert(['user_id' => 20, 'role_id' => 2]);
+        DB::table('role_menu')->insert(['menu_id' => $id, 'role_id' => 2]);
+        $this->request('/admin/menu/mineMenu');
+        request()->user = (object) ['id' => 20];
+        $body = (new MenuController())->mineMenu()->getData(true);
+        $this->assertSame(0, $body['code']);
+        $this->assertCount(1, $body['data']);
+        $this->assertSame($id, $body['data'][0]['id']);
+        $this->assertSame('https://admin.example.test/storage/images/allowed.png', $body['data'][0]['icon']);
+    }
+}

+ 44 - 1
tests/Integration/ShareCommissionRecordingTest.php

@@ -60,7 +60,7 @@ class ShareCommissionRecordingTest extends TestCase
             $t->integer('recharge_channel_group_id')->default(1); $t->timestamps();
         });
         $db->schema()->create('wallets', function (Blueprint $t) {
-            $t->id(); $t->string('member_id')->unique();
+            $t->id(); $t->unsignedBigInteger('user_id')->nullable(); $t->string('member_id')->unique();
             $t->decimal('available_balance', 24, 10)->default(0);
             $t->decimal('frozen_balance', 24, 10)->default(0); $t->timestamps();
         });
@@ -206,6 +206,49 @@ class ShareCommissionRecordingTest extends TestCase
         $this->assertEquals('22.50', DB::table('wallets')->where('member_id', '1001')->value('available_balance'));
     }
 
+    public function test_missing_promoter_wallet_identifies_the_expected_member_without_recreating_it(): void
+    {
+        $this->manualOrder();
+        DB::table('wallets')->where('member_id', '1001')->delete();
+
+        $response = $this->manualAudit();
+        $this->assertNotSame(0, $response['code']);
+        $this->assertSame('推广人钱包不存在(推广人会员ID:1001)', $response['msg']);
+        $this->assertSame(0, DB::table('wallets')->where('member_id', '1001')->count());
+        $this->assertSame(PaymentOrderService::STATUS_AUDIT, (int) DB::table('payment_orders')->value('status'));
+        $this->assertSame(0, (int) DB::table('payment_orders')->value('state'));
+        $this->assertEquals('100.00', DB::table('wallets')->where('member_id', '1002')->value('available_balance'));
+        $this->assertSame(0, BalanceLog::count());
+        $this->assertSame(0, ShareCommissionRecord::count());
+        $this->assertSame(0, DB::transactionLevel());
+    }
+
+    public function test_internal_user_id_does_not_replace_the_business_member_id_in_the_promoter_wallet(): void
+    {
+        $this->manualOrder();
+        DB::table('wallets')->where('member_id', '1001')->delete();
+        DB::table('wallets')->insert(['user_id' => 7, 'member_id' => '7', 'available_balance' => '0.00']);
+
+        $response = $this->manualAudit();
+        $this->assertNotSame(0, $response['code']);
+        $this->assertSame('推广人钱包不存在(推广人会员ID:1001)', $response['msg']);
+        $this->assertEquals('0.00', DB::table('wallets')->where('member_id', '7')->value('available_balance'));
+        $this->assertEquals('100.00', DB::table('wallets')->where('member_id', '1002')->value('available_balance'));
+        $this->assertSame(0, BalanceLog::count());
+        $this->assertSame(0, ShareCommissionRecord::count());
+
+        // Simulate correcting the manually inserted record, without creating another wallet.
+        DB::table('wallets')->where('user_id', 7)->update(['member_id' => '1001']);
+        $this->assertSame(0, $this->manualAudit()['code']);
+        $this->assertSame(2, DB::table('wallets')->count());
+        $this->assertEquals('200.00', DB::table('wallets')->where('member_id', '1002')->value('available_balance'));
+        $this->assertEquals('2.50', DB::table('wallets')->where('member_id', '1001')->value('available_balance'));
+        $this->assertSame(1, ShareCommissionRecord::count());
+        $this->assertNotSame(0, $this->manualAudit()['code']);
+        $this->assertEquals('2.50', DB::table('wallets')->where('member_id', '1001')->value('available_balance'));
+        $this->assertSame(2, BalanceLog::count());
+    }
+
     public static function unapprovedManualOrders(): array
     {
         return [