fix(sync): 优先级同步前置过滤远端失效账号防排序污染,移除 locals 隐患,并将自愈结果纳入本地日志与摘要统计

This commit is contained in:
liumangmang
2026-07-01 10:20:13 +08:00
parent efd0a88249
commit bf5470caf3
4 changed files with 403 additions and 141 deletions
+191
View File
@@ -685,3 +685,194 @@ def test_priority_sync_graceful_on_remote_list_failure(db_session, monkeypatch):
assert res_a1["old_priority"] is None
assert res_a1["new_priority"] == 1
assert res_a1["status"] == "success"
def test_priority_sync_stale_account_cleanup_on_list_success(db_session, monkeypatch):
"""验证 priority sync 时,若远端账号列表中缺失某个导入账号,能自愈清理本地标记并不影响其他竞争账号。"""
w = Website(name="W1", base_url="http://w1", enabled=True,
auth_config_json="{}", timeout_seconds=30)
u = Upstream(name="U1", base_url="http://u1")
db_session.add_all([w, u])
db_session.commit()
db_session.refresh(w); db_session.refresh(u)
_make_snapshot(db_session, u.id, {"G1": 1.0, "G2": 2.0, "G3": 3.0})
# A1(K1), A2(K2) 与 A3(K3) 竞争
k1 = _make_key(db_session, u.id, "G1", "K1", "V1", w.id, "A1", imported_target_group_id="TG1")
k2 = _make_key(db_session, u.id, "G2", "K2", "V2", w.id, "A2", imported_target_group_id="TG1")
k3 = _make_key(db_session, u.id, "G3", "K3", "V3", w.id, "A3", imported_target_group_id="TG1")
update_calls = []
class MockClient:
def __init__(self, **kwargs): pass
def __enter__(self): return self
def __exit__(self, *a): pass
def list_accounts(self):
# 只有 A1 和 A2,缺失 A3
return [
{"id": "A1", "name": "SmartUp-A1"},
{"id": "A2", "name": "SmartUp-A2"},
]
def get_groups(self, *a, **kw):
return [{"id": "TG1", "name": "TG1-Group"}]
def update_account(self, account_id, data):
update_calls.append((account_id, data))
monkeypatch.setattr("app.services.website_sync.Sub2ApiWebsiteClient", MockClient)
results = sync_account_priorities_for_upstream(db_session, u.id, website_id=w.id)
# A1 和 A2 正常竞争更新,A3 被自愈清除不调用 update_account
assert len(update_calls) == 2
priority_map = {aid: data["priority"] for aid, data in update_calls}
assert priority_map["A1"] == 1
assert priority_map["A2"] == 11
res_a3 = next(r for r in results if r["account_id"] == "A3")
assert res_a3["status"] == "stale"
assert "目标账号已不存在,已清除导入标记" in res_a3["message"]
db_session.refresh(k3)
assert k3.imported_account_id is None
assert k3.imported_website_id is None
assert k3.status == "created"
def test_priority_sync_stale_account_single_remaining_skips_update(db_session, monkeypatch):
"""验证 priority sync 时,若清除失效账号后某分组只剩一个有效账号,该分组跳过更新不调用 update_account。"""
w = Website(name="W1", base_url="http://w1", enabled=True,
auth_config_json="{}", timeout_seconds=30)
u = Upstream(name="U1", base_url="http://u1")
db_session.add_all([w, u])
db_session.commit()
db_session.refresh(w); db_session.refresh(u)
_make_snapshot(db_session, u.id, {"G1": 1.0, "G2": 2.0})
# A1 与 A2 竞争
k1 = _make_key(db_session, u.id, "G1", "K1", "V1", w.id, "A1", imported_target_group_id="TG1")
k2 = _make_key(db_session, u.id, "G2", "K2", "V2", w.id, "A2", imported_target_group_id="TG1")
update_calls = []
class MockClient:
def __init__(self, **kwargs): pass
def __enter__(self): return self
def __exit__(self, *a): pass
def list_accounts(self):
# 只有 A1,缺失 A2
return [{"id": "A1", "name": "SmartUp-A1"}]
def get_groups(self, *a, **kw):
return [{"id": "TG1", "name": "TG1-Group"}]
def update_account(self, account_id, data):
update_calls.append((account_id, data))
monkeypatch.setattr("app.services.website_sync.Sub2ApiWebsiteClient", MockClient)
results = sync_account_priorities_for_upstream(db_session, u.id, website_id=w.id)
# 竞争分组只剩 A1 一个有效账号 -> 跳过不更新,update_account 不应被调用
assert update_calls == []
res_a2 = next(r for r in results if r["account_id"] == "A2")
assert res_a2["status"] == "stale"
db_session.refresh(k2)
assert k2.imported_account_id is None
def test_priority_sync_no_cleanup_on_list_failure(db_session, monkeypatch):
"""验证 list_accounts 失败时,不清理本地导入标记,仍尝试调用 update_account。"""
w = Website(name="W1", base_url="http://w1", enabled=True,
auth_config_json="{}", timeout_seconds=30)
u = Upstream(name="U1", base_url="http://u1")
db_session.add_all([w, u])
db_session.commit()
db_session.refresh(w); db_session.refresh(u)
_make_snapshot(db_session, u.id, {"G1": 1.0, "G2": 2.0})
k1 = _make_key(db_session, u.id, "G1", "K1", "V1", w.id, "A1", imported_target_group_id="TG1")
k2 = _make_key(db_session, u.id, "G2", "K2", "V2", w.id, "A2", imported_target_group_id="TG1")
update_calls = []
class MockClient:
def __init__(self, **kwargs): pass
def __enter__(self): return self
def __exit__(self, *a): pass
def list_accounts(self):
raise Exception("Remote list error")
def update_account(self, account_id, data):
update_calls.append((account_id, data))
monkeypatch.setattr("app.services.website_sync.Sub2ApiWebsiteClient", MockClient)
sync_account_priorities_for_upstream(db_session, u.id, website_id=w.id)
# list_accounts 失败,不做清理,继续正常对 A1 和 A2 尝试更新
assert len(update_calls) == 2
db_session.refresh(k1)
db_session.refresh(k2)
assert k1.imported_account_id == "A1"
assert k2.imported_account_id == "A2"
def test_update_account_404_error_custom_msg_and_fallback_cleanup(db_session, monkeypatch):
"""验证 update_account 抛出 404 错误时转换为友好文案并触发自愈清除。"""
from app.services.website_client import WebsiteError
w = Website(name="W1", base_url="http://w1", enabled=True,
auth_config_json="{}", timeout_seconds=30)
u = Upstream(name="U1", base_url="http://u1")
db_session.add_all([w, u])
db_session.commit()
db_session.refresh(w); db_session.refresh(u)
_make_snapshot(db_session, u.id, {"G1": 1.0, "G2": 2.0})
k1 = _make_key(db_session, u.id, "G1", "K1", "V1", w.id, "A1", imported_target_group_id="TG1")
k2 = _make_key(db_session, u.id, "G2", "K2", "V2", w.id, "A2", imported_target_group_id="TG1")
import httpx
class MockClient:
def __init__(self, **kwargs): pass
def __enter__(self): return self
def __exit__(self, *a): pass
def list_accounts(self):
# 模拟 list 失败,以便依赖 update_account 兜底清理
raise Exception("list failed")
def update_account(self, account_id, data):
# A2 触发 404 错误
if account_id == "A2":
req = httpx.Request("PUT", f"http://w1/accounts/{account_id}")
resp = httpx.Response(404, request=req)
raise WebsiteError("目标网站接口不存在") from httpx.HTTPStatusError("404", request=req, response=resp)
def get_groups(self, *a, **kw):
return []
# 验证单独调用 update_account 404 时的定制错误文案
client = Sub2ApiWebsiteClient(base_url="http://w1", api_prefix="", auth_type="api_key", auth_config={})
# Mock _request in client
def mock_request(method, path, body=None):
req = httpx.Request(method, path)
resp = httpx.Response(404, request=req)
raise WebsiteError("目标网站接口不存在") from httpx.HTTPStatusError("404", request=req, response=resp)
monkeypatch.setattr(client, "_request", mock_request)
with pytest.raises(WebsiteError) as exc_info:
client.update_account("A2", {"priority": 1})
assert "目标账号 A2 不存在或已被删除" in str(exc_info.value)
# 验证同步流程中的 404 兜底清理
monkeypatch.setattr("app.services.website_sync.Sub2ApiWebsiteClient", MockClient)
results = sync_account_priorities_for_upstream(db_session, u.id, website_id=w.id)
res_a2 = next(r for r in results if r["account_id"] == "A2")
assert res_a2["status"] == "stale"
assert "目标账号已不存在,已清除导入标记" in res_a2["message"]
db_session.refresh(k2)
assert k2.imported_account_id is None
assert k2.status == "created"