Compare commits
59 Commits
main
...
99d9741e97
| Author | SHA1 | Date | |
|---|---|---|---|
| 99d9741e97 | |||
| bddd68bc25 | |||
| 520ad99099 | |||
| 419a973d49 | |||
| 5db5b98dbc | |||
| 2322b27ace | |||
| 29bf215120 | |||
| 51e903e249 | |||
| bfacff902f | |||
| 745b115adc | |||
| cbc586683a | |||
| bb2681bff8 | |||
| 9954599a33 | |||
| f174761ec6 | |||
| e035334417 | |||
| 41711d68c8 | |||
| bb74dbbba7 | |||
| 80a9fbe148 | |||
| d16cbd289b | |||
| a9ebb5b5f9 | |||
| 1bacb8d78f | |||
| 13fcd40eb4 | |||
| c111a15d5b | |||
| 04cf6a325a | |||
| 4ef3064011 | |||
| f8550c9dc8 | |||
| 67d85a71c1 | |||
| 04dcf7bf9d | |||
| cbec2203cd | |||
| 66da0d0e27 | |||
| abf93a91e8 | |||
| 4727184182 | |||
| 7aea18c7fb | |||
| c7454cbb62 | |||
| 9ff2319990 | |||
| 3aac426364 | |||
| f3d86ffdde | |||
| fe598f94a3 | |||
| 845291acab | |||
| f11e60529b | |||
| a0808d29fa | |||
| d4b1c1f5ee | |||
| e7ed7ab977 | |||
| f720b51c60 | |||
| 38f6b43718 | |||
| 0da03f1dcd | |||
| 2073fbece9 | |||
| 26a96468cc | |||
| 8f0a5abfb3 | |||
| 14f7929e0f | |||
| 51135f6506 | |||
| 93fbd14cc4 | |||
| 730ee97666 | |||
| 0184fa9848 | |||
| 3bef4f0ba0 | |||
| e89cb58b11 | |||
| ca71883fe4 | |||
| 70db9e8a3d | |||
| fa9611cc99 |
81
app/api/admin_daily_quests.py
Normal file
81
app/api/admin_daily_quests.py
Normal file
@ -0,0 +1,81 @@
|
||||
from typing import List
|
||||
from fastapi import APIRouter, HTTPException, Body, Query
|
||||
from datetime import datetime, timezone
|
||||
from app.db.database import db
|
||||
|
||||
router = APIRouter(prefix="/api/admin/daily-quests", tags=["Admin Daily Quests"])
|
||||
|
||||
pool = db.daily_quests_pool
|
||||
|
||||
@router.get("/pool")
|
||||
async def list_pool(enabled: bool | None = Query(None)):
|
||||
q = {}
|
||||
if enabled is not None:
|
||||
q["enabled"] = enabled
|
||||
items = await pool.find(q, {"_id": 0}).sort("key", 1).to_list(1000)
|
||||
return {"ok": True, "items": items, "count": len(items)}
|
||||
|
||||
@router.post("/pool/upsert")
|
||||
async def upsert_pool_item(payload: dict = Body(...)):
|
||||
# минимальная валидация
|
||||
key = payload.get("key")
|
||||
if not key or not isinstance(key, str):
|
||||
raise HTTPException(status_code=400, detail="Missing key")
|
||||
|
||||
# дефолты
|
||||
payload.setdefault("enabled", True)
|
||||
payload.setdefault("weight", 1)
|
||||
payload.setdefault("min_required", 1)
|
||||
payload.setdefault("max_required", payload["min_required"])
|
||||
payload.setdefault("reward_per_unit", 1)
|
||||
|
||||
# тех.поля
|
||||
payload["updated_at"] = datetime.now(timezone.utc).replace(tzinfo=None)
|
||||
|
||||
await pool.update_one({"key": key}, {"$set": payload}, upsert=True)
|
||||
item = await pool.find_one({"key": key}, {"_id": 0})
|
||||
return {"ok": True, "item": item}
|
||||
|
||||
@router.post("/pool/bulk_upsert")
|
||||
async def bulk_upsert_pool_items(items: List[dict] = Body(...)):
|
||||
if not isinstance(items, list):
|
||||
raise HTTPException(status_code=400, detail="Body must be a list of objects")
|
||||
|
||||
out = []
|
||||
for payload in items:
|
||||
key = payload.get("key")
|
||||
if not key or not isinstance(key, str):
|
||||
continue
|
||||
|
||||
payload.setdefault("enabled", True)
|
||||
payload.setdefault("weight", 1)
|
||||
payload.setdefault("min_required", 1)
|
||||
payload.setdefault("max_required", payload["min_required"])
|
||||
payload.setdefault("reward_per_unit", 1)
|
||||
payload["updated_at"] = datetime.now(timezone.utc).replace(tzinfo=None)
|
||||
|
||||
await pool.update_one({"key": key}, {"$set": payload}, upsert=True)
|
||||
out.append(key)
|
||||
|
||||
return {"ok": True, "upserted": out, "count": len(out)}
|
||||
|
||||
@router.post("/pool/disable")
|
||||
async def disable_pool_item(key: str = Query(...)):
|
||||
res = await pool.update_one({"key": key}, {"$set": {"enabled": False}})
|
||||
if res.matched_count == 0:
|
||||
raise HTTPException(status_code=404, detail="Not found")
|
||||
return {"ok": True}
|
||||
|
||||
@router.post("/pool/enable")
|
||||
async def enable_pool_item(key: str = Query(...)):
|
||||
res = await pool.update_one({"key": key}, {"$set": {"enabled": True}})
|
||||
if res.matched_count == 0:
|
||||
raise HTTPException(status_code=404, detail="Not found")
|
||||
return {"ok": True}
|
||||
|
||||
@router.delete("/pool")
|
||||
async def delete_pool_item(key: str = Query(...)):
|
||||
res = await pool.delete_one({"key": key})
|
||||
if res.deleted_count == 0:
|
||||
raise HTTPException(status_code=404, detail="Not found")
|
||||
return {"ok": True}
|
||||
56
app/api/bonuses.py
Normal file
56
app/api/bonuses.py
Normal file
@ -0,0 +1,56 @@
|
||||
from fastapi import APIRouter, Query, Body
|
||||
from fastapi import HTTPException
|
||||
from datetime import datetime, timedelta
|
||||
from app.models.bonus import CreateBonusType, PurchaseBonus
|
||||
import uuid
|
||||
|
||||
router = APIRouter(
|
||||
prefix="/api/bonuses",
|
||||
tags=["Bonuses"]
|
||||
)
|
||||
|
||||
@router.post("/create")
|
||||
async def create_bonus_type(bonus: CreateBonusType):
|
||||
"""Создание нового типа бонуса (админ)"""
|
||||
from app.services.bonus import BonusService
|
||||
return await BonusService().create_bonus_type(bonus)
|
||||
|
||||
@router.get("/effects")
|
||||
async def get_user_effects(username: str):
|
||||
"""Получить активные эффекты пользователя для плагина"""
|
||||
from app.services.bonus import BonusService
|
||||
return await BonusService().get_user_active_effects(username)
|
||||
|
||||
@router.get("/types")
|
||||
async def get_bonus_types():
|
||||
"""Получить доступные типы бонусов"""
|
||||
from app.services.bonus import BonusService
|
||||
return await BonusService().list_available_bonuses()
|
||||
|
||||
@router.get("/user/{username}")
|
||||
async def get_user_bonuses(username: str):
|
||||
"""Получить активные бонусы пользователя"""
|
||||
from app.services.bonus import BonusService
|
||||
return await BonusService().get_user_bonuses(username)
|
||||
|
||||
@router.post("/purchase")
|
||||
async def purchase_bonus(purchase_bonus: PurchaseBonus):
|
||||
"""Купить бонус"""
|
||||
from app.services.bonus import BonusService
|
||||
return await BonusService().purchase_bonus(purchase_bonus.username, purchase_bonus.bonus_type_id)
|
||||
|
||||
@router.post("/upgrade")
|
||||
async def upgrade_user_bonus(username: str = Body(...), bonus_id: str = Body(...)):
|
||||
"""Улучшить существующий бонус"""
|
||||
from app.services.bonus import BonusService
|
||||
return await BonusService().upgrade_bonus(username, bonus_id)
|
||||
|
||||
@router.post("/toggle-activation")
|
||||
async def toggle_bonus_activation(username: str = Body(...), bonus_id: str = Body(...)):
|
||||
"""
|
||||
Переключить активность бонуса пользователя.
|
||||
Передаём username и bonus_id, is_active переключается на противоположное значение.
|
||||
"""
|
||||
from app.services.bonus import BonusService
|
||||
return await BonusService().toggle_bonus_activation(username, bonus_id)
|
||||
|
||||
38
app/api/case.py
Normal file
38
app/api/case.py
Normal file
@ -0,0 +1,38 @@
|
||||
from fastapi import APIRouter, Depends
|
||||
from typing import List
|
||||
from app.services.case import CaseService
|
||||
from app.models.case import CaseCreate, CaseUpdate
|
||||
|
||||
router = APIRouter(prefix="/cases", tags=["Cases"])
|
||||
|
||||
def get_case_service():
|
||||
return CaseService()
|
||||
|
||||
@router.get("/")
|
||||
async def list_cases(case_service: CaseService = Depends(get_case_service)):
|
||||
return await case_service.list_cases()
|
||||
|
||||
@router.post("/")
|
||||
async def create_case(case_data: CaseCreate, case_service: CaseService = Depends(get_case_service)):
|
||||
return await case_service.create_case(case_data)
|
||||
|
||||
@router.get("/{case_id}")
|
||||
async def get_case(case_id: str, case_service: CaseService = Depends(get_case_service)):
|
||||
return await case_service.get_case(case_id)
|
||||
|
||||
@router.put("/{case_id}")
|
||||
async def update_case(case_id: str, data: CaseUpdate, case_service: CaseService = Depends(get_case_service)):
|
||||
return await case_service.update_case(case_id, data)
|
||||
|
||||
@router.delete("/{case_id}")
|
||||
async def delete_case(case_id: str, case_service: CaseService = Depends(get_case_service)):
|
||||
return await case_service.delete_case(case_id)
|
||||
|
||||
@router.post("/{case_id}/open")
|
||||
async def open_case(
|
||||
case_id: str,
|
||||
username: str,
|
||||
server_ip: str,
|
||||
case_service: CaseService = Depends(get_case_service)
|
||||
):
|
||||
return await case_service.open_case(username=username, case_id=case_id, server_ip=server_ip)
|
||||
25
app/api/coins_ws.py
Normal file
25
app/api/coins_ws.py
Normal file
@ -0,0 +1,25 @@
|
||||
# app/api/coins_ws.py
|
||||
from fastapi import APIRouter, WebSocket, WebSocketDisconnect, Query
|
||||
from app.realtime.coins_hub import coins_hub
|
||||
|
||||
router = APIRouter(tags=["Coins WS"])
|
||||
|
||||
|
||||
@router.websocket("/ws/coins")
|
||||
async def coins_ws(
|
||||
websocket: WebSocket,
|
||||
username: str = Query(...),
|
||||
):
|
||||
await coins_hub.connect(username, websocket)
|
||||
try:
|
||||
while True:
|
||||
await websocket.receive_text() # ping / keep-alive
|
||||
except WebSocketDisconnect:
|
||||
pass
|
||||
finally:
|
||||
await coins_hub.disconnect(username, websocket)
|
||||
|
||||
|
||||
@router.get("/ws/coins/ping")
|
||||
async def ping():
|
||||
return {"ok": True}
|
||||
29
app/api/inventory.py
Normal file
29
app/api/inventory.py
Normal file
@ -0,0 +1,29 @@
|
||||
from fastapi import APIRouter, Depends
|
||||
from app.services.inventory import InventoryService
|
||||
from app.models.inventory import InventoryWithdrawRequest
|
||||
|
||||
router = APIRouter(prefix="/inventory", tags=["Inventory"])
|
||||
|
||||
def get_inventory_service():
|
||||
return InventoryService()
|
||||
|
||||
@router.get("/items")
|
||||
async def list_inventory_items(
|
||||
username: str,
|
||||
server_ip: str,
|
||||
page: int = 1,
|
||||
limit: int = 20,
|
||||
inventory: InventoryService = Depends(get_inventory_service),
|
||||
):
|
||||
return await inventory.list_items(username=username, server_ip=server_ip, page=page, limit=limit)
|
||||
|
||||
@router.post("/withdraw")
|
||||
async def withdraw_inventory_item(
|
||||
data: InventoryWithdrawRequest,
|
||||
inventory: InventoryService = Depends(get_inventory_service),
|
||||
):
|
||||
return await inventory.withdraw_item(
|
||||
username=data.username,
|
||||
item_id=data.item_id,
|
||||
server_ip=data.server_ip,
|
||||
)
|
||||
@ -80,3 +80,75 @@ async def update_item_price(
|
||||
"""Обновить цену предмета на торговой площадке"""
|
||||
from app.services.marketplace import MarketplaceService
|
||||
return await MarketplaceService().update_item_price(username, item_id, new_price)
|
||||
|
||||
@router.get("/items/by-seller/{username}")
|
||||
async def get_items_by_seller(
|
||||
username: str,
|
||||
server_ip: Optional[str] = None,
|
||||
page: int = Query(1, ge=1),
|
||||
limit: int = Query(20, ge=1, le=100)
|
||||
):
|
||||
"""Получить все товары, выставленные конкретным игроком"""
|
||||
from app.services.marketplace import MarketplaceService
|
||||
return await MarketplaceService().list_items_by_seller(
|
||||
username=username,
|
||||
server_ip=server_ip,
|
||||
page=page,
|
||||
limit=limit
|
||||
)
|
||||
|
||||
@router.get("/items/me")
|
||||
async def get_my_items(
|
||||
username: str = Query(...),
|
||||
server_ip: Optional[str] = None,
|
||||
page: int = Query(1, ge=1),
|
||||
limit: int = Query(20, ge=1, le=100)
|
||||
):
|
||||
"""Получить мои лоты на торговой площадке"""
|
||||
from app.services.marketplace import MarketplaceService
|
||||
return await MarketplaceService().list_items_by_seller(
|
||||
username=username,
|
||||
server_ip=server_ip,
|
||||
page=page,
|
||||
limit=limit
|
||||
)
|
||||
|
||||
@router.get("/operations/all")
|
||||
async def get_all_marketplace_operations(
|
||||
server_ip: Optional[str] = None,
|
||||
player_name: Optional[str] = None,
|
||||
status: Optional[str] = None,
|
||||
op_type: Optional[str] = None,
|
||||
page: int = Query(1, ge=1),
|
||||
limit: int = Query(20, ge=1, le=100),
|
||||
):
|
||||
"""Получить все операции маркетплейса (опционально: по игроку/статусу/типу)"""
|
||||
from app.services.marketplace import MarketplaceService
|
||||
return await MarketplaceService().list_operations(
|
||||
server_ip=server_ip,
|
||||
player_name=player_name,
|
||||
status=status,
|
||||
op_type=op_type,
|
||||
page=page,
|
||||
limit=limit
|
||||
)
|
||||
|
||||
@router.get("/operations/by-player/{username}")
|
||||
async def get_operations_by_player(
|
||||
username: str,
|
||||
server_ip: Optional[str] = None,
|
||||
status: Optional[str] = None,
|
||||
op_type: Optional[str] = None,
|
||||
page: int = Query(1, ge=1),
|
||||
limit: int = Query(20, ge=1, le=100),
|
||||
):
|
||||
"""Получить операции маркетплейса конкретного игрока"""
|
||||
from app.services.marketplace import MarketplaceService
|
||||
return await MarketplaceService().list_operations(
|
||||
server_ip=server_ip,
|
||||
player_name=username,
|
||||
status=status,
|
||||
op_type=op_type,
|
||||
page=page,
|
||||
limit=limit
|
||||
)
|
||||
23
app/api/marketplace_ws.py
Normal file
23
app/api/marketplace_ws.py
Normal file
@ -0,0 +1,23 @@
|
||||
from fastapi import APIRouter, WebSocket, WebSocketDisconnect, Query
|
||||
from app.realtime.marketplace_hub import marketplace_hub
|
||||
|
||||
router = APIRouter(tags=["Marketplace WS"])
|
||||
|
||||
@router.websocket("/ws/marketplace")
|
||||
async def marketplace_ws(
|
||||
websocket: WebSocket,
|
||||
server_ip: str = Query(...),
|
||||
):
|
||||
await marketplace_hub.connect(server_ip, websocket)
|
||||
try:
|
||||
# Можно принимать сообщения от клиента, но нам не обязательно.
|
||||
while True:
|
||||
await websocket.receive_text()
|
||||
except WebSocketDisconnect:
|
||||
pass
|
||||
finally:
|
||||
await marketplace_hub.disconnect(server_ip, websocket)
|
||||
|
||||
@router.get("/ws/marketplace/ping")
|
||||
async def ping():
|
||||
return {"ok": True}
|
||||
86
app/api/news.py
Normal file
86
app/api/news.py
Normal file
@ -0,0 +1,86 @@
|
||||
from fastapi import APIRouter, HTTPException, Query, Depends, Form
|
||||
from typing import List
|
||||
|
||||
from app.services.auth import AuthService
|
||||
from app.services.news import NewsService
|
||||
from app.models.news import NewsCreate, NewsUpdate, NewsInDB
|
||||
|
||||
router = APIRouter(tags=["News"])
|
||||
|
||||
news_service = NewsService()
|
||||
|
||||
# --- Публичные эндпоинты для лаунчера ---
|
||||
|
||||
@router.get("/news", response_model=List[NewsInDB])
|
||||
async def list_news(
|
||||
limit: int = Query(20, ge=1, le=100),
|
||||
skip: int = Query(0, ge=0),
|
||||
):
|
||||
"""
|
||||
Список опубликованных новостей (для лаунчера).
|
||||
"""
|
||||
return await news_service.list_news(limit=limit, skip=skip, include_unpublished=False)
|
||||
|
||||
|
||||
@router.get("/news/{news_id}", response_model=NewsInDB)
|
||||
async def get_news(news_id: str):
|
||||
"""
|
||||
Получить одну новость по id (для лаунчера).
|
||||
"""
|
||||
return await news_service.get_news(news_id)
|
||||
|
||||
# --- Админские эндпоинты (создание/редактирование) ---
|
||||
|
||||
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("/news", response_model=NewsInDB)
|
||||
async def create_news(
|
||||
accessToken: str = Form(...),
|
||||
clientToken: str = Form(...),
|
||||
title: str = Form(...),
|
||||
markdown: str = Form(...),
|
||||
preview: str = Form(""),
|
||||
is_published: bool = Form(True),
|
||||
):
|
||||
await validate_admin(accessToken, clientToken)
|
||||
payload = NewsCreate(
|
||||
title=title,
|
||||
markdown=markdown,
|
||||
preview=preview or None,
|
||||
is_published=is_published,
|
||||
tags=[],
|
||||
)
|
||||
return await news_service.create_news(payload)
|
||||
|
||||
|
||||
@router.put("/news/{news_id}", response_model=NewsInDB)
|
||||
async def update_news(
|
||||
news_id: str,
|
||||
accessToken: str = Form(...),
|
||||
clientToken: str = Form(...),
|
||||
title: str | None = Form(None),
|
||||
markdown: str | None = Form(None),
|
||||
preview: str | None = Form(None),
|
||||
is_published: bool | None = Form(None),
|
||||
):
|
||||
await validate_admin(accessToken, clientToken)
|
||||
payload = NewsUpdate(
|
||||
title=title,
|
||||
markdown=markdown,
|
||||
preview=preview,
|
||||
is_published=is_published,
|
||||
)
|
||||
return await news_service.update_news(news_id, payload)
|
||||
|
||||
|
||||
@router.delete("/news/{news_id}")
|
||||
async def delete_news(
|
||||
news_id: str,
|
||||
accessToken: str,
|
||||
clientToken: str,
|
||||
):
|
||||
await validate_admin(accessToken, clientToken)
|
||||
return await news_service.delete_news(news_id)
|
||||
89
app/api/promo.py
Normal file
89
app/api/promo.py
Normal file
@ -0,0 +1,89 @@
|
||||
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)
|
||||
|
||||
if max_uses is not None and max_uses <= 0:
|
||||
max_uses = None
|
||||
|
||||
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)
|
||||
|
||||
if max_uses is not None and max_uses <= 0:
|
||||
max_uses = None
|
||||
|
||||
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)
|
||||
@ -1,8 +1,12 @@
|
||||
import os
|
||||
import secrets
|
||||
from fastapi import APIRouter, HTTPException, Body, Response
|
||||
from app.models.user import UserCreate, UserLogin, VerifyCode
|
||||
from fastapi.params import Query
|
||||
from app.models.user import QrApprove, UserCreate, UserLogin, VerifyCode
|
||||
from app.models.request import ValidateRequest
|
||||
from app.services.auth import AuthService
|
||||
from app.db.database import users_collection, sessions_collection
|
||||
from app.db.database import db
|
||||
from datetime import datetime
|
||||
import json
|
||||
from fastapi import HTTPException
|
||||
@ -10,9 +14,13 @@ from datetime import datetime, timedelta
|
||||
from app.models.server.event import PlayerEvent, OnlinePlayersUpdate
|
||||
from app.models.server.playtime import PlayerSession, PlayerPlaytime
|
||||
from app.services.coins import CoinsService
|
||||
from app.services.dailyreward import DailyRewardService
|
||||
from app.services.dailyquests import DailyQuestsService
|
||||
|
||||
coins_service = CoinsService()
|
||||
|
||||
qr_logins_collection = db.qr_logins
|
||||
|
||||
router = APIRouter(
|
||||
tags=["Users"]
|
||||
)
|
||||
@ -119,8 +127,13 @@ async def get_user_by_uuid(uuid: str):
|
||||
return safe_user
|
||||
|
||||
@router.post("/auth/verify_code")
|
||||
async def verify_code(verify_code: VerifyCode):
|
||||
return await AuthService().verify_code(verify_code.username, verify_code.code, verify_code.telegram_chat_id)
|
||||
async def verify_code(payload: VerifyCode):
|
||||
return await AuthService().verify_code(
|
||||
username=payload.username,
|
||||
code=payload.code,
|
||||
telegram_user_id=payload.telegram_user_id,
|
||||
telegram_username=payload.telegram_username,
|
||||
)
|
||||
|
||||
@router.post("/auth/generate_code")
|
||||
async def generate_code(username: str):
|
||||
@ -129,3 +142,77 @@ async def generate_code(username: str):
|
||||
@router.get("/auth/verification_status/{username}")
|
||||
async def get_verification_status(username: str):
|
||||
return await AuthService().get_verification_status(username)
|
||||
|
||||
@router.get("/auth/me")
|
||||
async def get_me(
|
||||
accessToken: str = Query(...),
|
||||
clientToken: str = Query(...),
|
||||
):
|
||||
"""
|
||||
Текущий пользователь по accessToken + clientToken.
|
||||
"""
|
||||
return await AuthService().get_current_user(accessToken, clientToken)
|
||||
|
||||
@router.post("/auth/qr/init")
|
||||
async def qr_init(device_id: str | None = Query(default=None)):
|
||||
token = secrets.token_urlsafe(24)
|
||||
expires_at = datetime.utcnow() + timedelta(minutes=2)
|
||||
|
||||
await qr_logins_collection.insert_one({
|
||||
"token": token,
|
||||
"device_id": device_id,
|
||||
"status": "pending",
|
||||
"approved_username": None,
|
||||
"created_at": datetime.utcnow(),
|
||||
"expires_at": expires_at,
|
||||
})
|
||||
|
||||
# deep-link в бота
|
||||
BOT_USERNAME = os.getenv("TELEGRAM_BOT_USERNAME")
|
||||
qr_url = f"https://t.me/{BOT_USERNAME}?start=qr_{token}"
|
||||
return {"token": token, "qr_url": qr_url, "expires_at": expires_at.isoformat()}
|
||||
|
||||
@router.post("/auth/qr/approve")
|
||||
async def qr_approve(payload: QrApprove):
|
||||
return await AuthService().approve_qr_login(payload.token, payload.telegram_user_id)
|
||||
|
||||
@router.get("/auth/qr/status")
|
||||
async def qr_status(token: str = Query(...), device_id: str | None = Query(default=None)):
|
||||
return await AuthService().qr_status(token, device_id)
|
||||
|
||||
### daily reward
|
||||
|
||||
@router.post("/users/daily/claim")
|
||||
async def claim_daily(accessToken: str = Query(...), clientToken: str = Query(...)):
|
||||
me = await AuthService().get_current_user(accessToken, clientToken) # :contentReference[oaicite:7]{index=7}
|
||||
return await DailyRewardService().claim_daily(me["username"])
|
||||
|
||||
@router.get("/users/daily/status")
|
||||
async def daily_status(accessToken: str = Query(...), clientToken: str = Query(...)):
|
||||
me = await AuthService().get_current_user(accessToken, clientToken)
|
||||
return await DailyRewardService().get_status(me["username"])
|
||||
|
||||
@router.get("/users/daily/days")
|
||||
async def daily_days(
|
||||
accessToken: str = Query(...),
|
||||
clientToken: str = Query(...),
|
||||
limit: int = Query(60, ge=1, le=365),
|
||||
):
|
||||
me = await AuthService().get_current_user(accessToken, clientToken)
|
||||
return await DailyRewardService().get_claim_days(me["username"], limit=limit)
|
||||
|
||||
### daily quests
|
||||
|
||||
@router.get("/users/daily-quests/status")
|
||||
async def daily_quests_status(accessToken: str = Query(...), clientToken: str = Query(...)):
|
||||
me = await AuthService().get_current_user(accessToken, clientToken)
|
||||
return await DailyQuestsService().get_status(me["username"])
|
||||
|
||||
@router.post("/users/daily-quests/claim")
|
||||
async def daily_quests_claim(
|
||||
quest_key: str = Query(...),
|
||||
accessToken: str = Query(...),
|
||||
clientToken: str = Query(...),
|
||||
):
|
||||
me = await AuthService().get_current_user(accessToken, clientToken)
|
||||
return await DailyQuestsService().claim(me["username"], quest_key)
|
||||
@ -9,3 +9,9 @@ MONGO_URI = os.getenv("MONGO_URI")
|
||||
SECRET_KEY = os.getenv("SECRET_KEY")
|
||||
ALGORITHM = "HS256"
|
||||
ACCESS_TOKEN_EXPIRE_MINUTES = 1440 # 24 часа
|
||||
|
||||
BASE_DIR = Path(__file__).resolve().parent.parent # /app/app
|
||||
STATIC_DIR = BASE_DIR / "static"
|
||||
SKINS_DIR = STATIC_DIR / "skins"
|
||||
CAPES_DIR = STATIC_DIR / "capes"
|
||||
CAPES_STORE_DIR = STATIC_DIR / "capes_store"
|
||||
62
app/models/bonus.py
Normal file
62
app/models/bonus.py
Normal file
@ -0,0 +1,62 @@
|
||||
from pydantic import BaseModel
|
||||
from typing import Optional, List
|
||||
from datetime import datetime
|
||||
|
||||
class CreateBonusType(BaseModel):
|
||||
name: str
|
||||
description: str
|
||||
effect_type: str
|
||||
base_effect_value: float
|
||||
effect_increment: float
|
||||
price: int
|
||||
upgrade_price: int
|
||||
duration: int # в секундах
|
||||
max_level: int = 0
|
||||
image_url: Optional[str] = None
|
||||
|
||||
class PurchaseBonus(BaseModel):
|
||||
username: str
|
||||
bonus_type_id: str
|
||||
|
||||
class BonusEffect(BaseModel):
|
||||
effect_type: str
|
||||
effect_value: float
|
||||
expires_at: Optional[datetime] = None
|
||||
|
||||
class BonusType(BaseModel):
|
||||
id: str
|
||||
name: str
|
||||
description: str
|
||||
effect_type: str # "experience", "strength", "speed", etc.
|
||||
base_effect_value: float # Базовое значение эффекта (например, 1.0 для +100%)
|
||||
effect_increment: float # Прирост эффекта за уровень (например, 0.1 для +10%)
|
||||
price: int # Базовая цена
|
||||
upgrade_price: int # Цена улучшения за уровень
|
||||
duration: int # Длительность в секундах (0 для бесконечных)
|
||||
max_level: int = 0 # 0 = без ограничения уровней
|
||||
image_url: Optional[str] = None
|
||||
|
||||
class UserTypeBonus(BaseModel):
|
||||
id: str
|
||||
name: str
|
||||
description: str
|
||||
effect_type: str
|
||||
effect_value: float
|
||||
level: int
|
||||
purchased_at: datetime
|
||||
can_upgrade: bool
|
||||
upgrade_price: int
|
||||
expires_at: Optional[datetime] = None
|
||||
is_active: bool = True
|
||||
is_permanent: bool
|
||||
image_url: Optional[str] = None
|
||||
|
||||
class UserBonus(BaseModel):
|
||||
id: str
|
||||
user_id: str
|
||||
username: str
|
||||
bonus_type_id: str
|
||||
level: int
|
||||
purchased_at: datetime
|
||||
expires_at: Optional[datetime] = None
|
||||
is_active: bool
|
||||
32
app/models/case.py
Normal file
32
app/models/case.py
Normal file
@ -0,0 +1,32 @@
|
||||
from pydantic import BaseModel, Field
|
||||
from typing import List, Optional, Dict
|
||||
|
||||
class CaseItemMeta(BaseModel):
|
||||
display_name: Optional[str] = None
|
||||
lore: Optional[List[str]] = None
|
||||
enchants: Optional[Dict[str, int]] = None
|
||||
durability: Optional[int] = None
|
||||
|
||||
class CaseItem(BaseModel):
|
||||
id: Optional[str] = None
|
||||
name: str
|
||||
material: str
|
||||
amount: int = 1
|
||||
weight: int = 1
|
||||
meta: Optional[CaseItemMeta] = None
|
||||
|
||||
class CaseCreate(BaseModel):
|
||||
name: str
|
||||
description: Optional[str] = None
|
||||
price: int = Field(gt=0)
|
||||
server_ips: List[str] = Field(default_factory=lambda: ["*"])
|
||||
image_url: Optional[str] = None # 🔹 Картинка кейса
|
||||
items: List[CaseItem]
|
||||
|
||||
class CaseUpdate(BaseModel):
|
||||
name: Optional[str] = None
|
||||
description: Optional[str] = None
|
||||
price: Optional[int] = Field(default=None, gt=0)
|
||||
server_ips: Optional[List[str]] = None
|
||||
image_url: Optional[str] = None # 🔹 Можно менять картинку
|
||||
items: Optional[List[CaseItem]] = None
|
||||
26
app/models/inventory.py
Normal file
26
app/models/inventory.py
Normal file
@ -0,0 +1,26 @@
|
||||
from pydantic import BaseModel, Field
|
||||
from typing import Dict, Any, Optional, List
|
||||
from datetime import datetime
|
||||
|
||||
class InventoryItemCreate(BaseModel):
|
||||
username: str
|
||||
server_ip: str
|
||||
item_data: Dict[str, Any]
|
||||
source: Dict[str, Any] # например {"type":"case","case_id": "...", "case_name": "...", "rarity": "..."}
|
||||
created_at: datetime = Field(default_factory=datetime.utcnow)
|
||||
|
||||
class InventoryItem(BaseModel):
|
||||
id: str
|
||||
username: str
|
||||
server_ip: str
|
||||
item_data: Dict[str, Any]
|
||||
source: Dict[str, Any]
|
||||
status: str = "stored" # stored | withdrawing | delivered | failed
|
||||
created_at: datetime
|
||||
delivered_at: Optional[datetime] = None
|
||||
withdraw_operation_id: Optional[str] = None
|
||||
|
||||
class InventoryWithdrawRequest(BaseModel):
|
||||
username: str
|
||||
item_id: str
|
||||
server_ip: str
|
||||
28
app/models/news.py
Normal file
28
app/models/news.py
Normal file
@ -0,0 +1,28 @@
|
||||
from pydantic import BaseModel, Field
|
||||
from typing import Optional, List
|
||||
from datetime import datetime
|
||||
|
||||
class NewsBase(BaseModel):
|
||||
title: str = Field(..., max_length=200)
|
||||
markdown: str # полный текст в Markdown
|
||||
preview: Optional[str] = None # краткий текст/анонс (тоже можно в MD)
|
||||
tags: List[str] = []
|
||||
is_published: bool = True
|
||||
|
||||
class NewsCreate(NewsBase):
|
||||
pass
|
||||
|
||||
class NewsUpdate(BaseModel):
|
||||
title: Optional[str] = None
|
||||
markdown: Optional[str] = None
|
||||
preview: Optional[str] = None
|
||||
tags: Optional[List[str]] = None
|
||||
is_published: Optional[bool] = None
|
||||
|
||||
class NewsInDB(NewsBase):
|
||||
id: str
|
||||
created_at: datetime
|
||||
updated_at: datetime
|
||||
|
||||
class Config:
|
||||
orm_mode = True
|
||||
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
|
||||
|
||||
@ -10,6 +10,7 @@ class PrankCommandCreate(BaseModel):
|
||||
default=[],
|
||||
description='Список серверов, где доступна команда. Использование ["*"] означает доступность на всех серверах'
|
||||
)
|
||||
material: str
|
||||
targetDescription: Optional[str] = None # Сообщение для целевого игрока
|
||||
globalDescription: Optional[str] = None # Сообщение для всех остальных
|
||||
|
||||
|
||||
@ -22,9 +22,12 @@ class UserInDB(BaseModel):
|
||||
is_active: bool = True
|
||||
created_at: datetime = datetime.utcnow()
|
||||
code: Optional[str] = None
|
||||
telegram_id: Optional[str] = None
|
||||
telegram_user_id: Optional[int] = None
|
||||
telegram_username: Optional[str] = None
|
||||
is_verified: bool = False
|
||||
code_expires_at: Optional[datetime] = None
|
||||
is_admin: bool = False
|
||||
expires_at: Optional[datetime] = None
|
||||
class Session(BaseModel):
|
||||
access_token: str
|
||||
client_token: str
|
||||
@ -34,4 +37,9 @@ class Session(BaseModel):
|
||||
class VerifyCode(BaseModel):
|
||||
username: str
|
||||
code: str
|
||||
telegram_chat_id: int
|
||||
telegram_user_id: int
|
||||
telegram_username: Optional[str] = None
|
||||
|
||||
class QrApprove(BaseModel):
|
||||
token: str
|
||||
telegram_user_id: int
|
||||
53
app/realtime/coins_hub.py
Normal file
53
app/realtime/coins_hub.py
Normal file
@ -0,0 +1,53 @@
|
||||
# app/realtime/coins_hub.py
|
||||
from typing import Dict, Set
|
||||
from fastapi import WebSocket
|
||||
import asyncio
|
||||
|
||||
|
||||
class CoinsHub:
|
||||
def __init__(self):
|
||||
# username -> set of websockets
|
||||
self._connections: Dict[str, Set[WebSocket]] = {}
|
||||
self._lock = asyncio.Lock()
|
||||
|
||||
async def connect(self, username: str, ws: WebSocket):
|
||||
await ws.accept()
|
||||
async with self._lock:
|
||||
self._connections.setdefault(username, set()).add(ws)
|
||||
|
||||
async def disconnect(self, username: str, ws: WebSocket):
|
||||
async with self._lock:
|
||||
conns = self._connections.get(username)
|
||||
if not conns:
|
||||
return
|
||||
conns.discard(ws)
|
||||
if not conns:
|
||||
self._connections.pop(username, None)
|
||||
|
||||
async def send_update(self, username: str, coins: int):
|
||||
async with self._lock:
|
||||
conns = list(self._connections.get(username, []))
|
||||
|
||||
if not conns:
|
||||
return
|
||||
|
||||
payload = {
|
||||
"event": "coins:update",
|
||||
"coins": coins,
|
||||
}
|
||||
|
||||
dead: list[WebSocket] = []
|
||||
|
||||
for ws in conns:
|
||||
try:
|
||||
await ws.send_json(payload)
|
||||
except Exception:
|
||||
dead.append(ws)
|
||||
|
||||
if dead:
|
||||
async with self._lock:
|
||||
for ws in dead:
|
||||
self._connections.get(username, set()).discard(ws)
|
||||
|
||||
|
||||
coins_hub = CoinsHub()
|
||||
47
app/realtime/marketplace_hub.py
Normal file
47
app/realtime/marketplace_hub.py
Normal file
@ -0,0 +1,47 @@
|
||||
# app/realtime/marketplace_hub.py
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
from typing import Dict, Set, Any
|
||||
from fastapi import WebSocket
|
||||
|
||||
class MarketplaceHub:
|
||||
def __init__(self) -> None:
|
||||
self._rooms: Dict[str, Set[WebSocket]] = {}
|
||||
self._lock = asyncio.Lock()
|
||||
|
||||
async def connect(self, server_ip: str, ws: WebSocket) -> None:
|
||||
await ws.accept()
|
||||
async with self._lock:
|
||||
self._rooms.setdefault(server_ip, set()).add(ws)
|
||||
|
||||
async def disconnect(self, server_ip: str, ws: WebSocket) -> None:
|
||||
async with self._lock:
|
||||
room = self._rooms.get(server_ip)
|
||||
if not room:
|
||||
return
|
||||
room.discard(ws)
|
||||
if not room:
|
||||
self._rooms.pop(server_ip, None)
|
||||
|
||||
async def broadcast(self, server_ip: str, message: dict) -> None:
|
||||
async with self._lock:
|
||||
conns = list(self._rooms.get(server_ip, set()))
|
||||
|
||||
if not conns:
|
||||
return
|
||||
|
||||
dead: list[WebSocket] = []
|
||||
for ws in conns:
|
||||
try:
|
||||
await ws.send_json(message)
|
||||
except Exception:
|
||||
dead.append(ws)
|
||||
|
||||
if dead:
|
||||
async with self._lock:
|
||||
room = self._rooms.get(server_ip, set())
|
||||
for ws in dead:
|
||||
room.discard(ws)
|
||||
|
||||
marketplace_hub = MarketplaceHub()
|
||||
@ -2,6 +2,7 @@ import base64
|
||||
import json
|
||||
from fastapi import HTTPException, UploadFile
|
||||
from fastapi.responses import JSONResponse
|
||||
from app.db.database import db
|
||||
from app.models.user import UserLogin, UserInDB, UserCreate, Session
|
||||
from app.utils.misc import (
|
||||
verify_password,
|
||||
@ -23,6 +24,8 @@ env_path = Path(__file__).parent.parent / ".env"
|
||||
load_dotenv(dotenv_path=env_path)
|
||||
FILES_URL = os.getenv("FILES_URL")
|
||||
|
||||
qr_logins_collection = db.qr_logins
|
||||
|
||||
class AuthService:
|
||||
async def register(self, user: UserCreate):
|
||||
# Проверяем, существует ли пользователь
|
||||
@ -42,7 +45,9 @@ class AuthService:
|
||||
uuid=user_uuid,
|
||||
is_verified=False,
|
||||
code=None,
|
||||
code_expires_at=None
|
||||
code_expires_at=None,
|
||||
expires_at=datetime.utcnow() + timedelta(hours=1),
|
||||
is_admin=False
|
||||
)
|
||||
await users_collection.insert_one(new_user.dict())
|
||||
return {"status": "success", "uuid": user_uuid}
|
||||
@ -57,29 +62,50 @@ class AuthService:
|
||||
else:
|
||||
raise HTTPException(404, "User not found")
|
||||
|
||||
async def verify_code(self, username: str, code: str, telegram_chat_id: int):
|
||||
async def verify_code(
|
||||
self,
|
||||
username: str,
|
||||
code: str,
|
||||
telegram_user_id: int | None = None,
|
||||
telegram_username: str | None = None,
|
||||
):
|
||||
user = await users_collection.find_one({"username": username})
|
||||
if not user:
|
||||
raise HTTPException(404, "User not found")
|
||||
|
||||
raise HTTPException(404, "Пользователь не найден")
|
||||
|
||||
if user["is_verified"]:
|
||||
raise HTTPException(400, "User already verified")
|
||||
|
||||
# Проверяем код и привязку к Telegram
|
||||
if user.get("telegram_chat_id") and user["telegram_chat_id"] != telegram_chat_id:
|
||||
raise HTTPException(403, "This account is linked to another Telegram")
|
||||
|
||||
raise HTTPException(400, "Пользователь уже верифицирован")
|
||||
|
||||
if user.get("telegram_user_id") and user["telegram_user_id"] != telegram_user_id:
|
||||
raise HTTPException(403, "Этот аккаунт в Telegram уже привязан к другому пользователем.")
|
||||
|
||||
if user.get("code") != code:
|
||||
raise HTTPException(400, "Invalid code")
|
||||
raise HTTPException(400, "Инвалид код. Прям как ты")
|
||||
|
||||
# Обновляем chat_id при первом подтверждении
|
||||
if telegram_user_id is not None:
|
||||
existing = await users_collection.find_one({
|
||||
"telegram_user_id": telegram_user_id,
|
||||
"username": {"$ne": username},
|
||||
})
|
||||
if existing:
|
||||
raise HTTPException(
|
||||
status_code=403,
|
||||
detail="Этот аккаунт в Telegram уже привязан к другому пользователем.",
|
||||
)
|
||||
|
||||
update = {
|
||||
"is_verified": True,
|
||||
"telegram_user_id": telegram_user_id,
|
||||
"code": None,
|
||||
}
|
||||
if telegram_user_id is not None:
|
||||
update["telegram_user_id"] = telegram_user_id
|
||||
if telegram_username is not None:
|
||||
update["telegram_username"] = telegram_username
|
||||
|
||||
await users_collection.update_one(
|
||||
{"username": username},
|
||||
{"$set": {
|
||||
"is_verified": True,
|
||||
"telegram_chat_id": telegram_chat_id,
|
||||
"code": None
|
||||
}}
|
||||
{"$set": update, "$unset": {"expires_at": ""}},
|
||||
)
|
||||
return {"status": "success"}
|
||||
|
||||
@ -101,6 +127,10 @@ class AuthService:
|
||||
# Генерируем токены
|
||||
access_token = create_access_token({"sub": user["username"], "uuid": user["uuid"]})
|
||||
client_token = str(uuid.uuid4())
|
||||
|
||||
await sessions_collection.delete_many({
|
||||
"user_uuid": user["uuid"]
|
||||
})
|
||||
|
||||
# Сохраняем сессию
|
||||
session = Session(
|
||||
@ -121,27 +151,91 @@ class AuthService:
|
||||
}
|
||||
|
||||
async def validate(self, access_token: str, client_token: str):
|
||||
print(f"Searching for access_toke and client_token: '{access_token}', '{client_token}")
|
||||
session = await sessions_collection.find_one({
|
||||
"access_token": access_token,
|
||||
"client_token": client_token,
|
||||
})
|
||||
print("Session from DB:", session)
|
||||
if not session or datetime.utcnow() > session["expires_at"]:
|
||||
|
||||
if not session:
|
||||
return False
|
||||
|
||||
if datetime.utcnow() > session["expires_at"]:
|
||||
# можно сразу чистить
|
||||
await sessions_collection.delete_one({"_id": session["_id"]})
|
||||
return False
|
||||
|
||||
return True
|
||||
|
||||
async def is_admin(self, access_token: str, client_token: str) -> bool:
|
||||
session = await sessions_collection.find_one({
|
||||
"access_token": access_token,
|
||||
"client_token": client_token,
|
||||
})
|
||||
if not session:
|
||||
return False
|
||||
|
||||
user = await users_collection.find_one({"uuid": session["user_uuid"]})
|
||||
return user and user.get("is_admin") is True
|
||||
|
||||
async def get_current_user(self, access_token: str, client_token: str):
|
||||
session = await sessions_collection.find_one({
|
||||
"access_token": access_token,
|
||||
"client_token": client_token,
|
||||
})
|
||||
if not session:
|
||||
raise HTTPException(status_code=401, detail="Invalid session")
|
||||
|
||||
user = await users_collection.find_one({"uuid": session["user_uuid"]})
|
||||
if not user:
|
||||
raise HTTPException(status_code=404, detail="User not found")
|
||||
|
||||
return {
|
||||
"username": user["username"],
|
||||
"uuid": user["uuid"],
|
||||
"is_admin": user.get("is_admin", False),
|
||||
}
|
||||
|
||||
async def refresh(self, access_token: str, client_token: str):
|
||||
if not await self.validate(access_token, client_token):
|
||||
session = await sessions_collection.find_one({
|
||||
"access_token": access_token,
|
||||
"client_token": client_token,
|
||||
})
|
||||
|
||||
if not session:
|
||||
return None
|
||||
|
||||
# Обновляем токен
|
||||
new_access_token = create_access_token({"sub": "user", "uuid": "user_uuid"})
|
||||
if datetime.utcnow() > session["expires_at"]:
|
||||
return None
|
||||
|
||||
user = await users_collection.find_one({"uuid": session["user_uuid"]})
|
||||
if not user:
|
||||
return None
|
||||
|
||||
new_access_token = create_access_token({
|
||||
"sub": user["username"],
|
||||
"uuid": user["uuid"],
|
||||
})
|
||||
|
||||
new_expires_at = datetime.utcnow() + timedelta(minutes=1440)
|
||||
|
||||
await sessions_collection.update_one(
|
||||
{"access_token": access_token},
|
||||
{"$set": {"access_token": new_access_token}},
|
||||
{"_id": session["_id"]},
|
||||
{
|
||||
"$set": {
|
||||
"access_token": new_access_token,
|
||||
"expires_at": new_expires_at,
|
||||
}
|
||||
},
|
||||
)
|
||||
return {"accessToken": new_access_token, "clientToken": client_token}
|
||||
|
||||
return {
|
||||
"accessToken": new_access_token,
|
||||
"clientToken": client_token,
|
||||
"selectedProfile": {
|
||||
"id": user["uuid"],
|
||||
"name": user["username"],
|
||||
},
|
||||
}
|
||||
|
||||
async def get_minecraft_profile(self, uuid: str):
|
||||
# Преобразуем UUID без дефисов в формат с дефисами (если нужно)
|
||||
@ -236,28 +330,44 @@ class AuthService:
|
||||
|
||||
async def join_server(self, request_data: dict):
|
||||
access_token = request_data.get("accessToken")
|
||||
selected_profile = request_data.get("selectedProfile") # UUID без дефисов
|
||||
selected_profile = request_data.get("selectedProfile") # STRING UUID
|
||||
server_id = request_data.get("serverId")
|
||||
|
||||
if not all([access_token, selected_profile, server_id]):
|
||||
raise HTTPException(status_code=400, detail="Missing required parameters")
|
||||
|
||||
decoded_token = decode_token(access_token)
|
||||
if not decoded_token:
|
||||
session = await sessions_collection.find_one({
|
||||
"access_token": access_token,
|
||||
})
|
||||
|
||||
if not session:
|
||||
raise HTTPException(status_code=401, detail="Invalid session")
|
||||
|
||||
if datetime.utcnow() > session["expires_at"]:
|
||||
raise HTTPException(status_code=401, detail="Session expired")
|
||||
|
||||
decoded = decode_token(access_token)
|
||||
if not decoded:
|
||||
raise HTTPException(status_code=401, detail="Invalid access token")
|
||||
|
||||
token_uuid = decoded_token.get("uuid", "").replace("-", "")
|
||||
# 🔥 ВАЖНО
|
||||
token_uuid = decoded["uuid"].replace("-", "")
|
||||
|
||||
print("JOIN DEBUG:", {
|
||||
"token_uuid": token_uuid,
|
||||
"selected_profile": selected_profile,
|
||||
"raw": request_data
|
||||
})
|
||||
|
||||
if token_uuid != selected_profile:
|
||||
raise HTTPException(status_code=403, detail="Token doesn't match selected profile")
|
||||
raise HTTPException(status_code=403, detail="Profile mismatch")
|
||||
|
||||
# Сохраняем server_id в сессию
|
||||
await sessions_collection.update_one(
|
||||
{"user_uuid": decoded_token["uuid"]}, # UUID с дефисами
|
||||
{"_id": session["_id"]},
|
||||
{"$set": {"server_id": server_id}},
|
||||
upsert=True
|
||||
)
|
||||
|
||||
return True
|
||||
return JSONResponse(status_code=204, content=None)
|
||||
|
||||
async def has_joined(self, username: str, server_id: str):
|
||||
user = await users_collection.find_one({"username": username})
|
||||
@ -266,8 +376,9 @@ class AuthService:
|
||||
|
||||
# Ищем сессию с этим server_id
|
||||
session = await sessions_collection.find_one({
|
||||
"user_uuid": user["uuid"], # UUID с дефисами
|
||||
"server_id": server_id
|
||||
"user_uuid": user["uuid"],
|
||||
"server_id": server_id,
|
||||
"expires_at": {"$gt": datetime.utcnow()},
|
||||
})
|
||||
if not session:
|
||||
raise HTTPException(status_code=403, detail="Not joined this server")
|
||||
@ -328,3 +439,75 @@ class AuthService:
|
||||
"value": base64_textures
|
||||
}]
|
||||
}
|
||||
|
||||
async def approve_qr_login(self, token: str, telegram_user_id: int):
|
||||
qr = await qr_logins_collection.find_one({"token": token})
|
||||
if not qr:
|
||||
raise HTTPException(404, "QR token not found")
|
||||
|
||||
if qr["status"] != "pending":
|
||||
raise HTTPException(400, "QR token already used or not pending")
|
||||
|
||||
if datetime.utcnow() > qr["expires_at"]:
|
||||
await qr_logins_collection.update_one({"token": token}, {"$set": {"status": "expired"}})
|
||||
raise HTTPException(400, "QR token expired")
|
||||
|
||||
# находим пользователя по telegram_user_id
|
||||
user = await users_collection.find_one({"telegram_user_id": telegram_user_id})
|
||||
if not user:
|
||||
raise HTTPException(403, "Telegram аккаунт не привязан")
|
||||
|
||||
if not user.get("is_verified"):
|
||||
raise HTTPException(403, "Пользователь не верифицирован")
|
||||
|
||||
await qr_logins_collection.update_one(
|
||||
{"token": token},
|
||||
{"$set": {"status": "approved", "approved_username": user["username"]}}
|
||||
)
|
||||
return {"status": "success"}
|
||||
|
||||
async def qr_status(self, token: str, device_id: str | None = None):
|
||||
qr = await qr_logins_collection.find_one({"token": token})
|
||||
if not qr:
|
||||
raise HTTPException(404, "QR token not found")
|
||||
|
||||
if datetime.utcnow() > qr["expires_at"] and qr["status"] == "pending":
|
||||
await qr_logins_collection.update_one({"token": token}, {"$set": {"status": "expired"}})
|
||||
return {"status": "expired"}
|
||||
|
||||
# если хотите привязку к устройству:
|
||||
if device_id and qr.get("device_id") and qr["device_id"] != device_id:
|
||||
raise HTTPException(403, "Device mismatch")
|
||||
|
||||
if qr["status"] == "approved":
|
||||
username = qr["approved_username"]
|
||||
user = await users_collection.find_one({"username": username})
|
||||
if not user:
|
||||
raise HTTPException(404, "User not found")
|
||||
|
||||
# генерим токены как в login()
|
||||
access_token = create_access_token({"sub": user["username"], "uuid": user["uuid"]})
|
||||
client_token = str(uuid.uuid4())
|
||||
|
||||
session = Session(
|
||||
access_token=access_token,
|
||||
client_token=client_token,
|
||||
user_uuid=user["uuid"],
|
||||
expires_at=datetime.utcnow() + timedelta(minutes=1440),
|
||||
)
|
||||
await sessions_collection.insert_one(session.dict())
|
||||
|
||||
# одноразовость
|
||||
await qr_logins_collection.update_one(
|
||||
{"token": token},
|
||||
{"$set": {"status": "consumed"}}
|
||||
)
|
||||
|
||||
return {
|
||||
"status": "ok",
|
||||
"accessToken": access_token,
|
||||
"clientToken": client_token,
|
||||
"selectedProfile": {"id": user["uuid"], "name": user["username"]},
|
||||
}
|
||||
|
||||
return {"status": qr["status"]}
|
||||
|
||||
299
app/services/bonus.py
Normal file
299
app/services/bonus.py
Normal file
@ -0,0 +1,299 @@
|
||||
import uuid
|
||||
from datetime import datetime, timedelta
|
||||
from fastapi import HTTPException
|
||||
from app.db.database import db
|
||||
from app.services.coins import CoinsService
|
||||
from app.models.bonus import BonusType
|
||||
|
||||
# Коллекции для бонусов
|
||||
bonus_types_collection = db.bonus_types
|
||||
user_bonuses_collection = db.user_bonuses
|
||||
|
||||
class BonusService:
|
||||
|
||||
async def create_bonus_type(self, bonus_data):
|
||||
"""Создание нового типа бонуса"""
|
||||
bonus_id = str(uuid.uuid4())
|
||||
|
||||
bonus = {
|
||||
"id": bonus_id,
|
||||
"name": bonus_data.name,
|
||||
"description": bonus_data.description,
|
||||
"effect_type": bonus_data.effect_type,
|
||||
"base_effect_value": bonus_data.base_effect_value,
|
||||
"effect_increment": bonus_data.effect_increment,
|
||||
"price": bonus_data.price,
|
||||
"upgrade_price": bonus_data.upgrade_price,
|
||||
"duration": bonus_data.duration,
|
||||
"max_level": bonus_data.max_level,
|
||||
"image_url": bonus_data.image_url,
|
||||
}
|
||||
|
||||
# Проверка на дубликат имени
|
||||
existing = await bonus_types_collection.find_one({"name": bonus_data.name})
|
||||
if existing:
|
||||
raise HTTPException(status_code=400, detail="Бонус с таким именем уже существует")
|
||||
|
||||
await bonus_types_collection.insert_one(bonus)
|
||||
|
||||
return {
|
||||
"status": "success",
|
||||
"message": "Тип бонуса успешно создан",
|
||||
"bonus_id": bonus_id
|
||||
}
|
||||
|
||||
async def get_user_active_effects(self, username: str):
|
||||
"""Получить активные эффекты пользователя для плагина"""
|
||||
from app.db.database import users_collection
|
||||
|
||||
user = await users_collection.find_one({"username": username})
|
||||
if not user:
|
||||
return {"effects": []}
|
||||
|
||||
# Находим активные бонусы с учетом бесконечных (expires_at = null) или действующих
|
||||
active_bonuses = await user_bonuses_collection.find({
|
||||
"user_id": str(user["_id"]),
|
||||
"is_active": True,
|
||||
}).to_list(50)
|
||||
|
||||
effects = []
|
||||
for bonus in active_bonuses:
|
||||
bonus_type = await bonus_types_collection.find_one({"id": bonus["bonus_type_id"]})
|
||||
if bonus_type:
|
||||
# Рассчитываем итоговое значение эффекта с учетом уровня
|
||||
level = bonus.get("level", 1)
|
||||
effect_value = bonus_type["base_effect_value"] + (level - 1) * bonus_type["effect_increment"]
|
||||
|
||||
effect = {
|
||||
"effect_type": bonus_type["effect_type"],
|
||||
"effect_value": effect_value
|
||||
}
|
||||
|
||||
# Для временных бонусов добавляем срок
|
||||
if bonus.get("expires_at"):
|
||||
effect["expires_at"] = bonus["expires_at"].isoformat()
|
||||
|
||||
effects.append(effect)
|
||||
|
||||
return {"effects": effects}
|
||||
|
||||
async def list_available_bonuses(self):
|
||||
"""Получить список доступных типов бонусов"""
|
||||
bonuses = await bonus_types_collection.find().to_list(50)
|
||||
return {"bonuses": [BonusType(**bonus) for bonus in bonuses]}
|
||||
|
||||
async def get_user_bonuses(self, username: str):
|
||||
"""Получить бонусы пользователя (активные и неактивные)"""
|
||||
from app.db.database import users_collection
|
||||
|
||||
user = await users_collection.find_one({"username": username})
|
||||
if not user:
|
||||
raise HTTPException(status_code=404, detail="Пользователь не найден")
|
||||
|
||||
user_bonuses = await user_bonuses_collection.find({
|
||||
"user_id": str(user["_id"]),
|
||||
}).to_list(50)
|
||||
|
||||
result = []
|
||||
for bonus in user_bonuses:
|
||||
bonus_type = await bonus_types_collection.find_one({"id": bonus["bonus_type_id"]})
|
||||
if bonus_type:
|
||||
level = bonus.get("level", 1)
|
||||
effect_value = bonus_type["base_effect_value"] + (level - 1) * bonus_type["effect_increment"]
|
||||
|
||||
bonus_data = {
|
||||
"id": bonus["id"],
|
||||
"bonus_type_id": bonus["bonus_type_id"],
|
||||
"name": bonus_type["name"],
|
||||
"description": bonus_type["description"],
|
||||
"effect_type": bonus_type["effect_type"],
|
||||
"effect_value": effect_value,
|
||||
"level": level,
|
||||
"purchased_at": bonus["purchased_at"].isoformat(),
|
||||
"can_upgrade": bonus_type["max_level"] == 0 or level < bonus_type["max_level"],
|
||||
"upgrade_price": bonus_type["upgrade_price"],
|
||||
"image_url": bonus_type.get("image_url"),
|
||||
"is_active": bonus.get("is_active", True),
|
||||
}
|
||||
|
||||
if bonus.get("expires_at"):
|
||||
bonus_data["expires_at"] = bonus["expires_at"].isoformat()
|
||||
bonus_data["time_left"] = (bonus["expires_at"] - datetime.utcnow()).total_seconds()
|
||||
bonus_data["is_permanent"] = False
|
||||
else:
|
||||
bonus_data["is_permanent"] = True
|
||||
|
||||
result.append(bonus_data)
|
||||
|
||||
return {"bonuses": result}
|
||||
|
||||
async def toggle_bonus_activation(self, username: str, bonus_id: str):
|
||||
"""Переключить активность бонуса у пользователя"""
|
||||
from app.db.database import users_collection
|
||||
|
||||
# Находим пользователя
|
||||
user = await users_collection.find_one({"username": username})
|
||||
if not user:
|
||||
raise HTTPException(status_code=404, detail="Пользователь не найден")
|
||||
|
||||
# Находим бонус пользователя
|
||||
user_bonus = await user_bonuses_collection.find_one({
|
||||
"id": bonus_id,
|
||||
"user_id": str(user["_id"]),
|
||||
})
|
||||
|
||||
if not user_bonus:
|
||||
raise HTTPException(status_code=404, detail="Бонус не найден или не принадлежит вам")
|
||||
|
||||
# Проверяем, не истек ли бонус при попытке включить
|
||||
if user_bonus.get("expires_at") and user_bonus["expires_at"] < datetime.utcnow():
|
||||
# На всякий случай зафиксируем в БД, что он не активен
|
||||
await user_bonuses_collection.update_one(
|
||||
{"id": bonus_id},
|
||||
{"$set": {"is_active": False}}
|
||||
)
|
||||
raise HTTPException(status_code=400, detail="Срок действия бонуса истёк и он не может быть активирован")
|
||||
|
||||
new_status = not user_bonus.get("is_active", False)
|
||||
|
||||
await user_bonuses_collection.update_one(
|
||||
{"id": bonus_id},
|
||||
{"$set": {"is_active": new_status}}
|
||||
)
|
||||
|
||||
return {
|
||||
"status": "success",
|
||||
"message": "Активность бонуса переключена",
|
||||
"bonus_id": bonus_id,
|
||||
"is_active": new_status,
|
||||
}
|
||||
|
||||
async def purchase_bonus(self, username: str, bonus_type_id: str):
|
||||
"""Покупка базового бонуса пользователем"""
|
||||
from app.db.database import users_collection
|
||||
|
||||
# Находим пользователя
|
||||
user = await users_collection.find_one({"username": username})
|
||||
if not user:
|
||||
raise HTTPException(status_code=404, detail="Пользователь не найден")
|
||||
|
||||
# Находим тип бонуса
|
||||
bonus_type = await bonus_types_collection.find_one({"id": bonus_type_id})
|
||||
if not bonus_type:
|
||||
raise HTTPException(status_code=404, detail="Бонус не найден")
|
||||
|
||||
# Проверяем, есть ли уже такой бонус у пользователя
|
||||
existing_bonus = await user_bonuses_collection.find_one({
|
||||
"user_id": str(user["_id"]),
|
||||
"bonus_type_id": bonus_type_id,
|
||||
"is_active": True
|
||||
})
|
||||
|
||||
if existing_bonus:
|
||||
raise HTTPException(status_code=400, detail="Этот бонус уже приобретен. Вы можете улучшить его.")
|
||||
|
||||
# Проверяем достаточно ли монет
|
||||
coins_service = CoinsService()
|
||||
user_coins = await coins_service.get_balance(username)
|
||||
|
||||
if user_coins < bonus_type["price"]:
|
||||
raise HTTPException(status_code=400,
|
||||
detail=f"Недостаточно монет. Требуется: {bonus_type['price']}, имеется: {user_coins}")
|
||||
|
||||
# Создаем запись о бонусе для пользователя
|
||||
bonus_id = str(uuid.uuid4())
|
||||
now = datetime.utcnow()
|
||||
|
||||
# Если бонус имеет длительность
|
||||
expires_at = None
|
||||
if bonus_type["duration"] > 0:
|
||||
expires_at = now + timedelta(seconds=bonus_type["duration"])
|
||||
|
||||
user_bonus = {
|
||||
"id": bonus_id,
|
||||
"user_id": str(user["_id"]),
|
||||
"username": username,
|
||||
"bonus_type_id": bonus_type_id,
|
||||
"level": 1, # Начальный уровень
|
||||
"purchased_at": now,
|
||||
"expires_at": expires_at,
|
||||
"is_active": True
|
||||
}
|
||||
|
||||
# Сохраняем бонус в БД
|
||||
await user_bonuses_collection.insert_one(user_bonus)
|
||||
|
||||
# Списываем монеты
|
||||
await coins_service.decrease_balance(username, bonus_type["price"])
|
||||
|
||||
# Формируем текст сообщения
|
||||
duration_text = "навсегда" if bonus_type["duration"] == 0 else f"на {bonus_type['duration'] // 60} мин."
|
||||
message = f"Бонус '{bonus_type['name']}' успешно приобретен {duration_text}"
|
||||
|
||||
return {
|
||||
"status": "success",
|
||||
"message": message,
|
||||
"remaining_coins": user_coins - bonus_type["price"]
|
||||
}
|
||||
|
||||
async def upgrade_bonus(self, username: str, bonus_id: str):
|
||||
"""Улучшение уже купленного бонуса"""
|
||||
from app.db.database import users_collection
|
||||
|
||||
# Находим пользователя
|
||||
user = await users_collection.find_one({"username": username})
|
||||
if not user:
|
||||
raise HTTPException(status_code=404, detail="Пользователь не найден")
|
||||
|
||||
# Находим бонус пользователя
|
||||
user_bonus = await user_bonuses_collection.find_one({
|
||||
"id": bonus_id,
|
||||
"user_id": str(user["_id"]),
|
||||
"is_active": True
|
||||
})
|
||||
|
||||
if not user_bonus:
|
||||
raise HTTPException(status_code=404, detail="Бонус не найден или не принадлежит вам")
|
||||
|
||||
# Находим тип бонуса
|
||||
bonus_type = await bonus_types_collection.find_one({"id": user_bonus["bonus_type_id"]})
|
||||
if not bonus_type:
|
||||
raise HTTPException(status_code=404, detail="Тип бонуса не найден")
|
||||
|
||||
# Проверяем ограничение на максимальный уровень
|
||||
current_level = user_bonus["level"]
|
||||
if bonus_type["max_level"] > 0 and current_level >= bonus_type["max_level"]:
|
||||
raise HTTPException(status_code=400, detail="Достигнут максимальный уровень бонуса")
|
||||
|
||||
# Рассчитываем стоимость улучшения
|
||||
upgrade_price = bonus_type["upgrade_price"]
|
||||
|
||||
# Проверяем достаточно ли монет
|
||||
coins_service = CoinsService()
|
||||
user_coins = await coins_service.get_balance(username)
|
||||
|
||||
if user_coins < upgrade_price:
|
||||
raise HTTPException(status_code=400,
|
||||
detail=f"Недостаточно монет. Требуется: {upgrade_price}, имеется: {user_coins}")
|
||||
|
||||
# Обновляем уровень бонуса
|
||||
new_level = current_level + 1
|
||||
|
||||
await user_bonuses_collection.update_one(
|
||||
{"id": bonus_id},
|
||||
{"$set": {"level": new_level}}
|
||||
)
|
||||
|
||||
# Списываем монеты
|
||||
await coins_service.decrease_balance(username, upgrade_price)
|
||||
|
||||
# Рассчитываем новое значение эффекта
|
||||
new_effect_value = bonus_type["base_effect_value"] + (new_level - 1) * bonus_type["effect_increment"]
|
||||
|
||||
return {
|
||||
"status": "success",
|
||||
"message": f"Бонус '{bonus_type['name']}' улучшен до уровня {new_level}",
|
||||
"new_level": new_level,
|
||||
"effect_value": new_effect_value,
|
||||
"remaining_coins": user_coins - upgrade_price
|
||||
}
|
||||
@ -1,5 +1,5 @@
|
||||
from app.db.database import users_collection
|
||||
from app.core.config import FILES_URL
|
||||
from app.core.config import CAPES_DIR, FILES_URL
|
||||
from fastapi import HTTPException, UploadFile
|
||||
from datetime import datetime
|
||||
|
||||
@ -30,7 +30,7 @@ class CapeService:
|
||||
import os
|
||||
old_url = user["cloak_url"]
|
||||
old_filename = os.path.basename(urlparse(old_url).path)
|
||||
old_path = os.path.join("/app/static/capes", old_filename)
|
||||
old_path = CAPES_DIR / old_filename
|
||||
if os.path.exists(old_path):
|
||||
try:
|
||||
os.remove(old_path)
|
||||
@ -39,7 +39,7 @@ class CapeService:
|
||||
|
||||
# Создаем папку для плащей, если ее нет
|
||||
from pathlib import Path
|
||||
cape_dir = Path("/app/static/capes")
|
||||
cape_dir = CAPES_DIR
|
||||
cape_dir.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
cape_filename = f"{username}_{int(datetime.now().timestamp())}.{ext}"
|
||||
|
||||
209
app/services/case.py
Normal file
209
app/services/case.py
Normal file
@ -0,0 +1,209 @@
|
||||
import uuid
|
||||
import random
|
||||
from datetime import datetime
|
||||
from fastapi import HTTPException
|
||||
from app.db.database import db, users_collection
|
||||
from app.services.coins import CoinsService
|
||||
from app.models.case import CaseCreate, CaseUpdate
|
||||
|
||||
cases_collection = db.cases
|
||||
case_openings_collection = db.case_openings
|
||||
game_servers_collection = db.game_servers
|
||||
marketplace_operations = db.marketplace_operations
|
||||
player_inventory_collection = db.player_inventory
|
||||
|
||||
|
||||
class CaseService:
|
||||
def __init__(self):
|
||||
self.coins_service = CoinsService()
|
||||
|
||||
async def create_case(self, case_data: CaseCreate):
|
||||
case_id = str(uuid.uuid4())
|
||||
|
||||
# Генерим id для предметов, если не заданы
|
||||
items = []
|
||||
for item in case_data.items:
|
||||
item_dict = item.dict()
|
||||
if not item_dict.get("id"):
|
||||
item_dict["id"] = str(uuid.uuid4())
|
||||
items.append(item_dict)
|
||||
|
||||
doc = {
|
||||
"id": case_id,
|
||||
"name": case_data.name,
|
||||
"description": case_data.description,
|
||||
"price": case_data.price,
|
||||
"server_ips": case_data.server_ips or ["*"],
|
||||
"image_url": case_data.image_url, # 🔹 сохраняем картинку
|
||||
"items": items,
|
||||
"created_at": datetime.utcnow()
|
||||
}
|
||||
|
||||
await cases_collection.insert_one(doc)
|
||||
return {"status": "success", "id": case_id}
|
||||
|
||||
async def list_cases(self):
|
||||
cases = await cases_collection.find().to_list(1000)
|
||||
return [
|
||||
{
|
||||
"id": c["id"],
|
||||
"name": c["name"],
|
||||
"description": c.get("description"),
|
||||
"price": c["price"],
|
||||
"server_ips": c.get("server_ips", ["*"]),
|
||||
"image_url": c.get("image_url"), # 🔹 отдаем картинку
|
||||
"items_count": len(c.get("items", []))
|
||||
}
|
||||
for c in cases
|
||||
]
|
||||
|
||||
async def get_case(self, case_id: str):
|
||||
# проекция {"_id": 0} говорит Mongo не возвращать поле _id
|
||||
case = await cases_collection.find_one({"id": case_id}, {"_id": 0})
|
||||
if not case:
|
||||
raise HTTPException(status_code=404, detail="Кейс не найден")
|
||||
return case
|
||||
|
||||
async def update_case(self, case_id: str, data: CaseUpdate):
|
||||
case = await cases_collection.find_one({"id": case_id})
|
||||
if not case:
|
||||
raise HTTPException(status_code=404, detail="Кейс не найден")
|
||||
|
||||
update = {}
|
||||
if data.name is not None:
|
||||
update["name"] = data.name
|
||||
if data.description is not None:
|
||||
update["description"] = data.description
|
||||
if data.price is not None:
|
||||
update["price"] = data.price
|
||||
if data.server_ips is not None:
|
||||
update["server_ips"] = data.server_ips
|
||||
if data.image_url is not None:
|
||||
update["image_url"] = data.image_url # 🔹 обновляем картинку
|
||||
if data.items is not None:
|
||||
items = []
|
||||
for item in data.items:
|
||||
item_dict = item.dict()
|
||||
if not item_dict.get("id"):
|
||||
item_dict["id"] = str(uuid.uuid4())
|
||||
items.append(item_dict)
|
||||
update["items"] = items
|
||||
|
||||
if update:
|
||||
await cases_collection.update_one({"id": case_id}, {"$set": update})
|
||||
|
||||
return {"status": "success"}
|
||||
|
||||
async def delete_case(self, case_id: str):
|
||||
result = await cases_collection.delete_one({"id": case_id})
|
||||
if result.deleted_count == 0:
|
||||
raise HTTPException(status_code=404, detail="Кейс не найден")
|
||||
return {"status": "success"}
|
||||
|
||||
async def open_case(self, username: str, case_id: str, server_ip: str):
|
||||
# 1. Пользователь
|
||||
user = await users_collection.find_one({"username": username})
|
||||
if not user:
|
||||
raise HTTPException(status_code=404, detail="Пользователь не найден")
|
||||
|
||||
# 2. Кейс
|
||||
case = await cases_collection.find_one({"id": case_id})
|
||||
if not case:
|
||||
raise HTTPException(status_code=404, detail="Кейс не найден")
|
||||
|
||||
items = case.get("items", [])
|
||||
if not items:
|
||||
raise HTTPException(status_code=400, detail="В кейсе нет предметов")
|
||||
|
||||
allowed = case.get("server_ips") or ["*"]
|
||||
if "*" not in allowed and server_ip not in allowed:
|
||||
raise HTTPException(status_code=403, detail="Этот кейс не может быть открыт на этом сервере")
|
||||
|
||||
# 3. Сервер
|
||||
server = await game_servers_collection.find_one({"ip": server_ip})
|
||||
if not server:
|
||||
raise HTTPException(status_code=404, detail="Сервер не найден")
|
||||
|
||||
# 4. Проверяем баланс
|
||||
user_balance = await self.coins_service.get_balance(username)
|
||||
price = case["price"]
|
||||
|
||||
if user_balance < price:
|
||||
raise HTTPException(
|
||||
status_code=400,
|
||||
detail=f"Недостаточно монет. Требуется: {price}, имеется: {user_balance}"
|
||||
)
|
||||
|
||||
# 5. Выбираем предмет по весам (шансам)
|
||||
weights = [max(0, i.get("weight", 1)) for i in items]
|
||||
total_weight = sum(weights)
|
||||
if total_weight <= 0:
|
||||
raise HTTPException(status_code=500, detail="Неверно настроены шансы предметов (вес ≤ 0)")
|
||||
|
||||
rnd = random.uniform(0, total_weight)
|
||||
current = 0
|
||||
chosen_item = None
|
||||
for item, w in zip(items, weights):
|
||||
current += w
|
||||
if rnd <= current:
|
||||
chosen_item = item
|
||||
break
|
||||
|
||||
if not chosen_item:
|
||||
# на всякий случай, но по логике не должно случиться
|
||||
chosen_item = items[-1]
|
||||
|
||||
# 6. Списываем монеты
|
||||
await self.coins_service.decrease_balance(username, price)
|
||||
|
||||
# 7. Создаём операцию для сервера — как в Marketplace, только другой type :contentReference[oaicite:3]{index=3}
|
||||
operation_id = str(uuid.uuid4())
|
||||
|
||||
item_data = {
|
||||
"material": chosen_item["material"],
|
||||
"amount": chosen_item.get("amount", 1),
|
||||
"meta": (chosen_item.get("meta") or {})
|
||||
}
|
||||
|
||||
inventory_item = {
|
||||
"id": str(uuid.uuid4()),
|
||||
"username": username,
|
||||
"server_ip": server_ip,
|
||||
"item_data": item_data,
|
||||
"source": {
|
||||
"type": "case",
|
||||
"case_id": case_id,
|
||||
"case_name": case.get("name"),
|
||||
},
|
||||
"status": "stored",
|
||||
"created_at": datetime.utcnow(),
|
||||
"delivered_at": None,
|
||||
"withdraw_operation_id": None,
|
||||
}
|
||||
|
||||
await player_inventory_collection.insert_one(inventory_item)
|
||||
|
||||
# 8. Лог открытия кейса
|
||||
opening_log = {
|
||||
"id": str(uuid.uuid4()),
|
||||
"username": username,
|
||||
"user_id": user.get("_id"),
|
||||
"case_id": case_id,
|
||||
"case_name": case["name"],
|
||||
"server_ip": server_ip,
|
||||
"reward_item": chosen_item,
|
||||
"price": price,
|
||||
"opened_at": datetime.utcnow()
|
||||
}
|
||||
await case_openings_collection.insert_one(opening_log)
|
||||
|
||||
# 9. Можно вернуть новый баланс
|
||||
new_balance = await self.coins_service.get_balance(username)
|
||||
|
||||
return {
|
||||
"status": "success",
|
||||
"message": f"Кейс '{case['name']}' открыт",
|
||||
"reward": chosen_item,
|
||||
"inventory_item_id": inventory_item["id"],
|
||||
"balance": new_balance
|
||||
}
|
||||
@ -1,58 +1,79 @@
|
||||
from datetime import datetime
|
||||
from app.db.database import users_collection, sessions_collection
|
||||
import re
|
||||
from app.db.database import users_collection
|
||||
from fastapi import HTTPException
|
||||
from app.db.database import db
|
||||
from app.realtime.coins_hub import coins_hub
|
||||
|
||||
MAX_MINUTES_PER_UPDATE = 120
|
||||
|
||||
coins_sessions_collection = db.coins_sessions
|
||||
|
||||
class CoinsService:
|
||||
_AFK_PREFIX_RE = re.compile(r"^\s*\[\s*AFK\s*\]", re.IGNORECASE)
|
||||
|
||||
@classmethod
|
||||
def _is_afk_name(cls, player_name: str) -> bool:
|
||||
if not player_name:
|
||||
return False
|
||||
return bool(cls._AFK_PREFIX_RE.match(player_name))
|
||||
|
||||
async def update_player_coins(self, player_id: str, player_name: str, online_time: int, server_ip: str):
|
||||
"""Обновляет монеты игрока на основе времени онлайн"""
|
||||
|
||||
# Находим пользователя
|
||||
|
||||
user = await self._find_user_by_uuid(player_id)
|
||||
if not user:
|
||||
return # Пользователь не найден
|
||||
|
||||
# Находим последнее обновление монет
|
||||
last_update = await sessions_collection.find_one({
|
||||
return
|
||||
|
||||
last_update = await coins_sessions_collection.find_one({
|
||||
"player_id": player_id,
|
||||
"server_ip": server_ip,
|
||||
"update_type": "coins_update"
|
||||
}, sort=[("timestamp", -1)])
|
||||
|
||||
now = datetime.now()
|
||||
|
||||
now = datetime.utcnow()
|
||||
|
||||
# AFK: монеты не начисляем, но обязательно фиксируем тик,
|
||||
# чтобы AFK-время не накопилось и не начислилось потом.
|
||||
if self._is_afk_name(player_name):
|
||||
await coins_sessions_collection.insert_one({
|
||||
"player_id": player_id,
|
||||
"player_name": player_name,
|
||||
"server_ip": server_ip,
|
||||
"update_type": "coins_update",
|
||||
"timestamp": now,
|
||||
"minutes_added": 0,
|
||||
"coins_added": 0,
|
||||
"note": "afk_skip"
|
||||
})
|
||||
return
|
||||
|
||||
current_coins = user.get("coins", 0)
|
||||
current_total_time = user.get("total_time_played", 0)
|
||||
|
||||
|
||||
if last_update:
|
||||
# Время с последнего начисления
|
||||
last_timestamp = last_update["timestamp"]
|
||||
seconds_since_update = int((now - last_timestamp).total_seconds())
|
||||
|
||||
# Начисляем монеты только за полные минуты
|
||||
seconds_since_update = int((now - last_update["timestamp"]).total_seconds())
|
||||
minutes_to_reward = seconds_since_update // 60
|
||||
|
||||
# Если прошло меньше минуты, пропускаем
|
||||
|
||||
if minutes_to_reward < 1:
|
||||
return
|
||||
|
||||
minutes_to_reward = min(minutes_to_reward, 1)
|
||||
else:
|
||||
# Первое обновление (ограничиваем для безопасности)
|
||||
minutes_to_reward = min(online_time // 60, 5)
|
||||
|
||||
|
||||
if minutes_to_reward > 0:
|
||||
# Обновляем монеты и время
|
||||
new_coins = current_coins + minutes_to_reward
|
||||
new_total_time = current_total_time + (minutes_to_reward * 60)
|
||||
|
||||
# Сохраняем в БД
|
||||
|
||||
await users_collection.update_one(
|
||||
{"_id": user["_id"]},
|
||||
{"$set": {
|
||||
"coins": new_coins,
|
||||
"total_time_played": new_total_time
|
||||
}}
|
||||
{"$set": {"coins": new_coins, "total_time_played": new_total_time}}
|
||||
)
|
||||
|
||||
# Сохраняем запись о начислении
|
||||
await sessions_collection.insert_one({
|
||||
|
||||
await coins_hub.send_update(user["username"], new_coins)
|
||||
|
||||
await coins_sessions_collection.insert_one({
|
||||
"player_id": player_id,
|
||||
"player_name": player_name,
|
||||
"server_ip": server_ip,
|
||||
@ -61,9 +82,6 @@ class CoinsService:
|
||||
"minutes_added": minutes_to_reward,
|
||||
"coins_added": minutes_to_reward
|
||||
})
|
||||
|
||||
print(f"[{now}] Игроку {user.get('username')} начислено {minutes_to_reward} монет. "
|
||||
f"Всего монет: {new_coins}")
|
||||
|
||||
async def _find_user_by_uuid(self, player_id: str):
|
||||
"""Находит пользователя по UUID с поддержкой разных форматов"""
|
||||
@ -123,7 +141,9 @@ class CoinsService:
|
||||
raise HTTPException(status_code=404, detail=f"Пользователь {username} не найден")
|
||||
|
||||
user = await users_collection.find_one({"username": username})
|
||||
return user.get("coins", 0)
|
||||
new_balance = user.get("coins", 0)
|
||||
await coins_hub.send_update(username, new_balance)
|
||||
return new_balance
|
||||
|
||||
async def decrease_balance(self, username: str, amount: int) -> int:
|
||||
"""Уменьшить баланс пользователя"""
|
||||
@ -139,4 +159,6 @@ class CoinsService:
|
||||
raise HTTPException(status_code=404, detail=f"Пользователь {username} не найден")
|
||||
|
||||
user = await users_collection.find_one({"username": username})
|
||||
return user.get("coins", 0)
|
||||
new_balance = user.get("coins", 0)
|
||||
await coins_hub.send_update(username, new_balance)
|
||||
return new_balance
|
||||
|
||||
440
app/services/dailyquests.py
Normal file
440
app/services/dailyquests.py
Normal file
@ -0,0 +1,440 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from datetime import datetime, timedelta, timezone
|
||||
from zoneinfo import ZoneInfo
|
||||
from typing import Any, Dict, List, Optional
|
||||
import random
|
||||
|
||||
from fastapi import HTTPException
|
||||
from app.db.database import db, users_collection
|
||||
|
||||
from app.services.coins import CoinsService
|
||||
|
||||
TZ = ZoneInfo("Asia/Yekaterinburg") # как в dailyreward :contentReference[oaicite:1]{index=1}
|
||||
|
||||
coins_sessions_collection = db.coins_sessions
|
||||
daily_quests_pool_collection = db.daily_quests_pool
|
||||
user_daily_quests_collection = db.user_daily_quests
|
||||
|
||||
|
||||
def _day_bounds_utc(now_utc: datetime):
|
||||
# копия подхода из dailyreward :contentReference[oaicite:2]{index=2}
|
||||
now_local = now_utc.astimezone(TZ)
|
||||
start_local = now_local.replace(hour=0, minute=0, second=0, microsecond=0)
|
||||
start_utc = start_local.astimezone(timezone.utc)
|
||||
next_utc = (start_local + timedelta(days=1)).astimezone(timezone.utc)
|
||||
return now_local.date(), start_utc, next_utc, start_local
|
||||
|
||||
|
||||
class DailyQuestsService:
|
||||
"""
|
||||
Daily Quests:
|
||||
- пул (daily_quests_pool) админит бекенд
|
||||
- игроку генерятся N рандомных заданий на локальный день (TZ)
|
||||
- прогресс обновляется событиями (mob_kill и т.п.)
|
||||
- награда выдаётся при claim (атомарно)
|
||||
"""
|
||||
|
||||
DEFAULT_DAILY_COUNT = 3
|
||||
|
||||
def _choose_by_difficulty(self, pool: List[dict]) -> List[dict]:
|
||||
# группируем по сложности
|
||||
buckets = {"easy": [], "medium": [], "hard": []}
|
||||
for it in pool:
|
||||
diff = str(it.get("difficulty", "")).lower()
|
||||
if diff in buckets:
|
||||
buckets[diff].append(it)
|
||||
|
||||
chosen: List[dict] = []
|
||||
|
||||
# по 1 из каждой сложности, weighted, без повторов
|
||||
for diff in ("easy", "medium", "hard"):
|
||||
items = buckets[diff]
|
||||
if not items:
|
||||
continue
|
||||
pick = random.choices(
|
||||
items,
|
||||
weights=[max(1, int(x.get("weight", 1) or 1)) for x in items],
|
||||
k=1
|
||||
)[0]
|
||||
chosen.append(pick)
|
||||
|
||||
# если чего-то не хватило (например нет hard в пуле) —
|
||||
# добиваем случайными из оставшихся enabled, чтобы всё равно было 3
|
||||
need_total = 3
|
||||
if len(chosen) < need_total:
|
||||
already = {c.get("key") for c in chosen}
|
||||
rest = [x for x in pool if x.get("key") not in already]
|
||||
if rest:
|
||||
extra = self._weighted_sample_without_replacement(
|
||||
rest, k=min(need_total - len(chosen), len(rest))
|
||||
)
|
||||
chosen.extend(extra)
|
||||
|
||||
return chosen
|
||||
|
||||
async def get_status(self, username: str) -> dict:
|
||||
now_utc = datetime.now(timezone.utc)
|
||||
today_local, start_today_utc, start_tomorrow_utc, start_today_local = _day_bounds_utc(now_utc)
|
||||
day_key = today_local.isoformat()
|
||||
|
||||
user = await users_collection.find_one({"username": username})
|
||||
if not user:
|
||||
return {"ok": False, "reason": "user_not_found"}
|
||||
|
||||
doc = await self._get_or_generate_for_day(username=username, day_key=day_key)
|
||||
|
||||
# (опционально как dailyreward) — требовать “онлайн сегодня”
|
||||
was_online_today = await coins_sessions_collection.find_one({
|
||||
"player_name": username,
|
||||
"update_type": "coins_update",
|
||||
"timestamp": {
|
||||
"$gte": start_today_utc.replace(tzinfo=None),
|
||||
"$lt": start_tomorrow_utc.replace(tzinfo=None),
|
||||
},
|
||||
})
|
||||
|
||||
seconds_to_next = int((start_tomorrow_utc - now_utc).total_seconds())
|
||||
if seconds_to_next < 0:
|
||||
seconds_to_next = 0
|
||||
|
||||
return {
|
||||
"ok": True,
|
||||
"day": day_key,
|
||||
"was_online_today": bool(was_online_today),
|
||||
"seconds_to_next": seconds_to_next,
|
||||
"next_reset_at_utc": start_tomorrow_utc.isoformat().replace("+00:00", "Z"),
|
||||
"next_reset_at_local": (start_today_local + timedelta(days=1)).isoformat(),
|
||||
"quests": doc.get("quests", []),
|
||||
}
|
||||
|
||||
async def claim(self, username: str, quest_key: str) -> dict:
|
||||
now_utc = datetime.now(timezone.utc)
|
||||
today_local, start_today_utc, start_tomorrow_utc, _ = _day_bounds_utc(now_utc)
|
||||
day_key = today_local.isoformat()
|
||||
|
||||
user = await users_collection.find_one({"username": username})
|
||||
if not user:
|
||||
return {"claimed": False, "reason": "user_not_found"}
|
||||
|
||||
# (опционально) требование “онлайн сегодня” как dailyreward :contentReference[oaicite:3]{index=3}
|
||||
was_online_today = await coins_sessions_collection.find_one({
|
||||
"player_name": username,
|
||||
"update_type": "coins_update",
|
||||
"timestamp": {
|
||||
"$gte": start_today_utc.replace(tzinfo=None),
|
||||
"$lt": start_tomorrow_utc.replace(tzinfo=None),
|
||||
},
|
||||
})
|
||||
if not was_online_today:
|
||||
return {
|
||||
"claimed": False,
|
||||
"reason": "not_online_today",
|
||||
"message": "Вы должны зайти на сервер сегодня, чтобы получить награду за квест",
|
||||
}
|
||||
|
||||
# найдём квест, чтобы узнать reward (но выдачу делаем атомарно)
|
||||
doc = await self._get_or_generate_for_day(username=username, day_key=day_key)
|
||||
quest = next((q for q in doc.get("quests", []) if q.get("key") == quest_key), None)
|
||||
if not quest:
|
||||
return {"claimed": False, "reason": "quest_not_found"}
|
||||
|
||||
if quest.get("status") == "claimed":
|
||||
return {"claimed": False, "reason": "already_claimed"}
|
||||
|
||||
if quest.get("status") != "completed":
|
||||
return {"claimed": False, "reason": "not_completed"}
|
||||
|
||||
reward = int(quest.get("reward", 0) or 0)
|
||||
if reward <= 0:
|
||||
return {"claimed": False, "reason": "invalid_reward"}
|
||||
|
||||
# 1) атомарно помечаем quest claimed (только если он completed и ещё не claimed)
|
||||
res = await user_daily_quests_collection.update_one(
|
||||
{
|
||||
"username": username,
|
||||
"day": day_key,
|
||||
"quests": {"$elemMatch": {"key": quest_key, "status": "completed"}},
|
||||
},
|
||||
{
|
||||
"$set": {"quests.$.status": "claimed", "quests.$.claimed_at": now_utc.replace(tzinfo=None)}
|
||||
},
|
||||
)
|
||||
|
||||
if res.modified_count == 0:
|
||||
# кто-то уже заклеймил или статус не completed
|
||||
doc2 = await user_daily_quests_collection.find_one({"username": username, "day": day_key})
|
||||
q2 = next((q for q in (doc2 or {}).get("quests", []) if q.get("key") == quest_key), None)
|
||||
if q2 and q2.get("status") == "claimed":
|
||||
return {"claimed": False, "reason": "already_claimed"}
|
||||
return {"claimed": False, "reason": "not_completed"}
|
||||
|
||||
# 2) начисляем coins
|
||||
coins_service = CoinsService()
|
||||
await coins_service.increase_balance(username, reward)
|
||||
|
||||
# 3) лог в coins_sessions (как daily_login) :contentReference[oaicite:4]{index=4}
|
||||
await coins_sessions_collection.insert_one({
|
||||
"player_name": username,
|
||||
"update_type": "daily_quest",
|
||||
"timestamp": now_utc.replace(tzinfo=None),
|
||||
"quest_key": quest_key,
|
||||
"coins_added": reward,
|
||||
"day": day_key,
|
||||
})
|
||||
|
||||
return {"claimed": True, "quest_key": quest_key, "coins_added": reward, "day": day_key}
|
||||
|
||||
async def _auto_claim_completed(self, username: str, day_key: str) -> int:
|
||||
"""
|
||||
Автоматически забирает награду за все completed квесты, которые ещё не claimed.
|
||||
Возвращает сумму начисленных коинов.
|
||||
"""
|
||||
now_utc = datetime.now(timezone.utc)
|
||||
|
||||
doc = await user_daily_quests_collection.find_one({"username": username, "day": day_key})
|
||||
if not doc:
|
||||
return 0
|
||||
|
||||
total_added = 0
|
||||
|
||||
# IMPORTANT: идём по каждому completed и пытаемся атомарно "перевести" в claimed
|
||||
for q in doc.get("quests", []):
|
||||
if q.get("status") != "completed":
|
||||
continue
|
||||
|
||||
quest_key = q.get("key")
|
||||
reward = int(q.get("reward", 0) or 0)
|
||||
if not quest_key or reward <= 0:
|
||||
continue
|
||||
|
||||
# атомарно: только если всё ещё completed
|
||||
res = await user_daily_quests_collection.update_one(
|
||||
{
|
||||
"username": username,
|
||||
"day": day_key,
|
||||
"quests": {"$elemMatch": {"key": quest_key, "status": "completed"}},
|
||||
},
|
||||
{"$set": {"quests.$.status": "claimed", "quests.$.claimed_at": now_utc.replace(tzinfo=None)}},
|
||||
)
|
||||
|
||||
if res.modified_count == 0:
|
||||
continue # уже claimed/не completed
|
||||
|
||||
# начисляем coins
|
||||
coins_service = CoinsService()
|
||||
await coins_service.increase_balance(username, reward)
|
||||
total_added += reward
|
||||
|
||||
# лог (как в claim) :contentReference[oaicite:2]{index=2}
|
||||
await coins_sessions_collection.insert_one({
|
||||
"player_name": username,
|
||||
"update_type": "daily_quest",
|
||||
"timestamp": now_utc.replace(tzinfo=None),
|
||||
"quest_key": quest_key,
|
||||
"coins_added": reward,
|
||||
"day": day_key,
|
||||
"auto": True,
|
||||
})
|
||||
|
||||
return total_added
|
||||
|
||||
async def on_mob_kill(self, username: str, mob: str, count: int = 1) -> dict:
|
||||
"""
|
||||
Вызывается EventService при событии mob_kill.
|
||||
mob: например "SPIDER"
|
||||
"""
|
||||
if count <= 0:
|
||||
return {"ok": True, "updated": 0}
|
||||
|
||||
now_utc = datetime.now(timezone.utc)
|
||||
today_local, _, _, _ = _day_bounds_utc(now_utc)
|
||||
day_key = today_local.isoformat()
|
||||
|
||||
doc = await self._get_or_generate_for_day(username=username, day_key=day_key)
|
||||
|
||||
updated = 0
|
||||
quests = doc.get("quests", [])
|
||||
|
||||
# пройдёмся по квестам и обновим подходящие
|
||||
for q in quests:
|
||||
if q.get("status") in ("claimed",):
|
||||
continue
|
||||
if q.get("event") != "mob_kill":
|
||||
continue
|
||||
if str(q.get("target")).upper() != str(mob).upper():
|
||||
continue
|
||||
|
||||
need = int(q.get("required", 0) or 0)
|
||||
if need <= 0:
|
||||
continue
|
||||
|
||||
# инкремент прогресса атомарно по элементу массива
|
||||
res = await user_daily_quests_collection.update_one(
|
||||
{"username": username, "day": day_key, "quests.key": q.get("key")},
|
||||
{"$inc": {"quests.$.progress": int(count)}},
|
||||
)
|
||||
if res.modified_count:
|
||||
updated += 1
|
||||
|
||||
# после инкрементов — доведём status до completed там, где progress >= required
|
||||
# (делаем 1 запросом: вытянем документ и проставим статусы на сервере, чтобы было проще и надёжнее)
|
||||
doc2 = await user_daily_quests_collection.find_one({"username": username, "day": day_key})
|
||||
if not doc2:
|
||||
return {"ok": True, "updated": updated}
|
||||
|
||||
changed = False
|
||||
for q in doc2.get("quests", []):
|
||||
if q.get("status") in ("completed", "claimed"):
|
||||
continue
|
||||
need = int(q.get("required", 0) or 0)
|
||||
prog = int(q.get("progress", 0) or 0)
|
||||
if need > 0 and prog >= need:
|
||||
q["status"] = "completed"
|
||||
q["completed_at"] = now_utc.replace(tzinfo=None)
|
||||
changed = True
|
||||
|
||||
if changed:
|
||||
await user_daily_quests_collection.update_one(
|
||||
{"_id": doc2["_id"]},
|
||||
{"$set": {"quests": doc2["quests"]}},
|
||||
)
|
||||
|
||||
coins_added = await self._auto_claim_completed(username, day_key)
|
||||
return {"ok": True, "updated": updated, "coins_added": coins_added}
|
||||
|
||||
async def _get_or_generate_for_day(self, username: str, day_key: str) -> dict:
|
||||
existing = await user_daily_quests_collection.find_one({"username": username, "day": day_key})
|
||||
if existing:
|
||||
return existing
|
||||
|
||||
# генерируем
|
||||
pool = await daily_quests_pool_collection.find({"enabled": True}).to_list(1000)
|
||||
|
||||
if not pool:
|
||||
# нет активных квестов — всё равно создадим пустой документ
|
||||
doc = {
|
||||
"username": username,
|
||||
"day": day_key,
|
||||
"quests": [],
|
||||
"created_at": datetime.now(timezone.utc).replace(tzinfo=None),
|
||||
}
|
||||
await user_daily_quests_collection.insert_one(doc)
|
||||
return doc
|
||||
|
||||
chosen = self._choose_by_difficulty(pool)
|
||||
|
||||
quests = []
|
||||
for tpl in chosen:
|
||||
min_req = int(tpl.get("min_required", 1) or 1)
|
||||
max_req = int(tpl.get("max_required", min_req) or min_req)
|
||||
if max_req < min_req:
|
||||
max_req = min_req
|
||||
|
||||
required = random.randint(min_req, max_req)
|
||||
reward_per_unit = int(tpl.get("reward_per_unit", 1) or 1)
|
||||
reward = max(1, required * reward_per_unit)
|
||||
|
||||
quests.append({
|
||||
"key": tpl["key"],
|
||||
"title": self._render_title(tpl, required),
|
||||
"event": tpl.get("event"),
|
||||
"target": tpl.get("target"),
|
||||
"required": required,
|
||||
"progress": 0,
|
||||
"reward": reward,
|
||||
"status": "active",
|
||||
})
|
||||
|
||||
doc = {
|
||||
"username": username,
|
||||
"day": day_key,
|
||||
"quests": quests,
|
||||
"created_at": datetime.now(timezone.utc).replace(tzinfo=None),
|
||||
}
|
||||
await user_daily_quests_collection.insert_one(doc)
|
||||
return doc
|
||||
|
||||
async def on_active_time_tick(self, username: str, seconds: int) -> dict:
|
||||
"""
|
||||
Начисляет активное время игроку (секунды) и обновляет квесты event == active_time.
|
||||
"""
|
||||
if seconds <= 0:
|
||||
return {"ok": True, "updated": 0, "coins_added": 0}
|
||||
|
||||
minutes = int(seconds) // 60
|
||||
if minutes <= 0:
|
||||
return {"ok": True, "updated": 0, "coins_added": 0}
|
||||
|
||||
now_utc = datetime.now(timezone.utc)
|
||||
today_local, _, _, _ = _day_bounds_utc(now_utc)
|
||||
day_key = today_local.isoformat()
|
||||
|
||||
doc = await self._get_or_generate_for_day(username=username, day_key=day_key)
|
||||
quests = doc.get("quests", [])
|
||||
updated = 0
|
||||
|
||||
# инкрементим прогресс (секунды)
|
||||
for q in quests:
|
||||
if q.get("status") in ("claimed",):
|
||||
continue
|
||||
if q.get("event") != "active_time":
|
||||
continue
|
||||
|
||||
need = int(q.get("required", 0) or 0)
|
||||
if need <= 0:
|
||||
continue
|
||||
|
||||
res = await user_daily_quests_collection.update_one(
|
||||
{"username": username, "day": day_key, "quests.key": q.get("key")},
|
||||
{"$inc": {"quests.$.progress": minutes}},
|
||||
)
|
||||
if res.modified_count:
|
||||
updated += 1
|
||||
|
||||
# доводим статусы до completed
|
||||
doc2 = await user_daily_quests_collection.find_one({"username": username, "day": day_key})
|
||||
if not doc2:
|
||||
return {"ok": True, "updated": updated, "coins_added": 0}
|
||||
|
||||
changed = False
|
||||
for q in doc2.get("quests", []):
|
||||
if q.get("status") in ("completed", "claimed"):
|
||||
continue
|
||||
if q.get("event") != "active_time":
|
||||
continue
|
||||
|
||||
need = int(q.get("required", 0) or 0)
|
||||
prog = int(q.get("progress", 0) or 0)
|
||||
if need > 0 and prog >= need:
|
||||
q["status"] = "completed"
|
||||
q["completed_at"] = now_utc.replace(tzinfo=None)
|
||||
changed = True
|
||||
|
||||
if changed:
|
||||
await user_daily_quests_collection.update_one(
|
||||
{"_id": doc2["_id"]},
|
||||
{"$set": {"quests": doc2["quests"]}},
|
||||
)
|
||||
|
||||
coins_added = await self._auto_claim_completed(username, day_key)
|
||||
return {"ok": True, "updated": updated, "coins_added": coins_added}
|
||||
|
||||
def _render_title(self, tpl: dict, required: int) -> str:
|
||||
# если в пуле есть "title" как шаблон — используем
|
||||
title = tpl.get("title") or tpl.get("key")
|
||||
# простой хак: если в title нет числа, добавим
|
||||
if any(ch.isdigit() for ch in title):
|
||||
return title
|
||||
return f"{title} x{required}"
|
||||
|
||||
def _weighted_sample_without_replacement(self, items: List[dict], k: int) -> List[dict]:
|
||||
# простая реализация без внешних либ
|
||||
pool = items[:]
|
||||
chosen = []
|
||||
for _ in range(k):
|
||||
weights = [max(1, int(it.get("weight", 1) or 1)) for it in pool]
|
||||
pick = random.choices(pool, weights=weights, k=1)[0]
|
||||
chosen.append(pick)
|
||||
pool.remove(pick)
|
||||
return chosen
|
||||
148
app/services/dailyreward.py
Normal file
148
app/services/dailyreward.py
Normal file
@ -0,0 +1,148 @@
|
||||
from datetime import datetime, timedelta, timezone
|
||||
from zoneinfo import ZoneInfo
|
||||
from app.db.database import users_collection, db
|
||||
from app.realtime.coins_hub import coins_hub
|
||||
|
||||
coins_sessions_collection = db.coins_sessions
|
||||
|
||||
TZ = ZoneInfo("Asia/Yekaterinburg")
|
||||
|
||||
def _day_bounds_utc(now_utc: datetime):
|
||||
now_local = now_utc.astimezone(TZ)
|
||||
start_local = now_local.replace(hour=0, minute=0, second=0, microsecond=0)
|
||||
start_utc = start_local.astimezone(timezone.utc)
|
||||
next_utc = (start_local + timedelta(days=1)).astimezone(timezone.utc)
|
||||
return now_local.date(), start_utc, next_utc, start_local
|
||||
|
||||
class DailyRewardService:
|
||||
async def claim_daily(self, username: str) -> dict:
|
||||
now_utc = datetime.now(timezone.utc)
|
||||
today_local, start_today_utc, start_tomorrow_utc, _ = _day_bounds_utc(now_utc)
|
||||
|
||||
user = await users_collection.find_one({"username": username})
|
||||
if not user:
|
||||
return {"claimed": False, "reason": "user_not_found"}
|
||||
|
||||
was_online_today = await coins_sessions_collection.find_one(
|
||||
{
|
||||
"player_name": username,
|
||||
"update_type": "coins_update",
|
||||
"timestamp": {
|
||||
"$gte": start_today_utc.replace(tzinfo=None),
|
||||
"$lt": start_tomorrow_utc.replace(tzinfo=None),
|
||||
},
|
||||
}
|
||||
)
|
||||
|
||||
if not was_online_today:
|
||||
return {
|
||||
"claimed": False,
|
||||
"reason": "not_online_today",
|
||||
"message": "Вы должны зайти на сервер сегодня, чтобы получить ежедневную награду",
|
||||
}
|
||||
|
||||
last_claim_at = user.get("daily_last_claim_at") # ожидаем datetime (лучше хранить UTC)
|
||||
last_local_day = last_claim_at.replace(tzinfo=timezone.utc).astimezone(TZ).date() if last_claim_at else None
|
||||
|
||||
if last_local_day == today_local:
|
||||
return {"claimed": False, "reason": "already_claimed_today", "streak": user.get("daily_streak", 0)}
|
||||
|
||||
yesterday_local = today_local - timedelta(days=1)
|
||||
prev_streak = int(user.get("daily_streak", 0) or 0)
|
||||
new_streak = (prev_streak + 1) if (last_local_day == yesterday_local) else 1
|
||||
|
||||
# твоя новая формула: 10, 20, 30 ... до 50 (кап)
|
||||
reward = min(10 + (new_streak - 1) * 10, 50)
|
||||
|
||||
result = await users_collection.update_one(
|
||||
{
|
||||
"username": username,
|
||||
"$or": [
|
||||
{"daily_last_claim_at": {"$exists": False}},
|
||||
{"daily_last_claim_at": {"$lt": start_today_utc.replace(tzinfo=None)}},
|
||||
],
|
||||
},
|
||||
{
|
||||
"$inc": {"coins": reward},
|
||||
"$set": {"daily_last_claim_at": now_utc.replace(tzinfo=None), "daily_streak": new_streak},
|
||||
},
|
||||
)
|
||||
|
||||
if result.modified_count == 0:
|
||||
user2 = await users_collection.find_one({"username": username})
|
||||
return {"claimed": False, "reason": "already_claimed_today", "streak": user2.get("daily_streak", 0)}
|
||||
|
||||
new_balance = (await users_collection.find_one({"username": username})).get("coins", 0)
|
||||
await coins_hub.send_update(username, new_balance)
|
||||
|
||||
await coins_sessions_collection.insert_one({
|
||||
"player_name": username,
|
||||
"update_type": "daily_login",
|
||||
"timestamp": now_utc.replace(tzinfo=None),
|
||||
"coins_added": reward,
|
||||
"streak": new_streak,
|
||||
})
|
||||
|
||||
return {"claimed": True, "coins_added": reward, "streak": new_streak}
|
||||
|
||||
async def get_status(self, username: str) -> dict:
|
||||
now_utc = datetime.now(timezone.utc)
|
||||
today_local, start_today_utc, start_tomorrow_utc, start_today_local = _day_bounds_utc(now_utc)
|
||||
|
||||
user = await users_collection.find_one({"username": username})
|
||||
if not user:
|
||||
return {"ok": False, "reason": "user_not_found"}
|
||||
|
||||
was_online_today = await coins_sessions_collection.find_one({
|
||||
"player_name": username,
|
||||
"update_type": "coins_update",
|
||||
"timestamp": {
|
||||
"$gte": start_today_utc.replace(tzinfo=None),
|
||||
"$lt": start_tomorrow_utc.replace(tzinfo=None),
|
||||
},
|
||||
})
|
||||
|
||||
last_claim_at = user.get("daily_last_claim_at")
|
||||
last_local_day = last_claim_at.replace(tzinfo=timezone.utc).astimezone(TZ).date() if last_claim_at else None
|
||||
|
||||
can_claim = (last_local_day != today_local) and bool(was_online_today)
|
||||
|
||||
seconds_to_next = 0 if can_claim else int((start_tomorrow_utc - now_utc).total_seconds())
|
||||
if seconds_to_next < 0:
|
||||
seconds_to_next = 0
|
||||
|
||||
return {
|
||||
"ok": True,
|
||||
"can_claim": can_claim,
|
||||
"was_online_today": bool(was_online_today),
|
||||
"seconds_to_next": seconds_to_next,
|
||||
"next_claim_at_utc": start_tomorrow_utc.isoformat().replace("+00:00", "Z"),
|
||||
"next_claim_at_local": (start_today_local + timedelta(days=1)).isoformat(),
|
||||
"streak": int(user.get("daily_streak", 0) or 0),
|
||||
}
|
||||
|
||||
async def get_claim_days(self, username: str, limit: int = 60) -> dict:
|
||||
# Берём последние N записей daily_login и превращаем в список уникальных дней по ЕКБ
|
||||
cursor = coins_sessions_collection.find(
|
||||
{"player_name": username, "update_type": "daily_login"},
|
||||
{"timestamp": 1, "_id": 0},
|
||||
).sort("timestamp", -1).limit(limit)
|
||||
|
||||
days = []
|
||||
seen = set()
|
||||
|
||||
async for doc in cursor:
|
||||
ts = doc.get("timestamp")
|
||||
if not ts:
|
||||
continue
|
||||
|
||||
# У тебя timestamp в Mongo — naive UTC (now_utc.replace(tzinfo=None)) :contentReference[oaicite:1]{index=1}
|
||||
ts_utc = ts.replace(tzinfo=timezone.utc)
|
||||
day_local = ts_utc.astimezone(TZ).date().isoformat() # YYYY-MM-DD по ЕКБ
|
||||
|
||||
if day_local not in seen:
|
||||
seen.add(day_local)
|
||||
days.append(day_local)
|
||||
|
||||
days.reverse() # чтобы было по возрастанию (старые → новые), если надо
|
||||
return {"ok": True, "days": days, "count": len(days)}
|
||||
95
app/services/inventory.py
Normal file
95
app/services/inventory.py
Normal file
@ -0,0 +1,95 @@
|
||||
from datetime import datetime
|
||||
from uuid import uuid4
|
||||
from fastapi import HTTPException
|
||||
from app.db.database import db
|
||||
|
||||
player_inventory_collection = db.player_inventory
|
||||
marketplace_operations_collection = db.marketplace_operations
|
||||
|
||||
def _serialize_mongodb_doc(doc):
|
||||
"""Преобразует MongoDB документ для JSON сериализации"""
|
||||
if doc is None:
|
||||
return None
|
||||
|
||||
# Добавить проверку на список
|
||||
if isinstance(doc, list):
|
||||
return [_serialize_mongodb_doc(item) for item in doc]
|
||||
|
||||
result = {}
|
||||
for key, value in doc.items():
|
||||
# Обработка ObjectId
|
||||
if key == "_id":
|
||||
result["_id"] = str(value)
|
||||
continue
|
||||
|
||||
# Обработка ISODate
|
||||
if isinstance(value, datetime):
|
||||
result[key] = value.isoformat()
|
||||
# Обработка вложенных словарей
|
||||
elif isinstance(value, dict):
|
||||
if "$date" in value:
|
||||
# Это ISODate
|
||||
result[key] = datetime.fromisoformat(value["$date"].replace("Z", "+00:00")).isoformat()
|
||||
else:
|
||||
result[key] = _serialize_mongodb_doc(value)
|
||||
# Обработка списков
|
||||
elif isinstance(value, list):
|
||||
result[key] = [_serialize_mongodb_doc(item) if isinstance(item, dict) else item for item in value]
|
||||
else:
|
||||
result[key] = value
|
||||
|
||||
return result
|
||||
|
||||
class InventoryService:
|
||||
async def list_items(self, username: str, server_ip: str, page: int = 1, limit: int = 20):
|
||||
q = {"username": username, "server_ip": server_ip, "status": {"$in": ["stored", "withdrawing"]}}
|
||||
skip = max(page - 1, 0) * limit
|
||||
|
||||
items = await player_inventory_collection.find(q) \
|
||||
.sort("created_at", -1) \
|
||||
.skip(skip) \
|
||||
.limit(limit) \
|
||||
.to_list(length=limit)
|
||||
|
||||
serialized_items = _serialize_mongodb_doc(items)
|
||||
total = await player_inventory_collection.count_documents(q)
|
||||
return {"items": serialized_items, "page": page, "limit": limit, "total": total}
|
||||
|
||||
async def withdraw_item(self, username: str, item_id: str, server_ip: str):
|
||||
item = await player_inventory_collection.find_one({"id": item_id})
|
||||
if not item:
|
||||
raise HTTPException(status_code=404, detail="Item not found")
|
||||
|
||||
if item["username"] != username:
|
||||
raise HTTPException(status_code=403, detail="Not your item")
|
||||
|
||||
if item["server_ip"] != server_ip:
|
||||
raise HTTPException(status_code=400, detail="Wrong server_ip for this item")
|
||||
|
||||
if item.get("status") != "stored":
|
||||
raise HTTPException(status_code=400, detail="Item is not available for withdraw")
|
||||
|
||||
# создаём операцию выдачи НА СЕРВЕР (тип оставляем case_reward)
|
||||
op_id = str(uuid4())
|
||||
operation = {
|
||||
"id": op_id,
|
||||
"type": "case_reward",
|
||||
"player_name": username,
|
||||
"item_data": item["item_data"],
|
||||
"server_ip": server_ip,
|
||||
"status": "pending",
|
||||
"created_at": datetime.utcnow(),
|
||||
|
||||
# важно: связка с инвентарём, чтобы confirm мог отметить delivered
|
||||
"inventory_item_id": item_id,
|
||||
"source": item.get("source"),
|
||||
}
|
||||
await marketplace_operations_collection.insert_one(operation)
|
||||
|
||||
# помечаем предмет как withdrawing
|
||||
await player_inventory_collection.update_one(
|
||||
{"id": item_id},
|
||||
{"$set": {"status": "withdrawing", "withdraw_operation_id": op_id}}
|
||||
)
|
||||
|
||||
return {"ok": True, "operation_id": op_id, "item_id": item_id}
|
||||
@ -4,6 +4,7 @@ from fastapi import HTTPException
|
||||
from app.db.database import db
|
||||
from app.services.coins import CoinsService
|
||||
from app.services.server.command import CommandService
|
||||
from app.realtime.marketplace_hub import marketplace_hub
|
||||
|
||||
# Коллекция для хранения товаров на торговой площадке
|
||||
marketplace_collection = db.marketplace_items
|
||||
@ -112,19 +113,28 @@ class MarketplaceService:
|
||||
}
|
||||
|
||||
async def confirm_operation(self, operation_id: str, status: str = "success", error: str = None):
|
||||
"""Подтвердить выполнение операции"""
|
||||
update = {
|
||||
"status": status
|
||||
}
|
||||
|
||||
update = {"status": status}
|
||||
if error:
|
||||
update["error"] = error
|
||||
|
||||
result = await marketplace_operations.update_one(
|
||||
{"id": operation_id},
|
||||
{"$set": update}
|
||||
)
|
||||
|
||||
|
||||
await marketplace_operations.update_one({"id": operation_id}, {"$set": update})
|
||||
|
||||
# ✅ ДОБАВИТЬ ЭТО:
|
||||
operation = await marketplace_operations.find_one({"id": operation_id})
|
||||
if operation and operation.get("type") == "case_reward" and operation.get("inventory_item_id"):
|
||||
inv_id = operation["inventory_item_id"]
|
||||
|
||||
if status in ("success", "completed", "done"):
|
||||
await db.player_inventory.update_one(
|
||||
{"id": inv_id},
|
||||
{"$set": {"status": "delivered", "delivered_at": datetime.utcnow()}}
|
||||
)
|
||||
elif status in ("failed", "error", "cancelled"):
|
||||
await db.player_inventory.update_one(
|
||||
{"id": inv_id},
|
||||
{"$set": {"status": "stored", "withdraw_operation_id": None}}
|
||||
)
|
||||
|
||||
return {"status": "success"}
|
||||
|
||||
async def update_item_details(self, operation_id: str, item_data: dict):
|
||||
@ -152,6 +162,15 @@ class MarketplaceService:
|
||||
}
|
||||
|
||||
await marketplace_collection.insert_one(marketplace_item)
|
||||
|
||||
await marketplace_hub.broadcast(
|
||||
marketplace_item["server_ip"],
|
||||
{
|
||||
"event": "market:item_listed",
|
||||
"server_ip": marketplace_item["server_ip"],
|
||||
"item": _serialize_mongodb_doc(marketplace_item),
|
||||
}
|
||||
)
|
||||
|
||||
# Обновляем операцию
|
||||
await marketplace_operations.update_one(
|
||||
@ -205,6 +224,16 @@ class MarketplaceService:
|
||||
|
||||
# 7. Удаляем предмет с торговой площадки
|
||||
await marketplace_collection.delete_one({"id": item_id})
|
||||
|
||||
await marketplace_hub.broadcast(
|
||||
item["server_ip"],
|
||||
{
|
||||
"event": "market:item_sold",
|
||||
"server_ip": item["server_ip"],
|
||||
"item_id": item_id,
|
||||
"buyer": buyer_username,
|
||||
}
|
||||
)
|
||||
|
||||
return {
|
||||
"status": "pending",
|
||||
@ -241,12 +270,58 @@ class MarketplaceService:
|
||||
|
||||
# Удаляем предмет с торговой площадки
|
||||
await marketplace_collection.delete_one({"id": item_id})
|
||||
|
||||
await marketplace_hub.broadcast(
|
||||
item["server_ip"],
|
||||
{
|
||||
"event": "market:item_cancelled",
|
||||
"server_ip": item["server_ip"],
|
||||
"item_id": item_id,
|
||||
"seller": username,
|
||||
}
|
||||
)
|
||||
|
||||
return {
|
||||
"status": "pending",
|
||||
"operation_id": operation_id,
|
||||
"message": "Предмет снят с продажи и будет возвращен в ваш инвентарь"
|
||||
}
|
||||
|
||||
async def list_items_by_seller(
|
||||
self,
|
||||
username: str,
|
||||
server_ip: str = None,
|
||||
page: int = 1,
|
||||
limit: int = 20
|
||||
):
|
||||
"""Получить товары, выставленные конкретным продавцом"""
|
||||
query = {
|
||||
"seller_name": username
|
||||
}
|
||||
|
||||
if server_ip:
|
||||
query["server_ip"] = server_ip
|
||||
|
||||
total = await marketplace_collection.count_documents(query)
|
||||
|
||||
items_cursor = (
|
||||
marketplace_collection
|
||||
.find(query)
|
||||
.sort("created_at", -1)
|
||||
.skip((page - 1) * limit)
|
||||
.limit(limit)
|
||||
)
|
||||
|
||||
items = await items_cursor.to_list(limit)
|
||||
|
||||
serialized_items = [_serialize_mongodb_doc(item) for item in items]
|
||||
|
||||
return {
|
||||
"items": serialized_items,
|
||||
"total": total,
|
||||
"page": page,
|
||||
"pages": (total + limit - 1) // limit
|
||||
}
|
||||
|
||||
async def update_item_price(self, username: str, item_id: str, new_price: int):
|
||||
"""Обновить цену предмета на торговой площадке"""
|
||||
@ -268,11 +343,63 @@ class MarketplaceService:
|
||||
{"id": item_id},
|
||||
{"$set": {"price": new_price}}
|
||||
)
|
||||
|
||||
|
||||
if result.modified_count == 0:
|
||||
raise HTTPException(status_code=500, detail="Не удалось обновить цену предмета")
|
||||
|
||||
updated = await marketplace_collection.find_one({"id": item_id})
|
||||
if updated:
|
||||
await marketplace_hub.broadcast(
|
||||
updated["server_ip"],
|
||||
{
|
||||
"event": "market:item_price_updated",
|
||||
"server_ip": updated["server_ip"],
|
||||
"item": _serialize_mongodb_doc(updated),
|
||||
}
|
||||
)
|
||||
|
||||
return {
|
||||
"status": "success",
|
||||
"message": f"Цена предмета обновлена на {new_price} монет"
|
||||
}
|
||||
|
||||
async def list_operations(
|
||||
self,
|
||||
server_ip: str = None,
|
||||
player_name: str = None,
|
||||
status: str = None,
|
||||
op_type: str = None,
|
||||
page: int = 1,
|
||||
limit: int = 20
|
||||
):
|
||||
"""Получить операции маркетплейса (все или по игроку)"""
|
||||
query = {}
|
||||
|
||||
if server_ip:
|
||||
query["server_ip"] = server_ip
|
||||
if player_name:
|
||||
query["player_name"] = player_name
|
||||
if status:
|
||||
query["status"] = status
|
||||
if op_type:
|
||||
query["type"] = op_type
|
||||
|
||||
total = await marketplace_operations.count_documents(query)
|
||||
|
||||
cursor = (
|
||||
marketplace_operations
|
||||
.find(query)
|
||||
.sort("created_at", -1)
|
||||
.skip((page - 1) * limit)
|
||||
.limit(limit)
|
||||
)
|
||||
|
||||
ops = await cursor.to_list(limit)
|
||||
serialized_ops = _serialize_mongodb_doc(ops)
|
||||
|
||||
return {
|
||||
"operations": serialized_ops,
|
||||
"total": total,
|
||||
"page": page,
|
||||
"pages": (total + limit - 1) // limit
|
||||
}
|
||||
|
||||
97
app/services/news.py
Normal file
97
app/services/news.py
Normal file
@ -0,0 +1,97 @@
|
||||
from datetime import datetime
|
||||
from typing import List, Optional
|
||||
from fastapi import HTTPException
|
||||
from app.db.database import db
|
||||
from bson import ObjectId
|
||||
from app.models.news import NewsCreate, NewsUpdate, NewsInDB
|
||||
|
||||
news_collection = db["news"]
|
||||
|
||||
class NewsService:
|
||||
@staticmethod
|
||||
def _to_news_in_db(doc) -> NewsInDB:
|
||||
return NewsInDB(
|
||||
id=str(doc["_id"]),
|
||||
title=doc["title"],
|
||||
markdown=doc["markdown"],
|
||||
preview=doc.get("preview"),
|
||||
tags=doc.get("tags", []),
|
||||
is_published=doc.get("is_published", True),
|
||||
created_at=doc["created_at"],
|
||||
updated_at=doc["updated_at"],
|
||||
)
|
||||
|
||||
async def list_news(self, limit: int = 20, skip: int = 0, include_unpublished: bool = False) -> List[NewsInDB]:
|
||||
query = {}
|
||||
if not include_unpublished:
|
||||
query["is_published"] = True
|
||||
|
||||
cursor = (
|
||||
news_collection
|
||||
.find(query)
|
||||
.sort("created_at", -1)
|
||||
.skip(skip)
|
||||
.limit(limit)
|
||||
)
|
||||
docs = await cursor.to_list(length=limit)
|
||||
return [self._to_news_in_db(d) for d in docs]
|
||||
|
||||
async def get_news(self, news_id: str) -> NewsInDB:
|
||||
try:
|
||||
oid = ObjectId(news_id)
|
||||
except:
|
||||
raise HTTPException(status_code=400, detail="Invalid news id")
|
||||
|
||||
doc = await news_collection.find_one({"_id": oid})
|
||||
if not doc:
|
||||
raise HTTPException(status_code=404, detail="News not found")
|
||||
return self._to_news_in_db(doc)
|
||||
|
||||
async def create_news(self, payload: NewsCreate) -> NewsInDB:
|
||||
now = datetime.utcnow()
|
||||
doc = {
|
||||
"title": payload.title,
|
||||
"markdown": payload.markdown,
|
||||
"preview": payload.preview,
|
||||
"tags": payload.tags,
|
||||
"is_published": payload.is_published,
|
||||
"created_at": now,
|
||||
"updated_at": now,
|
||||
}
|
||||
result = await news_collection.insert_one(doc)
|
||||
doc["_id"] = result.inserted_id
|
||||
return self._to_news_in_db(doc)
|
||||
|
||||
async def update_news(self, news_id: str, payload: NewsUpdate) -> NewsInDB:
|
||||
try:
|
||||
oid = ObjectId(news_id)
|
||||
except:
|
||||
raise HTTPException(status_code=400, detail="Invalid news id")
|
||||
|
||||
update_data = {k: v for k, v in payload.dict(exclude_unset=True).items()}
|
||||
if not update_data:
|
||||
return await self.get_news(news_id)
|
||||
|
||||
update_data["updated_at"] = datetime.utcnow()
|
||||
|
||||
result = await news_collection.find_one_and_update(
|
||||
{"_id": oid},
|
||||
{"$set": update_data},
|
||||
return_document=True,
|
||||
)
|
||||
|
||||
if not result:
|
||||
raise HTTPException(status_code=404, detail="News not found")
|
||||
|
||||
return self._to_news_in_db(result)
|
||||
|
||||
async def delete_news(self, news_id: str):
|
||||
try:
|
||||
oid = ObjectId(news_id)
|
||||
except:
|
||||
raise HTTPException(status_code=400, detail="Invalid news id")
|
||||
|
||||
result = await news_collection.delete_one({"_id": oid})
|
||||
if result.deleted_count == 0:
|
||||
raise HTTPException(status_code=404, detail="News not found")
|
||||
return {"status": "success"}
|
||||
156
app/services/promo.py
Normal file
156
app/services/promo.py
Normal file
@ -0,0 +1,156 @@
|
||||
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
|
||||
}
|
||||
@ -4,6 +4,7 @@ import json
|
||||
from app.services.coins import CoinsService
|
||||
from app.models.server.event import PlayerEvent, OnlinePlayersUpdate
|
||||
import uuid
|
||||
from app.services.dailyquests import DailyQuestsService
|
||||
|
||||
class EventService:
|
||||
def __init__(self):
|
||||
@ -33,6 +34,14 @@ class EventService:
|
||||
# Обновляем данные об онлайн игроках
|
||||
players = event_data.get("players", [])
|
||||
await self._update_online_players(server_ip, players)
|
||||
|
||||
tick_seconds = 60
|
||||
|
||||
for p in players:
|
||||
name = p.get("player_name")
|
||||
if name and not self.coins_service._is_afk_name(name):
|
||||
await DailyQuestsService().on_active_time_tick(name, tick_seconds)
|
||||
|
||||
return {"status": "success"}
|
||||
|
||||
elif event_type == "player_join":
|
||||
@ -69,6 +78,17 @@ class EventService:
|
||||
await self._process_player_session(server_ip, player_id, player_name, duration)
|
||||
return {"status": "success"}
|
||||
|
||||
elif event_type == "mob_kill":
|
||||
player_name = event_data.get("player_name")
|
||||
mob = event_data.get("mob")
|
||||
count = int(event_data.get("count", 1) or 1)
|
||||
|
||||
if not player_name or not mob:
|
||||
raise HTTPException(status_code=400, detail="Missing mob_kill data")
|
||||
|
||||
await DailyQuestsService().on_mob_kill(player_name, mob, count)
|
||||
return {"status": "success"}
|
||||
|
||||
# Если тип события не распознан
|
||||
print(f"[{datetime.now()}] Неизвестное событие: {event_data}")
|
||||
raise HTTPException(status_code=400, detail="Invalid event type")
|
||||
@ -284,7 +304,7 @@ class EventService:
|
||||
})
|
||||
|
||||
# Начисляем коины за время игры
|
||||
coins_service = CoinsService()
|
||||
await coins_service.update_player_coins(player_id, player_name, duration, server_ip)
|
||||
# coins_service = CoinsService()
|
||||
# await coins_service.update_player_coins(player_id, player_name, duration, server_ip)
|
||||
|
||||
print(f"[{datetime.now()}] Сессия игрока {player_name} завершена, длительность: {duration} сек.")
|
||||
|
||||
@ -3,6 +3,7 @@ from app.db.database import db, users_collection
|
||||
from app.models.server.prank import PrankCommand, PrankCommandUpdate
|
||||
from datetime import datetime, timedelta
|
||||
import uuid
|
||||
from app.services.coins import CoinsService
|
||||
from app.services.server.command import CommandService
|
||||
|
||||
# Создаем коллекции для хранения пакостей и серверов
|
||||
@ -266,6 +267,12 @@ class PrankService:
|
||||
|
||||
command_result = await command_service.add_command(server_command)
|
||||
|
||||
coins_service = CoinsService()
|
||||
remaining = await coins_service.decrease_balance(
|
||||
username=username,
|
||||
amount=command["price"]
|
||||
)
|
||||
|
||||
# Логируем выполнение пакости
|
||||
log_entry = {
|
||||
"user_id": user["_id"],
|
||||
@ -284,5 +291,5 @@ class PrankService:
|
||||
return {
|
||||
"status": "success",
|
||||
"message": f"Команда '{command['name']}' успешно выполнена на игроке {target_player}",
|
||||
"remaining_coins": user_coins - command["price"]
|
||||
"remaining_coins": remaining
|
||||
}
|
||||
|
||||
@ -1,7 +1,7 @@
|
||||
from fastapi import HTTPException, UploadFile
|
||||
from datetime import datetime
|
||||
from app.db.database import users_collection
|
||||
from app.core.config import FILES_URL
|
||||
from app.core.config import FILES_URL, SKINS_DIR
|
||||
|
||||
class SkinService:
|
||||
async def set_skin(self, username: str, skin_file: UploadFile, skin_model: str = "classic"):
|
||||
@ -24,7 +24,7 @@ class SkinService:
|
||||
old_url = user["skin_url"]
|
||||
# Получаем имя файла из url
|
||||
old_filename = os.path.basename(urlparse(old_url).path)
|
||||
old_path = os.path.join("app/static/skins", old_filename)
|
||||
old_path = SKINS_DIR / old_filename
|
||||
print(f"Trying to delete old skin at: {old_path}")
|
||||
if os.path.exists(old_path):
|
||||
try:
|
||||
@ -34,7 +34,7 @@ class SkinService:
|
||||
|
||||
# Создаем папку для скинов, если ее нет
|
||||
from pathlib import Path
|
||||
skin_dir = Path("/app/static/skins")
|
||||
skin_dir = SKINS_DIR
|
||||
skin_dir.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
# Генерируем имя файла
|
||||
|
||||
@ -1,6 +1,6 @@
|
||||
from fastapi import HTTPException, UploadFile
|
||||
from app.db.database import users_collection
|
||||
from app.core.config import FILES_URL
|
||||
from app.core.config import CAPES_DIR, CAPES_STORE_DIR, FILES_URL
|
||||
from datetime import datetime
|
||||
import uuid
|
||||
from pathlib import Path
|
||||
@ -35,7 +35,7 @@ class StoreCapeService:
|
||||
raise HTTPException(status_code=400, detail="Файл слишком большой (максимум 2MB)")
|
||||
|
||||
# Создаем папку для плащей магазина, если ее нет
|
||||
cape_dir = Path("/app/static/capes_store")
|
||||
cape_dir = CAPES_STORE_DIR
|
||||
cape_dir.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
# Генерируем ID и имя файла
|
||||
@ -124,7 +124,7 @@ class StoreCapeService:
|
||||
raise HTTPException(status_code=404, detail="Плащ не найден")
|
||||
|
||||
# Удаляем файл
|
||||
cape_path = Path(f"/app/static/capes_store/{cape['file_name']}")
|
||||
cape_path = CAPES_STORE_DIR / cape["file_name"]
|
||||
if cape_path.exists():
|
||||
try:
|
||||
cape_path.unlink()
|
||||
@ -170,10 +170,10 @@ class StoreCapeService:
|
||||
detail=f"Недостаточно монет. Требуется: {cape['price']}, имеется: {user_coins}")
|
||||
|
||||
# Копируем плащ из хранилища магазина в персональную папку пользователя
|
||||
cape_store_path = Path(f"/app/static/capes_store/{cape['file_name']}")
|
||||
cape_store_path = CAPES_STORE_DIR / cape["file_name"]
|
||||
|
||||
# Создаем папку для плащей пользователя
|
||||
cape_dir = Path("/app/static/capes")
|
||||
cape_dir = CAPES_DIR
|
||||
cape_dir.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
# Генерируем имя файла для персонального плаща
|
||||
|
||||
91
app/webhooks/telegram.py
Normal file
91
app/webhooks/telegram.py
Normal file
@ -0,0 +1,91 @@
|
||||
import os
|
||||
from fastapi import APIRouter, Request, HTTPException
|
||||
from aiogram import Bot, Dispatcher, F
|
||||
from aiogram.types import Update, Message
|
||||
from aiogram.fsm.state import State, StatesGroup
|
||||
from aiogram.fsm.context import FSMContext
|
||||
from aiogram.fsm.storage.memory import MemoryStorage
|
||||
from aiogram.filters import CommandStart, CommandObject
|
||||
|
||||
from app.services.auth import AuthService
|
||||
|
||||
router = APIRouter()
|
||||
|
||||
BOT_TOKEN = os.getenv("TELEGRAM_BOT_TOKEN")
|
||||
WEBHOOK_SECRET = os.getenv("TELEGRAM_WEBHOOK_SECRET", "")
|
||||
|
||||
bot = Bot(token=BOT_TOKEN)
|
||||
dp = Dispatcher(storage=MemoryStorage())
|
||||
|
||||
auth_service = AuthService()
|
||||
|
||||
# ===== FSM =====
|
||||
|
||||
class Register(StatesGroup):
|
||||
username = State()
|
||||
code = State()
|
||||
|
||||
# ===== Handlers =====
|
||||
|
||||
@dp.message(CommandStart())
|
||||
async def start(message: Message, state: FSMContext, command: CommandObject):
|
||||
if command.args and command.args.startswith("qr_"):
|
||||
token = command.args.removeprefix("qr_").strip()
|
||||
tg_user = message.from_user
|
||||
try:
|
||||
await auth_service.approve_qr_login(token=token, telegram_user_id=tg_user.id)
|
||||
await message.answer("✅ Вход подтверждён. Вернитесь в лаунчер.")
|
||||
except Exception as e:
|
||||
await message.answer(f"❌ Не удалось подтвердить вход: {e}")
|
||||
return
|
||||
|
||||
# старое поведение регистрации/верификации:
|
||||
if command.args:
|
||||
await state.update_data(username=command.args)
|
||||
await state.set_state(Register.code)
|
||||
await message.answer("📋 Введите код из лаунчера:")
|
||||
else:
|
||||
await state.set_state(Register.username)
|
||||
await message.answer("🔑 Введите ваш игровой никнейм:")
|
||||
|
||||
@dp.message(Register.username)
|
||||
async def process_username(message: Message, state: FSMContext):
|
||||
await state.update_data(username=message.text.strip())
|
||||
await state.set_state(Register.code)
|
||||
await message.answer("📋 Теперь введите код из лаунчера:")
|
||||
|
||||
@dp.message(Register.code)
|
||||
async def process_code(message: Message, state: FSMContext):
|
||||
data = await state.get_data()
|
||||
username = data["username"]
|
||||
code = message.text.strip()
|
||||
|
||||
tg_user = message.from_user
|
||||
|
||||
try:
|
||||
await auth_service.verify_code(
|
||||
username=username,
|
||||
code=code,
|
||||
telegram_user_id=tg_user.id,
|
||||
telegram_username=tg_user.username,
|
||||
)
|
||||
await message.answer("✅ Аккаунт подтвержден!")
|
||||
await state.clear()
|
||||
except Exception as e:
|
||||
await message.answer(f"❌ Ошибка: {e}")
|
||||
await state.clear()
|
||||
|
||||
# ===== Webhook endpoint =====
|
||||
|
||||
@router.post("/telegram/webhook")
|
||||
async def telegram_webhook(request: Request):
|
||||
if WEBHOOK_SECRET:
|
||||
token = request.headers.get("X-Telegram-Bot-Api-Secret-Token")
|
||||
if token != WEBHOOK_SECRET:
|
||||
raise HTTPException(status_code=403, detail="Forbidden")
|
||||
|
||||
data = await request.json()
|
||||
update = Update.model_validate(data)
|
||||
|
||||
await dp.feed_update(bot, update)
|
||||
return {"ok": True}
|
||||
@ -8,24 +8,24 @@ services:
|
||||
- "3001:3000"
|
||||
user: "${UID:-1000}:${GID:-1000}"
|
||||
volumes:
|
||||
- ./app/static:/app/static:rw
|
||||
- ./app/static:/app/app/static:rw
|
||||
env_file:
|
||||
- .env
|
||||
depends_on:
|
||||
- mongodb
|
||||
command: ["uvicorn", "main:app", "--host", "0.0.0.0", "--port", "3000"]
|
||||
|
||||
telegram_bot:
|
||||
container_name: telegram_bot
|
||||
build:
|
||||
context: .
|
||||
dockerfile: Dockerfile
|
||||
user: "${UID:-1000}:${GID:-1000}"
|
||||
volumes:
|
||||
- ./telegram_bot.py:/app/telegram_bot.py
|
||||
env_file:
|
||||
- .env
|
||||
command: ["python", "telegram_bot.py"]
|
||||
# telegram_bot:
|
||||
# container_name: telegram_bot
|
||||
# build:
|
||||
# context: .
|
||||
# dockerfile: Dockerfile
|
||||
# user: "${UID:-1000}:${GID:-1000}"
|
||||
# volumes:
|
||||
# - ./telegram_bot.py:/app/telegram_bot.py
|
||||
# env_file:
|
||||
# - .env
|
||||
# command: ["python", "telegram_bot.py"]
|
||||
|
||||
mongodb:
|
||||
container_name: mongodb
|
||||
|
||||
73
main.py
73
main.py
@ -1,9 +1,63 @@
|
||||
from contextlib import asynccontextmanager
|
||||
import os
|
||||
from fastapi import FastAPI
|
||||
from fastapi.staticfiles import StaticFiles
|
||||
from app.api import users, skins, capes, meta, server, store, pranks, marketplace
|
||||
import httpx
|
||||
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
|
||||
|
||||
app = FastAPI()
|
||||
from app.core.config import CAPES_DIR, CAPES_STORE_DIR, SKINS_DIR
|
||||
from app.services.promo import PromoService
|
||||
from app.webhooks import telegram
|
||||
from app.db.database import users_collection
|
||||
from app.api import marketplace_ws, coins_ws
|
||||
from app.db.database import users_collection, sessions_collection
|
||||
|
||||
|
||||
###################### БОТ ######################
|
||||
|
||||
BOT_TOKEN = os.getenv("TELEGRAM_BOT_TOKEN")
|
||||
PUBLIC_WEBHOOK_URL = os.getenv("PUBLIC_WEBHOOK_URL") # https://minecraft.api.popa-popa.ru/telegram/webhook
|
||||
WEBHOOK_SECRET = os.getenv("TELEGRAM_WEBHOOK_SECRET", "")
|
||||
|
||||
@asynccontextmanager
|
||||
async def lifespan(app: FastAPI):
|
||||
# ===== STARTUP =====
|
||||
|
||||
# TTL для сессий (автоочистка)
|
||||
await sessions_collection.create_index(
|
||||
"expires_at",
|
||||
expireAfterSeconds=0
|
||||
)
|
||||
|
||||
await users_collection.create_index("expires_at", expireAfterSeconds=0)
|
||||
await users_collection.create_index("telegram_user_id", unique=True, sparse=True)
|
||||
|
||||
if BOT_TOKEN and PUBLIC_WEBHOOK_URL:
|
||||
payload = {"url": PUBLIC_WEBHOOK_URL}
|
||||
if WEBHOOK_SECRET:
|
||||
payload["secret_token"] = WEBHOOK_SECRET
|
||||
|
||||
async with httpx.AsyncClient(timeout=20) as client:
|
||||
await client.post(
|
||||
f"https://api.telegram.org/bot{BOT_TOKEN}/setWebhook",
|
||||
json=payload,
|
||||
)
|
||||
|
||||
await PromoService().ensure_indexes()
|
||||
|
||||
yield
|
||||
|
||||
# ===== SHUTDOWN =====
|
||||
async with httpx.AsyncClient(timeout=20) as client:
|
||||
await client.post(
|
||||
f"https://api.telegram.org/bot{BOT_TOKEN}/deleteWebhook"
|
||||
)
|
||||
|
||||
|
||||
app = FastAPI(lifespan=lifespan)
|
||||
|
||||
##################################################
|
||||
|
||||
app.include_router(meta.router)
|
||||
app.include_router(users.router)
|
||||
@ -13,11 +67,20 @@ app.include_router(server.router)
|
||||
app.include_router(store.router)
|
||||
app.include_router(pranks.router)
|
||||
app.include_router(marketplace.router)
|
||||
app.include_router(marketplace_ws.router)
|
||||
app.include_router(coins_ws.router)
|
||||
app.include_router(case.router)
|
||||
app.include_router(inventory.router)
|
||||
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="/app/static/skins"), name="skins")
|
||||
app.mount("/capes", StaticFiles(directory="/app/static/capes"), name="capes")
|
||||
app.mount("/capes_store", StaticFiles(directory="/app/static/capes_store"), name="capes_store")
|
||||
app.mount("/skins", StaticFiles(directory=str(SKINS_DIR)), name="skins")
|
||||
app.mount("/capes", StaticFiles(directory=str(CAPES_DIR)), name="capes")
|
||||
app.mount("/capes_store", StaticFiles(directory=str(CAPES_STORE_DIR)), name="capes_store")
|
||||
|
||||
# CORS, middleware и т.д.
|
||||
app.add_middleware(
|
||||
|
||||
@ -1,9 +1,9 @@
|
||||
fastapi>=0.110.0
|
||||
uvicorn>=0.28.0
|
||||
uvicorn[standard]>=0.28.0
|
||||
motor>=3.7.0
|
||||
python-jose>=3.3.0
|
||||
passlib>=1.7.4
|
||||
bcrypt>=4.0.1
|
||||
passlib==1.7.4
|
||||
bcrypt==4.3.0
|
||||
python-multipart>=0.0.9
|
||||
mongoengine>=0.24.2
|
||||
python-dotenv>=1.0.0
|
||||
@ -11,4 +11,4 @@ pydantic>=2.0.0
|
||||
cryptography>=43.0.0
|
||||
pytelegrambotapi>=2.0.0
|
||||
httpx>=0.27.2
|
||||
|
||||
aiogram>=3.20.0
|
||||
|
||||
Reference in New Issue
Block a user