doge 5 ore în urmă
părinte
comite
498261729d

+ 3 - 2
app/Http/Controllers/admin/Banner.php

@@ -117,7 +117,8 @@ class Banner extends Controller
             }
             $manualStatus = $banner->status ?? 1;
             if ($activityId === null) {
-                $manualStatus = $params['manual_status'] ?? $params['status'] ?? $manualStatus;
+                // Both the row button and the editor write status; a stale compatibility alias must not override it.
+                $manualStatus = $params['status'] ?? $params['manual_status'] ?? $manualStatus;
             }
             $banner->fill([
                 'title' => $params['title'],
@@ -154,7 +155,7 @@ class Banner extends Controller
                 if (BannerVisibilityService::activityId($banner->link) !== null) {
                     throw new Exception('关联活动的 Banner 由活动状态自动控制,不能手动上下架', HttpStatus::CUSTOM_ERROR);
                 }
-                $banner->status = $params['manual_status'] ?? $params['status'];
+                $banner->status = $params['status'] ?? $params['manual_status'];
                 $banner->save();
             });
         } catch (ValidationException $e) {

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

@@ -8,8 +8,8 @@
 
 | 返回字段 | 类型 | 含义与前端用途 |
 |---|---|---|
-| `status` | integer,`0/1` | 当前实际展示状态。`1` 上架中,`0` 已下架;后台状态标签读取此字段 |
-| `manual_status` | integer,`0/1` 或 `null` | 未绑定活动时为手动状态:`1` 允许展示,`0` 手动下架;绑定活动时为 `null` |
+| `status` | integer,`0/1` | 当前实际展示状态。`1` 上架中,`0` 已下架;未绑定活动时,列表标签、编辑表单和上下架按钮统一使用此字段 |
+| `manual_status` | integer,`0/1` 或 `null` | 只读兼容别名:未绑定活动时始终等于 `status`,绑定活动时为 `null`;新前端无需单独维护此值 |
 | `can_manual_toggle` | boolean | 是否允许手动切换。未绑定活动为 `true`,绑定活动为 `false`;控制列表及编辑表单中的手动控件是否可用 |
 | `activity_id` | string 或 `null` | 从 `link` 识别出的活动 ID,只读;数字字符串会去掉前导零,例如 `"000123"` 对应 `"123"` |
 | `effective_status` | integer,`0/1` | 兼容保留的实际状态字段,始终与 `status` 相同;新接入统一读取 `status` |
@@ -21,7 +21,7 @@
 
 | `offline_reason` | 条件 | 建议文案 |
 |---|---|---|
-| `manual` | 未绑定活动,且 `manual_status=0` | 手动下架 |
+| `manual` | 未绑定活动,且手动状态为 `0` | 手动下架 |
 | `activity_missing` | 绑定的活动已不存在 | 关联活动不存在 |
 | `activity_offline` | 关联活动未上架,活动 `status` 不为 `1` | 关联活动已下架 |
 | `activity_not_started` | 关联活动开始时间晚于服务器当前时间 | 关联活动未开始 |
@@ -30,7 +30,7 @@
 
 绑定活动的 Banner 在活动存在、已上架且满足 `start_time <= 服务器当前时间 < end_time` 时展示;历史保存过的手动状态不参与判断。未绑定活动的 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"`,应显示“已下架 / 关联活动已结束”,不能再显示“上架中”。
+**列表标签和编辑表单统一读取 `status`,手动控件先判断 `can_manual_toggle`。** 为 `true` 时可以直接用 `status` 回显和提交;为 `false` 时隐藏或禁用列表和编辑表单中的手动控件,仅显示实际状态及原因。例如活动已结束时,返回 `status=0`、`effective_status=0`、`manual_status=null`、`can_manual_toggle=false`、`offline_reason="activity_ended"`,应显示“已下架 / 关联活动已结束”,不能再显示“上架中”。
 
 活动重新上架、延期或调整开始时间后,绑定的 Banner 会在活动条件满足时自然恢复。前端以重新请求后的结果更新状态,不自行比较活动日期,也不把实际状态写入手动配置。
 
@@ -47,7 +47,7 @@
 
 只有正整数数字串会识别为活动 ID;后台列表的 `activity_id` 可用于显示关联活动。新增 Banner 或更换到另一个活动 ID 时,该活动必须存在,但允许绑定尚未开始、已经结束或已经下架的活动;保存成功不代表当前可展示。
 
-已有绑定活动被删除时,仍属于已绑定 Banner:实际状态显示 `activity_missing`,`can_manual_toggle=false`,不能手动上下架;允许保持原 `link` 修改标题、图片等。清除绑定时明确提交 `link:null`,省略 `link` 则保留原值。解除绑定后,Banner 恢复手动控制,可同时提交希望保存的 `manual_status`。
+已有绑定活动被删除时,仍属于已绑定 Banner:实际状态显示 `activity_missing`,`can_manual_toggle=false`,不能手动上下架;允许保持原 `link` 修改标题、图片等。清除绑定时明确提交 `link:null`,省略 `link` 则保留原值。解除绑定后,Banner 恢复手动控制,可同时提交希望保存的 `status`。
 
 ## 3. 后台列表
 
@@ -66,7 +66,7 @@
 
 筛选项省略、空字符串或 `null` 表示不限制该项;`status=0` 必须保留。各条件按 AND 组合,先筛选再统计及分页,`total` 是筛选后的总数。平台映射、组合示例和前端查询规则详见 [Banner 列表筛选](列表筛选.md)。
 
-列表保留分页结构及其他原有字段,状态含义按第 1 节处理:`status` 为实际状态,`manual_status` 仅在未绑定活动时可编辑,`can_manual_toggle` 决定手动控件是否可用。不传状态时同时返回实际上架、手动下架和活动联动下架的 Banner。排序仍为 `sort` 倒序,再按 `id` 倒序。
+列表保留分页结构及其他原有字段,状态含义按第 1 节处理:`status` 为实际状态,未绑定活动时可直接用于编辑和按钮提交;`manual_status` 只保留兼容回显,`can_manual_toggle` 决定手动控件是否可用。不传状态时同时返回实际上架、手动下架和活动联动下架的 Banner。排序仍为 `sort` 倒序,再按 `id` 倒序。
 
 下例展示一条因活动结束而下架的记录,原有其他字段按实际接口保留:
 
@@ -113,10 +113,10 @@
 | `lang` | 原语种值 | 是 | 沿用现有选项,例如 `zh`、`en` |
 | `link` | string、integer 或 `null` | 否 | 最长 `191` 个字符;兼容数字 JSON 值。编辑省略保留原值,新增省略为 `null` |
 | `sort` | integer 或 `null` | 否 | 编辑省略保留原值;新增省略或显式传 `null` 为 `0` |
-| `manual_status` | integer,`0/1` 或 `null` | 否 | 未绑定活动时推荐提交的手动状态,`null` 按省略处理;绑定活动时不应用、不保存该参数 |
-| `status` | integer,`0/1` | 否 | 未绑定活动时兼容旧客户端的手动写入别名;绑定活动时不应用、不保存该参数;含义与 GET 返回的实际状态不同 |
+| `status` | integer,`0/1` | 否 | 未绑定活动时的正式状态参数,`1` 上架、`0` 下架;绑定活动时不能覆盖活动状态 |
+| `manual_status` | integer,`0/1` 或 `null` | 否 | 仅兼容历史请求;只有省略 `status` 时才作为备用输入,`null` 按省略处理;新前端无需提交 |
 
-未绑定活动时,编辑表单从 `manual_status` 回显并优先提交此字段。两种手动输入同时有效时,以 `manual_status` 为准;`manual_status:null` 按省略处理。两者都省略时(含 `manual_status:null` 且省略 `status`),编辑保留原手动状态,新增默认为 `1`;非法值按参数错误处理。不要单独把 GET 返回的实际 `status` 当成手动状态回传。
+未绑定活动时,编辑表单直接从列表的 `status` 回显,并通过完整表单提交 `status`。**两个参数同时出现时,一律以 `status` 为准**;整行对象里残留的旧 `manual_status` 不会覆盖用户新选的状态。只有未传 `status` 时才兼容使用 `manual_status`;`manual_status:null` 按省略处理。两者都省略时(含 `manual_status:null` 且省略 `status`),编辑保留原状态,新增默认为 `1`;非法值按参数错误处理。
 
 保存后的 `link` 仍绑定活动时,允许正常修改标题、图片、排序和链接等;旧客户端随表单带回 `manual_status` 或 `status` 时,这两个手动参数均被忽略,不改变活动联动状态。`manual_status:null` 可用于兼容列表记录回填。前端按 `can_manual_toggle=false` 隐藏或禁用手动控件,不主动提交手动值。
 
@@ -146,7 +146,7 @@
   "lang": "zh",
   "link": null,
   "sort": 10,
-  "manual_status": 0
+  "status": 0
 }
 ```
 
@@ -170,28 +170,28 @@
 | 参数 | 类型 | 必填 | 说明 |
 |---|---|---|---|
 | `id` | integer | 是 | Banner ID,最小 `1` |
-| `manual_status` | integer,`0/1` | 与 `status` 至少传一个 | 推荐参数,`0` 手动下架,`1` 手动允许展示 |
-| `status` | integer,`0/1` | 与 `manual_status` 至少传一个 | 兼容旧客户端的手动写入别名;同时提交时以 `manual_status` 为准 |
+| `status` | integer,`0/1` | 推荐提交 | 正式状态参数,`0` 下架,`1` 上架;与 `manual_status` 至少传一个,同时提交时以 `status` 为准 |
+| `manual_status` | integer,`0/1` | 兼容备用 | 仅省略 `status` 时使用;新前端无需提交 |
 
 下架请求:
 
 ```json
 {
   "id": 41,
-  "manual_status": 0
+  "status": 0
 }
 ```
 
-手动允许展示请求:
+上架请求:
 
 ```json
 {
   "id": 41,
-  "manual_status": 1
+  "status": 1
 }
 ```
 
-对未绑定活动的 Banner,兼容请求同时提供两个参数时,以 `manual_status` 为准。例如以下请求将未绑定活动的 Banner 设为手动允许展示:
+对未绑定活动的 Banner,兼容请求同时提供两个参数时,以 `status` 为准。例如以下请求会下架 Banner;残留的 `manual_status=1` 不会阻止保存新的 `status=0`。完整编辑接口同样遵循此优先级:
 
 ```json
 {
@@ -201,7 +201,7 @@
 }
 ```
 
-成功响应与保存接口相同:`code=0`、`data=[]`。提交期间禁用该行手动控件;失败恢复原值并展示 `msg`,成功后刷新列表。实际状态标签读取 `status`,手动控件根据 `can_manual_toggle` 决定是否可用,再读取 `manual_status`。本接口不接受 `manual_status:null`;两种参数均未提交,或任一已提交参数不为合法 `0/1`,均按参数错误处理。
+成功响应与保存接口相同:`code=0`、`data=[]`。列表按钮直接提交 `{id,status}`;完整编辑提交基础表单和 `status`。提交期间禁用该行控件;失败恢复原值并展示 `msg`,成功后刷新列表。未绑定活动的返回 `status`、`effective_status`、`manual_status` 均为已保存的同一 `0/1` 状态,前端无需另建手动状态值。本接口不接受 `manual_status:null`;两种参数均未提交,或任一已提交参数不为合法 `0/1`,均按参数错误处理。
 
 ## 6. 会员端 Banner
 
@@ -284,6 +284,6 @@ Banner 不存在示例:
 - 对绑定活动的 Banner 调用状态切换接口应失败;完整编辑标题或图片时,即使旧客户端附带手动字段,也不改变活动联动规则。
 - 延长已结束活动到有效时间或重新上架活动后,绑定 Banner 按活动条件恢复;历史手动值为 `0` 也不阻止恢复。
 - 下架记录不占会员分页名额,`count` 与可展示记录一致;后台总数仍包含下架记录。
-- 编辑仅修改标题 / 图片并省略 `link`、`sort`、`manual_status` 和兼容参数 `status`,原关联、排序保留;未绑定活动时也保留原手动状态。
-- 未绑定活动时,编辑或切换接口同时提交 `manual_status=1`、`status=0`,以 `manual_status` 为准。
-- 清除活动绑定并提交 `manual_status=0` 后,列表返回 `can_manual_toggle=true`、`manual_status=0`、`status=0`,手动控件重新可用。
+- 编辑仅修改标题 / 图片并省略 `link`、`sort`、`status` 和兼容参数 `manual_status`,原关联、排序保留;未绑定活动时也保留原状态。
+- 未绑定活动时,列表按钮和完整编辑都只提交新的 `status` 即可同步状态;同时附带旧 `manual_status=1`、新 `status=0` 时,以 `status=0` 为准,刷新后列表和编辑表单都回显下架。
+- 清除活动绑定并提交 `status=0` 后,列表返回 `can_manual_toggle=true`、`manual_status=0`、`status=0`,手动控件重新可用。

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

@@ -31,7 +31,7 @@ Banner 现有 `type` 取值保留为 `pc/app`。`platform` 是列表筛选别名
 
 状态筛选使用**当前实际状态**:绑定活动的 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)。
+状态标签、未绑定活动的编辑表单和列表按钮统一使用 `status`;`effective_status` 与其相同。仅在 `can_manual_toggle=true` 时允许手动操作,按钮直接提交 `{id,status}`,完整编辑提交基础表单和 `status`。`manual_status` 是兼容回显别名,未绑定活动时等于 `status`,无需另建前端值;请求同时携带两者时以 `status` 为准。绑定活动时为 `can_manual_toggle=false`、`manual_status=null`,隐藏或禁用手动控件,不能用编辑提交的状态覆盖活动展示规则。完整规则见 [上下架与活动联动](上下架与活动联动.md)。
 
 ## 组合请求与返回
 

+ 64 - 8
tests/Integration/BannerPublicationTest.php

@@ -196,28 +196,76 @@ class BannerPublicationTest extends TestCase
         }
     }
 
-    public function test_manual_status_takes_precedence_in_full_forms_and_status_requests(): void
+    public function test_status_takes_precedence_with_manual_status_as_fallback_in_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],
+            [['status' => 0, 'manual_status' => 1], 0],
+            [['status' => 1, 'manual_status' => 0], 1],
         ] as [$fields, $expected]) {
             $result = $this->save($fields);
             $this->assertSame(HttpStatus::OK, $result['code'], $result['msg']);
             $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]);
+        foreach ([[0, 1], [1, 0]] as [$canonical, $manual]) {
+            $result = $this->status(['id' => $id, 'status' => $canonical, '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($canonical, (int) DB::table('banner')->where('id', $id)->value('status'));
+            $result = $this->save(['id' => $id, 'title' => '兼容完整编辑', 'status' => $canonical, 'manual_status' => $manual]);
             $this->assertSame(HttpStatus::OK, $result['code'], $result['msg']);
-            $this->assertSame($manual, (int) DB::table('banner')->where('id', $id)->value('status'));
+            $this->assertSame($canonical, (int) DB::table('banner')->where('id', $id)->value('status'));
+        }
+    }
+
+    public function test_unbound_list_row_edits_and_buttons_follow_changed_status_over_stale_manual_alias(): void
+    {
+        $ids = [];
+        foreach ([null, '', '0', 'https://example.test/promotion'] as $link) {
+            $ids[] = $this->banner(['link' => $link, 'status' => 1]);
+        }
+        $orderedIds = array_reverse($ids);
+        $assertPublication = function (int $id, int $expected) use ($orderedIds): array {
+            $rows = collect($this->listing()['data'])->keyBy('id')->all();
+            $this->assertState($rows[$id], $expected, $expected, $expected ? null : 'manual', null);
+            $this->assertSame($expected, (int) DB::table('banner')->where('id', $id)->value('status'));
+            $downIds = $expected ? [] : [$id];
+            $upIds = array_values(array_diff($orderedIds, $downIds));
+            foreach ([['0', $downIds], ['1', $upIds]] as [$status, $expectedIds]) {
+                $page = $this->listing(['status' => $status]);
+                $this->assertSame(count($expectedIds), $page['total']);
+                $this->assertSame($expectedIds, array_column($page['data'], 'id'));
+            }
+            return $rows[$id];
+        };
+        foreach ($ids as $id) {
+            $row = $assertPublication($id, 1);
+            $originalLink = $row['link'];
+            $row['status'] = 0;
+            $row['title'] = '列表编辑下架';
+            $this->assertSame(1, $row['manual_status']);
+            $result = $this->save($row);
+            $this->assertSame(HttpStatus::OK, $result['code'], $result['msg']);
+            $row = $assertPublication($id, 0);
+
+            $result = $this->status(['id' => $id, 'status' => 1, 'manual_status' => $row['manual_status']]);
+            $this->assertSame(HttpStatus::OK, $result['code'], $result['msg']);
+            $row = $assertPublication($id, 1);
+            $result = $this->status(['id' => $id, 'status' => 0, 'manual_status' => $row['manual_status']]);
+            $this->assertSame(HttpStatus::OK, $result['code'], $result['msg']);
+            $row = $assertPublication($id, 0);
+
+            $row['status'] = 1;
+            $row['title'] = '列表编辑上架';
+            $this->assertSame(0, $row['manual_status']);
+            $result = $this->save($row);
+            $this->assertSame(HttpStatus::OK, $result['code'], $result['msg']);
+            $row = $assertPublication($id, 1);
+            $this->assertSame('列表编辑上架', $row['title']);
+            $this->assertSame($originalLink, $row['link']);
         }
     }
 
@@ -236,6 +284,14 @@ class BannerPublicationTest extends TestCase
                 $this->assertSame('轮播图', $row->title);
             }
         }
+        foreach ([['status' => 1, 'manual_status' => 'invalid'], ['status' => 'invalid', 'manual_status' => 1]] as $fields) {
+            $result = $this->save($fields + ['id' => $id, 'title' => '不应保存']);
+            $this->assertSame(HttpStatus::VALIDATION_FAILED, $result['code']);
+            $result = $this->status($fields + ['id' => $id]);
+            $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(['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'));