47c7f17c90
— RPU v2: бюджет удачи, потолок (ceiling), комбек после проигрышей, hot/cold анализ, интеграция с promo-фазами — Bank API: mock-банк для карты (депозит/вывод с балансом 1000₽) — Promo-сервис: автогенерация промокодов каждый час, типы (card_to_site, luck_boost, ceiling_boost, free_case, reset_streak) — Promo-фазы: LUCKY/NEUTRAL/UNLUCKY, переключение каждые 1-6ч — Admin: история транзакций (user_history), управление промокодами, управление promo-фазами, API фильтров/коллекций, RPU info — Database: WAL mode, новые поля RPU v2, TransactionLog, карточный баланс, статистики (streaks, ceiling, budget) — Frontend: /deposit, /promo/activate, /withdraw, /card-balance, /online-count, /rpu-info, lifespan вместо startup event — UI: user_history.html, улучшения профиля, кейсов, навигации — Cases: обновление cases.json (1892 строки новых данных) — Achievements: кэш коллекций, оптимизация is_custom проверок — Sounds: дополнительные звуковые эффекты — Upgrade: интеграция с RPU (множители, streaks) — .gitignore: исключены .db, .bak, __pycache__
353 lines
12 KiB
Python
353 lines
12 KiB
Python
"""
|
|
Promo Service — логика промокодов и депозитов.
|
|
"""
|
|
|
|
from datetime import datetime, timedelta
|
|
import random
|
|
from typing import Optional
|
|
from sqlalchemy.orm import Session
|
|
from database import User, PromoCode, TransactionLog
|
|
from bank_api import bank_client
|
|
from promo_phase import get_promo_effect_for_user, get_current_phase
|
|
|
|
DAILY_DEPOSIT_LIMIT = 999999999.0 # без лимита
|
|
|
|
# ── Авто-генерация промокодов ──
|
|
PROMO_WORDS = ["storm", "glitch", "frost", "blaze", "cipher", "nexus", "phantom", "prism"]
|
|
PROMO_PERCENTS = [10, 15, 20, 21, 24, 25]
|
|
|
|
# Хранилище текущего авто-промокода (in-memory кэш)
|
|
_current_auto_promo: Optional[dict] = None
|
|
|
|
|
|
def generate_auto_promo_code() -> str:
|
|
"""Генерирует промокод формата слово-процент, например 'storm-21'"""
|
|
word = random.choice(PROMO_WORDS)
|
|
pct = random.choice(PROMO_PERCENTS)
|
|
return f"{word}-{pct}"
|
|
|
|
|
|
def get_or_create_auto_promo(db: Session) -> PromoCode:
|
|
"""
|
|
Возвращает активный авто-сгенерированный промокод.
|
|
Если его нет или он истёк (>1ч) — создаёт новый.
|
|
"""
|
|
global _current_auto_promo
|
|
now = datetime.utcnow()
|
|
|
|
# Ищем существующий авто-промокод, созданный <1 часа назад
|
|
candidates = db.query(PromoCode).filter(
|
|
PromoCode.reward_type == "card_to_site",
|
|
PromoCode.is_active == True,
|
|
PromoCode.created_by == -1 # маркер авто-генерации
|
|
).order_by(PromoCode.created_at.desc()).limit(1).all()
|
|
|
|
if candidates:
|
|
promo = candidates[0]
|
|
age = (now - promo.created_at).total_seconds()
|
|
if age < 3600: # меньше часа — живой
|
|
_current_auto_promo = {
|
|
"code": promo.code,
|
|
"reward_amount": promo.reward_amount,
|
|
"created_at": promo.created_at.isoformat(),
|
|
}
|
|
return promo
|
|
|
|
# Создаём новый
|
|
code = generate_auto_promo_code()
|
|
pct = int(code.split("-")[1])
|
|
reward = pct # процент от daily лимита как сумма бонуса
|
|
|
|
new_promo = PromoCode(
|
|
code=code,
|
|
reward_type="card_to_site",
|
|
reward_amount=reward,
|
|
reward_data="auto",
|
|
max_uses=999,
|
|
used_count=0,
|
|
start_date=now,
|
|
end_date=now + timedelta(hours=1),
|
|
is_active=True,
|
|
created_by=-1,
|
|
)
|
|
db.add(new_promo)
|
|
db.commit()
|
|
|
|
_current_auto_promo = {
|
|
"code": new_promo.code,
|
|
"reward_amount": new_promo.reward_amount,
|
|
"created_at": new_promo.created_at.isoformat(),
|
|
}
|
|
return new_promo
|
|
|
|
|
|
def get_current_auto_promo(db: Session) -> Optional[dict]:
|
|
"""Возвращает информацию о текущем авто-промокоде."""
|
|
promo = get_or_create_auto_promo(db)
|
|
now = datetime.utcnow()
|
|
age = (now - promo.created_at).total_seconds()
|
|
remaining = max(0, 3600 - age)
|
|
return {
|
|
"code": promo.code,
|
|
"reward_amount": promo.reward_amount,
|
|
"created_at": promo.created_at.isoformat(),
|
|
"expires_in_seconds": int(remaining),
|
|
"used_count": promo.used_count,
|
|
}
|
|
|
|
|
|
def validate_promo_code(db: Session, code: str) -> Optional[dict]:
|
|
"""Проверяет промокод, возвращает данные или None"""
|
|
promo = db.query(PromoCode).filter(
|
|
PromoCode.code == code,
|
|
PromoCode.is_active == True
|
|
).first()
|
|
|
|
if not promo:
|
|
return None
|
|
|
|
now = datetime.utcnow()
|
|
|
|
if promo.end_date and now > promo.end_date:
|
|
return {"error": "Срок действия промокода истёк"}
|
|
|
|
if promo.start_date and now < promo.start_date:
|
|
return {"error": "Промокод ещё не активен"}
|
|
|
|
if promo.max_uses > 0 and promo.used_count >= promo.max_uses:
|
|
return {"error": "Промокод уже использован максимальное количество раз"}
|
|
|
|
return {
|
|
"id": promo.id,
|
|
"code": promo.code,
|
|
"reward_type": promo.reward_type,
|
|
"reward_amount": promo.reward_amount,
|
|
"reward_data": promo.reward_data,
|
|
}
|
|
|
|
|
|
async def activate_promo_code(db: Session, user_id: int, code: str, amount: float = 0) -> dict:
|
|
"""
|
|
Активирует промокод.
|
|
Типы: card_to_site, luck_boost, ceiling_boost, free_case, reset_streak
|
|
"""
|
|
promo_data = validate_promo_code(db, code)
|
|
if not promo_data:
|
|
return {"success": False, "error": "Промокод не найден"}
|
|
if "error" in promo_data:
|
|
return {"success": False, "error": promo_data["error"]}
|
|
|
|
promo = db.query(PromoCode).filter(PromoCode.id == promo_data["id"]).first()
|
|
if not promo:
|
|
return {"success": False, "error": "Ошибка загрузки промокода"}
|
|
|
|
user = db.query(User).filter(User.id == user_id).first()
|
|
if not user:
|
|
return {"success": False, "error": "Пользователь не найден"}
|
|
|
|
rpu = user.rpu_settings
|
|
result = {"success": True, "code": code, "type": promo.reward_type}
|
|
promo.used_count += 1
|
|
|
|
# Записываем транзакцию
|
|
txn = TransactionLog(
|
|
user_id=user_id,
|
|
tx_type="promo_bonus",
|
|
amount=0,
|
|
promo_code=code,
|
|
details=f"Промокод: {code} ({promo.reward_type})"
|
|
)
|
|
db.add(txn)
|
|
|
|
if promo.reward_type == "card_to_site":
|
|
if amount <= 0:
|
|
return {"success": False, "error": "Укажите сумму депозита"}
|
|
|
|
pct = promo.reward_amount # например 24 → 24%
|
|
bonus = round(amount * pct / 100, 2)
|
|
total = round(amount + bonus, 2)
|
|
|
|
# Проверяем баланс карты (требуем только сумму депозита)
|
|
bank_balance = await bank_client.get_balance(user_id)
|
|
|
|
if bank_balance < amount:
|
|
return {"success": False, "error": f"Недостаточно средств на карте. Нужно: {amount}₽"}
|
|
|
|
# Списываем с карты только депозит
|
|
await bank_client.transfer_to_site(user_id, amount, promo_code=code)
|
|
|
|
# Зачисляем на сайт (депозит + бонус от платформы)
|
|
user.balance += total
|
|
user.total_deposited += amount
|
|
|
|
txn.amount = total
|
|
txn.details = f"Депозит {amount}₽ + бонус {pct}% ({bonus}₽) по промокоду {code}"
|
|
|
|
result["amount"] = total
|
|
result["deposit"] = amount
|
|
result["bonus"] = bonus
|
|
result["new_balance"] = user.balance
|
|
|
|
elif promo.reward_type == "luck_boost":
|
|
boost = promo.reward_amount / 100.0 # например 20 → 0.2
|
|
if rpu:
|
|
rpu.luck_multiplier = min(3.0, rpu.luck_multiplier + boost)
|
|
result["boost"] = boost
|
|
|
|
elif promo.reward_type == "ceiling_boost":
|
|
boost_mult = promo.reward_amount / 100.0 + 1.0 # 50 → 1.5
|
|
if rpu:
|
|
rpu.ceiling_multiplier = min(3.0, rpu.ceiling_multiplier * boost_mult)
|
|
result["ceiling_boost"] = boost_mult
|
|
|
|
elif promo.reward_type == "free_case":
|
|
# Бесплатное открытие — просто отмечаем в результате
|
|
result["free_case"] = promo.reward_data or ""
|
|
|
|
elif promo.reward_type == "reset_streak":
|
|
if rpu:
|
|
rpu.current_streak = 0
|
|
rpu.consecutive_loss_count = 0
|
|
rpu.consecutive_loss_value = 0.0
|
|
result["reset_streak"] = True
|
|
|
|
db.commit()
|
|
return result
|
|
|
|
|
|
async def direct_deposit(db: Session, user_id: int, amount: float, promo_code: str = "") -> dict:
|
|
"""
|
|
Прямой депозит с карты на сайт.
|
|
Если указан промокод — применяет бонус поверх депозита.
|
|
"""
|
|
if amount <= 0:
|
|
return {"success": False, "error": "Сумма должна быть положительной"}
|
|
|
|
user = db.query(User).filter(User.id == user_id).first()
|
|
if not user:
|
|
return {"success": False, "error": "Пользователь не найден"}
|
|
|
|
# Проверка промокода (если указан)
|
|
bonus = 0
|
|
promo_pct = 0
|
|
if promo_code:
|
|
promo_data = validate_promo_code(db, promo_code)
|
|
if promo_data and "error" not in promo_data:
|
|
promo = db.query(PromoCode).filter(PromoCode.id == promo_data["id"]).first()
|
|
if promo and promo.reward_type == "card_to_site":
|
|
promo_pct = promo.reward_amount
|
|
bonus = round(amount * promo_pct / 100, 2)
|
|
promo.used_count += 1
|
|
|
|
total = round(amount + bonus, 2)
|
|
|
|
# Проверка дневного лимита
|
|
today = datetime.utcnow().date()
|
|
daily_total = db.query(TransactionLog).filter(
|
|
TransactionLog.user_id == user_id,
|
|
TransactionLog.tx_type == "card_to_site",
|
|
).all()
|
|
|
|
today_deposits = sum(
|
|
t.amount for t in daily_total
|
|
if t.created_at.date() == today
|
|
)
|
|
|
|
if today_deposits + amount > DAILY_DEPOSIT_LIMIT:
|
|
remaining = max(0, DAILY_DEPOSIT_LIMIT - today_deposits)
|
|
return {
|
|
"success": False,
|
|
"error": f"Дневной лимит депозита: {remaining:.2f}₽"
|
|
}
|
|
|
|
# Проверяем баланс карты
|
|
bank_balance = await bank_client.get_balance(user_id)
|
|
|
|
if bank_balance < amount:
|
|
return {"success": False, "error": "Недостаточно средств на карте"}
|
|
|
|
# Списываем с карты (только депозит, бонус от платформы)
|
|
await bank_client.transfer_to_site(user_id, amount)
|
|
|
|
# Зачисляем на сайт
|
|
user.balance += total
|
|
user.total_deposited += amount
|
|
user.last_deposit_date = datetime.utcnow()
|
|
|
|
details = f"Прямой депозит: +{amount}₽"
|
|
if bonus > 0:
|
|
details += f" + бонус {promo_pct}% ({bonus}₽)"
|
|
|
|
txn = TransactionLog(
|
|
user_id=user_id,
|
|
tx_type="card_to_site",
|
|
amount=total,
|
|
promo_code=promo_code if promo_code else None,
|
|
details=details
|
|
)
|
|
db.add(txn)
|
|
db.commit()
|
|
|
|
return {
|
|
"success": True,
|
|
"amount": total,
|
|
"deposit": amount,
|
|
"bonus": bonus,
|
|
"new_balance": user.balance,
|
|
"daily_remaining": max(0, DAILY_DEPOSIT_LIMIT - today_deposits - amount)
|
|
}
|
|
|
|
|
|
async def withdraw_item(db: Session, user_id: int, inventory_item_id: int) -> dict:
|
|
"""
|
|
Вывод скина: продажа предмета из инвентаря → зачисление на карту (с комиссией).
|
|
"""
|
|
from database import InventoryItem
|
|
|
|
item = db.query(InventoryItem).filter(
|
|
InventoryItem.id == inventory_item_id,
|
|
InventoryItem.user_id == user_id
|
|
).first()
|
|
|
|
if not item:
|
|
return {"success": False, "error": "Предмет не найден в инвентаре"}
|
|
|
|
price = item.price_rub or 0
|
|
if price <= 0:
|
|
return {"success": False, "error": "Предмет не имеет цены"}
|
|
|
|
user = db.query(User).filter(User.id == user_id).first()
|
|
|
|
result = await bank_client.withdraw_to_card(user_id, price)
|
|
|
|
if not result["success"]:
|
|
return {"success": False, "error": "Ошибка вывода"}
|
|
|
|
# Обновляем статистику пользователя
|
|
user.total_withdrawn += result["net"] # net = price - fee
|
|
db.delete(item)
|
|
|
|
txn = TransactionLog(
|
|
user_id=user_id,
|
|
tx_type="site_to_card",
|
|
amount=price,
|
|
fee=result["fee"],
|
|
item_name=item.market_hash_name,
|
|
inventory_item_id=inventory_item_id,
|
|
details=f"Вывод: {item.market_hash_name} за {price}₽ (комиссия {result['fee']}₽)"
|
|
)
|
|
db.add(txn)
|
|
db.commit()
|
|
|
|
from rpu import check_withdrawal_penalty
|
|
check_withdrawal_penalty(db, user_id)
|
|
|
|
return {
|
|
"success": True,
|
|
"item_name": item.market_hash_name,
|
|
"gross": price,
|
|
"fee": result["fee"],
|
|
"net": result["net"],
|
|
"new_card_balance": result["new_card_balance"]
|
|
}
|