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:
@@ -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
|
||||
Reference in New Issue
Block a user