Удалены демо данные и подключен рабочий API BotFactory v2.2
Этот коммит содержится в:
+22
-1
@@ -1,4 +1,4 @@
|
|||||||
# BotFactory v2.1
|
# BotFactory v2.2
|
||||||
|
|
||||||
SaaS-платформа для Telegram-магазинов: платформенный админ-бот, боты управления магазинами, клиентские боты, FastAPI backend и React/Vite frontend.
|
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.
|
Не публикуйте реальные 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
|
import logging
|
||||||
from contextlib import asynccontextmanager
|
from contextlib import asynccontextmanager
|
||||||
|
from datetime import datetime, timedelta
|
||||||
from pathlib import Path
|
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.middleware.cors import CORSMiddleware
|
||||||
from fastapi.staticfiles import StaticFiles
|
from fastapi.staticfiles import StaticFiles
|
||||||
|
from pydantic import BaseModel, Field
|
||||||
|
from sqlalchemy import select, func, desc
|
||||||
|
|
||||||
from config import settings
|
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(
|
logging.basicConfig(
|
||||||
level=logging.DEBUG if settings.DEBUG else logging.INFO,
|
level=logging.DEBUG if settings.DEBUG else logging.INFO,
|
||||||
format="%(asctime)s [%(levelname)s] %(name)s: %(message)s",
|
format="%(asctime)s [%(levelname)s] %(name)s: %(message)s",
|
||||||
)
|
)
|
||||||
logger = logging.getLogger("botfactory")
|
logger = logging.getLogger("botfactory")
|
||||||
|
APP_VERSION = "2.2.0"
|
||||||
|
|
||||||
|
|
||||||
@asynccontextmanager
|
@asynccontextmanager
|
||||||
@@ -27,7 +37,7 @@ async def lifespan(app: FastAPI):
|
|||||||
|
|
||||||
app = FastAPI(
|
app = FastAPI(
|
||||||
title="BotFactory API",
|
title="BotFactory API",
|
||||||
version="2.1.0",
|
version=APP_VERSION,
|
||||||
docs_url="/api/docs" if settings.DEBUG else None,
|
docs_url="/api/docs" if settings.DEBUG else None,
|
||||||
redoc_url=None,
|
redoc_url=None,
|
||||||
lifespan=lifespan,
|
lifespan=lifespan,
|
||||||
@@ -35,7 +45,7 @@ app = FastAPI(
|
|||||||
|
|
||||||
app.add_middleware(
|
app.add_middleware(
|
||||||
CORSMiddleware,
|
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_credentials=True,
|
||||||
allow_methods=["*"],
|
allow_methods=["*"],
|
||||||
allow_headers=["*"],
|
allow_headers=["*"],
|
||||||
@@ -46,26 +56,452 @@ upload_path.mkdir(parents=True, exist_ok=True)
|
|||||||
app.mount("/uploads", StaticFiles(directory=str(upload_path)), name="uploads")
|
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"])
|
@app.get("/api/health", tags=["System"])
|
||||||
async def health():
|
async def health():
|
||||||
return {"status": "ok", "version": "2.1.0"}
|
return {"status": "ok", "version": APP_VERSION}
|
||||||
|
|
||||||
|
|
||||||
@app.get("/api/version", tags=["System"])
|
@app.get("/api/version", tags=["System"])
|
||||||
async def version():
|
async def version():
|
||||||
return {
|
return {"version": APP_VERSION, "debug": settings.DEBUG, "domain": settings.DOMAIN or None}
|
||||||
"version": "2.1.0",
|
|
||||||
"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__":
|
if __name__ == "__main__":
|
||||||
import uvicorn
|
import uvicorn
|
||||||
uvicorn.run(
|
uvicorn.run("main:app", host=settings.API_HOST, port=settings.API_PORT, reload=settings.DEBUG, loop="uvloop")
|
||||||
"main:app",
|
|
||||||
host=settings.API_HOST,
|
|
||||||
port=settings.API_PORT,
|
|
||||||
reload=settings.DEBUG,
|
|
||||||
loop="uvloop",
|
|
||||||
)
|
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
"""BotFactory — Platform Admin Bot (системный бот платформы)"""
|
"""BotFactory — Platform Admin Bot (системный бот платформы)"""
|
||||||
import asyncio, logging
|
import asyncio, logging, inspect
|
||||||
|
from functools import wraps
|
||||||
from datetime import datetime, timedelta
|
from datetime import datetime, timedelta
|
||||||
from aiogram import Bot, Dispatcher, F, Router
|
from aiogram import Bot, Dispatcher, F, Router
|
||||||
from aiogram.filters import Command
|
from aiogram.filters import Command
|
||||||
@@ -25,13 +26,20 @@ def is_admin(uid: int) -> bool:
|
|||||||
return uid in settings.admin_ids
|
return uid in settings.admin_ids
|
||||||
|
|
||||||
def guard(fn):
|
def guard(fn):
|
||||||
|
sig = inspect.signature(fn)
|
||||||
|
accepted = set(sig.parameters.keys())
|
||||||
|
|
||||||
|
@wraps(fn)
|
||||||
async def w(event, *a, **kw):
|
async def w(event, *a, **kw):
|
||||||
uid = event.from_user.id
|
uid = event.from_user.id
|
||||||
if not is_admin(uid):
|
if not is_admin(uid):
|
||||||
t = event.answer if isinstance(event, Message) else event.answer
|
t = event.answer if isinstance(event, Message) else event.answer
|
||||||
await t("⛔ Доступ запрещён.", show_alert=True) if isinstance(event, CallbackQuery) else await t("⛔ Доступ запрещён.")
|
await t("⛔ Доступ запрещён.", show_alert=True) if isinstance(event, CallbackQuery) else await t("⛔ Доступ запрещён.")
|
||||||
return
|
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
|
return w
|
||||||
|
|
||||||
|
|
||||||
@@ -686,6 +694,16 @@ async def start_platform_bot():
|
|||||||
storage = RedisStorage.from_url(settings.REDIS_URL)
|
storage = RedisStorage.from_url(settings.REDIS_URL)
|
||||||
bot = Bot(token=settings.PLATFORM_BOT_TOKEN)
|
bot = Bot(token=settings.PLATFORM_BOT_TOKEN)
|
||||||
dp = Dispatcher(storage=storage)
|
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)
|
dp.include_router(router)
|
||||||
logger.info("Platform admin bot started")
|
logger.info("Platform admin bot started")
|
||||||
try:
|
try:
|
||||||
|
|||||||
Generated
+2018
Разница между файлами не показана из-за своего большого размера
Загрузить разницу
+187
-1251
Разница между файлами не показана из-за своего большого размера
Загрузить разницу
@@ -1,9 +1,5 @@
|
|||||||
import React from 'react'
|
import React from "react";
|
||||||
import ReactDOM from 'react-dom/client'
|
import { createRoot } from "react-dom/client";
|
||||||
import App from './App.jsx'
|
import App from "./App.jsx";
|
||||||
|
|
||||||
ReactDOM.createRoot(document.getElementById('root')).render(
|
createRoot(document.getElementById("root")).render(<App />);
|
||||||
<React.StrictMode>
|
|
||||||
<App />
|
|
||||||
</React.StrictMode>
|
|
||||||
)
|
|
||||||
|
|||||||
@@ -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)
|
Description=BotFactory API (FastAPI)
|
||||||
After=network.target postgresql.service redis-server.service
|
After=network.target postgresql.service redis-server.service
|
||||||
Requires=postgresql.service redis-server.service
|
Requires=postgresql.service redis-server.service
|
||||||
|
StartLimitIntervalSec=60
|
||||||
|
StartLimitBurst=5
|
||||||
|
|
||||||
[Service]
|
[Service]
|
||||||
Type=exec
|
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
|
ExecStart=${VENV}/bin/uvicorn main:app --host 127.0.0.1 --port ${API_PORT} --workers 1 --loop uvloop --log-level info
|
||||||
Restart=always
|
Restart=always
|
||||||
RestartSec=5
|
RestartSec=5
|
||||||
StartLimitIntervalSec=60
|
TimeoutStopSec=20
|
||||||
StartLimitBurst=5
|
KillMode=mixed
|
||||||
StandardOutput=append:${APP_DIR}/logs/api.log
|
StandardOutput=append:${APP_DIR}/logs/api.log
|
||||||
StandardError=append:${APP_DIR}/logs/api-error.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)
|
Description=BotFactory Bots Runner (platform + shops)
|
||||||
After=network-online.target postgresql.service redis-server.service
|
After=network-online.target postgresql.service redis-server.service
|
||||||
Wants=network-online.target postgresql.service redis-server.service
|
Wants=network-online.target postgresql.service redis-server.service
|
||||||
|
StartLimitIntervalSec=60
|
||||||
|
StartLimitBurst=5
|
||||||
|
|
||||||
[Service]
|
[Service]
|
||||||
Type=exec
|
Type=exec
|
||||||
@@ -430,8 +434,8 @@ EnvironmentFile=${APP_DIR}/.env
|
|||||||
ExecStart=${VENV}/bin/python shop_bots_runner.py
|
ExecStart=${VENV}/bin/python shop_bots_runner.py
|
||||||
Restart=always
|
Restart=always
|
||||||
RestartSec=10
|
RestartSec=10
|
||||||
StartLimitIntervalSec=60
|
TimeoutStopSec=20
|
||||||
StartLimitBurst=5
|
KillMode=mixed
|
||||||
StandardOutput=append:${APP_DIR}/logs/bots.log
|
StandardOutput=append:${APP_DIR}/logs/bots.log
|
||||||
StandardError=append:${APP_DIR}/logs/bots-error.log
|
StandardError=append:${APP_DIR}/logs/bots-error.log
|
||||||
|
|
||||||
|
|||||||
@@ -1,11 +1,13 @@
|
|||||||
#!/usr/bin/env bash
|
#!/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
|
set -euo pipefail
|
||||||
|
|
||||||
APP_DIR="/opt/botfactory"
|
APP_DIR="/opt/botfactory"
|
||||||
SRC_DIR="/opt/botfactory-src"
|
SRC_DIR="/opt/botfactory-src"
|
||||||
APP_USER="botfactory"
|
APP_USER="botfactory"
|
||||||
FRONT_DIR="${APP_DIR}/frontend"
|
FRONT_DIR="${APP_DIR}/frontend"
|
||||||
|
BACK_DIR="${APP_DIR}/backend"
|
||||||
|
VENV="${APP_DIR}/venv"
|
||||||
API_PORT="8000"
|
API_PORT="8000"
|
||||||
|
|
||||||
ok(){ echo -e "\033[0;32m[✓]\033[0m $*"; }
|
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; }
|
[[ $EUID -eq 0 ]] || { err "Запустите от root: sudo bash scripts/repair_server.sh"; exit 1; }
|
||||||
[[ -d "$APP_DIR" ]] || { err "Нет ${APP_DIR}. Сначала установите BotFactory."; exit 1; }
|
[[ -d "$APP_DIR" ]] || { err "Нет ${APP_DIR}. Сначала установите BotFactory."; exit 1; }
|
||||||
|
|
||||||
mkdir -p "${APP_DIR}/logs" "${APP_DIR}/uploads" "${APP_DIR}/backups"
|
mkdir -p "${APP_DIR}/logs" "${APP_DIR}/uploads" "${APP_DIR}/backups" "$BACK_DIR" "$FRONT_DIR"
|
||||||
|
|
||||||
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 "Права исправлены"
|
|
||||||
|
|
||||||
if [[ -d "$SRC_DIR" ]]; then
|
if [[ -d "$SRC_DIR" ]]; then
|
||||||
if [[ -d "$SRC_DIR/backend" ]]; 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}"
|
ok "Backend синхронизирован из ${SRC_DIR}"
|
||||||
fi
|
fi
|
||||||
if [[ -d "$SRC_DIR/frontend" ]]; then
|
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}"
|
ok "Frontend синхронизирован из ${SRC_DIR}"
|
||||||
fi
|
fi
|
||||||
fi
|
fi
|
||||||
|
|
||||||
if [[ -f "${APP_DIR}/backend/requirements.txt" && -x "${APP_DIR}/venv/bin/pip" ]]; then
|
if ! id "$APP_USER" >/dev/null 2>&1; then
|
||||||
"${APP_DIR}/venv/bin/pip" install -q -r "${APP_DIR}/backend/requirements.txt"
|
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-зависимости проверены"
|
ok "Python-зависимости проверены"
|
||||||
fi
|
fi
|
||||||
|
|
||||||
if [[ -f "${APP_DIR}/frontend/package.json" ]]; then
|
if [[ -f "${FRONT_DIR}/package.json" ]]; then
|
||||||
npm install --prefix "${APP_DIR}/frontend" --silent --no-fund --no-audit
|
npm install --prefix "${FRONT_DIR}" --silent --no-fund --no-audit
|
||||||
npm run build --prefix "${APP_DIR}/frontend" --silent
|
npm run build --prefix "${FRONT_DIR}" --silent
|
||||||
[[ -f "${APP_DIR}/frontend/dist/index.html" ]] || { err "Frontend build failed: нет dist/index.html"; exit 1; }
|
[[ -f "${FRONT_DIR}/dist/index.html" ]] || { err "Frontend build failed: нет dist/index.html"; exit 1; }
|
||||||
find "${APP_DIR}/frontend/dist" -type d -exec chmod 755 {} \;
|
chown -R "${APP_USER}:${APP_USER}" "${FRONT_DIR}"
|
||||||
find "${APP_DIR}/frontend/dist" -type f -exec chmod 644 {} \;
|
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 пересобран"
|
ok "Frontend пересобран"
|
||||||
fi
|
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
|
if [[ -f /etc/nginx/sites-available/botfactory ]]; then
|
||||||
nginx -t
|
nginx -t
|
||||||
systemctl reload nginx || systemctl restart nginx
|
systemctl reload nginx || systemctl restart nginx
|
||||||
@@ -71,6 +123,7 @@ else
|
|||||||
fi
|
fi
|
||||||
|
|
||||||
systemctl daemon-reload
|
systemctl daemon-reload
|
||||||
|
systemctl enable botfactory-api botfactory-bots >/dev/null 2>&1 || true
|
||||||
systemctl restart botfactory-api || true
|
systemctl restart botfactory-api || true
|
||||||
sleep 2
|
sleep 2
|
||||||
systemctl restart botfactory-bots || true
|
systemctl restart botfactory-bots || true
|
||||||
@@ -87,8 +140,6 @@ echo
|
|||||||
curl -sS -I "http://127.0.0.1/" || true
|
curl -sS -I "http://127.0.0.1/" || true
|
||||||
|
|
||||||
echo
|
echo
|
||||||
warn "Если бот не стартует, сразу пришлите вывод команд:"
|
warn "Если бот молчит, пришлите вывод:"
|
||||||
echo "journalctl -u botfactory-bots -n 120 --no-pager"
|
echo "journalctl -u botfactory-bots -n 160 --no-pager"
|
||||||
echo "journalctl -u botfactory-api -n 120 --no-pager"
|
echo "tail -n 160 ${APP_DIR}/logs/bots-error.log"
|
||||||
echo "tail -n 120 ${APP_DIR}/logs/bots-error.log"
|
|
||||||
echo "tail -n 120 ${APP_DIR}/logs/api-error.log"
|
|
||||||
|
|||||||
Ссылка в новой задаче
Block a user