From 536c0aa3d498c254d49847c6cec10bf6148be1d7 Mon Sep 17 00:00:00 2001 From: liumangmang Date: Thu, 2 Jul 2026 00:19:26 +0800 Subject: [PATCH] =?UTF-8?q?feat:=20=E5=AE=9E=E7=8E=B0=E6=B8=85=E7=90=86?= =?UTF-8?q?=E5=A4=B1=E6=95=88=E8=B4=A6=E5=8F=B7=E5=AE=89=E5=85=A8=E6=96=B9?= =?UTF-8?q?=E6=A1=88=EF=BC=88=E5=8C=85=E5=90=AB=E9=A2=84=E8=A7=88=E3=80=81?= =?UTF-8?q?SSE=E6=B5=8B=E8=AF=95=E3=80=81=E5=AE=89=E5=85=A8=E5=88=A0?= =?UTF-8?q?=E9=99=A4=E4=BB=A5=E5=8F=8A=E6=9C=AC=E5=9C=B0=E6=A0=87=E8=AE=B0?= =?UTF-8?q?=E6=B8=85=E7=A9=BA=EF=BC=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- backend/app/routers/websites.py | 231 +++++++++++++++++- backend/app/schemas/website.py | 25 ++ backend/app/services/website_client.py | 78 +++++- backend/test_cleanup_invalid_accounts.py | 291 +++++++++++++++++++++++ frontend/src/api/index.ts | 17 ++ frontend/src/views/Websites.vue | 159 +++++++++++++ 6 files changed, 798 insertions(+), 3 deletions(-) create mode 100644 backend/test_cleanup_invalid_accounts.py diff --git a/backend/app/routers/websites.py b/backend/app/routers/websites.py index 1c9e6fc..ff349d9 100644 --- a/backend/app/routers/websites.py +++ b/backend/app/routers/websites.py @@ -38,6 +38,9 @@ from app.schemas.website import ( WebsiteBatchSyncResponse, OrganizeGroupsItem, OrganizeGroupsResponse, + CleanupInvalidAccountsItem, + CleanupInvalidAccountsPreviewResponse, + CleanupInvalidAccountsExecuteResponse, ) from app.services.website_client import Sub2ApiWebsiteClient, _extract_id @@ -865,6 +868,230 @@ def import_upstream_keys_as_accounts( ) +def _get_cleanup_candidates(db: Session, website: Website, c: Sub2ApiWebsiteClient) -> dict[str, dict[str, Any]] | None: + import re + # 1. Fetch remote accounts + remote_accounts = c.list_accounts() + if remote_accounts is None: + return None + + remote_accounts_map = {} + for acc in remote_accounts: + aid = c.extract_id(acc) + if aid: + remote_accounts_map[aid] = acc + + candidates = {} + + # 2. Condition A: Local orphaned keys + orphaned_keys = db.query(UpstreamGeneratedKey).filter( + UpstreamGeneratedKey.status == "orphaned", + UpstreamGeneratedKey.imported_website_id == website.id, + UpstreamGeneratedKey.imported_account_id.isnot(None), + ).all() + + for key in orphaned_keys: + acc_id = key.imported_account_id + remote_acc = remote_accounts_map.get(acc_id) + acc_name = remote_acc.get("name") if remote_acc else key.key_name + upstream = db.query(Upstream).filter(Upstream.id == key.upstream_id).first() + upstream_name = upstream.name if upstream else None + + candidates[acc_id] = { + "account_id": acc_id, + "account_name": acc_name, + "upstream_key_id": key.id, + "upstream_name": upstream_name, + "source_group_name": key.group_name, + "reason": "本地上游 Key 已被标记为 orphaned", + "is_local_orphan": True, + "db_key": key, + } + + # 3. Condition B: Remote SmartUp-marked orphan accounts + pattern = re.compile(r"Imported by SmartUp from upstream key #(\d+)") + for aid, acc in remote_accounts_map.items(): + if aid in candidates: + continue + + key_id = None + for field in ("notes", "description", "remark"): + val = acc.get(field) + if val and isinstance(val, str): + match = pattern.search(val) + if match: + key_id = int(match.group(1)) + break + if key_id is None: + continue + + key = db.query(UpstreamGeneratedKey).filter( + UpstreamGeneratedKey.id == key_id + ).first() + + is_orphan = False + reason = "" + if not key: + is_orphan = True + reason = "本地找不到对应的上游 Key 记录" + elif key.imported_website_id != website.id or key.imported_account_id != aid: + is_orphan = True + reason = "本地 Key 记录存在,但未关联到当前目标站的当前账号" + elif key.status == 'orphaned': + is_orphan = True + reason = "本地上游 Key 已被标记为 orphaned" + + if is_orphan: + upstream_name = None + source_group_name = None + if key: + upstream = db.query(Upstream).filter(Upstream.id == key.upstream_id).first() + upstream_name = upstream.name if upstream else None + source_group_name = key.group_name + + candidates[aid] = { + "account_id": aid, + "account_name": acc.get("name") or aid, + "upstream_key_id": key_id, + "upstream_name": upstream_name, + "source_group_name": source_group_name, + "reason": reason, + "is_local_orphan": False, + "db_key": key, + } + + return candidates + + +def _test_candidate_account(c: Sub2ApiWebsiteClient, account_id: str) -> tuple[str, bool, str]: + """返回 (test_status, cleanup_allowed, message)""" + try: + res = c.test_account(account_id) + status = res.get("status") + msg = res.get("message", "") + if status == "404": + return "404", True, msg + elif status == "success": + return "test_passed", False, msg + elif status == "failed": + return "failed", True, msg + else: + return "test_unknown", False, msg + except Exception as e: + return "test_unknown", False, f"测试时发生异常: {e}" + + +@router.post("/api/websites/{wid}/accounts/cleanup-invalid/preview", response_model=CleanupInvalidAccountsPreviewResponse) +def preview_cleanup_invalid_accounts(wid: int, db: Session = Depends(get_db), _=Depends(get_current_user)): + website = db.query(Website).filter(Website.id == wid).first() + if not website: + raise HTTPException(404, "website not found") + + with _client(website) as c: + candidates = _get_cleanup_candidates(db, website, c) + if candidates is None: + return CleanupInvalidAccountsPreviewResponse( + success=False, + message="拉取远端账号列表失败,无法执行清理", + items=[] + ) + + items = [] + for aid, cand in candidates.items(): + test_status, cleanup_allowed, test_msg = _test_candidate_account(c, aid) + items.append(CleanupInvalidAccountsItem( + account_id=cand["account_id"], + account_name=cand["account_name"], + upstream_key_id=cand["upstream_key_id"], + upstream_name=cand["upstream_name"], + source_group_name=cand["source_group_name"], + reason=cand["reason"], + test_status=test_status, + cleanup_allowed=cleanup_allowed, + action_status="previewed", + message=test_msg, + )) + + return CleanupInvalidAccountsPreviewResponse( + success=True, + message=f"已成功加载 {len(items)} 个失效账号候选对象", + items=items + ) + + +@router.post("/api/websites/{wid}/accounts/cleanup-invalid/execute", response_model=CleanupInvalidAccountsExecuteResponse) +def execute_cleanup_invalid_accounts(wid: int, db: Session = Depends(get_db), _=Depends(get_current_user)): + website = db.query(Website).filter(Website.id == wid).first() + if not website: + raise HTTPException(404, "website not found") + + with _client(website) as c: + candidates = _get_cleanup_candidates(db, website, c) + if candidates is None: + return CleanupInvalidAccountsExecuteResponse( + success=False, + message="拉取远端账号列表失败,无法执行清理", + items=[] + ) + + items = [] + for aid, cand in candidates.items(): + test_status, cleanup_allowed, test_msg = _test_candidate_account(c, aid) + + action_status = "skipped" + msg = "测试通过或未知,已跳过删除" + + if cleanup_allowed: + try: + del_res = c.delete_account(aid) + status = del_res.get("status") + del_msg = del_res.get("message", "") + + if status in ("deleted", "already_deleted"): + action_status = status + msg = del_msg + + # Clear database flags if it's a local orphan key + if cand["is_local_orphan"] and cand["db_key"]: + key = cand["db_key"] + key.imported_website_id = None + key.imported_account_id = None + key.imported_at = None + key.imported_target_group_id = None + key.imported_target_group_name = None + db.add(key) + else: + action_status = "failed" + msg = f"删除失败: {del_msg}" + except Exception as e: + action_status = "failed" + msg = f"删除失败: {e}" + else: + msg = f"账号状态正常,无需清理 ({test_msg})" + + items.append(CleanupInvalidAccountsItem( + account_id=cand["account_id"], + account_name=cand["account_name"], + upstream_key_id=cand["upstream_key_id"], + upstream_name=cand["upstream_name"], + source_group_name=cand["source_group_name"], + reason=cand["reason"], + test_status=test_status, + cleanup_allowed=cleanup_allowed, + action_status=action_status, + message=msg, + )) + + db.commit() + + deleted_count = sum(1 for item in items if item.action_status in ("deleted", "already_deleted")) + return CleanupInvalidAccountsExecuteResponse( + success=True, + message=f"清理执行完毕,成功处理了 {deleted_count} 个失效账号", + items=items + ) + + @router.post("/api/websites/{wid}/groups/organize", response_model=OrganizeGroupsResponse) def organize_website_groups( wid: int, @@ -1306,12 +1533,12 @@ def sync_website_group_bindings( success_count = sum(1 for r in results if r.status == "success") failed_count = sum(1 for r in results if r.status == "failed") skipped_count = sum(1 for r in results if r.status == "skipped") - + msg_parts = [] if success_count: msg_parts.append(f"成功 {success_count}") if failed_count: msg_parts.append(f"失败 {failed_count}") if skipped_count: msg_parts.append(f"跳过 {skipped_count}") - + return WebsiteBatchSyncResponse( total=len(bindings), success=success_count, diff --git a/backend/app/schemas/website.py b/backend/app/schemas/website.py index df3ab23..62731d9 100644 --- a/backend/app/schemas/website.py +++ b/backend/app/schemas/website.py @@ -243,3 +243,28 @@ class OrganizeGroupsResponse(BaseModel): success: bool message: str items: list[OrganizeGroupsItem] + + +class CleanupInvalidAccountsItem(BaseModel): + account_id: str + account_name: Optional[str] = None + upstream_key_id: Optional[int] = None + upstream_name: Optional[str] = None + source_group_name: Optional[str] = None + reason: str + test_status: str + cleanup_allowed: bool + action_status: Optional[str] = None + message: str = "" + + +class CleanupInvalidAccountsPreviewResponse(BaseModel): + success: bool + message: str + items: list[CleanupInvalidAccountsItem] + + +class CleanupInvalidAccountsExecuteResponse(BaseModel): + success: bool + message: str + items: list[CleanupInvalidAccountsItem] diff --git a/backend/app/services/website_client.py b/backend/app/services/website_client.py index 4492d38..a1cd862 100644 --- a/backend/app/services/website_client.py +++ b/backend/app/services/website_client.py @@ -345,7 +345,7 @@ class Sub2ApiWebsiteClient: def account_exists(self, account_id: str, endpoint: str = "/accounts") -> bool | None: """检查目标账号是否存在。 - + 优先拉取账号列表判断: - 列表成功取到 → return account_id in ids(True=存在,False=已删除) - 列表取不到(None)→ return None(校验失败,不清本地) @@ -357,6 +357,82 @@ class Sub2ApiWebsiteClient: return None return account_id in ids + def test_account(self, account_id: str, endpoint: str = "/accounts") -> dict[str, Any]: + """测试远端账号可用性。利用 SSE 监听 test_complete 事件的 success 状态。 + 若 404,返回 {"status": "404", "cleanup_allowed": True, "message": "账号不存在"} + 若 timeout / 无法建立连接,返回 {"status": "timeout"/"error", "cleanup_allowed": False} + """ + import json + url = self._url(f"{endpoint}/{account_id}/test") + headers = self._headers() + # Accept text/event-stream since it's an SSE stream + headers["Accept"] = "text/event-stream" + + try: + with self._client.stream("POST", url, headers=headers) as response: + if response.status_code == 404: + return {"status": "404", "cleanup_allowed": True, "message": "账号在远端不存在 (404)"} + if response.status_code >= 400: + return {"status": "http_error", "cleanup_allowed": False, "message": f"测试接口返回 HTTP {response.status_code}"} + + # Read SSE stream line by line + current_event = None + success_value = None + has_error_event = False + + for line in response.iter_lines(): + if not line: + continue + if line.startswith("event:"): + current_event = line[6:].strip() + elif line.startswith("data:"): + data_str = line[5:].strip() + if current_event == "error": + has_error_event = True + if current_event == "test_complete" or not current_event: + try: + parsed = json.loads(data_str) + if isinstance(parsed, dict): + if "success" in parsed: + success_value = parsed["success"] + elif parsed.get("event") == "test_complete" and "success" in parsed: + success_value = parsed["success"] + except Exception: + pass + + if success_value is True: + return {"status": "success", "cleanup_allowed": False, "message": "测试成功,账号有效"} + elif success_value is False: + return {"status": "failed", "cleanup_allowed": True, "message": "测试失败,账号失效"} + elif has_error_event: + return {"status": "error", "cleanup_allowed": False, "message": "测试接口返回了错误事件"} + else: + return {"status": "invalid_sse", "cleanup_allowed": False, "message": "未收到有效的测试完成事件"} + + except httpx.TimeoutException as exc: + return {"status": "timeout", "cleanup_allowed": False, "message": f"连接超时: {exc}"} + except Exception as exc: + return {"status": "error", "cleanup_allowed": False, "message": f"请求异常: {exc}"} + + def delete_account(self, account_id: str, endpoint: str = "/accounts") -> dict[str, Any]: + """删除目标账号。 + 若 404 视为 already_deleted。 + """ + url = self._url(f"{endpoint}/{account_id}") + headers = self._headers() + try: + resp = self._client.request("DELETE", url, headers=headers) + if resp.status_code == 404: + return {"status": "already_deleted", "message": "账号不存在或已在远端被删除"} + resp.raise_for_status() + return {"status": "deleted", "message": "账号已删除"} + except httpx.HTTPStatusError as exc: + if exc.response.status_code == 404: + return {"status": "already_deleted", "message": "账号不存在或已在远端被删除"} + raise WebsiteError(_friendly_http_error(exc)) from exc + except Exception as exc: + raise WebsiteError(f"删除账号失败: {exc}") from exc + @staticmethod def extract_id(value: Any) -> str: return _extract_id(value) diff --git a/backend/test_cleanup_invalid_accounts.py b/backend/test_cleanup_invalid_accounts.py new file mode 100644 index 0000000..1d312aa --- /dev/null +++ b/backend/test_cleanup_invalid_accounts.py @@ -0,0 +1,291 @@ +import json +import pytest +import httpx +from sqlalchemy import create_engine +from sqlalchemy.orm import sessionmaker +from sqlalchemy.pool import StaticPool + +from app.database import Base +from app.models.upstream import Upstream +from app.models.upstream_key import UpstreamGeneratedKey +from app.models.website import Website +from app.routers.websites import preview_cleanup_invalid_accounts, execute_cleanup_invalid_accounts +from app.services.website_client import Sub2ApiWebsiteClient + + +@pytest.fixture() +def db_session(): + engine = create_engine( + "sqlite://", + connect_args={"check_same_thread": False}, + poolclass=StaticPool, + ) + from app.models import admin_user, upstream, snapshot, webhook_config, notification_log, custom_page, website, revoked_token, upstream_key + Base.metadata.create_all(bind=engine) + TestingSessionLocal = sessionmaker(autocommit=False, autoflush=False, bind=engine) + db = TestingSessionLocal() + try: + yield db + finally: + db.close() + Base.metadata.drop_all(bind=engine) + + +class MockResponse: + def __init__(self, status_code, lines, raise_exc=None): + self.status_code = status_code + self._lines = lines + self._raise_exc = raise_exc + + def __enter__(self): + if self._raise_exc: + raise self._raise_exc + return self + + def __exit__(self, *args): + pass + + def iter_lines(self): + return self._lines + + +def mock_stream(status_code, lines, raise_exc=None): + def mock_fn(*args, **kwargs): + return MockResponse(status_code, lines, raise_exc) + return mock_fn + + +def test_website_client_test_account_sse_scenarios(monkeypatch): + """测试 Sub2ApiWebsiteClient.test_account 在各种 SSE 场景下的解析和处理。""" + c = Sub2ApiWebsiteClient( + base_url="http://w1", + api_prefix="api/v1", + auth_type="bearer", + auth_config={"token": "t1"}, + ) + + # 1. success=true + monkeypatch.setattr(c._client, "stream", mock_stream(200, ["event: test_complete", "data: {\"success\": true}"])) + res = c.test_account("acc1") + assert res["status"] == "success" + assert res["cleanup_allowed"] is False + + # 2. success=false + monkeypatch.setattr(c._client, "stream", mock_stream(200, ["event: test_complete", "data: {\"success\": false}"])) + res = c.test_account("acc1") + assert res["status"] == "failed" + assert res["cleanup_allowed"] is True + + # 3. HTTP 404 + monkeypatch.setattr(c._client, "stream", mock_stream(404, [])) + res = c.test_account("acc1") + assert res["status"] == "404" + assert res["cleanup_allowed"] is True + + # 4. error event + monkeypatch.setattr(c._client, "stream", mock_stream(200, ["event: error", "data: \"internal error\""])) + res = c.test_account("acc1") + assert res["status"] == "error" + assert res["cleanup_allowed"] is False + + # 5. 超时 + monkeypatch.setattr(c._client, "stream", mock_stream(200, [], raise_exc=httpx.TimeoutException("Connection timed out"))) + res = c.test_account("acc1") + assert res["status"] == "timeout" + assert res["cleanup_allowed"] is False + + # 6. 非法 SSE (没有 test_complete) + monkeypatch.setattr(c._client, "stream", mock_stream(200, ["data: hello world"])) + res = c.test_account("acc1") + assert res["status"] == "invalid_sse" + assert res["cleanup_allowed"] is False + + +def test_cleanup_invalid_preview_and_execute(db_session, monkeypatch): + """测试预览与执行清理的完整工作流。 + - 包含本地 orphaned 键。 + - 包含远端 SmartUp 备注孤立账号。 + - 包含无 SmartUp 备注的手工账号(不参与候选)。 + - 包含测试通过与失败的不同流程分支。 + """ + w = Website( + name="W1", + site_type="sub2api", + base_url="http://w1", + enabled=True, + auth_config_json="{}", + timeout_seconds=30 + ) + u1 = Upstream(name="U1", base_url="http://u1") + db_session.add_all([w, u1]) + db_session.commit() + db_session.refresh(w) + db_session.refresh(u1) + + # 1. 本地 orphaned 键 k1:对应的远端账号测试将失败 (可清理) + k1 = UpstreamGeneratedKey( + upstream_id=u1.id, + group_id="G1", + group_name="G1-Name", + key_name="Key-G1", + key_value="sk-g1", + status="orphaned", + imported_website_id=w.id, + imported_account_id="acc-k1", + error="original-error-k1" + ) + # 2. 本地 orphaned 键 k2:对应的远端账号测试将成功 (不可清理) + k2 = UpstreamGeneratedKey( + upstream_id=u1.id, + group_id="G2", + group_name="G2-Name", + key_name="Key-G2", + key_value="sk-g2", + status="orphaned", + imported_website_id=w.id, + imported_account_id="acc-k2", + error="original-error-k2" + ) + # 3. 本地 imported 正常键 k3(不进入候选) + k3 = UpstreamGeneratedKey( + upstream_id=u1.id, + group_id="G3", + group_name="G3-Name", + key_name="Key-G3", + key_value="sk-g3", + status="imported", + imported_website_id=w.id, + imported_account_id="acc-k3" + ) + db_session.add_all([k1, k2, k3]) + db_session.commit() + + deleted_accounts = [] + test_calls = {} + + class MockClient: + def __init__(self, **kwargs): pass + def __enter__(self): return self + def __exit__(self, *a): pass + + def list_accounts(self): + # 远端列表: + # acc-k1 (Condition A, k1) + # acc-k2 (Condition A, k2) + # acc-k3 (k3 正常) + # acc-orphan-remote (Condition B, notes 带备注,本地不存在关联) + # acc-manual (无备注手工账号,notes 没匹配上,不应进候选) + return [ + {"id": "acc-k1", "name": "Acc-K1", "notes": f"Imported by SmartUp from upstream key #{k1.id}"}, + {"id": "acc-k2", "name": "Acc-K2", "notes": f"Imported by SmartUp from upstream key #{k2.id}"}, + {"id": "acc-k3", "name": "Acc-K3", "notes": f"Imported by SmartUp from upstream key #{k3.id}"}, + {"id": "acc-orphan-remote", "name": "Orphan-Remote", "notes": "Imported by SmartUp from upstream key #9999"}, + {"id": "acc-manual", "name": "Manual-Acc", "notes": "Manually created account"}, + ] + + def extract_id(self, val): + return val.get("id") if isinstance(val, dict) else str(val) + + def test_account(self, account_id): + test_calls[account_id] = test_calls.get(account_id, 0) + 1 + if account_id in ("acc-k1", "acc-orphan-remote"): + # 测试失败 -> 可删除 + return {"status": "failed", "cleanup_allowed": True, "message": "API check failed"} + else: + # 其他测试成功 + return {"status": "success", "cleanup_allowed": False, "message": "API check OK"} + + def delete_account(self, account_id): + deleted_accounts.append(account_id) + return {"status": "deleted", "message": f"Account {account_id} has been deleted"} + + monkeypatch.setattr("app.routers.websites._client", lambda row: MockClient()) + + # ── 1. 测试预览 ── + preview_res = preview_cleanup_invalid_accounts(wid=w.id, db=db_session) + assert preview_res.success is True + # 候选账号应为 3 个:acc-k1, acc-k2, acc-orphan-remote (acc-k3 正常, acc-manual 无备注,均不进入候选) + items = preview_res.items + assert len(items) == 3 + + item_k1 = next(i for i in items if i.account_id == "acc-k1") + assert item_k1.cleanup_allowed is True + assert item_k1.test_status == "failed" + + item_k2 = next(i for i in items if i.account_id == "acc-k2") + assert item_k2.cleanup_allowed is False + assert item_k2.test_status == "test_passed" + + item_orphan = next(i for i in items if i.account_id == "acc-orphan-remote") + assert item_orphan.cleanup_allowed is True + assert item_orphan.test_status == "failed" + assert item_orphan.upstream_key_id == 9999 + + # 确认没有调用过删除 + assert len(deleted_accounts) == 0 + + # ── 2. 测试执行 ── + # 重置调用计数 + test_calls.clear() + execute_res = execute_cleanup_invalid_accounts(wid=w.id, db=db_session) + assert execute_res.success is True + + # 验证是否重新跑了测试 + assert "acc-k1" in test_calls + assert "acc-k2" in test_calls + assert "acc-orphan-remote" in test_calls + + # 验证被删除的账号 (只有测试失败/可清理的才删:acc-k1 和 acc-orphan-remote) + assert len(deleted_accounts) == 2 + assert "acc-k1" in deleted_accounts + assert "acc-orphan-remote" in deleted_accounts + assert "acc-k2" not in deleted_accounts + + # 验证本地数据库的 DB key 清空逻辑: + db_session.refresh(k1) + db_session.refresh(k2) + db_session.refresh(k3) + + # k1 应该被清空 imported 字段,但保留 status='orphaned' 及原始 error + assert k1.imported_website_id is None + assert k1.imported_account_id is None + assert k1.status == "orphaned" + assert k1.error == "original-error-k1" + + # k2 由于测试成功跳过删除,DB 不变 + assert k2.imported_website_id == w.id + assert k2.imported_account_id == "acc-k2" + assert k2.status == "orphaned" + + +def test_cleanup_list_accounts_failure(db_session, monkeypatch): + """测试 list_accounts() 失败时,整个清理预览与执行直接阻断报错。""" + w = Website( + name="W1", + site_type="sub2api", + base_url="http://w1", + enabled=True, + auth_config_json="{}", + timeout_seconds=30 + ) + db_session.add(w) + db_session.commit() + + class FailClient: + def __init__(self, **kwargs): pass + def __enter__(self): return self + def __exit__(self, *a): pass + def list_accounts(self): + return None # 模拟获取失败 + + monkeypatch.setattr("app.routers.websites._client", lambda row: FailClient()) + + preview_res = preview_cleanup_invalid_accounts(wid=w.id, db=db_session) + assert preview_res.success is False + assert "拉取远端账号列表失败" in preview_res.message + assert len(preview_res.items) == 0 + + execute_res = execute_cleanup_invalid_accounts(wid=w.id, db=db_session) + assert execute_res.success is False + assert "拉取远端账号列表失败" in execute_res.message + assert len(execute_res.items) == 0 diff --git a/frontend/src/api/index.ts b/frontend/src/api/index.ts index 60996e4..65e03a9 100644 --- a/frontend/src/api/index.ts +++ b/frontend/src/api/index.ts @@ -330,6 +330,19 @@ export interface OrganizeGroupsItem { message: string } +export interface CleanupInvalidAccountsItem { + account_id: string + account_name: string | null + upstream_key_id: number | null + upstream_name: string | null + source_group_name: string | null + reason: string + test_status: string + cleanup_allowed: boolean + action_status: string | null + message: string +} + export const websitesApi = { list: () => api.get('/api/websites'), create: (data: WebsiteForm) => api.post('/api/websites', data), @@ -357,6 +370,10 @@ export const websitesApi = { }) => api.post<{ success: boolean; message: string; items: ImportAccountItem[] }>(`/api/websites/${id}/accounts/import-upstream-keys`, data), organizeGroups: (id: number) => api.post<{ success: boolean; message: string; items: OrganizeGroupsItem[] }>(`/api/websites/${id}/groups/organize`), + cleanupPreview: (id: number) => + api.post<{ success: boolean; message: string; items: CleanupInvalidAccountsItem[] }>(`/api/websites/${id}/accounts/cleanup-invalid/preview`), + cleanupExecute: (id: number) => + api.post<{ success: boolean; message: string; items: CleanupInvalidAccountsItem[] }>(`/api/websites/${id}/accounts/cleanup-invalid/execute`), listBindings: () => api.get('/api/group-bindings'), createBinding: (data: GroupBindingForm) => api.post('/api/group-bindings', data), updateBinding: (id: number, data: Partial) => api.put(`/api/group-bindings/${id}`, data), diff --git a/frontend/src/views/Websites.vue b/frontend/src/views/Websites.vue index 12f6e7f..f564d77 100644 --- a/frontend/src/views/Websites.vue +++ b/frontend/src/views/Websites.vue @@ -124,6 +124,15 @@ > 一键整理分组 + + 清理失效账号 + 新增绑定
@@ -741,6 +750,97 @@ 关闭 + + + +
+ +
+ 系统已自动分析出可能已经失效、上游孤立或属于脏数据的账号候选。
+ 是否将删除 字段是由后端调用目标站账号测试接口的结果判定。只有测试明确失败(非正常响应)或 404 的账号,才会被允许清理。
+ “确认清理”时,后端会重新测试 and 验证所有账号状态,只清理最终判定为失效的项。无 SmartUp 导入备注的手工账号不会被处理。 +
+
+ + + + + + + + + + + + + + + + + + + + + + + + + +
+ 没有找到可以清理的失效或孤立账号。 +
+
+ +
@@ -765,6 +865,7 @@ import { type WebsiteGroup, type WebsiteSyncLog, type OrganizeGroupsItem, + type CleanupInvalidAccountsItem, } from '@/api' const websites = ref<(WebsiteData & { _testing?: boolean })[]>([]) @@ -914,6 +1015,12 @@ const organizeLoading = ref(false) const organizeResults = ref([]) const organizeMessage = ref('') +const cleanupDialog = ref(false) +const cleanupLoading = ref(false) +const cleanupExecuting = ref(false) +const cleanupHasExecuted = ref(false) +const cleanupItems = ref([]) + const upstreamGroupOptions = computed(() => { const rows: Array<{ key: string; label: string; rate: string | number; source: BindingSourceGroup }> = [] for (const upstream of upstreams.value) { @@ -1568,6 +1675,58 @@ async function organizeWebsiteGroups() { } } +async function openCleanupDialog() { + if (!selectedWebsite.value) return + cleanupItems.value = [] + cleanupHasExecuted.value = false + cleanupDialog.value = true + cleanupLoading.value = true + try { + const res = await websitesApi.cleanupPreview(selectedWebsite.value.id) + if (res.data.success) { + cleanupItems.value = res.data.items + } else { + ElMessage.error(res.data.message || '加载失效账号候选预览失败') + cleanupDialog.value = false + } + } catch (e: any) { + ElMessage.error(e.response?.data?.detail || '加载失效账号候选预览失败') + cleanupDialog.value = false + } finally { + cleanupLoading.value = false + } +} + +async function executeCleanup() { + if (!selectedWebsite.value) return + try { + await ElMessageBox.confirm( + '确认清理失效账号?此操作将永久从目标站删除测试失败或不存在的账号,本地 orphaned 键将清空导入关联。', + '确认清理', + { type: 'warning' } + ) + } catch { + return + } + + cleanupExecuting.value = true + try { + const res = await websitesApi.cleanupExecute(selectedWebsite.value.id) + if (res.data.success) { + cleanupItems.value = res.data.items + cleanupHasExecuted.value = true + ElMessage.success(res.data.message || '清理完成') + await Promise.all([loadLogs(), loadWebsiteGroups(), loadBindings()]) + } else { + ElMessage.error(res.data.message || '执行清理失败') + } + } catch (e: any) { + ElMessage.error(e.response?.data?.detail || '执行清理失败') + } finally { + cleanupExecuting.value = false + } +} + async function toggleBinding(row: GroupBindingData) { try { await websitesApi.updateBinding(row.id, { enabled: row.enabled })