Add complete ai.card Rust implementation
- Implement complete Rust API server with axum framework - Add database abstraction supporting PostgreSQL and SQLite - Implement comprehensive gacha system with probability calculations - Add JWT authentication with atproto DID integration - Create card master data system with rarities (Normal, Rare, SuperRare, Kira, Unique) - Implement draw history tracking and collection management - Add API endpoints for authentication, card drawing, and collection viewing - Include database migrations for both PostgreSQL and SQLite - Maintain full compatibility with Python API implementation - Add comprehensive documentation and development guide 🤖 Generated with [Claude Code](https://claude.ai/code) Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
1
python/api/app/routes/__init__.py
Normal file
1
python/api/app/routes/__init__.py
Normal file
@ -0,0 +1 @@
|
||||
# Routes Package
|
133
python/api/app/routes/auth.py
Normal file
133
python/api/app/routes/auth.py
Normal file
@ -0,0 +1,133 @@
|
||||
"""Authentication routes"""
|
||||
from datetime import timedelta
|
||||
from typing import Optional
|
||||
from fastapi import APIRouter, HTTPException, Depends, Response
|
||||
from fastapi.responses import JSONResponse
|
||||
from pydantic import BaseModel
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.auth.dependencies import (
|
||||
AtprotoAuth,
|
||||
create_access_token,
|
||||
require_user,
|
||||
AuthUser,
|
||||
ACCESS_TOKEN_EXPIRE_MINUTES
|
||||
)
|
||||
from app.db.base import get_session
|
||||
from app.repositories.user import UserRepository
|
||||
# from app.services.atproto import AtprotoService
|
||||
|
||||
|
||||
router = APIRouter(prefix="/auth", tags=["auth"])
|
||||
|
||||
|
||||
class LoginRequest(BaseModel):
|
||||
"""Login request model"""
|
||||
identifier: str # Handle or DID
|
||||
password: str # App password
|
||||
|
||||
|
||||
class LoginResponse(BaseModel):
|
||||
"""Login response model"""
|
||||
access_token: str
|
||||
token_type: str = "bearer"
|
||||
did: str
|
||||
handle: str
|
||||
|
||||
|
||||
class VerifyResponse(BaseModel):
|
||||
"""Verify response model"""
|
||||
did: str
|
||||
handle: str
|
||||
valid: bool = True
|
||||
|
||||
|
||||
@router.post("/login", response_model=LoginResponse)
|
||||
async def login(
|
||||
request: LoginRequest,
|
||||
response: Response,
|
||||
db: AsyncSession = Depends(get_session)
|
||||
):
|
||||
"""
|
||||
Login with atproto credentials
|
||||
|
||||
- **identifier**: atproto handle or DID
|
||||
- **password**: App password (not main password)
|
||||
"""
|
||||
auth = AtprotoAuth()
|
||||
|
||||
# Authenticate with atproto
|
||||
user = await auth.authenticate(request.identifier, request.password)
|
||||
if not user:
|
||||
raise HTTPException(
|
||||
status_code=401,
|
||||
detail="Invalid credentials"
|
||||
)
|
||||
|
||||
# Create or update user in database
|
||||
user_repo = UserRepository(db)
|
||||
await user_repo.get_or_create(did=user.did, handle=user.handle)
|
||||
await db.commit()
|
||||
|
||||
# Create access token
|
||||
access_token = create_access_token(
|
||||
data={"did": user.did, "handle": user.handle},
|
||||
expires_delta=timedelta(minutes=ACCESS_TOKEN_EXPIRE_MINUTES)
|
||||
)
|
||||
|
||||
# Set cookie for web clients
|
||||
response.set_cookie(
|
||||
key="ai_card_token",
|
||||
value=access_token,
|
||||
httponly=True,
|
||||
secure=True,
|
||||
samesite="lax",
|
||||
max_age=ACCESS_TOKEN_EXPIRE_MINUTES * 60
|
||||
)
|
||||
|
||||
return LoginResponse(
|
||||
access_token=access_token,
|
||||
did=user.did,
|
||||
handle=user.handle or ""
|
||||
)
|
||||
|
||||
|
||||
@router.post("/logout")
|
||||
async def logout(response: Response):
|
||||
"""Logout and clear session"""
|
||||
response.delete_cookie("ai_card_token")
|
||||
return {"message": "Logged out successfully"}
|
||||
|
||||
|
||||
@router.get("/verify", response_model=VerifyResponse)
|
||||
async def verify_session(
|
||||
current_user: AuthUser = Depends(require_user)
|
||||
):
|
||||
"""Verify current session is valid"""
|
||||
return VerifyResponse(
|
||||
did=current_user.did,
|
||||
handle=current_user.handle or "",
|
||||
valid=True
|
||||
)
|
||||
|
||||
|
||||
@router.post("/verify-did")
|
||||
async def verify_did(did: str, handle: Optional[str] = None):
|
||||
"""
|
||||
Verify DID is valid (public endpoint)
|
||||
|
||||
- **did**: DID to verify
|
||||
- **handle**: Optional handle to cross-check
|
||||
"""
|
||||
service = AtprotoService()
|
||||
is_valid = await service.verify_did(did, handle)
|
||||
|
||||
return {
|
||||
"did": did,
|
||||
"handle": handle,
|
||||
"valid": is_valid
|
||||
}
|
||||
|
||||
|
||||
# Import Optional here
|
||||
from typing import Optional
|
173
python/api/app/routes/cards.py
Normal file
173
python/api/app/routes/cards.py
Normal file
@ -0,0 +1,173 @@
|
||||
"""Card-related API routes"""
|
||||
from typing import List, Dict
|
||||
from fastapi import APIRouter, HTTPException, Depends
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.models.card import Card, CardDraw, CardDrawResult
|
||||
from app.services.gacha import GachaService
|
||||
from app.services.card_master import card_master_service
|
||||
from app.repositories.user import UserRepository
|
||||
from app.repositories.card import CardRepository, UniqueCardRepository
|
||||
from app.db.base import get_session
|
||||
|
||||
router = APIRouter(prefix="/cards", tags=["cards"])
|
||||
|
||||
|
||||
@router.post("/draw", response_model=CardDrawResult)
|
||||
async def draw_card(
|
||||
draw_request: CardDraw,
|
||||
db: AsyncSession = Depends(get_session)
|
||||
):
|
||||
"""
|
||||
カードを抽選する
|
||||
|
||||
- **user_did**: ユーザーのatproto DID
|
||||
- **is_paid**: 課金ガチャかどうか
|
||||
"""
|
||||
try:
|
||||
gacha_service = GachaService(db)
|
||||
card, is_unique = await gacha_service.draw_card(
|
||||
user_did=draw_request.user_did,
|
||||
is_paid=draw_request.is_paid
|
||||
)
|
||||
|
||||
# 演出タイプを決定
|
||||
animation_type = "normal"
|
||||
if is_unique:
|
||||
animation_type = "unique"
|
||||
elif card.status.value == "kira":
|
||||
animation_type = "kira"
|
||||
elif card.status.value in ["super_rare", "rare"]:
|
||||
animation_type = "rare"
|
||||
|
||||
# 新規取得かチェック
|
||||
user_repo = UserRepository(db)
|
||||
card_repo = CardRepository(db)
|
||||
user = await user_repo.get_by_did(draw_request.user_did)
|
||||
count = await card_repo.count_user_cards(user.id, card.id)
|
||||
is_new = count == 1 # 今引いたカードが初めてなら1枚
|
||||
|
||||
result = CardDrawResult(
|
||||
card=card,
|
||||
is_new=is_new,
|
||||
animation_type=animation_type
|
||||
)
|
||||
|
||||
await db.commit()
|
||||
return result
|
||||
|
||||
except Exception as e:
|
||||
await db.rollback()
|
||||
raise HTTPException(status_code=500, detail=str(e))
|
||||
|
||||
|
||||
@router.get("/user/{user_did}", response_model=List[Card])
|
||||
async def get_user_cards(
|
||||
user_did: str,
|
||||
skip: int = 0,
|
||||
limit: int = 100,
|
||||
db: AsyncSession = Depends(get_session)
|
||||
):
|
||||
"""
|
||||
ユーザーの所有カード一覧を取得
|
||||
|
||||
- **user_did**: ユーザーのatproto DID
|
||||
"""
|
||||
user_repo = UserRepository(db)
|
||||
card_repo = CardRepository(db)
|
||||
|
||||
user = await user_repo.get_by_did(user_did)
|
||||
if not user:
|
||||
raise HTTPException(status_code=404, detail="User not found")
|
||||
|
||||
user_cards = await card_repo.get_user_cards(user.id, skip=skip, limit=limit)
|
||||
|
||||
# Convert to API model
|
||||
cards = []
|
||||
for uc in user_cards:
|
||||
card = Card(
|
||||
id=uc.card_id,
|
||||
cp=uc.cp,
|
||||
status=uc.status,
|
||||
skill=uc.skill,
|
||||
owner_did=user_did,
|
||||
obtained_at=uc.obtained_at,
|
||||
is_unique=uc.is_unique,
|
||||
unique_id=str(uc.unique_id) if uc.unique_id else None
|
||||
)
|
||||
cards.append(card)
|
||||
|
||||
return cards
|
||||
|
||||
|
||||
@router.get("/unique")
|
||||
async def get_unique_cards(db: AsyncSession = Depends(get_session)):
|
||||
"""
|
||||
全てのuniqueカード一覧を取得(所有者情報付き)
|
||||
"""
|
||||
unique_repo = UniqueCardRepository(db)
|
||||
unique_cards = await unique_repo.get_all_unique_cards()
|
||||
|
||||
return [
|
||||
{
|
||||
"card_id": uc.card_id,
|
||||
"owner_did": uc.owner_did,
|
||||
"obtained_at": uc.obtained_at,
|
||||
"unique_id": str(uc.unique_id)
|
||||
}
|
||||
for uc in unique_cards
|
||||
]
|
||||
|
||||
|
||||
@router.get("/stats")
|
||||
async def get_gacha_stats(db: AsyncSession = Depends(get_session)):
|
||||
"""
|
||||
ガチャ統計情報を取得
|
||||
"""
|
||||
try:
|
||||
card_repo = CardRepository(db)
|
||||
|
||||
# 総ガチャ実行数
|
||||
total_draws = await card_repo.get_total_card_count()
|
||||
|
||||
# レアリティ別カード数
|
||||
cards_by_rarity = await card_repo.get_cards_by_rarity()
|
||||
|
||||
# 成功率計算(簡易版)
|
||||
success_rates = {}
|
||||
if total_draws > 0:
|
||||
for rarity, count in cards_by_rarity.items():
|
||||
success_rates[rarity] = count / total_draws
|
||||
|
||||
# 最近の活動(最新10件)
|
||||
recent_cards = await card_repo.get_recent_cards(limit=10)
|
||||
recent_activity = []
|
||||
for card_data in recent_cards:
|
||||
recent_activity.append({
|
||||
"timestamp": card_data.get("obtained_at", "").isoformat() if card_data.get("obtained_at") else "",
|
||||
"user_did": card_data.get("owner_did", "unknown"),
|
||||
"card_name": f"Card #{card_data.get('card_id', 0)}",
|
||||
"rarity": card_data.get("status", "common")
|
||||
})
|
||||
|
||||
return {
|
||||
"total_draws": total_draws,
|
||||
"cards_by_rarity": cards_by_rarity,
|
||||
"success_rates": success_rates,
|
||||
"recent_activity": recent_activity
|
||||
}
|
||||
|
||||
except Exception as e:
|
||||
raise HTTPException(status_code=500, detail=f"Statistics error: {str(e)}")
|
||||
|
||||
|
||||
@router.get("/master", response_model=List[Dict])
|
||||
async def get_card_master_data():
|
||||
"""
|
||||
全カードマスターデータを取得(ai.jsonから)
|
||||
"""
|
||||
try:
|
||||
cards = card_master_service.get_all_cards()
|
||||
return cards
|
||||
except Exception as e:
|
||||
raise HTTPException(status_code=500, detail=f"Failed to get card master data: {str(e)}")
|
152
python/api/app/routes/sync.py
Normal file
152
python/api/app/routes/sync.py
Normal file
@ -0,0 +1,152 @@
|
||||
"""Synchronization routes for atproto"""
|
||||
from typing import Optional
|
||||
from fastapi import APIRouter, HTTPException, Depends
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
from pydantic import BaseModel
|
||||
|
||||
from app.auth.dependencies import require_user, AuthUser
|
||||
from app.db.base import get_session
|
||||
from app.services.card_sync import CardSyncService
|
||||
from app.repositories.user import UserRepository
|
||||
|
||||
|
||||
router = APIRouter(prefix="/sync", tags=["sync"])
|
||||
|
||||
|
||||
class SyncRequest(BaseModel):
|
||||
"""Sync request model"""
|
||||
atproto_session: str # Session string from atproto login
|
||||
|
||||
|
||||
class SyncResponse(BaseModel):
|
||||
"""Sync response model"""
|
||||
synced_to_pds: int = 0
|
||||
imported_from_pds: int = 0
|
||||
message: str
|
||||
|
||||
|
||||
@router.post("/cards", response_model=SyncResponse)
|
||||
async def sync_cards(
|
||||
request: SyncRequest,
|
||||
current_user: AuthUser = Depends(require_user),
|
||||
db: AsyncSession = Depends(get_session)
|
||||
):
|
||||
"""
|
||||
Sync cards between database and atproto PDS
|
||||
|
||||
- **atproto_session**: Session string from atproto login
|
||||
"""
|
||||
try:
|
||||
# Get user from database
|
||||
user_repo = UserRepository(db)
|
||||
user = await user_repo.get_by_did(current_user.did)
|
||||
if not user:
|
||||
raise HTTPException(status_code=404, detail="User not found")
|
||||
|
||||
# Create sync service
|
||||
sync_service = CardSyncService(db, request.atproto_session)
|
||||
|
||||
# Import from PDS first
|
||||
imported = await sync_service.import_cards_from_pds(current_user.did)
|
||||
|
||||
# Then sync all cards to PDS
|
||||
synced = await sync_service.sync_all_user_cards(user.id, current_user.did)
|
||||
|
||||
await db.commit()
|
||||
|
||||
return SyncResponse(
|
||||
synced_to_pds=synced,
|
||||
imported_from_pds=imported,
|
||||
message=f"Successfully synced {synced} cards to PDS and imported {imported} cards from PDS"
|
||||
)
|
||||
|
||||
except Exception as e:
|
||||
await db.rollback()
|
||||
raise HTTPException(status_code=500, detail=str(e))
|
||||
|
||||
|
||||
@router.post("/export")
|
||||
async def export_to_pds(
|
||||
request: SyncRequest,
|
||||
current_user: AuthUser = Depends(require_user),
|
||||
db: AsyncSession = Depends(get_session)
|
||||
):
|
||||
"""
|
||||
Export all cards to atproto PDS
|
||||
|
||||
- **atproto_session**: Session string from atproto login
|
||||
"""
|
||||
try:
|
||||
user_repo = UserRepository(db)
|
||||
user = await user_repo.get_by_did(current_user.did)
|
||||
if not user:
|
||||
raise HTTPException(status_code=404, detail="User not found")
|
||||
|
||||
sync_service = CardSyncService(db, request.atproto_session)
|
||||
synced = await sync_service.sync_all_user_cards(user.id, current_user.did)
|
||||
|
||||
return {
|
||||
"exported": synced,
|
||||
"message": f"Successfully exported {synced} cards to PDS"
|
||||
}
|
||||
|
||||
except Exception as e:
|
||||
raise HTTPException(status_code=500, detail=str(e))
|
||||
|
||||
|
||||
@router.post("/import")
|
||||
async def import_from_pds(
|
||||
request: SyncRequest,
|
||||
current_user: AuthUser = Depends(require_user),
|
||||
db: AsyncSession = Depends(get_session)
|
||||
):
|
||||
"""
|
||||
Import cards from atproto PDS
|
||||
|
||||
- **atproto_session**: Session string from atproto login
|
||||
"""
|
||||
try:
|
||||
sync_service = CardSyncService(db, request.atproto_session)
|
||||
imported = await sync_service.import_cards_from_pds(current_user.did)
|
||||
|
||||
await db.commit()
|
||||
|
||||
return {
|
||||
"imported": imported,
|
||||
"message": f"Successfully imported {imported} cards from PDS"
|
||||
}
|
||||
|
||||
except Exception as e:
|
||||
await db.rollback()
|
||||
raise HTTPException(status_code=500, detail=str(e))
|
||||
|
||||
|
||||
@router.get("/verify/{card_id}")
|
||||
async def verify_card_ownership(
|
||||
card_id: int,
|
||||
unique_id: Optional[str] = None,
|
||||
current_user: AuthUser = Depends(require_user),
|
||||
db: AsyncSession = Depends(get_session)
|
||||
):
|
||||
"""
|
||||
Verify user owns a specific card
|
||||
|
||||
- **card_id**: Card type ID (0-15)
|
||||
- **unique_id**: Unique ID for unique cards
|
||||
"""
|
||||
sync_service = CardSyncService(db)
|
||||
owns_card = await sync_service.verify_card_ownership(
|
||||
current_user.did,
|
||||
card_id,
|
||||
unique_id
|
||||
)
|
||||
|
||||
return {
|
||||
"card_id": card_id,
|
||||
"unique_id": unique_id,
|
||||
"owned": owns_card
|
||||
}
|
||||
|
||||
|
||||
# Import Optional
|
||||
from typing import Optional
|
Reference in New Issue
Block a user