Improve abnormal account cleanup
This commit is contained in:
+121
-38
@@ -919,9 +919,37 @@ def _get_cleanup_candidates(db: Session, website: Website, c: Sub2ApiWebsiteClie
|
|||||||
if aid:
|
if aid:
|
||||||
remote_accounts_map[aid] = acc
|
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(
|
orphaned_keys = db.query(UpstreamGeneratedKey).filter(
|
||||||
UpstreamGeneratedKey.status == "orphaned",
|
UpstreamGeneratedKey.status == "orphaned",
|
||||||
UpstreamGeneratedKey.imported_website_id == website.id,
|
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 = db.query(Upstream).filter(Upstream.id == key.upstream_id).first()
|
||||||
upstream_name = upstream.name if upstream else None
|
upstream_name = upstream.name if upstream else None
|
||||||
|
|
||||||
candidates[acc_id] = {
|
if acc_id in candidates:
|
||||||
"account_id": acc_id,
|
# Already captured as missing_remote_account; update reason but keep action.
|
||||||
"account_name": acc_name,
|
candidates[acc_id]["reason"] = "本地上游 Key 已归档,且目标账号已被手动删除"
|
||||||
"upstream_key_id": key.id,
|
candidates[acc_id]["db_key"] = key
|
||||||
"upstream_name": upstream_name,
|
candidates[acc_id]["upstream_key_id"] = key.id
|
||||||
"source_group_name": key.group_name,
|
candidates[acc_id]["upstream_name"] = upstream_name
|
||||||
"reason": "本地上游 Key 已被标记为 orphaned",
|
candidates[acc_id]["source_group_name"] = key.group_name
|
||||||
"is_local_orphan": True,
|
continue
|
||||||
"db_key": key,
|
|
||||||
}
|
|
||||||
|
|
||||||
# 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+)")
|
pattern = re.compile(r"Imported by SmartUp from upstream key #(\d+)")
|
||||||
for aid, acc in remote_accounts_map.items():
|
for aid, acc in remote_accounts_map.items():
|
||||||
if aid in candidates:
|
if aid in candidates:
|
||||||
@@ -971,13 +1022,13 @@ def _get_cleanup_candidates(db: Session, website: Website, c: Sub2ApiWebsiteClie
|
|||||||
reason = ""
|
reason = ""
|
||||||
if not key:
|
if not key:
|
||||||
is_orphan = True
|
is_orphan = True
|
||||||
reason = "本地找不到对应的上游 Key 记录"
|
reason = "本地找不到对应 Key"
|
||||||
elif key.imported_website_id != website.id or key.imported_account_id != aid:
|
elif key.imported_website_id != website.id or key.imported_account_id != aid:
|
||||||
is_orphan = True
|
is_orphan = True
|
||||||
reason = "本地 Key 记录存在,但未关联到当前目标站的当前账号"
|
reason = "本地 Key 与目标账号关联不一致"
|
||||||
elif key.status == 'orphaned':
|
elif key.status == 'orphaned':
|
||||||
is_orphan = True
|
is_orphan = True
|
||||||
reason = "本地上游 Key 已被标记为 orphaned"
|
reason = "本地上游 Key 已归档"
|
||||||
|
|
||||||
if is_orphan:
|
if is_orphan:
|
||||||
upstream_name = None
|
upstream_name = None
|
||||||
@@ -994,7 +1045,7 @@ def _get_cleanup_candidates(db: Session, website: Website, c: Sub2ApiWebsiteClie
|
|||||||
"upstream_name": upstream_name,
|
"upstream_name": upstream_name,
|
||||||
"source_group_name": source_group_name,
|
"source_group_name": source_group_name,
|
||||||
"reason": reason,
|
"reason": reason,
|
||||||
"is_local_orphan": False,
|
"cleanup_action": "delete_remote_account",
|
||||||
"db_key": key,
|
"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]:
|
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:
|
try:
|
||||||
res = c.test_account(account_id)
|
res = c.test_account(account_id)
|
||||||
status = res.get("status")
|
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}"
|
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)
|
@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)):
|
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()
|
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 = []
|
items = []
|
||||||
for aid, cand in candidates.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(
|
items.append(CleanupInvalidAccountsItem(
|
||||||
account_id=cand["account_id"],
|
account_id=cand["account_id"],
|
||||||
account_name=cand["account_name"],
|
account_name=cand["account_name"],
|
||||||
@@ -1046,13 +1121,14 @@ def preview_cleanup_invalid_accounts(wid: int, db: Session = Depends(get_db), _=
|
|||||||
reason=cand["reason"],
|
reason=cand["reason"],
|
||||||
test_status=test_status,
|
test_status=test_status,
|
||||||
cleanup_allowed=cleanup_allowed,
|
cleanup_allowed=cleanup_allowed,
|
||||||
|
cleanup_action=action,
|
||||||
action_status="previewed",
|
action_status="previewed",
|
||||||
message=test_msg,
|
message=test_msg,
|
||||||
))
|
))
|
||||||
|
|
||||||
return CleanupInvalidAccountsPreviewResponse(
|
return CleanupInvalidAccountsPreviewResponse(
|
||||||
success=True,
|
success=True,
|
||||||
message=f"已成功加载 {len(items)} 个失效账号候选对象",
|
message=f"已成功加载 {len(items)} 个异常账号候选对象",
|
||||||
items=items
|
items=items
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -1074,12 +1150,21 @@ def execute_cleanup_invalid_accounts(wid: int, db: Session = Depends(get_db), _=
|
|||||||
|
|
||||||
items = []
|
items = []
|
||||||
for aid, cand in candidates.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"
|
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:
|
try:
|
||||||
del_res = c.delete_account(aid)
|
del_res = c.delete_account(aid)
|
||||||
status = del_res.get("status")
|
status = del_res.get("status")
|
||||||
@@ -1089,23 +1174,17 @@ def execute_cleanup_invalid_accounts(wid: int, db: Session = Depends(get_db), _=
|
|||||||
action_status = status
|
action_status = status
|
||||||
msg = del_msg
|
msg = del_msg
|
||||||
|
|
||||||
# Clear database flags if it's a local orphan key
|
if action == "delete_remote_and_clear_marker":
|
||||||
if cand["is_local_orphan"] and cand["db_key"]:
|
key = cand.get("db_key")
|
||||||
key = cand["db_key"]
|
if key:
|
||||||
key.imported_website_id = None
|
_clear_local_import_marker(key, db)
|
||||||
key.imported_account_id = None
|
msg += ",已清除本地导入标记"
|
||||||
key.imported_at = None
|
|
||||||
key.imported_target_group_id = None
|
|
||||||
key.imported_target_group_name = None
|
|
||||||
db.add(key)
|
|
||||||
else:
|
else:
|
||||||
action_status = "failed"
|
action_status = "failed"
|
||||||
msg = f"删除失败: {del_msg}"
|
msg = f"删除失败: {del_msg}"
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
action_status = "failed"
|
action_status = "failed"
|
||||||
msg = f"删除失败: {e}"
|
msg = f"删除失败: {e}"
|
||||||
else:
|
|
||||||
msg = f"账号状态正常,无需清理 ({test_msg})"
|
|
||||||
|
|
||||||
items.append(CleanupInvalidAccountsItem(
|
items.append(CleanupInvalidAccountsItem(
|
||||||
account_id=cand["account_id"],
|
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"],
|
upstream_name=cand["upstream_name"],
|
||||||
source_group_name=cand["source_group_name"],
|
source_group_name=cand["source_group_name"],
|
||||||
reason=cand["reason"],
|
reason=cand["reason"],
|
||||||
test_status=test_status,
|
test_status="executed",
|
||||||
cleanup_allowed=cleanup_allowed,
|
cleanup_allowed=True,
|
||||||
|
cleanup_action=action,
|
||||||
action_status=action_status,
|
action_status=action_status,
|
||||||
message=msg,
|
message=msg,
|
||||||
))
|
))
|
||||||
|
|
||||||
db.commit()
|
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"))
|
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")
|
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")
|
skipped_count = sum(1 for item in items if item.action_status == "skipped")
|
||||||
success = (failed_count == 0)
|
success = (failed_count == 0)
|
||||||
|
|
||||||
msg_parts = []
|
msg_parts = []
|
||||||
|
if cleared_count:
|
||||||
|
msg_parts.append(f"清除本地标记 {cleared_count} 个")
|
||||||
if deleted_count:
|
if deleted_count:
|
||||||
msg_parts.append(f"成功清理 {deleted_count} 个")
|
msg_parts.append(f"成功清理 {deleted_count} 个")
|
||||||
if failed_count:
|
if failed_count:
|
||||||
@@ -1135,7 +1218,7 @@ def execute_cleanup_invalid_accounts(wid: int, db: Session = Depends(get_db), _=
|
|||||||
if skipped_count:
|
if skipped_count:
|
||||||
msg_parts.append(f"跳过 {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(
|
return CleanupInvalidAccountsExecuteResponse(
|
||||||
success=success,
|
success=success,
|
||||||
|
|||||||
@@ -254,6 +254,7 @@ class CleanupInvalidAccountsItem(BaseModel):
|
|||||||
reason: str
|
reason: str
|
||||||
test_status: str
|
test_status: str
|
||||||
cleanup_allowed: bool
|
cleanup_allowed: bool
|
||||||
|
cleanup_action: Optional[str] = None # clear_local_marker | delete_remote_account | delete_remote_and_clear_marker
|
||||||
action_status: Optional[str] = None
|
action_status: Optional[str] = None
|
||||||
message: str = ""
|
message: str = ""
|
||||||
|
|
||||||
|
|||||||
@@ -239,50 +239,44 @@ def test_cleanup_invalid_preview_and_execute(db_session, monkeypatch):
|
|||||||
assert item_k1.test_status == "failed"
|
assert item_k1.test_status == "failed"
|
||||||
|
|
||||||
item_k2 = next(i for i in items if i.account_id == "acc-k2")
|
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.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")
|
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.cleanup_allowed is True
|
||||||
assert item_orphan.test_status == "failed"
|
assert item_orphan.test_status == "failed"
|
||||||
assert item_orphan.upstream_key_id == 9999
|
assert item_orphan.upstream_key_id == 9999
|
||||||
|
assert item_orphan.cleanup_action == "delete_remote_account"
|
||||||
|
|
||||||
# 确认没有调用过删除
|
# 确认没有调用过删除
|
||||||
assert len(deleted_accounts) == 0
|
assert len(deleted_accounts) == 0
|
||||||
|
|
||||||
# ── 2. 测试执行 ──
|
# ── 2. 测试执行 ──
|
||||||
# 重置调用计数
|
|
||||||
test_calls.clear()
|
|
||||||
execute_res = execute_cleanup_invalid_accounts(wid=w.id, db=db_session)
|
execute_res = execute_cleanup_invalid_accounts(wid=w.id, db=db_session)
|
||||||
assert execute_res.success is True
|
assert execute_res.success is True
|
||||||
assert "成功清理 2 个" in execute_res.message
|
assert "成功清理 3 个" in execute_res.message
|
||||||
assert "跳过 1 个" in execute_res.message
|
|
||||||
|
|
||||||
# 验证是否重新跑了测试
|
# 验证被删除的账号(所有候选都按 cleanup_action 执行删除)
|
||||||
assert "acc-k1" in test_calls
|
assert len(deleted_accounts) == 3
|
||||||
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-k1" in deleted_accounts
|
||||||
|
assert "acc-k2" in deleted_accounts
|
||||||
assert "acc-orphan-remote" in deleted_accounts
|
assert "acc-orphan-remote" in deleted_accounts
|
||||||
assert "acc-k2" not in deleted_accounts
|
|
||||||
|
|
||||||
# 验证本地数据库的 DB key 清空逻辑:
|
# 验证本地数据库的 DB key 清空逻辑:
|
||||||
db_session.refresh(k1)
|
db_session.refresh(k1)
|
||||||
db_session.refresh(k2)
|
db_session.refresh(k2)
|
||||||
db_session.refresh(k3)
|
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_website_id is None
|
||||||
assert k1.imported_account_id is None
|
assert k1.imported_account_id is None
|
||||||
assert k1.status == "orphaned"
|
assert k1.status == "orphaned"
|
||||||
assert k1.error == "original-error-k1"
|
assert k1.error == "original-error-k1"
|
||||||
|
|
||||||
# k2 由于测试成功跳过删除,DB 不变
|
# k2 也是 delete_remote_and_clear_marker,删除成功后清空导入标记
|
||||||
assert k2.imported_website_id == w.id
|
assert k2.imported_website_id is None
|
||||||
assert k2.imported_account_id == "acc-k2"
|
assert k2.imported_account_id is None
|
||||||
assert k2.status == "orphaned"
|
assert k2.status == "orphaned"
|
||||||
|
|
||||||
|
|
||||||
@@ -317,3 +311,93 @@ def test_cleanup_list_accounts_failure(db_session, monkeypatch):
|
|||||||
assert execute_res.success is False
|
assert execute_res.success is False
|
||||||
assert "拉取远端账号列表失败" in execute_res.message
|
assert "拉取远端账号列表失败" in execute_res.message
|
||||||
assert len(execute_res.items) == 0
|
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"
|
||||||
|
|||||||
@@ -361,6 +361,7 @@ export interface CleanupInvalidAccountsItem {
|
|||||||
reason: string
|
reason: string
|
||||||
test_status: string
|
test_status: string
|
||||||
cleanup_allowed: boolean
|
cleanup_allowed: boolean
|
||||||
|
cleanup_action: string | null // clear_local_marker | delete_remote_account | delete_remote_and_clear_marker
|
||||||
action_status: string | null
|
action_status: string | null
|
||||||
message: string
|
message: string
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -402,7 +402,7 @@
|
|||||||
</el-table-column>
|
</el-table-column>
|
||||||
<el-table-column label="状态" width="96">
|
<el-table-column label="状态" width="96">
|
||||||
<template #default="{ row }">
|
<template #default="{ row }">
|
||||||
<el-tooltip v-if="row.error" :content="row.error" placement="top" :show-after="300">
|
<el-tooltip v-if="row.error || (row.status === 'orphaned' && row.imported_account_id)" :content="orphanedKeyTooltip(row)" placement="top" :show-after="300">
|
||||||
<el-tag size="small" :type="keyStatusTagType(row.status)">
|
<el-tag size="small" :type="keyStatusTagType(row.status)">
|
||||||
{{ keyStatusLabel(row) }}
|
{{ keyStatusLabel(row) }}
|
||||||
</el-tag>
|
</el-tag>
|
||||||
@@ -1084,6 +1084,15 @@ const keyStatusTagType = (s: string) => {
|
|||||||
return 'info'
|
return 'info'
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const orphanedKeyTooltip = (row: GeneratedUpstreamKey) => {
|
||||||
|
const parts: string[] = []
|
||||||
|
if (row.error) parts.push(row.error)
|
||||||
|
if (row.status === 'orphaned' && row.imported_account_id) {
|
||||||
|
parts.push('可到目标站「清理异常账号」中处理')
|
||||||
|
}
|
||||||
|
return parts.join('\n')
|
||||||
|
}
|
||||||
|
|
||||||
async function loadList() {
|
async function loadList() {
|
||||||
tableLoading.value = true
|
tableLoading.value = true
|
||||||
try {
|
try {
|
||||||
|
|||||||
@@ -160,9 +160,9 @@
|
|||||||
text
|
text
|
||||||
:disabled="!selectedWebsite"
|
:disabled="!selectedWebsite"
|
||||||
@click="openCleanupDialog"
|
@click="openCleanupDialog"
|
||||||
title="清理当前网站已导入但失效/孤立的账号"
|
title="清理当前网站中手动删除残留、已归档 Key 关联、关联不一致等异常账号"
|
||||||
>
|
>
|
||||||
清理失效账号
|
清理异常账号
|
||||||
</el-button>
|
</el-button>
|
||||||
<el-button
|
<el-button
|
||||||
size="small"
|
size="small"
|
||||||
@@ -806,7 +806,7 @@
|
|||||||
<!-- 清理失效账号弹窗 -->
|
<!-- 清理失效账号弹窗 -->
|
||||||
<el-dialog
|
<el-dialog
|
||||||
v-model="cleanupDialog"
|
v-model="cleanupDialog"
|
||||||
title="清理失效账号候选预览"
|
title="清理异常账号候选预览"
|
||||||
width="1000px"
|
width="1000px"
|
||||||
destroy-on-close
|
destroy-on-close
|
||||||
>
|
>
|
||||||
@@ -819,9 +819,11 @@
|
|||||||
style="margin-bottom: 15px;"
|
style="margin-bottom: 15px;"
|
||||||
>
|
>
|
||||||
<div style="font-size: 13px; line-height: 1.5;">
|
<div style="font-size: 13px; line-height: 1.5;">
|
||||||
系统已自动分析出可能已经失效、上游孤立或属于脏数据的账号候选。<br />
|
系统已自动分析出三类异常账号候选:<br />
|
||||||
<strong>是否将删除</strong> 字段是由后端调用目标站账号测试接口的结果判定。只有测试明确失败(非正常响应)或 404 的账号,才会被允许清理。<br />
|
• <strong>手动删除残留</strong>:下游账号已被手动删除,但 SmartUp 本地仍记录着导入关联。<br />
|
||||||
“确认清理”时,后端会重新测试 and 验证所有账号状态,只清理最终判定为失效的项。无 SmartUp 导入备注的手工账号不会被处理。
|
• <strong>已归档 Key 关联</strong>:本地上游 Key 已归档(orphaned),但其关联的下游账号仍存在。<br />
|
||||||
|
• <strong>关联不一致</strong>:下游账号备注引用了不存在的或关联不一致的本地 Key。<br />
|
||||||
|
「确认清理」时将根据处理动作自动执行清标或删除,无需手动判断。
|
||||||
</div>
|
</div>
|
||||||
</el-alert>
|
</el-alert>
|
||||||
|
|
||||||
@@ -852,12 +854,16 @@
|
|||||||
<el-tag v-if="row.test_status === 'test_passed'" type="success" size="small">测试通过</el-tag>
|
<el-tag v-if="row.test_status === 'test_passed'" type="success" size="small">测试通过</el-tag>
|
||||||
<el-tag v-else-if="row.test_status === 'failed'" type="danger" size="small">测试失败</el-tag>
|
<el-tag v-else-if="row.test_status === 'failed'" type="danger" size="small">测试失败</el-tag>
|
||||||
<el-tag v-else-if="row.test_status === '404'" type="warning" size="small">404 不存在</el-tag>
|
<el-tag v-else-if="row.test_status === '404'" type="warning" size="small">404 不存在</el-tag>
|
||||||
|
<el-tag v-else-if="row.test_status === 'not_needed'" type="info" size="small">无需测试</el-tag>
|
||||||
|
<el-tag v-else-if="row.test_status === 'executed'" type="info" size="small">已执行</el-tag>
|
||||||
<el-tag v-else type="info" size="small">未知 / 异常</el-tag>
|
<el-tag v-else type="info" size="small">未知 / 异常</el-tag>
|
||||||
</template>
|
</template>
|
||||||
</el-table-column>
|
</el-table-column>
|
||||||
<el-table-column prop="cleanup_allowed" label="是否将删除" width="100" align="center">
|
<el-table-column label="处理动作" width="120" align="center">
|
||||||
<template #default="{ row }">
|
<template #default="{ row }">
|
||||||
<el-tag v-if="row.cleanup_allowed" type="danger" effect="dark" size="small">将删除</el-tag>
|
<el-tag v-if="row.cleanup_action === 'clear_local_marker'" type="warning" size="small">清除本地标记</el-tag>
|
||||||
|
<el-tag v-else-if="row.cleanup_action === 'delete_remote_account'" type="danger" size="small">删除下游账号</el-tag>
|
||||||
|
<el-tag v-else-if="row.cleanup_action === 'delete_remote_and_clear_marker'" type="danger" size="small">删除并清标记</el-tag>
|
||||||
<el-tag v-else type="info" size="small">跳过</el-tag>
|
<el-tag v-else type="info" size="small">跳过</el-tag>
|
||||||
</template>
|
</template>
|
||||||
</el-table-column>
|
</el-table-column>
|
||||||
@@ -865,15 +871,16 @@
|
|||||||
<template #default="{ row }">
|
<template #default="{ row }">
|
||||||
<el-tag v-if="row.action_status === 'deleted'" type="success" effect="dark" size="small">已删除</el-tag>
|
<el-tag v-if="row.action_status === 'deleted'" type="success" effect="dark" size="small">已删除</el-tag>
|
||||||
<el-tag v-else-if="row.action_status === 'already_deleted'" type="warning" effect="dark" size="small">已不存在</el-tag>
|
<el-tag v-else-if="row.action_status === 'already_deleted'" type="warning" effect="dark" size="small">已不存在</el-tag>
|
||||||
|
<el-tag v-else-if="row.action_status === 'cleared'" type="success" effect="dark" size="small">已清标记</el-tag>
|
||||||
<el-tag v-else-if="row.action_status === 'skipped'" type="info" size="small">已跳过</el-tag>
|
<el-tag v-else-if="row.action_status === 'skipped'" type="info" size="small">已跳过</el-tag>
|
||||||
<el-tag v-else-if="row.action_status === 'failed'" type="danger" effect="dark" size="small">删除失败</el-tag>
|
<el-tag v-else-if="row.action_status === 'failed'" type="danger" effect="dark" size="small">失败</el-tag>
|
||||||
<span v-else>-</span>
|
<span v-else>-</span>
|
||||||
</template>
|
</template>
|
||||||
</el-table-column>
|
</el-table-column>
|
||||||
</el-table>
|
</el-table>
|
||||||
|
|
||||||
<div v-if="cleanupItems.length === 0" style="padding: 30px; text-align: center; color: var(--el-text-color-secondary);">
|
<div v-if="cleanupItems.length === 0" style="padding: 30px; text-align: center; color: var(--el-text-color-secondary);">
|
||||||
没有找到可以清理的失效或孤立账号。
|
没有找到可以清理的异常账号。
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<template #footer>
|
<template #footer>
|
||||||
@@ -885,10 +892,10 @@
|
|||||||
v-if="!cleanupHasExecuted"
|
v-if="!cleanupHasExecuted"
|
||||||
type="danger"
|
type="danger"
|
||||||
:loading="cleanupExecuting"
|
:loading="cleanupExecuting"
|
||||||
:disabled="cleanupLoading || cleanupItems.filter(i => i.cleanup_allowed).length === 0"
|
:disabled="cleanupLoading || cleanupItems.length === 0"
|
||||||
@click="executeCleanup"
|
@click="executeCleanup"
|
||||||
>
|
>
|
||||||
确认清理 ({{ cleanupItems.filter(i => i.cleanup_allowed).length }} 个)
|
确认清理 ({{ cleanupItems.length }} 个)
|
||||||
</el-button>
|
</el-button>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
@@ -1956,7 +1963,7 @@ async function organizeWebsiteGroups() {
|
|||||||
if (!selectedWebsite.value) return
|
if (!selectedWebsite.value) return
|
||||||
try {
|
try {
|
||||||
await ElMessageBox.confirm(
|
await ElMessageBox.confirm(
|
||||||
'一键整理分组将根据当前的“分组管理”绑定关系,自动执行已导入账号的校验补齐,以及未导入账号的自动创建。确认执行?',
|
'一键整理分组将根据当前的「分组管理」绑定关系,自动执行已导入账号的校验补齐,以及未导入账号的自动创建。确认执行?',
|
||||||
'一键整理分组',
|
'一键整理分组',
|
||||||
{ type: 'info' }
|
{ type: 'info' }
|
||||||
)
|
)
|
||||||
@@ -2005,7 +2012,7 @@ async function executeCleanup() {
|
|||||||
if (!selectedWebsite.value) return
|
if (!selectedWebsite.value) return
|
||||||
try {
|
try {
|
||||||
await ElMessageBox.confirm(
|
await ElMessageBox.confirm(
|
||||||
'确认清理失效账号?此操作将永久从目标站删除测试失败或不存在的账号,本地 orphaned 键将清空导入关联。',
|
'确认清理异常账号?此操作将按预览中的处理动作执行:清除本地标记仅清 SmartUp 导入关联(不调远端接口),删除下游账号/删除并清标记会永久从目标站删除对应账号(即使远端测试通过也会删除)。',
|
||||||
'确认清理',
|
'确认清理',
|
||||||
{ type: 'warning' }
|
{ type: 'warning' }
|
||||||
)
|
)
|
||||||
|
|||||||
Reference in New Issue
Block a user