feat: 实现清理失效账号安全方案(包含预览、SSE测试、安全删除以及本地标记清空)
This commit is contained in:
@@ -38,6 +38,9 @@ from app.schemas.website import (
|
|||||||
WebsiteBatchSyncResponse,
|
WebsiteBatchSyncResponse,
|
||||||
OrganizeGroupsItem,
|
OrganizeGroupsItem,
|
||||||
OrganizeGroupsResponse,
|
OrganizeGroupsResponse,
|
||||||
|
CleanupInvalidAccountsItem,
|
||||||
|
CleanupInvalidAccountsPreviewResponse,
|
||||||
|
CleanupInvalidAccountsExecuteResponse,
|
||||||
)
|
)
|
||||||
|
|
||||||
from app.services.website_client import Sub2ApiWebsiteClient, _extract_id
|
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)
|
@router.post("/api/websites/{wid}/groups/organize", response_model=OrganizeGroupsResponse)
|
||||||
def organize_website_groups(
|
def organize_website_groups(
|
||||||
wid: int,
|
wid: int,
|
||||||
|
|||||||
@@ -243,3 +243,28 @@ class OrganizeGroupsResponse(BaseModel):
|
|||||||
success: bool
|
success: bool
|
||||||
message: str
|
message: str
|
||||||
items: list[OrganizeGroupsItem]
|
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]
|
||||||
|
|||||||
@@ -357,6 +357,82 @@ class Sub2ApiWebsiteClient:
|
|||||||
return None
|
return None
|
||||||
return account_id in ids
|
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
|
@staticmethod
|
||||||
def extract_id(value: Any) -> str:
|
def extract_id(value: Any) -> str:
|
||||||
return _extract_id(value)
|
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
|
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 = {
|
export const websitesApi = {
|
||||||
list: () => api.get<WebsiteData[]>('/api/websites'),
|
list: () => api.get<WebsiteData[]>('/api/websites'),
|
||||||
create: (data: WebsiteForm) => api.post<WebsiteData>('/api/websites', data),
|
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),
|
}) => api.post<{ success: boolean; message: string; items: ImportAccountItem[] }>(`/api/websites/${id}/accounts/import-upstream-keys`, data),
|
||||||
organizeGroups: (id: number) =>
|
organizeGroups: (id: number) =>
|
||||||
api.post<{ success: boolean; message: string; items: OrganizeGroupsItem[] }>(`/api/websites/${id}/groups/organize`),
|
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'),
|
listBindings: () => api.get<GroupBindingData[]>('/api/group-bindings'),
|
||||||
createBinding: (data: GroupBindingForm) => api.post<GroupBindingData>('/api/group-bindings', data),
|
createBinding: (data: GroupBindingForm) => api.post<GroupBindingData>('/api/group-bindings', data),
|
||||||
updateBinding: (id: number, data: Partial<GroupBindingForm>) => api.put<GroupBindingData>(`/api/group-bindings/${id}`, data),
|
updateBinding: (id: number, data: Partial<GroupBindingForm>) => api.put<GroupBindingData>(`/api/group-bindings/${id}`, data),
|
||||||
|
|||||||
@@ -124,6 +124,15 @@
|
|||||||
>
|
>
|
||||||
一键整理分组
|
一键整理分组
|
||||||
</el-button>
|
</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>
|
<el-button size="small" text :disabled="websites.length === 0" @click="openBindingCreate(selectedWebsite || websites[0])">新增绑定</el-button>
|
||||||
</div>
|
</div>
|
||||||
<div class="binding-list" v-loading="bindingLoading">
|
<div class="binding-list" v-loading="bindingLoading">
|
||||||
@@ -741,6 +750,97 @@
|
|||||||
<el-button @click="organizeDialog = false">关闭</el-button>
|
<el-button @click="organizeDialog = false">关闭</el-button>
|
||||||
</template>
|
</template>
|
||||||
</el-dialog>
|
</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>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
@@ -765,6 +865,7 @@ import {
|
|||||||
type WebsiteGroup,
|
type WebsiteGroup,
|
||||||
type WebsiteSyncLog,
|
type WebsiteSyncLog,
|
||||||
type OrganizeGroupsItem,
|
type OrganizeGroupsItem,
|
||||||
|
type CleanupInvalidAccountsItem,
|
||||||
} from '@/api'
|
} from '@/api'
|
||||||
|
|
||||||
const websites = ref<(WebsiteData & { _testing?: boolean })[]>([])
|
const websites = ref<(WebsiteData & { _testing?: boolean })[]>([])
|
||||||
@@ -914,6 +1015,12 @@ const organizeLoading = ref(false)
|
|||||||
const organizeResults = ref<OrganizeGroupsItem[]>([])
|
const organizeResults = ref<OrganizeGroupsItem[]>([])
|
||||||
const organizeMessage = ref('')
|
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 upstreamGroupOptions = computed(() => {
|
||||||
const rows: Array<{ key: string; label: string; rate: string | number; source: BindingSourceGroup }> = []
|
const rows: Array<{ key: string; label: string; rate: string | number; source: BindingSourceGroup }> = []
|
||||||
for (const upstream of upstreams.value) {
|
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) {
|
async function toggleBinding(row: GroupBindingData) {
|
||||||
try {
|
try {
|
||||||
await websitesApi.updateBinding(row.id, { enabled: row.enabled })
|
await websitesApi.updateBinding(row.id, { enabled: row.enabled })
|
||||||
|
|||||||
Reference in New Issue
Block a user