From 3b23f21ef39c26d37c5be61206d0f27708dc566e Mon Sep 17 00:00:00 2001 From: liumangmang Date: Thu, 2 Jul 2026 00:25:24 +0800 Subject: [PATCH] =?UTF-8?q?fix:=20=E4=BC=98=E5=8C=96=E6=B8=85=E7=90=86?= =?UTF-8?q?=E5=A4=B1=E6=95=88=E8=B4=A6=E5=8F=B7=E6=89=A7=E8=A1=8C=E5=93=8D?= =?UTF-8?q?=E5=BA=94=E7=9A=84=20success=20=E6=A0=87=E5=BF=97=EF=BC=8C?= =?UTF-8?q?=E5=AF=B9=20test=5Faccount/delete=5Faccount=20=E5=BC=95?= =?UTF-8?q?=E5=85=A5=E5=A4=96=E9=83=A8=20API=20=E6=97=A5=E5=BF=97=E5=AE=A1?= =?UTF-8?q?=E8=AE=A1=EF=BC=8C=E5=B9=B6=E5=AF=B9=E8=B4=A6=E5=8F=B7=20ID=20?= =?UTF-8?q?=E8=BF=9B=E8=A1=8C=E5=AE=89=E5=85=A8=E8=BD=AC=E4=B9=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- backend/app/routers/websites.py | 18 ++- backend/app/services/website_client.py | 137 ++++++++++++++--------- backend/test_cleanup_invalid_accounts.py | 42 +++++-- 3 files changed, 134 insertions(+), 63 deletions(-) diff --git a/backend/app/routers/websites.py b/backend/app/routers/websites.py index ff349d9..71dd6b1 100644 --- a/backend/app/routers/websites.py +++ b/backend/app/routers/websites.py @@ -1085,9 +1085,23 @@ def execute_cleanup_invalid_accounts(wid: int, db: Session = Depends(get_db), _= db.commit() deleted_count = sum(1 for item in items if item.action_status in ("deleted", "already_deleted")) + failed_count = sum(1 for item in items if item.action_status == "failed") + skipped_count = sum(1 for item in items if item.action_status == "skipped") + success = (failed_count == 0) + + msg_parts = [] + if deleted_count: + msg_parts.append(f"成功清理 {deleted_count} 个") + if failed_count: + msg_parts.append(f"失败 {failed_count} 个") + if skipped_count: + msg_parts.append(f"跳过 {skipped_count} 个") + + message = "清理执行完毕:" + ",".join(msg_parts) if msg_parts else "没有需要清理的失效账号" + return CleanupInvalidAccountsExecuteResponse( - success=True, - message=f"清理执行完毕,成功处理了 {deleted_count} 个失效账号", + success=success, + message=message, items=items ) diff --git a/backend/app/services/website_client.py b/backend/app/services/website_client.py index a1cd862..6cf1e26 100644 --- a/backend/app/services/website_client.py +++ b/backend/app/services/website_client.py @@ -363,75 +363,106 @@ class Sub2ApiWebsiteClient: 若 timeout / 无法建立连接,返回 {"status": "timeout"/"error", "cleanup_allowed": False} """ import json - url = self._url(f"{endpoint}/{account_id}/test") + quoted_id = quote(account_id, safe="") + path = f"{endpoint}/{quoted_id}/test" + url = self._url(path) headers = self._headers() # Accept text/event-stream since it's an SSE stream headers["Accept"] = "text/event-stream" + started = time.monotonic() + status_code: int | None = None + error_type: str | None = None + error_msg: str | None = None + 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}"} + try: + with self._client.stream("POST", url, headers=headers) as response: + status_code = response.status_code + if response.status_code == 404: + return {"status": "404", "cleanup_allowed": True, "message": "账号在远端不存在 (404)"} + if response.status_code >= 400: + response.raise_for_status() - # Read SSE stream line by line - current_event = None - success_value = None - has_error_event = False + # 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 + 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}"} + 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: + error_type = "WebsiteError" + error_msg = "测试接口返回了错误事件" + return {"status": "error", "cleanup_allowed": False, "message": "测试接口返回了错误事件"} + else: + error_type = "WebsiteError" + error_msg = "未收到有效的测试完成事件" + return {"status": "invalid_sse", "cleanup_allowed": False, "message": "未收到有效的测试完成事件"} + except httpx.TimeoutException as exc: + error_type, error_msg = type(exc).__name__, str(exc)[:500] + return {"status": "timeout", "cleanup_allowed": False, "message": f"连接超时: {exc}"} + except httpx.HTTPStatusError as exc: + error_type, error_msg = type(exc).__name__, str(exc)[:500] + status_code = exc.response.status_code + return {"status": "http_error", "cleanup_allowed": False, "message": f"测试接口返回 HTTP {status_code}"} + except Exception as exc: + error_type, error_msg = type(exc).__name__, str(exc)[:500] + return {"status": "error", "cleanup_allowed": False, "message": f"请求异常: {exc}"} + finally: + elapsed = int((time.monotonic() - started) * 1000) + parsed = urlparse(url) + log_external_api_call( + direction="website", + target_type="website", + method="POST", + path=parsed.path or path, + url_host=parsed.hostname or "", + duration_ms=elapsed, + status_code=status_code, + success=error_type is None, + target_id=self.target_id, + target_name=self.target_name, + error_type=error_type, + error_message=error_msg, + ) 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() + quoted_id = quote(account_id, safe="") + path = f"{endpoint}/{quoted_id}" try: - resp = self._client.request("DELETE", url, headers=headers) - if resp.status_code == 404: - return {"status": "already_deleted", "message": "账号不存在或已在远端被删除"} - resp.raise_for_status() + self._request("DELETE", path) return {"status": "deleted", "message": "账号已删除"} - except httpx.HTTPStatusError as exc: - if exc.response.status_code == 404: + except WebsiteError as exc: + cause = exc.__cause__ + if isinstance(cause, httpx.HTTPStatusError) and cause.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 + raise @staticmethod def extract_id(value: Any) -> str: diff --git a/backend/test_cleanup_invalid_accounts.py b/backend/test_cleanup_invalid_accounts.py index 1d312aa..c13d4b9 100644 --- a/backend/test_cleanup_invalid_accounts.py +++ b/backend/test_cleanup_invalid_accounts.py @@ -56,7 +56,7 @@ def mock_stream(status_code, lines, raise_exc=None): def test_website_client_test_account_sse_scenarios(monkeypatch): - """测试 Sub2ApiWebsiteClient.test_account 在各种 SSE 场景下的解析和处理。""" + """测试 Sub2ApiWebsiteClient.test_account 在各种 SSE 场景下的解析、处理、ID 转义和 API 日志记录。""" c = Sub2ApiWebsiteClient( base_url="http://w1", api_prefix="api/v1", @@ -64,41 +64,65 @@ def test_website_client_test_account_sse_scenarios(monkeypatch): auth_config={"token": "t1"}, ) - # 1. success=true + log_calls = [] + def mock_log(**kwargs): + log_calls.append(kwargs) + monkeypatch.setattr("app.services.website_client.log_external_api_call", mock_log) + + # 1. success=true,包含带有斜杠的 ID 校验以测试 URL quote 转义 monkeypatch.setattr(c._client, "stream", mock_stream(200, ["event: test_complete", "data: {\"success\": true}"])) - res = c.test_account("acc1") + res = c.test_account("acc/1") assert res["status"] == "success" assert res["cleanup_allowed"] is False + # 验证日志记录和 URL 转义:acc/1 应被转义为 acc%2F1 + assert len(log_calls) == 1 + assert log_calls[-1]["method"] == "POST" + assert log_calls[-1]["path"] == "/api/v1/accounts/acc%2F1/test" + assert log_calls[-1]["success"] is True # 2. success=false monkeypatch.setattr(c._client, "stream", mock_stream(200, ["event: test_complete", "data: {\"success\": false}"])) - res = c.test_account("acc1") + res = c.test_account("acc/1") assert res["status"] == "failed" assert res["cleanup_allowed"] is True + assert len(log_calls) == 2 + assert log_calls[-1]["success"] is True # 3. HTTP 404 monkeypatch.setattr(c._client, "stream", mock_stream(404, [])) - res = c.test_account("acc1") + res = c.test_account("acc/1") assert res["status"] == "404" assert res["cleanup_allowed"] is True + assert len(log_calls) == 3 + assert log_calls[-1]["status_code"] == 404 + assert log_calls[-1]["success"] is True # 4. error event monkeypatch.setattr(c._client, "stream", mock_stream(200, ["event: error", "data: \"internal error\""])) - res = c.test_account("acc1") + res = c.test_account("acc/1") assert res["status"] == "error" assert res["cleanup_allowed"] is False + assert len(log_calls) == 4 + assert log_calls[-1]["success"] is False + assert log_calls[-1]["error_type"] == "WebsiteError" # 5. 超时 monkeypatch.setattr(c._client, "stream", mock_stream(200, [], raise_exc=httpx.TimeoutException("Connection timed out"))) - res = c.test_account("acc1") + res = c.test_account("acc/1") assert res["status"] == "timeout" assert res["cleanup_allowed"] is False + assert len(log_calls) == 5 + assert log_calls[-1]["success"] is False + assert log_calls[-1]["error_type"] == "TimeoutException" # 6. 非法 SSE (没有 test_complete) monkeypatch.setattr(c._client, "stream", mock_stream(200, ["data: hello world"])) - res = c.test_account("acc1") + res = c.test_account("acc/1") assert res["status"] == "invalid_sse" assert res["cleanup_allowed"] is False + assert len(log_calls) == 6 + assert log_calls[-1]["success"] is False + assert log_calls[-1]["error_type"] == "WebsiteError" def test_cleanup_invalid_preview_and_execute(db_session, monkeypatch): @@ -229,6 +253,8 @@ def test_cleanup_invalid_preview_and_execute(db_session, monkeypatch): test_calls.clear() execute_res = execute_cleanup_invalid_accounts(wid=w.id, db=db_session) assert execute_res.success is True + assert "成功清理 2 个" in execute_res.message + assert "跳过 1 个" in execute_res.message # 验证是否重新跑了测试 assert "acc-k1" in test_calls