Compare commits
2 Commits
f37bc9bdf5
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
35c0b7de16 | ||
|
|
20e842a60a |
@@ -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,
|
||||
)
|
||||
@@ -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"}
|
||||
@@ -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)
|
||||
@@ -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_"
|
||||
|
||||
29
app/core/crypto.py
Normal file
29
app/core/crypto.py
Normal file
@@ -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
|
||||
@@ -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]
|
||||
0
app/integrations/__init__.py
Normal file
0
app/integrations/__init__.py
Normal file
18
app/integrations/sub2api/__init__.py
Normal file
18
app/integrations/sub2api/__init__.py
Normal file
@@ -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",
|
||||
]
|
||||
97
app/integrations/sub2api/admin.py
Normal file
97
app/integrations/sub2api/admin.py
Normal file
@@ -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},
|
||||
)
|
||||
131
app/integrations/sub2api/client.py
Normal file
131
app/integrations/sub2api/client.py
Normal file
@@ -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)
|
||||
96
app/integrations/sub2api/user.py
Normal file
96
app/integrations/sub2api/user.py
Normal file
@@ -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},
|
||||
)
|
||||
@@ -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"])
|
||||
|
||||
|
||||
@@ -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)
|
||||
@@ -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)
|
||||
|
||||
# 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()
|
||||
@@ -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
|
||||
74
app/services/sub2api_session.py
Normal file
74
app/services/sub2api_session.py
Normal file
@@ -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]
|
||||
@@ -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
|
||||
@@ -19,7 +19,7 @@ services:
|
||||
|
||||
backend:
|
||||
container_name: superdreamfront
|
||||
image: superdreamfront:0.0.1
|
||||
image: superdreamfront:0.0.2
|
||||
build:
|
||||
context: .
|
||||
dockerfile: Dockerfile
|
||||
|
||||
339
docs/sub2api_api.md
Normal file
339
docs/sub2api_api.md
Normal file
@@ -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 <access_token>` | `/keys`、`/usage`、`/user/*` |
|
||||
| 管理员 | `x-api-key: <admin_api_key>` 或 `Authorization: Bearer <admin_jwt>` | `/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<br>④成功后 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`
|
||||
@@ -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<ApiKeyInfo[]>([]);
|
||||
const [keys, setKeys] = useState<ApiKey[]>([]);
|
||||
const [name, setName] = useState("");
|
||||
const [quota, setQuota] = useState<string>("0");
|
||||
const [newKey, setNewKey] = useState("");
|
||||
const [copied, setCopied] = useState(false);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [error, setError] = useState("");
|
||||
const [deleteTarget, setDeleteTarget] = useState<string | null>(null);
|
||||
const [deleteTarget, setDeleteTarget] = useState<number | null>(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() {
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Create Key */}
|
||||
<div className="bg-superdream-panel rounded-xl p-4 mb-4">
|
||||
<div className="flex gap-3 items-end">
|
||||
<div className="flex-1">
|
||||
<label className="block text-sm text-gray-600 dark:text-gray-400 mb-1">Key 名称(可选)</label>
|
||||
<label className="block text-sm text-gray-600 dark:text-gray-400 mb-1">Key 名称</label>
|
||||
<input
|
||||
type="text"
|
||||
value={name}
|
||||
@@ -84,6 +99,17 @@ export default function Keys() {
|
||||
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"
|
||||
/>
|
||||
</div>
|
||||
<div className="w-40">
|
||||
<label className="block text-sm text-gray-600 dark:text-gray-400 mb-1">配额 USD (0=无限)</label>
|
||||
<input
|
||||
type="number"
|
||||
value={quota}
|
||||
onChange={(e) => 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"
|
||||
/>
|
||||
</div>
|
||||
<button
|
||||
onClick={handleCreate}
|
||||
disabled={loading}
|
||||
@@ -94,10 +120,9 @@ export default function Keys() {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Newly created key */}
|
||||
{newKey && (
|
||||
<div className="mb-4 p-4 rounded-xl bg-green-500/10 border border-green-500/30">
|
||||
<p className="text-sm text-green-400 mb-2">Key 创建成功!请立即复制保存,此 Key 仅显示一次。</p>
|
||||
<p className="text-sm text-green-400 mb-2">Key 创建成功!请立即复制保存。</p>
|
||||
<div className="flex items-center gap-2">
|
||||
<code className="flex-1 text-sm text-gray-900 dark:text-white bg-superdream-bg px-3 py-2 rounded-lg break-all">
|
||||
{newKey}
|
||||
@@ -112,13 +137,15 @@ export default function Keys() {
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Key list */}
|
||||
<div className="bg-superdream-panel rounded-xl overflow-hidden">
|
||||
<table className="w-full text-sm">
|
||||
<thead>
|
||||
<tr className="text-gray-600 dark:text-gray-400 border-b border-gray-200 dark:border-gray-800">
|
||||
<th className="text-left px-4 py-3 font-medium">名称</th>
|
||||
<th className="text-left px-4 py-3 font-medium">Key</th>
|
||||
<th className="text-left px-4 py-3 font-medium">状态</th>
|
||||
<th className="text-left px-4 py-3 font-medium">配额 (USD)</th>
|
||||
<th className="text-left px-4 py-3 font-medium">最近使用</th>
|
||||
<th className="text-left px-4 py-3 font-medium">创建时间</th>
|
||||
<th className="text-right px-4 py-3 font-medium">操作</th>
|
||||
</tr>
|
||||
@@ -126,7 +153,7 @@ export default function Keys() {
|
||||
<tbody>
|
||||
{keys.length === 0 ? (
|
||||
<tr>
|
||||
<td colSpan={4} className="px-4 py-8 text-center text-gray-500">
|
||||
<td colSpan={7} className="px-4 py-8 text-center text-gray-500">
|
||||
暂无 Key,点击上方按钮创建
|
||||
</td>
|
||||
</tr>
|
||||
@@ -134,8 +161,21 @@ export default function Keys() {
|
||||
keys.map((k) => (
|
||||
<tr key={k.id} className="border-b border-gray-200/50 dark:border-gray-800/50 hover:bg-gray-200/30 dark:hover:bg-gray-800/30">
|
||||
<td className="px-4 py-3 text-gray-900 dark:text-white">{k.name || "-"}</td>
|
||||
<td className="px-4 py-3 text-gray-700 dark:text-gray-300 font-mono">
|
||||
sk-sd-{k.key_prefix}...{k.key_suffix}
|
||||
<td className="px-4 py-3 text-gray-700 dark:text-gray-300 font-mono">{maskKey(k.key)}</td>
|
||||
<td className="px-4 py-3">
|
||||
<span className={`px-2 py-0.5 rounded text-xs ${
|
||||
k.status === "active"
|
||||
? "bg-green-500/10 text-green-400"
|
||||
: "bg-gray-500/10 text-gray-400"
|
||||
}`}>
|
||||
{k.status}
|
||||
</span>
|
||||
</td>
|
||||
<td className="px-4 py-3 text-gray-700 dark:text-gray-300">
|
||||
{formatQuota(k.quota_used, k.quota)}
|
||||
</td>
|
||||
<td className="px-4 py-3 text-gray-600 dark:text-gray-400">
|
||||
{k.last_used_at ? new Date(k.last_used_at).toLocaleString() : "-"}
|
||||
</td>
|
||||
<td className="px-4 py-3 text-gray-600 dark:text-gray-400">
|
||||
{new Date(k.created_at).toLocaleString()}
|
||||
@@ -155,8 +195,7 @@ export default function Keys() {
|
||||
</table>
|
||||
</div>
|
||||
|
||||
{/* Delete confirm modal */}
|
||||
{deleteTarget && (
|
||||
{deleteTarget !== null && (
|
||||
<div className="fixed inset-0 z-50 flex items-center justify-center">
|
||||
<div
|
||||
className="absolute inset-0 bg-black/60"
|
||||
|
||||
@@ -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<UsageLogItem[]>([]);
|
||||
const [logs, setLogs] = useState<UsageLog[]>([]);
|
||||
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 (
|
||||
<div>
|
||||
<h2 className="text-xl font-bold text-gray-900 dark:text-white mb-4">调用日志</h2>
|
||||
|
||||
{/* Filters */}
|
||||
<div className="bg-superdream-panel rounded-xl p-4 mb-4">
|
||||
<div className="flex gap-3 items-end">
|
||||
<div>
|
||||
@@ -59,53 +62,44 @@ export default function Logs() {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Table */}
|
||||
<div className="bg-superdream-panel rounded-xl overflow-hidden">
|
||||
<table className="w-full text-sm">
|
||||
<thead>
|
||||
<tr className="text-gray-600 dark:text-gray-400 border-b border-gray-200 dark:border-gray-800">
|
||||
<th className="text-left px-4 py-3 font-medium">时间</th>
|
||||
<th className="text-left px-4 py-3 font-medium">模型</th>
|
||||
<th className="text-right px-4 py-3 font-medium">Prompt</th>
|
||||
<th className="text-right px-4 py-3 font-medium">Completion</th>
|
||||
<th className="text-right px-4 py-3 font-medium">Total</th>
|
||||
<th className="text-left px-4 py-3 font-medium">类型</th>
|
||||
<th className="text-right px-4 py-3 font-medium">Input</th>
|
||||
<th className="text-right px-4 py-3 font-medium">Output</th>
|
||||
<th className="text-right px-4 py-3 font-medium">Cache 读</th>
|
||||
<th className="text-right px-4 py-3 font-medium">费用</th>
|
||||
<th className="text-center px-4 py-3 font-medium">状态</th>
|
||||
<th className="text-right px-4 py-3 font-medium">耗时 ms</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{loading ? (
|
||||
<tr>
|
||||
<td colSpan={7} className="px-4 py-8 text-center text-gray-500">
|
||||
加载中...
|
||||
</td>
|
||||
<td colSpan={8} className="px-4 py-8 text-center text-gray-500">加载中...</td>
|
||||
</tr>
|
||||
) : logs.length === 0 ? (
|
||||
<tr>
|
||||
<td colSpan={7} className="px-4 py-8 text-center text-gray-500">
|
||||
暂无调用记录
|
||||
</td>
|
||||
<td colSpan={8} className="px-4 py-8 text-center text-gray-500">暂无调用记录</td>
|
||||
</tr>
|
||||
) : (
|
||||
logs.map((log) => (
|
||||
<tr key={log.id} className="border-b border-gray-200/50 dark:border-gray-800/50 hover:bg-gray-200/30 dark:hover:bg-gray-800/30">
|
||||
<td className="px-4 py-3 text-gray-700 dark:text-gray-300 whitespace-nowrap">
|
||||
{new Date(log.request_time).toLocaleString()}
|
||||
{new Date(log.created_at).toLocaleString()}
|
||||
</td>
|
||||
<td className="px-4 py-3 text-gray-900 dark:text-white">{log.model}</td>
|
||||
<td className="px-4 py-3 text-gray-700 dark:text-gray-300 text-right">{log.prompt_tokens.toLocaleString()}</td>
|
||||
<td className="px-4 py-3 text-gray-700 dark:text-gray-300 text-right">{log.completion_tokens.toLocaleString()}</td>
|
||||
<td className="px-4 py-3 text-gray-900 dark:text-white text-right font-medium">{log.total_tokens.toLocaleString()}</td>
|
||||
<td className="px-4 py-3 text-superdream-accent text-right">¥{log.cost}</td>
|
||||
<td className="px-4 py-3 text-center">
|
||||
<span className={`px-2 py-0.5 rounded text-xs ${
|
||||
log.status === "success"
|
||||
? "bg-green-500/10 text-green-400"
|
||||
: "bg-red-500/10 text-red-400"
|
||||
}`}>
|
||||
{log.status}
|
||||
</span>
|
||||
<td className="px-4 py-3 text-gray-600 dark:text-gray-400">
|
||||
{log.request_type}{log.stream ? " · stream" : ""}
|
||||
</td>
|
||||
<td className="px-4 py-3 text-gray-700 dark:text-gray-300 text-right">{log.input_tokens.toLocaleString()}</td>
|
||||
<td className="px-4 py-3 text-gray-700 dark:text-gray-300 text-right">{log.output_tokens.toLocaleString()}</td>
|
||||
<td className="px-4 py-3 text-gray-600 dark:text-gray-400 text-right">{log.cache_read_tokens.toLocaleString()}</td>
|
||||
<td className="px-4 py-3 text-superdream-accent text-right">${log.total_cost.toFixed(6)}</td>
|
||||
<td className="px-4 py-3 text-gray-600 dark:text-gray-400 text-right">{log.duration_ms ?? "-"}</td>
|
||||
</tr>
|
||||
))
|
||||
)}
|
||||
@@ -113,7 +107,6 @@ export default function Logs() {
|
||||
</table>
|
||||
</div>
|
||||
|
||||
{/* Pagination */}
|
||||
<div className="flex justify-between items-center mt-4">
|
||||
<button
|
||||
onClick={() => setPage((p) => Math.max(1, p - 1))}
|
||||
@@ -122,10 +115,12 @@ export default function Logs() {
|
||||
>
|
||||
上一页
|
||||
</button>
|
||||
<span className="text-sm text-gray-600 dark:text-gray-400">第 {page} 页</span>
|
||||
<span className="text-sm text-gray-600 dark:text-gray-400">
|
||||
第 {page} / {pages} 页(共 {total} 条)
|
||||
</span>
|
||||
<button
|
||||
onClick={() => setPage((p) => p + 1)}
|
||||
disabled={logs.length < pageSize}
|
||||
disabled={page >= pages}
|
||||
className="px-4 py-2 rounded-lg border border-gray-300 dark:border-gray-700 text-gray-700 dark:text-gray-300 text-sm hover:bg-gray-200 dark:hover:bg-gray-800 transition disabled:opacity-30"
|
||||
>
|
||||
下一页
|
||||
|
||||
@@ -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<UserInfo | null>(null);
|
||||
const [keyCount, setKeyCount] = useState(0);
|
||||
const [summary, setSummary] = useState<UsageSummary | null>(null);
|
||||
const [today, setToday] = useState<UsageStats | null>(null);
|
||||
const [month, setMonth] = useState<UsageStats | null>(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 (
|
||||
<div>
|
||||
<h2 className="text-xl font-bold text-gray-900 dark:text-white mb-4">控制台</h2>
|
||||
<div className="grid grid-cols-4 gap-4">
|
||||
<div className="bg-superdream-panel rounded-xl p-4">
|
||||
<p className="text-gray-600 dark:text-gray-400 text-sm">余额</p>
|
||||
<p className="text-2xl font-bold text-gray-900 dark:text-white mt-1">¥{balance}</p>
|
||||
<p className="text-2xl font-bold text-gray-900 dark:text-white mt-1">
|
||||
${(user?.balance ?? 0).toFixed(4)}
|
||||
</p>
|
||||
</div>
|
||||
<div className="bg-superdream-panel rounded-xl p-4">
|
||||
<p className="text-gray-600 dark:text-gray-400 text-sm">活跃 Key</p>
|
||||
<p className="text-gray-600 dark:text-gray-400 text-sm">API Key 数量</p>
|
||||
<p className="text-2xl font-bold text-gray-900 dark:text-white mt-1">{keyCount}</p>
|
||||
</div>
|
||||
<div className="bg-superdream-panel rounded-xl p-4">
|
||||
<p className="text-gray-600 dark:text-gray-400 text-sm">今日调用</p>
|
||||
<p className="text-gray-600 dark:text-gray-400 text-sm">今日 Tokens</p>
|
||||
<p className="text-2xl font-bold text-gray-900 dark:text-white mt-1">
|
||||
{summary ? `${summary.today_tokens.toLocaleString()} tokens` : "0"}
|
||||
{todayTokens.toLocaleString()}
|
||||
</p>
|
||||
</div>
|
||||
<div className="bg-superdream-panel rounded-xl p-4">
|
||||
<p className="text-gray-600 dark:text-gray-400 text-sm">今日消耗</p>
|
||||
<p className="text-2xl font-bold text-gray-900 dark:text-white mt-1">¥{summary?.today_cost ?? "0"}</p>
|
||||
<p className="text-2xl font-bold text-gray-900 dark:text-white mt-1">
|
||||
${todayCost.toFixed(4)}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
{month && (
|
||||
<div className="mt-6 bg-superdream-panel rounded-xl p-4">
|
||||
<h3 className="text-sm font-medium text-gray-600 dark:text-gray-400 mb-3">本月累计</h3>
|
||||
<div className="grid grid-cols-3 gap-4 text-sm">
|
||||
<div>
|
||||
<p className="text-gray-500">请求数</p>
|
||||
<p className="text-lg font-semibold text-gray-900 dark:text-white">
|
||||
{month.total_requests?.toLocaleString() ?? 0}
|
||||
</p>
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-gray-500">Tokens</p>
|
||||
<p className="text-lg font-semibold text-gray-900 dark:text-white">
|
||||
{(month.total_input_tokens + month.total_output_tokens).toLocaleString()}
|
||||
</p>
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-gray-500">消耗</p>
|
||||
<p className="text-lg font-semibold text-gray-900 dark:text-white">
|
||||
${month.total_cost?.toFixed(4) ?? "0"}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -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<UsageSummary | null>(null);
|
||||
const [daily, setDaily] = useState<DailyUsage[]>([]);
|
||||
const [byModel, setByModel] = useState<ModelUsage[]>([]);
|
||||
const [today, setToday] = useState<UsageStats | null>(null);
|
||||
const [month, setMonth] = useState<UsageStats | null>(null);
|
||||
const [trend, setTrend] = useState<DashboardTrendPoint[]>([]);
|
||||
const [models, setModels] = useState<DashboardModelStat[]>([]);
|
||||
|
||||
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 (
|
||||
<div>
|
||||
<h2 className="text-xl font-bold text-gray-900 dark:text-white mb-4">用量统计</h2>
|
||||
|
||||
{/* Summary cards */}
|
||||
<div className="grid grid-cols-4 gap-4 mb-6">
|
||||
<Card label="今日 Tokens" value={summary?.today_tokens?.toLocaleString() ?? "0"} />
|
||||
<Card label="今日消耗" value={`¥${summary?.today_cost ?? "0"}`} />
|
||||
<Card label="本月 Tokens" value={summary?.month_tokens?.toLocaleString() ?? "0"} />
|
||||
<Card label="本月消耗" value={`¥${summary?.month_cost ?? "0"}`} />
|
||||
<Card label="今日 Tokens" value={todayTokens.toLocaleString()} />
|
||||
<Card label="今日消耗" value={`$${(today?.total_cost ?? 0).toFixed(4)}`} />
|
||||
<Card label="本月 Tokens" value={monthTokens.toLocaleString()} />
|
||||
<Card label="本月消耗" value={`$${(month?.total_cost ?? 0).toFixed(4)}`} />
|
||||
</div>
|
||||
|
||||
{/* Daily chart */}
|
||||
<div className="bg-superdream-panel rounded-xl p-4 mb-6">
|
||||
<h3 className="text-sm font-medium text-gray-600 dark:text-gray-400 mb-3">每日用量(最近 30 天)</h3>
|
||||
{daily.length === 0 ? (
|
||||
<h3 className="text-sm font-medium text-gray-600 dark:text-gray-400 mb-3">每日用量</h3>
|
||||
{trend.length === 0 ? (
|
||||
<p className="text-gray-500 text-sm py-8 text-center">暂无数据</p>
|
||||
) : (
|
||||
<ResponsiveContainer width="100%" height={260}>
|
||||
<LineChart data={daily}>
|
||||
<LineChart data={trend}>
|
||||
<CartesianGrid strokeDasharray="3 3" stroke="#333" />
|
||||
<XAxis
|
||||
dataKey="date"
|
||||
@@ -52,40 +55,29 @@ export default function Usage() {
|
||||
labelStyle={{ color: "#fff" }}
|
||||
itemStyle={{ color: "#a78bfa" }}
|
||||
/>
|
||||
<Line
|
||||
type="monotone"
|
||||
dataKey="total_tokens"
|
||||
name="Tokens"
|
||||
stroke="#8B5CF6"
|
||||
strokeWidth={2}
|
||||
dot={false}
|
||||
/>
|
||||
<Line type="monotone" dataKey="requests" name="请求数" stroke="#8B5CF6" strokeWidth={2} dot={false} />
|
||||
</LineChart>
|
||||
</ResponsiveContainer>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* By model chart */}
|
||||
<div className="bg-superdream-panel rounded-xl p-4">
|
||||
<h3 className="text-sm font-medium text-gray-600 dark:text-gray-400 mb-3">按模型统计</h3>
|
||||
{byModel.length === 0 ? (
|
||||
{models.length === 0 ? (
|
||||
<p className="text-gray-500 text-sm py-8 text-center">暂无数据</p>
|
||||
) : (
|
||||
<ResponsiveContainer width="100%" height={260}>
|
||||
<BarChart data={byModel}>
|
||||
<BarChart data={models}>
|
||||
<CartesianGrid strokeDasharray="3 3" stroke="#333" />
|
||||
<XAxis
|
||||
dataKey="model"
|
||||
tick={{ fill: "#9ca3af", fontSize: 12 }}
|
||||
/>
|
||||
<XAxis dataKey="model" tick={{ fill: "#9ca3af", fontSize: 12 }} />
|
||||
<YAxis tick={{ fill: "#9ca3af", fontSize: 12 }} />
|
||||
<Tooltip
|
||||
contentStyle={{ background: "#1a1a2e", border: "1px solid #333", borderRadius: 8 }}
|
||||
labelStyle={{ color: "#fff" }}
|
||||
itemStyle={{ color: "#a78bfa" }}
|
||||
/>
|
||||
<Bar dataKey="total_tokens" name="Tokens" radius={[4, 4, 0, 0]}>
|
||||
{byModel.map((_, i) => (
|
||||
<Bar dataKey="requests" name="请求数" radius={[4, 4, 0, 0]}>
|
||||
{models.map((_, i) => (
|
||||
<Cell key={i} fill={COLORS[i % COLORS.length]} />
|
||||
))}
|
||||
</Bar>
|
||||
|
||||
@@ -1,138 +1,26 @@
|
||||
import { useState, useEffect } from "react";
|
||||
import { walletService, TransactionInfo } from "../../services/walletService";
|
||||
|
||||
const TYPE_LABELS: Record<string, string> = {
|
||||
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<TransactionInfo[]>([]);
|
||||
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<UserInfo | null>(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 (
|
||||
<div>
|
||||
<h2 className="text-xl font-bold text-gray-900 dark:text-white mb-4">钱包</h2>
|
||||
|
||||
{/* Balance */}
|
||||
<div className="bg-superdream-panel rounded-xl p-6 mb-4">
|
||||
<p className="text-gray-600 dark:text-gray-400 text-sm">当前余额</p>
|
||||
<p className="text-3xl font-bold text-gray-900 dark:text-white mt-1">¥{balance}</p>
|
||||
<p className="text-gray-600 dark:text-gray-400 text-sm">当前余额 (USD)</p>
|
||||
<p className="text-3xl font-bold text-gray-900 dark:text-white mt-1">
|
||||
${(user?.balance ?? 0).toFixed(4)}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Redeem */}
|
||||
<div className="bg-superdream-panel rounded-xl p-4 mb-4">
|
||||
<label className="block text-sm text-gray-600 dark:text-gray-400 mb-2">兑换码充值</label>
|
||||
|
||||
{error && (
|
||||
<div className="mb-3 p-3 rounded-lg bg-red-500/10 border border-red-500/30 text-red-400 text-sm">
|
||||
{error}
|
||||
</div>
|
||||
)}
|
||||
{success && (
|
||||
<div className="mb-3 p-3 rounded-lg bg-green-500/10 border border-green-500/30 text-green-400 text-sm">
|
||||
{success}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="flex gap-3">
|
||||
<input
|
||||
type="text"
|
||||
value={code}
|
||||
onChange={(e) => 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"
|
||||
/>
|
||||
<button
|
||||
onClick={handleRedeem}
|
||||
disabled={loading || !code.trim()}
|
||||
className="px-4 py-2 rounded-lg bg-superdream-primary text-gray-900 dark:text-white text-sm font-medium hover:bg-purple-600 transition disabled:opacity-50"
|
||||
>
|
||||
{loading ? "兑换中..." : "兑换"}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Transactions */}
|
||||
<div className="bg-superdream-panel rounded-xl overflow-hidden">
|
||||
<h3 className="text-sm font-medium text-gray-600 dark:text-gray-400 px-4 py-3 border-b border-gray-200 dark:border-gray-800">
|
||||
交易记录
|
||||
</h3>
|
||||
<table className="w-full text-sm">
|
||||
<thead>
|
||||
<tr className="text-gray-600 dark:text-gray-400 border-b border-gray-200 dark:border-gray-800">
|
||||
<th className="text-left px-4 py-3 font-medium">类型</th>
|
||||
<th className="text-left px-4 py-3 font-medium">金额</th>
|
||||
<th className="text-left px-4 py-3 font-medium">余额</th>
|
||||
<th className="text-left px-4 py-3 font-medium">备注</th>
|
||||
<th className="text-left px-4 py-3 font-medium">时间</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{transactions.length === 0 ? (
|
||||
<tr>
|
||||
<td colSpan={5} className="px-4 py-8 text-center text-gray-500">
|
||||
暂无交易记录
|
||||
</td>
|
||||
</tr>
|
||||
) : (
|
||||
transactions.map((txn) => (
|
||||
<tr key={txn.id} className="border-b border-gray-200/50 dark:border-gray-800/50 hover:bg-gray-200/30 dark:hover:bg-gray-800/30">
|
||||
<td className="px-4 py-3 text-gray-900 dark:text-white">
|
||||
{TYPE_LABELS[txn.type] || txn.type}
|
||||
</td>
|
||||
<td className={`px-4 py-3 font-medium ${txn.type === "consume" ? "text-red-400" : "text-green-400"}`}>
|
||||
{txn.type === "consume" ? "-" : "+"}¥{txn.amount}
|
||||
</td>
|
||||
<td className="px-4 py-3 text-gray-700 dark:text-gray-300">¥{txn.balance_after}</td>
|
||||
<td className="px-4 py-3 text-gray-600 dark:text-gray-400">{txn.reference_id || "-"}</td>
|
||||
<td className="px-4 py-3 text-gray-600 dark:text-gray-400">
|
||||
{new Date(txn.created_at).toLocaleString()}
|
||||
</td>
|
||||
</tr>
|
||||
))
|
||||
)}
|
||||
</tbody>
|
||||
</table>
|
||||
<div className="bg-superdream-panel rounded-xl p-4 text-sm text-gray-600 dark:text-gray-400">
|
||||
兑换码充值和交易记录暂未对接 sub2api,后续接入 <code>/api/v1/redeem</code> 后恢复。
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -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 = {
|
||||
|
||||
@@ -19,7 +19,7 @@ async function request<T>(
|
||||
});
|
||||
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: <T>(endpoint: string, body: unknown) =>
|
||||
request<T>(endpoint, {
|
||||
method: "PUT",
|
||||
body: JSON.stringify(body),
|
||||
}),
|
||||
delete: <T>(endpoint: string) =>
|
||||
request<T>(endpoint, { method: "DELETE" }),
|
||||
};
|
||||
@@ -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<T> {
|
||||
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<ApiKeyInfo[]>("/keys"),
|
||||
create: (name: string) => httpClient.post<ApiKeyCreated>("/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<Paginated<ApiKey>>(`/keys${qs ? `?${qs}` : ""}`);
|
||||
},
|
||||
|
||||
get: (keyId: number) => httpClient.get<ApiKey>(`/keys/${keyId}`),
|
||||
|
||||
create: (payload: CreateKeyPayload) => httpClient.post<ApiKey>("/keys", payload),
|
||||
|
||||
update: (keyId: number, payload: UpdateKeyPayload) =>
|
||||
httpClient.put<ApiKey>(`/keys/${keyId}`, payload),
|
||||
|
||||
remove: (keyId: number) => httpClient.delete<{ message: string }>(`/keys/${keyId}`),
|
||||
|
||||
availableGroups: () => httpClient.get<Group[]>("/keys/meta/available-groups"),
|
||||
};
|
||||
@@ -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<UsageSummary>("/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<DailyUsage[]>(`/usage/daily${qs ? `?${qs}` : ""}`);
|
||||
},
|
||||
|
||||
byModel: () => httpClient.get<ModelUsage[]>("/usage/by-model"),
|
||||
|
||||
byKey: () => httpClient.get<KeyUsage[]>("/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<UsageLogItem[]>(`/usage/logs${qs ? `?${qs}` : ""}`);
|
||||
return httpClient.get<Paginated<UsageLog>>(`/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<UsageStats>(`/usage/stats${qs ? `?${qs}` : ""}`);
|
||||
},
|
||||
|
||||
dashboardStats: () => httpClient.get<DashboardStats>("/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<string, UsageStats> }>("/usage/dashboard/api-keys-usage", { api_key_ids }),
|
||||
};
|
||||
@@ -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<BalanceInfo>("/wallet/balance"),
|
||||
redeem: (code: string) => httpClient.post<TransactionInfo>("/wallet/redeem", { code }),
|
||||
transactions: (page = 1, size = 20) =>
|
||||
httpClient.get<TransactionInfo[]>(`/wallet/transactions?page=${page}&size=${size}`),
|
||||
};
|
||||
@@ -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"}
|
||||
{"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"}
|
||||
@@ -7,5 +7,6 @@ aiofiles>=23.2.0
|
||||
httpx>=0.25.0
|
||||
sqlalchemy[asyncio]>=2.0.23
|
||||
aiomysql>=0.2.0
|
||||
cryptography>=41.0.0
|
||||
PyJWT>=2.8.0
|
||||
bcrypt>=4.1.0
|
||||
Reference in New Issue
Block a user