Изоляция арендаторов маркетплейса
Мульти-вендорный маркетплейс, где каждая витрина видит только свои 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, конверсий и топ-запросов по вендорам.
Отключение вендора
Когда вендор покидает маркетплейс:
- Отзовите его токен коннектора:
orpc.search.revokeConnectorToken.call({ tokenId }). - Массово удалите его документы:
admin.deleteByQuery("marketplace", "vendor_id:=vendor_xyz"). - Удалите все закешированные токены с ограничением (клиенты перезапрашивают и получают 401 на старый).
Без шага 2 товары вендора остаются доступными для поиска в публичной витрине неограниченно долго. Всегда удаляйте данные, а не только учётные данные.
Связанные страницы
- Токены поиска с ограниченной областью действия — модель безопасности
- B2B-каталог с ограниченным доступом — вариант с ограничением по клиенту
- Жизненный цикл API коннектора — токены коннектора
- API-ключи
B2B-каталог с ограниченным доступом
Ценовые уровни для каждого клиента, видимость SKU по клиентам и каталоги на основе контрактов — всё обеспечивается на серверной стороне через токены поиска с ограниченной областью действия.
Мультиязычный каталог
Один товар, несколько языков. Единый индекс с фасетом `locale`, языково-специфичные текстовые поля и токен с ограничением, привязывающий пользователя к его языку.