Legan Studio
Все статьи
~ 7 мин чтения

Бот для маркетплейса в MAX: каталог, корзина, продавцы, заказы

Как сделать бот-маркетплейс в MAX: каталог товаров с фильтрами, корзина, оплата, мультипродавец, push о статусах заказа и интеграция с 1С/CRM.

  • MAX
  • ритейл
  • маркетплейс

Маркетплейс в мессенджере — это не просто бот с каталогом. Это многосторонняя площадка: покупатели листают товары, продавцы заливают остатки, площадка модерирует, обрабатывает платежи и закрывает сделки. В MAX бот-маркетплейс хорошо ложится на нативный UX: inline-карточки товаров, корзина в editMessage, статусы заказа push'ами в реальном времени, мини-приложение для продавцов. В этой статье — архитектура такого бота, модель данных, поиск с фильтрами, мультипродавцовая корзина, эскроу-платежи, рейтинги и что чаще всего ломается на старте.

Сценарий типичного заказа

  1. Пользователь жмёт «🛍 Каталог».
  2. Видит категории → подкатегории → список товаров с фильтрами (цена, рейтинг, продавец).
  3. Открывает карточку товара: фото-галерея, описание, отзывы, кнопка «В корзину».
  4. Корзина — одно сообщение, обновляется через editMessage. Видны товары, итог, кнопки «+/−», «Оформить».
  5. Чекаут: адрес доставки → способ оплаты → подтверждение → оплата (платёжная система).
  6. Заказ уходит продавцам (если корзина мультипродавцовая — несколько подзаказов).
  7. Push-уведомления: «Принят», «Собран», «В пути», «Доставлен».
  8. После доставки — просьба оценить товар и продавца.

Модель данных

CREATE TABLE sellers (
    id BIGSERIAL PRIMARY KEY,
    title TEXT NOT NULL,
    inn TEXT,
    contact_user_id BIGINT,
    rating NUMERIC(3,2) DEFAULT 0,
    is_verified BOOLEAN DEFAULT false
);

CREATE TABLE categories (
    id BIGSERIAL PRIMARY KEY,
    parent_id BIGINT REFERENCES categories(id),
    title TEXT NOT NULL
);

CREATE TABLE products (
    id BIGSERIAL PRIMARY KEY,
    seller_id BIGINT NOT NULL REFERENCES sellers(id),
    category_id BIGINT NOT NULL REFERENCES categories(id),
    title TEXT NOT NULL,
    description TEXT,
    price NUMERIC(10,2) NOT NULL,
    currency CHAR(3) DEFAULT 'RUB',
    stock INT DEFAULT 0,
    photos JSONB,                       -- ["url1", "url2"]
    attributes JSONB,                   -- {"color":"black","size":"M"}
    is_published BOOLEAN DEFAULT false,
    rating NUMERIC(3,2) DEFAULT 0,
    search_vector tsvector
);

CREATE INDEX ix_products_search ON products USING GIN(search_vector);
CREATE INDEX ix_products_category ON products(category_id) WHERE is_published;

CREATE TABLE carts (
    user_id BIGINT PRIMARY KEY,
    items JSONB DEFAULT '[]',           -- [{"product_id":1,"qty":2}]
    updated_at TIMESTAMPTZ DEFAULT now()
);

CREATE TABLE orders (
    id BIGSERIAL PRIMARY KEY,
    user_id BIGINT NOT NULL,
    seller_id BIGINT NOT NULL REFERENCES sellers(id),
    total NUMERIC(10,2) NOT NULL,
    status TEXT NOT NULL,               -- pending|paid|shipped|delivered|cancelled
    items JSONB,
    delivery JSONB,
    payment_id TEXT,
    created_at TIMESTAMPTZ DEFAULT now()
);

tsvector для поиска: триггер обновляет search_vector из title || description || category с весами и нормализацией под русский (russian configuration).

Поиск и фильтры

async def search_products(query: str, category_id: int | None, price_max: int | None, page: int = 0):
    sql = """
    SELECT id, title, price, photos->0 AS photo, rating
    FROM products
    WHERE is_published
      AND ($1 = '' OR search_vector @@ plainto_tsquery('russian', $1))
      AND ($2::bigint IS NULL OR category_id = $2)
      AND ($3::numeric IS NULL OR price <= $3)
    ORDER BY rating DESC, id DESC
    LIMIT 5 OFFSET $4
    """
    return await db.fetch_all(sql, query, category_id, price_max, page * 5)

В UX: фильтры через inline-меню «📂 Категория», «💰 Цена до», «⭐ Только 4+», все callback_data вида flt:cat:5, состояние фильтра — в Redis сессии пользователя.

Карточка товара

async def show_product(chat_id: int, product_id: int):
    p = await db.fetch_one("SELECT * FROM products WHERE id = $1", product_id)
    text = (
        f"*{p.title}*\n"
        f"⭐ {p.rating} | {p.seller_title}\n\n"
        f"{p.description}\n\n"
        f"💰 *{p.price} ₽*"
    )
    kb = InlineKeyboardMarkup([
        [InlineKeyboardButton("🛒 В корзину", callback_data=f"cart:add:{p.id}")],
        [InlineKeyboardButton("📷 Все фото", callback_data=f"prod:gallery:{p.id}"),
         InlineKeyboardButton("⭐ Отзывы", callback_data=f"prod:reviews:{p.id}")],
        [InlineKeyboardButton("‹ Назад", callback_data="menu:catalog")],
    ])
    await bot.send_photo(chat_id, photo=p.photos[0], caption=text, reply_markup=kb, parse_mode="Markdown")

Корзина: editMessage вместо новых сообщений

async def render_cart(chat_id: int, user_id: int, msg_id: int | None = None):
    cart = await load_cart(user_id)
    if not cart["items"]:
        text = "Корзина пуста"
        kb = InlineKeyboardMarkup([[InlineKeyboardButton("К каталогу", callback_data="menu:catalog")]])
    else:
        prods = await fetch_products_by_ids([i["product_id"] for i in cart["items"]])
        lines, total = [], 0
        for it in cart["items"]:
            p = prods[it["product_id"]]
            sub = p.price * it["qty"]
            total += sub
            lines.append(f"• {p.title} ×{it['qty']} = {sub} ₽")
        text = "\n".join(lines) + f"\n\n*Итого: {total} ₽*"
        rows = [
            [InlineKeyboardButton("−", callback_data=f"cart:dec:{it['product_id']}"),
             InlineKeyboardButton(f"{it['qty']}", callback_data="noop"),
             InlineKeyboardButton("+", callback_data=f"cart:inc:{it['product_id']}")]
            for it in cart["items"]
        ]
        rows.append([InlineKeyboardButton(f"Оформить за {total} ₽", callback_data="cart:checkout")])
        rows.append([InlineKeyboardButton("Очистить", callback_data="cart:clear")])
        kb = InlineKeyboardMarkup(rows)
    if msg_id:
        await bot.edit_message_text(chat_id=chat_id, message_id=msg_id, text=text, reply_markup=kb, parse_mode="Markdown")
    else:
        await bot.send_message(chat_id, text, reply_markup=kb, parse_mode="Markdown")

Мультипродавцовый чекаут

Если в корзине товары от 2+ продавцов — создаются разные orders:

async def checkout(user_id: int, delivery: dict):
    cart = await load_cart(user_id)
    items_by_seller = group_by_seller(cart["items"])
    orders = []
    async with db.transaction():
        for seller_id, items in items_by_seller.items():
            order = await db.fetch_one("""
                INSERT INTO orders (user_id, seller_id, total, status, items, delivery)
                VALUES ($1, $2, $3, 'pending', $4, $5)
                RETURNING *
            """, user_id, seller_id, sum_total(items), items, delivery)
            orders.append(order)
        await clear_cart(user_id)
    payment_url = await create_payment(orders)   # объединяем в один платёж
    return payment_url

После оплаты webhook платёжной системы переводит все orders в paid, рассылает push продавцам и пользователю.

Эскроу: деньги площадки до подтверждения доставки

Деньги зачисляются на счёт площадки, держатся 14 дней или до подтверждения доставки. Затем выводятся продавцу за вычетом комиссии. Это защищает покупателя и снимает большую часть споров.

Схема платежей: ЮKassa / Тинькофф Эквайринг / СБП с использованием механизма «маркетплейс / платёжный посредник». В реквизитах — площадка как принципал, продавец — как поставщик.

Push о статусах

STATUS_TEMPLATES = {
    "paid":      "✅ Оплата получена. Заказ #{id} передан продавцу.",
    "shipped":   "📦 Заказ #{id} отправлен. Трек: {track}",
    "delivered": "🎉 Заказ #{id} доставлен. Оцените покупку!",
    "cancelled": "❌ Заказ #{id} отменён. Возврат в течение 5 дней.",
}

async def notify_status(order: Order, new_status: str, **extra):
    text = STATUS_TEMPLATES[new_status].format(id=order.id, **extra)
    kb = None
    if new_status == "delivered":
        kb = InlineKeyboardMarkup([[InlineKeyboardButton("⭐ Оценить", callback_data=f"order:rate:{order.id}")]])
    await bot.send_message(order.user_id, text, reply_markup=kb)

Уведомления продавцу — в чат продавца (отдельный групповой чат продавца с ботом или web app кабинета).

Кабинет продавца

Аналогично кабинету франчайзи (см. статью про бот для франшизы): web-app или mini app в MAX, авторизация через одноразовый токен.

Возможности:

  • управление товарами (add/edit/publish/unpublish, фото, остатки);
  • просмотр и обработка заказов;
  • статусы (принят, собран, отправлен с треком);
  • ответы на вопросы и отзывы;
  • баланс и заявки на вывод;
  • метрики продаж.

Отзывы и модерация

После статуса delivered через 1–24 часа — cron job присылает запрос «Оцените товар (1–5 звёзд) и продавца (1–5)». Текст отзыва — опциональный. Модерация:

  • автоматический фильтр на токсичность (Yandex Toxicity Classifier, OpenAI Moderation);
  • ручная модерация при низкой оценке (1–2 звезды) или жалобе продавца;
  • усреднение в products.rating и sellers.rating после публикации.

Антифрод

Маркетплейс — мишень мошенников. Базовые меры:

  • лимит N заказов в час с одного user_id и одного IP;
  • проверка карты привязки к пользователю (3DS);
  • скоринг новых покупателей: возраст аккаунта в MAX, наличие фото, количество диалогов;
  • ML-модель аномалий по сумме, частоте, географии;
  • ручное ревью первых 3 заказов от новых продавцов.

Интеграции

  • — через REST: остатки, цены, заказы синхронизируются раз в 5–15 минут.
  • CDEK / Boxberry — расчёт доставки и треки.
  • ЮKassa — приём платежей и эскроу.
  • amoCRM/Bitrix24 — сделки по покупателям и LTV.

Common pitfalls

  1. Один общий cart и orders без локов — race condition при двойном клике «оформить».
  2. Цены в корзине не фиксируются — продавец поднял цену, пользователь увидел один итог, заплатил другой.
  3. Нет stock check на оформлении — продают то, чего нет на складе.
  4. photos как кучка URL без CDN — карточка открывается 5 секунд.
  5. tsvector без russian configuration — поиск «носки» не находит «носок».
  6. Платежи без webhook idempotency — двойное списание.

Итого

Бот-маркетплейс в MAX строится на стеке: Postgres с tsvector + GIN для поиска, sellers/products/categories/carts/orders с JSONB для гибких полей, корзина в одном сообщении через editMessage, мультипродавцовый чекаут с разделением на несколько orders, эскроу через ЮKassa-маркетплейс, push о статусах, кабинет продавца как mini app или web с авторизацией через MAX, отзывы с модерацией, базовый антифрод. MVP — 6–10 недель и 1.5–3 млн ₽; полнофункциональная версия с эскроу, кабинетом, антифродом и интеграцией 1С/CDEK — 14–20 недель и 4–7 млн ₽. Стоимость инфраструктуры — 5–25 тыс. ₽/мес в зависимости от количества SKU и заказов.

Частые вопросы

Как организовать поиск товаров в боте MAX?

Используйте PostgreSQL tsvector с конфигурацией russian + GIN-индекс. Триггер обновляет search_vector из title, description, category с весами (title — A, description — B, category — C). Запросы через plainto_tsquery находят варианты словоформ: «носки» = «носок» = «носки чёрные». Фильтры по категории, цене, рейтингу — обычные WHERE с BTREE-индексами. Пагинация через keyset (WHERE id > last_id) на крупных каталогах вместо OFFSET.

Как сделать корзину в боте, чтобы не плодить сообщения?

Корзина — одно сообщение, которое обновляется через editMessageText при каждом изменении. Состав корзины храните в Redis или Postgres (JSONB). Кнопки «+/−» по каждому товару, кнопка с итогом «Оформить за 1290 ₽», кнопка «Очистить». Используйте debounce 0.3 секунды и идемпотентность через SET NX EX, чтобы двойной клик не создавал двойной заказ. Старое сообщение корзины удаляйте через deleteMessage при закрытии или после оформления.

Как сделать мультипродавцовый чекаут?

Группируете корзину по seller_id, создаёте отдельный order на каждого продавца, объединяете суммы в один платёж в ЮKassa с указанием маркетплейса и поставщиков (механизм «маркетплейс»). После оплаты webhook переводит все orders в paid, разрезает деньги по продавцам с удержанием комиссии площадки. Эскроу: деньги держатся на счёте площадки до подтверждения доставки или 14 дней, затем перечисляются продавцам — это защищает покупателя.

Как защитить цену в корзине от изменений?

Фиксируйте цену в момент добавления товара в корзину: сохраняйте product_id, qty и price_locked. На чекауте сверяете с текущей ценой в products — если совпадает, продолжаете; если нет, показываете «Цена изменилась с 990 на 1290 ₽, подтвердить?» и пересоздаёте сообщение. Дополнительно — TTL корзины 24 часа: после этого товары считаются «неактуальными», бот предлагает обновить. Это защищает и покупателя от подмены цен, и продавца от продажи по устаревшей.

Где взять фото товаров и как ускорить их выдачу?

Фото грузятся продавцом через кабинет (или mini app в MAX) и сохраняются в Object Storage (Yandex / Selectel S3). Перед сохранением — оптимизация: ресайз до 1200×1200 для full и 400×400 для thumb, конвертация в WebP, прогрессивный JPEG как fallback. Раздача через CDN (CloudFront, Yandex CDN). Карточка товара показывает thumb в caption, full — по кнопке «Все фото». Без CDN на 50K SKU и 1000 заказах в день карточки будут грузиться 3–5 секунд, конверсия упадёт.

Как организовать антифрод на маркетплейсе?

Базовые правила: лимит 5 заказов в час с user_id и 10 с IP, проверка 3DS на новой карте, скоринг новых покупателей (возраст MAX-аккаунта, наличие аватара, активность в платформе). Для крупных сумм — ML-модель аномалий по сумме/частоте/географии заказа. Первые 3 заказа от нового продавца — ручное ревью. Чёрный список user_id и карт по чарджбекам. Подозрительные транзакции замораживайте в эскроу до подтверждения доставки.

Какая стоимость и срок разработки?

MVP с каталогом, корзиной, оплатой, статусами заказа, базовым кабинетом продавца — 6–10 недель и 1.5–3 млн ₽. Полная версия с эскроу, мультипродавцовым чекаутом, отзывами и модерацией, антифродом, интеграцией 1С и CDEK, mini app — 14–20 недель и 4–7 млн ₽. Поддержка инфры — 5–25 тыс. ₽/мес в зависимости от количества SKU и заказов в день. ROI достигается обычно за 4–8 месяцев за счёт снижения комиссии vs Wildberries/OZON и удержания лояльной аудитории в собственном канале MAX.