integrate sub2api as upstream for auth/keys/usage via FastAPI BFF

Preserve local user table for superDream-specific features while syncing
user lifecycle, API key CRUD and usage queries through sub2api. Admin token
handles reads and user lifecycle; per-user tokens (Fernet-encrypted in DB)
handle key writes that admin endpoints do not expose.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
xuyong
2026-04-17 21:23:08 +08:00
parent 20e842a60a
commit 35c0b7de16
30 changed files with 1707 additions and 803 deletions

View File

@@ -1,84 +1,111 @@
import hashlib
import secrets
import uuid
"""API Key service: proxied to sub2api.
from sqlalchemy import select, func
- Reads use the admin API key to call ``/admin/users/:id/api-keys``.
- Writes (create/update/delete) require the user's own sub2api JWT; we fetch
one via ``ensure_access_token`` using the stored refresh token.
"""
from __future__ import annotations
from typing import Any
from fastapi import HTTPException
from sqlalchemy.ext.asyncio import AsyncSession
from app.models import ApiKey
from app.core.exceptions import BadRequestError, NotFoundError
from app.core.exceptions import BadRequestError
from app.integrations.sub2api import admin as sub2api_admin
from app.integrations.sub2api import user as sub2api_user
from app.integrations.sub2api.client import (
Sub2APIError,
Sub2APIReauthRequired,
Sub2APITransportError,
)
from app.models import User
from app.services.sub2api_session import ensure_access_token
MAX_KEYS_PER_USER = 5
KEY_PREFIX = "sk-sd-"
def _require_sub2api_binding(user: User) -> int:
if not user.sub2api_user_id:
raise BadRequestError("账号未完成 sub2api 绑定,请重新登录")
return user.sub2api_user_id
def _translate_upstream(exc: Exception) -> HTTPException:
if isinstance(exc, Sub2APIReauthRequired):
return HTTPException(status_code=401, detail="sub2api_reauth_required")
if isinstance(exc, Sub2APIError):
status = exc.http_status or 502
if status < 400 or status >= 600:
status = 502
return HTTPException(status_code=status, detail=exc.message or exc.reason or "upstream_error")
return HTTPException(status_code=504, detail="upstream_timeout")
class KeyService:
@staticmethod
async def list_keys(
db: AsyncSession,
user: User,
*,
page: int = 1,
page_size: int = 20,
sort_by: str = "created_at",
sort_order: str = "desc",
) -> dict[str, Any]:
uid = _require_sub2api_binding(user)
try:
return await sub2api_admin.list_user_api_keys(
uid,
page=page,
page_size=page_size,
sort_by=sort_by,
sort_order=sort_order,
)
except (Sub2APIError, Sub2APITransportError) as exc:
raise _translate_upstream(exc) from exc
@staticmethod
def _generate_key() -> str:
return KEY_PREFIX + secrets.token_urlsafe(36)
async def create_key(db: AsyncSession, user: User, payload: dict[str, Any]) -> dict[str, Any]:
_require_sub2api_binding(user)
try:
token = await ensure_access_token(db, user)
return await sub2api_user.create_key(token, payload)
except (Sub2APIError, Sub2APITransportError) as exc:
raise _translate_upstream(exc) from exc
@staticmethod
def _hash_key(raw_key: str) -> str:
return hashlib.sha256(raw_key.encode()).hexdigest()
async def update_key(
db: AsyncSession, user: User, key_id: int, payload: dict[str, Any]
) -> dict[str, Any]:
_require_sub2api_binding(user)
try:
token = await ensure_access_token(db, user)
return await sub2api_user.update_key(token, key_id, payload)
except (Sub2APIError, Sub2APITransportError) as exc:
raise _translate_upstream(exc) from exc
@staticmethod
async def list_keys(db: AsyncSession, user_id: str) -> list:
result = await db.execute(
select(ApiKey)
.where(ApiKey.user_id == user_id, ApiKey.status == "active")
.order_by(ApiKey.created_at.desc())
)
return result.scalars().all()
async def delete_key(db: AsyncSession, user: User, key_id: int) -> dict[str, Any]:
_require_sub2api_binding(user)
try:
token = await ensure_access_token(db, user)
return await sub2api_user.delete_key(token, key_id)
except (Sub2APIError, Sub2APITransportError) as exc:
raise _translate_upstream(exc) from exc
@staticmethod
async def create_key(db: AsyncSession, user_id: str, name: str = "") -> dict:
# Check limit
count_result = await db.execute(
select(func.count()).select_from(ApiKey)
.where(ApiKey.user_id == user_id, ApiKey.status == "active")
)
count = count_result.scalar()
if count >= MAX_KEYS_PER_USER:
raise BadRequestError(f"最多创建 {MAX_KEYS_PER_USER} 个 Key")
raw_key = KeyService._generate_key()
key_hash = KeyService._hash_key(raw_key)
# prefix/suffix for masked display (after "sk-sd-")
body = raw_key[len(KEY_PREFIX):]
key_prefix = body[:4]
key_suffix = body[-4:]
api_key = ApiKey(
id=str(uuid.uuid4()),
user_id=user_id,
name=name,
key_hash=key_hash,
key_prefix=key_prefix,
key_suffix=key_suffix,
)
db.add(api_key)
await db.commit()
await db.refresh(api_key)
return {
"id": api_key.id,
"name": api_key.name,
"key": raw_key, # only returned once
"key_prefix": key_prefix,
"key_suffix": key_suffix,
"created_at": api_key.created_at,
}
async def get_key(db: AsyncSession, user: User, key_id: int) -> dict[str, Any]:
_require_sub2api_binding(user)
try:
token = await ensure_access_token(db, user)
return await sub2api_user.get_key(token, key_id)
except (Sub2APIError, Sub2APITransportError) as exc:
raise _translate_upstream(exc) from exc
@staticmethod
async def delete_key(db: AsyncSession, user_id: str, key_id: str) -> None:
result = await db.execute(
select(ApiKey).where(ApiKey.id == key_id, ApiKey.user_id == user_id)
)
api_key = result.scalar_one_or_none()
if not api_key:
raise NotFoundError("Key not found")
api_key.status = "revoked"
await db.commit()
async def available_groups(db: AsyncSession, user: User) -> list[dict[str, Any]]:
_require_sub2api_binding(user)
try:
token = await ensure_access_token(db, user)
return await sub2api_user.list_available_groups(token)
except (Sub2APIError, Sub2APITransportError) as exc:
raise _translate_upstream(exc) from exc