Add Jin10 QR connection with cancellable login tasks

Store account credentials encrypted and bind QR tasks to administrator sessions. Report calendar access as awaiting validation after login.

Prevent late polls and status responses from overwriting cancelled or newer operations, and clean up QR tasks when settings unmount. Cover authentication, cancellation races, and stale frontend responses.
This commit is contained in:
eezd committed 2026-10-30 22:42:26 +08:00
1 parent 5a991e3d29
commit 62d8c7eb65
14 files changed
+871 -1

No files matched your search

@@ -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")
+1
View File
@@ -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",
+129
View File
@@ -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)
+2 -1
View File
@@ -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)
@@ -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
@@ -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",
+21
View File
@@ -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)
+227
View File
@@ -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)
+19
View File
@@ -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"