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
|
from app.services.marketplace import MarketplaceService
|
||||||
return await MarketplaceService().update_item_price(username, item_id, new_price)
|
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 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.models.request import ValidateRequest
|
||||||
from app.services.auth import AuthService
|
from app.services.auth import AuthService
|
||||||
from app.db.database import users_collection, sessions_collection
|
from app.db.database import users_collection, sessions_collection
|
||||||
|
from app.db.database import db
|
||||||
from datetime import datetime
|
from datetime import datetime
|
||||||
import json
|
import json
|
||||||
from fastapi import HTTPException
|
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.event import PlayerEvent, OnlinePlayersUpdate
|
||||||
from app.models.server.playtime import PlayerSession, PlayerPlaytime
|
from app.models.server.playtime import PlayerSession, PlayerPlaytime
|
||||||
from app.services.coins import CoinsService
|
from app.services.coins import CoinsService
|
||||||
|
from app.services.dailyreward import DailyRewardService
|
||||||
|
from app.services.dailyquests import DailyQuestsService
|
||||||
|
|
||||||
coins_service = CoinsService()
|
coins_service = CoinsService()
|
||||||
|
|
||||||
|
qr_logins_collection = db.qr_logins
|
||||||
|
|
||||||
router = APIRouter(
|
router = APIRouter(
|
||||||
tags=["Users"]
|
tags=["Users"]
|
||||||
)
|
)
|
||||||
@ -119,8 +127,13 @@ async def get_user_by_uuid(uuid: str):
|
|||||||
return safe_user
|
return safe_user
|
||||||
|
|
||||||
@router.post("/auth/verify_code")
|
@router.post("/auth/verify_code")
|
||||||
async def verify_code(verify_code: VerifyCode):
|
async def verify_code(payload: VerifyCode):
|
||||||
return await AuthService().verify_code(verify_code.username, verify_code.code, verify_code.telegram_chat_id)
|
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")
|
@router.post("/auth/generate_code")
|
||||||
async def generate_code(username: str):
|
async def generate_code(username: str):
|
||||||
@ -129,3 +142,77 @@ async def generate_code(username: str):
|
|||||||
@router.get("/auth/verification_status/{username}")
|
@router.get("/auth/verification_status/{username}")
|
||||||
async def get_verification_status(username: str):
|
async def get_verification_status(username: str):
|
||||||
return await AuthService().get_verification_status(username)
|
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")
|
SECRET_KEY = os.getenv("SECRET_KEY")
|
||||||
ALGORITHM = "HS256"
|
ALGORITHM = "HS256"
|
||||||
ACCESS_TOKEN_EXPIRE_MINUTES = 1440 # 24 часа
|
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=[],
|
default=[],
|
||||||
description='Список серверов, где доступна команда. Использование ["*"] означает доступность на всех серверах'
|
description='Список серверов, где доступна команда. Использование ["*"] означает доступность на всех серверах'
|
||||||
)
|
)
|
||||||
|
material: str
|
||||||
targetDescription: Optional[str] = None # Сообщение для целевого игрока
|
targetDescription: Optional[str] = None # Сообщение для целевого игрока
|
||||||
globalDescription: Optional[str] = None # Сообщение для всех остальных
|
globalDescription: Optional[str] = None # Сообщение для всех остальных
|
||||||
|
|
||||||
|
|||||||
@ -22,9 +22,12 @@ class UserInDB(BaseModel):
|
|||||||
is_active: bool = True
|
is_active: bool = True
|
||||||
created_at: datetime = datetime.utcnow()
|
created_at: datetime = datetime.utcnow()
|
||||||
code: Optional[str] = None
|
code: Optional[str] = None
|
||||||
telegram_id: Optional[str] = None
|
telegram_user_id: Optional[int] = None
|
||||||
|
telegram_username: Optional[str] = None
|
||||||
is_verified: bool = False
|
is_verified: bool = False
|
||||||
code_expires_at: Optional[datetime] = None
|
code_expires_at: Optional[datetime] = None
|
||||||
|
is_admin: bool = False
|
||||||
|
expires_at: Optional[datetime] = None
|
||||||
class Session(BaseModel):
|
class Session(BaseModel):
|
||||||
access_token: str
|
access_token: str
|
||||||
client_token: str
|
client_token: str
|
||||||
@ -34,4 +37,9 @@ class Session(BaseModel):
|
|||||||
class VerifyCode(BaseModel):
|
class VerifyCode(BaseModel):
|
||||||
username: str
|
username: str
|
||||||
code: 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
|
import json
|
||||||
from fastapi import HTTPException, UploadFile
|
from fastapi import HTTPException, UploadFile
|
||||||
from fastapi.responses import JSONResponse
|
from fastapi.responses import JSONResponse
|
||||||
|
from app.db.database import db
|
||||||
from app.models.user import UserLogin, UserInDB, UserCreate, Session
|
from app.models.user import UserLogin, UserInDB, UserCreate, Session
|
||||||
from app.utils.misc import (
|
from app.utils.misc import (
|
||||||
verify_password,
|
verify_password,
|
||||||
@ -23,6 +24,8 @@ env_path = Path(__file__).parent.parent / ".env"
|
|||||||
load_dotenv(dotenv_path=env_path)
|
load_dotenv(dotenv_path=env_path)
|
||||||
FILES_URL = os.getenv("FILES_URL")
|
FILES_URL = os.getenv("FILES_URL")
|
||||||
|
|
||||||
|
qr_logins_collection = db.qr_logins
|
||||||
|
|
||||||
class AuthService:
|
class AuthService:
|
||||||
async def register(self, user: UserCreate):
|
async def register(self, user: UserCreate):
|
||||||
# Проверяем, существует ли пользователь
|
# Проверяем, существует ли пользователь
|
||||||
@ -42,7 +45,9 @@ class AuthService:
|
|||||||
uuid=user_uuid,
|
uuid=user_uuid,
|
||||||
is_verified=False,
|
is_verified=False,
|
||||||
code=None,
|
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())
|
await users_collection.insert_one(new_user.dict())
|
||||||
return {"status": "success", "uuid": user_uuid}
|
return {"status": "success", "uuid": user_uuid}
|
||||||
@ -57,29 +62,50 @@ class AuthService:
|
|||||||
else:
|
else:
|
||||||
raise HTTPException(404, "User not found")
|
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})
|
user = await users_collection.find_one({"username": username})
|
||||||
if not user:
|
if not user:
|
||||||
raise HTTPException(404, "User not found")
|
raise HTTPException(404, "Пользователь не найден")
|
||||||
|
|
||||||
if user["is_verified"]:
|
if user["is_verified"]:
|
||||||
raise HTTPException(400, "User already verified")
|
raise HTTPException(400, "Пользователь уже верифицирован")
|
||||||
|
|
||||||
# Проверяем код и привязку к Telegram
|
if user.get("telegram_user_id") and user["telegram_user_id"] != telegram_user_id:
|
||||||
if user.get("telegram_chat_id") and user["telegram_chat_id"] != telegram_chat_id:
|
raise HTTPException(403, "Этот аккаунт в Telegram уже привязан к другому пользователем.")
|
||||||
raise HTTPException(403, "This account is linked to another Telegram")
|
|
||||||
|
|
||||||
if user.get("code") != code:
|
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(
|
await users_collection.update_one(
|
||||||
{"username": username},
|
{"username": username},
|
||||||
{"$set": {
|
{"$set": update, "$unset": {"expires_at": ""}},
|
||||||
"is_verified": True,
|
|
||||||
"telegram_chat_id": telegram_chat_id,
|
|
||||||
"code": None
|
|
||||||
}}
|
|
||||||
)
|
)
|
||||||
return {"status": "success"}
|
return {"status": "success"}
|
||||||
|
|
||||||
@ -101,6 +127,10 @@ class AuthService:
|
|||||||
# Генерируем токены
|
# Генерируем токены
|
||||||
access_token = create_access_token({"sub": user["username"], "uuid": user["uuid"]})
|
access_token = create_access_token({"sub": user["username"], "uuid": user["uuid"]})
|
||||||
client_token = str(uuid.uuid4())
|
client_token = str(uuid.uuid4())
|
||||||
|
|
||||||
|
await sessions_collection.delete_many({
|
||||||
|
"user_uuid": user["uuid"]
|
||||||
|
})
|
||||||
|
|
||||||
# Сохраняем сессию
|
# Сохраняем сессию
|
||||||
session = Session(
|
session = Session(
|
||||||
@ -121,27 +151,91 @@ class AuthService:
|
|||||||
}
|
}
|
||||||
|
|
||||||
async def validate(self, access_token: str, client_token: str):
|
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({
|
session = await sessions_collection.find_one({
|
||||||
"access_token": access_token,
|
"access_token": access_token,
|
||||||
"client_token": client_token,
|
"client_token": client_token,
|
||||||
})
|
})
|
||||||
print("Session from DB:", session)
|
|
||||||
if not session or datetime.utcnow() > session["expires_at"]:
|
if not session:
|
||||||
return False
|
return False
|
||||||
|
|
||||||
|
if datetime.utcnow() > session["expires_at"]:
|
||||||
|
# можно сразу чистить
|
||||||
|
await sessions_collection.delete_one({"_id": session["_id"]})
|
||||||
|
return False
|
||||||
|
|
||||||
return True
|
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):
|
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
|
return None
|
||||||
|
|
||||||
# Обновляем токен
|
if datetime.utcnow() > session["expires_at"]:
|
||||||
new_access_token = create_access_token({"sub": "user", "uuid": "user_uuid"})
|
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(
|
await sessions_collection.update_one(
|
||||||
{"access_token": access_token},
|
{"_id": session["_id"]},
|
||||||
{"$set": {"access_token": new_access_token}},
|
{
|
||||||
|
"$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):
|
async def get_minecraft_profile(self, uuid: str):
|
||||||
# Преобразуем UUID без дефисов в формат с дефисами (если нужно)
|
# Преобразуем UUID без дефисов в формат с дефисами (если нужно)
|
||||||
@ -236,28 +330,44 @@ class AuthService:
|
|||||||
|
|
||||||
async def join_server(self, request_data: dict):
|
async def join_server(self, request_data: dict):
|
||||||
access_token = request_data.get("accessToken")
|
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")
|
server_id = request_data.get("serverId")
|
||||||
|
|
||||||
if not all([access_token, selected_profile, server_id]):
|
if not all([access_token, selected_profile, server_id]):
|
||||||
raise HTTPException(status_code=400, detail="Missing required parameters")
|
raise HTTPException(status_code=400, detail="Missing required parameters")
|
||||||
|
|
||||||
decoded_token = decode_token(access_token)
|
session = await sessions_collection.find_one({
|
||||||
if not decoded_token:
|
"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")
|
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:
|
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(
|
await sessions_collection.update_one(
|
||||||
{"user_uuid": decoded_token["uuid"]}, # UUID с дефисами
|
{"_id": session["_id"]},
|
||||||
{"$set": {"server_id": server_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):
|
async def has_joined(self, username: str, server_id: str):
|
||||||
user = await users_collection.find_one({"username": username})
|
user = await users_collection.find_one({"username": username})
|
||||||
@ -266,8 +376,9 @@ class AuthService:
|
|||||||
|
|
||||||
# Ищем сессию с этим server_id
|
# Ищем сессию с этим server_id
|
||||||
session = await sessions_collection.find_one({
|
session = await sessions_collection.find_one({
|
||||||
"user_uuid": user["uuid"], # UUID с дефисами
|
"user_uuid": user["uuid"],
|
||||||
"server_id": server_id
|
"server_id": server_id,
|
||||||
|
"expires_at": {"$gt": datetime.utcnow()},
|
||||||
})
|
})
|
||||||
if not session:
|
if not session:
|
||||||
raise HTTPException(status_code=403, detail="Not joined this server")
|
raise HTTPException(status_code=403, detail="Not joined this server")
|
||||||
@ -328,3 +439,75 @@ class AuthService:
|
|||||||
"value": base64_textures
|
"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.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 fastapi import HTTPException, UploadFile
|
||||||
from datetime import datetime
|
from datetime import datetime
|
||||||
|
|
||||||
@ -30,7 +30,7 @@ class CapeService:
|
|||||||
import os
|
import os
|
||||||
old_url = user["cloak_url"]
|
old_url = user["cloak_url"]
|
||||||
old_filename = os.path.basename(urlparse(old_url).path)
|
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):
|
if os.path.exists(old_path):
|
||||||
try:
|
try:
|
||||||
os.remove(old_path)
|
os.remove(old_path)
|
||||||
@ -39,7 +39,7 @@ class CapeService:
|
|||||||
|
|
||||||
# Создаем папку для плащей, если ее нет
|
# Создаем папку для плащей, если ее нет
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
cape_dir = Path("/app/static/capes")
|
cape_dir = CAPES_DIR
|
||||||
cape_dir.mkdir(parents=True, exist_ok=True)
|
cape_dir.mkdir(parents=True, exist_ok=True)
|
||||||
|
|
||||||
cape_filename = f"{username}_{int(datetime.now().timestamp())}.{ext}"
|
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 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 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:
|
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):
|
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)
|
user = await self._find_user_by_uuid(player_id)
|
||||||
if not user:
|
if not user:
|
||||||
return # Пользователь не найден
|
return
|
||||||
|
|
||||||
# Находим последнее обновление монет
|
last_update = await coins_sessions_collection.find_one({
|
||||||
last_update = await sessions_collection.find_one({
|
|
||||||
"player_id": player_id,
|
"player_id": player_id,
|
||||||
"server_ip": server_ip,
|
"server_ip": server_ip,
|
||||||
"update_type": "coins_update"
|
"update_type": "coins_update"
|
||||||
}, sort=[("timestamp", -1)])
|
}, 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_coins = user.get("coins", 0)
|
||||||
current_total_time = user.get("total_time_played", 0)
|
current_total_time = user.get("total_time_played", 0)
|
||||||
|
|
||||||
if last_update:
|
if last_update:
|
||||||
# Время с последнего начисления
|
seconds_since_update = int((now - last_update["timestamp"]).total_seconds())
|
||||||
last_timestamp = last_update["timestamp"]
|
|
||||||
seconds_since_update = int((now - last_timestamp).total_seconds())
|
|
||||||
|
|
||||||
# Начисляем монеты только за полные минуты
|
|
||||||
minutes_to_reward = seconds_since_update // 60
|
minutes_to_reward = seconds_since_update // 60
|
||||||
|
|
||||||
# Если прошло меньше минуты, пропускаем
|
|
||||||
if minutes_to_reward < 1:
|
if minutes_to_reward < 1:
|
||||||
return
|
return
|
||||||
|
|
||||||
|
minutes_to_reward = min(minutes_to_reward, 1)
|
||||||
else:
|
else:
|
||||||
# Первое обновление (ограничиваем для безопасности)
|
|
||||||
minutes_to_reward = min(online_time // 60, 5)
|
minutes_to_reward = min(online_time // 60, 5)
|
||||||
|
|
||||||
if minutes_to_reward > 0:
|
if minutes_to_reward > 0:
|
||||||
# Обновляем монеты и время
|
|
||||||
new_coins = current_coins + minutes_to_reward
|
new_coins = current_coins + minutes_to_reward
|
||||||
new_total_time = current_total_time + (minutes_to_reward * 60)
|
new_total_time = current_total_time + (minutes_to_reward * 60)
|
||||||
|
|
||||||
# Сохраняем в БД
|
|
||||||
await users_collection.update_one(
|
await users_collection.update_one(
|
||||||
{"_id": user["_id"]},
|
{"_id": user["_id"]},
|
||||||
{"$set": {
|
{"$set": {"coins": new_coins, "total_time_played": new_total_time}}
|
||||||
"coins": new_coins,
|
|
||||||
"total_time_played": new_total_time
|
|
||||||
}}
|
|
||||||
)
|
)
|
||||||
|
|
||||||
# Сохраняем запись о начислении
|
await coins_hub.send_update(user["username"], new_coins)
|
||||||
await sessions_collection.insert_one({
|
|
||||||
|
await coins_sessions_collection.insert_one({
|
||||||
"player_id": player_id,
|
"player_id": player_id,
|
||||||
"player_name": player_name,
|
"player_name": player_name,
|
||||||
"server_ip": server_ip,
|
"server_ip": server_ip,
|
||||||
@ -61,9 +82,6 @@ class CoinsService:
|
|||||||
"minutes_added": minutes_to_reward,
|
"minutes_added": minutes_to_reward,
|
||||||
"coins_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):
|
async def _find_user_by_uuid(self, player_id: str):
|
||||||
"""Находит пользователя по UUID с поддержкой разных форматов"""
|
"""Находит пользователя по UUID с поддержкой разных форматов"""
|
||||||
@ -123,7 +141,9 @@ class CoinsService:
|
|||||||
raise HTTPException(status_code=404, detail=f"Пользователь {username} не найден")
|
raise HTTPException(status_code=404, detail=f"Пользователь {username} не найден")
|
||||||
|
|
||||||
user = await users_collection.find_one({"username": 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:
|
async def decrease_balance(self, username: str, amount: int) -> int:
|
||||||
"""Уменьшить баланс пользователя"""
|
"""Уменьшить баланс пользователя"""
|
||||||
@ -139,4 +159,6 @@ class CoinsService:
|
|||||||
raise HTTPException(status_code=404, detail=f"Пользователь {username} не найден")
|
raise HTTPException(status_code=404, detail=f"Пользователь {username} не найден")
|
||||||
|
|
||||||
user = await users_collection.find_one({"username": 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.db.database import db
|
||||||
from app.services.coins import CoinsService
|
from app.services.coins import CoinsService
|
||||||
from app.services.server.command import CommandService
|
from app.services.server.command import CommandService
|
||||||
|
from app.realtime.marketplace_hub import marketplace_hub
|
||||||
|
|
||||||
# Коллекция для хранения товаров на торговой площадке
|
# Коллекция для хранения товаров на торговой площадке
|
||||||
marketplace_collection = db.marketplace_items
|
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):
|
async def confirm_operation(self, operation_id: str, status: str = "success", error: str = None):
|
||||||
"""Подтвердить выполнение операции"""
|
update = {"status": status}
|
||||||
update = {
|
|
||||||
"status": status
|
|
||||||
}
|
|
||||||
|
|
||||||
if error:
|
if error:
|
||||||
update["error"] = error
|
update["error"] = error
|
||||||
|
|
||||||
result = await marketplace_operations.update_one(
|
await marketplace_operations.update_one({"id": operation_id}, {"$set": update})
|
||||||
{"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"}
|
return {"status": "success"}
|
||||||
|
|
||||||
async def update_item_details(self, operation_id: str, item_data: dict):
|
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_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(
|
await marketplace_operations.update_one(
|
||||||
@ -205,6 +224,16 @@ class MarketplaceService:
|
|||||||
|
|
||||||
# 7. Удаляем предмет с торговой площадки
|
# 7. Удаляем предмет с торговой площадки
|
||||||
await marketplace_collection.delete_one({"id": item_id})
|
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 {
|
return {
|
||||||
"status": "pending",
|
"status": "pending",
|
||||||
@ -241,12 +270,58 @@ class MarketplaceService:
|
|||||||
|
|
||||||
# Удаляем предмет с торговой площадки
|
# Удаляем предмет с торговой площадки
|
||||||
await marketplace_collection.delete_one({"id": item_id})
|
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 {
|
return {
|
||||||
"status": "pending",
|
"status": "pending",
|
||||||
"operation_id": operation_id,
|
"operation_id": operation_id,
|
||||||
"message": "Предмет снят с продажи и будет возвращен в ваш инвентарь"
|
"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):
|
async def update_item_price(self, username: str, item_id: str, new_price: int):
|
||||||
"""Обновить цену предмета на торговой площадке"""
|
"""Обновить цену предмета на торговой площадке"""
|
||||||
@ -268,11 +343,63 @@ class MarketplaceService:
|
|||||||
{"id": item_id},
|
{"id": item_id},
|
||||||
{"$set": {"price": new_price}}
|
{"$set": {"price": new_price}}
|
||||||
)
|
)
|
||||||
|
|
||||||
if result.modified_count == 0:
|
if result.modified_count == 0:
|
||||||
raise HTTPException(status_code=500, detail="Не удалось обновить цену предмета")
|
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 {
|
return {
|
||||||
"status": "success",
|
"status": "success",
|
||||||
"message": f"Цена предмета обновлена на {new_price} монет"
|
"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.services.coins import CoinsService
|
||||||
from app.models.server.event import PlayerEvent, OnlinePlayersUpdate
|
from app.models.server.event import PlayerEvent, OnlinePlayersUpdate
|
||||||
import uuid
|
import uuid
|
||||||
|
from app.services.dailyquests import DailyQuestsService
|
||||||
|
|
||||||
class EventService:
|
class EventService:
|
||||||
def __init__(self):
|
def __init__(self):
|
||||||
@ -33,6 +34,14 @@ class EventService:
|
|||||||
# Обновляем данные об онлайн игроках
|
# Обновляем данные об онлайн игроках
|
||||||
players = event_data.get("players", [])
|
players = event_data.get("players", [])
|
||||||
await self._update_online_players(server_ip, 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"}
|
return {"status": "success"}
|
||||||
|
|
||||||
elif event_type == "player_join":
|
elif event_type == "player_join":
|
||||||
@ -69,6 +78,17 @@ class EventService:
|
|||||||
await self._process_player_session(server_ip, player_id, player_name, duration)
|
await self._process_player_session(server_ip, player_id, player_name, duration)
|
||||||
return {"status": "success"}
|
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}")
|
print(f"[{datetime.now()}] Неизвестное событие: {event_data}")
|
||||||
raise HTTPException(status_code=400, detail="Invalid event type")
|
raise HTTPException(status_code=400, detail="Invalid event type")
|
||||||
@ -284,7 +304,7 @@ class EventService:
|
|||||||
})
|
})
|
||||||
|
|
||||||
# Начисляем коины за время игры
|
# Начисляем коины за время игры
|
||||||
coins_service = CoinsService()
|
# coins_service = CoinsService()
|
||||||
await coins_service.update_player_coins(player_id, player_name, duration, server_ip)
|
# await coins_service.update_player_coins(player_id, player_name, duration, server_ip)
|
||||||
|
|
||||||
print(f"[{datetime.now()}] Сессия игрока {player_name} завершена, длительность: {duration} сек.")
|
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 app.models.server.prank import PrankCommand, PrankCommandUpdate
|
||||||
from datetime import datetime, timedelta
|
from datetime import datetime, timedelta
|
||||||
import uuid
|
import uuid
|
||||||
|
from app.services.coins import CoinsService
|
||||||
from app.services.server.command import CommandService
|
from app.services.server.command import CommandService
|
||||||
|
|
||||||
# Создаем коллекции для хранения пакостей и серверов
|
# Создаем коллекции для хранения пакостей и серверов
|
||||||
@ -266,6 +267,12 @@ class PrankService:
|
|||||||
|
|
||||||
command_result = await command_service.add_command(server_command)
|
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 = {
|
log_entry = {
|
||||||
"user_id": user["_id"],
|
"user_id": user["_id"],
|
||||||
@ -284,5 +291,5 @@ class PrankService:
|
|||||||
return {
|
return {
|
||||||
"status": "success",
|
"status": "success",
|
||||||
"message": f"Команда '{command['name']}' успешно выполнена на игроке {target_player}",
|
"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 fastapi import HTTPException, UploadFile
|
||||||
from datetime import datetime
|
from datetime import datetime
|
||||||
from app.db.database import users_collection
|
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:
|
class SkinService:
|
||||||
async def set_skin(self, username: str, skin_file: UploadFile, skin_model: str = "classic"):
|
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"]
|
old_url = user["skin_url"]
|
||||||
# Получаем имя файла из url
|
# Получаем имя файла из url
|
||||||
old_filename = os.path.basename(urlparse(old_url).path)
|
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}")
|
print(f"Trying to delete old skin at: {old_path}")
|
||||||
if os.path.exists(old_path):
|
if os.path.exists(old_path):
|
||||||
try:
|
try:
|
||||||
@ -34,7 +34,7 @@ class SkinService:
|
|||||||
|
|
||||||
# Создаем папку для скинов, если ее нет
|
# Создаем папку для скинов, если ее нет
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
skin_dir = Path("/app/static/skins")
|
skin_dir = SKINS_DIR
|
||||||
skin_dir.mkdir(parents=True, exist_ok=True)
|
skin_dir.mkdir(parents=True, exist_ok=True)
|
||||||
|
|
||||||
# Генерируем имя файла
|
# Генерируем имя файла
|
||||||
|
|||||||
@ -1,6 +1,6 @@
|
|||||||
from fastapi import HTTPException, UploadFile
|
from fastapi import HTTPException, UploadFile
|
||||||
from app.db.database import users_collection
|
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
|
from datetime import datetime
|
||||||
import uuid
|
import uuid
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
@ -35,7 +35,7 @@ class StoreCapeService:
|
|||||||
raise HTTPException(status_code=400, detail="Файл слишком большой (максимум 2MB)")
|
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)
|
cape_dir.mkdir(parents=True, exist_ok=True)
|
||||||
|
|
||||||
# Генерируем ID и имя файла
|
# Генерируем ID и имя файла
|
||||||
@ -124,7 +124,7 @@ class StoreCapeService:
|
|||||||
raise HTTPException(status_code=404, detail="Плащ не найден")
|
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():
|
if cape_path.exists():
|
||||||
try:
|
try:
|
||||||
cape_path.unlink()
|
cape_path.unlink()
|
||||||
@ -170,10 +170,10 @@ class StoreCapeService:
|
|||||||
detail=f"Недостаточно монет. Требуется: {cape['price']}, имеется: {user_coins}")
|
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)
|
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"
|
- "3001:3000"
|
||||||
user: "${UID:-1000}:${GID:-1000}"
|
user: "${UID:-1000}:${GID:-1000}"
|
||||||
volumes:
|
volumes:
|
||||||
- ./app/static:/app/static:rw
|
- ./app/static:/app/app/static:rw
|
||||||
env_file:
|
env_file:
|
||||||
- .env
|
- .env
|
||||||
depends_on:
|
depends_on:
|
||||||
- mongodb
|
- mongodb
|
||||||
command: ["uvicorn", "main:app", "--host", "0.0.0.0", "--port", "3000"]
|
command: ["uvicorn", "main:app", "--host", "0.0.0.0", "--port", "3000"]
|
||||||
|
|
||||||
telegram_bot:
|
# telegram_bot:
|
||||||
container_name: telegram_bot
|
# container_name: telegram_bot
|
||||||
build:
|
# build:
|
||||||
context: .
|
# context: .
|
||||||
dockerfile: Dockerfile
|
# dockerfile: Dockerfile
|
||||||
user: "${UID:-1000}:${GID:-1000}"
|
# user: "${UID:-1000}:${GID:-1000}"
|
||||||
volumes:
|
# volumes:
|
||||||
- ./telegram_bot.py:/app/telegram_bot.py
|
# - ./telegram_bot.py:/app/telegram_bot.py
|
||||||
env_file:
|
# env_file:
|
||||||
- .env
|
# - .env
|
||||||
command: ["python", "telegram_bot.py"]
|
# command: ["python", "telegram_bot.py"]
|
||||||
|
|
||||||
mongodb:
|
mongodb:
|
||||||
container_name: 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 import FastAPI
|
||||||
from fastapi.staticfiles import StaticFiles
|
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
|
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(meta.router)
|
||||||
app.include_router(users.router)
|
app.include_router(users.router)
|
||||||
@ -13,11 +67,20 @@ app.include_router(server.router)
|
|||||||
app.include_router(store.router)
|
app.include_router(store.router)
|
||||||
app.include_router(pranks.router)
|
app.include_router(pranks.router)
|
||||||
app.include_router(marketplace.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("/skins", StaticFiles(directory=str(SKINS_DIR)), name="skins")
|
||||||
app.mount("/capes", StaticFiles(directory="/app/static/capes"), name="capes")
|
app.mount("/capes", StaticFiles(directory=str(CAPES_DIR)), name="capes")
|
||||||
app.mount("/capes_store", StaticFiles(directory="/app/static/capes_store"), name="capes_store")
|
app.mount("/capes_store", StaticFiles(directory=str(CAPES_STORE_DIR)), name="capes_store")
|
||||||
|
|
||||||
# CORS, middleware и т.д.
|
# CORS, middleware и т.д.
|
||||||
app.add_middleware(
|
app.add_middleware(
|
||||||
|
|||||||
@ -1,9 +1,9 @@
|
|||||||
fastapi>=0.110.0
|
fastapi>=0.110.0
|
||||||
uvicorn>=0.28.0
|
uvicorn[standard]>=0.28.0
|
||||||
motor>=3.7.0
|
motor>=3.7.0
|
||||||
python-jose>=3.3.0
|
python-jose>=3.3.0
|
||||||
passlib>=1.7.4
|
passlib==1.7.4
|
||||||
bcrypt>=4.0.1
|
bcrypt==4.3.0
|
||||||
python-multipart>=0.0.9
|
python-multipart>=0.0.9
|
||||||
mongoengine>=0.24.2
|
mongoengine>=0.24.2
|
||||||
python-dotenv>=1.0.0
|
python-dotenv>=1.0.0
|
||||||
@ -11,4 +11,4 @@ pydantic>=2.0.0
|
|||||||
cryptography>=43.0.0
|
cryptography>=43.0.0
|
||||||
pytelegrambotapi>=2.0.0
|
pytelegrambotapi>=2.0.0
|
||||||
httpx>=0.27.2
|
httpx>=0.27.2
|
||||||
|
aiogram>=3.20.0
|
||||||
|
|||||||
Reference in New Issue
Block a user