diff --git a/backend/main.py b/backend/main.py index e553199..2753d77 100644 --- a/backend/main.py +++ b/backend/main.py @@ -770,13 +770,18 @@ def _get_account_cookie_data(account: Account) -> Optional[str]: return read_cookie_file(account.id) -async def _build_cookie_response(account: Account, cookie_data: Optional[str] = None) -> AccountCookieResponse: +async def _build_cookie_response( + account: Account, + cookie_data: Optional[str] = None, + *, + runtime_check: bool = True, +) -> AccountCookieResponse: cookie_data = cookie_data if cookie_data is not None else _get_account_cookie_data(account) summary = cookie_summary(cookie_data) im_detail = await build_cookie_credential_detail( cookie_data, account.im_session_data, - runtime_check=True, + runtime_check=runtime_check, ) return AccountCookieResponse( account_id=account.id, @@ -1608,7 +1613,17 @@ async def get_account_cookie( cookie_data = _get_account_cookie_data(account) if cookie_data and purpose != "management": await _require_desktop_login_sec_user_id(account, db) - return await _build_cookie_response(account, cookie_data) + return await _build_cookie_response( + account, + cookie_data, + # Opening the edit dialog is a read-only management action. A live + # Douyin credential probe can take many seconds and, with hundreds of + # hosted accounts, would wait behind recurring background traffic. + # The dialog only needs the locally stored credential fields; users + # can still request an authoritative online check with the explicit + # "recheck credential" action. + runtime_check=purpose != "management", + ) @app.get( diff --git a/backend/rpa_engine/credential.py b/backend/rpa_engine/credential.py index 0c181ba..191783b 100644 --- a/backend/rpa_engine/credential.py +++ b/backend/rpa_engine/credential.py @@ -161,7 +161,13 @@ async def validate_im_session( auth = DouyinAuth.from_im_session(session) # 优先用已持久化的 my_uid,避免每次都发起网络 query_my_uid(uid_tt 是加密串, # int() 解析必然失败而回退到网络请求;该请求偶发失败会误判为“未就绪”)。 - uid = session.my_uid or auth.get_uid() + uid = session.my_uid + if not uid: + # get_uid() may fall back to a synchronous HTTP request with a + # multi-second timeout. Keep that work off FastAPI's event loop + # so a manual credential recheck cannot freeze account editing or + # unrelated API requests. + uid = await asyncio.to_thread(auth.get_uid) if not uid: return False, "服务端未认可当前 Cookie(无法获取用户 UID)" if not auth.is_sign_ready(): diff --git a/backend/tests/test_credential_responsiveness.py b/backend/tests/test_credential_responsiveness.py new file mode 100644 index 0000000..d46d125 --- /dev/null +++ b/backend/tests/test_credential_responsiveness.py @@ -0,0 +1,53 @@ +import threading +import unittest +from types import SimpleNamespace +from unittest.mock import patch + +from rpa_engine.credential import validate_im_session + + +class CredentialResponsivenessTests(unittest.IsolatedAsyncioTestCase): + async def test_uid_lookup_does_not_block_event_loop(self): + event_loop_thread_id = threading.get_ident() + lookup_thread_ids = [] + + def get_uid(): + lookup_thread_ids.append(threading.get_ident()) + return 123456 + + session = SimpleNamespace( + my_uid=0, + can_direct_im=lambda: True, + ) + auth = SimpleNamespace( + get_uid=get_uid, + is_sign_ready=lambda: True, + ) + + with ( + patch( + "rpa_engine.credential.ensure_frontier_ws", + return_value=None, + ), + patch( + "rpa_engine.credential.DouyinAuth.from_im_session", + return_value=auth, + ), + ): + result = await validate_im_session( + session, + _bypass_global_limit=True, + ) + + self.assertTrue(result[0]) + self.assertEqual(len(lookup_thread_ids), 1) + self.assertNotEqual( + lookup_thread_ids[0], + event_loop_thread_id, + "the synchronous UID lookup ran on the event-loop thread", + ) + self.assertEqual(session.my_uid, 123456) + + +if __name__ == "__main__": + unittest.main() diff --git a/backend/tests/test_desktop_login_sec_user_id.py b/backend/tests/test_desktop_login_sec_user_id.py index 00de3a6..7836c2a 100644 --- a/backend/tests/test_desktop_login_sec_user_id.py +++ b/backend/tests/test_desktop_login_sec_user_id.py @@ -171,7 +171,52 @@ class DesktopLoginSecUserIdTests(unittest.IsolatedAsyncioTestCase): self.assertIs(result, response) db.execute.assert_not_awaited() - build.assert_awaited_once_with(account, "cookie-json") + build.assert_awaited_once_with( + account, + "cookie-json", + runtime_check=False, + ) + + async def test_cookie_response_forwards_static_management_check(self): + account = self._account() + summary = { + "cookie_count": 2, + "cookie_valid": True, + "cookie_expired": False, + "reason": "ok", + "expires_at": None, + "key_names": ["sessionid"], + } + detail = { + "has_sessionid": True, + "sessionid": "sid", + "sessionid_ss": "", + "im_ready": False, + "im_status": "static", + "can_skip_browser": False, + "should_reset": False, + } + + with ( + patch.object(main, "cookie_summary", return_value=summary), + patch.object( + main, + "build_cookie_credential_detail", + new=AsyncMock(return_value=detail), + ) as build_detail, + ): + result = await main._build_cookie_response( + account, + "cookie-json", + runtime_check=False, + ) + + self.assertTrue(result.has_sessionid) + build_detail.assert_awaited_once_with( + "cookie-json", + None, + runtime_check=False, + ) async def test_legacy_cookie_request_is_guarded_for_existing_desktop_clients(self): account = self._account() diff --git a/frontend/src/views/Accounts.vue b/frontend/src/views/Accounts.vue index e5fd62c..8351072 100644 --- a/frontend/src/views/Accounts.vue +++ b/frontend/src/views/Accounts.vue @@ -1546,7 +1546,7 @@ const formatCookieTime = (value) => { return date.toLocaleString('zh-CN') } -const applyCookieResponse = (data) => { +const applyCookieResponse = (data, { preserveRuntimeCredential = false } = {}) => { editForm.value.cookie_updated_at = data.cookie_updated_at editForm.value.cookie_count = data.cookie_count editForm.value.cookie_valid = data.cookie_valid @@ -1556,10 +1556,12 @@ const applyCookieResponse = (data) => { editForm.value.has_sessionid = !!data.has_sessionid editForm.value.sessionid = data.sessionid || '' editForm.value.sessionid_ss = data.sessionid_ss || '' - editForm.value.im_ready = !!data.im_ready - editForm.value.im_status = data.im_status || '' - editForm.value.can_skip_browser = !!data.can_skip_browser - editForm.value.should_reset = !!data.should_reset + if (!preserveRuntimeCredential) { + editForm.value.im_ready = !!data.im_ready + editForm.value.im_status = data.im_status || '' + editForm.value.can_skip_browser = !!data.can_skip_browser + editForm.value.should_reset = !!data.should_reset + } } const openEditModal = async (acc) => { @@ -1628,7 +1630,7 @@ const refreshCredential = async () => { } const cookieRes = await api.get(`/accounts/${editForm.value.id}/cookie?purpose=management`) - applyCookieResponse(cookieRes.data) + applyCookieResponse(cookieRes.data, { preserveRuntimeCredential: true }) message.success('凭证检测完成') } catch (error) { message.error(error.response?.data?.detail || '凭证检测失败')