From 9954599a331bf0885db21e5b98ac3e976dcb1920 Mon Sep 17 00:00:00 2001 From: DIKER0K Date: Sat, 20 Dec 2025 19:51:18 +0500 Subject: [PATCH] add promocodes --- app/api/promo.py | 81 +++++++++++++++++++++++ app/models/promo.py | 33 ++++++++++ app/services/promo.py | 145 ++++++++++++++++++++++++++++++++++++++++++ main.py | 3 +- 4 files changed, 261 insertions(+), 1 deletion(-) create mode 100644 app/api/promo.py create mode 100644 app/models/promo.py create mode 100644 app/services/promo.py diff --git a/app/api/promo.py b/app/api/promo.py new file mode 100644 index 0000000..95fe0c9 --- /dev/null +++ b/app/api/promo.py @@ -0,0 +1,81 @@ +from fastapi import APIRouter, HTTPException, Query, Form +from typing import List +from app.services.auth import AuthService +from app.services.promo import PromoService +from app.models.promo import PromoCreate, PromoUpdate, PromoRedeemResponse + +router = APIRouter(tags=["Promo"]) + +promo_service = PromoService() + +async def validate_admin(accessToken: str, clientToken: str): + auth = AuthService() + if not await auth.is_admin(accessToken, clientToken): + raise HTTPException(status_code=403, detail="Admin privileges required") + +# --- Игровая ручка (активация) --- + +@router.post("/promo/redeem", response_model=PromoRedeemResponse) +async def redeem_promo( + username: str = Form(...), + code: str = Form(...), +): + # при желании сюда можно добавить проверку accessToken/clientToken, + # как у вас в админских ручках, но это зависит от вашей auth-логики. + return await promo_service.redeem(username=username, code=code) + +# --- Админские ручки --- + +@router.get("/admin/promo", response_model=List[dict]) +async def admin_list_promos( + accessToken: str, + clientToken: str, + limit: int = Query(50, ge=1, le=200), + skip: int = Query(0, ge=0), +): + await validate_admin(accessToken, clientToken) + return await promo_service.list(limit=limit, skip=skip) + +@router.post("/admin/promo", response_model=dict) +async def admin_create_promo( + accessToken: str = Form(...), + clientToken: str = Form(...), + code: str = Form(...), + reward_coins: int = Form(...), + max_uses: int | None = Form(None), + is_active: bool = Form(True), +): + await validate_admin(accessToken, clientToken) + payload = PromoCreate( + code=code, + reward_coins=reward_coins, + max_uses=max_uses, + is_active=is_active, + ) + return await promo_service.create(payload) + +@router.put("/admin/promo/{promo_id}", response_model=dict) +async def admin_update_promo( + promo_id: str, + accessToken: str = Form(...), + clientToken: str = Form(...), + reward_coins: int | None = Form(None), + max_uses: int | None = Form(None), + is_active: bool | None = Form(None), +): + await validate_admin(accessToken, clientToken) + payload = PromoUpdate( + reward_coins=reward_coins, + max_uses=max_uses, + is_active=is_active + ) + return await promo_service.update(promo_id, payload) + +@router.delete("/admin/promo/{promo_id}") +async def admin_delete_promo( + promo_id: str, + accessToken: str, + clientToken: str, +): + await validate_admin(accessToken, clientToken) + return await promo_service.delete(promo_id) diff --git a/app/models/promo.py b/app/models/promo.py new file mode 100644 index 0000000..520cf4e --- /dev/null +++ b/app/models/promo.py @@ -0,0 +1,33 @@ +from pydantic import BaseModel, Field +from typing import Optional +from datetime import datetime + +class PromoBase(BaseModel): + code: str = Field(..., min_length=3, max_length=64) + reward_coins: int = Field(..., ge=1, le=1_000_000) + max_uses: Optional[int] = Field(default=None, ge=1) # None = бесконечно + is_active: bool = True + starts_at: Optional[datetime] = None + ends_at: Optional[datetime] = None + +class PromoCreate(PromoBase): + pass + +class PromoUpdate(BaseModel): + reward_coins: Optional[int] = Field(default=None, ge=1, le=1_000_000) + max_uses: Optional[int] = Field(default=None, ge=1) + is_active: Optional[bool] = None + starts_at: Optional[datetime] = None + ends_at: Optional[datetime] = None + +class PromoInDB(PromoBase): + id: str + uses_count: int + created_at: datetime + updated_at: datetime + +class PromoRedeemResponse(BaseModel): + code: str + reward_coins: int + new_balance: int + diff --git a/app/services/promo.py b/app/services/promo.py new file mode 100644 index 0000000..8b56598 --- /dev/null +++ b/app/services/promo.py @@ -0,0 +1,145 @@ +from datetime import datetime +from fastapi import HTTPException +from bson import ObjectId +from pymongo import ReturnDocument +from app.db.database import db +from app.services.coins import CoinsService + +promo_codes = db["promo_codes"] +promo_redemptions = db["promo_redemptions"] + +class PromoService: + def __init__(self): + self.coins = CoinsService() + + @staticmethod + def _normalize_code(code: str) -> str: + return code.strip().upper() + + async def ensure_indexes(self): + # уникальность самого кода + await promo_codes.create_index("code", unique=True) + # одноразовость на пользователя + await promo_redemptions.create_index([("code", 1), ("username", 1)], unique=True) + await promo_redemptions.create_index("redeemed_at") + + async def create(self, payload): + now = datetime.utcnow() + doc = { + "code": self._normalize_code(payload.code), + "reward_coins": payload.reward_coins, + "max_uses": payload.max_uses, + "uses_count": 0, + "is_active": payload.is_active, + "starts_at": payload.starts_at, + "ends_at": payload.ends_at, + "created_at": now, + "updated_at": now, + } + try: + r = await promo_codes.insert_one(doc) + except Exception: + # можно точнее ловить DuplicateKeyError + raise HTTPException(status_code=409, detail="Promo code already exists") + doc["_id"] = r.inserted_id + return doc + + async def list(self, limit: int = 50, skip: int = 0): + cursor = promo_codes.find({}).sort("created_at", -1).skip(skip).limit(limit) + return await cursor.to_list(length=limit) + + async def update(self, promo_id: str, payload): + try: + oid = ObjectId(promo_id) + except: + raise HTTPException(status_code=400, detail="Invalid promo id") + + update_data = {k: v for k, v in payload.dict(exclude_unset=True).items()} + if not update_data: + doc = await promo_codes.find_one({"_id": oid}) + if not doc: + raise HTTPException(status_code=404, detail="Promo not found") + return doc + + update_data["updated_at"] = datetime.utcnow() + + doc = await promo_codes.find_one_and_update( + {"_id": oid}, + {"$set": update_data}, + return_document=ReturnDocument.AFTER + ) + if not doc: + raise HTTPException(status_code=404, detail="Promo not found") + return doc + + async def delete(self, promo_id: str): + try: + oid = ObjectId(promo_id) + except: + raise HTTPException(status_code=400, detail="Invalid promo id") + + r = await promo_codes.delete_one({"_id": oid}) + if r.deleted_count == 0: + raise HTTPException(status_code=404, detail="Promo not found") + return {"status": "success"} + + async def redeem(self, username: str, code: str): + username = username.strip() + code = self._normalize_code(code) + now = datetime.utcnow() + + # 1) Сначала фиксируем, что этот пользователь пытается активировать этот код. + # Уникальный индекс (code, username) гарантирует одноразовость. + try: + await promo_redemptions.insert_one({ + "code": code, + "username": username, + "redeemed_at": now, + }) + except Exception: + # DuplicateKey -> уже активировал + raise HTTPException(status_code=409, detail="Promo code already redeemed by this user") + + # 2) Затем пытаемся “забрать” 1 использование у промокода атомарно. + # Если max_uses = None -> ограничение не проверяем. + promo = await promo_codes.find_one({"code": code}) + if not promo: + # откатываем redemption запись + await promo_redemptions.delete_one({"code": code, "username": username}) + raise HTTPException(status_code=404, detail="Promo code not found") + + if not promo.get("is_active", True): + await promo_redemptions.delete_one({"code": code, "username": username}) + raise HTTPException(status_code=400, detail="Promo code is inactive") + + starts_at = promo.get("starts_at") + ends_at = promo.get("ends_at") + if starts_at and now < starts_at: + await promo_redemptions.delete_one({"code": code, "username": username}) + raise HTTPException(status_code=400, detail="Promo code is not started yet") + if ends_at and now > ends_at: + await promo_redemptions.delete_one({"code": code, "username": username}) + raise HTTPException(status_code=400, detail="Promo code expired") + + query = {"code": code} + if promo.get("max_uses") is not None: + query["uses_count"] = {"$lt": promo["max_uses"]} + + updated = await promo_codes.find_one_and_update( + query, + {"$inc": {"uses_count": 1}, "$set": {"updated_at": now}}, + return_document=ReturnDocument.AFTER + ) + + if not updated: + # лимит исчерпан — откатываем redemption + await promo_redemptions.delete_one({"code": code, "username": username}) + raise HTTPException(status_code=409, detail="Promo code usage limit reached") + + # 3) Начисляем монеты + new_balance = await self.coins.increase_balance(username, promo["reward_coins"]) + return { + "code": code, + "reward_coins": promo["reward_coins"], + "new_balance": new_balance + } diff --git a/main.py b/main.py index a6a06cc..fb4053f 100644 --- a/main.py +++ b/main.py @@ -3,7 +3,7 @@ import os from fastapi import FastAPI from fastapi.staticfiles import StaticFiles import httpx -from app.api import admin_daily_quests, inventory, news, users, skins, capes, meta, server, store, pranks, marketplace, bonuses, case +from app.api import admin_daily_quests, inventory, news, users, skins, capes, meta, server, store, pranks, marketplace, bonuses, case, promo from fastapi.middleware.cors import CORSMiddleware from app.core.config import CAPES_DIR, CAPES_STORE_DIR, SKINS_DIR @@ -64,6 +64,7 @@ app.include_router(bonuses.router) app.include_router(news.router) app.include_router(telegram.router) app.include_router(admin_daily_quests.router) +app.include_router(promo.router) # Монтируем статику app.mount("/skins", StaticFiles(directory=str(SKINS_DIR)), name="skins")