This commit is contained in:
Redsandy
2026-08-12 11:26:05 +03:00
commit c9f66f330e
17 changed files with 1569 additions and 0 deletions

0
app/__init__.py Normal file
View File

189
app/api/routes.py Normal file
View File

@@ -0,0 +1,189 @@
from datetime import datetime, timezone
from decimal import Decimal
from fastapi import APIRouter, Depends, HTTPException, status
from fastapi.security import HTTPAuthorizationCredentials, HTTPBearer
from sqlalchemy import select
from sqlalchemy.orm import Session
from app.core.database import get_db
from app.core.security import create_access_token, decode_access_token
from app.models import Deposit, User
from app.schemas import (
DepositRequest,
DepositResponse,
DevLoginRequest,
GameStub,
TelegramAuthRequest,
TokenResponse,
UserResponse,
WalletResponse,
)
router = APIRouter()
security = HTTPBearer()
ALLOWED_DEPOSIT_AMOUNTS = {Decimal("100"), Decimal("500"), Decimal("1000")}
GAMES: list[GameStub] = [
GameStub(
slug="raven-reels",
title="Вороньи Барабаны",
description="Слоты у врат Вырия. Скоро откроются.",
),
GameStub(
slug="golden-gate",
title="Золотые Врата",
description="Портал удачи. Пока запечатан.",
),
GameStub(
slug="forest-fortune",
title="Лесная Удача",
description="Древние символы ещё не проснулись.",
),
]
def user_to_response(user: User) -> UserResponse:
return UserResponse(
id=user.id,
telegram_id=user.telegram_id,
username=user.username,
first_name=user.first_name,
balance=user.balance,
agreement_accepted=user.agreement_accepted_at is not None,
agreement_accepted_at=user.agreement_accepted_at,
created_at=user.created_at,
)
def get_current_user(
credentials: HTTPAuthorizationCredentials = Depends(security),
db: Session = Depends(get_db),
) -> User:
user_id = decode_access_token(credentials.credentials)
if user_id is None:
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="Invalid token")
user = db.execute(select(User).where(User.id == int(user_id))).scalar_one_or_none()
if user is None:
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="User not found")
return user
@router.post("/auth/dev-login", response_model=TokenResponse)
def dev_login(payload: DevLoginRequest, db: Session = Depends(get_db)) -> TokenResponse:
user = db.execute(select(User).where(User.telegram_id == payload.telegram_id)).scalar_one_or_none()
if user is None:
user = User(
telegram_id=payload.telegram_id,
username=payload.username,
first_name=payload.first_name,
balance=Decimal("0.00"),
)
db.add(user)
db.commit()
db.refresh(user)
else:
user.username = payload.username
user.first_name = payload.first_name
db.commit()
db.refresh(user)
token = create_access_token(str(user.id))
return TokenResponse(access_token=token)
@router.post("/auth/telegram", response_model=TokenResponse)
def telegram_auth(_payload: TelegramAuthRequest) -> TokenResponse:
# BOT_TOKEN not configured yet — wire real initData validation later.
raise HTTPException(
status_code=status.HTTP_501_NOT_IMPLEMENTED,
detail="Telegram auth requires BOT_TOKEN. Use /auth/dev-login for local development.",
)
@router.get("/me", response_model=UserResponse)
def get_me(user: User = Depends(get_current_user)) -> UserResponse:
return user_to_response(user)
@router.post("/me/agreement", response_model=UserResponse)
def accept_agreement(
user: User = Depends(get_current_user),
db: Session = Depends(get_db),
) -> UserResponse:
if user.agreement_accepted_at is None:
user.agreement_accepted_at = datetime.now(timezone.utc)
db.commit()
db.refresh(user)
return user_to_response(user)
@router.get("/wallet", response_model=WalletResponse)
def get_wallet(
user: User = Depends(get_current_user),
db: Session = Depends(get_db),
) -> WalletResponse:
deposits = (
db.execute(
select(Deposit).where(Deposit.user_id == user.id).order_by(Deposit.created_at.desc()).limit(20)
)
.scalars()
.all()
)
return WalletResponse(
balance=user.balance,
deposits=[DepositResponse.model_validate(d) for d in deposits],
)
@router.post("/wallet/deposit", response_model=WalletResponse)
def deposit_stub(
payload: DepositRequest,
user: User = Depends(get_current_user),
db: Session = Depends(get_db),
) -> WalletResponse:
if user.agreement_accepted_at is None:
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail="Accept user agreement first",
)
amount = payload.amount.quantize(Decimal("0.01"))
if amount not in ALLOWED_DEPOSIT_AMOUNTS:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail=f"Amount must be one of: {sorted(ALLOWED_DEPOSIT_AMOUNTS)}",
)
deposit = Deposit(user_id=user.id, amount=amount)
user.balance = (user.balance or Decimal("0.00")) + amount
db.add(deposit)
db.commit()
db.refresh(user)
deposits = (
db.execute(
select(Deposit).where(Deposit.user_id == user.id).order_by(Deposit.created_at.desc()).limit(20)
)
.scalars()
.all()
)
return WalletResponse(
balance=user.balance,
deposits=[DepositResponse.model_validate(d) for d in deposits],
)
@router.get("/games", response_model=list[GameStub])
def list_games() -> list[GameStub]:
return GAMES
@router.get("/games/{slug}", response_model=GameStub)
def get_game(slug: str) -> GameStub:
for game in GAMES:
if game.slug == slug:
return game
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Game not found")

27
app/core/config.py Normal file
View File

@@ -0,0 +1,27 @@
from functools import lru_cache
from pydantic_settings import BaseSettings, SettingsConfigDict
class Settings(BaseSettings):
model_config = SettingsConfigDict(env_file=".env", env_file_encoding="utf-8", extra="ignore")
database_url: str = "sqlite:///./vyryi.db"
jwt_secret: str = "dev-secret-change-me"
jwt_algorithm: str = "HS256"
jwt_expire_minutes: int = 60 * 24 * 7
cors_origins: str = (
"http://localhost:5173,http://127.0.0.1:5173,"
"http://localhost:5174,http://127.0.0.1:5174"
)
cors_origin_regex: str = r"http://(localhost|127\.0\.0\.1):\d+"
bot_token: str = ""
@property
def cors_origins_list(self) -> list[str]:
return [origin.strip() for origin in self.cors_origins.split(",") if origin.strip()]
@lru_cache
def get_settings() -> Settings:
return Settings()

27
app/core/database.py Normal file
View File

@@ -0,0 +1,27 @@
from collections.abc import Generator
from sqlalchemy import create_engine
from sqlalchemy.orm import DeclarativeBase, Session, sessionmaker
from app.core.config import get_settings
settings = get_settings()
connect_args: dict = {}
if settings.database_url.startswith("sqlite"):
connect_args = {"check_same_thread": False}
engine = create_engine(settings.database_url, echo=False, connect_args=connect_args)
SessionLocal = sessionmaker(bind=engine, autocommit=False, autoflush=False)
class Base(DeclarativeBase):
pass
def get_db() -> Generator[Session, None, None]:
db = SessionLocal()
try:
yield db
finally:
db.close()

22
app/core/security.py Normal file
View File

@@ -0,0 +1,22 @@
from datetime import datetime, timedelta, timezone
from jose import JWTError, jwt
from app.core.config import get_settings
def create_access_token(subject: str) -> str:
settings = get_settings()
expire = datetime.now(timezone.utc) + timedelta(minutes=settings.jwt_expire_minutes)
payload = {"sub": subject, "exp": expire}
return jwt.encode(payload, settings.jwt_secret, algorithm=settings.jwt_algorithm)
def decode_access_token(token: str) -> str | None:
settings = get_settings()
try:
payload = jwt.decode(token, settings.jwt_secret, algorithms=[settings.jwt_algorithm])
except JWTError:
return None
subject = payload.get("sub")
return str(subject) if subject is not None else None

39
app/main.py Normal file
View File

@@ -0,0 +1,39 @@
from contextlib import asynccontextmanager
from fastapi import FastAPI
from fastapi.middleware.cors import CORSMiddleware
from app.api.routes import router
from app.core.config import get_settings
from app.core.database import Base, engine
from app.models import Deposit, User # noqa: F401
@asynccontextmanager
async def lifespan(_app: FastAPI):
Base.metadata.create_all(bind=engine)
yield
engine.dispose()
def create_app() -> FastAPI:
settings = get_settings()
app = FastAPI(title="Vyryi API", version="0.1.0", lifespan=lifespan)
app.add_middleware(
CORSMiddleware,
allow_origins=settings.cors_origins_list,
allow_origin_regex=settings.cors_origin_regex or None,
allow_credentials=True,
allow_methods=["*"],
allow_headers=["*"],
)
app.include_router(router)
@app.get("/health")
def health() -> dict[str, str]:
return {"status": "ok"}
return app
app = create_app()

38
app/models/__init__.py Normal file
View File

@@ -0,0 +1,38 @@
from datetime import datetime
from decimal import Decimal
from sqlalchemy import DateTime, ForeignKey, Numeric, String, BigInteger, func
from sqlalchemy.orm import Mapped, mapped_column, relationship
from app.core.database import Base
class User(Base):
__tablename__ = "users"
id: Mapped[int] = mapped_column(primary_key=True, autoincrement=True)
telegram_id: Mapped[int | None] = mapped_column(BigInteger, unique=True, index=True, nullable=True)
username: Mapped[str | None] = mapped_column(String(255), nullable=True)
first_name: Mapped[str | None] = mapped_column(String(255), nullable=True)
balance: Mapped[Decimal] = mapped_column(Numeric(18, 2), default=Decimal("0.00"), nullable=False)
agreement_accepted_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True)
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now(), nullable=False)
updated_at: Mapped[datetime] = mapped_column(
DateTime(timezone=True),
server_default=func.now(),
onupdate=func.now(),
nullable=False,
)
deposits: Mapped[list["Deposit"]] = relationship(back_populates="user", cascade="all, delete-orphan")
class Deposit(Base):
__tablename__ = "deposits"
id: Mapped[int] = mapped_column(primary_key=True, autoincrement=True)
user_id: Mapped[int] = mapped_column(ForeignKey("users.id", ondelete="CASCADE"), index=True, nullable=False)
amount: Mapped[Decimal] = mapped_column(Numeric(18, 2), nullable=False)
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now(), nullable=False)
user: Mapped[User] = relationship(back_populates="deposits")

56
app/schemas/__init__.py Normal file
View File

@@ -0,0 +1,56 @@
from datetime import datetime
from decimal import Decimal
from pydantic import BaseModel, ConfigDict, Field
class DevLoginRequest(BaseModel):
telegram_id: int = Field(default=100001, ge=1)
username: str | None = "dev_user"
first_name: str | None = "Traveler"
class TelegramAuthRequest(BaseModel):
init_data: str
class TokenResponse(BaseModel):
access_token: str
token_type: str = "bearer"
class UserResponse(BaseModel):
model_config = ConfigDict(from_attributes=True)
id: int
telegram_id: int | None
username: str | None
first_name: str | None
balance: Decimal
agreement_accepted: bool
agreement_accepted_at: datetime | None
created_at: datetime
class DepositRequest(BaseModel):
amount: Decimal = Field(..., description="Allowed stub amounts: 100, 500, 1000")
class DepositResponse(BaseModel):
model_config = ConfigDict(from_attributes=True)
id: int
amount: Decimal
created_at: datetime
class WalletResponse(BaseModel):
balance: Decimal
deposits: list[DepositResponse]
class GameStub(BaseModel):
slug: str
title: str
description: str
status: str = "coming_soon"