diff --git a/backend/app/routers/websites.py b/backend/app/routers/websites.py index 82f2c2d..12892fd 100644 --- a/backend/app/routers/websites.py +++ b/backend/app/routers/websites.py @@ -919,9 +919,37 @@ def _get_cleanup_candidates(db: Session, website: Website, c: Sub2ApiWebsiteClie if aid: remote_accounts_map[aid] = acc - candidates = {} + candidates: dict[str, dict[str, Any]] = {} - # 2. Condition A: Local orphaned keys + # 2. Category: missing_remote_account + # Local keys that still reference an imported_account_id, + # but that account no longer appears in the remote account list. + imported_keys = db.query(UpstreamGeneratedKey).filter( + UpstreamGeneratedKey.imported_website_id == website.id, + UpstreamGeneratedKey.imported_account_id.isnot(None), + ).all() + + for key in imported_keys: + acc_id = key.imported_account_id + if acc_id in candidates: + continue + if acc_id in remote_accounts_map: + continue # account still exists remotely — handled by other categories + + upstream = db.query(Upstream).filter(Upstream.id == key.upstream_id).first() + candidates[acc_id] = { + "account_id": acc_id, + "account_name": key.key_name, + "upstream_key_id": key.id, + "upstream_name": upstream.name if upstream else None, + "source_group_name": key.group_name, + "reason": "目标账号已被手动删除", + "cleanup_action": "clear_local_marker", + "db_key": key, + } + + # 3. Category: local_orphan_account + # Local keys with status=orphaned that still carry an imported_account_id. orphaned_keys = db.query(UpstreamGeneratedKey).filter( UpstreamGeneratedKey.status == "orphaned", UpstreamGeneratedKey.imported_website_id == website.id, @@ -935,18 +963,41 @@ def _get_cleanup_candidates(db: Session, website: Website, c: Sub2ApiWebsiteClie 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, - } + if acc_id in candidates: + # Already captured as missing_remote_account; update reason but keep action. + candidates[acc_id]["reason"] = "本地上游 Key 已归档,且目标账号已被手动删除" + candidates[acc_id]["db_key"] = key + candidates[acc_id]["upstream_key_id"] = key.id + candidates[acc_id]["upstream_name"] = upstream_name + candidates[acc_id]["source_group_name"] = key.group_name + continue - # 3. Condition B: Remote SmartUp-marked orphan accounts + if remote_acc: + 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 已归档", + "cleanup_action": "delete_remote_and_clear_marker", + "db_key": key, + } + else: + 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 已归档,且目标账号已被手动删除", + "cleanup_action": "clear_local_marker", + "db_key": key, + } + + # 4. Category: remote_orphan_account + # Remote accounts whose notes contain "Imported by SmartUp from upstream key #…" + # but the local key is missing, inconsistent, or orphaned. pattern = re.compile(r"Imported by SmartUp from upstream key #(\d+)") for aid, acc in remote_accounts_map.items(): if aid in candidates: @@ -971,13 +1022,13 @@ def _get_cleanup_candidates(db: Session, website: Website, c: Sub2ApiWebsiteClie reason = "" if not key: is_orphan = True - reason = "本地找不到对应的上游 Key 记录" + reason = "本地找不到对应 Key" elif key.imported_website_id != website.id or key.imported_account_id != aid: is_orphan = True - reason = "本地 Key 记录存在,但未关联到当前目标站的当前账号" + reason = "本地 Key 与目标账号关联不一致" elif key.status == 'orphaned': is_orphan = True - reason = "本地上游 Key 已被标记为 orphaned" + reason = "本地上游 Key 已归档" if is_orphan: upstream_name = None @@ -994,7 +1045,7 @@ def _get_cleanup_candidates(db: Session, website: Website, c: Sub2ApiWebsiteClie "upstream_name": upstream_name, "source_group_name": source_group_name, "reason": reason, - "is_local_orphan": False, + "cleanup_action": "delete_remote_account", "db_key": key, } @@ -1002,7 +1053,9 @@ def _get_cleanup_candidates(db: Session, website: Website, c: Sub2ApiWebsiteClie def _test_candidate_account(c: Sub2ApiWebsiteClient, account_id: str) -> tuple[str, bool, str]: - """返回 (test_status, cleanup_allowed, message)""" + """返回 (test_status, cleanup_allowed, message)。 + 保留测试用于信息展示;cleanup_allowed 由 cleanup_action 决定,不依赖测试结果。 + """ try: res = c.test_account(account_id) status = res.get("status") @@ -1019,6 +1072,16 @@ def _test_candidate_account(c: Sub2ApiWebsiteClient, account_id: str) -> tuple[s return "test_unknown", False, f"测试时发生异常: {e}" +def _clear_local_import_marker(key: UpstreamGeneratedKey, db: Session) -> None: + """清空单条 Key 上的 SmartUp 导入标记字段。""" + 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) + + @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() @@ -1036,7 +1099,19 @@ def preview_cleanup_invalid_accounts(wid: int, db: Session = Depends(get_db), _= items = [] for aid, cand in candidates.items(): - test_status, cleanup_allowed, test_msg = _test_candidate_account(c, aid) + action = cand.get("cleanup_action", "") + + # For clear_local_marker we skip the remote test (not needed). + # For delete actions we still test for informational display. + if action == "clear_local_marker": + test_status = "not_needed" + test_msg = "无需远端测试,直接清除本地标记" + else: + test_status, _, test_msg = _test_candidate_account(c, aid) + + # All candidates are actionable. + cleanup_allowed = True + items.append(CleanupInvalidAccountsItem( account_id=cand["account_id"], account_name=cand["account_name"], @@ -1046,13 +1121,14 @@ def preview_cleanup_invalid_accounts(wid: int, db: Session = Depends(get_db), _= reason=cand["reason"], test_status=test_status, cleanup_allowed=cleanup_allowed, + cleanup_action=action, action_status="previewed", message=test_msg, )) return CleanupInvalidAccountsPreviewResponse( success=True, - message=f"已成功加载 {len(items)} 个失效账号候选对象", + message=f"已成功加载 {len(items)} 个异常账号候选对象", items=items ) @@ -1074,12 +1150,21 @@ def execute_cleanup_invalid_accounts(wid: int, db: Session = Depends(get_db), _= items = [] for aid, cand in candidates.items(): - test_status, cleanup_allowed, test_msg = _test_candidate_account(c, aid) - + action = cand.get("cleanup_action", "") action_status = "skipped" - msg = "测试通过或未知,已跳过删除" + msg = "未识别的清理动作,已跳过" - if cleanup_allowed: + if action == "clear_local_marker": + key = cand.get("db_key") + if key: + _clear_local_import_marker(key, db) + action_status = "cleared" + msg = "已清除本地导入标记" + else: + action_status = "failed" + msg = "清除本地标记失败:找不到对应的 Key 记录" + + elif action in ("delete_remote_account", "delete_remote_and_clear_marker"): try: del_res = c.delete_account(aid) status = del_res.get("status") @@ -1089,23 +1174,17 @@ def execute_cleanup_invalid_accounts(wid: int, db: Session = Depends(get_db), _= 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) + if action == "delete_remote_and_clear_marker": + key = cand.get("db_key") + if key: + _clear_local_import_marker(key, db) + msg += ",已清除本地导入标记" 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"], @@ -1114,20 +1193,24 @@ def execute_cleanup_invalid_accounts(wid: int, db: Session = Depends(get_db), _= upstream_name=cand["upstream_name"], source_group_name=cand["source_group_name"], reason=cand["reason"], - test_status=test_status, - cleanup_allowed=cleanup_allowed, + test_status="executed", + cleanup_allowed=True, + cleanup_action=action, action_status=action_status, message=msg, )) db.commit() + cleared_count = sum(1 for item in items if item.action_status == "cleared") deleted_count = sum(1 for item in items if item.action_status in ("deleted", "already_deleted")) failed_count = sum(1 for item in items if item.action_status == "failed") skipped_count = sum(1 for item in items if item.action_status == "skipped") success = (failed_count == 0) msg_parts = [] + if cleared_count: + msg_parts.append(f"清除本地标记 {cleared_count} 个") if deleted_count: msg_parts.append(f"成功清理 {deleted_count} 个") if failed_count: @@ -1135,7 +1218,7 @@ def execute_cleanup_invalid_accounts(wid: int, db: Session = Depends(get_db), _= if skipped_count: msg_parts.append(f"跳过 {skipped_count} 个") - message = "清理执行完毕:" + ",".join(msg_parts) if msg_parts else "没有需要清理的失效账号" + message = "清理执行完毕:" + ",".join(msg_parts) if msg_parts else "没有需要清理的异常账号" return CleanupInvalidAccountsExecuteResponse( success=success, diff --git a/backend/app/schemas/website.py b/backend/app/schemas/website.py index 5c53a88..bc702fb 100644 --- a/backend/app/schemas/website.py +++ b/backend/app/schemas/website.py @@ -254,6 +254,7 @@ class CleanupInvalidAccountsItem(BaseModel): reason: str test_status: str cleanup_allowed: bool + cleanup_action: Optional[str] = None # clear_local_marker | delete_remote_account | delete_remote_and_clear_marker action_status: Optional[str] = None message: str = "" diff --git a/backend/test_cleanup_invalid_accounts.py b/backend/test_cleanup_invalid_accounts.py index 531a224..5757725 100644 --- a/backend/test_cleanup_invalid_accounts.py +++ b/backend/test_cleanup_invalid_accounts.py @@ -239,50 +239,44 @@ def test_cleanup_invalid_preview_and_execute(db_session, monkeypatch): 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.cleanup_allowed is True assert item_k2.test_status == "test_passed" + assert item_k2.cleanup_action == "delete_remote_and_clear_marker" 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 item_orphan.cleanup_action == "delete_remote_account" # 确认没有调用过删除 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 "成功清理 2 个" in execute_res.message - assert "跳过 1 个" in execute_res.message + assert "成功清理 3 个" in execute_res.message - # 验证是否重新跑了测试 - 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 + # 验证被删除的账号(所有候选都按 cleanup_action 执行删除) + assert len(deleted_accounts) == 3 assert "acc-k1" in deleted_accounts + assert "acc-k2" 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 + # k1 应该被清空 imported 字段(delete_remote_and_clear_marker),但保留 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" + # k2 也是 delete_remote_and_clear_marker,删除成功后清空导入标记 + assert k2.imported_website_id is None + assert k2.imported_account_id is None assert k2.status == "orphaned" @@ -317,3 +311,93 @@ def test_cleanup_list_accounts_failure(db_session, monkeypatch): assert execute_res.success is False assert "拉取远端账号列表失败" in execute_res.message assert len(execute_res.items) == 0 + + +def test_cleanup_missing_remote_account_clear_local_marker(db_session, monkeypatch): + """status=imported 且本地有 imported_account_id,但远端账号列表已不存在该账号 + → 走 clear_local_marker,不调用 delete_account,只清 SmartUp 本地导入标记。 + """ + w = Website( + name="W-clear", + site_type="sub2api", + base_url="http://w-clear", + enabled=True, + auth_config_json="{}", + timeout_seconds=30 + ) + u = Upstream(name="U-clear", base_url="http://u-clear") + db_session.add_all([w, u]) + db_session.commit() + db_session.refresh(w) + db_session.refresh(u) + + # 正常 imported 的 Key,关联到远端账号 acc-gone + k = UpstreamGeneratedKey( + upstream_id=u.id, + group_id="G-clear", + group_name="G-Clear-Name", + key_name="Key-Clear", + key_value="sk-clear", + status="imported", + imported_website_id=w.id, + imported_account_id="acc-gone", + imported_target_group_id="tg-1", + imported_target_group_name="Target Group 1", + ) + db_session.add(k) + db_session.commit() + + deleted_accounts: list[str] = [] + + class MockClient: + def __init__(self, **kwargs): pass + def __enter__(self): return self + def __exit__(self, *a): pass + + def list_accounts(self): + # 远端列表中不包含 acc-gone + return [ + {"id": "acc-other", "name": "Other-Acc", "notes": ""}, + ] + + def extract_id(self, val): + return val.get("id") if isinstance(val, dict) else str(val) + + def delete_account(self, account_id: str): + deleted_accounts.append(account_id) + return {"status": "deleted", "message": "deleted"} + + def test_account(self, account_id: str): + return {"status": "404", "cleanup_allowed": True, "message": "not found"} + + monkeypatch.setattr("app.routers.websites._client", lambda row: MockClient()) + + # ── Preview ── + preview_res = preview_cleanup_invalid_accounts(wid=w.id, db=db_session) + assert preview_res.success is True + assert len(preview_res.items) == 1 + + item = preview_res.items[0] + assert item.account_id == "acc-gone" + assert item.cleanup_action == "clear_local_marker" + assert item.cleanup_allowed is True + assert item.test_status == "not_needed" + assert item.reason == "目标账号已被手动删除" + + # ── Execute ── + execute_res = execute_cleanup_invalid_accounts(wid=w.id, db=db_session) + assert execute_res.success is True + assert "清除本地标记 1 个" in execute_res.message + + # 不应调用删除 + assert len(deleted_accounts) == 0 + + # 本地导入标记应被清空 + db_session.refresh(k) + assert k.imported_website_id is None + assert k.imported_account_id is None + assert k.imported_at is None + assert k.imported_target_group_id is None + assert k.imported_target_group_name is None + # status 不受影响 + assert k.status == "imported" diff --git a/frontend/src/api/index.ts b/frontend/src/api/index.ts index f8ade19..48e7f9f 100644 --- a/frontend/src/api/index.ts +++ b/frontend/src/api/index.ts @@ -361,6 +361,7 @@ export interface CleanupInvalidAccountsItem { reason: string test_status: string cleanup_allowed: boolean + cleanup_action: string | null // clear_local_marker | delete_remote_account | delete_remote_and_clear_marker action_status: string | null message: string } diff --git a/frontend/src/views/Upstreams.vue b/frontend/src/views/Upstreams.vue index 985eb13..56367b5 100644 --- a/frontend/src/views/Upstreams.vue +++ b/frontend/src/views/Upstreams.vue @@ -402,7 +402,7 @@ - + @@ -865,15 +871,16 @@
- 没有找到可以清理的失效或孤立账号。 + 没有找到可以清理的异常账号。
@@ -1956,7 +1963,7 @@ async function organizeWebsiteGroups() { if (!selectedWebsite.value) return try { await ElMessageBox.confirm( - '一键整理分组将根据当前的“分组管理”绑定关系,自动执行已导入账号的校验补齐,以及未导入账号的自动创建。确认执行?', + '一键整理分组将根据当前的「分组管理」绑定关系,自动执行已导入账号的校验补齐,以及未导入账号的自动创建。确认执行?', '一键整理分组', { type: 'info' } ) @@ -2005,7 +2012,7 @@ async function executeCleanup() { if (!selectedWebsite.value) return try { await ElMessageBox.confirm( - '确认清理失效账号?此操作将永久从目标站删除测试失败或不存在的账号,本地 orphaned 键将清空导入关联。', + '确认清理异常账号?此操作将按预览中的处理动作执行:清除本地标记仅清 SmartUp 导入关联(不调远端接口),删除下游账号/删除并清标记会永久从目标站删除对应账号(即使远端测试通过也会删除)。', '确认清理', { type: 'warning' } )