RPU v2, promo/bank система, админка, оптимизации

— 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__
This commit is contained in:
2026-07-25 13:11:08 +00:00
parent 6638ab99f3
commit 47c7f17c90
30 changed files with 5862 additions and 1295 deletions
+416 -65
View File
@@ -8,7 +8,7 @@ import json
from datetime import datetime
from pathlib import Path
from database import get_db, User, InventoryItem, CaseOpening, Contract
from database import get_db, User, InventoryItem, CaseOpening, Contract, Upgrade, TransactionLog
from auth import get_current_admin
from rpu import get_user_rpu, calculate_rpu_level, get_rpu_description
@@ -184,6 +184,48 @@ async def admin_user_detail(
"inventory": inventory
})
@admin_router.get("/user/{user_id}/history", response_class=HTMLResponse)
async def admin_user_history(
user_id: int,
request: Request,
db: Session = Depends(get_db),
admin: User = Depends(get_current_admin)
):
target_user = db.query(User).filter(User.id == user_id).first()
if not target_user:
raise HTTPException(404, "Пользователь не найден")
# Все открытия кейсов
openings = db.query(CaseOpening).filter(
CaseOpening.user_id == user_id
).order_by(desc(CaseOpening.opened_at)).limit(200).all()
# Все апгрейды
upgrades = db.query(Upgrade).filter(
Upgrade.user_id == user_id
).order_by(desc(Upgrade.created_at)).limit(200).all()
# Все транзакции
transactions = db.query(TransactionLog).filter(
TransactionLog.user_id == user_id
).order_by(desc(TransactionLog.created_at)).limit(200).all()
from rpu import calculate_ceiling, get_user_total_value, get_user_rpu
ceiling = calculate_ceiling(db, user_id)
total_value = get_user_total_value(db, user_id)
rpu = get_user_rpu(db, user_id)
return templates.TemplateResponse(request, "admin/user_history.html", {
"user": admin,
"target_user": target_user,
"openings": openings,
"upgrades": upgrades,
"transactions": transactions,
"ceiling": ceiling,
"total_value": total_value,
"rpu": rpu,
})
@admin_router.get("/cases", response_class=HTMLResponse)
async def admin_cases(
request: Request,
@@ -197,6 +239,7 @@ async def admin_cases(
for case in cases:
if not isinstance(case.get('items'), list):
case['items'] = []
case['items_count'] = len(case['items'])
all_items = []
for i, item in enumerate(ALL_ITEMS[:500]):
@@ -272,7 +315,7 @@ async def admin_give_item(
if not item_data:
return JSONResponse(status_code=404, content={"error": "Предмет не найден"})
item_float = generate_item_float(item_data.get("rarity", "Unknown"))
item_float = item_data.get("mid_float_used") or generate_item_float(item_data.get("rarity", "Unknown"))
inventory_item = InventoryItem(
user_id=user_id,
@@ -282,6 +325,8 @@ async def admin_give_item(
wear=item_data.get("wear", "Unknown"),
float_value=item_float,
type=item_data.get("type", "Normal"),
image_url=item_data.get("image_url", ""),
price_rub=item_data.get("price_rub", 0),
obtained_from="admin"
)
db.add(inventory_item)
@@ -370,6 +415,13 @@ async def admin_get_user_rpu(
rpu = get_user_rpu(db, user_id)
from promo_phase import get_promo_phase_info
promo = get_promo_phase_info()
# Потолок
from rpu import calculate_ceiling
ceiling = calculate_ceiling(db, user_id)
return {
"user_id": user_id,
"username": target_user.username,
@@ -387,7 +439,44 @@ async def admin_get_user_rpu(
"rpu_level": calculate_rpu_level(rpu),
"stats": {
"total_spent": rpu.total_spent,
"total_opened": rpu.total_opened
"total_opened": rpu.total_opened,
"total_value_received": rpu.total_value_received,
"current_streak": rpu.current_streak,
"best_streak": rpu.best_streak,
"worst_streak": rpu.worst_streak
},
# ── RPU v2 ──
"v2": {
"ceiling": {
"total_deposited": target_user.total_deposited or 0,
"ceiling_multiplier": rpu.ceiling_multiplier,
"current_ceiling": ceiling,
"break_count": rpu.ceiling_break_count,
"break_session": rpu.ceiling_break_session,
},
"session": {
"luck_budget": round(rpu.luck_budget, 1),
"session_spent": round(rpu.session_spent, 0),
"session_won": round(rpu.session_won, 0),
"reset_date": rpu.session_reset_date.isoformat() if rpu.session_reset_date else None,
},
"comeback": {
"active": rpu.comeback_active,
"openings_left": rpu.comeback_openings_left,
"multiplier": rpu.comeback_multiplier,
"consecutive_loss_value": round(rpu.consecutive_loss_value or 0, 0),
"consecutive_loss_count": rpu.consecutive_loss_count or 0,
},
"hot_cold": {
"hot_score": round(rpu.hot_score or 0, 2),
"last_activity": rpu.last_activity_date.isoformat() if rpu.last_activity_date else None,
},
"promo_phase": {
"phase": promo["phase"],
"effect": promo["effect"],
"next_switch": promo["next_switch"],
"remaining_minutes": promo["remaining_minutes"],
}
}
}
@@ -481,30 +570,30 @@ async def admin_set_rpu_preset(
@admin_router.post("/api/cases/create")
async def admin_create_case(
name: str = Form(...),
case_name: str = Form(...),
display_name: str = Form(...),
price: float = Form(...),
price_open: float = Form(...),
description: str = Form(""),
image_url: str = Form(""),
items: str = Form(...),
items_json: str = Form(...),
admin: User = Depends(get_current_admin)
):
cases = load_cases_config()
for case in cases:
if case['name'] == name:
if case['name'] == case_name:
return JSONResponse(status_code=400, content={"error": "Кейс с таким именем уже существует"})
try:
item_ids = json.loads(items)
parsed_items = json.loads(items_json)
except:
return JSONResponse(status_code=400, content={"error": "Неверный формат списка предметов"})
new_case = {
"name": name,
"name": case_name,
"display_name": display_name,
"price_open": price,
"items": item_ids,
"price_open": price_open,
"items": parsed_items,
"image_url": image_url,
"description": description
}
@@ -522,10 +611,11 @@ async def admin_create_case(
async def admin_update_case(
case_name: str,
display_name: str = Form(...),
price: float = Form(...),
price_open: float = Form(...),
description: str = Form(""),
image_url: str = Form(""),
items: str = Form(...),
items_json: str = Form(...),
new_name: str = Form(""),
admin: User = Depends(get_current_admin)
):
cases = load_cases_config()
@@ -533,18 +623,34 @@ async def admin_update_case(
for case in cases:
if case['name'] == case_name:
try:
item_ids = json.loads(items)
parsed_items = json.loads(items_json)
except:
return JSONResponse(status_code=400, content={"error": "Неверный формат списка предметов"})
target_name = new_name or case_name
# Проверяем конфликт имени при переименовании
if new_name and new_name != case_name:
for c in cases:
if c['name'] == new_name:
return JSONResponse(status_code=400, content={"error": "Кейс с таким именем уже существует"})
case['name'] = new_name
case['display_name'] = display_name
case['price_open'] = price
case['price_open'] = price_open
case['description'] = description
case['image_url'] = image_url
case['items'] = item_ids
case['items'] = parsed_items
save_cases_config(cases)
# Обновляем mainpage если переименован
if new_name and new_name != case_name:
mainpage = load_mainpage_config()
for section in mainpage:
section['cases'] = [new_name if c == case_name else c for c in section['cases']]
save_mainpage_config(mainpage)
return JSONResponse({
"success": True,
"case": case,
@@ -594,58 +700,91 @@ async def admin_search_items(
q: str = "",
min_price: float = 0,
max_price: float = 0,
limit: int = 200
limit: int = 200,
rarity_filter: str = "",
collection_filter: str = "",
type_filter: str = "",
weapon_filter: str = "",
sort_by: str = "",
sort_order: str = "asc",
include_subvariants: bool = False
):
"""Поиск предметов для добавления в кейс / апгрейда"""
"""Поиск предметов для добавления в кейс / апгрейда."""
from frontend import ALL_ITEMS
from backend import COLLECTIONS_INDEX
query = q.lower().strip()
results = []
seen_bases = set()
def accept_item(item, idx):
if not include_subvariants and item.get("is_subvariant"):
return False
if min_price > 0 and item.get("price_rub", 0) < min_price:
return False
if max_price > 0 and item.get("price_rub", 0) > max_price:
return False
if rarity_filter:
item_rarity = item.get("rarity", "").lower()
if rarity_filter.lower() not in item_rarity:
return False
if collection_filter:
item_coll = item.get("collection", "").lower()
if collection_filter.lower() not in item_coll:
return False
if type_filter:
item_type = item.get("type", "").lower()
if type_filter.lower() != item_type:
return False
if weapon_filter:
item_weapon = item.get("weapon", "").lower()
if weapon_filter.lower() != item_weapon:
return False
return True
def make_result(item, idx):
name = item.get("market_hash_name", "Unknown")
dedup_key = f"{name}|{item.get('weapon','')}|{item.get('pattern','')}|{item.get('rarity','')}"
if dedup_key in seen_bases:
return None
seen_bases.add(dedup_key)
return {
"id": item.get("_id", idx),
"name": name,
"rarity": item.get("rarity", "Unknown"),
"image_url": item.get("image_url", ""),
"price_rub": item.get("price_rub", 0),
"weapon": item.get("weapon", ""),
"pattern": item.get("pattern", ""),
"collection": item.get("collection", ""),
"type": item.get("type", "Normal"),
}
if query and len(query) < 2:
return []
if not query or len(query) < 2:
# Без поиска возвращаем разнообразные предметы со всего ассортимента
# Если есть ценовой фильтр — проходим по всем, иначе шагами
if min_price > 0 or max_price > 0:
if any([min_price > 0, max_price > 0, rarity_filter, collection_filter,
type_filter, weapon_filter]):
for idx, item in enumerate(ALL_ITEMS):
if accept_item(item, idx):
item_id = item.get("_id", idx)
results.append({
"id": item_id,
"name": item.get("market_hash_name", "Unknown"),
"rarity": item.get("rarity", "Unknown"),
"image_url": item.get("image_url", ""),
"price_rub": item.get("price_rub", 0),
"weapon": item.get("weapon", ""),
"pattern": item.get("pattern", "")
})
if not accept_item(item, idx):
continue
r = make_result(item, idx)
if r:
results.append(r)
if len(results) >= limit:
break
else:
step = max(1, len(ALL_ITEMS) // limit)
for idx in range(0, len(ALL_ITEMS), step):
if not accept_item(ALL_ITEMS[idx], idx):
continue
item = ALL_ITEMS[idx]
item_id = item.get("_id", idx)
results.append({
"id": item_id,
"name": item.get("market_hash_name", "Unknown"),
"rarity": item.get("rarity", "Unknown"),
"image_url": item.get("image_url", ""),
"price_rub": item.get("price_rub", 0),
"weapon": item.get("weapon", ""),
"pattern": item.get("pattern", "")
})
if len(results) >= 200:
break
if not accept_item(item, idx):
continue
r = make_result(item, idx)
if r:
results.append(r)
if len(results) >= limit:
break
else:
for idx, item in enumerate(ALL_ITEMS):
if not accept_item(item, idx):
@@ -653,24 +792,113 @@ async def admin_search_items(
name = item.get("market_hash_name", "").lower()
weapon = item.get("weapon", "").lower()
pattern = item.get("pattern", "").lower()
if query in name or query in weapon or query in pattern:
item_id = item.get("_id", idx)
results.append({
"id": item_id,
"name": item.get("market_hash_name", "Unknown"),
"rarity": item.get("rarity", "Unknown"),
"image_url": item.get("image_url", ""),
"price_rub": item.get("price_rub", 0),
"weapon": item.get("weapon", ""),
"pattern": item.get("pattern", "")
})
if len(results) >= 200:
break
coll = item.get("collection", "").lower()
if query in name or query in weapon or query in pattern or query in coll:
r = make_result(item, idx)
if r:
results.append(r)
if len(results) >= limit:
break
# Сортировка
if sort_by in ("price_rub", "name", "rarity"):
reverse = sort_order == "desc"
if sort_by == "rarity":
rarity_rank = {
"Consumer Grade": 0, "Industrial Grade": 1,
"Mil-Spec": 2, "Mil-Spec Grade": 2,
"Restricted": 3, "Classified": 4,
"Covert": 5, "Rare Special Item": 6,
"Extraordinary": 6, "Contraband": 7
}
results.sort(key=lambda x: rarity_rank.get(x.get("rarity", ""), 0),
reverse=reverse)
else:
results.sort(key=lambda x: x.get(sort_by, ""), reverse=reverse)
return results[:200]
@admin_router.get("/api/items/filters")
async def admin_items_filters():
"""Возвращает списки уникальных значений для фильтров"""
from frontend import ALL_ITEMS
weapons = set()
types = set()
for item in ALL_ITEMS:
if item.get("is_subvariant"):
continue
w = item.get("weapon", "")
if w:
weapons.add(w)
t = item.get("type", "")
if t:
types.add(t)
return {
"weapons": sorted(weapons),
"types": sorted(types),
}
@admin_router.get("/api/collections")
async def admin_list_collections():
"""Возвращает список всех коллекций для фильтра"""
from backend import COLLECTIONS_INDEX
return {"collections": sorted(COLLECTIONS_INDEX.keys())}
@admin_router.get("/api/items/by-collection")
async def admin_items_by_collection(
collection: str = "",
rarity_filter: str = "",
type_filter: str = "",
weapon_filter: str = "",
include_subvariants: bool = False
):
"""Возвращает предметы из указанной коллекции"""
from backend import COLLECTIONS_INDEX
from frontend import get_item
results = []
seen_bases = set()
if collection not in COLLECTIONS_INDEX:
return results
for idx in COLLECTIONS_INDEX[collection]:
item = get_item(idx)
if not item:
continue
if not include_subvariants and item.get("is_subvariant"):
continue
if rarity_filter:
if rarity_filter.lower() not in item.get("rarity", "").lower():
continue
if type_filter:
if type_filter.lower() != item.get("type", "").lower():
continue
if weapon_filter:
if weapon_filter.lower() != item.get("weapon", "").lower():
continue
name = item.get("market_hash_name", "Unknown")
dedup_key = f"{name}|{item.get('weapon','')}|{item.get('pattern','')}|{item.get('rarity','')}"
if dedup_key in seen_bases:
continue
seen_bases.add(dedup_key)
results.append({
"id": item.get("_id", idx),
"name": name,
"rarity": item.get("rarity", "Unknown"),
"image_url": item.get("image_url", ""),
"price_rub": item.get("price_rub", 0),
"weapon": item.get("weapon", ""),
"pattern": item.get("pattern", ""),
"collection": item.get("collection", ""),
"type": item.get("type", "Normal"),
})
return results
@admin_router.post("/api/mainpage/section/add")
async def admin_add_mainpage_section(
tab: str = Form(...),
@@ -787,6 +1015,129 @@ async def admin_get_item(
return JSONResponse(status_code=404, content={"error": "Предмет не найден"})
# ========== API ПРОМОКОДЫ ==========
@admin_router.get("/api/promocodes")
async def admin_list_promocodes(
db: Session = Depends(get_db),
admin: User = Depends(get_current_admin)
):
codes = db.query(PromoCode).order_by(desc(PromoCode.created_at)).limit(100).all()
return [{
"id": c.id,
"code": c.code,
"reward_type": c.reward_type,
"reward_amount": c.reward_amount,
"max_uses": c.max_uses,
"used_count": c.used_count,
"start_date": c.start_date.isoformat() if c.start_date else None,
"end_date": c.end_date.isoformat() if c.end_date else None,
"is_active": c.is_active,
} for c in codes]
@admin_router.post("/api/promocodes/create")
async def admin_create_promocode(
code: str = Form(...),
reward_type: str = Form(...),
reward_amount: float = Form(0),
reward_data: str = Form(""),
max_uses: int = Form(1),
end_date_str: str = Form(""),
is_active: bool = Form(True),
db: Session = Depends(get_db),
admin: User = Depends(get_current_admin)
):
existing = db.query(PromoCode).filter(PromoCode.code == code).first()
if existing:
return JSONResponse(status_code=400, content={"error": "Промокод с таким кодом уже существует"})
end_date = None
if end_date_str:
try:
end_date = datetime.fromisoformat(end_date_str)
except:
pass
promo = PromoCode(
code=code,
reward_type=reward_type,
reward_amount=reward_amount,
reward_data=reward_data,
max_uses=max_uses,
end_date=end_date,
is_active=is_active,
created_by=admin.id,
)
db.add(promo)
db.commit()
return {"success": True, "message": f"Промокод '{code}' создан"}
@admin_router.post("/api/promocodes/toggle/{promo_id}")
async def admin_toggle_promocode(
promo_id: int,
db: Session = Depends(get_db),
admin: User = Depends(get_current_admin)
):
promo = db.query(PromoCode).filter(PromoCode.id == promo_id).first()
if not promo:
return JSONResponse(status_code=404, content={"error": "Промокод не найден"})
promo.is_active = not promo.is_active
db.commit()
return {"success": True, "is_active": promo.is_active}
@admin_router.post("/api/promocodes/delete/{promo_id}")
async def admin_delete_promocode(
promo_id: int,
db: Session = Depends(get_db),
admin: User = Depends(get_current_admin)
):
promo = db.query(PromoCode).filter(PromoCode.id == promo_id).first()
if not promo:
return JSONResponse(status_code=404, content={"error": "Промокод не найден"})
db.delete(promo)
db.commit()
return {"success": True, "message": "Промокод удален"}
@admin_router.get("/api/promo-phase")
async def admin_get_promo_phase(
admin: User = Depends(get_current_admin)
):
"""Информация о текущей промо-фазе (для админки)"""
from promo_phase import get_phase_debug_info
return get_phase_debug_info()
# ========== API ТРАНЗАКЦИИ ==========
@admin_router.get("/api/transactions")
async def admin_list_transactions(
user_id: Optional[int] = None,
limit: int = 100,
db: Session = Depends(get_db),
admin: User = Depends(get_current_admin)
):
query = db.query(TransactionLog)
if user_id:
query = query.filter(TransactionLog.user_id == user_id)
txns = query.order_by(desc(TransactionLog.created_at)).limit(limit).all()
return [{
"id": t.id,
"user_id": t.user_id,
"tx_type": t.tx_type,
"amount": t.amount,
"fee": t.fee,
"promo_code": t.promo_code,
"item_name": t.item_name,
"details": t.details,
"created_at": t.created_at.isoformat(),
} for t in txns]
@admin_router.post("/api/reset-password/{user_id}")
async def admin_reset_password(
user_id: int,