doge 11 цаг өмнө
parent
commit
b2447cd647

+ 2 - 0
app/Http/Controllers/Controller.php

@@ -90,6 +90,8 @@ class Controller extends BaseController
     public function __construct()
     {
         $lang = request()->input('lang', 'zh');
+        // Invalid list-filter input must reach the action validator, not crash locale setup first.
+        if (!is_string($lang) || $lang === '') $lang = 'zh';
         App::setLocale($lang);
         $this->lang = $lang;
     }

+ 23 - 1
app/Http/Controllers/admin/ActivityReward.php

@@ -21,11 +21,33 @@ class ActivityReward extends Controller
                 'limit' => ['required', 'integer', 'min:1'],
                 'title' => ['nullable', 'string'],
                 'type' => ['nullable', 'string'],
+                'platform' => ['nullable', 'string', 'in:pc,h5'],
+                'status' => ['nullable', 'integer', 'in:0,1'],
+                'lang' => ['nullable', 'string', 'max:10'],
             ]);
             $page = request()->input('page', 1);
             $limit = request()->input('limit', 10);
-            $where = ActivityRewardService::getWhere($params);
+            $where = ActivityRewardService::getWhere(array_intersect_key($params, ['title' => true, 'type' => true]));
             $query = ActivityRewardModel::where($where);
+            if (isset($params['status']) && $params['status'] !== '') {
+                $query->where('status', (int)$params['status']);
+            }
+            if (isset($params['lang']) && $params['lang'] !== '') {
+                $query->where('lang', $params['lang']);
+            }
+            if (isset($params['platform']) && $params['platform'] !== '') {
+                $platform = strtoupper($params['platform']);
+                $query->where(function ($platformQuery) use ($platform) {
+                    $platformQuery->where('type', 2)
+                        ->orWhereNull('publish_platforms')
+                        ->orWhereJsonLength('publish_platforms', 0);
+                    if ($platformQuery->getModel()->getConnection()->getDriverName() === 'sqlite') {
+                        $platformQuery->orWhereRaw('EXISTS (SELECT 1 FROM json_each(publish_platforms) WHERE json_each.value = ?)', [$platform]);
+                    } else {
+                        $platformQuery->orWhereJsonContains('publish_platforms', $platform);
+                    }
+                });
+            }
             $count = $query->count();
             $list = $query->orderByDesc('sort')->orderByDesc('id')
                 ->forPage($page, $limit)->get()->toArray();

+ 46 - 13
app/Http/Controllers/admin/Banner.php

@@ -28,23 +28,45 @@ class Banner extends Controller
     function index()
     {
         try {
-            request()->validate([
+            $params = request()->validate([
                 'page' => ['sometimes', 'integer', 'min:1'],
                 'limit' => ['sometimes', 'integer', 'min:1', 'max:200'],
                 'type' => ['nullable', 'in:pc,app'],
+                'platform' => ['nullable', 'in:pc,h5'],
+                'status' => ['nullable', 'integer', 'in:0,1'],
+                'lang' => ['nullable', 'string', 'max:10'],
             ]);
             $page = request()->input('page', 1);
             $limit = request()->input('limit', 15);
-            $type = request()->input('type', '');
-            
+            $type = $params['type'] ?? '';
+            if (isset($params['platform']) && $params['platform'] !== '') {
+                // The existing mobile banner enum is "app"; H5 filtering preserves that storage contract.
+                $platformType = $params['platform'] === 'pc' ? 'pc' : 'app';
+                if ($type !== '' && $type !== $platformType) {
+                    throw ValidationException::withMessages(['platform' => '平台与原类型筛选不一致']);
+                }
+                $type = $platformType;
+            }
+
             $query = BannerModel::query();
 
-            if (!empty($type)) {
+            if ($type !== '') {
                 $query->where('type', $type);
             }
-            $count = $query->count();
-            $list = BannerVisibilityService::decorate($query->forPage($page, $limit)
-                ->orderByDesc('sort')->orderByDesc('id')->get());
+            if (isset($params['lang']) && $params['lang'] !== '') {
+                $query->where('lang', $params['lang']);
+            }
+            $query->orderByDesc('sort')->orderByDesc('id');
+            if (isset($params['status']) && $params['status'] !== '') {
+                // Stored manual status is not the linked activity's actual status. Filter before pagination.
+                $rows = BannerVisibilityService::decorate($query->get());
+                $rows = array_values(array_filter($rows, static fn ($row) => $row['status'] === (int) $params['status']));
+                $count = count($rows);
+                $list = array_slice($rows, ((int) $page - 1) * (int) $limit, (int) $limit);
+            } else {
+                $count = $query->count();
+                $list = BannerVisibilityService::decorate($query->forPage($page, $limit)->get());
+            }
         } catch (ValidationException $e) {
             return $this->error(HttpStatus::VALIDATION_FAILED, $e->validator->errors()->first());
         } catch (Exception $e) {
@@ -79,6 +101,7 @@ class Banner extends Controller
                 'sort' => ['nullable', 'integer'],
                 'lang' => ['required'],
                 'status' => ['sometimes', 'integer', 'in:0,1'],
+                'manual_status' => ['nullable', 'integer', 'in:0,1'],
             ]);
             $id = $params['id'] ?? null;
             $banner = $id === null ? new BannerModel() : BannerModel::where('id', $id)->lockForUpdate()->first();
@@ -92,6 +115,10 @@ class Banner extends Controller
                     throw ValidationException::withMessages(['link' => '绑定的活动不存在']);
                 }
             }
+            $manualStatus = $banner->status ?? 1;
+            if ($activityId === null) {
+                $manualStatus = $params['manual_status'] ?? $params['status'] ?? $manualStatus;
+            }
             $banner->fill([
                 'title' => $params['title'],
                 'image' => Util::replacePartInUrl($params['image']),
@@ -99,7 +126,7 @@ class Banner extends Controller
                 'type' => $params['type'],
                 'sort' => array_key_exists('sort', $params) ? ($params['sort'] ?? 0) : ($banner->sort ?? 0),
                 'lang' => $params['lang'],
-                'status' => $params['status'] ?? ($banner->status ?? 1),
+                'status' => $manualStatus,
             ])->save();
             DB::commit();
         } catch (ValidationException $e) {
@@ -118,12 +145,18 @@ class Banner extends Controller
         try {
             $params = request()->validate([
                 'id' => ['required', 'integer', 'min:1'],
-                'status' => ['required', 'integer', 'in:0,1'],
+                'status' => ['required_without:manual_status', 'integer', 'in:0,1'],
+                'manual_status' => ['required_without:status', 'integer', 'in:0,1'],
             ]);
-            $banner = BannerModel::find($params['id']);
-            if (!$banner) throw new Exception('数据不存在', HttpStatus::CUSTOM_ERROR);
-            $banner->status = $params['status'];
-            $banner->save();
+            DB::transaction(function () use ($params) {
+                $banner = BannerModel::where('id', $params['id'])->lockForUpdate()->first();
+                if (!$banner) throw new Exception('数据不存在', HttpStatus::CUSTOM_ERROR);
+                if (BannerVisibilityService::activityId($banner->link) !== null) {
+                    throw new Exception('关联活动的 Banner 由活动状态自动控制,不能手动上下架', HttpStatus::CUSTOM_ERROR);
+                }
+                $banner->status = $params['manual_status'] ?? $params['status'];
+                $banner->save();
+            });
         } catch (ValidationException $e) {
             return $this->error(HttpStatus::VALIDATION_FAILED, $e->validator->errors()->first());
         } catch (Exception $e) {

+ 7 - 4
app/Services/BannerVisibilityService.php

@@ -19,7 +19,7 @@ class BannerVisibilityService
         return $id === '' ? null : $id;
     }
 
-    /** Compute display state without overwriting the operator's manual setting. */
+    /** Bound banners follow the activity; only unbound banners use the stored manual setting. */
     public static function decorate(iterable $banners, ?int $now = null): array
     {
         $now = $now ?? time();
@@ -29,6 +29,8 @@ class BannerVisibilityService
             $item = $banner instanceof Banner ? $banner->toArray() : $banner;
             $item['status'] = (int) $item['status'];
             $item['activity_id'] = self::activityId($item['link'] ?? null);
+            $item['can_manual_toggle'] = $item['activity_id'] === null;
+            $item['manual_status'] = $item['can_manual_toggle'] ? $item['status'] : null;
             if ($item['activity_id'] !== null) {
                 $activityIds[$item['activity_id']] = $item['activity_id'];
             }
@@ -43,9 +45,7 @@ class BannerVisibilityService
         foreach ($items as &$item) {
             $activity = $item['activity_id'] === null ? null : $activities->get($item['activity_id']);
             $reason = null;
-            if ($item['status'] !== 1) {
-                $reason = 'manual';
-            } elseif ($item['activity_id'] !== null) {
+            if ($item['activity_id'] !== null) {
                 if ($activity === null) {
                     $reason = 'activity_missing';
                 } elseif ((int) $activity->status !== 1) {
@@ -55,8 +55,11 @@ class BannerVisibilityService
                 } elseif ((int) $activity->end_time <= $now) {
                     $reason = 'activity_ended';
                 }
+            } elseif ($item['manual_status'] !== 1) {
+                $reason = 'manual';
             }
             $item['effective_status'] = $reason === null ? 1 : 0;
+            $item['status'] = $item['effective_status'];
             $item['offline_reason'] = $reason;
         }
         unset($item);

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

@@ -2,35 +2,37 @@
 
 适用页面:后台 `/admin/banner/index` 轮播图管理,以及会员端首页 Banner。后台「跳转 ID」继续使用 `link` 绑定 `/admin/ActivityReward` 列表中的活动 `id`,不新增提交字段 `activity_id`。本文用于本次接口变更接入,不表示已在生产环境部署。
 
-后台增加 Banner 手动上下架开关;绑定活动的 Banner 还会随活动状态、开始和结束时间实时决定是否展示。活动结束后,会员接口自动排除对应 Banner;后台仍保留记录并返回下架原因。
+未绑定活动的 Banner 可以手动上下架;绑定活动的 Banner 仅由活动状态、开始和结束时间决定是否展示,不允许手动切换。活动结束后,会员接口自动排除对应 Banner;后台仍保留记录并返回下架原因。
 
 ## 1. 两种状态与显示规则
 
 | 返回字段 | 类型 | 含义与前端用途 |
 |---|---|---|
-| `status` | integer,`0/1` | Banner 的手动开关。`1` 手动上架,`0` 手动下架;编辑表单和行内开关绑定此字段 |
+| `status` | integer,`0/1` | 当前实际展示状态。`1` 上架中,`0` 已下架;后台状态标签读取此字段 |
+| `manual_status` | integer,`0/1` 或 `null` | 未绑定活动时为手动状态:`1` 允许展示,`0` 手动下架;绑定活动时为 `null` |
+| `can_manual_toggle` | boolean | 是否允许手动切换。未绑定活动为 `true`,绑定活动为 `false`;控制列表及编辑表单中的手动控件是否可用 |
 | `activity_id` | string 或 `null` | 从 `link` 识别出的活动 ID,只读;数字字符串会去掉前导零,例如 `"000123"` 对应 `"123"` |
-| `effective_status` | integer,`0/1` | 当前实际展示状态。`1` 可展示,`0` 已下架;后台状态标签使用此字段 |
+| `effective_status` | integer,`0/1` | 兼容保留的实际状态字段,始终与 `status` 相同;新接入统一读取 `status` |
 | `offline_reason` | string 或 `null` | 实际下架原因;可展示时为 `null`,取值见下表 |
 
 这些新增状态字段仅由后台列表返回。会员列表保持原字段,由服务端过滤后直接展示。
 
-下架原因按以下顺序判断,命中第一项即返回:
+未绑定活动时只判断手动状态。绑定活动时依次检查活动是否存在、是否上架、是否开始、是否结束,命中第一项即返回:
 
 | `offline_reason` | 条件 | 建议文案 |
 |---|---|---|
-| `manual` | Banner `status=0` | 手动下架 |
+| `manual` | 未绑定活动,且 `manual_status=0` | 手动下架 |
 | `activity_missing` | 绑定的活动已不存在 | 关联活动不存在 |
 | `activity_offline` | 关联活动未上架,活动 `status` 不为 `1` | 关联活动已下架 |
 | `activity_not_started` | 关联活动开始时间晚于服务器当前时间 | 关联活动未开始 |
 | `activity_ended` | 关联活动结束时间小于或等于服务器当前时间 | 关联活动已结束 |
 | `null` | 以上条件均未命中 | 上架中 |
 
-绑定活动的 Banner 只有在手动开关为 `1`、活动存在且已上架、并满足 `start_time <= 服务器当前时间 < end_time` 时才展示。无活动绑定的 Banner 只由手动开关决定。
+绑定活动的 Banner 在活动存在、已上架且满足 `start_time <= 服务器当前时间 < end_time` 时展示;历史保存过的手动状态不参与判断。未绑定活动的 Banner 只由手动状态决定。
 
-**后台建议同时展示「手动开关」和「实际状态 / 原因」。** 例如 `status=1`、`effective_status=0`、`offline_reason="activity_ended"` 应显示开关开启、实际状态“关联活动已结束”。不要将实际下架结果写回手动开关。手动开启也不能强制展示已结束、未开始、已下架或已删除活动的 Banner。
+**实际状态标签只按 `status` 显示,手动控件先判断 `can_manual_toggle`。** 为 `true` 时按 `manual_status` 回显,控件可命名为「手动允许展示」;为 `false` 时隐藏或禁用列表和编辑表单中的手动控件,仅显示实际状态及原因。例如活动已结束时,返回 `status=0`、`effective_status=0`、`manual_status=null`、`can_manual_toggle=false`、`offline_reason="activity_ended"`,应显示“已下架 / 关联活动已结束”,不能再显示“上架中”。
 
-活动重新上架、延期或调整开始时间后,手动开关仍为 `1` 的 Banner 会在条件满足时自然恢复;手动开关为 `0` 的 Banner 不会自动恢复。前端以重新请求后的结果更新状态,不自行比较活动日期。
+活动重新上架、延期或调整开始时间后,绑定的 Banner 会在活动条件满足时自然恢复。前端以重新请求后的结果更新状态,不自行比较活动日期,也不把实际状态写入手动配置。
 
 ## 2. 跳转 ID / 链接约定
 
@@ -45,7 +47,7 @@
 
 只有正整数数字串会识别为活动 ID;后台列表的 `activity_id` 可用于显示关联活动。新增 Banner 或更换到另一个活动 ID 时,该活动必须存在,但允许绑定尚未开始、已经结束或已经下架的活动;保存成功不代表当前可展示。
 
-已有绑定活动被删除时,允许保持原 `link` 编辑 Banner 或手动下架;实际状态显示 `activity_missing`。清除绑定时明确提交 `link:null`,省略 `link` 则保留原值。
+已有绑定活动被删除时,仍属于已绑定 Banner:实际状态显示 `activity_missing`,`can_manual_toggle=false`,不能手动上下架;允许保持原 `link` 修改标题、图片等。清除绑定时明确提交 `link:null`,省略 `link` 则保留原值。解除绑定后,Banner 恢复手动控制,可同时提交希望保存的 `manual_status`。
 
 ## 3. 后台列表
 
@@ -57,9 +59,14 @@
 |---|---|---|---|
 | `page` | integer | 否 | 默认 `1`,最小 `1` |
 | `limit` | integer | 否 | 默认 `15`,范围 `1`~`200` |
-| `type` | string | 否 | `pc` 或 `app`;省略或留空时不按类型筛选 |
+| `platform` | string | 否 | `pc` 或 `h5`;分别筛选旧 `type=pc`、`type=app` |
+| `status` | integer,`0/1` | 否 | 实际状态,`1` 上架、`0` 下架;不是历史保存的手动值 |
+| `lang` | string | 否 | 语言代码,例如 `zh`、`en`,最长 `10` 个字符;省略时查全部语言 |
+| `type` | string | 否 | 兼容参数,`pc` 或 `app`;与 `platform` 同传时必须对应一致 |
 
-列表保留原有字段和分页结构,并增加第 1 节的四个字段。后台同时返回实际上架、手动下架和活动联动下架的 Banner;不新增 `status` 筛选参数。排序仍为 `sort` 倒序,再按 `id` 倒序。
+筛选项省略、空字符串或 `null` 表示不限制该项;`status=0` 必须保留。各条件按 AND 组合,先筛选再统计及分页,`total` 是筛选后的总数。平台映射、组合示例和前端查询规则详见 [Banner 列表筛选](列表筛选.md)。
+
+列表保留分页结构及其他原有字段,状态含义按第 1 节处理:`status` 为实际状态,`manual_status` 仅在未绑定活动时可编辑,`can_manual_toggle` 决定手动控件是否可用。不传状态时同时返回实际上架、手动下架和活动联动下架的 Banner。排序仍为 `sort` 倒序,再按 `id` 倒序。
 
 下例展示一条因活动结束而下架的记录,原有其他字段按实际接口保留:
 
@@ -79,7 +86,9 @@
         "type": "pc",
         "sort": 10,
         "lang": "zh",
-        "status": 1,
+        "status": 0,
+        "manual_status": null,
+        "can_manual_toggle": false,
         "activity_id": "123",
         "effective_status": 0,
         "offline_reason": "activity_ended"
@@ -93,7 +102,7 @@
 
 `POST /admin/banner/update`
 
-沿用后台 Bearer Token。新增省略 `id`,编辑提交现有 Banner `id`。新增和编辑都必须提交完整基础字段 `title`、`image`、`type`、`lang`;只切换状态可使用第 5 节接口。
+沿用后台 Bearer Token。新增省略 `id`,编辑提交现有 Banner `id`。新增和编辑都必须提交完整基础字段 `title`、`image`、`type`、`lang`;未绑定活动的 Banner 只切换状态时可使用第 5 节接口。
 
 | 参数 | 类型 | 必填 | 说明 |
 |---|---|---|---|
@@ -104,9 +113,14 @@
 | `lang` | 原语种值 | 是 | 沿用现有选项,例如 `zh`、`en` |
 | `link` | string、integer 或 `null` | 否 | 最长 `191` 个字符;兼容数字 JSON 值。编辑省略保留原值,新增省略为 `null` |
 | `sort` | integer 或 `null` | 否 | 编辑省略保留原值;新增省略或显式传 `null` 为 `0` |
-| `status` | integer,`0/1` | 否 | 手动开关;编辑省略保留原值,新增省略为 `1` |
+| `manual_status` | integer,`0/1` 或 `null` | 否 | 未绑定活动时推荐提交的手动状态,`null` 按省略处理;绑定活动时不应用、不保存该参数 |
+| `status` | integer,`0/1` | 否 | 未绑定活动时兼容旧客户端的手动写入别名;绑定活动时不应用、不保存该参数;含义与 GET 返回的实际状态不同 |
+
+未绑定活动时,编辑表单从 `manual_status` 回显并优先提交此字段。两种手动输入同时有效时,以 `manual_status` 为准;`manual_status:null` 按省略处理。两者都省略时(含 `manual_status:null` 且省略 `status`),编辑保留原手动状态,新增默认为 `1`;非法值按参数错误处理。不要单独把 GET 返回的实际 `status` 当成手动状态回传。
+
+保存后的 `link` 仍绑定活动时,允许正常修改标题、图片、排序和链接等;旧客户端随表单带回 `manual_status` 或 `status` 时,这两个手动参数均被忽略,不改变活动联动状态。`manual_status:null` 可用于兼容列表记录回填。前端按 `can_manual_toggle=false` 隐藏或禁用手动控件,不主动提交手动值。
 
-不要将 `activity_id`、`effective_status`、`offline_reason` 作为保存参数。旧 Banner 的手动状态默认是 `1`,绑定活动的旧记录同样遵循实时展示规则。
+不要将 `activity_id`、`effective_status`、`offline_reason`、`can_manual_toggle` 作为保存参数。
 
 新增并绑定活动:
 
@@ -117,8 +131,7 @@
   "type": "pc",
   "lang": "zh",
   "link": "123",
-  "sort": 10,
-  "status": 1
+  "sort": 10
 }
 ```
 
@@ -133,7 +146,7 @@
   "lang": "zh",
   "link": null,
   "sort": 10,
-  "status": 0
+  "manual_status": 0
 }
 ```
 
@@ -152,32 +165,43 @@
 
 `POST /admin/banner/status`
 
-沿用后台 Bearer Token,无需重新提交标题、图片等表单字段。
+沿用后台 Bearer Token,无需重新提交标题、图片等表单字段。**仅允许未绑定活动且 `can_manual_toggle=true` 的 Banner 调用。** 绑定活动的 Banner(包括活动已删除的记录)调用返回 `code=-3`,提示关联活动的 Banner 不能手动上下架;应通过关联活动的状态和时间管理展示。
 
 | 参数 | 类型 | 必填 | 说明 |
 |---|---|---|---|
 | `id` | integer | 是 | Banner ID,最小 `1` |
-| `status` | integer,`0/1` | 是 | `0` 手动下架,`1` 手动上架 |
+| `manual_status` | integer,`0/1` | 与 `status` 至少传一个 | 推荐参数,`0` 手动下架,`1` 手动允许展示 |
+| `status` | integer,`0/1` | 与 `manual_status` 至少传一个 | 兼容旧客户端的手动写入别名;同时提交时以 `manual_status` 为准 |
 
 下架请求:
 
 ```json
 {
-  "id": 40,
-  "status": 0
+  "id": 41,
+  "manual_status": 0
 }
 ```
 
-上架请求:
+手动允许展示请求:
 
 ```json
 {
-  "id": 40,
-  "status": 1
+  "id": 41,
+  "manual_status": 1
+}
+```
+
+对未绑定活动的 Banner,兼容请求同时提供两个参数时,以 `manual_status` 为准。例如以下请求将未绑定活动的 Banner 设为手动允许展示:
+
+```json
+{
+  "id": 41,
+  "status": 0,
+  "manual_status": 1
 }
 ```
 
-成功响应与保存接口相同:`code=0`、`data=[]`。提交期间禁用该行开关;失败恢复原值并展示 `msg`,成功后刷新列表。若重新请求后实际仍下架,展示 `offline_reason` 对应文案,不将开关错误回滚为 `0`。
+成功响应与保存接口相同:`code=0`、`data=[]`。提交期间禁用该行手动控件;失败恢复原值并展示 `msg`,成功后刷新列表。实际状态标签读取 `status`,手动控件根据 `can_manual_toggle` 决定是否可用,再读取 `manual_status`。本接口不接受 `manual_status:null`;两种参数均未提交,或任一已提交参数不为合法 `0/1`,均按参数错误处理。
 
 ## 6. 会员端 Banner
 
@@ -224,9 +248,10 @@
 
 | `code` | 场景 | 前端处理 |
 |---|---|---|
-| `101009` | `status` 非 `0/1`、ID 或分页格式不合法、基础字段缺失、`type` 不合法、`link` 超长等 | 展示 `msg` 并保留表单 |
+| `101009` | 手动参数值不合法(编辑接口允许 `manual_status:null`,状态切换接口不允许)、状态接口两者均缺失、ID 或分页格式不合法、基础字段缺失、`type` 不合法、`link` 超长等 | 展示 `msg` 并保留表单 |
 | `101009` | 新增或更换绑定的活动不存在 | 提示重新选择活动;保持已存在的旧绑定不触发此校验 |
 | `-3` | 编辑或切换状态时 Banner 不存在 | 展示“数据不存在”,刷新列表 |
+| `-3` | 对已绑定活动的 Banner 调用状态切换接口,包括活动已删除的记录 | 展示 `msg`,刷新列表并隐藏或禁用手动控件;通过活动配置控制展示 |
 
 活动绑定校验失败示例:
 
@@ -255,7 +280,10 @@ Banner 不存在示例:
 - 无活动绑定的 Banner 手动下架后从会员列表消失,手动上架后恢复。
 - 已上架且处于有效时间内的活动对应 Banner 可展示;达到结束时间即不再返回。
 - 活动未开始、下架或删除时,后台仍显示 Banner 和对应原因,会员列表不返回。
-- 关联活动已结束时手动上架 Banner:接口成功,手动开关为开启,实际状态仍为“关联活动已结束”。
-- 手动下架后延长活动时间,Banner 仍保持下架;手动开关开启时延长到有效时间可恢复展示。
+- 关联活动已结束时,列表返回 `manual_status=null`、`can_manual_toggle=false`、`status=0`、`effective_status=0`、`offline_reason="activity_ended"`;只显示下架状态和结束原因,不再同时显示“上架中”。
+- 对绑定活动的 Banner 调用状态切换接口应失败;完整编辑标题或图片时,即使旧客户端附带手动字段,也不改变活动联动规则。
+- 延长已结束活动到有效时间或重新上架活动后,绑定 Banner 按活动条件恢复;历史手动值为 `0` 也不阻止恢复。
 - 下架记录不占会员分页名额,`count` 与可展示记录一致;后台总数仍包含下架记录。
-- 编辑仅修改标题 / 图片并省略 `link`、`sort`、`status`,原关联、排序和手动状态保留。
+- 编辑仅修改标题 / 图片并省略 `link`、`sort`、`manual_status` 和兼容参数 `status`,原关联、排序保留;未绑定活动时也保留原手动状态。
+- 未绑定活动时,编辑或切换接口同时提交 `manual_status=1`、`status=0`,以 `manual_status` 为准。
+- 清除活动绑定并提交 `manual_status=0` 后,列表返回 `can_manual_toggle=true`、`manual_status=0`、`status=0`,手动控件重新可用。

+ 104 - 0
docs/前端接口/Banner/列表筛选.md

@@ -0,0 +1,104 @@
+# Banner 列表筛选-前端接入说明
+
+适用于后台轮播图管理列表。接口为 `GET /admin/banner/index`,URL 相对后台 API 域名;请求头沿用 `Authorization: Bearer <后台登录令牌>`。新增平台、状态、语言筛选,列表返回结构和编辑接口保持原约定。
+
+## 查询参数
+
+| 参数 | 类型 | 必填 | 说明 |
+|---|---|---|---|
+| `page` | integer | 否 | 默认 `1`,最小 `1` |
+| `limit` | integer | 否 | 默认 `15`,范围 `1`~`200` |
+| `platform` | string | 否 | 小写 `pc` 或 `h5`,对应平台选项 PC、H5 |
+| `status` | integer,`0/1` | 否 | `1` 实际上架、`0` 实际下架;按返回的实际 `status` 筛选 |
+| `lang` | string | 否 | 现有语言代码,例如 `zh`、`en`,最长 `10` 个字符;不是参数名 `language` |
+| `type` | string | 否 | 兼容旧筛选参数,仍为 `pc` 或 `app`;新平台控件优先只提交 `platform` |
+
+可选筛选项省略、传空字符串或 `null` 时,表示该项“全部”。语言不传时查全部语言,不自动限制为中文。`status=0` 是有效筛选值,不能作为空值丢弃。
+
+所有已提交条件按 AND 组合;服务端先筛选,再计算 `total` 和分页。修改任一筛选项后,前端将 `page` 重置为 `1`;切换为“全部”时直接省略该项。
+
+## 平台与实际状态
+
+Banner 现有 `type` 取值保留为 `pc/app`。`platform` 是列表筛选别名,映射如下,不改写记录中的原 `type`:
+
+| 平台控件 | 查询参数 | 匹配的原字段 |
+|---|---|---|
+| PC | `platform=pc` | `type=pc` |
+| H5 | `platform=h5` | `type=app` |
+| 全部 | 省略 `platform`,也不传旧 `type` | 不限制 |
+
+若同时传 `platform`、`type`,必须对应一致:`pc + pc`、`h5 + app` 合法;`pc + app` 或 `h5 + pc` 返回参数错误。不要将 `h5` 写入旧 `type` 参数或把原记录的 `type=app` 改写为 `h5`。
+
+状态筛选使用**当前实际状态**:绑定活动的 Banner 仅随活动存在性、上架状态及 `start_time <= 当前服务器时间 < end_time` 判断;未绑定活动的 Banner 使用手动状态。活动已结束、尚未开始、已下架或已删除时,绑定 Banner 会匹配 `status=0`,不看历史保存的手动值。
+
+状态标签使用返回的 `status`;`effective_status` 与其相同。手动控件仅在 `can_manual_toggle=true` 时可用,并读取 `manual_status`。绑定活动时为 `can_manual_toggle=false`、`manual_status=null`,不能手动切换。完整规则见 [上下架与活动联动](上下架与活动联动.md)。
+
+## 组合请求与返回
+
+查询 H5、实际下架、英文的 Banner:
+
+`GET /admin/banner/index?page=1&limit=15&platform=h5&status=0&lang=en`
+
+以下 JSON 仅表达 GET 查询参数,不能作为 POST 请求体:
+
+```json
+{
+  "page": 1,
+  "limit": 15,
+  "platform": "h5",
+  "status": 0,
+  "lang": "en"
+}
+```
+
+成功 `code=0`,总数取 `data.total`,本页记录取 `data.data`。下例保留状态相关字段,其他原有字段按实际接口读取;排序仍为 `sort` 倒序、`id` 倒序。
+
+```json
+{
+  "code": 0,
+  "timestamp": 1792800000,
+  "msg": "OK",
+  "data": {
+    "total": 1,
+    "data": [
+      {
+        "id": 40,
+        "title": "Autumn promotion",
+        "image": "https://example.com/uploads/banner-40.jpg",
+        "link": "123",
+        "type": "app",
+        "sort": 10,
+        "lang": "en",
+        "status": 0,
+        "effective_status": 0,
+        "manual_status": null,
+        "can_manual_toggle": false,
+        "activity_id": "123",
+        "offline_reason": "activity_ended"
+      }
+    ]
+  }
+}
+```
+
+没有匹配结果时为 `data.total=0`、`data.data=[]`,显示空列表。不要在收到本页数据后自行过滤并修改总数。
+
+## 错误与联调
+
+非法平台、状态、超长语言、非法分页,或 `platform/type` 冲突时返回 `code=101009`;展示实际 `msg`,保留筛选值供修改。不要仅用 HTTP 状态判断成功。
+
+```json
+{
+  "code": 101009,
+  "timestamp": 1792800000,
+  "msg": "参数验证失败:具体原因以接口返回为准",
+  "data": []
+}
+```
+
+- 不传筛选项时,返回全部平台、状态和语言;只改变分页不丢失已有筛选项。
+- `platform=h5` 返回原 `type=app`;与旧 `type=app` 同传合法,与 `type=pc` 同传报错。
+- 明确传 `status=0`,确认手动下架的未绑定 Banner 和活动联动下架的绑定 Banner 均可查到。
+- 同时选择平台、状态、语言时,记录和 `total` 均满足全部条件;翻页不会出现另一种语言或状态。
+
+活动列表也有相同名称的筛选项,但状态及平台匹配规则不同,见 [活动列表筛选](../活动/列表筛选.md)。

+ 115 - 0
docs/前端接口/活动/列表筛选.md

@@ -0,0 +1,115 @@
+# 活动列表筛选-前端接入说明
+
+适用于后台活动管理列表。接口为 `GET /admin/ActivityReward`,URL 相对后台 API 域名;请求头沿用 `Authorization: Bearer <后台登录令牌>`。新增平台、状态、语言筛选,原活动类型、标题筛选及返回字段继续保留。
+
+## 查询参数
+
+| 参数 | 类型 | 必填 | 说明 |
+|---|---|---|---|
+| `page` | integer | 是 | 页码,最小 `1` |
+| `limit` | integer | 是 | 每页数量,最小 `1`;沿用现有分页传值 |
+| `platform` | string | 否 | 小写 `pc` 或 `h5`,对应发布平台 PC、H5 |
+| `status` | integer,`0/1` | 否 | 活动已保存的配置状态,`1` 上架、`0` 下架;与返回的活动 `status` 含义相同 |
+| `lang` | string | 否 | 现有语言代码,例如 `zh`、`en`,最长 `10` 个字符;参数名为 `lang` |
+| `title` | string | 否 | 原标题模糊筛选 |
+| `type` | 原活动类型 | 否 | 原业务类型 `1`~`6`;`2` 为足球首页,不能用作平台筛选 |
+
+可选筛选项省略、空字符串或 `null` 表示该项“全部”。语言不传时查全部语言。`page`、`limit` 仍必须提交。`status=0` 是有效值,不要因判断为假而从请求中移除。
+
+各条件按 AND 组合,先筛选再统计及分页;`data.total` 是全部匹配记录数,`data.data` 是当前页。修改筛选条件时将页码重置为 `1`;选择“全部”时省略该筛选参数。语言使用 `lang`,平台使用 `platform`,不要提交 `language` 或用活动 `type` 代替平台。
+
+## 平台与状态的含义
+
+平台匹配活动的**发布平台** `publish_platforms`,不使用会员注册平台 `register_platforms`:
+
+| 活动配置 | `platform=pc` | `platform=h5` |
+|---|---|---|
+| `publish_platforms` 为 `null` 或 `[]` | 匹配 | 匹配 |
+| 包含 `PC` | 匹配 | 仅在同时包含 `H5` 时匹配 |
+| 包含 `H5` | 仅在同时包含 `PC` 时匹配 | 匹配 |
+| 仅 `APP` | 不匹配 | 不匹配 |
+| `type=2` 足球首页 | 匹配,不受该活动发布平台配置限制 | 匹配,不受该活动发布平台配置限制 |
+
+足球首页的豁免只作用于平台匹配;语言、状态、标题和业务类型等其他筛选仍按正常条件组合。不把仅发布 APP 的普通活动视为 H5,也不修改活动原平台配置。
+
+**这里的状态是活动保存的上架 / 下架配置,不是按日期计算的新状态。** 活动 `status=1` 即匹配上架筛选,即使其尚未开始或已经结束。日期字段保持原值,前端不要把“已结束”自动改成 `status=0`,也不要在收到列表后自行移除这些记录。
+
+Banner 列表使用实际展示状态,已结束活动对应的 Banner 会匹配下架;因此同一活动在活动列表为 `status=1`、对应 Banner 为 `status=0` 可以同时成立。两处筛选含义分别见本页和 [Banner 列表筛选](../Banner/列表筛选.md)。
+
+## 组合请求与返回
+
+查询 H5 发布平台、配置为上架、中文的活动:
+
+`GET /admin/ActivityReward?page=1&limit=20&platform=h5&status=1&lang=zh`
+
+以下 JSON 仅表达 GET 查询参数:
+
+```json
+{
+  "page": 1,
+  "limit": 20,
+  "platform": "h5",
+  "status": 1,
+  "lang": "zh"
+}
+```
+
+可继续组合原筛选,例如追加 `&type=3&title=充值`,只查询符合全部条件的业务类型 `3`、标题包含“充值”的活动;`type` 不改变平台参数的含义。
+
+成功 `code=0`,响应保持 `data.total/data.data`。下例仅列出部分原有活动字段:第一条即使结束日期已过,仍可匹配配置状态 `1`;第二条为足球首页,发布平台仅 `APP` 也符合其平台豁免规则。排序仍为 `sort` 倒序、`id` 倒序。
+
+```json
+{
+  "code": 0,
+  "timestamp": 1792800000,
+  "msg": "OK",
+  "data": {
+    "total": 2,
+    "data": [
+      {
+        "id": 123,
+        "title": "秋季充值活动",
+        "type": 3,
+        "status": 1,
+        "lang": "zh",
+        "publish_platforms": ["H5"],
+        "start_time": "2026-10-01",
+        "end_time": "2026-10-10",
+        "sort": 10
+      },
+      {
+        "id": 122,
+        "title": "足球首页",
+        "type": 2,
+        "status": 1,
+        "lang": "zh",
+        "publish_platforms": ["APP"],
+        "start_time": "2026-10-01",
+        "end_time": "2026-10-31",
+        "sort": 5
+      }
+    ]
+  }
+}
+```
+
+无匹配结果时返回 `data.total=0`、`data.data=[]`。活动资格、预算、份数及表单保存字段继续按 [资格条件与展示开关](资格条件与展示开关.md) 接入。
+
+## 错误与联调
+
+参数错误使用活动接口现有业务码 `code=-3`,例如缺少分页参数、平台不是 `pc/h5`、状态不是 `0/1`、语言超过 `10` 个字符。展示实际 `msg`,不要套用 Banner 的 `101009` 或仅判断 HTTP 状态。
+
+```json
+{
+  "code": -3,
+  "timestamp": 1792800000,
+  "msg": "具体参数错误以接口返回为准",
+  "data": []
+}
+```
+
+- 不传平台、状态、语言时查全部,保留原分页、标题与业务类型筛选。
+- 普通活动 `publish_platforms=null/[]` 在 PC、H5 筛选中均可见;仅 APP 的普通活动两边均不可见。
+- `type=2` 足球首页在 PC、H5 筛选中都可见,其他语言或状态条件仍需匹配。
+- `status=0` 可正确查询配置为下架的活动;`status=1` 不因日期结束而自动排除活动。
+- 同时选择平台、状态、语言时,记录及 `total` 满足全部条件;变更筛选后回到第一页。

+ 123 - 0
tests/Integration/ActivityRewardEligibilityTest.php

@@ -198,6 +198,129 @@ class ActivityRewardEligibilityTest extends TestCase
         return $result['data'];
     }
 
+    private function adminList(array $params = []): array
+    {
+        $this->request('/admin/ActivityReward', $params + ['page' => 1, 'limit' => 10]);
+        $result = (new AdminActivityReward())->index()->getData(true);
+        $this->assertSame(0, $result['code'], $result['msg']);
+        return $result['data'];
+    }
+
+    public function test_admin_activity_list_combines_filters_before_count_and_pagination(): void
+    {
+        $base = ['title' => '联合筛选活动', 'status' => 0, 'lang' => 'zh', 'type' => 1,
+            'publish_platforms' => ['PC'], 'show_ineligible' => false];
+        $low = $this->activity(['sort' => 1, 'params' => ['marker' => 'original']] + $base);
+        $middle = $this->activity(['sort' => 2, 'start_time' => '2090-01-01 00:00:00'] + $base);
+        $high = $this->activity(['sort' => 3, 'end_time' => '2001-01-01 00:00:00'] + $base);
+        foreach ([['publish_platforms' => ['H5']], ['status' => 1], ['lang' => 'en'],
+            ['title' => '其他标题'], ['type' => 3], ['type' => 2]] as $override) {
+            $this->activity($override + ['sort' => 99] + $base);
+        }
+        $filters = ['platform' => 'pc', 'status' => '0', 'lang' => 'zh', 'title' => '联合筛选', 'type' => '1', 'limit' => 2];
+        $first = $this->adminList($filters);
+        $this->assertSame(3, $first['total']);
+        $this->assertSame([$high->id, $middle->id], array_column($first['data'], 'id'));
+        $this->assertSame([0, 0], array_map('intval', array_column($first['data'], 'status')));
+        $this->assertSame('2001-01-01', $first['data'][0]['end_time']);
+        $this->assertSame('2090-01-01', $first['data'][1]['start_time']);
+        $second = $this->adminList(['page' => 2] + $filters);
+        $this->assertSame(3, $second['total']);
+        $this->assertSame([$low->id], array_column($second['data'], 'id'));
+        $this->assertSame(['marker' => 'original'], $second['data'][0]['params']);
+    }
+
+    public function test_admin_activity_platform_filters_include_unrestricted_and_football_rows(): void
+    {
+        $rows = [];
+        foreach (['pc' => ['PC'], 'h5' => ['H5'], 'both' => ['PC', 'H5'], 'app' => ['APP'], 'empty' => []] as $key => $platforms) {
+            $rows[$key] = $this->legacyActivity(['title' => $key, 'publish_platforms' => json_encode($platforms)]);
+        }
+        $rows['null'] = $this->legacyActivity(['title' => 'legacy null', 'publish_platforms' => null]);
+        $rows['football'] = $this->legacyActivity(['title' => '任意足球标题', 'type' => 2, 'publish_platforms' => '["APP"]']);
+        foreach (['pc' => ['pc', 'both', 'empty', 'null', 'football'], 'h5' => ['h5', 'both', 'empty', 'null', 'football']] as $platform => $keys) {
+            $result = $this->adminList(['platform' => $platform]);
+            $expected = array_map(static fn($key) => $rows[$key]->id, $keys);
+            rsort($expected);
+            $this->assertSame(5, $result['total']);
+            $this->assertSame($expected, array_column($result['data'], 'id'));
+            $this->assertNotContains($rows['app']->id, array_column($result['data'], 'id'));
+            $football = array_column($result['data'], null, 'id')[$rows['football']->id];
+            $this->assertSame(['APP'], $football['publish_platforms']);
+        }
+        $this->assertSame(7, $this->adminList()['total']);
+    }
+
+    public function test_admin_activity_empty_filters_and_zero_status_use_saved_status_without_time_hiding(): void
+    {
+        $expired = $this->legacyActivity(['title' => '已结束但仍上架', 'status' => 1, 'end_time' => '2001-01-01 00:00:00']);
+        $future = $this->legacyActivity(['title' => '未开始但已上架', 'status' => 1, 'start_time' => '2090-01-01 00:00:00']);
+        $down = $this->legacyActivity(['title' => '已手动下架', 'status' => 0]);
+        $up = $this->adminList(['status' => 1]);
+        $this->assertSame(2, $up['total']);
+        $this->assertSame([$future->id, $expired->id], array_column($up['data'], 'id'));
+        $this->assertSame([1, 1], array_map('intval', array_column($up['data'], 'status')));
+        foreach ([0, '0'] as $status) {
+            $result = $this->adminList(['status' => $status]);
+            $this->assertSame(1, $result['total']);
+            $this->assertSame([$down->id], array_column($result['data'], 'id'));
+        }
+        foreach ([[], ['platform' => '', 'status' => '', 'lang' => ''], ['platform' => null, 'status' => null, 'lang' => null]] as $filters) {
+            $this->assertSame(3, $this->adminList($filters)['total']);
+        }
+    }
+
+    public function test_admin_activity_invalid_list_filters_return_validation_errors_without_writes(): void
+    {
+        $this->legacyActivity(['title' => '校验前活动', 'status' => 0]);
+        $before = DB::table('activity_rewards')->orderBy('id')->get()->toArray();
+        foreach ([['platform' => 'app'], ['platform' => 'PC'], ['platform' => []], ['status' => 2],
+            ['status' => -1], ['status' => 'up'], ['status' => []], ['lang' => str_repeat('a', 11)],
+            ['lang' => []], ['page' => 0], ['limit' => 0]] as $invalid) {
+            $this->request('/admin/ActivityReward', $invalid + ['page' => 1, 'limit' => 10]);
+            DB::connection()->enableQueryLog();
+            DB::connection()->flushQueryLog();
+            $result = (new AdminActivityReward())->index()->getData(true);
+            $queries = DB::connection()->getQueryLog();
+            DB::connection()->disableQueryLog();
+            $this->assertSame(HttpStatus::CUSTOM_ERROR, $result['code'], json_encode($invalid));
+            $this->assertSame([], $queries, 'Invalid filters must be rejected before querying or mutating activities.');
+        }
+        foreach ([['page' => 1], ['limit' => 10]] as $incomplete) {
+            $this->request('/admin/ActivityReward', $incomplete);
+            $this->assertSame(HttpStatus::CUSTOM_ERROR, (new AdminActivityReward())->index()->getData(true)['code']);
+        }
+        $this->assertEquals($before, DB::table('activity_rewards')->orderBy('id')->get()->toArray());
+    }
+
+    public function test_admin_platform_filter_compiles_mysql_json_queries_without_connecting_to_a_database(): void
+    {
+        $this->app['config']->set('database.connections.mysql_filter_preview', [
+            'driver' => 'mysql', 'host' => '127.0.0.1', 'database' => 'not_connected',
+            'username' => 'not_connected', 'password' => '', 'prefix' => 'bot_',
+        ]);
+        $original = DB::getDefaultConnection();
+        DB::setDefaultConnection('mysql_filter_preview');
+        try {
+            $connection = DB::connection();
+            $connection->setPdo(static function () { throw new \RuntimeException('This SQL preview must never connect to a database.'); });
+            $this->request('/admin/ActivityReward', ['page' => 1, 'limit' => 10, 'platform' => 'h5', 'status' => '0', 'lang' => 'en']);
+            $queries = $connection->pretend(function () {
+                $result = (new AdminActivityReward())->index()->getData(true);
+                $this->assertSame(0, $result['code'], $result['msg']);
+            });
+            $sql = strtolower(implode("\n", array_column($queries, 'query')));
+            $this->assertStringContainsString('json_contains', $sql);
+            $this->assertStringContainsString('json_length', $sql);
+            $this->assertStringNotContainsString('json_each', $sql);
+            $this->assertContains('"H5"', $queries[0]['bindings']);
+            $this->assertContains('en', $queries[0]['bindings']);
+        } finally {
+            DB::disconnect('mysql_filter_preview');
+            DB::setDefaultConnection($original);
+        }
+    }
+
     public function test_old_forms_and_old_database_rows_keep_unrestricted_defaults(): void
     {
         $activity = $this->activity();

+ 320 - 33
tests/Integration/BannerPublicationTest.php

@@ -128,12 +128,14 @@ class BannerPublicationTest extends TestCase
         return collect(BannerVisibilityService::decorate(Banner::query()->orderBy('id')->get(), $now))->keyBy('id')->all();
     }
 
-    private function assertState(array $row, int $manual, int $effective, ?string $reason, ?string $activityId): void
+    private function assertState(array $row, ?int $manual, int $effective, ?string $reason, ?string $activityId): void
     {
-        $this->assertSame($manual, $row['status']);
+        $this->assertSame($manual, $row['manual_status']);
+        $this->assertSame($effective, $row['status']);
         $this->assertSame($effective, $row['effective_status']);
         $this->assertSame($reason, $row['offline_reason']);
         $this->assertSame($activityId, $row['activity_id']);
+        $this->assertSame($activityId === null, $row['can_manual_toggle']);
     }
 
     public function test_migration_publishes_existing_banners_by_default_and_rolls_back_only_its_column(): void
@@ -178,17 +180,44 @@ class BannerPublicationTest extends TestCase
         $this->assertSame('https://banner.example.test/banner.png', $banner->image);
     }
 
-    public function test_status_endpoint_accepts_repeated_zero_and_one_without_changing_banner_content(): void
+    public function test_status_endpoint_accepts_both_aliases_and_repeated_values_without_changing_banner_content(): void
     {
         $id = $this->banner(['sort' => 19, 'lang' => 'en', 'link' => 'https://example.test']);
-        foreach ([0, 0, 1, 1, '0', '1'] as $status) {
-            $result = $this->status(['id' => $id, 'status' => $status]);
+        foreach (['status', 'manual_status'] as $field) {
+            foreach ([0, 0, 1, 1, '0', '1'] as $status) {
+                $result = $this->status(['id' => $id, $field => $status]);
+                $this->assertSame(HttpStatus::OK, $result['code'], $result['msg']);
+                $row = DB::table('banner')->find($id);
+                $this->assertSame((int) $status, (int) $row->status);
+                $this->assertSame('https://example.test', $row->link);
+                $this->assertSame(19, (int) $row->sort);
+                $this->assertSame('en', $row->lang);
+            }
+        }
+    }
+
+    public function test_manual_status_takes_precedence_in_full_forms_and_status_requests(): void
+    {
+        foreach ([
+            [['manual_status' => 0], 0],
+            [['status' => 1], 1],
+            [['manual_status' => null], 1],
+            [['status' => 1, 'manual_status' => null], 1],
+            [['status' => 0, 'manual_status' => 1], 1],
+            [['status' => 1, 'manual_status' => 0], 0],
+        ] as [$fields, $expected]) {
+            $result = $this->save($fields);
             $this->assertSame(HttpStatus::OK, $result['code'], $result['msg']);
-            $row = DB::table('banner')->find($id);
-            $this->assertSame((int) $status, (int) $row->status);
-            $this->assertSame('https://example.test', $row->link);
-            $this->assertSame(19, (int) $row->sort);
-            $this->assertSame('en', $row->lang);
+            $id = DB::table('banner')->latest('id')->value('id');
+            $this->assertSame($expected, (int) DB::table('banner')->where('id', $id)->value('status'));
+        }
+        foreach ([[0, 1], [1, 0]] as [$legacy, $manual]) {
+            $result = $this->status(['id' => $id, 'status' => $legacy, 'manual_status' => $manual]);
+            $this->assertSame(HttpStatus::OK, $result['code'], $result['msg']);
+            $this->assertSame($manual, (int) DB::table('banner')->where('id', $id)->value('status'));
+            $result = $this->save(['id' => $id, 'title' => '兼容完整编辑', 'status' => $legacy, 'manual_status' => $manual]);
+            $this->assertSame(HttpStatus::OK, $result['code'], $result['msg']);
+            $this->assertSame($manual, (int) DB::table('banner')->where('id', $id)->value('status'));
         }
     }
 
@@ -198,13 +227,18 @@ class BannerPublicationTest extends TestCase
         $this->assertSame(HttpStatus::OK, $result['code'], $result['msg']);
         $id = DB::table('banner')->value('id');
         $this->assertSame(0, (int) DB::table('banner')->where('id', $id)->value('status'));
-        foreach ([null, -1, 2, 'invalid'] as $status) {
-            $result = $this->save(['id' => $id, 'title' => '不应保存', 'status' => $status]);
-            $this->assertSame(HttpStatus::VALIDATION_FAILED, $result['code']);
-            $row = DB::table('banner')->find($id);
-            $this->assertSame(0, (int) $row->status);
-            $this->assertSame('轮播图', $row->title);
+        foreach (['status', 'manual_status'] as $field) {
+            foreach ($field === 'status' ? [null, -1, 2, 'invalid'] : [-1, 2, 'invalid'] as $status) {
+                $result = $this->save(['id' => $id, 'title' => '不应保存', $field => $status]);
+                $this->assertSame(HttpStatus::VALIDATION_FAILED, $result['code']);
+                $row = DB::table('banner')->find($id);
+                $this->assertSame(0, (int) $row->status);
+                $this->assertSame('轮播图', $row->title);
+            }
         }
+        $result = $this->save(['id' => $id, 'manual_status' => null, 'title' => '空值保持手动设置']);
+        $this->assertSame(HttpStatus::OK, $result['code'], $result['msg']);
+        $this->assertSame(0, (int) DB::table('banner')->where('id', $id)->value('status'));
         $this->assertSame(1, DB::table('banner')->count());
     }
 
@@ -214,7 +248,9 @@ class BannerPublicationTest extends TestCase
         foreach ([[], ['id' => $id], ['status' => 0], ['id' => $id, 'status' => -1],
             ['id' => $id, 'status' => 2], ['id' => $id, 'status' => 'invalid'],
             ['id' => $id, 'status' => null], ['id' => 0, 'status' => 0],
-            ['id' => 'invalid', 'status' => 0]] as $params) {
+            ['id' => 'invalid', 'status' => 0], ['manual_status' => 0],
+            ['id' => $id, 'manual_status' => null], ['id' => $id, 'manual_status' => -1],
+            ['id' => $id, 'manual_status' => 2], ['id' => $id, 'manual_status' => 'invalid']] as $params) {
             $this->assertSame(HttpStatus::VALIDATION_FAILED, $this->status($params)['code']);
             $this->assertSame(1, (int) DB::table('banner')->where('id', $id)->value('status'));
         }
@@ -244,7 +280,7 @@ class BannerPublicationTest extends TestCase
         $expected[$id] = ['activity_missing', '999'];
         $rows = $this->decorated($now);
         foreach ($expected as $id => [$reason, $activityId]) {
-            $this->assertState($rows[$id], 1, $reason === null ? 1 : 0, $reason, $activityId);
+            $this->assertState($rows[$id], null, $reason === null ? 1 : 0, $reason, $activityId);
             $this->assertSame(1, (int) DB::table('banner')->where('id', $id)->value('status'));
         }
     }
@@ -268,27 +304,134 @@ class BannerPublicationTest extends TestCase
         $this->assertCount(1, $queries, 'Unbound banners should not query activities.');
     }
 
-    public function test_manual_on_cannot_override_ended_activity_and_reopening_respects_manual_off(): void
+    public function test_bound_banners_ignore_archived_manual_status_across_activity_states(): void
     {
         $now = time();
         $this->activity(7, $now - 100, $now - 10);
-        $on = $this->banner(['link' => '7', 'status' => 0]);
+        $on = $this->banner(['link' => '7', 'status' => 1]);
         $off = $this->banner(['link' => '7', 'status' => 0]);
-        $this->assertSame(HttpStatus::OK, $this->status(['id' => $on, 'status' => 1])['code']);
         $rows = $this->decorated($now);
-        $this->assertState($rows[$on], 1, 0, 'activity_ended', '7');
-        $this->assertState($rows[$off], 0, 0, 'manual', '7');
+        $this->assertState($rows[$on], null, 0, 'activity_ended', '7');
+        $this->assertState($rows[$off], null, 0, 'activity_ended', '7');
         DB::table('activity_rewards')->where('id', 7)->update(['end_time' => $now + 100]);
         $rows = $this->decorated($now);
-        $this->assertState($rows[$on], 1, 1, null, '7');
-        $this->assertState($rows[$off], 0, 0, 'manual', '7');
+        $this->assertState($rows[$on], null, 1, null, '7');
+        $this->assertState($rows[$off], null, 1, null, '7');
         DB::table('activity_rewards')->where('id', 7)->update(['status' => 0]);
-        $this->assertState($this->decorated($now)[$on], 1, 0, 'activity_offline', '7');
+        $this->assertState($this->decorated($now)[$on], null, 0, 'activity_offline', '7');
         DB::table('activity_rewards')->where('id', 7)->update(['status' => 1]);
-        $this->assertState($this->decorated($now)[$on], 1, 1, null, '7');
+        $this->assertState($this->decorated($now)[$on], null, 1, null, '7');
+        $this->assertSame(1, (int) DB::table('banner')->where('id', $on)->value('status'));
+        $this->assertSame(0, (int) DB::table('banner')->where('id', $off)->value('status'));
+    }
+
+    public function test_get_expired_banner_reports_actual_off_and_full_row_edit_preserves_archive_for_reopening(): void
+    {
+        $now = time();
+        $this->activity(7, $now - 120, $now - 60);
+        $on = $this->banner(['link' => '7', 'status' => 1, 'sort' => 17, 'lang' => 'en']);
+        $off = $this->banner(['link' => '7', 'status' => 0]);
+        $manualOff = $this->banner(['link' => 'https://example.test', 'status' => 0]);
+        $page = $this->listing();
+        $rows = collect($page['data'])->keyBy('id')->all();
+        $this->assertSame(3, $page['total']);
+        $this->assertState($rows[$on], null, 0, 'activity_ended', '7');
+        $this->assertState($rows[$off], null, 0, 'activity_ended', '7');
+        $this->assertState($rows[$manualOff], 0, 0, 'manual', null);
+        $this->assertSame(1, (int) DB::table('banner')->where('id', $on)->value('status'));
+
+        // Resubmitted activity-derived status and null manual_status do not alter the archived database flag.
+        $edited = $rows[$on];
+        $edited['title'] = '过期活动轮播图编辑';
+        $result = $this->save($edited);
+        $this->assertSame(HttpStatus::OK, $result['code'], $result['msg']);
+        $stored = DB::table('banner')->find($on);
+        $this->assertSame(1, (int) $stored->status);
+        $this->assertSame('过期活动轮播图编辑', $stored->title);
+        $this->assertSame('7', $stored->link);
+        $this->assertSame(17, (int) $stored->sort);
+        $this->assertSame('en', $stored->lang);
+        $this->assertSame('/banner.png', $stored->image);
+        $rows = collect($this->listing()['data'])->keyBy('id')->all();
+        $this->assertState($rows[$on], null, 0, 'activity_ended', '7');
+
+        DB::table('activity_rewards')->where('id', 7)->update(['end_time' => time() + 600]);
+        $rows = collect($this->listing()['data'])->keyBy('id')->all();
+        $this->assertState($rows[$on], null, 1, null, '7');
+        $this->assertState($rows[$off], null, 1, null, '7');
+        $this->assertState($rows[$manualOff], 0, 0, 'manual', null);
+        $this->assertSame(1, (int) DB::table('banner')->where('id', $on)->value('status'));
         $this->assertSame(0, (int) DB::table('banner')->where('id', $off)->value('status'));
     }
 
+    public function test_bound_banners_reject_both_manual_toggle_aliases_without_database_mutation(): void
+    {
+        $now = time();
+        $this->activity(7, $now - 120, $now + 600);
+        $this->activity(8, $now - 120, $now - 60);
+        foreach ([['7', 0], ['8', 1]] as [$link, $archivedStatus]) {
+            $id = $this->banner(['link' => $link, 'status' => $archivedStatus]);
+            $before = (array) DB::table('banner')->find($id);
+            foreach (['status', 'manual_status'] as $field) {
+                foreach ([0, 1] as $value) {
+                    $result = $this->status(['id' => $id, $field => $value]);
+                    $this->assertSame(HttpStatus::CUSTOM_ERROR, $result['code']);
+                    $this->assertStringContainsString('关联活动', $result['msg']);
+                    $this->assertStringContainsString('不能手动上下架', $result['msg']);
+                    $this->assertSame($before, (array) DB::table('banner')->find($id));
+                }
+            }
+            foreach ([['status' => null], ['manual_status' => null], ['status' => 'invalid'], ['manual_status' => []]] as $fields) {
+                $result = $this->status(['id' => $id] + $fields);
+                $this->assertSame(HttpStatus::VALIDATION_FAILED, $result['code']);
+                $this->assertSame($before, (array) DB::table('banner')->find($id));
+            }
+        }
+    }
+
+    public function test_bound_full_forms_ignore_status_inputs_and_new_bound_rows_keep_database_default_on(): void
+    {
+        $now = time();
+        $this->activity(7, $now - 120, $now + 600);
+        $id = $this->banner(['link' => '7', 'status' => 0]);
+        foreach ([['status' => 1], ['manual_status' => 0], ['status' => 0, 'manual_status' => 1],
+            ['status' => 0, 'manual_status' => null]] as $fields) {
+            $result = $this->save($fields + ['id' => $id, 'title' => '活动控制上下架']);
+            $this->assertSame(HttpStatus::OK, $result['code'], $result['msg']);
+            $this->assertSame(0, (int) DB::table('banner')->where('id', $id)->value('status'));
+            $this->assertState($this->decorated($now)[$id], null, 1, null, '7');
+        }
+        foreach ([['status' => 'invalid'], ['manual_status' => []], ['status' => null]] as $fields) {
+            $result = $this->save($fields + ['id' => $id, 'title' => '不应保存']);
+            $this->assertSame(HttpStatus::VALIDATION_FAILED, $result['code']);
+            $this->assertSame(0, (int) DB::table('banner')->where('id', $id)->value('status'));
+            $this->assertSame('活动控制上下架', DB::table('banner')->where('id', $id)->value('title'));
+        }
+        $result = $this->save(['link' => '7', 'status' => 0, 'manual_status' => 0]);
+        $this->assertSame(HttpStatus::OK, $result['code'], $result['msg']);
+        $newId = DB::table('banner')->latest('id')->value('id');
+        $this->assertSame(1, (int) DB::table('banner')->where('id', $newId)->value('status'));
+        $this->assertState($this->decorated($now)[$newId], null, 1, null, '7');
+    }
+
+    public function test_clearing_activity_link_restores_manual_control_and_rebinding_ignores_manual_off_input(): void
+    {
+        $now = time();
+        $this->activity(7, $now - 120, $now + 600);
+        $id = $this->banner(['link' => '7', 'status' => 0]);
+        $this->assertState($this->decorated($now)[$id], null, 1, null, '7');
+        $result = $this->save(['id' => $id, 'link' => null]);
+        $this->assertSame(HttpStatus::OK, $result['code'], $result['msg']);
+        $this->assertState($this->decorated($now)[$id], 0, 0, 'manual', null);
+        $result = $this->status(['id' => $id, 'manual_status' => 1]);
+        $this->assertSame(HttpStatus::OK, $result['code'], $result['msg']);
+        $this->assertState($this->decorated($now)[$id], 1, 1, null, null);
+        $result = $this->save(['id' => $id, 'link' => '7', 'status' => 0, 'manual_status' => 0]);
+        $this->assertSame(HttpStatus::OK, $result['code'], $result['msg']);
+        $this->assertSame(1, (int) DB::table('banner')->where('id', $id)->value('status'));
+        $this->assertState($this->decorated($now)[$id], null, 1, null, '7');
+    }
+
     public function test_integer_and_leading_zero_activity_links_are_validated_and_canonicalized(): void
     {
         $this->activity(7, 1, 4102444799);
@@ -312,7 +455,7 @@ class BannerPublicationTest extends TestCase
         $this->assertSame(3, DB::table('banner')->count());
     }
 
-    public function test_stale_missing_activity_link_can_be_edited_unchanged_and_taken_offline(): void
+    public function test_stale_missing_activity_link_can_be_edited_but_cannot_be_manually_toggled(): void
     {
         $id = $this->banner(['link' => '500', 'sort' => 31]);
         $result = $this->save(['id' => $id, 'title' => '已删除活动的轮播图']);
@@ -321,10 +464,16 @@ class BannerPublicationTest extends TestCase
         $this->assertSame(31, (int) DB::table('banner')->where('id', $id)->value('sort'));
         $result = $this->save(['id' => $id, 'link' => '000500', 'status' => 0]);
         $this->assertSame(HttpStatus::OK, $result['code'], $result['msg']);
-        $this->assertState($this->decorated(time())[$id], 0, 0, 'manual', '500');
-        $this->assertSame(HttpStatus::OK, $this->status(['id' => $id, 'status' => 1])['code']);
-        $this->assertState($this->decorated(time())[$id], 1, 0, 'activity_missing', '500');
-        $this->assertSame(HttpStatus::OK, $this->status(['id' => $id, 'status' => 0])['code']);
+        $this->assertState($this->decorated(time())[$id], null, 0, 'activity_missing', '500');
+        foreach (['status', 'manual_status'] as $field) {
+            foreach ([0, 1] as $value) {
+                $result = $this->status(['id' => $id, $field => $value]);
+                $this->assertSame(HttpStatus::CUSTOM_ERROR, $result['code']);
+                $this->assertStringContainsString('关联活动', $result['msg']);
+                $this->assertStringContainsString('不能手动上下架', $result['msg']);
+                $this->assertSame(1, (int) DB::table('banner')->where('id', $id)->value('status'));
+            }
+        }
     }
 
     public function test_admin_pagination_counts_hidden_rows_and_batches_activity_queries(): void
@@ -366,6 +515,144 @@ class BannerPublicationTest extends TestCase
         $this->assertSame([1, 0, 1], array_column($page['data'], 'effective_status'));
     }
 
+    public function test_platform_language_and_actual_status_filters_combine_without_mutating_data(): void
+    {
+        $now = time();
+        $this->activity(7, $now - 100, $now + 600);
+        $this->activity(8, $now - 100, $now - 60);
+        $this->activity(9, $now + 600, $now + 1200);
+        $this->activity(10, $now - 100, $now + 600, 0);
+        foreach ([
+            ['pc_zh_active', 'pc', 'zh', '7', 0, 110],
+            ['pc_zh_ended', 'pc', 'zh', '8', 1, 100],
+            ['pc_en_on', 'pc', 'en', null, 1, 90],
+            ['pc_en_off', 'pc', 'en', null, 0, 80],
+            ['app_zh_active', 'app', 'zh', '7', 0, 70],
+            ['app_zh_ended', 'app', 'zh', '8', 1, 60],
+            ['app_en_on', 'app', 'en', null, 1, 50],
+            ['app_en_off', 'app', 'en', null, 0, 40],
+            ['pc_zh_future', 'pc', 'zh', '9', 1, 30],
+            ['pc_zh_offline', 'pc', 'zh', '10', 1, 20],
+            ['pc_zh_missing', 'pc', 'zh', '999', 1, 10],
+        ] as [$name, $type, $lang, $link, $manual, $sort]) {
+            $ids[$name] = $this->banner(['type' => $type, 'lang' => $lang, 'link' => $link, 'status' => $manual, 'sort' => $sort]);
+        }
+        $bannerBefore = DB::table('banner')->orderBy('id')->get()->toJson();
+        $activityBefore = DB::table('activity_rewards')->orderBy('id')->get()->toJson();
+        $cases = [
+            [[], array_keys($ids)],
+            [['platform' => 'pc'], ['pc_zh_active', 'pc_zh_ended', 'pc_en_on', 'pc_en_off', 'pc_zh_future', 'pc_zh_offline', 'pc_zh_missing']],
+            [['platform' => 'h5'], ['app_zh_active', 'app_zh_ended', 'app_en_on', 'app_en_off']],
+            [['type' => 'app'], ['app_zh_active', 'app_zh_ended', 'app_en_on', 'app_en_off']],
+            [['platform' => 'h5', 'type' => 'app'], ['app_zh_active', 'app_zh_ended', 'app_en_on', 'app_en_off']],
+            [['platform' => 'pc', 'type' => 'pc', 'lang' => 'en'], ['pc_en_on', 'pc_en_off']],
+            [['lang' => 'zh'], ['pc_zh_active', 'pc_zh_ended', 'app_zh_active', 'app_zh_ended', 'pc_zh_future', 'pc_zh_offline', 'pc_zh_missing']],
+            [['lang' => 'en'], ['pc_en_on', 'pc_en_off', 'app_en_on', 'app_en_off']],
+            [['status' => 1], ['pc_zh_active', 'pc_en_on', 'app_zh_active', 'app_en_on']],
+            [['status' => '0'], ['pc_zh_ended', 'pc_en_off', 'app_zh_ended', 'app_en_off', 'pc_zh_future', 'pc_zh_offline', 'pc_zh_missing']],
+            [['platform' => 'pc', 'lang' => 'zh', 'status' => 0], ['pc_zh_ended', 'pc_zh_future', 'pc_zh_offline', 'pc_zh_missing']],
+            [['platform' => 'h5', 'lang' => 'en', 'status' => '1'], ['app_en_on']],
+            [['platform' => 'h5', 'lang' => 'zh', 'status' => '0'], ['app_zh_ended']],
+            [['platform' => 'h5', 'status' => 0], ['app_zh_ended', 'app_en_off']],
+            [['platform' => 'pc', 'lang' => 'fr', 'status' => 1], []],
+        ];
+        foreach ($cases as [$filters, $names]) {
+            $page = $this->listing($filters);
+            $expectedIds = array_map(fn ($name) => $ids[$name], $names);
+            $this->assertSame(count($expectedIds), $page['total']);
+            $this->assertSame($expectedIds, array_column($page['data'], 'id'));
+            if (isset($filters['platform'])) {
+                foreach ($page['data'] as $row) $this->assertSame($filters['platform'] === 'h5' ? 'app' : 'pc', $row['type']);
+            }
+            if (array_key_exists('status', $filters)) {
+                foreach ($page['data'] as $row) $this->assertSame((int) $filters['status'], $row['status']);
+            }
+        }
+        $this->assertSame($bannerBefore, DB::table('banner')->orderBy('id')->get()->toJson());
+        $this->assertSame($activityBefore, DB::table('activity_rewards')->orderBy('id')->get()->toJson());
+    }
+
+    public function test_actual_status_is_filtered_before_pagination_and_activity_lookup_stays_batched(): void
+    {
+        $now = time();
+        $this->activity(7, $now - 100, $now + 600);
+        $this->activity(8, $now - 100, $now - 60);
+        $up = $down = [];
+        for ($i = 0; $i < 12; $i++) {
+            $id = $this->banner(['type' => 'app', 'lang' => 'zh', 'link' => $i % 2 ? '8' : '7', 'status' => $i % 2, 'sort' => $i]);
+            if ($i % 2) $down[] = $id; else $up[] = $id;
+        }
+        $this->banner(['type' => 'app', 'lang' => 'en', 'link' => '7', 'sort' => 100]);
+        $this->banner(['type' => 'pc', 'lang' => 'zh', 'link' => '7', 'sort' => 101]);
+        $up = array_reverse($up); $down = array_reverse($down);
+        $snapshot = DB::table('banner')->orderBy('id')->get()->toJson();
+        $filters = ['platform' => 'h5', 'lang' => 'zh', 'status' => 1, 'limit' => 2];
+        DB::connection()->enableQueryLog();
+        $first = $this->listing($filters);
+        $queries = DB::connection()->getQueryLog();
+        DB::connection()->disableQueryLog();
+        $this->assertCount(2, $queries, 'Status filtering should fetch matching candidates and all referenced activities in two queries.');
+        $this->assertSame(6, $first['total']);
+        $this->assertSame(array_slice($up, 0, 2), array_column($first['data'], 'id'));
+        $seen = array_column($first['data'], 'id');
+        foreach ([2, 3, 4] as $number) {
+            $page = $this->listing($filters + ['page' => $number]);
+            $this->assertSame(6, $page['total']);
+            $this->assertSame(array_slice($up, ($number - 1) * 2, 2), array_column($page['data'], 'id'));
+            $seen = array_merge($seen, array_column($page['data'], 'id'));
+        }
+        $this->assertSame($up, $seen);
+        foreach ([1, 2] as $number) {
+            $page = $this->listing(['platform' => 'h5', 'type' => 'app', 'lang' => 'zh', 'status' => '0', 'page' => $number, 'limit' => 4]);
+            $this->assertSame(6, $page['total']);
+            $this->assertSame(array_slice($down, ($number - 1) * 4, 4), array_column($page['data'], 'id'));
+        }
+        $this->assertSame($snapshot, DB::table('banner')->orderBy('id')->get()->toJson());
+    }
+
+    public function test_empty_optional_filters_keep_all_languages_and_default_pagination(): void
+    {
+        for ($i = 0; $i < 16; $i++) {
+            $ids[] = $this->banner(['type' => $i % 2 ? 'pc' : 'app', 'lang' => $i % 2 ? 'zh' : 'en', 'status' => $i % 2]);
+        }
+        $ids = array_reverse($ids);
+        $snapshot = DB::table('banner')->orderBy('id')->get()->toJson();
+        foreach ([[], ['platform' => null, 'type' => null, 'lang' => null, 'status' => null],
+            ['platform' => '', 'type' => '', 'lang' => '', 'status' => '']] as $filters) {
+            $page = $this->listing($filters);
+            $this->assertSame(16, $page['total']);
+            $this->assertCount(15, $page['data']);
+            $this->assertSame(array_slice($ids, 0, 15), array_column($page['data'], 'id'));
+            $this->assertSame(['zh', 'en'], array_values(array_unique(array_column($page['data'], 'lang'))));
+            $last = $this->listing($filters + ['page' => 2]);
+            $this->assertSame(16, $last['total']);
+            $this->assertSame(array_slice($ids, 15), array_column($last['data'], 'id'));
+        }
+        $largePage = $this->listing(['limit' => 200]);
+        $this->assertSame(16, $largePage['total']);
+        $this->assertSame($ids, array_column($largePage['data'], 'id'));
+        $this->assertSame($snapshot, DB::table('banner')->orderBy('id')->get()->toJson());
+    }
+
+    public function test_invalid_filters_and_conflicting_platform_aliases_are_rejected_without_mutation(): void
+    {
+        $this->banner(['type' => 'app', 'lang' => 'en']);
+        $snapshot = DB::table('banner')->get()->toJson();
+        foreach ([
+            ['platform' => 'app'], ['platform' => 'android'], ['platform' => ['pc']],
+            ['type' => 'h5'], ['type' => ['app']],
+            ['platform' => 'pc', 'type' => 'app'], ['platform' => 'h5', 'type' => 'pc'],
+            ['lang' => ['zh']], ['lang' => 123], ['lang' => 'abcdefghijk'],
+            ['status' => -1], ['status' => 2], ['status' => 'up'], ['status' => '1.5'], ['status' => ['0']],
+            ['page' => 0], ['limit' => 0], ['limit' => 201],
+        ] as $filters) {
+            $this->request('index', $filters);
+            $result = (new AdminBanner())->index()->getData(true);
+            $this->assertSame(HttpStatus::VALIDATION_FAILED, $result['code']);
+        }
+        $this->assertSame($snapshot, DB::table('banner')->get()->toJson());
+    }
+
     public function test_status_route_requires_existing_banner_edit_permission_and_preserves_superadmin_access(): void
     {
         Schema::create('menus', function (Blueprint $t) {
@@ -398,7 +685,7 @@ class BannerPublicationTest extends TestCase
         $this->assertSame(AdminBanner::class . '@status', $route->getAction('uses'));
         $id = $this->banner();
         $changeStatus = function (int $userId, int $status) use ($router, $id): array {
-            $this->request('status', ['id' => $id, 'status' => $status, 'user' => (object) ['id' => $userId]]);
+            $this->request('status', ['id' => $id, 'manual_status' => $status, 'user' => (object) ['id' => $userId]]);
             $request = $this->app['request'];
             $route = $router->getRoutes()->match($request);
             $request->setRouteResolver(fn () => $route);

+ 11 - 4
tests/Regression/banner_site_publication.php

@@ -82,7 +82,7 @@ namespace {
     $before = \think\facade\Db::table('bot_banner')->order('id')->select()->toArray();
     $list = new \app\api\lists\BannerLists(['type' => 'pc']);
     $queryCount = $selectCount;
-    check($list->count(), 8, 'Only manual-on unbound and active bound banners are counted');
+    check($list->count(), 9, 'Unbound banners use manual status; bound banners use only activity lifecycle');
     check($selectCount - $queryCount, 2, 'One banner scan and one activity lookup');
     $queryCount = $selectCount;
     check(array_column($list->lists(), 'id'), [3, 4], 'Lifecycle filtering precedes pagination');
@@ -93,6 +93,8 @@ namespace {
     check(array_column($list->lists(), 'id'), [10, 13], 'Start boundary and null link are visible');
     $list->limitOffset = 6;
     check(array_column($list->lists(), 'id'), [14, 15], 'Leading zeros match the correct positive ID or unbound zero');
+    $list->limitOffset = 8;
+    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');
@@ -107,12 +109,17 @@ namespace {
     $english->setParams(['type' => 'app']);
     check(array_column($english->lists(), 'id'), [12], 'setParams clears cached rows and restores query filters');
 
-    // Reopen an activity: its automatically hidden banner returns, manually disabled banners do not.
+    \think\facade\Db::table('bot_activity_rewards')->where('id', 101)->update(['status' => 0]);
+    check((new \app\api\lists\BannerLists(['type' => 'pc']))->count(), 6, 'Activity off hides every linked banner regardless of archived manual status');
+    \think\facade\Db::table('bot_activity_rewards')->where('id', 101)->update(['status' => 1]);
+    check((new \app\api\lists\BannerLists(['type' => 'pc']))->count(), 9, 'Activity on restores every linked banner including archived manual-off');
+
+    // Reopen an activity: its linked banner returns, while unrelated unbound manual-off banners stay hidden.
     \think\facade\Db::table('bot_activity_rewards')->where('id', 102)->update(['end_time' => $now + 100]);
     $fresh = new \app\api\lists\BannerLists(['type' => 'pc']);
-    check($fresh->count(), 9, 'Activity extension immediately restores eligible banner on the next request');
+    check($fresh->count(), 10, 'Activity extension immediately restores eligible banner on the next request');
     check(array_column($fresh->lists(), 'id'), [1, 3], 'Extended activity returns to original order');
-    check($list->count(), 8, 'One response keeps count and lists consistent across concurrent changes');
+    check($list->count(), 9, 'One response keeps count and lists consistent across concurrent changes');
     $GLOBALS['banner_test_now'] = $now + 100;
     $ended = new \app\api\lists\BannerLists(['type' => 'pc']);
     $ended->limitLength = 100;