Improve abnormal account cleanup

This commit is contained in:
SmartUp Developer
2026-07-06 13:48:41 +08:00
parent c26bdb766c
commit a62ee00115
6 changed files with 255 additions and 70 deletions
+121 -38
View File
@@ -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,
+1
View File
@@ -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 = ""
+101 -17
View File
@@ -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"