Files

508 строки
20 KiB
Python

import logging
from contextlib import asynccontextmanager
from datetime import datetime, timedelta
from pathlib import Path
from typing import Optional
from fastapi import FastAPI, HTTPException
from fastapi.middleware.cors import CORSMiddleware
from fastapi.staticfiles import StaticFiles
from pydantic import BaseModel, Field
from sqlalchemy import select, func, desc
from config import settings
from database import AsyncSessionLocal, init_db
from models import (
Tenant, Shop, ShopToken, Product, PaymentCard, Order,
BalanceTransaction, OrderStatus, PlanEnum, ShopMember,
)
from billing import commission_rate, is_postpaid, tenant_can_sell, current_due_date, postpaid_previous_month_totals
logging.basicConfig(
level=logging.DEBUG if settings.DEBUG else logging.INFO,
format="%(asctime)s [%(levelname)s] %(name)s: %(message)s",
)
logger = logging.getLogger("botfactory")
APP_VERSION = "2.2.0"
@asynccontextmanager
async def lifespan(app: FastAPI):
logger.info("Starting BotFactory API...")
await init_db()
logger.info("Database initialized")
yield
logger.info("Shutting down BotFactory API")
app = FastAPI(
title="BotFactory API",
version=APP_VERSION,
docs_url="/api/docs" if settings.DEBUG else None,
redoc_url=None,
lifespan=lifespan,
)
app.add_middleware(
CORSMiddleware,
allow_origins=[o.strip() for o in settings.ALLOWED_ORIGINS.split(",") if o.strip()] or ["*"],
allow_credentials=True,
allow_methods=["*"],
allow_headers=["*"],
)
upload_path = Path(settings.UPLOAD_DIR)
upload_path.mkdir(parents=True, exist_ok=True)
app.mount("/uploads", StaticFiles(directory=str(upload_path)), name="uploads")
class TenantIn(BaseModel):
name: str = Field(min_length=1, max_length=128)
email: str = Field(min_length=3, max_length=256)
telegram_id: Optional[int] = None
balance: float = 0
alert_threshold: float = 200
plan: PlanEnum = PlanEnum.trial_week
class TenantTopupIn(BaseModel):
amount: float = Field(gt=0)
note: str = "Пополнение через веб-панель"
class TenantPlanIn(BaseModel):
plan: PlanEnum
postpaid_commission_percent: Optional[float] = None
postpaid_due_day: Optional[int] = None
class ShopIn(BaseModel):
tenant_id: int
name: str = Field(min_length=1, max_length=128)
ctrl_bot_token: str = Field(min_length=10, max_length=128)
ctrl_bot_username: str = ""
welcome_msg: str = "Добро пожаловать!"
class ProductIn(BaseModel):
shop_id: int
name: str = Field(min_length=1, max_length=256)
price: float = Field(gt=0)
category: str = "Общее"
description: str = ""
content: str = ""
photo_url: Optional[str] = None
stock: int = 1
class CardIn(BaseModel):
shop_id: int
bank: str = Field(min_length=1, max_length=64)
number: str = Field(min_length=4, max_length=32)
holder: str = Field(min_length=1, max_length=128)
phone: str = ""
class TokenIn(BaseModel):
shop_id: int
token: str = Field(min_length=10, max_length=128)
username: str = ""
note: str = "Основной"
is_active: bool = True
class MemberIn(BaseModel):
shop_id: int
telegram_id: int
username: str = ""
name: str = Field(min_length=1, max_length=128)
role: str = "moderator"
def safe_dt(v):
return v.isoformat() if v else None
def money(v) -> float:
return float(v or 0)
async def serialize_tenant(db, t: Tenant):
shops_count = (await db.execute(select(func.count(Shop.id)).where(Shop.tenant_id == t.id))).scalar() or 0
revenue = (await db.execute(
select(func.sum(Order.amount)).join(Shop, Order.shop_id == Shop.id)
.where(Shop.tenant_id == t.id, Order.status == OrderStatus.completed)
)).scalar() or 0
commission = (await db.execute(
select(func.sum(Order.commission)).join(Shop, Order.shop_id == Shop.id)
.where(Shop.tenant_id == t.id, Order.status == OrderStatus.completed)
)).scalar() or 0
due = None
prev_revenue = prev_due = 0
if t.plan == PlanEnum.postpaid_custom:
due = safe_dt(current_due_date(t))
prev_revenue, prev_due = await postpaid_previous_month_totals(db, t.id)
return {
"id": t.id,
"name": t.name,
"email": t.email,
"telegram_id": t.telegram_id,
"plan": t.plan.value if t.plan else None,
"trial_ends_at": safe_dt(t.trial_ends_at),
"postpaid_commission_percent": t.postpaid_commission_percent,
"postpaid_due_day": t.postpaid_due_day,
"postpaid_due_date": due,
"postpaid_previous_month_revenue": money(prev_revenue),
"postpaid_previous_month_due": money(prev_due),
"balance": money(t.balance),
"alert_threshold": money(t.alert_threshold),
"is_active": bool(t.is_active),
"is_blocked": bool(t.is_blocked),
"shops_count": int(shops_count),
"total_revenue": money(revenue),
"total_commission": money(commission),
"created_at": safe_dt(t.created_at),
}
async def serialize_shop(db, s: Shop):
products_count = (await db.execute(select(func.count(Product.id)).where(Product.shop_id == s.id))).scalar() or 0
orders_count = (await db.execute(select(func.count(Order.id)).where(Order.shop_id == s.id))).scalar() or 0
revenue = (await db.execute(select(func.sum(Order.amount)).where(Order.shop_id == s.id, Order.status == OrderStatus.completed))).scalar() or 0
active_token = (await db.execute(select(ShopToken).where(ShopToken.shop_id == s.id, ShopToken.is_active == True))).scalar_one_or_none()
return {
"id": s.id,
"tenant_id": s.tenant_id,
"name": s.name,
"ctrl_bot_username": s.ctrl_bot_username,
"ctrl_bot_token_set": bool(s.ctrl_bot_token),
"welcome_msg": s.welcome_msg,
"is_active": bool(s.is_active),
"products_count": int(products_count),
"orders_count": int(orders_count),
"revenue": money(revenue),
"active_shop_bot": active_token.username if active_token else None,
"created_at": safe_dt(s.created_at),
}
@app.get("/api/health", tags=["System"])
async def health():
return {"status": "ok", "version": APP_VERSION}
@app.get("/api/version", tags=["System"])
async def version():
return {"version": APP_VERSION, "debug": settings.DEBUG, "domain": settings.DOMAIN or None}
@app.get("/api/admin/overview", tags=["Admin"])
async def admin_overview():
async with AsyncSessionLocal() as db:
tenants = (await db.execute(select(Tenant).order_by(desc(Tenant.created_at)))).scalars().all()
shops = (await db.execute(select(Shop).order_by(desc(Shop.created_at)))).scalars().all()
products = (await db.execute(select(Product).order_by(desc(Product.created_at)).limit(300))).scalars().all()
cards = (await db.execute(select(PaymentCard).order_by(PaymentCard.id.desc()).limit(300))).scalars().all()
tokens = (await db.execute(select(ShopToken).order_by(ShopToken.id.desc()).limit(300))).scalars().all()
members = (await db.execute(select(ShopMember).order_by(ShopMember.id.desc()).limit(300))).scalars().all()
orders = (await db.execute(select(Order).order_by(desc(Order.created_at)).limit(300))).scalars().all()
tx = (await db.execute(select(BalanceTransaction).order_by(desc(BalanceTransaction.created_at)).limit(100))).scalars().all()
stats = {
"tenants": len(tenants),
"shops": len(shops),
"products": len(products),
"cards": len(cards),
"orders": len(orders),
"pending_orders": int((await db.execute(select(func.count(Order.id)).where(Order.status.in_([OrderStatus.pending, OrderStatus.confirming])))).scalar() or 0),
"turnover": money((await db.execute(select(func.sum(Order.amount)).where(Order.status == OrderStatus.completed))).scalar()),
"commission": money((await db.execute(select(func.sum(Order.commission)).where(Order.status == OrderStatus.completed))).scalar()),
}
return {
"stats": stats,
"tenants": [await serialize_tenant(db, t) for t in tenants],
"shops": [await serialize_shop(db, s) for s in shops],
"products": [{
"id": p.id, "shop_id": p.shop_id, "name": p.name, "category": p.category,
"description": p.description, "content": p.content, "photo_url": p.photo_url,
"price": money(p.price), "stock": p.stock, "sold": p.sold,
"is_active": p.is_active, "created_at": safe_dt(p.created_at),
} for p in products],
"cards": [{
"id": c.id, "shop_id": c.shop_id, "bank": c.bank, "number": c.number,
"holder": c.holder, "phone": c.phone, "is_active": c.is_active,
"orders_count": c.orders_count, "received_total": money(c.received_total),
} for c in cards],
"tokens": [{
"id": t.id, "shop_id": t.shop_id, "username": t.username,
"note": t.note, "is_active": t.is_active, "added_at": safe_dt(t.added_at),
"token_masked": (t.token[:10] + "…" + t.token[-4:]) if t.token else "",
} for t in tokens],
"members": [{
"id": m.id, "shop_id": m.shop_id, "telegram_id": m.telegram_id,
"username": m.username, "name": m.name, "role": m.role,
"added_at": safe_dt(m.added_at),
} for m in members],
"orders": [{
"id": o.id, "shop_id": o.shop_id, "product_id": o.product_id, "card_id": o.card_id,
"buyer_telegram_id": o.buyer_telegram_id, "buyer_username": o.buyer_username,
"amount": money(o.amount), "commission": money(o.commission),
"status": o.status.value if o.status else None, "proof_file_id": bool(o.proof_file_id),
"confirmed_by": o.confirmed_by, "created_at": safe_dt(o.created_at),
"updated_at": safe_dt(o.updated_at),
} for o in orders],
"transactions": [{
"id": x.id, "tenant_id": x.tenant_id, "type": x.type,
"amount": money(x.amount), "balance_after": money(x.balance_after),
"note": x.note, "order_id": x.order_id, "created_at": safe_dt(x.created_at),
} for x in tx],
}
@app.post("/api/admin/tenants", tags=["Admin"])
async def create_tenant(data: TenantIn):
async with AsyncSessionLocal() as db:
exists = (await db.execute(select(Tenant).where(Tenant.email == data.email))).scalar_one_or_none()
if exists:
raise HTTPException(400, "Пользователь с таким email уже есть")
tenant = Tenant(
name=data.name, email=data.email, password_hash="manual-created",
telegram_id=data.telegram_id, plan=data.plan,
trial_ends_at=datetime.utcnow() + timedelta(days=7) if data.plan == PlanEnum.trial_week else None,
balance=data.balance, alert_threshold=data.alert_threshold,
)
db.add(tenant)
await db.commit()
await db.refresh(tenant)
return {"ok": True, "tenant": await serialize_tenant(db, tenant)}
@app.post("/api/admin/tenants/{tenant_id}/topup", tags=["Admin"])
async def topup_tenant(tenant_id: int, data: TenantTopupIn):
async with AsyncSessionLocal() as db:
tenant = await db.get(Tenant, tenant_id)
if not tenant:
raise HTTPException(404, "Пользователь не найден")
tenant.balance += data.amount
if tenant.balance > 0:
tenant.is_blocked = False
db.add(BalanceTransaction(
tenant_id=tenant.id, type="deposit", amount=data.amount,
balance_after=tenant.balance, note=data.note,
))
await db.commit()
return {"ok": True, "tenant": await serialize_tenant(db, tenant)}
@app.post("/api/admin/tenants/{tenant_id}/plan", tags=["Admin"])
async def set_tenant_plan(tenant_id: int, data: TenantPlanIn):
async with AsyncSessionLocal() as db:
tenant = await db.get(Tenant, tenant_id)
if not tenant:
raise HTTPException(404, "Пользователь не найден")
tenant.plan = data.plan
tenant.trial_ends_at = datetime.utcnow() + timedelta(days=7) if data.plan == PlanEnum.trial_week else None
if data.plan == PlanEnum.postpaid_custom:
tenant.postpaid_commission_percent = float(data.postpaid_commission_percent or settings.COMMISSION_POSTPAID_DEFAULT)
tenant.postpaid_due_day = max(1, min(28, int(data.postpaid_due_day or settings.POSTPAID_DEFAULT_DUE_DAY)))
tenant.postpaid_enabled_at = datetime.utcnow()
tenant.postpaid_note = "Назначено через веб-панель"
tenant.is_blocked = False
else:
tenant.postpaid_enabled_at = None
tenant.postpaid_note = ""
await db.commit()
return {"ok": True, "tenant": await serialize_tenant(db, tenant)}
@app.post("/api/admin/tenants/{tenant_id}/toggle-block", tags=["Admin"])
async def toggle_tenant_block(tenant_id: int):
async with AsyncSessionLocal() as db:
tenant = await db.get(Tenant, tenant_id)
if not tenant:
raise HTTPException(404, "Пользователь не найден")
tenant.is_blocked = not tenant.is_blocked
await db.commit()
return {"ok": True, "is_blocked": tenant.is_blocked}
@app.post("/api/admin/shops", tags=["Admin"])
async def create_shop(data: ShopIn):
async with AsyncSessionLocal() as db:
tenant = await db.get(Tenant, data.tenant_id)
if not tenant:
raise HTTPException(404, "Владелец не найден")
shop = Shop(
tenant_id=data.tenant_id, name=data.name,
ctrl_bot_token=data.ctrl_bot_token, ctrl_bot_username=data.ctrl_bot_username,
welcome_msg=data.welcome_msg,
)
db.add(shop)
await db.commit()
await db.refresh(shop)
return {"ok": True, "shop": await serialize_shop(db, shop)}
@app.post("/api/admin/products", tags=["Admin"])
async def create_product(data: ProductIn):
async with AsyncSessionLocal() as db:
shop = await db.get(Shop, data.shop_id)
if not shop:
raise HTTPException(404, "Магазин не найден")
product = Product(
shop_id=data.shop_id, name=data.name, price=data.price, category=data.category,
description=data.description, content=data.content, photo_url=data.photo_url,
stock=max(0, data.stock), is_active=data.stock > 0,
)
db.add(product)
await db.commit()
await db.refresh(product)
return {"ok": True, "id": product.id}
@app.post("/api/admin/cards", tags=["Admin"])
async def create_card(data: CardIn):
async with AsyncSessionLocal() as db:
shop = await db.get(Shop, data.shop_id)
if not shop:
raise HTTPException(404, "Магазин не найден")
card = PaymentCard(shop_id=data.shop_id, bank=data.bank, number=data.number, holder=data.holder, phone=data.phone)
db.add(card)
await db.commit()
await db.refresh(card)
return {"ok": True, "id": card.id}
@app.post("/api/admin/tokens", tags=["Admin"])
async def create_token(data: TokenIn):
async with AsyncSessionLocal() as db:
shop = await db.get(Shop, data.shop_id)
if not shop:
raise HTTPException(404, "Магазин не найден")
if data.is_active:
rows = (await db.execute(select(ShopToken).where(ShopToken.shop_id == data.shop_id))).scalars().all()
for row in rows:
row.is_active = False
token = ShopToken(
shop_id=data.shop_id, token=data.token, username=data.username,
note=data.note, is_active=data.is_active,
)
db.add(token)
await db.commit()
await db.refresh(token)
return {"ok": True, "id": token.id}
@app.post("/api/admin/tokens/{token_id}/activate", tags=["Admin"])
async def activate_token(token_id: int):
async with AsyncSessionLocal() as db:
token = await db.get(ShopToken, token_id)
if not token:
raise HTTPException(404, "Токен не найден")
rows = (await db.execute(select(ShopToken).where(ShopToken.shop_id == token.shop_id))).scalars().all()
for row in rows:
row.is_active = row.id == token.id
await db.commit()
return {"ok": True}
@app.post("/api/admin/members", tags=["Admin"])
async def create_member(data: MemberIn):
async with AsyncSessionLocal() as db:
shop = await db.get(Shop, data.shop_id)
if not shop:
raise HTTPException(404, "Магазин не найден")
member = ShopMember(
shop_id=data.shop_id, telegram_id=data.telegram_id,
username=data.username, name=data.name, role=data.role,
)
db.add(member)
await db.commit()
await db.refresh(member)
return {"ok": True, "id": member.id}
@app.post("/api/admin/orders/{order_id}/reject", tags=["Admin"])
async def reject_order(order_id: int):
async with AsyncSessionLocal() as db:
order = await db.get(Order, order_id)
if not order:
raise HTTPException(404, "Заказ не найден")
order.status = OrderStatus.rejected
await db.commit()
return {"ok": True}
async def deliver_order(shop_id: int, buyer_tg_id: int, content: str, order_id: int) -> bool:
try:
async with AsyncSessionLocal() as db:
tok = (await db.execute(select(ShopToken).where(ShopToken.shop_id == shop_id, ShopToken.is_active == True))).scalar_one_or_none()
if not tok:
return False
from aiogram import Bot
bot = Bot(token=tok.token)
try:
await bot.send_message(
buyer_tg_id,
f"✅ <b>Оплата подтверждена!</b>\n\nЗаказ #{order_id}\n\n<code>{content}</code>",
parse_mode="HTML",
)
return True
finally:
await bot.session.close()
except Exception as e:
logger.error("deliver_order #%s failed: %s", order_id, e)
return False
@app.post("/api/admin/orders/{order_id}/confirm", tags=["Admin"])
async def confirm_order_api(order_id: int):
async with AsyncSessionLocal() as db:
order = await db.get(Order, order_id)
if not order or order.status not in [OrderStatus.pending, OrderStatus.confirming]:
raise HTTPException(400, "Заказ нельзя подтвердить")
shop = await db.get(Shop, order.shop_id)
tenant = await db.get(Tenant, shop.tenant_id) if shop else None
ok, reason = tenant_can_sell(tenant)
if not ok:
raise HTTPException(400, reason)
product = await db.get(Product, order.product_id)
card = await db.get(PaymentCard, order.card_id) if order.card_id else None
order.status = OrderStatus.completed
order.confirmed_by = 0
if product:
product.sold += 1
if product.stock > 0:
product.stock -= 1
if product.stock <= 0:
product.is_active = False
if card:
card.received_total += order.amount
if tenant and not is_postpaid(tenant):
old = tenant.balance
tenant.balance -= order.commission
db.add(BalanceTransaction(
tenant_id=tenant.id, type="commission", amount=-order.commission,
balance_after=tenant.balance, note=f"Комиссия заказ #{order_id}", order_id=order_id,
))
if old > 0 and tenant.balance <= 0:
tenant.is_blocked = True
elif tenant:
db.add(BalanceTransaction(
tenant_id=tenant.id, type="postpaid_accrual", amount=0,
balance_after=tenant.balance, note=f"Постоплатная комиссия {order.commission:.2f} за заказ #{order_id}",
order_id=order_id,
))
shop_id = order.shop_id
buyer_id = order.buyer_telegram_id
content = order.product_content
await db.commit()
delivered = await deliver_order(shop_id, buyer_id, content, order_id)
return {"ok": True, "delivered": delivered}
if __name__ == "__main__":
import uvicorn
uvicorn.run("main:app", host=settings.API_HOST, port=settings.API_PORT, reload=settings.DEBUG, loop="uvloop")