add promocodes
This commit is contained in:
81
app/api/promo.py
Normal file
81
app/api/promo.py
Normal file
@ -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)
|
||||||
33
app/models/promo.py
Normal file
33
app/models/promo.py
Normal file
@ -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
|
||||||
|
|
||||||
145
app/services/promo.py
Normal file
145
app/services/promo.py
Normal file
@ -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
|
||||||
|
}
|
||||||
3
main.py
3
main.py
@ -3,7 +3,7 @@ import os
|
|||||||
from fastapi import FastAPI
|
from fastapi import FastAPI
|
||||||
from fastapi.staticfiles import StaticFiles
|
from fastapi.staticfiles import StaticFiles
|
||||||
import httpx
|
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 fastapi.middleware.cors import CORSMiddleware
|
||||||
|
|
||||||
from app.core.config import CAPES_DIR, CAPES_STORE_DIR, SKINS_DIR
|
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(news.router)
|
||||||
app.include_router(telegram.router)
|
app.include_router(telegram.router)
|
||||||
app.include_router(admin_daily_quests.router)
|
app.include_router(admin_daily_quests.router)
|
||||||
|
app.include_router(promo.router)
|
||||||
|
|
||||||
# Монтируем статику
|
# Монтируем статику
|
||||||
app.mount("/skins", StaticFiles(directory=str(SKINS_DIR)), name="skins")
|
app.mount("/skins", StaticFiles(directory=str(SKINS_DIR)), name="skins")
|
||||||
|
|||||||
Reference in New Issue
Block a user