Исправлен установщик и добавлены тарифы BotFactory v2.1
Этот коммит содержится в:
@@ -0,0 +1,12 @@
|
||||
.env
|
||||
*.log
|
||||
__pycache__/
|
||||
*.pyc
|
||||
.venv/
|
||||
venv/
|
||||
node_modules/
|
||||
dist/
|
||||
.DS_Store
|
||||
uploads/
|
||||
backups/
|
||||
install-info.txt
|
||||
@@ -0,0 +1 @@
|
||||
2.1.0
|
||||
@@ -0,0 +1,103 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from datetime import datetime, timedelta
|
||||
from calendar import monthrange
|
||||
from sqlalchemy import func, select
|
||||
|
||||
from config import settings
|
||||
from models import Tenant, PlanEnum, Order, OrderStatus, Shop
|
||||
|
||||
|
||||
PLAN_TITLES = {
|
||||
"trial_week": "Тест 7 дней",
|
||||
"trial": "Trial",
|
||||
"basic": "Basic",
|
||||
"pro": "Pro",
|
||||
"enterprise": "Enterprise",
|
||||
"postpaid_custom": "Индивидуальный постоплатный",
|
||||
}
|
||||
|
||||
|
||||
def plan_value(plan: PlanEnum | str | None) -> str:
|
||||
if plan is None:
|
||||
return "basic"
|
||||
return getattr(plan, "value", str(plan))
|
||||
|
||||
|
||||
def plan_title(plan: PlanEnum | str | None) -> str:
|
||||
return PLAN_TITLES.get(plan_value(plan), plan_value(plan))
|
||||
|
||||
|
||||
def is_postpaid(tenant: Tenant | None) -> bool:
|
||||
return bool(tenant and plan_value(tenant.plan) == PlanEnum.postpaid_custom.value)
|
||||
|
||||
|
||||
def is_trial_week_expired(tenant: Tenant | None) -> bool:
|
||||
return bool(
|
||||
tenant
|
||||
and plan_value(tenant.plan) == PlanEnum.trial_week.value
|
||||
and tenant.trial_ends_at
|
||||
and tenant.trial_ends_at < datetime.utcnow()
|
||||
)
|
||||
|
||||
|
||||
def tenant_can_sell(tenant: Tenant | None) -> tuple[bool, str]:
|
||||
if not tenant:
|
||||
return False, "Владелец магазина не найден."
|
||||
if not tenant.is_active or tenant.is_blocked:
|
||||
return False, "Магазин временно приостановлен."
|
||||
if is_trial_week_expired(tenant):
|
||||
return False, "Тестовый период закончился. Обратитесь к администрации."
|
||||
if not is_postpaid(tenant) and tenant.balance <= 0:
|
||||
return False, "Баланс BotFactory исчерпан. Магазин временно приостановлен."
|
||||
return True, ""
|
||||
|
||||
|
||||
def commission_rate(tenant: Tenant | None) -> float:
|
||||
if not tenant:
|
||||
return float(settings.COMMISSION_BASIC)
|
||||
if is_postpaid(tenant):
|
||||
return float(tenant.postpaid_commission_percent or settings.COMMISSION_POSTPAID_DEFAULT)
|
||||
return float(settings.commission.get(plan_value(tenant.plan), settings.COMMISSION_BASIC))
|
||||
|
||||
|
||||
def previous_month_range(now: datetime | None = None) -> tuple[datetime, datetime]:
|
||||
now = now or datetime.utcnow()
|
||||
first_this_month = datetime(now.year, now.month, 1)
|
||||
last_prev_day = first_this_month - timedelta(days=1)
|
||||
start = datetime(last_prev_day.year, last_prev_day.month, 1)
|
||||
end = first_this_month
|
||||
return start, end
|
||||
|
||||
|
||||
def current_due_date(tenant: Tenant, now: datetime | None = None) -> datetime:
|
||||
now = now or datetime.utcnow()
|
||||
day = int(tenant.postpaid_due_day or settings.POSTPAID_DEFAULT_DUE_DAY or 5)
|
||||
day = max(1, min(28, day))
|
||||
last_day = monthrange(now.year, now.month)[1]
|
||||
return datetime(now.year, now.month, min(day, last_day), 23, 59, 59)
|
||||
|
||||
|
||||
async def postpaid_previous_month_totals(db, tenant_id: int) -> tuple[float, float]:
|
||||
start, end = previous_month_range()
|
||||
revenue = (await db.execute(
|
||||
select(func.sum(Order.amount))
|
||||
.join(Shop, Order.shop_id == Shop.id)
|
||||
.where(
|
||||
Shop.tenant_id == tenant_id,
|
||||
Order.status == OrderStatus.completed,
|
||||
Order.created_at >= start,
|
||||
Order.created_at < end,
|
||||
)
|
||||
)).scalar() or 0
|
||||
commission = (await db.execute(
|
||||
select(func.sum(Order.commission))
|
||||
.join(Shop, Order.shop_id == Shop.id)
|
||||
.where(
|
||||
Shop.tenant_id == tenant_id,
|
||||
Order.status == OrderStatus.completed,
|
||||
Order.created_at >= start,
|
||||
Order.created_at < end,
|
||||
)
|
||||
)).scalar() or 0
|
||||
return float(revenue), float(commission)
|
||||
@@ -0,0 +1,48 @@
|
||||
from pydantic_settings import BaseSettings
|
||||
from typing import List
|
||||
|
||||
|
||||
class Settings(BaseSettings):
|
||||
DEBUG: bool = False
|
||||
SECRET_KEY: str = "change-me"
|
||||
DATABASE_URL: str = ""
|
||||
DB_POOL_SIZE: int = 20
|
||||
REDIS_URL: str = "redis://127.0.0.1:6379/0"
|
||||
PLATFORM_BOT_TOKEN: str = ""
|
||||
PLATFORM_ADMIN_IDS: str = ""
|
||||
API_HOST: str = "127.0.0.1"
|
||||
API_PORT: int = 8000
|
||||
DOMAIN: str = ""
|
||||
ALLOWED_ORIGINS: str = "http://localhost:3000"
|
||||
UPLOAD_DIR: str = "/opt/botfactory/uploads"
|
||||
MAX_UPLOAD_MB: int = 10
|
||||
COMMISSION_TRIAL_WEEK: int = 10
|
||||
COMMISSION_TRIAL: int = 10
|
||||
COMMISSION_BASIC: int = 7
|
||||
COMMISSION_PRO: int = 5
|
||||
COMMISSION_ENTERPRISE: int = 3
|
||||
COMMISSION_POSTPAID_DEFAULT: int = 5
|
||||
POSTPAID_DEFAULT_DUE_DAY: int = 5
|
||||
|
||||
@property
|
||||
def admin_ids(self) -> List[int]:
|
||||
return [int(i.strip()) for i in self.PLATFORM_ADMIN_IDS.split(",")
|
||||
if i.strip().isdigit()]
|
||||
|
||||
@property
|
||||
def commission(self) -> dict:
|
||||
return {
|
||||
"trial_week": self.COMMISSION_TRIAL_WEEK,
|
||||
"trial": self.COMMISSION_TRIAL,
|
||||
"basic": self.COMMISSION_BASIC,
|
||||
"pro": self.COMMISSION_PRO,
|
||||
"enterprise": self.COMMISSION_ENTERPRISE,
|
||||
"postpaid_custom": self.COMMISSION_POSTPAID_DEFAULT,
|
||||
}
|
||||
|
||||
class Config:
|
||||
env_file = "/opt/botfactory/.env"
|
||||
extra = "ignore"
|
||||
|
||||
|
||||
settings = Settings()
|
||||
@@ -0,0 +1,613 @@
|
||||
"""BotFactory — Shop Control Bot (бот управления магазином)"""
|
||||
import asyncio, logging, random
|
||||
from datetime import datetime
|
||||
from typing import Optional
|
||||
from aiogram import Bot, Dispatcher, F, Router
|
||||
from aiogram.filters import Command
|
||||
from aiogram.fsm.context import FSMContext
|
||||
from aiogram.fsm.state import State, StatesGroup
|
||||
from aiogram.fsm.storage.redis import RedisStorage
|
||||
from aiogram.types import (Message, CallbackQuery, InlineKeyboardButton,
|
||||
KeyboardButton, ReplyKeyboardMarkup, ReplyKeyboardRemove)
|
||||
from aiogram.utils.keyboard import InlineKeyboardBuilder, ReplyKeyboardBuilder
|
||||
from sqlalchemy import select, func, desc
|
||||
from config import settings
|
||||
from database import AsyncSessionLocal
|
||||
from models import Shop, ShopMember, ShopToken, Product, PaymentCard, Order, OrderStatus, BalanceTransaction, Tenant
|
||||
from billing import is_postpaid, tenant_can_sell
|
||||
|
||||
logger = logging.getLogger("ctrl_bot")
|
||||
|
||||
|
||||
def fmt(v: float) -> str:
|
||||
return f"{v:,.0f}".replace(",", " ") + " ₽"
|
||||
|
||||
def dt(d: datetime) -> str:
|
||||
return d.strftime("%d.%m.%Y %H:%M")
|
||||
|
||||
|
||||
class CS(StatesGroup):
|
||||
prod_name = State(); prod_price = State(); prod_cat = State()
|
||||
prod_desc = State(); prod_content = State()
|
||||
card_bank = State(); card_num = State(); card_holder = State(); card_phone = State()
|
||||
mbr_id = State(); mbr_name = State()
|
||||
tok_val = State(); tok_user = State(); tok_note = State()
|
||||
|
||||
|
||||
async def get_role(shop_id: int, tg_id: int) -> Optional[str]:
|
||||
async with AsyncSessionLocal() as db:
|
||||
shop = await db.get(Shop, shop_id)
|
||||
if not shop: return None
|
||||
tenant = await db.get(Tenant, shop.tenant_id)
|
||||
if tenant and tenant.telegram_id == tg_id:
|
||||
return "admin"
|
||||
m = (await db.execute(
|
||||
select(ShopMember).where(ShopMember.shop_id==shop_id, ShopMember.telegram_id==tg_id)
|
||||
)).scalar_one_or_none()
|
||||
return m.role if m else None
|
||||
|
||||
|
||||
def admin_kb():
|
||||
b = ReplyKeyboardBuilder()
|
||||
b.row(KeyboardButton(text="📊 Статистика"), KeyboardButton(text="📦 Заказы"))
|
||||
b.row(KeyboardButton(text="🛍 Товары"), KeyboardButton(text="💳 Карты"))
|
||||
b.row(KeyboardButton(text="👥 Команда"), KeyboardButton(text="⚙️ Токены"))
|
||||
b.row(KeyboardButton(text="💰 Баланс"))
|
||||
return b.as_markup(resize_keyboard=True)
|
||||
|
||||
def cashier_kb():
|
||||
b = ReplyKeyboardBuilder()
|
||||
b.row(KeyboardButton(text="📦 Заказы на проверке"))
|
||||
b.row(KeyboardButton(text="📋 История платежей"))
|
||||
return b.as_markup(resize_keyboard=True)
|
||||
|
||||
def ib(*rows):
|
||||
bld = InlineKeyboardBuilder()
|
||||
for row in rows:
|
||||
if isinstance(row, list):
|
||||
bld.row(*[InlineKeyboardButton(text=t, callback_data=d) for t, d in row])
|
||||
else:
|
||||
bld.add(InlineKeyboardButton(text=row[0], callback_data=row[1]))
|
||||
return bld.as_markup()
|
||||
|
||||
|
||||
class ShopControlBot:
|
||||
def __init__(self, shop_id: int, token: str, shop_name: str):
|
||||
self.shop_id = shop_id
|
||||
self.token = token
|
||||
self.name = shop_name
|
||||
self.router = Router()
|
||||
self._reg()
|
||||
|
||||
def _reg(self):
|
||||
r = self.router; sid = self.shop_id
|
||||
|
||||
async def role(event) -> Optional[str]:
|
||||
uid = event.from_user.id
|
||||
r = await get_role(sid, uid)
|
||||
if not r:
|
||||
deny = "⛔ Вы не в команде магазина. Обратитесь к владельцу."
|
||||
if isinstance(event, Message): await event.answer(deny)
|
||||
else: await event.answer(deny, show_alert=True)
|
||||
return r
|
||||
|
||||
# /start
|
||||
@r.message(Command("start"))
|
||||
async def start(msg: Message, state: FSMContext):
|
||||
await state.clear()
|
||||
rl = await role(msg)
|
||||
if not rl: return
|
||||
await msg.answer(
|
||||
f"🏪 <b>{self.name}</b>\n{'Администратор' if rl=='admin' else 'Кассир'}",
|
||||
parse_mode="HTML",
|
||||
reply_markup=admin_kb() if rl=="admin" else cashier_kb())
|
||||
|
||||
# ── СТАТИСТИКА ────────────────────────────────────────────────────────
|
||||
@r.message(F.text=="📊 Статистика")
|
||||
async def stats(msg: Message):
|
||||
if await role(msg) != "admin": return
|
||||
async with AsyncSessionLocal() as db:
|
||||
rev = (await db.execute(select(func.sum(Order.amount)).where(Order.shop_id==sid, Order.status==OrderStatus.completed))).scalar() or 0
|
||||
comm = (await db.execute(select(func.sum(Order.commission)).where(Order.shop_id==sid, Order.status==OrderStatus.completed))).scalar() or 0
|
||||
ordn = (await db.execute(select(func.count(Order.id)).where(Order.shop_id==sid, Order.status==OrderStatus.completed))).scalar()
|
||||
pend = (await db.execute(select(func.count(Order.id)).where(Order.shop_id==sid, Order.status.in_([OrderStatus.pending,OrderStatus.confirming])))).scalar()
|
||||
prods= (await db.execute(select(func.count(Product.id)).where(Product.shop_id==sid, Product.is_active==True))).scalar()
|
||||
cards= (await db.execute(select(func.count(PaymentCard.id)).where(PaymentCard.shop_id==sid, PaymentCard.is_active==True))).scalar()
|
||||
shop = await db.get(Shop, sid)
|
||||
tenant = await db.get(Tenant, shop.tenant_id)
|
||||
be = "🔴" if tenant.balance<=0 else ("🟡" if tenant.balance<=tenant.alert_threshold else "🟢")
|
||||
await msg.answer(
|
||||
f"📊 <b>{self.name}</b> · {datetime.now():%d.%m.%Y %H:%M}\n{'─'*26}\n\n"
|
||||
f"📦 Заказов: <b>{ordn}</b> | ⏳ Ждут: <b>{pend}</b>\n"
|
||||
f"💰 Оборот: <b>{fmt(rev)}</b>\n"
|
||||
f"📊 Комиссия: <b>{fmt(comm)}</b>\n"
|
||||
f"💵 Мой доход: <b>{fmt(rev-comm)}</b>\n\n"
|
||||
f"🛍 Товаров: {prods} | 💳 Карт: {cards}\n\n"
|
||||
f"{be} Баланс BotFactory: <b>{fmt(tenant.balance)}</b>",
|
||||
parse_mode="HTML")
|
||||
|
||||
# ── ЗАКАЗЫ ────────────────────────────────────────────────────────────
|
||||
@r.message(F.text.in_(["📦 Заказы","📦 Заказы на проверке"]))
|
||||
async def orders(msg: Message):
|
||||
if not await role(msg): return
|
||||
async with AsyncSessionLocal() as db:
|
||||
ords = (await db.execute(
|
||||
select(Order).where(Order.shop_id==sid,
|
||||
Order.status.in_([OrderStatus.pending,OrderStatus.confirming]))
|
||||
.order_by(Order.created_at.asc()).limit(20)
|
||||
)).scalars().all()
|
||||
if not ords:
|
||||
await msg.answer("✅ Ожидающих заказов нет."); return
|
||||
txt = f"📦 <b>Заказы на проверке ({len(ords)}):</b>\n\n"
|
||||
bld = InlineKeyboardBuilder()
|
||||
for o in ords:
|
||||
em = "🟡" if o.status==OrderStatus.pending else "🟠"
|
||||
txt += f"{em} #{o.id} · {fmt(o.amount)} · {o.buyer_username or o.buyer_telegram_id}\n"
|
||||
bld.add(InlineKeyboardButton(text=f"{em}#{o.id}·{o.amount:.0f}₽", callback_data=f"co_{o.id}"))
|
||||
bld.adjust(2)
|
||||
await msg.answer(txt, parse_mode="HTML", reply_markup=bld.as_markup())
|
||||
|
||||
@r.callback_query(F.data.startswith("co_"))
|
||||
async def order_detail(cb: CallbackQuery):
|
||||
if not await role(cb): return
|
||||
oid = int(cb.data[3:])
|
||||
async with AsyncSessionLocal() as db:
|
||||
o = await db.get(Order, oid)
|
||||
if not o or o.shop_id!=sid: await cb.answer("Не найден",show_alert=True); return
|
||||
card = await db.get(PaymentCard, o.card_id) if o.card_id else None
|
||||
txt = (f"📦 <b>Заказ #{o.id}</b>\n{'─'*22}\n"
|
||||
f"👤 {o.buyer_username or o.buyer_telegram_id}\n"
|
||||
f"💰 {fmt(o.amount)} | Комиссия: {fmt(o.commission)}\n"
|
||||
+ (f"💳 {card.bank} · {card.number}\n" if card else "")
|
||||
+ f"📅 {dt(o.created_at)}")
|
||||
bld = InlineKeyboardBuilder()
|
||||
if o.status in [OrderStatus.pending, OrderStatus.confirming]:
|
||||
bld.row(InlineKeyboardButton(text="✅ Подтвердить", callback_data=f"cc_{oid}"),
|
||||
InlineKeyboardButton(text="❌ Отклонить", callback_data=f"cr_{oid}"))
|
||||
bld.row(InlineKeyboardButton(text="← Назад", callback_data="cord_back"))
|
||||
if o.proof_file_id:
|
||||
await cb.message.answer_photo(photo=o.proof_file_id, caption=txt,
|
||||
parse_mode="HTML", reply_markup=bld.as_markup())
|
||||
else:
|
||||
await cb.message.answer(txt, parse_mode="HTML", reply_markup=bld.as_markup())
|
||||
await cb.answer()
|
||||
|
||||
@r.callback_query(F.data.startswith("cc_"))
|
||||
async def confirm_order(cb: CallbackQuery):
|
||||
if not await role(cb): return
|
||||
oid = int(cb.data[3:])
|
||||
async with AsyncSessionLocal() as db:
|
||||
o = await db.get(Order, oid)
|
||||
if not o or o.shop_id!=sid or o.status not in [OrderStatus.pending,OrderStatus.confirming]:
|
||||
await cb.answer("Нельзя подтвердить", show_alert=True); return
|
||||
shop = await db.get(Shop, sid)
|
||||
tenant = await db.get(Tenant, shop.tenant_id) if shop else None
|
||||
ok, reason = tenant_can_sell(tenant)
|
||||
if not ok:
|
||||
await cb.answer(f"❌ {reason}", show_alert=True); return
|
||||
|
||||
product = await db.get(Product, o.product_id)
|
||||
card = await db.get(PaymentCard, o.card_id) if o.card_id else None
|
||||
|
||||
o.status = OrderStatus.completed
|
||||
o.confirmed_by = cb.from_user.id
|
||||
|
||||
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 += o.amount
|
||||
|
||||
charged_now = False
|
||||
if tenant and not is_postpaid(tenant):
|
||||
old = tenant.balance
|
||||
tenant.balance -= o.commission
|
||||
charged_now = True
|
||||
db.add(BalanceTransaction(tenant_id=tenant.id, type="commission",
|
||||
amount=-o.commission, balance_after=tenant.balance,
|
||||
note=f"Заказ #{oid}", order_id=oid))
|
||||
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"Начислена постоплатная комиссия {fmt(o.commission)} за заказ #{oid}", order_id=oid))
|
||||
|
||||
content = o.product_content; buyer_id = o.buyer_telegram_id
|
||||
await db.commit()
|
||||
await self._deliver(buyer_id, content, oid)
|
||||
suffix = "комиссия списана" if charged_now else "комиссия начислена в постоплату"
|
||||
txt = f"✅ <b>Заказ #{oid} подтверждён.</b> Товар выдан, {suffix}."
|
||||
if cb.message.photo:
|
||||
await cb.message.edit_caption(caption=txt, parse_mode="HTML")
|
||||
else:
|
||||
await cb.message.edit_text(txt, parse_mode="HTML")
|
||||
await cb.answer("✅")
|
||||
|
||||
@r.callback_query(F.data.startswith("cr_"))
|
||||
async def reject_order(cb: CallbackQuery):
|
||||
if not await role(cb): return
|
||||
oid = int(cb.data[3:])
|
||||
async with AsyncSessionLocal() as db:
|
||||
o = await db.get(Order, oid); o.status = OrderStatus.rejected; await db.commit()
|
||||
await cb.message.edit_text(f"❌ Заказ #{oid} отклонён."); await cb.answer()
|
||||
|
||||
@r.callback_query(F.data=="cord_back")
|
||||
async def cord_back(cb: CallbackQuery):
|
||||
await orders(cb.message); await cb.answer()
|
||||
|
||||
@r.message(F.text=="📋 История платежей")
|
||||
async def history(msg: Message):
|
||||
if not await role(msg): return
|
||||
async with AsyncSessionLocal() as db:
|
||||
ords = (await db.execute(
|
||||
select(Order).where(Order.shop_id==sid, Order.status==OrderStatus.completed)
|
||||
.order_by(desc(Order.created_at)).limit(15)
|
||||
)).scalars().all()
|
||||
if not ords: await msg.answer("Нет выполненных заказов."); return
|
||||
txt = "📋 <b>Последние выполненные:</b>\n\n"
|
||||
for o in ords:
|
||||
txt += f"✅ #{o.id} · {fmt(o.amount)} · {dt(o.created_at)}\n"
|
||||
await msg.answer(txt, parse_mode="HTML")
|
||||
|
||||
# ── ТОВАРЫ (admin) ────────────────────────────────────────────────────
|
||||
@r.message(F.text=="🛍 Товары")
|
||||
async def products_menu(msg: Message):
|
||||
if await role(msg) != "admin": return
|
||||
async with AsyncSessionLocal() as db:
|
||||
prods = (await db.execute(
|
||||
select(Product).where(Product.shop_id==sid).order_by(desc(Product.created_at)).limit(20)
|
||||
)).scalars().all()
|
||||
if not prods:
|
||||
await msg.answer("Товаров нет.", reply_markup=ib(("➕ Добавить","cp_add"))); return
|
||||
bld = InlineKeyboardBuilder(); txt = f"🛍 <b>Товары ({len(prods)}):</b>\n\n"
|
||||
for p in prods:
|
||||
em = "✅" if p.is_active else "🔴"
|
||||
txt += f"{em} {p.name} · {fmt(p.price)}\n"
|
||||
bld.add(InlineKeyboardButton(text=f"{em}{p.name[:22]}", callback_data=f"cp_{p.id}"))
|
||||
bld.adjust(1); bld.row(InlineKeyboardButton(text="➕ Добавить", callback_data="cp_add"))
|
||||
await msg.answer(txt, parse_mode="HTML", reply_markup=bld.as_markup())
|
||||
|
||||
@r.callback_query(F.data=="cp_add")
|
||||
async def add_prod(cb: CallbackQuery, state: FSMContext):
|
||||
if await role(cb) != "admin": return
|
||||
await state.set_state(CS.prod_name)
|
||||
await cb.message.answer("🛍 <b>Название товара:</b>", parse_mode="HTML", reply_markup=ReplyKeyboardRemove())
|
||||
await cb.answer()
|
||||
|
||||
@r.message(CS.prod_name)
|
||||
async def p_name(msg: Message, state: FSMContext):
|
||||
await state.update_data(name=msg.text); await state.set_state(CS.prod_price)
|
||||
await msg.answer("Цена (₽):")
|
||||
|
||||
@r.message(CS.prod_price)
|
||||
async def p_price(msg: Message, state: FSMContext):
|
||||
try: price=float(msg.text.replace(",",".")); assert price>0
|
||||
except: await msg.answer("❌ Введите цену, например 599"); return
|
||||
await state.update_data(price=price); await state.set_state(CS.prod_cat)
|
||||
await msg.answer("Категория (или '-'):")
|
||||
|
||||
@r.message(CS.prod_cat)
|
||||
async def p_cat(msg: Message, state: FSMContext):
|
||||
await state.update_data(category=msg.text if msg.text!="-" else "Общее")
|
||||
await state.set_state(CS.prod_desc)
|
||||
await msg.answer("Описание для покупателя:")
|
||||
|
||||
@r.message(CS.prod_desc)
|
||||
async def p_desc(msg: Message, state: FSMContext):
|
||||
await state.update_data(description=msg.text); await state.set_state(CS.prod_content)
|
||||
await msg.answer("Содержимое товара (выдаётся после оплаты):")
|
||||
|
||||
@r.message(CS.prod_content)
|
||||
async def p_content(msg: Message, state: FSMContext):
|
||||
data = await state.get_data(); await state.clear()
|
||||
async with AsyncSessionLocal() as db:
|
||||
db.add(Product(shop_id=sid, name=data["name"], price=data["price"],
|
||||
category=data.get("category","Общее"), description=data.get("description",""),
|
||||
content=msg.text))
|
||||
await db.commit()
|
||||
await msg.answer(f"✅ Товар <b>{data['name']}</b> добавлен!", parse_mode="HTML", reply_markup=admin_kb())
|
||||
|
||||
@r.callback_query(F.data.startswith("cp_") & ~F.data.in_({"cp_add"}))
|
||||
async def prod_detail(cb: CallbackQuery):
|
||||
if await role(cb) != "admin": return
|
||||
pid = int(cb.data[3:])
|
||||
async with AsyncSessionLocal() as db:
|
||||
p = await db.get(Product, pid)
|
||||
if not p or p.shop_id!=sid: await cb.answer("Не найден"); return
|
||||
bld = InlineKeyboardBuilder()
|
||||
bld.row(InlineKeyboardButton(text="🔄 Скрыть/Показать", callback_data=f"cpt_{pid}"),
|
||||
InlineKeyboardButton(text="🗑 Удалить", callback_data=f"cpd_{pid}"))
|
||||
bld.row(InlineKeyboardButton(text="← Назад", callback_data="cpback"))
|
||||
await cb.message.edit_text(
|
||||
f"🛍 <b>{p.name}</b>\n💰 {fmt(p.price)}\n📂 {p.category}\n"
|
||||
f"Продано: {p.sold}\n{'✅' if p.is_active else '🔴'} {'Активен' if p.is_active else 'Скрыт'}",
|
||||
parse_mode="HTML", reply_markup=bld.as_markup()); await cb.answer()
|
||||
|
||||
@r.callback_query(F.data.startswith("cpt_"))
|
||||
async def prod_toggle(cb: CallbackQuery):
|
||||
pid=int(cb.data[4:])
|
||||
async with AsyncSessionLocal() as db:
|
||||
p=await db.get(Product,pid)
|
||||
if p and p.shop_id==sid: p.is_active=not p.is_active; await db.commit()
|
||||
await prod_detail(cb)
|
||||
|
||||
@r.callback_query(F.data.startswith("cpd_"))
|
||||
async def prod_del(cb: CallbackQuery):
|
||||
pid=int(cb.data[4:])
|
||||
async with AsyncSessionLocal() as db:
|
||||
p=await db.get(Product,pid)
|
||||
if p and p.shop_id==sid: await db.delete(p); await db.commit()
|
||||
await cb.message.edit_text("🗑 Товар удалён."); await cb.answer()
|
||||
|
||||
@r.callback_query(F.data=="cpback")
|
||||
async def cpback(cb: CallbackQuery):
|
||||
await products_menu(cb.message); await cb.answer()
|
||||
|
||||
# ── КАРТЫ (admin) ─────────────────────────────────────────────────────
|
||||
@r.message(F.text=="💳 Карты")
|
||||
async def cards_menu(msg: Message):
|
||||
if await role(msg) != "admin": return
|
||||
async with AsyncSessionLocal() as db:
|
||||
cards = (await db.execute(select(PaymentCard).where(PaymentCard.shop_id==sid))).scalars().all()
|
||||
if not cards:
|
||||
await msg.answer("Карт нет.", reply_markup=ib(("➕ Добавить карту","ccard_add"))); return
|
||||
bld=InlineKeyboardBuilder(); txt="💳 <b>Пул карт:</b>\n\n"
|
||||
for c in cards:
|
||||
em="🟢" if c.is_active else "🔴"
|
||||
txt+=f"{em} {c.bank} · {c.number}\n Получено: {fmt(c.received_total)}\n\n"
|
||||
bld.add(InlineKeyboardButton(text=f"{em}{c.bank}···{c.number[-4:]}", callback_data=f"ccd_{c.id}"))
|
||||
bld.adjust(2); bld.row(InlineKeyboardButton(text="➕ Добавить карту", callback_data="ccard_add"))
|
||||
await msg.answer(txt, parse_mode="HTML", reply_markup=bld.as_markup())
|
||||
|
||||
@r.callback_query(F.data=="ccard_add")
|
||||
async def add_card(cb: CallbackQuery, state: FSMContext):
|
||||
if await role(cb) != "admin": return
|
||||
await state.set_state(CS.card_bank)
|
||||
await cb.message.answer("🏦 Введите название банка:", reply_markup=ReplyKeyboardRemove())
|
||||
await cb.answer()
|
||||
|
||||
@r.message(CS.card_bank)
|
||||
async def cb_bank(msg: Message, state: FSMContext):
|
||||
await state.update_data(bank=msg.text); await state.set_state(CS.card_num)
|
||||
await msg.answer("Номер карты или номер для СБП:")
|
||||
|
||||
@r.message(CS.card_num)
|
||||
async def cb_num(msg: Message, state: FSMContext):
|
||||
await state.update_data(number=msg.text); await state.set_state(CS.card_holder)
|
||||
await msg.answer("Имя владельца (ЛАТИНИЦА, CAPS):")
|
||||
|
||||
@r.message(CS.card_holder)
|
||||
async def cb_holder(msg: Message, state: FSMContext):
|
||||
await state.update_data(holder=msg.text.upper()); await state.set_state(CS.card_phone)
|
||||
await msg.answer("Телефон для СБП (или '-'):")
|
||||
|
||||
@r.message(CS.card_phone)
|
||||
async def cb_phone(msg: Message, state: FSMContext):
|
||||
data=await state.get_data(); await state.clear()
|
||||
async with AsyncSessionLocal() as db:
|
||||
db.add(PaymentCard(shop_id=sid, bank=data["bank"], number=data["number"],
|
||||
holder=data["holder"], phone=msg.text if msg.text!="-" else "", is_active=True))
|
||||
await db.commit()
|
||||
await msg.answer(f"✅ Карта {data['bank']} добавлена в пул!", reply_markup=admin_kb())
|
||||
|
||||
@r.callback_query(F.data.startswith("ccd_"))
|
||||
async def card_detail(cb: CallbackQuery):
|
||||
if await role(cb) != "admin": return
|
||||
cid=int(cb.data[4:])
|
||||
async with AsyncSessionLocal() as db:
|
||||
c=await db.get(PaymentCard,cid)
|
||||
if not c or c.shop_id!=sid: await cb.answer("Не найдена"); return
|
||||
bld=InlineKeyboardBuilder()
|
||||
bld.row(InlineKeyboardButton(text="🔄 Вкл/Откл", callback_data=f"cct_{cid}"),
|
||||
InlineKeyboardButton(text="🗑 Удалить", callback_data=f"ccr_{cid}"))
|
||||
bld.row(InlineKeyboardButton(text="← Назад", callback_data="ccback"))
|
||||
await cb.message.edit_text(
|
||||
f"💳 <b>{c.bank}</b>\nНомер: <code>{c.number}</code>\n{c.holder}\n"
|
||||
f"{'СБП: '+c.phone+chr(10) if c.phone else ''}"
|
||||
f"Заказов: {c.orders_count} | Получено: {fmt(c.received_total)}\n"
|
||||
f"{'🟢 В пуле' if c.is_active else '🔴 Отключена'}",
|
||||
parse_mode="HTML", reply_markup=bld.as_markup()); await cb.answer()
|
||||
|
||||
@r.callback_query(F.data.startswith("cct_"))
|
||||
async def card_toggle(cb: CallbackQuery):
|
||||
cid=int(cb.data[4:])
|
||||
async with AsyncSessionLocal() as db:
|
||||
c=await db.get(PaymentCard,cid)
|
||||
if c and c.shop_id==sid: c.is_active=not c.is_active; await db.commit()
|
||||
await card_detail(cb)
|
||||
|
||||
@r.callback_query(F.data.startswith("ccr_"))
|
||||
async def card_del(cb: CallbackQuery):
|
||||
cid=int(cb.data[4:])
|
||||
async with AsyncSessionLocal() as db:
|
||||
c=await db.get(PaymentCard,cid)
|
||||
if c and c.shop_id==sid: await db.delete(c); await db.commit()
|
||||
await cb.message.edit_text("🗑 Карта удалена."); await cb.answer()
|
||||
|
||||
@r.callback_query(F.data=="ccback")
|
||||
async def ccback(cb: CallbackQuery):
|
||||
await cards_menu(cb.message); await cb.answer()
|
||||
|
||||
# ── КОМАНДА (admin) ───────────────────────────────────────────────────
|
||||
@r.message(F.text=="👥 Команда")
|
||||
async def team_menu(msg: Message):
|
||||
if await role(msg) != "admin": return
|
||||
async with AsyncSessionLocal() as db:
|
||||
members=(await db.execute(select(ShopMember).where(ShopMember.shop_id==sid))).scalars().all()
|
||||
txt="👥 <b>Кассиры:</b>\n\n"
|
||||
bld=InlineKeyboardBuilder()
|
||||
for m in members:
|
||||
txt+=f"💼 {m.name} · {m.username}\nTG: <code>{m.telegram_id}</code>\n\n"
|
||||
bld.add(InlineKeyboardButton(text=f"🗑{m.name[:20]}", callback_data=f"cdm_{m.id}"))
|
||||
bld.adjust(2); bld.row(InlineKeyboardButton(text="➕ Добавить кассира", callback_data="cadd_mbr"))
|
||||
if not members: txt+="Кассиров нет."
|
||||
await msg.answer(txt, parse_mode="HTML", reply_markup=bld.as_markup())
|
||||
|
||||
@r.callback_query(F.data=="cadd_mbr")
|
||||
async def add_mbr(cb: CallbackQuery, state: FSMContext):
|
||||
if await role(cb) != "admin": return
|
||||
await state.set_state(CS.mbr_id)
|
||||
await cb.message.answer("Введите Telegram ID кассира\n(узнать у @userinfobot):", reply_markup=ReplyKeyboardRemove())
|
||||
await cb.answer()
|
||||
|
||||
@r.message(CS.mbr_id)
|
||||
async def mbr_id(msg: Message, state: FSMContext):
|
||||
try: tid=int(msg.text.strip())
|
||||
except: await msg.answer("❌ Только число"); return
|
||||
await state.update_data(tid=tid); await state.set_state(CS.mbr_name)
|
||||
await msg.answer("Имя кассира:")
|
||||
|
||||
@r.message(CS.mbr_name)
|
||||
async def mbr_name(msg: Message, state: FSMContext):
|
||||
data=await state.get_data(); await state.clear()
|
||||
async with AsyncSessionLocal() as db:
|
||||
db.add(ShopMember(shop_id=sid, telegram_id=data["tid"], name=msg.text, role="moderator"))
|
||||
await db.commit()
|
||||
await msg.answer(f"✅ Кассир {msg.text} добавлен!\nПусть напишет /start боту управления.", reply_markup=admin_kb())
|
||||
|
||||
@r.callback_query(F.data.startswith("cdm_"))
|
||||
async def del_mbr(cb: CallbackQuery):
|
||||
mid=int(cb.data[4:])
|
||||
async with AsyncSessionLocal() as db:
|
||||
m=await db.get(ShopMember,mid)
|
||||
if m and m.shop_id==sid: await db.delete(m); await db.commit()
|
||||
await cb.message.edit_text("🗑 Кассир удалён."); await cb.answer()
|
||||
|
||||
# ── ТОКЕНЫ (admin) ────────────────────────────────────────────────────
|
||||
@r.message(F.text=="⚙️ Токены")
|
||||
async def tokens_menu(msg: Message):
|
||||
if await role(msg) != "admin": return
|
||||
async with AsyncSessionLocal() as db:
|
||||
toks=(await db.execute(select(ShopToken).where(ShopToken.shop_id==sid))).scalars().all()
|
||||
txt="⚙️ <b>Токены бота магазина:</b>\n\n"
|
||||
bld=InlineKeyboardBuilder()
|
||||
for t in toks:
|
||||
em="🟢" if t.is_active else "⬛"
|
||||
txt+=f"{em} <b>{t.username}</b> [{t.note}]\n{t.token[:20]}…\n\n"
|
||||
if not t.is_active:
|
||||
bld.add(InlineKeyboardButton(text=f"🔀 Переключить на {t.username[:16]}", callback_data=f"tsw_{t.id}"))
|
||||
bld.adjust(1); bld.row(InlineKeyboardButton(text="➕ Добавить токен", callback_data="tadd"))
|
||||
txt+="⚠️ При блокировке бота — добавьте новый токен и переключитесь."
|
||||
await msg.answer(txt, parse_mode="HTML", reply_markup=bld.as_markup())
|
||||
|
||||
@r.callback_query(F.data.startswith("tsw_"))
|
||||
async def token_switch(cb: CallbackQuery):
|
||||
if await role(cb) != "admin": return
|
||||
tid=int(cb.data[4:])
|
||||
async with AsyncSessionLocal() as db:
|
||||
toks=(await db.execute(select(ShopToken).where(ShopToken.shop_id==sid))).scalars().all()
|
||||
for t in toks: t.is_active=(t.id==tid)
|
||||
await db.commit()
|
||||
await cb.answer("✅ Токен переключён! Бот магазина перезапустится.", show_alert=True)
|
||||
await tokens_menu(cb.message)
|
||||
|
||||
@r.callback_query(F.data=="tadd")
|
||||
async def token_add(cb: CallbackQuery, state: FSMContext):
|
||||
if await role(cb) != "admin": return
|
||||
await state.set_state(CS.tok_val)
|
||||
await cb.message.answer("Вставьте токен нового бота (@BotFather):", reply_markup=ReplyKeyboardRemove())
|
||||
await cb.answer()
|
||||
|
||||
@r.message(CS.tok_val)
|
||||
async def tok_val(msg: Message, state: FSMContext):
|
||||
await state.update_data(token=msg.text.strip()); await state.set_state(CS.tok_user)
|
||||
await msg.answer("Username бота (например @myshop2_bot):")
|
||||
|
||||
@r.message(CS.tok_user)
|
||||
async def tok_user(msg: Message, state: FSMContext):
|
||||
await state.update_data(username=msg.text.strip()); await state.set_state(CS.tok_note)
|
||||
await msg.answer("Метка токена (например 'Резервный #2'):")
|
||||
|
||||
@r.message(CS.tok_note)
|
||||
async def tok_note(msg: Message, state: FSMContext):
|
||||
data=await state.get_data(); await state.clear()
|
||||
async with AsyncSessionLocal() as db:
|
||||
db.add(ShopToken(shop_id=sid, token=data["token"], username=data["username"], note=msg.text, is_active=False))
|
||||
await db.commit()
|
||||
await msg.answer(f"✅ Токен {data['username']} добавлен!\nДля переключения — раздел «⚙️ Токены»", reply_markup=admin_kb())
|
||||
|
||||
# ── БАЛАНС (admin) ────────────────────────────────────────────────────
|
||||
@r.message(F.text=="💰 Баланс")
|
||||
async def balance(msg: Message):
|
||||
if await role(msg) != "admin": return
|
||||
async with AsyncSessionLocal() as db:
|
||||
shop=await db.get(Shop,sid); tenant=await db.get(Tenant,shop.tenant_id)
|
||||
txs=(await db.execute(select(BalanceTransaction)
|
||||
.where(BalanceTransaction.tenant_id==tenant.id)
|
||||
.order_by(desc(BalanceTransaction.created_at)).limit(5))).scalars().all()
|
||||
be="🔴" if tenant.balance<=0 else ("🟡" if tenant.balance<=tenant.alert_threshold else "🟢")
|
||||
txt=(f"💰 <b>Баланс BotFactory</b>\n{be} <b>{fmt(tenant.balance)}</b>\n"
|
||||
f"⚠️ Порог: {fmt(tenant.alert_threshold)}\n\n")
|
||||
if tenant.balance<=0: txt+="🔴 Боты приостановлены!\n\n"
|
||||
if txs:
|
||||
txt+="<b>Последние транзакции:</b>\n"
|
||||
for tx in txs:
|
||||
s="+" if tx.amount>0 else ""; em="💚" if tx.amount>0 else "📊"
|
||||
txt+=f"{em} {s}{fmt(tx.amount)} — {tx.note[:40]}\n"
|
||||
await msg.answer(txt, parse_mode="HTML",
|
||||
reply_markup=ib(("💳 Реквизиты для пополнения","topup_req")))
|
||||
|
||||
@r.callback_query(F.data=="topup_req")
|
||||
async def topup_req(cb: CallbackQuery):
|
||||
await cb.message.answer(
|
||||
"💳 <b>Реквизиты для пополнения BotFactory</b>\n\n"
|
||||
"🏦 Тинькофф: <code>5536 **** **** 0001</code>\n"
|
||||
"👤 ООО БОТФАКТОРИ\n\n"
|
||||
"📝 Назначение: <code>Пополнение BotFactory</code>\n"
|
||||
"⚠️ Обязательно укажите назначение!", parse_mode="HTML")
|
||||
await cb.answer()
|
||||
|
||||
# ── Deliver product ────────────────────────────────────────────────────────
|
||||
async def _deliver(self, buyer_tg_id: int, content: str, order_id: int):
|
||||
try:
|
||||
async with AsyncSessionLocal() as db:
|
||||
tok=(await db.execute(select(ShopToken).where(
|
||||
ShopToken.shop_id==self.shop_id, ShopToken.is_active==True)
|
||||
)).scalar_one_or_none()
|
||||
if not tok: return
|
||||
bot=Bot(token=tok.token)
|
||||
await bot.send_message(buyer_tg_id,
|
||||
f"✅ <b>Оплата подтверждена!</b>\n\nЗаказ #{order_id}\n\n"
|
||||
f"<code>{content}</code>", parse_mode="HTML")
|
||||
await bot.session.close()
|
||||
except Exception as e:
|
||||
logger.error(f"_deliver: {e}")
|
||||
|
||||
# ── Notify cashiers (вызывается из shop_bot) ───────────────────────────────
|
||||
async def notify_cashiers(self, order_id: int, amount: float, buyer: str, file_id: str):
|
||||
try:
|
||||
async with AsyncSessionLocal() as db:
|
||||
members=(await db.execute(select(ShopMember).where(ShopMember.shop_id==self.shop_id))).scalars().all()
|
||||
shop=await db.get(Shop,self.shop_id); tenant=await db.get(Tenant,shop.tenant_id)
|
||||
recipients=[m.telegram_id for m in members]
|
||||
if tenant and tenant.telegram_id: recipients.append(tenant.telegram_id)
|
||||
if not recipients: return
|
||||
bot=Bot(token=self.token)
|
||||
bld=InlineKeyboardBuilder()
|
||||
bld.row(InlineKeyboardButton(text=f"✅ Подтвердить #{order_id}", callback_data=f"cc_{order_id}"),
|
||||
InlineKeyboardButton(text="❌ Отклонить", callback_data=f"cr_{order_id}"))
|
||||
caption=(f"🔔 <b>Новый платёж!</b>\n#{order_id} · {fmt(amount)}\n👤 @{buyer}")
|
||||
for uid in set(recipients):
|
||||
try:
|
||||
await bot.send_photo(uid, photo=file_id, caption=caption,
|
||||
parse_mode="HTML", reply_markup=bld.as_markup())
|
||||
except Exception as e:
|
||||
logger.warning(f"notify uid={uid}: {e}")
|
||||
await bot.session.close()
|
||||
except Exception as e:
|
||||
logger.error(f"notify_cashiers: {e}")
|
||||
|
||||
async def run(self):
|
||||
storage=RedisStorage.from_url(settings.REDIS_URL+f"?db={self.shop_id%13+1}")
|
||||
bot=Bot(token=self.token); dp=Dispatcher(storage=storage)
|
||||
dp.include_router(self.router)
|
||||
logger.info(f"Ctrl bot: {self.name} (shop={self.shop_id})")
|
||||
try:
|
||||
await dp.start_polling(bot, allowed_updates=["message","callback_query"])
|
||||
finally:
|
||||
await bot.session.close()
|
||||
@@ -0,0 +1,53 @@
|
||||
from sqlalchemy import text
|
||||
from sqlalchemy.ext.asyncio import create_async_engine, AsyncSession, async_sessionmaker
|
||||
from sqlalchemy.orm import DeclarativeBase
|
||||
from config import settings
|
||||
|
||||
engine = create_async_engine(
|
||||
settings.DATABASE_URL,
|
||||
pool_size=settings.DB_POOL_SIZE,
|
||||
max_overflow=10,
|
||||
echo=settings.DEBUG,
|
||||
)
|
||||
AsyncSessionLocal = async_sessionmaker(engine, expire_on_commit=False)
|
||||
|
||||
|
||||
class Base(DeclarativeBase):
|
||||
pass
|
||||
|
||||
|
||||
async def get_db():
|
||||
async with AsyncSessionLocal() as session:
|
||||
yield session
|
||||
|
||||
|
||||
async def _run_lightweight_migrations(conn):
|
||||
"""Small safe migrations for the installer/update path.
|
||||
|
||||
The project still uses SQLAlchemy create_all for first deployment. These
|
||||
ALTER statements keep existing installs alive when new billing fields are
|
||||
added before a full Alembic workflow appears.
|
||||
"""
|
||||
dialect = conn.dialect.name
|
||||
if dialect != "postgresql":
|
||||
return
|
||||
|
||||
# PostgreSQL enum values must be added before columns can use them.
|
||||
await conn.execute(text("ALTER TYPE planenum ADD VALUE IF NOT EXISTS 'trial_week'"))
|
||||
await conn.execute(text("ALTER TYPE planenum ADD VALUE IF NOT EXISTS 'postpaid_custom'"))
|
||||
|
||||
await conn.execute(text("ALTER TABLE tenants ADD COLUMN IF NOT EXISTS trial_ends_at TIMESTAMP NULL"))
|
||||
await conn.execute(text("ALTER TABLE tenants ADD COLUMN IF NOT EXISTS postpaid_commission_percent DOUBLE PRECISION DEFAULT 5"))
|
||||
await conn.execute(text("ALTER TABLE tenants ADD COLUMN IF NOT EXISTS postpaid_due_day INTEGER DEFAULT 5"))
|
||||
await conn.execute(text("ALTER TABLE tenants ADD COLUMN IF NOT EXISTS postpaid_note VARCHAR(512) DEFAULT ''"))
|
||||
await conn.execute(text("ALTER TABLE tenants ADD COLUMN IF NOT EXISTS postpaid_enabled_at TIMESTAMP NULL"))
|
||||
|
||||
|
||||
async def init_db():
|
||||
# Import models here so Base.metadata is populated even when init_db is
|
||||
# called from worker entrypoints that did not import models yet.
|
||||
import models # noqa: F401
|
||||
|
||||
async with engine.begin() as conn:
|
||||
await conn.run_sync(Base.metadata.create_all)
|
||||
await _run_lightweight_migrations(conn)
|
||||
@@ -0,0 +1,71 @@
|
||||
import logging
|
||||
from contextlib import asynccontextmanager
|
||||
from pathlib import Path
|
||||
|
||||
from fastapi import FastAPI
|
||||
from fastapi.middleware.cors import CORSMiddleware
|
||||
from fastapi.staticfiles import StaticFiles
|
||||
|
||||
from config import settings
|
||||
from database import init_db
|
||||
|
||||
logging.basicConfig(
|
||||
level=logging.DEBUG if settings.DEBUG else logging.INFO,
|
||||
format="%(asctime)s [%(levelname)s] %(name)s: %(message)s",
|
||||
)
|
||||
logger = logging.getLogger("botfactory")
|
||||
|
||||
|
||||
@asynccontextmanager
|
||||
async def lifespan(app: FastAPI):
|
||||
logger.info("Starting BotFactory API...")
|
||||
await init_db()
|
||||
logger.info("Database initialized")
|
||||
yield
|
||||
logger.info("Shutting down BotFactory API")
|
||||
|
||||
|
||||
app = FastAPI(
|
||||
title="BotFactory API",
|
||||
version="2.1.0",
|
||||
docs_url="/api/docs" if settings.DEBUG else None,
|
||||
redoc_url=None,
|
||||
lifespan=lifespan,
|
||||
)
|
||||
|
||||
app.add_middleware(
|
||||
CORSMiddleware,
|
||||
allow_origins=[o.strip() for o in settings.ALLOWED_ORIGINS.split(",") if o.strip()],
|
||||
allow_credentials=True,
|
||||
allow_methods=["*"],
|
||||
allow_headers=["*"],
|
||||
)
|
||||
|
||||
upload_path = Path(settings.UPLOAD_DIR)
|
||||
upload_path.mkdir(parents=True, exist_ok=True)
|
||||
app.mount("/uploads", StaticFiles(directory=str(upload_path)), name="uploads")
|
||||
|
||||
|
||||
@app.get("/api/health", tags=["System"])
|
||||
async def health():
|
||||
return {"status": "ok", "version": "2.1.0"}
|
||||
|
||||
|
||||
@app.get("/api/version", tags=["System"])
|
||||
async def version():
|
||||
return {
|
||||
"version": "2.1.0",
|
||||
"debug": settings.DEBUG,
|
||||
"domain": settings.DOMAIN or None,
|
||||
}
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
import uvicorn
|
||||
uvicorn.run(
|
||||
"main:app",
|
||||
host=settings.API_HOST,
|
||||
port=settings.API_PORT,
|
||||
reload=settings.DEBUG,
|
||||
loop="uvloop",
|
||||
)
|
||||
@@ -0,0 +1,160 @@
|
||||
from datetime import datetime
|
||||
from sqlalchemy import (
|
||||
String, Integer, Float, Boolean, DateTime,
|
||||
ForeignKey, Text, BigInteger, Enum,
|
||||
)
|
||||
from sqlalchemy.orm import Mapped, mapped_column, relationship
|
||||
from database import Base
|
||||
import enum
|
||||
|
||||
|
||||
class PlanEnum(str, enum.Enum):
|
||||
trial_week = "trial_week"
|
||||
trial = "trial"
|
||||
basic = "basic"
|
||||
pro = "pro"
|
||||
enterprise = "enterprise"
|
||||
postpaid_custom = "postpaid_custom"
|
||||
|
||||
|
||||
class OrderStatus(str, enum.Enum):
|
||||
pending = "pending"
|
||||
confirming = "confirming"
|
||||
completed = "completed"
|
||||
rejected = "rejected"
|
||||
cancelled = "cancelled"
|
||||
|
||||
|
||||
class Tenant(Base):
|
||||
__tablename__ = "tenants"
|
||||
id: Mapped[int] = mapped_column(Integer, primary_key=True)
|
||||
name: Mapped[str] = mapped_column(String(128))
|
||||
email: Mapped[str] = mapped_column(String(256), unique=True)
|
||||
password_hash: Mapped[str] = mapped_column(String(256))
|
||||
telegram_id: Mapped[int|None] = mapped_column(BigInteger, nullable=True, index=True)
|
||||
plan: Mapped[PlanEnum] = mapped_column(Enum(PlanEnum), default=PlanEnum.trial_week)
|
||||
trial_ends_at: Mapped[datetime|None] = mapped_column(DateTime, nullable=True)
|
||||
postpaid_commission_percent: Mapped[float] = mapped_column(Float, default=5.0)
|
||||
postpaid_due_day: Mapped[int] = mapped_column(Integer, default=5)
|
||||
postpaid_note: Mapped[str] = mapped_column(String(512), default="")
|
||||
postpaid_enabled_at: Mapped[datetime|None] = mapped_column(DateTime, nullable=True)
|
||||
balance: Mapped[float] = mapped_column(Float, default=0.0)
|
||||
alert_threshold: Mapped[float] = mapped_column(Float, default=200.0)
|
||||
is_active: Mapped[bool] = mapped_column(Boolean, default=True)
|
||||
is_blocked: Mapped[bool] = mapped_column(Boolean, default=False)
|
||||
created_at: Mapped[datetime] = mapped_column(DateTime, default=datetime.utcnow)
|
||||
|
||||
shops: Mapped[list["Shop"]] = relationship(back_populates="tenant")
|
||||
transactions: Mapped[list["BalanceTransaction"]] = relationship(back_populates="tenant")
|
||||
|
||||
|
||||
class Shop(Base):
|
||||
__tablename__ = "shops"
|
||||
id: Mapped[int] = mapped_column(Integer, primary_key=True)
|
||||
tenant_id: Mapped[int] = mapped_column(ForeignKey("tenants.id"), index=True)
|
||||
name: Mapped[str] = mapped_column(String(128))
|
||||
ctrl_bot_token: Mapped[str] = mapped_column(String(128))
|
||||
ctrl_bot_username:Mapped[str] = mapped_column(String(64), default="")
|
||||
welcome_msg: Mapped[str] = mapped_column(Text, default="Добро пожаловать!")
|
||||
is_active: Mapped[bool] = mapped_column(Boolean, default=True)
|
||||
created_at: Mapped[datetime] = mapped_column(DateTime, default=datetime.utcnow)
|
||||
|
||||
tenant: Mapped["Tenant"] = relationship(back_populates="shops")
|
||||
tokens: Mapped[list["ShopToken"]] = relationship(back_populates="shop")
|
||||
products: Mapped[list["Product"]] = relationship(back_populates="shop")
|
||||
cards: Mapped[list["PaymentCard"]] = relationship(back_populates="shop")
|
||||
orders: Mapped[list["Order"]] = relationship(back_populates="shop")
|
||||
team: Mapped[list["ShopMember"]] = relationship(back_populates="shop")
|
||||
|
||||
|
||||
class ShopToken(Base):
|
||||
__tablename__ = "shop_tokens"
|
||||
id: Mapped[int] = mapped_column(Integer, primary_key=True)
|
||||
shop_id: Mapped[int] = mapped_column(ForeignKey("shops.id"), index=True)
|
||||
token: Mapped[str] = mapped_column(String(128))
|
||||
username: Mapped[str] = mapped_column(String(64), default="")
|
||||
note: Mapped[str] = mapped_column(String(64), default="Основной")
|
||||
is_active: Mapped[bool] = mapped_column(Boolean, default=False)
|
||||
added_at: Mapped[datetime] = mapped_column(DateTime, default=datetime.utcnow)
|
||||
|
||||
shop: Mapped["Shop"] = relationship(back_populates="tokens")
|
||||
|
||||
|
||||
class ShopMember(Base):
|
||||
__tablename__ = "shop_members"
|
||||
id: Mapped[int] = mapped_column(Integer, primary_key=True)
|
||||
shop_id: Mapped[int] = mapped_column(ForeignKey("shops.id"), index=True)
|
||||
telegram_id: Mapped[int] = mapped_column(BigInteger, index=True)
|
||||
username: Mapped[str] = mapped_column(String(64), default="")
|
||||
name: Mapped[str] = mapped_column(String(128))
|
||||
role: Mapped[str] = mapped_column(String(32), default="moderator")
|
||||
added_at: Mapped[datetime] = mapped_column(DateTime, default=datetime.utcnow)
|
||||
|
||||
shop: Mapped["Shop"] = relationship(back_populates="team")
|
||||
|
||||
|
||||
class Product(Base):
|
||||
__tablename__ = "products"
|
||||
id: Mapped[int] = mapped_column(Integer, primary_key=True)
|
||||
shop_id: Mapped[int] = mapped_column(ForeignKey("shops.id"), index=True)
|
||||
name: Mapped[str] = mapped_column(String(256))
|
||||
category: Mapped[str] = mapped_column(String(64), default="Общее")
|
||||
description: Mapped[str] = mapped_column(Text, default="")
|
||||
content: Mapped[str] = mapped_column(Text, default="")
|
||||
photo_url: Mapped[str|None] = mapped_column(String(512), nullable=True)
|
||||
price: Mapped[float] = mapped_column(Float)
|
||||
stock: Mapped[int] = mapped_column(Integer, default=999)
|
||||
sold: Mapped[int] = mapped_column(Integer, default=0)
|
||||
is_active: Mapped[bool] = mapped_column(Boolean, default=True)
|
||||
created_at: Mapped[datetime] = mapped_column(DateTime, default=datetime.utcnow)
|
||||
|
||||
shop: Mapped["Shop"] = relationship(back_populates="products")
|
||||
|
||||
|
||||
class PaymentCard(Base):
|
||||
__tablename__ = "payment_cards"
|
||||
id: Mapped[int] = mapped_column(Integer, primary_key=True)
|
||||
shop_id: Mapped[int] = mapped_column(ForeignKey("shops.id"), index=True)
|
||||
bank: Mapped[str] = mapped_column(String(64))
|
||||
number: Mapped[str] = mapped_column(String(32))
|
||||
holder: Mapped[str] = mapped_column(String(128))
|
||||
phone: Mapped[str] = mapped_column(String(32), default="")
|
||||
is_active: Mapped[bool] = mapped_column(Boolean, default=True)
|
||||
orders_count: Mapped[int] = mapped_column(Integer, default=0)
|
||||
received_total: Mapped[float] = mapped_column(Float, default=0.0)
|
||||
|
||||
shop: Mapped["Shop"] = relationship(back_populates="cards")
|
||||
|
||||
|
||||
class Order(Base):
|
||||
__tablename__ = "orders"
|
||||
id: Mapped[int] = mapped_column(Integer, primary_key=True)
|
||||
shop_id: Mapped[int] = mapped_column(ForeignKey("shops.id"), index=True)
|
||||
product_id: Mapped[int] = mapped_column(ForeignKey("products.id"))
|
||||
card_id: Mapped[int|None] = mapped_column(ForeignKey("payment_cards.id"), nullable=True)
|
||||
buyer_telegram_id: Mapped[int] = mapped_column(BigInteger, index=True)
|
||||
buyer_username: Mapped[str] = mapped_column(String(64), default="")
|
||||
amount: Mapped[float] = mapped_column(Float)
|
||||
commission: Mapped[float] = mapped_column(Float, default=0.0)
|
||||
status: Mapped[OrderStatus] = mapped_column(Enum(OrderStatus), default=OrderStatus.pending, index=True)
|
||||
proof_file_id: Mapped[str|None] = mapped_column(String(256), nullable=True)
|
||||
confirmed_by: Mapped[int|None] = mapped_column(BigInteger, nullable=True)
|
||||
product_content: Mapped[str] = mapped_column(Text, default="")
|
||||
created_at: Mapped[datetime] = mapped_column(DateTime, default=datetime.utcnow, index=True)
|
||||
updated_at: Mapped[datetime] = mapped_column(DateTime, default=datetime.utcnow, onupdate=datetime.utcnow)
|
||||
|
||||
shop: Mapped["Shop"] = relationship(back_populates="orders")
|
||||
|
||||
|
||||
class BalanceTransaction(Base):
|
||||
__tablename__ = "balance_transactions"
|
||||
id: Mapped[int] = mapped_column(Integer, primary_key=True)
|
||||
tenant_id: Mapped[int] = mapped_column(ForeignKey("tenants.id"), index=True)
|
||||
type: Mapped[str] = mapped_column(String(32)) # deposit / commission / refund
|
||||
amount: Mapped[float] = mapped_column(Float)
|
||||
balance_after: Mapped[float] = mapped_column(Float)
|
||||
note: Mapped[str] = mapped_column(String(512), default="")
|
||||
order_id: Mapped[int|None] = mapped_column(Integer, nullable=True)
|
||||
created_at: Mapped[datetime] = mapped_column(DateTime, default=datetime.utcnow, index=True)
|
||||
|
||||
tenant: Mapped["Tenant"] = relationship(back_populates="transactions")
|
||||
@@ -0,0 +1,698 @@
|
||||
"""BotFactory — Platform Admin Bot (системный бот платформы)"""
|
||||
import asyncio, logging
|
||||
from datetime import datetime, timedelta
|
||||
from aiogram import Bot, Dispatcher, F, Router
|
||||
from aiogram.filters import Command
|
||||
from aiogram.fsm.context import FSMContext
|
||||
from aiogram.fsm.state import State, StatesGroup
|
||||
from aiogram.fsm.storage.redis import RedisStorage
|
||||
from aiogram.types import (Message, CallbackQuery,
|
||||
InlineKeyboardButton, ReplyKeyboardMarkup, KeyboardButton, ReplyKeyboardRemove)
|
||||
from aiogram.utils.keyboard import InlineKeyboardBuilder, ReplyKeyboardBuilder
|
||||
from sqlalchemy import select, func, desc
|
||||
from config import settings
|
||||
from database import AsyncSessionLocal
|
||||
from models import Tenant, Shop, ShopToken, Product, PaymentCard, Order, BalanceTransaction, OrderStatus, PlanEnum
|
||||
from billing import (current_due_date, is_postpaid, plan_title,
|
||||
postpaid_previous_month_totals, tenant_can_sell)
|
||||
|
||||
logger = logging.getLogger("platform_bot")
|
||||
router = Router()
|
||||
|
||||
|
||||
# ── Guards ────────────────────────────────────────────────────────────────────
|
||||
def is_admin(uid: int) -> bool:
|
||||
return uid in settings.admin_ids
|
||||
|
||||
def guard(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)
|
||||
return w
|
||||
|
||||
|
||||
# ── States ────────────────────────────────────────────────────────────────────
|
||||
class S(StatesGroup):
|
||||
topup_amount = State()
|
||||
topup_note = State()
|
||||
broadcast = State()
|
||||
bcast_ok = State()
|
||||
plan_percent = State()
|
||||
plan_due_day = State()
|
||||
|
||||
|
||||
# ── Keyboards ─────────────────────────────────────────────────────────────────
|
||||
def main_kb():
|
||||
b = ReplyKeyboardBuilder()
|
||||
b.row(KeyboardButton(text="📊 Статистика"), KeyboardButton(text="👥 Пользователи"))
|
||||
b.row(KeyboardButton(text="💰 Балансы"), KeyboardButton(text="📦 Заказы"))
|
||||
b.row(KeyboardButton(text="📢 Рассылка"), KeyboardButton(text="⚙️ Настройки"))
|
||||
b.row(KeyboardButton(text="🧾 Постоплата"))
|
||||
return b.as_markup(resize_keyboard=True)
|
||||
|
||||
def ib(*rows):
|
||||
bld = InlineKeyboardBuilder()
|
||||
for row in rows:
|
||||
if isinstance(row, list):
|
||||
bld.row(*[InlineKeyboardButton(text=t, callback_data=d) for t, d in row])
|
||||
else:
|
||||
bld.add(InlineKeyboardButton(text=row[0], callback_data=row[1]))
|
||||
return bld.as_markup()
|
||||
|
||||
def fmt(v: float) -> str:
|
||||
return f"{v:,.0f}".replace(",", " ") + " ₽"
|
||||
|
||||
def bal_em(b: float, t: float) -> str:
|
||||
return "🔴" if b <= 0 else ("🟡" if b <= t else "🟢")
|
||||
|
||||
|
||||
# ── /start ────────────────────────────────────────────────────────────────────
|
||||
@router.message(Command("start"))
|
||||
@guard
|
||||
async def start(msg: Message, state: FSMContext):
|
||||
await state.clear()
|
||||
await msg.answer(
|
||||
f"👋 <b>BotFactory Admin</b>\n{msg.from_user.full_name}\n"
|
||||
f"ID: <code>{msg.from_user.id}</code>",
|
||||
reply_markup=main_kb(), parse_mode="HTML")
|
||||
|
||||
|
||||
# ── Статистика ────────────────────────────────────────────────────────────────
|
||||
@router.message(F.text == "📊 Статистика")
|
||||
@guard
|
||||
async def stats(msg: Message):
|
||||
async with AsyncSessionLocal() as db:
|
||||
n_all = (await db.execute(select(func.count(Tenant.id)))).scalar()
|
||||
n_act = (await db.execute(select(func.count(Tenant.id)).where(Tenant.is_active==True))).scalar()
|
||||
n_blk = (await db.execute(select(func.count(Tenant.id)).where(Tenant.is_blocked==True))).scalar()
|
||||
n_low = (await db.execute(select(func.count(Tenant.id)).where(Tenant.balance>0, Tenant.balance<=Tenant.alert_threshold))).scalar()
|
||||
n_zero = (await db.execute(select(func.count(Tenant.id)).where(Tenant.balance<=0, Tenant.is_active==True))).scalar()
|
||||
turn = (await db.execute(select(func.sum(Order.amount)).where(Order.status==OrderStatus.completed))).scalar() or 0
|
||||
comm = (await db.execute(select(func.sum(Order.commission)).where(Order.status==OrderStatus.completed))).scalar() or 0
|
||||
pending = (await db.execute(select(func.count(Order.id)).where(Order.status.in_([OrderStatus.pending,OrderStatus.confirming])))).scalar()
|
||||
today = datetime.utcnow().replace(hour=0,minute=0,second=0,microsecond=0)
|
||||
orders_td = (await db.execute(select(func.count(Order.id)).where(Order.status==OrderStatus.completed, Order.created_at>=today))).scalar()
|
||||
|
||||
txt = (
|
||||
f"📊 <b>Статистика платформы</b>\n<i>{datetime.now():%d.%m.%Y %H:%M}</i>\n{'─'*28}\n\n"
|
||||
f"👥 Пользователей: <b>{n_all}</b> (активных {n_act})\n"
|
||||
f"🔴 Заблокировано: <b>{n_blk}</b>\n🟡 Низкий баланс: <b>{n_low}</b>\n"
|
||||
f"🔴 Баланс=0: <b>{n_zero}</b>\n\n"
|
||||
f"💰 Оборот: <b>{fmt(turn)}</b>\n📊 Комиссия: <b>{fmt(comm)}</b>\n\n"
|
||||
f"📦 Заказов сегодня: <b>{orders_td}</b>\n⏳ На подтверждении: <b>{pending}</b>"
|
||||
)
|
||||
await msg.answer(txt, parse_mode="HTML", reply_markup=ib(("🔄 Обновить","stats_ref")))
|
||||
|
||||
@router.callback_query(F.data=="stats_ref")
|
||||
@guard
|
||||
async def stats_ref(cb: CallbackQuery):
|
||||
await stats(cb.message); await cb.answer("Обновлено")
|
||||
|
||||
|
||||
# ── Пользователи ──────────────────────────────────────────────────────────────
|
||||
@router.message(F.text == "👥 Пользователи")
|
||||
@guard
|
||||
async def users(msg: Message):
|
||||
async with AsyncSessionLocal() as db:
|
||||
tenants = (await db.execute(
|
||||
select(Tenant).order_by(desc(Tenant.created_at)).limit(20)
|
||||
)).scalars().all()
|
||||
if not tenants:
|
||||
await msg.answer("Нет пользователей."); return
|
||||
bld = InlineKeyboardBuilder()
|
||||
txt = "👥 <b>Пользователи</b>\n\n"
|
||||
for t in tenants:
|
||||
em = "🔒" if t.is_blocked else ("🔴" if t.balance<=0 else "✅")
|
||||
txt += f"{em} <b>{t.name}</b> · {t.email}\n 📦 {plan_title(t.plan)} · {bal_em(t.balance,t.alert_threshold)} {fmt(t.balance)}\n\n"
|
||||
bld.add(InlineKeyboardButton(text=f"{em} {t.name[:22]}", callback_data=f"t_{t.id}"))
|
||||
bld.adjust(2)
|
||||
await msg.answer(txt, parse_mode="HTML", reply_markup=bld.as_markup())
|
||||
|
||||
@router.callback_query(F.data.startswith("t_"))
|
||||
@guard
|
||||
async def tenant_detail(cb: CallbackQuery, state: FSMContext):
|
||||
data = cb.data or ""
|
||||
if data.startswith("t_"):
|
||||
tid = int(data[2:])
|
||||
elif data.startswith("blk_"):
|
||||
tid = int(data[4:])
|
||||
elif data.startswith("setplan_"):
|
||||
tid = int(data[len("setplan_"):].split("_", 1)[0])
|
||||
else:
|
||||
await cb.answer("Не удалось определить пользователя", show_alert=True)
|
||||
return
|
||||
async with AsyncSessionLocal() as db:
|
||||
t = await db.get(Tenant, tid)
|
||||
if not t:
|
||||
await cb.answer("Не найден", show_alert=True)
|
||||
return
|
||||
shops_n = (await db.execute(select(func.count(Shop.id)).where(Shop.tenant_id == tid))).scalar()
|
||||
trial_txt = f"\n🧪 Тест до: <b>{t.trial_ends_at:%d.%m.%Y}</b>" if t.trial_ends_at else ""
|
||||
postpaid_txt = ""
|
||||
if is_postpaid(t):
|
||||
revenue, due = await postpaid_previous_month_totals(db, tid)
|
||||
due_date = current_due_date(t)
|
||||
postpaid_txt = (
|
||||
f"\n🧾 Постоплата: <b>{t.postpaid_commission_percent:g}%</b> от выручки прошлого месяца"
|
||||
f"\n📅 Оплата до: <b>{due_date:%d.%m.%Y}</b>"
|
||||
f"\n📈 Прошлый месяц: {fmt(revenue)} · к оплате {fmt(due)}"
|
||||
)
|
||||
txt = (
|
||||
f"👤 <b>{t.name}</b>\n{'─'*24}\n"
|
||||
f"📧 {t.email}\n🆔 TG: <code>{t.telegram_id or '—'}</code>\n"
|
||||
f"📦 Тариф: <b>{plan_title(t.plan)}</b>{trial_txt}{postpaid_txt}\n🏪 Магазинов: {shops_n}\n\n"
|
||||
f"{bal_em(t.balance,t.alert_threshold)} Баланс: <b>{fmt(t.balance)}</b>\n"
|
||||
f"⚠️ Порог: {fmt(t.alert_threshold)}\n"
|
||||
f"Статус: {'🔒 Заблокирован' if t.is_blocked else '✅ Активен'}"
|
||||
)
|
||||
bld = InlineKeyboardBuilder()
|
||||
bld.row(
|
||||
InlineKeyboardButton(text="💳 Пополнить", callback_data=f"topup_{tid}"),
|
||||
InlineKeyboardButton(text="🧾 Тариф", callback_data=f"plan_{tid}"),
|
||||
)
|
||||
bld.row(InlineKeyboardButton(
|
||||
text="🔒 Блок" if not t.is_blocked else "🔓 Разблок",
|
||||
callback_data=f"blk_{tid}",
|
||||
))
|
||||
bld.row(InlineKeyboardButton(text="← Назад", callback_data="users_back"))
|
||||
await cb.message.edit_text(txt, parse_mode="HTML", reply_markup=bld.as_markup())
|
||||
await cb.answer()
|
||||
|
||||
@router.callback_query(F.data=="users_back")
|
||||
@guard
|
||||
async def users_back(cb: CallbackQuery):
|
||||
await users(cb.message); await cb.answer()
|
||||
|
||||
@router.callback_query(F.data.startswith("blk_"))
|
||||
@guard
|
||||
async def toggle_block(cb: CallbackQuery):
|
||||
tid = int(cb.data[4:])
|
||||
async with AsyncSessionLocal() as db:
|
||||
t = await db.get(Tenant, tid)
|
||||
if not t: await cb.answer("Не найден"); return
|
||||
t.is_blocked = not t.is_blocked
|
||||
await db.commit()
|
||||
await cb.answer("🔒 Заблокирован" if t.is_blocked else "🔓 Разблокирован", show_alert=True)
|
||||
await tenant_detail(cb, None)
|
||||
|
||||
|
||||
|
||||
|
||||
# ── Тарифы пользователей ─────────────────────────────────────────────────────
|
||||
def plan_keyboard(tid: int):
|
||||
bld = InlineKeyboardBuilder()
|
||||
bld.row(InlineKeyboardButton(text="🧪 Тест 7 дней", callback_data=f"setplan_{tid}_trial_week"))
|
||||
bld.row(InlineKeyboardButton(text="Trial", callback_data=f"setplan_{tid}_trial"),
|
||||
InlineKeyboardButton(text="Basic", callback_data=f"setplan_{tid}_basic"))
|
||||
bld.row(InlineKeyboardButton(text="Pro", callback_data=f"setplan_{tid}_pro"),
|
||||
InlineKeyboardButton(text="Enterprise", callback_data=f"setplan_{tid}_enterprise"))
|
||||
bld.row(InlineKeyboardButton(text="🧾 Индивидуальный постоплатный", callback_data=f"postpaid_{tid}"))
|
||||
bld.row(InlineKeyboardButton(text="← Назад", callback_data=f"t_{tid}"))
|
||||
return bld.as_markup()
|
||||
|
||||
|
||||
@router.callback_query(F.data.startswith("plan_"))
|
||||
@guard
|
||||
async def plan_menu(cb: CallbackQuery):
|
||||
tid = int(cb.data[5:])
|
||||
async with AsyncSessionLocal() as db:
|
||||
t = await db.get(Tenant, tid)
|
||||
if not t:
|
||||
await cb.answer("Не найден", show_alert=True)
|
||||
return
|
||||
await cb.message.edit_text(
|
||||
f"🧾 <b>Тариф пользователя</b>\n\n👤 {t.name}\nТекущий: <b>{plan_title(t.plan)}</b>\n\n"
|
||||
"Постоплатный тариф применяется только вручную администратором.",
|
||||
parse_mode="HTML",
|
||||
reply_markup=plan_keyboard(tid),
|
||||
)
|
||||
await cb.answer()
|
||||
|
||||
|
||||
@router.callback_query(F.data.startswith("setplan_"))
|
||||
@guard
|
||||
async def set_regular_plan(cb: CallbackQuery):
|
||||
raw = cb.data[len("setplan_"):]
|
||||
tid_raw, plan_raw = raw.split("_", 1)
|
||||
tid = int(tid_raw)
|
||||
if plan_raw not in {p.value for p in PlanEnum} or plan_raw == PlanEnum.postpaid_custom.value:
|
||||
await cb.answer("Некорректный тариф", show_alert=True)
|
||||
return
|
||||
async with AsyncSessionLocal() as db:
|
||||
t = await db.get(Tenant, tid)
|
||||
if not t:
|
||||
await cb.answer("Не найден", show_alert=True)
|
||||
return
|
||||
t.plan = PlanEnum(plan_raw)
|
||||
t.trial_ends_at = datetime.utcnow() + timedelta(days=7) if plan_raw == PlanEnum.trial_week.value else None
|
||||
t.postpaid_enabled_at = None
|
||||
t.postpaid_note = ""
|
||||
if t.is_blocked and t.balance > 0:
|
||||
t.is_blocked = False
|
||||
await db.commit()
|
||||
await cb.answer("Тариф применён", show_alert=True)
|
||||
await tenant_detail(cb, None)
|
||||
|
||||
|
||||
@router.callback_query(F.data.startswith("postpaid_"))
|
||||
@guard
|
||||
async def postpaid_start(cb: CallbackQuery, state: FSMContext):
|
||||
tid = int(cb.data[len("postpaid_"):])
|
||||
async with AsyncSessionLocal() as db:
|
||||
t = await db.get(Tenant, tid)
|
||||
if not t:
|
||||
await cb.answer("Не найден", show_alert=True)
|
||||
return
|
||||
await state.update_data(tid=tid, tname=t.name)
|
||||
await state.set_state(S.plan_percent)
|
||||
await cb.message.answer(
|
||||
f"🧾 Индивидуальная постоплата для <b>{t.name}</b>\n\n"
|
||||
"Введите процент комиссии от выручки предыдущего месяца, например: 5",
|
||||
parse_mode="HTML",
|
||||
reply_markup=ReplyKeyboardRemove(),
|
||||
)
|
||||
await cb.answer()
|
||||
|
||||
|
||||
@router.message(S.plan_percent)
|
||||
@guard
|
||||
async def postpaid_percent(msg: Message, state: FSMContext):
|
||||
try:
|
||||
percent = float(msg.text.replace(",", ".").replace(" ", ""))
|
||||
assert 0 < percent <= 100
|
||||
except Exception:
|
||||
await msg.answer("❌ Введите процент числом от 0.1 до 100, например: 5")
|
||||
return
|
||||
await state.update_data(percent=percent)
|
||||
await state.set_state(S.plan_due_day)
|
||||
await msg.answer("📅 Введите день месяца для оплаты комиссии: 1–28")
|
||||
|
||||
|
||||
@router.message(S.plan_due_day)
|
||||
@guard
|
||||
async def postpaid_due_day(msg: Message, state: FSMContext):
|
||||
try:
|
||||
day = int(msg.text.strip())
|
||||
assert 1 <= day <= 28
|
||||
except Exception:
|
||||
await msg.answer("❌ Введите день месяца числом от 1 до 28")
|
||||
return
|
||||
data = await state.get_data()
|
||||
async with AsyncSessionLocal() as db:
|
||||
t = await db.get(Tenant, data["tid"])
|
||||
if not t:
|
||||
await state.clear()
|
||||
await msg.answer("Пользователь не найден.", reply_markup=main_kb())
|
||||
return
|
||||
t.plan = PlanEnum.postpaid_custom
|
||||
t.trial_ends_at = None
|
||||
t.postpaid_commission_percent = float(data["percent"])
|
||||
t.postpaid_due_day = day
|
||||
t.postpaid_enabled_at = datetime.utcnow()
|
||||
t.postpaid_note = f"Назначено администратором {msg.from_user.id}"
|
||||
t.is_blocked = False
|
||||
await db.commit()
|
||||
await state.clear()
|
||||
await msg.answer(
|
||||
f"✅ Постоплатный тариф применён\n\n👤 {data['tname']}\n"
|
||||
f"Комиссия: <b>{data['percent']:g}%</b> от выручки прошлого месяца\n"
|
||||
f"Оплата: до <b>{day}</b> числа каждого месяца",
|
||||
parse_mode="HTML",
|
||||
reply_markup=main_kb(),
|
||||
)
|
||||
|
||||
|
||||
@router.message(F.text == "🧾 Постоплата")
|
||||
@guard
|
||||
async def postpaid_report(msg: Message):
|
||||
async with AsyncSessionLocal() as db:
|
||||
tenants = (await db.execute(
|
||||
select(Tenant).where(Tenant.plan == PlanEnum.postpaid_custom).order_by(Tenant.name.asc())
|
||||
)).scalars().all()
|
||||
if not tenants:
|
||||
await msg.answer("🧾 Постоплатных пользователей пока нет.")
|
||||
return
|
||||
lines = ["🧾 <b>Индивидуальная постоплата</b>\n"]
|
||||
for t in tenants:
|
||||
revenue, due = await postpaid_previous_month_totals(db, t.id)
|
||||
due_date = current_due_date(t)
|
||||
lines.append(
|
||||
f"👤 <b>{t.name}</b> · {t.postpaid_commission_percent:g}%\n"
|
||||
f" Выручка прошлого месяца: {fmt(revenue)}\n"
|
||||
f" К оплате: <b>{fmt(due)}</b> до {due_date:%d.%m.%Y}\n"
|
||||
)
|
||||
await msg.answer("\n".join(lines), parse_mode="HTML")
|
||||
|
||||
|
||||
# ── Балансы ───────────────────────────────────────────────────────────────────
|
||||
@router.message(F.text == "💰 Балансы")
|
||||
@guard
|
||||
async def balances(msg: Message):
|
||||
async with AsyncSessionLocal() as db:
|
||||
tenants = (await db.execute(
|
||||
select(Tenant).where(Tenant.is_active==True).order_by(Tenant.balance.asc())
|
||||
)).scalars().all()
|
||||
crit, low, ok_list = [], [], []
|
||||
for t in tenants:
|
||||
line = f"{bal_em(t.balance,t.alert_threshold)} <b>{t.name[:20]}</b>: {fmt(t.balance)}"
|
||||
if t.balance <= 0: crit.append(line + " 🔴 СТОП")
|
||||
elif t.balance <= t.alert_threshold: low.append(line)
|
||||
else: ok_list.append(line)
|
||||
txt = "💰 <b>Балансы</b>\n\n"
|
||||
if crit: txt += "🔴 <b>Критично:</b>\n" + "\n".join(crit) + "\n\n"
|
||||
if low: txt += "🟡 <b>Низкий:</b>\n" + "\n".join(low) + "\n\n"
|
||||
txt += f"🟢 В норме: {len(ok_list)}"
|
||||
await msg.answer(txt, parse_mode="HTML",
|
||||
reply_markup=ib(("🔄 Обновить","bal_ref")))
|
||||
|
||||
@router.callback_query(F.data=="bal_ref")
|
||||
@guard
|
||||
async def bal_ref(cb: CallbackQuery):
|
||||
await balances(cb.message); await cb.answer("Обновлено")
|
||||
|
||||
|
||||
# ── Пополнение баланса ────────────────────────────────────────────────────────
|
||||
@router.callback_query(F.data.startswith("topup_"))
|
||||
@guard
|
||||
async def topup_start(cb: CallbackQuery, state: FSMContext):
|
||||
tid = int(cb.data[6:])
|
||||
async with AsyncSessionLocal() as db:
|
||||
t = await db.get(Tenant, tid)
|
||||
await state.update_data(tid=tid, tname=t.name, tbal=t.balance)
|
||||
await state.set_state(S.topup_amount)
|
||||
await cb.message.answer(
|
||||
f"💳 Пополнение: <b>{t.name}</b>\nБаланс: {fmt(t.balance)}\n\nВведите сумму (₽):",
|
||||
parse_mode="HTML", reply_markup=ReplyKeyboardRemove())
|
||||
await cb.answer()
|
||||
|
||||
@router.message(S.topup_amount)
|
||||
@guard
|
||||
async def topup_amount(msg: Message, state: FSMContext):
|
||||
try:
|
||||
amt = float(msg.text.replace(",",".").replace(" ",""))
|
||||
assert amt > 0
|
||||
except:
|
||||
await msg.answer("❌ Введите сумму, например: 1000"); return
|
||||
await state.update_data(amt=amt)
|
||||
await state.set_state(S.topup_note)
|
||||
await msg.answer(f"Сумма: <b>{fmt(amt)}</b>\nКомментарий (или '-'):", parse_mode="HTML")
|
||||
|
||||
@router.message(S.topup_note)
|
||||
@guard
|
||||
async def topup_note(msg: Message, state: FSMContext):
|
||||
data = await state.get_data()
|
||||
note = msg.text if msg.text != "-" else "Пополнение администратором"
|
||||
amt = data["amt"]
|
||||
async with AsyncSessionLocal() as db:
|
||||
t = await db.get(Tenant, data["tid"])
|
||||
old = t.balance
|
||||
t.balance += amt
|
||||
if old <= 0 and t.balance > 0:
|
||||
t.is_blocked = False
|
||||
db.add(BalanceTransaction(
|
||||
tenant_id=t.id, type="deposit", amount=amt,
|
||||
balance_after=t.balance, note=f"[Admin] {note}"))
|
||||
await db.commit()
|
||||
await state.clear()
|
||||
await msg.answer(
|
||||
f"✅ <b>Пополнено</b>\n{data['tname']}\n+{fmt(amt)}\nБаланс: {fmt(t.balance)}\n{note}",
|
||||
parse_mode="HTML", reply_markup=main_kb())
|
||||
# Уведомить владельца
|
||||
if t.telegram_id:
|
||||
try:
|
||||
bot = Bot(token=settings.PLATFORM_BOT_TOKEN)
|
||||
await bot.send_message(t.telegram_id,
|
||||
f"💰 <b>Баланс пополнен!</b>\n+{fmt(amt)}\nВаш баланс: {fmt(t.balance)}\n{note}",
|
||||
parse_mode="HTML")
|
||||
await bot.session.close()
|
||||
except Exception: pass
|
||||
|
||||
|
||||
|
||||
|
||||
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:
|
||||
logger.warning("order %s: active shop token not found", order_id)
|
||||
return False
|
||||
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
|
||||
|
||||
|
||||
# ── Заказы ────────────────────────────────────────────────────────────────────
|
||||
@router.message(F.text == "📦 Заказы")
|
||||
@guard
|
||||
async def orders_menu(msg: Message):
|
||||
async with AsyncSessionLocal() as db:
|
||||
pending = (await db.execute(
|
||||
select(Order)
|
||||
.where(Order.status.in_([OrderStatus.pending, OrderStatus.confirming]))
|
||||
.order_by(Order.created_at.asc()).limit(10)
|
||||
)).scalars().all()
|
||||
if not pending:
|
||||
await msg.answer("✅ Ожидающих заказов нет."); return
|
||||
txt = f"📦 <b>На подтверждении: {len(pending)}</b>\n\n"
|
||||
bld = InlineKeyboardBuilder()
|
||||
for o in pending:
|
||||
em = "🟡" if o.status==OrderStatus.pending else "🟠"
|
||||
txt += f"{em} #{o.id} · {fmt(o.amount)} · {o.created_at:%d.%m %H:%M}\n"
|
||||
bld.add(InlineKeyboardButton(text=f"{em}#{o.id}·{o.amount:.0f}₽", callback_data=f"ord_{o.id}"))
|
||||
bld.adjust(3)
|
||||
await msg.answer(txt, parse_mode="HTML", reply_markup=bld.as_markup())
|
||||
|
||||
@router.callback_query(F.data.startswith("ord_"))
|
||||
@guard
|
||||
async def order_detail(cb: CallbackQuery):
|
||||
oid = int(cb.data[4:])
|
||||
async with AsyncSessionLocal() as db:
|
||||
o = await db.get(Order, oid)
|
||||
if not o: await cb.answer("Не найден", show_alert=True); return
|
||||
shop = await db.get(Shop, o.shop_id)
|
||||
txt = (
|
||||
f"📦 <b>Заказ #{o.id}</b>\n{'─'*24}\n"
|
||||
f"🏪 {shop.name if shop else '—'}\n"
|
||||
f"👤 {o.buyer_username or o.buyer_telegram_id}\n"
|
||||
f"💰 {fmt(o.amount)} | Комиссия: {fmt(o.commission)}\n"
|
||||
f"📅 {o.created_at:%d.%m.%Y %H:%M}"
|
||||
)
|
||||
bld = InlineKeyboardBuilder()
|
||||
if o.status in [OrderStatus.pending, OrderStatus.confirming]:
|
||||
bld.row(InlineKeyboardButton(text="✅ Подтвердить", callback_data=f"oc_{oid}"),
|
||||
InlineKeyboardButton(text="❌ Отклонить", callback_data=f"or_{oid}"))
|
||||
bld.row(InlineKeyboardButton(text="← Назад", callback_data="orders_back"))
|
||||
if o.proof_file_id:
|
||||
await cb.message.answer_photo(photo=o.proof_file_id, caption=txt,
|
||||
parse_mode="HTML", reply_markup=bld.as_markup())
|
||||
else:
|
||||
await cb.message.edit_text(txt, parse_mode="HTML", reply_markup=bld.as_markup())
|
||||
await cb.answer()
|
||||
|
||||
@router.callback_query(F.data.startswith("oc_"))
|
||||
@guard
|
||||
async def order_confirm(cb: CallbackQuery):
|
||||
oid = int(cb.data[3:])
|
||||
charged_now = False
|
||||
async with AsyncSessionLocal() as db:
|
||||
o = await db.get(Order, oid)
|
||||
if not o or o.status not in [OrderStatus.pending, OrderStatus.confirming]:
|
||||
await cb.answer("Нельзя подтвердить", show_alert=True)
|
||||
return
|
||||
shop = await db.get(Shop, o.shop_id)
|
||||
tenant = await db.get(Tenant, shop.tenant_id) if shop else None
|
||||
ok, reason = tenant_can_sell(tenant)
|
||||
if not ok:
|
||||
await cb.answer(f"❌ {reason}", show_alert=True)
|
||||
return
|
||||
|
||||
product = await db.get(Product, o.product_id)
|
||||
card = await db.get(PaymentCard, o.card_id) if o.card_id else None
|
||||
|
||||
o.status = OrderStatus.completed
|
||||
o.confirmed_by = cb.from_user.id
|
||||
|
||||
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 += o.amount
|
||||
|
||||
if tenant and not is_postpaid(tenant):
|
||||
old_balance = tenant.balance
|
||||
tenant.balance -= o.commission
|
||||
charged_now = True
|
||||
db.add(BalanceTransaction(
|
||||
tenant_id=tenant.id,
|
||||
type="commission",
|
||||
amount=-o.commission,
|
||||
balance_after=tenant.balance,
|
||||
note=f"Комиссия заказ #{oid}",
|
||||
order_id=oid,
|
||||
))
|
||||
if old_balance > 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"Начислена постоплатная комиссия {fmt(o.commission)} за заказ #{oid}",
|
||||
order_id=oid,
|
||||
))
|
||||
|
||||
shop_id = o.shop_id
|
||||
buyer_id = o.buyer_telegram_id
|
||||
content = o.product_content
|
||||
await db.commit()
|
||||
|
||||
delivered = await deliver_order(shop_id, buyer_id, content, oid)
|
||||
suffix = "комиссия списана" if charged_now else "комиссия начислена в постоплату"
|
||||
delivery = "товар выдан" if delivered else "товар НЕ отправлен — нет активного токена магазина"
|
||||
text = f"✅ Заказ #{oid} подтверждён: {delivery}, {suffix}."
|
||||
if getattr(cb.message, "photo", None):
|
||||
await cb.message.edit_caption(caption=text, parse_mode="HTML")
|
||||
else:
|
||||
await cb.message.edit_text(text, parse_mode="HTML")
|
||||
await cb.answer("✅ Подтверждено!")
|
||||
|
||||
@router.callback_query(F.data.startswith("or_"))
|
||||
@guard
|
||||
async def order_reject(cb: CallbackQuery):
|
||||
oid = int(cb.data[3:])
|
||||
async with AsyncSessionLocal() as db:
|
||||
o = await db.get(Order, oid)
|
||||
if o: o.status = OrderStatus.rejected; await db.commit()
|
||||
await cb.message.edit_text(f"❌ Заказ #{oid} отклонён.")
|
||||
await cb.answer("Отклонено")
|
||||
|
||||
@router.callback_query(F.data=="orders_back")
|
||||
@guard
|
||||
async def orders_back(cb: CallbackQuery):
|
||||
await orders_menu(cb.message); await cb.answer()
|
||||
|
||||
|
||||
# ── Рассылка ──────────────────────────────────────────────────────────────────
|
||||
@router.message(F.text == "📢 Рассылка")
|
||||
@guard
|
||||
async def broadcast_start(msg: Message, state: FSMContext):
|
||||
await state.set_state(S.broadcast)
|
||||
b = ReplyKeyboardBuilder(); b.add(KeyboardButton(text="❌ Отмена"))
|
||||
await msg.answer("📢 Введите текст рассылки (HTML):", reply_markup=b.as_markup(resize_keyboard=True))
|
||||
|
||||
@router.message(S.broadcast)
|
||||
@guard
|
||||
async def broadcast_text(msg: Message, state: FSMContext):
|
||||
if msg.text == "❌ Отмена":
|
||||
await state.clear(); await msg.answer("Отменено.", reply_markup=main_kb()); return
|
||||
await state.update_data(text=msg.text)
|
||||
await state.set_state(S.bcast_ok)
|
||||
async with AsyncSessionLocal() as db:
|
||||
n = (await db.execute(select(func.count(Tenant.id))
|
||||
.where(Tenant.telegram_id != None, Tenant.is_active==True))).scalar()
|
||||
bld = InlineKeyboardBuilder()
|
||||
bld.row(InlineKeyboardButton(text=f"✅ Отправить {n} польз.", callback_data="bcast_go"),
|
||||
InlineKeyboardButton(text="❌ Отмена", callback_data="bcast_no"))
|
||||
await msg.answer(f"Предпросмотр:\n\n{msg.text}\n\n👥 Получателей: {n}",
|
||||
parse_mode="HTML", reply_markup=bld.as_markup())
|
||||
|
||||
@router.callback_query(F.data=="bcast_go", S.bcast_ok)
|
||||
@guard
|
||||
async def broadcast_go(cb: CallbackQuery, state: FSMContext):
|
||||
data = await state.get_data(); await state.clear()
|
||||
async with AsyncSessionLocal() as db:
|
||||
tenants = (await db.execute(
|
||||
select(Tenant).where(Tenant.telegram_id!=None, Tenant.is_active==True)
|
||||
)).scalars().all()
|
||||
bot = Bot(token=settings.PLATFORM_BOT_TOKEN)
|
||||
sent = fail = 0
|
||||
for t in tenants:
|
||||
try:
|
||||
await bot.send_message(t.telegram_id, data["text"], parse_mode="HTML")
|
||||
sent += 1; await asyncio.sleep(0.05)
|
||||
except: fail += 1
|
||||
await bot.session.close()
|
||||
await cb.message.edit_text(f"📢 Рассылка завершена\n✅ {sent} · ❌ {fail}")
|
||||
await cb.answer()
|
||||
|
||||
@router.callback_query(F.data=="bcast_no")
|
||||
@guard
|
||||
async def broadcast_no(cb: CallbackQuery, state: FSMContext):
|
||||
await state.clear(); await cb.message.edit_text("Отменено."); await cb.answer()
|
||||
|
||||
|
||||
# ── Настройки ─────────────────────────────────────────────────────────────────
|
||||
@router.message(F.text == "⚙️ Настройки")
|
||||
@guard
|
||||
async def platform_settings(msg: Message):
|
||||
c = settings.commission
|
||||
txt = (
|
||||
f"⚙️ <b>Настройки платформы</b>\n\n"
|
||||
f"Комиссии:\n Тест 7 дней: {c['trial_week']}% | Trial: {c['trial']}% | Basic: {c['basic']}%\n"
|
||||
f" Pro: {c['pro']}% | Enterprise: {c['enterprise']}% | Постоплата default: {c['postpaid_custom']}%\n\n"
|
||||
f"API: {settings.API_HOST}:{settings.API_PORT}\n"
|
||||
f"Domain: {settings.DOMAIN or '—'}"
|
||||
)
|
||||
await msg.answer(txt, parse_mode="HTML")
|
||||
|
||||
|
||||
# ── Уведомления (вызываются из кода) ─────────────────────────────────────────
|
||||
async def notify_admins(bot: Bot, text: str):
|
||||
for aid in settings.admin_ids:
|
||||
try: await bot.send_message(aid, text, parse_mode="HTML")
|
||||
except Exception as e: logger.warning(f"notify {aid}: {e}")
|
||||
|
||||
async def notify_zero_balance(bot: Bot, tenant: Tenant):
|
||||
await notify_admins(bot,
|
||||
f"🔴 <b>Баланс исчерпан — боты остановлены!</b>\n\n"
|
||||
f"👤 {tenant.name} ({tenant.email})\n"
|
||||
f"💰 Баланс: {fmt(tenant.balance)}\n\n/topup_{tenant.id}")
|
||||
|
||||
async def notify_low_balance(bot: Bot, tenant: Tenant):
|
||||
await notify_admins(bot,
|
||||
f"🟡 <b>Низкий баланс</b>\n\n"
|
||||
f"👤 {tenant.name}\n"
|
||||
f"💰 Баланс: {fmt(tenant.balance)} (порог {fmt(tenant.alert_threshold)})")
|
||||
|
||||
|
||||
# ── Запуск ────────────────────────────────────────────────────────────────────
|
||||
async def start_platform_bot():
|
||||
if not settings.PLATFORM_BOT_TOKEN:
|
||||
logger.warning("PLATFORM_BOT_TOKEN не задан — платформенный бот отключён")
|
||||
return
|
||||
storage = RedisStorage.from_url(settings.REDIS_URL)
|
||||
bot = Bot(token=settings.PLATFORM_BOT_TOKEN)
|
||||
dp = Dispatcher(storage=storage)
|
||||
dp.include_router(router)
|
||||
logger.info("Platform admin bot started")
|
||||
try:
|
||||
await dp.start_polling(bot, allowed_updates=["message","callback_query"])
|
||||
finally:
|
||||
await bot.session.close()
|
||||
|
||||
if __name__ == "__main__":
|
||||
logging.basicConfig(level=logging.INFO)
|
||||
asyncio.run(start_platform_bot())
|
||||
@@ -0,0 +1,18 @@
|
||||
fastapi==0.111.0
|
||||
uvicorn[standard]==0.29.0
|
||||
aiogram==3.7.0
|
||||
sqlalchemy[asyncio]==2.0.30
|
||||
asyncpg==0.29.0
|
||||
alembic==1.13.1
|
||||
redis==5.0.4
|
||||
aioredis==2.0.1
|
||||
passlib[bcrypt]==1.7.4
|
||||
python-jose[cryptography]==3.3.0
|
||||
python-multipart==0.0.9
|
||||
httpx==0.27.0
|
||||
pydantic==2.7.1
|
||||
pydantic-settings==2.3.0
|
||||
python-dotenv==1.0.1
|
||||
aiofiles==23.2.1
|
||||
pillow==10.3.0
|
||||
uvloop==0.19.0
|
||||
@@ -0,0 +1,216 @@
|
||||
"""BotFactory — Shop Customer Bot (бот магазина для покупателей)"""
|
||||
import asyncio, logging, random
|
||||
from datetime import datetime
|
||||
from aiogram import Bot, Dispatcher, F, Router
|
||||
from aiogram.filters import Command
|
||||
from aiogram.fsm.context import FSMContext
|
||||
from aiogram.fsm.state import State, StatesGroup
|
||||
from aiogram.fsm.storage.redis import RedisStorage
|
||||
from aiogram.types import (Message, CallbackQuery, InlineKeyboardButton,
|
||||
KeyboardButton, ReplyKeyboardRemove)
|
||||
from aiogram.utils.keyboard import InlineKeyboardBuilder, ReplyKeyboardBuilder
|
||||
from sqlalchemy import select, desc
|
||||
from config import settings
|
||||
from database import AsyncSessionLocal
|
||||
from models import Shop, ShopToken, Product, PaymentCard, Order, OrderStatus, Tenant
|
||||
from billing import commission_rate, tenant_can_sell
|
||||
|
||||
logger = logging.getLogger("shop_bot")
|
||||
|
||||
|
||||
def fmt(v: float) -> str:
|
||||
return f"{v:,.0f}".replace(",", " ") + " ₽"
|
||||
|
||||
|
||||
class SS(StatesGroup):
|
||||
waiting_proof = State()
|
||||
|
||||
|
||||
class ShopCustomerBot:
|
||||
def __init__(self, shop_id: int, shop_name: str):
|
||||
self.shop_id = shop_id
|
||||
self.name = shop_name
|
||||
self.router = Router()
|
||||
self._reg()
|
||||
|
||||
def _reg(self):
|
||||
r=self.router; sid=self.shop_id
|
||||
|
||||
def main_kb():
|
||||
b=ReplyKeyboardBuilder()
|
||||
b.row(KeyboardButton(text="🛍 Каталог"))
|
||||
b.row(KeyboardButton(text="📦 Мои заказы"), KeyboardButton(text="💬 Поддержка"))
|
||||
return b.as_markup(resize_keyboard=True)
|
||||
|
||||
@r.message(Command("start"))
|
||||
async def start(msg: Message, state: FSMContext):
|
||||
await state.clear()
|
||||
async with AsyncSessionLocal() as db:
|
||||
shop=await db.get(Shop,sid); tenant=await db.get(Tenant,shop.tenant_id) if shop else None
|
||||
ok, reason = tenant_can_sell(tenant)
|
||||
if not shop or not ok:
|
||||
await msg.answer(f"⚠️ {reason or 'Магазин временно приостановлен.'}"); return
|
||||
await msg.answer(f"{shop.welcome_msg}\n\n<b>{shop.name}</b>",
|
||||
parse_mode="HTML", reply_markup=main_kb())
|
||||
|
||||
@r.message(F.text=="🛍 Каталог")
|
||||
async def catalog(msg: Message):
|
||||
async with AsyncSessionLocal() as db:
|
||||
prods=(await db.execute(
|
||||
select(Product).where(Product.shop_id==sid, Product.is_active==True, Product.stock>0)
|
||||
.order_by(Product.category, Product.name)
|
||||
)).scalars().all()
|
||||
if not prods:
|
||||
await msg.answer("🛍 Каталог пуст."); return
|
||||
cats: dict = {}
|
||||
for p in prods: cats.setdefault(p.category,[]).append(p)
|
||||
bld=InlineKeyboardBuilder(); txt="🛍 <b>Каталог</b>\n\n"
|
||||
for cat,ps in cats.items():
|
||||
txt+=f"📂 <b>{cat}</b>\n"
|
||||
for p in ps:
|
||||
txt+=f" • {p.name} — <b>{fmt(p.price)}</b>\n"
|
||||
bld.add(InlineKeyboardButton(text=f"🛒 {p.name[:28]}", callback_data=f"buy_{p.id}"))
|
||||
txt+="\n"
|
||||
bld.adjust(1)
|
||||
await msg.answer(txt, parse_mode="HTML", reply_markup=bld.as_markup())
|
||||
|
||||
@r.callback_query(F.data.startswith("buy_"))
|
||||
async def product_card(cb: CallbackQuery):
|
||||
pid=int(cb.data[4:])
|
||||
async with AsyncSessionLocal() as db:
|
||||
p=await db.get(Product,pid)
|
||||
if not p or not p.is_active or p.shop_id!=sid or p.stock <= 0:
|
||||
await cb.answer("Товар недоступен",show_alert=True); return
|
||||
bld=InlineKeyboardBuilder()
|
||||
bld.row(InlineKeyboardButton(text=f"✅ Купить за {fmt(p.price)}", callback_data=f"order_{p.id}"),
|
||||
InlineKeyboardButton(text="← Назад", callback_data="back_cat"))
|
||||
txt=f"🛍 <b>{p.name}</b>\n{'─'*22}\n💰 <b>{fmt(p.price)}</b>\n📂 {p.category}\n\n{p.description}"
|
||||
if p.photo_url:
|
||||
await cb.message.answer_photo(photo=p.photo_url, caption=txt,
|
||||
parse_mode="HTML", reply_markup=bld.as_markup())
|
||||
else:
|
||||
await cb.message.answer(txt, parse_mode="HTML", reply_markup=bld.as_markup())
|
||||
await cb.answer()
|
||||
|
||||
@r.callback_query(F.data=="back_cat")
|
||||
async def back_cat(cb: CallbackQuery):
|
||||
await catalog(cb.message); await cb.answer()
|
||||
|
||||
@r.callback_query(F.data.startswith("order_"))
|
||||
async def create_order(cb: CallbackQuery, state: FSMContext):
|
||||
pid=int(cb.data[6:])
|
||||
async with AsyncSessionLocal() as db:
|
||||
p=await db.get(Product,pid)
|
||||
if not p or not p.is_active or p.shop_id!=sid or p.stock <= 0:
|
||||
await cb.answer("Недоступен",show_alert=True); return
|
||||
cards=(await db.execute(
|
||||
select(PaymentCard).where(PaymentCard.shop_id==sid, PaymentCard.is_active==True)
|
||||
)).scalars().all()
|
||||
if not cards:
|
||||
await cb.answer("Оплата временно недоступна.",show_alert=True); return
|
||||
card=random.choice(cards)
|
||||
shop=await db.get(Shop,sid); tenant=await db.get(Tenant,shop.tenant_id)
|
||||
ok, reason = tenant_can_sell(tenant)
|
||||
if not ok:
|
||||
await cb.answer(reason, show_alert=True); return
|
||||
rate=commission_rate(tenant)
|
||||
commission=round(p.price*rate/100, 2)
|
||||
order=Order(shop_id=sid, product_id=p.id, card_id=card.id,
|
||||
buyer_telegram_id=cb.from_user.id,
|
||||
buyer_username=cb.from_user.username or "",
|
||||
amount=p.price, commission=commission,
|
||||
status=OrderStatus.pending, product_content=p.content)
|
||||
db.add(order); card.orders_count+=1; await db.flush()
|
||||
oid=order.id; await db.commit()
|
||||
await state.update_data(order_id=oid); await state.set_state(SS.waiting_proof)
|
||||
pay_txt=(
|
||||
f"💳 <b>Заказ #{oid}</b>\n{'─'*24}\n"
|
||||
f"🛍 {p.name}\n💰 <b>{fmt(p.price)}</b>\n\n"
|
||||
f"<b>Переводите на карту:</b>\n"
|
||||
f"🏦 {card.bank}\n"
|
||||
f"💳 <code>{card.number}</code>\n"
|
||||
f"👤 {card.holder}\n"
|
||||
+(f"📱 СБП: <code>{card.phone}</code>\n" if card.phone else "")
|
||||
+f"\n📝 Назначение: <code>Заказ #{oid}</code>\n\n"
|
||||
f"📸 После оплаты пришлите <b>скриншот перевода</b>.\n"
|
||||
f"⏱ Товар выдаётся после проверки кассиром."
|
||||
)
|
||||
bld=InlineKeyboardBuilder()
|
||||
bld.add(InlineKeyboardButton(text="❌ Отменить заказ", callback_data=f"cancel_{oid}"))
|
||||
await cb.message.answer(pay_txt, parse_mode="HTML", reply_markup=bld.as_markup())
|
||||
await cb.answer()
|
||||
|
||||
@r.message(SS.waiting_proof, F.photo)
|
||||
async def receive_proof(msg: Message, state: FSMContext):
|
||||
data=await state.get_data(); oid=data.get("order_id")
|
||||
if not oid: await state.clear(); return
|
||||
file_id=msg.photo[-1].file_id
|
||||
async with AsyncSessionLocal() as db:
|
||||
o=await db.get(Order,oid)
|
||||
if not o or o.buyer_telegram_id!=msg.from_user.id:
|
||||
await msg.answer("❌ Заказ не найден."); await state.clear(); return
|
||||
o.status=OrderStatus.confirming; o.proof_file_id=file_id
|
||||
shop=await db.get(Shop,sid); await db.commit()
|
||||
await state.clear()
|
||||
await msg.answer(
|
||||
f"✅ Скриншот получен!\nЗаказ #{oid} на проверке.\n⏱ До 15 минут.",
|
||||
reply_markup=ReplyKeyboardRemove())
|
||||
# Уведомить кассиров
|
||||
await self._notify(shop, oid, o.amount, msg.from_user.username or str(msg.from_user.id), file_id)
|
||||
|
||||
@r.message(SS.waiting_proof)
|
||||
async def proof_not_photo(msg: Message):
|
||||
await msg.answer("📸 Пришлите скриншот перевода (фото).")
|
||||
|
||||
@r.callback_query(F.data.startswith("cancel_"))
|
||||
async def cancel(cb: CallbackQuery, state: FSMContext):
|
||||
oid=int(cb.data[7:])
|
||||
async with AsyncSessionLocal() as db:
|
||||
o=await db.get(Order,oid)
|
||||
if o and o.buyer_telegram_id==cb.from_user.id and o.status==OrderStatus.pending:
|
||||
o.status=OrderStatus.cancelled; await db.commit()
|
||||
await state.clear(); await cb.message.edit_text("❌ Заказ отменён."); await cb.answer()
|
||||
|
||||
@r.message(F.text=="📦 Мои заказы")
|
||||
async def my_orders(msg: Message):
|
||||
async with AsyncSessionLocal() as db:
|
||||
ords=(await db.execute(
|
||||
select(Order).where(Order.shop_id==sid, Order.buyer_telegram_id==msg.from_user.id)
|
||||
.order_by(desc(Order.created_at)).limit(10)
|
||||
)).scalars().all()
|
||||
if not ords: await msg.answer("📦 Заказов нет."); return
|
||||
em_map={OrderStatus.pending:"🟡",OrderStatus.confirming:"🟠",
|
||||
OrderStatus.completed:"✅",OrderStatus.rejected:"❌",OrderStatus.cancelled:"🚫"}
|
||||
txt="📦 <b>Ваши заказы:</b>\n\n"
|
||||
for o in ords:
|
||||
txt+=f"{em_map.get(o.status,'❓')} #{o.id} · {fmt(o.amount)} · {o.created_at:%d.%m %H:%M}\n"
|
||||
await msg.answer(txt, parse_mode="HTML")
|
||||
|
||||
@r.message(F.text=="💬 Поддержка")
|
||||
async def support(msg: Message):
|
||||
await msg.answer(f"💬 По вопросам — обращайтесь к администрации {self.name}.")
|
||||
|
||||
async def _notify(self, shop, oid: int, amount: float, buyer: str, file_id: str):
|
||||
if not shop or not shop.ctrl_bot_token: return
|
||||
try:
|
||||
from ctrl_bot import ShopControlBot
|
||||
ctrl=ShopControlBot(self.shop_id, shop.ctrl_bot_token, self.name)
|
||||
await ctrl.notify_cashiers(oid, amount, buyer, file_id)
|
||||
except Exception as e:
|
||||
logger.error(f"_notify: {e}")
|
||||
|
||||
async def run(self):
|
||||
async with AsyncSessionLocal() as db:
|
||||
tok=(await db.execute(
|
||||
select(ShopToken).where(ShopToken.shop_id==self.shop_id, ShopToken.is_active==True)
|
||||
)).scalar_one_or_none()
|
||||
if not tok:
|
||||
logger.warning(f"shop_id={self.shop_id}: нет активного токена"); await asyncio.sleep(30); return
|
||||
storage=RedisStorage.from_url(settings.REDIS_URL+f"?db={10+self.shop_id%4}")
|
||||
bot=Bot(token=tok.token); dp=Dispatcher(storage=storage)
|
||||
dp.include_router(self.router)
|
||||
logger.info(f"Shop bot: {self.name} ({tok.username})")
|
||||
try:
|
||||
await dp.start_polling(bot, allowed_updates=["message","callback_query"])
|
||||
finally:
|
||||
await bot.session.close()
|
||||
@@ -0,0 +1,129 @@
|
||||
"""
|
||||
BotFactory — Bots Runner
|
||||
Запускает платформенный бот, боты управления магазинами и клиентские боты.
|
||||
Следит за сменой токенов и перезапускает конкретный бот без рестарта сервера.
|
||||
"""
|
||||
import asyncio
|
||||
import logging
|
||||
from sqlalchemy import select
|
||||
from database import AsyncSessionLocal, init_db
|
||||
from models import Shop, ShopToken
|
||||
|
||||
logger = logging.getLogger("runner")
|
||||
|
||||
|
||||
class BotManager:
|
||||
def __init__(self):
|
||||
self._tasks: dict[str, asyncio.Task] = {}
|
||||
self._sigs: dict[str, str] = {}
|
||||
|
||||
async def _safe_run(self, fn, key: str):
|
||||
delay = 5
|
||||
while True:
|
||||
try:
|
||||
await fn()
|
||||
except asyncio.CancelledError:
|
||||
logger.info("[%s] stopped", key)
|
||||
return
|
||||
except Exception as e:
|
||||
logger.error("[%s] crashed: %s — retry in %ss", key, e, delay)
|
||||
await asyncio.sleep(delay)
|
||||
delay = min(delay * 2, 120)
|
||||
else:
|
||||
await asyncio.sleep(5)
|
||||
|
||||
async def _replace_task(self, key: str, signature: str, factory):
|
||||
old_sig = self._sigs.get(key)
|
||||
old_task = self._tasks.get(key)
|
||||
if old_task and not old_task.done() and old_sig == signature:
|
||||
return
|
||||
|
||||
if old_task and not old_task.done():
|
||||
logger.info("[%s] signature changed, restarting", key)
|
||||
old_task.cancel()
|
||||
try:
|
||||
await old_task
|
||||
except asyncio.CancelledError:
|
||||
pass
|
||||
|
||||
bot_obj = factory()
|
||||
self._sigs[key] = signature
|
||||
self._tasks[key] = asyncio.create_task(
|
||||
self._safe_run(bot_obj.run, key), name=key
|
||||
)
|
||||
logger.info("[%s] started", key)
|
||||
|
||||
async def start_platform(self):
|
||||
key = "platform"
|
||||
if key in self._tasks and not self._tasks[key].done():
|
||||
return
|
||||
from platform_bot import start_platform_bot
|
||||
self._sigs[key] = "platform"
|
||||
self._tasks[key] = asyncio.create_task(
|
||||
self._safe_run(start_platform_bot, key), name=key
|
||||
)
|
||||
logger.info("[platform] started")
|
||||
|
||||
async def start_shop(self, shop: Shop, active_token: ShopToken | None):
|
||||
from ctrl_bot import ShopControlBot
|
||||
from shop_bot import ShopCustomerBot
|
||||
|
||||
await self._replace_task(
|
||||
key=f"ctrl_{shop.id}",
|
||||
signature=f"ctrl:{shop.id}:{shop.ctrl_bot_token}",
|
||||
factory=lambda: ShopControlBot(shop.id, shop.ctrl_bot_token, shop.name),
|
||||
)
|
||||
|
||||
if active_token:
|
||||
await self._replace_task(
|
||||
key=f"shop_{shop.id}",
|
||||
signature=f"shop:{shop.id}:{active_token.token}",
|
||||
factory=lambda: ShopCustomerBot(shop.id, shop.name),
|
||||
)
|
||||
else:
|
||||
key = f"shop_{shop.id}"
|
||||
task = self._tasks.get(key)
|
||||
if task and not task.done():
|
||||
task.cancel()
|
||||
logger.info("[%s] stopped: active token missing", key)
|
||||
|
||||
async def sync(self):
|
||||
async with AsyncSessionLocal() as db:
|
||||
shops = (await db.execute(
|
||||
select(Shop).where(Shop.is_active == True)
|
||||
)).scalars().all()
|
||||
active_tokens = {}
|
||||
for shop in shops:
|
||||
active_tokens[shop.id] = (await db.execute(
|
||||
select(ShopToken).where(
|
||||
ShopToken.shop_id == shop.id,
|
||||
ShopToken.is_active == True,
|
||||
)
|
||||
)).scalar_one_or_none()
|
||||
|
||||
active_keys = {"platform"}
|
||||
for shop in shops:
|
||||
active_keys.add(f"ctrl_{shop.id}")
|
||||
if active_tokens.get(shop.id):
|
||||
active_keys.add(f"shop_{shop.id}")
|
||||
await self.start_shop(shop, active_tokens.get(shop.id))
|
||||
|
||||
for key, task in list(self._tasks.items()):
|
||||
if key not in active_keys and not task.done():
|
||||
task.cancel()
|
||||
logger.info("[%s] stopped: no longer active", key)
|
||||
|
||||
async def run(self):
|
||||
await init_db()
|
||||
logger.info("BotManager starting...")
|
||||
await self.start_platform()
|
||||
await self.sync()
|
||||
while True:
|
||||
await asyncio.sleep(30)
|
||||
await self.sync()
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
logging.basicConfig(level=logging.INFO,
|
||||
format="%(asctime)s [%(levelname)s] %(name)s: %(message)s")
|
||||
asyncio.run(BotManager().run())
|
||||
@@ -0,0 +1,14 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="ru">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<meta name="theme-color" content="#07080a" />
|
||||
<title>BotFactory</title>
|
||||
<link rel="icon" href="data:image/svg+xml,<svg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 100 100'><text y='.9em' font-size='90'>🤖</text></svg>" />
|
||||
</head>
|
||||
<body>
|
||||
<div id="root"></div>
|
||||
<script type="module" src="/src/main.jsx"></script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,21 @@
|
||||
{
|
||||
"name": "botfactory-frontend",
|
||||
"version": "2.1.0",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "vite --port 3000",
|
||||
"build": "vite build",
|
||||
"preview": "vite preview --port 3000"
|
||||
},
|
||||
"dependencies": {
|
||||
"react": "^18.3.1",
|
||||
"react-dom": "^18.3.1",
|
||||
"react-router-dom": "^6.23.1",
|
||||
"axios": "^1.7.2"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@vitejs/plugin-react": "^4.3.1",
|
||||
"vite": "^5.2.12"
|
||||
}
|
||||
}
|
||||
Разница между файлами не показана из-за своего большого размера
Загрузить разницу
@@ -0,0 +1,9 @@
|
||||
import React from 'react'
|
||||
import ReactDOM from 'react-dom/client'
|
||||
import App from './App.jsx'
|
||||
|
||||
ReactDOM.createRoot(document.getElementById('root')).render(
|
||||
<React.StrictMode>
|
||||
<App />
|
||||
</React.StrictMode>
|
||||
)
|
||||
@@ -0,0 +1,8 @@
|
||||
import { defineConfig } from 'vite'
|
||||
import react from '@vitejs/plugin-react'
|
||||
|
||||
export default defineConfig({
|
||||
plugins: [react()],
|
||||
server: { port: 3000 },
|
||||
build: { outDir: 'dist', sourcemap: false },
|
||||
})
|
||||
@@ -0,0 +1,549 @@
|
||||
#!/usr/bin/env bash
|
||||
# =============================================================================
|
||||
# BotFactory — SaaS Platform Interactive Installer v2.1
|
||||
# Ubuntu 22.04 / 24.04 clean server installer
|
||||
# =============================================================================
|
||||
set -euo pipefail
|
||||
IFS=$'\n\t'
|
||||
|
||||
R='\033[0;31m'; G='\033[0;32m'; Y='\033[1;33m'
|
||||
C='\033[0;36m'; B='\033[1m'; DIM='\033[2m'; NC='\033[0m'
|
||||
OK="${G}[✓]${NC}"; ERR="${R}[✗]${NC}"; WARN="${Y}[!]${NC}"; INFO="${C}[→]${NC}"
|
||||
hdr() { echo -e "\n${B}${C}━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━\n $1\n━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━${NC}\n"; }
|
||||
log() { echo -e "${OK} $1"; }
|
||||
info() { echo -e "${INFO} $1"; }
|
||||
warn() { echo -e "${WARN} $1"; }
|
||||
die() { echo -e "${ERR} $1"; exit 1; }
|
||||
|
||||
[[ $EUID -ne 0 ]] && die "Запустите от root: sudo bash install.sh"
|
||||
|
||||
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||||
[[ ! -d "${SCRIPT_DIR}/backend" ]] && die "Запустите из папки проекта: cd botfactory && sudo bash install.sh"
|
||||
|
||||
. /etc/os-release 2>/dev/null || true
|
||||
if [[ "${ID:-}" != "ubuntu" ]]; then
|
||||
warn "Скрипт рассчитан на Ubuntu 22.04/24.04. Текущая ОС: ${PRETTY_NAME:-неизвестна}"
|
||||
read -rp "Продолжить? [y/N]: " _c; [[ "$_c" =~ ^[Yy]$ ]] || exit 0
|
||||
elif [[ "${VERSION_ID:-}" != "22.04" && "${VERSION_ID:-}" != "24.04" ]]; then
|
||||
warn "Проверены Ubuntu 22.04 и 24.04. Текущая версия: ${VERSION_ID:-неизвестна}"
|
||||
read -rp "Продолжить? [y/N]: " _c; [[ "$_c" =~ ^[Yy]$ ]] || exit 0
|
||||
fi
|
||||
|
||||
clear
|
||||
echo -e "${B}${C}"
|
||||
cat << 'BANNER'
|
||||
____ _ _____ _
|
||||
| __ ) ___| |_| ___|_ _ ___| |_ ___ _ __ _ _
|
||||
| _ \ / _ \ __| |_ / _` |/ __| __/ _ \| '__| | | |
|
||||
| |_) | __/ |_| _| (_| | (__| || (_) | | | |_| |
|
||||
|____/ \___|\__|_| \__,_|\___|\__\___/|_| \__, |
|
||||
|___/
|
||||
BANNER
|
||||
echo -e "${NC}${B} SaaS Platform — Interactive Installer v2.1${NC}\n"
|
||||
|
||||
ask() {
|
||||
local var="$1" prompt="$2" default="${3:-}" secret="${4:-}"
|
||||
local val=""
|
||||
while [[ -z "$val" ]]; do
|
||||
if [[ -n "$default" ]]; then
|
||||
echo -ne "${C}?${NC} ${B}${prompt}${NC} ${DIM}[${default}]${NC}: "
|
||||
else
|
||||
echo -ne "${C}?${NC} ${B}${prompt}${NC}: "
|
||||
fi
|
||||
if [[ "$secret" == "secret" ]]; then
|
||||
read -rs val; echo ""
|
||||
else
|
||||
read -r val
|
||||
fi
|
||||
val="${val:-$default}"
|
||||
[[ -z "$val" ]] && echo -e " ${WARN} Поле обязательно для заполнения"
|
||||
done
|
||||
printf -v "$var" '%s' "$val"
|
||||
}
|
||||
|
||||
ask_optional() {
|
||||
local var="$1" prompt="$2" default="${3:-}"
|
||||
if [[ -n "$default" ]]; then
|
||||
echo -ne "${C}?${NC} ${B}${prompt}${NC} ${DIM}[${default}]${NC}: "
|
||||
else
|
||||
echo -ne "${C}?${NC} ${B}${prompt}${NC}: "
|
||||
fi
|
||||
local val=""; read -r val
|
||||
printf -v "$var" '%s' "${val:-$default}"
|
||||
}
|
||||
|
||||
ask_opt() {
|
||||
local var="$1" prompt="$2" opts="${3:-Y/n}"
|
||||
echo -ne "${C}?${NC} ${B}${prompt}${NC} ${DIM}[${opts}]${NC}: "
|
||||
local ans=""; read -r ans
|
||||
if [[ "$opts" == "Y/n" ]]; then
|
||||
[[ "$ans" =~ ^[Nn] ]] && printf -v "$var" 'no' || printf -v "$var" 'yes'
|
||||
else
|
||||
[[ "$ans" =~ ^[Yy] ]] && printf -v "$var" 'yes' || printf -v "$var" 'no'
|
||||
fi
|
||||
}
|
||||
|
||||
valid_number() { [[ "$1" =~ ^[0-9]+([.][0-9]+)?$ ]]; }
|
||||
|
||||
hdr "Шаг 1 из 3 — Конфигурация"
|
||||
|
||||
echo -e "\n${B}▸ Домен и SSL${NC}"
|
||||
echo -e "${DIM} Если домена нет — оставьте пустым, будет HTTP на IP-адресе${NC}\n"
|
||||
ask_optional DOMAIN "Доменное имя" ""
|
||||
DOMAIN="$(echo "$DOMAIN" | tr '[:upper:]' '[:lower:]' | sed -E 's#^https?://##;s#/$##')"
|
||||
|
||||
if [[ -n "$DOMAIN" ]]; then
|
||||
ask CERTBOT_EMAIL "Email для SSL-сертификата" "admin@${DOMAIN}"
|
||||
ask_opt WITH_WWW "Включить www.${DOMAIN} в сертификат?" "Y/n"
|
||||
ask_opt SETUP_SSL "Получить SSL-сертификат сейчас?" "Y/n"
|
||||
else
|
||||
CERTBOT_EMAIL=""; WITH_WWW="no"; SETUP_SSL="no"
|
||||
warn "Домен не задан — установка на HTTP (SSL можно добавить позже)"
|
||||
fi
|
||||
|
||||
echo -e "\n${B}▸ Telegram — платформенный бот${NC}"
|
||||
echo -e "${DIM} Создайте бота через @BotFather → /newbot → скопируйте токен${NC}\n"
|
||||
ask PLATFORM_BOT_TOKEN "Токен платформенного бота" ""
|
||||
echo -e "${DIM} Несколько ID через запятую. Свой ID узнайте у @userinfobot${NC}\n"
|
||||
ask PLATFORM_ADMIN_IDS "Telegram ID администраторов" ""
|
||||
|
||||
echo -e "\n${B}▸ База данных PostgreSQL${NC}\n"
|
||||
ask_opt CUSTOM_DB_PASS "Задать пароль БД вручную?" "y/N"
|
||||
if [[ "$CUSTOM_DB_PASS" == "yes" ]]; then
|
||||
ask DB_PASS "Пароль PostgreSQL" "" secret
|
||||
else
|
||||
DB_PASS="$(openssl rand -hex 16)"
|
||||
log "Пароль БД сгенерирован автоматически"
|
||||
fi
|
||||
|
||||
echo -e "\n${B}▸ Комиссии платформы${NC}\n"
|
||||
ask_opt CUSTOM_COMMISSIONS "Изменить комиссии? (Тест 7д 10% / Trial 10% / Basic 7% / Pro 5% / Ent 3% / Постоплата default 5%)" "y/N"
|
||||
if [[ "$CUSTOM_COMMISSIONS" == "yes" ]]; then
|
||||
ask COMM_TRIAL_WEEK "Тест 7 дней (%)" "10"
|
||||
ask COMM_TRIAL "Trial (%)" "10"
|
||||
ask COMM_BASIC "Basic (%)" "7"
|
||||
ask COMM_PRO "Pro (%)" "5"
|
||||
ask COMM_ENTERPRISE "Enterprise (%)" "3"
|
||||
ask COMM_POSTPAID_DEFAULT "Постоплата default (%)" "5"
|
||||
ask POSTPAID_DUE_DAY "День оплаты постоплаты по умолчанию (1-28)" "5"
|
||||
else
|
||||
COMM_TRIAL_WEEK=10; COMM_TRIAL=10; COMM_BASIC=7; COMM_PRO=5; COMM_ENTERPRISE=3
|
||||
COMM_POSTPAID_DEFAULT=5; POSTPAID_DUE_DAY=5
|
||||
fi
|
||||
|
||||
for n in "$COMM_TRIAL_WEEK" "$COMM_TRIAL" "$COMM_BASIC" "$COMM_PRO" "$COMM_ENTERPRISE" "$COMM_POSTPAID_DEFAULT"; do
|
||||
valid_number "$n" || die "Комиссии должны быть числами"
|
||||
done
|
||||
[[ "$POSTPAID_DUE_DAY" =~ ^[0-9]+$ ]] && (( POSTPAID_DUE_DAY >= 1 && POSTPAID_DUE_DAY <= 28 )) || die "День оплаты постоплаты должен быть от 1 до 28"
|
||||
|
||||
APP_DIR="/opt/botfactory"
|
||||
APP_USER="botfactory"
|
||||
DB_NAME="botfactory"
|
||||
DB_USER="botfactory"
|
||||
API_PORT=8000
|
||||
SECRET_KEY="$(openssl rand -hex 32)"
|
||||
VENV="${APP_DIR}/venv"
|
||||
FRONT_DIR="${APP_DIR}/frontend"
|
||||
PYTHON_BIN="$(command -v python3 || true)"
|
||||
|
||||
hdr "Шаг 2 из 3 — Подтверждение"
|
||||
|
||||
SERVER_IP="$(curl -4 -s --max-time 4 ifconfig.me 2>/dev/null || hostname -I | awk '{print $1}')"
|
||||
[[ -z "$SERVER_IP" ]] && SERVER_IP="$(hostname -I | awk '{print $1}')"
|
||||
|
||||
echo -e "${B}Параметры установки:${NC}\n"
|
||||
printf " %-25s %s\n" "Директория:" "${APP_DIR}"
|
||||
printf " %-25s %s\n" "IP сервера:" "${SERVER_IP}"
|
||||
printf " %-25s %s\n" "Домен:" "${DOMAIN:-"(нет — HTTP)"}"
|
||||
printf " %-25s %s\n" "SSL:" "${SETUP_SSL}"
|
||||
[[ -n "$DOMAIN" ]] && printf " %-25s %s\n" "www в сертификат:" "${WITH_WWW}"
|
||||
[[ -n "$DOMAIN" ]] && printf " %-25s %s\n" "Email SSL:" "${CERTBOT_EMAIL}"
|
||||
printf " %-25s %s\n" "Platform Bot Token:" "${PLATFORM_BOT_TOKEN:0:24}…"
|
||||
printf " %-25s %s\n" "Admin Telegram IDs:" "${PLATFORM_ADMIN_IDS}"
|
||||
printf " %-25s %s\n" "DB пароль:" "${DB_PASS:0:8}… (скрыт)"
|
||||
printf " %-25s %s\n" "Комиссии:" "Тест7д ${COMM_TRIAL_WEEK}% | Trial ${COMM_TRIAL}% | Basic ${COMM_BASIC}% | Pro ${COMM_PRO}% | Ent ${COMM_ENTERPRISE}% | Postpay ${COMM_POSTPAID_DEFAULT}%"
|
||||
printf " %-25s %s\n" "Постоплата default:" "до ${POSTPAID_DUE_DAY} числа"
|
||||
echo ""
|
||||
read -rp "$(echo -e "${B}Начать установку?${NC} [y/N]: ")" START
|
||||
[[ "$START" =~ ^[Yy]$ ]] || { echo "Отменено."; exit 0; }
|
||||
|
||||
hdr "Шаг 3 из 3 — Установка"
|
||||
START_TIME=$(date +%s)
|
||||
step() { echo -e "\n${B}${C} ▸ $1${NC}"; }
|
||||
|
||||
export DEBIAN_FRONTEND=noninteractive
|
||||
export NEEDRESTART_MODE=a
|
||||
|
||||
wait_for_apt() {
|
||||
local locks=(/var/lib/dpkg/lock-frontend /var/lib/dpkg/lock /var/cache/apt/archives/lock)
|
||||
for i in {1..60}; do
|
||||
local busy=0
|
||||
for lock in "${locks[@]}"; do
|
||||
fuser "$lock" >/dev/null 2>&1 && busy=1 || true
|
||||
done
|
||||
[[ "$busy" == "0" ]] && return 0
|
||||
sleep 2
|
||||
done
|
||||
die "APT занят другим процессом. Остановите apt/dpkg и повторите установку."
|
||||
}
|
||||
|
||||
apt_install() {
|
||||
wait_for_apt
|
||||
apt-get install -y -qq -o Dpkg::Options::="--force-confdef" -o Dpkg::Options::="--force-confold" "$@"
|
||||
}
|
||||
|
||||
step "Системные пакеты"
|
||||
wait_for_apt
|
||||
apt-get update -qq
|
||||
apt_install \
|
||||
ca-certificates curl wget git nano htop unzip gnupg lsb-release \
|
||||
build-essential libssl-dev libffi-dev libpq-dev \
|
||||
python3 python3-venv python3-dev python3-pip \
|
||||
postgresql postgresql-client \
|
||||
redis-server nginx \
|
||||
certbot python3-certbot-nginx \
|
||||
ufw fail2ban
|
||||
PYTHON_BIN="$(command -v python3)"
|
||||
log "Системные пакеты установлены без apt upgrade"
|
||||
|
||||
if ! command -v node >/dev/null 2>&1 || [[ "$(node -v 2>/dev/null | cut -d. -f1)" != "v20" ]]; then
|
||||
info "Устанавливаем Node.js 20 LTS..."
|
||||
curl -fsSL https://deb.nodesource.com/setup_20.x | bash -
|
||||
apt_install nodejs
|
||||
fi
|
||||
log "Node.js $(node -v) готов"
|
||||
|
||||
step "Системный пользователь"
|
||||
id "$APP_USER" >/dev/null 2>&1 || useradd -m -s /bin/bash "$APP_USER"
|
||||
log "Пользователь ${APP_USER} готов"
|
||||
|
||||
step "PostgreSQL"
|
||||
systemctl enable --now postgresql
|
||||
su - postgres -c "psql -tc \"SELECT 1 FROM pg_database WHERE datname='${DB_NAME}'\"" | grep -q 1 || su - postgres -c "createdb ${DB_NAME}"
|
||||
su - postgres -c "psql -tc \"SELECT 1 FROM pg_roles WHERE rolname='${DB_USER}'\"" | grep -q 1 || su - postgres -c "psql -c \"CREATE USER ${DB_USER} WITH PASSWORD '${DB_PASS}'\""
|
||||
su - postgres -c "psql -c \"ALTER USER ${DB_USER} WITH PASSWORD '${DB_PASS}'\""
|
||||
su - postgres -c "psql -c \"GRANT ALL PRIVILEGES ON DATABASE ${DB_NAME} TO ${DB_USER}\""
|
||||
su - postgres -c "psql -c \"ALTER DATABASE ${DB_NAME} OWNER TO ${DB_USER}\""
|
||||
log "PostgreSQL: база ${DB_NAME} готова"
|
||||
|
||||
step "Redis"
|
||||
systemctl enable --now redis-server
|
||||
redis-cli ping | grep -q PONG && log "Redis работает"
|
||||
|
||||
step "Структура директорий"
|
||||
mkdir -p "${APP_DIR}"/{backend,frontend,logs,uploads,backups}
|
||||
chown -R "$APP_USER:$APP_USER" "$APP_DIR"
|
||||
log "Директории созданы"
|
||||
|
||||
step "Конфигурация (.env)"
|
||||
cat > "${APP_DIR}/.env" << ENV
|
||||
APP_DIR=${APP_DIR}
|
||||
DEBUG=false
|
||||
SECRET_KEY=${SECRET_KEY}
|
||||
|
||||
DATABASE_URL=postgresql+asyncpg://${DB_USER}:${DB_PASS}@127.0.0.1:5432/${DB_NAME}
|
||||
DB_POOL_SIZE=20
|
||||
|
||||
REDIS_URL=redis://127.0.0.1:6379/0
|
||||
|
||||
PLATFORM_BOT_TOKEN=${PLATFORM_BOT_TOKEN}
|
||||
PLATFORM_ADMIN_IDS=${PLATFORM_ADMIN_IDS}
|
||||
|
||||
API_HOST=127.0.0.1
|
||||
API_PORT=${API_PORT}
|
||||
DOMAIN=${DOMAIN}
|
||||
ALLOWED_ORIGINS=http://localhost:3000${DOMAIN:+,https://$DOMAIN,http://$DOMAIN}
|
||||
|
||||
UPLOAD_DIR=${APP_DIR}/uploads
|
||||
MAX_UPLOAD_MB=10
|
||||
|
||||
COMMISSION_TRIAL_WEEK=${COMM_TRIAL_WEEK}
|
||||
COMMISSION_TRIAL=${COMM_TRIAL}
|
||||
COMMISSION_BASIC=${COMM_BASIC}
|
||||
COMMISSION_PRO=${COMM_PRO}
|
||||
COMMISSION_ENTERPRISE=${COMM_ENTERPRISE}
|
||||
COMMISSION_POSTPAID_DEFAULT=${COMM_POSTPAID_DEFAULT}
|
||||
POSTPAID_DEFAULT_DUE_DAY=${POSTPAID_DUE_DAY}
|
||||
ENV
|
||||
chmod 600 "${APP_DIR}/.env"
|
||||
log ".env создан"
|
||||
|
||||
step "Копирование backend"
|
||||
rsync -a --delete "${SCRIPT_DIR}/backend/" "${APP_DIR}/backend/"
|
||||
PY_COUNT=$(find "${APP_DIR}/backend" -name "*.py" | wc -l)
|
||||
log "Backend скопирован (${PY_COUNT} Python-файлов)"
|
||||
|
||||
step "Python виртуальное окружение"
|
||||
rm -rf "$VENV"
|
||||
"$PYTHON_BIN" -m venv "$VENV"
|
||||
# shellcheck disable=SC1091
|
||||
source "${VENV}/bin/activate"
|
||||
pip install -q --upgrade pip wheel setuptools
|
||||
pip install -q -r "${APP_DIR}/backend/requirements.txt"
|
||||
deactivate
|
||||
log "Python venv готов: $(${VENV}/bin/python --version)"
|
||||
|
||||
step "Frontend (React + Vite)"
|
||||
rsync -a --delete "${SCRIPT_DIR}/frontend/" "${FRONT_DIR}/"
|
||||
cat > "${FRONT_DIR}/.env.production" << FENV
|
||||
VITE_API_URL=/api
|
||||
FENV
|
||||
npm install --prefix "$FRONT_DIR" --silent --no-fund --no-audit
|
||||
npm run build --prefix "$FRONT_DIR" --silent
|
||||
log "Frontend собран"
|
||||
|
||||
step "Nginx"
|
||||
mkdir -p /var/www/certbot
|
||||
NGINX_CONF="/etc/nginx/sites-available/botfactory"
|
||||
SERVER_NAMES="_"
|
||||
[[ -n "$DOMAIN" ]] && SERVER_NAMES="$DOMAIN"
|
||||
[[ -n "$DOMAIN" && "$WITH_WWW" == "yes" ]] && SERVER_NAMES="$SERVER_NAMES www.$DOMAIN"
|
||||
|
||||
cat > "$NGINX_CONF" << NGINX
|
||||
server {
|
||||
listen 80;
|
||||
server_name ${SERVER_NAMES};
|
||||
|
||||
client_max_body_size 20M;
|
||||
gzip on;
|
||||
gzip_types text/plain text/css application/json application/javascript image/svg+xml;
|
||||
|
||||
location /.well-known/acme-challenge/ {
|
||||
root /var/www/certbot;
|
||||
}
|
||||
|
||||
location / {
|
||||
root ${FRONT_DIR}/dist;
|
||||
try_files \$uri \$uri/ /index.html;
|
||||
add_header Cache-Control "no-cache";
|
||||
}
|
||||
|
||||
location ~* \.(js|css|png|jpg|jpeg|gif|ico|svg|woff2?)$ {
|
||||
root ${FRONT_DIR}/dist;
|
||||
expires 30d;
|
||||
add_header Cache-Control "public, immutable";
|
||||
}
|
||||
|
||||
location /api/ {
|
||||
proxy_pass http://127.0.0.1:${API_PORT};
|
||||
proxy_http_version 1.1;
|
||||
proxy_set_header Host \$host;
|
||||
proxy_set_header X-Real-IP \$remote_addr;
|
||||
proxy_set_header X-Forwarded-For \$proxy_add_x_forwarded_for;
|
||||
proxy_set_header X-Forwarded-Proto \$scheme;
|
||||
proxy_read_timeout 120s;
|
||||
}
|
||||
|
||||
location /uploads/ {
|
||||
alias ${APP_DIR}/uploads/;
|
||||
expires 7d;
|
||||
add_header Cache-Control "public";
|
||||
}
|
||||
}
|
||||
NGINX
|
||||
|
||||
rm -f /etc/nginx/sites-enabled/default
|
||||
ln -sf "$NGINX_CONF" /etc/nginx/sites-enabled/botfactory
|
||||
nginx -t || die "Ошибка конфигурации Nginx"
|
||||
systemctl enable nginx
|
||||
systemctl restart nginx
|
||||
log "Nginx запущен"
|
||||
|
||||
check_dns() {
|
||||
local host="$1" expected="$2"
|
||||
local ip
|
||||
ip="$(getent ahostsv4 "$host" 2>/dev/null | awk '{print $1}' | head -1 || true)"
|
||||
[[ -z "$ip" ]] && { warn "$host не резолвится. Настройте A-запись → $expected"; return 1; }
|
||||
[[ "$ip" != "$expected" ]] && { warn "$host указывает на $ip, а сервер: $expected"; return 1; }
|
||||
log "DNS OK: $host → $ip"
|
||||
return 0
|
||||
}
|
||||
|
||||
step "SSL — Let's Encrypt"
|
||||
run_certbot() {
|
||||
local d_args=(-d "$DOMAIN")
|
||||
check_dns "$DOMAIN" "$SERVER_IP" || return 1
|
||||
if [[ "$WITH_WWW" == "yes" ]]; then
|
||||
check_dns "www.$DOMAIN" "$SERVER_IP" || return 1
|
||||
d_args+=(-d "www.$DOMAIN")
|
||||
fi
|
||||
certbot --nginx "${d_args[@]}" \
|
||||
--email "$CERTBOT_EMAIL" \
|
||||
--agree-tos --no-eff-email \
|
||||
--redirect --non-interactive
|
||||
}
|
||||
|
||||
if [[ -n "$DOMAIN" && "$SETUP_SSL" == "yes" ]]; then
|
||||
if run_certbot; then
|
||||
systemctl reload nginx
|
||||
systemctl enable --now certbot.timer >/dev/null 2>&1 || true
|
||||
log "SSL сертификат получен"
|
||||
else
|
||||
warn "SSL не получен. Исправьте DNS и запустите: certbot --nginx -d ${DOMAIN} --email ${CERTBOT_EMAIL} --agree-tos --redirect"
|
||||
fi
|
||||
elif [[ -n "$DOMAIN" ]]; then
|
||||
warn "SSL пропущен. После DNS: certbot --nginx -d ${DOMAIN} --email ${CERTBOT_EMAIL} --agree-tos --redirect"
|
||||
else
|
||||
warn "SSL недоступен без домена"
|
||||
fi
|
||||
|
||||
step "Systemd сервисы"
|
||||
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
|
||||
|
||||
[Service]
|
||||
Type=exec
|
||||
User=${APP_USER}
|
||||
Group=${APP_USER}
|
||||
WorkingDirectory=${APP_DIR}/backend
|
||||
Environment=PYTHONPATH=${APP_DIR}/backend
|
||||
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
|
||||
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=botfactory-api.service
|
||||
Wants=botfactory-api.service
|
||||
|
||||
[Service]
|
||||
Type=exec
|
||||
User=${APP_USER}
|
||||
Group=${APP_USER}
|
||||
WorkingDirectory=${APP_DIR}/backend
|
||||
Environment=PYTHONPATH=${APP_DIR}/backend
|
||||
EnvironmentFile=${APP_DIR}/.env
|
||||
ExecStart=${VENV}/bin/python shop_bots_runner.py
|
||||
Restart=always
|
||||
RestartSec=10
|
||||
StartLimitIntervalSec=60
|
||||
StartLimitBurst=5
|
||||
StandardOutput=append:${APP_DIR}/logs/bots.log
|
||||
StandardError=append:${APP_DIR}/logs/bots-error.log
|
||||
|
||||
[Install]
|
||||
WantedBy=multi-user.target
|
||||
SERVICE
|
||||
|
||||
systemctl daemon-reload
|
||||
systemctl enable botfactory-api botfactory-bots
|
||||
systemctl restart botfactory-api && log "API запущен" || warn "API не стартовал — см. journalctl -u botfactory-api"
|
||||
systemctl restart botfactory-bots && log "Боты запущены" || warn "Боты не стартовали — см. journalctl -u botfactory-bots"
|
||||
|
||||
step "Firewall (UFW)"
|
||||
ufw allow 22/tcp comment "SSH" >/dev/null || true
|
||||
ufw allow 80/tcp comment "HTTP" >/dev/null || true
|
||||
ufw allow 443/tcp comment "HTTPS" >/dev/null || true
|
||||
ufw --force enable >/dev/null || warn "UFW не удалось включить автоматически"
|
||||
log "UFW: правила SSH + HTTP + HTTPS добавлены без reset"
|
||||
|
||||
step "Fail2ban"
|
||||
cat > /etc/fail2ban/jail.local << F2B
|
||||
[DEFAULT]
|
||||
bantime = 1h
|
||||
findtime = 10m
|
||||
maxretry = 5
|
||||
ignoreip = 127.0.0.1/8
|
||||
|
||||
[sshd]
|
||||
enabled = true
|
||||
|
||||
[nginx-http-auth]
|
||||
enabled = true
|
||||
F2B
|
||||
systemctl enable --now fail2ban || warn "Fail2ban не стартовал"
|
||||
log "Fail2ban настроен"
|
||||
|
||||
step "Права доступа"
|
||||
chown -R "$APP_USER:$APP_USER" "$APP_DIR"
|
||||
chmod 750 "$APP_DIR"
|
||||
chmod 600 "$APP_DIR/.env"
|
||||
chmod -R 755 "${APP_DIR}/backend" "${APP_DIR}/frontend"
|
||||
chmod -R 770 "${APP_DIR}/logs" "${APP_DIR}/uploads" "${APP_DIR}/backups"
|
||||
log "Права выставлены"
|
||||
|
||||
step "Logrotate"
|
||||
cat > /etc/logrotate.d/botfactory << LR
|
||||
${APP_DIR}/logs/*.log {
|
||||
daily
|
||||
rotate 30
|
||||
compress
|
||||
delaycompress
|
||||
missingok
|
||||
notifempty
|
||||
create 0640 ${APP_USER} ${APP_USER}
|
||||
}
|
||||
LR
|
||||
log "Logrotate настроен"
|
||||
|
||||
step "Автоматическое резервное копирование"
|
||||
cat > "${APP_DIR}/backups/backup.sh" << BACKUP
|
||||
#!/usr/bin/env bash
|
||||
set -euo pipefail
|
||||
DIR="${APP_DIR}/backups"
|
||||
DATE=\$(date +%Y%m%d_%H%M%S)
|
||||
PGPASSWORD="${DB_PASS}" pg_dump -U ${DB_USER} -h 127.0.0.1 ${DB_NAME} | gzip > "\${DIR}/db_\${DATE}.sql.gz"
|
||||
tar -czf "\${DIR}/uploads_\${DATE}.tar.gz" -C ${APP_DIR} uploads/ 2>/dev/null || true
|
||||
find "\${DIR}" -name "*.gz" -mtime +30 -delete 2>/dev/null || true
|
||||
echo "[\$(date '+%Y-%m-%d %H:%M:%S')] Backup OK: \${DATE}"
|
||||
BACKUP
|
||||
chmod +x "${APP_DIR}/backups/backup.sh"
|
||||
chown "$APP_USER:$APP_USER" "${APP_DIR}/backups/backup.sh"
|
||||
( crontab -l 2>/dev/null | grep -v "botfactory/backups"; echo "0 2 * * * ${APP_DIR}/backups/backup.sh >> ${APP_DIR}/logs/backup.log 2>&1" ) | crontab -
|
||||
log "Бекап: ежедневно в 02:00"
|
||||
|
||||
cat > "${APP_DIR}/install-info.txt" << INFO
|
||||
BotFactory — параметры установки
|
||||
Дата: $(date '+%Y-%m-%d %H:%M:%S')
|
||||
Директория: ${APP_DIR}
|
||||
Server IP: ${SERVER_IP}
|
||||
Домен: ${DOMAIN:-нет}
|
||||
SSL requested: ${SETUP_SSL}
|
||||
DB User: ${DB_USER}
|
||||
DB Name: ${DB_NAME}
|
||||
DB Pass: ${DB_PASS}
|
||||
Secret Key: ${SECRET_KEY}
|
||||
API Port: ${API_PORT}
|
||||
Комиссии: trial_week=${COMM_TRIAL_WEEK}% trial=${COMM_TRIAL}% basic=${COMM_BASIC}% pro=${COMM_PRO}% enterprise=${COMM_ENTERPRISE}% postpaid_default=${COMM_POSTPAID_DEFAULT}%
|
||||
Постоплата default due day: ${POSTPAID_DUE_DAY}
|
||||
INFO
|
||||
chmod 600 "${APP_DIR}/install-info.txt"
|
||||
chown "$APP_USER:$APP_USER" "${APP_DIR}/install-info.txt"
|
||||
|
||||
ELAPSED=$(( $(date +%s) - START_TIME ))
|
||||
clear
|
||||
echo -e "${B}${G}"
|
||||
echo " ╔═══════════════════════════════════════════╗"
|
||||
echo " ║ BotFactory успешно установлен! ✓ ║"
|
||||
printf " ║ Время установки: %-4s сек. ║\n" "${ELAPSED}"
|
||||
echo " ╚═══════════════════════════════════════════╝"
|
||||
echo -e "${NC}"
|
||||
|
||||
echo -e "${B}▸ Доступ:${NC}"
|
||||
if [[ -n "$DOMAIN" && -d "/etc/letsencrypt/live/${DOMAIN}" ]]; then
|
||||
echo -e " ${G}●${NC} Панель: ${C}https://${DOMAIN}${NC}"
|
||||
echo -e " ${G}●${NC} API: ${C}https://${DOMAIN}/api${NC}"
|
||||
else
|
||||
echo -e " ${G}●${NC} Панель: ${C}http://${DOMAIN:-$SERVER_IP}${NC}"
|
||||
echo -e " ${G}●${NC} API: ${C}http://${DOMAIN:-$SERVER_IP}/api${NC}"
|
||||
fi
|
||||
|
||||
echo -e "\n${B}▸ Управление:${NC}"
|
||||
echo -e " ${DIM}systemctl status botfactory-api${NC}"
|
||||
echo -e " ${DIM}systemctl status botfactory-bots${NC}"
|
||||
echo -e " ${DIM}journalctl -u botfactory-api -f${NC}"
|
||||
echo -e " ${DIM}journalctl -u botfactory-bots -f${NC}"
|
||||
echo -e "\n${G}${B}⚠ Сохраните ${APP_DIR}/install-info.txt в надёжное место.${NC}\n"
|
||||
Ссылка в новой задаче
Block a user