Удалены демо данные и подключен рабочий API BotFactory v2.2
Этот коммит содержится в:
+22
-1
@@ -1,4 +1,4 @@
|
||||
# BotFactory v2.1
|
||||
# BotFactory v2.2
|
||||
|
||||
SaaS-платформа для Telegram-магазинов: платформенный админ-бот, боты управления магазинами, клиентские боты, FastAPI backend и React/Vite frontend.
|
||||
|
||||
@@ -128,3 +128,24 @@ certbot --nginx -d tg-bot.fun -d www.tg-bot.fun --email admin@tg-bot.fun --agree
|
||||
## Важно по безопасности
|
||||
|
||||
Не публикуйте реальные Telegram bot token в GitHub, чатах и логах. Если токен уже засветился, перевыпустите его через BotFather.
|
||||
|
||||
|
||||
## v2.2.0
|
||||
|
||||
- Демо-данные удалены из React-панели.
|
||||
- Панель теперь читает реальные данные из PostgreSQL через `/api/admin/overview`.
|
||||
- Добавлены рабочие API для создания пользователей, магазинов, товаров, карт, токенов бота магазина и кассиров.
|
||||
- Добавлены API-действия: пополнение баланса, смена тарифа, назначение постоплаты, блокировка пользователя, активация резервного токена, подтверждение/отклонение заказов.
|
||||
- Исправлен молчащий платформенный бот: aiogram больше не падает на `unexpected keyword argument dispatcher`.
|
||||
- Исправлен повторный старт платформенного роутера после падения polling.
|
||||
- Исправлены systemd service-файлы: `StartLimitIntervalSec` перенесён в `[Unit]`, добавлены `TimeoutStopSec` и `KillMode=mixed`.
|
||||
|
||||
### Минимальная настройка после установки
|
||||
|
||||
1. В веб-панели создайте пользователя-владельца.
|
||||
2. Создайте магазин и укажите токен отдельного бота управления.
|
||||
3. Добавьте токен покупательского бота магазина и сделайте его активным.
|
||||
4. Добавьте карту/СБП-реквизиты.
|
||||
5. Добавьте товар с контентом для автовыдачи.
|
||||
6. Добавьте кассиров по Telegram ID, если нужны отдельные модераторы.
|
||||
7. Перезапустите `botfactory-bots` или подождите до 30 секунд — runner сам подхватит магазин.
|
||||
|
||||
+1
-1
@@ -1 +1 @@
|
||||
2.1.1
|
||||
2.2.0
|
||||
|
||||
+453
-17
@@ -1,19 +1,29 @@
|
||||
import logging
|
||||
from contextlib import asynccontextmanager
|
||||
from datetime import datetime, timedelta
|
||||
from pathlib import Path
|
||||
from typing import Optional
|
||||
|
||||
from fastapi import FastAPI
|
||||
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 init_db
|
||||
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
|
||||
@@ -27,7 +37,7 @@ async def lifespan(app: FastAPI):
|
||||
|
||||
app = FastAPI(
|
||||
title="BotFactory API",
|
||||
version="2.1.0",
|
||||
version=APP_VERSION,
|
||||
docs_url="/api/docs" if settings.DEBUG else None,
|
||||
redoc_url=None,
|
||||
lifespan=lifespan,
|
||||
@@ -35,7 +45,7 @@ app = FastAPI(
|
||||
|
||||
app.add_middleware(
|
||||
CORSMiddleware,
|
||||
allow_origins=[o.strip() for o in settings.ALLOWED_ORIGINS.split(",") if o.strip()],
|
||||
allow_origins=[o.strip() for o in settings.ALLOWED_ORIGINS.split(",") if o.strip()] or ["*"],
|
||||
allow_credentials=True,
|
||||
allow_methods=["*"],
|
||||
allow_headers=["*"],
|
||||
@@ -46,26 +56,452 @@ 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": "2.1.0"}
|
||||
return {"status": "ok", "version": APP_VERSION}
|
||||
|
||||
|
||||
@app.get("/api/version", tags=["System"])
|
||||
async def version():
|
||||
return {
|
||||
"version": "2.1.0",
|
||||
"debug": settings.DEBUG,
|
||||
"domain": settings.DOMAIN or None,
|
||||
}
|
||||
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",
|
||||
)
|
||||
uvicorn.run("main:app", host=settings.API_HOST, port=settings.API_PORT, reload=settings.DEBUG, loop="uvloop")
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
"""BotFactory — Platform Admin Bot (системный бот платформы)"""
|
||||
import asyncio, logging
|
||||
import asyncio, logging, inspect
|
||||
from functools import wraps
|
||||
from datetime import datetime, timedelta
|
||||
from aiogram import Bot, Dispatcher, F, Router
|
||||
from aiogram.filters import Command
|
||||
@@ -25,13 +26,20 @@ def is_admin(uid: int) -> bool:
|
||||
return uid in settings.admin_ids
|
||||
|
||||
def guard(fn):
|
||||
sig = inspect.signature(fn)
|
||||
accepted = set(sig.parameters.keys())
|
||||
|
||||
@wraps(fn)
|
||||
async def w(event, *a, **kw):
|
||||
uid = event.from_user.id
|
||||
if not is_admin(uid):
|
||||
t = event.answer if isinstance(event, Message) else event.answer
|
||||
await t("⛔ Доступ запрещён.", show_alert=True) if isinstance(event, CallbackQuery) else await t("⛔ Доступ запрещён.")
|
||||
return
|
||||
return await fn(event, *a, **kw)
|
||||
# aiogram 3 passes service kwargs (bot, dispatcher, event_from_user, ...).
|
||||
# Wrapped handlers must receive only arguments they declared.
|
||||
clean_kw = {k: v for k, v in kw.items() if k in accepted}
|
||||
return await fn(event, *a, **clean_kw)
|
||||
return w
|
||||
|
||||
|
||||
@@ -686,6 +694,16 @@ async def start_platform_bot():
|
||||
storage = RedisStorage.from_url(settings.REDIS_URL)
|
||||
bot = Bot(token=settings.PLATFORM_BOT_TOKEN)
|
||||
dp = Dispatcher(storage=storage)
|
||||
# If the previous polling attempt crashed inside the same process, aiogram
|
||||
# may keep the global router marked as attached. Detach it before reusing.
|
||||
try:
|
||||
if getattr(router, "parent_router", None) is not None:
|
||||
try:
|
||||
router.parent_router = None
|
||||
except Exception:
|
||||
setattr(router, "_parent_router", None)
|
||||
except Exception:
|
||||
pass
|
||||
dp.include_router(router)
|
||||
logger.info("Platform admin bot started")
|
||||
try:
|
||||
|
||||
Generated
+2018
Разница между файлами не показана из-за своего большого размера
Загрузить разницу
+187
-1251
Разница между файлами не показана из-за своего большого размера
Загрузить разницу
@@ -1,9 +1,5 @@
|
||||
import React from 'react'
|
||||
import ReactDOM from 'react-dom/client'
|
||||
import App from './App.jsx'
|
||||
import React from "react";
|
||||
import { createRoot } from "react-dom/client";
|
||||
import App from "./App.jsx";
|
||||
|
||||
ReactDOM.createRoot(document.getElementById('root')).render(
|
||||
<React.StrictMode>
|
||||
<App />
|
||||
</React.StrictMode>
|
||||
)
|
||||
createRoot(document.getElementById("root")).render(<App />);
|
||||
|
||||
@@ -0,0 +1 @@
|
||||
:root{font-family:Inter,system-ui,-apple-system,Segoe UI,sans-serif;background:#08090d;color:#eef1f6}*{box-sizing:border-box}body{margin:0}.app{min-height:100vh;display:grid;grid-template-columns:280px 1fr;background:radial-gradient(circle at top left,#16213e 0,#08090d 42%)}aside{border-right:1px solid #202331;background:#0c0e14cc;backdrop-filter:blur(18px);padding:18px;position:sticky;top:0;height:100vh;overflow:auto}.brand{display:flex;gap:12px;align-items:center;margin-bottom:24px}.logo{width:44px;height:44px;border-radius:14px;background:linear-gradient(135deg,#5d7cff,#8e54ff);display:grid;place-items:center;font-weight:900}.brand b{display:block;font-size:18px}.brand span,small{display:block;color:#8a90a3;font-size:12px;margin-top:4px}aside button,header button,.actions button,.primary{width:100%;border:1px solid #252a38;background:#11141d;color:#eef1f6;border-radius:12px;padding:11px 12px;margin-bottom:8px;text-align:left;cursor:pointer;font-weight:650}aside button.active,aside button:hover{border-color:#5d7cff;background:#17213d}.ghost{opacity:.75}main{padding:26px;min-width:0}header{display:flex;justify-content:space-between;align-items:flex-end;gap:18px;margin-bottom:24px}header p{margin:0 0 5px;color:#8a90a3;text-transform:uppercase;letter-spacing:.1em;font-size:11px}h1{margin:0;font-size:32px}h2{margin:26px 0 14px}.grid{display:grid;grid-template-columns:repeat(auto-fit,minmax(180px,1fr));gap:14px}.card,.empty{border:1px solid #222638;background:#11141dcc;border-radius:18px;padding:18px;box-shadow:0 20px 70px #0006}.card span,.empty span{display:block;color:#8a90a3;font-size:13px}.card b{display:block;font-size:27px;margin-top:8px}.card.warn{border-color:#c99d34}.empty{padding:30px}.empty b{display:block;font-size:20px;margin-bottom:8px}.tableWrap{overflow:auto;border:1px solid #222638;border-radius:18px;background:#0f121a}table{width:100%;border-collapse:collapse;min-width:850px}th,td{text-align:left;padding:13px 14px;border-bottom:1px solid #202433;vertical-align:top}th{font-size:11px;text-transform:uppercase;letter-spacing:.08em;color:#7f869a;background:#11141d;position:sticky;top:0}td{font-size:14px}td b{display:block}.badge{display:inline-block;padding:5px 9px;border-radius:999px;background:#242937;color:#cbd1df;font-size:12px;font-weight:800}.badge.green{background:#143826;color:#74f3ad}.badge.red{background:#3b1717;color:#ff8989}.badge.yellow{background:#3d3315;color:#ffd36c}.badge.purple{background:#2b1840;color:#dba5ff}.actions{white-space:nowrap}.actions button{display:inline-block;width:auto;margin:0 6px 6px 0;padding:8px 10px}.modalBack{position:fixed;inset:0;background:#000b;display:grid;place-items:center;z-index:20;padding:16px}.modal{width:min(680px,100%);max-height:92vh;overflow:auto;background:#10131b;border:1px solid #2a3040;border-radius:20px;box-shadow:0 20px 80px #000}.modalHead{display:flex;justify-content:space-between;align-items:center;padding:18px 20px;border-bottom:1px solid #252a38}.modalHead button{width:auto;background:transparent;border:0;color:white;font-size:28px;cursor:pointer}.form{padding:20px}.field{display:block;margin-bottom:14px}.field span{display:block;margin-bottom:7px;color:#9ca3b7;font-size:13px}input,select,textarea{width:100%;border:1px solid #282e3f;background:#0b0d13;color:white;border-radius:12px;padding:12px;font:inherit}textarea{min-height:92px}.primary{text-align:center;background:#5d7cff;border-color:#6d87ff;color:white;margin-top:10px}.primary:disabled{opacity:.6}.toast{position:fixed;right:22px;bottom:22px;background:#5d7cff;color:white;padding:14px 18px;border-radius:14px;font-weight:800;box-shadow:0 12px 40px #0008;animation:toast 3s forwards}@keyframes toast{0%,80%{opacity:1;transform:translateY(0)}100%{opacity:0;transform:translateY(14px)}}@media(max-width:900px){.app{display:block}aside{height:auto;position:relative;display:grid;grid-template-columns:1fr 1fr;gap:8px}aside .brand{grid-column:1/-1}aside button{margin:0}main{padding:16px}header{align-items:flex-start;flex-direction:column}header button{width:auto}h1{font-size:25px}.stats{grid-template-columns:1fr 1fr}table{min-width:760px}}
|
||||
+8
-4
@@ -394,6 +394,8 @@ cat > /etc/systemd/system/botfactory-api.service << SERVICE
|
||||
Description=BotFactory API (FastAPI)
|
||||
After=network.target postgresql.service redis-server.service
|
||||
Requires=postgresql.service redis-server.service
|
||||
StartLimitIntervalSec=60
|
||||
StartLimitBurst=5
|
||||
|
||||
[Service]
|
||||
Type=exec
|
||||
@@ -405,8 +407,8 @@ EnvironmentFile=${APP_DIR}/.env
|
||||
ExecStart=${VENV}/bin/uvicorn main:app --host 127.0.0.1 --port ${API_PORT} --workers 1 --loop uvloop --log-level info
|
||||
Restart=always
|
||||
RestartSec=5
|
||||
StartLimitIntervalSec=60
|
||||
StartLimitBurst=5
|
||||
TimeoutStopSec=20
|
||||
KillMode=mixed
|
||||
StandardOutput=append:${APP_DIR}/logs/api.log
|
||||
StandardError=append:${APP_DIR}/logs/api-error.log
|
||||
|
||||
@@ -419,6 +421,8 @@ cat > /etc/systemd/system/botfactory-bots.service << SERVICE
|
||||
Description=BotFactory Bots Runner (platform + shops)
|
||||
After=network-online.target postgresql.service redis-server.service
|
||||
Wants=network-online.target postgresql.service redis-server.service
|
||||
StartLimitIntervalSec=60
|
||||
StartLimitBurst=5
|
||||
|
||||
[Service]
|
||||
Type=exec
|
||||
@@ -430,8 +434,8 @@ EnvironmentFile=${APP_DIR}/.env
|
||||
ExecStart=${VENV}/bin/python shop_bots_runner.py
|
||||
Restart=always
|
||||
RestartSec=10
|
||||
StartLimitIntervalSec=60
|
||||
StartLimitBurst=5
|
||||
TimeoutStopSec=20
|
||||
KillMode=mixed
|
||||
StandardOutput=append:${APP_DIR}/logs/bots.log
|
||||
StandardError=append:${APP_DIR}/logs/bots-error.log
|
||||
|
||||
|
||||
@@ -1,11 +1,13 @@
|
||||
#!/usr/bin/env bash
|
||||
# BotFactory v2.1.1 — emergency repair for nginx 500 + service diagnostics
|
||||
# BotFactory v2.2.0 — repair/update script for real API + frontend + bots
|
||||
set -euo pipefail
|
||||
|
||||
APP_DIR="/opt/botfactory"
|
||||
SRC_DIR="/opt/botfactory-src"
|
||||
APP_USER="botfactory"
|
||||
FRONT_DIR="${APP_DIR}/frontend"
|
||||
BACK_DIR="${APP_DIR}/backend"
|
||||
VENV="${APP_DIR}/venv"
|
||||
API_PORT="8000"
|
||||
|
||||
ok(){ echo -e "\033[0;32m[✓]\033[0m $*"; }
|
||||
@@ -15,53 +17,103 @@ err(){ echo -e "\033[0;31m[✗]\033[0m $*"; }
|
||||
[[ $EUID -eq 0 ]] || { err "Запустите от root: sudo bash scripts/repair_server.sh"; exit 1; }
|
||||
[[ -d "$APP_DIR" ]] || { err "Нет ${APP_DIR}. Сначала установите BotFactory."; exit 1; }
|
||||
|
||||
mkdir -p "${APP_DIR}/logs" "${APP_DIR}/uploads" "${APP_DIR}/backups"
|
||||
|
||||
if id "$APP_USER" >/dev/null 2>&1; then
|
||||
chown -R "${APP_USER}:${APP_USER}" "$APP_DIR"
|
||||
else
|
||||
warn "Пользователь ${APP_USER} не найден"
|
||||
fi
|
||||
|
||||
# Важно: nginx должен пройти по /opt/botfactory и читать frontend/dist.
|
||||
chmod 755 /opt || true
|
||||
chmod 755 "$APP_DIR"
|
||||
chmod 755 "${APP_DIR}/frontend" 2>/dev/null || true
|
||||
chmod 755 "${APP_DIR}/frontend/dist" 2>/dev/null || true
|
||||
find "${APP_DIR}/frontend/dist" -type d -exec chmod 755 {} \; 2>/dev/null || true
|
||||
find "${APP_DIR}/frontend/dist" -type f -exec chmod 644 {} \; 2>/dev/null || true
|
||||
chmod 755 "${APP_DIR}/backend" 2>/dev/null || true
|
||||
find "${APP_DIR}/backend" -type d -exec chmod 755 {} \; 2>/dev/null || true
|
||||
find "${APP_DIR}/backend" -type f -name '*.py' -exec chmod 644 {} \; 2>/dev/null || true
|
||||
chmod 770 "${APP_DIR}/logs" "${APP_DIR}/uploads" "${APP_DIR}/backups" 2>/dev/null || true
|
||||
[[ -f "${APP_DIR}/.env" ]] && chmod 600 "${APP_DIR}/.env"
|
||||
ok "Права исправлены"
|
||||
mkdir -p "${APP_DIR}/logs" "${APP_DIR}/uploads" "${APP_DIR}/backups" "$BACK_DIR" "$FRONT_DIR"
|
||||
|
||||
if [[ -d "$SRC_DIR" ]]; then
|
||||
if [[ -d "$SRC_DIR/backend" ]]; then
|
||||
rsync -a --delete "${SRC_DIR}/backend/" "${APP_DIR}/backend/"
|
||||
rsync -a --delete "${SRC_DIR}/backend/" "${BACK_DIR}/"
|
||||
ok "Backend синхронизирован из ${SRC_DIR}"
|
||||
fi
|
||||
if [[ -d "$SRC_DIR/frontend" ]]; then
|
||||
rsync -a --delete "${SRC_DIR}/frontend/" "${APP_DIR}/frontend/"
|
||||
rsync -a --delete "${SRC_DIR}/frontend/" "${FRONT_DIR}/"
|
||||
ok "Frontend синхронизирован из ${SRC_DIR}"
|
||||
fi
|
||||
fi
|
||||
|
||||
if [[ -f "${APP_DIR}/backend/requirements.txt" && -x "${APP_DIR}/venv/bin/pip" ]]; then
|
||||
"${APP_DIR}/venv/bin/pip" install -q -r "${APP_DIR}/backend/requirements.txt"
|
||||
if ! id "$APP_USER" >/dev/null 2>&1; then
|
||||
useradd --system --home "$APP_DIR" --shell /usr/sbin/nologin "$APP_USER"
|
||||
fi
|
||||
|
||||
chown -R "${APP_USER}:${APP_USER}" "$APP_DIR"
|
||||
chmod 755 /opt || true
|
||||
chmod 755 "$APP_DIR" "$BACK_DIR" "$FRONT_DIR" 2>/dev/null || true
|
||||
find "$BACK_DIR" -type d -exec chmod 755 {} \; 2>/dev/null || true
|
||||
find "$BACK_DIR" -type f -name '*.py' -exec chmod 644 {} \; 2>/dev/null || true
|
||||
chmod 770 "${APP_DIR}/logs" "${APP_DIR}/uploads" "${APP_DIR}/backups" 2>/dev/null || true
|
||||
[[ -f "${APP_DIR}/.env" ]] && { chown "${APP_USER}:${APP_USER}" "${APP_DIR}/.env"; chmod 600 "${APP_DIR}/.env"; }
|
||||
ok "Права исправлены"
|
||||
|
||||
if [[ -f "${BACK_DIR}/requirements.txt" && -x "${VENV}/bin/pip" ]]; then
|
||||
sudo -u "$APP_USER" "${VENV}/bin/pip" install -q -r "${BACK_DIR}/requirements.txt"
|
||||
ok "Python-зависимости проверены"
|
||||
fi
|
||||
|
||||
if [[ -f "${APP_DIR}/frontend/package.json" ]]; then
|
||||
npm install --prefix "${APP_DIR}/frontend" --silent --no-fund --no-audit
|
||||
npm run build --prefix "${APP_DIR}/frontend" --silent
|
||||
[[ -f "${APP_DIR}/frontend/dist/index.html" ]] || { err "Frontend build failed: нет dist/index.html"; exit 1; }
|
||||
find "${APP_DIR}/frontend/dist" -type d -exec chmod 755 {} \;
|
||||
find "${APP_DIR}/frontend/dist" -type f -exec chmod 644 {} \;
|
||||
if [[ -f "${FRONT_DIR}/package.json" ]]; then
|
||||
npm install --prefix "${FRONT_DIR}" --silent --no-fund --no-audit
|
||||
npm run build --prefix "${FRONT_DIR}" --silent
|
||||
[[ -f "${FRONT_DIR}/dist/index.html" ]] || { err "Frontend build failed: нет dist/index.html"; exit 1; }
|
||||
chown -R "${APP_USER}:${APP_USER}" "${FRONT_DIR}"
|
||||
chmod 755 "${FRONT_DIR}" "${FRONT_DIR}/dist"
|
||||
find "${FRONT_DIR}/dist" -type d -exec chmod 755 {} \;
|
||||
find "${FRONT_DIR}/dist" -type f -exec chmod 644 {} \;
|
||||
ok "Frontend пересобран"
|
||||
fi
|
||||
|
||||
cat > /etc/systemd/system/botfactory-api.service <<SERVICE
|
||||
[Unit]
|
||||
Description=BotFactory API (FastAPI)
|
||||
After=network.target postgresql.service redis-server.service
|
||||
Requires=postgresql.service redis-server.service
|
||||
StartLimitIntervalSec=60
|
||||
StartLimitBurst=5
|
||||
|
||||
[Service]
|
||||
Type=exec
|
||||
User=${APP_USER}
|
||||
Group=${APP_USER}
|
||||
WorkingDirectory=${BACK_DIR}
|
||||
Environment=PYTHONPATH=${BACK_DIR}
|
||||
EnvironmentFile=${APP_DIR}/.env
|
||||
ExecStart=${VENV}/bin/uvicorn main:app --host 127.0.0.1 --port ${API_PORT} --workers 1 --loop uvloop --log-level info
|
||||
Restart=always
|
||||
RestartSec=5
|
||||
TimeoutStopSec=20
|
||||
KillMode=mixed
|
||||
StandardOutput=append:${APP_DIR}/logs/api.log
|
||||
StandardError=append:${APP_DIR}/logs/api-error.log
|
||||
|
||||
[Install]
|
||||
WantedBy=multi-user.target
|
||||
SERVICE
|
||||
|
||||
cat > /etc/systemd/system/botfactory-bots.service <<SERVICE
|
||||
[Unit]
|
||||
Description=BotFactory Bots Runner (platform + shops)
|
||||
After=network-online.target postgresql.service redis-server.service
|
||||
Wants=network-online.target postgresql.service redis-server.service
|
||||
StartLimitIntervalSec=60
|
||||
StartLimitBurst=5
|
||||
|
||||
[Service]
|
||||
Type=exec
|
||||
User=${APP_USER}
|
||||
Group=${APP_USER}
|
||||
WorkingDirectory=${BACK_DIR}
|
||||
Environment=PYTHONPATH=${BACK_DIR}
|
||||
EnvironmentFile=${APP_DIR}/.env
|
||||
ExecStart=${VENV}/bin/python shop_bots_runner.py
|
||||
Restart=always
|
||||
RestartSec=10
|
||||
TimeoutStopSec=20
|
||||
KillMode=mixed
|
||||
StandardOutput=append:${APP_DIR}/logs/bots.log
|
||||
StandardError=append:${APP_DIR}/logs/bots-error.log
|
||||
|
||||
[Install]
|
||||
WantedBy=multi-user.target
|
||||
SERVICE
|
||||
ok "systemd service-файлы обновлены"
|
||||
|
||||
if [[ -f /etc/nginx/sites-available/botfactory ]]; then
|
||||
nginx -t
|
||||
systemctl reload nginx || systemctl restart nginx
|
||||
@@ -71,6 +123,7 @@ else
|
||||
fi
|
||||
|
||||
systemctl daemon-reload
|
||||
systemctl enable botfactory-api botfactory-bots >/dev/null 2>&1 || true
|
||||
systemctl restart botfactory-api || true
|
||||
sleep 2
|
||||
systemctl restart botfactory-bots || true
|
||||
@@ -87,8 +140,6 @@ echo
|
||||
curl -sS -I "http://127.0.0.1/" || true
|
||||
|
||||
echo
|
||||
warn "Если бот не стартует, сразу пришлите вывод команд:"
|
||||
echo "journalctl -u botfactory-bots -n 120 --no-pager"
|
||||
echo "journalctl -u botfactory-api -n 120 --no-pager"
|
||||
echo "tail -n 120 ${APP_DIR}/logs/bots-error.log"
|
||||
echo "tail -n 120 ${APP_DIR}/logs/api-error.log"
|
||||
warn "Если бот молчит, пришлите вывод:"
|
||||
echo "journalctl -u botfactory-bots -n 160 --no-pager"
|
||||
echo "tail -n 160 ${APP_DIR}/logs/bots-error.log"
|
||||
|
||||
Ссылка в новой задаче
Block a user