diff --git a/backend/alembic/versions/20261030_d5e8fa132bc0_jin10_connection.py b/backend/alembic/versions/20261030_d5e8fa132bc0_jin10_connection.py new file mode 100644 index 0000000..fa476c0 --- /dev/null +++ b/backend/alembic/versions/20261030_d5e8fa132bc0_jin10_connection.py @@ -0,0 +1,26 @@ +"""Add encrypted Jin10 QR connection; preserve existing calendar records.""" +from alembic import op +import sqlalchemy as sa + +revision = "d5e8fa132bc0" +down_revision = "c4d7e9f021ab" +branch_labels = None +depends_on = None + + +def upgrade(): + op.create_table("jin10_connection", + sa.Column("id", sa.Integer(), primary_key=True), + sa.Column("token_encrypted", sa.LargeBinary()), + sa.Column("connected_at", sa.DateTime(timezone=True)), + sa.Column("task_id", sa.String(64)), + sa.Column("owner", sa.String(64)), + sa.Column("qr_encrypted", sa.LargeBinary()), + sa.Column("expires_at", sa.DateTime(timezone=True)), + sa.Column("next_poll_at", sa.DateTime(timezone=True)), + sa.Column("error", sa.Text())) + op.execute("INSERT INTO jin10_connection (id) VALUES (1)") + + +def downgrade(): + op.drop_table("jin10_connection") diff --git a/backend/pyproject.toml b/backend/pyproject.toml index c5e8dec..2d258af 100644 --- a/backend/pyproject.toml +++ b/backend/pyproject.toml @@ -19,6 +19,7 @@ dependencies = [ "pydantic-settings>=2.15.0", "pyjwt>=2.15.1", "pypdf>=6.21.2", + "qrcode[pil]>=8.2,<9", "redis>=8.1.0", "scrapy>=2.19.0", "scrapy-redis>=0.9.1", diff --git a/backend/src/alphapulse/api/jin10.py b/backend/src/alphapulse/api/jin10.py new file mode 100644 index 0000000..0362da7 --- /dev/null +++ b/backend/src/alphapulse/api/jin10.py @@ -0,0 +1,129 @@ +"""Administrator-only Jin10 connection; credentials never leave the backend.""" +from datetime import UTC, datetime, timedelta +import secrets +from typing import Annotated + +from fastapi import APIRouter, Depends, HTTPException +from sqlalchemy import select +from sqlalchemy.ext.asyncio import AsyncSession + +from alphapulse.api.response import ApiResponse, success +from alphapulse.calendar import jin10_auth +from alphapulse.core.auth import AuthSession, check_session, require_admin +from alphapulse.core.crypto import decrypt, encrypt +from alphapulse.core.db import get_session +from alphapulse.models.jin10 import Jin10Connection + +router = APIRouter(prefix="/api/settings/jin10", tags=["settings"], dependencies=[Depends(require_admin)]) +Session = Annotated[AsyncSession, Depends(get_session)] +Principal = Annotated[AuthSession, Depends(require_admin)] + + +async def connection(session: AsyncSession, *, lock: bool = False) -> Jin10Connection: + query = select(Jin10Connection).where(Jin10Connection.id == 1) + if lock: + query = query.with_for_update().execution_options(populate_existing=True) + row = await session.scalar(query) + if row is None: + raise HTTPException(503, "金十连接尚未初始化,请先完成数据库迁移") + return row + + +def clear_task(row: Jin10Connection) -> None: + row.task_id = row.owner = row.qr_encrypted = row.expires_at = row.next_poll_at = None + + +def public_status(row: Jin10Connection) -> dict: + return {"connected": row.token_encrypted is not None, "connected_at": row.connected_at, + "error": row.error, "sync_state": "awaiting_validation" if row.token_encrypted else "disconnected"} + + +@router.get("") +async def status(session: Session) -> ApiResponse[dict]: + return success(public_status(await connection(session))) + + +@router.post("/qr", status_code=201) +async def create_qr(session: Session, principal: Principal) -> ApiResponse[dict]: + row = await connection(session, lock=True) + try: + token = await jin10_auth.generate_qr() + except jin10_auth.Jin10Error as exc: + raise HTTPException(502, str(exc)) from None + await check_session(principal) + row.task_id, row.owner = secrets.token_urlsafe(24), principal.jti + row.qr_encrypted = encrypt(token) + row.expires_at = datetime.now(UTC) + timedelta(seconds=60) + row.next_poll_at = datetime.now(UTC) + timedelta(seconds=5) + row.error = None + await session.commit() + return success({"task_id": row.task_id, "image": jin10_auth.qr_image(token), "expires_at": row.expires_at}) + + +async def owned_task(session: AsyncSession, principal: AuthSession, task_id: str) -> Jin10Connection: + row = await connection(session, lock=True) + if row.task_id != task_id or row.owner != principal.jti: + raise HTTPException(404, "扫码任务不存在或已失效") + return row + + +@router.post("/qr/{task_id}/poll") +async def poll(task_id: str, session: Session, principal: Principal) -> ApiResponse[dict]: + row = await owned_task(session, principal, task_id) + now = datetime.now(UTC) + if row.expires_at <= now: + clear_task(row) + await session.commit() + return success({"state": "expired"}) + if row.next_poll_at > now: + return success({"state": "pending"}) + token = decrypt(row.qr_encrypted) + # Claim the remaining QR lifetime, then release the lock before network I/O. + # A failed request can be retried with a new QR; overlapping polls cannot log in twice. + row.next_poll_at = row.expires_at + await session.commit() + error = None + try: + state, credential = await jin10_auth.poll_qr(token) + except jin10_auth.Jin10Error as exc: + error = str(exc) + # Cancellation, replacement and disconnection must invalidate both success and failure. + row = await owned_task(session, principal, task_id) + await check_session(principal) + # Recheck expiry after network I/O, including user confirmation latency. + if datetime.now(UTC) >= row.expires_at: + state = "expired" + elif error is not None: + row.error = error + clear_task(row) + await session.commit() + raise HTTPException(502, error) + if state == "connected": + row.token_encrypted = encrypt(credential) + row.connected_at = datetime.now(UTC) + row.error = None + if state != "pending": + clear_task(row) + else: + row.next_poll_at = datetime.now(UTC) + timedelta(seconds=5) + await session.commit() + return success({"state": state}) + + +@router.delete("/qr/{task_id}") +async def cancel(task_id: str, session: Session, principal: Principal) -> ApiResponse[None]: + row = await owned_task(session, principal, task_id) + await check_session(principal) + clear_task(row) + await session.commit() + return success(None) + + +@router.delete("") +async def disconnect(session: Session, principal: Principal) -> ApiResponse[None]: + row = await connection(session, lock=True) + await check_session(principal) + clear_task(row) + row.token_encrypted = row.connected_at = row.error = None + await session.commit() + return success(None) diff --git a/backend/src/alphapulse/api/main.py b/backend/src/alphapulse/api/main.py index 4289200..e86c278 100644 --- a/backend/src/alphapulse/api/main.py +++ b/backend/src/alphapulse/api/main.py @@ -5,7 +5,7 @@ from fastapi.exceptions import RequestValidationError from fastapi.responses import RedirectResponse from starlette.exceptions import HTTPException -from alphapulse.api import auth, calendar, chat, crawler, market, news, settings, skills, trading, risk +from alphapulse.api import auth, calendar, chat, crawler, market, news, settings, skills, trading, risk, jin10 from alphapulse.api.response import ( ApiResponse, ValidationErrors, http_error, internal_error, success, validation_error, ) @@ -30,6 +30,7 @@ app.include_router(chat.router) app.include_router(trading.router) app.include_router(calendar.router) app.include_router(risk.router) +app.include_router(jin10.router) @app.get("/api/health", include_in_schema=False) diff --git a/backend/src/alphapulse/calendar/jin10_auth.py b/backend/src/alphapulse/calendar/jin10_auth.py new file mode 100644 index 0000000..af3ce0c --- /dev/null +++ b/backend/src/alphapulse/calendar/jin10_auth.py @@ -0,0 +1,66 @@ +"""Use the QR endpoints published by Jin10's current login component.""" +import base64 +from io import BytesIO + +import httpx +import qrcode + +UC_URL = "https://uc-api.jin10.com" +HEADERS = {"User-Agent": "Mozilla/5.0", "Referer": "https://rili.jin10.com/", + "x-app-id": "YDcStOzjliuTJUvh", "x-version": "1.0.0"} + + +class Jin10Error(Exception): + 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: + async with httpx.AsyncClient(timeout=10, trust_env=False) as client: + response = await client.get(UC_URL + path, params=params, headers=headers) + response.raise_for_status() + body = response.json() + if not isinstance(body, dict) or body.get("status") != 200: + raise Jin10Error("金十未接受请求,请重新扫码或稍后重试") + return body + except (httpx.HTTPError, ValueError) as exc: + # Upstream messages and request URLs may contain credentials. + raise Jin10Error("金十连接失败,请稍后重试") from exc + + +async def generate_qr() -> str: + body = await uc_request("/qrcode/generate", {"device_type": "web"}) + value = body.get("data") + if isinstance(value, dict): + value = value.get("token") + if not isinstance(value, str) or not value or len(value) > 2048: + raise Jin10Error("金十二维码响应格式已变化") + return value + + +def qr_image(token: str) -> str: + from urllib.parse import urlencode + image = qrcode.make("https://www.jin10.com/?" + urlencode({"action": "login", "tag": token})) + stream = BytesIO() + image.save(stream, format="PNG") + return "data:image/png;base64," + base64.b64encode(stream.getvalue()).decode() + + +async def poll_qr(token: str) -> tuple[str, str | None]: + body = await uc_request("/qrcode/try_login", {"token": token}) + data = body.get("data") + if data is None: + return "pending", None + if not isinstance(data, dict): + raise Jin10Error("金十扫码状态响应格式已变化") + credential = data.get("token") + if isinstance(credential, str) and credential: + # Confirm that a QR success really yields an authenticated account. + info = await uc_request("/userinfo", token=credential) + if not isinstance(info.get("data"), dict) or not info["data"]: + raise Jin10Error("金十登录态验证失败") + return "connected", credential + if str(data.get("qr_status", "")).lower() in ("expired", "invalid", "timeout"): + return "expired", None + return "pending", None diff --git a/backend/src/alphapulse/models/__init__.py b/backend/src/alphapulse/models/__init__.py index aeaabff..f149a1d 100644 --- a/backend/src/alphapulse/models/__init__.py +++ b/backend/src/alphapulse/models/__init__.py @@ -1,3 +1,4 @@ +from alphapulse.models.jin10 import Jin10Connection from alphapulse.models.risk import RiskSettings, RiskEvent, RiskAnalysis, RiskSnapshot from alphapulse.core.db import Base from alphapulse.models.ai import AiProvider, ChatMessage, ChatSession, ContextVersion, SkillNode, SkillVersion @@ -7,6 +8,7 @@ from alphapulse.models.news import CrawlLog, CrawlRun, NewsItem, NewsRevision, R from alphapulse.models.trading import NewsSignal, TradingAccount, TradingAnalysis, TradingCredential, TradingEvent, TradingFill, TradingOrder, TradingSymbol __all__ = [ + "Jin10Connection", "RiskSettings", "RiskEvent", "RiskAnalysis", "RiskSnapshot", "AiProvider", "Base", diff --git a/backend/src/alphapulse/models/jin10.py b/backend/src/alphapulse/models/jin10.py new file mode 100644 index 0000000..79ea554 --- /dev/null +++ b/backend/src/alphapulse/models/jin10.py @@ -0,0 +1,21 @@ +"""Encrypted connection and short-lived, session-owned QR login state.""" +from datetime import datetime + +from sqlalchemy import DateTime, LargeBinary, String, Text +from sqlalchemy.orm import Mapped, mapped_column + +from alphapulse.core.db import Base + + +class Jin10Connection(Base): + __tablename__ = "jin10_connection" + + id: Mapped[int] = mapped_column(primary_key=True) + token_encrypted: Mapped[bytes | None] = mapped_column(LargeBinary) + connected_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True)) + task_id: Mapped[str | None] = mapped_column(String(64)) + owner: Mapped[str | None] = mapped_column(String(64)) + qr_encrypted: Mapped[bytes | None] = mapped_column(LargeBinary) + expires_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True)) + next_poll_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True)) + error: Mapped[str | None] = mapped_column(Text) diff --git a/backend/tests/test_jin10.py b/backend/tests/test_jin10.py new file mode 100644 index 0000000..3d77f0b --- /dev/null +++ b/backend/tests/test_jin10.py @@ -0,0 +1,227 @@ +"""QR tests isolate all third-party, database and session I/O.""" +import asyncio +from datetime import UTC, datetime, timedelta +from types import SimpleNamespace +from unittest.mock import AsyncMock + +import httpx +import pytest + + +@pytest.fixture +def qr_env(trading, monkeypatch): + from alphapulse.api import jin10 + from alphapulse.calendar import jin10_auth + from alphapulse.core.auth import AuthSession + from alphapulse.core.crypto import encrypt + from fastapi import FastAPI + from alphapulse.core.db import get_session + from alphapulse.core.auth import require_admin + row = SimpleNamespace(id=1, token_encrypted=None, connected_at=None, task_id=None, + owner=None, qr_encrypted=None, expires_at=None, next_poll_at=None, error=None) + session = SimpleNamespace(scalar=AsyncMock(return_value=row), commit=AsyncMock()) + principal = AuthSession("test", "session-a", 9999999999) + app = FastAPI() + app.include_router(jin10.router) + app.dependency_overrides[get_session] = lambda: session + monkeypatch.setattr(jin10, "check_session", AsyncMock()) + monkeypatch.setattr(jin10_auth, "generate_qr", AsyncMock(return_value="private-qr-token")) + monkeypatch.setattr(jin10_auth, "poll_qr", AsyncMock(return_value=("pending", None))) + def authorize(): + app.dependency_overrides[require_admin] = lambda: principal + def pending(): + row.task_id, row.owner = "task-a", principal.jti + row.qr_encrypted = encrypt("private-qr-token") + row.expires_at = datetime.now(UTC) + timedelta(seconds=60) + row.next_poll_at = datetime.now(UTC) - timedelta(seconds=1) + return SimpleNamespace(api=jin10, upstream=jin10_auth, app=app, row=row, session=session, + principal=principal, authorize=authorize, pending=pending) + + +def request(env, method, path="", **kwargs): + async def run(): + async with httpx.AsyncClient(transport=httpx.ASGITransport(env.app), base_url="http://test") as client: + return await client.request(method, "/api/settings/jin10" + path, **kwargs) + return asyncio.run(run()) + + +@pytest.mark.parametrize("method,path", [("GET", ""), ("POST", "/qr"), ("POST", "/qr/task-a/poll"), + ("DELETE", "/qr/task-a"), ("DELETE", "")]) +def test_auth_required(qr_env, method, path): + assert request(qr_env, method, path).status_code == 401 + qr_env.session.scalar.assert_not_awaited() + + +def test_create_hides_credentials_and_invalidates_old_task(qr_env): + from alphapulse.core.crypto import decrypt + e = qr_env + e.authorize() + e.pending() + response = request(e, "POST", "/qr") + assert response.status_code == 201 + body = response.json()["data"] + assert body["image"].startswith("data:image/png;base64,") + assert "private-qr-token" not in response.text + assert e.row.task_id != "task-a" + assert decrypt(e.row.qr_encrypted) == "private-qr-token" + assert e.row.owner == e.principal.jti + assert request(e, "POST", "/qr/task-a/poll").status_code == 404 + + +@pytest.mark.parametrize("owner,expired", [("different-session", False), ("session-a", True)]) +def test_owner_and_expiry_block_upstream(qr_env, owner, expired): + e = qr_env + e.authorize() + e.pending() + e.row.owner = owner + if expired: + e.row.expires_at = datetime.now(UTC) - timedelta(seconds=1) + response = request(e, "POST", "/qr/task-a/poll") + assert response.status_code == (200 if expired else 404) + if expired: + assert response.json()["data"]["state"] == "expired" + assert e.row.qr_encrypted is None + e.upstream.poll_qr.assert_not_awaited() + + +def test_poll_throttles_and_stores_success_encrypted(qr_env): + from alphapulse.core.crypto import decrypt + e = qr_env + e.authorize() + e.pending() + e.row.next_poll_at = datetime.now(UTC) + timedelta(seconds=5) + assert request(e, "POST", "/qr/task-a/poll").json()["data"]["state"] == "pending" + e.upstream.poll_qr.assert_not_awaited() + e.row.next_poll_at = datetime.now(UTC) - timedelta(seconds=1) + e.upstream.poll_qr.return_value = "connected", "private-account-token" + response = request(e, "POST", "/qr/task-a/poll") + assert response.json()["data"]["state"] == "connected" + assert "private-account-token" not in response.text + assert decrypt(e.row.token_encrypted) == "private-account-token" + assert e.row.qr_encrypted is None + status = request(e, "GET") + assert status.json()["data"]["sync_state"] == "awaiting_validation" + assert "private-account-token" not in status.text + + +@pytest.mark.parametrize("failure", [False, True]) +def test_revoke_during_upstream_cannot_save_result(qr_env, failure): + from fastapi import HTTPException + e = qr_env + e.authorize() + e.pending() + e.upstream.poll_qr.return_value = "connected", "private-account-token" + if failure: + e.upstream.poll_qr.side_effect = e.upstream.Jin10Error("upstream failed") + e.api.check_session.side_effect = HTTPException(401, "expired") + assert request(e, "POST", "/qr/task-a/poll").status_code == 401 + assert e.row.token_encrypted is None + assert e.row.error is None + assert e.session.commit.await_count == 1 # Only the poll claim was committed. + + +@pytest.mark.parametrize("action", ["cancel", "replace", "disconnect"]) +@pytest.mark.parametrize("failure", [False, True]) +def test_invalidated_poll_cannot_change_connection(qr_env, action, failure): + e = qr_env + e.authorize() + e.pending() + + async def run(): + entered, release = asyncio.Event(), asyncio.Event() + + async def upstream(token): + entered.set() + await release.wait() + if failure: + raise e.upstream.Jin10Error("upstream failed") + return "connected", "private-account-token" + + e.upstream.poll_qr.side_effect = upstream + async with httpx.AsyncClient(transport=httpx.ASGITransport(e.app), base_url="http://test") as client: + polling = asyncio.create_task(client.post("/api/settings/jin10/qr/task-a/poll")) + await asyncio.wait_for(entered.wait(), 1) + try: + if action == "replace": + response = await client.post("/api/settings/jin10/qr") + assert response.status_code == 201 + replacement = response.json()["data"]["task_id"] + else: + path = "/qr/task-a" if action == "cancel" else "" + response = await client.delete("/api/settings/jin10" + path) + assert response.status_code == 200 + commits = e.session.commit.await_count + finally: + release.set() + assert (await polling).status_code == 404 + assert e.session.commit.await_count == commits + assert e.row.token_encrypted is None + assert e.row.error is None + assert e.row.task_id == (replacement if action == "replace" else None) + + asyncio.run(run()) + + +def test_overlapping_polls_only_contact_upstream_once(qr_env): + e = qr_env + e.authorize() + e.pending() + + async def run(): + entered, release = asyncio.Event(), asyncio.Event() + + async def upstream(token): + entered.set() + await release.wait() + return "pending", None + + e.upstream.poll_qr.side_effect = upstream + async with httpx.AsyncClient(transport=httpx.ASGITransport(e.app), base_url="http://test") as client: + first = asyncio.create_task(client.post("/api/settings/jin10/qr/task-a/poll")) + await asyncio.wait_for(entered.wait(), 1) + try: + duplicate = await client.post("/api/settings/jin10/qr/task-a/poll") + assert duplicate.json()["data"]["state"] == "pending" + assert e.upstream.poll_qr.await_count == 1 + finally: + release.set() + assert (await first).json()["data"]["state"] == "pending" + e.upstream.poll_qr.return_value = "connected", "private-account-token" + e.upstream.poll_qr.side_effect = None + e.row.next_poll_at = datetime.now(UTC) - timedelta(seconds=1) + assert (await client.post("/api/settings/jin10/qr/task-a/poll")).json()["data"]["state"] == "connected" + + asyncio.run(run()) + + +def test_disconnect_and_cancel_do_not_contact_remote_logout(qr_env): + e = qr_env + e.authorize() + e.pending() + assert request(e, "DELETE", "/qr/task-a").status_code == 200 + assert e.row.qr_encrypted is None + e.row.token_encrypted = b"encrypted" + assert request(e, "DELETE").status_code == 200 + assert e.row.token_encrypted is None + e.upstream.poll_qr.assert_not_awaited() + + +@pytest.mark.parametrize("data,state", [(None, "pending"), ({}, "pending"), ({"qr_status": "timeout"}, "expired")]) +def test_sdk_pending_and_expiry_responses(qr_env, monkeypatch, data, state): + from alphapulse.calendar import jin10_auth + # Restore the real parser, while keeping HTTP isolated. + import importlib + jin10_auth = importlib.reload(jin10_auth) + monkeypatch.setattr(jin10_auth, "uc_request", AsyncMock(return_value={"status": 200, "data": data})) + assert asyncio.run(jin10_auth.poll_qr("private")) == (state, None) + + +def test_upstream_errors_never_expose_tokens(qr_env, monkeypatch): + from alphapulse.calendar import jin10_auth + import importlib + jin10_auth = importlib.reload(jin10_auth) + mock = AsyncMock(side_effect=httpx.ConnectError("private-token-in-url")) + monkeypatch.setattr(httpx.AsyncClient, "get", mock) + 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) diff --git a/backend/uv.lock b/backend/uv.lock index 4089a8a..19f0cb6 100644 --- a/backend/uv.lock +++ b/backend/uv.lock @@ -181,6 +181,7 @@ dependencies = [ { name = "pydantic-settings" }, { name = "pyjwt" }, { name = "pypdf" }, + { name = "qrcode", extra = ["pil"] }, { name = "redis" }, { name = "scrapy" }, { name = "scrapy-redis" }, @@ -207,6 +208,7 @@ requires-dist = [ { name = "pydantic-settings", specifier = ">=2.15.0" }, { name = "pyjwt", specifier = ">=2.15.1" }, { name = "pypdf", specifier = ">=6.21.2" }, + { name = "qrcode", extras = ["pil"], specifier = ">=8.2,<9" }, { name = "redis", specifier = ">=8.1.0" }, { name = "scrapy", specifier = ">=2.19.0" }, { name = "scrapy-redis", specifier = ">=0.9.1" }, @@ -2036,6 +2038,23 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/f1/12/de94a39c2ef588c7e6455cfbe7343d3b2dc9d6b6b2f40c4c6565744c873d/pyyaml-6.0.3-cp314-cp314t-win_arm64.whl", hash = "sha256:ebc55a14a21cb14062aa4162f906cd962b28e2e9ea38f9b4391244cd8de4ae0b", size = 149341, upload-time = "2025-09-25T21:32:56.828Z" }, ] +[[package]] +name = "qrcode" +version = "8.2" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "colorama", marker = "sys_platform == 'win32'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/8f/b2/7fc2931bfae0af02d5f53b174e9cf701adbb35f39d69c2af63d4a39f81a9/qrcode-8.2.tar.gz", hash = "sha256:35c3f2a4172b33136ab9f6b3ef1c00260dd2f66f858f24d88418a015f446506c", size = 43317, upload-time = "2025-05-01T15:44:24.726Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/dd/b8/d2d6d731733f51684bbf76bf34dab3b70a9148e8f2cef2bb544fccec681a/qrcode-8.2-py3-none-any.whl", hash = "sha256:16e64e0716c14960108e85d853062c9e8bba5ca8252c0b4d0231b9df4060ff4f", size = 45986, upload-time = "2025-05-01T15:44:22.781Z" }, +] + +[package.optional-dependencies] +pil = [ + { name = "pillow" }, +] + [[package]] name = "queuelib" version = "1.10.0" diff --git a/web/src/common/apis/jin10/index.ts b/web/src/common/apis/jin10/index.ts new file mode 100644 index 0000000..21c8a3b --- /dev/null +++ b/web/src/common/apis/jin10/index.ts @@ -0,0 +1,18 @@ +import type * as Jin10 from "./type" +import { request } from "@/http/axios" + +export function getJin10Api() { + return request({ url: "/settings/jin10", method: "get" }) +} +export function createJin10QrApi() { + return request({ url: "/settings/jin10/qr", method: "post" }) +} +export function pollJin10QrApi(id: string) { + return request({ url: `/settings/jin10/qr/${id}/poll`, method: "post" }) +} +export function cancelJin10QrApi(id: string) { + return request({ url: `/settings/jin10/qr/${id}`, method: "delete", notify: false }) +} +export function disconnectJin10Api() { + return request({ url: "/settings/jin10", method: "delete" }) +} diff --git a/web/src/common/apis/jin10/type.ts b/web/src/common/apis/jin10/type.ts new file mode 100644 index 0000000..fe62dc6 --- /dev/null +++ b/web/src/common/apis/jin10/type.ts @@ -0,0 +1,14 @@ +export interface Jin10Status { + connected: boolean + connected_at: string | null + error: string | null + sync_state: "awaiting_validation" | "disconnected" +} +export interface Jin10Qr { + task_id: string + image: string + expires_at: string +} +export interface Jin10Poll { + state: "pending" | "connected" | "expired" +} diff --git a/web/src/pages/settings/components/Jin10Settings.vue b/web/src/pages/settings/components/Jin10Settings.vue new file mode 100644 index 0000000..26fdf5b --- /dev/null +++ b/web/src/pages/settings/components/Jin10Settings.vue @@ -0,0 +1,163 @@ + + + + + diff --git a/web/src/pages/settings/index.vue b/web/src/pages/settings/index.vue index f5c6da1..b423ee8 100644 --- a/web/src/pages/settings/index.vue +++ b/web/src/pages/settings/index.vue @@ -1,11 +1,13 @@ diff --git a/web/tests/jin10.test.ts b/web/tests/jin10.test.ts new file mode 100644 index 0000000..9e3bb72 --- /dev/null +++ b/web/tests/jin10.test.ts @@ -0,0 +1,181 @@ +import assert from "node:assert/strict" +import test from "node:test" +import * as vue from "vue" +import { moduleLoader } from "./module-loader.ts" + +function setup(overrides: Record = {}) { + const user = { sessionVersion: 1 } + const unmountHooks: (() => void)[] = [] + const onBeforeUnmount = (hook: () => void) => unmountHooks.push(hook) + const cancelled: string[] = [] + const messages: string[] = [] + const timers: (() => Promise)[] = [] + const load = moduleLoader({ + "vue": { ...vue, onBeforeUnmount }, + "@/pinia/stores/user": { useUserStore: () => user }, + "@@/apis/jin10": { + createJin10QrApi: async () => ({ task_id: "a", image: "png", expires_at: "later" }), + pollJin10QrApi: async () => ({ state: "connected" }), + cancelJin10QrApi: async (id: string) => cancelled.push(id), + getJin10Api: async () => ({ connected: true, sync_state: "awaiting_validation" }), + disconnectJin10Api: async () => {}, + ...overrides + } + }, { + ...vue, + onMounted() {}, + onBeforeUnmount, + setTimeout: (fn: (...args: unknown[]) => Promise, _delay: number, ...args: unknown[]) => { + timers.push(() => fn(...args)) + return timers.length + }, + clearTimeout() {}, + ElMessage: { success: (message: string) => messages.push(message) }, + ElMessageBox: { confirm: async () => {} } + }) + return { + page: load("@/pages/settings/components/Jin10Settings.vue").default.setup({}, { expose() {} }), + cancelled, + messages, + timers, + unmount: () => unmountHooks.forEach(hook => hook()), + invalidate: () => { + user.sessionVersion++ + } + } +} + +test("QR generation is serialized and closing cancels a late response", async () => { + const pending = Promise.withResolvers() + let creates = 0 + const env = setup({ createJin10QrApi: () => { + creates++ + return pending.promise + } }) + const first = env.page.start() + await env.page.start() + assert.equal(creates, 1) + env.page.visible.value = false + await vue.nextTick() + pending.resolve({ task_id: "old", image: "png", expires_at: "later" }) + await first + assert.equal(env.page.qr.value, undefined) + assert.deepEqual(env.cancelled, ["old"]) + assert.equal(env.page.busy.value, false) + assert.equal(env.timers.length, 0) +}) + +test("a closed QR dialog ignores a late successful poll", async () => { + const pending = Promise.withResolvers() + const env = setup({ pollJin10QrApi: () => pending.promise }) + await env.page.start() + const polling = env.timers[0]() + env.page.visible.value = false + await vue.nextTick() + pending.resolve({ state: "connected" }) + await polling + assert.deepEqual(env.messages, []) + assert.equal(env.page.status.value, undefined) +}) + +test("successful login reports connection without claiming calendar sync", async () => { + const env = setup() + await env.page.start() + await env.timers[0]() + assert.equal(env.page.visible.value, false) + assert.equal(env.page.status.value.sync_state, "awaiting_validation") + assert.match(env.messages[0], /待核验/) +}) + +test("session changes discard QR responses and never cancel under a new identity", async () => { + const pending = Promise.withResolvers() + const env = setup({ createJin10QrApi: () => pending.promise }) + const first = env.page.start() + env.invalidate() + pending.resolve({ task_id: "old-session", image: "png", expires_at: "later" }) + await first + assert.equal(env.page.qr.value, undefined) + assert.deepEqual(env.cancelled, []) +}) + +test("unmount cancels the QR after the real session scope has disposed", async () => { + const env = setup() + await env.page.start() + env.unmount() + await env.timers[0]() + assert.deepEqual(env.cancelled, ["a"]) + assert.equal(env.page.qr.value, undefined) + assert.deepEqual(env.messages, []) +}) + +test("a QR created after unmount is cancelled without changing the disposed page", async () => { + const pending = Promise.withResolvers() + const env = setup({ createJin10QrApi: () => pending.promise }) + const starting = env.page.start() + env.unmount() + pending.resolve({ task_id: "late", image: "png", expires_at: "later" }) + await starting + assert.deepEqual(env.cancelled, ["late"]) + assert.equal(env.page.qr.value, undefined) + assert.equal(env.timers.length, 0) +}) + +test("unmount never cancels a task under a changed identity", async () => { + const env = setup() + await env.page.start() + env.invalidate() + env.unmount() + assert.deepEqual(env.cancelled, []) +}) + +test("an older status response cannot overwrite a newer response", async () => { + const older = Promise.withResolvers() + let requests = 0 + const env = setup({ getJin10Api: () => ++requests === 1 ? older.promise : Promise.resolve({ connected: false }) }) + const loading = env.page.load() + await env.page.load() + older.resolve({ connected: true }) + await loading + assert.equal(env.page.status.value.connected, false) + assert.equal(env.page.failed.value, false) +}) + +test("an older status error cannot replace a newer successful response", async () => { + const older = Promise.withResolvers() + let requests = 0 + const env = setup({ getJin10Api: () => ++requests === 1 ? older.promise : Promise.resolve({ connected: false }) }) + const loading = env.page.load() + await env.page.load() + older.reject(new Error("old request failed")) + await loading + assert.equal(env.page.status.value.connected, false) + assert.equal(env.page.failed.value, false) +}) + +test("disconnect invalidates status requests before its API response", async () => { + const oldStatus = Promise.withResolvers() + const removing = Promise.withResolvers() + let requests = 0 + const env = setup({ + getJin10Api: () => ++requests === 1 ? oldStatus.promise : Promise.resolve({ connected: false }), + disconnectJin10Api: () => removing.promise + }) + const loading = env.page.load() + const disconnecting = env.page.disconnect() + await vue.nextTick() + oldStatus.resolve({ connected: true }) + await loading + assert.equal(env.page.status.value, undefined) + removing.resolve() + await disconnecting + assert.equal(env.page.status.value.connected, false) +}) + +test("a stale disconnect confirmation cannot cancel a newer QR", async () => { + const env = setup() + const disconnecting = env.page.disconnect() + await env.page.start() + await disconnecting + assert.equal(env.page.qr.value.task_id, "a") + assert.deepEqual(env.cancelled, []) +})