diff --git a/app/api/v1/auth.py b/app/api/v1/auth.py index ccb46cb..a2acaf9 100644 --- a/app/api/v1/auth.py +++ b/app/api/v1/auth.py @@ -3,12 +3,20 @@ from sqlalchemy.ext.asyncio import AsyncSession from app.core.database import get_db from app.core.dependencies import get_current_user +from app.datamodels.schemas import ( + ForgotPasswordRequest, + LoginRequest, + MessageResponse, + RefreshRequest, + RegisterRequest, + ResetPasswordRequest, + TokenResponse, + UserResponse, +) +from app.integrations.sub2api import admin as sub2api_admin +from app.integrations.sub2api.client import Sub2APIError, Sub2APITransportError from app.models import User from app.services.auth_service import AuthService -from app.datamodels.schemas import ( - RegisterRequest, LoginRequest, TokenResponse, RefreshRequest, - ForgotPasswordRequest, ResetPasswordRequest, UserResponse, MessageResponse, -) router = APIRouter(prefix="/auth", tags=["auth"]) @@ -16,7 +24,7 @@ router = APIRouter(prefix="/auth", tags=["auth"]) @router.post("/register", response_model=UserResponse) async def register(body: RegisterRequest, db: AsyncSession = Depends(get_db)): user = await AuthService.register(db, body.email, body.password) - return user + return _user_to_response(user) @router.post("/login", response_model=TokenResponse) @@ -38,7 +46,7 @@ async def logout(): async def forgot_password(body: ForgotPasswordRequest, db: AsyncSession = Depends(get_db)): token = await AuthService.forgot_password(db, body.email) if token: - # MVP: print token to console; production: send email + # MVP: print to console; production should send via email print(f"[Password Reset] email={body.email} token={token}") return {"message": "If the email exists, a reset link has been sent"} @@ -51,4 +59,24 @@ async def reset_password(body: ResetPasswordRequest, db: AsyncSession = Depends( @router.get("/me", response_model=UserResponse) async def me(user: User = Depends(get_current_user)): - return user + """Merge the local row with sub2api's live balance when available.""" + response = _user_to_response(user) + if user.sub2api_user_id: + try: + remote = await sub2api_admin.get_user(user.sub2api_user_id) + response.balance = float(remote.get("balance") or 0) + except (Sub2APIError, Sub2APITransportError): + # non-fatal; fall back to 0 + pass + return response + + +def _user_to_response(user: User) -> UserResponse: + return UserResponse( + id=user.id, + email=user.email, + status=user.status, + created_at=user.created_at, + sub2api_user_id=user.sub2api_user_id, + balance=0.0, + ) \ No newline at end of file diff --git a/app/api/v1/keys.py b/app/api/v1/keys.py index abd519d..94c7d64 100644 --- a/app/api/v1/keys.py +++ b/app/api/v1/keys.py @@ -1,39 +1,74 @@ -from typing import List +from typing import Any, Dict, List -from fastapi import APIRouter, Depends +from fastapi import APIRouter, Depends, Query from sqlalchemy.ext.asyncio import AsyncSession from app.core.database import get_db from app.core.dependencies import get_current_user +from app.datamodels.schemas import CreateKeyRequest, MessageResponse, UpdateKeyRequest from app.models import User from app.services.key_service import KeyService -from app.datamodels.schemas import CreateKeyRequest, ApiKeyResponse, ApiKeyCreatedResponse, MessageResponse router = APIRouter(prefix="/keys", tags=["keys"]) -@router.get("", response_model=List[ApiKeyResponse]) +@router.get("") async def list_keys( + page: int = Query(1, ge=1), + page_size: int = Query(20, ge=1, le=100), + sort_by: str = Query("created_at"), + sort_order: str = Query("desc"), user: User = Depends(get_current_user), db: AsyncSession = Depends(get_db), -): - return await KeyService.list_keys(db, user.id) +) -> Dict[str, Any]: + return await KeyService.list_keys( + db, user, page=page, page_size=page_size, sort_by=sort_by, sort_order=sort_order + ) -@router.post("", response_model=ApiKeyCreatedResponse) +@router.get("/meta/available-groups") +async def available_groups( + user: User = Depends(get_current_user), + db: AsyncSession = Depends(get_db), +) -> List[Dict[str, Any]]: + return await KeyService.available_groups(db, user) + + +@router.get("/{key_id}") +async def get_key( + key_id: int, + user: User = Depends(get_current_user), + db: AsyncSession = Depends(get_db), +) -> Dict[str, Any]: + return await KeyService.get_key(db, user, key_id) + + +@router.post("") async def create_key( body: CreateKeyRequest, user: User = Depends(get_current_user), db: AsyncSession = Depends(get_db), -): - return await KeyService.create_key(db, user.id, body.name) +) -> Dict[str, Any]: + payload = body.model_dump(exclude_none=True) + return await KeyService.create_key(db, user, payload) + + +@router.put("/{key_id}") +async def update_key( + key_id: int, + body: UpdateKeyRequest, + user: User = Depends(get_current_user), + db: AsyncSession = Depends(get_db), +) -> Dict[str, Any]: + payload = body.model_dump(exclude_none=True) + return await KeyService.update_key(db, user, key_id, payload) @router.delete("/{key_id}", response_model=MessageResponse) async def delete_key( - key_id: str, + key_id: int, user: User = Depends(get_current_user), db: AsyncSession = Depends(get_db), ): - await KeyService.delete_key(db, user.id, key_id) - return {"message": "Key deleted"} + await KeyService.delete_key(db, user, key_id) + return {"message": "API key deleted successfully"} \ No newline at end of file diff --git a/app/api/v1/usage.py b/app/api/v1/usage.py index 1fd2e78..43284dc 100644 --- a/app/api/v1/usage.py +++ b/app/api/v1/usage.py @@ -1,64 +1,112 @@ -from datetime import date -from typing import List, Optional +from typing import Any, Dict, Optional from fastapi import APIRouter, Depends, Query from sqlalchemy.ext.asyncio import AsyncSession from app.core.database import get_db from app.core.dependencies import get_current_user +from app.datamodels.schemas import DashboardAPIKeysUsageRequest from app.models import User from app.services.usage_service import UsageService -from app.datamodels.schemas import ( - UsageSummaryResponse, DailyUsageResponse, - ModelUsageResponse, KeyUsageResponse, UsageLogResponse, -) router = APIRouter(prefix="/usage", tags=["usage"]) -@router.get("/summary", response_model=UsageSummaryResponse) -async def usage_summary( - user: User = Depends(get_current_user), - db: AsyncSession = Depends(get_db), -): - return await UsageService.summary(db, user.id) - - -@router.get("/daily", response_model=List[DailyUsageResponse]) -async def usage_daily( - start: Optional[date] = Query(None), - end: Optional[date] = Query(None), - user: User = Depends(get_current_user), - db: AsyncSession = Depends(get_db), -): - return await UsageService.daily(db, user.id, start, end) - - -@router.get("/by-model", response_model=List[ModelUsageResponse]) -async def usage_by_model( - user: User = Depends(get_current_user), - db: AsyncSession = Depends(get_db), -): - return await UsageService.by_model(db, user.id) - - -@router.get("/by-key", response_model=List[KeyUsageResponse]) -async def usage_by_key( - user: User = Depends(get_current_user), - db: AsyncSession = Depends(get_db), -): - return await UsageService.by_key(db, user.id) - - -@router.get("/logs", response_model=List[UsageLogResponse]) -async def usage_logs( +@router.get("") +async def list_logs( page: int = Query(1, ge=1), - size: int = Query(20, ge=1, le=100), + page_size: int = Query(20, ge=1, le=100), + sort_by: str = Query("created_at"), + sort_order: str = Query("desc"), + api_key_id: Optional[int] = Query(None), model: Optional[str] = Query(None), - key_id: Optional[str] = Query(None), - start: Optional[date] = Query(None), - end: Optional[date] = Query(None), + request_type: Optional[str] = Query(None), + stream: Optional[bool] = Query(None), + billing_type: Optional[int] = Query(None), + start_date: Optional[str] = Query(None), + end_date: Optional[str] = Query(None), + timezone: Optional[str] = Query(None), user: User = Depends(get_current_user), db: AsyncSession = Depends(get_db), -): - return await UsageService.logs(db, user.id, page, size, model, key_id, start, end) +) -> Dict[str, Any]: + return await UsageService.list_logs( + db, + user, + page=page, + page_size=page_size, + sort_by=sort_by, + sort_order=sort_order, + api_key_id=api_key_id, + model=model, + request_type=request_type, + stream=stream, + billing_type=billing_type, + start_date=start_date, + end_date=end_date, + timezone=timezone, + ) + + +@router.get("/stats") +async def stats( + period: Optional[str] = Query(None, regex="^(today|week|month)$"), + start_date: Optional[str] = Query(None), + end_date: Optional[str] = Query(None), + api_key_id: Optional[int] = Query(None), + timezone: Optional[str] = Query(None), + user: User = Depends(get_current_user), + db: AsyncSession = Depends(get_db), +) -> Dict[str, Any]: + return await UsageService.stats( + db, + user, + period=period, + start_date=start_date, + end_date=end_date, + api_key_id=api_key_id, + timezone=timezone, + ) + + +@router.get("/dashboard/stats") +async def dashboard_stats( + user: User = Depends(get_current_user), + db: AsyncSession = Depends(get_db), +) -> Dict[str, Any]: + return await UsageService.dashboard_stats(db, user) + + +@router.get("/dashboard/trend") +async def dashboard_trend( + granularity: str = Query("day"), + start_date: Optional[str] = Query(None), + end_date: Optional[str] = Query(None), + timezone: Optional[str] = Query(None), + user: User = Depends(get_current_user), + db: AsyncSession = Depends(get_db), +) -> Dict[str, Any]: + return await UsageService.dashboard_trend( + db, user, granularity=granularity, start_date=start_date, end_date=end_date, timezone=timezone + ) + + +@router.get("/dashboard/models") +async def dashboard_models( + start_date: Optional[str] = Query(None), + end_date: Optional[str] = Query(None), + timezone: Optional[str] = Query(None), + user: User = Depends(get_current_user), + db: AsyncSession = Depends(get_db), +) -> Dict[str, Any]: + return await UsageService.dashboard_models( + db, user, start_date=start_date, end_date=end_date, timezone=timezone + ) + + +@router.post("/dashboard/api-keys-usage") +async def dashboard_api_keys_usage( + body: DashboardAPIKeysUsageRequest, + user: User = Depends(get_current_user), + db: AsyncSession = Depends(get_db), +) -> Dict[str, Any]: + return await UsageService.dashboard_api_keys_usage(db, user, body.api_key_ids) \ No newline at end of file diff --git a/app/config/settings.py b/app/config/settings.py index 48d629e..da0f0a1 100644 --- a/app/config/settings.py +++ b/app/config/settings.py @@ -25,6 +25,15 @@ class Settings(BaseSettings): jwt_access_expire_minutes: int = 30 jwt_refresh_expire_days: int = 7 + # sub2api upstream + sub2api_base_url: str = "http://127.0.0.1:8080" + sub2api_admin_token: str = "" + sub2api_request_timeout: float = 10.0 + + # Fernet key (urlsafe base64, 44 chars). Generate with: + # python -c "from cryptography.fernet import Fernet; print(Fernet.generate_key().decode())" + token_encryption_key: str = "" + @property def database_url(self) -> str: return ( @@ -32,6 +41,10 @@ class Settings(BaseSettings): f"@{self.db_host}:{self.db_port}/{self.db_name}" ) + @property + def sub2api_api_prefix(self) -> str: + return self.sub2api_base_url.rstrip("/") + "/api/v1" + class Config: env_file = ".env" env_prefix = "SD_" diff --git a/app/core/crypto.py b/app/core/crypto.py new file mode 100644 index 0000000..9a926e8 --- /dev/null +++ b/app/core/crypto.py @@ -0,0 +1,29 @@ +from cryptography.fernet import Fernet, InvalidToken + +from app.config.settings import settings + + +def _get_fernet() -> Fernet: + key = settings.token_encryption_key + if not key: + raise RuntimeError( + "SD_TOKEN_ENCRYPTION_KEY is not configured. " + "Generate one with: python -c \"from cryptography.fernet import Fernet; " + "print(Fernet.generate_key().decode())\"" + ) + return Fernet(key.encode() if isinstance(key, str) else key) + + +def encrypt_token(plaintext: str) -> str: + if not plaintext: + return "" + return _get_fernet().encrypt(plaintext.encode()).decode() + + +def decrypt_token(ciphertext: str) -> str: + if not ciphertext: + return "" + try: + return _get_fernet().decrypt(ciphertext.encode()).decode() + except InvalidToken as exc: + raise ValueError("Failed to decrypt token (key mismatch or corrupted value)") from exc \ No newline at end of file diff --git a/app/datamodels/schemas.py b/app/datamodels/schemas.py index db8081d..e993b23 100644 --- a/app/datamodels/schemas.py +++ b/app/datamodels/schemas.py @@ -1,10 +1,12 @@ -from decimal import Decimal -from pydantic import BaseModel, EmailStr -from datetime import datetime, date -from typing import Optional, List +from __future__ import annotations + +from datetime import datetime +from typing import Any, Dict, List, Optional + +from pydantic import BaseModel -# ── Auth ── +# ── Auth ────────────────────────────────────────────────────────────── class RegisterRequest(BaseModel): email: str @@ -36,144 +38,59 @@ class ResetPasswordRequest(BaseModel): class UserResponse(BaseModel): + """Local user view. Balance mirrored from sub2api when available.""" + id: str email: str - balance: Decimal status: str created_at: datetime + sub2api_user_id: Optional[int] = None + balance: float = 0.0 class Config: from_attributes = True -# ── API Key ── +# ── API Key (shapes match sub2api dto.APIKey 1:1) ───────────────────── class CreateKeyRequest(BaseModel): - name: str = "" - - -class ApiKeyResponse(BaseModel): - id: str name: str - key_prefix: str - key_suffix: str - status: str - created_at: datetime - - class Config: - from_attributes = True + group_id: Optional[int] = None + custom_key: Optional[str] = None + ip_whitelist: List[str] = [] + ip_blacklist: List[str] = [] + quota: Optional[float] = None + expires_in_days: Optional[int] = None + rate_limit_5h: Optional[float] = None + rate_limit_1d: Optional[float] = None + rate_limit_7d: Optional[float] = None -class ApiKeyCreatedResponse(BaseModel): - id: str - name: str - key: str - key_prefix: str - key_suffix: str - created_at: datetime +class UpdateKeyRequest(BaseModel): + name: Optional[str] = None + group_id: Optional[int] = None + status: Optional[str] = None + ip_whitelist: Optional[List[str]] = None + ip_blacklist: Optional[List[str]] = None + quota: Optional[float] = None + expires_at: Optional[str] = None + reset_quota: Optional[bool] = None + rate_limit_5h: Optional[float] = None + rate_limit_1d: Optional[float] = None + rate_limit_7d: Optional[float] = None + reset_rate_limit_usage: Optional[bool] = None -# ── Wallet ── +# ── Usage ───────────────────────────────────────────────────────────── -class RedeemCodeRequest(BaseModel): - code: str +class DashboardAPIKeysUsageRequest(BaseModel): + api_key_ids: List[int] -class TransactionResponse(BaseModel): - id: str - type: str - amount: Decimal - balance_after: Decimal - reference_id: str - created_at: datetime - - class Config: - from_attributes = True - - -class BalanceResponse(BaseModel): - balance: Decimal - - -# ── Models ── - -class ModelPricingResponse(BaseModel): - id: int - model_name: str - provider: str - input_price_per_1k: Decimal - output_price_per_1k: Decimal - status: str - updated_at: datetime - - class Config: - from_attributes = True - - -# ── Example (legacy) ── - -class ExampleCreate(BaseModel): - name: str - description: str = "" - - -class ExampleResponse(BaseModel): - id: str - name: str - description: str - created_at: datetime - - -# ── Common ── +# ── Common ──────────────────────────────────────────────────────────── class MessageResponse(BaseModel): message: str -# ── Usage ── - -class UsageSummaryResponse(BaseModel): - today_tokens: int - today_cost: Decimal - month_tokens: int - month_cost: Decimal - total_requests: int - - -class DailyUsageResponse(BaseModel): - date: date - total_tokens: int - cost: Decimal - requests: int - - -class ModelUsageResponse(BaseModel): - model: str - total_tokens: int - cost: Decimal - requests: int - - -class KeyUsageResponse(BaseModel): - key_id: str - key_name: str - key_prefix: str - key_suffix: str - total_tokens: int - cost: Decimal - requests: int - - -class UsageLogResponse(BaseModel): - id: int - key_id: str - model: str - prompt_tokens: int - completion_tokens: int - total_tokens: int - cost: Decimal - request_time: datetime - status: str - - class Config: - from_attributes = True +JSONDict = Dict[str, Any] \ No newline at end of file diff --git a/app/integrations/__init__.py b/app/integrations/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/app/integrations/sub2api/__init__.py b/app/integrations/sub2api/__init__.py new file mode 100644 index 0000000..efa201e --- /dev/null +++ b/app/integrations/sub2api/__init__.py @@ -0,0 +1,18 @@ +from app.integrations.sub2api.client import ( + Sub2APIError, + Sub2APIReauthRequired, + Sub2APITransportError, + get_client, + close_client, +) +from app.integrations.sub2api import admin, user + +__all__ = [ + "Sub2APIError", + "Sub2APIReauthRequired", + "Sub2APITransportError", + "get_client", + "close_client", + "admin", + "user", +] diff --git a/app/integrations/sub2api/admin.py b/app/integrations/sub2api/admin.py new file mode 100644 index 0000000..f5993c4 --- /dev/null +++ b/app/integrations/sub2api/admin.py @@ -0,0 +1,97 @@ +"""Admin-token-authenticated calls to sub2api. + +Used for: +- User lifecycle sync (create / update / delete / lookup) +- Reading a user's API keys and usage (admin endpoints only support reads) +""" +from __future__ import annotations + +from typing import Any + +from app.integrations.sub2api.client import admin_request + + +# ── Users ───────────────────────────────────────────────────────────── + +async def create_user( + *, + email: str, + password: str, + username: str = "", + notes: str = "", + balance: float = 0, + concurrency: int = 0, + allowed_groups: list[int] | None = None, +) -> dict[str, Any]: + return await admin_request( + "POST", + "/admin/users", + json={ + "email": email, + "password": password, + "username": username, + "notes": notes, + "balance": balance, + "concurrency": concurrency, + "allowed_groups": allowed_groups or [], + }, + ) + + +async def update_user(user_id: int, **fields: Any) -> dict[str, Any]: + """Partial update. Only non-None fields are sent.""" + payload = {k: v for k, v in fields.items() if v is not None} + return await admin_request("PUT", f"/admin/users/{user_id}", json=payload) + + +async def delete_user(user_id: int) -> None: + await admin_request("DELETE", f"/admin/users/{user_id}") + + +async def get_user(user_id: int) -> dict[str, Any]: + return await admin_request("GET", f"/admin/users/{user_id}") + + +async def find_user_by_email(email: str) -> dict[str, Any] | None: + data = await admin_request( + "GET", + "/admin/users", + params={"search": email, "page": 1, "page_size": 5}, + ) + items = (data or {}).get("items") or [] + for item in items: + if (item.get("email") or "").lower() == email.lower(): + return item + return None + + +# ── API Keys (read-only from admin side) ────────────────────────────── + +async def list_user_api_keys( + user_id: int, + *, + page: int = 1, + page_size: int = 20, + sort_by: str = "created_at", + sort_order: str = "desc", +) -> dict[str, Any]: + return await admin_request( + "GET", + f"/admin/users/{user_id}/api-keys", + params={ + "page": page, + "page_size": page_size, + "sort_by": sort_by, + "sort_order": sort_order, + }, + ) + + +# ── Usage (admin view per user) ─────────────────────────────────────── + +async def get_user_usage_stats(user_id: int, period: str = "month") -> dict[str, Any]: + return await admin_request( + "GET", + f"/admin/users/{user_id}/usage", + params={"period": period}, + ) \ No newline at end of file diff --git a/app/integrations/sub2api/client.py b/app/integrations/sub2api/client.py new file mode 100644 index 0000000..09e91b3 --- /dev/null +++ b/app/integrations/sub2api/client.py @@ -0,0 +1,131 @@ +"""Low-level HTTP client for sub2api. + +Responsibilities: +- Singleton httpx.AsyncClient with configured base URL and timeout. +- Uniform envelope parsing: {code, message, reason, metadata, data}. +- Error translation to domain exceptions. +""" +from __future__ import annotations + +import logging +from typing import Any, Mapping + +import httpx + +from app.config.settings import settings + +logger = logging.getLogger(__name__) + + +class Sub2APIError(Exception): + """sub2api returned a non-zero envelope code or HTTP error.""" + + def __init__( + self, + code: int, + message: str, + reason: str = "", + metadata: Mapping[str, str] | None = None, + http_status: int | None = None, + ) -> None: + super().__init__(f"[{code}] {message}" + (f" ({reason})" if reason else "")) + self.code = code + self.message = message + self.reason = reason + self.metadata = dict(metadata) if metadata else {} + self.http_status = http_status + + +class Sub2APIReauthRequired(Sub2APIError): + """User-level token invalid/expired; caller must re-authenticate with password.""" + + +class Sub2APITransportError(Exception): + """Network / timeout / invalid JSON etc.""" + + +_client: httpx.AsyncClient | None = None + + +def get_client() -> httpx.AsyncClient: + global _client + if _client is None or _client.is_closed: + _client = httpx.AsyncClient( + base_url=settings.sub2api_api_prefix, + timeout=settings.sub2api_request_timeout, + ) + return _client + + +async def close_client() -> None: + global _client + if _client is not None and not _client.is_closed: + await _client.aclose() + _client = None + + +def _parse_envelope(resp: httpx.Response) -> Any: + try: + body = resp.json() + except ValueError as exc: + raise Sub2APITransportError( + f"sub2api returned non-JSON body (status={resp.status_code}): {resp.text[:200]}" + ) from exc + + code = body.get("code", resp.status_code) + message = body.get("message", "") + reason = body.get("reason", "") or "" + metadata = body.get("metadata") or {} + + if code == 0: + return body.get("data") + + # Token invalidation signals from sub2api admin middleware + if reason in {"TOKEN_EXPIRED", "INVALID_TOKEN", "TOKEN_REVOKED", "USER_INACTIVE"}: + raise Sub2APIReauthRequired(code, message, reason, metadata, resp.status_code) + + raise Sub2APIError(code, message, reason, metadata, resp.status_code) + + +async def request( + method: str, + path: str, + *, + json: Any = None, + params: Mapping[str, Any] | None = None, + headers: Mapping[str, str] | None = None, +) -> Any: + """Execute a sub2api call and return the unwrapped ``data`` field.""" + client = get_client() + try: + resp = await client.request( + method, + path, + json=json, + params={k: v for k, v in (params or {}).items() if v is not None}, + headers=headers, + ) + except httpx.TimeoutException as exc: + raise Sub2APITransportError(f"sub2api timeout on {method} {path}") from exc + except httpx.HTTPError as exc: + raise Sub2APITransportError(f"sub2api transport error on {method} {path}: {exc}") from exc + + return _parse_envelope(resp) + + +async def admin_request(method: str, path: str, **kwargs: Any) -> Any: + """Issue a request authenticated with the admin API key.""" + if not settings.sub2api_admin_token: + raise RuntimeError("SD_SUB2API_ADMIN_TOKEN is not configured") + headers = dict(kwargs.pop("headers", None) or {}) + headers["x-api-key"] = settings.sub2api_admin_token + return await request(method, path, headers=headers, **kwargs) + + +async def user_request(access_token: str, method: str, path: str, **kwargs: Any) -> Any: + """Issue a request authenticated with a user's JWT access token.""" + if not access_token: + raise Sub2APIReauthRequired(401, "missing user access token", "MISSING_TOKEN") + headers = dict(kwargs.pop("headers", None) or {}) + headers["Authorization"] = f"Bearer {access_token}" + return await request(method, path, headers=headers, **kwargs) diff --git a/app/integrations/sub2api/user.py b/app/integrations/sub2api/user.py new file mode 100644 index 0000000..855bf1c --- /dev/null +++ b/app/integrations/sub2api/user.py @@ -0,0 +1,96 @@ +"""User-JWT-authenticated calls to sub2api. + +Used for: +- Auth login / refresh / me (to obtain tokens for BFF proxying) +- API Key CRUD (admin endpoints cannot create / delete keys) +- Usage list / detail / dashboard for the authenticated user +""" +from __future__ import annotations + +from typing import Any + +from app.integrations.sub2api.client import request, user_request + + +# ── Auth (public; no Bearer required) ───────────────────────────────── + +async def login(email: str, password: str) -> dict[str, Any]: + """Returns AuthResponse: {access_token, refresh_token, expires_in, token_type, user}.""" + return await request( + "POST", + "/auth/login", + json={"email": email, "password": password, "turnstile_token": ""}, + ) + + +async def refresh_tokens(refresh_token: str) -> dict[str, Any]: + """Returns RefreshTokenResponse: {access_token, refresh_token, expires_in, token_type}.""" + return await request( + "POST", + "/auth/refresh", + json={"refresh_token": refresh_token}, + ) + + +async def logout(refresh_token: str | None = None) -> dict[str, Any]: + body = {"refresh_token": refresh_token} if refresh_token else {} + return await request("POST", "/auth/logout", json=body) + + +# ── API Key CRUD ────────────────────────────────────────────────────── + +async def create_key(access_token: str, payload: dict[str, Any]) -> dict[str, Any]: + return await user_request(access_token, "POST", "/keys", json=payload) + + +async def update_key(access_token: str, key_id: int, payload: dict[str, Any]) -> dict[str, Any]: + return await user_request(access_token, "PUT", f"/keys/{key_id}", json=payload) + + +async def delete_key(access_token: str, key_id: int) -> dict[str, Any]: + return await user_request(access_token, "DELETE", f"/keys/{key_id}") + + +async def get_key(access_token: str, key_id: int) -> dict[str, Any]: + return await user_request(access_token, "GET", f"/keys/{key_id}") + + +# ── Groups ──────────────────────────────────────────────────────────── + +async def list_available_groups(access_token: str) -> list[dict[str, Any]]: + return await user_request(access_token, "GET", "/groups/available") + + +async def get_user_group_rates(access_token: str) -> dict[str, float]: + return await user_request(access_token, "GET", "/groups/rates") + + +# ── Usage (user view) ───────────────────────────────────────────────── + +async def list_usage(access_token: str, **params: Any) -> dict[str, Any]: + return await user_request(access_token, "GET", "/usage", params=params) + + +async def usage_stats(access_token: str, **params: Any) -> dict[str, Any]: + return await user_request(access_token, "GET", "/usage/stats", params=params) + + +async def dashboard_stats(access_token: str) -> dict[str, Any]: + return await user_request(access_token, "GET", "/usage/dashboard/stats") + + +async def dashboard_trend(access_token: str, **params: Any) -> dict[str, Any]: + return await user_request(access_token, "GET", "/usage/dashboard/trend", params=params) + + +async def dashboard_models(access_token: str, **params: Any) -> dict[str, Any]: + return await user_request(access_token, "GET", "/usage/dashboard/models", params=params) + + +async def dashboard_api_keys_usage(access_token: str, api_key_ids: list[int]) -> dict[str, Any]: + return await user_request( + access_token, + "POST", + "/usage/dashboard/api-keys-usage", + json={"api_key_ids": api_key_ids}, + ) \ No newline at end of file diff --git a/app/main.py b/app/main.py index 7dea562..3d7de1c 100644 --- a/app/main.py +++ b/app/main.py @@ -3,7 +3,7 @@ from fastapi import FastAPI from fastapi.middleware.cors import CORSMiddleware from fastapi.staticfiles import StaticFiles from fastapi.responses import FileResponse -from app.api.v1 import health, example, auth, keys, models as models_api, wallet, usage +from app.api.v1 import health, example, auth, keys, usage from app.config.settings import settings from app.core.database import init_db import os @@ -35,8 +35,6 @@ def create_app() -> FastAPI: app.include_router(health.router, prefix="/api/v1", tags=["health"]) app.include_router(auth.router, prefix="/api/v1") app.include_router(keys.router, prefix="/api/v1") - app.include_router(models_api.router, prefix="/api/v1") - app.include_router(wallet.router, prefix="/api/v1") app.include_router(usage.router, prefix="/api/v1") app.include_router(example.router, prefix="/api/v1", tags=["example"]) diff --git a/app/models/__init__.py b/app/models/__init__.py index 594e11b..1a10858 100644 --- a/app/models/__init__.py +++ b/app/models/__init__.py @@ -2,11 +2,10 @@ from __future__ import annotations import uuid from datetime import datetime -from decimal import Decimal -from typing import List +from typing import Optional -from sqlalchemy import String, Text, Integer, BigInteger, DateTime, Numeric, Enum, ForeignKey, Index, func -from sqlalchemy.orm import Mapped, mapped_column, relationship +from sqlalchemy import String, Text, BigInteger, DateTime, func +from sqlalchemy.orm import Mapped, mapped_column from app.core.database import Base @@ -17,71 +16,12 @@ class User(Base): id: Mapped[str] = mapped_column(String(36), primary_key=True, default=lambda: str(uuid.uuid4())) email: Mapped[str] = mapped_column(String(255), unique=True, nullable=False, index=True) password_hash: Mapped[str] = mapped_column(String(255), nullable=False) - balance: Mapped[Decimal] = mapped_column(Numeric(16, 6), default=Decimal("0")) - status: Mapped[str] = mapped_column(String(20), default="active") # active / disabled + status: Mapped[str] = mapped_column(String(20), default="active") created_at: Mapped[datetime] = mapped_column(DateTime, server_default=func.now()) updated_at: Mapped[datetime] = mapped_column(DateTime, server_default=func.now(), onupdate=func.now()) - api_keys: Mapped[List[ApiKey]] = relationship(back_populates="user") - transactions: Mapped[List[Transaction]] = relationship(back_populates="user") - - -class ApiKey(Base): - __tablename__ = "api_keys" - - id: Mapped[str] = mapped_column(String(36), primary_key=True, default=lambda: str(uuid.uuid4())) - user_id: Mapped[str] = mapped_column(String(36), ForeignKey("users.id"), nullable=False, index=True) - name: Mapped[str] = mapped_column(String(100), default="") - key_hash: Mapped[str] = mapped_column(String(64), unique=True, nullable=False) # SHA256 - key_prefix: Mapped[str] = mapped_column(String(10), nullable=False) - key_suffix: Mapped[str] = mapped_column(String(10), nullable=False) - status: Mapped[str] = mapped_column(String(20), default="active") # active / revoked - created_at: Mapped[datetime] = mapped_column(DateTime, server_default=func.now()) - - user: Mapped["User"] = relationship(back_populates="api_keys") - - -class UsageLog(Base): - __tablename__ = "usage_logs" - __table_args__ = ( - Index("ix_usage_user_time", "user_id", "request_time"), - Index("ix_usage_model", "user_id", "model"), - ) - - id: Mapped[int] = mapped_column(BigInteger, primary_key=True, autoincrement=True) - user_id: Mapped[str] = mapped_column(String(36), ForeignKey("users.id"), nullable=False) - key_id: Mapped[str] = mapped_column(String(36), ForeignKey("api_keys.id"), nullable=False) - model: Mapped[str] = mapped_column(String(100), nullable=False) - prompt_tokens: Mapped[int] = mapped_column(Integer, default=0) - completion_tokens: Mapped[int] = mapped_column(Integer, default=0) - total_tokens: Mapped[int] = mapped_column(Integer, default=0) - cost: Mapped[Decimal] = mapped_column(Numeric(16, 6), default=Decimal("0")) - request_time: Mapped[datetime] = mapped_column(DateTime, nullable=False) - response_time: Mapped[datetime] = mapped_column(DateTime, nullable=True) - status: Mapped[str] = mapped_column(String(20), default="success") # success / error - - -class Transaction(Base): - __tablename__ = "transactions" - - id: Mapped[str] = mapped_column(String(36), primary_key=True, default=lambda: str(uuid.uuid4())) - user_id: Mapped[str] = mapped_column(String(36), ForeignKey("users.id"), nullable=False, index=True) - type: Mapped[str] = mapped_column(String(20), nullable=False) # topup / consume / refund - amount: Mapped[Decimal] = mapped_column(Numeric(16, 6), nullable=False) - balance_after: Mapped[Decimal] = mapped_column(Numeric(16, 6), nullable=False) - reference_id: Mapped[str] = mapped_column(String(100), default="") - created_at: Mapped[datetime] = mapped_column(DateTime, server_default=func.now()) - - user: Mapped["User"] = relationship(back_populates="transactions") - - -class ModelPricing(Base): - __tablename__ = "models_pricing" - - id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True) - model_name: Mapped[str] = mapped_column(String(100), unique=True, nullable=False) - provider: Mapped[str] = mapped_column(String(50), nullable=False) - input_price_per_1k: Mapped[Decimal] = mapped_column(Numeric(16, 6), nullable=False) - output_price_per_1k: Mapped[Decimal] = mapped_column(Numeric(16, 6), nullable=False) - status: Mapped[str] = mapped_column(String(20), default="available") # available / offline - updated_at: Mapped[datetime] = mapped_column(DateTime, server_default=func.now(), onupdate=func.now()) + # sub2api 用户体系映射 + sub2api_user_id: Mapped[Optional[int]] = mapped_column(BigInteger, nullable=True, index=True) + sub2api_refresh_token_enc: Mapped[Optional[str]] = mapped_column(Text, nullable=True) + sub2api_access_token: Mapped[Optional[str]] = mapped_column(Text, nullable=True) + sub2api_access_expires_at: Mapped[Optional[datetime]] = mapped_column(DateTime, nullable=True) \ No newline at end of file diff --git a/app/services/auth_service.py b/app/services/auth_service.py index f7b8e6c..f448407 100644 --- a/app/services/auth_service.py +++ b/app/services/auth_service.py @@ -1,11 +1,41 @@ +"""Auth service: local user table + sub2api sync. + +Registration and login keep two systems in sync: +- superDream local User row (owns password hash, superDream JWT) +- sub2api User (owns API keys, usage, balance) + +On registration, sub2api user is created via admin API; then we login to sub2api +with the same password to obtain and store a refresh_token. Any sub2api failure +rolls back the local insert so the two systems never drift apart silently. + +On login, after local password verification we also refresh the stored sub2api +tokens (by re-logging in to sub2api) so that subsequent proxied calls have a +valid refresh_token without ever persisting the plaintext password. +""" +from __future__ import annotations + +import logging import re from sqlalchemy import select from sqlalchemy.ext.asyncio import AsyncSession -from app.models import User -from app.core.security import hash_password, verify_password, create_access_token, create_refresh_token, decode_token from app.core.exceptions import BadRequestError, UnauthorizedError +from app.core.security import ( + create_access_token, + create_refresh_token, + create_reset_token, + decode_token, + hash_password, + verify_password, +) +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, Sub2APITransportError +from app.models import User +from app.services.sub2api_session import store_tokens + +logger = logging.getLogger(__name__) def _validate_password(password: str) -> None: @@ -17,8 +47,14 @@ def _validate_password(password: str) -> None: raise BadRequestError("密码需同时包含字母和数字") -class AuthService: +def _upstream_failure(action: str, exc: Exception) -> BadRequestError: + logger.error("sub2api %s failed: %s", action, exc) + if isinstance(exc, Sub2APIError): + return BadRequestError(f"上游同步失败:{exc.message or exc.reason or 'unknown'}") + return BadRequestError("上游服务不可用,请稍后重试") + +class AuthService: @staticmethod async def register(db: AsyncSession, email: str, password: str) -> User: _validate_password(password) @@ -27,10 +63,44 @@ class AuthService: if existing.scalar_one_or_none(): raise BadRequestError("Email already registered") - user = User(email=email, password_hash=hash_password(password)) + # 1. Create the sub2api user first (so a conflict on their side halts us + # before we touch our DB). + try: + remote = await sub2api_admin.create_user(email=email, password=password) + except (Sub2APIError, Sub2APITransportError) as exc: + raise _upstream_failure("create_user", exc) from exc + + remote_user_id = int(remote.get("id")) + + # 2. Grab a token pair by logging in as the new user. + try: + tokens = await sub2api_user.login(email, password) + except (Sub2APIError, Sub2APITransportError) as exc: + # Roll back the remote user so re-registration works. + try: + await sub2api_admin.delete_user(remote_user_id) + except Exception: # pragma: no cover - best-effort cleanup + logger.exception("failed to roll back sub2api user %s", remote_user_id) + raise _upstream_failure("login_after_register", exc) from exc + + # 3. Persist the local user with sub2api references. + user = User( + email=email, + password_hash=hash_password(password), + sub2api_user_id=remote_user_id, + ) db.add(user) - await db.commit() - await db.refresh(user) + try: + await db.flush() + await store_tokens(db, user, tokens) + except Exception: + await db.rollback() + try: + await sub2api_admin.delete_user(remote_user_id) + except Exception: # pragma: no cover + logger.exception("failed to roll back sub2api user %s", remote_user_id) + raise + return user @staticmethod @@ -42,6 +112,18 @@ class AuthService: if user.status != "active": raise UnauthorizedError("Account is disabled") + # Refresh stored sub2api tokens with the same password. If sub2api is + # down we still allow local login; proxied calls will surface 503 later. + try: + tokens = await sub2api_user.login(email, password) + if not user.sub2api_user_id and tokens.get("user", {}).get("id"): + user.sub2api_user_id = int(tokens["user"]["id"]) + await store_tokens(db, user, tokens) + except Sub2APIError as exc: + logger.warning("sub2api login failed for %s: %s", email, exc) + except Sub2APITransportError as exc: + logger.warning("sub2api unreachable during login: %s", exc) + return { "access_token": create_access_token(user.id), "refresh_token": create_refresh_token(user.id), @@ -65,14 +147,10 @@ class AuthService: @staticmethod async def forgot_password(db: AsyncSession, email: str) -> str: - """Generate a password reset token. MVP: returns the token directly (production: send via email).""" result = await db.execute(select(User).where(User.email == email)) user = result.scalar_one_or_none() if not user: - # Don't reveal whether email exists return "" - - from app.core.security import create_reset_token return create_reset_token(user.id) @staticmethod @@ -88,4 +166,16 @@ class AuthService: raise BadRequestError("User not found") user.password_hash = hash_password(new_password) - await db.commit() + + # Sync the new password to sub2api so subsequent proxied calls continue + # to work. If this fails the user can still log in locally but won't be + # able to mutate keys until the systems re-converge. + if user.sub2api_user_id: + try: + await sub2api_admin.update_user(user.sub2api_user_id, password=new_password) + tokens = await sub2api_user.login(user.email, new_password) + await store_tokens(db, user, tokens) + except (Sub2APIError, Sub2APITransportError) as exc: + logger.error("sub2api password sync failed for user %s: %s", user.id, exc) + + await db.commit() \ No newline at end of file diff --git a/app/services/key_service.py b/app/services/key_service.py index 15b1faf..b4cfd3f 100644 --- a/app/services/key_service.py +++ b/app/services/key_service.py @@ -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 \ No newline at end of file diff --git a/app/services/sub2api_session.py b/app/services/sub2api_session.py new file mode 100644 index 0000000..8d8c77e --- /dev/null +++ b/app/services/sub2api_session.py @@ -0,0 +1,74 @@ +"""Helpers to obtain a fresh sub2api user access token from stored state. + +The User row carries: +- sub2api_access_token + sub2api_access_expires_at (cache) +- sub2api_refresh_token_enc (Fernet-encrypted refresh_token) + +``ensure_access_token`` returns a usable access_token, refreshing via sub2api +``/auth/refresh`` when the cached one is expired. On refresh failure the caller +receives ``Sub2APIReauthRequired`` and should surface 401 so the frontend can +prompt re-login. +""" +from __future__ import annotations + +from datetime import datetime, timedelta, timezone + +from sqlalchemy.ext.asyncio import AsyncSession + +from app.core.crypto import decrypt_token, encrypt_token +from app.integrations.sub2api import user as sub2api_user +from app.integrations.sub2api.client import Sub2APIError, Sub2APIReauthRequired +from app.models import User + +_CLOCK_SKEW = timedelta(seconds=60) + + +def _utcnow() -> datetime: + return datetime.now(tz=timezone.utc) + + +def _is_access_fresh(user: User) -> bool: + if not user.sub2api_access_token or not user.sub2api_access_expires_at: + return False + expires_at = user.sub2api_access_expires_at + if expires_at.tzinfo is None: + expires_at = expires_at.replace(tzinfo=timezone.utc) + return expires_at - _CLOCK_SKEW > _utcnow() + + +async def store_tokens(db: AsyncSession, user: User, token_response: dict) -> None: + """Persist a fresh token pair (from /auth/login or /auth/refresh).""" + access = token_response.get("access_token") or "" + refresh = token_response.get("refresh_token") or "" + expires_in = int(token_response.get("expires_in") or 3600) + + user.sub2api_access_token = access + user.sub2api_access_expires_at = _utcnow() + timedelta(seconds=expires_in) + if refresh: + user.sub2api_refresh_token_enc = encrypt_token(refresh) + await db.commit() + await db.refresh(user) + + +async def ensure_access_token(db: AsyncSession, user: User) -> str: + """Return a usable sub2api access_token, refreshing if necessary.""" + if _is_access_fresh(user): + return user.sub2api_access_token # type: ignore[return-value] + + if not user.sub2api_refresh_token_enc: + raise Sub2APIReauthRequired(401, "no refresh token stored", "NO_REFRESH_TOKEN") + + try: + refresh_plain = decrypt_token(user.sub2api_refresh_token_enc) + except ValueError as exc: + raise Sub2APIReauthRequired(401, str(exc), "DECRYPT_FAILED") from exc + + try: + token_response = await sub2api_user.refresh_tokens(refresh_plain) + except Sub2APIError as exc: + raise Sub2APIReauthRequired( + exc.code, exc.message, exc.reason or "REFRESH_FAILED", exc.metadata + ) from exc + + await store_tokens(db, user, token_response) + return user.sub2api_access_token # type: ignore[return-value] \ No newline at end of file diff --git a/app/services/usage_service.py b/app/services/usage_service.py index f1dd7f9..89fbf08 100644 --- a/app/services/usage_service.py +++ b/app/services/usage_service.py @@ -1,139 +1,92 @@ -from datetime import date, datetime, timedelta -from decimal import Decimal -from typing import Optional +"""Usage service: fully proxied to sub2api (user JWT for user-scoped endpoints).""" +from __future__ import annotations -from sqlalchemy import select, func, cast, Date +from typing import Any + +from fastapi import HTTPException from sqlalchemy.ext.asyncio import AsyncSession -from app.models import UsageLog, ApiKey +from app.core.exceptions import BadRequestError +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 + + +def _require_binding(user: User) -> int: + if not user.sub2api_user_id: + raise BadRequestError("账号未完成 sub2api 绑定,请重新登录") + return user.sub2api_user_id + + +def _translate(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 UsageService: + @staticmethod + async def list_logs(db: AsyncSession, user: User, **params: Any) -> dict[str, Any]: + _require_binding(user) + try: + token = await ensure_access_token(db, user) + return await sub2api_user.list_usage(token, **params) + except (Sub2APIError, Sub2APITransportError) as exc: + raise _translate(exc) from exc @staticmethod - async def summary(db: AsyncSession, user_id: str) -> dict: - today_start = datetime.combine(date.today(), datetime.min.time()) - month_start = today_start.replace(day=1) - - # Today - today_row = (await db.execute( - select( - func.coalesce(func.sum(UsageLog.total_tokens), 0), - func.coalesce(func.sum(UsageLog.cost), Decimal("0")), - ).where(UsageLog.user_id == user_id, UsageLog.request_time >= today_start) - )).one() - - # This month - month_row = (await db.execute( - select( - func.coalesce(func.sum(UsageLog.total_tokens), 0), - func.coalesce(func.sum(UsageLog.cost), Decimal("0")), - func.count(), - ).where(UsageLog.user_id == user_id, UsageLog.request_time >= month_start) - )).one() - - return { - "today_tokens": int(today_row[0]), - "today_cost": today_row[1], - "month_tokens": int(month_row[0]), - "month_cost": month_row[1], - "total_requests": int(month_row[2]), - } + async def stats(db: AsyncSession, user: User, **params: Any) -> dict[str, Any]: + _require_binding(user) + try: + token = await ensure_access_token(db, user) + return await sub2api_user.usage_stats(token, **params) + except (Sub2APIError, Sub2APITransportError) as exc: + raise _translate(exc) from exc @staticmethod - async def daily( - db: AsyncSession, user_id: str, - start: Optional[date] = None, end: Optional[date] = None, - ) -> list: - if not start: - start = date.today() - timedelta(days=29) - if not end: - end = date.today() - - day_col = cast(UsageLog.request_time, Date).label("day") - result = await db.execute( - select( - day_col, - func.coalesce(func.sum(UsageLog.total_tokens), 0), - func.coalesce(func.sum(UsageLog.cost), Decimal("0")), - func.count(), - ) - .where( - UsageLog.user_id == user_id, - cast(UsageLog.request_time, Date) >= start, - cast(UsageLog.request_time, Date) <= end, - ) - .group_by(day_col) - .order_by(day_col) - ) - return [ - {"date": row[0], "total_tokens": int(row[1]), "cost": row[2], "requests": int(row[3])} - for row in result.all() - ] + async def dashboard_stats(db: AsyncSession, user: User) -> dict[str, Any]: + _require_binding(user) + try: + token = await ensure_access_token(db, user) + return await sub2api_user.dashboard_stats(token) + except (Sub2APIError, Sub2APITransportError) as exc: + raise _translate(exc) from exc @staticmethod - async def by_model(db: AsyncSession, user_id: str) -> list: - result = await db.execute( - select( - UsageLog.model, - func.coalesce(func.sum(UsageLog.total_tokens), 0), - func.coalesce(func.sum(UsageLog.cost), Decimal("0")), - func.count(), - ) - .where(UsageLog.user_id == user_id) - .group_by(UsageLog.model) - .order_by(func.sum(UsageLog.cost).desc()) - ) - return [ - {"model": row[0], "total_tokens": int(row[1]), "cost": row[2], "requests": int(row[3])} - for row in result.all() - ] + async def dashboard_trend(db: AsyncSession, user: User, **params: Any) -> dict[str, Any]: + _require_binding(user) + try: + token = await ensure_access_token(db, user) + return await sub2api_user.dashboard_trend(token, **params) + except (Sub2APIError, Sub2APITransportError) as exc: + raise _translate(exc) from exc @staticmethod - async def by_key(db: AsyncSession, user_id: str) -> list: - result = await db.execute( - select( - UsageLog.key_id, - ApiKey.name, - ApiKey.key_prefix, - ApiKey.key_suffix, - func.coalesce(func.sum(UsageLog.total_tokens), 0), - func.coalesce(func.sum(UsageLog.cost), Decimal("0")), - func.count(), - ) - .join(ApiKey, UsageLog.key_id == ApiKey.id) - .where(UsageLog.user_id == user_id) - .group_by(UsageLog.key_id, ApiKey.name, ApiKey.key_prefix, ApiKey.key_suffix) - .order_by(func.sum(UsageLog.cost).desc()) - ) - return [ - { - "key_id": row[0], "key_name": row[1] or "", - "key_prefix": row[2], "key_suffix": row[3], - "total_tokens": int(row[4]), "cost": row[5], "requests": int(row[6]), - } - for row in result.all() - ] + async def dashboard_models(db: AsyncSession, user: User, **params: Any) -> dict[str, Any]: + _require_binding(user) + try: + token = await ensure_access_token(db, user) + return await sub2api_user.dashboard_models(token, **params) + except (Sub2APIError, Sub2APITransportError) as exc: + raise _translate(exc) from exc @staticmethod - async def logs( - db: AsyncSession, user_id: str, - page: int = 1, size: int = 20, - model: Optional[str] = None, - key_id: Optional[str] = None, - start: Optional[date] = None, - end: Optional[date] = None, - ) -> list: - q = select(UsageLog).where(UsageLog.user_id == user_id) - if model: - q = q.where(UsageLog.model == model) - if key_id: - q = q.where(UsageLog.key_id == key_id) - if start: - q = q.where(UsageLog.request_time >= datetime.combine(start, datetime.min.time())) - if end: - q = q.where(UsageLog.request_time < datetime.combine(end + timedelta(days=1), datetime.min.time())) - - q = q.order_by(UsageLog.request_time.desc()).offset((page - 1) * size).limit(size) - result = await db.execute(q) - return result.scalars().all() + async def dashboard_api_keys_usage( + db: AsyncSession, user: User, api_key_ids: list[int] + ) -> dict[str, Any]: + _require_binding(user) + try: + token = await ensure_access_token(db, user) + return await sub2api_user.dashboard_api_keys_usage(token, api_key_ids) + except (Sub2APIError, Sub2APITransportError) as exc: + raise _translate(exc) from exc \ No newline at end of file diff --git a/docker-compose.yml b/docker-compose.yml index 8cdc1df..4ba3305 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -19,7 +19,7 @@ services: backend: container_name: superdreamfront - image: superdreamfront:0.0.1 + image: superdreamfront:0.0.2 build: context: . dockerfile: Dockerfile diff --git a/docs/sub2api_api.md b/docs/sub2api_api.md new file mode 100644 index 0000000..748cb03 --- /dev/null +++ b/docs/sub2api_api.md @@ -0,0 +1,339 @@ +# sub2api 接口对接规范 + +本文档记录 superDreamFront 与 sub2api 对接所使用的全部 HTTP 接口,以及 BFF 层(superDreamFront FastAPI)各服务的调用策略。 + +> 对应的 sub2api 源码位置: +> - 路由:`sub2api/backend/internal/server/routes/{auth,user,admin}.go` +> - 用户侧 handler:`sub2api/backend/internal/handler/{auth_handler,api_key_handler,usage_handler,user_handler}.go` +> - 管理员 handler:`sub2api/backend/internal/handler/admin/{user_handler,apikey_handler}.go` +> - DTO 类型:`sub2api/backend/internal/handler/dto/types.go` + +--- + +## 1. 通用约定 + +### 1.1 Base URL +所有接口以 `/api/v1` 为前缀,完整地址由环境变量 `SD_SUB2API_BASE_URL` 决定,例如: +``` +SD_SUB2API_BASE_URL = http://127.0.0.1:8080 +→ 实际请求:http://127.0.0.1:8080/api/v1/... +``` + +### 1.2 响应 envelope +所有响应统一包一层(来源 `internal/pkg/response/response.go`): +```json +{ + "code": 0, // 0 表示成功;非 0 为 HTTP 状态码 + "message": "success", + "reason": "", // 仅错误时出现 + "metadata": null, // 错误附加信息 + "data": { ... } // 业务载荷 +} +``` + +分页响应的 `data` 形状固定为: +```json +{ + "items": [...], + "total": 1234, + "page": 1, + "page_size": 20, + "pages": 62 +} +``` + +BFF 层建议在 `sub2api.client.request()` 中统一解 envelope: +- `code == 0` → 返回 `data` +- `code != 0` → 抛自定义异常 `Sub2APIError(code, message, reason, metadata)` + +### 1.3 认证方式 +sub2api 有三类鉴权: + +| 鉴权类型 | 携带方式 | 用途 | +|---|---|---| +| 公开 | 无 | 注册、登录、忘记密码、验证码 | +| 用户 JWT | `Authorization: Bearer ` | `/keys`、`/usage`、`/user/*` | +| 管理员 | `x-api-key: ` 或 `Authorization: Bearer ` | `/admin/*` | + +> 管理员 API Key 在 sub2api 后台 `Admin 设置 → Admin API Key → 生成` 后拿到,写入 `SD_SUB2API_ADMIN_TOKEN`。 + +### 1.4 Turnstile +部分公开接口会校验 Cloudflare Turnstile。sub2api 管理员可在设置里关闭;本 BFF 暂不处理 Turnstile,若 sub2api 启用会 400。 + +--- + +## 2. 认证(公开接口) + +### 2.1 发送邮箱验证码 +`POST /api/v1/auth/send-verify-code` + +请求: +```json +{ "email": "user@example.com", "turnstile_token": "" } +``` +响应: +```json +{ "message": "Verification code sent successfully", "countdown": 60 } +``` + +### 2.2 注册 +`POST /api/v1/auth/register` + +请求: +```json +{ + "email": "user@example.com", + "password": "******", // min 6 + "verify_code": "123456", // 若 sub2api 开启邮箱验证码注册 + "turnstile_token": "", + "promo_code": "", // 可选注册优惠码 + "invitation_code": "" // 可选邀请码 +} +``` +响应(同 `AuthResponse`): +```json +{ + "access_token": "...", + "refresh_token": "...", + "expires_in": 3600, // access 有效期(秒) + "token_type": "Bearer", + "user": { ...dto.User } +} +``` + +### 2.3 登录 +`POST /api/v1/auth/login` + +请求: +```json +{ "email": "user@example.com", "password": "******", "turnstile_token": "" } +``` + +若用户未开 2FA,响应同注册。 +若开启 2FA,响应: +```json +{ "requires_2fa": true, "temp_token": "...", "user_email_masked": "u**@e**.com" } +``` +此时前端需再调 `POST /api/v1/auth/login/2fa`: +```json +{ "temp_token": "...", "totp_code": "123456" } +``` + +### 2.4 刷新 Token +`POST /api/v1/auth/refresh` +```json +// 请求 +{ "refresh_token": "..." } +// 响应 +{ "access_token": "...", "refresh_token": "...", "expires_in": 3600, "token_type": "Bearer" } +``` +注意:sub2api 的 refresh_token **一次性使用、每次刷新会 rotate**,BFF 取到新 refresh_token 要覆盖旧值。 + +### 2.5 登出 +`POST /api/v1/auth/logout` body 可选 `{"refresh_token": "..."}`,服务端会撤销该 token。 + +### 2.6 忘记 / 重置密码 +- `POST /api/v1/auth/forgot-password` `{"email", "turnstile_token"}` +- `POST /api/v1/auth/reset-password` `{"email", "token", "new_password"}` + +### 2.7 当前用户 +`GET /api/v1/auth/me` 需要用户 JWT,响应 `dto.User + {run_mode}`。 + +--- + +## 3. API Key(用户 JWT) + +路由前缀 **`/api/v1/keys`**(注意不是 `/api-keys`,handler 注释里旧路径是误导)。 + +### 3.1 列表 +`GET /api/v1/keys?page=1&page_size=20&sort_by=created_at&sort_order=desc&search=&status=&group_id=` + +响应 `data`(分页 envelope): +```json +{ + "items": [ { ...dto.APIKey } ], + "total": 5, "page": 1, "page_size": 20, "pages": 1 +} +``` + +`dto.APIKey` 关键字段(完整字段见 `types.go`): +```ts +{ + id: number, + user_id: number, + key: string, // 完整 key(注意:sub2api 确实返回明文完整 key,不做截断) + name: string, + group_id: number | null, + status: "active" | "inactive", + ip_whitelist: string[], ip_blacklist: string[], + last_used_at: string | null, + quota: number, // USD,0 = 无限 + quota_used: number, + expires_at: string | null, + + rate_limit_5h: number, rate_limit_1d: number, rate_limit_7d: number, // 0=不限 + usage_5h: number, usage_1d: number, usage_7d: number, + reset_5h_at, reset_1d_at, reset_7d_at: string | null, + + created_at, updated_at: string, + group?: dto.Group +} +``` + +### 3.2 单个查询 +`GET /api/v1/keys/:id` → `dto.APIKey` + +### 3.3 新建 +`POST /api/v1/keys` +```json +{ + "name": "my-key", + "group_id": 1, // 可选 + "custom_key": null, // 可选自定义 key + "ip_whitelist": [], "ip_blacklist": [], + "quota": 0, // 0=无限 + "expires_in_days": null, // null=不过期 + "rate_limit_5h": 0, "rate_limit_1d": 0, "rate_limit_7d": 0 +} +``` +响应:`dto.APIKey`。 + +### 3.4 更新 +`PUT /api/v1/keys/:id`(字段同上,全部可选;特殊:`expires_at=""` 清除过期,`reset_quota=true` 重置已用,`reset_rate_limit_usage=true` 重置限速计数) + +### 3.5 删除 +`DELETE /api/v1/keys/:id` → `{ "message": "API key deleted successfully" }` + +### 3.6 用户可用分组 / 专属倍率 +- `GET /api/v1/groups/available` → `dto.Group[]` +- `GET /api/v1/groups/rates` → `{ [groupID: number]: number }` + +--- + +## 4. 用量(用户 JWT) + +### 4.1 列表 +`GET /api/v1/usage?page=&page_size=&api_key_id=&model=&request_type=&stream=&billing_type=&start_date=YYYY-MM-DD&end_date=YYYY-MM-DD&timezone=Asia/Shanghai` + +响应 `items: dto.UsageLog[]`(分页)。 + +`dto.UsageLog` 关键字段: +```ts +{ + id, user_id, api_key_id, account_id: number, + request_id, model: string, + input_tokens, output_tokens, cache_creation_tokens, cache_read_tokens, + input_cost, output_cost, cache_creation_cost, cache_read_cost, total_cost, actual_cost, + request_type: string, // "chat"/"responses"/"messages"/"image" 等 + stream: boolean, + duration_ms, first_token_ms: number | null, + image_count: number, + created_at: string +} +``` + +### 4.2 详情 +`GET /api/v1/usage/:id` → `dto.UsageLog` + +### 4.3 统计 +`GET /api/v1/usage/stats?period=today|week|month` 或 `start_date&end_date` +→ 返回 `service.UsageStats`(求和 + tokens/requests/cost 各纬度汇总;字段见 sub2api `UsageStats` 定义)。 + +### 4.4 Dashboard +- `GET /api/v1/usage/dashboard/stats` +- `GET /api/v1/usage/dashboard/trend?granularity=day&start_date&end_date` +- `GET /api/v1/usage/dashboard/models?start_date&end_date` +- `POST /api/v1/usage/dashboard/api-keys-usage` body `{ "api_key_ids": [1,2,3] }` + +--- + +## 5. 管理员接口(x-api-key) + +superDream BFF 仅用到以下 admin 接口: + +### 5.1 用户 +- `POST /api/v1/admin/users` — 注册同步 + ```json + { "email", "password", "username", "notes": "from superDream", + "balance": 0, "concurrency": 0, "allowed_groups": [] } + ``` + → `dto.AdminUser` +- `PUT /api/v1/admin/users/:id` — 改密/改状态同步(字段均为可选 PATCH 语义) +- `DELETE /api/v1/admin/users/:id` — 删除同步 +- `GET /api/v1/admin/users?search=email@x.com` — 查找用户(拿 `id` 用于后续调用) +- `GET /api/v1/admin/users/:id` — 详情 + +### 5.2 查用户 API Keys / 用量 +- `GET /api/v1/admin/users/:id/api-keys?page=&page_size=&sort_by=&sort_order=` → `dto.APIKey[]`(分页) +- `GET /api/v1/admin/users/:id/usage?period=today|week|month` → `UsageStats` + +### 5.3 能力缺口 +sub2api **admin 路径不提供** 代用户创建/删除/修改 Key 的接口(`registerAdminAPIKeyRoutes` 仅 `PUT /admin/api-keys/:id` 改分组)。 +→ Key 的写操作必须使用用户 JWT。 + +--- + +## 6. BFF 对接策略 + +### 6.1 本地 `User` 表扩展字段 + +| 字段 | 类型 | 说明 | +|---|---|---| +| `sub2api_user_id` | `BIGINT` | 对应 sub2api 的 `dto.User.id` | +| `sub2api_refresh_token_enc` | `TEXT` | Fernet 加密后的 refresh_token | +| `sub2api_access_token` | `TEXT` | 当前有效的 access_token(内存级缓存即可,但写库便于重启复用) | +| `sub2api_access_expires_at` | `DATETIME` | access_token 过期时间(UTC) | + +### 6.2 鉴权 → Token 获取顺序 +每次 BFF 需要代理到 sub2api **用户接口** 时: +1. 如本地缓存 access_token 未过期(预留 60s 余量),直接用 +2. 否则用 Fernet 解密 refresh_token,调 `/auth/refresh` 换新(rotate 后写回) +3. 若 refresh 也 401(token 被撤销)→ 返回前端 `401 sub2api_reauth_required`,前端弹窗让用户重新输密码,后端重调 `/auth/login` 重新拿到 token + +### 6.3 各业务同步矩阵 + +| 动作 | 本地 | sub2api(admin token) | sub2api(用户 token) | +|---|---|---|---| +| 注册 | ①写 user(pending) | ②`POST /admin/users` 取回 `id` | ③`POST /auth/login` 拿首对 tokens
④成功后 commit 本地;任一失败整体回滚 | +| 登录 | ①校验本地密码 → 签本地 JWT | — | ②同步调 `/auth/login` 刷新存储 refresh_token | +| 改密 | ①更新本地 hash | ②`PUT /admin/users/:id` | ③用新密码调 `/auth/login` 替换 token | +| 删用户 | ①级联删 | ②`DELETE /admin/users/:id` | — | +| `GET /keys` | — | `GET /admin/users/:id/api-keys` | — | +| `POST/PUT/DELETE /keys` | — | — | `/keys` 系列 | +| `GET /usage/*` | — | `GET /admin/users/:id/usage` | 兜底:若 admin 接口粒度不够,回退到用户 token 调 `/usage` | +| Dashboard 统计 | 本地聚合 sub2api 返回 | `/admin/users/:id/usage` | `/usage/dashboard/*` | + +### 6.4 前端字段替换 + +替换 `frontend/src/services/` 下的 TS interface,使其与 `dto.APIKey` / `dto.UsageLog` / `dto.User` 完全一致(字段名用 snake_case 保留,不转 camelCase,避免映射层)。具体列在实现阶段列出 diff。 + +### 6.5 错误映射 + +| sub2api 返回 | BFF 抛出 | 前端显示 | +|---|---|---| +| `code=400` | `HTTPException 400` + `reason` | 表单校验提示 | +| `code=401 INVALID_TOKEN/TOKEN_EXPIRED` | 触发 refresh 流;失败抛 `401 sub2api_reauth_required` | 弹框"请重新登录" | +| `code=403` | `403` | "没有权限" | +| `code=404` | `404` | "资源不存在" | +| `code>=500` | `502 upstream_error` + 日志 | "上游服务异常" | +| 网络异常 | `504 upstream_timeout` | "请求超时" | + +--- + +## 7. 新增配置项 + +写入 `app/config/settings.py`: +```python +sub2api_base_url: str = "http://127.0.0.1:8080" +sub2api_admin_token: str = "" # sub2api Admin API Key +sub2api_request_timeout: float = 10.0 # 秒 +sub2api_turnstile_bypass: bool = True # 是否绕过 turnstile(需 sub2api 端也关) +token_encryption_key: str = "" # Fernet key(44 字节 urlsafe base64) +``` + +生成 Fernet key: +```bash +python -c "from cryptography.fernet import Fernet; print(Fernet.generate_key().decode())" +``` + +`requirements.txt` 增加:`httpx>=0.27` `cryptography>=42` diff --git a/frontend/src/pages/dashboard/Keys.tsx b/frontend/src/pages/dashboard/Keys.tsx index 6931355..171a7f9 100644 --- a/frontend/src/pages/dashboard/Keys.tsx +++ b/frontend/src/pages/dashboard/Keys.tsx @@ -1,22 +1,34 @@ import { useState, useEffect } from "react"; -import { keyService, ApiKeyInfo } from "../../services/keyService"; +import { keyService, ApiKey } from "../../services/keyService"; + +function maskKey(key: string): string { + if (!key) return ""; + if (key.length <= 12) return key; + return `${key.slice(0, 8)}...${key.slice(-4)}`; +} + +function formatQuota(used: number, total: number): string { + if (total === 0) return `$${used.toFixed(4)} / 无限`; + return `$${used.toFixed(4)} / $${total.toFixed(2)}`; +} export default function Keys() { - const [keys, setKeys] = useState([]); + const [keys, setKeys] = useState([]); const [name, setName] = useState(""); + const [quota, setQuota] = useState("0"); const [newKey, setNewKey] = useState(""); const [copied, setCopied] = useState(false); const [loading, setLoading] = useState(false); const [error, setError] = useState(""); - const [deleteTarget, setDeleteTarget] = useState(null); + const [deleteTarget, setDeleteTarget] = useState(null); const [deleting, setDeleting] = useState(false); const fetchKeys = async () => { try { - const data = await keyService.list(); - setKeys(data); - } catch (err: any) { - setError(err.message); + const page = await keyService.list({ page_size: 100 }); + setKeys(page.items); + } catch (err) { + setError(err instanceof Error ? err.message : String(err)); } }; @@ -29,26 +41,30 @@ export default function Keys() { setNewKey(""); setLoading(true); try { - const res = await keyService.create(name); + const quotaNum = Number(quota); + const res = await keyService.create({ + name: name || "未命名", + quota: Number.isFinite(quotaNum) ? quotaNum : 0, + }); setNewKey(res.key); setName(""); await fetchKeys(); - } catch (err: any) { - setError(err.message); + } catch (err) { + setError(err instanceof Error ? err.message : String(err)); } finally { setLoading(false); } }; const confirmDelete = async () => { - if (!deleteTarget) return; + if (deleteTarget === null) return; setDeleting(true); try { await keyService.remove(deleteTarget); setDeleteTarget(null); await fetchKeys(); - } catch (err: any) { - setError(err.message); + } catch (err) { + setError(err instanceof Error ? err.message : String(err)); setDeleteTarget(null); } finally { setDeleting(false); @@ -71,11 +87,10 @@ export default function Keys() { )} - {/* Create Key */}
- +
+
+ + setQuota(e.target.value)} + min="0" + step="0.01" + className="w-full px-3 py-2 rounded-lg bg-superdream-bg border border-gray-300 dark:border-gray-700 text-gray-900 dark:text-white text-sm focus:border-superdream-accent focus:outline-none" + /> +
- {/* Newly created key */} {newKey && (
-

Key 创建成功!请立即复制保存,此 Key 仅显示一次。

+

Key 创建成功!请立即复制保存。

{newKey} @@ -112,13 +137,15 @@ export default function Keys() {
)} - {/* Key list */}
+ + + @@ -126,7 +153,7 @@ export default function Keys() { {keys.length === 0 ? ( - @@ -134,8 +161,21 @@ export default function Keys() { keys.map((k) => ( - + + +
名称 Key状态配额 (USD)最近使用 创建时间 操作
+ 暂无 Key,点击上方按钮创建
{k.name || "-"} - sk-sd-{k.key_prefix}...{k.key_suffix} + {maskKey(k.key)} + + {k.status} + + + {formatQuota(k.quota_used, k.quota)} + + {k.last_used_at ? new Date(k.last_used_at).toLocaleString() : "-"} {new Date(k.created_at).toLocaleString()} @@ -155,8 +195,7 @@ export default function Keys() {
- {/* Delete confirm modal */} - {deleteTarget && ( + {deleteTarget !== null && (
); -} +} \ No newline at end of file diff --git a/frontend/src/pages/dashboard/Logs.tsx b/frontend/src/pages/dashboard/Logs.tsx index 6d12aae..6d0d48d 100644 --- a/frontend/src/pages/dashboard/Logs.tsx +++ b/frontend/src/pages/dashboard/Logs.tsx @@ -1,8 +1,9 @@ import { useState, useEffect } from "react"; -import { usageService, UsageLogItem } from "../../services/usageService"; +import { usageService, UsageLog } from "../../services/usageService"; export default function Logs() { - const [logs, setLogs] = useState([]); + const [logs, setLogs] = useState([]); + const [total, setTotal] = useState(0); const [page, setPage] = useState(1); const [model, setModel] = useState(""); const [loading, setLoading] = useState(false); @@ -13,12 +14,13 @@ export default function Logs() { try { const data = await usageService.logs({ page, - size: pageSize, + page_size: pageSize, model: model || undefined, }); - setLogs(data); + setLogs(data.items); + setTotal(data.total); } catch { - // silently fail + setLogs([]); } finally { setLoading(false); } @@ -29,15 +31,16 @@ export default function Logs() { }, [page]); const handleFilter = () => { - setPage(1); - fetchLogs(); + if (page !== 1) setPage(1); + else fetchLogs(); }; + const pages = Math.max(1, Math.ceil(total / pageSize)); + return (

调用日志

- {/* Filters */}
@@ -59,53 +62,44 @@ export default function Logs() {
- {/* Table */}
- - - + + + + - + {loading ? ( - + ) : logs.length === 0 ? ( - + ) : ( logs.map((log) => ( - - - - - + + + + + )) )} @@ -113,7 +107,6 @@ export default function Logs() {
时间 模型PromptCompletionTotal类型InputOutputCache 读 费用状态耗时 ms
- 加载中... - 加载中...
- 暂无调用记录 - 暂无调用记录
- {new Date(log.request_time).toLocaleString()} + {new Date(log.created_at).toLocaleString()} {log.model}{log.prompt_tokens.toLocaleString()}{log.completion_tokens.toLocaleString()}{log.total_tokens.toLocaleString()}¥{log.cost} - - {log.status} - + + {log.request_type}{log.stream ? " · stream" : ""} {log.input_tokens.toLocaleString()}{log.output_tokens.toLocaleString()}{log.cache_read_tokens.toLocaleString()}${log.total_cost.toFixed(6)}{log.duration_ms ?? "-"}
- {/* Pagination */}
- 第 {page} 页 + + 第 {page} / {pages} 页(共 {total} 条) +
); -} +} \ No newline at end of file diff --git a/frontend/src/pages/dashboard/Overview.tsx b/frontend/src/pages/dashboard/Overview.tsx index b400391..2218848 100644 --- a/frontend/src/pages/dashboard/Overview.tsx +++ b/frontend/src/pages/dashboard/Overview.tsx @@ -1,42 +1,76 @@ import { useState, useEffect } from "react"; -import { walletService } from "../../services/walletService"; +import { authService, UserInfo } from "../../services/authService"; import { keyService } from "../../services/keyService"; -import { usageService, UsageSummary } from "../../services/usageService"; +import { usageService, UsageStats } from "../../services/usageService"; export default function Overview() { - const [balance, setBalance] = useState("0"); + const [user, setUser] = useState(null); const [keyCount, setKeyCount] = useState(0); - const [summary, setSummary] = useState(null); + const [today, setToday] = useState(null); + const [month, setMonth] = useState(null); useEffect(() => { - walletService.getBalance().then((b) => setBalance(b.balance)).catch(() => {}); - keyService.list().then((keys) => setKeyCount(keys.length)).catch(() => {}); - usageService.summary().then(setSummary).catch(() => {}); + authService.me().then(setUser).catch(() => {}); + keyService.list({ page_size: 1 }).then((p) => setKeyCount(p.total)).catch(() => {}); + usageService.stats({ period: "today" }).then(setToday).catch(() => {}); + usageService.stats({ period: "month" }).then(setMonth).catch(() => {}); }, []); + const todayTokens = today ? (today.total_input_tokens + today.total_output_tokens) : 0; + const todayCost = today?.total_cost ?? 0; + return (

控制台

余额

-

¥{balance}

+

+ ${(user?.balance ?? 0).toFixed(4)} +

-

活跃 Key

+

API Key 数量

{keyCount}

-

今日调用

+

今日 Tokens

- {summary ? `${summary.today_tokens.toLocaleString()} tokens` : "0"} + {todayTokens.toLocaleString()}

今日消耗

-

¥{summary?.today_cost ?? "0"}

+

+ ${todayCost.toFixed(4)} +

+ {month && ( +
+

本月累计

+
+
+

请求数

+

+ {month.total_requests?.toLocaleString() ?? 0} +

+
+
+

Tokens

+

+ {(month.total_input_tokens + month.total_output_tokens).toLocaleString()} +

+
+
+

消耗

+

+ ${month.total_cost?.toFixed(4) ?? "0"} +

+
+
+
+ )}
); -} +} \ No newline at end of file diff --git a/frontend/src/pages/dashboard/Usage.tsx b/frontend/src/pages/dashboard/Usage.tsx index 1a45be3..a354494 100644 --- a/frontend/src/pages/dashboard/Usage.tsx +++ b/frontend/src/pages/dashboard/Usage.tsx @@ -4,42 +4,45 @@ import { BarChart, Bar, Cell, } from "recharts"; import { - usageService, UsageSummary, DailyUsage, ModelUsage, + usageService, UsageStats, DashboardTrendPoint, DashboardModelStat, } from "../../services/usageService"; const COLORS = ["#8B5CF6", "#a78bfa", "#6366f1", "#818cf8", "#c084fc", "#7c3aed"]; export default function Usage() { - const [summary, setSummary] = useState(null); - const [daily, setDaily] = useState([]); - const [byModel, setByModel] = useState([]); + const [today, setToday] = useState(null); + const [month, setMonth] = useState(null); + const [trend, setTrend] = useState([]); + const [models, setModels] = useState([]); useEffect(() => { - usageService.summary().then(setSummary).catch(() => {}); - usageService.daily().then(setDaily).catch(() => {}); - usageService.byModel().then(setByModel).catch(() => {}); + usageService.stats({ period: "today" }).then(setToday).catch(() => {}); + usageService.stats({ period: "month" }).then(setMonth).catch(() => {}); + usageService.dashboardTrend({ granularity: "day" }).then((r) => setTrend(r.trend)).catch(() => {}); + usageService.dashboardModels().then((r) => setModels(r.models)).catch(() => {}); }, []); + const todayTokens = today ? today.total_input_tokens + today.total_output_tokens : 0; + const monthTokens = month ? month.total_input_tokens + month.total_output_tokens : 0; + return (

用量统计

- {/* Summary cards */}
- - - - + + + +
- {/* Daily chart */}
-

每日用量(最近 30 天)

- {daily.length === 0 ? ( +

每日用量

+ {trend.length === 0 ? (

暂无数据

) : ( - + - + )}
- {/* By model chart */}

按模型统计

- {byModel.length === 0 ? ( + {models.length === 0 ? (

暂无数据

) : ( - + - + - - {byModel.map((_, i) => ( + + {models.map((_, i) => ( ))} @@ -104,4 +96,4 @@ function Card({ label, value }: { label: string; value: string }) {

{value}

); -} +} \ No newline at end of file diff --git a/frontend/src/pages/dashboard/Wallet.tsx b/frontend/src/pages/dashboard/Wallet.tsx index 70aceb2..0d7ec21 100644 --- a/frontend/src/pages/dashboard/Wallet.tsx +++ b/frontend/src/pages/dashboard/Wallet.tsx @@ -1,139 +1,27 @@ import { useState, useEffect } from "react"; -import { walletService, TransactionInfo } from "../../services/walletService"; - -const TYPE_LABELS: Record = { - topup: "充值", - consume: "消费", - refund: "退款", -}; +import { authService, UserInfo } from "../../services/authService"; export default function Wallet() { - const [balance, setBalance] = useState("0"); - const [code, setCode] = useState(""); - const [transactions, setTransactions] = useState([]); - const [loading, setLoading] = useState(false); - const [error, setError] = useState(""); - const [success, setSuccess] = useState(""); - - const fetchData = async () => { - try { - const [b, txns] = await Promise.all([ - walletService.getBalance(), - walletService.transactions(), - ]); - setBalance(b.balance); - setTransactions(txns); - } catch (err: any) { - setError(err.message); - } - }; + const [user, setUser] = useState(null); useEffect(() => { - fetchData(); + authService.me().then(setUser).catch(() => {}); }, []); - const handleRedeem = async () => { - setError(""); - setSuccess(""); - if (!code.trim()) return; - setLoading(true); - try { - const txn = await walletService.redeem(code.trim()); - setSuccess(`充值成功!+¥${txn.amount}`); - setCode(""); - await fetchData(); - } catch (err: any) { - setError(err.message); - } finally { - setLoading(false); - } - }; - return (

钱包

- {/* Balance */}
-

当前余额

-

¥{balance}

+

当前余额 (USD)

+

+ ${(user?.balance ?? 0).toFixed(4)} +

- {/* Redeem */} -
- - - {error && ( -
- {error} -
- )} - {success && ( -
- {success} -
- )} - -
- setCode(e.target.value)} - placeholder="输入兑换码" - className="flex-1 px-3 py-2 rounded-lg bg-superdream-bg border border-gray-300 dark:border-gray-700 text-gray-900 dark:text-white text-sm focus:border-superdream-accent focus:outline-none" - /> - -
-
- - {/* Transactions */} -
-

- 交易记录 -

- - - - - - - - - - - - {transactions.length === 0 ? ( - - - - ) : ( - transactions.map((txn) => ( - - - - - - - - )) - )} - -
类型金额余额备注时间
- 暂无交易记录 -
- {TYPE_LABELS[txn.type] || txn.type} - - {txn.type === "consume" ? "-" : "+"}¥{txn.amount} - ¥{txn.balance_after}{txn.reference_id || "-"} - {new Date(txn.created_at).toLocaleString()} -
+
+ 兑换码充值和交易记录暂未对接 sub2api,后续接入 /api/v1/redeem 后恢复。
); -} +} \ No newline at end of file diff --git a/frontend/src/services/authService.ts b/frontend/src/services/authService.ts index 630f15e..32b4459 100644 --- a/frontend/src/services/authService.ts +++ b/frontend/src/services/authService.ts @@ -9,9 +9,10 @@ export interface TokenResponse { export interface UserInfo { id: string; email: string; - balance: string; status: string; created_at: string; + sub2api_user_id: number | null; + balance: number; } export const authService = { @@ -31,4 +32,4 @@ export const authService = { resetPassword: (token: string, new_password: string) => httpClient.post<{ message: string }>("/auth/reset-password", { token, new_password }), -}; +}; \ No newline at end of file diff --git a/frontend/src/services/httpClient.ts b/frontend/src/services/httpClient.ts index 5d228fc..6522c76 100644 --- a/frontend/src/services/httpClient.ts +++ b/frontend/src/services/httpClient.ts @@ -19,7 +19,7 @@ async function request( }); if (!res.ok) { const body = await res.json().catch(() => ({})); - throw new Error(body.detail || `HTTP ${res.status}: ${res.statusText}`); + throw new Error(body.detail || body.message || `HTTP ${res.status}: ${res.statusText}`); } return res.json(); } @@ -31,6 +31,11 @@ export const httpClient = { method: "POST", body: JSON.stringify(body), }), + put: (endpoint: string, body: unknown) => + request(endpoint, { + method: "PUT", + body: JSON.stringify(body), + }), delete: (endpoint: string) => request(endpoint, { method: "DELETE" }), -}; +}; \ No newline at end of file diff --git a/frontend/src/services/keyService.ts b/frontend/src/services/keyService.ts index ed04295..f223958 100644 --- a/frontend/src/services/keyService.ts +++ b/frontend/src/services/keyService.ts @@ -1,25 +1,100 @@ import { httpClient } from "./httpClient"; -export interface ApiKeyInfo { - id: string; +// Matches sub2api dto.APIKey 1:1 (see docs/sub2api_api.md §3.1) +export interface ApiKey { + id: number; + user_id: number; + key: string; name: string; - key_prefix: string; - key_suffix: string; - status: string; + group_id: number | null; + status: "active" | "inactive"; + ip_whitelist: string[]; + ip_blacklist: string[]; + last_used_at: string | null; + quota: number; + quota_used: number; + expires_at: string | null; + rate_limit_5h: number; + rate_limit_1d: number; + rate_limit_7d: number; + usage_5h: number; + usage_1d: number; + usage_7d: number; + window_5h_start: string | null; + window_1d_start: string | null; + window_7d_start: string | null; + reset_5h_at?: string | null; + reset_1d_at?: string | null; + reset_7d_at?: string | null; created_at: string; + updated_at: string; + group?: Group; } -export interface ApiKeyCreated { - id: string; +export interface Group { + id: number; name: string; - key: string; - key_prefix: string; - key_suffix: string; - created_at: string; + description: string; + platform: string; + rate_multiplier: number; + status: string; +} + +export interface Paginated { + items: T[]; + total: number; + page: number; + page_size: number; + pages: number; +} + +export interface CreateKeyPayload { + name: string; + group_id?: number | null; + custom_key?: string | null; + ip_whitelist?: string[]; + ip_blacklist?: string[]; + quota?: number; + expires_in_days?: number | null; + rate_limit_5h?: number; + rate_limit_1d?: number; + rate_limit_7d?: number; +} + +export interface UpdateKeyPayload { + name?: string; + group_id?: number | null; + status?: "active" | "inactive"; + ip_whitelist?: string[]; + ip_blacklist?: string[]; + quota?: number; + expires_at?: string | null; + reset_quota?: boolean; + rate_limit_5h?: number; + rate_limit_1d?: number; + rate_limit_7d?: number; + reset_rate_limit_usage?: boolean; } export const keyService = { - list: () => httpClient.get("/keys"), - create: (name: string) => httpClient.post("/keys", { name }), - remove: (keyId: string) => httpClient.delete<{ message: string }>(`/keys/${keyId}`), -}; + list: (params: { page?: number; page_size?: number; sort_by?: string; sort_order?: string } = {}) => { + const sp = new URLSearchParams(); + if (params.page) sp.set("page", String(params.page)); + if (params.page_size) sp.set("page_size", String(params.page_size)); + if (params.sort_by) sp.set("sort_by", params.sort_by); + if (params.sort_order) sp.set("sort_order", params.sort_order); + const qs = sp.toString(); + return httpClient.get>(`/keys${qs ? `?${qs}` : ""}`); + }, + + get: (keyId: number) => httpClient.get(`/keys/${keyId}`), + + create: (payload: CreateKeyPayload) => httpClient.post("/keys", payload), + + update: (keyId: number, payload: UpdateKeyPayload) => + httpClient.put(`/keys/${keyId}`, payload), + + remove: (keyId: number) => httpClient.delete<{ message: string }>(`/keys/${keyId}`), + + availableGroups: () => httpClient.get("/keys/meta/available-groups"), +}; \ No newline at end of file diff --git a/frontend/src/services/usageService.ts b/frontend/src/services/usageService.ts index 2a0b5bf..d4b87ba 100644 --- a/frontend/src/services/usageService.ts +++ b/frontend/src/services/usageService.ts @@ -1,80 +1,143 @@ import { httpClient } from "./httpClient"; +import type { Paginated } from "./keyService"; -export interface UsageSummary { - today_tokens: number; - today_cost: string; - month_tokens: number; - month_cost: string; - total_requests: number; -} - -export interface DailyUsage { - date: string; - total_tokens: number; - cost: string; - requests: number; -} - -export interface ModelUsage { - model: string; - total_tokens: number; - cost: string; - requests: number; -} - -export interface KeyUsage { - key_id: string; - key_name: string; - key_prefix: string; - key_suffix: string; - total_tokens: number; - cost: string; - requests: number; -} - -export interface UsageLogItem { +// Matches sub2api dto.UsageLog 1:1 (see docs/sub2api_api.md §4.1) +export interface UsageLog { id: number; - key_id: string; + user_id: number; + api_key_id: number; + account_id: number; + request_id: string; model: string; - prompt_tokens: number; - completion_tokens: number; - total_tokens: number; - cost: string; - request_time: string; - status: string; + service_tier?: string | null; + reasoning_effort?: string | null; + inbound_endpoint?: string | null; + upstream_endpoint?: string | null; + group_id: number | null; + subscription_id: number | null; + input_tokens: number; + output_tokens: number; + cache_creation_tokens: number; + cache_read_tokens: number; + input_cost: number; + output_cost: number; + cache_creation_cost: number; + cache_read_cost: number; + total_cost: number; + actual_cost: number; + rate_multiplier: number; + billing_type: number; + request_type: string; + stream: boolean; + duration_ms: number | null; + first_token_ms: number | null; + image_count: number; + image_size: string | null; + created_at: string; +} + +// sub2api service.UsageStats shape (summed totals for a period) +export interface UsageStats { + total_requests: number; + total_input_tokens: number; + total_output_tokens: number; + total_cache_creation_tokens: number; + total_cache_read_tokens: number; + total_cost: number; + total_actual_cost: number; + [key: string]: unknown; +} + +export interface DashboardStats { + balance?: number; + today?: UsageStats; + month?: UsageStats; + total_requests?: number; + active_keys?: number; + [key: string]: unknown; +} + +export interface DashboardTrendPoint { + date: string; + requests: number; + input_tokens: number; + output_tokens: number; + total_cost: number; + [key: string]: unknown; +} + +export interface DashboardModelStat { + model: string; + requests: number; + input_tokens: number; + output_tokens: number; + total_cost: number; + [key: string]: unknown; } export const usageService = { - summary: () => httpClient.get("/usage/summary"), - - daily: (start?: string, end?: string) => { - const params = new URLSearchParams(); - if (start) params.set("start", start); - if (end) params.set("end", end); - const qs = params.toString(); - return httpClient.get(`/usage/daily${qs ? `?${qs}` : ""}`); - }, - - byModel: () => httpClient.get("/usage/by-model"), - - byKey: () => httpClient.get("/usage/by-key"), - - logs: (params: { - page?: number; - size?: number; - model?: string; - key_id?: string; - start?: string; - end?: string; - } = {}) => { + logs: ( + params: { + page?: number; + page_size?: number; + api_key_id?: number; + model?: string; + request_type?: string; + stream?: boolean; + start_date?: string; + end_date?: string; + timezone?: string; + } = {}, + ) => { const sp = new URLSearchParams(); - if (params.page) sp.set("page", String(params.page)); - if (params.size) sp.set("size", String(params.size)); - if (params.model) sp.set("model", params.model); - if (params.key_id) sp.set("key_id", params.key_id); - if (params.start) sp.set("start", params.start); - if (params.end) sp.set("end", params.end); + Object.entries(params).forEach(([k, v]) => { + if (v !== undefined && v !== null && v !== "") sp.set(k, String(v)); + }); const qs = sp.toString(); - return httpClient.get(`/usage/logs${qs ? `?${qs}` : ""}`); + return httpClient.get>(`/usage${qs ? `?${qs}` : ""}`); }, -}; + + stats: ( + params: { + period?: "today" | "week" | "month"; + start_date?: string; + end_date?: string; + api_key_id?: number; + timezone?: string; + } = {}, + ) => { + const sp = new URLSearchParams(); + Object.entries(params).forEach(([k, v]) => { + if (v !== undefined && v !== null && v !== "") sp.set(k, String(v)); + }); + const qs = sp.toString(); + return httpClient.get(`/usage/stats${qs ? `?${qs}` : ""}`); + }, + + dashboardStats: () => httpClient.get("/usage/dashboard/stats"), + + dashboardTrend: (params: { granularity?: string; start_date?: string; end_date?: string } = {}) => { + const sp = new URLSearchParams(); + Object.entries(params).forEach(([k, v]) => { + if (v !== undefined && v !== null && v !== "") sp.set(k, String(v)); + }); + const qs = sp.toString(); + return httpClient.get<{ trend: DashboardTrendPoint[]; start_date: string; end_date: string; granularity: string }>( + `/usage/dashboard/trend${qs ? `?${qs}` : ""}`, + ); + }, + + dashboardModels: (params: { start_date?: string; end_date?: string } = {}) => { + const sp = new URLSearchParams(); + Object.entries(params).forEach(([k, v]) => { + if (v !== undefined && v !== null && v !== "") sp.set(k, String(v)); + }); + const qs = sp.toString(); + return httpClient.get<{ models: DashboardModelStat[]; start_date: string; end_date: string }>( + `/usage/dashboard/models${qs ? `?${qs}` : ""}`, + ); + }, + + dashboardAPIKeysUsage: (api_key_ids: number[]) => + httpClient.post<{ stats: Record }>("/usage/dashboard/api-keys-usage", { api_key_ids }), +}; \ No newline at end of file diff --git a/frontend/src/services/walletService.ts b/frontend/src/services/walletService.ts deleted file mode 100644 index a71755a..0000000 --- a/frontend/src/services/walletService.ts +++ /dev/null @@ -1,21 +0,0 @@ -import { httpClient } from "./httpClient"; - -export interface BalanceInfo { - balance: string; -} - -export interface TransactionInfo { - id: string; - type: string; - amount: string; - balance_after: string; - reference_id: string; - created_at: string; -} - -export const walletService = { - getBalance: () => httpClient.get("/wallet/balance"), - redeem: (code: string) => httpClient.post("/wallet/redeem", { code }), - transactions: (page = 1, size = 20) => - httpClient.get(`/wallet/transactions?page=${page}&size=${size}`), -}; diff --git a/frontend/tsconfig.tsbuildinfo b/frontend/tsconfig.tsbuildinfo index 5c90324..23b7ffc 100644 --- a/frontend/tsconfig.tsbuildinfo +++ b/frontend/tsconfig.tsbuildinfo @@ -1 +1 @@ -{"root":["./src/app.tsx","./src/index.tsx","./src/types.ts","./src/components/authguard.tsx","./src/components/header.tsx","./src/components/statusbar.tsx","./src/hooks/usefetch.ts","./src/pages/docs.tsx","./src/pages/forgotpassword.tsx","./src/pages/home.tsx","./src/pages/login.tsx","./src/pages/pricing.tsx","./src/pages/register.tsx","./src/pages/resetpassword.tsx","./src/pages/dashboard/keys.tsx","./src/pages/dashboard/layout.tsx","./src/pages/dashboard/logs.tsx","./src/pages/dashboard/overview.tsx","./src/pages/dashboard/usage.tsx","./src/pages/dashboard/wallet.tsx","./src/services/authservice.ts","./src/services/exampleservice.ts","./src/services/httpclient.ts","./src/services/keyservice.ts","./src/services/usageservice.ts","./src/services/walletservice.ts","./src/stores/authstore.ts","./src/stores/themestore.ts"],"version":"5.8.3"} \ No newline at end of file +{"root":["./src/app.tsx","./src/index.tsx","./src/types.ts","./src/components/authguard.tsx","./src/components/header.tsx","./src/components/statusbar.tsx","./src/hooks/usefetch.ts","./src/pages/docs.tsx","./src/pages/forgotpassword.tsx","./src/pages/home.tsx","./src/pages/login.tsx","./src/pages/pricing.tsx","./src/pages/register.tsx","./src/pages/resetpassword.tsx","./src/pages/dashboard/keys.tsx","./src/pages/dashboard/layout.tsx","./src/pages/dashboard/logs.tsx","./src/pages/dashboard/overview.tsx","./src/pages/dashboard/usage.tsx","./src/pages/dashboard/wallet.tsx","./src/services/authservice.ts","./src/services/exampleservice.ts","./src/services/httpclient.ts","./src/services/keyservice.ts","./src/services/usageservice.ts","./src/stores/authstore.ts","./src/stores/themestore.ts"],"version":"5.8.3"} \ No newline at end of file