Files
2025-12-20 20:02:04 +05:00

157 lines
6.1 KiB
Python
Raw Permalink Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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:
@staticmethod
def _to_json(doc: dict | None):
if not doc:
return doc
doc = dict(doc)
if "_id" in doc:
doc["_id"] = str(doc["_id"])
return doc
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 self._to_json(doc)
async def list(self, limit: int = 50, skip: int = 0):
cursor = promo_codes.find({}).sort("created_at", -1).skip(skip).limit(limit)
items = await cursor.to_list(length=limit)
return [self._to_json(x) for x in items]
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 self._to_json(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 self._to_json(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
}