Удалены демо данные и подключен рабочий API BotFactory v2.2

Этот коммит содержится в:
2026-05-25 03:08:22 +09:00
родитель 33f4eabe80
Коммит 07d4314f1d
10 изменённых файлов: 2802 добавлений и 1321 удалений
+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")
+20 -2
Просмотреть файл
@@ -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:
Разница между файлами не показана из-за своего большого размера Загрузить разницу
Разница между файлами не показана из-за своего большого размера Загрузить разницу
+4 -8
Просмотреть файл
@@ -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 />);
+1
Просмотреть файл
@@ -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
+88 -37
Просмотреть файл
@@ -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"