feat: 实现清理失效账号安全方案(包含预览、SSE测试、安全删除以及本地标记清空)
This commit is contained in:
@@ -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,
|
||||
|
||||
@@ -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]
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
@@ -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<WebsiteData[]>('/api/websites'),
|
||||
create: (data: WebsiteForm) => api.post<WebsiteData>('/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<GroupBindingData[]>('/api/group-bindings'),
|
||||
createBinding: (data: GroupBindingForm) => api.post<GroupBindingData>('/api/group-bindings', data),
|
||||
updateBinding: (id: number, data: Partial<GroupBindingForm>) => api.put<GroupBindingData>(`/api/group-bindings/${id}`, data),
|
||||
|
||||
@@ -124,6 +124,15 @@
|
||||
>
|
||||
一键整理分组
|
||||
</el-button>
|
||||
<el-button
|
||||
size="small"
|
||||
text
|
||||
:disabled="!selectedWebsite"
|
||||
@click="openCleanupDialog"
|
||||
title="清理当前网站已导入但失效/孤立的账号"
|
||||
>
|
||||
清理失效账号
|
||||
</el-button>
|
||||
<el-button size="small" text :disabled="websites.length === 0" @click="openBindingCreate(selectedWebsite || websites[0])">新增绑定</el-button>
|
||||
</div>
|
||||
<div class="binding-list" v-loading="bindingLoading">
|
||||
@@ -741,6 +750,97 @@
|
||||
<el-button @click="organizeDialog = false">关闭</el-button>
|
||||
</template>
|
||||
</el-dialog>
|
||||
|
||||
<!-- 清理失效账号弹窗 -->
|
||||
<el-dialog
|
||||
v-model="cleanupDialog"
|
||||
title="清理失效账号候选预览"
|
||||
width="1000px"
|
||||
destroy-on-close
|
||||
>
|
||||
<div v-loading="cleanupLoading">
|
||||
<el-alert
|
||||
title="清理规则说明"
|
||||
type="info"
|
||||
show-icon
|
||||
:closable="false"
|
||||
style="margin-bottom: 15px;"
|
||||
>
|
||||
<div style="font-size: 13px; line-height: 1.5;">
|
||||
系统已自动分析出可能已经失效、上游孤立或属于脏数据的账号候选。<br />
|
||||
<strong>是否将删除</strong> 字段是由后端调用目标站账号测试接口的结果判定。只有测试明确失败(非正常响应)或 404 的账号,才会被允许清理。<br />
|
||||
“确认清理”时,后端会重新测试 and 验证所有账号状态,只清理最终判定为失效的项。无 SmartUp 导入备注的手工账号不会被处理。
|
||||
</div>
|
||||
</el-alert>
|
||||
|
||||
<el-table :data="cleanupItems" border stripe style="width: 100%; max-height: 500px; overflow-y: auto;">
|
||||
<el-table-column prop="account_id" label="账号 ID" width="160" show-overflow-tooltip />
|
||||
<el-table-column prop="account_name" label="账号名" width="180" show-overflow-tooltip />
|
||||
<el-table-column prop="upstream_name" label="来源上游" width="110" show-overflow-tooltip>
|
||||
<template #default="{ row }">
|
||||
<el-tag v-if="row.upstream_name" size="small">{{ row.upstream_name }}</el-tag>
|
||||
<span v-else class="text-muted">-</span>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column prop="source_group_name" label="来源分组" width="120" show-overflow-tooltip>
|
||||
<template #default="{ row }">
|
||||
<el-tag v-if="row.source_group_name" type="info" size="small">{{ row.source_group_name }}</el-tag>
|
||||
<span v-else class="text-muted">-</span>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column prop="upstream_key_id" label="Key ID" width="90" align="center">
|
||||
<template #default="{ row }">
|
||||
<span v-if="row.upstream_key_id" style="font-family: monospace;">#{{ row.upstream_key_id }}</span>
|
||||
<span v-else class="text-muted">-</span>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column prop="reason" label="原因" min-width="180" show-overflow-tooltip />
|
||||
<el-table-column prop="test_status" label="测试结果" width="110" align="center">
|
||||
<template #default="{ row }">
|
||||
<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 === '404'" type="warning" size="small">404 不存在</el-tag>
|
||||
<el-tag v-else type="info" size="small">未知 / 异常</el-tag>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column prop="cleanup_allowed" label="是否将删除" width="100" align="center">
|
||||
<template #default="{ row }">
|
||||
<el-tag v-if="row.cleanup_allowed" type="danger" effect="dark" size="small">将删除</el-tag>
|
||||
<el-tag v-else type="info" size="small">跳过</el-tag>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column v-if="cleanupHasExecuted" prop="action_status" label="执行结果" width="110" align="center">
|
||||
<template #default="{ row }">
|
||||
<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 === 'skipped'" type="info" 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>
|
||||
</template>
|
||||
</el-table-column>
|
||||
</el-table>
|
||||
|
||||
<div v-if="cleanupItems.length === 0" style="padding: 30px; text-align: center; color: var(--el-text-color-secondary);">
|
||||
没有找到可以清理的失效或孤立账号。
|
||||
</div>
|
||||
</div>
|
||||
<template #footer>
|
||||
<div class="dialog-footer">
|
||||
<el-button @click="cleanupDialog = false" :disabled="cleanupExecuting">
|
||||
{{ cleanupHasExecuted ? '关闭' : '取消' }}
|
||||
</el-button>
|
||||
<el-button
|
||||
v-if="!cleanupHasExecuted"
|
||||
type="danger"
|
||||
:loading="cleanupExecuting"
|
||||
:disabled="cleanupLoading || cleanupItems.filter(i => i.cleanup_allowed).length === 0"
|
||||
@click="executeCleanup"
|
||||
>
|
||||
确认清理 ({{ cleanupItems.filter(i => i.cleanup_allowed).length }} 个)
|
||||
</el-button>
|
||||
</div>
|
||||
</template>
|
||||
</el-dialog>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
@@ -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<OrganizeGroupsItem[]>([])
|
||||
const organizeMessage = ref('')
|
||||
|
||||
const cleanupDialog = ref(false)
|
||||
const cleanupLoading = ref(false)
|
||||
const cleanupExecuting = ref(false)
|
||||
const cleanupHasExecuted = ref(false)
|
||||
const cleanupItems = ref<CleanupInvalidAccountsItem[]>([])
|
||||
|
||||
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 })
|
||||
|
||||
Reference in New Issue
Block a user