1
0

add claude

This commit is contained in:
2025-06-01 21:39:53 +09:00
parent 3459231bba
commit 4246f718ef
80 changed files with 7249 additions and 0 deletions

32
api/.env.example Normal file
View File

@ -0,0 +1,32 @@
# Application
APP_NAME=ai.card
APP_VERSION=0.1.0
DEBUG=false
# Database (Local PostgreSQL)
DATABASE_URL=postgresql+asyncpg://postgres:postgres@localhost:5432/aicard
# Database (Supabase - optional)
DATABASE_URL_SUPABASE=postgresql+asyncpg://postgres.xxxxxxxxxxxx:password@aws-0-region.pooler.supabase.com:5432/postgres
USE_SUPABASE=false
# atproto (optional)
ATPROTO_PDS_URL=https://bsky.social
ATPROTO_HANDLE=your.handle
ATPROTO_PASSWORD=your-app-password
# Card probabilities (in percentage)
PROB_NORMAL=99.789
PROB_RARE=0.1
PROB_SUPER_RARE=0.01
PROB_KIRA=0.1
PROB_UNIQUE=0.0001
# Unique card settings
MAX_UNIQUE_CARDS=1000
# CORS
CORS_ORIGINS=["http://localhost:3000", "https://card.syui.ai"]
# Security
SECRET_KEY=your-secret-key-change-this-in-production

24
api/Dockerfile Normal file
View File

@ -0,0 +1,24 @@
FROM python:3.11-slim
WORKDIR /app
# Install system dependencies
RUN apt-get update && apt-get install -y \
gcc \
postgresql-client \
&& rm -rf /var/lib/apt/lists/*
# Copy requirements first for better caching
COPY requirements.txt .
RUN pip install --no-cache-dir -r requirements.txt
# Copy application
COPY . .
# Create non-root user
RUN useradd -m -u 1000 appuser && chown -R appuser:appuser /app
USER appuser
EXPOSE 8000
CMD ["uvicorn", "app.main:app", "--host", "0.0.0.0", "--port", "8000"]

47
api/alembic.ini Normal file
View File

@ -0,0 +1,47 @@
# Alembic Configuration
[alembic]
script_location = alembic
prepend_sys_path = .
version_path_separator = os
sqlalchemy.url = postgresql+asyncpg://postgres:postgres@localhost:5432/aicard
[post_write_hooks]
hooks = black
black.type = console_scripts
black.entrypoint = black
black.options = -l 88
[loggers]
keys = root,sqlalchemy,alembic
[handlers]
keys = console
[formatters]
keys = generic
[logger_root]
level = WARN
handlers = console
qualname =
[logger_sqlalchemy]
level = WARN
handlers =
qualname = sqlalchemy.engine
[logger_alembic]
level = INFO
handlers =
qualname = alembic
[handler_console]
class = StreamHandler
args = (sys.stderr,)
level = NOTSET
formatter = generic
[formatter_generic]
format = %(levelname)-5.5s [%(name)s] %(message)s
datefmt = %H:%M:%S

82
api/alembic/env.py Normal file
View File

@ -0,0 +1,82 @@
"""Alembic environment configuration"""
import asyncio
from logging.config import fileConfig
from sqlalchemy import pool
from sqlalchemy.engine import Connection
from sqlalchemy.ext.asyncio import async_engine_from_config
from alembic import context
import os
import sys
from pathlib import Path
# Add parent directory to path
sys.path.append(str(Path(__file__).parent.parent))
from app.db.base import Base
from app.db.models import * # Import all models
from app.core.config import settings
# Alembic Config object
config = context.config
# Interpret the config file for Python logging
if config.config_file_name is not None:
fileConfig(config.config_file_name)
# Model metadata
target_metadata = Base.metadata
# Override sqlalchemy.url with environment variable if present
if os.getenv("DATABASE_URL"):
config.set_main_option("sqlalchemy.url", os.getenv("DATABASE_URL"))
else:
config.set_main_option("sqlalchemy.url", settings.database_url)
def run_migrations_offline() -> None:
"""Run migrations in 'offline' mode."""
url = config.get_main_option("sqlalchemy.url")
context.configure(
url=url,
target_metadata=target_metadata,
literal_binds=True,
dialect_opts={"paramstyle": "named"},
)
with context.begin_transaction():
context.run_migrations()
def do_run_migrations(connection: Connection) -> None:
context.configure(connection=connection, target_metadata=target_metadata)
with context.begin_transaction():
context.run_migrations()
async def run_async_migrations() -> None:
"""Run migrations in 'online' mode with async engine."""
configuration = config.get_section(config.config_ini_section)
configuration["sqlalchemy.url"] = config.get_main_option("sqlalchemy.url")
connectable = async_engine_from_config(
configuration,
prefix="sqlalchemy.",
poolclass=pool.NullPool,
)
async with connectable.connect() as connection:
await connection.run_sync(do_run_migrations)
await connectable.dispose()
def run_migrations_online() -> None:
"""Run migrations in 'online' mode."""
asyncio.run(run_async_migrations())
if context.is_offline_mode():
run_migrations_offline()
else:
run_migrations_online()

View File

@ -0,0 +1,24 @@
"""${message}
Revision ID: ${up_revision}
Revises: ${down_revision | comma,n}
Create Date: ${create_date}
"""
from alembic import op
import sqlalchemy as sa
${imports if imports else ""}
# revision identifiers, used by Alembic.
revision = ${repr(up_revision)}
down_revision = ${repr(down_revision)}
branch_labels = ${repr(branch_labels)}
depends_on = ${repr(depends_on)}
def upgrade() -> None:
${upgrades if upgrades else "pass"}
def downgrade() -> None:
${downgrades if downgrades else "pass"}

1
api/app/__init__.py Normal file
View File

@ -0,0 +1 @@
# ai.card API Package

1
api/app/auth/__init__.py Normal file
View File

@ -0,0 +1 @@
# Auth Package

View File

@ -0,0 +1,154 @@
"""Authentication dependencies"""
from typing import Optional, Annotated
from fastapi import Depends, HTTPException, Header, Cookie
from fastapi.security import HTTPBearer, HTTPAuthorizationCredentials
from jose import JWTError, jwt
from datetime import datetime, timedelta
from app.services.atproto import AtprotoService
from app.core.config import settings
# Bearer token scheme
bearer_scheme = HTTPBearer(auto_error=False)
# JWT settings
SECRET_KEY = settings.secret_key if hasattr(settings, 'secret_key') else "your-secret-key"
ALGORITHM = "HS256"
ACCESS_TOKEN_EXPIRE_MINUTES = 60 * 24 # 24 hours
class AuthUser:
"""Authenticated user data"""
def __init__(self, did: str, handle: Optional[str] = None):
self.did = did
self.handle = handle
def create_access_token(data: dict, expires_delta: Optional[timedelta] = None):
"""Create JWT access token"""
to_encode = data.copy()
if expires_delta:
expire = datetime.utcnow() + expires_delta
else:
expire = datetime.utcnow() + timedelta(minutes=ACCESS_TOKEN_EXPIRE_MINUTES)
to_encode.update({"exp": expire})
encoded_jwt = jwt.encode(to_encode, SECRET_KEY, algorithm=ALGORITHM)
return encoded_jwt
async def verify_token(token: str) -> Optional[AuthUser]:
"""Verify JWT token and return user"""
try:
payload = jwt.decode(token, SECRET_KEY, algorithms=[ALGORITHM])
did: str = payload.get("did")
if did is None:
return None
handle: Optional[str] = payload.get("handle")
return AuthUser(did=did, handle=handle)
except JWTError:
return None
async def get_current_user(
credentials: Optional[HTTPAuthorizationCredentials] = Depends(bearer_scheme),
token_cookie: Optional[str] = Cookie(None, alias="ai_card_token")
) -> Optional[AuthUser]:
"""
Get current user from JWT token
Supports both Bearer token and cookie
"""
token = None
# Try Bearer token first
if credentials and credentials.credentials:
token = credentials.credentials
# Fall back to cookie
elif token_cookie:
token = token_cookie
if not token:
return None
user = await verify_token(token)
return user
async def require_user(
current_user: Optional[AuthUser] = Depends(get_current_user)
) -> AuthUser:
"""Require authenticated user"""
if not current_user:
raise HTTPException(
status_code=401,
detail="Not authenticated",
headers={"WWW-Authenticate": "Bearer"},
)
return current_user
async def get_optional_user(
current_user: Optional[AuthUser] = Depends(get_current_user)
) -> Optional[AuthUser]:
"""Get user if authenticated, None otherwise"""
return current_user
class AtprotoAuth:
"""atproto authentication handler"""
def __init__(self):
self.service = AtprotoService()
async def authenticate(self, identifier: str, password: str) -> Optional[AuthUser]:
"""
Authenticate user with atproto
Args:
identifier: Handle or DID
password: App password
Returns:
AuthUser if successful
"""
try:
# Login to atproto
session = await self.service.login(identifier, password)
# Get user info from session
# The session contains the DID
if self.service.client:
did = self.service.client.did
handle = self.service.client.handle
return AuthUser(did=did, handle=handle)
return None
except Exception:
return None
async def verify_did_ownership(self, did: str, session_string: str) -> bool:
"""
Verify user owns the DID by checking session
Args:
did: DID to verify
session_string: Session string from login
Returns:
True if session is valid for DID
"""
try:
self.service.restore_session(session_string)
if self.service.client and self.service.client.did == did:
return True
return False
except Exception:
return False

1
api/app/core/__init__.py Normal file
View File

@ -0,0 +1 @@
# Core Package

46
api/app/core/config.py Normal file
View File

@ -0,0 +1,46 @@
"""Application configuration"""
from typing import Optional
from pydantic_settings import BaseSettings
class Settings(BaseSettings):
# Application
app_name: str = "ai.card"
app_version: str = "0.1.0"
debug: bool = False
# API
api_v1_prefix: str = "/api/v1"
# Database
database_url: str = "postgresql+asyncpg://postgres:postgres@localhost:5432/aicard"
database_url_supabase: Optional[str] = None
use_supabase: bool = False
# atproto
atproto_pds_url: Optional[str] = None
atproto_handle: Optional[str] = None
atproto_password: Optional[str] = None
# Card probabilities (in percentage)
prob_normal: float = 99.789
prob_rare: float = 0.1
prob_super_rare: float = 0.01
prob_kira: float = 0.1
prob_unique: float = 0.0001
# Unique card settings
max_unique_cards: int = 1000 # Maximum number of unique cards
# CORS
cors_origins: list[str] = ["http://localhost:3000", "https://card.syui.ai"]
# Security
secret_key: str = "your-secret-key-change-this-in-production"
class Config:
env_file = ".env"
env_file_encoding = "utf-8"
settings = Settings()

1
api/app/db/__init__.py Normal file
View File

@ -0,0 +1 @@
# Database Package

40
api/app/db/base.py Normal file
View File

@ -0,0 +1,40 @@
"""Database base configuration"""
from sqlalchemy.ext.declarative import declarative_base
from sqlalchemy.ext.asyncio import AsyncSession, create_async_engine, async_sessionmaker
from app.core.config import settings
# Create base class for models
Base = declarative_base()
# Select database URL based on configuration
database_url = settings.database_url_supabase if settings.use_supabase else settings.database_url
# Create async engine
engine = create_async_engine(
database_url,
echo=settings.debug,
future=True,
pool_pre_ping=True, # Enable connection health checks
pool_size=5,
max_overflow=10
)
# Create async session factory
async_session = async_sessionmaker(
engine,
class_=AsyncSession,
expire_on_commit=False
)
async def get_db() -> AsyncSession:
"""Dependency to get database session"""
async with async_session() as session:
try:
yield session
await session.commit()
except Exception:
await session.rollback()
raise
finally:
await session.close()

121
api/app/db/models.py Normal file
View File

@ -0,0 +1,121 @@
"""Database models"""
from datetime import datetime
from sqlalchemy import (
Column, Integer, String, DateTime, Boolean,
Float, ForeignKey, UniqueConstraint, Index,
Enum as SQLEnum
)
from sqlalchemy.dialects.postgresql import UUID
from sqlalchemy.orm import relationship
import uuid
import enum
from app.db.base import Base
from app.models.card import CardRarity
class User(Base):
"""ユーザーモデル"""
__tablename__ = "users"
id = Column(Integer, primary_key=True, index=True)
did = Column(String, unique=True, nullable=False, index=True)
handle = Column(String, nullable=True)
created_at = Column(DateTime, default=datetime.utcnow)
updated_at = Column(DateTime, default=datetime.utcnow, onupdate=datetime.utcnow)
# Relationships
cards = relationship("UserCard", back_populates="owner")
draws = relationship("DrawHistory", back_populates="user")
class CardMaster(Base):
"""カードマスタデータ"""
__tablename__ = "card_master"
id = Column(Integer, primary_key=True) # 0-15
name = Column(String, nullable=False)
base_cp_min = Column(Integer, nullable=False)
base_cp_max = Column(Integer, nullable=False)
color = Column(String, nullable=False)
description = Column(String)
# Relationships
user_cards = relationship("UserCard", back_populates="card_info")
class UserCard(Base):
"""ユーザー所有カード"""
__tablename__ = "user_cards"
id = Column(Integer, primary_key=True, index=True)
user_id = Column(Integer, ForeignKey("users.id"), nullable=False)
card_id = Column(Integer, ForeignKey("card_master.id"), nullable=False)
cp = Column(Integer, nullable=False)
status = Column(SQLEnum(CardRarity), nullable=False)
skill = Column(String, nullable=True)
obtained_at = Column(DateTime, default=datetime.utcnow)
is_unique = Column(Boolean, default=False)
unique_id = Column(UUID(as_uuid=True), nullable=True, unique=True)
# Relationships
owner = relationship("User", back_populates="cards")
card_info = relationship("CardMaster", back_populates="user_cards")
# Indexes
__table_args__ = (
Index('idx_user_cards', 'user_id', 'card_id'),
)
class UniqueCardRegistry(Base):
"""uniqueカードのグローバルレジストリ"""
__tablename__ = "unique_card_registry"
id = Column(Integer, primary_key=True)
unique_id = Column(UUID(as_uuid=True), default=uuid.uuid4, unique=True, nullable=False)
card_id = Column(Integer, ForeignKey("card_master.id"), nullable=False)
owner_did = Column(String, ForeignKey("users.did"), nullable=False)
obtained_at = Column(DateTime, default=datetime.utcnow)
verse_skill_id = Column(String, nullable=True) # ai.verse連携用
# Unique constraint: 各card_idは1人のみ所有可能
__table_args__ = (
UniqueConstraint('card_id', name='unique_card_per_type'),
)
class DrawHistory(Base):
"""ガチャ履歴"""
__tablename__ = "draw_history"
id = Column(Integer, primary_key=True, index=True)
user_id = Column(Integer, ForeignKey("users.id"), nullable=False)
card_id = Column(Integer, nullable=False)
status = Column(SQLEnum(CardRarity), nullable=False)
cp = Column(Integer, nullable=False)
is_paid = Column(Boolean, default=False)
drawn_at = Column(DateTime, default=datetime.utcnow)
# Relationships
user = relationship("User", back_populates="draws")
# Indexes
__table_args__ = (
Index('idx_draw_history_user', 'user_id', 'drawn_at'),
)
class GachaPool(Base):
"""ガチャプール(ピックアップ管理)"""
__tablename__ = "gacha_pools"
id = Column(Integer, primary_key=True)
name = Column(String, nullable=False)
description = Column(String)
is_active = Column(Boolean, default=True)
start_at = Column(DateTime, nullable=False)
end_at = Column(DateTime, nullable=True)
pickup_card_ids = Column(String) # JSON array of card IDs
rate_up_multiplier = Column(Float, default=1.0)
created_at = Column(DateTime, default=datetime.utcnow)

54
api/app/main.py Normal file
View File

@ -0,0 +1,54 @@
"""FastAPI application entry point"""
from fastapi import FastAPI
from fastapi.middleware.cors import CORSMiddleware
from app.core.config import settings
from app.routes import cards, auth, sync
# Create FastAPI app
app = FastAPI(
title=settings.app_name,
version=settings.app_version,
docs_url="/docs",
redoc_url="/redoc",
)
# CORS middleware
app.add_middleware(
CORSMiddleware,
allow_origins=settings.cors_origins,
allow_credentials=True,
allow_methods=["*"],
allow_headers=["*"],
)
# Include routers
app.include_router(auth.router, prefix=settings.api_v1_prefix)
app.include_router(cards.router, prefix=settings.api_v1_prefix)
app.include_router(sync.router, prefix=settings.api_v1_prefix)
@app.get("/")
async def root():
"""Root endpoint"""
return {
"app": settings.app_name,
"version": settings.app_version,
"status": "running"
}
@app.get("/health")
async def health_check():
"""Health check endpoint"""
return {"status": "healthy"}
if __name__ == "__main__":
import uvicorn
uvicorn.run(
"app.main:app",
host="0.0.0.0",
port=8000,
reload=True
)

View File

@ -0,0 +1 @@
# Models Package

57
api/app/models/card.py Normal file
View File

@ -0,0 +1,57 @@
"""Card data models"""
from datetime import datetime
from enum import Enum
from typing import Optional
from pydantic import BaseModel, Field
class CardRarity(str, Enum):
"""カードのレアリティ"""
NORMAL = "normal"
RARE = "rare"
SUPER_RARE = "super_rare"
KIRA = "kira" # キラカード0.1%
UNIQUE = "unique" # uniqueカード0.0001%
class CardBase(BaseModel):
"""カードの基本情報"""
id: int = Field(..., ge=0, le=15, description="カード種類ID (0-15)")
cp: int = Field(..., ge=1, le=999, description="カードパワー")
status: CardRarity = Field(default=CardRarity.NORMAL, description="レアリティ")
skill: Optional[str] = Field(None, description="スキル情報")
class Card(CardBase):
"""所有カード情報"""
owner_did: str = Field(..., description="所有者のatproto DID")
obtained_at: datetime = Field(default_factory=datetime.utcnow, description="取得日時")
is_unique: bool = Field(default=False, description="uniqueカードフラグ")
unique_id: Optional[str] = Field(None, description="unique時のグローバルID")
class Config:
json_encoders = {
datetime: lambda v: v.isoformat()
}
class CardDraw(BaseModel):
"""カード抽選リクエスト"""
user_did: str = Field(..., description="ユーザーのDID")
is_paid: bool = Field(default=False, description="課金ガチャかどうか")
class CardDrawResult(BaseModel):
"""カード抽選結果"""
card: Card
is_new: bool = Field(..., description="新規取得かどうか")
animation_type: str = Field(..., description="演出タイプ")
class UniqueCardRegistry(BaseModel):
"""uniqueカードの登録情報"""
card_id: int
unique_id: str
owner_did: str
obtained_at: datetime
verse_skill_id: Optional[str] = None

View File

@ -0,0 +1 @@
# Repositories Package

View File

@ -0,0 +1,65 @@
"""Base repository class"""
from typing import Generic, Type, TypeVar, Optional, List
from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy import select, update, delete
from sqlalchemy.orm import selectinload
from app.db.base import Base
ModelType = TypeVar("ModelType", bound=Base)
class BaseRepository(Generic[ModelType]):
"""Base repository with common CRUD operations"""
def __init__(self, model: Type[ModelType], session: AsyncSession):
self.model = model
self.session = session
async def create(self, **kwargs) -> ModelType:
"""Create a new record"""
instance = self.model(**kwargs)
self.session.add(instance)
await self.session.flush()
return instance
async def get(self, id: int) -> Optional[ModelType]:
"""Get a record by ID"""
result = await self.session.execute(
select(self.model).where(self.model.id == id)
)
return result.scalar_one_or_none()
async def get_multi(
self,
skip: int = 0,
limit: int = 100,
**filters
) -> List[ModelType]:
"""Get multiple records with pagination"""
query = select(self.model)
# Apply filters
for key, value in filters.items():
if hasattr(self.model, key):
query = query.where(getattr(self.model, key) == value)
query = query.offset(skip).limit(limit)
result = await self.session.execute(query)
return result.scalars().all()
async def update(self, id: int, **kwargs) -> Optional[ModelType]:
"""Update a record"""
await self.session.execute(
update(self.model)
.where(self.model.id == id)
.values(**kwargs)
)
return await self.get(id)
async def delete(self, id: int) -> bool:
"""Delete a record"""
result = await self.session.execute(
delete(self.model).where(self.model.id == id)
)
return result.rowcount > 0

View File

@ -0,0 +1,136 @@
"""Card repository"""
from typing import List, Optional
from datetime import datetime
from sqlalchemy import select, and_, func
from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy.orm import selectinload
import uuid
from app.repositories.base import BaseRepository
from app.db.models import UserCard, UniqueCardRegistry, CardMaster
from app.models.card import CardRarity
class CardRepository(BaseRepository[UserCard]):
"""Card repository with custom methods"""
def __init__(self, session: AsyncSession):
super().__init__(UserCard, session)
async def get_user_cards(
self,
user_id: int,
skip: int = 0,
limit: int = 100
) -> List[UserCard]:
"""Get all cards for a user"""
result = await self.session.execute(
select(UserCard)
.options(selectinload(UserCard.card_info))
.where(UserCard.user_id == user_id)
.order_by(UserCard.obtained_at.desc())
.offset(skip)
.limit(limit)
)
return result.scalars().all()
async def count_user_cards(self, user_id: int, card_id: int) -> int:
"""Count how many of a specific card a user has"""
result = await self.session.execute(
select(func.count(UserCard.id))
.where(
and_(
UserCard.user_id == user_id,
UserCard.card_id == card_id
)
)
)
return result.scalar() or 0
async def create_user_card(
self,
user_id: int,
card_id: int,
cp: int,
status: CardRarity,
skill: Optional[str] = None,
is_unique: bool = False
) -> UserCard:
"""Create a new user card"""
unique_id = None
if is_unique:
unique_id = uuid.uuid4()
card = await self.create(
user_id=user_id,
card_id=card_id,
cp=cp,
status=status,
skill=skill,
is_unique=is_unique,
unique_id=unique_id
)
# If unique, register it globally
if is_unique:
await self._register_unique_card(card)
return card
async def _register_unique_card(self, card: UserCard):
"""Register a unique card in the global registry"""
# Get user DID
user_did = await self.session.execute(
select(User.did).where(User.id == card.user_id)
)
user_did = user_did.scalar()
registry = UniqueCardRegistry(
unique_id=card.unique_id,
card_id=card.card_id,
owner_did=user_did,
obtained_at=card.obtained_at
)
self.session.add(registry)
class UniqueCardRepository(BaseRepository[UniqueCardRegistry]):
"""Unique card registry repository"""
def __init__(self, session: AsyncSession):
super().__init__(UniqueCardRegistry, session)
async def is_card_available(self, card_id: int) -> bool:
"""Check if a unique card is still available"""
result = await self.session.execute(
select(func.count(UniqueCardRegistry.id))
.where(UniqueCardRegistry.card_id == card_id)
)
count = result.scalar() or 0
return count == 0
async def get_all_unique_cards(self) -> List[UniqueCardRegistry]:
"""Get all registered unique cards"""
result = await self.session.execute(
select(UniqueCardRegistry)
.order_by(UniqueCardRegistry.obtained_at.desc())
)
return result.scalars().all()
async def get_available_unique_cards(self) -> List[int]:
"""Get list of card IDs that are still available as unique"""
# Get all card IDs
all_card_ids = set(range(16))
# Get taken card IDs
result = await self.session.execute(
select(UniqueCardRegistry.card_id).distinct()
)
taken_ids = set(result.scalars().all())
# Return available IDs
return list(all_card_ids - taken_ids)
# Import User model here to avoid circular import
from app.db.models import User

View File

@ -0,0 +1,38 @@
"""User repository"""
from typing import Optional
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy.orm import selectinload
from app.repositories.base import BaseRepository
from app.db.models import User
class UserRepository(BaseRepository[User]):
"""User repository with custom methods"""
def __init__(self, session: AsyncSession):
super().__init__(User, session)
async def get_by_did(self, did: str) -> Optional[User]:
"""Get user by DID"""
result = await self.session.execute(
select(User).where(User.did == did)
)
return result.scalar_one_or_none()
async def get_or_create(self, did: str, handle: Optional[str] = None) -> User:
"""Get existing user or create new one"""
user = await self.get_by_did(did)
if not user:
user = await self.create(did=did, handle=handle)
return user
async def get_with_cards(self, user_id: int) -> Optional[User]:
"""Get user with all their cards"""
result = await self.session.execute(
select(User)
.options(selectinload(User.cards))
.where(User.id == user_id)
)
return result.scalar_one_or_none()

View File

@ -0,0 +1 @@
# Routes Package

132
api/app/routes/auth.py Normal file
View File

@ -0,0 +1,132 @@
"""Authentication routes"""
from datetime import timedelta
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_db
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_db)
):
"""
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

118
api/app/routes/cards.py Normal file
View File

@ -0,0 +1,118 @@
"""Card-related API routes"""
from typing import List
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.repositories.user import UserRepository
from app.repositories.card import CardRepository, UniqueCardRepository
from app.db.base import get_db
router = APIRouter(prefix="/cards", tags=["cards"])
@router.post("/draw", response_model=CardDrawResult)
async def draw_card(
draw_request: CardDraw,
db: AsyncSession = Depends(get_db)
):
"""
カードを抽選する
- **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_db)
):
"""
ユーザーの所有カード一覧を取得
- **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_db)):
"""
全ての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
]

151
api/app/routes/sync.py Normal file
View File

@ -0,0 +1,151 @@
"""Synchronization routes for atproto"""
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_db
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_db)
):
"""
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_db)
):
"""
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_db)
):
"""
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_db)
):
"""
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

View File

@ -0,0 +1 @@
# Services Package

288
api/app/services/atproto.py Normal file
View File

@ -0,0 +1,288 @@
"""atproto integration service"""
import json
from typing import Optional, Dict, Any, List
from datetime import datetime
import httpx
from atproto import Client, SessionString
from atproto.exceptions import AtProtocolError
from app.core.config import settings
from app.models.card import Card, CardRarity
class AtprotoService:
"""atproto integration service"""
def __init__(self):
self.client = None
self.session_string = None
async def login(self, identifier: str, password: str) -> SessionString:
"""
Login to atproto PDS
Args:
identifier: Handle or DID
password: App password
Returns:
Session string for future requests
"""
self.client = Client()
try:
self.client.login(identifier, password)
self.session_string = self.client.export_session_string()
return self.session_string
except AtProtocolError as e:
raise Exception(f"Failed to login to atproto: {str(e)}")
def restore_session(self, session_string: str):
"""Restore session from string"""
self.client = Client()
self.client.login_with_session_string(session_string)
self.session_string = session_string
async def verify_did(self, did: str, handle: Optional[str] = None) -> bool:
"""
Verify DID is valid
Args:
did: DID to verify
handle: Optional handle to cross-check
Returns:
True if valid
"""
try:
# Use public API to resolve DID
async with httpx.AsyncClient() as client:
response = await client.get(
f"https://plc.directory/{did}",
follow_redirects=True
)
if response.status_code != 200:
return False
data = response.json()
# Verify handle if provided
if handle and data.get("alsoKnownAs"):
expected_handle = f"at://{handle}"
return expected_handle in data["alsoKnownAs"]
return True
except Exception:
return False
async def get_profile(self, did: str) -> Optional[Dict[str, Any]]:
"""Get user profile from atproto"""
if not self.client:
raise Exception("Not logged in")
try:
profile = self.client.get_profile(did)
return {
"did": profile.did,
"handle": profile.handle,
"display_name": profile.display_name,
"avatar": profile.avatar,
"description": profile.description
}
except Exception:
return None
async def create_card_record(
self,
did: str,
card: Card,
collection: str = "ai.card.collection"
) -> str:
"""
Create card record in user's PDS
Args:
did: User's DID
card: Card data
collection: Collection name (lexicon)
Returns:
Record URI
"""
if not self.client:
raise Exception("Not logged in")
# Prepare card data for atproto
record_data = {
"$type": collection,
"cardId": card.id,
"cp": card.cp,
"status": card.status.value,
"skill": card.skill,
"obtainedAt": card.obtained_at.isoformat(),
"isUnique": card.is_unique,
"uniqueId": card.unique_id,
"createdAt": datetime.utcnow().isoformat()
}
try:
# Create record
response = self.client.com.atproto.repo.create_record(
repo=did,
collection=collection,
record=record_data
)
return response.uri
except AtProtocolError as e:
raise Exception(f"Failed to create card record: {str(e)}")
async def get_user_cards(
self,
did: str,
collection: str = "ai.card.collection",
limit: int = 100
) -> List[Dict[str, Any]]:
"""
Get user's cards from PDS
Args:
did: User's DID
collection: Collection name
limit: Maximum records to fetch
Returns:
List of card records
"""
if not self.client:
raise Exception("Not logged in")
try:
# List records
response = self.client.com.atproto.repo.list_records(
repo=did,
collection=collection,
limit=limit
)
cards = []
for record in response.records:
card_data = record.value
card_data["uri"] = record.uri
card_data["cid"] = record.cid
cards.append(card_data)
return cards
except AtProtocolError:
# Collection might not exist yet
return []
async def delete_card_record(self, did: str, record_uri: str):
"""Delete a card record from PDS"""
if not self.client:
raise Exception("Not logged in")
try:
# Parse collection and rkey from URI
# Format: at://did/collection/rkey
parts = record_uri.split("/")
if len(parts) < 5:
raise ValueError("Invalid record URI")
collection = parts[3]
rkey = parts[4]
self.client.com.atproto.repo.delete_record(
repo=did,
collection=collection,
rkey=rkey
)
except AtProtocolError as e:
raise Exception(f"Failed to delete record: {str(e)}")
async def create_oauth_session(self, code: str, redirect_uri: str) -> Dict[str, Any]:
"""
Handle OAuth callback and create session
Args:
code: Authorization code
redirect_uri: Redirect URI used in authorization
Returns:
Session data including DID and access token
"""
# TODO: Implement when atproto OAuth is available
raise NotImplementedError("OAuth support is not yet available in atproto")
class CardLexicon:
"""Card collection lexicon definition"""
LEXICON_ID = "ai.card.collection"
@staticmethod
def get_lexicon() -> Dict[str, Any]:
"""Get lexicon definition for card collection"""
return {
"lexicon": 1,
"id": CardLexicon.LEXICON_ID,
"defs": {
"main": {
"type": "record",
"description": "A collectible card",
"key": "tid",
"record": {
"type": "object",
"required": ["cardId", "cp", "status", "obtainedAt", "createdAt"],
"properties": {
"cardId": {
"type": "integer",
"description": "Card type ID (0-15)",
"minimum": 0,
"maximum": 15
},
"cp": {
"type": "integer",
"description": "Card power",
"minimum": 1,
"maximum": 999
},
"status": {
"type": "string",
"description": "Card rarity",
"enum": ["normal", "rare", "super_rare", "kira", "unique"]
},
"skill": {
"type": "string",
"description": "Card skill",
"maxLength": 1000
},
"obtainedAt": {
"type": "string",
"format": "datetime",
"description": "When the card was obtained"
},
"isUnique": {
"type": "boolean",
"description": "Whether this is a unique card",
"default": False
},
"uniqueId": {
"type": "string",
"description": "Global unique identifier",
"format": "uuid"
},
"createdAt": {
"type": "string",
"format": "datetime",
"description": "Record creation time"
}
}
}
}
}
}

View File

@ -0,0 +1,184 @@
"""Card synchronization service for atproto"""
from typing import List, Dict, Any, Optional
from datetime import datetime
from sqlalchemy.ext.asyncio import AsyncSession
from app.services.atproto import AtprotoService, CardLexicon
from app.repositories.card import CardRepository
from app.repositories.user import UserRepository
from app.models.card import Card as CardModel
from app.db.models import UserCard
from app.core.config import settings
class CardSyncService:
"""Service for syncing cards between database and atproto PDS"""
def __init__(self, session: AsyncSession, atproto_session: Optional[str] = None):
self.db_session = session
self.card_repo = CardRepository(session)
self.user_repo = UserRepository(session)
self.atproto_service = AtprotoService()
# Restore atproto session if provided
if atproto_session:
self.atproto_service.restore_session(atproto_session)
async def sync_card_to_pds(self, user_card: UserCard, user_did: str) -> Optional[str]:
"""
Sync a single card to user's PDS
Args:
user_card: Card from database
user_did: User's DID
Returns:
Record URI if successful
"""
if not settings.atproto_handle or not settings.atproto_password:
# Skip if atproto credentials not configured
return None
try:
# Login if not already
if not self.atproto_service.client:
await self.atproto_service.login(
settings.atproto_handle,
settings.atproto_password
)
# Convert to API model
card_model = CardModel(
id=user_card.card_id,
cp=user_card.cp,
status=user_card.status,
skill=user_card.skill,
owner_did=user_did,
obtained_at=user_card.obtained_at,
is_unique=user_card.is_unique,
unique_id=str(user_card.unique_id) if user_card.unique_id else None
)
# Create record in PDS
uri = await self.atproto_service.create_card_record(
did=user_did,
card=card_model,
collection=CardLexicon.LEXICON_ID
)
# Store URI in database for future reference
# (You might want to add a field to UserCard model for this)
return uri
except Exception as e:
print(f"Failed to sync card to PDS: {e}")
return None
async def sync_all_user_cards(self, user_id: int, user_did: str) -> int:
"""
Sync all user's cards to PDS
Args:
user_id: Database user ID
user_did: User's DID
Returns:
Number of cards synced
"""
# Get all user cards from database
user_cards = await self.card_repo.get_user_cards(user_id)
synced_count = 0
for card in user_cards:
uri = await self.sync_card_to_pds(card, user_did)
if uri:
synced_count += 1
return synced_count
async def import_cards_from_pds(self, user_did: str) -> int:
"""
Import cards from user's PDS to database
Args:
user_did: User's DID
Returns:
Number of cards imported
"""
if not self.atproto_service.client:
return 0
# Get user from database
user = await self.user_repo.get_by_did(user_did)
if not user:
return 0
# Get cards from PDS
pds_cards = await self.atproto_service.get_user_cards(
did=user_did,
collection=CardLexicon.LEXICON_ID
)
imported_count = 0
for pds_card in pds_cards:
# Check if card already exists
existing_count = await self.card_repo.count_user_cards(
user.id,
pds_card.get("cardId")
)
if existing_count == 0:
# Import card
await self.card_repo.create_user_card(
user_id=user.id,
card_id=pds_card.get("cardId"),
cp=pds_card.get("cp"),
status=pds_card.get("status"),
skill=pds_card.get("skill"),
is_unique=pds_card.get("isUnique", False)
)
imported_count += 1
await self.db_session.commit()
return imported_count
async def verify_card_ownership(
self,
user_did: str,
card_id: int,
unique_id: Optional[str] = None
) -> bool:
"""
Verify user owns a card by checking both database and PDS
Args:
user_did: User's DID
card_id: Card type ID
unique_id: Unique ID for unique cards
Returns:
True if user owns the card
"""
# Check database first
user = await self.user_repo.get_by_did(user_did)
if user:
user_cards = await self.card_repo.get_user_cards(user.id)
for card in user_cards:
if card.card_id == card_id:
if not unique_id or str(card.unique_id) == unique_id:
return True
# Check PDS if configured
if self.atproto_service.client:
try:
pds_cards = await self.atproto_service.get_user_cards(user_did)
for card in pds_cards:
if card.get("cardId") == card_id:
if not unique_id or card.get("uniqueId") == unique_id:
return True
except Exception:
pass
return False

181
api/app/services/gacha.py Normal file
View File

@ -0,0 +1,181 @@
"""ガチャシステムのロジック"""
import random
import uuid
from datetime import datetime
from typing import Optional, Tuple
from sqlalchemy.ext.asyncio import AsyncSession
from app.core.config import settings
from app.models.card import Card, CardRarity
from app.repositories.user import UserRepository
from app.repositories.card import CardRepository, UniqueCardRepository
from app.db.models import DrawHistory
class GachaService:
"""ガチャシステムのサービスクラス"""
# カード基本情報ai.jsonから
CARD_INFO = {
0: {"name": "ai", "base_cp_range": (10, 100)},
1: {"name": "dream", "base_cp_range": (20, 120)},
2: {"name": "radiance", "base_cp_range": (30, 130)},
3: {"name": "neutron", "base_cp_range": (40, 140)},
4: {"name": "sun", "base_cp_range": (50, 150)},
5: {"name": "night", "base_cp_range": (25, 125)},
6: {"name": "snow", "base_cp_range": (15, 115)},
7: {"name": "thunder", "base_cp_range": (60, 160)},
8: {"name": "ultimate", "base_cp_range": (80, 180)},
9: {"name": "sword", "base_cp_range": (70, 170)},
10: {"name": "destruction", "base_cp_range": (90, 190)},
11: {"name": "earth", "base_cp_range": (35, 135)},
12: {"name": "galaxy", "base_cp_range": (65, 165)},
13: {"name": "create", "base_cp_range": (75, 175)},
14: {"name": "supernova", "base_cp_range": (100, 200)},
15: {"name": "world", "base_cp_range": (85, 185)},
}
def __init__(self, session: AsyncSession):
self.session = session
self.user_repo = UserRepository(session)
self.card_repo = CardRepository(session)
self.unique_repo = UniqueCardRepository(session)
async def draw_card(self, user_did: str, is_paid: bool = False) -> Tuple[Card, bool]:
"""
カードを抽選する
Args:
user_did: ユーザーのDID
is_paid: 課金ガチャかどうか
Returns:
(Card, is_unique): 抽選されたカードとuniqueかどうか
"""
# Get or create user
user = await self.user_repo.get_or_create(user_did)
# レアリティ抽選
rarity = self._determine_rarity(is_paid)
# カード種類を選択
card_id = self._select_card_id(rarity)
# CPを決定
cp = self._calculate_cp(card_id, rarity)
# uniqueカードチェック
is_unique = False
if rarity == CardRarity.UNIQUE:
# uniqueカードの場合、利用可能かチェック
is_available = await self.unique_repo.is_card_available(card_id)
if not is_available:
# 利用不可の場合はキラカードに変更
rarity = CardRarity.KIRA
else:
is_unique = True
# データベースにカードを保存
user_card = await self.card_repo.create_user_card(
user_id=user.id,
card_id=card_id,
cp=cp,
status=rarity,
skill=self._get_skill_for_card(card_id, rarity),
is_unique=is_unique
)
# 抽選履歴を保存
draw_history = DrawHistory(
user_id=user.id,
card_id=card_id,
status=rarity,
cp=cp,
is_paid=is_paid
)
self.session.add(draw_history)
# API用のCardモデルに変換
card = Card(
id=card_id,
cp=cp,
status=rarity,
skill=user_card.skill,
owner_did=user_did,
obtained_at=user_card.obtained_at,
is_unique=is_unique,
unique_id=str(user_card.unique_id) if user_card.unique_id else None
)
# atproto PDSに同期非同期で実行
try:
from app.services.card_sync import CardSyncService
sync_service = CardSyncService(self.session)
await sync_service.sync_card_to_pds(user_card, user_did)
except Exception:
# 同期失敗してもガチャは成功とする
pass
return card, is_unique
def _determine_rarity(self, is_paid: bool) -> CardRarity:
"""レアリティを抽選する"""
rand = random.random() * 100
if is_paid:
# 課金ガチャは確率アップ
if rand < settings.prob_unique * 2: # 0.0002%
return CardRarity.UNIQUE
elif rand < settings.prob_kira * 2: # 0.2%
return CardRarity.KIRA
elif rand < 0.5: # 0.5%
return CardRarity.SUPER_RARE
elif rand < 5: # 5%
return CardRarity.RARE
else:
# 通常ガチャ
if rand < settings.prob_unique:
return CardRarity.UNIQUE
elif rand < settings.prob_kira:
return CardRarity.KIRA
elif rand < settings.prob_super_rare:
return CardRarity.SUPER_RARE
elif rand < settings.prob_rare:
return CardRarity.RARE
return CardRarity.NORMAL
def _select_card_id(self, rarity: CardRarity) -> int:
"""レアリティに応じてカードIDを選択"""
if rarity in [CardRarity.UNIQUE, CardRarity.KIRA]:
# レアカードは特定のIDに偏らせる
weights = [1, 1, 2, 2, 3, 1, 1, 3, 5, 4, 5, 2, 3, 4, 6, 5]
else:
# 通常は均等
weights = [1] * 16
return random.choices(range(16), weights=weights)[0]
def _calculate_cp(self, card_id: int, rarity: CardRarity) -> int:
"""カードのCPを計算"""
base_range = self.CARD_INFO[card_id]["base_cp_range"]
base_cp = random.randint(*base_range)
# レアリティボーナス
multiplier = {
CardRarity.NORMAL: 1.0,
CardRarity.RARE: 1.5,
CardRarity.SUPER_RARE: 2.0,
CardRarity.KIRA: 3.0,
CardRarity.UNIQUE: 5.0,
}[rarity]
return int(base_cp * multiplier)
def _get_skill_for_card(self, card_id: int, rarity: CardRarity) -> Optional[str]:
"""カードのスキルを取得"""
if rarity in [CardRarity.KIRA, CardRarity.UNIQUE]:
# TODO: スキル情報を返す
return f"skill_{card_id}_{rarity.value}"
return None

View File

@ -0,0 +1 @@
# Test Package

View File

@ -0,0 +1,57 @@
"""ガチャシステムのテスト"""
import pytest
from app.services.gacha import GachaService
from app.models.card import CardRarity
class TestGachaService:
"""GachaServiceのテストクラス"""
def test_determine_rarity_normal(self):
"""通常ガチャのレアリティ判定テスト"""
rarities = []
for _ in range(1000):
rarity = GachaService._determine_rarity(is_paid=False)
rarities.append(rarity)
# 大部分がNORMALであることを確認
normal_count = rarities.count(CardRarity.NORMAL)
assert normal_count > 900
def test_determine_rarity_paid(self):
"""課金ガチャのレアリティ判定テスト"""
rarities = []
for _ in range(1000):
rarity = GachaService._determine_rarity(is_paid=True)
rarities.append(rarity)
# 課金ガチャの方がレアが出やすいことを確認
rare_count = sum(1 for r in rarities if r != CardRarity.NORMAL)
assert rare_count > 50 # 5%以上
def test_card_id_selection(self):
"""カードID選択のテスト"""
for rarity in CardRarity:
card_id = GachaService._select_card_id(rarity)
assert 0 <= card_id <= 15
def test_cp_calculation(self):
"""CP計算のテスト"""
# 通常カード
cp_normal = GachaService._calculate_cp(0, CardRarity.NORMAL)
assert 10 <= cp_normal <= 100
# uniqueカード5倍
cp_unique = GachaService._calculate_cp(0, CardRarity.UNIQUE)
assert 50 <= cp_unique <= 500
@pytest.mark.asyncio
async def test_draw_card(self):
"""カード抽選の統合テスト"""
user_did = "did:plc:test123"
card, is_unique = await GachaService.draw_card(user_did, is_paid=False)
assert card.owner_did == user_did
assert 0 <= card.id <= 15
assert card.cp > 0
assert card.status in CardRarity

71
api/init_db.py Normal file
View File

@ -0,0 +1,71 @@
"""Initialize database with master data"""
import asyncio
from sqlalchemy.ext.asyncio import AsyncSession
from app.db.base import engine, Base, async_session
from app.db.models import CardMaster
from app.core.config import settings
# Card master data from ai.json
CARD_MASTER_DATA = [
{"id": 0, "name": "ai", "base_cp_min": 10, "base_cp_max": 100, "color": "#fff700", "description": "世界の最小単位"},
{"id": 1, "name": "dream", "base_cp_min": 20, "base_cp_max": 120, "color": "#b19cd9", "description": "意識が物質を作る"},
{"id": 2, "name": "radiance", "base_cp_min": 30, "base_cp_max": 130, "color": "#ffd700", "description": "存在は光に向かう"},
{"id": 3, "name": "neutron", "base_cp_min": 40, "base_cp_max": 140, "color": "#cacfd2", "description": "中性子"},
{"id": 4, "name": "sun", "base_cp_min": 50, "base_cp_max": 150, "color": "#ff6b35", "description": "太陽"},
{"id": 5, "name": "night", "base_cp_min": 25, "base_cp_max": 125, "color": "#1a1a2e", "description": "夜空"},
{"id": 6, "name": "snow", "base_cp_min": 15, "base_cp_max": 115, "color": "#e3f2fd", "description": ""},
{"id": 7, "name": "thunder", "base_cp_min": 60, "base_cp_max": 160, "color": "#ffd93d", "description": ""},
{"id": 8, "name": "ultimate", "base_cp_min": 80, "base_cp_max": 180, "color": "#6c5ce7", "description": "超究"},
{"id": 9, "name": "sword", "base_cp_min": 70, "base_cp_max": 170, "color": "#a8e6cf", "description": ""},
{"id": 10, "name": "destruction", "base_cp_min": 90, "base_cp_max": 190, "color": "#ff4757", "description": "破壊"},
{"id": 11, "name": "earth", "base_cp_min": 35, "base_cp_max": 135, "color": "#4834d4", "description": "地球"},
{"id": 12, "name": "galaxy", "base_cp_min": 65, "base_cp_max": 165, "color": "#9c88ff", "description": "天の川"},
{"id": 13, "name": "create", "base_cp_min": 75, "base_cp_max": 175, "color": "#00d2d3", "description": "創造"},
{"id": 14, "name": "supernova", "base_cp_min": 100, "base_cp_max": 200, "color": "#ff9ff3", "description": "超新星"},
{"id": 15, "name": "world", "base_cp_min": 85, "base_cp_max": 185, "color": "#54a0ff", "description": "存在と世界は同じもの"},
]
async def init_db():
"""Initialize database tables and master data"""
print("Creating database tables...")
async with engine.begin() as conn:
await conn.run_sync(Base.metadata.create_all)
print("Inserting master data...")
async with async_session() as session:
# Check if master data already exists
existing = await session.execute(
"SELECT COUNT(*) FROM card_master"
)
count = existing.scalar()
if count == 0:
# Insert card master data
for card_data in CARD_MASTER_DATA:
card = CardMaster(**card_data)
session.add(card)
await session.commit()
print(f"Inserted {len(CARD_MASTER_DATA)} card master records")
else:
print("Master data already exists, skipping...")
print("Database initialization complete!")
async def drop_db():
"""Drop all database tables"""
print("Dropping all database tables...")
async with engine.begin() as conn:
await conn.run_sync(Base.metadata.drop_all)
print("All tables dropped!")
if __name__ == "__main__":
import sys
if len(sys.argv) > 1 and sys.argv[1] == "drop":
asyncio.run(drop_db())
else:
asyncio.run(init_db())

18
api/requirements.txt Normal file
View File

@ -0,0 +1,18 @@
fastapi==0.104.1
uvicorn[standard]==0.24.0
pydantic==2.5.0
pydantic-settings==2.1.0
python-multipart==0.0.6
httpx==0.25.2
python-jose[cryptography]==3.3.0
passlib[bcrypt]==1.7.4
sqlalchemy==2.0.23
alembic==1.12.1
asyncpg==0.29.0
psycopg2-binary==2.9.9
aiosqlite==0.19.0
python-dotenv==1.0.0
pytest==7.4.3
pytest-asyncio==0.21.1
atproto==0.0.46
supabase==2.3.0