Compare commits

...

59 Commits

Author SHA1 Message Date
99d9741e97 add websocket for coins 2025-12-29 13:10:39 +05:00
bddd68bc25 add material in prank 2025-12-29 12:11:14 +05:00
520ad99099 fix coins in pranks 2025-12-28 15:57:18 +05:00
419a973d49 fix join_server 2025-12-28 12:07:31 +05:00
5db5b98dbc fix refresh token 2025-12-28 11:55:30 +05:00
2322b27ace edit req 2025-12-22 13:18:13 +05:00
29bf215120 fix websocket 2025-12-22 12:11:17 +05:00
51e903e249 add websocket market 2025-12-22 11:11:26 +05:00
bfacff902f fix promo #4 2025-12-20 20:07:48 +05:00
745b115adc fix promo #3 2025-12-20 20:02:04 +05:00
cbc586683a fix promo #2 2025-12-20 19:57:57 +05:00
bb2681bff8 fix promo max_uses 2025-12-20 19:56:13 +05:00
9954599a33 add promocodes 2025-12-20 19:51:18 +05:00
f174761ec6 fix test qr code 2025-12-20 15:53:40 +05:00
e035334417 test qr code 2025-12-20 15:48:15 +05:00
41711d68c8 fix afk server logic 2025-12-20 12:23:35 +05:00
bb74dbbba7 test player_inventory 2025-12-16 00:15:29 +05:00
80a9fbe148 add new enpoints to marketplace 2025-12-13 19:46:44 +05:00
d16cbd289b fix time in active_time daily reward 2025-12-13 18:48:57 +05:00
a9ebb5b5f9 fix active_time quest 2025-12-13 18:37:48 +05:00
1bacb8d78f add active_time quests support 2025-12-13 18:30:27 +05:00
13fcd40eb4 add bulk upset to admin daily quest 2025-12-13 18:00:52 +05:00
c111a15d5b add autoclaim to dailyreward 2025-12-13 17:49:41 +05:00
04cf6a325a add difficulty to daily quests 2025-12-13 17:47:39 +05:00
4ef3064011 test add daily quests 2025-12-13 16:51:28 +05:00
f8550c9dc8 add get day claim reward endpoint 2025-12-13 16:03:21 +05:00
67d85a71c1 add today online check in dailyreward 2025-12-13 01:21:47 +05:00
04dcf7bf9d fix reward in dailyreward 2025-12-13 01:01:52 +05:00
cbec2203cd add dailyreward 2025-12-13 00:51:35 +05:00
66da0d0e27 russian errors 2025-12-12 22:14:47 +05:00
abf93a91e8 remove telegram_chat_id 2025-12-12 22:08:24 +05:00
4727184182 add check telegram_id and delete old not verefied accounts 2025-12-12 21:56:54 +05:00
7aea18c7fb add telegram username to bd 2025-12-12 21:33:51 +05:00
c7454cbb62 fix start command in telegram bot 2025-12-12 21:17:49 +05:00
9ff2319990 aiogram telegram bot 2025-12-12 21:05:08 +05:00
3aac426364 bot http fix 2025-12-12 20:40:47 +05:00
f3d86ffdde WebHook telegram bot test 2025-12-12 20:31:40 +05:00
fe598f94a3 fix coin accural AKF 2025-12-07 23:21:23 +05:00
845291acab fix for coin accural #2 2025-12-07 22:56:34 +05:00
f11e60529b fix get_user_bonuses 2025-12-07 20:08:49 +05:00
a0808d29fa add img to bonus and endpoint toogle activation bonus 2025-12-07 17:26:43 +05:00
d4b1c1f5ee fix for coin accrual 2025-12-07 16:39:01 +05:00
e7ed7ab977 fix coins collections 2025-12-07 15:52:43 +05:00
f720b51c60 add endpoint create bonus 2025-12-07 14:52:40 +05:00
38f6b43718 fix get_case 2025-12-07 01:46:36 +05:00
0da03f1dcd fix case router 2025-12-07 01:07:57 +05:00
2073fbece9 add cases router 2025-12-07 01:05:48 +05:00
26a96468cc test cases 2025-12-07 01:04:04 +05:00
8f0a5abfb3 add check is_admin route 2025-12-06 01:50:02 +05:00
14f7929e0f fix test news db 2025-12-06 00:20:09 +05:00
51135f6506 add news router 2025-12-06 00:16:37 +05:00
93fbd14cc4 test news 2025-12-06 00:14:04 +05:00
730ee97666 fix coin accrual 2025-12-04 01:46:28 +05:00
0184fa9848 fix capes purchase 2025-12-04 01:31:32 +05:00
3bef4f0ba0 fix skins and capes saves 2025-12-04 01:21:07 +05:00
e89cb58b11 fix requirements 2025-12-01 22:20:15 +05:00
ca71883fe4 revers 2025-12-01 16:48:22 +05:00
70db9e8a3d test fix 2025-12-01 16:42:37 +05:00
fa9611cc99 add:bonus store 2025-07-31 07:00:07 +05:00
39 changed files with 2962 additions and 123 deletions

View 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
View 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
View 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
View 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
View 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,
)

View File

@ -80,3 +80,75 @@ async def update_item_price(
"""Обновить цену предмета на торговой площадке"""
from app.services.marketplace import MarketplaceService
return await MarketplaceService().update_item_price(username, item_id, new_price)
@router.get("/items/by-seller/{username}")
async def get_items_by_seller(
username: str,
server_ip: Optional[str] = None,
page: int = Query(1, ge=1),
limit: int = Query(20, ge=1, le=100)
):
"""Получить все товары, выставленные конкретным игроком"""
from app.services.marketplace import MarketplaceService
return await MarketplaceService().list_items_by_seller(
username=username,
server_ip=server_ip,
page=page,
limit=limit
)
@router.get("/items/me")
async def get_my_items(
username: str = Query(...),
server_ip: Optional[str] = None,
page: int = Query(1, ge=1),
limit: int = Query(20, ge=1, le=100)
):
"""Получить мои лоты на торговой площадке"""
from app.services.marketplace import MarketplaceService
return await MarketplaceService().list_items_by_seller(
username=username,
server_ip=server_ip,
page=page,
limit=limit
)
@router.get("/operations/all")
async def get_all_marketplace_operations(
server_ip: Optional[str] = None,
player_name: Optional[str] = None,
status: Optional[str] = None,
op_type: Optional[str] = None,
page: int = Query(1, ge=1),
limit: int = Query(20, ge=1, le=100),
):
"""Получить все операции маркетплейса (опционально: по игроку/статусу/типу)"""
from app.services.marketplace import MarketplaceService
return await MarketplaceService().list_operations(
server_ip=server_ip,
player_name=player_name,
status=status,
op_type=op_type,
page=page,
limit=limit
)
@router.get("/operations/by-player/{username}")
async def get_operations_by_player(
username: str,
server_ip: Optional[str] = None,
status: Optional[str] = None,
op_type: Optional[str] = None,
page: int = Query(1, ge=1),
limit: int = Query(20, ge=1, le=100),
):
"""Получить операции маркетплейса конкретного игрока"""
from app.services.marketplace import MarketplaceService
return await MarketplaceService().list_operations(
server_ip=server_ip,
player_name=username,
status=status,
op_type=op_type,
page=page,
limit=limit
)

23
app/api/marketplace_ws.py Normal file
View 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
View 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
View 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)

View File

@ -1,8 +1,12 @@
import os
import secrets
from fastapi import APIRouter, HTTPException, Body, Response
from app.models.user import UserCreate, UserLogin, VerifyCode
from fastapi.params import Query
from app.models.user import QrApprove, UserCreate, UserLogin, VerifyCode
from app.models.request import ValidateRequest
from app.services.auth import AuthService
from app.db.database import users_collection, sessions_collection
from app.db.database import db
from datetime import datetime
import json
from fastapi import HTTPException
@ -10,9 +14,13 @@ from datetime import datetime, timedelta
from app.models.server.event import PlayerEvent, OnlinePlayersUpdate
from app.models.server.playtime import PlayerSession, PlayerPlaytime
from app.services.coins import CoinsService
from app.services.dailyreward import DailyRewardService
from app.services.dailyquests import DailyQuestsService
coins_service = CoinsService()
qr_logins_collection = db.qr_logins
router = APIRouter(
tags=["Users"]
)
@ -119,8 +127,13 @@ async def get_user_by_uuid(uuid: str):
return safe_user
@router.post("/auth/verify_code")
async def verify_code(verify_code: VerifyCode):
return await AuthService().verify_code(verify_code.username, verify_code.code, verify_code.telegram_chat_id)
async def verify_code(payload: VerifyCode):
return await AuthService().verify_code(
username=payload.username,
code=payload.code,
telegram_user_id=payload.telegram_user_id,
telegram_username=payload.telegram_username,
)
@router.post("/auth/generate_code")
async def generate_code(username: str):
@ -129,3 +142,77 @@ async def generate_code(username: str):
@router.get("/auth/verification_status/{username}")
async def get_verification_status(username: str):
return await AuthService().get_verification_status(username)
@router.get("/auth/me")
async def get_me(
accessToken: str = Query(...),
clientToken: str = Query(...),
):
"""
Текущий пользователь по accessToken + clientToken.
"""
return await AuthService().get_current_user(accessToken, clientToken)
@router.post("/auth/qr/init")
async def qr_init(device_id: str | None = Query(default=None)):
token = secrets.token_urlsafe(24)
expires_at = datetime.utcnow() + timedelta(minutes=2)
await qr_logins_collection.insert_one({
"token": token,
"device_id": device_id,
"status": "pending",
"approved_username": None,
"created_at": datetime.utcnow(),
"expires_at": expires_at,
})
# deep-link в бота
BOT_USERNAME = os.getenv("TELEGRAM_BOT_USERNAME")
qr_url = f"https://t.me/{BOT_USERNAME}?start=qr_{token}"
return {"token": token, "qr_url": qr_url, "expires_at": expires_at.isoformat()}
@router.post("/auth/qr/approve")
async def qr_approve(payload: QrApprove):
return await AuthService().approve_qr_login(payload.token, payload.telegram_user_id)
@router.get("/auth/qr/status")
async def qr_status(token: str = Query(...), device_id: str | None = Query(default=None)):
return await AuthService().qr_status(token, device_id)
### daily reward
@router.post("/users/daily/claim")
async def claim_daily(accessToken: str = Query(...), clientToken: str = Query(...)):
me = await AuthService().get_current_user(accessToken, clientToken) # :contentReference[oaicite:7]{index=7}
return await DailyRewardService().claim_daily(me["username"])
@router.get("/users/daily/status")
async def daily_status(accessToken: str = Query(...), clientToken: str = Query(...)):
me = await AuthService().get_current_user(accessToken, clientToken)
return await DailyRewardService().get_status(me["username"])
@router.get("/users/daily/days")
async def daily_days(
accessToken: str = Query(...),
clientToken: str = Query(...),
limit: int = Query(60, ge=1, le=365),
):
me = await AuthService().get_current_user(accessToken, clientToken)
return await DailyRewardService().get_claim_days(me["username"], limit=limit)
### daily quests
@router.get("/users/daily-quests/status")
async def daily_quests_status(accessToken: str = Query(...), clientToken: str = Query(...)):
me = await AuthService().get_current_user(accessToken, clientToken)
return await DailyQuestsService().get_status(me["username"])
@router.post("/users/daily-quests/claim")
async def daily_quests_claim(
quest_key: str = Query(...),
accessToken: str = Query(...),
clientToken: str = Query(...),
):
me = await AuthService().get_current_user(accessToken, clientToken)
return await DailyQuestsService().claim(me["username"], quest_key)

View File

@ -9,3 +9,9 @@ MONGO_URI = os.getenv("MONGO_URI")
SECRET_KEY = os.getenv("SECRET_KEY")
ALGORITHM = "HS256"
ACCESS_TOKEN_EXPIRE_MINUTES = 1440 # 24 часа
BASE_DIR = Path(__file__).resolve().parent.parent # /app/app
STATIC_DIR = BASE_DIR / "static"
SKINS_DIR = STATIC_DIR / "skins"
CAPES_DIR = STATIC_DIR / "capes"
CAPES_STORE_DIR = STATIC_DIR / "capes_store"

62
app/models/bonus.py Normal file
View 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
View 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
View 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
View 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
View 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

View File

@ -10,6 +10,7 @@ class PrankCommandCreate(BaseModel):
default=[],
description='Список серверов, где доступна команда. Использование ["*"] означает доступность на всех серверах'
)
material: str
targetDescription: Optional[str] = None # Сообщение для целевого игрока
globalDescription: Optional[str] = None # Сообщение для всех остальных

View File

@ -22,9 +22,12 @@ class UserInDB(BaseModel):
is_active: bool = True
created_at: datetime = datetime.utcnow()
code: Optional[str] = None
telegram_id: Optional[str] = None
telegram_user_id: Optional[int] = None
telegram_username: Optional[str] = None
is_verified: bool = False
code_expires_at: Optional[datetime] = None
is_admin: bool = False
expires_at: Optional[datetime] = None
class Session(BaseModel):
access_token: str
client_token: str
@ -34,4 +37,9 @@ class Session(BaseModel):
class VerifyCode(BaseModel):
username: str
code: str
telegram_chat_id: int
telegram_user_id: int
telegram_username: Optional[str] = None
class QrApprove(BaseModel):
token: str
telegram_user_id: int

53
app/realtime/coins_hub.py Normal file
View 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()

View 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()

View File

@ -2,6 +2,7 @@ import base64
import json
from fastapi import HTTPException, UploadFile
from fastapi.responses import JSONResponse
from app.db.database import db
from app.models.user import UserLogin, UserInDB, UserCreate, Session
from app.utils.misc import (
verify_password,
@ -23,6 +24,8 @@ env_path = Path(__file__).parent.parent / ".env"
load_dotenv(dotenv_path=env_path)
FILES_URL = os.getenv("FILES_URL")
qr_logins_collection = db.qr_logins
class AuthService:
async def register(self, user: UserCreate):
# Проверяем, существует ли пользователь
@ -42,7 +45,9 @@ class AuthService:
uuid=user_uuid,
is_verified=False,
code=None,
code_expires_at=None
code_expires_at=None,
expires_at=datetime.utcnow() + timedelta(hours=1),
is_admin=False
)
await users_collection.insert_one(new_user.dict())
return {"status": "success", "uuid": user_uuid}
@ -57,29 +62,50 @@ class AuthService:
else:
raise HTTPException(404, "User not found")
async def verify_code(self, username: str, code: str, telegram_chat_id: int):
async def verify_code(
self,
username: str,
code: str,
telegram_user_id: int | None = None,
telegram_username: str | None = None,
):
user = await users_collection.find_one({"username": username})
if not user:
raise HTTPException(404, "User not found")
raise HTTPException(404, "Пользователь не найден")
if user["is_verified"]:
raise HTTPException(400, "User already verified")
# Проверяем код и привязку к Telegram
if user.get("telegram_chat_id") and user["telegram_chat_id"] != telegram_chat_id:
raise HTTPException(403, "This account is linked to another Telegram")
raise HTTPException(400, "Пользователь уже верифицирован")
if user.get("telegram_user_id") and user["telegram_user_id"] != telegram_user_id:
raise HTTPException(403, "Этот аккаунт в Telegram уже привязан к другому пользователем.")
if user.get("code") != code:
raise HTTPException(400, "Invalid code")
raise HTTPException(400, "Инвалид код. Прям как ты")
# Обновляем chat_id при первом подтверждении
if telegram_user_id is not None:
existing = await users_collection.find_one({
"telegram_user_id": telegram_user_id,
"username": {"$ne": username},
})
if existing:
raise HTTPException(
status_code=403,
detail="Этот аккаунт в Telegram уже привязан к другому пользователем.",
)
update = {
"is_verified": True,
"telegram_user_id": telegram_user_id,
"code": None,
}
if telegram_user_id is not None:
update["telegram_user_id"] = telegram_user_id
if telegram_username is not None:
update["telegram_username"] = telegram_username
await users_collection.update_one(
{"username": username},
{"$set": {
"is_verified": True,
"telegram_chat_id": telegram_chat_id,
"code": None
}}
{"$set": update, "$unset": {"expires_at": ""}},
)
return {"status": "success"}
@ -101,6 +127,10 @@ class AuthService:
# Генерируем токены
access_token = create_access_token({"sub": user["username"], "uuid": user["uuid"]})
client_token = str(uuid.uuid4())
await sessions_collection.delete_many({
"user_uuid": user["uuid"]
})
# Сохраняем сессию
session = Session(
@ -121,27 +151,91 @@ class AuthService:
}
async def validate(self, access_token: str, client_token: str):
print(f"Searching for access_toke and client_token: '{access_token}', '{client_token}")
session = await sessions_collection.find_one({
"access_token": access_token,
"client_token": client_token,
})
print("Session from DB:", session)
if not session or datetime.utcnow() > session["expires_at"]:
if not session:
return False
if datetime.utcnow() > session["expires_at"]:
# можно сразу чистить
await sessions_collection.delete_one({"_id": session["_id"]})
return False
return True
async def is_admin(self, access_token: str, client_token: str) -> bool:
session = await sessions_collection.find_one({
"access_token": access_token,
"client_token": client_token,
})
if not session:
return False
user = await users_collection.find_one({"uuid": session["user_uuid"]})
return user and user.get("is_admin") is True
async def get_current_user(self, access_token: str, client_token: str):
session = await sessions_collection.find_one({
"access_token": access_token,
"client_token": client_token,
})
if not session:
raise HTTPException(status_code=401, detail="Invalid session")
user = await users_collection.find_one({"uuid": session["user_uuid"]})
if not user:
raise HTTPException(status_code=404, detail="User not found")
return {
"username": user["username"],
"uuid": user["uuid"],
"is_admin": user.get("is_admin", False),
}
async def refresh(self, access_token: str, client_token: str):
if not await self.validate(access_token, client_token):
session = await sessions_collection.find_one({
"access_token": access_token,
"client_token": client_token,
})
if not session:
return None
# Обновляем токен
new_access_token = create_access_token({"sub": "user", "uuid": "user_uuid"})
if datetime.utcnow() > session["expires_at"]:
return None
user = await users_collection.find_one({"uuid": session["user_uuid"]})
if not user:
return None
new_access_token = create_access_token({
"sub": user["username"],
"uuid": user["uuid"],
})
new_expires_at = datetime.utcnow() + timedelta(minutes=1440)
await sessions_collection.update_one(
{"access_token": access_token},
{"$set": {"access_token": new_access_token}},
{"_id": session["_id"]},
{
"$set": {
"access_token": new_access_token,
"expires_at": new_expires_at,
}
},
)
return {"accessToken": new_access_token, "clientToken": client_token}
return {
"accessToken": new_access_token,
"clientToken": client_token,
"selectedProfile": {
"id": user["uuid"],
"name": user["username"],
},
}
async def get_minecraft_profile(self, uuid: str):
# Преобразуем UUID без дефисов в формат с дефисами (если нужно)
@ -236,28 +330,44 @@ class AuthService:
async def join_server(self, request_data: dict):
access_token = request_data.get("accessToken")
selected_profile = request_data.get("selectedProfile") # UUID без дефисов
selected_profile = request_data.get("selectedProfile") # STRING UUID
server_id = request_data.get("serverId")
if not all([access_token, selected_profile, server_id]):
raise HTTPException(status_code=400, detail="Missing required parameters")
decoded_token = decode_token(access_token)
if not decoded_token:
session = await sessions_collection.find_one({
"access_token": access_token,
})
if not session:
raise HTTPException(status_code=401, detail="Invalid session")
if datetime.utcnow() > session["expires_at"]:
raise HTTPException(status_code=401, detail="Session expired")
decoded = decode_token(access_token)
if not decoded:
raise HTTPException(status_code=401, detail="Invalid access token")
token_uuid = decoded_token.get("uuid", "").replace("-", "")
# 🔥 ВАЖНО
token_uuid = decoded["uuid"].replace("-", "")
print("JOIN DEBUG:", {
"token_uuid": token_uuid,
"selected_profile": selected_profile,
"raw": request_data
})
if token_uuid != selected_profile:
raise HTTPException(status_code=403, detail="Token doesn't match selected profile")
raise HTTPException(status_code=403, detail="Profile mismatch")
# Сохраняем server_id в сессию
await sessions_collection.update_one(
{"user_uuid": decoded_token["uuid"]}, # UUID с дефисами
{"_id": session["_id"]},
{"$set": {"server_id": server_id}},
upsert=True
)
return True
return JSONResponse(status_code=204, content=None)
async def has_joined(self, username: str, server_id: str):
user = await users_collection.find_one({"username": username})
@ -266,8 +376,9 @@ class AuthService:
# Ищем сессию с этим server_id
session = await sessions_collection.find_one({
"user_uuid": user["uuid"], # UUID с дефисами
"server_id": server_id
"user_uuid": user["uuid"],
"server_id": server_id,
"expires_at": {"$gt": datetime.utcnow()},
})
if not session:
raise HTTPException(status_code=403, detail="Not joined this server")
@ -328,3 +439,75 @@ class AuthService:
"value": base64_textures
}]
}
async def approve_qr_login(self, token: str, telegram_user_id: int):
qr = await qr_logins_collection.find_one({"token": token})
if not qr:
raise HTTPException(404, "QR token not found")
if qr["status"] != "pending":
raise HTTPException(400, "QR token already used or not pending")
if datetime.utcnow() > qr["expires_at"]:
await qr_logins_collection.update_one({"token": token}, {"$set": {"status": "expired"}})
raise HTTPException(400, "QR token expired")
# находим пользователя по telegram_user_id
user = await users_collection.find_one({"telegram_user_id": telegram_user_id})
if not user:
raise HTTPException(403, "Telegram аккаунт не привязан")
if not user.get("is_verified"):
raise HTTPException(403, "Пользователь не верифицирован")
await qr_logins_collection.update_one(
{"token": token},
{"$set": {"status": "approved", "approved_username": user["username"]}}
)
return {"status": "success"}
async def qr_status(self, token: str, device_id: str | None = None):
qr = await qr_logins_collection.find_one({"token": token})
if not qr:
raise HTTPException(404, "QR token not found")
if datetime.utcnow() > qr["expires_at"] and qr["status"] == "pending":
await qr_logins_collection.update_one({"token": token}, {"$set": {"status": "expired"}})
return {"status": "expired"}
# если хотите привязку к устройству:
if device_id and qr.get("device_id") and qr["device_id"] != device_id:
raise HTTPException(403, "Device mismatch")
if qr["status"] == "approved":
username = qr["approved_username"]
user = await users_collection.find_one({"username": username})
if not user:
raise HTTPException(404, "User not found")
# генерим токены как в login()
access_token = create_access_token({"sub": user["username"], "uuid": user["uuid"]})
client_token = str(uuid.uuid4())
session = Session(
access_token=access_token,
client_token=client_token,
user_uuid=user["uuid"],
expires_at=datetime.utcnow() + timedelta(minutes=1440),
)
await sessions_collection.insert_one(session.dict())
# одноразовость
await qr_logins_collection.update_one(
{"token": token},
{"$set": {"status": "consumed"}}
)
return {
"status": "ok",
"accessToken": access_token,
"clientToken": client_token,
"selectedProfile": {"id": user["uuid"], "name": user["username"]},
}
return {"status": qr["status"]}

299
app/services/bonus.py Normal file
View 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
}

View File

@ -1,5 +1,5 @@
from app.db.database import users_collection
from app.core.config import FILES_URL
from app.core.config import CAPES_DIR, FILES_URL
from fastapi import HTTPException, UploadFile
from datetime import datetime
@ -30,7 +30,7 @@ class CapeService:
import os
old_url = user["cloak_url"]
old_filename = os.path.basename(urlparse(old_url).path)
old_path = os.path.join("/app/static/capes", old_filename)
old_path = CAPES_DIR / old_filename
if os.path.exists(old_path):
try:
os.remove(old_path)
@ -39,7 +39,7 @@ class CapeService:
# Создаем папку для плащей, если ее нет
from pathlib import Path
cape_dir = Path("/app/static/capes")
cape_dir = CAPES_DIR
cape_dir.mkdir(parents=True, exist_ok=True)
cape_filename = f"{username}_{int(datetime.now().timestamp())}.{ext}"

209
app/services/case.py Normal file
View 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
}

View File

@ -1,58 +1,79 @@
from datetime import datetime
from app.db.database import users_collection, sessions_collection
import re
from app.db.database import users_collection
from fastapi import HTTPException
from app.db.database import db
from app.realtime.coins_hub import coins_hub
MAX_MINUTES_PER_UPDATE = 120
coins_sessions_collection = db.coins_sessions
class CoinsService:
_AFK_PREFIX_RE = re.compile(r"^\s*\[\s*AFK\s*\]", re.IGNORECASE)
@classmethod
def _is_afk_name(cls, player_name: str) -> bool:
if not player_name:
return False
return bool(cls._AFK_PREFIX_RE.match(player_name))
async def update_player_coins(self, player_id: str, player_name: str, online_time: int, server_ip: str):
"""Обновляет монеты игрока на основе времени онлайн"""
# Находим пользователя
user = await self._find_user_by_uuid(player_id)
if not user:
return # Пользователь не найден
# Находим последнее обновление монет
last_update = await sessions_collection.find_one({
return
last_update = await coins_sessions_collection.find_one({
"player_id": player_id,
"server_ip": server_ip,
"update_type": "coins_update"
}, sort=[("timestamp", -1)])
now = datetime.now()
now = datetime.utcnow()
# AFK: монеты не начисляем, но обязательно фиксируем тик,
# чтобы AFK-время не накопилось и не начислилось потом.
if self._is_afk_name(player_name):
await coins_sessions_collection.insert_one({
"player_id": player_id,
"player_name": player_name,
"server_ip": server_ip,
"update_type": "coins_update",
"timestamp": now,
"minutes_added": 0,
"coins_added": 0,
"note": "afk_skip"
})
return
current_coins = user.get("coins", 0)
current_total_time = user.get("total_time_played", 0)
if last_update:
# Время с последнего начисления
last_timestamp = last_update["timestamp"]
seconds_since_update = int((now - last_timestamp).total_seconds())
# Начисляем монеты только за полные минуты
seconds_since_update = int((now - last_update["timestamp"]).total_seconds())
minutes_to_reward = seconds_since_update // 60
# Если прошло меньше минуты, пропускаем
if minutes_to_reward < 1:
return
minutes_to_reward = min(minutes_to_reward, 1)
else:
# Первое обновление (ограничиваем для безопасности)
minutes_to_reward = min(online_time // 60, 5)
if minutes_to_reward > 0:
# Обновляем монеты и время
new_coins = current_coins + minutes_to_reward
new_total_time = current_total_time + (minutes_to_reward * 60)
# Сохраняем в БД
await users_collection.update_one(
{"_id": user["_id"]},
{"$set": {
"coins": new_coins,
"total_time_played": new_total_time
}}
{"$set": {"coins": new_coins, "total_time_played": new_total_time}}
)
# Сохраняем запись о начислении
await sessions_collection.insert_one({
await coins_hub.send_update(user["username"], new_coins)
await coins_sessions_collection.insert_one({
"player_id": player_id,
"player_name": player_name,
"server_ip": server_ip,
@ -61,9 +82,6 @@ class CoinsService:
"minutes_added": minutes_to_reward,
"coins_added": minutes_to_reward
})
print(f"[{now}] Игроку {user.get('username')} начислено {minutes_to_reward} монет. "
f"Всего монет: {new_coins}")
async def _find_user_by_uuid(self, player_id: str):
"""Находит пользователя по UUID с поддержкой разных форматов"""
@ -123,7 +141,9 @@ class CoinsService:
raise HTTPException(status_code=404, detail=f"Пользователь {username} не найден")
user = await users_collection.find_one({"username": username})
return user.get("coins", 0)
new_balance = user.get("coins", 0)
await coins_hub.send_update(username, new_balance)
return new_balance
async def decrease_balance(self, username: str, amount: int) -> int:
"""Уменьшить баланс пользователя"""
@ -139,4 +159,6 @@ class CoinsService:
raise HTTPException(status_code=404, detail=f"Пользователь {username} не найден")
user = await users_collection.find_one({"username": username})
return user.get("coins", 0)
new_balance = user.get("coins", 0)
await coins_hub.send_update(username, new_balance)
return new_balance

440
app/services/dailyquests.py Normal file
View 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
View 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
View 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}

View File

@ -4,6 +4,7 @@ from fastapi import HTTPException
from app.db.database import db
from app.services.coins import CoinsService
from app.services.server.command import CommandService
from app.realtime.marketplace_hub import marketplace_hub
# Коллекция для хранения товаров на торговой площадке
marketplace_collection = db.marketplace_items
@ -112,19 +113,28 @@ class MarketplaceService:
}
async def confirm_operation(self, operation_id: str, status: str = "success", error: str = None):
"""Подтвердить выполнение операции"""
update = {
"status": status
}
update = {"status": status}
if error:
update["error"] = error
result = await marketplace_operations.update_one(
{"id": operation_id},
{"$set": update}
)
await marketplace_operations.update_one({"id": operation_id}, {"$set": update})
# ✅ ДОБАВИТЬ ЭТО:
operation = await marketplace_operations.find_one({"id": operation_id})
if operation and operation.get("type") == "case_reward" and operation.get("inventory_item_id"):
inv_id = operation["inventory_item_id"]
if status in ("success", "completed", "done"):
await db.player_inventory.update_one(
{"id": inv_id},
{"$set": {"status": "delivered", "delivered_at": datetime.utcnow()}}
)
elif status in ("failed", "error", "cancelled"):
await db.player_inventory.update_one(
{"id": inv_id},
{"$set": {"status": "stored", "withdraw_operation_id": None}}
)
return {"status": "success"}
async def update_item_details(self, operation_id: str, item_data: dict):
@ -152,6 +162,15 @@ class MarketplaceService:
}
await marketplace_collection.insert_one(marketplace_item)
await marketplace_hub.broadcast(
marketplace_item["server_ip"],
{
"event": "market:item_listed",
"server_ip": marketplace_item["server_ip"],
"item": _serialize_mongodb_doc(marketplace_item),
}
)
# Обновляем операцию
await marketplace_operations.update_one(
@ -205,6 +224,16 @@ class MarketplaceService:
# 7. Удаляем предмет с торговой площадки
await marketplace_collection.delete_one({"id": item_id})
await marketplace_hub.broadcast(
item["server_ip"],
{
"event": "market:item_sold",
"server_ip": item["server_ip"],
"item_id": item_id,
"buyer": buyer_username,
}
)
return {
"status": "pending",
@ -241,12 +270,58 @@ class MarketplaceService:
# Удаляем предмет с торговой площадки
await marketplace_collection.delete_one({"id": item_id})
await marketplace_hub.broadcast(
item["server_ip"],
{
"event": "market:item_cancelled",
"server_ip": item["server_ip"],
"item_id": item_id,
"seller": username,
}
)
return {
"status": "pending",
"operation_id": operation_id,
"message": "Предмет снят с продажи и будет возвращен в ваш инвентарь"
}
async def list_items_by_seller(
self,
username: str,
server_ip: str = None,
page: int = 1,
limit: int = 20
):
"""Получить товары, выставленные конкретным продавцом"""
query = {
"seller_name": username
}
if server_ip:
query["server_ip"] = server_ip
total = await marketplace_collection.count_documents(query)
items_cursor = (
marketplace_collection
.find(query)
.sort("created_at", -1)
.skip((page - 1) * limit)
.limit(limit)
)
items = await items_cursor.to_list(limit)
serialized_items = [_serialize_mongodb_doc(item) for item in items]
return {
"items": serialized_items,
"total": total,
"page": page,
"pages": (total + limit - 1) // limit
}
async def update_item_price(self, username: str, item_id: str, new_price: int):
"""Обновить цену предмета на торговой площадке"""
@ -268,11 +343,63 @@ class MarketplaceService:
{"id": item_id},
{"$set": {"price": new_price}}
)
if result.modified_count == 0:
raise HTTPException(status_code=500, detail="Не удалось обновить цену предмета")
updated = await marketplace_collection.find_one({"id": item_id})
if updated:
await marketplace_hub.broadcast(
updated["server_ip"],
{
"event": "market:item_price_updated",
"server_ip": updated["server_ip"],
"item": _serialize_mongodb_doc(updated),
}
)
return {
"status": "success",
"message": f"Цена предмета обновлена на {new_price} монет"
}
async def list_operations(
self,
server_ip: str = None,
player_name: str = None,
status: str = None,
op_type: str = None,
page: int = 1,
limit: int = 20
):
"""Получить операции маркетплейса (все или по игроку)"""
query = {}
if server_ip:
query["server_ip"] = server_ip
if player_name:
query["player_name"] = player_name
if status:
query["status"] = status
if op_type:
query["type"] = op_type
total = await marketplace_operations.count_documents(query)
cursor = (
marketplace_operations
.find(query)
.sort("created_at", -1)
.skip((page - 1) * limit)
.limit(limit)
)
ops = await cursor.to_list(limit)
serialized_ops = _serialize_mongodb_doc(ops)
return {
"operations": serialized_ops,
"total": total,
"page": page,
"pages": (total + limit - 1) // limit
}

97
app/services/news.py Normal file
View 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
View 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
}

View File

@ -4,6 +4,7 @@ import json
from app.services.coins import CoinsService
from app.models.server.event import PlayerEvent, OnlinePlayersUpdate
import uuid
from app.services.dailyquests import DailyQuestsService
class EventService:
def __init__(self):
@ -33,6 +34,14 @@ class EventService:
# Обновляем данные об онлайн игроках
players = event_data.get("players", [])
await self._update_online_players(server_ip, players)
tick_seconds = 60
for p in players:
name = p.get("player_name")
if name and not self.coins_service._is_afk_name(name):
await DailyQuestsService().on_active_time_tick(name, tick_seconds)
return {"status": "success"}
elif event_type == "player_join":
@ -69,6 +78,17 @@ class EventService:
await self._process_player_session(server_ip, player_id, player_name, duration)
return {"status": "success"}
elif event_type == "mob_kill":
player_name = event_data.get("player_name")
mob = event_data.get("mob")
count = int(event_data.get("count", 1) or 1)
if not player_name or not mob:
raise HTTPException(status_code=400, detail="Missing mob_kill data")
await DailyQuestsService().on_mob_kill(player_name, mob, count)
return {"status": "success"}
# Если тип события не распознан
print(f"[{datetime.now()}] Неизвестное событие: {event_data}")
raise HTTPException(status_code=400, detail="Invalid event type")
@ -284,7 +304,7 @@ class EventService:
})
# Начисляем коины за время игры
coins_service = CoinsService()
await coins_service.update_player_coins(player_id, player_name, duration, server_ip)
# coins_service = CoinsService()
# await coins_service.update_player_coins(player_id, player_name, duration, server_ip)
print(f"[{datetime.now()}] Сессия игрока {player_name} завершена, длительность: {duration} сек.")

View File

@ -3,6 +3,7 @@ from app.db.database import db, users_collection
from app.models.server.prank import PrankCommand, PrankCommandUpdate
from datetime import datetime, timedelta
import uuid
from app.services.coins import CoinsService
from app.services.server.command import CommandService
# Создаем коллекции для хранения пакостей и серверов
@ -266,6 +267,12 @@ class PrankService:
command_result = await command_service.add_command(server_command)
coins_service = CoinsService()
remaining = await coins_service.decrease_balance(
username=username,
amount=command["price"]
)
# Логируем выполнение пакости
log_entry = {
"user_id": user["_id"],
@ -284,5 +291,5 @@ class PrankService:
return {
"status": "success",
"message": f"Команда '{command['name']}' успешно выполнена на игроке {target_player}",
"remaining_coins": user_coins - command["price"]
"remaining_coins": remaining
}

View File

@ -1,7 +1,7 @@
from fastapi import HTTPException, UploadFile
from datetime import datetime
from app.db.database import users_collection
from app.core.config import FILES_URL
from app.core.config import FILES_URL, SKINS_DIR
class SkinService:
async def set_skin(self, username: str, skin_file: UploadFile, skin_model: str = "classic"):
@ -24,7 +24,7 @@ class SkinService:
old_url = user["skin_url"]
# Получаем имя файла из url
old_filename = os.path.basename(urlparse(old_url).path)
old_path = os.path.join("app/static/skins", old_filename)
old_path = SKINS_DIR / old_filename
print(f"Trying to delete old skin at: {old_path}")
if os.path.exists(old_path):
try:
@ -34,7 +34,7 @@ class SkinService:
# Создаем папку для скинов, если ее нет
from pathlib import Path
skin_dir = Path("/app/static/skins")
skin_dir = SKINS_DIR
skin_dir.mkdir(parents=True, exist_ok=True)
# Генерируем имя файла

View File

@ -1,6 +1,6 @@
from fastapi import HTTPException, UploadFile
from app.db.database import users_collection
from app.core.config import FILES_URL
from app.core.config import CAPES_DIR, CAPES_STORE_DIR, FILES_URL
from datetime import datetime
import uuid
from pathlib import Path
@ -35,7 +35,7 @@ class StoreCapeService:
raise HTTPException(status_code=400, detail="Файл слишком большой (максимум 2MB)")
# Создаем папку для плащей магазина, если ее нет
cape_dir = Path("/app/static/capes_store")
cape_dir = CAPES_STORE_DIR
cape_dir.mkdir(parents=True, exist_ok=True)
# Генерируем ID и имя файла
@ -124,7 +124,7 @@ class StoreCapeService:
raise HTTPException(status_code=404, detail="Плащ не найден")
# Удаляем файл
cape_path = Path(f"/app/static/capes_store/{cape['file_name']}")
cape_path = CAPES_STORE_DIR / cape["file_name"]
if cape_path.exists():
try:
cape_path.unlink()
@ -170,10 +170,10 @@ class StoreCapeService:
detail=f"Недостаточно монет. Требуется: {cape['price']}, имеется: {user_coins}")
# Копируем плащ из хранилища магазина в персональную папку пользователя
cape_store_path = Path(f"/app/static/capes_store/{cape['file_name']}")
cape_store_path = CAPES_STORE_DIR / cape["file_name"]
# Создаем папку для плащей пользователя
cape_dir = Path("/app/static/capes")
cape_dir = CAPES_DIR
cape_dir.mkdir(parents=True, exist_ok=True)
# Генерируем имя файла для персонального плаща

91
app/webhooks/telegram.py Normal file
View 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}

View File

@ -8,24 +8,24 @@ services:
- "3001:3000"
user: "${UID:-1000}:${GID:-1000}"
volumes:
- ./app/static:/app/static:rw
- ./app/static:/app/app/static:rw
env_file:
- .env
depends_on:
- mongodb
command: ["uvicorn", "main:app", "--host", "0.0.0.0", "--port", "3000"]
telegram_bot:
container_name: telegram_bot
build:
context: .
dockerfile: Dockerfile
user: "${UID:-1000}:${GID:-1000}"
volumes:
- ./telegram_bot.py:/app/telegram_bot.py
env_file:
- .env
command: ["python", "telegram_bot.py"]
# telegram_bot:
# container_name: telegram_bot
# build:
# context: .
# dockerfile: Dockerfile
# user: "${UID:-1000}:${GID:-1000}"
# volumes:
# - ./telegram_bot.py:/app/telegram_bot.py
# env_file:
# - .env
# command: ["python", "telegram_bot.py"]
mongodb:
container_name: mongodb

73
main.py
View File

@ -1,9 +1,63 @@
from contextlib import asynccontextmanager
import os
from fastapi import FastAPI
from fastapi.staticfiles import StaticFiles
from app.api import users, skins, capes, meta, server, store, pranks, marketplace
import httpx
from app.api import admin_daily_quests, inventory, news, users, skins, capes, meta, server, store, pranks, marketplace, bonuses, case, promo
from fastapi.middleware.cors import CORSMiddleware
app = FastAPI()
from app.core.config import CAPES_DIR, CAPES_STORE_DIR, SKINS_DIR
from app.services.promo import PromoService
from app.webhooks import telegram
from app.db.database import users_collection
from app.api import marketplace_ws, coins_ws
from app.db.database import users_collection, sessions_collection
###################### БОТ ######################
BOT_TOKEN = os.getenv("TELEGRAM_BOT_TOKEN")
PUBLIC_WEBHOOK_URL = os.getenv("PUBLIC_WEBHOOK_URL") # https://minecraft.api.popa-popa.ru/telegram/webhook
WEBHOOK_SECRET = os.getenv("TELEGRAM_WEBHOOK_SECRET", "")
@asynccontextmanager
async def lifespan(app: FastAPI):
# ===== STARTUP =====
# TTL для сессий (автоочистка)
await sessions_collection.create_index(
"expires_at",
expireAfterSeconds=0
)
await users_collection.create_index("expires_at", expireAfterSeconds=0)
await users_collection.create_index("telegram_user_id", unique=True, sparse=True)
if BOT_TOKEN and PUBLIC_WEBHOOK_URL:
payload = {"url": PUBLIC_WEBHOOK_URL}
if WEBHOOK_SECRET:
payload["secret_token"] = WEBHOOK_SECRET
async with httpx.AsyncClient(timeout=20) as client:
await client.post(
f"https://api.telegram.org/bot{BOT_TOKEN}/setWebhook",
json=payload,
)
await PromoService().ensure_indexes()
yield
# ===== SHUTDOWN =====
async with httpx.AsyncClient(timeout=20) as client:
await client.post(
f"https://api.telegram.org/bot{BOT_TOKEN}/deleteWebhook"
)
app = FastAPI(lifespan=lifespan)
##################################################
app.include_router(meta.router)
app.include_router(users.router)
@ -13,11 +67,20 @@ app.include_router(server.router)
app.include_router(store.router)
app.include_router(pranks.router)
app.include_router(marketplace.router)
app.include_router(marketplace_ws.router)
app.include_router(coins_ws.router)
app.include_router(case.router)
app.include_router(inventory.router)
app.include_router(bonuses.router)
app.include_router(news.router)
app.include_router(telegram.router)
app.include_router(admin_daily_quests.router)
app.include_router(promo.router)
# Монтируем статику
app.mount("/skins", StaticFiles(directory="/app/static/skins"), name="skins")
app.mount("/capes", StaticFiles(directory="/app/static/capes"), name="capes")
app.mount("/capes_store", StaticFiles(directory="/app/static/capes_store"), name="capes_store")
app.mount("/skins", StaticFiles(directory=str(SKINS_DIR)), name="skins")
app.mount("/capes", StaticFiles(directory=str(CAPES_DIR)), name="capes")
app.mount("/capes_store", StaticFiles(directory=str(CAPES_STORE_DIR)), name="capes_store")
# CORS, middleware и т.д.
app.add_middleware(

View File

@ -1,9 +1,9 @@
fastapi>=0.110.0
uvicorn>=0.28.0
uvicorn[standard]>=0.28.0
motor>=3.7.0
python-jose>=3.3.0
passlib>=1.7.4
bcrypt>=4.0.1
passlib==1.7.4
bcrypt==4.3.0
python-multipart>=0.0.9
mongoengine>=0.24.2
python-dotenv>=1.0.0
@ -11,4 +11,4 @@ pydantic>=2.0.0
cryptography>=43.0.0
pytelegrambotapi>=2.0.0
httpx>=0.27.2
aiogram>=3.20.0