feat: 实现清理失效账号安全方案(包含预览、SSE测试、安全删除以及本地标记清空)

This commit is contained in:
liumangmang
2026-07-02 00:19:26 +08:00
parent 1ef6188c02
commit 536c0aa3d4
6 changed files with 798 additions and 3 deletions
+229 -2
View File
@@ -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,
@@ -1306,12 +1533,12 @@ def sync_website_group_bindings(
success_count = sum(1 for r in results if r.status == "success") success_count = sum(1 for r in results if r.status == "success")
failed_count = sum(1 for r in results if r.status == "failed") failed_count = sum(1 for r in results if r.status == "failed")
skipped_count = sum(1 for r in results if r.status == "skipped") skipped_count = sum(1 for r in results if r.status == "skipped")
msg_parts = [] msg_parts = []
if success_count: msg_parts.append(f"成功 {success_count}") if success_count: msg_parts.append(f"成功 {success_count}")
if failed_count: msg_parts.append(f"失败 {failed_count}") if failed_count: msg_parts.append(f"失败 {failed_count}")
if skipped_count: msg_parts.append(f"跳过 {skipped_count}") if skipped_count: msg_parts.append(f"跳过 {skipped_count}")
return WebsiteBatchSyncResponse( return WebsiteBatchSyncResponse(
total=len(bindings), total=len(bindings),
success=success_count, success=success_count,
+25
View File
@@ -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]
+77 -1
View File
@@ -345,7 +345,7 @@ class Sub2ApiWebsiteClient:
def account_exists(self, account_id: str, endpoint: str = "/accounts") -> bool | None: def account_exists(self, account_id: str, endpoint: str = "/accounts") -> bool | None:
"""检查目标账号是否存在。 """检查目标账号是否存在。
优先拉取账号列表判断: 优先拉取账号列表判断:
- 列表成功取到 → return account_id in idsTrue=存在,False=已删除) - 列表成功取到 → return account_id in idsTrue=存在,False=已删除)
- 列表取不到(None)→ return None(校验失败,不清本地) - 列表取不到(None)→ return None(校验失败,不清本地)
@@ -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)
+291
View File
@@ -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
+17
View File
@@ -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),
+159
View File
@@ -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 })