diff --git a/backend/src/alphapulse/calendar/jin10_auth.py b/backend/src/alphapulse/calendar/jin10_auth.py index af3ce0c..ede3fdf 100644 --- a/backend/src/alphapulse/calendar/jin10_auth.py +++ b/backend/src/alphapulse/calendar/jin10_auth.py @@ -14,6 +14,10 @@ class Jin10Error(Exception): pass +class Jin10QrExpired(Jin10Error): + pass + + async def uc_request(path: str, params: dict | None = None, token: str | None = None) -> dict: headers = {**HEADERS, **({"x-token": token} if token else {})} try: @@ -21,6 +25,8 @@ async def uc_request(path: str, params: dict | None = None, token: str | None = response = await client.get(UC_URL + path, params=params, headers=headers) response.raise_for_status() body = response.json() + if path == "/qrcode/try_login" and isinstance(body, dict) and body.get("status") == 400 and body.get("message") == "二维码已过期": + raise Jin10QrExpired("金十二维码已过期") if not isinstance(body, dict) or body.get("status") != 200: raise Jin10Error("金十未接受请求,请重新扫码或稍后重试") return body @@ -48,7 +54,10 @@ def qr_image(token: str) -> str: async def poll_qr(token: str) -> tuple[str, str | None]: - body = await uc_request("/qrcode/try_login", {"token": token}) + try: + body = await uc_request("/qrcode/try_login", {"token": token}) + except Jin10QrExpired: + return "expired", None data = body.get("data") if data is None: return "pending", None diff --git a/backend/tests/test_jin10.py b/backend/tests/test_jin10.py index 3d77f0b..56b9973 100644 --- a/backend/tests/test_jin10.py +++ b/backend/tests/test_jin10.py @@ -225,3 +225,31 @@ def test_upstream_errors_never_expose_tokens(qr_env, monkeypatch): with pytest.raises(jin10_auth.Jin10Error, match="金十连接失败") as exc: asyncio.run(jin10_auth.uc_request("/qrcode/try_login", {"token": "private"})) assert "private" not in str(exc.value) + + +@pytest.mark.parametrize("message,expired", [("二维码已过期", True), ("请求被拒绝", False)]) +def test_observed_upstream_expiry_is_not_a_generic_connection_failure(qr_env, monkeypatch, message, expired): + from alphapulse.calendar import jin10_auth + import importlib + jin10_auth = importlib.reload(jin10_auth) + response = httpx.Response(200, json={"status": 400, "message": message}, request=httpx.Request("GET", "https://uc-api.jin10.com/qrcode/try_login")) + monkeypatch.setattr(httpx.AsyncClient, "get", AsyncMock(return_value=response)) + if expired: + assert asyncio.run(jin10_auth.poll_qr("private")) == ("expired", None) + else: + with pytest.raises(jin10_auth.Jin10Error, match="未接受请求"): + asyncio.run(jin10_auth.poll_qr("private")) + + +def test_upstream_expiry_clears_task_without_disconnecting_existing_account(qr_env): + e = qr_env + e.authorize() + e.pending() + e.row.token_encrypted = b"existing-encrypted-credential" + e.upstream.poll_qr.return_value = "expired", None + response = request(e, "POST", "/qr/task-a/poll") + assert response.status_code == 200 + assert response.json()["data"]["state"] == "expired" + assert e.row.token_encrypted == b"existing-encrypted-credential" + assert e.row.qr_encrypted is None + assert e.row.error is None diff --git a/web/src/pages/settings/components/Jin10Settings.vue b/web/src/pages/settings/components/Jin10Settings.vue index 26fdf5b..c278d35 100644 --- a/web/src/pages/settings/components/Jin10Settings.vue +++ b/web/src/pages/settings/components/Jin10Settings.vue @@ -135,7 +135,7 @@ watch(visible, (value) => {
- 请用金十 App 扫描并确认,二维码 60 秒后失效。 + 请立即用金十 App 扫描并确认,二维码有效时间较短,失效后可刷新。
二维码已失效,请刷新后重新扫码。