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:
1 parent
5a991e3d29
commit
62d8c7eb65
14 files changed
+871
-1
No files matched your search
@@ -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)
|
||||
@@ -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",
|
||||
|
||||
@@ -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)
|
||||
Reference in new issue
Block a user