Jelajahi Sumber

活动多选

doge 2 hari lalu
induk
melakukan
55c15a1c3d

+ 12 - 5
app/Http/Controllers/admin/Banner.php

@@ -69,7 +69,7 @@ class Banner extends Controller
             $limit = request()->input('limit', 15);
             $type = $params['type'] ?? '';
             if (isset($params['platform']) && $params['platform'] !== '') {
-                // The existing mobile banner enum is "app"; H5 filtering preserves that storage contract.
+                // H5 uses the same banner type as APP.
                 $platformType = $params['platform'] === 'pc' ? 'pc' : 'app';
                 if ($type !== '' && $type !== $platformType) {
                     throw ValidationException::withMessages(['platform' => '平台与原类型筛选不一致']);
@@ -80,7 +80,7 @@ class Banner extends Controller
             $query = BannerModel::query();
 
             if ($type !== '') {
-                $query->where('type', $type);
+                $query->whereIn('type', [$type, BannerModel::TYPE_BOTH]);
             }
             if (isset($params['lang']) && $params['lang'] !== '') {
                 $query->where('lang', $params['lang']);
@@ -129,12 +129,18 @@ class Banner extends Controller
                 }],
                 'link_pc' => ['nullable', 'string', 'max:2048'],
                 'link_h5' => ['nullable', 'string', 'max:2048'],
-                'type' => ['required', 'in:pc,app'],
+                'type' => ['required_without:types', 'nullable', 'in:pc,app'],
+                'types' => ['sometimes', 'required', 'array', 'list', 'max:2'],
+                'types.*' => ['bail', 'required', 'string', 'in:pc,app', 'distinct:strict'],
                 'sort' => ['nullable', 'integer'],
                 'lang' => ['required'],
                 'status' => ['sometimes', 'integer', 'in:0,1'],
                 'manual_status' => ['nullable', 'integer', 'in:0,1'],
-            ], ['click_type.in' => '点击方式仅支持无链接、跳转活动、外站链接、站内相对路径']);
+            ], [
+                'click_type.in' => '点击方式仅支持无链接、跳转活动、外站链接、站内相对路径',
+                'types.required' => '请至少选择一个类型',
+                'types.*.in' => '类型仅支持 APP 和 PC',
+            ]);
             $id = $params['id'] ?? null;
             $banner = $id === null ? new BannerModel() : BannerModel::where('id', $id)->lockForUpdate()->first();
             if (!$banner) throw new Exception('数据不存在', HttpStatus::CUSTOM_ERROR);
@@ -152,6 +158,7 @@ class Banner extends Controller
                 // Both the row button and the editor write status; a stale compatibility alias must not override it.
                 $manualStatus = $params['status'] ?? $params['manual_status'] ?? $manualStatus;
             }
+            $types = $params['types'] ?? [$params['type']];
             $banner->fill([
                 'title' => $params['title'],
                 'image' => Util::replacePartInUrl($params['image']),
@@ -159,7 +166,7 @@ class Banner extends Controller
                 'link' => $links['link'],
                 'link_pc' => $links['link_pc'],
                 'link_h5' => $links['link_h5'],
-                'type' => $params['type'],
+                'type' => count($types) === 2 ? BannerModel::TYPE_BOTH : $types[0],
                 'sort' => array_key_exists('sort', $params) ? ($params['sort'] ?? 0) : ($banner->sort ?? 0),
                 'lang' => $params['lang'],
                 'status' => $manualStatus,

+ 13 - 1
app/Models/Banner.php

@@ -4,11 +4,23 @@ use App\Constants\Util;
 
 class Banner extends BaseModel
 {
+    public const TYPE_BOTH = 'both';
+
     protected $table = 'banner';
     protected $fillable = ['title', 'image', 'link' ,'type' ,'sort' ,'cny_rate','lang', 'status', 'click_type', 'link_pc', 'link_h5'];
 
     protected $casts = ['status' => 'integer'];
-    
+    protected $appends = ['types'];
+
+    protected function getTypesAttribute(): array
+    {
+        return match ($this->type) {
+            self::TYPE_BOTH => ['app', 'pc'],
+            'app', 'pc' => [$this->type],
+            default => [],
+        };
+    }
+
     protected function getImageAttribute($value)
     {
         return Util::ensureUrl($value);

+ 29 - 0
database/migrations/2026_11_11_150000_allow_banner_on_both_platforms.php

@@ -0,0 +1,29 @@
+<?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
+    {
+        Schema::table('banner', function (Blueprint $table) {
+            $table->enum('type', ['pc', 'app', 'both'])->default('pc')
+                ->comment('类型:pc端,app端,双端')->change();
+        });
+    }
+
+    public function down(): void
+    {
+        if (DB::table('banner')->where('type', 'both')->exists()) {
+            throw new RuntimeException('仍有同时发布到 APP 和 PC 的 Banner,无法还原单选字段');
+        }
+
+        Schema::table('banner', function (Blueprint $table) {
+            $table->enum('type', ['pc', 'app'])->default('pc')
+                ->comment('类型:pc端,app端')->change();
+        });
+    }
+};

+ 3 - 3
docs/前端接口/Banner/上下架与活动联动.md

@@ -59,7 +59,7 @@
 |---|---|---|---|
 | `page` | integer | 否 | 默认 `1`,最小 `1` |
 | `limit` | integer | 否 | 默认 `15`,范围 `1`~`200` |
-| `platform` | string | 否 | `pc` 或 `h5`;分别筛选旧 `type=pc`、`type=app` |
+| `platform` | string | 否 | `pc` 或 `h5`,分别匹配 `types` 含 `pc`、`app` 的记录 |
 | `status` | integer,`0/1` | 否 | 实际状态,`1` 上架、`0` 下架;不是历史保存的手动值 |
 | `lang` | string | 否 | 语言代码,例如 `zh`、`en`,最长 `10` 个字符;省略时查全部语言 |
 | `type` | string | 否 | 兼容参数,`pc` 或 `app`;与 `platform` 同传时必须对应一致 |
@@ -102,14 +102,14 @@
 
 `POST /admin/banner/update`
 
-沿用后台 Bearer Token。新增省略 `id`,编辑提交现有 Banner `id`。新增和编辑都必须提交完整基础字段 `title`、`image`、`type`、`lang`;未绑定活动的 Banner 只切换状态时可使用第 5 节接口。
+沿用后台 Bearer Token。新增省略 `id`,编辑提交现有 Banner `id`。新增和编辑都提交 `title`、`image`、`types`、`lang`。未绑定活动的 Banner 只切换状态时可使用第 5 节接口。
 
 | 参数 | 类型 | 必填 | 说明 |
 |---|---|---|---|
 | `id` | integer | 编辑必填 | Banner ID,最小 `1`;新增省略 |
 | `title` | 原表单标题 | 是 | 沿用现有标题字段 |
 | `image` | 原图片路径或 URL | 是 | 沿用现有上传与图片回显逻辑 |
-| `type` | string | 是 | `pc` 或 `app` |
+| `types` | string[] | 是 | 从 `app`、`pc` 中选一项或两项。兼容旧版单值 `type` |
 | `lang` | 原语种值 | 是 | 沿用现有选项,例如 `zh`、`en` |
 | `link` | string、integer 或 `null` | 否 | 最长 `191` 个字符;兼容数字 JSON 值。编辑省略保留原值,新增省略为 `null` |
 | `sort` | integer 或 `null` | 否 | 编辑省略保留原值;新增省略或显式传 `null` 为 `0` |

+ 7 - 6
docs/前端接口/Banner/列表筛选.md

@@ -1,6 +1,6 @@
 # Banner 列表筛选-前端接入说明
 
-适用于后台轮播图管理列表。接口为 `GET /admin/banner/index`,URL 相对后台 API 域名;请求头沿用 `Authorization: Bearer <后台登录令牌>`。新增平台、状态、语言筛选,列表返回结构和编辑接口保持原约定。
+适用于后台轮播图管理列表。接口为 `GET /admin/banner/index`,URL 相对后台 API 域名。请求头沿用 `Authorization: Bearer <后台登录令牌>`。
 
 ## 查询参数
 
@@ -19,12 +19,12 @@
 
 ## 平台与实际状态
 
-Banner 现有 `type` 取值保留为 `pc/app`。`platform` 是列表筛选别名,映射如下,不改写记录中的原 `type`:
+列表展示和表单回显使用 `types` 数组,详见 [类型多选](类型多选-前端接入.md)。
 
-| 平台控件 | 查询参数 | 匹配的原字段 |
+| 平台控件 | 查询参数 | 匹配记录 |
 |---|---|---|
-| PC | `platform=pc` | `type=pc` |
-| H5 | `platform=h5` | `type=app` |
+| PC | `platform=pc` | `types` 含 `pc` |
+| H5 | `platform=h5` | `types` 含 `app` |
 | 全部 | 省略 `platform`,也不传旧 `type` | 不限制 |
 
 若同时传 `platform`、`type`,必须对应一致:`pc + pc`、`h5 + app` 合法;`pc + app` 或 `h5 + pc` 返回参数错误。不要将 `h5` 写入旧 `type` 参数或把原记录的 `type=app` 改写为 `h5`。
@@ -67,6 +67,7 @@ Banner 现有 `type` 取值保留为 `pc/app`。`platform` 是列表筛选别名
         "image": "https://example.com/uploads/banner-40.jpg",
         "link": "123",
         "type": "app",
+        "types": ["app"],
         "sort": 10,
         "lang": "en",
         "status": 0,
@@ -97,7 +98,7 @@ Banner 现有 `type` 取值保留为 `pc/app`。`platform` 是列表筛选别名
 ```
 
 - 不传筛选项时,返回全部平台、状态和语言;只改变分页不丢失已有筛选项。
-- `platform=h5` 返回原 `type=app`;与旧 `type=app` 同传合法,与 `type=pc` 同传报错。
+- `platform=h5` 返回 `types` 含 `app` 的记录。与 `type=app` 同传合法,与 `type=pc` 同传报错。
 - 明确传 `status=0`,确认手动下架的未绑定 Banner 和活动联动下架的绑定 Banner 均可查到。
 - 同时选择平台、状态、语言时,记录和 `total` 均满足全部条件;翻页不会出现另一种语言或状态。
 

+ 24 - 0
docs/前端接口/Banner/类型多选-前端接入.md

@@ -0,0 +1,24 @@
+# Banner 类型多选
+
+新增和编辑的「类型」使用多选,只提供 APP(`app`)和 PC(`pc`)。至少选一项,也可同时选两项。
+
+## 保存
+
+`POST /admin/banner/update` 的 `types` 提交字符串数组。新增省略 `id`,编辑带上当前 `id`。
+
+```json
+{
+  "id": 40,
+  "title": "首页 Banner",
+  "image": "/uploads/banner.png",
+  "lang": "zh",
+  "types": ["app", "pc"]
+}
+```
+
+只发布到 APP 时传 `["app"]`,只发布到 PC 时传 `["pc"]`。保存成功为 `code=0`。
+
+## 列表与回显
+
+- `GET /admin/banner/index` 从 `data.data[].types` 读取已选项。双端记录返回 `["app", "pc"]`,列表显示为「APP、PC」。
+- 筛选 `platform=pc` 或 `platform=h5` 时,均包含双端记录。APP 对应现有 H5 筛选项。

+ 38 - 0
tests/Integration/BannerPublicationTest.php

@@ -577,6 +577,44 @@ class BannerPublicationTest extends TestCase
         $this->assertSame([1, 0, 1], array_column($page['data'], 'effective_status'));
     }
 
+    public function test_banner_can_be_saved_once_for_both_platforms_and_edited_back_to_one(): void
+    {
+        $migration = require dirname(__DIR__, 2) . '/database/migrations/2026_11_11_150000_allow_banner_on_both_platforms.php';
+        $app = $this->banner(['type' => 'app']);
+        $pc = $this->banner();
+        $migration->up();
+
+        $this->request('update', [
+            'title' => '双端轮播图', 'image' => '/banner.png', 'lang' => 'zh', 'types' => ['pc', 'app'],
+        ]);
+        $this->assertSame(HttpStatus::OK, (new AdminBanner())->update()->getData(true)['code']);
+        $both = (int) DB::table('banner')->max('id');
+
+        $this->assertSame([$both, $pc, $app], array_column($this->listing()['data'], 'id'));
+        foreach ([['pc', 'pc', $pc], ['h5', 'app', $app]] as [$platform, $type, $single]) {
+            foreach ([['platform' => $platform], ['type' => $type]] as $filter) {
+                $page = $this->listing($filter);
+                $this->assertSame(2, $page['total']);
+                $this->assertSame([$both, $single], array_column($page['data'], 'id'));
+                $this->assertSame([['app', 'pc'], [$type]], array_column($page['data'], 'types'));
+            }
+        }
+
+        foreach ([[], ['pc', 'pc'], ['app', 'h5'], ['both'], ['pc', null], ['key' => 'pc'], 'pc'] as $invalidTypes) {
+            $this->assertSame(HttpStatus::VALIDATION_FAILED, $this->save(['id' => $both, 'types' => $invalidTypes])['code']);
+        }
+        $this->assertSame(['app', 'pc'], $this->listing()['data'][0]['types']);
+
+        $this->request('update', [
+            'id' => $both, 'title' => 'APP 轮播图', 'image' => '/banner.png', 'lang' => 'zh', 'types' => ['app'],
+        ]);
+        $this->assertSame(HttpStatus::OK, (new AdminBanner())->update()->getData(true)['code']);
+        $this->assertSame([$pc], array_column($this->listing(['type' => 'pc'])['data'], 'id'));
+        $this->assertSame([$both, $app], array_column($this->listing(['type' => 'app'])['data'], 'id'));
+        $migration->down();
+        $this->assertSame(3, $this->listing()['total']);
+    }
+
     public function test_platform_language_and_actual_status_filters_combine_without_mutating_data(): void
     {
         $now = time();

+ 7 - 2
tests/Regression/banner_site_publication.php

@@ -4,11 +4,13 @@ namespace app\api\lists {
     // Isolate list infrastructure; run the real list implementation and ThinkPHP ORM below.
     class BaseApiDataLists {
         protected string $lang = 'zh';
+        protected array $params = [];
         protected array $searchWhere = [];
         public int $limitOffset = 0;
         public int $limitLength = 2;
         public function __construct(array $params = []) { $this->setParams($params); }
         public function setParams($params): self {
+            $this->params = $params;
             $this->lang = $params['lang'] ?? 'zh';
             $this->searchWhere = isset($params['type']) ? [['type', '=', $params['type']]] : [];
             return $this;
@@ -27,8 +29,10 @@ namespace {
     $loader = require $repo . '/vendor/autoload.php';
     $loader->setPsr4('app\\', [$stage . '/app/', $repo . '/app/']);
     require $repo . '/app/common.php';
+    if (!function_exists('request')) require $repo . '/vendor/topthink/framework/src/helper.php';
     $app = new \think\App($repo . '/');
     \think\Container::setInstance($app);
+    $app->instance('request', new \app\Request());
     $db = new \think\DbManager();
     $db->setConfig(['default' => 'isolated', 'connections' => ['isolated' => [
         'type' => 'sqlite', 'database' => ':memory:', 'prefix' => '', 'fields_strict' => false, 'debug' => false,
@@ -39,7 +43,8 @@ namespace {
     $db->event('before_select', static function () use (&$selectCount) { ++$selectCount; });
     $GLOBALS['banner_test_now'] = 1792800000;
     $now = $GLOBALS['banner_test_now'];
-    $db->execute('CREATE TABLE bot_banner (id INTEGER PRIMARY KEY,title TEXT,image TEXT,link TEXT,type TEXT,sort INTEGER,lang TEXT,status INTEGER DEFAULT 1)');
+    $db->execute('CREATE TABLE bot_banner (id INTEGER PRIMARY KEY,title TEXT,image TEXT,link TEXT,type TEXT,sort INTEGER,lang TEXT,status INTEGER DEFAULT 1,
+        click_type TEXT,link_pc TEXT,link_h5 TEXT)');
     $db->execute('CREATE TABLE bot_activity_rewards (id INTEGER PRIMARY KEY,status INTEGER,start_time INTEGER,end_time INTEGER)');
     foreach ([
         [101, 1, $now - 100, $now + 100],
@@ -97,7 +102,7 @@ namespace {
     check(array_column($list->lists(), 'id'), [16], 'Archived manual-off cannot hide a banner bound to an active activity');
     $list->limitOffset = 0;
     $item = $list->lists()[0];
-    check(array_keys($item), ['id', 'type', 'image', 'link', 'title'], 'Public fields remain compatible');
+    check(array_keys($item), ['id', 'type', 'image', 'link', 'title', 'click_type'], 'Public fields remain compatible');
     check($item['image'], 'https://bot.example.test/banner.png', 'Existing image host replacement is preserved');
     check($item['link'], '101', 'Public jump target preserved');
     check(\think\facade\Db::table('bot_banner')->order('id')->select()->toArray(), $before, 'Reads do not mutate manual status or other original fields');