AACsearch
SDKРецепты

Изоляция арендаторов маркетплейса

Мульти-вендорный маркетплейс, где каждая витрина видит только свои SKU. Единый индекс, токены с ограничением по вендору, нулевая утечка данных между арендаторами.

Платформа маркетплейса обслуживает десятки или тысячи независимых витрин. Вендор A никогда не должен видеть SKU вендора B через ваш поисковый API — даже если вендор A является искушённым атакующим, знающим внутреннее устройство AACsearch. Этот рецепт показывает промышленный паттерн: один общий индекс, токены с ограничением по вендору, идентификатор арендатора enforced на серверной стороне.

Почему один индекс, а не по одному на вендора

ПодходПлюсыМинусы
Один индекс на вендораЖёсткая изоляцияРазрастание индексов (тысячи); потеря квоты на индекс; накладные расходы на администрирование
Один общий индекс, токены с ограничением по вендоруЕдиный индекс, простые миграции схемы, низкая стоимостьНеобходимо обеспечить фильтр арендатора на каждом запросе — но токены с ограничением делают это автоматически

Подход с общим индексом масштабируется линейно по документам, а не по вендорам. Модель токенов с ограниченной областью действия AACsearch существует именно для этого случая.

Схема

Каждый документ содержит идентификатор владеющего им вендора:

type MarketplaceProduct = {
  id: string;
  external_id: string;
  vendor_id: string; // критично — управляет фильтром с ограничением
  title: string;
  description: string;
  brand: string;
  categories: string[];
  price: number;
  in_stock: boolean;
  created_at: number;
};

Пометьте vendor_id как фасетный, чтобы фильтр мог его использовать:

await admin.createIndex({
  slug: "marketplace",
  fields: [
    { name: "title", type: "string" },
    { name: "vendor_id", type: "string", facet: true },
    { name: "brand", type: "string", facet: true },
    { name: "categories", type: "string[]", facet: true },
    { name: "price", type: "float", facet: true },
    { name: "in_stock", type: "bool", facet: true },
  ],
  defaultSortingField: "created_at",
});

Создание токена с ограничением по вендору

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

// app/api/vendor/search-token/route.ts
import { NextResponse } from "next/server";
import { getVendorSession } from "@/lib/auth";
import { orpc } from "@/lib/orpc";

export async function POST() {
  const session = await getVendorSession();
  if (!session)
    return NextResponse.json({ error: "unauthorized" }, { status: 401 });

  const token = await orpc.search.createScopedToken.call({
    organizationId: process.env.AACSEARCH_ORG_ID!, // организация платформы маркетплейса
    indexSlug: "marketplace",
    scopedFilter: `vendor_id:=${JSON.stringify(session.vendorId)}`,
    expiresInSeconds: 4 * 60 * 60,
    name: `vendor-${session.vendorId}`,
  });

  return NextResponse.json({ token: token.token });
}

Витрина вендора использует возвращённый токен. Они не могут видеть товары других — фильтр с ограничением не может быть удалён.

Публичный поиск по маркетплейсу (кросс-вендорный)

Публичная страница поиска маркетплейса (где покупатели просматривают всех вендоров) использует обычный ключ ss_search_* без фильтра арендатора:

// Публичный клиент — без ограничений
const publicClient = new SearchClient({
  baseUrl: process.env.NEXT_PUBLIC_AACSEARCH_BASE_URL!,
  apiKey: process.env.NEXT_PUBLIC_AACSEARCH_PUBLIC_KEY!, // ss_search_*
  indexSlug: "marketplace",
});

Для публичного поиска откройте vendor_id как фасет, чтобы покупатели могли фильтровать по магазину:

const result = await publicClient.search({
  q: "running shoes",
  facetBy: "vendor_id,brand,price",
  perPage: 24,
});

Онбординг вендора: получение токена коннектора

Каждому вендору нужен токен коннектора для отправки собственного каталога. Создайте по одному на вендора:

// Выполняется один раз при регистрации вендора
const connectorToken = await orpc.search.createConnectorToken.call({
  organizationId: process.env.AACSEARCH_ORG_ID!,
  indexSlug: "marketplace",
  name: `vendor-${vendorId}-connector`,
});

// Сохраните в таблице вендоров; вендор использует его из своей CMS / коннектора
await db.vendor.update({
  where: { id: vendorId },
  data: { aacsearchConnectorToken: connectorToken.plaintext },
});

Вендор отправляет только свои товары

CMS-модуль вендора отправляет документы, помеченные его vendor_id. Платформа должна обеспечить это на серверной стороне, потому что один лишь токен коннектора этого не делает (он может записать любой vendor_id).

Два паттерна обеспечения:

Паттерн A: проксирование через вашу платформу (рекомендуется)

CMS вендора отправляет запросы к вашему API, а не напрямую в AACsearch. Ваш API проставляет vendor_id из сессии:

// /api/vendor/sync/full
export async function POST(req: Request) {
  const session = await getVendorSession();
  if (!session) return new Response("unauthorized", { status: 401 });

  const { products } = await req.json();
  const stamped = products.map((p) => ({ ...p, vendor_id: session.vendorId }));

  await fetch(
    `https://app.aacsearch.com/api/projects/${process.env.AACSEARCH_ORG_ID}/sync/full`,
    {
      method: "POST",
      headers: {
        Authorization: `Bearer ${process.env.AACSEARCH_CONNECTOR_TOKEN}`, // токен платформы
        "Content-Type": "application/json",
      },
      body: JSON.stringify({ products: stamped }),
    },
  );

  return Response.json({ ok: true });
}

Вендоры не могут подделать vendor_id, потому что ваш API переопределяет его.

Паттерн B: токен коннектора на каждого вендора (проще, слабее)

Если вы даёте каждому вендору собственный токен коннектора, и они отправляют напрямую в AACsearch, вы должны доверять им не подделывать vendor_id. Добавьте задачу рантайм-аудита:

// Ежедневный cron — пометить любой документ, где vendor_id не совпадает с токеном, использованным для отправки
const recent = await admin.listDocuments("marketplace", { since: oneDayAgo });
for (const doc of recent.documents) {
	const expectedVendor = await getVendorByConnectorToken(doc._writtenWith);
	if (doc.vendor_id !== expectedVendor) {
		await alert("vendor_id spoofed", { docId: doc.id, ... });
	}
}

Паттерн A предпочтителен. Паттерн B — запасной вариант для унаследованных коннекторов.

Аналитика по вендорам

Нагрузка events/track принимает произвольные свойства — включите vendor_id, чтобы можно было разделять аналитику по вендорам:

trackEvent("result_click", {
  queryId: result.queryId,
  documentId: hit.document.id,
  vendor_id: hit.document.vendor_id,
});

В Поиск → Аналитика группируйте по vendor_id для получения CTR, конверсий и топ-запросов по вендорам.

Отключение вендора

Когда вендор покидает маркетплейс:

  1. Отзовите его токен коннектора: orpc.search.revokeConnectorToken.call({ tokenId }).
  2. Массово удалите его документы: admin.deleteByQuery("marketplace", "vendor_id:=vendor_xyz").
  3. Удалите все закешированные токены с ограничением (клиенты перезапрашивают и получают 401 на старый).

Без шага 2 товары вендора остаются доступными для поиска в публичной витрине неограниченно долго. Всегда удаляйте данные, а не только учётные данные.

Связанные страницы

On this page