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 @@
- 是否将删除 字段是由后端调用目标站账号测试接口的结果判定。只有测试明确失败(非正常响应)或 404 的账号,才会被允许清理。
- “确认清理”时,后端会重新测试 and 验证所有账号状态,只清理最终判定为失效的项。无 SmartUp 导入备注的手工账号不会被处理。
+ 系统已自动分析出三类异常账号候选:
+ • 手动删除残留:下游账号已被手动删除,但 SmartUp 本地仍记录着导入关联。
+ • 已归档 Key 关联:本地上游 Key 已归档(orphaned),但其关联的下游账号仍存在。
+ • 关联不一致:下游账号备注引用了不存在的或关联不一致的本地 Key。
+ 「确认清理」时将根据处理动作自动执行清标或删除,无需手动判断。