Ver código fonte

删除接口

doge 8 horas atrás
pai
commit
4c8f4bc06b

+ 8 - 0
app/Http/Controllers/admin/Announcement.php

@@ -31,6 +31,14 @@ class Announcement extends Controller
     }
     public function logs(AnnouncementService $service) { return $this->run(fn () => $service->logs(request()->all())); }
 
+    public function delete(AnnouncementService $service)
+    {
+        return $this->run(function () use ($service) {
+            $params = request()->validate(['id' => ['required', 'integer', 'min:1']]);
+            return $service->delete((int) $params['id']);
+        });
+    }
+
     private function run(callable $callback)
     {
         try { return $this->success($callback()); }

+ 19 - 2
app/Services/AnnouncementService.php

@@ -155,7 +155,8 @@ class AnnouncementService
             $after = $this->snapshot((int) $id);
             OperationAuditService::record('announcement', (int) $id, $before ? 'update' : 'create', $before, $after, $now);
             SiteRealtimeService::changed('announcement', (int) $id, $before ? 'update' : 'create',
-                $row['status'] ? ['start' => $row['publish_at'], 'end' => $row['end_at']] : []);
+                $row['status'] ? ['start' => $row['publish_at'], 'end' => $row['end_at']] : [],
+                ($before && Catalog::state($before, $now) === 'active') || Catalog::state($after, $now) === 'active');
             return $this->dto($after);
         });
     }
@@ -173,11 +174,27 @@ class AnnouncementService
             $after = $this->snapshot($id);
             OperationAuditService::record('announcement', $id, 'status', $before, $after, $now);
             SiteRealtimeService::changed('announcement', $id, 'status',
-                $status ? ['start' => $after['publish_at'], 'end' => $after['end_at']] : []);
+                $status ? ['start' => $after['publish_at'], 'end' => $after['end_at']] : [],
+                Catalog::state($before, $now) === 'active' || Catalog::state($after, $now) === 'active');
             return $this->dto($after);
         });
     }
 
+    public function delete(int $id): array
+    {
+        Validator::make(compact('id'), ['id' => ['required', 'integer', 'min:1']])->validate();
+        return DB::transaction(function () use ($id) {
+            $row = DB::table('announcements')->where('id', $id)->lockForUpdate()->first();
+            if (!$row) $this->invalid('id', '公告不存在');
+            $before = $this->snapshot($id); $now = $this->now();
+            DB::table('announcement_translations')->where('announcement_id', $id)->delete();
+            DB::table('announcements')->where('id', $id)->delete();
+            OperationAuditService::record('announcement', $id, 'delete', $before, [], $now);
+            SiteRealtimeService::changed('announcement', $id, 'delete', [], false);
+            return ['id' => $id, 'deleted' => true];
+        });
+    }
+
     public function logs(array $input): array
     {
         if (isset($input['announcement_id'])) $input['resource_id'] = $input['announcement_id'];

+ 6 - 5
app/Services/SiteRealtimeService.php

@@ -11,19 +11,20 @@ use InvalidArgumentException;
 /** Written inside the same transaction as the change; consumed by the member WebSocket worker. */
 final class SiteRealtimeService
 {
-    public static function changed(string $module, int $id, string $action, array $boundaries = []): void
+    public static function changed(string $module, int $id, string $action, array $boundaries = [], bool $notifyNow = true): void
     {
         if (!in_array($module, ['message', 'announcement', 'maintenance'], true) || $id < 1) throw new InvalidArgumentException('Invalid site event');
         if (!Schema::hasTable('site_realtime_events')) {
             throw ValidationException::withMessages(['realtime' => '实时通知服务未初始化,请先完成系统更新']);
         }
         $now = Carbon::now('Asia/Shanghai')->format('Y-m-d H:i:s');
-        DB::table('site_realtime_events')->where('module', $module)->where('resource_id', $id)
-            ->whereNull('dispatched_at')->where('available_at', '>', $now)->delete();
-        $rows = [['module' => $module, 'resource_id' => $id, 'action' => $action, 'available_at' => $now, 'created_at' => $now]];
+        $pending = DB::table('site_realtime_events')->where('module', $module)->where('resource_id', $id)->whereNull('dispatched_at');
+        if ($module !== 'announcement') $pending->where('available_at', '>', $now);
+        $pending->delete();
+        $rows = $notifyNow ? [['module' => $module, 'resource_id' => $id, 'action' => $action, 'available_at' => $now, 'created_at' => $now]] : [];
         foreach ($boundaries as $edge => $at) {
             if ($at !== null && $at > $now) $rows[] = ['module' => $module, 'resource_id' => $id, 'action' => $edge, 'available_at' => $at, 'created_at' => $now];
         }
-        DB::table('site_realtime_events')->insert($rows);
+        if ($rows) DB::table('site_realtime_events')->insert($rows);
     }
 }

+ 21 - 0
docs/总后台/公告删除-前端接入.md

@@ -0,0 +1,21 @@
+# 公告删除
+
+`POST /admin/announcement/delete`
+
+沿用现有后台登录,请求JSON:
+
+```json
+{"id":123}
+```
+
+`id` 必填,为公告ID。
+
+成功返回 `code=0`,`data` 为:
+
+```json
+{"id":123,"deleted":true}
+```
+
+删除成功后刷新列表,关闭该公告的详情/编辑窗口。已删除或不存在的ID返回 `code=-3`、`msg="公告不存在"`;其他失败直接展示返回的 `msg`。
+
+删除公告后取消其所有待推送任务,不发送WebSocket删除通知。后续API不再返回该公告,已打开的公告不强制移除。

+ 1 - 0
routes/admin.php

@@ -137,6 +137,7 @@ Route::middleware(['admin.jwt'])->group(function () {
             Route::get('/detail', [Announcement::class, 'detail'])->defaults('permission_uri', 'admin/announcement');
             Route::post('/submit', [Announcement::class, 'submit']);
             Route::post('/status', [Announcement::class, 'status'])->defaults('permission_uri', 'admin/announcement/submit');
+            Route::post('/delete', [Announcement::class, 'delete']);
             Route::get('/logs', [Announcement::class, 'logs']);
         });
 

+ 50 - 4
tests/Integration/AnnouncementTest.php

@@ -78,12 +78,12 @@ class AnnouncementTest extends TestCase
     public function test_websocket_events_cover_publication_boundaries_cancel_old_schedules_and_roll_back(): void
     {
         $row = $this->create(['publish_immediately' => false, 'publish_at' => '2026-10-26 11:00:00', 'no_end' => false, 'end_at' => '2026-10-26 12:00:00']);
-        $this->assertSame(['create', 'start', 'end'], DB::table('site_realtime_events')->orderBy('id')->pluck('action')->all());
-        $this->assertSame(['2026-10-26 10:00:00', '2026-10-26 11:00:00', '2026-10-26 12:00:00'], DB::table('site_realtime_events')->orderBy('id')->pluck('available_at')->all());
+        $this->assertSame(['start', 'end'], DB::table('site_realtime_events')->orderBy('id')->pluck('action')->all());
+        $this->assertSame(['2026-10-26 11:00:00', '2026-10-26 12:00:00'], DB::table('site_realtime_events')->orderBy('id')->pluck('available_at')->all());
         $this->service->status($row['id'], 0); $this->service->status($row['id'], 0);
-        $this->assertSame(['create', 'status'], DB::table('site_realtime_events')->orderBy('id')->pluck('action')->all());
+        $this->assertSame([], DB::table('site_realtime_events')->orderBy('id')->pluck('action')->all());
         DB::beginTransaction(); $this->create(); DB::rollBack();
-        $this->assertSame(2, DB::table('site_realtime_events')->count());
+        $this->assertSame(0, DB::table('site_realtime_events')->count());
     }
 
     private function create(array $params = []): array
@@ -283,6 +283,48 @@ class AnnouncementTest extends TestCase
         }
     }
 
+    public function test_delete_removes_announcement_and_translations_and_cancels_notifications(): void
+    {
+        $row = $this->create(['publish_immediately' => false, 'publish_at' => '2026-10-26 11:00:00']);
+        $this->request(['id' => $row['id']]);
+        $body = (new AnnouncementController())->delete($this->service)->getData(true);
+        $this->assertSame(0, $body['code']);
+        $this->assertSame(['id' => $row['id'], 'deleted' => true], $body['data']);
+        $this->assertSame(0, DB::table('announcements')->count());
+        $this->assertSame(0, DB::table('announcement_translations')->count());
+        $this->assertSame(0, $this->service->listing([])['total']);
+        $this->assertSame([], DB::table('site_realtime_events')->orderBy('id')->pluck('action')->all());
+        $this->assertSame('delete', DB::table('operation_audits')->orderByDesc('id')->value('action'));
+        $this->assertSame(-3, (new AnnouncementController())->delete($this->service)->getData(true)['code']);
+        $this->assertSame(2, DB::table('operation_audits')->count());
+        $this->assertSame(2, $this->service->logs([])['total']);
+    }
+
+    public function test_announcement_deletion_never_notifies_and_all_pending_events_are_cancelled(): void
+    {
+        $active = $this->create();
+        $this->service->delete($active['id']);
+        $this->assertSame([], DB::table('site_realtime_events')->pluck('action')->all());
+        DB::table('site_realtime_events')->delete();
+        $future = $this->create(['publish_immediately' => false, 'publish_at' => '2026-10-26 11:00:00']);
+        DB::table('site_realtime_events')->insert(['module' => 'announcement', 'resource_id' => $future['id'], 'action' => 'create',
+            'available_at' => '2026-10-26 10:00:00', 'created_at' => '2026-10-26 10:00:00']);
+        $this->service->delete($future['id']);
+        Carbon::setTestNow(Carbon::parse('2026-10-26 12:00:00', 'Asia/Shanghai'));
+        $this->assertSame(0, DB::table('site_realtime_events')->count());
+    }
+
+    public function test_delete_rolls_back_if_audit_or_notification_cannot_be_saved(): void
+    {
+        $row = $this->create();
+        Schema::drop('site_realtime_events');
+        try { $this->service->delete($row['id']); $this->fail('Missing event table must fail atomically'); }
+        catch (ValidationException $e) { $this->assertArrayHasKey('realtime', $e->errors()); }
+        $this->assertSame(1, DB::table('announcements')->count());
+        $this->assertSame(1, DB::table('announcement_translations')->count());
+        $this->assertSame(1, DB::table('operation_audits')->count());
+    }
+
     public function test_controller_routes_are_authenticated_and_status_reuses_save_permission(): void
     {
         $router = new Router(new Dispatcher($this->app), $this->app);
@@ -293,6 +335,10 @@ class AnnouncementTest extends TestCase
             $this->assertContains('admin.jwt', $route->middleware()); $this->assertContains('check.button.uri', $route->middleware());
         }
         $this->assertSame('admin/announcement/submit', $route->defaults['permission_uri']);
+        $delete = $router->getRoutes()->match(Request::create('/admin/announcement/delete', 'POST'));
+        $this->assertContains('admin.jwt', $delete->middleware());
+        $this->assertContains('check.button.uri', $delete->middleware());
+        $this->assertSame('delete', $delete->getActionMethod());
         $this->request(['translations' => ['zh' => ['title' => '测试', 'content' => '内容']]]);
         $result = (new AnnouncementController())->submit($this->service)->getData(true);
         $this->assertSame(0, $result['code']);