ソースを参照

feat: remove unavailable chats from management

AI-Co-Authored-By: Codex
chendeben 1 週間 前
親
コミット
9f8a4312d8

+ 37 - 5
admin-web/src/pages/Chats.tsx

@@ -1,7 +1,7 @@
-import { Button, Input, Space, Table, Tag, Typography, message } from 'antd';
+import { Button, Input, Popconfirm, Space, Table, Tag, Typography, message } from 'antd';
 import type { ColumnsType, TablePaginationConfig } from 'antd/es/table';
 import type { ColumnsType, TablePaginationConfig } from 'antd/es/table';
 import dayjs from 'dayjs';
 import dayjs from 'dayjs';
-import { RefreshCw, Search, Settings2 } from 'lucide-react';
+import { RefreshCw, Search, Settings2, Trash2 } from 'lucide-react';
 import { history } from '@umijs/max';
 import { history } from '@umijs/max';
 import { useEffect, useState } from 'react';
 import { useEffect, useState } from 'react';
 
 
@@ -14,6 +14,7 @@ export default function ChatsPage() {
   const [data, setData] = useState<Paged<ChatSummary>>({ items: [], total: 0, page: 1, page_size: 20 });
   const [data, setData] = useState<Paged<ChatSummary>>({ items: [], total: 0, page: 1, page_size: 20 });
   const [query, setQuery] = useState('');
   const [query, setQuery] = useState('');
   const [loading, setLoading] = useState(false);
   const [loading, setLoading] = useState(false);
+  const [deletingChatId, setDeletingChatId] = useState<string | null>(null);
 
 
   const load = async (page = data.page, search = query) => {
   const load = async (page = data.page, search = query) => {
     setLoading(true);
     setLoading(true);
@@ -35,6 +36,19 @@ export default function ChatsPage() {
     void load(1, '');
     void load(1, '');
   }, []);
   }, []);
 
 
+  const removeUnavailableChat = async (record: ChatSummary) => {
+    setDeletingChatId(record.chat_id);
+    try {
+      await apiRequest<{ removed: boolean }>(`/chats/${record.chat_id}`, { method: 'DELETE' });
+      message.success(`已从管理列表移除“${record.title}”`);
+      await load(data.items.length === 1 && data.page > 1 ? data.page - 1 : data.page);
+    } catch (error) {
+      message.error(error instanceof Error ? error.message : '群记录移除失败');
+    } finally {
+      setDeletingChatId(null);
+    }
+  };
+
   const columns: ColumnsType<ChatSummary> = [
   const columns: ColumnsType<ChatSummary> = [
     {
     {
       title: '群组',
       title: '群组',
@@ -66,16 +80,34 @@ export default function ChatsPage() {
       title: '操作',
       title: '操作',
       key: 'actions',
       key: 'actions',
       fixed: 'right',
       fixed: 'right',
-      width: 108,
-      render: (_, record) => (
+      width: 120,
+      render: (_, record) => record.accessible ? (
         <Button
         <Button
           type="link"
           type="link"
           icon={<Settings2 size={16} />}
           icon={<Settings2 size={16} />}
-          disabled={!record.accessible}
           onClick={() => history.push(`/chats/${record.chat_id}`)}
           onClick={() => history.push(`/chats/${record.chat_id}`)}
         >
         >
           管理
           管理
         </Button>
         </Button>
+      ) : (
+        <Popconfirm
+          title="移除不可用群?"
+          description="仅移除当前 Bot 的管理列表记录,不会删除群消息、积分或抽奖数据;Bot 以后重新在群里活动时会再次出现。"
+          okText="移除"
+          cancelText="取消"
+          okButtonProps={{ danger: true }}
+          onConfirm={() => removeUnavailableChat(record)}
+        >
+          <Button
+            danger
+            type="link"
+            icon={<Trash2 size={16} />}
+            loading={deletingChatId === record.chat_id}
+            aria-label={`移除不可用群 ${record.title}`}
+          >
+            移除
+          </Button>
+        </Popconfirm>
       ),
       ),
     },
     },
   ];
   ];

+ 10 - 2
admin-web/src/pages/Giveaways.tsx

@@ -57,7 +57,7 @@ export default function GiveawaysPage() {
   const initialChat = new URLSearchParams(location.search).get('chat_id') || undefined;
   const initialChat = new URLSearchParams(location.search).get('chat_id') || undefined;
   const [data, setData] = useState<Paged<Giveaway>>({ items: [], total: 0, page: 1, page_size: 20 });
   const [data, setData] = useState<Paged<Giveaway>>({ items: [], total: 0, page: 1, page_size: 20 });
   const [chats, setChats] = useState<ChatSummary[]>([]);
   const [chats, setChats] = useState<ChatSummary[]>([]);
-  const [chatId, setChatId] = useState<string | undefined>(initialChat);
+  const [chatId, setChatId] = useState<string | undefined>();
   const [status, setStatus] = useState<string | undefined>();
   const [status, setStatus] = useState<string | undefined>();
   const [loading, setLoading] = useState(false);
   const [loading, setLoading] = useState(false);
   const [createOpen, setCreateOpen] = useState(false);
   const [createOpen, setCreateOpen] = useState(false);
@@ -118,7 +118,15 @@ export default function GiveawaysPage() {
   };
   };
 
 
   useEffect(() => {
   useEffect(() => {
-    apiRequest<Paged<ChatSummary>>('/chats?page_size=100').then((result) => setChats(result.items));
+    apiRequest<Paged<ChatSummary>>('/chats?page_size=100').then((result) => {
+      const accessibleChats = result.items.filter((chat) => chat.accessible);
+      setChats(accessibleChats);
+      setChatId(
+        initialChat && accessibleChats.some((chat) => chat.chat_id === initialChat)
+          ? initialChat
+          : undefined,
+      );
+    });
   }, []);
   }, []);
 
 
   useEffect(() => {
   useEffect(() => {

+ 3 - 2
admin-web/src/pages/Points.tsx

@@ -44,8 +44,9 @@ export default function PointsPage() {
 
 
   useEffect(() => {
   useEffect(() => {
     apiRequest<Paged<ChatSummary>>('/chats?page_size=100').then((result) => {
     apiRequest<Paged<ChatSummary>>('/chats?page_size=100').then((result) => {
-      setChats(result.items);
-      if (result.items.length) setChatId(result.items[0].chat_id);
+      const accessibleChats = result.items.filter((chat) => chat.accessible);
+      setChats(accessibleChats);
+      if (accessibleChats.length) setChatId(accessibleChats[0].chat_id);
     });
     });
   }, []);
   }, []);
 
 

+ 54 - 28
admin-web/tests/unit/Chats.test.tsx

@@ -9,37 +9,63 @@ vi.mock('@/services/api', () => ({ apiRequest: vi.fn() }));
 
 
 beforeEach(() => vi.clearAllMocks());
 beforeEach(() => vi.clearAllMocks());
 
 
-it('加载群列表并进入管理页', async () => {
-  vi.mocked(apiRequest).mockResolvedValue({
-    items: [
-      {
-        chat_id: '-100123',
-        title: '测试运营群',
-        username: 'ops_test',
-        type: 'supergroup',
-        accessible: true,
-        member_count: 42,
-        bot_status: 'administrator',
-      },
-      {
-        chat_id: '-100456',
-        title: '机器人已退出的群',
-        type: 'supergroup',
-        accessible: false,
-        member_count: 8,
-        bot_status: 'left',
-      },
-    ],
-    total: 2,
-    page: 1,
-    page_size: 20,
-  });
+const chatPage = {
+  items: [
+    {
+      chat_id: '-100123',
+      title: '测试运营群',
+      username: 'ops_test',
+      type: 'supergroup',
+      accessible: true,
+      member_count: 42,
+      bot_status: 'administrator',
+    },
+    {
+      chat_id: '-100456',
+      title: '机器人已退出的群',
+      type: 'supergroup',
+      accessible: false,
+      member_count: 8,
+      bot_status: 'left',
+    },
+  ],
+  total: 2,
+  page: 1,
+  page_size: 20,
+};
+
+it('可用群进入管理页,不可用群提供移除入口', async () => {
+  vi.mocked(apiRequest).mockResolvedValue(chatPage);
   render(<ChatsPage />);
   render(<ChatsPage />);
 
 
   expect(await screen.findByText('测试运营群')).toBeInTheDocument();
   expect(await screen.findByText('测试运营群')).toBeInTheDocument();
-  const manageButtons = screen.getAllByRole('button', { name: '管理' });
-  fireEvent.click(manageButtons[0]);
+  fireEvent.click(screen.getByRole('button', { name: '管理' }));
   expect(history.push).toHaveBeenCalledWith('/chats/-100123');
   expect(history.push).toHaveBeenCalledWith('/chats/-100123');
-  expect(manageButtons[1]).toBeDisabled();
+  expect(
+    screen.getByRole('button', { name: '移除不可用群 机器人已退出的群' }),
+  ).toBeInTheDocument();
   await waitFor(() => expect(apiRequest).toHaveBeenCalledTimes(1));
   await waitFor(() => expect(apiRequest).toHaveBeenCalledTimes(1));
 });
 });
+
+it('确认后移除不可用群并刷新列表', async () => {
+  vi.mocked(apiRequest)
+    .mockResolvedValueOnce(chatPage)
+    .mockResolvedValueOnce({ removed: true })
+    .mockResolvedValueOnce({ ...chatPage, items: chatPage.items.slice(0, 1), total: 1 });
+  render(<ChatsPage />);
+
+  fireEvent.click(
+    await screen.findByRole('button', { name: '移除不可用群 机器人已退出的群' }),
+  );
+  expect(await screen.findByText('仅移除当前 Bot 的管理列表记录,不会删除群消息、积分或抽奖数据;Bot 以后重新在群里活动时会再次出现。')).toBeInTheDocument();
+  fireEvent.click(screen.getByRole('button', { name: /^移\s*除$/ }));
+
+  await waitFor(() => {
+    expect(apiRequest).toHaveBeenNthCalledWith(
+      2,
+      '/chats/-100456',
+      { method: 'DELETE' },
+    );
+    expect(apiRequest).toHaveBeenCalledTimes(3);
+  });
+});

+ 23 - 2
admin-web/tests/unit/Giveaways.test.tsx

@@ -17,8 +17,11 @@ beforeEach(() => {
   vi.mocked(apiRequest).mockImplementation(async (path) => {
   vi.mocked(apiRequest).mockImplementation(async (path) => {
     if (path.startsWith('/chats?')) {
     if (path.startsWith('/chats?')) {
       return {
       return {
-        items: [{ chat_id: '-100123', title: '抽奖测试群', type: 'supergroup', accessible: true }],
-        total: 1,
+        items: [
+          { chat_id: '-100999', title: '不可用抽奖群', type: 'supergroup', accessible: false },
+          { chat_id: '-100123', title: '抽奖测试群', type: 'supergroup', accessible: true },
+        ],
+        total: 2,
         page: 1,
         page: 1,
         page_size: 100,
         page_size: 100,
       };
       };
@@ -126,6 +129,24 @@ it('创建表单包含积分联动字段且发布前必须二次确认', async (
   });
   });
 });
 });
 
 
+it('不可用群不会成为筛选或创建目标', async () => {
+  mockLocation.search = '?chat_id=-100999';
+  render(<GiveawaysPage />);
+
+  await waitFor(() => {
+    const requests = vi.mocked(apiRequest).mock.calls
+      .map(([path]) => path)
+      .filter((path) => path.startsWith('/giveaways?'));
+    expect(requests.length).toBeGreaterThan(0);
+    expect(requests.every((path) => !path.includes('chat_id=-100999'))).toBe(true);
+  });
+
+  fireEvent.click(screen.getByRole('button', { name: /创\s*建\s*抽\s*奖/ }));
+  fireEvent.mouseDown(await screen.findByLabelText('群组'));
+  expect(screen.queryByText('不可用抽奖群')).not.toBeInTheDocument();
+  expect(screen.getByText('抽奖测试群')).toBeInTheDocument();
+});
+
 it('选择群组后提供抽奖资格封禁管理', async () => {
 it('选择群组后提供抽奖资格封禁管理', async () => {
   mockLocation.search = '?chat_id=-100123';
   mockLocation.search = '?chat_id=-100123';
   vi.mocked(apiRequest).mockImplementation(async (path) => {
   vi.mocked(apiRequest).mockImplementation(async (path) => {

+ 8 - 2
admin-web/tests/unit/Points.test.tsx

@@ -31,8 +31,11 @@ beforeEach(() => {
   vi.mocked(apiRequest).mockImplementation(async (path) => {
   vi.mocked(apiRequest).mockImplementation(async (path) => {
     if (path.startsWith('/chats?')) {
     if (path.startsWith('/chats?')) {
       return {
       return {
-        items: [{ chat_id: '-100123', title: '积分测试群', type: 'supergroup', accessible: true }],
-        total: 1,
+        items: [
+          { chat_id: '-100999', title: '不可用积分群', type: 'supergroup', accessible: false },
+          { chat_id: '-100123', title: '积分测试群', type: 'supergroup', accessible: true },
+        ],
+        total: 2,
         page: 1,
         page: 1,
         page_size: 100,
         page_size: 100,
       };
       };
@@ -64,6 +67,9 @@ it('展示余额排行、流水并保存积分规则', async () => {
 
 
   expect(await screen.findByText('爱丽丝 示例')).toBeInTheDocument();
   expect(await screen.findByText('爱丽丝 示例')).toBeInTheDocument();
   expect(screen.queryByText('@alice')).not.toBeInTheDocument();
   expect(screen.queryByText('@alice')).not.toBeInTheDocument();
+  expect(
+    vi.mocked(apiRequest).mock.calls.some(([path]) => path.includes('chat_id=-100999')),
+  ).toBe(false);
   fireEvent.click(screen.getByRole('tab', { name: /流水/ }));
   fireEvent.click(screen.getByRole('tab', { name: /流水/ }));
   expect(await screen.findByText('Daily check-in')).toBeInTheDocument();
   expect(await screen.findByText('Daily check-in')).toBeInTheDocument();
 
 

+ 21 - 0
tests/test_admin_api_auth.py

@@ -65,6 +65,27 @@ async def test_login_forced_password_change_csrf_and_session(app_modules):
         assert dashboard.status == 200
         assert dashboard.status == 200
         assert "counts" in (await dashboard.json())["data"]
         assert "counts" in (await dashboard.json())["data"]
 
 
+        await app_modules.wbb.db.managed_chats.insert_one(
+            {
+                "bot_id": "primary",
+                "chat_id": -100,
+                "title": "机器人已退出的群",
+                "accessible": False,
+            }
+        )
+        delete_without_csrf = await client.delete("/api/admin/v1/chats/-100")
+        assert delete_without_csrf.status == 403
+        deleted = await client.delete(
+            "/api/admin/v1/chats/-100",
+            headers={"X-CSRF-Token": changed_data["csrf_token"]},
+        )
+        assert deleted.status == 200
+        assert (await deleted.json())["data"] == {
+            "chat_id": "-100",
+            "removed": True,
+        }
+        assert await app_modules.wbb.db.managed_chats.find_one({"chat_id": -100}) is None
+
         logout = await client.post(
         logout = await client.post(
             "/api/admin/v1/auth/logout",
             "/api/admin/v1/auth/logout",
             headers={"X-CSRF-Token": changed_data["csrf_token"]},
             headers={"X-CSRF-Token": changed_data["csrf_token"]},

+ 4 - 0
tests/test_bot_config.py

@@ -140,6 +140,10 @@ def test_role_permissions_gate_telegram_modules(app_modules):
     assert permissions.TELEGRAM_COMMAND_PERMISSIONS["ban"] == "chat.members"
     assert permissions.TELEGRAM_COMMAND_PERMISSIONS["ban"] == "chat.members"
     assert permissions.TELEGRAM_COMMAND_PERMISSIONS["pin"] == "chat.announcements"
     assert permissions.TELEGRAM_COMMAND_PERMISSIONS["pin"] == "chat.announcements"
     assert permissions.TELEGRAM_COMMAND_PERMISSIONS["invite"] == "chat.invites"
     assert permissions.TELEGRAM_COMMAND_PERMISSIONS["invite"] == "chat.invites"
+    assert (
+        permissions.api_permission("DELETE", "/api/admin/v1/chats/-100123")
+        == "chat.profile"
+    )
 
 
 
 
 def test_supervisor_assigns_each_worker_an_isolated_database(app_modules, tmp_path):
 def test_supervisor_assigns_each_worker_an_isolated_database(app_modules, tmp_path):

+ 54 - 0
tests/test_chat_management.py

@@ -20,6 +20,60 @@ def chat_overview() -> dict[str, object]:
     }
     }
 
 
 
 
+async def test_remove_unavailable_chat_only_deletes_management_index(app_modules):
+    service = app_modules.load("wbb.services.chat_management")
+    await app_modules.wbb.db.managed_chats.insert_one(
+        {
+            "bot_id": "primary",
+            "chat_id": -100,
+            "title": "机器人已退出的群",
+            "accessible": False,
+        }
+    )
+    await app_modules.wbb.db.managed_chat_settings.insert_one(
+        {"bot_id": "primary", "chat_id": -100, "welcome_enabled": True}
+    )
+
+    assert await service.remove_unavailable_chat(-100) == {
+        "chat_id": "-100",
+        "removed": True,
+    }
+    assert await app_modules.wbb.db.managed_chats.find_one({"chat_id": -100}) is None
+    assert (
+        await app_modules.wbb.db.managed_chat_settings.find_one({"chat_id": -100})
+        is not None
+    )
+
+
+async def test_remove_unavailable_chat_rejects_accessible_chat(app_modules):
+    service = app_modules.load("wbb.services.chat_management")
+    await app_modules.wbb.db.managed_chats.insert_one(
+        {
+            "bot_id": "primary",
+            "chat_id": -100,
+            "title": "仍可管理的群",
+            "accessible": True,
+        }
+    )
+
+    with pytest.raises(service.ChatManagementError) as caught:
+        await service.remove_unavailable_chat(-100)
+
+    assert caught.value.code == "chat_still_accessible"
+    assert caught.value.status == 409
+    assert await app_modules.wbb.db.managed_chats.find_one({"chat_id": -100})
+
+
+async def test_remove_unavailable_chat_reports_missing_record(app_modules):
+    service = app_modules.load("wbb.services.chat_management")
+
+    with pytest.raises(service.ChatManagementError) as caught:
+        await service.remove_unavailable_chat(-100)
+
+    assert caught.value.code == "chat_not_found"
+    assert caught.value.status == 404
+
+
 async def test_profile_update_does_not_refresh_after_success(app_modules, monkeypatch):
 async def test_profile_update_does_not_refresh_after_success(app_modules, monkeypatch):
     service = app_modules.load("wbb.services.chat_management")
     service = app_modules.load("wbb.services.chat_management")
     ensure_permission = AsyncMock()
     ensure_permission = AsyncMock()

+ 12 - 0
wbb/admin/api.py

@@ -59,6 +59,7 @@ from wbb.services.chat_management import (
     list_accessible_chats,
     list_accessible_chats,
     list_chat_admins,
     list_chat_admins,
     list_recent_members,
     list_recent_members,
+    remove_unavailable_chat,
     revoke_invite_link,
     revoke_invite_link,
     search_chat_members,
     search_chat_members,
     send_announcement,
     send_announcement,
@@ -812,6 +813,7 @@ class AdminApi:
 
 
         router.add_get(f"{API_PREFIX}/chats", self.chats)
         router.add_get(f"{API_PREFIX}/chats", self.chats)
         router.add_get(f"{API_PREFIX}/chats/{{chat_id}}", self.chat)
         router.add_get(f"{API_PREFIX}/chats/{{chat_id}}", self.chat)
+        router.add_delete(f"{API_PREFIX}/chats/{{chat_id}}", self.chat_delete)
         router.add_patch(f"{API_PREFIX}/chats/{{chat_id}}/profile", self.chat_profile)
         router.add_patch(f"{API_PREFIX}/chats/{{chat_id}}/profile", self.chat_profile)
         router.add_put(f"{API_PREFIX}/chats/{{chat_id}}/permissions", self.chat_permissions)
         router.add_put(f"{API_PREFIX}/chats/{{chat_id}}/permissions", self.chat_permissions)
         router.add_post(f"{API_PREFIX}/chats/{{chat_id}}/announcements", self.announcement)
         router.add_post(f"{API_PREFIX}/chats/{{chat_id}}/announcements", self.announcement)
@@ -2120,6 +2122,16 @@ class AdminApi:
     async def chat(self, request: web.Request) -> web.Response:
     async def chat(self, request: web.Request) -> web.Response:
         return success(await get_chat_overview(chat_id_param(request)))
         return success(await get_chat_overview(chat_id_param(request)))
 
 
+    async def chat_delete(self, request: web.Request) -> web.Response:
+        chat_id = chat_id_param(request)
+        set_audit(
+            request,
+            "chat.record.remove",
+            chat_id=chat_id,
+            summary="Remove unavailable chat from management list",
+        )
+        return success(await remove_unavailable_chat(chat_id))
+
     async def chat_profile(self, request: web.Request) -> web.Response:
     async def chat_profile(self, request: web.Request) -> web.Response:
         chat_id = chat_id_param(request)
         chat_id = chat_id_param(request)
         body = await json_body(request)
         body = await json_body(request)

+ 5 - 0
wbb/services/bot_permissions.py

@@ -243,6 +243,11 @@ def api_permission(method: str, path: str) -> str | None:
         return "giveaways.manage"
         return "giveaways.manage"
     if not path.startswith("/api/admin/v1/chats/"):
     if not path.startswith("/api/admin/v1/chats/"):
         return None
         return None
+    if (
+        method == "DELETE"
+        and path.removeprefix("/api/admin/v1/chats/").lstrip("-").isdigit()
+    ):
+        return "chat.profile"
     if path.endswith("/profile"):
     if path.endswith("/profile"):
         return "chat.profile"
         return "chat.profile"
     if path.endswith("/permissions"):
     if path.endswith("/permissions"):

+ 18 - 0
wbb/services/chat_management.py

@@ -17,9 +17,11 @@ from wbb.services.blacklist_enforcement import (
 )
 )
 from wbb.services.member_identity import observe_member_identity
 from wbb.services.member_identity import observe_member_identity
 from wbb.utils.dbadmin import (
 from wbb.utils.dbadmin import (
+    delete_unavailable_managed_chat,
     get_managed_chat_settings,
     get_managed_chat_settings,
     list_managed_chats,
     list_managed_chats,
     list_stored_invite_links,
     list_stored_invite_links,
+    managed_chat_exists,
     mark_managed_chat_unavailable,
     mark_managed_chat_unavailable,
     revoke_stored_invite_link,
     revoke_stored_invite_link,
     store_invite_link,
     store_invite_link,
@@ -219,6 +221,22 @@ async def list_accessible_chats(
     return visible[start : start + page_size], len(visible)
     return visible[start : start + page_size], len(visible)
 
 
 
 
+async def remove_unavailable_chat(chat_id: int) -> dict[str, Any]:
+    if await delete_unavailable_managed_chat(chat_id):
+        return {"chat_id": str(chat_id), "removed": True}
+    if not await managed_chat_exists(chat_id):
+        raise ChatManagementError(
+            "chat_not_found",
+            "群记录不存在或已被移除。",
+            status=404,
+        )
+    raise ChatManagementError(
+        "chat_still_accessible",
+        "该群当前仍可用,不能从管理列表移除。",
+        status=409,
+    )
+
+
 async def ensure_permission(
 async def ensure_permission(
     chat_id: int,
     chat_id: int,
     permission: str,
     permission: str,

+ 23 - 0
wbb/utils/dbadmin.py

@@ -249,6 +249,29 @@ async def mark_managed_chat_unavailable(chat_id: int, reason: str) -> None:
     )
     )
 
 
 
 
+async def delete_unavailable_managed_chat(chat_id: int) -> bool:
+    """Remove an unavailable chat from this bot's management index only."""
+    await ensure_admin_indexes()
+    result = await managed_chatsdb.delete_one(
+        {
+            "bot_id": BOT_PROFILE_ID,
+            "chat_id": int(chat_id),
+            "accessible": False,
+        }
+    )
+    return result.deleted_count == 1
+
+
+async def managed_chat_exists(chat_id: int) -> bool:
+    await ensure_admin_indexes()
+    return (
+        await managed_chatsdb.find_one(
+            {"bot_id": BOT_PROFILE_ID, "chat_id": int(chat_id)}, {"_id": 1}
+        )
+        is not None
+    )
+
+
 async def list_managed_chats(
 async def list_managed_chats(
     *, query: str = "", page: int = 1, page_size: int = 20
     *, query: str = "", page: int = 1, page_size: int = 20
 ) -> tuple[list[dict[str, Any]], int]:
 ) -> tuple[list[dict[str, Any]], int]: